@cardenelabs/cdl 0.5.0

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 (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +343 -0
  3. package/SPEC.md +374 -0
  4. package/dist/index.cjs +28085 -0
  5. package/dist/index.cjs.map +1 -0
  6. package/dist/index.d.cts +3415 -0
  7. package/dist/index.d.ts +3415 -0
  8. package/dist/index.js +27962 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/react.cjs +23043 -0
  11. package/dist/react.cjs.map +1 -0
  12. package/dist/react.d.cts +2 -0
  13. package/dist/react.d.ts +2 -0
  14. package/dist/react.js +23041 -0
  15. package/dist/react.js.map +1 -0
  16. package/dist/render-C78lIXeC.d.cts +2449 -0
  17. package/dist/render-C78lIXeC.d.ts +2449 -0
  18. package/examples/quick-start.md +88 -0
  19. package/package.json +79 -0
  20. package/src/anim/core/easing.ts +77 -0
  21. package/src/anim/core/timeline.ts +335 -0
  22. package/src/anim/core/types.ts +42 -0
  23. package/src/anim/index.ts +20 -0
  24. package/src/anim/react/index.ts +3 -0
  25. package/src/anim/react/useReducedMotion.ts +27 -0
  26. package/src/anim/react/useTimeline.ts +48 -0
  27. package/src/assert-never.ts +16 -0
  28. package/src/author-intent-verify.ts +587 -0
  29. package/src/builder.ts +3740 -0
  30. package/src/compile.ts +12 -0
  31. package/src/dom-verify-core.ts +121 -0
  32. package/src/dom-verify-types.ts +21 -0
  33. package/src/dom-verify.ts +948 -0
  34. package/src/event-handler/index.ts +184 -0
  35. package/src/formula/ast.ts +46 -0
  36. package/src/formula/evaluator.ts +163 -0
  37. package/src/formula/formula-computeds.ts +83 -0
  38. package/src/formula/index.ts +5 -0
  39. package/src/formula/parser.ts +399 -0
  40. package/src/index.ts +284 -0
  41. package/src/input-signals.ts +74 -0
  42. package/src/kinds/actor.tsx +79 -0
  43. package/src/kinds/card.tsx +61 -0
  44. package/src/kinds/chart-bar.tsx +121 -0
  45. package/src/kinds/chart-line.tsx +163 -0
  46. package/src/kinds/chart-pie.tsx +107 -0
  47. package/src/kinds/compact-title.ts +164 -0
  48. package/src/kinds/dyn-shape.tsx +394 -0
  49. package/src/kinds/event.tsx +70 -0
  50. package/src/kinds/function.tsx +53 -0
  51. package/src/kinds/funnel.tsx +122 -0
  52. package/src/kinds/gantt.tsx +193 -0
  53. package/src/kinds/generic.tsx +368 -0
  54. package/src/kinds/mind-map.tsx +367 -0
  55. package/src/kinds/mind-radial.tsx +209 -0
  56. package/src/kinds/node-tone.ts +18 -0
  57. package/src/kinds/quadrant.tsx +164 -0
  58. package/src/kinds/row-align.ts +179 -0
  59. package/src/kinds/shape-basement.tsx +683 -0
  60. package/src/kinds/shape-blockchain.tsx +750 -0
  61. package/src/kinds/shape-commerce.tsx +553 -0
  62. package/src/kinds/shape-finance.tsx +706 -0
  63. package/src/kinds/shape-hardware.tsx +862 -0
  64. package/src/kinds/shape-people.tsx +439 -0
  65. package/src/kinds/shape-region.tsx +383 -0
  66. package/src/kinds/shape-software.tsx +859 -0
  67. package/src/kinds/storage.tsx +180 -0
  68. package/src/kinds/text-width.ts +73 -0
  69. package/src/kinds/tree.tsx +275 -0
  70. package/src/kinds/user-journey.tsx +240 -0
  71. package/src/label-text.ts +71 -0
  72. package/src/layout/clearance-constants.ts +47 -0
  73. package/src/layout/collisions.ts +2078 -0
  74. package/src/layout/edges.ts +2498 -0
  75. package/src/layout/footer-shape.ts +97 -0
  76. package/src/layout/geometry.ts +135 -0
  77. package/src/layout/label-shift.ts +28 -0
  78. package/src/layout/lanes.ts +328 -0
  79. package/src/layout/nodes.ts +190 -0
  80. package/src/layout/predict-bbox.ts +76 -0
  81. package/src/layout/px-projection.ts +176 -0
  82. package/src/layout/spec.ts +872 -0
  83. package/src/layout/text-width.ts +133 -0
  84. package/src/layout/tokens.ts +181 -0
  85. package/src/layout/viewbox.ts +47 -0
  86. package/src/layout-with-validation.ts +175 -0
  87. package/src/layout.ts +381 -0
  88. package/src/presets.ts +2108 -0
  89. package/src/reactive/batch.ts +48 -0
  90. package/src/reactive/computed.ts +85 -0
  91. package/src/reactive/effect.ts +145 -0
  92. package/src/reactive/index.ts +6 -0
  93. package/src/reactive/internal.ts +109 -0
  94. package/src/reactive/signal.ts +113 -0
  95. package/src/render/edges.tsx +318 -0
  96. package/src/render/header.tsx +146 -0
  97. package/src/render/interactive-panel.tsx +9620 -0
  98. package/src/render/nodes.tsx +319 -0
  99. package/src/render/stage.tsx +377 -0
  100. package/src/render/tone.ts +64 -0
  101. package/src/render/utils.ts +238 -0
  102. package/src/render.tsx +221 -0
  103. package/src/scroll-trigger/index.ts +109 -0
  104. package/src/scroll-trigger/progress.ts +49 -0
  105. package/src/thumbnail.tsx +114 -0
  106. package/src/types.ts +2371 -0
  107. package/src/validate.ts +268 -0
  108. package/src/visual-validate.ts +4381 -0
@@ -0,0 +1,88 @@
1
+ # cdl quick-start examples
2
+
3
+ cdl は engine 層 — 通常は [dragon](https://github.com/cardene777/dragon) 経由で使う。 但し builder API を直接使うと 細かい制御 (49 kind / dyn-shape / phase 制御 / interactive signal) が可能。
4
+
5
+ ## 1. シンプル sequence
6
+
7
+ ```tsx
8
+ import { sequence, CdlDiagramView } from "@cardenelabs/cdl";
9
+
10
+ const login = sequence({
11
+ id: "login",
12
+ topic: "User Login",
13
+ actors: ["User", "API", "DB"],
14
+ })
15
+ .step({ from: "User", to: "API", label: "POST /login" })
16
+ .step({ from: "API", to: "DB", label: "SELECT" })
17
+ .step({ from: "DB", to: "API", label: "rows", tone: "success" })
18
+ .step({ from: "API", to: "User", label: "200 OK", tone: "success" })
19
+ .build();
20
+
21
+ export default function Demo() {
22
+ return <CdlDiagramView diagram={login} />;
23
+ }
24
+ ```
25
+
26
+ ## 2. dyn-wave gauge (液面 metaphor)
27
+
28
+ ```tsx
29
+ import { diagram, CdlDiagramView } from "@cardenelabs/cdl";
30
+
31
+ const tank = diagram("tank", { topic: "貯水タンク水位" })
32
+ .lane("l", { x: 0, width: 400 })
33
+ .state("water", { initial: 100 })
34
+ .node("t", {
35
+ lane: "l", stack: 0, kind: "dyn-wave", title: "タンク", subtitle: "{water}%",
36
+ w: 380, h: 400,
37
+ shape: { kind: "wave", level: "{water}", amplitude: 100, frequency: 2.5, waveHeight: 10, fill: "#4e9dc4" },
38
+ })
39
+ .phase("p1", { duration: 3000, title: "水位低下", body: "" }, (p) =>
40
+ p.activate("t").tween("water", 100, 30).badge("使用中")
41
+ )
42
+ .phase("p2", { duration: 2000, title: "補充", body: "" }, (p) =>
43
+ p.activate("t").tween("water", 30, 100).badge("満水")
44
+ )
45
+ .build();
46
+ ```
47
+
48
+ ## 3. interactive slider
49
+
50
+ ```tsx
51
+ import { diagram, CdlDiagramView } from "@cardenelabs/cdl";
52
+
53
+ const priceGauge = diagram("price", { topic: "価格 slider" })
54
+ .input.slider("price", { min: 0, max: 1000, defaultValue: 100, label: "価格" })
55
+ .lane("l", { x: 0, width: 400 })
56
+ .node("g", {
57
+ lane: "l", stack: 0, kind: "dyn-arc", title: "価格 gauge", subtitle: "{price} 円",
58
+ w: 380, h: 380,
59
+ shape: { kind: "arc", angle: "{price}", sweepMax: 1000, outerRadius: 140, innerRadius: 100, fill: "#4e9dc4" },
60
+ })
61
+ .phase("p", { duration: 3000, title: "slider で操作", body: "" }, (p) => p.activate("g"))
62
+ .build();
63
+ ```
64
+
65
+ ## 4. React に埋込み
66
+
67
+ ```tsx
68
+ import { CdlDiagramView } from "@cardenelabs/cdl";
69
+ // ...
70
+ <CdlDiagramView diagram={myDiagram} hideHeader /> // header 非表示
71
+ <CdlDiagramView diagram={myDiagram} debug /> // 座標 debug 表示
72
+ ```
73
+
74
+ ## 5. compile from JSON
75
+
76
+ ```ts
77
+ import { compile, CdlDiagramView } from "@cardenelabs/cdl";
78
+
79
+ const spec = { /* CdlDiagramSpec JSON */ };
80
+ const diagram = compile(spec);
81
+ ```
82
+
83
+ ## 参考
84
+
85
+ - SPEC.md: cdl 記法仕様書
86
+ - README: 12 preset + shape kind 一覧
87
+ - dragon: `@cardenelabs/dragon` — YAML/JSON 記法 wrapper
88
+ - ISSUES: https://github.com/cardene777/cdl/issues
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "@cardenelabs/cdl",
3
+ "version": "0.5.0",
4
+ "description": "CDL (Chainome Diagram Language). Mermaid-like declarative DSL that compiles to animated SVG diagrams. Built for blockchain / Solidity flows, generic enough for sequence / flow / state / ER / topology diagrams.",
5
+ "license": "MIT",
6
+ "author": "cardene777",
7
+ "homepage": "https://github.com/cardene777/cdl",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/cardene777/cdl.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/cardene777/cdl/issues"
14
+ },
15
+ "keywords": [
16
+ "diagram",
17
+ "animation",
18
+ "svg",
19
+ "mermaid-alternative",
20
+ "solidity",
21
+ "solidity-diagram",
22
+ "blockchain",
23
+ "yaml-dsl",
24
+ "text-dsl",
25
+ "sequence-diagram",
26
+ "react",
27
+ "typescript"
28
+ ],
29
+ "type": "module",
30
+ "main": "./dist/index.cjs",
31
+ "module": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "exports": {
34
+ ".": {
35
+ "types": "./dist/index.d.ts",
36
+ "import": "./dist/index.js",
37
+ "require": "./dist/index.cjs"
38
+ },
39
+ "./react": {
40
+ "types": "./dist/react.d.ts",
41
+ "import": "./dist/react.js",
42
+ "require": "./dist/react.cjs"
43
+ }
44
+ },
45
+ "files": [
46
+ "dist",
47
+ "src",
48
+ "examples",
49
+ "README.md",
50
+ "SPEC.md"
51
+ ],
52
+ "publishConfig": {
53
+ "access": "public"
54
+ },
55
+ "engines": {
56
+ "node": ">=18"
57
+ },
58
+ "sideEffects": false,
59
+ "scripts": {
60
+ "build": "tsup",
61
+ "dev": "tsup --watch",
62
+ "typecheck": "tsc --noEmit",
63
+ "test": "cd ../.. && vitest run packages/cdl",
64
+ "lint": "tsc --noEmit",
65
+ "prepublishOnly": "pnpm run typecheck && pnpm run build"
66
+ },
67
+ "peerDependencies": {
68
+ "react": "^19.0.0",
69
+ "react-dom": "^19.0.0"
70
+ },
71
+ "devDependencies": {
72
+ "@types/react": "^19.1.0",
73
+ "react": "^19.1.0",
74
+ "tsup": "^8.5.1",
75
+ "typedoc": "^0.28.19",
76
+ "typedoc-plugin-markdown": "^4.12.0",
77
+ "typescript": "^5.8.0"
78
+ }
79
+ }
@@ -0,0 +1,77 @@
1
+ import type { EasingFn } from "./types";
2
+
3
+ /**
4
+ * Cubic Bezier easing を 0..1 input から 0..1 output へ近似する自作実装。
5
+ * P0 = (0, 0)、 P3 = (1, 1) 固定、 P1 = (x1, y1)、 P2 = (x2, y2)。
6
+ *
7
+ * Newton-Raphson + binary subdivision で t を求める。
8
+ * tolerance = 1e-5 (60fps 描画には十分)
9
+ */
10
+ export function cubicBezier(x1: number, y1: number, x2: number, y2: number): EasingFn {
11
+ const A = (a: number, b: number) => 1.0 - 3.0 * b + 3.0 * a;
12
+ const B = (a: number, b: number) => 3.0 * b - 6.0 * a;
13
+ const C = (a: number) => 3.0 * a;
14
+
15
+ const sampleCurveX = (t: number) => ((A(x1, x2) * t + B(x1, x2)) * t + C(x1)) * t;
16
+ const sampleCurveY = (t: number) => ((A(y1, y2) * t + B(y1, y2)) * t + C(y1)) * t;
17
+ const sampleDerivativeX = (t: number) =>
18
+ 3.0 * A(x1, x2) * t * t + 2.0 * B(x1, x2) * t + C(x1);
19
+
20
+ function solveCurveX(x: number): number {
21
+ if (x <= 0) return 0;
22
+ if (x >= 1) return 1;
23
+ // Newton-Raphson 8 iterations
24
+ let t = x;
25
+ for (let i = 0; i < 8; i += 1) {
26
+ const currentX = sampleCurveX(t) - x;
27
+ if (Math.abs(currentX) < 1e-5) return t;
28
+ const currentSlope = sampleDerivativeX(t);
29
+ if (Math.abs(currentSlope) < 1e-5) break;
30
+ t -= currentX / currentSlope;
31
+ }
32
+ // Fallback to bisection
33
+ let lo = 0;
34
+ let hi = 1;
35
+ let guess = x;
36
+ while (lo < hi) {
37
+ const currentX = sampleCurveX(guess);
38
+ if (Math.abs(currentX - x) < 1e-5) return guess;
39
+ if (x > currentX) lo = guess;
40
+ else hi = guess;
41
+ guess = (hi - lo) / 2 + lo;
42
+ }
43
+ return guess;
44
+ }
45
+
46
+ return (t: number) => {
47
+ if (t <= 0) return 0;
48
+ if (t >= 1) return 1;
49
+ return sampleCurveY(solveCurveX(t));
50
+ };
51
+ }
52
+
53
+ /** Linear (no easing) */
54
+ export const linear: EasingFn = (t) => t;
55
+
56
+ /** Standard easings (CSS spec aligned) */
57
+ export const easeInOut = cubicBezier(0.42, 0, 0.58, 1);
58
+ export const easeOut = cubicBezier(0, 0, 0.58, 1);
59
+ export const easeIn = cubicBezier(0.42, 0, 1, 1);
60
+
61
+ /** "Spring out" feel without an actual spring solver, good for UI reveals */
62
+ export const easeOutBack = cubicBezier(0.34, 1.56, 0.64, 1);
63
+
64
+ /** Smooth deceleration; recommended default for phase transitions */
65
+ export const easeOutQuint = cubicBezier(0.22, 1, 0.36, 1);
66
+
67
+ /** Clamp a number to [0, 1] */
68
+ export function clamp01(value: number): number {
69
+ if (value < 0) return 0;
70
+ if (value > 1) return 1;
71
+ return value;
72
+ }
73
+
74
+ /** Linear interpolation */
75
+ export function lerp(start: number, end: number, t: number): number {
76
+ return start + (end - start) * t;
77
+ }
@@ -0,0 +1,335 @@
1
+ import { clamp01, easeOutQuint } from "./easing";
2
+ import type {
3
+ EasingFn,
4
+ PhaseDescriptor,
5
+ PhaseId,
6
+ TimelineState,
7
+ TimelineStatus,
8
+ } from "./types";
9
+
10
+ /**
11
+ * 自作 Timeline
12
+ *
13
+ * 設計方針。
14
+ * - requestAnimationFrame ベースの単一 raf loop で進行
15
+ * - 「Phase 間の遷移」 のみが時系列、 Phase 内は同期 0..1 補間値を提供
16
+ * - Subscriber 一覧に毎フレームの状態を push、 view 側で interpolate して描画
17
+ * - 完全独立、 React / DOM 依存ゼロ (テスト + node でも動かせる作りに後で寄せる)
18
+ *
19
+ * 利用パターン (典型)。
20
+ * const t = new Timeline([{ id: "call", label: "Call" }, ...]);
21
+ * const unsub = t.subscribe((state) => { ... });
22
+ * t.goto(0); // Phase 0 へ
23
+ * t.play(); // 自動進行開始
24
+ * t.next(); // 1 つ進める
25
+ * t.prev(); // 1 つ戻る
26
+ * unsub();
27
+ * t.destroy();
28
+ */
29
+
30
+ export type TimelineOptions = {
31
+ /** 自動進行時の Phase 間 wait (ms)、 デフォルト Phase の durationMs に従う */
32
+ autoAdvanceMs?: number;
33
+ /** Phase 内補間の easing、 デフォルト easeOutQuint */
34
+ easing?: EasingFn;
35
+ /** loop 再生するか (settled 後に最初に戻る)、 デフォルト false */
36
+ loop?: boolean;
37
+ /** 初期 Phase index、 デフォルト 0 (= 最初の Phase) */
38
+ initialPhaseIndex?: number;
39
+ /** Phase 完了後、 次の Phase に進む前の hold ms (Phase の意味を見せる間)、 デフォルト 0 */
40
+ phaseHoldMs?: number;
41
+ /** loop 時、 最後の Phase が終わってから loop 先頭に戻るまでの wait ms、 デフォルト phaseHoldMs と同じ */
42
+ loopRestartDelayMs?: number;
43
+ /** mount 時に自動で再生開始するか、 デフォルト false */
44
+ autoplay?: boolean;
45
+ };
46
+
47
+ type Subscriber = (state: TimelineState) => void;
48
+
49
+ export class Timeline {
50
+ private readonly phases: PhaseDescriptor[];
51
+ private readonly easing: EasingFn;
52
+ private readonly loop: boolean;
53
+ private readonly autoAdvanceMsOverride: number | undefined;
54
+ private readonly phaseHoldMs: number;
55
+ private readonly loopRestartDelayMs: number;
56
+ private readonly autoplay: boolean;
57
+ private readonly subscribers = new Set<Subscriber>();
58
+ private holdTimerHandle: ReturnType<typeof setTimeout> | null = null;
59
+
60
+ private rafHandle: number | null = null;
61
+ private phaseIndex = 0;
62
+ private phaseProgress = 0;
63
+ private phaseStartedAt = 0;
64
+ private status: TimelineStatus = "idle";
65
+ /**
66
+ * snapshot を「state が変わった時のみ」 新 object として返すためのキャッシュ。
67
+ * useSyncExternalStore は getSnapshot() の戻り値が前回と参照同値の時に
68
+ * re-render を skip する設計なので、 これがないと毎 frame 別 object で
69
+ * 無限 render → React #185 になる。
70
+ */
71
+ private cachedSnapshot: TimelineState | null = null;
72
+ /**
73
+ * constructor 直後の state snapshot。 destroy() で復元して StrictMode 二重 mount 経路の
74
+ * autostart() が正しく初期状態から再開できるようにする (initialPhaseIndex + autoplay 組合せ保持)。
75
+ */
76
+ private readonly initialState: {
77
+ phaseIndex: number;
78
+ phaseProgress: number;
79
+ status: TimelineStatus;
80
+ };
81
+
82
+ constructor(phases: PhaseDescriptor[], options: TimelineOptions = {}) {
83
+ if (phases.length === 0) {
84
+ throw new Error("Timeline requires at least one phase");
85
+ }
86
+ this.phases = phases;
87
+ this.easing = options.easing ?? easeOutQuint;
88
+ this.loop = options.loop ?? false;
89
+ this.autoAdvanceMsOverride = options.autoAdvanceMs;
90
+ this.phaseHoldMs = options.phaseHoldMs ?? 0;
91
+ this.loopRestartDelayMs = options.loopRestartDelayMs ?? this.phaseHoldMs;
92
+ this.autoplay = options.autoplay ?? false;
93
+
94
+ const initial = options.initialPhaseIndex;
95
+ if (initial != null && initial > 0 && initial < phases.length) {
96
+ this.phaseIndex = initial;
97
+ this.phaseProgress = 1;
98
+ this.status = initial === phases.length - 1 ? "settled" : "paused";
99
+ }
100
+ this.initialState = {
101
+ phaseIndex: this.phaseIndex,
102
+ phaseProgress: this.phaseProgress,
103
+ status: this.status,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * mount 後に呼んで autoplay opt-in が真なら play を発火する。
109
+ * constructor で呼ぶと SSR 環境で rAF が動いて失敗するので別経路に分離。
110
+ */
111
+ autostart(): void {
112
+ if (this.autoplay && this.status === "idle") {
113
+ this.play();
114
+ }
115
+ }
116
+
117
+ // ─── public API ──────────────────────────────────────────────
118
+
119
+ subscribe(subscriber: Subscriber): () => void {
120
+ // 登録時に即時呼び出すと React 19 の useSyncExternalStore で
121
+ // Maximum update depth exceeded (#185) になるため、 同期呼び出しはしない。
122
+ // 初期値は subscribe 利用側が getSnapshot で取る契約。
123
+ this.subscribers.add(subscriber);
124
+ return () => {
125
+ this.subscribers.delete(subscriber);
126
+ };
127
+ }
128
+
129
+ snapshot(): TimelineState {
130
+ const cached = this.cachedSnapshot;
131
+ if (
132
+ cached &&
133
+ cached.status === this.status &&
134
+ cached.phaseIndex === this.phaseIndex &&
135
+ cached.phaseProgress === this.phaseProgress &&
136
+ cached.totalPhases === this.phases.length
137
+ ) {
138
+ return cached;
139
+ }
140
+ const next: TimelineState = {
141
+ status: this.status,
142
+ phaseIndex: this.phaseIndex,
143
+ phaseProgress: this.phaseProgress,
144
+ totalPhases: this.phases.length,
145
+ };
146
+ this.cachedSnapshot = next;
147
+ return next;
148
+ }
149
+
150
+ getPhase(): PhaseDescriptor {
151
+ // phaseIndex は constructor で phases.length >= 1 を保証しているため
152
+ // 必ず有効なインデックスを返す
153
+ const phase = this.phases[this.phaseIndex];
154
+ if (!phase) {
155
+ throw new Error(`Invalid phase index ${this.phaseIndex}`);
156
+ }
157
+ return phase;
158
+ }
159
+
160
+ /** Phase ID 指定でジャンプ (見つからない場合は no-op) */
161
+ gotoId(id: PhaseId): void {
162
+ const index = this.phases.findIndex((phase) => phase.id === id);
163
+ if (index >= 0) this.goto(index);
164
+ }
165
+
166
+ /** Phase index 指定でジャンプ */
167
+ goto(index: number): void {
168
+ const clamped = Math.max(0, Math.min(this.phases.length - 1, index));
169
+ this.cancelRaf();
170
+ this.phaseIndex = clamped;
171
+ this.phaseProgress = 1;
172
+ this.status = clamped === this.phases.length - 1 ? "settled" : "paused";
173
+ this.emit();
174
+ }
175
+
176
+ play(): void {
177
+ if (this.status === "playing") return;
178
+ this.status = "playing";
179
+ if (this.phaseProgress >= 1 && this.phaseIndex < this.phases.length - 1) {
180
+ this.advancePhase();
181
+ } else {
182
+ this.beginPhaseTransition();
183
+ }
184
+ this.emit();
185
+ }
186
+
187
+ pause(): void {
188
+ if (this.status !== "playing") return;
189
+ this.cancelRaf();
190
+ this.status = "paused";
191
+ this.emit();
192
+ }
193
+
194
+ next(): void {
195
+ if (this.phaseIndex >= this.phases.length - 1) {
196
+ if (this.loop) {
197
+ this.goto(0);
198
+ this.play();
199
+ }
200
+ return;
201
+ }
202
+ this.cancelRaf();
203
+ this.phaseIndex += 1;
204
+ this.beginPhaseTransition();
205
+ if (this.status !== "playing") {
206
+ this.status = "paused";
207
+ }
208
+ this.emit();
209
+ }
210
+
211
+ prev(): void {
212
+ if (this.phaseIndex <= 0) return;
213
+ this.cancelRaf();
214
+ this.phaseIndex -= 1;
215
+ this.phaseProgress = 1;
216
+ this.status = "paused";
217
+ this.emit();
218
+ }
219
+
220
+ reset(): void {
221
+ this.cancelRaf();
222
+ this.phaseIndex = 0;
223
+ this.phaseProgress = 0;
224
+ this.status = "idle";
225
+ this.emit();
226
+ }
227
+
228
+ /**
229
+ * cleanup + reset for-remount。 subscribers.clear() + cancelRaf() で active resource を
230
+ * 全解放した後、 (phaseIndex, phaseProgress, status) を constructor 直後の initialState に
231
+ * 復元する。 API 名は "destroy" だが object dead / GC-ready 契約ではなく、 destroy 後も
232
+ * subscribe / autostart / snapshot / play が正しく動作する reusable reset object として設計。
233
+ *
234
+ * 主 caller = React `useEffect` の cleanup で、 StrictMode の dev 二重 mount 経路で
235
+ * setup 再実行時に autostart() が正しく再発火するよう status を initialState に戻す。
236
+ *
237
+ * 詳細 = active edge が pathSubpath(d, 0) = "M x y" のみで空 line 描画される
238
+ * regression (dragon Issue #381) を防ぐため、 destroy() は「破棄」 でなく「再利用可能な reset」。
239
+ */
240
+ destroy(): void {
241
+ this.cancelRaf();
242
+ this.subscribers.clear();
243
+ this.status = this.initialState.status;
244
+ this.phaseIndex = this.initialState.phaseIndex;
245
+ this.phaseProgress = this.initialState.phaseProgress;
246
+ }
247
+
248
+ // ─── internals ───────────────────────────────────────────────
249
+
250
+ private advancePhase(): void {
251
+ if (this.phaseIndex >= this.phases.length - 1) {
252
+ if (this.loop) {
253
+ // 最後の Phase 完了 → 先頭に戻る (loopRestartDelayMs hold)
254
+ this.scheduleHold(this.loopRestartDelayMs, () => {
255
+ this.phaseIndex = 0;
256
+ this.beginPhaseTransition();
257
+ });
258
+ } else {
259
+ this.status = "settled";
260
+ this.emit();
261
+ }
262
+ return;
263
+ }
264
+ // 次 Phase へ (phaseHoldMs hold)
265
+ this.scheduleHold(this.phaseHoldMs, () => {
266
+ this.phaseIndex += 1;
267
+ this.beginPhaseTransition();
268
+ });
269
+ }
270
+
271
+ private scheduleHold(ms: number, after: () => void): void {
272
+ if (this.holdTimerHandle != null) {
273
+ clearTimeout(this.holdTimerHandle);
274
+ this.holdTimerHandle = null;
275
+ }
276
+ if (ms <= 0) {
277
+ after();
278
+ return;
279
+ }
280
+ this.holdTimerHandle = setTimeout(() => {
281
+ this.holdTimerHandle = null;
282
+ if (this.status === "playing") after();
283
+ }, ms);
284
+ }
285
+
286
+ private beginPhaseTransition(): void {
287
+ this.phaseProgress = 0;
288
+ this.phaseStartedAt = performance.now();
289
+ this.cancelRaf();
290
+ const step = (now: number) => {
291
+ const duration =
292
+ this.autoAdvanceMsOverride ?? this.getPhase().durationMs ?? 600;
293
+ const elapsed = now - this.phaseStartedAt;
294
+ const linearT = clamp01(elapsed / duration);
295
+ this.phaseProgress = this.easing(linearT);
296
+ this.emit();
297
+ if (linearT < 1) {
298
+ this.rafHandle = requestAnimationFrame(step);
299
+ return;
300
+ }
301
+ this.phaseProgress = 1;
302
+ this.emit();
303
+ if (this.status === "playing") {
304
+ // 自動進行中は次 Phase へ
305
+ if (this.phaseIndex < this.phases.length - 1) {
306
+ this.advancePhase();
307
+ } else if (this.loop) {
308
+ this.advancePhase();
309
+ } else {
310
+ this.status = "settled";
311
+ this.emit();
312
+ }
313
+ }
314
+ };
315
+ this.rafHandle = requestAnimationFrame(step);
316
+ }
317
+
318
+ private cancelRaf(): void {
319
+ if (this.rafHandle != null) {
320
+ cancelAnimationFrame(this.rafHandle);
321
+ this.rafHandle = null;
322
+ }
323
+ if (this.holdTimerHandle != null) {
324
+ clearTimeout(this.holdTimerHandle);
325
+ this.holdTimerHandle = null;
326
+ }
327
+ }
328
+
329
+ private emit(): void {
330
+ const snapshot = this.snapshot();
331
+ for (const subscriber of this.subscribers) {
332
+ subscriber(snapshot);
333
+ }
334
+ }
335
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Phase = アニメーションの 1 ステップ。
3
+ * 学習用アニメーションは通常「処理が時系列で進む」 構成のため、
4
+ * 連続した時間軸ではなく、 ユーザーが任意で進める / 戻れる Phase 単位を採用する。
5
+ */
6
+ export type PhaseId = string;
7
+
8
+ export type PhaseDescriptor = {
9
+ id: PhaseId;
10
+ /** 表示名 (UI のタイムラインに出る短いラベル) */
11
+ label: string;
12
+ /** Phase の説明文 */
13
+ description?: string;
14
+ /** Phase の進入アニメ完了までの目安 ms。 デフォルト 600ms */
15
+ durationMs?: number;
16
+ };
17
+
18
+ /**
19
+ * 自作の easing 関数群。
20
+ * 入力 0..1 を受け取り 0..1 を返す。
21
+ */
22
+ export type EasingFn = (t: number) => number;
23
+
24
+ /**
25
+ * Timeline 内部状態。
26
+ * - status ... ユーザー操作の結果としての timeline の意図状態
27
+ * - idle ... 未開始 (まだ Phase 0 に未到達)
28
+ * - playing ... 自動進行中
29
+ * - paused ... 自動進行を停止
30
+ * - settled ... 全 Phase 終了して停止
31
+ */
32
+ export type TimelineStatus = "idle" | "playing" | "paused" | "settled";
33
+
34
+ export type TimelineState = {
35
+ status: TimelineStatus;
36
+ /** 現在 Phase index */
37
+ phaseIndex: number;
38
+ /** 現在 Phase 内の進行度 (0..1)、 視覚要素の補間に使う */
39
+ phaseProgress: number;
40
+ /** 全 Phase 数 */
41
+ totalPhases: number;
42
+ };
@@ -0,0 +1,20 @@
1
+ export { Timeline } from "./core/timeline";
2
+ export type { TimelineOptions } from "./core/timeline";
3
+ export type {
4
+ EasingFn,
5
+ PhaseDescriptor,
6
+ PhaseId,
7
+ TimelineState,
8
+ TimelineStatus,
9
+ } from "./core/types";
10
+ export {
11
+ clamp01,
12
+ cubicBezier,
13
+ easeIn,
14
+ easeInOut,
15
+ easeOut,
16
+ easeOutBack,
17
+ easeOutQuint,
18
+ lerp,
19
+ linear,
20
+ } from "./core/easing";
@@ -0,0 +1,3 @@
1
+ export { useTimeline } from "./useTimeline";
2
+ export type { UseTimelineReturn } from "./useTimeline";
3
+ export { useReducedMotion } from "./useReducedMotion";
@@ -0,0 +1,27 @@
1
+ import { useSyncExternalStore } from "react";
2
+
3
+ const MEDIA_QUERY = "(prefers-reduced-motion: reduce)";
4
+
5
+ function getSnapshot(): boolean {
6
+ if (typeof window === "undefined" || !window.matchMedia) return false;
7
+ return window.matchMedia(MEDIA_QUERY).matches;
8
+ }
9
+
10
+ function subscribe(callback: () => void): () => void {
11
+ if (typeof window === "undefined" || !window.matchMedia) return () => {};
12
+ const mql = window.matchMedia(MEDIA_QUERY);
13
+ mql.addEventListener("change", callback);
14
+ return () => mql.removeEventListener("change", callback);
15
+ }
16
+
17
+ function getServerSnapshot(): boolean {
18
+ return false;
19
+ }
20
+
21
+ /**
22
+ * `prefers-reduced-motion: reduce` を react で購読する hook。
23
+ * true を返したら animation の duration を 0 にする / 即座に終端へ飛ばす等の対応を呼び出し側で行う。
24
+ */
25
+ export function useReducedMotion(): boolean {
26
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
27
+ }