UNPKG

@neo4j-nvl/layout-workers

Version:

Layout workers for the Neo4j Visualization Library

376 lines (375 loc) 16 kB
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 }; };