Skip to content
  1. Examples

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": {
 

...