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