dsh-model-everything 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.en-US.md +63 -19
  2. package/README.md +373 -345
  3. package/cordis.patch.yml +9 -0
  4. package/diagram/package.json +5 -0
  5. package/lib/client.js +1273 -0
  6. package/lib/diagram-diff-tool.js +90 -0
  7. package/lib/diagram-render-tool.js +102 -0
  8. package/lib/diagram-review-tool.js +99 -0
  9. package/lib/diagram.js +43 -0
  10. package/lib/gate.js +14 -12
  11. package/lib/presentation.js +10 -0
  12. package/lib/render/budget.js +99 -0
  13. package/lib/render/collapse.js +163 -0
  14. package/lib/render/diff.js +138 -0
  15. package/lib/render/from-mermaid.js +211 -0
  16. package/lib/render/from-plantuml.js +158 -0
  17. package/lib/render/index.js +42 -0
  18. package/lib/render/layout.js +52 -0
  19. package/lib/render/model.js +17 -0
  20. package/lib/render/paint.js +194 -0
  21. package/lib/render/palette.js +127 -0
  22. package/lib/render/place.js +210 -0
  23. package/lib/render/svg.js +107 -0
  24. package/lib/render/viewmodel.js +126 -0
  25. package/lib/render/write.js +14 -0
  26. package/lib/scan.js +248 -8
  27. package/lib/structure-tool.js +2 -2
  28. package/lib/types/client.d.ts +83 -0
  29. package/lib/types/diagram-diff-tool.d.ts +8 -0
  30. package/lib/types/diagram-render-tool.d.ts +8 -0
  31. package/lib/types/diagram-review-tool.d.ts +8 -0
  32. package/lib/types/diagram.d.ts +34 -0
  33. package/lib/types/presentation.d.ts +7 -0
  34. package/lib/types/render/budget.d.ts +55 -0
  35. package/lib/types/render/collapse.d.ts +23 -0
  36. package/lib/types/render/diff.d.ts +44 -0
  37. package/lib/types/render/from-mermaid.d.ts +27 -0
  38. package/lib/types/render/from-plantuml.d.ts +27 -0
  39. package/lib/types/render/index.d.ts +38 -0
  40. package/lib/types/render/layout.d.ts +22 -0
  41. package/lib/types/render/model.d.ts +56 -0
  42. package/lib/types/render/paint.d.ts +94 -0
  43. package/lib/types/render/palette.d.ts +41 -0
  44. package/lib/types/render/place.d.ts +39 -0
  45. package/lib/types/render/svg.d.ts +19 -0
  46. package/lib/types/render/viewmodel.d.ts +20 -0
  47. package/lib/types/render/write.d.ts +14 -0
  48. package/lib/types/scan.d.ts +10 -2
  49. package/lib/types/structure.d.ts +1 -0
  50. package/package.json +35 -19
  51. package/skills/model-everything/SKILL.md +454 -454
  52. package/skills/model-everything/references/architecture-domains.md +137 -0
  53. package/skills/model-everything/references/architecture-views.md +119 -0
  54. package/skills/model-everything-diagram/SKILL.md +82 -0
  55. package/src/client.ts +307 -0
  56. package/src/diagram-diff-tool.ts +94 -0
  57. package/src/diagram-render-tool.ts +108 -0
  58. package/src/diagram-review-tool.ts +109 -0
  59. package/src/diagram.ts +61 -0
  60. package/src/gate.ts +14 -12
  61. package/src/presentation.ts +12 -0
  62. package/src/render/budget.ts +135 -0
  63. package/src/render/collapse.ts +155 -0
  64. package/src/render/diff.ts +162 -0
  65. package/src/render/from-mermaid.ts +236 -0
  66. package/src/render/from-plantuml.ts +178 -0
  67. package/src/render/index.ts +73 -0
  68. package/src/render/layout.ts +62 -0
  69. package/src/render/model.ts +67 -0
  70. package/src/render/paint.ts +290 -0
  71. package/src/render/palette.ts +164 -0
  72. package/src/render/place.ts +234 -0
  73. package/src/render/svg.ts +127 -0
  74. package/src/render/viewmodel.ts +136 -0
  75. package/src/render/write.ts +17 -0
  76. package/src/scan.ts +807 -564
  77. package/src/structure-tool.ts +2 -2
  78. package/src/structure.ts +1 -1
@@ -0,0 +1,178 @@
1
+ /**
2
+ * PlantUML text to a diagram, for the subset this renderer can actually draw.
3
+ *
4
+ * PlantUML is a large language, and a renderer that accepted all of it would be a re-implementation of
5
+ * PlantUML. This one accepts what an architecture document actually contains - boxes, groups and arrows -
6
+ * and refuses the rest **by line**, because a component diagram that quietly loses a lifeline reads as
7
+ * complete and is not.
8
+ *
9
+ * Supported:
10
+ *
11
+ * - `@startuml` / `@enduml` (optional) and `title ...`;
12
+ * - `component X`, `component "text" as X`, `[text] as X`, `[text]`;
13
+ * - `package "text" { ... }`, `node`, `folder`, `cloud`, `database` - a group, closed by `}`;
14
+ * - `X --> Y`, `X ---> Y`, `X ..> Y`, `X --> Y : label`, arrow either direction;
15
+ * - `state X`, `state "text" as X`, and `[*] --> X` for a start marker;
16
+ * - `'` comments and `skinparam`/`!theme` directives.
17
+ *
18
+ * Refused, by name: `sequence`, `actor`, `participant`, `usecase`, `class`, `object`, `note`, `legend`,
19
+ * `alt`/`opt`/`loop`/`par` and any other fragment keyword.
20
+ */
21
+
22
+ import { emptyDiagram, type Diagram, type DiagramEdge, type DiagramGroup, type DiagramNode } from './model.js'
23
+
24
+ export interface Parsed {
25
+ diagram: Diagram
26
+ /** Lines the parser refused to interpret, with the reason. Empty when the whole source was understood. */
27
+ refused: string[]
28
+ }
29
+
30
+ const ARROW = /^(.+?)\s*(<?)(-{2,}|\.{2,})(>?)\s*(.+?)(?:\s*:\s*(.+))?$/
31
+ const DECLARED = /^(component|package|node|folder|cloud|database|state|artifact|frame|rectangle)\s+(.+)$/
32
+ const QUOTED_ALIAS = /^["'](.+?)["']\s+as\s+([A-Za-z_][\w-]*)$/
33
+ const BRACKET_ALIAS = /^\[(.+?)\]\s+as\s+([A-Za-z_][\w-]*)$/
34
+ const BRACKET = /^\[(.+?)\]$/
35
+ const UNSUPPORTED = /^(actor|participant|usecase|class|object|note|legend|alt|opt|loop|par|group\s|ref\s|activate|deactivate|destroy|create|newpage)\b/
36
+ const IGNORED = /^(skinparam|!theme|!define|!include|hide|show|scale|left to right direction|top to bottom direction|together)\b/
37
+
38
+ interface Builder {
39
+ nodes: Map<string, DiagramNode>
40
+ edges: DiagramEdge[]
41
+ groups: DiagramGroup[]
42
+ open: DiagramGroup[]
43
+ }
44
+
45
+ function ensure(builder: Builder, id: string, label?: string): void {
46
+ const existing = builder.nodes.get(id)
47
+ if (existing === undefined) {
48
+ builder.nodes.set(id, { id, role: 'normal', ...(label === undefined ? {} : { label }) })
49
+ // Membership is decided where the node is first written: the innermost open boundary.
50
+ const innermost = builder.open[builder.open.length - 1]
51
+ if (innermost !== undefined && !innermost.members.includes(id)) innermost.members.push(id)
52
+ return
53
+ }
54
+ if (label !== undefined && (existing.label === undefined || existing.label === '')) existing.label = label
55
+ }
56
+
57
+ /** A component reference: `[text]`, `X`, or a quoted name. Returns the id and the label it carried. */
58
+ function reference(token: string): { id: string; label?: string } {
59
+ const text = token.trim()
60
+ const bracket = BRACKET.exec(text)
61
+ if (bracket !== null) {
62
+ const label = bracket[1].trim()
63
+ return { id: label, label }
64
+ }
65
+ const quoted = /^["'](.+?)["']$/.exec(text)
66
+ if (quoted !== null) return { id: quoted[1], label: quoted[1] }
67
+ return { id: text }
68
+ }
69
+
70
+ export function fromPlantUml(source: string, title = 'plantuml'): Parsed {
71
+ const lines = source.split(/\r?\n/)
72
+ const refused: string[] = []
73
+ const builder: Builder = { nodes: new Map(), edges: [], groups: [], open: [] }
74
+ let startId: string | undefined
75
+ let seenAny = false
76
+ let groupCount = 0
77
+
78
+ lines.forEach((raw, index) => {
79
+ const line = raw.replace(/'.*$/, '').trim()
80
+ if (line === '') return
81
+ const where = 'line ' + String(index + 1) + ': '
82
+
83
+ if (/^@(start|end)uml\b/.test(line)) return
84
+ seenAny = true
85
+ if (/^title\b/.test(line)) return
86
+ if (IGNORED.test(line)) return
87
+ if (UNSUPPORTED.test(line)) {
88
+ refused.push(where + '`' + (line.split(/\s/)[0] ?? '') + '` is not a diagram this renderer draws')
89
+ return
90
+ }
91
+
92
+ if (line === '}') {
93
+ const closing = builder.open.pop()
94
+ if (closing !== undefined) {
95
+ const container = builder.open[builder.open.length - 1]
96
+ if (container !== undefined) closing.parent = container.id
97
+ builder.groups.push(closing)
98
+ }
99
+ return
100
+ }
101
+
102
+ if (line.endsWith('{')) {
103
+ const declaration = line.slice(0, -1).trim()
104
+ const declared = DECLARED.exec(declaration)
105
+ const label = declared === null ? declaration.replace(/^["']|["']$/g, '') : declared[2].replace(/^["']|["']$/g, '')
106
+ groupCount += 1
107
+ builder.open.push({ id: 'group-' + String(groupCount), label, members: [] })
108
+ return
109
+ }
110
+
111
+ const declared = DECLARED.exec(line)
112
+ if (declared !== null) {
113
+ const rest = declared[2].trim()
114
+ const quoted = QUOTED_ALIAS.exec(rest)
115
+ if (quoted !== null) {
116
+ ensure(builder, quoted[2], quoted[1])
117
+ return
118
+ }
119
+ const bracketed = BRACKET_ALIAS.exec(rest)
120
+ if (bracketed !== null) {
121
+ ensure(builder, bracketed[2], bracketed[1])
122
+ return
123
+ }
124
+ if (/^["'](.+?)["']$/.test(rest)) return // a group heading with no alias: nothing to draw
125
+ const reference_ = reference(rest)
126
+ ensure(builder, reference_.id, reference_.label)
127
+ return
128
+ }
129
+
130
+ if (line === '[*] --> [*]') return
131
+
132
+ const startMarker = /^\[\*\]\s*-->\s*(.+)$/.exec(line)
133
+ if (startMarker !== null) {
134
+ const target = reference(startMarker[1])
135
+ ensure(builder, target.id, target.label)
136
+ if (startId === undefined) startId = target.id
137
+ return
138
+ }
139
+
140
+ const arrow = ARROW.exec(line)
141
+ if (arrow !== null) {
142
+ const left = arrow[2] === '<' ? arrow[5] : arrow[1]
143
+ const right = arrow[2] === '<' ? arrow[1] : arrow[5]
144
+ const from = reference(left)
145
+ const to = reference(right)
146
+ ensure(builder, from.id, from.label)
147
+ ensure(builder, to.id, to.label)
148
+ builder.edges.push({ from: from.id, to: to.id, label: (arrow[6] ?? '').trim(), ...(arrow[3].startsWith('.') ? { dashed: true } : {}) })
149
+ return
150
+ }
151
+
152
+ const single = BRACKET.exec(line)
153
+ if (single !== null) {
154
+ ensure(builder, single[1].trim(), single[1].trim())
155
+ return
156
+ }
157
+
158
+ refused.push(where + '`' + line + '` is not something this renderer draws')
159
+ })
160
+
161
+ if (!seenAny || builder.nodes.size === 0) {
162
+ return { diagram: emptyDiagram(title, 'no nodes found'), refused }
163
+ }
164
+
165
+ const diagram: Diagram = {
166
+ kind: startId === undefined ? 'graph' : 'machine',
167
+ title,
168
+ ...(startId === undefined ? {} : { init: startId }),
169
+ nodes: [...builder.nodes.values()],
170
+ edges: builder.edges,
171
+ ...(builder.groups.length === 0 ? {} : { groups: builder.groups }),
172
+ caption:
173
+ 'plantuml, ' + String(builder.nodes.size) + ' box' + (builder.nodes.size === 1 ? '' : 'es') + ', ' +
174
+ String(builder.edges.length) + ' arrow' + (builder.edges.length === 1 ? '' : 's') +
175
+ (refused.length === 0 ? '' : ', ' + String(refused.length) + ' line(s) refused'),
176
+ }
177
+ return { diagram, refused }
178
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The renderer's front door: whatever notation arrived, one drawing comes out.
3
+ *
4
+ * Three containers read this - the tool card, the full-screen view and the sidebar whiteboard - and each of
5
+ * them wants the same two things: shapes to draw and markup to embed. Neither knows which notation produced
6
+ * them, and neither needs to.
7
+ *
8
+ * Pure: no filesystem, because the same module is bundled into the page for the whiteboard.
9
+ */
10
+
11
+ import { fromMermaid } from './from-mermaid.js'
12
+ import { collapse } from './collapse.js'
13
+ import { fromPlantUml } from './from-plantuml.js'
14
+ import type { Diagram } from './model.js'
15
+ import { paintWith, type Canvas } from './paint.js'
16
+ import type { Palette, PaletteOptions } from './palette.js'
17
+ import { toSvg } from './svg.js'
18
+
19
+ export type Notation = 'mermaid' | 'plantuml'
20
+
21
+ export interface RenderInput {
22
+ /** The notation, or `auto` to read it off the first non-empty line. */
23
+ notation?: Notation | 'auto'
24
+ source: string
25
+ title?: string
26
+ /** Groups folded to a single box. Ids nobody declared are ignored. */
27
+ folded?: Iterable<string>
28
+ }
29
+
30
+ export interface Rendered {
31
+ diagram: Diagram
32
+ canvas: Canvas
33
+ /** The colours the drawing was made with, so a container can paint the same surface behind it. */
34
+ colours: Palette
35
+ /** Ready to embed: an `<svg>` with a viewBox and no fixed pixel size of its own. */
36
+ markup: string
37
+ /** Which notation the source turned out to be. */
38
+ notation: Notation
39
+ /** Lines the parser refused, with their reason. Empty when the whole source was understood. */
40
+ refused: string[]
41
+ /** Shapes the SVG writer had no case for. Must be zero; reported rather than hidden. */
42
+ dropped: number
43
+ }
44
+
45
+ /** Read the notation off the source: PlantUML starts with `@startuml` or `@startmindmap`-style markers. */
46
+ export function detectNotation(source: string): Notation {
47
+ const head = source
48
+ .split(/\r?\n/)
49
+ .map((line) => line.trim())
50
+ .find((line) => line !== '' && !line.startsWith("'") && !line.startsWith('//'))
51
+ if (head === undefined) return 'mermaid'
52
+ return /^@start/.test(head) ? 'plantuml' : 'mermaid'
53
+ }
54
+
55
+ export function render(input: RenderInput, overrides: Partial<Palette> = {}, options: PaletteOptions = {}): Rendered {
56
+ const notation = input.notation === undefined || input.notation === 'auto' ? detectNotation(input.source) : input.notation
57
+ const parsed =
58
+ notation === 'plantuml'
59
+ ? fromPlantUml(input.source, input.title ?? 'plantuml')
60
+ : fromMermaid(input.source, input.title ?? 'mermaid')
61
+ const diagram = input.folded === undefined ? parsed.diagram : collapse(parsed.diagram, input.folded)
62
+ const { canvas, colours } = paintWith(diagram, overrides, options)
63
+ const written = toSvg(canvas, colours, diagram.title + ': ' + diagram.caption)
64
+ return {
65
+ diagram,
66
+ canvas,
67
+ colours,
68
+ markup: written.markup,
69
+ notation,
70
+ refused: parsed.refused,
71
+ dropped: written.dropped,
72
+ }
73
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Ranks for a machine, and the order of states within each rank.
3
+ *
4
+ * Breadth-first from the initial state decides the row: the layout a reader expects, and the one a layered
5
+ * drawing asks for - edges mostly flow down, and a state's row is how far it is from the start. A state
6
+ * nothing reaches goes in a block of its own rather than being dropped.
7
+ *
8
+ * Within a row, states keep the order breadth-first found them in: a row is filled by walking the row above
9
+ * left to right, so a state already sits under the parent that discovered it. A barycentre pass was written
10
+ * and removed - with breadth-first insertion it could never move a state, so it was complexity without an
11
+ * observable effect.
12
+ *
13
+ * Pure, and asserted on the rows themselves.
14
+ */
15
+
16
+ import type { DiagramEdge, DiagramNode } from './model.js'
17
+
18
+ export interface Ranked {
19
+ /** The state ids of each row, top to bottom, already ordered left to right. */
20
+ rows: string[][]
21
+ /** States no edge reaches from the initial one. */
22
+ unreachable: string[]
23
+ }
24
+
25
+ export function rankStates(nodes: DiagramNode[], edges: DiagramEdge[], init: string | undefined): Ranked {
26
+ const ids = nodes.map((node) => node.id)
27
+ const known = new Set(ids)
28
+ const start = init !== undefined && known.has(init) ? init : ids[0]
29
+
30
+ const outgoing = new Map<string, string[]>()
31
+ const incoming = new Map<string, string[]>()
32
+ for (const edge of edges) {
33
+ if (!known.has(edge.from) || !known.has(edge.to)) continue
34
+ outgoing.set(edge.from, [...(outgoing.get(edge.from) ?? []), edge.to])
35
+ incoming.set(edge.to, [...(incoming.get(edge.to) ?? []), edge.from])
36
+ }
37
+
38
+ const depth = new Map<string, number>()
39
+ if (start !== undefined) {
40
+ depth.set(start, 0)
41
+ const queue: string[] = [start]
42
+ while (queue.length > 0) {
43
+ const id = queue.shift() as string
44
+ const level = depth.get(id) as number
45
+ for (const target of outgoing.get(id) ?? []) {
46
+ if (depth.has(target)) continue
47
+ depth.set(target, level + 1)
48
+ queue.push(target)
49
+ }
50
+ }
51
+ }
52
+
53
+ const rows: string[][] = []
54
+ for (const id of ids) {
55
+ const level = depth.get(id)
56
+ if (level === undefined) continue
57
+ while (rows.length <= level) rows.push([])
58
+ rows[level].push(id)
59
+ }
60
+
61
+ return { rows, unreachable: ids.filter((id) => !depth.has(id)) }
62
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The diagram a renderer draws, in the notation it came from being nobody's business.
3
+ *
4
+ * Every source - a verification report, a Mermaid file, a PlantUML component diagram, a dependency scan -
5
+ * is reduced to this one shape before anything is laid out. A renderer that understood notations would have
6
+ * to be changed for each of them; a renderer that understands this does not.
7
+ *
8
+ * Pure data, no functions: a model can be serialised, compared and asserted.
9
+ */
10
+
11
+ /** What a node is, as far as colour is concerned. */
12
+ export type NodeRole = 'normal' | 'error' | 'warning' | 'muted'
13
+
14
+ export interface DiagramNode {
15
+ id: string
16
+ /** The text drawn inside the box. Defaults to the id when a notation labelled it. */
17
+ label?: string
18
+ role: NodeRole
19
+ }
20
+
21
+ export interface DiagramEdge {
22
+ from: string
23
+ to: string
24
+ label: string
25
+ /** A dashed edge, for notations that distinguish one. */
26
+ dashed?: boolean
27
+ }
28
+
29
+ export interface DiagramGroup {
30
+ id: string
31
+ label: string
32
+ /**
33
+ * The nodes declared directly inside it: a group is a box around boxes, and its own members are the ones a
34
+ * source named there. A node inside a nested group is that group's member, not this one's.
35
+ */
36
+ members: string[]
37
+ /**
38
+ * The group that contains this one, when the source said so.
39
+ *
40
+ * Only a parser can know this - nesting is a property of where the text put the boundary - so the layout does
41
+ * not guess it. Left absent, the painter falls back to inferring containment from member sets, which is the
42
+ * best a hand-written model can offer.
43
+ */
44
+ parent?: string
45
+ }
46
+
47
+ export interface Diagram {
48
+ kind: 'machine' | 'graph' | 'none'
49
+ /** The state the machine starts in, when the source declared one. */
50
+ init?: string
51
+ title: string
52
+ nodes: DiagramNode[]
53
+ edges: DiagramEdge[]
54
+ groups?: DiagramGroup[]
55
+ /** One line under the drawing: what was decided, in words the drawing cannot carry. */
56
+ caption: string
57
+ }
58
+
59
+ /** An empty drawing, for a source that produced no nodes. */
60
+ export function emptyDiagram(title: string, caption: string): Diagram {
61
+ return { kind: 'none', title, nodes: [], edges: [], caption }
62
+ }
63
+
64
+ /** The label to draw for a node: its own label when the notation gave one, else its id. */
65
+ export function labelOf(node: DiagramNode): string {
66
+ return node.label !== undefined && node.label !== '' ? node.label : node.id
67
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Geometry: what a diagram turns into, in coordinates.
3
+ *
4
+ * A drawing has to survive three containers - inline in a conversation, full-screen, and a persistent
5
+ * sidebar tab - so nothing here knows about pixel size. It emits a content-sized canvas and lets the
6
+ * container scale it: the same numbers serve a 360px card and a 1200px whiteboard.
7
+ *
8
+ * Two placements, chosen by the source:
9
+ *
10
+ * - **layered** (no groups): breadth-first rows, forward edges dropping straight down, back edges on a side
11
+ * channel clear of every box, self loops as small loops. The shape a state machine wants.
12
+ * - **tree** (groups present): a group is a block, its members stack inside it, a nested group is a block
13
+ * inside its parent, and the top level stacks. The shape an architecture diagram wants, because a package
14
+ * whose members float somewhere else on the page is not showing containment at all.
15
+ *
16
+ * Edges are placed from where the boxes ended up, so neither placement needs to tell the edge code which one
17
+ * ran: "below" is a straight line, "beside" is horizontal, "above" routes around.
18
+ *
19
+ * Pure: a diagram in, shapes out, every decision assertable.
20
+ */
21
+
22
+ import { MARGIN, place, type Box } from './place.js'
23
+ import { palette, roleForVerdict, type ColourRole, type Palette, type PaletteOptions } from './palette.js'
24
+ import { labelOf, type Diagram, type DiagramGroup, type DiagramNode } from './model.js'
25
+
26
+ export interface Point {
27
+ x: number
28
+ y: number
29
+ }
30
+
31
+ export interface BoxShape {
32
+ kind: 'box'
33
+ x: number
34
+ y: number
35
+ width: number
36
+ height: number
37
+ label: string
38
+ colour: ColourRole
39
+ initial: boolean
40
+ }
41
+
42
+ export interface LinkShape {
43
+ kind: 'link'
44
+ x1: number
45
+ y1: number
46
+ x2: number
47
+ y2: number
48
+ label: string
49
+ labelX: number
50
+ labelY: number
51
+ colour: ColourRole
52
+ }
53
+
54
+ export interface PathShape {
55
+ kind: 'path'
56
+ points: Point[]
57
+ label: string
58
+ labelX: number
59
+ labelY: number
60
+ colour: ColourRole
61
+ }
62
+
63
+ /** A boundary around several states: a package, a layer, a subgraph. */
64
+ export interface GroupShape {
65
+ kind: 'group'
66
+ /** The group's own id, so a container can fold it without guessing from the label. */
67
+ id: string
68
+ x: number
69
+ y: number
70
+ width: number
71
+ height: number
72
+ label: string
73
+ colour: ColourRole
74
+ }
75
+
76
+ export interface CaptionShape {
77
+ kind: 'caption'
78
+ x: number
79
+ y: number
80
+ lines: string[]
81
+ colour: ColourRole
82
+ }
83
+
84
+ export interface TitleShape {
85
+ kind: 'title'
86
+ x: number
87
+ y: number
88
+ text: string
89
+ colour: ColourRole
90
+ }
91
+
92
+ export type Shape = GroupShape | BoxShape | LinkShape | PathShape | CaptionShape | TitleShape
93
+
94
+ export interface Canvas {
95
+ width: number
96
+ height: number
97
+ shapes: Shape[]
98
+ }
99
+
100
+ /** Geometry constants in one place: a container with different proportions changes these, not the rules. */
101
+ const MIN_NODE_WIDTH = 88
102
+ const MAX_NODE_WIDTH = 220
103
+ const CHAR_WIDTH = 7.2
104
+
105
+ const nodeWidth = (label: string): number => Math.min(MAX_NODE_WIDTH, Math.max(MIN_NODE_WIDTH, label.length * CHAR_WIDTH + 26))
106
+
107
+ const TITLE_HEIGHT = 26
108
+ const NODE_HEIGHT = 34
109
+ const CHANNEL_GAP = 16
110
+ const CAPTION_CHARS = 78
111
+ const CAPTION_LINE = 15
112
+ const CAPTION_GAP = 16
113
+ const CAPTION_CHAR_WIDTH = 6.2
114
+
115
+ /** Which colour a box earns from its role. One mapping, used by every container. */
116
+ export function roleOfNode(node: DiagramNode): ColourRole {
117
+ if (node.role === 'error') return 'fail'
118
+ if (node.role === 'warning') return 'findings'
119
+ if (node.role === 'muted') return 'muted'
120
+ return 'edge'
121
+ }
122
+
123
+ /** Wrap on spaces; break a token longer than the line rather than let it overflow the canvas. */
124
+ function wrap(text: string, limit: number): string[] {
125
+ const lines: string[] = []
126
+ let line = ''
127
+ for (const word of text.split(/\s+/).filter((part) => part !== '')) {
128
+ const candidate = line === '' ? word : line + ' ' + word
129
+ if (candidate.length <= limit) {
130
+ line = candidate
131
+ continue
132
+ }
133
+ if (line !== '') lines.push(line)
134
+ line = ''
135
+ let rest = word
136
+ while (rest.length > limit) {
137
+ lines.push(rest.slice(0, limit))
138
+ rest = rest.slice(limit)
139
+ }
140
+ line = rest
141
+ }
142
+ if (line !== '') lines.push(line)
143
+ return lines.length === 0 ? [''] : lines
144
+ }
145
+
146
+ /** The colour a caption earns, read from the verdict it quotes. */
147
+ function captionRole(caption: string): ColourRole {
148
+ return roleForVerdict(caption.split(' — ')[0] as string)
149
+ }
150
+
151
+ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, options: PaletteOptions = {}): { canvas: Canvas; colours: Palette } {
152
+ const colours = palette(overrides, options)
153
+ const shapes: Shape[] = []
154
+
155
+ const groups = diagram.groups ?? []
156
+ const topOffset = MARGIN + TITLE_HEIGHT
157
+ const placement = place(diagram, topOffset)
158
+ const { boxes, groupRects } = placement
159
+
160
+ // Boundaries first, so everything else is painted on top of them.
161
+ for (const group of groups) {
162
+ const rect = groupRects.get(group.id)
163
+ if (rect === undefined) continue
164
+ shapes.push({ kind: 'group', id: group.id, x: rect.x, y: rect.y, width: rect.width, height: rect.height, label: group.label, colour: 'muted' })
165
+ }
166
+
167
+ const drawn = new Set<string>()
168
+ for (const node of diagram.nodes) {
169
+ // A state id may repeat in a malformed model: the first placement wins and the repeat is skipped.
170
+ if (drawn.has(node.id)) continue
171
+ const box = boxes.get(node.id) as Box
172
+ drawn.add(node.id)
173
+ shapes.push({
174
+ kind: 'box',
175
+ x: box.x,
176
+ y: box.y,
177
+ width: box.width,
178
+ height: box.height,
179
+ label: labelOf(node),
180
+ colour: roleOfNode(node),
181
+ initial: diagram.init === node.id,
182
+ })
183
+ }
184
+
185
+ const roleById = new Map(diagram.nodes.map((node) => [node.id, roleOfNode(node)] as const))
186
+ let channel = Math.max(placement.width, ...Array.from(boxes.values(), (box) => box.x + box.width)) + CHANNEL_GAP
187
+ let channelUses = 0
188
+
189
+ for (const edge of diagram.edges) {
190
+ const source = boxes.get(edge.from)
191
+ const target = boxes.get(edge.to)
192
+ if (source === undefined || target === undefined) continue
193
+ const colour = roleById.get(edge.from) as ColourRole
194
+ // Placement decided where things are; "below", "beside" and "above" are read from the result.
195
+ const sameRow = Math.abs(target.y - source.y) < NODE_HEIGHT / 2
196
+
197
+ if (edge.from === edge.to) {
198
+ const x = source.x + source.width
199
+ const y = source.y + source.height / 2
200
+ shapes.push({
201
+ kind: 'path',
202
+ points: [
203
+ { x, y: y - 9 },
204
+ { x: x + 14, y: y - 9 },
205
+ { x: x + 14, y: y + 9 },
206
+ { x, y: y + 9 },
207
+ ],
208
+ label: edge.label,
209
+ labelX: x + 20,
210
+ labelY: y,
211
+ colour,
212
+ })
213
+ continue
214
+ }
215
+
216
+ if (target.y > source.y && !sameRow) {
217
+ const x1 = source.x + source.width / 2
218
+ const y1 = source.y + source.height
219
+ const x2 = target.x + target.width / 2
220
+ const y2 = target.y
221
+ shapes.push({ kind: 'link', x1, y1, x2, y2, label: edge.label, labelX: (x1 + x2) / 2 + 7, labelY: (y1 + y2) / 2, colour })
222
+ continue
223
+ }
224
+
225
+ if (sameRow) {
226
+ const right = source.x + source.width
227
+ const left = target.x
228
+ const y = source.y + source.height / 2
229
+ const forward = right <= left
230
+ shapes.push({
231
+ kind: 'link',
232
+ x1: forward ? right : source.x,
233
+ y1: y,
234
+ x2: forward ? left : target.x + target.width,
235
+ y2: y,
236
+ label: edge.label,
237
+ labelX: (right + left) / 2,
238
+ labelY: y - 7,
239
+ colour,
240
+ })
241
+ continue
242
+ }
243
+
244
+ // Backward: leave right, run down a lane clear of every box, come back in from the right.
245
+ const lane = channel + (channelUses % 3) * 8
246
+ channelUses += 1
247
+ channel = Math.max(channel, lane)
248
+ const startY = source.y + source.height / 2
249
+ const endY = target.y + target.height / 2
250
+ shapes.push({
251
+ kind: 'path',
252
+ points: [
253
+ { x: source.x + source.width, y: startY },
254
+ { x: lane, y: startY },
255
+ { x: lane, y: endY },
256
+ { x: target.x + target.width, y: endY },
257
+ ],
258
+ label: edge.label,
259
+ labelX: lane + 6,
260
+ labelY: (startY + endY) / 2,
261
+ colour,
262
+ })
263
+ }
264
+
265
+ shapes.push({ kind: 'title', x: MARGIN, y: MARGIN + 12, text: diagram.title, colour: 'text' })
266
+
267
+ const contentRight = Math.max(
268
+ MARGIN,
269
+ ...Array.from(boxes.values(), (box) => box.x + box.width),
270
+ ...Array.from(groupRects.values(), (rect) => rect.x + rect.width),
271
+ )
272
+ const captionLines = wrap(diagram.caption, CAPTION_CHARS)
273
+ const captionWidth = Math.max(...captionLines.map((line) => line.length * CAPTION_CHAR_WIDTH)) + MARGIN * 2
274
+ const width = Math.max(contentRight + MARGIN + (channelUses > 0 ? CHANNEL_GAP * 3 : 0), captionWidth, 240)
275
+
276
+ const contentBottom = Math.max(
277
+ topOffset,
278
+ ...Array.from(boxes.values(), (box) => box.y + box.height),
279
+ ...Array.from(groupRects.values(), (rect) => rect.y + rect.height),
280
+ )
281
+ const captionTop = contentBottom + CAPTION_GAP
282
+ shapes.push({ kind: 'caption', x: MARGIN, y: captionTop, lines: captionLines, colour: captionRole(diagram.caption) })
283
+ const height = captionTop + captionLines.length * CAPTION_LINE + MARGIN / 2
284
+
285
+ return { canvas: { width: Math.round(width), height: Math.round(height), shapes }, colours }
286
+ }
287
+
288
+ export function paint(diagram: Diagram, overrides: Partial<Palette> = {}): Canvas {
289
+ return paintWith(diagram, overrides).canvas
290
+ }