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/dist/index.js ADDED
@@ -0,0 +1,298 @@
1
+ // src/index.ts
2
+ var BOUND_TO_DOM = "⟵";
3
+ var BOUND_TWO_WAY = "⟷";
4
+ var shownValue = (v) => {
5
+ if (typeof v !== "string")
6
+ return;
7
+ for (const arrow of [BOUND_TWO_WAY, BOUND_TO_DOM]) {
8
+ const at = v.indexOf(arrow);
9
+ if (at >= 0)
10
+ return v.slice(0, at).trim();
11
+ }
12
+ return v;
13
+ };
14
+ var boundsOf = (element) => {
15
+ const rect = element.getBoundingClientRect();
16
+ return {
17
+ x: Math.round(rect.x + (globalThis.scrollX ?? 0)),
18
+ y: Math.round(rect.y + (globalThis.scrollY ?? 0)),
19
+ width: Math.round(rect.width),
20
+ height: Math.round(rect.height)
21
+ };
22
+ };
23
+ var intersects = (a, b) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
24
+ var contains = (outer, inner) => inner.x >= outer.x && inner.y >= outer.y && inner.x + inner.width <= outer.x + outer.width && inner.y + inner.height <= outer.y + outer.height;
25
+ var esc = (s) => s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
26
+ var TRANSPARENT = "rgba(0, 0, 0, 0)";
27
+ var FLAG_COLORS = {
28
+ error: "#d32f2f",
29
+ warn: "#e6a700",
30
+ info: "#888888"
31
+ };
32
+ var wrapCaption = (caption, maxChars, maxLines) => {
33
+ if (maxLines <= 1 || caption.length <= maxChars) {
34
+ return [caption.slice(0, maxChars + 2)];
35
+ }
36
+ const words = caption.split(" ");
37
+ const lines = [];
38
+ let line = "";
39
+ for (const word of words) {
40
+ const candidate = line === "" ? word : `${line} ${word}`;
41
+ if (candidate.length <= maxChars) {
42
+ line = candidate;
43
+ } else {
44
+ if (line !== "")
45
+ lines.push(line);
46
+ line = word.length > maxChars ? word.slice(0, maxChars) : word;
47
+ if (lines.length === maxLines)
48
+ break;
49
+ }
50
+ }
51
+ if (lines.length < maxLines && line !== "")
52
+ lines.push(line);
53
+ if (lines.length > maxLines || lines.length === maxLines && line !== "" && !lines.includes(line)) {
54
+ lines.length = maxLines;
55
+ lines[maxLines - 1] = lines[maxLines - 1].slice(0, maxChars - 1) + "…";
56
+ }
57
+ return lines;
58
+ };
59
+ var schematic = (description, options = {}) => {
60
+ const {
61
+ pad = 8,
62
+ minLabelHeight = 14,
63
+ maxCaption = 36,
64
+ fontSize = 11,
65
+ within,
66
+ index: showIndex = false,
67
+ targetSize = 24,
68
+ legendNote = true,
69
+ decorate
70
+ } = options;
71
+ const legend = [];
72
+ const boxes = description.wiring.filter((w) => w.bounds != null && w.bounds.width > 0 && w.bounds.height > 0 && (w.viewportFixed === true || w.bounds.x + w.bounds.width > 0 && w.bounds.y + w.bounds.height > 0) && (within == null || w.viewportFixed === true || intersects(w.bounds, within)));
73
+ const flow = boxes.filter((w) => w.viewportFixed !== true);
74
+ if (boxes.length === 0) {
75
+ return {
76
+ svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 0 0"></svg>',
77
+ legend
78
+ };
79
+ }
80
+ const fitBoxes = flow.length > 0 ? flow : boxes;
81
+ const minX = within != null ? within.x - pad : Math.min(...fitBoxes.map((w) => w.bounds.x)) - pad;
82
+ const minY = within != null ? within.y - pad : Math.min(...fitBoxes.map((w) => w.bounds.y)) - pad;
83
+ const maxX = within != null ? within.x + within.width + pad : Math.max(...fitBoxes.map((w) => w.bounds.x + w.bounds.width)) + pad;
84
+ const maxY = within != null ? within.y + within.height + pad : Math.max(...fitBoxes.map((w) => w.bounds.y + w.bounds.height)) + pad;
85
+ const parts = [
86
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${maxX - minX} ${maxY - minY}" width="${maxX - minX}" height="${maxY - minY}">`
87
+ ];
88
+ const ground = (w) => w.structural === true || w.list != null && w.on == null;
89
+ const drawOrder = [...boxes].sort((a, b) => Number(ground(b)) - Number(ground(a)));
90
+ for (const w of drawOrder) {
91
+ const index = description.wiring.indexOf(w);
92
+ const pinOffsetX = w.viewportFixed === true ? minX + pad : 0;
93
+ const pinOffsetY = w.viewportFixed === true ? minY + pad : 0;
94
+ const x = w.bounds.x + pinOffsetX;
95
+ const y = w.bounds.y + pinOffsetY;
96
+ const { width, height } = w.bounds;
97
+ const isContainer = w.viewportFixed !== true && boxes.some((other) => other !== w && other.viewportFixed !== true && contains(w.bounds, other.bounds));
98
+ const toggle = w.type === "checkbox" || w.type === "radio";
99
+ let caption;
100
+ let hint = false;
101
+ if (isContainer) {
102
+ caption = String(w.label ?? "");
103
+ } else if (toggle) {
104
+ caption = String(w.label ?? "");
105
+ } else if (w.tag === "input" || w.tag === "textarea" || w.tag === "select" || w.contentEditable === true) {
106
+ const value = shownValue(w.value);
107
+ if (value) {
108
+ caption = w.label != null ? `${w.label}: ${value}` : value;
109
+ } else if (w.placeholder != null && w.placeholder !== "") {
110
+ caption = String(w.placeholder);
111
+ hint = true;
112
+ } else {
113
+ caption = String(w.label ?? w.text ?? `<${w.tag}>`);
114
+ }
115
+ } else {
116
+ caption = String(w.label ?? shownValue(w.text) ?? shownValue(w.value) ?? w.href ?? `<${w.tag}>`);
117
+ }
118
+ const structural = ground(w);
119
+ const actable = !structural && w.on != null;
120
+ const editable = !structural && (w.contentEditable === true || Object.values(w).some((v) => typeof v === "string" && v.includes(BOUND_TWO_WAY)));
121
+ const fill = structural ? "none" : w.style != null ? w.style.background : "transparent";
122
+ const stroke = !structural && w.style != null && w.style.borderColor !== TRANSPARENT ? w.style.borderColor : "currentColor";
123
+ const color = w.style != null ? w.style.color : "currentColor";
124
+ const drawImage = !structural && typeof w.image === "string" && w.image.startsWith("data:");
125
+ const cramped = !structural && (height < minLabelHeight || width < fontSize * 3);
126
+ const interactive = !structural && (w.on != null || w.contentEditable === true || Object.values(w).some((v) => typeof v === "string" && v.includes(BOUND_TWO_WAY)));
127
+ const producerTargetFlag = Array.isArray(w.flags) && w.flags.some((f) => f.kind.toLowerCase().includes("target"));
128
+ const textSizedLink = w.tag === "a" && typeof w.text === "string" && w.text !== "";
129
+ const undersized = targetSize > 0 && interactive && !producerTargetFlag && !textSizedLink && !(w.type === "checkbox" || w.type === "radio") && (width < targetSize || height < targetSize) ? `${width}×${height} — below ${targetSize}×${targetSize} (WCAG 2.5.8)` : undefined;
130
+ const emphasis = structural ? ' stroke-dasharray="1 3" stroke-linecap="round" opacity="0.45"' : w.disabled === true ? ' opacity="0.4"' : actable ? ' stroke-width="2"' : "";
131
+ parts.push(`<g data-record="${index}"${w.ref != null ? ` data-ref="${esc(String(w.ref))}"` : ""}>`);
132
+ if (w.type === "radio") {
133
+ const r = Math.min(width, height) / 2;
134
+ const cx = x + width / 2;
135
+ const cy = y + height / 2;
136
+ parts.push(`<circle cx="${cx}" cy="${cy}" r="${r - 0.5}" fill="${esc(fill)}" ` + `stroke="${esc(stroke)}"${emphasis}/>`);
137
+ if (w.checked === true) {
138
+ parts.push(`<circle cx="${cx}" cy="${cy}" r="${Math.max(2, r * 0.45)}" ` + `fill="${esc(color)}"/>`);
139
+ }
140
+ } else {
141
+ parts.push(`<rect x="${x}" y="${y}" width="${width}" height="${height}" ` + `fill="${esc(fill)}" stroke="${esc(stroke)}"${emphasis}/>`);
142
+ if (drawImage) {
143
+ parts.push(`<image x="${x + 1}" y="${y + 1}" width="${width - 2}" ` + `height="${height - 2}" href="${esc(w.image)}" ` + `preserveAspectRatio="xMidYMid meet"/>`);
144
+ }
145
+ if (w.type === "checkbox" && w.checked === true) {
146
+ const inset = 3;
147
+ parts.push(`<line x1="${x + inset}" y1="${y + inset}" x2="${x + width - inset}" ` + `y2="${y + height - inset}" stroke="${esc(color)}" stroke-width="1.5"/>`, `<line x1="${x + inset}" y1="${y + height - inset}" ` + `x2="${x + width - inset}" y2="${y + inset}" ` + `stroke="${esc(color)}" stroke-width="1.5"/>`);
148
+ }
149
+ }
150
+ if (!cramped && !structural && Array.isArray(w.flags) && w.flags.length > 0) {
151
+ w.flags.forEach((flag, at) => {
152
+ const color2 = FLAG_COLORS[flag.severity ?? "warn"] ?? FLAG_COLORS.warn;
153
+ parts.push(`<rect x="${x + at * 3}" y="${y}" width="3" height="${height}" ` + `fill="${color2}" data-flag="${esc(flag.kind)}"/>`);
154
+ });
155
+ const first = w.flags[0];
156
+ if (first.label && height >= minLabelHeight) {
157
+ const flagColor = FLAG_COLORS[first.severity ?? "warn"] ?? FLAG_COLORS.warn;
158
+ parts.push(`<rect x="${x + w.flags.length * 3 + 1}" y="${y + height - 9}" ` + `width="${first.label.length * 4.5 + 2}" height="8" ` + `fill="white" opacity="0.85"/>`, `<text x="${x + w.flags.length * 3 + 2}" y="${y + height - 2}" ` + `font-size="7" font-family="monospace" fill="${flagColor}">` + `${esc(first.label)}</text>`);
159
+ }
160
+ }
161
+ if (undersized != null) {
162
+ parts.push(`<rect x="${x}" y="${y}" width="3" height="${height}" ` + `fill="${FLAG_COLORS.warn}" data-flag="target-size"/>`);
163
+ }
164
+ if (w.invalid === true && !structural) {
165
+ const flagSize = Math.min(7, Math.floor(Math.min(width, height) / 2));
166
+ parts.push(`<path d="M${x} ${y} l${flagSize} 0 l-${flagSize} ${flagSize} z" ` + `fill="#d32f2f"/>`);
167
+ }
168
+ if (w.focused === true && !structural) {
169
+ parts.push(`<rect x="${x - 2.5}" y="${y - 2.5}" width="${width + 5}" ` + `height="${height + 5}" fill="none" stroke="${esc(stroke)}" ` + `stroke-width="1.5"/>`);
170
+ }
171
+ if (editable && !toggle && !cramped) {
172
+ parts.push(`<text x="${x + width - 3}" y="${y + height - 4}" ` + `font-size="${fontSize}" text-anchor="end" ` + `font-family="monospace" fill="${esc(color)}">↔</text>`);
173
+ }
174
+ let drawnCaption = null;
175
+ const shownCaption = w.required === true && !structural && caption !== "" ? `${caption} *` : caption;
176
+ if (toggle) {
177
+ if (shownCaption !== "") {
178
+ parts.push(`<text x="${x + width + 4}" ` + `y="${y + height / 2 + fontSize / 2 - 1}" ` + `font-size="${fontSize}" font-family="monospace" ` + `fill="${esc(color)}">` + `${esc(shownCaption.slice(0, maxCaption + 2))}</text>`);
179
+ }
180
+ } else if (!cramped && height >= minLabelHeight && shownCaption !== "") {
181
+ const lineHeight = fontSize + 2;
182
+ const maxLines = Math.max(1, Math.floor((height - 6) / lineHeight));
183
+ const perLine = Math.min(maxCaption, Math.max(8, Math.floor((width - 8) / (fontSize * 0.6))));
184
+ const lines = wrapCaption(shownCaption, perLine, maxLines);
185
+ drawnCaption = lines.join(" ");
186
+ const styleAttrs = `font-size="${fontSize}" font-family="monospace" fill="${esc(color)}"` + `${hint ? ' font-style="italic" opacity="0.6"' : ""}`;
187
+ if (lines.length === 1) {
188
+ parts.push(`<text x="${x + 4}" y="${y + Math.min(height - 4, fontSize + 2)}" ` + `${styleAttrs}>${esc(lines[0])}</text>`);
189
+ } else {
190
+ parts.push(`<text x="${x + 4}" y="${y + fontSize + 2}" ${styleAttrs}>` + lines.map((line, at) => `<tspan x="${x + 4}"${at > 0 ? ` dy="${lineHeight}"` : ""}>` + `${esc(line)}</tspan>`).join("") + "</text>");
191
+ }
192
+ }
193
+ const truncated = drawnCaption != null && drawnCaption.length < shownCaption.split(" ").filter(Boolean).join(" ").length;
194
+ const elided = { index, tag: w.tag };
195
+ if (w.ref != null)
196
+ elided.ref = String(w.ref);
197
+ if (typeof w.href === "string" && w.href !== "" && !structural) {
198
+ elided.href = w.href;
199
+ }
200
+ if (cramped || truncated) {
201
+ if (shownCaption !== "" && !toggle)
202
+ elided.caption = caption;
203
+ const heldValue = shownValue(w.value);
204
+ if (heldValue)
205
+ elided.value = heldValue;
206
+ if (cramped) {
207
+ if (editable)
208
+ elided.editable = true;
209
+ if (w.required === true)
210
+ elided.required = true;
211
+ if (Array.isArray(w.flags) && w.flags.length > 0) {
212
+ elided.flags = w.flags;
213
+ }
214
+ }
215
+ }
216
+ if (w.invalid === true && cramped)
217
+ elided.invalid = true;
218
+ if (w.disabled === true && cramped)
219
+ elided.disabled = true;
220
+ if (undersized != null)
221
+ elided.undersized = undersized;
222
+ const inLegend = elided.caption != null || elided.href != null || elided.value != null || elided.editable != null || elided.required != null || elided.flags != null || elided.invalid != null || elided.disabled != null || elided.undersized != null;
223
+ if (inLegend)
224
+ legend.push(elided);
225
+ if (showIndex || w.ref != null || inLegend) {
226
+ const shown = w.ref != null ? String(w.ref) : String(index);
227
+ const indexX = toggle ? x - 3 : x + width - 2;
228
+ parts.push(`<rect x="${indexX - shown.length * 5 - 1}" y="${y + 1}" ` + `width="${shown.length * 5 + 2}" height="8" fill="white" ` + `opacity="0.85" data-index-backdrop="true"/>`, `<text x="${indexX}" y="${y + 8}" font-size="8" ` + `text-anchor="end" font-family="monospace" fill="black" ` + `opacity="0.8">${esc(shown)}</text>`);
229
+ }
230
+ if (decorate != null) {
231
+ decorate({
232
+ record: w,
233
+ index,
234
+ x,
235
+ y,
236
+ width,
237
+ height,
238
+ structural,
239
+ emit: (svg) => parts.push(svg)
240
+ });
241
+ }
242
+ parts.push("</g>");
243
+ }
244
+ const footerExtra = legendNote && legend.length > 0 ? 14 : 0;
245
+ if (footerExtra > 0) {
246
+ parts.push(`<text x="${minX + pad}" y="${maxY + 10}" font-size="8" ` + `font-family="monospace" fill="currentColor" opacity="0.75">` + `${legend.length} element${legend.length === 1 ? "" : "s"} with ` + `details in legend — match by stamped number</text>`);
247
+ }
248
+ parts[0] = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${maxX - minX} ${maxY - minY + footerExtra}" width="${maxX - minX}" height="${maxY - minY + footerExtra}">` + (legend.length > 0 ? `<desc>${description.wiring.length} records; ${legend.length} ` + "legend entries carry metadata the drawing could not — pair this " + "image with its legend JSON (schematic().legend), matched by the " + "stamped number / data-record index.</desc>" : "");
249
+ parts.push("</svg>");
250
+ return { svg: parts.join(""), legend };
251
+ };
252
+ var schematicSVG = (description, options = {}) => schematic(description, options).svg;
253
+ var rasterizeSVG = (svg, options = {}) => {
254
+ const { scale = 2 } = options;
255
+ if (typeof document === "undefined" || typeof Image === "undefined") {
256
+ return Promise.reject(new Error("rasterizeSVG needs a browser (Image + canvas); under bun/node use @resvg/resvg-js — see the doc comment"));
257
+ }
258
+ const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
259
+ const img = new Image;
260
+ return new Promise((resolve, reject) => {
261
+ img.onload = () => {
262
+ try {
263
+ const width = (img.naturalWidth || 800) * scale;
264
+ const height = (img.naturalHeight || 600) * scale;
265
+ const canvas = document.createElement("canvas");
266
+ canvas.width = width;
267
+ canvas.height = height;
268
+ const ctx = canvas.getContext("2d");
269
+ if (ctx == null)
270
+ throw new Error("no 2d context");
271
+ ctx.drawImage(img, 0, 0, width, height);
272
+ canvas.toBlob((blob) => {
273
+ if (blob != null)
274
+ resolve(blob);
275
+ else
276
+ reject(new Error("canvas.toBlob produced no data"));
277
+ }, "image/png");
278
+ } catch (e) {
279
+ reject(e);
280
+ } finally {
281
+ URL.revokeObjectURL(url);
282
+ }
283
+ };
284
+ img.onerror = () => {
285
+ URL.revokeObjectURL(url);
286
+ reject(new Error("SVG failed to load as an image"));
287
+ };
288
+ img.src = url;
289
+ });
290
+ };
291
+ export {
292
+ schematicSVG,
293
+ schematic,
294
+ rasterizeSVG,
295
+ boundsOf,
296
+ BOUND_TWO_WAY,
297
+ BOUND_TO_DOM
298
+ };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "tosijs-floorplan",
3
+ "version": "0.3.0",
4
+ "description": "Render an agent-surface map (plain records) as a floorplan SVG — the affordance grammar as a pure, dependency-free function. No DOM, no framework. Formerly tosijs-schematic.",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "author": "Tonio Loewald",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/tonioloewald/tosijs-floorplan.git"
11
+ },
12
+ "main": "dist/index.js",
13
+ "module": "dist/index.js",
14
+ "types": "dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "src",
24
+ "LICENSE",
25
+ "README.md"
26
+ ],
27
+ "scripts": {
28
+ "build": "bun build src/index.ts --outdir dist --format esm && tsc -p tsconfig.json",
29
+ "test": "bun test",
30
+ "prepublishOnly": "bun test && bun run build"
31
+ },
32
+ "devDependencies": {
33
+ "typescript": "^7.0.2"
34
+ },
35
+ "keywords": [
36
+ "floorplan",
37
+ "schematic",
38
+ "svg",
39
+ "agent",
40
+ "accessibility",
41
+ "affordance",
42
+ "wireframe",
43
+ "tosijs",
44
+ "webmcp"
45
+ ]
46
+ }