Appearance
Identity card nodes new
ts
import Ogma from '@linkurious/ogma';
import { NodeCardsPlugin } from './nodeCardsPlugin';
const ogma = new Ogma({
container: 'graph-container'
});
const graph = await Ogma.parse.jsonFromUrl('files/eurosys.json');
graph.nodes.forEach((node, i) => {
node.data = {
label: `Celebrity ${i}`
};
});
await ogma.setGraph(graph);
await ogma.layouts.force({ gpu: true, locate: true });
const plugin = new NodeCardsPlugin(
ogma,
node => true,
node => {
return `<img src="files/faces/${+node.getId() % 150}.jpg"><span>${node.getData('label')}</span>`;
}
);
let timeout = 0;
plugin.on('click', ({ node }) => {
clearTimeout(timeout);
const info = document.querySelector<HTMLDivElement>('#node-info')!;
info.innerText = `Clicked on node ${node.getData('label')}`;
setTimeout(() => {
info.innerText = '';
}, 1000);
});
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
#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;
}
.card {
cursor: pointer;
pointer-events: initial;
position: absolute;
border: 1px solid #555;
border-radius: 10px;
display: flex;
justify-content: center;
align-items: center;
padding: 5px;
gap: 5px;
background-color: #fff;
width: fit-content;
transform-origin: 50%;
}
.card:hover {
border: 1px solid red;
}
.card.selected {
border: 2px solid green;
}
.card > span {
font-size: 1em;
font-family: 'IBM Plex Sans', sans-serif;
}
.card > img {
max-width: 56px;
height: auto;
}
.card.hidden {
display: none;
}
#node-info {
position: absolute;
top: 1em;
right: 1em;
padding: 10px;
background-color: #fff;
border: 1px solid #555;
border-radius: 10px;
z-index: 1;
}
ts
import Ogma, { Node, NodeId, NodeList, StyleRule } from '@linkurious/ogma';
import EventEmitter from 'eventemitter3';
type CardGenerator = (node: Node) => string;
type NodeSelector = (node: Node) => boolean;
type CardEvents = {
click: { node: Node };
};
export class NodeCardsPlugin extends EventEmitter<CardEvents> {
private idToCard: Map<NodeId, HTMLDivElement>;
private container: HTMLDivElement;
private generator: CardGenerator;
private selector: NodeSelector;
private selected: HTMLDivElement[];
private dragging: Node | null = null;
private dragStart = { x: 0, y: 0 };
private styleRule: StyleRule;
private ruleEnabled = true;
private ogma: Ogma;
private ZOOM_RATIO = 5;
private MAX_CARDS = 100;
private MIN_ZOOM = 0.2;
private cache: {
xs: number[];
ys: number[];
index: Map<NodeId, number>;
} = {
xs: [],
ys: [],
index: new Map()
};
constructor(ogma: Ogma, selector: NodeSelector, generator: CardGenerator) {
super();
this.idToCard = new Map();
this.selected = [];
this.selector = selector;
this.generator = generator;
this.container = document.createElement('div');
this.container.classList.add('cards-container');
this.ogma = ogma;
this.styleRule = ogma.styles.addRule({
nodeSelector: node => this.ruleEnabled && this.idToCard.has(node.getId()),
nodeAttributes: {
radius: 0.01
}
});
this.addNodes(ogma.getNodes().filter(this.selector));
ogma.layers.addLayer(this.container);
ogma.events
.on('frame', () => {
this.onNewFrame();
})
.on('addNodes', evt => {
const filtered = evt.nodes.filter(this.selector);
this.addNodes(filtered);
})
.on('removeNodes', evt => {
this.removeNodes(evt.nodes);
})
.on('updateNodeData', evt => {
this.regenerateCards(evt.nodes);
})
.on('mousemove', evt => {
if (!this.dragging) return;
const pos = ogma.view.screenToGraphCoordinates(evt);
this.dragging.setAttributes({
x: pos.x + this.dragStart.x,
y: pos.y + this.dragStart.y
});
})
.on(['nodesSelected', 'nodesUnselected'], () => {
this.selected.forEach(div => {
div.classList.remove('selected');
});
this.selected.length = 0;
ogma.getSelectedNodes().forEach(node => {
const id = node.getId();
if (!this.idToCard.has(id)) return;
const div = this.idToCard.get(id)!;
this.selected.push(div);
div.classList.add('selected');
});
});
}
onNewFrame() {
[...this.idToCard.values()].forEach(div => {
div.classList.add('hidden');
});
const { selector, ogma, idToCard, MAX_CARDS, MIN_ZOOM, ZOOM_RATIO, cache } =
this;
const zoom = ogma.view.getZoom() / ZOOM_RATIO;
if (zoom < MIN_ZOOM) {
if (this.ruleEnabled) {
this.ruleEnabled = false;
this.styleRule.refresh();
}
return;
}
if (!this.ruleEnabled) {
this.ruleEnabled = true;
this.styleRule.refresh();
}
// TODO: there might be a faster way to
// get the most centered elements via ogma.view.get
const center = ogma.view.getCenter();
const { xs, ys, index } = cache;
const nodes = ogma.view
.getElementsInView()
.nodes.filter(selector)
.sort((a, b) => {
const ia = index.get(a.getId())!;
const ib = index.get(b.getId())!;
const xa = xs[ia];
const ya = ys[ia];
const xb = xs[ib];
const yb = ys[ib];
const da = (xa - center.x) ** 2 + (ya - center.y) ** 2;
const db = (xb - center.x) ** 2 + (yb - center.y) ** 2;
return da - db;
})
.slice(0, MAX_CARDS);
nodes.forEach(node => {
const div = idToCard.get(node.getId());
if (!div) return;
const pos = ogma.view.graphToScreenCoordinates(node.getPosition());
div.style.transform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%) scale(${zoom}, ${zoom})`;
div.classList.remove('hidden');
});
}
regenerateCards(nodes: NodeList) {
const { selector } = this;
const toGenerate = nodes.filter(selector);
const toDelete = nodes.filter(node => !selector(node));
this.removeNodes(toDelete);
this.addNodes(toGenerate);
}
addNodes(nodes: NodeList) {
const { idToCard, generator, cache, container } = this;
const xs = nodes.getAttribute('x');
const ys = nodes.getAttribute('y');
const ids = nodes.getId();
for (let i = 0; i < ids.length; i++) {
const id = ids[i];
if (!cache.index.has(id)) {
cache.index.set(id, cache.xs.length);
cache.xs.push(xs[i]);
cache.ys.push(ys[i]);
} else {
let j = cache.index.get(id)!;
cache.xs[j] = xs[i];
cache.ys[j] = ys[i];
}
const node = nodes.get(i);
if (idToCard.has(id)) {
const div = idToCard.get(id)!;
div.innerHTML = generator(node);
return;
} else {
const div = document.createElement('div');
div.classList.add('card');
div.innerHTML = generator(node);
div.addEventListener('click', () => {
super.emit('click', { node });
ogma.mouse.click({
...ogma.view.graphToScreenCoordinates(node.getPosition())
});
});
div.addEventListener('mousedown', evt => {
evt.stopPropagation();
evt.preventDefault();
ogma.setOptions({
interactions: {
drag: { enabled: false }
}
});
this.dragging = node;
const nodePos = node.getPosition();
const pos = ogma.view.screenToGraphCoordinates({
x: evt.clientX,
y: evt.clientY
});
this.dragStart = {
x: pos.x - nodePos.x,
y: pos.y - nodePos.y
};
});
div.addEventListener('mouseup', evt => {
evt.stopPropagation();
evt.preventDefault();
ogma.setOptions({
interactions: {
drag: { enabled: true }
}
});
this.dragging = null;
});
idToCard.set(node.getId(), div);
container.appendChild(div);
}
}
}
removeNodes(nodes: NodeList) {
const { idToCard, cache } = this;
const { index, xs, ys } = cache;
nodes.forEach(node => {
if (index.has(node.getId())) {
const i = index.get(node.getId())!;
index.delete(node.getId());
xs.splice(i, 1);
ys.splice(i, 1);
}
const div = idToCard.get(node.getId());
if (!div) return;
div.remove();
idToCard.delete(node.getId());
});
}
}
ts
import Ogma, { Node, NodeId, NodeList, StyleRule } from '@linkurious/ogma';
import EventEmitter from 'eventemitter3';
type CardGenerator = (node: Node) => string;
type NodeSelector = (node: Node) => boolean;
type CardEvents = {
click: { node: Node };
};
export class NodeCardsPlugin extends EventEmitter<CardEvents> {
private idToCard: Map<NodeId, HTMLDivElement>;
private container: HTMLDivElement;
private generator: CardGenerator;
private selector: NodeSelector;
private selected: HTMLDivElement[];
private dragging: Node | null = null;
private dragStart = { x: 0, y: 0 };
private styleRule: StyleRule;
private ruleEnabled = true;
private ogma: Ogma;
private ZOOM_RATIO = 5;
private MAX_CARDS = 100;
private MIN_ZOOM = 0.2;
private cache: {
xs: number[];
ys: number[];
index: Map<NodeId, number>;
} = {
xs: [],
ys: [],
index: new Map()
};
constructor(ogma: Ogma, selector: NodeSelector, generator: CardGenerator) {
super();
this.idToCard = new Map();
this.selected = [];
this.selector = selector;
this.generator = generator;
this.container = document.createElement('div');
this.container.classList.add('cards-container');
this.ogma = ogma;
this.styleRule = ogma.styles.addRule({
nodeSelector: node => this.ruleEnabled && this.idToCard.has(node.getId()),
nodeAttributes: {
radius: 0.01
}
});
this.addNodes(ogma.getNodes().filter(this.selector));
ogma.layers.addLayer(this.container);
ogma.events
.on('frame', this.onNewFrame)
.on('addNodes', evt => {
const filtered = evt.nodes.filter(this.selector);
this.addNodes(filtered);
})
.on('removeNodes', evt => {
this.removeNodes(evt.nodes);
})
.on('updateNodeData', evt => {
this.regenerateCards(evt.nodes);
})
.on('mousemove', evt => {
if (!this.dragging) return;
const pos = ogma.view.screenToGraphCoordinates(evt);
this.dragging.setAttributes({
x: pos.x - this.dragStart.x,
y: pos.y - this.dragStart.y
});
})
.on(['nodesSelected', 'nodesUnselected'], () => {
this.selected.forEach(div => {
div.classList.remove('selected');
});
this.selected.length = 0;
ogma.getSelectedNodes().forEach(node => {
const id = node.getId();
if (!this.idToCard.has(id)) return;
const div = this.idToCard.get(id)!;
this.selected.push(div);
div.classList.add('selected');
});
});
}
onNewFrame = () => {
[...this.idToCard.values()].forEach(div => {
div.classList.add('hidden');
});
const { selector, ogma, idToCard, MAX_CARDS, MIN_ZOOM, ZOOM_RATIO, cache } =
this;
const zoom = ogma.view.getZoom() / ZOOM_RATIO;
if (zoom < MIN_ZOOM) {
if (this.ruleEnabled) {
this.ruleEnabled = false;
this.styleRule.refresh();
}
return;
}
if (!this.ruleEnabled) {
this.ruleEnabled = true;
this.styleRule.refresh();
}
// TODO: there might be a faster way to
// get the most centered elements via ogma.view.get
const center = ogma.view.getCenter();
const { xs, ys, index } = cache;
const nodes = ogma.view
.getElementsInView()
.nodes.filter(selector)
.sort((a, b) => {
const ia = index.get(a.getId())!;
const ib = index.get(b.getId())!;
const xa = xs[ia];
const ya = ys[ia];
const xb = xs[ib];
const yb = ys[ib];
const da = (xa - center.x) ** 2 + (ya - center.y) ** 2;
const db = (xb - center.x) ** 2 + (yb - center.y) ** 2;
return da - db;
})
.slice(0, MAX_CARDS);
nodes.forEach(node => {
const div = idToCard.get(node.getId());
if (!div) return;
const pos = ogma.view.graphToScreenCoordinates(node.getPosition());
div.style.transform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%) scale(${zoom}, ${zoom})`;
div.classList.remove('hidden');
});
};
regenerateCards(nodes: NodeList) {
const { selector } = this;
const toGenerate = nodes.filter(selector);
const toDelete = nodes.filter(node => !selector(node));
this.removeNodes(toDelete);
this.addNodes(toGenerate);
}
addNodes(nodes: NodeList) {
const { idToCard, generator, cache, container } = this;
const xs = nodes.getAttribute('x');
const ys = nodes.getAttribute('y');
const ids = nodes.getId();
for (let i = 0; i < ids.length; i++) {
const id = ids[i];
if (!cache.index.has(id)) {
cache.index.set(id, cache.xs.length);
cache.xs.push(xs[i]);
cache.ys.push(ys[i]);
} else {
let j = cache.index.get(id)!;
cache.xs[j] = xs[i];
cache.ys[j] = ys[i];
}
const node = nodes.get(i);
if (idToCard.has(id)) {
const div = idToCard.get(id)!;
div.innerHTML = generator(node);
return;
} else {
const div = document.createElement('div');
div.classList.add('card');
div.innerHTML = generator(node);
div.addEventListener('click', () => {
super.emit('click', { node });
ogma.mouse.click({
...ogma.view.graphToScreenCoordinates(node.getPosition())
});
});
div.addEventListener('mousedown', evt => {
evt.stopPropagation();
evt.preventDefault();
ogma.setOptions({
interactions: {
drag: { enabled: false }
}
});
this.dragging = node;
const nodePos = node.getPosition();
const pos = ogma.view.screenToGraphCoordinates({
x: evt.clientX,
y: evt.clientY
});
this.dragStart = {
x: pos.x - nodePos.x,
y: pos.y - nodePos.y
};
});
div.addEventListener('mouseup', evt => {
evt.stopPropagation();
evt.preventDefault();
ogma.setOptions({
interactions: {
drag: { enabled: true }
}
});
this.dragging = null;
});
idToCard.set(node.getId(), div);
container.appendChild(div);
}
}
}
removeNodes(nodes: NodeList) {
const { idToCard, cache } = this;
const { index, xs, ys } = cache;
nodes.forEach(node => {
if (index.has(node.getId())) {
const i = index.get(node.getId())!;
index.delete(node.getId());
xs.splice(i, 1);
ys.splice(i, 1);
}
const div = idToCard.get(node.getId());
if (!div) return;
div.remove();
idToCard.delete(node.getId());
});
}
}