@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,74 @@
1
+ import { assertNever } from "./assert-never";
2
+ import { signal, type Signal } from "./reactive";
3
+ import type { CdlDiagram, CdlInput } from "./types";
4
+
5
+ /**
6
+ * CdlInput の kind に応じた value type。 slider / number → number、 dropdown → string、 toggle → boolean。
7
+ */
8
+ export type InputSignalValueMap = {
9
+ slider: number;
10
+ number: number;
11
+ dropdown: string;
12
+ toggle: boolean;
13
+ };
14
+
15
+ /**
16
+ * diagram の `inputs` から signal を生成、 id をキーにした map を返す helper (CAR #243)。
17
+ *
18
+ * consumer app 側 = 「HTML widget → onChange で signal.value 更新 → SVG 内 bind が signal.value を read」
19
+ * の bind 経路の起点。 static SSR では signal 生成せず defaultValue を SVG に埋込むだけで良い、
20
+ * client hydration の時点で本 helper を呼んで signal を生成、 widget と bind する。
21
+ *
22
+ * diagram に inputs がない場合は空 object を返す。 id が重複する diagram は builder 側で error になるため
23
+ * 本 helper 内では重複チェックしない (fail-fast は builder 責務)。
24
+ */
25
+ export function createInputSignals(diagram: CdlDiagram): Record<string, Signal<unknown>> {
26
+ // Object.create(null) で prototype なし storage を使う。 通常の `{}` だと
27
+ // `__proto__` / `constructor` / `hasOwnProperty` 等の Object prototype key が
28
+ // input id として来た場合、 assignment が prototype setter に落ちて own property として
29
+ // 登録されず lookup 失敗する (widget bind が silent fail する)。
30
+ // Object.create(null) は Object.prototype を継承しないので全 string を own key として扱える。
31
+ const result = Object.create(null) as Record<string, Signal<unknown>>;
32
+ const inputs = diagram.inputs ?? [];
33
+ for (const input of inputs) {
34
+ result[input.id] = signal(inputDefaultValue(input));
35
+ }
36
+ return result;
37
+ }
38
+
39
+ /**
40
+ * CdlInput から default value を kind-safe に取り出す。 discriminated union なので type narrowing が効く。
41
+ * default 節 assertNever で新 kind 追加時 compile error 検知する (CAR #262)。
42
+ */
43
+ export function inputDefaultValue(input: CdlInput): number | string | boolean {
44
+ switch (input.kind) {
45
+ case "slider":
46
+ case "number":
47
+ case "stepper":
48
+ return input.defaultValue;
49
+ case "dropdown":
50
+ case "radio":
51
+ case "color":
52
+ return input.defaultValue;
53
+ case "toggle":
54
+ return input.defaultValue;
55
+ // xypad は object 値、 単一 signal に string encode で格納 ("x,y" 形式)
56
+ case "xypad":
57
+ return `${input.defaultX},${input.defaultY}`;
58
+ // timeline は 0..1 progress の numeric、 起動時 0 (0 秒目)
59
+ case "timeline":
60
+ return 0;
61
+ // range は "lo,hi" string で単一 signal に格納
62
+ case "range":
63
+ return `${input.defaultLo},${input.defaultHi}`;
64
+ // multi-select は comma-sep string で signal に格納
65
+ case "multi-select":
66
+ return input.defaultValues.join(",");
67
+ case "tabs":
68
+ case "datetime":
69
+ case "text":
70
+ return input.defaultValue;
71
+ default:
72
+ return assertNever(input, "inputDefaultValue");
73
+ }
74
+ }
@@ -0,0 +1,79 @@
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 ActorNode({ node, active, value }: { node: LaidNode; active: boolean; value: string | undefined }): JSX.Element {
7
+ const accent = nodeAccent(node, "var(--cdl-tone-accent, #2d6a8f)");
8
+ const x = node.cx - node.w / 2;
9
+ const y = node.cy - node.h / 2;
10
+ // 小型の箱では名前を中央に置く。 固定の位置のままだと下端をまたぐ (#416)
11
+ const 名前 = titlePlacement(node, { x: 26, y: 86, size: 32 }, { hasEyebrow: Boolean(node.eyebrow) });
12
+ return (
13
+ <g transform={`translate(${x} ${y})`}>
14
+ <rect
15
+ data-cdl-role="node-body"
16
+ x={0}
17
+ y={0}
18
+ width={node.w}
19
+ height={node.h}
20
+ rx={18}
21
+ fill="var(--cdl-node-fill, #ffffff)"
22
+ stroke={active ? accent : "var(--cdl-divider, #d4d4d8)"}
23
+ strokeWidth={active ? 3 : 1.25}
24
+ />
25
+ {node.eyebrow && (
26
+ <text x={26} y={38} fontSize={18} fontWeight={700} letterSpacing={2} fill={accent}>
27
+ {node.eyebrow}
28
+ </text>
29
+ )}
30
+ <text
31
+ data-cdl-role="node-label"
32
+ x={名前.x}
33
+ y={名前.y}
34
+ fontSize={名前.size}
35
+ fontWeight={700}
36
+ fill="var(--cdl-text, #1a1f2a)"
37
+ textAnchor={名前.anchor}
38
+ >
39
+ {node.title}
40
+ </text>
41
+ {/*
42
+ 名前と説明の区切り (#424)。 色は `--cdl-row-divider` を独立して持つ。 枠線と同じ
43
+ `--cdl-divider` を借りていたが、 呼び出し側はこの変数を **枠線の色** として上書きする
44
+ (枠を消すために `transparent` にする等) ため、 区切り線まで一緒に消えていた。
45
+
46
+ 不透明度を下げる指定もやめた = 既定の箱の塗りに対して 1.08 しか無く、 呼び出し側が
47
+ 何も指定しない状態で見えなかった (`storage` と同型、 `#423`)。
48
+ */}
49
+ {(node.subtitle || value) && (
50
+ <line
51
+ data-cdl-role="node-row-divider"
52
+ x1={26}
53
+ y1={110}
54
+ x2={node.w - 26}
55
+ y2={110}
56
+ stroke="var(--cdl-row-divider, #7e848e)"
57
+ />
58
+ )}
59
+ {node.subtitle && (
60
+ <text x={26} y={140} fontSize={20} fill="var(--cdl-text-dim, #5a6270)">
61
+ {node.subtitle}
62
+ </text>
63
+ )}
64
+ {value && (
65
+ <text
66
+ x={node.w - 26}
67
+ y={node.subtitle ? 172 : 152}
68
+ fontSize={42}
69
+ fontWeight={700}
70
+ textAnchor="end"
71
+ fill="var(--cdl-text, #1a1f2a)"
72
+ style={{ fontFamily: "'JetBrains Mono', monospace" }}
73
+ >
74
+ {value}
75
+ </text>
76
+ )}
77
+ </g>
78
+ );
79
+ }
@@ -0,0 +1,61 @@
1
+ import type { JSX } from "react";
2
+ import type { LaidNode } from "../types";
3
+ import { nodeAccent } from "./node-tone";
4
+ import { fitTextSize, isCompactBox } from "./compact-title";
5
+
6
+ export function CardNode({ node, active }: { node: LaidNode; active: boolean }): JSX.Element {
7
+ const accent = nodeAccent(node, "var(--cdl-tone-accent, #2d6a8f)");
8
+ const x = node.cx - node.w / 2;
9
+ const y = node.cy - node.h / 2;
10
+ // w<10 は anchor 用 invisible marker (sequence preset の activation marker 等)
11
+ if (node.w < 10) {
12
+ return <g transform={`translate(${x} ${y})`} />;
13
+ }
14
+ // compact card (h<100) は中央揃え (sequence header 等の小型 card 用)。
15
+ // 高さの境界は `compact-title.ts` が持つ = 4 種と同じ値を使う (#416)
16
+ const compact = isCompactBox(node.h);
17
+ const titleY = compact ? node.h / 2 + 8 : 86;
18
+ const titleX = compact ? node.w / 2 : 26;
19
+ const paddingX = compact ? 16 : 52;
20
+ const availableW = node.w - paddingX;
21
+ const initialTitleSize = compact ? 20 : 24;
22
+ const titleSize = fitTextSize(node.title, availableW, initialTitleSize, 11);
23
+ const textAnchor = compact ? ("middle" as const) : ("start" as const);
24
+ const subtitleSize = node.subtitle ? fitTextSize(node.subtitle, availableW, 21, 11) : 21;
25
+ return (
26
+ <g transform={`translate(${x} ${y})`}>
27
+ <rect
28
+ data-cdl-role="node-body"
29
+ x={0}
30
+ y={0}
31
+ width={node.w}
32
+ height={node.h}
33
+ rx={16}
34
+ fill="var(--cdl-node-fill, #ffffff)"
35
+ stroke={active ? accent : "var(--cdl-divider, #d4d4d8)"}
36
+ strokeWidth={active ? 3 : 1.25}
37
+ />
38
+ {!compact && node.eyebrow && (
39
+ <text x={26} y={38} fontSize={18} fontWeight={700} letterSpacing={2} fill={accent}>
40
+ {node.eyebrow}
41
+ </text>
42
+ )}
43
+ <text
44
+ data-cdl-role="node-label"
45
+ x={titleX}
46
+ y={titleY}
47
+ fontSize={titleSize}
48
+ fontWeight={700}
49
+ fill="var(--cdl-text, #1a1f2a)"
50
+ textAnchor={textAnchor}
51
+ >
52
+ {node.title}
53
+ </text>
54
+ {!compact && node.subtitle && (
55
+ <text x={26} y={124} fontSize={subtitleSize} fill="var(--cdl-text-dim, #5a6270)">
56
+ {node.subtitle}
57
+ </text>
58
+ )}
59
+ </g>
60
+ );
61
+ }
@@ -0,0 +1,121 @@
1
+ import type { JSX } from "react";
2
+ import type { LaidNode } from "../types";
3
+
4
+ /**
5
+ * chart-bar kind ... 1 node に datum 配列 (chartData) を保持し、 SVG rect で棒グラフ描画する。
6
+ * layout は node bbox 内側の canvas に縦棒を等間隔配置、 縦軸 = value 0-max、 横軸 = datum label。
7
+ */
8
+ export function ChartBarNode({ node, active }: { node: LaidNode; active: boolean }): JSX.Element {
9
+ const x0 = node.cx - node.w / 2;
10
+ const y0 = node.cy - node.h / 2;
11
+ const data = node.chartData ?? [];
12
+
13
+ const PAD_TOP = 20;
14
+ const PAD_RIGHT = 24;
15
+ const PAD_BOTTOM = 44;
16
+ const PAD_LEFT = 60;
17
+ const canvasW = node.w - PAD_LEFT - PAD_RIGHT;
18
+ const canvasH = node.h - PAD_TOP - PAD_BOTTOM;
19
+
20
+ const vMax = data.length > 0 ? Math.max(...data.map((d) => d.value)) : 1;
21
+ const vRange = vMax || 1;
22
+
23
+ const barGap = 10;
24
+ const barCount = data.length || 1;
25
+ const barW = (canvasW - barGap * (barCount + 1)) / barCount;
26
+
27
+ const yAt = (v: number) => PAD_TOP + canvasH - (v / vRange) * canvasH;
28
+ const xAt = (idx: number) => PAD_LEFT + barGap + idx * (barW + barGap);
29
+
30
+ const gridCount = 4;
31
+ const gridTicks = Array.from({ length: gridCount + 1 }, (_, i) => {
32
+ const v = (vMax * i) / gridCount;
33
+ return { y: yAt(v), value: v };
34
+ });
35
+
36
+ return (
37
+ <g transform={`translate(${x0} ${y0})`}>
38
+ <rect
39
+ data-cdl-role="node-body"
40
+ x={0}
41
+ y={0}
42
+ width={node.w}
43
+ height={node.h}
44
+ rx={16}
45
+ fill="var(--cdl-node-fill, #ffffff)"
46
+ stroke={active ? "var(--cdl-tone-accent, #2d6a8f)" : "var(--cdl-divider, #d4d4d8)"}
47
+ strokeWidth={active ? 3 : 1.25}
48
+ />
49
+ {gridTicks.map((t, i) => (
50
+ <g key={`grid-${i}`}>
51
+ <line
52
+ x1={PAD_LEFT}
53
+ y1={t.y}
54
+ x2={PAD_LEFT + canvasW}
55
+ y2={t.y}
56
+ stroke="var(--cdl-divider, #d4d4d8)"
57
+ strokeWidth={0.75}
58
+ strokeDasharray="3 3"
59
+ opacity={0.65}
60
+ />
61
+ <text
62
+ x={PAD_LEFT - 8}
63
+ y={t.y + 4}
64
+ fontSize={11}
65
+ textAnchor="end"
66
+ fill="var(--cdl-text-dim, #5a6270)"
67
+ >
68
+ {Math.round(t.value)}
69
+ </text>
70
+ </g>
71
+ ))}
72
+
73
+ <line
74
+ x1={PAD_LEFT}
75
+ y1={PAD_TOP + canvasH}
76
+ x2={PAD_LEFT + canvasW}
77
+ y2={PAD_TOP + canvasH}
78
+ stroke="var(--cdl-text-mute, #8a8678)"
79
+ strokeWidth={1.25}
80
+ />
81
+
82
+ {data.map((d, idx) => {
83
+ const bx = xAt(idx);
84
+ const by = yAt(d.value);
85
+ const bh = PAD_TOP + canvasH - by;
86
+ return (
87
+ <g key={`bar-${idx}`}>
88
+ <rect
89
+ data-cdl-role="chart-bar"
90
+ x={bx}
91
+ y={by}
92
+ width={barW}
93
+ height={bh}
94
+ rx={2}
95
+ fill="var(--cdl-tone-accent, #2d6a8f)"
96
+ fillOpacity={0.9}
97
+ />
98
+ <text
99
+ x={bx + barW / 2}
100
+ y={by - 6}
101
+ fontSize={11}
102
+ textAnchor="middle"
103
+ fill="var(--cdl-text, #1a1f2a)"
104
+ >
105
+ {d.value.toLocaleString()}
106
+ </text>
107
+ <text
108
+ x={bx + barW / 2}
109
+ y={PAD_TOP + canvasH + 18}
110
+ fontSize={12}
111
+ textAnchor="middle"
112
+ fill="var(--cdl-text-dim, #5a6270)"
113
+ >
114
+ {d.label}
115
+ </text>
116
+ </g>
117
+ );
118
+ })}
119
+ </g>
120
+ );
121
+ }
@@ -0,0 +1,163 @@
1
+ import type { JSX } from "react";
2
+ import type { LaidNode } from "../types";
3
+
4
+ /**
5
+ * chart-line kind ... 1 node に datum 配列 (chartData) を保持し、 SVG polyline で折れ線描画する。
6
+ * layout は node bbox (node.w × node.h) 内側に padding (top/right/bottom/left) を確保し、
7
+ * その内側 rect を chart canvas とみなす。 縦軸 = value min-max、 横軸 = datum index。
8
+ */
9
+ export function ChartLineNode({ node, active }: { node: LaidNode; active: boolean }): JSX.Element {
10
+ const x0 = node.cx - node.w / 2;
11
+ const y0 = node.cy - node.h / 2;
12
+ const data = node.chartData ?? [];
13
+
14
+ const PAD_TOP = 36;
15
+ const PAD_RIGHT = 40;
16
+ const PAD_BOTTOM = 56;
17
+ const PAD_LEFT = 72;
18
+ const canvasW = node.w - PAD_LEFT - PAD_RIGHT;
19
+ const canvasH = node.h - PAD_TOP - PAD_BOTTOM;
20
+
21
+ const values = data.map((d) => d.value);
22
+ const vMin = values.length > 0 ? Math.min(...values) : 0;
23
+ const vMax = values.length > 0 ? Math.max(...values) : 1;
24
+ const vRange = vMax - vMin || 1;
25
+
26
+ // 最大値 / 最小値の点が描画領域の上下端に張り付くと、 値 label が線に重なる。
27
+ // 上下に label 1 行ぶんの余白を確保して、 その内側に折れ線を描く。
28
+ const LABEL_ROOM = 20;
29
+ const plotTop = PAD_TOP + LABEL_ROOM;
30
+ const plotH = Math.max(canvasH - LABEL_ROOM * 2, 1);
31
+
32
+ const xAt = (idx: number) =>
33
+ PAD_LEFT + (data.length <= 1 ? canvasW / 2 : (canvasW * idx) / (data.length - 1));
34
+ const yAt = (v: number) => plotTop + plotH - ((v - vMin) / vRange) * plotH;
35
+
36
+ const points = data.map((d, idx) => `${xAt(idx)},${yAt(d.value)}`).join(" ");
37
+
38
+ const gridCount = 4;
39
+ const gridTicks = Array.from({ length: gridCount + 1 }, (_, i) => {
40
+ const v = vMin + (vRange * i) / gridCount;
41
+ return { y: yAt(v), value: v };
42
+ });
43
+
44
+ return (
45
+ <g transform={`translate(${x0} ${y0})`}>
46
+ <rect
47
+ data-cdl-role="node-body"
48
+ x={0}
49
+ y={0}
50
+ width={node.w}
51
+ height={node.h}
52
+ rx={16}
53
+ fill="var(--cdl-node-fill, #ffffff)"
54
+ stroke={active ? "var(--cdl-tone-accent, #2d6a8f)" : "var(--cdl-divider, #d4d4d8)"}
55
+ strokeWidth={active ? 3 : 1.25}
56
+ />
57
+ {gridTicks.map((t, i) => (
58
+ <g key={`grid-${i}`}>
59
+ <line
60
+ x1={PAD_LEFT}
61
+ y1={t.y}
62
+ x2={PAD_LEFT + canvasW}
63
+ y2={t.y}
64
+ stroke="var(--cdl-divider, #d4d4d8)"
65
+ strokeWidth={0.75}
66
+ strokeDasharray="3 3"
67
+ opacity={0.65}
68
+ />
69
+ <text
70
+ x={PAD_LEFT - 8}
71
+ y={t.y + 4}
72
+ fontSize={11}
73
+ textAnchor="end"
74
+ fill="var(--cdl-text-dim, #5a6270)"
75
+ >
76
+ {Math.round(t.value)}
77
+ </text>
78
+ </g>
79
+ ))}
80
+
81
+ <line
82
+ x1={PAD_LEFT}
83
+ y1={plotTop + plotH}
84
+ x2={PAD_LEFT + canvasW}
85
+ y2={plotTop + plotH}
86
+ stroke="var(--cdl-text-mute, #8a8678)"
87
+ strokeWidth={1.25}
88
+ />
89
+ <line
90
+ x1={PAD_LEFT}
91
+ y1={plotTop}
92
+ x2={PAD_LEFT}
93
+ y2={plotTop + plotH}
94
+ stroke="var(--cdl-text-mute, #8a8678)"
95
+ strokeWidth={1.25}
96
+ />
97
+
98
+ {data.length > 1 && (
99
+ <polyline
100
+ data-cdl-role="chart-line"
101
+ points={points}
102
+ fill="none"
103
+ stroke="var(--cdl-tone-accent, #2d6a8f)"
104
+ strokeWidth={2.5}
105
+ strokeLinejoin="round"
106
+ strokeLinecap="round"
107
+ />
108
+ )}
109
+ {data.map((d, idx) => {
110
+ const cx = xAt(idx);
111
+ const cy = yAt(d.value);
112
+ // value label は marker の上に置くのが基本、 canvas 上端 6px より内側になる場合は
113
+ // 下に反転して他要素と重ならないようにする
114
+ // 折れ線に重ならない側へ置く。 山 (両隣より高い) なら上、 谷なら下。
115
+ // 単調な区間では上に置く。 上下端には LABEL_ROOM を確保してあるので反転は不要。
116
+ const prev = idx > 0 ? data[idx - 1]!.value : undefined;
117
+ const next = idx < data.length - 1 ? data[idx + 1]!.value : undefined;
118
+ const isValley =
119
+ (prev === undefined || d.value <= prev) && (next === undefined || d.value <= next);
120
+ const labelAbove = !isValley;
121
+ const labelY = labelAbove ? cy - 12 : cy + 18;
122
+ return (
123
+ <g key={`pt-${idx}`}>
124
+ <circle
125
+ cx={cx}
126
+ cy={cy}
127
+ r={5}
128
+ fill="var(--cdl-node-fill, #ffffff)"
129
+ stroke="var(--cdl-tone-accent, #2d6a8f)"
130
+ strokeWidth={2.5}
131
+ />
132
+ <circle
133
+ cx={cx}
134
+ cy={cy}
135
+ r={2.5}
136
+ fill="var(--cdl-tone-accent, #2d6a8f)"
137
+ />
138
+ <text
139
+ data-cdl-role="chart-line-value"
140
+ x={cx}
141
+ y={labelY}
142
+ fontSize={11}
143
+ fontWeight={600}
144
+ textAnchor="middle"
145
+ fill="var(--cdl-text, #1a1f2a)"
146
+ >
147
+ {d.value.toLocaleString()}
148
+ </text>
149
+ <text
150
+ x={cx}
151
+ y={PAD_TOP + canvasH + 20}
152
+ fontSize={12}
153
+ textAnchor="middle"
154
+ fill="var(--cdl-text-dim, #5a6270)"
155
+ >
156
+ {d.label}
157
+ </text>
158
+ </g>
159
+ );
160
+ })}
161
+ </g>
162
+ );
163
+ }
@@ -0,0 +1,107 @@
1
+ import type { JSX } from "react";
2
+ import type { LaidNode, Tone } from "../types";
3
+ import { TONE } from "../render/tone";
4
+
5
+ /**
6
+ * chart-pie kind ... 1 node に datum 配列 (chartData) を保持し、 SVG path arc で円グラフ描画する。
7
+ * layout は node bbox 内側の左半分に円を配置、 右半分に凡例 (label + %) を stack 表示する。
8
+ */
9
+ export function ChartPieNode({ node, active }: { node: LaidNode; active: boolean }): JSX.Element {
10
+ const x0 = node.cx - node.w / 2;
11
+ const y0 = node.cy - node.h / 2;
12
+ const data = node.chartData ?? [];
13
+ const total = data.reduce((acc, d) => acc + d.value, 0) || 1;
14
+
15
+ const PAD_TOP = 20;
16
+ const PAD_BOTTOM = 20;
17
+ const chartAreaH = node.h - PAD_TOP - PAD_BOTTOM;
18
+ const pieAreaW = node.w * 0.5;
19
+ const radius = Math.min(pieAreaW, chartAreaH) / 2 - 12;
20
+ const cx = pieAreaW / 2;
21
+ const cy = PAD_TOP + chartAreaH / 2;
22
+
23
+ let cumAngle = -Math.PI / 2;
24
+ const slices = data.map((d) => {
25
+ const frac = d.value / total;
26
+ const angle = frac * Math.PI * 2;
27
+ const a0 = cumAngle;
28
+ const a1 = cumAngle + angle;
29
+ cumAngle = a1;
30
+
31
+ const x1 = cx + radius * Math.cos(a0);
32
+ const y1 = cy + radius * Math.sin(a0);
33
+ const x2 = cx + radius * Math.cos(a1);
34
+ const y2 = cy + radius * Math.sin(a1);
35
+ const large = angle > Math.PI ? 1 : 0;
36
+ const path = `M ${cx} ${cy} L ${x1} ${y1} A ${radius} ${radius} 0 ${large} 1 ${x2} ${y2} Z`;
37
+ return { d, frac, path };
38
+ });
39
+
40
+ const legendX = pieAreaW + 8;
41
+ const legendGap = Math.min(28, chartAreaH / Math.max(data.length, 1));
42
+ const legendStartY = PAD_TOP + (chartAreaH - legendGap * data.length) / 2 + legendGap / 2;
43
+
44
+ return (
45
+ <g transform={`translate(${x0} ${y0})`}>
46
+ <rect
47
+ data-cdl-role="node-body"
48
+ x={0}
49
+ y={0}
50
+ width={node.w}
51
+ height={node.h}
52
+ rx={16}
53
+ fill="var(--cdl-node-fill, #ffffff)"
54
+ stroke={active ? "var(--cdl-tone-accent, #2d6a8f)" : "var(--cdl-divider, #d4d4d8)"}
55
+ strokeWidth={active ? 3 : 1.25}
56
+ />
57
+ {slices.map((s, idx) => (
58
+ <path
59
+ key={`slice-${idx}`}
60
+ data-cdl-role="chart-pie-slice"
61
+ d={s.path}
62
+ fill={sliceColor(s.d.tone, idx)}
63
+ fillOpacity={active ? 0.95 : 0.85}
64
+ stroke="var(--cdl-node-fill, #ffffff)"
65
+ strokeWidth={1.5}
66
+ />
67
+ ))}
68
+
69
+ {slices.map((s, idx) => (
70
+ <g key={`legend-${idx}`} transform={`translate(${legendX} ${legendStartY + idx * legendGap})`}>
71
+ <rect
72
+ x={0}
73
+ y={-8}
74
+ width={14}
75
+ height={14}
76
+ rx={3}
77
+ fill={sliceColor(s.d.tone, idx)}
78
+ />
79
+ <text
80
+ x={20}
81
+ y={2}
82
+ fontSize={12}
83
+ fill="var(--cdl-text, #1a1f2a)"
84
+ >
85
+ {s.d.label}
86
+ </text>
87
+ <text
88
+ x={20}
89
+ y={16}
90
+ fontSize={11}
91
+ fill="var(--cdl-text-dim, #5a6270)"
92
+ >
93
+ {(s.frac * 100).toFixed(1)}%
94
+ </text>
95
+ </g>
96
+ ))}
97
+ </g>
98
+ );
99
+ }
100
+
101
+
102
+ const FALLBACK_ORDER: Tone[] = ["accent", "teal", "warning", "success", "info", "error"];
103
+
104
+ function sliceColor(tone: Tone | undefined, idx: number): string {
105
+ if (tone) return TONE[tone];
106
+ return TONE[FALLBACK_ORDER[idx % FALLBACK_ORDER.length]!];
107
+ }