Appearance
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;
}
}
});
}