sketchmark 0.2.7 → 0.2.8

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 (62) hide show
  1. package/README.md +1041 -1066
  2. package/dist/animation/index.d.ts +6 -1
  3. package/dist/animation/index.d.ts.map +1 -1
  4. package/dist/ast/types.d.ts +6 -17
  5. package/dist/ast/types.d.ts.map +1 -1
  6. package/dist/config.d.ts +150 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/export/index.d.ts.map +1 -1
  9. package/dist/index.cjs +1566 -1352
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.js +1566 -1352
  12. package/dist/index.js.map +1 -1
  13. package/dist/layout/entity-rect.d.ts +9 -0
  14. package/dist/layout/entity-rect.d.ts.map +1 -0
  15. package/dist/layout/index.d.ts.map +1 -1
  16. package/dist/markdown/parser.d.ts.map +1 -1
  17. package/dist/parser/index.d.ts.map +1 -1
  18. package/dist/parser/tokenizer.d.ts.map +1 -1
  19. package/dist/renderer/canvas/index.d.ts.map +1 -1
  20. package/dist/renderer/roughChart.d.ts.map +1 -1
  21. package/dist/renderer/shapes/box.d.ts +3 -0
  22. package/dist/renderer/shapes/box.d.ts.map +1 -0
  23. package/dist/renderer/shapes/circle.d.ts +3 -0
  24. package/dist/renderer/shapes/circle.d.ts.map +1 -0
  25. package/dist/renderer/shapes/cylinder.d.ts +3 -0
  26. package/dist/renderer/shapes/cylinder.d.ts.map +1 -0
  27. package/dist/renderer/shapes/diamond.d.ts +3 -0
  28. package/dist/renderer/shapes/diamond.d.ts.map +1 -0
  29. package/dist/renderer/shapes/hexagon.d.ts +3 -0
  30. package/dist/renderer/shapes/hexagon.d.ts.map +1 -0
  31. package/dist/renderer/shapes/icon.d.ts +3 -0
  32. package/dist/renderer/shapes/icon.d.ts.map +1 -0
  33. package/dist/renderer/shapes/image.d.ts +3 -0
  34. package/dist/renderer/shapes/image.d.ts.map +1 -0
  35. package/dist/renderer/shapes/index.d.ts +4 -0
  36. package/dist/renderer/shapes/index.d.ts.map +1 -0
  37. package/dist/renderer/shapes/line.d.ts +3 -0
  38. package/dist/renderer/shapes/line.d.ts.map +1 -0
  39. package/dist/renderer/shapes/note.d.ts +3 -0
  40. package/dist/renderer/shapes/note.d.ts.map +1 -0
  41. package/dist/renderer/shapes/parallelogram.d.ts +3 -0
  42. package/dist/renderer/shapes/parallelogram.d.ts.map +1 -0
  43. package/dist/renderer/shapes/path.d.ts +3 -0
  44. package/dist/renderer/shapes/path.d.ts.map +1 -0
  45. package/dist/renderer/shapes/registry.d.ts +5 -0
  46. package/dist/renderer/shapes/registry.d.ts.map +1 -0
  47. package/dist/renderer/shapes/text-shape.d.ts +3 -0
  48. package/dist/renderer/shapes/text-shape.d.ts.map +1 -0
  49. package/dist/renderer/shapes/triangle.d.ts +3 -0
  50. package/dist/renderer/shapes/triangle.d.ts.map +1 -0
  51. package/dist/renderer/shapes/types.d.ts +50 -0
  52. package/dist/renderer/shapes/types.d.ts.map +1 -0
  53. package/dist/renderer/shared.d.ts +26 -0
  54. package/dist/renderer/shared.d.ts.map +1 -0
  55. package/dist/renderer/svg/index.d.ts.map +1 -1
  56. package/dist/renderer/svg/roughChartSVG.d.ts.map +1 -1
  57. package/dist/renderer/typography.d.ts +27 -0
  58. package/dist/renderer/typography.d.ts.map +1 -0
  59. package/dist/scene/index.d.ts +5 -13
  60. package/dist/scene/index.d.ts.map +1 -1
  61. package/dist/sketchmark.iife.js +1567 -1353
  62. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -18,6 +18,8 @@ const KEYWORDS = new Set([
18
18
  "text",
19
19
  "image",
20
20
  "icon",
21
+ "line",
22
+ "path",
21
23
  "group",
22
24
  "style",
23
25
  "step",
@@ -221,7 +223,7 @@ function uid(prefix) {
221
223
  function resetUid() {
222
224
  _uid = 0;
223
225
  }
224
- const SHAPES = [
226
+ const SHAPES$1 = [
225
227
  "box",
226
228
  "circle",
227
229
  "diamond",
@@ -232,6 +234,8 @@ const SHAPES = [
232
234
  "text",
233
235
  "image",
234
236
  "icon",
237
+ "line",
238
+ "path",
235
239
  ];
236
240
  const CHART_TYPES = [
237
241
  "bar-chart",
@@ -312,7 +316,6 @@ function parse(src) {
312
316
  edges: [],
313
317
  groups: [],
314
318
  steps: [],
315
- notes: [],
316
319
  charts: [],
317
320
  tables: [],
318
321
  markdowns: [],
@@ -323,7 +326,6 @@ function parse(src) {
323
326
  };
324
327
  const nodeIds = new Set();
325
328
  const tableIds = new Set();
326
- const noteIds = new Set();
327
329
  const chartIds = new Set();
328
330
  const groupIds = new Set();
329
331
  const markdownIds = new Set();
@@ -422,10 +424,14 @@ function parse(src) {
422
424
  kind: "node",
423
425
  id,
424
426
  shape,
425
- label: props.label || id,
427
+ label: props.label || "",
426
428
  ...(groupId ? { groupId } : {}),
427
429
  ...(props.width ? { width: parseFloat(props.width) } : {}),
428
430
  ...(props.height ? { height: parseFloat(props.height) } : {}),
431
+ ...(props.deg ? { deg: parseFloat(props.deg) } : {}),
432
+ ...(props.dx ? { dx: parseFloat(props.dx) } : {}),
433
+ ...(props.dy ? { dy: parseFloat(props.dy) } : {}),
434
+ ...(props.factor ? { factor: parseFloat(props.factor) } : {}),
429
435
  ...(props.theme ? { theme: props.theme } : {}),
430
436
  style: propsToStyle(props),
431
437
  };
@@ -433,6 +439,8 @@ function parse(src) {
433
439
  node.imageUrl = props.url;
434
440
  if (props.name)
435
441
  node.iconName = props.name;
442
+ if (props.value)
443
+ node.pathData = props.value;
436
444
  return node;
437
445
  }
438
446
  function parseEdge(fromId, connector, rest) {
@@ -473,7 +481,7 @@ function parse(src) {
473
481
  style: propsToStyle(props),
474
482
  };
475
483
  }
476
- // ── parseNote ────────────────────────────────────────────
484
+ // ── parseNote → returns ASTNode with shape='note' ────────
477
485
  function parseNote(groupId) {
478
486
  skip(); // 'note'
479
487
  const toks = lineTokens();
@@ -503,9 +511,11 @@ function parse(src) {
503
511
  // Support multiline via literal \n in label string
504
512
  const rawLabel = props.label ?? "";
505
513
  return {
506
- kind: "note",
514
+ kind: "node",
507
515
  id,
516
+ shape: "note",
508
517
  label: rawLabel.replace(/\\n/g, "\n"),
518
+ groupId,
509
519
  theme: props.theme,
510
520
  style: propsToStyle(props),
511
521
  ...(props.width ? { width: parseFloat(props.width) } : {}),
@@ -593,12 +603,12 @@ function parse(src) {
593
603
  group.children.push({ kind: "table", id: tbl.id });
594
604
  continue;
595
605
  }
596
- // ── Note ──────────────────────────────────────────
606
+ // ── Note (parsed as node with shape='note') ──────
597
607
  if (v === "note") {
598
608
  const note = parseNote(id);
599
- ast.notes.push(note);
600
- noteIds.add(note.id);
601
- group.children.push({ kind: "note", id: note.id });
609
+ ast.nodes.push(note);
610
+ nodeIds.add(note.id);
611
+ group.children.push({ kind: "node", id: note.id });
602
612
  continue;
603
613
  }
604
614
  // ── Markdown ───────────────────────────────────────
@@ -625,7 +635,7 @@ function parse(src) {
625
635
  continue;
626
636
  }
627
637
  // ── Node shape ────────────────────────────────────
628
- if (SHAPES.includes(v)) {
638
+ if (SHAPES$1.includes(v)) {
629
639
  const node = parseNode(v, id);
630
640
  if (!nodeIds.has(node.id)) {
631
641
  nodeIds.add(node.id);
@@ -827,7 +837,7 @@ function parse(src) {
827
837
  props[k] = cur().value;
828
838
  skip();
829
839
  }
830
- else if (SHAPES.includes(v) ||
840
+ else if (SHAPES$1.includes(v) ||
831
841
  v === "step" ||
832
842
  v === "group" ||
833
843
  v === "note" || // ← ADD
@@ -887,7 +897,7 @@ function parse(src) {
887
897
  const table = {
888
898
  kind: "table",
889
899
  id,
890
- label: props.label ?? id,
900
+ label: props.label ?? "",
891
901
  rows: [],
892
902
  theme: props.theme,
893
903
  style: propsToStyle(props),
@@ -1098,12 +1108,12 @@ function parse(src) {
1098
1108
  ast.rootOrder.push({ kind: "table", id: tbl.id });
1099
1109
  continue;
1100
1110
  }
1101
- // note
1111
+ // note (parsed as node with shape='note')
1102
1112
  if (v === "note") {
1103
1113
  const note = parseNote();
1104
- ast.notes.push(note);
1105
- noteIds.add(note.id);
1106
- ast.rootOrder.push({ kind: "note", id: note.id });
1114
+ ast.nodes.push(note);
1115
+ nodeIds.add(note.id);
1116
+ ast.rootOrder.push({ kind: "node", id: note.id });
1107
1117
  continue;
1108
1118
  }
1109
1119
  // step
@@ -1140,7 +1150,6 @@ function parse(src) {
1140
1150
  for (const nid of [fromId, edge.to]) {
1141
1151
  if (!nodeIds.has(nid) &&
1142
1152
  !tableIds.has(nid) &&
1143
- !noteIds.has(nid) &&
1144
1153
  !chartIds.has(nid) &&
1145
1154
  !groupIds.has(nid)) {
1146
1155
  nodeIds.add(nid);
@@ -1158,7 +1167,7 @@ function parse(src) {
1158
1167
  }
1159
1168
  }
1160
1169
  // node shapes — only reached if NOT followed by an arrow
1161
- if (SHAPES.includes(v)) {
1170
+ if (SHAPES$1.includes(v)) {
1162
1171
  const node = parseNode(v);
1163
1172
  if (!nodeIds.has(node.id)) {
1164
1173
  nodeIds.add(node.id);
@@ -1179,32 +1188,146 @@ function parse(src) {
1179
1188
  }
1180
1189
 
1181
1190
  // ============================================================
1182
- // sketchmark — Markdown inline parser
1183
- // Supports: # h1 ## h2 ### h3 **bold** *italic* blank lines
1191
+ // sketchmark — Design Tokens (single source of truth)
1192
+ //
1193
+ // All layout, sizing, typography, and rendering constants live
1194
+ // here. Import from this file instead of scattering magic
1195
+ // numbers across modules.
1184
1196
  // ============================================================
1185
- // ── Font sizes per line kind ──────────────────────────────
1186
- const LINE_FONT_SIZE = {
1187
- h1: 40,
1188
- h2: 28,
1189
- h3: 20,
1190
- p: 15,
1191
- blank: 0,
1197
+ // ── Layout ─────────────────────────────────────────────────
1198
+ const LAYOUT = {
1199
+ margin: 60, // default canvas margin (px)
1200
+ gap: 80, // default gap between root-level items (px)
1201
+ groupLabelH: 22, // height reserved for group label strip (px)
1202
+ groupPad: 26, // default group inner padding (px)
1203
+ groupGap: 10, // default gap between items inside a group (px)
1204
+ };
1205
+ // ── Node sizing ────────────────────────────────────────────
1206
+ const NODE = {
1207
+ minW: 90, // minimum auto-sized node width (px)
1208
+ maxW: 180, // maximum auto-sized node width (px)
1209
+ fontPxPerChar: 8.6, // approximate px per character for label width
1210
+ basePad: 26, // base padding added to label width (px)
1211
+ };
1212
+ // ── Shape-specific sizing ──────────────────────────────────
1213
+ const SHAPES = {
1214
+ cylinder: { defaultH: 66, ellipseH: 18 },
1215
+ diamond: { minW: 130, minH: 62, aspect: 0.46, labelPad: 30 },
1216
+ hexagon: { minW: 126, minH: 54, aspect: 0.44, labelPad: 20, inset: 0.56 },
1217
+ triangle: { minW: 108, minH: 64, aspect: 0.6, labelPad: 10 },
1218
+ parallelogram: { defaultH: 50, labelPad: 28, skew: 18 },
1219
+ };
1220
+ // ── Table sizing ───────────────────────────────────────────
1221
+ const TABLE = {
1222
+ cellPad: 20, // total horizontal padding per cell (px)
1223
+ minColW: 50, // minimum column width (px)
1224
+ fontPxPerChar: 7.5, // approx px per char at 12px sans-serif
1225
+ rowH: 30, // data row height (px)
1226
+ headerH: 34, // header row height (px)
1227
+ labelH: 22, // label strip height (px)
1228
+ };
1229
+ // ── Note shape ─────────────────────────────────────────────
1230
+ const NOTE = {
1231
+ lineH: 20, // line height for note text (px)
1232
+ padX: 16, // horizontal padding (px)
1233
+ padY: 12, // vertical padding (px)
1234
+ fontPxPerChar: 7.5, // approx px per char for note text
1235
+ fold: 14, // fold corner size (px)
1236
+ minW: 120, // minimum note width (px)
1237
+ };
1238
+ // ── Typography defaults ────────────────────────────────────
1239
+ const TYPOGRAPHY = {
1240
+ defaultFontSize: 14,
1241
+ defaultFontWeight: 500,
1242
+ defaultLineHeight: 1.3, // multiplier (× fontSize = px)
1243
+ defaultPadding: 8,
1244
+ defaultAlign: "center",
1245
+ defaultVAlign: "middle",
1246
+ };
1247
+ // ── Title ──────────────────────────────────────────────────
1248
+ const TITLE = {
1249
+ y: 26, // baseline Y position (px)
1250
+ fontSize: 18, // default title font size
1251
+ fontWeight: 600, // default title font weight
1252
+ };
1253
+ // ── Group label typography ─────────────────────────────────
1254
+ const GROUP_LABEL = {
1255
+ fontSize: 12,
1256
+ fontWeight: 500,
1257
+ padding: 14,
1192
1258
  };
1193
- const LINE_FONT_WEIGHT = {
1194
- h1: 700,
1195
- h2: 600,
1196
- h3: 600,
1197
- p: 400,
1198
- blank: 400,
1259
+ // ── Edge / arrow ───────────────────────────────────────────
1260
+ const EDGE = {
1261
+ arrowSize: 12, // arrowhead polygon size (px)
1262
+ headInset: 13, // line shortening for arrowhead overlap (px)
1263
+ labelOffset: 14, // perpendicular offset of label from edge line (px)
1264
+ labelFontSize: 11, // default edge label font size
1265
+ labelFontWeight: 400, // default edge label font weight
1266
+ dashPattern: [6, 5], // stroke-dasharray for dashed edges
1199
1267
  };
1200
- // Spacing below each line kind (px)
1201
- const LINE_SPACING = {
1202
- h1: 52,
1203
- h2: 38,
1204
- h3: 28,
1205
- p: 22,
1206
- blank: 10,
1268
+ // ── Markdown typography ────────────────────────────────────
1269
+ const MARKDOWN = {
1270
+ fontSize: { h1: 40, h2: 28, h3: 20, p: 15, blank: 0 },
1271
+ fontWeight: { h1: 700, h2: 600, h3: 600, p: 400, blank: 400 },
1272
+ spacing: { h1: 52, h2: 38, h3: 28, p: 22, blank: 10 },
1273
+ defaultPad: 16,
1207
1274
  };
1275
+ // ── Rough.js rendering ─────────────────────────────────────
1276
+ const ROUGH = {
1277
+ roughness: 1.3, // default roughness for nodes/edges
1278
+ chartRoughness: 1.2, // slightly smoother for chart elements
1279
+ bowing: 0.7,
1280
+ };
1281
+ // ── Chart layout ───────────────────────────────────────────
1282
+ const CHART = {
1283
+ titleH: 24, // title strip height when label present (px)
1284
+ titleHEmpty: 8, // title strip height when no label (px)
1285
+ padL: 44, // left padding for plot area (px)
1286
+ padR: 12, // right padding (px)
1287
+ padT: 6, // top padding (px)
1288
+ padB: 28, // bottom padding (px)
1289
+ defaultW: 320, // default chart width (px)
1290
+ defaultH: 240, // default chart height (px)
1291
+ };
1292
+ // ── Animation timing ───────────────────────────────────────
1293
+ const ANIMATION = {
1294
+ // Edge drawing
1295
+ strokeDur: 360, // edge stroke-draw duration (ms)
1296
+ arrowReveal: 120, // arrow fade-in delay after stroke (ms)
1297
+ dashClear: 160, // delay before clearing dash overrides (ms)
1298
+ // Shape drawing (per entity type)
1299
+ nodeStrokeDur: 420, // node stroke-draw duration (ms)
1300
+ nodeStagger: 55, // stagger between node paths (ms)
1301
+ groupStrokeDur: 550, // group stroke-draw duration (ms)
1302
+ groupStagger: 40, // stagger between group paths (ms)
1303
+ tableStrokeDur: 500, // table stroke-draw duration (ms)
1304
+ tableStagger: 40, // stagger between table paths (ms)
1305
+ // Text / misc
1306
+ textFade: 200, // text opacity fade-in duration (ms)
1307
+ fillFadeOffset: -60, // fill-opacity start relative to stroke end (ms)
1308
+ textDelay: 80, // extra buffer before text reveals (ms)
1309
+ chartFade: 500, // chart/markdown opacity transition (ms)
1310
+ };
1311
+ // ── Export defaults ────────────────────────────────────────
1312
+ const EXPORT = {
1313
+ pngScale: 2, // default PNG pixel density multiplier
1314
+ fallbackW: 400, // fallback SVG width when not set (px)
1315
+ fallbackH: 300, // fallback SVG height when not set (px)
1316
+ fallbackBg: "#f8f4ea", // default PNG/HTML background color
1317
+ revokeDelay: 5000, // blob URL revocation delay (ms)
1318
+ defaultFps: 30, // default video FPS
1319
+ };
1320
+ // ── SVG namespace ──────────────────────────────────────────
1321
+ const SVG_NS$1 = "http://www.w3.org/2000/svg";
1322
+
1323
+ // ============================================================
1324
+ // sketchmark — Markdown inline parser
1325
+ // Supports: # h1 ## h2 ### h3 **bold** *italic* blank lines
1326
+ // ============================================================
1327
+ // ── Font sizes per line kind (re-exported from config) ───
1328
+ const LINE_FONT_SIZE = { ...MARKDOWN.fontSize };
1329
+ const LINE_FONT_WEIGHT = { ...MARKDOWN.fontWeight };
1330
+ const LINE_SPACING = { ...MARKDOWN.spacing };
1208
1331
  // ── Parse a full markdown string into lines ───────────────
1209
1332
  function parseMarkdownContent(content) {
1210
1333
  const raw = content.split('\n');
@@ -1256,7 +1379,7 @@ function parseInline(text) {
1256
1379
  return runs;
1257
1380
  }
1258
1381
  // ── Calculate natural height of a parsed block ────────────
1259
- function calcMarkdownHeight(lines, pad = 16) {
1382
+ function calcMarkdownHeight(lines, pad = MARKDOWN.defaultPad) {
1260
1383
  let h = pad * 2; // top + bottom
1261
1384
  for (const line of lines)
1262
1385
  h += LINE_SPACING[line.kind];
@@ -1278,9 +1401,14 @@ function buildSceneGraph(ast) {
1278
1401
  groupId: n.groupId,
1279
1402
  width: n.width,
1280
1403
  height: n.height,
1404
+ deg: n.deg,
1405
+ dx: n.dx,
1406
+ dy: n.dy,
1407
+ factor: n.factor,
1281
1408
  meta: n.meta,
1282
1409
  imageUrl: n.imageUrl,
1283
1410
  iconName: n.iconName,
1411
+ pathData: n.pathData,
1284
1412
  x: 0,
1285
1413
  y: 0,
1286
1414
  w: 0,
@@ -1296,8 +1424,8 @@ function buildSceneGraph(ast) {
1296
1424
  children: g.children,
1297
1425
  layout: (g.layout ?? "column"),
1298
1426
  columns: g.columns ?? 1,
1299
- padding: g.padding ?? 26,
1300
- gap: g.gap ?? 10,
1427
+ padding: g.padding ?? LAYOUT.groupPad,
1428
+ gap: g.gap ?? LAYOUT.groupGap,
1301
1429
  align: (g.align ?? "start"),
1302
1430
  justify: (g.justify ?? "start"),
1303
1431
  style: { ...ast.styles[g.id], ...themeStyle, ...g.style },
@@ -1316,9 +1444,9 @@ function buildSceneGraph(ast) {
1316
1444
  label: t.label,
1317
1445
  rows: t.rows,
1318
1446
  colWidths: [],
1319
- rowH: 30,
1320
- headerH: 34,
1321
- labelH: 22,
1447
+ rowH: TABLE.rowH,
1448
+ headerH: TABLE.headerH,
1449
+ labelH: TABLE.labelH,
1322
1450
  style: { ...ast.styles[t.id], ...themeStyle, ...t.style },
1323
1451
  x: 0,
1324
1452
  y: 0,
@@ -1326,20 +1454,6 @@ function buildSceneGraph(ast) {
1326
1454
  h: 0,
1327
1455
  };
1328
1456
  });
1329
- const notes = ast.notes.map((n) => {
1330
- const themeStyle = n.theme ? (ast.themes[n.theme] ?? {}) : {};
1331
- return {
1332
- id: n.id,
1333
- lines: n.label.split("\n"),
1334
- style: { ...ast.styles[n.id], ...themeStyle, ...n.style },
1335
- x: 0,
1336
- y: 0,
1337
- w: 0,
1338
- h: 0,
1339
- width: n.width,
1340
- height: n.height,
1341
- };
1342
- });
1343
1457
  const charts = ast.charts.map((c) => {
1344
1458
  const themeStyle = c.theme ? (ast.themes[c.theme] ?? {}) : {};
1345
1459
  return {
@@ -1350,8 +1464,8 @@ function buildSceneGraph(ast) {
1350
1464
  style: { ...ast.styles[c.id], ...themeStyle, ...c.style },
1351
1465
  x: 0,
1352
1466
  y: 0,
1353
- w: c.width ?? 320,
1354
- h: c.height ?? 240,
1467
+ w: c.width ?? CHART.defaultW,
1468
+ h: c.height ?? CHART.defaultH,
1355
1469
  };
1356
1470
  });
1357
1471
  const markdowns = (ast.markdowns ?? []).map(m => {
@@ -1394,7 +1508,6 @@ function buildSceneGraph(ast) {
1394
1508
  edges,
1395
1509
  groups,
1396
1510
  tables,
1397
- notes,
1398
1511
  charts,
1399
1512
  markdowns,
1400
1513
  animation: { steps: ast.steps, currentStep: -1 },
@@ -1415,9 +1528,6 @@ function groupMap(sg) {
1415
1528
  function tableMap(sg) {
1416
1529
  return new Map(sg.tables.map((t) => [t.id, t]));
1417
1530
  }
1418
- function noteMap(sg) {
1419
- return new Map(sg.notes.map((n) => [n.id, n]));
1420
- }
1421
1531
  function chartMap(sg) {
1422
1532
  return new Map(sg.charts.map((c) => [c.id, c]));
1423
1533
  }
@@ -1425,6 +1535,688 @@ function markdownMap(sg) {
1425
1535
  return new Map((sg.markdowns ?? []).map(m => [m.id, m]));
1426
1536
  }
1427
1537
 
1538
+ // ============================================================
1539
+ // Entity Rect Map — unified lookup for all positionable entities
1540
+ //
1541
+ // Every scene entity (node, group, table, chart, markdown)
1542
+ // has { x, y, w, h }. This map lets layout code look up any
1543
+ // entity by ID without kind dispatch.
1544
+ // ============================================================
1545
+ function buildEntityMap(sg) {
1546
+ const m = new Map();
1547
+ for (const n of sg.nodes)
1548
+ m.set(n.id, n);
1549
+ for (const g of sg.groups)
1550
+ m.set(g.id, g);
1551
+ for (const t of sg.tables)
1552
+ m.set(t.id, t);
1553
+ for (const c of sg.charts)
1554
+ m.set(c.id, c);
1555
+ for (const md of sg.markdowns)
1556
+ m.set(md.id, md);
1557
+ return m;
1558
+ }
1559
+
1560
+ // ============================================================
1561
+ // Shape Strategy Interfaces
1562
+ // ============================================================
1563
+ // Re-export from centralized config for backward compatibility
1564
+ const MIN_W = NODE.minW;
1565
+ const MAX_W = NODE.maxW;
1566
+ const SVG_NS = SVG_NS$1;
1567
+
1568
+ // ============================================================
1569
+ // Shape Registry — Strategy pattern for extensible shapes
1570
+ // ============================================================
1571
+ const shapes = new Map();
1572
+ function registerShape(name, def) {
1573
+ shapes.set(name, def);
1574
+ }
1575
+ function getShape(name) {
1576
+ return shapes.get(name);
1577
+ }
1578
+
1579
+ const boxShape = {
1580
+ size(n, labelW) {
1581
+ n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW));
1582
+ n.h = n.h || 52;
1583
+ },
1584
+ renderSVG(rc, n, _palette, opts) {
1585
+ return [rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, opts)];
1586
+ },
1587
+ renderCanvas(rc, _ctx, n, _palette, opts) {
1588
+ rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, opts);
1589
+ },
1590
+ };
1591
+
1592
+ const circleShape = {
1593
+ size(n, labelW) {
1594
+ n.w = n.w || Math.max(84, Math.min(MAX_W, labelW));
1595
+ n.h = n.h || n.w;
1596
+ },
1597
+ renderSVG(rc, n, _palette, opts) {
1598
+ const cx = n.x + n.w / 2, cy = n.y + n.h / 2;
1599
+ return [rc.ellipse(cx, cy, n.w * 0.88, n.h * 0.88, opts)];
1600
+ },
1601
+ renderCanvas(rc, _ctx, n, _palette, opts) {
1602
+ const cx = n.x + n.w / 2, cy = n.y + n.h / 2;
1603
+ rc.ellipse(cx, cy, n.w * 0.88, n.h * 0.88, opts);
1604
+ },
1605
+ };
1606
+
1607
+ const diamondShape = {
1608
+ size(n, labelW) {
1609
+ n.w = n.w || Math.max(SHAPES.diamond.minW, Math.min(MAX_W, labelW + SHAPES.diamond.labelPad));
1610
+ n.h = n.h || Math.max(SHAPES.diamond.minH, n.w * SHAPES.diamond.aspect);
1611
+ },
1612
+ renderSVG(rc, n, _palette, opts) {
1613
+ const cx = n.x + n.w / 2, cy = n.y + n.h / 2;
1614
+ const hw = n.w / 2 - 2;
1615
+ return [rc.polygon([[cx, n.y + 2], [cx + hw, cy], [cx, n.y + n.h - 2], [cx - hw, cy]], opts)];
1616
+ },
1617
+ renderCanvas(rc, _ctx, n, _palette, opts) {
1618
+ const cx = n.x + n.w / 2, cy = n.y + n.h / 2;
1619
+ const hw = n.w / 2 - 2;
1620
+ rc.polygon([[cx, n.y + 2], [cx + hw, cy], [cx, n.y + n.h - 2], [cx - hw, cy]], opts);
1621
+ },
1622
+ };
1623
+
1624
+ const hexagonShape = {
1625
+ size(n, labelW) {
1626
+ n.w = n.w || Math.max(SHAPES.hexagon.minW, Math.min(MAX_W, labelW + SHAPES.hexagon.labelPad));
1627
+ n.h = n.h || Math.max(SHAPES.hexagon.minH, n.w * SHAPES.hexagon.aspect);
1628
+ },
1629
+ renderSVG(rc, n, _palette, opts) {
1630
+ const cx = n.x + n.w / 2, cy = n.y + n.h / 2;
1631
+ const hw = n.w / 2 - 2;
1632
+ const hw2 = hw * SHAPES.hexagon.inset;
1633
+ return [rc.polygon([
1634
+ [cx - hw2, n.y + 3], [cx + hw2, n.y + 3], [cx + hw, cy],
1635
+ [cx + hw2, n.y + n.h - 3], [cx - hw2, n.y + n.h - 3], [cx - hw, cy],
1636
+ ], opts)];
1637
+ },
1638
+ renderCanvas(rc, _ctx, n, _palette, opts) {
1639
+ const cx = n.x + n.w / 2, cy = n.y + n.h / 2;
1640
+ const hw = n.w / 2 - 2;
1641
+ const hw2 = hw * SHAPES.hexagon.inset;
1642
+ rc.polygon([
1643
+ [cx - hw2, n.y + 3], [cx + hw2, n.y + 3], [cx + hw, cy],
1644
+ [cx + hw2, n.y + n.h - 3], [cx - hw2, n.y + n.h - 3], [cx - hw, cy],
1645
+ ], opts);
1646
+ },
1647
+ };
1648
+
1649
+ const triangleShape = {
1650
+ size(n, labelW) {
1651
+ n.w = n.w || Math.max(SHAPES.triangle.minW, Math.min(MAX_W, labelW + SHAPES.triangle.labelPad));
1652
+ n.h = n.h || Math.max(SHAPES.triangle.minH, n.w * SHAPES.triangle.aspect);
1653
+ },
1654
+ renderSVG(rc, n, _palette, opts) {
1655
+ const cx = n.x + n.w / 2;
1656
+ return [rc.polygon([
1657
+ [cx, n.y + 3],
1658
+ [n.x + n.w - 3, n.y + n.h - 3],
1659
+ [n.x + 3, n.y + n.h - 3],
1660
+ ], opts)];
1661
+ },
1662
+ renderCanvas(rc, _ctx, n, _palette, opts) {
1663
+ const cx = n.x + n.w / 2;
1664
+ rc.polygon([
1665
+ [cx, n.y + 3],
1666
+ [n.x + n.w - 3, n.y + n.h - 3],
1667
+ [n.x + 3, n.y + n.h - 3],
1668
+ ], opts);
1669
+ },
1670
+ };
1671
+
1672
+ const cylinderShape = {
1673
+ size(n, labelW) {
1674
+ n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW));
1675
+ n.h = n.h || SHAPES.cylinder.defaultH;
1676
+ },
1677
+ renderSVG(rc, n, _palette, opts) {
1678
+ const cx = n.x + n.w / 2;
1679
+ const eH = SHAPES.cylinder.ellipseH;
1680
+ return [
1681
+ rc.rectangle(n.x + 3, n.y + eH / 2, n.w - 6, n.h - eH, opts),
1682
+ rc.ellipse(cx, n.y + eH / 2, n.w - 8, eH, { ...opts, roughness: 0.6 }),
1683
+ rc.ellipse(cx, n.y + n.h - eH / 2, n.w - 8, eH, { ...opts, roughness: 0.6, fill: "none" }),
1684
+ ];
1685
+ },
1686
+ renderCanvas(rc, _ctx, n, _palette, opts) {
1687
+ const cx = n.x + n.w / 2;
1688
+ const eH = SHAPES.cylinder.ellipseH;
1689
+ rc.rectangle(n.x + 3, n.y + eH / 2, n.w - 6, n.h - eH, opts);
1690
+ rc.ellipse(cx, n.y + eH / 2, n.w - 8, eH, { ...opts, roughness: 0.6 });
1691
+ rc.ellipse(cx, n.y + n.h - eH / 2, n.w - 8, eH, { ...opts, roughness: 0.6, fill: "none" });
1692
+ },
1693
+ };
1694
+
1695
+ const parallelogramShape = {
1696
+ size(n, labelW) {
1697
+ n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW + SHAPES.parallelogram.labelPad));
1698
+ n.h = n.h || SHAPES.parallelogram.defaultH;
1699
+ },
1700
+ renderSVG(rc, n, _palette, opts) {
1701
+ return [rc.polygon([
1702
+ [n.x + SHAPES.parallelogram.skew, n.y + 1], [n.x + n.w - 1, n.y + 1],
1703
+ [n.x + n.w - SHAPES.parallelogram.skew, n.y + n.h - 1], [n.x + 1, n.y + n.h - 1],
1704
+ ], opts)];
1705
+ },
1706
+ renderCanvas(rc, _ctx, n, _palette, opts) {
1707
+ rc.polygon([
1708
+ [n.x + SHAPES.parallelogram.skew, n.y + 1], [n.x + n.w - 1, n.y + 1],
1709
+ [n.x + n.w - SHAPES.parallelogram.skew, n.y + n.h - 1], [n.x + 1, n.y + n.h - 1],
1710
+ ], opts);
1711
+ },
1712
+ };
1713
+
1714
+ const textShape = {
1715
+ size(n, _labelW) {
1716
+ const fontSize = Number(n.style?.fontSize ?? 13);
1717
+ const charWidth = fontSize * 0.55;
1718
+ const pad = Number(n.style?.padding ?? 8) * 2;
1719
+ if (n.width) {
1720
+ const approxLines = Math.ceil((n.label.length * charWidth) / (n.width - pad));
1721
+ n.w = n.width;
1722
+ n.h = n.height ?? Math.max(24, approxLines * fontSize * 1.5 + pad);
1723
+ }
1724
+ else {
1725
+ const lines = n.label.split("\\n");
1726
+ const longest = lines.reduce((a, b) => (a.length > b.length ? a : b), "");
1727
+ n.w = Math.max(MIN_W, Math.round(longest.length * charWidth + pad));
1728
+ n.h = n.height ?? Math.max(24, lines.length * fontSize * 1.5 + pad);
1729
+ }
1730
+ },
1731
+ renderSVG(_rc, _n, _palette, _opts) {
1732
+ return []; // no shape drawn — text only
1733
+ },
1734
+ renderCanvas(_rc, _ctx, _n, _palette, _opts) {
1735
+ // no shape drawn — text only
1736
+ },
1737
+ };
1738
+
1739
+ const iconShape = {
1740
+ size(n, labelW) {
1741
+ const iconBase = 48;
1742
+ const labelH = n.label ? 20 : 0;
1743
+ n.w = n.w || Math.max(iconBase, n.label ? labelW : 0);
1744
+ n.h = n.h || (iconBase + labelH);
1745
+ },
1746
+ renderSVG(rc, n, palette, opts) {
1747
+ const s = n.style ?? {};
1748
+ if (n.iconName) {
1749
+ const [prefix, name] = n.iconName.includes(":")
1750
+ ? n.iconName.split(":", 2)
1751
+ : ["mdi", n.iconName];
1752
+ const iconColor = s.color
1753
+ ? encodeURIComponent(String(s.color))
1754
+ : encodeURIComponent(String(palette.nodeStroke));
1755
+ const labelSpace = n.label ? 20 : 0;
1756
+ const iconAreaH = n.h - labelSpace;
1757
+ const iconSize = Math.min(n.w, iconAreaH) - 4;
1758
+ const iconUrl = `https://api.iconify.design/${prefix}/${name}.svg?color=${iconColor}&width=${iconSize}&height=${iconSize}`;
1759
+ const img = document.createElementNS(SVG_NS, "image");
1760
+ img.setAttribute("href", iconUrl);
1761
+ const iconX = n.x + (n.w - iconSize) / 2;
1762
+ const iconY = n.y + (iconAreaH - iconSize) / 2;
1763
+ img.setAttribute("x", String(iconX));
1764
+ img.setAttribute("y", String(iconY));
1765
+ img.setAttribute("width", String(iconSize));
1766
+ img.setAttribute("height", String(iconSize));
1767
+ img.setAttribute("preserveAspectRatio", "xMidYMid meet");
1768
+ if (s.opacity != null)
1769
+ img.setAttribute("opacity", String(s.opacity));
1770
+ const clipId = `clip-${n.id}`;
1771
+ const defs = document.createElementNS(SVG_NS, "defs");
1772
+ const clip = document.createElementNS(SVG_NS, "clipPath");
1773
+ clip.setAttribute("id", clipId);
1774
+ const rect = document.createElementNS(SVG_NS, "rect");
1775
+ rect.setAttribute("x", String(iconX));
1776
+ rect.setAttribute("y", String(iconY));
1777
+ rect.setAttribute("width", String(iconSize));
1778
+ rect.setAttribute("height", String(iconSize));
1779
+ rect.setAttribute("rx", "6");
1780
+ clip.appendChild(rect);
1781
+ defs.appendChild(clip);
1782
+ img.setAttribute("clip-path", `url(#${clipId})`);
1783
+ const els = [defs, img];
1784
+ if (s.stroke) {
1785
+ els.push(rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, { ...opts, fill: "none" }));
1786
+ }
1787
+ return els;
1788
+ }
1789
+ return [rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, { ...opts, fill: "#e0e0e0", stroke: "#999999" })];
1790
+ },
1791
+ renderCanvas(rc, ctx, n, palette, opts) {
1792
+ const s = n.style ?? {};
1793
+ if (n.iconName) {
1794
+ const [prefix, name] = n.iconName.includes(":")
1795
+ ? n.iconName.split(":", 2)
1796
+ : ["mdi", n.iconName];
1797
+ const iconColor = s.color
1798
+ ? encodeURIComponent(String(s.color))
1799
+ : encodeURIComponent(String(palette.nodeStroke));
1800
+ const iconLabelSpace = n.label ? 20 : 0;
1801
+ const iconAreaH = n.h - iconLabelSpace;
1802
+ const iconSize = Math.min(n.w, iconAreaH) - 4;
1803
+ const iconUrl = `https://api.iconify.design/${prefix}/${name}.svg?color=${iconColor}&width=${iconSize}&height=${iconSize}`;
1804
+ const img = new Image();
1805
+ img.crossOrigin = "anonymous";
1806
+ img.onload = () => {
1807
+ ctx.save();
1808
+ if (s.opacity != null)
1809
+ ctx.globalAlpha = Number(s.opacity);
1810
+ const iconX = n.x + (n.w - iconSize) / 2;
1811
+ const iconY = n.y + (iconAreaH - iconSize) / 2;
1812
+ ctx.beginPath();
1813
+ const r = 6;
1814
+ ctx.moveTo(iconX + r, iconY);
1815
+ ctx.lineTo(iconX + iconSize - r, iconY);
1816
+ ctx.quadraticCurveTo(iconX + iconSize, iconY, iconX + iconSize, iconY + r);
1817
+ ctx.lineTo(iconX + iconSize, iconY + iconSize - r);
1818
+ ctx.quadraticCurveTo(iconX + iconSize, iconY + iconSize, iconX + iconSize - r, iconY + iconSize);
1819
+ ctx.lineTo(iconX + r, iconY + iconSize);
1820
+ ctx.quadraticCurveTo(iconX, iconY + iconSize, iconX, iconY + iconSize - r);
1821
+ ctx.lineTo(iconX, iconY + r);
1822
+ ctx.quadraticCurveTo(iconX, iconY, iconX + r, iconY);
1823
+ ctx.closePath();
1824
+ ctx.clip();
1825
+ ctx.drawImage(img, iconX, iconY, iconSize, iconSize);
1826
+ ctx.restore();
1827
+ if (s.stroke) {
1828
+ rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, { ...opts, fill: "none" });
1829
+ }
1830
+ };
1831
+ img.src = iconUrl;
1832
+ }
1833
+ else {
1834
+ rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, { ...opts, fill: "#e0e0e0", stroke: "#999999" });
1835
+ }
1836
+ },
1837
+ };
1838
+
1839
+ const imageShape = {
1840
+ size(n, labelW) {
1841
+ n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW));
1842
+ n.h = n.h || 52;
1843
+ },
1844
+ renderSVG(rc, n, _palette, opts) {
1845
+ const s = n.style ?? {};
1846
+ if (n.imageUrl) {
1847
+ const imgLabelSpace = n.label ? 20 : 0;
1848
+ const imgAreaH = n.h - imgLabelSpace;
1849
+ const img = document.createElementNS(SVG_NS, "image");
1850
+ img.setAttribute("href", n.imageUrl);
1851
+ img.setAttribute("x", String(n.x + 1));
1852
+ img.setAttribute("y", String(n.y + 1));
1853
+ img.setAttribute("width", String(n.w - 2));
1854
+ img.setAttribute("height", String(imgAreaH - 2));
1855
+ img.setAttribute("preserveAspectRatio", "xMidYMid meet");
1856
+ const clipId = `clip-${n.id}`;
1857
+ const defs = document.createElementNS(SVG_NS, "defs");
1858
+ const clip = document.createElementNS(SVG_NS, "clipPath");
1859
+ clip.setAttribute("id", clipId);
1860
+ const rect = document.createElementNS(SVG_NS, "rect");
1861
+ rect.setAttribute("x", String(n.x + 1));
1862
+ rect.setAttribute("y", String(n.y + 1));
1863
+ rect.setAttribute("width", String(n.w - 2));
1864
+ rect.setAttribute("height", String(imgAreaH - 2));
1865
+ rect.setAttribute("rx", "6");
1866
+ clip.appendChild(rect);
1867
+ defs.appendChild(clip);
1868
+ img.setAttribute("clip-path", `url(#${clipId})`);
1869
+ const els = [defs, img];
1870
+ if (s.stroke) {
1871
+ els.push(rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, { ...opts, fill: "none" }));
1872
+ }
1873
+ return els;
1874
+ }
1875
+ return [rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, { ...opts, fill: "#e0e0e0", stroke: "#999999" })];
1876
+ },
1877
+ renderCanvas(rc, ctx, n, _palette, opts) {
1878
+ const s = n.style ?? {};
1879
+ if (n.imageUrl) {
1880
+ const imgLblSpace = n.label ? 20 : 0;
1881
+ const imgAreaH = n.h - imgLblSpace;
1882
+ const img = new Image();
1883
+ img.crossOrigin = "anonymous";
1884
+ img.onload = () => {
1885
+ ctx.save();
1886
+ ctx.beginPath();
1887
+ const r = 6;
1888
+ ctx.moveTo(n.x + r, n.y);
1889
+ ctx.lineTo(n.x + n.w - r, n.y);
1890
+ ctx.quadraticCurveTo(n.x + n.w, n.y, n.x + n.w, n.y + r);
1891
+ ctx.lineTo(n.x + n.w, n.y + imgAreaH - r);
1892
+ ctx.quadraticCurveTo(n.x + n.w, n.y + imgAreaH, n.x + n.w - r, n.y + imgAreaH);
1893
+ ctx.lineTo(n.x + r, n.y + imgAreaH);
1894
+ ctx.quadraticCurveTo(n.x, n.y + imgAreaH, n.x, n.y + imgAreaH - r);
1895
+ ctx.lineTo(n.x, n.y + r);
1896
+ ctx.quadraticCurveTo(n.x, n.y, n.x + r, n.y);
1897
+ ctx.closePath();
1898
+ ctx.clip();
1899
+ ctx.drawImage(img, n.x + 1, n.y + 1, n.w - 2, imgAreaH - 2);
1900
+ ctx.restore();
1901
+ if (s.stroke) {
1902
+ rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, { ...opts, fill: "none" });
1903
+ }
1904
+ };
1905
+ img.src = n.imageUrl;
1906
+ }
1907
+ else {
1908
+ rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, { ...opts, fill: "#e0e0e0", stroke: "#999999" });
1909
+ }
1910
+ },
1911
+ };
1912
+
1913
+ // ============================================================
1914
+ // sketchmark — Font Registry
1915
+ // ============================================================
1916
+ // built-in named fonts — user can reference these by short name
1917
+ const BUILTIN_FONTS = {
1918
+ // hand-drawn
1919
+ caveat: {
1920
+ family: "'Caveat', cursive",
1921
+ url: 'https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600&display=swap',
1922
+ },
1923
+ handlee: {
1924
+ family: "'Handlee', cursive",
1925
+ url: 'https://fonts.googleapis.com/css2?family=Handlee&display=swap',
1926
+ },
1927
+ 'indie-flower': {
1928
+ family: "'Indie Flower', cursive",
1929
+ url: 'https://fonts.googleapis.com/css2?family=Indie+Flower&display=swap',
1930
+ },
1931
+ 'patrick-hand': {
1932
+ family: "'Patrick Hand', cursive",
1933
+ url: 'https://fonts.googleapis.com/css2?family=Patrick+Hand&display=swap',
1934
+ },
1935
+ // clean / readable
1936
+ 'dm-mono': {
1937
+ family: "'DM Mono', monospace",
1938
+ url: 'https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&display=swap',
1939
+ },
1940
+ 'jetbrains': {
1941
+ family: "'JetBrains Mono', monospace",
1942
+ url: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500&display=swap',
1943
+ },
1944
+ 'instrument': {
1945
+ family: "'Instrument Serif', serif",
1946
+ url: 'https://fonts.googleapis.com/css2?family=Instrument+Serif&display=swap',
1947
+ },
1948
+ 'playfair': {
1949
+ family: "'Playfair Display', serif",
1950
+ url: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500&display=swap',
1951
+ },
1952
+ // system fallbacks (no URL needed)
1953
+ system: { family: 'system-ui, sans-serif' },
1954
+ mono: { family: "'Courier New', monospace" },
1955
+ serif: { family: 'Georgia, serif' },
1956
+ };
1957
+ // default — what renders when no font is specified
1958
+ const DEFAULT_FONT = 'system-ui, sans-serif';
1959
+ // resolve a short name or pass-through a quoted CSS family
1960
+ function resolveFont(nameOrFamily) {
1961
+ const key = nameOrFamily.toLowerCase().trim();
1962
+ if (BUILTIN_FONTS[key])
1963
+ return BUILTIN_FONTS[key].family;
1964
+ return nameOrFamily; // treat as raw CSS font-family
1965
+ }
1966
+ // inject a <link> into <head> for a built-in font (browser only)
1967
+ function loadFont(name) {
1968
+ if (typeof document === 'undefined')
1969
+ return;
1970
+ const key = name.toLowerCase().trim();
1971
+ const def = BUILTIN_FONTS[key];
1972
+ if (!def?.url || def.loaded)
1973
+ return;
1974
+ if (document.querySelector(`link[data-sketchmark-font="${key}"]`))
1975
+ return;
1976
+ const link = document.createElement('link');
1977
+ link.rel = 'stylesheet';
1978
+ link.href = def.url;
1979
+ link.setAttribute('data-sketchmark-font', key);
1980
+ document.head.appendChild(link);
1981
+ def.loaded = true;
1982
+ }
1983
+ // user registers their own font (already loaded via CSS/link)
1984
+ function registerFont(name, family, url) {
1985
+ BUILTIN_FONTS[name.toLowerCase()] = { family, url };
1986
+ if (url)
1987
+ loadFont(name);
1988
+ }
1989
+
1990
+ // ============================================================
1991
+ // sketchmark — Shared Renderer Utilities
1992
+ //
1993
+ // Functions used by both SVG and Canvas renderers, extracted
1994
+ // to eliminate duplication (Phase 1 of SOLID refactoring).
1995
+ // ============================================================
1996
+ // ── Hash string to seed ───────────────────────────────────────────────────
1997
+ function hashStr$3(s) {
1998
+ let h = 5381;
1999
+ for (let i = 0; i < s.length; i++)
2000
+ h = ((h * 33) ^ s.charCodeAt(i)) & 0xffff;
2001
+ return h;
2002
+ }
2003
+ // ── Darken a CSS hex colour by `amount` (0–1) ────────────────────────────
2004
+ function darkenHex(hex, amount = 0.12) {
2005
+ const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
2006
+ if (!m)
2007
+ return hex;
2008
+ const d = (v) => Math.max(0, Math.round(parseInt(v, 16) * (1 - amount)));
2009
+ return `#${d(m[1]).toString(16).padStart(2, "0")}${d(m[2]).toString(16).padStart(2, "0")}${d(m[3]).toString(16).padStart(2, "0")}`;
2010
+ }
2011
+ // ── Load + resolve font from style or fall back ──────────────────────────
2012
+ function resolveStyleFont(style, fallback) {
2013
+ const raw = String(style["font"] ?? "");
2014
+ if (!raw)
2015
+ return fallback;
2016
+ loadFont(raw);
2017
+ return resolveFont(raw);
2018
+ }
2019
+ // ── Soft word-wrap ───────────────────────────────────────────────────────
2020
+ function wrapText(text, maxWidth, fontSize) {
2021
+ const charWidth = fontSize * 0.55;
2022
+ const maxChars = Math.floor(maxWidth / charWidth);
2023
+ const words = text.split(' ');
2024
+ const lines = [];
2025
+ let current = '';
2026
+ for (const word of words) {
2027
+ const test = current ? `${current} ${word}` : word;
2028
+ if (test.length > maxChars && current) {
2029
+ lines.push(current);
2030
+ current = word;
2031
+ }
2032
+ else {
2033
+ current = test;
2034
+ }
2035
+ }
2036
+ if (current)
2037
+ lines.push(current);
2038
+ return lines.length ? lines : [text];
2039
+ }
2040
+ // ── Arrow direction from connector ───────────────────────────────────────
2041
+ function connMeta(connector) {
2042
+ if (connector === "--")
2043
+ return { arrowAt: "none", dashed: false };
2044
+ if (connector === "---")
2045
+ return { arrowAt: "none", dashed: true };
2046
+ const bidir = connector.includes("<") && connector.includes(">");
2047
+ if (bidir)
2048
+ return { arrowAt: "both", dashed: connector.includes("--") };
2049
+ const back = connector.startsWith("<");
2050
+ const dashed = connector.includes("--");
2051
+ if (back)
2052
+ return { arrowAt: "start", dashed };
2053
+ return { arrowAt: "end", dashed };
2054
+ }
2055
+ // ── Generic rect connection point ────────────────────────────────────────
2056
+ function rectConnPoint$1(rx, ry, rw, rh, ox, oy) {
2057
+ const cx = rx + rw / 2, cy = ry + rh / 2;
2058
+ const dx = ox - cx, dy = oy - cy;
2059
+ if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01)
2060
+ return [cx, cy];
2061
+ const hw = rw / 2 - 2, hh = rh / 2 - 2;
2062
+ const tx = Math.abs(dx) > 0.01 ? hw / Math.abs(dx) : 1e9;
2063
+ const ty = Math.abs(dy) > 0.01 ? hh / Math.abs(dy) : 1e9;
2064
+ const t = Math.min(tx, ty);
2065
+ return [cx + t * dx, cy + t * dy];
2066
+ }
2067
+ // ── Resolve an endpoint entity by ID across all maps ─────────────────────
2068
+ function resolveEndpoint(id, nm, tm, gm, cm) {
2069
+ return nm.get(id) ?? tm.get(id) ?? gm.get(id) ?? cm.get(id) ?? null;
2070
+ }
2071
+ // ── Get connection point for any entity ──────────────────────────────────
2072
+ function getConnPoint(src, dstCX, dstCY) {
2073
+ if ("shape" in src && src.shape) {
2074
+ return connPoint(src, { x: dstCX - 1, y: dstCY - 1, w: 2, h: 2});
2075
+ }
2076
+ return rectConnPoint$1(src.x, src.y, src.w, src.h, dstCX, dstCY);
2077
+ }
2078
+ // ── Group depth (for paint order) ────────────────────────────────────────
2079
+ function groupDepth(g, gm) {
2080
+ let d = 0;
2081
+ let cur = g;
2082
+ while (cur?.parentId) {
2083
+ d++;
2084
+ cur = gm.get(cur.parentId);
2085
+ }
2086
+ return d;
2087
+ }
2088
+
2089
+ const noteShape = {
2090
+ idPrefix: "note",
2091
+ cssClass: "ntg",
2092
+ size(n, _labelW) {
2093
+ const lines = n.label.split("\n");
2094
+ const maxChars = Math.max(...lines.map((l) => l.length));
2095
+ n.w = n.w || Math.max(NOTE.minW, Math.ceil(maxChars * NOTE.fontPxPerChar) + NOTE.padX * 2);
2096
+ n.h = n.h || lines.length * NOTE.lineH + NOTE.padY * 2;
2097
+ if (n.width && n.w < n.width)
2098
+ n.w = n.width;
2099
+ if (n.height && n.h < n.height)
2100
+ n.h = n.height;
2101
+ },
2102
+ renderSVG(rc, n, palette, opts) {
2103
+ const s = n.style ?? {};
2104
+ const { x, y, w, h } = n;
2105
+ const fold = NOTE.fold;
2106
+ const strk = String(s.stroke ?? palette.noteStroke);
2107
+ const nStrokeWidth = Number(s.strokeWidth ?? 1.2);
2108
+ const body = rc.polygon([[x, y], [x + w - fold, y], [x + w, y + fold], [x + w, y + h], [x, y + h]], {
2109
+ ...opts,
2110
+ stroke: strk,
2111
+ strokeWidth: nStrokeWidth,
2112
+ ...(s.strokeDash ? { strokeLineDash: s.strokeDash } : {}),
2113
+ });
2114
+ const foldEl = rc.polygon([[x + w - fold, y], [x + w, y + fold], [x + w - fold, y + fold]], {
2115
+ roughness: 0.4,
2116
+ seed: hashStr$3(n.id + "f"),
2117
+ fill: palette.noteFold,
2118
+ fillStyle: "solid",
2119
+ stroke: strk,
2120
+ strokeWidth: Math.min(nStrokeWidth, 0.8),
2121
+ });
2122
+ return [body, foldEl];
2123
+ },
2124
+ renderCanvas(rc, _ctx, n, palette, opts) {
2125
+ const s = n.style ?? {};
2126
+ const { x, y, w, h } = n;
2127
+ const fold = NOTE.fold;
2128
+ const strk = String(s.stroke ?? palette.noteStroke);
2129
+ const nStrokeWidth = Number(s.strokeWidth ?? 1.2);
2130
+ rc.polygon([[x, y], [x + w - fold, y], [x + w, y + fold], [x + w, y + h], [x, y + h]], {
2131
+ ...opts,
2132
+ stroke: strk,
2133
+ strokeWidth: nStrokeWidth,
2134
+ ...(s.strokeDash ? { strokeLineDash: s.strokeDash } : {}),
2135
+ });
2136
+ rc.polygon([[x + w - fold, y], [x + w, y + fold], [x + w - fold, y + fold]], {
2137
+ roughness: 0.4,
2138
+ seed: hashStr$3(n.id + "f"),
2139
+ fill: palette.noteFold,
2140
+ fillStyle: "solid",
2141
+ stroke: strk,
2142
+ strokeWidth: Math.min(nStrokeWidth, 0.8),
2143
+ });
2144
+ },
2145
+ };
2146
+
2147
+ const lineShape = {
2148
+ size(n, labelW) {
2149
+ const labelH = n.label ? 20 : 0;
2150
+ n.w = n.width ?? Math.max(MIN_W, labelW + 20);
2151
+ n.h = n.height ?? (6 + labelH);
2152
+ },
2153
+ renderSVG(rc, n, _palette, opts) {
2154
+ const labelH = n.label ? 20 : 0;
2155
+ const lineY = n.y + (n.h - labelH) / 2;
2156
+ return [rc.line(n.x, lineY, n.x + n.w, lineY, opts)];
2157
+ },
2158
+ renderCanvas(rc, _ctx, n, _palette, opts) {
2159
+ const labelH = n.label ? 20 : 0;
2160
+ const lineY = n.y + (n.h - labelH) / 2;
2161
+ rc.line(n.x, lineY, n.x + n.w, lineY, opts);
2162
+ },
2163
+ };
2164
+
2165
+ const pathShape = {
2166
+ size(n, labelW) {
2167
+ // User should provide width/height; defaults to 100x100
2168
+ n.w = n.width ?? Math.max(100, labelW + 20);
2169
+ n.h = n.height ?? 100;
2170
+ },
2171
+ renderSVG(rc, n, _palette, opts) {
2172
+ const d = n.pathData;
2173
+ if (!d) {
2174
+ // No path data — render placeholder box
2175
+ return [rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, opts)];
2176
+ }
2177
+ const el = rc.path(d, opts);
2178
+ // Wrap in a group to translate the user's path to the node position
2179
+ const g = document.createElementNS(SVG_NS, "g");
2180
+ g.setAttribute("transform", `translate(${n.x},${n.y})`);
2181
+ g.appendChild(el);
2182
+ return [g];
2183
+ },
2184
+ renderCanvas(rc, ctx, n, _palette, opts) {
2185
+ const d = n.pathData;
2186
+ if (!d) {
2187
+ rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, opts);
2188
+ return;
2189
+ }
2190
+ ctx.save();
2191
+ ctx.translate(n.x, n.y);
2192
+ rc.path(d, opts);
2193
+ ctx.restore();
2194
+ },
2195
+ };
2196
+
2197
+ // ============================================================
2198
+ // Shape Registry — registers all built-in shapes
2199
+ //
2200
+ // To add a new shape:
2201
+ // 1. Create src/renderer/shapes/my-shape.ts implementing ShapeDefinition
2202
+ // 2. Import and register it here
2203
+ // 3. Add the shape name to NodeShape union in ast/types.ts
2204
+ // 4. Add to SHAPES array in parser/index.ts and KEYWORDS in tokenizer.ts
2205
+ // ============================================================
2206
+ registerShape("box", boxShape);
2207
+ registerShape("circle", circleShape);
2208
+ registerShape("diamond", diamondShape);
2209
+ registerShape("hexagon", hexagonShape);
2210
+ registerShape("triangle", triangleShape);
2211
+ registerShape("cylinder", cylinderShape);
2212
+ registerShape("parallelogram", parallelogramShape);
2213
+ registerShape("text", textShape);
2214
+ registerShape("icon", iconShape);
2215
+ registerShape("image", imageShape);
2216
+ registerShape("note", noteShape);
2217
+ registerShape("line", lineShape);
2218
+ registerShape("path", pathShape);
2219
+
1428
2220
  // ============================================================
1429
2221
  // sketchmark — Layout Engine (Flexbox-style, recursive)
1430
2222
  //
@@ -1439,22 +2231,6 @@ function markdownMap(sg) {
1439
2231
  // align=… → align-items
1440
2232
  // justify=… → justify-content
1441
2233
  // ============================================================
1442
- // ── Constants ─────────────────────────────────────────────
1443
- const FONT_PX_PER_CHAR = 8.6;
1444
- const MIN_W = 90;
1445
- const MAX_W = 180;
1446
- const BASE_PAD = 26;
1447
- const GROUP_LABEL_H = 22;
1448
- const DEFAULT_MARGIN = 60;
1449
- const DEFAULT_GAP_MAIN = 80;
1450
- // Table sizing
1451
- const CELL_PAD = 20; // total horizontal padding per cell (left + right)
1452
- const MIN_COL_W = 50; // minimum column width
1453
- const TBL_FONT = 7.5; // px per char at 12px sans-serif
1454
- const NOTE_LINE_H = 20;
1455
- const NOTE_PAD_X = 16;
1456
- const NOTE_PAD_Y = 12;
1457
- const NOTE_FONT = 7.5;
1458
2234
  // ── Node auto-sizing ──────────────────────────────────────
1459
2235
  function sizeNode(n) {
1460
2236
  // User-specified dimensions win
@@ -1462,72 +2238,16 @@ function sizeNode(n) {
1462
2238
  n.w = n.width;
1463
2239
  if (n.height && n.height > 0)
1464
2240
  n.h = n.height;
1465
- const labelW = Math.round(n.label.length * FONT_PX_PER_CHAR + BASE_PAD);
1466
- switch (n.shape) {
1467
- case "circle":
1468
- n.w = n.w || Math.max(84, Math.min(MAX_W, labelW));
1469
- n.h = n.h || n.w;
1470
- break;
1471
- case "diamond":
1472
- n.w = n.w || Math.max(130, Math.min(MAX_W, labelW + 30));
1473
- n.h = n.h || Math.max(62, n.w * 0.46);
1474
- break;
1475
- case "hexagon":
1476
- n.w = n.w || Math.max(126, Math.min(MAX_W, labelW + 20));
1477
- n.h = n.h || Math.max(54, n.w * 0.44);
1478
- break;
1479
- case "triangle":
1480
- n.w = n.w || Math.max(108, Math.min(MAX_W, labelW + 10));
1481
- n.h = n.h || Math.max(64, n.w * 0.6);
1482
- break;
1483
- case "cylinder":
1484
- n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW));
1485
- n.h = n.h || 66;
1486
- break;
1487
- case "parallelogram":
1488
- n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW + 28));
1489
- n.h = n.h || 50;
1490
- break;
1491
- case "text": {
1492
- const fontSize = Number(n.style?.fontSize ?? 13);
1493
- const charWidth = fontSize * 0.55;
1494
- const pad = Number(n.style?.padding ?? 8) * 2;
1495
- if (n.width) {
1496
- // User set width → word-wrap within it
1497
- const approxLines = Math.ceil((n.label.length * charWidth) / (n.width - pad));
1498
- n.w = n.width;
1499
- n.h = n.height ?? Math.max(24, approxLines * fontSize * 1.5 + pad);
1500
- }
1501
- else {
1502
- // Auto-size to content
1503
- const lines = n.label.split("\\n");
1504
- const longest = lines.reduce((a, b) => (a.length > b.length ? a : b), "");
1505
- n.w = Math.max(MIN_W, Math.round(longest.length * charWidth + pad));
1506
- n.h = n.height ?? Math.max(24, lines.length * fontSize * 1.5 + pad);
1507
- }
1508
- break;
1509
- }
1510
- case "icon": {
1511
- const iconBase = 48;
1512
- const labelH = n.label ? 20 : 0;
1513
- n.w = n.w || Math.max(iconBase, n.label ? labelW : 0);
1514
- n.h = n.h || (iconBase + labelH);
1515
- break;
1516
- }
1517
- default:
1518
- n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW));
1519
- n.h = n.h || 52;
1520
- break;
2241
+ const labelW = Math.round(n.label.length * NODE.fontPxPerChar + NODE.basePad);
2242
+ const shape = getShape(n.shape);
2243
+ if (shape) {
2244
+ shape.size(n, labelW);
2245
+ }
2246
+ else {
2247
+ // fallback for unknown shapes — box-like default
2248
+ n.w = n.w || Math.max(90, Math.min(180, labelW));
2249
+ n.h = n.h || 52;
1521
2250
  }
1522
- }
1523
- function sizeNote(n) {
1524
- const maxChars = Math.max(...n.lines.map((l) => l.length));
1525
- n.w = Math.max(120, Math.ceil(maxChars * NOTE_FONT) + NOTE_PAD_X * 2);
1526
- n.h = n.lines.length * NOTE_LINE_H + NOTE_PAD_Y * 2;
1527
- if (n.width && n.w < n.width)
1528
- n.w = n.width; // ← add
1529
- if (n.height && n.h < n.height)
1530
- n.h = n.height; // ← add
1531
2251
  }
1532
2252
  // ── Table auto-sizing ─────────────────────────────────────
1533
2253
  function sizeTable(t) {
@@ -1538,10 +2258,10 @@ function sizeTable(t) {
1538
2258
  return;
1539
2259
  }
1540
2260
  const numCols = Math.max(...rows.map((r) => r.cells.length));
1541
- const colW = Array(numCols).fill(MIN_COL_W);
2261
+ const colW = Array(numCols).fill(TABLE.minColW);
1542
2262
  for (const row of rows) {
1543
2263
  row.cells.forEach((cell, i) => {
1544
- colW[i] = Math.max(colW[i], Math.ceil(cell.length * TBL_FONT) + CELL_PAD);
2264
+ colW[i] = Math.max(colW[i], Math.ceil(cell.length * TABLE.fontPxPerChar) + TABLE.cellPad);
1545
2265
  });
1546
2266
  }
1547
2267
  t.colWidths = colW;
@@ -1558,79 +2278,27 @@ function sizeMarkdown(m) {
1558
2278
  m.w = m.width ?? 400;
1559
2279
  m.h = m.height ?? calcMarkdownHeight(m.lines, pad);
1560
2280
  }
1561
- // ── Item size helpers ─────────────────────────────────────
1562
- function iW(r, nm, gm, tm, ntm, cm, mdm) {
1563
- if (r.kind === "node")
1564
- return nm.get(r.id).w;
1565
- if (r.kind === "table")
1566
- return tm.get(r.id).w;
1567
- if (r.kind === "note")
1568
- return ntm.get(r.id).w;
1569
- if (r.kind === "chart")
1570
- return cm.get(r.id).w;
1571
- if (r.kind === "markdown")
1572
- return mdm.get(r.id).w;
1573
- return gm.get(r.id).w;
1574
- }
1575
- function iH(r, nm, gm, tm, ntm, cm, mdm) {
1576
- if (r.kind === "node")
1577
- return nm.get(r.id).h;
1578
- if (r.kind === "table")
1579
- return tm.get(r.id).h;
1580
- if (r.kind === "note")
1581
- return ntm.get(r.id).h;
1582
- if (r.kind === "chart")
1583
- return cm.get(r.id).h;
1584
- if (r.kind === "markdown")
1585
- return mdm.get(r.id).h;
1586
- return gm.get(r.id).h;
1587
- }
1588
- function setPos(r, x, y, nm, gm, tm, ntm, cm, mdm) {
1589
- if (r.kind === "node") {
1590
- const n = nm.get(r.id);
1591
- n.x = Math.round(x);
1592
- n.y = Math.round(y);
1593
- return;
1594
- }
1595
- if (r.kind === "table") {
1596
- const t = tm.get(r.id);
1597
- t.x = Math.round(x);
1598
- t.y = Math.round(y);
1599
- return;
1600
- }
1601
- if (r.kind === "note") {
1602
- const nt = ntm.get(r.id);
1603
- nt.x = Math.round(x);
1604
- nt.y = Math.round(y);
1605
- return;
1606
- }
1607
- if (r.kind === "chart") {
1608
- const c = cm.get(r.id);
1609
- c.x = Math.round(x);
1610
- c.y = Math.round(y);
1611
- return;
1612
- }
1613
- if (r.kind === "markdown") {
1614
- const md = mdm.get(r.id);
1615
- md.x = Math.round(x);
1616
- md.y = Math.round(y);
1617
- return;
1618
- }
1619
- const g = gm.get(r.id);
1620
- g.x = Math.round(x);
1621
- g.y = Math.round(y);
2281
+ // ── Item size helpers (entity-map based) ─────────────────
2282
+ function iW(r, em) {
2283
+ return em.get(r.id).w;
2284
+ }
2285
+ function iH(r, em) {
2286
+ return em.get(r.id).h;
2287
+ }
2288
+ function setPos(r, x, y, em) {
2289
+ const e = em.get(r.id);
2290
+ e.x = Math.round(x);
2291
+ e.y = Math.round(y);
1622
2292
  }
1623
2293
  // ── Pass 1: Measure (bottom-up) ───────────────────────────
1624
2294
  // Recursively computes w, h for a group from its children's sizes.
1625
- function measure(g, nm, gm, tm, ntm, cm, mdm) {
2295
+ function measure(g, gm, tm, cm, mdm, em) {
1626
2296
  // Recurse into nested groups first; size tables before reading their dims
1627
2297
  for (const r of g.children) {
1628
2298
  if (r.kind === "group")
1629
- measure(gm.get(r.id), nm, gm, tm, ntm, cm, mdm);
2299
+ measure(gm.get(r.id), gm, tm, cm, mdm, em);
1630
2300
  if (r.kind === "table")
1631
2301
  sizeTable(tm.get(r.id));
1632
- if (r.kind === "note")
1633
- sizeNote(ntm.get(r.id));
1634
2302
  if (r.kind === "chart")
1635
2303
  sizeChart(cm.get(r.id));
1636
2304
  if (r.kind === "markdown")
@@ -1638,7 +2306,7 @@ function measure(g, nm, gm, tm, ntm, cm, mdm) {
1638
2306
  }
1639
2307
  const { padding: pad, gap, columns, layout } = g;
1640
2308
  const kids = g.children;
1641
- const labelH = g.label ? GROUP_LABEL_H : 0;
2309
+ const labelH = g.label ? LAYOUT.groupLabelH : 0;
1642
2310
  if (!kids.length) {
1643
2311
  g.w = pad * 2;
1644
2312
  g.h = pad * 2 + labelH;
@@ -1648,8 +2316,8 @@ function measure(g, nm, gm, tm, ntm, cm, mdm) {
1648
2316
  g.h = g.height;
1649
2317
  return;
1650
2318
  }
1651
- const ws = kids.map((r) => iW(r, nm, gm, tm, ntm, cm, mdm));
1652
- const hs = kids.map((r) => iH(r, nm, gm, tm, ntm, cm, mdm));
2319
+ const ws = kids.map((r) => iW(r, em));
2320
+ const hs = kids.map((r) => iH(r, em));
1653
2321
  const n = kids.length;
1654
2322
  if (layout === "row") {
1655
2323
  g.w = ws.reduce((s, w) => s + w, 0) + gap * (n - 1) + pad * 2;
@@ -1714,9 +2382,9 @@ function distribute(sizes, contentSize, gap, justify) {
1714
2382
  }
1715
2383
  // ── Pass 2: Place (top-down) ──────────────────────────────
1716
2384
  // Assigns x, y to each child. Assumes g.x / g.y already set by parent.
1717
- function place(g, nm, gm, tm, ntm, cm, mdm) {
2385
+ function place(g, gm, em) {
1718
2386
  const { padding: pad, gap, columns, layout, align, justify } = g;
1719
- const labelH = g.label ? GROUP_LABEL_H : 0;
2387
+ const labelH = g.label ? LAYOUT.groupLabelH : 0;
1720
2388
  const contentX = g.x + pad;
1721
2389
  const contentY = g.y + labelH + pad;
1722
2390
  const contentW = g.w - pad * 2;
@@ -1725,8 +2393,8 @@ function place(g, nm, gm, tm, ntm, cm, mdm) {
1725
2393
  if (!kids.length)
1726
2394
  return;
1727
2395
  if (layout === "row") {
1728
- const ws = kids.map((r) => iW(r, nm, gm, tm, ntm, cm, mdm));
1729
- const hs = kids.map((r) => iH(r, nm, gm, tm, ntm, cm, mdm));
2396
+ const ws = kids.map((r) => iW(r, em));
2397
+ const hs = kids.map((r) => iH(r, em));
1730
2398
  const { start, gaps } = distribute(ws, contentW, gap, justify);
1731
2399
  let x = contentX + start;
1732
2400
  for (let i = 0; i < kids.length; i++) {
@@ -1741,22 +2409,22 @@ function place(g, nm, gm, tm, ntm, cm, mdm) {
1741
2409
  default:
1742
2410
  y = contentY;
1743
2411
  }
1744
- setPos(kids[i], x, y, nm, gm, tm, ntm, cm, mdm);
2412
+ setPos(kids[i], x, y, em);
1745
2413
  x += ws[i] + (i < gaps.length ? gaps[i] : 0);
1746
2414
  }
1747
2415
  }
1748
2416
  else if (layout === "grid") {
1749
2417
  const cols = Math.max(1, columns);
1750
- const cellW = Math.max(...kids.map((r) => iW(r, nm, gm, tm, ntm, cm, mdm)));
1751
- const cellH = Math.max(...kids.map((r) => iH(r, nm, gm, tm, ntm, cm, mdm)));
2418
+ const cellW = Math.max(...kids.map((r) => iW(r, em)));
2419
+ const cellH = Math.max(...kids.map((r) => iH(r, em)));
1752
2420
  kids.forEach((ref, i) => {
1753
- setPos(ref, contentX + (i % cols) * (cellW + gap), contentY + Math.floor(i / cols) * (cellH + gap), nm, gm, tm, ntm, cm, mdm);
2421
+ setPos(ref, contentX + (i % cols) * (cellW + gap), contentY + Math.floor(i / cols) * (cellH + gap), em);
1754
2422
  });
1755
2423
  }
1756
2424
  else {
1757
2425
  // column (default)
1758
- const ws = kids.map((r) => iW(r, nm, gm, tm, ntm, cm, mdm));
1759
- const hs = kids.map((r) => iH(r, nm, gm, tm, ntm, cm, mdm));
2426
+ const ws = kids.map((r) => iW(r, em));
2427
+ const hs = kids.map((r) => iH(r, em));
1760
2428
  const { start, gaps } = distribute(hs, contentH, gap, justify);
1761
2429
  let y = contentY + start;
1762
2430
  for (let i = 0; i < kids.length; i++) {
@@ -1771,14 +2439,14 @@ function place(g, nm, gm, tm, ntm, cm, mdm) {
1771
2439
  default:
1772
2440
  x = contentX;
1773
2441
  }
1774
- setPos(kids[i], x, y, nm, gm, tm, ntm, cm, mdm);
2442
+ setPos(kids[i], x, y, em);
1775
2443
  y += hs[i] + (i < gaps.length ? gaps[i] : 0);
1776
2444
  }
1777
2445
  }
1778
2446
  // Recurse into nested groups
1779
2447
  for (const r of kids) {
1780
2448
  if (r.kind === "group")
1781
- place(gm.get(r.id), nm, gm, tm, ntm, cm, mdm);
2449
+ place(gm.get(r.id), gm, em);
1782
2450
  }
1783
2451
  }
1784
2452
  // ── Edge routing ──────────────────────────────────────────
@@ -1798,7 +2466,7 @@ function connPoint(n, other) {
1798
2466
  const t = Math.min(tx, ty);
1799
2467
  return [cx + t * dx, cy + t * dy];
1800
2468
  }
1801
- function rectConnPoint$2(rx, ry, rw, rh, ox, oy) {
2469
+ function rectConnPoint(rx, ry, rw, rh, ox, oy) {
1802
2470
  const cx = rx + rw / 2, cy = ry + rh / 2;
1803
2471
  const dx = ox - cx, dy = oy - cy;
1804
2472
  if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01)
@@ -1814,7 +2482,6 @@ function routeEdges(sg) {
1814
2482
  const tm = tableMap(sg);
1815
2483
  const gm = groupMap(sg);
1816
2484
  const cm = chartMap(sg);
1817
- const ntm = noteMap(sg);
1818
2485
  function resolve(id) {
1819
2486
  const n = nm.get(id);
1820
2487
  if (n)
@@ -1828,9 +2495,6 @@ function routeEdges(sg) {
1828
2495
  const c = cm.get(id);
1829
2496
  if (c)
1830
2497
  return c;
1831
- const nt = ntm.get(id);
1832
- if (nt)
1833
- return nt;
1834
2498
  return null;
1835
2499
  }
1836
2500
  function connPt(src, dstCX, dstCY) {
@@ -1842,7 +2506,7 @@ function routeEdges(sg) {
1842
2506
  w: 2,
1843
2507
  h: 2});
1844
2508
  }
1845
- return rectConnPoint$2(src.x, src.y, src.w, src.h, dstCX, dstCY);
2509
+ return rectConnPoint(src.x, src.y, src.w, src.h, dstCX, dstCY);
1846
2510
  }
1847
2511
  for (const e of sg.edges) {
1848
2512
  const src = resolve(e.from);
@@ -1861,7 +2525,6 @@ function computeBounds(sg, margin) {
1861
2525
  ...sg.nodes.map((n) => n.x + n.w),
1862
2526
  ...sg.groups.filter((g) => g.w).map((g) => g.x + g.w),
1863
2527
  ...sg.tables.map((t) => t.x + t.w),
1864
- ...sg.notes.map((n) => n.x + n.w),
1865
2528
  ...sg.charts.map((c) => c.x + c.w),
1866
2529
  ...sg.markdowns.map((m) => m.x + m.w),
1867
2530
  ];
@@ -1869,7 +2532,6 @@ function computeBounds(sg, margin) {
1869
2532
  ...sg.nodes.map((n) => n.y + n.h),
1870
2533
  ...sg.groups.filter((g) => g.h).map((g) => g.y + g.h),
1871
2534
  ...sg.tables.map((t) => t.y + t.h),
1872
- ...sg.notes.map((n) => n.y + n.h),
1873
2535
  ...sg.charts.map((c) => c.y + c.h),
1874
2536
  ...sg.markdowns.map((m) => m.y + m.h),
1875
2537
  ];
@@ -1878,37 +2540,33 @@ function computeBounds(sg, margin) {
1878
2540
  }
1879
2541
  // ── Public entry point ────────────────────────────────────
1880
2542
  function layout(sg) {
1881
- const GAP_MAIN = Number(sg.config["gap"] ?? DEFAULT_GAP_MAIN);
1882
- const MARGIN = Number(sg.config["margin"] ?? DEFAULT_MARGIN);
1883
- const nm = nodeMap(sg);
2543
+ const GAP_MAIN = Number(sg.config["gap"] ?? LAYOUT.gap);
2544
+ const MARGIN = Number(sg.config["margin"] ?? LAYOUT.margin);
1884
2545
  const gm = groupMap(sg);
1885
2546
  const tm = tableMap(sg);
1886
- const ntm = noteMap(sg);
1887
2547
  const cm = chartMap(sg);
1888
2548
  const mdm = markdownMap(sg);
1889
2549
  // 1. Size all nodes and tables
1890
2550
  sg.nodes.forEach(sizeNode);
1891
2551
  sg.tables.forEach(sizeTable);
1892
- sg.notes.forEach(sizeNote);
1893
2552
  sg.charts.forEach(sizeChart);
1894
2553
  sg.markdowns.forEach(sizeMarkdown);
1895
- // src/layout/index.tsafter sg.charts.forEach(sizeChart);
2554
+ // Build unified entity map (all entities have x,y,w,h map holds direct refs)
2555
+ const em = buildEntityMap(sg);
1896
2556
  // 2. Identify root vs nested items
1897
2557
  const nestedGroupIds = new Set(sg.groups.flatMap((g) => g.children.filter((c) => c.kind === "group").map((c) => c.id)));
1898
2558
  const groupedNodeIds = new Set(sg.groups.flatMap((g) => g.children.filter((c) => c.kind === "node").map((c) => c.id)));
1899
2559
  const groupedTableIds = new Set(sg.groups.flatMap((g) => g.children.filter((c) => c.kind === "table").map((c) => c.id)));
1900
- const groupedNoteIds = new Set(sg.groups.flatMap((g) => g.children.filter((c) => c.kind === "note").map((c) => c.id)));
1901
2560
  const groupedChartIds = new Set(sg.groups.flatMap((g) => g.children.filter((c) => c.kind === "chart").map((c) => c.id)));
1902
2561
  const groupedMarkdownIds = new Set(sg.groups.flatMap((g) => g.children.filter((c) => c.kind === "markdown").map((c) => c.id)));
1903
2562
  const rootGroups = sg.groups.filter((g) => !nestedGroupIds.has(g.id));
1904
2563
  const rootNodes = sg.nodes.filter((n) => !groupedNodeIds.has(n.id));
1905
2564
  const rootTables = sg.tables.filter((t) => !groupedTableIds.has(t.id));
1906
- const rootNotes = sg.notes.filter((n) => !groupedNoteIds.has(n.id));
1907
2565
  const rootCharts = sg.charts.filter((c) => !groupedChartIds.has(c.id));
1908
2566
  const rootMarkdowns = sg.markdowns.filter((m) => !groupedMarkdownIds.has(m.id));
1909
2567
  // 3. Measure root groups bottom-up
1910
2568
  for (const g of rootGroups)
1911
- measure(g, nm, gm, tm, ntm, cm, mdm);
2569
+ measure(g, gm, tm, cm, mdm, em);
1912
2570
  // 4. Build root order
1913
2571
  // sg.rootOrder preserves DSL declaration order.
1914
2572
  // Fall back: groups, then nodes, then tables.
@@ -1918,7 +2576,6 @@ function layout(sg) {
1918
2576
  ...rootGroups.map((g) => ({ kind: "group", id: g.id })),
1919
2577
  ...rootNodes.map((n) => ({ kind: "node", id: n.id })),
1920
2578
  ...rootTables.map((t) => ({ kind: "table", id: t.id })),
1921
- ...rootNotes.map((n) => ({ kind: "note", id: n.id })),
1922
2579
  ...rootCharts.map((c) => ({ kind: "chart", id: c.id })),
1923
2580
  ...rootMarkdowns.map((m) => ({ kind: "markdown", id: m.id })),
1924
2581
  ];
@@ -1934,39 +2591,15 @@ function layout(sg) {
1934
2591
  if (useGrid) {
1935
2592
  // ── Grid: per-row heights, per-column widths (no wasted space) ──
1936
2593
  const cols = rootCols;
1937
- const rows = Math.ceil(rootOrder.length / cols);
1938
- const colWidths = Array(cols).fill(0);
1939
- const rowHeights = Array(rows).fill(0);
1940
- rootOrder.forEach((ref, idx) => {
1941
- const col = idx % cols;
1942
- const row = Math.floor(idx / cols);
1943
- let w = 0, h = 0;
1944
- if (ref.kind === "group") {
1945
- w = gm.get(ref.id).w;
1946
- h = gm.get(ref.id).h;
1947
- }
1948
- else if (ref.kind === "table") {
1949
- w = tm.get(ref.id).w;
1950
- h = tm.get(ref.id).h;
1951
- }
1952
- else if (ref.kind === "note") {
1953
- w = ntm.get(ref.id).w;
1954
- h = ntm.get(ref.id).h;
1955
- }
1956
- else if (ref.kind === "chart") {
1957
- w = cm.get(ref.id).w;
1958
- h = cm.get(ref.id).h;
1959
- }
1960
- else if (ref.kind === "markdown") {
1961
- w = mdm.get(ref.id).w;
1962
- h = mdm.get(ref.id).h;
1963
- }
1964
- else {
1965
- w = nm.get(ref.id).w;
1966
- h = nm.get(ref.id).h;
1967
- }
1968
- colWidths[col] = Math.max(colWidths[col], w);
1969
- rowHeights[row] = Math.max(rowHeights[row], h);
2594
+ const rows = Math.ceil(rootOrder.length / cols);
2595
+ const colWidths = Array(cols).fill(0);
2596
+ const rowHeights = Array(rows).fill(0);
2597
+ rootOrder.forEach((ref, idx) => {
2598
+ const col = idx % cols;
2599
+ const row = Math.floor(idx / cols);
2600
+ const e = em.get(ref.id);
2601
+ colWidths[col] = Math.max(colWidths[col], e.w);
2602
+ rowHeights[row] = Math.max(rowHeights[row], e.h);
1970
2603
  });
1971
2604
  const colX = [];
1972
2605
  let cx = MARGIN;
@@ -1981,95 +2614,24 @@ function layout(sg) {
1981
2614
  ry += rowHeights[r] + GAP_MAIN;
1982
2615
  }
1983
2616
  rootOrder.forEach((ref, idx) => {
1984
- const x = colX[idx % cols];
1985
- const y = rowY[Math.floor(idx / cols)];
1986
- if (ref.kind === "group") {
1987
- gm.get(ref.id).x = x;
1988
- gm.get(ref.id).y = y;
1989
- }
1990
- else if (ref.kind === "table") {
1991
- tm.get(ref.id).x = x;
1992
- tm.get(ref.id).y = y;
1993
- }
1994
- else if (ref.kind === "note") {
1995
- ntm.get(ref.id).x = x;
1996
- ntm.get(ref.id).y = y;
1997
- }
1998
- else if (ref.kind === "chart") {
1999
- cm.get(ref.id).x = x;
2000
- cm.get(ref.id).y = y;
2001
- }
2002
- else if (ref.kind === "markdown") {
2003
- mdm.get(ref.id).x = x;
2004
- mdm.get(ref.id).y = y;
2005
- }
2006
- else {
2007
- nm.get(ref.id).x = x;
2008
- nm.get(ref.id).y = y;
2009
- }
2617
+ const e = em.get(ref.id);
2618
+ e.x = colX[idx % cols];
2619
+ e.y = rowY[Math.floor(idx / cols)];
2010
2620
  });
2011
2621
  }
2012
2622
  else {
2013
2623
  // ── Row or Column linear flow ──────────────────────────
2014
2624
  let pos = MARGIN;
2015
2625
  for (const ref of rootOrder) {
2016
- let w = 0, h = 0;
2017
- if (ref.kind === "group") {
2018
- w = gm.get(ref.id).w;
2019
- h = gm.get(ref.id).h;
2020
- }
2021
- else if (ref.kind === "table") {
2022
- w = tm.get(ref.id).w;
2023
- h = tm.get(ref.id).h;
2024
- }
2025
- else if (ref.kind === "note") {
2026
- w = ntm.get(ref.id).w;
2027
- h = ntm.get(ref.id).h;
2028
- }
2029
- else if (ref.kind === "chart") {
2030
- w = cm.get(ref.id).w;
2031
- h = cm.get(ref.id).h;
2032
- }
2033
- else if (ref.kind === "markdown") {
2034
- w = mdm.get(ref.id).w;
2035
- h = mdm.get(ref.id).h;
2036
- }
2037
- else {
2038
- w = nm.get(ref.id).w;
2039
- h = nm.get(ref.id).h;
2040
- }
2041
- const x = useColumn ? MARGIN : pos;
2042
- const y = useColumn ? pos : MARGIN;
2043
- if (ref.kind === "group") {
2044
- gm.get(ref.id).x = x;
2045
- gm.get(ref.id).y = y;
2046
- }
2047
- else if (ref.kind === "table") {
2048
- tm.get(ref.id).x = x;
2049
- tm.get(ref.id).y = y;
2050
- }
2051
- else if (ref.kind === "note") {
2052
- ntm.get(ref.id).x = x;
2053
- ntm.get(ref.id).y = y;
2054
- }
2055
- else if (ref.kind === "chart") {
2056
- cm.get(ref.id).x = x;
2057
- cm.get(ref.id).y = y;
2058
- }
2059
- else if (ref.kind === "markdown") {
2060
- mdm.get(ref.id).x = x;
2061
- mdm.get(ref.id).y = y;
2062
- }
2063
- else {
2064
- nm.get(ref.id).x = x;
2065
- nm.get(ref.id).y = y;
2066
- }
2067
- pos += (useColumn ? h : w) + GAP_MAIN;
2626
+ const e = em.get(ref.id);
2627
+ e.x = useColumn ? MARGIN : pos;
2628
+ e.y = useColumn ? pos : MARGIN;
2629
+ pos += (useColumn ? e.h : e.w) + GAP_MAIN;
2068
2630
  }
2069
2631
  }
2070
2632
  // 6. Place children within each root group (top-down, recursive)
2071
2633
  for (const g of rootGroups)
2072
- place(g, nm, gm, tm, ntm, cm, mdm);
2634
+ place(g, gm, em);
2073
2635
  // 7. Route edges and compute canvas size
2074
2636
  routeEdges(sg);
2075
2637
  computeBounds(sg, MARGIN);
@@ -2086,8 +2648,8 @@ const CHART_COLORS = [
2086
2648
  '#7F77DD', '#D4537E', '#639922', '#E24B4A',
2087
2649
  ];
2088
2650
  function chartLayout(c) {
2089
- const titleH = c.label ? 24 : 8;
2090
- const padL = 44, padR = 12, padB = 28, padT = 6;
2651
+ const titleH = c.label ? CHART.titleH : CHART.titleHEmpty;
2652
+ const padL = CHART.padL, padR = CHART.padR, padB = CHART.padB, padT = CHART.padT;
2091
2653
  const pw = c.w - padL - padR;
2092
2654
  const ph = c.h - titleH - padT - padB;
2093
2655
  return {
@@ -2181,7 +2743,7 @@ function donutArcPath(cx, cy, r, ir, startAngle, endAngle) {
2181
2743
  // Also remove the Chart.js `declare const Chart: any;` at the top of svg/index.ts
2182
2744
  // and the CHART_COLORS array (they live in roughChart.ts now).
2183
2745
  // ============================================================
2184
- const NS$1 = 'http://www.w3.org/2000/svg';
2746
+ const NS$1 = SVG_NS$1;
2185
2747
  const se$1 = (tag) => document.createElementNS(NS$1, tag);
2186
2748
  function mkG(id, cls) {
2187
2749
  const g = se$1('g');
@@ -2204,23 +2766,23 @@ function mkT(txt, x, y, sz = 10, wt = 400, col = '#4a2e10', anchor = 'middle', f
2204
2766
  t.textContent = txt;
2205
2767
  return t;
2206
2768
  }
2207
- function hashStr$4(s) {
2769
+ function hashStr$2(s) {
2208
2770
  let h = 5381;
2209
2771
  for (let i = 0; i < s.length; i++)
2210
2772
  h = ((h * 33) ^ s.charCodeAt(i)) & 0xffff;
2211
2773
  return h;
2212
2774
  }
2213
- const BASE = { roughness: 1.2, bowing: 0.7 };
2775
+ const BASE = { roughness: ROUGH.chartRoughness, bowing: ROUGH.bowing };
2214
2776
  // ── Axes ───────────────────────────────────────────────────
2215
2777
  function drawAxes$1(rc, g, c, px, py, pw, ph, allY, labelCol, font = 'system-ui, sans-serif') {
2216
2778
  // Y axis
2217
2779
  g.appendChild(rc.line(px, py, px, py + ph, {
2218
- roughness: 0.4, seed: hashStr$4(c.id + 'ya'), stroke: labelCol, strokeWidth: 1,
2780
+ roughness: 0.4, seed: hashStr$2(c.id + 'ya'), stroke: labelCol, strokeWidth: 1,
2219
2781
  }));
2220
2782
  // X axis (baseline)
2221
2783
  const baseline = makeValueToY(allY, py, ph)(0);
2222
2784
  g.appendChild(rc.line(px, baseline, px + pw, baseline, {
2223
- roughness: 0.4, seed: hashStr$4(c.id + 'xa'), stroke: labelCol, strokeWidth: 1,
2785
+ roughness: 0.4, seed: hashStr$2(c.id + 'xa'), stroke: labelCol, strokeWidth: 1,
2224
2786
  }));
2225
2787
  // Y ticks + labels
2226
2788
  const toY = makeValueToY(allY, py, ph);
@@ -2229,7 +2791,7 @@ function drawAxes$1(rc, g, c, px, py, pw, ph, allY, labelCol, font = 'system-ui,
2229
2791
  if (ty < py - 2 || ty > py + ph + 2)
2230
2792
  continue;
2231
2793
  g.appendChild(rc.line(px - 3, ty, px, ty, {
2232
- roughness: 0.2, seed: hashStr$4(c.id + 'yt' + tick), stroke: labelCol, strokeWidth: 0.7,
2794
+ roughness: 0.2, seed: hashStr$2(c.id + 'yt' + tick), stroke: labelCol, strokeWidth: 0.7,
2233
2795
  }));
2234
2796
  g.appendChild(mkT(fmtNum$1(tick), px - 5, ty, 9, 400, labelCol, 'end', font));
2235
2797
  }
@@ -2268,7 +2830,7 @@ function renderRoughChartSVG(rc, c, palette, isDark) {
2268
2830
  cg.setAttribute('opacity', String(s.opacity));
2269
2831
  // Background box
2270
2832
  cg.appendChild(rc.rectangle(c.x, c.y, c.w, c.h, {
2271
- ...BASE, seed: hashStr$4(c.id),
2833
+ ...BASE, seed: hashStr$2(c.id),
2272
2834
  fill: bgFill, fillStyle: 'solid',
2273
2835
  stroke: bgStroke, strokeWidth: Number(s.strokeWidth ?? 1.2),
2274
2836
  ...(s.strokeDash ? { strokeLineDash: s.strokeDash } : {}),
@@ -2292,7 +2854,7 @@ function renderRoughChartSVG(rc, c, palette, isDark) {
2292
2854
  ? donutArcPath(cx, cy, r, ir, angle, angle + sweep)
2293
2855
  : pieArcPath(cx, cy, r, angle, angle + sweep);
2294
2856
  cg.appendChild(rc.path(d, {
2295
- roughness: 1.0, bowing: 0.5, seed: hashStr$4(c.id + seg.label),
2857
+ roughness: 1.0, bowing: 0.5, seed: hashStr$2(c.id + seg.label),
2296
2858
  fill: seg.color + 'bb',
2297
2859
  fillStyle: 'solid',
2298
2860
  stroke: seg.color,
@@ -2311,11 +2873,11 @@ function renderRoughChartSVG(rc, c, palette, isDark) {
2311
2873
  const toX = makeValueToX(xs, px, pw);
2312
2874
  const toY = makeValueToY(ys, py, ph);
2313
2875
  // Simple axes (no named ticks — raw data ranges)
2314
- cg.appendChild(rc.line(px, py, px, py + ph, { roughness: 0.4, seed: hashStr$4(c.id + 'ya'), stroke: lc, strokeWidth: 1 }));
2315
- cg.appendChild(rc.line(px, py + ph, px + pw, py + ph, { roughness: 0.4, seed: hashStr$4(c.id + 'xa'), stroke: lc, strokeWidth: 1 }));
2876
+ cg.appendChild(rc.line(px, py, px, py + ph, { roughness: 0.4, seed: hashStr$2(c.id + 'ya'), stroke: lc, strokeWidth: 1 }));
2877
+ cg.appendChild(rc.line(px, py + ph, px + pw, py + ph, { roughness: 0.4, seed: hashStr$2(c.id + 'xa'), stroke: lc, strokeWidth: 1 }));
2316
2878
  pts.forEach((pt, i) => {
2317
2879
  cg.appendChild(rc.ellipse(toX(pt.x), toY(pt.y), 10, 10, {
2318
- roughness: 0.8, seed: hashStr$4(c.id + pt.label),
2880
+ roughness: 0.8, seed: hashStr$2(c.id + pt.label),
2319
2881
  fill: CHART_COLORS[i % CHART_COLORS.length] + '99',
2320
2882
  fillStyle: 'solid',
2321
2883
  stroke: CHART_COLORS[i % CHART_COLORS.length],
@@ -2348,7 +2910,7 @@ function renderRoughChartSVG(rc, c, palette, isDark) {
2348
2910
  const bh = Math.abs(baseline - toY(val)) || 2;
2349
2911
  cg.appendChild(rc.rectangle(bx, by, barW, bh, {
2350
2912
  roughness: 1.1, bowing: 0.5,
2351
- seed: hashStr$4(c.id + si + i),
2913
+ seed: hashStr$2(c.id + si + i),
2352
2914
  fill: ser.color + 'bb',
2353
2915
  fillStyle: 'hachure',
2354
2916
  hachureAngle: -41,
@@ -2376,7 +2938,7 @@ function renderRoughChartSVG(rc, c, palette, isDark) {
2376
2938
  [pts[pts.length - 1][0], baseline],
2377
2939
  ];
2378
2940
  cg.appendChild(rc.polygon(poly, {
2379
- roughness: 0.5, seed: hashStr$4(c.id + 'af' + si),
2941
+ roughness: 0.5, seed: hashStr$2(c.id + 'af' + si),
2380
2942
  fill: ser.color + '44',
2381
2943
  fillStyle: 'solid',
2382
2944
  stroke: 'none',
@@ -2386,7 +2948,7 @@ function renderRoughChartSVG(rc, c, palette, isDark) {
2386
2948
  for (let i = 0; i < pts.length - 1; i++) {
2387
2949
  cg.appendChild(rc.line(pts[i][0], pts[i][1], pts[i + 1][0], pts[i + 1][1], {
2388
2950
  roughness: 0.9, bowing: 0.6,
2389
- seed: hashStr$4(c.id + si + i),
2951
+ seed: hashStr$2(c.id + si + i),
2390
2952
  stroke: ser.color,
2391
2953
  strokeWidth: 1.8,
2392
2954
  }));
@@ -2394,7 +2956,7 @@ function renderRoughChartSVG(rc, c, palette, isDark) {
2394
2956
  // Point dots
2395
2957
  pts.forEach(([px2, py2], i) => {
2396
2958
  cg.appendChild(rc.ellipse(px2, py2, 7, 7, {
2397
- roughness: 0.3, seed: hashStr$4(c.id + 'dot' + si + i),
2959
+ roughness: 0.3, seed: hashStr$2(c.id + 'dot' + si + i),
2398
2960
  fill: ser.color,
2399
2961
  fillStyle: 'solid',
2400
2962
  stroke: ser.color,
@@ -2708,83 +3270,6 @@ function listThemes() {
2708
3270
  }
2709
3271
  const THEME_NAMES = Object.keys(PALETTES);
2710
3272
 
2711
- // ============================================================
2712
- // sketchmark — Font Registry
2713
- // ============================================================
2714
- // built-in named fonts — user can reference these by short name
2715
- const BUILTIN_FONTS = {
2716
- // hand-drawn
2717
- caveat: {
2718
- family: "'Caveat', cursive",
2719
- url: 'https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600&display=swap',
2720
- },
2721
- handlee: {
2722
- family: "'Handlee', cursive",
2723
- url: 'https://fonts.googleapis.com/css2?family=Handlee&display=swap',
2724
- },
2725
- 'indie-flower': {
2726
- family: "'Indie Flower', cursive",
2727
- url: 'https://fonts.googleapis.com/css2?family=Indie+Flower&display=swap',
2728
- },
2729
- 'patrick-hand': {
2730
- family: "'Patrick Hand', cursive",
2731
- url: 'https://fonts.googleapis.com/css2?family=Patrick+Hand&display=swap',
2732
- },
2733
- // clean / readable
2734
- 'dm-mono': {
2735
- family: "'DM Mono', monospace",
2736
- url: 'https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&display=swap',
2737
- },
2738
- 'jetbrains': {
2739
- family: "'JetBrains Mono', monospace",
2740
- url: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500&display=swap',
2741
- },
2742
- 'instrument': {
2743
- family: "'Instrument Serif', serif",
2744
- url: 'https://fonts.googleapis.com/css2?family=Instrument+Serif&display=swap',
2745
- },
2746
- 'playfair': {
2747
- family: "'Playfair Display', serif",
2748
- url: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500&display=swap',
2749
- },
2750
- // system fallbacks (no URL needed)
2751
- system: { family: 'system-ui, sans-serif' },
2752
- mono: { family: "'Courier New', monospace" },
2753
- serif: { family: 'Georgia, serif' },
2754
- };
2755
- // default — what renders when no font is specified
2756
- const DEFAULT_FONT = 'system-ui, sans-serif';
2757
- // resolve a short name or pass-through a quoted CSS family
2758
- function resolveFont(nameOrFamily) {
2759
- const key = nameOrFamily.toLowerCase().trim();
2760
- if (BUILTIN_FONTS[key])
2761
- return BUILTIN_FONTS[key].family;
2762
- return nameOrFamily; // treat as raw CSS font-family
2763
- }
2764
- // inject a <link> into <head> for a built-in font (browser only)
2765
- function loadFont(name) {
2766
- if (typeof document === 'undefined')
2767
- return;
2768
- const key = name.toLowerCase().trim();
2769
- const def = BUILTIN_FONTS[key];
2770
- if (!def?.url || def.loaded)
2771
- return;
2772
- if (document.querySelector(`link[data-sketchmark-font="${key}"]`))
2773
- return;
2774
- const link = document.createElement('link');
2775
- link.rel = 'stylesheet';
2776
- link.href = def.url;
2777
- link.setAttribute('data-sketchmark-font', key);
2778
- document.head.appendChild(link);
2779
- def.loaded = true;
2780
- }
2781
- // user registers their own font (already loaded via CSS/link)
2782
- function registerFont(name, family, url) {
2783
- BUILTIN_FONTS[name.toLowerCase()] = { family, url };
2784
- if (url)
2785
- loadFont(name);
2786
- }
2787
-
2788
3273
  function rotatePoints(points, center, degrees) {
2789
3274
  if (points && points.length) {
2790
3275
  const [cx, cy] = center;
@@ -4860,53 +5345,62 @@ var rough = {
4860
5345
  };
4861
5346
 
4862
5347
  // ============================================================
4863
- // sketchmark SVG Renderer (rough.js hand-drawn)
5348
+ // Shared Typography Resolution
5349
+ //
5350
+ // Extracts the repeated pattern of resolving fontSize, fontWeight,
5351
+ // textColor, font, textAlign, letterSpacing, lineHeight, padding,
5352
+ // verticalAlign from a style object with entity-specific defaults.
4864
5353
  // ============================================================
4865
- const NS = "http://www.w3.org/2000/svg";
4866
- const se = (tag) => document.createElementNS(NS, tag);
4867
- function hashStr$3(s) {
4868
- let h = 5381;
4869
- for (let i = 0; i < s.length; i++)
4870
- h = ((h * 33) ^ s.charCodeAt(i)) & 0xffff;
4871
- return h;
4872
- }
4873
- const BASE_ROUGH = { roughness: 1.3, bowing: 0.7 };
4874
- /** Darken a CSS hex colour by `amount` (0–1). Falls back to input for non-hex. */
4875
- function darkenHex$1(hex, amount = 0.12) {
4876
- const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
4877
- if (!m)
4878
- return hex;
4879
- const d = (v) => Math.max(0, Math.round(parseInt(v, 16) * (1 - amount)));
4880
- return `#${d(m[1]).toString(16).padStart(2, "0")}${d(m[2]).toString(16).padStart(2, "0")}${d(m[3]).toString(16).padStart(2, "0")}`;
4881
- }
4882
- // ── Small helper: load + resolve font from style or fall back ─────────────
4883
- function resolveStyleFont$1(style, fallback) {
4884
- const raw = String(style["font"] ?? "");
4885
- if (!raw)
4886
- return fallback;
4887
- loadFont(raw);
4888
- return resolveFont(raw);
5354
+ const ANCHOR_MAP = {
5355
+ left: "start",
5356
+ center: "middle",
5357
+ right: "end",
5358
+ };
5359
+ function resolveTypography(style, defaults, diagramFont, fallbackTextColor) {
5360
+ const s = (style ?? {});
5361
+ const fontSize = Number(s.fontSize ?? defaults.fontSize ?? TYPOGRAPHY.defaultFontSize);
5362
+ const fontWeight = (s.fontWeight ?? defaults.fontWeight ?? TYPOGRAPHY.defaultFontWeight);
5363
+ const textColor = String(s.color ?? defaults.textColor ?? fallbackTextColor);
5364
+ const font = resolveStyleFont(s, diagramFont);
5365
+ const textAlign = String(s.textAlign ?? defaults.textAlign ?? TYPOGRAPHY.defaultAlign);
5366
+ const textAnchor = ANCHOR_MAP[textAlign] ?? "middle";
5367
+ const letterSpacing = s.letterSpacing;
5368
+ const lhMult = Number(s.lineHeight ?? defaults.lineHeight ?? TYPOGRAPHY.defaultLineHeight);
5369
+ const lineHeight = lhMult * fontSize;
5370
+ const verticalAlign = String(s.verticalAlign ?? defaults.verticalAlign ?? TYPOGRAPHY.defaultVAlign);
5371
+ const padding = Number(s.padding ?? defaults.padding ?? TYPOGRAPHY.defaultPadding);
5372
+ return {
5373
+ fontSize, fontWeight, textColor, font,
5374
+ textAlign, textAnchor, letterSpacing,
5375
+ lineHeight, verticalAlign, padding,
5376
+ };
4889
5377
  }
4890
- function wrapText$1(text, maxWidth, fontSize) {
4891
- const words = text.split(' ');
4892
- const charsPerPx = fontSize * 0.55; // approximate
4893
- const maxChars = Math.floor(maxWidth / charsPerPx);
4894
- const lines = [];
4895
- let current = '';
4896
- for (const word of words) {
4897
- const test = current ? `${current} ${word}` : word;
4898
- if (test.length > maxChars && current) {
4899
- lines.push(current);
4900
- current = word;
4901
- }
4902
- else {
4903
- current = test;
4904
- }
4905
- }
4906
- if (current)
4907
- lines.push(current);
4908
- return lines;
5378
+ /** Compute the x coordinate for text based on alignment within a box. */
5379
+ function computeTextX(typo, x, w) {
5380
+ return typo.textAlign === "left" ? x + typo.padding
5381
+ : typo.textAlign === "right" ? x + w - typo.padding
5382
+ : x + w / 2;
5383
+ }
5384
+ /** Compute the vertical center for a block of text lines within a box. */
5385
+ function computeTextCY(typo, y, h, lineCount, topOffset) {
5386
+ const pad = typo.padding;
5387
+ const top = y + (topOffset ?? pad);
5388
+ const bottom = y + h - pad;
5389
+ const mid = (top + bottom) / 2;
5390
+ const blockH = (lineCount - 1) * typo.lineHeight;
5391
+ if (typo.verticalAlign === "top")
5392
+ return top + blockH / 2;
5393
+ if (typo.verticalAlign === "bottom")
5394
+ return bottom - blockH / 2;
5395
+ return mid;
4909
5396
  }
5397
+
5398
+ // ============================================================
5399
+ // sketchmark — SVG Renderer (rough.js hand-drawn)
5400
+ // ============================================================
5401
+ const NS = SVG_NS$1;
5402
+ const se = (tag) => document.createElementNS(NS, tag);
5403
+ const BASE_ROUGH = { roughness: ROUGH.roughness, bowing: ROUGH.bowing };
4910
5404
  // ── SVG text helpers ──────────────────────────────────────────────────────
4911
5405
  /**
4912
5406
  * Single-line SVG text element.
@@ -4987,56 +5481,6 @@ function mkGroup(id, cls) {
4987
5481
  g.setAttribute("class", cls);
4988
5482
  return g;
4989
5483
  }
4990
- // ── Arrow direction from connector ────────────────────────────────────────
4991
- function connMeta$1(connector) {
4992
- if (connector === "--")
4993
- return { arrowAt: "none", dashed: false };
4994
- if (connector === "---")
4995
- return { arrowAt: "none", dashed: true };
4996
- const bidir = connector.includes("<") && connector.includes(">");
4997
- if (bidir)
4998
- return { arrowAt: "both", dashed: connector.includes("--") };
4999
- const back = connector.startsWith("<");
5000
- const dashed = connector.includes("--");
5001
- if (back)
5002
- return { arrowAt: "start", dashed };
5003
- return { arrowAt: "end", dashed };
5004
- }
5005
- // ── Generic rect connection point ─────────────────────────────────────────
5006
- function rectConnPoint$1(rx, ry, rw, rh, ox, oy) {
5007
- const cx = rx + rw / 2, cy = ry + rh / 2;
5008
- const dx = ox - cx, dy = oy - cy;
5009
- if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01)
5010
- return [cx, cy];
5011
- const hw = rw / 2 - 2, hh = rh / 2 - 2;
5012
- const tx = Math.abs(dx) > 0.01 ? hw / Math.abs(dx) : 1e9;
5013
- const ty = Math.abs(dy) > 0.01 ? hh / Math.abs(dy) : 1e9;
5014
- const t = Math.min(tx, ty);
5015
- return [cx + t * dx, cy + t * dy];
5016
- }
5017
- function resolveEndpoint$1(id, nm, tm, gm, cm, ntm) {
5018
- return (nm.get(id) ?? tm.get(id) ?? gm.get(id) ?? cm.get(id) ?? ntm.get(id) ?? null);
5019
- }
5020
- function getConnPoint$1(src, dstCX, dstCY) {
5021
- if ("shape" in src && src.shape) {
5022
- return connPoint(src, {
5023
- x: dstCX - 1,
5024
- y: dstCY - 1,
5025
- w: 2,
5026
- h: 2});
5027
- }
5028
- return rectConnPoint$1(src.x, src.y, src.w, src.h, dstCX, dstCY);
5029
- }
5030
- // ── Group depth (for paint order) ─────────────────────────────────────────
5031
- function groupDepth$1(g, gm) {
5032
- let d = 0;
5033
- let cur = g;
5034
- while (cur?.parentId) {
5035
- d++;
5036
- cur = gm.get(cur.parentId);
5037
- }
5038
- return d;
5039
- }
5040
5484
  // ── Node shapes ───────────────────────────────────────────────────────────
5041
5485
  function renderShape$1(rc, n, palette) {
5042
5486
  const s = n.style ?? {};
@@ -5051,171 +5495,15 @@ function renderShape$1(rc, n, palette) {
5051
5495
  strokeWidth: Number(s.strokeWidth ?? 1.9),
5052
5496
  ...(s.strokeDash ? { strokeLineDash: s.strokeDash } : {}),
5053
5497
  };
5054
- const cx = n.x + n.w / 2, cy = n.y + n.h / 2;
5055
- const hw = n.w / 2 - 2;
5056
- switch (n.shape) {
5057
- case "circle":
5058
- return [rc.ellipse(cx, cy, n.w * 0.88, n.h * 0.88, opts)];
5059
- case "diamond":
5060
- return [
5061
- rc.polygon([
5062
- [cx, n.y + 2],
5063
- [cx + hw, cy],
5064
- [cx, n.y + n.h - 2],
5065
- [cx - hw, cy],
5066
- ], opts),
5067
- ];
5068
- case "hexagon": {
5069
- const hw2 = hw * 0.56;
5070
- return [
5071
- rc.polygon([
5072
- [cx - hw2, n.y + 3],
5073
- [cx + hw2, n.y + 3],
5074
- [cx + hw, cy],
5075
- [cx + hw2, n.y + n.h - 3],
5076
- [cx - hw2, n.y + n.h - 3],
5077
- [cx - hw, cy],
5078
- ], opts),
5079
- ];
5080
- }
5081
- case "triangle":
5082
- return [
5083
- rc.polygon([
5084
- [cx, n.y + 3],
5085
- [n.x + n.w - 3, n.y + n.h - 3],
5086
- [n.x + 3, n.y + n.h - 3],
5087
- ], opts),
5088
- ];
5089
- case "parallelogram":
5090
- return [
5091
- rc.polygon([
5092
- [n.x + 18, n.y + 1],
5093
- [n.x + n.w - 1, n.y + 1],
5094
- [n.x + n.w - 18, n.y + n.h - 1],
5095
- [n.x + 1, n.y + n.h - 1],
5096
- ], opts),
5097
- ];
5098
- case "cylinder": {
5099
- const eH = 18;
5100
- return [
5101
- rc.rectangle(n.x + 3, n.y + eH / 2, n.w - 6, n.h - eH, opts),
5102
- rc.ellipse(cx, n.y + eH / 2, n.w - 8, eH, { ...opts, roughness: 0.6 }),
5103
- rc.ellipse(cx, n.y + n.h - eH / 2, n.w - 8, eH, {
5104
- ...opts,
5105
- roughness: 0.6,
5106
- fill: "none",
5107
- }),
5108
- ];
5109
- }
5110
- case "text":
5111
- return [];
5112
- case "icon": {
5113
- if (n.iconName) {
5114
- const [prefix, name] = n.iconName.includes(":")
5115
- ? n.iconName.split(":", 2)
5116
- : ["mdi", n.iconName];
5117
- const iconColor = s.color
5118
- ? encodeURIComponent(String(s.color))
5119
- : encodeURIComponent(String(palette.nodeStroke));
5120
- // reserve bottom 20px for label when present
5121
- const labelSpace = n.label ? 20 : 0;
5122
- const iconAreaH = n.h - labelSpace;
5123
- const iconSize = Math.min(n.w, iconAreaH) - 4;
5124
- const iconUrl = `https://api.iconify.design/${prefix}/${name}.svg?color=${iconColor}&width=${iconSize}&height=${iconSize}`;
5125
- const img = document.createElementNS(NS, "image");
5126
- img.setAttribute("href", iconUrl);
5127
- const iconX = n.x + (n.w - iconSize) / 2;
5128
- const iconY = n.y + (iconAreaH - iconSize) / 2;
5129
- img.setAttribute("x", String(iconX));
5130
- img.setAttribute("y", String(iconY));
5131
- img.setAttribute("width", String(iconSize));
5132
- img.setAttribute("height", String(iconSize));
5133
- img.setAttribute("preserveAspectRatio", "xMidYMid meet");
5134
- if (s.opacity != null)
5135
- img.setAttribute("opacity", String(s.opacity));
5136
- // clip-path for rounded corners
5137
- const clipId = `clip-${n.id}`;
5138
- const defs = document.createElementNS(NS, "defs");
5139
- const clip = document.createElementNS(NS, "clipPath");
5140
- clip.setAttribute("id", clipId);
5141
- const rect = document.createElementNS(NS, "rect");
5142
- rect.setAttribute("x", String(iconX));
5143
- rect.setAttribute("y", String(iconY));
5144
- rect.setAttribute("width", String(iconSize));
5145
- rect.setAttribute("height", String(iconSize));
5146
- rect.setAttribute("rx", "6");
5147
- clip.appendChild(rect);
5148
- defs.appendChild(clip);
5149
- img.setAttribute("clip-path", `url(#${clipId})`);
5150
- // only draw border when stroke is explicitly set
5151
- const els = [defs, img];
5152
- if (s.stroke) {
5153
- els.push(rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, {
5154
- ...opts,
5155
- fill: "none",
5156
- }));
5157
- }
5158
- return els;
5159
- }
5160
- // fallback: placeholder square
5161
- return [
5162
- rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, {
5163
- ...opts,
5164
- fill: "#e0e0e0",
5165
- stroke: "#999999",
5166
- }),
5167
- ];
5168
- }
5169
- case "image": {
5170
- if (n.imageUrl) {
5171
- // reserve bottom 20px for label when present
5172
- const imgLabelSpace = n.label ? 20 : 0;
5173
- const imgAreaH = n.h - imgLabelSpace;
5174
- const img = document.createElementNS(NS, "image");
5175
- img.setAttribute("href", n.imageUrl);
5176
- img.setAttribute("x", String(n.x + 1));
5177
- img.setAttribute("y", String(n.y + 1));
5178
- img.setAttribute("width", String(n.w - 2));
5179
- img.setAttribute("height", String(imgAreaH - 2));
5180
- img.setAttribute("preserveAspectRatio", "xMidYMid meet");
5181
- const clipId = `clip-${n.id}`;
5182
- const defs = document.createElementNS(NS, "defs");
5183
- const clip = document.createElementNS(NS, "clipPath");
5184
- clip.setAttribute("id", clipId);
5185
- const rect = document.createElementNS(NS, "rect");
5186
- rect.setAttribute("x", String(n.x + 1));
5187
- rect.setAttribute("y", String(n.y + 1));
5188
- rect.setAttribute("width", String(n.w - 2));
5189
- rect.setAttribute("height", String(imgAreaH - 2));
5190
- rect.setAttribute("rx", "6");
5191
- clip.appendChild(rect);
5192
- defs.appendChild(clip);
5193
- img.setAttribute("clip-path", `url(#${clipId})`);
5194
- // only draw border when stroke is explicitly set
5195
- const els = [defs, img];
5196
- if (s.stroke) {
5197
- els.push(rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, {
5198
- ...opts,
5199
- fill: "none",
5200
- }));
5201
- }
5202
- return els;
5203
- }
5204
- return [
5205
- rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, {
5206
- ...opts,
5207
- fill: "#e0e0e0",
5208
- stroke: "#999999",
5209
- }),
5210
- ];
5211
- }
5212
- default:
5213
- return [rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, opts)];
5214
- }
5498
+ const shape = getShape(n.shape);
5499
+ if (shape)
5500
+ return shape.renderSVG(rc, n, palette, opts);
5501
+ // fallback: box
5502
+ return [rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, opts)];
5215
5503
  }
5216
5504
  // ── Arrowhead ─────────────────────────────────────────────────────────────
5217
5505
  function arrowHead(rc, x, y, angle, col, seed) {
5218
- const as = 12;
5506
+ const as = EDGE.arrowSize;
5219
5507
  return rc.polygon([
5220
5508
  [x, y],
5221
5509
  [
@@ -5283,13 +5571,13 @@ function renderToSVG(sg, container, options = {}) {
5283
5571
  // ── Title ────────────────────────────────────────────────
5284
5572
  if (options.showTitle && sg.title) {
5285
5573
  const titleColor = String(sg.config["title-color"] ?? palette.titleText);
5286
- const titleSize = Number(sg.config["title-size"] ?? 18);
5287
- const titleWeight = Number(sg.config["title-weight"] ?? 600);
5288
- svg.appendChild(mkText(sg.title, sg.width / 2, 26, titleSize, titleWeight, titleColor, "middle", diagramFont));
5574
+ const titleSize = Number(sg.config["title-size"] ?? TITLE.fontSize);
5575
+ const titleWeight = Number(sg.config["title-weight"] ?? TITLE.fontWeight);
5576
+ svg.appendChild(mkText(sg.title, sg.width / 2, TITLE.y, titleSize, titleWeight, titleColor, "middle", diagramFont));
5289
5577
  }
5290
5578
  // ── Groups ───────────────────────────────────────────────
5291
5579
  const gmMap = new Map(sg.groups.map((g) => [g.id, g]));
5292
- const sortedGroups = [...sg.groups].sort((a, b) => groupDepth$1(a, gmMap) - groupDepth$1(b, gmMap));
5580
+ const sortedGroups = [...sg.groups].sort((a, b) => groupDepth(a, gmMap) - groupDepth(b, gmMap));
5293
5581
  const GL = mkGroup("grp-layer");
5294
5582
  for (const g of sortedGroups) {
5295
5583
  if (!g.w)
@@ -5310,26 +5598,10 @@ function renderToSVG(sg, container, options = {}) {
5310
5598
  strokeLineDash: gs.strokeDash ?? palette.groupDash,
5311
5599
  }));
5312
5600
  // ── Group label typography ──────────────────────────
5313
- const gLabelColor = gs.color ? String(gs.color) : palette.groupLabel;
5314
- const gFontSize = Number(gs.fontSize ?? 12);
5315
- const gFontWeight = gs.fontWeight ?? 500;
5316
- const gFont = resolveStyleFont$1(gs, diagramFont);
5317
- const gLetterSpacing = gs.letterSpacing;
5318
- const gPad = Number(gs.padding ?? 14);
5319
- const gTextAlign = String(gs.textAlign ?? "left");
5320
- const gAnchorMap = {
5321
- left: "start",
5322
- center: "middle",
5323
- right: "end",
5324
- };
5325
- const gAnchor = gAnchorMap[gTextAlign] ?? "start";
5326
- const gTextX = gTextAlign === "right"
5327
- ? g.x + g.w - gPad
5328
- : gTextAlign === "center"
5329
- ? g.x + g.w / 2
5330
- : g.x + gPad;
5601
+ const gTypo = resolveTypography(gs, { fontSize: GROUP_LABEL.fontSize, fontWeight: GROUP_LABEL.fontWeight, textAlign: "left", padding: GROUP_LABEL.padding }, diagramFont, palette.groupLabel);
5602
+ const gTextX = computeTextX(gTypo, g.x, g.w);
5331
5603
  if (g.label) {
5332
- gg.appendChild(mkText(g.label, gTextX, g.y + gPad, gFontSize, gFontWeight, gLabelColor, gAnchor, gFont, gLetterSpacing));
5604
+ gg.appendChild(mkText(g.label, gTextX, g.y + gTypo.padding, gTypo.fontSize, gTypo.fontWeight, gTypo.textColor, gTypo.textAnchor, gTypo.font, gTypo.letterSpacing));
5333
5605
  }
5334
5606
  GL.appendChild(gg);
5335
5607
  }
@@ -5338,44 +5610,51 @@ function renderToSVG(sg, container, options = {}) {
5338
5610
  const nm = nodeMap(sg);
5339
5611
  const tm = tableMap(sg);
5340
5612
  const cm = chartMap(sg);
5341
- const ntm = noteMap(sg);
5342
5613
  const EL = mkGroup("edge-layer");
5343
5614
  for (const e of sg.edges) {
5344
- const src = resolveEndpoint$1(e.from, nm, tm, gmMap, cm, ntm);
5345
- const dst = resolveEndpoint$1(e.to, nm, tm, gmMap, cm, ntm);
5615
+ const src = resolveEndpoint(e.from, nm, tm, gmMap, cm);
5616
+ const dst = resolveEndpoint(e.to, nm, tm, gmMap, cm);
5346
5617
  if (!src || !dst)
5347
5618
  continue;
5348
5619
  const dstCX = dst.x + dst.w / 2, dstCY = dst.y + dst.h / 2;
5349
5620
  const srcCX = src.x + src.w / 2, srcCY = src.y + src.h / 2;
5350
- const [x1, y1] = getConnPoint$1(src, dstCX, dstCY);
5351
- const [x2, y2] = getConnPoint$1(dst, srcCX, srcCY);
5621
+ const [x1, y1] = getConnPoint(src, dstCX, dstCY);
5622
+ const [x2, y2] = getConnPoint(dst, srcCX, srcCY);
5352
5623
  const eg = mkGroup(`edge-${e.from}-${e.to}`, "eg");
5353
5624
  if (e.style?.opacity != null)
5354
5625
  eg.setAttribute("opacity", String(e.style.opacity));
5355
5626
  const len = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) || 1;
5356
5627
  const nx = (x2 - x1) / len, ny = (y2 - y1) / len;
5357
5628
  const ecol = String(e.style?.stroke ?? palette.edgeStroke);
5358
- const { arrowAt, dashed } = connMeta$1(e.connector);
5359
- const HEAD = 13;
5629
+ const { arrowAt, dashed } = connMeta(e.connector);
5630
+ const HEAD = EDGE.headInset;
5360
5631
  const sx1 = arrowAt === "start" || arrowAt === "both" ? x1 + nx * HEAD : x1;
5361
5632
  const sy1 = arrowAt === "start" || arrowAt === "both" ? y1 + ny * HEAD : y1;
5362
5633
  const sx2 = arrowAt === "end" || arrowAt === "both" ? x2 - nx * HEAD : x2;
5363
5634
  const sy2 = arrowAt === "end" || arrowAt === "both" ? y2 - ny * HEAD : y2;
5364
- eg.appendChild(rc.line(sx1, sy1, sx2, sy2, {
5635
+ const shaft = rc.line(sx1, sy1, sx2, sy2, {
5365
5636
  ...BASE_ROUGH,
5366
5637
  roughness: 0.9,
5367
5638
  seed: hashStr$3(e.from + e.to),
5368
5639
  stroke: ecol,
5369
5640
  strokeWidth: Number(e.style?.strokeWidth ?? 1.6),
5370
- ...(dashed ? { strokeLineDash: [6, 5] } : {}),
5371
- }));
5372
- if (arrowAt === "end" || arrowAt === "both")
5373
- eg.appendChild(arrowHead(rc, x2, y2, Math.atan2(y2 - y1, x2 - x1), ecol, hashStr$3(e.to)));
5374
- if (arrowAt === "start" || arrowAt === "both")
5375
- eg.appendChild(arrowHead(rc, x1, y1, Math.atan2(y1 - y2, x1 - x2), ecol, hashStr$3(e.from + "back")));
5641
+ ...(dashed ? { strokeLineDash: EDGE.dashPattern } : {}),
5642
+ });
5643
+ shaft.setAttribute("data-edge-role", "shaft");
5644
+ eg.appendChild(shaft);
5645
+ if (arrowAt === "end" || arrowAt === "both") {
5646
+ const endHead = arrowHead(rc, x2, y2, Math.atan2(y2 - y1, x2 - x1), ecol, hashStr$3(e.to));
5647
+ endHead.setAttribute("data-edge-role", "head");
5648
+ eg.appendChild(endHead);
5649
+ }
5650
+ if (arrowAt === "start" || arrowAt === "both") {
5651
+ const startHead = arrowHead(rc, x1, y1, Math.atan2(y1 - y2, x1 - x2), ecol, hashStr$3(e.from + "back"));
5652
+ startHead.setAttribute("data-edge-role", "head");
5653
+ eg.appendChild(startHead);
5654
+ }
5376
5655
  if (e.label) {
5377
- const mx = (x1 + x2) / 2 - ny * 14;
5378
- const my = (y1 + y2) / 2 + nx * 14;
5656
+ const mx = (x1 + x2) / 2 - ny * EDGE.labelOffset;
5657
+ const my = (y1 + y2) / 2 + nx * EDGE.labelOffset;
5379
5658
  const tw = Math.max(e.label.length * 7 + 12, 36);
5380
5659
  const bg = se("rect");
5381
5660
  bg.setAttribute("x", String(mx - tw / 2));
@@ -5385,16 +5664,19 @@ function renderToSVG(sg, container, options = {}) {
5385
5664
  bg.setAttribute("fill", palette.edgeLabelBg);
5386
5665
  bg.setAttribute("rx", "3");
5387
5666
  bg.setAttribute("opacity", "0.9");
5667
+ bg.setAttribute("data-edge-role", "label-bg");
5388
5668
  eg.appendChild(bg);
5389
5669
  // ── Edge label typography ───────────────────────
5390
5670
  // supports: font, font-size, letter-spacing
5391
5671
  // always center-anchored (single line floating on edge)
5392
- const eFontSize = Number(e.style?.fontSize ?? 11);
5393
- const eFont = resolveStyleFont$1(e.style ?? {}, diagramFont);
5672
+ const eFontSize = Number(e.style?.fontSize ?? EDGE.labelFontSize);
5673
+ const eFont = resolveStyleFont(e.style ?? {}, diagramFont);
5394
5674
  const eLetterSpacing = e.style?.letterSpacing;
5395
- const eFontWeight = e.style?.fontWeight ?? 400;
5675
+ const eFontWeight = e.style?.fontWeight ?? EDGE.labelFontWeight;
5396
5676
  const eLabelColor = String(e.style?.color ?? palette.edgeLabelText);
5397
- eg.appendChild(mkText(e.label, mx, my, eFontSize, eFontWeight, eLabelColor, "middle", eFont, eLetterSpacing));
5677
+ const label = mkText(e.label, mx, my, eFontSize, eFontWeight, eLabelColor, "middle", eFont, eLetterSpacing);
5678
+ label.setAttribute("data-edge-role", "label");
5679
+ eg.appendChild(label);
5398
5680
  }
5399
5681
  EL.appendChild(eg);
5400
5682
  }
@@ -5402,54 +5684,70 @@ function renderToSVG(sg, container, options = {}) {
5402
5684
  // ── Nodes ─────────────────────────────────────────────────
5403
5685
  const NL = mkGroup("node-layer");
5404
5686
  for (const n of sg.nodes) {
5405
- const ng = mkGroup(`node-${n.id}`, "ng");
5687
+ const shapeDef = getShape(n.shape);
5688
+ const idPrefix = shapeDef?.idPrefix ?? "node";
5689
+ const cssClass = shapeDef?.cssClass ?? "ng";
5690
+ const ng = mkGroup(`${idPrefix}-${n.id}`, cssClass);
5691
+ ng.dataset.nodeShape = n.shape;
5692
+ ng.dataset.x = String(n.x);
5693
+ ng.dataset.y = String(n.y);
5694
+ ng.dataset.w = String(n.w);
5695
+ ng.dataset.h = String(n.h);
5696
+ if (n.pathData)
5697
+ ng.dataset.pathData = n.pathData;
5406
5698
  if (n.style?.opacity != null)
5407
5699
  ng.setAttribute("opacity", String(n.style.opacity));
5700
+ // ── Static transform (deg, dx, dy, factor) ──────────
5701
+ // Uses CSS style.transform so that transform-box:fill-box +
5702
+ // transform-origin:center on .ng gives correct center-anchored transforms.
5703
+ // The base transform is stored in data-base-transform so the animation
5704
+ // controller can restore it after _clearAll() instead of wiping to "".
5705
+ const hasTx = n.dx || n.dy || n.deg || (n.factor && n.factor !== 1);
5706
+ if (hasTx) {
5707
+ const parts = [];
5708
+ if (n.dx || n.dy)
5709
+ parts.push(`translate(${n.dx ?? 0}px,${n.dy ?? 0}px)`);
5710
+ if (n.deg)
5711
+ parts.push(`rotate(${n.deg}deg)`);
5712
+ if (n.factor && n.factor !== 1)
5713
+ parts.push(`scale(${n.factor})`);
5714
+ const tx = parts.join(" ");
5715
+ ng.style.transform = tx;
5716
+ ng.dataset.baseTransform = tx;
5717
+ }
5408
5718
  renderShape$1(rc, n, palette).forEach((s) => ng.appendChild(s));
5409
5719
  // ── Node / text typography ─────────────────────────
5410
- // supports: font, font-size, letter-spacing, text-align, line-height
5411
- const fontSize = Number(n.style?.fontSize ?? (n.shape === "text" ? 13 : 14));
5412
- const fontWeight = n.style?.fontWeight ?? (n.shape === "text" ? 400 : 500);
5413
- const textColor = String(n.style?.color ??
5414
- (n.shape === "text" ? palette.edgeLabelText : palette.nodeText));
5415
- const nodeFont = resolveStyleFont$1(n.style ?? {}, diagramFont);
5416
- const textAlign = String(n.style?.textAlign ?? "center");
5417
- const anchorMap = {
5418
- left: "start",
5419
- center: "middle",
5420
- right: "end",
5421
- };
5422
- const textAnchor = anchorMap[textAlign] ?? "middle";
5423
- // line-height is a multiplier (e.g. 1.4 = 140% of font-size)
5424
- const lineHeight = Number(n.style?.lineHeight ?? 1.3) * fontSize;
5425
- const letterSpacing = n.style?.letterSpacing;
5426
- const pad = Number(n.style?.padding ?? 8);
5427
- // x shifts for left / right alignment
5428
- const textX = textAlign === "left"
5429
- ? n.x + pad
5430
- : textAlign === "right"
5431
- ? n.x + n.w - pad
5432
- : n.x + n.w / 2;
5720
+ const isText = n.shape === "text";
5721
+ const isNote = n.shape === "note";
5722
+ const isMediaShape = n.shape === "icon" || n.shape === "image" || n.shape === "line";
5723
+ const typo = resolveTypography(n.style, {
5724
+ fontSize: isText ? 13 : isNote ? 12 : 14,
5725
+ fontWeight: isText || isNote ? 400 : 500,
5726
+ textColor: isText ? palette.edgeLabelText : isNote ? palette.noteText : palette.nodeText,
5727
+ textAlign: isNote ? "left" : undefined,
5728
+ lineHeight: isNote ? 1.4 : undefined,
5729
+ padding: isNote ? 12 : undefined,
5730
+ verticalAlign: isNote ? "top" : undefined,
5731
+ }, diagramFont, palette.nodeText);
5732
+ // Note textX accounts for fold corner
5733
+ const FOLD = NOTE.fold;
5734
+ const textX = isNote
5735
+ ? (typo.textAlign === "right" ? n.x + n.w - FOLD - typo.padding
5736
+ : typo.textAlign === "center" ? n.x + (n.w - FOLD) / 2
5737
+ : n.x + typo.padding)
5738
+ : computeTextX(typo, n.x, n.w);
5433
5739
  const lines = n.shape === 'text' && !n.label.includes('\n')
5434
- ? wrapText$1(n.label, n.w - pad * 2, fontSize)
5740
+ ? wrapText(n.label, n.w - typo.padding * 2, typo.fontSize)
5435
5741
  : n.label.split('\n');
5436
- const verticalAlign = String(n.style?.verticalAlign ?? "middle");
5437
- const nodeBodyTop = n.y + pad;
5438
- const nodeBodyBottom = n.y + n.h - pad;
5439
- const nodeBodyMid = n.y + n.h / 2;
5440
- const blockH = (lines.length - 1) * lineHeight;
5441
- const isMediaShape = n.shape === "icon" || n.shape === "image";
5442
5742
  const textCY = isMediaShape
5443
- ? n.y + n.h - 10 // label below the icon/image
5444
- : verticalAlign === "top"
5445
- ? nodeBodyTop + blockH / 2
5446
- : verticalAlign === "bottom"
5447
- ? nodeBodyBottom - blockH / 2
5448
- : nodeBodyMid;
5743
+ ? n.y + n.h - 10
5744
+ : isNote
5745
+ ? computeTextCY(typo, n.y, n.h, lines.length, FOLD + typo.padding)
5746
+ : computeTextCY(typo, n.y, n.h, lines.length);
5449
5747
  if (n.label) {
5450
5748
  ng.appendChild(lines.length > 1
5451
- ? mkMultilineText(lines, textX, textCY, fontSize, fontWeight, textColor, textAnchor, lineHeight, nodeFont, letterSpacing)
5452
- : mkText(n.label, textX, textCY, fontSize, fontWeight, textColor, textAnchor, nodeFont, letterSpacing));
5749
+ ? mkMultilineText(lines, textX, textCY, typo.fontSize, typo.fontWeight, typo.textColor, typo.textAnchor, typo.lineHeight, typo.font, typo.letterSpacing)
5750
+ : mkText(n.label, textX, textCY, typo.fontSize, typo.fontWeight, typo.textColor, typo.textAnchor, typo.font, typo.letterSpacing));
5453
5751
  }
5454
5752
  if (options.interactive) {
5455
5753
  ng.style.cursor = "pointer";
@@ -5472,7 +5770,7 @@ function renderToSVG(sg, container, options = {}) {
5472
5770
  const fill = String(gs.fill ?? palette.tableFill);
5473
5771
  const strk = String(gs.stroke ?? palette.tableStroke);
5474
5772
  const textCol = String(gs.color ?? palette.tableText);
5475
- const hdrFill = gs.fill ? darkenHex$1(fill, 0.08) : palette.tableHeaderFill;
5773
+ const hdrFill = gs.fill ? darkenHex(fill, 0.08) : palette.tableHeaderFill;
5476
5774
  const hdrText = String(gs.color ?? palette.tableHeaderText);
5477
5775
  const divCol = palette.tableDivider;
5478
5776
  const pad = t.labelH;
@@ -5481,7 +5779,7 @@ function renderToSVG(sg, container, options = {}) {
5481
5779
  // ── Table-level font (applies to label + all cells) ─
5482
5780
  // supports: font, font-size, letter-spacing
5483
5781
  const tFontSize = Number(gs.fontSize ?? 12);
5484
- const tFont = resolveStyleFont$1(gs, diagramFont);
5782
+ const tFont = resolveStyleFont(gs, diagramFont);
5485
5783
  const tLetterSpacing = gs.letterSpacing;
5486
5784
  if (gs.opacity != null)
5487
5785
  tg.setAttribute("opacity", String(gs.opacity));
@@ -5557,96 +5855,19 @@ function renderToSVG(sg, container, options = {}) {
5557
5855
  });
5558
5856
  rowY += rh;
5559
5857
  }
5560
- if (options.interactive) {
5561
- tg.style.cursor = "pointer";
5562
- tg.addEventListener("click", () => options.onNodeClick?.(t.id));
5563
- }
5564
- TL.appendChild(tg);
5565
- }
5566
- svg.appendChild(TL);
5567
- // ── Notes ─────────────────────────────────────────────────
5568
- const NoteL = mkGroup("note-layer");
5569
- for (const n of sg.notes) {
5570
- const ng = mkGroup(`note-${n.id}`, "ntg");
5571
- const gs = n.style ?? {};
5572
- const fill = String(gs.fill ?? palette.noteFill);
5573
- const strk = String(gs.stroke ?? palette.noteStroke);
5574
- const nStrokeWidth = Number(gs.strokeWidth ?? 1.2);
5575
- const fold = 14;
5576
- const { x, y, w, h } = n;
5577
- if (gs.opacity != null)
5578
- ng.setAttribute("opacity", String(gs.opacity));
5579
- // ── Note typography ─────────────────────────────────
5580
- const nFontSize = Number(gs.fontSize ?? 12);
5581
- const nFontWeight = gs.fontWeight ?? 400;
5582
- const nFont = resolveStyleFont$1(gs, diagramFont);
5583
- const nLetterSpacing = gs.letterSpacing;
5584
- const nLineHeight = Number(gs.lineHeight ?? 1.4) * nFontSize;
5585
- const nTextAlign = String(gs.textAlign ?? "left");
5586
- const nPad = Number(gs.padding ?? 12);
5587
- const nAnchorMap = {
5588
- left: "start",
5589
- center: "middle",
5590
- right: "end",
5591
- };
5592
- const nAnchor = nAnchorMap[nTextAlign] ?? "start";
5593
- const nTextX = nTextAlign === "right"
5594
- ? x + w - fold - nPad
5595
- : nTextAlign === "center"
5596
- ? x + (w - fold) / 2
5597
- : x + nPad;
5598
- const nFoldPad = fold + nPad; // text starts below fold + user padding
5599
- ng.appendChild(rc.polygon([
5600
- [x, y],
5601
- [x + w - fold, y],
5602
- [x + w, y + fold],
5603
- [x + w, y + h],
5604
- [x, y + h],
5605
- ], {
5606
- ...BASE_ROUGH,
5607
- seed: hashStr$3(n.id),
5608
- fill,
5609
- fillStyle: "solid",
5610
- stroke: strk,
5611
- strokeWidth: nStrokeWidth,
5612
- ...(gs.strokeDash ? { strokeLineDash: gs.strokeDash } : {}),
5613
- }));
5614
- ng.appendChild(rc.polygon([
5615
- [x + w - fold, y],
5616
- [x + w, y + fold],
5617
- [x + w - fold, y + fold],
5618
- ], {
5619
- roughness: 0.4,
5620
- seed: hashStr$3(n.id + "f"),
5621
- fill: palette.noteFold,
5622
- fillStyle: "solid",
5623
- stroke: strk,
5624
- strokeWidth: Math.min(nStrokeWidth, 0.8),
5625
- }));
5626
- const nVerticalAlign = String(gs.verticalAlign ?? "top");
5627
- const bodyTop = y + nFoldPad;
5628
- const bodyBottom = y + h - nPad;
5629
- const bodyMid = (bodyTop + bodyBottom) / 2;
5630
- const blockH = (n.lines.length - 1) * nLineHeight;
5631
- const blockCY = nVerticalAlign === "bottom"
5632
- ? bodyBottom - blockH / 2
5633
- : nVerticalAlign === "middle"
5634
- ? bodyMid
5635
- : bodyTop + blockH / 2;
5636
- if (n.lines.length > 1) {
5637
- ng.appendChild(mkMultilineText(n.lines, nTextX, blockCY, nFontSize, nFontWeight, String(gs.color ?? palette.noteText), nAnchor, nLineHeight, nFont, nLetterSpacing));
5638
- }
5639
- else {
5640
- ng.appendChild(mkText(n.lines[0] ?? "", nTextX, blockCY, nFontSize, nFontWeight, String(gs.color ?? palette.noteText), nAnchor, nFont, nLetterSpacing));
5858
+ if (options.interactive) {
5859
+ tg.style.cursor = "pointer";
5860
+ tg.addEventListener("click", () => options.onNodeClick?.(t.id));
5641
5861
  }
5642
- NoteL.appendChild(ng);
5862
+ TL.appendChild(tg);
5643
5863
  }
5644
- svg.appendChild(NoteL);
5864
+ svg.appendChild(TL);
5865
+ // ── Notes are now rendered as nodes via the shape registry ──
5645
5866
  const MDL = mkGroup('markdown-layer');
5646
5867
  for (const m of sg.markdowns) {
5647
5868
  const mg = mkGroup(`markdown-${m.id}`, 'mdg');
5648
5869
  const gs = m.style ?? {};
5649
- const mFont = resolveStyleFont$1(gs, diagramFont);
5870
+ const mFont = resolveStyleFont(gs, diagramFont);
5650
5871
  const baseColor = String(gs.color ?? palette.nodeText);
5651
5872
  const textAlign = String(gs.textAlign ?? 'left');
5652
5873
  const anchor = textAlign === 'right' ? 'end'
@@ -5734,7 +5955,7 @@ function svgToString(svg) {
5734
5955
  // with:
5735
5956
  // for (const c of sg.charts) drawRoughChartCanvas(rc, ctx, c, pal, R);
5736
5957
  // ============================================================
5737
- function hashStr$2(s) {
5958
+ function hashStr$1(s) {
5738
5959
  let h = 5381;
5739
5960
  for (let i = 0; i < s.length; i++)
5740
5961
  h = ((h * 33) ^ s.charCodeAt(i)) & 0xffff;
@@ -5783,15 +6004,15 @@ function drawAxes(rc, ctx, c, px, py, pw, ph, allY, labelCol, R, font = 'system-
5783
6004
  const toY = makeValueToY(allY, py, ph);
5784
6005
  const baseline = toY(0);
5785
6006
  // Y axis
5786
- rc.line(px, py, px, py + ph, { ...R, roughness: 0.4, seed: hashStr$2(c.id + 'ya'), stroke: labelCol, strokeWidth: 1 });
6007
+ rc.line(px, py, px, py + ph, { ...R, roughness: 0.4, seed: hashStr$1(c.id + 'ya'), stroke: labelCol, strokeWidth: 1 });
5787
6008
  // X axis (baseline)
5788
- rc.line(px, baseline, px + pw, baseline, { ...R, roughness: 0.4, seed: hashStr$2(c.id + 'xa'), stroke: labelCol, strokeWidth: 1 });
6009
+ rc.line(px, baseline, px + pw, baseline, { ...R, roughness: 0.4, seed: hashStr$1(c.id + 'xa'), stroke: labelCol, strokeWidth: 1 });
5789
6010
  // Y ticks + labels
5790
6011
  for (const tick of yTicks(allY)) {
5791
6012
  const ty = toY(tick);
5792
6013
  if (ty < py - 2 || ty > py + ph + 2)
5793
6014
  continue;
5794
- rc.line(px - 3, ty, px, ty, { roughness: 0.2, seed: hashStr$2(c.id + 'yt' + tick), stroke: labelCol, strokeWidth: 0.7 });
6015
+ rc.line(px - 3, ty, px, ty, { roughness: 0.2, seed: hashStr$1(c.id + 'yt' + tick), stroke: labelCol, strokeWidth: 0.7 });
5795
6016
  ctx.save();
5796
6017
  ctx.font = `400 9px ${font}`;
5797
6018
  ctx.fillStyle = labelCol;
@@ -5829,7 +6050,7 @@ function drawRoughChartCanvas(rc, ctx, c, pal, R) {
5829
6050
  ctx.globalAlpha = Number(s.opacity);
5830
6051
  // Background
5831
6052
  rc.rectangle(c.x, c.y, c.w, c.h, {
5832
- ...R, seed: hashStr$2(c.id),
6053
+ ...R, seed: hashStr$1(c.id),
5833
6054
  fill: bgFill,
5834
6055
  fillStyle: 'solid',
5835
6056
  stroke: bgStroke,
@@ -5857,7 +6078,7 @@ function drawRoughChartCanvas(rc, ctx, c, pal, R) {
5857
6078
  let angle = -Math.PI / 2;
5858
6079
  segments.forEach((seg, i) => {
5859
6080
  const sweep = (seg.value / total) * Math.PI * 2;
5860
- drawPieArc(rc, ctx, cx, cy, r, ir, angle, angle + sweep, seg.color, hashStr$2(c.id + seg.label + i));
6081
+ drawPieArc(rc, ctx, cx, cy, r, ir, angle, angle + sweep, seg.color, hashStr$1(c.id + seg.label + i));
5861
6082
  angle += sweep;
5862
6083
  });
5863
6084
  drawLegend(ctx, segments.map(s => `${s.label} ${Math.round(s.value / total * 100)}%`), segments.map(s => s.color), legendX, legendY, lc, cFont);
@@ -5870,11 +6091,11 @@ function drawRoughChartCanvas(rc, ctx, c, pal, R) {
5870
6091
  const xs = pts.map(p => p.x), ys = pts.map(p => p.y);
5871
6092
  const toX = makeValueToX(xs, px, pw);
5872
6093
  const toY = makeValueToY(ys, py, ph);
5873
- rc.line(px, py, px, py + ph, { ...R, roughness: 0.4, seed: hashStr$2(c.id + 'ya'), stroke: lc, strokeWidth: 1 });
5874
- rc.line(px, py + ph, px + pw, py + ph, { ...R, roughness: 0.4, seed: hashStr$2(c.id + 'xa'), stroke: lc, strokeWidth: 1 });
6094
+ rc.line(px, py, px, py + ph, { ...R, roughness: 0.4, seed: hashStr$1(c.id + 'ya'), stroke: lc, strokeWidth: 1 });
6095
+ rc.line(px, py + ph, px + pw, py + ph, { ...R, roughness: 0.4, seed: hashStr$1(c.id + 'xa'), stroke: lc, strokeWidth: 1 });
5875
6096
  pts.forEach((pt, i) => {
5876
6097
  rc.ellipse(toX(pt.x), toY(pt.y), 10, 10, {
5877
- roughness: 0.8, seed: hashStr$2(c.id + pt.label),
6098
+ roughness: 0.8, seed: hashStr$1(c.id + pt.label),
5878
6099
  fill: CHART_COLORS[i % CHART_COLORS.length] + '99',
5879
6100
  fillStyle: 'solid',
5880
6101
  stroke: CHART_COLORS[i % CHART_COLORS.length],
@@ -5914,7 +6135,7 @@ function drawRoughChartCanvas(rc, ctx, c, pal, R) {
5914
6135
  const bh = Math.abs(baseline - toY(val)) || 2;
5915
6136
  rc.rectangle(bx, by, barW, bh, {
5916
6137
  roughness: 1.1, bowing: 0.5,
5917
- seed: hashStr$2(c.id + si + i),
6138
+ seed: hashStr$1(c.id + si + i),
5918
6139
  fill: ser.color + 'bb',
5919
6140
  fillStyle: 'hachure',
5920
6141
  hachureAngle: -41,
@@ -5942,7 +6163,7 @@ function drawRoughChartCanvas(rc, ctx, c, pal, R) {
5942
6163
  [pts[pts.length - 1][0], baseline],
5943
6164
  ];
5944
6165
  rc.polygon(poly, {
5945
- roughness: 0.5, seed: hashStr$2(c.id + 'af' + si),
6166
+ roughness: 0.5, seed: hashStr$1(c.id + 'af' + si),
5946
6167
  fill: ser.color + '44',
5947
6168
  fillStyle: 'solid',
5948
6169
  stroke: 'none',
@@ -5952,7 +6173,7 @@ function drawRoughChartCanvas(rc, ctx, c, pal, R) {
5952
6173
  for (let i = 0; i < pts.length - 1; i++) {
5953
6174
  rc.line(pts[i][0], pts[i][1], pts[i + 1][0], pts[i + 1][1], {
5954
6175
  roughness: 0.9, bowing: 0.6,
5955
- seed: hashStr$2(c.id + si + i),
6176
+ seed: hashStr$1(c.id + si + i),
5956
6177
  stroke: ser.color,
5957
6178
  strokeWidth: 1.8,
5958
6179
  });
@@ -5960,7 +6181,7 @@ function drawRoughChartCanvas(rc, ctx, c, pal, R) {
5960
6181
  // Dots
5961
6182
  pts.forEach(([px2, py2], i) => {
5962
6183
  rc.ellipse(px2, py2, 7, 7, {
5963
- roughness: 0.3, seed: hashStr$2(c.id + 'dot' + si + i),
6184
+ roughness: 0.3, seed: hashStr$1(c.id + 'dot' + si + i),
5964
6185
  fill: ser.color,
5965
6186
  fillStyle: 'solid',
5966
6187
  stroke: ser.color,
@@ -5980,28 +6201,6 @@ function drawRoughChartCanvas(rc, ctx, c, pal, R) {
5980
6201
  // sketchmark — Canvas Renderer
5981
6202
  // Uses rough.js canvas API for hand-drawn rendering
5982
6203
  // ============================================================
5983
- function hashStr$1(s) {
5984
- let h = 5381;
5985
- for (let i = 0; i < s.length; i++)
5986
- h = ((h * 33) ^ s.charCodeAt(i)) & 0xffff;
5987
- return h;
5988
- }
5989
- /** Darken a CSS hex colour by `amount` (0–1). Falls back to input for non-hex. */
5990
- function darkenHex(hex, amount = 0.12) {
5991
- const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
5992
- if (!m)
5993
- return hex;
5994
- const d = (v) => Math.max(0, Math.round(parseInt(v, 16) * (1 - amount)));
5995
- return `#${d(m[1]).toString(16).padStart(2, "0")}${d(m[2]).toString(16).padStart(2, "0")}${d(m[3]).toString(16).padStart(2, "0")}`;
5996
- }
5997
- // ── Small helper: load + resolve font from a style map ────────────────────
5998
- function resolveStyleFont(style, fallback) {
5999
- const raw = String(style['font'] ?? '');
6000
- if (!raw)
6001
- return fallback;
6002
- loadFont(raw);
6003
- return resolveFont(raw);
6004
- }
6005
6204
  // ── Canvas text helpers ────────────────────────────────────────────────────
6006
6205
  function drawText(ctx, txt, x, y, sz = 14, wt = 500, col = '#1a1208', align = 'center', font = 'system-ui, sans-serif', letterSpacing) {
6007
6206
  ctx.save();
@@ -6034,211 +6233,28 @@ function drawMultilineText(ctx, lines, x, cy, sz = 14, wt = 500, col = '#1a1208'
6034
6233
  drawText(ctx, line, x, startY + i * lineH, sz, wt, col, align, font, letterSpacing);
6035
6234
  });
6036
6235
  }
6037
- // Soft word-wrap for `text` shape nodes
6038
- function wrapText(text, maxWidth, fontSize) {
6039
- const charWidth = fontSize * 0.55;
6040
- const maxChars = Math.floor(maxWidth / charWidth);
6041
- const words = text.split(' ');
6042
- const lines = [];
6043
- let current = '';
6044
- for (const word of words) {
6045
- const test = current ? `${current} ${word}` : word;
6046
- if (test.length > maxChars && current) {
6047
- lines.push(current);
6048
- current = word;
6049
- }
6050
- else {
6051
- current = test;
6052
- }
6053
- }
6054
- if (current)
6055
- lines.push(current);
6056
- return lines.length ? lines : [text];
6057
- }
6058
- // ── Arrow direction ────────────────────────────────────────────────────────
6059
- function connMeta(connector) {
6060
- if (connector === '--')
6061
- return { arrowAt: 'none', dashed: false };
6062
- if (connector === '---')
6063
- return { arrowAt: 'none', dashed: true };
6064
- const bidir = connector.includes('<') && connector.includes('>');
6065
- if (bidir)
6066
- return { arrowAt: 'both', dashed: connector.includes('--') };
6067
- const back = connector.startsWith('<');
6068
- const dashed = connector.includes('--');
6069
- if (back)
6070
- return { arrowAt: 'start', dashed };
6071
- return { arrowAt: 'end', dashed };
6072
- }
6073
- // ── Rect connection point ──────────────────────────────────────────────────
6074
- function rectConnPoint(rx, ry, rw, rh, ox, oy) {
6075
- const cx = rx + rw / 2, cy = ry + rh / 2;
6076
- const dx = ox - cx, dy = oy - cy;
6077
- if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01)
6078
- return [cx, cy];
6079
- const hw = rw / 2 - 2, hh = rh / 2 - 2;
6080
- const tx = Math.abs(dx) > 0.01 ? hw / Math.abs(dx) : 1e9;
6081
- const ty = Math.abs(dy) > 0.01 ? hh / Math.abs(dy) : 1e9;
6082
- const t = Math.min(tx, ty);
6083
- return [cx + t * dx, cy + t * dy];
6084
- }
6085
- function resolveEndpoint(id, nm, tm, gm, cm, ntm) {
6086
- return nm.get(id) ?? tm.get(id) ?? gm.get(id) ?? cm.get(id) ?? ntm.get(id) ?? null;
6087
- }
6088
- function getConnPoint(src, dstCX, dstCY) {
6089
- if ('shape' in src && src.shape) {
6090
- return connPoint(src, {
6091
- x: dstCX - 1, y: dstCY - 1, w: 2, h: 2});
6092
- }
6093
- return rectConnPoint(src.x, src.y, src.w, src.h, dstCX, dstCY);
6094
- }
6095
- // ── Group depth ────────────────────────────────────────────────────────────
6096
- function groupDepth(g, gm) {
6097
- let d = 0;
6098
- let cur = g;
6099
- while (cur?.parentId) {
6100
- d++;
6101
- cur = gm.get(cur.parentId);
6102
- }
6103
- return d;
6104
- }
6105
6236
  // ── Node shapes ────────────────────────────────────────────────────────────
6106
6237
  function renderShape(rc, ctx, n, palette, R) {
6107
6238
  const s = n.style ?? {};
6108
6239
  const fill = String(s.fill ?? palette.nodeFill);
6109
6240
  const stroke = String(s.stroke ?? palette.nodeStroke);
6110
6241
  const opts = {
6111
- ...R, seed: hashStr$1(n.id),
6242
+ ...R, seed: hashStr$3(n.id),
6112
6243
  fill, fillStyle: 'solid',
6113
6244
  stroke, strokeWidth: Number(s.strokeWidth ?? 1.9),
6114
6245
  ...(s.strokeDash ? { strokeLineDash: s.strokeDash } : {}),
6115
6246
  };
6116
- const cx = n.x + n.w / 2, cy = n.y + n.h / 2;
6117
- const hw = n.w / 2 - 2;
6118
- switch (n.shape) {
6119
- case 'circle':
6120
- rc.ellipse(cx, cy, n.w * 0.88, n.h * 0.88, opts);
6121
- break;
6122
- case 'diamond':
6123
- rc.polygon([[cx, n.y + 2], [cx + hw, cy], [cx, n.y + n.h - 2], [cx - hw, cy]], opts);
6124
- break;
6125
- case 'hexagon': {
6126
- const hw2 = hw * 0.56;
6127
- rc.polygon([
6128
- [cx - hw2, n.y + 3], [cx + hw2, n.y + 3], [cx + hw, cy],
6129
- [cx + hw2, n.y + n.h - 3], [cx - hw2, n.y + n.h - 3], [cx - hw, cy],
6130
- ], opts);
6131
- break;
6132
- }
6133
- case 'triangle':
6134
- rc.polygon([[cx, n.y + 3], [n.x + n.w - 3, n.y + n.h - 3], [n.x + 3, n.y + n.h - 3]], opts);
6135
- break;
6136
- case 'cylinder': {
6137
- const eH = 18;
6138
- rc.rectangle(n.x + 3, n.y + eH / 2, n.w - 6, n.h - eH, opts);
6139
- rc.ellipse(cx, n.y + eH / 2, n.w - 8, eH, { ...opts, roughness: 0.6 });
6140
- rc.ellipse(cx, n.y + n.h - eH / 2, n.w - 8, eH, { ...opts, roughness: 0.6, fill: 'none' });
6141
- break;
6142
- }
6143
- case 'parallelogram':
6144
- rc.polygon([
6145
- [n.x + 18, n.y + 1], [n.x + n.w - 1, n.y + 1],
6146
- [n.x + n.w - 18, n.y + n.h - 1], [n.x + 1, n.y + n.h - 1],
6147
- ], opts);
6148
- break;
6149
- case 'text':
6150
- break; // no shape drawn
6151
- case 'icon': {
6152
- if (n.iconName) {
6153
- const [prefix, name] = n.iconName.includes(':')
6154
- ? n.iconName.split(':', 2)
6155
- : ['mdi', n.iconName];
6156
- const iconColor = s.color
6157
- ? encodeURIComponent(String(s.color))
6158
- : encodeURIComponent(String(palette.nodeStroke));
6159
- // reserve bottom for label
6160
- const iconLabelSpace = n.label ? 20 : 0;
6161
- const iconAreaH = n.h - iconLabelSpace;
6162
- const iconSize = Math.min(n.w, iconAreaH) - 4;
6163
- const iconUrl = `https://api.iconify.design/${prefix}/${name}.svg?color=${iconColor}&width=${iconSize}&height=${iconSize}`;
6164
- const img = new Image();
6165
- img.crossOrigin = 'anonymous';
6166
- img.onload = () => {
6167
- ctx.save();
6168
- if (s.opacity != null)
6169
- ctx.globalAlpha = Number(s.opacity);
6170
- const iconX = n.x + (n.w - iconSize) / 2;
6171
- const iconY = n.y + (iconAreaH - iconSize) / 2;
6172
- ctx.beginPath();
6173
- const r = 6;
6174
- ctx.moveTo(iconX + r, iconY);
6175
- ctx.lineTo(iconX + iconSize - r, iconY);
6176
- ctx.quadraticCurveTo(iconX + iconSize, iconY, iconX + iconSize, iconY + r);
6177
- ctx.lineTo(iconX + iconSize, iconY + iconSize - r);
6178
- ctx.quadraticCurveTo(iconX + iconSize, iconY + iconSize, iconX + iconSize - r, iconY + iconSize);
6179
- ctx.lineTo(iconX + r, iconY + iconSize);
6180
- ctx.quadraticCurveTo(iconX, iconY + iconSize, iconX, iconY + iconSize - r);
6181
- ctx.lineTo(iconX, iconY + r);
6182
- ctx.quadraticCurveTo(iconX, iconY, iconX + r, iconY);
6183
- ctx.closePath();
6184
- ctx.clip();
6185
- ctx.drawImage(img, iconX, iconY, iconSize, iconSize);
6186
- ctx.restore();
6187
- if (s.stroke) {
6188
- rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, { ...opts, fill: 'none' });
6189
- }
6190
- };
6191
- img.src = iconUrl;
6192
- }
6193
- else {
6194
- rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, { ...opts, fill: '#e0e0e0', stroke: '#999999' });
6195
- }
6196
- return;
6197
- }
6198
- case 'image': {
6199
- if (n.imageUrl) {
6200
- // reserve bottom for label
6201
- const imgLblSpace = n.label ? 20 : 0;
6202
- const imgAreaH = n.h - imgLblSpace;
6203
- const img = new Image();
6204
- img.crossOrigin = 'anonymous';
6205
- img.onload = () => {
6206
- ctx.save();
6207
- ctx.beginPath();
6208
- const r = 6;
6209
- ctx.moveTo(n.x + r, n.y);
6210
- ctx.lineTo(n.x + n.w - r, n.y);
6211
- ctx.quadraticCurveTo(n.x + n.w, n.y, n.x + n.w, n.y + r);
6212
- ctx.lineTo(n.x + n.w, n.y + imgAreaH - r);
6213
- ctx.quadraticCurveTo(n.x + n.w, n.y + imgAreaH, n.x + n.w - r, n.y + imgAreaH);
6214
- ctx.lineTo(n.x + r, n.y + imgAreaH);
6215
- ctx.quadraticCurveTo(n.x, n.y + imgAreaH, n.x, n.y + imgAreaH - r);
6216
- ctx.lineTo(n.x, n.y + r);
6217
- ctx.quadraticCurveTo(n.x, n.y, n.x + r, n.y);
6218
- ctx.closePath();
6219
- ctx.clip();
6220
- ctx.drawImage(img, n.x + 1, n.y + 1, n.w - 2, imgAreaH - 2);
6221
- ctx.restore();
6222
- // only draw border when stroke is explicitly set
6223
- if (s.stroke) {
6224
- rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, { ...opts, fill: 'none' });
6225
- }
6226
- };
6227
- img.src = n.imageUrl;
6228
- }
6229
- else {
6230
- rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, { ...opts, fill: '#e0e0e0', stroke: '#999999' });
6231
- }
6232
- return;
6233
- }
6234
- default:
6235
- rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, opts);
6236
- break;
6247
+ const shape = getShape(n.shape);
6248
+ if (shape) {
6249
+ shape.renderCanvas(rc, ctx, n, palette, opts);
6250
+ return;
6237
6251
  }
6252
+ // fallback: box
6253
+ rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, opts);
6238
6254
  }
6239
6255
  // ── Arrowhead ─────────────────────────────────────────────────────────────
6240
6256
  function drawArrowHead(rc, x, y, angle, col, seed, R) {
6241
- const as = 12;
6257
+ const as = EDGE.arrowSize;
6242
6258
  rc.polygon([
6243
6259
  [x, y],
6244
6260
  [x - as * Math.cos(angle - Math.PI / 6.5), y - as * Math.sin(angle - Math.PI / 6.5)],
@@ -6281,18 +6297,17 @@ function renderToCanvas(sg, canvas, options = {}) {
6281
6297
  ctx.clearRect(0, 0, sg.width, sg.height);
6282
6298
  }
6283
6299
  const rc = rough.canvas(canvas);
6284
- const R = { roughness: options.roughness ?? 1.3, bowing: options.bowing ?? 0.7 };
6300
+ const R = { roughness: options.roughness ?? ROUGH.roughness, bowing: options.bowing ?? ROUGH.bowing };
6285
6301
  const nm = nodeMap(sg);
6286
6302
  const tm = tableMap(sg);
6287
6303
  const gm = groupMap(sg);
6288
6304
  const cm = chartMap(sg);
6289
- const ntm = noteMap(sg);
6290
6305
  // ── Title ────────────────────────────────────────────────
6291
6306
  if (sg.title) {
6292
- const titleSize = Number(sg.config['title-size'] ?? 18);
6293
- const titleWeight = Number(sg.config['title-weight'] ?? 600);
6307
+ const titleSize = Number(sg.config['title-size'] ?? TITLE.fontSize);
6308
+ const titleWeight = Number(sg.config['title-weight'] ?? TITLE.fontWeight);
6294
6309
  const titleColor = String(sg.config['title-color'] ?? palette.titleText);
6295
- drawText(ctx, sg.title, sg.width / 2, 28, titleSize, titleWeight, titleColor, 'center', diagramFont);
6310
+ drawText(ctx, sg.title, sg.width / 2, TITLE.y + 2, titleSize, titleWeight, titleColor, 'center', diagramFont);
6296
6311
  }
6297
6312
  // ── Groups (outermost first) ─────────────────────────────
6298
6313
  const sortedGroups = [...sg.groups].sort((a, b) => groupDepth(a, gm) - groupDepth(b, gm));
@@ -6303,7 +6318,7 @@ function renderToCanvas(sg, canvas, options = {}) {
6303
6318
  if (gs.opacity != null)
6304
6319
  ctx.globalAlpha = Number(gs.opacity);
6305
6320
  rc.rectangle(g.x, g.y, g.w, g.h, {
6306
- ...R, roughness: 1.7, bowing: 0.4, seed: hashStr$1(g.id),
6321
+ ...R, roughness: 1.7, bowing: 0.4, seed: hashStr$3(g.id),
6307
6322
  fill: String(gs.fill ?? palette.groupFill),
6308
6323
  fillStyle: 'solid',
6309
6324
  stroke: String(gs.stroke ?? palette.groupStroke),
@@ -6311,25 +6326,17 @@ function renderToCanvas(sg, canvas, options = {}) {
6311
6326
  strokeLineDash: gs.strokeDash ?? palette.groupDash,
6312
6327
  });
6313
6328
  if (g.label) {
6314
- const gFontSize = Number(gs.fontSize ?? 12);
6315
- const gFontWeight = gs.fontWeight ?? 500;
6316
- const gFont = resolveStyleFont(gs, diagramFont);
6317
- const gLetterSpacing = gs.letterSpacing;
6318
- const gLabelColor = gs.color ? String(gs.color) : palette.groupLabel;
6319
- const gPad = Number(gs.padding ?? 14);
6320
- const gTextAlign = String(gs.textAlign ?? 'left');
6321
- const gTextX = gTextAlign === 'right' ? g.x + g.w - gPad
6322
- : gTextAlign === 'center' ? g.x + g.w / 2
6323
- : g.x + gPad;
6324
- drawText(ctx, g.label, gTextX, g.y + gPad + 2, gFontSize, gFontWeight, gLabelColor, gTextAlign, gFont, gLetterSpacing);
6329
+ const gTypo = resolveTypography(gs, { fontSize: GROUP_LABEL.fontSize, fontWeight: GROUP_LABEL.fontWeight, textAlign: "left", padding: GROUP_LABEL.padding }, diagramFont, palette.groupLabel);
6330
+ const gTextX = computeTextX(gTypo, g.x, g.w);
6331
+ drawText(ctx, g.label, gTextX, g.y + gTypo.padding + 2, gTypo.fontSize, gTypo.fontWeight, gTypo.textColor, gTypo.textAlign, gTypo.font, gTypo.letterSpacing);
6325
6332
  }
6326
6333
  if (gs.opacity != null)
6327
6334
  ctx.globalAlpha = 1;
6328
6335
  }
6329
6336
  // ── Edges ─────────────────────────────────────────────────
6330
6337
  for (const e of sg.edges) {
6331
- const src = resolveEndpoint(e.from, nm, tm, gm, cm, ntm);
6332
- const dst = resolveEndpoint(e.to, nm, tm, gm, cm, ntm);
6338
+ const src = resolveEndpoint(e.from, nm, tm, gm, cm);
6339
+ const dst = resolveEndpoint(e.to, nm, tm, gm, cm);
6333
6340
  if (!src || !dst)
6334
6341
  continue;
6335
6342
  const dstCX = dst.x + dst.w / 2, dstCY = dst.y + dst.h / 2;
@@ -6342,31 +6349,31 @@ function renderToCanvas(sg, canvas, options = {}) {
6342
6349
  const { arrowAt, dashed } = connMeta(e.connector);
6343
6350
  const len = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) || 1;
6344
6351
  const nx = (x2 - x1) / len, ny = (y2 - y1) / len;
6345
- const HEAD = 13;
6352
+ const HEAD = EDGE.headInset;
6346
6353
  const sx1 = arrowAt === 'start' || arrowAt === 'both' ? x1 + nx * HEAD : x1;
6347
6354
  const sy1 = arrowAt === 'start' || arrowAt === 'both' ? y1 + ny * HEAD : y1;
6348
6355
  const sx2 = arrowAt === 'end' || arrowAt === 'both' ? x2 - nx * HEAD : x2;
6349
6356
  const sy2 = arrowAt === 'end' || arrowAt === 'both' ? y2 - ny * HEAD : y2;
6350
6357
  rc.line(sx1, sy1, sx2, sy2, {
6351
- ...R, roughness: 0.9, seed: hashStr$1(e.from + e.to),
6358
+ ...R, roughness: 0.9, seed: hashStr$3(e.from + e.to),
6352
6359
  stroke: ecol,
6353
6360
  strokeWidth: Number(e.style?.strokeWidth ?? 1.6),
6354
- ...(dashed ? { strokeLineDash: [6, 5] } : {}),
6361
+ ...(dashed ? { strokeLineDash: EDGE.dashPattern } : {}),
6355
6362
  });
6356
6363
  const ang = Math.atan2(y2 - y1, x2 - x1);
6357
6364
  if (arrowAt === 'end' || arrowAt === 'both')
6358
- drawArrowHead(rc, x2, y2, ang, ecol, hashStr$1(e.to));
6365
+ drawArrowHead(rc, x2, y2, ang, ecol, hashStr$3(e.to));
6359
6366
  if (arrowAt === 'start' || arrowAt === 'both')
6360
- drawArrowHead(rc, x1, y1, Math.atan2(y1 - y2, x1 - x2), ecol, hashStr$1(e.from + 'back'));
6367
+ drawArrowHead(rc, x1, y1, Math.atan2(y1 - y2, x1 - x2), ecol, hashStr$3(e.from + 'back'));
6361
6368
  if (e.label) {
6362
- const mx = (x1 + x2) / 2 - ny * 14;
6363
- const my = (y1 + y2) / 2 + nx * 14;
6369
+ const mx = (x1 + x2) / 2 - ny * EDGE.labelOffset;
6370
+ const my = (y1 + y2) / 2 + nx * EDGE.labelOffset;
6364
6371
  // ── Edge label: font, font-size, letter-spacing ──
6365
6372
  // always center-anchored (single line)
6366
- const eFontSize = Number(e.style?.fontSize ?? 11);
6373
+ const eFontSize = Number(e.style?.fontSize ?? EDGE.labelFontSize);
6367
6374
  const eFont = resolveStyleFont(e.style ?? {}, diagramFont);
6368
6375
  const eLetterSpacing = e.style?.letterSpacing;
6369
- const eFontWeight = e.style?.fontWeight ?? 400;
6376
+ const eFontWeight = e.style?.fontWeight ?? EDGE.labelFontWeight;
6370
6377
  const eLabelColor = String(e.style?.color ?? palette.edgeLabelText);
6371
6378
  ctx.save();
6372
6379
  ctx.font = `${eFontWeight} ${eFontSize}px ${eFont}`;
@@ -6382,47 +6389,60 @@ function renderToCanvas(sg, canvas, options = {}) {
6382
6389
  for (const n of sg.nodes) {
6383
6390
  if (n.style?.opacity != null)
6384
6391
  ctx.globalAlpha = Number(n.style.opacity);
6392
+ // ── Static transform (deg, dx, dy, factor) ──────────
6393
+ // All transforms anchor around the node's visual center.
6394
+ const hasTx = n.dx || n.dy || n.deg || (n.factor && n.factor !== 1);
6395
+ if (hasTx) {
6396
+ ctx.save();
6397
+ const cx = n.x + n.w / 2, cy = n.y + n.h / 2;
6398
+ // Move to center, apply rotate + scale there, move back
6399
+ ctx.translate(cx + (n.dx ?? 0), cy + (n.dy ?? 0));
6400
+ if (n.deg)
6401
+ ctx.rotate((n.deg * Math.PI) / 180);
6402
+ if (n.factor && n.factor !== 1)
6403
+ ctx.scale(n.factor, n.factor);
6404
+ ctx.translate(-cx, -cy);
6405
+ }
6385
6406
  renderShape(rc, ctx, n, palette, R);
6386
6407
  // ── Node / text typography ─────────────────────────
6387
- // supports: font, font-size, letter-spacing, text-align,
6388
- // vertical-align, line-height, word-wrap (text shape)
6389
- const fontSize = Number(n.style?.fontSize ?? (n.shape === 'text' ? 13 : 14));
6390
- const fontWeight = n.style?.fontWeight ?? (n.shape === 'text' ? 400 : 500);
6391
- const textColor = String(n.style?.color ??
6392
- (n.shape === 'text' ? palette.edgeLabelText : palette.nodeText));
6393
- const nodeFont = resolveStyleFont(n.style ?? {}, diagramFont);
6394
- const textAlign = String(n.style?.textAlign ?? 'center');
6395
- const lineHeight = Number(n.style?.lineHeight ?? 1.3) * fontSize;
6396
- const letterSpacing = n.style?.letterSpacing;
6397
- const vertAlign = String(n.style?.verticalAlign ?? 'middle');
6398
- const pad = Number(n.style?.padding ?? 8);
6399
- // x shifts for left/right alignment
6400
- const textX = textAlign === 'left' ? n.x + pad
6401
- : textAlign === 'right' ? n.x + n.w - pad
6402
- : n.x + n.w / 2;
6403
- // word-wrap for text shape; explicit \n for all others
6408
+ const isText = n.shape === 'text';
6409
+ const isNote = n.shape === 'note';
6410
+ const isMediaShape = n.shape === 'icon' || n.shape === 'image' || n.shape === 'line';
6411
+ const typo = resolveTypography(n.style, {
6412
+ fontSize: isText ? 13 : isNote ? 12 : 14,
6413
+ fontWeight: isText || isNote ? 400 : 500,
6414
+ textColor: isText ? palette.edgeLabelText : isNote ? palette.noteText : palette.nodeText,
6415
+ textAlign: isNote ? "left" : undefined,
6416
+ lineHeight: isNote ? 1.4 : undefined,
6417
+ padding: isNote ? 12 : undefined,
6418
+ verticalAlign: isNote ? "top" : undefined,
6419
+ }, diagramFont, palette.nodeText);
6420
+ // Note textX accounts for fold corner
6421
+ const FOLD = NOTE.fold;
6422
+ const textX = isNote
6423
+ ? (typo.textAlign === 'right' ? n.x + n.w - FOLD - typo.padding
6424
+ : typo.textAlign === 'center' ? n.x + (n.w - FOLD) / 2
6425
+ : n.x + typo.padding)
6426
+ : computeTextX(typo, n.x, n.w);
6404
6427
  const rawLines = n.label.split('\n');
6405
6428
  const lines = n.shape === 'text' && rawLines.length === 1
6406
- ? wrapText(n.label, n.w - pad * 2, fontSize)
6429
+ ? wrapText(n.label, n.w - typo.padding * 2, typo.fontSize)
6407
6430
  : rawLines;
6408
- // vertical-align: compute textCY from top/middle/bottom
6409
- const nodeBodyTop = n.y + pad;
6410
- const nodeBodyBottom = n.y + n.h - pad;
6411
- const blockH = (lines.length - 1) * lineHeight;
6412
- const isMediaShape = n.shape === 'icon' || n.shape === 'image';
6413
6431
  const textCY = isMediaShape
6414
- ? n.y + n.h - 10 // label below the icon/image
6415
- : vertAlign === 'top' ? nodeBodyTop + blockH / 2
6416
- : vertAlign === 'bottom' ? nodeBodyBottom - blockH / 2
6417
- : n.y + n.h / 2; // middle (default)
6432
+ ? n.y + n.h - 10
6433
+ : isNote
6434
+ ? computeTextCY(typo, n.y, n.h, lines.length, FOLD + typo.padding)
6435
+ : computeTextCY(typo, n.y, n.h, lines.length);
6418
6436
  if (n.label) {
6419
6437
  if (lines.length > 1) {
6420
- drawMultilineText(ctx, lines, textX, textCY, fontSize, fontWeight, textColor, textAlign, lineHeight, nodeFont, letterSpacing);
6438
+ drawMultilineText(ctx, lines, textX, textCY, typo.fontSize, typo.fontWeight, typo.textColor, typo.textAlign, typo.lineHeight, typo.font, typo.letterSpacing);
6421
6439
  }
6422
6440
  else {
6423
- drawText(ctx, lines[0] ?? '', textX, textCY, fontSize, fontWeight, textColor, textAlign, nodeFont, letterSpacing);
6441
+ drawText(ctx, lines[0] ?? '', textX, textCY, typo.fontSize, typo.fontWeight, typo.textColor, typo.textAlign, typo.font, typo.letterSpacing);
6424
6442
  }
6425
6443
  }
6444
+ if (hasTx)
6445
+ ctx.restore();
6426
6446
  if (n.style?.opacity != null)
6427
6447
  ctx.globalAlpha = 1;
6428
6448
  }
@@ -6442,12 +6462,12 @@ function renderToCanvas(sg, canvas, options = {}) {
6442
6462
  if (gs.opacity != null)
6443
6463
  ctx.globalAlpha = Number(gs.opacity);
6444
6464
  rc.rectangle(t.x, t.y, t.w, t.h, {
6445
- ...R, seed: hashStr$1(t.id),
6465
+ ...R, seed: hashStr$3(t.id),
6446
6466
  fill, fillStyle: 'solid', stroke: strk, strokeWidth: tStrokeWidth,
6447
6467
  ...(gs.strokeDash ? { strokeLineDash: gs.strokeDash } : {}),
6448
6468
  });
6449
6469
  rc.line(t.x, t.y + pad, t.x + t.w, t.y + pad, {
6450
- roughness: 0.6, seed: hashStr$1(t.id + 'l'), stroke: strk, strokeWidth: 1,
6470
+ roughness: 0.6, seed: hashStr$3(t.id + 'l'), stroke: strk, strokeWidth: 1,
6451
6471
  });
6452
6472
  // ── Table label: always left-anchored ───────────────
6453
6473
  drawText(ctx, t.label, t.x + 10, t.y + pad / 2, tFontSize, tFontWeight, textCol, 'left', tFont, tLetterSpacing);
@@ -6459,7 +6479,7 @@ function renderToCanvas(sg, canvas, options = {}) {
6459
6479
  ctx.fillRect(t.x + 1, rowY + 1, t.w - 2, rh - 1);
6460
6480
  }
6461
6481
  rc.line(t.x, rowY + rh, t.x + t.w, rowY + rh, {
6462
- roughness: 0.4, seed: hashStr$1(t.id + rowY),
6482
+ roughness: 0.4, seed: hashStr$3(t.id + rowY),
6463
6483
  stroke: row.kind === 'header' ? strk : palette.tableDivider,
6464
6484
  strokeWidth: row.kind === 'header' ? 1.2 : 0.6,
6465
6485
  });
@@ -6481,7 +6501,7 @@ function renderToCanvas(sg, canvas, options = {}) {
6481
6501
  drawText(ctx, cell, cellX, rowY + rh / 2, tFontSize, cellFw, cellColor, cellAlignProp, tFont, tLetterSpacing);
6482
6502
  if (i < row.cells.length - 1) {
6483
6503
  rc.line(cx + cw, t.y + pad, cx + cw, t.y + t.h, {
6484
- roughness: 0.3, seed: hashStr$1(t.id + 'c' + i),
6504
+ roughness: 0.3, seed: hashStr$3(t.id + 'c' + i),
6485
6505
  stroke: palette.tableDivider, strokeWidth: 0.5,
6486
6506
  });
6487
6507
  }
@@ -6491,62 +6511,7 @@ function renderToCanvas(sg, canvas, options = {}) {
6491
6511
  }
6492
6512
  ctx.globalAlpha = 1;
6493
6513
  }
6494
- // ── Notes ─────────────────────────────────────────────────
6495
- for (const n of sg.notes) {
6496
- const gs = n.style ?? {};
6497
- const fill = String(gs.fill ?? palette.noteFill);
6498
- const strk = String(gs.stroke ?? palette.noteStroke);
6499
- const nStrokeWidth = Number(gs.strokeWidth ?? 1.2);
6500
- const fold = 14;
6501
- const { x, y, w, h } = n;
6502
- if (gs.opacity != null)
6503
- ctx.globalAlpha = Number(gs.opacity);
6504
- rc.polygon([
6505
- [x, y],
6506
- [x + w - fold, y],
6507
- [x + w, y + fold],
6508
- [x + w, y + h],
6509
- [x, y + h],
6510
- ], { ...R, seed: hashStr$1(n.id), fill, fillStyle: 'solid', stroke: strk,
6511
- strokeWidth: nStrokeWidth,
6512
- ...(gs.strokeDash ? { strokeLineDash: gs.strokeDash } : {}),
6513
- });
6514
- rc.polygon([
6515
- [x + w - fold, y],
6516
- [x + w, y + fold],
6517
- [x + w - fold, y + fold],
6518
- ], { roughness: 0.4, seed: hashStr$1(n.id + 'f'),
6519
- fill: palette.noteFold, fillStyle: 'solid', stroke: strk,
6520
- strokeWidth: Math.min(nStrokeWidth, 0.8),
6521
- });
6522
- const nFontSize = Number(gs.fontSize ?? 12);
6523
- const nFontWeight = gs.fontWeight ?? 400;
6524
- const nFont = resolveStyleFont(gs, diagramFont);
6525
- const nLetterSpacing = gs.letterSpacing;
6526
- const nLineHeight = Number(gs.lineHeight ?? 1.4) * nFontSize;
6527
- const nTextAlign = String(gs.textAlign ?? 'left');
6528
- const nVertAlign = String(gs.verticalAlign ?? 'top');
6529
- const nColor = String(gs.color ?? palette.noteText);
6530
- const nPad = Number(gs.padding ?? 12);
6531
- const nTextX = nTextAlign === 'right' ? x + w - fold - nPad
6532
- : nTextAlign === 'center' ? x + (w - fold) / 2
6533
- : x + nPad;
6534
- const nFoldPad = fold + nPad;
6535
- const bodyTop = y + nFoldPad;
6536
- const bodyBottom = y + h - nPad;
6537
- const blockH = (n.lines.length - 1) * nLineHeight;
6538
- const blockCY = nVertAlign === 'bottom' ? bodyBottom - blockH / 2
6539
- : nVertAlign === 'middle' ? (bodyTop + bodyBottom) / 2
6540
- : bodyTop + blockH / 2;
6541
- if (n.lines.length > 1) {
6542
- drawMultilineText(ctx, n.lines, nTextX, blockCY, nFontSize, nFontWeight, nColor, nTextAlign, nLineHeight, nFont, nLetterSpacing);
6543
- }
6544
- else {
6545
- drawText(ctx, n.lines[0] ?? '', nTextX, blockCY, nFontSize, nFontWeight, nColor, nTextAlign, nFont, nLetterSpacing);
6546
- }
6547
- if (gs.opacity != null)
6548
- ctx.globalAlpha = 1;
6549
- }
6514
+ // ── Notes are now rendered as nodes via the shape registry ──
6550
6515
  // ── Markdown blocks ────────────────────────────────────────
6551
6516
  // Renders prose with Markdown headings and bold/italic inline spans.
6552
6517
  // Canvas has no native bold-within-a-run, so each run is drawn
@@ -6563,7 +6528,7 @@ function renderToCanvas(sg, canvas, options = {}) {
6563
6528
  // Background + border
6564
6529
  if (gs.fill || gs.stroke) {
6565
6530
  rc.rectangle(m.x, m.y, m.w, m.h, {
6566
- ...R, seed: hashStr$1(m.id),
6531
+ ...R, seed: hashStr$3(m.id),
6567
6532
  fill: String(gs.fill ?? 'none'), fillStyle: 'solid',
6568
6533
  stroke: String(gs.stroke ?? 'none'),
6569
6534
  strokeWidth: Number(gs.strokeWidth ?? 1.2),
@@ -6705,6 +6670,209 @@ function clearDashOverridesAfter(el, delayMs) {
6705
6670
  });
6706
6671
  }, delayMs);
6707
6672
  }
6673
+ const NODE_DRAW_GUIDE_ATTR = "data-node-draw-guide";
6674
+ const GUIDED_NODE_SHAPES = new Set([
6675
+ "box",
6676
+ "circle",
6677
+ "diamond",
6678
+ "hexagon",
6679
+ "triangle",
6680
+ "parallelogram",
6681
+ "line",
6682
+ "path",
6683
+ ]);
6684
+ function polygonPath(points) {
6685
+ return points.map(([x, y], i) => `${i === 0 ? "M" : "L"} ${x} ${y}`).join(" ") + " Z";
6686
+ }
6687
+ function rectPath(x, y, w, h) {
6688
+ return polygonPath([
6689
+ [x, y],
6690
+ [x + w, y],
6691
+ [x + w, y + h],
6692
+ [x, y + h],
6693
+ ]);
6694
+ }
6695
+ function ellipsePath(cx, cy, rx, ry) {
6696
+ return [
6697
+ `M ${cx - rx} ${cy}`,
6698
+ `A ${rx} ${ry} 0 1 0 ${cx + rx} ${cy}`,
6699
+ `A ${rx} ${ry} 0 1 0 ${cx - rx} ${cy}`,
6700
+ ].join(" ");
6701
+ }
6702
+ function nodeMetric(el, key) {
6703
+ const raw = el.dataset[key];
6704
+ const n = raw == null ? Number.NaN : Number(raw);
6705
+ return Number.isFinite(n) ? n : null;
6706
+ }
6707
+ function buildNodeGuidePath(el) {
6708
+ const shape = el.dataset.nodeShape;
6709
+ if (!shape || !GUIDED_NODE_SHAPES.has(shape))
6710
+ return null;
6711
+ const x = nodeMetric(el, "x");
6712
+ const y = nodeMetric(el, "y");
6713
+ const w = nodeMetric(el, "w");
6714
+ const h = nodeMetric(el, "h");
6715
+ if (x == null || y == null || w == null || h == null)
6716
+ return null;
6717
+ switch (shape) {
6718
+ case "box":
6719
+ return rectPath(x + 1, y + 1, w - 2, h - 2);
6720
+ case "circle":
6721
+ return ellipsePath(x + w / 2, y + h / 2, (w * 0.88) / 2, (h * 0.88) / 2);
6722
+ case "diamond": {
6723
+ const cx = x + w / 2;
6724
+ const cy = y + h / 2;
6725
+ const hw = w / 2 - 2;
6726
+ return polygonPath([
6727
+ [cx, y + 2],
6728
+ [cx + hw, cy],
6729
+ [cx, y + h - 2],
6730
+ [cx - hw, cy],
6731
+ ]);
6732
+ }
6733
+ case "hexagon": {
6734
+ const cx = x + w / 2;
6735
+ const cy = y + h / 2;
6736
+ const hw = w / 2 - 2;
6737
+ const hw2 = hw * SHAPES.hexagon.inset;
6738
+ return polygonPath([
6739
+ [cx - hw2, y + 3],
6740
+ [cx + hw2, y + 3],
6741
+ [cx + hw, cy],
6742
+ [cx + hw2, y + h - 3],
6743
+ [cx - hw2, y + h - 3],
6744
+ [cx - hw, cy],
6745
+ ]);
6746
+ }
6747
+ case "triangle": {
6748
+ const cx = x + w / 2;
6749
+ return polygonPath([
6750
+ [cx, y + 3],
6751
+ [x + w - 3, y + h - 3],
6752
+ [x + 3, y + h - 3],
6753
+ ]);
6754
+ }
6755
+ case "parallelogram":
6756
+ return polygonPath([
6757
+ [x + SHAPES.parallelogram.skew, y + 1],
6758
+ [x + w - 1, y + 1],
6759
+ [x + w - SHAPES.parallelogram.skew, y + h - 1],
6760
+ [x + 1, y + h - 1],
6761
+ ]);
6762
+ case "line": {
6763
+ const labelH = el.querySelector("text") ? 20 : 0;
6764
+ const lineY = y + (h - labelH) / 2;
6765
+ return `M ${x} ${lineY} L ${x + w} ${lineY}`;
6766
+ }
6767
+ case "path":
6768
+ return el.dataset.pathData ?? null;
6769
+ default:
6770
+ return null;
6771
+ }
6772
+ }
6773
+ function nodeGuidePathEl(el) {
6774
+ return el.querySelector(`path[${NODE_DRAW_GUIDE_ATTR}="true"]`);
6775
+ }
6776
+ function removeNodeGuide(el) {
6777
+ nodeGuidePathEl(el)?.remove();
6778
+ }
6779
+ function nodePaths(el) {
6780
+ return Array.from(el.querySelectorAll("path")).filter((p) => p.getAttribute(NODE_DRAW_GUIDE_ATTR) !== "true");
6781
+ }
6782
+ function nodeText(el) {
6783
+ return el.querySelector("text");
6784
+ }
6785
+ function nodeStrokeTemplate(el) {
6786
+ return (nodePaths(el).find((p) => (p.getAttribute("stroke") ?? "") !== "none") ??
6787
+ nodePaths(el)[0] ??
6788
+ null);
6789
+ }
6790
+ function clearNodeDrawStyles(el) {
6791
+ removeNodeGuide(el);
6792
+ nodePaths(el).forEach((p) => {
6793
+ p.style.strokeDasharray =
6794
+ p.style.strokeDashoffset =
6795
+ p.style.fillOpacity =
6796
+ p.style.transition =
6797
+ p.style.opacity =
6798
+ "";
6799
+ });
6800
+ const text = nodeText(el);
6801
+ if (text) {
6802
+ text.style.opacity = text.style.transition = "";
6803
+ }
6804
+ }
6805
+ function prepareNodeForDraw(el) {
6806
+ clearNodeDrawStyles(el);
6807
+ const d = buildNodeGuidePath(el);
6808
+ const source = nodeStrokeTemplate(el);
6809
+ if (!d || !source) {
6810
+ prepareForDraw(el);
6811
+ return;
6812
+ }
6813
+ const guide = document.createElementNS(SVG_NS$1, "path");
6814
+ guide.setAttribute("d", d);
6815
+ guide.setAttribute("fill", "none");
6816
+ guide.setAttribute("stroke", source.getAttribute("stroke") ?? "#000");
6817
+ guide.setAttribute("stroke-width", source.getAttribute("stroke-width") ?? "1.8");
6818
+ guide.setAttribute("stroke-linecap", "round");
6819
+ guide.setAttribute("stroke-linejoin", "round");
6820
+ guide.setAttribute(NODE_DRAW_GUIDE_ATTR, "true");
6821
+ if (el.dataset.nodeShape === "path") {
6822
+ const pathX = nodeMetric(el, "x") ?? 0;
6823
+ const pathY = nodeMetric(el, "y") ?? 0;
6824
+ guide.setAttribute("transform", `translate(${pathX},${pathY})`);
6825
+ }
6826
+ guide.style.pointerEvents = "none";
6827
+ const len = pathLength(guide);
6828
+ guide.style.strokeDasharray = `${len}`;
6829
+ guide.style.strokeDashoffset = `${len}`;
6830
+ guide.style.transition = "none";
6831
+ nodePaths(el).forEach((p) => {
6832
+ p.style.opacity = "0";
6833
+ p.style.transition = "none";
6834
+ });
6835
+ const text = nodeText(el);
6836
+ if (text) {
6837
+ text.style.opacity = "0";
6838
+ text.style.transition = "none";
6839
+ }
6840
+ el.appendChild(guide);
6841
+ }
6842
+ function revealNodeInstant(el) {
6843
+ clearNodeDrawStyles(el);
6844
+ }
6845
+ function animateNodeDraw(el, strokeDur = ANIMATION.nodeStrokeDur) {
6846
+ const guide = nodeGuidePathEl(el);
6847
+ if (!guide) {
6848
+ const firstPath = el.querySelector("path");
6849
+ if (!firstPath?.style.strokeDasharray)
6850
+ prepareForDraw(el);
6851
+ animateShapeDraw(el, strokeDur, ANIMATION.nodeStagger);
6852
+ const nodePathCount = el.querySelectorAll("path").length;
6853
+ clearDashOverridesAfter(el, nodePathCount * ANIMATION.nodeStagger + strokeDur + 120);
6854
+ return;
6855
+ }
6856
+ const roughPaths = nodePaths(el);
6857
+ const text = nodeText(el);
6858
+ const revealDelay = strokeDur + 30;
6859
+ const textDelay = revealDelay + ANIMATION.textDelay;
6860
+ requestAnimationFrame(() => requestAnimationFrame(() => {
6861
+ guide.style.transition = `stroke-dashoffset ${strokeDur}ms cubic-bezier(.4,0,.2,1)`;
6862
+ guide.style.strokeDashoffset = "0";
6863
+ roughPaths.forEach((p) => {
6864
+ p.style.transition = `opacity 140ms ease ${revealDelay}ms`;
6865
+ p.style.opacity = "1";
6866
+ });
6867
+ if (text) {
6868
+ text.style.transition = `opacity ${ANIMATION.textFade}ms ease ${textDelay}ms`;
6869
+ text.style.opacity = "1";
6870
+ }
6871
+ setTimeout(() => {
6872
+ clearNodeDrawStyles(el);
6873
+ }, textDelay + ANIMATION.textFade + 40);
6874
+ }));
6875
+ }
6708
6876
  // ── Arrow connector parser ────────────────────────────────
6709
6877
  const ARROW_CONNECTORS = ["<-->", "<->", "-->", "<--", "->", "<-", "---", "--"];
6710
6878
  function parseEdgeTarget(target) {
@@ -6755,19 +6923,6 @@ function prepareForDraw(el) {
6755
6923
  text.style.transition = "none";
6756
6924
  }
6757
6925
  }
6758
- function revealInstant(el) {
6759
- el.querySelectorAll("path").forEach((p) => {
6760
- p.style.transition = "none";
6761
- p.style.strokeDashoffset = "0";
6762
- p.style.fillOpacity = "";
6763
- p.style.strokeDasharray = "";
6764
- });
6765
- const text = el.querySelector("text");
6766
- if (text) {
6767
- text.style.transition = "none";
6768
- text.style.opacity = "";
6769
- }
6770
- }
6771
6926
  function clearDrawStyles(el) {
6772
6927
  el.querySelectorAll("path").forEach((p) => {
6773
6928
  p.style.strokeDasharray =
@@ -6781,12 +6936,12 @@ function clearDrawStyles(el) {
6781
6936
  text.style.opacity = text.style.transition = "";
6782
6937
  }
6783
6938
  }
6784
- function animateShapeDraw(el, strokeDur = 420, stag = 55) {
6939
+ function animateShapeDraw(el, strokeDur = ANIMATION.nodeStrokeDur, stag = ANIMATION.nodeStagger) {
6785
6940
  const paths = Array.from(el.querySelectorAll("path"));
6786
6941
  const text = el.querySelector("text");
6787
6942
  requestAnimationFrame(() => requestAnimationFrame(() => {
6788
6943
  paths.forEach((p, i) => {
6789
- const sd = i * stag, fd = sd + strokeDur - 60;
6944
+ const sd = i * stag, fd = sd + strokeDur + ANIMATION.fillFadeOffset;
6790
6945
  p.style.transition = [
6791
6946
  `stroke-dashoffset ${strokeDur}ms cubic-bezier(.4,0,.2,1) ${sd}ms`,
6792
6947
  `fill-opacity 180ms ease ${Math.max(0, fd)}ms`,
@@ -6795,57 +6950,87 @@ function animateShapeDraw(el, strokeDur = 420, stag = 55) {
6795
6950
  p.style.fillOpacity = "1";
6796
6951
  });
6797
6952
  if (text) {
6798
- const td = paths.length * stag + strokeDur + 80;
6799
- text.style.transition = `opacity 200ms ease ${td}ms`;
6953
+ const td = paths.length * stag + strokeDur + ANIMATION.textDelay;
6954
+ text.style.transition = `opacity ${ANIMATION.textFade}ms ease ${td}ms`;
6800
6955
  text.style.opacity = "1";
6801
6956
  }
6802
6957
  }));
6803
6958
  }
6804
6959
  // ── Edge draw helpers ─────────────────────────────────────
6960
+ const EDGE_SHAFT_SELECTOR = '[data-edge-role="shaft"] path';
6961
+ const EDGE_DECOR_SELECTOR = '[data-edge-role="head"], [data-edge-role="label"], [data-edge-role="label-bg"]';
6962
+ function edgeShaftPaths(el) {
6963
+ return Array.from(el.querySelectorAll(EDGE_SHAFT_SELECTOR));
6964
+ }
6965
+ function edgeDecorEls(el) {
6966
+ return Array.from(el.querySelectorAll(EDGE_DECOR_SELECTOR));
6967
+ }
6968
+ function prepareEdgeForDraw(el) {
6969
+ edgeShaftPaths(el).forEach((p) => {
6970
+ const len = pathLength(p);
6971
+ p.style.strokeDasharray = `${len}`;
6972
+ p.style.strokeDashoffset = `${len}`;
6973
+ p.style.transition = "none";
6974
+ });
6975
+ edgeDecorEls(el).forEach((part) => {
6976
+ part.style.opacity = "0";
6977
+ part.style.transition = "none";
6978
+ });
6979
+ }
6980
+ function revealEdgeInstant(el) {
6981
+ edgeShaftPaths(el).forEach((p) => {
6982
+ p.style.transition = "none";
6983
+ p.style.strokeDashoffset = "0";
6984
+ p.style.strokeDasharray = "";
6985
+ });
6986
+ edgeDecorEls(el).forEach((part) => {
6987
+ part.style.transition = "none";
6988
+ part.style.opacity = "1";
6989
+ });
6990
+ }
6805
6991
  function clearEdgeDrawStyles(el) {
6806
- el.querySelectorAll("path").forEach((p) => {
6992
+ edgeShaftPaths(el).forEach((p) => {
6807
6993
  p.style.strokeDasharray =
6808
6994
  p.style.strokeDashoffset =
6809
- p.style.opacity =
6810
- p.style.transition =
6811
- "";
6995
+ p.style.transition =
6996
+ "";
6997
+ });
6998
+ edgeDecorEls(el).forEach((part) => {
6999
+ part.style.opacity = part.style.transition = "";
6812
7000
  });
6813
7001
  }
6814
- function animateEdgeDraw(el, conn) {
6815
- const paths = Array.from(el.querySelectorAll('path'));
6816
- if (!paths.length)
7002
+ function animateEdgeDraw(el, conn, strokeDur = ANIMATION.strokeDur) {
7003
+ const shaftPaths = edgeShaftPaths(el);
7004
+ const decorEls = edgeDecorEls(el);
7005
+ if (!shaftPaths.length)
6817
7006
  return;
6818
- const linePath = paths[0];
6819
- const headPaths = paths.slice(1);
6820
- const STROKE_DUR = 360;
6821
- const len = pathLength(linePath);
6822
7007
  const reversed = conn.startsWith('<') && !conn.includes('>');
6823
- linePath.style.strokeDasharray = `${len}`;
6824
- linePath.style.strokeDashoffset = reversed ? `${-len}` : `${len}`;
6825
- linePath.style.transition = 'none';
6826
- headPaths.forEach(p => {
6827
- p.style.opacity = '0';
6828
- p.style.transition = 'none';
7008
+ shaftPaths.forEach((p) => {
7009
+ const len = pathLength(p);
7010
+ p.style.strokeDasharray = `${len}`;
7011
+ p.style.strokeDashoffset = reversed ? `${-len}` : `${len}`;
7012
+ p.style.transition = "none";
7013
+ });
7014
+ decorEls.forEach((part) => {
7015
+ part.style.opacity = "0";
7016
+ part.style.transition = "none";
6829
7017
  });
6830
- el.classList.remove('draw-hidden');
6831
- el.classList.add('draw-reveal');
6832
- el.style.opacity = '1';
6833
7018
  requestAnimationFrame(() => requestAnimationFrame(() => {
6834
- linePath.style.transition = `stroke-dashoffset ${STROKE_DUR}ms cubic-bezier(.4,0,.2,1)`;
6835
- linePath.style.strokeDashoffset = '0';
7019
+ shaftPaths.forEach((p) => {
7020
+ p.style.transition = `stroke-dashoffset ${strokeDur}ms cubic-bezier(.4,0,.2,1)`;
7021
+ p.style.strokeDashoffset = "0";
7022
+ });
6836
7023
  setTimeout(() => {
6837
- headPaths.forEach(p => {
6838
- p.style.transition = 'opacity 120ms ease';
6839
- p.style.opacity = '1';
7024
+ decorEls.forEach((part) => {
7025
+ part.style.transition = `opacity ${ANIMATION.arrowReveal}ms ease`;
7026
+ part.style.opacity = "1";
6840
7027
  });
6841
7028
  // ── ADD: clear inline dash overrides so SVG attribute
6842
7029
  // (stroke-dasharray="6,5" for dashed arrows) takes over again
6843
7030
  setTimeout(() => {
6844
- linePath.style.strokeDasharray = '';
6845
- linePath.style.strokeDashoffset = '';
6846
- linePath.style.transition = '';
6847
- }, 160);
6848
- }, STROKE_DUR - 40);
7031
+ clearEdgeDrawStyles(el);
7032
+ }, ANIMATION.dashClear);
7033
+ }, Math.max(0, strokeDur - 40));
6849
7034
  }));
6850
7035
  }
6851
7036
  // ── AnimationController ───────────────────────────────────
@@ -6857,6 +7042,7 @@ class AnimationController {
6857
7042
  this.svg = svg;
6858
7043
  this.steps = steps;
6859
7044
  this._step = -1;
7045
+ this._pendingStepTimers = new Set();
6860
7046
  this._transforms = new Map();
6861
7047
  this._listeners = [];
6862
7048
  this.drawTargetEdges = getDrawTargetEdgeIds(steps);
@@ -6955,8 +7141,9 @@ class AnimationController {
6955
7141
  async play(msPerStep = 900) {
6956
7142
  this.emit("animation-start");
6957
7143
  while (this.canNext) {
7144
+ const nextStep = this.steps[this._step + 1];
6958
7145
  this.next();
6959
- await new Promise((r) => setTimeout(r, msPerStep));
7146
+ await new Promise((r) => setTimeout(r, this._playbackWaitMs(nextStep, msPerStep)));
6960
7147
  }
6961
7148
  }
6962
7149
  goTo(index) {
@@ -6973,20 +7160,43 @@ class AnimationController {
6973
7160
  }
6974
7161
  this.emit("step-change");
6975
7162
  }
7163
+ _clearPendingStepTimers() {
7164
+ this._pendingStepTimers.forEach((id) => window.clearTimeout(id));
7165
+ this._pendingStepTimers.clear();
7166
+ }
7167
+ _scheduleStep(fn, delayMs) {
7168
+ if (delayMs <= 0) {
7169
+ fn();
7170
+ return;
7171
+ }
7172
+ const id = window.setTimeout(() => {
7173
+ this._pendingStepTimers.delete(id);
7174
+ fn();
7175
+ }, delayMs);
7176
+ this._pendingStepTimers.add(id);
7177
+ }
7178
+ _playbackWaitMs(step, fallbackMs) {
7179
+ if (!step)
7180
+ return fallbackMs;
7181
+ const delay = Math.max(0, step.delay ?? 0);
7182
+ const duration = Math.max(0, step.duration ?? 0);
7183
+ return delay + Math.max(fallbackMs, duration);
7184
+ }
6976
7185
  _clearAll() {
7186
+ this._clearPendingStepTimers();
6977
7187
  this._transforms.clear();
6978
7188
  // Nodes
6979
7189
  this.svg.querySelectorAll(".ng").forEach((el) => {
6980
- el.style.transform = "";
7190
+ el.style.transform = el.dataset.baseTransform ?? "";
6981
7191
  el.style.transition = "";
6982
7192
  el.classList.remove("hl", "faded", "hidden");
6983
7193
  el.style.opacity = el.style.filter = "";
6984
7194
  if (this.drawTargetNodes.has(el.id)) {
6985
- clearDrawStyles(el);
6986
- prepareForDraw(el);
7195
+ prepareNodeForDraw(el);
7196
+ }
7197
+ else {
7198
+ clearNodeDrawStyles(el);
6987
7199
  }
6988
- else
6989
- clearDrawStyles(el);
6990
7200
  });
6991
7201
  // Groups — hide draw-target groups, show the rest
6992
7202
  this.svg.querySelectorAll(".gg").forEach((el) => {
@@ -7006,16 +7216,13 @@ class AnimationController {
7006
7216
  });
7007
7217
  // Edges
7008
7218
  this.svg.querySelectorAll(".eg").forEach((el) => {
7009
- el.classList.remove("draw-reveal");
7010
7219
  clearEdgeDrawStyles(el);
7011
7220
  el.style.transition = "none";
7221
+ el.style.opacity = "";
7012
7222
  if (this.drawTargetEdges.has(el.id)) {
7013
- el.style.opacity = "";
7014
- el.classList.add("draw-hidden");
7223
+ prepareEdgeForDraw(el);
7015
7224
  }
7016
7225
  else {
7017
- el.style.opacity = "";
7018
- el.classList.remove("draw-hidden");
7019
7226
  requestAnimationFrame(() => {
7020
7227
  el.style.transition = "";
7021
7228
  });
@@ -7092,6 +7299,14 @@ class AnimationController {
7092
7299
  const s = this.steps[i];
7093
7300
  if (!s)
7094
7301
  return;
7302
+ const run = () => this._runStep(s, silent);
7303
+ if (silent) {
7304
+ run();
7305
+ return;
7306
+ }
7307
+ this._scheduleStep(run, Math.max(0, s.delay ?? 0));
7308
+ }
7309
+ _runStep(s, silent) {
7095
7310
  switch (s.action) {
7096
7311
  case "highlight":
7097
7312
  this._doHighlight(s.target);
@@ -7103,20 +7318,20 @@ class AnimationController {
7103
7318
  this._doFade(s.target, false);
7104
7319
  break;
7105
7320
  case "draw":
7106
- this._doDraw(s.target, silent);
7321
+ this._doDraw(s, silent);
7107
7322
  break;
7108
7323
  case "erase":
7109
- this._doErase(s.target);
7324
+ this._doErase(s.target, s.duration);
7110
7325
  break;
7111
7326
  case "show":
7112
- this._doShowHide(s.target, true, silent);
7327
+ this._doShowHide(s.target, true, silent, s.duration);
7113
7328
  break;
7114
7329
  case "hide":
7115
- this._doShowHide(s.target, false, silent);
7330
+ this._doShowHide(s.target, false, silent, s.duration);
7116
7331
  break;
7117
7332
  case "pulse":
7118
7333
  if (!silent)
7119
- this._doPulse(s.target);
7334
+ this._doPulse(s.target, s.duration);
7120
7335
  break;
7121
7336
  case "color":
7122
7337
  this._doColor(s.target, s.value);
@@ -7160,7 +7375,9 @@ class AnimationController {
7160
7375
  el.style.transition = silent
7161
7376
  ? "none"
7162
7377
  : `transform ${duration}ms cubic-bezier(.4,0,.2,1)`;
7163
- el.style.transform = parts.join(" ") || "";
7378
+ const base = el.dataset.baseTransform ?? "";
7379
+ const anim = parts.join(" ");
7380
+ el.style.transform = anim ? `${anim} ${base}`.trim() : base;
7164
7381
  if (silent) {
7165
7382
  requestAnimationFrame(() => requestAnimationFrame(() => {
7166
7383
  el.style.transition = "";
@@ -7216,7 +7433,8 @@ class AnimationController {
7216
7433
  });
7217
7434
  this._writeTransform(el, target, silent, step.duration ?? 400);
7218
7435
  }
7219
- _doDraw(target, silent) {
7436
+ _doDraw(step, silent) {
7437
+ const { target } = step;
7220
7438
  const edge = parseEdgeTarget(target);
7221
7439
  if (edge) {
7222
7440
  // ── Edge draw ──────────────────────────────────────
@@ -7224,17 +7442,13 @@ class AnimationController {
7224
7442
  if (!el)
7225
7443
  return;
7226
7444
  if (silent) {
7227
- clearEdgeDrawStyles(el);
7228
- el.style.transition = "none";
7229
- el.classList.remove("draw-hidden");
7230
- el.classList.add("draw-reveal");
7231
- el.style.opacity = "1";
7445
+ revealEdgeInstant(el);
7232
7446
  requestAnimationFrame(() => requestAnimationFrame(() => {
7233
- el.style.transition = "";
7447
+ clearEdgeDrawStyles(el);
7234
7448
  }));
7235
7449
  }
7236
7450
  else {
7237
- animateEdgeDraw(el, edge.conn);
7451
+ animateEdgeDraw(el, edge.conn, step.duration ?? ANIMATION.strokeDur);
7238
7452
  }
7239
7453
  return;
7240
7454
  }
@@ -7258,9 +7472,10 @@ class AnimationController {
7258
7472
  const firstPath = groupEl.querySelector("path");
7259
7473
  if (!firstPath?.style.strokeDasharray)
7260
7474
  prepareForDraw(groupEl);
7261
- animateShapeDraw(groupEl, 550, 40);
7475
+ const groupStrokeDur = step.duration ?? ANIMATION.groupStrokeDur;
7476
+ animateShapeDraw(groupEl, groupStrokeDur, ANIMATION.groupStagger);
7262
7477
  const pathCount = groupEl.querySelectorAll('path').length;
7263
- const totalMs = pathCount * 40 + 550 + 120; // stagger + duration + buffer
7478
+ const totalMs = pathCount * ANIMATION.groupStagger + groupStrokeDur + 120;
7264
7479
  clearDashOverridesAfter(groupEl, totalMs);
7265
7480
  }
7266
7481
  return;
@@ -7281,9 +7496,10 @@ class AnimationController {
7281
7496
  else {
7282
7497
  tableEl.classList.remove("gg-hidden");
7283
7498
  prepareForDraw(tableEl);
7284
- animateShapeDraw(tableEl, 500, 40);
7499
+ const tableStrokeDur = step.duration ?? ANIMATION.tableStrokeDur;
7500
+ animateShapeDraw(tableEl, tableStrokeDur, ANIMATION.tableStagger);
7285
7501
  const tablePathCount = tableEl.querySelectorAll('path').length;
7286
- clearDashOverridesAfter(tableEl, tablePathCount * 40 + 500 + 120);
7502
+ clearDashOverridesAfter(tableEl, tablePathCount * ANIMATION.tableStagger + tableStrokeDur + 120);
7287
7503
  }
7288
7504
  return;
7289
7505
  }
@@ -7303,9 +7519,10 @@ class AnimationController {
7303
7519
  else {
7304
7520
  noteEl.classList.remove("gg-hidden");
7305
7521
  prepareForDraw(noteEl);
7306
- animateShapeDraw(noteEl, 420, 55);
7522
+ const noteStrokeDur = step.duration ?? ANIMATION.nodeStrokeDur;
7523
+ animateShapeDraw(noteEl, noteStrokeDur, ANIMATION.nodeStagger);
7307
7524
  const notePathCount = noteEl.querySelectorAll('path').length;
7308
- clearDashOverridesAfter(noteEl, notePathCount * 55 + 420 + 120);
7525
+ clearDashOverridesAfter(noteEl, notePathCount * ANIMATION.nodeStagger + noteStrokeDur + 120);
7309
7526
  }
7310
7527
  return;
7311
7528
  }
@@ -7326,8 +7543,9 @@ class AnimationController {
7326
7543
  else {
7327
7544
  chartEl.style.opacity = "0"; // start from 0 explicitly
7328
7545
  chartEl.classList.remove("gg-hidden");
7546
+ const chartFade = step.duration ?? ANIMATION.chartFade;
7329
7547
  requestAnimationFrame(() => requestAnimationFrame(() => {
7330
- chartEl.style.transition = "opacity 500ms ease";
7548
+ chartEl.style.transition = `opacity ${chartFade}ms ease`;
7331
7549
  chartEl.style.opacity = "1";
7332
7550
  }));
7333
7551
  }
@@ -7348,8 +7566,9 @@ class AnimationController {
7348
7566
  else {
7349
7567
  markdownEl.style.opacity = "0";
7350
7568
  markdownEl.classList.remove("gg-hidden");
7569
+ const markdownFade = step.duration ?? ANIMATION.chartFade;
7351
7570
  requestAnimationFrame(() => requestAnimationFrame(() => {
7352
- markdownEl.style.transition = "opacity 500ms ease";
7571
+ markdownEl.style.transition = `opacity ${markdownFade}ms ease`;
7353
7572
  markdownEl.style.opacity = "1";
7354
7573
  }));
7355
7574
  }
@@ -7360,41 +7579,38 @@ class AnimationController {
7360
7579
  if (!nodeEl)
7361
7580
  return;
7362
7581
  if (silent) {
7363
- revealInstant(nodeEl);
7364
- requestAnimationFrame(() => requestAnimationFrame(() => clearDrawStyles(nodeEl)));
7582
+ revealNodeInstant(nodeEl);
7365
7583
  }
7366
7584
  else {
7367
- const firstPath = nodeEl.querySelector("path");
7368
- if (!firstPath?.style.strokeDasharray)
7369
- prepareForDraw(nodeEl);
7370
- animateShapeDraw(nodeEl, 420, 55);
7371
- const nodePathCount = nodeEl.querySelectorAll('path').length;
7372
- clearDashOverridesAfter(nodeEl, nodePathCount * 55 + 420 + 120);
7585
+ if (!nodeGuidePathEl(nodeEl) && !nodeEl.querySelector("path")?.style.strokeDasharray) {
7586
+ prepareNodeForDraw(nodeEl);
7587
+ }
7588
+ animateNodeDraw(nodeEl, step.duration ?? ANIMATION.nodeStrokeDur);
7373
7589
  }
7374
7590
  }
7375
7591
  // ── erase ─────────────────────────────────────────────────
7376
- _doErase(target) {
7592
+ _doErase(target, duration = 400) {
7377
7593
  const el = resolveEl(this.svg, target); // handles edges too now
7378
7594
  if (el) {
7379
- el.style.transition = "opacity 0.4s";
7595
+ el.style.transition = `opacity ${duration}ms`;
7380
7596
  el.style.opacity = "0";
7381
7597
  }
7382
7598
  }
7383
7599
  // ── show / hide ───────────────────────────────────────────
7384
- _doShowHide(target, show, silent) {
7600
+ _doShowHide(target, show, silent, duration = 400) {
7385
7601
  const el = resolveEl(this.svg, target);
7386
7602
  if (!el)
7387
7603
  return;
7388
- el.style.transition = silent ? "none" : "opacity 0.4s";
7604
+ el.style.transition = silent ? "none" : `opacity ${duration}ms`;
7389
7605
  el.style.opacity = show ? "1" : "0";
7390
7606
  }
7391
7607
  // ── pulse ─────────────────────────────────────────────────
7392
- _doPulse(target) {
7608
+ _doPulse(target, duration = 500) {
7393
7609
  resolveEl(this.svg, target)?.animate([
7394
7610
  { filter: "brightness(1)" },
7395
7611
  { filter: "brightness(1.6)" },
7396
7612
  { filter: "brightness(1)" },
7397
- ], { duration: 500, iterations: 3 });
7613
+ ], { duration, iterations: 3 });
7398
7614
  }
7399
7615
  // ── color ─────────────────────────────────────────────────
7400
7616
  _doColor(target, color) {
@@ -7432,41 +7648,39 @@ class AnimationController {
7432
7648
  }
7433
7649
  }
7434
7650
  }
7435
- const ANIMATION_CSS = `
7436
- .ng, .gg, .tg, .ntg, .cg, .eg, .mdg {
7437
- transform-box: fill-box;
7438
- transform-origin: center;
7439
- transition: filter 0.3s, opacity 0.35s;
7440
- }
7441
-
7442
- /* highlight */
7443
- .ng.hl path, .ng.hl rect, .ng.hl ellipse, .ng.hl polygon,
7444
- .tg.hl path, .tg.hl rect,
7445
- .ntg.hl path, .ntg.hl polygon,
7446
- .cg.hl path, .cg.hl rect,
7447
- .mdg.hl text,
7448
- .eg.hl path, .eg.hl line, .eg.hl polygon { stroke-width: 2.8 !important; }
7449
-
7450
- .ng.hl, .tg.hl, .ntg.hl, .cg.hl, .mdg.hl, .eg.hl {
7451
- animation: ng-pulse 1.4s ease-in-out infinite;
7452
- }
7453
- @keyframes ng-pulse {
7454
- 0%, 100% { filter: drop-shadow(0 0 7px rgba(200,84,40,.6)); }
7455
- 50% { filter: drop-shadow(0 0 14px rgba(200,84,40,.9)); }
7456
- }
7457
-
7458
- /* fade */
7459
- .ng.faded, .gg.faded, .tg.faded, .ntg.faded,
7651
+ const ANIMATION_CSS = `
7652
+ .ng, .gg, .tg, .ntg, .cg, .eg, .mdg {
7653
+ transform-box: fill-box;
7654
+ transform-origin: center;
7655
+ transition: filter 0.3s, opacity 0.35s;
7656
+ }
7657
+
7658
+ /* highlight */
7659
+ .ng.hl path, .ng.hl rect, .ng.hl ellipse, .ng.hl polygon,
7660
+ .tg.hl path, .tg.hl rect,
7661
+ .ntg.hl path, .ntg.hl polygon,
7662
+ .cg.hl path, .cg.hl rect,
7663
+ .mdg.hl text,
7664
+ .eg.hl path, .eg.hl line, .eg.hl polygon { stroke-width: 2.8 !important; }
7665
+
7666
+ .ng.hl, .tg.hl, .ntg.hl, .cg.hl, .mdg.hl, .eg.hl {
7667
+ animation: ng-pulse 1.4s ease-in-out infinite;
7668
+ }
7669
+ @keyframes ng-pulse {
7670
+ 0%, 100% { filter: drop-shadow(0 0 7px rgba(200,84,40,.6)); }
7671
+ 50% { filter: drop-shadow(0 0 14px rgba(200,84,40,.9)); }
7672
+ }
7673
+
7674
+ /* fade */
7675
+ .ng.faded, .gg.faded, .tg.faded, .ntg.faded,
7460
7676
  .cg.faded, .eg.faded, .mdg.faded { opacity: 0.22; }
7461
7677
 
7462
7678
  .ng.hidden { opacity: 0; pointer-events: none; }
7463
- .eg.draw-hidden { opacity: 0; }
7464
- .eg.draw-reveal { opacity: 1; }
7465
7679
  .gg.gg-hidden { opacity: 0; }
7466
7680
  .tg.gg-hidden { opacity: 0; }
7467
7681
  .ntg.gg-hidden { opacity: 0; }
7468
- .cg.gg-hidden { opacity: 0; }
7469
- .mdg.gg-hidden { opacity: 0; }
7682
+ .cg.gg-hidden { opacity: 0; }
7683
+ .mdg.gg-hidden { opacity: 0; }
7470
7684
  `;
7471
7685
 
7472
7686
  // ============================================================
@@ -7482,7 +7696,7 @@ function download(blob, filename) {
7482
7696
  document.body.appendChild(a);
7483
7697
  a.click();
7484
7698
  document.body.removeChild(a);
7485
- setTimeout(() => URL.revokeObjectURL(url), 5000);
7699
+ setTimeout(() => URL.revokeObjectURL(url), EXPORT.revokeDelay);
7486
7700
  }
7487
7701
  // ── SVG export ────────────────────────────────────────────
7488
7702
  function exportSVG(svg, opts = {}) {
@@ -7504,9 +7718,9 @@ async function exportPNG(svg, opts = {}) {
7504
7718
  download(blob, opts.filename ?? 'diagram.png');
7505
7719
  }
7506
7720
  async function svgToPNGDataURL(svg, opts = {}) {
7507
- const scale = opts.scale ?? 2;
7508
- const w = parseFloat(svg.getAttribute('width') ?? '400');
7509
- const h = parseFloat(svg.getAttribute('height') ?? '300');
7721
+ const scale = opts.scale ?? EXPORT.pngScale;
7722
+ const w = parseFloat(svg.getAttribute('width') ?? String(EXPORT.fallbackW));
7723
+ const h = parseFloat(svg.getAttribute('height') ?? String(EXPORT.fallbackH));
7510
7724
  const canvas = document.createElement('canvas');
7511
7725
  canvas.width = w * scale;
7512
7726
  canvas.height = h * scale;
@@ -7517,7 +7731,7 @@ async function svgToPNGDataURL(svg, opts = {}) {
7517
7731
  ctx.fillRect(0, 0, w, h);
7518
7732
  }
7519
7733
  else {
7520
- ctx.fillStyle = '#f8f4ea';
7734
+ ctx.fillStyle = EXPORT.fallbackBg;
7521
7735
  ctx.fillRect(0, 0, w, h);
7522
7736
  }
7523
7737
  const svgStr = svgToString(svg);
@@ -7546,7 +7760,7 @@ function exportHTML(svg, dslSource, opts = {}) {
7546
7760
  <meta name="viewport" content="width=device-width, initial-scale=1">
7547
7761
  <title>sketchmark export</title>
7548
7762
  <style>
7549
- body { margin: 0; background: #f8f4ea; display: flex; flex-direction: column; align-items: center; padding: 2rem; font-family: system-ui, sans-serif; }
7763
+ body { margin: 0; background: ${EXPORT.fallbackBg}; display: flex; flex-direction: column; align-items: center; padding: 2rem; font-family: system-ui, sans-serif; }
7550
7764
  .diagram { max-width: 100%; }
7551
7765
  .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; }
7552
7766
  </style>
@@ -7569,7 +7783,7 @@ async function exportGIF(frames, opts = {}) {
7569
7783
  }
7570
7784
  // ── MP4 stub (requires ffmpeg.wasm or MediaRecorder) ──────
7571
7785
  async function exportMP4(canvas, durationMs, opts = {}) {
7572
- const fps = opts.fps ?? 30;
7786
+ const fps = opts.fps ?? EXPORT.defaultFps;
7573
7787
  const stream = canvas.captureStream?.(fps);
7574
7788
  if (!stream)
7575
7789
  throw new Error('captureStream not supported in this browser');