Appearance
AI classification new
This project demonstrates how to integrate Ogma (for graph visualization) with TensorFlow.js (for in-browser machine learning). It provides a minimal, practical example of:
Extracting node features (e.g. age, prior fraud count, transaction data).
Building a dataset from graph data (NodeList) in Ogma.
Training a small neural network in the browser using
tfjs
.Predicting a fraud suspicion score (and confidence) per node in real time.
Updating the web component UI dynamically based on model output.
ts
import Ogma from '@linkurious/ogma';
import * as tf from '@tensorflow/tfjs';
import { OgLargeButton } from './component-large-btn';
import { OgNodeConnections } from './component-node-connections';
import { OgNodeList } from './component-node-list';
import { OgPill } from './component-pill';
import { OgRiskRow } from './component-risk-row';
import { OgSmallButton } from './component-small-btn';
import { OgSuspiciousCard } from './component-sus-card';
import { prepareInput, predictSuspicion } from './prepare-ai';
import { prepareGraph } from './prepare-graph';
import { prepareStyle } from './prepare-style';
import { prepareUI } from './prepare-ui';
import { NodeData } from './types';
customElements.define('og-node-list', OgNodeList);
customElements.define('og-node-connections', OgNodeConnections);
customElements.define('og-suspicious-card', OgSuspiciousCard);
customElements.define('og-large-button', OgLargeButton);
customElements.define('og-risk-row', OgRiskRow);
customElements.define('og-pill', OgPill);
customElements.define('og-small-button', OgSmallButton);
const ogma = new Ogma<NodeData>({
container: 'graph-container',
options: {
backgroundColor: '#f5f5f5',
detect: { edges: false }
}
});
prepareStyle(ogma);
// Load the graph data and apply a force layout
const graph = await Ogma.parse.jsonFromUrl<NodeData, unknown>(
'files/ai-classification.json'
);
const resetGraph = async () => {
ogma.setGraph(graph);
await ogma.layouts.force({
gpu: true,
gravity: 0.01,
charge: 40,
locate: true
});
};
resetGraph();
// Load the model and generate "suspicious score" from the graph data
const preTrainedModel = await tf.loadLayersModel('files/fraud.json');
const input = await prepareInput(ogma.getNodes());
const sus = await predictSuspicion(preTrainedModel, input); // predicted suspicion scores
// Update nodes data according to the model's prediction
const nodes = ogma.getNodes();
nodes.setData('category', node => {
const score = sus[node.getId() as number];
if (score > 0.5) return 0; // hacked
if (score > 0.25) return 2; // synthetic identity
return 1; // normal
});
nodes.setData(
'riskAttribute.confidenceScore',
node => 2 * Math.abs(sus[node.getId() as number] - 0.5)
);
// Prepare the UI components
prepareUI(ogma);
const cards = document.querySelectorAll('og-suspicious-card');
const susCard = cards.item(0);
const hackedCard = cards.item(1);
const syntIdCard = cards.item(2);
const totalHackedNodes = nodes.filter(n => n.getData('category') === 1).size;
const totalSyntIdNodes = nodes.filter(n => n.getData('category') === 2).size;
const totalSusNodes = totalHackedNodes + totalSyntIdNodes;
susCard?.setAttribute('value', `${totalSusNodes}`);
hackedCard?.setAttribute('value', `${totalHackedNodes}`);
syntIdCard?.setAttribute('value', `${totalSyntIdNodes}`);
// Update the list of suspicious nodes
const susList = document.querySelector('og-node-list');
const susNodes = nodes.filter(
node => node.getData('category') === 1 || node.getData('category') === 2
);
susList?.setAttribute(
'nodes',
JSON.stringify(Array.from(susNodes.getAttribute('text.content')))
);
susList?.addEventListener('account-change', e => {
const nodeId = Array.from(susNodes.getId())[e.detail.index];
const node = ogma.getNode(nodeId);
ogma.clearSelection();
node?.setSelected(true);
ogma.view.moveTo({
...node?.getPosition()!,
zoom: ogma.view.getZoom()
});
});
// Reset graph button
const resetBtn = document.querySelector('og-large-button');
resetBtn?.addEventListener('click', () => {
resetGraph();
});
html
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta charset="utf-8" />
<!-- Do NOT fetch .../tfjs@4.22.0/+esm from jsdelivr, it's poorly built! -->
<script type="importmap">
{
"imports": {
"@tensorflow/tfjs": "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.22.0/dist/tf.fesm.js"
}
}
</script>
<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..700;1,100..700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<script src="index.ts"></script>
<div id="graph-container"></div>
<div id="app">
<main class="bg-[#f8f8f8] flex flex-row w-full" data-model-id="444:717">
<div class="bg-[#f8f8f8] w-full max-w-[1040px] flex flex-row">
<div
class="flex flex-col w-full max-w-[220px] h-full items-start gap-4 pt-7 pb-6 px-5 bg-white"
>
<div class="flex flex-col w-full items-start">
<h2 class="font-medium text-[#131b34] text-[23px] leading-tight">
Results
</h2>
</div>
<div class="flex flex-col w-full items-start gap-2">
<og-suspicious-card
title="Total Suspicious Nodes Detected"
value=""
alert-type="warning"
></og-suspicious-card>
<og-suspicious-card
title="Hacked Accounts"
value=""
icon="files/icons/hacker.svg"
icon-bg="bg-[#fee7e7]"
></og-suspicious-card>
<og-suspicious-card
title="Synthetic Identity Accounts"
value=""
icon="files/icons/actor.svg"
icon-bg="bg-[#faf1dd]"
></og-suspicious-card>
</div>
<og-node-list
title="List of Suspicious Nodes"
nodes="[]"
></og-node-list>
<og-large-button label="Reset Graph" type="reset"></og-large-button>
</div>
<div
id="node-info"
class="hidden border bg-card text-card-foreground shadow w-[326px] rounded-[5.78px] overflow-hidden bg-white margin-[8px]"
>
<div class="flex flex-col space-y-1.5 p-6 px-4 py-2 pb-0">
<div class="flex justify-between items-center">
<div
class="tracking-tight text-base font-normal text-[#232222]"
>
Node <span class="font-bold">Account 209</span>
</div>
<img
class="w-[18px] h-[18px]"
alt="Close"
src="files/icons/close.svg"
/>
</div>
<og-node-connections
connected="Account 214, Account 212, Account 208"
></og-node-connections>
</div>
<div class="p-6 px-4 pt-4">
<div
class="text-card-foreground shadow w-full bg-[#f8f8f8] rounded-[5.78px]"
>
<div class="p-2 space-y-1">
<div class="flex items-center justify-between w-full">
<span
class="font-normal text-black text-[12px] leading-[14.4px]"
>Confidence Score</span
>
<img
class="w-[18px] h-[18px]"
alt="Info"
src="files/icons/info-circle.svg"
/>
</div>
<div id="confidence-score" class="flex items-center gap-1">
<div
class="inline-flex items-center border transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent shadow hover:bg-primary/80 bg-[#d1f1dc] text-[#155229] font-bold text-[12px] px-[8.67px] py-[5.78px] rounded-[5.06px]"
>
High
</div>
<span class="font-normal italic text-[#555353] text-[11px]"
>Score: 0.71</span
>
</div>
</div>
</div>
<h3 class="font-medium text-black text-sm mt-6 mb-2">
Risk Influence Table
</h3>
<div
class="rounded-[5.78px] text-card-foreground shadow w-full bg-[#f8f8f8]"
>
<div class="p-[7px]">
<div class="flex items-start gap-[7.22px]">
<img
class="w-[17.33px] h-[17.33px]"
alt="Info guidance"
src="files/icons/guidance.svg"
/>
<div class="text-[12px] leading-[17.3px]">
<p class="text-black">
This table shows how different features of the node
affect the prediction of fraud risk.<br /><br />
</p>
<p>
<span class="font-bold text-[#e7275a]">Red </span
><span class="text-black"
>supports the indication that </span
><span class="text-[#e6275a]"
>a case is more likely to be fraudulent.</span
><br />
</p>
<p>
<span class="font-bold text-[#0d8ae0]">Blue</span
><span class="text-black">
supports the indication </span
><span class="text-[#0d8ae0]"
>a case is less likely to be fraudulent.</span
>
</p>
</div>
</div>
</div>
</div>
<div
class="flex mb-2 text-black text-[12px] gap-2 font-medium pt-4"
>
<span class="w-[33px]">Effect</span>
<span class="w-[137px]">Feature</span>
<span>Value</span>
</div>
<og-risk-row>
<div class="flex items-center gap-2 text-[12px]">
<span class="w-[33px]"
><og-pill color="#e6275a"></og-pill
></span>
<span class="w-[137px]">Max transaction amount</span>
<span class="w-[64px]">$ 92015.55</span>
<og-small-button text="View"></og-small-button>
</div>
</og-risk-row>
<og-risk-row>
<div class="flex items-center gap-2 text-[12px]">
<span class="w-[33px]"
><og-pill color="#e6275a"></og-pill
></span>
<span class="w-[137px]">Previous fraud attempt</span>
<span class="w-[64px]">3</span>
</div>
</og-risk-row>
<og-risk-row>
<div class="flex items-center gap-2 text-[12px]">
<span class="w-[33px]"
><og-pill color="#e6275a"></og-pill
></span>
<span class="w-[137px]">Account age</span>
<span class="w-[64px]">72 days</span>
</div>
</og-risk-row>
<og-risk-row>
<div class="flex items-center gap-2 text-[12px]">
<span class="w-[33px]"
><og-pill color="#e6275a"></og-pill
></span>
<span class="w-[137px]">Last transaction date</span>
<span class="w-[64px]">29/12/24</span>
<og-small-button text="View"></og-small-button>
</div>
</og-risk-row>
<og-risk-row>
<div class="flex items-center gap-2 text-[12px]">
<span class="w-[33px]"
><og-pill color="#0d8ae0"></og-pill
></span>
<span class="w-[137px]">Minimum transaction amount</span>
<span class="w-[64px]">$ 192.44</span>
<og-small-button text="View"></og-small-button>
</div>
</og-risk-row>
<og-risk-row>
<div class="flex items-center gap-2 text-[12px]">
<span class="w-[33px]"
><og-pill color="#0d8ae0"></og-pill
></span>
<span class="w-[137px]">Recent activity</span>
<span class="w-[64px]">True</span>
</div>
</og-risk-row>
</div>
</div>
</div>
</main>
</div>
</body>
</html>
css
:root {
--selected-text-stroke: rgb(0, 0, 0);
}
.loader {
width: 14.5px;
height: 14.5px;
border-radius: 50%;
position: relative;
animation: rotate 1s linear infinite
}
.loader::before {
content: "";
box-sizing: border-box;
position: absolute;
inset: 0px;
border-radius: 50%;
border: 2px solid #000000;
animation: prixClipFix 2s linear infinite ;
}
@keyframes rotate {
100% {transform: rotate(360deg)}
}
@keyframes prixClipFix {
0% {clip-path:polygon(50% 50%,0 0,0 0,0 0,0 0,0 0)}
25% {clip-path:polygon(50% 50%,0 0,100% 0,100% 0,100% 0,100% 0)}
50% {clip-path:polygon(50% 50%,0 0,100% 0,100% 100%,100% 100%,100% 100%)}
75% {clip-path:polygon(50% 50%,0 0,100% 0,100% 100%,0 100%,0 100%)}
100% {clip-path:polygon(50% 50%,0 0,100% 0,100% 100%,0 100%,0 0)}
}
*,
::before,
::after {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style: ;
}
::backdrop {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style: ;
}
/*
! tailwindcss v3.4.16 | MIT License | https://tailwindcss.com
*/
/*
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
*/
*,
::before,
::after {
box-sizing: border-box; /* 1 */
border-width: 0; /* 2 */
border-style: solid; /* 2 */
border-color: #e5e7eb; /* 2 */
}
::before,
::after {
--tw-content: '';
}
/*
1. Use a consistent sensible line-height in all browsers.
2. Prevent adjustments of font size after orientation changes in iOS.
3. Use a more readable tab size.
4. Use the user's configured `sans` font-family by default.
5. Use the user's configured `sans` font-feature-settings by default.
6. Use the user's configured `sans` font-variation-settings by default.
7. Disable tap highlights on iOS
*/
html,
:host {
line-height: 1.5; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
-moz-tab-size: 4; /* 3 */
tab-size: 4; /* 3 */
font-family: "IBM Plex Sans", sans-serif, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; /* 4 */
font-feature-settings: normal; /* 5 */
font-variation-settings: normal; /* 6 */
-webkit-tap-highlight-color: transparent; /* 7 */
}
/*
1. Remove the margin in all browsers.
2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
*/
body {
margin: 0; /* 1 */
line-height: inherit; /* 2 */
}
/*
1. Add the correct height in Firefox.
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
3. Ensure horizontal rules are visible by default.
*/
hr {
height: 0; /* 1 */
color: inherit; /* 2 */
border-top-width: 1px; /* 3 */
}
/*
Add the correct text decoration in Chrome, Edge, and Safari.
*/
abbr:where([title]) {
text-decoration: underline dotted;
}
/*
Remove the default font size and weight for headings.
*/
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit;
}
/*
Reset links to optimize for opt-in styling instead of opt-out.
*/
a {
color: inherit;
text-decoration: inherit;
}
/*
Add the correct font weight in Edge and Safari.
*/
b,
strong {
font-weight: bolder;
}
/*
1. Use the user's configured `mono` font-family by default.
2. Use the user's configured `mono` font-feature-settings by default.
3. Use the user's configured `mono` font-variation-settings by default.
4. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp,
pre {
font-family: "IBM Plex Sans", sans-serif, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace; /* 1 */
font-feature-settings: normal; /* 2 */
font-variation-settings: normal; /* 3 */
font-size: 1em; /* 4 */
}
/*
Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/*
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/*
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
3. Remove gaps between table borders by default.
*/
table {
text-indent: 0; /* 1 */
border-color: inherit; /* 2 */
border-collapse: collapse; /* 3 */
}
/*
1. Change the font styles in all browsers.
2. Remove the margin in Firefox and Safari.
3. Remove default padding in all browsers.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-feature-settings: inherit; /* 1 */
font-variation-settings: inherit; /* 1 */
font-size: 100%; /* 1 */
font-weight: inherit; /* 1 */
line-height: inherit; /* 1 */
letter-spacing: inherit; /* 1 */
color: inherit; /* 1 */
margin: 0; /* 2 */
padding: 0; /* 3 */
}
/*
Remove the inheritance of text transform in Edge and Firefox.
*/
button,
select {
text-transform: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Remove default button styles.
*/
button,
input:where([type='button']),
input:where([type='reset']),
input:where([type='submit']) {
-webkit-appearance: button; /* 1 */
background-color: transparent; /* 2 */
background-image: none; /* 2 */
}
/*
Use the modern Firefox focus style for all focusable elements.
*/
:-moz-focusring {
outline: auto;
}
/*
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
*/
:-moz-ui-invalid {
box-shadow: none;
}
/*
Add the correct vertical alignment in Chrome and Firefox.
*/
progress {
vertical-align: baseline;
}
/*
Correct the cursor style of increment and decrement buttons in Safari.
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/*
1. Correct the odd appearance in Chrome and Safari.
2. Correct the outline style in Safari.
*/
[type='search'] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/*
Remove the inner padding in Chrome and Safari on macOS.
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
.hidden {
display: none;
}
/*
Add the correct display in Chrome and Safari.
*/
summary {
display: list-item;
}
/*
Removes the default spacing and border for appropriate elements.
*/
blockquote,
dl,
dd,
h1,
h2,
h3,
h4,
h5,
h6,
hr,
figure,
p,
pre {
margin: 0;
}
fieldset {
margin: 0;
padding: 0;
}
legend {
padding: 0;
}
ol,
ul,
menu {
list-style: none;
margin: 0;
padding: 0;
}
/*
Reset default styling for dialogs.
*/
dialog {
padding: 0;
}
/*
Prevent resizing textareas horizontally by default.
*/
textarea {
resize: vertical;
}
/*
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
2. Set the default placeholder color to the user's configured gray 400 color.
*/
input::placeholder,
textarea::placeholder {
opacity: 1; /* 1 */
color: #9ca3af; /* 2 */
}
/*
Set the default cursor for buttons.
*/
button,
[role='button'] {
cursor: pointer;
}
/*
Make sure disabled buttons don't get the pointer cursor.
*/
:disabled {
cursor: default;
}
/*
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
This can trigger a poorly considered lint error in some tools but is included by design.
*/
img,
svg,
video,
canvas,
audio,
iframe,
embed,
object {
display: block; /* 1 */
vertical-align: middle; /* 2 */
}
/*
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
*/
img,
video {
max-width: 100%;
height: auto;
}
/* Make elements with the HTML hidden attribute stay hidden by default */
[hidden]:where(:not([hidden='until-found'])) {
display: none;
}
:root {
--background: 0 0% 100%;
--foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 47.4% 11.2%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--card: transparent;
--card-foreground: 222.2 47.4% 11.2%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 100% 50%;
--destructive-foreground: 210 40% 98%;
--ring: 215 20.2% 65.1%;
--radius: 0.5rem;
}
* {
border-color: hsl(var(--border));
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
font-feature-settings:
'rlig' 1,
'calt' 1;
}
#graph-container {
top: 0;
bottom: 0;
left: 0;
right: 0;
position: absolute;
margin: 0;
overflow: hidden;
}
#app > main > div > div {
z-index: 1;
}
.margin-\[8px\] {
margin: 8px;
}
.absolute {
position: absolute;
}
.relative {
position: relative;
}
.-left-1 {
left: -0.25rem;
}
.-top-1 {
top: -0.25rem;
}
.left-0 {
left: 0px;
}
.left-1 {
left: 0.25rem;
}
.left-1\.5 {
left: 0.375rem;
}
.left-2 {
left: 0.5rem;
}
.left-3\.5 {
left: 0.875rem;
}
.left-7 {
left: 1.75rem;
}
.left-8 {
left: 2rem;
}
.left-\[-9px\] {
left: -9px;
}
.left-\[103px\] {
left: 103px;
}
.left-\[27px\] {
left: 27px;
}
.left-\[31px\] {
left: 31px;
}
.left-\[369px\] {
left: 369px;
}
.left-\[3px\] {
left: 3px;
}
.top-0 {
top: 0px;
}
.top-1 {
top: 0.25rem;
}
.top-24 {
top: 6rem;
}
.top-\[-9px\] {
top: -9px;
}
.top-\[11px\] {
top: 11px;
}
.top-\[129px\] {
top: 129px;
}
.top-\[15px\] {
top: 15px;
}
.top-\[27px\] {
top: 27px;
}
.top-\[334px\] {
top: 334px;
}
.top-\[397px\] {
top: 397px;
}
.top-\[3px\] {
top: 3px;
}
.top-\[43px\] {
top: 43px;
}
.top-\[45px\] {
top: 45px;
}
.top-\[5px\] {
top: 5px;
}
.my-2 {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.mb-2 {
margin-bottom: 0.5rem;
}
.ml-\[-1\.44px\] {
margin-left: -1.44px;
}
.ml-\[-2\.89px\] {
margin-left: -2.89px;
}
.mt-2 {
margin-top: 0.5rem;
}
.mt-4 {
margin-top: 1rem;
}
.mt-6 {
margin-top: 1.5rem;
}
.flex {
display: flex;
}
.inline-flex {
display: inline-flex;
}
.table {
display: table;
}
.h-10 {
height: 2.5rem;
}
.h-4 {
height: 1rem;
}
.h-60 {
height: 15rem;
}
.h-8 {
height: 2rem;
}
.h-9 {
height: 2.25rem;
}
.h-\[11\.56px\] {
height: 11.56px;
}
.h-\[111px\] {
height: 111px;
}
.h-\[113px\] {
height: 113px;
}
.h-\[11px\] {
height: 11px;
}
.h-\[133px\] {
height: 133px;
}
.h-\[15\.89px\] {
height: 15.89px;
}
.h-\[15px\] {
height: 15px;
}
.h-\[165px\] {
height: 165px;
}
.h-\[17\.33px\] {
height: 17.33px;
}
.h-\[18px\] {
height: 18px;
}
.h-\[195px\] {
height: 195px;
}
.h-\[1px\] {
height: 1px;
}
.h-\[22px\] {
height: 22px;
}
.h-\[23px\] {
height: 23px;
}
.h-\[25px\] {
height: 25px;
}
.h-\[29px\] {
height: 29px;
}
.h-\[34\.67px\] {
height: 34.67px;
}
.h-\[34px\] {
height: 34px;
}
.h-\[35px\] {
height: 35px;
}
.h-\[43px\] {
height: 43px;
}
.h-\[448px\] {
height: 448px;
}
.h-\[52px\] {
height: 52px;
}
.h-\[544px\] {
height: 544px;
}
.h-\[545px\] {
height: 545px;
}
.h-\[7\.22px\] {
height: 7.22px;
}
.h-\[86px\] {
height: 86px;
}
.h-auto {
height: auto;
}
.h-full {
height: 100%;
}
.h-px {
height: 1px;
}
.w-11 {
width: 2.75rem;
}
.w-2 {
width: 0.5rem;
}
.w-9 {
width: 2.25rem;
}
.w-\[115\.56px\] {
width: 115.56px;
}
.w-\[123px\] {
width: 123px;
}
.w-\[137px\] {
width: 137px;
}
.w-\[15px\] {
width: 15px;
}
.w-\[17\.33px\] {
width: 17.33px;
}
.w-\[180px\] {
width: 180px;
}
.w-\[17px\] {
width: 17px;
}
.w-\[18px\] {
width: 18px;
}
.w-\[1px\] {
width: 1px;
}
.w-\[21\.67px\] {
width: 21.67px;
}
.w-\[22px\] {
width: 22px;
}
.w-\[233px\] {
width: 233px;
}
.w-\[23px\] {
width: 23px;
}
.w-\[25px\] {
width: 25px;
}
.w-\[29px\] {
width: 29px;
}
.w-\[326px\] {
width: 326px;
}
.w-\[33px\] {
width: 33px;
}
.w-\[34\.67px\] {
width: 34.67px;
}
.w-\[35px\] {
width: 35px;
}
.w-\[397px\] {
width: 397px;
}
.w-\[431px\] {
width: 431px;
}
.w-\[43px\] {
width: 43px;
}
.w-\[45px\] {
width: 45px;
}
.w-\[52px\] {
width: 52px;
}
.w-\[64px\] {
width: 64px;
}
.w-\[7\.22px\] {
width: 7.22px;
}
.w-\[77px\] {
width: 77px;
}
.w-\[78px\] {
width: 78px;
}
.w-fit {
width: fit-content;
}
.w-full {
width: 100%;
}
.max-w-\[1040px\] {
max-width: 1040px;
}
.max-w-\[220px\] {
max-width: 220px;
}
.max-w-\[553px\] {
max-width: 553px;
}
.flex-1 {
flex: 1 1 0%;
}
.shrink-0 {
flex-shrink: 0;
}
.caption-bottom {
caption-side: bottom;
}
.-rotate-180 {
--tw-rotate: -180deg;
transform: translate(var(--tw-translate-x), var(--tw-translate-y))
rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y))
scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.rotate-180 {
--tw-rotate: 180deg;
transform: translate(var(--tw-translate-x), var(--tw-translate-y))
rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y))
scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.flex-row {
flex-direction: row;
}
.flex-col {
flex-direction: column;
}
.items-start {
align-items: flex-start;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.gap-1 {
gap: 0.25rem;
}
.gap-2 {
gap: 0.5rem;
}
.gap-3 {
gap: 0.75rem;
}
.gap-4 {
gap: 1rem;
}
.gap-5 {
gap: 1.25rem;
}
.gap-\[13px\] {
gap: 13px;
}
.gap-\[18\.78px\] {
gap: 18.78px;
}
.gap-\[2\.89px\] {
gap: 2.89px;
}
.gap-\[22px\] {
gap: 22px;
}
.gap-\[6\.5px\] {
gap: 6.5px;
}
.gap-\[7\.22px\] {
gap: 7.22px;
}
.gap-\[7\.94px\] {
gap: 7.94px;
}
.space-y-1 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0.25rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0.25rem * var(--tw-space-y-reverse));
}
.space-y-1\.5 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0.375rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0.375rem * var(--tw-space-y-reverse));
}
.space-y-2 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0.5rem * var(--tw-space-y-reverse));
}
.overflow-auto {
overflow: auto;
}
.overflow-hidden {
overflow: hidden;
}
.whitespace-nowrap {
white-space: nowrap;
}
.rounded-\[14\.44px\] {
border-radius: 14.44px;
}
.rounded-\[17\.33px\] {
border-radius: 17.33px;
}
.rounded-\[21\.67px\] {
border-radius: 21.67px;
}
.rounded-\[23\.83px\] {
border-radius: 23.83px;
}
.rounded-\[26px\] {
border-radius: 26px;
}
.rounded-\[3\.61px\] {
border-radius: 3.61px;
}
.rounded-\[5\.06px\] {
border-radius: 5.06px;
}
.rounded-\[5\.78px\] {
border-radius: 5.78px;
}
.rounded-\[6px\] {
border-radius: 6px;
}
.rounded-full {
border-radius: 9999px;
}
.rounded-md {
border-radius: calc(var(--radius) - 2px);
}
.rounded-xl {
border-radius: 0.75rem;
}
.border {
border-width: 1px;
}
.border-0 {
border-width: 0px;
}
.border-\[0\.72px\] {
border-width: 0.72px;
}
.border-\[1\.45px\] {
border-width: 1.45px;
}
.border-\[2\.17px\] {
border-width: 2.17px;
}
.border-\[8\.67px\] {
border-width: 8.67px;
}
.border-b {
border-bottom-width: 1px;
}
.border-t {
border-top-width: 1px;
}
.border-solid {
border-style: solid;
}
.border-\[\#0b62f4\] {
--tw-border-opacity: 1;
border-color: rgb(11 98 244 / var(--tw-border-opacity, 1));
}
.border-\[\#e6e5e5\] {
--tw-border-opacity: 1;
border-color: rgb(230 229 229 / var(--tw-border-opacity, 1));
}
.border-\[\#eef2f2\] {
--tw-border-opacity: 1;
border-color: rgb(238 242 242 / var(--tw-border-opacity, 1));
}
.border-\[\#ffd60d\] {
--tw-border-opacity: 1;
border-color: rgb(255 214 13 / var(--tw-border-opacity, 1));
}
.border-\[\#757C88\] {
--tw-border-opacity: 1;
border-color: #757C88;
}
.border-input {
border-color: hsl(var(--input));
}
.border-neutral-200 {
--tw-border-opacity: 1;
border-color: rgb(229 229 229 / var(--tw-border-opacity, 1));
}
.border-selected-text-stroke {
border-color: var(--selected-text-stroke);
}
.border-transparent {
border-color: transparent;
}
.bg-\[\#d1f1dc\] {
--tw-bg-opacity: 1;
background-color: rgb(209 241 220 / var(--tw-bg-opacity, 1));
}
.bg-\[\#dedefa\] {
--tw-bg-opacity: 1;
background-color: rgb(222 222 250 / var(--tw-bg-opacity, 1));
}
.bg-\[\#e6275a\] {
--tw-bg-opacity: 1;
background-color: rgb(230 39 90 / var(--tw-bg-opacity, 1));
}
.bg-\[\#f6f6f6\] {
--tw-bg-opacity: 1;
background-color: rgb(246 246 246 / var(--tw-bg-opacity, 1));
}
.bg-\[\#f8f8f8\] {
--tw-bg-opacity: 1;
background-color: rgb(248 248 248 / var(--tw-bg-opacity, 1));
}
.bg-\[\#f9f9f9\] {
--tw-bg-opacity: 1;
background-color: rgb(249 249 249 / var(--tw-bg-opacity, 1));
}
.bg-\[\#faf1dd\] {
--tw-bg-opacity: 1;
background-color: rgb(250 241 221 / var(--tw-bg-opacity, 1));
}
.bg-\[\#fafbfc\] {
--tw-bg-opacity: 1;
background-color: rgb(250 251 252 / var(--tw-bg-opacity, 1));
}
.bg-\[\#fee7e7\] {
--tw-bg-opacity: 1;
background-color: rgb(254 231 231 / var(--tw-bg-opacity, 1));
}
.bg-background {
background-color: hsl(var(--background));
}
.bg-border {
background-color: hsl(var(--border));
}
.bg-card {
background-color: hsl(var(--card));
}
.bg-destructive {
background-color: hsl(var(--destructive));
}
.bg-muted\/50 {
background-color: hsl(var(--muted) / 0.5);
}
.bg-primary {
background-color: hsl(var(--primary));
}
.bg-secondary {
background-color: hsl(var(--secondary));
}
.bg-white {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1));
}
.bg-\[url\(https\:\/\/c\.animaapp\.com\/bOBm0EuC\/img\/group\@2x\.png\)\] {
background-image: url(https://c.animaapp.com/bOBm0EuC/img/group@2x.png);
}
.bg-\[100\%_100\%\] {
background-position: 100% 100%;
}
.p-0 {
padding: 0px;
}
.p-2 {
padding: 0.5rem;
}
.p-6 {
padding: 1.5rem;
}
.p-\[7px\] {
padding: 7px;
}
.px-2 {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.px-2\.5 {
padding-left: 0.625rem;
padding-right: 0.625rem;
}
.px-3 {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.px-4 {
padding-left: 1rem;
padding-right: 1rem;
}
.px-5 {
padding-left: 1.25rem;
padding-right: 1.25rem;
}
.px-8 {
padding-left: 2rem;
padding-right: 2rem;
}
.px-\[4\.33px\] {
padding-left: 4.33px;
padding-right: 4.33px;
}
.px-\[7px\] {
padding-left: 7px;
padding-right: 7px;
}
.px-\[8\.67px\] {
padding-left: 8.67px;
padding-right: 8.67px;
}
.py-0\.5 {
padding-top: 0.125rem;
padding-bottom: 0.125rem;
}
.py-2 {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.py-\[2\.89px\] {
padding-top: 2.89px;
padding-bottom: 2.89px;
}
.py-\[3px\] {
padding-top: 3px;
padding-bottom: 3px;
}
.py-\[5\.78px\] {
padding-top: 5.78px;
padding-bottom: 5.78px;
}
.pb-0 {
padding-bottom: 0px;
}
.pb-6 {
padding-bottom: 1.5rem;
}
.pt-0 {
padding-top: 0px;
}
.pt-4 {
padding-top: 1rem;
}
.pt-7 {
padding-top: 1.75rem;
}
.text-left {
text-align: left;
}
.text-center {
text-align: center;
}
.align-middle {
vertical-align: middle;
}
.text-\[10\.1px\] {
font-size: 10.1px;
}
.text-\[10px\] {
font-size: 10px;
}
.text-\[11\.6px\] {
font-size: 11.6px;
}
.text-\[11px\] {
font-size: 11px;
}
.text-\[12px\] {
font-size: 12px;
}
.text-\[13px\] {
font-size: 13px;
}
.text-\[23px\] {
font-size: 23px;
}
.text-\[8\.7px\] {
font-size: 8.7px;
}
.text-base {
font-size: 1rem;
line-height: 1.5rem;
}
.text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
.text-xs {
font-size: 0.75rem;
line-height: 1rem;
}
.font-bold {
font-weight: 700;
}
.font-medium {
font-weight: 500;
}
.font-normal {
font-weight: 400;
}
.font-semibold {
font-weight: 600;
}
.italic {
font-style: italic;
}
.leading-\[14\.4px\] {
line-height: 14.4px;
}
.leading-\[14\.5px\] {
line-height: 14.5px;
}
.leading-\[17\.3px\] {
line-height: 17.3px;
}
.leading-none {
line-height: 1;
}
.leading-normal {
line-height: 1.5;
}
.leading-tight {
line-height: 1.25;
}
.tracking-\[0\] {
letter-spacing: 0;
}
.tracking-tight {
letter-spacing: -0.025em;
}
.text-\[\#0b62f4\] {
--tw-text-opacity: 1;
color: rgb(11 98 244 / var(--tw-text-opacity, 1));
}
.text-\[\#0d8ae0\] {
--tw-text-opacity: 1;
color: rgb(13 138 224 / var(--tw-text-opacity, 1));
}
.text-\[\#131b34\] {
--tw-text-opacity: 1;
color: rgb(19 27 52 / var(--tw-text-opacity, 1));
}
.text-\[\#155229\] {
--tw-text-opacity: 1;
color: rgb(21 82 41 / var(--tw-text-opacity, 1));
}
.text-\[\#172246\] {
--tw-text-opacity: 1;
color: rgb(23 34 70 / var(--tw-text-opacity, 1));
}
.text-\[\#20374a\] {
--tw-text-opacity: 1;
color: rgb(32 55 74 / var(--tw-text-opacity, 1));
}
.text-\[\#232222\] {
--tw-text-opacity: 1;
color: rgb(35 34 34 / var(--tw-text-opacity, 1));
}
.text-\[\#343232\] {
--tw-text-opacity: 1;
color: rgb(52 50 50 / var(--tw-text-opacity, 1));
}
.text-\[\#3b4153\] {
--tw-text-opacity: 1;
color: rgb(59 65 83 / var(--tw-text-opacity, 1));
}
.text-\[\#3f3f3f\] {
--tw-text-opacity: 1;
color: rgb(63 63 63 / var(--tw-text-opacity, 1));
}
.text-\[\#484545\] {
--tw-text-opacity: 1;
color: rgb(72 69 69 / var(--tw-text-opacity, 1));
}
.text-\[\#555353\] {
--tw-text-opacity: 1;
color: rgb(85 83 83 / var(--tw-text-opacity, 1));
}
.text-\[\#e6275a\] {
--tw-text-opacity: 1;
color: rgb(230 39 90 / var(--tw-text-opacity, 1));
}
.text-\[\#e7275a\] {
--tw-text-opacity: 1;
color: rgb(231 39 90 / var(--tw-text-opacity, 1));
}
.text-black {
--tw-text-opacity: 1;
color: rgb(0 0 0 / var(--tw-text-opacity, 1));
}
.text-\[\#757C88\] {
--tw-text-opacity: 1;
color: #757C88;
}
.text-card-foreground {
color: hsl(var(--card-foreground));
}
.text-destructive-foreground {
color: hsl(var(--destructive-foreground));
}
.text-foreground {
color: hsl(var(--foreground));
}
.text-muted-foreground {
color: hsl(var(--muted-foreground));
}
.text-primary {
color: hsl(var(--primary));
}
.text-primary-foreground {
color: hsl(var(--primary-foreground));
}
.text-secondary-foreground {
color: hsl(var(--secondary-foreground));
}
.underline-offset-4 {
text-underline-offset: 4px;
}
.shadow {
/* --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color),
0 1px 2px -1px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000),
var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); */
}
.shadow-sm {
--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000),
var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.outline {
outline-style: solid;
}
.filter {
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast)
var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate)
var(--tw-sepia) var(--tw-drop-shadow);
}
.transition-colors {
transition-property: color, background-color, border-color,
text-decoration-color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.\[-webkit-text-stroke\:1\.44px_\#f8f8f8\] {
-webkit-text-stroke: 1.44px #f8f8f8;
}
.\[-webkit-text-stroke\:2\.89px_\#ffffff\] {
-webkit-text-stroke: 2.89px #ffffff;
}
.hover\:bg-accent:hover {
background-color: hsl(var(--accent));
}
.hover\:bg-destructive\/80:hover {
background-color: hsl(var(--destructive) / 0.8);
}
.hover\:bg-destructive\/90:hover {
background-color: hsl(var(--destructive) / 0.9);
}
.hover\:bg-muted\/50:hover {
background-color: hsl(var(--muted) / 0.5);
}
.hover\:bg-primary\/80:hover {
background-color: hsl(var(--primary) / 0.8);
}
.hover\:bg-primary\/90:hover {
background-color: hsl(var(--primary) / 0.9);
}
.hover\:bg-secondary\/80:hover {
background-color: hsl(var(--secondary) / 0.8);
}
.hover\:text-accent-foreground:hover {
color: hsl(var(--accent-foreground));
}
.hover\:underline:hover {
text-decoration-line: underline;
}
.focus\:outline-none:focus {
outline: 2px solid transparent;
outline-offset: 2px;
}
.focus\:ring-2:focus {
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0
var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0
calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow),
var(--tw-shadow, 0 0 #0000);
}
.focus\:ring-ring:focus {
--tw-ring-color: hsl(var(--ring));
}
.focus\:ring-offset-2:focus {
--tw-ring-offset-width: 2px;
}
.focus-visible\:outline-none:focus-visible {
outline: 2px solid transparent;
outline-offset: 2px;
}
.focus-visible\:ring-1:focus-visible {
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0
var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0
calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow),
var(--tw-shadow, 0 0 #0000);
}
.focus-visible\:ring-ring:focus-visible {
--tw-ring-color: hsl(var(--ring));
}
.disabled\:pointer-events-none:disabled {
pointer-events: none;
}
.disabled\:opacity-50:disabled {
opacity: 0.5;
}
.data-\[state\=selected\]\:bg-muted[data-state='selected'] {
background-color: hsl(var(--muted));
}
.\[\&\:has\(\[role\=checkbox\]\)\]\:pr-0:has([role='checkbox']) {
padding-right: 0px;
}
.\[\&\>\[role\=checkbox\]\]\:translate-y-\[2px\] > [role='checkbox'] {
--tw-translate-y: 2px;
transform: translate(var(--tw-translate-x), var(--tw-translate-y))
rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y))
scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.\[\&\>tr\]\:last\:border-b-0:last-child > tr {
border-bottom-width: 0px;
}
.\[\&_svg\]\:pointer-events-none svg {
pointer-events: none;
}
.\[\&_svg\]\:size-4 svg {
width: 1rem;
height: 1rem;
}
.\[\&_svg\]\:shrink-0 svg {
flex-shrink: 0;
}
.\[\&_tr\:last-child\]\:border-0 tr:last-child {
border-width: 0px;
}
.\[\&_tr\]\:border-b tr {
border-bottom-width: 1px;
}
json
{
"dependencies": {
"@tensorflow/tfjs": "4.20.0"
}
}
ts
export class OgLargeButton extends HTMLElement {
connectedCallback() {
const label = this.getAttribute('label') || 'Button';
const type = this.getAttribute('type') || 'default'; // can be 'default', 'reset', 'primary'
const disabled = this.hasAttribute('disabled');
let classes =
'w-[180px] h-[34px] flex items-center justify-center rounded-[6px] border text-[11.6px] font-semibold px-4 py-2 transition-colors';
if (type === 'reset') {
classes +=
' bg-[#f9f9f9] text-[#484545] border-[#eef2f2] hover:bg-accent hover:text-accent-foreground';
} else if (type === 'primary') {
classes +=
' bg-[#0b62f4] text-white border-transparent hover:bg-primary/90';
} else {
classes += ' bg-white text-black border border-neutral-200';
}
if (disabled) {
classes += ' disabled:pointer-events-none disabled:opacity-50';
}
this.innerHTML = `
<button class="${classes}" ${disabled ? 'disabled' : ''}>
${label}
</button>
`;
}
}
ts
export class OgNodeConnections extends HTMLElement {
static get observedAttributes() {
return ['connected'];
}
connectedCallback() {
this.render();
}
attributeChangedCallback(
name: string,
oldValue: string | null,
newValue: string | null
) {
if (name === 'connected' && oldValue !== newValue) {
this.render();
}
}
private render() {
const connected = this.getAttribute('connected') || '';
this.innerHTML = `
<div class="flex items-center gap-[6.5px] mt-2 mb-4" style="max-height: 50px; overflow-y: auto;">
<div class="shrink-0 w-[7.22px] h-[7.22px] bg-white rounded-[3.61px] border-[2.17px] border-solid border-[#ffd60d]"></div>
<p class="font-normal text-[#3f3f3f] text-[12px]">
Connected Nodes: ${connected}
</p>
</div>
`;
}
}
ts
export class OgNodeList extends HTMLElement {
static get observedAttributes() {
return ['nodes', 'title'];
}
private accounts: string[] = [];
private selectedIndex: number = 0;
connectedCallback() {
this.render();
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
if (name === 'nodes' && oldValue !== newValue) {
try {
this.accounts = JSON.parse(newValue || '[]');
this.selectedIndex = 0; // reset index
} catch {
console.warn('Invalid nodes attribute passed to <og-node-list>');
this.accounts = [];
}
}
this.render();
}
render() {
const title = this.getAttribute('title') || 'Accounts';
const currentAccount = this.accounts[this.selectedIndex] || 'No account';
this.innerHTML = `
<div class="flex flex-col w-[180px] h-60 items-start gap-3 overflow-hidden">
<h3 class="font-medium text-[#20374a] text-[14px]">${title}</h3>
<div class="flex w-full items-center justify-center gap-[22px]">
<button id="prev-btn" class="p-0 h-auto">
<img class="relative w-[23px] h-[23px]" alt="Previous" src="files/icons/left-arrow.svg" />
</button>
<div class="font-normal text-[#3b4153] text-[12px] text-center">
${currentAccount}
</div>
<button id="next-btn" class="p-0 h-auto">
<img class="relative w-[23px] h-[23px] rotate-180" alt="Next" src="files/icons/left-arrow.svg" />
</button>
</div>
<div class="flex flex-col overflow-auto w-full bg-[#f6f6f6]">
${this.accounts
.map(
(account, i) => `
<div class="flex h-[35px] items-center gap-[13px] px-[7px] py-[3px] w-full ${i % 2 === 0 ? 'bg-[#fafbfc]' : 'bg-white'} ${i === this.selectedIndex ? 'font-medium' : 'font-normal'}">
<img class="w-[22px] h-[22px]" alt="Person icon" src="files/icons/person.svg" />
<div class="text-[#172246] text-[12px]">${account}</div>
</div>
`
)
.join('')}
</div>
</div>
`;
this.querySelector('#prev-btn')?.addEventListener('click', () =>
this.selectPrev()
);
this.querySelector('#next-btn')?.addEventListener('click', () =>
this.selectNext()
);
}
selectPrev() {
if (this.accounts.length === 0) return;
this.selectedIndex =
(this.selectedIndex - 1 + this.accounts.length) % this.accounts.length;
this.dispatchSelectedEvent();
this.render();
}
selectNext() {
if (this.accounts.length === 0) return;
this.selectedIndex = (this.selectedIndex + 1) % this.accounts.length;
this.dispatchSelectedEvent();
this.render();
}
dispatchSelectedEvent() {
const selected = this.accounts[this.selectedIndex];
this.dispatchEvent(
new CustomEvent('account-change', {
detail: { account: selected, index: this.selectedIndex },
bubbles: true,
composed: true
})
);
}
}
ts
export class OgPill extends HTMLElement {
connectedCallback() {
const color = this.getAttribute('color') || '#e6275a';
this.innerHTML = `
<div style="
background-color: ${color};
width: 22px;
height: 12px;
border-radius: 9999px;
"></div>
`;
}
}
ts
export class OgRiskRow extends HTMLElement {
connectedCallback() {
if (this.querySelector('slot')) return; // prevent double mounting
const container = document.createElement('div');
const slotContainer = document.createElement('div');
slotContainer.className = 'flex items-center gap-5';
const slot = document.createElement('slot');
slotContainer.appendChild(slot);
const line = document.createElement('div');
line.className = 'h-px w-full bg-border my-2';
container.appendChild(slotContainer);
container.appendChild(line);
this.appendChild(container);
}
}
ts
export class OgSmallButton extends HTMLElement {
static get observedAttributes() {
return ['text'];
}
connectedCallback() {
this.render();
}
attributeChangedCallback(
name: string,
oldValue: string | null,
newValue: string | null
) {
if (name === 'text' && oldValue !== newValue) {
this.render();
}
}
private render() {
const text = this.getAttribute('text') || 'View';
const isHide = text.toLowerCase() === 'hide';
this.innerHTML = `
<button
class="text-[#0b62f4] text-[8.7px] border-[0.72px] border-[#0b62f4] px-[4.33px] py-[2.89px] rounded-[5.78px] ${
isHide
? 'bg-[#f0f0f0] text-[#757C88] border-[#757C88]'
: 'bg-[#f5faff] text-[#0a6ed1] border-[#cce4ff]'
}"
>
${text}
</button>
`;
}
}
ts
export class OgSuspiciousCard extends HTMLElement {
static get observedAttributes() {
return ['title', 'value', 'alert-type', 'icon', 'icon-bg'];
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
render() {
const title = this.getAttribute('title') || '';
const value = this.getAttribute('value') || '';
const icon = this.getAttribute('icon');
const iconBg = this.getAttribute('icon-bg') || '';
const alertType = this.getAttribute('alert-type') || 'info';
const borderClass =
alertType === 'warning' || alertType === 'error'
? ''
: 'border border-neutral-200';
const bgClass =
alertType === 'warning' || alertType === 'error'
? 'bg-[#fee7e7]'
: 'bg-card';
this.innerHTML = `
<div class="${bgClass} text-card-foreground shadow w-full p-0 ${borderClass} rounded-[6px]">
<div class="p-2 w-[180px] ${icon ? 'flex justify-between' : ''}">
<div class="flex flex-col gap-1">
<p class="font-normal text-[#343232] text-[12px] leading-[14.5px]">${title}</p>
${value === '' ? `<span class="loader"></span>` : `<p class="font-bold text-[#343232] text-base leading-[14.5px]">${value}</p>`}
</div>
${
icon
? `
<div class="flex shrink-0 items-center justify-center w-[35px] h-[35px] ${iconBg} rounded-full overflow-hidden">
<img class="w-[25px] h-[25px]" src="${icon}" />
</div>`
: ''
}
</div>
</div>
`;
}
}
ts
import Ogma, { NodeList } from '@linkurious/ogma';
import * as tf from '@tensorflow/tfjs';
import { NodeData } from './types';
type InputExample = {
age: number;
previousFraud: number;
maxTxAmount: number;
minTxAmount: number;
isRecentTx: number;
};
type RawDataset = {
data: InputExample;
expectedSusScore: number;
};
export function prepareInput(nodes: NodeList<NodeData>): InputExample[] {
const input: InputExample[] = [];
const nodesData = nodes.getData();
nodesData.forEach((d: NodeData, i) => {
const edges = nodes.get(i).getAdjacentEdges();
const maxTx = Math.max(...edges.map(e => e.getData()?.amount || 0));
const minTx = Math.min(...edges.map(e => e.getData()?.amount || 0));
const recentTx = edges.some(
e => (e.getData()?.timestamp ?? 0) > 1750257776000 - 10000000000 / 2
);
input.push({
age: d.age / 1000,
previousFraud: d.previousFraud ?? 0,
maxTxAmount: maxTx / 1000,
minTxAmount: minTx / 1000,
isRecentTx: recentTx ? 1 : 0
});
});
return input;
}
export async function predictSuspicion(
model: tf.LayersModel,
example: InputExample[]
): Promise<number[]> {
if (!model) throw new Error('Model not trained yet');
const input = tf.tensor2d(
example.map(d => [
d.age,
d.previousFraud,
d.maxTxAmount,
d.minTxAmount,
d.isRecentTx
])
);
const prediction = model.predict(input) as tf.Tensor;
const score = (await prediction.array()) as number[][];
return score.map(([p]) => p); // returns value between 0-1
}
//
// 👇 this part below is used to build the AI model and train it from scratch 👇
//
function prepareExpectedOutput(input: InputExample[]): number[] {
return input.map(d => {
if (d.maxTxAmount > 80 || d.previousFraud > 5) return 1; // suspicious
return 0; // not suspicious
});
}
async function trainModel(
model: tf.LayersModel,
data: InputExample[],
labels: number[]
): Promise<void> {
const xs = tf.tensor2d(
data.map(d => [
d.age,
d.previousFraud,
d.maxTxAmount,
d.minTxAmount,
d.isRecentTx
])
);
const ys = tf.tensor2d(labels.map(label => [label]));
await model.fit(xs, ys, {
epochs: 500,
batchSize: 80,
shuffle: true,
callbacks: {
onEpochEnd: async (epoch, logs) => {
console.log(
`Epoch ${epoch + 1}: loss = ${logs?.loss?.toFixed(4)}, acc = ${logs?.acc ?? logs?.accuracy}`
);
}
}
});
}
async function createModel(): Promise<tf.LayersModel> {
const model = tf.sequential();
model.add(
tf.layers.dense({
inputShape: [5],
units: 16,
activation: 'relu',
kernelInitializer: 'randomNormal'
})
);
model.add(
tf.layers.dense({
units: 8,
activation: 'relu',
kernelInitializer: 'randomNormal'
})
);
model.add(
tf.layers.dense({
units: 1,
activation: 'sigmoid',
kernelInitializer: 'randomNormal'
})
);
model.compile({
optimizer: tf.train.adam(0.01),
loss: tf.losses.meanSquaredError
});
console.log('✅ Model created');
return model;
}
function computeAccuracy(prediction: number[], rawDataset: RawDataset[]) {
return (
prediction
.map((p, i) => Math.abs(p - rawDataset[i].expectedSusScore) < 0.5)
.reduce((acc, v) => acc + (v ? 1 : 0), 0) / rawDataset.length
);
}
export async function prepareAndDownloadAIModel(ogma: Ogma<NodeData, any>) {
// Load the AI model and train the model
const input = prepareInput(ogma.getNodes());
const expectedOutput = prepareExpectedOutput(input);
const rawDataset = input.map((data, i) => ({
data,
expectedSusScore: expectedOutput[i]
}));
console.log('Raw dataset:', rawDataset);
const model = await createModel();
console.log('Model created:', model);
// Train on the first 800 elements, validate on the others
const trainingDataset = rawDataset.slice(0, 800);
const validationDataset = rawDataset.slice(800, -1);
await trainModel(
model,
trainingDataset.map(({ data }) => data), // input data
trainingDataset.map(({ expectedSusScore }) => expectedSusScore) // expected output
);
console.log('Model trained:', model);
const prediction = await predictSuspicion(
model,
validationDataset.map(({ data }) => data)
);
console.log('Accuracy', computeAccuracy(prediction, rawDataset) * 100 + '%');
await model.save('downloads://fraud');
}
ts
import Ogma from '@linkurious/ogma';
import { NodeData } from './types';
import { cleanIds } from './utils';
export async function prepareGraph(ogma: Ogma) {
const graph = cleanIds(
await Ogma.parse.jsonFromUrl<NodeData, unknown>(
'files/ai-classification.json'
)
);
await ogma.setGraph(graph);
return graph;
}
ts
import Ogma, { EdgeAttributesValue } from '@linkurious/ogma';
import { formatMoney } from './utils';
const categoryToColor = ['#85B3FE', '#DD3D2D', '#FC852C', 'grey'];
const categoryToHoveredColor = ['#C8DBF9', '#EBABA5', '#F9C39A'];
const categoryToSelectedColor = ['#0C4B87', '#7F261D', '#E36608'];
export function prepareStyle(ogma: Ogma) {
ogma.styles.addNodeRule({
color: node =>
categoryToColor[node.getData().category] || categoryToColor[3],
text: node => ({
content: `Account ${node.getId()}`,
size: '14px'
// font: 'Inter'
})
});
ogma.styles.setHoveredNodeAttributes({
outerStroke: node => ({
color:
categoryToHoveredColor[node.getData().category] ||
categoryToHoveredColor[0],
width: 12
})
});
ogma.styles.setSelectedNodeAttributes({
outerStroke: node => ({
color:
categoryToSelectedColor[node.getData().category] ||
categoryToSelectedColor[0],
width: 12
})
});
ogma.styles.createClass({
name: 'neighbor',
nodeAttributes: {
outerStroke: '#FADA72'
}
});
const highlightedEdgeGreen: EdgeAttributesValue<any, any> = {
color: '#006A28',
text: {
content: e => `Amount: ${formatMoney(e.getData()?.amount || 0)}`,
size: '14px',
color: '#fff',
style: 'bold',
backgroundColor: '#006A28',
minVisibleSize: 0
}
};
const highlightedEdgePurple: EdgeAttributesValue<any, any> = {
color: '#C71C93',
text: {
content: e =>
`Last Transaction Date: ${e.getData()?.timestamp ? new Date(e.getData().timestamp).toLocaleDateString() : 'N/A'}`,
size: '14px',
color: '#fff',
style: 'bold',
backgroundColor: '#C71C93',
minVisibleSize: 0
}
};
ogma.styles.createClass({
name: 'highlighted-edge-max-transaction-amount',
edgeAttributes: highlightedEdgeGreen
});
ogma.styles.createClass({
name: 'highlighted-edge-min-transaction-amount',
edgeAttributes: highlightedEdgeGreen
});
ogma.styles.createClass({
name: 'highlighted-edge-last-transaction-date',
edgeAttributes: highlightedEdgePurple
});
}
ts
import Ogma from '@linkurious/ogma';
import { NodeData } from './types';
import { getAvgPos } from './utils';
export function prepareUI(ogma: Ogma) {
const nodeTitleContainer = document.querySelector('div.tracking-tight');
const connComponent = document.querySelector('og-node-connections');
const confidenceContainer = document.getElementById('confidence-score');
const container = document.querySelector('div.p-6.px-4.pt-4');
function updateSelectedNodeUI(nodeId: string, data: NodeData) {
// Update node title
if (nodeTitleContainer) {
nodeTitleContainer.innerHTML = `Node <span class="font-bold">${nodeId}</span>`;
}
// Update connections
const connections = ogma.getNode(nodeId)?.getAdjacentNodes().getId();
if (connComponent && connections) {
connComponent.setAttribute('connected', connections.join(', '));
}
// Update confidence score
if (confidenceContainer) {
const score = data.riskAttribute?.confidenceScore ?? 0;
const level = score > 0.66 ? 'High' : score > 0.33 ? 'Medium' : 'Low';
const color =
score > 0.66 ? '#d1f1dc' : score > 0.33 ? '#ffeec2' : '#fee7e7';
const textColor =
score > 0.66 ? '#155229' : score > 0.33 ? '#7a5800' : '#9a2020';
confidenceContainer.innerHTML = `
<div class="inline-flex items-center border shadow bg-[${color}] text-[${textColor}] font-bold text-[12px] px-[8.67px] py-[5.78px] rounded-[5.06px]">
${level}
</div>
<span class="font-normal italic text-[#555353] text-[11px]">Score: ${score.toFixed(2)}</span>
`;
}
// Update risk rows
const riskRows = document.querySelectorAll('og-risk-row');
riskRows.forEach(row => row.remove()); // Clear old rows
const riskAttribute = data.riskAttribute;
if (container && riskAttribute && riskAttribute.riskFeatures) {
for (const feature of riskAttribute.riskFeatures) {
const row = document.createElement('og-risk-row');
const color = feature.effect === 'negative' ? '#e6275a' : '#0d8ae0';
row.innerHTML = `
<div class="flex items-center gap-2 text-[12px]">
<span class="w-[33px]"><og-pill color="${color}"></og-pill></span>
<span class="w-[137px]">${feature.feature}</span>
<span class="w-[64px]" style="text-overflow: ellipsis; overflow: hidden;">${feature.value}</span>
${
feature.viewable
? (() => {
const edge = feature.viewable.edgeId
? ogma.getEdge(feature.viewable.edgeId)
: undefined;
const isHighlighted =
(edge?.hasClass(
'highlighted-edge-max-transaction-amount'
) &&
feature.feature === 'Max Transaction Amount') ||
(edge?.hasClass(
'highlighted-edge-min-transaction-amount'
) &&
feature.feature === 'Min Transaction Amount') ||
(edge?.hasClass('highlighted-edge-last-transaction-date') &&
feature.feature === 'Last Transaction Date');
const buttonText = isHighlighted ? 'Hide' : 'View';
return `<og-small-button text="${buttonText}" data-feature="${feature.feature}" data-edge-id="${feature.viewable.edgeId}"></og-small-button>`;
})()
: ''
}
</div>
`;
container.appendChild(row);
}
}
}
ogma.events.on('nodesSelected', event => {
const selectedNodes = event.nodes;
if (selectedNodes.size === 0) {
return;
}
openNodePanel();
const id = selectedNodes.get(0).getId();
const node = ogma.getNode(id);
if (!node) return console.error('Node not found:', id);
const neighbors = node.getAdjacentNodes();
neighbors.addClass('neighbor');
ogma.view.moveTo({
...getAvgPos(neighbors.concat(node.toList())),
zoom: ogma.view.getZoom()
});
updateSelectedNodeUI(id as string, node.getData());
});
const panel = document.getElementById('node-info');
// Utility function to hide the right-side node panel
function closeNodePanel() {
if (panel) {
panel.classList.add('hidden');
}
}
// Utility function to show the node panel
function openNodePanel() {
if (panel) {
panel.classList.remove('hidden');
}
}
// Close panel when no nodes are selected
ogma.events.on('nodesUnselected', e => {
closeNodePanel();
const selectedNodes = e.nodes;
if (selectedNodes.size === 0) {
console.warn('No nodes selected, closing panel');
return;
}
const node = selectedNodes.get(0);
node.getAdjacentNodes().removeClass('neighbor');
});
// Close panel when the 'X' (close) icon is clicked
document.addEventListener('click', (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest('img[alt="Close"]')) {
closeNodePanel();
}
});
// Delegate click handler for dynamically added "View" buttons
document.addEventListener('click', (e: MouseEvent) => {
const target = e.target as HTMLElement;
const viewBtn = target.closest('og-small-button');
if (viewBtn && viewBtn.hasAttribute('data-edge-id')) {
const edgeId = viewBtn.getAttribute('data-edge-id');
const featureName = viewBtn.getAttribute('data-feature');
if (!edgeId) return;
const edge = ogma.getEdge(edgeId);
if (!edge) {
console.warn('Edge not found for ID:', edgeId);
return;
}
const currentText = viewBtn.getAttribute('text');
const isHighlighted =
edge.hasClass('highlighted-edge-max-transaction-amount') ||
edge.hasClass('highlighted-edge-min-transaction-amount') ||
edge.hasClass('highlighted-edge-last-transaction-date');
if (currentText === 'View' && !isHighlighted) {
// Show and highlight edge
viewBtn.setAttribute('text', 'Hide');
const avgPos = getAvgPos(
edge.getSource().toList().concat(edge.getTarget().toList())
);
ogma.view.moveTo({ ...avgPos, zoom: ogma.view.getZoom() });
if (featureName === 'Last Transaction Date') {
edge.addClass('highlighted-edge-last-transaction-date');
} else if (featureName === 'Max Transaction Amount') {
edge.addClass('highlighted-edge-max-transaction-amount');
} else if (featureName === 'Min Transaction Amount') {
edge.addClass('highlighted-edge-min-transaction-amount');
}
} else {
// Hide and remove highlight
if (featureName === 'Last Transaction Date') {
viewBtn.setAttribute('text', 'View');
edge.removeClass('highlighted-edge-last-transaction-date');
} else if (featureName === 'Max Transaction Amount') {
viewBtn.setAttribute('text', 'View');
edge.removeClass('highlighted-edge-max-transaction-amount');
} else if (featureName === 'Min Transaction Amount') {
viewBtn.setAttribute('text', 'View');
edge.removeClass('highlighted-edge-min-transaction-amount');
}
}
}
});
}
ts
import { EdgeId, NodeId } from '@linkurious/ogma';
// Edge data types for AI classification
export type EdgeData = {
amount: number;
timestamp: string;
};
// Node data types for AI classification
export type NodeData = {
riskAttribute?: RiskAttribute;
age: number;
previousFraud?: number;
category?: number; // Category of the node, e.g., 0 for 'fraud', 1 for 'legit'
};
// Types for risk-related attributes
export enum Effect {
positive = 'positive',
negative = 'negative'
}
export type RiskFeature = {
effect: Effect;
feature: string;
value: string;
viewable?: { nodeId?: NodeId; edgeId?: EdgeId };
};
export type RiskAttribute = {
confidenceScore?: number;
riskFeatures: RiskFeature[];
};
ts
import { NodeList, Point, RawGraph } from '@linkurious/ogma';
export function formatMoney(value: number, precision: number = 2): string {
const absValue = Math.abs(value);
let suffix = '';
let formatted = value;
if (absValue >= 1_000_000_000) {
formatted = value / 1_000_000_000;
suffix = 'B';
} else if (absValue >= 1_000_000) {
formatted = value / 1_000_000;
suffix = 'M';
} else if (absValue >= 1_000) {
formatted = value / 1_000;
suffix = 'K';
}
const isInt = Number.isInteger(formatted);
const numberPart = isInt
? formatted.toString()
: formatted.toFixed(precision).replace(/\.?0+$/, '');
return `$${numberPart}${suffix}`;
}
/**
* Make sure that graph identifiers are unique integers, and correctly indexed.
* @param graph some potentially invalid graph
*/
export const cleanIds = (
graph: RawGraph<string | number>
): RawGraph<number> => {
const idToIndex = new Map<string | number, number>();
const cleanGraph: RawGraph<number> = { nodes: [], edges: [] };
graph.nodes.forEach((node, i) => {
if (idToIndex.has(node.id!)) console.warn(`Duplicated ID ${node.id}@${i}`);
cleanGraph.nodes.push({ ...node, id: i });
idToIndex.set(node.id!, i);
});
graph.edges.forEach(edge => {
const sourceIndex = idToIndex.get(edge.source);
const targetIndex = idToIndex.get(edge.target);
if (sourceIndex === undefined)
return console.warn(`Missing source ${edge.source}`);
if (targetIndex === undefined)
return console.warn(`Missing target ${edge.target}`);
// Convention: source < target
cleanGraph.edges.push({
...edge,
source: Math.min(sourceIndex, targetIndex),
target: Math.max(sourceIndex, targetIndex)
});
});
cleanGraph.edges.sort((a, b) => a.source - b.source || a.target - b.target);
return cleanGraph;
};
export const getAvgPos = (nodeList: NodeList): Point => {
const positions = nodeList.getPosition();
if (positions.length === 0) return { x: 0, y: 0 };
const avgX =
positions.reduce((sum, pos) => sum + pos.x, 0) / positions.length;
const avgY =
positions.reduce((sum, pos) => sum + pos.y, 0) / positions.length;
return { x: avgX, y: avgY };
};