Skip to content
  1. Examples

Clustering Middleware

This example demonstrates how the Clustering API works and compares it with the grouping API.
You can see that Clusters are much faster to apply to the graph because subnodes and subedges are not added added to the graph but just stored.

ts
import Ogma, { NodeClustering, type NodeGrouping } from '@linkurious/ogma';

interface NodeData {
  lang: string;
}

interface EdgeData {}
const ogma = new Ogma<NodeData, EdgeData>({
  container: 'graph-container'
});
ogma.styles.addNodeRule({
  text: {
    font: 'Roboto',
    size: 12
  },
  innerStroke: {
    minVisibleSize: 5
  }
});

type Mode = 'clustering' | 'grouping';

const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);

const modes: [Mode, Mode] = ['clustering', 'grouping'];
const dates = {} as Record<`start-${Mode}` | `update-${Mode}`, number>;
let i: 0 | 1 = 0;
let transformation:
  | NodeClustering<NodeData, EdgeData>
  | NodeGrouping<NodeData, EdgeData>;

const annotation = ogma.layers.addLayer(document.createElement('div'), 1);
const button = document.createElement('button');
const text = document.createElement('p');
annotation.element.classList.add('panel');

annotation.element.appendChild(button);
annotation.element.appendChild(text);

ogma.events.on(['addNodes', 'addEdges'], async () => {
  await ogma.view.afterNextFrame();
  updateInfo();
});
const total_grouping = 11258 + 12780;
const total_clustering = 11258 + 12780;

const isNodeClustering = (
  transformation: any
): transformation is NodeClustering<NodeData, EdgeData> => {
  return transformation.getName() === 'node-clustering';
};

function updateInfo() {
  // count how many nodes and edges have been parsed

  let nodes = 0;
  let edges = 0;
  const mode = modes[i];
  if (isNodeClustering(transformation)) {
    const clustering = transformation as NodeClustering<NodeData, EdgeData>;
    nodes = ogma
      .getNodes()
      .reduce((total, node) => total + clustering.getSubNodes(node).length, 0);
    edges = ogma
      .getEdges()
      .reduce(
        (total: number, edge) => total + clustering.getSubEdges(edge).length,
        0
      );
  } else {
    nodes = ogma
      .getNodes()
      .filter(node => node.isVirtual())
      .reduce((total, node) => {
        const subNodes = node.getSubNodes();
        return subNodes ? total + subNodes.size : total;
      }, 0);
    edges = ogma
      .getEdges()
      .filter(edge => edge.isVirtual())
      .reduce((total: number, edge) => {
        const subEdges = edge.getSubEdges();
        return subEdges ? total + subEdges.size : total;
      }, 0);
  }

  // display elapsed time and percentage
  dates[`update-${mode}`] = Date.now();
  const elapsed = dates[`start-${mode}`]
    ? (dates[`update-${mode}`] - dates[`start-${mode}`]) / 1000
    : 0;
  const loading = Math.round(
    ((nodes + edges) /
      (mode === 'grouping' ? total_grouping : total_clustering)) *
      100
  );
  text.innerHTML = `<strong>${capitalize(mode)}</strong>${loading < 100 ? '...' : ':'}\n
      <p>Added nodes: ${nodes}</p>
     <p>Added edges: ${edges}</p>
     <p>Elapsed: ${elapsed.toPrecision(2)}s</p>
     <p>Loaded: ${loading}%</p>
     `;
}

// import graph and group nodes
async function importGraph(mode: Mode) {
  await ogma.transformations.clear();
  await ogma.view.setZoom(0.2);
  await ogma.clearGraph();
  await ogma.view.afterNextFrame();
  dates[`start-${mode}`] = Date.now();
  if (mode === 'clustering') {
    transformation = ogma.transformations.addNodeClustering({
      groupIdFunction: node => node.data!.lang,
      nodeGenerator: nodes => {
        return {
          attributes: {
            color: nodes[0]!.attributes!.color,
            radius: nodes.length * 0.01
          }
        };
      },
      edgeGenerator: (edges, groupId) => {
        return { attributes: { width: Math.log(edges.length) } };
      }
    });
  } else {
    transformation = ogma.transformations.addNodeGrouping({
      groupIdFunction: node => node.getData('lang'),
      nodeGenerator: (nodes, groupId, transformation) => {
        return {
          attributes: {
            color: nodes.get(0).getAttribute('color'),
            radius: nodes.size * 0.01
          }
        };
      },
      edgeGenerator: (edges, groupId) => {
        return { attributes: { width: Math.log(edges.size) } };
      }
    });
  }
  const graph = await Ogma.parse.jsonFromUrl<NodeData, EdgeData>(
    'files/github.json'
  );
  await ogma.addGraph(graph);
  await transformation.whenApplied();
  await ogma.view.afterNextFrame();
  await ogma.layouts.force({ locate: true, duration: 0 });
}

await importGraph(modes[i]);
button.innerText = `Switch to ${modes[(i + 1) % modes.length]}`;
button.addEventListener('click', async () => {
  i = (i + 1) % modes.length;
  button.innerText = `Switch to ${modes[(i + 1) % modes.length]}`;
  await importGraph(modes[i]);
});
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />

    <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
.annotation {
  margin: 10px;
}
.info {
  padding: 3px;
  border: solid black 1px;
  border-radius: 3px;
  background-color: #fff;
  font-family: Georgia, 'Times New Roman', Times, serif;
}

html,
body {
  font-family: 'Inter', sans-serif;
}

:root {
  --base-color: #4999f7;
  --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 {
  background: var(--button-background-color);
  border-radius: var(--button-border-radius);
  box-shadow: var(--button-shadow);
  padding: 10px;
}

.panel {
  position: absolute;
  top: 1em !important;
  left: 1em !important;
}

.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 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);
}

.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);
}