@squinch/core 0.1.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/LICENSE +202 -0
- package/NOTICE +41 -0
- package/dist/api.d.ts +91 -0
- package/dist/api.js +232 -0
- package/dist/browser.d.ts +3 -0
- package/dist/browser.js +9 -0
- package/dist/diff/diff.d.ts +30 -0
- package/dist/diff/diff.js +365 -0
- package/dist/fonts.generated.d.ts +1 -0
- package/dist/fonts.generated.js +6 -0
- package/dist/grammar/parser.js +22 -0
- package/dist/grammar/parser.terms.js +115 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/layout/layout.d.ts +197 -0
- package/dist/layout/layout.js +1721 -0
- package/dist/metrics.d.ts +20 -0
- package/dist/metrics.generated.d.ts +4 -0
- package/dist/metrics.generated.js +4 -0
- package/dist/metrics.js +57 -0
- package/dist/model/build.d.ts +8 -0
- package/dist/model/build.js +1343 -0
- package/dist/model/packs.d.ts +13 -0
- package/dist/model/packs.js +29 -0
- package/dist/model/source.d.ts +10 -0
- package/dist/model/source.js +28 -0
- package/dist/model/suggest.d.ts +2 -0
- package/dist/model/suggest.js +24 -0
- package/dist/model/types.d.ts +226 -0
- package/dist/model/types.js +24 -0
- package/dist/packs/node-fs.d.ts +1 -0
- package/dist/packs/node-fs.js +37 -0
- package/dist/packs/registry.d.ts +61 -0
- package/dist/packs/registry.js +122 -0
- package/dist/packs/sanitize.d.ts +12 -0
- package/dist/packs/sanitize.js +127 -0
- package/dist/packs/sysGlyphs.d.ts +2 -0
- package/dist/packs/sysGlyphs.js +21 -0
- package/dist/render/adaptive.d.ts +13 -0
- package/dist/render/adaptive.js +112 -0
- package/dist/render/html/runtime.d.ts +1 -0
- package/dist/render/html/runtime.generated.d.ts +1 -0
- package/dist/render/html/runtime.generated.js +6 -0
- package/dist/render/html/runtime.js +362 -0
- package/dist/render/html.d.ts +39 -0
- package/dist/render/html.js +235 -0
- package/dist/render/svg.d.ts +75 -0
- package/dist/render/svg.js +1403 -0
- package/dist/render/validate.d.ts +4 -0
- package/dist/render/validate.js +9 -0
- package/dist/themes/index.d.ts +84 -0
- package/dist/themes/index.js +90 -0
- package/dist/view/dive.d.ts +55 -0
- package/dist/view/dive.js +57 -0
- package/dist/view/navigate.d.ts +38 -0
- package/dist/view/navigate.js +81 -0
- package/dist/view/resolve.d.ts +92 -0
- package/dist/view/resolve.js +591 -0
- package/fonts/inter-400.ttf +0 -0
- package/fonts/inter-500.ttf +0 -0
- package/fonts/inter-600.ttf +0 -0
- package/fonts/mono-400.ttf +0 -0
- package/metrics.json +510 -0
- package/package.json +89 -0
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
/** `->`/`~>` point one way, `<->` both, `--` neither. */
|
|
2
|
+
const headsOf = (a) => a === "<->" ? "both" : a === "--" ? "none" : "one";
|
|
3
|
+
/** Effective animation for a declared edge. Values were validated at build
|
|
4
|
+
* time, so anything present is trusted here. */
|
|
5
|
+
const animateOf = (e) => {
|
|
6
|
+
const a = e.attrs.animate;
|
|
7
|
+
if (a === "false")
|
|
8
|
+
return undefined;
|
|
9
|
+
if (a)
|
|
10
|
+
return a;
|
|
11
|
+
return e.arrow === "~>" ? "flow" : undefined;
|
|
12
|
+
};
|
|
13
|
+
/** Effective dash pattern. `packets` supplies its own, so it needs no style;
|
|
14
|
+
* `solid` normalizes to undefined (it is the sync default, and build errors
|
|
15
|
+
* it on async edges before this runs). */
|
|
16
|
+
const styleOf = (e) => {
|
|
17
|
+
const s = e.attrs.style;
|
|
18
|
+
if (s === "dashed" || s === "dotted")
|
|
19
|
+
return s;
|
|
20
|
+
return e.arrow === "~>" ? "dashed" : undefined;
|
|
21
|
+
};
|
|
22
|
+
const parentOf = (p) => (p.includes(".") ? p.slice(0, p.lastIndexOf(".")) : "");
|
|
23
|
+
const topOf = (p) => p.split(".")[0];
|
|
24
|
+
export function resolveView(model, view) {
|
|
25
|
+
const diagnostics = [];
|
|
26
|
+
const scope = view.scope ?? "";
|
|
27
|
+
const effectiveTags = (path) => {
|
|
28
|
+
const own = model.nodes.get(path)?.tags ?? model.containers.get(path)?.tags ?? [];
|
|
29
|
+
const tags = [...own];
|
|
30
|
+
let p = parentOf(path);
|
|
31
|
+
while (p) {
|
|
32
|
+
tags.push(...(model.containers.get(p)?.tags ?? []));
|
|
33
|
+
p = parentOf(p);
|
|
34
|
+
}
|
|
35
|
+
return [...new Set(tags)];
|
|
36
|
+
};
|
|
37
|
+
// a tag target expands to every element whose EFFECTIVE tags match —
|
|
38
|
+
// inherited tags count (SPEC: tags inherit to everything inside).
|
|
39
|
+
// Deterministic order: containers in declaration order, then nodes.
|
|
40
|
+
const tagMatches = (tag) => [
|
|
41
|
+
...[...model.containers.keys()].filter((p) => p && effectiveTags(p).includes(tag)),
|
|
42
|
+
...[...model.nodes.keys()].filter((p) => effectiveTags(p).includes(tag)),
|
|
43
|
+
];
|
|
44
|
+
// ── 1. scope children ────────────────────────────────────────────────────
|
|
45
|
+
let visible = scope
|
|
46
|
+
? [...(model.containers.get(scope)?.children ?? [])]
|
|
47
|
+
: [
|
|
48
|
+
...[...model.nodes.keys()].filter((p) => !p.includes(".")),
|
|
49
|
+
...[...model.containers.keys()].filter((p) => !p.includes(".")),
|
|
50
|
+
];
|
|
51
|
+
// expand: inline the container's children inside a rendered frame.
|
|
52
|
+
// One level only, by the rule-stack design (§5 rule 1): frames do not nest —
|
|
53
|
+
// unless the view says `expand *`, the one deliberate ladder, which opens
|
|
54
|
+
// every visible container to leaf depth and lets the frames it creates nest.
|
|
55
|
+
// For explicit expands the rule stands: the layout's single-level flattening
|
|
56
|
+
// of a *partial* ladder would leave "which levels am I looking at?"
|
|
57
|
+
// ambiguous, so the depth an author wants piecemeal is a deeper view.
|
|
58
|
+
const frames = [];
|
|
59
|
+
const frameOf = new Map(); // child path → frame path
|
|
60
|
+
if (view.expandStar) {
|
|
61
|
+
if (view.expand.length)
|
|
62
|
+
diagnostics.push({
|
|
63
|
+
severity: "warning",
|
|
64
|
+
message: "`expand *` already opens every container — the explicit `expand` lines are redundant",
|
|
65
|
+
fix: "drop the explicit expand lines",
|
|
66
|
+
loc: view.loc,
|
|
67
|
+
});
|
|
68
|
+
// Recursively open everything. An empty container stays a card: a
|
|
69
|
+
// childless frame is the 0×0 ELK failure the one-level rule was built
|
|
70
|
+
// against, and the "everything" view must never silently drop an element.
|
|
71
|
+
const opened = [];
|
|
72
|
+
const open = (path, parent) => {
|
|
73
|
+
const c = model.containers.get(path);
|
|
74
|
+
if (!c || c.children.length === 0) {
|
|
75
|
+
opened.push(path);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
frames.push({ path, label: c.label ?? c.name, frame: parent, color: c.color });
|
|
79
|
+
for (const child of c.children) {
|
|
80
|
+
frameOf.set(child, path);
|
|
81
|
+
open(child, path);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
for (const p of visible)
|
|
85
|
+
open(p);
|
|
86
|
+
visible = opened;
|
|
87
|
+
if (frames.length === 0)
|
|
88
|
+
diagnostics.push({
|
|
89
|
+
severity: "warning",
|
|
90
|
+
message: "`expand *` opened nothing — no containers are visible here",
|
|
91
|
+
fix: "drop the line; every element is already at full depth",
|
|
92
|
+
loc: view.loc,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
const expandSet = new Set(view.expand);
|
|
97
|
+
for (const ex of view.expand) {
|
|
98
|
+
let outer;
|
|
99
|
+
for (let p = parentOf(ex); p; p = parentOf(p))
|
|
100
|
+
if (expandSet.has(p))
|
|
101
|
+
outer = p;
|
|
102
|
+
if (outer) {
|
|
103
|
+
diagnostics.push({
|
|
104
|
+
severity: "error",
|
|
105
|
+
message: `expand \`${ex}\` sits inside \`${outer}\`, which this view also expands — a view opens one level of depth`,
|
|
106
|
+
fix: `give the inner container its own view: \`scope ${outer}\` + \`expand ${ex.slice(outer.length + 1)}\` — its card here dives there. Or open every level at once with \`expand *\``,
|
|
107
|
+
loc: view.loc,
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const i = visible.indexOf(ex);
|
|
112
|
+
const c = model.containers.get(ex);
|
|
113
|
+
if (i >= 0 && c) {
|
|
114
|
+
frames.push({ path: ex, label: c.label ?? c.name, color: c.color });
|
|
115
|
+
for (const child of c.children)
|
|
116
|
+
frameOf.set(child, ex);
|
|
117
|
+
visible.splice(i, 1, ...c.children);
|
|
118
|
+
}
|
|
119
|
+
else if (!c && model.nodes.has(ex)) {
|
|
120
|
+
diagnostics.push({
|
|
121
|
+
severity: "warning",
|
|
122
|
+
message: `expand \`${ex}\` targets a leaf — only containers open`,
|
|
123
|
+
fix: `drop the line; a leaf is already drawn at full depth`,
|
|
124
|
+
loc: view.loc,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
else if (c) {
|
|
128
|
+
diagnostics.push({
|
|
129
|
+
severity: "warning",
|
|
130
|
+
message: `expand \`${ex}\` is not among the scope's direct children — nothing opens`,
|
|
131
|
+
fix: `\`expand\` opens the scope's own children; an outside element is drawn at depth with \`detail ${ex}\``,
|
|
132
|
+
loc: view.loc,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const visSet = () => new Set(visible);
|
|
138
|
+
/** Lift targets: the visible set plus expanded frames that still hold at
|
|
139
|
+
* least one visible member. An expanded container is spliced out of
|
|
140
|
+
* `visible`, but its frame is a real drawable entity — an edge naming the
|
|
141
|
+
* container attaches to the frame border (layout hands ELK the compound
|
|
142
|
+
* id, layout.ts's "ports live on leaves" comment). A frame `only` or
|
|
143
|
+
* `exclude` has emptied is dead: it must neither render nor catch lifts. */
|
|
144
|
+
const liveFramePaths = () => frames.filter((f) => visible.some((p) => p.startsWith(`${f.path}.`))).map((f) => f.path);
|
|
145
|
+
// ── 3. only: the view's filter ───────────────────────────────────────────
|
|
146
|
+
// `scope` answers *where I stand*; `only` answers *which of that I keep*.
|
|
147
|
+
// The language had no second axis, so a cross-cutting concern — the entire
|
|
148
|
+
// reason tags exist — could not be selected at all: `include #pci` adds to a
|
|
149
|
+
// set that already contains it, `highlight` decorates without removing, and
|
|
150
|
+
// an auditor was left enumerating the complement by id.
|
|
151
|
+
//
|
|
152
|
+
// It runs after `expand` so it filters an expanded interior too, and before
|
|
153
|
+
// context so neighbours are earned against the *reduced* interior. That
|
|
154
|
+
// second ordering is not a special case — it is the existing rule that
|
|
155
|
+
// derived content follows visibility. Shrink the interior and fewer edges
|
|
156
|
+
// cross, so fewer neighbours qualify, automatically.
|
|
157
|
+
//
|
|
158
|
+
// A container survives if it or anything beneath it matches: at a high
|
|
159
|
+
// altitude the tagged things are usually leaves inside the cards, and a
|
|
160
|
+
// filter that dropped every card because the card itself is untagged would
|
|
161
|
+
// render an empty diagram for the most natural way to ask the question.
|
|
162
|
+
if (view.only?.length) {
|
|
163
|
+
const keep = new Set();
|
|
164
|
+
for (const on of view.only) {
|
|
165
|
+
const targets = typeof on === "string" ? [on] : tagMatches(on.tag);
|
|
166
|
+
if (targets.length === 0)
|
|
167
|
+
diagnostics.push({
|
|
168
|
+
severity: "warning",
|
|
169
|
+
message: typeof on === "string"
|
|
170
|
+
? `only \`${on}\`: no such element`
|
|
171
|
+
: `only #${on.tag}: nothing is tagged #${on.tag}`,
|
|
172
|
+
loc: view.loc,
|
|
173
|
+
});
|
|
174
|
+
// a match keeps itself, every visible ancestor holding it, and every
|
|
175
|
+
// visible descendant inside it — naming a container in an expanded view
|
|
176
|
+
// must keep its opened interior, not strand an empty frame
|
|
177
|
+
for (const t of targets)
|
|
178
|
+
for (const p of visible)
|
|
179
|
+
if (p === t || t.startsWith(`${p}.`) || p.startsWith(`${t}.`))
|
|
180
|
+
keep.add(p);
|
|
181
|
+
}
|
|
182
|
+
const dropped = visible.filter((p) => !keep.has(p));
|
|
183
|
+
visible = visible.filter((p) => keep.has(p));
|
|
184
|
+
if (!visible.length)
|
|
185
|
+
diagnostics.push({
|
|
186
|
+
severity: "warning",
|
|
187
|
+
message: "`only` filtered out everything — this view renders empty",
|
|
188
|
+
fix: "check the ids and tags; `only` keeps matches, it does not add them",
|
|
189
|
+
loc: view.loc,
|
|
190
|
+
});
|
|
191
|
+
else if (!dropped.length)
|
|
192
|
+
diagnostics.push({
|
|
193
|
+
severity: "warning",
|
|
194
|
+
message: "`only` changed nothing — everything here already matches",
|
|
195
|
+
fix: "drop the line, or narrow it further",
|
|
196
|
+
loc: view.loc,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/** Nearest visible ancestor-or-self, given the current visible set. */
|
|
200
|
+
const liftIn = (path, v) => {
|
|
201
|
+
let p = path;
|
|
202
|
+
while (p) {
|
|
203
|
+
if (v.has(p))
|
|
204
|
+
return p;
|
|
205
|
+
p = parentOf(p);
|
|
206
|
+
}
|
|
207
|
+
return undefined;
|
|
208
|
+
};
|
|
209
|
+
// ── 4. context neighbors (top-level lift, earned against the filtered interior) ──────────────────
|
|
210
|
+
const contextSet = new Set();
|
|
211
|
+
if (view.context === "auto") {
|
|
212
|
+
// frames count as inside: `client -> cluster` with the cluster expanded
|
|
213
|
+
// must still earn `client` its context card, exactly as a leaf target would
|
|
214
|
+
const v = new Set([...visible, ...liveFramePaths()]);
|
|
215
|
+
for (const e of model.edges) {
|
|
216
|
+
const fIn = liftIn(e.from, v);
|
|
217
|
+
const tIn = liftIn(e.to, v);
|
|
218
|
+
if (!!fIn === !!tIn)
|
|
219
|
+
continue; // both in or both out
|
|
220
|
+
const outside = fIn ? e.to : e.from;
|
|
221
|
+
const candidate = liftIn(outside, v) ?? topOf(outside);
|
|
222
|
+
// Context shows how the scope connects *outward*, so the scope itself can
|
|
223
|
+
// never be its own neighbour. Before `only` this was unreachable: the one
|
|
224
|
+
// way to lose a sibling was `exclude`, which runs after this. Filtering
|
|
225
|
+
// the interior makes it reachable — an edge to a filtered-out sibling
|
|
226
|
+
// lifts to the container we are standing in, and the view would draw a
|
|
227
|
+
// muted card of itself. A sibling removed on purpose is simply gone.
|
|
228
|
+
if (scope && (candidate === scope || scope.startsWith(`${candidate}.`)))
|
|
229
|
+
continue;
|
|
230
|
+
if (!v.has(candidate))
|
|
231
|
+
contextSet.add(candidate);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
visible.push(...contextSet);
|
|
235
|
+
// Explicitly named elements are exempt from the context rules below: they
|
|
236
|
+
// never have to EARN their spot, and edges among them render even though both
|
|
237
|
+
// endpoints are context-styled — the user asked for them.
|
|
238
|
+
const explicitSet = new Set();
|
|
239
|
+
// ── 5. detail: draw an outside element at its own depth ──────────────────
|
|
240
|
+
// This was `include`'s second, unadvertised job. One verb meaning both "add
|
|
241
|
+
// this element" and "…and redraw its whole branch at a different altitude" is
|
|
242
|
+
// why `include` could never be redefined to narrow: flipping it would have
|
|
243
|
+
// turned every altitude override into "delete the rest of the diagram". Split
|
|
244
|
+
// out, each verb has exactly one job and `only` above became possible.
|
|
245
|
+
for (const d of view.detail ?? []) {
|
|
246
|
+
if (!visible.includes(d)) {
|
|
247
|
+
visible.push(d);
|
|
248
|
+
if (!scope || !d.startsWith(`${scope}.`))
|
|
249
|
+
contextSet.add(d);
|
|
250
|
+
}
|
|
251
|
+
explicitSet.add(d);
|
|
252
|
+
const top = topOf(d);
|
|
253
|
+
if (contextSet.has(top) && top !== d) {
|
|
254
|
+
contextSet.delete(top);
|
|
255
|
+
visible = visible.filter((p) => p !== top);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// ── 6. include: purely additive ──────────────────────────────────────────
|
|
259
|
+
for (const inc of view.include) {
|
|
260
|
+
const targets = typeof inc === "string" ? [inc] : tagMatches(inc.tag);
|
|
261
|
+
if (typeof inc !== "string" && targets.length === 0)
|
|
262
|
+
diagnostics.push({
|
|
263
|
+
severity: "warning",
|
|
264
|
+
message: `include #${inc.tag}: nothing is tagged #${inc.tag}`,
|
|
265
|
+
loc: view.loc,
|
|
266
|
+
});
|
|
267
|
+
let added = 0;
|
|
268
|
+
for (const target of targets) {
|
|
269
|
+
explicitSet.add(target);
|
|
270
|
+
if (visible.includes(target))
|
|
271
|
+
continue;
|
|
272
|
+
added++;
|
|
273
|
+
visible.push(target);
|
|
274
|
+
if (!scope || !target.startsWith(`${scope}.`))
|
|
275
|
+
contextSet.add(target);
|
|
276
|
+
// The element is now drawn *and* so is the top-level card standing in for
|
|
277
|
+
// its branch — two cards for one thing. That is what `detail` is for.
|
|
278
|
+
const top = topOf(target);
|
|
279
|
+
if (contextSet.has(top) && top !== target)
|
|
280
|
+
diagnostics.push({
|
|
281
|
+
severity: "warning",
|
|
282
|
+
message: `\`${target}\` is included, but \`${top}\` is already here as a context card`,
|
|
283
|
+
fix: `use \`detail ${target}\` to draw it at that depth instead of \`${top}\``,
|
|
284
|
+
loc: view.loc,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
// `include` ADDS to a view (SPEC §5 rule stack); it cannot narrow one.
|
|
288
|
+
// Reading it as a filter is the natural mistake — a cold-run agent wrote
|
|
289
|
+
// `include #pci` for "show only the PCI parts", got a clean check and an
|
|
290
|
+
// unfiltered diagram. Silence there is the bug.
|
|
291
|
+
if (added === 0 && targets.length > 0)
|
|
292
|
+
diagnostics.push({
|
|
293
|
+
severity: "warning",
|
|
294
|
+
message: typeof inc === "string"
|
|
295
|
+
? `include \`${inc}\` changed nothing — it is already visible here`
|
|
296
|
+
: `include #${inc.tag} changed nothing — every match is already visible here`,
|
|
297
|
+
fix: typeof inc === "string"
|
|
298
|
+
? "`include` adds elements to a view; drop the line, or did you mean `exclude`?"
|
|
299
|
+
: `\`include\` adds elements, it cannot narrow a view. For only the ` +
|
|
300
|
+
`#${inc.tag} parts use \`only #${inc.tag}\`; to keep the whole picture with ` +
|
|
301
|
+
`those emphasised use \`highlight #${inc.tag}\``,
|
|
302
|
+
loc: view.loc,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
// ── 7. exclude wins last (removes whole subtrees) ─────────────────────────
|
|
306
|
+
for (const exc of view.exclude) {
|
|
307
|
+
const targets = typeof exc === "string" ? [exc] : tagMatches(exc.tag);
|
|
308
|
+
if (typeof exc !== "string" && targets.length === 0)
|
|
309
|
+
diagnostics.push({
|
|
310
|
+
severity: "warning",
|
|
311
|
+
message: `exclude #${exc.tag}: nothing is tagged #${exc.tag}`,
|
|
312
|
+
loc: view.loc,
|
|
313
|
+
});
|
|
314
|
+
for (const target of targets) {
|
|
315
|
+
visible = visible.filter((p) => p !== target && !p.startsWith(`${target}.`));
|
|
316
|
+
contextSet.delete(target);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// ── 5. edge lifting + aggregation over the final visible set ─────────────
|
|
320
|
+
// Native edges (endpoints unchanged by lifting) always render individually —
|
|
321
|
+
// parallel edges are legal and distinct at their own altitude. Only LIFTED
|
|
322
|
+
// edges aggregate into count-badged neutrals.
|
|
323
|
+
const liveFrames = frames.filter((f) => visible.some((p) => p.startsWith(`${f.path}.`)));
|
|
324
|
+
const frameSet = new Set(liveFrames.map((f) => f.path));
|
|
325
|
+
const v = new Set([...visible, ...frameSet]);
|
|
326
|
+
const groups = new Map();
|
|
327
|
+
const edges = [];
|
|
328
|
+
const groupSlot = new Map(); // key → index in edges[]
|
|
329
|
+
for (const e of model.edges) {
|
|
330
|
+
const f = liftIn(e.from, v);
|
|
331
|
+
const t = liftIn(e.to, v);
|
|
332
|
+
// `f === t` covers two different things. An edge whose ends both lift into
|
|
333
|
+
// one card is genuinely internal at this altitude and drops on purpose. An
|
|
334
|
+
// edge a node draws to *itself* is not: it parsed, validated, sits in the
|
|
335
|
+
// model, and then vanished with nothing said — and a self-edge is the one
|
|
336
|
+
// shape where "from and to landed in the same place" is what the author
|
|
337
|
+
// wrote rather than a consequence of altitude.
|
|
338
|
+
if (f && t && f === t && e.from === e.to) {
|
|
339
|
+
diagnostics.push({
|
|
340
|
+
severity: "warning",
|
|
341
|
+
message: `\`${e.from}\` connects to itself — a self-edge is not drawn`,
|
|
342
|
+
fix: `say it on the node instead: a \`note right-of ${e.from} "${e.label ?? "…"}"\`, `
|
|
343
|
+
+ `or fold it into the label`,
|
|
344
|
+
loc: view.loc,
|
|
345
|
+
});
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (!f || !t || f === t)
|
|
349
|
+
continue;
|
|
350
|
+
// One end is an expanded frame and the other lifted to something inside
|
|
351
|
+
// it: internal at this altitude — the same silent drop as both ends
|
|
352
|
+
// lifting into one card (SPEC §5 lifting), just with the card held open.
|
|
353
|
+
if (frameSet.has(f) && t.startsWith(`${f}.`))
|
|
354
|
+
continue;
|
|
355
|
+
if (frameSet.has(t) && f.startsWith(`${t}.`))
|
|
356
|
+
continue;
|
|
357
|
+
const lifted = f !== e.from || t !== e.to;
|
|
358
|
+
if (!lifted) {
|
|
359
|
+
edges.push({
|
|
360
|
+
id: e.id, from: f, to: t, label: e.label, async: e.arrow === "~>",
|
|
361
|
+
animate: animateOf(e), style: styleOf(e), count: 1,
|
|
362
|
+
tags: e.tags, color: e.color, heads: headsOf(e.arrow),
|
|
363
|
+
});
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const key = `${f}|${t}`;
|
|
367
|
+
if (!groups.has(key)) {
|
|
368
|
+
groups.set(key, { edges: [], from: f, to: t });
|
|
369
|
+
groupSlot.set(key, edges.push(null) - 1); // reserve slot in declaration order
|
|
370
|
+
}
|
|
371
|
+
groups.get(key).edges.push(e);
|
|
372
|
+
}
|
|
373
|
+
for (const [key, g] of groups) {
|
|
374
|
+
const slot = groupSlot.get(key);
|
|
375
|
+
if (g.edges.length === 1) {
|
|
376
|
+
const e = g.edges[0];
|
|
377
|
+
edges[slot] = {
|
|
378
|
+
id: e.id, from: g.from, to: g.to, label: e.label, async: e.arrow === "~>",
|
|
379
|
+
animate: animateOf(e), style: styleOf(e), count: 1,
|
|
380
|
+
tags: e.tags, color: e.color, heads: headsOf(e.arrow),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
// A trunk only claims styling every member agrees on (same rule as
|
|
385
|
+
// `heads` below): all-agree keeps the animation and pattern, any
|
|
386
|
+
// disagreement falls back to neutral rather than asserting something
|
|
387
|
+
// only some constituents said. SPEC §lifting rule 4 states this.
|
|
388
|
+
const agreedAnimate = animateOf(g.edges[0]);
|
|
389
|
+
const agreedStyle = styleOf(g.edges[0]);
|
|
390
|
+
edges[slot] = {
|
|
391
|
+
id: `agg:${g.from}|${g.to}`,
|
|
392
|
+
from: g.from, to: g.to,
|
|
393
|
+
label: `×${g.edges.length}`,
|
|
394
|
+
async: g.edges.every((e) => e.arrow === "~>"),
|
|
395
|
+
animate: g.edges.every((e) => animateOf(e) === agreedAnimate) ? agreedAnimate : undefined,
|
|
396
|
+
style: g.edges.every((e) => styleOf(e) === agreedStyle) ? agreedStyle : undefined,
|
|
397
|
+
count: g.edges.length,
|
|
398
|
+
tags: [...new Set(g.edges.flatMap((e) => e.tags))],
|
|
399
|
+
color: g.edges.every((e) => e.color === g.edges[0].color) ? g.edges[0].color : undefined,
|
|
400
|
+
// A trunk only claims a shape every member agrees on; a mixed bundle
|
|
401
|
+
// falls back to the plain arrow rather than asserting something false.
|
|
402
|
+
heads: g.edges.every((e) => e.arrow === "<->") ? "both"
|
|
403
|
+
: g.edges.every((e) => e.arrow === "--") ? "none" : "one",
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// Context exists to show how the scope connects outward. An edge between two
|
|
408
|
+
// *outsiders* is their business, not this view's — suppress it, or zooming
|
|
409
|
+
// into one service drags in the whole neighbourhood's wiring.
|
|
410
|
+
const scopeEdges = edges.filter((e) => !(contextSet.has(e.from) && contextSet.has(e.to) &&
|
|
411
|
+
!explicitSet.has(e.from) && !explicitSet.has(e.to)));
|
|
412
|
+
// context cards must earn their spot: drop any without a surviving edge —
|
|
413
|
+
// except explicit includes, which were asked for by name
|
|
414
|
+
for (const c of [...contextSet]) {
|
|
415
|
+
if (explicitSet.has(c))
|
|
416
|
+
continue;
|
|
417
|
+
if (!scopeEdges.some((e) => e.from === c || e.to === c)) {
|
|
418
|
+
contextSet.delete(c);
|
|
419
|
+
visible = visible.filter((p) => p !== c);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// frames are drawable endpoints too — an edge lifted to an expanded
|
|
423
|
+
// container attaches to its frame border
|
|
424
|
+
const drawable = new Set([...visible, ...frameSet]);
|
|
425
|
+
const finalEdges = scopeEdges.filter((e) => drawable.has(e.from) && drawable.has(e.to));
|
|
426
|
+
// ── 6. materialize nodes ──────────────────────────────────────────────────
|
|
427
|
+
const leafDescendants = (path) => {
|
|
428
|
+
const c = model.containers.get(path);
|
|
429
|
+
if (!c)
|
|
430
|
+
return [path];
|
|
431
|
+
return c.children.flatMap(leafDescendants);
|
|
432
|
+
};
|
|
433
|
+
const nodes = visible.map((path) => {
|
|
434
|
+
const isContext = contextSet.has(path);
|
|
435
|
+
const container = model.containers.get(path);
|
|
436
|
+
if (container) {
|
|
437
|
+
const leaves = leafDescendants(path);
|
|
438
|
+
const previewMode = container.attrs["preview"] ?? "auto";
|
|
439
|
+
const icons = leaves
|
|
440
|
+
.map((l) => model.nodes.get(l)?.icon)
|
|
441
|
+
.filter((i) => !!i);
|
|
442
|
+
const preview = previewMode === "none" ? [] : icons.slice(0, 3);
|
|
443
|
+
const glyphRef = container.attrs["glyph"];
|
|
444
|
+
const glyph = glyphRef?.includes("/")
|
|
445
|
+
? { pack: glyphRef.split("/")[0], id: glyphRef.split("/")[1] }
|
|
446
|
+
: undefined;
|
|
447
|
+
// The card's own mark. Authored `icon:` wins; otherwise the first leaf
|
|
448
|
+
// icon stands in, so a system that never named one still gets a plate
|
|
449
|
+
// instead of a bare label — the same icon the preview strip leads with,
|
|
450
|
+
// which is what a reader already associates with that card.
|
|
451
|
+
const iconRef = container.attrs["icon"];
|
|
452
|
+
const icon = iconRef?.includes("/")
|
|
453
|
+
? { pack: iconRef.split("/")[0], id: iconRef.split("/")[1] }
|
|
454
|
+
: icons[0];
|
|
455
|
+
return {
|
|
456
|
+
path,
|
|
457
|
+
kind: isContext ? "context-card" : "card",
|
|
458
|
+
label: container.label ?? container.name,
|
|
459
|
+
glyph,
|
|
460
|
+
icon,
|
|
461
|
+
more: icons.length - preview.length || undefined,
|
|
462
|
+
domain: container.attrs["domain"],
|
|
463
|
+
tagline: container.attrs["description"] ??
|
|
464
|
+
`${leaves.length} component${leaves.length === 1 ? "" : "s"}`,
|
|
465
|
+
preview,
|
|
466
|
+
tags: effectiveTags(path),
|
|
467
|
+
external: container.kinds.includes("external") || undefined,
|
|
468
|
+
frame: frameOf.get(path),
|
|
469
|
+
color: container.color,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
const n = model.nodes.get(path);
|
|
473
|
+
// Lenient like the container glyph split: validation happened at check
|
|
474
|
+
// time, and a half-typed value in the editor must not throw here.
|
|
475
|
+
const badgeRef = n.attrs["badge"];
|
|
476
|
+
const badge = badgeRef?.includes("/")
|
|
477
|
+
? { pack: badgeRef.split("/")[0], id: badgeRef.split("/")[1] }
|
|
478
|
+
: undefined;
|
|
479
|
+
return {
|
|
480
|
+
path,
|
|
481
|
+
kind: isContext ? "context-leaf" : n.kinds.includes("person") ? "person" : "leaf",
|
|
482
|
+
label: n.label,
|
|
483
|
+
icon: n.icon,
|
|
484
|
+
badge,
|
|
485
|
+
preview: [],
|
|
486
|
+
tags: effectiveTags(path),
|
|
487
|
+
external: n.kinds.includes("external") || undefined,
|
|
488
|
+
description: n.description,
|
|
489
|
+
frame: frameOf.get(path),
|
|
490
|
+
color: n.color,
|
|
491
|
+
};
|
|
492
|
+
});
|
|
493
|
+
// ── view colour lens (`color #tag hue`) ───────────────────────────────────
|
|
494
|
+
// A lens over the model, so it wins over an element's own `color:`; among
|
|
495
|
+
// statements, declaration order and the last one wins — but two different
|
|
496
|
+
// hues landing on one element is almost always two tags that were meant to
|
|
497
|
+
// be disjoint, so it is said once per element rather than resolved silently.
|
|
498
|
+
// tolerant of a hand-built SView with no `colors` (tests, older callers)
|
|
499
|
+
const colorStmts = view.colors ?? [];
|
|
500
|
+
const viewHue = (tags, subject) => {
|
|
501
|
+
const hits = colorStmts.filter((c) => tags.includes(c.tag));
|
|
502
|
+
if (!hits.length)
|
|
503
|
+
return undefined;
|
|
504
|
+
const hues = new Set(hits.map((h) => h.hue));
|
|
505
|
+
if (hues.size > 1) {
|
|
506
|
+
const last = hits[hits.length - 1];
|
|
507
|
+
const other = hits.find((h) => h.hue !== last.hue);
|
|
508
|
+
diagnostics.push({
|
|
509
|
+
severity: "warning",
|
|
510
|
+
message: `${subject} is tagged #${other.tag} (${other.hue}) and #${last.tag} (${last.hue}) — #${last.tag} wins`,
|
|
511
|
+
fix: `give both tags the same hue, or narrow one of them`,
|
|
512
|
+
loc: last.loc,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
return hits[hits.length - 1].hue;
|
|
516
|
+
};
|
|
517
|
+
if (colorStmts.length) {
|
|
518
|
+
for (const n of nodes)
|
|
519
|
+
n.color = viewHue(n.tags, `\`${n.path}\``) ?? n.color;
|
|
520
|
+
for (const e of finalEdges)
|
|
521
|
+
e.color = viewHue(e.tags, `${e.from} → ${e.to}`) ?? e.color;
|
|
522
|
+
for (const f of frames)
|
|
523
|
+
f.color = viewHue(effectiveTags(f.path), `\`${f.path}\``) ?? f.color;
|
|
524
|
+
for (const c of colorStmts)
|
|
525
|
+
if (!nodes.some((n) => n.tags.includes(c.tag))
|
|
526
|
+
&& !finalEdges.some((e) => e.tags.includes(c.tag))
|
|
527
|
+
&& !frames.some((f) => effectiveTags(f.path).includes(c.tag)))
|
|
528
|
+
diagnostics.push({
|
|
529
|
+
severity: "warning",
|
|
530
|
+
message: `color #${c.tag}: nothing visible here is tagged #${c.tag}`,
|
|
531
|
+
fix: `nothing would take the colour — check the tag, or the view's \`include\`/\`only\``,
|
|
532
|
+
loc: c.loc,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
// ── flow badges (SPEC §Flows): map each step onto the edge that renders
|
|
536
|
+
// it at this altitude — steps whose endpoints lift into the same card
|
|
537
|
+
// simply don't appear here.
|
|
538
|
+
let flow;
|
|
539
|
+
if (view.showFlow) {
|
|
540
|
+
const f = model.flows.find((fl) => fl.id === view.showFlow);
|
|
541
|
+
if (f) {
|
|
542
|
+
const v = visSet();
|
|
543
|
+
const byEdge = {};
|
|
544
|
+
f.steps.forEach((step, i) => {
|
|
545
|
+
const from = liftIn(step.from, v);
|
|
546
|
+
const to = liftIn(step.to, v);
|
|
547
|
+
if (!from || !to || from === to)
|
|
548
|
+
return;
|
|
549
|
+
const edge = finalEdges.find((e) => (e.from === from && e.to === to) || (e.from === to && e.to === from));
|
|
550
|
+
if (!edge)
|
|
551
|
+
return;
|
|
552
|
+
(byEdge[edge.id] ??= []).push(i + 1);
|
|
553
|
+
});
|
|
554
|
+
flow = { label: f.label ?? f.id, byEdge };
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
// `highlight` never came through here. `include`, `exclude` and `only` each
|
|
558
|
+
// warn when a tag matches nothing — the same typo, caught three times out of
|
|
559
|
+
// four — but `highlight` is collected by the parser and handed straight to
|
|
560
|
+
// the renderer, so `highlight #pcii` dimmed the whole diagram and emphasised
|
|
561
|
+
// nothing, silently. Match against what this view can actually see: a tag
|
|
562
|
+
// that exists elsewhere in the model is still useless here.
|
|
563
|
+
// Edges count. `highlight #hot-path` on an edge-only tag is a documented and
|
|
564
|
+
// working idiom — `api -> create { tags: #hot-path }` — and the first cut of
|
|
565
|
+
// this warning checked nodes alone, so it fired on a correct diagram the very
|
|
566
|
+
// next gauntlet round. A guard that is narrower than the thing it guards is
|
|
567
|
+
// worse than none.
|
|
568
|
+
for (const tag of view.highlight)
|
|
569
|
+
if (!nodes.some((n) => n.tags.includes(tag))
|
|
570
|
+
&& !finalEdges.some((e) => e.tags.includes(tag)))
|
|
571
|
+
diagnostics.push({
|
|
572
|
+
severity: "warning",
|
|
573
|
+
message: `highlight #${tag}: nothing visible here is tagged #${tag}`,
|
|
574
|
+
fix: `everything would dim and nothing would stand out — check the tag, or the view's \`include\`/\`only\``,
|
|
575
|
+
loc: view.loc,
|
|
576
|
+
});
|
|
577
|
+
// A view that resolves to nothing renders a blank canvas. The empty-container
|
|
578
|
+
// case is caught at build time, but that is one cause of many: excluding
|
|
579
|
+
// everything, scoping to something that isn't there, or a filter that keeps
|
|
580
|
+
// nothing all land here too.
|
|
581
|
+
if (!nodes.length)
|
|
582
|
+
diagnostics.push({
|
|
583
|
+
severity: "warning",
|
|
584
|
+
message: `view \`${view.name}\` has nothing to draw`,
|
|
585
|
+
fix: "every element is filtered out — check `scope`, `include`, `only` and `exclude`",
|
|
586
|
+
loc: view.loc,
|
|
587
|
+
});
|
|
588
|
+
// liveFrames, not frames: a frame `exclude`/`only` emptied has no members
|
|
589
|
+
// to size it and would reach ELK as a childless compound — a 0×0 rect.
|
|
590
|
+
return { nodes, edges: finalEdges, frames: liveFrames, flow, diagnostics };
|
|
591
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|