@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,377 @@
1
+ import { useMemo, type JSX } from "react";
2
+ import type { CdlPhase, LaidDiagram } from "../types";
3
+ import { CdlEdgeView, CdlEdgeGlowView, CdlEdgeLabelView } from "./edges";
4
+ import { CdlNodeView } from "./nodes";
5
+ import { TONE, TONE_HEX } from "./tone";
6
+ import { buildDiagramAltText } from "../layout/spec";
7
+ import { footerShapeDropsOf, lifelineFooterNodes } from "../layout/footer-shape";
8
+
9
+ export function CdlStage({
10
+ laid,
11
+ currentPhase,
12
+ progress,
13
+ stateValues,
14
+ debug,
15
+ compact = false,
16
+ svgRef,
17
+ }: {
18
+ laid: LaidDiagram;
19
+ currentPhase: CdlPhase | undefined;
20
+ progress: number;
21
+ stateValues: Record<string, string>;
22
+ debug: boolean;
23
+ compact?: boolean;
24
+ /** interactive integration (CAR #246) 用 SVG ref、 caller が event handler attach に使う */
25
+ svgRef?: React.RefObject<SVGSVGElement | null>;
26
+ }): JSX.Element {
27
+ const activeSet = new Set(currentPhase?.activate ?? []);
28
+ // 全 lifeline lane 横断で uniform y2 (footer.top の min) を事前計算して等長保証。
29
+ // 単一 node lane も同じ y2 を使う (mixed 構成での分岐回避、 Round 2 major fix)。
30
+ // footer.top を集めた配列を作り、 min を uniformFooterTop とする。 配列空 (全 lane が
31
+ // 1 node 以下 or 0 node) の場合、 最初の lifeline lane の (lane.y + lane.height) を fallback。
32
+ const lifelineLanes = laid.lanes.filter((l) => l.lifeline);
33
+ // 下端の名札は 2 つの用途に使う。 縦線の終点 (uniformFooterTop) と、 絵を下げる量の表。
34
+ //
35
+ // `shape-` の一部は中身を箱の下端から固定の距離で組み立てるため、 箱が低いと絵が上へ出る
36
+ // (`SHAPE_DRAW_UP_EXTENT` の説明)。 縦線は箱の上端で止まるので、 出た分だけ縦線が絵の中を通る。
37
+ //
38
+ // 名札を集める処理は 1 度だけ行い、 配置が変わらない限り作り直さない。 この component は
39
+ // 動きの 1 コマごとに描き直されるため、 毎コマ集め直すと lane 数 × node 数を毎コマ払う
40
+ const { footerTops, footerDropById } = useMemo(() => {
41
+ const footers = lifelineFooterNodes(laid.lanes, laid.nodes);
42
+ return {
43
+ footerTops: footers.map((f) => f.cy - f.h / 2),
44
+ footerDropById: footerShapeDropsOf(footers),
45
+ };
46
+ }, [laid]);
47
+ const uniformFooterTop = footerTops.length > 0
48
+ ? Math.min(...footerTops)
49
+ : (lifelineLanes[0] ? lifelineLanes[0].y + lifelineLanes[0].height : 0);
50
+ const diagramScale = laid.diagramScale ?? 1;
51
+ return (
52
+ <div className={compact ? "px-3 py-3" : "px-6 py-6"} style={compact ? undefined : { minHeight: 460 }}>
53
+ <svg
54
+ ref={svgRef}
55
+ viewBox={`${laid.viewBox.x} ${laid.viewBox.y} ${laid.viewBox.w} ${laid.viewBox.h}`}
56
+ preserveAspectRatio="xMidYMid meet"
57
+ className="block h-auto w-full"
58
+ // 図全体の倍率は **表示幅** で表す。 高さは viewBox の縦横比から決まる
59
+ // (SVG の intrinsic sizing、 CSS を一切与えなくても同じ)。
60
+ // field の仕様は `CdlViewport.scale` の JSDoc、 ここには選ばなかった実装とその理由を
61
+ // 書く。
62
+ //
63
+ // cdl は Tailwind を依存にも同梱にも持たないので、 ここの class 名が実際に効くかは
64
+ // consumer の CSS 設定次第。 幅に関しては効かなくても `<svg>` の既定が 100% なので
65
+ // 結果は変わらないが、 それ以外の class に同じ保証は無い。 JSDoc 側では CSS の
66
+ // 結果を断定しない。
67
+ //
68
+ // 却下 1 = viewBox と中身の両方に同じ k を掛ける。 恒等変換で画面上は何も変わらない。
69
+ // viewBox を k 倍すると表示倍率が 1/k になり、 中身の scale(k) と厳密に打ち消し合う。
70
+ //
71
+ // device_x = offsetX + (k·p − k·x0) × W/(k·w) = offsetX + (p − x0) × W/w
72
+ //
73
+ // k が約分で消える (実測 = k=1/3/0.5 で device 座標が完全一致)。
74
+ //
75
+ // 却下 2 = viewBox の実寸に k を掛けた px を width に置く。 倍率 1 が相対幅
76
+ // (class `w-full` = 親幅基準) なのに倍率 k だけ絶対幅 (viewBox 基準) になり、 基準が
77
+ // 食い違う。 親幅と viewBox 幅が一致しない限り「k 倍」 にならず、 親が viewBox より
78
+ // 広ければ `scale: 1.2` が `scale: 1` より小さくなる (親 3000px / viewBox 1086px で
79
+ // 0.43 倍 = 拡大指定で縮む)。
80
+ //
81
+ // 採用 = 倍率 1 と同じ親幅基準に揃えて `width: {k × 100}%` を置く。
82
+ style={diagramScale === 1
83
+ ? { maxWidth: "100%" }
84
+ : { width: `${diagramScale * 100}%`, maxWidth: "none" }}
85
+ role="img"
86
+ aria-label={buildDiagramAltText(laid.topic, currentPhase)}
87
+ // 図を描く `<svg>` はこれ 1 つ、 という目印。 interactive panel は widget ごとに
88
+ // `<svg>` を持ち (readout ring / gauge / sparkline 等)、 DOM 順では panel が先に来る。
89
+ // 「最初の `<svg>`」 で拾うと panel の widget を掴む (cdl#336)。
90
+ data-cdl-stage=""
91
+ // 倍率は SVG 要素そのものに載せる。 中身を包む `<g>` を作らないので、
92
+ // 倍率 1 / 未指定の DOM は従来と 1 byte も変わらない。
93
+ data-cdl-scale={diagramScale === 1 ? undefined : diagramScale}
94
+ >
95
+ <defs>
96
+ {/* CAR-643 = cdl は geometry only。 shadow / border effect の filter は cdl SVG 内に
97
+ 持たず、 consumer app (dragon 等) が独自 filter を CSS 経由で data-cdl-role selector
98
+ に適用する。 marker (cdl-arrow-*) は edge routing の SSOT なので cdl 内に維持。 */}
99
+ {/* 光っている edge 用 (14) と光っていない edge 用 (10) を tone ごとに用意する。
100
+ 光っていない edge も tone の色で描くが、 矢頭の大きさは従来通り小さいまま保つ
101
+ (Issue の制約 = 色だけを足す)。 */}
102
+ {Object.entries(TONE).flatMap(([tone, color]) =>
103
+ ([["", 14], ["-sm", 10]] as const).map(([suffix, size]) => (
104
+ <marker
105
+ key={`${tone}${suffix}`}
106
+ id={`cdl-arrow-${tone}${suffix}`}
107
+ viewBox="0 0 10 10"
108
+ refX="9"
109
+ refY="5"
110
+ markerWidth={size}
111
+ markerHeight={size}
112
+ markerUnits="userSpaceOnUse"
113
+ orient="auto-start-reverse"
114
+ >
115
+ <path data-cdl-role="edge-arrowhead" d="M 0 0 L 10 5 L 0 10 z" fill={color} />
116
+ </marker>
117
+ )),
118
+ )}
119
+ {/* tone が TONE 定義に無い edge の受け皿。 visual-validate の tone-marker-ref 軸が
120
+ dead ref を検知するが、 検知するまでの間に矢頭が消えないようにする。 */}
121
+ <marker
122
+ id="cdl-arrow-muted"
123
+ viewBox="0 0 10 10"
124
+ refX="9"
125
+ refY="5"
126
+ markerWidth="10"
127
+ markerHeight="10"
128
+ markerUnits="userSpaceOnUse"
129
+ orient="auto-start-reverse"
130
+ >
131
+ <path data-cdl-role="edge-arrowhead" d="M 0 0 L 10 5 L 0 10 z" fill="var(--cdl-line-mute, #a89d8f)" />
132
+ </marker>
133
+ </defs>
134
+ {/* lanes (contain なら boundary 描く + label、 lifeline なら lane 中央 x に縦点線) */}
135
+ {laid.lanes.map((lane) => (
136
+ <g
137
+ key={lane.id}
138
+ data-cdl-lane={lane.id}
139
+ data-cdl-lane-x={lane.x}
140
+ data-cdl-lane-y={lane.y}
141
+ data-cdl-lane-w={lane.width}
142
+ data-cdl-lane-h={lane.height}
143
+ >
144
+ {/* lane bbox を <g> の boundingBox に anchor するための invisible rect (lane.contain=false でも常に存在) */}
145
+ <rect
146
+ x={lane.x}
147
+ y={lane.y}
148
+ width={lane.width}
149
+ height={lane.height}
150
+ fill="none"
151
+ stroke="none"
152
+ pointerEvents="none"
153
+ />
154
+ {lane.contain && (
155
+ <rect
156
+ data-cdl-role="lane-container"
157
+ x={lane.x}
158
+ y={lane.y}
159
+ width={lane.width}
160
+ height={lane.height}
161
+ rx={24}
162
+ fill="var(--cdl-stage-bg, #fafafa)"
163
+ stroke="var(--cdl-divider, #d4d4d8)"
164
+ strokeWidth={1.5}
165
+ strokeDasharray="8 6"
166
+ opacity={0.7}
167
+ />
168
+ )}
169
+ {lane.lifeline && (() => {
170
+ // lifeline は header (topmost node) の bottom edge から uniformFooterTop
171
+ // (全 lifeline lane 共通、 CdlStage 冒頭で事前計算) までを結ぶ。 単一 node lane も
172
+ // multi-node lane も同じ y2 を使うことで mixed 構成の等長保証を守る (Round 2 major fix)。
173
+ const laneNodes = laid.nodes.filter((n) => n.lane === lane.id);
174
+ if (laneNodes.length === 0) return null;
175
+ const sortedByCy = laneNodes.slice().sort((a, b) => a.cy - b.cy);
176
+ const header = sortedByCy[0]!;
177
+ const y1 = header.cy + header.h / 2;
178
+ const y2 = uniformFooterTop;
179
+ if (y2 <= y1) return null;
180
+ return (
181
+ <line
182
+ data-cdl-role="lane-lifeline"
183
+ x1={lane.x + lane.width / 2}
184
+ y1={y1}
185
+ x2={lane.x + lane.width / 2}
186
+ y2={y2}
187
+ stroke="var(--cdl-line-mute, #a89d8f)"
188
+ strokeWidth={1.5}
189
+ strokeDasharray="6 6"
190
+ opacity={0.7}
191
+ />
192
+ );
193
+ })()}
194
+ {lane.label && (
195
+ <text
196
+ data-cdl-role="lane-label"
197
+ x={lane.x + 24}
198
+ y={lane.y + 26}
199
+ fontSize={20}
200
+ fontWeight={700}
201
+ letterSpacing={2}
202
+ fill="var(--cdl-text-mute, #8a8678)"
203
+ >
204
+ {lane.label}
205
+ </text>
206
+ )}
207
+ </g>
208
+ ))}
209
+
210
+ {/* edges (1st pass) */}
211
+ {laid.edges.map((edge) => (
212
+ <CdlEdgeView key={edge.id} edge={edge} active={activeSet.has(edge.id)} progress={progress} stateValues={stateValues} />
213
+ ))}
214
+
215
+ {/* nodes (2nd pass) */}
216
+ {laid.nodes.map((node) => {
217
+ const view = (
218
+ <CdlNodeView
219
+ node={node}
220
+ active={activeSet.has(node.id)}
221
+ progress={progress}
222
+ stateValues={stateValues}
223
+ currentPhaseId={currentPhase?.id}
224
+ />
225
+ );
226
+ const dy = footerDropById.get(node.id);
227
+ // 下端の名札で絵が上へ出る種別だけ、 絵の上端が箱の上端に来るまで下げる。
228
+ // node の座標そのものは動かさない = 矢印の接続先や当たり判定は箱のままにする
229
+ return dy === undefined ? (
230
+ <g key={node.id}>{view}</g>
231
+ ) : (
232
+ <g key={node.id} transform={`translate(0, ${dy})`}>{view}</g>
233
+ );
234
+ })}
235
+
236
+ {/* 経由 node 貫通する dotted-flow edge の進行点 glow (3rd pass) */}
237
+ {laid.edges
238
+ .filter((edge) => Boolean(edge.dThrough))
239
+ .map((edge) => (
240
+ <CdlEdgeGlowView key={`glow-${edge.id}`} edge={edge} active={activeSet.has(edge.id)} />
241
+ ))}
242
+
243
+ {/* edge labels (3rd pass) ... 全 edge の label を最前面に描画 */}
244
+ {laid.edges.map((edge) => (
245
+ <CdlEdgeLabelView
246
+ key={`label-${edge.id}`}
247
+ edge={edge}
248
+ active={activeSet.has(edge.id)}
249
+ />
250
+ ))}
251
+
252
+ {/* debug overlay = engine が把握している bbox + collision を半透明で重ね描き */}
253
+ {debug && (
254
+ <g pointerEvents="none">
255
+ {laid.bboxes.map((bbox, idx) => {
256
+ const color =
257
+ bbox.kind === "node"
258
+ ? "#3b82f6"
259
+ : bbox.kind === "lane-label"
260
+ ? "#a855f7"
261
+ : bbox.kind === "edge-label"
262
+ ? "#10b981"
263
+ : "#f59e0b";
264
+ return (
265
+ <g key={`${bbox.kind}-${bbox.id}-${idx}`}>
266
+ <rect
267
+ x={bbox.x}
268
+ y={bbox.y}
269
+ width={bbox.w}
270
+ height={bbox.h}
271
+ fill={color}
272
+ fillOpacity={0.08}
273
+ stroke={color}
274
+ strokeWidth={1}
275
+ strokeDasharray="4 3"
276
+ />
277
+ <text
278
+ x={bbox.x + 4}
279
+ y={bbox.y + 14}
280
+ fontSize={10}
281
+ fill={color}
282
+ fontFamily="monospace"
283
+ >
284
+ {bbox.kind}#{bbox.id}
285
+ </text>
286
+ </g>
287
+ );
288
+ })}
289
+ {laid.collisions.map((c, idx) => {
290
+ const xMin = Math.max(c.a.x, c.b.x);
291
+ const yMin = Math.max(c.a.y, c.b.y);
292
+ const xMax = Math.min(c.a.x + c.a.w, c.b.x + c.b.w);
293
+ const yMax = Math.min(c.a.y + c.a.h, c.b.y + c.b.h);
294
+ return (
295
+ <rect
296
+ key={`collision-${idx}`}
297
+ x={xMin}
298
+ y={yMin}
299
+ width={xMax - xMin}
300
+ height={yMax - yMin}
301
+ fill="#ef4444"
302
+ fillOpacity={0.35}
303
+ stroke="#ef4444"
304
+ strokeWidth={2}
305
+ />
306
+ );
307
+ })}
308
+ {laid.nearCollisions.map((nc, idx) => {
309
+ const xMin = Math.min(nc.a.x, nc.b.x);
310
+ const yMin = Math.min(nc.a.y, nc.b.y);
311
+ const xMax = Math.max(nc.a.x + nc.a.w, nc.b.x + nc.b.w);
312
+ const yMax = Math.max(nc.a.y + nc.a.h, nc.b.y + nc.b.h);
313
+ return (
314
+ <g key={`near-${idx}`}>
315
+ <rect
316
+ x={xMin - 2}
317
+ y={yMin - 2}
318
+ width={xMax - xMin + 4}
319
+ height={yMax - yMin + 4}
320
+ fill="none"
321
+ stroke="#eab308"
322
+ strokeWidth={2}
323
+ strokeDasharray="6 4"
324
+ opacity={0.75}
325
+ />
326
+ <text
327
+ x={xMin}
328
+ y={yMin - 6}
329
+ fontSize={10}
330
+ fill="#a16207"
331
+ fontFamily="monospace"
332
+ >
333
+ near {Math.round(nc.gap)}/{nc.required}px
334
+ </text>
335
+ </g>
336
+ );
337
+ })}
338
+ </g>
339
+ )}
340
+ </svg>
341
+ {debug && (
342
+ <div className="mt-2 px-6 text-xs text-[color:var(--cdl-text-dim,#5a6270)]">
343
+ <div>
344
+ <strong>cdl debug</strong> ... bboxes: {laid.bboxes.length}、 collisions:{" "}
345
+ <span style={{ color: laid.collisions.length > 0 ? TONE_HEX.error : TONE_HEX.success }}>
346
+ {laid.collisions.length}
347
+ </span>
348
+ 、 near-collisions:{" "}
349
+ <span style={{ color: laid.nearCollisions.length > 0 ? TONE_HEX.warning : TONE_HEX.success }}>
350
+ {laid.nearCollisions.length}
351
+ </span>
352
+ </div>
353
+ {laid.collisions.length > 0 && (
354
+ <ul className="mt-1 list-disc pl-4">
355
+ {laid.collisions.map((c, idx) => (
356
+ <li key={`c-${idx}`} style={{ color: "#ef4444" }}>
357
+ collision ... {c.a.kind}#{c.a.id} ∩ {c.b.kind}#{c.b.id} ={" "}
358
+ {Math.round(c.overlap_area)}px²
359
+ </li>
360
+ ))}
361
+ </ul>
362
+ )}
363
+ {laid.nearCollisions.length > 0 && (
364
+ <ul className="mt-1 list-disc pl-4">
365
+ {laid.nearCollisions.map((nc, idx) => (
366
+ <li key={`nc-${idx}`} style={{ color: "#a16207" }}>
367
+ near ... {nc.a.kind}#{nc.a.id} ↔ {nc.b.kind}#{nc.b.id} ={" "}
368
+ {Math.round(nc.gap)}px (need {nc.required}px)
369
+ </li>
370
+ ))}
371
+ </ul>
372
+ )}
373
+ </div>
374
+ )}
375
+ </div>
376
+ );
377
+ }
@@ -0,0 +1,64 @@
1
+ import type { Tone } from "../types";
2
+
3
+ /**
4
+ * Tone (= edge / node の意味付き色) の RGB マッピング SSOT。
5
+ * cdl の全 render 経路で同じ色を使う。
6
+ *
7
+ * v0.9+: CSS variable 経由化。 downstream (dragon / chainome) 側で
8
+ * --cdl-tone-{name} を dark theme 用に override 可能。
9
+ * fallback として従来の hex 値を保持、 downstream 未対応時は従来動作。
10
+ */
11
+ export const TONE: Record<Tone, string> = {
12
+ accent: "var(--cdl-tone-accent, #2d6a8f)",
13
+ teal: "var(--cdl-tone-teal, #2f8770)",
14
+ success: "var(--cdl-tone-success, #4ea36a)",
15
+ error: "var(--cdl-tone-error, #c15a4a)",
16
+ warning: "var(--cdl-tone-warning, #a67a2e)",
17
+ info: "var(--cdl-tone-info, #5a8ec1)",
18
+ };
19
+
20
+ /**
21
+ * TONE の hex 値のみ SSOT (contrast / opacity 計算等で raw hex が必要な場合用)。
22
+ * runtime で CSS variable を評価できない環境 (SSR / test) では本 map を参照。
23
+ */
24
+ export const TONE_HEX: Record<Tone, string> = {
25
+ accent: "#2d6a8f",
26
+ teal: "#2f8770",
27
+ success: "#4ea36a",
28
+ error: "#c15a4a",
29
+ warning: "#a67a2e",
30
+ info: "#5a8ec1",
31
+ };
32
+
33
+ /** TONE に無い tone が来た時に使う色。 */
34
+ export const TONE_FALLBACK = "#2d6a8f";
35
+
36
+ /**
37
+ * tone が TONE に定義されているかを返す。
38
+ *
39
+ * `TONE[tone]` の真偽で見てはいけない。 `TONE` は object literal なので `toString` /
40
+ * `constructor` 等の prototype 由来の key が truthy になり、 関数が色として扱われる
41
+ * (実測 = tone "toString" で stroke が落ち、 存在しない marker を指した)。
42
+ */
43
+ export function hasTone(tone: string): boolean {
44
+ return Object.hasOwn(TONE, tone);
45
+ }
46
+
47
+ /** tone の色。 TONE に無い tone は既定色に落とす。 */
48
+ export function toneColor(tone: string): string {
49
+ return hasTone(tone) ? TONE[tone as Tone] : TONE_FALLBACK;
50
+ }
51
+
52
+ /**
53
+ * tone の色を透過付きで塗るための SVG 属性を返す。
54
+ *
55
+ * 背景の淡い塗りに使う。 色を直書きすると、 元の色を直した時に背景だけ古い色で残る
56
+ * (実際に mind-map 系と quadrant で起きていた)。
57
+ *
58
+ * `rgba()` の 1 文字列ではなく `fill` と `fillOpacity` に分けて返す。 `rgba()` は数値の hex から
59
+ * 組み立てるしかなく、 consumer が `--cdl-tone-*` を上書きしても効かない (下流の dark 化で
60
+ * 淡い背景だけ元の色のまま残る)。 分ければ色は CSS 変数のまま渡せる。
61
+ */
62
+ export function toneFill(tone: Tone, alpha: number): { fill: string; fillOpacity: number } {
63
+ return { fill: TONE[tone], fillOpacity: alpha };
64
+ }
@@ -0,0 +1,238 @@
1
+ import { lerp } from "../anim";
2
+ import type { LaidDiagram } from "../types";
3
+
4
+ /**
5
+ * 全 state の現時点 value を計算 (現在 phase + これまでの phase の最終 tween 値を集約)。
6
+ * - set = 即時切替 (phase 到達で value 上書き、 lerp なし)
7
+ * - tween = 数値補間 (現 phase 中は progress で lerp、 過去 phase は to 固定)
8
+ */
9
+ export function computeStateValues(
10
+ laid: LaidDiagram,
11
+ phaseIdx: number,
12
+ progress: number,
13
+ ): Record<string, string> {
14
+ const values: Record<string, number | string> = {};
15
+ for (const s of laid.states) values[s.id] = s.initial;
16
+
17
+ for (let i = 0; i < laid.phases.length; i++) {
18
+ const phase = laid.phases[i];
19
+ if (!phase) continue;
20
+ if (i <= phaseIdx) {
21
+ for (const s of phase.sets) {
22
+ values[s.stateId] = s.value;
23
+ }
24
+ }
25
+ for (const t of phase.tweens) {
26
+ if (i < phaseIdx) {
27
+ values[t.stateId] = t.to;
28
+ } else if (i === phaseIdx) {
29
+ values[t.stateId] = Math.round(lerp(t.from, t.to, progress));
30
+ }
31
+ }
32
+ }
33
+ const out: Record<string, string> = {};
34
+ for (const [k, v] of Object.entries(values)) out[k] = String(v);
35
+ return out;
36
+ }
37
+
38
+ /**
39
+ * template literal `{stateId}` を values map で置換 (未解決は `{id}` のまま)。
40
+ * array signal 拡張 (SSOT): 値が JSON array (例 `"[1,2,3]"`) の signal に対しては、
41
+ * `{sig[0]}` = 要素 0 の値、 `{sig.length}` = 要素数、
42
+ * `{sig.sum}` / `{sig.max}` / `{sig.min}` / `{sig.avg}` = 数値集約 (非数値 element は 0 扱い)。
43
+ * array 記法対象外 signal は従来通り `{sig}` で単純置換。
44
+ */
45
+ export function interpolate(template: string, values: Record<string, string>): string {
46
+ return template.replace(/\{(\w+)(\.length|\.sum|\.max|\.min|\.avg|\[\d+\])?\}/g, (_, id: string, accessor?: string) => {
47
+ const raw = values[id];
48
+ if (raw === undefined) return `{${id}${accessor ?? ""}}`;
49
+ if (!accessor) return raw;
50
+ const arr = parseArraySignal(raw);
51
+ if (!arr) return `{${id}${accessor}}`;
52
+ if (accessor === ".length") return String(arr.length);
53
+ const nums = arr.map((v) => (Number.isFinite(Number(v)) ? Number(v) : 0));
54
+ if (accessor === ".sum") return String(nums.reduce((s, v) => s + v, 0));
55
+ if (accessor === ".max") return nums.length ? String(nums.reduce((a, b) => (a > b ? a : b), nums[0]!)) : "0";
56
+ if (accessor === ".min") return nums.length ? String(nums.reduce((a, b) => (a < b ? a : b), nums[0]!)) : "0";
57
+ if (accessor === ".avg") {
58
+ if (!nums.length) return "0";
59
+ const sum = nums.reduce((s, v) => s + v, 0);
60
+ return String(Math.round((sum / nums.length) * 100) / 100);
61
+ }
62
+ if (accessor.startsWith("[")) {
63
+ const idx = Number(accessor.slice(1, -1));
64
+ if (Number.isInteger(idx) && idx >= 0 && idx < arr.length) return String(arr[idx]);
65
+ return `{${id}${accessor}}`;
66
+ }
67
+ return raw;
68
+ });
69
+ }
70
+
71
+ function parseArraySignal(raw: string): (string | number)[] | null {
72
+ const trimmed = raw.trim();
73
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null;
74
+ try {
75
+ const parsed = JSON.parse(trimmed);
76
+ if (Array.isArray(parsed)) return parsed as (string | number)[];
77
+ } catch {
78
+ // 数値 CSV のみ fallback (`[1,2,3]` 等)、 garbage は不正 array として null 返却
79
+ // → interpolate に `{sig[0]}` そのまま残し, ArrayBarReadout で silent 0 化を防ぐ (author bug 検知)
80
+ const inner = trimmed.slice(1, -1).trim();
81
+ if (!inner) return [];
82
+ if (/^[\s\d.,+\-eE]+$/.test(inner)) return inner.split(",").map((s) => Number(s.trim()));
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * numeric attribute (node.w / h / opacity 等) を template + fallback で解決する SSOT
89
+ * (visual binding foundation)。
90
+ *
91
+ * value が number ならそのまま返す。 string の場合、 全体が `{signal-id}` の pure template なら
92
+ * signal 値を Number 解釈、 一部だけ template を含む string は interpolate で置換後 Number 解釈。
93
+ * 解決失敗時は fallback を返す。
94
+ *
95
+ * 用途例:
96
+ * node.w = 120 → 120 (直接 numeric)
97
+ * node.w = "{barW}" → parseFloat(values.barW ?? "120") で signal 追随
98
+ * node.opacity = "{fade}" → 0..1 の signal 値で fade animation
99
+ */
100
+ export function resolveNumericAttr(
101
+ value: number | string | undefined,
102
+ values: Record<string, string>,
103
+ fallback: number,
104
+ ): number {
105
+ if (typeof value === "number") return value;
106
+ if (typeof value !== "string") return fallback;
107
+ const trimmed = value.trim();
108
+ if (trimmed === "") return fallback;
109
+ const resolved = interpolate(trimmed, values);
110
+ const parsed = Number(resolved);
111
+ return Number.isFinite(parsed) ? parsed : fallback;
112
+ }
113
+
114
+ /**
115
+ * SVG path d 文字列の末尾を shrinkPx だけ手前で切り、 末端で描画される dot を隠す。
116
+ * dotted-flow style で「path 末端に visible になる小 dot」 を消すために使用。
117
+ */
118
+ export function shrinkPathEnd(d: string, shrinkPx: number): string {
119
+ const commandRegex = /([MLCQZmlcqz])([^MLCQZmlcqz]*)/g;
120
+ const matches: Array<{ cmd: string; args: number[] }> = [];
121
+ let m: RegExpExecArray | null;
122
+ while ((m = commandRegex.exec(d)) !== null) {
123
+ const cmd = m[1]!;
124
+ const args = (m[2] ?? "").trim().split(/[\s,]+/).filter(Boolean).map(parseFloat);
125
+ matches.push({ cmd, args });
126
+ }
127
+ if (matches.length < 2) return d;
128
+
129
+ let prevEnd: { x: number; y: number } = { x: 0, y: 0 };
130
+ for (let i = 0; i < matches.length - 1; i++) {
131
+ const { cmd, args } = matches[i]!;
132
+ if (cmd === "M" || cmd === "L") {
133
+ prevEnd = { x: args[0]!, y: args[1]! };
134
+ } else if (cmd === "Q") {
135
+ prevEnd = { x: args[2]!, y: args[3]! };
136
+ } else if (cmd === "C") {
137
+ prevEnd = { x: args[4]!, y: args[5]! };
138
+ }
139
+ }
140
+
141
+ const last = matches[matches.length - 1]!;
142
+ let endX: number, endY: number;
143
+ if (last.cmd === "L" || last.cmd === "M") {
144
+ endX = last.args[0]!;
145
+ endY = last.args[1]!;
146
+ } else if (last.cmd === "Q") {
147
+ endX = last.args[2]!;
148
+ endY = last.args[3]!;
149
+ } else if (last.cmd === "C") {
150
+ endX = last.args[4]!;
151
+ endY = last.args[5]!;
152
+ } else {
153
+ return d;
154
+ }
155
+
156
+ const dx = endX - prevEnd.x;
157
+ const dy = endY - prevEnd.y;
158
+ const len = Math.hypot(dx, dy);
159
+ if (len < shrinkPx + 1) return d;
160
+ const ratio = (len - shrinkPx) / len;
161
+ const newEndX = prevEnd.x + dx * ratio;
162
+ const newEndY = prevEnd.y + dy * ratio;
163
+
164
+ const tokens = d.trim().split(/\s+/);
165
+ if (tokens.length < 2) return d;
166
+ tokens[tokens.length - 2] = newEndX.toFixed(1);
167
+ tokens[tokens.length - 1] = newEndY.toFixed(1);
168
+ return tokens.join(" ");
169
+ }
170
+
171
+ /**
172
+ * SVG path d (M / L のみ対応) を progress (0-1) で切り詰めた新 d を返す。
173
+ * cdl の routePath は直線 + L-bend (M x y L x y [L x y]) のみを返すため、
174
+ * 累積セグメント length で progress * total が属するセグメントを特定し、
175
+ * その上で線形補間して新終点を計算 → `M ... L ... L ...` 形式の部分 path を返す。
176
+ *
177
+ * これにより SVG <path d={subD} markerEnd="..."> で marker が path 末端 (= 進行先端) に
178
+ * SVG renderer が atomic に描画する = 線と矢頭の同期ずれが構造的に発生しない。
179
+ */
180
+ export function pathSubpath(d: string, progress: number): string {
181
+ if (progress >= 1) return d;
182
+ if (progress <= 0) {
183
+ const first = d.trim().match(/^M\s+([-\d.]+)\s+([-\d.]+)/);
184
+ if (!first) return d;
185
+ return `M ${first[1]} ${first[2]}`;
186
+ }
187
+
188
+ const tokens = d.trim().split(/\s+/);
189
+ const points: Array<{ x: number; y: number }> = [];
190
+ let i = 0;
191
+ while (i < tokens.length) {
192
+ const t = tokens[i]!;
193
+ if (t === "M" || t === "L") {
194
+ const x = parseFloat(tokens[i + 1]!);
195
+ const y = parseFloat(tokens[i + 2]!);
196
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return d;
197
+ points.push({ x, y });
198
+ i += 3;
199
+ } else {
200
+ return d;
201
+ }
202
+ }
203
+ if (points.length < 2) return d;
204
+
205
+ const segLens: number[] = [];
206
+ let total = 0;
207
+ for (let k = 1; k < points.length; k++) {
208
+ const dx = points[k]!.x - points[k - 1]!.x;
209
+ const dy = points[k]!.y - points[k - 1]!.y;
210
+ const len = Math.hypot(dx, dy);
211
+ segLens.push(len);
212
+ total += len;
213
+ }
214
+ if (total === 0) return d;
215
+
216
+ const target = total * progress;
217
+ let acc = 0;
218
+ for (let k = 0; k < segLens.length; k++) {
219
+ const segLen = segLens[k]!;
220
+ if (acc + segLen >= target) {
221
+ const remain = target - acc;
222
+ const ratio = segLen === 0 ? 0 : remain / segLen;
223
+ const sx = points[k]!.x;
224
+ const sy = points[k]!.y;
225
+ const ex = points[k + 1]!.x;
226
+ const ey = points[k + 1]!.y;
227
+ const tx = sx + (ex - sx) * ratio;
228
+ const ty = sy + (ey - sy) * ratio;
229
+ const head = points
230
+ .slice(0, k + 1)
231
+ .map((p, idx) => `${idx === 0 ? "M" : "L"} ${p.x} ${p.y}`)
232
+ .join(" ");
233
+ return `${head} L ${tx.toFixed(1)} ${ty.toFixed(1)}`;
234
+ }
235
+ acc += segLen;
236
+ }
237
+ return d;
238
+ }