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,42 @@
|
|
|
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
|
+
import { fromMermaid } from './from-mermaid.js';
|
|
11
|
+
import { collapse } from './collapse.js';
|
|
12
|
+
import { fromPlantUml } from './from-plantuml.js';
|
|
13
|
+
import { paintWith } from './paint.js';
|
|
14
|
+
import { toSvg } from './svg.js';
|
|
15
|
+
/** Read the notation off the source: PlantUML starts with `@startuml` or `@startmindmap`-style markers. */
|
|
16
|
+
export function detectNotation(source) {
|
|
17
|
+
const head = source
|
|
18
|
+
.split(/\r?\n/)
|
|
19
|
+
.map((line) => line.trim())
|
|
20
|
+
.find((line) => line !== '' && !line.startsWith("'") && !line.startsWith('//'));
|
|
21
|
+
if (head === undefined)
|
|
22
|
+
return 'mermaid';
|
|
23
|
+
return /^@start/.test(head) ? 'plantuml' : 'mermaid';
|
|
24
|
+
}
|
|
25
|
+
export function render(input, overrides = {}, options = {}) {
|
|
26
|
+
const notation = input.notation === undefined || input.notation === 'auto' ? detectNotation(input.source) : input.notation;
|
|
27
|
+
const parsed = notation === 'plantuml'
|
|
28
|
+
? fromPlantUml(input.source, input.title ?? 'plantuml')
|
|
29
|
+
: fromMermaid(input.source, input.title ?? 'mermaid');
|
|
30
|
+
const diagram = input.folded === undefined ? parsed.diagram : collapse(parsed.diagram, input.folded);
|
|
31
|
+
const { canvas, colours } = paintWith(diagram, overrides, options);
|
|
32
|
+
const written = toSvg(canvas, colours, diagram.title + ': ' + diagram.caption);
|
|
33
|
+
return {
|
|
34
|
+
diagram,
|
|
35
|
+
canvas,
|
|
36
|
+
colours,
|
|
37
|
+
markup: written.markup,
|
|
38
|
+
notation,
|
|
39
|
+
refused: parsed.refused,
|
|
40
|
+
dropped: written.dropped,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
export function rankStates(nodes, edges, init) {
|
|
16
|
+
const ids = nodes.map((node) => node.id);
|
|
17
|
+
const known = new Set(ids);
|
|
18
|
+
const start = init !== undefined && known.has(init) ? init : ids[0];
|
|
19
|
+
const outgoing = new Map();
|
|
20
|
+
const incoming = new Map();
|
|
21
|
+
for (const edge of edges) {
|
|
22
|
+
if (!known.has(edge.from) || !known.has(edge.to))
|
|
23
|
+
continue;
|
|
24
|
+
outgoing.set(edge.from, [...(outgoing.get(edge.from) ?? []), edge.to]);
|
|
25
|
+
incoming.set(edge.to, [...(incoming.get(edge.to) ?? []), edge.from]);
|
|
26
|
+
}
|
|
27
|
+
const depth = new Map();
|
|
28
|
+
if (start !== undefined) {
|
|
29
|
+
depth.set(start, 0);
|
|
30
|
+
const queue = [start];
|
|
31
|
+
while (queue.length > 0) {
|
|
32
|
+
const id = queue.shift();
|
|
33
|
+
const level = depth.get(id);
|
|
34
|
+
for (const target of outgoing.get(id) ?? []) {
|
|
35
|
+
if (depth.has(target))
|
|
36
|
+
continue;
|
|
37
|
+
depth.set(target, level + 1);
|
|
38
|
+
queue.push(target);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const rows = [];
|
|
43
|
+
for (const id of ids) {
|
|
44
|
+
const level = depth.get(id);
|
|
45
|
+
if (level === undefined)
|
|
46
|
+
continue;
|
|
47
|
+
while (rows.length <= level)
|
|
48
|
+
rows.push([]);
|
|
49
|
+
rows[level].push(id);
|
|
50
|
+
}
|
|
51
|
+
return { rows, unreachable: ids.filter((id) => !depth.has(id)) };
|
|
52
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
/** An empty drawing, for a source that produced no nodes. */
|
|
11
|
+
export function emptyDiagram(title, caption) {
|
|
12
|
+
return { kind: 'none', title, nodes: [], edges: [], caption };
|
|
13
|
+
}
|
|
14
|
+
/** The label to draw for a node: its own label when the notation gave one, else its id. */
|
|
15
|
+
export function labelOf(node) {
|
|
16
|
+
return node.label !== undefined && node.label !== '' ? node.label : node.id;
|
|
17
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
import { MARGIN, place } from './place.js';
|
|
22
|
+
import { palette, roleForVerdict } from './palette.js';
|
|
23
|
+
import { labelOf } from './model.js';
|
|
24
|
+
/** Geometry constants in one place: a container with different proportions changes these, not the rules. */
|
|
25
|
+
const MIN_NODE_WIDTH = 88;
|
|
26
|
+
const MAX_NODE_WIDTH = 220;
|
|
27
|
+
const CHAR_WIDTH = 7.2;
|
|
28
|
+
const nodeWidth = (label) => Math.min(MAX_NODE_WIDTH, Math.max(MIN_NODE_WIDTH, label.length * CHAR_WIDTH + 26));
|
|
29
|
+
const TITLE_HEIGHT = 26;
|
|
30
|
+
const NODE_HEIGHT = 34;
|
|
31
|
+
const CHANNEL_GAP = 16;
|
|
32
|
+
const CAPTION_CHARS = 78;
|
|
33
|
+
const CAPTION_LINE = 15;
|
|
34
|
+
const CAPTION_GAP = 16;
|
|
35
|
+
const CAPTION_CHAR_WIDTH = 6.2;
|
|
36
|
+
/** Which colour a box earns from its role. One mapping, used by every container. */
|
|
37
|
+
export function roleOfNode(node) {
|
|
38
|
+
if (node.role === 'error')
|
|
39
|
+
return 'fail';
|
|
40
|
+
if (node.role === 'warning')
|
|
41
|
+
return 'findings';
|
|
42
|
+
if (node.role === 'muted')
|
|
43
|
+
return 'muted';
|
|
44
|
+
return 'edge';
|
|
45
|
+
}
|
|
46
|
+
/** Wrap on spaces; break a token longer than the line rather than let it overflow the canvas. */
|
|
47
|
+
function wrap(text, limit) {
|
|
48
|
+
const lines = [];
|
|
49
|
+
let line = '';
|
|
50
|
+
for (const word of text.split(/\s+/).filter((part) => part !== '')) {
|
|
51
|
+
const candidate = line === '' ? word : line + ' ' + word;
|
|
52
|
+
if (candidate.length <= limit) {
|
|
53
|
+
line = candidate;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (line !== '')
|
|
57
|
+
lines.push(line);
|
|
58
|
+
line = '';
|
|
59
|
+
let rest = word;
|
|
60
|
+
while (rest.length > limit) {
|
|
61
|
+
lines.push(rest.slice(0, limit));
|
|
62
|
+
rest = rest.slice(limit);
|
|
63
|
+
}
|
|
64
|
+
line = rest;
|
|
65
|
+
}
|
|
66
|
+
if (line !== '')
|
|
67
|
+
lines.push(line);
|
|
68
|
+
return lines.length === 0 ? [''] : lines;
|
|
69
|
+
}
|
|
70
|
+
/** The colour a caption earns, read from the verdict it quotes. */
|
|
71
|
+
function captionRole(caption) {
|
|
72
|
+
return roleForVerdict(caption.split(' — ')[0]);
|
|
73
|
+
}
|
|
74
|
+
export function paintWith(diagram, overrides = {}, options = {}) {
|
|
75
|
+
const colours = palette(overrides, options);
|
|
76
|
+
const shapes = [];
|
|
77
|
+
const groups = diagram.groups ?? [];
|
|
78
|
+
const topOffset = MARGIN + TITLE_HEIGHT;
|
|
79
|
+
const placement = place(diagram, topOffset);
|
|
80
|
+
const { boxes, groupRects } = placement;
|
|
81
|
+
// Boundaries first, so everything else is painted on top of them.
|
|
82
|
+
for (const group of groups) {
|
|
83
|
+
const rect = groupRects.get(group.id);
|
|
84
|
+
if (rect === undefined)
|
|
85
|
+
continue;
|
|
86
|
+
shapes.push({ kind: 'group', id: group.id, x: rect.x, y: rect.y, width: rect.width, height: rect.height, label: group.label, colour: 'muted' });
|
|
87
|
+
}
|
|
88
|
+
const drawn = new Set();
|
|
89
|
+
for (const node of diagram.nodes) {
|
|
90
|
+
// A state id may repeat in a malformed model: the first placement wins and the repeat is skipped.
|
|
91
|
+
if (drawn.has(node.id))
|
|
92
|
+
continue;
|
|
93
|
+
const box = boxes.get(node.id);
|
|
94
|
+
drawn.add(node.id);
|
|
95
|
+
shapes.push({
|
|
96
|
+
kind: 'box',
|
|
97
|
+
x: box.x,
|
|
98
|
+
y: box.y,
|
|
99
|
+
width: box.width,
|
|
100
|
+
height: box.height,
|
|
101
|
+
label: labelOf(node),
|
|
102
|
+
colour: roleOfNode(node),
|
|
103
|
+
initial: diagram.init === node.id,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
const roleById = new Map(diagram.nodes.map((node) => [node.id, roleOfNode(node)]));
|
|
107
|
+
let channel = Math.max(placement.width, ...Array.from(boxes.values(), (box) => box.x + box.width)) + CHANNEL_GAP;
|
|
108
|
+
let channelUses = 0;
|
|
109
|
+
for (const edge of diagram.edges) {
|
|
110
|
+
const source = boxes.get(edge.from);
|
|
111
|
+
const target = boxes.get(edge.to);
|
|
112
|
+
if (source === undefined || target === undefined)
|
|
113
|
+
continue;
|
|
114
|
+
const colour = roleById.get(edge.from);
|
|
115
|
+
// Placement decided where things are; "below", "beside" and "above" are read from the result.
|
|
116
|
+
const sameRow = Math.abs(target.y - source.y) < NODE_HEIGHT / 2;
|
|
117
|
+
if (edge.from === edge.to) {
|
|
118
|
+
const x = source.x + source.width;
|
|
119
|
+
const y = source.y + source.height / 2;
|
|
120
|
+
shapes.push({
|
|
121
|
+
kind: 'path',
|
|
122
|
+
points: [
|
|
123
|
+
{ x, y: y - 9 },
|
|
124
|
+
{ x: x + 14, y: y - 9 },
|
|
125
|
+
{ x: x + 14, y: y + 9 },
|
|
126
|
+
{ x, y: y + 9 },
|
|
127
|
+
],
|
|
128
|
+
label: edge.label,
|
|
129
|
+
labelX: x + 20,
|
|
130
|
+
labelY: y,
|
|
131
|
+
colour,
|
|
132
|
+
});
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (target.y > source.y && !sameRow) {
|
|
136
|
+
const x1 = source.x + source.width / 2;
|
|
137
|
+
const y1 = source.y + source.height;
|
|
138
|
+
const x2 = target.x + target.width / 2;
|
|
139
|
+
const y2 = target.y;
|
|
140
|
+
shapes.push({ kind: 'link', x1, y1, x2, y2, label: edge.label, labelX: (x1 + x2) / 2 + 7, labelY: (y1 + y2) / 2, colour });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (sameRow) {
|
|
144
|
+
const right = source.x + source.width;
|
|
145
|
+
const left = target.x;
|
|
146
|
+
const y = source.y + source.height / 2;
|
|
147
|
+
const forward = right <= left;
|
|
148
|
+
shapes.push({
|
|
149
|
+
kind: 'link',
|
|
150
|
+
x1: forward ? right : source.x,
|
|
151
|
+
y1: y,
|
|
152
|
+
x2: forward ? left : target.x + target.width,
|
|
153
|
+
y2: y,
|
|
154
|
+
label: edge.label,
|
|
155
|
+
labelX: (right + left) / 2,
|
|
156
|
+
labelY: y - 7,
|
|
157
|
+
colour,
|
|
158
|
+
});
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
// Backward: leave right, run down a lane clear of every box, come back in from the right.
|
|
162
|
+
const lane = channel + (channelUses % 3) * 8;
|
|
163
|
+
channelUses += 1;
|
|
164
|
+
channel = Math.max(channel, lane);
|
|
165
|
+
const startY = source.y + source.height / 2;
|
|
166
|
+
const endY = target.y + target.height / 2;
|
|
167
|
+
shapes.push({
|
|
168
|
+
kind: 'path',
|
|
169
|
+
points: [
|
|
170
|
+
{ x: source.x + source.width, y: startY },
|
|
171
|
+
{ x: lane, y: startY },
|
|
172
|
+
{ x: lane, y: endY },
|
|
173
|
+
{ x: target.x + target.width, y: endY },
|
|
174
|
+
],
|
|
175
|
+
label: edge.label,
|
|
176
|
+
labelX: lane + 6,
|
|
177
|
+
labelY: (startY + endY) / 2,
|
|
178
|
+
colour,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
shapes.push({ kind: 'title', x: MARGIN, y: MARGIN + 12, text: diagram.title, colour: 'text' });
|
|
182
|
+
const contentRight = Math.max(MARGIN, ...Array.from(boxes.values(), (box) => box.x + box.width), ...Array.from(groupRects.values(), (rect) => rect.x + rect.width));
|
|
183
|
+
const captionLines = wrap(diagram.caption, CAPTION_CHARS);
|
|
184
|
+
const captionWidth = Math.max(...captionLines.map((line) => line.length * CAPTION_CHAR_WIDTH)) + MARGIN * 2;
|
|
185
|
+
const width = Math.max(contentRight + MARGIN + (channelUses > 0 ? CHANNEL_GAP * 3 : 0), captionWidth, 240);
|
|
186
|
+
const contentBottom = Math.max(topOffset, ...Array.from(boxes.values(), (box) => box.y + box.height), ...Array.from(groupRects.values(), (rect) => rect.y + rect.height));
|
|
187
|
+
const captionTop = contentBottom + CAPTION_GAP;
|
|
188
|
+
shapes.push({ kind: 'caption', x: MARGIN, y: captionTop, lines: captionLines, colour: captionRole(diagram.caption) });
|
|
189
|
+
const height = captionTop + captionLines.length * CAPTION_LINE + MARGIN / 2;
|
|
190
|
+
return { canvas: { width: Math.round(width), height: Math.round(height), shapes }, colours };
|
|
191
|
+
}
|
|
192
|
+
export function paint(diagram, overrides = {}) {
|
|
193
|
+
return paintWith(diagram, overrides).canvas;
|
|
194
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
/** Ours: a quiet neutral drawing, with a green/amber/red answer. */
|
|
15
|
+
const NEUTRAL_LIGHT = {
|
|
16
|
+
pass: '#2f8f5b',
|
|
17
|
+
findings: '#b7791f',
|
|
18
|
+
fail: '#c0392b',
|
|
19
|
+
undecided: '#6b7280',
|
|
20
|
+
muted: '#9aa3ad',
|
|
21
|
+
text: '#1f2933',
|
|
22
|
+
edge: '#6b7280',
|
|
23
|
+
surface: 'transparent',
|
|
24
|
+
background: '#ffffff',
|
|
25
|
+
};
|
|
26
|
+
const NEUTRAL_DARK = {
|
|
27
|
+
pass: '#4ac486',
|
|
28
|
+
findings: '#e0b050',
|
|
29
|
+
fail: '#f2705f',
|
|
30
|
+
undecided: '#9aa3ad',
|
|
31
|
+
muted: '#7d8794',
|
|
32
|
+
text: '#e6e8eb',
|
|
33
|
+
edge: '#8b949e',
|
|
34
|
+
surface: 'transparent',
|
|
35
|
+
background: '#0d1117',
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* GitHub's own palette, light: the colours a reader already knows from the place they keep the diagram.
|
|
39
|
+
* Values are Primer's foreground and canvas tokens, not invented for this plugin.
|
|
40
|
+
*/
|
|
41
|
+
const GITHUB_LIGHT = {
|
|
42
|
+
pass: '#1a7f37',
|
|
43
|
+
findings: '#9a6700',
|
|
44
|
+
fail: '#cf222e',
|
|
45
|
+
undecided: '#656d76',
|
|
46
|
+
muted: '#8c959f',
|
|
47
|
+
text: '#1f2328',
|
|
48
|
+
edge: '#656d76',
|
|
49
|
+
surface: 'transparent',
|
|
50
|
+
background: '#ffffff',
|
|
51
|
+
};
|
|
52
|
+
/** GitHub dark default: canvas #0d1117, foreground #e6edf3. */
|
|
53
|
+
const GITHUB_DARK = {
|
|
54
|
+
pass: '#3fb950',
|
|
55
|
+
findings: '#d29922',
|
|
56
|
+
fail: '#f85149',
|
|
57
|
+
undecided: '#7d8590',
|
|
58
|
+
muted: '#6e7681',
|
|
59
|
+
text: '#e6edf3',
|
|
60
|
+
edge: '#7d8590',
|
|
61
|
+
surface: 'transparent',
|
|
62
|
+
background: '#0d1117',
|
|
63
|
+
};
|
|
64
|
+
/** Black on white, for a projector or a poor print. The shape carries the meaning, not the hue. */
|
|
65
|
+
const HIGH_CONTRAST = {
|
|
66
|
+
pass: '#0a5c35',
|
|
67
|
+
findings: '#7a4300',
|
|
68
|
+
fail: '#a30000',
|
|
69
|
+
undecided: '#333333',
|
|
70
|
+
muted: '#444444',
|
|
71
|
+
text: '#000000',
|
|
72
|
+
edge: '#000000',
|
|
73
|
+
surface: 'transparent',
|
|
74
|
+
background: '#ffffff',
|
|
75
|
+
};
|
|
76
|
+
/** Greyscale throughout: a drawing that will be printed on a black-and-white printer. */
|
|
77
|
+
const PRINT = {
|
|
78
|
+
pass: '#333333',
|
|
79
|
+
findings: '#555555',
|
|
80
|
+
fail: '#000000',
|
|
81
|
+
undecided: '#666666',
|
|
82
|
+
muted: '#777777',
|
|
83
|
+
text: '#000000',
|
|
84
|
+
edge: '#333333',
|
|
85
|
+
surface: 'transparent',
|
|
86
|
+
background: '#ffffff',
|
|
87
|
+
};
|
|
88
|
+
const SCHEMES = {
|
|
89
|
+
default: { light: NEUTRAL_LIGHT, dark: NEUTRAL_DARK },
|
|
90
|
+
github: { light: GITHUB_LIGHT, dark: GITHUB_DARK },
|
|
91
|
+
'high-contrast': { light: HIGH_CONTRAST },
|
|
92
|
+
print: { light: PRINT },
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Resolve a palette.
|
|
96
|
+
*
|
|
97
|
+
* `overrides` win over everything, so a caller can retheme one role without inventing a scheme. A scheme with
|
|
98
|
+
* a dark variant follows the surface; one without stays as declared - high-contrast and print are what they
|
|
99
|
+
* say they are, on purpose. An unknown name falls back to the default palette rather than throwing, because
|
|
100
|
+
* the value can arrive from a DOM control.
|
|
101
|
+
*/
|
|
102
|
+
export function palette(overrides = {}, options = {}) {
|
|
103
|
+
const scheme = options.scheme ?? 'default';
|
|
104
|
+
const mode = options.mode ?? 'light';
|
|
105
|
+
const chosen = SCHEMES[scheme] ?? SCHEMES.default;
|
|
106
|
+
const base = mode === 'dark' ? (chosen.dark ?? chosen.light) : chosen.light;
|
|
107
|
+
return { ...base, ...overrides };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The role a verdict earns.
|
|
111
|
+
*
|
|
112
|
+
* `pass_with_findings` is deliberately not `pass`: it reads as the middle band, because a card that paints a
|
|
113
|
+
* report with warnings the same colour as a clean one has stopped carrying the answer.
|
|
114
|
+
*/
|
|
115
|
+
export function roleForVerdict(verdict) {
|
|
116
|
+
if (verdict === 'pass')
|
|
117
|
+
return 'pass';
|
|
118
|
+
if (verdict === 'pass_with_findings')
|
|
119
|
+
return 'findings';
|
|
120
|
+
if (verdict === 'fail')
|
|
121
|
+
return 'fail';
|
|
122
|
+
return 'undecided';
|
|
123
|
+
}
|
|
124
|
+
/** Every scheme by name, so a picker does not hard-code the list. */
|
|
125
|
+
export function schemeNames() {
|
|
126
|
+
return Object.keys(SCHEMES);
|
|
127
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
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
|
+
import { rankStates } from './layout.js';
|
|
16
|
+
/** Geometry constants shared by both placements. */
|
|
17
|
+
export const MARGIN = 20;
|
|
18
|
+
const NODE_HEIGHT = 34;
|
|
19
|
+
const ROW_GAP = 58;
|
|
20
|
+
const COLUMN_GAP = 30;
|
|
21
|
+
const MIN_NODE_WIDTH = 88;
|
|
22
|
+
const MAX_NODE_WIDTH = 220;
|
|
23
|
+
const CHAR_WIDTH = 7.2;
|
|
24
|
+
/** A boundary's inner padding and the strip its name sits in. */
|
|
25
|
+
const GROUP_PADDING = 10;
|
|
26
|
+
const GROUP_HEADER = 16;
|
|
27
|
+
/** Between two blocks stacked inside a boundary, and between two top-level blocks. */
|
|
28
|
+
const BLOCK_GAP = 14;
|
|
29
|
+
/** A box wide enough for its label, clamped so a long id cannot dominate the page. */
|
|
30
|
+
export const nodeWidth = (label) => Math.min(MAX_NODE_WIDTH, Math.max(MIN_NODE_WIDTH, label.length * CHAR_WIDTH + 26));
|
|
31
|
+
/** The layered placement: breadth-first rows, centred, no containment. */
|
|
32
|
+
function placeByRanks(diagram, nodes, top) {
|
|
33
|
+
const ranked = rankStates(diagram.nodes, diagram.edges, diagram.init);
|
|
34
|
+
const rows = [...ranked.rows];
|
|
35
|
+
if (ranked.unreachable.length > 0)
|
|
36
|
+
rows.push(ranked.unreachable);
|
|
37
|
+
const boxes = new Map();
|
|
38
|
+
let widest = 0;
|
|
39
|
+
rows.forEach((row, index) => {
|
|
40
|
+
const widths = row.map((id) => nodeWidth(id));
|
|
41
|
+
widest = Math.max(widest, widths.reduce((total, width) => total + width, 0) + Math.max(0, row.length - 1) * COLUMN_GAP);
|
|
42
|
+
let x = 0;
|
|
43
|
+
row.forEach((id, position) => {
|
|
44
|
+
boxes.set(id, { x, y: top + index * (NODE_HEIGHT + ROW_GAP), width: widths[position], height: NODE_HEIGHT });
|
|
45
|
+
x += widths[position] + COLUMN_GAP;
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
rows.forEach((row) => {
|
|
49
|
+
const rowWidth = row.reduce((total, id) => total + boxes.get(id).width, 0) + Math.max(0, row.length - 1) * COLUMN_GAP;
|
|
50
|
+
const offset = (widest - rowWidth) / 2;
|
|
51
|
+
for (const id of row)
|
|
52
|
+
boxes.get(id).x += offset + MARGIN;
|
|
53
|
+
});
|
|
54
|
+
return { boxes, groupRects: new Map(), width: widest + MARGIN * 2 };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Which group each id belongs to, and which group contains which.
|
|
58
|
+
*
|
|
59
|
+
* Membership is read from `members`; containment is read from `parent`, because only the parser knows where a
|
|
60
|
+
* boundary was written. A model assembled by hand may omit `parent`, and then containment is inferred from
|
|
61
|
+
* member sets - the tightest superset wins - which is the best such a model can offer.
|
|
62
|
+
*
|
|
63
|
+
* Guards, because a model is data and data can be wrong: a parent that does not exist is ignored, a group that
|
|
64
|
+
* contains itself or closes a cycle is treated as a root, and a member claimed by two groups belongs to the
|
|
65
|
+
* tightest one, so a nested boundary is not drawn around nothing.
|
|
66
|
+
*/
|
|
67
|
+
function groupTree(groups) {
|
|
68
|
+
const byId = new Map(groups.map((group) => [group.id, group]));
|
|
69
|
+
const owner = new Map();
|
|
70
|
+
const bySize = [...groups].sort((left, right) => left.members.length - right.members.length);
|
|
71
|
+
for (const group of bySize) {
|
|
72
|
+
for (const member of group.members)
|
|
73
|
+
if (!owner.has(member))
|
|
74
|
+
owner.set(member, group.id);
|
|
75
|
+
}
|
|
76
|
+
const declared = (group) => {
|
|
77
|
+
const parent = group.parent;
|
|
78
|
+
if (parent === undefined || parent === group.id)
|
|
79
|
+
return undefined;
|
|
80
|
+
return byId.has(parent) ? parent : undefined;
|
|
81
|
+
};
|
|
82
|
+
const members = new Map(groups.map((group) => [group.id, new Set(group.members)]));
|
|
83
|
+
const infer = (group) => {
|
|
84
|
+
let best;
|
|
85
|
+
for (const other of groups) {
|
|
86
|
+
if (other.id === group.id)
|
|
87
|
+
continue;
|
|
88
|
+
const outer = members.get(other.id);
|
|
89
|
+
const inner = members.get(group.id);
|
|
90
|
+
if (inner.size === 0 || inner.size >= outer.size)
|
|
91
|
+
continue;
|
|
92
|
+
let contained = true;
|
|
93
|
+
for (const member of inner) {
|
|
94
|
+
if (!outer.has(member)) {
|
|
95
|
+
contained = false;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (contained && (best === undefined || outer.size < best.size))
|
|
100
|
+
best = { id: other.id, size: outer.size };
|
|
101
|
+
}
|
|
102
|
+
return best?.id;
|
|
103
|
+
};
|
|
104
|
+
const parentOf = new Map();
|
|
105
|
+
for (const group of groups) {
|
|
106
|
+
const parent = declared(group) ?? infer(group);
|
|
107
|
+
if (parent !== undefined)
|
|
108
|
+
parentOf.set(group.id, parent);
|
|
109
|
+
}
|
|
110
|
+
// A cycle would recurse forever: walk up from each group and drop the link that closes the loop.
|
|
111
|
+
for (const group of groups) {
|
|
112
|
+
const seen = new Set([group.id]);
|
|
113
|
+
let cursor = parentOf.get(group.id);
|
|
114
|
+
while (cursor !== undefined) {
|
|
115
|
+
if (seen.has(cursor)) {
|
|
116
|
+
parentOf.delete(cursor);
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
seen.add(cursor);
|
|
120
|
+
cursor = parentOf.get(cursor);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const children = new Map();
|
|
124
|
+
const roots = [];
|
|
125
|
+
for (const group of groups) {
|
|
126
|
+
const parent = parentOf.get(group.id);
|
|
127
|
+
if (parent === undefined)
|
|
128
|
+
roots.push(group.id);
|
|
129
|
+
else
|
|
130
|
+
children.set(parent, [...(children.get(parent) ?? []), group.id]);
|
|
131
|
+
}
|
|
132
|
+
return { owner, children, roots };
|
|
133
|
+
}
|
|
134
|
+
/** The tree placement: a boundary is a block, its contents stack inside it, the top level stacks. */
|
|
135
|
+
function placeByGroups(diagram, nodes, top) {
|
|
136
|
+
const groups = diagram.groups;
|
|
137
|
+
const byId = new Map(groups.map((group) => [group.id, group]));
|
|
138
|
+
const { owner, children, roots } = groupTree(groups);
|
|
139
|
+
// Nodes that belong to no group, and the first-seen order that keeps a source's own ordering visible.
|
|
140
|
+
const order = new Map(nodes.map((node, index) => [node.id, index]));
|
|
141
|
+
const loose = nodes.filter((node) => !owner.has(node.id)).map((node) => node.id);
|
|
142
|
+
const nodeIds = new Set(nodes.map((node) => node.id));
|
|
143
|
+
const rawContents = (groupId) => {
|
|
144
|
+
const group = byId.get(groupId);
|
|
145
|
+
const own = [...new Set(group.members)].filter((member) => owner.get(member) === groupId && nodeIds.has(member));
|
|
146
|
+
const nested = children.get(groupId) ?? [];
|
|
147
|
+
return [...own, ...nested];
|
|
148
|
+
};
|
|
149
|
+
// A boundary with nothing inside it is not drawn: an empty box claims a containment the source never stated.
|
|
150
|
+
const contents = (groupId) => rawContents(groupId).filter((child) => !byId.has(child) || rawContents(child).length > 0);
|
|
151
|
+
const measure = (id) => {
|
|
152
|
+
if (!byId.has(id))
|
|
153
|
+
return { width: nodeWidth(id), height: NODE_HEIGHT };
|
|
154
|
+
const items = contents(id);
|
|
155
|
+
const sizes = items.map(measure);
|
|
156
|
+
return {
|
|
157
|
+
width: Math.max(MIN_NODE_WIDTH, ...sizes.map((size) => size.width)) + GROUP_PADDING * 2,
|
|
158
|
+
height: GROUP_HEADER + GROUP_PADDING * 2 + sizes.reduce((total, size) => total + size.height, 0) + Math.max(0, items.length - 1) * BLOCK_GAP,
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
const boxes = new Map();
|
|
162
|
+
const groupRects = new Map();
|
|
163
|
+
const place = (id, x, y) => {
|
|
164
|
+
const size = measure(id);
|
|
165
|
+
if (!byId.has(id)) {
|
|
166
|
+
boxes.set(id, { x, y, width: size.width, height: NODE_HEIGHT });
|
|
167
|
+
return size;
|
|
168
|
+
}
|
|
169
|
+
groupRects.set(id, { x, y, width: size.width, height: size.height });
|
|
170
|
+
let cursor = y + GROUP_HEADER + GROUP_PADDING;
|
|
171
|
+
const inner = size.width - GROUP_PADDING * 2;
|
|
172
|
+
const ordered = [...contents(id)].sort((left, right) => firstIndex(left) - firstIndex(right));
|
|
173
|
+
for (const child of ordered) {
|
|
174
|
+
const childSize = measure(child);
|
|
175
|
+
place(child, x + GROUP_PADDING + (inner - childSize.width) / 2, cursor);
|
|
176
|
+
cursor += childSize.height + BLOCK_GAP;
|
|
177
|
+
}
|
|
178
|
+
return size;
|
|
179
|
+
};
|
|
180
|
+
// Top level: loose nodes and root groups, in the order the source named them.
|
|
181
|
+
const visibleRoots = roots.filter((id) => contents(id).length > 0);
|
|
182
|
+
// A block takes the earliest position of anything inside it, so a group is drawn where its first member was
|
|
183
|
+
// named - not after every loose node, which put a downstream box above the packages it depends on.
|
|
184
|
+
const firstIndex = (id) => {
|
|
185
|
+
// `order` holds every node, and a block is only ever asked about when it has contents.
|
|
186
|
+
if (!byId.has(id))
|
|
187
|
+
return order.get(id);
|
|
188
|
+
return Math.min(...contents(id).map(firstIndex));
|
|
189
|
+
};
|
|
190
|
+
const topItems = [...loose, ...visibleRoots].sort((left, right) => firstIndex(left) - firstIndex(right));
|
|
191
|
+
const sizes = topItems.map(measure);
|
|
192
|
+
const width = Math.max(MIN_NODE_WIDTH, ...sizes.map((size) => size.width));
|
|
193
|
+
let cursor = top;
|
|
194
|
+
for (const item of topItems) {
|
|
195
|
+
const size = measure(item);
|
|
196
|
+
place(item, MARGIN + (width - size.width) / 2, cursor);
|
|
197
|
+
cursor += size.height + BLOCK_GAP;
|
|
198
|
+
}
|
|
199
|
+
return { boxes, groupRects, width: width + MARGIN * 2 };
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Place every box of a diagram.
|
|
203
|
+
*
|
|
204
|
+
* The choice of placement is a property of the source, not of the caller: a drawing that declares boundaries is
|
|
205
|
+
* read as containers and gets the tree; one that does not is read as a machine and gets the ranks.
|
|
206
|
+
*/
|
|
207
|
+
export function place(diagram, top) {
|
|
208
|
+
const groups = diagram.groups ?? [];
|
|
209
|
+
return groups.length > 0 ? placeByGroups(diagram, diagram.nodes, top) : placeByRanks(diagram, diagram.nodes, top);
|
|
210
|
+
}
|