@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,164 @@
1
+ /**
2
+ * 小型の箱で名前をどこに置くか (#416)。
3
+ *
4
+ * `actor` / `function` / `storage` / `event` は名前を **箱の上端から固定の距離** に置く。
5
+ * 箱が小さいと名前が下端をまたぐ = 呼び出し側 (順序図の名札は高さ 72 に固定) で実測すると
6
+ * 12.7 から 23.6 はみ出していた。
7
+ *
8
+ * `card` は最初から高さで分岐しており (`h < 100` で中央揃え)、 その形を 4 種にも足す。
9
+ *
10
+ * ## 収まらない時は、 重ならないことを優先する
11
+ *
12
+ * 目印が入る高さでも、 その下に名前の帯が足りないことがある (h=43 から 61 のあたり)。
13
+ * その時は名前を目印の直下に置き、 箱の下へ出す。 上へ逃がすと目印と重なり、 どちらも
14
+ * 読めなくなるため。
15
+ *
16
+ * 目印自体が入らない低さでは箱全体に戻し、 張り出しを上下へ均等に出す。
17
+ *
18
+ * ## 字の大きさは種類ごとの値を保つ
19
+ *
20
+ * 一律に 20 へ落とすと `actor` (32) の見え方が大きく変わる。 高さが足りないのではなく
21
+ * **置く位置が固定だったこと** が原因なので、 位置だけ中央へ移し、 大きさは幅に収まる範囲で
22
+ * 種類ごとの値を保つ。
23
+ */
24
+
25
+ /** 概算 monospace / gothic 混合 font のグリフ幅係数。 半角 ≈ 0.62、 全角 (CJK) ≈ 1.05。 */
26
+ export function estimateTextWidth(text: string, fontSize: number): number {
27
+ let w = 0;
28
+ for (const ch of text) {
29
+ // CJK 記号・句読点 (U+3000) から CJK 統合漢字 (U+9FFF) までと、 全角英数・記号 (U+FF00-FFEF)。
30
+ // 範囲の下端は全角空白そのもの。 字面で書くと no-irregular-whitespace が発火するので escape する。
31
+ const isFullWidth = /[\u3000-\u9FFF\uFF00-\uFFEF]/.test(ch);
32
+ w += fontSize * (isFullWidth ? 1.05 : 0.62);
33
+ }
34
+ return w;
35
+ }
36
+
37
+ /** 幅に収まるまで 1 ずつ小さくする。 `minSize` より下げない。 */
38
+ export function fitTextSize(
39
+ text: string,
40
+ maxWidth: number,
41
+ targetSize: number,
42
+ minSize: number,
43
+ ): number {
44
+ let size = targetSize;
45
+ while (size > minSize && estimateTextWidth(text, size) > maxWidth) {
46
+ size -= 1;
47
+ }
48
+ return size;
49
+ }
50
+
51
+ /** これより低い箱を小型とみなす。 `card` が使ってきた値をそのまま用いる。 */
52
+ export const COMPACT_MAX_H = 100;
53
+
54
+ /** 小型で名前の左右に空ける幅の合計。 `card` の compact と同じ。 */
55
+ const COMPACT_PADDING_X = 16;
56
+
57
+ /** 小型で名前を縮める下限。 `card` と同じ。 */
58
+ const MIN_SIZE = 11;
59
+
60
+ /**
61
+ * 字が baseline から上下へどれだけ張り出すかの見積り。
62
+ *
63
+ * 収まるかを決めるのは baseline ではなく **字の上端と下端** なので、 中央に置く時も収まるかを
64
+ * 見る時もこの 2 つで測る。
65
+ */
66
+ const ASCENT = 0.72;
67
+ const DESCENT = 0.25;
68
+
69
+ /**
70
+ * 名前が収まる最小の帯の高さ。 これより低い帯では **収めきれない**。
71
+ *
72
+ * 読める下限 (`MIN_SIZE`) を割ってまで縮めない。 その場合の張り出しは上下へ均等に出す =
73
+ * 下だけに落ちる形にはしない。
74
+ */
75
+ export const MIN_BAND_H = MIN_SIZE * (ASCENT + DESCENT);
76
+
77
+ /** 目印 (`eyebrow`) の baseline と大きさ。 4 種で同じ値を使っている。 */
78
+ const EYEBROW_BASELINE = 38;
79
+ const EYEBROW_SIZE = 18;
80
+
81
+ /** 目印と名前の間に空ける幅。 実ブラウザで箱が重ならないところまで広げた。 */
82
+ const EYEBROW_GAP = 8;
83
+
84
+ /** 箱の下端と名前の間に残す幅。 下端に貼り付いて見えるのを避ける。 */
85
+ const BOTTOM_INSET = 4;
86
+
87
+ /**
88
+ * 名前を置ける帯の上端。 目印があるとその下から始まる。
89
+ *
90
+ * 目印は箱の上端から固定の位置に描かれる。 名前だけを箱の中央へ動かすと **目印と重なる**
91
+ * (review 指摘 = h=72 の `actor` で baseline の差が 10.8 しかない)。 目印の下から下端までを
92
+ * 名前の帯とする。
93
+ */
94
+ /** 目印そのものが占める下端 (間隔を含まない)。 これより低い箱には目印が入らない。 */
95
+ export const eyebrowBottom = (): number => EYEBROW_BASELINE + EYEBROW_SIZE * DESCENT;
96
+
97
+ /**
98
+ * 名前を置ける帯の上端。 目印があるとその下から始まる。
99
+ *
100
+ * 目印は箱の上端から固定の位置に描かれる。 名前だけを箱の中央へ動かすと **目印と重なる**
101
+ * (review 指摘 = h=72 の `actor` で baseline の差が 10.8 しかない)。 目印の下から下端までを
102
+ * 名前の帯とする。
103
+ */
104
+ export const titleBandTop = (hasEyebrow: boolean): number =>
105
+ hasEyebrow ? eyebrowBottom() + EYEBROW_GAP : 0;
106
+
107
+ export const isCompactBox = (h: number): boolean => h < COMPACT_MAX_H;
108
+
109
+ export type TitlePlacement = {
110
+ x: number;
111
+ y: number;
112
+ size: number;
113
+ anchor: "start" | "middle";
114
+ };
115
+
116
+ /**
117
+ * 名前の位置と大きさを返す。 小型でなければ渡された値をそのまま返す。
118
+ *
119
+ * 小型では **幅と、 残りの縦幅の両方** に収まる大きさへ縮めてから、 帯の中央に置く。
120
+ * 縦を見ないと、 低い箱や目印のある箱で字が箱の外に出る (review 指摘)。
121
+ *
122
+ * @param 通常 小型でない時の位置と大きさ (種類ごとの固定値)
123
+ * @param opts.hasEyebrow 目印を描くか。 描くならその下から名前の帯が始まる
124
+ */
125
+ export function titlePlacement(
126
+ node: { w: number; h: number; title: string },
127
+ 通常: { x: number; y: number; size: number },
128
+ opts: { hasEyebrow?: boolean } = {},
129
+ ): TitlePlacement {
130
+ if (!isCompactBox(node.h)) {
131
+ return { x: 通常.x, y: 通常.y, size: 通常.size, anchor: "start" };
132
+ }
133
+ const 高さ = Math.max(0, node.h);
134
+ const 目印あり = opts.hasEyebrow === true;
135
+ // **目印が箱に入る時だけ** その下から始める。 入らない高さでは目印も描かれた所で読めないので、
136
+ // 名前だけ下へ押し出す理由が無い (review 指摘)
137
+ const 上 = 目印あり && 高さ >= eyebrowBottom() ? titleBandTop(true) : 0;
138
+ const 生の帯 = Math.max(0, 高さ - 上);
139
+ // 下の余白は **余裕がある時だけ** 取る。 常に取ると、 低い箱で帯が足りなくなり字が上へ出る
140
+ const 下余白 = 生の帯 >= MIN_BAND_H + BOTTOM_INSET ? BOTTOM_INSET : 0;
141
+ const 帯 = 生の帯 - 下余白;
142
+
143
+ const 幅で = fitTextSize(
144
+ node.title,
145
+ Math.max(0, node.w - COMPACT_PADDING_X),
146
+ 通常.size,
147
+ MIN_SIZE,
148
+ );
149
+ const 帯で = Math.floor(帯 / (ASCENT + DESCENT));
150
+ const size = Math.max(MIN_SIZE, Math.min(幅で, 帯で));
151
+ const 字高 = size * (ASCENT + DESCENT);
152
+
153
+ // 収まるなら帯の中央。 収まらない時の逃がし方は目印の有無で変える
154
+ // 目印あり = 目印の直下から下へ出す (上へ出すと目印と重なり、 どちらも読めなくなる)
155
+ // 目印なし = 上下へ均等に出す (下だけに落ちる形にしない)
156
+ const y =
157
+ 字高 <= 帯
158
+ ? 上 + 帯 / 2 + (size * (ASCENT - DESCENT)) / 2
159
+ : 上 > 0
160
+ ? 上 + size * ASCENT
161
+ : (高さ - 字高) / 2 + size * ASCENT;
162
+
163
+ return { x: node.w / 2, y, size, anchor: "middle" };
164
+ }
@@ -0,0 +1,394 @@
1
+ import { useEffect, useState, type JSX } from "react";
2
+ import type { CdlDynShape, LaidNode } from "../types";
3
+ import { resolveNumericAttr } from "../render/utils";
4
+
5
+ /**
6
+ * time-driven tick hook = ~30fps throttle で performance.now() を state に反映。
7
+ * wave の位相 offset 等「時間で常に微振動」 する animation の駆動源。
8
+ * 60fps 相当の rAF で毎 frame setState すると同 diagram 内の複数 dyn-wave で描画が重くなるため、
9
+ * 33ms (~30fps) throttle で描画コストを半減 (視覚上は依然滑らかな流れ)。
10
+ */
11
+ function useTimeTick(): number {
12
+ const [t, setT] = useState(() => (typeof performance !== "undefined" ? performance.now() : 0));
13
+ useEffect(() => {
14
+ if (typeof requestAnimationFrame === "undefined") return;
15
+ let handle = 0;
16
+ let lastEmit = performance.now();
17
+ const tick = (now: number) => {
18
+ if (now - lastEmit >= 33) {
19
+ lastEmit = now;
20
+ setT(now);
21
+ }
22
+ handle = requestAnimationFrame(tick);
23
+ };
24
+ handle = requestAnimationFrame(tick);
25
+ return () => cancelAnimationFrame(handle);
26
+ }, []);
27
+ return t;
28
+ }
29
+
30
+ /**
31
+ * dynamic shape の SVG render (CAR: shape primitive)。
32
+ *
33
+ * signal 値で内部 fill / radius / angle 等が動く汎用 container。 chart / gantt のような
34
+ * fixed template ではなく orthogonal primitive で、 「rect の縦 fill」 「circle の
35
+ * radius」 「arc の angle」 「wave の水位」 「polygon の rotation」 が全部 signal で
36
+ * 駆動できる。 EIP1559 相当の「rect 3 個並びで各 fill が signal 追随」 を成立させる基盤。
37
+ *
38
+ * render 経路 = kind (dyn-rect / dyn-circle / dyn-arc / dyn-wave / dyn-polygon) → 対応
39
+ * sub-component、 shape.source 等の template を resolveNumericAttr で解決して SVG 属性に。
40
+ */
41
+ export function DynShapeNode({
42
+ node,
43
+ stateValues,
44
+ active,
45
+ }: {
46
+ node: LaidNode;
47
+ stateValues: Record<string, string>;
48
+ active: boolean;
49
+ }): JSX.Element {
50
+ const shape = node.shape;
51
+ const cx = node.cx;
52
+ const cy = node.cy;
53
+ const w = node.w;
54
+ const h = node.h;
55
+ const x = cx - w / 2;
56
+ const y = cy - h / 2;
57
+ if (!shape) return <rect x={x} y={y} width={w} height={h} fill="none" stroke="#ccc" />;
58
+
59
+ const strokeColor = shape.stroke ?? "var(--cdl-line, #a89d8f)";
60
+ const fillColor = shape.fill ?? "var(--cdl-fill, #e0e6ef)";
61
+ const activeOpacity = active ? 1 : 0.85;
62
+
63
+ switch (shape.kind) {
64
+ case "rect":
65
+ return renderRect(shape, x, y, w, h, stateValues, fillColor, strokeColor, activeOpacity, node);
66
+ case "circle":
67
+ return renderCircle(shape, cx, cy, w, h, stateValues, fillColor, strokeColor, activeOpacity, node);
68
+ case "arc":
69
+ return renderArc(shape, cx, cy, w, h, stateValues, fillColor, strokeColor, activeOpacity, node);
70
+ case "wave":
71
+ return <WaveShape shape={shape} x={x} y={y} w={w} h={h} stateValues={stateValues} fillColor={fillColor} strokeColor={strokeColor} opacity={activeOpacity} node={node} />;
72
+ case "polygon":
73
+ return renderPolygon(shape, cx, cy, stateValues, fillColor, strokeColor, activeOpacity, node);
74
+ }
75
+ }
76
+
77
+ function renderRect(
78
+ shape: Extract<CdlDynShape, { kind: "rect" }>,
79
+ x: number,
80
+ y: number,
81
+ w: number,
82
+ h: number,
83
+ stateValues: Record<string, string>,
84
+ fillColor: string,
85
+ strokeColor: string,
86
+ opacity: number,
87
+ node: LaidNode,
88
+ ): JSX.Element {
89
+ const value = resolveNumericAttr(shape.source, stateValues, 0);
90
+ const clampedValue = Math.min(shape.fillMax, Math.max(0, value));
91
+ const ratio = shape.fillMax > 0 ? clampedValue / shape.fillMax : 0;
92
+ const orient = shape.orient ?? "up";
93
+ const radius = shape.radius ?? 6;
94
+ // fill rect の位置と大きさ
95
+ let fx = x;
96
+ let fy = y;
97
+ let fw = w;
98
+ let fh = h;
99
+ if (orient === "up") {
100
+ fh = h * ratio;
101
+ fy = y + h - fh;
102
+ } else if (orient === "down") {
103
+ fh = h * ratio;
104
+ fy = y;
105
+ } else if (orient === "right") {
106
+ fw = w * ratio;
107
+ fx = x;
108
+ } else if (orient === "left") {
109
+ fw = w * ratio;
110
+ fx = x + w - fw;
111
+ }
112
+ const resolvedFill = interpolateColor(shape.fill, stateValues, fillColor);
113
+ return (
114
+ <g data-cdl-shape="rect" opacity={opacity}>
115
+ {/* 枠 */}
116
+ <rect
117
+ x={x}
118
+ y={y}
119
+ width={w}
120
+ height={h}
121
+ rx={radius}
122
+ ry={radius}
123
+ fill="none"
124
+ stroke={strokeColor}
125
+ strokeWidth={1.5}
126
+ />
127
+ {/* 内部 fill、 clip-path で角丸 rect の内側に閉じ込める */}
128
+ <clipPath id={`dyn-rect-clip-${node.id}`}>
129
+ <rect x={x} y={y} width={w} height={h} rx={radius} ry={radius} />
130
+ </clipPath>
131
+ <rect
132
+ x={fx}
133
+ y={fy}
134
+ width={fw}
135
+ height={fh}
136
+ fill={resolvedFill}
137
+ clipPath={`url(#dyn-rect-clip-${node.id})`}
138
+ style={{ transition: "all 200ms ease-out" }}
139
+ />
140
+ {/* value label */}
141
+ <text
142
+ x={x + w / 2}
143
+ y={y - 6}
144
+ textAnchor="middle"
145
+ fontSize={11}
146
+ fontFamily="monospace"
147
+ fill="var(--cdl-text-dim, #5a6270)"
148
+ >
149
+ {Math.round(clampedValue)} / {shape.fillMax}
150
+ </text>
151
+ </g>
152
+ );
153
+ }
154
+
155
+ function renderCircle(
156
+ shape: Extract<CdlDynShape, { kind: "circle" }>,
157
+ cx: number,
158
+ cy: number,
159
+ w: number,
160
+ h: number,
161
+ stateValues: Record<string, string>,
162
+ fillColor: string,
163
+ strokeColor: string,
164
+ opacity: number,
165
+ node: LaidNode,
166
+ ): JSX.Element {
167
+ const maxR = Math.min(w, h) / 2;
168
+ const r = shape.radius !== undefined ? resolveNumericAttr(shape.radius, stateValues, maxR) : maxR;
169
+ const progress = shape.fillProgress !== undefined
170
+ ? Math.min(1, Math.max(0, resolveNumericAttr(shape.fillProgress, stateValues, 0)))
171
+ : 1;
172
+ const resolvedFill = interpolateColor(shape.fill, stateValues, fillColor);
173
+ const clampedR = Math.min(maxR, Math.max(0, r));
174
+ const filledR = clampedR * progress;
175
+ return (
176
+ <g data-cdl-shape="circle" opacity={opacity}>
177
+ <circle cx={cx} cy={cy} r={clampedR} fill="none" stroke={strokeColor} strokeWidth={1.5} />
178
+ <circle
179
+ cx={cx}
180
+ cy={cy}
181
+ r={filledR}
182
+ fill={resolvedFill}
183
+ style={{ transition: "all 200ms ease-out" }}
184
+ />
185
+ </g>
186
+ );
187
+ }
188
+
189
+ function renderArc(
190
+ shape: Extract<CdlDynShape, { kind: "arc" }>,
191
+ cx: number,
192
+ cy: number,
193
+ w: number,
194
+ h: number,
195
+ stateValues: Record<string, string>,
196
+ fillColor: string,
197
+ strokeColor: string,
198
+ opacity: number,
199
+ node: LaidNode,
200
+ ): JSX.Element {
201
+ const maxR = Math.min(w, h) / 2;
202
+ const outerR = shape.outerRadius ?? maxR;
203
+ const innerR = shape.innerRadius ?? outerR * 0.7;
204
+ const startAngle = shape.startAngle ?? -90;
205
+ const angle = resolveNumericAttr(shape.angle, stateValues, 0);
206
+ const sweepMax = shape.sweepMax ?? 360;
207
+ const clamped = Math.min(sweepMax, Math.max(0, angle));
208
+ const endAngle = startAngle + clamped;
209
+ const resolvedFill = interpolateColor(shape.fill, stateValues, fillColor);
210
+ const path = arcPath(cx, cy, innerR, outerR, startAngle, endAngle);
211
+ const trackPath = arcPath(cx, cy, innerR, outerR, startAngle, startAngle + sweepMax);
212
+ return (
213
+ <g data-cdl-shape="arc" opacity={opacity}>
214
+ <path d={trackPath} fill="none" stroke={strokeColor} strokeWidth={1.5} opacity={0.3} />
215
+ {clamped > 0.5 && (
216
+ <path
217
+ d={path}
218
+ fill={resolvedFill}
219
+ style={{ transition: "all 200ms ease-out" }}
220
+ />
221
+ )}
222
+ </g>
223
+ );
224
+ }
225
+
226
+ function WaveShape({
227
+ shape,
228
+ x,
229
+ y,
230
+ w,
231
+ h,
232
+ stateValues,
233
+ fillColor,
234
+ strokeColor,
235
+ opacity,
236
+ node,
237
+ }: {
238
+ shape: Extract<CdlDynShape, { kind: "wave" }>;
239
+ x: number;
240
+ y: number;
241
+ w: number;
242
+ h: number;
243
+ stateValues: Record<string, string>;
244
+ fillColor: string;
245
+ strokeColor: string;
246
+ opacity: number;
247
+ node: LaidNode;
248
+ }): JSX.Element {
249
+ const level = resolveNumericAttr(shape.level, stateValues, 0);
250
+ const clamped = Math.min(shape.amplitude, Math.max(0, level));
251
+ const ratio = shape.amplitude > 0 ? clamped / shape.amplitude : 0;
252
+ const frequency = shape.frequency ?? 2;
253
+ const waveHeight = shape.waveHeight ?? 6;
254
+ // waterY を level に応じて波の下限が rectangle 上端を超えないよう調整。
255
+ // ratio=1 (100%) 時、 waterY = y - waveHeight にして波の底が矩形上端と一致、 隙間なく完全 fill。
256
+ // ratio=0 時は waterY = y+h (底面)、 中間は線形補間。
257
+ const waterY = y + h * (1 - ratio) - waveHeight * ratio;
258
+ const resolvedFill = interpolateColor(shape.fill, stateValues, fillColor);
259
+ // 時間駆動の位相 offset で波が常に流れる (2 秒周期で 1 波長分平行移動)
260
+ const t = useTimeTick();
261
+ const phaseOffset = (t / 2000) * Math.PI * 2;
262
+ const containerRadius = 8;
263
+ // 0% (ratio=0) の時は波を出さず、 rectangle の枠だけ描画する。
264
+ // level=0 なのに底面から小さな波山が突き出るのを防ぐ。
265
+ if (ratio <= 0) {
266
+ return (
267
+ <g data-cdl-shape="wave" opacity={opacity}>
268
+ <rect
269
+ x={x}
270
+ y={y}
271
+ width={w}
272
+ height={h}
273
+ rx={containerRadius}
274
+ ry={containerRadius}
275
+ fill="none"
276
+ stroke={strokeColor}
277
+ strokeWidth={1.5}
278
+ />
279
+ </g>
280
+ );
281
+ }
282
+ // 波の path: 上端は wave curve、 下端は矩形の底辺で閉じる
283
+ const points: string[] = [];
284
+ const steps = 40;
285
+ for (let i = 0; i <= steps; i++) {
286
+ const px = x + (w * i) / steps;
287
+ const wave = Math.sin((i / steps) * Math.PI * 2 * frequency + phaseOffset) * waveHeight;
288
+ const py = waterY + wave;
289
+ points.push(`${i === 0 ? "M" : "L"} ${px.toFixed(1)} ${py.toFixed(1)}`);
290
+ }
291
+ points.push(`L ${x + w} ${y + h}`);
292
+ points.push(`L ${x} ${y + h}`);
293
+ points.push("Z");
294
+ return (
295
+ <g data-cdl-shape="wave" opacity={opacity}>
296
+ <rect
297
+ x={x}
298
+ y={y}
299
+ width={w}
300
+ height={h}
301
+ rx={containerRadius}
302
+ ry={containerRadius}
303
+ fill="none"
304
+ stroke={strokeColor}
305
+ strokeWidth={1.5}
306
+ />
307
+ <clipPath id={`dyn-wave-clip-${node.id}`}>
308
+ <rect x={x} y={y} width={w} height={h} rx={containerRadius} ry={containerRadius} />
309
+ </clipPath>
310
+ <path
311
+ d={points.join(" ")}
312
+ fill={resolvedFill}
313
+ clipPath={`url(#dyn-wave-clip-${node.id})`}
314
+ style={{ transition: "all 300ms ease-out" }}
315
+ />
316
+ </g>
317
+ );
318
+ }
319
+
320
+ function renderPolygon(
321
+ shape: Extract<CdlDynShape, { kind: "polygon" }>,
322
+ cx: number,
323
+ cy: number,
324
+ stateValues: Record<string, string>,
325
+ fillColor: string,
326
+ strokeColor: string,
327
+ opacity: number,
328
+ node: LaidNode,
329
+ ): JSX.Element {
330
+ const sides = Math.min(12, Math.max(3, shape.sides));
331
+ const r = shape.radius !== undefined ? resolveNumericAttr(shape.radius, stateValues, 40) : 40;
332
+ const rotation = shape.rotation !== undefined ? resolveNumericAttr(shape.rotation, stateValues, 0) : 0;
333
+ const resolvedFill = interpolateColor(shape.fill, stateValues, fillColor);
334
+ const points: string[] = [];
335
+ for (let i = 0; i < sides; i++) {
336
+ const angle = ((i / sides) * 360 + rotation - 90) * (Math.PI / 180);
337
+ const px = cx + r * Math.cos(angle);
338
+ const py = cy + r * Math.sin(angle);
339
+ points.push(`${px.toFixed(1)},${py.toFixed(1)}`);
340
+ }
341
+ return (
342
+ <g data-cdl-shape="polygon" opacity={opacity}>
343
+ <polygon
344
+ points={points.join(" ")}
345
+ fill={resolvedFill}
346
+ stroke={strokeColor}
347
+ strokeWidth={1.5}
348
+ style={{ transition: "all 200ms ease-out" }}
349
+ />
350
+ </g>
351
+ );
352
+ }
353
+
354
+ /**
355
+ * SVG arc path (annulus segment) を生成、 innerRadius が 0 なら pie slice。
356
+ */
357
+ function arcPath(
358
+ cx: number,
359
+ cy: number,
360
+ innerR: number,
361
+ outerR: number,
362
+ startAngle: number,
363
+ endAngle: number,
364
+ ): string {
365
+ const sRad = (startAngle * Math.PI) / 180;
366
+ const eRad = (endAngle * Math.PI) / 180;
367
+ const outerStart = { x: cx + outerR * Math.cos(sRad), y: cy + outerR * Math.sin(sRad) };
368
+ const outerEnd = { x: cx + outerR * Math.cos(eRad), y: cy + outerR * Math.sin(eRad) };
369
+ const innerStart = { x: cx + innerR * Math.cos(eRad), y: cy + innerR * Math.sin(eRad) };
370
+ const innerEnd = { x: cx + innerR * Math.cos(sRad), y: cy + innerR * Math.sin(sRad) };
371
+ const sweep = endAngle - startAngle;
372
+ const largeArc = Math.abs(sweep) > 180 ? 1 : 0;
373
+ const sweepFlag = sweep > 0 ? 1 : 0;
374
+ return [
375
+ `M ${outerStart.x.toFixed(2)} ${outerStart.y.toFixed(2)}`,
376
+ `A ${outerR} ${outerR} 0 ${largeArc} ${sweepFlag} ${outerEnd.x.toFixed(2)} ${outerEnd.y.toFixed(2)}`,
377
+ `L ${innerStart.x.toFixed(2)} ${innerStart.y.toFixed(2)}`,
378
+ `A ${innerR} ${innerR} 0 ${largeArc} ${sweepFlag === 1 ? 0 : 1} ${innerEnd.x.toFixed(2)} ${innerEnd.y.toFixed(2)}`,
379
+ "Z",
380
+ ].join(" ");
381
+ }
382
+
383
+ /**
384
+ * fill color template を stateValues で解決。 色は数値以外の string を含むので、
385
+ * `{signal}` 全体を signal 値で置換 (interpolate だけを使う)。
386
+ */
387
+ function interpolateColor(
388
+ template: string | undefined,
389
+ stateValues: Record<string, string>,
390
+ fallback: string,
391
+ ): string {
392
+ if (!template) return fallback;
393
+ return template.replace(/\{(\w+)\}/g, (_, id: string) => stateValues[id] ?? `{${id}}`);
394
+ }
@@ -0,0 +1,70 @@
1
+ import type { JSX } from "react";
2
+ import type { LaidNode } from "../types";
3
+ import { nodeAccent } from "./node-tone";
4
+ import { titlePlacement } from "./compact-title";
5
+
6
+ export function EventNode({
7
+ node,
8
+ active,
9
+ progress,
10
+ }: {
11
+ node: LaidNode;
12
+ active: boolean;
13
+ progress: number;
14
+ }): JSX.Element {
15
+ const accent = nodeAccent(node, "var(--cdl-tone-success, #4ea36a)");
16
+ const x = node.cx - node.w / 2;
17
+ const y = node.cy - node.h / 2;
18
+ // 小型の箱では名前を中央に置く。 固定の位置のままだと下端をまたぐ (#416)
19
+ const 名前 = titlePlacement(node, { x: 26, y: 88, size: 26 }, { hasEyebrow: Boolean(node.eyebrow) });
20
+ const scale = active ? 0.78 + 0.22 * progress : 0.92;
21
+ const opacity = active ? 0.5 + 0.5 * progress : 0.65;
22
+ return (
23
+ <g
24
+ transform={`translate(${node.cx} ${node.cy}) scale(${scale}) translate(${-node.cx} ${-node.cy})`}
25
+ opacity={opacity}
26
+ >
27
+ <g transform={`translate(${x} ${y})`}>
28
+ <rect
29
+ data-cdl-role="node-body"
30
+ x={0}
31
+ y={0}
32
+ width={node.w}
33
+ height={node.h}
34
+ rx={16}
35
+ fill="var(--cdl-node-fill, #ffffff)"
36
+ stroke={active ? accent : "var(--cdl-divider, #d4d4d8)"}
37
+ strokeWidth={active ? 3 : 1.25}
38
+ />
39
+ {node.eyebrow && (
40
+ <text x={26} y={38} fontSize={18} fontWeight={700} letterSpacing={2} fill={accent}>
41
+ {node.eyebrow}
42
+ </text>
43
+ )}
44
+ <text
45
+ data-cdl-role="node-label"
46
+ x={名前.x}
47
+ y={名前.y}
48
+ fontSize={名前.size}
49
+ fontWeight={700}
50
+ fill="var(--cdl-text, #1a1f2a)"
51
+ textAnchor={名前.anchor}
52
+ style={{ fontFamily: "'JetBrains Mono', monospace" }}
53
+ >
54
+ {node.title}
55
+ </text>
56
+ {node.subtitle && (
57
+ <text
58
+ x={26}
59
+ y={128}
60
+ fontSize={21}
61
+ fill="var(--cdl-text-dim, #5a6270)"
62
+ style={{ fontFamily: "'JetBrains Mono', monospace" }}
63
+ >
64
+ {node.subtitle}
65
+ </text>
66
+ )}
67
+ </g>
68
+ </g>
69
+ );
70
+ }
@@ -0,0 +1,53 @@
1
+ import type { JSX } from "react";
2
+ import type { LaidNode } from "../types";
3
+ import { nodeAccent } from "./node-tone";
4
+ import { titlePlacement } from "./compact-title";
5
+
6
+ export function FunctionNode({ node, active }: { node: LaidNode; active: boolean }): JSX.Element {
7
+ const accent = nodeAccent(node, "var(--cdl-tone-teal, #2f8770)");
8
+ // tone 未指定時だけ active / 非 active で既定色が変わる (活性を青で示す従来の見せ方)。
9
+ // tone 指定時はどちらの状態でも指定色を使う。 他 4 component と揃える。
10
+ const stroke = nodeAccent(node, active ? "var(--cdl-tone-accent, #2d6a8f)" : "var(--cdl-tone-teal, #2f8770)");
11
+ const x = node.cx - node.w / 2;
12
+ const y = node.cy - node.h / 2;
13
+ // 小型の箱では名前を中央に置く。 固定の位置のままだと下端をまたぐ (#416)
14
+ const 名前 = titlePlacement(node, { x: 26, y: 86, size: 24 }, { hasEyebrow: Boolean(node.eyebrow) });
15
+ return (
16
+ <g transform={`translate(${x} ${y})`}>
17
+ <rect
18
+ data-cdl-role="node-body"
19
+ x={0}
20
+ y={0}
21
+ width={node.w}
22
+ height={node.h}
23
+ rx={16}
24
+ fill="var(--cdl-node-fill, #ffffff)"
25
+ stroke={stroke}
26
+ strokeWidth={active ? 3 : 1.5}
27
+ strokeOpacity={active ? 1 : 0.5}
28
+ />
29
+ {node.eyebrow && (
30
+ <text x={26} y={38} fontSize={18} fontWeight={700} letterSpacing={2} fill={accent}>
31
+ {node.eyebrow}
32
+ </text>
33
+ )}
34
+ <text
35
+ data-cdl-role="node-label"
36
+ x={名前.x}
37
+ y={名前.y}
38
+ fontSize={名前.size}
39
+ fontWeight={700}
40
+ fill="var(--cdl-text, #1a1f2a)"
41
+ textAnchor={名前.anchor}
42
+ style={{ fontFamily: "'JetBrains Mono', monospace" }}
43
+ >
44
+ {node.title}
45
+ </text>
46
+ {node.subtitle && (
47
+ <text x={26} y={126} fontSize={20} fill="var(--cdl-text-dim, #5a6270)">
48
+ {node.subtitle}
49
+ </text>
50
+ )}
51
+ </g>
52
+ );
53
+ }