sketchmark 0.1.1

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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +748 -0
  3. package/dist/animation/index.d.ts +56 -0
  4. package/dist/animation/index.d.ts.map +1 -0
  5. package/dist/ast/index.d.ts +2 -0
  6. package/dist/ast/index.d.ts.map +1 -0
  7. package/dist/ast/types.d.ts +159 -0
  8. package/dist/ast/types.d.ts.map +1 -0
  9. package/dist/export/index.d.ts +21 -0
  10. package/dist/export/index.d.ts.map +1 -0
  11. package/dist/index.cjs +4706 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.d.ts +51 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +4669 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/layout/index.d.ts +4 -0
  18. package/dist/layout/index.d.ts.map +1 -0
  19. package/dist/parser/index.d.ts +9 -0
  20. package/dist/parser/index.d.ts.map +1 -0
  21. package/dist/parser/tokenizer.d.ts +10 -0
  22. package/dist/parser/tokenizer.d.ts.map +1 -0
  23. package/dist/renderer/canvas/index.d.ts +14 -0
  24. package/dist/renderer/canvas/index.d.ts.map +1 -0
  25. package/dist/renderer/canvas/roughChartCanvas.d.ts +16 -0
  26. package/dist/renderer/canvas/roughChartCanvas.d.ts.map +1 -0
  27. package/dist/renderer/roughChart.d.ts +57 -0
  28. package/dist/renderer/roughChart.d.ts.map +1 -0
  29. package/dist/renderer/svg/index.d.ts +13 -0
  30. package/dist/renderer/svg/index.d.ts.map +1 -0
  31. package/dist/renderer/svg/roughChartSVG.d.ts +12 -0
  32. package/dist/renderer/svg/roughChartSVG.d.ts.map +1 -0
  33. package/dist/scene/index.d.ts +118 -0
  34. package/dist/scene/index.d.ts.map +1 -0
  35. package/dist/sketchmark.iife.js +4710 -0
  36. package/dist/theme/index.d.ts +35 -0
  37. package/dist/theme/index.d.ts.map +1 -0
  38. package/dist/utils/index.d.ts +14 -0
  39. package/dist/utils/index.d.ts.map +1 -0
  40. package/package.json +66 -0
package/dist/index.js ADDED
@@ -0,0 +1,4669 @@
1
+ // ============================================================
2
+ // sketchmark — Tokenizer
3
+ // ============================================================
4
+ const KEYWORDS = new Set([
5
+ "diagram",
6
+ "end",
7
+ "direction",
8
+ "layout",
9
+ "title",
10
+ "description",
11
+ "box",
12
+ "circle",
13
+ "diamond",
14
+ "hexagon",
15
+ "triangle",
16
+ "cylinder",
17
+ "parallelogram",
18
+ "text",
19
+ "image",
20
+ "group",
21
+ "style",
22
+ "step",
23
+ "config",
24
+ "theme",
25
+ "bar-chart",
26
+ "line-chart",
27
+ "pie-chart",
28
+ "donut-chart",
29
+ "scatter-chart",
30
+ "area-chart",
31
+ "table",
32
+ "align",
33
+ "valign",
34
+ "gap",
35
+ "padding",
36
+ "margin",
37
+ "highlight",
38
+ "fade",
39
+ "unfade",
40
+ "draw",
41
+ "erase",
42
+ "show",
43
+ "hide",
44
+ "pulse",
45
+ "move",
46
+ "scale",
47
+ "rotate",
48
+ "color",
49
+ "after-previous",
50
+ "with-previous",
51
+ "on-click",
52
+ "auto",
53
+ "LR",
54
+ "TB",
55
+ "RL",
56
+ "BT",
57
+ "row",
58
+ "column",
59
+ "grid",
60
+ "layer",
61
+ "dag",
62
+ "tree",
63
+ "force",
64
+ ]);
65
+ const ARROW_PATTERNS = ["<-->", "<->", "-->", "<--", "->", "<-", "---", "--"];
66
+ // Characters that can start an arrow pattern — used to decide whether a '-'
67
+ // inside an identifier is part of a kebab-case name or the start of an arrow.
68
+ const ARROW_START_AFTER_DASH = new Set([">", "-", "."]);
69
+ function tokenize(src) {
70
+ const tokens = [];
71
+ let i = 0, line = 1, lineStart = 0;
72
+ const col = () => i - lineStart + 1;
73
+ const peek = (offset = 0) => src[i + offset] ?? "";
74
+ const add = (type, value) => tokens.push({ type, value, line, col: col() - value.length });
75
+ while (i < src.length) {
76
+ const ch = src[i];
77
+ // Skip comments
78
+ if (ch === "#" || (ch === "/" && peek(1) === "/")) {
79
+ while (i < src.length && src[i] !== "\n")
80
+ i++;
81
+ continue;
82
+ }
83
+ // Newlines
84
+ if (ch === "\n") {
85
+ add("NEWLINE", "\n");
86
+ line++;
87
+ lineStart = i + 1;
88
+ i++;
89
+ continue;
90
+ }
91
+ // Carriage return
92
+ if (ch === "\r") {
93
+ i++;
94
+ continue;
95
+ }
96
+ // Whitespace (not newline)
97
+ if (/[ \t]/.test(ch)) {
98
+ i++;
99
+ continue;
100
+ }
101
+ // Strings
102
+ if (ch === '"' || ch === "'") {
103
+ const q = ch;
104
+ let val = "";
105
+ i++;
106
+ while (i < src.length && src[i] !== q) {
107
+ if (src[i] === "\\") {
108
+ i++;
109
+ const esc = src[i] ?? "";
110
+ if (esc === "n")
111
+ val += "\n";
112
+ else if (esc === "t")
113
+ val += "\t";
114
+ else if (esc === "\\")
115
+ val += "\\";
116
+ else
117
+ val += esc;
118
+ }
119
+ else
120
+ val += src[i];
121
+ i++;
122
+ }
123
+ i++; // closing quote
124
+ add("STRING", val);
125
+ continue;
126
+ }
127
+ // Numbers
128
+ if (/[0-9]/.test(ch) || (ch === "-" && /[0-9]/.test(peek(1)))) {
129
+ let num = "";
130
+ if (ch === "-") {
131
+ num = "-";
132
+ i++;
133
+ }
134
+ while (i < src.length && /[0-9.]/.test(src[i]))
135
+ num += src[i++];
136
+ add("NUMBER", num);
137
+ continue;
138
+ }
139
+ // Arrows — check longest match first
140
+ let matchedArrow = "";
141
+ for (const pat of ARROW_PATTERNS) {
142
+ if (src.startsWith(pat, i)) {
143
+ matchedArrow = pat;
144
+ break;
145
+ }
146
+ }
147
+ if (matchedArrow) {
148
+ add("ARROW", matchedArrow);
149
+ i += matchedArrow.length;
150
+ continue;
151
+ }
152
+ // Brackets/punctuation
153
+ const punct = {
154
+ "{": "LBRACE",
155
+ "}": "RBRACE",
156
+ "[": "LBRACKET",
157
+ "]": "RBRACKET",
158
+ "(": "LPAREN",
159
+ ")": "RPAREN",
160
+ "=": "EQUALS",
161
+ ",": "COMMA",
162
+ ":": "COLON",
163
+ ".": "DOT",
164
+ "*": "STAR",
165
+ };
166
+ if (ch in punct) {
167
+ add(punct[ch], ch);
168
+ i++;
169
+ continue;
170
+ }
171
+ // Identifiers & keywords
172
+ // Hyphens are allowed inside identifiers for kebab-case (e.g. "api-gateway"),
173
+ // BUT a hyphen that starts an arrow pattern ("->" / "-->" / "---") must NOT
174
+ // be consumed as part of the identifier — it belongs to the next token.
175
+ if (/[a-zA-Z_]/.test(ch)) {
176
+ let id = "";
177
+ while (i < src.length && /[a-zA-Z0-9_-]/.test(src[i])) {
178
+ // Stop before consuming a '-' that begins an arrow pattern.
179
+ // e.g. in "client->gateway": stop at '-' so '->' is lexed as ARROW.
180
+ if (src[i] === "-" && ARROW_START_AFTER_DASH.has(src[i + 1] ?? ""))
181
+ break;
182
+ id += src[i++];
183
+ }
184
+ add(KEYWORDS.has(id) ? "KEYWORD" : "IDENT", id);
185
+ continue;
186
+ }
187
+ add("UNKNOWN", ch);
188
+ i++;
189
+ }
190
+ add("EOF", "");
191
+ return tokens;
192
+ }
193
+
194
+ // ============================================================
195
+ // sketchmark — Parser (Tokens → DiagramAST)
196
+ // ============================================================
197
+ let _uid = 0;
198
+ function uid(prefix) {
199
+ return `${prefix}_${++_uid}`;
200
+ }
201
+ function resetUid() {
202
+ _uid = 0;
203
+ }
204
+ const SHAPES = [
205
+ "box",
206
+ "circle",
207
+ "diamond",
208
+ "hexagon",
209
+ "triangle",
210
+ "cylinder",
211
+ "parallelogram",
212
+ "text",
213
+ "image",
214
+ ];
215
+ const CHART_TYPES = [
216
+ "bar-chart",
217
+ "line-chart",
218
+ "pie-chart",
219
+ "donut-chart",
220
+ "scatter-chart",
221
+ "area-chart",
222
+ ];
223
+ class ParseError extends Error {
224
+ constructor(msg, line, col) {
225
+ super(`[ParseError L${line}:${col}] ${msg}`);
226
+ this.line = line;
227
+ this.col = col;
228
+ this.name = "ParseError";
229
+ }
230
+ }
231
+ function propsToStyle(p) {
232
+ const s = {};
233
+ if (p.fill)
234
+ s.fill = p.fill;
235
+ if (p.stroke)
236
+ s.stroke = p.stroke;
237
+ if (p["stroke-width"])
238
+ s.strokeWidth = parseFloat(p["stroke-width"]);
239
+ if (p.color)
240
+ s.color = p.color;
241
+ if (p.opacity)
242
+ s.opacity = parseFloat(p.opacity);
243
+ if (p.radius)
244
+ s.radius = parseFloat(p.radius);
245
+ if (p.shadow)
246
+ s.shadow = p.shadow === "true";
247
+ if (p["font-size"])
248
+ s.fontSize = parseFloat(p["font-size"]);
249
+ if (p["font-weight"])
250
+ s.fontWeight = p["font-weight"];
251
+ if (p["dash"]) {
252
+ const parts = p["dash"]
253
+ .split(",")
254
+ .map(Number)
255
+ .filter((n) => !isNaN(n));
256
+ if (parts.length)
257
+ s.strokeDash = parts;
258
+ }
259
+ return s;
260
+ }
261
+ function parse(src) {
262
+ resetUid();
263
+ const tokens = tokenize(src).filter((t) => t.type !== "NEWLINE" || t.value === "\n");
264
+ // Collapse multiple consecutive NEWLINEs into one
265
+ const flat = [];
266
+ let lastNL = false;
267
+ for (const t of tokens) {
268
+ if (t.type === "NEWLINE") {
269
+ if (!lastNL)
270
+ flat.push(t);
271
+ lastNL = true;
272
+ }
273
+ else {
274
+ flat.push(t);
275
+ lastNL = false;
276
+ }
277
+ }
278
+ const ast = {
279
+ kind: "diagram",
280
+ layout: "column",
281
+ nodes: [],
282
+ edges: [],
283
+ groups: [],
284
+ steps: [],
285
+ notes: [],
286
+ charts: [],
287
+ tables: [],
288
+ styles: {},
289
+ themes: {},
290
+ config: {},
291
+ rootOrder: [],
292
+ };
293
+ const nodeIds = new Set();
294
+ const tableIds = new Set();
295
+ const noteIds = new Set();
296
+ const chartIds = new Set();
297
+ const groupIds = new Set();
298
+ let i = 0;
299
+ const cur = () => flat[i] ?? flat[flat.length - 1];
300
+ const peek1 = () => flat[i + 1] ?? flat[flat.length - 1];
301
+ const skip = () => i++;
302
+ const skipNL = () => {
303
+ while (cur().type === "NEWLINE")
304
+ skip();
305
+ };
306
+ // Consume until EOL, return all tokens
307
+ function lineTokens() {
308
+ const acc = [];
309
+ while (cur().type !== "NEWLINE" && cur().type !== "EOF") {
310
+ acc.push(cur());
311
+ skip();
312
+ }
313
+ if (cur().type === "NEWLINE")
314
+ skip();
315
+ return acc;
316
+ }
317
+ function parseDataArray() {
318
+ const rows = [];
319
+ while (cur().type !== "LBRACKET" && cur().type !== "EOF")
320
+ skip();
321
+ skip(); // outer [
322
+ skipNL();
323
+ while (cur().type !== "RBRACKET" && cur().type !== "EOF") {
324
+ skipNL();
325
+ if (cur().type === "RBRACKET" || cur().type === "EOF")
326
+ break; // ← ADD THIS LINE
327
+ if (cur().type === "LBRACKET") {
328
+ skip();
329
+ const row = [];
330
+ while (cur().type !== "RBRACKET" && cur().type !== "EOF") {
331
+ const v = cur();
332
+ if (v.type === "STRING" ||
333
+ v.type === "IDENT" ||
334
+ v.type === "KEYWORD") {
335
+ row.push(v.value);
336
+ skip();
337
+ }
338
+ else if (v.type === "NUMBER") {
339
+ row.push(parseFloat(v.value));
340
+ skip();
341
+ }
342
+ else if (v.type === "COMMA" || v.type === "NEWLINE") {
343
+ skip();
344
+ }
345
+ else
346
+ break;
347
+ }
348
+ if (cur().type === "RBRACKET")
349
+ skip();
350
+ rows.push(row);
351
+ }
352
+ else if (cur().type === "COMMA" || cur().type === "NEWLINE") {
353
+ skip();
354
+ }
355
+ else
356
+ skip();
357
+ }
358
+ if (cur().type === "RBRACKET")
359
+ skip();
360
+ return rows;
361
+ }
362
+ function parseNode(shape, groupId) {
363
+ skip(); // shape keyword
364
+ const toks = lineTokens();
365
+ let id = groupId ? groupId + "_" + uid(shape) : uid(shape);
366
+ const props = {};
367
+ let j = 0;
368
+ // First token may be the node id
369
+ if (j < toks.length &&
370
+ (toks[j].type === "IDENT" || toks[j].type === "STRING")) {
371
+ id = toks[j++].value;
372
+ }
373
+ // Remaining tokens are key=value pairs
374
+ while (j < toks.length) {
375
+ const t = toks[j];
376
+ if ((t.type === "IDENT" || t.type === "KEYWORD") &&
377
+ j + 1 < toks.length &&
378
+ toks[j + 1].type === "EQUALS") {
379
+ const key = t.value;
380
+ j += 2;
381
+ if (j < toks.length) {
382
+ props[key] = toks[j].value;
383
+ j++;
384
+ }
385
+ }
386
+ else
387
+ j++;
388
+ }
389
+ const node = {
390
+ kind: "node",
391
+ id,
392
+ shape,
393
+ label: props.label || id,
394
+ ...(groupId ? { groupId } : {}),
395
+ ...(props.width ? { width: parseFloat(props.width) } : {}),
396
+ ...(props.height ? { height: parseFloat(props.height) } : {}),
397
+ ...(props.theme ? { theme: props.theme } : {}),
398
+ style: propsToStyle(props),
399
+ };
400
+ if (props.url)
401
+ node.imageUrl = props.url;
402
+ return node;
403
+ }
404
+ function parseEdge(fromId, connector, rest) {
405
+ const toTok = rest.shift();
406
+ if (!toTok)
407
+ throw new ParseError("Expected edge target", 0, 0);
408
+ const toId = toTok.value;
409
+ const props = {};
410
+ let j = 0;
411
+ while (j < rest.length) {
412
+ const t = rest[j];
413
+ if ((t.type === "IDENT" || t.type === "KEYWORD") &&
414
+ j + 1 < rest.length &&
415
+ rest[j + 1].type === "EQUALS") {
416
+ const key = t.value;
417
+ j += 2;
418
+ if (j < rest.length) {
419
+ props[key] = rest[j].value;
420
+ j++;
421
+ }
422
+ }
423
+ else
424
+ j++;
425
+ }
426
+ const dashed = connector.includes("--") ||
427
+ connector.includes(".-") ||
428
+ connector.includes("-.");
429
+ const bidir = connector.includes("<") && connector.includes(">");
430
+ return {
431
+ kind: "edge",
432
+ id: uid("edge"),
433
+ from: fromId,
434
+ to: toId,
435
+ connector: connector,
436
+ label: props.label,
437
+ dashed,
438
+ bidirectional: bidir,
439
+ style: propsToStyle(props),
440
+ };
441
+ }
442
+ // ── parseNote ────────────────────────────────────────────
443
+ function parseNote(groupId) {
444
+ skip(); // 'note'
445
+ const toks = lineTokens();
446
+ let id = groupId ? groupId + "_" + uid("note") : uid("note");
447
+ if (toks[0])
448
+ id = toks[0].value;
449
+ const props = {};
450
+ let j = 1;
451
+ // Backward compat: second token is a bare/quoted string → label
452
+ if (toks[1] &&
453
+ (toks[1].type === "STRING" ||
454
+ (toks[1].type === "IDENT" && toks[2]?.type !== "EQUALS"))) {
455
+ props.label = toks[1].value;
456
+ j = 2;
457
+ }
458
+ // Parse remaining key=value props
459
+ while (j < toks.length - 1) {
460
+ const k = toks[j];
461
+ const eq = toks[j + 1];
462
+ if (eq && eq.type === "EQUALS" && j + 2 < toks.length) {
463
+ props[k.value] = toks[j + 2].value;
464
+ j += 3;
465
+ }
466
+ else
467
+ j++;
468
+ }
469
+ // Support multiline via literal \n in label string
470
+ const rawLabel = props.label ?? id;
471
+ return {
472
+ kind: "note",
473
+ id,
474
+ label: rawLabel.replace(/\\n/g, "\n"),
475
+ theme: props.theme,
476
+ style: propsToStyle(props),
477
+ };
478
+ }
479
+ // ── parseGroup ───────────────────────────────────────────
480
+ function parseGroup(parentGroupId) {
481
+ skip(); // 'group'
482
+ const toks = lineTokens();
483
+ let id = uid("group");
484
+ if (toks[0])
485
+ id = toks[0].value;
486
+ const props = {};
487
+ let j = 1;
488
+ // Backward compat: second token is a quoted/bare string (old label syntax)
489
+ if (toks[1] &&
490
+ (toks[1].type === "STRING" ||
491
+ (toks[1].type === "IDENT" && toks[2]?.type !== "EQUALS"))) {
492
+ props.label = toks[1].value;
493
+ j = 2;
494
+ }
495
+ // Parse remaining key=value props
496
+ while (j < toks.length - 1) {
497
+ const k = toks[j];
498
+ const eq = toks[j + 1];
499
+ if (eq && eq.type === "EQUALS" && j + 2 < toks.length) {
500
+ props[k.value] = toks[j + 2].value;
501
+ j += 3;
502
+ }
503
+ else
504
+ j++;
505
+ }
506
+ const group = {
507
+ kind: "group",
508
+ id,
509
+ label: props.label ?? id,
510
+ children: [],
511
+ layout: props.layout,
512
+ columns: props.columns !== undefined ? parseInt(props.columns, 10) : undefined,
513
+ padding: props.padding !== undefined ? parseInt(props.padding, 10) : undefined,
514
+ gap: props.gap !== undefined ? parseInt(props.gap, 10) : undefined,
515
+ align: props.align,
516
+ justify: props.justify,
517
+ theme: props.theme,
518
+ style: propsToStyle(props),
519
+ width: props.width !== undefined ? parseFloat(props.width) : undefined, // ← add
520
+ height: props.height !== undefined ? parseFloat(props.height) : undefined,
521
+ };
522
+ skipNL();
523
+ if (cur().type === "LBRACE") {
524
+ skip();
525
+ skipNL();
526
+ }
527
+ while (cur().type !== "RBRACE" &&
528
+ cur().value !== "end" &&
529
+ cur().type !== "EOF") {
530
+ skipNL();
531
+ if (cur().type === "RBRACE")
532
+ break;
533
+ const v = cur().value;
534
+ // ── Nested group ──────────────────────────────────
535
+ if (v === "group") {
536
+ const nested = parseGroup();
537
+ ast.groups.push(nested);
538
+ groupIds.add(nested.id);
539
+ group.children.push({ kind: "group", id: nested.id });
540
+ continue;
541
+ }
542
+ // ── Table ─────────────────────────────────────────
543
+ if (v === "table") {
544
+ const tbl = parseTable();
545
+ ast.tables.push(tbl);
546
+ tableIds.add(tbl.id);
547
+ group.children.push({ kind: "table", id: tbl.id });
548
+ continue;
549
+ }
550
+ // ── Note ──────────────────────────────────────────
551
+ if (v === "note") {
552
+ const note = parseNote(id);
553
+ ast.notes.push(note);
554
+ noteIds.add(note.id);
555
+ group.children.push({ kind: "note", id: note.id });
556
+ continue;
557
+ }
558
+ // ── Chart ──────────────────────────────────────────
559
+ if (CHART_TYPES.includes(v)) {
560
+ const chart = parseChart(v);
561
+ ast.charts.push(chart);
562
+ chartIds.add(chart.id);
563
+ group.children.push({ kind: "chart", id: chart.id });
564
+ continue;
565
+ }
566
+ // ── Node shape ────────────────────────────────────
567
+ if (SHAPES.includes(v)) {
568
+ const node = parseNode(v, id);
569
+ if (!nodeIds.has(node.id)) {
570
+ nodeIds.add(node.id);
571
+ ast.nodes.push(node);
572
+ }
573
+ group.children.push({ kind: "node", id: node.id });
574
+ continue;
575
+ }
576
+ // ── Edge inside group ─────────────────────────────
577
+ if (cur().type === "IDENT" ||
578
+ cur().type === "STRING" ||
579
+ cur().type === "KEYWORD") {
580
+ const nextTok = flat[i + 1];
581
+ if (nextTok && nextTok.type === "ARROW") {
582
+ const lineToks = lineTokens();
583
+ if (lineToks.length >= 3 && lineToks[1].type === "ARROW") {
584
+ const fromId = lineToks[0].value;
585
+ const conn = lineToks[1].value;
586
+ const edge = parseEdge(fromId, conn, lineToks.slice(2));
587
+ ast.edges.push(edge);
588
+ }
589
+ continue;
590
+ }
591
+ }
592
+ skip();
593
+ }
594
+ if (cur().type === "RBRACE")
595
+ skip();
596
+ return group;
597
+ }
598
+ function parseStep() {
599
+ skip();
600
+ const toks = lineTokens();
601
+ const action = (toks[0]?.value ?? 'highlight');
602
+ let target = toks[1]?.value ?? '';
603
+ if (toks[2]?.type === 'ARROW' && toks[3]) {
604
+ target = `${toks[1].value}${toks[2].value}${toks[3].value}`;
605
+ }
606
+ const step = { kind: 'step', action, target };
607
+ for (let j = 2; j < toks.length; j++) {
608
+ const k = toks[j]?.value;
609
+ const eq = toks[j + 1];
610
+ const vt = toks[j + 2];
611
+ // key=value form
612
+ if (eq?.type === 'EQUALS' && vt) {
613
+ if (k === 'dx') {
614
+ step.dx = parseFloat(vt.value);
615
+ j += 2;
616
+ continue;
617
+ }
618
+ if (k === 'dy') {
619
+ step.dy = parseFloat(vt.value);
620
+ j += 2;
621
+ continue;
622
+ }
623
+ if (k === 'duration') {
624
+ step.duration = parseFloat(vt.value);
625
+ j += 2;
626
+ continue;
627
+ }
628
+ if (k === 'delay') {
629
+ step.delay = parseFloat(vt.value);
630
+ j += 2;
631
+ continue;
632
+ }
633
+ if (k === 'factor') {
634
+ step.factor = parseFloat(vt.value);
635
+ j += 2;
636
+ continue;
637
+ }
638
+ if (k === 'deg') {
639
+ step.deg = parseFloat(vt.value);
640
+ j += 2;
641
+ continue;
642
+ }
643
+ if (k === 'fill') {
644
+ step.value = vt.value;
645
+ j += 2;
646
+ continue;
647
+ }
648
+ if (k === 'color') {
649
+ step.value = vt.value;
650
+ j += 2;
651
+ continue;
652
+ }
653
+ }
654
+ // bare key value (legacy)
655
+ if (k === 'delay' && eq?.type === 'NUMBER') {
656
+ step.delay = parseFloat(eq.value);
657
+ j++;
658
+ continue;
659
+ }
660
+ if (k === 'duration' && eq?.type === 'NUMBER') {
661
+ step.duration = parseFloat(eq.value);
662
+ j++;
663
+ continue;
664
+ }
665
+ if (k === 'trigger') {
666
+ step.trigger = eq?.value;
667
+ j++;
668
+ continue;
669
+ }
670
+ }
671
+ return step;
672
+ }
673
+ // function parseStep(): ASTStep {
674
+ // skip(); // 'step'
675
+ // const toks = lineTokens();
676
+ // const action = (toks[0]?.value ?? "highlight") as AnimationAction;
677
+ // let target = toks[1]?.value ?? "";
678
+ // if (toks[2]?.type === "ARROW" && toks[3]) {
679
+ // target = `${toks[1].value}${toks[2].value}${toks[3].value}`;
680
+ // }
681
+ // const step: ASTStep = { kind: "step", action, target };
682
+ // for (let j = 2; j < toks.length - 1; j++) {
683
+ // const k = toks[j].value;
684
+ // const eq = toks[j + 1];
685
+ // const vt = toks[j + 2];
686
+ // // key=value form (dx=50, dy=-80, duration=600)
687
+ // if (eq?.type === "EQUALS" && vt) {
688
+ // if (k === "dx") {
689
+ // step.dx = parseFloat(vt.value);
690
+ // j += 2;
691
+ // continue;
692
+ // }
693
+ // if (k === "dy") {
694
+ // step.dy = parseFloat(vt.value);
695
+ // j += 2;
696
+ // continue;
697
+ // }
698
+ // if (k === "duration") {
699
+ // step.duration = parseFloat(vt.value);
700
+ // j += 2;
701
+ // continue;
702
+ // }
703
+ // if (k === "delay") {
704
+ // step.delay = parseFloat(vt.value);
705
+ // j += 2;
706
+ // continue;
707
+ // }
708
+ // }
709
+ // // bare key value form (legacy: delay 500, duration 400)
710
+ // if (k === "delay" && eq?.type === "NUMBER") {
711
+ // step.delay = parseFloat(eq.value);
712
+ // j++;
713
+ // }
714
+ // if (k === "duration" && eq?.type === "NUMBER") {
715
+ // step.duration = parseFloat(eq.value);
716
+ // j++;
717
+ // }
718
+ // if (k === "trigger") {
719
+ // step.trigger = eq?.value as AnimationTrigger;
720
+ // j++;
721
+ // }
722
+ // if (k === "factor") {
723
+ // step.factor = parseFloat(vt.value);
724
+ // j += 2;
725
+ // continue;
726
+ // }
727
+ // if (k === "deg") {
728
+ // step.deg = parseFloat(vt.value);
729
+ // j += 2;
730
+ // continue;
731
+ // }
732
+ // }
733
+ // return step;
734
+ // }
735
+ function parseChart(chartType) {
736
+ skip();
737
+ const toks = lineTokens();
738
+ const id = toks[0]?.value ?? uid("chart");
739
+ const props = {};
740
+ let j = 1;
741
+ while (j < toks.length - 1) {
742
+ const k = toks[j];
743
+ const eq = toks[j + 1];
744
+ if (eq?.type === "EQUALS" && j + 2 < toks.length) {
745
+ props[k.value] = toks[j + 2].value;
746
+ j += 3;
747
+ }
748
+ else
749
+ j++;
750
+ }
751
+ let dataRows = [];
752
+ skipNL();
753
+ while (cur().type !== "EOF" && cur().value !== "end") {
754
+ skipNL();
755
+ const v = cur().value;
756
+ if (v === "data") {
757
+ dataRows = parseDataArray();
758
+ }
759
+ else if ((cur().type === "IDENT" || cur().type === "KEYWORD") &&
760
+ peek1().type === "EQUALS") {
761
+ const k = cur().value;
762
+ skip();
763
+ skip();
764
+ props[k] = cur().value;
765
+ skip();
766
+ }
767
+ else if (SHAPES.includes(v) ||
768
+ v === "step" ||
769
+ v === "group" ||
770
+ v === "note" || // ← ADD
771
+ v === "table" ||
772
+ v === "config" || // ← ADD
773
+ v === "theme" || // ← ADD
774
+ v === "style" ||
775
+ CHART_TYPES.includes(v)) {
776
+ break;
777
+ }
778
+ else if (peek1().type === "ARROW") {
779
+ // ← ADD THIS WHOLE BLOCK
780
+ break;
781
+ }
782
+ else
783
+ skip();
784
+ }
785
+ const headers = dataRows[0]?.map(String) ?? [];
786
+ const rows = dataRows.slice(1);
787
+ return {
788
+ kind: "chart",
789
+ id,
790
+ chartType: chartType.replace("-chart", ""),
791
+ title: props.title,
792
+ data: { headers, rows },
793
+ width: props.width ? parseFloat(props.width) : undefined,
794
+ height: props.height ? parseFloat(props.height) : undefined,
795
+ theme: props.theme,
796
+ style: propsToStyle(props),
797
+ };
798
+ }
799
+ function parseTable() {
800
+ skip(); // 'table'
801
+ const toks = lineTokens();
802
+ let id = uid("table");
803
+ if (toks[0])
804
+ id = toks[0].value;
805
+ const props = {};
806
+ let j = 1;
807
+ // label="..." or bare second token
808
+ if (toks[1] &&
809
+ (toks[1].type === "STRING" ||
810
+ (toks[1].type === "IDENT" && toks[2]?.type !== "EQUALS"))) {
811
+ props.label = toks[1].value;
812
+ j = 2;
813
+ }
814
+ while (j < toks.length - 1) {
815
+ const k = toks[j], eq = toks[j + 1];
816
+ if (eq?.type === "EQUALS" && j + 2 < toks.length) {
817
+ props[k.value] = toks[j + 2].value;
818
+ j += 3;
819
+ }
820
+ else
821
+ j++;
822
+ }
823
+ const table = {
824
+ kind: "table",
825
+ id,
826
+ label: props.label ?? id,
827
+ rows: [],
828
+ theme: props.theme,
829
+ style: propsToStyle(props),
830
+ };
831
+ skipNL();
832
+ if (cur().type === "LBRACE") {
833
+ skip();
834
+ skipNL();
835
+ }
836
+ while (cur().type !== "RBRACE" &&
837
+ cur().value !== "end" &&
838
+ cur().type !== "EOF") {
839
+ skipNL();
840
+ if (cur().type === "RBRACE")
841
+ break;
842
+ const v = cur().value;
843
+ if (v === "header" || v === "row") {
844
+ skip();
845
+ const cells = [];
846
+ while (cur().type !== "NEWLINE" && cur().type !== "EOF") {
847
+ if (cur().type === "STRING" ||
848
+ cur().type === "IDENT" ||
849
+ cur().type === "NUMBER") {
850
+ cells.push(cur().value);
851
+ }
852
+ skip();
853
+ }
854
+ if (cur().type === "NEWLINE")
855
+ skip();
856
+ table.rows.push({ kind: v === "header" ? "header" : "data", cells });
857
+ }
858
+ else
859
+ skip();
860
+ }
861
+ if (cur().type === "RBRACE")
862
+ skip();
863
+ return table;
864
+ }
865
+ // ── Main parse loop ─────────────────────────────────────
866
+ skipNL();
867
+ if (cur().value === "diagram")
868
+ skip();
869
+ skipNL();
870
+ while (cur().type !== "EOF" && cur().value !== "end") {
871
+ skipNL();
872
+ const t = cur();
873
+ const v = t.value;
874
+ if (t.type === "NEWLINE") {
875
+ skip();
876
+ continue;
877
+ }
878
+ if (v === "diagram") {
879
+ skip();
880
+ continue;
881
+ }
882
+ if (v === "end")
883
+ break;
884
+ // direction — silently ignored (removed from engine)
885
+ if (v === "direction") {
886
+ lineTokens();
887
+ continue;
888
+ }
889
+ // layout
890
+ if (v === "layout") {
891
+ skip();
892
+ ast.layout = cur().value ?? "column";
893
+ skip();
894
+ continue;
895
+ }
896
+ // title
897
+ if (v === "title") {
898
+ skip();
899
+ const toks = lineTokens();
900
+ const labelProp = toks.find((t2, idx) => t2.value === "label" && toks[idx + 1]?.type === "EQUALS");
901
+ if (labelProp) {
902
+ const idx = toks.indexOf(labelProp);
903
+ ast.title = toks[idx + 2]?.value ?? "";
904
+ }
905
+ else {
906
+ ast.title = toks
907
+ .map((t2) => t2.value)
908
+ .join(" ")
909
+ .replace(/"/g, "");
910
+ }
911
+ continue;
912
+ }
913
+ // description
914
+ if (v === "description") {
915
+ skip();
916
+ ast.description = lineTokens()
917
+ .map((t2) => t2.value)
918
+ .join(" ")
919
+ .replace(/"/g, "");
920
+ continue;
921
+ }
922
+ // config
923
+ if (v === "config") {
924
+ skip();
925
+ const k = cur().value;
926
+ skip();
927
+ if (cur().type === "EQUALS")
928
+ skip();
929
+ const cv = cur().value;
930
+ skip();
931
+ ast.config[k] = cv;
932
+ continue;
933
+ }
934
+ // style
935
+ if (v === "style") {
936
+ skip();
937
+ const targetId = cur().value;
938
+ skip();
939
+ const lineToks = lineTokens();
940
+ const p = {};
941
+ let j = 0;
942
+ while (j < lineToks.length - 1) {
943
+ const k2 = lineToks[j];
944
+ const eq = lineToks[j + 1];
945
+ if (eq.type === "EQUALS") {
946
+ p[k2.value] = lineToks[j + 2]?.value ?? "";
947
+ j += 3;
948
+ }
949
+ else
950
+ j++;
951
+ }
952
+ ast.styles[targetId] = { ...ast.styles[targetId], ...propsToStyle(p) };
953
+ continue;
954
+ }
955
+ // theme
956
+ if (v === "theme") {
957
+ skip();
958
+ const toks = lineTokens();
959
+ const themeId = toks[0]?.value;
960
+ if (!themeId)
961
+ continue;
962
+ const props = {};
963
+ let j = 1;
964
+ while (j < toks.length - 1) {
965
+ const k2 = toks[j];
966
+ const eq = toks[j + 1];
967
+ if (eq && eq.type === "EQUALS" && j + 2 < toks.length) {
968
+ props[k2.value] = toks[j + 2].value;
969
+ j += 3;
970
+ }
971
+ else
972
+ j++;
973
+ }
974
+ ast.themes[themeId] = propsToStyle(props);
975
+ continue;
976
+ }
977
+ // group
978
+ if (v === "group") {
979
+ const grp = parseGroup();
980
+ ast.groups.push(grp);
981
+ groupIds.add(grp.id);
982
+ ast.rootOrder.push({ kind: "group", id: grp.id });
983
+ continue;
984
+ }
985
+ // table
986
+ if (v === "table") {
987
+ const tbl = parseTable();
988
+ ast.tables.push(tbl);
989
+ tableIds.add(tbl.id);
990
+ ast.rootOrder.push({ kind: "table", id: tbl.id });
991
+ continue;
992
+ }
993
+ // note
994
+ if (v === "note") {
995
+ const note = parseNote();
996
+ ast.notes.push(note);
997
+ noteIds.add(note.id);
998
+ ast.rootOrder.push({ kind: "note", id: note.id });
999
+ continue;
1000
+ }
1001
+ // step
1002
+ if (v === "step") {
1003
+ ast.steps.push(parseStep());
1004
+ continue;
1005
+ }
1006
+ // charts
1007
+ if (CHART_TYPES.includes(v)) {
1008
+ const chart = parseChart(v);
1009
+ ast.charts.push(chart);
1010
+ chartIds.add(chart.id);
1011
+ ast.rootOrder.push({ kind: "chart", id: chart.id }); // ← ADD
1012
+ continue;
1013
+ }
1014
+ // edge: A -> B (MUST come before shape check)
1015
+ if (t.type === "IDENT" || t.type === "STRING" || t.type === "KEYWORD") {
1016
+ const nextTok = flat[i + 1];
1017
+ if (nextTok && nextTok.type === "ARROW") {
1018
+ const lineToks = lineTokens();
1019
+ if (lineToks.length >= 3 && lineToks[1].type === "ARROW") {
1020
+ const fromId = lineToks[0].value;
1021
+ const conn = lineToks[1].value;
1022
+ const edge = parseEdge(fromId, conn, lineToks.slice(2));
1023
+ ast.edges.push(edge);
1024
+ // Auto-create implied nodes if they don't exist yet
1025
+ for (const nid of [fromId, edge.to]) {
1026
+ if (!nodeIds.has(nid) &&
1027
+ !tableIds.has(nid) &&
1028
+ !noteIds.has(nid) &&
1029
+ !chartIds.has(nid) &&
1030
+ !groupIds.has(nid)) {
1031
+ nodeIds.add(nid);
1032
+ ast.nodes.push({
1033
+ kind: "node",
1034
+ id: nid,
1035
+ shape: "box",
1036
+ label: nid,
1037
+ style: {},
1038
+ });
1039
+ }
1040
+ }
1041
+ continue;
1042
+ }
1043
+ }
1044
+ }
1045
+ // node shapes — only reached if NOT followed by an arrow
1046
+ if (SHAPES.includes(v)) {
1047
+ const node = parseNode(v);
1048
+ if (!nodeIds.has(node.id)) {
1049
+ nodeIds.add(node.id);
1050
+ ast.nodes.push(node);
1051
+ ast.rootOrder.push({ kind: "node", id: node.id });
1052
+ }
1053
+ continue;
1054
+ }
1055
+ skip();
1056
+ }
1057
+ // Merge global styles into node styles
1058
+ for (const node of ast.nodes) {
1059
+ if (ast.styles[node.id]) {
1060
+ node.style = { ...ast.styles[node.id], ...node.style };
1061
+ }
1062
+ }
1063
+ console.log("[parse] charts:", ast.charts.map((c) => c.id));
1064
+ console.log("[parse] rootOrder:", ast.rootOrder.map((r) => r.kind + ":" + r.id));
1065
+ return ast;
1066
+ }
1067
+
1068
+ // ============================================================
1069
+ // sketchmark — Scene Graph
1070
+ // ============================================================
1071
+ // ── Build scene graph from AST ────────────────────────────
1072
+ function buildSceneGraph(ast) {
1073
+ const nodes = ast.nodes.map((n) => {
1074
+ const themeStyle = n.theme ? (ast.themes[n.theme] ?? {}) : {};
1075
+ return {
1076
+ id: n.id,
1077
+ shape: n.shape,
1078
+ label: n.label,
1079
+ style: { ...ast.styles[n.id], ...themeStyle, ...n.style },
1080
+ groupId: n.groupId,
1081
+ width: n.width,
1082
+ height: n.height,
1083
+ meta: n.meta,
1084
+ imageUrl: n.imageUrl,
1085
+ x: 0,
1086
+ y: 0,
1087
+ w: 0,
1088
+ h: 0,
1089
+ };
1090
+ });
1091
+ const groups = ast.groups.map((g) => {
1092
+ const themeStyle = g.theme ? (ast.themes[g.theme] ?? {}) : {};
1093
+ return {
1094
+ id: g.id,
1095
+ label: g.label,
1096
+ parentId: undefined, // set below
1097
+ children: g.children,
1098
+ layout: (g.layout ?? "column"),
1099
+ columns: g.columns ?? 1,
1100
+ padding: g.padding ?? 26,
1101
+ gap: g.gap ?? 10,
1102
+ align: (g.align ?? "start"),
1103
+ justify: (g.justify ?? "start"),
1104
+ style: { ...ast.styles[g.id], ...themeStyle, ...g.style },
1105
+ width: g.width,
1106
+ height: g.height,
1107
+ x: 0,
1108
+ y: 0,
1109
+ w: 0,
1110
+ h: 0,
1111
+ };
1112
+ });
1113
+ const tables = ast.tables.map((t) => {
1114
+ const themeStyle = t.theme ? (ast.themes[t.theme] ?? {}) : {};
1115
+ return {
1116
+ id: t.id,
1117
+ label: t.label,
1118
+ rows: t.rows,
1119
+ colWidths: [],
1120
+ rowH: 30,
1121
+ headerH: 34,
1122
+ labelH: 22,
1123
+ style: { ...ast.styles[t.id], ...themeStyle, ...t.style },
1124
+ x: 0,
1125
+ y: 0,
1126
+ w: 0,
1127
+ h: 0,
1128
+ };
1129
+ });
1130
+ const notes = ast.notes.map((n) => {
1131
+ const themeStyle = n.theme ? (ast.themes[n.theme] ?? {}) : {};
1132
+ return {
1133
+ id: n.id,
1134
+ lines: n.label.split("\n"),
1135
+ style: { ...ast.styles[n.id], ...themeStyle, ...n.style },
1136
+ x: 0,
1137
+ y: 0,
1138
+ w: 0,
1139
+ h: 0,
1140
+ };
1141
+ });
1142
+ const charts = ast.charts.map((c) => {
1143
+ const themeStyle = c.theme ? (ast.themes[c.theme] ?? {}) : {};
1144
+ return {
1145
+ id: c.id,
1146
+ chartType: c.chartType,
1147
+ title: c.title,
1148
+ data: c.data,
1149
+ style: { ...ast.styles[c.id], ...themeStyle, ...c.style },
1150
+ x: 0,
1151
+ y: 0,
1152
+ w: c.width ?? 320,
1153
+ h: c.height ?? 240,
1154
+ };
1155
+ });
1156
+ // Set parentId for nested groups
1157
+ for (const g of groups) {
1158
+ for (const child of g.children) {
1159
+ if (child.kind === "group") {
1160
+ const nested = groups.find((gg) => gg.id === child.id);
1161
+ if (nested)
1162
+ nested.parentId = g.id;
1163
+ }
1164
+ }
1165
+ }
1166
+ const edges = ast.edges.map((e) => ({
1167
+ id: e.id,
1168
+ from: e.from,
1169
+ to: e.to,
1170
+ connector: e.connector,
1171
+ label: e.label,
1172
+ dashed: e.dashed ?? false,
1173
+ bidirectional: e.bidirectional ?? false,
1174
+ style: e.style ?? {},
1175
+ }));
1176
+ return {
1177
+ title: ast.title,
1178
+ description: ast.description,
1179
+ layout: ast.layout,
1180
+ nodes,
1181
+ edges,
1182
+ groups,
1183
+ tables,
1184
+ notes,
1185
+ charts,
1186
+ animation: { steps: ast.steps, currentStep: -1 },
1187
+ styles: ast.styles,
1188
+ config: ast.config,
1189
+ rootOrder: ast.rootOrder ?? [],
1190
+ width: 0,
1191
+ height: 0,
1192
+ };
1193
+ }
1194
+ // ── Helpers ───────────────────────────────────────────────
1195
+ function nodeMap(sg) {
1196
+ return new Map(sg.nodes.map((n) => [n.id, n]));
1197
+ }
1198
+ function groupMap(sg) {
1199
+ return new Map(sg.groups.map((g) => [g.id, g]));
1200
+ }
1201
+ function tableMap(sg) {
1202
+ return new Map(sg.tables.map((t) => [t.id, t]));
1203
+ }
1204
+ function noteMap(sg) {
1205
+ return new Map(sg.notes.map((n) => [n.id, n]));
1206
+ }
1207
+ function chartMap(sg) {
1208
+ return new Map(sg.charts.map((c) => [c.id, c]));
1209
+ }
1210
+
1211
+ // ============================================================
1212
+ // sketchmark — Layout Engine (Flexbox-style, recursive)
1213
+ //
1214
+ // Two-pass algorithm:
1215
+ // Pass 1 measure() bottom-up : computes w, h for every group
1216
+ // Pass 2 place() top-down : assigns x, y to every item
1217
+ //
1218
+ // Each group is a CSS-like flex container:
1219
+ // layout=row → flex-direction: row
1220
+ // layout=column → flex-direction: column (default)
1221
+ // layout=grid → CSS grid (fixed columns count)
1222
+ // align=… → align-items
1223
+ // justify=… → justify-content
1224
+ // ============================================================
1225
+ // ── Constants ─────────────────────────────────────────────
1226
+ const FONT_PX_PER_CHAR = 8.6;
1227
+ const MIN_W = 90;
1228
+ const MAX_W = 180;
1229
+ const BASE_PAD = 26;
1230
+ const GROUP_LABEL_H = 22;
1231
+ const DEFAULT_MARGIN = 60;
1232
+ const DEFAULT_GAP_MAIN = 80;
1233
+ // Table sizing
1234
+ const CELL_PAD = 20; // total horizontal padding per cell (left + right)
1235
+ const MIN_COL_W = 50; // minimum column width
1236
+ const TBL_FONT = 7.5; // px per char at 12px sans-serif
1237
+ const NOTE_LINE_H = 20;
1238
+ const NOTE_PAD_X = 16;
1239
+ const NOTE_PAD_Y = 12;
1240
+ const NOTE_FONT = 7.5;
1241
+ // ── Node auto-sizing ──────────────────────────────────────
1242
+ function sizeNode(n) {
1243
+ // User-specified dimensions win
1244
+ if (n.width && n.width > 0)
1245
+ n.w = n.width;
1246
+ if (n.height && n.height > 0)
1247
+ n.h = n.height;
1248
+ const labelW = Math.round(n.label.length * FONT_PX_PER_CHAR + BASE_PAD);
1249
+ switch (n.shape) {
1250
+ case 'circle':
1251
+ n.w = n.w || Math.max(84, Math.min(MAX_W, labelW));
1252
+ n.h = n.h || n.w;
1253
+ break;
1254
+ case 'diamond':
1255
+ n.w = n.w || Math.max(130, Math.min(MAX_W, labelW + 30));
1256
+ n.h = n.h || Math.max(62, n.w * 0.46);
1257
+ break;
1258
+ case 'hexagon':
1259
+ n.w = n.w || Math.max(126, Math.min(MAX_W, labelW + 20));
1260
+ n.h = n.h || Math.max(54, n.w * 0.44);
1261
+ break;
1262
+ case 'triangle':
1263
+ n.w = n.w || Math.max(108, Math.min(MAX_W, labelW + 10));
1264
+ n.h = n.h || Math.max(64, n.w * 0.60);
1265
+ break;
1266
+ case 'cylinder':
1267
+ n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW));
1268
+ n.h = n.h || 66;
1269
+ break;
1270
+ case 'parallelogram':
1271
+ n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW + 28));
1272
+ n.h = n.h || 50;
1273
+ break;
1274
+ default:
1275
+ n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW));
1276
+ n.h = n.h || 52;
1277
+ break;
1278
+ }
1279
+ }
1280
+ function sizeNote(n) {
1281
+ const maxChars = Math.max(...n.lines.map(l => l.length));
1282
+ n.w = Math.max(120, Math.ceil(maxChars * NOTE_FONT) + NOTE_PAD_X * 2);
1283
+ n.h = n.lines.length * NOTE_LINE_H + NOTE_PAD_Y * 2;
1284
+ }
1285
+ // ── Table auto-sizing ─────────────────────────────────────
1286
+ function sizeTable(t) {
1287
+ const { rows, labelH, headerH, rowH } = t;
1288
+ if (!rows.length) {
1289
+ t.w = 120;
1290
+ t.h = labelH + rowH;
1291
+ return;
1292
+ }
1293
+ const numCols = Math.max(...rows.map(r => r.cells.length));
1294
+ const colW = Array(numCols).fill(MIN_COL_W);
1295
+ for (const row of rows) {
1296
+ row.cells.forEach((cell, i) => {
1297
+ colW[i] = Math.max(colW[i], Math.ceil(cell.length * TBL_FONT) + CELL_PAD);
1298
+ });
1299
+ }
1300
+ t.colWidths = colW;
1301
+ t.w = colW.reduce((s, w) => s + w, 0);
1302
+ const nHeader = rows.filter(r => r.kind === 'header').length;
1303
+ const nData = rows.filter(r => r.kind === 'data').length;
1304
+ t.h = labelH + nHeader * headerH + nData * rowH;
1305
+ }
1306
+ function sizeChart(c) {
1307
+ c.w = c.w || 320;
1308
+ c.h = c.h || 240;
1309
+ }
1310
+ // ── Item size helpers ─────────────────────────────────────
1311
+ function iW(r, nm, gm, tm, ntm, cm) {
1312
+ if (r.kind === 'node')
1313
+ return nm.get(r.id).w;
1314
+ if (r.kind === 'table')
1315
+ return tm.get(r.id).w;
1316
+ if (r.kind === 'note')
1317
+ return ntm.get(r.id).w;
1318
+ if (r.kind === 'chart')
1319
+ return cm.get(r.id).w;
1320
+ return gm.get(r.id).w;
1321
+ }
1322
+ function iH(r, nm, gm, tm, ntm, cm) {
1323
+ if (r.kind === 'node')
1324
+ return nm.get(r.id).h;
1325
+ if (r.kind === 'table')
1326
+ return tm.get(r.id).h;
1327
+ if (r.kind === 'note')
1328
+ return ntm.get(r.id).h;
1329
+ if (r.kind === 'chart')
1330
+ return cm.get(r.id).h;
1331
+ return gm.get(r.id).h;
1332
+ }
1333
+ function setPos(r, x, y, nm, gm, tm, ntm, cm) {
1334
+ if (r.kind === 'node') {
1335
+ const n = nm.get(r.id);
1336
+ n.x = Math.round(x);
1337
+ n.y = Math.round(y);
1338
+ return;
1339
+ }
1340
+ if (r.kind === 'table') {
1341
+ const t = tm.get(r.id);
1342
+ t.x = Math.round(x);
1343
+ t.y = Math.round(y);
1344
+ return;
1345
+ }
1346
+ if (r.kind === 'note') {
1347
+ const nt = ntm.get(r.id);
1348
+ nt.x = Math.round(x);
1349
+ nt.y = Math.round(y);
1350
+ return;
1351
+ }
1352
+ if (r.kind === 'chart') {
1353
+ const c = cm.get(r.id);
1354
+ c.x = Math.round(x);
1355
+ c.y = Math.round(y);
1356
+ return;
1357
+ }
1358
+ const g = gm.get(r.id);
1359
+ g.x = Math.round(x);
1360
+ g.y = Math.round(y);
1361
+ }
1362
+ // ── Pass 1: Measure (bottom-up) ───────────────────────────
1363
+ // Recursively computes w, h for a group from its children's sizes.
1364
+ function measure(g, nm, gm, tm, ntm, cm) {
1365
+ // Recurse into nested groups first; size tables before reading their dims
1366
+ for (const r of g.children) {
1367
+ if (r.kind === 'group')
1368
+ measure(gm.get(r.id), nm, gm, tm, ntm, cm);
1369
+ if (r.kind === 'table')
1370
+ sizeTable(tm.get(r.id));
1371
+ if (r.kind === 'note')
1372
+ sizeNote(ntm.get(r.id));
1373
+ if (r.kind === 'chart')
1374
+ sizeChart(cm.get(r.id));
1375
+ }
1376
+ const { padding: pad, gap, columns, layout } = g;
1377
+ const kids = g.children;
1378
+ if (!kids.length) {
1379
+ g.w = pad * 2;
1380
+ g.h = pad * 2 + GROUP_LABEL_H;
1381
+ if (g.width && g.w < g.width)
1382
+ g.w = g.width;
1383
+ if (g.height && g.h < g.height)
1384
+ g.h = g.height;
1385
+ return;
1386
+ }
1387
+ const ws = kids.map(r => iW(r, nm, gm, tm, ntm, cm));
1388
+ const hs = kids.map(r => iH(r, nm, gm, tm, ntm, cm));
1389
+ const n = kids.length;
1390
+ if (layout === 'row') {
1391
+ g.w = ws.reduce((s, w) => s + w, 0) + gap * (n - 1) + pad * 2;
1392
+ g.h = Math.max(...hs) + pad * 2 + GROUP_LABEL_H;
1393
+ }
1394
+ else if (layout === 'grid') {
1395
+ const cols = Math.max(1, columns);
1396
+ const rows = Math.ceil(n / cols);
1397
+ const cellW = Math.max(...ws);
1398
+ const cellH = Math.max(...hs);
1399
+ g.w = cols * cellW + (cols - 1) * gap + pad * 2;
1400
+ g.h = rows * cellH + (rows - 1) * gap + pad * 2 + GROUP_LABEL_H;
1401
+ }
1402
+ else {
1403
+ // column (default)
1404
+ g.w = Math.max(...ws) + pad * 2;
1405
+ g.h = hs.reduce((s, h) => s + h, 0) + gap * (n - 1) + pad * 2 + GROUP_LABEL_H;
1406
+ }
1407
+ // Clamp to minWidth / minHeight — this is what gives distribute() free
1408
+ // space to work with for justify=center/end/space-between/space-around
1409
+ if (g.width && g.w < g.width)
1410
+ g.w = g.width;
1411
+ if (g.height && g.h < g.height)
1412
+ g.h = g.height;
1413
+ }
1414
+ // ── Justify distribution helper ───────────────────────────
1415
+ function distribute(sizes, contentSize, gap, justify) {
1416
+ const n = sizes.length;
1417
+ const totalSize = sizes.reduce((s, v) => s + v, 0);
1418
+ const gapCount = n - 1;
1419
+ switch (justify) {
1420
+ case 'center': {
1421
+ const total = totalSize + gap * gapCount;
1422
+ return { start: Math.max(0, (contentSize - total) / 2), gaps: Array(gapCount).fill(gap) };
1423
+ }
1424
+ case 'end': {
1425
+ const total = totalSize + gap * gapCount;
1426
+ return { start: Math.max(0, contentSize - total), gaps: Array(gapCount).fill(gap) };
1427
+ }
1428
+ case 'space-between': {
1429
+ const g2 = gapCount > 0 ? Math.max(gap, (contentSize - totalSize) / gapCount) : gap;
1430
+ return { start: 0, gaps: Array(gapCount).fill(g2) };
1431
+ }
1432
+ case 'space-around': {
1433
+ const space = n > 0 ? (contentSize - totalSize) / n : gap;
1434
+ return { start: Math.max(0, space / 2), gaps: Array(gapCount).fill(Math.max(gap, space)) };
1435
+ }
1436
+ default: // start
1437
+ return { start: 0, gaps: Array(gapCount).fill(gap) };
1438
+ }
1439
+ }
1440
+ // ── Pass 2: Place (top-down) ──────────────────────────────
1441
+ // Assigns x, y to each child. Assumes g.x / g.y already set by parent.
1442
+ function place(g, nm, gm, tm, ntm, cm) {
1443
+ const { padding: pad, gap, columns, layout, align, justify } = g;
1444
+ const contentX = g.x + pad;
1445
+ const contentY = g.y + GROUP_LABEL_H + pad;
1446
+ const contentW = g.w - pad * 2;
1447
+ const contentH = g.h - pad * 2 - GROUP_LABEL_H;
1448
+ const kids = g.children;
1449
+ if (!kids.length)
1450
+ return;
1451
+ if (layout === 'row') {
1452
+ const ws = kids.map(r => iW(r, nm, gm, tm, ntm, cm));
1453
+ const hs = kids.map(r => iH(r, nm, gm, tm, ntm, cm));
1454
+ const maxH = Math.max(...hs);
1455
+ const { start, gaps } = distribute(ws, contentW, gap, justify);
1456
+ let x = contentX + start;
1457
+ for (let i = 0; i < kids.length; i++) {
1458
+ let y;
1459
+ switch (align) {
1460
+ case 'center':
1461
+ y = contentY + (maxH - hs[i]) / 2;
1462
+ break;
1463
+ case 'end':
1464
+ y = contentY + maxH - hs[i];
1465
+ break;
1466
+ default: y = contentY;
1467
+ }
1468
+ setPos(kids[i], x, y, nm, gm, tm, ntm, cm);
1469
+ x += ws[i] + (i < gaps.length ? gaps[i] : 0);
1470
+ }
1471
+ }
1472
+ else if (layout === 'grid') {
1473
+ const cols = Math.max(1, columns);
1474
+ const cellW = Math.max(...kids.map(r => iW(r, nm, gm, tm, ntm, cm)));
1475
+ const cellH = Math.max(...kids.map(r => iH(r, nm, gm, tm, ntm, cm)));
1476
+ kids.forEach((ref, i) => {
1477
+ setPos(ref, contentX + (i % cols) * (cellW + gap), contentY + Math.floor(i / cols) * (cellH + gap), nm, gm, tm, ntm, cm);
1478
+ });
1479
+ }
1480
+ else {
1481
+ // column (default)
1482
+ const ws = kids.map(r => iW(r, nm, gm, tm, ntm, cm));
1483
+ const hs = kids.map(r => iH(r, nm, gm, tm, ntm, cm));
1484
+ const maxW = Math.max(...ws);
1485
+ const { start, gaps } = distribute(hs, contentH, gap, justify);
1486
+ let y = contentY + start;
1487
+ for (let i = 0; i < kids.length; i++) {
1488
+ let x;
1489
+ switch (align) {
1490
+ case 'center':
1491
+ x = contentX + (maxW - ws[i]) / 2;
1492
+ break;
1493
+ case 'end':
1494
+ x = contentX + maxW - ws[i];
1495
+ break;
1496
+ default: x = contentX;
1497
+ }
1498
+ setPos(kids[i], x, y, nm, gm, tm, ntm, cm);
1499
+ y += hs[i] + (i < gaps.length ? gaps[i] : 0);
1500
+ }
1501
+ }
1502
+ // Recurse into nested groups
1503
+ for (const r of kids) {
1504
+ if (r.kind === 'group')
1505
+ place(gm.get(r.id), nm, gm, tm, ntm, cm);
1506
+ }
1507
+ }
1508
+ // ── Edge routing ──────────────────────────────────────────
1509
+ function connPoint(n, other) {
1510
+ const cx = n.x + n.w / 2, cy = n.y + n.h / 2;
1511
+ const ox = other.x + other.w / 2, oy = other.y + other.h / 2;
1512
+ const dx = ox - cx, dy = oy - cy;
1513
+ if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01)
1514
+ return [cx, cy];
1515
+ if (n.shape === 'circle') {
1516
+ const r = n.w * 0.44, len = Math.sqrt(dx * dx + dy * dy);
1517
+ return [cx + dx / len * r, cy + dy / len * r];
1518
+ }
1519
+ const hw = n.w / 2 - 2, hh = n.h / 2 - 2;
1520
+ const tx = Math.abs(dx) > 0.01 ? hw / Math.abs(dx) : 1e9;
1521
+ const ty = Math.abs(dy) > 0.01 ? hh / Math.abs(dy) : 1e9;
1522
+ const t = Math.min(tx, ty);
1523
+ return [cx + t * dx, cy + t * dy];
1524
+ }
1525
+ function rectConnPoint$2(rx, ry, rw, rh, ox, oy) {
1526
+ const cx = rx + rw / 2, cy = ry + rh / 2;
1527
+ const dx = ox - cx, dy = oy - cy;
1528
+ if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01)
1529
+ return [cx, cy];
1530
+ const hw = rw / 2 - 2, hh = rh / 2 - 2;
1531
+ const tx = Math.abs(dx) > 0.01 ? hw / Math.abs(dx) : 1e9;
1532
+ const ty = Math.abs(dy) > 0.01 ? hh / Math.abs(dy) : 1e9;
1533
+ const t = Math.min(tx, ty);
1534
+ return [cx + t * dx, cy + t * dy];
1535
+ }
1536
+ function routeEdges(sg) {
1537
+ const nm = nodeMap(sg);
1538
+ const tm = tableMap(sg);
1539
+ const gm = groupMap(sg);
1540
+ const cm = chartMap(sg);
1541
+ const ntm = noteMap(sg);
1542
+ function resolve(id) {
1543
+ const n = nm.get(id);
1544
+ if (n)
1545
+ return n;
1546
+ const t = tm.get(id);
1547
+ if (t)
1548
+ return t;
1549
+ const g = gm.get(id);
1550
+ if (g)
1551
+ return g;
1552
+ const c = cm.get(id);
1553
+ if (c)
1554
+ return c;
1555
+ const nt = ntm.get(id);
1556
+ if (nt)
1557
+ return nt;
1558
+ return null;
1559
+ }
1560
+ function connPt(src, dstCX, dstCY) {
1561
+ // SceneNode has a .shape field; use the existing connPoint for it
1562
+ if ('shape' in src && src.shape) {
1563
+ return connPoint(src, {
1564
+ x: dstCX - 1, y: dstCY - 1, w: 2, h: 2});
1565
+ }
1566
+ return rectConnPoint$2(src.x, src.y, src.w, src.h, dstCX, dstCY);
1567
+ }
1568
+ for (const e of sg.edges) {
1569
+ const src = resolve(e.from);
1570
+ const dst = resolve(e.to);
1571
+ if (!src || !dst) {
1572
+ e.points = [];
1573
+ continue;
1574
+ }
1575
+ const dstCX = dst.x + dst.w / 2, dstCY = dst.y + dst.h / 2;
1576
+ const srcCX = src.x + src.w / 2, srcCY = src.y + src.h / 2;
1577
+ e.points = [
1578
+ connPt(src, dstCX, dstCY),
1579
+ connPt(dst, srcCX, srcCY),
1580
+ ];
1581
+ }
1582
+ }
1583
+ function computeBounds(sg, margin) {
1584
+ const allX = [
1585
+ ...sg.nodes.map(n => n.x + n.w),
1586
+ ...sg.groups.filter(g => g.w).map(g => g.x + g.w),
1587
+ ...sg.tables.map(t => t.x + t.w),
1588
+ ...sg.notes.map(n => n.x + n.w),
1589
+ ...sg.charts.map(c => c.x + c.w)
1590
+ ];
1591
+ const allY = [
1592
+ ...sg.nodes.map(n => n.y + n.h),
1593
+ ...sg.groups.filter(g => g.h).map(g => g.y + g.h),
1594
+ ...sg.tables.map(t => t.y + t.h),
1595
+ ...sg.notes.map(n => n.y + n.h),
1596
+ ...sg.charts.map(c => c.y + c.h)
1597
+ ];
1598
+ sg.width = (allX.length ? Math.max(...allX) : 400) + margin;
1599
+ sg.height = (allY.length ? Math.max(...allY) : 300) + margin;
1600
+ }
1601
+ // ── Public entry point ────────────────────────────────────
1602
+ function layout(sg) {
1603
+ const GAP_MAIN = Number(sg.config['gap'] ?? DEFAULT_GAP_MAIN);
1604
+ const MARGIN = Number(sg.config['margin'] ?? DEFAULT_MARGIN);
1605
+ const nm = nodeMap(sg);
1606
+ const gm = groupMap(sg);
1607
+ const tm = tableMap(sg);
1608
+ const ntm = noteMap(sg);
1609
+ const cm = chartMap(sg);
1610
+ console.log('[layout] sg.charts:', sg.charts.map(c => c.id));
1611
+ console.log('[layout] sg.rootOrder:', sg.rootOrder.map(r => r.kind + ':' + r.id));
1612
+ // 1. Size all nodes and tables
1613
+ sg.nodes.forEach(sizeNode);
1614
+ sg.tables.forEach(sizeTable);
1615
+ sg.notes.forEach(sizeNote);
1616
+ sg.charts.forEach(sizeChart);
1617
+ // src/layout/index.ts — after sg.charts.forEach(sizeChart);
1618
+ // 2. Identify root vs nested items
1619
+ const nestedGroupIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind === 'group').map(c => c.id)));
1620
+ const groupedNodeIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind === 'node').map(c => c.id)));
1621
+ const groupedTableIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind === 'table').map(c => c.id)));
1622
+ const groupedNoteIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind === 'note').map(c => c.id)));
1623
+ const groupedChartIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind === 'chart').map(c => c.id)));
1624
+ const rootGroups = sg.groups.filter(g => !nestedGroupIds.has(g.id));
1625
+ const rootNodes = sg.nodes.filter(n => !groupedNodeIds.has(n.id));
1626
+ const rootTables = sg.tables.filter(t => !groupedTableIds.has(t.id));
1627
+ const rootNotes = sg.notes.filter(n => !groupedNoteIds.has(n.id));
1628
+ const rootCharts = sg.charts.filter(c => !groupedChartIds.has(c.id));
1629
+ // 3. Measure root groups bottom-up
1630
+ for (const g of rootGroups)
1631
+ measure(g, nm, gm, tm, ntm, cm);
1632
+ // 4. Build root order
1633
+ // sg.rootOrder preserves DSL declaration order.
1634
+ // Fall back: groups, then nodes, then tables.
1635
+ const rootOrder = sg.rootOrder?.length
1636
+ ? sg.rootOrder
1637
+ : [
1638
+ ...rootGroups.map(g => ({ kind: 'group', id: g.id })),
1639
+ ...rootNodes.map(n => ({ kind: 'node', id: n.id })),
1640
+ ...rootTables.map(t => ({ kind: 'table', id: t.id })),
1641
+ ...rootNotes.map(n => ({ kind: 'note', id: n.id })),
1642
+ ...rootCharts.map(c => ({ kind: 'chart', id: c.id }))
1643
+ ];
1644
+ // 5. Root-level layout
1645
+ // sg.layout:
1646
+ // 'row' → items flow left to right (default)
1647
+ // 'column' → items flow top to bottom
1648
+ // 'grid' → config columns=N grid
1649
+ const rootLayout = (sg.layout ?? 'row');
1650
+ const rootCols = Number(sg.config['columns'] ?? 1);
1651
+ const useGrid = rootLayout === 'grid' && rootCols > 0;
1652
+ const useColumn = rootLayout === 'column';
1653
+ console.log('[layout] sized charts:', sg.charts.map(c => `${c.id} w=${c.w} h=${c.h}`));
1654
+ console.log('[layout] rootOrder chart refs:', rootOrder.filter(r => r.kind === 'chart'));
1655
+ if (useGrid) {
1656
+ // ── Grid: per-row heights, per-column widths (no wasted space) ──
1657
+ const cols = rootCols;
1658
+ const rows = Math.ceil(rootOrder.length / cols);
1659
+ const colWidths = Array(cols).fill(0);
1660
+ const rowHeights = Array(rows).fill(0);
1661
+ rootOrder.forEach((ref, idx) => {
1662
+ const col = idx % cols;
1663
+ const row = Math.floor(idx / cols);
1664
+ let w = 0, h = 0;
1665
+ if (ref.kind === 'group') {
1666
+ w = gm.get(ref.id).w;
1667
+ h = gm.get(ref.id).h;
1668
+ }
1669
+ else if (ref.kind === 'table') {
1670
+ w = tm.get(ref.id).w;
1671
+ h = tm.get(ref.id).h;
1672
+ }
1673
+ else if (ref.kind === 'note') {
1674
+ w = ntm.get(ref.id).w;
1675
+ h = ntm.get(ref.id).h;
1676
+ }
1677
+ else if (ref.kind === 'chart') {
1678
+ w = cm.get(ref.id).w;
1679
+ h = cm.get(ref.id).h;
1680
+ }
1681
+ else {
1682
+ w = nm.get(ref.id).w;
1683
+ h = nm.get(ref.id).h;
1684
+ }
1685
+ colWidths[col] = Math.max(colWidths[col], w);
1686
+ rowHeights[row] = Math.max(rowHeights[row], h);
1687
+ });
1688
+ const colX = [];
1689
+ let cx = MARGIN;
1690
+ for (let c = 0; c < cols; c++) {
1691
+ colX.push(cx);
1692
+ cx += colWidths[c] + GAP_MAIN;
1693
+ }
1694
+ const rowY = [];
1695
+ let ry = MARGIN;
1696
+ for (let r = 0; r < rows; r++) {
1697
+ rowY.push(ry);
1698
+ ry += rowHeights[r] + GAP_MAIN;
1699
+ }
1700
+ rootOrder.forEach((ref, idx) => {
1701
+ const x = colX[idx % cols];
1702
+ const y = rowY[Math.floor(idx / cols)];
1703
+ if (ref.kind === 'group') {
1704
+ gm.get(ref.id).x = x;
1705
+ gm.get(ref.id).y = y;
1706
+ }
1707
+ else if (ref.kind === 'table') {
1708
+ tm.get(ref.id).x = x;
1709
+ tm.get(ref.id).y = y;
1710
+ }
1711
+ else if (ref.kind === 'note') {
1712
+ ntm.get(ref.id).x = x;
1713
+ ntm.get(ref.id).y = y;
1714
+ }
1715
+ else if (ref.kind === 'chart') {
1716
+ cm.get(ref.id).x = x;
1717
+ cm.get(ref.id).y = y;
1718
+ }
1719
+ else {
1720
+ nm.get(ref.id).x = x;
1721
+ nm.get(ref.id).y = y;
1722
+ }
1723
+ });
1724
+ }
1725
+ else {
1726
+ // ── Row or Column linear flow ──────────────────────────
1727
+ let pos = MARGIN;
1728
+ for (const ref of rootOrder) {
1729
+ let w = 0, h = 0;
1730
+ if (ref.kind === 'group') {
1731
+ w = gm.get(ref.id).w;
1732
+ h = gm.get(ref.id).h;
1733
+ }
1734
+ else if (ref.kind === 'table') {
1735
+ w = tm.get(ref.id).w;
1736
+ h = tm.get(ref.id).h;
1737
+ }
1738
+ else if (ref.kind === 'note') {
1739
+ w = ntm.get(ref.id).w;
1740
+ h = ntm.get(ref.id).h;
1741
+ }
1742
+ else if (ref.kind === 'chart') {
1743
+ w = cm.get(ref.id).w;
1744
+ h = cm.get(ref.id).h;
1745
+ }
1746
+ else {
1747
+ w = nm.get(ref.id).w;
1748
+ h = nm.get(ref.id).h;
1749
+ }
1750
+ const x = useColumn ? MARGIN : pos;
1751
+ const y = useColumn ? pos : MARGIN;
1752
+ if (ref.kind === 'group') {
1753
+ gm.get(ref.id).x = x;
1754
+ gm.get(ref.id).y = y;
1755
+ }
1756
+ else if (ref.kind === 'table') {
1757
+ tm.get(ref.id).x = x;
1758
+ tm.get(ref.id).y = y;
1759
+ }
1760
+ else if (ref.kind === 'note') {
1761
+ ntm.get(ref.id).x = x;
1762
+ ntm.get(ref.id).y = y;
1763
+ }
1764
+ else if (ref.kind === 'chart') {
1765
+ cm.get(ref.id).x = x;
1766
+ cm.get(ref.id).y = y;
1767
+ }
1768
+ else {
1769
+ nm.get(ref.id).x = x;
1770
+ nm.get(ref.id).y = y;
1771
+ }
1772
+ pos += (useColumn ? h : w) + GAP_MAIN;
1773
+ }
1774
+ }
1775
+ // 6. Place children within each root group (top-down, recursive)
1776
+ for (const g of rootGroups)
1777
+ place(g, nm, gm, tm, ntm, cm);
1778
+ // 7. Route edges and compute canvas size
1779
+ routeEdges(sg);
1780
+ console.log('[layout] chart positions:', sg.charts.map(c => `${c.id} x=${c.x} y=${c.y}`));
1781
+ computeBounds(sg, MARGIN);
1782
+ return sg;
1783
+ }
1784
+
1785
+ // ============================================================
1786
+ // sketchmark — Rough Chart Math
1787
+ // Shared data-processing and layout helpers for both renderers.
1788
+ // No rough.js dependency — pure geometry.
1789
+ // ============================================================
1790
+ const CHART_COLORS = [
1791
+ '#378ADD', '#1D9E75', '#D85A30', '#BA7517',
1792
+ '#7F77DD', '#D4537E', '#639922', '#E24B4A',
1793
+ ];
1794
+ function chartLayout(c) {
1795
+ const titleH = c.title ? 24 : 8;
1796
+ const padL = 44, padR = 12, padB = 28, padT = 6;
1797
+ const pw = c.w - padL - padR;
1798
+ const ph = c.h - titleH - padT - padB;
1799
+ return {
1800
+ px: c.x + padL,
1801
+ py: c.y + titleH + padT,
1802
+ pw, ph, titleH,
1803
+ cx: c.x + c.w / 2,
1804
+ cy: c.y + titleH + padT + ph / 2,
1805
+ };
1806
+ }
1807
+ function parseBarLine(data) {
1808
+ return {
1809
+ labels: data.rows.map(r => String(r[0])),
1810
+ series: data.headers.slice(1).map((h, si) => ({
1811
+ name: String(h),
1812
+ values: data.rows.map(r => Number(r[si + 1])),
1813
+ color: CHART_COLORS[si % CHART_COLORS.length],
1814
+ })),
1815
+ };
1816
+ }
1817
+ function parsePie(data) {
1818
+ const segments = data.rows.map((r, i) => ({
1819
+ label: String(r[0]),
1820
+ value: Number(r[1]),
1821
+ color: CHART_COLORS[i % CHART_COLORS.length],
1822
+ }));
1823
+ return { segments, total: segments.reduce((s, g) => s + g.value, 0) };
1824
+ }
1825
+ function parseScatter(data) {
1826
+ return data.rows.map(r => ({
1827
+ label: String(r[0]), x: Number(r[1]), y: Number(r[2]),
1828
+ }));
1829
+ }
1830
+ // ── Value → pixel mappers ──────────────────────────────────
1831
+ function makeValueToY(allValues, py, ph) {
1832
+ const lo = Math.min(0, ...allValues);
1833
+ const hi = Math.max(...allValues);
1834
+ const range = hi - lo || 1;
1835
+ return (v) => py + ph - ((v - lo) / range) * ph;
1836
+ }
1837
+ function makeValueToX(allValues, px, pw) {
1838
+ const lo = Math.min(...allValues);
1839
+ const hi = Math.max(...allValues);
1840
+ const range = hi - lo || 1;
1841
+ return (v) => px + ((v - lo) / range) * pw;
1842
+ }
1843
+ /** Nice round tick values for a Y axis. */
1844
+ function yTicks(allValues) {
1845
+ const lo = Math.min(0, ...allValues);
1846
+ const hi = Math.max(...allValues);
1847
+ const rng = hi - lo || 1;
1848
+ const mag = Math.pow(10, Math.floor(Math.log10(rng)));
1849
+ const step = rng / mag > 5 ? mag * 2 : rng / mag > 2 ? mag : mag / 2;
1850
+ const ticks = [];
1851
+ for (let v = Math.ceil(lo / step) * step; v <= hi + step * 0.01; v += step) {
1852
+ ticks.push(Math.round(v * 1e6) / 1e6);
1853
+ }
1854
+ return ticks;
1855
+ }
1856
+ // ── SVG arc path helpers ───────────────────────────────────
1857
+ function pieArcPath(cx, cy, r, startAngle, endAngle) {
1858
+ const x1 = cx + r * Math.cos(startAngle), y1 = cy + r * Math.sin(startAngle);
1859
+ const x2 = cx + r * Math.cos(endAngle), y2 = cy + r * Math.sin(endAngle);
1860
+ const lg = endAngle - startAngle > Math.PI ? 1 : 0;
1861
+ return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${lg} 1 ${x2} ${y2} Z`;
1862
+ }
1863
+ function donutArcPath(cx, cy, r, ir, startAngle, endAngle) {
1864
+ const x1 = cx + r * Math.cos(startAngle), y1 = cy + r * Math.sin(startAngle);
1865
+ const x2 = cx + r * Math.cos(endAngle), y2 = cy + r * Math.sin(endAngle);
1866
+ const ix1 = cx + ir * Math.cos(endAngle), iy1 = cy + ir * Math.sin(endAngle);
1867
+ const ix2 = cx + ir * Math.cos(startAngle), iy2 = cy + ir * Math.sin(startAngle);
1868
+ const lg = endAngle - startAngle > Math.PI ? 1 : 0;
1869
+ return (`M ${x1} ${y1} A ${r} ${r} 0 ${lg} 1 ${x2} ${y2} ` +
1870
+ `L ${ix1} ${iy1} A ${ir} ${ir} 0 ${lg} 0 ${ix2} ${iy2} Z`);
1871
+ }
1872
+
1873
+ // ============================================================
1874
+ // sketchmark — SVG Rough Chart Drawing
1875
+ // Drop this file as src/renderer/svg/roughChartSVG.ts
1876
+ // and import renderRoughChartSVG into svg/index.ts.
1877
+ //
1878
+ // CHANGES TO svg/index.ts:
1879
+ // 1. Remove the entire `const CL = mkGroup("chart-layer")` block
1880
+ // 2. Add import at the top:
1881
+ // import { renderRoughChartSVG } from './roughChartSVG';
1882
+ // 3. Replace removed block with:
1883
+ // const CL = mkGroup('chart-layer');
1884
+ // for (const c of sg.charts) CL.appendChild(renderRoughChartSVG(rc, c, palette, isDark));
1885
+ // svg.appendChild(CL);
1886
+ //
1887
+ // Also remove the Chart.js `declare const Chart: any;` at the top of svg/index.ts
1888
+ // and the CHART_COLORS array (they live in roughChart.ts now).
1889
+ // ============================================================
1890
+ const NS$1 = 'http://www.w3.org/2000/svg';
1891
+ const se$1 = (tag) => document.createElementNS(NS$1, tag);
1892
+ function mkG(id, cls) {
1893
+ const g = se$1('g');
1894
+ if (id)
1895
+ g.setAttribute('id', id);
1896
+ g.setAttribute('class', cls);
1897
+ return g;
1898
+ }
1899
+ function mkT(txt, x, y, sz = 10, wt = 400, col = '#4a2e10', anchor = 'middle') {
1900
+ const t = se$1('text');
1901
+ t.setAttribute('x', String(x));
1902
+ t.setAttribute('y', String(y));
1903
+ t.setAttribute('text-anchor', anchor);
1904
+ t.setAttribute('dominant-baseline', 'middle');
1905
+ t.setAttribute('font-family', 'system-ui, sans-serif');
1906
+ t.setAttribute('font-size', String(sz));
1907
+ t.setAttribute('font-weight', String(wt));
1908
+ t.setAttribute('fill', col);
1909
+ t.setAttribute('pointer-events', 'none');
1910
+ t.textContent = txt;
1911
+ return t;
1912
+ }
1913
+ function hashStr$4(s) {
1914
+ let h = 5381;
1915
+ for (let i = 0; i < s.length; i++)
1916
+ h = ((h * 33) ^ s.charCodeAt(i)) & 0xffff;
1917
+ return h;
1918
+ }
1919
+ const BASE = { roughness: 1.2, bowing: 0.7 };
1920
+ // ── Axes ───────────────────────────────────────────────────
1921
+ function drawAxes$1(rc, g, c, px, py, pw, ph, allY, labelCol) {
1922
+ // Y axis
1923
+ g.appendChild(rc.line(px, py, px, py + ph, {
1924
+ roughness: 0.4, seed: hashStr$4(c.id + 'ya'), stroke: labelCol, strokeWidth: 1,
1925
+ }));
1926
+ // X axis (baseline)
1927
+ const baseline = makeValueToY(allY, py, ph)(0);
1928
+ g.appendChild(rc.line(px, baseline, px + pw, baseline, {
1929
+ roughness: 0.4, seed: hashStr$4(c.id + 'xa'), stroke: labelCol, strokeWidth: 1,
1930
+ }));
1931
+ // Y ticks + labels
1932
+ const toY = makeValueToY(allY, py, ph);
1933
+ for (const tick of yTicks(allY)) {
1934
+ const ty = toY(tick);
1935
+ if (ty < py - 2 || ty > py + ph + 2)
1936
+ continue;
1937
+ g.appendChild(rc.line(px - 3, ty, px, ty, {
1938
+ roughness: 0.2, seed: hashStr$4(c.id + 'yt' + tick), stroke: labelCol, strokeWidth: 0.7,
1939
+ }));
1940
+ g.appendChild(mkT(fmtNum$1(tick), px - 5, ty, 9, 400, labelCol, 'end'));
1941
+ }
1942
+ }
1943
+ function fmtNum$1(v) {
1944
+ if (Math.abs(v) >= 1000)
1945
+ return (v / 1000).toFixed(1) + 'k';
1946
+ return String(v);
1947
+ }
1948
+ // ── Legend row ─────────────────────────────────────────────
1949
+ function legend(g, labels, colors, x, y, labelCol) {
1950
+ labels.forEach((lbl, i) => {
1951
+ const dot = se$1('rect');
1952
+ dot.setAttribute('x', String(x));
1953
+ dot.setAttribute('y', String(y + i * 14));
1954
+ dot.setAttribute('width', '8');
1955
+ dot.setAttribute('height', '8');
1956
+ dot.setAttribute('fill', colors[i % colors.length]);
1957
+ dot.setAttribute('rx', '1');
1958
+ g.appendChild(dot);
1959
+ g.appendChild(mkT(lbl, x + 12, y + i * 14 + 4, 9, 400, labelCol, 'start'));
1960
+ });
1961
+ }
1962
+ // ── Public entry ───────────────────────────────────────────
1963
+ function renderRoughChartSVG(rc, c, palette, isDark) {
1964
+ const cg = mkG(`chart-${c.id}`, 'cg');
1965
+ const s = c.style ?? {};
1966
+ // style/theme props, falling back to palette
1967
+ const bgFill = String(s.fill ?? palette.nodeFill);
1968
+ const bgStroke = String(s.stroke ?? (isDark ? '#5a4a30' : '#c8b898'));
1969
+ const lc = String(s.color ?? palette.titleText);
1970
+ // Background box
1971
+ cg.appendChild(rc.rectangle(c.x, c.y, c.w, c.h, {
1972
+ ...BASE, seed: hashStr$4(c.id),
1973
+ fill: bgFill, fillStyle: 'solid',
1974
+ stroke: bgStroke, strokeWidth: Number(s.strokeWidth ?? 1.2),
1975
+ ...(s.strokeDash ? { strokeLineDash: s.strokeDash } : {}),
1976
+ }));
1977
+ // Title
1978
+ if (c.title) {
1979
+ cg.appendChild(mkT(c.title, c.x + c.w / 2, c.y + 14, 12, 600, lc));
1980
+ }
1981
+ const { px, py, pw, ph, cx, cy } = chartLayout(c);
1982
+ // ── Pie / Donut ──────────────────────────────────────────
1983
+ if (c.chartType === 'pie' || c.chartType === 'donut') {
1984
+ const { segments, total } = parsePie(c.data);
1985
+ const r = Math.min(c.w * 0.38, (c.h - (c.title ? 24 : 8)) * 0.44);
1986
+ const ir = c.chartType === 'donut' ? r * 0.48 : 0;
1987
+ const legendX = c.x + 8;
1988
+ const legendY = c.y + (c.title ? 28 : 12);
1989
+ let angle = -Math.PI / 2;
1990
+ for (const seg of segments) {
1991
+ const sweep = (seg.value / total) * Math.PI * 2;
1992
+ const d = c.chartType === 'donut'
1993
+ ? donutArcPath(cx, cy, r, ir, angle, angle + sweep)
1994
+ : pieArcPath(cx, cy, r, angle, angle + sweep);
1995
+ cg.appendChild(rc.path(d, {
1996
+ roughness: 1.0, bowing: 0.5, seed: hashStr$4(c.id + seg.label),
1997
+ fill: seg.color + 'bb',
1998
+ fillStyle: 'solid',
1999
+ stroke: seg.color,
2000
+ strokeWidth: 1.4,
2001
+ }));
2002
+ angle += sweep;
2003
+ }
2004
+ // Mini legend on left
2005
+ legend(cg, segments.map(s => `${s.label} ${Math.round(s.value / total * 100)}%`), segments.map(s => s.color), legendX, legendY, lc);
2006
+ return cg;
2007
+ }
2008
+ // ── Scatter ───────────────────────────────────────────────
2009
+ if (c.chartType === 'scatter') {
2010
+ const pts = parseScatter(c.data);
2011
+ const xs = pts.map(p => p.x), ys = pts.map(p => p.y);
2012
+ const toX = makeValueToX(xs, px, pw);
2013
+ const toY = makeValueToY(ys, py, ph);
2014
+ // Simple axes (no named ticks — raw data ranges)
2015
+ cg.appendChild(rc.line(px, py, px, py + ph, { roughness: 0.4, seed: hashStr$4(c.id + 'ya'), stroke: lc, strokeWidth: 1 }));
2016
+ cg.appendChild(rc.line(px, py + ph, px + pw, py + ph, { roughness: 0.4, seed: hashStr$4(c.id + 'xa'), stroke: lc, strokeWidth: 1 }));
2017
+ pts.forEach((pt, i) => {
2018
+ cg.appendChild(rc.ellipse(toX(pt.x), toY(pt.y), 10, 10, {
2019
+ roughness: 0.8, seed: hashStr$4(c.id + pt.label),
2020
+ fill: CHART_COLORS[i % CHART_COLORS.length] + '99',
2021
+ fillStyle: 'solid',
2022
+ stroke: CHART_COLORS[i % CHART_COLORS.length],
2023
+ strokeWidth: 1.2,
2024
+ }));
2025
+ });
2026
+ legend(cg, pts.map(p => p.label), CHART_COLORS, c.x + 8, c.y + (c.title ? 28 : 12), lc);
2027
+ return cg;
2028
+ }
2029
+ // ── Bar / Line / Area ─────────────────────────────────────
2030
+ const { labels, series } = parseBarLine(c.data);
2031
+ const allY = series.flatMap(s => s.values);
2032
+ const toY = makeValueToY(allY, py, ph);
2033
+ const baseline = toY(0);
2034
+ const n = labels.length;
2035
+ drawAxes$1(rc, cg, c, px, py, pw, ph, allY, lc);
2036
+ // X labels
2037
+ labels.forEach((lbl, i) => {
2038
+ cg.appendChild(mkT(lbl, px + (i + 0.5) * (pw / n), py + ph + 14, 9, 400, lc));
2039
+ });
2040
+ if (c.chartType === 'bar') {
2041
+ const groupW = pw / n;
2042
+ const m = series.length;
2043
+ const barW = (groupW / m) * 0.72;
2044
+ const slip = (groupW - barW * m) / (m + 1);
2045
+ series.forEach((ser, si) => {
2046
+ ser.values.forEach((val, i) => {
2047
+ const bx = px + i * groupW + slip + si * (barW + slip);
2048
+ const by = Math.min(toY(val), baseline);
2049
+ const bh = Math.abs(baseline - toY(val)) || 2;
2050
+ cg.appendChild(rc.rectangle(bx, by, barW, bh, {
2051
+ roughness: 1.1, bowing: 0.5,
2052
+ seed: hashStr$4(c.id + si + i),
2053
+ fill: ser.color + 'bb',
2054
+ fillStyle: 'hachure',
2055
+ hachureAngle: -41,
2056
+ hachureGap: 4,
2057
+ fillWeight: 0.8,
2058
+ stroke: ser.color,
2059
+ strokeWidth: 1.2,
2060
+ }));
2061
+ });
2062
+ });
2063
+ }
2064
+ else {
2065
+ // line / area — x positions evenly spaced
2066
+ const stepX = n > 1 ? pw / (n - 1) : 0;
2067
+ series.forEach((ser, si) => {
2068
+ const pts = ser.values.map((v, i) => [
2069
+ n > 1 ? px + i * stepX : px + pw / 2,
2070
+ toY(v),
2071
+ ]);
2072
+ // Area fill polygon
2073
+ if (c.chartType === 'area') {
2074
+ const poly = [
2075
+ [pts[0][0], baseline],
2076
+ ...pts,
2077
+ [pts[pts.length - 1][0], baseline],
2078
+ ];
2079
+ cg.appendChild(rc.polygon(poly, {
2080
+ roughness: 0.5, seed: hashStr$4(c.id + 'af' + si),
2081
+ fill: ser.color + '44',
2082
+ fillStyle: 'solid',
2083
+ stroke: 'none',
2084
+ }));
2085
+ }
2086
+ // Line segments
2087
+ for (let i = 0; i < pts.length - 1; i++) {
2088
+ cg.appendChild(rc.line(pts[i][0], pts[i][1], pts[i + 1][0], pts[i + 1][1], {
2089
+ roughness: 0.9, bowing: 0.6,
2090
+ seed: hashStr$4(c.id + si + i),
2091
+ stroke: ser.color,
2092
+ strokeWidth: 1.8,
2093
+ }));
2094
+ }
2095
+ // Point dots
2096
+ pts.forEach(([px2, py2], i) => {
2097
+ cg.appendChild(rc.ellipse(px2, py2, 7, 7, {
2098
+ roughness: 0.3, seed: hashStr$4(c.id + 'dot' + si + i),
2099
+ fill: ser.color,
2100
+ fillStyle: 'solid',
2101
+ stroke: ser.color,
2102
+ strokeWidth: 1,
2103
+ }));
2104
+ });
2105
+ });
2106
+ }
2107
+ // Multi-series legend
2108
+ if (series.length > 1) {
2109
+ legend(cg, series.map(s => s.name), series.map(s => s.color), px, py - 2, lc);
2110
+ }
2111
+ return cg;
2112
+ }
2113
+
2114
+ // ============================================================
2115
+ // sketchmark — Global Theme Palette Library
2116
+ // ============================================================
2117
+ // Usage in DSL:
2118
+ // theme default=ocean ← activates a built-in palette
2119
+ // theme default=dark ← built-in dark mode
2120
+ // config theme=ocean ← alternative syntax
2121
+ //
2122
+ // All palettes follow the same DiagramPalette shape so renderers
2123
+ // only need one code path regardless of which theme is active.
2124
+ // ============================================================
2125
+ // ── Built-in palettes ──────────────────────────────────────
2126
+ const PALETTES = {
2127
+ // ── light (default) ───────────────────────────────────
2128
+ light: {
2129
+ nodeFill: "#fefcf8",
2130
+ nodeStroke: "#2c1c0e",
2131
+ nodeText: "#1a1208",
2132
+ edgeStroke: "#2c1c0e",
2133
+ edgeLabelBg: "#f8f4ea",
2134
+ edgeLabelText: "#4a2e10",
2135
+ groupFill: "#f5f0e8",
2136
+ groupStroke: "#c8a878",
2137
+ groupDash: [7, 5],
2138
+ groupLabel: "#7a5028",
2139
+ tableFill: "#fefcf8",
2140
+ tableStroke: "#c8b898",
2141
+ tableText: "#1a1208",
2142
+ tableHeaderFill: "#f0e8d8",
2143
+ tableHeaderText: "#3a2010",
2144
+ tableDivider: "#d8c8a8",
2145
+ noteFill: "#fffde7",
2146
+ noteStroke: "#f0a500",
2147
+ noteText: "#5a4000",
2148
+ noteFold: "#f0a500",
2149
+ chartFill: "#fefcf8",
2150
+ chartStroke: "#c8b898",
2151
+ chartAxisStroke: "#8a7060",
2152
+ chartText: "#4a2e10",
2153
+ chartTitleText: "#1a1208",
2154
+ background: "#f8f4ea",
2155
+ titleText: "#1a1208",
2156
+ },
2157
+ // ── dark ──────────────────────────────────────────────
2158
+ dark: {
2159
+ nodeFill: "#1e1812",
2160
+ nodeStroke: "#c8a870",
2161
+ nodeText: "#f0dca8",
2162
+ edgeStroke: "#c8a870",
2163
+ edgeLabelBg: "#1e1812",
2164
+ edgeLabelText: "#ddc898",
2165
+ groupFill: "#2a2218",
2166
+ groupStroke: "#6a5030",
2167
+ groupDash: [7, 5],
2168
+ groupLabel: "#c8a060",
2169
+ tableFill: "#1e1812",
2170
+ tableStroke: "#6a5030",
2171
+ tableText: "#f0dca8",
2172
+ tableHeaderFill: "#2e2418",
2173
+ tableHeaderText: "#f5e0a8",
2174
+ tableDivider: "#4a3820",
2175
+ noteFill: "#2a2410",
2176
+ noteStroke: "#c8a060",
2177
+ noteText: "#ddc898",
2178
+ noteFold: "#c8a060",
2179
+ chartFill: "#1e1812",
2180
+ chartStroke: "#6a5030",
2181
+ chartAxisStroke: "#9a8060",
2182
+ chartText: "#ddc898",
2183
+ chartTitleText: "#f0dca8",
2184
+ background: "#12100a",
2185
+ titleText: "#f0dca8",
2186
+ },
2187
+ // ── sketch ─────────────────────────────────────────────
2188
+ sketch: {
2189
+ nodeFill: "#f4f4f2",
2190
+ nodeStroke: "#2e2e2e",
2191
+ nodeText: "#1a1a1a",
2192
+ edgeStroke: "#3a3a3a",
2193
+ edgeLabelBg: "#ebebea",
2194
+ edgeLabelText: "#2a2a2a",
2195
+ groupFill: "#eeeeec",
2196
+ groupStroke: "#8a8a88",
2197
+ groupDash: [6, 4],
2198
+ groupLabel: "#4a4a48",
2199
+ tableFill: "#f7f7f5",
2200
+ tableStroke: "#9a9a98",
2201
+ tableText: "#1a1a1a",
2202
+ tableHeaderFill: "#dededc",
2203
+ tableHeaderText: "#111111",
2204
+ tableDivider: "#c4c4c2",
2205
+ noteFill: "#f5f5f0",
2206
+ noteStroke: "#6a6a68",
2207
+ noteText: "#2a2a2a",
2208
+ noteFold: "#8a8a88",
2209
+ chartFill: "#f4f4f2",
2210
+ chartStroke: "#9a9a98",
2211
+ chartAxisStroke: "#5a5a58",
2212
+ chartText: "#2a2a2a",
2213
+ chartTitleText: "#111111",
2214
+ background: "#f0f0ee",
2215
+ titleText: "#111111",
2216
+ },
2217
+ // ── ocean ─────────────────────────────────────────────
2218
+ ocean: {
2219
+ nodeFill: "#e8f4ff",
2220
+ nodeStroke: "#0044cc",
2221
+ nodeText: "#003399",
2222
+ edgeStroke: "#0055cc",
2223
+ edgeLabelBg: "#d0e8ff",
2224
+ edgeLabelText: "#003388",
2225
+ groupFill: "#ddeeff",
2226
+ groupStroke: "#4488dd",
2227
+ groupDash: [7, 5],
2228
+ groupLabel: "#0044aa",
2229
+ tableFill: "#e8f4ff",
2230
+ tableStroke: "#4488dd",
2231
+ tableText: "#003399",
2232
+ tableHeaderFill: "#cce0ff",
2233
+ tableHeaderText: "#002288",
2234
+ tableDivider: "#88bbee",
2235
+ noteFill: "#e0f0ff",
2236
+ noteStroke: "#0066cc",
2237
+ noteText: "#003388",
2238
+ noteFold: "#0066cc",
2239
+ chartFill: "#e8f4ff",
2240
+ chartStroke: "#4488dd",
2241
+ chartAxisStroke: "#336699",
2242
+ chartText: "#003388",
2243
+ chartTitleText: "#002277",
2244
+ background: "#f0f8ff",
2245
+ titleText: "#002277",
2246
+ },
2247
+ // ── forest ────────────────────────────────────────────
2248
+ forest: {
2249
+ nodeFill: "#e8ffe8",
2250
+ nodeStroke: "#007700",
2251
+ nodeText: "#004400",
2252
+ edgeStroke: "#228822",
2253
+ edgeLabelBg: "#d0f0d0",
2254
+ edgeLabelText: "#004400",
2255
+ groupFill: "#d8f0d8",
2256
+ groupStroke: "#44aa44",
2257
+ groupDash: [7, 5],
2258
+ groupLabel: "#005500",
2259
+ tableFill: "#e8ffe8",
2260
+ tableStroke: "#44aa44",
2261
+ tableText: "#004400",
2262
+ tableHeaderFill: "#c8eec8",
2263
+ tableHeaderText: "#003300",
2264
+ tableDivider: "#88cc88",
2265
+ noteFill: "#e0ffe0",
2266
+ noteStroke: "#009900",
2267
+ noteText: "#004400",
2268
+ noteFold: "#009900",
2269
+ chartFill: "#e8ffe8",
2270
+ chartStroke: "#44aa44",
2271
+ chartAxisStroke: "#336633",
2272
+ chartText: "#004400",
2273
+ chartTitleText: "#003300",
2274
+ background: "#f0fff0",
2275
+ titleText: "#003300",
2276
+ },
2277
+ // ── sunset ────────────────────────────────────────────
2278
+ sunset: {
2279
+ nodeFill: "#fff0e8",
2280
+ nodeStroke: "#c85428",
2281
+ nodeText: "#7a2800",
2282
+ edgeStroke: "#c85428",
2283
+ edgeLabelBg: "#ffe0cc",
2284
+ edgeLabelText: "#7a2800",
2285
+ groupFill: "#ffe8d8",
2286
+ groupStroke: "#e07040",
2287
+ groupDash: [7, 5],
2288
+ groupLabel: "#883300",
2289
+ tableFill: "#fff0e8",
2290
+ tableStroke: "#e07040",
2291
+ tableText: "#7a2800",
2292
+ tableHeaderFill: "#ffd8c0",
2293
+ tableHeaderText: "#661800",
2294
+ tableDivider: "#e8a888",
2295
+ noteFill: "#fff0d8",
2296
+ noteStroke: "#e07040",
2297
+ noteText: "#7a2800",
2298
+ noteFold: "#e07040",
2299
+ chartFill: "#fff0e8",
2300
+ chartStroke: "#e07040",
2301
+ chartAxisStroke: "#aa5530",
2302
+ chartText: "#7a2800",
2303
+ chartTitleText: "#661800",
2304
+ background: "#fff8f0",
2305
+ titleText: "#661800",
2306
+ },
2307
+ // ── slate ─────────────────────────────────────────────
2308
+ slate: {
2309
+ nodeFill: "#f0f2f5",
2310
+ nodeStroke: "#4a5568",
2311
+ nodeText: "#1a202c",
2312
+ edgeStroke: "#4a5568",
2313
+ edgeLabelBg: "#e2e8f0",
2314
+ edgeLabelText: "#2d3748",
2315
+ groupFill: "#e2e8f0",
2316
+ groupStroke: "#718096",
2317
+ groupDash: [7, 5],
2318
+ groupLabel: "#2d3748",
2319
+ tableFill: "#f0f2f5",
2320
+ tableStroke: "#718096",
2321
+ tableText: "#1a202c",
2322
+ tableHeaderFill: "#e2e8f0",
2323
+ tableHeaderText: "#1a202c",
2324
+ tableDivider: "#a0aec0",
2325
+ noteFill: "#fefcbf",
2326
+ noteStroke: "#d69e2e",
2327
+ noteText: "#744210",
2328
+ noteFold: "#d69e2e",
2329
+ chartFill: "#f0f2f5",
2330
+ chartStroke: "#718096",
2331
+ chartAxisStroke: "#4a5568",
2332
+ chartText: "#2d3748",
2333
+ chartTitleText: "#1a202c",
2334
+ background: "#edf2f7",
2335
+ titleText: "#1a202c",
2336
+ },
2337
+ // ── rose ──────────────────────────────────────────────
2338
+ rose: {
2339
+ nodeFill: "#fff0f3",
2340
+ nodeStroke: "#cc3355",
2341
+ nodeText: "#7a0022",
2342
+ edgeStroke: "#cc3355",
2343
+ edgeLabelBg: "#ffd0da",
2344
+ edgeLabelText: "#7a0022",
2345
+ groupFill: "#ffe0e8",
2346
+ groupStroke: "#dd5577",
2347
+ groupDash: [7, 5],
2348
+ groupLabel: "#880033",
2349
+ tableFill: "#fff0f3",
2350
+ tableStroke: "#dd5577",
2351
+ tableText: "#7a0022",
2352
+ tableHeaderFill: "#ffd0da",
2353
+ tableHeaderText: "#660022",
2354
+ tableDivider: "#eea0b0",
2355
+ noteFill: "#fff0f3",
2356
+ noteStroke: "#cc3355",
2357
+ noteText: "#7a0022",
2358
+ noteFold: "#cc3355",
2359
+ chartFill: "#fff0f3",
2360
+ chartStroke: "#dd5577",
2361
+ chartAxisStroke: "#aa3355",
2362
+ chartText: "#7a0022",
2363
+ chartTitleText: "#660022",
2364
+ background: "#fff5f7",
2365
+ titleText: "#660022",
2366
+ },
2367
+ // ── midnight ──────────────────────────────────────────
2368
+ midnight: {
2369
+ nodeFill: "#0d1117",
2370
+ nodeStroke: "#58a6ff",
2371
+ nodeText: "#c9d1d9",
2372
+ edgeStroke: "#58a6ff",
2373
+ edgeLabelBg: "#161b22",
2374
+ edgeLabelText: "#c9d1d9",
2375
+ groupFill: "#161b22",
2376
+ groupStroke: "#30363d",
2377
+ groupDash: [7, 5],
2378
+ groupLabel: "#8b949e",
2379
+ tableFill: "#0d1117",
2380
+ tableStroke: "#30363d",
2381
+ tableText: "#c9d1d9",
2382
+ tableHeaderFill: "#161b22",
2383
+ tableHeaderText: "#e6edf3",
2384
+ tableDivider: "#30363d",
2385
+ noteFill: "#161b22",
2386
+ noteStroke: "#58a6ff",
2387
+ noteText: "#c9d1d9",
2388
+ noteFold: "#58a6ff",
2389
+ chartFill: "#0d1117",
2390
+ chartStroke: "#30363d",
2391
+ chartAxisStroke: "#8b949e",
2392
+ chartText: "#c9d1d9",
2393
+ chartTitleText: "#e6edf3",
2394
+ background: "#010409",
2395
+ titleText: "#e6edf3",
2396
+ },
2397
+ };
2398
+ // ── Palette resolver ───────────────────────────────────────
2399
+ function resolvePalette(name) {
2400
+ if (!name)
2401
+ return PALETTES.light;
2402
+ return PALETTES[name] ?? PALETTES.light;
2403
+ }
2404
+ // ── DSL config key that activates a palette ────────────────
2405
+ // Usage in DSL: config theme=ocean
2406
+ const THEME_CONFIG_KEY = "theme";
2407
+ function listThemes() {
2408
+ return Object.keys(PALETTES);
2409
+ }
2410
+ const THEME_NAMES = Object.keys(PALETTES);
2411
+
2412
+ // ============================================================
2413
+ // sketchmark — SVG Renderer (rough.js hand-drawn)
2414
+ // ============================================================
2415
+ const NS = "http://www.w3.org/2000/svg";
2416
+ const se = (tag) => document.createElementNS(NS, tag);
2417
+ function hashStr$3(s) {
2418
+ let h = 5381;
2419
+ for (let i = 0; i < s.length; i++)
2420
+ h = ((h * 33) ^ s.charCodeAt(i)) & 0xffff;
2421
+ return h;
2422
+ }
2423
+ const BASE_ROUGH = { roughness: 1.3, bowing: 0.7 };
2424
+ // ── SVG helpers ───────────────────────────────────────────
2425
+ function mkMultilineText(lines, x, cy, // vertical center of the whole block
2426
+ sz = 14, wt = 500, col = "#1a1208", anchor = "middle", lineH = 18) {
2427
+ const t = se("text");
2428
+ t.setAttribute("text-anchor", anchor);
2429
+ t.setAttribute("font-family", "var(--font-sans, system-ui, sans-serif)");
2430
+ t.setAttribute("font-size", String(sz));
2431
+ t.setAttribute("font-weight", String(wt));
2432
+ t.setAttribute("fill", col);
2433
+ t.setAttribute("pointer-events", "none");
2434
+ t.setAttribute("user-select", "none");
2435
+ // vertically centre the whole block
2436
+ const totalH = (lines.length - 1) * lineH;
2437
+ const startY = cy - totalH / 2;
2438
+ lines.forEach((line, i) => {
2439
+ const ts = se("tspan");
2440
+ ts.setAttribute("x", String(x));
2441
+ ts.setAttribute("y", String(startY + i * lineH));
2442
+ ts.setAttribute("dominant-baseline", "middle");
2443
+ ts.textContent = line;
2444
+ t.appendChild(ts);
2445
+ });
2446
+ return t;
2447
+ }
2448
+ function mkText(txt, x, y, sz = 14, wt = 500, col = "#1a1208", anchor = "middle") {
2449
+ const t = se("text");
2450
+ t.setAttribute("x", String(x));
2451
+ t.setAttribute("y", String(y));
2452
+ t.setAttribute("text-anchor", anchor);
2453
+ t.setAttribute("dominant-baseline", "middle");
2454
+ t.setAttribute("font-family", "var(--font-sans, system-ui, sans-serif)");
2455
+ t.setAttribute("font-size", String(sz));
2456
+ t.setAttribute("font-weight", String(wt));
2457
+ t.setAttribute("fill", col);
2458
+ t.setAttribute("pointer-events", "none");
2459
+ t.setAttribute("user-select", "none");
2460
+ t.textContent = txt;
2461
+ return t;
2462
+ }
2463
+ function mkGroup(id, cls) {
2464
+ const g = se("g");
2465
+ if (id)
2466
+ g.setAttribute("id", id);
2467
+ if (cls)
2468
+ g.setAttribute("class", cls);
2469
+ return g;
2470
+ }
2471
+ // ── Arrow direction from connector ────────────────────────
2472
+ function connMeta$1(connector) {
2473
+ if (connector === "--")
2474
+ return { arrowAt: "none", dashed: false };
2475
+ if (connector === "---")
2476
+ return { arrowAt: "none", dashed: true };
2477
+ const bidir = connector.includes("<") && connector.includes(">");
2478
+ if (bidir)
2479
+ return { arrowAt: "both", dashed: connector.includes("--") };
2480
+ const back = connector.startsWith("<");
2481
+ const dashed = connector.includes("--");
2482
+ if (back)
2483
+ return { arrowAt: "start", dashed };
2484
+ return { arrowAt: "end", dashed };
2485
+ }
2486
+ // ── Generic rect connection point ─────────────────────────
2487
+ function rectConnPoint$1(rx, ry, rw, rh, ox, oy) {
2488
+ const cx = rx + rw / 2, cy = ry + rh / 2;
2489
+ const dx = ox - cx, dy = oy - cy;
2490
+ if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01)
2491
+ return [cx, cy];
2492
+ const hw = rw / 2 - 2, hh = rh / 2 - 2;
2493
+ const tx = Math.abs(dx) > 0.01 ? hw / Math.abs(dx) : 1e9;
2494
+ const ty = Math.abs(dy) > 0.01 ? hh / Math.abs(dy) : 1e9;
2495
+ const t = Math.min(tx, ty);
2496
+ return [cx + t * dx, cy + t * dy];
2497
+ }
2498
+ function resolveEndpoint$1(id, nm, tm, gm, cm, ntm) {
2499
+ return (nm.get(id) ?? tm.get(id) ?? gm.get(id) ?? cm.get(id) ?? ntm.get(id) ?? null);
2500
+ }
2501
+ function getConnPoint$1(src, dstCX, dstCY) {
2502
+ if ("shape" in src && src.shape) {
2503
+ return connPoint(src, {
2504
+ x: dstCX - 1,
2505
+ y: dstCY - 1,
2506
+ w: 2,
2507
+ h: 2});
2508
+ }
2509
+ return rectConnPoint$1(src.x, src.y, src.w, src.h, dstCX, dstCY);
2510
+ }
2511
+ // ── Group depth (for paint order) ─────────────────────────
2512
+ function groupDepth$1(g, gm) {
2513
+ let d = 0;
2514
+ let cur = g;
2515
+ while (cur?.parentId) {
2516
+ d++;
2517
+ cur = gm.get(cur.parentId);
2518
+ }
2519
+ return d;
2520
+ }
2521
+ // ── Node shapes ───────────────────────────────────────────
2522
+ function renderShape$1(rc, n, palette) {
2523
+ const s = n.style ?? {};
2524
+ const fill = String(s.fill ?? palette.nodeFill);
2525
+ const stroke = String(s.stroke ?? palette.nodeStroke);
2526
+ const opts = {
2527
+ ...BASE_ROUGH,
2528
+ seed: hashStr$3(n.id),
2529
+ fill,
2530
+ fillStyle: "solid",
2531
+ stroke,
2532
+ strokeWidth: Number(s.strokeWidth ?? 1.9),
2533
+ };
2534
+ const cx = n.x + n.w / 2, cy = n.y + n.h / 2;
2535
+ const hw = n.w / 2 - 2;
2536
+ switch (n.shape) {
2537
+ case "circle":
2538
+ return [rc.ellipse(cx, cy, n.w * 0.88, n.h * 0.88, opts)];
2539
+ case "diamond":
2540
+ return [
2541
+ rc.polygon([
2542
+ [cx, n.y + 2],
2543
+ [cx + hw, cy],
2544
+ [cx, n.y + n.h - 2],
2545
+ [cx - hw, cy],
2546
+ ], opts),
2547
+ ];
2548
+ case "hexagon": {
2549
+ const hw2 = hw * 0.56;
2550
+ return [
2551
+ rc.polygon([
2552
+ [cx - hw2, n.y + 3],
2553
+ [cx + hw2, n.y + 3],
2554
+ [cx + hw, cy],
2555
+ [cx + hw2, n.y + n.h - 3],
2556
+ [cx - hw2, n.y + n.h - 3],
2557
+ [cx - hw, cy],
2558
+ ], opts),
2559
+ ];
2560
+ }
2561
+ case "triangle":
2562
+ return [
2563
+ rc.polygon([
2564
+ [cx, n.y + 3],
2565
+ [n.x + n.w - 3, n.y + n.h - 3],
2566
+ [n.x + 3, n.y + n.h - 3],
2567
+ ], opts),
2568
+ ];
2569
+ case "parallelogram":
2570
+ return [
2571
+ rc.polygon([
2572
+ [n.x + 18, n.y + 1],
2573
+ [n.x + n.w - 1, n.y + 1],
2574
+ [n.x + n.w - 18, n.y + n.h - 1],
2575
+ [n.x + 1, n.y + n.h - 1],
2576
+ ], opts),
2577
+ ];
2578
+ case "cylinder": {
2579
+ const eH = 18;
2580
+ return [
2581
+ rc.rectangle(n.x + 3, n.y + eH / 2, n.w - 6, n.h - eH, opts),
2582
+ rc.ellipse(cx, n.y + eH / 2, n.w - 8, eH, { ...opts, roughness: 0.6 }),
2583
+ rc.ellipse(cx, n.y + n.h - eH / 2, n.w - 8, eH, {
2584
+ ...opts,
2585
+ roughness: 0.6,
2586
+ fill: "none",
2587
+ }),
2588
+ ];
2589
+ }
2590
+ case "text":
2591
+ return [];
2592
+ case "image": {
2593
+ if (n.imageUrl) {
2594
+ const img = document.createElementNS("http://www.w3.org/2000/svg", "image");
2595
+ img.setAttribute("href", n.imageUrl);
2596
+ img.setAttribute("x", String(n.x + 1));
2597
+ img.setAttribute("y", String(n.y + 1));
2598
+ img.setAttribute("width", String(n.w - 2));
2599
+ img.setAttribute("height", String(n.h - 2));
2600
+ img.setAttribute("preserveAspectRatio", "xMidYMid meet");
2601
+ // optional: clip to rounded rect
2602
+ const clipId = `clip-${n.id}`;
2603
+ const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
2604
+ const clip = document.createElementNS("http://www.w3.org/2000/svg", "clipPath");
2605
+ clip.setAttribute("id", clipId);
2606
+ const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
2607
+ rect.setAttribute("x", String(n.x + 1));
2608
+ rect.setAttribute("y", String(n.y + 1));
2609
+ rect.setAttribute("width", String(n.w - 2));
2610
+ rect.setAttribute("height", String(n.h - 2));
2611
+ rect.setAttribute("rx", "6");
2612
+ clip.appendChild(rect);
2613
+ defs.appendChild(clip);
2614
+ img.setAttribute("clip-path", `url(#${clipId})`);
2615
+ // border box drawn on top
2616
+ const border = rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, {
2617
+ ...opts,
2618
+ fill: "none",
2619
+ fillStyle: "solid",
2620
+ });
2621
+ return [defs, img, border];
2622
+ }
2623
+ // fallback: no URL → grey placeholder box
2624
+ return [
2625
+ rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, {
2626
+ ...opts,
2627
+ fill: "#e0e0e0",
2628
+ stroke: "#999999",
2629
+ }),
2630
+ ];
2631
+ }
2632
+ default:
2633
+ return [rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, opts)];
2634
+ }
2635
+ }
2636
+ // ── Arrowhead ─────────────────────────────────────────────
2637
+ function arrowHead(rc, x, y, angle, col, seed) {
2638
+ const as = 12;
2639
+ return rc.polygon([
2640
+ [x, y],
2641
+ [
2642
+ x - as * Math.cos(angle - Math.PI / 6.5),
2643
+ y - as * Math.sin(angle - Math.PI / 6.5),
2644
+ ],
2645
+ [
2646
+ x - as * Math.cos(angle + Math.PI / 6.5),
2647
+ y - as * Math.sin(angle + Math.PI / 6.5),
2648
+ ],
2649
+ ], {
2650
+ roughness: 0.35,
2651
+ seed,
2652
+ fill: col,
2653
+ fillStyle: "solid",
2654
+ stroke: col,
2655
+ strokeWidth: 0.8,
2656
+ });
2657
+ }
2658
+ function renderToSVG(sg, container, options = {}) {
2659
+ if (typeof rough === "undefined") {
2660
+ throw new Error('rough.js is not loaded. Add <script src="https://unpkg.com/roughjs/bundled/rough.js"></script>');
2661
+ }
2662
+ const isDark = options.theme === "dark" ||
2663
+ (options.theme === "auto" &&
2664
+ window.matchMedia?.("(prefers-color-scheme:dark)").matches);
2665
+ // Resolve palette: DSL config takes priority, then options.theme, then light
2666
+ const themeName = String(sg.config[THEME_CONFIG_KEY] ?? (isDark ? "dark" : "light"));
2667
+ const palette = resolvePalette(themeName);
2668
+ BASE_ROUGH.roughness = options.roughness ?? 1.3;
2669
+ BASE_ROUGH.bowing = options.bowing ?? 0.7;
2670
+ let svg;
2671
+ if (container instanceof SVGSVGElement) {
2672
+ svg = container;
2673
+ }
2674
+ else {
2675
+ svg = se("svg");
2676
+ container.appendChild(svg);
2677
+ }
2678
+ svg.innerHTML = "";
2679
+ svg.setAttribute("xmlns", NS);
2680
+ svg.setAttribute("width", String(sg.width));
2681
+ svg.setAttribute("height", String(sg.height));
2682
+ svg.setAttribute("viewBox", `0 0 ${sg.width} ${sg.height}`);
2683
+ svg.style.fontFamily = "var(--font-sans, system-ui, sans-serif)";
2684
+ // Background rect so exported SVGs have correct bg
2685
+ // const bgRect = se("rect") as SVGRectElement;
2686
+ // bgRect.setAttribute("x", "0");
2687
+ // bgRect.setAttribute("y", "0");
2688
+ // bgRect.setAttribute("width", String(sg.width));
2689
+ // bgRect.setAttribute("height", String(sg.height));
2690
+ // bgRect.setAttribute("fill", palette.background);
2691
+ // svg.appendChild(bgRect);
2692
+ if (!options.transparent) {
2693
+ const bgRect = se("rect");
2694
+ bgRect.setAttribute("x", "0");
2695
+ bgRect.setAttribute("y", "0");
2696
+ bgRect.setAttribute("width", String(sg.width));
2697
+ bgRect.setAttribute("height", String(sg.height));
2698
+ bgRect.setAttribute("fill", palette.background);
2699
+ svg.appendChild(bgRect);
2700
+ }
2701
+ const rc = rough.svg(svg);
2702
+ // ── Title ────────────────────────────────────────────────
2703
+ if (options.showTitle && sg.title) {
2704
+ const titleColor = String(sg.config["title-color"] ?? palette.titleText);
2705
+ const titleSize = Number(sg.config["title-size"] ?? 18);
2706
+ const titleWeight = Number(sg.config["title-weight"] ?? 600);
2707
+ svg.appendChild(mkText(sg.title, sg.width / 2, 26, titleSize, titleWeight, titleColor));
2708
+ }
2709
+ // ── Groups (depth-sorted: outermost first) ────────────────
2710
+ const gmMap = new Map(sg.groups.map((g) => [g.id, g]));
2711
+ const sortedGroups = [...sg.groups].sort((a, b) => groupDepth$1(a, gmMap) - groupDepth$1(b, gmMap));
2712
+ const GL = mkGroup("grp-layer");
2713
+ for (const g of sortedGroups) {
2714
+ if (!g.w)
2715
+ continue;
2716
+ const gs = g.style ?? {};
2717
+ const gg = mkGroup(`group-${g.id}`, "gg");
2718
+ gg.appendChild(rc.rectangle(g.x, g.y, g.w, g.h, {
2719
+ ...BASE_ROUGH,
2720
+ roughness: 1.7,
2721
+ bowing: 0.4,
2722
+ seed: hashStr$3(g.id),
2723
+ fill: String(gs.fill ?? palette.groupFill),
2724
+ fillStyle: "solid",
2725
+ stroke: String(gs.stroke ?? palette.groupStroke),
2726
+ strokeWidth: Number(gs.strokeWidth ?? 1.2),
2727
+ strokeLineDash: gs.strokeDash ?? palette.groupDash,
2728
+ }));
2729
+ const labelColor = gs.color ? String(gs.color) : palette.groupLabel;
2730
+ gg.appendChild(mkText(g.label, g.x + 14, g.y + 14, 12, 500, labelColor, "start"));
2731
+ GL.appendChild(gg);
2732
+ }
2733
+ svg.appendChild(GL);
2734
+ // ── Edges ─────────────────────────────────────────────────
2735
+ const nm = nodeMap(sg);
2736
+ const tm = tableMap(sg);
2737
+ const cm = chartMap(sg);
2738
+ const ntm = noteMap(sg);
2739
+ const EL = mkGroup("edge-layer");
2740
+ for (const e of sg.edges) {
2741
+ const src = resolveEndpoint$1(e.from, nm, tm, gmMap, cm, ntm);
2742
+ const dst = resolveEndpoint$1(e.to, nm, tm, gmMap, cm, ntm);
2743
+ if (!src || !dst)
2744
+ continue;
2745
+ const dstCX = dst.x + dst.w / 2, dstCY = dst.y + dst.h / 2;
2746
+ const srcCX = src.x + src.w / 2, srcCY = src.y + src.h / 2;
2747
+ const [x1, y1] = getConnPoint$1(src, dstCX, dstCY);
2748
+ const [x2, y2] = getConnPoint$1(dst, srcCX, srcCY);
2749
+ const eg = mkGroup(`edge-${e.from}-${e.to}`, "eg");
2750
+ const len = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) || 1;
2751
+ const nx = (x2 - x1) / len, ny = (y2 - y1) / len;
2752
+ const ecol = String(e.style?.stroke ?? palette.edgeStroke);
2753
+ const { arrowAt, dashed } = connMeta$1(e.connector);
2754
+ const HEAD = 13;
2755
+ const sx1 = arrowAt === "start" || arrowAt === "both" ? x1 + nx * HEAD : x1;
2756
+ const sy1 = arrowAt === "start" || arrowAt === "both" ? y1 + ny * HEAD : y1;
2757
+ const sx2 = arrowAt === "end" || arrowAt === "both" ? x2 - nx * HEAD : x2;
2758
+ const sy2 = arrowAt === "end" || arrowAt === "both" ? y2 - ny * HEAD : y2;
2759
+ eg.appendChild(rc.line(sx1, sy1, sx2, sy2, {
2760
+ ...BASE_ROUGH,
2761
+ roughness: 0.9,
2762
+ seed: hashStr$3(e.from + e.to),
2763
+ stroke: ecol,
2764
+ strokeWidth: Number(e.style?.strokeWidth ?? 1.6),
2765
+ ...(dashed ? { strokeLineDash: [6, 5] } : {}),
2766
+ }));
2767
+ if (arrowAt === "end" || arrowAt === "both")
2768
+ eg.appendChild(arrowHead(rc, x2, y2, Math.atan2(y2 - y1, x2 - x1), ecol, hashStr$3(e.to)));
2769
+ if (arrowAt === "start" || arrowAt === "both")
2770
+ eg.appendChild(arrowHead(rc, x1, y1, Math.atan2(y1 - y2, x1 - x2), ecol, hashStr$3(e.from + "back")));
2771
+ if (e.label) {
2772
+ const mx = (x1 + x2) / 2 - ny * 14;
2773
+ const my = (y1 + y2) / 2 + nx * 14;
2774
+ const tw = Math.max(e.label.length * 7 + 12, 36);
2775
+ const bg = se("rect");
2776
+ bg.setAttribute("x", String(mx - tw / 2));
2777
+ bg.setAttribute("y", String(my - 8));
2778
+ bg.setAttribute("width", String(tw));
2779
+ bg.setAttribute("height", "15");
2780
+ bg.setAttribute("fill", palette.edgeLabelBg);
2781
+ bg.setAttribute("rx", "3");
2782
+ bg.setAttribute("opacity", "0.9");
2783
+ eg.appendChild(bg);
2784
+ eg.appendChild(mkText(e.label, mx, my, 11, 400, palette.edgeLabelText));
2785
+ }
2786
+ EL.appendChild(eg);
2787
+ }
2788
+ svg.appendChild(EL);
2789
+ // ── Nodes ─────────────────────────────────────────────────
2790
+ const NL = mkGroup("node-layer");
2791
+ for (const n of sg.nodes) {
2792
+ const ng = mkGroup(`node-${n.id}`, "ng");
2793
+ renderShape$1(rc, n, palette).forEach((s) => ng.appendChild(s));
2794
+ const fontSize = Number(n.style?.fontSize ?? (n.shape === "text" ? 13 : 14));
2795
+ const fontWeight = n.style?.fontWeight ?? (n.shape === "text" ? 400 : 500);
2796
+ const lines = n.label.split("\n");
2797
+ ng.appendChild(lines.length > 1
2798
+ ? mkMultilineText(lines, n.x + n.w / 2, n.y + n.h / 2, fontSize, fontWeight, String(n.style?.color ??
2799
+ (n.shape === "text" ? palette.edgeLabelText : palette.nodeText)))
2800
+ : mkText(n.label, n.x + n.w / 2, n.y + n.h / 2, fontSize, fontWeight, String(n.style?.color ??
2801
+ (n.shape === "text" ? palette.edgeLabelText : palette.nodeText))));
2802
+ if (options.interactive) {
2803
+ ng.style.cursor = "pointer";
2804
+ ng.addEventListener("click", () => options.onNodeClick?.(n.id));
2805
+ ng.addEventListener("mouseenter", () => {
2806
+ ng.style.filter = "brightness(0.92)";
2807
+ });
2808
+ ng.addEventListener("mouseleave", () => {
2809
+ ng.style.filter = "";
2810
+ });
2811
+ }
2812
+ NL.appendChild(ng);
2813
+ }
2814
+ svg.appendChild(NL);
2815
+ // ── Tables ────────────────────────────────────────────────
2816
+ const TL = mkGroup("table-layer");
2817
+ for (const t of sg.tables) {
2818
+ const tg = mkGroup(`table-${t.id}`, "tg");
2819
+ const gs = t.style ?? {};
2820
+ const fill = String(gs.fill ?? palette.tableFill);
2821
+ const strk = String(gs.stroke ?? palette.tableStroke);
2822
+ const textCol = String(gs.color ?? palette.tableText);
2823
+ const hdrFill = palette.tableHeaderFill;
2824
+ const hdrText = String(gs.color ?? palette.tableHeaderText);
2825
+ const divCol = palette.tableDivider;
2826
+ const pad = t.labelH;
2827
+ // Outer border
2828
+ tg.appendChild(rc.rectangle(t.x, t.y, t.w, t.h, {
2829
+ ...BASE_ROUGH,
2830
+ seed: hashStr$3(t.id),
2831
+ fill,
2832
+ fillStyle: "solid",
2833
+ stroke: strk,
2834
+ strokeWidth: 1.5,
2835
+ }));
2836
+ // Label strip separator
2837
+ tg.appendChild(rc.line(t.x, t.y + pad, t.x + t.w, t.y + pad, {
2838
+ roughness: 0.6,
2839
+ seed: hashStr$3(t.id + "l"),
2840
+ stroke: strk,
2841
+ strokeWidth: 1,
2842
+ }));
2843
+ // Label text
2844
+ tg.appendChild(mkText(t.label, t.x + 10, t.y + pad / 2, 12, 500, textCol, "start"));
2845
+ // Rows
2846
+ let rowY = t.y + pad;
2847
+ for (const row of t.rows) {
2848
+ const rh = row.kind === "header" ? t.headerH : t.rowH;
2849
+ // Header background fill
2850
+ if (row.kind === "header") {
2851
+ const hdrBg = se("rect");
2852
+ hdrBg.setAttribute("x", String(t.x + 1));
2853
+ hdrBg.setAttribute("y", String(rowY + 1));
2854
+ hdrBg.setAttribute("width", String(t.w - 2));
2855
+ hdrBg.setAttribute("height", String(rh - 1));
2856
+ hdrBg.setAttribute("fill", hdrFill);
2857
+ tg.appendChild(hdrBg);
2858
+ }
2859
+ // Row separator
2860
+ tg.appendChild(rc.line(t.x, rowY + rh, t.x + t.w, rowY + rh, {
2861
+ roughness: 0.4,
2862
+ seed: hashStr$3(t.id + rowY),
2863
+ stroke: row.kind === "header" ? strk : divCol,
2864
+ strokeWidth: row.kind === "header" ? 1.2 : 0.6,
2865
+ }));
2866
+ // Cell text + col separators
2867
+ let cx = t.x;
2868
+ row.cells.forEach((cell, i) => {
2869
+ const cw = t.colWidths[i] ?? 60;
2870
+ const fw = row.kind === "header" ? 600 : 400;
2871
+ tg.appendChild(mkText(cell, cx + cw / 2, rowY + rh / 2, 12, fw, row.kind === "header" ? hdrText : textCol));
2872
+ if (i < row.cells.length - 1) {
2873
+ tg.appendChild(rc.line(cx + cw, t.y + pad, cx + cw, t.y + t.h, {
2874
+ roughness: 0.3,
2875
+ seed: hashStr$3(t.id + "c" + i),
2876
+ stroke: divCol,
2877
+ strokeWidth: 0.5,
2878
+ }));
2879
+ }
2880
+ cx += cw;
2881
+ });
2882
+ rowY += rh;
2883
+ }
2884
+ if (options.interactive) {
2885
+ tg.style.cursor = "pointer";
2886
+ tg.addEventListener("click", () => options.onNodeClick?.(t.id));
2887
+ }
2888
+ TL.appendChild(tg);
2889
+ }
2890
+ svg.appendChild(TL);
2891
+ // ── Notes ─────────────────────────────────────────────────
2892
+ const NoteL = mkGroup("note-layer");
2893
+ for (const n of sg.notes) {
2894
+ const ng = mkGroup(`note-${n.id}`, "ntg");
2895
+ const gs = n.style ?? {};
2896
+ const fill = String(gs.fill ?? palette.noteFill);
2897
+ const strk = String(gs.stroke ?? palette.noteStroke);
2898
+ const fold = 14;
2899
+ const { x, y, w, h } = n;
2900
+ ng.appendChild(rc.polygon([
2901
+ [x, y],
2902
+ [x + w - fold, y],
2903
+ [x + w, y + fold],
2904
+ [x + w, y + h],
2905
+ [x, y + h],
2906
+ ], {
2907
+ ...BASE_ROUGH,
2908
+ seed: hashStr$3(n.id),
2909
+ fill,
2910
+ fillStyle: "solid",
2911
+ stroke: strk,
2912
+ strokeWidth: 1.2,
2913
+ }));
2914
+ ng.appendChild(rc.polygon([
2915
+ [x + w - fold, y],
2916
+ [x + w, y + fold],
2917
+ [x + w - fold, y + fold],
2918
+ ], {
2919
+ roughness: 0.4,
2920
+ seed: hashStr$3(n.id + "f"),
2921
+ fill: palette.noteFold,
2922
+ fillStyle: "solid",
2923
+ stroke: strk,
2924
+ strokeWidth: 0.8,
2925
+ }));
2926
+ n.lines.forEach((line, i) => {
2927
+ ng.appendChild(mkText(line, x + 12, y + 12 + i * 20 + 10, 12, 400, String(gs.color ?? palette.noteText), "start"));
2928
+ });
2929
+ NoteL.appendChild(ng);
2930
+ }
2931
+ svg.appendChild(NoteL);
2932
+ // ── Charts ────────────────────────────────────────────────
2933
+ const CL = mkGroup("chart-layer");
2934
+ for (const c of sg.charts) {
2935
+ CL.appendChild(renderRoughChartSVG(rc, c, palette, themeName !== "light"));
2936
+ }
2937
+ svg.appendChild(CL);
2938
+ return svg;
2939
+ }
2940
+ function svgToString(svg) {
2941
+ return ('<?xml version="1.0" encoding="utf-8"?>\n' +
2942
+ new XMLSerializer().serializeToString(svg));
2943
+ }
2944
+
2945
+ // ============================================================
2946
+ // sketchmark — Canvas Rough Chart Drawing
2947
+ // Drop this file as src/renderer/canvas/roughChartCanvas.ts
2948
+ // and import drawRoughChartCanvas into canvas/index.ts.
2949
+ //
2950
+ // CHANGES TO canvas/index.ts:
2951
+ // 1. Remove the entire `function drawChart(...)` function
2952
+ // 2. Remove the `declare const Chart: any;` declaration
2953
+ // 3. Remove the CHART_COLORS array (lives in roughChart.ts now)
2954
+ // 4. Add import at the top:
2955
+ // import { drawRoughChartCanvas } from './roughChartCanvas';
2956
+ // 5. In the "── Charts ──" section replace:
2957
+ // for (const c of sg.charts) drawChart(ctx, c, pal);
2958
+ // with:
2959
+ // for (const c of sg.charts) drawRoughChartCanvas(rc, ctx, c, pal, R);
2960
+ // ============================================================
2961
+ function hashStr$2(s) {
2962
+ let h = 5381;
2963
+ for (let i = 0; i < s.length; i++)
2964
+ h = ((h * 33) ^ s.charCodeAt(i)) & 0xffff;
2965
+ return h;
2966
+ }
2967
+ function fmtNum(v) {
2968
+ if (Math.abs(v) >= 1000)
2969
+ return (v / 1000).toFixed(1) + 'k';
2970
+ return String(v);
2971
+ }
2972
+ // ── Pie arc helpers ────────────────────────────────────────
2973
+ // Canvas renderer draws pie arcs natively (no SVG path needed).
2974
+ function drawPieArc(rc, ctx, cx, cy, r, ir, startAngle, endAngle, color, seed) {
2975
+ // Build polygon approximation of the arc segment for rough.js
2976
+ const STEPS = 32;
2977
+ const pts = [];
2978
+ if (ir > 0) {
2979
+ // Donut: outer arc CCW, inner arc CW
2980
+ for (let i = 0; i <= STEPS; i++) {
2981
+ const a = startAngle + (endAngle - startAngle) * (i / STEPS);
2982
+ pts.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]);
2983
+ }
2984
+ for (let i = STEPS; i >= 0; i--) {
2985
+ const a = startAngle + (endAngle - startAngle) * (i / STEPS);
2986
+ pts.push([cx + ir * Math.cos(a), cy + ir * Math.sin(a)]);
2987
+ }
2988
+ }
2989
+ else {
2990
+ // Pie: center + arc points
2991
+ pts.push([cx, cy]);
2992
+ for (let i = 0; i <= STEPS; i++) {
2993
+ const a = startAngle + (endAngle - startAngle) * (i / STEPS);
2994
+ pts.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]);
2995
+ }
2996
+ }
2997
+ rc.polygon(pts, {
2998
+ roughness: 1.0, bowing: 0.5, seed,
2999
+ fill: color + 'bb',
3000
+ fillStyle: 'solid',
3001
+ stroke: color,
3002
+ strokeWidth: 1.4,
3003
+ });
3004
+ }
3005
+ // ── Axes ───────────────────────────────────────────────────
3006
+ function drawAxes(rc, ctx, c, px, py, pw, ph, allY, labelCol, R) {
3007
+ const toY = makeValueToY(allY, py, ph);
3008
+ const baseline = toY(0);
3009
+ // Y axis
3010
+ rc.line(px, py, px, py + ph, { ...R, roughness: 0.4, seed: hashStr$2(c.id + 'ya'), stroke: labelCol, strokeWidth: 1 });
3011
+ // X axis (baseline)
3012
+ rc.line(px, baseline, px + pw, baseline, { ...R, roughness: 0.4, seed: hashStr$2(c.id + 'xa'), stroke: labelCol, strokeWidth: 1 });
3013
+ // Y ticks + labels
3014
+ for (const tick of yTicks(allY)) {
3015
+ const ty = toY(tick);
3016
+ if (ty < py - 2 || ty > py + ph + 2)
3017
+ continue;
3018
+ rc.line(px - 3, ty, px, ty, { roughness: 0.2, seed: hashStr$2(c.id + 'yt' + tick), stroke: labelCol, strokeWidth: 0.7 });
3019
+ ctx.save();
3020
+ ctx.font = '400 9px system-ui, sans-serif';
3021
+ ctx.fillStyle = labelCol;
3022
+ ctx.textAlign = 'right';
3023
+ ctx.textBaseline = 'middle';
3024
+ ctx.fillText(fmtNum(tick), px - 5, ty);
3025
+ ctx.restore();
3026
+ }
3027
+ }
3028
+ // ── Legend ─────────────────────────────────────────────────
3029
+ function drawLegend(ctx, labels, colors, x, y, labelCol) {
3030
+ ctx.save();
3031
+ ctx.font = '400 9px system-ui, sans-serif';
3032
+ ctx.textAlign = 'left';
3033
+ ctx.textBaseline = 'middle';
3034
+ labels.forEach((lbl, i) => {
3035
+ ctx.fillStyle = colors[i % colors.length];
3036
+ ctx.fillRect(x, y + i * 14, 8, 8);
3037
+ ctx.fillStyle = labelCol;
3038
+ ctx.fillText(lbl, x + 12, y + i * 14 + 4);
3039
+ });
3040
+ ctx.restore();
3041
+ }
3042
+ // ── Public entry ───────────────────────────────────────────
3043
+ function drawRoughChartCanvas(rc, ctx, c, pal, R) {
3044
+ const s = c.style ?? {};
3045
+ // Background
3046
+ const bgFill = String(s.fill ?? pal.nodeFill);
3047
+ const bgStroke = String(s.stroke ?? (pal.nodeStroke === 'none' ? '#c8b898' : pal.nodeStroke));
3048
+ const lc = String(s.color ?? pal.labelText);
3049
+ // Background
3050
+ rc.rectangle(c.x, c.y, c.w, c.h, {
3051
+ ...R, seed: hashStr$2(c.id),
3052
+ fill: bgFill,
3053
+ fillStyle: 'solid',
3054
+ stroke: bgStroke,
3055
+ strokeWidth: Number(s.strokeWidth ?? 1.2),
3056
+ ...(s.strokeDash ? { strokeLineDash: s.strokeDash } : {}),
3057
+ });
3058
+ // Title
3059
+ if (c.title) {
3060
+ ctx.save();
3061
+ ctx.font = '600 12px system-ui, sans-serif';
3062
+ ctx.fillStyle = lc;
3063
+ ctx.textAlign = 'center';
3064
+ ctx.textBaseline = 'middle';
3065
+ ctx.fillText(c.title, c.x + c.w / 2, c.y + 14);
3066
+ ctx.restore();
3067
+ }
3068
+ const { px, py, pw, ph, cx, cy } = chartLayout(c);
3069
+ // ── Pie / Donut ──────────────────────────────────────────
3070
+ if (c.chartType === 'pie' || c.chartType === 'donut') {
3071
+ const { segments, total } = parsePie(c.data);
3072
+ const r = Math.min(c.w * 0.38, (c.h - (c.title ? 24 : 8)) * 0.44);
3073
+ const ir = c.chartType === 'donut' ? r * 0.48 : 0;
3074
+ const legendX = c.x + 8;
3075
+ const legendY = c.y + (c.title ? 28 : 12);
3076
+ let angle = -Math.PI / 2;
3077
+ segments.forEach((seg, i) => {
3078
+ const sweep = (seg.value / total) * Math.PI * 2;
3079
+ drawPieArc(rc, ctx, cx, cy, r, ir, angle, angle + sweep, seg.color, hashStr$2(c.id + seg.label + i));
3080
+ angle += sweep;
3081
+ });
3082
+ drawLegend(ctx, segments.map(s => `${s.label} ${Math.round(s.value / total * 100)}%`), segments.map(s => s.color), legendX, legendY, lc);
3083
+ return;
3084
+ }
3085
+ // ── Scatter ───────────────────────────────────────────────
3086
+ if (c.chartType === 'scatter') {
3087
+ const pts = parseScatter(c.data);
3088
+ const xs = pts.map(p => p.x), ys = pts.map(p => p.y);
3089
+ const toX = makeValueToX(xs, px, pw);
3090
+ const toY = makeValueToY(ys, py, ph);
3091
+ rc.line(px, py, px, py + ph, { ...R, roughness: 0.4, seed: hashStr$2(c.id + 'ya'), stroke: lc, strokeWidth: 1 });
3092
+ rc.line(px, py + ph, px + pw, py + ph, { ...R, roughness: 0.4, seed: hashStr$2(c.id + 'xa'), stroke: lc, strokeWidth: 1 });
3093
+ pts.forEach((pt, i) => {
3094
+ rc.ellipse(toX(pt.x), toY(pt.y), 10, 10, {
3095
+ roughness: 0.8, seed: hashStr$2(c.id + pt.label),
3096
+ fill: CHART_COLORS[i % CHART_COLORS.length] + '99',
3097
+ fillStyle: 'solid',
3098
+ stroke: CHART_COLORS[i % CHART_COLORS.length],
3099
+ strokeWidth: 1.2,
3100
+ });
3101
+ });
3102
+ drawLegend(ctx, pts.map(p => p.label), CHART_COLORS, c.x + 8, c.y + (c.title ? 28 : 12), lc);
3103
+ return;
3104
+ }
3105
+ // ── Bar / Line / Area ─────────────────────────────────────
3106
+ const { labels, series } = parseBarLine(c.data);
3107
+ const allY = series.flatMap(s => s.values);
3108
+ const toY = makeValueToY(allY, py, ph);
3109
+ const baseline = toY(0);
3110
+ const n = labels.length;
3111
+ drawAxes(rc, ctx, c, px, py, pw, ph, allY, lc, R);
3112
+ // X labels
3113
+ ctx.save();
3114
+ ctx.font = '400 9px system-ui, sans-serif';
3115
+ ctx.fillStyle = lc;
3116
+ ctx.textAlign = 'center';
3117
+ ctx.textBaseline = 'top';
3118
+ labels.forEach((lbl, i) => {
3119
+ ctx.fillText(lbl, px + (i + 0.5) * (pw / n), py + ph + 6);
3120
+ });
3121
+ ctx.restore();
3122
+ if (c.chartType === 'bar') {
3123
+ const groupW = pw / n;
3124
+ const m = series.length;
3125
+ const barW = (groupW / m) * 0.72;
3126
+ const slip = (groupW - barW * m) / (m + 1);
3127
+ series.forEach((ser, si) => {
3128
+ ser.values.forEach((val, i) => {
3129
+ const bx = px + i * groupW + slip + si * (barW + slip);
3130
+ const by = Math.min(toY(val), baseline);
3131
+ const bh = Math.abs(baseline - toY(val)) || 2;
3132
+ rc.rectangle(bx, by, barW, bh, {
3133
+ roughness: 1.1, bowing: 0.5,
3134
+ seed: hashStr$2(c.id + si + i),
3135
+ fill: ser.color + 'bb',
3136
+ fillStyle: 'hachure',
3137
+ hachureAngle: -41,
3138
+ hachureGap: 4,
3139
+ fillWeight: 0.8,
3140
+ stroke: ser.color,
3141
+ strokeWidth: 1.2,
3142
+ });
3143
+ });
3144
+ });
3145
+ }
3146
+ else {
3147
+ // line / area
3148
+ const stepX = n > 1 ? pw / (n - 1) : 0;
3149
+ series.forEach((ser, si) => {
3150
+ const pts = ser.values.map((v, i) => [
3151
+ n > 1 ? px + i * stepX : px + pw / 2,
3152
+ toY(v),
3153
+ ]);
3154
+ // Area fill
3155
+ if (c.chartType === 'area') {
3156
+ const poly = [
3157
+ [pts[0][0], baseline],
3158
+ ...pts,
3159
+ [pts[pts.length - 1][0], baseline],
3160
+ ];
3161
+ rc.polygon(poly, {
3162
+ roughness: 0.5, seed: hashStr$2(c.id + 'af' + si),
3163
+ fill: ser.color + '44',
3164
+ fillStyle: 'solid',
3165
+ stroke: 'none',
3166
+ });
3167
+ }
3168
+ // Lines
3169
+ for (let i = 0; i < pts.length - 1; i++) {
3170
+ rc.line(pts[i][0], pts[i][1], pts[i + 1][0], pts[i + 1][1], {
3171
+ roughness: 0.9, bowing: 0.6,
3172
+ seed: hashStr$2(c.id + si + i),
3173
+ stroke: ser.color,
3174
+ strokeWidth: 1.8,
3175
+ });
3176
+ }
3177
+ // Dots
3178
+ pts.forEach(([px2, py2], i) => {
3179
+ rc.ellipse(px2, py2, 7, 7, {
3180
+ roughness: 0.3, seed: hashStr$2(c.id + 'dot' + si + i),
3181
+ fill: ser.color,
3182
+ fillStyle: 'solid',
3183
+ stroke: ser.color,
3184
+ strokeWidth: 1,
3185
+ });
3186
+ });
3187
+ });
3188
+ }
3189
+ // Multi-series legend
3190
+ if (series.length > 1) {
3191
+ drawLegend(ctx, series.map(s => s.name), series.map(s => s.color), px, py - 2, lc);
3192
+ }
3193
+ }
3194
+
3195
+ // ============================================================
3196
+ // sketchmark — Canvas Renderer
3197
+ // Uses rough.js canvas API for hand-drawn rendering
3198
+ // ============================================================
3199
+ function hashStr$1(s) {
3200
+ let h = 5381;
3201
+ for (let i = 0; i < s.length; i++)
3202
+ h = ((h * 33) ^ s.charCodeAt(i)) & 0xffff;
3203
+ return h;
3204
+ }
3205
+ // ── Arrow direction from connector (mirrors svg/index.ts) ─
3206
+ function connMeta(connector) {
3207
+ if (connector === "--")
3208
+ return { arrowAt: "none", dashed: false };
3209
+ if (connector === "---")
3210
+ return { arrowAt: "none", dashed: true };
3211
+ const bidir = connector.includes("<") && connector.includes(">");
3212
+ if (bidir)
3213
+ return { arrowAt: "both", dashed: connector.includes("--") };
3214
+ const back = connector.startsWith("<");
3215
+ const dashed = connector.includes("--");
3216
+ if (back)
3217
+ return { arrowAt: "start", dashed };
3218
+ return { arrowAt: "end", dashed };
3219
+ }
3220
+ // ── Generic rect connection point ─────────────────────────
3221
+ function rectConnPoint(rx, ry, rw, rh, ox, oy) {
3222
+ const cx = rx + rw / 2, cy = ry + rh / 2;
3223
+ const dx = ox - cx, dy = oy - cy;
3224
+ if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01)
3225
+ return [cx, cy];
3226
+ const hw = rw / 2 - 2, hh = rh / 2 - 2;
3227
+ const tx = Math.abs(dx) > 0.01 ? hw / Math.abs(dx) : 1e9;
3228
+ const ty = Math.abs(dy) > 0.01 ? hh / Math.abs(dy) : 1e9;
3229
+ const t = Math.min(tx, ty);
3230
+ return [cx + t * dx, cy + t * dy];
3231
+ }
3232
+ function resolveEndpoint(id, nm, tm, gm, cm, ntm) {
3233
+ return (nm.get(id) ?? tm.get(id) ?? gm.get(id) ?? cm.get(id) ?? ntm.get(id) ?? null);
3234
+ }
3235
+ function getConnPoint(src, dstCX, dstCY) {
3236
+ if ("shape" in src && src.shape) {
3237
+ return connPoint(src, {
3238
+ x: dstCX - 1,
3239
+ y: dstCY - 1,
3240
+ w: 2,
3241
+ h: 2});
3242
+ }
3243
+ return rectConnPoint(src.x, src.y, src.w, src.h, dstCX, dstCY);
3244
+ }
3245
+ // ── Group depth (for paint order, outermost first) ────────
3246
+ function groupDepth(g, gm) {
3247
+ let d = 0;
3248
+ let cur = g;
3249
+ while (cur?.parentId) {
3250
+ d++;
3251
+ cur = gm.get(cur.parentId);
3252
+ }
3253
+ return d;
3254
+ }
3255
+ // ── Node shapes ───────────────────────────────────────────
3256
+ function renderShape(rc, ctx, n, palette, R) {
3257
+ const s = n.style ?? {};
3258
+ const fill = String(s.fill ?? palette.nodeFill);
3259
+ const stroke = String(s.stroke ?? palette.nodeStroke);
3260
+ const opts = {
3261
+ ...R,
3262
+ seed: hashStr$1(n.id),
3263
+ fill,
3264
+ fillStyle: "solid",
3265
+ stroke,
3266
+ strokeWidth: Number(s.strokeWidth ?? 1.9),
3267
+ };
3268
+ const cx = n.x + n.w / 2, cy = n.y + n.h / 2;
3269
+ const hw = n.w / 2 - 2;
3270
+ switch (n.shape) {
3271
+ case "circle":
3272
+ rc.ellipse(cx, cy, n.w * 0.88, n.h * 0.88, opts);
3273
+ break;
3274
+ case "diamond":
3275
+ rc.polygon([
3276
+ [cx, n.y + 2],
3277
+ [cx + hw, cy],
3278
+ [cx, n.y + n.h - 2],
3279
+ [cx - hw, cy],
3280
+ ], opts);
3281
+ break;
3282
+ case "hexagon": {
3283
+ const hw2 = hw * 0.56;
3284
+ rc.polygon([
3285
+ [cx - hw2, n.y + 3],
3286
+ [cx + hw2, n.y + 3],
3287
+ [cx + hw, cy],
3288
+ [cx + hw2, n.y + n.h - 3],
3289
+ [cx - hw2, n.y + n.h - 3],
3290
+ [cx - hw, cy],
3291
+ ], opts);
3292
+ break;
3293
+ }
3294
+ case "triangle":
3295
+ rc.polygon([
3296
+ [cx, n.y + 3],
3297
+ [n.x + n.w - 3, n.y + n.h - 3],
3298
+ [n.x + 3, n.y + n.h - 3],
3299
+ ], opts);
3300
+ break;
3301
+ case "cylinder": {
3302
+ const eH = 18;
3303
+ rc.rectangle(n.x + 3, n.y + eH / 2, n.w - 6, n.h - eH, opts);
3304
+ rc.ellipse(cx, n.y + eH / 2, n.w - 8, eH, { ...opts, roughness: 0.6 });
3305
+ rc.ellipse(cx, n.y + n.h - eH / 2, n.w - 8, eH, {
3306
+ ...opts,
3307
+ roughness: 0.6,
3308
+ fill: "none",
3309
+ });
3310
+ break;
3311
+ }
3312
+ case "parallelogram":
3313
+ rc.polygon([
3314
+ [n.x + 18, n.y + 1],
3315
+ [n.x + n.w - 1, n.y + 1],
3316
+ [n.x + n.w - 18, n.y + n.h - 1],
3317
+ [n.x + 1, n.y + n.h - 1],
3318
+ ], opts);
3319
+ break;
3320
+ case "text":
3321
+ break; // text nodes: no background shape
3322
+ case "image": {
3323
+ if (n.imageUrl) {
3324
+ const img = new Image();
3325
+ img.crossOrigin = "anonymous";
3326
+ img.onload = () => {
3327
+ ctx.save();
3328
+ // rounded clip
3329
+ ctx.beginPath();
3330
+ const r = 6;
3331
+ ctx.moveTo(n.x + r, n.y);
3332
+ ctx.lineTo(n.x + n.w - r, n.y);
3333
+ ctx.quadraticCurveTo(n.x + n.w, n.y, n.x + n.w, n.y + r);
3334
+ ctx.lineTo(n.x + n.w, n.y + n.h - r);
3335
+ ctx.quadraticCurveTo(n.x + n.w, n.y + n.h, n.x + n.w - r, n.y + n.h);
3336
+ ctx.lineTo(n.x + r, n.y + n.h);
3337
+ ctx.quadraticCurveTo(n.x, n.y + n.h, n.x, n.y + n.h - r);
3338
+ ctx.lineTo(n.x, n.y + r);
3339
+ ctx.quadraticCurveTo(n.x, n.y, n.x + r, n.y);
3340
+ ctx.closePath();
3341
+ ctx.clip();
3342
+ ctx.drawImage(img, n.x + 1, n.y + 1, n.w - 2, n.h - 2);
3343
+ ctx.restore();
3344
+ // border on top
3345
+ rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, {
3346
+ ...opts,
3347
+ fill: "none",
3348
+ });
3349
+ };
3350
+ img.src = n.imageUrl;
3351
+ }
3352
+ else {
3353
+ // placeholder
3354
+ rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, {
3355
+ ...opts,
3356
+ fill: "#e0e0e0",
3357
+ stroke: "#999999",
3358
+ });
3359
+ }
3360
+ return;
3361
+ }
3362
+ default:
3363
+ rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, opts);
3364
+ break;
3365
+ }
3366
+ }
3367
+ // ── Arrowhead ─────────────────────────────────────────────
3368
+ function drawArrowHead(rc, x, y, angle, col, seed, R) {
3369
+ const as = 12;
3370
+ rc.polygon([
3371
+ [x, y],
3372
+ [
3373
+ x - as * Math.cos(angle - Math.PI / 6.5),
3374
+ y - as * Math.sin(angle - Math.PI / 6.5),
3375
+ ],
3376
+ [
3377
+ x - as * Math.cos(angle + Math.PI / 6.5),
3378
+ y - as * Math.sin(angle + Math.PI / 6.5),
3379
+ ],
3380
+ ], {
3381
+ roughness: 0.3,
3382
+ seed,
3383
+ fill: col,
3384
+ fillStyle: "solid",
3385
+ stroke: col,
3386
+ strokeWidth: 0.8,
3387
+ });
3388
+ }
3389
+ function renderToCanvas(sg, canvas, options = {}) {
3390
+ if (typeof rough === "undefined")
3391
+ throw new Error("rough.js not loaded");
3392
+ const scale = options.scale ?? window.devicePixelRatio ?? 1;
3393
+ canvas.width = sg.width * scale;
3394
+ canvas.height = sg.height * scale;
3395
+ canvas.style.width = sg.width + "px";
3396
+ canvas.style.height = sg.height + "px";
3397
+ const ctx = canvas.getContext("2d");
3398
+ ctx.scale(scale, scale);
3399
+ if (options.transparent) {
3400
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
3401
+ }
3402
+ // ── Resolve palette (mirrors SVG renderer) ───────────────
3403
+ const isDark = options.theme === "dark" ||
3404
+ (options.theme === "auto" &&
3405
+ window.matchMedia?.("(prefers-color-scheme:dark)").matches);
3406
+ const themeName = String(sg.config[THEME_CONFIG_KEY] ?? (isDark ? "dark" : "light"));
3407
+ const palette = resolvePalette(themeName);
3408
+ if (!options.transparent) {
3409
+ ctx.fillStyle = options.background ?? palette.background;
3410
+ ctx.fillRect(0, 0, sg.width, sg.height);
3411
+ }
3412
+ const rc = rough.canvas(canvas);
3413
+ const R = {
3414
+ roughness: options.roughness ?? 1.3,
3415
+ bowing: options.bowing ?? 0.7,
3416
+ };
3417
+ // ── Lookup maps ──────────────────────────────────────────
3418
+ const nm = nodeMap(sg);
3419
+ const tm = tableMap(sg);
3420
+ const gm = groupMap(sg);
3421
+ const cm = chartMap(sg);
3422
+ const ntm = noteMap(sg);
3423
+ // ── Title ────────────────────────────────────────────────
3424
+ if (sg.title) {
3425
+ ctx.save();
3426
+ ctx.font = "600 18px system-ui, sans-serif";
3427
+ ctx.fillStyle = palette.titleText;
3428
+ ctx.textAlign = "center";
3429
+ ctx.fillText(sg.title, sg.width / 2, 28);
3430
+ ctx.restore();
3431
+ }
3432
+ // ── Groups (depth-sorted: outermost first) ────────────────
3433
+ const sortedGroups = [...sg.groups].sort((a, b) => groupDepth(a, gm) - groupDepth(b, gm));
3434
+ for (const g of sortedGroups) {
3435
+ if (!g.w)
3436
+ continue;
3437
+ const gs = g.style ?? {};
3438
+ rc.rectangle(g.x, g.y, g.w, g.h, {
3439
+ ...R,
3440
+ roughness: 1.7,
3441
+ bowing: 0.4,
3442
+ seed: hashStr$1(g.id),
3443
+ fill: String(gs.fill ?? palette.groupFill),
3444
+ fillStyle: "solid",
3445
+ stroke: String(gs.stroke ?? palette.groupStroke),
3446
+ strokeWidth: Number(gs.strokeWidth ?? 1.2),
3447
+ strokeLineDash: gs.strokeDash ?? palette.groupDash,
3448
+ });
3449
+ ctx.save();
3450
+ ctx.font = "500 12px system-ui, sans-serif";
3451
+ ctx.fillStyle = gs.color ? String(gs.color) : palette.groupLabel;
3452
+ ctx.textAlign = "left";
3453
+ ctx.fillText(g.label, g.x + 14, g.y + 16);
3454
+ ctx.restore();
3455
+ }
3456
+ // ── Edges ─────────────────────────────────────────────────
3457
+ for (const e of sg.edges) {
3458
+ const src = resolveEndpoint(e.from, nm, tm, gm, cm, ntm);
3459
+ const dst = resolveEndpoint(e.to, nm, tm, gm, cm, ntm);
3460
+ if (!src || !dst)
3461
+ continue;
3462
+ const dstCX = dst.x + dst.w / 2, dstCY = dst.y + dst.h / 2;
3463
+ const srcCX = src.x + src.w / 2, srcCY = src.y + src.h / 2;
3464
+ const [x1, y1] = getConnPoint(src, dstCX, dstCY);
3465
+ const [x2, y2] = getConnPoint(dst, srcCX, srcCY);
3466
+ const ecol = String(e.style?.stroke ?? palette.edgeStroke);
3467
+ const { arrowAt, dashed } = connMeta(e.connector);
3468
+ const len = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) || 1;
3469
+ const nx = (x2 - x1) / len, ny = (y2 - y1) / len;
3470
+ const HEAD = 13;
3471
+ const sx1 = arrowAt === "start" || arrowAt === "both" ? x1 + nx * HEAD : x1;
3472
+ const sy1 = arrowAt === "start" || arrowAt === "both" ? y1 + ny * HEAD : y1;
3473
+ const sx2 = arrowAt === "end" || arrowAt === "both" ? x2 - nx * HEAD : x2;
3474
+ const sy2 = arrowAt === "end" || arrowAt === "both" ? y2 - ny * HEAD : y2;
3475
+ rc.line(sx1, sy1, sx2, sy2, {
3476
+ ...R,
3477
+ roughness: 0.9,
3478
+ seed: hashStr$1(e.from + e.to),
3479
+ stroke: ecol,
3480
+ strokeWidth: Number(e.style?.strokeWidth ?? 1.6),
3481
+ ...(dashed ? { strokeLineDash: [6, 5] } : {}),
3482
+ });
3483
+ const ang = Math.atan2(y2 - y1, x2 - x1);
3484
+ if (arrowAt === "end" || arrowAt === "both")
3485
+ drawArrowHead(rc, x2, y2, ang, ecol, hashStr$1(e.to));
3486
+ if (arrowAt === "start" || arrowAt === "both")
3487
+ drawArrowHead(rc, x1, y1, Math.atan2(y1 - y2, x1 - x2), ecol, hashStr$1(e.from + "back"));
3488
+ if (e.label) {
3489
+ const mx = (x1 + x2) / 2 - ny * 14;
3490
+ const my = (y1 + y2) / 2 + nx * 14;
3491
+ ctx.save();
3492
+ ctx.font = "400 11px system-ui, sans-serif";
3493
+ ctx.textAlign = "center";
3494
+ const tw = ctx.measureText(e.label).width + 12;
3495
+ ctx.fillStyle = palette.edgeLabelBg;
3496
+ ctx.fillRect(mx - tw / 2, my - 8, tw, 15);
3497
+ ctx.fillStyle = palette.edgeLabelText;
3498
+ ctx.fillText(e.label, mx, my + 3);
3499
+ ctx.restore();
3500
+ }
3501
+ }
3502
+ // ── Nodes ─────────────────────────────────────────────────
3503
+ for (const n of sg.nodes) {
3504
+ renderShape(rc, ctx, n, palette, R);
3505
+ const s = n.style ?? {};
3506
+ const fontSize = Number(s.fontSize ?? (n.shape === "text" ? 13 : 14));
3507
+ const fontWeight = s.fontWeight ?? (n.shape === "text" ? 400 : 500);
3508
+ const textColor = String(s.color ??
3509
+ (n.shape === "text" ? palette.edgeLabelText : palette.nodeText));
3510
+ ctx.save();
3511
+ ctx.font = `${fontWeight} ${fontSize}px system-ui, sans-serif`;
3512
+ ctx.fillStyle = textColor;
3513
+ ctx.textAlign = "center";
3514
+ ctx.textBaseline = "middle";
3515
+ const lines = n.label.split("\n");
3516
+ if (lines.length === 1) {
3517
+ ctx.fillText(n.label, n.x + n.w / 2, n.y + n.h / 2);
3518
+ }
3519
+ else {
3520
+ const lineH = fontSize * 1.35;
3521
+ const startY = n.y + n.h / 2 - ((lines.length - 1) * lineH) / 2;
3522
+ lines.forEach((line, i) => {
3523
+ ctx.fillText(line, n.x + n.w / 2, startY + i * lineH);
3524
+ });
3525
+ }
3526
+ ctx.restore();
3527
+ }
3528
+ // ── Tables ────────────────────────────────────────────────
3529
+ for (const t of sg.tables) {
3530
+ const gs = t.style ?? {};
3531
+ const fill = String(gs.fill ?? palette.tableFill);
3532
+ const strk = String(gs.stroke ?? palette.tableStroke);
3533
+ const textCol = String(gs.color ?? palette.tableText);
3534
+ const pad = t.labelH;
3535
+ // Outer border
3536
+ rc.rectangle(t.x, t.y, t.w, t.h, {
3537
+ ...R,
3538
+ seed: hashStr$1(t.id),
3539
+ fill,
3540
+ fillStyle: "solid",
3541
+ stroke: strk,
3542
+ strokeWidth: 1.5,
3543
+ });
3544
+ // Label strip separator
3545
+ rc.line(t.x, t.y + pad, t.x + t.w, t.y + pad, {
3546
+ roughness: 0.6,
3547
+ seed: hashStr$1(t.id + "l"),
3548
+ stroke: strk,
3549
+ strokeWidth: 1,
3550
+ });
3551
+ // Label text
3552
+ ctx.save();
3553
+ ctx.font = "500 12px system-ui, sans-serif";
3554
+ ctx.fillStyle = textCol;
3555
+ ctx.textAlign = "left";
3556
+ ctx.textBaseline = "middle";
3557
+ ctx.fillText(t.label, t.x + 10, t.y + pad / 2);
3558
+ ctx.restore();
3559
+ // Rows
3560
+ let rowY = t.y + pad;
3561
+ for (const row of t.rows) {
3562
+ const rh = row.kind === "header" ? t.headerH : t.rowH;
3563
+ // Header background
3564
+ if (row.kind === "header") {
3565
+ ctx.fillStyle = palette.tableHeaderFill;
3566
+ ctx.fillRect(t.x + 1, rowY + 1, t.w - 2, rh - 1);
3567
+ }
3568
+ // Row separator
3569
+ rc.line(t.x, rowY + rh, t.x + t.w, rowY + rh, {
3570
+ roughness: 0.4,
3571
+ seed: hashStr$1(t.id + rowY),
3572
+ stroke: row.kind === "header" ? strk : palette.tableDivider,
3573
+ strokeWidth: row.kind === "header" ? 1.2 : 0.6,
3574
+ });
3575
+ // Cell text + column separators
3576
+ let cx = t.x;
3577
+ row.cells.forEach((cell, i) => {
3578
+ const cw = t.colWidths[i] ?? 60;
3579
+ const fw = row.kind === "header" ? 600 : 400;
3580
+ ctx.save();
3581
+ ctx.font = `${fw} 12px system-ui, sans-serif`;
3582
+ ctx.fillStyle =
3583
+ row.kind === "header" ? palette.tableHeaderText : textCol;
3584
+ ctx.textAlign = "center";
3585
+ ctx.textBaseline = "middle";
3586
+ ctx.fillText(cell, cx + cw / 2, rowY + rh / 2);
3587
+ ctx.restore();
3588
+ if (i < row.cells.length - 1) {
3589
+ rc.line(cx + cw, t.y + pad, cx + cw, t.y + t.h, {
3590
+ roughness: 0.3,
3591
+ seed: hashStr$1(t.id + "c" + i),
3592
+ stroke: palette.tableDivider,
3593
+ strokeWidth: 0.5,
3594
+ });
3595
+ }
3596
+ cx += cw;
3597
+ });
3598
+ rowY += rh;
3599
+ }
3600
+ }
3601
+ // ── Notes ─────────────────────────────────────────────────
3602
+ for (const n of sg.notes) {
3603
+ const gs = n.style ?? {};
3604
+ const fill = String(gs.fill ?? palette.noteFill);
3605
+ const strk = String(gs.stroke ?? palette.noteStroke);
3606
+ const fold = 14;
3607
+ const { x, y, w, h } = n;
3608
+ // Note body (folded corner polygon)
3609
+ rc.polygon([
3610
+ [x, y],
3611
+ [x + w - fold, y],
3612
+ [x + w, y + fold],
3613
+ [x + w, y + h],
3614
+ [x, y + h],
3615
+ ], {
3616
+ ...R,
3617
+ seed: hashStr$1(n.id),
3618
+ fill,
3619
+ fillStyle: "solid",
3620
+ stroke: strk,
3621
+ strokeWidth: 1.2,
3622
+ });
3623
+ // Folded corner triangle
3624
+ rc.polygon([
3625
+ [x + w - fold, y],
3626
+ [x + w, y + fold],
3627
+ [x + w - fold, y + fold],
3628
+ ], {
3629
+ roughness: 0.4,
3630
+ seed: hashStr$1(n.id + "f"),
3631
+ fill: palette.noteFold,
3632
+ fillStyle: "solid",
3633
+ stroke: strk,
3634
+ strokeWidth: 0.8,
3635
+ });
3636
+ // Text lines
3637
+ ctx.save();
3638
+ ctx.font = "400 12px system-ui, sans-serif";
3639
+ ctx.fillStyle = String(gs.color ?? palette.noteText);
3640
+ ctx.textAlign = "left";
3641
+ ctx.textBaseline = "middle";
3642
+ n.lines.forEach((line, i) => {
3643
+ ctx.fillText(line, x + 12, y + 12 + i * 20 + 10);
3644
+ });
3645
+ ctx.restore();
3646
+ }
3647
+ // ── Charts ────────────────────────────────────────────────
3648
+ for (const c of sg.charts) {
3649
+ drawRoughChartCanvas(rc, ctx, c, {
3650
+ nodeFill: palette.chartFill,
3651
+ nodeStroke: palette.chartStroke,
3652
+ labelText: palette.chartText,
3653
+ labelBg: palette.edgeLabelBg,
3654
+ }, R);
3655
+ }
3656
+ }
3657
+ // ── Export canvas to PNG blob ─────────────────────────────
3658
+ function canvasToPNGBlob(canvas) {
3659
+ return new Promise((resolve, reject) => {
3660
+ canvas.toBlob((blob) => {
3661
+ if (blob)
3662
+ resolve(blob);
3663
+ else
3664
+ reject(new Error("Canvas toBlob failed"));
3665
+ }, "image/png");
3666
+ });
3667
+ }
3668
+ function canvasToPNGDataURL(canvas) {
3669
+ return canvas.toDataURL("image/png");
3670
+ }
3671
+
3672
+ // ============================================================
3673
+ // sketchmark — Animation Engine (nodes + edges + groups)
3674
+ // ============================================================
3675
+ // ── DOM helpers ───────────────────────────────────────────
3676
+ const getEl = (svg, id) => svg.querySelector(`#${id}`);
3677
+ const getNodeEl = (svg, id) => getEl(svg, `node-${id}`);
3678
+ const getGroupEl = (svg, id) => getEl(svg, `group-${id}`);
3679
+ const getEdgeEl = (svg, f, t) => getEl(svg, `edge-${f}-${t}`);
3680
+ const getTableEl = (svg, id) => getEl(svg, `table-${id}`);
3681
+ const getNoteEl = (svg, id) => getEl(svg, `note-${id}`);
3682
+ const getChartEl = (svg, id) => getEl(svg, `chart-${id}`);
3683
+ function resolveEl(svg, target) {
3684
+ // check edge first — target contains connector like "a-->b"
3685
+ const edge = parseEdgeTarget(target);
3686
+ if (edge)
3687
+ return getEdgeEl(svg, edge.from, edge.to);
3688
+ // everything else resolved by prefixed id
3689
+ return (getNodeEl(svg, target) ??
3690
+ getGroupEl(svg, target) ??
3691
+ getTableEl(svg, target) ??
3692
+ getNoteEl(svg, target) ??
3693
+ getChartEl(svg, target) ??
3694
+ null);
3695
+ }
3696
+ function pathLength(p) {
3697
+ try {
3698
+ return p.getTotalLength() || 200;
3699
+ }
3700
+ catch {
3701
+ return 200;
3702
+ }
3703
+ }
3704
+ // ── Arrow connector parser ────────────────────────────────
3705
+ const ARROW_CONNECTORS = ["<-->", "<->", "-->", "<--", "->", "<-", "---", "--"];
3706
+ function parseEdgeTarget(target) {
3707
+ for (const conn of ARROW_CONNECTORS) {
3708
+ const idx = target.indexOf(conn);
3709
+ if (idx !== -1)
3710
+ return {
3711
+ from: target.slice(0, idx).trim(),
3712
+ to: target.slice(idx + conn.length).trim(),
3713
+ conn,
3714
+ };
3715
+ }
3716
+ return null;
3717
+ }
3718
+ // ── Draw target helpers ───────────────────────────────────
3719
+ function getDrawTargetEdgeIds(steps) {
3720
+ const ids = new Set();
3721
+ for (const s of steps) {
3722
+ if (s.action !== "draw")
3723
+ continue;
3724
+ const e = parseEdgeTarget(s.target);
3725
+ if (e)
3726
+ ids.add(`edge-${e.from}-${e.to}`);
3727
+ }
3728
+ return ids;
3729
+ }
3730
+ function getDrawTargetNodeIds(steps) {
3731
+ const ids = new Set();
3732
+ for (const s of steps) {
3733
+ if (s.action !== "draw" || parseEdgeTarget(s.target))
3734
+ continue;
3735
+ ids.add(`node-${s.target}`);
3736
+ }
3737
+ return ids;
3738
+ }
3739
+ // ── Generic shape-draw helpers (shared by nodes and groups) ──
3740
+ function prepareForDraw(el) {
3741
+ el.querySelectorAll("path").forEach((p) => {
3742
+ const len = pathLength(p);
3743
+ p.style.strokeDasharray = `${len}`;
3744
+ p.style.strokeDashoffset = `${len}`;
3745
+ p.style.fillOpacity = "0";
3746
+ p.style.transition = "none";
3747
+ });
3748
+ const text = el.querySelector("text");
3749
+ if (text) {
3750
+ text.style.opacity = "0";
3751
+ text.style.transition = "none";
3752
+ }
3753
+ }
3754
+ function revealInstant(el) {
3755
+ el.querySelectorAll("path").forEach((p) => {
3756
+ p.style.transition = "none";
3757
+ p.style.strokeDashoffset = "0";
3758
+ p.style.fillOpacity = "";
3759
+ p.style.strokeDasharray = "";
3760
+ });
3761
+ const text = el.querySelector("text");
3762
+ if (text) {
3763
+ text.style.transition = "none";
3764
+ text.style.opacity = "";
3765
+ }
3766
+ }
3767
+ function clearDrawStyles(el) {
3768
+ el.querySelectorAll("path").forEach((p) => {
3769
+ p.style.strokeDasharray =
3770
+ p.style.strokeDashoffset =
3771
+ p.style.fillOpacity =
3772
+ p.style.transition =
3773
+ "";
3774
+ });
3775
+ const text = el.querySelector("text");
3776
+ if (text) {
3777
+ text.style.opacity = text.style.transition = "";
3778
+ }
3779
+ }
3780
+ function animateShapeDraw(el, strokeDur = 420, stag = 55) {
3781
+ const paths = Array.from(el.querySelectorAll("path"));
3782
+ const text = el.querySelector("text");
3783
+ requestAnimationFrame(() => requestAnimationFrame(() => {
3784
+ paths.forEach((p, i) => {
3785
+ const sd = i * stag, fd = sd + strokeDur - 60;
3786
+ p.style.transition = [
3787
+ `stroke-dashoffset ${strokeDur}ms cubic-bezier(.4,0,.2,1) ${sd}ms`,
3788
+ `fill-opacity 180ms ease ${Math.max(0, fd)}ms`,
3789
+ ].join(", ");
3790
+ p.style.strokeDashoffset = "0";
3791
+ p.style.fillOpacity = "1";
3792
+ });
3793
+ if (text) {
3794
+ const td = paths.length * stag + strokeDur + 80;
3795
+ text.style.transition = `opacity 200ms ease ${td}ms`;
3796
+ text.style.opacity = "1";
3797
+ }
3798
+ }));
3799
+ }
3800
+ // ── Edge draw helpers ─────────────────────────────────────
3801
+ function clearEdgeDrawStyles(el) {
3802
+ el.querySelectorAll("path").forEach((p) => {
3803
+ p.style.strokeDasharray =
3804
+ p.style.strokeDashoffset =
3805
+ p.style.opacity =
3806
+ p.style.transition =
3807
+ "";
3808
+ });
3809
+ }
3810
+ function animateEdgeDraw(el, conn) {
3811
+ const paths = Array.from(el.querySelectorAll("path"));
3812
+ if (!paths.length)
3813
+ return;
3814
+ const linePath = paths[0];
3815
+ const headPaths = paths.slice(1);
3816
+ const STROKE_DUR = 360;
3817
+ const len = pathLength(linePath);
3818
+ const reversed = conn.startsWith("<") && !conn.includes(">");
3819
+ linePath.style.strokeDasharray = `${len}`;
3820
+ linePath.style.strokeDashoffset = reversed ? `${-len}` : `${len}`;
3821
+ linePath.style.transition = "none";
3822
+ headPaths.forEach((p) => {
3823
+ p.style.opacity = "0";
3824
+ p.style.transition = "none";
3825
+ });
3826
+ el.classList.remove("draw-hidden");
3827
+ el.classList.add("draw-reveal");
3828
+ el.style.opacity = "1";
3829
+ requestAnimationFrame(() => requestAnimationFrame(() => {
3830
+ linePath.style.transition = `stroke-dashoffset ${STROKE_DUR}ms cubic-bezier(.4,0,.2,1)`;
3831
+ linePath.style.strokeDashoffset = "0";
3832
+ setTimeout(() => {
3833
+ headPaths.forEach((p) => {
3834
+ p.style.transition = "opacity 120ms ease";
3835
+ p.style.opacity = "1";
3836
+ });
3837
+ }, STROKE_DUR - 40);
3838
+ }));
3839
+ }
3840
+ // ── AnimationController ───────────────────────────────────
3841
+ class AnimationController {
3842
+ get drawTargets() {
3843
+ return this.drawTargetEdges;
3844
+ }
3845
+ constructor(svg, steps) {
3846
+ this.svg = svg;
3847
+ this.steps = steps;
3848
+ this._step = -1;
3849
+ this._transforms = new Map();
3850
+ this._listeners = [];
3851
+ this.drawTargetEdges = getDrawTargetEdgeIds(steps);
3852
+ this.drawTargetNodes = getDrawTargetNodeIds(steps);
3853
+ // Groups: non-edge draw steps whose target has a #group-{id} element in the SVG.
3854
+ // We detect this at construction time (after render) so we correctly distinguish
3855
+ // a group ID from a node ID without needing extra metadata.
3856
+ this.drawTargetGroups = new Set();
3857
+ this.drawTargetTables = new Set();
3858
+ this.drawTargetNotes = new Set();
3859
+ this.drawTargetCharts = new Set();
3860
+ for (const s of steps) {
3861
+ if (s.action !== "draw" || parseEdgeTarget(s.target))
3862
+ continue;
3863
+ if (svg.querySelector(`#group-${s.target}`)) {
3864
+ this.drawTargetGroups.add(`group-${s.target}`);
3865
+ // Remove from node targets if it was accidentally added
3866
+ this.drawTargetNodes.delete(`node-${s.target}`);
3867
+ }
3868
+ if (svg.querySelector(`#table-${s.target}`)) {
3869
+ this.drawTargetTables.add(`table-${s.target}`);
3870
+ this.drawTargetNodes.delete(`node-${s.target}`);
3871
+ }
3872
+ if (svg.querySelector(`#note-${s.target}`)) {
3873
+ this.drawTargetNotes.add(`note-${s.target}`);
3874
+ this.drawTargetNodes.delete(`node-${s.target}`);
3875
+ }
3876
+ if (svg.querySelector(`#chart-${s.target}`)) {
3877
+ this.drawTargetCharts.add(`chart-${s.target}`);
3878
+ this.drawTargetNodes.delete(`node-${s.target}`);
3879
+ }
3880
+ }
3881
+ this._clearAll();
3882
+ }
3883
+ get currentStep() {
3884
+ return this._step;
3885
+ }
3886
+ get total() {
3887
+ return this.steps.length;
3888
+ }
3889
+ get canNext() {
3890
+ return this._step < this.steps.length - 1;
3891
+ }
3892
+ get canPrev() {
3893
+ return this._step >= 0;
3894
+ }
3895
+ get atEnd() {
3896
+ return this._step === this.steps.length - 1;
3897
+ }
3898
+ on(listener) {
3899
+ this._listeners.push(listener);
3900
+ return () => {
3901
+ this._listeners = this._listeners.filter((l) => l !== listener);
3902
+ };
3903
+ }
3904
+ emit(type) {
3905
+ const e = {
3906
+ type,
3907
+ stepIndex: this._step,
3908
+ step: this.steps[this._step],
3909
+ total: this.total,
3910
+ };
3911
+ for (const l of this._listeners)
3912
+ l(e);
3913
+ }
3914
+ reset() {
3915
+ this._step = -1;
3916
+ this._clearAll();
3917
+ this.emit("animation-reset");
3918
+ }
3919
+ next() {
3920
+ if (!this.canNext)
3921
+ return false;
3922
+ this._step++;
3923
+ this._applyStep(this._step, false);
3924
+ this.emit("step-change");
3925
+ if (!this.canNext)
3926
+ this.emit("animation-end");
3927
+ return true;
3928
+ }
3929
+ prev() {
3930
+ if (!this.canPrev)
3931
+ return false;
3932
+ this._step--;
3933
+ this._clearAll();
3934
+ for (let i = 0; i <= this._step; i++)
3935
+ this._applyStep(i, true);
3936
+ this.emit("step-change");
3937
+ return true;
3938
+ }
3939
+ async play(msPerStep = 900) {
3940
+ this.emit("animation-start");
3941
+ while (this.canNext) {
3942
+ this.next();
3943
+ await new Promise((r) => setTimeout(r, msPerStep));
3944
+ }
3945
+ }
3946
+ goTo(index) {
3947
+ index = Math.max(-1, Math.min(this.steps.length - 1, index));
3948
+ if (index === this._step)
3949
+ return;
3950
+ if (index < this._step) {
3951
+ this._step = -1;
3952
+ this._clearAll();
3953
+ }
3954
+ while (this._step < index) {
3955
+ this._step++;
3956
+ this._applyStep(this._step, true);
3957
+ }
3958
+ this.emit("step-change");
3959
+ }
3960
+ _clearAll() {
3961
+ this._transforms.clear();
3962
+ // Nodes
3963
+ this.svg.querySelectorAll(".ng").forEach((el) => {
3964
+ el.style.transform = "";
3965
+ el.style.transition = "";
3966
+ el.classList.remove("hl", "faded", "hidden");
3967
+ el.style.opacity = el.style.filter = "";
3968
+ if (this.drawTargetNodes.has(el.id)) {
3969
+ clearDrawStyles(el);
3970
+ prepareForDraw(el);
3971
+ }
3972
+ else
3973
+ clearDrawStyles(el);
3974
+ });
3975
+ // Groups — hide draw-target groups, show the rest
3976
+ this.svg.querySelectorAll(".gg").forEach((el) => {
3977
+ clearDrawStyles(el);
3978
+ el.style.transition = "none";
3979
+ if (this.drawTargetGroups.has(el.id)) {
3980
+ el.style.opacity = "";
3981
+ el.classList.add("gg-hidden");
3982
+ }
3983
+ else {
3984
+ el.style.opacity = "";
3985
+ el.classList.remove("gg-hidden");
3986
+ requestAnimationFrame(() => {
3987
+ el.style.transition = "";
3988
+ });
3989
+ }
3990
+ });
3991
+ // Edges
3992
+ this.svg.querySelectorAll(".eg").forEach((el) => {
3993
+ el.classList.remove("draw-reveal");
3994
+ clearEdgeDrawStyles(el);
3995
+ el.style.transition = "none";
3996
+ if (this.drawTargetEdges.has(el.id)) {
3997
+ el.style.opacity = "";
3998
+ el.classList.add("draw-hidden");
3999
+ }
4000
+ else {
4001
+ el.style.opacity = "";
4002
+ el.classList.remove("draw-hidden");
4003
+ requestAnimationFrame(() => {
4004
+ el.style.transition = "";
4005
+ });
4006
+ }
4007
+ });
4008
+ // Tables
4009
+ this.svg.querySelectorAll(".tg").forEach((el) => {
4010
+ clearDrawStyles(el);
4011
+ el.style.transition = "none";
4012
+ if (this.drawTargetTables.has(el.id)) {
4013
+ el.classList.add("gg-hidden");
4014
+ }
4015
+ else {
4016
+ el.classList.remove("gg-hidden");
4017
+ requestAnimationFrame(() => {
4018
+ el.style.transition = "";
4019
+ });
4020
+ }
4021
+ });
4022
+ // Notes
4023
+ this.svg.querySelectorAll(".ntg").forEach((el) => {
4024
+ clearDrawStyles(el);
4025
+ el.style.transition = "none";
4026
+ if (this.drawTargetNotes.has(el.id)) {
4027
+ el.classList.add("gg-hidden");
4028
+ }
4029
+ else {
4030
+ el.classList.remove("gg-hidden");
4031
+ requestAnimationFrame(() => {
4032
+ el.style.transition = "";
4033
+ });
4034
+ }
4035
+ });
4036
+ // Charts
4037
+ this.svg.querySelectorAll(".cg").forEach((el) => {
4038
+ clearDrawStyles(el);
4039
+ el.style.transition = "none";
4040
+ el.style.opacity = "";
4041
+ if (this.drawTargetCharts.has(el.id)) {
4042
+ el.classList.add("gg-hidden");
4043
+ }
4044
+ else {
4045
+ el.classList.remove("gg-hidden");
4046
+ requestAnimationFrame(() => {
4047
+ el.style.transition = "";
4048
+ });
4049
+ }
4050
+ });
4051
+ this.svg.querySelectorAll(".tg, .ntg, .cg").forEach((el) => {
4052
+ el.style.transform = "";
4053
+ el.style.transition = "";
4054
+ el.style.opacity = "";
4055
+ el.classList.remove("hl", "faded");
4056
+ });
4057
+ }
4058
+ _applyStep(i, silent) {
4059
+ const s = this.steps[i];
4060
+ if (!s)
4061
+ return;
4062
+ switch (s.action) {
4063
+ case "highlight":
4064
+ this._doHighlight(s.target);
4065
+ break;
4066
+ case "fade":
4067
+ this._doFade(s.target, true);
4068
+ break;
4069
+ case "unfade":
4070
+ this._doFade(s.target, false);
4071
+ break;
4072
+ case "draw":
4073
+ this._doDraw(s.target, silent);
4074
+ break;
4075
+ case "erase":
4076
+ this._doErase(s.target);
4077
+ break;
4078
+ case "show":
4079
+ this._doShowHide(s.target, true, silent);
4080
+ break;
4081
+ case "hide":
4082
+ this._doShowHide(s.target, false, silent);
4083
+ break;
4084
+ case "pulse":
4085
+ if (!silent)
4086
+ this._doPulse(s.target);
4087
+ break;
4088
+ case "color":
4089
+ this._doColor(s.target, s.value);
4090
+ break;
4091
+ case "move":
4092
+ this._doMove(s.target, s, silent);
4093
+ break;
4094
+ case "scale":
4095
+ this._doScale(s.target, s, silent);
4096
+ break;
4097
+ case "rotate":
4098
+ this._doRotate(s.target, s, silent);
4099
+ break;
4100
+ }
4101
+ }
4102
+ // ── highlight ────────────────────────────────────────────
4103
+ _doHighlight(target) {
4104
+ this.svg
4105
+ .querySelectorAll(".ng.hl, .tg.hl, .ntg.hl, .cg.hl, .eg.hl")
4106
+ .forEach((e) => e.classList.remove("hl"));
4107
+ resolveEl(this.svg, target)?.classList.add("hl");
4108
+ }
4109
+ // ── fade / unfade ─────────────────────────────────────────
4110
+ _doFade(target, doFade) {
4111
+ resolveEl(this.svg, target)?.classList.toggle("faded", doFade);
4112
+ }
4113
+ _writeTransform(el, target, silent, duration = 420) {
4114
+ const t = this._transforms.get(target) ?? {
4115
+ tx: 0,
4116
+ ty: 0,
4117
+ scale: 1,
4118
+ rotate: 0,
4119
+ };
4120
+ const parts = [];
4121
+ if (t.tx !== 0 || t.ty !== 0)
4122
+ parts.push(`translate(${t.tx}px,${t.ty}px)`);
4123
+ if (t.rotate !== 0)
4124
+ parts.push(`rotate(${t.rotate}deg)`);
4125
+ if (t.scale !== 1)
4126
+ parts.push(`scale(${t.scale})`);
4127
+ el.style.transition = silent
4128
+ ? "none"
4129
+ : `transform ${duration}ms cubic-bezier(.4,0,.2,1)`;
4130
+ el.style.transform = parts.join(" ") || "";
4131
+ if (silent) {
4132
+ requestAnimationFrame(() => requestAnimationFrame(() => {
4133
+ el.style.transition = "";
4134
+ }));
4135
+ }
4136
+ }
4137
+ // ── move ──────────────────────────────────────────────────
4138
+ _doMove(target, step, silent) {
4139
+ const el = resolveEl(this.svg, target);
4140
+ if (!el)
4141
+ return;
4142
+ const cur = this._transforms.get(target) ?? {
4143
+ tx: 0,
4144
+ ty: 0,
4145
+ scale: 1,
4146
+ rotate: 0,
4147
+ };
4148
+ this._transforms.set(target, {
4149
+ ...cur,
4150
+ tx: cur.tx + (step.dx ?? 0),
4151
+ ty: cur.ty + (step.dy ?? 0),
4152
+ });
4153
+ this._writeTransform(el, target, silent, step.duration ?? 420);
4154
+ }
4155
+ // ── scale ─────────────────────────────────────────────────
4156
+ _doScale(target, step, silent) {
4157
+ const el = resolveEl(this.svg, target);
4158
+ if (!el)
4159
+ return;
4160
+ const cur = this._transforms.get(target) ?? {
4161
+ tx: 0,
4162
+ ty: 0,
4163
+ scale: 1,
4164
+ rotate: 0,
4165
+ };
4166
+ this._transforms.set(target, { ...cur, scale: step.factor ?? 1 });
4167
+ this._writeTransform(el, target, silent, step.duration ?? 350);
4168
+ }
4169
+ // ── rotate ────────────────────────────────────────────────
4170
+ _doRotate(target, step, silent) {
4171
+ const el = resolveEl(this.svg, target);
4172
+ if (!el)
4173
+ return;
4174
+ const cur = this._transforms.get(target) ?? {
4175
+ tx: 0,
4176
+ ty: 0,
4177
+ scale: 1,
4178
+ rotate: 0,
4179
+ };
4180
+ this._transforms.set(target, {
4181
+ ...cur,
4182
+ rotate: cur.rotate + (step.deg ?? 0),
4183
+ });
4184
+ this._writeTransform(el, target, silent, step.duration ?? 400);
4185
+ }
4186
+ _doDraw(target, silent) {
4187
+ const edge = parseEdgeTarget(target);
4188
+ if (edge) {
4189
+ // ── Edge draw ──────────────────────────────────────
4190
+ const el = getEdgeEl(this.svg, edge.from, edge.to);
4191
+ if (!el)
4192
+ return;
4193
+ if (silent) {
4194
+ clearEdgeDrawStyles(el);
4195
+ el.style.transition = "none";
4196
+ el.classList.remove("draw-hidden");
4197
+ el.classList.add("draw-reveal");
4198
+ el.style.opacity = "1";
4199
+ requestAnimationFrame(() => requestAnimationFrame(() => {
4200
+ el.style.transition = "";
4201
+ }));
4202
+ }
4203
+ else {
4204
+ animateEdgeDraw(el, edge.conn);
4205
+ }
4206
+ return;
4207
+ }
4208
+ // Check if target is a group (has #group-{target} element)
4209
+ const groupEl = getGroupEl(this.svg, target);
4210
+ if (groupEl) {
4211
+ // ── Group draw ──────────────────────────────────────
4212
+ if (silent) {
4213
+ clearDrawStyles(groupEl);
4214
+ groupEl.style.transition = "none";
4215
+ groupEl.classList.remove("gg-hidden");
4216
+ groupEl.style.opacity = "1";
4217
+ requestAnimationFrame(() => requestAnimationFrame(() => {
4218
+ groupEl.style.transition = "";
4219
+ clearDrawStyles(groupEl);
4220
+ }));
4221
+ }
4222
+ else {
4223
+ groupEl.classList.remove("gg-hidden");
4224
+ // Groups use slightly longer stroke-draw (bigger box, dashed border = more paths)
4225
+ const firstPath = groupEl.querySelector("path");
4226
+ if (!firstPath?.style.strokeDasharray)
4227
+ prepareForDraw(groupEl);
4228
+ animateShapeDraw(groupEl, 550, 40);
4229
+ }
4230
+ return;
4231
+ }
4232
+ // ── Table ──────────────────────────────────────────────
4233
+ const tableEl = getEl(this.svg, `table-${target}`);
4234
+ if (tableEl) {
4235
+ if (silent) {
4236
+ clearDrawStyles(tableEl);
4237
+ tableEl.style.transition = "none";
4238
+ tableEl.classList.remove("gg-hidden");
4239
+ tableEl.style.opacity = "1";
4240
+ requestAnimationFrame(() => requestAnimationFrame(() => {
4241
+ tableEl.style.transition = "";
4242
+ clearDrawStyles(tableEl);
4243
+ }));
4244
+ }
4245
+ else {
4246
+ tableEl.classList.remove("gg-hidden");
4247
+ prepareForDraw(tableEl);
4248
+ animateShapeDraw(tableEl, 500, 40);
4249
+ }
4250
+ return;
4251
+ }
4252
+ // ── Note ───────────────────────────────────────────────
4253
+ const noteEl = getEl(this.svg, `note-${target}`);
4254
+ if (noteEl) {
4255
+ if (silent) {
4256
+ clearDrawStyles(noteEl);
4257
+ noteEl.style.transition = "none";
4258
+ noteEl.classList.remove("gg-hidden");
4259
+ noteEl.style.opacity = "1";
4260
+ requestAnimationFrame(() => requestAnimationFrame(() => {
4261
+ noteEl.style.transition = "";
4262
+ clearDrawStyles(noteEl);
4263
+ }));
4264
+ }
4265
+ else {
4266
+ noteEl.classList.remove("gg-hidden");
4267
+ prepareForDraw(noteEl);
4268
+ animateShapeDraw(noteEl, 420, 55);
4269
+ }
4270
+ return;
4271
+ }
4272
+ // ── Chart ──────────────────────────────────────────────
4273
+ const chartEl = getEl(this.svg, `chart-${target}`);
4274
+ if (chartEl) {
4275
+ if (silent) {
4276
+ clearDrawStyles(chartEl);
4277
+ chartEl.style.transition = "none";
4278
+ chartEl.style.opacity = "";
4279
+ chartEl.classList.remove("gg-hidden");
4280
+ chartEl.style.opacity = "1";
4281
+ requestAnimationFrame(() => requestAnimationFrame(() => {
4282
+ chartEl.style.transition = "";
4283
+ clearDrawStyles(chartEl);
4284
+ }));
4285
+ }
4286
+ else {
4287
+ chartEl.style.opacity = "0"; // start from 0 explicitly
4288
+ chartEl.classList.remove("gg-hidden");
4289
+ requestAnimationFrame(() => requestAnimationFrame(() => {
4290
+ chartEl.style.transition = "opacity 500ms ease";
4291
+ chartEl.style.opacity = "1";
4292
+ }));
4293
+ }
4294
+ return;
4295
+ }
4296
+ // ── Node draw ──────────────────────────────────────
4297
+ const nodeEl = getNodeEl(this.svg, target);
4298
+ if (!nodeEl)
4299
+ return;
4300
+ if (silent) {
4301
+ revealInstant(nodeEl);
4302
+ requestAnimationFrame(() => requestAnimationFrame(() => clearDrawStyles(nodeEl)));
4303
+ }
4304
+ else {
4305
+ const firstPath = nodeEl.querySelector("path");
4306
+ if (!firstPath?.style.strokeDasharray)
4307
+ prepareForDraw(nodeEl);
4308
+ animateShapeDraw(nodeEl, 420, 55);
4309
+ }
4310
+ }
4311
+ // ── erase ─────────────────────────────────────────────────
4312
+ _doErase(target) {
4313
+ const el = resolveEl(this.svg, target); // handles edges too now
4314
+ if (el) {
4315
+ el.style.transition = 'opacity 0.4s';
4316
+ el.style.opacity = '0';
4317
+ }
4318
+ }
4319
+ // ── show / hide ───────────────────────────────────────────
4320
+ _doShowHide(target, show, silent) {
4321
+ const el = resolveEl(this.svg, target);
4322
+ if (!el)
4323
+ return;
4324
+ el.style.transition = silent ? "none" : "opacity 0.4s";
4325
+ el.style.opacity = show ? "1" : "0";
4326
+ }
4327
+ // ── pulse ─────────────────────────────────────────────────
4328
+ _doPulse(target) {
4329
+ resolveEl(this.svg, target)?.animate([
4330
+ { filter: "brightness(1)" },
4331
+ { filter: "brightness(1.6)" },
4332
+ { filter: "brightness(1)" },
4333
+ ], { duration: 500, iterations: 3 });
4334
+ }
4335
+ // ── color ─────────────────────────────────────────────────
4336
+ _doColor(target, color) {
4337
+ if (!color)
4338
+ return;
4339
+ const el = resolveEl(this.svg, target);
4340
+ if (!el)
4341
+ return;
4342
+ // edge — color stroke
4343
+ if (parseEdgeTarget(target)) {
4344
+ el.querySelectorAll('path, line, polyline').forEach(p => {
4345
+ p.style.stroke = color;
4346
+ });
4347
+ el.querySelectorAll('polygon').forEach(p => {
4348
+ p.style.fill = color;
4349
+ p.style.stroke = color;
4350
+ });
4351
+ return;
4352
+ }
4353
+ // everything else — color fill
4354
+ let hit = false;
4355
+ el.querySelectorAll('path, rect, ellipse, polygon').forEach(c => {
4356
+ const attrFill = c.getAttribute('fill');
4357
+ if (attrFill === 'none')
4358
+ return;
4359
+ if (attrFill === null && c.tagName === 'path')
4360
+ return;
4361
+ c.style.fill = color;
4362
+ hit = true;
4363
+ });
4364
+ if (!hit) {
4365
+ el.querySelectorAll('text').forEach(t => { t.style.fill = color; });
4366
+ }
4367
+ }
4368
+ }
4369
+ const ANIMATION_CSS = `
4370
+ .ng, .gg, .tg, .ntg, .cg, .eg {
4371
+ transform-box: fill-box;
4372
+ transform-origin: center;
4373
+ transition: filter 0.3s, opacity 0.35s;
4374
+ }
4375
+
4376
+ /* highlight */
4377
+ .ng.hl path, .ng.hl rect, .ng.hl ellipse, .ng.hl polygon,
4378
+ .tg.hl path, .tg.hl rect,
4379
+ .ntg.hl path, .ntg.hl polygon,
4380
+ .cg.hl path, .cg.hl rect,
4381
+ .eg.hl path, .eg.hl line, .eg.hl polygon { stroke-width: 2.8 !important; }
4382
+
4383
+ .ng.hl, .tg.hl, .ntg.hl, .cg.hl, .eg.hl {
4384
+ animation: ng-pulse 1.4s ease-in-out infinite;
4385
+ }
4386
+ @keyframes ng-pulse {
4387
+ 0%, 100% { filter: drop-shadow(0 0 7px rgba(200,84,40,.6)); }
4388
+ 50% { filter: drop-shadow(0 0 14px rgba(200,84,40,.9)); }
4389
+ }
4390
+
4391
+ /* fade */
4392
+ .ng.faded, .gg.faded, .tg.faded, .ntg.faded, .cg.faded, .eg.faded { opacity: 0.22; }
4393
+
4394
+ .ng.hidden { opacity: 0; pointer-events: none; }
4395
+ .eg.draw-hidden { opacity: 0; }
4396
+ .eg.draw-reveal { opacity: 1; }
4397
+ .gg.gg-hidden { opacity: 0; }
4398
+ .tg.gg-hidden { opacity: 0; }
4399
+ .ntg.gg-hidden { opacity: 0; }
4400
+ .cg.gg-hidden { opacity: 0; }
4401
+ `;
4402
+
4403
+ // ============================================================
4404
+ // sketchmark — Export System
4405
+ // SVG, PNG, Canvas, GIF (stub), MP4 (stub)
4406
+ // ============================================================
4407
+ // ── Trigger browser download ──────────────────────────────
4408
+ function download(blob, filename) {
4409
+ const url = URL.createObjectURL(blob);
4410
+ const a = document.createElement('a');
4411
+ a.href = url;
4412
+ a.download = filename;
4413
+ document.body.appendChild(a);
4414
+ a.click();
4415
+ document.body.removeChild(a);
4416
+ setTimeout(() => URL.revokeObjectURL(url), 5000);
4417
+ }
4418
+ // ── SVG export ────────────────────────────────────────────
4419
+ function exportSVG(svg, opts = {}) {
4420
+ const str = svgToString(svg);
4421
+ const blob = new Blob([str], { type: 'image/svg+xml;charset=utf-8' });
4422
+ download(blob, opts.filename ?? 'diagram.svg');
4423
+ }
4424
+ function getSVGString(svg) {
4425
+ return svgToString(svg);
4426
+ }
4427
+ function getSVGBlob(svg) {
4428
+ return new Blob([svgToString(svg)], { type: 'image/svg+xml;charset=utf-8' });
4429
+ }
4430
+ // ── PNG export (from SVG via Canvas) ─────────────────────
4431
+ async function exportPNG(svg, opts = {}) {
4432
+ const dataUrl = await svgToPNGDataURL(svg, opts);
4433
+ const res = await fetch(dataUrl);
4434
+ const blob = await res.blob();
4435
+ download(blob, opts.filename ?? 'diagram.png');
4436
+ }
4437
+ async function svgToPNGDataURL(svg, opts = {}) {
4438
+ const scale = opts.scale ?? 2;
4439
+ const w = parseFloat(svg.getAttribute('width') ?? '400');
4440
+ const h = parseFloat(svg.getAttribute('height') ?? '300');
4441
+ const canvas = document.createElement('canvas');
4442
+ canvas.width = w * scale;
4443
+ canvas.height = h * scale;
4444
+ const ctx = canvas.getContext('2d');
4445
+ ctx.scale(scale, scale);
4446
+ if (opts.background) {
4447
+ ctx.fillStyle = opts.background;
4448
+ ctx.fillRect(0, 0, w, h);
4449
+ }
4450
+ else {
4451
+ ctx.fillStyle = '#f8f4ea';
4452
+ ctx.fillRect(0, 0, w, h);
4453
+ }
4454
+ const svgStr = svgToString(svg);
4455
+ const blob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
4456
+ const url = URL.createObjectURL(blob);
4457
+ await new Promise((resolve, reject) => {
4458
+ const img = new Image();
4459
+ img.onload = () => { ctx.drawImage(img, 0, 0); URL.revokeObjectURL(url); resolve(); };
4460
+ img.onerror = reject;
4461
+ img.src = url;
4462
+ });
4463
+ return canvas.toDataURL('image/png');
4464
+ }
4465
+ // ── Canvas PNG export ─────────────────────────────────────
4466
+ async function exportCanvasPNG(canvas, opts = {}) {
4467
+ const blob = await canvasToPNGBlob(canvas);
4468
+ download(blob, opts.filename ?? 'diagram.png');
4469
+ }
4470
+ // ── HTML export (self-contained) ──────────────────────────
4471
+ function exportHTML(svg, dslSource, opts = {}) {
4472
+ const svgStr = svgToString(svg);
4473
+ const html = `<!DOCTYPE html>
4474
+ <html lang="en">
4475
+ <head>
4476
+ <meta charset="utf-8">
4477
+ <meta name="viewport" content="width=device-width, initial-scale=1">
4478
+ <title>sketchmark export</title>
4479
+ <style>
4480
+ body { margin: 0; background: #f8f4ea; display: flex; flex-direction: column; align-items: center; padding: 2rem; font-family: system-ui, sans-serif; }
4481
+ .diagram { max-width: 100%; }
4482
+ .dsl { margin-top: 2rem; background: #131008; color: #e0c898; padding: 1rem; border-radius: 8px; font-family: monospace; font-size: 13px; line-height: 1.7; white-space: pre; max-width: 800px; width: 100%; overflow: auto; }
4483
+ </style>
4484
+ </head>
4485
+ <body>
4486
+ <div class="diagram">${svgStr}</div>
4487
+ <details class="dsl"><summary style="cursor:pointer;color:#f0c96a">DSL source</summary><pre>${escapeHtml(dslSource)}</pre></details>
4488
+ </body>
4489
+ </html>`;
4490
+ const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
4491
+ download(blob, opts.filename ?? 'diagram.html');
4492
+ }
4493
+ function escapeHtml(s) {
4494
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
4495
+ }
4496
+ // ── GIF stub (requires gifshot or gif.js at runtime) ──────
4497
+ async function exportGIF(frames, opts = {}) {
4498
+ // gifshot integration point
4499
+ throw new Error('GIF export requires gifshot to be loaded. See docs/EXPORT.md for setup.');
4500
+ }
4501
+ // ── MP4 stub (requires ffmpeg.wasm or MediaRecorder) ──────
4502
+ async function exportMP4(canvas, durationMs, opts = {}) {
4503
+ const fps = opts.fps ?? 30;
4504
+ const stream = canvas.captureStream?.(fps);
4505
+ if (!stream)
4506
+ throw new Error('captureStream not supported in this browser');
4507
+ return new Promise((resolve, reject) => {
4508
+ const chunks = [];
4509
+ const rec = new MediaRecorder(stream, {
4510
+ mimeType: 'video/webm;codecs=vp9',
4511
+ });
4512
+ rec.ondataavailable = e => { if (e.data.size)
4513
+ chunks.push(e.data); };
4514
+ rec.onstop = () => resolve(new Blob(chunks, { type: 'video/webm' }));
4515
+ rec.onerror = reject;
4516
+ rec.start();
4517
+ setTimeout(() => rec.stop(), durationMs);
4518
+ });
4519
+ }
4520
+
4521
+ var index = /*#__PURE__*/Object.freeze({
4522
+ __proto__: null,
4523
+ exportCanvasPNG: exportCanvasPNG,
4524
+ exportGIF: exportGIF,
4525
+ exportHTML: exportHTML,
4526
+ exportMP4: exportMP4,
4527
+ exportPNG: exportPNG,
4528
+ exportSVG: exportSVG,
4529
+ getSVGBlob: getSVGBlob,
4530
+ getSVGString: getSVGString,
4531
+ svgToPNGDataURL: svgToPNGDataURL
4532
+ });
4533
+
4534
+ // ============================================================
4535
+ // sketchmark — Utility Helpers
4536
+ // ============================================================
4537
+ function hashStr(s) {
4538
+ let h = 5381;
4539
+ for (let i = 0; i < s.length; i++)
4540
+ h = ((h * 33) ^ s.charCodeAt(i)) & 0xffff;
4541
+ return h;
4542
+ }
4543
+ function clamp(v, min, max) {
4544
+ return Math.max(min, Math.min(max, v));
4545
+ }
4546
+ function lerp(a, b, t) {
4547
+ return a + (b - a) * t;
4548
+ }
4549
+ function parseHex(hex) {
4550
+ const clean = hex.replace('#', '');
4551
+ if (clean.length === 3) {
4552
+ const r = parseInt(clean[0] + clean[0], 16);
4553
+ const g = parseInt(clean[1] + clean[1], 16);
4554
+ const b = parseInt(clean[2] + clean[2], 16);
4555
+ return [r, g, b];
4556
+ }
4557
+ if (clean.length === 6) {
4558
+ return [
4559
+ parseInt(clean.slice(0, 2), 16),
4560
+ parseInt(clean.slice(2, 4), 16),
4561
+ parseInt(clean.slice(4, 6), 16),
4562
+ ];
4563
+ }
4564
+ return null;
4565
+ }
4566
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
4567
+ function throttle(fn, ms) {
4568
+ let last = 0;
4569
+ return ((...args) => {
4570
+ const now = Date.now();
4571
+ if (now - last >= ms) {
4572
+ last = now;
4573
+ fn(...args);
4574
+ }
4575
+ });
4576
+ }
4577
+ function debounce(fn, ms) {
4578
+ let tid;
4579
+ return ((...args) => {
4580
+ clearTimeout(tid);
4581
+ tid = setTimeout(() => fn(...args), ms);
4582
+ });
4583
+ }
4584
+ class EventEmitter {
4585
+ constructor() {
4586
+ this._ls = new Map();
4587
+ }
4588
+ on(event, fn) {
4589
+ if (!this._ls.has(event))
4590
+ this._ls.set(event, new Set());
4591
+ this._ls.get(event).add(fn);
4592
+ return this;
4593
+ }
4594
+ off(event, fn) {
4595
+ this._ls.get(event)?.delete(fn);
4596
+ return this;
4597
+ }
4598
+ emit(event, data) {
4599
+ this._ls.get(event)?.forEach(fn => fn(data));
4600
+ return this;
4601
+ }
4602
+ }
4603
+
4604
+ // ============================================================
4605
+ // sketchmark — Public API
4606
+ // ============================================================
4607
+ // ── Core Pipeline ─────────────────────────────────────────
4608
+ function render(options) {
4609
+ const { container: rawContainer, dsl, renderer = 'svg', injectCSS = true, svgOptions = {}, canvasOptions = {}, onNodeClick, onReady, } = options;
4610
+ // Inject animation CSS once
4611
+ if (injectCSS && !document.getElementById('ai-diagram-css')) {
4612
+ const style = document.createElement('style');
4613
+ style.id = 'ai-diagram-css';
4614
+ style.textContent = ANIMATION_CSS;
4615
+ document.head.appendChild(style);
4616
+ }
4617
+ // Resolve container
4618
+ let el;
4619
+ if (typeof rawContainer === 'string') {
4620
+ el = document.querySelector(rawContainer);
4621
+ if (!el)
4622
+ throw new Error(`Container "${rawContainer}" not found`);
4623
+ }
4624
+ else {
4625
+ el = rawContainer;
4626
+ }
4627
+ // Pipeline: DSL → AST → Scene → Layout → Render
4628
+ const ast = parse(dsl);
4629
+ const scene = buildSceneGraph(ast);
4630
+ layout(scene);
4631
+ let svg;
4632
+ let canvas;
4633
+ let anim;
4634
+ if (renderer === 'canvas') {
4635
+ canvas = el instanceof HTMLCanvasElement
4636
+ ? el
4637
+ : (() => { const c = document.createElement('canvas'); el.appendChild(c); return c; })();
4638
+ renderToCanvas(scene, canvas, canvasOptions);
4639
+ anim = new AnimationController(document.createElementNS('http://www.w3.org/2000/svg', 'svg'), ast.steps);
4640
+ }
4641
+ else {
4642
+ svg = renderToSVG(scene, el, {
4643
+ ...svgOptions,
4644
+ interactive: true,
4645
+ onNodeClick,
4646
+ });
4647
+ anim = new AnimationController(svg, ast.steps);
4648
+ }
4649
+ onReady?.(anim, svg);
4650
+ const instance = {
4651
+ scene, anim, svg, canvas,
4652
+ update: (newDsl) => render({ ...options, dsl: newDsl }),
4653
+ exportSVG: (filename = 'diagram.svg') => {
4654
+ if (svg) {
4655
+ Promise.resolve().then(function () { return index; }).then(m => m.exportSVG(svg, { filename }));
4656
+ }
4657
+ },
4658
+ exportPNG: async (filename = 'diagram.png') => {
4659
+ if (svg) {
4660
+ const m = await Promise.resolve().then(function () { return index; });
4661
+ await m.exportPNG(svg, { filename });
4662
+ }
4663
+ },
4664
+ };
4665
+ return instance;
4666
+ }
4667
+
4668
+ export { ANIMATION_CSS, AnimationController, EventEmitter, PALETTES, ParseError, THEME_CONFIG_KEY, THEME_NAMES, buildSceneGraph, canvasToPNGBlob, canvasToPNGDataURL, clamp, connPoint, debounce, exportCanvasPNG, exportGIF, exportHTML, exportMP4, exportPNG, exportSVG, getSVGBlob, groupMap, hashStr, layout, lerp, listThemes, nodeMap, parse, parseHex, render, renderToCanvas, renderToSVG, resolvePalette, sleep, svgToPNGDataURL, svgToString, throttle };
4669
+ //# sourceMappingURL=index.js.map