@cardenelabs/cdl 0.7.0 → 0.10.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 (45) hide show
  1. package/CHANGELOG.md +446 -0
  2. package/SPEC.md +42 -18
  3. package/dist/index.cjs +793 -347
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +73 -14
  6. package/dist/index.d.ts +73 -14
  7. package/dist/index.js +793 -347
  8. package/dist/index.js.map +1 -1
  9. package/dist/react.cjs +658 -236
  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 +658 -236
  14. package/dist/react.js.map +1 -1
  15. package/dist/{render-DquCvgOB.d.cts → render-CH1Qn3Jv.d.cts} +47 -2
  16. package/dist/{render-DquCvgOB.d.ts → render-CH1Qn3Jv.d.ts} +47 -2
  17. package/package.json +6 -3
  18. package/src/builder.ts +13 -1
  19. package/src/kinds/box-lines.ts +120 -0
  20. package/src/kinds/chart-line.tsx +94 -25
  21. package/src/kinds/gantt.tsx +152 -24
  22. package/src/kinds/mind-map.tsx +50 -12
  23. package/src/kinds/quadrant.tsx +28 -2
  24. package/src/kinds/shape-blockchain.tsx +65 -12
  25. package/src/kinds/shape-region.tsx +152 -34
  26. package/src/kinds/tree.tsx +92 -20
  27. package/src/layout/collisions.ts +10 -2
  28. package/src/layout/footer-shape.ts +86 -24
  29. package/src/layout/lifeline.ts +80 -0
  30. package/src/layout/spec.ts +21 -2
  31. package/src/layout/text-width.ts +92 -4
  32. package/src/layout/viewbox.ts +0 -8
  33. package/src/layout.ts +8 -1
  34. package/src/presets.ts +120 -22
  35. package/src/render/header.tsx +3 -1
  36. package/src/render/nodes.tsx +15 -1
  37. package/src/render/payload-binding.ts +31 -8
  38. package/src/render/stage.tsx +21 -34
  39. package/src/render/template-fields.ts +212 -0
  40. package/src/render/utils.ts +32 -2
  41. package/src/render.tsx +2 -9
  42. package/src/types.ts +47 -2
  43. package/src/validate.ts +45 -13
  44. package/src/visual-validate.ts +34 -1
  45. package/src/layout/label-shift.ts +0 -28
@@ -33,21 +33,141 @@ export function GanttNode({
33
33
  const rowCount = tasks.length || 1;
34
34
  const rowH = Math.max(28, (canvasH - rowGap * (rowCount + 1)) / rowCount);
35
35
 
36
+ // 位置を先に整える (#468 Round 5)。 端も始まりも状態から来るため、 そのまま使うと
37
+ // 図が壊れる形が 3 つある (いずれも実測)。
38
+ //
39
+ // | 入力 | そのまま使った時 |
40
+ // |---|---|
41
+ // | 始まり `1e308` | `x="Infinity"` |
42
+ // | 始まり `-1e308` | `x="-Infinity" width="Infinity"` |
43
+ // | 始まり 10 / 端 1 | 帯が図の外 (`x=2739`) |
44
+ //
45
+ // 負は 0 に寄せ、 端が始まりより手前なら始まりに揃える。 落とさず整えるのは、 状態が動く
46
+ // 途中で一瞬だけ逆転する形があるため (帯が消えるより潰れて見える方がよい)。
47
+ const 位置の上限 = 1e6;
48
+ const 収めた = tasks.map((t) => {
49
+ const 始 = Math.min(位置の上限, Math.max(0, Number.isFinite(t.startIdx) ? t.startIdx : 0));
50
+ const 終 = Math.min(位置の上限, Math.max(始, Number.isFinite(t.endIdx) ? t.endIdx : 始));
51
+ return { ...t, startIdx: 始, endIdx: 終 };
52
+ });
53
+
54
+ // 書き手が宣言した横軸の範囲 (#483)。 有限の正の数だけ採る = 壊れた値で尺を作ると帯が
55
+ // 図の外へ出るため、 それより自動に落ちる方が読める図になる。
56
+ //
57
+ // 位置は 0 から `宣言した尺 - 1` までで、 `帯の右` は 1 目盛り分先まで伸びるため、
58
+ // 最後の目盛りにある帯の右端がちょうど図の右端に来る。
59
+ const 宣言 = node.ganttAxisMax;
60
+ const 宣言した尺 = typeof 宣言 === "number" && Number.isFinite(宣言) && 宣言 > 0
61
+ ? Math.min(位置の上限, Math.ceil(宣言))
62
+ : undefined;
63
+
36
64
  // 帯の右端が状態で動くと端数が出る。 目盛りは本数を数えるので切り上げて整数にする
37
65
  // (静止した図では endIdx が整数なので切り上げても値は変わらない)。
38
- const maxIdx = Math.max(1, Math.ceil(Math.max(...tasks.map((t) => t.endIdx + 1), 1)));
66
+ //
67
+ // **始まりも見る**。 上の整えで端は始まり以上になるため、 端だけでも同じ値になる
68
+ // (この 2 つは今は等価)。 整えを緩めた時に帯が図の外へ出る形を作らないための二重。
69
+ const maxIdx = 宣言した尺 ?? Math.max(
70
+ 1,
71
+ Math.ceil(Math.max(...収めた.flatMap((t) => [t.startIdx + 1, t.endIdx + 1]), 1)),
72
+ );
39
73
  const cellW = canvasW / maxIdx;
40
74
 
75
+ // 宣言した範囲から外れる帯は **右端の目盛りに寄せる** (#483)。 尺を広げて収める形は採らない
76
+ // = 広げた瞬間に他の task の帯が動き、 範囲を宣言した意味が消える。
77
+ //
78
+ // 落とす形も採らない (Issue の 反例 = 帯が図の外へ消えてはいけない)。 寄せた帯には印を付け、
79
+ // 実際の位置と違うことを読み取れるようにする。
80
+ const 最後の目盛り = maxIdx - 1;
81
+ const 整えた = 宣言した尺 === undefined
82
+ ? 収めた
83
+ : 収めた.map((t) => {
84
+ const 始 = Math.min(t.startIdx, 最後の目盛り);
85
+ const 終 = Math.min(t.endIdx, 最後の目盛り);
86
+ const 寄せた = 始 !== t.startIdx || 終 !== t.endIdx;
87
+ return 寄せた ? { ...t, startIdx: 始, endIdx: 終, clipped: true } : t;
88
+ });
89
+
90
+ // **描く目盛りの本数だけに上限を置く** (#468)。 端は状態から来るため、 大きな値を書かれると
91
+ // 目盛りをその数だけ作ることになる (実測 = `4294967295` で `Array.from` が
92
+ // `RangeError: Invalid array length`)。
93
+ //
94
+ // **尺 (`maxIdx`) には掛けない**。 掛けると尺が実際の最大より小さくなり、 上限を超える位置の
95
+ // 帯が図の外へ出る (Round 2 の実測 = 位置 250 の帯が右端 800.6、 図の右端は 672)。
96
+ // 本数が上限を超える時は間引いて描く = 目盛りは目安なので、 全部描けなくても図は読める。
97
+ const 目盛りの上限 = 200;
98
+ const 目盛りの間隔 = Math.max(1, Math.ceil(maxIdx / 目盛りの上限));
99
+ const 目盛りの本数 = Math.ceil(maxIdx / 目盛りの間隔);
100
+
41
101
  const xAt = (idx: number) => PAD_LEFT + idx * cellW;
42
102
  const yAt = (row: number) => PAD_TOP + rowGap + row * (rowH + rowGap);
43
103
 
44
- const gridLabels = Array.from({ length: maxIdx }, (_, i) => {
45
- const t = tasks.find((tt) => tt.startIdx === i);
46
- return t?.startLabel ?? String(i);
47
- });
104
+ // 目盛りの名前は task が持つ (開始と終了の両方) どちらも指していない位置は
105
+ // **空にする** = index の数字をそのまま出すと、 月や四半期の並びに数字が 1 つ混ざって
106
+ // 与えた値のように読める (実測 = "1月 2月 2 4月 5月" の 3 番目)
107
+ //
108
+ // 引き継ぎ (前の名前を繰り返す) は採らない。 別の目盛りに同じ名前が並ぶと、 どちらが
109
+ // その task の境目か読めなくなる。
110
+ //
111
+ // 規則は 3 つ。 いずれも決めておかないと task の書き順で結果が変わる。
112
+ //
113
+ // 1. 同じ位置を複数の task が指す時は **先に書いた task** を採る
114
+ // 2. 開始の名前を終了の名前より優先する
115
+ // 3. 空文字は名前が無いものとして扱い、 次の候補へ落ちる
116
+ //
117
+ // 端数を持つ位置は四捨五入で合わせる。 端は状態に追随して端数を持つ (`{signal}` で動く)
118
+ // 一方、 目盛りは整数の位置にしか無いため、 厳密一致だと動いている間だけ名前が消える
119
+ // (実測 = endIdx 1.5 で終了の名前が出なくなった)。
120
+ //
121
+ // **整数の一致を端数より優先する**。 まとめて四捨五入すると、 動いている task が
122
+ // その位置を静的に指す task の名前を奪う (実測 = endIdx 1.5 の task が先にあると、
123
+ // endIdx 2 の task の名前が消えた)。
124
+ //
125
+ // 表を 1 度だけ作る = 目盛りごとに task を探すと目盛り数 × task 数になり、 動きの
126
+ // 1 コマごとに払う。
127
+ const 名前 = { 整数の開始: new Map<number, string>(), 整数の終了: new Map<number, string>(), 端数の開始: new Map<number, string>(), 端数の終了: new Map<number, string>() };
128
+ const 置く = (表: Map<number, string>, 位置: number, label: string) => {
129
+ // 空文字は名前が無いものとして扱う。 同じ位置は先に書いた task を採る
130
+ if (label !== "" && Number.isFinite(位置) && !表.has(位置)) 表.set(位置, label);
131
+ };
132
+ // 名前は宣言範囲へ寄せる前の位置から作る。 範囲外 task の名前まで右端へ
133
+ // 寄せると、 実際に右端を指す task の名前を上書きして軸の意味が変わる。
134
+ for (const t of 収めた) {
135
+ const 整数の開始 = Number.isInteger(t.startIdx);
136
+ 置く(整数の開始 ? 名前.整数の開始 : 名前.端数の開始, Math.round(t.startIdx), t.startLabel);
137
+ const 整数の終了 = Number.isInteger(t.endIdx);
138
+ 置く(整数の終了 ? 名前.整数の終了 : 名前.端数の終了, Math.round(t.endIdx), t.endLabel);
139
+ }
140
+ const 名前を引く = (i: number): string =>
141
+ 名前.整数の開始.get(i) ?? 名前.整数の終了.get(i) ?? 名前.端数の開始.get(i) ?? 名前.端数の終了.get(i) ?? "";
142
+
143
+ // 間引いた位置に加えて、 **名前のある位置は必ず残す** (#468 Round 3)。 等間隔だけで選ぶと
144
+ // task の境目に付いた名前が落ちる (実測 = 位置 202 まで伸ばすと間隔 2 になり、 位置 1 と 3 の
145
+ // 名前が消えた)。 名前は task から来るので、 残す数は task 数で頭打ちになる。
146
+ //
147
+ // 位置そのものも持ち回る = 間隔が 1 でない時に添字と位置がずれるため、 線と名前を置く場所を
148
+ // 位置から出す。
149
+ const 位置の集合 = new Set<number>();
150
+ for (let n = 0; n < 目盛りの本数; n += 1) 位置の集合.add(n * 目盛りの間隔);
151
+ for (const 表 of [名前.整数の開始, 名前.整数の終了, 名前.端数の開始, 名前.端数の終了]) {
152
+ for (const i of 表.keys()) if (i >= 0 && i < maxIdx) 位置の集合.add(i);
153
+ }
154
+ const gridLabels = [...位置の集合]
155
+ .sort((a, b) => a - b)
156
+ .map((i) => ({ 位置: i, label: 名前を引く(i) }));
157
+
158
+ // 帯の左右に置く余白。 **目盛りの幅に比例させる** (#468 Round 7)。
159
+ //
160
+ // 固定 (6) だと、 目盛りが細かい図で帯が消える。 1 目盛りの幅が 6 ちょうどだと幅 0、
161
+ // それより小さいと負になる (実測 = 位置 1e6 で -5.999、 位置 85 で 0)。
162
+ // 目盛りが広い図 (通常の工程表) では 6 のままなので見た目は変わらない。
163
+ const 帯の余白 = Math.min(6, cellW / 2);
164
+ // 帯の左端と右端。 **依存矢印も同じ式を使う** (#468 Round 9)。 別々に持つと、 余白を
165
+ // 可変にした時に矢印の基準点だけが旧い端に取り残される
166
+ const 帯の左 = (idx: number): number => xAt(idx) + 帯の余白 / 2;
167
+ const 帯の右 = (idx: number): number => xAt(idx) + cellW - 帯の余白 / 2;
48
168
 
49
- const taskById = new Map(tasks.map((t) => [t.id, t]));
50
- const rowOf = new Map(tasks.map((t, i) => [t.id, i]));
169
+ const taskById = new Map(整えた.map((t) => [t.id, t]));
170
+ const rowOf = new Map(整えた.map((t, i) => [t.id, i]));
51
171
 
52
172
  return (
53
173
  <g transform={`translate(${x0} ${y0})`}>
@@ -63,9 +183,10 @@ export function GanttNode({
63
183
  strokeWidth={active ? 3 : 1.25}
64
184
  />
65
185
 
66
- {gridLabels.map((label, i) => (
186
+ {gridLabels.map(({ 位置: i, label }) => (
67
187
  <g key={`grid-${i}`}>
68
188
  <line
189
+ data-cdl-role="gantt-grid"
69
190
  x1={xAt(i)}
70
191
  y1={PAD_TOP}
71
192
  x2={xAt(i)}
@@ -75,15 +196,18 @@ export function GanttNode({
75
196
  strokeDasharray="3 3"
76
197
  opacity={0.5}
77
198
  />
78
- <text
79
- x={xAt(i) + cellW / 2}
80
- y={PAD_TOP + canvasH + 18}
81
- fontSize={11}
82
- textAnchor="middle"
83
- fill="var(--cdl-text-dim, #5a6270)"
84
- >
85
- {label}
86
- </text>
199
+ {label === "" ? null : (
200
+ <text
201
+ data-cdl-role="gantt-tick"
202
+ x={xAt(i) + cellW / 2}
203
+ y={PAD_TOP + canvasH + 18}
204
+ fontSize={11}
205
+ textAnchor="middle"
206
+ fill="var(--cdl-text-dim, #5a6270)"
207
+ >
208
+ {label}
209
+ </text>
210
+ )}
87
211
  </g>
88
212
  ))}
89
213
 
@@ -97,7 +221,7 @@ export function GanttNode({
97
221
  opacity={0.5}
98
222
  />
99
223
 
100
- {tasks.map((t, row) => (
224
+ {整えた.map((t, row) => (
101
225
  <text
102
226
  key={`label-${t.id}`}
103
227
  x={PAD_LEFT - 12}
@@ -111,15 +235,19 @@ export function GanttNode({
111
235
  </text>
112
236
  ))}
113
237
 
114
- {tasks.map((t, row) => {
115
- const bx = xAt(t.startIdx) + 3;
238
+ {整えた.map((t, row) => {
239
+ const bx = 帯の左(t.startIdx);
116
240
  const by = yAt(row);
117
- const bw = Math.max(cellW - 6, (t.endIdx - t.startIdx + 1) * cellW - 6);
241
+ // 0 で切るのは二重。 余白が目盛りの幅の半分で頭打ちのため、 引いた結果は必ず
242
+ // 目盛りの幅の半分以上になる = **この分岐に届く入力を今は作れない** (変異試験で確認)。
243
+ // 余白の決め方を変えた時に負の幅を出さないために残す
244
+ const bw = Math.max(0, 帯の右(t.endIdx) - bx);
118
245
  return (
119
246
  <g key={`bar-${t.id}`}>
120
247
  <rect
121
248
  data-cdl-role="gantt-bar"
122
249
  data-cdl-unresolved={t.unresolved ? "true" : undefined}
250
+ data-cdl-clipped={"clipped" in t && t.clipped ? "true" : undefined}
123
251
  x={bx}
124
252
  y={by}
125
253
  width={bw}
@@ -143,15 +271,15 @@ export function GanttNode({
143
271
  );
144
272
  })}
145
273
 
146
- {tasks
274
+ {整えた
147
275
  .filter((t) => t.dependsOn && taskById.has(t.dependsOn))
148
276
  .map((t) => {
149
277
  const parent = taskById.get(t.dependsOn!)!;
150
278
  const parentRow = rowOf.get(parent.id)!;
151
279
  const childRow = rowOf.get(t.id)!;
152
- const parentBarRight = xAt(parent.endIdx) + cellW - 3;
280
+ const parentBarRight = 帯の右(parent.endIdx);
153
281
  const parentMidY = yAt(parentRow) + rowH / 2;
154
- const childBarLeft = xAt(t.startIdx) + 3;
282
+ const childBarLeft = 帯の左(t.startIdx);
155
283
  const childMidY = yAt(childRow) + rowH / 2;
156
284
  const arrowHeadSize = 8;
157
285
  // arrow head 三角形の頂点 (= tipX + arrowHeadSize) が bar 左辺の 10px 手前に来る
@@ -1,4 +1,5 @@
1
1
  import type { JSX } from "react";
2
+ import { 箱に積む } from "./box-lines";
2
3
  import type { LaidNode, Tone, MindBranchNode } from "../types";
3
4
  import { TONE } from "../render/tone";
4
5
  import { resolveMindData } from "../render/payload-binding";
@@ -76,7 +77,22 @@ export function MindMapNode({
76
77
  />
77
78
  ))}
78
79
 
79
- {layout.nodes.map((n) => (
80
+ {layout.nodes.map((n) => {
81
+ // 補足は箱の中の 2 行目に出す (#471)。 箱の高さは変えないため、 入らない時は落ちる
82
+ const 行 = 箱に積む(
83
+ [
84
+ { 役割: "title" as const, text: n.title, fontSize: n.isRoot ? 15 : 13 },
85
+ { 役割: "subtitle" as const, text: n.subtitle ?? "", fontSize: 9 },
86
+ ],
87
+ n.w,
88
+ n.h,
89
+ );
90
+ // 判定は行数でなく「補足を渡したか」 で行う (理由は tree.tsx と同じ、 #471 Round 1)
91
+ const 補足あり = (n.subtitle ?? "").trim() !== "";
92
+ const 文字の色 = n.isRoot
93
+ ? "var(--cdl-text-on-tone, #ffffff)"
94
+ : "var(--cdl-chip-text, #1a1f2a)";
95
+ return (
80
96
  <g key={`n-${n.id}`} data-cdl-unresolved={n.unresolved ? "true" : undefined}>
81
97
  <rect
82
98
  x={n.x - n.w / 2}
@@ -88,18 +104,37 @@ export function MindMapNode({
88
104
  stroke={n.isRoot ? toneColor(n.tone ?? "accent") : toneColor(n.tone)}
89
105
  strokeWidth={n.isRoot ? 2 : 1.75}
90
106
  />
91
- <text
92
- x={n.x}
93
- y={n.y + 5}
94
- fontSize={n.isRoot ? 15 : 13}
95
- fontWeight={n.isRoot ? 700 : 600}
96
- textAnchor="middle"
97
- fill={n.isRoot ? "var(--cdl-text-on-tone, #ffffff)" : "var(--cdl-chip-text, #1a1f2a)"}
98
- >
99
- {n.title}
100
- </text>
107
+ {補足あり ? (
108
+ 行.map((r) => (
109
+ <text
110
+ key={r.役割}
111
+ data-cdl-role={r.役割 === "subtitle" ? "mind-node-subtitle" : undefined}
112
+ x={n.x}
113
+ y={n.y + r.dy}
114
+ fontSize={r.fontSize}
115
+ fontWeight={r.役割 === "title" ? (n.isRoot ? 700 : 600) : 500}
116
+ textAnchor="middle"
117
+ fill={文字の色}
118
+ opacity={r.役割 === "subtitle" ? 0.85 : undefined}
119
+ >
120
+ {r.text}
121
+ </text>
122
+ ))
123
+ ) : (
124
+ <text
125
+ x={n.x}
126
+ y={n.y + 5}
127
+ fontSize={n.isRoot ? 15 : 13}
128
+ fontWeight={n.isRoot ? 700 : 600}
129
+ textAnchor="middle"
130
+ fill={文字の色}
131
+ >
132
+ {n.title}
133
+ </text>
134
+ )}
101
135
  </g>
102
- ))}
136
+ );
137
+ })}
103
138
  </g>
104
139
  );
105
140
  }
@@ -107,6 +142,8 @@ export function MindMapNode({
107
142
  type LayoutNode = {
108
143
  id: string;
109
144
  title: string;
145
+ /** 箱の 2 行目に出す補足 (#471) */
146
+ subtitle?: string;
110
147
  x: number;
111
148
  y: number;
112
149
  w: number;
@@ -297,6 +334,7 @@ export function computeMindMapLayout(
297
334
  nodes.push({
298
335
  id: node.id,
299
336
  title: node.title,
337
+ ...(node.subtitle !== undefined ? { subtitle: node.subtitle } : {}),
300
338
  x,
301
339
  y,
302
340
  w,
@@ -35,6 +35,10 @@ export function QuadrantNode({
35
35
  // 名前の前後に矢印と間隔 (12) が付くので、 その分も見込む。
36
36
  const AXIS_LABEL_FONT = 12;
37
37
  const PAD_MIN = 60;
38
+ // 項目の枠。 名前の字幅に左右の余白を足した幅にする (#457)
39
+ const ITEM_FONT = 13;
40
+ const ITEM_PAD_X = 12;
41
+ const ITEM_MIN_W = 48;
38
42
  const axisLabelPad = (text: string): number =>
39
43
  Math.max(PAD_MIN, measureTextWidth(`← ${text}`, { fontSize: AXIS_LABEL_FONT, fontWeight: 700 }) + 12);
40
44
  // 名前が長くても図そのものは残す。 上限を置かないと、 名前が箱の幅を超えた時に
@@ -145,6 +149,26 @@ export function QuadrantNode({
145
149
  const qxStart = (isLeft ? PAD_LEFT : cx) + 18;
146
150
  const qyStart = (isTop ? PAD_TOP : cy) + 44;
147
151
  const qHalfW = halfW - 36;
152
+ // 枠の幅は名前に合わせる。 固定だと短い名前で枠が間延びし、 狭い図では名前が枠から
153
+ // 出る (実測 = 5 文字の「重複を消す」 が 70px で、 枠が狭い図では越えた)。
154
+ //
155
+ // 頭打ちは象限に収まる幅 (qHalfW)。 これを超えると隣の象限へ出るため、 名前が長い時は
156
+ // 枠を伸ばさず名前が枠から出る形にする。 軸の名前と同じ考え方で、 溢れても図は残す。
157
+ //
158
+ // 下限 (ITEM_MIN_W) は **頭打ちより優先しない**。 図が極端に狭いと qHalfW が下限を
159
+ // 下回り、 下限を通すと枠が象限から出る。
160
+ //
161
+ // 余地が無い図では項目を描かない。 qHalfW は負にもなり (実測 = 幅 180 の図で -6)、
162
+ // 幅を 0 に切っても **枠の開始位置 (qxStart) が既に象限の外** にある
163
+ // (実測 = 幅 120 の図で開始 78、 象限の右端 60)。 描く場所が無い時に 0 幅の枠を
164
+ // 象限の外へ置くより、 何も描かない方が図として正しい。
165
+ const 枠の余地 = qHalfW;
166
+ if (枠の余地 <= 0) return null;
167
+ const 枠の幅 = (title: string) =>
168
+ Math.min(
169
+ 枠の余地,
170
+ Math.max(ITEM_MIN_W, measureTextWidth(title, { fontSize: ITEM_FONT, fontWeight: 600 }) + ITEM_PAD_X * 2),
171
+ );
148
172
  const qHalfH = halfH - 60;
149
173
  const gap = 10;
150
174
  const rowH = 34;
@@ -158,14 +182,16 @@ export function QuadrantNode({
158
182
  data-cdl-unresolved={it.unresolved ? "true" : undefined}
159
183
  x={qxStart}
160
184
  y={py}
161
- width={qHalfW}
185
+ width={枠の幅(it.title)}
162
186
  height={rowH}
163
187
  rx={6}
164
188
  fill="var(--cdl-node-fill, #ffffff)"
165
189
  stroke={quadrantAccent[q]}
166
190
  strokeWidth={1.5}
167
191
  />
168
- <text x={qxStart + 12} y={py + rowH / 2 + 5} fontSize={13} fontWeight={600} fill="var(--cdl-text, #1a1f2a)">
192
+ {/* 字間を 0 に固定する。 枠の幅は字幅の見積り (letterSpacing 0 前提) で決めるため、
193
+ 外から字間を継承すると見積りより名前が広がって枠から出る (#457 Round 4) */}
194
+ <text x={qxStart + ITEM_PAD_X} y={py + rowH / 2 + 5} fontSize={ITEM_FONT} fontWeight={600} letterSpacing={0} fill="var(--cdl-text, #1a1f2a)">
169
195
  {it.title}
170
196
  </text>
171
197
  </g>
@@ -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>