dsh-model-everything 0.5.0 → 0.5.1

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.
Files changed (62) hide show
  1. package/README.en-US.md +12 -0
  2. package/README.md +4 -0
  3. package/lib/client.js +2437 -216
  4. package/lib/render/channels.js +243 -0
  5. package/lib/render/collapse.js +12 -20
  6. package/lib/render/components.js +99 -0
  7. package/lib/render/coords.js +288 -0
  8. package/lib/render/cycle-break.js +147 -0
  9. package/lib/render/dummy.js +43 -0
  10. package/lib/render/from-mermaid.js +34 -10
  11. package/lib/render/from-plantuml.js +318 -46
  12. package/lib/render/layer.js +91 -0
  13. package/lib/render/layout.js +27 -11
  14. package/lib/render/network-simplex.js +272 -0
  15. package/lib/render/order.js +122 -0
  16. package/lib/render/paint.js +764 -67
  17. package/lib/render/place.js +393 -121
  18. package/lib/render/route.js +225 -0
  19. package/lib/render/svg.js +143 -21
  20. package/lib/render/symbol.js +108 -0
  21. package/lib/render/text.js +74 -0
  22. package/lib/render/wrap.js +87 -0
  23. package/lib/types/render/channels.d.ts +63 -0
  24. package/lib/types/render/components.d.ts +57 -0
  25. package/lib/types/render/coords.d.ts +64 -0
  26. package/lib/types/render/cycle-break.d.ts +45 -0
  27. package/lib/types/render/dummy.d.ts +32 -0
  28. package/lib/types/render/from-plantuml.d.ts +12 -6
  29. package/lib/types/render/layer.d.ts +28 -0
  30. package/lib/types/render/layout.d.ts +43 -11
  31. package/lib/types/render/model.d.ts +17 -0
  32. package/lib/types/render/network-simplex.d.ts +54 -0
  33. package/lib/types/render/order.d.ts +27 -0
  34. package/lib/types/render/paint.d.ts +42 -9
  35. package/lib/types/render/place.d.ts +35 -19
  36. package/lib/types/render/route.d.ts +77 -0
  37. package/lib/types/render/svg.d.ts +1 -1
  38. package/lib/types/render/symbol.d.ts +73 -0
  39. package/lib/types/render/text.d.ts +38 -0
  40. package/lib/types/render/wrap.d.ts +27 -0
  41. package/package.json +148 -148
  42. package/skills/model-everything-diagram/SKILL.md +84 -82
  43. package/src/render/channels.ts +309 -0
  44. package/src/render/collapse.ts +150 -155
  45. package/src/render/components.ts +126 -0
  46. package/src/render/coords.ts +328 -0
  47. package/src/render/cycle-break.ts +164 -0
  48. package/src/render/dummy.ts +65 -0
  49. package/src/render/from-mermaid.ts +261 -236
  50. package/src/render/from-plantuml.ts +469 -178
  51. package/src/render/layer.ts +98 -0
  52. package/src/render/layout.ts +101 -62
  53. package/src/render/model.ts +86 -67
  54. package/src/render/network-simplex.ts +290 -0
  55. package/src/render/order.ts +139 -0
  56. package/src/render/paint.ts +1006 -290
  57. package/src/render/place.ts +527 -234
  58. package/src/render/route.ts +269 -0
  59. package/src/render/svg.ts +270 -127
  60. package/src/render/symbol.ts +137 -0
  61. package/src/render/text.ts +86 -0
  62. package/src/render/wrap.ts +103 -0
package/src/render/svg.ts CHANGED
@@ -1,127 +1,270 @@
1
- /**
2
- * Shapes to SVG markup.
3
- *
4
- * The one module that knows tags, so it is also the one place where a shape can silently disappear if a
5
- * writer forgets a case: the writer table has an entry per shape kind, and the caller counts the drops
6
- * instead of hiding them.
7
- *
8
- * Three containers read this output - an inline card, a full-screen view, and a sidebar tab - so the markup
9
- * carries no size of its own beyond its `viewBox`: the container decides how large the drawing is, and the
10
- * same string serves a thumbnail and a whiteboard.
11
- *
12
- * Pure, and the output is asserted as text: escaping, geometry, arrowheads, wrapped captions.
13
- */
14
-
15
- import type { Canvas, Shape } from './paint.js'
16
- import type { Palette } from './palette.js'
17
-
18
- /** One decimal place is enough at any zoom, and it keeps the markup readable. */
19
- const n = (value: number): string => {
20
- const rounded = Math.round(value * 10) / 10
21
- return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
22
- }
23
-
24
- const escape = (value: string): string =>
25
- value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
26
-
27
- type Writer<K extends Shape['kind']> = (shape: Extract<Shape, { kind: K }>, colours: Palette) => string
28
-
29
- const writers: { [K in Shape['kind']]: Writer<K> } = {
30
- // A boundary is a dashed rectangle with its name in the top-left corner, drawn under everything else.
31
- group: (shape, colours) => {
32
- const stroke = colours[shape.colour]
33
- // The id travels in the markup so a container can fold a boundary by delegation, without re-deriving which
34
- // group a click landed on from its position or its label.
35
- return (
36
- '<g data-group-id="' + escape(shape.id) + '" style="cursor:pointer">' +
37
- '<rect x="' + n(shape.x) + '" y="' + n(shape.y) + '" width="' + n(shape.width) + '" height="' + n(shape.height) +
38
- '" rx="8" fill="none" stroke="' + stroke + '" stroke-width="1" stroke-dasharray="4 3" />' +
39
- '<text x="' + n(shape.x + 6) + '" y="' + n(shape.y + 12) + '" font-size="10" fill="' + stroke + '">' + escape(shape.label) + '</text>' +
40
- '</g>'
41
- )
42
- },
43
- box: (shape, colours) => {
44
- const stroke = colours[shape.colour]
45
- const width = shape.initial ? 2.5 : 1
46
- return (
47
- '<rect x="' + n(shape.x) + '" y="' + n(shape.y) + '" width="' + n(shape.width) + '" height="' + n(shape.height) +
48
- '" rx="6" fill="' + colours.surface + '" stroke="' + stroke + '" stroke-width="' + n(width) + '" />' +
49
- '<text x="' + n(shape.x + shape.width / 2) + '" y="' + n(shape.y + shape.height / 2 + 4) +
50
- '" font-size="11" text-anchor="middle" fill="' + stroke + '">' + escape(shape.label) + '</text>'
51
- )
52
- },
53
- link: (shape, colours) => {
54
- const stroke = colours[shape.colour]
55
- const arrow = arrowHead(shape.x1, shape.y1, shape.x2, shape.y2, stroke)
56
- return (
57
- '<line x1="' + n(shape.x1) + '" y1="' + n(shape.y1) + '" x2="' + n(shape.x2) + '" y2="' + n(shape.y2) +
58
- '" stroke="' + stroke + '" stroke-width="1" />' + arrow + edgeLabel(shape.label, shape.labelX, shape.labelY, colours)
59
- )
60
- },
61
- path: (shape, colours) => {
62
- const stroke = colours[shape.colour]
63
- const points = shape.points.map((point) => n(point.x) + ',' + n(point.y)).join(' ')
64
- const last = shape.points[shape.points.length - 1]
65
- const before = shape.points[shape.points.length - 2] ?? last
66
- return (
67
- '<polyline points="' + points + '" fill="none" stroke="' + stroke + '" stroke-width="1" stroke-linejoin="round" />' +
68
- arrowHead(before.x, before.y, last.x, last.y, stroke) + edgeLabel(shape.label, shape.labelX, shape.labelY, colours)
69
- )
70
- },
71
- title: (shape, colours) => {
72
- return '<text x="' + n(shape.x) + '" y="' + n(shape.y) + '" font-size="12" fill="' + colours[shape.colour] + '">' + escape(shape.text) + '</text>'
73
- },
74
- caption: (shape, colours) => {
75
- const colour = colours[shape.colour]
76
- const lines = shape.lines
77
- .map((line, index) => '<tspan x="' + n(shape.x) + '" dy="' + (index === 0 ? 0 : 15) + '">' + escape(line) + '</tspan>')
78
- .join('')
79
- return '<text x="' + n(shape.x) + '" y="' + n(shape.y + 11) + '" font-size="10" fill="' + colour + '">' + lines + '</text>'
80
- },
81
- }
82
-
83
- /** A small filled triangle at the end of a segment, pointing along it. */
84
- function arrowHead(x1: number, y1: number, x2: number, y2: number, stroke: string): string {
85
- const length = Math.hypot(x2 - x1, y2 - y1)
86
- if (length < 1) return ''
87
- const ux = (x2 - x1) / length
88
- const uy = (y2 - y1) / length
89
- const size = 7
90
- const half = 3.2
91
- const baseX = x2 - ux * size
92
- const baseY = y2 - uy * size
93
- const left = n(baseX - uy * half) + ',' + n(baseY + ux * half)
94
- const right = n(baseX + uy * half) + ',' + n(baseY - ux * half)
95
- return '<polygon points="' + n(x2) + ',' + n(y2) + ' ' + left + ' ' + right + '" fill="' + stroke + '" />'
96
- }
97
-
98
- /** An edge label, or nothing when the edge carries no event. */
99
- function edgeLabel(label: string, x: number, y: number, colours: Palette): string {
100
- // Total on purpose: a malformed edge must lose its label, not the whole drawing.
101
- if (typeof label !== 'string' || label === '') return ''
102
- return '<text x="' + n(x) + '" y="' + n(y) + '" font-size="10" fill="' + colours.muted + '">' + escape(label) + '</text>'
103
- }
104
-
105
- export function toSvg(canvas: Canvas, colours: Palette, label?: string): { markup: string; dropped: number } {
106
- let dropped = 0
107
- const body: string[] = []
108
- for (const shape of canvas.shapes) {
109
- const writer = writers[shape.kind] as ((shape: Shape, colours: Palette) => string) | undefined
110
- if (writer === undefined) {
111
- dropped += 1
112
- continue
113
- }
114
- body.push(writer(shape, colours))
115
- }
116
- const header =
117
- '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + n(canvas.width) + ' ' + n(canvas.height) +
118
- '" width="' + n(canvas.width) + '" height="' + n(canvas.height) + '" role="img"' +
119
- (label === undefined ? '' : ' aria-label="' + escape(label) + '"') +
120
- ' font-family="system-ui, -apple-system, Segoe UI, sans-serif">'
121
- // The surface is part of the drawing, not the container's business: a page that paints its own layers - a
122
- // wallpaper, a themed panel - would otherwise change the contrast of a picture it knows nothing about.
123
- const background =
124
- '<rect x="0" y="0" width="' + n(canvas.width) + '" height="' + n(canvas.height) +
125
- '" fill="' + colours.background + '" />'
126
- return { markup: header + background + body.join('') + '</svg>', dropped }
127
- }
1
+ /**
2
+ * Shapes to SVG markup.
3
+ *
4
+ * The one module that knows tags, so it is also the one place where a shape can silently disappear if a
5
+ * writer forgets a case: the writer table has an entry per shape kind, and the caller counts the drops
6
+ * instead of hiding them.
7
+ *
8
+ * Three containers read this output - an inline card, a full-screen view, and a sidebar tab - so the markup
9
+ * carries no size of its own beyond its `viewBox`: the container decides how large the drawing is, and the
10
+ * same string serves a thumbnail and a whiteboard.
11
+ *
12
+ * Pure, and the output is asserted as text: escaping, geometry, arrowheads, wrapped captions.
13
+ */
14
+
15
+ /**
16
+ * A box's label, one `tspan` per line.
17
+ *
18
+ * The lines were wrapped before the box was placed, so the text fits by construction; a single `<text>` with the
19
+ * whole label is what used to overflow the box and print on top of its neighbours.
20
+ */
21
+ function boxText(shape: { x: number; y: number; width: number; height: number; label: string; lines?: string[] }, stroke: string): string {
22
+ const lines = shape.lines === undefined || shape.lines.length === 0 ? [shape.label] : shape.lines
23
+ const lineHeight = 15
24
+ const first = shape.y + shape.height / 2 - ((lines.length - 1) * lineHeight) / 2 + 4
25
+ const spans = lines
26
+ .map((line, index) => '<tspan x="' + n(shape.x + shape.width / 2) + '" y="' + n(first + index * lineHeight) + '">' + escape(line) + '</tspan>')
27
+ .join('')
28
+ return '<text font-size="11" text-anchor="middle" fill="' + stroke + '">' + spans + '</text>'
29
+ }
30
+
31
+
32
+ import { LABEL_HEIGHT, type Canvas, type Shape } from './paint.js'
33
+ import { textWidth } from './text.js'
34
+ import type { EdgeArrow } from './model.js'
35
+ import type { Palette } from './palette.js'
36
+ import { cylinderCaps, diamondPoints, hexagonPoints, stadiumRadius, type Cubic } from './symbol.js'
37
+
38
+ /** One decimal place is enough at any zoom, and it keeps the markup readable. */
39
+ const n = (value: number): string => {
40
+ const rounded = Math.round(value * 10) / 10
41
+ return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
42
+ }
43
+
44
+ const escape = (value: string): string =>
45
+ value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
46
+
47
+ type Writer<K extends Shape['kind']> = (shape: Extract<Shape, { kind: K }>, colours: Palette) => string
48
+
49
+ const writers: { [K in Shape['kind']]: Writer<K> } = {
50
+ // A boundary is a dashed rectangle with its name in the top-left corner, drawn under everything else.
51
+ group: (shape, colours) => {
52
+ const stroke = colours[shape.colour]
53
+ // The id travels in the markup so a container can fold a boundary by delegation, without re-deriving which
54
+ // group a click landed on from its position or its label.
55
+ return (
56
+ '<g data-group-id="' + escape(shape.id) + '" style="cursor:pointer">' +
57
+ '<rect x="' + n(shape.x) + '" y="' + n(shape.y) + '" width="' + n(shape.width) + '" height="' + n(shape.height) +
58
+ '" rx="8" fill="none" stroke="' + stroke + '" stroke-width="1" stroke-dasharray="4 3" />' +
59
+ '<text x="' + n(shape.x + 6) + '" y="' + n(shape.y + 12) + '" font-size="10" fill="' + stroke + '">' + escape(shape.label) + '</text>' +
60
+ '</g>'
61
+ )
62
+ },
63
+ box: (shape, colours) => {
64
+ const stroke = colours[shape.colour]
65
+ const width = shape.initial ? 2.5 : 1
66
+ const tail = '" fill="' + colours.surface + '" stroke="' + stroke + '" stroke-width="' + n(width) + '" />'
67
+ const x = shape.x
68
+ const y = shape.y
69
+ const w = shape.width
70
+ const h = shape.height
71
+ // The symbol's own points come from `symbol.ts`, which is also where the painter reads a port from: one
72
+ // arithmetic for the outline, so an anchor cannot disagree with the shape it lands on.
73
+ const at = (point: { x: number; y: number }): string => n(point.x) + ' ' + n(point.y)
74
+ const points = (list: Array<{ x: number; y: number }>): string => list.map((point) => n(point.x) + ',' + n(point.y)).join(',')
75
+ const curve = (segment: Cubic): string => ' C' + at(segment.c1) + ' ' + at(segment.c2) + ' ' + at(segment.to)
76
+ switch (shape.shape) {
77
+ // A decision: the notation wrote `{…}`, and a rounded rectangle would say "step".
78
+ case 'diamond':
79
+ return '<polygon points="' + points(diamondPoints(shape)) + tail + boxText(shape, stroke)
80
+ case 'hexagon':
81
+ return '<polygon points="' + points(hexagonPoints(shape)) + tail + boxText(shape, stroke)
82
+ // A datastore: the cylinder UML draws for a stored collection.
83
+ case 'cylinder': {
84
+ const caps = cylinderCaps(shape)
85
+ const path = 'M' + at(caps.top.from) + curve(caps.top) + ' L' + at(caps.bottom.from) + curve(caps.bottom) + ' Z'
86
+ return '<path d="' + path + tail + boxText(shape, stroke)
87
+ }
88
+ // A start or end state: the stadium a terminal node is drawn as.
89
+ case 'stadium':
90
+ return '<rect x="' + n(x) + '" y="' + n(y) + '" width="' + n(w) + '" height="' + n(h) + '" rx="' + n(stadiumRadius(shape)) + tail + boxText(shape, stroke)
91
+ case 'circle':
92
+ return '<ellipse cx="' + n(x + w / 2) + '" cy="' + n(y + h / 2) + '" rx="' + n(w / 2) + '" ry="' + n(h / 2) + tail + boxText(shape, stroke)
93
+ // A subroutine carries its double side lines.
94
+ case 'subroutine':
95
+ return (
96
+ '<rect x="' + n(x) + '" y="' + n(y) + '" width="' + n(w) + '" height="' + n(h) + tail +
97
+ '<line x1="' + n(x + 6) + '" y1="' + n(y) + '" x2="' + n(x + 6) + '" y2="' + n(y + h) + '" stroke="' + stroke + '" stroke-width="1" />' +
98
+ '<line x1="' + n(x + w - 6) + '" y1="' + n(y) + '" x2="' + n(x + w - 6) + '" y2="' + n(y + h) + '" stroke="' + stroke + '" stroke-width="1" />' +
99
+ boxText(shape, stroke)
100
+ )
101
+ default:
102
+ return '<rect x="' + n(x) + '" y="' + n(y) + '" width="' + n(w) + '" height="' + n(h) + '" rx="6' + tail + boxText(shape, stroke)
103
+ }
104
+ },
105
+ link: (shape, colours) => {
106
+ const stroke = colours[shape.colour]
107
+ const marks = edgeMarks(
108
+ { x: shape.x1, y: shape.y1 },
109
+ { x: shape.x2, y: shape.y2 },
110
+ { x: shape.x1, y: shape.y1 },
111
+ { x: shape.x2, y: shape.y2 },
112
+ shape.arrow,
113
+ stroke,
114
+ colours.background,
115
+ )
116
+ return (
117
+ '<line x1="' + n(shape.x1) + '" y1="' + n(shape.y1) + '" x2="' + n(shape.x2) + '" y2="' + n(shape.y2) +
118
+ '" stroke="' + stroke + '" stroke-width="1"' + dash(shape) + ' />' + marks + edgeLabel(shape.label, shape.labelX, shape.labelY, colours)
119
+ )
120
+ },
121
+ path: (shape, colours) => {
122
+ const stroke = colours[shape.colour]
123
+ const points = shape.points.map((point) => n(point.x) + ',' + n(point.y)).join(' ')
124
+ const last = shape.points[shape.points.length - 1]
125
+ const before = shape.points[shape.points.length - 2] ?? last
126
+ const first = shape.points[0]
127
+ const after = shape.points[1] ?? first
128
+ return (
129
+ '<polyline points="' + points + '" fill="none" stroke="' + stroke + '" stroke-width="1" stroke-linejoin="round"' + dash(shape) + ' />' +
130
+ edgeMarks(first, after, before, last, shape.arrow, stroke, colours.background) +
131
+ edgeLabel(shape.label, shape.labelX, shape.labelY, colours)
132
+ )
133
+ },
134
+ title: (shape, colours) => {
135
+ return '<text x="' + n(shape.x) + '" y="' + n(shape.y) + '" font-size="12" fill="' + colours[shape.colour] + '">' + escape(shape.text) + '</text>'
136
+ },
137
+ caption: (shape, colours) => {
138
+ const colour = colours[shape.colour]
139
+ const lines = shape.lines
140
+ .map((line, index) => '<tspan x="' + n(shape.x) + '" dy="' + (index === 0 ? 0 : 15) + '">' + escape(line) + '</tspan>')
141
+ .join('')
142
+ return '<text x="' + n(shape.x) + '" y="' + n(shape.y + 11) + '" font-size="10" text-anchor="middle" fill="' + colour + '">' + lines + '</text>'
143
+ },
144
+ }
145
+
146
+ /** The dash pattern a route's source stated, or nothing for a solid line. */
147
+ function dash(shape: { dashed?: boolean }): string {
148
+ return shape.dashed === true ? ' stroke-dasharray="4 3"' : ''
149
+ }
150
+
151
+ interface At {
152
+ x: number
153
+ y: number
154
+ }
155
+
156
+ /**
157
+ * The marks at an edge's two ends.
158
+ *
159
+ * `source`/`sourceNext` give the direction the line leaves the source, and `targetPrev`/`target` the direction it
160
+ * arrives at the target: the two ends of a polyline run in different directions. An edge with no arrow field keeps
161
+ * the plain arrow it has always been drawn with. A composition or an aggregation moves the mark to the source; a
162
+ * dependency and a realisation are told apart by the line, not by the head.
163
+ */
164
+ function edgeMarks(source: At, sourceNext: At, targetPrev: At, target: At, arrow: EdgeArrow | undefined, stroke: string, background: string): string {
165
+ if (arrow === 'composition' || arrow === 'aggregation') {
166
+ return diamond(source, sourceNext, stroke, arrow === 'composition' ? stroke : background)
167
+ }
168
+ if (arrow === 'generalization' || arrow === 'realization') {
169
+ return hollowTriangle(targetPrev, target, stroke, background)
170
+ }
171
+ return arrowHead(targetPrev.x, targetPrev.y, target.x, target.y, stroke)
172
+ }
173
+
174
+ /** The tip and the two base corners of a head that points from `from` to `to`. */
175
+ function tipAndBase(from: At, to: At): { tip: string; left: string; right: string } | undefined {
176
+ const length = Math.hypot(to.x - from.x, to.y - from.y)
177
+ if (length < 1) return undefined
178
+ const ux = (to.x - from.x) / length
179
+ const uy = (to.y - from.y) / length
180
+ const size = 7
181
+ const half = 3.2
182
+ const baseX = to.x - ux * size
183
+ const baseY = to.y - uy * size
184
+ return {
185
+ tip: n(to.x) + ',' + n(to.y),
186
+ left: n(baseX - uy * half) + ',' + n(baseY + ux * half),
187
+ right: n(baseX + uy * half) + ',' + n(baseY - ux * half),
188
+ }
189
+ }
190
+
191
+ /** A small filled triangle at the end of a segment, pointing along it. */
192
+ function arrowHead(x1: number, y1: number, x2: number, y2: number, stroke: string): string {
193
+ const points = tipAndBase({ x: x1, y: y1 }, { x: x2, y: y2 })
194
+ if (points === undefined) return ''
195
+ return '<polygon points="' + points.tip + ' ' + points.left + ' ' + points.right + '" fill="' + stroke + '" />'
196
+ }
197
+
198
+ /** The open triangle a generalisation or a realisation ends in: the surface shows through it. */
199
+ function hollowTriangle(from: At, to: At, stroke: string, background: string): string {
200
+ const points = tipAndBase(from, to)
201
+ if (points === undefined) return ''
202
+ return (
203
+ '<polygon points="' + points.tip + ' ' + points.left + ' ' + points.right +
204
+ '" fill="' + background + '" stroke="' + stroke + '" stroke-width="1" />'
205
+ )
206
+ }
207
+
208
+ /** The diamond a composition or an aggregation puts at its source, pointing back along the line. */
209
+ function diamond(at: At, next: At, stroke: string, fill: string): string {
210
+ const length = Math.hypot(next.x - at.x, next.y - at.y)
211
+ if (length < 1) return ''
212
+ const ux = (next.x - at.x) / length
213
+ const uy = (next.y - at.y) / length
214
+ const size = 12
215
+ const half = 4
216
+ const back = { x: at.x + ux * size, y: at.y + uy * size }
217
+ const middle = { x: at.x + (ux * size) / 2, y: at.y + (uy * size) / 2 }
218
+ const left = { x: middle.x - uy * half, y: middle.y + ux * half }
219
+ const right = { x: middle.x + uy * half, y: middle.y - ux * half }
220
+ const points = [at, left, back, right].map((point) => n(point.x) + ',' + n(point.y)).join(' ')
221
+ return '<polygon points="' + points + '" fill="' + fill + '" stroke="' + stroke + '" stroke-width="1" />'
222
+ }
223
+
224
+ /**
225
+ * An edge label, or nothing when the edge carries no event.
226
+ *
227
+ * The text is centred on the point the painter chose - a point on the edge itself - and drawn over a rectangle in
228
+ * the page colour. The mask is the whole point of drawing it here: it hides the stretch of line behind the text,
229
+ * so the label interrupts its edge instead of floating beside it, which is the only way a reader can tell that
230
+ * the word names that line and not something it happens to sit next to. The label keeps its own colour: the mask
231
+ * is geometry, not emphasis.
232
+ */
233
+ function edgeLabel(label: string, x: number, y: number, colours: Palette): string {
234
+ // Total on purpose: a malformed edge must lose its label, not the whole drawing.
235
+ if (typeof label !== 'string' || label === '') return ''
236
+ const width = textWidth(label)
237
+ const left = x - width / 2
238
+ const top = y - LABEL_HEIGHT / 2
239
+ // Ten point text sits on a baseline about a third of the mask above its centre, which centres the cap height.
240
+ const baseline = y + 3.5
241
+ return (
242
+ '<rect x="' + n(left) + '" y="' + n(top) + '" width="' + n(width) + '" height="' + n(LABEL_HEIGHT) +
243
+ '" fill="' + colours.background + '" />' +
244
+ '<text x="' + n(x) + '" y="' + n(baseline) + '" font-size="10" text-anchor="middle" fill="' + colours.muted + '">' + escape(label) + '</text>'
245
+ )
246
+ }
247
+
248
+ export function toSvg(canvas: Canvas, colours: Palette, label?: string): { markup: string; dropped: number } {
249
+ let dropped = 0
250
+ const body: string[] = []
251
+ for (const shape of canvas.shapes) {
252
+ const writer = writers[shape.kind] as ((shape: Shape, colours: Palette) => string) | undefined
253
+ if (writer === undefined) {
254
+ dropped += 1
255
+ continue
256
+ }
257
+ body.push(writer(shape, colours))
258
+ }
259
+ const header =
260
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + n(canvas.width) + ' ' + n(canvas.height) +
261
+ '" width="' + n(canvas.width) + '" height="' + n(canvas.height) + '" role="img"' +
262
+ (label === undefined ? '' : ' aria-label="' + escape(label) + '"') +
263
+ ' font-family="system-ui, -apple-system, Segoe UI, sans-serif">'
264
+ // The surface is part of the drawing, not the container's business: a page that paints its own layers - a
265
+ // wallpaper, a themed panel - would otherwise change the contrast of a picture it knows nothing about.
266
+ const background =
267
+ '<rect x="0" y="0" width="' + n(canvas.width) + '" height="' + n(canvas.height) +
268
+ '" fill="' + colours.background + '" />'
269
+ return { markup: header + background + body.join('') + '</svg>', dropped }
270
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The geometry of a node's symbol, shared by the module that draws it and the module that attaches edges to it.
3
+ *
4
+ * A box is a rectangle, but a symbol is not: the diamond's side midpoints are its vertices, a circle has no side at
5
+ * all, and a cylinder's top is an arc. Any rule that decides where an edge may meet a node therefore needs the
6
+ * symbol's own points - and the module that draws the symbol needs the same numbers, or the anchor and the outline
7
+ * disagree and an edge ends in the air.
8
+ *
9
+ * So the arithmetic lives here once: the painter reads a port from it, the writer draws the shape from it, and a
10
+ * change to a symbol's outline moves both. Pure data and pure functions, no SVG markup.
11
+ */
12
+
13
+ import type { Box, Point } from './place.js'
14
+ import type { NodeShape } from './model.js'
15
+
16
+ /** A cubic Bezier, as the writer draws a curve and the painter reads a point from it. */
17
+ export interface Cubic {
18
+ from: Point
19
+ c1: Point
20
+ c2: Point
21
+ to: Point
22
+ }
23
+
24
+ /** Where the two caps of a cylinder start and end, and how deep each one is. */
25
+ export interface CylinderCaps {
26
+ /** How far the cap reaches into the box: the writer's `min(10, h / 3)`. */
27
+ cap: number
28
+ /** The top cap, drawn from its left shoulder to its right shoulder. */
29
+ top: Cubic
30
+ /** The bottom cap, drawn from its right shoulder to its left shoulder. */
31
+ bottom: Cubic
32
+ }
33
+
34
+ /** A point on a cubic Bezier at parameter `t`, which is what makes "the top of the cap" a shared fact. */
35
+ export function cubicPoint(curve: Cubic, t: number): Point {
36
+ const u = 1 - t
37
+ const a = u * u * u
38
+ const b = 3 * u * u * t
39
+ const c = 3 * u * t * t
40
+ const d = t * t * t
41
+ return {
42
+ x: a * curve.from.x + b * curve.c1.x + c * curve.c2.x + d * curve.to.x,
43
+ y: a * curve.from.y + b * curve.c1.y + c * curve.c2.y + d * curve.to.y,
44
+ }
45
+ }
46
+
47
+ /**
48
+ * A diamond's four points, in drawing order: top, right, bottom, left.
49
+ *
50
+ * These are the only four places an edge may meet a diamond: each side of the box meets the symbol at exactly that
51
+ * point, and every other point on the side is in the air inside or outside the outline.
52
+ */
53
+ export function diamondPoints(box: Box): [Point, Point, Point, Point] {
54
+ return [
55
+ { x: box.x + box.width / 2, y: box.y },
56
+ { x: box.x + box.width, y: box.y + box.height / 2 },
57
+ { x: box.x + box.width / 2, y: box.y + box.height },
58
+ { x: box.x, y: box.y + box.height / 2 },
59
+ ]
60
+ }
61
+
62
+ /**
63
+ * Does this symbol meet each of its four sides at exactly one point?
64
+ *
65
+ * A diamond's side midpoints are its four vertices, and an ellipse's are its four cardinal points: both offer one legal
66
+ * port per side and no run to spread along. Every other symbol offers a run on at least one side - a rectangle along
67
+ * its whole side, a hexagon along the flat between its cut corners, a stadium between its end caps, a cylinder along
68
+ * its straight flank - so a port there has a place to sit rather than a point to take.
69
+ *
70
+ * This is the distinction the painter needs: a node with one legal point per side can give its edges a point each,
71
+ * and a node with a run can only give them places along it. The two points the discrete shapes offer are the same
72
+ * four points of the box, which is why one answer serves both.
73
+ */
74
+ export function hasDiscretePorts(shape: NodeShape | undefined): boolean {
75
+ return shape === 'diamond' || shape === 'circle'
76
+ }
77
+
78
+ /** How much of each end of a hexagon the corner cuts away. */
79
+ export function hexagonCut(width: number): number {
80
+ return Math.min(12, width / 4)
81
+ }
82
+
83
+ /**
84
+ * A hexagon's six points, in drawing order: top-left, top-right, right, bottom-right, bottom-left, left.
85
+ *
86
+ * The top and bottom are flat runs between the first two and the last two points; the left and right sides are the
87
+ * single points at index 5 and 2, because the corner cut removed everything else.
88
+ */
89
+ export function hexagonPoints(box: Box): [Point, Point, Point, Point, Point, Point] {
90
+ const cut = hexagonCut(box.width)
91
+ return [
92
+ { x: box.x + cut, y: box.y },
93
+ { x: box.x + box.width - cut, y: box.y },
94
+ { x: box.x + box.width, y: box.y + box.height / 2 },
95
+ { x: box.x + box.width - cut, y: box.y + box.height },
96
+ { x: box.x + cut, y: box.y + box.height },
97
+ { x: box.x, y: box.y + box.height / 2 },
98
+ ]
99
+ }
100
+
101
+ /** The radius of a stadium's end caps: half its height, unless the box is narrower than that. */
102
+ export function stadiumRadius(box: Box): number {
103
+ return Math.min(box.height / 2, box.width / 2)
104
+ }
105
+
106
+ /**
107
+ * A cylinder's two caps.
108
+ *
109
+ * The writer draws the top cap from the left shoulder to the right shoulder with the box's own top edge as the
110
+ * control line, and the bottom cap mirrored. The cap curve's apex - the point the painter attaches a top or bottom
111
+ * port to - is therefore a property of this curve, not a constant: it sits a quarter of the cap's depth inside the
112
+ * box, not `cap` inside it.
113
+ */
114
+ export function cylinderCaps(box: Box): CylinderCaps {
115
+ const cap = Math.min(10, box.height / 3)
116
+ return {
117
+ cap,
118
+ top: {
119
+ from: { x: box.x, y: box.y + cap },
120
+ c1: { x: box.x, y: box.y },
121
+ c2: { x: box.x + box.width, y: box.y },
122
+ to: { x: box.x + box.width, y: box.y + cap },
123
+ },
124
+ bottom: {
125
+ from: { x: box.x + box.width, y: box.y + box.height - cap },
126
+ c1: { x: box.x + box.width, y: box.y + box.height },
127
+ c2: { x: box.x, y: box.y + box.height },
128
+ to: { x: box.x, y: box.y + box.height - cap },
129
+ },
130
+ }
131
+ }
132
+
133
+ /** The apex of a cylinder's top or bottom cap: the only point on that side an edge meets the outline at. */
134
+ export function cylinderApex(box: Box, side: 'top' | 'bottom'): Point {
135
+ const caps = cylinderCaps(box)
136
+ return cubicPoint(side === 'top' ? caps.top : caps.bottom, 0.5)
137
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Text as the layout sees it: measured, broken into lines, and turned into a box.
3
+ *
4
+ * Mermaid and Graphviz both measure a label in the platform's own text engine and then lay out around that
5
+ * measurement - a node's size is its text plus padding, and a label too long for its maximum width is wrapped
6
+ * before anything is placed. There is no DOM here, so the measurement is an estimate over a per-character width
7
+ * table; what matters is that the *order* is the same: text first, geometry second. A renderer that fixes the box
8
+ * and pours text into it cannot help overflowing.
9
+ *
10
+ * Explicit breaks (`<br>`, `<br/>`, `<br />`, a real newline) are honoured as written, because a source that put
11
+ * one there wanted that line to end.
12
+ */
13
+
14
+ /** Average advance per character, in the drawing's units, at the 11px font the writer emits. */
15
+ const CHAR_WIDTH = 6.6
16
+
17
+ /** A line longer than this wraps on a space, or breaks in the middle when it has none. */
18
+ export const MAX_LINE_CHARS = 30
19
+
20
+ /** Height of one line of box text, and the breathing room around the block. */
21
+ export const LINE_HEIGHT = 15
22
+ export const TEXT_PADDING = 12
23
+
24
+ /** The smallest and largest box a label may produce. */
25
+ export const MIN_NODE_WIDTH = 88
26
+ export const MAX_NODE_WIDTH = 260
27
+
28
+ /** `<br>`, `<br/>`, `<br />` and a real newline all end a line. */
29
+ const BREAK = /<br\s*\/?>|\r?\n/gi
30
+
31
+ /** The lines a label asks for, before wrapping: an explicit break is a line end, not a space. */
32
+ export function explicitLines(label: string): string[] {
33
+ return label.split(BREAK).map((line) => line.trim())
34
+ }
35
+
36
+ /**
37
+ * Wrap one line to the maximum width.
38
+ *
39
+ * A word longer than the limit is broken rather than allowed to overflow: a single token with no spaces is the
40
+ * common case in identifiers, and letting it run off the box is the defect this function exists to prevent.
41
+ */
42
+ export function wrapLine(line: string, limit: number = MAX_LINE_CHARS): string[] {
43
+ if (line === '') return ['']
44
+ const out: string[] = []
45
+ let current = ''
46
+ for (const word of line.split(/\s+/).filter((part) => part !== '')) {
47
+ const candidate = current === '' ? word : current + ' ' + word
48
+ if (candidate.length <= limit) {
49
+ current = candidate
50
+ continue
51
+ }
52
+ if (current !== '') out.push(current)
53
+ if (word.length <= limit) {
54
+ current = word
55
+ continue
56
+ }
57
+ let rest = word
58
+ while (rest.length > limit) {
59
+ out.push(rest.slice(0, limit))
60
+ rest = rest.slice(limit)
61
+ }
62
+ current = rest
63
+ }
64
+ if (current !== '') out.push(current)
65
+ return out.length === 0 ? [''] : out
66
+ }
67
+
68
+ export interface Measured {
69
+ lines: string[]
70
+ width: number
71
+ height: number
72
+ }
73
+
74
+ /** The text block a label produces: its lines, and the box it needs. */
75
+ export function measure(label: string): Measured {
76
+ const lines = explicitLines(label).flatMap((line) => wrapLine(line))
77
+ const longest = lines.reduce((max, line) => Math.max(max, line.length), 0)
78
+ const width = Math.min(MAX_NODE_WIDTH, Math.max(MIN_NODE_WIDTH, longest * CHAR_WIDTH + TEXT_PADDING * 2))
79
+ const height = lines.length * LINE_HEIGHT + TEXT_PADDING
80
+ return { lines, width, height }
81
+ }
82
+
83
+ /** Rough width of a single-line piece of text, for placing an edge label without a box. */
84
+ export function textWidth(text: string): number {
85
+ return text.length * CHAR_WIDTH
86
+ }