From 1335e0c438ff10d98b1cac939ee96e0deb63cb92 Mon Sep 17 00:00:00 2001 From: sbosse Date: Mon, 21 Jul 2025 23:41:49 +0200 Subject: [PATCH] Mon 21 Jul 22:43:21 CEST 2025 --- .../stage/mxMedianHybridCrossingReduction.js | 675 ++++++++++++++++++ 1 file changed, 675 insertions(+) create mode 100644 js/ui/mxgraph/src/js/layout/hierarchical/stage/mxMedianHybridCrossingReduction.js diff --git a/js/ui/mxgraph/src/js/layout/hierarchical/stage/mxMedianHybridCrossingReduction.js b/js/ui/mxgraph/src/js/layout/hierarchical/stage/mxMedianHybridCrossingReduction.js new file mode 100644 index 0000000..139079d --- /dev/null +++ b/js/ui/mxgraph/src/js/layout/hierarchical/stage/mxMedianHybridCrossingReduction.js @@ -0,0 +1,675 @@ +/** + * Copyright (c) 2006-2015, JGraph Ltd + * Copyright (c) 2006-2015, Gaudenz Alder + */ +/** + * Class: mxMedianHybridCrossingReduction + * + * Sets the horizontal locations of node and edge dummy nodes on each layer. + * Uses median down and up weighings as well heuristic to straighten edges as + * far as possible. + * + * Constructor: mxMedianHybridCrossingReduction + * + * Creates a coordinate assignment. + * + * Arguments: + * + * intraCellSpacing - the minimum buffer between cells on the same rank + * interRankCellSpacing - the minimum distance between cells on adjacent ranks + * orientation - the position of the root node(s) relative to the graph + * initialX - the leftmost coordinate node placement starts at + */ +function mxMedianHybridCrossingReduction(layout) +{ + this.layout = layout; +}; + +/** + * Extends mxMedianHybridCrossingReduction. + */ +mxMedianHybridCrossingReduction.prototype = new mxHierarchicalLayoutStage(); +mxMedianHybridCrossingReduction.prototype.constructor = mxMedianHybridCrossingReduction; + +/** + * Variable: layout + * + * Reference to the enclosing . + */ +mxMedianHybridCrossingReduction.prototype.layout = null; + +/** + * Variable: maxIterations + * + * The maximum number of iterations to perform whilst reducing edge + * crossings. Default is 24. + */ +mxMedianHybridCrossingReduction.prototype.maxIterations = 24; + +/** + * Variable: nestedBestRanks + * + * Stores each rank as a collection of cells in the best order found for + * each layer so far + */ +mxMedianHybridCrossingReduction.prototype.nestedBestRanks = null; + +/** + * Variable: currentBestCrossings + * + * The total number of crossings found in the best configuration so far + */ +mxMedianHybridCrossingReduction.prototype.currentBestCrossings = 0; + +/** + * Variable: iterationsWithoutImprovement + * + * The total number of crossings found in the best configuration so far + */ +mxMedianHybridCrossingReduction.prototype.iterationsWithoutImprovement = 0; + +/** + * Variable: maxNoImprovementIterations + * + * The total number of crossings found in the best configuration so far + */ +mxMedianHybridCrossingReduction.prototype.maxNoImprovementIterations = 2; + +/** + * Function: execute + * + * Performs a vertex ordering within ranks as described by Gansner et al + * 1993 + */ +mxMedianHybridCrossingReduction.prototype.execute = function(parent) +{ + var model = this.layout.getModel(); + + // Stores initial ordering as being the best one found so far + this.nestedBestRanks = []; + + for (var i = 0; i < model.ranks.length; i++) + { + this.nestedBestRanks[i] = model.ranks[i].slice(); + } + + var iterationsWithoutImprovement = 0; + var currentBestCrossings = this.calculateCrossings(model); + + for (var i = 0; i < this.maxIterations && + iterationsWithoutImprovement < this.maxNoImprovementIterations; i++) + { + this.weightedMedian(i, model); + this.transpose(i, model); + var candidateCrossings = this.calculateCrossings(model); + + if (candidateCrossings < currentBestCrossings) + { + currentBestCrossings = candidateCrossings; + iterationsWithoutImprovement = 0; + + // Store the current rankings as the best ones + for (var j = 0; j < this.nestedBestRanks.length; j++) + { + var rank = model.ranks[j]; + + for (var k = 0; k < rank.length; k++) + { + var cell = rank[k]; + this.nestedBestRanks[j][cell.getGeneralPurposeVariable(j)] = cell; + } + } + } + else + { + // Increase count of iterations where we haven't improved the + // layout + iterationsWithoutImprovement++; + + // Restore the best values to the cells + for (var j = 0; j < this.nestedBestRanks.length; j++) + { + var rank = model.ranks[j]; + + for (var k = 0; k < rank.length; k++) + { + var cell = rank[k]; + cell.setGeneralPurposeVariable(j, k); + } + } + } + + if (currentBestCrossings == 0) + { + // Do nothing further + break; + } + } + + // Store the best rankings but in the model + var ranks = []; + var rankList = []; + + for (var i = 0; i < model.maxRank + 1; i++) + { + rankList[i] = []; + ranks[i] = rankList[i]; + } + + for (var i = 0; i < this.nestedBestRanks.length; i++) + { + for (var j = 0; j < this.nestedBestRanks[i].length; j++) + { + rankList[i].push(this.nestedBestRanks[i][j]); + } + } + + model.ranks = ranks; +}; + + +/** + * Function: calculateCrossings + * + * Calculates the total number of edge crossing in the current graph. + * Returns the current number of edge crossings in the hierarchy graph + * model in the current candidate layout + * + * Parameters: + * + * model - the internal model describing the hierarchy + */ +mxMedianHybridCrossingReduction.prototype.calculateCrossings = function(model) +{ + var numRanks = model.ranks.length; + var totalCrossings = 0; + + for (var i = 1; i < numRanks; i++) + { + totalCrossings += this.calculateRankCrossing(i, model); + } + + return totalCrossings; +}; + +/** + * Function: calculateRankCrossing + * + * Calculates the number of edges crossings between the specified rank and + * the rank below it. Returns the number of edges crossings with the rank + * beneath + * + * Parameters: + * + * i - the topmost rank of the pair ( higher rank value ) + * model - the internal model describing the hierarchy + */ +mxMedianHybridCrossingReduction.prototype.calculateRankCrossing = function(i, model) +{ + var totalCrossings = 0; + var rank = model.ranks[i]; + var previousRank = model.ranks[i - 1]; + + var tmpIndices = []; + + // Iterate over the top rank and fill in the connection information + for (var j = 0; j < rank.length; j++) + { + var node = rank[j]; + var rankPosition = node.getGeneralPurposeVariable(i); + var connectedCells = node.getPreviousLayerConnectedCells(i); + var nodeIndices = []; + + for (var k = 0; k < connectedCells.length; k++) + { + var connectedNode = connectedCells[k]; + var otherCellRankPosition = connectedNode.getGeneralPurposeVariable(i - 1); + nodeIndices.push(otherCellRankPosition); + } + + nodeIndices.sort(function(x, y) { return x - y; }); + tmpIndices[rankPosition] = nodeIndices; + } + + var indices = []; + + for (var j = 0; j < tmpIndices.length; j++) + { + indices = indices.concat(tmpIndices[j]); + } + + var firstIndex = 1; + + while (firstIndex < previousRank.length) + { + firstIndex <<= 1; + } + + var treeSize = 2 * firstIndex - 1; + firstIndex -= 1; + + var tree = []; + + for (var j = 0; j < treeSize; ++j) + { + tree[j] = 0; + } + + for (var j = 0; j < indices.length; j++) + { + var index = indices[j]; + var treeIndex = index + firstIndex; + ++tree[treeIndex]; + + while (treeIndex > 0) + { + if (treeIndex % 2) + { + totalCrossings += tree[treeIndex + 1]; + } + + treeIndex = (treeIndex - 1) >> 1; + ++tree[treeIndex]; + } + } + + return totalCrossings; +}; + +/** + * Function: transpose + * + * Takes each possible adjacent cell pair on each rank and checks if + * swapping them around reduces the number of crossing + * + * Parameters: + * + * mainLoopIteration - the iteration number of the main loop + * model - the internal model describing the hierarchy + */ +mxMedianHybridCrossingReduction.prototype.transpose = function(mainLoopIteration, model) +{ + var improved = true; + + // Track the number of iterations in case of looping + var count = 0; + var maxCount = 10; + while (improved && count++ < maxCount) + { + // On certain iterations allow allow swapping of cell pairs with + // equal edge crossings switched or not switched. This help to + // nudge a stuck layout into a lower crossing total. + var nudge = mainLoopIteration % 2 == 1 && count % 2 == 1; + improved = false; + + for (var i = 0; i < model.ranks.length; i++) + { + var rank = model.ranks[i]; + var orderedCells = []; + + for (var j = 0; j < rank.length; j++) + { + var cell = rank[j]; + var tempRank = cell.getGeneralPurposeVariable(i); + + // FIXME: Workaround to avoid negative tempRanks + if (tempRank < 0) + { + tempRank = j; + } + orderedCells[tempRank] = cell; + } + + var leftCellAboveConnections = null; + var leftCellBelowConnections = null; + var rightCellAboveConnections = null; + var rightCellBelowConnections = null; + + var leftAbovePositions = null; + var leftBelowPositions = null; + var rightAbovePositions = null; + var rightBelowPositions = null; + + var leftCell = null; + var rightCell = null; + + for (var j = 0; j < (rank.length - 1); j++) + { + // For each intra-rank adjacent pair of cells + // see if swapping them around would reduce the + // number of edges crossing they cause in total + // On every cell pair except the first on each rank, we + // can save processing using the previous values for the + // right cell on the new left cell + if (j == 0) + { + leftCell = orderedCells[j]; + leftCellAboveConnections = leftCell + .getNextLayerConnectedCells(i); + leftCellBelowConnections = leftCell + .getPreviousLayerConnectedCells(i); + leftAbovePositions = []; + leftBelowPositions = []; + + for (var k = 0; k < leftCellAboveConnections.length; k++) + { + leftAbovePositions[k] = leftCellAboveConnections[k].getGeneralPurposeVariable(i + 1); + } + + for (var k = 0; k < leftCellBelowConnections.length; k++) + { + leftBelowPositions[k] = leftCellBelowConnections[k].getGeneralPurposeVariable(i - 1); + } + } + else + { + leftCellAboveConnections = rightCellAboveConnections; + leftCellBelowConnections = rightCellBelowConnections; + leftAbovePositions = rightAbovePositions; + leftBelowPositions = rightBelowPositions; + leftCell = rightCell; + } + + rightCell = orderedCells[j + 1]; + rightCellAboveConnections = rightCell + .getNextLayerConnectedCells(i); + rightCellBelowConnections = rightCell + .getPreviousLayerConnectedCells(i); + + rightAbovePositions = []; + rightBelowPositions = []; + + for (var k = 0; k < rightCellAboveConnections.length; k++) + { + rightAbovePositions[k] = rightCellAboveConnections[k].getGeneralPurposeVariable(i + 1); + } + + for (var k = 0; k < rightCellBelowConnections.length; k++) + { + rightBelowPositions[k] = rightCellBelowConnections[k].getGeneralPurposeVariable(i - 1); + } + + var totalCurrentCrossings = 0; + var totalSwitchedCrossings = 0; + + for (var k = 0; k < leftAbovePositions.length; k++) + { + for (var ik = 0; ik < rightAbovePositions.length; ik++) + { + if (leftAbovePositions[k] > rightAbovePositions[ik]) + { + totalCurrentCrossings++; + } + + if (leftAbovePositions[k] < rightAbovePositions[ik]) + { + totalSwitchedCrossings++; + } + } + } + + for (var k = 0; k < leftBelowPositions.length; k++) + { + for (var ik = 0; ik < rightBelowPositions.length; ik++) + { + if (leftBelowPositions[k] > rightBelowPositions[ik]) + { + totalCurrentCrossings++; + } + + if (leftBelowPositions[k] < rightBelowPositions[ik]) + { + totalSwitchedCrossings++; + } + } + } + + if ((totalSwitchedCrossings < totalCurrentCrossings) || + (totalSwitchedCrossings == totalCurrentCrossings && + nudge)) + { + var temp = leftCell.getGeneralPurposeVariable(i); + leftCell.setGeneralPurposeVariable(i, rightCell + .getGeneralPurposeVariable(i)); + rightCell.setGeneralPurposeVariable(i, temp); + + // With this pair exchanged we have to switch all of + // values for the left cell to the right cell so the + // next iteration for this rank uses it as the left + // cell again + rightCellAboveConnections = leftCellAboveConnections; + rightCellBelowConnections = leftCellBelowConnections; + rightAbovePositions = leftAbovePositions; + rightBelowPositions = leftBelowPositions; + rightCell = leftCell; + + if (!nudge) + { + // Don't count nudges as improvement or we'll end + // up stuck in two combinations and not finishing + // as early as we should + improved = true; + } + } + } + } + } +}; + +/** + * Function: weightedMedian + * + * Sweeps up or down the layout attempting to minimise the median placement + * of connected cells on adjacent ranks + * + * Parameters: + * + * iteration - the iteration number of the main loop + * model - the internal model describing the hierarchy + */ +mxMedianHybridCrossingReduction.prototype.weightedMedian = function(iteration, model) +{ + // Reverse sweep direction each time through this method + var downwardSweep = (iteration % 2 == 0); + if (downwardSweep) + { + for (var j = model.maxRank - 1; j >= 0; j--) + { + this.medianRank(j, downwardSweep); + } + } + else + { + for (var j = 1; j < model.maxRank; j++) + { + this.medianRank(j, downwardSweep); + } + } +}; + +/** + * Function: medianRank + * + * Attempts to minimise the median placement of connected cells on this rank + * and one of the adjacent ranks + * + * Parameters: + * + * rankValue - the layer number of this rank + * downwardSweep - whether or not this is a downward sweep through the graph + */ +mxMedianHybridCrossingReduction.prototype.medianRank = function(rankValue, downwardSweep) +{ + var numCellsForRank = this.nestedBestRanks[rankValue].length; + var medianValues = []; + var reservedPositions = []; + + for (var i = 0; i < numCellsForRank; i++) + { + var cell = this.nestedBestRanks[rankValue][i]; + var sorterEntry = new MedianCellSorter(); + sorterEntry.cell = cell; + + // Flip whether or not equal medians are flipped on up and down + // sweeps + // TODO re-implement some kind of nudge + // medianValues[i].nudge = !downwardSweep; + var nextLevelConnectedCells; + + if (downwardSweep) + { + nextLevelConnectedCells = cell + .getNextLayerConnectedCells(rankValue); + } + else + { + nextLevelConnectedCells = cell + .getPreviousLayerConnectedCells(rankValue); + } + + var nextRankValue; + + if (downwardSweep) + { + nextRankValue = rankValue + 1; + } + else + { + nextRankValue = rankValue - 1; + } + + if (nextLevelConnectedCells != null + && nextLevelConnectedCells.length != 0) + { + sorterEntry.medianValue = this.medianValue( + nextLevelConnectedCells, nextRankValue); + medianValues.push(sorterEntry); + } + else + { + // Nodes with no adjacent vertices are flagged in the reserved array + // to indicate they should be left in their current position. + reservedPositions[cell.getGeneralPurposeVariable(rankValue)] = true; + } + } + + medianValues.sort(MedianCellSorter.prototype.compare); + + // Set the new position of each node within the rank using + // its temp variable + for (var i = 0; i < numCellsForRank; i++) + { + if (reservedPositions[i] == null) + { + var cell = medianValues.shift().cell; + cell.setGeneralPurposeVariable(rankValue, i); + } + } +}; + +/** + * Function: medianValue + * + * Calculates the median rank order positioning for the specified cell using + * the connected cells on the specified rank. Returns the median rank + * ordering value of the connected cells + * + * Parameters: + * + * connectedCells - the cells on the specified rank connected to the + * specified cell + * rankValue - the rank that the connected cell lie upon + */ +mxMedianHybridCrossingReduction.prototype.medianValue = function(connectedCells, rankValue) +{ + var medianValues = []; + var arrayCount = 0; + + for (var i = 0; i < connectedCells.length; i++) + { + var cell = connectedCells[i]; + medianValues[arrayCount++] = cell.getGeneralPurposeVariable(rankValue); + } + + // Sort() sorts lexicographically by default (i.e. 11 before 9) so force + // numerical order sort + medianValues.sort(function(a,b){return a - b;}); + + if (arrayCount % 2 == 1) + { + // For odd numbers of adjacent vertices return the median + return medianValues[Math.floor(arrayCount / 2)]; + } + else if (arrayCount == 2) + { + return ((medianValues[0] + medianValues[1]) / 2.0); + } + else + { + var medianPoint = arrayCount / 2; + var leftMedian = medianValues[medianPoint - 1] - medianValues[0]; + var rightMedian = medianValues[arrayCount - 1] + - medianValues[medianPoint]; + + return (medianValues[medianPoint - 1] * rightMedian + medianValues[medianPoint] + * leftMedian) + / (leftMedian + rightMedian); + } +}; + +/** + * Class: MedianCellSorter + * + * A utility class used to track cells whilst sorting occurs on the median + * values. Does not violate (x.compareTo(y)==0) == (x.equals(y)) + * + * Constructor: MedianCellSorter + * + * Constructs a new median cell sorter. + */ +function MedianCellSorter() +{ + // empty +}; + +/** + * Variable: medianValue + * + * The weighted value of the cell stored. + */ +MedianCellSorter.prototype.medianValue = 0; + +/** + * Variable: cell + * + * The cell whose median value is being calculated + */ +MedianCellSorter.prototype.cell = false; + +/** + * Function: compare + * + * Compares two MedianCellSorters. + */ +MedianCellSorter.prototype.compare = function(a, b) +{ + if (a != null && b != null) + { + if (b.medianValue > a.medianValue) + { + return -1; + } + else if (b.medianValue < a.medianValue) + { + return 1; + } + else + { + return 0; + } + } + else + { + return 0; + } +};