UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

610 lines (609 loc) 21.6 kB
function getGraph(nodes, edges) { const graph = {}; const layering = []; const nodeIdToId = new Map(); let id; let count = 0; for (const node of nodes) { id = `innerNodeId_${count++}`; let layer = layering[node.level]; if (!layer) { layering[node.level] = layer = new Set(); } layer.add(id); nodeIdToId.set(node.id, id); graph[id] = { node: { ...node }, in: [], out: [] }; } for (const edge of edges) { const from = nodeIdToId.get(edge.from); const to = nodeIdToId.get(edge.to); if (from !== undefined && to !== undefined) { graph[from].out.push(to); graph[to].in.push(from); } } return { graph, layering: layering.filter(Boolean), levels: layering.map((layer, index) => (layer ? index : -1)).filter((i) => i !== -1), }; } function prepareGraph(graph, layering, levels) { let count = 0; for (let i = 0; i < layering.length; i++) { const layer = layering[i]; const nextLayer = layering[i + 1]; for (const nodeId of layer) { const nodeGraph = graph[nodeId]; nodeGraph.rank = i; if (i === layering.length - 1) { continue; } for (let index = 0; index < nodeGraph.out.length; index++) { const outputNode = nodeGraph.out[index]; if (!nextLayer.has(outputNode)) { const newId = `${outputNode}_${count++}`; const newNode = { id: newId, shape: "dot", size: 0, level: levels[i] }; graph[newId] = { node: newNode, in: [nodeId], out: [], virtual: true, }; nextLayer.add(newId); nodeGraph.out[index] = newId; graph[newId].out.push(outputNode); graph[outputNode].in[graph[outputNode].in.indexOf(nodeId)] = newId; } } } } } function initOrder(graph, layering) { const queue = [...layering[0]]; const order = []; const visited = new Set(queue); while (queue.length > 0) { const nodeId = queue.shift(); if (nodeId === undefined) { break; } const graphNode = graph[nodeId]; let layer = order[graphNode.rank]; if (!layer) { order[graphNode.rank] = layer = []; } layer.push(nodeId); for (const outNodeId of graphNode.out) { if (visited.has(outNodeId)) { continue; } visited.add(outNodeId); queue.push(outNodeId); } } return order; } function twoLayerCrossCount(graph, northLayer, southLayer) { const southPos = southLayer.reduce((obj, nodeId, i) => { obj[nodeId] = i; return obj; }, {}); const southEntries = []; for (const v of northLayer) { southEntries.push(...graph[v].out.map((e) => southPos[e]).sort((a, b) => a - b)); } let firstIndex = 1; while (firstIndex < southLayer.length) { firstIndex *= 2; } const treeSize = 2 * firstIndex - 1; firstIndex -= 1; const tree = new Array(treeSize).fill(0); let cc = 0; southEntries.forEach((pos) => { let index = pos + firstIndex; tree[index]++; while (index > 0) { if (index % 2) { cc += tree[index + 1]; } index = Math.floor((index - 1) / 2); tree[index]++; } }); return cc; } function countCrossing(graph, order) { let cc = 0; for (let i = 1; i < order.length; ++i) { cc += twoLayerCrossCount(graph, order[i - 1], order[i]); } return cc; } function medianValue(nodes, order) { const positions = order.reduce((pos, nodeId, i) => { if (nodes.includes(nodeId)) { pos.push(i); } return pos; }, []); if (positions.length === 0) { return -1; } const m = Math.floor(positions.length / 2); if (positions.length % 2 === 1) { return positions[m]; } if (positions.length === 2) { return (positions[0] + positions[1]) / 2; } const left = positions[m - 1] - positions[0]; const right = positions[positions.length - 1] - positions[m]; return (positions[m - 1] * right + positions[m] * left) / (left + right); } function sortWithFixedNodes(order, median) { const sorted = order.filter((a) => median[a] !== -1).sort((a, b) => median[a] - median[b]); for (let i = 0, sortIndex = 0; sortIndex < sorted.length; i++) { if (median[order[i]] !== -1) { order[i] = sorted[sortIndex++]; } } } function applyWeightedMedianHeuristic(graph, order, iter) { if (iter % 2 === 0) { for (let i = 1; i < order.length; i++) { const median = {}; for (const nodeId of order[i]) { median[nodeId] = medianValue(graph[nodeId].in, order[i - 1]); } sortWithFixedNodes(order[i], median); } } else { for (let i = order.length - 2; i >= 0; i--) { const median = {}; for (const nodeId of order[i]) { median[nodeId] = medianValue(graph[nodeId].out, order[i + 1]); } sortWithFixedNodes(order[i], median); } } } function swap(rank, i, j) { const tmp = rank[i]; rank[i] = rank[j]; rank[j] = tmp; } function partialCountCrossing(graph, order, layerIndex) { let cc = 0; if (layerIndex > 0) { cc += twoLayerCrossCount(graph, order[layerIndex - 1], order[layerIndex]); } if (layerIndex < order.length - 1) { cc += twoLayerCrossCount(graph, order[layerIndex], order[layerIndex + 1]); } return cc; } function transpose(graph, order) { let improved = true; let count = 0; const maxIteration = 10; while (improved && count++ < maxIteration) { improved = false; for (let layerIndex = 0; layerIndex < order.length; layerIndex++) { const layer = order[layerIndex]; let bestCC = partialCountCrossing(graph, order, layerIndex); for (let i = 0; i < layer.length - 1; i++) { swap(layer, i, i + 1); const cc = partialCountCrossing(graph, order, layerIndex); if (bestCC > cc) { improved = true; bestCC = cc; } else { swap(layer, i, i + 1); } } } } } function ordering(graph, layering, skipTranspose) { const maxSweeps = 24; const order = initOrder(graph, layering); let bestOrder = order.map((el) => [...el]); let bestCC = countCrossing(graph, order); for (let i = 0, lastBest = 0; lastBest < 4 && i < maxSweeps; i++, lastBest++) { applyWeightedMedianHeuristic(graph, order, i); if (!skipTranspose) { transpose(graph, order); } const cc = countCrossing(graph, order); if (bestCC > cc) { bestCC = cc; bestOrder = order.map((el) => [...el]); lastBest = 0; } } bestOrder.forEach((layer) => { layer.forEach((v, i) => { graph[v].order = i; }); }); return bestOrder; } function findOtherInnerSegmentNode(graph, v) { if (graph[v].virtual) { return graph[v].in.find((u) => graph[u].virtual); } return undefined; } function addConflict(conflicts, v, w) { let conflictsV = conflicts[v]; if (!conflictsV) { conflicts[v] = conflictsV = new Set(); } conflictsV.add(w); } function hasConflict(conflicts, v, w) { return (conflicts[v] && conflicts[v].has(w)) || (conflicts[w] && conflicts[w].has(v)); } function findType1Conflicts(graph, layering) { const conflicts = {}; function visitLayer(prevLayer, layer) { let k0 = 0; let scanPos = 0; const prevLayerLength = prevLayer.length; const lastNode = layer[layer.length - 1]; layer.forEach((v, i) => { const w = findOtherInnerSegmentNode(graph, v); const k1 = w ? graph[w].order : prevLayerLength; if (w || v === lastNode) { layer.slice(scanPos, i + 1).forEach((scanNode) => { graph[scanNode].in.forEach((u) => { const uNode = graph[u]; const uPos = uNode.order; if ((uPos < k0 || k1 < uPos) && !(uNode.virtual && graph[scanNode].virtual)) { addConflict(conflicts, u, scanNode); } }); }); scanPos = i + 1; k0 = k1; } }); return layer; } layering.reduce(visitLayer); return conflicts; } function findType2Conflicts(graph, layering) { const conflicts = {}; function scan(south, southPos, southEnd, prevNorthBorder, nextNorthBorder) { for (let i = southPos; i < southEnd; i++) { const v = south[i]; if (graph[v].virtual) { graph[v].in.forEach((u) => { const uNode = graph[u]; if (uNode.virtual && (uNode.order < prevNorthBorder || uNode.order > nextNorthBorder)) { addConflict(conflicts, u, v); } }); } } } function visitLayer(north, south) { let prevNorthPos = -1; let nextNorthPos = 0; let southPos = 0; south.forEach((v, southLookahead) => { if (graph[v].virtual) { const predecessors = graph[v].in; if (predecessors.length) { nextNorthPos = graph[predecessors[0]].order; scan(south, southPos, southLookahead, prevNorthPos, nextNorthPos); southPos = southLookahead; prevNorthPos = nextNorthPos; } } scan(south, southPos, south.length, prevNorthPos, north.length); }); return south; } layering.reduce(visitLayer); return conflicts; } function verticalAlignment(graph, layering, conflicts, neighbor) { const root = {}; const align = {}; const pos = {}; layering.forEach((layer) => { layer.forEach((v, order) => { root[v] = v; align[v] = v; pos[v] = order; }); }); layering.forEach((layer) => { let prevIdx = -1; layer.forEach((v) => { const ws = graph[v][neighbor]; if (ws.length) { ws.sort((a, b) => pos[a] - pos[b]); const mp = (ws.length - 1) / 2; for (let i = Math.floor(mp), il = Math.ceil(mp); i <= il; i++) { const w = ws[i]; if (align[v] === v && prevIdx < pos[w] && !hasConflict(conflicts, v, w)) { align[w] = v; align[v] = root[v] = root[w]; prevIdx = pos[w]; } } } }); }); return { root, align }; } export const DEFAULT_NODE_WIDTH = 100; export const DEFAULT_NODE_HEIGHT = 100; function resolveLayoutOptions(options) { const defaultNodeWidth = options?.defaultNodeWidth ?? DEFAULT_NODE_WIDTH; const defaultNodeHeight = options?.defaultNodeHeight ?? DEFAULT_NODE_HEIGHT; return { defaultNodeWidth, defaultNodeHeight, nodeHorizontalGap: options?.nodeHorizontalGap ?? defaultNodeWidth * 2, nodeVerticalGap: options?.nodeVerticalGap ?? 200, layerSpacingFactor: options?.layerSpacingFactor ?? 1.7, enormousGraphNodeThreshold: options?.enormousGraphNodeThreshold ?? 700, enormousGraphEdgeThreshold: options?.enormousGraphEdgeThreshold ?? 3000, }; } function nodeWidth(graph, v, opts) { if (graph && v && graph[v]) { const width = graph[v].node.width ?? opts.defaultNodeWidth; return width + opts.nodeHorizontalGap; } return opts.defaultNodeWidth + opts.nodeHorizontalGap; } function nodeHeight(graph, v, opts) { if (graph && v && graph[v]) { const height = graph[v].node.height ?? opts.defaultNodeHeight; return height + opts.nodeVerticalGap; } return opts.defaultNodeHeight + opts.nodeVerticalGap; } function horizontalCompaction(graph, layering, root, align, reverse, opts) { const xs = {}; const sink = Object.keys(graph).reduce((obj, key) => { obj[key] = key; return obj; }, {}); const shift = Object.keys(graph).reduce((obj, key) => { obj[key] = Number.POSITIVE_INFINITY; return obj; }, {}); function placeBlock(v) { if (xs[v] === undefined) { xs[v] = 0; let w = v; do { const rank = reverse ? layering.length - graph[w].rank - 1 : graph[w].rank; const pos = layering[rank].indexOf(w); if (pos > 0) { const u = root[layering[rank][pos - 1]]; placeBlock(u); if (sink[v] === v) { sink[v] = sink[u]; } if (sink[v] === sink[u]) { xs[v] = Math.max(xs[v], xs[u] + nodeHeight(graph, v, opts)); } else { shift[sink[u]] = Math.min(shift[sink[u]], xs[v] - xs[u] - nodeHeight(graph, v, opts)); } } w = align[w]; } while (w !== v); } } Object.keys(graph).forEach((v) => { if (root[v] === v) { placeBlock(v); } }); Object.keys(graph).forEach((v) => { xs[v] = xs[root[v]]; if (shift[sink[root[v]]] < Number.POSITIVE_INFINITY) { xs[v] = xs[v] + shift[sink[root[v]]]; } }); return xs; } function findSmallestHeightAlignment(graph, xss, opts) { return Object.values(xss).reduce((res, xs) => { let max = Number.NEGATIVE_INFINITY; let min = Number.POSITIVE_INFINITY; Object.entries(xs).forEach(([v, x]) => { const halfHeight = nodeHeight(graph, v, opts) / 2; max = Math.max(x + halfHeight, max); min = Math.min(x - halfHeight, min); }); const height = max - min; if (res.min > height) { return { min: height, align: xs, }; } return res; }, { min: Number.POSITIVE_INFINITY }).align; } function alignCoordinates(xss, alignTo) { const alignToVals = Object.values(alignTo); const alignToMin = alignToVals.reduce((a, b) => Math.min(a, b)); const alignToMax = alignToVals.reduce((a, b) => Math.max(a, b)); ["u", "d"].forEach((vert) => { ["l", "r"].forEach((horiz) => { const alignment = vert + horiz; const xs = xss[alignment]; if (xs === alignTo) { return; } const xsVals = Object.values(xs); const delta = horiz === "l" ? alignToMin - xsVals.reduce((a, b) => Math.min(a, b)) : alignToMax - xsVals.reduce((a, b) => Math.max(a, b)); if (delta) { Object.keys(xs).forEach((key) => { xs[key] += delta; }); } }); }); } function balance(xss, align) { return Object.keys(xss.ul).reduce((obj, v) => { if (align) { obj[v] = xss[align.toLowerCase()][v]; } else { const vXs = Object.values(xss) .map((xs) => xs[v]) .sort((a, b) => a - b); obj[v] = (vXs[1] + vXs[2]) / 2; } return obj; }, {}); } function mergeConflicts(a, b) { const conflicts = {}; Object.entries(a).forEach(([v, ws]) => { ws.forEach((w) => { addConflict(conflicts, v, w); }); }); Object.entries(b).forEach(([v, ws]) => { ws.forEach((w) => { addConflict(conflicts, v, w); }); }); return conflicts; } function positionY(graph, layering, opts) { const conflicts = mergeConflicts(findType1Conflicts(graph, layering), findType2Conflicts(graph, layering)); const xss = {}; let adjustedLayering; ["u", "d"].forEach((vert) => { adjustedLayering = vert === "u" ? layering : [...layering].reverse(); ["l", "r"].forEach((horiz) => { if (horiz === "r") { adjustedLayering = adjustedLayering.map((inner) => { return [...inner].reverse(); }); } const neighborFn = vert === "u" ? "in" : "out"; const { root, align } = verticalAlignment(graph, adjustedLayering, conflicts, neighborFn); const xs = horizontalCompaction(graph, adjustedLayering, root, align, vert === "d", opts); if (horiz === "r") { Object.keys(xs).forEach((v) => { xs[v] = -xs[v]; }); } xss[vert + horiz] = xs; }); }); const smallestHeight = findSmallestHeightAlignment(graph, xss, opts); alignCoordinates(xss, smallestHeight); return balance(xss); } function position(graph, order, opts) { const ys = positionY(graph, order, opts); Object.keys(ys).forEach((v) => { graph[v].node.y = ys[v]; }); const valueY = Object.values(ys); const step = Math.max(nodeWidth(undefined, undefined, opts), (valueY.reduce((a, b) => Math.max(a, b)) - valueY.reduce((a, b) => Math.min(a, b))) / order.length) * opts.layerSpacingFactor; let x = 0; order.forEach((layer) => { layer.forEach((nodeId) => { const node = graph[nodeId].node; node.x = x; }); x += step; }); } function prepareResult(graph) { const nodes = []; const edges = []; Object.values(graph).forEach((graphNode) => { const node = graphNode.node; if (graphNode.virtual) { if (!graph[graphNode.in[0]].virtual) { let to = graphNode.out[0]; if (graph[to].virtual) { while (graph[graph[to].out[0]].virtual) { to = graph[to].out[0]; } } const startNode = graph[graphNode.in[0]].node; const toNode = graph[to].node; if (graph[to].virtual) { const finalNode = graph[graph[to].out[0]].node; if (startNode.y === node.y) { if (toNode.y === finalNode.y) { edges.push({ from: startNode.id, to: finalNode.id }); } else { nodes.push(toNode); edges.push({ from: startNode.id, to: toNode.id, arrows: { to: false } }); edges.push({ from: toNode.id, to: finalNode.id, arrows: { from: false } }); } } else if (toNode.y === finalNode.y) { nodes.push(node); edges.push({ from: startNode.id, to: node.id, arrows: { to: false } }); edges.push({ from: node.id, to: finalNode.id, arrows: { from: false } }); } else { nodes.push(node); nodes.push(toNode); edges.push({ from: startNode.id, to: node.id, arrows: { to: false } }); edges.push({ from: node.id, to: toNode.id, arrows: { from: false, to: false }, }); edges.push({ from: toNode.id, to: finalNode.id, arrows: { from: false } }); } } else if (startNode.y === node.y && node.y === toNode.y) { edges.push({ from: startNode.id, to: toNode.id }); } else { nodes.push(node); edges.push({ from: startNode.id, to: node.id, arrows: { to: false } }); edges.push({ from: node.id, to: toNode.id, arrows: { from: false } }); } } } else { nodes.push(node); graphNode.out.forEach((to) => { if (!graph[to].virtual) { edges.push({ from: node.id, to: graph[to].node.id }); } }); } }); return { nodes, edges }; } export async function layoutGraph({ nodes, edges, options, }) { const opts = resolveLayoutOptions(options); const enormousGraph = nodes.length > opts.enormousGraphNodeThreshold || edges.length > opts.enormousGraphEdgeThreshold; const { graph, layering, levels } = getGraph(nodes, edges); prepareGraph(graph, layering, levels); const order = ordering(graph, layering, enormousGraph); position(graph, order, opts); return prepareResult(graph); }