Skip to content
  1. Examples

IT Management demo

This example shows how to use node grouping and node filter to regroup nodes and filter them based on their data.

ts
import Ogma from '@linkurious/ogma';

const ogma = new Ogma({
  container: 'graph-container'
});

const scale = 0.5;

// always show labels
ogma.styles.addNodeRule({
  text: {
    minVisibleSize: 0
  },
  icon: node => {
    if (node.isVirtual()) {
      return {
        content: getIconCode('icon-router'),
        scale,
        font: 'Lucide',
        color: 'white'
      };
    }
    return {
      content: getIconCode('icon-router'),
      scale,
      font: 'Lucide',
      color: 'white'
    };
  }
});

const formatContent = ids => {
  if (!Array.isArray(ids)) {
    return ''; // this is for regular nodes
  }
  return (
    '<br/><br/>Contains:<ul/><li>' + ids.join('</li><li/>"') + '</li></ul>'
  );
};

// Show some info from the hovered node
ogma.tools.tooltip.onNodeHover(
  node => {
    // pick the color of the node
    const color = node.getAttribute('color');
    // Now change the title based on the type of node (grouped or not?)
    const title = node.isVirtual()
      ? 'Grouped Area&nbsp;'
      : 'ID: "' + node.getId() + '"&nbsp;';
    // and pick the content of the node if grouped;
    const content =
      node.isVirtual() && !isVisualGroup(node)
        ? node.getData('ids')
        : node.getId();
    return (
      '<div class="arrow"></div>' +
      '<div class="ogma-tooltip-header">' +
      '<span class="title">' +
      title +
      '<span class="line-color" style="background: ' +
      color +
      '">' +
      '&nbsp;' +
      '</span>' +
      formatContent(content) +
      '</span>' +
      '</div>'
    );
  },
  {
    className: 'ogma-tooltip' // tooltip container class to bind to css
  }
);

let groups;
let nodeGroupings;

const placeholder = document.createElement('span');
document.body.appendChild(placeholder);
placeholder.style.visibility = 'hidden';

// helper routine to get the icon HEX code
function getIconCode(className: string) {
  placeholder.className = className;
  const code = getComputedStyle(placeholder, ':before').content;
  return code[1];
}

const graph = await Ogma.parse.jsonFromUrl('network.json');
await ogma.setGraph(graph);
// eslint-disable-next-line es5/no-es6-static-methods
groups = Array.from(new Set(ogma.getNodes().getData('area')));

// transformation objects: create them now and use them later
nodeGroupings = groups.map(group => createGroupingTransformation(group));

await ogma.layouts.force({ locate: { padding: 120 } });

async function updateUI() {
  // Check what it should
  const nodeGrouped = getMode('node-group') === 'group';
  const edgeGrouped = getMode('edge-group') === 'group';
  const filterActive = getMode('filter-switch') === 'filter';
  const opens = getOpens('area');

  // instead of working out how to go from state A to state B
  // execute all transformation and for each one workout what to do
  await execute(filterSwitch, filterActive);
  await execute(edgeGrouping, edgeGrouped);
  await executeGrouping(nodeGrouped, opens);
  const someVisualGrouping = Object.keys(opens).some(area => {
    return opens[area];
  });
  await updateConnectivityUI(!nodeGrouped || someVisualGrouping);
  await ogma.layouts.force();
}

// this is the duration of the animations
const transitionTime = 500;
// Menu at the top right
const form = document.querySelector<HTMLFormElement>('#ui')!;
form.addEventListener('change', updateUI);

document.querySelector('#layout')!.addEventListener('click', evt => {
  evt.preventDefault();
  ogma.layouts.force();
});

// add the double click feature to open/close groups
ogma.events.on('doubleclick', evt => {
  if (evt.target) {
    if (evt.target.isNode && evt.target.isVirtual()) {
      // check if it's closed or open group
      const isOpen = isVisualGroup(evt.target);
      // update the UI accordingly now
      form['area'].forEach(checkbox => {
        if (checkbox.value === evt.target!.getData('area')) {
          checkbox.checked = !isOpen;
        }
      });
      updateUI();
    }
    return;
  }
  ogma.view.zoomIn({ duration: 300 });
});

const isVisualGroup = node =>
  node.isVirtual() &&
  node
    .getSubNodes()
    .isVisible()
    .some(b => {
      return b;
    });

const hexToRgb = (hex: string) => {
  const cleanHex = hex.slice(1);
  const bigint = parseInt(cleanHex, 16);
  const r = (bigint >> 16) & 255;
  const g = (bigint >> 8) & 255;
  const b = bigint & 255;

  return r + ',' + g + ',' + b;
};

function createGroupingTransformation(area: string) {
  const transformation = ogma.transformations.addNodeGrouping({
    selector: node => node.getData('area') === area,
    groupIdFunction: node => node.getData('area'),
    onGroupUpdate: (_, nodes) => {
      return ogma.layouts.force({ nodes });
    },
    nodeGenerator: (nodeList, groupId) => {
      const opens = getOpens('area');
      const originalColor = nodeList.getAttribute('color')[0];
      // make visual group color a bit lighter playing with the alpha channel
      const color = opens[nodeList.getData('area')[0]]
        ? 'rgba(' + hexToRgb(originalColor) + ', 0.4)'
        : originalColor;
      return {
        id: 'grouped-node-' + groupId,
        attributes: {
          text: groupId + ' Group',
          radius: 10,
          color: color
        },
        // add some content to be picked up later on in the tooltip
        data: {
          ids: nodeList.getId(),
          area: groupId
        }
      };
    },
    showContents: node => {
      const opens = getOpens('area');
      return opens[node.getData('area')];
    },
    enabled: false // create the transformation but do not activate it
  });
  // add a prop on the transformation to know the criteria used here
  transformation.area = area;
  return transformation;
}

const edgeGrouping = ogma.transformations.addEdgeGrouping({
  enabled: false // create the transformation but do not activate it
});
const filterSwitch = ogma.transformations.addNodeFilter({
  criteria: node => node.isVirtual() || /^router/.test(node.getId()), // just keep the router nodes,
  enabled: false // create the transformation but do not activate it
});

// Disable the connectivity form when nodes are grouped
const updateConnectivityUI = enable => {
  for (let i = 0; i < 2; i++) {
    form.elements['edge-group'][i].disabled = !enable;
    form.elements['filter-switch'][i].disabled = !enable;
  }
};

const executeGrouping = (toGroup, opens) =>
  Promise.all(
    nodeGroupings.map(nodeGrouping => {
      // enable the transformation when either it has to node group or to visual group
      const enableTransformation =
        (toGroup && !opens[nodeGrouping.area]) || opens[nodeGrouping.area];
      if (nodeGrouping.isEnabled() === enableTransformation) {
        return nodeGrouping.refresh();
      }
      if (enableTransformation) {
        return nodeGrouping.enable(transitionTime);
      }
      return nodeGrouping.disable(transitionTime);
    })
  );

const execute = (transformation, enable) => {
  // avoid extra work if we're already in the state requested
  if (transformation.isEnabled() === enable) {
    return Promise.resolve();
  }
  // if now toggle the state with animation
  if (enable) {
    return transformation.enable(transitionTime);
  }
  return transformation.disable(transitionTime);
};

const getOpens = (name: string) => {
  const checkboxes = form[name];
  const opens = {};
  return [...checkboxes].reduce((input, acc) => {
    acc[input.value] = input.checked;
    return acc;
  }, {});
};

function getMode(id: string) {
  const select = form[id];
  const currentMode = Array.prototype.filter.call(select, input => {
    return input.checked;
  })[0].value; // IE inconsistency
  return currentMode;
}
html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />

    <link type="text/css" rel="stylesheet" href="style.css" />
    <link
      href="https://cdn.jsdelivr.net/npm/lucide-static@0.483.0/font/lucide.css"
      rel="stylesheet"
    />
    <link
      href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap"
      rel="stylesheet"
    />
  </head>

  <body>
    <div id="graph-container"></div>
    <form class="toolbar" id="ui">
      <div class="section mode">
        <h3>Connectivity</h3>
        <div class="switch switch--horizontal">
          <input
            type="radio"
            name="edge-group"
            value="detail"
            checked="checked"
          />
          <label for="detail">All Edges</label>
          <input type="radio" name="edge-group" value="group" />
          <label for="group">Backbone</label>
          <span class="toggle-outside">
            <span class="toggle-inside"></span>
          </span>
        </div>
        <div class="switch switch--horizontal">
          <input
            type="radio"
            name="filter-switch"
            value="full"
            checked="checked"
          />
          <label for="full">Show all</label>
          <input type="radio" name="filter-switch" value="filter" />
          <label for="filter">Router only</label>
          <span class="toggle-outside">
            <span class="toggle-inside"></span>
          </span>
        </div>
      </div>
      <div class="section mode">
        <h3>Overview</h3>
        <div class="switch switch--horizontal">
          <input
            type="radio"
            name="node-group"
            value="detail"
            checked="checked"
          />
          <label for="detail">All Nodes</label>
          <input type="radio" name="node-group" value="group" />
          <label for="group">By Area</label>
          <span class="toggle-outside">
            <span class="toggle-inside"></span>
          </span>
        </div>
      </div>
      <div class="section mode">
        <h3>Open Group</h3>
        <div class="checkboxes">
          <input type="checkbox" id="yellow" name="area" value="yellow" />
          <label for="yellow">Yellow</label>

          <input type="checkbox" id="purple" name="area" value="purple" />
          <label for="purple">Purple</label>

          <input type="checkbox" id="green" name="area" value="green" />
          <label for="green">Green</label>
        </div>
      </div>
      <div class="controls">
        <button id="layout" class="btn menu">Layout</button>
      </div>
    </form>
    <script src="index.ts"></script>
  </body>
</html>
css
:root {
  --font: 'IBM Plex Sans', sans-serif;
}

body {
  margin: 0;
  padding: 0;
  width: 100%;
  height: 100%;
  font-family: var(--font);
}

*,
*:before,
*:after {
  box-sizing: border-box;
}

#graph-container {
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  position: absolute;
  margin: 0;
  overflow: hidden;
}

.toolbar {
  display: block;
  position: absolute;
  top: 20px;
  right: 20px;
  padding: 10px;
  box-shadow: 0 1px 5px rgba(0, 0, 0, 0.65);
  border-radius: 4px;
  background: #ffffff;
  color: #222222;
  font-weight: 300;
}

.toolbar .section {
  position: relative;
  display: block;
}

.toolbar .section h3 {
  display: block;
  font-weight: 300;
  border-bottom: 1px solid #ddd;
  color: #606060;
  font-size: 1rem;
}

.toolbar .section .clearable-input {
  border-radius: 4px;
  padding: 5px;
  border: 1px solid #dddddd;
}

.toolbar .section .line-color,
.ogma-tooltip .line-color {
  display: inline-block;
  width: 1rem;
  height: 1rem;
  margin-right: 0.25rem;
  border-radius: 50%;
  color: #ffffff;
  text-align: center;
  font-size: 0.65rem;
  line-height: 1rem;
  font-weight: bold;
  vertical-align: text-top;
}

.toolbar .section p {
  padding-left: 18px;
  padding-right: 18px;
}

.toolbar .section p label {
  width: 4rem;
  display: inline-block;
}

.toolbar .mode {
  text-align: center;
}

.toolbar .disabled {
  display: none;
}

.ogma-tooltip-header .title {
  font-weight: bold;
  text-transform: uppercase;
}

/* --- Tooltip */
.ogma-tooltip {
  max-width: 240px;
  max-height: 280px;
  background-color: #fff;
  border: 1px solid #999;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
  border-radius: 6px;
  cursor: auto;
  font-size: 12px;
  pointer-events: none;
}

.ogma-tooltip-header {
  font-variant: small-caps;
  font-size: 120%;
  color: #000;
  border-bottom: 1px solid #999;
  padding: 10px;
}

.ogma-tooltip-body {
  padding: 10px;
  overflow-x: hidden;
  overflow-y: auto;
  max-width: inherit;
  max-height: 180px;
}

.ogma-tooltip-body th {
  color: #999;
  text-align: left;
}

.ogma-tooltip-footer {
  padding: 10px;
  border-top: 1px solid #999;
}

.ogma-tooltip > .arrow {
  border-width: 10px;
  position: absolute;
  display: block;
  width: 0;
  height: 0;
  border-color: transparent;
  border-style: solid;
}

.ogma-tooltip.top {
  margin-top: -12px;
}

.ogma-tooltip.top > .arrow {
  left: 50%;
  bottom: -10px;
  margin-left: -10px;
  border-top-color: #999;
  border-bottom-width: 0;
}

.ogma-tooltip.bottom {
  margin-top: 12px;
}

.ogma-tooltip.bottom > .arrow {
  left: 50%;
  top: -10px;
  margin-left: -10px;
  border-bottom-color: #999;
  border-top-width: 0;
}

.ogma-tooltip.left {
  margin-left: -12px;
}

.ogma-tooltip.left > .arrow {
  top: 50%;
  right: -10px;
  margin-top: -10px;
  border-left-color: #999;
  border-right-width: 0;
}

.ogma-tooltip.right {
  margin-left: 12px;
}

.ogma-tooltip.right > .arrow {
  top: 50%;
  left: -10px;
  margin-top: -10px;
  border-right-color: #999;
  border-left-width: 0;
}

/* -- CSS flip switch */
.switch {
  width: 100%;
  position: relative;
}

.switch input {
  position: absolute;
  top: 0;
  z-index: 2;
  opacity: 0;
  cursor: pointer;
}
.switch input:checked {
  z-index: 1;
}
.switch input:checked + label {
  opacity: 1;
  cursor: default;
}
.switch input:not(:checked) + label:hover {
  opacity: 0.5;
}
.switch label {
  color: #222222;
  opacity: 0.33;
  transition: opacity 0.25s ease;
  cursor: pointer;
}
.switch .toggle-outside {
  height: 100%;
  border-radius: 2rem;
  padding: 0.25rem;
  overflow: hidden;
  transition: 0.25s ease all;
}
.switch .toggle-inside {
  border-radius: 2.5rem;
  background: #4a4a4a;
  position: absolute;
  transition: 0.25s ease all;
}
.switch--horizontal {
  width: 15rem;
  height: 2rem;
  margin: 0 auto;
  font-size: 0;
  margin-bottom: 1rem;
}
.switch--horizontal input {
  height: 2rem;
  width: 5rem;
  left: 5rem;
  margin: 0;
}
.switch--horizontal label {
  font-size: 1rem;
  line-height: 2rem;
  display: inline-block;
  width: 5rem;
  height: 100%;
  margin: 0;
  text-align: center;
}
.switch--horizontal label:last-of-type {
  margin-left: 5rem;
}
.switch--horizontal .toggle-outside {
  background: #dddddd;
  position: absolute;
  width: 5rem;
  left: 5rem;
}
.switch--horizontal .toggle-inside {
  height: 1.5rem;
  width: 1.5rem;
}
.switch--horizontal input:checked ~ .toggle-outside .toggle-inside {
  left: 0.25rem;
}
.switch--horizontal input ~ input:checked ~ .toggle-outside .toggle-inside {
  left: 3.25rem;
}
.switch--horizontal input:disabled ~ .toggle-outside .toggle-inside {
  background: #9a9a9a;
}
.switch--horizontal input:disabled ~ label {
  color: #9a9a9a;
}

.hidden {
  display: none;
}

.controls {
  text-align: center;
  margin-top: 5px;
}

.control-bar {
  font-family: Helvetica, Arial, sans-serif;
  box-shadow: 0 1px 5px rgba(0, 0, 0, 0.65);
  border-radius: 4px;
}

.btn {
  padding: 6px 8px;
  background-color: white;
  cursor: pointer;
  font-size: 18px;
  border: none;
  border-radius: 5px;
  outline: none;
}

.btn:hover {
  color: #333;
  background-color: #e6e6e6;
}

.menu {
  border: 1px solid #ddd;
  width: 80%;
  font-size: 14px;
  margin-top: 10px;
}
json
{
  "nodes": [
    {
      "id": "router-0",
      "attributes": {
        "radius": 10,
        "color": "#67328E",
        "text": "Router 1\nPurple Area"
      },
      "data": {
        "area": "purple"
      }
    },
    {
      "id": "router-1",
      "attributes": {
        "radius": 10,
        "color": "#67328E",
        "text": "Router 2\nPurple Area"
      },
      "data": {
        "area": "purple"
      }
    },
    {
      "id": "server-0",
      "attributes": {
        "radius": 8,
        "color": "#67328E",
        "text": "Server 1\nPurple Area"
      },
      "data": {
        "area": "purple"
      }
    },
    {
      "id": "server-1",
      "attributes": {
        "radius": 8,
        "color": "#67328E",
        "text": "Server 2\nPurple Area"
      },
      "data": {
        "area": "purple"
      }
    },
    {
      "id": "router-2",
      "attributes": {
        "radius": 10,
        "color": "#328E5B",
        "text": "Router 1\nGreen Area"
      },
      "data": {
        "area": "green"
      }
    },
    {
      "id": "router-3",
      "attributes": {
        "radius": 10,
        "color": "#328E5B",
        "text": "Router 2\nGreen Area"
      },
      "data": {
        "area": "green"
      }
    },
    {
      "id": "server-2",
      "attributes": {
        "radius": 8,
        "color": "#328E5B",
        "text": "Server 1\nGreen Area"
      },
      "data": {
        "area": "green"
      }
    },
    {
      "id": "router-4",
      "attributes": {
        "radius": 10,
        "color": "#F2C931",
        "text": "Router 1\nYellow Area"
      },
      "data": {
        "area": "yellow"
      }
    },
    {
      "id": "router-5",
      "attributes": {
        "radius": 10,
        "color": "#F2C931",
        "text": "Router 2\nYellow Area"
      },
      "data": {
        "area": "yellow"
      }
    },
    {
      "id": "server-3",
      "attributes": {
        "radius": 8,
        "color": "#F2C931",
        "text": "Server 1\nYellow Area"
      },
      "data": {
        "area": "yellow"
      }
    },
    {
      "id": "server-4",
      "attributes": {
        "radius": 8,
        "color": "#F2C931",
        "text": "Server 2\nYellow Area"
      },
      "data": {
        "area": "yellow"
      }
    }
  ],
  "edges": [
    {
      "id": "external-0",
      "source": "router-0",
      "target": "router-2"
    },
    {
      "id": "external-1",
      "source": "router-0",
      "target": "router-4"
    },
    {
      "id": "external-2",
      "source": "router-1",
      "target": "router-5"
    },
    {
      "id": "internal-0",
      "source": "router-0",
      "target": "router-1"
    },
    {
      "id": "internal-1",
      "source": "router-2",
      "target": "router-3"
    },
    {
      "id": "internal-2",
      "source": "router-4",
      "target": "router-5"
    },
    {
      "id": "internal-3",
      "source": "router-4",
      "target": "server-3"
    },
    {
      "id": "internal-4",
      "source": "router-4",
      "target": "server-4"
    },
    {
      "id": "internal-5",
      "source": "router-5",
      "target": "server-4"
    },
    {
      "id": "internal-6",
      "source": "router-3",
      "target": "server-2"
    },
    {
      "id": "internal-7",
      "source": "router-0",
      "target": "server-0"
    },
    {
      "id": "internal-8",
      "source": "router-1",
      "target": "server-1"
    },
    {
      "id": "internal-9",
      "source": "router-1",
      "target": "server-1"
    },
    {
      "id": "internal-10",
      "source": "router-1",
      "target": "server-1"
    },
    {
      "id": "internal-11",
      "source": "router-3",
      "target": "server-2"
    },
    {
      "id": "internal-12",
      "source": "router-0",
      "target": "router-1"
    },
    {
      "id": "internal-13",
      "source": "server-3",
      "target": "server-4"
    }
  ]
}