Appearance
Visual grouping clustering
This example show how to use the node grouping transformation to group a large graph.
ArcticWEB is an academic initiative to create an international network of scientists dedicated to conduct active multi-disciplinary research on ecosystem processes in the Arctic. See how Ogma allows you to group articles by topic while still showing the inner connections inside of the fields.
ts
import Ogma, { Color, NodeId, type NodesEvent } from '@linkurious/ogma';
import chroma from 'chroma-js';
import { categorical16 } from '@linkurious/ogma-styles/colors';
type NodeData = { topic: string; groupId: string };
const ogma = new Ogma<NodeData>({
container: 'graph-container',
options: { backgroundColor: '#f5f6f6' }
});
const formatRGBA = (color: [number, number, number, number]) =>
`rgba(${color.join(', ')})`;
const toggleButton = (on: boolean) => {
const button = document.getElementById('group-btn') as HTMLButtonElement;
button.innerHTML = on ? 'Ungroup' : 'Group articles by field';
};
// toggle group layout button based on whether there are selected groups
const onSelectionChange = <ND, ED>(evt: NodesEvent<ND, ED>) => {
const nodes = evt.nodes;
const metaNodes = nodes.filter(node => {
return node.getSubNodes() !== null;
});
const button = document.getElementById(
'group-layout-btn'
) as HTMLButtonElement;
if (
metaNodes.isSelected().some(selected => {
return selected;
})
) {
button.removeAttribute('disabled');
} else {
button.setAttribute('disabled', 'disabled');
}
};
ogma.events
.on('nodesUnselected', onSelectionChange)
.on('nodesSelected', onSelectionChange);
ogma.styles.addRule({
nodeAttributes: {
innerStroke: { width: 0.1, scalingMethod: 'scaled', color: '#555' },
color: n => (!n.isVirtual() ? colorByTopic[n.getData('topic')] : undefined),
badges: {
topRight: {
text: {
content: n => {
const isClosed = n.getData('open') === false;
const count = n.getSubNodes()?.size || 0;
if (!isClosed || !count) return '';
return count > 1000 ? `${Math.round(count / 1000)}k` : count;
}
}
}
},
text: n => {
if (n.isVirtual()) {
return {
content: n.getAttribute('text') as string,
backgroundColor: '#444',
size: 14,
color: '#fff',
minVisibleSize: 2,
tip: false
};
}
},
radius: 14
},
edgeAttributes: {
color: '#444'
}
});
ogma.styles.setHoveredNodeAttributes({
text: { backgroundColor: '#222', color: '#fff' },
outerStroke: { color: '#0093ff' }
});
const graph = await Ogma.parse.jsonFromUrl<NodeData>('files/arctic.json');
let colorIndex = 0;
const colorByTopic = graph.nodes.reduce(
(acc, n) => {
const topic = n.data?.topic || 'Other';
if (!acc[topic]) {
acc[topic] = categorical16[colorIndex % categorical16.length];
colorIndex++;
}
return acc;
},
{} as Record<string, Color>
);
await ogma.setGraph(graph);
await ogma.view.locateGraph();
const legend = document.createElement('div');
legend.setAttribute('id', 'legend');
const legendHtml = `<ul>${Object.keys(colorByTopic)
.reverse()
.map(name => {
const color = colorByTopic[name];
return `<li><div class="color" style="background-color: ${color}"></div>${name}</li>`;
})
.join('\n')}</ul>`;
legend.innerHTML = legendHtml;
document.body.appendChild(legend);
const openFirst = new Set<NodeId>(Object.keys(colorByTopic));
const grouping = ogma.transformations.addNodeGrouping({
groupIdFunction: node => node.getData('topic'),
nodeGenerator: (_, topic) => ({
id: 'special group ' + topic,
data: {
topic,
open: openFirst.has(topic)
},
attributes: {
text: topic,
color: formatRGBA(chroma(colorByTopic[topic]).brighten(0.7).rgba()),
opacity: 0.72
}
}),
edgeGenerator: (edges, id) => ({
id: id,
attributes: {
// logarithmic scale for the grouped edges width
width: 1 + Math.log(edges.size),
opacity: 0.5
}
}),
duration: 200,
showContents: metaNode => metaNode.getData('open'),
onGroupUpdate: (metaNode, subNodes, open) => {
if (open) {
return {
layout: 'force',
params: {
gpu: true
}
};
}
},
enabled: false
});
document.getElementById('group-btn')!.addEventListener('click', async () => {
await grouping.toggle();
toggleButton(grouping.isEnabled());
});
document.getElementById('group-layout-btn')!.addEventListener('click', () => {
ogma.layouts.force({
gpu: true,
nodes: ogma.getSelectedNodes().get(0).getSubNodes()!
});
});
const layoutGraph = () =>
ogma.layouts.force({
gpu: true,
locate: true
});
document.getElementById('layout-btn')!.addEventListener('click', layoutGraph);
await grouping.toggle();
await layoutGraph();
toggleButton(true);
ogma.events.on('doubleclick', async ({ target }) => {
if (target && target.isNode && target.isVirtual()) {
const node = target;
if (node.getData('open')) {
const id = node.getId();
if (openFirst.has(id)) {
openFirst.delete(id);
return;
}
const subNodesPositions = node.getSubNodes()!.map(n => n.getPosition());
ogma.transformations.collapseGroup({
node,
onCollapse: () => {
node.setData('open', false);
node.setData(
'offsets',
subNodesPositions.map(p => ({
x: p.x - node.getPosition().x,
y: p.y - node.getPosition().y
}))
);
}
});
} else {
ogma.transformations.expandGroup({
node,
computeSubNodesPosition: async (target, subNodes) => {
const offsets = node.getData('offsets');
if (!offsets) {
return ogma.layouts.force({
gpu: true,
nodes: subNodes,
duration: 0,
useWebWorker: false
});
}
const origin = node.getPosition();
return Promise.resolve(
subNodes.map((n, i) => {
const offset = offsets[i];
return {
x: origin.x + offset.x,
y: origin.y + offset.y
};
})
);
},
onExpand: () => {
node.setData('open', true);
}
});
}
}
});html
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<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"
/>
<link type="text/css" rel="stylesheet" href="styles.css" />
</head>
<body>
<div id="graph-container"></div>
<div class="panel">
<div class="section">
<button id="group-btn">Group articles by field</button>
</div>
<div class="section">
<button id="group-layout-btn" disabled>Layout selected group</button>
</div>
<div class="section">
<button id="layout-btn">Layout graph</button>
</div>
</div>
<script src="index.ts" type="module"></script>
</body>
</html>css
html,
body {
font-family: 'IBM Plex sans', sans-serif;
}
:root {
--base-color: #4999f7;
--base-font-family: 'IBM Plex Sans', sans-serif;
--active-color: var(--base-color);
--gray: #d9d9d9;
--white: #ffffff;
--lighter-gray: #f4f4f4;
--light-gray: #e6e6e6;
--inactive-color: #cee5ff;
--group-color: #525fe1;
--group-inactive-color: #c2c8ff;
--selection-color: #04ddcb;
--darker-gray: #b6b6b6;
--dark-gray: #555;
--dark-color: #3a3535;
--edge-color: var(--dark-color);
--border-radius: 5px;
--button-border-radius: var(--border-radius);
--edge-inactive-color: var(--light-gray);
--button-background-color: #ffffff;
--shadow-color: rgba(0, 0, 0, 0.25);
--shadow-hover-color: rgba(0, 0, 0, 0.5);
--button-shadow: 0 0 4px var(--shadow-color);
--button-shadow-hover: 0 0 4px var(--shadow-hover-color);
--button-icon-color: #000000;
--button-icon-hover-color: var(--active-color);
}
#graph-container {
top: 0;
bottom: 0;
left: 0;
right: 0;
position: absolute;
margin: 0;
overflow: hidden;
}
.ui {
position: absolute;
display: flex;
flex-direction: column;
gap: 0.5em;
}
#custom-group-btn {
top: 40px;
}
.panel,
#legend {
background: var(--button-background-color);
border-radius: var(--button-border-radius);
box-shadow: var(--button-shadow);
padding: 10px;
}
.panel {
position: absolute;
top: 20px;
left: 20px;
font-family: var(--base-font-family);
}
.panel h2 {
text-transform: uppercase;
font-weight: 400;
font-size: 14px;
margin: 0;
}
.panel .section {
margin-top: 1px;
padding: 5px 10px;
text-align: center;
}
.panel .section button {
background: var(--button-background-color);
border: none;
border-radius: var(--button-border-radius);
border-color: var(--shadow-color);
padding: 5px 10px;
cursor: pointer;
width: 100%;
color: var(--dark-gray);
border: 1px solid var(--light-gray);
font-family: var(--base-font-family);
}
.panel .section button:hover {
background: var(--lighter-gray);
border: 1px solid var(--darker-gray);
}
.panel .section button[disabled] {
color: var(--light-gray);
border: 1px solid var(--light-gray);
background-color: var(--lighter-gray);
}
#legend {
display: block;
position: absolute;
bottom: 20px;
left: 20px;
background: var(--button-background-color);
padding-right: 1em;
}
#legend ul {
list-style: none;
padding-left: 1em;
}
#legend ul li .color {
display: inline-block;
width: 1em;
height: 1em;
margin-right: 5px;
}