Skip to content
  1. Examples

Real-time collaboration

This is an example of how you can create real-time collaborative visualisation with Ogma. It is using   Y.js for the CRDT solution and Websocket for the communication between the clients.

ts
import { App } from './app';

// this is needed because of the playground environment.
// in a real app, you can skip it to the App initialization part

// normally you would not be initializing them in the same context,
// you would have different users in opening the app on different machines
// room data gets refreshed dayly in this example
const roomId = 'room-id-' + new Date().toDateString().replace(/ /g, '-');
// we use a public websocket server for this example
const url = 'wss://ogma-yjs-server.linkurious.com';
const app1 = App(url, roomId, '1');
const app2 = App(url, roomId, '2');
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />

    <link type="text/css" rel="stylesheet" href="styles.css" />
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap"
      rel="stylesheet"
    />
  </head>

  <body>
    <div class="container">
      <div id="graph-container-user1" class="graph-container"></div>
      <div id="graph-container-user2" class="graph-container"></div>
    </div>
    <script type="module" src="index.ts"></script>
  </body>
</html>
css
html,
body {
  height: 100%;
  margin: 0;
  font-family: 'IBM Plex Sans', Arial, Helvetica, sans-serif;
}

.container {
  height: 100%;
  min-height: 100%;
  display: flex;
  flex-direction: row;
  background: #ddd;
}

.graph-container {
  flex: 1;
  display: flex;
  overflow: hidden;
  border: 1px dotted #aaa;
}

.controls {
  margin: 10px;
  font-family: 'IBM Plex Sans', Calibri, 'Trebuchet MS', sans-serif;
  padding: 1em;
  background: #fff;
  border-radius: 0.375em;
  box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
  align-items: center;
  display: flex;
  flex-direction: column;
}

.cursor-svg text {
  font-family: 'IBM Plex Sans', Calibri, 'Trebuchet MS', sans-serif;
  fill: #222;
}

.controls button {
  flex: 1;
  align-self: center;
  margin: 0.5em;
  font-family: 'IBM Plex Sans', Calibri, 'Trebuchet MS', sans-serif;
}

.cursor-svg {
  transition: transform 0.25s cubic-bezier(0.17, 0.93, 0.38, 1);
  transform: translateX(0px) translateY(0px);
}
ts
import Ogma, {
  NodeAttributesValue,
  Node,
  RawEdge,
  RawNode,
  NodeList
} from '@linkurious/ogma';

import { v4 as uuid } from 'uuid';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

console.log({ uuid, Y, WebsocketProvider });

export function App(url: string, room: string, user: string) {
  const ogma = new Ogma({
    container: `graph-container-user${user}`,
    options: {
      backgroundColor: '#dfdfdf'
    }
  });

  const textStyle: NodeAttributesValue<any, any>['text'] = {
    backgroundColor: 'black',
    color: 'white',
    minVisibleSize: 0,
    font: 'IBM Plex Sans',
    content: (node?: Node) => node!.getId().toString().substring(0, 6)
  };

  ogma.styles.addNodeRule({
    text: textStyle
  });
  ogma.styles.setHoveredNodeAttributes({
    text: textStyle
  });

  const doc = new Y.Doc({ guid: uuid() });
  const wsProvider = new WebsocketProvider(url, `ogma-${room}`, doc);

  const yNodesMap = doc.getMap<RawNode>('nodesMap');
  const yEdgesMap = doc.getMap<RawEdge>('edgesMap');

  wsProvider.on('synced', () => {
    ogma.addGraph({
      nodes: Array.from(yNodesMap.values()),
      edges: Array.from(yEdgesMap.values())
    });

    yEdgesMap.observe(evt => {
      evt.changes.keys.forEach((key, value) => {
        if (key.action === 'add') {
          ogma.addEdge(yEdgesMap.get(value) as RawEdge);
        } else if (key.action === 'delete') {
          ogma.removeEdge(value);
        } else if (key.action === 'update') {
          ogma
            .getEdge(value)
            ?.setAttributes((yEdgesMap.get(value) as RawEdge).attributes!);
        }
      });
    });

    yNodesMap.observe(evt => {
      evt.changes.keys.forEach((key, value) => {
        if (key.action === 'add') {
          ogma.addNode(yNodesMap.get(value) as RawNode);
        } else if (key.action === 'delete') {
          ogma.removeNode(value);
        } else if (key.action === 'update') {
          ogma
            .getNode(value)
            ?.setAttributes((yNodesMap.get(value) as RawNode).attributes!);
        }
      });
    });
  });

  const controls = ogma.layers.addLayer(`
    <div class="controls">
      <div class="title">User ${user}</div>
      <button class="add-node">Add node</button>
      <button class="remove-node" title="Remove selected node">Remove node</button>
      <button class="add-edge" title="Add edge between selected nodes" disabled>Add edge</button>
    </div>
  `);

  let selectedNodes: NodeList | null = null;
  ogma.events
    .on('nodesDragEnd', evt => {
      evt.nodes.forEach(node => {
        // TODO: don't send the selected style!
        const rawNode = node.toJSON({ attributes: ['x', 'y'] });
        // send new position
        yNodesMap.set(rawNode.id!.toString(), rawNode);
      });
    })
    // allow creating edges only when two nodes are selected
    .on('nodesSelected', () => {
      const addEdgeButton = controls.element.querySelector('.add-edge')!;
      if (ogma.getSelectedNodes().getId().length === 2) {
        selectedNodes = ogma.getSelectedNodes();
        addEdgeButton.removeAttribute('disabled');
      } else {
        selectedNodes = null;
        addEdgeButton.setAttribute('disabled', 'true');
      }
    });

  controls.element.addEventListener('click', evt => {
    evt.stopPropagation();
    evt.preventDefault();
  });

  // add node button
  controls.element
    .querySelector<HTMLButtonElement>('.add-node')!
    .addEventListener('click', () => {
      const rawNode = { id: uuid() };
      const node = ogma.addNode(rawNode);
      ogma.layouts.force().then(() => {
        // send update
        yNodesMap.set(
          node.getId().toString(),
          node.toJSON({ attributes: 'all' })
        );
      });
    });

  // Add edges
  controls.element
    .querySelector('.remove-node')!
    .addEventListener('click', () => {
      const node = ogma.getNodes().get(ogma.getNodes().size - 1);
      const id = node.getId();
      ogma.removeNode(node);
      // send update
      yNodesMap.delete(id.toString());
    });

  // add edge between two selected nodes
  controls.element
    .querySelector('.add-edge')!
    .addEventListener('click', evt => {
      evt.stopPropagation();
      evt.preventDefault();

      const selected = selectedNodes as NodeList;
      const rawEdge = {
        source: selected.get(0).getId(),
        target: selected.get(1).getId()
      };
      const edge = ogma.addEdge(rawEdge);
      yEdgesMap.set(
        edge.getId().toString(),
        edge.toJSON({ attributes: 'all' })
      );
    });

  // cursor SVG arrow
  const arrowContainerSvg = document.createElementNS(
    'http://www.w3.org/2000/svg',
    'svg'
  );
  arrowContainerSvg.innerHTML = `
  <g class="cursor-svg">
    <g
      class="arrow"
      width="24"
      height="36"
      viewBox="0 0 24 36"
      fill="none"
      stroke="white"
    >
      <path
        d="M5.65376 12.3673H5.46026L5.31717 12.4976L0.500002 16.8829L0.500002 1.19841L11.7841 12.3673H5.65376Z"
        fill="red"
      />
    </g>
    <text dy="34" dx="-12" class="user-name"></text>
  </g>
  `;
  const arrowLayer = ogma.layers.addSVGLayer({
    element: arrowContainerSvg,
    draw: elt => {}
  });
  const awareness = wsProvider.awareness;

  // update cursor position
  const updateCursorPosition = (
    evtX: number,
    evtY: number,
    visible: boolean
  ) => {
    const { x, y } = ogma.view.screenToGraphCoordinates({ x: evtX, y: evtY });
    awareness.setLocalStateField('cursor', { x, y, user, visible });
  };

  // send cursor position to other users
  ogma.events.on('mousemove', evt => updateCursorPosition(evt.x, evt.y, true));
  // notify other users when you leave the graph
  ogma
    .getContainer()
    ?.addEventListener('mouseleave', evt =>
      updateCursorPosition(evt.x, evt.y, false)
    );

  const cursorLayer =
    arrowContainerSvg.querySelector<SVGGElement>('.cursor-svg')!;
  const cursorUserName =
    arrowContainerSvg.querySelector<SVGTextElement>('.user-name')!;

  let cursorFrame = 0;
  const hideCursor = () => {
    cancelAnimationFrame(cursorFrame);
    cursorLayer.style.visibility = 'hidden';
  };

  hideCursor();

  // move cursor when you get the updates from other users
  awareness.on('change', (changes: { updated: number[] }) => {
    for (const client of changes.updated) {
      if (client !== awareness.clientID) {
        const cursor = awareness.states.get(client)!.cursor;
        if (cursor.visible) {
          cursorFrame = requestAnimationFrame(() => {
            cursorUserName.innerHTML = `<tspan>User ${cursor.user}</tspan>`;
            cursorLayer.style.visibility = 'visible';
            cursorLayer.style.transform = `translate(${cursor.x}px, ${
              cursor.y
            }px) scale(${1 / ogma.view.getZoom()})`;
          });
        } else {
          hideCursor();
        }
        // in this demo we only show cursor of one user different than the local one
        break;
      }
    }
  });
}