tosijs-floorplan 0.3.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/src/index.ts ADDED
@@ -0,0 +1,788 @@
1
+ /**
2
+ * tosijs-floorplan — render an agent-surface map as a floorplan SVG.
3
+ *
4
+ * (Formerly tosijs-schematic — renamed to stop near-colliding with
5
+ * tosijs-schema. Exported API names are unchanged.)
6
+ *
7
+ * A PURE FUNCTION over plain data: one record per wired element, drawn at
8
+ * its true geometry, wearing the affordance grammar. No DOM, no framework,
9
+ * no dependencies — the map travels as JSON, so this runs in the page, in
10
+ * a headless embodiment, or on the far side of a wire from an app nobody
11
+ * is viewing.
12
+ *
13
+ * The RECORD FORMAT is the contract (see README): tosijs's describe()
14
+ * produces it, but anything that emits records gets the renderer — and
15
+ * every consumer inherits the grammar's hard-won rules (geometry over
16
+ * glyphs, hints are not content, ground is not figure).
17
+ */
18
+
19
+ /** provenance tokens for bound values: "shown ⟵ path" (display-only) and
20
+ * "shown ⟷ path" (two-way — user-writable). Part of the record format. */
21
+ export const BOUND_TO_DOM = '⟵'
22
+ export const BOUND_TWO_WAY = '⟷'
23
+
24
+ /**
25
+ * One wired element, flat. Producers may include fields beyond these —
26
+ * bound props ride as "value ⟷ path" strings under their own keys.
27
+ */
28
+ export interface SchematicRecord {
29
+ tag: string
30
+ id?: string
31
+ part?: string
32
+ role?: string
33
+ label?: string
34
+ placeholder?: string
35
+ type?: string
36
+ checked?: boolean
37
+ focused?: boolean
38
+ invalid?: boolean
39
+ required?: boolean
40
+ disabled?: boolean
41
+ contentEditable?: boolean
42
+ description?: string
43
+ text?: string
44
+ on?: Record<string, string | string[]>
45
+ list?: { path: string; idPath?: string }
46
+ bounds?: { x: number; y: number; width: number; height: number }
47
+ viewportFixed?: boolean
48
+ structural?: boolean
49
+ style?: { background: string; borderColor: string; color: string }
50
+ /** a DURABLE, actionable handle from the producer (haltija's `@42`) —
51
+ * survives re-renders where a wiring index doesn't; rendered in the
52
+ * index slot in preference to the index, and emitted as data-ref */
53
+ ref?: string
54
+ /** computed verdicts about this element (WCAG contrast failures, etc.) —
55
+ * drawn as severity-colored bars on the LEFT edge (the unclaimed slot),
56
+ * with the first flag's label */
57
+ flags?: Array<{ kind: string; label: string; severity?: 'info' | 'warn' | 'error' }>
58
+ /** pixels a pure renderer can't obtain: a data-URL snapshot of inline
59
+ * media (serialized <svg>, <canvas>.toDataURL()) drawn IN PLACE — on an
60
+ * illustration-led page the picture IS the content */
61
+ image?: string
62
+ /** a link's destination — the most actionable fact about a link, and
63
+ * deliberately distinct from `text` ("the link says X" is not "the link
64
+ * goes to Y"). Captions fall back to it only when nothing else names the
65
+ * element; it ALWAYS rides the legend — URLs are the facts most often
66
+ * too long to draw */
67
+ href?: string
68
+ /** a filled control's value, distinct from label/placeholder — static
69
+ * ("3") or bound ("3 ⟷ app.qty"). tosijs emits it as a bound prop; the
70
+ * declared field gives plain-DOM producers the same home */
71
+ value?: string
72
+ [boundProp: string]: unknown
73
+ }
74
+
75
+ /** the map: only `wiring` is read. The named optional fields are the
76
+ * known producer extras (tosijs's describe() shape) — deliberately NOT an
77
+ * index signature, which would stop interface-typed producers (TS gives
78
+ * implicit index signatures to literals, never to interfaces) from
79
+ * assigning without casts. */
80
+ export interface SchematicDescription {
81
+ wiring: SchematicRecord[]
82
+ roots?: unknown
83
+ actions?: unknown
84
+ exposure?: unknown
85
+ contract?: unknown
86
+ }
87
+
88
+
89
+ export interface SchematicBounds {
90
+ x: number
91
+ y: number
92
+ width: number
93
+ height: number
94
+ }
95
+
96
+ export interface SchematicOptions {
97
+ /** padding around the drawn region, px (default 8) */
98
+ pad?: number
99
+ /** boxes shorter than this get no caption (default 14) */
100
+ minLabelHeight?: number
101
+ /** caption length limit (default 36) */
102
+ maxCaption?: number
103
+ /** caption font size, px (default 11) */
104
+ fontSize?: number
105
+ /**
106
+ * Scope the map SPATIALLY: only records whose bounds intersect this
107
+ * page-coordinate rect are drawn, and the viewBox IS the rect — the
108
+ * schematic becomes "this region of the page". Use `boundsOf(element)`
109
+ * to scope to an element's region; omit for the whole map.
110
+ */
111
+ within?: SchematicBounds
112
+ /**
113
+ * Stamp each box with its wiring index (top-right corner) — the raster
114
+ * form of `data-record`: a vision consumer reads the number off the image
115
+ * and looks the record up in `description.wiring[n]` — image as legend.
116
+ */
117
+ index?: boolean
118
+ /**
119
+ * Interactive elements (handlers or editable, toggles exempt as
120
+ * user-agent-sized) smaller than this on either axis are flagged
121
+ * undersized — amber bar + legend fact. Default 24 (WCAG 2.5.8 AA);
122
+ * raise to 44/48 for the AAA / platform touch-target bar. 0 disables.
123
+ */
124
+ targetSize?: number
125
+ /** draw the footer strip advertising the legend when it's non-empty
126
+ * (default true) — the raster must confess what it couldn't carry */
127
+ legendNote?: boolean
128
+ /**
129
+ * EXPERIMENTAL plugin seam: called once per drawn record, just before
130
+ * its <g> closes — emit extra SVG into the record's group. The corner
131
+ * slots already spoken for: top-left = invalid flag, top-right = index,
132
+ * bottom-right = ↔ badge, outline = focus ring / emphasis. Claim empty
133
+ * real estate; the first real plugins will shape the successor API.
134
+ */
135
+ decorate?: (ctx: {
136
+ record: SchematicRecord
137
+ index: number
138
+ x: number
139
+ y: number
140
+ width: number
141
+ height: number
142
+ structural: boolean
143
+ emit: (svg: string) => void
144
+ }) => void
145
+ }
146
+
147
+ /** what the drawing could not legibly carry, keyed back by index/ref —
148
+ * the image's companion JSON. Pair every raster with this. */
149
+ export interface SchematicLegendEntry {
150
+ index: number
151
+ ref?: string
152
+ tag: string
153
+ /** the caption that would have been drawn (or its untruncated form) */
154
+ caption?: string
155
+ editable?: boolean
156
+ required?: boolean
157
+ invalid?: boolean
158
+ disabled?: boolean
159
+ flags?: Array<{ kind: string; label: string; severity?: 'info' | 'warn' | 'error' }>
160
+ /** the link's destination — carried whenever the record has one */
161
+ href?: string
162
+ /** the control's held value (provenance stripped), when the drawing
163
+ * elided or truncated it */
164
+ value?: string
165
+ /** interactive element below the target-size floor, e.g.
166
+ * "18×13 — below 24×24 (WCAG 2.5.8)" */
167
+ undersized?: string
168
+ }
169
+
170
+ export interface SchematicResult {
171
+ svg: string
172
+ legend: SchematicLegendEntry[]
173
+ }
174
+
175
+ // strip provenance from a bound-value string: "shown ⟷ path" → "shown"
176
+ // (empty when the binding holds no value yet); a plain string (no arrow)
177
+ // is a live-but-unbound value and passes through whole
178
+ const shownValue = (v: unknown): string | undefined => {
179
+ if (typeof v !== 'string') return undefined
180
+ for (const arrow of [BOUND_TWO_WAY, BOUND_TO_DOM]) {
181
+ const at = v.indexOf(arrow)
182
+ if (at >= 0) return v.slice(0, at).trim()
183
+ }
184
+ return v
185
+ }
186
+
187
+ /**
188
+ * An element's page-coordinate bounds (the same space describe() records) —
189
+ * the natural `within` argument for a region-scoped schematic.
190
+ */
191
+ export const boundsOf = (element: Element): SchematicBounds => {
192
+ const rect = element.getBoundingClientRect()
193
+ return {
194
+ x: Math.round(rect.x + ((globalThis as any).scrollX ?? 0)),
195
+ y: Math.round(rect.y + ((globalThis as any).scrollY ?? 0)),
196
+ width: Math.round(rect.width),
197
+ height: Math.round(rect.height),
198
+ }
199
+ }
200
+
201
+ const intersects = (a: SchematicBounds, b: SchematicBounds): boolean =>
202
+ a.x < b.x + b.width &&
203
+ b.x < a.x + a.width &&
204
+ a.y < b.y + b.height &&
205
+ b.y < a.y + a.height
206
+
207
+ const contains = (outer: SchematicBounds, inner: SchematicBounds): boolean =>
208
+ inner.x >= outer.x &&
209
+ inner.y >= outer.y &&
210
+ inner.x + inner.width <= outer.x + outer.width &&
211
+ inner.y + inner.height <= outer.y + outer.height
212
+
213
+ // string args (not regexes) — tjs convert's lexer mis-reads a quote inside a
214
+ // regex literal (/"/g) as a string opener; see tjs-lang issue
215
+ const esc = (s: string): string =>
216
+ s
217
+ .replaceAll('&', '&amp;')
218
+ .replaceAll('<', '&lt;')
219
+ .replaceAll('>', '&gt;')
220
+ .replaceAll('"', '&quot;')
221
+
222
+ const TRANSPARENT = 'rgba(0, 0, 0, 0)'
223
+
224
+ const FLAG_COLORS: Record<string, string> = {
225
+ error: '#d32f2f',
226
+ warn: '#e6a700',
227
+ info: '#888888',
228
+ }
229
+
230
+ // greedy word-wrap: captions should USE vertical room, not truncate with
231
+ // space to spare (a <p> that wraps on the real page has the same height
232
+ // here). Returns at most maxLines lines, each at most maxChars long;
233
+ // appends … when the caption is cut.
234
+ const wrapCaption = (
235
+ caption: string,
236
+ maxChars: number,
237
+ maxLines: number
238
+ ): string[] => {
239
+ if (maxLines <= 1 || caption.length <= maxChars) {
240
+ return [caption.slice(0, maxChars + 2)]
241
+ }
242
+ const words = caption.split(' ')
243
+ const lines: string[] = []
244
+ let line = ''
245
+ for (const word of words) {
246
+ const candidate = line === '' ? word : `${line} ${word}`
247
+ if (candidate.length <= maxChars) {
248
+ line = candidate
249
+ } else {
250
+ if (line !== '') lines.push(line)
251
+ line = word.length > maxChars ? word.slice(0, maxChars) : word
252
+ if (lines.length === maxLines) break
253
+ }
254
+ }
255
+ if (lines.length < maxLines && line !== '') lines.push(line)
256
+ if (lines.length > maxLines || (lines.length === maxLines && line !== '' && !lines.includes(line))) {
257
+ lines.length = maxLines
258
+ lines[maxLines - 1] = lines[maxLines - 1].slice(0, maxChars - 1) + '…'
259
+ }
260
+ return lines
261
+ }
262
+
263
+ export const schematic = (
264
+ description: SchematicDescription,
265
+ options: SchematicOptions = {}
266
+ ): SchematicResult => {
267
+ const {
268
+ pad = 8,
269
+ minLabelHeight = 14,
270
+ maxCaption = 36,
271
+ fontSize = 11,
272
+ within,
273
+ index: showIndex = false,
274
+ targetSize = 24,
275
+ legendNote = true,
276
+ decorate,
277
+ } = options
278
+ const legend: SchematicLegendEntry[] = []
279
+ const boxes = description.wiring.filter(
280
+ (w) =>
281
+ w.bounds != null &&
282
+ w.bounds.width > 0 &&
283
+ w.bounds.height > 0 &&
284
+ // fully negative coordinates = hidden by off-page positioning (the
285
+ // spatial analog of zero-size): invisible to humans, invisible here
286
+ (w.viewportFixed === true ||
287
+ (w.bounds.x + w.bounds.width > 0 && w.bounds.y + w.bounds.height > 0)) &&
288
+ (within == null ||
289
+ w.viewportFixed === true ||
290
+ intersects(w.bounds, within))
291
+ )
292
+ // viewport furniture (fixed/sticky) has viewport coordinates: it neither
293
+ // stretches the viewBox nor sits at a page position — it gets PINNED as an
294
+ // overlay at the map's origin, which is where it lives on screen
295
+ const flow = boxes.filter((w) => w.viewportFixed !== true)
296
+ if (boxes.length === 0) {
297
+ return {
298
+ svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 0 0"></svg>',
299
+ legend,
300
+ }
301
+ }
302
+ // scoped: the viewBox IS the region; unscoped: fit the FLOW boxes (pinned
303
+ // furniture must not stretch the map)
304
+ const fitBoxes = flow.length > 0 ? flow : boxes
305
+ const minX =
306
+ within != null
307
+ ? within.x - pad
308
+ : Math.min(...fitBoxes.map((w) => w.bounds!.x)) - pad
309
+ const minY =
310
+ within != null
311
+ ? within.y - pad
312
+ : Math.min(...fitBoxes.map((w) => w.bounds!.y)) - pad
313
+ const maxX =
314
+ within != null
315
+ ? within.x + within.width + pad
316
+ : Math.max(...fitBoxes.map((w) => w.bounds!.x + w.bounds!.width)) + pad
317
+ const maxY =
318
+ within != null
319
+ ? within.y + within.height + pad
320
+ : Math.max(...fitBoxes.map((w) => w.bounds!.y + w.bounds!.height)) + pad
321
+
322
+ // explicit width/height (not just viewBox): gives the svg an intrinsic
323
+ // size as a document/img, and Firefox refuses to draw an svg image onto a
324
+ // canvas without them — which rasterizeSVG depends on
325
+ const parts: string[] = [
326
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${
327
+ maxX - minX
328
+ } ${maxY - minY}" width="${maxX - minX}" height="${maxY - minY}">`,
329
+ ]
330
+ // structure behind affordances: dotted outlines the eye (and the raster)
331
+ // reads as grouping, not controls. A LIST CONTAINER is ground too — it's
332
+ // wired (the collection binds here, and the JSON record says so), but its
333
+ // items are the affordances; drawing it solid would read as actionable.
334
+ const ground = (w: (typeof boxes)[number]): boolean =>
335
+ w.structural === true || (w.list != null && w.on == null)
336
+ const drawOrder = [...boxes].sort(
337
+ (a, b) => Number(ground(b)) - Number(ground(a))
338
+ )
339
+ for (const w of drawOrder) {
340
+ const index = description.wiring.indexOf(w)
341
+ const pinOffsetX = w.viewportFixed === true ? minX + pad : 0
342
+ const pinOffsetY = w.viewportFixed === true ? minY + pad : 0
343
+ const x = w.bounds!.x + pinOffsetX
344
+ const y = w.bounds!.y + pinOffsetY
345
+ const { width, height } = w.bounds!
346
+ // a box that CONTAINS other drawn boxes is a container: its textContent
347
+ // is its children's text concatenated, so a text-derived caption would
348
+ // overprint the children's own captions — the children speak for
349
+ // themselves. Only an explicit label earns a container a caption.
350
+ const isContainer =
351
+ w.viewportFixed !== true &&
352
+ boxes.some(
353
+ (other) =>
354
+ other !== w &&
355
+ other.viewportFixed !== true &&
356
+ contains(w.bounds!, other.bounds!)
357
+ )
358
+ // caption truth, per control kind:
359
+ // - checkbox/radio: the state is GEOMETRY (✕ in the box, dot in the
360
+ // circle — drawn below, legible at any raster scale); the caption is
361
+ // just the label, set to the RIGHT of the control
362
+ // - other form controls: the live VALUE wins; an empty control falls
363
+ // back to its placeholder in italics (a hint must not read as content)
364
+ // - everything else: label, then text, then value
365
+ const toggle = w.type === 'checkbox' || w.type === 'radio'
366
+ let caption: string
367
+ let hint = false
368
+ if (isContainer) {
369
+ caption = String(w.label ?? '')
370
+ } else if (toggle) {
371
+ caption = String(w.label ?? '')
372
+ } else if (
373
+ w.tag === 'input' ||
374
+ w.tag === 'textarea' ||
375
+ w.tag === 'select' ||
376
+ w.contentEditable === true
377
+ ) {
378
+ const value = shownValue(w.value)
379
+ if (value) {
380
+ // both name and value known: say both — "qty: 3"
381
+ caption = w.label != null ? `${w.label}: ${value}` : value
382
+ } else if (w.placeholder != null && w.placeholder !== '') {
383
+ caption = String(w.placeholder)
384
+ hint = true
385
+ } else {
386
+ caption = String(w.label ?? w.text ?? `<${w.tag}>`)
387
+ }
388
+ } else {
389
+ // href is last resort before the bare tag: a link with no name at all
390
+ // (haltija's icon-less sidebar case) is still distinguished by where
391
+ // it goes — but a name, when present, wins; the destination's real
392
+ // home is the legend
393
+ caption = String(
394
+ w.label ??
395
+ shownValue(w.text) ??
396
+ shownValue(w.value) ??
397
+ w.href ??
398
+ `<${w.tag}>`
399
+ )
400
+ }
401
+ const structural = ground(w)
402
+ // the affordance grammar, explicit: BOLD outline = wired to act (has
403
+ // handlers); a trailing ⟷ on the caption = editable here (two-way
404
+ // binding), added when the caption is a label that would otherwise
405
+ // hide it. Solid = affordance, dotted = structure.
406
+ const actable = !structural && w.on != null
407
+ const editable =
408
+ !structural &&
409
+ (w.contentEditable === true ||
410
+ Object.values(w).some(
411
+ (v) => typeof v === 'string' && v.includes(BOUND_TWO_WAY)
412
+ ))
413
+ const fill = structural
414
+ ? 'none'
415
+ : w.style != null
416
+ ? w.style.background
417
+ : 'transparent'
418
+ const stroke =
419
+ !structural && w.style != null && w.style.borderColor !== TRANSPARENT
420
+ ? w.style.borderColor
421
+ : 'currentColor'
422
+ const color = w.style != null ? w.style.color : 'currentColor'
423
+ // embedded media first: pixels the producer captured, drawn in place —
424
+ // everything else (state geometry, captions, badges) reads over it
425
+ const drawImage =
426
+ !structural && typeof w.image === 'string' && w.image.startsWith('data:')
427
+ // CRAMPED: the box can't legibly carry its dress — draw it bare (shape,
428
+ // state geometry, emphasis, focus) with an auto stamp pointing into the
429
+ // legend, where the metadata actually lives. Toggles are exempt from
430
+ // caption suppression (their label draws OUTSIDE the box).
431
+ const cramped =
432
+ !structural && (height < minLabelHeight || width < fontSize * 3)
433
+ // UNDERSIZED: an interactive element below the target-size floor is a
434
+ // usability defect in its own right (WCAG 2.5.8: 24×24 AA; 44/48 is the
435
+ // platform touch bar) — toggles exempt as user-agent-sized controls
436
+ const interactive =
437
+ !structural &&
438
+ (w.on != null || w.contentEditable === true ||
439
+ Object.values(w).some(
440
+ (v) => typeof v === 'string' && v.includes(BOUND_TWO_WAY)
441
+ ))
442
+ // WCAG 2.5.8 exempts inline targets sized by their text — flagging
443
+ // prose links fires on every paragraph, and a check that cries wolf
444
+ // gets ignored, taking the real findings with it. A pure renderer
445
+ // can't see computed display, so: a link WITH text is presumed
446
+ // text-sized and exempt (icon links — an <a> wrapping an <svg>, no
447
+ // text — stay flagged). Producers with DOM access compute this
448
+ // properly and ship it via `flags`, which also SUPERSEDES the built-in
449
+ // audit here: no double amber bars for the same finding.
450
+ const producerTargetFlag =
451
+ Array.isArray(w.flags) &&
452
+ w.flags.some((f) => f.kind.toLowerCase().includes('target'))
453
+ const textSizedLink =
454
+ w.tag === 'a' && typeof w.text === 'string' && w.text !== ''
455
+ const undersized =
456
+ targetSize > 0 &&
457
+ interactive &&
458
+ !producerTargetFlag &&
459
+ !textSizedLink &&
460
+ !(w.type === 'checkbox' || w.type === 'radio') &&
461
+ (width < targetSize || height < targetSize)
462
+ ? `${width}×${height} — below ${targetSize}×${targetSize} (WCAG 2.5.8)`
463
+ : undefined
464
+ const emphasis = structural
465
+ ? ' stroke-dasharray="1 3" stroke-linecap="round" opacity="0.45"'
466
+ : w.disabled === true
467
+ ? ' opacity="0.4"'
468
+ : actable
469
+ ? ' stroke-width="2"'
470
+ : ''
471
+ parts.push(
472
+ `<g data-record="${index}"${
473
+ w.ref != null ? ` data-ref="${esc(String(w.ref))}"` : ''
474
+ }>`
475
+ )
476
+ if (w.type === 'radio') {
477
+ // a radio IS a circle — and its state is a filled dot, legible at any
478
+ // raster scale (text glyphs are mush at 13px; geometry is not)
479
+ const r = Math.min(width, height) / 2
480
+ const cx = x + width / 2
481
+ const cy = y + height / 2
482
+ parts.push(
483
+ `<circle cx="${cx}" cy="${cy}" r="${r - 0.5}" fill="${esc(fill)}" ` +
484
+ `stroke="${esc(stroke)}"${emphasis}/>`
485
+ )
486
+ if (w.checked === true) {
487
+ parts.push(
488
+ `<circle cx="${cx}" cy="${cy}" r="${Math.max(2, r * 0.45)}" ` +
489
+ `fill="${esc(color)}"/>`
490
+ )
491
+ }
492
+ } else {
493
+ parts.push(
494
+ `<rect x="${x}" y="${y}" width="${width}" height="${height}" ` +
495
+ `fill="${esc(fill)}" stroke="${esc(stroke)}"${emphasis}/>`
496
+ )
497
+ if (drawImage) {
498
+ parts.push(
499
+ `<image x="${x + 1}" y="${y + 1}" width="${width - 2}" ` +
500
+ `height="${height - 2}" href="${esc(w.image as string)}" ` +
501
+ `preserveAspectRatio="xMidYMid meet"/>`
502
+ )
503
+ }
504
+ if (w.type === 'checkbox' && w.checked === true) {
505
+ // checked = an ✕ drawn corner to corner, inset a hair
506
+ const inset = 3
507
+ parts.push(
508
+ `<line x1="${x + inset}" y1="${y + inset}" x2="${x + width - inset}" ` +
509
+ `y2="${y + height - inset}" stroke="${esc(color)}" stroke-width="1.5"/>`,
510
+ `<line x1="${x + inset}" y1="${y + height - inset}" ` +
511
+ `x2="${x + width - inset}" y2="${y + inset}" ` +
512
+ `stroke="${esc(color)}" stroke-width="1.5"/>`
513
+ )
514
+ }
515
+ }
516
+ // computed verdicts (contrast failures etc.): severity-colored bars on
517
+ // the LEFT edge — the unclaimed slot — plus the first flag's label
518
+ if (!cramped && !structural && Array.isArray(w.flags) && w.flags.length > 0) {
519
+ w.flags.forEach((flag, at) => {
520
+ const color = FLAG_COLORS[flag.severity ?? 'warn'] ?? FLAG_COLORS.warn
521
+ parts.push(
522
+ `<rect x="${x + at * 3}" y="${y}" width="3" height="${height}" ` +
523
+ `fill="${color}" data-flag="${esc(flag.kind)}"/>`
524
+ )
525
+ })
526
+ const first = w.flags[0]
527
+ if (first.label && height >= minLabelHeight) {
528
+ const flagColor = FLAG_COLORS[first.severity ?? 'warn'] ?? FLAG_COLORS.warn
529
+ parts.push(
530
+ `<rect x="${x + w.flags.length * 3 + 1}" y="${y + height - 9}" ` +
531
+ `width="${first.label.length * 4.5 + 2}" height="8" ` +
532
+ `fill="white" opacity="0.85"/>`,
533
+ `<text x="${x + w.flags.length * 3 + 2}" y="${y + height - 2}" ` +
534
+ `font-size="7" font-family="monospace" fill="${flagColor}">` +
535
+ `${esc(first.label)}</text>`
536
+ )
537
+ }
538
+ }
539
+ // undersized interactive element: one amber bar on the left edge —
540
+ // legible at any size; the measurement itself rides the legend
541
+ if (undersized != null) {
542
+ parts.push(
543
+ `<rect x="${x}" y="${y}" width="3" height="${height}" ` +
544
+ `fill="${FLAG_COLORS.warn}" data-flag="target-size"/>`
545
+ )
546
+ }
547
+ // invalid = the spreadsheet error-corner: a red flag at top-left
548
+ // (index owns top-right, ↔ owns bottom-right) — geometry, so it's
549
+ // legible at any raster scale and in any font
550
+ if (w.invalid === true && !structural) {
551
+ const flagSize = Math.min(7, Math.floor(Math.min(width, height) / 2))
552
+ parts.push(
553
+ `<path d="M${x} ${y} l${flagSize} 0 l-${flagSize} ${flagSize} z" ` +
554
+ `fill="#d32f2f"/>`
555
+ )
556
+ }
557
+ // focus ring: a second outline just outside the box — where the user IS
558
+ if (w.focused === true && !structural) {
559
+ parts.push(
560
+ `<rect x="${x - 2.5}" y="${y - 2.5}" width="${width + 5}" ` +
561
+ `height="${height + 5}" fill="none" stroke="${esc(stroke)}" ` +
562
+ `stroke-width="1.5"/>`
563
+ )
564
+ }
565
+ // editable = an ↔ badge at the box's right edge — SEPARATE from the
566
+ // caption text: rasterizers resolve fonts per text run, and one exotic
567
+ // glyph (⟷ is rare in monospace fonts) can tofu the whole caption.
568
+ // ↔ (U+2194) is near-universal; isolated, it can only cost itself.
569
+ if (editable && !toggle && !cramped) {
570
+ parts.push(
571
+ `<text x="${x + width - 3}" y="${y + height - 4}" ` +
572
+ `font-size="${fontSize}" text-anchor="end" ` +
573
+ `font-family="monospace" fill="${esc(color)}">↔</text>`
574
+ )
575
+ }
576
+ let drawnCaption: string | null = null // null = caption block never ran
577
+ // required wears the universal asterisk on its caption — ASCII-safe
578
+ const shownCaption =
579
+ w.required === true && !structural && caption !== ''
580
+ ? `${caption} *`
581
+ : caption
582
+ if (toggle) {
583
+ // the toggle's label sits to the RIGHT of the control, like the real
584
+ // layout — the control itself already says everything else
585
+ if (shownCaption !== '') {
586
+ parts.push(
587
+ `<text x="${x + width + 4}" ` +
588
+ `y="${y + height / 2 + fontSize / 2 - 1}" ` +
589
+ `font-size="${fontSize}" font-family="monospace" ` +
590
+ `fill="${esc(color)}">` +
591
+ `${esc(shownCaption.slice(0, maxCaption + 2))}</text>`
592
+ )
593
+ }
594
+ } else if (!cramped && height >= minLabelHeight && shownCaption !== '') {
595
+ // wrap when the box affords more than one line — a paragraph that
596
+ // wraps on the real page has the same vertical room here; truncating
597
+ // at maxCaption with space to spare threw that text away
598
+ const lineHeight = fontSize + 2
599
+ const maxLines = Math.max(1, Math.floor((height - 6) / lineHeight))
600
+ const perLine = Math.min(
601
+ maxCaption,
602
+ Math.max(8, Math.floor((width - 8) / (fontSize * 0.6)))
603
+ )
604
+ const lines = wrapCaption(shownCaption, perLine, maxLines)
605
+ // rejoining with single spaces reconstructs a fully-wrapped caption
606
+ // exactly (wrapping only consumes break spaces) — a caption-count
607
+ // comparison would mark EVERY wrapped caption truncated
608
+ drawnCaption = lines.join(' ')
609
+ const styleAttrs =
610
+ `font-size="${fontSize}" font-family="monospace" fill="${esc(color)}"` +
611
+ `${hint ? ' font-style="italic" opacity="0.6"' : ''}`
612
+ if (lines.length === 1) {
613
+ parts.push(
614
+ `<text x="${x + 4}" y="${y + Math.min(height - 4, fontSize + 2)}" ` +
615
+ `${styleAttrs}>${esc(lines[0])}</text>`
616
+ )
617
+ } else {
618
+ parts.push(
619
+ `<text x="${x + 4}" y="${y + fontSize + 2}" ${styleAttrs}>` +
620
+ lines
621
+ .map(
622
+ (line, at) =>
623
+ `<tspan x="${x + 4}"${at > 0 ? ` dy="${lineHeight}"` : ''}>` +
624
+ `${esc(line)}</tspan>`
625
+ )
626
+ .join('') +
627
+ '</text>'
628
+ )
629
+ }
630
+ }
631
+ // build the legend entry: everything the drawing could not carry
632
+ const truncated =
633
+ drawnCaption != null &&
634
+ drawnCaption.length <
635
+ shownCaption.split(' ').filter(Boolean).join(' ').length
636
+ const elided: SchematicLegendEntry = { index, tag: w.tag }
637
+ if (w.ref != null) elided.ref = String(w.ref)
638
+ // a destination is always legend-worthy: it never fits a caption
639
+ // legibly, and it's the fact an agent acts on ("goes to Y", not
640
+ // "says X")
641
+ if (typeof w.href === 'string' && w.href !== '' && !structural) {
642
+ elided.href = w.href
643
+ }
644
+ if (cramped || truncated) {
645
+ if (shownCaption !== '' && !toggle) elided.caption = caption
646
+ const heldValue = shownValue(w.value)
647
+ if (heldValue) elided.value = heldValue
648
+ if (cramped) {
649
+ if (editable) elided.editable = true
650
+ if (w.required === true) elided.required = true
651
+ if (Array.isArray(w.flags) && w.flags.length > 0) {
652
+ elided.flags = w.flags as SchematicLegendEntry['flags']
653
+ }
654
+ }
655
+ }
656
+ if (w.invalid === true && cramped) elided.invalid = true
657
+ if (w.disabled === true && cramped) elided.disabled = true
658
+ if (undersized != null) elided.undersized = undersized
659
+ const inLegend =
660
+ elided.caption != null ||
661
+ elided.href != null ||
662
+ elided.value != null ||
663
+ elided.editable != null ||
664
+ elided.required != null ||
665
+ elided.flags != null ||
666
+ elided.invalid != null ||
667
+ elided.disabled != null ||
668
+ elided.undersized != null
669
+ if (inLegend) legend.push(elided)
670
+ // the stamp is the POINTER into the legend — cramped and legend-bearing
671
+ // records always wear one, whatever showIndex says
672
+ if (showIndex || w.ref != null || inLegend) {
673
+ // the raster's actionable handle: the producer's DURABLE ref when it
674
+ // has one (it survives re-renders; an agent can act on it), else the
675
+ // wiring index (look up description.wiring[n]). Toggles are too small
676
+ // to wear it inside — theirs sits just left of the control. A mostly
677
+ // opaque white backdrop keeps it legible over ANY artwork.
678
+ const shown = w.ref != null ? String(w.ref) : String(index)
679
+ const indexX = toggle ? x - 3 : x + width - 2
680
+ parts.push(
681
+ `<rect x="${indexX - shown.length * 5 - 1}" y="${y + 1}" ` +
682
+ `width="${shown.length * 5 + 2}" height="8" fill="white" ` +
683
+ `opacity="0.85" data-index-backdrop="true"/>`,
684
+ `<text x="${indexX}" y="${y + 8}" font-size="8" ` +
685
+ `text-anchor="end" font-family="monospace" fill="black" ` +
686
+ `opacity="0.8">${esc(shown)}</text>`
687
+ )
688
+ }
689
+ if (decorate != null) {
690
+ decorate({
691
+ record: w,
692
+ index,
693
+ x,
694
+ y,
695
+ width,
696
+ height,
697
+ structural,
698
+ emit: (svg) => parts.push(svg),
699
+ })
700
+ }
701
+ parts.push('</g>')
702
+ }
703
+ // the image confesses what it couldn't carry: a machine-readable <desc>
704
+ // plus a visible footer strip — the raster's pointer to its legend JSON
705
+ const footerExtra = legendNote && legend.length > 0 ? 14 : 0
706
+ if (footerExtra > 0) {
707
+ parts.push(
708
+ `<text x="${minX + pad}" y="${maxY + 10}" font-size="8" ` +
709
+ `font-family="monospace" fill="currentColor" opacity="0.75">` +
710
+ `${legend.length} element${legend.length === 1 ? '' : 's'} with ` +
711
+ `details in legend — match by stamped number</text>`
712
+ )
713
+ }
714
+ parts[0] =
715
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${
716
+ maxX - minX
717
+ } ${maxY - minY + footerExtra}" width="${maxX - minX}" height="${
718
+ maxY - minY + footerExtra
719
+ }">` +
720
+ (legend.length > 0
721
+ ? `<desc>${description.wiring.length} records; ${legend.length} ` +
722
+ 'legend entries carry metadata the drawing could not — pair this ' +
723
+ 'image with its legend JSON (schematic().legend), matched by the ' +
724
+ 'stamped number / data-record index.</desc>'
725
+ : '')
726
+ parts.push('</svg>')
727
+ return { svg: parts.join(''), legend }
728
+ }
729
+
730
+ /** the string-only form — schematic().svg, kept for drop-in compatibility */
731
+ export const schematicSVG = (
732
+ description: SchematicDescription,
733
+ options: SchematicOptions = {}
734
+ ): string => schematic(description, options).svg
735
+
736
+ /**
737
+ * Rasterize an SVG string to a PNG Blob — the vision-encoder form of the map
738
+ * (rasterize at 2× so labels land large enough to OCR near-losslessly).
739
+ *
740
+ * Browser-only by design: it uses Image + canvas, which keeps tosijs at zero
741
+ * dependencies. Under bun/node, use `@resvg/resvg-js` directly instead:
742
+ *
743
+ * const { Resvg } = await import('@resvg/resvg-js')
744
+ * const png = new Resvg(svg, { fitTo: { mode: 'zoom', value: 2 } })
745
+ * .render().asPng()
746
+ */
747
+ export const rasterizeSVG = (
748
+ svg: string,
749
+ options: { scale?: number } = {}
750
+ ): Promise<Blob> => {
751
+ const { scale = 2 } = options
752
+ if (typeof document === 'undefined' || typeof Image === 'undefined') {
753
+ return Promise.reject(
754
+ new Error(
755
+ 'rasterizeSVG needs a browser (Image + canvas); under bun/node use @resvg/resvg-js — see the doc comment'
756
+ )
757
+ )
758
+ }
759
+ const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }))
760
+ const img = new Image()
761
+ return new Promise<Blob>((resolve, reject) => {
762
+ img.onload = () => {
763
+ try {
764
+ const width = (img.naturalWidth || 800) * scale
765
+ const height = (img.naturalHeight || 600) * scale
766
+ const canvas = document.createElement('canvas')
767
+ canvas.width = width
768
+ canvas.height = height
769
+ const ctx = canvas.getContext('2d')
770
+ if (ctx == null) throw new Error('no 2d context')
771
+ ctx.drawImage(img, 0, 0, width, height)
772
+ canvas.toBlob((blob) => {
773
+ if (blob != null) resolve(blob)
774
+ else reject(new Error('canvas.toBlob produced no data'))
775
+ }, 'image/png')
776
+ } catch (e) {
777
+ reject(e as Error)
778
+ } finally {
779
+ URL.revokeObjectURL(url)
780
+ }
781
+ }
782
+ img.onerror = () => {
783
+ URL.revokeObjectURL(url)
784
+ reject(new Error('SVG failed to load as an image'))
785
+ }
786
+ img.src = url
787
+ })
788
+ }