Skip to content
  1. Examples

Fraud detection

tsx
import React from 'react';
import ReactDOM from 'react-dom';
import ReactDOMServer from 'react-dom/server';
import { createRoot } from 'react-dom/client';
import App from './App';

const root = createRoot(document.getElementById('root'));
root.render(<App />);
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"
    />
    <!-- this importmap is only needed for Ogma playground -->
    <script type="importmap">
      {
        "imports": {
          "@shoelace-style/shoelace": "https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/shoelace.js",
          "@shoelace-style/shoelace/react/checkbox": "https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/react/checkbox/index.js",
          "@lit/react": "https://cdn.jsdelivr.net/npm/@lit/react/+esm"
        }
      }
    </script>
    <title>Ogma Fraud Detection</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="./index.tsx"></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%;
}
json
{
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@lit/react": "3.3.0",
    "lit": "3.3.0",
    "@linkurious/ogma-react": "5.1.9",
    "@linkurious/ogma-ui-kit": "0.0.9",
    "@shoelace-style/shoelace": "2.20.1",
    "react": "19.1.0",
    "react-dom": "19.1.0"
  },
  "devDependencies": {
    "@types/react": "19.1.4",
    "@types/react-dom": "19.1.4",
    "@vitejs/plugin-react": "4.4.1",
    "vite": "6.3.5"
  }
}
tsx
import React, { useCallback, useRef } from 'react';
import { renderToString } from 'react-dom/server';
import { Ogma, useEvent } from '@linkurious/ogma-react';
import OgmaLib from '@linkurious/ogma';

import './style.css';
import { CustomNode } from './types';

// Import the components used in the application
import { TooltipContent } from './TooltipContent';
import { Searchbar } from './Searchbar';
import { Panel } from './Panel';
import { Styles } from './Styles';
import { Buttons } from './Buttons';

// Retrieve the fake database defined in `dummyDatabase.ts`
import * as DB from './dummyDatabase';

function App() {
  const ogmaRef = useRef<OgmaLib<unknown, unknown>>(null);
  const fullNames = DB.getFullNames();
  const [key, setKey] = React.useState(0);

  // Utility function to run a layout
  const runLayout = name =>
    ogmaRef.current?.layouts[name]({
      locate: {
        duration: 400,
        padding: { top: 200, bottom: 80, left: 50, right: 50 }
      },
      duration: 400
    });

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

  // 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 (
        ogmaRef.current?.getNode(edge.source) &&
        ogmaRef.current?.getNode(edge.target)
      );
    });

  // Expand the specified node by retrieving its neighbors from the database
  // and adding them to the visualization
  const expandNeighbors = node => {
    // Retrieve the neighbors from the DB
    const neighbors = DB.getNeighbors(node.getId()),
      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 (ogmaRef.current!.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) {
        // @ts-expect-error assigning the attributes
        const neighbor: CustomNode = 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 ogmaRef
        .current!.addNodes(nodes)
        .then(() => ogmaRef.current!.addEdges(selectAdjacentEdgesToAdd(ids)))
        .then(() => runForceLayout());
    }
  };

  const onReady = useCallback((ogma: OgmaLib) => {
    ogmaRef.current = ogma;

    ogma.layouts.force({ locate: true });

    // Assign the tooltip when the Ogma instance is ready
    ogma.tools.tooltip.onNodeHover(
      node => {
        return renderToString(<TooltipContent node={node} />);
      },
      {
        className: 'ogma-tooltip'
      }
    );
  }, []);

  const onDoubleclick = useEvent('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());
    }
  });

  return (
    <Ogma
      options={{
        backgroundColor: '#f0f0f0'
      }}
      graph={DB.getFullGraph()}
      ref={ogmaRef}
      onReady={onReady}
      onDoubleclick={onDoubleclick}
    >
      {/* Contains the style of the graph */}
      <Styles />

      {/* Panel for the actions */}
      <Panel
        runForceLayout={runForceLayout}
        runHierarchical={runHierarchical}
        key={key}
      />

      <Searchbar
        selectAdjacentEdgesToAdd={selectAdjacentEdgesToAdd}
        runForceLayout={runForceLayout}
        sortedNames={fullNames}
      />

      {/* The buttons for miscellaneous uses */}
      <Buttons setKey={setKey} />
    </Ogma>
  );
}

export default App;
tsx
import React from "react";

export const Button = (props: {
  className: string;
  onClick: () => void;
  title: string;
}) => {
  const { className, onClick, title } = props;

  return (
    <button className="button" onClick={onClick} title={title}>
      <i className={className} />
    </button>
  );
}
css
.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;
}
tsx
import React from "react";
import { Button } from "./Button";
import { useOgma } from "@linkurious/ogma-react";
import "./Buttons.css";

export const Buttons = (props: {
  setKey: React.Dispatch<React.SetStateAction<number>>
}) => {

  const ogma = useOgma();

  const buttons = [
    {
      className: "icon-plus",
      onClick: () => {
        ogma.view.zoomIn({
          duration: 150,
          easing: 'quadraticOut'
        });
      },
      title: "Zoom In"
    },
    {
      className: "icon-minus",
      onClick: () => {
        ogma.view.zoomOut({
          duration: 150,
          easing: 'quadraticOut'
        });
      },
      title: "Zoom Out"
    },
    {
      className: "icon-rotate-cw",
      onClick: () => {
        ogma.clearGraph();
        props.setKey(prevKey => prevKey + 1);
      },
      title: "Clear Graph"
    },
    {
      className: "icon-focus",
      onClick: () => {
        ogma.view.locateGraph({
          duration: 400,
          padding: { top: 200, bottom: 80, left: 50, right: 50 }
        });
      },
      title: "Focus on Graph"
    }
  ]

  return (
    <div className="buttons">
      {buttons.map((button, index) => (
        <Button
          key={index}
          className={button.className}
          onClick={button.onClick}
          title={button.title}
        />
      ))}
    </div>
  );

}
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;
tsx
import React from 'react';
import { Item } from './types';
import SLCheckbox from '@shoelace-style/shoelace/react/checkbox';
export const ItemInput = (props: { item: Item }) => {
  const { item } = props;
  const [checked, setChecked] = React.useState(item.checked || false);

  return (
    <div className="checklist-item">
      <SLCheckbox
        checked={checked}
        onSlChange={() => {
          setChecked(!checked);
          if (item.action) item.action();
        }}
      >
        {item.label}
      </SLCheckbox>
      {checked && item.component && item.component}
    </div>
  );
};
css
.itemlist-container {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
tsx
import React from 'react';
import { ItemInput } from './ItemInput';
import { Item } from './types';
import './Itemlist.css';

export const Itemlist = (props: { items: Item[] }) => {
  return (
    <div className="itemlist-container">
      {props.items.map((item, index) => (
        <ItemInput key={index} item={item} />
      ))}
    </div>
  );
};
css
.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;
}
tsx
import React from "react";
import "./LayoutSwitch.css";

export const LayoutSwitch = (props: {
  runForceLayout: () => void;
  runHierarchical: () => void;
}) => {

  const { runForceLayout, runHierarchical } = props;
  const [forceLayout, setForceLayout] = React.useState(true);

  const onChange = () => {}

  return (
    <form className="layout-switch">
      <input className="switch" type="radio" onChange={onChange} checked={forceLayout} name="network" />
      <label className="label" htmlFor="network" onClick={() => { setForceLayout(true); runForceLayout() }}>Network</label>
  
      <input className="switch" type="radio" onChange={onChange} checked={! forceLayout} name="hierarchical" />
      <label className="label" htmlFor="hierarchical" onClick={() => { setForceLayout(false); runHierarchical() }}>Hierarchical</label>
    </form>
  )

}
css
.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;
}
tsx
import { useOgma } from '@linkurious/ogma-react';
import React from 'react';
import { getClassNameFromType, getNodeTypeColor } from './utils';
import { CustomNodeDataType } from './dataPropertiesType';
import './Legend.css';

export const Legend = () => {
  const ogma = useOgma();
  const shownTypes = new Set<keyof CustomNodeDataType>();

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

  return (
    <div className="legend">
      <h3>LEGEND</h3>
      <ul>
        {Array.from(shownTypes)
          .sort((a, b) => a.localeCompare(b))
          .map(type => {
            const color = getNodeTypeColor(type);
            const iconCode = getClassNameFromType(type);

            return (
              <li key={type}>
                <span
                  className="legend-icon"
                  style={{ backgroundColor: color }}
                >
                  {iconCode && <i className={iconCode} />}
                </span>
                <span className="legend-label">{type}</span>
              </li>
            );
          })}
      </ul>
    </div>
  );
};
css
.optionlist {
  position: absolute;
  z-index: 1;
  background-color: white;
  border: 1px solid #ccc;
  width: 316px;
  max-height: 220px;
  overflow-y: auto;
  list-style: none;
  padding: 0;
  margin: 0;
  translate: -9px 34px;
  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;
}
tsx
import React, {
  useImperativeHandle,
  useRef,
  type KeyboardEvent,
  type Ref
} from 'react';
import './Optionlist.css';
  
export const Optionlist = (props: {
  filteredNames: string[];
  handleOptionClick: (name: string) => void;
  ref: Ref<{
    onKeyPress: (
      event: KeyboardEvent<HTMLInputElement>,
      setQuery: (query: string) => void,
      searchNode: (name: string) => void
    ) => void;
    resetPositions: () => void;
  }>;
}) => {
  const { filteredNames, handleOptionClick, ref } = props;
  const positionsRef = useRef([0, 0]);

  const ul = useRef<HTMLUListElement>(null);

  useImperativeHandle(ref, () => {
    // Function passed to the parent component
    // to handle the keypress events
    return {
      onKeyPress(
        event: KeyboardEvent<HTMLInputElement>,
        setQuery: (query: string) => void,
        searchNode: (name: string) => void
      ) {
        if (filteredNames.length < 1) return;

        const input = event.currentTarget as HTMLInputElement;
        if (event.key === 'Enter') {
          // Select the current item
          searchNode(filteredNames[positionsRef.current[1]]);
          setQuery(filteredNames[positionsRef.current[1]]);
          input.blur();
        } else if (event.key === 'ArrowDown') {
          // Go down in the list
          event.preventDefault();
          const li = ul.current?.querySelector('.selected') as HTMLLIElement;
          let nextLi: Element;
          if (li.nextElementSibling) {
            nextLi = li.nextElementSibling;
            li.nextElementSibling.classList.add('selected');
            li.classList.remove('selected');
            positionsRef.current = [positionsRef.current[1], positionsRef.current[1] + 1];
          } else {
            nextLi = ul.current?.firstChild as Element;
            (ul.current?.firstChild as HTMLLIElement)?.classList.add('selected');
            li.classList.remove('selected');
            positionsRef.current = [positionsRef.current[1], 0];
          }

          if (isItemInvisible(nextLi)) {
            nextLi.scrollIntoView({
            block: 'end',
            inline: 'nearest',
            behavior: 'smooth'
            });
          }
        } else if (event.key === 'ArrowUp') {
          // Go up in the list
          event.preventDefault();
          const li = ul.current?.querySelector('.selected') as HTMLLIElement;
          let nextLi: Element;
          if (li.previousElementSibling) {
            nextLi = li.previousElementSibling;
            li.previousElementSibling.classList.add('selected');
            li.classList.remove('selected');
            positionsRef.current = [positionsRef.current[1], positionsRef.current[1] - 1];
          } else {
            nextLi = ul.current?.lastChild as Element;
            (ul.current?.lastChild as HTMLLIElement)?.classList.add('selected');
            li.classList.remove('selected');
            positionsRef.current = [positionsRef.current[1], filteredNames.length - 1];
          }

          if (isItemInvisible(nextLi)) {
            nextLi.scrollIntoView({
            block: 'start',
            inline: 'nearest',
            behavior: 'smooth'
            });
          }
        } else if (event.key === 'Escape') {
          // Unfocus the input
          input.blur();
        }
      },
      resetPositions() {
        if (filteredNames.length > 0) {
            const li = ul.current?.querySelector('.selected') as HTMLLIElement;
          if (li) {
            li.classList.remove('selected');
          }
          (ul.current?.firstChild as HTMLLIElement)?.classList.add('selected');
          positionsRef.current = [0, 0];
        }
      }
    };
  }, [filteredNames, positionsRef]);

// 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.current!.getBoundingClientRect();
  const ulTop = rect2.top;
  const ulBottom = rect2.bottom - 1;

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

return (
  <ul className="optionlist" ref={ul}>
    {filteredNames.map((name, i) => {
      return (
        <li
          key={i}
          className={`optionlist-item ${positionsRef.current[1] === i ? 'selected' : ''}`}
          onClick={() => handleOptionClick(name)}
        >
          <div className="arrow"></div>
          <div className="">
          <span className="">
            {name}
          </span>
          </div>
        </li>
      );
    })}
  </ul>
);
};
css
.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;
  margin-top: 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: 16px;
}

/* 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;
}
tsx
import React from 'react';
import { LayoutSwitch } from './LayoutSwitch';
import { CustomNode } from './types';
import { useOgma, NodeFilter, NodeStyle } from '@linkurious/ogma-react';
import './Panel.css';

import * as DB from './dummyDatabase';
import { Itemlist } from './Itemlist';
import { getIconCode, getNodeTypeColor } from './utils';
import { Legend } from './Legend';

export const Panel = (props: {
  runForceLayout: () => void;
  runHierarchical: () => void;
}) => {
  const ogma = useOgma();
  const { runForceLayout, runHierarchical } = props;
  const panelRef = React.useRef<HTMLDivElement>(null);

  const evaluatorFilter = (
    <NodeFilter
      criteria={node => {
        return node.getData('type') !== 'Evaluator';
      }}
    />
  );

  const hideSmallClaimsFilter = (
    <NodeFilter
      criteria={node => {
        return (
          node.getData('type') !== 'Claim' ||
          node.getData('properties.amount') >= 50000
        );
      }}
    />
  );

  const hideLeafNodesFilter = (
    <NodeFilter
      criteria={node => {
        return node.getAdjacentNodes().size > 1;
      }}
    />
  );

  const textRule = (
    <NodeStyle
      attributes={{
        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') +
                '$)'
              );
            }
          }
        }
      }}
    />
  );

  const colorRule = (
    <NodeStyle
      attributes={{
        color: node => getNodeTypeColor(node.getData('type'))
      }}
    />
  );

  const iconRule = (
    <NodeStyle
      attributes={{
        icon: {
          content: node => getIconCode(node.getData('type')),
          font: 'Lucide'
        }
      }}
    />
  );

  const claimSizeRule = (
    <NodeStyle
      selector={node => {
        return node.getData('type') === 'Claim';
      }}
      attributes={{
        radius: ogma.rules.slices({
          field: 'properties.amount',
          values: { min: 8, max: 24 },
          stops: { min: 48000, max: 52000 }
        })
      }}
    />
  );

  const displayAll = () => {
    const graph = DB.getFullGraph();

    // If there is the same amount of nodes in Ogma as in the DB, center the graph
    if (ogma.getNodes('all').size === graph.nodes.length) {
      ogma.view.locateGraph({
        duration: 400,
        padding: { top: 200, bottom: 80, left: 50, right: 50 }
      });
      return;
    }

    // Assign a random position to all nodes, in preparation for the layout
    for (let i = 0; i < graph.nodes.length; ++i) {
      const node: CustomNode = graph.nodes[i];
      node.attributes = {
        x: Math.random(),
        y: Math.random()
      };
    }

    ogma
      .setGraph(graph)
      .then(() =>
        ogma.view.locateGraph({
          duration: 400,
          padding: { top: 200, bottom: 80, left: 50, right: 50 }
        })
      )
      .then(() => runForceLayout());
  };

  const filters = [
    {
      label: 'Hide evaluators',
      checked: false,
      component: evaluatorFilter
    },
    {
      label: 'Hide claims < 50K$',
      checked: false,
      component: hideSmallClaimsFilter
    },
    {
      label: 'Hide leaf nodes',
      checked: false,
      component: hideLeafNodesFilter
    }
  ];

  const styles = [
    {
      label: 'Node text',
      checked: true,
      component: textRule
    },
    {
      label: 'Node color',
      checked: true,
      component: colorRule
    },
    {
      label: 'Node icon',
      checked: true,
      component: iconRule
    },
    {
      label: 'Claim size',
      checked: false,
      component: claimSizeRule
    },
    {
      label: 'Legend',
      checked: false,
      component: <Legend />
    }
  ];

  const onClick = evt => {
    panelRef.current?.classList.toggle('closed');
    evt.currentTarget.title = panelRef.current?.classList.contains('closed')
      ? 'Open Menu'
      : 'Hide Menu';
  };

  return (
    <div className="panel" ref={panelRef}>
      {/* Title and menu toggler button */}
      <div className="title-container">
        <span className="fingerprint-container">
          <img src="img/fingerprint.svg" alt="Logo" />
        </span>
        <span className="hide-closed">Counter Fraud Inc.</span>
        <button title="Hide Menu" className="menu-toggle" onClick={onClick}>
          <i className="icon-chevron-left" />
        </button>
      </div>

      {/* Layout toggle */}
      <div>
        <div className="title">LAYOUT</div>
        <LayoutSwitch
          runForceLayout={runForceLayout}
          runHierarchical={runHierarchical}
        />
      </div>

      {/* <SlDivider role="separator" aria-orientation="horizontal" /> */}

      {/* Filter buttons */}
      <div>
        <div className="title">FILTERS</div>
        <Itemlist items={filters} />
      </div>

      {/* <SlDivider role="separator" aria-orientation="horizontal" /> */}

      {/* Design buttons */}
      <div id="design">
        <div className="title">DESIGN</div>
        <Itemlist items={styles} />
      </div>
    </div>
  );
};
css
.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;  
}
tsx
import React, { useEffect, useMemo, useRef, useState } from 'react';
import './Searchbar.css';
import { useOgma } from '@linkurious/ogma-react';
import * as DB from './dummyDatabase';
import { Optionlist } from './Optionlist';

export const Searchbar = (props: {
  sortedNames: string[];
  selectAdjacentEdgesToAdd: (ids: number[]) => any[];
  runForceLayout: () => void;
}) => {
  const { selectAdjacentEdgesToAdd, runForceLayout, sortedNames } = props;
  const [query, setQuery] = useState<string>('');
  const [showOptionlist, setShowOptionlist] = useState<boolean>(false);

  const ogma = useOgma();
  const inputRef = React.useRef<HTMLInputElement>(null);

  // Reference given to the Optionlist component
  const ref = useRef<{
    onKeyPress: (
      event: React.KeyboardEvent<HTMLInputElement>,
      setQuery: (query: string) => void,
      searchNode: (name: string) => void
    ) => void;
    resetPositions: () => void;
  }>(null);

  // Filter the stations based on the query
  const filteredNames = sortedNames.filter(name =>
    name.toLowerCase().includes(query.toLowerCase())
  );

  // Memorize the Optionlist for performance
  const optionList = useMemo(
    () => (
      <Optionlist
        filteredNames={filteredNames}
        handleOptionClick={handleOptionClick}
        ref={ref}
      />
    ),
    [filteredNames]
  );

  useEffect(() => {
    setTimeout(() => {
      searchNode('Keeley Bins');
    }, 200);
  });

  const addNodeToViz = (name: string) => {
    // Look for the node in the DB
    const node = DB.search(name);

    if (!node) return;

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

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

    if (ogma.getNodes().size === 1) {
      // If this is the first node in the visualization, we simply center the camera on it
      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)
      ogma
        .addEdges(selectAdjacentEdgesToAdd([node.id]))
        .then(() => runForceLayout());
    }

    return addedNode;
  };

  const searchNode = (value?: string) => {
    // If both the input and the value are empty, do nothing
    if ((!inputRef.current || !inputRef.current.value) && !value) return;

    // Prioritize the value passed as an argument over the input value
    const val = value || inputRef.current!.value;
    const node = addNodeToViz(val);
    if (node) {
      node.addClass('pulse');
      node.locate({
        duration: 100
      });
    } else {
      alert(
        'No node has the property "fullname" equal to "' +
          inputRef.current!.value +
          '".'
      );
    }
  };

  // Transmit the event to the Optionlist component
  const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    ref.current?.onKeyPress(event, setQuery, searchNode);
  };

  function handleBlur() {
    // To allow the click event to be registered, wait a bit before removing the selection
    // since the blur event is triggered before the click event
    setTimeout(() => {
      setShowOptionlist(false);
      const searchbar = document.querySelector('.searchbar') as HTMLElement;
      searchbar.style.border = '1px solid #E4E4E7';
      searchbar.style.borderRadius = '4px 4px 4px 4px';
    }, 250);
  }

  const onSearchClick = (event: React.MouseEvent<HTMLButtonElement>) => {
    event.preventDefault();
    searchNode();
  };

  const onFocus = () => {
    const searchbar = document.querySelector('.searchbar') as HTMLElement;
    searchbar.style.border = '1px solid #1A70E5';
    searchbar.style.borderRadius = '4px 4px 0 0';
    if (filteredNames.length !== 0) {
      setShowOptionlist(true);
    }
  };

  function handleOptionClick(name: string) {
    setQuery(name);
    searchNode(name);
  }

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    const value = event.target.value;
    setQuery(value);
    ref.current?.resetPositions();
  }

  return (
    <div className="searchbar-container">
      <div className="searchbar">
        <button title="Search" className="btn" onClick={onSearchClick}>
          <i className="icon-search" />
        </button>
        <input
          ref={inputRef}
          type="text"
          placeholder="Enter a name"
          onChange={handleChange}
          value={query}
          onKeyDown={onKeyDown}
          onFocus={onFocus}
          onBlur={handleBlur}
          autoComplete="off"
        />
        {showOptionlist && optionList}
      </div>
    </div>
  );
};
tsx
import { StyleClass, NodeStyle, EdgeStyle } from '@linkurious/ogma-react';
import React from 'react';

export const Styles = () => {
  // Rerender the component for some reason makes icons appear
  const [test, setTest] = React.useState(false);

  React.useEffect(() => {
    setTest(true);
  }, []);

  return (
    <>
      <StyleClass
        name="pulse"
        nodeAttributes={{
          pulse: {
            enabled: true,
            endRatio: 5,
            width: 1,
            startColor: 'red',
            endColor: 'red',
            interval: 1000,
            startRatio: 1.0
          }
        }}
      />
      <NodeStyle
        attributes={{
          radius: 16,
          text: {
            font: 'IBM Plex Sans',
            size: 14
          },
          badges: {
            bottomRight: {
              scale: 0.3,
              color: 'inherit',
              text: {
                scale: 0.5,
                style: 'normal',
                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'
              }
            }
          }
        }}
      />
      <NodeStyle.Hovered
        attributes={{
          outerStroke: {
            color: '#FFC488',
            width: 9
          },
          text: {
            backgroundColor: '#272727',
            color: '#fff',
            padding: 4
          }
        }}
      />
      <NodeStyle.Selected
        attributes={{
          outerStroke: {
            color: '#DE425B',
            width: 9
          },
          text: {
            backgroundColor: '#272727',
            color: '#fff',
            padding: 4
          },
          outline: false
        }}
      />
      <EdgeStyle
        attributes={{
          color: '#979595',
          width: 2,
          shape: 'arrow'
        }}
      />
      <EdgeStyle.Hovered attributes={{}} />
    </>
  );
};
css
.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;
}
tsx
import React from "react";
import "./Tooltip.css";
import { Node as OgmaNode } from "@linkurious/ogma";
import { TooltipHeader } from "./TooltipHeader";
import { TooltipData } from "./TooltipData";

export const TooltipContent = (props: {
  node: OgmaNode;
}) => {
  const { node } = props;
  const properties = node.getData("properties");

  return (
    <>
      <div className="arrow"></div>
      <TooltipHeader type={node.getData("type")} neighbors={node.getData("nbNeighbors")} />
      <TooltipData properties={properties} />
    </>
  );
}
tsx
import React from 'react';
import { CustomNodeDataType } from './dataPropertiesType';

export const TooltipData = (props: {
  properties: CustomNodeDataType[keyof CustomNodeDataType];
}) => {
  const { properties } = props;

  return (
    <div className="ogma-tooltip-data">
      {Object.keys(properties).map((key, index) => {
        const value = properties[key];
        return (
          <div className="ogma-tooltip-data-entry" key={index}>
            <span className="ogma-tooltip-data-key">
              {key.charAt(0).toUpperCase().concat(key.substring(1))}
            </span>
            <span className="ogma-tooltip-data-value">{value}</span>
          </div>
        );
      })}
    </div>
  );
};
tsx
import React from 'react';
import { CustomNodeDataType } from './dataPropertiesType';
import { getClassNameFromType, getNodeTypeColor } from './utils';

export const TooltipHeader = (props: {
  type: keyof CustomNodeDataType;
  neighbors: number;
}) => {
  const { type, neighbors } = props;

  return (
    <div className="ogma-tooltip-header">
      <div className="ogma-tooltip-header-title">
        <span
          className={`ogma-tooltip-header-icon-container`}
          style={{ backgroundColor: getNodeTypeColor(type) }}
        >
          <span className={getClassNameFromType(type)} />
        </span>

        <span className="ogma-tooltip-title">{type}</span>
      </div>

      <div className="ogma-tooltip-header-description">
        <span className={`ogma-tooltip-header-description-icon-container`}>
          <span className="icon-workflow" />
        </span>
        <span className="ogma-tooltip-header-description-text">
          Connections : {neighbors}
        </span>
      </div>
    </div>
  );
};
json
{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ]
}
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 "#FF9C39";
    case "Address":
      return "#C5EA56";
    case "Claim":
      return "#FFCB2F";
    case "MailAddress":
      return "#F796C0";
    case "Phone":
      return "#5BD4EC";
    case "Lawyer":
      return "#C373ED";
    case "Evaluator":
      return "#047AE7";
    default:
      return "#63E38C";
  }
}
ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

// https://vite.dev/config/
export default defineConfig({
  plugins: [react()]
});