Appearance
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 < 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
}
}