Appearance
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>) => {};
}