Appearance
Visual grouping of arctic web
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, { NodeId, type NodesEvent } from '@linkurious/ogma';
import chroma from 'chroma-js';
const ogma = new Ogma({
container: 'graph-container',
options: { backgroundColor: '#f5f6f6' }
});
const formatRGBA = (color: [number, number, number, number]) =>
`rgba(${color.join(', ')})`;
const names = {
'rgba(51, 153, 255, 1)': 'Arctic, communities',
'rgba(0, 204, 204, 1)': 'Research',
'rgba(153, 255, 255, 1)': 'Population',
'rgba(255, 204, 51, 1)': 'Aerospace',
'rgba(255, 204, 102, 1)': 'Geosciences',
'rgba(255, 255, 51, 1)': 'Physics',
'rgba(102, 0, 102, 1)': 'Petrol',
'rgba(153, 0, 0, 1)': 'Political Studies',
'rgba(102, 102, 0, 1)': 'Pollution',
'rgba(0, 153, 0, 1)': 'Energy resources',
'rgba(153, 255, 0, 1)': 'Biodiversity',
'rgba(0, 204, 51, 1)': 'Global warming'
};
type TopicColor = keyof typeof names;
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' }
},
edgeAttributes: {
color: '#444'
}
});
const graph = await Ogma.parse.gexfFromUrl('files/arctic.gexf');
await ogma.setGraph(graph);
await ogma.view.locateGraph();
const legend = document.createElement('div');
legend.setAttribute('id', 'legend');
const legendHtml = `<ul>${Object.keys(names)
.map(color => {
const name = names[color as TopicColor];
return `<li><div class="color" style="background-color: ${color}"></div>${name}</li>`;
})
.join('\n')}</ul>`;
legend.innerHTML = legendHtml;
document.body.appendChild(legend);
const byColor = ogma
.getNodes()
.getAttribute('color')
.reduce(
(acc, color) => {
acc[color as TopicColor] = [];
return acc;
},
{} as Record<TopicColor, NodeId[]>
);
ogma.getNodes().forEach(n => {
byColor[n.getAttribute('color') as TopicColor].push(n.getId());
});
Object.keys(byColor).forEach(color => {
ogma.getNodes(byColor[color as TopicColor]).fillData('groupId', color);
});
const openFirst = new Set<NodeId>([
'rgba(153, 0, 0, 1)',
'rgba(0, 153, 0, 1)',
'rgba(153, 255, 0, 1)'
]);
const grouping = ogma.transformations.addNodeGrouping({
groupIdFunction: node => node.getData('groupId'),
nodeGenerator: (nodes, groupId) => ({
id: 'special group ' + groupId,
data: {
groupId: groupId,
open: openFirst.has(groupId)
},
attributes: {
radius: 50,
text: names[groupId as TopicColor] || 'Other',
color: formatRGBA(chroma(groupId).brighten(0.7).rgba()),
opacity: 0.82
}
}),
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 ogma.layouts.force({
nodes: subNodes,
duration: 0,
useWebWorker: false
});
}
},
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({
nodes: ogma.getSelectedNodes().get(0).getSubNodes()!
});
});
const layoutGraph = () =>
ogma.layouts.force({
gpu: true,
charge: 0.125,
gravity: 0.01,
edgeStrength: 1,
theta: 0.9,
locate: true
});
document.getElementById('layout-btn')!.addEventListener('click', layoutGraph);
await grouping.toggle();
await layoutGraph();
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({
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;
}