Browse Source

documentation + rework 2 (coordinate assignment update, removed grid system, cleanup)

icestormikk 1 month ago
parent
commit
b7205a5bd7

+ 2 - 4
src/index.ts

@@ -13,7 +13,6 @@ import LayerAssignmentStep from "./v1/optimizer/steps/LayerAssignmentStep.js";
 import NodeOrderingStep from "./v1/optimizer/steps/NodeOrderingStep.js";
 import SiguiyamaAlgorithm from "./v1/optimizer/siguiyama/SiguiyamaAlgorithm.js";
 import { SiguiyamaContext } from "./v1/optimizer/siguiyama/SiguiyamaContext.js";
-import CleanupStep from "./v1/optimizer/steps/CleanupStep.js";
 import EdgeRoutingStep from "./v1/optimizer/steps/EdgeRoutingStep.js";
 
 export class BPMNOptimizer {
@@ -43,9 +42,8 @@ const algorithm = new SiguiyamaAlgorithm()
 	.addStep(new CycleRemoveStep())
 	.addStep(new LayerAssignmentStep())
 	.addStep(new NodeOrderingStep())
-	.addStep(new CoordinateAssignmentStep(100, 50))
-	.addStep(new EdgeRoutingStep())
-	.addStep(new CleanupStep());
+	.addStep(new CoordinateAssignmentStep(60, 100))
+	.addStep(new EdgeRoutingStep());
 const optimizer = new BPMNOptimizer().setOptimizationAlgorithm(algorithm);
 
 const data = readFileSync("./data/graph.json").toString();

+ 8 - 0
src/v1/graph/edge/DummyEdge.ts

@@ -0,0 +1,8 @@
+import Edge from "./Edge.js";
+import Node from "../node/Node.js";
+
+export default class DummyEdge<TNode extends Node> extends Edge<TNode> {
+	public constructor(from: TNode, to: TNode, waypoints: { x: number; y: number }[], id?: string) {
+		super(from, to, waypoints, id);
+	}
+}

+ 0 - 4
src/v1/graph/layering/Layer.ts

@@ -20,8 +20,4 @@ export default class Layer<TNode extends Node> {
 	public addNode(node: TNode) : this {
 		return this._nodes.push(node), this;
 	}
-
-	public getWidth(): number {
-		return this._nodes.length;
-	}
 }

+ 2 - 4
src/v1/io/serialize/json/DefaultJsonSerializer.ts

@@ -2,7 +2,6 @@ import Edge from "../../../graph/edge/Edge.js";
 import Graph from "../../../graph/Graph.js";
 import Node from "../../../graph/node/Node.js";
 import { JsonSerializer } from "./JsonSerializer.js";
-import DummyNode from "../../../graph/node/DummyNode.js";
 
 type JsonNode = { x: number, y: number, width: number, height: number, type: string, id: string };
 
@@ -24,10 +23,9 @@ export default class DefaultJsonSerializer implements JsonSerializer<Graph<Node,
 
 		const nodes: JsonNode[] = [];
 		data.getNodes().forEach((node) => {
-			if(!(node instanceof DummyNode))
-				nodes.push({ x: node.getX(), y: node.getY(), width: node.getWidth(), height: node.getHeight(), type: node.getType(), id: node.getId() });
+			nodes.push({ x: node.getX(), y: node.getY(), width: node.getWidth(), height: node.getHeight(), type: node.getType(), id: node.getId() });
 		})
 
-		return JSON.stringify({ nodes, edges }, null, 2);
+		return JSON.stringify({ nodes, edges }, null, 4);
 	}
 }

+ 1 - 1
src/v1/optimizer/siguiyama/SiguiyamaAlgorithm.ts

@@ -29,7 +29,7 @@ export default class SiguiyamaAlgorithm {
 	 * @param graph Граф, который нужно послойно отрисовать
 	 */
 	public run(graph: Graph<Node, Edge<Node>>): SiguiyamaContext {
-		const context: SiguiyamaContext = { graph, edgeSubdivisions: new Map(), config: {} };
+		const context: SiguiyamaContext = { graph, config: {} };
 
 		this._steps.forEach((step) => {
 			step.onBeforeRun();

+ 0 - 13
src/v1/optimizer/siguiyama/SiguiyamaContext.ts

@@ -2,29 +2,16 @@ import Edge from "../../graph/edge/Edge.js"
 import Layering from "../../graph/layering/Layering.js";
 import Node from "../../graph/node/Node.js"
 import { AlgorithmContext } from "../AlgorithmContext.js"
-import Grid from "./grid/Grid.js";
-import DummyNode from "../../graph/node/DummyNode.js";
 
 /**
  * Набор рёбер обратной связи - рёбра, которые были инвертированы на этапе удаления циклов и должны быть восстановлены на этапе назначения координат.
  */
 export type FeedbackSet = Edge<Node>[];
 
-/**
- * Информация о разбиении длинного ребра на сегменты с dummy-вершинами. Содержит ссылку на исходное ребро и, при наличии, ссылки на новые рёбра, образованные в результате разбиения.
- */
-export type EdgeSubdivision<TNode extends Node> = {
-    originalEdge: Edge<TNode>;
-    segments: Edge<TNode>[];
-    dummies: DummyNode[];
-};
-
 /**
  * Контекст, передаваемый между шагами алгоритма Сигуямы. Содержит всю необходимую информацию о графе, его разбиении на слои, сетке и рёбрах обратной связи, которая может быть использована и модифицирована на каждом этапе оптимизации.
  */
 export type SiguiyamaContext = AlgorithmContext & Partial<{
 	feedbackSet: FeedbackSet,
 	layering: Layering<Node, Edge<Node>>,
-	grid: Grid<Node>,
-    edgeSubdivisions: Map<string, EdgeSubdivision<Node>>
 }>

+ 0 - 96
src/v1/optimizer/siguiyama/grid/Grid.ts

@@ -1,96 +0,0 @@
-import Node from "../../../graph/node/Node.js";
-
-export default class Grid<TNode extends Node> {
-	private readonly _cells: Map<string, TNode> = new Map();
-	private _rows: number = 0;
-	private _cols: number = 0;
-
-	private static key(row: number, col: number): string {
-		return `${row}:${col}`;
-	}
-
-	public set(row: number, col: number, node: TNode): void {
-		this._cells.set(Grid.key(row, col), node);
-		this._rows = Math.max(this._rows, row + 1);
-		this._cols = Math.max(this._cols, col + 1);
-	}
-
-	public get(row: number, col: number): TNode | null {
-		return this._cells.get(Grid.key(row, col)) ?? null;
-	}
-
-	public getCoordinates(nodeId: TNode): { row: number, col: number } | null {
-		for(const [key, id] of this._cells) {
-			if(id === nodeId) {
-				const [row, col] = key.split(":").map(Number);
-				return { row: row!, col: col! };
-			}
-		}
-
-		return null;
-	}
-
-	public getColumnWidth(column: number) : number {
-		let width = 0;
-
-		for(let i = 0; i < this.getRows(); i++) {
-			const node = this._cells.get(Grid.key(i, column));
-			if(!node)
-				continue;
-
-			width = Math.max(node.getWidth(), width);
-		}
-
-		return width;
-	}
-
-	public getColumnHeight(column: number) : number {
-		let height = 0;
-
-		for(let i = 0; i < this.getRows(); i++) {
-			const node = this._cells.get(Grid.key(i, column));
-			if(!node)
-				continue;
-
-			height = Math.max(node.getHeight(), height);
-		}
-
-		return height;
-	}
-
-	public getRowWidth(row: number) : number {
-		let width = 0;
-
-		for(let i = 0; i < this.getCols(); i++) {
-			const node = this._cells.get(Grid.key(row, i));
-			if(!node)
-				continue;
-
-			width = Math.max(node.getWidth(), width);
-		}
-
-		return width;
-	}
-
-	public getRowHeight(row: number) : number {
-		let height = 0;
-
-		for(let i = 0; i < this.getCols(); i++) {
-			const node = this._cells.get(Grid.key(row, i));
-			if(!node)
-				continue;
-
-			height = Math.max(node.getHeight(), height);
-		}
-
-		return height;
-	}
-
-	public getRows(): number {
-		return this._rows; 
-	}
-
-	public getCols(): number {
-		return this._cols; 
-	}
-}

+ 0 - 50
src/v1/optimizer/steps/CleanupStep.ts

@@ -1,50 +0,0 @@
-import AlgorithmStep from "../AlgorithmStep.js";
-import {EdgeSubdivision, FeedbackSet, SiguiyamaContext} from "../siguiyama/SiguiyamaContext.js";
-import CleanupStepError from "../../errors/optimizer/CleanupStepError.js";
-import Graph from "../../graph/Graph.js";
-import Node from "../../graph/node/Node.js";
-import Edge from "../../graph/edge/Edge.js";
-
-export default class CleanupStep extends AlgorithmStep<SiguiyamaContext> {
-	public constructor() {
-		super(CleanupStep.name)
-	}
-
-	public run(context: SiguiyamaContext): void {
-		const { graph, feedbackSet, edgeSubdivisions } = context;
-
-		if(!graph)
-			throw new CleanupStepError("Graph is undefined");
-		if(!edgeSubdivisions)
-			throw new CleanupStepError("Edge subdivision is undefined");
-		if(!feedbackSet)
-			throw new CleanupStepError("Feedback is undefined")
-
-		for(const subdivision of edgeSubdivisions.values())
-			this.restoreSubdivision(graph, subdivision, feedbackSet);
-
-		for(const edge of feedbackSet) {
-			const from = edge.getFrom(), to = edge.getTo();
-			edge.setFrom(to).setTo(from);
-		}
-	}
-
-	private restoreSubdivision(graph: Graph<Node, Edge<Node>>, subdivision: EdgeSubdivision<Node>, feedbackSet: FeedbackSet): void {
-		const { originalEdge, segments, dummies } = subdivision;
-
-		const waypoints = dummies.map((d) => ({ x: d.getX(), y: d.getY() }));
-
-		if(feedbackSet.includes(originalEdge))
-			waypoints.reverse();
-
-		originalEdge.setWaypoints(waypoints);
-
-		for(const segment of segments)
-			graph.removeEdge(segment.getId());
-
-		for(const dummy of dummies)
-			graph.removeNode(dummy.getId());
-
-		graph.addEdge(originalEdge);
-	}
-}

+ 64 - 89
src/v1/optimizer/steps/CoordinateAssignmentStep.ts

@@ -1,107 +1,82 @@
+import AlgorithmStep from "../AlgorithmStep.js";
+import { SiguiyamaContext } from "../siguiyama/SiguiyamaContext.js";
+import EdgeRoutingStepError from "../../errors/optimizer/EdgeRoutingStepError.js";
 import Edge from "../../graph/edge/Edge.js";
-import Graph from "../../graph/Graph.js";
 import Layering from "../../graph/layering/Layering.js";
 import Node from "../../graph/node/Node.js";
-import AlgorithmStep from "../AlgorithmStep.js";
-import Grid from "../siguiyama/grid/Grid.js";
-import { FeedbackSet, SiguiyamaContext } from "../siguiyama/SiguiyamaContext.js";
 
+/**
+ * Шаг назначения координат вершинам
+ * Размещает вершины в двумерной плоскости по колонкам (слоям) и строкам (позициям в слое),
+ * учитывая размеры вершин, вертикальный отступ между строками и горизонтальный отступ между слоями.
+ */
 export default class CoordinateAssignmentStep extends AlgorithmStep<SiguiyamaContext> {
+	/**
+	 * Отступ между вершинами по вертикали внутри одной колонки.
+	 */
 	private readonly _layerGap: number;
-	private readonly _nodeGap: number;
 
-	public constructor(layerGap: number, nodeGap: number) {
+	/**
+	 * Отступ между колонками (слоями) по горизонтали.
+	 */
+	private readonly _padding: number;
+
+	/**
+	 * @param layerGap Вертикальный отступ между вершинами в колонке.
+	 * @param padding Горизонтальный отступ между слоями.
+	 * @throws {EdgeRoutingStepError} Если передан отрицательный `layerGap` или `padding`.
+	 */
+	public constructor(layerGap: number = 100, padding: number = 60) {
+		if(layerGap < 0)
+			throw new EdgeRoutingStepError("Layer Gap must be greater than 0");
+		if(padding < 0)
+			throw new EdgeRoutingStepError("Padding must be greater than 0");
+
 		super(CoordinateAssignmentStep.name);
+
 		this._layerGap = layerGap;
-		this._nodeGap = nodeGap;
+		this._padding = padding;
 	}
 
 	public run(context: SiguiyamaContext): void {
-		const { graph, layering, feedbackSet } = context;
+		const { layering } = context;
 
-		if(!graph)
-			throw new Error("Source graph was not found!");
 		if(!layering)
 			throw new Error("Layering of graph was not found!");
-		if(!feedbackSet)
-			throw new Error("Feedback set was not found!");
 
-		// const grid = this.buildGrid(layering);
-		// context.grid = grid;
-		//
-		// this.assignCoordinatesFromGrid(graph, grid);
+		this.assignCoordinates(layering);
 	}
 
-	// private buildGrid(layering: Layering<Node, Edge<Node>>): Grid<Node> {
-	// 	const grid = new Grid<Node>();
-	// 	const layers = layering.getLayers();
-	//
-	// 	for(let col = layers.length - 1; col >= 0; col--) {
-	// 		const layer = layers[col]!;
-	//
-	// 		for(let row = 0; row < layer.nodes.length; row++)
-	// 			grid.set(row, col, layer.nodes[row]!);
-	// 	}
-	//
-	// 	return grid;
-	// }
-	//
-	// private assignCoordinatesFromGrid(graph: Graph<Node, Edge<Node>>, grid: Grid<Node>): void {
-	// 	const PADDING = 60;
-	//
-	// 	const colWidths = this.computeColWidths(grid);
-	// 	const rowHeights = this.computeRowHeights(grid);
-	//
-	// 	const colX = this.computeOffsets(colWidths, this._nodeGap, PADDING);
-	// 	const rowY = this.computeOffsets(rowHeights, this._layerGap, PADDING);
-	//
-	// 	for(let col = 0; col < grid.getCols(); col++) {
-	// 		for(let row = 0; row < grid.getRows(); row++) {
-	// 			const node = grid.get(row, col);
-	// 			if(!node)
-	// 				continue;
-	//
-	// 			node.setX(colX[col]! + (colWidths[col]! - node.getWidth()) / 2);
-	// 			node.setY(rowY[row]! + (rowHeights[row]! - node.getHeight()) / 2);
-	// 		}
-	// 	}
-	// }
-	//
-	// private computeColWidths(grid: Grid<Node>): number[] {
-	// 	const widths: number[] = new Array(grid.getCols()).fill(0);
-	//
-	// 	for(let col = 0; col < grid.getCols(); col++)
-	// 		for(let row = 0; row < grid.getRows(); row++) {
-	// 			const node = grid.get(row, col);
-	// 			if(!node)
-	// 				continue;
-	//
-	// 			widths[col] = Math.max(widths[col]!, node.getWidth());
-	// 		}
-	//
-	// 	return widths;
-	// }
-	//
-	// private computeRowHeights(grid: Grid<Node>): number[] {
-	// 	const heights: number[] = new Array(grid.getRows()).fill(0);
-	//
-	// 	for(let row = 0; row < grid.getRows(); row++)
-	// 		for(let col = 0; col < grid.getCols(); col++) {
-	// 			const node = grid.get(row, col);
-	// 			if(!node) continue;
-	//
-	// 			heights[row] = Math.max(heights[row]!, node.getHeight());
-	// 		}
-	//
-	// 	return heights;
-	// }
-	//
-	// private computeOffsets(sizes: number[], gap: number, padding: number): number[] {
-	// 	const offsets: number[] = [padding];
-	//
-	// 	for(let i = 1; i < sizes.length; i++)
-	// 		offsets.push(offsets[i - 1]! + sizes[i - 1]! + gap);
-	//
-	// 	return offsets;
-	// }
-}
+	/**
+	 * Назначение координат всем вершинам графа.
+	 *
+	 * Алгоритм:
+	 * - слои обходятся справа налево (`toReversed()`),
+	 * - для каждого слоя вычисляется ширина колонки как максимальная ширина вершины в слое,
+	 * - для каждой позиции `i` внутри слоя вычисляется высота строки как максимум по всем слоям в этой позиции `i`,
+	 * - вершина размещается по центру своей ячейки.
+	 * @param layering Слоистая укладка графа.
+	 */
+	private assignCoordinates(layering: Layering<Node, Edge<Node>>) : void {
+		const layers = layering.getLayers().toReversed();
+
+		let xOffset = 0.0;
+		for(const layer of layers) {
+			const nodes = layer.getNodes();
+
+			const width = Math.max(...nodes.map(node => node.getWidth()));
+
+			let yOffset = 0.0;
+			for(let i = 0; i < nodes.length; i++) {
+				const node = nodes[i]!;
+				const height = Math.max(...layers.map((l) => l.getNodes().at(i)?.getHeight() ?? 0));
+
+				node.setX(xOffset + width / 2 - node.getWidth() / 2).setY(yOffset + height / 2 - node.getHeight() / 2);
+
+				yOffset += height + this._layerGap;
+			}
+
+			xOffset += width + this._padding;
+		}
+	}
+}

+ 3 - 3
src/v1/optimizer/steps/CycleRemoveStep.ts

@@ -1,6 +1,6 @@
 import CycleRemoveStepError from "../../errors/optimizer/CycleRemoveStepError.js";
 import AlgorithmStep from "../AlgorithmStep.js";
-import {SiguiyamaContext} from "../siguiyama/SiguiyamaContext.js";
+import {FeedbackSet, SiguiyamaContext} from "../siguiyama/SiguiyamaContext.js";
 import Node from "../../graph/node/Node.js";
 import Graph from "../../graph/Graph.js";
 import Edge from "../../graph/edge/Edge.js";
@@ -71,8 +71,8 @@ export default class CycleRemoveStep extends AlgorithmStep<SiguiyamaContext> {
      * @param graph Граф, в котором могут быть инвертированы рёбра.
      * @returns Массив рёбер, которые были инвертированы.
      */
-	protected createFeedbackSet(orderedNodes: Node[], graph: Graph<Node, Edge<Node>>) : NonNullable<SiguiyamaContext["feedbackSet"]> {
-		const feedbackSet: NonNullable<SiguiyamaContext["feedbackSet"]> = [];
+	protected createFeedbackSet(orderedNodes: Node[], graph: Graph<Node, Edge<Node>>) : FeedbackSet {
+		const feedbackSet: FeedbackSet = [];
 
 		for(const edge of graph.getEdges()) {
 			const from = edge.getFrom();

+ 1 - 3
src/v1/optimizer/steps/EdgeRoutingStep.ts

@@ -8,13 +8,11 @@ export default class EdgeRoutingStep extends AlgorithmStep<SiguiyamaContext> {
 	}
 
 	public run(context: SiguiyamaContext): void {
-		const { graph, layering, grid } = context;
+		const { graph, layering } = context;
 
 		if(!graph)
 			throw new EdgeRoutingStepError("Source graph was not found!");
 		if(!layering)
 			throw new EdgeRoutingStepError("Layering of graph was not found!");
-		if(!grid)
-			throw new EdgeRoutingStepError("Grid was not found!");
 	}
 }

+ 27 - 17
src/v1/optimizer/steps/LayerAssignmentStep.ts

@@ -4,8 +4,9 @@ import Graph from "../../graph/Graph.js";
 import Layering from "../../graph/layering/Layering.js";
 import Node from "../../graph/node/Node.js";
 import AlgorithmStep from "../AlgorithmStep.js";
-import {SiguiyamaContext} from "../siguiyama/SiguiyamaContext.js";
+import {FeedbackSet, SiguiyamaContext} from "../siguiyama/SiguiyamaContext.js";
 import DummyNode from "../../graph/node/DummyNode.js";
+import DummyEdge from "../../graph/edge/DummyEdge.js";
 
 /**
  * Шаг алгоритма Sugiyama для присвоения слоёв вершинам графа.
@@ -25,21 +26,19 @@ export default class LayerAssignmentStep extends AlgorithmStep<SiguiyamaContext>
 	 * @throws {LayerAssignmentStepError} Если граф не найден, feedback set не определён или граф ацикличен.
 	 */
 	public run(context: SiguiyamaContext): void {
-		const {graph, feedbackSet, edgeSubdivisions} = context;
+		const {graph, feedbackSet} = context;
 
 		if(!graph)
 			throw new LayerAssignmentStepError("Graph was not found!");
 		if(!feedbackSet)
 			throw new LayerAssignmentStepError("Feedback set is undefined!");
-		if(!edgeSubdivisions)
-			throw new LayerAssignmentStepError("Edge subdivisions information is undefined!")
 
 		if(!graph.isAcyclic())
 			throw new LayerAssignmentStepError("Graph is acyclic, can not assign layers to an acyclic graph!");
 
 		const layering = this.longestPathAlgorithm(graph);
 
-		this.divideLongEdges(graph, layering);
+		this.divideLongEdges(graph, layering, feedbackSet);
 
 		context.layering = layering;
 	}
@@ -80,17 +79,18 @@ export default class LayerAssignmentStep extends AlgorithmStep<SiguiyamaContext>
 	}
 
 	/**
-	 * Разбивает длинные рёбра (span >= 2) добавлением dummy-вершин между слоями.
-	 *
-	 * Для каждого ребра, у которого {@link Layering.getEdgeSpan} возвращает значение больше 1:
-	 * - создаётся цепочка из `span - 1` вершин {@link DummyNode};
-	 * - каждая dummy-вершина добавляется в промежуточный слой;
-	 * - исходная вершина/последняя dummy соединяется новым сегментом {@link Edge}.
-	 *
-	 * @param graph Граф, в который добавляются dummy-вершины и сегменты рёбер.
-	 * @param layering Текущее разбиение графа на слои, используемое для вычисления span и вставки dummy-вершин.
+     * Разбивает длинные рёбра (span >= 2) добавлением dummy-вершин между слоями.
+     *
+     * Для каждого ребра, у которого {@link Layering.getEdgeSpan} возвращает значение больше 1:
+     * - создаётся цепочка из `span - 1` вершин {@link DummyNode};
+     * - каждая dummy-вершина добавляется в промежуточный слой;
+     * - исходная вершина/последняя dummy соединяется новым сегментом {@link Edge}.
+     *
+     * @param graph Граф, в который добавляются dummy-вершины и сегменты рёбер.
+     * @param layering Текущее разбиение графа на слои, используемое для вычисления span и вставки dummy-вершин.
+     * @param feedbackSet Набор рёбер обратной связи
 	 */
-	private divideLongEdges(graph: Graph<Node, Edge<Node>>, layering: Layering<Node, Edge<Node>>) : void {
+	private divideLongEdges(graph: Graph<Node, Edge<Node>>, layering: Layering<Node, Edge<Node>>, feedbackSet: FeedbackSet) : void {
 		const edges = graph.getEdges();
 
 		for(const edge of edges) {
@@ -98,8 +98,9 @@ export default class LayerAssignmentStep extends AlgorithmStep<SiguiyamaContext>
 			if(span < 2)
 				continue;
 
-			const from = edge.getFrom();
+			const from = edge.getFrom(), to = edge.getTo();
 			const fromLayer = layering.getNodeLayerIndex(from);
+			const isReversed = feedbackSet.includes(edge);
 
 			let currentNode = from;
 			for(let i = 1; i < span; i++) {
@@ -107,11 +108,20 @@ export default class LayerAssignmentStep extends AlgorithmStep<SiguiyamaContext>
 				layering.addToLayer(fromLayer - i, dummyNode);
 				graph.addNode(dummyNode);
 
-				const dummyEdge = new Edge(currentNode, dummyNode);
+				const dummyEdge = new DummyEdge(currentNode, dummyNode, [], "dummy-" + crypto.randomUUID());
 				graph.addEdge(dummyEdge);
+				if(isReversed)
+					feedbackSet.push(dummyEdge);
 
 				currentNode = dummyNode;
 			}
+
+			const finalEdge = new DummyEdge(currentNode, to, [], "dummy-" + crypto.randomUUID());
+			graph.addEdge(finalEdge);
+			if(isReversed)
+				feedbackSet.push(finalEdge);
+
+			graph.removeEdge(edge.getId());
 		}
 	}
 }