From e5b2c7787053a582751cb385b43b4c65fcf58e0f Mon Sep 17 00:00:00 2001 From: Jan Bicker Date: Tue, 5 Dec 2023 09:54:55 +0000 Subject: [PATCH] Move edge labels freely or constrain them to the edge --- examples/classdiagram/src/di.config.ts | 4 +- examples/classdiagram/src/model-source.ts | 57 +++++++++++-- packages/sprotty-protocol/src/model.ts | 42 ++++++++++ .../sprotty-protocol/src/utils/geometry.ts | 10 +++ .../src/features/edge-layout/edge-layout.ts | 81 ++++++++++++++----- .../sprotty/src/features/edge-layout/model.ts | 21 ++++- .../features/routing/abstract-edge-router.ts | 35 ++++++++ .../sprotty/src/features/routing/routing.ts | 9 +++ 8 files changed, 229 insertions(+), 30 deletions(-) diff --git a/examples/classdiagram/src/di.config.ts b/examples/classdiagram/src/di.config.ts index d404d0bc..2a9a00c6 100644 --- a/examples/classdiagram/src/di.config.ts +++ b/examples/classdiagram/src/di.config.ts @@ -21,7 +21,7 @@ import { SRoutingHandleView, PreRenderedElementImpl, HtmlRootImpl, SGraphImpl, configureModelElement, SLabelImpl, SCompartmentImpl, SEdgeImpl, SButtonImpl, SRoutingHandleImpl, RevealNamedElementActionProvider, CenterGridSnapper, expandFeature, nameFeature, withEditLabelFeature, editLabelFeature, - RectangularNode, BezierCurveEdgeView, SBezierCreateHandleView, SBezierControlHandleView + RectangularNode, BezierCurveEdgeView, SBezierCreateHandleView, SBezierControlHandleView, moveFeature, selectFeature } from 'sprotty'; import edgeIntersectionModule from 'sprotty/lib/features/edge-intersection/di.config'; import { BezierMouseListener } from 'sprotty/lib/features/routing/bezier-edge-router'; @@ -63,7 +63,7 @@ export default (containerId: string) => { enable: [editLabelFeature] }); configureModelElement(context, 'label:text', PropertyLabel, SLabelView, { - enable: [editLabelFeature] + enable: [moveFeature, selectFeature] }); configureModelElement(context, 'comp:comp', SCompartmentImpl, SCompartmentView); configureModelElement(context, 'comp:header', SCompartmentImpl, SCompartmentView); diff --git a/examples/classdiagram/src/model-source.ts b/examples/classdiagram/src/model-source.ts index a080367d..7710d7de 100644 --- a/examples/classdiagram/src/model-source.ts +++ b/examples/classdiagram/src/model-source.ts @@ -15,7 +15,7 @@ ********************************************************************************/ import { injectable } from 'inversify'; -import { ActionHandlerRegistry, LocalModelSource, Expandable } from 'sprotty'; +import { ActionHandlerRegistry, LocalModelSource, Expandable, EdgeLayoutable } from 'sprotty'; import { Action, CollapseExpandAction, CollapseExpandAllAction, SCompartment, SEdge, SGraph, SLabel, SModelElement, SModelIndex, SModelRoot, SNode @@ -400,14 +400,15 @@ export class ClassDiagramModelSource extends LocalModelSource { rotate: false } }, - { + { id: 'edge0_label_right', type: 'label:text', text: 'right', edgePlacement: { position: 0.7, side: 'right', - rotate: false + rotate: false, + moveMode: 'edge' // optional, because it's the default anyway } } ] @@ -456,13 +457,15 @@ export class ClassDiagramModelSource extends LocalModelSource { side: 'left' } }, - { + { id: 'edge1_label_right', type: 'label:text', text: 'right', edgePlacement: { position: 1, - side: 'right' + rotate: true, + side: 'right', + moveMode: 'edge' } } ] @@ -480,7 +483,49 @@ export class ClassDiagramModelSource extends LocalModelSource { { x: 390, y: 120 }, { x: 450, y: 40 } ], - children: [] + children: [ + { + id: 'edge2_label_free1', + type: 'label:text', + text: 'free1', + edgePlacement: { + position: 0.9, + offset: 10, + side: 'top', + rotate: false, + moveMode: 'free' + } + }, + { + id: 'edge2_label_edge', + type: 'label:text', + text: 'edge', + edgePlacement: { + position: 0.2, + offset: 0, + side: 'right', + rotate: true, + moveMode: 'edge' + } + }, + { + id: 'edge2_label_fix', + type: 'label:text', + text: 'fix', + edgePlacement: { + position: 0.3, + offset: 10, + side: 'left', + rotate: true, + moveMode: 'none' + } + }, + { + id: 'edge2_label_free2', + type: 'label:text', + text: 'free2' + } + ] } as SEdge; const graph: SGraph = { id: 'graph', diff --git a/packages/sprotty-protocol/src/model.ts b/packages/sprotty-protocol/src/model.ts index 43561a98..302dce23 100644 --- a/packages/sprotty-protocol/src/model.ts +++ b/packages/sprotty-protocol/src/model.ts @@ -207,3 +207,45 @@ export interface ForeignObjectElement extends ShapedPreRenderedElement { /** The namespace to be assigned to the elements inside of the `foreignObject`. */ namespace: string } + +/** + * Feature extension interface for {@link edgeLayoutFeature}. + */ +export interface EdgeLayoutable { + edgePlacement: EdgePlacement +} + +export type EdgeSide = 'left' | 'right' | 'top' | 'bottom' | 'on'; + +/** + * Each label attached to an edge can be placed on the edge in different ways. + * With this interface the placement of such a single label is defined. + */ +export interface EdgePlacement { + /** + * true, if the label should be rotated to touch the edge tangentially + */ + rotate: boolean; + + /** + * where is the label relative to the line's direction + */ + side: EdgeSide; + + /** + * between 0 (source anchor) and 1 (target anchor) + */ + position: number; + + /** + * space between label and edge/connected nodes + */ + offset: number; + + /** + * where should the label be moved when move feature is enabled. + * 'edge' means the label is moved along the edge, 'free' means the label is moved freely, 'none' means the label can not be moved. + * Default is 'edge'. + */ + moveMode?: 'edge' | 'free' | 'none'; +} diff --git a/packages/sprotty-protocol/src/utils/geometry.ts b/packages/sprotty-protocol/src/utils/geometry.ts index 038c1551..a6b9f926 100644 --- a/packages/sprotty-protocol/src/utils/geometry.ts +++ b/packages/sprotty-protocol/src/utils/geometry.ts @@ -150,6 +150,16 @@ export namespace Point { export function maxDistance(a: Point, b: Point): number { return Math.max(Math.abs(b.x - a.x), Math.abs(b.y - a.y)); } + + /** + * Returns the dot product of two points. + * @param {Point} a - First point + * @param {Point} b - Second point + * @returns {number} The dot product + */ + export function dotProduct(a: Point, b: Point): number { + return a.x * b.x + a.y * b.y; + } } /** diff --git a/packages/sprotty/src/features/edge-layout/edge-layout.ts b/packages/sprotty/src/features/edge-layout/edge-layout.ts index fe6abe4e..18d1923c 100644 --- a/packages/sprotty/src/features/edge-layout/edge-layout.ts +++ b/packages/sprotty/src/features/edge-layout/edge-layout.ts @@ -23,44 +23,87 @@ import { setAttr } from "../../base/views/vnode-utils"; import { SEdgeImpl } from "../../graph/sgraph"; import { Orientation } from "../../utils/geometry"; import { isAlignable, BoundsAware } from "../bounds/model"; -import { DEFAULT_EDGE_PLACEMENT, isEdgeLayoutable, EdgeLayoutable, EdgePlacement } from "./model"; +import { DEFAULT_EDGE_PLACEMENT, isEdgeLayoutable, EdgeLayoutable, EdgePlacement, checkEdgePlacement } from "./model"; import { EdgeRouterRegistry } from "../routing/routing"; +import { TYPES } from "../../base/types"; +import { ILogger } from "../../utils/logging"; @injectable() export class EdgeLayoutPostprocessor implements IVNodePostprocessor { @inject(EdgeRouterRegistry) edgeRouterRegistry: EdgeRouterRegistry; + @inject(TYPES.ILogger) protected readonly logger: ILogger; + /** + * Decorates the vnode with the appropriate transformation based on the element's placement and bounds. + * @param vnode - The vnode to decorate. + * @param element - The SModelElementImpl to decorate. + * @returns The decorated vnode. + */ decorate(vnode: VNode, element: SModelElementImpl): VNode { if (isEdgeLayoutable(element) && element.parent instanceof SEdgeImpl) { if (element.bounds !== Bounds.EMPTY) { + const actualBounds = element.bounds; + const hasOwnPlacement = checkEdgePlacement(element); const placement = this.getEdgePlacement(element); const edge = element.parent; const position = Math.min(1, Math.max(0, placement.position)); const router = this.edgeRouterRegistry.get(edge.routerKind); + // point on edge derived from edgePlacement.position const pointOnEdge = router.pointAt(edge, position); - const derivativeOnEdge = router.derivativeAt(edge, position); let transform = ''; - if (pointOnEdge && derivativeOnEdge) { - transform += `translate(${pointOnEdge.x}, ${pointOnEdge.y})`; - const angle = toDegrees(Math.atan2(derivativeOnEdge.y, derivativeOnEdge.x)); - if (placement.rotate) { - let flippedAngle = angle; - if (Math.abs(angle) > 90) { - if (angle < 0) - flippedAngle += 180; - else if (angle > 0) - flippedAngle -= 180; + // Calculation of potential free movement. Just add the actual bounds to the point on edge. + const freeTransform = `translate(${(pointOnEdge?.x ?? 0) + actualBounds.x}, ${(pointOnEdge?.y ?? 0) + actualBounds.y})`; + // Check if edgeplacement is set. If not the label is freely movable if movefeature is enabled for such labels. + if (hasOwnPlacement) { + if (pointOnEdge) { + let derivativeOnEdge: Point | undefined; + // handle different move modes + if (placement.moveMode && placement.moveMode !== 'edge') { + // get the relative position on segment + derivativeOnEdge = router.derivativeAt(edge, position); + // handle free move mode + if (placement.moveMode === 'free') { + transform += freeTransform; + } else { + // The moveMode is neither 'edge' nor 'free' so it is 'none'. Hence the label is not movable and gets the fixed point on edge. + transform += `translate(${pointOnEdge.x}, ${pointOnEdge.y})`; + } + } else { + // no movemode was set or set to 'edge': label movement is constrained to the edge + // Find orthogonal intersection point on edge and use it as the label's position + const orthogonalPoint = router.findOrthogonalIntersection(edge, Point.add(pointOnEdge, actualBounds)); + if (orthogonalPoint) { + derivativeOnEdge = orthogonalPoint.derivative; + transform += `translate(${orthogonalPoint.point.x}, ${orthogonalPoint.point.y})`; + } + } + if (derivativeOnEdge) { + const angle = toDegrees(Math.atan2(derivativeOnEdge.y, derivativeOnEdge.x)); + if (placement.rotate) { + let flippedAngle = angle; + // Flip angle if it exceeds 90 degrees + if (Math.abs(angle) > 90) { + if (angle < 0) + flippedAngle += 180; + else if (angle > 0) + flippedAngle -= 180; + } + transform += ` rotate(${flippedAngle})`; + // Get rotated alignment based on flipped angle + const alignment = this.getRotatedAlignment(element, placement, flippedAngle !== angle); + transform += ` translate(${alignment.x}, ${alignment.y})`; + } else { + // Get alignment based on angle + const alignment = this.getAlignment(element, placement, angle); + transform += ` translate(${alignment.x}, ${alignment.y})`; + } } - transform += ` rotate(${flippedAngle})`; - const alignment = this.getRotatedAlignment(element, placement, flippedAngle !== angle); - transform += ` translate(${alignment.x}, ${alignment.y})`; - } else { - const alignment = this.getAlignment(element, placement, angle); - transform += ` translate(${alignment.x}, ${alignment.y})`; } - setAttr(vnode, 'transform', transform); + } else { + transform += freeTransform; } + setAttr(vnode, 'transform', transform); } } return vnode; diff --git a/packages/sprotty/src/features/edge-layout/model.ts b/packages/sprotty/src/features/edge-layout/model.ts index c3ca0458..0cca50a7 100644 --- a/packages/sprotty/src/features/edge-layout/model.ts +++ b/packages/sprotty/src/features/edge-layout/model.ts @@ -21,6 +21,7 @@ import { SRoutableElementImpl } from '../routing/model'; export const edgeLayoutFeature = Symbol('edgeLayout'); /** + * @deprecated Use EdgeLayoutable from sprotty-protocol instead * Feature extension interface for {@link edgeLayoutFeature}. */ export interface EdgeLayoutable { @@ -30,17 +31,22 @@ export interface EdgeLayoutable { export function isEdgeLayoutable(element: T): element is T & SChildElementImpl & BoundsAware & EdgeLayoutable { return element instanceof SChildElementImpl && element.parent instanceof SRoutableElementImpl - && checkEdgeLayoutable(element) && isBoundsAware(element) && element.hasFeature(edgeLayoutFeature); } -function checkEdgeLayoutable(element: SChildElementImpl): element is SChildElementImpl & EdgeLayoutable { +export function checkEdgePlacement(element: SChildElementImpl): element is SChildElementImpl & EdgeLayoutable { return 'edgePlacement' in element; } +/** + * @deprecated Use EdgeSide from sprotty-protocol instead + */ export type EdgeSide = 'left' | 'right' | 'top' | 'bottom' | 'on'; +/** + * @deprecated Use EdgePlacement from sprotty-protocol instead + */ export class EdgePlacement extends Object { /** * true, if the label should be rotated to touch the edge tangentially @@ -61,11 +67,20 @@ export class EdgePlacement extends Object { * space between label and edge/connected nodes */ offset: number; + + /** + * where should the label be moved when move feature is enabled. + * 'edge' means the label is moved along the edge, 'free' means the label is moved freely, 'none' means the label is not moved. + * Default is 'edge'. + */ + moveMode?: 'edge' | 'free' | 'none'; + } export const DEFAULT_EDGE_PLACEMENT: EdgePlacement = { rotate: true, side: 'top', position: 0.5, - offset: 7 + offset: 7, + moveMode: 'edge' }; diff --git a/packages/sprotty/src/features/routing/abstract-edge-router.ts b/packages/sprotty/src/features/routing/abstract-edge-router.ts index 58dddde0..134c34e8 100644 --- a/packages/sprotty/src/features/routing/abstract-edge-router.ts +++ b/packages/sprotty/src/features/routing/abstract-edge-router.ts @@ -88,6 +88,41 @@ export abstract class AbstractEdgeRouter implements IEdgeRouter { protected abstract getOptions(edge: SRoutableElementImpl): LinearRouteOptions; + findOrthogonalIntersection(edge: SRoutableElementImpl, point: Point): {point: Point, derivative: Point} { + const calcOrthogonalIntersectionForSegment = (p1: Point, p2: Point) => { + // Calculate the direction vector d of the edge and vector pq from p1 to point q + const d: Point = Point.subtract(p2, p1); + const pq: Point = Point.subtract(point, p1); + + // Calculate the scalar t for the direction vector d + const t: number = Point.dotProduct(pq, d) / Point.dotProduct(d, d); + + // Check if the intersection point lies on the edge segment + if (t >= 0 && t <= 1) { + // Calculate and return the intersection point x + return Point.linear(p1, p2, t); + } else if (t < 0) { + return p1; + } else { + return p2; + } + }; + + // Calculate the intersection for each segment of the edge and return the closest one + const routedPoints = this.route(edge); + let intersectionPoint: Point = routedPoints[0]; + let index = 0; + for (let i = 0; i < routedPoints.length - 1; ++i) { + const intersection = calcOrthogonalIntersectionForSegment(routedPoints[i], routedPoints[i + 1]); + if (Point.euclideanDistance(point, intersection) < Point.euclideanDistance(point, intersectionPoint)) { + intersectionPoint = intersection; + index = i; + } + } + const derivative = Point.subtract(routedPoints[index + 1], routedPoints[index]); + return {point: intersectionPoint, derivative}; + } + pointAt(edge: SRoutableElementImpl, t: number): Point | undefined { const segments = this.calculateSegment(edge, t); if (!segments) diff --git a/packages/sprotty/src/features/routing/routing.ts b/packages/sprotty/src/features/routing/routing.ts index f3a268ad..8803dd36 100644 --- a/packages/sprotty/src/features/routing/routing.ts +++ b/packages/sprotty/src/features/routing/routing.ts @@ -71,6 +71,15 @@ export interface IEdgeRouter { */ route(edge: SRoutableElementImpl): RoutedPoint[] + /** + * Finds the orthogonal intersection point between an edge and a given point in 2D space. + * + * @param edge - The edge to find the intersection point on. + * @param point - The point to find the intersection with. + * @returns The intersection point and its derivative on the respective edge segment. + */ + findOrthogonalIntersection(edge: SRoutableElementImpl, point: Point): {point: Point, derivative: Point} | undefined + /** * Calculates a point on the edge *