dsh-model-everything 0.5.0 → 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/README.en-US.md +12 -0
- package/README.md +4 -0
- package/lib/client.js +2829 -224
- package/lib/render/channels.js +243 -0
- package/lib/render/collapse.js +12 -20
- package/lib/render/components.js +99 -0
- package/lib/render/coords.js +288 -0
- package/lib/render/cycle-break.js +147 -0
- package/lib/render/dummy.js +43 -0
- package/lib/render/from-mermaid.js +34 -10
- package/lib/render/from-plantuml.js +318 -46
- package/lib/render/layer.js +91 -0
- package/lib/render/layout.js +27 -11
- package/lib/render/network-simplex.js +272 -0
- package/lib/render/order.js +122 -0
- package/lib/render/paint.js +1227 -69
- package/lib/render/place.js +393 -121
- package/lib/render/route.js +225 -0
- package/lib/render/svg.js +146 -21
- package/lib/render/symbol.js +108 -0
- package/lib/render/text.js +74 -0
- package/lib/render/trunk.js +213 -0
- package/lib/render/wrap.js +87 -0
- package/lib/types/render/channels.d.ts +63 -0
- package/lib/types/render/components.d.ts +57 -0
- package/lib/types/render/coords.d.ts +64 -0
- package/lib/types/render/cycle-break.d.ts +45 -0
- package/lib/types/render/dummy.d.ts +32 -0
- package/lib/types/render/from-plantuml.d.ts +12 -6
- package/lib/types/render/layer.d.ts +28 -0
- package/lib/types/render/layout.d.ts +43 -11
- package/lib/types/render/model.d.ts +17 -0
- package/lib/types/render/network-simplex.d.ts +54 -0
- package/lib/types/render/order.d.ts +27 -0
- package/lib/types/render/paint.d.ts +157 -10
- package/lib/types/render/place.d.ts +40 -19
- package/lib/types/render/route.d.ts +77 -0
- package/lib/types/render/svg.d.ts +1 -1
- package/lib/types/render/symbol.d.ts +73 -0
- package/lib/types/render/text.d.ts +38 -0
- package/lib/types/render/trunk.d.ts +104 -0
- package/lib/types/render/wrap.d.ts +27 -0
- package/package.json +148 -148
- package/skills/model-everything-diagram/SKILL.md +85 -82
- package/src/render/channels.ts +309 -0
- package/src/render/collapse.ts +150 -155
- package/src/render/components.ts +126 -0
- package/src/render/coords.ts +328 -0
- package/src/render/cycle-break.ts +164 -0
- package/src/render/dummy.ts +65 -0
- package/src/render/from-mermaid.ts +261 -236
- package/src/render/from-plantuml.ts +469 -178
- package/src/render/layer.ts +98 -0
- package/src/render/layout.ts +101 -62
- package/src/render/model.ts +86 -67
- package/src/render/network-simplex.ts +290 -0
- package/src/render/order.ts +139 -0
- package/src/render/paint.ts +1570 -290
- package/src/render/place.ts +532 -234
- package/src/render/route.ts +269 -0
- package/src/render/svg.ts +274 -127
- package/src/render/symbol.ts +137 -0
- package/src/render/text.ts +86 -0
- package/src/render/trunk.ts +275 -0
- package/src/render/wrap.ts +103 -0
package/src/render/svg.ts
CHANGED
|
@@ -1,127 +1,274 @@
|
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
'<
|
|
58
|
-
'" stroke="' + stroke + '" stroke-width="1" />' +
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
|
|
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
|
+
// 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] + '" />',
|
|
138
|
+
title: (shape, colours) => {
|
|
139
|
+
return '<text x="' + n(shape.x) + '" y="' + n(shape.y) + '" font-size="12" fill="' + colours[shape.colour] + '">' + escape(shape.text) + '</text>'
|
|
140
|
+
},
|
|
141
|
+
caption: (shape, colours) => {
|
|
142
|
+
const colour = colours[shape.colour]
|
|
143
|
+
const lines = shape.lines
|
|
144
|
+
.map((line, index) => '<tspan x="' + n(shape.x) + '" dy="' + (index === 0 ? 0 : 15) + '">' + escape(line) + '</tspan>')
|
|
145
|
+
.join('')
|
|
146
|
+
return '<text x="' + n(shape.x) + '" y="' + n(shape.y + 11) + '" font-size="10" text-anchor="middle" fill="' + colour + '">' + lines + '</text>'
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The dash pattern a route's source stated, or nothing for a solid line. */
|
|
151
|
+
function dash(shape: { dashed?: boolean }): string {
|
|
152
|
+
return shape.dashed === true ? ' stroke-dasharray="4 3"' : ''
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface At {
|
|
156
|
+
x: number
|
|
157
|
+
y: number
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The marks at an edge's two ends.
|
|
162
|
+
*
|
|
163
|
+
* `source`/`sourceNext` give the direction the line leaves the source, and `targetPrev`/`target` the direction it
|
|
164
|
+
* arrives at the target: the two ends of a polyline run in different directions. An edge with no arrow field keeps
|
|
165
|
+
* the plain arrow it has always been drawn with. A composition or an aggregation moves the mark to the source; a
|
|
166
|
+
* dependency and a realisation are told apart by the line, not by the head.
|
|
167
|
+
*/
|
|
168
|
+
function edgeMarks(source: At, sourceNext: At, targetPrev: At, target: At, arrow: EdgeArrow | undefined, stroke: string, background: string): string {
|
|
169
|
+
if (arrow === 'composition' || arrow === 'aggregation') {
|
|
170
|
+
return diamond(source, sourceNext, stroke, arrow === 'composition' ? stroke : background)
|
|
171
|
+
}
|
|
172
|
+
if (arrow === 'generalization' || arrow === 'realization') {
|
|
173
|
+
return hollowTriangle(targetPrev, target, stroke, background)
|
|
174
|
+
}
|
|
175
|
+
return arrowHead(targetPrev.x, targetPrev.y, target.x, target.y, stroke)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The tip and the two base corners of a head that points from `from` to `to`. */
|
|
179
|
+
function tipAndBase(from: At, to: At): { tip: string; left: string; right: string } | undefined {
|
|
180
|
+
const length = Math.hypot(to.x - from.x, to.y - from.y)
|
|
181
|
+
if (length < 1) return undefined
|
|
182
|
+
const ux = (to.x - from.x) / length
|
|
183
|
+
const uy = (to.y - from.y) / length
|
|
184
|
+
const size = 7
|
|
185
|
+
const half = 3.2
|
|
186
|
+
const baseX = to.x - ux * size
|
|
187
|
+
const baseY = to.y - uy * size
|
|
188
|
+
return {
|
|
189
|
+
tip: n(to.x) + ',' + n(to.y),
|
|
190
|
+
left: n(baseX - uy * half) + ',' + n(baseY + ux * half),
|
|
191
|
+
right: n(baseX + uy * half) + ',' + n(baseY - ux * half),
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** A small filled triangle at the end of a segment, pointing along it. */
|
|
196
|
+
function arrowHead(x1: number, y1: number, x2: number, y2: number, stroke: string): string {
|
|
197
|
+
const points = tipAndBase({ x: x1, y: y1 }, { x: x2, y: y2 })
|
|
198
|
+
if (points === undefined) return ''
|
|
199
|
+
return '<polygon points="' + points.tip + ' ' + points.left + ' ' + points.right + '" fill="' + stroke + '" />'
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The open triangle a generalisation or a realisation ends in: the surface shows through it. */
|
|
203
|
+
function hollowTriangle(from: At, to: At, stroke: string, background: string): string {
|
|
204
|
+
const points = tipAndBase(from, to)
|
|
205
|
+
if (points === undefined) return ''
|
|
206
|
+
return (
|
|
207
|
+
'<polygon points="' + points.tip + ' ' + points.left + ' ' + points.right +
|
|
208
|
+
'" fill="' + background + '" stroke="' + stroke + '" stroke-width="1" />'
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The diamond a composition or an aggregation puts at its source, pointing back along the line. */
|
|
213
|
+
function diamond(at: At, next: At, stroke: string, fill: string): string {
|
|
214
|
+
const length = Math.hypot(next.x - at.x, next.y - at.y)
|
|
215
|
+
if (length < 1) return ''
|
|
216
|
+
const ux = (next.x - at.x) / length
|
|
217
|
+
const uy = (next.y - at.y) / length
|
|
218
|
+
const size = 12
|
|
219
|
+
const half = 4
|
|
220
|
+
const back = { x: at.x + ux * size, y: at.y + uy * size }
|
|
221
|
+
const middle = { x: at.x + (ux * size) / 2, y: at.y + (uy * size) / 2 }
|
|
222
|
+
const left = { x: middle.x - uy * half, y: middle.y + ux * half }
|
|
223
|
+
const right = { x: middle.x + uy * half, y: middle.y - ux * half }
|
|
224
|
+
const points = [at, left, back, right].map((point) => n(point.x) + ',' + n(point.y)).join(' ')
|
|
225
|
+
return '<polygon points="' + points + '" fill="' + fill + '" stroke="' + stroke + '" stroke-width="1" />'
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* An edge label, or nothing when the edge carries no event.
|
|
230
|
+
*
|
|
231
|
+
* The text is centred on the point the painter chose - a point on the edge itself - and drawn over a rectangle in
|
|
232
|
+
* the page colour. The mask is the whole point of drawing it here: it hides the stretch of line behind the text,
|
|
233
|
+
* so the label interrupts its edge instead of floating beside it, which is the only way a reader can tell that
|
|
234
|
+
* the word names that line and not something it happens to sit next to. The label keeps its own colour: the mask
|
|
235
|
+
* is geometry, not emphasis.
|
|
236
|
+
*/
|
|
237
|
+
function edgeLabel(label: string, x: number, y: number, colours: Palette): string {
|
|
238
|
+
// Total on purpose: a malformed edge must lose its label, not the whole drawing.
|
|
239
|
+
if (typeof label !== 'string' || label === '') return ''
|
|
240
|
+
const width = textWidth(label)
|
|
241
|
+
const left = x - width / 2
|
|
242
|
+
const top = y - LABEL_HEIGHT / 2
|
|
243
|
+
// Ten point text sits on a baseline about a third of the mask above its centre, which centres the cap height.
|
|
244
|
+
const baseline = y + 3.5
|
|
245
|
+
return (
|
|
246
|
+
'<rect x="' + n(left) + '" y="' + n(top) + '" width="' + n(width) + '" height="' + n(LABEL_HEIGHT) +
|
|
247
|
+
'" fill="' + colours.background + '" />' +
|
|
248
|
+
'<text x="' + n(x) + '" y="' + n(baseline) + '" font-size="10" text-anchor="middle" fill="' + colours.muted + '">' + escape(label) + '</text>'
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function toSvg(canvas: Canvas, colours: Palette, label?: string): { markup: string; dropped: number } {
|
|
253
|
+
let dropped = 0
|
|
254
|
+
const body: string[] = []
|
|
255
|
+
for (const shape of canvas.shapes) {
|
|
256
|
+
const writer = writers[shape.kind] as ((shape: Shape, colours: Palette) => string) | undefined
|
|
257
|
+
if (writer === undefined) {
|
|
258
|
+
dropped += 1
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
body.push(writer(shape, colours))
|
|
262
|
+
}
|
|
263
|
+
const header =
|
|
264
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + n(canvas.width) + ' ' + n(canvas.height) +
|
|
265
|
+
'" width="' + n(canvas.width) + '" height="' + n(canvas.height) + '" role="img"' +
|
|
266
|
+
(label === undefined ? '' : ' aria-label="' + escape(label) + '"') +
|
|
267
|
+
' font-family="system-ui, -apple-system, Segoe UI, sans-serif">'
|
|
268
|
+
// The surface is part of the drawing, not the container's business: a page that paints its own layers - a
|
|
269
|
+
// wallpaper, a themed panel - would otherwise change the contrast of a picture it knows nothing about.
|
|
270
|
+
const background =
|
|
271
|
+
'<rect x="0" y="0" width="' + n(canvas.width) + '" height="' + n(canvas.height) +
|
|
272
|
+
'" fill="' + colours.background + '" />'
|
|
273
|
+
return { markup: header + background + body.join('') + '</svg>', dropped }
|
|
274
|
+
}
|
|
@@ -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
|
+
}
|