Skip to content
  1. Examples

Transformation pipeline

This example illustrates the Ogma transformation pipeline. In Ogma all transformations are executed successively depending on their index. Select a node in the top panel and click to change its index. You will see how the transformation pipeline is executed and how diifferent is the result.

ts
import Ogma, { RawGraph } from '@linkurious/ogma';
import { ui } from './ui';
import { count } from 'd3';

type NodeData = {
  type: 'person' | 'company' | 'country';
  country?: string;
  name: string;
};

const ogma = new Ogma<NodeData>({
  container: 'graph-container'
});
const graph: RawGraph<NodeData> = {
  nodes: [
    { id: 0, data: { type: 'person', country: 'USA', name: 'Sylvestre' } },
    { id: 1, data: { type: 'person', country: 'UK', name: 'Kevin' } },
    { id: 2, data: { type: 'company', country: 'USA', name: 'World Company' } },
    { id: 3, data: { type: 'company', country: 'UK', name: 'Moon Company' } }
  ],
  edges: [
    { source: 0, target: 2 },
    { source: 1, target: 3 }
  ]
};
const COLORS = {
  person: 'green',
  company: 'cyan',
  country: 'yellow'
};

ogma.styles.addRule({
  nodeAttributes: {
    text: node => node.getData('name'),
    color: node => COLORS[node.getData('type')],
    opacity: node => (node.isVirtual() ? 0.32 : undefined)
  }
});

await ogma.setGraph(graph);
await ogma.layouts.force({ locate: true });

const filterNodes = ogma.transformations.addNodeFilter({
  criteria: node => node.getData('type') === 'person'
});

const generating = ogma.transformations.addNeighborGeneration({
  selector: node => !node.isVirtual(),
  neighborIdFunction: node => node.getData('country')!,
  nodeGenerator: countryName => {
    return { data: { type: 'country', name: countryName } };
  }
});

const grouping = ogma.transformations.addNodeGrouping({
  groupIdFunction: node => node.getData('type'),
  nodeGenerator: nodes => ({
    data: {
      location: nodes.get(0).getData('type')
    },
    attributes: {
      text: nodes.get(0).getData('type'),
      color: 'rgba(127, 127, 127, 0.5)'
    }
  }),
  showContents: true,
  onGroupUpdate: (metaNode, nodes) => {
    return ogma.layouts.force({ nodes });
  }
});

ogma.events.once('transformationEnabled', () => {
  ogma.layouts.force({ locate: true });
});
ui.onChange(async data => {
  console.log({ data });
  const groupIndex = data.find(({ name }) => name === 'grouping')!.index;
  const filterIndex = data.find(({ name }) => name === 'filter')!.index;
  const generateIndex = data.find(({ name }) => name === 'generating')!.index;

  await Promise.all(ogma.transformations.getList().map(t => t.disable()));
  await Promise.all([
    grouping.setIndex(groupIndex),
    filterNodes.setIndex(filterIndex),
    generating.setIndex(generateIndex)
  ]);
  await new Promise(resolve => setTimeout(resolve, 500));
  await ogma.transformations.getList()[0].enable(1000);
  await ogma.layouts.force({ duration: 1000 });
  await ogma.transformations.getList()[1].enable(1000);
  await ogma.layouts.force({ duration: 1000 });
  await ogma.transformations.getList()[2].enable(1000);
  await ogma.layouts.force({ duration: 1000 });
});
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <link type="text/css" rel="stylesheet" href="styles.css" />
    <script src="https://cdnjs.cloudflare.com/ajax/libs/eventemitter3/4.0.7/index.min.js"></script>
  </head>
  <body>
    <div id="ui-container"></div>
    <div id="graph-container"></div>
    <script type="module" src="index.ts"></script>
  </body>
</html>
css
html,
body {
  font-family: 'Inter', sans-serif;
  height: 100%;
  padding: 0;
  margin: 0;
}

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

body {
  display: grid;
  grid-auto-flow: row;
  grid-template-rows: 200px auto;
  min-height: 100%;
  overflow: hidden;
}

#ui-container {
  border-bottom: 1px solid black;
}

.context-menu {
  display: flex;
  justify-content: space-evenly;
  padding-top: 1em;
  margin-left: 25px;
}

.ui {
  position: absolute;
  display: flex;
  flex-direction: column;
  gap: 0.5em;
}

.panel {
  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;
}

.panel h2 {
  text-transform: uppercase;
  font-weight: 400;
  font-size: 14px;
  margin: 0;
}

.panel {
  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 button:hover {
  background: var(--lighter-gray);
  border: 1px solid var(--darker-gray);
}

.panel button[disabled] {
  color: var(--light-gray);
  border: 1px solid var(--light-gray);
  background-color: var(--lighter-gray);
}
ts
import Ogma, { Overlay, type Node } from '@linkurious/ogma';

export type NodeData = {
  index: number;
  name: string;
};

class UI {
  private onChangeCallbacks: ((data: NodeData[]) => void)[] = [];
  onChange(callback: (data: NodeData[]) => void) {
    this.onChangeCallbacks.push(callback);
  }

  change(data: NodeData[]) {
    this.onChangeCallbacks.forEach(callback => callback(data));
  }
}

export const ui = new UI();

const ogma = new Ogma({
  container: 'ui-container',
  options: {
    minimumHeight: 150,
    interactions: {
      drag: { enabled: false },
      zoom: { enabled: false },
      pan: { enabled: false }
    }
  }
});

ogma.addNodes([
  {
    id: 'n0',
    attributes: { x: 0, text: 'filter' },
    data: { index: 0, name: 'filter' }
  },
  {
    id: 'n1',
    attributes: { x: 50, text: 'grouping' },
    data: { index: 1, name: 'grouping' }
  },
  {
    id: 'n2',
    attributes: { x: 100, text: 'generating' },
    data: { index: 2, name: 'generating' }
  }
]);
ogma.addEdges([
  { source: 'n0', target: 'n1' },
  { source: 'n1', target: 'n2' }
]);
ogma.view.locateGraph({ padding: 80 });
ogma.styles.addEdgeRule(() => true, {
  shape: {
    head: 'arrow'
  }
});
const pulseOptions = {
  duration: 2000,
  width: 5,
  endColor: '#fff',
  startColor: '#ccc'
};
function setPos(nodeA: Node<NodeData>, indexB: number) {
  ogma.removeEdges(ogma.getEdges());
  const indexA = nodeA.getData('index');
  const nodeB = ogma
    .getNodes()
    .filter(node => node.getData('index') === indexB)
    .get(0);
  nodeA.setData('index', indexB);
  nodeB.setData('index', indexA);
  ui.change(
    ogma
      .getNodes()
      .getData()
      .sort((a, b) => a.index - b.index)
  );
  return Promise.all([
    nodeA.setAttribute('x', nodeB.getAttribute('x'), 500),
    nodeB.setAttribute('x', nodeA.getAttribute('x'), 500)
  ]).then(() => {
    const nodes = ogma
      .getNodes()
      .sort((a, b) => a.getData('index') - b.getData('index'));
    ogma.addEdge({
      source: nodes.get(0).getId(),
      target: nodes.get(1).getId()
    });
    ogma.addEdge({
      source: nodes.get(1).getId(),
      target: nodes.get(2).getId()
    });
    nodes.get(0).pulse(pulseOptions);
    setTimeout(() => {
      nodes.get(1).pulse(pulseOptions);
    }, 2000);
    setTimeout(() => {
      nodes.get(2).pulse(pulseOptions);
    }, 4000);
  });
}
function createMenuItem(text: string, action: () => void) {
  const item = document.createElement('button');
  item.classList.add('item');
  item.innerText = text;
  item.addEventListener('click', action);
  return item;
}
let contextmenu: Overlay | null = null;
function showContextMenu(node: Node<NodeData>) {
  destroyContextMenu();
  const index = node.getData('index');
  const buttons = [];
  if (index > 0) {
    buttons.push(
      createMenuItem('<<', () => {
        setPos(node, index - 1);
        destroyContextMenu();
      })
    );
  }
  if (index < 2) {
    buttons.push(
      createMenuItem('>>', () => {
        setPos(node, index + 1);
        destroyContextMenu();
      })
    );
  }

  const div = document.createElement('div');
  div.classList.add('context-menu');
  buttons.forEach(button => {
    div.appendChild(button);
  });

  const position = node.getPosition();
  contextmenu = ogma.layers.addOverlay({
    element: div,
    position: {
      x: position.x - 4 * buttons.length,
      y: position.y - Number(node.getAttribute('radius')) - 6
    },
    scaled: false,
    size: { width: 50 * buttons.length, height: 'auto' }
  });
}

function destroyContextMenu() {
  if (!contextmenu) return;
  contextmenu.destroy();
  contextmenu = null;
}

ogma.events.on('nodesSelected', ({ nodes }) => {
  showContextMenu(nodes.get(0));
});
ogma.getNodes().get(1).setSelected(true);