Extend DRAW with custom visual bricks made with CODE
Introduction
The Olympe DRAW visual development platform can be extended through the creation of custom visual components. This tutorial focuses on the creation of an hardcoded visual component that can later be used and replicated in DRAW for UI construction.
Prerequisites
It is assumed that the following tutorials have been completed already:
- How to bootstrap an Olympe project with hardcoded parts.
- How to implement a Celsius to Fahrenheit converter function
In particular, we use the tutorials
project created earlier.
Outcome
At the end of this tutorial, you will have a bar chart implementation:
How to implement an hardcoded bar chart
Overview of the steps required
The creation of an hardcoded visual component, similarly to an hardcoded functional component, involves the followings steps:
- Designate the parameters the visual component will be sensible to
- Define the visual component in the softcoded world
- Implement the rendering and UX of the component in the hardcoded world.
Figuring out the Bar Chart properties
Let's assume the following specifications for our "Bar Chart" component:
- Numerical values in the CSV (comma separated values) format are taken as input
- The chart should have a configurable title
- The bar color should be configurable
We establish thus the following list of properties (and their types) of our component:
- CSV (String)
- Title (String)
- BarColor (Color)
Similarly to the Celsius-to-Fahrenheit function, we thus need to generate at least one tag (you'll see why not necessarily 4 below) using the helper:
olympe.utils.generateUniqueTag()
barchart
module
Creating the We implement the bar chart in a new module that we name barchart
.
The involved steps to create this module is similar to the one we created for the Celsius-to-Fahrenheit function:
- We create a
barchart
directory insidemodules
- We create a
src
and aninitscripts
folders withinbarchart
- We provide a
_ns.js
file (contained insrc
) that declares thebarchart
namespace:
// barchart/src/_ns.js
/**
*
* @namespace
*/
const barchart = {};
- We create a
BarChartImpl.js
file in thesrc
directory as well (which is for now empty) - We create an init script
BarChart.js
in theinitscripts
folder, containing the following four lines:
// barchart/initscript/BarChart.js
db = /** @type {composer.script.OSaveFileBuilder} */(db);
db.setDefaultGroupOfObjects('10000000000000000GOO');
db.newRepository('0170ef75108629bf0000', 'Custom visual component repository', db.ROOT_REPO_DEFAULT).done();
db.setWorkingRepository('0170ef75108629bf0000');
- We create a
module.json
inside thebarchart
directory with the following content:
{
"name": "barchart",
"version": "0.0.1",
"sources": [
"src/_ns.js",
"src/BarChartImpl.js"
],
"datacloudinit": {
"defaultDomain": "bricks.olympe.ch",
"initScriptDirectories": [
"initscripts/"
]
}
}
We should obtain the following project structure (omitting the helloworld
and c2f
modules):
tutorial
├── modules
│ └── barchart
│ ├── module.json
│ ├── initscripts
│ │ └── BarChart.js
│ └── src
│ ├── BarChartImpl.js
│ └── _ns.js
├── package-lock.json
├── package.json
├── gulpfile.js
├── module.json
├── snapshot.sh
└── node_modules/
We should also modify the root module.json
to indicate that we include a new module. It should now look as follows:
{
"name": "tutorials",
"version": "0.0.1",
"dependencies": {
"olympe-composer.targets": "*"
},
"targets": {
"html": {
"dependencies": {
"olympe-composer": "*",
"olympe.native.html.target.default": "*",
"helloworld": "*",
"c2f": "*",
"barchart": "*"
}
}
}
}
Writing the "SC" definition of the visual component
Now we can write the softcoded definition of the visual component by considering initscripts/BarChart.js
. We start the definition of our component, this time by calling db.newModel()
. Unfortunately, a dedicated helper similar to db.newFunction()
for functions is not available yet for component. This means we will have to perform a couple of operation manually... fortunately, that will be also the opportunity to unveil a bit how data are structured in Olympe (the full blown explanation is kept for another tutorial).
Creating a model
By calling db.newModel()
, we instruct the OSaveFileBuilder
to initiate the definition of a model. Why a model ? Because this model will be instantiated each time a bar chart will be dragged onto a screen in draw. As usual, if your linting configuration works correctly, you should receive a hint from e.g. WebStorm about parameters. newModel()
takes a tag as first argument (this is a convention in init scripts), and a name as second.
Making the model an extension of another
After the call to newModel
, we need to indicate that we want this model to extend another existing model called Component
. For that, we need to locate the tag of this Component. We find it in the file modules/olympe/sc/modules/ui/datamodel/01_abstract_models.js
(in the near future, this tag will be available as a constant - db.COMPONENT
). We copy the definition of its tag value and paste it in our init script:
// barchart/initscript/BarChart.js
const Component = '01612df263119ee77622';
db.newModel('0170f4619348dfd0c935', 'Bar Chart')
.extends(Component)
through this extension, the instance of our model will (among other things) inherit all the standard properties define in Component
, as the height, width, border, etc.
RenderableDefinition
Designating our model as an instance of Now, since our bar chart model itself needs to inherit properties, we need to designate it as instance of a specific model called RenderableDefinition
. This operation is done through the helper setModelTag()
:
// barchart/initscript/BarChart.js
const Component = '01612df263119ee77622';
const RenderableDefinition = '01612df1f810c91e8997';
db.newModel('0170f4619348dfd0c935', 'Bar Chart')
.extends(Component)
.setModelTag(RenderableDefinition)
Now, I see you telling "hey, dude, isn't it kinda weird to have to set the model of a model?" Don't worry, this is absolutely normal (to find this weird). As a matter of fact, in the Olympe world, all models have a model. This (generally confusing) oddity will be explained in another tutorial. For now, just consider the gist of it: our bar chart model can be considered as a template. Now, isn't it totally acceptable to consider this template as an instance of a class Template
? This is what RenderableDefinition
is: the mother class of all templates of renderable components. In the Olympe jargon, such class Template
are called Definitions.
Defining properties (themable or not)
Now that our model is correctly tied to existing elements, we can start defining its properties. This is done through either
defineThemableProperty
: for properties that can typically be determined by a theme. In our example, this will typically be theBarColor
property. This will let us define classes ofBarChart
s in DRAW's theme editor, with different bar colors.defineProperty
: for properties not really themable, typically the values the bar chart should display.
These both methods share the same signature:
- the tag of the property
- the type of the property
- the name of the property
- an optional default value.
Our model definition thus becomes:
// barchart/initscript/BarChart.js
db.newModel('0170f4619348dfd0c935', 'Bar Chart')
.extends(Component).setModelTag(RenderableDefinition)
.defineThemableProperty('0170f477486cc0d10315', db.COLOR, 'Bar color', db.createColor(100, 100, 100, 1))
.defineProperty('0170f477486cc0d10316', db.STRING, 'Chart title')
.defineProperty('0170f477486cc0d10317', db.STRING, 'CSV')
.done();
Observe the following in the above code:
- The tags are very similar ! Yes, it is permitted to cheat sometimes and just increment the first one (but in this usage only)
- To specify a default color, we use the
db.createColor()
helper.
Registering the properties in DRAW
Unfortunately, we are not done yet with SC definitions. We still need to register the properties we want to expose in DRAW in the properties editor. As a reminder, the property editor is this part of the interface:
To perform the later action, we need to find the reference to the section under which each property should be listed (for instance, under the "Value" section of the "Custom properties tab" as shown on the figure above). As a convention, properties of custom components should be placed under this "Custom properties tab", which has itself two sections of interests for us:
- the "Value" section, which has tag
0161bd31111c6b32e43d
- the "Custom properties" section, which has tag
0164188f5cc0c205bc06
We typically want to register the CSV
property under "Value", and the two other properties under "Custom properties". This registration goes as follow:
// barchart/initscript/BarChart.js
const barColorProperty = '0170f477486cc0d10315';
const chartTitleProperty = '0170f477486cc0d10316';
const CSVProperty = '0170f477486cc0d10317';
db.newModel('0170f4619348dfd0c935', 'Bar Chart')
.extends(Component).setModelTag(RenderableDefinition)
.defineThemableProperty(barColorProperty, db.COLOR, 'Bar color', db.createColor(100, 100, 100, 1))
.defineProperty(chartTitleProperty, db.STRING, 'Chart title')
.defineProperty(CSVProperty, db.STRING, 'CSV')
.done();
const multiLineTextInlineEditorInstance = '016cfca73e63f9ab5ba6';
const valueSection = '0161bd31111c6b32e43d';
const customSection = '0164188f5cc0c205bc06';
db.newComposerProperty('0170f477486cc0d10319', 'Chart values', valueSection, CSVProperty, multiLineTextInlineEditorInstance)
.setRank(1)
.done();
Observe that:
- registering a property actually means creating something called a
ComposerProperty
. Any new such element must be created with a tag. - we cheated again and incremented the tag of the first property to obtain the tag of the composer property.
- since the tag of the
CSV
property is used twice (once at the creation, once as a reference), it is a good practice to declare it into aconst
. - A rank can be designated for ordering within the section.
- with the last argument we prescribe what kind of editor will be associated to the property. For your information, here's a list of the most common inline editors (this is how we call them):
const stringInlineEditorInstance = '01611e3015371b459cf7';
const numberInlineEditorInstance = '01611e3015371b459cf5';
const booleanInlineEditorInstance = '01616a7f404d2d428a35';
const colorInlineEditorInstance = '01616aa63574678f7c45';
const fontFamilyInlineEditorInstance = '016d258c26b7a9fcffc7';
const multiLineTextInlineEditorInstance = '016cfca73e63f9ab5ba6';
const stringEnumInlineEditorInstance = '016c286b15a32cd8ce4b';
Note finally that we aim at simplifying a great deal this property registration process, which nowadays is quite cumbersome and error prone.
Exercice : registering the two other properties
To practice a bit, the registration of the two other properties is let up to you.
Solution
We just need to add the following lines:
// barchart/initscript/BarChart.js
const stringInlineEditorInstance = '01611e3015371b459cf7';
const colorInlineEditorInstance = '01616aa63574678f7c45';
db.newComposerProperty('0170f477486cc0d10320', 'Chart title', customSection, chartTitleProperty, stringInlineEditorInstance)
.setRank(2)
.done();
db.newComposerProperty('0170f477486cc0d10321', 'Bar color', customSection, barColorProperty, colorInlineEditorInstance)
.setRank(3)
.done();
The initscript/BarChart.js
script should now look as follows:
// barchart/initscript/BarChart.js
db = /** @type {composer.script.OSaveFileBuilder} */(db);
db.setDefaultGroupOfObjects('10000000000000000GOO');
db.newRepository('0170ef75108629bf0000', 'Custom visual component repository', db.ROOT_REPO_DEFAULT).done();
db.setWorkingRepository('0170ef75108629bf0000');
const Component = '01612df263119ee77622';
const RenderableDefinition = '01612df1f810c91e8997';
const multiLineTextInlineEditorInstance = '016cfca73e63f9ab5ba6';
const stringInlineEditorInstance = '01611e3015371b459cf7';
const colorInlineEditorInstance = '01616aa63574678f7c45';
const valueSection = '0161bd31111c6b32e43d';
const customSection = '0164188f5cc0c205bc06';
const barColorProperty = '0170f477486cc0d10315';
const chartTitleProperty = '0170f477486cc0d10316';
const CSVProperty = '0170f477486cc0d10317';
db.newModel('0170f4619348dfd0c935', 'Bar Chart')
.extends(Component)
.setModelTag(RenderableDefinition)
.defineThemableProperty(barColorProperty, db.COLOR, 'Bar color', db.createColor(100, 100, 100, 1))
.defineProperty(chartTitleProperty, db.STRING, 'Chart title')
.defineProperty(CSVProperty, db.STRING, 'CSV')
.done();
db.newComposerProperty('0170f477486cc0d10320', 'Chart title', customSection, chartTitleProperty, stringInlineEditorInstance)
.setRank(1)
.done();
db.newComposerProperty('0170f477486cc0d10321', 'Bar color', customSection, barColorProperty, colorInlineEditorInstance)
.setRank(2)
.done();
db.newComposerProperty('0170f477486cc0d10319', 'Chart values', valueSection, CSVProperty, multiLineTextInlineEditorInstance)
.setRank(3)
.done();
Registering the component within the Visual editor
Unlike functions that are automatically made available in the function editor, custom visual component must be manually associated with the visual editor (the DRAW editor used for screens and visual components). For that, we need to add the following lines:
// barchart/initscript/BarChart.js
const marketPlaceItemRel = '01611d3c5cfb4e80d64d';
const visualSubEditorInstance = '0161d6e0551951d18bc9';
db.newRelation(marketPlaceItemRel, visualSubEditorInstance, '0170f4619348dfd0c935');
Notice that in the above code, we perform the most rudimental operation: we add a link (of type marketPlaceItemRel
) between two nodes in the database.
Re-initializing the datacloud to incorporate our new definition
It is now time to call gulp rst
once again. Once the reset process is over, you can open DRAW and check if you see the bar chart component in the marketplace of the visual editor . If it is there as expected, try to drag one onto the screen. This should provoke an error as the implementation is missing.
Implementing the bar chart
Writing the boilerplate
Let's now implement the rendering of the bar chart in src/BarChartImpl.js
In this file, we start by writing (or copy-pasting from this tutorial - it is allowed) the following boilerplate:
// barchart/src/BarChartImpl.js
/**
* @extends {olympe.sc.ui.Component}
*/
barchart.BarChart = class BarChart extends olympe.sc.ui.Component {
/**
* @override
*/
render(context) {
const dimension = this.getDimension(context);
const layout = new olympe.ui.std.AbsoluteLayout(dimension);
this.initCommonProperties(layout, context);
layout.setBackgroundColor(olympe.df.Color.darkGray());
return layout;
}
};
Note that our class BarChart
extends from olympe.sc.ui.Component
. In that we follow what we wrote in our SC definition.
For now we simply create an empty AbsoluteLayout
painted in gray, sized to the dimension of the component, and we return it.
Linking this implementation with the SC definition
As it was the case for the CelsiusToFahrenheit
function, we need to establish a link between this implementation and our SC definition. This time we use the olympe.dc.Registry
:
// barchart/src/BarChartImpl.js
barchart.BarChart.entry = olympe.dc.Registry.registerSync('0170f4619348dfd0c935', barchart.BarChart);
Testing if the link is established
Refresh DRAW and check if the error you had before is still there. You should see now a dark gray rectangle on your screen.
Linking the properties
Before going any further in the implementation, we also need to link the properties between the SC and HC world. Here, contrarily to functions, we do not use a method chaining mechanism. Instead, we use the "entry" that was returned by the registerSync()
method (that we kept under barchart.BarChart.entry
and add properties on it:
// barchart/src/BarChartImpl.js
barchart.BarChart.csvProp = barchart.BarChart.entry.addProperty('0170f477486cc0d10317');
barchart.BarChart.titleProp = barchart.BarChart.entry.addProperty('0170f477486cc0d10316');
barchart.BarChart.colorProp = barchart.BarChart.entry.addProperty('0170f477486cc0d10315');
Implementing the bar chart
This time, we are ready to write the implementation itself. As a first step, we should collect the value of the csvProp
. This can be done by calling this.getProperty()
inside the render
method:
// barchart/src/BarChartImpl.js
const csvValue = this.getProperty(barchart.BarChart.csvProp, context, olympe.df.OString);
The getProperty
method takes a parameter of type olympe.dc.InstanceTag
as first argument that should designate which property one want to retrieve. The olympe.dc.InstanceTag
type is the union of about everything that can be or carry a tag, be it a string as in the present case, or an instance. The second argument is the context
, that we received as parameter in the render
method (we can ignore it for now). Finally, the third argument is the type of the property. Since our values will be given as a string, we put here olympe.df.OString
.
It is important to remark that this.getProperty
will return a Proxy, since the property of the bar chart that we render can be modified over time (also remotely).
Therefore, our next operation will consist in describing a processing node
. Hence, we want typically the bar chart to be repainted each time the csv values are updated.
// barchart/src/BarChartImpl.js
olympe.df.processFlows([csvValue], (val) => {
});
Then, inside the processing function of the process function, we can
- transform the updated value of the csv property,
val
, into a string by callingval.valueOf()
- split this (JavaScript) string using commas as separator
- iterate over the so obtained values array and, for each value:
- create a rectangle of width 20 and height proportional to the i-th value (scaled by a factor 10)
- set the background color of this rectangle to blue (for now)
- add this rectangle to the layout, at a position depending on its index.
// barchart/src/BarChartImpl.js
const valuesArray = val.valueOf().split(',');
for (let i=0, l=valuesArray.length ; i < l ; i++) {
const rectangle = new olympe.ui.std.Rectangle(new olympe.df.Vector2(20, valuesArray[i]*10));
rectangle.setBackgroundColor(olympe.df.Color.blue());
layout.appendChild(rectangle, 'rect' + i, new olympe.df.Vector2(i*30, 0));
}
Finally, we can remove the gray background color as its purpose was to check if something was getting rendered:
// barchart/src/BarChartImpl.js
// remove the following line
layout.setBackgroundColor(olympe.df.Color.darkGray());
Full code for
BarChartImpl.js
// barchart/src/BarChartImpl.js
/**
* @extends {olympe.sc.ui.Component}
*/
barchart.BarChart = class BarChart extends olympe.sc.ui.Component {
/**
* @override
*/
render(context) {
const dimension = this.getDimension(context);
const layout = new olympe.ui.std.AbsoluteLayout(dimension);
this.initCommonProperties(layout, context);
const csvValue = this.getProperty(barchart.BarChart.csvProp, context, olympe.df.OString);
olympe.df.processFlows([csvValue], (val) => {
const valuesArray = val.valueOf().split(',');
for (let i=0, l=valuesArray.length ; i < l ; i++) {
const rectangle = new olympe.ui.std.Rectangle(new olympe.df.Vector2(20, valuesArray[i]*10));
rectangle.setBackgroundColor(olympe.df.Color.blue());
layout.appendChild(rectangle, 'rect' + i, new olympe.df.Vector2(i*30, 0));
}
});
return layout;
}
};
barchart.BarChart.entry =
olympe.dc.Registry.registerSync('0170f4619348dfd0c935', barchart.BarChart);
barchart.BarChart.csvProp = barchart.BarChart.entry.addProperty('0170f477486cc0d10317');
barchart.BarChart.titleProp = barchart.BarChart.entry.addProperty('0170f477486cc0d10316');
barchart.BarChart.colorProp = barchart.BarChart.entry.addProperty('0170f477486cc0d10315');
Putting all these instructions together, and by specifying some values in the "Custom properties" tab, the following result is expected:
Exercise: improving this rudimental bar chart
We can improve the bar chart by:
- Making the bars "grow upwards".
- Applying the color property to the bars.
- Displaying the chart title.
Solution
Inverting the bars
To fix the direction of the bars, we need to set the Y position of each bar to the layout height minus the height of the bar.
A first attempt may look as follow:
// barchart/src/BarChartImpl.js
layout.appendChild(
rectangle,
'rect' + i,
new olympe.df.Vector2(
i*30,
layout.getHeight() - valuesArray[i]*10 // <-- line of interest
));
However, layout.getHeight()
returns a Proxy
because this property may change over time.
Since this Proxy
object encapsulates a numerical value, it provides the usual mathematical operations through methods. For the subtraction, the associated method is minus()
.
The line of interest then becomes:
// barchart/src/BarChartImpl.js
layout.getHeight().minus(valuesArray[i]*10)
When the layout changes (which can be due to the user resizing the bar chart), the Y position of the bars are automatically updated.
Bars color
We just need to retrieve the color property through this.getProperty()
, as we have done earlier to fetch the CSV values:
// barchart/src/BarChartImpl.js
rectangle.setBackgroundColor(
this.getProperty(barchart.BarChart.colorProp, context, olympe.df.Color)
);
Chart title
For this part, we need to:
- Create a label
- Set its text to the value contained in the
barchart.BarChart.titleProp
property - Append it to
layout
and specify its position.
For instance, if we would like to have the title to be centered, we can add the following lines to render
, after having created the layout
object:
// barchart/src/BarChartImpl.js
const chartTitle = new olympe.ui.std.Label();
chartTitle.setText(
this.getProperty(barchart.BarChart.titleProp, context, olympe.df.OString)
);
chartTitle.setTextHorizontalAlign(olympe.ui.common.HorizontalAlign.CENTER);
layout.appendChild(
chartTitle,
'charttitle',
new olympe.df.Vector2(
// We center the title by computing the appropriate X coordinate.
// We note that layout.getWidth() and chartTitle.getWidth() return
// a Proxy to denote the fact that the values may change over time.
// The position will be automatically recalculated if at least one
// of the layout or chart title changes.
layout.getWidth()
.minus(chartTitle.getWidth())
.div(2),
0
)
);
Result
BartChartImpl.js
:
Full code for // barchart/src/BarChartImpl.js
/**
* @extends {olympe.sc.ui.Component}
*/
barchart.BarChart = class BarChart extends olympe.sc.ui.Component {
/**
* @override
*/
render(context) {
const dimension = this.getDimension(context);
const layout = new olympe.ui.std.AbsoluteLayout(dimension);
this.initCommonProperties(layout, context);
const csvValue = this.getProperty(barchart.BarChart.csvProp, context, olympe.df.OString);
const chartTitle = new olympe.ui.std.Label();
chartTitle.setText(this.getProperty(barchart.BarChart.titleProp, context, olympe.df.OString));
chartTitle.setTextHorizontalAlign(olympe.ui.common.HorizontalAlign.CENTER);
layout.appendChild(
chartTitle,
'charttitle',
new olympe.df.Vector2(
layout.getWidth()
.minus(chartTitle.getWidth())
.div(2),
0
)
);
olympe.df.processFlows([csvValue], (val) => {
const valuesArray = val.valueOf().split(',');
for (let i=0, l=valuesArray.length ; i < l ; i++) {
const rectangle = new olympe.ui.std.Rectangle(new olympe.df.Vector2(20, valuesArray[i]*10));
rectangle.setBackgroundColor(
this.getProperty(barchart.BarChart.colorProp, context, olympe.df.Color, olympe.df.Color.blue())
);
layout.appendChild(rectangle, 'rect' + i, new olympe.df.Vector2(i*30, layout.getHeight().minus(valuesArray[i]*10)));
}
});
return layout;
}
};
barchart.BarChart.entry =
olympe.dc.Registry.registerSync('0170f4619348dfd0c935', barchart.BarChart);
barchart.BarChart.csvProp = barchart.BarChart.entry.addProperty('0170f477486cc0d10317');
barchart.BarChart.titleProp = barchart.BarChart.entry.addProperty('0170f477486cc0d10316');
barchart.BarChart.colorProp = barchart.BarChart.entry.addProperty('0170f477486cc0d10315');