dsh-model-everything 0.4.0 → 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.
- package/README.en-US.md +63 -19
- package/README.md +373 -345
- package/cordis.patch.yml +9 -0
- package/diagram/package.json +5 -0
- package/lib/client.js +1273 -0
- package/lib/diagram-diff-tool.js +90 -0
- package/lib/diagram-render-tool.js +102 -0
- package/lib/diagram-review-tool.js +99 -0
- package/lib/diagram.js +43 -0
- package/lib/gate.js +14 -12
- package/lib/presentation.js +10 -0
- package/lib/render/budget.js +99 -0
- package/lib/render/collapse.js +163 -0
- package/lib/render/diff.js +138 -0
- package/lib/render/from-mermaid.js +211 -0
- package/lib/render/from-plantuml.js +158 -0
- package/lib/render/index.js +42 -0
- package/lib/render/layout.js +52 -0
- package/lib/render/model.js +17 -0
- package/lib/render/paint.js +194 -0
- package/lib/render/palette.js +127 -0
- package/lib/render/place.js +210 -0
- package/lib/render/svg.js +107 -0
- package/lib/render/viewmodel.js +126 -0
- package/lib/render/write.js +14 -0
- package/lib/scan.js +248 -8
- package/lib/structure-tool.js +2 -2
- package/lib/types/client.d.ts +83 -0
- package/lib/types/diagram-diff-tool.d.ts +8 -0
- package/lib/types/diagram-render-tool.d.ts +8 -0
- package/lib/types/diagram-review-tool.d.ts +8 -0
- package/lib/types/diagram.d.ts +34 -0
- package/lib/types/presentation.d.ts +7 -0
- package/lib/types/render/budget.d.ts +55 -0
- package/lib/types/render/collapse.d.ts +23 -0
- package/lib/types/render/diff.d.ts +44 -0
- package/lib/types/render/from-mermaid.d.ts +27 -0
- package/lib/types/render/from-plantuml.d.ts +27 -0
- package/lib/types/render/index.d.ts +38 -0
- package/lib/types/render/layout.d.ts +22 -0
- package/lib/types/render/model.d.ts +56 -0
- package/lib/types/render/paint.d.ts +94 -0
- package/lib/types/render/palette.d.ts +41 -0
- package/lib/types/render/place.d.ts +39 -0
- package/lib/types/render/svg.d.ts +19 -0
- package/lib/types/render/viewmodel.d.ts +20 -0
- package/lib/types/render/write.d.ts +14 -0
- package/lib/types/scan.d.ts +10 -2
- package/lib/types/structure.d.ts +1 -0
- package/package.json +148 -131
- package/skills/model-everything/SKILL.md +454 -454
- package/skills/model-everything/references/architecture-domains.md +137 -0
- package/skills/model-everything/references/architecture-views.md +119 -0
- package/skills/model-everything-diagram/SKILL.md +82 -0
- package/src/client.ts +307 -0
- package/src/diagram-diff-tool.ts +94 -0
- package/src/diagram-render-tool.ts +108 -0
- package/src/diagram-review-tool.ts +109 -0
- package/src/diagram.ts +61 -0
- package/src/gate.ts +14 -12
- package/src/presentation.ts +12 -0
- package/src/render/budget.ts +135 -0
- package/src/render/collapse.ts +155 -0
- package/src/render/diff.ts +162 -0
- package/src/render/from-mermaid.ts +236 -0
- package/src/render/from-plantuml.ts +178 -0
- package/src/render/index.ts +73 -0
- package/src/render/layout.ts +62 -0
- package/src/render/model.ts +67 -0
- package/src/render/paint.ts +290 -0
- package/src/render/palette.ts +164 -0
- package/src/render/place.ts +234 -0
- package/src/render/svg.ts +127 -0
- package/src/render/viewmodel.ts +136 -0
- package/src/render/write.ts +17 -0
- package/src/scan.ts +807 -564
- package/src/structure-tool.ts +2 -2
- 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
|
+
}
|