sketchmark 1.3.7 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -359,6 +359,39 @@ function isValueToken(t) {
359
359
  function isPropKeyToken(t) {
360
360
  return !!t && (t.type === "IDENT" || t.type === "KEYWORD");
361
361
  }
362
+ const NUMBER_RE = /[-+]?(?:\d*\.\d+|\d+)(?:[eE][-+]?\d+)?/g;
363
+ function parseEdgeWaypoints(value, token) {
364
+ if (!value)
365
+ return undefined;
366
+ const numbers = (value.match(NUMBER_RE) ?? []).map((part) => Number(part));
367
+ if (!numbers.length)
368
+ return undefined;
369
+ if (numbers.length % 2 !== 0) {
370
+ throw new ParseError(`Edge via must contain x,y coordinate pairs`, token.line, token.col);
371
+ }
372
+ const points = [];
373
+ for (let index = 0; index < numbers.length; index += 2) {
374
+ const x = numbers[index];
375
+ const y = numbers[index + 1];
376
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
377
+ throw new ParseError(`Edge via contains a non-numeric coordinate`, token.line, token.col);
378
+ }
379
+ points.push([x, y]);
380
+ }
381
+ return points.length ? points : undefined;
382
+ }
383
+ function normalizeEdgeRoute(value, token) {
384
+ if (!value)
385
+ return undefined;
386
+ const normalized = value.toLowerCase();
387
+ if (normalized === "straight" || normalized === "polyline" || normalized === "orthogonal") {
388
+ return normalized;
389
+ }
390
+ if (normalized === "ortho" || normalized === "elbow") {
391
+ return "orthogonal";
392
+ }
393
+ throw new ParseError(`Unsupported edge route "${value}"; use straight, orthogonal, or polyline`, token.line, token.col);
394
+ }
362
395
  function parse(src, options = {}) {
363
396
  resetUid();
364
397
  const preparedSource = applyPluginPreprocessors(src, options.plugins);
@@ -717,28 +750,63 @@ function parse(src, options = {}) {
717
750
  height: props.height !== undefined ? parseFloat(props.height) : undefined,
718
751
  };
719
752
  }
720
- function parseEdge(fromId, connector, rest) {
721
- const toTok = rest.shift();
722
- if (!toTok)
723
- throw new ParseError("Expected edge target", 0, 0);
753
+ function parseEdgeProps(toks) {
724
754
  const props = {};
725
755
  let j = 0;
726
- while (j < rest.length) {
727
- const t = rest[j];
728
- if ((t.type === "IDENT" || t.type === "KEYWORD") &&
729
- j + 1 < rest.length &&
730
- rest[j + 1].type === "EQUALS") {
731
- props[t.value] = rest[j + 2]?.value ?? "";
732
- j += 3;
756
+ while (j < toks.length) {
757
+ const key = toks[j];
758
+ const eq = toks[j + 1];
759
+ if (!isPropKeyToken(key) || eq?.type !== "EQUALS") {
760
+ j++;
761
+ continue;
733
762
  }
734
- else {
763
+ const value = toks[j + 2];
764
+ if (!value) {
735
765
  j++;
766
+ continue;
736
767
  }
768
+ if (value.type === "LBRACKET") {
769
+ const parts = [];
770
+ let depth = 1;
771
+ j += 3;
772
+ while (j < toks.length && depth > 0) {
773
+ const tok = toks[j];
774
+ if (tok.type === "LBRACKET") {
775
+ depth++;
776
+ }
777
+ else if (tok.type === "RBRACKET") {
778
+ depth--;
779
+ if (depth === 0) {
780
+ j++;
781
+ break;
782
+ }
783
+ }
784
+ if (depth > 0)
785
+ parts.push(tok.value);
786
+ j++;
787
+ }
788
+ if (depth > 0) {
789
+ throw new ParseError(`Unterminated edge property list; expected ']'`, key.line, key.col);
790
+ }
791
+ props[key.value] = parts.join(" ");
792
+ continue;
793
+ }
794
+ props[key.value] = value.value;
795
+ j += 3;
737
796
  }
797
+ return props;
798
+ }
799
+ function parseEdge(fromId, connector, rest) {
800
+ const toTok = rest.shift();
801
+ if (!toTok)
802
+ throw new ParseError("Expected edge target", 0, 0);
803
+ const props = parseEdgeProps(rest);
738
804
  const dashed = connector.includes("--") ||
739
805
  connector.includes(".-") ||
740
806
  connector.includes("-.");
741
807
  const bidirectional = connector.includes("<") && connector.includes(">");
808
+ const via = parseEdgeWaypoints(props.via, toTok);
809
+ const route = normalizeEdgeRoute(props.route, toTok) ?? (via?.length ? "polyline" : undefined);
742
810
  return {
743
811
  kind: "edge",
744
812
  id: uid("edge"),
@@ -750,6 +818,8 @@ function parse(src, options = {}) {
750
818
  labelDy: props["label-dy"] !== undefined ? parseFloat(props["label-dy"]) : undefined,
751
819
  fromAnchor: props["anchor-from"],
752
820
  toAnchor: props["anchor-to"],
821
+ route,
822
+ via,
753
823
  dashed,
754
824
  bidirectional,
755
825
  style: propsToStyle(props),
@@ -3683,6 +3753,8 @@ function buildSceneGraph(ast) {
3683
3753
  labelDy: e.labelDy,
3684
3754
  fromAnchor: e.fromAnchor,
3685
3755
  toAnchor: e.toAnchor,
3756
+ route: e.route,
3757
+ via: e.via,
3686
3758
  dashed: e.dashed ?? false,
3687
3759
  bidirectional: e.bidirectional ?? false,
3688
3760
  style: e.style ?? {},
@@ -4284,6 +4356,151 @@ function getConnPoint(src, dstCX, dstCY, anchor) {
4284
4356
  return anchoredConnPoint(src, anchor, dstCX, dstCY);
4285
4357
  }
4286
4358
  // ── Group depth (for paint order) ────────────────────────────────────────
4359
+ function segmentLength(a, b) {
4360
+ return Math.hypot(b[0] - a[0], b[1] - a[1]);
4361
+ }
4362
+ function compactPolylinePoints(points) {
4363
+ const compacted = [];
4364
+ for (const point of points) {
4365
+ const previous = compacted[compacted.length - 1];
4366
+ if (!previous || segmentLength(previous, point) > 0.01) {
4367
+ compacted.push(point);
4368
+ }
4369
+ }
4370
+ return compacted;
4371
+ }
4372
+ function polylinePathData(points) {
4373
+ return points
4374
+ .map(([x, y], index) => `${index === 0 ? "M" : "L"} ${x} ${y}`)
4375
+ .join(" ");
4376
+ }
4377
+ function polylineEndpointDirection(points, end) {
4378
+ const step = end === "start" ? 1 : -1;
4379
+ let index = end === "start" ? 0 : points.length - 1;
4380
+ while (index + step >= 0 && index + step < points.length) {
4381
+ const from = points[index];
4382
+ const to = points[index + step];
4383
+ const dx = to[0] - from[0];
4384
+ const dy = to[1] - from[1];
4385
+ const len = Math.hypot(dx, dy);
4386
+ if (len > 0.01) {
4387
+ return end === "start" ? [dx / len, dy / len] : [-dx / len, -dy / len];
4388
+ }
4389
+ index += step;
4390
+ }
4391
+ return [1, 0];
4392
+ }
4393
+ function insetPolylineEndpoints(points, arrowAt, inset) {
4394
+ const next = points.map((point) => [point[0], point[1]]);
4395
+ if (next.length < 2)
4396
+ return next;
4397
+ if (arrowAt === "start" || arrowAt === "both") {
4398
+ const [dx, dy] = polylineEndpointDirection(next, "start");
4399
+ next[0] = [next[0][0] + dx * inset, next[0][1] + dy * inset];
4400
+ }
4401
+ if (arrowAt === "end" || arrowAt === "both") {
4402
+ const [dx, dy] = polylineEndpointDirection(next, "end");
4403
+ const last = next.length - 1;
4404
+ next[last] = [next[last][0] - dx * inset, next[last][1] - dy * inset];
4405
+ }
4406
+ return compactPolylinePoints(next);
4407
+ }
4408
+ function polylineLabelPosition(points, offset, dx = 0, dy = 0) {
4409
+ if (points.length < 2) {
4410
+ const [x, y] = points[0] ?? [0, 0];
4411
+ return { x: x + dx, y: y + dy };
4412
+ }
4413
+ const lengths = points.slice(1).map((point, index) => segmentLength(points[index], point));
4414
+ const total = lengths.reduce((sum, value) => sum + value, 0);
4415
+ if (total <= 0.01) {
4416
+ const [x, y] = points[0];
4417
+ return { x: x + dx, y: y + dy };
4418
+ }
4419
+ let travelled = 0;
4420
+ const target = total / 2;
4421
+ for (let index = 0; index < lengths.length; index += 1) {
4422
+ const length = lengths[index];
4423
+ if (travelled + length >= target) {
4424
+ const from = points[index];
4425
+ const to = points[index + 1];
4426
+ const t = length > 0 ? (target - travelled) / length : 0;
4427
+ const ux = (to[0] - from[0]) / length;
4428
+ const uy = (to[1] - from[1]) / length;
4429
+ return {
4430
+ x: from[0] + (to[0] - from[0]) * t - uy * offset + dx,
4431
+ y: from[1] + (to[1] - from[1]) * t + ux * offset + dy,
4432
+ };
4433
+ }
4434
+ travelled += length;
4435
+ }
4436
+ const [x, y] = points[points.length - 1];
4437
+ return { x: x + dx, y: y + dy };
4438
+ }
4439
+ function rectBoundaryPoint(entity, point, direction) {
4440
+ const [px, py] = point;
4441
+ const [dx, dy] = direction;
4442
+ const candidates = [];
4443
+ const minX = entity.x;
4444
+ const maxX = entity.x + entity.w;
4445
+ const minY = entity.y;
4446
+ const maxY = entity.y + entity.h;
4447
+ const epsilon = 0.01;
4448
+ if (Math.abs(dx) > epsilon) {
4449
+ candidates.push((minX - px) / dx, (maxX - px) / dx);
4450
+ }
4451
+ if (Math.abs(dy) > epsilon) {
4452
+ candidates.push((minY - py) / dy, (maxY - py) / dy);
4453
+ }
4454
+ const valid = candidates
4455
+ .filter((t) => t >= -epsilon)
4456
+ .map((t) => ({
4457
+ t: Math.max(0, t),
4458
+ x: px + dx * t,
4459
+ y: py + dy * t,
4460
+ }))
4461
+ .filter(({ x, y }) => x >= minX - epsilon &&
4462
+ x <= maxX + epsilon &&
4463
+ y >= minY - epsilon &&
4464
+ y <= maxY + epsilon)
4465
+ .sort((a, b) => a.t - b.t);
4466
+ const hit = valid[0];
4467
+ return hit ? [hit.x, hit.y] : point;
4468
+ }
4469
+ function ellipseBoundaryPoint(entity, point, direction) {
4470
+ const [px, py] = point;
4471
+ const [dx, dy] = direction;
4472
+ const cx = entity.x + entity.w / 2;
4473
+ const cy = entity.y + entity.h / 2;
4474
+ const rx = Math.max(1, entity.w * 0.44);
4475
+ const ry = Math.max(1, entity.h * 0.44);
4476
+ const x0 = px - cx;
4477
+ const y0 = py - cy;
4478
+ const a = (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry);
4479
+ const b = 2 * ((x0 * dx) / (rx * rx) + (y0 * dy) / (ry * ry));
4480
+ const c = (x0 * x0) / (rx * rx) + (y0 * y0) / (ry * ry) - 1;
4481
+ const disc = b * b - 4 * a * c;
4482
+ if (a <= 0 || disc < 0)
4483
+ return point;
4484
+ const sqrt = Math.sqrt(disc);
4485
+ const hits = [(-b - sqrt) / (2 * a), (-b + sqrt) / (2 * a)]
4486
+ .filter((t) => t >= -0.01)
4487
+ .sort((left, right) => left - right);
4488
+ const t = Math.max(0, hits[0] ?? 0);
4489
+ return [px + dx * t, py + dy * t];
4490
+ }
4491
+ function polylineArrowTipPoint(entity, points, end) {
4492
+ const point = end === "start" ? points[0] : points[points.length - 1];
4493
+ if (!point)
4494
+ return [0, 0];
4495
+ const [dx, dy] = polylineEndpointDirection(points, end);
4496
+ const outward = end === "start" ? [dx, dy] : [-dx, -dy];
4497
+ if (Math.hypot(outward[0], outward[1]) <= 0.01)
4498
+ return point;
4499
+ if (entity.shape === "circle") {
4500
+ return ellipseBoundaryPoint(entity, point, outward);
4501
+ }
4502
+ return rectBoundaryPoint(entity, point, outward);
4503
+ }
4287
4504
  function groupDepth(g, gm) {
4288
4505
  let d = 0;
4289
4506
  let cur = g;
@@ -5244,6 +5461,31 @@ function rectConnPoint(rx, ry, rw, rh, ox, oy) {
5244
5461
  const t = Math.min(tx, ty);
5245
5462
  return [cx + t * dx, cy + t * dy];
5246
5463
  }
5464
+ function distance$1(a, b) {
5465
+ return Math.hypot(b[0] - a[0], b[1] - a[1]);
5466
+ }
5467
+ function compactEdgePoints(points) {
5468
+ const compacted = [];
5469
+ for (const point of points) {
5470
+ const previous = compacted[compacted.length - 1];
5471
+ if (!previous || distance$1(previous, point) > 0.01) {
5472
+ compacted.push(point);
5473
+ }
5474
+ }
5475
+ return compacted;
5476
+ }
5477
+ function orthogonalEdgePoints(start, end) {
5478
+ if (Math.abs(start[0] - end[0]) < 0.01 || Math.abs(start[1] - end[1]) < 0.01) {
5479
+ return [start, end];
5480
+ }
5481
+ const midX = (start[0] + end[0]) / 2;
5482
+ return compactEdgePoints([
5483
+ start,
5484
+ [midX, start[1]],
5485
+ [midX, end[1]],
5486
+ end,
5487
+ ]);
5488
+ }
5247
5489
  function routeEdges(sg) {
5248
5490
  const nm = nodeMap(sg);
5249
5491
  const tm = tableMap(sg);
@@ -5273,10 +5515,17 @@ function routeEdges(sg) {
5273
5515
  }
5274
5516
  const dstCX = dst.x + dst.w / 2, dstCY = dst.y + dst.h / 2;
5275
5517
  const srcCX = src.x + src.w / 2, srcCY = src.y + src.h / 2;
5276
- e.points = [
5277
- anchoredConnPoint(src, e.fromAnchor, dstCX, dstCY),
5278
- anchoredConnPoint(dst, e.toAnchor, srcCX, srcCY),
5279
- ];
5518
+ const start = anchoredConnPoint(src, e.fromAnchor, dstCX, dstCY);
5519
+ const end = anchoredConnPoint(dst, e.toAnchor, srcCX, srcCY);
5520
+ if (e.via?.length) {
5521
+ e.points = compactEdgePoints([start, ...e.via, end]);
5522
+ }
5523
+ else if (e.route === "orthogonal") {
5524
+ e.points = orthogonalEdgePoints(start, end);
5525
+ }
5526
+ else {
5527
+ e.points = [start, end];
5528
+ }
5280
5529
  }
5281
5530
  }
5282
5531
  function computeBounds(sg, margin) {
@@ -5286,6 +5535,7 @@ function computeBounds(sg, margin) {
5286
5535
  ...sg.tables.map((t) => t.x + t.w),
5287
5536
  ...sg.charts.map((c) => c.x + c.w),
5288
5537
  ...sg.markdowns.map((m) => m.x + m.w),
5538
+ ...sg.edges.flatMap((e) => (e.points ?? []).map(([x]) => x)),
5289
5539
  ];
5290
5540
  const allY = [
5291
5541
  ...sg.nodes.map((n) => n.y + n.h),
@@ -5293,6 +5543,7 @@ function computeBounds(sg, margin) {
5293
5543
  ...sg.tables.map((t) => t.y + t.h),
5294
5544
  ...sg.charts.map((c) => c.y + c.h),
5295
5545
  ...sg.markdowns.map((m) => m.y + m.h),
5546
+ ...sg.edges.flatMap((e) => (e.points ?? []).map(([, y]) => y)),
5296
5547
  ];
5297
5548
  const autoWidth = (allX.length ? Math.max(...allX) : 400) + margin;
5298
5549
  const autoHeight = (allY.length ? Math.max(...allY) : 300) + margin;
@@ -8466,20 +8717,16 @@ function renderToSVG(sg, container, options = {}) {
8466
8717
  const srcCX = src.x + src.w / 2, srcCY = src.y + src.h / 2;
8467
8718
  const [x1, y1] = getConnPoint(src, dstCX, dstCY, e.fromAnchor);
8468
8719
  const [x2, y2] = getConnPoint(dst, srcCX, srcCY, e.toAnchor);
8720
+ const points = compactPolylinePoints(e.points?.length && e.points.length >= 2 ? e.points : [[x1, y1], [x2, y2]]);
8469
8721
  const eg = mkGroup(`edge-${e.from}-${e.to}`, "eg");
8470
8722
  setParentGroupData(eg, resolveEdgeParentGroupId(e.from, e.to, nm, tm, gmMap, cm, parentGroups));
8471
8723
  if (e.style?.opacity != null)
8472
8724
  eg.setAttribute("opacity", String(e.style.opacity));
8473
- const len = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) || 1;
8474
- const nx = (x2 - x1) / len, ny = (y2 - y1) / len;
8475
8725
  const ecol = String(e.style?.stroke ?? palette.edgeStroke);
8476
8726
  const { arrowAt, dashed } = connMeta(e.connector);
8477
8727
  const HEAD = EDGE.headInset;
8478
- const sx1 = arrowAt === "start" || arrowAt === "both" ? x1 + nx * HEAD : x1;
8479
- const sy1 = arrowAt === "start" || arrowAt === "both" ? y1 + ny * HEAD : y1;
8480
- const sx2 = arrowAt === "end" || arrowAt === "both" ? x2 - nx * HEAD : x2;
8481
- const sy2 = arrowAt === "end" || arrowAt === "both" ? y2 - ny * HEAD : y2;
8482
- const shaft = rc.line(sx1, sy1, sx2, sy2, {
8728
+ const shaftPoints = insetPolylineEndpoints(points, arrowAt, HEAD);
8729
+ const shaft = rc.path(polylinePathData(shaftPoints), {
8483
8730
  ...BASE_ROUGH,
8484
8731
  roughness: 0.9,
8485
8732
  seed: hashStr$3(e.from + e.to),
@@ -8490,18 +8737,21 @@ function renderToSVG(sg, container, options = {}) {
8490
8737
  shaft.setAttribute("data-edge-role", "shaft");
8491
8738
  eg.appendChild(shaft);
8492
8739
  if (arrowAt === "end" || arrowAt === "both") {
8493
- const endHead = arrowHead(rc, x2, y2, Math.atan2(y2 - y1, x2 - x1), ecol, hashStr$3(e.to));
8740
+ const [endDx, endDy] = polylineEndpointDirection(points, "end");
8741
+ const [endX, endY] = polylineArrowTipPoint(dst, points, "end");
8742
+ const endHead = arrowHead(rc, endX, endY, Math.atan2(endDy, endDx), ecol, hashStr$3(e.to));
8494
8743
  endHead.setAttribute("data-edge-role", "head");
8495
8744
  eg.appendChild(endHead);
8496
8745
  }
8497
8746
  if (arrowAt === "start" || arrowAt === "both") {
8498
- const startHead = arrowHead(rc, x1, y1, Math.atan2(y1 - y2, x1 - x2), ecol, hashStr$3(e.from + "back"));
8747
+ const [startDx, startDy] = polylineEndpointDirection(points, "start");
8748
+ const [startX, startY] = polylineArrowTipPoint(src, points, "start");
8749
+ const startHead = arrowHead(rc, startX, startY, Math.atan2(-startDy, -startDx), ecol, hashStr$3(e.from + "back"));
8499
8750
  startHead.setAttribute("data-edge-role", "head");
8500
8751
  eg.appendChild(startHead);
8501
8752
  }
8502
8753
  if (e.label) {
8503
- const mx = (x1 + x2) / 2 - ny * EDGE.labelOffset + (e.labelDx ?? 0);
8504
- const my = (y1 + y2) / 2 + nx * EDGE.labelOffset + (e.labelDy ?? 0);
8754
+ const { x: mx, y: my } = polylineLabelPosition(points, EDGE.labelOffset, e.labelDx ?? 0, e.labelDy ?? 0);
8505
8755
  const tw = Math.max(e.label.length * 7 + 12, 36);
8506
8756
  const bg = se("rect");
8507
8757
  bg.setAttribute("x", String(mx - tw / 2));
@@ -9213,31 +9463,31 @@ function renderToCanvas(sg, canvas, options = {}) {
9213
9463
  const srcCX = src.x + src.w / 2, srcCY = src.y + src.h / 2;
9214
9464
  const [x1, y1] = getConnPoint(src, dstCX, dstCY, e.fromAnchor);
9215
9465
  const [x2, y2] = getConnPoint(dst, srcCX, srcCY, e.toAnchor);
9466
+ const points = compactPolylinePoints(e.points?.length && e.points.length >= 2 ? e.points : [[x1, y1], [x2, y2]]);
9216
9467
  if (e.style?.opacity != null)
9217
9468
  ctx.globalAlpha = Number(e.style.opacity);
9218
9469
  const ecol = String(e.style?.stroke ?? palette.edgeStroke);
9219
9470
  const { arrowAt, dashed } = connMeta(e.connector);
9220
- const len = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) || 1;
9221
- const nx = (x2 - x1) / len, ny = (y2 - y1) / len;
9222
9471
  const HEAD = EDGE.headInset;
9223
- const sx1 = arrowAt === 'start' || arrowAt === 'both' ? x1 + nx * HEAD : x1;
9224
- const sy1 = arrowAt === 'start' || arrowAt === 'both' ? y1 + ny * HEAD : y1;
9225
- const sx2 = arrowAt === 'end' || arrowAt === 'both' ? x2 - nx * HEAD : x2;
9226
- const sy2 = arrowAt === 'end' || arrowAt === 'both' ? y2 - ny * HEAD : y2;
9227
- rc.line(sx1, sy1, sx2, sy2, {
9472
+ const shaftPoints = insetPolylineEndpoints(points, arrowAt, HEAD);
9473
+ rc.path(polylinePathData(shaftPoints), {
9228
9474
  ...R, roughness: 0.9, seed: hashStr$3(e.from + e.to),
9229
9475
  stroke: ecol,
9230
9476
  strokeWidth: Number(e.style?.strokeWidth ?? 1.6),
9231
9477
  ...(dashed ? { strokeLineDash: EDGE.dashPattern } : {}),
9232
9478
  });
9233
- const ang = Math.atan2(y2 - y1, x2 - x1);
9234
- if (arrowAt === 'end' || arrowAt === 'both')
9235
- drawArrowHead(rc, x2, y2, ang, ecol, hashStr$3(e.to));
9236
- if (arrowAt === 'start' || arrowAt === 'both')
9237
- drawArrowHead(rc, x1, y1, Math.atan2(y1 - y2, x1 - x2), ecol, hashStr$3(e.from + 'back'));
9479
+ if (arrowAt === 'end' || arrowAt === 'both') {
9480
+ const [endDx, endDy] = polylineEndpointDirection(points, 'end');
9481
+ const [endX, endY] = polylineArrowTipPoint(dst, points, 'end');
9482
+ drawArrowHead(rc, endX, endY, Math.atan2(endDy, endDx), ecol, hashStr$3(e.to));
9483
+ }
9484
+ if (arrowAt === 'start' || arrowAt === 'both') {
9485
+ const [startDx, startDy] = polylineEndpointDirection(points, 'start');
9486
+ const [startX, startY] = polylineArrowTipPoint(src, points, 'start');
9487
+ drawArrowHead(rc, startX, startY, Math.atan2(-startDy, -startDx), ecol, hashStr$3(e.from + 'back'));
9488
+ }
9238
9489
  if (e.label) {
9239
- const mx = (x1 + x2) / 2 - ny * EDGE.labelOffset + (e.labelDx ?? 0);
9240
- const my = (y1 + y2) / 2 + nx * EDGE.labelOffset + (e.labelDy ?? 0);
9490
+ const { x: mx, y: my } = polylineLabelPosition(points, EDGE.labelOffset, e.labelDx ?? 0, e.labelDy ?? 0);
9241
9491
  // ── Edge label: font, font-size, letter-spacing ──
9242
9492
  // always center-anchored (single line)
9243
9493
  const eFontSize = Number(e.style?.fontSize ?? EDGE.labelFontSize);
@@ -9934,7 +10184,7 @@ function animateShapeDraw(el, strokeDur = ANIMATION.nodeStrokeDur, stag = ANIMAT
9934
10184
  }));
9935
10185
  }
9936
10186
  // ── Edge draw helpers ─────────────────────────────────────
9937
- const EDGE_SHAFT_SELECTOR = '[data-edge-role="shaft"] path';
10187
+ const EDGE_SHAFT_SELECTOR = '[data-edge-role="shaft"] path, path[data-edge-role="shaft"]';
9938
10188
  const EDGE_DECOR_SELECTOR = '[data-edge-role="head"], [data-edge-role="label"], [data-edge-role="label-bg"]';
9939
10189
  function edgeShaftPaths(el) {
9940
10190
  return Array.from(el.querySelectorAll(EDGE_SHAFT_SELECTOR));
@@ -11151,8 +11401,11 @@ class AnimationController {
11151
11401
  * 4. After guide finishes → fade in rough.js element, remove guide
11152
11402
  */
11153
11403
  _animateAnnotation(roughEl, guideD, silent) {
11154
- if (silent)
11404
+ if (silent) {
11405
+ roughEl.style.opacity = "1";
11406
+ roughEl.style.transition = "none";
11155
11407
  return;
11408
+ }
11156
11409
  // Hide rough.js element — will be revealed after guide draws
11157
11410
  roughEl.style.opacity = "0";
11158
11411
  roughEl.style.transition = "none";
@@ -11442,10 +11695,74 @@ const ANIMATION_CSS = `
11442
11695
  .skm-caption { pointer-events: none; user-select: none; }
11443
11696
  `;
11444
11697
 
11698
+ const exportAnimationState = new WeakMap();
11699
+ function bindExportAnimationState(svg, state) {
11700
+ exportAnimationState.set(svg, state);
11701
+ }
11702
+ function getExportAnimationState(svg) {
11703
+ return exportAnimationState.get(svg);
11704
+ }
11705
+
11445
11706
  // ============================================================
11446
11707
  // sketchmark — Export System
11447
11708
  // SVG, PNG, Canvas, GIF (stub), MP4 (stub)
11448
11709
  // ============================================================
11710
+ const EXPORT_SVG_STYLE_ID = "sketchmark-export-state";
11711
+ const EXPORT_SVG_STATE_CSS = `
11712
+ .ng, .gg, .tg, .ntg, .cg, .eg, .mdg {
11713
+ animation: none !important;
11714
+ transition: none !important;
11715
+ }
11716
+
11717
+ .ng.hidden { opacity: 0 !important; pointer-events: none !important; }
11718
+ .gg.gg-hidden,
11719
+ .tg.gg-hidden,
11720
+ .ntg.gg-hidden,
11721
+ .cg.gg-hidden,
11722
+ .eg.gg-hidden,
11723
+ .mdg.gg-hidden { opacity: 0 !important; }
11724
+
11725
+ .ng.faded,
11726
+ .gg.faded,
11727
+ .tg.faded,
11728
+ .ntg.faded,
11729
+ .cg.faded,
11730
+ .eg.faded,
11731
+ .mdg.faded { opacity: 0.22 !important; }
11732
+
11733
+ .ng.hl path, .ng.hl rect, .ng.hl ellipse, .ng.hl polygon,
11734
+ .tg.hl path, .tg.hl rect,
11735
+ .ntg.hl path, .ntg.hl polygon,
11736
+ .cg.hl path, .cg.hl rect,
11737
+ .mdg.hl text,
11738
+ .eg.hl path, .eg.hl line, .eg.hl polygon { stroke-width: 2.8 !important; }
11739
+ `;
11740
+ function buildExportSnapshot(svg) {
11741
+ const snapshot = svg.cloneNode(true);
11742
+ const animationState = getExportAnimationState(svg);
11743
+ if (animationState?.steps.length) {
11744
+ snapshot.querySelector("#annotation-layer")?.remove();
11745
+ let rc = null;
11746
+ try {
11747
+ rc = rough.svg(snapshot);
11748
+ }
11749
+ catch {
11750
+ rc = null;
11751
+ }
11752
+ const anim = new AnimationController(snapshot, animationState.steps, undefined, rc, animationState.config);
11753
+ anim.goTo(animationState.steps.length - 1);
11754
+ }
11755
+ injectExportStyles(snapshot);
11756
+ return snapshot;
11757
+ }
11758
+ function injectExportStyles(svg) {
11759
+ if (svg.querySelector(`#${EXPORT_SVG_STYLE_ID}`))
11760
+ return;
11761
+ const style = document.createElementNS(SVG_NS$1, "style");
11762
+ style.setAttribute("id", EXPORT_SVG_STYLE_ID);
11763
+ style.textContent = EXPORT_SVG_STATE_CSS;
11764
+ svg.insertBefore(style, svg.firstChild);
11765
+ }
11449
11766
  // ── Trigger browser download ──────────────────────────────
11450
11767
  function download(blob, filename) {
11451
11768
  const url = URL.createObjectURL(blob);
@@ -11459,15 +11776,15 @@ function download(blob, filename) {
11459
11776
  }
11460
11777
  // ── SVG export ────────────────────────────────────────────
11461
11778
  function exportSVG(svg, opts = {}) {
11462
- const str = svgToString(svg);
11779
+ const str = svgToString(buildExportSnapshot(svg));
11463
11780
  const blob = new Blob([str], { type: 'image/svg+xml;charset=utf-8' });
11464
11781
  download(blob, opts.filename ?? 'diagram.svg');
11465
11782
  }
11466
11783
  function getSVGString(svg) {
11467
- return svgToString(svg);
11784
+ return svgToString(buildExportSnapshot(svg));
11468
11785
  }
11469
11786
  function getSVGBlob(svg) {
11470
- return new Blob([svgToString(svg)], { type: 'image/svg+xml;charset=utf-8' });
11787
+ return new Blob([svgToString(buildExportSnapshot(svg))], { type: 'image/svg+xml;charset=utf-8' });
11471
11788
  }
11472
11789
  // ── PNG export (from SVG via Canvas) ─────────────────────
11473
11790
  async function exportPNG(svg, opts = {}) {
@@ -11493,7 +11810,7 @@ async function svgToPNGDataURL(svg, opts = {}) {
11493
11810
  ctx.fillStyle = EXPORT.fallbackBg;
11494
11811
  ctx.fillRect(0, 0, w, h);
11495
11812
  }
11496
- const svgStr = svgToString(svg);
11813
+ const svgStr = svgToString(buildExportSnapshot(svg));
11497
11814
  const blob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
11498
11815
  const url = URL.createObjectURL(blob);
11499
11816
  await new Promise((resolve, reject) => {
@@ -11511,7 +11828,7 @@ async function exportCanvasPNG(canvas, opts = {}) {
11511
11828
  }
11512
11829
  // ── HTML export (self-contained) ──────────────────────────
11513
11830
  function exportHTML(svg, dslSource, opts = {}) {
11514
- const svgStr = svgToString(svg);
11831
+ const svgStr = svgToString(buildExportSnapshot(svg));
11515
11832
  const html = `<!DOCTYPE html>
11516
11833
  <html lang="en">
11517
11834
  <head>
@@ -11692,6 +12009,7 @@ function render(options) {
11692
12009
  }
11693
12010
  const containerEl = el instanceof SVGSVGElement ? undefined : el;
11694
12011
  anim = new AnimationController(svg, ast.steps, containerEl, rc, ast.config);
12012
+ bindExportAnimationState(svg, { steps: ast.steps, config: ast.config });
11695
12013
  }
11696
12014
  if (typeof tts === "boolean") {
11697
12015
  anim.tts = tts;