Support

Support

  • Olympe Website

›Tutorials

Olympe support overview

  • Orchestrator
  • Olympe Training Plan

DRAW

    Getting Started

    • Introduction
    • Content Organization
    • User Interface - Main concepts
    • User Interface - Functions
    • User Interface - Custom Visual Components
    • User Interface - Interactions
    • User Interface - Navigation
    • Data - Data Models
    • Data - Data Sets
    • Logic - Functions vs Actions
    • Create and modify data
    • Query Data
    • Transition with data
    • Generate Rest API connectors from OpenAPI files
    • Themes
    • Project dependencies
    • Export & Import

    Tutorials

    • Simple App Example
    • Expense App Tutorial

CODE

  • CODE API
  • Tutorials

    • Bootstrap a CODE development environment
    • Initialize the versioning system
    • Extend DRAW with custom logic bricks made with CODE
    • Extend DRAW with custom visual bricks made with CODE
    • Retrieve data from Olympe datacloud, transform it and display it in a custom Visual Component
    • Modifying the data on the data cloud
    • Visualizing participants interaction with a graph

    Further reading

    • A Beginner's Guide to Dataflows

Platform

  • VM Supported Hosts

Release notes

  • Archives DRAW
  • Archives CODE
  • Latest Releases

Demos

  • Charts Example
  • Workflows Management

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.

Your browser does not support MP4 files.

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.

Your browser does not support MP4 files.

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:

Your browser does not support MP4 files.

Creating the participantgraph module

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 inside modules and src within participantgraph

  • We create a _ns.js file (in src) declaring the participantgraph namespace:

    /**
     * @namespace
     */
    const participantgraph = {};
    
  • We add a GraphImpl.js file contained in src 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 root module.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.

Your browser does not support MP4 files.

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:

radius-formula

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:

radius-formula

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:

Your browser does not support MP4 files.

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:

Your browser does not support MP4 files.

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 derive selectedParticipantPos from the selectedParticipant.getFlow() dataflow using the tag2pos map and the transformFlows 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).

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);
    }
});
Your browser does not support MP4 files.

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());
});
Your browser does not support MP4 files.

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:

Your browser does not support MP4 files.
← Modifying the data on the data cloudA Beginner's Guide to Dataflows →
  • Introduction
    • Prerequisites
    • Outcome
  • Preamble: creating the "met" relationship
  • Creating the participantgraph module
  • Placing and rendering participant nodes
  • Displaying relationships
  • Adding "met" relations
  • Removing "met" relations
  • Appendix: Exporting and importing projects
Olympe Website
Copyright © 2021 Olympe