Skip to content
  1. Examples

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