sketchmark 1.3.4 → 1.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -600,6 +600,12 @@ function parse(src, options = {}) {
600
600
  id,
601
601
  shape,
602
602
  label: props.label || "",
603
+ ...(props["label-dx"] !== undefined
604
+ ? { labelDx: parseFloat(props["label-dx"]) }
605
+ : {}),
606
+ ...(props["label-dy"] !== undefined
607
+ ? { labelDy: parseFloat(props["label-dy"]) }
608
+ : {}),
603
609
  ...(props.width ? { width: parseFloat(props.width) } : {}),
604
610
  ...(props.height ? { height: parseFloat(props.height) } : {}),
605
611
  ...(props.x ? { x: parseFloat(props.x) } : {}),
@@ -640,6 +646,12 @@ function parse(src, options = {}) {
640
646
  id,
641
647
  shape: "note",
642
648
  label: (props.label ?? "").replace(/\\n/g, "\n"),
649
+ ...(props["label-dx"] !== undefined
650
+ ? { labelDx: parseFloat(props["label-dx"]) }
651
+ : {}),
652
+ ...(props["label-dy"] !== undefined
653
+ ? { labelDy: parseFloat(props["label-dy"]) }
654
+ : {}),
643
655
  theme: props.theme,
644
656
  ...(meta ? { meta } : {}),
645
657
  style: propsToStyle(props),
@@ -688,6 +700,8 @@ function parse(src, options = {}) {
688
700
  kind: "group",
689
701
  id,
690
702
  label: props.label ?? "",
703
+ labelDx: props["label-dx"] !== undefined ? parseFloat(props["label-dx"]) : undefined,
704
+ labelDy: props["label-dy"] !== undefined ? parseFloat(props["label-dy"]) : undefined,
691
705
  children: [],
692
706
  layout: props.layout,
693
707
  columns: props.columns !== undefined ? parseInt(props.columns, 10) : undefined,
@@ -732,6 +746,8 @@ function parse(src, options = {}) {
732
746
  to: toTok.value,
733
747
  connector: connector,
734
748
  label: props.label,
749
+ labelDx: props["label-dx"] !== undefined ? parseFloat(props["label-dx"]) : undefined,
750
+ labelDy: props["label-dy"] !== undefined ? parseFloat(props["label-dy"]) : undefined,
735
751
  fromAnchor: props["anchor-from"],
736
752
  toAnchor: props["anchor-to"],
737
753
  dashed,
@@ -1233,6 +1249,7 @@ const LAYOUT = {
1233
1249
  const NODE = {
1234
1250
  minW: 90, // minimum auto-sized node width (px)
1235
1251
  maxW: 300, // maximum auto-sized node width (px)
1252
+ mediaLabelH: 20, // reserved bottom strip for icon/image/line labels (px)
1236
1253
  basePad: 26, // base padding added to label width (px)
1237
1254
  };
1238
1255
  // ── Shape-specific sizing ──────────────────────────────────
@@ -3556,6 +3573,8 @@ function buildSceneGraph(ast) {
3556
3573
  id: n.id,
3557
3574
  shape: n.shape,
3558
3575
  label: n.label,
3576
+ labelDx: n.labelDx,
3577
+ labelDy: n.labelDy,
3559
3578
  style: { ...ast.styles[n.id], ...themeStyle, ...n.style },
3560
3579
  groupId: nodeParentById.get(n.id),
3561
3580
  width: n.width,
@@ -3581,6 +3600,8 @@ function buildSceneGraph(ast) {
3581
3600
  return {
3582
3601
  id: g.id,
3583
3602
  label: g.label,
3603
+ labelDx: g.labelDx,
3604
+ labelDy: g.labelDy,
3584
3605
  parentId: groupParentById.get(g.id),
3585
3606
  children: g.children,
3586
3607
  layout: (g.layout ?? "column"),
@@ -3658,6 +3679,8 @@ function buildSceneGraph(ast) {
3658
3679
  to: e.to,
3659
3680
  connector: e.connector,
3660
3681
  label: e.label,
3682
+ labelDx: e.labelDx,
3683
+ labelDy: e.labelDy,
3661
3684
  fromAnchor: e.fromAnchor,
3662
3685
  toAnchor: e.toAnchor,
3663
3686
  dashed: e.dashed ?? false,
@@ -3999,10 +4022,25 @@ const textShape = {
3999
4022
  },
4000
4023
  };
4001
4024
 
4025
+ const MEDIA_LABEL_SHAPES = new Set(["icon", "image", "line"]);
4026
+ function usesBottomLabelStrip(shape) {
4027
+ return MEDIA_LABEL_SHAPES.has(shape);
4028
+ }
4029
+ function getBottomLabelStripHeight(node) {
4030
+ return usesBottomLabelStrip(node.shape) && node.label ? NODE.mediaLabelH : 0;
4031
+ }
4032
+ function getBottomLabelContentHeight(node) {
4033
+ return node.h - getBottomLabelStripHeight(node);
4034
+ }
4035
+ function getBottomLabelCenterY(node) {
4036
+ const stripH = getBottomLabelStripHeight(node);
4037
+ return stripH > 0 ? node.y + node.h - stripH / 2 : node.y + node.h / 2;
4038
+ }
4039
+
4002
4040
  const iconShape = {
4003
4041
  size(n, labelW) {
4004
4042
  const iconBase = 48;
4005
- const labelH = n.label ? 20 : 0;
4043
+ const labelH = getBottomLabelStripHeight(n);
4006
4044
  n.w = n.w || Math.max(iconBase, n.label ? labelW : 0);
4007
4045
  n.h = n.h || (iconBase + labelH);
4008
4046
  },
@@ -4015,8 +4053,7 @@ const iconShape = {
4015
4053
  const iconColor = s.color
4016
4054
  ? encodeURIComponent(String(s.color))
4017
4055
  : encodeURIComponent(String(palette.nodeStroke));
4018
- const labelSpace = n.label ? 20 : 0;
4019
- const iconAreaH = n.h - labelSpace;
4056
+ const iconAreaH = getBottomLabelContentHeight(n);
4020
4057
  const iconSize = Math.min(n.w, iconAreaH) - 4;
4021
4058
  const iconUrl = `https://api.iconify.design/${prefix}/${name}.svg?color=${iconColor}&width=${iconSize}&height=${iconSize}`;
4022
4059
  const img = document.createElementNS(SVG_NS, "image");
@@ -4060,8 +4097,7 @@ const iconShape = {
4060
4097
  const iconColor = s.color
4061
4098
  ? encodeURIComponent(String(s.color))
4062
4099
  : encodeURIComponent(String(palette.nodeStroke));
4063
- const iconLabelSpace = n.label ? 20 : 0;
4064
- const iconAreaH = n.h - iconLabelSpace;
4100
+ const iconAreaH = getBottomLabelContentHeight(n);
4065
4101
  const iconSize = Math.min(n.w, iconAreaH) - 4;
4066
4102
  const iconUrl = `https://api.iconify.design/${prefix}/${name}.svg?color=${iconColor}&width=${iconSize}&height=${iconSize}`;
4067
4103
  const img = new Image();
@@ -4104,21 +4140,21 @@ const imageShape = {
4104
4140
  const w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW));
4105
4141
  n.w = w;
4106
4142
  if (!n.h) {
4143
+ const labelH = getBottomLabelStripHeight(n);
4107
4144
  if (labelW > w) {
4108
4145
  const fontSize = Number(n.style?.fontSize ?? 14);
4109
4146
  const lines = Math.ceil(labelW / (w - 16));
4110
- n.h = Math.max(52, lines * fontSize * 1.5 + 20);
4147
+ n.h = Math.max(52, lines * fontSize * 1.5 + labelH);
4111
4148
  }
4112
4149
  else {
4113
- n.h = 52;
4150
+ n.h = 52 + labelH;
4114
4151
  }
4115
4152
  }
4116
4153
  },
4117
4154
  renderSVG(rc, n, _palette, opts) {
4118
4155
  const s = n.style ?? {};
4119
4156
  if (n.imageUrl) {
4120
- const imgLabelSpace = n.label ? 20 : 0;
4121
- const imgAreaH = n.h - imgLabelSpace;
4157
+ const imgAreaH = getBottomLabelContentHeight(n);
4122
4158
  const img = document.createElementNS(SVG_NS, "image");
4123
4159
  img.setAttribute("href", n.imageUrl);
4124
4160
  img.setAttribute("x", String(n.x + 1));
@@ -4150,8 +4186,7 @@ const imageShape = {
4150
4186
  renderCanvas(rc, ctx, n, _palette, opts) {
4151
4187
  const s = n.style ?? {};
4152
4188
  if (n.imageUrl) {
4153
- const imgLblSpace = n.label ? 20 : 0;
4154
- const imgAreaH = n.h - imgLblSpace;
4189
+ const imgAreaH = getBottomLabelContentHeight(n);
4155
4190
  const img = new Image();
4156
4191
  img.crossOrigin = "anonymous";
4157
4192
  img.onload = () => {
@@ -4328,17 +4363,17 @@ const noteShape = {
4328
4363
 
4329
4364
  const lineShape = {
4330
4365
  size(n, labelW) {
4331
- const labelH = n.label ? 20 : 0;
4366
+ const labelH = getBottomLabelStripHeight(n);
4332
4367
  n.w = n.width ?? Math.max(MIN_W, labelW + 20);
4333
4368
  n.h = n.height ?? (6 + labelH);
4334
4369
  },
4335
4370
  renderSVG(rc, n, _palette, opts) {
4336
- const labelH = n.label ? 20 : 0;
4371
+ const labelH = getBottomLabelStripHeight(n);
4337
4372
  const lineY = n.y + (n.h - labelH) / 2;
4338
4373
  return [rc.line(n.x, lineY, n.x + n.w, lineY, opts)];
4339
4374
  },
4340
4375
  renderCanvas(rc, _ctx, n, _palette, opts) {
4341
- const labelH = n.label ? 20 : 0;
4376
+ const labelH = getBottomLabelStripHeight(n);
4342
4377
  const lineY = n.y + (n.h - labelH) / 2;
4343
4378
  rc.line(n.x, lineY, n.x + n.w, lineY, opts);
4344
4379
  },
@@ -5584,18 +5619,18 @@ function renderRoughChartSVG(rc, c, palette, isDark) {
5584
5619
  stroke: bgStroke, strokeWidth: Number(s.strokeWidth ?? 1.2),
5585
5620
  ...(s.strokeDash ? { strokeLineDash: s.strokeDash } : {}),
5586
5621
  }));
5622
+ const { px, py, pw, ph, titleH, cx, cy } = chartLayout(c);
5587
5623
  // Title
5588
5624
  if (c.label) {
5589
- cg.appendChild(mkT(c.label, c.x + c.w / 2, c.y + 14, cFontSize, cFontWeight, lc, 'middle', cFont));
5625
+ cg.appendChild(mkT(c.label, c.x + c.w / 2, c.y + titleH / 2 + 2, cFontSize, cFontWeight, lc, 'middle', cFont));
5590
5626
  }
5591
- const { px, py, pw, ph, cx, cy } = chartLayout(c);
5592
5627
  // ── Pie / Donut ──────────────────────────────────────────
5593
5628
  if (c.chartType === 'pie' || c.chartType === 'donut') {
5594
5629
  const { segments, total } = parsePie(c.data);
5595
- const r = Math.min(c.w * 0.38, (c.h - (c.label ? 24 : 8)) * 0.44);
5630
+ const r = Math.min(c.w * 0.38, (c.h - titleH) * 0.44);
5596
5631
  const ir = c.chartType === 'donut' ? r * 0.48 : 0;
5597
5632
  const legendX = c.x + 8;
5598
- const legendY = c.y + (c.label ? 28 : 12);
5633
+ const legendY = c.y + titleH + 4;
5599
5634
  let angle = -Math.PI / 2;
5600
5635
  for (const seg of segments) {
5601
5636
  const sweep = (seg.value / total) * Math.PI * 2;
@@ -5633,7 +5668,7 @@ function renderRoughChartSVG(rc, c, palette, isDark) {
5633
5668
  strokeWidth: 1.2,
5634
5669
  }));
5635
5670
  });
5636
- legend(cg, pts.map(p => p.label), CHART_COLORS, c.x + 8, c.y + (c.label ? 28 : 12), lc, cFont);
5671
+ legend(cg, pts.map(p => p.label), CHART_COLORS, c.x + 8, c.y + titleH + 4, lc, cFont);
5637
5672
  return cg;
5638
5673
  }
5639
5674
  // ── Bar / Line / Area ─────────────────────────────────────
@@ -8379,9 +8414,10 @@ function renderToSVG(sg, container, options = {}) {
8379
8414
  }));
8380
8415
  // ── Group label typography ──────────────────────────
8381
8416
  const gTypo = resolveTypography(gs, { fontSize: GROUP_LABEL.fontSize, fontWeight: GROUP_LABEL.fontWeight, textAlign: "left", padding: GROUP_LABEL.padding }, diagramFont, palette.groupLabel);
8382
- const gTextX = computeTextX(gTypo, g.x, g.w);
8417
+ const gTextX = computeTextX(gTypo, g.x, g.w) + (g.labelDx ?? 0);
8418
+ const gTextY = g.y + gTypo.padding + (g.labelDy ?? 0);
8383
8419
  if (g.label) {
8384
- gg.appendChild(mkText(g.label, gTextX, g.y + gTypo.padding, gTypo.fontSize, gTypo.fontWeight, gTypo.textColor, gTypo.textAnchor, gTypo.font, gTypo.letterSpacing));
8420
+ gg.appendChild(mkText(g.label, gTextX, gTextY, gTypo.fontSize, gTypo.fontWeight, gTypo.textColor, gTypo.textAnchor, gTypo.font, gTypo.letterSpacing));
8385
8421
  }
8386
8422
  GL.appendChild(gg);
8387
8423
  }
@@ -8433,8 +8469,8 @@ function renderToSVG(sg, container, options = {}) {
8433
8469
  eg.appendChild(startHead);
8434
8470
  }
8435
8471
  if (e.label) {
8436
- const mx = (x1 + x2) / 2 - ny * EDGE.labelOffset;
8437
- const my = (y1 + y2) / 2 + nx * EDGE.labelOffset;
8472
+ const mx = (x1 + x2) / 2 - ny * EDGE.labelOffset + (e.labelDx ?? 0);
8473
+ const my = (y1 + y2) / 2 + nx * EDGE.labelOffset + (e.labelDy ?? 0);
8438
8474
  const tw = Math.max(e.label.length * 7 + 12, 36);
8439
8475
  const bg = se("rect");
8440
8476
  bg.setAttribute("x", String(mx - tw / 2));
@@ -8502,7 +8538,7 @@ function renderToSVG(sg, container, options = {}) {
8502
8538
  // ── Node / text typography ─────────────────────────
8503
8539
  const isText = n.shape === "text";
8504
8540
  const isNote = n.shape === "note";
8505
- const isMediaShape = n.shape === "icon" || n.shape === "image" || n.shape === "line";
8541
+ const usesBottomStrip = usesBottomLabelStrip(n.shape);
8506
8542
  const typo = resolveTypography(n.style, {
8507
8543
  fontSize: isText ? 13 : isNote ? 12 : 14,
8508
8544
  fontWeight: isText || isNote ? 400 : 500,
@@ -8520,20 +8556,22 @@ function renderToSVG(sg, container, options = {}) {
8520
8556
  : n.x + typo.padding)
8521
8557
  : computeTextX(typo, n.x, n.w);
8522
8558
  const fontStr = buildFontStr(typo.fontSize, typo.fontWeight, typo.font);
8523
- const shouldWrap = !isMediaShape && !n.label.includes('\n');
8559
+ const shouldWrap = !usesBottomStrip && !n.label.includes('\n');
8524
8560
  const innerW = shapeInnerTextWidth(n.shape, n.w, typo.padding);
8525
8561
  const lines = shouldWrap
8526
8562
  ? wrapText(n.label, innerW, typo.fontSize, fontStr)
8527
8563
  : n.label.split('\n');
8528
- const textCY = isMediaShape
8529
- ? n.y + n.h - 10
8564
+ const textCY = usesBottomStrip
8565
+ ? getBottomLabelCenterY(n)
8530
8566
  : isNote
8531
8567
  ? computeTextCY(typo, n.y, n.h, lines.length, FOLD + typo.padding)
8532
8568
  : computeTextCY(typo, n.y, n.h, lines.length);
8569
+ const labelX = textX + (n.labelDx ?? 0);
8570
+ const labelY = textCY + (n.labelDy ?? 0);
8533
8571
  if (n.label) {
8534
8572
  ng.appendChild(lines.length > 1
8535
- ? mkMultilineText(lines, textX, textCY, typo.fontSize, typo.fontWeight, typo.textColor, typo.textAnchor, typo.lineHeight, typo.font, typo.letterSpacing)
8536
- : mkText(n.label, textX, textCY, typo.fontSize, typo.fontWeight, typo.textColor, typo.textAnchor, typo.font, typo.letterSpacing));
8573
+ ? mkMultilineText(lines, labelX, labelY, typo.fontSize, typo.fontWeight, typo.textColor, typo.textAnchor, typo.lineHeight, typo.font, typo.letterSpacing)
8574
+ : mkText(n.label, labelX, labelY, typo.fontSize, typo.fontWeight, typo.textColor, typo.textAnchor, typo.font, typo.letterSpacing));
8537
8575
  }
8538
8576
  if (options.interactive) {
8539
8577
  ng.style.cursor = "pointer";
@@ -8847,6 +8885,7 @@ function drawRoughChartCanvas(rc, ctx, c, pal, R) {
8847
8885
  strokeWidth: Number(s.strokeWidth ?? 1.2),
8848
8886
  ...(s.strokeDash ? { strokeLineDash: s.strokeDash } : {}),
8849
8887
  });
8888
+ const { px, py, pw, ph, titleH, cx, cy } = chartLayout(c);
8850
8889
  // Title
8851
8890
  if (c.label) {
8852
8891
  ctx.save();
@@ -8854,17 +8893,16 @@ function drawRoughChartCanvas(rc, ctx, c, pal, R) {
8854
8893
  ctx.fillStyle = lc;
8855
8894
  ctx.textAlign = 'center';
8856
8895
  ctx.textBaseline = 'middle';
8857
- ctx.fillText(c.label, c.x + c.w / 2, c.y + 14);
8896
+ ctx.fillText(c.label, c.x + c.w / 2, c.y + titleH / 2 + 2);
8858
8897
  ctx.restore();
8859
8898
  }
8860
- const { px, py, pw, ph, cx, cy } = chartLayout(c);
8861
8899
  // ── Pie / Donut ──────────────────────────────────────────
8862
8900
  if (c.chartType === 'pie' || c.chartType === 'donut') {
8863
8901
  const { segments, total } = parsePie(c.data);
8864
- const r = Math.min(c.w * 0.38, (c.h - (c.label ? 24 : 8)) * 0.44);
8902
+ const r = Math.min(c.w * 0.38, (c.h - titleH) * 0.44);
8865
8903
  const ir = c.chartType === 'donut' ? r * 0.48 : 0;
8866
8904
  const legendX = c.x + 8;
8867
- const legendY = c.y + (c.label ? 28 : 12);
8905
+ const legendY = c.y + titleH + 4;
8868
8906
  let angle = -Math.PI / 2;
8869
8907
  segments.forEach((seg, i) => {
8870
8908
  const sweep = (seg.value / total) * Math.PI * 2;
@@ -8892,7 +8930,7 @@ function drawRoughChartCanvas(rc, ctx, c, pal, R) {
8892
8930
  strokeWidth: 1.2,
8893
8931
  });
8894
8932
  });
8895
- drawLegend(ctx, pts.map(p => p.label), CHART_COLORS, c.x + 8, c.y + (c.label ? 28 : 12), lc, cFont);
8933
+ drawLegend(ctx, pts.map(p => p.label), CHART_COLORS, c.x + 8, c.y + titleH + 4, lc, cFont);
8896
8934
  ctx.globalAlpha = 1;
8897
8935
  return;
8898
8936
  }
@@ -9127,8 +9165,9 @@ function renderToCanvas(sg, canvas, options = {}) {
9127
9165
  });
9128
9166
  if (g.label) {
9129
9167
  const gTypo = resolveTypography(gs, { fontSize: GROUP_LABEL.fontSize, fontWeight: GROUP_LABEL.fontWeight, textAlign: "left", padding: GROUP_LABEL.padding }, diagramFont, palette.groupLabel);
9130
- const gTextX = computeTextX(gTypo, g.x, g.w);
9131
- drawText(ctx, g.label, gTextX, g.y + gTypo.padding + 2, gTypo.fontSize, gTypo.fontWeight, gTypo.textColor, gTypo.textAlign, gTypo.font, gTypo.letterSpacing);
9168
+ const gTextX = computeTextX(gTypo, g.x, g.w) + (g.labelDx ?? 0);
9169
+ const gTextY = g.y + gTypo.padding + 2 + (g.labelDy ?? 0);
9170
+ drawText(ctx, g.label, gTextX, gTextY, gTypo.fontSize, gTypo.fontWeight, gTypo.textColor, gTypo.textAlign, gTypo.font, gTypo.letterSpacing);
9132
9171
  }
9133
9172
  if (gs.opacity != null)
9134
9173
  ctx.globalAlpha = 1;
@@ -9166,8 +9205,8 @@ function renderToCanvas(sg, canvas, options = {}) {
9166
9205
  if (arrowAt === 'start' || arrowAt === 'both')
9167
9206
  drawArrowHead(rc, x1, y1, Math.atan2(y1 - y2, x1 - x2), ecol, hashStr$3(e.from + 'back'));
9168
9207
  if (e.label) {
9169
- const mx = (x1 + x2) / 2 - ny * EDGE.labelOffset;
9170
- const my = (y1 + y2) / 2 + nx * EDGE.labelOffset;
9208
+ const mx = (x1 + x2) / 2 - ny * EDGE.labelOffset + (e.labelDx ?? 0);
9209
+ const my = (y1 + y2) / 2 + nx * EDGE.labelOffset + (e.labelDy ?? 0);
9171
9210
  // ── Edge label: font, font-size, letter-spacing ──
9172
9211
  // always center-anchored (single line)
9173
9212
  const eFontSize = Number(e.style?.fontSize ?? EDGE.labelFontSize);
@@ -9207,7 +9246,7 @@ function renderToCanvas(sg, canvas, options = {}) {
9207
9246
  // ── Node / text typography ─────────────────────────
9208
9247
  const isText = n.shape === 'text';
9209
9248
  const isNote = n.shape === 'note';
9210
- const isMediaShape = n.shape === 'icon' || n.shape === 'image' || n.shape === 'line';
9249
+ const usesBottomStrip = usesBottomLabelStrip(n.shape);
9211
9250
  const typo = resolveTypography(n.style, {
9212
9251
  fontSize: isText ? 13 : isNote ? 12 : 14,
9213
9252
  fontWeight: isText || isNote ? 400 : 500,
@@ -9225,23 +9264,25 @@ function renderToCanvas(sg, canvas, options = {}) {
9225
9264
  : n.x + typo.padding)
9226
9265
  : computeTextX(typo, n.x, n.w);
9227
9266
  const fontStr = buildFontStr(typo.fontSize, typo.fontWeight, typo.font);
9228
- const shouldWrap = !isMediaShape && !n.label.includes('\n');
9267
+ const shouldWrap = !usesBottomStrip && !n.label.includes('\n');
9229
9268
  const innerW = shapeInnerTextWidth(n.shape, n.w, typo.padding);
9230
9269
  const rawLines = n.label.split('\n');
9231
9270
  const lines = shouldWrap && rawLines.length === 1
9232
9271
  ? wrapText(n.label, innerW, typo.fontSize, fontStr)
9233
9272
  : rawLines;
9234
- const textCY = isMediaShape
9235
- ? n.y + n.h - 10
9273
+ const textCY = usesBottomStrip
9274
+ ? getBottomLabelCenterY(n)
9236
9275
  : isNote
9237
9276
  ? computeTextCY(typo, n.y, n.h, lines.length, FOLD + typo.padding)
9238
9277
  : computeTextCY(typo, n.y, n.h, lines.length);
9278
+ const labelX = textX + (n.labelDx ?? 0);
9279
+ const labelY = textCY + (n.labelDy ?? 0);
9239
9280
  if (n.label) {
9240
9281
  if (lines.length > 1) {
9241
- drawMultilineText(ctx, lines, textX, textCY, typo.fontSize, typo.fontWeight, typo.textColor, typo.textAlign, typo.lineHeight, typo.font, typo.letterSpacing);
9282
+ drawMultilineText(ctx, lines, labelX, labelY, typo.fontSize, typo.fontWeight, typo.textColor, typo.textAlign, typo.lineHeight, typo.font, typo.letterSpacing);
9242
9283
  }
9243
9284
  else {
9244
- drawText(ctx, lines[0] ?? '', textX, textCY, typo.fontSize, typo.fontWeight, typo.textColor, typo.textAlign, typo.font, typo.letterSpacing);
9285
+ drawText(ctx, lines[0] ?? '', labelX, labelY, typo.fontSize, typo.fontWeight, typo.textColor, typo.textAlign, typo.font, typo.letterSpacing);
9245
9286
  }
9246
9287
  }
9247
9288
  if (hasTx)
@@ -9579,7 +9620,7 @@ function buildNodeGuidePath(el) {
9579
9620
  [x + 1, y + h - 1],
9580
9621
  ]);
9581
9622
  case "line": {
9582
- const labelH = el.querySelector("text") ? 20 : 0;
9623
+ const labelH = el.querySelector("text") ? NODE.mediaLabelH : 0;
9583
9624
  const lineY = y + (h - labelH) / 2;
9584
9625
  return `M ${x} ${lineY} L ${x + w} ${lineY}`;
9585
9626
  }
@@ -9950,8 +9991,12 @@ class AnimationController {
9950
9991
  this._rc = _rc;
9951
9992
  this._config = _config;
9952
9993
  this._step = -1;
9994
+ this._isPlaying = false;
9995
+ this._playRunId = 0;
9953
9996
  this._pendingStepTimers = new Set();
9954
9997
  this._pendingNarrationTimers = new Set();
9998
+ this._playbackDelayTimerId = null;
9999
+ this._resolvePlaybackDelay = null;
9955
10000
  this._transforms = new Map();
9956
10001
  this._listeners = [];
9957
10002
  // ── Narration caption ──
@@ -9967,6 +10012,7 @@ class AnimationController {
9967
10012
  // ── TTS ──
9968
10013
  this._tts = false;
9969
10014
  this._speechDone = null;
10015
+ this._resolveSpeechDone = null;
9970
10016
  this.drawTargetEdges = getDrawTargetEdgeIds(steps);
9971
10017
  this.drawTargetNodes = getDrawTargetNodeIds(steps);
9972
10018
  // Groups: non-edge draw steps whose target has a #group-{id} element in the SVG.
@@ -10190,6 +10236,9 @@ class AnimationController {
10190
10236
  get atEnd() {
10191
10237
  return this._step === this.steps.length - 1;
10192
10238
  }
10239
+ get isPlaying() {
10240
+ return this._isPlaying;
10241
+ }
10193
10242
  on(listener) {
10194
10243
  this._listeners.push(listener);
10195
10244
  return () => {
@@ -10207,12 +10256,14 @@ class AnimationController {
10207
10256
  l(e);
10208
10257
  }
10209
10258
  reset() {
10259
+ this.stop();
10210
10260
  this._step = -1;
10211
10261
  this._clearAll();
10212
10262
  this.emit("animation-reset");
10213
10263
  }
10214
10264
  /** Remove caption and annotation layer from the DOM */
10215
10265
  destroy() {
10266
+ this.stop();
10216
10267
  this._clearAll();
10217
10268
  this._captionEl?.remove();
10218
10269
  this._captionEl = null;
@@ -10223,16 +10274,11 @@ class AnimationController {
10223
10274
  this._pointerEl = null;
10224
10275
  }
10225
10276
  next() {
10226
- if (!this.canNext)
10227
- return false;
10228
- this._step++;
10229
- this._applyStep(this._step, false);
10230
- this.emit("step-change");
10231
- if (!this.canNext)
10232
- this.emit("animation-end");
10233
- return true;
10277
+ this.stop();
10278
+ return this._advanceNext();
10234
10279
  }
10235
10280
  prev() {
10281
+ this.stop();
10236
10282
  if (!this.canPrev)
10237
10283
  return false;
10238
10284
  this._step--;
@@ -10243,18 +10289,33 @@ class AnimationController {
10243
10289
  return true;
10244
10290
  }
10245
10291
  async play(msPerStep = 900) {
10292
+ if (this._isPlaying || !this.canNext)
10293
+ return;
10294
+ const runId = ++this._playRunId;
10295
+ this._isPlaying = true;
10246
10296
  this.emit("animation-start");
10247
- while (this.canNext) {
10248
- const nextStep = this.steps[this._step + 1];
10249
- this.next();
10250
- // Wait for timer AND speech to finish (whichever is longer)
10251
- await Promise.all([
10252
- new Promise((r) => setTimeout(r, this._playbackWaitMs(nextStep, msPerStep))),
10253
- this._speechDone ?? Promise.resolve(),
10254
- ]);
10297
+ try {
10298
+ while (this.canNext && this._playRunId === runId) {
10299
+ const nextStep = this.steps[this._step + 1];
10300
+ if (!this._advanceNext())
10301
+ break;
10302
+ if (this._playRunId !== runId)
10303
+ break;
10304
+ await Promise.all([
10305
+ this._waitForPlaybackDelay(this._playbackWaitMs(nextStep, msPerStep)),
10306
+ this._speechDone ?? Promise.resolve(),
10307
+ ]);
10308
+ }
10309
+ }
10310
+ finally {
10311
+ if (this._playRunId === runId) {
10312
+ this._isPlaying = false;
10313
+ this._cancelPlaybackDelay();
10314
+ }
10255
10315
  }
10256
10316
  }
10257
10317
  goTo(index) {
10318
+ this.stop();
10258
10319
  index = Math.max(-1, Math.min(this.steps.length - 1, index));
10259
10320
  if (index === this._step)
10260
10321
  return;
@@ -10268,6 +10329,30 @@ class AnimationController {
10268
10329
  }
10269
10330
  this.emit("step-change");
10270
10331
  }
10332
+ stop() {
10333
+ if (!this._isPlaying && !this._resolvePlaybackDelay) {
10334
+ this._clearPendingStepTimers();
10335
+ this._cancelNarrationTyping();
10336
+ this._cancelSpeech();
10337
+ return;
10338
+ }
10339
+ this._isPlaying = false;
10340
+ this._playRunId += 1;
10341
+ this._cancelPlaybackDelay();
10342
+ this._clearPendingStepTimers();
10343
+ this._cancelNarrationTyping();
10344
+ this._cancelSpeech();
10345
+ }
10346
+ _advanceNext() {
10347
+ if (!this.canNext)
10348
+ return false;
10349
+ this._step++;
10350
+ this._applyStep(this._step, false);
10351
+ this.emit("step-change");
10352
+ if (!this.canNext)
10353
+ this.emit("animation-end");
10354
+ return true;
10355
+ }
10271
10356
  _clearTimerBucket(bucket) {
10272
10357
  bucket.forEach((id) => window.clearTimeout(id));
10273
10358
  bucket.clear();
@@ -10293,6 +10378,34 @@ class AnimationController {
10293
10378
  _scheduleStep(fn, delayMs) {
10294
10379
  this._scheduleTimer(fn, delayMs, this._pendingStepTimers);
10295
10380
  }
10381
+ _waitForPlaybackDelay(delayMs) {
10382
+ this._cancelPlaybackDelay();
10383
+ return new Promise((resolve) => {
10384
+ let settled = false;
10385
+ const finish = () => {
10386
+ if (settled)
10387
+ return;
10388
+ settled = true;
10389
+ if (this._playbackDelayTimerId !== null) {
10390
+ window.clearTimeout(this._playbackDelayTimerId);
10391
+ this._playbackDelayTimerId = null;
10392
+ }
10393
+ if (this._resolvePlaybackDelay === finish) {
10394
+ this._resolvePlaybackDelay = null;
10395
+ }
10396
+ resolve();
10397
+ };
10398
+ this._resolvePlaybackDelay = finish;
10399
+ if (delayMs <= 0) {
10400
+ finish();
10401
+ return;
10402
+ }
10403
+ this._playbackDelayTimerId = window.setTimeout(finish, delayMs);
10404
+ });
10405
+ }
10406
+ _cancelPlaybackDelay() {
10407
+ this._resolvePlaybackDelay?.();
10408
+ }
10296
10409
  _stepWaitMs(step, fallbackMs) {
10297
10410
  const delay = Math.max(0, step.delay ?? 0);
10298
10411
  const duration = Math.max(0, step.duration ?? 0);
@@ -10334,6 +10447,7 @@ class AnimationController {
10334
10447
  return this._stepWaitMs(step, fallbackMs);
10335
10448
  }
10336
10449
  _clearAll() {
10450
+ this._cancelPlaybackDelay();
10337
10451
  this._clearPendingStepTimers();
10338
10452
  this._cancelNarrationTyping();
10339
10453
  this._cancelSpeech();
@@ -10945,16 +11059,30 @@ class AnimationController {
10945
11059
  utter.rate = 0.95;
10946
11060
  utter.pitch = 1;
10947
11061
  utter.lang = "en-US";
10948
- // Track when speech actually finishes
11062
+ // Track when speech actually finishes so play() can block until the utterance ends.
10949
11063
  this._speechDone = new Promise((resolve) => {
10950
- utter.onend = () => resolve();
10951
- utter.onerror = () => resolve();
11064
+ let settled = false;
11065
+ const finish = () => {
11066
+ if (settled)
11067
+ return;
11068
+ settled = true;
11069
+ if (this._resolveSpeechDone === finish) {
11070
+ this._resolveSpeechDone = null;
11071
+ this._speechDone = null;
11072
+ }
11073
+ resolve();
11074
+ };
11075
+ this._resolveSpeechDone = finish;
11076
+ utter.onend = finish;
11077
+ utter.onerror = finish;
10952
11078
  });
10953
11079
  speechSynthesis.speak(utter);
10954
11080
  }
10955
11081
  _cancelSpeech() {
10956
11082
  if (typeof speechSynthesis !== "undefined")
10957
11083
  speechSynthesis.cancel();
11084
+ this._resolveSpeechDone?.();
11085
+ this._resolveSpeechDone = null;
10958
11086
  this._speechDone = null;
10959
11087
  }
10960
11088
  /** Pre-warm the speech engine with a silent utterance to eliminate cold-start delay */
@@ -11763,7 +11891,13 @@ class SketchmarkCanvas {
11763
11891
  this.resetButton.addEventListener("click", () => this.resetAnimation());
11764
11892
  this.prevButton.addEventListener("click", () => this.prevStep());
11765
11893
  this.nextButton.addEventListener("click", () => this.nextStep());
11766
- this.playButton.addEventListener("click", () => void this.play());
11894
+ this.playButton.addEventListener("click", () => {
11895
+ if (this.playInFlight) {
11896
+ this.stopPlayback();
11897
+ return;
11898
+ }
11899
+ void this.play();
11900
+ });
11767
11901
  this.captionButton.addEventListener("click", () => this.setCaptionVisible(!this.showCaption));
11768
11902
  this.ttsButton.addEventListener("click", () => this.setTtsEnabled(!this.getTtsEnabled()));
11769
11903
  this.viewport.addEventListener("pointerdown", this.onPointerDown);
@@ -11827,6 +11961,7 @@ class SketchmarkCanvas {
11827
11961
  this.dsl = normalizeNewlines(nextDsl);
11828
11962
  this.clearError();
11829
11963
  this.mirroredEditor?.clearError();
11964
+ this.playInFlight = false;
11830
11965
  this.animUnsub?.();
11831
11966
  this.animUnsub = null;
11832
11967
  this.instance?.anim?.destroy();
@@ -11897,9 +12032,16 @@ class SketchmarkCanvas {
11897
12032
  this.syncAnimationUi();
11898
12033
  }
11899
12034
  }
12035
+ stopPlayback() {
12036
+ this.playInFlight = false;
12037
+ if (this.renderer === "svg")
12038
+ this.instance?.anim.stop();
12039
+ this.syncAnimationUi();
12040
+ }
11900
12041
  nextStep() {
11901
12042
  if (!this.instance || this.renderer !== "svg")
11902
12043
  return;
12044
+ this.playInFlight = false;
11903
12045
  this.instance.anim.next();
11904
12046
  this.syncAnimationUi();
11905
12047
  this.focusCurrentStep();
@@ -11907,6 +12049,7 @@ class SketchmarkCanvas {
11907
12049
  prevStep() {
11908
12050
  if (!this.instance || this.renderer !== "svg")
11909
12051
  return;
12052
+ this.playInFlight = false;
11910
12053
  this.instance.anim.prev();
11911
12054
  this.syncAnimationUi();
11912
12055
  this.focusCurrentStep();
@@ -11914,6 +12057,7 @@ class SketchmarkCanvas {
11914
12057
  resetAnimation() {
11915
12058
  if (!this.instance || this.renderer !== "svg")
11916
12059
  return;
12060
+ this.playInFlight = false;
11917
12061
  this.instance.anim.reset();
11918
12062
  this.syncAnimationUi();
11919
12063
  }
@@ -11942,6 +12086,7 @@ class SketchmarkCanvas {
11942
12086
  this.render();
11943
12087
  }
11944
12088
  destroy() {
12089
+ this.playInFlight = false;
11945
12090
  this.editorCleanup?.();
11946
12091
  this.animUnsub?.();
11947
12092
  this.instance?.anim?.destroy();
@@ -12016,6 +12161,9 @@ class SketchmarkCanvas {
12016
12161
  this.prevButton.disabled = true;
12017
12162
  this.nextButton.disabled = true;
12018
12163
  this.resetButton.disabled = true;
12164
+ this.playButton.textContent = "Play";
12165
+ this.playButton.classList.remove("is-active");
12166
+ this.playButton.setAttribute("aria-pressed", "false");
12019
12167
  this.playButton.disabled = true;
12020
12168
  this.syncToggleUi();
12021
12169
  return;
@@ -12025,7 +12173,10 @@ class SketchmarkCanvas {
12025
12173
  this.prevButton.disabled = !anim.canPrev;
12026
12174
  this.nextButton.disabled = !anim.canNext;
12027
12175
  this.resetButton.disabled = false;
12028
- this.playButton.disabled = this.playInFlight || !anim.canNext;
12176
+ this.playButton.textContent = this.playInFlight ? "Stop" : "Play";
12177
+ this.playButton.classList.toggle("is-active", this.playInFlight);
12178
+ this.playButton.setAttribute("aria-pressed", this.playInFlight ? "true" : "false");
12179
+ this.playButton.disabled = this.playInFlight ? false : !anim.canNext;
12029
12180
  this.syncToggleUi();
12030
12181
  }
12031
12182
  getStepTarget(stepItem) {
@@ -12931,6 +13082,10 @@ class SketchmarkEmbed {
12931
13082
  this.btnPrev.addEventListener("click", () => this.prevStep());
12932
13083
  this.btnNext.addEventListener("click", () => this.nextStep());
12933
13084
  this.btnPlay.addEventListener("click", () => {
13085
+ if (this.playInFlight) {
13086
+ this.stopPlayback();
13087
+ return;
13088
+ }
12934
13089
  void this.play();
12935
13090
  });
12936
13091
  this.btnCaption.addEventListener("click", () => this.setCaptionVisible(!this.showCaption));
@@ -12988,6 +13143,7 @@ class SketchmarkEmbed {
12988
13143
  }
12989
13144
  this.clearError();
12990
13145
  this.stopMotion();
13146
+ this.playInFlight = false;
12991
13147
  this.animUnsub?.();
12992
13148
  this.animUnsub = null;
12993
13149
  this.instance?.anim?.destroy();
@@ -13060,9 +13216,15 @@ class SketchmarkEmbed {
13060
13216
  this.syncControls();
13061
13217
  }
13062
13218
  }
13219
+ stopPlayback() {
13220
+ this.playInFlight = false;
13221
+ this.instance?.anim.stop();
13222
+ this.syncControls();
13223
+ }
13063
13224
  nextStep() {
13064
13225
  if (!this.instance)
13065
13226
  return;
13227
+ this.playInFlight = false;
13066
13228
  this.instance.anim.next();
13067
13229
  this.syncControls();
13068
13230
  if (this.options.autoFocus !== false && this.options.autoFocusOnStep !== false) {
@@ -13072,6 +13234,7 @@ class SketchmarkEmbed {
13072
13234
  prevStep() {
13073
13235
  if (!this.instance)
13074
13236
  return;
13237
+ this.playInFlight = false;
13075
13238
  this.instance.anim.prev();
13076
13239
  this.syncControls();
13077
13240
  if (this.options.autoFocus !== false && this.options.autoFocusOnStep !== false) {
@@ -13081,6 +13244,7 @@ class SketchmarkEmbed {
13081
13244
  resetAnimation() {
13082
13245
  if (!this.instance)
13083
13246
  return;
13247
+ this.playInFlight = false;
13084
13248
  this.instance.anim.reset();
13085
13249
  this.syncControls();
13086
13250
  }
@@ -13107,6 +13271,7 @@ class SketchmarkEmbed {
13107
13271
  }
13108
13272
  destroy() {
13109
13273
  this.stopMotion();
13274
+ this.playInFlight = false;
13110
13275
  this.animUnsub?.();
13111
13276
  this.instance?.anim?.destroy();
13112
13277
  this.instance = null;
@@ -13139,6 +13304,9 @@ class SketchmarkEmbed {
13139
13304
  this.btnRestart.disabled = true;
13140
13305
  this.btnPrev.disabled = true;
13141
13306
  this.btnNext.disabled = true;
13307
+ this.btnPlay.textContent = "Play";
13308
+ this.btnPlay.classList.remove("is-active");
13309
+ this.btnPlay.setAttribute("aria-pressed", "false");
13142
13310
  this.btnPlay.disabled = true;
13143
13311
  return;
13144
13312
  }
@@ -13147,7 +13315,10 @@ class SketchmarkEmbed {
13147
13315
  this.btnRestart.disabled = false;
13148
13316
  this.btnPrev.disabled = !anim.canPrev;
13149
13317
  this.btnNext.disabled = !anim.canNext;
13150
- this.btnPlay.disabled = this.playInFlight || !anim.canNext;
13318
+ this.btnPlay.textContent = this.playInFlight ? "Stop" : "Play";
13319
+ this.btnPlay.classList.toggle("is-active", this.playInFlight);
13320
+ this.btnPlay.setAttribute("aria-pressed", this.playInFlight ? "true" : "false");
13321
+ this.btnPlay.disabled = this.playInFlight ? false : !anim.canNext;
13151
13322
  }
13152
13323
  syncViewControls() {
13153
13324
  const hasView = !!this.instance?.svg;