agentic-canvas 0.10.0 → 0.12.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/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  A shared whiteboard for diagrams, drawn by people and agents.
9
9
 
10
- > Status: 0.10.0. The board model, the agent operations, the exports, the CLI, the
10
+ > Status: 0.12.0. The board model, the agent operations, the exports, the CLI, the
11
11
  > MCP server, a small Worker API, and live co-editing work. The CLI, the MCP server,
12
12
  > and the SDK can join a live room. A remote client calls the Worker API. A small web UI, served by the Worker, is new in 0.4.0.
13
13
  > The package exports `agentic-canvas/ui`, a board view you can mount in your own app, new in 0.5.0.
@@ -111,6 +111,24 @@ There is one method per route: `me`, `listBoards`, `createBoard`, `readBoard`, `
111
111
  and status 0. The client never retries. A 503 means "try again", and the caller decides, because a
112
112
  repeated write can change a board twice. Pass `fetch` to use your own.
113
113
 
114
+ ## Run locally
115
+
116
+ ```sh
117
+ git clone https://github.com/pooriaarab/agentic-canvas
118
+ cd agentic-canvas
119
+ npm install && npm run dev
120
+ ```
121
+
122
+ You can also use `bun install && bun run dev`. The `dev` script calls npm, so npm must be installed. The scripts need macOS, Linux, or WSL. The `dev` script builds the package, then starts `wrangler dev` on <http://localhost:8793>. Wrangler keeps a local D1 database. The `dev` script turns off Wrangler telemetry. Set `WRANGLER_SEND_METRICS=false` when you run other wrangler commands.
123
+
124
+ ```sh
125
+ curl -i http://localhost:8793/health # 200
126
+ ```
127
+
128
+ The `dev` script sets `CANVAS_REGION=unknown`, which local Wrangler needs. The API answers 401 until you sign in. For local work, see the dev fallback in "Auth".
129
+
130
+ `npm run dev:smoke` runs the same check by itself. It starts the dev server. It waits for `/health` to answer 200. It stops the server. CI runs it as the "dev smoke" step. To use another port, change `8793` in the `dev` and `dev:smoke` scripts.
131
+
114
132
  ## Live demo
115
133
 
116
134
  A demo Worker runs on workers.dev: <https://agentic-canvas-demo.hello-fd1.workers.dev>. `GET /health` answers 200
@@ -240,7 +258,7 @@ What it does:
240
258
 
241
259
  - Lists boards, makes a board, and opens one. Empty, loading, and error states have a retry.
242
260
  - Draws shapes, connectors, text, and sticky notes. Select, move, resize, connect, and delete an
243
- item. Pan by dragging the background, zoom with the wheel or the toolbar.
261
+ item. Pan by dragging the background, zoom with the wheel or the zoom controls on the canvas.
244
262
  - Marks an item an agent wrote with an "Agent" tag and in its accessible name. The side panel shows
245
263
  who made the item and who last edited it.
246
264
  - Shows who is in the board, from the live room (`agentic-realtime`). Edits go over the HTTP API.
@@ -283,6 +301,39 @@ server still checks every write against the token. The host page owns the theme.
283
301
  For a React app, `BoardEditor` is the same component without a root. Wrap it in the providers of
284
302
  `agentic-design/react`.
285
303
 
304
+ ### Use your own Yjs document
305
+
306
+ An app that keeps the board in its own Yjs room gives it as `host`. The UI then reads and writes that document as a
307
+ board (its `elements` and `meta` maps, the shape this package stores) and redraws as soon as the document changes.
308
+ It makes no HTTP call, does not poll, and opens no live room. `client` is not needed. The default is still client
309
+ mode: without `host`, the UI works as described above.
310
+
311
+ ```ts
312
+ const board = mount(element, {
313
+ boardId: "b1",
314
+ principal: { kind: "human", name: "Ada", id: "ada", role: "editor" }, // id is stamped on what Ada writes; the default is name
315
+ host: {
316
+ doc, // a Y.Doc
317
+ presence: sync, // optional: a SyncClient of agentic-realtime, or { peers, onPeers }
318
+ status: () => status, // optional: "connecting" | "connected" | "disconnected"
319
+ onStatus: (listener) => stop, // optional: tell the UI when the status changes
320
+ },
321
+ });
322
+ ```
323
+
324
+ - Use the same `yjs` copy as the UI. A document from another copy is refused with a `TypeError`.
325
+ - Each element the UI creates or edits is stamped with the principal's `id` and `kind`. A delete has no stamp. The `id` follows the identity rule of the
326
+ SDK (letters, digits, `.`, `_`, `-`). An agent kind marks its elements as machine-made. A refused write shows a message
327
+ and leaves the document alone. The server does not check these writes: your app and its room decide who may write.
328
+ - In host mode `boardId` only keys the view. The UI shows the one board that the document holds (its `elements` and `meta` maps), so keep one board in each document.
329
+ - A document pushed past the element cap (10,000) can no longer be read. If that happens after the first draw, the UI keeps the last board it drew. At mount it shows the error page with Retry. Your app should stop a peer from writing that far.
330
+ - `client` or `room` together with `host` throws a `TypeError`, so a board never half-writes to a server. (`BoardEditor` shows an alert for these, as it does for a bad principal.) Give presence in `host.presence`.
331
+ - The principal's `id` (default: `name`) must pass the identity rule, so a display name with a space needs an explicit `id`. A bad one throws a `TypeError` at mount.
332
+ - The UI redraws only for changes in the document's `elements` and `meta` maps. Other data in the same document is left alone.
333
+ - `status` and `onStatus` are read when the UI opens `doc`. A new function on `update()` does not open it again. `onStatus` alone starts as not live, until it reports "connected". With neither, the board shows as live. Give `onStatus` at mount to follow later changes. It is not picked up by a later `update()` for the same `doc`.
334
+ - With `presence`, the UI shows the peers. It never sends a cursor, and never closes your room or your document.
335
+ - On `unmount` the UI removes its observers from your document and leaves it as it is.
336
+
286
337
  Develop and test the UI:
287
338
 
288
339
  ```sh
@@ -291,7 +342,7 @@ npx playwright-core install chromium
291
342
  npm run test:e2e # Playwright against `wrangler dev`, with axe (WCAG 2.2 AA)
292
343
  ```
293
344
 
294
- The E2E run creates a board, adds a shape and a note, moves them, reloads, and checks each view
345
+ `E2E_PORT` sets the port of the E2E server (default 18900). The E2E run creates a board, adds a shape and a note, moves them, reloads, and checks each view
295
346
  with axe in the light and the dark theme. It saves screenshots to `test-results/screens/`. The committed set in `docs/ui/` shows the UI at this release. Run `UPDATE_SCREENSHOTS=1 npm run test:e2e` to refresh it.
296
347
 
297
348
  A browser cannot set a header on a WebSocket, so the UI sends its token to the live room as a WebSocket sub-protocol (`Sec-WebSocket-Protocol`). A proxy that logs request headers can record it. The API itself takes the token only in an `Authorization` header, and the sub-protocol only on a WebSocket upgrade.
@@ -408,6 +459,7 @@ old author stays on its elements. Only new writes follow the strict rule.
408
459
  | `mount(element, options)` | Render the board UI into an element. It returns `update(options)` and `unmount()`. See Mount the board in your app. |
409
460
  | `BoardEditor(options)` | The same board UI as a React component, without a root. |
410
461
  | `BoardEditorOptions` (type), `BoardPrincipal` (type), `MountedBoard` (type) | Types for the options, the principal, and the object that `mount` returns. |
462
+ | `HostBoard` (type) | The `host` option: `doc`, and optionally `presence`, `status`, and `onStatus`. |
411
463
  | `CanvasClient` (type), `RoomOptions` (type) | The client type, as in `agentic-canvas/client`, and the live room's base URL and token for the `room` option. |
412
464
 
413
465
  The stylesheet is a separate export, `agentic-canvas/ui.css`. Load `agentic-design/styles.css` too, for the tokens.
@@ -3,10 +3,10 @@ import {
3
3
  MAX_LIVE_BYTES,
4
4
  isBoardId,
5
5
  isStoreId
6
- } from "./chunk-M74ENUWR.js";
6
+ } from "./chunk-Z6KLGAFP.js";
7
7
  import {
8
8
  CanvasError
9
- } from "./chunk-ZSPZMGHG.js";
9
+ } from "./chunk-MVWUIBGV.js";
10
10
 
11
11
  // src/worker.ts
12
12
  import { createStoreAuth, hasScope as hasScope2, parsePrincipal, parseRegion } from "agentic-identity";
@@ -1,14 +1,12 @@
1
1
  import {
2
2
  CanvasError,
3
+ PRINCIPAL_KINDS,
3
4
  SHAPE_TYPES
4
- } from "./chunk-ZSPZMGHG.js";
5
- import {
6
- PRINCIPAL_KINDS
7
- } from "./chunk-JOBTIAFJ.js";
5
+ } from "./chunk-MVWUIBGV.js";
8
6
 
9
7
  // src/version.ts
10
8
  import { createRequire } from "module";
11
- var VERSION = true ? "0.10.0" : readPackageVersion();
9
+ var VERSION = true ? "0.12.0" : readPackageVersion();
12
10
 
13
11
  // src/brand.ts
14
12
  var BRAND = {
@@ -0,0 +1,290 @@
1
+ import {
2
+ COLOR_PATTERN,
3
+ CanvasError,
4
+ IMAGE_PATTERN,
5
+ MAX_ELEMENTS,
6
+ SHAPE_TYPES
7
+ } from "./chunk-MVWUIBGV.js";
8
+
9
+ // src/core/flow.ts
10
+ import dagre from "@dagrejs/dagre";
11
+ import * as Y from "yjs";
12
+ var MAX_FLOW_NODES = 500;
13
+ var MAX_FLOW_EDGES = 2e3;
14
+ var MAX_FLOW_CHARS = 2e5;
15
+ var NODE = String.raw`([A-Za-z0-9_-]{1,48})(?:\[([^\]]*)\]|\(([^)]*)\)|\{([^}]*)\})?`;
16
+ var EDGE = new RegExp(String.raw`^${NODE}\s*->\s*${NODE}\s*(?::(.*))?$`);
17
+ var ALONE = new RegExp(String.raw`^${NODE}$`);
18
+ var SHAPES = ["rect", "ellipse", "diamond"];
19
+ function parseFlow(source) {
20
+ if (source.length > MAX_FLOW_CHARS) throw new CanvasError("LIMIT", `flow text is over ${MAX_FLOW_CHARS} characters`);
21
+ const graph = { direction: "TB", nodes: [], edges: [] };
22
+ const byId = /* @__PURE__ */ new Map();
23
+ const fail = (line, message) => {
24
+ throw new CanvasError("INVALID", `line ${line}: ${message}`);
25
+ };
26
+ const node = (line, m, at) => {
27
+ const id = m[at];
28
+ if (/^edge_\d+$/.test(id)) fail(line, `"${id}" is reserved for connectors. Pick another node id.`);
29
+ const brackets = m.slice(at + 1, at + 4);
30
+ const open = brackets.findIndex((b) => b !== void 0);
31
+ const shape = SHAPES[Math.max(open, 0)];
32
+ const text = (open < 0 ? "" : brackets[open].trim()) || id;
33
+ const known = byId.get(id);
34
+ if (!known) {
35
+ const created = { id, text, shape, explicit: open >= 0 };
36
+ byId.set(id, created);
37
+ graph.nodes.push(created);
38
+ } else if (open >= 0) {
39
+ if (known.explicit && (known.text !== text || known.shape !== shape)) fail(line, `"${id}" is defined twice with different text`);
40
+ Object.assign(known, { text, shape, explicit: true });
41
+ }
42
+ return id;
43
+ };
44
+ source.replace(/^/, "").split(/\r?\n/).forEach((raw, index) => {
45
+ const line = index + 1;
46
+ const text = raw.trim();
47
+ if (text === "" || text.startsWith("#") || text.startsWith("//")) return;
48
+ if (/[\u0000-\u0008\u000a-\u001f\u007f]/.test(text)) fail(line, "control characters are not allowed");
49
+ const direction = /^direction\s+(\S+)$/.exec(text);
50
+ if (direction) {
51
+ if (direction[1] !== "TB" && direction[1] !== "LR") fail(line, `direction must be TB or LR, got "${direction[1]}"`);
52
+ graph.direction = direction[1];
53
+ return;
54
+ }
55
+ const edge = EDGE.exec(text);
56
+ if (edge) {
57
+ const from = node(line, edge, 1);
58
+ const to = node(line, edge, 5);
59
+ graph.edges.push({ from, to, text: (edge[9] ?? "").trim() });
60
+ } else {
61
+ const alone = ALONE.exec(text);
62
+ if (!alone) fail(line, `cannot read "${text.slice(0, 40)}". Use "A -> B: label".`);
63
+ else node(line, alone, 1);
64
+ }
65
+ if (graph.nodes.length > MAX_FLOW_NODES) throw new CanvasError("LIMIT", `a flow holds at most ${MAX_FLOW_NODES} nodes`);
66
+ if (graph.edges.length > MAX_FLOW_EDGES) throw new CanvasError("LIMIT", `a flow holds at most ${MAX_FLOW_EDGES} edges`);
67
+ });
68
+ if (graph.nodes.length === 0) throw new CanvasError("INVALID", "the flow has no nodes");
69
+ return { ...graph, nodes: graph.nodes.map(({ id, text, shape }) => ({ id, text, shape })) };
70
+ }
71
+ function sizeOf(n) {
72
+ const w = Math.min(360, Math.max(120, 24 + 8 * n.text.length));
73
+ return n.shape === "diamond" ? { w: Math.min(360, Math.round(w * 1.5)), h: 90 } : { w, h: 60 };
74
+ }
75
+ function layoutFlow(graph) {
76
+ const g = new dagre.graphlib.Graph({ multigraph: true });
77
+ g.setGraph({ rankdir: graph.direction, nodesep: 40, ranksep: 70, marginx: 0, marginy: 0 });
78
+ g.setDefaultEdgeLabel(() => ({}));
79
+ const index = new Map(graph.nodes.map((n, i) => [n.id, `n${i}`]));
80
+ const sizes = graph.nodes.map(sizeOf);
81
+ sizes.forEach(({ w, h }, i) => g.setNode(`n${i}`, { width: w, height: h }));
82
+ graph.edges.forEach((e, i) => g.setEdge(index.get(e.from), index.get(e.to), {}, String(i)));
83
+ dagre.layout(g);
84
+ const boxes = graph.nodes.map((n, i) => {
85
+ const { w, h } = sizes[i];
86
+ const c = g.node(`n${i}`);
87
+ return { id: n.id, x: Math.round(c.x - w / 2), y: Math.round(c.y - h / 2), w, h };
88
+ });
89
+ const minX = Math.min(...boxes.map((b) => b.x));
90
+ const minY = Math.min(...boxes.map((b) => b.y));
91
+ return boxes.map((b) => ({ ...b, x: b.x - minX, y: b.y - minY }));
92
+ }
93
+ function drawFlow(board, source, actor, options = {}) {
94
+ const prefix = options.idPrefix ?? `${crypto.randomUUID().slice(0, 8)}-`;
95
+ if (!/^[A-Za-z0-9_-]{0,16}$/.test(prefix)) throw new CanvasError("INVALID", "idPrefix must be up to 16 of A-Z, a-z, 0-9, _ and -");
96
+ const graph = parseFlow(source);
97
+ const placed = layoutFlow(graph);
98
+ const shapes = board.list().flatMap((e) => e.type === "connector" || e.type === "group" ? [] : [e]);
99
+ const origin = options.origin ?? {
100
+ x: shapes.length ? Math.min(...shapes.map((s) => s.x)) : 0,
101
+ y: shapes.length ? Math.max(...shapes.map((s) => s.y + s.h)) + 80 : 0
102
+ };
103
+ const work = board.clone();
104
+ const text = new Map(graph.nodes.map((n) => [n.id, n]));
105
+ for (const p of placed) {
106
+ const n = text.get(p.id);
107
+ work.addShape({ id: prefix + p.id, type: n.shape, text: n.text, x: p.x + origin.x, y: p.y + origin.y, w: p.w, h: p.h }, actor);
108
+ }
109
+ const edges = graph.edges.map((e, i) => {
110
+ const id = `${prefix}edge_${i + 1}`;
111
+ work.addConnector({ id, from: { id: prefix + e.from }, to: { id: prefix + e.to }, text: e.text }, actor);
112
+ return id;
113
+ });
114
+ Y.applyUpdate(board.doc, Y.encodeStateAsUpdate(work.doc, Y.encodeStateVector(board.doc)));
115
+ return { nodes: placed.map((p) => prefix + p.id), edges };
116
+ }
117
+
118
+ // src/core/svg.ts
119
+ var LINE = 17;
120
+ var num = (n) => String(Math.round(n * 100) / 100);
121
+ var esc = (value) => (typeof value === "string" ? value : "").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f￾￿]|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g, "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
122
+ var paint = (value, fallback) => typeof value === "string" && COLOR_PATTERN.test(value) ? value : fallback;
123
+ var str = (value) => typeof value === "string" ? value : "";
124
+ var within = (n, low, high) => typeof n === "number" && Number.isFinite(n) && n >= low && n <= high;
125
+ var LIMIT_COORD = 1e7;
126
+ var TYPES = /* @__PURE__ */ new Set([...SHAPE_TYPES, "connector", "group"]);
127
+ var sane = (s) => within(s.x, -LIMIT_COORD, LIMIT_COORD) && within(s.y, -LIMIT_COORD, LIMIT_COORD) && within(s.w, 1e-6, LIMIT_COORD) && within(s.h, 1e-6, LIMIT_COORD);
128
+ var isShape = (v) => "w" in v;
129
+ var center = (s) => ({ x: s.x + s.w / 2, y: s.y + s.h / 2 });
130
+ function edgePoint(s, toward) {
131
+ const c = center(s);
132
+ const dx = toward.x - c.x;
133
+ const dy = toward.y - c.y;
134
+ if (dx === 0 && dy === 0) return c;
135
+ const a = s.w / 2;
136
+ const b = s.h / 2;
137
+ const k = s.type === "ellipse" ? 1 / Math.hypot(dx / a, dy / b) : s.type === "diamond" ? 1 / (Math.abs(dx) / a + Math.abs(dy) / b) : Math.min(a / Math.abs(dx), b / Math.abs(dy));
138
+ return { x: c.x + dx * k, y: c.y + dy * k };
139
+ }
140
+ function wrap(text, width) {
141
+ const max = Math.max(4, Math.floor((width - 16) / 7.5));
142
+ const out = [];
143
+ for (const paragraph of text.split(/\r?\n/)) {
144
+ let line = "";
145
+ for (const word of paragraph.split(/\s+/).filter(Boolean)) {
146
+ const chars = [...word];
147
+ while (chars.length > max) {
148
+ if (line) out.push(line);
149
+ line = "";
150
+ out.push(chars.splice(0, max).join(""));
151
+ }
152
+ const rest = chars.join("");
153
+ if (line && [...line].length + 1 + chars.length > max) {
154
+ out.push(line);
155
+ line = rest;
156
+ } else line = line ? `${line} ${rest}` : rest;
157
+ }
158
+ out.push(line);
159
+ }
160
+ return out.slice(0, 40);
161
+ }
162
+ function label(text, cx, cy, width, extra = "") {
163
+ if (text === "") return "";
164
+ const lines = wrap(text, width);
165
+ const top = cy - (lines.length - 1) * LINE / 2;
166
+ const spans = lines.map((l, i) => `<tspan x="${num(cx)}" y="${num(top + i * LINE)}">${esc(l)}</tspan>`);
167
+ return `<text text-anchor="middle" dominant-baseline="central"${extra}>${spans.join("")}</text>`;
168
+ }
169
+ function shapeBody(s) {
170
+ const { x, y, w, h } = s;
171
+ const stroke = paint(s.stroke, "#1f2937");
172
+ const look = (fill, more = "") => `fill="${paint(s.fill, fill)}" stroke="${stroke}" stroke-width="2"${more}`;
173
+ const text = label(str(s.text), x + w / 2, y + h / 2, w);
174
+ switch (s.type) {
175
+ case "ellipse":
176
+ return `<ellipse cx="${num(x + w / 2)}" cy="${num(y + h / 2)}" rx="${num(w / 2)}" ry="${num(h / 2)}" ${look("#ffffff")}/>${text}`;
177
+ case "diamond": {
178
+ const pts = [[x + w / 2, y], [x + w, y + h / 2], [x + w / 2, y + h], [x, y + h / 2]];
179
+ return `<polygon points="${pts.map(([px, py]) => `${num(px)},${num(py)}`).join(" ")}" ${look("#ffffff")}/>${text}`;
180
+ }
181
+ case "text":
182
+ return text;
183
+ case "frame":
184
+ return `<rect x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}" ${look("none", ' stroke-dasharray="8 6"')}/>${label(str(s.text), x + w / 2, y - 12, w)}`;
185
+ case "image":
186
+ if (typeof s.src === "string" && s.src.startsWith("data:") && IMAGE_PATTERN.test(s.src)) {
187
+ return `<image href="${s.src}" x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}" preserveAspectRatio="xMidYMid meet"/>`;
188
+ }
189
+ return `<rect x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}" ${look("#f3f4f6", ' stroke-dasharray="4 4"')}/>${label("image", x + w / 2, y + h / 2, w)}`;
190
+ default:
191
+ return `<rect x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}" rx="${s.type === "sticky" ? 2 : 6}" ${look(s.type === "sticky" ? "#fde68a" : "#ffffff")}/>${text}`;
192
+ }
193
+ }
194
+ var endKey = (e) => "id" in e ? e.id : `${e.x},${e.y}`;
195
+ var wellFormed = (e) => typeof e === "object" && e !== null && (typeof e.id === "string" || within(e.x, -LIMIT_COORD, LIMIT_COORD) && within(e.y, -LIMIT_COORD, LIMIT_COORD));
196
+ function connectorBody(c, shapes, lane) {
197
+ const resolve = (e) => "id" in e ? shapes.get(e.id) : e;
198
+ const a = resolve(c.from);
199
+ const b = resolve(c.to);
200
+ if (!a || !b) return "";
201
+ let d;
202
+ let at;
203
+ if (isShape(a) && a === b) {
204
+ const x0 = a.x + a.w * 0.35;
205
+ const x1 = a.x + a.w * 0.65;
206
+ const lift = 45 + 32 * lane.index;
207
+ d = `M${num(x0)} ${num(a.y)}C${num(x0)} ${num(a.y - lift)} ${num(x1)} ${num(a.y - lift)} ${num(x1)} ${num(a.y)}`;
208
+ at = { x: a.x + a.w / 2, y: a.y - lift * 0.75 - 6 };
209
+ } else {
210
+ const ca = isShape(a) ? center(a) : a;
211
+ const cb = isShape(b) ? center(b) : b;
212
+ const flip = endKey(c.from) > endKey(c.to);
213
+ const dx = (flip ? ca.x - cb.x : cb.x - ca.x) || 0;
214
+ const dy = (flip ? ca.y - cb.y : cb.y - ca.y) || 0;
215
+ const len = Math.hypot(dx, dy) || 1;
216
+ const offset = (lane.index - (lane.count - 1) / 2) * 44;
217
+ const mid = { x: (ca.x + cb.x) / 2, y: (ca.y + cb.y) / 2 };
218
+ const ctrl = { x: mid.x - dy / len * 2 * offset, y: mid.y + dx / len * 2 * offset };
219
+ const p = isShape(a) ? edgePoint(a, ctrl) : a;
220
+ const q = isShape(b) ? edgePoint(b, ctrl) : b;
221
+ d = offset === 0 ? `M${num(p.x)} ${num(p.y)}L${num(q.x)} ${num(q.y)}` : `M${num(p.x)} ${num(p.y)}Q${num(ctrl.x)} ${num(ctrl.y)} ${num(q.x)} ${num(q.y)}`;
222
+ at = offset === 0 ? { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 } : { x: (p.x + q.x) / 4 + ctrl.x / 2, y: (p.y + q.y) / 4 + ctrl.y / 2 };
223
+ }
224
+ const marks = `${c.startArrow ? ' marker-start="url(#arrow)"' : ""}${c.endArrow ? ' marker-end="url(#arrow)"' : ""}`;
225
+ const halo = ' stroke="#ffffff" stroke-width="4" paint-order="stroke"';
226
+ return `<path d="${d}" fill="none" stroke="${paint(c.stroke, "#1f2937")}" stroke-width="2"${marks}/>${label(str(c.text), at.x, at.y, 160, halo)}`;
227
+ }
228
+ var drawable = (el) => TYPES.has(el.type) && (el.type === "connector" ? wellFormed(el.from) && wellFormed(el.to) : el.type === "group" || sane(el));
229
+ function toSvg(board) {
230
+ const listed = board.list();
231
+ if (listed.length > MAX_ELEMENTS) throw new CanvasError("LIMIT", `a board holds at most ${MAX_ELEMENTS} elements`);
232
+ const all = listed.filter((el) => drawable(el));
233
+ const shapes = /* @__PURE__ */ new Map();
234
+ const box = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
235
+ const grow = (x, y) => {
236
+ box.minX = Math.min(box.minX, x);
237
+ box.minY = Math.min(box.minY, y);
238
+ box.maxX = Math.max(box.maxX, x);
239
+ box.maxY = Math.max(box.maxY, y);
240
+ };
241
+ for (const el of all) {
242
+ if (el.type === "connector") {
243
+ for (const e of [el.from, el.to]) if (!("id" in e)) grow(e.x, e.y);
244
+ } else if (el.type !== "group") {
245
+ shapes.set(el.id, el);
246
+ grow(el.x, el.y);
247
+ grow(el.x + el.w, el.y + el.h);
248
+ }
249
+ }
250
+ const pad = 80;
251
+ const any = box.minX !== Infinity;
252
+ const minX = any ? box.minX - pad : 0;
253
+ const minY = any ? box.minY - pad : 0;
254
+ const width = any ? Math.ceil(box.maxX + pad - minX) : 240;
255
+ const height = any ? Math.ceil(box.maxY + pad - minY) : 120;
256
+ const lanes = /* @__PURE__ */ new Map();
257
+ const groups = /* @__PURE__ */ new Map();
258
+ for (const el of all) {
259
+ if (el.type !== "connector") continue;
260
+ const key = [endKey(el.from), endKey(el.to)].sort().join("|");
261
+ groups.set(key, [...groups.get(key) ?? [], el.id]);
262
+ }
263
+ for (const ids of groups.values()) ids.forEach((id, index) => lanes.set(id, { index, count: ids.length }));
264
+ const body = all.map((el) => {
265
+ if (el.type === "group") return "";
266
+ const inner = el.type === "connector" ? connectorBody(el, shapes, lanes.get(el.id)) : shapeBody(el);
267
+ if (inner === "") return "";
268
+ const kind = el.author.kind;
269
+ const mark = `data-id="${esc(el.id)}" data-type="${el.type}" data-author-kind="${kind}" data-machine-made="${kind !== "human"}"`;
270
+ return `<g ${mark}>${inner}</g>`;
271
+ });
272
+ const title = esc(board.title) || "Whiteboard";
273
+ return [
274
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="${num(minX)} ${num(minY)} ${width} ${height}" font-family="sans-serif" font-size="14" fill="#111827" role="img" aria-label="${title}">`,
275
+ `<title>${title}</title>`,
276
+ '<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="#1f2937"/></marker></defs>',
277
+ `<rect x="${num(minX)}" y="${num(minY)}" width="${width}" height="${height}" fill="#ffffff"/>`,
278
+ ...body.filter(Boolean),
279
+ "</svg>"
280
+ ].join("\n");
281
+ }
282
+
283
+ export {
284
+ MAX_FLOW_NODES,
285
+ MAX_FLOW_EDGES,
286
+ parseFlow,
287
+ layoutFlow,
288
+ drawFlow,
289
+ toSvg
290
+ };