@markdy/renderer-dom 0.8.0 → 0.8.2

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/README.md CHANGED
@@ -8,7 +8,9 @@ Web Animations API renderer for [MarkdyScript](../../docs/SYNTAX.md) scenes. Tra
8
8
  - **Auto-layout diagrams** — renders positioned nodes and orthogonal, obstacle-aware edges from a compiled `RenderPlan`
9
9
  - **Flow edges** — `->` request, `<-` response, `~>` event, `--` dependency, each with its own stroke, plus a pulse that travels the edge as it draws
10
10
  - **Beat-driven cues** — `show`, `hide`, `glow`, and `focus`, sequenced by named beats
11
+ - **Semantic node cards** — compact SVG glyphs for browsers, services, gateways, queues, workers, databases, storage, CDN, security, platform, and more
11
12
  - **Seek-safe** — manual `currentTime` control enables reliable `seek()` in any direction
13
+ - **Playback-rate controls** — set timeline speed to slow down or speed up diagrams without rebuilding animations
12
14
  - **Semantic themes** — `midnight` and `paper`, with per-role node colors
13
15
  - **Single dependency** — only `@markdy/core`
14
16
 
@@ -68,7 +70,9 @@ player.destroy(); // clean up DOM + cancel animations
68
70
  | `autoplay` | `boolean` | `true` | Start playing immediately |
69
71
  | `loop` | `boolean` | `true` | Loop the animation when it reaches the end |
70
72
  | `copyright` | `boolean` | `true` | Show a small "Powered by Markdy" badge below the animation |
71
- | `progressBar` | `boolean` | `true` | Show a rainbow progress bar around the viewport border |
73
+ | `progressBar` | `boolean` | `true` | Deprecated compatibility flag for the rainbow scene-boundary progress bar |
74
+ | `sceneBoundaryProgress` | `boolean` | `progressBar ?? true` | Preferred flag for the rainbow scene-boundary progress bar |
75
+ | `playbackRate` | `number` | `1` | Timeline speed multiplier |
72
76
  | `onWarning` | `(warning: Diagnostic) => void` | `console.warn` | Called for each soft parse warning |
73
77
  | `onTimeUpdate` | `(seconds: number, durationSeconds: number) => void` | — | Called whenever playback or seek changes the current time |
74
78
  | `onPlayStateChange` | `(playing: boolean) => void` | — | Called when playback starts or pauses |
@@ -80,6 +84,8 @@ player.destroy(); // clean up DOM + cancel animations
80
84
  | `play()` | Start or resume playback |
81
85
  | `pause()` | Pause at current position |
82
86
  | `seek(seconds)` | Jump to a specific time |
87
+ | `setPlaybackRate(rate)` | Change timeline speed; ignores non-positive or non-finite values |
88
+ | `playbackRate()` | Current timeline speed multiplier |
83
89
  | `currentTime()` | Current playback position in seconds |
84
90
  | `duration()` | Total scene duration in seconds |
85
91
  | `isPlaying()` | Whether the scene is currently playing |
package/dist/index.d.ts CHANGED
@@ -10,7 +10,12 @@ interface PlayerOptions {
10
10
  autoplay?: boolean;
11
11
  loop?: boolean;
12
12
  copyright?: boolean;
13
+ /** @deprecated Prefer sceneBoundaryProgress. */
13
14
  progressBar?: boolean;
15
+ /** Show rainbow progress around scene boundary. Defaults to true. */
16
+ sceneBoundaryProgress?: boolean;
17
+ /** Playback speed multiplier. Defaults to 1. */
18
+ playbackRate?: number;
14
19
  onWarning?: (warning: Diagnostic) => void;
15
20
  onTimeUpdate?: (seconds: number, durationSeconds: number) => void;
16
21
  onPlayStateChange?: (playing: boolean) => void;
@@ -19,6 +24,8 @@ interface Player {
19
24
  play(): void;
20
25
  pause(): void;
21
26
  seek(seconds: number): void;
27
+ setPlaybackRate(rate: number): void;
28
+ playbackRate(): number;
22
29
  currentTime(): number;
23
30
  duration(): number;
24
31
  isPlaying(): boolean;
package/dist/index.js CHANGED
@@ -62,25 +62,57 @@ function polylineLength(points) {
62
62
  function segmentLength(a, b) {
63
63
  return Math.hypot(b.x - a.x, b.y - a.y);
64
64
  }
65
- function labelPointForPath(points, lane) {
66
- if (points.length < 2) return points[0] ?? { x: 0, y: 0 };
65
+ var LABEL_BOX_HEIGHT = 16;
66
+ function rectsOverlap(a, b) {
67
+ return a.x1 < b.x2 && a.x2 > b.x1 && a.y1 < b.y2 && a.y2 > b.y1;
68
+ }
69
+ function overlapCount(rect, obstacles) {
70
+ let hits = 0;
71
+ for (const o of obstacles) if (rectsOverlap(rect, o)) hits++;
72
+ return hits;
73
+ }
74
+ function placeFlowLabel(points, textWidth, obstacles, bounds) {
67
75
  let bestIndex = 0;
68
76
  let bestLength = -1;
69
77
  for (let i = 0; i < points.length - 1; i++) {
70
78
  const len = segmentLength(points[i], points[i + 1]);
71
79
  if (len > bestLength) {
72
- bestIndex = i;
73
80
  bestLength = len;
81
+ bestIndex = i;
74
82
  }
75
83
  }
76
- const a = points[bestIndex];
77
- const b = points[bestIndex + 1];
84
+ const a = points[bestIndex] ?? { x: 0, y: 0 };
85
+ const b = points[bestIndex + 1] ?? a;
78
86
  const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
79
- const offset = lane * 8;
80
- if (Math.abs(a.x - b.x) >= Math.abs(a.y - b.y)) {
81
- return { x: round1(mid.x), y: round1(mid.y - 10 - offset) };
87
+ const horizontal = Math.abs(a.x - b.x) >= Math.abs(a.y - b.y);
88
+ const half = textWidth / 2;
89
+ const halfH = LABEL_BOX_HEIGHT / 2;
90
+ const pad = 8;
91
+ const base = horizontal ? 14 : half + 12;
92
+ const step = horizontal ? LABEL_BOX_HEIGHT + 4 : textWidth + 12;
93
+ const order = horizontal ? [-1, 1] : [1, -1];
94
+ const offsets = [];
95
+ for (let k = 0; k < 8; k++) {
96
+ for (const sign of order) offsets.push(sign * (base + k * step));
97
+ }
98
+ let fallback = null;
99
+ let fallbackHits = Number.POSITIVE_INFINITY;
100
+ for (const off of offsets) {
101
+ const cx = clamp(horizontal ? mid.x : mid.x + off, pad + half, bounds.width - pad - half);
102
+ const cy = clamp(horizontal ? mid.y + off : mid.y, pad + halfH, bounds.height - pad - halfH);
103
+ const rect = { x1: cx - half, y1: cy - halfH, x2: cx + half, y2: cy + halfH };
104
+ const hits = overlapCount(rect, obstacles);
105
+ if (hits === 0) return { x: round1(cx), y: round1(cy), rect };
106
+ if (hits < fallbackHits) {
107
+ fallbackHits = hits;
108
+ fallback = { x: round1(cx), y: round1(cy), rect };
109
+ }
82
110
  }
83
- return { x: round1(mid.x + 10 + offset), y: round1(mid.y) };
111
+ return fallback ?? {
112
+ x: round1(mid.x),
113
+ y: round1(mid.y),
114
+ rect: { x1: mid.x - half, y1: mid.y - halfH, x2: mid.x + half, y2: mid.y + halfH }
115
+ };
84
116
  }
85
117
  function clamp(n, min, max) {
86
118
  if (max < min) return n;
@@ -214,11 +246,11 @@ function ensureDefs(svg, theme, id) {
214
246
  }
215
247
  svg.prepend(defs);
216
248
  }
217
- function createEdgeRuntime(svg, from, to, kind, label, theme, sceneId, obstacles, bounds) {
249
+ function createEdgeRuntime(svg, from, to, kind, label, theme, sceneId, routeObstacles, labelObstacles, bounds, lane) {
218
250
  ensureDefs(svg, theme, sceneId);
219
251
  const color = theme.edges[kind];
220
252
  const style = EDGE_STYLES[kind];
221
- const points = dedupePoints(routeOrthogonal(boxRect(from), boxRect(to), obstacles, bounds));
253
+ const points = dedupePoints(routeOrthogonal(boxRect(from), boxRect(to), routeObstacles, bounds, lane));
222
254
  const d = toPathD(points);
223
255
  const len = polylineLength(points);
224
256
  const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
@@ -243,21 +275,29 @@ function createEdgeRuntime(svg, from, to, kind, label, theme, sceneId, obstacles
243
275
  dot.style.filter = `drop-shadow(0 0 6px ${color})`;
244
276
  group.append(path, dot);
245
277
  let labelEl;
278
+ let labelRect;
246
279
  if (label) {
247
- const mid = labelPointForPath(points, 0);
280
+ const textWidth = label.length * 6.6 + 10;
281
+ const placement = placeFlowLabel(points, textWidth, labelObstacles, bounds);
282
+ labelRect = placement.rect;
248
283
  labelEl = document.createElementNS("http://www.w3.org/2000/svg", "text");
249
- labelEl.setAttribute("x", String(mid.x));
250
- labelEl.setAttribute("y", String(mid.y));
284
+ labelEl.setAttribute("x", String(placement.x));
285
+ labelEl.setAttribute("y", String(placement.y));
251
286
  labelEl.setAttribute("text-anchor", "middle");
287
+ labelEl.setAttribute("dominant-baseline", "middle");
252
288
  labelEl.setAttribute("font-size", "11");
253
289
  labelEl.setAttribute("font-family", "ui-monospace, SFMono-Regular, Menlo, monospace");
254
290
  labelEl.setAttribute("fill", theme.textMuted);
291
+ labelEl.setAttribute("stroke", theme.canvas);
292
+ labelEl.setAttribute("stroke-width", "3");
293
+ labelEl.setAttribute("paint-order", "stroke");
294
+ labelEl.setAttribute("stroke-linejoin", "round");
255
295
  labelEl.textContent = label;
256
296
  labelEl.style.opacity = "0";
257
297
  group.appendChild(labelEl);
258
298
  }
259
299
  svg.appendChild(group);
260
- return { group, path, label: labelEl, dot, pathLen: len, points };
300
+ return { group, path, label: labelEl, dot, pathLen: len, points, labelRect };
261
301
  }
262
302
  function dotTravelKeyframes(points) {
263
303
  const total = polylineLength(points);
@@ -309,6 +349,9 @@ function buildCueAnimations(cues, nodeEls, nodes, theme, scene, titleEl, bounds)
309
349
  const anims = [];
310
350
  const nodeById = new Map(nodes.map((n) => [n.id, n]));
311
351
  const rectById = new Map(nodes.map((n) => [n.id, boxRect(n)]));
352
+ const allNodeRects = [...rectById.values()];
353
+ const placedLabels = [];
354
+ const laneByPair = /* @__PURE__ */ new Map();
312
355
  const svg = ensureEdgeLayer(scene);
313
356
  const sceneId = `md-${Math.random().toString(36).slice(2, 8)}`;
314
357
  anims.push(
@@ -376,11 +419,16 @@ function buildCueAnimations(cues, nodeEls, nodes, theme, scene, titleEl, bounds)
376
419
  const from = nodeById.get(seg.from);
377
420
  const to = nodeById.get(seg.to);
378
421
  if (!from || !to) continue;
379
- const obstacles = [];
422
+ const routeObstacles = [];
380
423
  for (const [id, rect] of rectById) {
381
- if (id !== seg.from && id !== seg.to) obstacles.push(rect);
424
+ if (id !== seg.from && id !== seg.to) routeObstacles.push(rect);
382
425
  }
383
- const runtime = createEdgeRuntime(svg, from, to, seg.op, seg.label, theme, sceneId, obstacles, bounds);
426
+ const pairKey = [seg.from, seg.to].sort().join("|");
427
+ const lane = laneByPair.get(pairKey) ?? 0;
428
+ laneByPair.set(pairKey, lane + 1);
429
+ const labelObstacles = [...allNodeRects, ...placedLabels];
430
+ const runtime = createEdgeRuntime(svg, from, to, seg.op, seg.label, theme, sceneId, routeObstacles, labelObstacles, bounds, lane);
431
+ if (runtime.labelRect) placedLabels.push(runtime.labelRect);
384
432
  anims.push(...animateEdgeReveal(runtime, startMs, durMs));
385
433
  }
386
434
  }
@@ -400,7 +448,7 @@ function ensureNodeStyles(doc) {
400
448
  box-sizing: border-box;
401
449
  width: var(--md-node-w, 184px);
402
450
  height: var(--md-node-h, 88px);
403
- border-radius: 16px;
451
+ border-radius: 14px;
404
452
  border: 1px solid var(--md-border);
405
453
  background: linear-gradient(145deg, var(--md-surface-raised), var(--md-surface));
406
454
  color: var(--md-text);
@@ -431,15 +479,41 @@ function ensureNodeStyles(doc) {
431
479
  border-radius: 16px 0 0 16px;
432
480
  }
433
481
  .markdy-node__type {
434
- padding: 10px 14px 0 18px;
435
- font-size: 10px;
436
- font-weight: 700;
437
- letter-spacing: 0.08em;
438
- text-transform: uppercase;
439
- color: var(--md-text-muted);
482
+ display: none;
483
+ }
484
+ .markdy-node__body {
485
+ height: 100%;
486
+ padding: 0 14px 0 18px;
487
+ display: flex;
488
+ align-items: center;
489
+ gap: 10px;
490
+ min-width: 0;
491
+ }
492
+ .markdy-node__icon {
493
+ flex: 0 0 auto;
494
+ width: 30px;
495
+ height: 30px;
496
+ border-radius: 10px;
497
+ display: flex;
498
+ align-items: center;
499
+ justify-content: center;
500
+ color: var(--md-role-color, var(--md-accent));
501
+ background: color-mix(in srgb, var(--md-role-color, var(--md-accent)) 16%, transparent);
502
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--md-role-color, var(--md-accent)) 28%, transparent) inset;
503
+ }
504
+ .markdy-node__icon svg {
505
+ width: 18px;
506
+ height: 18px;
507
+ display: block;
508
+ stroke: currentColor;
509
+ }
510
+ .markdy-node[data-icon="decision"] .markdy-node__icon,
511
+ .markdy-node[data-icon="flow"] .markdy-node__icon {
512
+ border-radius: 999px;
440
513
  }
441
514
  .markdy-node__label {
442
- padding: 2px 14px 0 18px;
515
+ min-width: 0;
516
+ padding: 0;
443
517
  font-size: 15px;
444
518
  font-weight: 650;
445
519
  line-height: 1.25;
@@ -471,6 +545,139 @@ function ensureNodeStyles(doc) {
471
545
  `;
472
546
  doc.head.appendChild(style);
473
547
  }
548
+ var ICONS = {
549
+ compute: [
550
+ ["rect", { x: "5", y: "5", width: "14", height: "14", rx: "3" }],
551
+ ["path", { d: "M9 9h6v6H9zM9 2.5v2.5M15 2.5v2.5M9 19v2.5M15 19v2.5M2.5 9h2.5M2.5 15h2.5M19 9h2.5M19 15h2.5" }]
552
+ ],
553
+ user: [
554
+ ["circle", { cx: "12", cy: "8", r: "3.4" }],
555
+ ["path", { d: "M5.5 20a6.5 6.5 0 0 1 13 0" }]
556
+ ],
557
+ browser: [
558
+ ["rect", { x: "3", y: "5", width: "18", height: "14", rx: "2.5" }],
559
+ ["path", { d: "M3 9h18" }],
560
+ ["path", { d: "M7 7h.01M10 7h.01" }]
561
+ ],
562
+ service: [
563
+ ["path", { d: "M12 3 4.5 7.2 12 11.4l7.5-4.2L12 3Z" }],
564
+ ["path", { d: "M4.5 12 12 16.2 19.5 12" }],
565
+ ["path", { d: "M4.5 16.8 12 21l7.5-4.2" }]
566
+ ],
567
+ gateway: [
568
+ ["circle", { cx: "5", cy: "12", r: "2" }],
569
+ ["circle", { cx: "19", cy: "6", r: "2" }],
570
+ ["circle", { cx: "19", cy: "18", r: "2" }],
571
+ ["path", { d: "M7 12h4l5.5-5" }],
572
+ ["path", { d: "M11 12l5.5 5" }]
573
+ ],
574
+ queue: [
575
+ ["rect", { x: "5", y: "5", width: "14", height: "4", rx: "1.4" }],
576
+ ["rect", { x: "5", y: "10", width: "14", height: "4", rx: "1.4" }],
577
+ ["rect", { x: "5", y: "15", width: "14", height: "4", rx: "1.4" }]
578
+ ],
579
+ worker: [
580
+ ["circle", { cx: "12", cy: "12", r: "3.2" }],
581
+ ["path", { d: "M12 2.8v3M12 18.2v3M2.8 12h3M18.2 12h3M5.5 5.5l2.1 2.1M16.4 16.4l2.1 2.1M18.5 5.5l-2.1 2.1M7.6 16.4l-2.1 2.1" }]
582
+ ],
583
+ database: [
584
+ ["ellipse", { cx: "12", cy: "5.5", rx: "7", ry: "3" }],
585
+ ["path", { d: "M5 5.5v10c0 1.7 3.1 3 7 3s7-1.3 7-3v-10" }],
586
+ ["path", { d: "M5 10.5c0 1.7 3.1 3 7 3s7-1.3 7-3" }]
587
+ ],
588
+ storage: [
589
+ ["path", { d: "M4 8.5 12 4l8 4.5v7L12 20l-8-4.5v-7Z" }],
590
+ ["path", { d: "M4 8.5 12 13l8-4.5" }],
591
+ ["path", { d: "M12 13v7" }]
592
+ ],
593
+ cdn: [
594
+ ["circle", { cx: "12", cy: "12", r: "8" }],
595
+ ["path", { d: "M4 12h16M12 4c2.2 2.1 3.3 4.8 3.3 8S14.2 17.9 12 20M12 4c-2.2 2.1-3.3 4.8-3.3 8S9.8 17.9 12 20" }]
596
+ ],
597
+ cache: [
598
+ ["path", { d: "M13 2 5 13h6l-1 9 9-13h-6l0-7Z" }]
599
+ ],
600
+ code: [
601
+ ["path", { d: "m9 18-6-6 6-6" }],
602
+ ["path", { d: "m15 6 6 6-6 6" }],
603
+ ["path", { d: "m14 4-4 16" }]
604
+ ],
605
+ messaging: [
606
+ ["path", { d: "M4 6h16v10H8l-4 4V6Z" }],
607
+ ["path", { d: "M8 10h8M8 13h5" }]
608
+ ],
609
+ network: [
610
+ ["circle", { cx: "6", cy: "7", r: "2" }],
611
+ ["circle", { cx: "18", cy: "7", r: "2" }],
612
+ ["circle", { cx: "12", cy: "18", r: "2" }],
613
+ ["path", { d: "M8 8.5 11 16M16 8.5 13 16M8 7h8" }]
614
+ ],
615
+ platform: [
616
+ ["rect", { x: "4", y: "5", width: "16", height: "14", rx: "2.5" }],
617
+ ["path", { d: "M8 9h8M8 13h8M8 17h4" }]
618
+ ],
619
+ security: [
620
+ ["path", { d: "M12 3 5 6v5c0 4.5 3 7.7 7 10 4-2.3 7-5.5 7-10V6l-7-3Z" }],
621
+ ["path", { d: "M9.5 12.5 11.2 14 15 10" }]
622
+ ],
623
+ delivery: [
624
+ ["path", { d: "M4 7h10" }],
625
+ ["path", { d: "M4 12h16" }],
626
+ ["path", { d: "M4 17h10" }],
627
+ ["path", { d: "m16 7 4 5-4 5" }]
628
+ ],
629
+ observability: [
630
+ ["path", { d: "M4 14s2.5-5 8-5 8 5 8 5-2.5 5-8 5-8-5-8-5Z" }],
631
+ ["circle", { cx: "12", cy: "14", r: "2.5" }]
632
+ ],
633
+ distributed: [
634
+ ["circle", { cx: "6", cy: "12", r: "2.5" }],
635
+ ["circle", { cx: "18", cy: "6", r: "2.5" }],
636
+ ["circle", { cx: "18", cy: "18", r: "2.5" }],
637
+ ["path", { d: "M8.4 11 15.6 7M8.4 13 15.6 17" }]
638
+ ],
639
+ flow: [
640
+ ["path", { d: "M5 12h14" }],
641
+ ["path", { d: "m13 6 6 6-6 6" }]
642
+ ],
643
+ decision: [
644
+ ["path", { d: "M12 3 21 12 12 21 3 12 12 3Z" }],
645
+ ["path", { d: "M12 8v4" }],
646
+ ["path", { d: "M12 16h.01" }]
647
+ ]
648
+ };
649
+ function iconKeyForNode(node) {
650
+ if (node.kind === "api_gateway" || node.kind === "gateway" || node.kind === "load_balancer" || node.kind === "ingress") return "gateway";
651
+ if (node.kind === "db" || node.kind === "database" || node.kind === "sql" || node.kind === "nosql" || node.kind === "warehouse") return "database";
652
+ if (node.kind === "bucket" || node.kind === "object_store" || node.kind === "blob" || node.kind === "volume" || node.kind === "disk") return "storage";
653
+ if (node.kind === "cdn" || node.kind === "dns" || node.kind === "internet") return "cdn";
654
+ if (node.kind === "queue" || node.kind === "topic" || node.kind === "stream" || node.kind === "event_bus" || node.kind === "broker") return "queue";
655
+ if (node.kind === "worker" || node.kind === "job" || node.kind === "scheduler" || node.kind === "cron" || node.kind === "batch") return "worker";
656
+ if (node.kind === "browser" || node.kind === "web" || node.kind === "frontend" || node.kind === "app") return "browser";
657
+ if (node.kind === "user" || node.kind === "client") return "user";
658
+ if (node.kind === "decision" || node.kind === "condition") return "decision";
659
+ if (node.kind === "cache") return "cache";
660
+ return ICONS[node.kind] ? node.kind : node.role;
661
+ }
662
+ function createIconEl(doc, node) {
663
+ const wrap = doc.createElement("div");
664
+ wrap.className = "markdy-node__icon";
665
+ wrap.setAttribute("aria-hidden", "true");
666
+ const svg = doc.createElementNS("http://www.w3.org/2000/svg", "svg");
667
+ svg.setAttribute("viewBox", "0 0 24 24");
668
+ svg.setAttribute("fill", "none");
669
+ svg.setAttribute("stroke-width", "2");
670
+ svg.setAttribute("stroke-linecap", "round");
671
+ svg.setAttribute("stroke-linejoin", "round");
672
+ const spec = ICONS[iconKeyForNode(node)] ?? ICONS.service;
673
+ for (const [tag, attrs] of spec) {
674
+ const child = doc.createElementNS("http://www.w3.org/2000/svg", tag);
675
+ for (const [name, value] of Object.entries(attrs)) child.setAttribute(name, value);
676
+ svg.appendChild(child);
677
+ }
678
+ wrap.appendChild(svg);
679
+ return wrap;
680
+ }
474
681
  function createNodeEl(node, theme) {
475
682
  const el = document.createElement("div");
476
683
  el.className = "markdy-node markdy-scene-actor";
@@ -482,15 +689,24 @@ function createNodeEl(node, theme) {
482
689
  el.style.setProperty("--md-node-h", `${node.height}px`);
483
690
  const roleColor = theme.roles[node.role] ?? theme.accent;
484
691
  el.style.setProperty("--md-role-color", roleColor);
692
+ const typeText = node.kind.replace(/_/g, " ");
693
+ el.dataset.kind = node.kind;
694
+ el.dataset.icon = iconKeyForNode(node);
695
+ el.title = `${node.label} (${typeText})`;
696
+ el.setAttribute("aria-label", el.title);
485
697
  const rail = document.createElement("div");
486
698
  rail.className = "markdy-node__rail";
487
699
  const type = document.createElement("div");
488
700
  type.className = "markdy-node__type";
489
- type.textContent = node.kind.replace(/_/g, " ");
701
+ type.textContent = typeText;
702
+ const body = document.createElement("div");
703
+ body.className = "markdy-node__body";
704
+ const icon = createIconEl(document, node);
490
705
  const label = document.createElement("div");
491
706
  label.className = "markdy-node__label";
492
707
  label.textContent = node.label;
493
- el.append(rail, type, label);
708
+ body.append(icon, label);
709
+ el.append(rail, type, body);
494
710
  return el;
495
711
  }
496
712
  function createTitleEl(title) {
@@ -567,11 +783,14 @@ function createPlayer(opts) {
567
783
  autoplay = true,
568
784
  loop = true,
569
785
  copyright = true,
570
- progressBar = true,
786
+ progressBar,
787
+ sceneBoundaryProgress,
788
+ playbackRate: initialPlaybackRate = 1,
571
789
  onWarning = (w) => console.warn(`[markdy] line ${w.line}: ${w.message}`),
572
790
  onTimeUpdate,
573
791
  onPlayStateChange
574
792
  } = opts;
793
+ const showSceneBoundaryProgress = sceneBoundaryProgress ?? progressBar ?? true;
575
794
  const { ast, plan } = parseAndCompile(code);
576
795
  for (const w of ast.diagnostics) {
577
796
  if (w.severity === "warning") onWarning(w);
@@ -587,7 +806,7 @@ function createPlayer(opts) {
587
806
  });
588
807
  container.appendChild(viewport);
589
808
  let progressEl = null;
590
- if (progressBar) {
809
+ if (showSceneBoundaryProgress) {
591
810
  progressEl = document.createElement("div");
592
811
  Object.assign(progressEl.style, {
593
812
  position: "absolute",
@@ -678,6 +897,7 @@ function createPlayer(opts) {
678
897
  anim.currentTime = 0;
679
898
  }
680
899
  let sceneMs = 0;
900
+ let playbackRate = Number.isFinite(initialPlaybackRate) && initialPlaybackRate > 0 ? initialPlaybackRate : 1;
681
901
  let lastRafTs = null;
682
902
  let isPlaying = false;
683
903
  let rafId = null;
@@ -686,7 +906,7 @@ function createPlayer(opts) {
686
906
  onTimeUpdate?.(sceneMs / 1e3, durationSeconds);
687
907
  }
688
908
  function rafTick(timestamp) {
689
- if (lastRafTs !== null) sceneMs += timestamp - lastRafTs;
909
+ if (lastRafTs !== null) sceneMs += (timestamp - lastRafTs) * playbackRate;
690
910
  lastRafTs = timestamp;
691
911
  if (totalDurationMs > 0 && sceneMs >= totalDurationMs) {
692
912
  if (loop) sceneMs = sceneMs % totalDurationMs;
@@ -725,6 +945,13 @@ function createPlayer(opts) {
725
945
  applyCurrentTime();
726
946
  if (totalDurationMs > 0) updateProgressBar(sceneMs / totalDurationMs);
727
947
  },
948
+ setPlaybackRate(rate) {
949
+ if (!Number.isFinite(rate) || rate <= 0) return;
950
+ playbackRate = rate;
951
+ },
952
+ playbackRate() {
953
+ return playbackRate;
954
+ },
728
955
  currentTime() {
729
956
  return sceneMs / 1e3;
730
957
  },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@markdy/renderer-dom",
3
- "version": "0.8.0",
4
- "description": "Web Animations API renderer for MarkdyScript scenes.",
3
+ "version": "0.8.2",
4
+ "description": "Browser renderer for animated MarkdyScript architecture diagrams, built on the Web Animations API.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "sideEffects": false,
@@ -20,14 +20,14 @@
20
20
  },
21
21
  "keywords": [
22
22
  "markdy",
23
- "animation",
23
+ "animated-diagram",
24
+ "architecture-diagram",
25
+ "diagram-as-code",
24
26
  "web-animations-api",
25
27
  "renderer",
26
- "dom",
27
- "declarative-animation",
28
- "motion",
29
- "framer-motion-alternative",
30
- "gsap-alternative"
28
+ "mermaid-alternative",
29
+ "svg",
30
+ "motion"
31
31
  ],
32
32
  "author": "Hoang Yell <hoangyell@gmail.com> (https://hoangyell.com)",
33
33
  "homepage": "https://markdy.com",
@@ -43,14 +43,14 @@
43
43
  "access": "public"
44
44
  },
45
45
  "dependencies": {
46
- "@markdy/core": "0.8.0"
46
+ "@markdy/core": "0.8.2"
47
47
  },
48
48
  "devDependencies": {
49
49
  "jsdom": "^29.1.1",
50
50
  "tsup": "^8.5.1",
51
51
  "typescript": "^5.9.3",
52
52
  "vitest": "^4.1.7",
53
- "@markdy/stdlib-systems": "0.8.0"
53
+ "@markdy/stdlib-systems": "0.8.2"
54
54
  },
55
55
  "scripts": {
56
56
  "build": "tsup",