Skip to content
  1. Examples

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