dsh-model-everything 0.5.1 → 0.5.2
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/lib/client.js +524 -140
- package/lib/render/paint.js +668 -207
- package/lib/render/svg.js +3 -0
- package/lib/render/trunk.js +213 -0
- package/lib/types/render/paint.d.ts +120 -6
- package/lib/types/render/place.d.ts +8 -3
- package/lib/types/render/trunk.d.ts +104 -0
- package/package.json +1 -1
- package/skills/model-everything-diagram/SKILL.md +1 -0
- package/src/render/paint.ts +769 -205
- package/src/render/place.ts +8 -3
- package/src/render/svg.ts +4 -0
- package/src/render/trunk.ts +275 -0
package/lib/render/svg.js
CHANGED
|
@@ -104,6 +104,9 @@ const writers = {
|
|
|
104
104
|
edgeMarks(first, after, before, last, shape.arrow, stroke, colours.background) +
|
|
105
105
|
edgeLabel(shape.label, shape.labelX, shape.labelY, colours));
|
|
106
106
|
},
|
|
107
|
+
// A junction dot: the visible answer to "is this a join or a crossing?". Filled in the line's own colour, so it
|
|
108
|
+
// reads as the line thickening where a branch meets it rather than as a new mark on the page.
|
|
109
|
+
junction: (shape, colours) => '<circle cx="' + n(shape.x) + '" cy="' + n(shape.y) + '" r="2.5" fill="' + colours[shape.colour] + '" />',
|
|
107
110
|
title: (shape, colours) => {
|
|
108
111
|
return '<text x="' + n(shape.x) + '" y="' + n(shape.y) + '" font-size="12" fill="' + colours[shape.colour] + '">' + escape(shape.text) + '</text>';
|
|
109
112
|
},
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fan-in trunks: a batch of arrows into one box from one side, drawn as one bundle.
|
|
3
|
+
*
|
|
4
|
+
* A node that four relations point at is drawn as four ports along its side and four lines converging on it. Each
|
|
5
|
+
* line is right on its own, and the picture is wrong: the bundle reads as a comb, and a reader counts ports to count
|
|
6
|
+
* arrows. The mature layered implementations solve this by merging the edges - ELK's *Merge Edges* with junction
|
|
7
|
+
* points, Graphviz's `concentrate` - and the shape they produce is one straight run outside the node, with a branch
|
|
8
|
+
* joining it wherever a separate edge arrives, and one shared stub into the node.
|
|
9
|
+
*
|
|
10
|
+
* This module decides only which ends may merge, where the shared run sits and which of the branches becomes the
|
|
11
|
+
* shared entry. It is pure geometry over boxes and ports, so the rules can be asserted without a canvas; the painter
|
|
12
|
+
* draws the answer.
|
|
13
|
+
*
|
|
14
|
+
* The rules, and why each one exists:
|
|
15
|
+
*
|
|
16
|
+
* - **One target, one entry side.** Edges that arrive from different sides are travelling in different directions,
|
|
17
|
+
* and a trunk that changed axis would be two trunks.
|
|
18
|
+
* - **The exit faces the trunk.** A branch arrives at the trunk along the direction its own port leaves by, so the
|
|
19
|
+
* trunk is outside the source as well as outside the target. An end that does not face the trunk is not merged: it
|
|
20
|
+
* would have to turn before it got there, which is the bend the merge was supposed to save.
|
|
21
|
+
* - **Distinct sources, three or more.** Two merged edges read as one thick line. A pair of relations about one pair
|
|
22
|
+
* has its own, older rule - they stay visibly apart - and that rule is the distinct-source rule here: two
|
|
23
|
+
* statements about one pair share their source, so they can never both be a branch of one trunk.
|
|
24
|
+
* - **One arrow semantics.** A composition and an association are different statements about the relation; drawn on
|
|
25
|
+
* one trunk they would be read as one statement. Ends that disagree on their arrow are not merged with each other.
|
|
26
|
+
* - **Clear of every box.** A trunk that crosses a box is the same defect as a route that does, and it is worse here
|
|
27
|
+
* because it is drawn once for the whole bundle.
|
|
28
|
+
* - **One trunk per line.** Two groups whose trunks would be drawn on one line would read as one bundle between two
|
|
29
|
+
* unrelated targets, so the later group is refused.
|
|
30
|
+
*
|
|
31
|
+
* The shared entry is the middle of the target's side. A bundle enters the target through one port, and a side that
|
|
32
|
+
* carries one port is centred on it, so the trunk runs to the middle and every branch joins it there: the branches
|
|
33
|
+
* that are not in the middle bend, which is the price of one entry that reads as centred rather than one that leans
|
|
34
|
+
* towards whichever branch happened to arrive straightest.
|
|
35
|
+
*
|
|
36
|
+
* Merging forces every branch but one to bend twice, so an unmerged drawing of `k` straight branches costs
|
|
37
|
+
* `2 * (n - k)` bends and a merged one costs `2 * (n - 1)`; the branch nearest the middle is the lead because it is
|
|
38
|
+
* the only one that can still reach the shared port without a horizontal leg, and on a target whose middle happens
|
|
39
|
+
* to fall on a branch's own line that branch is free, as before.
|
|
40
|
+
*
|
|
41
|
+
* What this module does not decide: the colour of a branch, the text of its label, and how a branch's own route
|
|
42
|
+
* reaches the trunk. Those belong to the painter.
|
|
43
|
+
*/
|
|
44
|
+
import { crossesBox, normalOf, ROUTE_STUB } from './route.js';
|
|
45
|
+
/** The least a trunk is worth drawing for: two merged edges read as one thick line. */
|
|
46
|
+
const TRUNK_MIN_EDGES = 3;
|
|
47
|
+
/** Does this side move along the page's vertical axis? A run on it is horizontal. */
|
|
48
|
+
function isVerticalSide(side) {
|
|
49
|
+
return side === 'top' || side === 'bottom';
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Where the trunk outside a box sits.
|
|
53
|
+
*
|
|
54
|
+
* The same stub the router leaves before it turns, so the bundle starts exactly where a single route would have
|
|
55
|
+
* turned: about `ROUTE_STUB` outside the target, square to the side it enters.
|
|
56
|
+
*/
|
|
57
|
+
function trunkAt(box, side) {
|
|
58
|
+
if (side === 'top')
|
|
59
|
+
return box.y - ROUTE_STUB;
|
|
60
|
+
if (side === 'bottom')
|
|
61
|
+
return box.y + box.height + ROUTE_STUB;
|
|
62
|
+
if (side === 'left')
|
|
63
|
+
return box.x - ROUTE_STUB;
|
|
64
|
+
return box.x + box.width + ROUTE_STUB;
|
|
65
|
+
}
|
|
66
|
+
/** The coordinate a point takes along the side it sits on: where along that side the port is. */
|
|
67
|
+
function alongSide(point, side) {
|
|
68
|
+
return isVerticalSide(side) ? point.x : point.y;
|
|
69
|
+
}
|
|
70
|
+
/** The coordinate a point takes on its side's outward normal: the axis a trunk outside that side moves on. */
|
|
71
|
+
function alongNormal(point, side) {
|
|
72
|
+
return isVerticalSide(side) ? point.y : point.x;
|
|
73
|
+
}
|
|
74
|
+
/** Does the port on `side` travel toward the trunk at `at`? A branch only merges into a trunk it moves toward. */
|
|
75
|
+
function facesTrunk(point, side, at) {
|
|
76
|
+
const normal = normalOf(side);
|
|
77
|
+
const outward = isVerticalSide(side) ? normal.y : normal.x;
|
|
78
|
+
return (at - alongNormal(point, side)) * outward > 0.5;
|
|
79
|
+
}
|
|
80
|
+
/** The group an edge end belongs to: its target, the side it enters by, and the arrow it means. */
|
|
81
|
+
function keyOf(end) {
|
|
82
|
+
return end.to + '\u0000' + end.entry + '\u0000' + (end.arrow ?? '');
|
|
83
|
+
}
|
|
84
|
+
/** Is this axis-aligned segment clear of every box it does not attach to? */
|
|
85
|
+
function clearSegment(boxes, skip, x1, y1, x2, y2) {
|
|
86
|
+
for (const box of boxes) {
|
|
87
|
+
if (skip.includes(box))
|
|
88
|
+
continue;
|
|
89
|
+
if (crossesBox(x1, y1, x2, y2, box))
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
/** Would this run lie on the line of an accepted trunk and share a stretch of it? */
|
|
95
|
+
function onOneLine(group, side, at, low, high) {
|
|
96
|
+
if (isVerticalSide(group.side) !== isVerticalSide(side))
|
|
97
|
+
return false;
|
|
98
|
+
if (group.at !== at)
|
|
99
|
+
return false;
|
|
100
|
+
return Math.min(group.to, high) - Math.max(group.from, low) > 0.5;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Decide which ends merge, and where each accepted trunk runs.
|
|
104
|
+
*
|
|
105
|
+
* Ends that cannot merge are simply absent from the plan; the painter draws them as it always has. Groups are
|
|
106
|
+
* ordered by the first member the source wrote, so the answer does not depend on map iteration order.
|
|
107
|
+
*/
|
|
108
|
+
export function planTrunks(input) {
|
|
109
|
+
const groups = [];
|
|
110
|
+
const groupOf = new Map();
|
|
111
|
+
// Per edge first: the cheap rules. An end that fails one of them cannot join any group, so it never reaches the
|
|
112
|
+
// count that decides whether a group is worth drawing.
|
|
113
|
+
const buckets = new Map();
|
|
114
|
+
for (const end of input.ends) {
|
|
115
|
+
if (end.runY === undefined)
|
|
116
|
+
continue;
|
|
117
|
+
// The branch approaches the trunk along its own exit normal, so the two ends must move on one axis: a mixed pair
|
|
118
|
+
// would have to turn before it reached the trunk, which is the bend the merge exists to save.
|
|
119
|
+
if (isVerticalSide(end.exit) !== isVerticalSide(end.entry))
|
|
120
|
+
continue;
|
|
121
|
+
const source = input.boxOf(end.from);
|
|
122
|
+
const target = input.boxOf(end.to);
|
|
123
|
+
if (source === undefined || target === undefined)
|
|
124
|
+
continue;
|
|
125
|
+
if (source === target)
|
|
126
|
+
continue;
|
|
127
|
+
const exit = input.exitPortOf(end.index);
|
|
128
|
+
if (exit === undefined)
|
|
129
|
+
continue;
|
|
130
|
+
const at = trunkAt(target, end.entry);
|
|
131
|
+
if (!facesTrunk(exit, end.exit, at))
|
|
132
|
+
continue;
|
|
133
|
+
const candidate = { end, source, target, at, exit, branch: alongSide(exit, end.exit) };
|
|
134
|
+
const key = keyOf(end);
|
|
135
|
+
const list = buckets.get(key);
|
|
136
|
+
if (list === undefined)
|
|
137
|
+
buckets.set(key, [candidate]);
|
|
138
|
+
else
|
|
139
|
+
list.push(candidate);
|
|
140
|
+
}
|
|
141
|
+
for (const list of buckets.values()) {
|
|
142
|
+
// A pair of relations about one node is not two branches: the pair's own rule keeps them visibly apart, and both
|
|
143
|
+
// of them name the same source, so dropping the duplicated source drops the whole pair. Neither joins, and the
|
|
144
|
+
// group keeps the singles.
|
|
145
|
+
const bySource = new Map();
|
|
146
|
+
for (const candidate of list) {
|
|
147
|
+
const seen = bySource.get(candidate.end.from);
|
|
148
|
+
if (seen === undefined)
|
|
149
|
+
bySource.set(candidate.end.from, [candidate]);
|
|
150
|
+
else
|
|
151
|
+
seen.push(candidate);
|
|
152
|
+
}
|
|
153
|
+
const members = list.filter((candidate) => bySource.get(candidate.end.from).length === 1);
|
|
154
|
+
if (members.length < TRUNK_MIN_EDGES)
|
|
155
|
+
continue;
|
|
156
|
+
const target = members[0].target;
|
|
157
|
+
const side = members[0].end.entry;
|
|
158
|
+
const at = members[0].at;
|
|
159
|
+
// The shared entry is the middle of the target's side. A side that carries one port is centred on it, and a bundle
|
|
160
|
+
// enters through one port, so the run reaches the middle rather than whichever branch asked for the middle first.
|
|
161
|
+
const entryAlong = isVerticalSide(side) ? target.x + target.width / 2 : target.y + target.height / 2;
|
|
162
|
+
// The lead is the branch nearest that middle: the only member that can reach the shared port without a lateral
|
|
163
|
+
// leg. The source's order breaks a tie, so the answer does not depend on map iteration.
|
|
164
|
+
let lead = members[0];
|
|
165
|
+
for (const candidate of members) {
|
|
166
|
+
if (Math.abs(candidate.branch - entryAlong) < Math.abs(lead.branch - entryAlong) - 1e-9)
|
|
167
|
+
lead = candidate;
|
|
168
|
+
}
|
|
169
|
+
// The run: the whole stretch the branches and the one port span, so every branch joins a line that reaches it.
|
|
170
|
+
let low = entryAlong;
|
|
171
|
+
let high = entryAlong;
|
|
172
|
+
for (const candidate of members) {
|
|
173
|
+
low = Math.min(low, candidate.branch);
|
|
174
|
+
high = Math.max(high, candidate.branch);
|
|
175
|
+
}
|
|
176
|
+
// Clearance. The run is checked against every box but the target; each branch's own drop is checked against every
|
|
177
|
+
// box but its source and the target. The drop leaves a port square to its side, so those are the only two boxes it
|
|
178
|
+
// can legitimately touch.
|
|
179
|
+
const vertical = isVerticalSide(side);
|
|
180
|
+
const run = vertical ? [low, at, high, at] : [at, low, at, high];
|
|
181
|
+
if (!clearSegment(input.boxes, [target], run[0], run[1], run[2], run[3]))
|
|
182
|
+
continue;
|
|
183
|
+
let blocked = false;
|
|
184
|
+
for (const candidate of members) {
|
|
185
|
+
// The drop: from the branch's own port to the point where it meets the trunk, along its exit normal.
|
|
186
|
+
const drop = vertical
|
|
187
|
+
? [candidate.branch, candidate.exit.y, candidate.branch, at]
|
|
188
|
+
: [candidate.exit.x, candidate.branch, at, candidate.branch];
|
|
189
|
+
if (!clearSegment(input.boxes, [candidate.source, target], drop[0], drop[1], drop[2], drop[3]))
|
|
190
|
+
blocked = true;
|
|
191
|
+
}
|
|
192
|
+
if (blocked)
|
|
193
|
+
continue;
|
|
194
|
+
// Two trunks on one line would be drawn as one bundle. The first group keeps the line; the later one travels alone.
|
|
195
|
+
if (groups.some((group) => onOneLine(group, side, at, low, high)))
|
|
196
|
+
continue;
|
|
197
|
+
const id = groups.length;
|
|
198
|
+
groups.push({
|
|
199
|
+
id,
|
|
200
|
+
target: members[0].end.to,
|
|
201
|
+
side,
|
|
202
|
+
at,
|
|
203
|
+
entryAlong,
|
|
204
|
+
from: low,
|
|
205
|
+
to: high,
|
|
206
|
+
lead: lead.end.index,
|
|
207
|
+
members: members.map((candidate) => candidate.end.index),
|
|
208
|
+
});
|
|
209
|
+
for (const candidate of members)
|
|
210
|
+
groupOf.set(candidate.end.index, id);
|
|
211
|
+
}
|
|
212
|
+
return { groups, groupOf };
|
|
213
|
+
}
|
|
@@ -11,20 +11,31 @@
|
|
|
11
11
|
* route, not picked by comparing the boxes' centres: a route between two rows arrives vertically, so the endpoint
|
|
12
12
|
* is snapped onto the top or bottom perimeter in the direction the route approaches. Where along that side it sits
|
|
13
13
|
* follows the symbol: a rectangle spreads its ports along the side, a cylinder's top port sits on its cap curve, and a
|
|
14
|
-
* diamond or a circle has four legal points and no side to spread along at all.
|
|
14
|
+
* diamond or a circle has four legal points and no side to spread along at all. A side that carries one port is
|
|
15
|
+
* centred on it, and a side that carries several spreads them symmetrically about the middle; the bend budget comes
|
|
16
|
+
* first, so a port that is the end of a straight run or of a lane keeps the coordinate its route asked for.
|
|
15
17
|
*
|
|
16
18
|
* A symbol with discrete points gives each of its edges a point of its own: the point whose outward normal faces the
|
|
17
19
|
* box at the other end is the one that edge takes, and an edge only shares a point when all four are spoken for. A
|
|
18
20
|
* node with a run on its side keeps doing what it always did, because there the ports are places on the run and not
|
|
19
21
|
* points at all. Sharing is the fallback, not the rule: two labelled branches of a decision leave by two vertices.
|
|
20
22
|
*
|
|
21
|
-
* An edge's name is drawn on the edge, not beside it: it is anchored at the midpoint of the edge's
|
|
22
|
-
*
|
|
23
|
-
* something is in the way.
|
|
24
|
-
* whole
|
|
23
|
+
* An edge's name is drawn on the edge, not beside it: it is anchored at the midpoint of one of the edge's own straight
|
|
24
|
+
* runs, over a rectangle in the page colour that masks the line behind the text, and it slides along that run when
|
|
25
|
+
* something is in the way. It tries every run - the longest first - because on a dense drawing the longest one can be
|
|
26
|
+
* crowded for its whole length while a shorter run of the same edge is clear. A label that can clear nothing still
|
|
27
|
+
* stays on its edge, takes the place with the fewest collisions, and records what it could not avoid. Labels are
|
|
28
|
+
* placed after every edge is drawn, because what a label has to avoid is the whole picture.
|
|
29
|
+
*
|
|
30
|
+
* Every route is checked before it is drawn, and the assembled drawing is checked once more: a segment may touch no
|
|
31
|
+
* box except the two the edge terminates at. A route is a proposal - a channel run, a lane, a trunk branch, a wrapped
|
|
32
|
+
* column's sideways read - and any proposal that would cross a box is handed to the router instead. When even the
|
|
33
|
+
* router cannot clear a box, the failure is reported on the canvas rather than hidden: a drawing that says which edge
|
|
34
|
+
* could not be routed is worth more than one that quietly draws it through a box.
|
|
25
35
|
*
|
|
26
36
|
* Pure: a diagram in, shapes out, every decision assertable.
|
|
27
37
|
*/
|
|
38
|
+
import { type Box } from './place.js';
|
|
28
39
|
import { type ColourRole, type Palette, type PaletteOptions } from './palette.js';
|
|
29
40
|
import { type Diagram, type DiagramNode, type EdgeArrow, type NodeShape } from './model.js';
|
|
30
41
|
export interface Point {
|
|
@@ -45,6 +56,22 @@ export interface BoxShape {
|
|
|
45
56
|
colour: ColourRole;
|
|
46
57
|
initial: boolean;
|
|
47
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Why a label sits where it does.
|
|
61
|
+
*
|
|
62
|
+
* A clear label records `collisions: 0` and the run it chose. A label that could clear nothing records the collisions
|
|
63
|
+
* it could not avoid, so a reader of the drawing (or a test) can tell a crowded drawing from a placement bug. The
|
|
64
|
+
* label never leaves its own edge to earn any of these numbers.
|
|
65
|
+
*/
|
|
66
|
+
export interface LabelPlacement {
|
|
67
|
+
/** The straight run it sits on, 0 being the edge's longest. */
|
|
68
|
+
run: number;
|
|
69
|
+
/** Everything its mask overlaps: the other labels, boxes and edges it would have had to move off its line to clear. */
|
|
70
|
+
collisions: number;
|
|
71
|
+
boxes: number;
|
|
72
|
+
labels: number;
|
|
73
|
+
edges: number;
|
|
74
|
+
}
|
|
48
75
|
export interface LinkShape {
|
|
49
76
|
kind: 'link';
|
|
50
77
|
x1: number;
|
|
@@ -60,6 +87,8 @@ export interface LinkShape {
|
|
|
60
87
|
dashed?: boolean;
|
|
61
88
|
/** The arrow the source edge stated. Undefined means the plain arrow every edge has been drawn with. */
|
|
62
89
|
arrow?: EdgeArrow;
|
|
90
|
+
/** Why the label sits where it does. Undefined on an edge with no label. */
|
|
91
|
+
labelPlacement?: LabelPlacement;
|
|
63
92
|
}
|
|
64
93
|
export interface PathShape {
|
|
65
94
|
kind: 'path';
|
|
@@ -71,6 +100,22 @@ export interface PathShape {
|
|
|
71
100
|
colour: ColourRole;
|
|
72
101
|
dashed?: boolean;
|
|
73
102
|
arrow?: EdgeArrow;
|
|
103
|
+
/** Why the label sits where it does. Undefined on an edge with no label. */
|
|
104
|
+
labelPlacement?: LabelPlacement;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* A junction dot: the place one branch of a merged bundle meets the bundle's trunk.
|
|
108
|
+
*
|
|
109
|
+
* The dot is not decoration. Two lines that meet at a right angle read as a crossing unless one of them is marked as
|
|
110
|
+
* joining the other, and a bundle whose branches are not marked reads as a set of broken lines rather than a merge.
|
|
111
|
+
*/
|
|
112
|
+
export interface JunctionShape {
|
|
113
|
+
kind: 'junction';
|
|
114
|
+
x: number;
|
|
115
|
+
y: number;
|
|
116
|
+
colour: ColourRole;
|
|
117
|
+
/** The trunk the dot belongs to: one dot per branch, one trunk id per bundle. */
|
|
118
|
+
trunk: number;
|
|
74
119
|
}
|
|
75
120
|
/** A boundary around several states: a package, a layer, a subgraph. */
|
|
76
121
|
export interface GroupShape {
|
|
@@ -98,11 +143,33 @@ export interface TitleShape {
|
|
|
98
143
|
text: string;
|
|
99
144
|
colour: ColourRole;
|
|
100
145
|
}
|
|
101
|
-
export type Shape = GroupShape | BoxShape | LinkShape | PathShape | CaptionShape | TitleShape;
|
|
146
|
+
export type Shape = GroupShape | BoxShape | LinkShape | PathShape | JunctionShape | CaptionShape | TitleShape;
|
|
147
|
+
/** One segment of a drawn route that could not be kept out of a box it does not terminate at. */
|
|
148
|
+
export interface RoutingFailure {
|
|
149
|
+
/** The edge's index in the diagram. */
|
|
150
|
+
edge: number;
|
|
151
|
+
from: string;
|
|
152
|
+
to: string;
|
|
153
|
+
/** The box the segment passes through. */
|
|
154
|
+
box: string;
|
|
155
|
+
x1: number;
|
|
156
|
+
y1: number;
|
|
157
|
+
x2: number;
|
|
158
|
+
y2: number;
|
|
159
|
+
}
|
|
102
160
|
export interface Canvas {
|
|
103
161
|
width: number;
|
|
104
162
|
height: number;
|
|
105
163
|
shapes: Shape[];
|
|
164
|
+
/**
|
|
165
|
+
* Every place a drawn segment still passes through a box it does not terminate at, after every route was checked.
|
|
166
|
+
*
|
|
167
|
+
* A drawing should carry none. The field exists for the case where no stage and no router could find a way around:
|
|
168
|
+
* a route that cannot be drawn honestly is reported here rather than silently drawn through a box.
|
|
169
|
+
*/
|
|
170
|
+
routingFailures: RoutingFailure[];
|
|
171
|
+
/** How many edge labels could not be placed clear of every box, label and other edge. */
|
|
172
|
+
labelCollisions: number;
|
|
106
173
|
}
|
|
107
174
|
/**
|
|
108
175
|
* How far apart two edges that join the same pair are drawn.
|
|
@@ -120,8 +187,55 @@ export declare function roleOfNode(node: DiagramNode): ColourRole;
|
|
|
120
187
|
* place, and the writer draws the rectangle that was reserved. A label with two sizes is a label half masked.
|
|
121
188
|
*/
|
|
122
189
|
export declare const LABEL_HEIGHT = 12;
|
|
190
|
+
/** A straight piece of an edge, as the collision test needs it. */
|
|
191
|
+
interface Segment {
|
|
192
|
+
x1: number;
|
|
193
|
+
y1: number;
|
|
194
|
+
x2: number;
|
|
195
|
+
y2: number;
|
|
196
|
+
}
|
|
197
|
+
/** Where a label sits: the point it is centred on, and the rectangle its mask covers. */
|
|
198
|
+
interface LabelSpot {
|
|
199
|
+
x: number;
|
|
200
|
+
y: number;
|
|
201
|
+
width: number;
|
|
202
|
+
height: number;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The point an edge's label is drawn at.
|
|
206
|
+
*
|
|
207
|
+
* A label is not furniture parked beside the line: it names the transition, so it sits on the line - anchored on one
|
|
208
|
+
* of its own straight runs - and the writer masks the stretch of line it covers. Text beside a line can be read as
|
|
209
|
+
* belonging to whatever it happens to sit next to; text that interrupts its own line reads as part of it.
|
|
210
|
+
*
|
|
211
|
+
* The anchor then has to be earned. A mask over a box hides a border, over another label it makes both unreadable,
|
|
212
|
+
* and over a third edge's line it hides that line. So the label walks along a run in small steps, in both directions
|
|
213
|
+
* and bounded so its rectangle stays on that run, and takes the first place that is clear. It tries the longest run
|
|
214
|
+
* first and then the shorter ones: on a dense drawing the longest run can be crowded for its whole length while a
|
|
215
|
+
* shorter run of the same edge is empty, and a label that gives up after one run leaves the drawing with a collision
|
|
216
|
+
* it did not have to accept.
|
|
217
|
+
*
|
|
218
|
+
* When no run offers a clear place, the label takes the place with the fewest collisions - boxes and other labels
|
|
219
|
+
* first, because text over a border hides the border and text over text is unreadable, then other edges, which a mask
|
|
220
|
+
* merely interrupts. It never leaves its own edge: a label moved onto a neighbouring edge is a label that names the
|
|
221
|
+
* wrong thing. The choice is returned with the label, so a crowded drawing can be told from a placement bug.
|
|
222
|
+
*
|
|
223
|
+
* Exported because the choice is pure and worth asserting without a layout: a test can hand it one polyline, one box
|
|
224
|
+
* and one other route, and read which run it took and what it could not clear.
|
|
225
|
+
*/
|
|
226
|
+
export declare function placeLabel(label: string, points: Point[], boxes: Iterable<Box>, placed: LabelSpot[], otherRoutes: Segment[][]): LabelPlacement & LabelSpot;
|
|
227
|
+
/**
|
|
228
|
+
* The boxes a polyline passes through other than the two it terminates at.
|
|
229
|
+
*
|
|
230
|
+
* This is the drawing's one clearance rule, and it is deliberately written once: a channel run, a lane, a trunk
|
|
231
|
+
* branch, a wrapped column's sideways step and the router's fallback all have to obey the same test. A box is skipped
|
|
232
|
+
* only when it is one of the edge's own two ends, so a port that sits inside its own symbol (a cylinder's cap apex)
|
|
233
|
+
* is allowed and a segment that ends inside somebody else's box is not.
|
|
234
|
+
*/
|
|
235
|
+
export declare function boxesCrossedBy(points: Point[], from: Box, to: Box, boxes: Iterable<Box>): Box[];
|
|
123
236
|
export declare function paintWith(diagram: Diagram, overrides?: Partial<Palette>, options?: PaletteOptions): {
|
|
124
237
|
canvas: Canvas;
|
|
125
238
|
colours: Palette;
|
|
126
239
|
};
|
|
127
240
|
export declare function paint(diagram: Diagram, overrides?: Partial<Palette>): Canvas;
|
|
241
|
+
export {};
|
|
@@ -41,9 +41,14 @@ export interface Placement {
|
|
|
41
41
|
groupRects: Map<string, Box>;
|
|
42
42
|
width: number;
|
|
43
43
|
lanes: Map<number, Lane>;
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
/**
|
|
45
|
+
* The height of the one horizontal run an edge between two adjacent rows needs.
|
|
46
|
+
*
|
|
47
|
+
* A run is measured between two rows of the level that produced it. When a row is a boundary, that strip is the space
|
|
48
|
+
* outside the boundary while the painter draws the boxes inside it, so the run is a proposal rather than a fact: the
|
|
49
|
+
* painter clears it against every box and hands the edge to the router when a leg would cross one.
|
|
50
|
+
*/
|
|
51
|
+
runs: Map<number, number>; /** Edges whose two ends ended up in different wrapped columns: they are read across, not down. */
|
|
47
52
|
across: Set<number>;
|
|
48
53
|
}
|
|
49
54
|
/** Geometry constants in one place: a container with different proportions changes these, not the rules. */
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fan-in trunks: a batch of arrows into one box from one side, drawn as one bundle.
|
|
3
|
+
*
|
|
4
|
+
* A node that four relations point at is drawn as four ports along its side and four lines converging on it. Each
|
|
5
|
+
* line is right on its own, and the picture is wrong: the bundle reads as a comb, and a reader counts ports to count
|
|
6
|
+
* arrows. The mature layered implementations solve this by merging the edges - ELK's *Merge Edges* with junction
|
|
7
|
+
* points, Graphviz's `concentrate` - and the shape they produce is one straight run outside the node, with a branch
|
|
8
|
+
* joining it wherever a separate edge arrives, and one shared stub into the node.
|
|
9
|
+
*
|
|
10
|
+
* This module decides only which ends may merge, where the shared run sits and which of the branches becomes the
|
|
11
|
+
* shared entry. It is pure geometry over boxes and ports, so the rules can be asserted without a canvas; the painter
|
|
12
|
+
* draws the answer.
|
|
13
|
+
*
|
|
14
|
+
* The rules, and why each one exists:
|
|
15
|
+
*
|
|
16
|
+
* - **One target, one entry side.** Edges that arrive from different sides are travelling in different directions,
|
|
17
|
+
* and a trunk that changed axis would be two trunks.
|
|
18
|
+
* - **The exit faces the trunk.** A branch arrives at the trunk along the direction its own port leaves by, so the
|
|
19
|
+
* trunk is outside the source as well as outside the target. An end that does not face the trunk is not merged: it
|
|
20
|
+
* would have to turn before it got there, which is the bend the merge was supposed to save.
|
|
21
|
+
* - **Distinct sources, three or more.** Two merged edges read as one thick line. A pair of relations about one pair
|
|
22
|
+
* has its own, older rule - they stay visibly apart - and that rule is the distinct-source rule here: two
|
|
23
|
+
* statements about one pair share their source, so they can never both be a branch of one trunk.
|
|
24
|
+
* - **One arrow semantics.** A composition and an association are different statements about the relation; drawn on
|
|
25
|
+
* one trunk they would be read as one statement. Ends that disagree on their arrow are not merged with each other.
|
|
26
|
+
* - **Clear of every box.** A trunk that crosses a box is the same defect as a route that does, and it is worse here
|
|
27
|
+
* because it is drawn once for the whole bundle.
|
|
28
|
+
* - **One trunk per line.** Two groups whose trunks would be drawn on one line would read as one bundle between two
|
|
29
|
+
* unrelated targets, so the later group is refused.
|
|
30
|
+
*
|
|
31
|
+
* The shared entry is the middle of the target's side. A bundle enters the target through one port, and a side that
|
|
32
|
+
* carries one port is centred on it, so the trunk runs to the middle and every branch joins it there: the branches
|
|
33
|
+
* that are not in the middle bend, which is the price of one entry that reads as centred rather than one that leans
|
|
34
|
+
* towards whichever branch happened to arrive straightest.
|
|
35
|
+
*
|
|
36
|
+
* Merging forces every branch but one to bend twice, so an unmerged drawing of `k` straight branches costs
|
|
37
|
+
* `2 * (n - k)` bends and a merged one costs `2 * (n - 1)`; the branch nearest the middle is the lead because it is
|
|
38
|
+
* the only one that can still reach the shared port without a horizontal leg, and on a target whose middle happens
|
|
39
|
+
* to fall on a branch's own line that branch is free, as before.
|
|
40
|
+
*
|
|
41
|
+
* What this module does not decide: the colour of a branch, the text of its label, and how a branch's own route
|
|
42
|
+
* reaches the trunk. Those belong to the painter.
|
|
43
|
+
*/
|
|
44
|
+
import { type Side } from './route.js';
|
|
45
|
+
import type { Box } from './place.js';
|
|
46
|
+
import type { EdgeArrow } from './model.js';
|
|
47
|
+
/** One edge end, as the trunk planner reads it. */
|
|
48
|
+
export interface TrunkEnd {
|
|
49
|
+
/** The edge's index in the diagram, so the painter can key its answer back to the edge. */
|
|
50
|
+
index: number;
|
|
51
|
+
from: string;
|
|
52
|
+
to: string;
|
|
53
|
+
/** The side the edge's port leaves by, and the side it arrives at, as the port stage decided them. */
|
|
54
|
+
exit: Side;
|
|
55
|
+
entry: Side;
|
|
56
|
+
/** The arrow the notation stated. Ends merge only when they agree on it; two unset arrows agree. */
|
|
57
|
+
arrow?: EdgeArrow;
|
|
58
|
+
/** The height of the run the channel stage gave the edge. An edge without one is not drawn by a run. */
|
|
59
|
+
runY?: number;
|
|
60
|
+
}
|
|
61
|
+
/** A port in the drawing's coordinates. Structural, so this module needs no import from the painter. */
|
|
62
|
+
export interface TrunkPoint {
|
|
63
|
+
x: number;
|
|
64
|
+
y: number;
|
|
65
|
+
}
|
|
66
|
+
export interface TrunkInput {
|
|
67
|
+
ends: TrunkEnd[];
|
|
68
|
+
/** The box of a node, and nothing else: a boundary is not a trunk target, because an arrow points past it. */
|
|
69
|
+
boxOf: (id: string) => Box | undefined;
|
|
70
|
+
/** Every box the trunk and its branches must keep clear of. A boundary is not an obstacle, as on any route. */
|
|
71
|
+
boxes: Iterable<Box>;
|
|
72
|
+
/** Where an edge's own exit port sits, when it has one. The branch joins the trunk at that port's coordinate. */
|
|
73
|
+
exitPortOf: (index: number) => TrunkPoint | undefined;
|
|
74
|
+
}
|
|
75
|
+
/** One accepted bundle: what it merges, where its run sits, and which branch the target is entered by. */
|
|
76
|
+
export interface TrunkGroup {
|
|
77
|
+
/** Position in `groups`, which is also the id a junction dot carries. */
|
|
78
|
+
id: number;
|
|
79
|
+
target: string;
|
|
80
|
+
side: Side;
|
|
81
|
+
/** The coordinate of the trunk line: a y when the trunk runs across, an x when it runs down. */
|
|
82
|
+
at: number;
|
|
83
|
+
/** The coordinate of the one port the bundle enters by: the middle of the target's side. */
|
|
84
|
+
entryAlong: number;
|
|
85
|
+
/** The two ends of the run, along the axis it moves on. A branch joins it between them. */
|
|
86
|
+
from: number;
|
|
87
|
+
to: number;
|
|
88
|
+
/** The member whose own exit is nearest the shared entry, and therefore the only branch that may still be a straight drop. */
|
|
89
|
+
lead: number;
|
|
90
|
+
/** The edges in the bundle, in the order the source wrote them. */
|
|
91
|
+
members: number[];
|
|
92
|
+
}
|
|
93
|
+
export interface TrunkPlan {
|
|
94
|
+
groups: TrunkGroup[];
|
|
95
|
+
/** By edge index: the id of the group it joined. An edge not in here travels alone. */
|
|
96
|
+
groupOf: Map<number, number>;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Decide which ends merge, and where each accepted trunk runs.
|
|
100
|
+
*
|
|
101
|
+
* Ends that cannot merge are simply absent from the plan; the painter draws them as it always has. Groups are
|
|
102
|
+
* ordered by the first member the source wrote, so the answer does not depend on map iteration order.
|
|
103
|
+
*/
|
|
104
|
+
export declare function planTrunks(input: TrunkInput): TrunkPlan;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-model-everything",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "Modeling abstraction, architecture visualisation and automated review for designs and code, including a refactoring harness: draw a Mermaid or PlantUML source and measure its structure, executable verification of machines and protocols with before/after regression, data-model and migration checks, concurrency claim routing, export to external checkers, dependency-graph review, and report diffing. Native plugin for DeepSeek Harness (dsh).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -22,6 +22,7 @@ question is what the picture shows.
|
|
|
22
22
|
| "what changed between these two diagrams", "did this package move" | `diagram_diff`: boundaries added, removed, renamed or re-parented, and nodes that changed owner |
|
|
23
23
|
| A source in a notation the plugin does not draw | Refused with the line number; `diagram_render` still draws everything else and reports what it skipped |
|
|
24
24
|
| An edge whose straight line would cross a component | Routed around it on a sparse grid (candidate lines from each box edge, A* with a bend penalty), so a line never reads as a connection that is not there |
|
|
25
|
+
| A batch of arrows into one component from the same side | Drawn as one **bundle**: a straight run outside the target, one shared entry, and a junction dot where each branch joins. Only ends agreeing on target, entry side and arrow meaning merge, from distinct sources, and three is the least that does - so a decision's two branches stay two lines and two relations between one pair stay apart |
|
|
25
26
|
| A label with a hard line break, or one too long for its box | `<br>`, `<br/>` and a real newline end a line; anything longer than the drawing's line width wraps. The box is sized from the lines it draws. An edge label is drawn on its edge - centred on the middle of the edge's longest straight run, over a mask in the page colour - and slides along that run to clear a box, another label or another edge's line |
|
|
26
27
|
| A diagram already in a file, to link from a document | `diagram_render` with `out`, then link the SVG |
|
|
27
28
|
|