Skip to content
  1. Examples

Org chart with DOM nodes new

This example shows how to choose which node to place on top of the hierarchy. Click on a node to promote it at the top of the tree.
Click an edge to make its extremities the top of the layout.

ts
import Ogma, {
  EdgeId,
  HierarchicalLayoutOptions,
  Node,
  NodeId,
  RawEdge,
  RawGraph,
  RawNode
} from '@linkurious/ogma';
import { NodeCardsPlugin } from './nodeCards';

const ogma = new Ogma({
  container: 'graph-container',
  options: {
    edgesRoutingStyle: 'vertical'
  }
});

// edge style
ogma.styles.addEdgeRule({
  shape: {
    head: 'arrow'
  }
});

const defaultLayoutOptions: HierarchicalLayoutOptions = {
  direction: 'TB', // Direction of the layout. Can be TB, BT, LR, or RL,
  // where T = top, B = bottom, L = left, and R = right.
  duration: 300, // Duration of the animation
  nodeDistance: 25, // Number of pixels that separate nodes horizontally in the layout.
  levelDistance: 50, // Number of pixels between each layer in the layout.
  componentDistance: 50, // Number of pixels between each component in the layout.
  arrangeComponents: 'fit'
};

function runWithParameters() {
  // create fresh new options
  const newOptions: HierarchicalLayoutOptions = { ...defaultLayoutOptions };
  // pass the roots to the layout
  newOptions.roots = ogma.getSelectedNodes();
  // now add the chosen one
  return runLayout(newOptions);
}

const graph = await ogma.generate.balancedTree({
  children: 2,
  height: 4
});
await ogma.setGraph(graph, { ignoreInvalid: true });
const leafs = ogma.getNodes().filter(n => n.getDegree() === 1);
let counter = ogma.getNodes().size;
const addedNodes: RawNode[] = [];
const addedEdges: RawEdge[] = [];

leafs.concat(ogma.getNodes([5, 7, 9])!.toList()).forEach((n, leafNo) => {
  const id = n.getId();
  if ((leafNo > 4 && leafNo < 12) || leafNo === 7 || leafNo === 13) return;
  for (let i = 0; i < 8; i++) {
    const newNode = { id: counter++ };
    addedNodes.push(newNode);
    addedEdges.push({ id: counter++, source: id, target: newNode.id });
  }
});

await ogma.addGraph({ nodes: addedNodes, edges: addedEdges });
await ogma.getNode(1)!.setSelected(true);
await runWithParameters();
await ogma.getNodes().forEach(node => {
  node.setData('label', `Person ${node.getId()}`);
});

const fadedClass = ogma.styles.createClass({
  name: 'faded',
  nodeAttributes: {
    opacity: 0.1
  },
  edgeAttributes: {
    opacity: 0.1
  }
});
ogma.styles.addNodeRule({
  outerStroke: n =>
    n.getDegree() >= 3 ? { color: 'darkblue', width: 1 } : undefined
});
const plugin = new NodeCardsPlugin(
  ogma,
  node => {
    if (node.hasClass('faded')) return false;
    return true;
  },
  node => {
    let id = +node.getId();
    let degree = 0;
    if (node.getData('children') && node.getData('children').nodes) {
      degree = node.getData('children').nodes.length;
    }
    if (id === 18) id = 0;
    const html = `<img src="files/faces/${id % 150}.jpg"><div><h3 class="title">${node.getData('label')}</h3><p class="details">Details: …</p></div>`;
    if (degree > 1) {
      return html + `<span class="badge">${degree - 1}</span>`;
    }
    return html;
  }
);

const onClick = async (node: Node) => {
  if (node && node.getDegree() >= 3) {
    console.log('clicked on node', node.getId());
    const root = node;
    const subtreeNodes: NodeId[] = [root.getId()];
    const subtreeEdges: EdgeId[] = [];
    const rootId = node.getId();
    ogma.algorithms.dfs({
      root,
      onNode(node) {
        if (node === root) return;
        subtreeNodes.push(node.getId());
      },
      onEdge(edge) {
        if (
          edge.getSource().getId() < rootId ||
          edge.getTarget().getId() < rootId
        )
          return false;
        subtreeEdges.push(edge.getId());
      }
    });
    await ogma.view.afterNextFrame();
    await fadedClass.getNodes().removeClass('faded');
    await fadedClass.getEdges().removeClass('faded');
    await Promise.all([
      ogma.getNodes(subtreeNodes).inverse().addClass('faded'),
      ogma.getEdges(subtreeEdges).inverse().addClass('faded')
    ]);
    await ogma.view.moveToBounds(
      ogma.getNodes(subtreeNodes).getBoundingBox().pad(30),
      {
        duration: 300
      }
    );
  }
};

let clickTimer: ReturnType<typeof setTimeout>;
ogma.events.on('click', async evt => {
  clearTimeout(clickTimer);
  clickTimer = setTimeout(async () => {
    if (!evt.target) {
      console.log('clear');
      await Promise.all([
        fadedClass.getNodes().removeClass('faded'),
        fadedClass.getEdges().removeClass('faded')
      ]);
    } else if (evt.target.isNode) onClick(evt.target);
  }, 300);
});

plugin
  .on('click', ({ node }) => {
    clearTimeout(clickTimer);
    clickTimer = setTimeout(() => onClick(node), 300);
  })
  .on('doubleclick', async evt => {
    clearTimeout(clickTimer);
    if (!evt.node) return;
    else if (evt.node && evt.node.getData('children')) {
      const root = evt.node;
      const rootPositon = root.getPosition();
      const { nodes, edges } = root.getData('children');
      root.setData('children', null);
      const positions = nodes.map(n => {
        const pos = { x: n.attributes.x, y: n.attributes.y };
        n.attributes.x = rootPositon.x;
        n.attributes.y = rootPositon.y;
        return pos;
      });
      await ogma.addGraph({ nodes, edges });
      await ogma.getNodes(nodes.map(n => n.id)).setAttributes(positions, 200);
    } else if (evt.node && evt.node.getDegree() >= 3) {
      const root = evt.node;
      const rootId = root.getId();
      const subtreeNodes: NodeId[] = [];
      const subtreeEdges: EdgeId[] = [];
      const subGraph: RawGraph = { nodes: [], edges: [] };
      ogma.algorithms.dfs({
        root,
        onNode(node) {
          if (node === root) return;
          subtreeNodes.push(node.getId());
          subGraph.nodes.push(node.toJSON({ attributes: ['x', 'y'] }));
        },
        onEdge(edge) {
          if (
            edge.getSource().getId() < rootId ||
            edge.getTarget().getId() < rootId
          )
            return false;
          subtreeEdges.push(edge.getId());
          subGraph.edges.push(edge.toJSON({ attributes: [] }));
        }
      });
      await ogma.getNodes(subtreeNodes).setAttributes(root.getPosition(), 200);
      await root.setData('children', subGraph);
      await ogma.removeNodes(subtreeNodes);
    }
  });

function runLayout(options: HierarchicalLayoutOptions) {
  // Run layout
  return ogma.layouts.hierarchical({ ...options, locate: true });
}

await onClick(ogma.getNode(18)!);
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"
    />
    <link type="text/css" rel="stylesheet" href="styles.css" />
  </head>

  <body>
    <div id="graph-container"></div>

    <div class="toolbar" id="ui">
      <div class="section">
        <h4>Layout parameters</h4>
        <div class="controls">
          <label for="vertical">Level Distance</label>
          <input
            type="range"
            id="vertical"
            name="levelDistance"
            min="25"
            max="100"
            value="50"
          />
        </div>
        <div class="controls">
          <label for="horizontal">Node Distance</label>
          <input
            type="range"
            id="horizontal"
            name="nodeDistance"
            min="5"
            max="100"
            value="25"
          />
        </div>
        <div class="controls">
          <label for="horizontal">Component Distance</label>
          <input
            type="range"
            id="component"
            name="componentDistance"
            min="1"
            max="100"
            value="25"
          />
        </div>
        <div class="controls">
          <label>Change Direction:</label>
          <select name="directionMode" id="directionMode">
            <option value="TB">Top Down</option>
            <option value="BT">Bottom Up</option>
            <option value="LR">Left to Right</option>
            <option value="RL">Right to Left</option>
          </select>
        </div>
        <div class="controls">
          <label>Arrange Components:</label>
          <select name="arrangeMode" id="arrangeMode">
            <option value="fit">Fit</option>
            <option value="grid">Grid</option>
            <option value="singleLine">Single Line</option>
          </select>
        </div>
        <div class="controls">
          <label>Bent Edges:</label>
          <select name="bentEdges" id="bentEdges">
            <option value="none">None</option>
            <option value="horizontal">Horizontal</option>
            <option value="vertical" selected>Vertical</option>
          </select>
        </div>
      </div>
    </div>
    <script type="module" src="index.ts"></script>
  </body>
</html>
css
html,
body {
  font-family: 'IBM Plex Sans', Arial, Helvetica, sans-serif;
}

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

h4 {
  margin: 15px 5px 5px 5px;
}

#ui {
  display: none;
}

.toolbar {
  display: block;
  position: absolute;
  top: 20px;
  right: 20px;
  padding: 0 10px 10px;
  box-shadow: 0 1px 5px rgba(0, 0, 0, 0.65);
  border-radius: 4px;
  background: #ffffff;
  color: #222222;
  font-weight: 300;
}

.toolbar .section {
  position: relative;
  display: block;
}

.toolbar .section h4 {
  display: block;
  font-weight: 300;
  border-bottom: 1px solid #ddd;
  color: #606060;
  font-size: 1rem;
}

.controls {
  margin-top: 15px;
  clear: both;
  display: none;
}

.controls select {
  width: 100%;
  clear: both;
}

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

.card > img {
  max-width: 56px;
  height: auto;
}

.card h3 {
  margin-top: 0.25em;
  margin-bottom: 0.25em;
}

.card .badge {
  display: inline-block;
  position: absolute;
  top: -16px;
  right: -16px;
  background: black;
  color: white;
  border-radius: 50%;
  padding: 5px;
  width: 16px;
  height: 16px;
  text-align: center;
  line-height: 16px;
  z-index: 1000;
}

.card div p {
  margin-top: 0;
}

.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,
  DoubleClickEvent
} 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.4;
  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');
        });
      })
      .on('doubleclick', this._onDoubleClick);
  }

  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', evt => {
          super.emit('click', { node, domEvent: evt });
          ogma.mouse.click({
            ...ogma.view.graphToScreenCoordinates(node.getPosition())
          });
        });
        div.addEventListener('dblclick', evt => {
          super.emit('doubleclick', { node, domEvent: evt });
        });
        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());
    });
  }

  private _onDoubleClick = (evt: DoubleClickEvent<unknown, unknown>) => {};
}