Skip to content
  1. Examples

Supply chain

This example shows a bicycle supply-chain. It pulses a retailer which has more demand that supplies and uses nodeGrouping to show a simplified version of the topology.

ts
import Ogma, { Node, Edge, Color, EdgeList } from '@linkurious/ogma';
import { API } from './api';

interface NodeData {
  stock: number;
  lat: number;
  lon: number;
  neo4jLabels: string[];
  neo4jProperties: Record<string, string>;
}

interface EdgeData {
  neo4jProperties: {
    quantity: number;
  };
}

const HOST = 'bolt://localhost:4743';
const DB_NAME = 'db_name';
const PASSWORD = 'db_password';

const ogma = new Ogma<NodeData, EdgeData>({ container: 'graph-container' });

// instance of the API client
const api = new API(HOST, DB_NAME, PASSWORD);

type NodeTypes =
  | 'SupplierA'
  | 'SupplierB'
  | 'RawSupplierA'
  | 'RawSupplierB'
  | 'RawSuppliers'
  | 'Retailer'
  | 'Retailers'
  | 'Wholesaler'
  | 'Wholesalers'
  | 'FrameSupplier'
  | 'WheelSupplier'
  | 'Product'
  | 'unknown';

api
  .getGraph()
  .then(response => Ogma.parse.neo4j<NodeData, EdgeData>(response))
  .then(rawGraph => ogma.setGraph(rawGraph))
  // apply the force layout to the graph (places the nodes so it is readable )
  .then(() => ogma.layouts.force({ locate: true }));

// Helper function to get the type of a Node
function getNodeType(node: Node<NodeData>): NodeTypes {
  const type = node.getData('neo4jLabels')
    ? node.getData('neo4jLabels')[0]
    : 'unknown';
  return type as NodeTypes;
}

function getTotalDemand(node: Node<NodeData, EdgeData>) {
  return node
    .getAdjacentEdges({ direction: 'out' })
    .reduce(
      (total, edge) => total + edge.getData('neo4jProperties.quantity'),
      0
    );
}

// Helper function to check if a node should pulse
// Nodes pulses if the total demand is higher than their stock
function shouldPulse(node: Node<NodeData, EdgeData>) {
  const totalDemand = getTotalDemand(node);
  return node.getData('neo4jProperties.stock') - totalDemand < 100;
}

// Define buckets of importance for nodes
// some values repeat themselves for different types
const supplierStyle = { color: '#6FC1A7', icon: '\uf275' };
const rawSupplierStyle = { color: '#3DCB8D', icon: '\uf472' };
const wholeSalerStyle = { color: '#E77152', icon: '\uf468' };
const retailerStyle = { color: '#9AD0D0', icon: '\uf07a' };

const nodeStyles: Record<NodeTypes, { color: Color; icon: string }> = {
  SupplierA: supplierStyle,
  SupplierB: supplierStyle,

  RawSupplierA: rawSupplierStyle,
  RawSupplierB: rawSupplierStyle,
  RawSuppliers: rawSupplierStyle,

  Retailer: retailerStyle,
  Retailers: retailerStyle,

  Wholesaler: wholeSalerStyle,
  Wholesalers: wholeSalerStyle,

  FrameSupplier: { color: '#6FC1A7', icon: '\uf565' },
  WheelSupplier: { color: '#6FC1A7', icon: '\uf655' },

  Product: { color: '#76378A', icon: '\uf466' },
  unknown: { color: '#777', icon: '' }
};

// edge color and width buckets
const edgeBuckets = ogma.rules.slices({
  field: 'neo4jProperties.quantity',
  values: [
    { color: '#132b43', width: 0.5 },
    { color: '#132b43', width: 1.5 },
    { color: '#326896', width: 2 },
    { color: '#54aef3', width: 5 },
    { color: 'orange', width: 8 }
  ],
  fallback: { color: '#132b43', width: 3.5 },
  stops: [15, 150, 280, 500]
});

ogma.styles.addRule({
  // Edges style:
  edgeAttributes: {
    color: edge => {
      const { color } = edgeBuckets(edge) || { color: '#132b43' };
      return color;
    },
    width: edge => {
      const { width } = edgeBuckets(edge) || { width: 3.5 };
      return width;
    },
    shape: {
      head: 'arrow'
    }
  },
  // Node style:
  nodeAttributes: {
    icon: node => {
      const type = getNodeType(node);
      const color = nodeStyles[type].color;
      const icon = nodeStyles[type].icon;

      return {
        font: 'Font Awesome 5 Free',
        color: color,
        content: icon,
        scale: 0.6,
        minVisibleSize: 0
      };
    },
    // define colors depending on the type of the node
    color: 'white',
    // define the inner stroke of the node
    innerStroke: {
      color: node => {
        const type = getNodeType(node);
        return nodeStyles[type].color;
      },
      width: 1,
      minVisibleSize: 30
    },
    // nodes size depend on the quantity of Product that is exanged through them
    radius: node =>
      15 +
      (2 *
        node.getAdjacentEdges().reduce((acc, e) => {
          return acc + e.getData('neo4jProperties.quantity');
        }, 0)) /
        300,
    // Make nodes that have a higher demand than their stock pulsating
    pulse: {
      enabled: node => shouldPulse(node),
      endRatio: 2,
      width: 3,
      startColor: 'red',
      endColor: 'red',
      interval: 1000,
      startRatio: 1.0
    }
  }
});

const geoRule = ogma.styles.addRule({
  nodeSelector: () => false,
  edgeSelector: () => false,
  nodeAttributes: {
    radius: 8
  },
  edgeAttributes: {
    width: 2
  }
});

ogma.tools.tooltip.onNodeHover(
  function (node) {
    const supply = node.getData('neo4jProperties.stock'),
      demand = getTotalDemand(node);
    return `<table><thead><tr>${
      supply ? `<th>Supply</th>` : ''
    }<th>Demand</th></tr></thead><tr>${
      supply ? `<td>${supply}</td>` : ''
    }<td>${demand}</td></table>`;
  },
  { className: 'tooltip' }
);

function groupEdges(edges: EdgeList<EdgeData, NodeData>) {
  return {
    data: {
      neo4jProperties: {
        quantity: edges.reduce((total, edge) => {
          return total + edge.getData('neo4jProperties.quantity');
        }, 0)
      }
    }
  };
}

const edgeGrouping = ogma.transformations.addEdgeGrouping({
  selector: function (edge) {
    return edge.getData('neo4jType') === 'DELIVER';
  },
  enabled: false,
  duration: 500,
  generator: function (edges) {
    return groupEdges(edges);
  }
});

const edgeGroupingButton = document.getElementById('edge-grouping')!;
edgeGroupingButton.addEventListener('click', function () {
  ogma.transformations.triggerGroupsUpdated();
  edgeGrouping.toggle().then(updateButtons);
});

const nodeGrouping = ogma.transformations.addNodeGrouping({
  groupIdFunction: node => getNodeType(node),
  selector: node => getNodeType(node) !== 'Product',
  enabled: false,
  duration: 500,
  edgeGenerator: edges => groupEdges(edges),
  nodeGenerator: (nodes, groupId) => {
    // choose label
    let label = ['Wholesalers']; // default
    if (groupId === 'SupplierA') label = ['FrameSupplier'];
    else if (groupId === 'SupplierB') label = ['WheelSupplier'];
    else if (/RawSupplier/.test(groupId)) label = ['RawSuppliers'];
    else if (groupId === 'Retailer') label = ['Retailers'];

    // generated group node
    return {
      data: {
        neo4jLabels: label
      }
    };
  }
});

function layout(locate = false) {
  if (ogma.geo.enabled()) {
    return Promise.resolve();
  }
  if (nodeGrouping.isEnabled()) {
    return ogma.layouts.hierarchical({
      componentDistance: 500,
      nodeDistance: 50,
      locate,
      levelDistance: 80,
      direction: 'LR'
    });
  }
  return ogma.layouts.force({
    locate,
    edgeWeight: e => 0.1
  });
}
ogma.transformations.onGroupsUpdated(() => layout());

const geoGrouping = ogma.transformations.addGeoClustering({
  groupIdFunction: node => {
    const type = getNodeType(node);
    return /RawSupplier/.test(type) ? 'RawSuppliers' : type;
  },
  selector: node => getNodeType(node) !== 'Product',
  enabled: false,
  edgeGenerator: edges => groupEdges(edges),
  nodeGenerator: (nodes, groupId) => {
    // choose label
    let label = ['Wholesalers']; // default
    if (/RawSupplier/.test(groupId)) label = ['RawSuppliers'];
    else if (/SupplierA/.test(groupId)) label = ['SupplierA'];
    else if (/SupplierB/.test(groupId)) label = ['SupplierB'];
    else if (/Retailer/.test(groupId)) label = ['Retailer'];

    // generated group node
    return {
      data: {
        neo4jLabels: label
      },
      attributes: {
        badges: {
          bottomRight:
            nodes.size > 1
              ? {
                  text: { content: nodes.size, color: 'white', style: 'bold' },
                  color: 'red',
                  stroke: { width: 0 }
                }
              : undefined
        }
      }
    };
  }
});

const nodeGroupingButton = document.getElementById('node-grouping')!;
nodeGroupingButton.addEventListener('click', function () {
  ogma.transformations.triggerGroupsUpdated();
  nodeGrouping.toggle().then(updateButtons);
});

const geoGroupingButton = document.getElementById('geo-grouping')!;
geoGroupingButton.addEventListener('click', function () {
  geoGrouping.toggle().then(updateButtons);
});

function updateButtons() {
  edgeGroupingButton.innerHTML =
    'Edge grouping: ' + (edgeGrouping.isEnabled() ? 'on' : 'off');
  nodeGroupingButton.innerHTML =
    'Node grouping: ' + (nodeGrouping.isEnabled() ? 'on' : 'off');
  geoModeButton.innerHTML = 'Geo mode: ' + (ogma.geo.enabled() ? 'on' : 'off');
  geoGroupingButton.innerHTML =
    'Geo grouping: ' + (geoGrouping.isEnabled() ? 'on' : 'off');

  if (ogma.geo.enabled()) {
    nodeGroupingButton.setAttribute('disabled', 'disabled');
    edgeGroupingButton.setAttribute('disabled', 'disabled');
    geoGroupingButton.removeAttribute('disabled');
  } else {
    nodeGroupingButton.removeAttribute('disabled');
    edgeGroupingButton.removeAttribute('disabled');
    geoGroupingButton.setAttribute('disabled', 'disabled');
  }
}

const geoModeButton = document.getElementById('geo-mode')!;
geoModeButton.addEventListener('click', function () {
  let promise = Promise.resolve();
  if (!ogma.geo.enabled()) {
    promise = promise
      .then(() => edgeGrouping.disable())
      .then(() => nodeGrouping.disable())
      .then(updateButtons);
  }

  const enabled = !ogma.geo.enabled();
  return promise
    .then(() => {
      geoRule.update({
        nodeSelector: () => enabled,
        edgeSelector: () => enabled
      });
      ogma.transformations.triggerGroupsUpdated();
      ogma.geo.toggle({
        longitudePath: 'neo4jProperties.lon',
        latitudePath: 'neo4jProperties.lat'
      });
    })
    .then(() => {
      updateButtons();
    });
});
updateButtons();
html
<!doctype html>
<html>
  <head>
    <title>Ogma demo</title>
    <meta charset="utf-8" />
    <link
      type="text/css"
      rel="stylesheet"
      href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.11.2/css/solid.min.css"
    />
    <link type="text/css" rel="stylesheet" href="styles.css" />
    <script src="https://cdn.jsdelivr.net/npm/leaflet@1.7.1/dist/leaflet-src.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/neo4j-driver@4.1.2/lib/browser/neo4j-web.min.js"></script>
    <link
      rel="stylesheet"
      type="text/css"
      href="https://cdn.jsdelivr.net/npm/leaflet@1.7.1/dist/leaflet.css"
    />
  </head>

  <body>
    <div id="graph-container"></div>
    <div id="controls">
      <button id="edge-grouping">Edge grouping: off</button>
      <button id="node-grouping">Node grouping: off</button>
      <button id="geo-mode">Geo mode: off</button>
      <button id="geo-grouping">Geo grouping: off</button>
    </div>
    <i class="fa fa-camera-retro fa-1x" style="color: rgba(0, 0, 0, 0)"></i>
    <script type="module" src="index.ts"></script>
  </body>
</html>
css
html, body {
  width: 100%;
  height: 100%;
  padding: 0;
  margin: 0;
}
#graph-container {
  width: 99vw;
  height: 99vh;
  margin: 0;
  padding: 0;
}

p {
  font-family: Arial, sans-serif;
}

#controls {
  position: absolute;
  top: 15px;
  right: 15px;
  display: flex;
  flex-direction: column;
  min-height: max-content;
  z-index: 9999;
  margin: 10px;
}

#controls > button {
  margin: 5px 0px;
  padding: 5px;
  display: flex;
}

.tooltip {
  padding: 5px;
  font-family: 'Courier New', Courier, monospace;
  background: #fff;
  box-shadow: 0 0 5px rgba(0,0,0, 0.5);
  border-radius: 5px;
}
ts
// little neo4j access wrapper. You would want to replace that with
// a backend API for obvious reasons
export class API {
  private _db: string;
  private _pass: string;
  private _uri: string;
  private _session: Session;

  constructor(uri: string, db: string, pass: string) {
    this._db = db;
    this._pass = pass;
    this._uri = uri;

    const neo4j: Neo4j = window.neo4j;

    // Create a driver to connect to the Neo4j database
    const driver: Driver = neo4j.driver(this._uri, neo4j.auth.basic(db, pass));
    this._session = driver.session({
      database: db,
      defaultAccessMode: neo4j.session.READ
    });
  }

  // Query the graph to the server and load it to ogma
  // getGraph() {
  //   const query = 'MATCH (a)-[r]-() RETURN a, r';
  //   return this._session.run(query);
  // }

  // override to serve a static response copy instead
  getGraph() {
    return fetch('./supply-chain-neo4j.json').then(response => response.json());
  }
}

// missing types for global neo4j variables imported from CDN file:
declare global {
  interface Auth {
    basic: (user: string, pass: string) => Auth;
  }

  interface Session {
    run: (query: string) => Promise<any>;
    READ: number;
  }

  interface Driver {
    (uri: string, auth: Auth): Driver; // Call signature for Driver interface
    session: (options: {
      database: string;
      defaultAccessMode: number;
    }) => Session;
  }

  interface Neo4j {
    driver: Driver;
    auth: Auth;
    session: Session;
  }

  interface Window {
    neo4j: Neo4j;
  }
}
json
{
  "records": [
    {
      "keys": [
        "a",
        "r"
      ],
      "length": 2,
      "_fields": [
        {
          "identity": "14",
          "labels": [
            "SupplierA"
          ],
          "properties": {
            "cost": 33,
            "co2": 254,
            "name": "SupplierA0",
            "lon": 92.70139901973018,
            "time": 4,
            "lat": 11.13204871524422
          }
        },
        {
          "identity": "69",
          "start": "14",
          "end": "0",
          "type": "DELIVER",
          "properties": {
            "km": 537,
            "quantity": 289
          }
        }
      ],
      "_fieldLookup": {
        "a": 0,
        "r": 1
      }
    },
    {
      "keys": [
        "a",
        "r"
      ],
      "length": 2,
      "_fields": [
        {
          "identity": "14",
          "labels": [
            "SupplierA"
          ],
          "properties": {
            "cost": 33,
            "co2": 254,
            "name": "SupplierA0",
            "lon": 92.70139901973018,
            "time": 4,
            "lat": 12.13204871524422
          }
        },
        {
          "identity": "60",
          "start": "14",
          "end": "0",
          "type": "DELIVER",
          "properties": {
            "km": 537,
            "quantity": 205
          }
        }
      ],
      "_fieldLookup": {
        "a": 0,
        "r": 1
      }
    },
    {
      "keys": [
        "a",
        "r"
      ],
      "length": 2,
      "_fields": [
        {
          "identity": "14",
          "labels": [
            "SupplierA"
          ],
          "properties": {
            "cost": 33,
            "co2": 254,
            "name": "SupplierA0",
            "lon": 92.70139901973018,
            "time": 4,
            "lat": 55.13204871524422
          }
        },
        {
          "identity": "45",
          "start": "14",
          "end": "0",
          "type": "DELIVER",
          "properties": {
            "km": 537,
            "quantity": 281
          }
        }
      ],
      "_fieldLookup": {
        "a": 0,
        "r": 1
      }
    },
    {
      "keys": [
        "a",
        "r"
      ],
      "length": 2,
      "_fields": [
        {
          "identity": "18",
          "labels": [
            "SupplierB"
          ],
          "properties": {
            "cost": 26,
            "co2": 255,
            "name": "SupplierB1",
            "lon": 6.338227685970327,
            "time": 5,
            "lat": 59.06009250248424
          }
        },
        {
          "identity": "73",
          "start": "18",
          "end": "0",
          "type": "DELIVER",
          "properties": {
            "km": 3143,
            "quantity": 188
          }
        }
      ],
      "_fieldLookup": {
        "a": 0,
        "r": 1
      }
    },
    {
      "keys": [
        "a",
        "r"
      ],
      "length": 2,
      "_fields": [
        {
          "identity": "2",
          "labels": [
            "Wholesaler"
          ],
          "properties": {
            "cost": 21,
            "co2": 353,
            "name": "Wholesaler1",
            "lon": 46.140638991456186,
            "time": 3,
            "stock": "3000",
            "lat": 48.415497461193
          }
        },
        {
          "identity": "52",
          "start": "0",
          "end": "2",
          "type": "DELIVER",
          "properties": {
            "km": 6890,
            "quantity": 160
          }
        }
      ],
      "_fieldLookup": {
        "a": 0,
        "r": 1
      }
    },
    {
      "keys": [
        "a",
        "r"
      ],
      "length": 2,
      "_fields": [
        {
          "identity": "2",
          "labels": [
            "Wholesaler"
          ],
          "properties": {
            "cost": 21,
            "co2": 353,
            "name": "Wholesaler1",
            "lon": 46.140638991456186,
            "time": 3,
            "stock": "3000",
            "lat": 48.415497461193
          }
        },
        {
          "identity": "58",
          "start": "0",
          "end": "2",
          "type": "DELIVER",
          "properties": {
            "km": 6890,
            "quantity": 122
          }
        }
      ],
      "_fieldLookup": {
        "a": 0,
        "r": 1
      }
    },
    {
      "keys": [
        "a",
        "r"
      ],
      "length": 2,
      "_fields": [
        {
          "identity": "1",
          "labels": [
            "Wholesaler"
          ],
          "properties": {
            "cost": 25,
            "co2": 454,
            "name": "Wholesaler0",
            "lon": 21.323179132582617,
            "time": 2,
            "stock": 1028,
            "lat": 53.493312000154994
          }
        },
        {
          "identity": "34",
          "start": "0",
          "end": "1",
          "type": "DELIVER",
          "properties": {
            "km": 3737,
            "quantity": 175
          }
        }
      ],
      "_fieldLookup": {
        "a": 0,
        "r": 1
      }
    },
    {
      "keys": [
        

...