Appearance
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": [
...