Appearance
Anti Money Laundering demo
This example shows how to use NodeGrouping and EdgeGrouping to reduce the complexity of a graph and enhance money laundering patterns.
ts
import Ogma, { Node } from '@linkurious/ogma';
/**
* Create the chart
*/
const ogma = new Ogma({
container: 'graph-container',
options: {
interactions: { zoom: { onDoubleClick: true } }
}
});
/**
* Styling section
*/
const placeholder = document.createElement('span');
document.body.appendChild(placeholder);
placeholder.style.visibility = 'hidden';
// helper routine to get the icon HEX code
function getIconCode(className: string) {
placeholder.className = className;
const code = getComputedStyle(placeholder, ':before').content;
return code[1];
}
const personIcon = getIconCode('icon-user');
const companyIcon = getIconCode('icon-building-2');
const isShareholderType = 'IS-SHAREHOLDER';
const getNodeColor = (node: Node) => {
const group = node.getData('group');
if (group === 'purple') return '#67328E';
if (group === 'green') return '#328E5B';
return '#DE8B53';
};
const getEdgeColor = edge => {
if (edge.getData('type') === isShareholderType) return '#89C7D6';
return '#8E6538';
};
const getEdgeHaloColor = edge => {
if (edge.getData('type') === isShareholderType) return '#BDF2FF';
return '#DABB98';
};
ogma.styles.addNodeRule({
text: {
minVisibleSize: 0,
font: 'IBM Plex Sans'
},
color: 'white',
outerStroke: {
color: getNodeColor
},
icon: {
font: 'Lucide',
content: node => {
if (node.getData('type') === 'company') {
return companyIcon;
}
return personIcon;
},
style: 'bold',
color: getNodeColor
}
});
ogma.styles.setHoveredNodeAttributes({
outline: false, // Disabling the shadow on hover
outerStroke: {
color: getNodeColor
},
text: {
tip: false
}
});
ogma.styles.addEdgeRule({
shape: {
head: 'arrow'
},
color: getEdgeColor
});
ogma.styles.setHoveredEdgeAttributes({
outline: true,
color: getEdgeColor,
halo: getEdgeHaloColor,
text: {
backgroundColor: 'rgb(220, 220, 220)',
minVisibleSize: 0
}
});
const formatContent = ids => {
if (!Array.isArray(ids)) {
return ''; // this is for regular nodes
}
return (
'<br/><br/>Contains:<ul/><li>"' + ids.join('"</li><li/>"') + '"</li></ul>'
);
};
const createTooltip = (title: string, content: string, color: string) =>
`<div class="arrow"></div>
<div class="ogma-tooltip-header">
<span class="title">${title}<span class="line-color" style="background: ${color}"></span>
<div>
${formatContent(content)}</div>
</div>`;
// Show some info from the hovered node
ogma.tools.tooltip.onNodeHover(
node => {
// pick the color of the node
const color = node.getAttribute('outerStroke');
// Now change the title based on the type of node (grouped or not?)
const title = node.isVirtual()
? 'Grouped Area '
: 'ID: "' + node.getId() + '" ';
// and pick the content of the node if grouped;
const content = node.isVirtual() ? node.getData('ids') : node.getId();
return createTooltip(title, content, color);
},
{
className: 'ogma-tooltip' // tooltip container class to bind to css
}
);
const graph = await Ogma.parse.jsonFromUrl('ownerships.json');
await ogma.setGraph(graph);
await ogma.layouts.force({ locate: { padding: 120 } });
const updateUI = () => {
// Check what it should
const nodeGrouped = getMode('node-group') === 'group';
const edgeGrouped = getMode('edge-group') === 'group';
// instead of working out how to go from state A to state B
// execute all transformation and for each one workout what to do
execute(edgeGrouping, edgeGrouped);
execute(nodeGrouping, nodeGrouped);
return ogma.transformations.afterNextUpdate().then(() => {
return ogma.layouts.force();
});
};
// this is the duration of the animations
const transitionTime = 500;
// Menu at the top right
const form = document.querySelector('#ui form')!;
form.addEventListener('change', updateUI);
document.querySelector('#layout')!.addEventListener('click', evt => {
evt.preventDefault();
ogma.layouts.force();
});
// transformation objects: create them now and use them later
const nodeGrouping = ogma.transformations.addNodeGrouping({
selector: node => node.getData('type') === 'company',
groupIdFunction: node => node.getData('group'),
nodeGenerator: (nodeList, groupId) => ({
id: 'grouped-node-' + groupId,
attributes: {
text: groupId + ' Group',
radius: nodeList.size * 3,
color: nodeList.getAttribute('icon').color
},
// add some content to be picked up later on in the tooltip
data: {
group: nodeList.getData('group')[0],
type: nodeList.getData('type')[0],
ids: nodeList.getId()
}
}),
groupEdges: false, // do not group edges as for now
enabled: false // create the transformation but do not activate it
});
const edgeGrouping = ogma.transformations.addEdgeGrouping({
generator: (edgeList, groupId) => {
const data = edgeList.getData()[0];
return {
id: groupId,
data: data,
attributes: {
width: edgeList.size
}
};
},
enabled: false // create the transformation but do not activate it
});
const execute = (transformation, enable) => {
// avoid extra work if we're already in the state requested
if (transformation.isEnabled() === enable) {
return;
}
transformation.toggle(transitionTime);
};
const getMode = id => {
const select = form[id];
const currentMode = Array.prototype.filter.call(select, input => {
return input.checked;
})[0].value; // IE inconsistency
return currentMode;
};
html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link type="text/css" rel="stylesheet" href="style.css" />
<link
href="https://cdn.jsdelivr.net/npm/lucide-static@0.483.0/font/lucide.css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="graph-container"></div>
<div class="toolbar" id="ui">
<form>
<div class="section mode">
<h3>Company analysis</h3>
<div class="switch switch--horizontal">
<input
type="radio"
name="node-group"
value="detail"
checked="checked"
/>
<label for="detail">All Nodes</label>
<input type="radio" name="node-group" value="group" />
<label for="group">Group</label>
<span class="toggle-outside">
<span class="toggle-inside"></span>
</span>
</div>
<div class="switch switch--horizontal">
<input
type="radio"
name="edge-group"
value="detail"
checked="checked"
/>
<label for="detail">All Edges</label>
<input type="radio" name="edge-group" value="group" />
<label for="group">Backbone</label>
<span class="toggle-outside">
<span class="toggle-inside"></span>
</span>
</div>
</div>
<div class="controls">
<button id="layout" class="btn menu">Layout</button>
</div>
</form>
<div class="section mode" id="details"></div>
</div>
<script src="index.ts"></script>
</body>
</html>
css
:root {
--font: 'IBM Plex Sans', sans-serif;
}
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
font-family: var(--font);
}
*,
*:before,
*:after {
box-sizing: border-box;
}
#graph-container {
top: 0;
bottom: 0;
left: 0;
right: 0;
position: absolute;
margin: 0;
overflow: hidden;
}
.toolbar {
display: block;
position: absolute;
top: 20px;
right: 20px;
padding: 10px;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.65);
border-radius: 4px;
background: #ffffff;
color: #222222;
font-weight: 300;
}
.toolbar .section {
position: relative;
display: block;
}
.toolbar .section h3 {
display: block;
font-weight: 300;
border-bottom: 1px solid #ddd;
color: #606060;
font-size: 1rem;
}
.toolbar .section .clearable-input {
border-radius: 4px;
padding: 5px;
border: 1px solid #dddddd;
}
.toolbar .section .line-color,
.ogma-tooltip .line-color {
display: inline-block;
width: 1rem;
height: 1rem;
margin-right: 0.25rem;
border-radius: 50%;
color: #ffffff;
text-align: center;
font-size: 0.65rem;
line-height: 1rem;
font-weight: bold;
vertical-align: text-top;
}
.toolbar .section p {
padding-left: 18px;
padding-right: 18px;
}
.toolbar .section p label {
width: 4rem;
display: inline-block;
}
.toolbar .mode {
text-align: center;
}
.toolbar .disabled {
display: none;
}
.ogma-tooltip-header .title {
font-weight: bold;
text-transform: uppercase;
}
/* --- Tooltip */
.ogma-tooltip {
max-width: 240px;
max-height: 280px;
background-color: #fff;
border: 1px solid #999;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
border-radius: 6px;
cursor: auto;
font-size: 12px;
pointer-events: none;
}
.ogma-tooltip-header {
font-variant: small-caps;
font-size: 120%;
color: #000;
border-bottom: 1px solid #999;
padding: 10px;
}
.ogma-tooltip-body {
padding: 10px;
overflow-x: hidden;
overflow-y: auto;
max-width: inherit;
max-height: 180px;
}
.ogma-tooltip-body th {
color: #999;
text-align: left;
}
.ogma-tooltip-footer {
padding: 10px;
border-top: 1px solid #999;
}
.ogma-tooltip > .arrow {
border-width: 10px;
position: absolute;
display: block;
width: 0;
height: 0;
border-color: transparent;
border-style: solid;
}
.ogma-tooltip.top {
margin-top: -12px;
}
.ogma-tooltip.top > .arrow {
left: 50%;
bottom: -10px;
margin-left: -10px;
border-top-color: #999;
border-bottom-width: 0;
}
.ogma-tooltip.bottom {
margin-top: 12px;
}
.ogma-tooltip.bottom > .arrow {
left: 50%;
top: -10px;
margin-left: -10px;
border-bottom-color: #999;
border-top-width: 0;
}
.ogma-tooltip.left {
margin-left: -12px;
}
.ogma-tooltip.left > .arrow {
top: 50%;
right: -10px;
margin-top: -10px;
border-left-color: #999;
border-right-width: 0;
}
.ogma-tooltip.right {
margin-left: 12px;
}
.ogma-tooltip.right > .arrow {
top: 50%;
left: -10px;
margin-top: -10px;
border-right-color: #999;
border-left-width: 0;
}
/* -- CSS flip switch */
.switch {
width: 100%;
position: relative;
}
.switch input {
position: absolute;
top: 0;
z-index: 2;
opacity: 0;
cursor: pointer;
}
.switch input:checked {
z-index: 1;
}
.switch input:checked + label {
opacity: 1;
cursor: default;
}
.switch input:not(:checked) + label:hover {
opacity: 0.5;
}
.switch label {
color: #222222;
opacity: 0.33;
transition: opacity 0.25s ease;
cursor: pointer;
}
.switch .toggle-outside {
height: 100%;
border-radius: 2rem;
padding: 0.25rem;
overflow: hidden;
transition: 0.25s ease all;
}
.switch .toggle-inside {
border-radius: 2.5rem;
background: #4a4a4a;
position: absolute;
transition: 0.25s ease all;
}
.switch--horizontal {
width: 15rem;
height: 2rem;
margin: 0 auto;
font-size: 0;
margin-bottom: 1rem;
}
.switch--horizontal input {
height: 2rem;
width: 5rem;
left: 5rem;
margin: 0;
}
.switch--horizontal label {
font-size: 1rem;
line-height: 2rem;
display: inline-block;
width: 5rem;
height: 100%;
margin: 0;
text-align: center;
}
.switch--horizontal label:last-of-type {
margin-left: 5rem;
}
.switch--horizontal .toggle-outside {
background: #dddddd;
position: absolute;
width: 5rem;
left: 5rem;
}
.switch--horizontal .toggle-inside {
height: 1.5rem;
width: 1.5rem;
}
.switch--horizontal input:checked ~ .toggle-outside .toggle-inside {
left: 0.25rem;
}
.switch--horizontal input ~ input:checked ~ .toggle-outside .toggle-inside {
left: 3.25rem;
}
.switch--horizontal input:disabled ~ .toggle-outside .toggle-inside {
background: #9a9a9a;
}
.switch--horizontal input:disabled ~ label {
color: #9a9a9a;
}
.hidden {
display: none;
}
.controls {
text-align: center;
margin-top: 5px;
}
.control-bar {
font-family: Helvetica, Arial, sans-serif;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.65);
border-radius: 4px;
}
.btn {
padding: 6px 8px;
background-color: white;
cursor: pointer;
font-size: 18px;
border: none;
border-radius: 5px;
outline: none;
}
.btn:hover {
color: #333;
background-color: #e6e6e6;
}
.menu {
border: 1px solid #ddd;
width: 80%;
font-size: 14px;
margin-top: 10px;
}
json
{
"nodes": [
{
"id": "owner-1",
"attributes": {
"radius": 10,
"text": "Stakeholder #1"
},
"data": {
"group": "purple",
"type": "person"
}
},
{
"id": "company-1",
"attributes": {
"radius": 10,
"text": "Company #1"
},
"data": {
"group": "purple",
"type": "company"
}
},
{
"id": "company-2",
"attributes": {
"radius": 8,
"text": "Company #2"
},
"data": {
"group": "purple",
"type": "company"
}
},
{
"id": "owner-2",
"attributes": {
"radius": 10,
"text": "Stakeholder #2"
},
"data": {
"group": "green",
"type": "person"
}
},
{
"id": "company-3",
"attributes": {
"radius": 10,
"text": "Company #3"
},
"data": {
"group": "green",
"type": "company"
}
},
{
"id": "company-4",
"attributes": {
"radius": 8,
"text": "Company #4"
},
"data": {
"group": "green",
"type": "company"
}
},
{
"id": "owner-3",
"attributes": {
"radius": 10,
"text": "Stakeholder #3"
},
"data": {
"group": "orange",
"type": "person"
}
},
{
"id": "company-5",
"attributes": {
"radius": 10,
"text": "Company #5"
},
"data": {
"group": "orange",
"type": "company"
}
},
{
"id": "company-6",
"attributes": {
"radius": 8,
"text": "Company #6"
},
"data": {
"group": "orange",
"type": "company"
}
},
{
"id": "company-7",
"attributes": {
"radius": 8,
"text": "Company #7"
},
"data": {
"group": "orange",
"type": "company"
}
},
{
"id": "company-8",
"attributes": {
"radius": 8,
"text": "Company #8"
},
"data": {
"group": "orange",
"type": "company"
}
}
],
"edges": [
{
"id": "is-shareholder-0",
"source": "owner-1",
"target": "company-1",
"attributes": {
"text": "IS-SHAREHOLDER"
},
"data": {
"type": "IS-SHAREHOLDER"
}
},
{
"id": "is-shareholder-1",
"source": "company-1",
"target": "company-2",
"attributes": {
"text": "IS-SHAREHOLDER"
},
"data": {
"type": "IS-SHAREHOLDER"
}
},
{
"id": "is-shareholder-2",
"source": "owner-2",
"target": "company-4",
"attributes": {
"text": "IS-SHAREHOLDER"
},
"data": {
"type": "IS-SHAREHOLDER"
}
},
{
"id": "is-shareholder-3",
"source": "owner-2",
"target": "company-3",
"attributes": {
"text": "IS-SHAREHOLDER"
},
"data": {
"type": "IS-SHAREHOLDER"
}
},
{
"id": "is-shareholder-4",
"source": "company-4",
"target": "company-3",
"attributes": {
"text": "IS-SHAREHOLDER"
},
"data": {
"type": "IS-SHAREHOLDER"
}
},
{
"id": "is-shareholder-5",
"source": "owner-3",
"target": "company-5",
"attributes": {
"text": "IS-SHAREHOLDER"
},
"data": {
"type": "IS-SHAREHOLDER"
}
},
{
"id": "is-shareholder-6",
"source": "owner-3",
"target": "company-6",
"attributes": {
"text": "IS-SHAREHOLDER"
},
"data": {
"type": "IS-SHAREHOLDER"
}
},
{
"id": "is-shareholder-7",
"source": "company-6",
"target": "company-8",
"attributes": {
"text": "IS-SHAREHOLDER"
},
"data": {
"type": "IS-SHAREHOLDER"
}
},
{
"id": "is-shareholder-8",
"source": "company-5",
"target": "company-7",
"attributes": {
"text": "IS-SHAREHOLDER"
},
"data": {
"type": "IS-SHAREHOLDER"
}
},
{
"id": "is-shareholder-9",
"source": "company-7",
"target": "company-8",
"attributes": {
"text": "IS-SHAREHOLDER"
},
"data": {
"type": "IS-SHAREHOLDER"
}
},
{
"id": "transaction-1",
"source": "company-5",
"target": "company-4",
"attributes": {
"text": "IS-TRANSACTION"
},
"data": {
"type": "IS-TRANSACTION"
}
},
{
"id": "transaction-2",
"source": "company-7",
"target": "company-4",
"attributes": {
"text": "IS-TRANSACTION"
},
"data": {
"type": "IS-TRANSACTION"
}
},
{
"id": "transaction-3",
"source": "company-8",
"target": "company-3",
"attributes": {
"text": "IS-TRANSACTION"
},
"data": {
"type": "IS-TRANSACTION"
}
},
{
"id": "transaction-4"
...