@cardenelabs/cdl 0.83.0 → 0.102.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 (43) hide show
  1. package/CHANGELOG.md +851 -1
  2. package/dist/index.cjs +878 -186
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +17 -3
  5. package/dist/index.d.ts +17 -3
  6. package/dist/index.js +878 -186
  7. package/dist/index.js.map +1 -1
  8. package/dist/react.cjs +869 -178
  9. package/dist/react.cjs.map +1 -1
  10. package/dist/react.d.cts +1 -1
  11. package/dist/react.d.ts +1 -1
  12. package/dist/react.js +869 -178
  13. package/dist/react.js.map +1 -1
  14. package/dist/{render-DfXCs3Eh.d.cts → render-oEmwYN-q.d.cts} +21 -3
  15. package/dist/{render-DfXCs3Eh.d.ts → render-oEmwYN-q.d.ts} +21 -3
  16. package/package.json +1 -1
  17. package/src/kinds/chart-gauge.tsx +20 -3
  18. package/src/kinds/chart-legend-number.tsx +33 -0
  19. package/src/kinds/chart-number.ts +33 -0
  20. package/src/kinds/chart-radial.tsx +24 -7
  21. package/src/kinds/chart-stat.tsx +35 -5
  22. package/src/kinds/chart-waffle.tsx +25 -7
  23. package/src/kinds/dyn-shape.tsx +72 -12
  24. package/src/kinds/funnel.tsx +50 -2
  25. package/src/kinds/mind-map.tsx +206 -64
  26. package/src/kinds/quadrant.tsx +318 -45
  27. package/src/kinds/shape-blockchain.tsx +50 -18
  28. package/src/kinds/shape-commerce.tsx +27 -4
  29. package/src/kinds/shape-region.tsx +42 -1
  30. package/src/kinds/shape-software.tsx +51 -12
  31. package/src/kinds/storage.tsx +16 -8
  32. package/src/kinds/tree.tsx +46 -10
  33. package/src/lang.ts +5 -5
  34. package/src/layout/nodes.ts +3 -1
  35. package/src/layout/spec.ts +154 -25
  36. package/src/layout/text-width.ts +25 -0
  37. package/src/layout/viewbox.ts +32 -0
  38. package/src/presets.ts +21 -7
  39. package/src/render/nodes.tsx +77 -0
  40. package/src/render/payload-binding.ts +108 -25
  41. package/src/render/stage.tsx +24 -2
  42. package/src/types.ts +117 -38
  43. package/src/visual-validate.ts +61 -1
@@ -625,12 +625,30 @@ type QuadrantPayload = {
625
625
  };
626
626
  /** quadrant-matrix の象限 4 種 */
627
627
  type QuadrantKey = "topLeft" | "topRight" | "bottomLeft" | "bottomRight";
628
- /** quadrant-matrix の 1 item payload */
628
+ /**
629
+ * quadrant-matrix の中の位置。 描画域の左下を (0, 0)、 右上を (1, 1) とする。
630
+ *
631
+ * **y は上向き**。 SVG の座標とは逆で、 図に出る軸の名前 (`↑ 大きい`) と同じ向きにする。
632
+ * 書き手が読む向きと書く向きが逆だと、 値を反転して書くことになる。
633
+ */
634
+ type QuadrantAt = {
635
+ x: BoundNumber;
636
+ y: BoundNumber;
637
+ };
638
+ /**
639
+ * quadrant-matrix の 1 item payload。
640
+ *
641
+ * 位置の決め方は 2 通りで、**どちらか片方だけを読む**。 `at` があれば座標で置き、
642
+ * 無ければ `quadrant` の象限に積む。 両方を読むと同じことが 2 か所に書かれ、
643
+ * 食い違った時にどちらが正しいか決められない。
644
+ */
629
645
  type QuadrantItemPayload = {
630
646
  id: string;
631
647
  title: string;
632
- /** どの象限に居るか。 `{signal}` を書くと項目が象限を移る */
633
- quadrant: BoundEnum<QuadrantKey>;
648
+ /** どの象限に居るか。 `{signal}` を書くと項目が象限を移る。 `at` がある項目では読まない */
649
+ quadrant?: BoundEnum<QuadrantKey>;
650
+ /** 描画域の中の位置。 書くと点として描かれ、象限は座標から決まる */
651
+ at?: QuadrantAt;
634
652
  subtitle?: string;
635
653
  };
636
654
  /**
@@ -625,12 +625,30 @@ type QuadrantPayload = {
625
625
  };
626
626
  /** quadrant-matrix の象限 4 種 */
627
627
  type QuadrantKey = "topLeft" | "topRight" | "bottomLeft" | "bottomRight";
628
- /** quadrant-matrix の 1 item payload */
628
+ /**
629
+ * quadrant-matrix の中の位置。 描画域の左下を (0, 0)、 右上を (1, 1) とする。
630
+ *
631
+ * **y は上向き**。 SVG の座標とは逆で、 図に出る軸の名前 (`↑ 大きい`) と同じ向きにする。
632
+ * 書き手が読む向きと書く向きが逆だと、 値を反転して書くことになる。
633
+ */
634
+ type QuadrantAt = {
635
+ x: BoundNumber;
636
+ y: BoundNumber;
637
+ };
638
+ /**
639
+ * quadrant-matrix の 1 item payload。
640
+ *
641
+ * 位置の決め方は 2 通りで、**どちらか片方だけを読む**。 `at` があれば座標で置き、
642
+ * 無ければ `quadrant` の象限に積む。 両方を読むと同じことが 2 か所に書かれ、
643
+ * 食い違った時にどちらが正しいか決められない。
644
+ */
629
645
  type QuadrantItemPayload = {
630
646
  id: string;
631
647
  title: string;
632
- /** どの象限に居るか。 `{signal}` を書くと項目が象限を移る */
633
- quadrant: BoundEnum<QuadrantKey>;
648
+ /** どの象限に居るか。 `{signal}` を書くと項目が象限を移る。 `at` がある項目では読まない */
649
+ quadrant?: BoundEnum<QuadrantKey>;
650
+ /** 描画域の中の位置。 書くと点として描かれ、象限は座標から決まる */
651
+ at?: QuadrantAt;
634
652
  subtitle?: string;
635
653
  };
636
654
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardenelabs/cdl",
3
- "version": "0.83.0",
3
+ "version": "0.102.0",
4
4
  "description": "CDL (Chainome Diagram Language). Mermaid-like declarative DSL that compiles to animated SVG diagrams. Built for blockchain / Solidity flows, generic enough for sequence / flow / state / ER / topology diagrams.",
5
5
  "license": "MIT",
6
6
  "author": "cardene777",
@@ -3,7 +3,8 @@ import type { LaidNode, Tone } from "../types";
3
3
  import { TONE, chartSeriesColor } from "../render/tone";
4
4
  import { resolveChartData } from "../render/payload-binding";
5
5
  import { textWidth } from "./text-width";
6
- import { 実値表記, 割合表記 } from "./chart-number";
6
+ import { 実値表記, 割合表記, 値と割合の文字 } from "./chart-number";
7
+ import { 値と割合の字 } from "./chart-legend-number";
7
8
  import { 進みを畳む } from "./draw-progress";
8
9
  import { 扇形の切り抜き } from "./draw-arc";
9
10
 
@@ -88,7 +89,7 @@ export function ChartGaugeNode({
88
89
  */
89
90
  /** 内訳 1 件の数値の行 (実値と割合) */
90
91
  const 数値の行 = (d: { value: number }): string =>
91
- `${実値表記(d.value)} ${割合表記(d.value / total)}`;
92
+ 値と割合の文字(実値表記(d.value), 割合表記(d.value / total), total);
92
93
 
93
94
  /**
94
95
  * 件の並びから、1 行に置ける件数と段数を出す。
@@ -387,6 +388,14 @@ export function ChartGaugeNode({
387
388
  const y = 内訳のy + 段の高さ * 段;
388
389
  /** 区画に収まる字幅。 名前も数値も同じ幅で切る */
389
390
  const 収まる幅 = 内訳の幅 - 区画の間;
391
+ /**
392
+ * 数の行。 切らずに済む時だけ色で 2 つに分ける (cdl#911)。
393
+ *
394
+ * 切る時は 1 つの字として扱う = 切った後の字を値と割合に分け直すと、
395
+ * 切れ目が値の途中に来た行だけ色が付かない形になる。
396
+ */
397
+ const 数の行 = 内訳の数値[idx] ?? "";
398
+ const 切った数の行 = 切り詰める(数の行, 収まる幅, 数値の級);
390
399
  return (
391
400
  <g key={`legend-${idx}`} data-cdl-role="chart-gauge-item">
392
401
  <rect
@@ -408,7 +417,15 @@ export function ChartGaugeNode({
408
417
  </text>
409
418
  {/* **数値も切り詰める** = 切らないと隣の区画の字とくっつく (cdl#731) */}
410
419
  <text x={x} y={y + 46} fontSize={数値の級} fill="var(--cdl-text-dim, #5a6270)">
411
- {切り詰める(内訳の数値[idx] ?? "", 収まる幅, 数値の級)}
420
+ {切った数の行 === 数の行 ? (
421
+ <値と割合の字
422
+ 値={実値表記(d.value)}
423
+ 割合={割合表記(d.value / total)}
424
+ 合計={total}
425
+ />
426
+ ) : (
427
+ 切った数の行
428
+ )}
412
429
  </text>
413
430
  </g>
414
431
  );
@@ -0,0 +1,33 @@
1
+ import type { JSX } from "react";
2
+ import { 値と割合の組 } from "./chart-number";
3
+
4
+ /**
5
+ * 一覧の数の行を組む (cdl#911)。
6
+ *
7
+ * 値を濃く、割合を薄くして **色で 2 つに分ける**。 中黒のような区切りを字で足すと 1 件の幅が
8
+ * 増え、幅から置ける件数を決めている半円ゲージでは実測で件数 20 が 12 まで畳まれた。
9
+ * 色なら空白 1 つで並べられるので、繋ぐ前 (空白 2 つ) より狭くなる。
10
+ *
11
+ * **幅の見積りは `値と割合の文字` が持つ**。 描く側と測る側で別の字を組むと、収まるはずの
12
+ * 行が溢れる。 組み方をこの 1 か所に置いて、3 つの種別から呼ぶ。
13
+ */
14
+ export function 値と割合の字({
15
+ 値,
16
+ 割合,
17
+ 合計,
18
+ }: {
19
+ 値: string;
20
+ 割合: string;
21
+ 合計: number;
22
+ }): JSX.Element {
23
+ const 組 = 値と割合の組(値, 割合, 合計);
24
+ if (組.値 === null) return <tspan data-cdl-role="chart-legend-share">{組.割合}</tspan>;
25
+ return (
26
+ <>
27
+ <tspan data-cdl-role="chart-legend-value" fill="var(--cdl-text, #1a1f2a)">
28
+ {`${組.値} `}
29
+ </tspan>
30
+ <tspan data-cdl-role="chart-legend-share">{組.割合}</tspan>
31
+ </>
32
+ );
33
+ }
@@ -31,3 +31,36 @@ export function 割合表記(frac: number): string {
31
31
  const v = Math.round(frac * 1000) / 10;
32
32
  return `${Number.isInteger(v) ? v : v.toFixed(1)}%`;
33
33
  }
34
+
35
+ /**
36
+ * 一覧の 1 行に出す「値と割合」 (cdl#911)。
37
+ *
38
+ * **2 つの数を字で繋がない**。 `78 78%` のように空白 2 つで繋ぐと 1 つの数に見えるが、
39
+ * 中黒などの区切りを足すと 1 件あたりの幅が増える。 半円ゲージの内訳は幅で置ける件数を
40
+ * 決めているため、実測では件数 20 が 12 まで畳まれた (中黒に空白を添えない形でも同じ)。
41
+ * **色で分ける** = 値を濃く、割合を薄くすれば、空白 1 つで並べても 2 つの数として読める。
42
+ * 幅は繋ぐ前より狭くなる。
43
+ *
44
+ * **合計が 100 の図は割合だけを出す**。 値と割合が同じ数になり、同じ数を 2 度並べることに
45
+ * なる。 `chart-pie` が既に同じ判断を持っており (名前と値が同じ時は片方を落とす)、
46
+ * そこから形を揃えた。
47
+ *
48
+ * 判定に幅を持たせるのは、合計が小数の足し合わせで作られる図があるため。
49
+ */
50
+ export function 値と割合の組(値: string, 割合: string, 合計: number): {
51
+ 値: string | null;
52
+ 割合: string;
53
+ } {
54
+ return Math.abs(合計 - 100) < 1e-9 ? { 値: null, 割合 } : { 値, 割合 };
55
+ }
56
+
57
+ /**
58
+ * 同じ 1 行を 1 つの字として読んだ形 (cdl#911)。
59
+ *
60
+ * 幅を測る時に使う。 描く時は `値と割合の組` を使って色で分ける = 描画と幅の見積りで
61
+ * 別々の字を組むと、収まるはずの行が溢れる。
62
+ */
63
+ export function 値と割合の文字(値: string, 割合: string, 合計: number): string {
64
+ const 組 = 値と割合の組(値, 割合, 合計);
65
+ return 組.値 === null ? 組.割合 : `${組.値} ${組.割合}`;
66
+ }
@@ -3,7 +3,8 @@ import type { LaidNode, Tone } from "../types";
3
3
  import { TONE, chartSeriesColor } from "../render/tone";
4
4
  import { resolveChartData } from "../render/payload-binding";
5
5
  import { textWidth } from "./text-width";
6
- import { 実値表記, 割合表記 } from "./chart-number";
6
+ import { 実値表記, 割合表記, 値と割合の文字 } from "./chart-number";
7
+ import { 値と割合の字 } from "./chart-legend-number";
7
8
  import { 進みを畳む } from "./draw-progress";
8
9
  import { 扇形の切り抜き } from "./draw-arc";
9
10
 
@@ -67,7 +68,10 @@ export function ChartRadialNode({
67
68
  /** 一覧と弧の間 */
68
69
  const 一覧と弧の間 = 28;
69
70
 
70
- const 数値の文字 = data.map((d) => `${実値表記(d.value)} ${割合表記(d.value / total)}`);
71
+ /** 合計が 100 なら値と割合が同じ数になるので、一覧には割合だけを出す (cdl#911) */
72
+ const 数値の文字 = data.map((d) =>
73
+ 値と割合の文字(実値表記(d.value), 割合表記(d.value / total), total),
74
+ );
71
75
  /** 一覧の幅。 **中身から決める** = 割り当てを固定すると使わない分が死ぬ (cdl#542) */
72
76
  const 一覧の幅 =
73
77
  印の幅 +
@@ -97,7 +101,16 @@ export function ChartRadialNode({
97
101
  const 帯1本 = 使える厚み / Math.max(data.length, 1);
98
102
  const 帯の幅 = 帯1本 * 0.66;
99
103
 
100
- const 弧のcx = PAD + 一覧の幅 + 一覧と弧の間 + 外半径;
104
+ /**
105
+ * 一覧と弧を合わせた塊を、箱の横中央へ置く (cdl#911)。
106
+ *
107
+ * 外半径は高さでも決まるため、箱が横に長いほど右が余る。 左端から順に置いていた形では
108
+ * 実測で 37% が右に空いていた。
109
+ * **塊の単位で寄せる** = 一覧と弧を別々に中央へ寄せると 2 つが重なる。
110
+ */
111
+ const 中身の幅 = 一覧の幅 + 一覧と弧の間 + 外半径 * 2;
112
+ const 一覧の左 = Math.max(PAD, (node.w - 中身の幅) / 2);
113
+ const 弧のcx = 一覧の左 + 一覧の幅 + 一覧と弧の間 + 外半径;
101
114
  const cy = PAD + 使える高さ / 2;
102
115
 
103
116
  const rings = data.map((d, idx) => {
@@ -209,9 +222,9 @@ export function ChartRadialNode({
209
222
  const y = 一覧の始まり + 行の高さ * idx;
210
223
  return (
211
224
  <g key={`item-${idx}`} data-cdl-role="chart-radial-item">
212
- <rect x={PAD} y={y - 11} width={4} height={22} rx={2} fill={arcColor(d.tone, idx)} />
225
+ <rect x={一覧の左} y={y - 11} width={4} height={22} rx={2} fill={arcColor(d.tone, idx)} />
213
226
  <text
214
- x={PAD + 印の幅}
227
+ x={一覧の左 + 印の幅}
215
228
  y={y + 1}
216
229
  fontSize={名前の級}
217
230
  fontWeight={700}
@@ -220,12 +233,16 @@ export function ChartRadialNode({
220
233
  {d.label}
221
234
  </text>
222
235
  <text
223
- x={PAD + 印の幅}
236
+ x={一覧の左 + 印の幅}
224
237
  y={y + 20}
225
238
  fontSize={数値の級}
226
239
  fill="var(--cdl-text-dim, #5a6270)"
227
240
  >
228
- {数値の文字[idx]}
241
+ <値と割合の字
242
+ 値={実値表記(d.value)}
243
+ 割合={割合表記(d.value / total)}
244
+ 合計={total}
245
+ />
229
246
  </text>
230
247
  </g>
231
248
  );
@@ -53,7 +53,7 @@ export function ChartStatNode({
53
53
  */
54
54
  const 前を出す = data.some((d) => d.previous !== undefined);
55
55
 
56
- const 区画 = 区画の割付(node.w, node.h, data.length, 前を出す);
56
+ const 区画 = 区画の割付(node.w, node.h, data.length, 前を出す, data.length > 1);
57
57
 
58
58
  /**
59
59
  * 割合を出すか (cdl#759)。
@@ -207,17 +207,24 @@ export function ChartStatNode({
207
207
  * 件数で横に等分する。 **縦の位置は件数に依らない** = 件数が変わると数値の高さが動く形に
208
208
  * すると、同じ図を並べた時に数値の行が揃わない。
209
209
  */
210
- function 区画の割付(w: number, h: number, 件数: number, 前を出す: boolean) {
210
+ function 区画の割付(
211
+ w: number,
212
+ h: number,
213
+ 件数: number,
214
+ 前を出す: boolean,
215
+ 割合を出す: boolean,
216
+ ) {
211
217
  const 余白 = 20;
212
218
  const 幅 = 件数 > 0 ? w / 件数 : w;
213
219
  const 名前の級 = 14;
214
- /** 数値の級。 **名前より大きい** のがこの種別の中身 */
215
- const 数値の級 = 40;
216
220
  /** 前の時点の級。 名前と同じにする = 数値より大きくすると主役が入れ替わる (cdl#763) */
217
221
  const 前の級 = 名前の級;
218
222
  const 割合の級 = 12;
219
223
  const 弧の半径 = 20;
220
224
  const 弧の太さ = 4;
225
+ /** 数値の級の下限と上限 (cdl#911)。 下限は寄せる前の値で、上限は箱を大きくしても伸ばさない線 */
226
+ const 数値の級の下限 = 40;
227
+ const 数値の級の上限 = 112;
221
228
 
222
229
  /**
223
230
  * 前の行が使う縦の量 (cdl#763)。
@@ -227,8 +234,31 @@ function 区画の割付(w: number, h: number, 件数: number, 前を出す: boo
227
234
  */
228
235
  const 前の高さ = 前を出す ? 8 + 前の級 : 0;
229
236
 
237
+ /**
238
+ * 弧と割合が使う縦の量 (cdl#911)。
239
+ *
240
+ * **描かない図では 0 にする**。 前の行 (`前の高さ`) と同じ判断で、寄せる前はここだけ
241
+ * 常に 76 を数えていた。 件が 1 つの図は弧も割合も描かないのに縦だけ取られ、
242
+ * 中身が上へ寄って箱の下半分が空いていた。
243
+ */
244
+ const 弧と割合の高さ = 割合を出す ? 16 + 弧の半径 * 2 + 8 + 割合の級 : 0;
245
+
246
+ /**
247
+ * 数値の級。 **名前より大きい** のがこの種別の中身 (cdl#911)。
248
+ *
249
+ * 弧も割合も描かない図では、空いた縦を数値に回す = 数値 1 つのために箱を空けない。
250
+ * 上限を置くのは、箱が大きいほど字が伸び続けると図ではなく看板になるため。
251
+ * 幅に収める縮めは描く側の `収める級` が持つので、ここでは縦だけを見る。
252
+ */
253
+ const 数値の級 = 割合を出す
254
+ ? 数値の級の下限
255
+ : Math.max(
256
+ 数値の級の下限,
257
+ Math.min(数値の級の上限, Math.floor(h - 余白 * 2 - 名前の級 - 12 - 前の高さ)),
258
+ );
259
+
230
260
  // 上から 名前 / 数値 / 前 / 弧 / 割合 の順に積む。 中身の高さを出してから縦中央に寄せる
231
- const 中身の高さ = 名前の級 + 12 + 数値の級 + 前の高さ + 16 + 弧の半径 * 2 + 8 + 割合の級;
261
+ const 中身の高さ = 名前の級 + 12 + 数値の級 + 前の高さ + 弧と割合の高さ;
232
262
  const 上端 = Math.max(余白, (h - 中身の高さ) / 2);
233
263
 
234
264
  const 名前のy = 上端 + 名前の級;
@@ -3,7 +3,8 @@ import type { LaidNode, Tone } from "../types";
3
3
  import { TONE, chartSeriesColor } from "../render/tone";
4
4
  import { resolveChartData } from "../render/payload-binding";
5
5
  import { textWidth } from "./text-width";
6
- import { 実値表記 } from "./chart-number";
6
+ import { 実値表記, 値と割合の文字 } from "./chart-number";
7
+ import { 値と割合の字 } from "./chart-legend-number";
7
8
  import { 進みを畳む } from "./draw-progress";
8
9
 
9
10
  /**
@@ -104,7 +105,11 @@ export function ChartWaffleNode({
104
105
  const 一覧と格子の間 = 28;
105
106
  const 印の幅 = 18;
106
107
 
107
- const 数値の文字 = data.map((d, i) => `${実値表記(d.value)} ${配分[i] ?? 0}%`);
108
+ /** 合計が 100 なら値と割合が同じ数になるので、一覧には割合だけを出す (cdl#911) */
109
+ const 実値の合計 = data.reduce((acc, d) => acc + d.value, 0);
110
+ const 数値の文字 = data.map((d, i) =>
111
+ 値と割合の文字(実値表記(d.value), `${配分[i] ?? 0}%`, 実値の合計),
112
+ );
108
113
  /** 一覧の幅。 中身から決める = 割り当てを固定すると使わない分が死ぬ (cdl#542) */
109
114
  const 一覧の幅 =
110
115
  印の幅 +
@@ -120,7 +125,16 @@ export function ChartWaffleNode({
120
125
  /** 升の 1 辺。 縦横の小さい方に収める (格子は正方形) */
121
126
  const 升 = Math.max(4, Math.min(使える高さ, 格子に使える幅) / WAFFLE_COLS);
122
127
  const 印 = 升 * 0.78;
123
- const 格子の左 = PAD + 一覧の幅 + 一覧と格子の間;
128
+ /**
129
+ * 一覧と格子を合わせた塊を、箱の横中央へ置く (cdl#911)。
130
+ *
131
+ * 格子の 1 辺は高さで決まるため、箱が横に長いほど右が余る。 左端から順に置いていた形では
132
+ * 実測で 39% が右に空き、「何かが入るはずの空き」 に見えていた。
133
+ * **塊の単位で寄せる** = 一覧と格子を別々に中央へ寄せると 2 つが重なる。
134
+ */
135
+ const 中身の幅 = 一覧の幅 + 一覧と格子の間 + 升 * WAFFLE_COLS;
136
+ const 一覧の左 = Math.max(PAD, (node.w - 中身の幅) / 2);
137
+ const 格子の左 = 一覧の左 + 一覧の幅 + 一覧と格子の間;
124
138
  const 格子の上 = PAD + (使える高さ - 升 * WAFFLE_COLS) / 2;
125
139
 
126
140
  /** 印 1 個ごとの持ち主。 上の行から左詰めで配る = 読む向きと同じで数えやすい */
@@ -187,9 +201,9 @@ export function ChartWaffleNode({
187
201
  const y = 一覧の始まり + 行の高さ * idx;
188
202
  return (
189
203
  <g key={`item-${idx}`} data-cdl-role="chart-waffle-item">
190
- <rect x={PAD} y={y - 11} width={4} height={22} rx={2} fill={印の色(d.tone, idx)} />
204
+ <rect x={一覧の左} y={y - 11} width={4} height={22} rx={2} fill={印の色(d.tone, idx)} />
191
205
  <text
192
- x={PAD + 印の幅}
206
+ x={一覧の左 + 印の幅}
193
207
  y={y + 1}
194
208
  fontSize={名前の級}
195
209
  fontWeight={700}
@@ -200,12 +214,16 @@ export function ChartWaffleNode({
200
214
  <text
201
215
  data-cdl-role="chart-waffle-count"
202
216
  data-cdl-unresolved={d.unresolved ? "true" : undefined}
203
- x={PAD + 印の幅}
217
+ x={一覧の左 + 印の幅}
204
218
  y={y + 20}
205
219
  fontSize={数値の級}
206
220
  fill="var(--cdl-text-dim, #5a6270)"
207
221
  >
208
- {数値の文字[idx]}
222
+ <値と割合の字
223
+ 値={実値表記(d.value)}
224
+ 割合={`${配分[idx] ?? 0}%`}
225
+ 合計={実値の合計}
226
+ />
209
227
  </text>
210
228
  </g>
211
229
  );
@@ -60,18 +60,78 @@ export function DynShapeNode({
60
60
  const fillColor = shape.fill ?? "var(--cdl-fill, #e0e6ef)";
61
61
  const activeOpacity = active ? 1 : 0.85;
62
62
 
63
- switch (shape.kind) {
64
- case "rect":
65
- return renderRect(shape, x, y, w, h, stateValues, fillColor, strokeColor, activeOpacity, node);
66
- case "circle":
67
- return renderCircle(shape, cx, cy, w, h, stateValues, fillColor, strokeColor, activeOpacity);
68
- case "arc":
69
- return renderArc(shape, cx, cy, w, h, stateValues, fillColor, strokeColor, activeOpacity);
70
- case "wave":
71
- return <WaveShape shape={shape} x={x} y={y} w={w} h={h} stateValues={stateValues} fillColor={fillColor} strokeColor={strokeColor} opacity={activeOpacity} node={node} />;
72
- case "polygon":
73
- return renderPolygon(shape, cx, cy, stateValues, fillColor, strokeColor, activeOpacity);
74
- }
63
+ const 形 = (() => {
64
+ switch (shape.kind) {
65
+ case "rect":
66
+ return renderRect(shape, x, y, w, h, stateValues, fillColor, strokeColor, activeOpacity, node);
67
+ case "circle":
68
+ return renderCircle(shape, cx, cy, w, h, stateValues, fillColor, strokeColor, activeOpacity);
69
+ case "arc":
70
+ return renderArc(shape, cx, cy, w, h, stateValues, fillColor, strokeColor, activeOpacity);
71
+ case "wave":
72
+ return <WaveShape shape={shape} x={x} y={y} w={w} h={h} stateValues={stateValues} fillColor={fillColor} strokeColor={strokeColor} opacity={activeOpacity} node={node} />;
73
+ case "polygon":
74
+ return renderPolygon(shape, cx, cy, stateValues, fillColor, strokeColor, activeOpacity);
75
+ }
76
+ })();
77
+
78
+ // 名前と値は箱の外に置く (#925)。 中に置くと、内側の塗りが値で動くたびに文字と重なる。
79
+ //
80
+ // **差し込みはここで通さない**。 `render/nodes.tsx` が `{名前}` を解決した節を渡してくるので、
81
+ // ここで通しても何も変わらない (実測 = 通さない形に変えても検査が 1 件も落ちなかった)。
82
+ // しかも向こうは値ごとの呼び名の表を持っており、ここには無い = 二重に通すと、
83
+ // 呼び名を持つ図だけ結果が変わる余地を作る。
84
+ const 名前 = (node.title ?? "").trim();
85
+ const 値 = (node.subtitle ?? "").trim();
86
+ // どちらも書かれていない節では包む `<g>` も足さない = 書かない図の DOM を 1 要素も変えない
87
+ if (名前 === "" && 値 === "") return 形;
88
+ const 揃え = 箱いっぱいに描く(shape.kind) ? "start" : "middle";
89
+ return (
90
+ <g>
91
+ {形}
92
+ {名前 !== "" && (
93
+ <text
94
+ data-cdl-role="shape-name"
95
+ x={揃え === "start" ? x : cx}
96
+ y={y - NAME_GAP}
97
+ textAnchor={揃え}
98
+ fontSize={13}
99
+ fontWeight={600}
100
+ fill="var(--cdl-text, #23201b)"
101
+ >
102
+ {名前}
103
+ </text>
104
+ )}
105
+ {値 !== "" && (
106
+ <text
107
+ data-cdl-role="shape-value"
108
+ x={cx}
109
+ y={y + h + VALUE_GAP}
110
+ textAnchor="middle"
111
+ fontSize={12}
112
+ fill="var(--cdl-text-dim, #5a6270)"
113
+ >
114
+ {値}
115
+ </text>
116
+ )}
117
+ </g>
118
+ );
119
+ }
120
+
121
+ /** 名前の下端から箱の上端までの間 (world unit)。 読み取り値 (`y - 6`) と同じ高さに並ぶ */
122
+ const NAME_GAP = 6;
123
+ /** 箱の下端から値の上端までの間 (world unit) */
124
+ const VALUE_GAP = 18;
125
+
126
+ /**
127
+ * その形が箱いっぱいに描くか (#925)。
128
+ *
129
+ * 名前を揃える先が形の描き方で分かれる。 `rect` と `wave` は箱いっぱいに描くので、
130
+ * 箱の左端が形の左端になる。 `circle` / `arc` / `polygon` は箱の中心に描くので、
131
+ * 左端には何も無い = 左に揃えると名前だけが離れて浮く。
132
+ */
133
+ function 箱いっぱいに描く(kind: CdlDynShape["kind"]): boolean {
134
+ return kind === "rect" || kind === "wave";
75
135
  }
76
136
 
77
137
  function renderRect(
@@ -2,6 +2,7 @@ import { useId, type JSX } from "react";
2
2
  import type { LaidNode, Tone } from "../types";
3
3
  import { TONE } from "../render/tone";
4
4
  import { resolveFunnelData } from "../render/payload-binding";
5
+ import { 収める倍率 } from "../layout/text-width";
5
6
  import { 進みを畳む } from "./draw-progress";
6
7
 
7
8
  /**
@@ -68,6 +69,10 @@ export function FunnelNode({
68
69
  return canvasW * (topRatio + (bottomRatio - topRatio) * t);
69
70
  };
70
71
 
72
+ /** 段の名前と補足の素の大きさ。 補足は名前より 1 段小さくして、読む順を名前から先にする */
73
+ const 名前の字 = 14;
74
+ const 補足の字 = 10.5;
75
+
71
76
  const 段 = stages.map((s, idx) => {
72
77
  const prev = idx > 0 ? stages[idx - 1]! : null;
73
78
  // 前段が 0 件なら離脱率は決まらない (0 で割ることになる)。 静止画では起きなかったが、
@@ -82,6 +87,34 @@ export function FunnelNode({
82
87
  const points = `${topX},${y} ${topX + topW},${y} ${bottomX + bottomW},${y + stageH} ${bottomX},${y + stageH}`;
83
88
  const tone = toneByIndex(idx);
84
89
  const legendX = PAD_LEFT + canvasW + 24;
90
+
91
+ /*
92
+ 補足を段の中に置く (#931)。 書いた補足が 1 文字も描かれていなかった。
93
+
94
+ 収まる幅は **その段の下辺** で測る。 段は下へ向かって狭まるため、上辺で測ると
95
+ 下辺より広い補足が台形の外へ出る。 左右に 8 ずつ空けて辺に触れないようにする。
96
+
97
+ 縦は 2 行ぶんの高さが段に入るかで決める。 入らない時は両方を同じ倍率で縮める =
98
+ 片方だけ縮めると名前と補足の大小が入れ替わって読む順が崩れる。 落とさないのは
99
+ 書いた値を消さないため (この Issue が直しているのがまさにその形)。
100
+
101
+ 補足が無い段は 1 行のままにする。 位置の式を変えると、補足を書いていない図の
102
+ 焼き付けた見た目が動く。
103
+ */
104
+ const 補足 = (s.subtitle ?? "").trim();
105
+ const 補足あり = 補足 !== "";
106
+ const 二行の高さ = 名前の字 * 1.2 + 補足の字 * 1.25;
107
+ const 縦倍 = 補足あり ? Math.min(1, Math.max(0, stageH - 8) / 二行の高さ) : 1;
108
+ const 名前の実字 = 名前の字 * 縦倍;
109
+ const 補足の枠字 = 補足の字 * 縦倍;
110
+ const 収まる幅 = Math.max(0, bottomW - 16);
111
+ const 補足の実字 =
112
+ 補足の枠字 * 収める倍率(補足, { fontSize: 補足の枠字, fontWeight: 500 }, 収まる幅);
113
+ // 2 行の塊を段の中央に積む。 縦の位置は枠の字 (縮める前) で決める = 幅で縮めた分だけ
114
+ // 行が上下に動くと、段ごとに行の高さが揃わない
115
+ const 塊の上 = y + stageH / 2 - (名前の実字 * 1.2 + 補足の枠字 * 1.25) / 2;
116
+ const 名前のy = 補足あり ? 塊の上 + 名前の実字 * 0.95 : y + stageH / 2 + 5;
117
+ const 補足のy = 塊の上 + 名前の実字 * 1.2 + 補足の枠字 * 0.95;
85
118
  return (
86
119
  <g key={`stage-${s.id}`}>
87
120
  <polygon
@@ -95,14 +128,29 @@ export function FunnelNode({
95
128
  />
96
129
  <text
97
130
  x={cx}
98
- y={y + stageH / 2 + 5}
99
- fontSize={14}
131
+ y={名前のy}
132
+ fontSize={名前の実字}
100
133
  fontWeight={700}
101
134
  textAnchor="middle"
102
135
  fill="var(--cdl-node-fill, #ffffff)"
103
136
  >
104
137
  {s.title}
105
138
  </text>
139
+ {補足あり && (
140
+ <text
141
+ data-cdl-role="funnel-stage-subtitle"
142
+ x={cx}
143
+ y={補足のy}
144
+ fontSize={補足の実字}
145
+ fontWeight={500}
146
+ letterSpacing={0}
147
+ textAnchor="middle"
148
+ fill="var(--cdl-node-fill, #ffffff)"
149
+ fillOpacity={0.82}
150
+ >
151
+ {補足}
152
+ </text>
153
+ )}
106
154
  {/* 件数と離脱率は段が出切ってから。 切り抜きだけでは途中から見えるため自分で判定する */}
107
155
  {段が出切った(idx) && (
108
156
  <text