Skip to content
  1. Examples

Circular layout (plugin)

This example shows how to implement a circular layout.

ts
import Ogma from '@linkurious/ogma';
import { circularLayout } from './circular';

const ogma = new Ogma({
  container: 'graph-container',
  options: {
    interactions: { drag: { enabled: false } }
  }
});

const NodeTypes = [
  'red',
  'green',
  'blue',
  'yellow',
  'orange',
  'purple',
  'pink',
  'brown',
  'grey',
  'black'
];

// color the types
ogma.styles.addNodeRule({
  color: node => node?.getData('type')
});

function degreesToRadians(degrees: number) {
  return (degrees * Math.PI) / 180;
}

// @ts-ignore
const gui = new dat.GUI();
// initial options
const options = {
  startAngle: 0,
  clockwise: true,
  sortBy: 'by size',
  distanceRatio: 0.25,
  duration: 500,
  run: () => {
    // get all nodes "as is"
    let nodes = ogma.getNodes();

    // figure out the sorting order
    if (options.sortBy === 'by size') {
      nodes = nodes.sort(
        (a, b) => +a.getAttribute('radius') - +b.getAttribute('radius')
      );
    } else if (options.sortBy === 'by type') {
      // group by types
      const typeMap = new Map();
      NodeTypes.forEach((type, i) => typeMap.set(type, i));
      nodes = nodes.sort((a, b) => {
        const aType = a.getData('type');
        const bType = b.getData('type');
        return typeMap.get(aType) - typeMap.get(bType);
      });
    }
    // read start angle, duration and direction
    const { startAngle, clockwise, duration, distanceRatio } = options;
    // keep the layout around the current graph centroid
    const { cx, cy } = nodes.getBoundingBox();
    // get the radii of the nodes
    const radii = nodes.getAttribute('radius');

    // get target positions from the layout
    const positions = circularLayout({
      radii,
      clockwise,
      startAngle: degreesToRadians(startAngle - 90),
      cx,
      cy,
      distanceRatio
    });

    // move camera to fit the target positions
    ogma.view.locateRawGraph({
      nodes: positions.map((pos, i) => {
        return { id: i, attributes: { ...pos, radius: radii[i] } };
      }),
      edges: []
    });
    return nodes.setAttributes(positions, duration);
  }
};
gui
  .add(options, 'startAngle', 0, 360)
  .step(1)
  .name('start angle')
  .onChange(options.run);
gui
  .add(options, 'distanceRatio', 0, 3)
  .step(0.05)
  .name('distance')
  .onChange(options.run);
gui
  .add(options, 'sortBy', ['by size', 'by type', 'random'])
  .onChange(options.run);
gui.add(options, 'clockwise').onChange(options.run);
gui.add(options, 'duration', 0, 10000).step(100).onChange(options.run);

gui.add(options, 'run').name('Run layout');

ogma.generate
  .random({ nodes: 120, edges: 140 })
  .then(graph => {
    graph.nodes.forEach(node => {
      node.attributes = node.attributes || {};
      node.attributes.radius = 5 + Math.random() * 30;
      node.data = {
        type: NodeTypes[Math.floor(Math.random() * NodeTypes.length)]
      };
    });
    return ogma.setGraph(graph);
  })
  .then(() => ogma.view.locateGraph())
  .then(() => ogma.view.setZoom(0.05, { ignoreZoomLimits: true }))
  .then(options.run);
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <script src="https://cdn.jsdelivr.net/npm/dat.gui@0.7.9/build/dat.gui.min.js"></script>
    <link type="text/css" rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <div id="graph-container"></div>
    <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;
}
ts
import type { Point, PixelSize } from '@linkurious/ogma';

interface Options {
  radii: PixelSize[] | number[];
  cx?: number;
  cy?: number;
  startAngle?: number;
  clockwise?: boolean;
  getRadius?: (radius: PixelSize) => number;
  distanceRatio?: number;
}

export function circularLayout({
  radii,
  clockwise = true,
  cx = 0,
  cy = 0,
  startAngle = (3 / 2) * Math.PI,
  getRadius = (radius: PixelSize) => Number(radius),
  distanceRatio = 0.0
}: Options): Point[] {
  const N = radii.length;
  // dummy checks
  if (N === 0) return [];
  if (N === 1) return [{ x: cx, y: cy }];

  // minDistance
  const minDistance =
    radii.map(getRadius).reduce((acc, r) => Math.max(acc, r), 0) *
    (2 + distanceRatio);

  const sweep = 2 * Math.PI - (2 * Math.PI) / N;
  const deltaAngle = sweep / Math.max(1, N - 1);

  const dcos = Math.cos(deltaAngle) - Math.cos(0);
  const dsin = Math.sin(deltaAngle) - Math.sin(0);

  const rMin = Math.sqrt(
    (minDistance * minDistance) / (dcos * dcos + dsin * dsin)
  );
  const r = Math.max(rMin, 0);

  return radii.map((_, i) => {
    const angle = startAngle + i * deltaAngle * (clockwise ? 1 : -1);

    const rx = r * Math.cos(angle);
    const ry = r * Math.sin(angle);
    return {
      x: cx + rx,
      y: cy + ry
    };
  });
}