diff --git a/.vscode/launch.json b/.vscode/launch.json index 8e71e34e..80cba067 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,8 +11,8 @@ "args": [ "${file}", "--no-timeouts", - "--opts", - "${workspaceRoot}/configs/mocha.opts" + "--config", + "${workspaceRoot}/configs/.mocharc.json" ], "env": { "TS_NODE_PROJECT": "${workspaceRoot}/tsconfig.json" diff --git a/examples/random-graph/src/di.config.ts b/examples/random-graph/src/di.config.ts index 56bc7ea3..399f3f98 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 { TYPES, configureViewerOptions, SGraphView, SLabelView, ConsoleLogger, LogLevel, loadDefaultModules, LocalModelSource, SNode, SEdge, SLabel, configureModelElement, - SGraph, RectangularNodeView, PolylineEdgeView + SGraph, RectangularNodeView, edgeIntersectionModule, PolylineEdgeViewWithGapsOnIntersections } from 'sprotty'; import { ElkFactory, ElkLayoutEngine, elkLayoutModule } from 'sprotty-elk/lib/inversify'; @@ -41,7 +41,7 @@ export default (containerId: string) => { const context = { bind, unbind, isBound, rebind }; configureModelElement(container, 'graph', SGraph, SGraphView); configureModelElement(container, 'node', SNode, RectangularNodeView); - configureModelElement(container, 'edge', SEdge, PolylineEdgeView); + configureModelElement(container, 'edge', SEdge, PolylineEdgeViewWithGapsOnIntersections); configureModelElement(container, 'label', SLabel, SLabelView); configureViewerOptions(context, { @@ -52,6 +52,7 @@ export default (containerId: string) => { const container = new Container(); loadDefaultModules(container); + container.load(edgeIntersectionModule); container.load(elkLayoutModule, randomGraphModule); return container; }; diff --git a/packages/sprotty/src/features/edge-intersection/intersection-finder.ts b/packages/sprotty/src/features/edge-intersection/intersection-finder.ts index 44fc21e3..c1e0a307 100644 --- a/packages/sprotty/src/features/edge-intersection/intersection-finder.ts +++ b/packages/sprotty/src/features/edge-intersection/intersection-finder.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2021 EclipseSource and others. + * Copyright (c) 2021-2022 EclipseSource 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 @@ -43,8 +43,31 @@ export const BY_X_THEN_Y = (a: Intersection, b: Intersection): number => { return a.intersectionPoint.x - b.intersectionPoint.x; }; +export const BY_DESCENDING_X_THEN_Y = (a: Intersection, b: Intersection): number => { + if (a.intersectionPoint.x === b.intersectionPoint.x) { + return a.intersectionPoint.y - b.intersectionPoint.y; + } + return b.intersectionPoint.x - a.intersectionPoint.x; +}; + +export const BY_X_THEN_DESCENDING_Y = (a: Intersection, b: Intersection): number => { + if (a.intersectionPoint.x === b.intersectionPoint.x) { + return b.intersectionPoint.y - a.intersectionPoint.y; + } + return a.intersectionPoint.x - b.intersectionPoint.x; +}; + +export const BY_DESCENDING_X_THEN_DESCENDING_Y = (a: Intersection, b: Intersection): number => { + if (a.intersectionPoint.x === b.intersectionPoint.x) { + return b.intersectionPoint.y - a.intersectionPoint.y; + } + return b.intersectionPoint.x - a.intersectionPoint.x; +}; + /** * Finds intersections among edges and updates routed points to reflect those intersections. + * + * This only yields correct intersections among straight line segments and doesn't work with bezier curves. */ @injectable() export class IntersectionFinder implements IEdgeRoutePostprocessor { diff --git a/packages/sprotty/src/graph/views.tsx b/packages/sprotty/src/graph/views.tsx index 9c13ff79..28157a7e 100644 --- a/packages/sprotty/src/graph/views.tsx +++ b/packages/sprotty/src/graph/views.tsx @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2017-2018 TypeFox and others. + * Copyright (c) 2017-2022 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 @@ -19,10 +19,18 @@ import { inject, injectable } from 'inversify'; import { VNode } from "snabbdom"; import { Point } from 'sprotty-protocol/lib/utils/geometry'; import { getSubType } from 'sprotty-protocol/lib/utils/model-utils'; -import { IViewArgs, IView, RenderingContext } from "../base/views/view"; +import { IView, IViewArgs, RenderingContext } from "../base/views/view"; import { setAttr } from '../base/views/vnode-utils'; import { ShapeView } from '../features/bounds/views'; -import { BY_X_THEN_Y, IntersectingRoutedPoint, Intersection, isIntersectingRoutedPoint } from '../features/edge-intersection/intersection-finder'; +import { + BY_DESCENDING_X_THEN_DESCENDING_Y, + BY_DESCENDING_X_THEN_Y, + BY_X_THEN_DESCENDING_Y, + BY_X_THEN_Y, + IntersectingRoutedPoint, + Intersection, + isIntersectingRoutedPoint +} from '../features/edge-intersection/intersection-finder'; import { isEdgeLayoutable } from '../features/edge-layout/model'; import { SRoutableElement, SRoutingHandle } from '../features/routing/model'; import { EdgeRouterRegistry, RoutedPoint } from '../features/routing/routing'; @@ -103,6 +111,8 @@ export class PolylineEdgeView extends RoutableView { * In order to find intersections, `IntersectionFinder` needs to be configured as a `TYPES.IEdgeRoutePostprocessor` * so that that intersections are declared as `IntersectingRoutedPoint` in the computed routes. * + * This view only draws correct line jumps for intersections among straight line segments and doesn't work with bezier curves. + * * @see IntersectionFinder * @see IntersectingRoutedPoint * @see EdgeRouterRegistry @@ -132,31 +142,79 @@ export class JumpingPolylineEdgeView extends PolylineEdgeView { return ; } + /** + * Returns a path that takes the intersections into account by drawing a line jump or a gap for intersections on that path. + */ protected intersectionPath(edge: SEdge, segments: Point[], intersectingPoint: IntersectingRoutedPoint, args?: IViewArgs): string { + if (intersectingPoint.intersections.length < 1) { + return ''; + } + + const segment = this.getLineSegment(edge, intersectingPoint.intersections[0], args, segments); + const intersections = this.getIntersectionsSortedBySegmentDirection(segment, intersectingPoint); + let path = ''; - for (const intersection of intersectingPoint.intersections.sort(BY_X_THEN_Y)) { + for (const intersection of intersections) { const otherLineSegment = this.getOtherLineSegment(edge, intersection, args); if (otherLineSegment === undefined) { continue; } - const lineSegment = this.getLineSegment(edge, intersection, args, segments); + const currentLineSegment = this.getLineSegment(edge, intersection, args, segments); const intersectionPoint = intersection.intersectionPoint; - if (Math.abs(lineSegment.slopeOrMax) < Math.abs(otherLineSegment.slopeOrMax)) { - path += this.createJumpPath(intersectionPoint, lineSegment); - } else { - path += this.createSkipPath(intersectionPoint, lineSegment); + if (this.shouldDrawLineJumpOnIntersection(currentLineSegment, otherLineSegment)) { + path += this.createJumpPath(intersectionPoint, currentLineSegment); + } else if (this.shouldDrawLineGapOnIntersection(currentLineSegment, otherLineSegment)) { + path += this.createGapPath(intersectionPoint, currentLineSegment); } } + return path; } - protected getOtherLineSegment(currentEdge: SEdge, intersection: Intersection, args?: IViewArgs): PointToPointLine | undefined { - const otherEdgeId = intersection.routable1 === currentEdge.id ? intersection.routable2 : intersection.routable1; - const otherEdge = currentEdge.index.getById(otherEdgeId); - if (!(otherEdge instanceof SRoutableElement)) { - return undefined; + /** + * Returns the intersections sorted by the direction of the `lineSegment`. + * + * The coordinate system goes from left to right and top to bottom. + * Thus, x increases to the right and y increases downwards. + * + * We need to draw the intersections in the order of the direction of the line segment. + * To draw a line pointing north, we need to order intersections by Y in a descending order. + * To draw a line pointing south, we need to order intersections by Y in an ascending order. + */ + protected getIntersectionsSortedBySegmentDirection(lineSegment: PointToPointLine, intersectingPoint: IntersectingRoutedPoint) { + switch (lineSegment.direction) { + case 'north': + case 'north-east': + return intersectingPoint.intersections.sort(BY_X_THEN_DESCENDING_Y); + + case 'south': + case 'south-east': + case 'east': + return intersectingPoint.intersections.sort(BY_X_THEN_Y); + + case 'south-west': + case 'west': + return intersectingPoint.intersections.sort(BY_DESCENDING_X_THEN_Y); + + case 'north-west': + return intersectingPoint.intersections.sort(BY_DESCENDING_X_THEN_DESCENDING_Y); } - return this.getLineSegment(otherEdge, intersection, args); + } + + /** + * Whether or not to draw a line jump on an intersection for the `currentLineSegment`. + * This should usually be inverse of `shouldDrawLineGapOnIntersection()`. + */ + protected shouldDrawLineJumpOnIntersection(currentLineSegment: PointToPointLine, otherLineSegment: PointToPointLine) { + return Math.abs(currentLineSegment.slopeOrMax) < Math.abs(otherLineSegment.slopeOrMax); + } + + /** + * Whether or not to draw a line gap on an intersection for the `currentLineSegment`. + * This should usually be inverse of `shouldDrawLineJumpOnIntersection()`. + */ + protected shouldDrawLineGapOnIntersection(currentLineSegment: PointToPointLine, otherLineSegment: PointToPointLine) { + return !this.shouldDrawLineJumpOnIntersection(currentLineSegment, otherLineSegment); } protected getLineSegment(edge: SRoutableElement, intersection: Intersection, args?: IViewArgs, segments?: Point[]): PointToPointLine { @@ -165,6 +223,15 @@ export class JumpingPolylineEdgeView extends PolylineEdgeView { return new PointToPointLine(route[index], route[index + 1]); } + protected getOtherLineSegment(currentEdge: SEdge, intersection: Intersection, args?: IViewArgs): PointToPointLine | undefined { + const otherEdgeId = intersection.routable1 === currentEdge.id ? intersection.routable2 : intersection.routable1; + const otherEdge = currentEdge.index.getById(otherEdgeId); + if (!(otherEdge instanceof SRoutableElement)) { + return undefined; + } + return this.getLineSegment(otherEdge, intersection, args); + } + protected createJumpPath(intersectionPoint: Point, lineSegment: PointToPointLine): string { const anchorBefore = Point.shiftTowards(intersectionPoint, lineSegment.p1, this.jumpOffsetBefore); const anchorAfter = Point.shiftTowards(intersectionPoint, lineSegment.p2, this.jumpOffsetAfter); @@ -172,7 +239,7 @@ export class JumpingPolylineEdgeView extends PolylineEdgeView { return ` L ${anchorBefore.x},${anchorBefore.y} A 1,1 0,0 ${rotation} ${anchorAfter.x},${anchorAfter.y}`; } - protected createSkipPath(intersectionPoint: Point, lineSegment: PointToPointLine): string { + protected createGapPath(intersectionPoint: Point, lineSegment: PointToPointLine): string { let offsetBefore; let offsetAfter; if (intersectionPoint.y < lineSegment.p1.y) { @@ -195,12 +262,14 @@ export class JumpingPolylineEdgeView extends PolylineEdgeView { * In order to find intersections, `IntersectionFinder` needs to be configured as a `TYPES.IEdgeRoutePostprocessor` * so that that intersections are declared as `IntersectingRoutedPoint` in the computed routes. * + * This view only draws correct gaps for intersections among straight line segments and doesn't work with bezier curves. + * * @see IntersectionFinder * @see IntersectingRoutedPoint * @see EdgeRouterRegistry */ - @injectable() - export class PolylineEdgeViewWithGapsOnIntersections extends JumpingPolylineEdgeView { +@injectable() +export class PolylineEdgeViewWithGapsOnIntersections extends JumpingPolylineEdgeView { protected skipOffsetBefore = 3; protected skipOffsetAfter = 3; @@ -209,13 +278,13 @@ export class JumpingPolylineEdgeView extends PolylineEdgeView { return ""; } - protected createSkipPath(intersectionPoint: Point, lineSegment: PointToPointLine): string { + protected createGapPath(intersectionPoint: Point, lineSegment: PointToPointLine): string { const anchorBefore = Point.shiftTowards(intersectionPoint, lineSegment.p1, this.skipOffsetBefore); const anchorAfter = Point.shiftTowards(intersectionPoint, lineSegment.p2, this.skipOffsetAfter); return ` L ${anchorBefore.x},${anchorBefore.y} M ${anchorAfter.x},${anchorAfter.y}`; } - } +} @injectable() export class BezierCurveEdgeView extends RoutableView { @@ -372,7 +441,7 @@ export class SBezierCreateHandleView extends SRoutingHandleView { const text = (handle.kind === "bezier-add") ? "+" : "-"; const node = + class-selected={handle.selected} class-mouseover={handle.hoverFeedback}> {text} @@ -421,7 +490,7 @@ export class SBezierControlHandleView extends SRoutingHandleView { ; } else { node = ; + cx={position.x} cy={position.y} r={this.getRadius()} />; } setAttr(node, 'data-kind', handle.kind); diff --git a/packages/sprotty/src/utils/geometry.spec.ts b/packages/sprotty/src/utils/geometry.spec.ts index 39a99a3f..34817860 100644 --- a/packages/sprotty/src/utils/geometry.spec.ts +++ b/packages/sprotty/src/utils/geometry.spec.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2017-2018 TypeFox and others. + * Copyright (c) 2017-2022 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 @@ -48,4 +48,41 @@ describe('PointToPointLine', () => { expect(intersection).to.be.undefined; }); }); + describe('direction', () => { + // the coordinate system goes from left to right and top to bottom + // thus, x increases to the right and y increases downwards + // so a line going north is (x:0,y:1) -> (x:0:y0) + it('correctly defines line to north', () => { + const line = new PointToPointLine({ x: 0, y: 1 }, { x: 0, y: 0 }); + expect(line.direction).to.equal('north'); + }); + it('correctly defines line to north-east', () => { + const line = new PointToPointLine({ x: 0, y: 1 }, { x: 1, y: 0 }); + expect(line.direction).to.equal('north-east'); + }); + it('correctly defines line to east', () => { + const line = new PointToPointLine({ x: 0, y: 0 }, { x: 1, y: 0 }); + expect(line.direction).to.equal('east'); + }); + it('correctly defines line to south-east', () => { + const line = new PointToPointLine({ x: 0, y: 0 }, { x: 1, y: 1 }); + expect(line.direction).to.equal('south-east'); + }); + it('correctly defines line to south', () => { + const line = new PointToPointLine({ x: 0, y: 0 }, { x: 0, y: 1 }); + expect(line.direction).to.equal('south'); + }); + it('correctly defines line to south-west', () => { + const line = new PointToPointLine({ x: 1, y: 0 }, { x: 0, y: 1 }); + expect(line.direction).to.equal('south-west'); + }); + it('correctly defines line to west', () => { + const line = new PointToPointLine({ x: 1, y: 0 }, { x: 0, y: 0 }); + expect(line.direction).to.equal('west'); + }); + it('correctly defines line to north-west', () => { + const line = new PointToPointLine({ x: 1, y: 1 }, { x: 0, y: 0 }); + expect(line.direction).to.equal('north-west'); + }); + }); }); diff --git a/packages/sprotty/src/utils/geometry.ts b/packages/sprotty/src/utils/geometry.ts index a09dd4e8..b74d1c41 100644 --- a/packages/sprotty/src/utils/geometry.ts +++ b/packages/sprotty/src/utils/geometry.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2017-2018 TypeFox and others. + * Copyright (c) 2017-2022 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 @@ -388,7 +388,7 @@ export function linear(p0: Point, p1: Point, lambda: number): Point { /** * A diamond or rhombus is a quadrilateral whose four sides all have the same length. - * It consinsts of four points, a `topPoint`, `rightPoint`, `bottomPoint`, and a `leftPoint`, + * It consists of four points, a `topPoint`, `rightPoint`, `bottomPoint`, and a `leftPoint`, * which are connected by four lines -- the `topRightSideLight`, `topLeftSideLine`, `bottomRightSideLine`, * and the `bottomLeftSideLine`. */ @@ -472,6 +472,10 @@ export interface Line { readonly c: number } +export type CardinalDirection = + 'north' | 'north-east' | 'east' | 'south-east' | + 'south' | 'south-west' | 'west' | 'north-west'; + /** * A line made up from two points. */ @@ -517,6 +521,33 @@ export class PointToPointLine implements Line { return this.slope; } + /** + * The direction of this line, such as 'north', 'south', or 'south-west'. + */ + get direction(): CardinalDirection { + const hDegrees = toDegrees(this.angle); + const degrees = hDegrees < 0 ? 360 + hDegrees : hDegrees; + // degrees are relative to the x-axis + if (degrees === 90) { + return 'south'; + } else if (degrees === 0 || degrees === 360) { + return 'east'; + } else if (degrees === 270) { + return 'north'; + } else if (degrees === 180) { + return 'west'; + } else if (degrees > 0 && degrees < 90) { + return 'south-east'; + } else if (degrees > 90 && degrees < 180) { + return 'south-west'; + } else if (degrees > 180 && degrees < 270) { + return 'north-west'; + } else if (degrees > 270 && degrees < 360) { + return 'north-east'; + } + throw new Error(`Cannot determine direction of line (${this.p1.x},${this.p1.y}) to (${this.p2.x},${this.p2.y})`); + } + /** * @param otherLine the other line * @returns the intersection point between `this` line and the `otherLine` if exists, or `undefined`.