@ai-gui/plugin-figure 0.11.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/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # @ai-gui/plugin-figure
2
+
3
+ ## 0.11.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [58d1b6c]
8
+ - @ai-gui/core@0.11.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Liang Li
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # @ai-gui/plugin-figure
2
+
3
+ Labelled figures for [AIGUI](../../README.md). The plugin claims one `figure` fence and draws the parts it declares as SVG, with leader-line callouts naming each one.
4
+
5
+ This is the diagram whose point is what the parts are *called*: a cell with its organelles named, a leaf's layers, apparatus with the parts a method refers to, a labelled cross-section. Use `mermaid` for boxes joined by arrows and `chart` for data.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pnpm add @ai-gui/plugin-figure
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```tsx
16
+ import { figure, figureCss } from "@ai-gui/plugin-figure"
17
+ import { AIRenderer } from "@ai-gui/react"
18
+
19
+ <style>{figureCss}</style>
20
+ <AIRenderer plugins={[figure()]} />
21
+ ```
22
+
23
+ ```figure
24
+ {"version":1,"title":"动物细胞","caption":"图 1 动物细胞的基本结构","parts":[
25
+ {"at":[0,0],"width":220,"height":160,"fill":"none","label":"细胞膜","note":"控制物质进出"},
26
+ {"at":[-20,10],"width":70,"height":60,"fill":"solid","label":"细胞核","note":"储存 DNA"},
27
+ {"at":[55,-30],"width":40,"height":22,"rotation":25,"label":"线粒体","note":"供能"},
28
+ {"shape":"polygon","points":[[-70,-40],[-40,-55],[-30,-30],[-60,-20]],"label":"高尔基体"}
29
+ ]}
30
+ ```
31
+
32
+ Labels are laid out for you. Omit `labelAt` and each callout goes out to the side its part is already on, stacked down that side in top-to-bottom order — so a model can name six organelles without also solving a layout problem whose result it cannot see. Give `labelAt` to place one yourself.
33
+
34
+ `y` increases upwards, the same convention as [`@ai-gui/plugin-physics`](../plugin-physics), so a lesson that draws both does not hold two conventions at once.
35
+
36
+ Complex outlines are a `polygon`: a point list can be validated, where hand-written path data cannot. Nothing is shipped as artwork, so nothing here decides which figures a curriculum may teach.
37
+
38
+ ## API
39
+
40
+ - `figure(options?)` creates the AIGUI plugin.
41
+ - `figurePromptSpec(options?)` returns the model-facing protocol description.
42
+ - `parseFigure(source, options?)` strictly parses and validates a block.
43
+ - `renderFigureSVG(diagram, options?)` renders a parsed figure.
44
+ - `figureCss` contains the package styling.
45
+
46
+ ## Options
47
+
48
+ - `width?: number`: the rendered maximum width in px, default `480`.
49
+ - `height?: number`: the rendered maximum height in px, default `360`.
50
+ - `maxParts?: number`: default `32`.
51
+ - `maxSourceBytes?: number`: default 8 KiB.
52
+
53
+ Unknown fields, URLs, non-finite coordinates, unknown shapes and fills, polygons under three points, and oversized blocks are rejected, and the block renders an error in place rather than a confident drawing of something the model did not mean.
54
+
55
+ Colours come from `currentColor` and `--aigui-figure-*` custom properties, so a figure on a dark page is not drawn in ink chosen for a light one. Output is marked `trusted` because it is built from coordinates rather than from model markup; a sanitizer would otherwise strip the drawing.
package/dist/index.cjs ADDED
@@ -0,0 +1,395 @@
1
+ "use strict";
2
+
3
+ //#region src/index.ts
4
+ const DEFAULTS = {
5
+ width: 480,
6
+ height: 360,
7
+ maxSourceBytes: 8 * 1024,
8
+ maxParts: 32
9
+ };
10
+ const FILLS = [
11
+ "none",
12
+ "tint",
13
+ "solid"
14
+ ];
15
+ const SHAPES = [
16
+ "ellipse",
17
+ "rect",
18
+ "polygon",
19
+ "point"
20
+ ];
21
+ function escapeHtml(value) {
22
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
23
+ }
24
+ function isFinitePoint(value) {
25
+ return Array.isArray(value) && value.length === 2 && value.every((part) => typeof part === "number" && Number.isFinite(part));
26
+ }
27
+ function num(value, fallback) {
28
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
29
+ }
30
+ function round(value) {
31
+ return (Math.round(value * 100) / 100).toString();
32
+ }
33
+ /**
34
+ * Read a figure out of a block, rejecting anything that is not one.
35
+ *
36
+ * Strict, like the other plugins that accept model output: a figure that half-parses would label a
37
+ * shape the model did not mean, and a mislabelled diagram teaches the wrong thing confidently.
38
+ */
39
+ function parseFigure(source, options = {}) {
40
+ const limits = {
41
+ ...DEFAULTS,
42
+ ...options
43
+ };
44
+ if (new TextEncoder().encode(source).byteLength > limits.maxSourceBytes) return {
45
+ valid: false,
46
+ issues: ["Figure is too large."]
47
+ };
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(source);
51
+ } catch {
52
+ return {
53
+ valid: false,
54
+ issues: ["Figure must be valid JSON."]
55
+ };
56
+ }
57
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {
58
+ valid: false,
59
+ issues: ["Figure must be a JSON object."]
60
+ };
61
+ const value = parsed;
62
+ if (value.version !== 1) return {
63
+ valid: false,
64
+ issues: ["Figure must declare \"version\": 1."]
65
+ };
66
+ if (!Array.isArray(value.parts)) return {
67
+ valid: false,
68
+ issues: ["$.parts must be an array."]
69
+ };
70
+ if (value.parts.length === 0) return {
71
+ valid: false,
72
+ issues: ["Figure must contain at least one part."]
73
+ };
74
+ if (value.parts.length > limits.maxParts) return {
75
+ valid: false,
76
+ issues: [`$.parts has more than ${limits.maxParts} entries.`]
77
+ };
78
+ const issues = [];
79
+ const parts = [];
80
+ value.parts.forEach((raw, index) => {
81
+ const path = `$.parts[${index}]`;
82
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
83
+ issues.push(`${path} must be an object.`);
84
+ return;
85
+ }
86
+ const item = raw;
87
+ const shape = item.shape === void 0 ? "ellipse" : item.shape;
88
+ if (typeof shape !== "string" || !SHAPES.includes(shape)) {
89
+ issues.push(`${path}.shape must be one of ${SHAPES.join(", ")}.`);
90
+ return;
91
+ }
92
+ const fill = item.fill === void 0 ? "tint" : item.fill;
93
+ if (typeof fill !== "string" || !FILLS.includes(fill)) {
94
+ issues.push(`${path}.fill must be one of ${FILLS.join(", ")}.`);
95
+ return;
96
+ }
97
+ if (shape === "polygon") {
98
+ if (!Array.isArray(item.points) || item.points.length < 3) {
99
+ issues.push(`${path}.points must be at least three [x,y] pairs.`);
100
+ return;
101
+ }
102
+ if (item.points.length > 200) {
103
+ issues.push(`${path}.points has more than 200 points.`);
104
+ return;
105
+ }
106
+ if (!item.points.every(isFinitePoint)) {
107
+ issues.push(`${path}.points must all be two finite numbers.`);
108
+ return;
109
+ }
110
+ } else if (!isFinitePoint(item.at)) {
111
+ issues.push(`${path}.at must be two finite numbers.`);
112
+ return;
113
+ }
114
+ parts.push({
115
+ shape,
116
+ fill,
117
+ ...isFinitePoint(item.at) ? { at: item.at } : {},
118
+ ...shape === "polygon" ? { points: item.points } : {},
119
+ width: num(item.width, 80),
120
+ height: num(item.height, 60),
121
+ rotation: num(item.rotation, 0),
122
+ ...typeof item.label === "string" ? { label: item.label } : {},
123
+ ...typeof item.note === "string" ? { note: item.note } : {},
124
+ ...isFinitePoint(item.labelAt) ? { labelAt: item.labelAt } : {}
125
+ });
126
+ });
127
+ if (issues.length > 0) return {
128
+ valid: false,
129
+ issues
130
+ };
131
+ return {
132
+ valid: true,
133
+ data: {
134
+ version: 1,
135
+ ...Array.isArray(value.view) && value.view.length === 4 && value.view.every((part) => typeof part === "number" && Number.isFinite(part)) ? { view: value.view } : {},
136
+ ...typeof value.title === "string" ? { title: value.title } : {},
137
+ ...typeof value.caption === "string" ? { caption: value.caption } : {},
138
+ parts
139
+ }
140
+ };
141
+ }
142
+ /** Where a part sits, and how far it reaches. */
143
+ function boundsOf(part) {
144
+ if (part.shape === "polygon" && part.points) {
145
+ const xs = part.points.map((point) => point[0]);
146
+ const ys = part.points.map((point) => point[1]);
147
+ const minX = Math.min(...xs);
148
+ const maxX = Math.max(...xs);
149
+ const minY = Math.min(...ys);
150
+ const maxY = Math.max(...ys);
151
+ return {
152
+ at: [(minX + maxX) / 2, (minY + maxY) / 2],
153
+ halfWidth: (maxX - minX) / 2,
154
+ halfHeight: (maxY - minY) / 2
155
+ };
156
+ }
157
+ const at = part.at ?? [0, 0];
158
+ if (part.shape === "point") return {
159
+ at,
160
+ halfWidth: 4,
161
+ halfHeight: 4
162
+ };
163
+ return {
164
+ at,
165
+ halfWidth: (part.width ?? 80) / 2,
166
+ halfHeight: (part.height ?? 60) / 2
167
+ };
168
+ }
169
+ /** The box the figure occupies, before room is made for the labels beside it. */
170
+ function figureBounds(diagram) {
171
+ const xs = [];
172
+ const ys = [];
173
+ for (const part of diagram.parts) {
174
+ const { at, halfWidth, halfHeight } = boundsOf(part);
175
+ xs.push(at[0] - halfWidth, at[0] + halfWidth);
176
+ ys.push(at[1] - halfHeight, at[1] + halfHeight);
177
+ }
178
+ if (xs.length === 0) return [
179
+ 0,
180
+ 0,
181
+ 100,
182
+ 100
183
+ ];
184
+ return [
185
+ Math.min(...xs),
186
+ Math.min(...ys),
187
+ Math.max(...xs),
188
+ Math.max(...ys)
189
+ ];
190
+ }
191
+ /**
192
+ * Decide where each label goes.
193
+ *
194
+ * A label the model placed is left where it put it. The rest go out to the side the part is already
195
+ * on and are stacked down it, evenly spread and in the order they appear top to bottom — the
196
+ * arrangement a textbook plate uses, so a model can name six organelles without also solving a
197
+ * layout problem whose result it cannot see.
198
+ *
199
+ * The side has to follow the part, not its turn in the list. Alternating sides sends the leader for
200
+ * a part on the left out to the right, straight across everything drawn in between, and a figure
201
+ * whose lines cross the thing they are labelling cannot be read.
202
+ */
203
+ function layOutCallouts(diagram, bounds) {
204
+ const [minX, minY, maxX, maxY] = bounds;
205
+ const centreX = (minX + maxX) / 2;
206
+ const labelled = diagram.parts.filter((part) => part.label !== void 0);
207
+ const placed = [];
208
+ const gutter = 56;
209
+ const automatic = [];
210
+ for (const part of labelled) {
211
+ if (part.labelAt) {
212
+ const { at: at$1 } = boundsOf(part);
213
+ placed.push({
214
+ part,
215
+ at: part.labelAt,
216
+ side: part.labelAt[0] < at$1[0] ? "left" : "right"
217
+ });
218
+ continue;
219
+ }
220
+ const { at } = boundsOf(part);
221
+ automatic.push({
222
+ part,
223
+ side: at[0] <= centreX ? "left" : "right",
224
+ y: at[1]
225
+ });
226
+ }
227
+ for (const side of ["left", "right"]) {
228
+ const column = automatic.filter((entry) => entry.side === side);
229
+ column.sort((left, right) => right.y - left.y);
230
+ const span = maxY - minY;
231
+ const step = column.length > 1 ? span / (column.length - 1) : 0;
232
+ column.forEach((entry, index) => {
233
+ const y = column.length > 1 ? maxY - index * step : (minY + maxY) / 2;
234
+ placed.push({
235
+ part: entry.part,
236
+ at: [side === "left" ? minX - gutter : maxX + gutter, y],
237
+ side
238
+ });
239
+ });
240
+ }
241
+ return placed;
242
+ }
243
+ /** The point on a part's edge a leader line should meet, coming from `towards`. */
244
+ function anchorOn(part, towards) {
245
+ const { at, halfWidth, halfHeight } = boundsOf(part);
246
+ const dx = towards[0] - at[0];
247
+ const dy = towards[1] - at[1];
248
+ const distance = Math.hypot(dx, dy);
249
+ if (distance === 0) return at;
250
+ const scale = Math.min(1, Math.hypot(halfWidth, halfHeight) / distance);
251
+ return [at[0] + dx * scale * .95, at[1] + dy * scale * .95];
252
+ }
253
+ /**
254
+ * Draw the figure.
255
+ *
256
+ * The y axis is flipped once in the outer transform, the same way `@ai-gui/plugin-physics` does it,
257
+ * so a lesson that draws both does not have to hold two conventions at once. Every text node flips
258
+ * back so it stays upright.
259
+ */
260
+ function renderFigureSVG(diagram, options = {}) {
261
+ const limits = {
262
+ ...DEFAULTS,
263
+ ...options
264
+ };
265
+ const bounds = figureBounds(diagram);
266
+ const callouts = layOutCallouts(diagram, bounds);
267
+ const parts = [];
268
+ for (const part of diagram.parts) {
269
+ const rotation = part.rotation && part.shape !== "polygon" ? ` transform="rotate(${round(part.rotation)} ${round((part.at ?? [0, 0])[0])} ${round((part.at ?? [0, 0])[1])})"` : "";
270
+ const fill = ` class="aigui-figure-part aigui-figure-fill-${part.fill ?? "tint"}"`;
271
+ if (part.shape === "polygon" && part.points) {
272
+ const points = part.points.map((point) => `${round(point[0])},${round(point[1])}`).join(" ");
273
+ parts.push(`<polygon points="${points}"${fill} />`);
274
+ } else if (part.shape === "point") {
275
+ const at = part.at ?? [0, 0];
276
+ parts.push(`<circle cx="${round(at[0])}" cy="${round(at[1])}" r="4" class="aigui-figure-point" />`);
277
+ } else if (part.shape === "rect") {
278
+ const at = part.at ?? [0, 0];
279
+ const width$1 = part.width ?? 80;
280
+ const height$1 = part.height ?? 60;
281
+ parts.push(`<rect x="${round(at[0] - width$1 / 2)}" y="${round(at[1] - height$1 / 2)}" width="${round(width$1)}" height="${round(height$1)}" rx="4"${fill}${rotation} />`);
282
+ } else {
283
+ const at = part.at ?? [0, 0];
284
+ parts.push(`<ellipse cx="${round(at[0])}" cy="${round(at[1])}" rx="${round((part.width ?? 80) / 2)}" ry="${round((part.height ?? 60) / 2)}"${fill}${rotation} />`);
285
+ }
286
+ }
287
+ for (const callout of callouts) {
288
+ const anchor = anchorOn(callout.part, callout.at);
289
+ const elbowX = callout.side === "left" ? callout.at[0] + 12 : callout.at[0] - 12;
290
+ parts.push(`<path d="M ${round(anchor[0])} ${round(anchor[1])} L ${round(elbowX)} ${round(callout.at[1])} L ${round(callout.at[0])} ${round(callout.at[1])}" class="aigui-figure-leader" />`, `<circle cx="${round(anchor[0])}" cy="${round(anchor[1])}" r="2.5" class="aigui-figure-leader-dot" />`);
291
+ const anchorAttribute = callout.side === "left" ? "end" : "start";
292
+ const flip = `transform="scale(1,-1)" transform-origin="${round(callout.at[0])} ${round(callout.at[1])}"`;
293
+ parts.push(`<text x="${round(callout.at[0])}" y="${round(callout.at[1])}" class="aigui-figure-label" text-anchor="${anchorAttribute}" ${flip}>${escapeHtml(callout.part.label ?? "")}</text>`);
294
+ if (callout.part.note) parts.push(`<text x="${round(callout.at[0])}" y="${round(callout.at[1] - 15)}" class="aigui-figure-note" text-anchor="${anchorAttribute}" transform="scale(1,-1)" transform-origin="${round(callout.at[0])} ${round(callout.at[1] - 15)}">${escapeHtml(callout.part.note)}</text>`);
295
+ }
296
+ const view = diagram.view ?? viewWithCallouts(bounds, callouts, Boolean(diagram.caption));
297
+ const [minX, minY, maxX, maxY] = view;
298
+ const width = Math.max(1, maxX - minX);
299
+ const height = Math.max(1, maxY - minY);
300
+ if (diagram.caption) {
301
+ const y = minY + 14;
302
+ parts.push(`<text x="${round((minX + maxX) / 2)}" y="${round(y)}" class="aigui-figure-caption" text-anchor="middle" transform="scale(1,-1)" transform-origin="${round((minX + maxX) / 2)} ${round(y)}">${escapeHtml(diagram.caption)}</text>`);
303
+ }
304
+ const title = diagram.title ? `<title>${escapeHtml(diagram.title)}</title>` : "";
305
+ return [
306
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${round(minX)} ${round(minY)} ${round(width)} ${round(height)}"`,
307
+ ` width="100%" style="max-width:${limits.width}px;max-height:${limits.height}px;display:block;margin:auto"`,
308
+ ` role="img" aria-label="${escapeHtml(diagram.title ?? diagram.caption ?? "Labelled figure")}" data-aigui-figure="diagram">`,
309
+ title,
310
+ `<g transform="translate(0, ${round(minY + maxY)}) scale(1, -1)">`,
311
+ parts.join(""),
312
+ "</g></svg>"
313
+ ].join("");
314
+ }
315
+ /** Widen the drawing box until every callout, and the caption, is inside it. */
316
+ function viewWithCallouts(bounds, callouts, hasCaption) {
317
+ const textRoom = 120;
318
+ const xs = [bounds[0], bounds[2]];
319
+ const ys = [bounds[1], bounds[3]];
320
+ for (const callout of callouts) {
321
+ xs.push(callout.side === "left" ? callout.at[0] - textRoom : callout.at[0] + textRoom);
322
+ ys.push(callout.at[1] - 20, callout.at[1] + 12);
323
+ }
324
+ const pad = 12;
325
+ return [
326
+ Math.min(...xs) - pad,
327
+ Math.min(...ys) - pad - (hasCaption ? 22 : 0),
328
+ Math.max(...xs) + pad,
329
+ Math.max(...ys) + pad
330
+ ];
331
+ }
332
+ function figurePromptSpec(options = {}) {
333
+ const limits = {
334
+ ...DEFAULTS,
335
+ ...options
336
+ };
337
+ return [
338
+ "Labelled figures (one fenced block): ```figure <strict JSON>```.",
339
+ "Root: {\"version\":1,\"title\":\"...\"?,\"caption\":\"...\"?,\"view\":[minX,minY,maxX,maxY]?,\"parts\":[...]}. No unknown fields.",
340
+ "Part: {\"shape\":\"ellipse|rect|polygon|point\"?,\"at\":[x,y],\"width\":n?,\"height\":n?,\"points\":[[x,y],...]?,\"rotation\":deg?,\"fill\":\"none|tint|solid\"?,\"label\":\"...\"?,\"note\":\"...\"?,\"labelAt\":[x,y]?}.",
341
+ "Give \"at\" for ellipse, rect and point; give \"points\" (three or more) for polygon. Default shape is ellipse, default fill is tint.",
342
+ "\"label\" is what the part is called and \"note\" is a shorter second line saying what it does. Omit \"labelAt\" and the labels are stacked down both sides with leader lines drawn for you.",
343
+ "Draw containers before the parts inside them, so an enclosing outline does not cover them.",
344
+ "y increases upwards, the same convention as ```physics.",
345
+ `Figure source is local JSON only, at most ${limits.maxSourceBytes} UTF-8 bytes. Never emit URLs, scripts, remote resources, HTML, or executable content.`,
346
+ "Use this when naming the parts is the lesson: cell organelles, a leaf's layers, apparatus, a labelled cross-section. Use ```mermaid for boxes joined by arrows and ```chart for data."
347
+ ].join("\n");
348
+ }
349
+ const figureCss = `
350
+ [data-aigui-figure] { max-width: 100%; height: auto; overflow: visible; color: currentColor; }
351
+ [data-aigui-figure] .aigui-figure-part { stroke: currentColor; stroke-width: 1.5; }
352
+ [data-aigui-figure] .aigui-figure-fill-none { fill: none; }
353
+ [data-aigui-figure] .aigui-figure-fill-tint { fill: var(--aigui-figure-tint, color-mix(in srgb, currentColor 10%, transparent)); }
354
+ [data-aigui-figure] .aigui-figure-fill-solid { fill: var(--aigui-figure-solid, color-mix(in srgb, currentColor 28%, transparent)); }
355
+ [data-aigui-figure] .aigui-figure-point { fill: currentColor; stroke: none; }
356
+ [data-aigui-figure] .aigui-figure-leader { fill: none; stroke: var(--aigui-figure-leader, currentColor); stroke-width: 1; opacity: .6; }
357
+ [data-aigui-figure] .aigui-figure-leader-dot { fill: var(--aigui-figure-leader, currentColor); opacity: .8; }
358
+ [data-aigui-figure] .aigui-figure-label { font-size: 13px; font-family: inherit; dominant-baseline: middle; fill: currentColor; }
359
+ [data-aigui-figure] .aigui-figure-note { font-size: 11px; font-family: inherit; dominant-baseline: middle; fill: currentColor; opacity: .7; }
360
+ [data-aigui-figure] .aigui-figure-caption { font-size: 12px; font-family: inherit; fill: currentColor; opacity: .75; }
361
+ `;
362
+ function errorHtml(issue) {
363
+ return {
364
+ kind: "html",
365
+ html: `<pre data-aigui-figure-error>${escapeHtml(issue)}</pre>`
366
+ };
367
+ }
368
+ function figure(options = {}) {
369
+ const render = (node) => {
370
+ const parsed = parseFigure(node.content ?? "", options);
371
+ if (!parsed.valid) return errorHtml(parsed.issues[0] ?? "Invalid figure.");
372
+ try {
373
+ return {
374
+ kind: "html",
375
+ html: renderFigureSVG(parsed.data, options),
376
+ trusted: true
377
+ };
378
+ } catch {
379
+ return errorHtml("Figure could not be drawn.");
380
+ }
381
+ };
382
+ return {
383
+ name: "figure",
384
+ nodeRenderers: { figure: render },
385
+ css: figureCss,
386
+ promptSpec: figurePromptSpec(options)
387
+ };
388
+ }
389
+
390
+ //#endregion
391
+ exports.figure = figure
392
+ exports.figureCss = figureCss
393
+ exports.figurePromptSpec = figurePromptSpec
394
+ exports.parseFigure = parseFigure
395
+ exports.renderFigureSVG = renderFigureSVG
@@ -0,0 +1,94 @@
1
+ import { AIGuiPlugin } from "@ai-gui/core";
2
+
3
+ //#region src/index.d.ts
4
+ /**
5
+ * Labelled figures: the diagram whose whole point is what the parts are called.
6
+ *
7
+ * A cell with its organelles named, a leaf's layers, a piece of apparatus with the parts a method
8
+ * refers to. Mermaid draws boxes joined by arrows and a chart draws data; neither draws a shape with
9
+ * "细胞核 — 储存 DNA" on a leader line pointing into it, which is the figure a biology lesson is
10
+ * built around.
11
+ *
12
+ * The model composes the shapes and writes the callouts. Nothing is shipped as artwork, so nothing
13
+ * here decides which figures a curriculum is allowed to teach — an ellipse inside an ellipse is a
14
+ * crude cell, but it is the learner's cell, labelled with what this lesson is about. Complex
15
+ * outlines are a polygon: a point list can be checked, where a hand-written path cannot.
16
+ */
17
+ /**
18
+ * Labelled figures: the diagram whose whole point is what the parts are called.
19
+ *
20
+ * A cell with its organelles named, a leaf's layers, a piece of apparatus with the parts a method
21
+ * refers to. Mermaid draws boxes joined by arrows and a chart draws data; neither draws a shape with
22
+ * "细胞核 — 储存 DNA" on a leader line pointing into it, which is the figure a biology lesson is
23
+ * built around.
24
+ *
25
+ * The model composes the shapes and writes the callouts. Nothing is shipped as artwork, so nothing
26
+ * here decides which figures a curriculum is allowed to teach — an ellipse inside an ellipse is a
27
+ * crude cell, but it is the learner's cell, labelled with what this lesson is about. Complex
28
+ * outlines are a polygon: a point list can be checked, where a hand-written path cannot.
29
+ */
30
+ type PartShape = "ellipse" | "rect" | "polygon" | "point";
31
+ /** How much a part's interior is filled, so nesting reads as nesting. */
32
+ type PartFill = "none" | "tint" | "solid";
33
+ interface FigurePart {
34
+ shape?: PartShape;
35
+ /** Centre, for an ellipse, a rect or a point. */
36
+ at?: [number, number];
37
+ width?: number;
38
+ height?: number;
39
+ /** The outline, for a polygon. Closed automatically. */
40
+ points?: [number, number][];
41
+ /** Degrees, counter-clockwise. */
42
+ rotation?: number;
43
+ fill?: PartFill;
44
+ /** What this part is called. Omit for a part that is only there to be drawn. */
45
+ label?: string;
46
+ /** A second, quieter line under the label: what it does, why it matters. */
47
+ note?: string;
48
+ /**
49
+ * Where the callout text sits. Omitted, labels are stacked down the sides of the figure with
50
+ * leader lines drawn to them — the arrangement a textbook plate uses.
51
+ */
52
+ labelAt?: [number, number];
53
+ }
54
+ interface FigureDiagram {
55
+ version: 1;
56
+ /** The coordinate box the figure is drawn in: [minX, minY, maxX, maxY]. */
57
+ view?: [number, number, number, number];
58
+ title?: string;
59
+ /** A line under the figure: what it is a figure of. */
60
+ caption?: string;
61
+ parts: FigurePart[];
62
+ }
63
+ interface FigureOptions {
64
+ width?: number;
65
+ height?: number;
66
+ maxSourceBytes?: number;
67
+ /** How many parts to draw before refusing — a runaway block is not a figure. */
68
+ maxParts?: number;
69
+ }
70
+ /**
71
+ * Read a figure out of a block, rejecting anything that is not one.
72
+ *
73
+ * Strict, like the other plugins that accept model output: a figure that half-parses would label a
74
+ * shape the model did not mean, and a mislabelled diagram teaches the wrong thing confidently.
75
+ */
76
+ declare function parseFigure(source: string, options?: FigureOptions): {
77
+ valid: true;
78
+ data: FigureDiagram;
79
+ } | {
80
+ valid: false;
81
+ issues: string[];
82
+ };
83
+ /**
84
+ * Draw the figure.
85
+ *
86
+ * The y axis is flipped once in the outer transform, the same way `@ai-gui/plugin-physics` does it,
87
+ * so a lesson that draws both does not have to hold two conventions at once. Every text node flips
88
+ * back so it stays upright.
89
+ */
90
+ declare function renderFigureSVG(diagram: FigureDiagram, options?: FigureOptions): string;
91
+ declare function figurePromptSpec(options?: FigureOptions): string;
92
+ declare const figureCss = "\n[data-aigui-figure] { max-width: 100%; height: auto; overflow: visible; color: currentColor; }\n[data-aigui-figure] .aigui-figure-part { stroke: currentColor; stroke-width: 1.5; }\n[data-aigui-figure] .aigui-figure-fill-none { fill: none; }\n[data-aigui-figure] .aigui-figure-fill-tint { fill: var(--aigui-figure-tint, color-mix(in srgb, currentColor 10%, transparent)); }\n[data-aigui-figure] .aigui-figure-fill-solid { fill: var(--aigui-figure-solid, color-mix(in srgb, currentColor 28%, transparent)); }\n[data-aigui-figure] .aigui-figure-point { fill: currentColor; stroke: none; }\n[data-aigui-figure] .aigui-figure-leader { fill: none; stroke: var(--aigui-figure-leader, currentColor); stroke-width: 1; opacity: .6; }\n[data-aigui-figure] .aigui-figure-leader-dot { fill: var(--aigui-figure-leader, currentColor); opacity: .8; }\n[data-aigui-figure] .aigui-figure-label { font-size: 13px; font-family: inherit; dominant-baseline: middle; fill: currentColor; }\n[data-aigui-figure] .aigui-figure-note { font-size: 11px; font-family: inherit; dominant-baseline: middle; fill: currentColor; opacity: .7; }\n[data-aigui-figure] .aigui-figure-caption { font-size: 12px; font-family: inherit; fill: currentColor; opacity: .75; }\n";
93
+ declare function figure(options?: FigureOptions): AIGuiPlugin; //#endregion
94
+ export { FigureDiagram, FigureOptions, FigurePart, PartFill, PartShape, figure, figureCss, figurePromptSpec, parseFigure, renderFigureSVG };
@@ -0,0 +1,94 @@
1
+ import { AIGuiPlugin } from "@ai-gui/core";
2
+
3
+ //#region src/index.d.ts
4
+ /**
5
+ * Labelled figures: the diagram whose whole point is what the parts are called.
6
+ *
7
+ * A cell with its organelles named, a leaf's layers, a piece of apparatus with the parts a method
8
+ * refers to. Mermaid draws boxes joined by arrows and a chart draws data; neither draws a shape with
9
+ * "细胞核 — 储存 DNA" on a leader line pointing into it, which is the figure a biology lesson is
10
+ * built around.
11
+ *
12
+ * The model composes the shapes and writes the callouts. Nothing is shipped as artwork, so nothing
13
+ * here decides which figures a curriculum is allowed to teach — an ellipse inside an ellipse is a
14
+ * crude cell, but it is the learner's cell, labelled with what this lesson is about. Complex
15
+ * outlines are a polygon: a point list can be checked, where a hand-written path cannot.
16
+ */
17
+ /**
18
+ * Labelled figures: the diagram whose whole point is what the parts are called.
19
+ *
20
+ * A cell with its organelles named, a leaf's layers, a piece of apparatus with the parts a method
21
+ * refers to. Mermaid draws boxes joined by arrows and a chart draws data; neither draws a shape with
22
+ * "细胞核 — 储存 DNA" on a leader line pointing into it, which is the figure a biology lesson is
23
+ * built around.
24
+ *
25
+ * The model composes the shapes and writes the callouts. Nothing is shipped as artwork, so nothing
26
+ * here decides which figures a curriculum is allowed to teach — an ellipse inside an ellipse is a
27
+ * crude cell, but it is the learner's cell, labelled with what this lesson is about. Complex
28
+ * outlines are a polygon: a point list can be checked, where a hand-written path cannot.
29
+ */
30
+ type PartShape = "ellipse" | "rect" | "polygon" | "point";
31
+ /** How much a part's interior is filled, so nesting reads as nesting. */
32
+ type PartFill = "none" | "tint" | "solid";
33
+ interface FigurePart {
34
+ shape?: PartShape;
35
+ /** Centre, for an ellipse, a rect or a point. */
36
+ at?: [number, number];
37
+ width?: number;
38
+ height?: number;
39
+ /** The outline, for a polygon. Closed automatically. */
40
+ points?: [number, number][];
41
+ /** Degrees, counter-clockwise. */
42
+ rotation?: number;
43
+ fill?: PartFill;
44
+ /** What this part is called. Omit for a part that is only there to be drawn. */
45
+ label?: string;
46
+ /** A second, quieter line under the label: what it does, why it matters. */
47
+ note?: string;
48
+ /**
49
+ * Where the callout text sits. Omitted, labels are stacked down the sides of the figure with
50
+ * leader lines drawn to them — the arrangement a textbook plate uses.
51
+ */
52
+ labelAt?: [number, number];
53
+ }
54
+ interface FigureDiagram {
55
+ version: 1;
56
+ /** The coordinate box the figure is drawn in: [minX, minY, maxX, maxY]. */
57
+ view?: [number, number, number, number];
58
+ title?: string;
59
+ /** A line under the figure: what it is a figure of. */
60
+ caption?: string;
61
+ parts: FigurePart[];
62
+ }
63
+ interface FigureOptions {
64
+ width?: number;
65
+ height?: number;
66
+ maxSourceBytes?: number;
67
+ /** How many parts to draw before refusing — a runaway block is not a figure. */
68
+ maxParts?: number;
69
+ }
70
+ /**
71
+ * Read a figure out of a block, rejecting anything that is not one.
72
+ *
73
+ * Strict, like the other plugins that accept model output: a figure that half-parses would label a
74
+ * shape the model did not mean, and a mislabelled diagram teaches the wrong thing confidently.
75
+ */
76
+ declare function parseFigure(source: string, options?: FigureOptions): {
77
+ valid: true;
78
+ data: FigureDiagram;
79
+ } | {
80
+ valid: false;
81
+ issues: string[];
82
+ };
83
+ /**
84
+ * Draw the figure.
85
+ *
86
+ * The y axis is flipped once in the outer transform, the same way `@ai-gui/plugin-physics` does it,
87
+ * so a lesson that draws both does not have to hold two conventions at once. Every text node flips
88
+ * back so it stays upright.
89
+ */
90
+ declare function renderFigureSVG(diagram: FigureDiagram, options?: FigureOptions): string;
91
+ declare function figurePromptSpec(options?: FigureOptions): string;
92
+ declare const figureCss = "\n[data-aigui-figure] { max-width: 100%; height: auto; overflow: visible; color: currentColor; }\n[data-aigui-figure] .aigui-figure-part { stroke: currentColor; stroke-width: 1.5; }\n[data-aigui-figure] .aigui-figure-fill-none { fill: none; }\n[data-aigui-figure] .aigui-figure-fill-tint { fill: var(--aigui-figure-tint, color-mix(in srgb, currentColor 10%, transparent)); }\n[data-aigui-figure] .aigui-figure-fill-solid { fill: var(--aigui-figure-solid, color-mix(in srgb, currentColor 28%, transparent)); }\n[data-aigui-figure] .aigui-figure-point { fill: currentColor; stroke: none; }\n[data-aigui-figure] .aigui-figure-leader { fill: none; stroke: var(--aigui-figure-leader, currentColor); stroke-width: 1; opacity: .6; }\n[data-aigui-figure] .aigui-figure-leader-dot { fill: var(--aigui-figure-leader, currentColor); opacity: .8; }\n[data-aigui-figure] .aigui-figure-label { font-size: 13px; font-family: inherit; dominant-baseline: middle; fill: currentColor; }\n[data-aigui-figure] .aigui-figure-note { font-size: 11px; font-family: inherit; dominant-baseline: middle; fill: currentColor; opacity: .7; }\n[data-aigui-figure] .aigui-figure-caption { font-size: 12px; font-family: inherit; fill: currentColor; opacity: .75; }\n";
93
+ declare function figure(options?: FigureOptions): AIGuiPlugin; //#endregion
94
+ export { FigureDiagram, FigureOptions, FigurePart, PartFill, PartShape, figure, figureCss, figurePromptSpec, parseFigure, renderFigureSVG };
package/dist/index.js ADDED
@@ -0,0 +1,389 @@
1
+ //#region src/index.ts
2
+ const DEFAULTS = {
3
+ width: 480,
4
+ height: 360,
5
+ maxSourceBytes: 8 * 1024,
6
+ maxParts: 32
7
+ };
8
+ const FILLS = [
9
+ "none",
10
+ "tint",
11
+ "solid"
12
+ ];
13
+ const SHAPES = [
14
+ "ellipse",
15
+ "rect",
16
+ "polygon",
17
+ "point"
18
+ ];
19
+ function escapeHtml(value) {
20
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
21
+ }
22
+ function isFinitePoint(value) {
23
+ return Array.isArray(value) && value.length === 2 && value.every((part) => typeof part === "number" && Number.isFinite(part));
24
+ }
25
+ function num(value, fallback) {
26
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
27
+ }
28
+ function round(value) {
29
+ return (Math.round(value * 100) / 100).toString();
30
+ }
31
+ /**
32
+ * Read a figure out of a block, rejecting anything that is not one.
33
+ *
34
+ * Strict, like the other plugins that accept model output: a figure that half-parses would label a
35
+ * shape the model did not mean, and a mislabelled diagram teaches the wrong thing confidently.
36
+ */
37
+ function parseFigure(source, options = {}) {
38
+ const limits = {
39
+ ...DEFAULTS,
40
+ ...options
41
+ };
42
+ if (new TextEncoder().encode(source).byteLength > limits.maxSourceBytes) return {
43
+ valid: false,
44
+ issues: ["Figure is too large."]
45
+ };
46
+ let parsed;
47
+ try {
48
+ parsed = JSON.parse(source);
49
+ } catch {
50
+ return {
51
+ valid: false,
52
+ issues: ["Figure must be valid JSON."]
53
+ };
54
+ }
55
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {
56
+ valid: false,
57
+ issues: ["Figure must be a JSON object."]
58
+ };
59
+ const value = parsed;
60
+ if (value.version !== 1) return {
61
+ valid: false,
62
+ issues: ["Figure must declare \"version\": 1."]
63
+ };
64
+ if (!Array.isArray(value.parts)) return {
65
+ valid: false,
66
+ issues: ["$.parts must be an array."]
67
+ };
68
+ if (value.parts.length === 0) return {
69
+ valid: false,
70
+ issues: ["Figure must contain at least one part."]
71
+ };
72
+ if (value.parts.length > limits.maxParts) return {
73
+ valid: false,
74
+ issues: [`$.parts has more than ${limits.maxParts} entries.`]
75
+ };
76
+ const issues = [];
77
+ const parts = [];
78
+ value.parts.forEach((raw, index) => {
79
+ const path = `$.parts[${index}]`;
80
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
81
+ issues.push(`${path} must be an object.`);
82
+ return;
83
+ }
84
+ const item = raw;
85
+ const shape = item.shape === void 0 ? "ellipse" : item.shape;
86
+ if (typeof shape !== "string" || !SHAPES.includes(shape)) {
87
+ issues.push(`${path}.shape must be one of ${SHAPES.join(", ")}.`);
88
+ return;
89
+ }
90
+ const fill = item.fill === void 0 ? "tint" : item.fill;
91
+ if (typeof fill !== "string" || !FILLS.includes(fill)) {
92
+ issues.push(`${path}.fill must be one of ${FILLS.join(", ")}.`);
93
+ return;
94
+ }
95
+ if (shape === "polygon") {
96
+ if (!Array.isArray(item.points) || item.points.length < 3) {
97
+ issues.push(`${path}.points must be at least three [x,y] pairs.`);
98
+ return;
99
+ }
100
+ if (item.points.length > 200) {
101
+ issues.push(`${path}.points has more than 200 points.`);
102
+ return;
103
+ }
104
+ if (!item.points.every(isFinitePoint)) {
105
+ issues.push(`${path}.points must all be two finite numbers.`);
106
+ return;
107
+ }
108
+ } else if (!isFinitePoint(item.at)) {
109
+ issues.push(`${path}.at must be two finite numbers.`);
110
+ return;
111
+ }
112
+ parts.push({
113
+ shape,
114
+ fill,
115
+ ...isFinitePoint(item.at) ? { at: item.at } : {},
116
+ ...shape === "polygon" ? { points: item.points } : {},
117
+ width: num(item.width, 80),
118
+ height: num(item.height, 60),
119
+ rotation: num(item.rotation, 0),
120
+ ...typeof item.label === "string" ? { label: item.label } : {},
121
+ ...typeof item.note === "string" ? { note: item.note } : {},
122
+ ...isFinitePoint(item.labelAt) ? { labelAt: item.labelAt } : {}
123
+ });
124
+ });
125
+ if (issues.length > 0) return {
126
+ valid: false,
127
+ issues
128
+ };
129
+ return {
130
+ valid: true,
131
+ data: {
132
+ version: 1,
133
+ ...Array.isArray(value.view) && value.view.length === 4 && value.view.every((part) => typeof part === "number" && Number.isFinite(part)) ? { view: value.view } : {},
134
+ ...typeof value.title === "string" ? { title: value.title } : {},
135
+ ...typeof value.caption === "string" ? { caption: value.caption } : {},
136
+ parts
137
+ }
138
+ };
139
+ }
140
+ /** Where a part sits, and how far it reaches. */
141
+ function boundsOf(part) {
142
+ if (part.shape === "polygon" && part.points) {
143
+ const xs = part.points.map((point) => point[0]);
144
+ const ys = part.points.map((point) => point[1]);
145
+ const minX = Math.min(...xs);
146
+ const maxX = Math.max(...xs);
147
+ const minY = Math.min(...ys);
148
+ const maxY = Math.max(...ys);
149
+ return {
150
+ at: [(minX + maxX) / 2, (minY + maxY) / 2],
151
+ halfWidth: (maxX - minX) / 2,
152
+ halfHeight: (maxY - minY) / 2
153
+ };
154
+ }
155
+ const at = part.at ?? [0, 0];
156
+ if (part.shape === "point") return {
157
+ at,
158
+ halfWidth: 4,
159
+ halfHeight: 4
160
+ };
161
+ return {
162
+ at,
163
+ halfWidth: (part.width ?? 80) / 2,
164
+ halfHeight: (part.height ?? 60) / 2
165
+ };
166
+ }
167
+ /** The box the figure occupies, before room is made for the labels beside it. */
168
+ function figureBounds(diagram) {
169
+ const xs = [];
170
+ const ys = [];
171
+ for (const part of diagram.parts) {
172
+ const { at, halfWidth, halfHeight } = boundsOf(part);
173
+ xs.push(at[0] - halfWidth, at[0] + halfWidth);
174
+ ys.push(at[1] - halfHeight, at[1] + halfHeight);
175
+ }
176
+ if (xs.length === 0) return [
177
+ 0,
178
+ 0,
179
+ 100,
180
+ 100
181
+ ];
182
+ return [
183
+ Math.min(...xs),
184
+ Math.min(...ys),
185
+ Math.max(...xs),
186
+ Math.max(...ys)
187
+ ];
188
+ }
189
+ /**
190
+ * Decide where each label goes.
191
+ *
192
+ * A label the model placed is left where it put it. The rest go out to the side the part is already
193
+ * on and are stacked down it, evenly spread and in the order they appear top to bottom — the
194
+ * arrangement a textbook plate uses, so a model can name six organelles without also solving a
195
+ * layout problem whose result it cannot see.
196
+ *
197
+ * The side has to follow the part, not its turn in the list. Alternating sides sends the leader for
198
+ * a part on the left out to the right, straight across everything drawn in between, and a figure
199
+ * whose lines cross the thing they are labelling cannot be read.
200
+ */
201
+ function layOutCallouts(diagram, bounds) {
202
+ const [minX, minY, maxX, maxY] = bounds;
203
+ const centreX = (minX + maxX) / 2;
204
+ const labelled = diagram.parts.filter((part) => part.label !== void 0);
205
+ const placed = [];
206
+ const gutter = 56;
207
+ const automatic = [];
208
+ for (const part of labelled) {
209
+ if (part.labelAt) {
210
+ const { at: at$1 } = boundsOf(part);
211
+ placed.push({
212
+ part,
213
+ at: part.labelAt,
214
+ side: part.labelAt[0] < at$1[0] ? "left" : "right"
215
+ });
216
+ continue;
217
+ }
218
+ const { at } = boundsOf(part);
219
+ automatic.push({
220
+ part,
221
+ side: at[0] <= centreX ? "left" : "right",
222
+ y: at[1]
223
+ });
224
+ }
225
+ for (const side of ["left", "right"]) {
226
+ const column = automatic.filter((entry) => entry.side === side);
227
+ column.sort((left, right) => right.y - left.y);
228
+ const span = maxY - minY;
229
+ const step = column.length > 1 ? span / (column.length - 1) : 0;
230
+ column.forEach((entry, index) => {
231
+ const y = column.length > 1 ? maxY - index * step : (minY + maxY) / 2;
232
+ placed.push({
233
+ part: entry.part,
234
+ at: [side === "left" ? minX - gutter : maxX + gutter, y],
235
+ side
236
+ });
237
+ });
238
+ }
239
+ return placed;
240
+ }
241
+ /** The point on a part's edge a leader line should meet, coming from `towards`. */
242
+ function anchorOn(part, towards) {
243
+ const { at, halfWidth, halfHeight } = boundsOf(part);
244
+ const dx = towards[0] - at[0];
245
+ const dy = towards[1] - at[1];
246
+ const distance = Math.hypot(dx, dy);
247
+ if (distance === 0) return at;
248
+ const scale = Math.min(1, Math.hypot(halfWidth, halfHeight) / distance);
249
+ return [at[0] + dx * scale * .95, at[1] + dy * scale * .95];
250
+ }
251
+ /**
252
+ * Draw the figure.
253
+ *
254
+ * The y axis is flipped once in the outer transform, the same way `@ai-gui/plugin-physics` does it,
255
+ * so a lesson that draws both does not have to hold two conventions at once. Every text node flips
256
+ * back so it stays upright.
257
+ */
258
+ function renderFigureSVG(diagram, options = {}) {
259
+ const limits = {
260
+ ...DEFAULTS,
261
+ ...options
262
+ };
263
+ const bounds = figureBounds(diagram);
264
+ const callouts = layOutCallouts(diagram, bounds);
265
+ const parts = [];
266
+ for (const part of diagram.parts) {
267
+ const rotation = part.rotation && part.shape !== "polygon" ? ` transform="rotate(${round(part.rotation)} ${round((part.at ?? [0, 0])[0])} ${round((part.at ?? [0, 0])[1])})"` : "";
268
+ const fill = ` class="aigui-figure-part aigui-figure-fill-${part.fill ?? "tint"}"`;
269
+ if (part.shape === "polygon" && part.points) {
270
+ const points = part.points.map((point) => `${round(point[0])},${round(point[1])}`).join(" ");
271
+ parts.push(`<polygon points="${points}"${fill} />`);
272
+ } else if (part.shape === "point") {
273
+ const at = part.at ?? [0, 0];
274
+ parts.push(`<circle cx="${round(at[0])}" cy="${round(at[1])}" r="4" class="aigui-figure-point" />`);
275
+ } else if (part.shape === "rect") {
276
+ const at = part.at ?? [0, 0];
277
+ const width$1 = part.width ?? 80;
278
+ const height$1 = part.height ?? 60;
279
+ parts.push(`<rect x="${round(at[0] - width$1 / 2)}" y="${round(at[1] - height$1 / 2)}" width="${round(width$1)}" height="${round(height$1)}" rx="4"${fill}${rotation} />`);
280
+ } else {
281
+ const at = part.at ?? [0, 0];
282
+ parts.push(`<ellipse cx="${round(at[0])}" cy="${round(at[1])}" rx="${round((part.width ?? 80) / 2)}" ry="${round((part.height ?? 60) / 2)}"${fill}${rotation} />`);
283
+ }
284
+ }
285
+ for (const callout of callouts) {
286
+ const anchor = anchorOn(callout.part, callout.at);
287
+ const elbowX = callout.side === "left" ? callout.at[0] + 12 : callout.at[0] - 12;
288
+ parts.push(`<path d="M ${round(anchor[0])} ${round(anchor[1])} L ${round(elbowX)} ${round(callout.at[1])} L ${round(callout.at[0])} ${round(callout.at[1])}" class="aigui-figure-leader" />`, `<circle cx="${round(anchor[0])}" cy="${round(anchor[1])}" r="2.5" class="aigui-figure-leader-dot" />`);
289
+ const anchorAttribute = callout.side === "left" ? "end" : "start";
290
+ const flip = `transform="scale(1,-1)" transform-origin="${round(callout.at[0])} ${round(callout.at[1])}"`;
291
+ parts.push(`<text x="${round(callout.at[0])}" y="${round(callout.at[1])}" class="aigui-figure-label" text-anchor="${anchorAttribute}" ${flip}>${escapeHtml(callout.part.label ?? "")}</text>`);
292
+ if (callout.part.note) parts.push(`<text x="${round(callout.at[0])}" y="${round(callout.at[1] - 15)}" class="aigui-figure-note" text-anchor="${anchorAttribute}" transform="scale(1,-1)" transform-origin="${round(callout.at[0])} ${round(callout.at[1] - 15)}">${escapeHtml(callout.part.note)}</text>`);
293
+ }
294
+ const view = diagram.view ?? viewWithCallouts(bounds, callouts, Boolean(diagram.caption));
295
+ const [minX, minY, maxX, maxY] = view;
296
+ const width = Math.max(1, maxX - minX);
297
+ const height = Math.max(1, maxY - minY);
298
+ if (diagram.caption) {
299
+ const y = minY + 14;
300
+ parts.push(`<text x="${round((minX + maxX) / 2)}" y="${round(y)}" class="aigui-figure-caption" text-anchor="middle" transform="scale(1,-1)" transform-origin="${round((minX + maxX) / 2)} ${round(y)}">${escapeHtml(diagram.caption)}</text>`);
301
+ }
302
+ const title = diagram.title ? `<title>${escapeHtml(diagram.title)}</title>` : "";
303
+ return [
304
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${round(minX)} ${round(minY)} ${round(width)} ${round(height)}"`,
305
+ ` width="100%" style="max-width:${limits.width}px;max-height:${limits.height}px;display:block;margin:auto"`,
306
+ ` role="img" aria-label="${escapeHtml(diagram.title ?? diagram.caption ?? "Labelled figure")}" data-aigui-figure="diagram">`,
307
+ title,
308
+ `<g transform="translate(0, ${round(minY + maxY)}) scale(1, -1)">`,
309
+ parts.join(""),
310
+ "</g></svg>"
311
+ ].join("");
312
+ }
313
+ /** Widen the drawing box until every callout, and the caption, is inside it. */
314
+ function viewWithCallouts(bounds, callouts, hasCaption) {
315
+ const textRoom = 120;
316
+ const xs = [bounds[0], bounds[2]];
317
+ const ys = [bounds[1], bounds[3]];
318
+ for (const callout of callouts) {
319
+ xs.push(callout.side === "left" ? callout.at[0] - textRoom : callout.at[0] + textRoom);
320
+ ys.push(callout.at[1] - 20, callout.at[1] + 12);
321
+ }
322
+ const pad = 12;
323
+ return [
324
+ Math.min(...xs) - pad,
325
+ Math.min(...ys) - pad - (hasCaption ? 22 : 0),
326
+ Math.max(...xs) + pad,
327
+ Math.max(...ys) + pad
328
+ ];
329
+ }
330
+ function figurePromptSpec(options = {}) {
331
+ const limits = {
332
+ ...DEFAULTS,
333
+ ...options
334
+ };
335
+ return [
336
+ "Labelled figures (one fenced block): ```figure <strict JSON>```.",
337
+ "Root: {\"version\":1,\"title\":\"...\"?,\"caption\":\"...\"?,\"view\":[minX,minY,maxX,maxY]?,\"parts\":[...]}. No unknown fields.",
338
+ "Part: {\"shape\":\"ellipse|rect|polygon|point\"?,\"at\":[x,y],\"width\":n?,\"height\":n?,\"points\":[[x,y],...]?,\"rotation\":deg?,\"fill\":\"none|tint|solid\"?,\"label\":\"...\"?,\"note\":\"...\"?,\"labelAt\":[x,y]?}.",
339
+ "Give \"at\" for ellipse, rect and point; give \"points\" (three or more) for polygon. Default shape is ellipse, default fill is tint.",
340
+ "\"label\" is what the part is called and \"note\" is a shorter second line saying what it does. Omit \"labelAt\" and the labels are stacked down both sides with leader lines drawn for you.",
341
+ "Draw containers before the parts inside them, so an enclosing outline does not cover them.",
342
+ "y increases upwards, the same convention as ```physics.",
343
+ `Figure source is local JSON only, at most ${limits.maxSourceBytes} UTF-8 bytes. Never emit URLs, scripts, remote resources, HTML, or executable content.`,
344
+ "Use this when naming the parts is the lesson: cell organelles, a leaf's layers, apparatus, a labelled cross-section. Use ```mermaid for boxes joined by arrows and ```chart for data."
345
+ ].join("\n");
346
+ }
347
+ const figureCss = `
348
+ [data-aigui-figure] { max-width: 100%; height: auto; overflow: visible; color: currentColor; }
349
+ [data-aigui-figure] .aigui-figure-part { stroke: currentColor; stroke-width: 1.5; }
350
+ [data-aigui-figure] .aigui-figure-fill-none { fill: none; }
351
+ [data-aigui-figure] .aigui-figure-fill-tint { fill: var(--aigui-figure-tint, color-mix(in srgb, currentColor 10%, transparent)); }
352
+ [data-aigui-figure] .aigui-figure-fill-solid { fill: var(--aigui-figure-solid, color-mix(in srgb, currentColor 28%, transparent)); }
353
+ [data-aigui-figure] .aigui-figure-point { fill: currentColor; stroke: none; }
354
+ [data-aigui-figure] .aigui-figure-leader { fill: none; stroke: var(--aigui-figure-leader, currentColor); stroke-width: 1; opacity: .6; }
355
+ [data-aigui-figure] .aigui-figure-leader-dot { fill: var(--aigui-figure-leader, currentColor); opacity: .8; }
356
+ [data-aigui-figure] .aigui-figure-label { font-size: 13px; font-family: inherit; dominant-baseline: middle; fill: currentColor; }
357
+ [data-aigui-figure] .aigui-figure-note { font-size: 11px; font-family: inherit; dominant-baseline: middle; fill: currentColor; opacity: .7; }
358
+ [data-aigui-figure] .aigui-figure-caption { font-size: 12px; font-family: inherit; fill: currentColor; opacity: .75; }
359
+ `;
360
+ function errorHtml(issue) {
361
+ return {
362
+ kind: "html",
363
+ html: `<pre data-aigui-figure-error>${escapeHtml(issue)}</pre>`
364
+ };
365
+ }
366
+ function figure(options = {}) {
367
+ const render = (node) => {
368
+ const parsed = parseFigure(node.content ?? "", options);
369
+ if (!parsed.valid) return errorHtml(parsed.issues[0] ?? "Invalid figure.");
370
+ try {
371
+ return {
372
+ kind: "html",
373
+ html: renderFigureSVG(parsed.data, options),
374
+ trusted: true
375
+ };
376
+ } catch {
377
+ return errorHtml("Figure could not be drawn.");
378
+ }
379
+ };
380
+ return {
381
+ name: "figure",
382
+ nodeRenderers: { figure: render },
383
+ css: figureCss,
384
+ promptSpec: figurePromptSpec(options)
385
+ };
386
+ }
387
+
388
+ //#endregion
389
+ export { figure, figureCss, figurePromptSpec, parseFigure, renderFigureSVG };
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@ai-gui/plugin-figure",
3
+ "version": "0.11.0",
4
+ "description": "Labelled figures — regions with leader-line callouts — rendered as SVG from a declarative block.",
5
+ "keywords": [
6
+ "llm",
7
+ "ai",
8
+ "diagram",
9
+ "figure",
10
+ "labels",
11
+ "biology",
12
+ "anatomy",
13
+ "teaching",
14
+ "svg",
15
+ "plugin",
16
+ "aigui"
17
+ ],
18
+ "license": "MIT",
19
+ "author": "Liang Li <ll_faw@hotmail.com>",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/liliang-cn/aigui.git",
23
+ "directory": "packages/plugin-figure"
24
+ },
25
+ "homepage": "https://github.com/liliang-cn/aigui#readme",
26
+ "bugs": "https://github.com/liliang-cn/aigui/issues",
27
+ "type": "module",
28
+ "sideEffects": false,
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "main": "./dist/index.cjs",
33
+ "module": "./dist/index.js",
34
+ "types": "./dist/index.d.ts",
35
+ "exports": {
36
+ ".": {
37
+ "import": {
38
+ "types": "./dist/index.d.ts",
39
+ "default": "./dist/index.js"
40
+ },
41
+ "require": {
42
+ "types": "./dist/index.d.cts",
43
+ "default": "./dist/index.cjs"
44
+ }
45
+ }
46
+ },
47
+ "files": [
48
+ "dist",
49
+ "README.md",
50
+ "LICENSE",
51
+ "CHANGELOG.md"
52
+ ],
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "dependencies": {
57
+ "@ai-gui/core": "0.11.0"
58
+ },
59
+ "scripts": {
60
+ "build": "tsdown",
61
+ "test": "pnpm --dir ../.. exec vitest run --project plugin-figure",
62
+ "typecheck": "tsc --noEmit"
63
+ }
64
+ }