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 @@
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
};