Appearance
Canvas layer export new
This example shows how to use layers extensively to enhance your visualisation with contextual data. The backgrounds are showing the shapes of the clusters and the donut charts are illustrating the content of the clusters
ts
import Ogma, { NodeId } from '@linkurious/ogma';
import { ArcData, renderDonutChart } from './chart';
import * as d3 from 'd3';
import { annotation as d3annotation } from 'd3-svg-annotation';
// missing types for `alea` package
declare class Alea {
constructor(seed: number);
next(): number;
}
type PRNG = () => number;
// seeded random number generator
const prng = new Alea(new Date().getFullYear());
interface NodeData {
cluster: number;
}
interface Shape {
id: number;
x: number;
y: number;
radius: number;
color: string;
}
const ogma = new Ogma<NodeData>({
container: 'graph-container'
});
ogma.styles.addNodeRule({
outerStroke: {
scalingMethod: 'scaled',
width: 1,
color: '#777'
},
radius: n => n!.getDegree() * 3
});
const NUM_CLUSTERS = 3;
const alpha = 0.15;
const shapes: Shape[] = [];
let grouping = new Map<number, NodeId[]>();
const arcData: Record<number, ArcData[]> = {};
const data = [
{ label: '<5', value: 2704659 },
{ label: '5-13', value: 4499890 },
{ label: '14-17', value: 2159981 },
{ label: '18-24', value: 3853788 },
{ label: '25-44', value: 14106543 },
{ label: '45-64', value: 8819342 },
{ label: '≥65', value: 612463 }
];
// @ts-ignore
const colors: string[] = chroma
.scale(['orange', '#2A4858'])
.mode('lch')
.colors(3);
const clusterColors = new Map<number, string>();
const underlay = ogma.layers.addCanvasLayer(ctx => {
shapes.forEach(shape => {
ctx.globalAlpha = alpha;
ctx.fillStyle = shape.color;
ctx.beginPath();
ctx.moveTo(shape.x, shape.y + shape.radius);
ctx.arc(shape.x, shape.y, shape.radius, 0, 2 * Math.PI);
ctx.closePath();
ctx.fill();
});
});
underlay.moveToBottom();
const overlay = ogma.layers.addCanvasLayer(ctx => {
shapes.forEach(shape => {
renderDonutChart(ctx, arcData[shape.id], shape.radius, shape.x, shape.y);
});
});
overlay.moveToTop();
const svgLayer = ogma.layers.addSVGLayer({
element: document.createElementNS('http://www.w3.org/2000/svg', 'svg'),
draw: svg => {
svg.innerHTML = '';
shapes
.sort((a, b) => a.x - b.x)
.forEach((shape, i) => {
// renderDonutChart(ctx, arcData[shape.id], shape.radius, shape.x, shape.y);
const annotations = [
{
note: { title: `Cluster number ${i}`, wrap: 250 },
x: shape.x + shape.radius * 1.3 * (i === 0 ? -1 : 1),
y: shape.y,
dy: -shape.radius / 4,
dx: (i === 0 ? -1 : 1) * shape.radius * 0.7,
subject: { radius: 50, radiusPadding: 10 }
}
];
d3.select(svg)
.append('g')
.attr('class', 'annotation-group')
.call(d3annotation().annotations(annotations));
});
}
});
function updateGroups() {
shapes.length = 0;
grouping.forEach((list, id) => {
const { x, y, radius } = ogma.algorithms.getMinimumEnclosingCircle(
ogma.getNodes(list)
);
shapes.push({ x, y, radius, color: clusterColors.get(id) as string, id });
});
}
ogma.events
.on('nodesDragProgress', () => {
updateGroups();
underlay.refresh();
overlay.refresh();
svgLayer.refresh();
})
.on('click', ({ x: sx, y: sy, target }) => {
const { x, y } = ogma.view.screenToGraphCoordinates({ x: sx, y: sy });
ogma.getSelectedNodes().setSelected(false);
if (target) return;
for (const shape of shapes) {
if (Math.hypot(x - shape.x, y - shape.y) < shape.radius) {
const nodes = grouping.get(shape.id);
if (nodes) ogma.getNodes(nodes).setSelected(true);
break;
}
}
});
const graph = await Ogma.parse.jsonFromUrl<NodeData>('data.json');
await ogma.setGraph(graph);
await ogma.layouts.force({ edgeStrength: 15, locate: true });
// @ts-ignore
const pie = d3
.pie<{ label: string; value: number }>()
.sort(null)
// @ts-ignore
.value(d => d.value);
grouping = ogma.getNodes().reduce<Map<number, NodeId[]>>((acc, node) => {
const cluster = node.getData('cluster');
if (!acc.has(cluster)) {
acc.set(cluster, []);
clusterColors.set(cluster, colors[cluster]);
arcData[cluster] = pie(
data.map(({ label }) => ({
label,
value: Math.round(prng.next() * 7e6)
}))
);
}
(acc.get(cluster) as NodeId[]).push(node.getId());
return acc;
}, new Map());
updateGroups();
underlay.refresh();
overlay.refresh();
svgLayer.moveToTop();
svgLayer.refresh();
document.querySelector('#export')?.addEventListener('click', () => {
ogma.export.png({
width: 2048,
height: 2048,
margin: 200,
background: 'white'
});
});
const layers = [underlay, overlay];
ogma.events.on('dragProgress', () => {
layers.forEach(layer => layer.refresh && layer.refresh());
});
html
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<script src="https://cdn.jsdelivr.net/npm/d3@7.1.1/dist/d3.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chroma-js@2.4.2/chroma.min.js"></script>
<link type="text/css" rel="stylesheet" href="styles.css" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/iconoir-icons/iconoir@main/css/iconoir.css"
/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3-annotation/2.5.1/d3-annotation.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/alea"></script>
</head>
<body>
<div id="graph-container"></div>
<button id="export" title="Download">
<i class="iconoir-download"></i>
</button>
<script type="module" src="index.ts"></script>
</body>
</html>
css
#graph-container {
top: 0;
bottom: 0;
left: 0;
right: 0;
position: absolute;
margin: 0;
overflow: hidden;
}
#export {
z-index: 1000;
font-size: 3em;
position: absolute;
background-color: white;
cursor: pointer;
border-radius: 50%;
font-size: 1.2em;
width: 32px;
height: 32px;
}
#export > i {
margin: 0px 0 0 -2px;
}
text.title {
font-size: 1.2em;
}
.annotation-note {
font-size: 2em;
}
ts
import * as d3 from 'd3';
export interface ArcData {
data: { label: string };
endAngle: number;
index: number;
padAngle: number;
startAngle: number;
value: number;
}
const colors = [
'#98abc5',
'#8a89a6',
'#7b6888',
'#6b486b',
'#a05d56',
'#d0743c',
'#ff8c00'
];
/**
*
* @param {CanvasRenderingContext2D} ctx
* @param {number} x
* @param {number} y
*/
export function renderDonutChart(
ctx: CanvasRenderingContext2D,
arcs: ArcData[],
radius: number,
x: number,
y: number
) {
ctx.globalAlpha = 1;
// @ts-ignore
const arc = d3
.arc()
.outerRadius(radius + radius * 0.1)
.innerRadius(radius + radius * 0.3);
// @ts-ignore
const labelArc = d3
.arc()
.outerRadius(radius + radius * 0.2)
.innerRadius(radius + radius * 0.2);
ctx.save();
ctx.translate(x, y);
const drawArc = arc.context(ctx);
arcs.forEach((d, i) => {
ctx.beginPath();
drawArc(d);
ctx.fillStyle = colors[i];
ctx.fill();
});
ctx.beginPath();
arcs.forEach(arc);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 0.1;
ctx.stroke();
ctx.font = '24px Georgia, Times, serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#fff';
const drawLabelArc = labelArc.context(ctx);
arcs.forEach(d => {
const [x, y] = drawLabelArc.centroid(d);
ctx.fillText(d.data.label, x, y);
});
ctx.restore();
}
json
{
"nodes": [
{
"id": 0,
"attributes": {
"radius": 51
},
"data": {
"cluster": 0
}
},
{
"id": 1,
"attributes": {
"radius": 30
},
"data": {
"cluster": 0
}
},
{
"id": 2,
"attributes": {
"radius": 60
},
"data": {
"cluster": 0
}
},
{
"id": 3,
"attributes": {
"radius": 72
},
"data": {
"cluster": 0
}
},
{
"id": 4,
"attributes": {
"radius": 63
},
"data": {
"cluster": 0
}
},
{
"id": 5,
"attributes": {
"radius": 12
},
"data": {
"cluster": 0
}
},
{
"id": 6,
"attributes": {
"radius": 21
},
"data": {
"cluster": 0
}
},
{
"id": 7,
"attributes": {
"radius": 45
},
"data": {
"cluster": 0
}
},
{
"id": 8,
"attributes": {
"radius": 9
},
"data": {
"cluster": 0
}
},
{
"id": 9,
"attributes": {
"radius": 6
},
"data": {
"cluster": 0
}
},
{
"id": 10,
"attributes": {
"radius": 48
},
"data": {
"cluster": 0
}
},
{
"id": 11,
"attributes": {
"radius": 15
},
"data": {
"cluster": 0
}
},
{
"id": 12,
"attributes": {
"radius": 6
},
"data": {
"cluster": 0
}
},
{
"id": 13,
"attributes": {
"radius": 18
},
"data": {
"cluster": 0
}
},
{
"id": 14,
"attributes": {
"radius": 15
},
"data": {
"cluster": 0
}
},
{
"id": 15,
"attributes": {
"radius": 18
},
"data": {
"cluster": 0
}
},
{
"id": 16,
"attributes": {
"radius": 6
},
"data": {
"cluster": 0
}
},
{
"id": 17,
"attributes": {
"radius": 6
},
"data": {
"cluster": 0
}
},
{
"id": 18,
"attributes": {
"radius": 9
},
"data": {
"cluster": 0
}
},
{
"id": 19,
"attributes": {
"radius": 18
},
"data": {
"cluster": 0
}
},
{
"id": 20,
"attributes": {
"radius": 12
},
"data": {
"cluster": 0
}
},
{
"id": 21,
"attributes": {
"radius": 21
},
"data": {
"cluster": 0
}
},
{
"id": 22,
"attributes": {
"radius": 6
},
"data": {
"cluster": 0
}
},
{
"id": 23,
"attributes": {
"radius": 18
},
"data": {
"cluster": 0
}
},
{
"id": 24,
"attributes": {
"radius": 9
},
"data": {
"cluster": 0
}
},
{
"id": 25,
"attributes": {
"radius": 15
},
"data": {
"cluster": 0
}
},
{
"id": 26,
"attributes": {
"radius": 12
},
"data": {
"cluster": 0
}
},
{
"id": 27,
"attributes": {
"radius": 18
},
"data": {
"cluster": 0
}
},
{
"id": 28,
"attributes": {
"radius": 6
},
"data": {
"cluster": 0
}
},
{
"id": 29,
"attributes": {
"radius": 12
},
"data": {
"cluster": 0
}
},
{
"id": 30,
"attributes": {
"radius": 9
},
"data": {
"cluster": 0
}
},
{
"id": 31,
"attributes": {
"radius": 6
},
"data": {
"cluster": 0
}
},
{
"id": 32,
"attributes": {
"radius": 12
},
"data": {
"cluster": 0
}
},
{
"id": 33,
"attributes": {
"radius": 12
},
"data": {
"cluster": 0
}
},
{
"id": 34,
"attributes": {
"radius": 9
},
"data": {
"cluster": 0
}
},
{
"id": 35,
"attributes": {
"radius": 6
},
"data": {
"cluster": 0
}
},
{
"id": 36,
"attributes": {
"radius": 12
},
"data": {
"cluster": 0
}
},
{
"id": 37,
"attributes": {
"radius": 6
},
"data": {
"cluster": 0
}
},
{
"id": 38,
"attributes": {
"radius": 9
},
"data": {
"cluster": 0
}
},
{
"id": 39,
"attributes": {
"radius": 6
},
"data": {
"cluster": 0
}
},
{
"id": 40,
"attributes": {
"radius": 27
},
"data": {
...