Skip to content
  1. Examples

Categories annotation

This example shows how to enhance the hierarchical or sequential layout by displaying horizontal lines showing the level at which each node is. It uses the canvas layer API.

ts
import Ogma, { NodeList, type SequentialLayoutOptions } from '@linkurious/ogma';
import { renderLayoutOutlines } from './render';

const ogma = new Ogma({
  container: 'graph-container',
  // make it transparent if you don't want the ogma layer to hide the previous ones
  options: { backgroundColor: 'transparent' }
});

const defaultLayoutOptions: SequentialLayoutOptions = {
  direction: 'TB', // Direction of the layout. Can be TB, BT, LR, or RL,
  // where T = top, B = bottom, L = left, and R = right.
  duration: 300, // Duration of the animation
  nodeDistance: 15, // Number of pixels that separate nodes horizontally in the layout.
  levelDistance: 50 // Number of pixels between each layer in the layout.
};

const graph = await Ogma.parse.jsonFromUrl('data-flow.json');
await ogma.setGraph(graph);
await ogma.view.locateGraph();
const options = defaultLayoutOptions;
const drawLayer = ogma.layers.addCanvasLayer(ctx => {
  renderLayoutOutlines(ctx, ogma.getNodes(), options.direction!);
});
drawLayer.moveToBottom();
await runLayout(options);

function runLayout(options: SequentialLayoutOptions) {
  ogma.layouts.sequential(options).then(() =>
    ogma.view.locateGraph({
      easing: 'linear',
      duration: 300
    })
  );
}

const form = document.getElementById('wrapper') as HTMLFormElement;

form.addEventListener('change', () => {
  setTimeout(onChange);
});

const modes = {
  // you can mix layering and roots/sinks definitions
  1: { Concept: 0, Table: 'sink' },
  // or control the layer by layer as well
  2: {
    Concept: 0,
    Subject: 1,
    Area: 2,
    DataElement: 3,
    DataOwner: 4,
    DataSteward: 5,
    Column: 6,
    Table: 7,
    DQMetric: 8,
    Database: 9,
    DQDimension: 10,
    DataPolicy: 11,
    DataPrinciple: 12
  }
};

const onChange = () => {
  const mode = Number(
    Array.prototype.filter.call(form['layering'], input => {
      return input.checked;
    })[0].value
  ) as 1 | 2;
  ogma.getNodes().fillData('layer', null);

  let sinks: NodeList;

  // check if it's a supported mode
  if (mode in modes) {
    const customLogic = modes[mode];

    // pick the sinks first
    sinks = ogma.getNodes().filter(node => {
      const category = node.getData(
        'categories'
      )[0] as keyof typeof customLogic;
      return customLogic[category] === 'sink';
    });

    // now set the custom layering
    ogma.getNodes().forEach(node => {
      const category = node.getData(
        'categories'
      )[0] as keyof typeof customLogic;

      const layer = customLogic[category];

      if (typeof layer === 'number') {
        // save the layer in the node data
        node.setData('layer', layer);
      }
    });
  }

  // create fresh new options
  const newOptions: SequentialLayoutOptions = {};
  for (const prop in defaultLayoutOptions) {
    // @ts-ignore
    newOptions[prop] = defaultLayoutOptions[prop];
  }
  // @ts-expect-error save the sinks in the layout options
  newOptions.sinks = sinks;

  runLayout(newOptions);
};
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>
    <form id="wrapper" class="control-bar">
      <p>How to order the sequence:</p>
      <label
        ><input type="radio" name="layering" checked value="0" /> Auto</label
      >
      <label
        ><input type="radio" name="layering" value="2" /> One color per
        layer</label
      >
      <label
        ><input type="radio" name="layering" value="1" />
        <span class="concept"></span> Concept &rarr;
        <span class="table"></span> Table</label
      >
    </form>
    <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;
}
#wrapper {
  position: absolute;
  top: 10px;
  right: 10px;
  z-index: 400;
  background: white;
  padding: 10px;
}

.control-bar {
  font-family: Helvetica, Arial, sans-serif;
  box-shadow: 0 1px 5px rgba(0, 0, 0, 0.65);
  border-radius: 4px;
}
.control-bar label {
  display: block;
}
.concept {
  height: 10px;
  width: 10px;
  background-color: #2ca02c;
  border-radius: 50%;
  display: inline-block;
}
.table {
  height: 10px;
  width: 10px;
  background-color: #ff9896;
  border-radius: 50%;
  display: inline-block;
}
json
{
  "nodes": [
    {
      "id": 0,
      "attributes": {
        "x": 2995,
        "y": 300,
        "color": "#2ca02c",
        "radius": 5,
        "shape": "circle",
        "text": "Customer Portfolio"
      },
      "data": {
        "categories": [
          "Concept"
        ],
        "properties": {
          "name": "Customer Portfolio",
          "definition": "A grouping of financial assets such as stocks, bonds and cash equivalents, as well as their mutual, exchange-traded and closed-fund counterparts. Portfolios are held directly by investors and/or managed by financial professionals.",
          "id": 4
        }
      },
      "x": 421.66666666666674,
      "y": 0,
      "r": 8.889887252841921,
      "layer": 0,
      "sink": false
    },
    {
      "id": 1,
      "attributes": {
        "x": 2667.5,
        "y": 200,
        "color": "#ff7f0e",
        "radius": 5,
        "shape": "circle",
        "text": "Customer"
      },
      "data": {
        "categories": [
          "Subject Area"
        ],
        "properties": {
          "name": "Customer"
        }
      },
      "x": 337.33333333333337,
      "y": 99.47145877378435,
      "r": 7.015674871145073,
      "sink": false
    },
    {
      "id": 2,
      "attributes": {
        "x": 3127.5,
        "y": 250,
        "color": "#1f77b4",
        "radius": 5,
        "shape": "circle",
        "text": "Portfolio Code"
      },
      "data": {
        "categories": [
          "DataElement"
        ],
        "properties": {
          "id": 9,
          "label": "Portfolio Code"
        }
      },
      "x": 506,
      "y": 99.47145877378435,
      "r": 5.461372976784961,
      "sink": false
    },
    {
      "id": 3,
      "attributes": {
        "x": 2867.5,
        "y": 250,
        "color": "#1f77b4",
        "radius": 5,
        "shape": "circle",
        "text": "Portfolio Short Name"
      },
      "data": {
        "categories": [
          "DataElement"
        ],
        "properties": {
          "id": 8,
          "label": "Portfolio Short Name"
        }
      },
      "x": 463.8333333333333,
      "y": 99.47145877378435,
      "r": 10.645027362934385,
      "sink": false
    },
    {
      "id": 4,
      "attributes": {
        "x": 300,
        "y": 200,
        "color": "#17becf",
        "radius": 5,
        "shape": "circle",
        "text": null
      },
      "data": {
        "categories": [
          "DataOwner"
        ],
        "properties": {
          "full_name": "David Jones",
          "id": 1
        }
      },
      "x": 674.6666666666666,
      "y": 99.47145877378435,
      "r": 4.378711202635261,
      "sink": false
    },
    {
      "id": 5,
      "attributes": {
        "x": 365,
        "y": 250,
        "color": "#2ca02c",
        "radius": 5,
        "shape": "circle",
        "text": "Trader"
      },
      "data": {
        "categories": [
          "Concept"
        ],
        "properties": {
          "name": "Trader",
          "definition": "An individual who engages in the transfer of financial assets in any financial market, either for themselves, or on behalf of a someone else. The main difference between a trader and an investor is the duration for which the person holds the asset. Investors tend to have a longer term time horizon, whereas traders tend to hold assets for shorter periods of time in order to capitalize on short-term trends.",
          "id": 5
        }
      },
      "x": 0,
      "y": 0,
      "r": 9.551798628830197,
      "layer": 0,
      "sink": false
    },
    {
      "id": 6,
      "attributes": {
        "x": 310,
        "y": 250,
        "color": "#2ca02c",
        "radius": 5,
        "shape": "circle",
        "text": "Investment"
      },
      "data": {
        "categories": [
          "Concept"
        ],
        "properties": {
          "name": "Investment",
          "definition": "An asset or item that is purchased with the hope that it will generate income or appreciate in the future. In an economic sense, an investment is the purchase of goods that are not consumed today but are used in the future to create wealth. In finance, an investment is a monetary asset purchased with the idea that the asset will provide income in the future or appreciate and be sold at a higher price.",
          "id": 3
        }
      },
      "x": 759,
      "y": 0,
      "r": 8.277798660184793,
      "layer": 0,
      "sink": false
    },
    {
      "id": 7,
      "attributes": {
        "x": 255,
        "y": 250,
        "color": "#2ca02c",
        "radius": 5,
        "shape": "circle",
        "text": "Share Price"
      },
      "data": {
        "categories": [
          "Concept"
        ],
        "properties": {
          "name": "Share Price",
          "definition": "The price of shares of a particular company at a particular time.",
          "id": 2
        }
      },
      "x": 674.6666666666667,
      "y": 0,
      "r": 10.763584846041306,
      "layer": 0,
      "sink": false
    },
    {
      "id": 8,
      "attributes": {
        "x": 127.5,
        "y": 250,
     

...
ts
import { NodeId, NodeList } from '@linkurious/ogma';

// distances have to be sorted in ascending order
const cleanDistances = (distances: number[], tolerance = 8, maxCount = 20) => {
  let distance,
    last = 0;
  const cleanedDistances = [];
  for (let i = 0; i < distances.length; i++) {
    distance = distances[i];
    if (distance - last > tolerance) {
      cleanedDistances.push(distance);
      last = distance;
    }
    if (cleanedDistances.length >= maxCount) return cleanedDistances;
  }
  return cleanedDistances;
};

// distances have to be sorted in ascending order
const renderOutlines = (
  ctx: CanvasRenderingContext2D,
  distances: number[],
  length: number,
  fontSize = 8,
  horiz: boolean,
  reversed: boolean
) => {
  ctx.lineWidth = 1;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  distances = cleanDistances(distances);

  // straight lines
  let i, len, distance, opacity;
  const maxDist = distances[distances.length - 1],
    minDist = distances[0];
  for (i = 0, len = distances.length; i < len; i++) {
    distance = distances[reversed ? len - i - 1 : i];
    opacity = Math.min(0.5, (maxDist - distance) / (maxDist - minDist));
    ctx.beginPath();
    ctx.strokeStyle = `rgba(0, 0, 0, ${reversed ? 0.5 - opacity : opacity})`;

    if (horiz) {
      ctx.moveTo(0, distance);
      ctx.lineTo(length, distance);
    } else {
      ctx.moveTo(distance, 0);
      ctx.lineTo(distance, length);
    }
    ctx.stroke();
  }

  // label backgrounds
  ctx.beginPath();
  ctx.lineWidth = 0;
  ctx.fillStyle = '#ffffff';
  for (i = 0, len = distances.length; i < len; i++) {
    distance = distances[reversed ? len - i - 1 : i];
    if (horiz) {
      ctx.arc(0, distance, fontSize, 0, 2 * Math.PI, false);
    } else {
      ctx.arc(distance, 0, fontSize, 0, 2 * Math.PI, false);
    }
  }
  ctx.fill();

  // label texts
  ctx.fillStyle = 'black';
  ctx.font = fontSize + 'px sans-serif';
  for (i = 0, len = distances.length; i < len; i++) {
    distance = distances[reversed ? len - i - 1 : i];
    if (horiz) {
      ctx.fillText(String(i + 1), 0, distance);
    } else {
      ctx.fillText(String(i + 1), distance, 0);
    }
  }
};

// calculates distances of elements
const collectData = (nodes: NodeList, horiz: boolean) => {
  const positions = nodes.getPosition();
  const ids = nodes.getId();
  const layers: Record<number, NodeId[]> = {};
  let xmin = positions[0].x;
  let ymin = positions[0].y;
  let xmax = positions[0].x;
  let ymax = positions[0].y;
  for (let i = 0, len = nodes.size; i < len; i++) {
    const pos = positions[i];
    const dist = horiz ? pos.y : pos.x;
    layers[dist] = layers[dist] || [];
    layers[dist].push(ids[i]);
    if (pos.x < xmin) {
      xmin = pos.x;
    } else if (pos.x > xmax) {
      xmax = pos.x;
    }
    if (pos.y < ymin) {
      ymin = pos.y;
    } else if (pos.y > ymax) {
      ymax = pos.y;
    }
  }
  return {
    layers,
    distances: Object.keys(layers)
      .map(key => parseInt(key))
      .sort((a, b) => a - b),
    positions,
    xmin,
    ymin,
    xmax,
    ymax
  };
};

export const renderLayoutOutlines = (
  ctx: CanvasRenderingContext2D,
  nodes: NodeList,
  direction: 'TB' | 'BT' | 'LR' | 'RL' // top to bottom, bottom to top, left to right, right to left
) => {
  const horiz = direction === 'TB' || direction === 'BT';
  const reversed = direction[0] === 'B' || direction[0] === 'R';

  const { xmin, ymin, xmax, ymax, distances } = collectData(nodes, horiz);
  const offset = 20;
  const length = (horiz ? xmax - xmin : ymax - ymin) + 2 * offset;
  ctx.save();
  if (horiz) {
    ctx.translate(xmin - offset, 0);
  } else {
    ctx.translate(0, ymin - offset);
  }
  renderOutlines(ctx, distances, length, 8, horiz, reversed);
  ctx.restore();
};