diff --git a/examples/random-graph/random-graph.html b/examples/random-graph/random-graph.html index 7b71700e..45db8c05 100644 --- a/examples/random-graph/random-graph.html +++ b/examples/random-graph/random-graph.html @@ -23,6 +23,10 @@

Sprotty Random Graph Example

+

+ + +

Help diff --git a/examples/random-graph/src/di.config.ts b/examples/random-graph/src/di.config.ts index 991777c3..3f8aba9a 100644 --- a/examples/random-graph/src/di.config.ts +++ b/examples/random-graph/src/di.config.ts @@ -19,7 +19,7 @@ import ElkConstructor from 'elkjs/lib/elk.bundled'; import { Container, ContainerModule } from 'inversify'; import { Animation, CommandExecutionContext, configureModelElement, configureViewerOptions, ConsoleLogger, - edgeIntersectionModule, isSelectable, isViewport, loadDefaultModules, LocalModelSource, LogLevel, PolylineEdgeViewWithGapsOnIntersections, + edgeIntersectionModule, edgeJunctionModule, isSelectable, isViewport, loadDefaultModules, LocalModelSource, LogLevel, PolylineEdgeViewWithGapsOnIntersections, RectangularNodeView, SEdgeImpl, SGraphImpl, SGraphView, SLabelImpl, SLabelView, SModelRootImpl, SNodeImpl, SPortImpl, TYPES, UpdateAnimationData, UpdateModelCommand, ViewportAnimation } from 'sprotty'; @@ -64,6 +64,7 @@ export default (containerId: string) => { const container = new Container(); loadDefaultModules(container); container.load(edgeIntersectionModule); + container.load(edgeJunctionModule); container.load(elkLayoutModule, randomGraphModule); return container; }; diff --git a/examples/random-graph/src/standalone.ts b/examples/random-graph/src/standalone.ts index 6c2b250c..d9410eae 100644 --- a/examples/random-graph/src/standalone.ts +++ b/examples/random-graph/src/standalone.ts @@ -30,6 +30,14 @@ export default function runRandomGraph() { layoutConfigurator.setDirection((event.target as any)?.value ?? 'LEFT'); modelSource.updateModel(); }); + + document.getElementById('junction')!.addEventListener('change', async (event) => { + if ((event.target as any).checked) { + modelSource.updateModel(createRandomGraphWithJunction()); + } else { + modelSource.updateModel(createRandomGraph()); + } + }); } const NODES = 50; @@ -101,3 +109,66 @@ function createRandomGraph(): SGraph { } return graph; } + +function createRandomGraphWithJunction(): SGraph { + const graph: SGraph = { + type: 'graph', + id: 'root', + children: [] + }; + + for (let i = 0; i < NODES; i++) { + const node: SNode = { + type: 'node', + id: `node${i}`, + children: [ + { + type: 'label:node', + id: `node${i}_label`, + text: i.toString() + }, + { + type: 'port', + id: `port${i}-in`, + size: { width: 8, height: 8 }, + children: [ + { + type: 'label:port', + id: `port${i}-in-label`, + text: `in` + } + ] + }, + { + type: 'port', + id: `port${i}-out`, + size: { width: 8, height: 8 }, + children: [ + { + type: 'label:port', + id: `port${i}-out-label`, + text: `out` + } + ] + } + ] + }; + graph.children.push(node); + } + + for (let i = 0; i < EDGES; i++) { + const sourceNo = Math.floor(Math.random() * NODES); + const targetNo = Math.floor(Math.random() * NODES); + if (sourceNo === targetNo) { + continue; + } + const edge: SEdge = { + type: 'edge', + id: `edge${i}`, + sourceId: `port${sourceNo}-out`, + targetId: `port${targetNo}-in` + }; + graph.children.push(edge); + } + return graph; +} diff --git a/packages/sprotty/css/sprotty.css b/packages/sprotty/css/sprotty.css index 002795d9..e21dbba8 100644 --- a/packages/sprotty/css/sprotty.css +++ b/packages/sprotty/css/sprotty.css @@ -100,4 +100,10 @@ fill: #f00; font-size: 14pt; text-anchor: start; +} + +.sprotty-junction { + stroke: #000; + stroke-width: 1; + fill: #fff; } \ No newline at end of file diff --git a/packages/sprotty/src/features/edge-junction/di.config.ts b/packages/sprotty/src/features/edge-junction/di.config.ts new file mode 100644 index 00000000..370ff64f --- /dev/null +++ b/packages/sprotty/src/features/edge-junction/di.config.ts @@ -0,0 +1,29 @@ +/******************************************************************************** + * Copyright (c) 2024 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { ContainerModule } from "inversify"; +import { TYPES } from "../../base/types"; +import { JunctionFinder } from "./junction-finder"; +import { JunctionPostProcessor } from "./junction-postprocessor"; + +const edgeJunctionModule = new ContainerModule(bind => { + bind(JunctionFinder).toSelf().inSingletonScope(); + bind(TYPES.IEdgeRoutePostprocessor).toService(JunctionFinder); + bind(JunctionPostProcessor).toSelf().inSingletonScope(); + bind(TYPES.IVNodePostprocessor).toService(JunctionPostProcessor); +}); + +export default edgeJunctionModule; diff --git a/packages/sprotty/src/features/edge-junction/junction-finder.spec.ts b/packages/sprotty/src/features/edge-junction/junction-finder.spec.ts new file mode 100644 index 00000000..5ab99151 --- /dev/null +++ b/packages/sprotty/src/features/edge-junction/junction-finder.spec.ts @@ -0,0 +1,264 @@ +/******************************************************************************** + * Copyright (c) 2024 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import 'reflect-metadata'; + +import { Point } from 'sprotty-protocol'; +import { assert, describe, expect, it } from 'vitest'; +import { SEdgeImpl, SGraphImpl } from '../../graph/sgraph'; +import { EdgeRouting, RoutedPoint } from '../routing/routing'; +import { JunctionFinder } from './junction-finder'; + + +describe('JunctionFinder', () => { + it('should find no junction points on two identical paths', () => { + const model = new SGraphImpl(); + const edge1 = createEdge('edge1', 'A', 'B'); + const edge2 = createEdge('edge2', 'A', 'B'); + model.add(edge1); + model.add(edge2); + + const routing = new EdgeRouting(); + const route1 = createRouting([{x: 0, y: 0}, {x: 50, y: 0}, {x: 50, y: 0}, {x: 100, y: 100}]); + const route2 = createRouting([{x: 0, y: 0}, {x: 50, y: 0}, {x: 50, y: 0}, {x: 100, y: 100}]); + + routing.routes.set(edge1.id, route1); + routing.routes.set(edge2.id, route2); + + const finder = new JunctionFinder(); + finder.apply(routing, model); + + const allJunctionPoints = getAllJunctionPoints(routing); + + checkAllJunctionPoints([], allJunctionPoints); + }); + + it('should find single junction point on diverging edge with same source', () => { + const model = new SGraphImpl(); + const edge1 = createEdge('edge1', 'A', 'B'); + const edge2 = createEdge('edge2', 'A', 'C'); + model.add(edge1); + model.add(edge2); + + const routing = new EdgeRouting(); + const route1 = createRouting([{x: 0, y: 0}, {x: 100, y: 0}]); + const route2 = createRouting([{x: 0, y: 0}, {x: 50, y: 0}, {x: 50, y: 100}, {x: 100, y: 100}]); + + routing.routes.set(edge1.id, route1); + routing.routes.set(edge2.id, route2); + + const finder = new JunctionFinder(); + finder.apply(routing, model); + + const allJunctionPoints = getAllJunctionPoints(routing); + + const expectedJunctionPoint: RoutedPoint = {kind: 'linear', x: 50, y: 0, isJunction: true}; + + checkAllJunctionPoints([expectedJunctionPoint], allJunctionPoints); + }); + + it('should find two junction points on splitting edges with same source', () => { + const model = new SGraphImpl(); + const edge1 = createEdge('edge1', 'A', 'B'); + const edge2 = createEdge('edge2', 'A', 'C'); + model.add(edge1); + model.add(edge2); + + const routing = new EdgeRouting(); + const route1 = createRouting([{x: 0, y: 0}, {x: 50, y: 0}, {x: 50, y: 100}, {x: 100, y: 100}]); + const route2 = createRouting([{x: 0, y: 0}, {x: 50, y: 0}, {x: 50, y: -100}, {x: 100, y: -100}]); + + routing.routes.set(edge1.id, route1); + routing.routes.set(edge2.id, route2); + + const finder = new JunctionFinder(); + finder.apply(routing, model); + + const allJunctionPoints = getAllJunctionPoints(routing); + + const expectedJunctionPoint: RoutedPoint[] = [ + {kind: 'linear', x: 50, y: 0, isJunction: true}, + {kind: 'linear', x: 50, y: 0, isJunction: true} + ]; + + checkAllJunctionPoints(expectedJunctionPoint, allJunctionPoints); + }); + + it('should find single junction point on diverging edge with same target', () => { + const model = new SGraphImpl(); + const edge1 = createEdge('edge1', 'A', 'C'); + const edge2 = createEdge('edge2', 'B', 'C'); + model.add(edge1); + model.add(edge2); + + const routing = new EdgeRouting(); + const route1 = createRouting([{x: 0, y: 0}, {x: 50, y: 0}, {x: 50, y: 100}, {x: 100, y: 100}]); + const route2 = createRouting([{x: 0, y: 100}, {x: 100, y: 100}]); + + routing.routes.set(edge1.id, route1); + routing.routes.set(edge2.id, route2); + + const finder = new JunctionFinder(); + finder.apply(routing, model); + + const allJunctionPoints = getAllJunctionPoints(routing); + + const expectedJunctionPoint: RoutedPoint[] = [{kind: 'linear', x: 50, y: 100, isJunction: true}]; + + checkAllJunctionPoints(expectedJunctionPoint, allJunctionPoints); + }); + + it('should find two junction points on splitting edges with same target', () => { + const model = new SGraphImpl(); + const edge1 = createEdge('edge1', 'A', 'C'); + const edge2 = createEdge('edge2', 'B', 'C'); + model.add(edge1); + model.add(edge2); + + const routing = new EdgeRouting(); + const route1 = createRouting([{x: 0, y: 0}, {x: 50, y: 0}, {x: 50, y: 100}, {x: 100, y: 100}]); + const route2 = createRouting([{x: 0, y: 200}, {x: 50, y: 200}, {x: 50, y: 100}, {x: 100, y: 100}]); + + routing.routes.set(edge1.id, route1); + routing.routes.set(edge2.id, route2); + + const finder = new JunctionFinder(); + finder.apply(routing, model); + + const allJunctionPoints = getAllJunctionPoints(routing); + + const expectedJunctionPoint: RoutedPoint[] = [ + {kind: 'linear', x: 50, y: 100, isJunction: true}, + {kind: 'linear', x: 50, y: 100, isJunction: true} + ]; + + checkAllJunctionPoints(expectedJunctionPoint, allJunctionPoints); + }); + + it('should find three junction points on three diverging edges with same source', () => { + const model = new SGraphImpl(); + const edge1 = createEdge('edge1', 'A', 'B'); + const edge2 = createEdge('edge2', 'A', 'C'); + const edge3 = createEdge('edge3', 'A', 'D'); + model.add(edge1); + model.add(edge2); + model.add(edge3); + + const routing = new EdgeRouting(); + const route1 = createRouting([{x: 0, y: 0}, {x: 100, y: 0}]); + const route2 = createRouting([{x: 0, y: 0}, {x: 50, y: 0}, {x: 50, y: 100}, {x: 100, y: 100}]); + const route3 = createRouting([{x: 0, y: 0}, {x: 50, y: 0}, {x: 50, y: 200}, {x: 100, y: 200}]); + + routing.routes.set(edge1.id, route1); + routing.routes.set(edge2.id, route2); + routing.routes.set(edge3.id, route3); + + const finder = new JunctionFinder(); + finder.apply(routing, model); + + const allJunctionPoints = getAllJunctionPoints(routing); + + const expectedJunctionPoint: RoutedPoint[] = [ + {kind: 'linear', x: 50, y: 0, isJunction: true}, + {kind: 'linear', x: 50, y: 100, isJunction: true}, + {kind: 'linear', x: 50, y: 0, isJunction: true}, + ]; + + checkAllJunctionPoints(expectedJunctionPoint, allJunctionPoints); + }); + + it('should find four junction points on three diverging and splitting edges with same source', () => { + const model = new SGraphImpl(); + const edge1 = createEdge('edge1', 'A', 'B'); + const edge2 = createEdge('edge2', 'A', 'C'); + const edge3 = createEdge('edge3', 'A', 'D'); + model.add(edge1); + model.add(edge2); + model.add(edge3); + + const routing = new EdgeRouting(); + const route1 = createRouting([{x: 0, y: 0}, {x: 150, y: 0}]); + const route2 = createRouting([{x: 0, y: 0}, {x: 50, y: 0}, {x: 50, y: 100}, {x: 100, y: 100}, {x: 100, y: 50}, {x: 150, y: 50}]); + const route3 = createRouting([{x: 0, y: 0}, {x: 50, y: 0}, {x: 50, y: 100}, {x: 100, y: 100}, {x: 100, y: 150}, {x: 150, y: 150}]); + + routing.routes.set(edge1.id, route1); + routing.routes.set(edge2.id, route2); + routing.routes.set(edge3.id, route3); + + const finder = new JunctionFinder(); + finder.apply(routing, model); + + const allJunctionPoints = getAllJunctionPoints(routing); + + const expectedJunctionPoint: RoutedPoint[] = [ + {kind: 'linear', x: 50, y: 0, isJunction: true}, + {kind: 'linear', x: 50, y: 0, isJunction: true}, + {kind: 'linear', x: 100, y: 100, isJunction: true}, + {kind: 'linear', x: 100, y: 100, isJunction: true}, + ]; + + checkAllJunctionPoints(expectedJunctionPoint, allJunctionPoints); + }); + +}); + +function createEdge(id: string, source: string, target: string): SEdgeImpl { + const edge = new SEdgeImpl(); + edge.id = id; + edge.type = 'edge'; + edge.sourceId = source; + edge.targetId = target; + return edge; +} + +function createRouting(points: Point[]): RoutedPoint[] { + + return points.map((p, idx) => { + if (idx === 0) { + return {kind: 'source', x: p.x, y: p.y}; + } else if (idx === points.length - 1) { + return {kind: 'target', x: p.x, y: p.y}; + } else { + return {kind: 'linear', x: p.x, y: p.y}; + } + }); +} + +function getAllJunctionPoints(routing: EdgeRouting): RoutedPoint[] { + const junctionPoints: RoutedPoint[] = []; + + routing.routes.forEach(route => { + route.forEach(point => { + if (point.isJunction) { + junctionPoints.push(point); + } + }); + }); + + return junctionPoints; +} + +function checkAllJunctionPoints(expected: RoutedPoint[], actual: RoutedPoint[]): void { + expect(actual.length).toBe(expected.length); + for (let i = 0; i < expected.length; i++) { + const idx = actual.findIndex(p => p.x === expected[i].x && p.y === expected[i].y); + if (idx === -1) { + assert.fail('Could not find expected junction point'); + } + actual.splice(idx, 1); + } + expect(actual.length).toBe(0); +} diff --git a/packages/sprotty/src/features/edge-junction/junction-finder.ts b/packages/sprotty/src/features/edge-junction/junction-finder.ts new file mode 100644 index 00000000..e1011548 --- /dev/null +++ b/packages/sprotty/src/features/edge-junction/junction-finder.ts @@ -0,0 +1,241 @@ +/******************************************************************************** + * Copyright (c) 2024 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { injectable } from "inversify"; +import { SParentElementImpl } from "../../base/model/smodel"; +import { SEdgeImpl } from "../../graph/sgraph"; +import { EdgeRouting, IEdgeRoutePostprocessor, RoutedPoint } from "../routing/routing"; + +/** + * Finds junction points in the edge routes. A junction point is a point where two or more edges split. + * This excludes the source and target points of the edges. + * + * Only works with straight line segments. + */ +@injectable() +export class JunctionFinder implements IEdgeRoutePostprocessor { + /** Map of edges as SEdgeImpl for faster lookup by id */ + protected edgesMap: Map = new Map(); + /** Map of unique edges ids with the same source */ + protected sourcesMap: Map> = new Map(); + /** Map of unique edges ids with the same target */ + protected targetsMap: Map> = new Map(); + + apply(routing: EdgeRouting, parent: SParentElementImpl): void { + this.findJunctions(routing, parent); + } + + protected findJunctions(routing: EdgeRouting, parent: SParentElementImpl) { + // gather all edges from the parent + const edges = Array.from(parent.index.all().filter(child => child instanceof SEdgeImpl)) as SEdgeImpl[]; + + // populate the maps for faster lookup + edges.forEach(edge => { + this.edgesMap.set(edge.id, edge); + const sameSources = this.sourcesMap.get(edge.sourceId); + if (sameSources) { + sameSources.add(edge.id); + } else { + this.sourcesMap.set(edge.sourceId, new Set([edge.id])); + } + + const sameTargets = this.targetsMap.get(edge.targetId); + if (sameTargets) { + sameTargets.add(edge.id); + } else { + this.targetsMap.set(edge.targetId, new Set([edge.id])); + } + }); + + routing.routes.forEach((route, routeId) => { + // for each route we find the corresponding edge from the edges map by matching the route id and the edge id + const edge = this.edgesMap.get(routeId); + if (!edge) { + return; + } + + // find the junction points for edges with the same source + this.findJunctionPointsWithSameSource(edge, route, routing); + + // find the junction points for edges with the same target + this.findJunctionPointsWithSameTarget(edge, route, routing); + }); + } + + /** + * Finds the junction points of routes with the same source + */ + protected findJunctionPointsWithSameSource(edge: SEdgeImpl, route: RoutedPoint[], routing: EdgeRouting) { + // get an array of edge/route ids with the same source as the current edge, excluding the current edge + const sourcesSet = this.sourcesMap.get(edge.sourceId); + if (!sourcesSet) { + return; + } + const otherRoutesIds = Array.from(sourcesSet).filter(id => id !== edge.id); + const otherRoutes = otherRoutesIds.map(id => routing.get(id)).filter(r => r !== undefined) as RoutedPoint[][]; + + + for (const otherRoute of otherRoutes) { + // finds the index where the two routes diverge + const junctionIndex: number = this.getJunctionIndex(route, otherRoute); + + // if no junction point has been found (i.e. the routes are identical) + // or if the junction point is the first point of the routes (i.e the routes diverge at the source) + // we can skip this route + if (junctionIndex === -1 || junctionIndex === 0) { + continue; + } + + this.setJunctionPoints(route, otherRoute, junctionIndex); + } + } + + /** + * Finds the junction points of routes with the same target + */ + protected findJunctionPointsWithSameTarget(edge: SEdgeImpl, route: RoutedPoint[], routing: EdgeRouting) { + // get an array of edge/route ids with the same target as the current edge, excluding the current edge + const targetsSet = this.targetsMap.get(edge.targetId); + if (!targetsSet) { + return; + } + const otherRoutesIds = Array.from(targetsSet).filter(id => id !== edge.id); + const otherRoutes = otherRoutesIds.map(id => routing.get(id)).filter(r => r !== undefined) as RoutedPoint[][]; + + + // we reverse the route so that the target is considered the source for the algorithm + route.reverse(); + for (const otherRoute of otherRoutes) { + // we reverse the other route so that the target is considered the source for the algorithm + otherRoute.reverse(); + // finds the index where the two routes diverge + const junctionIndex: number = this.getJunctionIndex(route, otherRoute); + + // if no junction point has been found (i.e. the routes are identical) + // or if the junction point is the first point of the routes (i.e the routes diverge at the source) + // we can skip this route + if (junctionIndex === -1 || junctionIndex === 0) { + continue; + } + this.setJunctionPoints(route, otherRoute, junctionIndex); + // we reverse the other route back to its original order + otherRoute.reverse(); + } + // we reverse the route back to their original order + route.reverse(); + } + + /** + * Set the junction points of two routes according to the segments direction. + * If the segments have different directions, the junction point is the previous common point. + * If the segments have the same direction, the junction point is the point with the greatest or lowest value axis value depending on the direction. + */ + protected setJunctionPoints(route: RoutedPoint[], otherRoute: RoutedPoint[], junctionIndex: number) { + const firstSegmentDirection = this.getSegmentDirection(route[junctionIndex - 1], route[junctionIndex]); + const secondSegmentDirection = this.getSegmentDirection(otherRoute[junctionIndex - 1], otherRoute[junctionIndex]); + + // if the two segments have different directions, then the previous common point is the junction point + if (firstSegmentDirection !== secondSegmentDirection) { + this.setPreviousPointAsJunction(route, otherRoute, junctionIndex); + } else { // the two segments have the same direction + + if (firstSegmentDirection === 'left' || firstSegmentDirection === 'right') { + // if the segments are going horizontally, but their y values are different, then the previous common point is the junction point + if (route[junctionIndex].y !== otherRoute[junctionIndex].y) { + this.setPreviousPointAsJunction(route, otherRoute, junctionIndex); + return; + } + // depending on the direction, the junction point is the point with the greatest or lowest x value + route[junctionIndex].isJunction = firstSegmentDirection === 'left' ? + route[junctionIndex].x > otherRoute[junctionIndex].x + : route[junctionIndex].x < otherRoute[junctionIndex].x; + + otherRoute[junctionIndex].isJunction = firstSegmentDirection === 'left' ? + otherRoute[junctionIndex].x > route[junctionIndex].x + : otherRoute[junctionIndex].x < route[junctionIndex].x; + + } else { + // if the segments are going vertically, but their x values are different, then the previous common point is the junction point + if (route[junctionIndex].x !== otherRoute[junctionIndex].x) { + this.setPreviousPointAsJunction(route, otherRoute, junctionIndex); + return; + } + // depending on the direction, the junction point is the point with the greatest or lowest y value + route[junctionIndex].isJunction = firstSegmentDirection === 'up' ? + route[junctionIndex].y > otherRoute[junctionIndex].y + : route[junctionIndex].y < otherRoute[junctionIndex].y; + + otherRoute[junctionIndex].isJunction = firstSegmentDirection === 'up' ? + otherRoute[junctionIndex].y > route[junctionIndex].y + : otherRoute[junctionIndex].y < route[junctionIndex].y; + } + } + } + + /** + * Set the previous point as a junction point. + * This is used when two segments have the same direction but the other axis is different. + * For example if the routes are going in opposite directions, or if the route don't split orthogonally. + */ + protected setPreviousPointAsJunction(route: RoutedPoint[], sameSourceRoute: RoutedPoint[], junctionIndex: number) { + route[junctionIndex - 1].isJunction = true; + sameSourceRoute[junctionIndex - 1].isJunction = true; + } + + /** + * Get the main direction of a segment. + * The main direction is the axis with the greatest difference between the two points. + */ + protected getSegmentDirection(firstPoint: RoutedPoint, secondPoint: RoutedPoint) { + const dX = secondPoint.x - firstPoint.x; + const dY = secondPoint.y - firstPoint.y; + + let mainDirection = 'horizontal'; + if (Math.abs(dX) < Math.abs(dY)) { + mainDirection = 'vertical'; + } + + if (mainDirection === 'horizontal') { + if (dX > 0) { + return 'right'; + } else { + return 'left'; + } + } else { + if (dY > 0) { + return 'down'; + } else { + return 'up'; + } + } + } + + /** + * Finds the index where two routes diverge. + * Returns -1 if no divergence can be found. + */ + protected getJunctionIndex(firstRoute: RoutedPoint[], secondRoute: RoutedPoint[]): number { + let idx = 0; + while (idx < firstRoute.length && idx < secondRoute.length) { + if (firstRoute[idx].x !== secondRoute[idx].x + || firstRoute[idx].y !== secondRoute[idx].y) { + return idx; + } + idx++; + } + return -1; + } +} diff --git a/packages/sprotty/src/features/edge-junction/junction-postprocessor.ts b/packages/sprotty/src/features/edge-junction/junction-postprocessor.ts new file mode 100644 index 00000000..a15b2ae9 --- /dev/null +++ b/packages/sprotty/src/features/edge-junction/junction-postprocessor.ts @@ -0,0 +1,49 @@ +/******************************************************************************** + * Copyright (c) 2024 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { injectable, inject } from "inversify"; +import { IVNodePostprocessor } from "../../base/views/vnode-postprocessor"; +import { VNode } from "snabbdom"; +import { Action } from "sprotty-protocol"; +import { SModelElementImpl } from "../../base/model/smodel"; +import { TYPES } from "../../base/types"; +import { ViewerOptions } from "../../base/views/viewer-options"; + +/** + * Finds all junction points in the first SVG group element (diagram root level) and moves them to the end of the SVG. + * This ensures that junction points are rendered on top of all other elements. + */ +@injectable() +export class JunctionPostProcessor implements IVNodePostprocessor { + @inject(TYPES.ViewerOptions) private viewerOptions: ViewerOptions; + + decorate(vnode: VNode, element: SModelElementImpl): VNode { + return vnode; + } + postUpdate(cause?: Action | undefined): void { + const baseDiv = this.viewerOptions.baseDiv; + const svg = document.querySelector(`#${baseDiv} > svg > g`); + if (svg) { + const junctionGroups = Array.from(document.querySelectorAll('g.sprotty-junction')); + + junctionGroups.forEach(junctionGroup => { + junctionGroup.remove(); + }); + + svg.append(...junctionGroups); + } + } +} diff --git a/packages/sprotty/src/features/routing/routing.ts b/packages/sprotty/src/features/routing/routing.ts index 8803dd36..925f2f7c 100644 --- a/packages/sprotty/src/features/routing/routing.ts +++ b/packages/sprotty/src/features/routing/routing.ts @@ -38,6 +38,7 @@ import { PolylineEdgeRouter } from "./polyline-edge-router"; export interface RoutedPoint extends Point { kind: 'source' | 'target' | 'linear' | 'bezier-control-before' | 'bezier-junction' | 'bezier-control-after' pointIndex?: number + isJunction?: boolean } /** @@ -147,7 +148,7 @@ function isMultipleEdgesRouter( /** A postprocessor that is applied to all routes, once they are computed. */ export interface IEdgeRoutePostprocessor { - apply(routing: EdgeRouting): void; + apply(routing: EdgeRouting, parent: SParentElementImpl): void; } @injectable() @@ -178,7 +179,7 @@ export class EdgeRouterRegistry extends InstanceRegistry { routeAllChildren(parent: Readonly): EdgeRouting { const routing = this.doRouteAllChildren(parent); for (const postProcessor of this.postProcessors) { - postProcessor.apply(routing); + postProcessor.apply(routing, parent); } return routing; } diff --git a/packages/sprotty/src/graph/views.tsx b/packages/sprotty/src/graph/views.tsx index 3400c221..14ddba8e 100644 --- a/packages/sprotty/src/graph/views.tsx +++ b/packages/sprotty/src/graph/views.tsx @@ -81,10 +81,26 @@ export class PolylineEdgeView extends RoutableView { return {this.renderLine(edge, route, context, args)} {this.renderAdditionals(edge, route, context)} + {this.renderJunctionPoints(edge, route, context, args)} {context.renderChildren(edge, { route })} ; } + protected renderJunctionPoints(edge: Readonly, route: RoutedPoint[], context: RenderingContext, args: IViewArgs | undefined) { + const radius = 5; + const junctionPoints = []; + for (let i = 1; i < route.length; i++) { + if (route[i].isJunction) { + junctionPoints.push(); + } + } + if (junctionPoints.length > 0) { + return {junctionPoints}; + } + + return undefined; + } + protected renderLine(edge: SEdgeImpl, segments: Point[], context: RenderingContext, args?: IViewArgs): VNode { const firstPoint = segments[0]; let path = `M ${firstPoint.x},${firstPoint.y}`; diff --git a/packages/sprotty/src/index.ts b/packages/sprotty/src/index.ts index e86e164d..675a89c3 100644 --- a/packages/sprotty/src/index.ts +++ b/packages/sprotty/src/index.ts @@ -112,6 +112,8 @@ export * from './features/decoration/decoration-placer'; export * from './features/edge-intersection/intersection-finder'; export * from './features/edge-intersection/sweepline'; +export * from './features/edge-junction/junction-finder'; + export * from './features/move/model'; export * from './features/move/move'; export * from './features/move/snap'; @@ -159,6 +161,7 @@ import commandPaletteModule from './features/command-palette/di.config'; import contextMenuModule from './features/context-menu/di.config'; import decorationModule from './features/decoration/di.config'; import edgeIntersectionModule from './features/edge-intersection/di.config'; +import edgeJunctionModule from './features/edge-junction/di.config'; import edgeLayoutModule from './features/edge-layout/di.config'; import expandModule from './features/expand/di.config'; import exportModule from './features/export/di.config'; @@ -175,7 +178,7 @@ import zorderModule from './features/zorder/di.config'; export { boundsModule, buttonModule, commandPaletteModule, contextMenuModule, decorationModule, - edgeIntersectionModule, edgeLayoutModule, expandModule, exportModule, fadeModule, hoverModule, moveModule, + edgeIntersectionModule, edgeJunctionModule, edgeLayoutModule, expandModule, exportModule, fadeModule, hoverModule, moveModule, openModule, routingModule, selectModule, undoRedoModule, updateModule, viewportModule, zorderModule };