Skip to content

Commit

Permalink
Move edge labels
Browse files Browse the repository at this point in the history
freely or constrain them to the edge
  • Loading branch information
jbicker authored and spoenemann committed Jan 2, 2024
1 parent ee9698d commit e5b2c77
Show file tree
Hide file tree
Showing 8 changed files with 229 additions and 30 deletions.
4 changes: 2 additions & 2 deletions examples/classdiagram/src/di.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
57 changes: 51 additions & 6 deletions examples/classdiagram/src/model-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -400,14 +400,15 @@ export class ClassDiagramModelSource extends LocalModelSource {
rotate: false
}
},
<SLabel> {
<SLabel & EdgeLayoutable> {
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
}
}
]
Expand Down Expand Up @@ -456,13 +457,15 @@ export class ClassDiagramModelSource extends LocalModelSource {
side: 'left'
}
},
<SLabel> {
<SLabel & EdgeLayoutable> {
id: 'edge1_label_right',
type: 'label:text',
text: 'right',
edgePlacement: {
position: 1,
side: 'right'
rotate: true,
side: 'right',
moveMode: 'edge'
}
}
]
Expand All @@ -480,7 +483,49 @@ export class ClassDiagramModelSource extends LocalModelSource {
{ x: 390, y: 120 },
{ x: 450, y: 40 }
],
children: []
children: [
<SLabel & EdgeLayoutable> {
id: 'edge2_label_free1',
type: 'label:text',
text: 'free1',
edgePlacement: {
position: 0.9,
offset: 10,
side: 'top',
rotate: false,
moveMode: 'free'
}
},
<SLabel & EdgeLayoutable> {
id: 'edge2_label_edge',
type: 'label:text',
text: 'edge',
edgePlacement: {
position: 0.2,
offset: 0,
side: 'right',
rotate: true,
moveMode: 'edge'
}
},
<SLabel & EdgeLayoutable> {
id: 'edge2_label_fix',
type: 'label:text',
text: 'fix',
edgePlacement: {
position: 0.3,
offset: 10,
side: 'left',
rotate: true,
moveMode: 'none'
}
},
<SLabel> {
id: 'edge2_label_free2',
type: 'label:text',
text: 'free2'
}
]
} as SEdge;
const graph: SGraph = {
id: 'graph',
Expand Down
42 changes: 42 additions & 0 deletions packages/sprotty-protocol/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
10 changes: 10 additions & 0 deletions packages/sprotty-protocol/src/utils/geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

/**
Expand Down
81 changes: 62 additions & 19 deletions packages/sprotty/src/features/edge-layout/edge-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 18 additions & 3 deletions packages/sprotty/src/features/edge-layout/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -30,17 +31,22 @@ export interface EdgeLayoutable {
export function isEdgeLayoutable<T extends SModelElementImpl>(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
Expand All @@ -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'
};
35 changes: 35 additions & 0 deletions packages/sprotty/src/features/routing/abstract-edge-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions packages/sprotty/src/features/routing/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down

0 comments on commit e5b2c77

Please sign in to comment.