@cardenelabs/cdl 0.121.0 → 0.123.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 (69) hide show
  1. package/CHANGELOG.md +51 -1
  2. package/README.md +137 -0
  3. package/SPEC.md +1 -1
  4. package/dist/index.cjs +1513 -667
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +51 -33
  7. package/dist/index.d.ts +51 -33
  8. package/dist/index.js +1513 -668
  9. package/dist/index.js.map +1 -1
  10. package/dist/react.cjs +1271 -444
  11. package/dist/react.cjs.map +1 -1
  12. package/dist/react.d.cts +1 -1
  13. package/dist/react.d.ts +1 -1
  14. package/dist/react.js +1271 -444
  15. package/dist/react.js.map +1 -1
  16. package/dist/{render-A5AKjNuy.d.cts → render-CVczwfRb.d.cts} +40 -7
  17. package/dist/{render-A5AKjNuy.d.ts → render-CVczwfRb.d.ts} +40 -7
  18. package/package.json +9 -12
  19. package/src/builder.ts +8 -2
  20. package/src/index.ts +3 -0
  21. package/src/kinds/box-lines.ts +17 -4
  22. package/src/kinds/chart-bar.tsx +4 -0
  23. package/src/kinds/chart-gauge.tsx +20 -9
  24. package/src/kinds/chart-pie.tsx +67 -17
  25. package/src/kinds/chart-radial.tsx +7 -2
  26. package/src/kinds/chart-slope.tsx +26 -7
  27. package/src/kinds/chart-stacked-bar.tsx +8 -4
  28. package/src/kinds/chart-stat.tsx +19 -4
  29. package/src/kinds/chart-waffle.tsx +7 -2
  30. package/src/kinds/dyn-shape.tsx +22 -9
  31. package/src/kinds/funnel.tsx +9 -2
  32. package/src/kinds/generic.tsx +5 -2
  33. package/src/kinds/mind-map.tsx +26 -10
  34. package/src/kinds/quadrant.tsx +14 -4
  35. package/src/kinds/row-align.ts +19 -7
  36. package/src/kinds/sequence-board-metrics.ts +13 -3
  37. package/src/kinds/sequence-board.tsx +4 -2
  38. package/src/kinds/shape-basement.tsx +57 -32
  39. package/src/kinds/shape-blockchain.tsx +83 -34
  40. package/src/kinds/shape-commerce.tsx +65 -24
  41. package/src/kinds/shape-finance.tsx +90 -26
  42. package/src/kinds/shape-hardware.tsx +48 -17
  43. package/src/kinds/shape-region.tsx +16 -6
  44. package/src/kinds/shape-software.tsx +30 -8
  45. package/src/kinds/station.tsx +29 -0
  46. package/src/kinds/text-width.ts +17 -3
  47. package/src/kinds/tree.tsx +25 -7
  48. package/src/layout/collisions.ts +50 -28
  49. package/src/layout/curve-path.ts +126 -0
  50. package/src/layout/edges.ts +271 -58
  51. package/src/layout/head-label.ts +11 -5
  52. package/src/layout/lanes.ts +3 -2
  53. package/src/layout/metro-path.ts +185 -0
  54. package/src/layout/nodes.ts +14 -6
  55. package/src/layout/predict-bbox.ts +6 -3
  56. package/src/layout/self-loop.ts +10 -4
  57. package/src/layout/spec.ts +36 -7
  58. package/src/layout/text-width.ts +25 -8
  59. package/src/layout/tokens.ts +2 -0
  60. package/src/layout-with-validation.ts +10 -5
  61. package/src/layout.ts +55 -21
  62. package/src/presets.ts +69 -43
  63. package/src/render/edges.tsx +19 -3
  64. package/src/render/interactive-panel.tsx +34 -7
  65. package/src/render/nodes.tsx +100 -48
  66. package/src/render/stage.tsx +3 -0
  67. package/src/text-metrics.ts +9 -0
  68. package/src/types.ts +41 -4
  69. package/src/visual-validate.ts +91 -21
@@ -1,5 +1,5 @@
1
1
  import type { JSX } from "react";
2
- import { 使える幅, 幅に収める, 箱に積む, type 積む行 } from "./box-lines";
2
+ import { 使える幅, 幅に収める, 箱に積む, 箱の字を測る, type 積む行 } from "./box-lines";
3
3
  /*
4
4
  名前を描く幅の見積り。 **切る側と同じ物差しを使う** (#966)。
5
5
 
@@ -8,7 +8,7 @@ import { 使える幅, 幅に収める, 箱に積む, type 積む行 } from "./b
8
8
  置いたつもりの名前が描く側で切られた (実測 = `Features` が `Featur…` になった)。
9
9
  */
10
10
  import { textWidth as 名前の幅 } from "./text-width";
11
- import type { LaidNode, Tone, MindBranchNode, MindBranchPayload } from "../types";
11
+ import type { CdlTextMetrics, LaidNode, Tone, MindBranchNode, MindBranchPayload } from "../types";
12
12
  import { TONE } from "../render/tone";
13
13
  import { resolveMindData } from "../render/payload-binding";
14
14
  import { 進みを畳む, 伸ばすdash, 段の伸び } from "./draw-progress";
@@ -93,6 +93,7 @@ const MIND_PAD = 40;
93
93
  export function 放射の中身(
94
94
  node: LaidNode,
95
95
  stateValues: Record<string, string> = {},
96
+ textMetrics: CdlTextMetrics = "proportional",
96
97
  ): { nodes: LayoutNode[]; edges: LayoutEdge[] } | undefined {
97
98
  // 名前は状態を読める (#467)。 中心と枝の両方を解決層に通す
98
99
  const mindData = node.mindData ? resolveMindData(node.mindData, stateValues) : undefined;
@@ -103,6 +104,7 @@ export function 放射の中身(
103
104
  node.h / 2,
104
105
  node.w - MIND_PAD * 2,
105
106
  node.h - MIND_PAD * 2,
107
+ textMetrics,
106
108
  );
107
109
  }
108
110
 
@@ -124,6 +126,7 @@ export function MindMapNode({
124
126
  stateValues = {},
125
127
  drawing = false,
126
128
  progress = 1,
129
+ textMetrics = "proportional",
127
130
  }: {
128
131
  node: LaidNode;
129
132
  active: boolean;
@@ -132,11 +135,13 @@ export function MindMapNode({
132
135
  drawing?: boolean;
133
136
  /** phase の進み (0→1)。 `drawing` が true の時だけ読む */
134
137
  progress?: number;
138
+ textMetrics?: CdlTextMetrics;
135
139
  }): JSX.Element {
136
140
  const x0 = node.cx - node.w / 2;
137
141
  const y0 = node.cy - node.h / 2;
138
- const layout = 放射の中身(node, stateValues);
142
+ const layout = 放射の中身(node, stateValues, textMetrics);
139
143
  if (!layout) return <g transform={`translate(${x0} ${y0})`} />;
144
+ const 測る = 箱の字を測る(textMetrics);
140
145
 
141
146
  /**
142
147
  * 枝がどこまで伸びたか (0..1、 cdl#520)。 描く指定が無ければ常に 1 = 全部出ている。
@@ -208,10 +213,17 @@ export function MindMapNode({
208
213
  const 文字の寄せ = 枠なし ? (外へ ? "start" : "end") : "middle";
209
214
  const 描画域の端 = 外へ ? node.w - MIND_PAD : MIND_PAD;
210
215
  const 使える = 枠なし ? Math.abs(描画域の端 - 文字のx) : 使える幅(n.w);
211
- const 収めた名前 = 補足あり ? undefined : 幅に収める(名前.text, 名前.fontSize, 使える);
216
+ const 収めた名前 = 補足あり
217
+ ? undefined
218
+ : 幅に収める(名前.text, 名前.fontSize, 使える, 測る);
212
219
  // 積む側は箱の幅から余白を引いて使える幅を出すので、枠が無い時は同じ幅になる
213
220
  // 箱の幅を渡す。 助っ人の引数を増やすと木の側にも要らない欄が付く
214
- const 行 = 箱に積む(材料, 枠なし ? 使える + (n.w - 使える幅(n.w)) : n.w, n.h);
221
+ const 行 = 箱に積む(
222
+ 材料,
223
+ 枠なし ? 使える + (n.w - 使える幅(n.w)) : n.w,
224
+ n.h,
225
+ 測る,
226
+ );
215
227
  const 文字の色 = n.isRoot
216
228
  ? "var(--cdl-text-on-tone, #ffffff)"
217
229
  : "var(--cdl-chip-text, #1a1f2a)";
@@ -309,11 +321,14 @@ type LayoutEdge = {
309
321
  * 名前が描画域に入らない時、描く側は字を縮めてから切る。 ここで出すのは縮める前の幅 =
310
322
  * 実際に描く幅の上限になる。 縮むのは名前が溢れる図だけで、その時も箱の幅で測るより近い。
311
323
  */
312
- export function 描く横の端(n: LayoutNode): [number, number] {
324
+ export function 描く横の端(
325
+ n: LayoutNode,
326
+ textMetrics: CdlTextMetrics = "proportional",
327
+ ): [number, number] {
313
328
  if (n.isRoot || !n.isLeaf) return [n.x - n.w / 2, n.x + n.w / 2];
314
329
  const 起点 = n.x + (n.side === "right" ? -n.w / 2 : n.w / 2);
315
330
  const 名前 = 放射の行(n)[0]!;
316
- const 幅 = 名前の幅(名前.text, 名前.fontSize);
331
+ const 幅 = 名前の幅(名前.text, 名前.fontSize, 0, textMetrics);
317
332
  return n.side === "right" ? [起点, 起点 + 幅] : [起点 - 幅, 起点];
318
333
  }
319
334
 
@@ -323,6 +338,7 @@ export function computeMindMapLayout(
323
338
  cy: number,
324
339
  canvasW: number,
325
340
  canvasH: number,
341
+ textMetrics: CdlTextMetrics = "proportional",
326
342
  ): { nodes: LayoutNode[]; edges: LayoutEdge[] } {
327
343
  const nodes: LayoutNode[] = [];
328
344
  const edges: LayoutEdge[] = [];
@@ -422,7 +438,7 @@ export function computeMindMapLayout(
422
438
  /** 枝の名前を描く幅。 字の級は `放射の行` から取る (#538、 書き写すと 2 経路で別の値になる) */
423
439
  const 枝の名前の幅 = (title: string): number => {
424
440
  const 名前 = 放射の行({ title, isRoot: false })[0]!;
425
- return 名前の幅(名前.text, 名前.fontSize);
441
+ return 名前の幅(名前.text, 名前.fontSize, 0, textMetrics);
426
442
  };
427
443
 
428
444
  /** 片側の横の割付。 枝の離れと、その側の sub box の幅と、深さごとの追い出し量を持つ */
@@ -655,7 +671,7 @@ export function computeMindMapLayout(
655
671
  // 何もはみ出していない図まで縮む (実測 = 枝 1 本の図が 0.83 倍になり、
656
672
  // 箱の高さが 30.8 → 25.6 に縮んで補足の行が落ちた)。
657
673
  // すぐ下の寄せも同じ `描く横の端` で測るので、縮めると寄せの基準も揃う。
658
- const [左, 右] = 描く横の端(n);
674
+ const [左, 右] = 描く横の端(n, textMetrics);
659
675
  maxExtentX = Math.max(maxExtentX, Math.abs(左 - cx), Math.abs(右 - cx));
660
676
  maxExtentY = Math.max(maxExtentY, Math.abs(n.y - cy) + n.h / 2);
661
677
  }
@@ -683,7 +699,7 @@ export function computeMindMapLayout(
683
699
  let 左端 = Number.POSITIVE_INFINITY;
684
700
  let 右端 = Number.NEGATIVE_INFINITY;
685
701
  for (const n of nodes) {
686
- const [l, r] = 描く横の端(n);
702
+ const [l, r] = 描く横の端(n, textMetrics);
687
703
  左端 = Math.min(左端, l);
688
704
  右端 = Math.max(右端, r);
689
705
  }
@@ -1,5 +1,5 @@
1
1
  import type { JSX } from "react";
2
- import type { LaidNode } from "../types";
2
+ import type { CdlTextMetrics, LaidNode } from "../types";
3
3
  import { TONE, toneFill } from "../render/tone";
4
4
  import { measureTextWidth, 収める倍率 } from "../layout/text-width";
5
5
  import { resolveQuadrantData } from "../render/payload-binding";
@@ -23,10 +23,12 @@ export function QuadrantNode({
23
23
  node,
24
24
  active,
25
25
  stateValues = {},
26
+ textMetrics = "proportional",
26
27
  }: {
27
28
  node: LaidNode;
28
29
  active: boolean;
29
30
  stateValues?: Record<string, string>;
31
+ textMetrics?: CdlTextMetrics;
30
32
  }): JSX.Element {
31
33
  const x0 = node.cx - node.w / 2;
32
34
  const y0 = node.cy - node.h / 2;
@@ -56,7 +58,11 @@ export function QuadrantNode({
56
58
  const axisLabelPad = (text: string): number =>
57
59
  Math.max(
58
60
  PAD_MIN,
59
- measureTextWidth(`← ${text}`, { fontSize: AXIS_LABEL_FONT, fontWeight: 700 }) + 12,
61
+ measureTextWidth(`← ${text}`, {
62
+ fontSize: AXIS_LABEL_FONT,
63
+ fontWeight: 700,
64
+ textMetrics,
65
+ }) + 12,
60
66
  );
61
67
  // 名前が長くても図そのものは残す。 上限を置かないと、 名前が箱の幅を超えた時に
62
68
  // 描く場所が無くなる (実測 = 幅 640 に 50 文字の名前を左右へ置くと canvasW が -143 になった)。
@@ -294,7 +300,11 @@ export function QuadrantNode({
294
300
  ITEM_MIN_W,
295
301
  Math.max(
296
302
  ...行ら.map((r) =>
297
- measureTextWidth(r.text, { fontSize: r.size, fontWeight: r.weight }),
303
+ measureTextWidth(r.text, {
304
+ fontSize: r.size,
305
+ fontWeight: r.weight,
306
+ textMetrics,
307
+ }),
298
308
  ),
299
309
  ) +
300
310
  ITEM_PAD_X * 2,
@@ -333,7 +343,7 @@ export function QuadrantNode({
333
343
  SUB_FONT *
334
344
  収める倍率(
335
345
  補足,
336
- { fontSize: SUB_FONT, fontWeight: 500 },
346
+ { fontSize: SUB_FONT, fontWeight: 500, textMetrics },
337
347
  Math.max(0, 幅 - ITEM_PAD_X * 2),
338
348
  );
339
349
  return (
@@ -1,3 +1,4 @@
1
+ import type { CdlTextMetrics } from "../types";
1
2
  import { textWidth } from "./text-width";
2
3
 
3
4
  /**
@@ -114,8 +115,15 @@ export function splitRow(row: string): { left: string; right: string; split: boo
114
115
  * 等幅と比例を 1 つの関数で分けるのは、 同じ `generic` の行でも左が比例 (18) / 右が等幅 (21)
115
116
  * だから。 呼ぶ側が列ごとに選ぶ。
116
117
  */
117
- export function rowColumnWidth(text: string, size: number, mono: boolean): number {
118
- return mono ? displayWidth(text) * size * MONO_ADVANCE_RATIO : textWidth(text, size);
118
+ export function rowColumnWidth(
119
+ text: string,
120
+ size: number,
121
+ mono: boolean,
122
+ textMetrics: CdlTextMetrics = "proportional",
123
+ ): number {
124
+ return mono
125
+ ? displayWidth(text) * size * MONO_ADVANCE_RATIO
126
+ : textWidth(text, size, 0, textMetrics);
119
127
  }
120
128
 
121
129
  /**
@@ -179,7 +187,11 @@ export function 桁で省く(text: string, 上限: number): string {
179
187
  * 上に丸めるのは、 送り幅が小数のため幅が小数になり、 図の座標が読みにくくなるから。 下に
180
188
  * 丸めると 1 未満だけ足りない箱ができる。
181
189
  */
182
- export function requiredRowsWidth(rows: string[] | undefined, kind?: string): number {
190
+ export function requiredRowsWidth(
191
+ rows: string[] | undefined,
192
+ kind?: string,
193
+ textMetrics: CdlTextMetrics = "proportional",
194
+ ): number {
183
195
  if (!rows || rows.length === 0) return 0;
184
196
  const splits = rows.map(splitRow);
185
197
  const 一番広い = (取る: (s: { left: string; right: string }) => number) =>
@@ -188,17 +200,17 @@ export function requiredRowsWidth(rows: string[] | undefined, kind?: string): nu
188
200
  /** `storage` = 左右とも等幅の 26。 列の間に縦線を引くため間隔が広い。 */
189
201
  const storage =
190
202
  ROW_PAD_X_PX +
191
- 一番広い((s) => rowColumnWidth(s.left, 26, true)) +
203
+ 一番広い((s) => rowColumnWidth(s.left, 26, true, textMetrics)) +
192
204
  STORAGE_COL_GAP_PX +
193
- 一番広い((s) => rowColumnWidth(s.right, 26, true)) +
205
+ 一番広い((s) => rowColumnWidth(s.right, 26, true, textMetrics)) +
194
206
  ROW_RIGHT_PAD_PX;
195
207
 
196
208
  /** `generic` = 左が比例の 18、 右が等幅の 21。 */
197
209
  const generic =
198
210
  ROW_PAD_X_PX +
199
- 一番広い((s) => rowColumnWidth(s.left, GENERIC_ROW_LEFT_SIZE_PX, false)) +
211
+ 一番広い((s) => rowColumnWidth(s.left, GENERIC_ROW_LEFT_SIZE_PX, false, textMetrics)) +
200
212
  ROW_COL_GAP_PX +
201
- 一番広い((s) => rowColumnWidth(s.right, GENERIC_ROW_RIGHT_SIZE_PX, true)) +
213
+ 一番広い((s) => rowColumnWidth(s.right, GENERIC_ROW_RIGHT_SIZE_PX, true, textMetrics)) +
202
214
  ROW_RIGHT_PAD_PX;
203
215
 
204
216
  // **種別が分からない時は広い方を返す**。 どちらで描かれるか決められないので、 狭い方を返すと
@@ -8,6 +8,7 @@
8
8
  * React を引き込まない。
9
9
  */
10
10
  import { textWidth } from "./text-width";
11
+ import type { CdlTextMetrics } from "../types";
11
12
 
12
13
  /** 画布の縁 */
13
14
  export const SEQ_EDGE = 16;
@@ -100,12 +101,21 @@ export type SequenceBoardMetrics = {
100
101
  * 幅は **語の桁 + 列 × 面の数**。 語を線の脇ではなく左の桁に置くので、線の長さが語の長さに
101
102
  * 引きずられない = 面の数だけで横幅が決まる。
102
103
  */
103
- export function sequenceBoardMetrics(data: SequenceBoardData): SequenceBoardMetrics {
104
+ export function sequenceBoardMetrics(
105
+ data: SequenceBoardData,
106
+ textMetrics: CdlTextMetrics = "proportional",
107
+ ): SequenceBoardMetrics {
104
108
  const 名幅 = data.actors.map((a) =>
105
- Math.max(textWidth(a.name, SEQ_NAME), a.subtitle ? textWidth(a.subtitle, SEQ_SUB) : 0),
109
+ Math.max(
110
+ textWidth(a.name, SEQ_NAME, 0, textMetrics),
111
+ a.subtitle ? textWidth(a.subtitle, SEQ_SUB, 0, textMetrics) : 0,
112
+ ),
106
113
  );
107
114
  const colW = g4(Math.max(SEQ_COL_MIN, Math.max(0, ...名幅) + SEQ_COL_PAD));
108
- const 語幅 = Math.max(0, ...data.messages.map((m) => textWidth(m.label, SEQ_LABEL)));
115
+ const 語幅 = Math.max(
116
+ 0,
117
+ ...data.messages.map((m) => textWidth(m.label, SEQ_LABEL, 0, textMetrics)),
118
+ );
109
119
  const gutter = g4(SEQ_EDGE + SEQ_GUTTER_PAD + 語幅 + SEQ_GUTTER_GAP);
110
120
  const headY = SEQ_EDGE + SEQ_HEAD;
111
121
  const rowY = (i: number): number => headY + SEQ_ROW_PAD + i * SEQ_ROW;
@@ -1,6 +1,6 @@
1
1
  import { 等幅の字 } from "../render/mono-text";
2
2
  import type { JSX } from "react";
3
- import type { LaidNode } from "../types";
3
+ import type { CdlTextMetrics, LaidNode } from "../types";
4
4
  import { interpolate } from "../render/utils";
5
5
  import {
6
6
  SEQ_BAND_PAD,
@@ -45,13 +45,15 @@ import {
45
45
  export function SequenceBoardNode({
46
46
  node,
47
47
  stateValues,
48
+ textMetrics = "proportional",
48
49
  }: {
49
50
  node: LaidNode;
50
51
  stateValues?: Record<string, string>;
52
+ textMetrics?: CdlTextMetrics;
51
53
  }): JSX.Element {
52
54
  const data: SequenceBoardData | undefined = node.sequenceData;
53
55
  if (!data) return <g data-cdl-role="sequence-board" />;
54
- const m = sequenceBoardMetrics(data);
56
+ const m = sequenceBoardMetrics(data, textMetrics);
55
57
  const x = node.cx - node.w / 2;
56
58
  const y = node.cy - node.h / 2;
57
59
 
@@ -1,5 +1,5 @@
1
1
  import type { JSX } from "react";
2
- import type { LaidNode } from "../types";
2
+ import type { CdlTextMetrics, LaidNode } from "../types";
3
3
  import {
4
4
  ASCENT,
5
5
  DESCENT,
@@ -43,6 +43,7 @@ import {
43
43
  type ShapeProps = {
44
44
  node: LaidNode;
45
45
  active: boolean;
46
+ textMetrics?: CdlTextMetrics;
46
47
  };
47
48
 
48
49
  // basement は shape 全体が body なので、 純白ではなく accent 青系薄 tint (#dde8f0) で
@@ -81,34 +82,44 @@ function labelBlock(
81
82
  pos: { x: number; y: number },
82
83
  size: number,
83
84
  band: LabelBand,
85
+ textMetrics: CdlTextMetrics = "proportional",
84
86
  ): { k: number; x: number; baseline: number; 上端: number; 下端: number } {
85
87
  const 上 = node.eyebrow ? size + EYEBROW_GAP + EYEBROW_SIZE * ASCENT : size * ASCENT;
86
88
  const 下 = node.subtitle ? size + SUB_GAP + SUB_SIZE * DESCENT : size * DESCENT;
87
89
  // 3 行のうち一番広い行が横の制約になる
88
90
  const 幅 = Math.max(
89
- textWidth(node.title ?? "", size),
90
- node.eyebrow ? textWidth(node.eyebrow, EYEBROW_SIZE, EYEBROW_TRACKING) : 0,
91
- node.subtitle ? textWidth(node.subtitle, SUB_SIZE) : 0,
91
+ textWidth(node.title ?? "", size, 0, textMetrics),
92
+ node.eyebrow ? textWidth(node.eyebrow, EYEBROW_SIZE, EYEBROW_TRACKING, textMetrics) : 0,
93
+ node.subtitle ? textWidth(node.subtitle, SUB_SIZE, 0, textMetrics) : 0,
92
94
  );
93
95
  // 横で縮める倍率を読める下限で止める (#953)。 切って収まらない幅では下限を当てない。
94
96
  //
95
97
  // **題が一番広い行のときだけ当てる** (#957)。 塊の倍率は一番広い行で決まるので、
96
98
  // 補いの行が幅を決めている箱で下限を当てると、題を 0.6px 上げるために補いの行から
97
99
  // 字を落とすことになる = 払うのと得るのが別の行になる
98
- const 題幅 = textWidth(node.title ?? "", size);
100
+ const 題幅 = textWidth(node.title ?? "", size, 0, textMetrics);
101
+ const 測る = (t: string, s: number, ls: number): number => textWidth(t, s, ls, textMetrics);
99
102
  const 下限 =
100
- 題幅 >= 幅 ? 切って収まる下限(node.title ?? "", size, 0, band.w, textWidth) : 0;
103
+ 題幅 >= 幅 ? 切って収まる下限(node.title ?? "", size, 0, band.w, 測る) : 0;
101
104
  const { k, x, y: baseline } = fitLabel(band, { 上, 下, 幅 }, pos, 下限);
102
105
  return { k, x, baseline, 上端: baseline - 上 * k, 下端: baseline + 下 * k };
103
106
  }
104
107
 
105
- function commonLabel(node: LaidNode, pos: { x: number; y: number }, size: number, band: LabelBand) {
106
- const 塊 = labelBlock(node, pos, size, band);
108
+ function commonLabel(
109
+ node: LaidNode,
110
+ pos: { x: number; y: number },
111
+ size: number,
112
+ band: LabelBand,
113
+ textMetrics: CdlTextMetrics = "proportional",
114
+ ) {
115
+ const 塊 = labelBlock(node, pos, size, band, textMetrics);
107
116
  const k = 塊.k;
108
117
  const x = 塊.x;
109
118
  // 下限で止めた行は帯に入らないので、入るところまで切って `…` を付ける (#953)
110
119
  const 切る = (t: string, 大きさ: number, 字間 = 0) =>
111
- 帯で切る(t, 大きさ * k, band.w, (u, f) => textWidth(u, f, 字間 * k));
120
+ 帯で切る(t, 大きさ * k, band.w, (u, f) =>
121
+ textWidth(u, f, 字間 * k, textMetrics),
122
+ );
112
123
  return (
113
124
  <>
114
125
  {node.eyebrow && (
@@ -171,7 +182,7 @@ const RULE_PAD_RATIO = 0.14;
171
182
  const RULE_SHRINK_RATIO = 0.055;
172
183
 
173
184
  /** shape-file = 縦長 A4 型 file (right-top folded corner、 body に fold 内側 corner 統合) + drop shadow + text lines */
174
- export function ShapeFileNode({ node, active }: ShapeProps): JSX.Element {
185
+ export function ShapeFileNode({ node, active, textMetrics = "proportional" }: ShapeProps): JSX.Element {
175
186
  const 帯 = bodyBand(node, { 影ry: SHADOW_RY });
176
187
  const 紙h = 帯.h;
177
188
  const 紙w = Math.min(帯.w, 紙h * 0.72);
@@ -197,7 +208,7 @@ export function ShapeFileNode({ node, active }: ShapeProps): JSX.Element {
197
208
  const 名前帯 = { x, y, w: 紙w, h: 紙h };
198
209
  const 名前位置 = { x: 帯.cx, y: node.cy + node.h * 0.12 };
199
210
  // 名前の塊を先に測る。 3 本目が紙頭から固定 90 の位置に出ていたので、 塊に食い込む線は引かない
200
- const 名前 = labelBlock(node, 名前位置, 16, 名前帯);
211
+ const 名前 = labelBlock(node, 名前位置, 16, 名前帯, textMetrics);
201
212
  const 間隔 = Math.min(RULE_GAP_MAX, 紙h * RULE_GAP_RATIO);
202
213
  const 線余白 = Math.min(RULE_PAD_MAX, 紙w * RULE_PAD_RATIO);
203
214
  const textLines = [0, 1, 2]
@@ -231,7 +242,7 @@ export function ShapeFileNode({ node, active }: ShapeProps): JSX.Element {
231
242
  );
232
243
  })}
233
244
  {/* label = 中央 (text lines の下、 title 大、 subtitle 小、 A4 下半分) */}
234
- {commonLabel(node, 名前位置, 16, 名前帯)}
245
+ {commonLabel(node, 名前位置, 16, 名前帯, textMetrics)}
235
246
  </g>
236
247
  );
237
248
  }
@@ -241,7 +252,7 @@ const TAB_H_MAX = 22;
241
252
  const TAB_H_RATIO = 0.3;
242
253
 
243
254
  /** shape-folder = 左上 tab を body 左端に完全張り付け (tab 左 0、 tab 右 35%、 slope 42%) + drop shadow */
244
- export function ShapeFolderNode({ node, active }: ShapeProps): JSX.Element {
255
+ export function ShapeFolderNode({ node, active, textMetrics = "proportional" }: ShapeProps): JSX.Element {
245
256
  const 帯 = bodyBand(node, { 影ry: SHADOW_RY });
246
257
  const x = 帯.x;
247
258
  const y = 帯.y;
@@ -266,7 +277,7 @@ export function ShapeFolderNode({ node, active }: ShapeProps): JSX.Element {
266
277
  const 境界y = y + tabH + Math.min(8, (帯.h - tabH) * 0.2);
267
278
  const 端 = Math.min(6, 帯.w * 0.1);
268
279
  const 名前位置 = { x: 帯.cx, y: node.cy + 18 };
269
- const 名前 = labelBlock(node, 名前位置, 16, 帯);
280
+ const 名前 = labelBlock(node, 名前位置, 16, 帯, textMetrics);
270
281
  return (
271
282
  <g>
272
283
  {dropShadow(帯.cx, 帯.底, 帯.w * 0.44)}
@@ -284,7 +295,7 @@ export function ShapeFolderNode({ node, active }: ShapeProps): JSX.Element {
284
295
  opacity={0.35}
285
296
  />
286
297
  )}
287
- {commonLabel(node, 名前位置, 16, 帯)}
298
+ {commonLabel(node, 名前位置, 16, 帯, textMetrics)}
288
299
  </g>
289
300
  );
290
301
  }
@@ -297,7 +308,7 @@ const CLOUD_VB_H = 512;
297
308
  const CLOUD_W_RATIO = 0.9;
298
309
 
299
310
  /** shape-cloud = Font Awesome cloud icon path を scale + translate + drop shadow */
300
- export function ShapeCloudNode({ node, active }: ShapeProps): JSX.Element {
311
+ export function ShapeCloudNode({ node, active, textMetrics = "proportional" }: ShapeProps): JSX.Element {
301
312
  const 帯 = bodyBand(node, { 影ry: SHADOW_RY });
302
313
  const frame = commonFrame(active);
303
314
  // Font Awesome "cloud" icon の path (viewBox 640 x 512) を帯に scale + center。
@@ -328,7 +339,7 @@ export function ShapeCloudNode({ node, active }: ShapeProps): JSX.Element {
328
339
  />
329
340
  </g>
330
341
  {/* label = cloud 内側の中央 */}
331
- {commonLabel(node, { x: 帯.cx, y: node.cy + node.h * 0.05 }, 16, 帯)}
342
+ {commonLabel(node, { x: 帯.cx, y: node.cy + node.h * 0.05 }, 16, 帯, textMetrics)}
332
343
  </g>
333
344
  );
334
345
  }
@@ -341,7 +352,7 @@ const CYLINDER_RY_RATIO = 0.1;
341
352
  const CYLINDER_DISKS = [0.3, 0.55, 0.8];
342
353
 
343
354
  /** shape-cylinder = 3D 円柱 (上面 ellipse + 側面 + 底面 arc 下半分のみ) + drop shadow */
344
- export function ShapeCylinderNode({ node, active }: ShapeProps): JSX.Element {
355
+ export function ShapeCylinderNode({ node, active, textMetrics = "proportional" }: ShapeProps): JSX.Element {
345
356
  const 帯 = bodyBand(node, { 影ry: CYLINDER_SHADOW_RY });
346
357
  const x = 帯.x;
347
358
  const y = 帯.y;
@@ -361,7 +372,7 @@ export function ShapeCylinderNode({ node, active }: ShapeProps): JSX.Element {
361
372
  const 蓋底 = y + ry * 2;
362
373
  const 名前位置 = { x: 帯.cx, y: node.cy + 6 };
363
374
  const 名前帯 = { x: 帯.x, y: 蓋底, w: 帯.w, h: Math.max(0, 帯.底 - 蓋底) };
364
- const 名前 = labelBlock(node, 名前位置, 15, 名前帯);
375
+ const 名前 = labelBlock(node, 名前位置, 15, 名前帯, textMetrics);
365
376
  return (
366
377
  <g>
367
378
  {dropShadow(帯.cx, 帯.底, rx * 0.85, CYLINDER_SHADOW_RY)}
@@ -386,7 +397,7 @@ export function ShapeCylinderNode({ node, active }: ShapeProps): JSX.Element {
386
397
  opacity={0.3}
387
398
  />
388
399
  ))}
389
- {commonLabel(node, 名前位置, 15, 名前帯)}
400
+ {commonLabel(node, 名前位置, 15, 名前帯, textMetrics)}
390
401
  </g>
391
402
  );
392
403
  }
@@ -395,7 +406,7 @@ export function ShapeCylinderNode({ node, active }: ShapeProps): JSX.Element {
395
406
  const HEX_DOT_R = 2.5;
396
407
 
397
408
  /** shape-hexagon = 六角形 (component / service 表現、 accent 頂点) + drop shadow */
398
- export function ShapeHexagonNode({ node, active }: ShapeProps): JSX.Element {
409
+ export function ShapeHexagonNode({ node, active, textMetrics = "proportional" }: ShapeProps): JSX.Element {
399
410
  // 点は多角形の外へ出るので、 枠より点の方が広ければ点に合わせて帯を縮める
400
411
  const 帯 = bodyBand(node, { 影ry: SHADOW_RY, 外: Math.max(HEX_DOT_R, FRAME_HALF) });
401
412
  const cx = 帯.cx;
@@ -426,13 +437,14 @@ export function ShapeHexagonNode({ node, active }: ShapeProps): JSX.Element {
426
437
  { x: cx, y: node.cy + 6 },
427
438
  16,
428
439
  { x: 帯.x + cut, y: 帯.y, w: Math.max(0, 帯.w - cut * 2), h: 帯.h },
440
+ textMetrics,
429
441
  )}
430
442
  </g>
431
443
  );
432
444
  }
433
445
 
434
446
  /** shape-diamond = ひし形 (decision / 判定) + drop shadow + inner border */
435
- export function ShapeDiamondNode({ node, active }: ShapeProps): JSX.Element {
447
+ export function ShapeDiamondNode({ node, active, textMetrics = "proportional" }: ShapeProps): JSX.Element {
436
448
  const 帯 = bodyBand(node, { 影ry: SHADOW_RY });
437
449
  const cx = 帯.cx;
438
450
  const cy = 帯.cy;
@@ -475,6 +487,7 @@ export function ShapeDiamondNode({ node, active }: ShapeProps): JSX.Element {
475
487
  w: (帯.w * innerScale) / 2,
476
488
  h: (帯.h * innerScale) / 2,
477
489
  },
490
+ textMetrics,
478
491
  )}
479
492
  </g>
480
493
  );
@@ -485,7 +498,7 @@ const STACK_OFFSET_MAX = 12;
485
498
  const STACK_OFFSET_RATIO = 0.15;
486
499
 
487
500
  /** shape-stack = 重ね rect 3 枚 (layer / history) + drop shadow */
488
- export function ShapeStackNode({ node, active }: ShapeProps): JSX.Element {
501
+ export function ShapeStackNode({ node, active, textMetrics = "proportional" }: ShapeProps): JSX.Element {
489
502
  const 帯 = bodyBand(node, { 影ry: SHADOW_RY });
490
503
  const x = 帯.x;
491
504
  const y = 帯.y;
@@ -539,6 +552,7 @@ export function ShapeStackNode({ node, active }: ShapeProps): JSX.Element {
539
552
  { x: 手前cx, y: node.cy + ずれ + 6 },
540
553
  15,
541
554
  { x, y: y + ずれ * 2, w: cardW, h: cardH },
555
+ textMetrics,
542
556
  )}
543
557
  </g>
544
558
  );
@@ -564,21 +578,32 @@ const PERSON_SHADOW_RX = 55;
564
578
  * 動かさず **字の大きさだけ横に入る倍率まで下げる** = 各行の張り出しは縮むだけなので、
565
579
  * `labelPlan` が保証した縦の収まりは崩れない。
566
580
  */
567
- function planLabel(node: LaidNode, cx: number, 幅上限: number, plan: ReturnType<typeof labelPlan>) {
581
+ function planLabel(
582
+ node: LaidNode,
583
+ cx: number,
584
+ 幅上限: number,
585
+ plan: ReturnType<typeof labelPlan>,
586
+ textMetrics: CdlTextMetrics = "proportional",
587
+ ) {
568
588
  const 幅 = Math.max(
569
- textWidth(node.title ?? "", plan.名前.size),
570
- node.eyebrow && plan.肩書 ? textWidth(node.eyebrow, plan.肩書.size, EYEBROW_TRACKING) : 0,
571
- node.subtitle && plan.説明 ? textWidth(node.subtitle, plan.説明.size) : 0,
589
+ textWidth(node.title ?? "", plan.名前.size, 0, textMetrics),
590
+ node.eyebrow && plan.肩書
591
+ ? textWidth(node.eyebrow, plan.肩書.size, EYEBROW_TRACKING, textMetrics)
592
+ : 0,
593
+ node.subtitle && plan.説明 ? textWidth(node.subtitle, plan.説明.size, 0, textMetrics) : 0,
572
594
  );
573
595
  // 縮めは読める下限で止める (#953)。 下限が無いと長い名前が点にしか見えない大きさになる。
574
596
  // 題が一番広い行のときだけ当てる (#957、 上の `labelBlock` と同じ理由)
575
- const 題幅 = textWidth(node.title ?? "", plan.名前.size);
597
+ const 題幅 = textWidth(node.title ?? "", plan.名前.size, 0, textMetrics);
598
+ const 測る = (t: string, s: number, ls: number): number => textWidth(t, s, ls, textMetrics);
576
599
  const 下限 =
577
- 題幅 >= 幅 ? 切って収まる下限(node.title ?? "", plan.名前.size, 0, 幅上限, textWidth) : 0;
600
+ 題幅 >= 幅 ? 切って収まる下限(node.title ?? "", plan.名前.size, 0, 幅上限, 測る) : 0;
578
601
  const k = 幅 > 0 && 幅上限 > 0 ? Math.min(1, Math.max(幅上限 / 幅, 下限)) : 1;
579
602
  // 下限で止めた行は箱に入らないので、入るところまで切って `…` を付ける (#953)
580
603
  const 切る = (t: string, 大きさ: number, 字間 = 0) =>
581
- 帯で切る(t, 大きさ * k, 幅上限, (u, f) => textWidth(u, f, 字間 * k));
604
+ 帯で切る(t, 大きさ * k, 幅上限, (u, f) =>
605
+ textWidth(u, f, 字間 * k, textMetrics),
606
+ );
582
607
  return (
583
608
  <>
584
609
  {node.eyebrow && plan.肩書 && (
@@ -628,7 +653,7 @@ function planLabel(node: LaidNode, cx: number, 幅上限: number, plan: ReturnTy
628
653
  * `shapeRegion` で取り、 入らなければ `fitArt` が比を保ったまま縮める。 名前は箱の下端から
629
654
  * 積み上げる (`labelPlan`) = 固定位置に置くと箱の下 176.2 まで伸びていた。
630
655
  */
631
- export function ShapePersonNode({ node, active }: ShapeProps): JSX.Element {
656
+ export function ShapePersonNode({ node, active, textMetrics = "proportional" }: ShapeProps): JSX.Element {
632
657
  const r = shapeRegion(node);
633
658
  const cx = r.cx;
634
659
  const strokeColor = active ? "var(--cdl-tone-accent, #2d6a8f)" : "var(--cdl-divider, #d4d4d8)";
@@ -698,7 +723,7 @@ export function ShapePersonNode({ node, active }: ShapeProps): JSX.Element {
698
723
  />
699
724
  </g>,
700
725
  )}
701
- {planLabel(node, r.cx, Math.max(0, node.w), r.label)}
726
+ {planLabel(node, r.cx, Math.max(0, node.w), r.label, textMetrics)}
702
727
  </g>
703
728
  );
704
729
  }