@yalehwang/plantuml-js 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/dist/core.d.ts +102 -9
- package/dist/core.js +91 -51
- package/dist/foundation/layers.d.ts +25 -0
- package/dist/foundation/layers.js +101 -0
- package/dist/foundation/layout.d.ts +116 -0
- package/dist/foundation/layout.js +298 -0
- package/dist/{model.d.ts → foundation/model.d.ts} +20 -0
- package/dist/foundation/relation-semantics.d.ts +49 -0
- package/dist/foundation/relation-semantics.js +112 -0
- package/dist/{text-measure.d.ts → foundation/text-measure.d.ts} +2 -0
- package/dist/{text-measure.js → foundation/text-measure.js} +20 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/layout/compound.d.ts +59 -0
- package/dist/layout/compound.js +1178 -0
- package/dist/layout/elk-graph.d.ts +36 -0
- package/dist/layout/elk-graph.js +220 -0
- package/dist/layout/elk-result.d.ts +18 -0
- package/dist/layout/elk-result.js +127 -0
- package/dist/layout/elk.d.ts +63 -33
- package/dist/layout/elk.js +215 -321
- package/dist/layout/fanout-trunk.d.ts +18 -0
- package/dist/layout/fanout-trunk.js +380 -0
- package/dist/layout/grid-router.d.ts +69 -0
- package/dist/layout/grid-router.js +615 -0
- package/dist/layout/lane-spread.d.ts +11 -0
- package/dist/layout/lane-spread.js +302 -0
- package/dist/layout/layout-metrics.d.ts +183 -0
- package/dist/layout/layout-metrics.js +766 -0
- package/dist/layout/package-endpoints.d.ts +10 -0
- package/dist/layout/package-endpoints.js +54 -0
- package/dist/layout/placement-strategy.d.ts +98 -0
- package/dist/layout/placement-strategy.js +26 -0
- package/dist/layout/strategy.d.ts +27 -0
- package/dist/layout/strategy.js +19 -0
- package/dist/layout/trunk-lanes.d.ts +29 -0
- package/dist/layout/trunk-lanes.js +308 -0
- package/dist/layout/viz.d.ts +3 -3
- package/dist/layout/viz.js +13 -17
- package/dist/parser.d.ts +27 -1
- package/dist/parser.js +111 -5
- package/dist/render/labels.d.ts +44 -0
- package/dist/render/labels.js +231 -0
- package/dist/render/legend.d.ts +15 -0
- package/dist/render/legend.js +42 -0
- package/dist/render/svg.d.ts +60 -3
- package/dist/render/svg.js +326 -98
- package/dist/render/theme.d.ts +109 -0
- package/dist/render/theme.js +99 -0
- package/dist/serialize.d.ts +32 -0
- package/dist/serialize.js +218 -0
- package/dist/transform.d.ts +55 -1
- package/dist/transform.js +277 -29
- package/package.json +4 -3
- package/dist/layout-metrics.d.ts +0 -42
- package/dist/layout-metrics.js +0 -241
- package/dist/layout.d.ts +0 -75
- package/dist/layout.js +0 -51
- /package/dist/{edge-router.d.ts → foundation/edge-router.d.ts} +0 -0
- /package/dist/{edge-router.js → foundation/edge-router.js} +0 -0
- /package/dist/{model.js → foundation/model.js} +0 -0
- /package/dist/{render-dot.d.ts → foundation/render-dot.d.ts} +0 -0
- /package/dist/{render-dot.js → foundation/render-dot.js} +0 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-endpoint clipping. A relation that names a package (`X --> B` with
|
|
3
|
+
* `package ... as X`) is laid out against a representative entity inside it,
|
|
4
|
+
* so the routed polyline ends on that entity's box. The author meant the
|
|
5
|
+
* package, so the polyline is cut where it crosses the package frame: the edge
|
|
6
|
+
* visibly starts/ends on the frame boundary instead of reaching into it.
|
|
7
|
+
*/
|
|
8
|
+
import { PlantUmlModel } from '../foundation/model.js';
|
|
9
|
+
import { LayoutResult } from '../foundation/layout.js';
|
|
10
|
+
export declare function clipPackageEndpoints(model: PlantUmlModel, layout: LayoutResult): LayoutResult;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-endpoint clipping. A relation that names a package (`X --> B` with
|
|
3
|
+
* `package ... as X`) is laid out against a representative entity inside it,
|
|
4
|
+
* so the routed polyline ends on that entity's box. The author meant the
|
|
5
|
+
* package, so the polyline is cut where it crosses the package frame: the edge
|
|
6
|
+
* visibly starts/ends on the frame boundary instead of reaching into it.
|
|
7
|
+
*/
|
|
8
|
+
const inside = (p, r) => p.x > r.x && p.x < r.x + r.width && p.y > r.y && p.y < r.y + r.height;
|
|
9
|
+
/** Point where segment a→b (a inside `r`, b outside) leaves the rectangle. */
|
|
10
|
+
function exitPoint(a, b, r) {
|
|
11
|
+
let t = 1;
|
|
12
|
+
const dx = b.x - a.x;
|
|
13
|
+
const dy = b.y - a.y;
|
|
14
|
+
if (dx > 0)
|
|
15
|
+
t = Math.min(t, (r.x + r.width - a.x) / dx);
|
|
16
|
+
if (dx < 0)
|
|
17
|
+
t = Math.min(t, (r.x - a.x) / dx);
|
|
18
|
+
if (dy > 0)
|
|
19
|
+
t = Math.min(t, (r.y + r.height - a.y) / dy);
|
|
20
|
+
if (dy < 0)
|
|
21
|
+
t = Math.min(t, (r.y - a.y) / dy);
|
|
22
|
+
return { x: a.x + dx * t, y: a.y + dy * t };
|
|
23
|
+
}
|
|
24
|
+
/** Cut the head of `points` at the first exit from `r`; unchanged if it never leaves. */
|
|
25
|
+
function clipHead(points, r) {
|
|
26
|
+
if (points.length < 2 || !inside(points[0], r))
|
|
27
|
+
return points;
|
|
28
|
+
for (let i = 1; i < points.length; i++) {
|
|
29
|
+
if (!inside(points[i], r)) {
|
|
30
|
+
return [exitPoint(points[i - 1], points[i], r), ...points.slice(i)];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return points;
|
|
34
|
+
}
|
|
35
|
+
export function clipPackageEndpoints(model, layout) {
|
|
36
|
+
if (!model.relations.some((r) => r.sourcePackageId || r.targetPackageId))
|
|
37
|
+
return layout;
|
|
38
|
+
const boxes = new Map((layout.packages ?? []).map((p) => [p.id, p]));
|
|
39
|
+
const edges = layout.edges.map((edge) => {
|
|
40
|
+
const m = /^rel-(\d+)$/.exec(edge.id);
|
|
41
|
+
const rel = m ? model.relations[Number(m[1])] : undefined;
|
|
42
|
+
if (!rel)
|
|
43
|
+
return edge;
|
|
44
|
+
let points = edge.points;
|
|
45
|
+
const src = rel.sourcePackageId ? boxes.get(rel.sourcePackageId) : undefined;
|
|
46
|
+
const dst = rel.targetPackageId ? boxes.get(rel.targetPackageId) : undefined;
|
|
47
|
+
if (src)
|
|
48
|
+
points = clipHead(points, src);
|
|
49
|
+
if (dst)
|
|
50
|
+
points = clipHead([...points].reverse(), dst).reverse();
|
|
51
|
+
return points === edge.points ? edge : { ...edge, points };
|
|
52
|
+
});
|
|
53
|
+
return { ...layout, edges };
|
|
54
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block placement strategy extension point for the compound layout.
|
|
3
|
+
*
|
|
4
|
+
* The compound layout lays each top-level package out as a block, then has to
|
|
5
|
+
* decide where the blocks go: orbit stubs, stack semantic layers, rect-pack the
|
|
6
|
+
* blocks, or let ELK's layered algorithm place them. Each of those is a
|
|
7
|
+
* [[PlacementStrategy]]; the first one to return a placement wins and
|
|
8
|
+
* `undefined` declines to the next. New placement behaviour is added by
|
|
9
|
+
* injecting strategies through `CompoundOptions.placementStrategies` (tried
|
|
10
|
+
* before the built-in chain) instead of editing `placeBlocks`' decision chain.
|
|
11
|
+
* A strategy that throws is not swallowed: the error propagates out of the
|
|
12
|
+
* layout.
|
|
13
|
+
*
|
|
14
|
+
* The same interface also covers the smaller "pack the boxes inside one block"
|
|
15
|
+
* dimension ([[BoxPlacementContext]]), so the ordered shelf pack and the
|
|
16
|
+
* equal-column grid are strategies too rather than branches in `layoutBlock`.
|
|
17
|
+
*/
|
|
18
|
+
import type { ELK as ElkInstance, ElkNode } from 'elkjs';
|
|
19
|
+
import type { PlantUmlModel } from '../foundation/model.js';
|
|
20
|
+
import type { NodeLayout, PackageLayout, Point } from '../foundation/layout.js';
|
|
21
|
+
/** Channel to the frame (`gap`) and spacing between neighbouring stubs (`spacing`), px. */
|
|
22
|
+
export interface StubOrbit {
|
|
23
|
+
gap: number;
|
|
24
|
+
spacing: number;
|
|
25
|
+
/** Row/column a stub slides to: its targets' centroid (default) or the target nearest its side. */
|
|
26
|
+
anchor?: 'centroid' | 'nearest';
|
|
27
|
+
}
|
|
28
|
+
/** One top-level block as laid out on its own, ready to be placed. */
|
|
29
|
+
export interface Block {
|
|
30
|
+
/** Block key: package id, or entity id for a bare top-level entity. */
|
|
31
|
+
key: string;
|
|
32
|
+
width: number;
|
|
33
|
+
height: number;
|
|
34
|
+
nodes: NodeLayout[];
|
|
35
|
+
packages: PackageLayout[];
|
|
36
|
+
/** Intra-block edge polylines keyed by relation index, block-local coordinates. */
|
|
37
|
+
edges: Map<number, Point[]>;
|
|
38
|
+
/** Intra-block relations the block layout left for the grid router. */
|
|
39
|
+
unrouted?: number[];
|
|
40
|
+
}
|
|
41
|
+
/** Where every placed block (or box) sits, and the canvas size that needs. */
|
|
42
|
+
export interface Placement {
|
|
43
|
+
positions: Map<string, Point>;
|
|
44
|
+
width: number;
|
|
45
|
+
height: number;
|
|
46
|
+
}
|
|
47
|
+
/** Knobs a strategy may consult; `CompoundOptions` satisfies this shape. */
|
|
48
|
+
export interface PlacementStrategyOptions {
|
|
49
|
+
/** Pack every flat block tightly (very large graphs). */
|
|
50
|
+
dense?: boolean;
|
|
51
|
+
/** Dense blocks use rect packing instead of dependency-ordered rows. */
|
|
52
|
+
rectPackDense?: boolean;
|
|
53
|
+
/** Target canvas width/height (ELK `elk.aspectRatio`). */
|
|
54
|
+
aspectRatio?: number;
|
|
55
|
+
/** Raw ELK options injected into each block's root and package containers. */
|
|
56
|
+
elk?: Record<string, string>;
|
|
57
|
+
/** Block id → semantic-layer partition index. */
|
|
58
|
+
partitions: ReadonlyMap<string, number>;
|
|
59
|
+
/** Where stub nodes orbit the placed blocks; the caller resolves the default. */
|
|
60
|
+
stubOrbit?: StubOrbit;
|
|
61
|
+
}
|
|
62
|
+
/** What the layout tells a block placement strategy about the run it is part of. */
|
|
63
|
+
export interface PlacementStrategyContext {
|
|
64
|
+
readonly elk: ElkInstance;
|
|
65
|
+
readonly model: PlantUmlModel;
|
|
66
|
+
readonly blocks: ReadonlyMap<string, Block>;
|
|
67
|
+
readonly crossRelations: readonly number[];
|
|
68
|
+
readonly blockOfEntity: ReadonlyMap<string, string>;
|
|
69
|
+
readonly options: PlacementStrategyOptions;
|
|
70
|
+
/**
|
|
71
|
+
* The chain this strategy runs in. A strategy may re-enter it for a
|
|
72
|
+
* sub-placement (stub orbit places the main blocks first).
|
|
73
|
+
*/
|
|
74
|
+
readonly strategies: readonly PlacementStrategy[];
|
|
75
|
+
}
|
|
76
|
+
/** What a box placement strategy is given: the boxes of one block, in dependency order. */
|
|
77
|
+
export interface BoxPlacementContext {
|
|
78
|
+
/** The boxes to arrange; their order is preserved when they are materialised. */
|
|
79
|
+
readonly boxes: readonly ElkNode[];
|
|
80
|
+
/** Box ids in dependency order. */
|
|
81
|
+
readonly order: readonly string[];
|
|
82
|
+
/** Target canvas width/height. */
|
|
83
|
+
readonly aspect: number;
|
|
84
|
+
/** Gap between boxes, px. */
|
|
85
|
+
readonly gap: number;
|
|
86
|
+
/** Row gap, when it differs from `gap`. */
|
|
87
|
+
readonly rowGap?: number;
|
|
88
|
+
/** Size each column to its own widest box (grid pack only). */
|
|
89
|
+
readonly tight?: boolean;
|
|
90
|
+
}
|
|
91
|
+
export interface PlacementStrategy<C = PlacementStrategyContext> {
|
|
92
|
+
/** Short identifier, used in diagnostics. */
|
|
93
|
+
readonly name: string;
|
|
94
|
+
/** Place the blocks/boxes, or return `undefined` to decline. */
|
|
95
|
+
place(ctx: C): Placement | undefined | Promise<Placement | undefined>;
|
|
96
|
+
}
|
|
97
|
+
/** First non-declining placement, or `undefined` when all decline. */
|
|
98
|
+
export declare function runPlacementStrategies<C>(strategies: readonly PlacementStrategy<C>[] | undefined, ctx: C): Promise<Placement | undefined>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block placement strategy extension point for the compound layout.
|
|
3
|
+
*
|
|
4
|
+
* The compound layout lays each top-level package out as a block, then has to
|
|
5
|
+
* decide where the blocks go: orbit stubs, stack semantic layers, rect-pack the
|
|
6
|
+
* blocks, or let ELK's layered algorithm place them. Each of those is a
|
|
7
|
+
* [[PlacementStrategy]]; the first one to return a placement wins and
|
|
8
|
+
* `undefined` declines to the next. New placement behaviour is added by
|
|
9
|
+
* injecting strategies through `CompoundOptions.placementStrategies` (tried
|
|
10
|
+
* before the built-in chain) instead of editing `placeBlocks`' decision chain.
|
|
11
|
+
* A strategy that throws is not swallowed: the error propagates out of the
|
|
12
|
+
* layout.
|
|
13
|
+
*
|
|
14
|
+
* The same interface also covers the smaller "pack the boxes inside one block"
|
|
15
|
+
* dimension ([[BoxPlacementContext]]), so the ordered shelf pack and the
|
|
16
|
+
* equal-column grid are strategies too rather than branches in `layoutBlock`.
|
|
17
|
+
*/
|
|
18
|
+
/** First non-declining placement, or `undefined` when all decline. */
|
|
19
|
+
export async function runPlacementStrategies(strategies, ctx) {
|
|
20
|
+
for (const strategy of strategies ?? []) {
|
|
21
|
+
const result = await strategy.place(ctx);
|
|
22
|
+
if (result !== undefined)
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout strategy extension point for the ELK engine.
|
|
3
|
+
*
|
|
4
|
+
* A strategy is tried before the engine's built-in decision chain; the first
|
|
5
|
+
* one that returns a result wins, `undefined` declines and falls through. New
|
|
6
|
+
* layout strategies are added by injecting them through
|
|
7
|
+
* `ElkLayoutOptions.strategies` (or `createRenderer({ layoutOptions })`)
|
|
8
|
+
* instead of editing `ElkLayoutEngine`. A strategy that throws is not
|
|
9
|
+
* swallowed: the error propagates out of the render.
|
|
10
|
+
*/
|
|
11
|
+
import type { PlantUmlModel } from '../foundation/model.js';
|
|
12
|
+
import type { LayoutResult } from '../foundation/layout.js';
|
|
13
|
+
/** What the engine tells a strategy about the run it is part of. */
|
|
14
|
+
export interface LayoutStrategyContext {
|
|
15
|
+
/** Semantic layer order resolved for the model. */
|
|
16
|
+
layers: string[];
|
|
17
|
+
/** Layer partition index per entity id. */
|
|
18
|
+
partitions: Map<string, number>;
|
|
19
|
+
}
|
|
20
|
+
export interface LayoutStrategy {
|
|
21
|
+
/** Short identifier, used in diagnostics. */
|
|
22
|
+
readonly name: string;
|
|
23
|
+
/** Lay the model out, or return `undefined` to decline. */
|
|
24
|
+
layout(model: PlantUmlModel, ctx: LayoutStrategyContext): Promise<LayoutResult | undefined>;
|
|
25
|
+
}
|
|
26
|
+
/** First non-declining strategy result, or `undefined` when all decline. */
|
|
27
|
+
export declare function runStrategies(strategies: readonly LayoutStrategy[] | undefined, model: PlantUmlModel, ctx: LayoutStrategyContext): Promise<LayoutResult | undefined>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout strategy extension point for the ELK engine.
|
|
3
|
+
*
|
|
4
|
+
* A strategy is tried before the engine's built-in decision chain; the first
|
|
5
|
+
* one that returns a result wins, `undefined` declines and falls through. New
|
|
6
|
+
* layout strategies are added by injecting them through
|
|
7
|
+
* `ElkLayoutOptions.strategies` (or `createRenderer({ layoutOptions })`)
|
|
8
|
+
* instead of editing `ElkLayoutEngine`. A strategy that throws is not
|
|
9
|
+
* swallowed: the error propagates out of the render.
|
|
10
|
+
*/
|
|
11
|
+
/** First non-declining strategy result, or `undefined` when all decline. */
|
|
12
|
+
export async function runStrategies(strategies, model, ctx) {
|
|
13
|
+
for (const strategy of strategies ?? []) {
|
|
14
|
+
const result = await strategy.layout(model, ctx);
|
|
15
|
+
if (result !== undefined)
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trunk lanes. A router that has to dodge a cluster of nodes can leave an edge
|
|
3
|
+
* that overshoots — out sideways, down, back across, down again — and stacked
|
|
4
|
+
* edges doing this draw the concentric rectangles of a coil. When the two end
|
|
5
|
+
* segments run the same axis, the edge is re-drawn as a Z: leave the source,
|
|
6
|
+
* cross over on a lane between the rows (the gap between the two end points),
|
|
7
|
+
* and enter the target. It runs after lane spreading and fan-out merging, so only
|
|
8
|
+
* what those passes left as an overshoot is touched. A Z replaces a detour only when it is shorter, passes
|
|
9
|
+
* no unrelated node or package frame, and adds at most [[EXTRA_CROSSINGS]] crossings.
|
|
10
|
+
*/
|
|
11
|
+
import { LayoutResult } from '../foundation/layout.js';
|
|
12
|
+
export declare function straightenTrunkDetours(layout: LayoutResult): LayoutResult;
|
|
13
|
+
/**
|
|
14
|
+
* Lift segments that run flush along the side of a node the edge belongs to —
|
|
15
|
+
* hard to tell from the box outline — a few pixels off that side. The segment
|
|
16
|
+
* moves outwards; when it was the first or last one, the original end point is
|
|
17
|
+
* kept and joined by a short stub. The nearest lift that clears every node and
|
|
18
|
+
* every parallel neighbour edge is used; none, and the segment stays.
|
|
19
|
+
*/
|
|
20
|
+
export declare function liftFlushSegments(layout: LayoutResult): LayoutResult;
|
|
21
|
+
/**
|
|
22
|
+
* Un-coil: an edge whose route wraps round other nodes (the U of a trunk that
|
|
23
|
+
* circles a cluster — stacked, the concentric rectangles of a coil) is routed
|
|
24
|
+
* again on the lattice with cheaper crossings, against every other edge as a
|
|
25
|
+
* fixed wire. The new route is kept when it wraps fewer nodes, adds at most
|
|
26
|
+
* [[UNCOIL_EXTRA_CROSSINGS]] crossings and opens no new comb at either end.
|
|
27
|
+
* Runs after fan-out merging, so a hub's trunk has been settled by then.
|
|
28
|
+
*/
|
|
29
|
+
export declare function uncoilWraps(layout: LayoutResult): LayoutResult;
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trunk lanes. A router that has to dodge a cluster of nodes can leave an edge
|
|
3
|
+
* that overshoots — out sideways, down, back across, down again — and stacked
|
|
4
|
+
* edges doing this draw the concentric rectangles of a coil. When the two end
|
|
5
|
+
* segments run the same axis, the edge is re-drawn as a Z: leave the source,
|
|
6
|
+
* cross over on a lane between the rows (the gap between the two end points),
|
|
7
|
+
* and enter the target. It runs after lane spreading and fan-out merging, so only
|
|
8
|
+
* what those passes left as an overshoot is touched. A Z replaces a detour only when it is shorter, passes
|
|
9
|
+
* no unrelated node or package frame, and adds at most [[EXTRA_CROSSINGS]] crossings.
|
|
10
|
+
*/
|
|
11
|
+
import { routeOrthogonal } from './grid-router.js';
|
|
12
|
+
import { findParallelBundles } from './layout-metrics.js';
|
|
13
|
+
/** Path length ÷ Manhattan distance above which an edge is considered for a Z. */
|
|
14
|
+
const DETOUR_FACTOR = 2;
|
|
15
|
+
/** Clearance between a Z segment and an unrelated node box. */
|
|
16
|
+
const NODE_CLEARANCE = 10;
|
|
17
|
+
/** The lane must sit at least this far from both end points along the main axis. */
|
|
18
|
+
const END_MARGIN = 14;
|
|
19
|
+
/** Extra crossings a straightened route may add: a few crossings read better than a coil. */
|
|
20
|
+
const EXTRA_CROSSINGS = 2;
|
|
21
|
+
/** Spacing of the candidate lanes tried between the end points. */
|
|
22
|
+
const LANE_STEP = 10;
|
|
23
|
+
/** Spacing of the candidate channels tried for an overshooting S-route. */
|
|
24
|
+
const CHANNEL_STEP = 6;
|
|
25
|
+
const length = (pts) => pts.slice(1).reduce((sum, p, i) => sum + Math.abs(p.x - pts[i].x) + Math.abs(p.y - pts[i].y), 0);
|
|
26
|
+
/** Segment touches the box inflated by `pad` (axis-aligned segments only). */
|
|
27
|
+
function hitsBox(a, b, r, pad) {
|
|
28
|
+
const x0 = Math.min(a.x, b.x);
|
|
29
|
+
const x1 = Math.max(a.x, b.x);
|
|
30
|
+
const y0 = Math.min(a.y, b.y);
|
|
31
|
+
const y1 = Math.max(a.y, b.y);
|
|
32
|
+
return x1 > r.x - pad && x0 < r.x + r.width + pad && y1 > r.y - pad && y0 < r.y + r.height + pad;
|
|
33
|
+
}
|
|
34
|
+
function crosses(a, b, c, d) {
|
|
35
|
+
const o = (p, q, r) => Math.sign((q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x));
|
|
36
|
+
return o(a, b, c) * o(a, b, d) < 0 && o(c, d, a) * o(c, d, b) < 0;
|
|
37
|
+
}
|
|
38
|
+
function segmentsOf(pts) {
|
|
39
|
+
return pts.slice(1).map((p, i) => [pts[i], p]);
|
|
40
|
+
}
|
|
41
|
+
function crossingCount(route, others) {
|
|
42
|
+
let n = 0;
|
|
43
|
+
for (const s of segmentsOf(route)) {
|
|
44
|
+
for (const other of others)
|
|
45
|
+
for (const t of segmentsOf(other))
|
|
46
|
+
if (crosses(s[0], s[1], t[0], t[1]))
|
|
47
|
+
n++;
|
|
48
|
+
}
|
|
49
|
+
return n;
|
|
50
|
+
}
|
|
51
|
+
const centreInside = (n, f) => {
|
|
52
|
+
const cx = n.x + n.width / 2;
|
|
53
|
+
const cy = n.y + n.height / 2;
|
|
54
|
+
return cx > f.x && cx < f.x + f.width && cy > f.y && cy < f.y + f.height;
|
|
55
|
+
};
|
|
56
|
+
/** Z-route with its lane at `lane` on the main axis; `vertical` = the end segments are vertical. */
|
|
57
|
+
function zRoute(a, b, lane, vertical) {
|
|
58
|
+
return vertical
|
|
59
|
+
? [a, { x: a.x, y: lane }, { x: b.x, y: lane }, b]
|
|
60
|
+
: [a, { x: lane, y: a.y }, { x: lane, y: b.y }, b];
|
|
61
|
+
}
|
|
62
|
+
export function straightenTrunkDetours(layout) {
|
|
63
|
+
const nodeById = new Map(layout.nodes.map((n) => [n.id, n]));
|
|
64
|
+
const frames = layout.packages ?? [];
|
|
65
|
+
const routes = layout.edges.map((e) => e.points);
|
|
66
|
+
let changed = false;
|
|
67
|
+
layout.edges.forEach((edge, index) => {
|
|
68
|
+
const pts = edge.points;
|
|
69
|
+
if (pts.length < 5)
|
|
70
|
+
return;
|
|
71
|
+
const a = pts[0];
|
|
72
|
+
const b = pts[pts.length - 1];
|
|
73
|
+
const manhattan = Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
|
|
74
|
+
if (length(pts) <= DETOUR_FACTOR * Math.max(manhattan, 1))
|
|
75
|
+
return;
|
|
76
|
+
const verticalEnds = Math.abs(pts[0].x - pts[1].x) < 1e-6 && Math.abs(pts[pts.length - 2].x - b.x) < 1e-6;
|
|
77
|
+
const horizontalEnds = Math.abs(pts[0].y - pts[1].y) < 1e-6 && Math.abs(pts[pts.length - 2].y - b.y) < 1e-6;
|
|
78
|
+
if (verticalEnds === horizontalEnds)
|
|
79
|
+
return;
|
|
80
|
+
const vertical = verticalEnds;
|
|
81
|
+
const from = vertical ? a.y : a.x;
|
|
82
|
+
const to = vertical ? b.y : b.x;
|
|
83
|
+
const lo = Math.min(from, to) + END_MARGIN;
|
|
84
|
+
const hi = Math.max(from, to) - END_MARGIN;
|
|
85
|
+
if (hi < lo)
|
|
86
|
+
return;
|
|
87
|
+
const source = nodeById.get(edge.source);
|
|
88
|
+
const target = nodeById.get(edge.target);
|
|
89
|
+
const endFrames = frames.filter((f) => (source && centreInside(source, f)) || (target && centreInside(target, f)));
|
|
90
|
+
const obstacles = layout.nodes.filter((n) => n.id !== edge.source && n.id !== edge.target);
|
|
91
|
+
const others = routes.filter((_, i) => i !== index);
|
|
92
|
+
const oldCrossings = crossingCount(pts, others);
|
|
93
|
+
const mid = (lo + hi) / 2;
|
|
94
|
+
const lanes = [];
|
|
95
|
+
for (let d = 0; mid - d >= lo || mid + d <= hi; d += LANE_STEP) {
|
|
96
|
+
if (mid - d >= lo)
|
|
97
|
+
lanes.push(mid - d);
|
|
98
|
+
if (d > 0 && mid + d <= hi)
|
|
99
|
+
lanes.push(mid + d);
|
|
100
|
+
}
|
|
101
|
+
const candidates = lanes.map((lane) => zRoute(a, b, lane, vertical));
|
|
102
|
+
if (pts.length === 6) {
|
|
103
|
+
// Overshoot through a gap in the row between: keep the two cross-over
|
|
104
|
+
// lanes and slide the channel to the gap nearest the end points.
|
|
105
|
+
const c0 = vertical ? pts[2].x : pts[2].y;
|
|
106
|
+
const span = [vertical ? a.x : a.y, vertical ? b.x : b.y, c0];
|
|
107
|
+
for (let c = Math.min(...span); c <= Math.max(...span); c += CHANNEL_STEP) {
|
|
108
|
+
candidates.push(vertical
|
|
109
|
+
? [a, pts[1], { x: c, y: pts[1].y }, { x: c, y: pts[3].y }, { x: b.x, y: pts[3].y }, b]
|
|
110
|
+
: [a, pts[1], { x: pts[1].x, y: c }, { x: pts[3].x, y: c }, { x: pts[3].x, y: b.y }, b]);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
let best;
|
|
114
|
+
for (const route of candidates) {
|
|
115
|
+
if (best && length(route) >= length(best.route))
|
|
116
|
+
continue;
|
|
117
|
+
const segs = segmentsOf(route);
|
|
118
|
+
if (obstacles.some((n) => segs.some(([p, q]) => hitsBox(p, q, n, NODE_CLEARANCE))))
|
|
119
|
+
continue;
|
|
120
|
+
// a frame that neither end sits in must stay unentered
|
|
121
|
+
if (frames.some((f) => !endFrames.includes(f) && segs.some(([p, q]) => hitsBox(p, q, f, -1))))
|
|
122
|
+
continue;
|
|
123
|
+
const crossings = crossingCount(route, others);
|
|
124
|
+
if (crossings > oldCrossings + EXTRA_CROSSINGS)
|
|
125
|
+
continue;
|
|
126
|
+
best = { route, crossings };
|
|
127
|
+
}
|
|
128
|
+
if (!best || length(best.route) >= length(pts))
|
|
129
|
+
return;
|
|
130
|
+
routes[index] = best.route;
|
|
131
|
+
changed = true;
|
|
132
|
+
});
|
|
133
|
+
if (!changed)
|
|
134
|
+
return layout;
|
|
135
|
+
return { ...layout, edges: layout.edges.map((e, i) => ({ ...e, points: routes[i] })) };
|
|
136
|
+
}
|
|
137
|
+
/** A segment this close to a side of an end node (and at least [[FLUSH_MIN_LENGTH]] long) hugs the box. */
|
|
138
|
+
const FLUSH_DISTANCE = 4;
|
|
139
|
+
const FLUSH_MIN_LENGTH = 40;
|
|
140
|
+
/** Distances off the node side tried for a lifted segment, nearest first. */
|
|
141
|
+
const FLUSH_LIFTS = Array.from({ length: 36 }, (_, i) => i + 8);
|
|
142
|
+
/** A lifted segment keeps this far from other edges' parallel segments it runs beside. */
|
|
143
|
+
const FLUSH_LANE_CLEARANCE = 9;
|
|
144
|
+
/** Another route has a segment parallel to a–b, closer than [[FLUSH_LANE_CLEARANCE]] and overlapping it in extent. */
|
|
145
|
+
function besideParallel(a, b, other) {
|
|
146
|
+
const horizontal = Math.abs(a.y - b.y) < 1e-6;
|
|
147
|
+
for (let j = 0; j + 1 < other.length; j++) {
|
|
148
|
+
const c = other[j];
|
|
149
|
+
const d = other[j + 1];
|
|
150
|
+
if (horizontal) {
|
|
151
|
+
if (Math.abs(c.y - d.y) > 1e-6 || Math.abs(c.y - a.y) >= FLUSH_LANE_CLEARANCE)
|
|
152
|
+
continue;
|
|
153
|
+
if (Math.min(Math.max(a.x, b.x), Math.max(c.x, d.x)) - Math.max(Math.min(a.x, b.x), Math.min(c.x, d.x)) > 1e-6)
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
if (Math.abs(c.x - d.x) > 1e-6 || Math.abs(c.x - a.x) >= FLUSH_LANE_CLEARANCE)
|
|
158
|
+
continue;
|
|
159
|
+
if (Math.min(Math.max(a.y, b.y), Math.max(c.y, d.y)) - Math.max(Math.min(a.y, b.y), Math.min(c.y, d.y)) > 1e-6)
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Lift segments that run flush along the side of a node the edge belongs to —
|
|
167
|
+
* hard to tell from the box outline — a few pixels off that side. The segment
|
|
168
|
+
* moves outwards; when it was the first or last one, the original end point is
|
|
169
|
+
* kept and joined by a short stub. The nearest lift that clears every node and
|
|
170
|
+
* every parallel neighbour edge is used; none, and the segment stays.
|
|
171
|
+
*/
|
|
172
|
+
export function liftFlushSegments(layout) {
|
|
173
|
+
const nodeById = new Map(layout.nodes.map((n) => [n.id, n]));
|
|
174
|
+
let changed = false;
|
|
175
|
+
const edges = layout.edges.map((edge) => {
|
|
176
|
+
let pts = edge.points;
|
|
177
|
+
for (let i = 0; i + 1 < pts.length; i++) {
|
|
178
|
+
const a = pts[i];
|
|
179
|
+
const b = pts[i + 1];
|
|
180
|
+
const horizontal = Math.abs(a.y - b.y) < 1e-6;
|
|
181
|
+
const vertical = Math.abs(a.x - b.x) < 1e-6;
|
|
182
|
+
if (horizontal === vertical || Math.abs(a.x - b.x) + Math.abs(a.y - b.y) < FLUSH_MIN_LENGTH)
|
|
183
|
+
continue;
|
|
184
|
+
for (const id of [edge.source, edge.target]) {
|
|
185
|
+
const n = nodeById.get(id);
|
|
186
|
+
if (!n)
|
|
187
|
+
continue;
|
|
188
|
+
const lo = horizontal ? Math.min(a.x, b.x) : Math.min(a.y, b.y);
|
|
189
|
+
const hi = horizontal ? Math.max(a.x, b.x) : Math.max(a.y, b.y);
|
|
190
|
+
const n0 = horizontal ? n.x : n.y;
|
|
191
|
+
const n1 = horizontal ? n.x + n.width : n.y + n.height;
|
|
192
|
+
if (Math.min(hi, n1) - Math.max(lo, n0) <= 1e-6)
|
|
193
|
+
continue;
|
|
194
|
+
const at = horizontal ? a.y : a.x;
|
|
195
|
+
const side0 = horizontal ? n.y : n.x;
|
|
196
|
+
const side1 = horizontal ? n.y + n.height : n.x + n.width;
|
|
197
|
+
const nearLow = Math.abs(at - side0) < FLUSH_DISTANCE;
|
|
198
|
+
const nearHigh = Math.abs(at - side1) < FLUSH_DISTANCE;
|
|
199
|
+
if (!nearLow && !nearHigh)
|
|
200
|
+
continue;
|
|
201
|
+
let lifted;
|
|
202
|
+
// First pass keeps clear of every neighbouring lane; the second takes the
|
|
203
|
+
// nearest lift that merely clears the nodes — a lane beside another lane
|
|
204
|
+
// reads better than a line that is part of a box outline.
|
|
205
|
+
for (const strict of [true, false])
|
|
206
|
+
for (const lift of lifted ? [] : FLUSH_LIFTS) {
|
|
207
|
+
const to = nearLow ? side0 - lift : side1 + lift;
|
|
208
|
+
const a2 = horizontal ? { x: a.x, y: to } : { x: to, y: a.y };
|
|
209
|
+
const b2 = horizontal ? { x: b.x, y: to } : { x: to, y: b.y };
|
|
210
|
+
if (layout.nodes.some((o) => hitsBox(a2, b2, o, 2)))
|
|
211
|
+
continue;
|
|
212
|
+
if (strict && layout.edges.some((o) => o !== edge && besideParallel(a2, b2, o.points)))
|
|
213
|
+
continue;
|
|
214
|
+
lifted = [...pts.slice(0, i), ...(i === 0 ? [a] : []), a2, b2, ...(i + 2 === pts.length ? [b] : []), ...pts.slice(i + 2)];
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
if (!lifted)
|
|
218
|
+
continue;
|
|
219
|
+
pts = lifted;
|
|
220
|
+
changed = true;
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return pts === edge.points ? edge : { ...edge, points: pts };
|
|
225
|
+
});
|
|
226
|
+
return changed ? { ...layout, edges } : layout;
|
|
227
|
+
}
|
|
228
|
+
/** Crossing prices an un-coil re-route is tried with, dearest first, and the crossings it may add. */
|
|
229
|
+
const UNCOIL_CROSSING_COSTS = [6, 3, 1.5];
|
|
230
|
+
const UNCOIL_EXTRA_CROSSINGS = 5;
|
|
231
|
+
/** Nodes (other than its ends) enclosed by the boxes of the route's U-turns (outer segments opposite), summed. */
|
|
232
|
+
function wrapCount(pts, nodes, ends) {
|
|
233
|
+
let wrapped = 0;
|
|
234
|
+
for (let i = 0; i + 3 < pts.length; i++) {
|
|
235
|
+
const [p0, p1, p2, p3] = pts.slice(i, i + 4);
|
|
236
|
+
const vertical = Math.abs(p0.x - p1.x) < 1e-6 && Math.abs(p0.y - p1.y) > 1e-6;
|
|
237
|
+
const horizontal = Math.abs(p0.y - p1.y) < 1e-6 && Math.abs(p0.x - p1.x) > 1e-6;
|
|
238
|
+
if (!vertical && !horizontal)
|
|
239
|
+
continue;
|
|
240
|
+
const d1 = vertical ? p1.y - p0.y : p1.x - p0.x;
|
|
241
|
+
const d3 = vertical ? p3.y - p2.y : p3.x - p2.x;
|
|
242
|
+
const middleOk = vertical ? Math.abs(p1.y - p2.y) < 1e-6 : Math.abs(p1.x - p2.x) < 1e-6;
|
|
243
|
+
const lastOk = vertical ? Math.abs(p2.x - p3.x) < 1e-6 : Math.abs(p2.y - p3.y) < 1e-6;
|
|
244
|
+
if (!middleOk || !lastOk || d1 * d3 >= 0)
|
|
245
|
+
continue;
|
|
246
|
+
const x0 = Math.min(p0.x, p1.x, p2.x, p3.x);
|
|
247
|
+
const x1 = Math.max(p0.x, p1.x, p2.x, p3.x);
|
|
248
|
+
const y0 = Math.min(p0.y, p1.y, p2.y, p3.y);
|
|
249
|
+
const y1 = Math.max(p0.y, p1.y, p2.y, p3.y);
|
|
250
|
+
wrapped += nodes.filter((n) => !ends.includes(n.id) && n.x > x0 && n.x + n.width < x1 && n.y > y0 && n.y + n.height < y1).length;
|
|
251
|
+
}
|
|
252
|
+
return wrapped;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Un-coil: an edge whose route wraps round other nodes (the U of a trunk that
|
|
256
|
+
* circles a cluster — stacked, the concentric rectangles of a coil) is routed
|
|
257
|
+
* again on the lattice with cheaper crossings, against every other edge as a
|
|
258
|
+
* fixed wire. The new route is kept when it wraps fewer nodes, adds at most
|
|
259
|
+
* [[UNCOIL_EXTRA_CROSSINGS]] crossings and opens no new comb at either end.
|
|
260
|
+
* Runs after fan-out merging, so a hub's trunk has been settled by then.
|
|
261
|
+
*/
|
|
262
|
+
export function uncoilWraps(layout) {
|
|
263
|
+
const nodeById = new Map(layout.nodes.map((n) => [n.id, n]));
|
|
264
|
+
const routes = layout.edges.map((e) => e.points);
|
|
265
|
+
const combKeys = () => new Set(findParallelBundles(layout.edges.map((e, i) => ({ ...e, points: routes[i] }))).map((b) => `${b.end}\u0000${b.node}`));
|
|
266
|
+
const combsBefore = combKeys();
|
|
267
|
+
let changed = false;
|
|
268
|
+
layout.edges.forEach((edge, index) => {
|
|
269
|
+
const pts = routes[index];
|
|
270
|
+
const source = nodeById.get(edge.source);
|
|
271
|
+
const target = nodeById.get(edge.target);
|
|
272
|
+
const ends = [edge.source, edge.target];
|
|
273
|
+
const wrapped = source && target && pts.length >= 4 ? wrapCount(pts, layout.nodes, ends) : 0;
|
|
274
|
+
if (!source || !target || wrapped === 0)
|
|
275
|
+
return;
|
|
276
|
+
const others = routes.filter((_, i) => i !== index);
|
|
277
|
+
const oldCrossings = crossingCount(pts, others);
|
|
278
|
+
const existing = layout.edges
|
|
279
|
+
.map((e, i) => ({ source: e.source, target: e.target, points: routes[i] }))
|
|
280
|
+
.filter((_, i) => i !== index);
|
|
281
|
+
for (const crossingCost of UNCOIL_CROSSING_COSTS) {
|
|
282
|
+
const route = routeOrthogonal([{ id: 'uncoil', source, target }], layout.nodes, {
|
|
283
|
+
width: layout.width,
|
|
284
|
+
height: layout.height,
|
|
285
|
+
frames: layout.packages,
|
|
286
|
+
existing,
|
|
287
|
+
crossingCost,
|
|
288
|
+
ripupPasses: 0,
|
|
289
|
+
}).get('uncoil');
|
|
290
|
+
if (!route || route.length < 2 || wrapCount(route, layout.nodes, ends) >= wrapped)
|
|
291
|
+
continue;
|
|
292
|
+
if (crossingCount(route, others) > oldCrossings + UNCOIL_EXTRA_CROSSINGS)
|
|
293
|
+
continue;
|
|
294
|
+
const previous = routes[index];
|
|
295
|
+
routes[index] = route;
|
|
296
|
+
const opened = [...combKeys()].some((k) => !combsBefore.has(k));
|
|
297
|
+
if (opened) {
|
|
298
|
+
routes[index] = previous;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
changed = true;
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
if (!changed)
|
|
306
|
+
return layout;
|
|
307
|
+
return { ...layout, edges: layout.edges.map((e, i) => ({ ...e, points: routes[i] })) };
|
|
308
|
+
}
|
package/dist/layout/viz.d.ts
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
* bounding-box origin so the result matches the top-down pixel space the ELK
|
|
12
12
|
* engine and the SVG renderer already speak.
|
|
13
13
|
*/
|
|
14
|
-
import { PlantUmlModel } from '../model.js';
|
|
15
|
-
import { LayoutEngine, LayoutResult } from '../layout.js';
|
|
16
|
-
import { GraphvizEngine } from '../render-dot.js';
|
|
14
|
+
import { PlantUmlModel } from '../foundation/model.js';
|
|
15
|
+
import { LayoutEngine, LayoutResult } from '../foundation/layout.js';
|
|
16
|
+
import { GraphvizEngine } from '../foundation/render-dot.js';
|
|
17
17
|
export declare class VizLayoutEngine implements LayoutEngine {
|
|
18
18
|
private readonly graphvizEngine;
|
|
19
19
|
constructor(graphvizEngine?: GraphvizEngine);
|
package/dist/layout/viz.js
CHANGED
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
* bounding-box origin so the result matches the top-down pixel space the ELK
|
|
12
12
|
* engine and the SVG renderer already speak.
|
|
13
13
|
*/
|
|
14
|
-
import { PACKAGE_LABEL, displayName, estimateEntitySize, } from '../layout.js';
|
|
15
|
-
import { getViz } from '../render-dot.js';
|
|
16
|
-
import { measureTextWidth } from '../text-measure.js';
|
|
14
|
+
import { PACKAGE_LABEL, displayName, estimateEntitySize, } from '../foundation/layout.js';
|
|
15
|
+
import { getViz } from '../foundation/render-dot.js';
|
|
16
|
+
import { measureTextWidth } from '../foundation/text-measure.js';
|
|
17
|
+
import { relationSemantics } from '../foundation/relation-semantics.js';
|
|
17
18
|
const POINTS_PER_INCH = 72;
|
|
18
19
|
const CLUSTER_PREFIX = 'cluster_';
|
|
19
20
|
export class VizLayoutEngine {
|
|
@@ -159,28 +160,23 @@ function clusterIndex(name) {
|
|
|
159
160
|
return null;
|
|
160
161
|
return Number(tail);
|
|
161
162
|
}
|
|
163
|
+
/** Translate the relation's semantic facts into Graphviz DOT attributes. */
|
|
162
164
|
function edgeAttrs(relation) {
|
|
163
165
|
const attrs = [];
|
|
164
|
-
|
|
165
|
-
|
|
166
|
+
const semantics = relationSemantics(relation.type);
|
|
167
|
+
switch (semantics.arrowKind()) {
|
|
168
|
+
case 'triangle':
|
|
166
169
|
attrs.push('arrowhead="empty"');
|
|
167
170
|
break;
|
|
168
|
-
case '
|
|
169
|
-
attrs.push('arrowhead="empty"', 'style="dashed"');
|
|
170
|
-
break;
|
|
171
|
-
case 'dependency':
|
|
172
|
-
attrs.push('arrowhead="vee"', 'style="dashed"');
|
|
173
|
-
break;
|
|
174
|
-
case 'association':
|
|
171
|
+
case 'chevron':
|
|
175
172
|
attrs.push('arrowhead="vee"');
|
|
176
173
|
break;
|
|
177
|
-
case '
|
|
178
|
-
attrs.push(
|
|
179
|
-
break;
|
|
180
|
-
case 'composition':
|
|
181
|
-
attrs.push('arrowhead="diamond"');
|
|
174
|
+
case 'diamond':
|
|
175
|
+
attrs.push(`arrowhead="${semantics.isFilled() ? 'diamond' : 'odiamond'}"`);
|
|
182
176
|
break;
|
|
183
177
|
}
|
|
178
|
+
if (semantics.isDashed())
|
|
179
|
+
attrs.push('style="dashed"');
|
|
184
180
|
if (relation.label)
|
|
185
181
|
attrs.push(`label=${quoteDotLabel(relation.label)}`);
|
|
186
182
|
return attrs.length > 0 ? ` [${attrs.join(', ')}]` : '';
|