import _ from 'lodash' import { Graph } from 'graphlibrary' import util from '../util' /* * This module provides coordinate assignment based on Brandes and Köpf, "Fast * and Simple Horizontal Coordinate Assignment." */ /* * Marks all edges in the graph with a type-1 conflict with the "type1Conflict" * property. A type-1 conflict is one where a non-inner segment crosses an * inner segment. An inner segment is an edge with both incident nodes marked * with the "dummy" property. * * This algorithm scans layer by layer, starting with the second, for type-1 * conflicts between the current layer and the previous layer. For each layer * it scans the nodes from left to right until it reaches one that is incident * on an inner segment. It then scans predecessors to determine if they have * edges that cross that inner segment. At the end a final scan is done for all * nodes on the current rank to see if they cross the last visited inner * segment. * * This algorithm (safely) assumes that a dummy node will only be incident on a * single node in the layers being scanned. */ function findType1Conflicts (g, layering) { const conflicts = {} function visitLayer (prevLayer, layer) { // last visited node in the previous layer that is incident on an inner // segment. let k0 = 0 // Tracks the last node in this layer scanned for crossings with a type-1 // segment. let scanPos = 0 const prevLayerLength = prevLayer.length const lastNode = _.last(layer) _.forEach(layer, function (v, i) { const w = findOtherInnerSegmentNode(g, v) const k1 = w ? g.node(w).order : prevLayerLength if (w || v === lastNode) { _.forEach(layer.slice(scanPos, i + 1), function (scanNode) { _.forEach(g.predecessors(scanNode), function (u) { const uLabel = g.node(u) const uPos = uLabel.order if ((uPos < k0 || k1 < uPos) && !(uLabel.dummy && g.node(scanNode).dummy)) { addConflict(conflicts, u, scanNode) } }) }) scanPos = i + 1 k0 = k1 } }) return layer } _.reduce(layering, visitLayer) return conflicts } function findType2Conflicts (g, layering) { const conflicts = {} function scan (south, southPos, southEnd, prevNorthBorder, nextNorthBorder) { let v _.forEach(_.range(southPos, southEnd), function (i) { v = south[i] if (g.node(v).dummy) { _.forEach(g.predecessors(v), function (u) { const uNode = g.node(u) if (uNode.dummy && (uNode.order < prevNorthBorder || uNode.order > nextNorthBorder)) { addConflict(conflicts, u, v) } }) } }) } function visitLayer (north, south) { let prevNorthPos = -1 let nextNorthPos let southPos = 0 _.forEach(south, function (v, southLookahead) { if (g.node(v).dummy === 'border') { const predecessors = g.predecessors(v) if (predecessors.length) { nextNorthPos = g.node(predecessors[0]).order scan(south, southPos, southLookahead, prevNorthPos, nextNorthPos) southPos = southLookahead prevNorthPos = nextNorthPos } } scan(south, southPos, south.length, nextNorthPos, north.length) }) return south } _.reduce(layering, visitLayer) return conflicts } function findOtherInnerSegmentNode (g, v) { if (g.node(v).dummy) { return _.find(g.predecessors(v), function (u) { return g.node(u).dummy }) } } function addConflict (conflicts, v, w) { if (v > w) { const tmp = v v = w w = tmp } let conflictsV = conflicts[v] if (!conflictsV) { conflicts[v] = conflictsV = {} } conflictsV[w] = true } function hasConflict (conflicts, v, w) { if (v > w) { const tmp = v v = w w = tmp } return _.has(conflicts[v], w) } /* * Try to align nodes into vertical "blocks" where possible. This algorithm * attempts to align a node with one of its median neighbors. If the edge * connecting a neighbor is a type-1 conflict then we ignore that possibility. * If a previous node has already formed a block with a node after the node * we're trying to form a block with, we also ignore that possibility - our * blocks would be split in that scenario. */ function verticalAlignment (g, layering, conflicts, neighborFn) { const root = {} const align = {} const pos = {} // We cache the position here based on the layering because the graph and // layering may be out of sync. The layering matrix is manipulated to // generate different extreme alignments. _.forEach(layering, function (layer) { _.forEach(layer, function (v, order) { root[v] = v align[v] = v pos[v] = order }) }) _.forEach(layering, function (layer) { let prevIdx = -1 _.forEach(layer, function (v) { let ws = neighborFn(v) if (ws.length) { ws = _.sortBy(ws, function (w) { return pos[w] }) 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: root, align: align } } function horizontalCompaction (g, layering, root, align, reverseSep) { // This portion of the algorithm differs from BK due to a number of problems. // Instead of their algorithm we construct a new block graph and do two // sweeps. The first sweep places blocks with the smallest possible // coordinates. The second sweep removes unused space by moving blocks to the // greatest coordinates without violating separation. const xs = {} const blockG = buildBlockGraph(g, layering, root, reverseSep) // First pass, assign smallest coordinates via DFS const visited = {} function pass1 (v) { if (!_.has(visited, v)) { visited[v] = true xs[v] = _.reduce(blockG.inEdges(v), function (max, e) { pass1(e.v) return Math.max(max, xs[e.v] + blockG.edge(e)) }, 0) } } _.forEach(blockG.nodes(), pass1) const borderType = reverseSep ? 'borderLeft' : 'borderRight' function pass2 (v) { if (visited[v] !== 2) { visited[v]++ const node = g.node(v) const min = _.reduce(blockG.outEdges(v), function (min, e) { pass2(e.w) return Math.min(min, xs[e.w] - blockG.edge(e)) }, Number.POSITIVE_INFINITY) if (min !== Number.POSITIVE_INFINITY && node.borderType !== borderType) { xs[v] = Math.max(xs[v], min) } } } _.forEach(blockG.nodes(), pass2) // Assign x coordinates to all nodes _.forEach(align, function (v) { xs[v] = xs[root[v]] }) return xs } function buildBlockGraph (g, layering, root, reverseSep) { const blockGraph = new Graph() const graphLabel = g.graph() const sepFn = sep(graphLabel.nodesep, graphLabel.edgesep, reverseSep) _.forEach(layering, function (layer) { let u _.forEach(layer, function (v) { const vRoot = root[v] blockGraph.setNode(vRoot) if (u) { const uRoot = root[u] const prevMax = blockGraph.edge(uRoot, vRoot) blockGraph.setEdge(uRoot, vRoot, Math.max(sepFn(g, v, u), prevMax || 0)) } u = v }) }) return blockGraph } /* * Returns the alignment that has the smallest width of the given alignments. */ function findSmallestWidthAlignment (g, xss) { return _.minBy(_.values(xss), function (xs) { const min = (_.minBy(_.toPairs(xs), (pair) => pair[1] - width(g, pair[0]) / 2) || ['k', 0])[1] const max = (_.maxBy(_.toPairs(xs), (pair) => pair[1] + width(g, pair[0]) / 2) || ['k', 0])[1] return max - min }) } /* * Align the coordinates of each of the layout alignments such that * left-biased alignments have their minimum coordinate at the same point as * the minimum coordinate of the smallest width alignment and right-biased * alignments have their maximum coordinate at the same point as the maximum * coordinate of the smallest width alignment. */ function alignCoordinates (xss, alignTo) { const alignToVals = _.values(alignTo) const alignToMin = _.min(alignToVals) const alignToMax = _.max(alignToVals) _.forEach(['u', 'd'], function (vert) { _.forEach(['l', 'r'], function (horiz) { const alignment = vert + horiz const xs = xss[alignment] if (xs === alignTo) { return } const xsVals = _.values(xs) const delta = horiz === 'l' ? alignToMin - _.min(xsVals) : alignToMax - _.max(xsVals) if (delta) { xss[alignment] = _.mapValues(xs, function (x) { return x + delta }) } }) }) } function balance (xss, align) { return _.mapValues(xss.ul, function (ignore, v) { if (align) { return xss[align.toLowerCase()][v] } else { const xs = _.sortBy(_.map(xss, v)) return (xs[1] + xs[2]) / 2 } }) } export function positionX (g) { const layering = util.buildLayerMatrix(g) const conflicts = _.merge(findType1Conflicts(g, layering), findType2Conflicts(g, layering)) const xss = {} let adjustedLayering _.forEach(['u', 'd'], function (vert) { adjustedLayering = vert === 'u' ? layering : _.values(layering).reverse() _.forEach(['l', 'r'], function (horiz) { if (horiz === 'r') { adjustedLayering = _.map(adjustedLayering, function (inner) { return _.values(inner).reverse() }) } const neighborFn = _.bind(vert === 'u' ? g.predecessors : g.successors, g) const align = verticalAlignment(g, adjustedLayering, conflicts, neighborFn) let xs = horizontalCompaction(g, adjustedLayering, align.root, align.align, horiz === 'r') if (horiz === 'r') { xs = _.mapValues(xs, function (x) { return -x }) } xss[vert + horiz] = xs }) }) const smallestWidth = findSmallestWidthAlignment(g, xss) alignCoordinates(xss, smallestWidth) return balance(xss, g.graph().align) } function sep (nodeSep, edgeSep, reverseSep) { return function (g, v, w) { const vLabel = g.node(v) const wLabel = g.node(w) let sum = 0 let delta sum += vLabel.width / 2 if (_.has(vLabel, 'labelpos')) { switch (vLabel.labelpos.toLowerCase()) { case 'l': delta = -vLabel.width / 2; break case 'r': delta = vLabel.width / 2; break } } if (delta) { sum += reverseSep ? delta : -delta } delta = 0 sum += (vLabel.dummy ? edgeSep : nodeSep) / 2 sum += (wLabel.dummy ? edgeSep : nodeSep) / 2 sum += wLabel.width / 2 if (_.has(wLabel, 'labelpos')) { switch (wLabel.labelpos.toLowerCase()) { case 'l': delta = wLabel.width / 2; break case 'r': delta = -wLabel.width / 2; break } } if (delta) { sum += reverseSep ? delta : -delta } delta = 0 return sum } } function width (g, v) { return g.node(v).width } export default { positionX: positionX, findType1Conflicts: findType1Conflicts, findType2Conflicts: findType2Conflicts, addConflict: addConflict, hasConflict: hasConflict, verticalAlignment: verticalAlignment, horizontalCompaction: horizontalCompaction, alignCoordinates: alignCoordinates, findSmallestWidthAlignment: findSmallestWidthAlignment, balance: balance }