Appearance
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()]
});