@neo4j-nvl/layout-workers
Version:
Layout workers for the Neo4j Visualization Library
376 lines (375 loc) • 16 kB
JavaScript
import dagre from '@neo4j-bloom/dagre';
import binPack from 'bin-pack';
import graphlib from 'graphlib';
import { DefaultNodeSize, DirectionDown, DirectionRight, DirectionUp, Directions, GlAdjust, PackingBin, Ranker, SubGraphSpacing } from './constants.js';
const isDirectionVertical = (direction) => direction === DirectionUp || direction === DirectionDown;
const isDirectionNatural = (direction) => direction === DirectionDown || direction === DirectionRight;
const getGraphDimensions = (g) => {
let minX = null;
let minY = null;
let maxX = null;
let maxY = null;
let minCenterX = null;
let minCenterY = null;
let maxCenterX = null;
let maxCenterY = null;
for (const id of g.nodes()) {
const sn = g.node(id);
if (minCenterX === null || sn.x < minCenterX) {
minCenterX = sn.x;
}
if (minCenterY === null || sn.y < minCenterY) {
minCenterY = sn.y;
}
if (maxCenterX === null || sn.x > maxCenterX) {
maxCenterX = sn.x;
}
if (maxCenterY === null || sn.y > maxCenterY) {
maxCenterY = sn.y;
}
const halfSize = Math.ceil(sn.width / 2.0);
if (minX === null || sn.x - halfSize < minX) {
minX = sn.x - halfSize;
}
if (minY === null || sn.y - halfSize < minY) {
minY = sn.y - halfSize;
}
if (maxX === null || sn.x + halfSize > maxX) {
maxX = sn.x + halfSize;
}
if (maxY === null || sn.y + halfSize > maxY) {
maxY = sn.y + halfSize;
}
}
return {
minX,
minY,
maxX,
maxY,
minCenterX,
minCenterY,
maxCenterX,
maxCenterY,
width: maxX - minX,
height: maxY - minY,
xOffset: minCenterX - minX,
yOffset: minCenterY - minY
};
};
const createGraph = (pixelRatio) => {
// Create a new directed graph
const g = new dagre.graphlib.Graph();
// Set an object for the graph label
g.setGraph({});
// Default to assigning a new object as a label for each new edge.
g.setDefaultEdgeLabel(() => ({}));
// Configuration https://github.com/dagrejs/dagre/wiki#configuring-the-layout
// Number of pixels that separate nodes horizontally in the layout.
g.graph().nodesep = 75 * pixelRatio;
// Number of pixels between each rank in the layout.
g.graph().ranksep = 75 * pixelRatio;
return g;
};
const findParentForEdges = (id, connectedNodes, layoutGraph) => {
const { rank: currentRank } = layoutGraph.node(id);
let pRank = null;
let pId = null;
for (const otherId of connectedNodes) {
const { rank } = layoutGraph.node(otherId);
if (otherId === id || rank >= currentRank) {
continue;
}
else if (rank === currentRank - 1) {
pRank = rank;
pId = otherId;
break;
}
else if ((pRank === null && pId === null) || rank > pRank) {
pRank = rank;
pId = otherId;
}
}
return pId;
};
const findParent = (id, layoutGraph) => {
// predecessors uses inEdges, successors uses outEdges
let pId = findParentForEdges(id, layoutGraph.predecessors(id), layoutGraph);
if (pId === null) {
pId = findParentForEdges(id, layoutGraph.successors(id), layoutGraph);
}
return pId;
};
const getConnectedSubGraphs = (g, pixelRatio) => {
const subGraphs = [];
const components = graphlib.alg.components(g);
if (components.length > 1) {
for (const component of components) {
const subGraph = createGraph(pixelRatio);
for (const id of component) {
const n = g.node(id);
subGraph.setNode(id, { width: n.width, height: n.height });
const outEdges = g.outEdges(id);
if (outEdges) {
for (const e of outEdges) {
subGraph.setEdge(e.v, e.w);
}
}
}
subGraphs.push(subGraph);
}
}
else {
subGraphs.push(g);
}
return subGraphs;
};
const layoutGraph = (g, direction, parents) => {
g.graph().ranker = Ranker;
g.graph().rankdir = Directions[direction];
const dagreLayoutGraph = dagre.layout(g);
for (const id of dagreLayoutGraph.nodes()) {
const pId = findParent(id, dagreLayoutGraph);
if (pId !== null) {
parents[id] = pId;
}
}
};
const getDistance = (p1, p2) => Math.sqrt((p1.x - p2.x) * (p1.x - p2.x) + (p1.y - p2.y) * (p1.y - p2.y));
const mergeStraightPoints = (points) => {
const mergedPoints = [points[0]];
let prevSegment = { p1: points[0], p2: points[1] };
let prevLength = getDistance(prevSegment.p1, prevSegment.p2);
for (let i = 2; i < points.length; i++) {
let currentSegment = { p1: points[i - 1], p2: points[i] };
let currentLength = getDistance(currentSegment.p1, currentSegment.p2);
const compositeSegment = { p1: prevSegment.p1, p2: currentSegment.p2 };
const compositeLength = getDistance(compositeSegment.p1, compositeSegment.p2);
// if 2 consecutive segments are parallel then join them
// Use the triangular inequality for checking the parallelism
if (currentLength + prevLength - compositeLength < 0.1) {
mergedPoints.pop();
currentSegment = compositeSegment;
currentLength = compositeLength;
}
mergedPoints.push(currentSegment.p1);
prevSegment = currentSegment;
prevLength = currentLength;
}
mergedPoints.push(points[points.length - 1]);
return mergedPoints;
};
export const layout = (nodes, nodeIds, idToPosition, rels, direction, packing, pixelRatio = 1) => {
const g = createGraph(pixelRatio);
const parents = {};
const positionSum = { x: 0, y: 0 };
const numNodes = nodes.length;
// Add nodes to the graph. The first argument is the node id. The second is
// metadata about the node.
for (const n of nodes) {
const position = idToPosition[n.id];
positionSum.x += position?.x || 0;
positionSum.y += position?.y || 0;
const size = (n.size || DefaultNodeSize) * GlAdjust * pixelRatio;
g.setNode(n.id, { width: size, height: size });
}
const prevNodeCenterPoint = numNodes ? [positionSum.x / numNodes, positionSum.y / numNodes] : [0, 0];
// Add edges to the graph.
const addedRel = {};
for (const r of rels) {
if (nodeIds[r.from] && nodeIds[r.to] && r.from !== r.to) {
const relKey = r.from < r.to ? `${r.from}-${r.to}` : `${r.to}-${r.from}`;
if (!addedRel[relKey]) {
addedRel[relKey] = 1;
g.setEdge(r.from, r.to);
}
}
}
const subGraphs = getConnectedSubGraphs(g, pixelRatio);
if (subGraphs.length > 1) {
subGraphs.forEach((subGraph) => layoutGraph(subGraph, direction, parents));
const isVertical = isDirectionVertical(direction);
const isNatural = isDirectionNatural(direction);
const singleNodeGraphs = subGraphs.filter((sg) => sg.nodeCount() === 1);
const multiNodeGraphs = subGraphs.filter((sg) => sg.nodeCount() !== 1);
if (packing === PackingBin) {
multiNodeGraphs.sort((a, b) => b.nodeCount() - a.nodeCount());
const adjustDimensionPaddingNormal = ({ width, height, ...rest }) => ({
...rest,
width: width + SubGraphSpacing,
height: height + SubGraphSpacing
});
const adjustDimensionPaddingFlip = ({ width, height, ...rest }) => ({
...rest,
width: height + SubGraphSpacing,
height: width + SubGraphSpacing
});
const adjustDimensionPadding = isVertical ? adjustDimensionPaddingNormal : adjustDimensionPaddingFlip;
const multiGraphDimensions = multiNodeGraphs.map(getGraphDimensions).map(adjustDimensionPadding);
const singleGraphDimensions = singleNodeGraphs.map(getGraphDimensions).map(adjustDimensionPadding);
const bins = multiGraphDimensions.concat(singleGraphDimensions);
binPack(bins, { inPlace: true });
const halfSpacing = Math.floor(SubGraphSpacing / 2);
const xProp = isVertical ? 'x' : 'y';
const yProp = isVertical ? 'y' : 'x';
if (!isNatural) {
const positionProp = isVertical ? 'y' : 'x';
const extentProp = isVertical ? 'height' : 'width';
const min = bins.reduce((minBin, d) => (minBin === null ? d[positionProp] : Math.min(d[positionProp], minBin[extentProp] || 0)), null);
const max = bins.reduce((maxBin, d) => {
return maxBin === null
? d[positionProp] + d[extentProp]
: Math.max(d[positionProp] + d[extentProp], maxBin[extentProp] || 0);
}, null);
bins.forEach((d) => {
d[positionProp] = min + (max - (d[positionProp] + d[extentProp]));
});
}
const assignPositions = (subGraph, dimensions) => {
for (const id of subGraph.nodes()) {
const sn = subGraph.node(id);
const n = g.node(id);
n.x = sn.x - dimensions.xOffset + dimensions[xProp] + halfSpacing;
n.y = sn.y - dimensions.yOffset + dimensions[yProp] + halfSpacing;
}
};
for (let i = 0; i < multiNodeGraphs.length; i++) {
const subGraph = multiNodeGraphs[i];
const dimensions = multiGraphDimensions[i];
assignPositions(subGraph, dimensions);
}
for (let i = 0; i < singleNodeGraphs.length; i++) {
const subGraph = singleNodeGraphs[i];
const dimensions = singleGraphDimensions[i];
assignPositions(subGraph, dimensions);
}
}
else {
multiNodeGraphs.sort(isNatural ? (a, b) => b.nodeCount() - a.nodeCount() : (a, b) => a.nodeCount() - b.nodeCount());
const multiGraphDimensions = multiNodeGraphs.map(getGraphDimensions);
const singleNodesAcc = singleNodeGraphs.reduce((acc, subGraph) => acc + g.node(subGraph.nodes()[0]).width, 0);
const singleNodesMaxSize = singleNodeGraphs.reduce((maxSize, subGraph) => Math.max(maxSize, g.node(subGraph.nodes()[0]).width), 0);
const singleNodesSize = singleNodeGraphs.length > 0 ? singleNodesAcc + (singleNodeGraphs.length - 1) * SubGraphSpacing : 0;
const maxSubGraphWidth = multiGraphDimensions.reduce((maxWidth, { width }) => Math.max(maxWidth, width), 0);
const graphWidth = Math.max(maxSubGraphWidth, singleNodesSize);
const maxSubGraphHeight = multiGraphDimensions.reduce((maxHeight, { height }) => Math.max(maxHeight, height), 0);
const graphHeight = Math.max(maxSubGraphHeight, singleNodesSize);
let position = 0;
const positionMultiNodeGraphs = () => {
for (let i = 0; i < multiNodeGraphs.length; i++) {
const subGraph = multiNodeGraphs[i];
const dimensions = multiGraphDimensions[i];
const centerOffset = isVertical
? Math.floor((graphWidth - dimensions.width) / 2.0)
: Math.floor((graphHeight - dimensions.height) / 2.0);
for (const id of subGraph.nodes()) {
const sn = subGraph.node(id);
const n = g.node(id);
if (isVertical) {
n.x = sn.x - dimensions.minX + centerOffset;
n.y = sn.y - dimensions.minY + position;
}
else {
n.x = sn.x - dimensions.minX + position;
n.y = sn.y - dimensions.minY + centerOffset;
}
}
for (const id of subGraph.edges()) {
const sedge = subGraph.edge(id);
const edge = g.edge(id);
if (sedge.points && sedge.points.length > 3) {
edge.points = sedge.points.map(({ x, y }) => ({
x: x - dimensions.minX + (isVertical ? centerOffset : position),
y: y - dimensions.minY + (isVertical ? position : centerOffset)
}));
}
}
position += (isVertical ? dimensions.height : dimensions.width) + SubGraphSpacing;
}
};
const positionSingleNodeGraphs = () => {
const singleCenterOffset = Math.floor(((isVertical ? graphWidth : graphHeight) - singleNodesSize) / 2.0);
position += Math.floor(singleNodesMaxSize / 2.0);
let singlePosition = singleCenterOffset;
for (const subGraph of singleNodeGraphs) {
const id = subGraph.nodes()[0];
const n = g.node(id);
if (isVertical) {
n.x = singlePosition + Math.floor(n.width / 2.0);
n.y = position;
}
else {
n.x = position;
n.y = singlePosition + Math.floor(n.width / 2.0);
}
singlePosition += SubGraphSpacing + n.width;
}
position = singleNodesMaxSize + SubGraphSpacing;
};
if (isNatural) {
positionMultiNodeGraphs();
positionSingleNodeGraphs();
}
else {
positionSingleNodeGraphs();
positionMultiNodeGraphs();
}
}
}
else {
layoutGraph(g, direction, parents);
}
positionSum.x = 0;
positionSum.y = 0;
const positions = {};
for (const id of g.nodes()) {
const n = g.node(id);
positionSum.x += n.x || 0;
positionSum.y += n.y || 0;
positions[id] = { x: n.x, y: n.y };
}
const newNodeCenterPoint = numNodes ? [positionSum.x / numNodes, positionSum.y / numNodes] : [0, 0];
const translateX = prevNodeCenterPoint[0] - newNodeCenterPoint[0];
const translateY = prevNodeCenterPoint[1] - newNodeCenterPoint[1];
for (const key in positions) {
positions[key].x += translateX;
positions[key].y += translateY;
}
const waypoints = {};
for (const id of g.edges()) {
const e = g.edge(id);
if (e.points && e.points.length > 3) {
const mergedPoints = mergeStraightPoints(e.points);
for (const p of mergedPoints) {
p.x += translateX;
p.y += translateY;
}
waypoints[`${id.v}-${id.w}`] = {
points: [...mergedPoints],
from: {
x: positions[id.v].x,
y: positions[id.v].y
},
to: {
x: positions[id.w].x,
y: positions[id.w].y
}
};
waypoints[`${id.w}-${id.v}`] = {
points: mergedPoints.reverse(),
from: {
x: positions[id.w].x,
y: positions[id.w].y
},
to: {
x: positions[id.v].x,
y: positions[id.v].y
}
};
}
}
return {
positions,
parents,
waypoints
};
};