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/LICENSE +202 -0
- package/README.md +202 -0
- package/dist/index.d.ts +207 -0
- package/dist/index.js +298 -0
- package/package.json +46 -0
- package/src/index.ts +788 -0
- package/src/schematic.test.ts +737 -0
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('&', '&')
|
|
218
|
+
.replaceAll('<', '<')
|
|
219
|
+
.replaceAll('>', '>')
|
|
220
|
+
.replaceAll('"', '"')
|
|
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
|
+
}
|