Skip to content
  1. Examples
  2. Layers

Identity card nodes new

ts
import Ogma, { RawGraph, Node } from '@linkurious/ogma';
import { NodeCardsPlugin } from './nodeCardsPlugin';
import { EdgeData, NodeData, isPerson } from './types';
import { addStyles } from './styles';

const ogma = new Ogma<NodeData, EdgeData>({
  container: 'graph-container',
  options: {
    interactions: {
      zoom: {
        maxValue: () => 17
      }
    }
  }
});
addStyles(ogma);
const graph = (await Ogma.parse.jsonFromUrl('files/movies.json')) as RawGraph<
  NodeData,
  EdgeData
>;
graph.nodes = graph.nodes.filter(node => node.data!.type !== 'Genre');
graph.edges = graph.edges.filter(edge => edge.data!.type !== 'HasGenre');
const data = (await fetch('files/actor-info.json').then(res =>
  res.json()
)) as NodeData[];
let i = 0;
graph.nodes.forEach(node => {
  if (node.data!.type !== 'Person') return;
  node.data = {
    ...node.data,
    ...data[i++]
  } as NodeData;
});
await ogma.setGraph(graph as RawGraph<NodeData, EdgeData>, {
  ignoreInvalid: true
});
await ogma.layouts.force({
  gpu: true,
  locate: true,
  edgeStrength: 3
});

const plugin = new NodeCardsPlugin(
  ogma,
  node => node.getData('type') === 'Person'
);
let timeout = 0;
plugin.addEventListener('click', event => {
  const { node } = (event as CustomEvent<{ node: Node }>).detail;
  clearTimeout(timeout);
  const info = document.querySelector<HTMLDivElement>('#node-info')!;
  info.innerHTML = `Clicked on node <strong>${node.getData('name')}</strong>`;
  setTimeout(() => {
    info.innerText = '';
  }, 1000);
});

ogma.view.moveToBounds(
  [
    -647.7666391590881, -270.38910465743294, -505.8818887125908,
    -159.8295588549675
  ],
  { duration: 5000 }
);
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap"
      rel="stylesheet"
    />
    <script src="
    https://cdn.jsdelivr.net/npm/eventemitter3@5.0.1/index.min.js
    "></script>
    <link type="text/css" rel="stylesheet" href="styles.css" />
  </head>

  <body>
    <div id="graph-container"></div>
    <div id="node-info"></div>
    <script src="index.ts"></script>
  </body>
</html>
css
:root {
  --font: 'IBM Plex Sans', sans-serif;
}

#graph-container {
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  position: absolute;
  margin: 0;
  overflow: hidden;
}

.cards-container {
  width: 100%;
  height: 100%;
  pointer-events: none;
  position: absolute;
  top: 0;
  left: 0;
}

ogma-card-node {
  pointer-events: none;
}

#node-info {
  position: absolute;
  top: 1em;
  right: 1em;
  padding: 10px;
  background-color: #fff;
  border: 1px solid #555;
  border-radius: 10px;
  z-index: 1;
  font-family: var(--font);
}
css
.card {
  cursor: pointer;
  pointer-events: all;
  position: absolute;
  border-radius: 10px;
  display: grid;
  grid-template-columns: auto 1fr;
  grid-template-rows: auto;
  align-items: center;
  padding: 12px;
  width: 200px;
  box-sizing: border-box;
  min-height: 68px;
  column-gap: 8px;
  row-gap: 0;
  background-color: #fff;
  transform-origin: 50% 50%;
  font-size: 12px;
  font-family: var(--font);
  box-shadow:
    rgba(50, 50, 93, 0.25) 0px 2px 5px -1px,
    rgba(0, 0, 0, 0.3) 0px 1px 3px -1px;
  transition:
    opacity 0.2s ease-out,
    width 0.2s ease-out,
    height 0.2s ease-out,
    min-height 0.2s ease-out;
}
.card:hover {
  background-color: #efefef;
}
.card.selected .basic-info {
  color: #2282ef;
}
.card.hidden {
  display: none;
}
.card img {
  grid-column: 1;
  grid-row: 1;
  width: 44px;
  height: 44px;
  border-radius: 8px;
  object-fit: cover;
  opacity: 0.7;
  transition: opacity 0.3s ease-out;
  align-self: baseline;
}

.card img.loaded {
  opacity: 1;
}

.card img.placeholder {
  opacity: 0;
}

.loader {
  grid-column: 1;
  grid-row: 1;
  width: 44px;
  height: 44px;
  border-radius: 8px;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: #f0f0f0;
  position: relative;
}

.loader::after {
  content: '';
  width: 20px;
  height: 20px;
  border: 2px solid #ddd;
  border-top: 2px solid #666;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}

.loader.hidden {
  display: none;
}

.info-container {
  grid-column: 2;
  grid-row: 1;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
}

.basic-info .name {
  font-weight: 600;
  font-size: 16px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 120px;
}

.detailed-info {
  display: flex;
  flex-direction: column;
  opacity: 0;
  max-height: 0;
  overflow: hidden;
  transition:
    opacity 0.2s ease-out,
    max-height 0.2s ease-out;
}
.detailed-info .city {
  font-size: 12px;
  color: #666;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 120px;
}

.detailed-info .role {
  font-size: 10px;
  color: white;
  font-weight: 500;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 120px;
  padding: 2px 6px;
  border-radius: 4px;
  display: inline-block;
}

.card.zoom-detailed .detailed-info,
.card.zoom-panel .detailed-info {
  opacity: 1;
  max-height: 50px;
}

.panel-info {
  grid-column: 1 / -1;
  grid-row: 2;
  opacity: 0;
  max-height: 0;
  overflow: hidden;
  transition:
    opacity 0.2s ease-out,
    max-height 0.2s ease-out;
  padding-top: 0;
  margin-top: 0;
}
.card.zoom-panel {
  grid-template-rows: auto auto;
  row-gap: 8px;
}

/* Icon mode - only show person icon */
.card.zoom-icon {
  width: 60px;
  height: 60px;
  min-height: 60px;
  padding: 8px;
  grid-template-columns: 1fr;
  justify-content: center;
  align-items: center;
}

.card.zoom-icon .info-container,
.card.zoom-icon .panel-info {
  display: none;
}

.card.zoom-icon img {
  display: none;
}

.card.zoom-icon .person-icon {
  display: flex;
}

.person-icon {
  grid-column: 1;
  grid-row: 1;
  width: 44px;
  height: 44px;
  border-radius: 8px;
  background-color: #f0f0f0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 24px;
  color: #666;
}

/* Hide person icon when image is present */
.card img + .person-icon {
  display: none;
}

/* Minimal mode - hide basic-info text */
.card.zoom-minimal {
  width: 120px;
  height: 66px;
  min-height: 60px;
}

.card.zoom-minimal .basic-info {
  display: none;
}

.card.zoom-minimal img {
  width: 32px;
  height: 32px;
}

.card.zoom-panel {
  grid-template-rows: auto auto;
  row-gap: 8px;
}

.card.zoom-panel .panel-info {
  opacity: 1;
  max-height: 200px;
  transition:
    opacity 0.2s ease-out,
    max-height 0.2s ease-out,
    padding-top 0.2s ease-out;
}

.wikidata-link-container {
  padding-top: 4px;
}
ts
import type { Node } from '@linkurious/ogma';
import Ogma from '@linkurious/ogma';
import { EdgeData, NodeData, Person } from './types';
import { imageLoader } from './ImageLoader';
import styles from './card-node.css?raw';

type ZoomLevel = 'hidden' | 'icon' | 'minimal' | 'small' | 'detailed' | 'panel';

export class OgmaCardNode extends HTMLElement {
  private node!: Node<NodeData, EdgeData>;
  private ogma!: Ogma<NodeData, EdgeData>;

  private cardDiv: HTMLDivElement;
  private onClickCallback?: (node: Node) => void;
  private dragging = false;
  private dragStart = { x: 0, y: 0 };
  private currentZoomLevel: ZoomLevel = 'small';
  private imageLoader = imageLoader;
  private imgUrl: string = '';
  private shouldRefreshImage = false;

  private getRoleColor(role: string): string {
    // Define role categories and their colors
    const roleCategories = {
      '#2B65BA': [
        'film director',
        'television director',
        'theatrical director',
        'director',
        'music director',
        'video game director',
        'music video director',
        'showrunner'
      ],
      '#E46802': [
        'television actor',
        'film actor',
        'stage actor',
        'voice actor',
        'actor',
        'character actor',
        'child actor',
        'Actor'
      ]
    };

    const normalizedRole = role.toLowerCase();

    // Check each color category
    for (const [color, roles] of Object.entries(roleCategories)) {
      if (roles.some(r => normalizedRole.includes(r.toLowerCase()))) {
        return color;
      }
    }

    // Default color for other roles
    return '#666';
  }
  static get observedAttributes() {
    return [];
  }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot!.innerHTML = `
      <style>
      ${styles}
      </style>
      <div class="card"></div>
    `;
    this.cardDiv = this.shadowRoot!.querySelector('.card')!;
    this.setupEventListeners();
  }

  public setNode(node: Node) {
    this.node = node;
    this.renderContent();
  }

  public setOgma(ogma: Ogma<NodeData, EdgeData>) {
    this.ogma = ogma;
  }

  public setOnClick(callback: (node: Node<NodeData, EdgeData>) => void) {
    this.onClickCallback = callback;
  }

  public setZoomLevel(zoomLevel: ZoomLevel) {
    if (this.currentZoomLevel === zoomLevel) return;

    this.currentZoomLevel = zoomLevel;

    // Remove old zoom level classes
    this.cardDiv.classList.remove(
      'zoom-hidden',
      'zoom-icon',
      'zoom-minimal',
      'zoom-small',
      'zoom-detailed',
      'zoom-panel'
    );

    // Add new zoom level class
    if (zoomLevel !== 'small') {
      // small is the default, no class needed
      this.cardDiv.classList.add(`zoom-${zoomLevel}`);
    }
    this.loadImageIfNeeded();
  }

  private loadImageIfNeeded(): void {
    // Only load images when they will be visible (not in icon mode)
    if (
      !this.shouldRefreshImage ||
      this.currentZoomLevel === 'icon' ||
      this.currentZoomLevel === 'hidden'
    ) {
      return;
    }

    // Show loader and hide placeholder
    const loader = this.cardDiv.querySelector('.loader')!;
    const placeholder = this.cardDiv.querySelector<HTMLIFrameElement>('img')!;
    const personIcon =
      this.cardDiv.querySelector<HTMLDivElement>('.person-icon')!;

    loader.classList.remove('hidden');
    placeholder.style.display = 'none';

    this.imageLoader
      .loadImage(this.imgUrl)
      .then(img => {
        const currentImgElement = this.cardDiv.querySelector('img')!;
        this.cardDiv.replaceChild(img, currentImgElement);
        // Hide person icon when actual image is loaded
        personIcon.style.display = 'none';
      })
      .catch(() => {
        // Keep placeholder on error and show it again
        console.warn(`Failed to load image for node: ${this.imgUrl}`);
        placeholder.style.display = 'block';
        // Show person icon when image fails to load
        personIcon.style.display = 'flex';
      })
      .finally(() => {
        // Hide loader when loading is complete (success or failure)
        loader.classList.add('hidden');
        this.shouldRefreshImage = false;
      });
  }

  private setupEventListeners(): void {
    this.cardDiv.addEventListener('click', this.handleClick);
    this.cardDiv.addEventListener('mousedown', this.handleMouseDown);
    this.cardDiv.addEventListener('mouseup', this.handleMouseUp);
  }

  private handleClick = (evt: MouseEvent): void => {
    if (!this.node || !this.ogma) return;

    if (this.onClickCallback) {
      this.onClickCallback(this.node);
    }

    this.ogma.mouse.click({
      ...this.ogma.view.graphToScreenCoordinates(this.node.getPosition())
    });
  };

  private handleMouseDown = (evt: MouseEvent): void => {
    if (!this.node || !this.ogma) return;

    evt.stopPropagation();
    evt.preventDefault();

    this.ogma.setOptions({
      interactions: {
        drag: { enabled: false }
      }
    });

    this.dragging = true;
    const nodePos = this.node.getPosition();
    const pos = this.ogma.view.screenToGraphCoordinates({
      x: evt.clientX,
      y: evt.clientY
    });

    this.dragStart = {
      x: pos.x - nodePos.x,
      y: pos.y - nodePos.y
    };
    document.addEventListener('mousemove', this.handleMouseMove.bind(this));
  };

  private handleMouseMove = (evt: MouseEvent): void => {
    if (!this.dragging || !this.node || !this.ogma) return;

    const pos = this.ogma.view.screenToGraphCoordinates({
      x: evt.clientX,
      y: evt.clientY
    });

    this.node.setAttributes({
      x: pos.x - this.dragStart.x,
      y: pos.y - this.dragStart.y
    });
  };

  private handleMouseUp = (evt: MouseEvent): void => {
    if (!this.ogma) return;

    evt.stopPropagation();
    evt.preventDefault();

    this.ogma.setOptions({
      interactions: {
        drag: { enabled: true }
      }
    });

    this.dragging = false;
    document.removeEventListener('mousemove', this.handleMouseMove.bind(this));
  };

  private renderContent(): void {
    if (!this.node) return;

    const nodeId = +this.node.getId();
    const data = this.node.getData() as Person;
    const name = data.name || `Node ${nodeId}`;
    const birthPlace = data.birthPlace || 'Unknown Birth Place';
    const rawDescription = data.description || 'No description available';
    const occupation = data.occupation || 'Unknown Occupation';
    const roleColor = this.getRoleColor(occupation);
    const imgUrl = data.imageUrl || '';
    if (imgUrl !== this.imgUrl) {
      this.imgUrl = imgUrl;
      this.shouldRefreshImage = imgUrl.length > 0;
    }
    // Extract first sentence and trim to 75 characters
    const firstSentence = rawDescription.split(/[.!?]/)[0];
    const description =
      firstSentence.length > 75
        ? firstSentence.substring(0, 75).trim() + '...'
        : firstSentence.trim();

    this.cardDiv.innerHTML = `
      <img alt="${name}" class="placeholder">
      <div class="loader hidden"></div>
      <div class="person-icon">👤</div>
      <div class="info-container">
        <div class="detailed-info">
          <span class="role"
                data-role="${occupation}"
                style="background-color: ${roleColor}">
                ${capitalizeFirstLetter(occupation)}
          </span>
        </div>
        <div class="basic-info">
          <span class="name">${capitalizeFirstLetter(name)}</span>
        </div>
        <div class="detailed-info">
          <span class="city">${birthPlace}</span>
        </div>
      </div>
      <div class="panel-info">
        ${description}
        <div class="wikidata-link-container">
          <a href="${data.wikidataUrl}" target="_blank" rel="noopener noreferrer">
            <span class="wikidata-link">View on Wikidata &rarr;</span>
          </a>
        </div>
      </div>
    `;
  }

  public updateContent(): void {
    this.renderContent();
  }

  public setSelected(selected: boolean): void {
    this.cardDiv.classList[selected ? 'add' : 'remove']('selected');
  }

  public hide(): void {
    this.node && this.node.removeClass('card-node-hidden');
    this.cardDiv.classList.add('hidden');
  }

  public show(): void {
    this.node && this.node.addClass('card-node-hidden');
    this.cardDiv.classList.remove('hidden');
  }

  public updatePosition(zoom: number, maxScale: number): void {
    if (!this.node || !this.ogma) return;

    const pos = this.ogma.view.graphToScreenCoordinates(
      this.node.getPosition()
    );
    const cappedZoom = Math.min(zoom, maxScale);
    this.cardDiv.style.transform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%) scale(${cappedZoom})`;
  }
}

// first letter uppercase
function capitalizeFirstLetter(string: string): string {
  return string.charAt(0).toUpperCase() + string.slice(1);
}
customElements.define('ogma-card-node', OgmaCardNode);
ts
// Collision detection worker

// Handle messages from main thread
self.onmessage = function (e) {
  const { boxes, zoomLevel } = e.data;

  // Find collisions between boxes considering the zoom level
  const collisions = findCollisions(boxes, zoomLevel);

  // Send collisions back to main thread
  self.postMessage({ collisions });
};

// Function to find collisions between boxes considering zoom level
function findCollisions(boxes: Float32Array, zoomLevel: number) {
  const numBoxes = boxes.length / 4;
  const collisions = new Set<number>();
  // Check each pair of boxes for collision
  for (let i = 0; i < numBoxes; i++) {
    const iOffset = i * 4;
    // Get original coordinates
    const x0 = boxes[iOffset];
    const y0 = boxes[iOffset + 1];
    // Keep box size constant regardless of zoom
    const w0 = boxes[iOffset + 2] / zoomLevel;
    const h0 = boxes[iOffset + 3] / zoomLevel;

    for (let j = i + 1; j < numBoxes; j++) {
      const jOffset = j * 4;
      // Get original coordinates
      const x1 = boxes[jOffset];
      const y1 = boxes[jOffset + 1];
      // Keep box size constant regardless of zoom
      const w1 = boxes[jOffset + 2] / zoomLevel;
      const h1 = boxes[jOffset + 3] / zoomLevel;

      if (
        collide(
          x0 - w0 / 2,
          y0 - h0 / 2,
          w0,
          h0,
          x1 - w1 / 2,
          y1 - h1 / 2,
          w1,
          h1
        )
      ) {
        if (i < j) collisions.add(i);
        else collisions.add(j);
      }
    }
  }

  // Convert Set to Array for transferring
  return Array.from(collisions);
}

function collide(
  x0: number,
  y0: number,
  w0: number,
  h0: number,
  x1: number,
  y1: number,
  w1: number,
  h1: number
) {
  return boxesOverlap(x0, y0, x0 + w0, y0 + h0, x1, y1, x1 + w1, y1 + h1);
}

function boxesOverlap(
  ax0: number,
  ay0: number,
  ax1: number,
  ay1: number,
  bx0: number,
  by0: number,
  bx1: number,
  by1: number
) {
  return ay1 > by0 && ay0 < by1 && ax1 > bx0 && ax0 < bx1;
}
ts
import workerUrl from './collision-worker.ts?worker';
import { NodeId } from '@linkurious/ogma';
export interface Rect {
  x: number;
  y: number;
  width: number;
  height: number;
}

export interface CardRect extends Rect {
  id: string;
}

export class CollisionDetector extends EventTarget {
  private worker!: Worker;
  private indexToId: Map<number, NodeId> = new Map();
  private rectangles: Map<NodeId, Rect> = new Map();
  private collisionTimer?: number;

  constructor() {
    super();
    try {
      this.worker = new Worker(workerUrl, { type: 'module' });
      this.worker.onmessage = e => {
        const collidingIds = e.data.collisions.map(
          (id: number) => this.indexToId.get(id) || ''
        );
        this.dispatchEvent(
          new CustomEvent('collisionUpdate', {
            detail: {
              collidingIds
            }
          })
        );
      };
      this.worker.onerror = e => {
        console.error('Worker error:', e);
      };
    } catch (err) {
      console.error('Failed to instantiate collision worker:', err);
    }
  }

  public add(id: NodeId, rect: Rect): void {
    this.rectangles.set(id, rect);
    this.scheduleCollisionCheck();
  }

  public remove(id: NodeId): boolean {
    const removed = this.rectangles.delete(id);
    if (removed) {
      this.scheduleCollisionCheck();
    }
    return removed;
  }

  public update(id: NodeId, rect: Rect): void {
    if (this.rectangles.has(id)) {
      this.rectangles.set(id, { ...rect });
      this.scheduleCollisionCheck();
    }
  }

  public clear(): void {
    this.rectangles.clear();
    this.scheduleCollisionCheck();
  }

  private scheduleCollisionCheck(): void {
    if (this.collisionTimer) {
      cancelAnimationFrame(this.collisionTimer);
    }

    this.collisionTimer = requestAnimationFrame(() => {
      this.checkCollisions();
    });
  }

  private checkCollisions(): void {
    if (!this.rectangles.size) {
      this.indexToId.clear();
      this.dispatchEvent(
        new CustomEvent('collisionUpdate', {
          detail: { collidingIds: [] }
        })
      );
      return;
    }
    const rectangleArray = Array.from(this.rectangles.entries());
    const boxesArray = new Float32Array(rectangleArray.length * 4);
    rectangleArray.forEach(([id, rect], i) => {
      this.indexToId.set(i, id);
      const base = i * 4;
      boxesArray[base] = rect.x;
      boxesArray[base + 1] = rect.y;
      boxesArray[base + 2] = rect.width;
      boxesArray[base + 3] = rect.height;
    });

    this.worker.postMessage(
      {
        boxes: boxesArray,
        zoomLevel: 1.0
      },
      // Transfer the array buffer to the worker
      [boxesArray.buffer]
    );
  }

  public destroy(): void {
    if (this.collisionTimer) cancelAnimationFrame(this.collisionTimer);
    this.worker.terminate();
  }
}
ts
export interface ImageCacheEntry {
  loaded: boolean;
  element: HTMLImageElement;
  promise: Promise<HTMLImageElement>;
}

interface QueueItem {
  url: string;
  element: HTMLImageElement;
  resolve: (img: HTMLImageElement) => void;
  reject: (error: Error) => void;
}

const QUEUE_PROCESSING_DELAY = 100; // ms

class ImageLoader {
  private cache = new Map<string, ImageCacheEntry>();
  private queue: QueueItem[] = [];
  private isProcessing = false;
  private maxConcurrent = 1;
  private currentlyLoading = 0;

  public async loadImage(url: string): Promise<HTMLImageElement> {
    const cached = this.cache.get(url);
    if (cached) {
      return cached.promise;
    }
    const element = document.createElement('img');
    const promise = new Promise<HTMLImageElement>((resolve, reject) => {
      this.queue.push({ url, element, resolve, reject });
      this.processQueue();
    });
    this.cache.set(url, { loaded: false, promise, element });
    return promise;
  }

  private async processQueue(): Promise<void> {
    if (this.isProcessing || this.currentlyLoading >= this.maxConcurrent)
      return;

    const item = this.queue.shift();
    if (!item) return;

    this.isProcessing = true;
    this.currentlyLoading++;

    try {
      await this._loadImage(item.element, item.url);
      this.cache.get(item.url)!.loaded = true;
      item.resolve(item.element);
    } catch (error) {
      item.reject(error as Error);
    } finally {
      this.currentlyLoading--;

      // Process next item in queue
      if (this.queue.length > 0) {
        // Add small delay to avoid overwhelming the server
        setTimeout(() => {
          this.isProcessing = false;
          this.processQueue();
        }, QUEUE_PROCESSING_DELAY);
      } else {
        this.isProcessing = false;
      }
    }
  }

  private _loadImage(
    element: HTMLImageElement,
    src: string
  ): Promise<HTMLImageElement> {
    return new Promise((resolve, reject) => {
      element.onload = () => resolve(element);
      element.onerror = () =>
        reject(new Error(`Failed to load image: ${element.src}`));

      element.src = src;
    });
  }
}

export const imageLoader = new ImageLoader();
ts
import Ogma, { Node, NodeId, NodeList, StyleClass } from '@linkurious/ogma';
import { OgmaCardNode } from './card-node';
import { CollisionDetector, Rect } from './collision';
import { EdgeData, NodeData } from './types';

if (!customElements.get('ogma-card-node')) {
  customElements.define('ogma-card-node', OgmaCardNode);
}

type NodeSelector<ND> = (node: Node<ND>) => boolean;
type ZoomThresholds = {
  minZoom: number;
  iconToMinimal: number;
  minimalToSmall: number;
  smallToDetailed: number;
  detailedToPanel: number;
};
type ZoomLevel = 'hidden' | 'icon' | 'minimal' | 'small' | 'detailed' | 'panel';
export class NodeCardsPlugin extends EventTarget {
  private container: HTMLDivElement;
  private selector: NodeSelector<NodeData>;
  private cardPool: OgmaCardNode[];
  private freeCards: Set<OgmaCardNode> = new Set();
  private cardToNode: Map<OgmaCardNode, NodeId> = new Map();
  private nodeToCard: Map<NodeId, OgmaCardNode> = new Map();
  private selectedNodes: Set<NodeId> = new Set();
  private collisionDetector = new CollisionDetector();
  private styleClass: StyleClass;
  private ruleEnabled = true;
  private ogma: Ogma<NodeData, EdgeData>;
  private ZOOM_RATIO = 5;
  private MAX_CARDS = 20;
  private MAX_CARD_SCALE = 2;
  private zoomThresholds: ZoomThresholds;

  constructor(
    ogma: Ogma,
    selector: NodeSelector<NodeData> = () => true,
    zoomThresholds: ZoomThresholds = {
      minZoom: 0.1,
      iconToMinimal: 0.3,
      minimalToSmall: 0.6,
      smallToDetailed: 1.0,
      detailedToPanel: 1.5
    }
  ) {
    super();
    this.selector = selector;
    this.zoomThresholds = zoomThresholds;
    this.cardPool = [];
    this.container = document.createElement('div');
    this.container.classList.add('cards-container');
    this.ogma = ogma;

    this.createCardPool();
    this.setupCollisionHandling();
    this.styleClass = ogma.styles.createClass({
      name: 'card-node-hidden',
      nodeAttributes: {
        opacity: 0,
        text: null
      }
    });
    ogma.layers.addLayer(this.container);
    ogma.events
      .on('frame', this.onNewFrame)
      .on(['nodesSelected', 'nodesUnselected'], () => {
        this.selectedNodes.clear();
        ogma.getSelectedNodes().forEach(node => {
          if (this.selector(node)) {
            this.selectedNodes.add(node.getId());
          }
        });
      });
  }

  private createCardPool(): void {
    for (let i = 0; i < this.MAX_CARDS; i++) {
      const card = document.createElement('ogma-card-node') as OgmaCardNode;
      card.setOgma(this.ogma);
      card.setOnClick(node => {
        this.dispatchEvent(
          new CustomEvent('click', {
            detail: { node }
          })
        );
      });
      card.hide();
      this.cardPool.push(card);
      this.freeCards.add(card);
      this.container.appendChild(card);
    }
  }

  private setupCollisionHandling(): void {
    this.collisionDetector.addEventListener('collisionUpdate', event => {
      // @ts-expect-error event detail is not typed
      const { collidingIds } = event.detail;
      const collidingSet = new Set(collidingIds);

      // Show all non-colliding cards, hide colliding ones
      for (const [nodeId, card] of this.nodeToCard) {
        if (collidingSet.has(nodeId)) {
          card.hide();
        } else {
          card.show();
        }
      }
    });
  }

  private getZoomLevel(zoom: number): ZoomLevel {
    if (zoom < this.zoomThresholds.minZoom) return 'hidden';
    if (zoom < this.zoomThresholds.iconToMinimal) return 'icon';
    if (zoom < this.zoomThresholds.minimalToSmall) return 'minimal';
    if (zoom < this.zoomThresholds.smallToDetailed) return 'small';
    if (zoom < this.zoomThresholds.detailedToPanel) return 'detailed';
    return 'panel';
  }

  private getCardRect(node: Node, zoom: number, zoomLevel: ZoomLevel): Rect {
    const pos = this.ogma.view.graphToScreenCoordinates(node.getPosition());

    // Base card dimensions (approximate)
    let width = 180;
    let height = 50;

    // Adjust dimensions based on zoom level
    switch (zoomLevel) {
      case 'icon':
        width = 60;
        height = 60;
        break;
      case 'panel':
        width = 176;
        height = 100;
        break;
    }

    // Apply zoom scaling
    const cappedZoom = Math.min(zoom, this.MAX_CARD_SCALE);
    width *= cappedZoom;
    height *= cappedZoom;

    // Center the rectangle around the node position
    return {
      x: pos.x - width / 2,
      y: pos.y - height / 2,
      width,
      height
    };
  }

  onNewFrame = () => {
    const {
      ogma,
      MAX_CARDS,
      ZOOM_RATIO,
      selectedNodes,
      cardToNode,
      nodeToCard
    } = this;
    const zoom = ogma.view.getZoom() / ZOOM_RATIO;
    const zoomLevel = this.getZoomLevel(zoom);

    if (zoomLevel === 'hidden') {
      this.cardPool.forEach(card => card.hide());
      cardToNode.clear();
      nodeToCard.clear();
      this.freeCards.clear();
      this.cardPool.forEach(card => this.freeCards.add(card));
      return;
    }
    // Get the most central nodes efficiently
    const visibleNodes = this.getMostCentralNodes(MAX_CARDS);

    // Create sets for efficient lookups
    const newVisibleNodeIds = new Set(visibleNodes.map(n => n.getId()));

    // Find cards that need to be hidden (showing nodes no longer visible)
    const cardsToHide: OgmaCardNode[] = [];
    for (const [card, nodeId] of cardToNode) {
      if (newVisibleNodeIds.has(nodeId)) continue;
      cardsToHide.push(card);
    }

    // Remove cards from collision detector and free them up
    cardsToHide.forEach(card => {
      const nodeId = cardToNode.get(card)!;
      card.hide();
      this.collisionDetector.remove(nodeId);
      cardToNode.delete(card);
      nodeToCard.delete(nodeId);
      this.freeCards.add(card);
    });

    // Place cards with collision detection
    this.placeCardsWithCollisionDetection(
      visibleNodes,
      zoomLevel,
      zoom,
      selectedNodes
    );
  };

  private getMostCentralNodes(maxNodes: number): NodeList {
    const { ogma, selector } = this;

    // Get viewport bounds
    const bounds = ogma.view.getBounds();
    let { minX, minY, maxX, maxY } = bounds;

    // Start with all nodes in view
    let nodes = ogma.view.getElementsInView().nodes.filter(selector);

    // If we have too many nodes, narrow down to the center
    while (nodes.size > maxNodes) {
      // Calculate center and reduce bounds by half
      const centerX = (minX + maxX) / 2;
      const centerY = (minY + maxY) / 2;
      const halfWidth = (maxX - minX) / 4;
      const halfHeight = (maxY - minY) / 4;

      minX = centerX - halfWidth;
      maxX = centerX + halfWidth;
      minY = centerY - halfHeight;
      maxY = centerY + halfHeight;

      // Get nodes in the smaller area
      nodes = ogma.view
        .getElementsInside(minX, minY, maxX, maxY, true)
        .nodes.filter(selector);

      // Safety break to avoid infinite loop
      if (halfWidth < 1 || halfHeight < 1) break;
    }

    return nodes;
  }

  private updateCard(
    card: OgmaCardNode,
    zoomLevel: ZoomLevel,
    zoom: number,
    isSelected: boolean
  ): void {
    card.setZoomLevel(zoomLevel);
    card.updatePosition(zoom, this.MAX_CARD_SCALE);
    card.setSelected(isSelected);
  }

  private placeCardsWithCollisionDetection(
    visibleNodes: NodeList,
    zoomLevel: ZoomLevel,
    zoom: number,
    selectedNodes: Set<NodeId>
  ): void {
    const { cardToNode, nodeToCard, freeCards } = this;
    const freeCardsIterator = freeCards.values();

    visibleNodes.forEach(node => {
      const nodeId = node.getId();
      const cardRect = this.getCardRect(node, zoom, zoomLevel);
      const isSelected = selectedNodes.has(nodeId);

      if (nodeToCard.has(nodeId)) {
        // Update existing card - don't change visibility, just update properties
        const existingCard = nodeToCard.get(nodeId)!;
        this.updateCard(existingCard, zoomLevel, zoom, isSelected);
        this.collisionDetector.update(nodeId, cardRect);
        return;
      }

      // Get a free card and prepare it (start hidden for new cards)
      const freeCard = freeCardsIterator.next().value;
      if (!freeCard) return;

      freeCards.delete(freeCard);
      freeCard.setNode(node);
      freeCard.hide(); // New cards start hidden until collision check passes
      this.updateCard(freeCard, zoomLevel, zoom, isSelected);

      this.collisionDetector.add(nodeId, cardRect);
      cardToNode.set(freeCard, nodeId);
      nodeToCard.set(nodeId, freeCard);
    });
  }

  public destroy(): void {
    this.collisionDetector.destroy();
    this.styleClass.destroy();
    this.container.remove();
  }
}
ts
import Ogma from '@linkurious/ogma';
import { isGenre, isMovie, isPerson, NodeData } from './types';

const placeholder = document.createElement('span');
document.body.appendChild(placeholder);
placeholder.style.visibility = 'hidden';

// helper routine to get the icon HEX code
export function getIconCode(className: string) {
  placeholder.className = className;
  const code = getComputedStyle(placeholder, ':before').content;
  return code[1];
}

const font = 'IBM Plex Sans';
const HIGHLIGHTED_STYLE = {
  outerStroke: {
    color: '#de425b'
  },
  text: {
    backgroundColor: '#2d2e41',
    color: '#fff',
    margin: 6
  }
};

export function addStyles(ogma: Ogma<NodeData>) {
  ogma.styles.addNodeRule(isMovie, {
    color: '#FCBC05',
    text: {
      content: node => node.getData('title'),
      font,
      size: 12,
      minVisibleSize: 5
    },
    radius: n => n.getDegree() + 1,
    icon: {
      content: getIconCode('icon-film'),
      font: 'Lucide',
      scale: 0.5,
      color: '#372b09'
    }
  });

  ogma.styles.addNodeRule(isPerson, {
    color: '#4386F3',
    text: {
      content: node => node.getData('name'),
      font,
      size: 11,
      minVisibleSize: 10
    },
    icon: {
      content: getIconCode('icon-circle-user-round'),
      font: 'Lucide',
      scale: 0.5,
      color: '#fff'
    }
  });

  ogma.styles.addNodeRule(isGenre, {
    color: '#7843F3',
    radius: 20,
    text: {
      content: node => node.getData('name'),
      font,
      size: 11,
      minVisibleSize: 10
    },
    icon: {
      content: getIconCode('icon-tag'),
      font: 'Lucide',
      scale: 0.5,
      color: '#fff'
    }
  });
  // ogma.styles.addEdgeRule({
  //   color: 'rgba(100, 100, 100, 0.2)',
  //   width: 0.1
  // });

  ogma.styles.setSelectedNodeAttributes(HIGHLIGHTED_STYLE);
  ogma.styles.setHoveredNodeAttributes(HIGHLIGHTED_STYLE);
}
ts
import { Node } from '@linkurious/ogma';
export type Person = {
  type: 'Person';
  name: string;
  wikidataId: string;
  birthDate: string;
  deathDate: string;
  birthPlace: string;
  nationality: string;
  occupation: string;
  description: string;
  imageUrl: string;
  website: string;
  wikidataUrl: string;
};
export type Movie = {
  description: string;
  id: string;
  runtime: number;
  title: string;
  type: 'Movie';
  votes: number;
  year: number;
};
export type Genre = {
  id: string;
  name: string;
  type: 'Genre';
};

export type EdgeData = {
  type: 'ActedIn' | 'Directed' | 'HasGenre';
};

export type NodeData = Person | Movie | Genre;

export const isMovie = (node: Node): node is Node<Movie> => {
  return node.getData('type') === 'Movie';
};

export const isPerson = (node: Node): node is Node<Person> => {
  return node.getData('type') === 'Person';
};

export const isGenre = (node: Node): node is Node<Genre> => {
  return node.getData('type') === 'Genre';
};