@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,862 @@
1
+ import type { JSX } from "react";
2
+ import { shapeRegion, labelPlan, fitArt, textWidth, type ArtExtent } from "./shape-region";
3
+ import type { LaidNode } from "../types";
4
+
5
+ /**
6
+ * Shape-driven hardware 6 kinds (CAR-1111 Phase 2-C)。
7
+ *
8
+ * ハードウェア / IoT / エッジ領域の視覚要素 = server-rack / network-node /
9
+ * mobile-device / iot-sensor / robot-arm / satellite を SVG path で直接描画する。
10
+ *
11
+ * 各 kind は 1 SVG path (+ 補助 shape) + title + optional subtitle 構成、
12
+ * fill / stroke は theme (var(--cdl-*)) 追従、 active 時は accent stroke + width 拡張。
13
+ *
14
+ * ## 箱に収める (#433)
15
+ *
16
+ * 高さ 72 の名札 (幅 140、 説明つき) で実測すると、 4 種が箱の下か左右へ出ていた。
17
+ *
18
+ * | 種別 | 下 | 左右 | 出ていた理由 |
19
+ * |---|---|---|---|
20
+ * | server-rack | 94 | 0 | 本体の高さは箱から決めるのに、 中の 1 U は固定の高さで積んでいた |
21
+ * | network-node | 55 | 21.9 | 箱を見ずに中心から 105 広がる絵を描いていた |
22
+ * | mobile-device | 26.2 | 0 | 絵は収まっていた = 名前を絵の下 (箱の外) に置いていた |
23
+ * | satellite | 2 | 33 | 箱を見ずに固定の寸法で描いていた |
24
+ *
25
+ * 直し方は 2 通りで、 どちらも「名前の場所を先に決め、 残った帯に絵を描く」 が土台になる
26
+ * (`shapeRegion` / `labelPlan`)。 絵が帯より大きい時は `fitArt` が縦横の比を保って縮める。
27
+ * 名前は箱の高さだけでなく **幅にも収める** = 字の種類ごとの幅から行ごとに縮める
28
+ * (`幅に収める` + 共有の `textWidth`)。
29
+ *
30
+ * **上へのはみ出しは直していない**。 何ともぶつからず図の外にも出ないことを呼び出し側が
31
+ * 実測済で、 残す判断をしている (`shape-robot-arm` の 129 も同じ)。
32
+ *
33
+ * ## 自然な寸法は変更前のまま
34
+ *
35
+ * 絵の帯は名前の塊の分だけ箱より狭い (高さ 400 の箱で 345.6、 高さ 200 の箱で 145.6)。 帯より
36
+ * 大きい絵は `fitArt` が縮める = **低い箱で縮むのは設計どおりで欠陥ではない**。
37
+ *
38
+ * 守るのは「絵が帯に収まる高さの範囲では、 外接矩形の大きさと位置が箱の高さで変わらない」 =
39
+ * `fitArt` が働かない範囲では自然な寸法そのままで描かれること。 4 種の自然な高さは 101 から
40
+ * 243 で、 高さ 400 以上の箱ではどの文字を書いても帯に収まる。
41
+ */
42
+
43
+ type ShapeProps = {
44
+ node: LaidNode;
45
+ active: boolean;
46
+ };
47
+
48
+ const commonFrame = (active: boolean) => ({
49
+ fill: "var(--cdl-node-fill, #ffffff)",
50
+ stroke: active ? "var(--cdl-tone-accent, #2d6a8f)" : "var(--cdl-divider, #d4d4d8)",
51
+ strokeWidth: active ? 3 : 1.5,
52
+ });
53
+
54
+ const externalLabel = (node: LaidNode, cx: number, bottomY: number) => (
55
+ <>
56
+ {node.eyebrow && (
57
+ <text
58
+ x={cx}
59
+ y={node.cy - node.h / 2 - 8}
60
+ fontSize={12}
61
+ fontWeight={700}
62
+ letterSpacing={2}
63
+ textAnchor="middle"
64
+ fill="var(--cdl-tone-accent, #2d6a8f)"
65
+ >
66
+ {node.eyebrow}
67
+ </text>
68
+ )}
69
+ <text
70
+ data-cdl-role="node-label"
71
+ x={cx}
72
+ y={bottomY}
73
+ fontSize={15}
74
+ fontWeight={700}
75
+ textAnchor="middle"
76
+ fill="var(--cdl-text, #1a1f2a)"
77
+ >
78
+ {node.title}
79
+ </text>
80
+ {node.subtitle && (
81
+ <text
82
+ x={cx}
83
+ y={bottomY + 16}
84
+ fontSize={11}
85
+ textAnchor="middle"
86
+ fill="var(--cdl-text-dim, #5a6270)"
87
+ >
88
+ {node.subtitle}
89
+ </text>
90
+ )}
91
+ </>
92
+ );
93
+
94
+ /** 肩書の字間。 */
95
+ const LABEL_EYEBROW_SPACING = 2;
96
+
97
+ /**
98
+ * 1 行を箱の幅に収める字の大きさと字間。
99
+ *
100
+ * `labelPlan` は箱の **高さ** だけから字の大きさを決めていて、 幅も字数も見ていない。 その
101
+ * ままだと長い名前や幅の広い字が箱の左右へ出る (幅 140 の箱で `W` 20 個は 18px で 373.7 =
102
+ * 左右へ 117)。 絵と違って `fitArt` は名前を包まないので、 ここで行ごとに縮める。
103
+ *
104
+ * 字幅の見積りは共有の `textWidth` (実測を階級で丸め上げた表) が持つ。 字間も同じ割合で
105
+ * 詰める = 大きさだけ縮めても字間が残ると幅が比例して縮まない。
106
+ */
107
+ function 幅に収める(文字列: string, 大きさ: number, 字間: number, 箱幅: number) {
108
+ const 幅 = textWidth(文字列, 大きさ, 字間);
109
+ const 倍率 = 幅 > 0 && 箱幅 > 0 ? Math.min(1, 箱幅 / 幅) : 1;
110
+ return { size: 大きさ * 倍率, spacing: 字間 * 倍率 };
111
+ }
112
+
113
+ /**
114
+ * 名前の塊を描く。 縦の位置と大きさは `labelPlan` が決め (箱の下端から積み上げる、 #433)、
115
+ * 横は `幅に収める` が箱の幅に合わせて縮める。
116
+ *
117
+ * `externalLabel` と違い、 **箱の中に必ず収まる**。 字の大きさも箱の高さに追随する。
118
+ */
119
+ function bottomLabel(node: LaidNode, cx: number, plan: ReturnType<typeof labelPlan>) {
120
+ const 肩 = 幅に収める(node.eyebrow ?? "", plan.肩書?.size ?? 0, LABEL_EYEBROW_SPACING, node.w);
121
+ const 名 = 幅に収める(node.title ?? "", plan.名前.size, 0, node.w);
122
+ const 説 = 幅に収める(node.subtitle ?? "", plan.説明?.size ?? 0, 0, node.w);
123
+ return (
124
+ <>
125
+ {node.eyebrow && plan.肩書 && (
126
+ <text x={cx} y={plan.肩書.y} fontSize={肩.size} fontWeight={700} letterSpacing={肩.spacing} textAnchor="middle" fill="var(--cdl-tone-accent, #2d6a8f)">
127
+ {node.eyebrow}
128
+ </text>
129
+ )}
130
+ <text data-cdl-role="node-label" x={cx} y={plan.名前.y} fontSize={名.size} fontWeight={700} textAnchor="middle" fill="var(--cdl-text, #1a1f2a)">
131
+ {node.title}
132
+ </text>
133
+ {node.subtitle && plan.説明 && (
134
+ <text x={cx} y={plan.説明.y} fontSize={説.size} textAnchor="middle" fill="var(--cdl-text-dim, #5a6270)">
135
+ {node.subtitle}
136
+ </text>
137
+ )}
138
+ </>
139
+ );
140
+ }
141
+
142
+ /**
143
+ * ラックの自然な寸法。 **箱の高さで伸ばさない** (#433)。
144
+ *
145
+ * 変更前は本体の高さを `node.h - 42` で決めながら、 中の 1 U は固定の高さで積んでいた。
146
+ * 高さ 208 未満の箱では 1 U が本体をはみ出し、 高さ 72 の名札では箱の下へ 94 出ていた。
147
+ * 高さを「1 U × 台数」 から決めれば、 中身は本体に必ず収まる。
148
+ */
149
+ const RACK_SLOT_TOP = 20;
150
+ const RACK_SLOT_H = 32;
151
+ const RACK_SLOT_GAP = 6;
152
+ const RACK_SLOT_N = 4;
153
+ const RACK_BOTTOM = 8;
154
+ const RACK_H =
155
+ RACK_SLOT_TOP + RACK_SLOT_N * RACK_SLOT_H + (RACK_SLOT_N - 1) * RACK_SLOT_GAP + RACK_BOTTOM;
156
+
157
+ /**
158
+ * 本体の幅の下限。 中身 (ドライブベイの右端 68 と右の通気口の左端 `幅 - 42`) が重ならない値。
159
+ *
160
+ * 上限は置かない = 変更前と同じく箱の幅いっぱいに広がる。 下限を割る箱では `fitArt` が
161
+ * 縦横の比を保って縮める。
162
+ */
163
+ const RACK_W_MIN = 120;
164
+
165
+ /** shape-server-rack = 19 inch rack (top vent + 4 U rack mount server + power indicator) */
166
+ export function ShapeServerRackNode({ node, active }: ShapeProps): JSX.Element {
167
+ const r = shapeRegion(node);
168
+ const frame = commonFrame(active);
169
+ const 線 = frame.strokeWidth / 2;
170
+ // 枠は本体の外へ 線 だけ出る。 外形が箱の幅ちょうどになるよう、 先に本体を細める
171
+ const 外幅 = Math.max(r.availW, RACK_W_MIN);
172
+ const bodyW = Math.max(0, 外幅 - frame.strokeWidth);
173
+ const x = r.cx - bodyW / 2;
174
+ const y = r.cy - RACK_H / 2;
175
+ const slotStart = y + RACK_SLOT_TOP;
176
+ const 広がり: ArtExtent = {
177
+ 上: RACK_H / 2 + 線,
178
+ 下: RACK_H / 2 + 線,
179
+ 左: 外幅 / 2,
180
+ 右: 外幅 / 2,
181
+ };
182
+ return (
183
+ <g>
184
+ {fitArt(r, 広がり, (
185
+ <g>
186
+ <rect
187
+ data-cdl-role="node-body"
188
+ x={x}
189
+ y={y}
190
+ width={bodyW}
191
+ height={RACK_H}
192
+ rx={6}
193
+ {...frame}
194
+ />
195
+ {/* top vent grille = 6 horizontal slits */}
196
+ {[0, 1, 2, 3, 4, 5].map((i) => (
197
+ <rect
198
+ key={i}
199
+ x={x + 10 + i * ((bodyW - 20) / 6)}
200
+ y={y + 4}
201
+ width={(bodyW - 20) / 6 - 4}
202
+ height={4}
203
+ rx={1}
204
+ fill="var(--cdl-divider, #d4d4d8)"
205
+ fillOpacity={0.5}
206
+ stroke="none"
207
+ />
208
+ ))}
209
+ {/* 4 rack mount server (1 U 相当) */}
210
+ {Array.from({ length: RACK_SLOT_N }, (_, i) => {
211
+ const sy = slotStart + i * (RACK_SLOT_H + RACK_SLOT_GAP);
212
+ const isActive = i === 0;
213
+ return (
214
+ <g key={i}>
215
+ <rect
216
+ x={x + 8}
217
+ y={sy}
218
+ width={bodyW - 16}
219
+ height={RACK_SLOT_H}
220
+ rx={2}
221
+ fill="var(--cdl-divider, #d4d4d8)"
222
+ fillOpacity={i === 0 ? 0.15 : 0.4}
223
+ stroke={frame.stroke}
224
+ strokeWidth={1}
225
+ />
226
+ {/* left power LED + HDD LED */}
227
+ <circle cx={x + 16} cy={sy + RACK_SLOT_H / 2} r={2.5} fill={isActive ? "#27c93f" : "#4a5266"} stroke="none">
228
+ {isActive && <animate attributeName="opacity" values="1;0.3;1" dur="1.5s" repeatCount="indefinite" />}
229
+ </circle>
230
+ <circle cx={x + 24} cy={sy + RACK_SLOT_H / 2} r={2.5} fill={isActive ? "#ffbd2e" : "#4a5266"} stroke="none">
231
+ {isActive && <animate attributeName="opacity" values="0.4;1;0.4" dur="0.6s" repeatCount="indefinite" />}
232
+ </circle>
233
+ {/* mid drive bays (2 slots) */}
234
+ <rect x={x + 34} y={sy + 6} width={16} height={RACK_SLOT_H - 12} rx={1} fill="#2a3140" opacity={0.35} stroke="none" />
235
+ <rect x={x + 52} y={sy + 6} width={16} height={RACK_SLOT_H - 12} rx={1} fill="#2a3140" opacity={0.35} stroke="none" />
236
+ {/* right vent grille */}
237
+ {[0, 1, 2, 3].map((j) => (
238
+ <rect
239
+ key={j}
240
+ x={x + bodyW - 42 + j * 8}
241
+ y={sy + RACK_SLOT_H / 2 - 6}
242
+ width={4}
243
+ height={12}
244
+ rx={1}
245
+ fill="var(--cdl-divider, #d4d4d8)"
246
+ fillOpacity={0.6}
247
+ stroke="none"
248
+ />
249
+ ))}
250
+ </g>
251
+ );
252
+ })}
253
+ </g>
254
+ ))}
255
+ {bottomLabel(node, r.cx, r.label)}
256
+ </g>
257
+ );
258
+ }
259
+
260
+ /**
261
+ * 網の絵の自然な寸法。 **箱を見ずに描く** ので `fitArt` で収める (#433)。
262
+ *
263
+ * 中心から 105 (hub 30 + 腕 68 + 端 7) 広がる。 高さ 72 / 幅 140 の名札では左右へ 21.9、
264
+ * 下へ 55 出ていたが、 **寸法は変更前のまま** で `fitArt` に収めさせる = 帯に収まる箱では
265
+ * 変更前と同じ大きさで描かれる。
266
+ */
267
+ const HUB_R = 30;
268
+ const ARM_LEN = 68;
269
+ const END_R = 7;
270
+
271
+ /** 中央の電波の弧 3 段と、 その基準の高さ。 hub の中に収まる大きさ。 */
272
+ const WIFI_ARC_R = [10, 16, 22];
273
+ const WIFI_BASE_Y = 5;
274
+
275
+ /** shape-network-node = 六角 hub (network signal cone icon + 6-arm radial + node dots) */
276
+ export function ShapeNetworkNodeNode({ node, active }: ShapeProps): JSX.Element {
277
+ const r = shapeRegion(node);
278
+ const cx = r.cx;
279
+ const cy = r.cy;
280
+ const frame = commonFrame(active);
281
+ const 線 = frame.strokeWidth / 2;
282
+ // 6 方向 radial (60 度刻み)
283
+ const dirs = Array.from({ length: 6 }, (_, i) => {
284
+ const a = (i / 6) * Math.PI * 2 - Math.PI / 2;
285
+ return { dx: Math.cos(a), dy: Math.sin(a), i };
286
+ });
287
+ // 広がりは **描く側の値から** 出す。 表に書き写すと腕の長さを変えた時に片方だけ古くなる
288
+ const 端まで = HUB_R + ARM_LEN;
289
+ const 縦広がり = Math.max(...dirs.map((d) => Math.abs(d.dy))) * 端まで + END_R + 線;
290
+ const 横広がり = Math.max(...dirs.map((d) => Math.abs(d.dx))) * 端まで + END_R + 線;
291
+ const 広がり: ArtExtent = { 上: 縦広がり, 下: 縦広がり, 左: 横広がり, 右: 横広がり };
292
+ return (
293
+ <g>
294
+ {fitArt(r, 広がり, (
295
+ <g>
296
+ {/* 6 arms + endpoints */}
297
+ {dirs.map((d) => {
298
+ const ex = cx + d.dx * 端まで;
299
+ const ey = cy + d.dy * 端まで;
300
+ const sx = cx + d.dx * HUB_R;
301
+ const sy = cy + d.dy * HUB_R;
302
+ return (
303
+ <g key={d.i}>
304
+ <line
305
+ x1={sx}
306
+ y1={sy}
307
+ x2={ex}
308
+ y2={ey}
309
+ stroke={frame.stroke}
310
+ strokeWidth={frame.strokeWidth}
311
+ strokeLinecap="round"
312
+ />
313
+ <circle
314
+ cx={ex}
315
+ cy={ey}
316
+ r={END_R}
317
+ fill="var(--cdl-node-fill, #ffffff)"
318
+ stroke={frame.stroke}
319
+ strokeWidth={frame.strokeWidth}
320
+ />
321
+ {/* signal packet dot on line, animate along path */}
322
+ <circle r={2.5} fill="var(--cdl-tone-accent, #2d6a8f)">
323
+ <animate attributeName="cx" values={`${sx};${ex};${sx}`} dur={`${1.5 + d.i * 0.2}s`} repeatCount="indefinite" />
324
+ <animate attributeName="cy" values={`${sy};${ey};${sy}`} dur={`${1.5 + d.i * 0.2}s`} repeatCount="indefinite" />
325
+ </circle>
326
+ </g>
327
+ );
328
+ })}
329
+ {/* hub hex */}
330
+ <polygon
331
+ data-cdl-role="node-body"
332
+ points={dirs.map((d) => `${cx + d.dx * HUB_R},${cy + d.dy * HUB_R}`).join(" ")}
333
+ fill="var(--cdl-node-fill, #ffffff)"
334
+ stroke={frame.stroke}
335
+ strokeWidth={frame.strokeWidth}
336
+ strokeLinejoin="round"
337
+ />
338
+ {/* signal cone icon (wifi 3 段 arc) 中央 */}
339
+ {WIFI_ARC_R.map((arcR, i) => (
340
+ <path
341
+ key={i}
342
+ d={`M ${cx - arcR * 0.75} ${cy + WIFI_BASE_Y} A ${arcR} ${arcR} 0 0 1 ${cx + arcR * 0.75} ${cy + WIFI_BASE_Y}`}
343
+ fill="none"
344
+ stroke="var(--cdl-tone-accent, #2d6a8f)"
345
+ strokeWidth={2}
346
+ opacity={0.85 - i * 0.2}
347
+ strokeLinecap="round"
348
+ />
349
+ ))}
350
+ <circle cx={cx} cy={cy + WIFI_BASE_Y + 3} r={2.5} fill="var(--cdl-tone-accent, #2d6a8f)" stroke="none" />
351
+ </g>
352
+ ))}
353
+ {bottomLabel(node, r.cx, r.label)}
354
+ </g>
355
+ );
356
+ }
357
+
358
+ /**
359
+ * スマホの自然な寸法。 **名前の場所を先に空けてから描く** (#433)。
360
+ *
361
+ * 変更前は本体を `node.h - 20` の高さで箱いっぱいに描き、 名前をその下 (`+22`) に置いていた。
362
+ * 絵そのものは箱に収まっていて、 箱の下へ 26.2 出ていたのは名前の方 = 名前に場所を与えれば
363
+ * 直る。 絵は残った帯に描き、 帯より大きい時だけ `fitArt` が縮める。
364
+ *
365
+ * 寸法は **変更前の既定の箱 (200 x 260) で描かれていた値** をそのまま自然な寸法にする =
366
+ * 高さ 260 の箱での見え方が変わらない。 中の部品 (受話口 / 画面 / ホームボタン) の位置も
367
+ * 変更前の固定値のまま合う。
368
+ */
369
+ const PHONE_H = 240;
370
+ const PHONE_W = 110;
371
+
372
+ /** shape-mobile-device = スマホ (rounded rect + speaker slit + home button) */
373
+ export function ShapeMobileDeviceNode({ node, active }: ShapeProps): JSX.Element {
374
+ const r = shapeRegion(node);
375
+ const frame = commonFrame(active);
376
+ const 線 = frame.strokeWidth / 2;
377
+ const x = r.cx - PHONE_W / 2;
378
+ const y = r.cy - PHONE_H / 2;
379
+ const screenPad = 10;
380
+ const screenTop = y + 26;
381
+ const screenH = PHONE_H - 60;
382
+ const 広がり: ArtExtent = {
383
+ 上: PHONE_H / 2 + 線,
384
+ 下: PHONE_H / 2 + 線,
385
+ 左: PHONE_W / 2 + 線,
386
+ 右: PHONE_W / 2 + 線,
387
+ };
388
+ return (
389
+ <g>
390
+ {fitArt(r, 広がり, (
391
+ <g>
392
+ <rect
393
+ data-cdl-role="node-body"
394
+ x={x}
395
+ y={y}
396
+ width={PHONE_W}
397
+ height={PHONE_H}
398
+ rx={16}
399
+ {...frame}
400
+ />
401
+ <rect
402
+ x={x + PHONE_W / 2 - 18}
403
+ y={y + 12}
404
+ width={36}
405
+ height={4}
406
+ rx={2}
407
+ fill="var(--cdl-divider, #d4d4d8)"
408
+ stroke="none"
409
+ />
410
+ <rect
411
+ x={x + screenPad}
412
+ y={screenTop}
413
+ width={PHONE_W - screenPad * 2}
414
+ height={screenH}
415
+ rx={4}
416
+ fill="var(--cdl-tone-accent, #2d6a8f)"
417
+ fillOpacity={0.12}
418
+ stroke={frame.stroke}
419
+ strokeWidth={1}
420
+ />
421
+ <circle
422
+ cx={x + PHONE_W / 2}
423
+ cy={y + PHONE_H - 16}
424
+ r={7}
425
+ fill="var(--cdl-node-fill, #ffffff)"
426
+ stroke={frame.stroke}
427
+ strokeWidth={frame.strokeWidth}
428
+ />
429
+ </g>
430
+ ))}
431
+ {bottomLabel(node, r.cx, r.label)}
432
+ </g>
433
+ );
434
+ }
435
+
436
+ /** shape-iot-sensor = 円形 sensor + 3 波紋 arc (電波発信、 IoT beacon) */
437
+ export function ShapeIotSensorNode({ node, active }: ShapeProps): JSX.Element {
438
+ const cx = node.cx;
439
+ const cy = node.cy - 14;
440
+ const frame = commonFrame(active);
441
+ const bodyR = 22;
442
+ const waveArcs = [
443
+ { r: 38, opacity: 0.55 },
444
+ { r: 54, opacity: 0.35 },
445
+ { r: 70, opacity: 0.2 },
446
+ ];
447
+ return (
448
+ <g>
449
+ {waveArcs.map((w, i) => (
450
+ <path
451
+ key={i}
452
+ d={`M ${cx - w.r} ${cy} A ${w.r} ${w.r} 0 0 1 ${cx + w.r} ${cy}`}
453
+ fill="none"
454
+ stroke={frame.stroke}
455
+ strokeWidth={frame.strokeWidth}
456
+ opacity={w.opacity}
457
+ strokeLinecap="round"
458
+ />
459
+ ))}
460
+ <circle
461
+ data-cdl-role="node-body"
462
+ cx={cx}
463
+ cy={cy}
464
+ r={bodyR}
465
+ {...frame}
466
+ />
467
+ <circle
468
+ cx={cx}
469
+ cy={cy}
470
+ r={bodyR * 0.4}
471
+ fill="var(--cdl-tone-accent, #2d6a8f)"
472
+ stroke="none"
473
+ />
474
+ {node.eyebrow && (
475
+ <text
476
+ x={cx}
477
+ y={cy - 82}
478
+ fontSize={12}
479
+ fontWeight={700}
480
+ letterSpacing={2}
481
+ textAnchor="middle"
482
+ fill="var(--cdl-tone-accent, #2d6a8f)"
483
+ >
484
+ {node.eyebrow}
485
+ </text>
486
+ )}
487
+ <text
488
+ data-cdl-role="node-label"
489
+ x={cx}
490
+ y={node.cy + node.h / 2 - 20}
491
+ fontSize={15}
492
+ fontWeight={700}
493
+ textAnchor="middle"
494
+ fill="var(--cdl-text, #1a1f2a)"
495
+ >
496
+ {node.title}
497
+ </text>
498
+ {node.subtitle && (
499
+ <text
500
+ x={cx}
501
+ y={node.cy + node.h / 2 - 4}
502
+ fontSize={11}
503
+ textAnchor="middle"
504
+ fill="var(--cdl-text-dim, #5a6270)"
505
+ >
506
+ {node.subtitle}
507
+ </text>
508
+ )}
509
+ </g>
510
+ );
511
+ }
512
+
513
+ /** shape-robot-arm = 産業ロボアーム (base + 3 関節 + 2 爪 gripper、 anatomically correct) */
514
+ export function ShapeRobotArmNode({ node, active }: ShapeProps): JSX.Element {
515
+ const cx = node.cx;
516
+ const baseCenterY = node.cy + node.h / 2 - 60;
517
+ const frame = commonFrame(active);
518
+ const baseW = 90;
519
+ const baseH = 22;
520
+ const baseTop = baseCenterY - baseH / 2;
521
+ // upper arm (shoulder → elbow)
522
+ const shoulderCy = baseTop - 8;
523
+ const elbowX = cx - 10;
524
+ const elbowY = shoulderCy - 60;
525
+ // forearm (elbow → wrist)
526
+ const wristX = cx + 42;
527
+ const wristY = elbowY - 34;
528
+ const armThickness = 14;
529
+ // gripper 2 爪
530
+ const gripperOpen = 12;
531
+ return (
532
+ <g>
533
+ {/* base with mounting bolts */}
534
+ <rect
535
+ data-cdl-role="node-body"
536
+ x={cx - baseW / 2}
537
+ y={baseTop}
538
+ width={baseW}
539
+ height={baseH}
540
+ rx={3}
541
+ {...frame}
542
+ />
543
+ <rect
544
+ x={cx - baseW / 2 - 6}
545
+ y={baseTop + baseH}
546
+ width={baseW + 12}
547
+ height={4}
548
+ rx={1}
549
+ {...frame}
550
+ />
551
+ {[-1, 0, 1].map((i) => (
552
+ <circle
553
+ key={i}
554
+ cx={cx + i * 24}
555
+ cy={baseTop + baseH / 2}
556
+ r={2}
557
+ fill="var(--cdl-divider, #d4d4d8)"
558
+ fillOpacity={0.6}
559
+ stroke="none"
560
+ />
561
+ ))}
562
+ {/* upper arm (shoulder pivot → elbow) */}
563
+ <line
564
+ x1={cx}
565
+ y1={shoulderCy}
566
+ x2={elbowX}
567
+ y2={elbowY}
568
+ stroke={frame.stroke}
569
+ strokeWidth={armThickness}
570
+ strokeLinecap="round"
571
+ />
572
+ <line
573
+ x1={cx}
574
+ y1={shoulderCy}
575
+ x2={elbowX}
576
+ y2={elbowY}
577
+ stroke="var(--cdl-node-fill, #ffffff)"
578
+ strokeWidth={armThickness - 4}
579
+ strokeLinecap="round"
580
+ />
581
+ {/* shoulder joint */}
582
+ <circle
583
+ cx={cx}
584
+ cy={shoulderCy}
585
+ r={12}
586
+ fill="var(--cdl-tone-accent, #2d6a8f)"
587
+ fillOpacity={0.4}
588
+ stroke={frame.stroke}
589
+ strokeWidth={frame.strokeWidth}
590
+ />
591
+ <circle cx={cx} cy={shoulderCy} r={3} fill={frame.stroke} stroke="none" />
592
+ {/* forearm (elbow → wrist) */}
593
+ <line
594
+ x1={elbowX}
595
+ y1={elbowY}
596
+ x2={wristX}
597
+ y2={wristY}
598
+ stroke={frame.stroke}
599
+ strokeWidth={armThickness}
600
+ strokeLinecap="round"
601
+ />
602
+ <line
603
+ x1={elbowX}
604
+ y1={elbowY}
605
+ x2={wristX}
606
+ y2={wristY}
607
+ stroke="var(--cdl-node-fill, #ffffff)"
608
+ strokeWidth={armThickness - 4}
609
+ strokeLinecap="round"
610
+ />
611
+ {/* elbow joint */}
612
+ <circle
613
+ cx={elbowX}
614
+ cy={elbowY}
615
+ r={10}
616
+ fill="var(--cdl-tone-accent, #2d6a8f)"
617
+ fillOpacity={0.4}
618
+ stroke={frame.stroke}
619
+ strokeWidth={frame.strokeWidth}
620
+ />
621
+ <circle cx={elbowX} cy={elbowY} r={2.5} fill={frame.stroke} stroke="none" />
622
+ {/* wrist joint */}
623
+ <circle
624
+ cx={wristX}
625
+ cy={wristY}
626
+ r={8}
627
+ fill="var(--cdl-tone-accent, #2d6a8f)"
628
+ fillOpacity={0.4}
629
+ stroke={frame.stroke}
630
+ strokeWidth={frame.strokeWidth}
631
+ />
632
+ {/* gripper 2 爪 (V 字型) */}
633
+ <line
634
+ x1={wristX}
635
+ y1={wristY - 4}
636
+ x2={wristX - gripperOpen}
637
+ y2={wristY - 22}
638
+ stroke={frame.stroke}
639
+ strokeWidth={5}
640
+ strokeLinecap="round"
641
+ />
642
+ <line
643
+ x1={wristX}
644
+ y1={wristY - 4}
645
+ x2={wristX + gripperOpen}
646
+ y2={wristY - 22}
647
+ stroke={frame.stroke}
648
+ strokeWidth={5}
649
+ strokeLinecap="round"
650
+ />
651
+ {/* 掴んでいる想定の小 box */}
652
+ <rect
653
+ x={wristX - 6}
654
+ y={wristY - 28}
655
+ width={12}
656
+ height={6}
657
+ rx={1}
658
+ fill="var(--cdl-tone-accent, #2d6a8f)"
659
+ fillOpacity={0.5}
660
+ stroke={frame.stroke}
661
+ strokeWidth={1}
662
+ />
663
+ {externalLabel(node, cx, node.cy + node.h / 2 - 18)}
664
+ </g>
665
+ );
666
+ }
667
+
668
+ /**
669
+ * 人工衛星の寸法。 **箱を見ずに描く** ので `fitArt` で収める (#433)。
670
+ *
671
+ * 変更前は幅 140 の名札で左右へ 33、 下へ 2 出ていた。 大きさ自体は自然な大きさの上限
672
+ * (縦 144) に収まる (縦 101) ので、 変更前の寸法をそのまま使う = 大きい箱での見え方は変わらない。
673
+ */
674
+ const SAT_BODY_W = 54;
675
+ const SAT_BODY_H = 48;
676
+ const SAT_PANEL_W = 68;
677
+ const SAT_PANEL_H = 40;
678
+ const SAT_PANEL_GAP = 8;
679
+ const SAT_DISH_R = 12;
680
+ /** 皿アンテナの支柱が皿の底からさらに下る分。 */
681
+ const SAT_DISH_STEM = 12;
682
+ /** 上のアンテナの長さと、 先端の球の半径。 */
683
+ const SAT_MAST_LEN = 22;
684
+ const SAT_MAST_TIP_R = 4;
685
+
686
+ /** shape-satellite = 人工衛星 (body + 左右 solar panel + dish antenna + orbit arc) */
687
+ export function ShapeSatelliteNode({ node, active }: ShapeProps): JSX.Element {
688
+ const r = shapeRegion(node);
689
+ const cx = r.cx;
690
+ const cy = r.cy;
691
+ const frame = commonFrame(active);
692
+ const 線 = frame.strokeWidth / 2;
693
+ const bodyW = SAT_BODY_W;
694
+ const bodyH = SAT_BODY_H;
695
+ const panelW = SAT_PANEL_W;
696
+ const panelH = SAT_PANEL_H;
697
+ const panelGap = SAT_PANEL_GAP;
698
+ const dishR = SAT_DISH_R;
699
+ const orbitRx = r.availW * 0.42;
700
+ const 広がり: ArtExtent = {
701
+ 上: bodyH / 2 + SAT_MAST_LEN + SAT_MAST_TIP_R + 線,
702
+ 下: bodyH / 2 + dishR + SAT_DISH_STEM + 線,
703
+ // 帆と軌道の輪のうち広い方。 軌道は箱の幅から決まるので、 帆より広くなることがある
704
+ 左: Math.max(bodyW / 2 + panelGap + panelW + 線, orbitRx),
705
+ 右: Math.max(bodyW / 2 + panelGap + panelW + 線, orbitRx),
706
+ };
707
+ return (
708
+ <g>
709
+ {fitArt(r, 広がり, (
710
+ <g>
711
+ {/* orbit arc 背景 (点線 ellipse) */}
712
+ <ellipse
713
+ cx={cx}
714
+ cy={cy + 4}
715
+ rx={orbitRx}
716
+ ry={12}
717
+ fill="none"
718
+ stroke="var(--cdl-divider, #d4d4d8)"
719
+ strokeWidth={1}
720
+ strokeDasharray="4 4"
721
+ opacity={0.5}
722
+ />
723
+ {/* left solar panel with photovoltaic cell grid (4x2) */}
724
+ <g>
725
+ <rect
726
+ x={cx - bodyW / 2 - panelGap - panelW}
727
+ y={cy - panelH / 2}
728
+ width={panelW}
729
+ height={panelH}
730
+ rx={2}
731
+ fill="#1a3450"
732
+ stroke={frame.stroke}
733
+ strokeWidth={frame.strokeWidth}
734
+ />
735
+ {[0, 1, 2, 3].map((i) => (
736
+ [0, 1].map((j) => (
737
+ <rect
738
+ key={`L-${i}-${j}`}
739
+ x={cx - bodyW / 2 - panelGap - panelW + 3 + i * (panelW - 6) / 4}
740
+ y={cy - panelH / 2 + 3 + j * (panelH - 6) / 2}
741
+ width={(panelW - 6) / 4 - 2}
742
+ height={(panelH - 6) / 2 - 2}
743
+ rx={1}
744
+ fill="#2d5a90"
745
+ stroke="none"
746
+ opacity={0.85}
747
+ />
748
+ ))
749
+ ))}
750
+ {/* boom */}
751
+ <line
752
+ x1={cx - bodyW / 2 - panelGap}
753
+ y1={cy}
754
+ x2={cx - bodyW / 2}
755
+ y2={cy}
756
+ stroke={frame.stroke}
757
+ strokeWidth={frame.strokeWidth}
758
+ strokeLinecap="round"
759
+ />
760
+ </g>
761
+ {/* right solar panel */}
762
+ <g>
763
+ <rect
764
+ x={cx + bodyW / 2 + panelGap}
765
+ y={cy - panelH / 2}
766
+ width={panelW}
767
+ height={panelH}
768
+ rx={2}
769
+ fill="#1a3450"
770
+ stroke={frame.stroke}
771
+ strokeWidth={frame.strokeWidth}
772
+ />
773
+ {[0, 1, 2, 3].map((i) => (
774
+ [0, 1].map((j) => (
775
+ <rect
776
+ key={`R-${i}-${j}`}
777
+ x={cx + bodyW / 2 + panelGap + 3 + i * (panelW - 6) / 4}
778
+ y={cy - panelH / 2 + 3 + j * (panelH - 6) / 2}
779
+ width={(panelW - 6) / 4 - 2}
780
+ height={(panelH - 6) / 2 - 2}
781
+ rx={1}
782
+ fill="#2d5a90"
783
+ stroke="none"
784
+ opacity={0.85}
785
+ />
786
+ ))
787
+ ))}
788
+ <line
789
+ x1={cx + bodyW / 2 + panelGap}
790
+ y1={cy}
791
+ x2={cx + bodyW / 2}
792
+ y2={cy}
793
+ stroke={frame.stroke}
794
+ strokeWidth={frame.strokeWidth}
795
+ strokeLinecap="round"
796
+ />
797
+ </g>
798
+ {/* main body */}
799
+ <rect
800
+ data-cdl-role="node-body"
801
+ x={cx - bodyW / 2}
802
+ y={cy - bodyH / 2}
803
+ width={bodyW}
804
+ height={bodyH}
805
+ rx={5}
806
+ {...frame}
807
+ />
808
+ {/* body detail = horizontal separator line */}
809
+ <line
810
+ x1={cx - bodyW / 2}
811
+ y1={cy}
812
+ x2={cx + bodyW / 2}
813
+ y2={cy}
814
+ stroke={frame.stroke}
815
+ strokeWidth={1}
816
+ opacity={0.5}
817
+ />
818
+ {/* dish antenna (parabolic reflector) at bottom */}
819
+ <path
820
+ d={`M ${cx - dishR} ${cy + bodyH / 2} Q ${cx} ${cy + bodyH / 2 + dishR + 4}, ${cx + dishR} ${cy + bodyH / 2} Z`}
821
+ fill="var(--cdl-node-fill, #ffffff)"
822
+ stroke={frame.stroke}
823
+ strokeWidth={frame.strokeWidth}
824
+ strokeLinejoin="round"
825
+ />
826
+ <line
827
+ x1={cx}
828
+ y1={cy + bodyH / 2 + dishR + 4}
829
+ x2={cx}
830
+ y2={cy + bodyH / 2 + dishR + SAT_DISH_STEM}
831
+ stroke={frame.stroke}
832
+ strokeWidth={frame.strokeWidth}
833
+ strokeLinecap="round"
834
+ />
835
+ {/* top antenna mast */}
836
+ <line
837
+ x1={cx}
838
+ y1={cy - bodyH / 2}
839
+ x2={cx}
840
+ y2={cy - bodyH / 2 - SAT_MAST_LEN}
841
+ stroke={frame.stroke}
842
+ strokeWidth={frame.strokeWidth}
843
+ strokeLinecap="round"
844
+ />
845
+ <circle
846
+ cx={cx}
847
+ cy={cy - bodyH / 2 - SAT_MAST_LEN}
848
+ r={SAT_MAST_TIP_R}
849
+ fill="var(--cdl-tone-accent, #2d6a8f)"
850
+ stroke={frame.stroke}
851
+ strokeWidth={frame.strokeWidth}
852
+ />
853
+ {/* 信号 dot 上に発射 */}
854
+ <circle cx={cx} cy={cy - bodyH / 2 - SAT_MAST_LEN} r={2} fill="#ff5f56" stroke="none">
855
+ <animate attributeName="opacity" values="0.4;1;0.4" dur="1.4s" repeatCount="indefinite" />
856
+ </circle>
857
+ </g>
858
+ ))}
859
+ {bottomLabel(node, r.cx, r.label)}
860
+ </g>
861
+ );
862
+ }