@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
package/src/render.tsx ADDED
@@ -0,0 +1,221 @@
1
+ import { useEffect, useMemo } from "react";
2
+ import type { JSX } from "react";
3
+ import { useReducedMotion, useTimeline } from "./anim/react";
4
+ import { compile } from "./compile";
5
+ import { CdlHeader } from "./render/header";
6
+ import { CdlStage } from "./render/stage";
7
+ import {
8
+ hasInteractivePrimitives,
9
+ useInteractiveIntegration,
10
+ type InteractiveHandlerMap,
11
+ } from "./render/interactive-panel";
12
+ import { computeStateValues } from "./render/utils";
13
+ import { visualValidateLaid } from "./visual-validate";
14
+ import type { CdlDiagram, LaidDiagram } from "./types";
15
+
16
+ export type CdlDiagramViewProps = {
17
+ diagram: CdlDiagram;
18
+ /**
19
+ * 配置まで済ませた図。 渡すとここでは組み立て直さない。
20
+ *
21
+ * 呼出側が既に `compile` を呼んでいる場合 (組み立ての結果を検査や図枠の原点に使う等)、
22
+ * ここで測り直すと同じ図の配置を 2 度計算する。 図の規模に比例して重く、 辺 500 本で
23
+ * 約 300ms かかる (実測)。
24
+ *
25
+ * 渡す時は `diagram` と対にする。 別の図の配置を渡すと、 描く内容と位置がずれる。
26
+ * 対応が取れているかはここでは確かめない (確かめる費用が、 省く費用を上回る)。
27
+ */
28
+ laid?: LaidDiagram;
29
+ hideHeader?: boolean;
30
+ /**
31
+ * `hideHeader` 時に下部へ出る phase の小さな指標 (点列 + 現在 phase の題) を出さない。
32
+ *
33
+ * 下流が独自の phase UI を持つ場合に使う。 CSS で消す形にすると、 こちらの class 名や
34
+ * 要素構造が変わった時に黙って効かなくなる (下流の hack が壊れたことに気付けない)。
35
+ */
36
+ hideMiniPhaseIndicator?: boolean;
37
+ focusPhaseId?: string;
38
+ /** debug mode = engine の bbox + collision を半透明で重ね描き (開発確認用) */
39
+ debug?: boolean;
40
+ /**
41
+ * dev warn mode = 生成時に visualValidate を走らせ warn / error を console に流す。
42
+ * default = process.env.NODE_ENV === "development" 相当を caller 側で判定して渡す設計。
43
+ * true 明示指定で production build でも有効化可 (Playwright 実測時など)。
44
+ */
45
+ emitGeometryWarn?: boolean;
46
+ /**
47
+ * CdlHeader 内 phase title の heading tag level (2 | 3 | 4)、 default 3。
48
+ * downstream で h1 直後配置なら 2、 h2 直後なら 3 (default)、 h3 直後なら 4 を渡す。
49
+ * heading order skip を防ぐ用途、 hideHeader=true 時は無視される。
50
+ */
51
+ headingLevel?: 2 | 3 | 4;
52
+ /**
53
+ * interactive event handler 関数 map (CAR #246 / render integration)。
54
+ * diagram.eventBindings が非空の時、 handlerId → (event, signals) => void の map で consumer が
55
+ * signal 更新 etc を実装する。 未指定の場合 event handler は skip される。
56
+ */
57
+ interactiveHandlers?: InteractiveHandlerMap;
58
+ };
59
+
60
+ export function CdlDiagramView({ diagram, laid: laidProp, hideHeader = false, hideMiniPhaseIndicator = false, focusPhaseId, debug = false, emitGeometryWarn = false, headingLevel = 3, interactiveHandlers }: CdlDiagramViewProps): JSX.Element {
61
+ // 呼出側が組み立て済みの配置を持っているなら、 それを使う。 測り直すと同じ図の配置を
62
+ // 2 度計算することになる (辺 500 本で約 300ms、 実測)
63
+ const laid = useMemo(() => laidProp ?? compile(diagram), [diagram, laidProp]);
64
+ const reduced = useReducedMotion();
65
+
66
+ // 生成時 geometry warn = 17 軸 visualValidate を走らせて error / warn を console に流す。
67
+ // React StrictMode の 2 重 mount で console 二重出力する可能性はあるが、 dev only の情報流用途で許容。
68
+ useEffect(() => {
69
+ if (!emitGeometryWarn) return;
70
+ // 上で組み立てた配置を渡す。 渡さない版は中でもう一度配置を計算するため、
71
+ // せっかく `laid` を受け取っても警告を出す時だけ計算が戻る
72
+ const report = visualValidateLaid(laid, diagram);
73
+ if (report.violations.length === 0) return;
74
+ const errors = report.violations.filter((v) => v.severity === "error");
75
+ const warns = report.violations.filter((v) => v.severity === "warn");
76
+ if (errors.length > 0) {
77
+ console.error(
78
+ `[cdl geometry] diagram "${diagram.id}" errors × ${errors.length}:\n` +
79
+ errors.slice(0, 5).map((v) => ` ${v.axis} — ${v.detail}`).join("\n") +
80
+ (errors.length > 5 ? `\n ...+${errors.length - 5} more` : ""),
81
+ );
82
+ }
83
+ if (warns.length > 0) {
84
+ console.warn(
85
+ `[cdl geometry] diagram "${diagram.id}" warns × ${warns.length}:\n` +
86
+ warns.slice(0, 5).map((v) => ` ${v.axis} — ${v.detail}`).join("\n") +
87
+ (warns.length > 5 ? `\n ...+${warns.length - 5} more` : ""),
88
+ );
89
+ }
90
+ }, [diagram, laid, emitGeometryWarn]);
91
+
92
+ // Timeline phases (description は cdl phase から拾う)
93
+ const phases = useMemo(
94
+ () =>
95
+ laid.phases.map((p) => ({
96
+ id: p.id,
97
+ label: p.id,
98
+ description: p.title,
99
+ durationMs: p.duration,
100
+ })),
101
+ [laid.phases],
102
+ );
103
+ const initialIndex = focusPhaseId ? laid.phases.findIndex((p) => p.id === focusPhaseId) : 0;
104
+ // focusPhaseId 指定時も autoplay + loop は true (= phase 内 state tween / particle は動く)。
105
+ // ただし phase 進行は engine が指定 phase に戻すことで「単 phase loop」 を擬似実装する。
106
+ const { timeline, state } = useTimeline(phases, {
107
+ initialPhaseIndex: initialIndex >= 0 ? initialIndex : 0,
108
+ autoplay: true,
109
+ loop: true,
110
+ // Legacy 並みの「ゆっくり読ませる」 テンポ
111
+ // = 各 phase の動き 0.9-1.1s + 2s 静止 = 1 phase 3s 前後、 4 phase 全体 ~14s
112
+ phaseHoldMs: 2000,
113
+ loopRestartDelayMs: 1800,
114
+ });
115
+
116
+ useEffect(() => {
117
+ timeline.autostart();
118
+ }, [timeline]);
119
+
120
+ // focusPhaseId 指定時 ... phase が focus 以外に進んだら強制的に戻す = 単 phase loop
121
+ useEffect(() => {
122
+ if (!focusPhaseId) return;
123
+ const focusIdx = laid.phases.findIndex((p) => p.id === focusPhaseId);
124
+ if (focusIdx < 0) return;
125
+ if (state.phaseIndex !== focusIdx) {
126
+ timeline.goto(focusIdx);
127
+ timeline.play();
128
+ }
129
+ }, [focusPhaseId, state.phaseIndex, timeline, laid.phases]);
130
+
131
+ const phaseIdx = focusPhaseId
132
+ ? laid.phases.findIndex((p) => p.id === focusPhaseId)
133
+ : state.phaseIndex;
134
+ const currentPhase = laid.phases[Math.max(0, phaseIdx)] ?? laid.phases[0];
135
+ const progress = reduced ? 1 : state.phaseProgress;
136
+
137
+ // state 値を計算 (現在 phase + これまでの phase の最終 tween 値を集約)
138
+ const baseStateValues = computeStateValues(laid, phaseIdx, progress);
139
+
140
+ // interactive integration: diagram に inputs / formulas / scrollTriggers / eventBindings が
141
+ // 含まれる場合、 signal / computed / scroll を wire up し stateOverrides で state 値を上書きする。
142
+ // 非 interactive diagram では noop (SSR safe、 signal 生成のみで DOM は触らない)。
143
+ const isInteractive = hasInteractivePrimitives(diagram);
144
+ const interactive = useInteractiveIntegration(
145
+ diagram,
146
+ {
147
+ ...(interactiveHandlers ? { handlers: interactiveHandlers } : {}),
148
+ phaseStateValues: baseStateValues,
149
+ },
150
+ );
151
+
152
+ // stateValues に signal / computed / scroll progress を merge (interactive 側優先)。
153
+ // 既存 phase-driven state と衝突しないよう state.id が signal / formula / scroll id と同じ場合のみ上書き。
154
+ const stateValues = useMemo(() => {
155
+ if (!isInteractive) return baseStateValues;
156
+ const merged: Record<string, string> = { ...baseStateValues };
157
+ for (const key of Object.keys(interactive.stateOverrides)) {
158
+ const v = interactive.stateOverrides[key];
159
+ if (typeof v === "number" || typeof v === "string" || typeof v === "boolean") {
160
+ merged[key] = String(v);
161
+ }
162
+ }
163
+ return merged;
164
+ }, [isInteractive, baseStateValues, interactive.stateOverrides]);
165
+
166
+ return (
167
+ <div
168
+ ref={isInteractive ? interactive.containerRef : undefined}
169
+ data-cdl-diagram={diagram.id}
170
+ data-cdl-phase-index={phaseIdx}
171
+ data-cdl-phase-id={currentPhase?.id ?? ""}
172
+ data-cdl-viewbox={`${laid.viewBox.x} ${laid.viewBox.y} ${laid.viewBox.w} ${laid.viewBox.h}`}
173
+ className="relative w-full overflow-hidden bg-[var(--cdl-stage-bg,#f8fafc)] text-[color:var(--cdl-text,#0f172a)]"
174
+ style={{ fontFamily: "Inter, -apple-system, system-ui, sans-serif" }}
175
+ >
176
+ {!hideHeader && currentPhase && (
177
+ <CdlHeader
178
+ topic={laid.topic}
179
+ phases={laid.phases}
180
+ currentPhase={currentPhase}
181
+ phaseIdx={Math.max(0, phaseIdx)}
182
+ stateValues={stateValues}
183
+ states={laid.states}
184
+ onJumpPhase={(idx) => {
185
+ timeline.goto(idx);
186
+ timeline.play();
187
+ }}
188
+ headingLevel={headingLevel}
189
+ />
190
+ )}
191
+ {/* interactive panel = input widget / formula readout / scroll readout (SVG の上に配置)、
192
+ diagram に interactive primitive がなければ null (SSR / render 影響なし) */}
193
+ {isInteractive && interactive.panel}
194
+ <CdlStage laid={laid} currentPhase={currentPhase} progress={progress} stateValues={stateValues} debug={debug} compact={hideHeader} svgRef={isInteractive ? interactive.svgRef : undefined} />
195
+ {/* hideHeader + multi-phase 時、 phase indicator が消えると動きが読めないため
196
+ mini indicator (dot 列 + 現在 phase title + badge) を bottom に追加 */}
197
+ {hideHeader && !hideMiniPhaseIndicator && currentPhase && laid.phases.length > 1 && (
198
+ <div className="flex items-center justify-between gap-3 border-t border-[var(--cdl-divider,#d4d4d8)]/40 bg-[var(--cdl-stage-bg,#f1f5f9)]/50 px-3 py-2 text-xs">
199
+ <div className="flex items-center gap-1.5">
200
+ {laid.phases.map((p, i) => (
201
+ <span
202
+ key={p.id}
203
+ className={
204
+ i === phaseIdx
205
+ ? "h-1.5 w-6 rounded-full bg-[color:var(--cdl-text-accent,#2d6a8f)]"
206
+ : "h-1.5 w-1.5 rounded-full bg-[color:var(--cdl-line-mute,#a89d8f)]/50"
207
+ }
208
+ />
209
+ ))}
210
+ <span className="ml-2 font-medium text-[color:var(--cdl-text,#1a1f2a)]">{currentPhase.title}</span>
211
+ </div>
212
+ {currentPhase.badge && (
213
+ <span className="rounded-full px-2 py-0.5 text-[10px] font-semibold" style={{ background: "color-mix(in srgb, var(--cdl-text-accent, #2d6a8f) 15%, transparent)", color: "var(--cdl-text-accent, #2d6a8f)" }}>
214
+ {currentPhase.badge}
215
+ </span>
216
+ )}
217
+ </div>
218
+ )}
219
+ </div>
220
+ );
221
+ }
@@ -0,0 +1,109 @@
1
+ import { signal, type Signal } from "../reactive";
2
+ import type { CdlDiagram, CdlScrollTrigger } from "../types";
3
+ import { computeScrollProgress } from "./progress";
4
+
5
+ /**
6
+ * 1 scroll trigger 用の progress signal + attach/detach 制御 handle (CAR #245)。
7
+ */
8
+ export type ScrollProgressHandle = {
9
+ /** progress signal (0..1)。 attach 前は初期値 0、 attach 後 scroll に追随して更新される。 */
10
+ readonly progress: Signal<number>;
11
+ /**
12
+ * 対象 element に scroll listener + IntersectionObserver を attach、 initial progress を計算する。
13
+ * 二重 attach は前 element を detach してから再 attach する挙動。
14
+ */
15
+ attach: (element: Element) => void;
16
+ /** 現 element から離脱、 listener + observer を解除。 progress signal は最終値を保持。 */
17
+ detach: () => void;
18
+ };
19
+
20
+ /**
21
+ * diagram の scrollTriggers から progress signal handles を生成 (CAR #245)。
22
+ * consumer は各 handle の attach(element) を DOM element に対して呼び、 signal.value で progress を read する。
23
+ *
24
+ * SSR safe = attach() 呼出前は browser API を触らないので Node.js / SSR 環境でも生成可能。
25
+ */
26
+ export function createScrollProgressSignals(
27
+ diagram: CdlDiagram,
28
+ ): Record<string, ScrollProgressHandle> {
29
+ const result = Object.create(null) as Record<string, ScrollProgressHandle>;
30
+ const triggers = diagram.scrollTriggers ?? [];
31
+ for (const trigger of triggers) {
32
+ result[trigger.id] = createScrollProgressHandle(trigger);
33
+ }
34
+ return result;
35
+ }
36
+
37
+ /**
38
+ * 1 CdlScrollTrigger spec から ScrollProgressHandle を生成。 単体でも export、 動的追加 use case で使う。
39
+ */
40
+ export function createScrollProgressHandle(spec: CdlScrollTrigger): ScrollProgressHandle {
41
+ const progress = signal(0);
42
+ let currentElement: Element | null = null;
43
+ let scrollListener: (() => void) | null = null;
44
+ let resizeListener: (() => void) | null = null;
45
+ let observer: IntersectionObserver | null = null;
46
+
47
+ const recompute = () => {
48
+ if (!currentElement || typeof window === "undefined") return;
49
+ const rect = currentElement.getBoundingClientRect();
50
+ try {
51
+ const p = computeScrollProgress(
52
+ { top: rect.top, bottom: rect.bottom },
53
+ window.innerHeight,
54
+ spec,
55
+ );
56
+ if (progress.value !== p) {
57
+ progress.value = p;
58
+ }
59
+ } catch {
60
+ // viewport 0 等の edge case は silent skip (次 recompute で正常化する想定)
61
+ }
62
+ };
63
+
64
+ const handle: ScrollProgressHandle = {
65
+ progress,
66
+ attach(element) {
67
+ if (currentElement === element) return;
68
+ if (currentElement) handle.detach();
69
+ currentElement = element;
70
+ if (typeof window === "undefined") return;
71
+ // IntersectionObserver は viewport in/out 変化時に recompute を trigger するだけの用途 (in-view gate
72
+ // ではない、 codex Round 3 findings 参照)。 anchor jump / programmatic scroll で observer callback が
73
+ // 抜けても、 scroll listener が常時 recompute するため progress は正しく追随する。
74
+ if (typeof IntersectionObserver !== "undefined") {
75
+ observer = new IntersectionObserver(() => {
76
+ recompute();
77
+ });
78
+ observer.observe(element);
79
+ }
80
+ scrollListener = () => {
81
+ recompute();
82
+ };
83
+ resizeListener = () => recompute();
84
+ window.addEventListener("scroll", scrollListener, { passive: true });
85
+ window.addEventListener("resize", resizeListener, { passive: true });
86
+ // 初回 recompute (attach 直後の scroll 位置を反映)
87
+ recompute();
88
+ },
89
+ detach() {
90
+ if (!currentElement) return;
91
+ if (observer) {
92
+ observer.disconnect();
93
+ observer = null;
94
+ }
95
+ if (scrollListener && typeof window !== "undefined") {
96
+ window.removeEventListener("scroll", scrollListener);
97
+ scrollListener = null;
98
+ }
99
+ if (resizeListener && typeof window !== "undefined") {
100
+ window.removeEventListener("resize", resizeListener);
101
+ resizeListener = null;
102
+ }
103
+ currentElement = null;
104
+ },
105
+ };
106
+ return handle;
107
+ }
108
+
109
+ export { computeScrollProgress } from "./progress";
@@ -0,0 +1,49 @@
1
+ import type { CdlScrollTrigger } from "../types";
2
+
3
+ /**
4
+ * scroll progress 計算 (pure function、 CAR #245)。
5
+ *
6
+ * spec.start / spec.end の semantics:
7
+ * - progress 0 = element top が viewport 相対位置 start に到達した瞬間
8
+ * (default start = 1.0 = element top が viewport bottom に到達 = 進捗開始)
9
+ * - progress 1 = element bottom が viewport 相対位置 end に到達した瞬間
10
+ * (default end = 0.0 = element bottom が viewport top に到達 = 進捗完了)
11
+ *
12
+ * 中心基準ではなく edge 基準にすることで、 element の height に依存しない一貫した scroll 距離での
13
+ * 進捗計算になる (codex Round 1 finding fix、 中心基準だと element height の半分だけ range が縮まる)。
14
+ *
15
+ * @param elementRect getBoundingClientRect 相当 (top / bottom は viewport 相対座標)
16
+ * @param viewportHeight window.innerHeight 相当
17
+ * @param spec CdlScrollTrigger の start / end
18
+ * @returns 0..1 の progress (clamp 済)
19
+ */
20
+ export function computeScrollProgress(
21
+ elementRect: { top: number; bottom: number },
22
+ viewportHeight: number,
23
+ spec: Pick<CdlScrollTrigger, "start" | "end">,
24
+ ): number {
25
+ if (viewportHeight <= 0) {
26
+ throw new Error(`viewport height must be positive (got ${viewportHeight})`);
27
+ }
28
+ const start = spec.start ?? 1.0;
29
+ const end = spec.end ?? 0.0;
30
+ const elementHeight = elementRect.bottom - elementRect.top;
31
+ // startPos = element top が progress 0 になる位置 (= start * viewport height)
32
+ // endPos = element top が progress 1 になる位置 (= end * viewport height - element height、
33
+ // element bottom が end * viewport height に来た瞬間を element top 座標で表現)
34
+ //
35
+ // 注 = start === end は「center-to-center」 系の valid trigger (element height の scroll 距離で
36
+ // element top から element bottom まで通過する)。 range は element height。
37
+ // 但し start === end かつ element height 0 の場合は degenerate case として startPos === endPos で throw。
38
+ const startPos = start * viewportHeight;
39
+ const endPos = end * viewportHeight - elementHeight;
40
+ if (startPos === endPos) {
41
+ throw new Error(
42
+ `scroll trigger の progress range が 0 になりました (start ${start} / end ${end} / element height ${elementHeight}、 element に高さがない可能性)`,
43
+ );
44
+ }
45
+ const rawProgress = (startPos - elementRect.top) / (startPos - endPos);
46
+ if (rawProgress < 0) return 0;
47
+ if (rawProgress > 1) return 1;
48
+ return rawProgress;
49
+ }
@@ -0,0 +1,114 @@
1
+ import type { JSX } from "react";
2
+ import { useEffect, useState } from "react";
3
+ import { CdlDiagramView, type CdlDiagramViewProps } from "./render";
4
+
5
+ /**
6
+ * CdlDiagramThumbnail ... 通常は CdlDiagramView を縮小表示、
7
+ * クリックでモーダル開いて viewport いっぱい (94vw x 90vh) に拡大表示する。
8
+ * Escape / 背景クリック / × ボタンで close。
9
+ */
10
+ export function CdlDiagramThumbnail(props: CdlDiagramViewProps): JSX.Element {
11
+ const [open, setOpen] = useState(false);
12
+
13
+ useEffect(() => {
14
+ if (!open) return;
15
+ const onKey = (e: KeyboardEvent): void => {
16
+ if (e.key === "Escape") setOpen(false);
17
+ };
18
+ document.addEventListener("keydown", onKey);
19
+ const prev = document.body.style.overflow;
20
+ document.body.style.overflow = "hidden";
21
+ return () => {
22
+ document.removeEventListener("keydown", onKey);
23
+ document.body.style.overflow = prev;
24
+ };
25
+ }, [open]);
26
+
27
+ return (
28
+ <>
29
+ <button
30
+ type="button"
31
+ onClick={() => setOpen(true)}
32
+ aria-label="拡大表示"
33
+ data-cdl-diagram={props.diagram.id}
34
+ style={{
35
+ display: "block",
36
+ width: "100%",
37
+ padding: 0,
38
+ margin: 0,
39
+ border: "none",
40
+ background: "transparent",
41
+ cursor: "zoom-in",
42
+ textAlign: "left",
43
+ }}
44
+ >
45
+ <CdlDiagramView {...props} />
46
+ </button>
47
+ {open && (
48
+ <div
49
+ onClick={() => setOpen(false)}
50
+ role="dialog"
51
+ aria-modal="true"
52
+ aria-label="diagram 拡大表示"
53
+ style={{
54
+ position: "fixed",
55
+ top: 0,
56
+ left: 0,
57
+ width: "100vw",
58
+ height: "100vh",
59
+ background: "var(--cdl-thumbnail-overlay-bg, rgba(45, 35, 25, 0.78))",
60
+ backdropFilter: "blur(4px)",
61
+ zIndex: 1000,
62
+ display: "flex",
63
+ alignItems: "center",
64
+ justifyContent: "center",
65
+ padding: "3vh 3vw",
66
+ cursor: "zoom-out",
67
+ }}
68
+ >
69
+ <div
70
+ onClick={(e) => e.stopPropagation()}
71
+ style={{
72
+ position: "relative",
73
+ maxWidth: "94vw",
74
+ maxHeight: "94vh",
75
+ width: "auto",
76
+ height: "auto",
77
+ background: "var(--cdl-thumbnail-modal-bg, #ede2d4)",
78
+ borderRadius: 24,
79
+ boxShadow: "0 24px 64px rgba(0, 0, 0, 0.35)",
80
+ overflow: "auto",
81
+ cursor: "default",
82
+ }}
83
+ >
84
+ <button
85
+ type="button"
86
+ onClick={() => setOpen(false)}
87
+ aria-label="閉じる"
88
+ style={{
89
+ position: "absolute",
90
+ top: 12,
91
+ right: 16,
92
+ width: 36,
93
+ height: 36,
94
+ borderRadius: 18,
95
+ border: "none",
96
+ background: "var(--cdl-thumbnail-close-bg, rgba(61, 50, 41, 0.12))",
97
+ color: "var(--cdl-text, #1a1f2a)",
98
+ fontSize: 22,
99
+ lineHeight: 1,
100
+ cursor: "pointer",
101
+ zIndex: 1,
102
+ }}
103
+ >
104
+ ×
105
+ </button>
106
+ <div style={{ padding: 24 }}>
107
+ <CdlDiagramView {...props} hideHeader={false} />
108
+ </div>
109
+ </div>
110
+ </div>
111
+ )}
112
+ </>
113
+ );
114
+ }