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.
@@ -50,9 +50,14 @@ export interface Placement {
50
50
  groupRects: Map<string, Box>
51
51
  width: number
52
52
  lanes: Map<number, Lane>
53
- /** The height of the one horizontal run an edge between two adjacent rows needs. */
54
- runs: Map<number, number>
55
- /** Edges whose two ends ended up in different wrapped columns: they are read across, not down. */
53
+ /**
54
+ * The height of the one horizontal run an edge between two adjacent rows needs.
55
+ *
56
+ * A run is measured between two rows of the level that produced it. When a row is a boundary, that strip is the space
57
+ * outside the boundary while the painter draws the boxes inside it, so the run is a proposal rather than a fact: the
58
+ * painter clears it against every box and hands the edge to the router when a leg would cross one.
59
+ */
60
+ runs: Map<number, number> /** Edges whose two ends ended up in different wrapped columns: they are read across, not down. */
56
61
  across: Set<number>
57
62
  }
58
63
 
package/src/render/svg.ts CHANGED
@@ -131,6 +131,10 @@ const writers: { [K in Shape['kind']]: Writer<K> } = {
131
131
  edgeLabel(shape.label, shape.labelX, shape.labelY, colours)
132
132
  )
133
133
  },
134
+ // A junction dot: the visible answer to "is this a join or a crossing?". Filled in the line's own colour, so it
135
+ // reads as the line thickening where a branch meets it rather than as a new mark on the page.
136
+ junction: (shape, colours) =>
137
+ '<circle cx="' + n(shape.x) + '" cy="' + n(shape.y) + '" r="2.5" fill="' + colours[shape.colour] + '" />',
134
138
  title: (shape, colours) => {
135
139
  return '<text x="' + n(shape.x) + '" y="' + n(shape.y) + '" font-size="12" fill="' + colours[shape.colour] + '">' + escape(shape.text) + '</text>'
136
140
  },
@@ -0,0 +1,275 @@
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
+
45
+ import { crossesBox, normalOf, ROUTE_STUB, type Side } from './route.js'
46
+ import type { Box } from './place.js'
47
+ import type { EdgeArrow } from './model.js'
48
+
49
+ /** The least a trunk is worth drawing for: two merged edges read as one thick line. */
50
+ const TRUNK_MIN_EDGES = 3
51
+
52
+ /** One edge end, as the trunk planner reads it. */
53
+ export interface TrunkEnd {
54
+ /** The edge's index in the diagram, so the painter can key its answer back to the edge. */
55
+ index: number
56
+ from: string
57
+ to: string
58
+ /** The side the edge's port leaves by, and the side it arrives at, as the port stage decided them. */
59
+ exit: Side
60
+ entry: Side
61
+ /** The arrow the notation stated. Ends merge only when they agree on it; two unset arrows agree. */
62
+ arrow?: EdgeArrow
63
+ /** The height of the run the channel stage gave the edge. An edge without one is not drawn by a run. */
64
+ runY?: number
65
+ }
66
+
67
+ /** A port in the drawing's coordinates. Structural, so this module needs no import from the painter. */
68
+ export interface TrunkPoint {
69
+ x: number
70
+ y: number
71
+ }
72
+
73
+ export interface TrunkInput {
74
+ ends: TrunkEnd[]
75
+ /** The box of a node, and nothing else: a boundary is not a trunk target, because an arrow points past it. */
76
+ boxOf: (id: string) => Box | undefined
77
+ /** Every box the trunk and its branches must keep clear of. A boundary is not an obstacle, as on any route. */
78
+ boxes: Iterable<Box>
79
+ /** Where an edge's own exit port sits, when it has one. The branch joins the trunk at that port's coordinate. */
80
+ exitPortOf: (index: number) => TrunkPoint | undefined
81
+ }
82
+
83
+ /** One accepted bundle: what it merges, where its run sits, and which branch the target is entered by. */
84
+ export interface TrunkGroup {
85
+ /** Position in `groups`, which is also the id a junction dot carries. */
86
+ id: number
87
+ target: string
88
+ side: Side
89
+ /** The coordinate of the trunk line: a y when the trunk runs across, an x when it runs down. */
90
+ at: number
91
+ /** The coordinate of the one port the bundle enters by: the middle of the target's side. */
92
+ entryAlong: number
93
+ /** The two ends of the run, along the axis it moves on. A branch joins it between them. */
94
+ from: number
95
+ to: number
96
+ /** The member whose own exit is nearest the shared entry, and therefore the only branch that may still be a straight drop. */
97
+ lead: number
98
+ /** The edges in the bundle, in the order the source wrote them. */
99
+ members: number[]
100
+ }
101
+
102
+ export interface TrunkPlan {
103
+ groups: TrunkGroup[]
104
+ /** By edge index: the id of the group it joined. An edge not in here travels alone. */
105
+ groupOf: Map<number, number>
106
+ }
107
+
108
+ /** Does this side move along the page's vertical axis? A run on it is horizontal. */
109
+ function isVerticalSide(side: Side): boolean {
110
+ return side === 'top' || side === 'bottom'
111
+ }
112
+
113
+ /**
114
+ * Where the trunk outside a box sits.
115
+ *
116
+ * The same stub the router leaves before it turns, so the bundle starts exactly where a single route would have
117
+ * turned: about `ROUTE_STUB` outside the target, square to the side it enters.
118
+ */
119
+ function trunkAt(box: Box, side: Side): number {
120
+ if (side === 'top') return box.y - ROUTE_STUB
121
+ if (side === 'bottom') return box.y + box.height + ROUTE_STUB
122
+ if (side === 'left') return box.x - ROUTE_STUB
123
+ return box.x + box.width + ROUTE_STUB
124
+ }
125
+
126
+ /** The coordinate a point takes along the side it sits on: where along that side the port is. */
127
+ function alongSide(point: TrunkPoint, side: Side): number {
128
+ return isVerticalSide(side) ? point.x : point.y
129
+ }
130
+
131
+ /** The coordinate a point takes on its side's outward normal: the axis a trunk outside that side moves on. */
132
+ function alongNormal(point: TrunkPoint, side: Side): number {
133
+ return isVerticalSide(side) ? point.y : point.x
134
+ }
135
+
136
+ /** Does the port on `side` travel toward the trunk at `at`? A branch only merges into a trunk it moves toward. */
137
+ function facesTrunk(point: TrunkPoint, side: Side, at: number): boolean {
138
+ const normal = normalOf(side)
139
+ const outward = isVerticalSide(side) ? normal.y : normal.x
140
+ return (at - alongNormal(point, side)) * outward > 0.5
141
+ }
142
+
143
+ /** The group an edge end belongs to: its target, the side it enters by, and the arrow it means. */
144
+ function keyOf(end: TrunkEnd): string {
145
+ return end.to + '\u0000' + end.entry + '\u0000' + (end.arrow ?? '')
146
+ }
147
+
148
+ /** One end that has passed every per-edge rule, with the ports its group is built from. */
149
+ interface Candidate {
150
+ end: TrunkEnd
151
+ source: Box
152
+ target: Box
153
+ at: number
154
+ /** The port the branch leaves by, and the coordinate it joins the trunk at. */
155
+ exit: TrunkPoint
156
+ branch: number
157
+ }
158
+
159
+ /** Is this axis-aligned segment clear of every box it does not attach to? */
160
+ function clearSegment(boxes: Iterable<Box>, skip: Box[], x1: number, y1: number, x2: number, y2: number): boolean {
161
+ for (const box of boxes) {
162
+ if (skip.includes(box)) continue
163
+ if (crossesBox(x1, y1, x2, y2, box)) return false
164
+ }
165
+ return true
166
+ }
167
+
168
+ /** Would this run lie on the line of an accepted trunk and share a stretch of it? */
169
+ function onOneLine(group: TrunkGroup, side: Side, at: number, low: number, high: number): boolean {
170
+ if (isVerticalSide(group.side) !== isVerticalSide(side)) return false
171
+ if (group.at !== at) return false
172
+ return Math.min(group.to, high) - Math.max(group.from, low) > 0.5
173
+ }
174
+
175
+ /**
176
+ * Decide which ends merge, and where each accepted trunk runs.
177
+ *
178
+ * Ends that cannot merge are simply absent from the plan; the painter draws them as it always has. Groups are
179
+ * ordered by the first member the source wrote, so the answer does not depend on map iteration order.
180
+ */
181
+ export function planTrunks(input: TrunkInput): TrunkPlan {
182
+ const groups: TrunkGroup[] = []
183
+ const groupOf = new Map<number, number>()
184
+
185
+ // Per edge first: the cheap rules. An end that fails one of them cannot join any group, so it never reaches the
186
+ // count that decides whether a group is worth drawing.
187
+ const buckets = new Map<string, Candidate[]>()
188
+ for (const end of input.ends) {
189
+ if (end.runY === undefined) continue
190
+ // The branch approaches the trunk along its own exit normal, so the two ends must move on one axis: a mixed pair
191
+ // would have to turn before it reached the trunk, which is the bend the merge exists to save.
192
+ if (isVerticalSide(end.exit) !== isVerticalSide(end.entry)) continue
193
+ const source = input.boxOf(end.from)
194
+ const target = input.boxOf(end.to)
195
+ if (source === undefined || target === undefined) continue
196
+ if (source === target) continue
197
+ const exit = input.exitPortOf(end.index)
198
+ if (exit === undefined) continue
199
+ const at = trunkAt(target, end.entry)
200
+ if (!facesTrunk(exit, end.exit, at)) continue
201
+ const candidate: Candidate = { end, source, target, at, exit, branch: alongSide(exit, end.exit) }
202
+ const key = keyOf(end)
203
+ const list = buckets.get(key)
204
+ if (list === undefined) buckets.set(key, [candidate])
205
+ else list.push(candidate)
206
+ }
207
+
208
+ for (const list of buckets.values()) {
209
+ // A pair of relations about one node is not two branches: the pair's own rule keeps them visibly apart, and both
210
+ // of them name the same source, so dropping the duplicated source drops the whole pair. Neither joins, and the
211
+ // group keeps the singles.
212
+ const bySource = new Map<string, Candidate[]>()
213
+ for (const candidate of list) {
214
+ const seen = bySource.get(candidate.end.from)
215
+ if (seen === undefined) bySource.set(candidate.end.from, [candidate])
216
+ else seen.push(candidate)
217
+ }
218
+ const members = list.filter((candidate) => (bySource.get(candidate.end.from) as Candidate[]).length === 1)
219
+ if (members.length < TRUNK_MIN_EDGES) continue
220
+
221
+ const target = members[0].target
222
+ const side = members[0].end.entry
223
+ const at = members[0].at
224
+
225
+ // The shared entry is the middle of the target's side. A side that carries one port is centred on it, and a bundle
226
+ // enters through one port, so the run reaches the middle rather than whichever branch asked for the middle first.
227
+ const entryAlong = isVerticalSide(side) ? target.x + target.width / 2 : target.y + target.height / 2
228
+ // The lead is the branch nearest that middle: the only member that can reach the shared port without a lateral
229
+ // leg. The source's order breaks a tie, so the answer does not depend on map iteration.
230
+ let lead = members[0]
231
+ for (const candidate of members) {
232
+ if (Math.abs(candidate.branch - entryAlong) < Math.abs(lead.branch - entryAlong) - 1e-9) lead = candidate
233
+ }
234
+ // The run: the whole stretch the branches and the one port span, so every branch joins a line that reaches it.
235
+ let low = entryAlong
236
+ let high = entryAlong
237
+ for (const candidate of members) {
238
+ low = Math.min(low, candidate.branch)
239
+ high = Math.max(high, candidate.branch)
240
+ }
241
+ // Clearance. The run is checked against every box but the target; each branch's own drop is checked against every
242
+ // box but its source and the target. The drop leaves a port square to its side, so those are the only two boxes it
243
+ // can legitimately touch.
244
+ const vertical = isVerticalSide(side)
245
+ const run = vertical ? [low, at, high, at] : [at, low, at, high]
246
+ if (!clearSegment(input.boxes, [target], run[0], run[1], run[2], run[3])) continue
247
+ let blocked = false
248
+ for (const candidate of members) {
249
+ // The drop: from the branch's own port to the point where it meets the trunk, along its exit normal.
250
+ const drop = vertical
251
+ ? [candidate.branch, candidate.exit.y, candidate.branch, at]
252
+ : [candidate.exit.x, candidate.branch, at, candidate.branch]
253
+ if (!clearSegment(input.boxes, [candidate.source, target], drop[0], drop[1], drop[2], drop[3])) blocked = true
254
+ }
255
+ if (blocked) continue
256
+ // Two trunks on one line would be drawn as one bundle. The first group keeps the line; the later one travels alone.
257
+ if (groups.some((group) => onOneLine(group, side, at, low, high))) continue
258
+
259
+ const id = groups.length
260
+ groups.push({
261
+ id,
262
+ target: members[0].end.to,
263
+ side,
264
+ at,
265
+ entryAlong,
266
+ from: low,
267
+ to: high,
268
+ lead: lead.end.index,
269
+ members: members.map((candidate) => candidate.end.index),
270
+ })
271
+ for (const candidate of members) groupOf.set(candidate.end.index, id)
272
+ }
273
+
274
+ return { groups, groupOf }
275
+ }