Visualizing participants interaction with a graph
Introduction
In this tutorial, we show how to build a graph displaying the participants and who they met using Olympe's SVG API. This component will also allow edition the "met" relation.
As usual, any modification done through the graph component or through the data set editor will be synchronized with the datacloud.
Prerequisites
We assume that the reader completed the tutorials:
- Retrieve data from Olympe datacloud, transform it and display it in a custom Visual Component.
- Modify data on the datacloud.
Outcome
At the end of this tutorial, you will know to use the Olympe API to construct SVG objects and have them interact with dataflows.
Preamble: creating the "met" relationship
Before diving in, we need to create a "met" relationship.
To do so, we need to open DRAW and navigate to the data model defining Participant
. Next,
we drag-and-drop a Participant
from the list on the right side to the Relations
zone, and
name the relation met
.
We will also need the tag of the relation, so we click on the marker to copy it.
We then need to report this addition to datacloud.Participant.js
.
In the hardcoded tags section, we should add the following line:
// datacloud/src/Participant.js, near the end of the file.
// ...
datacloud.Participant.metRel = datacloud.Participant.entry.addRelation(<tag-of-the-relation>);
Note that you need to replace <tag-of-the-relation>
with the tag of the relation you have (which is very likely different from ours).
We will also need to retrieve the list of participants a participant met. For that, we add the following
method to datacloud.Participant
:
// datacloud/src/Participant.js, in the datacloud.Participant class
/**
* @return {!olympe.dc.ListDef<datacloud.Participant>}
*/
getMetParticipants() {
return this.getRelated(datacloud.Participant.metRel);
}
Our component will let users modify relationships between participants.
We add the following two methods to datacloud.ParticipantController
(and not datacloud.Participant
!)
to respectively add and remove a "met" relationship between two participants:
// datacloud/src/ParticipantController.js, in the datacloud.ParticipantController class
/**
* @param {!datacloud.Participant} p1
* @param {!datacloud.Participant} p2
* @param {olympe.dc.Transaction=} tx
* @return {olympe.dc.Transaction}
*/
setMet(p1, p2, tx = new olympe.dc.Transaction()) {
tx.createRelation(datacloud.Participant.metRel, p1, p2);
return tx;
}
/**
* @param {!datacloud.Participant} p1
* @param {!datacloud.Participant} p2
* @param {olympe.dc.Transaction=} tx
* @return {olympe.dc.Transaction}
*/
removeMet(p1, p2, tx = new olympe.dc.Transaction()) {
tx.deleteRelation(datacloud.Participant.metRel, p1, p2);
return tx;
}
Finally, it would be useful for later to edit the dataset to add some relationships between the participants:
participantgraph
module
Creating the We will write the graph component within a dedicated module. For that, we create the participantgraph
module.
The participantgraph
module is created as follows:
We add two new folders,
participantgraph
insidemodules
andsrc
withinparticipantgraph
We create a
_ns.js
file (insrc
) declaring theparticipantgraph
namespace:/** * @namespace */ const participantgraph = {};
We add a
GraphImpl.js
file contained insrc
that will contain the graph implementation. For now, it should contain the following boilerplate:// src/GraphImpl.js /** * @extends {olympe.sc.ui.Component} */ participantgraph.Graph = class Graph extends olympe.sc.ui.Component { /** * @override */ render(context) { const dimension = this.getDimension(context); const layout = new olympe.ui.std.VerticalLayout(); layout.setDimension(dimension); const svgContainer = new olympe.ui.vectorial.SvgContainer(); layout.appendChild(svgContainer, 'svg-container'); // nodeGroup will contain the participant nodes while // metGroup will contain the arrows displaying the "met" // relationship const nodeGroup = new olympe.ui.vectorial.Group(); const metGroup = new olympe.ui.vectorial.Group(); svgContainer.appendChild(metGroup, 'met-group'); svgContainer.appendChild(nodeGroup, 'node-group'); return layout; } }; participantgraph.Graph.entry = olympe.dc.Registry.registerSync('017401cd83717c170e26', participantgraph.Graph);
We write a
module.json
file with the following content:{ "name": "participantgraph", "version": "0.0.1", "sources": [ "src/_ns.js", "src/GraphImpl.js" ] }
We register the
participantgraph
module by adding it to the rootmodule.json
:{ "name": "tutorials", "version": "0.0.1", "dependencies": { "olympe-composer.targets": "*" }, "targets": { "html": { "dependencies": { "olympe-composer": "*", "olympe.native.html.target.default": "*", "helloworld": "*", "c2f": "*", "barchart": "*", "datacloud": "*", "participantgraph": "*" } } } }
Next, we need to register the component so that Olympe is aware of it. To do so, we need to create an init script describing the component.
We need to create a folder initscripts
and a JS file Graph.js
contained in the initscripts
folder, with the following
snippet:
// initscripts/Graph.js
db = /** @type {composer.script.OSaveFileBuilder} */(db);
db.setDefaultGroupOfObjects('10000000000000000GOO');
db.setWorkingRepository(db.ROOT_REPO_DEFAULT);
const GraphTag = '017401cd83717c170e26';
const Component = '01612df263119ee77622';
const RenderableDefinition = '01612df1f810c91e8997';
db.newModel(GraphTag, 'Participant Graph')
.extends(Component)
.setModelTag(RenderableDefinition)
.done();
const marketPlaceItemRel = '01611d3c5cfb4e80d64d';
const visualSubEditorInstance = '0161d6e0551951d18bc9';
db.newRelation(marketPlaceItemRel, visualSubEditorInstance, GraphTag);
We then need to modify participantgraph/module.json
to indicate we have init scripts. It should look as follows:
{
"name": "participantgraph",
"version": "0.0.1",
"sources": [
"src/_ns.js",
"src/GraphImpl.js"
],
"datacloudinit": {
"initScriptDirectories": [
"initscripts/"
]
}
}
Next, we need to type gulp rst
to have the graph component registered.
Note that this will cause the DC to be reset, please be sure to export projects
that you would like to preserve first (in particular the project defining the Participant
model),
and import them back after the reset (see the Appendix)
To test our component, we need to setup an UI app containing the graph component, and run that UI app in a new tab, as shown in the video below.
Each time a change is done to the graph component code, only the UI app tab needs to be refreshed.
We are now set up to work on the graph.
Placing and rendering participant nodes
We would like to present the participant nodes as if they were positionned on a circle, as shown in the outcome video.
Since we may add or remove participants at any time, the circle on which the nodes are positionned should "grow" or "shrink" accordingly.
To compute the circle radius, we may employ the formula for a circular segment, as shown in the figure below:
Here, and
are constants (that we can choose arbitrarily). The value
is obtained by dividing
by the number of participants.
Now that we have and
, we
can compute any participant node position:
For each participant , there is an associated
that corresponds to the participant index in the list of participants (which we will see afterwards). With this value and the other
expressions we have computed earlier, we are able to calculate the node position for each participant using standard geometry tools.
Now that we have everything in our hands, we can translate the mathematical expressions into JavaScript:
// src/GraphImpl.js
const PARTICIPANT_NODE_RADIUS = 50;
const PARTICIPANT_NODE_SPACING = 40;
/**
* @extends {olympe.sc.ui.Component}
*/
participantgraph.Graph = class Graph extends olympe.sc.ui.Component {
/**
* @override
*/
render(context) {
const dimension = this.getDimension(context);
const layout = new olympe.ui.std.VerticalLayout();
layout.setDimension(dimension);
const svgContainer = new olympe.ui.vectorial.SvgContainer();
layout.appendChild(svgContainer, 'svg-container');
// nodeGroup will contain the participant nodes while
// metGroup will contain the arrows displaying the "met"
// relationship
const nodeGroup = new olympe.ui.vectorial.Group();
const metGroup = new olympe.ui.vectorial.Group();
svgContainer.appendChild(metGroup, 'met-group');
svgContainer.appendChild(nodeGroup, 'node-group');
///////////////////////////////////////////////////////////
// Getting all participants
const participantsList = new olympe.dc.ListDef(datacloud.Participant.entry, [olympe.dc.Sync.instancesRel]);
// Corresponds to 'Angle' in the figure
const anglePerParticipantNode = olympe.df.pONumber(2 * Math.PI).div(participantsList.getSize());
const circleRadius =
olympe.df.transformFlows(
[anglePerParticipantNode],
a => (2 * PARTICIPANT_NODE_RADIUS + PARTICIPANT_NODE_SPACING) / (2 * Math.sin(a / 2)),
olympe.df.ONumber
);
// Setting the dimensions of the container so that it is always centered
// when changing its dimensions.
const svgDim = circleRadius.plus(PARTICIPANT_NODE_RADIUS).mul(2).plus(40);
svgContainer.setPosition(
new olympe.df.Vector2(
layout.getWidth().minus(svgDim).div(2).returnsBiggest(0),
0
)
);
svgContainer.setDimension(
new olympe.df.Vector2(dimension.getX(), svgDim)
);
// We put the "circle" center in the middle of the container
const circleCenter = new olympe.df.Vector2(svgDim.div(2), svgDim.div(2));
return layout;
}
};
participantgraph.Graph.entry =
olympe.dc.Registry.registerSync('017401cd83717c170e26', participantgraph.Graph);
For circleRadius
, we have used the transformFlows
function that,
given an array of Proxys
and a transformation function, returns a new Proxy
that applies the transformation to the set of
given flows. If anglePerParticipantNode
is updated, circleRadius
will automatically get recalculated.
To avoid having PARTICIPANT_NODE_RADIUS
and PARTICIPANT_NODE_SPACING
(and future constants) pollute
the global namespace, we introduce brackets to delimit their scope. Note that participantgraph.Graph
and
participantgraph.Graph.entry
will however remain visible (as we would like).
// src/GraphImpl.js
{ // <--------------------
const PARTICIPANT_NODE_RADIUS = 50;
const PARTICIPANT_NODE_SPACING = 40;
/**
* @extends {olympe.sc.ui.Component}
*/
participantgraph.Graph = class Graph extends olympe.sc.ui.Component {
// ...
};
participantgraph.Graph.entry =
olympe.dc.Registry.registerSync('017401cd83717c170e26', participantgraph.Graph);
} // <--------------------
Next, we implement the computePosition
function that takes as input a participant's rank
and output its position. Note that this arrow-function captures variables computed earlier; we must
be sure to place computePosition
after the definition of its captured variables.
// src/GraphImpl.js in render()
/**
* @param {!olympe.df.PONumber} rank
* @return {!olympe.df.Vector2}
*/
const computePosition = rank => {
const participantAngle = anglePerParticipantNode.mul(rank);
return new olympe.df.Vector2(
participantAngle.cos().mul(circleRadius).plus(circleCenter.getX()),
participantAngle.sin().mul(circleRadius).plus(circleCenter.getY()),
);
};
Note that rank
is an olympe.df.PONumber
(alias for olympe.df.ONumber | olympe.df.Proxy<olympe.df.ONumber>
).
because a participant's rank may change overtime (e.g. when removing a participant, following participants are shifted).
If the rank or the number of participant happens to change, the positions will be recomputed.
Now that we have everything in place, we can render the participant nodes:
// src/GraphImpl.js in render()
participantsList.forEach((participant, key) => {
const rank = participantsList.getRank(key);
const position = computePosition(rank);
const participantNode = this.renderParticipantNode(position, participant);
nodeGroup.appendChild(participantNode, 'participant-node-' + participant.getTag());
});
We still need to implement the renderParticipantNode
method, but first we need to add
another constant:
// src/GraphImpl.js, constants location, at the near-top of the file
// Corresponds to "Spacing" of the first figure.
const NAME_AND_AGE_SPACING = 5;
The renderParticipantNode
definition is rather straigthforward, albeit a bit long:
// src/GraphImpl.js, new method of GraphImpl
/**
* @param {!olympe.df.Vector2} position
* @param {!datacloud.Participant} participant
* @return {!olympe.ui.vectorial.Group}
*/
renderParticipantNode(position, participant) {
const group = new olympe.ui.vectorial.Group();
group.appendChild(
new olympe.ui.vectorial.Circle()
.setRadius(PARTICIPANT_NODE_RADIUS)
.setPosition(position)
.setFillColor(olympe.df.Color.lime()),
'node'
);
const name = new olympe.ui.vectorial.Text();
name.setText(participant.getName()).setFontSize(14);
name.setPosition(
new olympe.df.Vector2(
position.getX().minus(name.getDimension().getX().div(2)),
position.getY().minus(NAME_AND_AGE_SPACING)
)
);
group.appendChild(name, 'name');
const age = new olympe.ui.vectorial.Text();
age.setText(participant.getAge().toOString()).setFontSize(14);
age.setPosition(
new olympe.df.Vector2(
position.getX().minus(age.getDimension().getX().div(2)),
position.getY().plus(age.getDimension().getY()).minus(NAME_AND_AGE_SPACING)
)
);
group.appendChild(age, 'age');
return group;
}
Note that we do not need to do anything special if a participant gets removed. In particular, we do not need to remove the UI components of the deleted participant. We keep the explanations of this behavior for another tutorial.
We should obtain the following result:
Click to reveal the code for
GraphImpl.js
at this step
// src/GraphImpl.js
{
const PARTICIPANT_NODE_RADIUS = 50;
const PARTICIPANT_NODE_SPACING = 40;
const NAME_AND_AGE_SPACING = 5;
/**
* @extends {olympe.sc.ui.Component}
*/
participantgraph.Graph = class Graph extends olympe.sc.ui.Component {
/**
* @override
*/
render(context) {
const dimension = this.getDimension(context);
const layout = new olympe.ui.std.VerticalLayout();
layout.setDimension(dimension);
const svgContainer = new olympe.ui.vectorial.SvgContainer();
layout.appendChild(svgContainer, 'svg-container');
const nodeGroup = new olympe.ui.vectorial.Group();
const metGroup = new olympe.ui.vectorial.Group();
svgContainer.appendChild(metGroup, 'met-group');
svgContainer.appendChild(nodeGroup, 'node-group');
///////////////////////////////////////////////////////////
const participantsList = new olympe.dc.ListDef(datacloud.Participant.entry, [olympe.dc.Sync.instancesRel]);
const anglePerParticipantNode = olympe.df.pONumber(2 * Math.PI).div(participantsList.getSize());
const circleRadius =
olympe.df.transformFlows(
[anglePerParticipantNode],
a => (2 * PARTICIPANT_NODE_RADIUS + PARTICIPANT_NODE_SPACING) / (2 * Math.sin(a / 2)),
olympe.df.ONumber
);
const svgDim = circleRadius.plus(PARTICIPANT_NODE_RADIUS).mul(2).plus(40);
svgContainer.setPosition(
new olympe.df.Vector2(
layout.getWidth().minus(svgDim).div(2).returnsBiggest(0),
0
)
);
svgContainer.setDimension(
new olympe.df.Vector2(dimension.getX(), svgDim)
);
const circleCenter = new olympe.df.Vector2(svgDim.div(2), svgDim.div(2));
/**
* @param {!olympe.df.PONumber} rank
* @return {!olympe.df.Vector2}
*/
const computePosition = rank => {
const participantAngle = anglePerParticipantNode.mul(rank);
return new olympe.df.Vector2(
participantAngle.cos().mul(circleRadius).plus(circleCenter.getX()),
participantAngle.sin().mul(circleRadius).plus(circleCenter.getY()),
);
};
participantsList.forEach((participant, key) => {
const rank = participantsList.getRank(key);
const position = computePosition(rank);
const participantNode = this.renderParticipantNode(position, participant);
nodeGroup.appendChild(participantNode, 'participant-node-' + participant.getTag());
});
return layout;
}
/**
* @param {!olympe.df.Vector2} position
* @param {!datacloud.Participant} participant
* @return {!olympe.ui.vectorial.Group}
*/
renderParticipantNode(position, participant) {
const group = new olympe.ui.vectorial.Group();
group.appendChild(
new olympe.ui.vectorial.Circle()
.setRadius(PARTICIPANT_NODE_RADIUS)
.setPosition(position)
.setFillColor(olympe.df.Color.lime()),
'node'
);
const name = new olympe.ui.vectorial.Text();
name.setText(participant.getName()).setFontSize(14);
name.setPosition(
new olympe.df.Vector2(
position.getX().minus(name.getDimension().getX().div(2)),
position.getY().minus(NAME_AND_AGE_SPACING)
)
);
group.appendChild(name, 'name');
const age = new olympe.ui.vectorial.Text();
age.setText(participant.getAge().toOString()).setFontSize(14);
age.setPosition(
new olympe.df.Vector2(
position.getX().minus(age.getDimension().getX().div(2)),
position.getY().plus(age.getDimension().getY()).minus(NAME_AND_AGE_SPACING)
)
);
group.appendChild(age, 'age');
return group;
}
};
participantgraph.Graph.entry =
olympe.dc.Registry.registerSync('017401cd83717c170e26', participantgraph.Graph);
}
Displaying relationships
We would like to draw arrows from a participant to another if the former met the latter (in general, this relationship is symmetric, but nothing is enforcing it).
This requires having the position of each participant. We can store these positions
in an olympe.df.Map
. We explain the choice of olympe.df.Map
over
the ES6 Map
afterwards.
We modify the code to incorporate a tag2pos
map that, given a participant tag, stores its associated position:
// src/GraphImpl.js, in the render() method
/** @type {!olympe.df.Map<olympe.df.Vector2>} */ // <---------------
const tag2pos = new olympe.df.Map(olympe.df.Vector2); // <---------------
participantsList.forEach((participant, key) => {
const rank = participantsList.getRank(key);
const position = computePosition(rank);
// Note: key and participant.getTag() are not necessarly the same
// 'keys' are generated for each entry of a ListDef query and do not
// have to coincide with the tag of the entry (if it has one)
tag2pos.set(participant.getTag(), position); // <---------------
const participantNode = this.renderParticipantNode(position, participant);
nodeGroup.appendChild(participantNode, 'participant-node-' + participant.getTag());
});
To retrieve the persons a participant has met, we use the getMetParticipants()
method of the datacloud.Participant
class. We can then call the to-be-defined renderArrow()
method taking as arguments the participant and
the met participant:
// src/GraphImpl.js, in the render() method
/** @type {!olympe.df.Map<olympe.df.Vector2>} */
const tag2pos = new olympe.df.Map(olympe.df.Vector2);
participantsList.forEach((participant, key) => {
const rank = participantsList.getRank(key);
const position = computePosition(rank);
tag2pos.set(participant.getTag(), position);
const participantNode = this.renderParticipantNode(position, participant);
nodeGroup.appendChild(participantNode, 'participant-node-' + participant.getTag());
// For each participant, render an arrow from their position to the met participant position
participant.getMetParticipants().forEach(metParticipant => {
// To avoid division by zero, though this case should not happen.
if (participant.getTag() === metParticipant.getTag()) {
return;
}
const metArrow = this.renderArrow(
position,
// We retrieve the position of the met participant using tag2pos
tag2pos.get(metParticipant.getTag()),
// We explain these two arguments next.
PARTICIPANT_NODE_RADIUS,
PARTICIPANT_NODE_RADIUS
);
metGroup.appendChild(metArrow, participant.getTag() + '-met-' + metParticipant.getTag());
});
});
We now have to define the renderArrow()
method that takes four arguments. The first two are expected and
represent the origin and destination of the arrow. The next two are here to "shift" the origin
and destination of the arrow alongside its direction. The reason is that we would like to have the tail and particularly the head be positionned at the border of the circle, and not at its center.
Now that we have that out of the way, we can implement this method:
// src/GraphImpl.js
// New method of GraphImpl
/**
* @param {!olympe.df.PVector2} fromPos
* @param {!olympe.df.PVector2} toPos
* @param {number} fromOffset
* @param {number} toOffset
* @param {!olympe.df.Color} color
* @return {!olympe.ui.vectorial.Group}
*/
renderArrow(fromPos, toPos, fromOffset = 0, toOffset = 0, color = olympe.df.Color.black()) {
const unitVec = toPos.minus(fromPos).normalize();
const group = new olympe.ui.vectorial.Group();
const shiftedFromPos = fromOffset ? fromPos.plus(unitVec.multiplyScalar(fromOffset)) : fromPos;
const shiftedToPos = toOffset ? toPos.minus(unitVec.multiplyScalar(toOffset)) : toPos;
const line = new olympe.ui.vectorial.Path()
.moveTo(shiftedFromPos)
.lineTo(shiftedToPos)
.setStrokeWidth(2.5)
.setStrokeColor(color);
const head = new olympe.ui.vectorial.Path()
.moveTo(new olympe.df.Vector2(0, 0))
.lineTo(new olympe.df.Vector2(5, 15))
.lineTo(new olympe.df.Vector2(0, 10))
.lineTo(new olympe.df.Vector2(-5, 15))
.setStrokeWidth(2.5)
.setStrokeColor(color)
.setFillColor(color)
.setClosed(true);
const angle = unitVec.getY().atan2(unitVec.getX()).plus(Math.PI / 2);
head.setPosition(shiftedToPos);
head.rotate(angle, shiftedToPos.getX().toggleSign(), shiftedToPos.getY().toggleSign());
group.appendChild(line, 'line');
group.appendChild(head, 'head');
return group;
}
We can now visualize the interactions between the participants:
Click to reveal the code for
GraphImpl.js
at this step
// src/GraphImpl.js
{
const PARTICIPANT_NODE_RADIUS = 50;
const PARTICIPANT_NODE_SPACING = 40;
const NAME_AND_AGE_SPACING = 5;
/**
* @extends {olympe.sc.ui.Component}
*/
participantgraph.Graph = class Graph extends olympe.sc.ui.Component {
/**
* @override
*/
render(context) {
const dimension = this.getDimension(context);
const layout = new olympe.ui.std.VerticalLayout();
layout.setDimension(dimension);
const svgContainer = new olympe.ui.vectorial.SvgContainer();
layout.appendChild(svgContainer, 'svg-container');
const nodeGroup = new olympe.ui.vectorial.Group();
const metGroup = new olympe.ui.vectorial.Group();
svgContainer.appendChild(metGroup, 'met-group');
svgContainer.appendChild(nodeGroup, 'node-group');
///////////////////////////////////////////////////////////
const participantsList = new olympe.dc.ListDef(datacloud.Participant.entry, [olympe.dc.Sync.instancesRel]);
const anglePerParticipantNode = olympe.df.pONumber(2 * Math.PI).div(participantsList.getSize());
const circleRadius =
olympe.df.transformFlows(
[anglePerParticipantNode],
a => (2 * PARTICIPANT_NODE_RADIUS + PARTICIPANT_NODE_SPACING) / (2 * Math.sin(a / 2)),
olympe.df.ONumber
);
const svgDim = circleRadius.plus(PARTICIPANT_NODE_RADIUS).mul(2).plus(40);
svgContainer.setPosition(
new olympe.df.Vector2(
layout.getWidth().minus(svgDim).div(2).returnsBiggest(0),
0
)
);
svgContainer.setDimension(
new olympe.df.Vector2(dimension.getX(), svgDim)
);
const circleCenter = new olympe.df.Vector2(svgDim.div(2), svgDim.div(2));
/**
* @param {!olympe.df.PONumber} rank
* @return {!olympe.df.Vector2}
*/
const computePosition = rank => {
const participantAngle = anglePerParticipantNode.mul(rank);
return new olympe.df.Vector2(
participantAngle.cos().mul(circleRadius).plus(circleCenter.getX()),
participantAngle.sin().mul(circleRadius).plus(circleCenter.getY()),
);
};
///////////////////////////////////////////////////////////
/** @type {!olympe.df.Map<olympe.df.Vector2>} */
const tag2pos = new olympe.df.Map(olympe.df.Vector2);
participantsList.forEach((participant, key) => {
const rank = participantsList.getRank(key);
const position = computePosition(rank);
tag2pos.set(participant.getTag(), position);
const participantNode = this.renderParticipantNode(position, participant);
nodeGroup.appendChild(participantNode, 'participant-node-' + participant.getTag());
participant.getMetParticipants().forEach(metParticipant => {
// To avoid division by zero, though this case should not happen.
if (participant.getTag() === metParticipant.getTag()) {
return;
}
const metArrow = this.renderArrow(
position,
tag2pos.get(metParticipant.getTag()),
PARTICIPANT_NODE_RADIUS,
PARTICIPANT_NODE_RADIUS
);
metGroup.appendChild(metArrow, participant.getTag() + '-met-' + metParticipant.getTag());
});
});
return layout;
}
/**
* @param {!olympe.df.PVector2} fromPos
* @param {!olympe.df.PVector2} toPos
* @param {number} fromOffset
* @param {number} toOffset
* @param {!olympe.df.Color} color
* @return {!olympe.ui.vectorial.Group}
*/
renderArrow(fromPos, toPos, fromOffset = 0, toOffset = 0, color = olympe.df.Color.black()) {
const unitVec = toPos.minus(fromPos).normalize();
const group = new olympe.ui.vectorial.Group();
const shiftedFromPos = fromOffset ? fromPos.plus(unitVec.multiplyScalar(fromOffset)) : fromPos;
const shiftedToPos = toOffset ? toPos.minus(unitVec.multiplyScalar(toOffset)) : toPos;
const line = new olympe.ui.vectorial.Path()
.moveTo(shiftedFromPos)
.lineTo(shiftedToPos)
.setStrokeWidth(2.5)
.setStrokeColor(color);
const head = new olympe.ui.vectorial.Path()
.moveTo(new olympe.df.Vector2(0, 0))
.lineTo(new olympe.df.Vector2(5, 15))
.lineTo(new olympe.df.Vector2(0, 10))
.lineTo(new olympe.df.Vector2(-5, 15))
.setStrokeWidth(2.5)
.setStrokeColor(color)
.setFillColor(color)
.setClosed(true);
const angle = unitVec.getY().atan2(unitVec.getX()).plus(Math.PI / 2);
head.setPosition(shiftedToPos);
head.rotate(angle, shiftedToPos.getX().toggleSign(), shiftedToPos.getY().toggleSign());
group.appendChild(line, 'line');
group.appendChild(head, 'head');
return group;
}
/**
* @param {!olympe.df.Vector2} position
* @param {!datacloud.Participant} participant
* @return {!olympe.ui.vectorial.Group}
*/
renderParticipantNode(position, participant) {
const group = new olympe.ui.vectorial.Group();
group.appendChild(
new olympe.ui.vectorial.Circle()
.setRadius(PARTICIPANT_NODE_RADIUS)
.setPosition(position)
.setFillColor(olympe.df.Color.lime()),
'node'
);
const name = new olympe.ui.vectorial.Text();
name.setText(participant.getName()).setFontSize(14);
name.setPosition(
new olympe.df.Vector2(
position.getX().minus(name.getDimension().getX().div(2)),
position.getY().minus(NAME_AND_AGE_SPACING)
)
);
group.appendChild(name, 'name');
const age = new olympe.ui.vectorial.Text();
age.setText(participant.getAge().toOString()).setFontSize(14);
age.setPosition(
new olympe.df.Vector2(
position.getX().minus(age.getDimension().getX().div(2)),
position.getY().plus(age.getDimension().getY()).minus(NAME_AND_AGE_SPACING)
)
);
group.appendChild(age, 'age');
return group;
}
};
participantgraph.Graph.entry =
olympe.dc.Registry.registerSync('017401cd83717c170e26', participantgraph.Graph);
}
Some reader may notice that fromPos
and toPos
of the renderArrow
method are "typed" olympe.df.PVector2
(which is an alias for olympe.df.Proxy<olympe.df.Vector2> | olympe.df.Vector2
) and not olympe.df.Vector2
as we could expect. The reason why is the same as the use of an olympe.df.Map
instead of
an ES6 Map
mentionned earlier.
If we look again at the call to this.renderArrow
:
// src/GraphImpl.js, in the render() method within participantsList.forEach
// and participant.getMetParticipants().forEach
const metArrow = this.renderArrow(
position,
tag2pos.get(metParticipant.getTag()), // <-------------
PARTICIPANT_NODE_RADIUS,
PARTICIPANT_NODE_RADIUS
);
we notice that we are accessing tag2pos.get(metParticipant.getTag())
, but metParticipant
may not be
present in tag2pos
map yet! Indeed, since things are asynchronous, a participant could be fetched in participant.getMetParticipants()
first before being processed in the "outer loop" participantsList.forEach
. And yet,
things work fine, that is, if we use an olympe.df.Map
.
This is due to the get
method of olympe.df.Map
returning a Proxy
, to denote the fact that the value
for a given key may change over time. In particular, it may not be present now but could be in the future.
For metParticipant
that are not in the tag2pos
map yet, the returned Proxy
will be "unresolved"
and will wait for a value before having its downstream flows updated (which will ultimately result in rendering the arrow once the met participant has its position computed).
If we changed the olympe.df.Map
to an ES6 Map
, we would get an error:
Uncaught TypeError: Cannot read property 'minus' of undefined
at Graph.renderArrow (const unitVec = toPos.minus(fromPos).normalize())
This is due to having tag2pos.get(metParticipant.getTag())
returning undefined
because
metParticipant
has not its position computed at the time of the call.
Adding "met" relations
We would like to have the possibility of adding relationships between participants by clicking on two participants.
When first clicking on a participant, we "select" them and move the mouse to the "target" participant. Clicking on the "target" will then add the relation between the "selected" participant and the "target" participant.
To implement this feature, we will:
- Have two dataflows:
selectedParticipant
that tracks the selected participant. We will update this flow when clicking on a participant node accordingly.selectedParticipantPos
that contains the position of the selected participant. It will allow to draw an arrow between the selected participant position and the position of the mouse. We can deriveselectedParticipantPos
from theselectedParticipant.getFlow()
dataflow using thetag2pos
map and thetransformFlows
function.
- Attach a listener on every participant node:
- If we do not have a selected participant and click on a node, we "push" the clicked
participant in
selectedParticipant
. - Otherwise, we add a "met" relation between the selected participant and the target participant (that has been clicked).
- If we do not have a selected participant and click on a node, we "push" the clicked
participant in
We point out that selectedParticipant
needs to somehow have new values pushed.
If we inspected the olympe.df.Proxy
API,
we are not offered such possibility (ignoring private methods starting with underscores).
Indeed, after all, a Proxy
is "read-only".
What we are looking for is olympe.df.FlowSource
.
A FlowSource
allows values to be "pushed" with its update
method. To retrieve the underlying dataflow or Proxy
, we use its getFlow
method. The newFlowSource
function
allows to create FlowSources
.
Combining all these things together, we can first implement the dataflows creation and the new relation arrow rendering:
// src/GraphImpl.js, in the render() method
// Note: we have moved tag2pos up
/** @type {!olympe.df.Map<olympe.df.Vector2>} */
const tag2pos = new olympe.df.Map(olympe.df.Vector2);
const selectedParticipant = olympe.df.newFlowSource(datacloud.Participant);
// No selected participant at the beginning.
selectedParticipant.update(null);
const selectedParticipantPos = olympe.df.transformFlows(
[selectedParticipant],
participant => participant ? tag2pos.get(participant.getTag()) : undefined,
olympe.df.Vector2
);
const newRelationArrow = this.renderArrow(
selectedParticipantPos,
svgContainer.getEventMouseMove().getRelativePos(),
PARTICIPANT_NODE_RADIUS,
10
);
svgContainer.appendChild(newRelationArrow, 'new-relation-arrow');
const selectionMouseNodeVec = svgContainer.getEventMouseMove().getRelativePos().minus(selectedParticipantPos);
// Hide the "new relation" arrow if either we have no selected participant, or if
// the mouse hovers the selected participant node (to avoid having a visual glitch).
newRelationArrow.setHidden(
olympe.df.if(
selectedParticipant.getFlow().oEquals(null),
() => olympe.df.oBoolean(true),
() => olympe.df.transformFlows(
[selectionMouseNodeVec.getX(), selectionMouseNodeVec.getY()],
(x, y) => olympe.df.oBoolean(x * x + y * y < PARTICIPANT_NODE_RADIUS * PARTICIPANT_NODE_RADIUS + 50 * 50),
olympe.df.OBoolean
)
)
);
// When "clicking away", we would like to cancel the relation addition.
svgContainer.onClickOrTap(() => {
selectedParticipant.update(null);
});
It remains us to handle the clicking on the participant node. The behavior
depends on whether we already have a selected participant. If we do,
we add the relationship between the selected participant and the clicked participant
using the setMet
method of datacloud.ParticipantController
. Otherwise, we set the clicked participant
as "selected". To get the current value of a flow, we use the getCurrentValue
function:
// src/GraphImpl.js, in the render() method within participantsList.forEach
participantNode.onClickOrTap(() => {
const currentlySelectedParticipant = olympe.df.getCurrentValue(selectedParticipant.getFlow());
if (!currentlySelectedParticipant) {
selectedParticipant.update(participant);
} else if (currentlySelectedParticipant.getTag() !== participant.getTag()) {
datacloud.ParticipantController
.getInstance()
.setMet(currentlySelectedParticipant, participant)
.execute();
selectedParticipant.update(null);
}
});
Click to reveal the code for
GraphImpl.js
at this step
// src/GraphImpl.js
{
const PARTICIPANT_NODE_RADIUS = 50;
const PARTICIPANT_NODE_SPACING = 40;
const NAME_AND_AGE_SPACING = 5;
/**
* @extends {olympe.sc.ui.Component}
*/
participantgraph.Graph = class Graph extends olympe.sc.ui.Component {
/**
* @override
*/
render(context) {
const dimension = this.getDimension(context);
const layout = new olympe.ui.std.VerticalLayout();
layout.setDimension(dimension);
const svgContainer = new olympe.ui.vectorial.SvgContainer();
layout.appendChild(svgContainer, 'svg-container');
const nodeGroup = new olympe.ui.vectorial.Group();
const metGroup = new olympe.ui.vectorial.Group();
svgContainer.appendChild(metGroup, 'met-group');
svgContainer.appendChild(nodeGroup, 'node-group');
///////////////////////////////////////////////////////////
const participantsList = new olympe.dc.ListDef(datacloud.Participant.entry, [olympe.dc.Sync.instancesRel]);
const anglePerParticipantNode = olympe.df.pONumber(2 * Math.PI).div(participantsList.getSize());
const circleRadius =
olympe.df.transformFlows(
[anglePerParticipantNode],
a => (2 * PARTICIPANT_NODE_RADIUS + PARTICIPANT_NODE_SPACING) / (2 * Math.sin(a / 2)),
olympe.df.ONumber
);
const svgDim = circleRadius.plus(PARTICIPANT_NODE_RADIUS).mul(2).plus(40);
svgContainer.setPosition(
new olympe.df.Vector2(
layout.getWidth().minus(svgDim).div(2).returnsBiggest(0),
0
)
);
svgContainer.setDimension(
new olympe.df.Vector2(dimension.getX(), svgDim)
);
const circleCenter = new olympe.df.Vector2(svgDim.div(2), svgDim.div(2));
/**
* @param {!olympe.df.PONumber} rank
* @return {!olympe.df.Vector2}
*/
const computePosition = rank => {
const participantAngle = anglePerParticipantNode.mul(rank);
return new olympe.df.Vector2(
participantAngle.cos().mul(circleRadius).plus(circleCenter.getX()),
participantAngle.sin().mul(circleRadius).plus(circleCenter.getY()),
);
};
/** @type {!olympe.df.Map<olympe.df.Vector2>} */
const tag2pos = new olympe.df.Map(olympe.df.Vector2);
///////////////////////////////////////////////////////////
const selectedParticipant = olympe.df.newFlowSource(datacloud.Participant);
// No selected participant at the beginning.
selectedParticipant.update(null);
const selectedParticipantPos = olympe.df.transformFlows(
[selectedParticipant],
participant => participant ? tag2pos.get(participant.getTag()) : undefined,
olympe.df.Vector2
);
const newRelationArrow = this.renderArrow(
selectedParticipantPos,
svgContainer.getEventMouseMove().getRelativePos(),
PARTICIPANT_NODE_RADIUS,
10
);
svgContainer.appendChild(newRelationArrow, 'new-relation-arrow');
const selectionMouseNodeVec = svgContainer.getEventMouseMove().getRelativePos().minus(selectedParticipantPos);
// Hide the "new relation" arrow if either we have no selected participant, or if
// the mouse hovers the selected participant node (to avoid having a visual glitch).
newRelationArrow.setHidden(
olympe.df.if(
selectedParticipant.getFlow().oEquals(null),
() => olympe.df.oBoolean(true),
() => olympe.df.transformFlows(
[selectionMouseNodeVec.getX(), selectionMouseNodeVec.getY()],
(x, y) => olympe.df.oBoolean(x * x + y * y < PARTICIPANT_NODE_RADIUS * PARTICIPANT_NODE_RADIUS + 50 * 50),
olympe.df.OBoolean
)
)
);
// When "clicking away", we would like to cancel the relation addition.
svgContainer.onClickOrTap(() => {
selectedParticipant.update(null);
});
///////////////////////////////////////////////////////////
participantsList.forEach((participant, key) => {
const rank = participantsList.getRank(key);
const position = computePosition(rank);
tag2pos.set(participant.getTag(), position);
const participantNode = this.renderParticipantNode(position, participant);
nodeGroup.appendChild(participantNode, 'participant-node-' + participant.getTag());
participantNode.onClickOrTap(() => {
const currentlySelectedParticipant = olympe.df.getCurrentValue(selectedParticipant.getFlow());
if (!currentlySelectedParticipant) {
selectedParticipant.update(participant);
} else if (currentlySelectedParticipant.getTag() !== participant.getTag()) {
datacloud.ParticipantController
.getInstance()
.setMet(currentlySelectedParticipant, participant)
.execute();
selectedParticipant.update(null);
}
});
participant.getMetParticipants().forEach(metParticipant => {
// To avoid division by zero, though this case should not happen.
if (participant.getTag() === metParticipant.getTag()) {
return;
}
const metArrow = this.renderArrow(
position,
tag2pos.get(metParticipant.getTag()),
PARTICIPANT_NODE_RADIUS,
PARTICIPANT_NODE_RADIUS
);
metGroup.appendChild(metArrow, participant.getTag() + '-met-' + metParticipant.getTag());
});
});
return layout;
}
/**
* @param {!olympe.df.PVector2} fromPos
* @param {!olympe.df.PVector2} toPos
* @param {number} fromOffset
* @param {number} toOffset
* @param {!olympe.df.Color} color
* @return {!olympe.ui.vectorial.Group}
*/
renderArrow(fromPos, toPos, fromOffset = 0, toOffset = 0, color = olympe.df.Color.black()) {
const unitVec = toPos.minus(fromPos).normalize();
const group = new olympe.ui.vectorial.Group();
const shiftedFromPos = fromOffset ? fromPos.plus(unitVec.multiplyScalar(fromOffset)) : fromPos;
const shiftedToPos = toOffset ? toPos.minus(unitVec.multiplyScalar(toOffset)) : toPos;
const line = new olympe.ui.vectorial.Path()
.moveTo(shiftedFromPos)
.lineTo(shiftedToPos)
.setStrokeWidth(2.5)
.setStrokeColor(color);
const head = new olympe.ui.vectorial.Path()
.moveTo(new olympe.df.Vector2(0, 0))
.lineTo(new olympe.df.Vector2(5, 15))
.lineTo(new olympe.df.Vector2(0, 10))
.lineTo(new olympe.df.Vector2(-5, 15))
.setStrokeWidth(2.5)
.setStrokeColor(color)
.setFillColor(color)
.setClosed(true);
const angle = unitVec.getY().atan2(unitVec.getX()).plus(Math.PI / 2);
head.setPosition(shiftedToPos);
head.rotate(angle, shiftedToPos.getX().toggleSign(), shiftedToPos.getY().toggleSign());
group.appendChild(line, 'line');
group.appendChild(head, 'head');
return group;
}
/**
* @param {!olympe.df.Vector2} position
* @param {!datacloud.Participant} participant
* @return {!olympe.ui.vectorial.Group}
*/
renderParticipantNode(position, participant) {
const group = new olympe.ui.vectorial.Group();
group.appendChild(
new olympe.ui.vectorial.Circle()
.setRadius(PARTICIPANT_NODE_RADIUS)
.setPosition(position)
.setFillColor(olympe.df.Color.lime()),
'node'
);
const name = new olympe.ui.vectorial.Text();
name.setText(participant.getName()).setFontSize(14);
name.setPosition(
new olympe.df.Vector2(
position.getX().minus(name.getDimension().getX().div(2)),
position.getY().minus(NAME_AND_AGE_SPACING)
)
);
group.appendChild(name, 'name');
const age = new olympe.ui.vectorial.Text();
age.setText(participant.getAge().toOString()).setFontSize(14);
age.setPosition(
new olympe.df.Vector2(
position.getX().minus(age.getDimension().getX().div(2)),
position.getY().plus(age.getDimension().getY()).minus(NAME_AND_AGE_SPACING)
)
);
group.appendChild(age, 'age');
return group;
}
};
participantgraph.Graph.entry =
olympe.dc.Registry.registerSync('017401cd83717c170e26', participantgraph.Graph);
}
Removing "met" relations
The last step before completing this component is adding the feature of deleting of a relationship - by first selecting the relation and clicking on a "Remove relation" button.
For that, we need to introduce two new constants:
// src/GraphImpl.js, constants location, at the near-top of the file
const BUTTON_REMOVE_WIDTH = 120;
const BUTTON_REMOVE_HEIGHT = 40;
Next, we can write the instructions to highlight the selected relation (by simply re-drawing it in yellow) and create the "Remove relation" button.
// src/GraphImpl.js, in the render() method
/** @type {!olympe.df.FlowSource<[datacloud.Participant, datacloud.Participant]>} */
const selectedRelation = olympe.df.newFlowSource();
selectedRelation.update(null);
const selectedRelationArrow = this.renderArrow(
olympe.df.transformFlows(
[selectedRelation.getFlow()],
rel => rel ? tag2pos.get(rel[0].getTag()) : undefined,
olympe.df.Vector2
),
olympe.df.transformFlows(
[selectedRelation.getFlow()],
rel => rel ? tag2pos.get(rel[1].getTag()) : undefined,
olympe.df.Vector2
),
PARTICIPANT_NODE_RADIUS,
PARTICIPANT_NODE_RADIUS,
olympe.df.Color.yellow()
);
selectedRelationArrow.setHidden(selectedRelation.getFlow().oEquals(null));
svgContainer.appendChild(selectedRelationArrow, 'selected-relation-arrow');
const removeRelButton = new olympe.ui.std.Button(
new olympe.df.Vector2(BUTTON_REMOVE_WIDTH, BUTTON_REMOVE_HEIGHT),
'Remove relation'
);
layout.appendChild(removeRelButton, 'remove-rel-button', olympe.ui.common.HorizontalAlign.CENTER);
removeRelButton.setHidden(selectedRelation.getFlow().oEquals(null));
removeRelButton.onClickOrTap(() => {
const [from, to] = olympe.df.getCurrentValue(selectedRelation);
datacloud.ParticipantController
.getInstance()
.removeMet(from, to)
.execute();
selectedRelation.update(null);
});
We would also like to unselect a relationship if we click away from it:
// src/GraphImpl.js in the render() method
svgContainer.onClickOrTap(() => {
selectedParticipant.update(null);
selectedRelation.update(null); // <----------
});
Finally, we should attach a listener on the relation arrow to push
the relation pair in selectedRelation
when clicked:
// src/GraphImpl.js, in the render() method within participantsList.forEach
// ...
participant.getMetParticipants().forEach(metParticipant => {
// To avoid division by zero, though this case should not happen.
if (participant.getTag() === metParticipant.getTag()) {
return;
}
const metArrow = this.renderArrow(
position,
tag2pos.get(metParticipant.getTag()),
PARTICIPANT_NODE_RADIUS,
PARTICIPANT_NODE_RADIUS
);
// v v v
metArrow.onClickOrTap(() => { selectedRelation.update([participant, metParticipant]); });
// ^ ^ ^
metGroup.appendChild(metArrow, participant.getTag() + '-met-' + metParticipant.getTag());
});
Click to reveal the final code for
GraphImpl.js
// src/GraphImpl.js
{
const PARTICIPANT_NODE_RADIUS = 50;
const PARTICIPANT_NODE_SPACING = 40;
const NAME_AND_AGE_SPACING = 5;
const BUTTON_REMOVE_WIDTH = 120;
const BUTTON_REMOVE_HEIGHT = 40;
/**
* @extends {olympe.sc.ui.Component}
*/
participantgraph.Graph = class Graph extends olympe.sc.ui.Component {
/**
* @override
*/
render(context) {
const dimension = this.getDimension(context);
const layout = new olympe.ui.std.VerticalLayout();
layout.setDimension(dimension);
const svgContainer = new olympe.ui.vectorial.SvgContainer();
layout.appendChild(svgContainer, 'svg-container');
const nodeGroup = new olympe.ui.vectorial.Group();
const metGroup = new olympe.ui.vectorial.Group();
svgContainer.appendChild(metGroup, 'met-group');
svgContainer.appendChild(nodeGroup, 'node-group');
///////////////////////////////////////////////////////////
const participantsList = new olympe.dc.ListDef(datacloud.Participant.entry, [olympe.dc.Sync.instancesRel]);
const anglePerParticipantNode = olympe.df.pONumber(2 * Math.PI).div(participantsList.getSize());
const circleRadius =
olympe.df.transformFlows(
[anglePerParticipantNode],
a => (2 * PARTICIPANT_NODE_RADIUS + PARTICIPANT_NODE_SPACING) / (2 * Math.sin(a / 2)),
olympe.df.ONumber
);
const svgDim = circleRadius.plus(PARTICIPANT_NODE_RADIUS).mul(2).plus(40);
svgContainer.setPosition(
new olympe.df.Vector2(
layout.getWidth().minus(svgDim).div(2).returnsBiggest(0),
0
)
);
svgContainer.setDimension(
new olympe.df.Vector2(dimension.getX(), svgDim)
);
const circleCenter = new olympe.df.Vector2(svgDim.div(2), svgDim.div(2));
/**
* @param {!olympe.df.PONumber} rank
* @return {!olympe.df.Vector2}
*/
const computePosition = rank => {
const participantAngle = anglePerParticipantNode.mul(rank);
return new olympe.df.Vector2(
participantAngle.cos().mul(circleRadius).plus(circleCenter.getX()),
participantAngle.sin().mul(circleRadius).plus(circleCenter.getY()),
);
};
/** @type {!olympe.df.Map<olympe.df.Vector2>} */
const tag2pos = new olympe.df.Map(olympe.df.Vector2);
///////////////////////////////////////////////////////////
const selectedParticipant = olympe.df.newFlowSource(datacloud.Participant);
// No selected participant at the beginning.
selectedParticipant.update(null);
const selectedParticipantPos = olympe.df.transformFlows(
[selectedParticipant],
participant => participant ? tag2pos.get(participant.getTag()) : undefined,
olympe.df.Vector2
);
const newRelationArrow = this.renderArrow(
selectedParticipantPos,
svgContainer.getEventMouseMove().getRelativePos(),
PARTICIPANT_NODE_RADIUS,
10
);
svgContainer.appendChild(newRelationArrow, 'new-relation-arrow');
const selectionMouseNodeVec = svgContainer.getEventMouseMove().getRelativePos().minus(selectedParticipantPos);
// Hide the "new relation" arrow if either we have no selected participant, or if
// the mouse hovers the selected participant node (to avoid having a visual glitch).
newRelationArrow.setHidden(
olympe.df.if(
selectedParticipant.getFlow().oEquals(null),
() => olympe.df.oBoolean(true),
() => olympe.df.transformFlows(
[selectionMouseNodeVec.getX(), selectionMouseNodeVec.getY()],
(x, y) => olympe.df.oBoolean(x * x + y * y < PARTICIPANT_NODE_RADIUS * PARTICIPANT_NODE_RADIUS + 50 * 50),
olympe.df.OBoolean
)
)
);
///////////////////////////////////////////////////////////
/** @type {!olympe.df.FlowSource<[datacloud.Participant, datacloud.Participant]>} */
const selectedRelation = olympe.df.newFlowSource();
selectedRelation.update(null);
const selectedRelationArrow = this.renderArrow(
olympe.df.transformFlows(
[selectedRelation.getFlow()],
rel => rel ? tag2pos.get(rel[0].getTag()) : undefined,
olympe.df.Vector2
),
olympe.df.transformFlows(
[selectedRelation.getFlow()],
rel => rel ? tag2pos.get(rel[1].getTag()) : undefined,
olympe.df.Vector2
),
PARTICIPANT_NODE_RADIUS,
PARTICIPANT_NODE_RADIUS,
olympe.df.Color.yellow()
);
selectedRelationArrow.setHidden(selectedRelation.getFlow().oEquals(null));
svgContainer.appendChild(selectedRelationArrow, 'selected-relation-arrow');
const removeRelButton = new olympe.ui.std.Button(
new olympe.df.Vector2(BUTTON_REMOVE_WIDTH, BUTTON_REMOVE_HEIGHT),
'Remove relation'
);
layout.appendChild(removeRelButton, 'remove-rel-button', olympe.ui.common.HorizontalAlign.CENTER);
removeRelButton.setHidden(selectedRelation.getFlow().oEquals(null));
removeRelButton.onClickOrTap(() => {
const [from, to] = olympe.df.getCurrentValue(selectedRelation);
datacloud.ParticipantController
.getInstance()
.removeMet(from, to)
.execute();
selectedRelation.update(null);
});
///////////////////////////////////////////////////////////
// When "clicking away", we would like to cancel the relation addition or relation selection.
svgContainer.onClickOrTap(() => {
selectedParticipant.update(null);
selectedRelation.update(null);
});
///////////////////////////////////////////////////////////
participantsList.forEach((participant, key) => {
const rank = participantsList.getRank(key);
const position = computePosition(rank);
tag2pos.set(participant.getTag(), position);
const participantNode = this.renderParticipantNode(position, participant);
nodeGroup.appendChild(participantNode, 'participant-node-' + participant.getTag());
participantNode.onClickOrTap(() => {
const currentlySelectedParticipant = olympe.df.getCurrentValue(selectedParticipant.getFlow());
if (!currentlySelectedParticipant) {
selectedParticipant.update(participant);
} else if (currentlySelectedParticipant.getTag() !== participant.getTag()) {
datacloud.ParticipantController
.getInstance()
.setMet(currentlySelectedParticipant, participant)
.execute();
selectedParticipant.update(null);
}
});
participant.getMetParticipants().forEach(metParticipant => {
// To avoid division by zero, though this case should not happen.
if (participant.getTag() === metParticipant.getTag()) {
return;
}
const metArrow = this.renderArrow(
position,
tag2pos.get(metParticipant.getTag()),
PARTICIPANT_NODE_RADIUS,
PARTICIPANT_NODE_RADIUS
);
metArrow.onClickOrTap(() => { selectedRelation.update([participant, metParticipant]); });
metGroup.appendChild(metArrow, participant.getTag() + '-met-' + metParticipant.getTag());
});
});
return layout;
}
/**
* @param {!olympe.df.PVector2} fromPos
* @param {!olympe.df.PVector2} toPos
* @param {number} fromOffset
* @param {number} toOffset
* @param {!olympe.df.Color} color
* @return {!olympe.ui.vectorial.Group}
*/
renderArrow(fromPos, toPos, fromOffset = 0, toOffset = 0, color = olympe.df.Color.black()) {
const unitVec = toPos.minus(fromPos).normalize();
const group = new olympe.ui.vectorial.Group();
const shiftedFromPos = fromOffset ? fromPos.plus(unitVec.multiplyScalar(fromOffset)) : fromPos;
const shiftedToPos = toOffset ? toPos.minus(unitVec.multiplyScalar(toOffset)) : toPos;
const line = new olympe.ui.vectorial.Path()
.moveTo(shiftedFromPos)
.lineTo(shiftedToPos)
.setStrokeWidth(2.5)
.setStrokeColor(color);
const head = new olympe.ui.vectorial.Path()
.moveTo(new olympe.df.Vector2(0, 0))
.lineTo(new olympe.df.Vector2(5, 15))
.lineTo(new olympe.df.Vector2(0, 10))
.lineTo(new olympe.df.Vector2(-5, 15))
.setStrokeWidth(2.5)
.setStrokeColor(color)
.setFillColor(color)
.setClosed(true);
const angle = unitVec.getY().atan2(unitVec.getX()).plus(Math.PI / 2);
head.setPosition(shiftedToPos);
head.rotate(angle, shiftedToPos.getX().toggleSign(), shiftedToPos.getY().toggleSign());
group.appendChild(line, 'line');
group.appendChild(head, 'head');
return group;
}
/**
* @param {!olympe.df.Vector2} position
* @param {!datacloud.Participant} participant
* @return {!olympe.ui.vectorial.Group}
*/
renderParticipantNode(position, participant) {
const group = new olympe.ui.vectorial.Group();
group.appendChild(
new olympe.ui.vectorial.Circle()
.setRadius(PARTICIPANT_NODE_RADIUS)
.setPosition(position)
.setFillColor(olympe.df.Color.lime()),
'node'
);
const name = new olympe.ui.vectorial.Text();
name.setText(participant.getName()).setFontSize(14);
name.setPosition(
new olympe.df.Vector2(
position.getX().minus(name.getDimension().getX().div(2)),
position.getY().minus(NAME_AND_AGE_SPACING)
)
);
group.appendChild(name, 'name');
const age = new olympe.ui.vectorial.Text();
age.setText(participant.getAge().toOString()).setFontSize(14);
age.setPosition(
new olympe.df.Vector2(
position.getX().minus(age.getDimension().getX().div(2)),
position.getY().plus(age.getDimension().getY()).minus(NAME_AND_AGE_SPACING)
)
);
group.appendChild(age, 'age');
return group;
}
};
participantgraph.Graph.entry =
olympe.dc.Registry.registerSync('017401cd83717c170e26', participantgraph.Graph);
}
Appendix: Exporting and importing projects
The video below shows how to export and import a project back after a gulp rst
: