Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move edge labels #411

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {
jbicker marked this conversation as resolved.
Show resolved Hide resolved
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';

}
jbicker marked this conversation as resolved.
Show resolved Hide resolved

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