@cardenelabs/cdl 0.7.0 → 0.9.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 (42) hide show
  1. package/CHANGELOG.md +401 -0
  2. package/SPEC.md +32 -17
  3. package/dist/index.cjs +700 -306
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +68 -14
  6. package/dist/index.d.ts +68 -14
  7. package/dist/index.js +700 -306
  8. package/dist/index.js.map +1 -1
  9. package/dist/react.cjs +572 -196
  10. package/dist/react.cjs.map +1 -1
  11. package/dist/react.d.cts +1 -1
  12. package/dist/react.d.ts +1 -1
  13. package/dist/react.js +572 -196
  14. package/dist/react.js.map +1 -1
  15. package/dist/{render-DquCvgOB.d.cts → render-hmFRAabt.d.cts} +33 -2
  16. package/dist/{render-DquCvgOB.d.ts → render-hmFRAabt.d.ts} +33 -2
  17. package/package.json +6 -3
  18. package/src/kinds/box-lines.ts +120 -0
  19. package/src/kinds/gantt.tsx +152 -24
  20. package/src/kinds/mind-map.tsx +50 -12
  21. package/src/kinds/quadrant.tsx +28 -2
  22. package/src/kinds/shape-blockchain.tsx +65 -12
  23. package/src/kinds/shape-region.tsx +152 -34
  24. package/src/kinds/tree.tsx +92 -20
  25. package/src/layout/collisions.ts +10 -2
  26. package/src/layout/footer-shape.ts +86 -24
  27. package/src/layout/lifeline.ts +80 -0
  28. package/src/layout/spec.ts +21 -2
  29. package/src/layout/text-width.ts +92 -4
  30. package/src/layout/viewbox.ts +0 -8
  31. package/src/layout.ts +8 -1
  32. package/src/presets.ts +120 -22
  33. package/src/render/header.tsx +3 -1
  34. package/src/render/payload-binding.ts +31 -8
  35. package/src/render/stage.tsx +17 -34
  36. package/src/render/template-fields.ts +212 -0
  37. package/src/render/utils.ts +32 -2
  38. package/src/render.tsx +2 -9
  39. package/src/types.ts +33 -2
  40. package/src/validate.ts +27 -9
  41. package/src/visual-validate.ts +34 -1
  42. package/src/layout/label-shift.ts +0 -28
@@ -1,6 +1,8 @@
1
1
  import type { JSX } from "react";
2
2
  import type { LaidNode } from "../types";
3
3
  import { fitArt, labelPlan, shapeRegion, textWidth, type ArtExtent } from "./shape-region";
4
+ import { 幅で切る } from "./box-lines";
5
+ import { MONO_ADVANCE_RATIO, displayWidth } from "./row-align";
4
6
 
5
7
  /**
6
8
  * Shape-driven blockchain / web3 10 kinds (CAR-1111 Phase 2-D)。
@@ -235,7 +237,53 @@ export function ShapeSmartContractNode({ node, active }: ShapeProps): JSX.Elemen
235
237
  );
236
238
  }
237
239
 
238
- /** shape-blockchain-block = 単一 block 詳細 (header + 5 fields: hash/prev/merkle/nonce/tx + chain link icon) */
240
+ /**
241
+ * この絵の中の字の塊の横幅 (#469)。
242
+ *
243
+ * **等幅と比例の見積りの大きい方を取る**。 種別は `ui-monospace` を指定するが、 それが実際に
244
+ * 効くかは載せる側の CSS で決まる。 dragon の `cdl-theme.css` は `font-family` を `!important`
245
+ * で当てており、 掛かる先は `node-label` / `edge-label` / `lane-label` の 3 つ (実測) なので
246
+ * 今はこの絵の文字に当たらない。 とはいえ載せる側の都合で変わる値に幅の計算を預けたくない。
247
+ *
248
+ * どちらの書体で描かれても実物以上に出る側へ倒す = 下回ると「収まっていない物を収まっていると
249
+ * 判定」 する向きに外れる (`row-align.ts` の `rowColumnWidth` と同じ考え方)。
250
+ */
251
+ const 字の幅 = (text: string, size: number): number =>
252
+ Math.max(displayWidth(text) * size * MONO_ADVANCE_RATIO, textWidth(text, size));
253
+
254
+ /** 絵の中の 5 行の既定値。 書き手が渡さなかった欄はこれで描く (#469) */
255
+ const BLOCK_DEFAULT_FIELDS = [
256
+ { label: "hash", value: "0xaf31c9d2..." },
257
+ { label: "prev", value: "0x7b3f92c1..." },
258
+ { label: "merkle", value: "0xc1892f4a..." },
259
+ { label: "nonce", value: "82,914" },
260
+ { label: "tx", value: "142 tx" },
261
+ ] as const;
262
+
263
+ /** 見出しの既定値 */
264
+ const BLOCK_DEFAULT_TITLE = "Block #421,873";
265
+
266
+ /**
267
+ * shape-blockchain-block = 単一 block 詳細 (header + 5 fields: hash/prev/merkle/nonce/tx + chain link icon)。
268
+ *
269
+ * **書き手が渡した題と副題を描く** (#469)。 以前は絵の中の文字がすべて種別に直接
270
+ * 書かれていたため、 題を付けても絵に出ず、 副題に `{名前}` を置いても見える文字は 1 つも
271
+ * 変わらなかった (変わるのは `data-cdl-subtitle` の控えだけ)。
272
+ *
273
+ * | 描く場所 | 読む欄 |
274
+ * |---|---|
275
+ * | 見出し | `title` |
276
+ * | hash の値 | `subtitle` |
277
+ *
278
+ * 渡さなかった欄は既定値で描くため、 指定の無い図の見え方は変わらない。 `{名前}` の置換は
279
+ * `render/nodes.tsx` が先に済ませる。
280
+ *
281
+ * **残り 4 行 (prev / merkle / nonce / tx) は `rows` に開けない**。 `rows` を描く種別は
282
+ * `layout/spec.ts` の `rowBaselineY` が「行の baseline が node 上端から何 world 下か」 を
283
+ * 返す契約を負うが、 この絵は `fitArt` が node の大きさに合わせて丸ごと縮尺するため、 行の
284
+ * 位置が node の大きさに依存する。 `rowBaselineY(kind, index)` は node の大きさを受け取らない
285
+ * ので、 この種別の位置を表せない。 開くなら述語の形から変える話になる (#469 の対象外 scope)。
286
+ */
239
287
  export function ShapeBlockchainBlockNode({ node, active }: ShapeProps): JSX.Element {
240
288
  const frame = commonFrame(active);
241
289
  const r = shapeRegion(node);
@@ -245,6 +293,17 @@ export function ShapeBlockchainBlockNode({ node, active }: ShapeProps): JSX.Elem
245
293
  const cy = r.cy;
246
294
  const x = cx - bw / 2;
247
295
  const y = cy - bh / 2;
296
+
297
+ // 書き手の値で置き換える。 空文字と空白だけの値は「渡していない」 と同じ扱いにする
298
+ const 渡された = (v: string | undefined): string => (v ?? "").trim();
299
+ const 見出し = 渡された(node.title) || BLOCK_DEFAULT_TITLE;
300
+ const fields = BLOCK_DEFAULT_FIELDS.map((f) => ({
301
+ label: f.label,
302
+ value: f.label === "hash" ? 渡された(node.subtitle) || f.value : f.value,
303
+ }));
304
+
305
+ // 見出しは右上の丸 (半径 5.5、 中心が bw - 16) に掛からないところまで
306
+ const 見出しの幅 = bw - 14 - 30;
248
307
  const art = (
249
308
  <g>
250
309
  {/* 3D shadow */}
@@ -252,24 +311,18 @@ export function ShapeBlockchainBlockNode({ node, active }: ShapeProps): JSX.Elem
252
311
  <rect data-cdl-role="node-body" x={x} y={y} width={bw} height={bh} rx={8} {...frame} />
253
312
  {/* header bar */}
254
313
  <path d={`M ${x} ${y + 32} L ${x} ${y + 8} Q ${x} ${y}, ${x + 8} ${y} L ${x + bw - 8} ${y} Q ${x + bw} ${y}, ${x + bw} ${y + 8} L ${x + bw} ${y + 32} Z`} fill="var(--cdl-tone-accent, #2d6a8f)" fillOpacity={0.9} stroke="none" />
255
- <text x={x + 14} y={y + 22} fontSize={13} fontWeight={700} fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace" fill="#ffffff">
256
- Block #421,873
314
+ <text data-cdl-role="blockchain-block-title" x={x + 14} y={y + 22} fontSize={13} fontWeight={700} fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace" fill="#ffffff">
315
+ {幅で切る(見出し, 13, 見出しの幅, 字の幅)}
257
316
  </text>
258
317
  <circle cx={x + bw - 16} cy={y + 16} r={5.5} fill="#7cd88f" stroke="#ffffff" strokeWidth={1.5} />
259
318
  {/* fields */}
260
- {[
261
- { label: "hash", value: "0xaf31c9d2..." },
262
- { label: "prev", value: "0x7b3f92c1..." },
263
- { label: "merkle", value: "0xc1892f4a..." },
264
- { label: "nonce", value: "82,914" },
265
- { label: "tx", value: "142 tx" },
266
- ].map((f, i) => (
319
+ {fields.map((f, i) => (
267
320
  <g key={i}>
268
321
  <text x={x + 14} y={y + 54 + i * 22} fontSize={10.5} fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace" fill="var(--cdl-text-dim, #5a6270)">
269
322
  {f.label}
270
323
  </text>
271
- <text x={x + bw - 14} y={y + 54 + i * 22} fontSize={11} fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace" fontWeight={600} textAnchor="end" fill="var(--cdl-text, #1a1f2a)">
272
- {f.value}
324
+ <text data-cdl-role={`blockchain-block-${f.label}`} x={x + bw - 14} y={y + 54 + i * 22} fontSize={11} fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace" fontWeight={600} textAnchor="end" fill="var(--cdl-text, #1a1f2a)">
325
+ {幅で切る(f.value, 11, bw - 28 - 字の幅(f.label, 10.5) - 8, 字の幅)}
273
326
  </text>
274
327
  <line x1={x + 14} y1={y + 60 + i * 22} x2={x + bw - 14} y2={y + 60 + i * 22} stroke="var(--cdl-divider, #d4d4d8)" strokeWidth={0.5} opacity={0.5} />
275
328
  </g>
@@ -11,22 +11,16 @@
11
11
  * | 高さに依存 | 23 | 箱を大きくすると収まる。 絵が固定の寸法で描かれ、 箱の高さを見ていない |
12
12
  * | 高さに依らない | 13 | どんな箱でも出る。 幾何そのものの誤り (`#434`) |
13
13
  *
14
- * ## 覆えていない範囲 (#436)
14
+ * ## 小さい箱の読みやすさ (#459)
15
15
  *
16
16
  * **収めた分だけ絵は小さくなる**。 高さ 72 の箱に「絵 180px + 名前 + 説明 + 肩書」 を入れると
17
- * 絵に残るのは 28px で、 倍率 0.156 = 3px の線が 0.47px になり細部が読めない。
17
+ * 変更前は絵に残るのが 28px 倍率が 0.156 になり、 3px の線も 0.47px まで細くなっていた。
18
18
  *
19
- * | 書いた文字 | h=72 の倍率 | 3px の線 |
20
- * |---|---|---|
21
- * | 名前だけ | 0.290 | 0.87 |
22
- * | + 説明 | 0.223 | 0.67 |
23
- * | + 説明 + 肩書 | 0.156 | 0.47 |
24
- *
25
- * 直すには「絵を優先して字を削る」 か「字を優先して絵を描かない」 かの判断が要り、 製品の
26
- * 方針として決める必要がある。 `#436` で扱う。
19
+ * 絵の帯が 45px を割る時は、 補いの行を肩書 説明の順で落とす。 名前は必ず残す。 高さ 72
20
+ * では名前だけの時と同じ倍率 0.308 まで戻り、 3px の線を約 0.92px で描ける。
27
21
  */
28
22
  import type { JSX } from "react";
29
- import type { LaidNode } from "../types";
23
+ import type { LaidNode, NodeKind } from "../types";
30
24
 
31
25
  // 既存の呼出 (検査を含む) がここから取っているため、 そのまま出し直す。
32
26
  export { textWidth } from "./text-width";
@@ -66,6 +60,88 @@ export const DESCENT = 0.25;
66
60
  /** 絵と名前の塊の間に空ける量。 */
67
61
  const ART_GAP = 4;
68
62
 
63
+ /**
64
+ * 絵に残す帯の下限 (`#459`)。 これを割るなら補いの行 (肩書 → 説明) を描かない。
65
+ *
66
+ * ## 値の出どころ
67
+ *
68
+ * 人型の絵は自然な大きさで縦 180 ある。 `fitArt` は帯に収まるまで一様に縮めるので、 帯が
69
+ * `B` なら倍率は `B / 180` になり、 絵の中の 3 の線は `3B / 180` で描かれる。
70
+ *
71
+ * 倍率 0.25 を下限に採り、 `0.25 × 180 = 45` を帯の下限とした。 この倍率で 3 の線は 0.75、
72
+ * 8 の字は 2 になる。
73
+ *
74
+ * ## なぜ箱の高さの割合にしないか
75
+ *
76
+ * 割合にすると中くらいの箱まで巻き込む。 高さ 106 の箱は 3 行で帯が 39 (割合 0.37) しか無く、
77
+ * 割合 0.65 を下限に置くと **高さ 191 未満の箱が全て** 補いの行を失う (実測)。
78
+ * 絵が読めるかは箱の高さではなく帯の実寸で決まるので、 実寸で判定する。
79
+ *
80
+ * ## 覆えていない範囲
81
+ *
82
+ * 下限は人型 (縦 180) を基準にした 1 つの値で、 絵の大きさが種別ごとに違うことは見ていない。
83
+ * 小さい絵の種別では帯が足りていても行を落とすことがある。 種別ごとの広がりは描く直前
84
+ * (`fitArt` の呼出) にしか分からず、 名前の塊を組む時点では手に入らない。
85
+ */
86
+ const ART_MIN_BAND = 45;
87
+
88
+ /** 補いの行のうち、 箱が狭くて描けなかったもの。 落とす順は肩書が先。 */
89
+ export type DroppedLabelLine = "肩書" | "説明";
90
+
91
+ /**
92
+ * `labelPlan` が落としても固有 layout で描く種別 (`#492`)。
93
+ *
94
+ * 肩書と説明を一括で判定してはいけない。 `shape-terminal` は肩書だけを描き、
95
+ * `shape-blockchain-block` は説明だけを描く。 種別単位で除外すると片方を誤報するか見逃す。
96
+ *
97
+ * 描き方は配置結果から分からないため一覧を持つ。 `test/shared-label-plan-kinds.test.tsx` が全
98
+ * `shape-` 種別の両方の行を実際に描き、 一覧との一致を確かめる。
99
+ */
100
+ const 落としても描く種別: Readonly<Record<DroppedLabelLine, ReadonlySet<NodeKind>>> = {
101
+ 肩書: new Set([
102
+ // basement 系 = `commonLabel` が自前の帯に置く
103
+ "shape-file",
104
+ "shape-folder",
105
+ "shape-cloud",
106
+ "shape-cylinder",
107
+ "shape-hexagon",
108
+ "shape-diamond",
109
+ "shape-stack",
110
+ // software 系 = 固有 layout
111
+ "shape-window",
112
+ "shape-terminal",
113
+ "shape-code-block",
114
+ "shape-message-bubble",
115
+ "shape-gear",
116
+ // hardware 系のうち固有 layout を持つもの
117
+ "shape-iot-sensor",
118
+ "shape-robot-arm",
119
+ ]),
120
+ 説明: new Set([
121
+ // basement 系 = `commonLabel` が自前の帯に置く
122
+ "shape-file",
123
+ "shape-folder",
124
+ "shape-cloud",
125
+ "shape-cylinder",
126
+ "shape-hexagon",
127
+ "shape-diamond",
128
+ "shape-stack",
129
+ // software 系 = 固有 layout
130
+ "shape-window",
131
+ "shape-message-bubble",
132
+ "shape-gear",
133
+ // hardware / blockchain 系のうち固有 layout を持つもの
134
+ "shape-iot-sensor",
135
+ "shape-robot-arm",
136
+ "shape-blockchain-block",
137
+ ]),
138
+ };
139
+
140
+ /** この種別で、共有 plan が落とした特定の行が実際にも描かれないか。 */
141
+ export function droppedLineIsHidden(kind: NodeKind, line: DroppedLabelLine): boolean {
142
+ return kind.startsWith("shape-") && !落としても描く種別[line].has(kind);
143
+ }
144
+
69
145
  /** 名前の塊の 1 行。 */
70
146
  export type LabelLine = { y: number; size: number };
71
147
 
@@ -91,6 +167,13 @@ export function labelPlan(node: LaidNode): {
91
167
  * なる (高さ 53 で実測、 review 指摘)。 一番小さい行で判定する。
92
168
  */
93
169
  読める: boolean;
170
+ /**
171
+ * 箱が狭くて描かなかった補いの行 (`#459`)。 書いていない行は載らない。
172
+ *
173
+ * 書き手に「書いた文字が出ていない」 を伝えるための出口。 描画側は行が `null` かどうかだけを
174
+ * 見るので、 この欄は伝えるためだけに持つ。
175
+ */
176
+ 落とした: DroppedLabelLine[];
94
177
  } {
95
178
  const 高さ = Math.max(0, node.h);
96
179
  const 底 = node.cy + 高さ / 2;
@@ -125,33 +208,67 @@ export function labelPlan(node: LaidNode): {
125
208
 
126
209
  let 説明あり = Boolean(node.subtitle);
127
210
  let 肩書あり = Boolean(node.eyebrow);
128
- if (!入る(説明あり, 肩書あり)) 肩書あり = false;
129
- if (!入る(説明あり, 肩書あり)) 説明あり = false;
130
-
131
- // 箱に入る一番大きい字を探す。 上限は「高さに比例」 と「読める上限」 の小さい方
132
- const 上限 = Math.min(TITLE_MAX, Math.max(1, Math.round(高さ * TITLE_RATIO)));
133
- let 名前大 = 0;
134
- for (let size = 上限; size >= 1; size--) {
135
- const 小さい字 = Math.max(1, Math.round(size * SUB_RATIO));
136
- const 下の行大 = 説明あり ? 小さい字 : size;
137
- if (積む(size, 説明あり, 肩書あり, 下の行大 * DESCENT).上端 >= 天) {
138
- 名前大 = size;
139
- break;
140
- }
211
+ const 落とした: DroppedLabelLine[] = [];
212
+ if (!入る(説明あり, 肩書あり) && 肩書あり) {
213
+ 肩書あり = false;
214
+ 落とした.push("肩書");
215
+ }
216
+ if (!入る(説明あり, 肩書あり) && 説明あり) {
217
+ 説明あり = false;
218
+ 落とした.push("説明");
141
219
  }
142
220
 
143
- // **1 でも入らない箱がある**。 名前 1 行でも上下の張り出しで 0.97 要るため、 高さ 0.97 未満の
144
- // 箱では整数の大きさに解が無い (review 指摘) 名前は落とせないので、 ちょうど入る大きさまで
145
- // 下げる。 見えない字にはなるが、 箱の外へ出て隣の領域を汚すよりは良い
146
- if (名前大 === 0) 名前大 = 高さ / (ASCENT + DESCENT);
221
+ /** 描く行を決めた上で、 字の大きさと baseline を決める。 */
222
+ const 組む = (説明あり: boolean, 肩書あり: boolean) => {
223
+ // 箱に入る一番大きい字を探す。 上限は「高さに比例」 と「読める上限」 の小さい方
224
+ const 上限 = Math.min(TITLE_MAX, Math.max(1, Math.round(高さ * TITLE_RATIO)));
225
+ let 名前大 = 0;
226
+ for (let size = 上限; size >= 1; size--) {
227
+ const 小さい字 = Math.max(1, Math.round(size * SUB_RATIO));
228
+ const 下の行大 = 説明あり ? 小さい字 : size;
229
+ if (積む(size, 説明あり, 肩書あり, 下の行大 * DESCENT).上端 >= 天) {
230
+ 名前大 = size;
231
+ break;
232
+ }
233
+ }
234
+
235
+ // **1 でも入らない箱がある**。 名前 1 行でも上下の張り出しで 0.97 要るため、 高さ 0.97 未満の
236
+ // 箱では整数の大きさに解が無い (review 指摘)。 名前は落とせないので、 ちょうど入る大きさまで
237
+ // 下げる。 見えない字にはなるが、 箱の外へ出て隣の領域を汚すよりは良い
238
+ if (名前大 === 0) 名前大 = 高さ / (ASCENT + DESCENT);
147
239
 
148
- // 下の余白は余った分までしか取れない。 固定の 4 を無条件に取ると塊が上へ出る
149
- const 小さい字 = Math.max(1, Math.round(名前大 * SUB_RATIO));
150
- const 張り出し = (説明あり ? 小さい字 : 名前大) * DESCENT;
151
- const 余り = Math.max(0, 積む(名前大, 説明あり, 肩書あり, 張り出し).上端 - 天);
152
- const 下余白 = Math.min(Math.max(BOTTOM_PAD, 張り出し), 張り出し + 余り);
240
+ // 下の余白は余った分までしか取れない。 固定の 4 を無条件に取ると塊が上へ出る
241
+ const 小さい字 = Math.max(1, Math.round(名前大 * SUB_RATIO));
242
+ const 張り出し = (説明あり ? 小さい字 : 名前大) * DESCENT;
243
+ const 余り = Math.max(0, 積む(名前大, 説明あり, 肩書あり, 張り出し).上端 - 天);
244
+ const 下余白 = Math.min(Math.max(BOTTOM_PAD, 張り出し), 張り出し + 余り);
245
+
246
+ return 積む(名前大, 説明あり, 肩書あり, 下余白);
247
+ };
248
+
249
+ /** この組み方で絵に残る帯。 `shapeRegion` が使う式と同じものをここでも使う。 */
250
+ const 絵の帯 = (組: ReturnType<typeof 組む>): number => Math.max(0, 組.上端 - ART_GAP - 天);
251
+
252
+ // 絵に残る帯が下限を割るなら、 補いの行を落として絵に譲る (#459)。
253
+ //
254
+ // **落とす順は肩書 → 説明で、 名前は落とさない**。 名前が無い箱は何を指しているか分からず、
255
+ // 絵が読めても図の意味が失われる。
256
+ //
257
+ // 絵を描かない案 (`#459` の案 B) は採らない = `shape-` は種別ごとの絵を持つことが存在理由で、
258
+ // 絵を落とすと `card` と区別が付かなくなる。 種別ごとに簡略版の絵を持つ案 (案 C) は 49 種
259
+ // それぞれに絵を足すことになり、 本 Issue の範囲を超える。
260
+ let 組 = 組む(説明あり, 肩書あり);
261
+ if (絵の帯(組) < ART_MIN_BAND && 肩書あり) {
262
+ 肩書あり = false;
263
+ 落とした.push("肩書");
264
+ 組 = 組む(説明あり, 肩書あり);
265
+ }
266
+ if (絵の帯(組) < ART_MIN_BAND && 説明あり) {
267
+ 説明あり = false;
268
+ 落とした.push("説明");
269
+ 組 = 組む(説明あり, 肩書あり);
270
+ }
153
271
 
154
- const 組 = 積む(名前大, 説明あり, 肩書あり, 下余白);
155
272
  const 一番小さい行 = Math.min(
156
273
  ...[組.名前, 組.説明, 組.肩書].filter((l) => l !== null).map((l) => l.size),
157
274
  );
@@ -161,6 +278,7 @@ export function labelPlan(node: LaidNode): {
161
278
  肩書: 組.肩書,
162
279
  top: 組.上端,
163
280
  読める: 一番小さい行 >= TITLE_MIN,
281
+ 落とした,
164
282
  };
165
283
  }
166
284
 
@@ -2,6 +2,7 @@ import type { JSX } from "react";
2
2
  import type { LaidNode, TreeNodePayload } from "../types";
3
3
  import { TONE } from "../render/tone";
4
4
  import { resolveTreeData } from "../render/payload-binding";
5
+ import { 箱に積む, type 積んだ行 } from "../kinds/box-lines";
5
6
 
6
7
  /**
7
8
  * tree-hierarchy kind ... 1 node に tree node 配列 (treeData) を保持し、
@@ -12,6 +13,42 @@ import { resolveTreeData } from "../render/payload-binding";
12
13
  * - depth 1+ は tone 別 accent の left-border + 白 fill card
13
14
  * - parent-child edge は elbow line + 中間 join dot で「分岐点」 を強調
14
15
  */
16
+ /**
17
+ * 箱の中に名前と補足を積んで描く (#471)。
18
+ *
19
+ * 補足が無い node は呼ばない = 従来の 1 行の `<text>` をそのまま残すため。 積む形に寄せると
20
+ * baseline の出し方が変わり、 補足を渡していない既存の図まで文字の位置が動く。
21
+ */
22
+ function 箱の文字({
23
+ 行,
24
+ x,
25
+ 箱の高さ,
26
+ fill,
27
+ 名前の太さ,
28
+ }: {
29
+ 行: 積んだ行[];
30
+ x: number;
31
+ 箱の高さ: number;
32
+ fill: string;
33
+ 名前の太さ: number;
34
+ }): JSX.Element[] {
35
+ return 行.map((r) => (
36
+ <text
37
+ key={r.役割}
38
+ data-cdl-role={r.役割 === "subtitle" ? "tree-node-subtitle" : undefined}
39
+ x={x}
40
+ y={箱の高さ / 2 + r.dy}
41
+ fontSize={r.fontSize}
42
+ fontWeight={r.役割 === "title" ? 名前の太さ : 500}
43
+ textAnchor="middle"
44
+ fill={fill}
45
+ opacity={r.役割 === "subtitle" ? 0.85 : undefined}
46
+ >
47
+ {r.text}
48
+ </text>
49
+ ));
50
+ }
51
+
15
52
  export function TreeNode({
16
53
  node,
17
54
  active,
@@ -88,6 +125,18 @@ export function TreeNode({
88
125
  {layout.nodes.map((n) => {
89
126
  const accent = depthColors[Math.min(n.depth, depthColors.length - 1)] ?? depthColors[0]!;
90
127
  const isRoot = n.depth === 0;
128
+ // 補足は箱の中の 2 行目に出す (#471)。 箱の高さは変えないため、 入らない時は落ちる
129
+ const 行 = 箱に積む(
130
+ [
131
+ { 役割: "title" as const, text: n.title, fontSize: isRoot ? 13 : 12 },
132
+ { 役割: "subtitle" as const, text: n.subtitle ?? "", fontSize: 9 },
133
+ ],
134
+ n.w,
135
+ n.h,
136
+ );
137
+ // **判定は行数でなく「補足を渡したか」 で行う**。 行数で見ると、 箱が低くて補足が落ちた時に
138
+ // 従来の 1 行の経路へ戻り、 幅で切っていない名前が箱から横にはみ出す (#471 Round 1)
139
+ const 補足あり = (n.subtitle ?? "").trim() !== "";
91
140
  return (
92
141
  <g
93
142
  key={`n-${n.id}`}
@@ -105,16 +154,26 @@ export function TreeNode({
105
154
  rx={12}
106
155
  fill={`url(#${gradientId})`}
107
156
  />
108
- <text
109
- x={n.w / 2}
110
- y={n.h / 2 + 5}
111
- fontSize={13}
112
- fontWeight={700}
113
- textAnchor="middle"
114
- fill="var(--cdl-text-on-tone, #ffffff)"
115
- >
116
- {n.title}
117
- </text>
157
+ {補足あり ? (
158
+ 箱の文字({
159
+ 行,
160
+ x: n.w / 2,
161
+ 箱の高さ: n.h,
162
+ fill: "var(--cdl-text-on-tone, #ffffff)",
163
+ 名前の太さ: 700,
164
+ })
165
+ ) : (
166
+ <text
167
+ x={n.w / 2}
168
+ y={n.h / 2 + 5}
169
+ fontSize={13}
170
+ fontWeight={700}
171
+ textAnchor="middle"
172
+ fill="var(--cdl-text-on-tone, #ffffff)"
173
+ >
174
+ {n.title}
175
+ </text>
176
+ )}
118
177
  </>
119
178
  ) : (
120
179
  <>
@@ -130,16 +189,26 @@ export function TreeNode({
130
189
  strokeWidth={1.5}
131
190
  />
132
191
  <rect x={0} y={0} width={4} height={n.h} rx={2} fill={accent} />
133
- <text
134
- x={n.w / 2 + 2}
135
- y={n.h / 2 + 4}
136
- fontSize={12}
137
- fontWeight={600}
138
- textAnchor="middle"
139
- fill="var(--cdl-chip-text, #1a1f2a)"
140
- >
141
- {n.title}
142
- </text>
192
+ {補足あり ? (
193
+ 箱の文字({
194
+ 行,
195
+ x: n.w / 2 + 2,
196
+ 箱の高さ: n.h,
197
+ fill: "var(--cdl-chip-text, #1a1f2a)",
198
+ 名前の太さ: 600,
199
+ })
200
+ ) : (
201
+ <text
202
+ x={n.w / 2 + 2}
203
+ y={n.h / 2 + 4}
204
+ fontSize={12}
205
+ fontWeight={600}
206
+ textAnchor="middle"
207
+ fill="var(--cdl-chip-text, #1a1f2a)"
208
+ >
209
+ {n.title}
210
+ </text>
211
+ )}
143
212
  </>
144
213
  )}
145
214
  </g>
@@ -152,6 +221,8 @@ export function TreeNode({
152
221
  type LayoutNode = {
153
222
  id: string;
154
223
  title: string;
224
+ /** 箱の 2 行目に出す補足 (#471) */
225
+ subtitle?: string;
155
226
  x: number;
156
227
  y: number;
157
228
  w: number;
@@ -250,6 +321,7 @@ export function computeTreeLayout(
250
321
  nodes.push({
251
322
  id: n.id,
252
323
  title: n.title,
324
+ ...(n.subtitle !== undefined ? { subtitle: n.subtitle } : {}),
253
325
  x,
254
326
  y,
255
327
  w: nodeW,
@@ -1,5 +1,6 @@
1
1
  import type { BBox, LaidEdge, LaidLane, LaidNode } from "../types";
2
2
  import { CHAIN_SHIFT_MAX_ACCUMULATED, computeLabelBBoxWorld, EDGE_STUB_OUT, hasRenderedLabel, LABEL_TO_PATH_CLEARANCE, requiredNearClearance } from "./spec";
3
+ import { footerShapeDrops } from "./footer-shape";
3
4
  import { measureTextWidth } from "./text-width";
4
5
 
5
6
  /**
@@ -26,14 +27,21 @@ export function collectBBoxes(lanes: LaidLane[], nodes: LaidNode[], edges: LaidE
26
27
  });
27
28
  }
28
29
 
30
+ // 下端の名札は絵が箱の上端より上へ出る (`SHAPE_DRAW_UP_EXTENT`)。 出た分を外接矩形に
31
+ // 含めないと、 描かれている範囲と衝突 / 近接の判定材料が食い違う (#478)。
32
+ //
33
+ // 名札を集める条件は `lifelineFooterNodes` が唯一持ち、 出る量は種別と箱の高さだけで
34
+ // 決まるので、 名札を下げた後に呼んでも同じ値になる。
35
+ const footerDraw = footerShapeDrops(lanes, nodes);
29
36
  for (const n of nodes) {
37
+ const up = footerDraw.get(n.id) ?? 0;
30
38
  out.push({
31
39
  kind: "node",
32
40
  id: n.id,
33
41
  x: n.cx - n.w / 2,
34
- y: n.cy - n.h / 2,
42
+ y: n.cy - n.h / 2 - up,
35
43
  w: n.w,
36
- h: n.h,
44
+ h: n.h + up,
37
45
  });
38
46
  }
39
47
 
@@ -28,35 +28,45 @@ export const SHAPE_DRAW_UP_EXTENT: Readonly<Record<string, number>> = {
28
28
  /**
29
29
  * 縦線を持つ lane の **下端の名札** を集める。
30
30
  *
31
- * 順序図は lane ごとに上端と下端へ名札を置く。 node 1 つ以下の lane は下端の名札を持たない
32
- * (上端だけ、 もしくは空) ため対象から外す。
31
+ * 判定は node `role` で行う。 順序図を組み立てる側 (`presets.ts` `build()`) が
32
+ * `role: "lifeline-footer"` を付けるため、 描画側は位置を見る必要が無い。
33
33
  *
34
- * 縦線の終点 (`CdlStage`) と絵を下げる量 (`footerShapeDrops`) と図の下端 (`computeViewBox`)
35
- * 3 箇所が同じ名札を指す必要があるため、 「どれが下端の名札か」 の判定をここに 1 つだけ置く。
34
+ * **位置で推測していた形は誤りだった** (`#458`) 「node 2 つ以上ある lane の一番下」 と
35
+ * 決めていたため、 手で組んだ図で一番下が名札でない `shape-` node だと、 その絵が下がった。
36
+ *
37
+ * 目印に加えて 2 つの条件を見る。 どちらも **どれが名札かの判定ではなく、 名札として扱うと
38
+ * 図が壊れる形を除く** ためのもの。
39
+ *
40
+ * | 条件 | 除く理由 |
41
+ * |---|---|
42
+ * | `lane.lifeline` が真 | 縦線が無い lane では絵を下げる理由 (縦線と絵の重なり) が起きない |
43
+ * | その lane に node が 2 つ以上 | 1 つしか無い lane の唯一の node を名札にすると縦線が退化する |
44
+ *
45
+ * 2 つ目は実測に基づく。 縦線の終点は **全 lane で 1 つに揃える** ため、 名札の位置が異常な lane が
46
+ * 1 つあるだけで図の全 lane から縦線が消えていた (1 node の lane を名札にして実測、 通常の lane と
47
+ * 混在させても全て消えた)。 1 node の lane にあるのは上端の名札のはずで、 それを下端として扱う形
48
+ * 自体が誤り。 終点の選び方そのものは `uniformLifelineEndY` (`#479`) が直した。
49
+ *
50
+ * 同じ lane に名札が複数あれば全て返す = 数を絞る根拠が無く、 絞るとどれを残すかで再び位置の
51
+ * 推測に戻る。
52
+ *
53
+ * 縦線の終点 (`uniformLifelineEndY`) と絵を下げる量 (`applyFooterShapeDrops`) と外接矩形
54
+ * (`collectBBoxes`) が同じ名札を指す必要があるため、 「どれが下端の名札か」 の判定をここに
55
+ * 1 つだけ置く。
36
56
  */
37
57
  export function lifelineFooterNodes(lanes: LaidLane[], nodes: LaidNode[]): LaidNode[] {
38
- // lane ごとに node を集める索引を 1 度だけ作る。 lane ごとに全 node を走査すると
39
- // lane 数 × node 数に膨らみ、 `CdlStage` は動きの 1 コマごとに描き直すため毎コマ払う
40
- const byLane = new Map<string, LaidNode[]>();
41
- for (const n of nodes) {
42
- const inside = byLane.get(n.lane);
43
- if (inside) inside.push(n);
44
- else byLane.set(n.lane, [n]);
45
- }
46
- const footers: LaidNode[] = [];
58
+ // lane ごとの node 数を 1 度だけ数える。 node ごとに全 node を走査すると node 数の 2 乗に
59
+ // 膨らみ、 `CdlStage` は動きの 1 コマごとに描き直すため毎コマ払う
60
+ const = new Map<string, number>();
61
+ for (const n of nodes) 数.set(n.lane, (数.get(n.lane) ?? 0) + 1);
62
+
63
+ const lifelineLanes = new Set<string>();
47
64
  for (const lane of lanes) {
48
- if (!lane.lifeline) continue;
49
- const inside = byLane.get(lane.id);
50
- if (inside === undefined || inside.length < 2) continue;
51
- // 一番下 = cy が最大。 並べ替えずに 1 回の走査で選ぶ。 同じ cy が複数ある時は
52
- // **後に現れた方** を採る (安定な並べ替えで末尾を取るのと同じ結果にする)
53
- let footer = inside[0]!;
54
- for (const n of inside) {
55
- if (n.cy >= footer.cy) footer = n;
56
- }
57
- footers.push(footer);
65
+ if (lane.lifeline) lifelineLanes.add(lane.id);
58
66
  }
59
- return footers;
67
+ return nodes.filter(
68
+ (n) => n.role === "lifeline-footer" && lifelineLanes.has(n.lane) && (数.get(n.lane) ?? 0) >= 2,
69
+ );
60
70
  }
61
71
 
62
72
  /**
@@ -95,3 +105,55 @@ export function footerShapeDropsOf(footers: LaidNode[]): Map<string, number> {
95
105
  }
96
106
  return drops;
97
107
  }
108
+
109
+ /**
110
+ * 下端の名札を、 下げる量だけ実際に動かす (`#478`)。
111
+ *
112
+ * **`cy` は「箱の中心」 のまま**。 絵は箱の下端を基準に組み立てられるため、 箱を動かせば
113
+ * 絵も同じだけ動く。 絵の上端は `cy + h / 2 - SHAPE_DRAW_UP_EXTENT[kind]` で、 箱と
114
+ * `SHAPE_DRAW_UP_EXTENT` から一意に導ける (`footerShapeDrawTop`)。
115
+ *
116
+ * ## 描画だけを動かす形を採らない理由
117
+ *
118
+ * 元は描画側が名札を包む `<g>` に `translate(0, dy)` を掛けており、 **箱ごと下へ動いて
119
+ * 見えるのに座標は元のまま** だった。 `cy` も `data-cdl-cy` も外接矩形も動かないため、
120
+ * 座標を読む側 (縦線の終点 / 図の下端 / 外接矩形 / 矢印の接続先) が実際の描画と食い違う。
121
+ *
122
+ * ## 「絵」 ではなく「箱」 を `cy` とする理由
123
+ *
124
+ * `cy` / `w` / `h` は全ての種別で **箱** を表し、 矢印の接続先 / 当たり判定 / `data-cdl-cy` /
125
+ * 絵の組み立ての原点がこの意味に依存している。 一部の種別だけ `cy` を絵の中心に変えると、
126
+ * 同じ field が node によって別の意味を持つ。
127
+ *
128
+ * 絵の範囲は箱から導けるが、 逆は導きにくい = 絵を `cy` にすると箱の位置を出すのに同じ
129
+ * `SHAPE_DRAW_UP_EXTENT` が要り、 依存の向きが 1 つ増えるだけで得るものが無い。
130
+ *
131
+ * ## 下げる量は位置に依存しない
132
+ *
133
+ * `footerShapeDropY` が見るのは種別と箱の高さだけなので、 動かした後に呼び直しても同じ値を
134
+ * 返す。 したがって後段が名札をさらに動かしても、 絵の上端は常に
135
+ * `cy + h / 2 - extent` として正しく導ける。
136
+ */
137
+ export function applyFooterShapeDrops(lanes: LaidLane[], nodes: LaidNode[]): LaidNode[] {
138
+ const drops = footerShapeDrops(lanes, nodes);
139
+ if (drops.size === 0) return nodes;
140
+ return nodes.map((n) => {
141
+ // posX / posY が揃った node は「指定した位置から動かさない」公開契約。 絵が箱の上へ出る
142
+ // 範囲は collectBBoxes / footerShapeDrawTop が扱うため、 絶対配置を崩してまで箱を下げない。
143
+ if (n.posX !== undefined && n.posY !== undefined) return n;
144
+ const dy = drops.get(n.id);
145
+ return dy === undefined ? n : { ...n, cy: n.cy + dy };
146
+ });
147
+ }
148
+
149
+ /**
150
+ * 下げた名札の **絵の上端**。 縦線の終点と外接矩形の上端がこの値を使う。
151
+ *
152
+ * 絵は箱の下端から上へ `SHAPE_DRAW_UP_EXTENT` 伸びるので、 上端は `cy + h / 2 - extent`。
153
+ * 下げた量が `extent - h` である分、 これは `cy - h / 2 - 下げた量` と等しい。
154
+ *
155
+ * 下げていない名札 (箱に収まる種別 / 収まる高さ) では箱の上端をそのまま返す。
156
+ */
157
+ export function footerShapeDrawTop(node: LaidNode): number {
158
+ return node.cy - node.h / 2 - footerShapeDropY(node.kind, node.h);
159
+ }