@cardenelabs/cdl 0.122.0 → 0.124.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 (64) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/README.md +70 -1
  3. package/dist/index.cjs +1125 -618
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +38 -26
  6. package/dist/index.d.ts +38 -26
  7. package/dist/index.js +1125 -619
  8. package/dist/index.js.map +1 -1
  9. package/dist/react.cjs +894 -404
  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 +894 -404
  14. package/dist/react.js.map +1 -1
  15. package/dist/{render-U_pC-Ksh.d.cts → render-U6omSNH2.d.cts} +23 -4
  16. package/dist/{render-U_pC-Ksh.d.ts → render-U6omSNH2.d.ts} +23 -4
  17. package/package.json +9 -12
  18. package/src/builder.ts +5 -0
  19. package/src/index.ts +2 -0
  20. package/src/kinds/box-lines.ts +17 -4
  21. package/src/kinds/chart-bar.tsx +4 -0
  22. package/src/kinds/chart-gauge.tsx +20 -9
  23. package/src/kinds/chart-pie.tsx +67 -17
  24. package/src/kinds/chart-radial.tsx +7 -2
  25. package/src/kinds/chart-slope.tsx +26 -7
  26. package/src/kinds/chart-stacked-bar.tsx +8 -4
  27. package/src/kinds/chart-stat.tsx +19 -4
  28. package/src/kinds/chart-waffle.tsx +7 -2
  29. package/src/kinds/funnel.tsx +9 -2
  30. package/src/kinds/generic.tsx +5 -2
  31. package/src/kinds/mind-map.tsx +26 -10
  32. package/src/kinds/quadrant.tsx +14 -4
  33. package/src/kinds/row-align.ts +19 -7
  34. package/src/kinds/sequence-board-metrics.ts +13 -3
  35. package/src/kinds/sequence-board.tsx +4 -2
  36. package/src/kinds/shape-basement.tsx +57 -32
  37. package/src/kinds/shape-blockchain.tsx +83 -34
  38. package/src/kinds/shape-commerce.tsx +65 -24
  39. package/src/kinds/shape-finance.tsx +90 -26
  40. package/src/kinds/shape-hardware.tsx +48 -17
  41. package/src/kinds/shape-region.tsx +16 -6
  42. package/src/kinds/shape-software.tsx +30 -8
  43. package/src/kinds/text-width.ts +17 -3
  44. package/src/kinds/tree.tsx +25 -7
  45. package/src/layout/collisions.ts +21 -8
  46. package/src/layout/curve-path.ts +3 -2
  47. package/src/layout/edges.ts +173 -29
  48. package/src/layout/head-label.ts +3 -1
  49. package/src/layout/lanes.ts +3 -2
  50. package/src/layout/metro-path.ts +3 -2
  51. package/src/layout/nodes.ts +14 -6
  52. package/src/layout/predict-bbox.ts +6 -3
  53. package/src/layout/self-loop.ts +10 -4
  54. package/src/layout/spec.ts +20 -6
  55. package/src/layout/text-width.ts +25 -8
  56. package/src/layout.ts +39 -15
  57. package/src/presets.ts +69 -43
  58. package/src/render/edges.tsx +18 -3
  59. package/src/render/interactive-panel.tsx +34 -7
  60. package/src/render/nodes.tsx +97 -48
  61. package/src/render/stage.tsx +3 -0
  62. package/src/text-metrics.ts +9 -0
  63. package/src/types.ts +22 -2
  64. package/src/visual-validate.ts +67 -17
@@ -10,7 +10,7 @@ import {
10
10
  帯で切る,
11
11
  } from "./shape-region";
12
12
  import { MONO_ADVANCE_RATIO, displayWidth } from "./row-align";
13
- import type { LaidNode } from "../types";
13
+ import type { CdlTextMetrics, LaidNode } from "../types";
14
14
  import { 字引きを作る, type CdlLang } from "../lang";
15
15
 
16
16
  /**
@@ -29,6 +29,7 @@ type ShapeProps = {
29
29
  active: boolean;
30
30
  /** 描画側の固定の字の言語 (#847)。 渡さない呼び方は英語で描く */
31
31
  lang?: CdlLang;
32
+ textMetrics?: CdlTextMetrics;
32
33
  };
33
34
 
34
35
  const commonFrame = (active: boolean) => ({
@@ -82,7 +83,13 @@ function 字の比(c: string): number {
82
83
  }
83
84
 
84
85
  /** 字の並びの幅の見積り。 字ごとの比を足して、 字間を字数分足す。 */
85
- function 字幅(文字列: string, 大きさ: number, 字間: number): number {
86
+ function 字幅(
87
+ 文字列: string,
88
+ 大きさ: number,
89
+ 字間: number,
90
+ textMetrics: CdlTextMetrics = "proportional",
91
+ ): number {
92
+ if (textMetrics === "monospace") return textWidth(文字列, 大きさ, 字間, textMetrics);
86
93
  let 幅 = 0;
87
94
  let 字数 = 0;
88
95
  for (const c of 文字列) {
@@ -101,28 +108,55 @@ function 字幅(文字列: string, 大きさ: number, 字間: number): number {
101
108
  *
102
109
  * 字間も同じ割合で詰める = 大きさだけ縮めても字間が残ると幅が比例して縮まない。
103
110
  */
104
- function 幅に収める(文字列: string, 大きさ: number, 字間: number, 箱幅: number, 題か = false) {
105
- const 幅 = 字幅(文字列, 大きさ, 字間);
111
+ function 幅に収める(
112
+ 文字列: string,
113
+ 大きさ: number,
114
+ 字間: number,
115
+ 箱幅: number,
116
+ 題か = false,
117
+ textMetrics: CdlTextMetrics = "proportional",
118
+ ) {
119
+ const 測る = (t: string, s: number, ls: number): number => 字幅(t, s, ls, textMetrics);
120
+ const 幅 = 測る(文字列, 大きさ, 字間);
106
121
  // 縮めは読める下限で止める (#953)。 下限が無いと長い名前が点にしか見えない大きさになる。
107
122
  // 止めた行は箱に入らないので、入るところまで切って `…` を付ける (#433 の決まりを保つ)。
108
123
  //
109
124
  // **下限を当てるのは題の行だけ** (#957)。 補いの行に当てると、収まっている行を
110
125
  // 大きくしてから切ることになる = 書いた字を落として得るのが 1px に満たない
111
- const 下限 = 題か ? 切って収まる下限(文字列, 大きさ, 字間, 箱幅, 字幅) : 0;
126
+ const 下限 = 題か ? 切って収まる下限(文字列, 大きさ, 字間, 箱幅, 測る) : 0;
112
127
  const 倍率 = 幅 > 0 && 箱幅 > 0 ? Math.min(1, Math.max(箱幅 / 幅, 下限)) : 1;
113
128
  const size = 大きさ * 倍率;
114
129
  const spacing = 字間 * 倍率;
115
- return { size, spacing, text: 帯で切る(文字列, size, 箱幅, (u, f) => 字幅(u, f, spacing)) };
130
+ return { size, spacing, text: 帯で切る(文字列, size, 箱幅, (u, f) => 測る(u, f, spacing)) };
116
131
  }
117
132
 
118
133
  /**
119
134
  * 名前の塊を描く。 縦の位置と大きさは `labelPlan` が決め (箱の下端から積み上げる、 #433)、
120
135
  * 横は `幅に収める` が箱の幅に合わせて縮める。
121
136
  */
122
- function bottomLabel(node: LaidNode, cx: number, plan: ReturnType<typeof labelPlan>) {
123
- const 肩 = 幅に収める(node.eyebrow ?? "", plan.肩書?.size ?? 0, LABEL_EYEBROW_SPACING, node.w);
124
- const 名 = 幅に収める(node.title ?? "", plan.名前.size, 0, node.w, true);
125
- const 説 = 幅に収める(node.subtitle ?? "", plan.説明?.size ?? 0, 0, node.w);
137
+ function bottomLabel(
138
+ node: LaidNode,
139
+ cx: number,
140
+ plan: ReturnType<typeof labelPlan>,
141
+ textMetrics: CdlTextMetrics = "proportional",
142
+ ) {
143
+ const 肩 = 幅に収める(
144
+ node.eyebrow ?? "",
145
+ plan.肩書?.size ?? 0,
146
+ LABEL_EYEBROW_SPACING,
147
+ node.w,
148
+ false,
149
+ textMetrics,
150
+ );
151
+ const 名 = 幅に収める(node.title ?? "", plan.名前.size, 0, node.w, true, textMetrics);
152
+ const 説 = 幅に収める(
153
+ node.subtitle ?? "",
154
+ plan.説明?.size ?? 0,
155
+ 0,
156
+ node.w,
157
+ false,
158
+ textMetrics,
159
+ );
126
160
  return (
127
161
  <>
128
162
  {node.eyebrow && plan.肩書 && (
@@ -160,7 +194,7 @@ const WEBSITE_W_MIN = 160;
160
194
  const WEBSITE_W_MAX = 280;
161
195
 
162
196
  /** shape-website = ブラウザ (シンプル + シンプルレイアウト) */
163
- export function ShapeWebsiteNode({ node, active }: ShapeProps): JSX.Element {
197
+ export function ShapeWebsiteNode({ node, active, textMetrics = "proportional" }: ShapeProps): JSX.Element {
164
198
  const frame = commonFrame(active);
165
199
  const r = shapeRegion(node);
166
200
  const cx = r.cx;
@@ -194,13 +228,13 @@ export function ShapeWebsiteNode({ node, active }: ShapeProps): JSX.Element {
194
228
  return (
195
229
  <g>
196
230
  {fitArt(r, 広がり, 絵)}
197
- {bottomLabel(node, r.cx, r.label)}
231
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
198
232
  </g>
199
233
  );
200
234
  }
201
235
 
202
236
  /** shape-storefront = S-A シンプル shop (平屋根 + SHOP 看板 + 大 window (商品) + 中央 door + OPEN sign) */
203
- export function ShapeStorefrontNode({ node, active, lang }: ShapeProps): JSX.Element {
237
+ export function ShapeStorefrontNode({ node, active, lang, textMetrics = "proportional" }: ShapeProps): JSX.Element {
204
238
  const 字引 = 字引きを作る(lang);
205
239
  const frame = commonFrame(active);
206
240
  const r = shapeRegion(node);
@@ -258,7 +292,7 @@ export function ShapeStorefrontNode({ node, active, lang }: ShapeProps): JSX.Ele
258
292
  <circle cx={doorX + 6} cy={doorY + doorH * 0.55} r={1.5} fill="#c15a4a" />
259
293
  {/* base 床 (前 storefront 直下のみ) */}
260
294
  <rect x={x - 6} y={y + bh - 6} width={bw + 12} height={8} fill="#5a6270" stroke="none" />
261
- {bottomLabel(node, r.cx, r.label)}
295
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
262
296
  </g>
263
297
  );
264
298
  }
@@ -286,7 +320,7 @@ const WAREHOUSE_W_MIN = 160;
286
320
  const WAREHOUSE_W_MAX = 240;
287
321
 
288
322
  /** shape-warehouse = W-A 大 shutter (平屋根 + WAREHOUSE 看板 + 中央大 shutter 10 rib + loading dock) */
289
- export function ShapeWarehouseNode({ node, active, lang }: ShapeProps): JSX.Element {
323
+ export function ShapeWarehouseNode({ node, active, lang, textMetrics = "proportional" }: ShapeProps): JSX.Element {
290
324
  const 字引 = 字引きを作る(lang);
291
325
  const frame = commonFrame(active);
292
326
  const r = shapeRegion(node);
@@ -353,7 +387,7 @@ export function ShapeWarehouseNode({ node, active, lang }: ShapeProps): JSX.Elem
353
387
  return (
354
388
  <g>
355
389
  {fitArt(r, 広がり, 絵)}
356
- {bottomLabel(node, r.cx, r.label)}
390
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
357
391
  </g>
358
392
  );
359
393
  }
@@ -372,7 +406,7 @@ const HANDLE_GRIP_R = 4;
372
406
  /** 影の楕円が中心から左右へ出る距離。 絵の中で一番右まで来る部品 */
373
407
  const CART_SHADOW_RX = 80;
374
408
 
375
- export function ShapeOnlineShopNode({ node, active }: ShapeProps): JSX.Element {
409
+ export function ShapeOnlineShopNode({ node, active, textMetrics = "proportional" }: ShapeProps): JSX.Element {
376
410
  const frame = commonFrame(active);
377
411
  const r = shapeRegion(node);
378
412
  const cy = r.cy + 10;
@@ -445,13 +479,13 @@ export function ShapeOnlineShopNode({ node, active }: ShapeProps): JSX.Element {
445
479
  <circle cx={wheelR} cy={wheelY} r={16 * s} fill="#0f1830" stroke="#000" strokeWidth={2 * s} />
446
480
  <circle cx={wheelR} cy={wheelY} r={8 * s} fill="#5a6270" stroke="#0f1830" strokeWidth={1.5 * s} />
447
481
  <circle cx={wheelR} cy={wheelY} r={2 * s} fill="#0f1830" />
448
- {bottomLabel(node, r.cx, r.label)}
482
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
449
483
  </g>
450
484
  );
451
485
  }
452
486
 
453
487
  /** shape-cdn-edge = 地球儀 (globe + 5 edge dots + pulse ring + lightning at center) */
454
- export function ShapeCdnEdgeNode({ node, active }: ShapeProps): JSX.Element {
488
+ export function ShapeCdnEdgeNode({ node, active, textMetrics = "proportional" }: ShapeProps): JSX.Element {
455
489
  const frame = commonFrame(active);
456
490
  const r = shapeRegion(node);
457
491
  const cx = r.cx;
@@ -523,7 +557,7 @@ export function ShapeCdnEdgeNode({ node, active }: ShapeProps): JSX.Element {
523
557
  return (
524
558
  <g>
525
559
  {fitArt(r, { 上: 広がり, 下: 広がり, 左: 広がり, 右: 広がり }, 絵)}
526
- {bottomLabel(node, r.cx, r.label)}
560
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
527
561
  </g>
528
562
  );
529
563
  }
@@ -553,7 +587,7 @@ const API_W_MIN = 200;
553
587
  const API_W_MAX = 300;
554
588
 
555
589
  /** shape-api-gateway = SaaS API panel (dark bg + method + path endpoints + status) */
556
- export function ShapeApiGatewayNode({ node, active, lang }: ShapeProps): JSX.Element {
590
+ export function ShapeApiGatewayNode({ node, active, lang, textMetrics = "proportional" }: ShapeProps): JSX.Element {
557
591
  const 字引 = 字引きを作る(lang);
558
592
  const frame = commonFrame(active);
559
593
  const r = shapeRegion(node);
@@ -570,7 +604,14 @@ export function ShapeApiGatewayNode({ node, active, lang }: ShapeProps): JSX.Ele
570
604
  // 11 のまま入り、長い綴り (日本語の辞書の `DELETE`) だけを縮める。 見積りは等幅と比例の大きい方
571
605
  // (`shape-blockchain.tsx` の `字の幅` と同じ考え方で、どちらの書体で描かれても箱からはみ出さない)
572
606
  const 種類の字 = (s: string): number =>
573
- Math.min(11, (11 * API_METHOD_TEXT_W) / Math.max(displayWidth(s) * 11 * MONO_ADVANCE_RATIO, textWidth(s, 11)));
607
+ Math.min(
608
+ 11,
609
+ (11 * API_METHOD_TEXT_W) /
610
+ Math.max(
611
+ displayWidth(s) * 11 * MONO_ADVANCE_RATIO,
612
+ textWidth(s, 11, 0, textMetrics),
613
+ ),
614
+ );
574
615
  const rowPitch = Math.max(
575
616
  API_ROW_PITCH_MIN,
576
617
  (bh - API_ROW_TOP - API_ROW_H - API_ROW_BOTTOM_PAD) / (API_ROWS - 1),
@@ -595,7 +636,7 @@ export function ShapeApiGatewayNode({ node, active, lang }: ShapeProps): JSX.Ele
595
636
  帯: { 幅: bw - 32 - 64 - 16, 高さ: API_ROW_H },
596
637
  }));
597
638
  const 絵 = (倍率: number): JSX.Element => {
598
- const 倍 = 打ち消しの倍率(倍率, 打ち消す字ら);
639
+ const 倍 = 打ち消しの倍率(倍率, 打ち消す字ら, textMetrics);
599
640
  return (
600
641
  <g>
601
642
  <rect x={x + DROP_SHADOW_D} y={y + DROP_SHADOW_D} width={bw} height={bh} rx={10} fill={frame.stroke} opacity={0.2} stroke="none" />
@@ -625,7 +666,7 @@ export function ShapeApiGatewayNode({ node, active, lang }: ShapeProps): JSX.Ele
625
666
  return (
626
667
  <g>
627
668
  {fitArt(r, 広がり, 絵)}
628
- {bottomLabel(node, r.cx, r.label)}
669
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
629
670
  </g>
630
671
  );
631
672
  }
@@ -3,12 +3,13 @@ import {
3
3
  shapeRegion,
4
4
  labelPlan,
5
5
  fitArt,
6
+ textWidth,
6
7
  打ち消しの倍率,
7
8
  type 打ち消す字,
8
9
  切って収まる下限,
9
10
  帯で切る,
10
11
  } from "./shape-region";
11
- import type { LaidNode } from "../types";
12
+ import type { CdlTextMetrics, LaidNode } from "../types";
12
13
  import { 字引きを作る, type CdlLang } from "../lang";
13
14
 
14
15
  /**
@@ -26,6 +27,7 @@ import { 字引きを作る, type CdlLang } from "../lang";
26
27
  type ShapeProps = {
27
28
  node: LaidNode;
28
29
  active: boolean;
30
+ textMetrics?: CdlTextMetrics;
29
31
  /** 描画側の固定の字の言語 (#847)。 渡さない呼び方は英語で描く */
30
32
  lang?: CdlLang;
31
33
  };
@@ -81,7 +83,13 @@ function 字の比(c: string): number {
81
83
  }
82
84
 
83
85
  /** 字の並びの幅の見積り。 字ごとの比を足して、 字間を字数分足す。 */
84
- function 字幅(文字列: string, 大きさ: number, 字間: number): number {
86
+ function 字幅(
87
+ 文字列: string,
88
+ 大きさ: number,
89
+ 字間: number,
90
+ textMetrics: CdlTextMetrics = "proportional",
91
+ ): number {
92
+ if (textMetrics === "monospace") return textWidth(文字列, 大きさ, 字間, textMetrics);
85
93
  let 幅 = 0;
86
94
  let 字数 = 0;
87
95
  for (const c of 文字列) {
@@ -100,28 +108,55 @@ function 字幅(文字列: string, 大きさ: number, 字間: number): number {
100
108
  *
101
109
  * 字間も同じ割合で詰める = 大きさだけ縮めても字間が残ると幅が比例して縮まない。
102
110
  */
103
- function 幅に収める(文字列: string, 大きさ: number, 字間: number, 箱幅: number, 題か = false) {
104
- const 幅 = 字幅(文字列, 大きさ, 字間);
111
+ function 幅に収める(
112
+ 文字列: string,
113
+ 大きさ: number,
114
+ 字間: number,
115
+ 箱幅: number,
116
+ 題か = false,
117
+ textMetrics: CdlTextMetrics = "proportional",
118
+ ) {
119
+ const 測る = (t: string, s: number, ls: number): number => 字幅(t, s, ls, textMetrics);
120
+ const 幅 = 測る(文字列, 大きさ, 字間);
105
121
  // 縮めは読める下限で止める (#953)。 下限が無いと長い名前が点にしか見えない大きさになる。
106
122
  // 止めた行は箱に入らないので、入るところまで切って `…` を付ける (#433 の決まりを保つ)。
107
123
  //
108
124
  // **下限を当てるのは題の行だけ** (#957)。 補いの行に当てると、収まっている行を
109
125
  // 大きくしてから切ることになる = 書いた字を落として得るのが 1px に満たない
110
- const 下限 = 題か ? 切って収まる下限(文字列, 大きさ, 字間, 箱幅, 字幅) : 0;
126
+ const 下限 = 題か ? 切って収まる下限(文字列, 大きさ, 字間, 箱幅, 測る) : 0;
111
127
  const 倍率 = 幅 > 0 && 箱幅 > 0 ? Math.min(1, Math.max(箱幅 / 幅, 下限)) : 1;
112
128
  const size = 大きさ * 倍率;
113
129
  const spacing = 字間 * 倍率;
114
- return { size, spacing, text: 帯で切る(文字列, size, 箱幅, (u, f) => 字幅(u, f, spacing)) };
130
+ return { size, spacing, text: 帯で切る(文字列, size, 箱幅, (u, f) => 測る(u, f, spacing)) };
115
131
  }
116
132
 
117
133
  /**
118
134
  * 名前の塊を描く。 縦の位置と大きさは `labelPlan` が決め (箱の下端から積み上げる、 #433)、
119
135
  * 横は `幅に収める` が箱の幅に合わせて縮める。
120
136
  */
121
- function bottomLabel(node: LaidNode, cx: number, plan: ReturnType<typeof labelPlan>) {
122
- const 肩 = 幅に収める(node.eyebrow ?? "", plan.肩書?.size ?? 0, LABEL_EYEBROW_SPACING, node.w);
123
- const 名 = 幅に収める(node.title ?? "", plan.名前.size, 0, node.w, true);
124
- const 説 = 幅に収める(node.subtitle ?? "", plan.説明?.size ?? 0, 0, node.w);
137
+ function bottomLabel(
138
+ node: LaidNode,
139
+ cx: number,
140
+ plan: ReturnType<typeof labelPlan>,
141
+ textMetrics: CdlTextMetrics = "proportional",
142
+ ) {
143
+ const 肩 = 幅に収める(
144
+ node.eyebrow ?? "",
145
+ plan.肩書?.size ?? 0,
146
+ LABEL_EYEBROW_SPACING,
147
+ node.w,
148
+ false,
149
+ textMetrics,
150
+ );
151
+ const 名 = 幅に収める(node.title ?? "", plan.名前.size, 0, node.w, true, textMetrics);
152
+ const 説 = 幅に収める(
153
+ node.subtitle ?? "",
154
+ plan.説明?.size ?? 0,
155
+ 0,
156
+ node.w,
157
+ false,
158
+ textMetrics,
159
+ );
125
160
  return (
126
161
  <>
127
162
  {node.eyebrow && plan.肩書 && (
@@ -174,7 +209,7 @@ const BANK_BLD_W_MIN = 120;
174
209
  const BANK_BLD_W_MAX = 280;
175
210
 
176
211
  /** shape-bank = 銀行 (Greek 神殿 pediment + $ + 4 columns + base、 中央配置) */
177
- export function ShapeBankNode({ node, active }: ShapeProps): JSX.Element {
212
+ export function ShapeBankNode({ node, active, textMetrics = "proportional" }: ShapeProps): JSX.Element {
178
213
  const frame = commonFrame(active);
179
214
  const r = shapeRegion(node);
180
215
  const 線 = frame.strokeWidth / 2;
@@ -231,7 +266,7 @@ export function ShapeBankNode({ node, active }: ShapeProps): JSX.Element {
231
266
  return (
232
267
  <g>
233
268
  {fitArt(r, 広がり, 絵)}
234
- {bottomLabel(node, r.cx, r.label)}
269
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
235
270
  </g>
236
271
  );
237
272
  }
@@ -252,7 +287,12 @@ const TRUST_BLD_W_MIN = 140;
252
287
  const TRUST_BLD_W_MAX = 280;
253
288
 
254
289
  /** shape-trust-bank = 信託銀行 (王冠 + 神殿 3 columns、 中央柱に余計装飾なし) */
255
- export function ShapeTrustBankNode({ node, active, lang }: ShapeProps): JSX.Element {
290
+ export function ShapeTrustBankNode({
291
+ node,
292
+ active,
293
+ lang,
294
+ textMetrics = "proportional",
295
+ }: ShapeProps): JSX.Element {
256
296
  const 字引 = 字引きを作る(lang);
257
297
  const frame = commonFrame(active);
258
298
  const r = shapeRegion(node);
@@ -316,13 +356,18 @@ export function ShapeTrustBankNode({ node, active, lang }: ShapeProps): JSX.Elem
316
356
  return (
317
357
  <g>
318
358
  {fitArt(r, 広がり, 絵)}
319
- {bottomLabel(node, r.cx, r.label)}
359
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
320
360
  </g>
321
361
  );
322
362
  }
323
363
 
324
364
  /** shape-payment-provider = P2-A base (中央 provider box + PAY header + processing spinner + PROCESSING label) */
325
- export function ShapePaymentProviderNode({ node, active, lang }: ShapeProps): JSX.Element {
365
+ export function ShapePaymentProviderNode({
366
+ node,
367
+ active,
368
+ lang,
369
+ textMetrics = "proportional",
370
+ }: ShapeProps): JSX.Element {
326
371
  const 字引 = 字引きを作る(lang);
327
372
  const frame = commonFrame(active);
328
373
  const r = shapeRegion(node);
@@ -355,13 +400,17 @@ export function ShapePaymentProviderNode({ node, active, lang }: ShapeProps): JS
355
400
  </g>
356
401
  {/* PROCESSING label */}
357
402
  <text x={cx} y={y + boxH - 16} fontSize={11} fontWeight={700} letterSpacing={2} textAnchor="middle" fill="#a4b5f2">{字引("paymentProvider.status")}</text>
358
- {bottomLabel(node, r.cx, r.label)}
403
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
359
404
  </g>
360
405
  );
361
406
  }
362
407
 
363
408
  /** shape-credit-card = クレジットカード (シンプル、 chip + NFC + 4 digit block + brand) */
364
- export function ShapeCreditCardNode({ node, active }: ShapeProps): JSX.Element {
409
+ export function ShapeCreditCardNode({
410
+ node,
411
+ active,
412
+ textMetrics = "proportional",
413
+ }: ShapeProps): JSX.Element {
365
414
  const frame = commonFrame(active);
366
415
  const r = shapeRegion(node);
367
416
  const cardW = Math.min(r.availW - 30, 260);
@@ -394,7 +443,7 @@ export function ShapeCreditCardNode({ node, active }: ShapeProps): JSX.Element {
394
443
  {/* brand mark */}
395
444
  <circle cx={x + cardW - 48} cy={y + cardH - 24} r={15} fill="#c9302c" opacity={0.9} stroke="none" />
396
445
  <circle cx={x + cardW - 30} cy={y + cardH - 24} r={15} fill="#e5c07b" opacity={0.9} stroke="none" />
397
- {bottomLabel(node, r.cx, r.label)}
446
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
398
447
  </g>
399
448
  );
400
449
  }
@@ -447,7 +496,12 @@ const BROKERAGE_W_MAX = 140;
447
496
  const BROKERAGE_H_MIN = BROKERAGE_TICK_H + BROKERAGE_LOGO_H + BROKERAGE_BASE_H;
448
497
 
449
498
  /** shape-brokerage = B2-A 大和証券風 (縦長本社 + 上部電光 + $ SECURITIES ロゴ帯 + window grid + entrance 回転扉) */
450
- export function ShapeBrokerageNode({ node, active, lang }: ShapeProps): JSX.Element {
499
+ export function ShapeBrokerageNode({
500
+ node,
501
+ active,
502
+ lang,
503
+ textMetrics = "proportional",
504
+ }: ShapeProps): JSX.Element {
451
505
  const 字引 = 字引きを作る(lang);
452
506
  const frame = commonFrame(active);
453
507
  const r = shapeRegion(node);
@@ -494,7 +548,7 @@ export function ShapeBrokerageNode({ node, active, lang }: ShapeProps): JSX.Elem
494
548
  },
495
549
  ];
496
550
  const 絵 = (倍率: number): JSX.Element => {
497
- const 倍 = 打ち消しの倍率(倍率, 打ち消す字ら);
551
+ const 倍 = 打ち消しの倍率(倍率, 打ち消す字ら, textMetrics);
498
552
  return (
499
553
  <g>
500
554
  {/* drop shadow */}
@@ -538,7 +592,7 @@ export function ShapeBrokerageNode({ node, active, lang }: ShapeProps): JSX.Elem
538
592
  return (
539
593
  <g>
540
594
  {fitArt(r, 広がり, 絵)}
541
- {bottomLabel(node, r.cx, r.label)}
595
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
542
596
  </g>
543
597
  );
544
598
  }
@@ -549,7 +603,12 @@ const EXCHANGE_SUB_SIZE = 11;
549
603
  /** 中央の六角形の外接半径。 看板の字 (指定 11、 字間 1 で幅 83.7) が入る大きさから決めた (#888)。 */
550
604
  const EXCHANGE_HEX_R = 52;
551
605
 
552
- export function ShapeExchangeNode({ node, active, lang }: ShapeProps): JSX.Element {
606
+ export function ShapeExchangeNode({
607
+ node,
608
+ active,
609
+ lang,
610
+ textMetrics = "proportional",
611
+ }: ShapeProps): JSX.Element {
553
612
  const 字引 = 字引きを作る(lang);
554
613
  const frame = commonFrame(active);
555
614
  const r = shapeRegion(node);
@@ -620,7 +679,7 @@ export function ShapeExchangeNode({ node, active, lang }: ShapeProps): JSX.Eleme
620
679
  },
621
680
  ];
622
681
  const 絵 = (倍率: number): JSX.Element => {
623
- const 倍 = 打ち消しの倍率(倍率, 打ち消す字ら);
682
+ const 倍 = 打ち消しの倍率(倍率, 打ち消す字ら, textMetrics);
624
683
  return (
625
684
  <g>
626
685
  {/* 8 dashed radial lines */}
@@ -654,7 +713,7 @@ export function ShapeExchangeNode({ node, active, lang }: ShapeProps): JSX.Eleme
654
713
  return (
655
714
  <g>
656
715
  {fitArt(r, 広がり, 絵)}
657
- {bottomLabel(node, r.cx, r.label)}
716
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
658
717
  </g>
659
718
  );
660
719
  }
@@ -700,7 +759,12 @@ const ATM_W_MAX = ATM_PANEL_W;
700
759
  const ATM_H_MIN = ATM_SIGN_H + ATM_SIGN_GAP + 30 + ATM_FLOOR_RESERVE;
701
760
 
702
761
  /** shape-atm = A3-C1 (支店内 ATM 単体、 上部 ATM CORNER 天井看板 + 中央 ATM 大 + 直下床帯) */
703
- export function ShapeAtmNode({ node, active, lang }: ShapeProps): JSX.Element {
762
+ export function ShapeAtmNode({
763
+ node,
764
+ active,
765
+ lang,
766
+ textMetrics = "proportional",
767
+ }: ShapeProps): JSX.Element {
704
768
  const 字引 = 字引きを作る(lang);
705
769
  const frame = commonFrame(active);
706
770
  const r = shapeRegion(node);
@@ -800,7 +864,7 @@ export function ShapeAtmNode({ node, active, lang }: ShapeProps): JSX.Element {
800
864
  return (
801
865
  <g>
802
866
  {fitArt(r, 広がり, 絵)}
803
- {bottomLabel(node, r.cx, r.label)}
867
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
804
868
  </g>
805
869
  );
806
870
  }
@@ -1,6 +1,6 @@
1
1
  import type { JSX } from "react";
2
2
  import { shapeRegion, labelPlan, fitArt, textWidth, 切って収まる下限, 帯で切る, type ArtExtent } from "./shape-region";
3
- import type { LaidNode } from "../types";
3
+ import type { CdlTextMetrics, LaidNode } from "../types";
4
4
 
5
5
  /**
6
6
  * Shape-driven hardware 6 kinds (CAR-1111 Phase 2-C)。
@@ -45,6 +45,10 @@ type ShapeProps = {
45
45
  active: boolean;
46
46
  };
47
47
 
48
+ type TextMeasuredShapeProps = ShapeProps & {
49
+ textMetrics?: CdlTextMetrics;
50
+ };
51
+
48
52
  const commonFrame = (active: boolean) => ({
49
53
  fill: "var(--cdl-node-fill, #ffffff)",
50
54
  stroke: active ? "var(--cdl-tone-accent, #2d6a8f)" : "var(--cdl-divider, #d4d4d8)",
@@ -104,18 +108,26 @@ const LABEL_EYEBROW_SPACING = 2;
104
108
  * 字幅の見積りは共有の `textWidth` (実測を階級で丸め上げた表) が持つ。 字間も同じ割合で
105
109
  * 詰める = 大きさだけ縮めても字間が残ると幅が比例して縮まない。
106
110
  */
107
- function 幅に収める(文字列: string, 大きさ: number, 字間: number, 箱幅: number, 題か = false) {
108
- const 幅 = textWidth(文字列, 大きさ, 字間);
111
+ function 幅に収める(
112
+ 文字列: string,
113
+ 大きさ: number,
114
+ 字間: number,
115
+ 箱幅: number,
116
+ 題か = false,
117
+ textMetrics: CdlTextMetrics = "proportional",
118
+ ) {
119
+ const 測る = (t: string, s: number, ls: number): number => textWidth(t, s, ls, textMetrics);
120
+ const 幅 = 測る(文字列, 大きさ, 字間);
109
121
  // 縮めは読める下限で止める (#953)。 下限が無いと長い名前が点にしか見えない大きさになる。
110
122
  // 止めた行は箱に入らないので、入るところまで切って `…` を付ける (#433 の決まりを保つ)。
111
123
  //
112
124
  // **下限を当てるのは題の行だけ** (#957)。 補いの行に当てると、収まっている行を
113
125
  // 大きくしてから切ることになる = 書いた字を落として得るのが 1px に満たない
114
- const 下限 = 題か ? 切って収まる下限(文字列, 大きさ, 字間, 箱幅, textWidth) : 0;
126
+ const 下限 = 題か ? 切って収まる下限(文字列, 大きさ, 字間, 箱幅, 測る) : 0;
115
127
  const 倍率 = 幅 > 0 && 箱幅 > 0 ? Math.min(1, Math.max(箱幅 / 幅, 下限)) : 1;
116
128
  const size = 大きさ * 倍率;
117
129
  const spacing = 字間 * 倍率;
118
- return { size, spacing, text: 帯で切る(文字列, size, 箱幅, (u, f) => textWidth(u, f, spacing)) };
130
+ return { size, spacing, text: 帯で切る(文字列, size, 箱幅, (u, f) => 測る(u, f, spacing)) };
119
131
  }
120
132
 
121
133
  /**
@@ -124,10 +136,29 @@ function 幅に収める(文字列: string, 大きさ: number, 字間: number,
124
136
  *
125
137
  * `externalLabel` と違い、 **箱の中に必ず収まる**。 字の大きさも箱の高さに追随する。
126
138
  */
127
- function bottomLabel(node: LaidNode, cx: number, plan: ReturnType<typeof labelPlan>) {
128
- const 肩 = 幅に収める(node.eyebrow ?? "", plan.肩書?.size ?? 0, LABEL_EYEBROW_SPACING, node.w);
129
- const 名 = 幅に収める(node.title ?? "", plan.名前.size, 0, node.w, true);
130
- const 説 = 幅に収める(node.subtitle ?? "", plan.説明?.size ?? 0, 0, node.w);
139
+ function bottomLabel(
140
+ node: LaidNode,
141
+ cx: number,
142
+ plan: ReturnType<typeof labelPlan>,
143
+ textMetrics: CdlTextMetrics = "proportional",
144
+ ) {
145
+ const 肩 = 幅に収める(
146
+ node.eyebrow ?? "",
147
+ plan.肩書?.size ?? 0,
148
+ LABEL_EYEBROW_SPACING,
149
+ node.w,
150
+ false,
151
+ textMetrics,
152
+ );
153
+ const 名 = 幅に収める(node.title ?? "", plan.名前.size, 0, node.w, true, textMetrics);
154
+ const 説 = 幅に収める(
155
+ node.subtitle ?? "",
156
+ plan.説明?.size ?? 0,
157
+ 0,
158
+ node.w,
159
+ false,
160
+ textMetrics,
161
+ );
131
162
  return (
132
163
  <>
133
164
  {node.eyebrow && plan.肩書 && (
@@ -171,7 +202,7 @@ const RACK_H =
171
202
  const RACK_W_MIN = 120;
172
203
 
173
204
  /** shape-server-rack = 19 inch rack (top vent + 4 U rack mount server + power indicator) */
174
- export function ShapeServerRackNode({ node, active }: ShapeProps): JSX.Element {
205
+ export function ShapeServerRackNode({ node, active, textMetrics = "proportional" }: TextMeasuredShapeProps): JSX.Element {
175
206
  const r = shapeRegion(node);
176
207
  const frame = commonFrame(active);
177
208
  const 線 = frame.strokeWidth / 2;
@@ -260,7 +291,7 @@ export function ShapeServerRackNode({ node, active }: ShapeProps): JSX.Element {
260
291
  })}
261
292
  </g>
262
293
  ))}
263
- {bottomLabel(node, r.cx, r.label)}
294
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
264
295
  </g>
265
296
  );
266
297
  }
@@ -281,7 +312,7 @@ const WIFI_ARC_R = [10, 16, 22];
281
312
  const WIFI_BASE_Y = 5;
282
313
 
283
314
  /** shape-network-node = 六角 hub (network signal cone icon + 6-arm radial + node dots) */
284
- export function ShapeNetworkNodeNode({ node, active }: ShapeProps): JSX.Element {
315
+ export function ShapeNetworkNodeNode({ node, active, textMetrics = "proportional" }: TextMeasuredShapeProps): JSX.Element {
285
316
  const r = shapeRegion(node);
286
317
  const cx = r.cx;
287
318
  const cy = r.cy;
@@ -358,7 +389,7 @@ export function ShapeNetworkNodeNode({ node, active }: ShapeProps): JSX.Element
358
389
  <circle cx={cx} cy={cy + WIFI_BASE_Y + 3} r={2.5} fill="var(--cdl-tone-accent, #2d6a8f)" stroke="none" />
359
390
  </g>
360
391
  ))}
361
- {bottomLabel(node, r.cx, r.label)}
392
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
362
393
  </g>
363
394
  );
364
395
  }
@@ -378,7 +409,7 @@ const PHONE_H = 240;
378
409
  const PHONE_W = 110;
379
410
 
380
411
  /** shape-mobile-device = スマホ (rounded rect + speaker slit + home button) */
381
- export function ShapeMobileDeviceNode({ node, active }: ShapeProps): JSX.Element {
412
+ export function ShapeMobileDeviceNode({ node, active, textMetrics = "proportional" }: TextMeasuredShapeProps): JSX.Element {
382
413
  const r = shapeRegion(node);
383
414
  const frame = commonFrame(active);
384
415
  const 線 = frame.strokeWidth / 2;
@@ -436,7 +467,7 @@ export function ShapeMobileDeviceNode({ node, active }: ShapeProps): JSX.Element
436
467
  />
437
468
  </g>
438
469
  ))}
439
- {bottomLabel(node, r.cx, r.label)}
470
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
440
471
  </g>
441
472
  );
442
473
  }
@@ -692,7 +723,7 @@ const SAT_MAST_LEN = 22;
692
723
  const SAT_MAST_TIP_R = 4;
693
724
 
694
725
  /** shape-satellite = 人工衛星 (body + 左右 solar panel + dish antenna + orbit arc) */
695
- export function ShapeSatelliteNode({ node, active }: ShapeProps): JSX.Element {
726
+ export function ShapeSatelliteNode({ node, active, textMetrics = "proportional" }: TextMeasuredShapeProps): JSX.Element {
696
727
  const r = shapeRegion(node);
697
728
  const cx = r.cx;
698
729
  const cy = r.cy;
@@ -864,7 +895,7 @@ export function ShapeSatelliteNode({ node, active }: ShapeProps): JSX.Element {
864
895
  </circle>
865
896
  </g>
866
897
  ))}
867
- {bottomLabel(node, r.cx, r.label)}
898
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
868
899
  </g>
869
900
  );
870
901
  }