@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
@@ -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
  }
@@ -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
  fitArt,
5
5
  labelPlan,
@@ -27,6 +27,7 @@ import { 字引きを作る, type CdlLang, type CdlTextKey } from "../lang";
27
27
  type ShapeProps = {
28
28
  node: LaidNode;
29
29
  active: boolean;
30
+ textMetrics?: CdlTextMetrics;
30
31
  /** 描画側の固定の字の言語 (#847)。 渡さない呼び方は英語で描く */
31
32
  lang?: CdlLang;
32
33
  };
@@ -136,7 +137,12 @@ function blockchainNodeExtent(strokeWidth: number): ArtExtent {
136
137
  /**
137
138
  * 名前の塊を描く。 位置と大きさは `labelPlan` が決める (箱の下端から積み上げる、 #433)。
138
139
  */
139
- function bottomLabel(node: LaidNode, cx: number, plan: ReturnType<typeof labelPlan>) {
140
+ function bottomLabel(
141
+ node: LaidNode,
142
+ cx: number,
143
+ plan: ReturnType<typeof labelPlan>,
144
+ textMetrics: CdlTextMetrics = "proportional",
145
+ ) {
140
146
  // 縮めは読める下限で止める (#953)。 止めた行は箱に入らないので、入るところまで切る。
141
147
  // 下限を当てるのは題の行だけ (#957) = 補いの行に当てると、収まっている行を大きくしてから
142
148
  // 切ることになる
@@ -144,7 +150,8 @@ function bottomLabel(node: LaidNode, cx: number, plan: ReturnType<typeof labelPl
144
150
  // **縮める時と切る時で同じ測り方を使う** (#959)。 絵の中の字は等幅の字送りと字形の
145
151
  // 大きい方を採る (`絵の中の字の幅`) ので、`幅の倍率` の `textWidth` で倍率を出すと
146
152
  // 「収まる」 と言った行を切る側が「はみ出す」 と判定する (実測 = 幅 140 の箱で 9 種)
147
- const 測る = (u: string, f: number, ls: number) => 字の幅(u, f) + ls * [...u].length;
153
+ const 測る = (u: string, f: number, ls: number) =>
154
+ 字の幅(u, f, textMetrics) + ls * [...u].length;
148
155
  const 素の幅 = 測る(t, 大きさ, 字間);
149
156
  const 収まる = 素の幅 > node.w && node.w > 0 ? node.w / 素の幅 : 1;
150
157
  const 下限 = 題か ? 切って収まる下限(t, 大きさ, 字間, node.w, 測る) : 0;
@@ -205,7 +212,12 @@ function EthereumDiamond({ cx, cy, h, colors }: { cx: number; cy: number; h: num
205
212
  }
206
213
 
207
214
  /** shape-smart-contract = 契約書 (前バージョン: document + 契約書テキスト + function 表示 + 底に回転 gear) */
208
- export function ShapeSmartContractNode({ node, active, lang }: ShapeProps): JSX.Element {
215
+ export function ShapeSmartContractNode({
216
+ node,
217
+ active,
218
+ lang,
219
+ textMetrics = "proportional",
220
+ }: ShapeProps): JSX.Element {
209
221
  const 字引 = 字引きを作る(lang);
210
222
  const frame = commonFrame(active);
211
223
  const r = shapeRegion(node);
@@ -254,7 +266,7 @@ export function ShapeSmartContractNode({ node, active, lang }: ShapeProps): JSX.
254
266
  },
255
267
  ];
256
268
  const art = (倍率: number): JSX.Element => {
257
- const 倍 = 打ち消しの倍率(倍率, 打ち消す字ら);
269
+ const 倍 = 打ち消しの倍率(倍率, 打ち消す字ら, textMetrics);
258
270
  return (
259
271
  <g>
260
272
  <path data-cdl-role="node-body" d={path} {...frame} strokeLinejoin="round" />
@@ -284,7 +296,7 @@ export function ShapeSmartContractNode({ node, active, lang }: ShapeProps): JSX.
284
296
  return (
285
297
  <g>
286
298
  {fitArt(r, smartContractExtent(frame.strokeWidth), art)}
287
- {bottomLabel(node, r.cx, r.label)}
299
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
288
300
  </g>
289
301
  );
290
302
  }
@@ -300,7 +312,11 @@ export function ShapeSmartContractNode({ node, active, lang }: ShapeProps): JSX.
300
312
  * どちらの書体で描かれても実物以上に出る側へ倒す = 下回ると「収まっていない物を収まっていると
301
313
  * 判定」 する向きに外れる (`row-align.ts` の `rowColumnWidth` と同じ考え方)。
302
314
  */
303
- const 字の幅 = (text: string, size: number): number => 絵の中の字の幅(text, size);
315
+ const 字の幅 = (
316
+ text: string,
317
+ size: number,
318
+ textMetrics: CdlTextMetrics = "proportional",
319
+ ): number => 絵の中の字の幅(text, size, 0, textMetrics);
304
320
 
305
321
  /**
306
322
  * 絵の中の 5 行の既定値。 書き手が渡さなかった欄はこれで描く (#469)。
@@ -353,10 +369,16 @@ const BLOCK_DEFAULT_FIELDS: readonly {
353
369
  * 位置が node の大きさに依存する。 `rowBaselineY(kind, index)` は node の大きさを受け取らない
354
370
  * ので、 この種別の位置を表せない。 開くなら述語の形から変える話になる (#469 の対象外 scope)。
355
371
  */
356
- export function ShapeBlockchainBlockNode({ node, active, lang }: ShapeProps): JSX.Element {
372
+ export function ShapeBlockchainBlockNode({
373
+ node,
374
+ active,
375
+ lang,
376
+ textMetrics = "proportional",
377
+ }: ShapeProps): JSX.Element {
357
378
  const 字引 = 字引きを作る(lang);
358
379
  const frame = commonFrame(active);
359
380
  const r = shapeRegion(node);
381
+ const 測る字 = (text: string, size: number): number => 字の幅(text, size, textMetrics);
360
382
  const bw = BLOCK_W;
361
383
  const bh = BLOCK_H;
362
384
  const cx = r.cx;
@@ -392,9 +414,10 @@ export function ShapeBlockchainBlockNode({ node, active, lang }: ShapeProps): JS
392
414
  // 上限は帯の残りの半分。 長い肩書は **切らずに字を縮める** = 分類を表す語なので、
393
415
  // 末尾を落とすと何の分類か読めなくなる (`台帳…`)。 縮めた後の幅は上限ちょうどになる
394
416
  const 肩書の上限 = (帯の残り - 肩書の隙間) / 2;
395
- const 肩書の倍率 = 肩書 === "" ? 1 : 幅の倍率(肩書, 肩書の字, 0, 肩書の上限);
417
+ const 肩書の倍率 =
418
+ 肩書 === "" ? 1 : 幅の倍率(肩書, 肩書の字, 0, 肩書の上限, textMetrics);
396
419
  const 肩書の指定 = 肩書の字 * 肩書の倍率;
397
- const 肩書の幅 = 肩書 === "" ? 0 : 字の幅(肩書, 肩書の指定);
420
+ const 肩書の幅 = 肩書 === "" ? 0 : 測る字(肩書, 肩書の指定);
398
421
  const 見出しの幅 = 帯の残り - (肩書 === "" ? 0 : 肩書の幅 + 肩書の隙間);
399
422
  /*
400
423
  * 絵を **倍率を受け取ってから** 組む (#871)。 縮んだ分を字の指定で打ち消す。
@@ -422,12 +445,12 @@ export function ShapeBlockchainBlockNode({ node, active, lang }: ShapeProps): JS
422
445
  {
423
446
  text: f.value,
424
447
  size: 値の字,
425
- 帯: { 幅: bw - 28 - 字の幅(f.label, 名の字) - 8, 高さ: 行の間隔 },
448
+ 帯: { 幅: bw - 28 - 測る字(f.label, 名の字) - 8, 高さ: 行の間隔 },
426
449
  },
427
450
  ]),
428
451
  ];
429
452
  const art = (倍率: number): JSX.Element => {
430
- const 倍 = 打ち消しの倍率(倍率, 打ち消す字ら);
453
+ const 倍 = 打ち消しの倍率(倍率, 打ち消す字ら, textMetrics);
431
454
  const 題の大きさ = 見出しの字 * 倍;
432
455
  const 肩書の大きさ = 肩書の指定 * 倍;
433
456
  const 名の大きさ = 名の字 * 倍;
@@ -440,7 +463,7 @@ export function ShapeBlockchainBlockNode({ node, active, lang }: ShapeProps): JS
440
463
  {/* header bar */}
441
464
  <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" />
442
465
  <text data-cdl-role="blockchain-block-title" data-cdl-counter-scaled="1" x={x + 14} y={y + 22} fontSize={題の大きさ} fontWeight={700} fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace" fill="#ffffff">
443
- {幅で切る(見出し, 題の大きさ, 見出しの幅, 字の幅)}
466
+ {幅で切る(見出し, 題の大きさ, 見出しの幅, 測る字)}
444
467
  </text>
445
468
  {肩書 !== "" && (
446
469
  <text
@@ -466,7 +489,12 @@ export function ShapeBlockchainBlockNode({ node, active, lang }: ShapeProps): JS
466
489
  {f.label}
467
490
  </text>
468
491
  <text data-cdl-role={`blockchain-block-${f.key}`} data-cdl-counter-scaled="1" x={x + bw - 14} y={y + 54 + i * 行の間隔} fontSize={値の大きさ} fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace" fontWeight={600} textAnchor="end" fill="var(--cdl-text, #1a1f2a)">
469
- {幅で切る(f.value, 値の大きさ, bw - 28 - 字の幅(f.label, 名の大きさ) - 8, 字の幅)}
492
+ {幅で切る(
493
+ f.value,
494
+ 値の大きさ,
495
+ bw - 28 - 測る字(f.label, 名の大きさ) - 8,
496
+ 測る字,
497
+ )}
470
498
  </text>
471
499
  <line x1={x + 14} y1={y + 60 + i * 行の間隔} x2={x + bw - 14} y2={y + 60 + i * 行の間隔} stroke="var(--cdl-divider, #d4d4d8)" strokeWidth={0.5} opacity={0.5} />
472
500
  </g>
@@ -484,7 +512,11 @@ export function ShapeBlockchainBlockNode({ node, active, lang }: ShapeProps): JS
484
512
  }
485
513
 
486
514
  /** shape-blockchain = 汎用 5 block chain (シンプル hash pointer) */
487
- export function ShapeBlockchainNode({ node, active }: ShapeProps): JSX.Element {
515
+ export function ShapeBlockchainNode({
516
+ node,
517
+ active,
518
+ textMetrics = "proportional",
519
+ }: ShapeProps): JSX.Element {
488
520
  const frame = commonFrame(active);
489
521
  const r = shapeRegion(node);
490
522
  const cx = r.cx;
@@ -506,7 +538,7 @@ export function ShapeBlockchainNode({ node, active }: ShapeProps): JSX.Element {
506
538
  { text: "0x…", size: CHAIN_HASH_SIZE, 帯: { 幅: CHAIN_TEXT_W, 高さ: CHAIN_ROW_GAP } },
507
539
  ];
508
540
  const art = (倍率: number): JSX.Element => {
509
- const 倍 = 打ち消しの倍率(倍率, 打ち消す字ら);
541
+ const 倍 = 打ち消しの倍率(倍率, 打ち消す字ら, textMetrics);
510
542
  return (
511
543
  <g>
512
544
  {Array.from({ length: blockCount }, (_, i) => i).map((i) => {
@@ -559,13 +591,17 @@ export function ShapeBlockchainNode({ node, active }: ShapeProps): JSX.Element {
559
591
  return (
560
592
  <g>
561
593
  {fitArt(r, chainExtent(), art)}
562
- {bottomLabel(node, r.cx, r.label)}
594
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
563
595
  </g>
564
596
  );
565
597
  }
566
598
 
567
599
  /** shape-bitcoin-chain = Bitcoin (橙 accent + ₿ symbol) */
568
- export function ShapeBitcoinChainNode({ node, active }: ShapeProps): JSX.Element {
600
+ export function ShapeBitcoinChainNode({
601
+ node,
602
+ active,
603
+ textMetrics = "proportional",
604
+ }: ShapeProps): JSX.Element {
569
605
  const frame = commonFrame(active);
570
606
  const r = shapeRegion(node);
571
607
  const cx = r.cx;
@@ -586,7 +622,7 @@ export function ShapeBitcoinChainNode({ node, active }: ShapeProps): JSX.Element
586
622
  { text: "#820", size: BTC_NO_SIZE, 帯: { 幅: CHAIN_TEXT_W, 高さ: 20 } },
587
623
  ];
588
624
  const art = (倍率: number): JSX.Element => {
589
- const 倍 = 打ち消しの倍率(倍率, 打ち消す字ら);
625
+ const 倍 = 打ち消しの倍率(倍率, 打ち消す字ら, textMetrics);
590
626
  return (
591
627
  <g>
592
628
  {Array.from({ length: blockCount }, (_, i) => i).map((i) => {
@@ -622,13 +658,17 @@ export function ShapeBitcoinChainNode({ node, active }: ShapeProps): JSX.Element
622
658
  return (
623
659
  <g>
624
660
  {fitArt(r, chainExtent(), art)}
625
- {bottomLabel(node, r.cx, r.label)}
661
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
626
662
  </g>
627
663
  );
628
664
  }
629
665
 
630
666
  /** shape-ethereum-chain = Ethereum (紫 accent + 本物 Ethereum Diamond ロゴ) */
631
- export function ShapeEthereumChainNode({ node, active }: ShapeProps): JSX.Element {
667
+ export function ShapeEthereumChainNode({
668
+ node,
669
+ active,
670
+ textMetrics = "proportional",
671
+ }: ShapeProps): JSX.Element {
632
672
  const frame = commonFrame(active);
633
673
  const r = shapeRegion(node);
634
674
  const cx = r.cx;
@@ -653,7 +693,7 @@ export function ShapeEthereumChainNode({ node, active }: ShapeProps): JSX.Elemen
653
693
  { text: "#214", size: ETH_NO_SIZE, 帯: { 幅: CHAIN_TEXT_W, 高さ: 20 } },
654
694
  ];
655
695
  const art = (倍率: number): JSX.Element => {
656
- const 倍 = 打ち消しの倍率(倍率, 打ち消す字ら);
696
+ const 倍 = 打ち消しの倍率(倍率, 打ち消す字ら, textMetrics);
657
697
  return (
658
698
  <g>
659
699
  {Array.from({ length: blockCount }, (_, i) => i).map((i) => {
@@ -691,13 +731,17 @@ export function ShapeEthereumChainNode({ node, active }: ShapeProps): JSX.Elemen
691
731
  return (
692
732
  <g>
693
733
  {fitArt(r, chainExtent(), art)}
694
- {bottomLabel(node, r.cx, r.label)}
734
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
695
735
  </g>
696
736
  );
697
737
  }
698
738
 
699
739
  /** shape-blockchain-node = P2P full node (中央 hex + 6 peer 外側配置) */
700
- export function ShapeBlockchainNodeNode({ node, active }: ShapeProps): JSX.Element {
740
+ export function ShapeBlockchainNodeNode({
741
+ node,
742
+ active,
743
+ textMetrics = "proportional",
744
+ }: ShapeProps): JSX.Element {
701
745
  const frame = commonFrame(active);
702
746
  const r = shapeRegion(node);
703
747
  const cx = r.cx;
@@ -752,13 +796,18 @@ export function ShapeBlockchainNodeNode({ node, active }: ShapeProps): JSX.Eleme
752
796
  return (
753
797
  <g>
754
798
  {fitArt(r, blockchainNodeExtent(frame.strokeWidth), art)}
755
- {bottomLabel(node, r.cx, r.label)}
799
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
756
800
  </g>
757
801
  );
758
802
  }
759
803
 
760
804
  /** shape-rpc-node = JSON-RPC provider */
761
- export function ShapeRpcNodeNode({ node, active, lang }: ShapeProps): JSX.Element {
805
+ export function ShapeRpcNodeNode({
806
+ node,
807
+ active,
808
+ lang,
809
+ textMetrics = "proportional",
810
+ }: ShapeProps): JSX.Element {
762
811
  const 字引 = 字引きを作る(lang);
763
812
  const frame = commonFrame(active);
764
813
  const r = shapeRegion(node);
@@ -788,13 +837,13 @@ export function ShapeRpcNodeNode({ node, active, lang }: ShapeProps): JSX.Elemen
788
837
  <rect x={x + 16} y={y + bh - 24} width={(bw - 32) * 0.92} height={8} rx={4} fill="#27c93f" stroke="none">
789
838
  <animate attributeName="width" values={`${(bw - 32) * 0.86};${(bw - 32) * 0.96};${(bw - 32) * 0.86}`} dur="3s" repeatCount="indefinite" />
790
839
  </rect>
791
- {bottomLabel(node, r.cx, r.label)}
840
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
792
841
  </g>
793
842
  );
794
843
  }
795
844
 
796
845
  /** shape-wallet = FC-S5 忠実移植 (二つ折り札入れ横長 + fold line 中央 + bar clasp + engraved Ξ text + コイン peek 3) */
797
- export function ShapeWalletNode({ node, active }: ShapeProps): JSX.Element {
846
+ export function ShapeWalletNode({ node, active, textMetrics = "proportional" }: ShapeProps): JSX.Element {
798
847
  const frame = commonFrame(active);
799
848
  const r = shapeRegion(node);
800
849
  const cx = r.cx;
@@ -885,13 +934,13 @@ export function ShapeWalletNode({ node, active }: ShapeProps): JSX.Element {
885
934
  {/* body 上端 highlight (FC-S5 元 = M 42 86 L 258 86) */}
886
935
  <path d={`M ${x + 2} ${y + 6} L ${x + w - 2} ${y + 6}`} fill="none" stroke="#ffffff" strokeWidth={1} opacity={0.4} />
887
936
  </g>
888
- {bottomLabel(node, r.cx, r.label)}
937
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
889
938
  </g>
890
939
  );
891
940
  }
892
941
 
893
942
  /** shape-nft = NFT card (白 body + accent art frame + 巨大 NFT 文字、 統一デザイン) */
894
- export function ShapeNftNode({ node, active }: ShapeProps): JSX.Element {
943
+ export function ShapeNftNode({ node, active, textMetrics = "proportional" }: ShapeProps): JSX.Element {
895
944
  const frame = commonFrame(active);
896
945
  const r = shapeRegion(node);
897
946
  const cx = r.cx;
@@ -914,7 +963,7 @@ export function ShapeNftNode({ node, active }: ShapeProps): JSX.Element {
914
963
  { text: "#1024", size: NFT_ID_SIZE, 帯: { 幅: cardW - 16 - 34, 高さ: cardH - artH } },
915
964
  ];
916
965
  const art = (倍率: number): JSX.Element => {
917
- const 倍 = 打ち消しの倍率(倍率, 打ち消す字ら);
966
+ const 倍 = 打ち消しの倍率(倍率, 打ち消す字ら, textMetrics);
918
967
  return (
919
968
  <g>
920
969
  {/* shadow */}
@@ -959,13 +1008,13 @@ export function ShapeNftNode({ node, active }: ShapeProps): JSX.Element {
959
1008
  return (
960
1009
  <g>
961
1010
  {fitArt(r, nftExtent(frame.strokeWidth), art)}
962
- {bottomLabel(node, r.cx, r.label)}
1011
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
963
1012
  </g>
964
1013
  );
965
1014
  }
966
1015
 
967
1016
  /** shape-token = coin (白 body + accent 色 rim + accent 色 Diamond ロゴ、 統一デザイン) */
968
- export function ShapeTokenNode({ node, active }: ShapeProps): JSX.Element {
1017
+ export function ShapeTokenNode({ node, active, textMetrics = "proportional" }: ShapeProps): JSX.Element {
969
1018
  const frame = commonFrame(active);
970
1019
  const r = shapeRegion(node);
971
1020
  const cx = r.cx;
@@ -1002,7 +1051,7 @@ export function ShapeTokenNode({ node, active }: ShapeProps): JSX.Element {
1002
1051
  stroke: "#1a4a68",
1003
1052
  }}
1004
1053
  />
1005
- {bottomLabel(node, r.cx, r.label)}
1054
+ {bottomLabel(node, r.cx, r.label, textMetrics)}
1006
1055
  </g>
1007
1056
  );
1008
1057
  }