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,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verdict to colour, in the two surfaces the page can be in, and in the scheme a reader prefers.
|
|
3
|
+
*
|
|
4
|
+
* A drawing is read before it is parsed, so the first thing it says is the answer. The roles are named rather
|
|
5
|
+
* than coloured: a scheme maps a role to a value, and every scheme is chosen so the drawing survives both a
|
|
6
|
+
* light and a dark page - the reader never has to fix a palette to read a result.
|
|
7
|
+
*
|
|
8
|
+
* The mode is read at render time from the page's own background (see `client.ts`). The host publishes its
|
|
9
|
+
* palette as CSS variables whose names are private to it, and a drawing that hard-coded them would break on
|
|
10
|
+
* the next theme; the luminance of the surface is the one fact that cannot drift.
|
|
11
|
+
*
|
|
12
|
+
* Pure, and tested: a wrong role is a wrong verdict shown as a shape.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type ColourRole =
|
|
16
|
+
| 'pass'
|
|
17
|
+
| 'findings'
|
|
18
|
+
| 'fail'
|
|
19
|
+
| 'undecided'
|
|
20
|
+
| 'muted'
|
|
21
|
+
| 'text'
|
|
22
|
+
| 'edge'
|
|
23
|
+
| 'surface'
|
|
24
|
+
| 'background'
|
|
25
|
+
|
|
26
|
+
export type Palette = Record<ColourRole, string>
|
|
27
|
+
|
|
28
|
+
/** Which surface the drawing will sit on. */
|
|
29
|
+
export type PaletteMode = 'light' | 'dark'
|
|
30
|
+
|
|
31
|
+
/** A named set of colours. A scheme with both variants follows the page; one with only light does not. */
|
|
32
|
+
export type PaletteScheme = 'default' | 'github' | 'high-contrast' | 'print'
|
|
33
|
+
|
|
34
|
+
/** Ours: a quiet neutral drawing, with a green/amber/red answer. */
|
|
35
|
+
const NEUTRAL_LIGHT: Palette = {
|
|
36
|
+
pass: '#2f8f5b',
|
|
37
|
+
findings: '#b7791f',
|
|
38
|
+
fail: '#c0392b',
|
|
39
|
+
undecided: '#6b7280',
|
|
40
|
+
muted: '#9aa3ad',
|
|
41
|
+
text: '#1f2933',
|
|
42
|
+
edge: '#6b7280',
|
|
43
|
+
surface: 'transparent',
|
|
44
|
+
background: '#ffffff',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const NEUTRAL_DARK: Palette = {
|
|
48
|
+
pass: '#4ac486',
|
|
49
|
+
findings: '#e0b050',
|
|
50
|
+
fail: '#f2705f',
|
|
51
|
+
undecided: '#9aa3ad',
|
|
52
|
+
muted: '#7d8794',
|
|
53
|
+
text: '#e6e8eb',
|
|
54
|
+
edge: '#8b949e',
|
|
55
|
+
surface: 'transparent',
|
|
56
|
+
background: '#0d1117',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* GitHub's own palette, light: the colours a reader already knows from the place they keep the diagram.
|
|
61
|
+
* Values are Primer's foreground and canvas tokens, not invented for this plugin.
|
|
62
|
+
*/
|
|
63
|
+
const GITHUB_LIGHT: Palette = {
|
|
64
|
+
pass: '#1a7f37',
|
|
65
|
+
findings: '#9a6700',
|
|
66
|
+
fail: '#cf222e',
|
|
67
|
+
undecided: '#656d76',
|
|
68
|
+
muted: '#8c959f',
|
|
69
|
+
text: '#1f2328',
|
|
70
|
+
edge: '#656d76',
|
|
71
|
+
surface: 'transparent',
|
|
72
|
+
background: '#ffffff',
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** GitHub dark default: canvas #0d1117, foreground #e6edf3. */
|
|
76
|
+
const GITHUB_DARK: Palette = {
|
|
77
|
+
pass: '#3fb950',
|
|
78
|
+
findings: '#d29922',
|
|
79
|
+
fail: '#f85149',
|
|
80
|
+
undecided: '#7d8590',
|
|
81
|
+
muted: '#6e7681',
|
|
82
|
+
text: '#e6edf3',
|
|
83
|
+
edge: '#7d8590',
|
|
84
|
+
surface: 'transparent',
|
|
85
|
+
background: '#0d1117',
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Black on white, for a projector or a poor print. The shape carries the meaning, not the hue. */
|
|
89
|
+
const HIGH_CONTRAST: Palette = {
|
|
90
|
+
pass: '#0a5c35',
|
|
91
|
+
findings: '#7a4300',
|
|
92
|
+
fail: '#a30000',
|
|
93
|
+
undecided: '#333333',
|
|
94
|
+
muted: '#444444',
|
|
95
|
+
text: '#000000',
|
|
96
|
+
edge: '#000000',
|
|
97
|
+
surface: 'transparent',
|
|
98
|
+
background: '#ffffff',
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Greyscale throughout: a drawing that will be printed on a black-and-white printer. */
|
|
102
|
+
const PRINT: Palette = {
|
|
103
|
+
pass: '#333333',
|
|
104
|
+
findings: '#555555',
|
|
105
|
+
fail: '#000000',
|
|
106
|
+
undecided: '#666666',
|
|
107
|
+
muted: '#777777',
|
|
108
|
+
text: '#000000',
|
|
109
|
+
edge: '#333333',
|
|
110
|
+
surface: 'transparent',
|
|
111
|
+
background: '#ffffff',
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface Scheme {
|
|
115
|
+
light: Palette
|
|
116
|
+
/** Absent when the scheme is deliberately the same on both surfaces. */
|
|
117
|
+
dark?: Palette
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const SCHEMES: Record<PaletteScheme, Scheme> = {
|
|
121
|
+
default: { light: NEUTRAL_LIGHT, dark: NEUTRAL_DARK },
|
|
122
|
+
github: { light: GITHUB_LIGHT, dark: GITHUB_DARK },
|
|
123
|
+
'high-contrast': { light: HIGH_CONTRAST },
|
|
124
|
+
print: { light: PRINT },
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface PaletteOptions {
|
|
128
|
+
mode?: PaletteMode
|
|
129
|
+
scheme?: PaletteScheme
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Resolve a palette.
|
|
134
|
+
*
|
|
135
|
+
* `overrides` win over everything, so a caller can retheme one role without inventing a scheme. A scheme with
|
|
136
|
+
* a dark variant follows the surface; one without stays as declared - high-contrast and print are what they
|
|
137
|
+
* say they are, on purpose. An unknown name falls back to the default palette rather than throwing, because
|
|
138
|
+
* the value can arrive from a DOM control.
|
|
139
|
+
*/
|
|
140
|
+
export function palette(overrides: Partial<Palette> = {}, options: PaletteOptions = {}): Palette {
|
|
141
|
+
const scheme = options.scheme ?? 'default'
|
|
142
|
+
const mode = options.mode ?? 'light'
|
|
143
|
+
const chosen = SCHEMES[scheme] ?? SCHEMES.default
|
|
144
|
+
const base = mode === 'dark' ? (chosen.dark ?? chosen.light) : chosen.light
|
|
145
|
+
return { ...base, ...overrides }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The role a verdict earns.
|
|
150
|
+
*
|
|
151
|
+
* `pass_with_findings` is deliberately not `pass`: it reads as the middle band, because a card that paints a
|
|
152
|
+
* report with warnings the same colour as a clean one has stopped carrying the answer.
|
|
153
|
+
*/
|
|
154
|
+
export function roleForVerdict(verdict: string | undefined): ColourRole {
|
|
155
|
+
if (verdict === 'pass') return 'pass'
|
|
156
|
+
if (verdict === 'pass_with_findings') return 'findings'
|
|
157
|
+
if (verdict === 'fail') return 'fail'
|
|
158
|
+
return 'undecided'
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Every scheme by name, so a picker does not hard-code the list. */
|
|
162
|
+
export function schemeNames(): PaletteScheme[] {
|
|
163
|
+
return Object.keys(SCHEMES) as PaletteScheme[]
|
|
164
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the boxes go.
|
|
3
|
+
*
|
|
4
|
+
* Two placements, chosen by the source:
|
|
5
|
+
*
|
|
6
|
+
* - **layered** (no groups): breadth-first rows, centred, no containment - the shape a state machine wants.
|
|
7
|
+
* - **tree** (groups present): a group is a block, its members stack inside it, a nested group is a block inside
|
|
8
|
+
* its parent, and the top level stacks - the shape an architecture diagram wants, because a package whose
|
|
9
|
+
* members float somewhere else on the page is not showing containment at all.
|
|
10
|
+
*
|
|
11
|
+
* Split out of the painter so the two questions stay separate: this module decides positions, `paint.ts` decides
|
|
12
|
+
* what to draw between them. A change to edge routing cannot move a box, and a change to nesting cannot move a
|
|
13
|
+
* line.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { rankStates } from './layout.js'
|
|
17
|
+
import type { Diagram, DiagramGroup, DiagramNode } from './model.js'
|
|
18
|
+
|
|
19
|
+
/** Geometry of one placed box, before anything is drawn. */
|
|
20
|
+
export type Box = { x: number; y: number; width: number; height: number }
|
|
21
|
+
|
|
22
|
+
/** What a placement returns: every box, every drawn boundary, and how wide the content is. */
|
|
23
|
+
export interface Placement {
|
|
24
|
+
boxes: Map<string, Box>
|
|
25
|
+
groupRects: Map<string, Box>
|
|
26
|
+
width: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Geometry constants shared by both placements. */
|
|
30
|
+
export const MARGIN = 20
|
|
31
|
+
const NODE_HEIGHT = 34
|
|
32
|
+
const ROW_GAP = 58
|
|
33
|
+
const COLUMN_GAP = 30
|
|
34
|
+
const MIN_NODE_WIDTH = 88
|
|
35
|
+
const MAX_NODE_WIDTH = 220
|
|
36
|
+
const CHAR_WIDTH = 7.2
|
|
37
|
+
/** A boundary's inner padding and the strip its name sits in. */
|
|
38
|
+
const GROUP_PADDING = 10
|
|
39
|
+
const GROUP_HEADER = 16
|
|
40
|
+
/** Between two blocks stacked inside a boundary, and between two top-level blocks. */
|
|
41
|
+
const BLOCK_GAP = 14
|
|
42
|
+
|
|
43
|
+
/** A box wide enough for its label, clamped so a long id cannot dominate the page. */
|
|
44
|
+
export const nodeWidth = (label: string): number => Math.min(MAX_NODE_WIDTH, Math.max(MIN_NODE_WIDTH, label.length * CHAR_WIDTH + 26))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
/** The layered placement: breadth-first rows, centred, no containment. */
|
|
49
|
+
function placeByRanks(diagram: Diagram, nodes: DiagramNode[], top: number): Placement {
|
|
50
|
+
const ranked = rankStates(diagram.nodes, diagram.edges, diagram.init)
|
|
51
|
+
const rows: string[][] = [...ranked.rows]
|
|
52
|
+
if (ranked.unreachable.length > 0) rows.push(ranked.unreachable)
|
|
53
|
+
|
|
54
|
+
const boxes = new Map<string, Box>()
|
|
55
|
+
let widest = 0
|
|
56
|
+
rows.forEach((row, index) => {
|
|
57
|
+
const widths = row.map((id) => nodeWidth(id))
|
|
58
|
+
widest = Math.max(widest, widths.reduce((total, width) => total + width, 0) + Math.max(0, row.length - 1) * COLUMN_GAP)
|
|
59
|
+
let x = 0
|
|
60
|
+
row.forEach((id, position) => {
|
|
61
|
+
boxes.set(id, { x, y: top + index * (NODE_HEIGHT + ROW_GAP), width: widths[position], height: NODE_HEIGHT })
|
|
62
|
+
x += widths[position] + COLUMN_GAP
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
rows.forEach((row) => {
|
|
66
|
+
const rowWidth = row.reduce((total, id) => total + (boxes.get(id) as Box).width, 0) + Math.max(0, row.length - 1) * COLUMN_GAP
|
|
67
|
+
const offset = (widest - rowWidth) / 2
|
|
68
|
+
for (const id of row) (boxes.get(id) as Box).x += offset + MARGIN
|
|
69
|
+
})
|
|
70
|
+
return { boxes, groupRects: new Map(), width: widest + MARGIN * 2 }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Which group each id belongs to, and which group contains which.
|
|
75
|
+
*
|
|
76
|
+
* Membership is read from `members`; containment is read from `parent`, because only the parser knows where a
|
|
77
|
+
* boundary was written. A model assembled by hand may omit `parent`, and then containment is inferred from
|
|
78
|
+
* member sets - the tightest superset wins - which is the best such a model can offer.
|
|
79
|
+
*
|
|
80
|
+
* Guards, because a model is data and data can be wrong: a parent that does not exist is ignored, a group that
|
|
81
|
+
* contains itself or closes a cycle is treated as a root, and a member claimed by two groups belongs to the
|
|
82
|
+
* tightest one, so a nested boundary is not drawn around nothing.
|
|
83
|
+
*/
|
|
84
|
+
function groupTree(groups: DiagramGroup[]): { owner: Map<string, string>; children: Map<string, string[]>; roots: string[] } {
|
|
85
|
+
const byId = new Map(groups.map((group) => [group.id, group] as const))
|
|
86
|
+
|
|
87
|
+
const owner = new Map<string, string>()
|
|
88
|
+
const bySize = [...groups].sort((left, right) => left.members.length - right.members.length)
|
|
89
|
+
for (const group of bySize) {
|
|
90
|
+
for (const member of group.members) if (!owner.has(member)) owner.set(member, group.id)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const declared = (group: DiagramGroup): string | undefined => {
|
|
94
|
+
const parent = group.parent
|
|
95
|
+
if (parent === undefined || parent === group.id) return undefined
|
|
96
|
+
return byId.has(parent) ? parent : undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const members = new Map(groups.map((group) => [group.id, new Set(group.members)] as const))
|
|
100
|
+
const infer = (group: DiagramGroup): string | undefined => {
|
|
101
|
+
let best: { id: string; size: number } | undefined
|
|
102
|
+
for (const other of groups) {
|
|
103
|
+
if (other.id === group.id) continue
|
|
104
|
+
const outer = members.get(other.id) as Set<string>
|
|
105
|
+
const inner = members.get(group.id) as Set<string>
|
|
106
|
+
if (inner.size === 0 || inner.size >= outer.size) continue
|
|
107
|
+
let contained = true
|
|
108
|
+
for (const member of inner) {
|
|
109
|
+
if (!outer.has(member)) {
|
|
110
|
+
contained = false
|
|
111
|
+
break
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (contained && (best === undefined || outer.size < best.size)) best = { id: other.id, size: outer.size }
|
|
115
|
+
}
|
|
116
|
+
return best?.id
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const parentOf = new Map<string, string>()
|
|
120
|
+
for (const group of groups) {
|
|
121
|
+
const parent = declared(group) ?? infer(group)
|
|
122
|
+
if (parent !== undefined) parentOf.set(group.id, parent)
|
|
123
|
+
}
|
|
124
|
+
// A cycle would recurse forever: walk up from each group and drop the link that closes the loop.
|
|
125
|
+
for (const group of groups) {
|
|
126
|
+
const seen = new Set<string>([group.id])
|
|
127
|
+
let cursor = parentOf.get(group.id)
|
|
128
|
+
while (cursor !== undefined) {
|
|
129
|
+
if (seen.has(cursor)) {
|
|
130
|
+
parentOf.delete(cursor)
|
|
131
|
+
break
|
|
132
|
+
}
|
|
133
|
+
seen.add(cursor)
|
|
134
|
+
cursor = parentOf.get(cursor)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const children = new Map<string, string[]>()
|
|
139
|
+
const roots: string[] = []
|
|
140
|
+
for (const group of groups) {
|
|
141
|
+
const parent = parentOf.get(group.id)
|
|
142
|
+
if (parent === undefined) roots.push(group.id)
|
|
143
|
+
else children.set(parent, [...(children.get(parent) ?? []), group.id])
|
|
144
|
+
}
|
|
145
|
+
return { owner, children, roots }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface Measured {
|
|
149
|
+
width: number
|
|
150
|
+
height: number
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The tree placement: a boundary is a block, its contents stack inside it, the top level stacks. */
|
|
154
|
+
function placeByGroups(diagram: Diagram, nodes: DiagramNode[], top: number): Placement {
|
|
155
|
+
const groups = diagram.groups as DiagramGroup[]
|
|
156
|
+
const byId = new Map(groups.map((group) => [group.id, group] as const))
|
|
157
|
+
const { owner, children, roots } = groupTree(groups)
|
|
158
|
+
|
|
159
|
+
// Nodes that belong to no group, and the first-seen order that keeps a source's own ordering visible.
|
|
160
|
+
const order = new Map(nodes.map((node, index) => [node.id, index] as const))
|
|
161
|
+
const loose = nodes.filter((node) => !owner.has(node.id)).map((node) => node.id)
|
|
162
|
+
|
|
163
|
+
const nodeIds = new Set(nodes.map((node) => node.id))
|
|
164
|
+
const rawContents = (groupId: string): string[] => {
|
|
165
|
+
const group = byId.get(groupId) as DiagramGroup
|
|
166
|
+
const own = [...new Set(group.members)].filter((member) => owner.get(member) === groupId && nodeIds.has(member))
|
|
167
|
+
const nested = children.get(groupId) ?? []
|
|
168
|
+
return [...own, ...nested]
|
|
169
|
+
}
|
|
170
|
+
// A boundary with nothing inside it is not drawn: an empty box claims a containment the source never stated.
|
|
171
|
+
const contents = (groupId: string): string[] => rawContents(groupId).filter((child) => !byId.has(child) || rawContents(child).length > 0)
|
|
172
|
+
|
|
173
|
+
const measure = (id: string): Measured => {
|
|
174
|
+
if (!byId.has(id)) return { width: nodeWidth(id), height: NODE_HEIGHT }
|
|
175
|
+
const items = contents(id)
|
|
176
|
+
const sizes = items.map(measure)
|
|
177
|
+
return {
|
|
178
|
+
width: Math.max(MIN_NODE_WIDTH, ...sizes.map((size) => size.width)) + GROUP_PADDING * 2,
|
|
179
|
+
height: GROUP_HEADER + GROUP_PADDING * 2 + sizes.reduce((total, size) => total + size.height, 0) + Math.max(0, items.length - 1) * BLOCK_GAP,
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const boxes = new Map<string, Box>()
|
|
184
|
+
const groupRects = new Map<string, Box>()
|
|
185
|
+
|
|
186
|
+
const place = (id: string, x: number, y: number): Measured => {
|
|
187
|
+
const size = measure(id)
|
|
188
|
+
if (!byId.has(id)) {
|
|
189
|
+
boxes.set(id, { x, y, width: size.width, height: NODE_HEIGHT })
|
|
190
|
+
return size
|
|
191
|
+
}
|
|
192
|
+
groupRects.set(id, { x, y, width: size.width, height: size.height })
|
|
193
|
+
let cursor = y + GROUP_HEADER + GROUP_PADDING
|
|
194
|
+
const inner = size.width - GROUP_PADDING * 2
|
|
195
|
+
const ordered = [...contents(id)].sort((left, right) => firstIndex(left) - firstIndex(right))
|
|
196
|
+
for (const child of ordered) {
|
|
197
|
+
const childSize = measure(child)
|
|
198
|
+
place(child, x + GROUP_PADDING + (inner - childSize.width) / 2, cursor)
|
|
199
|
+
cursor += childSize.height + BLOCK_GAP
|
|
200
|
+
}
|
|
201
|
+
return size
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Top level: loose nodes and root groups, in the order the source named them.
|
|
205
|
+
const visibleRoots = roots.filter((id) => contents(id).length > 0)
|
|
206
|
+
// A block takes the earliest position of anything inside it, so a group is drawn where its first member was
|
|
207
|
+
// named - not after every loose node, which put a downstream box above the packages it depends on.
|
|
208
|
+
const firstIndex = (id: string): number => {
|
|
209
|
+
// `order` holds every node, and a block is only ever asked about when it has contents.
|
|
210
|
+
if (!byId.has(id)) return order.get(id) as number
|
|
211
|
+
return Math.min(...contents(id).map(firstIndex))
|
|
212
|
+
}
|
|
213
|
+
const topItems = [...loose, ...visibleRoots].sort((left, right) => firstIndex(left) - firstIndex(right))
|
|
214
|
+
const sizes = topItems.map(measure)
|
|
215
|
+
const width = Math.max(MIN_NODE_WIDTH, ...sizes.map((size) => size.width))
|
|
216
|
+
let cursor = top
|
|
217
|
+
for (const item of topItems) {
|
|
218
|
+
const size = measure(item)
|
|
219
|
+
place(item, MARGIN + (width - size.width) / 2, cursor)
|
|
220
|
+
cursor += size.height + BLOCK_GAP
|
|
221
|
+
}
|
|
222
|
+
return { boxes, groupRects, width: width + MARGIN * 2 }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Place every box of a diagram.
|
|
227
|
+
*
|
|
228
|
+
* The choice of placement is a property of the source, not of the caller: a drawing that declares boundaries is
|
|
229
|
+
* read as containers and gets the tree; one that does not is read as a machine and gets the ranks.
|
|
230
|
+
*/
|
|
231
|
+
export function place(diagram: Diagram, top: number): Placement {
|
|
232
|
+
const groups = diagram.groups ?? []
|
|
233
|
+
return groups.length > 0 ? placeByGroups(diagram, diagram.nodes, top) : placeByRanks(diagram, diagram.nodes, top)
|
|
234
|
+
}
|
|
@@ -0,0 +1,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
|
+
import type { Canvas, Shape } from './paint.js'
|
|
16
|
+
import type { Palette } from './palette.js'
|
|
17
|
+
|
|
18
|
+
/** One decimal place is enough at any zoom, and it keeps the markup readable. */
|
|
19
|
+
const n = (value: number): string => {
|
|
20
|
+
const rounded = Math.round(value * 10) / 10
|
|
21
|
+
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const escape = (value: string): string =>
|
|
25
|
+
value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
|
|
26
|
+
|
|
27
|
+
type Writer<K extends Shape['kind']> = (shape: Extract<Shape, { kind: K }>, colours: Palette) => string
|
|
28
|
+
|
|
29
|
+
const writers: { [K in Shape['kind']]: Writer<K> } = {
|
|
30
|
+
// A boundary is a dashed rectangle with its name in the top-left corner, drawn under everything else.
|
|
31
|
+
group: (shape, colours) => {
|
|
32
|
+
const stroke = colours[shape.colour]
|
|
33
|
+
// The id travels in the markup so a container can fold a boundary by delegation, without re-deriving which
|
|
34
|
+
// group a click landed on from its position or its label.
|
|
35
|
+
return (
|
|
36
|
+
'<g data-group-id="' + escape(shape.id) + '" style="cursor:pointer">' +
|
|
37
|
+
'<rect x="' + n(shape.x) + '" y="' + n(shape.y) + '" width="' + n(shape.width) + '" height="' + n(shape.height) +
|
|
38
|
+
'" rx="8" fill="none" stroke="' + stroke + '" stroke-width="1" stroke-dasharray="4 3" />' +
|
|
39
|
+
'<text x="' + n(shape.x + 6) + '" y="' + n(shape.y + 12) + '" font-size="10" fill="' + stroke + '">' + escape(shape.label) + '</text>' +
|
|
40
|
+
'</g>'
|
|
41
|
+
)
|
|
42
|
+
},
|
|
43
|
+
box: (shape, colours) => {
|
|
44
|
+
const stroke = colours[shape.colour]
|
|
45
|
+
const width = shape.initial ? 2.5 : 1
|
|
46
|
+
return (
|
|
47
|
+
'<rect x="' + n(shape.x) + '" y="' + n(shape.y) + '" width="' + n(shape.width) + '" height="' + n(shape.height) +
|
|
48
|
+
'" rx="6" fill="' + colours.surface + '" stroke="' + stroke + '" stroke-width="' + n(width) + '" />' +
|
|
49
|
+
'<text x="' + n(shape.x + shape.width / 2) + '" y="' + n(shape.y + shape.height / 2 + 4) +
|
|
50
|
+
'" font-size="11" text-anchor="middle" fill="' + stroke + '">' + escape(shape.label) + '</text>'
|
|
51
|
+
)
|
|
52
|
+
},
|
|
53
|
+
link: (shape, colours) => {
|
|
54
|
+
const stroke = colours[shape.colour]
|
|
55
|
+
const arrow = arrowHead(shape.x1, shape.y1, shape.x2, shape.y2, stroke)
|
|
56
|
+
return (
|
|
57
|
+
'<line x1="' + n(shape.x1) + '" y1="' + n(shape.y1) + '" x2="' + n(shape.x2) + '" y2="' + n(shape.y2) +
|
|
58
|
+
'" stroke="' + stroke + '" stroke-width="1" />' + arrow + edgeLabel(shape.label, shape.labelX, shape.labelY, colours)
|
|
59
|
+
)
|
|
60
|
+
},
|
|
61
|
+
path: (shape, colours) => {
|
|
62
|
+
const stroke = colours[shape.colour]
|
|
63
|
+
const points = shape.points.map((point) => n(point.x) + ',' + n(point.y)).join(' ')
|
|
64
|
+
const last = shape.points[shape.points.length - 1]
|
|
65
|
+
const before = shape.points[shape.points.length - 2] ?? last
|
|
66
|
+
return (
|
|
67
|
+
'<polyline points="' + points + '" fill="none" stroke="' + stroke + '" stroke-width="1" stroke-linejoin="round" />' +
|
|
68
|
+
arrowHead(before.x, before.y, last.x, last.y, stroke) + edgeLabel(shape.label, shape.labelX, shape.labelY, colours)
|
|
69
|
+
)
|
|
70
|
+
},
|
|
71
|
+
title: (shape, colours) => {
|
|
72
|
+
return '<text x="' + n(shape.x) + '" y="' + n(shape.y) + '" font-size="12" fill="' + colours[shape.colour] + '">' + escape(shape.text) + '</text>'
|
|
73
|
+
},
|
|
74
|
+
caption: (shape, colours) => {
|
|
75
|
+
const colour = colours[shape.colour]
|
|
76
|
+
const lines = shape.lines
|
|
77
|
+
.map((line, index) => '<tspan x="' + n(shape.x) + '" dy="' + (index === 0 ? 0 : 15) + '">' + escape(line) + '</tspan>')
|
|
78
|
+
.join('')
|
|
79
|
+
return '<text x="' + n(shape.x) + '" y="' + n(shape.y + 11) + '" font-size="10" fill="' + colour + '">' + lines + '</text>'
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A small filled triangle at the end of a segment, pointing along it. */
|
|
84
|
+
function arrowHead(x1: number, y1: number, x2: number, y2: number, stroke: string): string {
|
|
85
|
+
const length = Math.hypot(x2 - x1, y2 - y1)
|
|
86
|
+
if (length < 1) return ''
|
|
87
|
+
const ux = (x2 - x1) / length
|
|
88
|
+
const uy = (y2 - y1) / length
|
|
89
|
+
const size = 7
|
|
90
|
+
const half = 3.2
|
|
91
|
+
const baseX = x2 - ux * size
|
|
92
|
+
const baseY = y2 - uy * size
|
|
93
|
+
const left = n(baseX - uy * half) + ',' + n(baseY + ux * half)
|
|
94
|
+
const right = n(baseX + uy * half) + ',' + n(baseY - ux * half)
|
|
95
|
+
return '<polygon points="' + n(x2) + ',' + n(y2) + ' ' + left + ' ' + right + '" fill="' + stroke + '" />'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** An edge label, or nothing when the edge carries no event. */
|
|
99
|
+
function edgeLabel(label: string, x: number, y: number, colours: Palette): string {
|
|
100
|
+
// Total on purpose: a malformed edge must lose its label, not the whole drawing.
|
|
101
|
+
if (typeof label !== 'string' || label === '') return ''
|
|
102
|
+
return '<text x="' + n(x) + '" y="' + n(y) + '" font-size="10" fill="' + colours.muted + '">' + escape(label) + '</text>'
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function toSvg(canvas: Canvas, colours: Palette, label?: string): { markup: string; dropped: number } {
|
|
106
|
+
let dropped = 0
|
|
107
|
+
const body: string[] = []
|
|
108
|
+
for (const shape of canvas.shapes) {
|
|
109
|
+
const writer = writers[shape.kind] as ((shape: Shape, colours: Palette) => string) | undefined
|
|
110
|
+
if (writer === undefined) {
|
|
111
|
+
dropped += 1
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
body.push(writer(shape, colours))
|
|
115
|
+
}
|
|
116
|
+
const header =
|
|
117
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + n(canvas.width) + ' ' + n(canvas.height) +
|
|
118
|
+
'" width="' + n(canvas.width) + '" height="' + n(canvas.height) + '" role="img"' +
|
|
119
|
+
(label === undefined ? '' : ' aria-label="' + escape(label) + '"') +
|
|
120
|
+
' font-family="system-ui, -apple-system, Segoe UI, sans-serif">'
|
|
121
|
+
// The surface is part of the drawing, not the container's business: a page that paints its own layers - a
|
|
122
|
+
// wallpaper, a themed panel - would otherwise change the contrast of a picture it knows nothing about.
|
|
123
|
+
const background =
|
|
124
|
+
'<rect x="0" y="0" width="' + n(canvas.width) + '" height="' + n(canvas.height) +
|
|
125
|
+
'" fill="' + colours.background + '" />'
|
|
126
|
+
return { markup: header + background + body.join('') + '</svg>', dropped }
|
|
127
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a tool card shows, reduced to a drawing.
|
|
3
|
+
*
|
|
4
|
+
* The input is what the host hands a settled tool view: a phase and a block whose shape differs per phase.
|
|
5
|
+
* A settled call carries the paired call's arguments as a **lazy view** (`PartialArguments`) and the result
|
|
6
|
+
* as content blocks, so nothing here reads `block.args.model`: the view is asked, and the report text is
|
|
7
|
+
* parsed for the two facts a drawing needs. Every reader is total - an absent field removes a shape rather
|
|
8
|
+
* than throwing, because the same view renders during streaming.
|
|
9
|
+
*
|
|
10
|
+
* Pure. Everything decided here is a fact a test can assert: which nodes exist, which edges, why they are
|
|
11
|
+
* coloured the way they are.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const record = (value: unknown): Record<string, unknown> | undefined =>
|
|
15
|
+
typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined
|
|
16
|
+
|
|
17
|
+
const array = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
|
|
18
|
+
|
|
19
|
+
const text = (value: unknown): string | undefined => (typeof value === 'string' && value !== '' ? value : undefined)
|
|
20
|
+
|
|
21
|
+
/** The report text a settled call carries, joined: a card reads words, not blocks. */
|
|
22
|
+
function reportText(content: unknown): string {
|
|
23
|
+
const parts: string[] = []
|
|
24
|
+
for (const entry of array(content)) {
|
|
25
|
+
const block = record(entry)
|
|
26
|
+
const body = text(block?.text)
|
|
27
|
+
if (body !== undefined) parts.push(body)
|
|
28
|
+
}
|
|
29
|
+
return parts.join(String.fromCharCode(10))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Two facts out of this plugin's own report format: the verdict, and the first error it named. */
|
|
33
|
+
function readReport(content: unknown): { verdict?: string; code?: string; message?: string } {
|
|
34
|
+
const body = reportText(content)
|
|
35
|
+
if (body === '') return {}
|
|
36
|
+
const verdict = /verdict=(\w+)/.exec(body)?.[1]
|
|
37
|
+
const named = /\[error\/([A-Z0-9_]+)\]\s*(.*)/.exec(body)
|
|
38
|
+
if (named !== null) return { verdict, code: named[1], message: named[2] }
|
|
39
|
+
const first = /first error:\s*([A-Z0-9_]+)/.exec(body)
|
|
40
|
+
return first === null ? { verdict } : { verdict, code: first[1] }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The `model` argument out of the host's lazy arguments view.
|
|
45
|
+
*
|
|
46
|
+
* A settled call exposes `value(key)`; a plain object (a test, or an older shape) is read directly. The
|
|
47
|
+
* view answers `undefined` until the field closes, which is exactly when a drawing should be attempted.
|
|
48
|
+
*/
|
|
49
|
+
function readModel(args: unknown): Record<string, unknown> | undefined {
|
|
50
|
+
const view = record(args)
|
|
51
|
+
if (view === undefined) return undefined
|
|
52
|
+
const read = view.value
|
|
53
|
+
if (typeof read === 'function') return record((read as (key: string) => unknown)('model'))
|
|
54
|
+
return record(view.model)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The states of a model: a machine, or nothing when the call carried none. */
|
|
58
|
+
function machineOf(model: Record<string, unknown> | undefined): { nodes: DiagramNode[]; edges: DiagramEdge[]; init?: string } | undefined {
|
|
59
|
+
if (model === undefined) return undefined
|
|
60
|
+
const states = array(model.states).map(record).filter((entry): entry is Record<string, unknown> => entry !== undefined && typeof entry.id === 'string')
|
|
61
|
+
if (states.length === 0) return undefined
|
|
62
|
+
const edges = array(model.transitions)
|
|
63
|
+
.map(record)
|
|
64
|
+
.filter((entry): entry is Record<string, unknown> => entry !== undefined && typeof entry.from === 'string' && typeof entry.to === 'string')
|
|
65
|
+
.map((entry) => ({ from: String(entry.from), to: String(entry.to), label: text(entry.event) ?? '' }))
|
|
66
|
+
return {
|
|
67
|
+
nodes: states.map((state) => ({ id: String(state.id), role: 'normal' as const })),
|
|
68
|
+
edges,
|
|
69
|
+
...(typeof model.init === 'string' ? { init: model.init } : {}),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The caption: the verdict, then the first error's code and message when the report named one. */
|
|
74
|
+
function captionOf(report: { verdict?: string; code?: string; message?: string }): string {
|
|
75
|
+
const verdict = report.verdict ?? 'no report yet'
|
|
76
|
+
if (report.code === undefined) return verdict
|
|
77
|
+
const message = report.message ?? ''
|
|
78
|
+
return verdict + ' — ' + report.code + (message === '' ? '' : ': ' + message)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Which nodes the report blames.
|
|
83
|
+
*
|
|
84
|
+
* A finding names a state inside its message as `"x"`; matching that is a heuristic, so it only ever *adds*
|
|
85
|
+
* emphasis and never decides the verdict.
|
|
86
|
+
*/
|
|
87
|
+
function blame(nodes: DiagramNode[], report: { message?: string }): DiagramNode[] {
|
|
88
|
+
if (report.message === undefined) return nodes
|
|
89
|
+
const message = report.message
|
|
90
|
+
// Whole words only: a report does not quote the state, and a state called `do` must not match `done`.
|
|
91
|
+
const named = nodes
|
|
92
|
+
.filter((node) => {
|
|
93
|
+
const escaped = node.id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
94
|
+
return new RegExp('(?<![A-Za-z0-9_])' + escaped + '(?![A-Za-z0-9_])').test(message)
|
|
95
|
+
})
|
|
96
|
+
.map((node) => node.id)
|
|
97
|
+
if (named.length === 0) return nodes
|
|
98
|
+
return nodes.map((node) => (named.includes(node.id) ? { ...node, role: 'error' as const } : node))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
import type { Diagram, DiagramEdge, DiagramNode } from './model.js'
|
|
102
|
+
|
|
103
|
+
export interface ViewInput {
|
|
104
|
+
phase?: unknown
|
|
105
|
+
toolName?: unknown
|
|
106
|
+
/** The whole block the host hands a view; its shape depends on the phase. */
|
|
107
|
+
block?: unknown
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function viewModel(input: ViewInput): Diagram {
|
|
111
|
+
const block = record(input.block)
|
|
112
|
+
const title = text(block?.name) ?? text(input.toolName) ?? 'tool'
|
|
113
|
+
const report = readReport(block?.content)
|
|
114
|
+
// A shape that still carries a parsed report object is read from there; it is what older callers pass.
|
|
115
|
+
const parsed = record(block?.result)
|
|
116
|
+
const findings = array(parsed?.findings).map(record)
|
|
117
|
+
const firstError = findings.find((finding) => finding?.severity === 'error')
|
|
118
|
+
const merged = {
|
|
119
|
+
verdict: text(parsed?.verdict) ?? report.verdict,
|
|
120
|
+
...(report.code === undefined && typeof firstError?.code === 'string' ? { code: firstError.code } : { code: report.code }),
|
|
121
|
+
...(report.message === undefined && typeof firstError?.message === 'string' ? { message: firstError.message } : { message: report.message }),
|
|
122
|
+
}
|
|
123
|
+
const caption = captionOf(merged)
|
|
124
|
+
const machine = machineOf(readModel(block?.args))
|
|
125
|
+
if (machine === undefined) {
|
|
126
|
+
return { kind: 'none', title, nodes: [], edges: [], caption }
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
kind: 'machine',
|
|
130
|
+
title,
|
|
131
|
+
...(machine.init === undefined ? {} : { init: machine.init }),
|
|
132
|
+
nodes: blame(machine.nodes, merged),
|
|
133
|
+
edges: machine.edges,
|
|
134
|
+
caption,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writing a drawing to disk: the CLI's job, kept out of the renderer.
|
|
3
|
+
*
|
|
4
|
+
* `render/index.ts` is bundled into the page as well as run in Node, so it may not import a filesystem;
|
|
5
|
+
* everything that touches one lives here, where only the host reaches it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { writeFileSync } from 'node:fs'
|
|
9
|
+
import { render, type RenderInput, type Rendered } from './index.js'
|
|
10
|
+
import type { Palette, PaletteOptions } from './palette.js'
|
|
11
|
+
|
|
12
|
+
/** Write the drawing where a document can link to it. Returns the path, the byte count and the render. */
|
|
13
|
+
export function writeSvg(input: RenderInput, path: string, overrides: Partial<Palette> = {}, options: PaletteOptions = {}): { path: string; bytes: number; rendered: Rendered } {
|
|
14
|
+
const rendered = render(input, overrides, options)
|
|
15
|
+
writeFileSync(path, rendered.markup, 'utf8')
|
|
16
|
+
return { path, bytes: rendered.markup.length, rendered }
|
|
17
|
+
}
|