@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,1403 @@
|
|
|
1
|
+
// Positioned + Theme (+ view annotations) → deterministic SVG string.
|
|
2
|
+
// Integers, LF, fixed attribute order — the string is the artifact under
|
|
3
|
+
// byte-identity tests.
|
|
4
|
+
import { fit, measure, wrapText } from "../metrics.js";
|
|
5
|
+
import { FONTS } from "../fonts.generated.js";
|
|
6
|
+
import { iconMeta, packMonochrome, packFullBleed } from "../model/packs.js";
|
|
7
|
+
import { iconAsset, symbolId } from "../packs/registry.js";
|
|
8
|
+
// SHELF_H is layout's: the shelf is inside the card height it sets, so the
|
|
9
|
+
// strip the renderer fills and the room the card reserves are one number.
|
|
10
|
+
import { pillDims, NOTE_GUTTER, SHELF_H } from "../layout/layout.js";
|
|
11
|
+
import { hueOf } from "../themes/index.js";
|
|
12
|
+
const PLATE = 40;
|
|
13
|
+
const PAD = 12;
|
|
14
|
+
/** comet travel speed. Fast enough to read as motion on a short hop, slow
|
|
15
|
+
* enough that a long cross-diagram edge is not a bullet. */
|
|
16
|
+
const COMET_PX_S = 150;
|
|
17
|
+
/** Duration floor. Below roughly 60px the constant-speed rule would produce a
|
|
18
|
+
* twitch rather than a journey, so very short edges run slightly fast — the
|
|
19
|
+
* one place in this renderer where px/s is not constant, and a deliberate
|
|
20
|
+
* trade. The alternative was drawing nothing below a length threshold, which
|
|
21
|
+
* is worse: the author wrote `animate: comet` and silently dropping it is the
|
|
22
|
+
* failure mode this project refuses everywhere else. */
|
|
23
|
+
const COMET_MIN_S = 0.4;
|
|
24
|
+
const R_NODE = 4;
|
|
25
|
+
const R_EDGE = 8;
|
|
26
|
+
const DIM = "0.35";
|
|
27
|
+
/** The accent bar down a live system card carries the brand ramp off the
|
|
28
|
+
* Squinch mark — magenta through to light blue, top to bottom. Brand, not
|
|
29
|
+
* theme: the same two stops in light and dark, exactly as the logo behaves,
|
|
30
|
+
* which also means an adaptive render has nothing to switch here.
|
|
31
|
+
*
|
|
32
|
+
* In objectBoundingBox units, so every bar runs the full ramp over its own
|
|
33
|
+
* height rather than sampling a slice of one diagram-wide gradient — a short
|
|
34
|
+
* card and a tall one should look like the same object. */
|
|
35
|
+
const ACCENT_GRAD = "sq-accent";
|
|
36
|
+
/** Card/actor corner radius. Leaves use R_NODE; a container is the larger,
|
|
37
|
+
* softer shape, and the sheets behind it share the radius so the stack reads
|
|
38
|
+
* as three of the same object. */
|
|
39
|
+
const R_CARD = 8;
|
|
40
|
+
/** The artwork inside a PLATE-sized tile. The ring of tile around it is the
|
|
41
|
+
* point: it separates vendor art from whatever surface the node is drawn on. */
|
|
42
|
+
const ICON_ART = 26;
|
|
43
|
+
/** The card's own inner margin for anything hung off an edge — the glyph chip
|
|
44
|
+
* at the top right, the shelf's contents at both ends. One pixel tighter than
|
|
45
|
+
* PAD, because these sit against a border rather than against text, and the
|
|
46
|
+
* reference render is built to that rhythm (docs/design). */
|
|
47
|
+
const CARD_INSET = 11;
|
|
48
|
+
/** The bordered chip holding a card's kind glyph, top-right. Its column is
|
|
49
|
+
* reserved in `sizeOf`, so the label never runs under it. */
|
|
50
|
+
const GLYPH_CHIP = 26;
|
|
51
|
+
/** DESIGN §3: "hatched surface variant for `external`" — someone else's system.
|
|
52
|
+
* A texture rather than a colour, because it has to survive every theme
|
|
53
|
+
* including `contrast` and read the same in print; and because colour is
|
|
54
|
+
* already spoken for (accent = subject, muted = scenery, zone tints =
|
|
55
|
+
* boundary). Drawn over the surface, so the fill still shows through.
|
|
56
|
+
* Emitted only when a diagram actually owns an external node, which keeps
|
|
57
|
+
* every other render byte-identical. */
|
|
58
|
+
const HATCH = "sq-hatch";
|
|
59
|
+
/** The one def whose content depends on the theme, which is why it is the one
|
|
60
|
+
* that takes a scope suffix when several palettes share a document. */
|
|
61
|
+
const hatchPattern = (t, id) => `<pattern id="${id}" width="8" height="8" patternUnits="userSpaceOnUse" ` +
|
|
62
|
+
`patternTransform="rotate(45)">` +
|
|
63
|
+
`<line x1="0" y1="0" x2="0" y2="8" stroke="${t.border}" stroke-width="1.5" opacity="0.4"/>` +
|
|
64
|
+
`</pattern>`;
|
|
65
|
+
const hatched = (rc, x, y, w, h, rx) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${rx}" fill="url(#${rc.hatch})"/>`;
|
|
66
|
+
/** The card surface: a 4% ramp, top lighter. Theme-dependent, like the hatch,
|
|
67
|
+
* so it takes the same scope suffix when several palettes share a document. */
|
|
68
|
+
const SURFACE_GRAD = "sq-surface";
|
|
69
|
+
const ACTOR_GRAD = "sq-actor";
|
|
70
|
+
const surfaceGradient = (t, id) => `<linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1">` +
|
|
71
|
+
`<stop offset="0" stop-color="${t.surfaceHi}"/><stop offset="1" stop-color="${t.surfaceLo}"/>` +
|
|
72
|
+
`</linearGradient>`;
|
|
73
|
+
/** The actor tile is filled rather than outlined, so its ramp sits a step
|
|
74
|
+
* darker than a card's — the fill *is* the shape, and at surface tones it
|
|
75
|
+
* would disappear into the canvas. */
|
|
76
|
+
const actorGradient = (t, id) => `<linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1">` +
|
|
77
|
+
`<stop offset="0" stop-color="${t.plate}"/><stop offset="1" stop-color="${t.actorLo}"/>` +
|
|
78
|
+
`</linearGradient>`;
|
|
79
|
+
/** The 1px contact shadow. The alpha rides `flood-color` rather than
|
|
80
|
+
* `flood-opacity` on purpose: the adaptive merge only rewrites colour-valued
|
|
81
|
+
* attributes, so an opacity that differed between light and dark would read
|
|
82
|
+
* as geometry and be refused (render/adaptive.ts). resvg honours
|
|
83
|
+
* feDropShadow, so PNG export keeps it. */
|
|
84
|
+
const SHADOW = "sq-shadow";
|
|
85
|
+
const shadowFilter = (t, id) =>
|
|
86
|
+
// `color-interpolation-filters="sRGB"` is load-bearing, not boilerplate. SVG
|
|
87
|
+
// filters default to linearRGB, so a filtered element is converted to linear
|
|
88
|
+
// space, filtered, and converted back — at 8 bits. Near white that costs
|
|
89
|
+
// almost nothing; near black the linear encoding is so coarse that the round
|
|
90
|
+
// trip flattens a subtle ramp into a few wide steps. The dark card's 4% ramp
|
|
91
|
+
// spans nine levels, and the trip left five uneven ones with a six-level
|
|
92
|
+
// cliff about three quarters down: the surface visibly "fell" rather than
|
|
93
|
+
// shading. Filtering in sRGB keeps the gradient the renderer computed.
|
|
94
|
+
`<filter id="${id}" x="-10%" y="-10%" width="130%" height="130%" color-interpolation-filters="sRGB">` +
|
|
95
|
+
`<feDropShadow dx="0" dy="1" stdDeviation="1" flood-color="${t.shadow}"/>` +
|
|
96
|
+
`</filter>`;
|
|
97
|
+
const accentGradient = () => `<linearGradient id="${ACCENT_GRAD}" x1="0" y1="0" x2="0" y2="1">` +
|
|
98
|
+
`<stop offset="0" stop-color="#C441FE"/><stop offset="1" stop-color="#15B6FF"/>` +
|
|
99
|
+
`</linearGradient>`;
|
|
100
|
+
/** The faces every render embeds: the document face at its two body weights.
|
|
101
|
+
* Kept unconditional because nearly every diagram draws both, and making
|
|
102
|
+
* them earned would churn every committed render for no bytes saved. */
|
|
103
|
+
const BASE_FACES = ["inter:400", "inter:500"];
|
|
104
|
+
/** Mono gets its own dedicated name for the same reason `SquinchInter` has
|
|
105
|
+
* one: the embedded face must win over whatever the page has installed, or
|
|
106
|
+
* text stops matching the metrics layout was computed from. */
|
|
107
|
+
const MONO_CSS = "SquinchMono, 'IBM Plex Mono', ui-monospace, monospace";
|
|
108
|
+
// Dedicated family names: guarantee the embedded face wins over any
|
|
109
|
+
// page-level font, so text width always matches the precomputed metrics.
|
|
110
|
+
/** The @font-face rules alone. Exported because the interactive HTML export
|
|
111
|
+
* hoists them into one <style> for the whole document instead of repeating
|
|
112
|
+
* 33 KB of base64 in every view. One implementation of the rule either way.
|
|
113
|
+
*
|
|
114
|
+
* `used` names extra `family:weight` faces to embed. The HTML export passes
|
|
115
|
+
* every face rather than a union of what its views drew: one document holds
|
|
116
|
+
* many views, any of which may be re-rendered client-side, so a hoisted set
|
|
117
|
+
* that happened to miss mono would silently fall back mid-navigation. */
|
|
118
|
+
export function fontFaceCSS(t, used = []) {
|
|
119
|
+
const cssName = (family) => (family === "mono" ? MONO_CSS : t.font.css).split(",")[0];
|
|
120
|
+
const face = (key) => {
|
|
121
|
+
const [family, w] = key.split(":");
|
|
122
|
+
const data = FONTS[family]?.[w];
|
|
123
|
+
if (!data)
|
|
124
|
+
throw new Error(`no embedded face for ${key} — regenerate with gen-fonts`);
|
|
125
|
+
return `@font-face{font-family:${cssName(family)};font-style:normal;font-weight:${w};` +
|
|
126
|
+
`src:url(data:font/woff2;base64,${data}) format("woff2")}`;
|
|
127
|
+
};
|
|
128
|
+
// sorted so the string is a pure function of the set, and so the two body
|
|
129
|
+
// weights keep leading — which is what keeps existing renders byte-identical
|
|
130
|
+
return [...new Set([...BASE_FACES, ...used])].sort().map(face).join("");
|
|
131
|
+
}
|
|
132
|
+
/** Every face the bundle carries — the HTML export's hoisted set. */
|
|
133
|
+
export function allFaces() {
|
|
134
|
+
return Object.entries(FONTS).flatMap(([f, ws]) => Object.keys(ws).map((w) => `${f}:${w}`));
|
|
135
|
+
}
|
|
136
|
+
function fontDefs(t, used) {
|
|
137
|
+
return `<style>${fontFaceCSS(t, used)}</style>`;
|
|
138
|
+
}
|
|
139
|
+
/** Crisp fill + stroke in one rect. `extra` lands on the same element. */
|
|
140
|
+
function box(_rc, x, y, w, h, rx, fill, stroke, strokeW, extra = "") {
|
|
141
|
+
return `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${rx}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}"${extra}/>`;
|
|
142
|
+
}
|
|
143
|
+
/** A vendor mark on the icon plate's corner (`badge:`, SPEC §nodes) — a 22px
|
|
144
|
+
* rounded-square surface plate overlapping the plate's bottom-right, held 5px
|
|
145
|
+
* clear of the card's interior edge, with the mark at 14px inside it.
|
|
146
|
+
*
|
|
147
|
+
* Deliberately NOT iconPlate: the logos pack is monochrome, and iconPlate's
|
|
148
|
+
* monochrome branch draws a brand-coloured plate with a white knockout — the
|
|
149
|
+
* inverse of this treatment. Here the plate is quiet (surface + border) and
|
|
150
|
+
* the mark carries its own brand colour from the pack manifest. Colour-pack
|
|
151
|
+
* artwork
|
|
152
|
+
* falls back to the same clip treatment iconPlate uses.
|
|
153
|
+
*/
|
|
154
|
+
function badgeMarkup(badge, plateX, plateY, rc) {
|
|
155
|
+
const { t } = rc;
|
|
156
|
+
const SIZE = 22, INSET = 4, R = 5;
|
|
157
|
+
// 40px plate → badge spans its corner: plate origin + 25 keeps the badge
|
|
158
|
+
// 5px inside the 64px card (PAD 12 + 25 + 22 = 59).
|
|
159
|
+
const x = plateX + 25, y = plateY + 25;
|
|
160
|
+
const plate = `<rect x="${x}" y="${y}" width="${SIZE}" height="${SIZE}" rx="${R}" fill="${t.surface}" stroke="${t.border}"/>`;
|
|
161
|
+
const asset = iconAsset(badge.pack, badge.id);
|
|
162
|
+
if (!asset)
|
|
163
|
+
return plate; // validated at check; an unloaded pack degrades to the bare plate
|
|
164
|
+
const ix = x + INSET, iy = y + INSET, isz = SIZE - INSET * 2;
|
|
165
|
+
if (badge.pack === "builtin" || packMonochrome(badge.pack)) {
|
|
166
|
+
const c = iconMeta(badge.pack, badge.id)?.color ?? t.muted;
|
|
167
|
+
return (plate +
|
|
168
|
+
`<g color="${c}" fill="${c}">` +
|
|
169
|
+
`<use href="#${symbolId(badge.pack, badge.id)}" x="${ix}" y="${iy}" width="${isz}" height="${isz}"/>` +
|
|
170
|
+
`</g>`);
|
|
171
|
+
}
|
|
172
|
+
const clip = `clip-${symbolId(badge.pack, badge.id)}-${ix}-${iy}-${isz}`;
|
|
173
|
+
return (plate +
|
|
174
|
+
def(rc, clip, `<clipPath id="${clip}"><rect x="${ix}" y="${iy}" width="${isz}" height="${isz}" rx="2"/></clipPath>`) +
|
|
175
|
+
`<g clip-path="url(#${clip})"><use href="#${symbolId(badge.pack, badge.id)}" x="${ix}" y="${iy}" width="${isz}" height="${isz}"/></g>`);
|
|
176
|
+
}
|
|
177
|
+
function edgePath(pts, lines) {
|
|
178
|
+
if (lines === "straight")
|
|
179
|
+
return `M ${pts[0].x} ${pts[0].y} L ${pts[pts.length - 1].x} ${pts[pts.length - 1].y}`;
|
|
180
|
+
return roundedPath(pts, lines === "curved" ? 24 : R_EDGE);
|
|
181
|
+
}
|
|
182
|
+
function roundedPath(pts, r = R_EDGE) {
|
|
183
|
+
if (pts.length < 3)
|
|
184
|
+
return `M ${pts[0].x} ${pts[0].y} L ${pts[pts.length - 1].x} ${pts[pts.length - 1].y}`;
|
|
185
|
+
let d = `M ${pts[0].x} ${pts[0].y}`;
|
|
186
|
+
for (let i = 1; i < pts.length - 1; i++) {
|
|
187
|
+
const p = pts[i - 1], c = pts[i], n = pts[i + 1];
|
|
188
|
+
const rr = Math.min(r, Math.floor(Math.hypot(c.x - p.x, c.y - p.y) / 2), Math.floor(Math.hypot(n.x - c.x, n.y - c.y) / 2));
|
|
189
|
+
if (rr < 2) {
|
|
190
|
+
d += ` L ${c.x} ${c.y}`;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
d += ` L ${c.x - Math.sign(c.x - p.x) * rr} ${c.y - Math.sign(c.y - p.y) * rr}`
|
|
194
|
+
+ ` Q ${c.x} ${c.y} ${c.x + Math.sign(n.x - c.x) * rr} ${c.y + Math.sign(n.y - c.y) * rr}`;
|
|
195
|
+
}
|
|
196
|
+
d += ` L ${pts[pts.length - 1].x} ${pts[pts.length - 1].y}`;
|
|
197
|
+
return d;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Crossing hops (DESIGN §4): where two unrelated wires cross, the later edge
|
|
201
|
+
* takes a small gap so a crossing can never read as a junction. Deterministic:
|
|
202
|
+
* declaration order decides who hops, and only perpendicular axis-aligned
|
|
203
|
+
* segments count. Edges that share an endpoint are connected, not crossing.
|
|
204
|
+
*/
|
|
205
|
+
const HOP = 4; // half-gap in px — an 8px break total
|
|
206
|
+
function hopPoints(edges) {
|
|
207
|
+
const hops = new Map();
|
|
208
|
+
const segsOf = (e) => {
|
|
209
|
+
const out = [];
|
|
210
|
+
for (let i = 0; i < e.points.length - 1; i++) {
|
|
211
|
+
const a = e.points[i], b = e.points[i + 1];
|
|
212
|
+
if (a.x === b.x && a.y === b.y)
|
|
213
|
+
continue;
|
|
214
|
+
out.push({ a, b, horiz: Math.abs(b.x - a.x) >= Math.abs(b.y - a.y) });
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
};
|
|
218
|
+
const between = (v, p, q, pad) => v >= Math.min(p, q) + pad && v <= Math.max(p, q) - pad;
|
|
219
|
+
for (let i = 0; i < edges.length; i++)
|
|
220
|
+
for (let j = i + 1; j < edges.length; j++) {
|
|
221
|
+
const A = edges[i], B = edges[j];
|
|
222
|
+
// connected edges meet at a node — that is a junction, not a crossing
|
|
223
|
+
if (A.from === B.from || A.from === B.to || A.to === B.from || A.to === B.to)
|
|
224
|
+
continue;
|
|
225
|
+
for (const sa of segsOf(A))
|
|
226
|
+
for (const sb of segsOf(B)) {
|
|
227
|
+
if (sa.horiz === sb.horiz)
|
|
228
|
+
continue;
|
|
229
|
+
const h = sa.horiz ? sa : sb;
|
|
230
|
+
const v = sa.horiz ? sb : sa;
|
|
231
|
+
// the crossing must sit clear of both segments' own bends
|
|
232
|
+
if (!between(v.a.x, h.a.x, h.b.x, HOP + 2))
|
|
233
|
+
continue;
|
|
234
|
+
if (!between(h.a.y, v.a.y, v.b.y, HOP + 2))
|
|
235
|
+
continue;
|
|
236
|
+
const pt = { x: v.a.x, y: h.a.y };
|
|
237
|
+
(hops.get(B.id) ?? hops.set(B.id, []).get(B.id)).push(pt);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return hops;
|
|
241
|
+
}
|
|
242
|
+
/** Break a polyline into sub-polylines around each hop point. */
|
|
243
|
+
function splitAtHops(pts, hops) {
|
|
244
|
+
const out = [];
|
|
245
|
+
let cur = [pts[0]];
|
|
246
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
247
|
+
const a = pts[i], b = pts[i + 1];
|
|
248
|
+
const len = Math.hypot(b.x - a.x, b.y - a.y) || 1;
|
|
249
|
+
const ux = (b.x - a.x) / len, uy = (b.y - a.y) / len;
|
|
250
|
+
const on = hops
|
|
251
|
+
.filter((h) => Math.min(a.x, b.x) - 1 <= h.x && h.x <= Math.max(a.x, b.x) + 1 &&
|
|
252
|
+
Math.min(a.y, b.y) - 1 <= h.y && h.y <= Math.max(a.y, b.y) + 1)
|
|
253
|
+
.map((h) => ({ h, d: Math.hypot(h.x - a.x, h.y - a.y) }))
|
|
254
|
+
.filter((x) => x.d > HOP + 2 && x.d < len - HOP - 2)
|
|
255
|
+
.sort((p, q) => p.d - q.d);
|
|
256
|
+
for (const { h, d } of on) {
|
|
257
|
+
cur.push({ x: Math.round(a.x + ux * (d - HOP)), y: Math.round(a.y + uy * (d - HOP)) });
|
|
258
|
+
out.push(cur);
|
|
259
|
+
cur = [{ x: Math.round(a.x + ux * (d + HOP)), y: Math.round(a.y + uy * (d + HOP)) }];
|
|
260
|
+
}
|
|
261
|
+
cur.push(b);
|
|
262
|
+
}
|
|
263
|
+
out.push(cur);
|
|
264
|
+
return out.filter((seg) => seg.length >= 2);
|
|
265
|
+
}
|
|
266
|
+
/** One head, pointing from `prev` toward `tip` (DESIGN §4: filled chevron 8×6,
|
|
267
|
+
* open chevron for async — never SVG default markers). */
|
|
268
|
+
function head(tip, prev, col, async,
|
|
269
|
+
/** the segment is not axis-aligned, so compute the direction with trig */
|
|
270
|
+
exact = false,
|
|
271
|
+
/** ` class="sq-pulse"` when the whole edge breathes — head included */
|
|
272
|
+
cls = "") {
|
|
273
|
+
let bx, by, ox, oy;
|
|
274
|
+
if (exact) {
|
|
275
|
+
// A real direction and a real perpendicular. Only `lines: straight` produces
|
|
276
|
+
// segments that are not axis-aligned, and the axis-aligned maths below
|
|
277
|
+
// cannot describe them: `Math.sign` collapses any diagonal onto an axis, so
|
|
278
|
+
// a head on a shallow diagonal came out pointing straight down — 69° off
|
|
279
|
+
// the line it was supposed to terminate.
|
|
280
|
+
const len = Math.hypot(tip.x - prev.x, tip.y - prev.y) || 1;
|
|
281
|
+
const ux = (tip.x - prev.x) / len, uy = (tip.y - prev.y) / len;
|
|
282
|
+
bx = Math.round(tip.x - ux * 8);
|
|
283
|
+
by = Math.round(tip.y - uy * 8);
|
|
284
|
+
ox = Math.round(-uy * 6);
|
|
285
|
+
oy = Math.round(ux * 6);
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
// Axis-aligned, where `(dy, dx)` happens to be the perpendicular and the
|
|
289
|
+
// arithmetic stays in whole pixels with no rounding at all.
|
|
290
|
+
const dx = Math.sign(tip.x - prev.x), dy = Math.sign(tip.y - prev.y);
|
|
291
|
+
bx = tip.x - dx * 8;
|
|
292
|
+
by = tip.y - dy * 8;
|
|
293
|
+
ox = dy * 6;
|
|
294
|
+
oy = dx * 6;
|
|
295
|
+
}
|
|
296
|
+
const p1 = `${bx + ox} ${by + oy}`, p2 = `${bx - ox} ${by - oy}`;
|
|
297
|
+
return async
|
|
298
|
+
? `<path${cls} d="M ${p1} L ${tip.x} ${tip.y} L ${p2}" fill="none" stroke="${col}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
|
299
|
+
: `<path${cls} d="M ${tip.x} ${tip.y} L ${p1} L ${p2} Z" fill="${col}"/>`;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* The heads for an edge. `->`/`~>` point one way, `<->` points both, and `--`
|
|
303
|
+
* points neither — the whole reason those two spellings exist. They used to
|
|
304
|
+
* draw as a plain one-way arrow, because the view graph reduced every arrow to
|
|
305
|
+
* `async: boolean` and the other two kinds fell through the gap. Both are
|
|
306
|
+
* documented in SKILL.md, so a diagram could state something the picture then
|
|
307
|
+
* contradicted.
|
|
308
|
+
*/
|
|
309
|
+
function arrow(e, t, lines, col, cls = "") {
|
|
310
|
+
if (e.heads === "none")
|
|
311
|
+
return "";
|
|
312
|
+
const n = e.points.length;
|
|
313
|
+
// `col` is the stroke's own colour, passed in rather than re-derived: the
|
|
314
|
+
// head has to travel with the line, or a highlighted (or hued) hop ends in
|
|
315
|
+
// a grey point
|
|
316
|
+
// `straight` draws first point to last and discards the route in between, so
|
|
317
|
+
// the head has to take its direction from what is actually drawn. Reading the
|
|
318
|
+
// route's final segment instead pointed the head along a leg the reader never
|
|
319
|
+
// sees.
|
|
320
|
+
const exact = lines === "straight";
|
|
321
|
+
const before = exact ? e.points[0] : e.points[n - 2];
|
|
322
|
+
const after = exact ? e.points[n - 1] : e.points[1];
|
|
323
|
+
const forward = head(e.points[n - 1], before, col, e.async, exact, cls);
|
|
324
|
+
return e.heads === "both"
|
|
325
|
+
? forward + head(e.points[0], after, col, e.async, exact, cls)
|
|
326
|
+
: forward;
|
|
327
|
+
}
|
|
328
|
+
/** class per animate value — `sq-flow` predates the vocabulary and keeps its
|
|
329
|
+
* name so flow-only output stays byte-identical to every committed render */
|
|
330
|
+
/** Stroke class per animate value. Partial on purpose: `comet` animates a
|
|
331
|
+
* separate travelling element and leaves the stroke alone, so it has no entry
|
|
332
|
+
* and the lookup must be allowed to miss. */
|
|
333
|
+
const ANIM_CLASS = {
|
|
334
|
+
flow: "sq-flow", reverse: "sq-flow-r", slow: "sq-flow-s",
|
|
335
|
+
fast: "sq-flow-f", packets: "sq-pk", pulse: "sq-pulse",
|
|
336
|
+
};
|
|
337
|
+
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
338
|
+
/** Greedy word-wrap against the metrics table; ≤maxLines, last line ellipsized. */
|
|
339
|
+
function wrap(rc, text, maxPx, sizePx, maxLines) {
|
|
340
|
+
return wrapText(text, maxPx, sizePx, rc.fam, maxLines);
|
|
341
|
+
}
|
|
342
|
+
/** The card/leaf surface: a 4% top-to-bottom ramp instead of a flat fill, over
|
|
343
|
+
* a 1px contact shadow. Both are defs so the light and dark draws merge into
|
|
344
|
+
* one adaptive file — a gradient's stops and a shadow's `flood-color` are
|
|
345
|
+
* colour-valued, which is the only kind of attribute that may differ between
|
|
346
|
+
* the two palettes (render/adaptive.ts). */
|
|
347
|
+
function surfaceRect(rc, x, y, w, h, rx) {
|
|
348
|
+
return (`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${rx}" ` +
|
|
349
|
+
`fill="url(#${rc.grad})" stroke="${rc.t.border}" stroke-width="1" ` +
|
|
350
|
+
`filter="url(#${rc.shadow})"/>`);
|
|
351
|
+
}
|
|
352
|
+
function leaf(n, rc, opts, dimmed, L) {
|
|
353
|
+
const { t } = rc;
|
|
354
|
+
const op = dimmed ? ` opacity="${DIM}"` : "";
|
|
355
|
+
const ctx = n.kind === "context-leaf";
|
|
356
|
+
L.push(`<g data-path="${esc(n.path)}" data-kind="${n.kind}"${op}>`);
|
|
357
|
+
// A context leaf keeps the flat surface and the dashed border: it is
|
|
358
|
+
// scenery, and scenery is not lit or lifted off the page.
|
|
359
|
+
if (ctx)
|
|
360
|
+
L.push(box(rc, n.x, n.y, n.w, n.h, R_NODE, t.surface, t.border, 1.5, ` stroke-dasharray="4 3"`));
|
|
361
|
+
else
|
|
362
|
+
L.push(surfaceRect(rc, n.x, n.y, n.w, n.h, R_NODE));
|
|
363
|
+
if (n.external)
|
|
364
|
+
L.push(hatched(rc, n.x, n.y, n.w, n.h, R_NODE));
|
|
365
|
+
// `color:` on a leaf is the card's spine, brought down: one treatment for
|
|
366
|
+
// every node kind, so a coloured leaf and a coloured card read as one rule.
|
|
367
|
+
// A separate element, only when coloured — every uncoloured render stays
|
|
368
|
+
// byte-identical, and the adaptive merge sees the same geometry in both
|
|
369
|
+
// themes with only the fill moving. Context leaves are scenery and stay
|
|
370
|
+
// muted. The artwork on the plate is never touched.
|
|
371
|
+
if (n.color && !ctx)
|
|
372
|
+
L.push(spine(n, R_NODE, hueOf(t, n.color), rc));
|
|
373
|
+
const px = n.x + PAD, py = n.y + PAD;
|
|
374
|
+
L.push(iconTile(n.icon, px, py, rc, ctx));
|
|
375
|
+
if (n.badge)
|
|
376
|
+
L.push(badgeMarkup(n.badge, px, py, rc));
|
|
377
|
+
const maxLabel = n.w - PAD - PLATE - PAD - PAD;
|
|
378
|
+
const withDesc = opts.showDescriptions && n.description;
|
|
379
|
+
const labelY = withDesc ? n.y + n.h / 2 - 1 : n.y + n.h / 2 + 5;
|
|
380
|
+
L.push(`<text x="${px + PLATE + PAD}" y="${labelY}" font-size="${rc.fx(13)}" font-weight="500" fill="${ctx ? t.muted : t.ink}">${esc(fit(n.label, maxLabel, rc.fx(13), "500", rc.fam))}</text>`);
|
|
381
|
+
if (withDesc)
|
|
382
|
+
L.push(`<text x="${px + PLATE + PAD}" y="${n.y + n.h / 2 + 15}" font-size="${rc.fx(11)}" fill="${t.muted}">${esc(fit(n.description, maxLabel, rc.fx(11), "400", rc.fam))}</text>`);
|
|
383
|
+
L.push(`</g>`);
|
|
384
|
+
}
|
|
385
|
+
/** The actor tile (docs/design): filled rather than outlined, so the human who
|
|
386
|
+
* starts the story separates from the services by shape before the icon is
|
|
387
|
+
* read. No border, a round avatar, and a caption where a service carries its
|
|
388
|
+
* description. */
|
|
389
|
+
function person(n, rc, opts, dimmed, L) {
|
|
390
|
+
const { t } = rc;
|
|
391
|
+
const op = dimmed ? ` opacity="${DIM}"` : "";
|
|
392
|
+
L.push(`<g data-path="${esc(n.path)}" data-kind="${n.kind}"${op}>`);
|
|
393
|
+
L.push(`<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="${R_CARD}" ` +
|
|
394
|
+
`fill="url(#${rc.actorGrad})" filter="url(#${rc.shadow})"/>`);
|
|
395
|
+
if (n.external)
|
|
396
|
+
L.push(hatched(rc, n.x, n.y, n.w, n.h, R_CARD));
|
|
397
|
+
// the same spine a coloured leaf or card wears (separate, only when coloured)
|
|
398
|
+
if (n.color)
|
|
399
|
+
L.push(spine(n, R_CARD, hueOf(t, n.color), rc));
|
|
400
|
+
// A disc, not a plate: round is the oldest shorthand for a person, and it
|
|
401
|
+
// keeps the actor legible where the icon is a generic silhouette.
|
|
402
|
+
const r = 17, cx = n.x + PAD + r, cy = n.y + Math.round(n.h / 2);
|
|
403
|
+
L.push(`<circle cx="${cx}" cy="${cy}" r="${r}" fill="${t.plate}"/>`);
|
|
404
|
+
// The mark goes on the disc bare — `iconPlate` would draw the monochrome
|
|
405
|
+
// branch's own rounded square inside it, and a square plate centred in a
|
|
406
|
+
// circle reads as a mistake rather than as an avatar. Colour-pack artwork
|
|
407
|
+
// keeps its own palette, same as everywhere else.
|
|
408
|
+
const asset = n.icon && iconAsset(n.icon.pack, n.icon.id);
|
|
409
|
+
if (asset && n.icon)
|
|
410
|
+
L.push(`<g color="${t.muted}"><use href="#${symbolId(n.icon.pack, n.icon.id)}" x="${cx - 10}" y="${cy - 10}" width="20" height="20"/></g>`);
|
|
411
|
+
const tx = n.x + PAD + r * 2 + PAD;
|
|
412
|
+
const maxLabel = n.w - (tx - n.x) - PAD;
|
|
413
|
+
const withDesc = opts.showDescriptions && n.description;
|
|
414
|
+
L.push(`<text x="${tx}" y="${withDesc ? cy - 1 : cy + 5}" font-size="${rc.fx(13)}" font-weight="500" fill="${t.ink}">${esc(fit(n.label, maxLabel, rc.fx(13), "500", rc.fam))}</text>`);
|
|
415
|
+
if (withDesc)
|
|
416
|
+
L.push(`<text x="${tx}" y="${cy + 15}" font-size="${rc.fx(11)}" fill="${t.muted}">${esc(fit(n.description, maxLabel, rc.fx(11), "400", rc.fam))}</text>`);
|
|
417
|
+
L.push(`</g>`);
|
|
418
|
+
}
|
|
419
|
+
/** The 3px bar down a node's left edge, clipped to its own radius so it turns
|
|
420
|
+
* the corner. Cards carry one always (the brand ramp, or a hue); leaves and
|
|
421
|
+
* actors carry one only when `color:` says so — the shared shape is what
|
|
422
|
+
* makes a coloured leaf and a coloured card read as one rule. */
|
|
423
|
+
function spine(n, rx, fill, rc) {
|
|
424
|
+
const clipId = `sq-clip-${rx}-${n.w}-${n.h}`;
|
|
425
|
+
const clip = def(rc, clipId, `<clipPath id="${clipId}"><rect x="0" y="0" width="${n.w}" height="${n.h}" rx="${rx}"/></clipPath>`);
|
|
426
|
+
return clip +
|
|
427
|
+
`<g transform="translate(${n.x},${n.y})" clip-path="url(#${clipId})">` +
|
|
428
|
+
`<rect x="0" y="0" width="3" height="${n.h}" fill="${fill}"/></g>`;
|
|
429
|
+
}
|
|
430
|
+
/** Two outline rects behind a container, offset back and down: "there is more
|
|
431
|
+
* inside", said by the shape, before anyone clicks.
|
|
432
|
+
*
|
|
433
|
+
* Emitted as siblings *outside* the node's own group on purpose. The SPA's
|
|
434
|
+
* hover rule styles the group's first rect, and the dive animation measures
|
|
435
|
+
* the group's bounding box — sheets inside it would light the back sheet on
|
|
436
|
+
* hover and throw every zoom 8px off centre. */
|
|
437
|
+
function sheets(n, rc, dimmed) {
|
|
438
|
+
const { t } = rc;
|
|
439
|
+
// back sheet first, so the nearer one overlaps it
|
|
440
|
+
return [{ d: 8, o: "0.5" }, { d: 4, o: "0.8" }]
|
|
441
|
+
.map(({ d, o }) => `<rect x="${n.x + d}" y="${n.y + d}" width="${n.w}" height="${n.h}" rx="${R_CARD}" ` +
|
|
442
|
+
`fill="${t.sheetFill}" stroke="${t.sheetBorder}" stroke-width="1" opacity="${dimmed ? DIM : o}"/>`)
|
|
443
|
+
.join("");
|
|
444
|
+
}
|
|
445
|
+
function card(n, rc, dimmed, L) {
|
|
446
|
+
const { t } = rc;
|
|
447
|
+
const ctx = n.kind === "context-card";
|
|
448
|
+
// one opacity, never two: dim wins over the context fade
|
|
449
|
+
const op = dimmed ? ` opacity="${DIM}"` : ctx ? ` opacity="0.75"` : "";
|
|
450
|
+
// A context card gets no sheets: it stands for somewhere else, and this view
|
|
451
|
+
// is not the place to advertise diving into it.
|
|
452
|
+
if (!ctx)
|
|
453
|
+
L.push(sheets(n, rc, dimmed));
|
|
454
|
+
L.push(`<g data-path="${esc(n.path)}" data-kind="${n.kind}"${op}>`);
|
|
455
|
+
if (ctx)
|
|
456
|
+
L.push(box(rc, n.x, n.y, n.w, n.h, R_CARD, t.surface, t.border, 1.5, ` stroke-dasharray="4 3"`));
|
|
457
|
+
else
|
|
458
|
+
L.push(surfaceRect(rc, n.x, n.y, n.w, n.h, R_CARD));
|
|
459
|
+
// The spine: the brand ramp down the left edge, clipped to the card's own
|
|
460
|
+
// radius so it turns the corner instead of squaring it off. Containers only
|
|
461
|
+
// — a leaf has no inside, so it gets none of the affordances that imply one.
|
|
462
|
+
// A context card keeps a muted one: the spine is what separates subject from
|
|
463
|
+
// scenery, and colour is the cheapest way to say which is which. An authored
|
|
464
|
+
// `color:` takes the spine over as a solid hue — same element, same shape,
|
|
465
|
+
// so the card reads the same and only its colour says something new.
|
|
466
|
+
const clipId = `sq-cardclip-${n.w}-${n.h}`;
|
|
467
|
+
L.push(def(rc, clipId, `<clipPath id="${clipId}"><rect x="0" y="0" width="${n.w}" height="${n.h}" rx="${R_CARD}"/></clipPath>`));
|
|
468
|
+
const inCard = (markup) => `<g transform="translate(${n.x},${n.y})" clip-path="url(#${clipId})">${markup}</g>`;
|
|
469
|
+
const spine = ctx ? t.muted : n.color ? hueOf(t, n.color) : `url(#${ACCENT_GRAD})`;
|
|
470
|
+
L.push(inCard(`<rect x="0" y="0" width="3" height="${n.h}" fill="${spine}"/>`));
|
|
471
|
+
// The shelf: what is inside, along the bottom. It continues the card's own
|
|
472
|
+
// surface (the gradient's lower tone) under a hairline, so it reads as one
|
|
473
|
+
// object with a divided base rather than a card sitting on a bar. Drawn only
|
|
474
|
+
// when it has something to hold — an empty strip is just a taller card.
|
|
475
|
+
const hasShelf = !!(n.preview.length || n.more || n.domain);
|
|
476
|
+
if (hasShelf)
|
|
477
|
+
L.push(inCard(`<rect x="3" y="${n.h - SHELF_H}" width="${n.w - 3}" height="${SHELF_H}" fill="${t.surfaceLo}"/>` +
|
|
478
|
+
`<line x1="3" y1="${n.h - SHELF_H}" x2="${n.w}" y2="${n.h - SHELF_H}" stroke="${t.shelfLine}" stroke-width="1"/>`));
|
|
479
|
+
// Hatch last of the surfaces, so "someone else's" covers the shelf too — it
|
|
480
|
+
// is one card, and a texture that stopped at the shelf line read as a gap.
|
|
481
|
+
if (n.external)
|
|
482
|
+
L.push(hatched(rc, n.x, n.y, n.w, n.h, R_CARD));
|
|
483
|
+
// The card's own mark, on the tile a leaf's icon sits on. The header row is
|
|
484
|
+
// top-aligned in the body it shares with the shelf; with no shelf there is
|
|
485
|
+
// no body to share, so it centres instead of leaving the bottom half empty.
|
|
486
|
+
const bodyH = hasShelf ? n.h - SHELF_H : n.h;
|
|
487
|
+
const px = n.x + PAD + 5, py = n.y + Math.round((bodyH - PLATE) / 2);
|
|
488
|
+
if (n.icon)
|
|
489
|
+
L.push(iconTile(n.icon, px, py, rc, ctx));
|
|
490
|
+
const tx = n.icon ? px + PLATE + PAD : n.x + PAD + 6;
|
|
491
|
+
// the glyph chip's column is reserved whether or not one is drawn, so a long
|
|
492
|
+
// label never runs under it
|
|
493
|
+
const maxText = n.x + n.w - CARD_INSET - GLYPH_CHIP - PAD - tx;
|
|
494
|
+
// Baselines hang off the tile's centre line, so the title/tagline pair reads
|
|
495
|
+
// as one block beside the icon rather than starting at an arbitrary height.
|
|
496
|
+
// Alone, the title takes the centre itself.
|
|
497
|
+
const mid = py + PLATE / 2;
|
|
498
|
+
L.push(`<text x="${tx}" y="${n.tagline ? mid - 2 : mid + 5}" font-size="${rc.fx(15)}" font-weight="500" fill="${ctx ? t.muted : t.ink}">${esc(fit(n.label, maxText, rc.fx(15), "500", rc.fam))}</text>`);
|
|
499
|
+
if (n.tagline)
|
|
500
|
+
L.push(`<text x="${tx}" y="${mid + 13}" font-size="${rc.fx(11)}" fill="${t.muted}">${esc(fit(n.tagline, maxText + GLYPH_CHIP, rc.fx(11), "400", rc.fam))}</text>`);
|
|
501
|
+
if (n.glyph) {
|
|
502
|
+
// A bordered chip, not a bare mark: the glyph says what kind of thing the
|
|
503
|
+
// card is, and the chip is what makes it read as a label rather than as
|
|
504
|
+
// part of the card's own artwork.
|
|
505
|
+
const gx = n.x + n.w - CARD_INSET - GLYPH_CHIP, gy = n.y + 10;
|
|
506
|
+
const asset = iconAsset(n.glyph.pack, n.glyph.id);
|
|
507
|
+
const meta = iconMeta(n.glyph.pack, n.glyph.id);
|
|
508
|
+
if (asset || meta)
|
|
509
|
+
L.push(`<rect x="${gx}" y="${gy}" width="${GLYPH_CHIP}" height="${GLYPH_CHIP}" rx="5" fill="${t.surface}" stroke="${t.border}" stroke-width="1"/>`);
|
|
510
|
+
if (asset)
|
|
511
|
+
L.push(`<g color="${t.muted}"><use href="#${symbolId(n.glyph.pack, n.glyph.id)}" x="${gx + 5}" y="${gy + 5}" width="16" height="16"/></g>`);
|
|
512
|
+
else if (meta)
|
|
513
|
+
L.push(`<text x="${gx + GLYPH_CHIP / 2}" y="${gy + 17}" text-anchor="middle" font-size="${rc.fx(10)}" font-weight="500" fill="${t.muted}">${esc(meta.code)}</text>`);
|
|
514
|
+
}
|
|
515
|
+
// Shelf contents. The bed itself went down with the surface, above.
|
|
516
|
+
if (hasShelf) {
|
|
517
|
+
const sy = n.y + n.h - SHELF_H;
|
|
518
|
+
let ix = n.x + CARD_INSET;
|
|
519
|
+
for (const icon of n.preview) {
|
|
520
|
+
L.push(iconPlate(icon, ix, sy + 7, 16, rc, ctx));
|
|
521
|
+
ix += 22; // 16 of icon + a 6 gap, the shelf's rhythm
|
|
522
|
+
}
|
|
523
|
+
if (n.more) {
|
|
524
|
+
const label = `+${n.more}`;
|
|
525
|
+
L.push(`<text x="${ix}" y="${sy + 19}" font-size="${rc.fx(11)}" font-weight="500" fill="${t.muted}">${label}</text>`);
|
|
526
|
+
ix += Math.ceil(measure(label, rc.fx(11), "500", rc.fam));
|
|
527
|
+
}
|
|
528
|
+
// The chip takes the room the icons left, not a share of the card: sizing
|
|
529
|
+
// it against half the width let a long domain slide left over the `+N` it
|
|
530
|
+
// was supposed to sit beside. Below MIN_DOMAIN there is no room worth
|
|
531
|
+
// taking — two letters and an ellipsis name nothing — so it steps aside
|
|
532
|
+
// and the icons keep the shelf.
|
|
533
|
+
const MIN_DOMAIN = 44;
|
|
534
|
+
if (n.domain) {
|
|
535
|
+
// ceil, not round: a label measuring 51.05 rounds its chip down to 51
|
|
536
|
+
// and then `fit` ellipsizes the text against its own chip
|
|
537
|
+
const natural = Math.ceil(measure(n.domain, rc.fx(11), "400", rc.fam)) + 12;
|
|
538
|
+
const room = n.x + n.w - CARD_INSET - (ix + 8);
|
|
539
|
+
const dw = Math.min(natural, room);
|
|
540
|
+
if (dw >= MIN_DOMAIN) {
|
|
541
|
+
const dx = n.x + n.w - CARD_INSET - dw;
|
|
542
|
+
L.push(
|
|
543
|
+
// a hairline, because the chip's fill is a couple of percent off the
|
|
544
|
+
// shelf it sits on — quiet by design, but without an edge it read as
|
|
545
|
+
// floating text rather than as a tag
|
|
546
|
+
`<rect x="${dx}" y="${sy + 6}" width="${dw}" height="18" rx="2" fill="${t.plate}" stroke="${t.border}" stroke-width="1"/>` +
|
|
547
|
+
`<text x="${dx + 6}" y="${sy + 19}" font-size="${rc.fx(11)}" fill="${t.muted}">${esc(fit(n.domain, dw - 12, rc.fx(11), "400", rc.fam))}</text>`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
L.push(`</g>`);
|
|
552
|
+
}
|
|
553
|
+
/** The 11px mark a note opens with. It carries the job the amber fill used to
|
|
554
|
+
* do — "this is commentary, not a diagram object" — without spending a third
|
|
555
|
+
* hue on it. An authored `style: warning` keeps its distinction here rather
|
|
556
|
+
* than in the plate: same neutral note, a triangle instead of an i, which is
|
|
557
|
+
* the difference a reader is scanning for anyway.
|
|
558
|
+
*
|
|
559
|
+
* Drawn from primitives rather than pulled from a pack: a note is chrome, and
|
|
560
|
+
* chrome that depended on an installed pack would vanish from a diagram that
|
|
561
|
+
* happens not to use one. */
|
|
562
|
+
function noteGlyph(x, y, warn, rc) {
|
|
563
|
+
const stroke = ` fill="none" stroke="${rc.t.faint}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"`;
|
|
564
|
+
return warn
|
|
565
|
+
? `<g${stroke}><path d="M${x + 5.5} ${y + 0.6} L${x + 10.8} ${y + 9.9} L${x + 0.2} ${y + 9.9} Z"/>` +
|
|
566
|
+
`<path d="M${x + 5.5} ${y + 4} L${x + 5.5} ${y + 6.4}"/><path d="M${x + 5.5} ${y + 8.2} L${x + 5.5} ${y + 8.3}"/></g>`
|
|
567
|
+
: `<g${stroke}><circle cx="${x + 5.5}" cy="${y + 5.5}" r="4.9"/>` +
|
|
568
|
+
`<path d="M${x + 5.5} ${y + 5} L${x + 5.5} ${y + 8}"/><path d="M${x + 5.5} ${y + 3.2} L${x + 5.5} ${y + 3.3}"/></g>`;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Sticky chips with a leader to their anchor (DESIGN §5). Returns the extent of
|
|
572
|
+
* everything drawn, because a note placed relative to a node at the edge of the
|
|
573
|
+
* diagram lands outside the canvas and is simply clipped away — `above` on the
|
|
574
|
+
* top row and `below` on the bottom row always do. Three committed diagrams
|
|
575
|
+
* shipped with an invisible note before anything measured this.
|
|
576
|
+
*/
|
|
577
|
+
function notes(p, rc, list, L) {
|
|
578
|
+
// The resolver lives in layout now (Positioned consolidation) — this draws
|
|
579
|
+
// p.notes and reports the extent, exactly the numbers the placement used to
|
|
580
|
+
// produce here, so the caller's canvas pad/shift is unchanged.
|
|
581
|
+
const { t } = rc;
|
|
582
|
+
const ext = { minX: 0, minY: 0, maxX: p.width, maxY: p.height };
|
|
583
|
+
for (const n of p.notes ?? []) {
|
|
584
|
+
const note = list[n.i];
|
|
585
|
+
if (!note)
|
|
586
|
+
continue;
|
|
587
|
+
const { x, y, w, h, lines } = n;
|
|
588
|
+
ext.minX = Math.min(ext.minX, x);
|
|
589
|
+
ext.minY = Math.min(ext.minY, y);
|
|
590
|
+
ext.maxX = Math.max(ext.maxX, x + w);
|
|
591
|
+
ext.maxY = Math.max(ext.maxY, y + h);
|
|
592
|
+
// One plate for every note. The amber `warning` fill is retired here
|
|
593
|
+
// (docs/design: the only third hue in a two-hue palette, and it read as a
|
|
594
|
+
// sticky note stuck onto the diagram rather than as part of it) — and the
|
|
595
|
+
// distinction it carried moves to the glyph below in the same change, so
|
|
596
|
+
// an authored `style: warning` is never silently dropped.
|
|
597
|
+
const warn = note.style === "warning";
|
|
598
|
+
const id = note.anchor.kind === "relpos"
|
|
599
|
+
? `${note.anchor.relpos}:${note.anchor.target}`
|
|
600
|
+
: note.anchor.kind === "edge"
|
|
601
|
+
? `on:${note.anchor.from}->${note.anchor.to}`
|
|
602
|
+
: note.anchor.corner;
|
|
603
|
+
L.push(`<g data-note="${esc(id)}">`);
|
|
604
|
+
if (n.leader)
|
|
605
|
+
L.push(`<line x1="${n.leader.x1}" y1="${n.leader.y1}" x2="${n.leader.x2}" y2="${n.leader.y2}" stroke="${t.border}" stroke-width="1" stroke-dasharray="2 3"/>`);
|
|
606
|
+
L.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="4" fill="${t.surface}" ` +
|
|
607
|
+
`stroke="${t.border}" stroke-width="1" filter="url(#${rc.shadow})"/>`);
|
|
608
|
+
// Aligned to the first line's optical centre, not the box's. A note can
|
|
609
|
+
// run to three lines, and a mark that centred itself on the box would
|
|
610
|
+
// drift down the taller ones — a leading glyph belongs beside the first
|
|
611
|
+
// line the way a bullet does. 11px Inter caps are ~8 tall and the first
|
|
612
|
+
// baseline sits at y+17, so that centre is y+13 and an 11px mark starts
|
|
613
|
+
// 5.5 above it. For a one-line note this is also the box's centre, which
|
|
614
|
+
// is why the two used to look interchangeable.
|
|
615
|
+
L.push(noteGlyph(x + 12, y + 8, warn, rc));
|
|
616
|
+
lines.forEach((line, i) => L.push(`<text x="${x + 12 + NOTE_GUTTER}" y="${y + 17 + i * 15}" font-size="${rc.fx(11)}" fill="${t.ink}">${esc(line)}</text>`));
|
|
617
|
+
L.push(`</g>`);
|
|
618
|
+
}
|
|
619
|
+
return ext;
|
|
620
|
+
}
|
|
621
|
+
// zone-kind → default hue (DESIGN §5: kind-tinted). The four hues here are the
|
|
622
|
+
// former zone tint tokens, verbatim, so no zone ever rendered changes a byte.
|
|
623
|
+
const ZONE_TINT = {
|
|
624
|
+
account: "red",
|
|
625
|
+
region: "gray",
|
|
626
|
+
vpc: "blue",
|
|
627
|
+
subnet: "blue",
|
|
628
|
+
network: "blue",
|
|
629
|
+
cloud: "violet",
|
|
630
|
+
onprem: "gray",
|
|
631
|
+
custom: "gray",
|
|
632
|
+
};
|
|
633
|
+
const zoneColor = (z, t) => hueOf(t, z.color ?? ZONE_TINT[z.kind]);
|
|
634
|
+
/** Legend of what's actually in the picture — never a fixed key (DESIGN:
|
|
635
|
+
* quiet structure; only earned entries). Returns markup + band height. */
|
|
636
|
+
function legend(p, rc, y, L, colors = []) {
|
|
637
|
+
const { t } = rc;
|
|
638
|
+
const items = [];
|
|
639
|
+
// Each wire entry samples the colour its edges were actually drawn in when
|
|
640
|
+
// they all agree (one `color:` across the board, or none), and the kind's
|
|
641
|
+
// theme default when they don't — a violet "async" swatch over red async
|
|
642
|
+
// edges would be a lie, and a grey one over mixed colours is the honest
|
|
643
|
+
// "several" sample.
|
|
644
|
+
const drawn = (e) => (e.color ? hueOf(t, e.color) : e.async ? t.asyncEdge : t.edge);
|
|
645
|
+
const agreed = (es, fallback) => {
|
|
646
|
+
const cols = new Set(es.map(drawn));
|
|
647
|
+
return cols.size === 1 ? [...cols][0] : fallback;
|
|
648
|
+
};
|
|
649
|
+
const syncs = p.edges.filter((e) => !e.async);
|
|
650
|
+
if (syncs.length) {
|
|
651
|
+
const col = agreed(syncs, t.edge);
|
|
652
|
+
items.push({
|
|
653
|
+
sample: (x, cy) => `<line x1="${x}" y1="${cy}" x2="${x + 24}" y2="${cy}" stroke="${col}" stroke-width="1.5"/>`,
|
|
654
|
+
label: "sync",
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
// The async sample follows the pattern the view's async edges actually use
|
|
658
|
+
// (`dotted` is the sanctioned alternative); mixed patterns sample the default.
|
|
659
|
+
const asyncs = p.edges.filter((e) => e.async);
|
|
660
|
+
if (asyncs.length) {
|
|
661
|
+
const dots = asyncs.every((e) => e.style === "dotted");
|
|
662
|
+
const col = agreed(asyncs, t.asyncEdge);
|
|
663
|
+
items.push({
|
|
664
|
+
sample: (x, cy) => `<line x1="${x}" y1="${cy}" x2="${x + 24}" y2="${cy}" stroke="${col}" stroke-width="1.5" stroke-dasharray="${dots ? "2 3" : "6 5"}"/>`,
|
|
665
|
+
label: "async",
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
// A restyled sync edge (`style: dashed | dotted`) earns nothing: the legend
|
|
669
|
+
// carries meanings, and a style name beside its own pattern is a tautology —
|
|
670
|
+
// the same reason an element-level `color:` earns no swatch. An author who
|
|
671
|
+
// wants it keyed tags the edge and colours the tag in the view.
|
|
672
|
+
if (p.edges.some((e) => e.count > 1))
|
|
673
|
+
items.push({
|
|
674
|
+
sample: (x, cy) => `<line x1="${x}" y1="${cy}" x2="${x + 24}" y2="${cy}" stroke="${t.edge}" stroke-width="1.5"/>` +
|
|
675
|
+
`<text x="${x + 12}" y="${cy - 4}" text-anchor="middle" font-size="${rc.fx(9)}" fill="${t.muted}">×n</text>`,
|
|
676
|
+
label: "aggregated",
|
|
677
|
+
});
|
|
678
|
+
// Cards and persons earn no entry: the chevron and the person glyph already
|
|
679
|
+
// say what they are, and a plain or gradient swatch beside the colour key
|
|
680
|
+
// read as one more hue.
|
|
681
|
+
if (p.nodes.some((n) => n.kind.startsWith("context")))
|
|
682
|
+
items.push({
|
|
683
|
+
sample: (x, cy) => `<rect x="${x + 2}" y="${cy - 7}" width="20" height="14" rx="2" fill="${t.surface}" stroke="${t.border}" stroke-width="1.5" stroke-dasharray="4 3"/>`,
|
|
684
|
+
label: "context",
|
|
685
|
+
});
|
|
686
|
+
if (p.flow && Object.keys(p.flow.byEdge).length)
|
|
687
|
+
items.push({
|
|
688
|
+
sample: (x, cy) => `<circle cx="${x + 12}" cy="${cy}" r="8" fill="${t.accent}"/>` +
|
|
689
|
+
`<text x="${x + 12}" y="${cy + 3}" text-anchor="middle" font-size="${rc.fx(9)}" font-weight="500" fill="${t.beadText}">1</text>`,
|
|
690
|
+
label: `flow: ${p.flow.label}`,
|
|
691
|
+
});
|
|
692
|
+
// Zone entries, keyed on (kind, the colour the boundary was actually drawn
|
|
693
|
+
// in) — in zone order, deduped. It used to sample the kind's default tint,
|
|
694
|
+
// which lied the moment a zone said `color:`; an account zone drawn gray
|
|
695
|
+
// earns a gray swatch, and two account zones in two colours earn two.
|
|
696
|
+
const zoneKeys = new Map();
|
|
697
|
+
for (const z of p.zones ?? []) {
|
|
698
|
+
const col = zoneColor(z, t);
|
|
699
|
+
zoneKeys.set(`${z.kind}|${col}`, { kind: z.kind, col });
|
|
700
|
+
}
|
|
701
|
+
for (const { kind, col } of zoneKeys.values())
|
|
702
|
+
items.push({
|
|
703
|
+
sample: (x, cy) => `<rect x="${x + 2}" y="${cy - 7}" width="20" height="14" rx="2" fill="none" stroke="${col}" stroke-width="1" stroke-dasharray="4 3"/>`,
|
|
704
|
+
label: kind,
|
|
705
|
+
});
|
|
706
|
+
// The view's `color #tag hue` statements, in declaration order, one per tag
|
|
707
|
+
// (a restated tag keeps its last hue, which is the one that was drawn). A
|
|
708
|
+
// solid swatch — the tag-key swatch — because the hue lands on
|
|
709
|
+
// spines and strokes, not on a box; an outlined sample read as one more
|
|
710
|
+
// kind of frame beside the zone and context entries. Element-level
|
|
711
|
+
// `color:` earns nothing: a hue name is not a label.
|
|
712
|
+
const byTag = new Map();
|
|
713
|
+
for (const c of colors)
|
|
714
|
+
byTag.set(c.tag, c.hue);
|
|
715
|
+
for (const [tag, hue] of byTag)
|
|
716
|
+
items.push({
|
|
717
|
+
sample: (x, cy) => `<rect x="${x + 5}" y="${cy - 5}" width="14" height="10" rx="2" fill="${hueOf(t, hue)}"/>`,
|
|
718
|
+
label: `#${tag}`,
|
|
719
|
+
});
|
|
720
|
+
if (!items.length)
|
|
721
|
+
return { h: 0, w: 0 };
|
|
722
|
+
const cy = y + 12;
|
|
723
|
+
let x = 16;
|
|
724
|
+
for (const it of items) {
|
|
725
|
+
L.push(it.sample(x, cy));
|
|
726
|
+
x += 24 + 8;
|
|
727
|
+
L.push(`<text x="${x}" y="${cy + 4}" font-size="${rc.fx(11)}" fill="${t.muted}">${esc(it.label)}</text>`);
|
|
728
|
+
x += Math.round(measure(it.label, rc.fx(11), "400", rc.fam)) + 24;
|
|
729
|
+
}
|
|
730
|
+
return { h: 24, w: x - 24 };
|
|
731
|
+
}
|
|
732
|
+
/** Reserved keys the meta chip lays out in a fixed order, each in its own
|
|
733
|
+
* segment. Everything else an author writes follows in declaration order —
|
|
734
|
+
* nothing is dropped, because a titleblock is free-form by design and the
|
|
735
|
+
* corpus already carries `owner` and `status`. */
|
|
736
|
+
const META_ORDER = ["version", "commit", "date"];
|
|
737
|
+
/** The header block: name, subtitle, and a segmented meta chip. Top-left,
|
|
738
|
+
* where a drawing's identity belongs — it used to sit bottom-right, which put
|
|
739
|
+
* the two things a reader needs first (what is this, which version) in the
|
|
740
|
+
* last place they look. Returns the band's height so the body can shift. */
|
|
741
|
+
function headerDims(rc, title, kv) {
|
|
742
|
+
if (!title && !Object.keys(kv).length)
|
|
743
|
+
return { h: 0, segs: [], chipW: 0 };
|
|
744
|
+
const known = new Set([...META_ORDER, "subtitle"]);
|
|
745
|
+
const segs = [
|
|
746
|
+
...META_ORDER.filter((k) => kv[k]).map((k) => [k, kv[k]]),
|
|
747
|
+
...Object.entries(kv).filter(([k]) => !known.has(k)),
|
|
748
|
+
];
|
|
749
|
+
// A reserved key's value speaks for itself — `v4.2`, `a41f0c2`, a date. An
|
|
750
|
+
// arbitrary one does not: `platform` alone says nothing, so `owner platform`
|
|
751
|
+
// is what goes in the segment. Losing the key would be losing the fact.
|
|
752
|
+
const segW = (k, v) => Math.ceil(measure(v, rc.fx(10.5), k === "commit" ? "400" : "500", k === "commit" ? "mono" : rc.fam)) +
|
|
753
|
+
(META_ORDER.includes(k) ? 0 : Math.ceil(measure(`${k} `, rc.fx(10.5), "400", rc.fam))) + 14;
|
|
754
|
+
const chipW = segs.length ? segs.reduce((a, [k, v]) => a + segW(k, v), 0) : 0;
|
|
755
|
+
const h = (title ? 24 : 0) + (kv.subtitle ? 16 : 0) + (segs.length ? 28 : 0);
|
|
756
|
+
return { h, segs, chipW };
|
|
757
|
+
}
|
|
758
|
+
function header(rc, x, y, title, kv, dims, L) {
|
|
759
|
+
const { t } = rc;
|
|
760
|
+
let ty = y;
|
|
761
|
+
if (title) {
|
|
762
|
+
// -.01em: at 19px the default tracking reads loose against the 11.5px
|
|
763
|
+
// subtitle under it. Negative, so the measured width is an overestimate
|
|
764
|
+
// and nothing it is fitted against can overflow.
|
|
765
|
+
ty += 18;
|
|
766
|
+
L.push(`<text x="${x}" y="${ty}" font-size="${rc.fx(19)}" font-weight="600" letter-spacing="-.01em" fill="${t.ink}">${esc(title)}</text>`);
|
|
767
|
+
rc.faces.add("inter:600");
|
|
768
|
+
ty += 6;
|
|
769
|
+
}
|
|
770
|
+
if (kv.subtitle) {
|
|
771
|
+
ty += 12;
|
|
772
|
+
L.push(`<text x="${x}" y="${ty}" font-size="${rc.fx(11.5)}" fill="${t.muted}">${esc(kv.subtitle)}</text>`);
|
|
773
|
+
ty += 4;
|
|
774
|
+
}
|
|
775
|
+
if (!dims.segs.length)
|
|
776
|
+
return;
|
|
777
|
+
// The meta chip: one hairline-bordered strip, segments in alternating tints
|
|
778
|
+
// so version / commit / date read as separate facts rather than a sentence.
|
|
779
|
+
// Clipped to the strip's radius, so a bed at either end takes the corner.
|
|
780
|
+
ty += 8;
|
|
781
|
+
const clip = `sq-meta-${dims.chipW}`;
|
|
782
|
+
L.push(def(rc, clip, `<clipPath id="${clip}"><rect x="0" y="0" width="${dims.chipW}" height="20" rx="3"/></clipPath>`));
|
|
783
|
+
const inChip = [];
|
|
784
|
+
let sx = 0;
|
|
785
|
+
for (const [i, [k, v]] of dims.segs.entries()) {
|
|
786
|
+
const mono = k === "commit";
|
|
787
|
+
const named = !META_ORDER.includes(k);
|
|
788
|
+
const keyW = named ? Math.ceil(measure(`${k} `, rc.fx(10.5), "400", rc.fam)) : 0;
|
|
789
|
+
const w = Math.ceil(measure(v, rc.fx(10.5), mono ? "400" : "500", mono ? "mono" : rc.fam)) + keyW + 14;
|
|
790
|
+
if (i % 2)
|
|
791
|
+
inChip.push(`<rect x="${sx}" y="0" width="${w}" height="20" fill="${t.plate}"/>`);
|
|
792
|
+
if (named)
|
|
793
|
+
inChip.push(`<text x="${sx + 7}" y="14" font-size="${rc.fx(10.5)}" fill="${t.faint}">${esc(k)}</text>`);
|
|
794
|
+
// the date is the least urgent of the three reserved facts, and reads so
|
|
795
|
+
const ink = k === "date" ? t.faint : t.muted;
|
|
796
|
+
inChip.push(`<text x="${sx + 7 + keyW}" y="14" font-size="${rc.fx(10.5)}"` +
|
|
797
|
+
(mono ? ` font-family="${MONO_CSS}"` : ` font-weight="500"`) +
|
|
798
|
+
` fill="${ink}">${esc(v)}</text>`);
|
|
799
|
+
if (mono)
|
|
800
|
+
rc.faces.add("mono:400");
|
|
801
|
+
sx += w;
|
|
802
|
+
}
|
|
803
|
+
L.push(`<g transform="translate(${x},${ty})">` +
|
|
804
|
+
`<rect x="0" y="0" width="${dims.chipW}" height="20" rx="3" fill="${t.surface}"/>` +
|
|
805
|
+
`<g clip-path="url(#${clip})">${inChip.join("")}</g>` +
|
|
806
|
+
`<rect x="0" y="0" width="${dims.chipW}" height="20" rx="3" fill="none" stroke="${t.border}" stroke-width="1"/></g>`);
|
|
807
|
+
}
|
|
808
|
+
/** Overlap with a margin. 4 is the standing figure — `docs/notes/edge-labels.md`
|
|
809
|
+
* records relaxing it as tried and rejected ("the label then visually kisses
|
|
810
|
+
* the node border, and it loosens every tight placement everywhere"). */
|
|
811
|
+
const hits = (a, b, m = 4) => a.x < b.x + b.w + m && a.x + a.w + m > b.x && a.y < b.y + b.h + m && a.y + a.h + m > b.y;
|
|
812
|
+
/**
|
|
813
|
+
* Compute all edge-label pills, then resolve collisions deterministically:
|
|
814
|
+
* a pill overlapping a node or an earlier pill shifts down until clear
|
|
815
|
+
* (DESIGN: a label never sits on another label — enforced, not hoped).
|
|
816
|
+
*/
|
|
817
|
+
function computePills(p, rc, edgeMatches) {
|
|
818
|
+
// Until label-space reservation (2026-08) this function was a 116-line
|
|
819
|
+
// placement search: obstacle lists, a nine-fraction candidate ladder over
|
|
820
|
+
// segments ranked longest-first, an overhang allowance, a relocate fallback
|
|
821
|
+
// and a shared baseline for detached labels — all machinery for finding room
|
|
822
|
+
// in gutters that were never sized to hold a label. The room is now made at
|
|
823
|
+
// layout time (ELK inline labels cross-rank, gutter/lane reservation
|
|
824
|
+
// coplanar; docs/notes/edge-labels.md), every labelled edge carries
|
|
825
|
+
// `labelRect`, and a pill cannot collide with anything because the space it
|
|
826
|
+
// occupies exists on purpose. The invariant sweep asserts exactly that over
|
|
827
|
+
// the whole corpus.
|
|
828
|
+
const pills = [];
|
|
829
|
+
for (const e of p.edges) {
|
|
830
|
+
if (!e.label)
|
|
831
|
+
continue;
|
|
832
|
+
const dims = pillDims(e.label, { metrics: rc.fam, scale: rc.t.font.scale });
|
|
833
|
+
// A labelled edge without a reservation cannot happen for engine-produced
|
|
834
|
+
// layouts; the midpoint fallback keeps a hand-built `Positioned` (tests,
|
|
835
|
+
// future tooling) drawing its label rather than silently dropping it.
|
|
836
|
+
const r = e.labelRect ?? {
|
|
837
|
+
x: Math.round((e.points[0].x + e.points[e.points.length - 1].x) / 2 - dims.w / 2),
|
|
838
|
+
y: Math.round((e.points[0].y + e.points[e.points.length - 1].y) / 2) - 9,
|
|
839
|
+
w: dims.w, h: 18,
|
|
840
|
+
};
|
|
841
|
+
const py = r.y + Math.round((r.h - 18) / 2);
|
|
842
|
+
pills.push({
|
|
843
|
+
x: r.x, y: py, w: r.w, h: 18, mx: r.x + Math.round(r.w / 2),
|
|
844
|
+
label: dims.label, dimmed: !edgeMatches(e), edgeId: e.id,
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
return pills;
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Zone label chips straddle their zone's top border. ELK doesn't treat labels
|
|
851
|
+
* as routing obstacles (spiked: outside compound labels get routed straight
|
|
852
|
+
* through), so this is ours, same philosophy as computePills: slide the chip
|
|
853
|
+
* right along the border in grid steps to the first spot clear of edges,
|
|
854
|
+
* nodes and pills — falling back to the least-crossed spot. The canvas halo
|
|
855
|
+
* (drawn in chipMarkup) keeps even the fallback legible.
|
|
856
|
+
*/
|
|
857
|
+
// placeZoneChips moved to layout.ts (Positioned consolidation): chip geometry
|
|
858
|
+
// is layout's; the colour below is the renderer's.
|
|
859
|
+
/** Tints of the zone's own colour, as 8-digit hex. The chip is built from one
|
|
860
|
+
* hue at three strengths — border, label bed, detail bed — so a boundary's
|
|
861
|
+
* identity carries across every segment without a second colour entering the
|
|
862
|
+
* palette. Alpha in the fill (not `fill-opacity`) keeps the adaptive merge
|
|
863
|
+
* treating these as colour rather than geometry. */
|
|
864
|
+
const CHIP_BORDER_A = "59"; // 35%
|
|
865
|
+
const CHIP_LABEL_A = "1F"; // 12%
|
|
866
|
+
const CHIP_DETAIL_A = "33"; // 20%
|
|
867
|
+
function chipMarkup(c, p, rc, t) {
|
|
868
|
+
const zone = (p.zones ?? []).find((z) => z.id === c.zone);
|
|
869
|
+
const col = zone ? zoneColor(zone, t) : t.muted;
|
|
870
|
+
// halo first: a canvas knockout 3px proud of the chip, so any edge the chip
|
|
871
|
+
// must sit over reads as deliberately interrupted, never collided-with
|
|
872
|
+
const halo = `<rect x="${c.x - 3}" y="${c.y - 3}" width="${c.w + 6}" height="${c.h + 6}" rx="4" fill="${t.canvas}"/>`;
|
|
873
|
+
// Border only, and drawn last: the segment beds are painted underneath it,
|
|
874
|
+
// so a filled chip rect here would erase them. The halo above already lays
|
|
875
|
+
// the canvas knockout this sits on.
|
|
876
|
+
const chip = `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" rx="3" fill="none" stroke="${col}${CHIP_BORDER_A}" stroke-width="1"/>`;
|
|
877
|
+
// the icon is a flush, full-height tab on the chip's left edge — the AWS
|
|
878
|
+
// boundary-label convention — never a padded thumbnail floating in the pill.
|
|
879
|
+
// Full-bleed artwork (k8s) is the exception: drawn edge-to-edge it collides
|
|
880
|
+
// with the chip border, so it gets a small inset inside the same tab slot —
|
|
881
|
+
// the text position never moves.
|
|
882
|
+
const inset = c.icon && packFullBleed(c.icon.pack) ? 3 : 0;
|
|
883
|
+
// A square tab, and the label bed starts where it ends — no gap. The tab
|
|
884
|
+
// used to be `c.h + 4` wide while the artwork filled only `c.h`, so 4px of
|
|
885
|
+
// the canvas knockout showed between icon and bed as a white sliver.
|
|
886
|
+
const iconTab = c.icon ? c.h : 0;
|
|
887
|
+
const icon = c.icon
|
|
888
|
+
? iconPlate(c.icon, c.x + inset, c.y + inset, c.h - 2 * inset, rc)
|
|
889
|
+
: "";
|
|
890
|
+
const tx = c.x + 8 + iconTab;
|
|
891
|
+
// Segments are clipped to the chip's rounded rect so a bed reaching an end
|
|
892
|
+
// takes the corner with it — a square bed inside a 3px radius shows as two
|
|
893
|
+
// pale wedges at that end.
|
|
894
|
+
const clip = `sq-chip-${c.x}-${c.y}-${c.w}-${c.h}`;
|
|
895
|
+
const clipDef = def(rc, clip, `<clipPath id="${clip}"><rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" rx="3"/></clipPath>`);
|
|
896
|
+
// clip-path on the rects themselves, not a wrapping <g>: the beds are plain
|
|
897
|
+
// rects (the "clip a wrapping g" rule is about `<use>`), and a nested group
|
|
898
|
+
// here would hide the icon from anything reading the chip as one element.
|
|
899
|
+
const clipped = ` clip-path="url(#${clip})"`;
|
|
900
|
+
// The tab is its own quiet step (docs/design): the icon sits on surface, not
|
|
901
|
+
// on the canvas the chip floats over, so a transparent mark reads against a
|
|
902
|
+
// plate rather than against whatever the chip happens to cover.
|
|
903
|
+
const iconBed = c.icon
|
|
904
|
+
? `<rect x="${c.x}" y="${c.y}" width="${iconTab}" height="${c.h}"${clipped} fill="${t.surface}"/>`
|
|
905
|
+
: "";
|
|
906
|
+
const labelBed = `<rect x="${c.x + iconTab}" y="${c.y}" width="${c.w - iconTab - (c.detailW ?? 0)}" height="${c.h}"${clipped} fill="${col}${CHIP_LABEL_A}"/>`;
|
|
907
|
+
const detail = c.detail
|
|
908
|
+
? `<rect x="${c.x + c.w - c.detailW}" y="${c.y}" width="${c.detailW}" height="${c.h}"${clipped} fill="${col}${CHIP_DETAIL_A}"/>` +
|
|
909
|
+
// mono, because this segment is where digits live and a CIDR read in a
|
|
910
|
+
// proportional face is a different width in every diagram
|
|
911
|
+
`<text x="${c.x + c.w - c.detailW + 8}" y="${c.y + 15}" font-size="${rc.fx(11)}" font-family="${MONO_CSS}" fill="${col}">${esc(c.detail)}</text>`
|
|
912
|
+
: "";
|
|
913
|
+
if (c.detail)
|
|
914
|
+
rc.faces.add("mono:400");
|
|
915
|
+
return (`${clipDef}<g data-kind="zone-chip" data-zone="${esc(c.zone)}">${halo}` +
|
|
916
|
+
`${iconBed}${labelBed}${detail}${chip}${icon}` +
|
|
917
|
+
`<text x="${tx}" y="${c.y + 15}" font-size="${rc.fx(11)}" font-weight="500" fill="${col}">${esc(c.label)}</text></g>`);
|
|
918
|
+
}
|
|
919
|
+
function pillMarkup(pill, rc) {
|
|
920
|
+
const { t } = rc;
|
|
921
|
+
const op = pill.dimmed ? ` opacity="${DIM}"` : "";
|
|
922
|
+
return (`<g${op}>` + box(rc, pill.x, pill.y, pill.w, pill.h, 2, t.surface, t.border, 1) +
|
|
923
|
+
`<text x="${pill.mx}" y="${pill.y + 13}" text-anchor="middle" font-size="${rc.fx(11)}" fill="${t.muted}">${esc(pill.label)}</text></g>`);
|
|
924
|
+
}
|
|
925
|
+
/** Record a def, or emit it inline when nothing is collecting. Re-recording an
|
|
926
|
+
* id with different markup is a bug in whatever made it theme-dependent, not
|
|
927
|
+
* something to paper over — the export would silently draw one view with
|
|
928
|
+
* another's definition. */
|
|
929
|
+
function def(rc, id, markup) {
|
|
930
|
+
if (!rc.collect)
|
|
931
|
+
return markup;
|
|
932
|
+
const seen = rc.collect.get(id);
|
|
933
|
+
if (seen !== undefined && seen !== markup)
|
|
934
|
+
throw new Error(`def \`${id}\` differs between renders sharing a document — it depends on ` +
|
|
935
|
+
`something (theme?) that a shared definition cannot carry. Give it a defsScope.`);
|
|
936
|
+
rc.collect.set(id, markup);
|
|
937
|
+
return "";
|
|
938
|
+
}
|
|
939
|
+
/** One <symbol> per distinct icon used in this render, in stable order. */
|
|
940
|
+
function iconDefs(p, rc) {
|
|
941
|
+
const used = new Map();
|
|
942
|
+
const note = (icon) => {
|
|
943
|
+
if (icon)
|
|
944
|
+
used.set(`${icon.pack}/${icon.id}`, icon);
|
|
945
|
+
};
|
|
946
|
+
for (const n of p.nodes) {
|
|
947
|
+
note(n.icon);
|
|
948
|
+
note(n.glyph);
|
|
949
|
+
note(n.badge);
|
|
950
|
+
for (const prev of n.preview)
|
|
951
|
+
note(prev);
|
|
952
|
+
}
|
|
953
|
+
for (const z of p.zones ?? [])
|
|
954
|
+
note(z.icon);
|
|
955
|
+
const symbols = [];
|
|
956
|
+
for (const key of [...used.keys()].sort()) {
|
|
957
|
+
const { pack, id } = used.get(key);
|
|
958
|
+
const asset = iconAsset(pack, id);
|
|
959
|
+
if (!asset)
|
|
960
|
+
continue;
|
|
961
|
+
// artwork inlined verbatim inside the symbol; placement is ours, the asset is untouched
|
|
962
|
+
symbols.push(`<symbol id="${symbolId(pack, id)}" viewBox="${asset.viewBox}">${asset.body}</symbol>`);
|
|
963
|
+
}
|
|
964
|
+
if (rc.collect) {
|
|
965
|
+
for (const sym of symbols)
|
|
966
|
+
def(rc, /id="([^"]+)"/.exec(sym)[1], sym);
|
|
967
|
+
return "";
|
|
968
|
+
}
|
|
969
|
+
return symbols.length ? `<defs>\n${symbols.join("\n")}\n</defs>` : "";
|
|
970
|
+
}
|
|
971
|
+
/** A node's main icon: one shell for every kind of mark — a PLATE-sized
|
|
972
|
+
* neutral tile with the artwork inset at ICON_ART (docs/design). The tile is
|
|
973
|
+
* what keeps pack art off the card surface: vendor artwork is drawn on white
|
|
974
|
+
* in its own guidelines, and against a gradient it reads as pasted on. A
|
|
975
|
+
* monochrome mark becomes `iconPlate`'s brand-coloured knockout chip in the
|
|
976
|
+
* same inset, so a GitHub chip and a Lambda have identical anatomy.
|
|
977
|
+
*
|
|
978
|
+
* This replaced the ink-on-neutral-tile treatment (2026-08): drawing the mark
|
|
979
|
+
* in its brand colour needed a white bed in dark mode (`brandPlate`, now
|
|
980
|
+
* gone) because navy ink on a dark tile was a 1.4:1 smudge — while the chip's
|
|
981
|
+
* knockout ink carries its own contrast in either theme. It also ended the
|
|
982
|
+
* split where shelf chips were knockout and tiles were not. */
|
|
983
|
+
function iconTile(icon, x, y, rc, soften = false) {
|
|
984
|
+
const { t } = rc;
|
|
985
|
+
const inset = Math.round((PLATE - ICON_ART) / 2);
|
|
986
|
+
const tile = `<rect x="${x}" y="${y}" width="${PLATE}" height="${PLATE}" rx="6" fill="${t.plate}"${soften ? ` opacity="0.6"` : ""}/>`;
|
|
987
|
+
return tile + iconPlate(icon, x + inset, y + inset, ICON_ART, rc, soften);
|
|
988
|
+
}
|
|
989
|
+
/** Perceived lightness of a `#rrggbb` chip, as the integer
|
|
990
|
+
* `2126·R + 7152·G + 722·B` (0…2,550,000). Deliberately NOT the WCAG
|
|
991
|
+
* formula: gamma correction runs through `pow()`, which carries no
|
|
992
|
+
* bit-identical guarantee across platforms, and the determinism contract
|
|
993
|
+
* byte-compares goldens on three OSes. A threshold needs a stable line with
|
|
994
|
+
* the right chips on each side, not colourimetric truth — integer multiply
|
|
995
|
+
* and add are exact everywhere. Validated against the WCAG measure over all
|
|
996
|
+
* installed packs: everything below 2.5:1 white-contrast lands above the
|
|
997
|
+
* line, and the only disagreements are borderline chips that flip to dark
|
|
998
|
+
* ink harmlessly early. */
|
|
999
|
+
const chipLightness = (hex) => {
|
|
1000
|
+
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
|
1001
|
+
return 2126 * r + 7152 * g + 722 * b;
|
|
1002
|
+
};
|
|
1003
|
+
const LIGHT_CHIP = 1_500_000;
|
|
1004
|
+
const KNOCKOUT_DARK = "#1C1C1A";
|
|
1005
|
+
function iconPlate(icon, x, y, size, rc, soften = false) {
|
|
1006
|
+
const { t } = rc;
|
|
1007
|
+
const meta = icon ? iconMeta(icon.pack, icon.id) : undefined;
|
|
1008
|
+
const asset = icon ? iconAsset(icon.pack, icon.id) : undefined;
|
|
1009
|
+
const r = Math.max(2, Math.round(size / 10));
|
|
1010
|
+
if (asset && icon && (icon.pack === "builtin" || packMonochrome(icon.pack))) {
|
|
1011
|
+
// single-colour marks — our own glyphs and logo packs alike: a coloured
|
|
1012
|
+
// plate with the mark knocked out of it, so a wordless logo still reads.
|
|
1013
|
+
// The knockout ink follows the chip: white carries a dark chip, but 17 of
|
|
1014
|
+
// the logos hexes are light (JavaScript's yellow, React's cyan) where
|
|
1015
|
+
// white is a 1.2–1.6:1 ghost — and dark-on-colour is those brands' own
|
|
1016
|
+
// usage anyway. One deterministic threshold, no per-icon table.
|
|
1017
|
+
const pad = Math.round(size * 0.2);
|
|
1018
|
+
const chip = meta?.color ?? t.muted;
|
|
1019
|
+
const ink = chipLightness(chip) > LIGHT_CHIP ? KNOCKOUT_DARK : t.plateText;
|
|
1020
|
+
return (`<rect x="${x}" y="${y}" width="${size}" height="${size}" rx="${r}" fill="${chip}"${soften ? ` opacity="0.6"` : ""}/>` +
|
|
1021
|
+
// `fill` as well as `color`: our own glyphs paint with currentColor, but
|
|
1022
|
+
// vendored marks (Simple Icons) carry no fill at all and would default to
|
|
1023
|
+
// black — invisible on a dark brand plate. fill is inherited, and any
|
|
1024
|
+
// glyph that sets its own fill still wins.
|
|
1025
|
+
`<g color="${ink}" fill="${ink}"${soften ? ` opacity="0.9"` : ""}>` +
|
|
1026
|
+
`<use href="#${symbolId(icon.pack, icon.id)}" x="${x + pad}" y="${y + pad}" width="${size - pad * 2}" height="${size - pad * 2}"/>` +
|
|
1027
|
+
`</g>`);
|
|
1028
|
+
}
|
|
1029
|
+
if (asset && icon) {
|
|
1030
|
+
// clip-path directly on <use> stops it instantiating in some renderers —
|
|
1031
|
+
// wrap instead, so the artwork still gets our rounded plate corners.
|
|
1032
|
+
const clip = `clip-${symbolId(icon.pack, icon.id)}-${x}-${y}-${size}`;
|
|
1033
|
+
return (def(rc, clip, `<clipPath id="${clip}"><rect x="${x}" y="${y}" width="${size}" height="${size}" rx="${r}"/></clipPath>`) +
|
|
1034
|
+
`<g clip-path="url(#${clip})"${soften ? ` opacity="0.6"` : ""}>` +
|
|
1035
|
+
`<use href="#${symbolId(icon.pack, icon.id)}" x="${x}" y="${y}" width="${size}" height="${size}"/>` +
|
|
1036
|
+
`</g>`);
|
|
1037
|
+
}
|
|
1038
|
+
const code = meta?.code ?? "?";
|
|
1039
|
+
return (`<rect x="${x}" y="${y}" width="${size}" height="${size}" rx="${r}" fill="${meta?.color ?? t.muted}"${soften ? ` opacity="0.6"` : ""}/>` +
|
|
1040
|
+
(size >= 24
|
|
1041
|
+
? `<text x="${x + size / 2}" y="${y + size / 2 + 4}" text-anchor="middle" font-size="${rc.fx(11)}" font-weight="500" fill="${t.plateText}">${esc(code)}</text>`
|
|
1042
|
+
: ""));
|
|
1043
|
+
}
|
|
1044
|
+
export function renderSVG(p, t, opts = {}) {
|
|
1045
|
+
const rc = {
|
|
1046
|
+
t,
|
|
1047
|
+
fam: t.font.metrics,
|
|
1048
|
+
fx: (px) => Math.round(px * t.font.scale),
|
|
1049
|
+
faces: new Set(),
|
|
1050
|
+
hatch: `${HATCH}${opts.defsScope ?? ""}`,
|
|
1051
|
+
grad: `${SURFACE_GRAD}${opts.defsScope ?? ""}`,
|
|
1052
|
+
actorGrad: `${ACTOR_GRAD}${opts.defsScope ?? ""}`,
|
|
1053
|
+
shadow: `${SHADOW}${opts.defsScope ?? ""}`,
|
|
1054
|
+
collect: opts.collectDefs,
|
|
1055
|
+
};
|
|
1056
|
+
const hl = opts.highlight ?? [];
|
|
1057
|
+
const byPath = new Map(p.nodes.map((n) => [n.path, n]));
|
|
1058
|
+
// Walking a flow (`flowStep`) dims by *progress* rather than by tag: an edge
|
|
1059
|
+
// is reached once one of its step numbers is due, and a node once an edge
|
|
1060
|
+
// touching it is. Both dimming rules compose — a highlighted view that also
|
|
1061
|
+
// steps a flow dims anything failing either test.
|
|
1062
|
+
const stepsOf = (e) => p.flow?.byEdge[e.id] ?? [];
|
|
1063
|
+
const walking = opts.flowStep !== undefined && !!p.flow;
|
|
1064
|
+
// the declared step numbers that render here, in order — the ordinal indexes
|
|
1065
|
+
// into this, so hop 1 is the first one on screen whatever it is called
|
|
1066
|
+
const visible = p.flow
|
|
1067
|
+
? [...new Set(Object.values(p.flow.byEdge).flat())].sort((a, b) => a - b)
|
|
1068
|
+
: [];
|
|
1069
|
+
const step = walking
|
|
1070
|
+
? visible[Math.min(Math.max(opts.flowStep, 0), visible.length) - 1]
|
|
1071
|
+
: undefined;
|
|
1072
|
+
const edgeReached = (e) => !walking || (step !== undefined && stepsOf(e).some((s) => s <= step));
|
|
1073
|
+
const reachedNodes = new Set();
|
|
1074
|
+
if (walking)
|
|
1075
|
+
for (const e of p.edges)
|
|
1076
|
+
if (edgeReached(e))
|
|
1077
|
+
reachedNodes.add(e.from), reachedNodes.add(e.to);
|
|
1078
|
+
const nodeMatches = (n) => (hl.length === 0 || n.tags.some((tag) => hl.includes(tag))) &&
|
|
1079
|
+
(!walking || reachedNodes.has(n.path));
|
|
1080
|
+
// An edge lights up when it carries the tag itself, or when both its endpoints
|
|
1081
|
+
// do. Edge-level `tags:` parse, are stored on the model, and are documented in
|
|
1082
|
+
// SKILL.md (`api -> create { tags: #hot-path }`) — but they never reached the
|
|
1083
|
+
// view graph, so `highlight #hot-path` dimmed the very edge it named. A
|
|
1084
|
+
// hot-path or a PCI wire is exactly the thing whose endpoints are often
|
|
1085
|
+
// ordinary, which is what makes tagging the edge worth doing at all.
|
|
1086
|
+
// An endpoint can be an expanded frame rather than a node (the edge attaches
|
|
1087
|
+
// to the compound border). A frame holds members of many tags, so it never
|
|
1088
|
+
// blocks its edge — the leaf end decides.
|
|
1089
|
+
const endpointMatches = (path) => {
|
|
1090
|
+
const n = byPath.get(path);
|
|
1091
|
+
return n ? nodeMatches(n) : true;
|
|
1092
|
+
};
|
|
1093
|
+
const edgeMatches = (e) => (hl.length === 0 ||
|
|
1094
|
+
e.tags.some((tag) => hl.includes(tag)) ||
|
|
1095
|
+
(endpointMatches(e.from) && endpointMatches(e.to))) &&
|
|
1096
|
+
edgeReached(e);
|
|
1097
|
+
const body = [];
|
|
1098
|
+
// zone boundaries first — the classic dashed deployment frame, behind
|
|
1099
|
+
// everything, outermost first (DESIGN §5). Kind picks the tint. The label
|
|
1100
|
+
// chips render LAST (top layer, after edges) so their canvas halo knocks
|
|
1101
|
+
// out anything they must sit over — see placeZoneChips below.
|
|
1102
|
+
const zoneMarkup = (z) => {
|
|
1103
|
+
const col = zoneColor(z, t);
|
|
1104
|
+
const dash = ` stroke-dasharray="8 5"`;
|
|
1105
|
+
// No fill, deliberately: a tint compounds where zones nest, so a subnet
|
|
1106
|
+
// inside a VPC read darker than either — the boundary is the dashed line,
|
|
1107
|
+
// and nesting must not change its weight (docs/design).
|
|
1108
|
+
const boundary = `<rect x="${z.x}" y="${z.y}" width="${z.w}" height="${z.h}" rx="8" fill="none" stroke="${col}" stroke-width="1.5"${dash}/>`;
|
|
1109
|
+
return `<g data-kind="zone" data-zone="${esc(z.id)}" data-zone-kind="${z.kind}">${boundary}</g>`;
|
|
1110
|
+
};
|
|
1111
|
+
for (const z of [...(p.zones ?? [])].sort((a, b) => a.depth - b.depth))
|
|
1112
|
+
body.push(zoneMarkup(z));
|
|
1113
|
+
// container frames first — recessed surface behind everything (DESIGN §5),
|
|
1114
|
+
// outermost first so an `expand *` ladder paints parent-then-child. No fill
|
|
1115
|
+
// past depth 0, deliberately: surfaceAlt compounds where frames nest, and
|
|
1116
|
+
// the recession must say "opened" once rather than encode depth as darkness
|
|
1117
|
+
// — the zones rule, applied to frames (docs/notes/full-detail.md).
|
|
1118
|
+
for (const f of [...p.frames].sort((a, b) => a.depth - b.depth)) {
|
|
1119
|
+
const fill = f.depth === 0 ? t.surfaceAlt : "none";
|
|
1120
|
+
// data-depth only past depth 0: every existing render has only depth-0
|
|
1121
|
+
// frames, and the determinism contract keeps those byte-identical
|
|
1122
|
+
const depthAttr = f.depth > 0 ? ` data-depth="${f.depth}"` : "";
|
|
1123
|
+
body.push(`<rect data-path="${esc(f.path)}" data-kind="frame"${depthAttr} x="${f.x}" y="${f.y}" width="${f.w}" height="${f.h}" rx="8" fill="${fill}" stroke="${f.color ? hueOf(t, f.color) : t.border}" stroke-width="1"/>`);
|
|
1124
|
+
body.push(`<text x="${f.x + 14}" y="${f.y + 24}" font-size="${rc.fx(13)}" font-weight="500" fill="${t.muted}">${esc(f.label)}</text>`);
|
|
1125
|
+
}
|
|
1126
|
+
const hops = hopPoints(p.edges);
|
|
1127
|
+
for (const e of p.edges) {
|
|
1128
|
+
const dimmed = !edgeMatches(e);
|
|
1129
|
+
// the hop being narrated right now: accent-coloured and heavier, so the
|
|
1130
|
+
// eye lands on it without having to hunt for the badge
|
|
1131
|
+
const current = step !== undefined && stepsOf(e).includes(step);
|
|
1132
|
+
// the narrated hop outranks an authored hue: while a flow is being walked,
|
|
1133
|
+
// "where we are" is the one thing colour has to say
|
|
1134
|
+
const col = current ? t.accent : e.color ? hueOf(t, e.color) : e.async ? t.asyncEdge : t.edge;
|
|
1135
|
+
const weight = current ? 2.5 : 1.5;
|
|
1136
|
+
// The pattern is a presentation attribute, never CSS: resvg ignores
|
|
1137
|
+
// stylesheets, so the static dashes are what a PNG export shows. `packets`
|
|
1138
|
+
// draws its own sparse pattern; otherwise the resolved style decides
|
|
1139
|
+
// (async edges resolve to `dashed` by default, so their output here is
|
|
1140
|
+
// byte-identical to when this line only knew about `e.async`).
|
|
1141
|
+
const pattern = e.animate === "packets" ? "3 15"
|
|
1142
|
+
: e.style === "dashed" ? "6 5"
|
|
1143
|
+
: e.style === "dotted" ? "2 3" : undefined;
|
|
1144
|
+
const dash = pattern ? ` stroke-dasharray="${pattern}"` : "";
|
|
1145
|
+
// Animation: dashes drift at constant px/s (shared keyframes with a fixed
|
|
1146
|
+
// dash period, so long edges never "flow faster"); CSS only, and
|
|
1147
|
+
// prefers-reduced-motion turns it all off. One class per animate value.
|
|
1148
|
+
// comet animates a separate element, not the stroke: the wire keeps
|
|
1149
|
+
// whatever `style:` says and the dot rides it.
|
|
1150
|
+
const cls = e.animate ? ANIM_CLASS[e.animate] : undefined;
|
|
1151
|
+
const anim = cls ? ` class="${cls}"` : "";
|
|
1152
|
+
const op = dimmed ? ` opacity="${DIM}"` : "";
|
|
1153
|
+
const myHops = hops.get(e.id);
|
|
1154
|
+
const runs = myHops?.length ? splitAtHops(e.points, myHops) : [e.points];
|
|
1155
|
+
body.push(`<g${op}>`);
|
|
1156
|
+
for (const run of runs) {
|
|
1157
|
+
const d = edgePath(run, p.lines);
|
|
1158
|
+
body.push(`<path${anim} d="${d}" fill="none" stroke="${col}" stroke-width="${weight}"${dash}/>`);
|
|
1159
|
+
}
|
|
1160
|
+
if (e.animate === "comet") {
|
|
1161
|
+
// The dot rides the *unsplit* route. Hop splitting exists to break the
|
|
1162
|
+
// stroke where two edges cross; the traveller has no such problem, and a
|
|
1163
|
+
// dot sailing over the gap is what a reader expects.
|
|
1164
|
+
const road = edgePath(e.points, p.lines);
|
|
1165
|
+
// Constant px/s, like every other animation here — but a comet cannot get
|
|
1166
|
+
// it from a shared keyframe, so the duration carries it: len ÷ speed.
|
|
1167
|
+
// Polyline sum, deliberately ignoring corner rounding (it shortens a
|
|
1168
|
+
// corner by under half its radius). sqrt is exact per IEEE-754, so this
|
|
1169
|
+
// is the same number on every platform; the round keeps it the same
|
|
1170
|
+
// *string* too, which is what the byte-compare actually gates on.
|
|
1171
|
+
let len = 0;
|
|
1172
|
+
for (let i = 1; i < e.points.length; i++)
|
|
1173
|
+
len += Math.hypot(e.points[i].x - e.points[i - 1].x, e.points[i].y - e.points[i - 1].y);
|
|
1174
|
+
const secs = Math.max(COMET_MIN_S, Math.round((len / COMET_PX_S) * 100) / 100);
|
|
1175
|
+
// opacity as a presentation attribute, not CSS: resvg reads those and
|
|
1176
|
+
// ignores CSS, so a PNG export gets no stray dot parked at the origin.
|
|
1177
|
+
body.push(`<circle r="3.5" fill="${col}" opacity="0"` +
|
|
1178
|
+
` style="offset-path:path('${road}');animation:sq-comet ${secs}s linear infinite"/>`);
|
|
1179
|
+
}
|
|
1180
|
+
// pulse breathes the whole edge, arrowhead included; travel animations
|
|
1181
|
+
// stay off the head — a drifting chevron reads as the head detaching
|
|
1182
|
+
body.push(arrow(e, t, p.lines, col, e.animate === "pulse" ? anim : ""));
|
|
1183
|
+
body.push(`</g>`);
|
|
1184
|
+
}
|
|
1185
|
+
for (const n of p.nodes) {
|
|
1186
|
+
const dimmed = !nodeMatches(n);
|
|
1187
|
+
if (n.kind === "card" || n.kind === "context-card")
|
|
1188
|
+
card(n, rc, dimmed, body);
|
|
1189
|
+
else if (n.kind === "person")
|
|
1190
|
+
person(n, rc, opts, dimmed, body);
|
|
1191
|
+
else
|
|
1192
|
+
leaf(n, rc, opts, dimmed, body);
|
|
1193
|
+
if (!dimmed && hl.length > 0)
|
|
1194
|
+
body.push(`<rect x="${n.x - 3}" y="${n.y - 3}" width="${n.w + 6}" height="${n.h + 6}" rx="${R_NODE + 3}" fill="none" stroke="${t.accent}" stroke-width="1.5" opacity="0.8"/>`);
|
|
1195
|
+
}
|
|
1196
|
+
// labels last, collision-resolved; canvas grows if a pill was pushed below
|
|
1197
|
+
const pills = computePills(p, rc, edgeMatches);
|
|
1198
|
+
for (const pill of pills)
|
|
1199
|
+
body.push(pillMarkup(pill, rc));
|
|
1200
|
+
// zone label chips last: slid clear of edges where possible, haloed always
|
|
1201
|
+
// Kept, not discarded: notes are placed after all of these and have to avoid
|
|
1202
|
+
// them. The ordering *is* the obstacle registry — each layer sees what came
|
|
1203
|
+
// before it — and notes are the end of the chain, so everything they need is
|
|
1204
|
+
// in scope here.
|
|
1205
|
+
const chips = p.chips ?? [];
|
|
1206
|
+
for (const chip of chips)
|
|
1207
|
+
body.push(chipMarkup(chip, p, rc, t));
|
|
1208
|
+
// flow step badges: numbered circles just after each edge leaves its
|
|
1209
|
+
// source, sliding further along the wire past pills, nodes and each other
|
|
1210
|
+
// Badges were reserved by layout for the FULL flow's text; walking a flow
|
|
1211
|
+
// draws the due subset right-aligned inside the reservation, so per-step
|
|
1212
|
+
// text changes never move a badge or collide with anything.
|
|
1213
|
+
const placedBadges = [];
|
|
1214
|
+
for (const b of p.badges ?? []) {
|
|
1215
|
+
const nums = b.nums.filter((s2) => !walking || (step !== undefined && s2 <= step));
|
|
1216
|
+
if (!nums.length)
|
|
1217
|
+
continue;
|
|
1218
|
+
const text = nums.join("·");
|
|
1219
|
+
const rWide = Math.max(9, Math.round(measure(text, rc.fx(10), "500", rc.fam) / 2) + 5);
|
|
1220
|
+
const cx = b.x + b.w - rWide;
|
|
1221
|
+
const cy = b.y + 9;
|
|
1222
|
+
placedBadges.push({ x: cx - rWide, y: cy - 9, w: rWide * 2, h: 18 });
|
|
1223
|
+
const done = walking && step !== undefined && !nums.includes(step);
|
|
1224
|
+
const halo = walking && !done
|
|
1225
|
+
? `<rect x="${cx - rWide - 3}" y="${cy - 12}" width="${rWide * 2 + 6}" height="24" rx="12" fill="none" stroke="${t.accent}" stroke-width="1.5" opacity="0.45"/>`
|
|
1226
|
+
: "";
|
|
1227
|
+
body.push(`<g data-kind="flow-step"${done ? ` opacity="0.55"` : ""}>` +
|
|
1228
|
+
halo +
|
|
1229
|
+
(rWide > 9
|
|
1230
|
+
? `<rect x="${cx - rWide}" y="${cy - 9}" width="${rWide * 2}" height="18" rx="9" fill="${t.accent}"/>`
|
|
1231
|
+
: `<circle cx="${cx}" cy="${cy}" r="9" fill="${t.accent}"/>`) +
|
|
1232
|
+
// beadText, not plateText: the dark theme's bead is a pale lavender
|
|
1233
|
+
// disc, and white-on-lavender is unreadable at 10px (docs/design)
|
|
1234
|
+
`<text x="${cx}" y="${cy + 4}" text-anchor="middle" font-size="${rc.fx(10)}" font-weight="500" fill="${t.beadText}">${esc(text)}</text></g>`);
|
|
1235
|
+
}
|
|
1236
|
+
let height = Math.max(p.height, ...pills.map((pl) => pl.y + pl.h + 16));
|
|
1237
|
+
// A note anchored to a node on the edge of the diagram lands outside the
|
|
1238
|
+
// canvas: `above` on the top row and `below` on the bottom row always do, and
|
|
1239
|
+
// `left-of`/`right-of` do on the outer columns. It rendered, and was then
|
|
1240
|
+
// clipped away — three committed diagrams shipped with an invisible note.
|
|
1241
|
+
// Grow the canvas to fit, and shift everything right/down when a note went
|
|
1242
|
+
// negative, since an SVG cannot draw left of its own origin.
|
|
1243
|
+
let padX = 0, padY = 0, width = p.width;
|
|
1244
|
+
// The header sits above everything, so the body shifts down by its height —
|
|
1245
|
+
// the same padY machinery notes already use for a diagram that grew upward.
|
|
1246
|
+
const hd = headerDims(rc, opts.title, opts.titleblock ?? {});
|
|
1247
|
+
const headerH = hd.h ? hd.h + 22 : 0;
|
|
1248
|
+
if (opts.notes?.length) {
|
|
1249
|
+
// Everything a note must not land on. The legend and titleblock are drawn
|
|
1250
|
+
// *after* notes but positioned from the final height, which is circular —
|
|
1251
|
+
// so reserve the strip they will occupy at the current bottom. A note that
|
|
1252
|
+
// still has to travel past it grows the canvas, and the footer follows the
|
|
1253
|
+
// new bottom, so it stays below the note either way.
|
|
1254
|
+
const ext = notes({ ...p, height }, rc, opts.notes, body);
|
|
1255
|
+
padX = Math.max(0, Math.ceil(-ext.minX) + (ext.minX < 0 ? 8 : 0));
|
|
1256
|
+
padY = Math.max(0, Math.ceil(-ext.minY) + (ext.minY < 0 ? 8 : 0));
|
|
1257
|
+
width = Math.max(width, Math.ceil(ext.maxX) + (ext.maxX > p.width ? 8 : 0)) + padX;
|
|
1258
|
+
height = Math.max(height, Math.ceil(ext.maxY) + (ext.maxY > height ? 8 : 0)) + padY;
|
|
1259
|
+
}
|
|
1260
|
+
// Chrome — the header above the diagram, the footer band below it — is drawn
|
|
1261
|
+
// in canvas coordinates, not the body's. The body is translated (a note can
|
|
1262
|
+
// push it right and down), and chrome that rode along would slide with it: a
|
|
1263
|
+
// full-width band starting 8px in, a header 8px off the corner.
|
|
1264
|
+
const chrome = [];
|
|
1265
|
+
const contentH = height;
|
|
1266
|
+
const wordmark = "squinch";
|
|
1267
|
+
if (headerH) {
|
|
1268
|
+
header(rc, 18, 14, opts.title, opts.titleblock ?? {}, hd, chrome);
|
|
1269
|
+
width = Math.max(width, hd.chipW + 36);
|
|
1270
|
+
}
|
|
1271
|
+
// The band exists to carry the legend; the wordmark rides it. Tying it to
|
|
1272
|
+
// the header instead would put an otherwise-empty strip under any diagram
|
|
1273
|
+
// that merely named itself — a signature looking for a reason to be there.
|
|
1274
|
+
let bandH = 0;
|
|
1275
|
+
if (opts.legend) {
|
|
1276
|
+
const bandY = headerH + contentH;
|
|
1277
|
+
const lg = opts.legend ? legend(p, rc, bandY + 5, chrome, opts.colors) : { h: 0, w: 0 };
|
|
1278
|
+
// +.02em over seven characters is ~1.5px the metrics table cannot see;
|
|
1279
|
+
// reserve it so the mark never sits closer to the edge than intended
|
|
1280
|
+
const markW = Math.ceil(measure(wordmark, rc.fx(10.5), "500", rc.fam)) + 2;
|
|
1281
|
+
// A legend wider than the canvas used to clip silently at the right edge;
|
|
1282
|
+
// grow the canvas instead.
|
|
1283
|
+
width = Math.max(width, 18 + lg.w + 24 + markW + 18);
|
|
1284
|
+
bandH = Math.max(lg.h, 24) + 10;
|
|
1285
|
+
// Unshifted so the band lands *under* the legend already drawn into
|
|
1286
|
+
// `chrome`, and the whole of chrome lands over the body.
|
|
1287
|
+
chrome.unshift(`<rect x="0" y="${bandY}" width="${width}" height="${bandH}" fill="${t.canvas}"/>` +
|
|
1288
|
+
`<line x1="0" y1="${bandY}" x2="${width}" y2="${bandY}" stroke="${t.border}" stroke-width="1"/>` +
|
|
1289
|
+
`<text x="${width - 18}" y="${bandY + Math.round(bandH / 2) + 4}" text-anchor="end" ` +
|
|
1290
|
+
`font-size="${rc.fx(10.5)}" font-weight="500" letter-spacing=".02em" fill="${t.dim}">${wordmark}</text>`);
|
|
1291
|
+
}
|
|
1292
|
+
height = headerH + contentH + bandH;
|
|
1293
|
+
// A view that resolves to nothing laid out to 0×0, and `<svg width="0">` is
|
|
1294
|
+
// not a picture — resvg rejects it outright ("SVG has an invalid size"), so
|
|
1295
|
+
// `render` returned ok and `render -o x.png` then failed. An empty system is
|
|
1296
|
+
// the easy way in: `system p "P" { }` is legal, gets an auto view, and that
|
|
1297
|
+
// view has nothing in it. Emit a real (if blank) canvas instead, so the
|
|
1298
|
+
// output is always a valid image and the *diagnostic* is what tells you the
|
|
1299
|
+
// view is empty.
|
|
1300
|
+
width = Math.max(width, 64);
|
|
1301
|
+
height = Math.max(height, 64);
|
|
1302
|
+
const L = [];
|
|
1303
|
+
L.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="${t.font.css}">`);
|
|
1304
|
+
if (opts.embedFonts !== false)
|
|
1305
|
+
L.push(fontDefs(t, rc.faces));
|
|
1306
|
+
{
|
|
1307
|
+
// One rule per animate value in use, one keyframe per distinct motion, all
|
|
1308
|
+
// inside the single reduced-motion gate. Emission is by fixed order so the
|
|
1309
|
+
// string is deterministic — and when only `flow` is used, byte-identical
|
|
1310
|
+
// to what this block emitted before the vocabulary existed, which is what
|
|
1311
|
+
// keeps every already-committed render untouched.
|
|
1312
|
+
//
|
|
1313
|
+
// The drift offset must be a whole number of dash periods, or the pattern
|
|
1314
|
+
// visibly jumps each time the animation loops. One class carries every
|
|
1315
|
+
// pattern (a dashed edge and a dotted one can both be `animate: flow`), so
|
|
1316
|
+
// the offset is the least common multiple of the periods it has to serve:
|
|
1317
|
+
// dashed 6+5=11 and dotted 2+3=5 → 55. Shifting by five dashed periods
|
|
1318
|
+
// looks exactly like shifting by one, so the longer cycle is invisible;
|
|
1319
|
+
// what it buys is a seamless loop for both patterns from one keyframe.
|
|
1320
|
+
// Durations are then derived from the speed the vocabulary promises rather
|
|
1321
|
+
// than restated — px/s is the constant, so a long edge never appears to
|
|
1322
|
+
// flow faster than a short one. Adding a pattern means revisiting DRIFT.
|
|
1323
|
+
// `packets` keeps its own keyframe (period 18, its own sparse rhythm), and
|
|
1324
|
+
// `pulse` animates `opacity` (not stroke-opacity) because a sync arrowhead
|
|
1325
|
+
// is a filled path and has to breathe with its wire.
|
|
1326
|
+
const used = new Set(p.edges.map((e) => e.animate).filter(Boolean));
|
|
1327
|
+
if (used.size) {
|
|
1328
|
+
const rules = [];
|
|
1329
|
+
const frames = new Map();
|
|
1330
|
+
const DRIFT = 55;
|
|
1331
|
+
/** seconds for one cycle at the given speed, rounded like every other
|
|
1332
|
+
* float that reaches the string (2dp — CI byte-compares this) */
|
|
1333
|
+
const secs = (pxPerSec) => (Math.round((DRIFT / pxPerSec) * 100) / 100).toFixed(2);
|
|
1334
|
+
const flowKF = `@keyframes sq-flow{to{stroke-dashoffset:-${DRIFT}}}`;
|
|
1335
|
+
if (used.has("flow")) {
|
|
1336
|
+
rules.push(`.sq-flow{animation:sq-flow ${secs(11.11)}s linear infinite}`);
|
|
1337
|
+
frames.set("sq-flow", flowKF);
|
|
1338
|
+
}
|
|
1339
|
+
if (used.has("reverse")) {
|
|
1340
|
+
rules.push(`.sq-flow-r{animation:sq-flow-r ${secs(11.11)}s linear infinite}`);
|
|
1341
|
+
frames.set("sq-flow-r", `@keyframes sq-flow-r{to{stroke-dashoffset:${DRIFT}}}`);
|
|
1342
|
+
}
|
|
1343
|
+
if (used.has("slow")) {
|
|
1344
|
+
rules.push(`.sq-flow-s{animation:sq-flow ${secs(3.85)}s linear infinite}`);
|
|
1345
|
+
frames.set("sq-flow", flowKF);
|
|
1346
|
+
}
|
|
1347
|
+
if (used.has("fast")) {
|
|
1348
|
+
rules.push(`.sq-flow-f{animation:sq-flow ${secs(26.32)}s linear infinite}`);
|
|
1349
|
+
frames.set("sq-flow", flowKF);
|
|
1350
|
+
}
|
|
1351
|
+
if (used.has("packets")) {
|
|
1352
|
+
rules.push(`.sq-pk{animation:sq-pk 1.1s linear infinite}`);
|
|
1353
|
+
frames.set("sq-pk", `@keyframes sq-pk{to{stroke-dashoffset:-18}}`);
|
|
1354
|
+
}
|
|
1355
|
+
if (used.has("comet")) {
|
|
1356
|
+
// No rule — the per-edge inline style names this keyframe. Gating the
|
|
1357
|
+
// *definition* is what makes reduced-motion work: with the keyframe
|
|
1358
|
+
// undefined the inline animation resolves to nothing, and the dot stays
|
|
1359
|
+
// at opacity 0.
|
|
1360
|
+
frames.set("sq-comet", `@keyframes sq-comet{0%{offset-distance:0%;opacity:0}6%{opacity:1}94%{opacity:1}100%{offset-distance:100%;opacity:0}}`);
|
|
1361
|
+
}
|
|
1362
|
+
if (used.has("pulse")) {
|
|
1363
|
+
rules.push(`.sq-pulse{animation:sq-pulse 1.8s ease-in-out infinite}`);
|
|
1364
|
+
frames.set("sq-pulse", `@keyframes sq-pulse{0%,100%{opacity:1}50%{opacity:.3}}`);
|
|
1365
|
+
}
|
|
1366
|
+
L.push(`<style>@media (prefers-reduced-motion: no-preference){` +
|
|
1367
|
+
rules.join("") + [...frames.values()].join("") + `}</style>`);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
L.push(`<rect width="${width}" height="${height}" fill="${t.canvas}"/>`);
|
|
1371
|
+
// only when something references it — a diagram of plain nodes should not
|
|
1372
|
+
// carry a gradient it never draws
|
|
1373
|
+
// A collected def comes back "" and lands in the caller's map instead; the
|
|
1374
|
+
// wrapping <defs> is only ours to emit when we are keeping them.
|
|
1375
|
+
const inline = [];
|
|
1376
|
+
if (p.nodes.some((n) => n.kind === "card" || n.kind === "context-card"))
|
|
1377
|
+
inline.push(def(rc, ACCENT_GRAD, accentGradient()));
|
|
1378
|
+
// Every lit surface shares one gradient and one shadow; a diagram of only
|
|
1379
|
+
// context cards draws neither, and pays for neither.
|
|
1380
|
+
const lit = p.nodes.some((n) => n.kind === "card" || n.kind === "leaf");
|
|
1381
|
+
if (lit)
|
|
1382
|
+
inline.push(def(rc, rc.grad, surfaceGradient(rc.t, rc.grad)));
|
|
1383
|
+
if (p.nodes.some((n) => n.kind === "person"))
|
|
1384
|
+
inline.push(def(rc, rc.actorGrad, actorGradient(rc.t, rc.actorGrad)));
|
|
1385
|
+
if (lit || p.nodes.some((n) => n.kind === "person") || (p.notes ?? []).length)
|
|
1386
|
+
inline.push(def(rc, rc.shadow, shadowFilter(rc.t, rc.shadow)));
|
|
1387
|
+
if (p.nodes.some((n) => n.external))
|
|
1388
|
+
inline.push(def(rc, rc.hatch, hatchPattern(rc.t, rc.hatch)));
|
|
1389
|
+
for (const d of inline)
|
|
1390
|
+
if (d)
|
|
1391
|
+
L.push(`<defs>${d}</defs>`);
|
|
1392
|
+
const defs = iconDefs(p, rc);
|
|
1393
|
+
if (defs)
|
|
1394
|
+
L.push(defs);
|
|
1395
|
+
if (padX || padY || headerH)
|
|
1396
|
+
L.push(`<g transform="translate(${padX}, ${padY + headerH})">`);
|
|
1397
|
+
L.push(...body);
|
|
1398
|
+
if (padX || padY || headerH)
|
|
1399
|
+
L.push(`</g>`);
|
|
1400
|
+
L.push(...chrome);
|
|
1401
|
+
L.push(`</svg>`);
|
|
1402
|
+
return L.join("\n") + "\n";
|
|
1403
|
+
}
|