Skip to content
  1. Examples

Fraud detection

This example shows how to use Ogma to create a fraud detection system.

ts
import Ogma, { Node as OgmaNode, StyleRule } from '@linkurious/ogma';

// Retrieve the fake database defined in `dummyDatabase.js`
import * as DB from './dummyDatabase';
import { getIconCode, getNodeTypeColor } from './utils';
import { Autocomplete } from './autocomplete';
import { addStyles } from './styles';
import { bindTooltip } from './tooltip';
import { Legend } from './legend';

// Constants used to configure the camera and the layouts
const LAYOUT_DURATION = 400;
const LOCATE_OPTIONS = {
  duration: 400,
  padding: { top: 200, bottom: 80, left: 50, right: 50 }
};

// Create an instance of Ogma
// We know we are dealing with a small graph, so we can afford to use the canvas renderer
const ogma = new Ogma({ renderer: 'canvas' });

// Retrieve the important elements of the UI
const panel = document.getElementById('panel') as HTMLDivElement;
const form = document.getElementsByClassName("layout-switch")[0] as HTMLFormElement;
const menuToggle = document.getElementById('menu-toggle') as HTMLButtonElement;
const searchBar = document.getElementById('searchbar') as HTMLDivElement;
const searchInput = document.getElementById('search-input') as HTMLInputElement;
const boxHideEvaluators = document.getElementById('evaluators') as HTMLInputElement;
const boxHideSmallClaims = document.getElementById('claims') as HTMLInputElement;
const boxHideLeaves = document.getElementById('leaves') as HTMLInputElement;
const boxTexts = document.getElementById('text') as HTMLInputElement;
const boxColors = document.getElementById('color') as HTMLInputElement;
const boxIcons = document.getElementById('icon') as HTMLInputElement;
const boxClaims = document.getElementById('claim-size') as HTMLInputElement;
const boxShowLegend = document.getElementById('legend') as HTMLInputElement;
const buttonForceLink = document.getElementById('network') as HTMLLabelElement;
const buttonHierarchical = document.getElementById('hierarchical') as HTMLLabelElement;
const buttonCenter = document.getElementById('focus') as HTMLButtonElement;
const buttonZoomOut = document.getElementById('zoom-out') as HTMLButtonElement;
const buttonZoomIn = document.getElementById('zoom-in') as HTMLButtonElement;
const buttonReset = document.getElementById('reset') as HTMLButtonElement;

// Initialize the filters and rules to null
let evaluatorFilter: ReturnType<typeof ogma.transformations.addNodeFilter> | null = null,
  smallClaimFilter: ReturnType<typeof ogma.transformations.addNodeFilter> | null = null,
  leafFilter: ReturnType<typeof ogma.transformations.addNodeFilter> | null = null,
  textRule: StyleRule | null = null,
  colorRule: StyleRule | null = null,
  iconRule: StyleRule | null = null,
  sizeRule: StyleRule | null = null;

// Main function, initializes the visualization
const init = () => {
  // Assign the HTML container to Ogma, and set the background color to be a bit darker that white
  ogma.setContainer('ogma-container');
  ogma.setOptions({ backgroundColor: '#F0F0F0' });

  // Bind the buttons/checkboxes to the associated actions
  menuToggle.addEventListener('click', toggleMenu);
  boxHideEvaluators.addEventListener('click', applyEvaluatorFilter);
  boxHideSmallClaims.addEventListener('click', applySmallClaimFilter);
  boxHideLeaves.addEventListener('click', applyLeafFilter);
  boxTexts.addEventListener('click', applyTextRule);
  boxColors.addEventListener('click', applyColorRule);
  boxIcons.addEventListener('click', applyIconRule);
  boxClaims.addEventListener('click', applyClaimSizeRule);
  boxShowLegend.addEventListener('click', toggleLegend);
  buttonForceLink.addEventListener('click', () => {applyLayout(true)});
  buttonHierarchical.addEventListener('click', () => {applyLayout(false)});
  buttonZoomIn.addEventListener('click', zoomIn);
  buttonZoomOut.addEventListener('click', zoomOut);
  buttonCenter.addEventListener('click', centerView);
  buttonReset.addEventListener('click', reset);

  addStyles(ogma);
  bindTooltip(ogma);

  // Expand a node when double-clicked
  ogma.events.on('doubleclick', evt => {
    if (evt.target && evt.target.isNode && evt.button === 'left') {
      expandNeighbors(evt.target);

      // Clicking on a node adds it to the selection, but we don't want a node to
      // be selected when double-clicked
      evt.target.setSelected(!evt.target.isSelected());
    }
  });

  boxTexts.checked = true;
  boxColors.checked = true;
  boxIcons.checked = true;

  // Apply the text, color and icon rules
  applyTextRule();
  applyColorRule();
  applyIconRule();

  ac.searchNode("Keeley Bins");
};

// Retrieve the list of adjacent edges to the specified nodes, for which both the
// source and target are already loaded in Ogma (in the viz)
const selectAdjacentEdgesToAdd = (nodeIds: number[]) =>
  DB.getAdjacentEdges(nodeIds).filter(edge => {
    return ogma.getNode(edge.source) && ogma.getNode(edge.target);
  });

// Expand the specified node by retrieving its neighbors from the database
// and adding them to the visualization
const expandNeighbors = (node: OgmaNode) => {
  // Retrieve the neighbors from the DB
  const neighbors = DB.getNeighbors(node.getId() as number),
    ids = neighbors.nodeIds,
    nodes = neighbors.nodes;

  // If this condition is false, it means that all the retrieved nodes are already in Ogma.
  // In this case we do nothing
  if (ogma.getNodes(ids).size < ids.length) {
    // Set the position of the neighbors around the nodes, in preparation to the force-directed layout
    let position = node.getPosition(),
      angleStep = (2 * Math.PI) / neighbors.nodes.length,
      angle = Math.random() * angleStep;

    for (let i = 0; i < nodes.length; ++i) {
      const neighbor = nodes[i];
      neighbor.attributes = {
        x: position.x + Math.cos(angle) * 0.001,
        y: position.y + Math.sin(angle) * 0.001
      };

      angle += angleStep;
    }

    // Add the neighbors to the visualization, add their adjacent edges and run a layout
    return ogma
      .addNodes(nodes)
      .then(() => ogma.addEdges(selectAdjacentEdgesToAdd(ids)))
      .then(() => runForceLayout());
  }
};

/* ============================== */
/* Function triggered by the menu */
/* ============================== */

const toggleMenu = () => {
  panel.classList.toggle('closed');
  if (panel.classList.contains('closed')) {
    menuToggle.title = 'Open menu';
  } else {
    menuToggle.title = 'Hide menu';
  }
};

const applyLayout = (isNetwork: boolean) => {
  const network = form[0] as HTMLInputElement;
  const hierarchical = form[1] as HTMLInputElement;
  if (isNetwork) {
    runForceLayout();
    network.checked = true; // Check the network button
    hierarchical.checked = false; // Uncheck the hierarchical button
  } else {
    runHierarchical();
    network.checked = false; // Uncheck the network button
    hierarchical.checked = true; // Check the hierarchical button
  }
};

const applyEvaluatorFilter = () => {
  if (boxHideEvaluators.checked && !evaluatorFilter) {
    evaluatorFilter = ogma.transformations.addNodeFilter(node => {
      return node.getData('type') !== 'Evaluator';
    });
  } else if (!boxHideEvaluators.checked && evaluatorFilter) {
    evaluatorFilter.destroy();
    evaluatorFilter = null;
  }
};

const applySmallClaimFilter = () => {
  if (boxHideSmallClaims.checked && !smallClaimFilter) {
    smallClaimFilter = ogma.transformations.addNodeFilter(node => {
      return (
        node.getData('type') !== 'Claim' ||
        node.getData('properties.amount') >= 50000
      );
    });
  } else if (!boxHideSmallClaims.checked && smallClaimFilter) {
    smallClaimFilter.destroy();
    smallClaimFilter = null;
  }
};

const applyLeafFilter = () => {
  if (boxHideLeaves.checked && !leafFilter) {
    leafFilter = ogma.transformations.addNodeFilter(node => {
      return node.getAdjacentNodes().size > 1;
    });
  } else if (!boxHideLeaves.checked && leafFilter) {
    leafFilter.destroy();
    leafFilter = null;
  }
};

const applyTextRule = () => {
  if (boxTexts.checked && !textRule) {
    textRule = ogma.styles.addNodeRule({
      text: {
        content: node => {
          const type = node.getData('type');

          if (
            type === 'Customer' ||
            type === 'Lawyer' ||
            type === 'Evaluator'
          ) {
            return node.getData('properties.fullname');
          } else if (
            type === 'Phone' ||
            type === 'MailAddress' ||
            type === 'SSN'
          ) {
            return node.getData('properties.name');
          } else if (type === 'Address') {
            return (
              node.getData('properties.city') +
              ', ' +
              node.getData('properties.state')
            );
          } else if (type === 'Claim') {
            return (
              node.getData('properties.name') +
              ' (' +
              node.getData('properties.amount') +
              '$)'
            );
          }
        }
      }
    });
  } else if (!boxTexts.checked && textRule) {
    textRule.destroy();
    textRule = null;
  }
};

const applyColorRule = () => {
  if (boxColors.checked && !colorRule) {
    colorRule = ogma.styles.addNodeRule({
      color: (node: OgmaNode) => getNodeTypeColor(node.getData('type'))
    });
  } else if (!boxColors.checked && colorRule) {
    colorRule.destroy();
    colorRule = null;
  }
};

const applyIconRule = () => {
  if (boxIcons.checked && !iconRule) {
    iconRule = ogma.styles.addNodeRule({
      icon: {
        font: "lucide",
        content: (node) => getIconCode(node.getData('type'))
      }
    });
  } else if (!boxIcons.checked && iconRule) {
    iconRule.destroy();
    iconRule = null;
  }
};

const applyClaimSizeRule = () => {
  if (boxClaims.checked && !sizeRule) {
    sizeRule = ogma.styles.addNodeRule(
      node => {
        return node.getData('type') === 'Claim';
      },
      {
        radius: ogma.rules.slices({
          field: 'properties.amount',
          values: { min: 8, max: 24 },
          stops: { min: 48000, max: 52000 }
        })
      }
    );
  } else if (!boxClaims.checked && sizeRule) {
    sizeRule.destroy();
    sizeRule = null;
  }
};

const toggleLegend = () => {
  legend.update(boxShowLegend.checked);
};

// Utility function to run a layout
const runLayout = (name: string) => {
  if (name === 'force') {
    ogma.layouts.force({
      locate: LOCATE_OPTIONS,
      duration: LAYOUT_DURATION,
    });
  } else {
    ogma.layouts.hierarchical({
      locate: LOCATE_OPTIONS,
      duration: LAYOUT_DURATION
    });
  }
}

const runForceLayout = () => runLayout('force');
const runHierarchical = () => runLayout('hierarchical');

const reset = () => {
  ogma.clearGraph();
  ogma.setGraph(DB.getFullGraph()).then(() => {
    ogma.layouts.force({
      locate: LOCATE_OPTIONS,
      duration: LAYOUT_DURATION
    });
  });
  boxHideEvaluators.checked = false;
  boxHideSmallClaims.checked = false;
  boxHideLeaves.checked = false;
  boxTexts.checked = true;
  boxColors.checked = true;
  boxIcons.checked = true;
  boxClaims.checked = false;
  boxShowLegend.checked = false;

  applyTextRule();
  applyLeafFilter();
  applySmallClaimFilter();
  applyEvaluatorFilter();
  applyClaimSizeRule();
  applyColorRule();
  applyIconRule();
  toggleLegend();
};

/* ================================================== */
/* Function triggered by buttons in the visualization */
/* ================================================== */

const zoomIn = () => {
  ogma.view.zoomIn({
    duration: 150,
    easing: 'quadraticOut'
  });
};

const zoomOut = () => {
  ogma.view.zoomOut({
    duration: 150,
    easing: 'quadraticOut'
  });
};

const centerView = () => ogma.view.locateGraph(LOCATE_OPTIONS);

ogma.setGraph(DB.getFullGraph()).then(() => {
  // Run the force layout after the graph is set
  runForceLayout();
});

const ac = new Autocomplete(ogma, DB.getFullNames(), searchInput, selectAdjacentEdgesToAdd, runForceLayout);
ac.createAutocomplete(searchBar);
const legend = new Legend(ogma);

init();
html
<html>
  <head>
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <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"
    />
    <script src="https://cdn.jsdelivr.net/npm/@linkurious/ogma-ui-kit@0.0.9/dist/ogma-ui-kit.min.js"></script>
    <link
      rel="stylesheet"
      type="text/css"
      href="https://cdn.jsdelivr.net/npm/lucide-static@0.483.0/font/lucide.css"
      id="lucide-css"
    />
    <link rel="stylesheet" type="text/css" href="./style.css" />
    <title>Ogma Fraud Detection</title>
  </head>
  <body>
    <div id="ogma-container" style="position: relative"></div>
    <div class="panel" id="panel">
      <div class="title-container">
        <img alt="Fingerprint Icon" src="img/fingerprint.svg" />
        <span class="hide-closed">Counter Fraud Inc.</span>
        <button title="Hide Menu" class="menu-toggle" id="menu-toggle">
          <i class="icon-chevron-left"></i>
        </button>
      </div>
      <div>
        <div class="title">LAYOUT</div>
        <form class="layout-switch">
          <input class="switch" type="radio" checked="" name="network" />
          <label class="label" for="network" id="network">Network</label>
          <input class="switch" type="radio" name="hierarchical" />
          <label class="label" for="hierarchical" id="hierarchical"
            >Hierarchical</label
          >
        </form>
      </div>
      <sl-divider aria-orientation="horizontal" role="separator"></sl-divider>
      <div>
        <div class="title">FILTERS</div>
        <div class="itemlist-container">
          <div class="checklist-item">
            <sl-checkbox
              size="medium"
              form=""
              data-optional=""
              data-valid=""
              id="evaluators"
            >
              Hide evaluators
            </sl-checkbox>
          </div>
          <div class="checklist-item">
            <sl-checkbox
              size="medium"
              form=""
              data-optional=""
              data-valid=""
              id="claims"
            >
              Hide claims &lt; 50K$
            </sl-checkbox>
          </div>
          <div class="checklist-item">
            <sl-checkbox
              size="medium"
              form=""
              data-optional=""
              data-valid=""
              id="leaves"
            >
              Hide leaf nodes
            </sl-checkbox>
          </div>
        </div>
      </div>
      <sl-divider aria-orientation="horizontal" role="separator"></sl-divider>
      <div id="design">
        <div class="title">DESIGN</div>
        <div class="itemlist-container">
          <div class="checklist-item">
            <sl-checkbox
              size="medium"
              checked=""
              form=""
              data-optional=""
              data-valid=""
              id="text"
            >
              Node text
            </sl-checkbox>
          </div>
          <div class="checklist-item">
            <sl-checkbox
              size="medium"
              checked=""
              form=""
              data-optional=""
              data-valid=""
              id="color"
            >
              Node color
            </sl-checkbox>
          </div>
          <div class="checklist-item">
            <sl-checkbox
              size="medium"
              checked=""
              form=""
              data-optional=""
              data-valid=""
              id="icon"
            >
              Node icon
            </sl-checkbox>
          </div>
          <div class="checklist-item">
            <sl-checkbox
              size="medium"
              form=""
              data-optional=""
              data-valid=""
              id="claim-size"
            >
              Claim size
            </sl-checkbox>
          </div>
          <div class="checklist-item">
            <sl-checkbox
              size="medium"
              form=""
              data-optional=""
              data-valid=""
              id="legend"
            >
              Legend
            </sl-checkbox>
          </div>
        </div>
      </div>
    </div>
    <div class="searchbar-container">
      <div class="searchbar" id="searchbar">
        <button title="Search" class="btn" id="search">
          <i class="icon-search"></i>
        </button>
        <input
          placeholder="Enter a name"
          autocomplete="off"
          type="text"
          value=""
          id="search-input"
        />
      </div>
    </div>
    <div class="buttons">
      <button class="button" title="Zoom In" id="zoom-in">
        <i class="icon-plus"></i>
      </button>
      <button class="button" title="Zoom Out" id="zoom-out">
        <i class="icon-minus"></i>
      </button>
      <button class="button" title="Reset Graph" id="reset">
        <i class="icon-rotate-cw"></i>
      </button>
      <button class="button" title="Focus on Graph" id="focus">
        <i class="icon-focus"></i>
      </button>
    </div>
    <script type="module" src="./index.ts"></script>
  </body>
</html>
css
:root {
  --base-color: #4999f7;
  --active-color: var(--base-color);
  --gray: #d9d9d9;
  --dark-color: #3a3535;
  --timeline-bar-fill: #166397;
  --timeline-bar-selected-fill: #ff9685;
  --timeline-bar-selected-stroke: #ff6c52;
  --timeline-bar-filtered-fill: #99d6ff;
}

body {
  margin: 0;
  padding: 0;
  width: 100vw;
  height: 100vh;
  font-size: 12px;
  max-height: 100vh;
  overflow: hidden;
}

#root {
  width: 100%;
  height: 100%;
}

#ogma-container {
  width: 100%;
  height: 100%;
}

.buttons {
  position: absolute;
  bottom: 20;
  right: 20;
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.button {
  background-color: #fff;
  width: 27px;
  height: 27px;
  font-size: 16px;
  border-radius: 4px;
  box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.15);
  border: none;
  transition: all 0.2s ease-in-out;
}

.button:hover {
  background-color: #f0f0f0;
  cursor: pointer;
}

.itemlist-container {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.layout-switch {
  display: flex;
  flex-direction: row;
  align-items: center;
  background-color: #ddd;
  padding: 2px;
  border-radius: 4px;
  width: calc(100% - 4px);
  height: 28px;
  margin: 0;
}

.label {
  color: #525252;
  display: flex;
  width: 100%;
  height: 100%;
  align-items: center;
  justify-content: center;
  border-radius: 2px;
  transition: all 0.3s;
  cursor: pointer;
}

.switch {
  display: none;
}

.switch:checked + .label {
  background-color: white;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
  cursor: default;
}

.legend {
  position: fixed;
  display: flex;
  flex-direction: column;
  background-color: white;
  width: 200px;
  padding: 12px 16px;
  border-radius: 12px;
  bottom: 20px;
  right: 60px;
  gap: 8px;
}

.legend h3 {
  margin: 0;
  font-size: 14px;
  font-weight: normal;
}

.legend ul {
  display: flex;
  flex-direction: column;
  list-style: none;
  padding: 0;
  margin: 0;
  gap: 4px;
}

.legend ul li {
  display: flex;
  align-items: center;
  gap: 8px;
}

.legend-icon {
  border-radius: 50%;
}

.legend ul li i {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 20px;
  height: 20px;
  font-size: 16px;
}

.optionlist {
  position: absolute;
  z-index: 10000;
  background-color: white;
  border: 1px solid #ccc;
  width: 316px;
  max-height: 220px;
  overflow-y: auto;
  list-style: none;
  padding: 0;
  margin: 0;
  translate: 0 39px;
  display: block;
  border: 1px solid #1a70e5;
  border-top: none;
  border-radius: 0 0 4px 4px;
}

.optionlist.hidden {
  display: none;
}

.optionlist-item {
  display: flex;
  align-items: center;
  text-align: left;
  height: 35px;
  border-top: 1px solid #999;
  padding: 0 10px;
  color: #000;
  font-weight: normal;
  font-size: 12px;
  cursor: pointer;
}

.optionlist-item:hover {
  background-color: #f0f0f0;
}

.optionlist-item.selected {
  background-color: #d0e0ff;
}

.optionlist-item.selected:hover {
  background-color: #aec6f8;
}

.hide-closed {
  font-size: 14px;
}

.menu-toggle {
  z-index: 1000;
  width: 24px;
  height: 24px;
  padding: 0;
  border: none;
  background-color: white;
  color: black;
  box-shadow: 0px 1px 4px 0px #00000026;
  border-radius: 50%;
  font-size: 16px;
  transform: translateX(30px);
  transition: transform 0.2s ease;
}

.menu-toggle:hover {
  cursor: pointer;
  background-color: #f6f6f6;
}

.title {
  font-size: 14px;
  margin-bottom: 12px;
}

sl-divider {
  margin: 15.75px 0;
}

.graph-toggler {
  display: flex;
  flex-direction: row;
  justify-content: space-between;
}

.panel .title-container {
  display: flex;
  align-items: center;
  gap: 15px;
  margin-bottom: 30px;
}

/* Quand le panneau est fermé */
.panel {
  gap: 0;
  transition: width 0.2s ease;
}

.panel.closed {
  width: 56px; /* Enough for the icons */
  height: 56px;
}

.panel.closed .title-container {
  margin: 0;
}

/* Hide everything inside the panel *except* the title and the legend */
.panel.closed > *:not(.title-container, #design),
.panel.closed > #design > *:not(:last-child),
.panel.closed > #design > :last-child > *:not(:last-child),
.panel.closed > #design > :last-child > :last-child > *:not(.legend) {
  display: none;
}

.panel.closed .hide-closed {
  display: none;
}

.panel.closed .menu-toggle {
  position: absolute;
  transform: translateX(30px) rotate(180deg);
  transition: transform 0.2s ease;
}

sl-checkbox::part(label) {
  font-size: 14px;
  font-family: 'IBM Plex Sans';
}

.fingerprint-container {
  font-size: 16px;
}

.searchbar-container {
  display: flex;
  justify-content: center;
  position: absolute;
  top: 30px;
  text-align: center;
  width: 100%;
}

.searchbar {
  z-index: 2000;
  background-color: #fff;
  border: 1px solid #e4e4e7;
  border-radius: 4px;
  display: flex;
  flex-direction: row;
  gap: 8px;
  position: relative;
  width: 300px;
  min-height: 30px;
  padding: 4px 8px;
}

.searchbar input {
  display: flex;
  align-items: center;
  height: 100%;
  width: 100%;
  font-size: 12px;
  outline: none;
  border: none;
}

.searchbar input::placeholder {
  color: #71717a;
}

.searchbar .btn {
  background: none;
  border: none;
  border-radius: 4px;
  height: 100%;
  padding: 3px 1px;
  font-size: 16px;
  color: #9f9fa2;
}

.searchbar .btn:hover {
  cursor: pointer;
}

.ogma-tooltip {
  background-color: white;
  width: 200px;
  padding: 14px 16px;
  border-radius: 8px;
  box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.15);
}

.ogma-tooltip-header {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin-bottom: 12px;
}

.ogma-tooltip-title {
  font-size: 14px;
}

.ogma-tooltip-header-title {
  display: flex;
  align-items: center;
  gap: 8px;
}

.ogma-tooltip-header-icon-container {
  width: 24px;
  height: 24px;
  font-size: 18px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
}

.ogma-tooltip-header-description {
  display: flex;
  gap: 4px;
  align-items: center;
  color: #525252;
}

.ogma-tooltip-header-description-icon-container {
  width: 13px;
  height: 13px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 11px;
}

.ogma-tooltip-data {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.ogma-tooltip-data-entry {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.ogma-tooltip-data-key {
  font-size: 12px;
  color: #525252;
}

.ogma-tooltip-data-value {
  font-size: 14px;
  color: #1b1b1b;
}
ts
import { createOptionList } from './optionlist';
import Ogma from "@linkurious/ogma";
import * as DB from "./dummyDatabase";

export class Autocomplete {
  private ogma: Ogma;
  private sortedNames: string[];
  private input: HTMLInputElement;
  private selectAdjacentEdgesToAdd: (ids: number[]) => any[];
  private runForceLayout: () => void;

  constructor(
    ogma: Ogma,
    sortedNames: string[],
    input: HTMLInputElement,
    selectAdjacentEdgesToAdd: (ids: number[]) => any[],
    runForceLayout: () => void
  ) {
    this.ogma = ogma;
    this.sortedNames = sortedNames;
    this.input = input;
    this.selectAdjacentEdgesToAdd = selectAdjacentEdgesToAdd;
    this.runForceLayout = runForceLayout;
  }

  // Add the given node to the visualization and returns the added node if it exist
  addNodeToViz(name: string) {
    // Look for the node in the DB
    const node = DB.search(name);
    
    if (! node) return;

    this.ogma.getNodesByClassName("pulse").forEach(n => {
      n.removeClass("pulse");
    });
    const addedNode = this.ogma.addNode(node);

    // Node is already in the graph
    if (! addedNode) return this.ogma.getNode(node.id);

    if (this.ogma.getNodes().size === 1) {
      // If this is the first node in the visualization, we simply center the camera on it
      this.ogma.view.locateGraph({
        duration: 400,
        padding: { top: 200, bottom: 80, left: 50, right: 50 }
      });
    } else {
      // If there are more than one node, apply the force layout (force-directed)
      this.ogma
        .addEdges(this.selectAdjacentEdgesToAdd([node.id]))
        .then(() => this.runForceLayout());
    }

    return addedNode;
  };

  searchNode(value?: string) {
    console.log(this.input);
    // If both the input and the value are empty, do nothing
    if (!this.input.value && ! value) return;
  
    // Prioritize the value passed as an argument over the input value
    const val = value || this.input.value;
    const node = this.addNodeToViz(val);
    if (node) {
      node.addClass("pulse");
      node.locate({
        duration: 100,
      });
    } else {
      alert(
        'No node has the property "fullname" equal to "' +
          this.input.value +
          '".'
      );
    }
  };

  public createAutocomplete(
    searchBar: HTMLDivElement
  ) {

    const buttonSearch = document.getElementById("search") as HTMLButtonElement;
    buttonSearch.addEventListener('click', () => this.searchNode());

    let position = 0;

    // Click handler for the options
    const handleOptionClick = (name: string) => {
      this.input.value = name;
      optionList.hide();
      position = 0;
      this.searchNode(name);
      this.input.blur();
    }

    const optionList = createOptionList({
      filteredNames: [],
      handleOptionClick: handleOptionClick
    });

    // Update the options
    Array.prototype.forEach.call(['input', 'focus'], eventName =>
      this.input.addEventListener(eventName, () => {
        if (eventName === 'focus') {
          searchBar.style.border = "1px solid #1A70E5";
          searchBar.style.borderRadius = "4px 4px 0 0"
        }

        const filtered = this.sortedNames.filter(name =>
          name.toLowerCase().includes(this.input.value.toLowerCase())
        );

        position = 0; // Reset position on input change

        // If the amount of filtered stations is greater than 0, show and update the option list
        if (filtered.length > 0) {
          optionList.update(filtered, position);
          optionList.show();
        } else {
          optionList.hide();
        }
      })
    );

    this.input.addEventListener('keydown', event => {
      // Handle arrow keys and enter key
      const filteredNames = optionList.currentNames;
      if (event.key === 'ArrowDown') {
        event.preventDefault();
        position = (position + 1) % filteredNames.length;
        optionList.updateSelectedOption(position, false);
      } else if (event.key === 'ArrowUp') {
        event.preventDefault();
        position =
          (position - 1 + filteredNames.length) %
          filteredNames.length;
        optionList.updateSelectedOption(position, true);
      } else if (event.key === 'Enter') {
        // If an option is selected, click it
        if (position >= 0 && position < filteredNames.length) {
          handleOptionClick(optionList.getIthStation(position));
        }
      } else if (event.key === 'Escape') {
        // Hide the option list on escape and reset position
        this.input.blur();
        position = 0;
      }
    });

    // On unfocus
    this.input.addEventListener('blur', () => {
      // Delay to allow click on option list
      setTimeout(() => {
        optionList.hide();
        searchBar.style.border = "1px solid #E4E4E7";
        searchBar.style.borderRadius = "4px 4px 4px 4px"
      }, 200);
    });

    // Add the option list after it's created
    this.input.parentNode?.parentNode?.appendChild(optionList.element);
  }
}
json
{
  "nodes": [
    {
      "id": 0,
      "data": {
        "type": "Customer",
        "properties": {
          "country": "USA",
          "fullname": "John Piggyback",
          "age": 32
        },
        "nbNeighbors": 6
      },
      "neighbors": {
        "edges": [
          0,
          1,
          2,
          3,
          4,
          5
        ],
        "nodes": [
          4,
          2,
          3,
          1,
          6,
          5
        ]
      }
    },
    {
      "id": 1,
      "data": {
        "type": "Phone",
        "properties": {
          "name": "123-878-000"
        },
        "nbNeighbors": 2
      },
      "neighbors": {
        "edges": [
          3,
          18
        ],
        "nodes": [
          0,
          15
        ]
      }
    },
    {
      "id": 2,
      "data": {
        "type": "SSN",
        "properties": {
          "name": 985365741
        },
        "nbNeighbors": 1
      },
      "neighbors": {
        "edges": [
          1
        ],
        "nodes": [
          0
        ]
      }
    },
    {
      "id": 3,
      "data": {
        "type": "Address",
        "properties": {
          "city": "Key West",
          "street": "Eisenhower Street",
          "state": "Florida"
        },
        "nbNeighbors": 1
      },
      "neighbors": {
        "edges": [
          2
        ],
        "nodes": [
          0
        ]
      }
    },
    {
      "id": 4,
      "data": {
        "type": "MailAddress",
        "properties": {
          "name": "john.piggyback@gmail.com"
        },
        "nbNeighbors": 1
      },
      "neighbors": {
        "edges": [
          0
        ],
        "nodes": [
          0
        ]
      }
    },
    {
      "id": 5,
      "data": {
        "type": "Claim",
        "properties": {
          "amount": 51000,
          "name": "Property damage"
        },
        "nbNeighbors": 3
      },
      "neighbors": {
        "edges": [
          5,
          6,
          8
        ],
        "nodes": [
          0,
          8,
          7
        ]
      }
    },
    {
      "id": 6,
      "data": {
        "type": "Claim",
        "properties": {
          "amount": 49000,
          "name": "Property Damage"
        },
        "nbNeighbors": 3
      },
      "neighbors": {
        "edges": [
          4,
          7,
          9
        ],
        "nodes": [
          0,
          8,
          7
        ]
      }
    },
    {
      "id": 7,
      "data": {
        "type": "Lawyer",
        "properties": {
          "fullname": "Keeley Bins"
        },
        "nbNeighbors": 3
      },
      "neighbors": {
        "edges": [
          8,
          9,
          10
        ],
        "nodes": [
          5,
          6,
          9
        ]
      }
    },
    {
      "id": 8,
      "data": {
        "type": "Evaluator",
        "properties": {
          "fullname": "Patrick Collison"
        },
        "nbNeighbors": 3
      },
      "neighbors": {
        "edges": [
          6,
          7,
          11
        ],
        "nodes": [
          5,
          6,
          9
        ]
      }
    },
    {
      "id": 9,
      "data": {
        "type": "Claim",
        "properties": {
          "amount": 50999,
          "name": "Property damage"
        },
        "nbNeighbors": 3
      },
      "neighbors": {
        "edges": [
          10,
          11,
          12
        ],
        "nodes": [
          7,
          8,
          10
        ]
      }
    },
    {
      "id": 10,
      "data": {
        "type": "Customer",
        "properties": {
          "fullname": "Werner Stiedemann"
        },
        "nbNeighbors": 5
      },
      "neighbors": {
        "edges": [
          12,
          13,
          14,
          15,
          16
        ],
        "nodes": [
          9,
          11,
          12,
          13,
          14
        ]
      }
    },
    {
      "id": 11,
      "data": {
        "type": "Address",
        "properties": {
          "city": "Alexanemouth",
          "street": "Wuckert Curve",
          "state": "Delaware"
        },
        "nbNeighbors": 1
      },
      "neighbors": {
        "edges": [
          13
        ],
        "nodes": [
          10
        ]
      }
    },
    {
      "id": 12,
      "data": {
        "type": "MailAddress",
        "properties": {
          "name": "soluta@hotmail.com"
        },
        "nbNeighbors": 2
      },
      "neighbors": {
        "edges": [
          14,
          17
        ],
        "nodes": [
          10,
          15
        ]
      }
    },
    {
      "id": 13,
      "data": {
        "type": "Phone",
        "properties": {
          "name": "485-256-662"
        },
        "nbNeighbors": 1
      },
      "neighbors": {
        "edges": [
          15
        ],
        "nodes": [
          10
        ]
      }
    },
    {
      "id": 14,
      "data": {
        "type": "SSN",
        "properties": {
          "name": 196546546
        },
        "nbNeighbors": 1
      },
      "neighbors": {
        "edges": [
          16
        ],
        "n

...
ts
type CustomerDataProperties = {
  country: string,
  fullname: string,
  age: number
}

type PhoneDataProperties = {
  name: string
}

type SSNDataProperties = {
  name: number
}

type AddressDataProperties = {
  city: string,
  street: string,
  state: string,
}

type MailAddressDataProperties = {
  name: string
}

type ClaimDataProperties = {
  amount: number,
  name: string
}

type LawyerDataProperties = {
  fullname: string
}

type EvaluatorDataProperties = {
  fullname: string
}

export type CustomNodeDataType = {
  "Customer": CustomerDataProperties,
  "Phone": PhoneDataProperties,
  "SSN": SSNDataProperties,
  "Address": AddressDataProperties,
  "MailAddress": MailAddressDataProperties,
  "Claim": ClaimDataProperties,
  "Lawyer": LawyerDataProperties,
  "Evaluator": EvaluatorDataProperties,
}
ts
import GRAPH from './data.json';
import { CustomEdge } from './types';

// Search a node by its `fullname` property
export const search = (name: string) =>
  GRAPH.nodes.filter(node => {
    const fullname = node.data.properties.fullname;

    return fullname && fullname.toLowerCase().indexOf(name.toLowerCase()) === 0;
  })[0] || null;

// Retrieve the list of neighbors of a node
export const getNeighbors = (id: number) => {
  const neighborsIds = GRAPH.nodes[id].neighbors;

  return {
    nodeIds: neighborsIds.nodes,
    nodes: neighborsIds.nodes.map(nid => {
      return GRAPH.nodes[nid];
    })
  };
};

// Retrieve the list of adjacent edges of a list of nodes
export const getAdjacentEdges = (ids: number[]) => {
  const edges: CustomEdge[] = [];

  GRAPH.edges.forEach((edge: CustomEdge) => {
    if (ids.indexOf(edge.source) !== -1 || ids.indexOf(edge.target) !== -1) {
      edges.push(edge);
    }
  });

  return edges;
};

export const getFullNames = () => {
  const names: string[] = [];
  GRAPH.nodes.forEach(node => {
    const fullname = node.data.properties.fullname;
    if (fullname) {
      names.push(fullname);
    }
  });
  return names.sort((a, b) => a.localeCompare(b));
}

// Returns the whole graph
export const getFullGraph = () => GRAPH;
ts
import Ogma from "@linkurious/ogma";
import { CustomNodeDataType } from "./dataPropertiesType";
import { getNodeTypeColor, getClassNameFromType } from "./utils";

export class Legend {
  private ogma: Ogma;
  private container: HTMLDivElement;

  constructor(ogma: Ogma) {
    this.ogma = ogma;
    this.container = document.createElement("div");
    this.container.className = "legend";
    this.container.style.display = "none";
    document.body.appendChild(this.container);
  }

  public update(shown: boolean) {
    if (! shown) {
      this.container.style.display = "none";
      return;
    }
    this.container.style.display = "flex";
    this.container.innerHTML = "<h3>LEGEND</h3>";
    const ul = document.createElement("ul");

    const shownTypes = new Set<keyof CustomNodeDataType>();

    this.ogma.getNodes().forEach(node => {
      shownTypes.add(node.getData('type'));
    });

    Array.from(shownTypes).sort((a, b) => a.localeCompare(b)).forEach((type) => {
      const color = getNodeTypeColor(type);
      const iconCode = getClassNameFromType(type);

      const li = document.createElement("li");
      const iconContainer = document.createElement("span");
      iconContainer.className = "legend-icon";
      iconContainer.style.backgroundColor = color;

      const icon = document.createElement("i");
      icon.className = iconCode;
      iconContainer.appendChild(icon);

      li.appendChild(iconContainer);

      const label = document.createElement("span");
      label.className = "legend-label";
      label.textContent = type;
      li.appendChild(label);

      ul.appendChild(li);
    })
    this.container.appendChild(ul);
  }
}
ts
export function createOptionList(options: {
  filteredNames: string[];
  handleOptionClick: (name: string) => void;
}) {
  let currentNames: string[] = options.filteredNames;

  const ul = document.createElement('ul');
  ul.className = 'optionlist';
  ul.style.display = 'none';

  function update(filteredNames: string[], position: number) {
    currentNames = filteredNames;
    ul.innerHTML = '';

    // Generate the options
    filteredNames.forEach((name, i) => {
      const li = document.createElement('li');
      li.className = 'optionlist-item';
      if (i === position) li.classList.add('selected');
      li.addEventListener('click', () => options.handleOptionClick(name));

      const arrow = document.createElement('div');
      arrow.className = 'arrow';

      const label = document.createElement('span');
      label.innerHTML = name;

      li.appendChild(arrow);
      li.appendChild(label);
      ul.appendChild(li);
    });
  }

  // Update the option that is selected
  function updateSelectedOption(position: number, isUp: boolean) {
    let old: Element;
    if (isUp) {
      if (position + 1 === currentNames.length) {
        old = ul.firstElementChild as Element;
      } else {
        old = ul.children[position + 1];
      }
    } else {
      if (position - 1 < 0) {
        old = ul.lastElementChild as Element;
      } else {
        old = ul.children[position - 1];
      }
    }
    const current = ul.children[position];
    old?.classList.remove('selected');
    current?.classList.add('selected');

    // Scroll the selected item into view if it is invisible
    if (isItemInvisible(current)) {
      current?.scrollIntoView({
        block: isUp ? 'start' : 'end',
        inline: 'nearest',
        behavior: 'smooth'
      });
    }
  }

  function show() {
    ul.style.display = 'block';
  }

  function hide() {
    ul.style.display = 'none';
  }

  // Get a station by its index
  function getIthStation(i: number): string {
    return currentNames[i];
  }

  // Checks if an item is invisible
  function isItemInvisible(el: Element) {
    const rect = el.getBoundingClientRect();
    const elemTop = rect.top;
    const elemBottom = rect.bottom - 1;

    const rect2 = ul.getBoundingClientRect();
    const ulTop = rect2.top;
    const ulBottom = rect2.bottom - 1;

    const isInvisible = elemTop <= ulTop || elemBottom >= ulBottom;
    return isInvisible;
  }

  return {
    element: ul,
    update,
    show,
    hide,
    updateSelectedOption,
    getIthStation,
    get currentNames() {
      return currentNames;
    }
  };
}
ts
import Ogma from "@linkurious/ogma";

export const addStyles = (ogma: Ogma) => {
  ogma.styles.createClass({
    name: 'pulse',
    nodeAttributes: {
      pulse: {
        enabled: true,
        endRatio: 5,
        width: 1,
        startColor: 'red',
        endColor: 'red',
        interval: 1000,
        startRatio: 1.0
      }
    }
  })

  // Styling rules that will be applied globally
  ogma.styles.addNodeRule({
    radius: 16,
    text: {
      font: "IBM Plex Sans",
      size: 14
    },
    badges: {
      bottomRight: {
        scale: 0.3,
        color: 'inherit',
        text: {
          scale: 0.5,
          content: (
            node // The bottom right badge displays the number of hidden neighbors
          ) =>
            // The `nbNeighbors` data property contains the total number of neighbors for the node in the DB
            // The `getDegree` method retrieves the number of neighbors displayed in the viz
            node.getData('nbNeighbors') - node.getDegree() || null
        },
        stroke: {
          color: "white"
        }
      }
    }
  });

  ogma.styles.setHoveredNodeAttributes({
    outerStroke: {
      color: "#FFC488",
      width: 9
    },
    text: {
      backgroundColor: "#272727",
      color: "#fff",
      padding: 4
    } 
  });

  ogma.styles.setSelectedNodeAttributes({
    outerStroke: {
      color: "#DE425B",
      width: 9
    },
    text: {
      backgroundColor: "#272727",
      color: "#fff",
      padding: 4
    },
    outline: false
  });

  ogma.styles.addEdgeRule({
    color: "#979595",
    width: 2,
    shape: 'arrow'
  });

  // Don't change any attributes when hovering over an edge
  ogma.styles.setHoveredEdgeAttributes({});
}
ts
import Ogma from "@linkurious/ogma";
import { getNodeTypeColor, getClassNameFromType } from "./utils";

export const bindTooltip = (ogma: Ogma) => {
  // Display the data of nodes as a tooltip on hover
  ogma.tools.tooltip.onNodeHover(
    node => {
      const html = ['<div class="arrow"></div>'];
      const type = node.getData('type');
      const neighbors = node.getData('nbNeighbors');
      html.push(`
      <div class="ogma-tooltip-header">
      
        <div class="ogma-tooltip-header-title">
          <span class="ogma-tooltip-header-icon-container"
            style="background-color: ${getNodeTypeColor(type)}">
            <i class=${getClassNameFromType(type)}></i>
          </span>

          <span class="ogma-tooltip-title">${type}</span>
        </div>
        <div class="ogma-tooltip-header-description">
          <span class="ogma-tooltip-header-description-icon-container">
            <span class="icon-workflow"></span>
          </span>
          <span class="ogma-tooltip-header-description-text">
            Connections : ${neighbors}
          </span>
        </div>
      </div>`);

      html.push(`<div class="ogma-tooltip-data">`);

      const properties = node.getData("properties");
      Object.keys(properties).forEach((key) => {
        const value = properties[key];
        html.push(`
          <div class="ogma-tooltip-data-entry">
            <span class="ogma-tooltip-data-key">${key.charAt(0).toUpperCase().concat(key.substring(1))}</span>
            <span class="ogma-tooltip-data-value">${value}</span>
          </div>
        `);
      })

      html.push(`</div>`);

      return html.join('');
    },
    { className: 'ogma-tooltip' }
  );
}
ts
import React from "react";
import { CustomNodeDataType } from "./dataPropertiesType";

export type Item = {
  label: string;
  checked?: boolean;
  component?: React.JSX.Element;
  action?: () => void;
}

type CustomNodeData = {
  type: keyof CustomNodeDataType,
  properties: CustomNodeDataType[keyof CustomNodeDataType],
  nbNeighbors: number,
}

type CustomNodeNeighbors = {
  nodes: number[],
  edges: number[]
}

export type CustomNode = {
  id: number,
  data: CustomNodeData,
  neighbors: CustomNodeNeighbors
  attributes?: {
    x?: number,
    y?: number,
  }
}

type CustomEdgeData = {
  type: string,
  properties: any
}

export type CustomEdge = {
  id: number,
  source: number,
  target: number,
  data: CustomEdgeData
}
ts
import { CustomNodeDataType } from "./dataPropertiesType";

// dummy icon element to retrieve the HEX code, it should be hidden
const placeholder = document.createElement('span');
document.body.appendChild(placeholder);
placeholder.style.visibility = 'hidden';

export function getClassNameFromType(type: keyof CustomNodeDataType): string {
  switch (type) {
    case 'Customer':
      return 'icon-user-round';
    case 'Address':
      return 'icon-house';
    case 'Claim':
      return 'icon-dollar-sign';
    case 'MailAddress':
      return 'icon-mail';
    case 'Phone':
      return 'icon-phone';
    case 'Lawyer':
      return 'icon-scale';
    case 'Evaluator':
      return 'icon-user-round-search';
    default:
      return 'icon-id-card'; // SSN
  }
}

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

export function getNodeTypeColor(type: keyof CustomNodeDataType): string {
  switch (type) {
    case 'Customer':
      return "#FF7523";
    case "Address":
      return "#CAF249";
    case "Claim":
      return "#FFCB2F";
    case "MailAddress":
      return "#FF9AAC";
    case "Phone":
      return "#0099FF";
    case "Lawyer":
      return "#9386CE";
    case "Evaluator":
      return "#4C5EFA";
    default:
      return "#80E5CA"; // SSN
  }
}