Appearance
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 →</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';
};