@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
@@ -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
  }
@@ -20,7 +20,7 @@
20
20
  * では名前だけの時と同じ倍率 0.308 まで戻り、 3px の線を約 0.92px で描ける。
21
21
  */
22
22
  import type { JSX } from "react";
23
- import type { LaidNode, NodeKind } from "../types";
23
+ import type { CdlTextMetrics, LaidNode, NodeKind } from "../types";
24
24
  import { MONO_ADVANCE_RATIO, displayWidth } from "./row-align";
25
25
  import { textWidth } from "./text-width";
26
26
  import { 幅で切る } from "./box-lines";
@@ -56,10 +56,11 @@ export function 幅の倍率(
56
56
  size: number,
57
57
  letterSpacing: number,
58
58
  幅: number,
59
+ textMetrics: CdlTextMetrics = "proportional",
59
60
  ): number {
60
61
  if (!(幅 > 0) || !(size > 0)) return 1;
61
62
  // 階級表を二重に持つと、 片方を直した時にもう片方だけ古くなる。
62
- const 実幅 = textWidth(text, size, letterSpacing);
63
+ const 実幅 = textWidth(text, size, letterSpacing, textMetrics);
63
64
  return 実幅 > 幅 ? 幅 / 実幅 : 1;
64
65
  }
65
66
 
@@ -684,10 +685,15 @@ const 帯の高さに対する上限 = 0.8;
684
685
  * 「収まっていない物を収まっていると判定」 する向きに外れる (`row-align.ts` の
685
686
  * `rowColumnWidth` と同じ考え方)。
686
687
  */
687
- export function 絵の中の字の幅(text: string, size: number, letterSpacing = 0): number {
688
+ export function 絵の中の字の幅(
689
+ text: string,
690
+ size: number,
691
+ letterSpacing = 0,
692
+ textMetrics: CdlTextMetrics = "proportional",
693
+ ): number {
688
694
  return Math.max(
689
695
  displayWidth(text) * size * MONO_ADVANCE_RATIO,
690
- textWidth(text, size, letterSpacing),
696
+ textWidth(text, size, letterSpacing, textMetrics),
691
697
  );
692
698
  }
693
699
 
@@ -715,7 +721,11 @@ export function 絵の中の字の幅(text: string, size: number, letterSpacing
715
721
  * 帯の値が有限の正の値でない絵は、収まるかの判定ができない。
716
722
  * `fitArt` が判定できない入力で手を触れないのと同じ向きに倒す。
717
723
  */
718
- export function 打ち消しの倍率(倍率: number, 字ら: readonly 打ち消す字[]): number {
724
+ export function 打ち消しの倍率(
725
+ 倍率: number,
726
+ 字ら: readonly 打ち消す字[],
727
+ textMetrics: CdlTextMetrics = "proportional",
728
+ ): number {
719
729
  const 戻し = Number.isFinite(倍率) && 倍率 > 0 ? 1 / 倍率 : 1;
720
730
  let 上限 = Number.POSITIVE_INFINITY;
721
731
  for (const 字 of 字ら) {
@@ -724,7 +734,7 @@ export function 打ち消しの倍率(倍率: number, 字ら: readonly 打ち消
724
734
  if (Number.isFinite(高さ) && 高さ > 0) {
725
735
  上限 = Math.min(上限, (高さ * 帯の高さに対する上限) / 字.size);
726
736
  }
727
- const 実幅 = 絵の中の字の幅(字.text, 字.size, 字.letterSpacing ?? 0);
737
+ const 実幅 = 絵の中の字の幅(字.text, 字.size, 字.letterSpacing ?? 0, textMetrics);
728
738
  if (Number.isFinite(幅) && 幅 > 0 && 実幅 > 0) {
729
739
  上限 = Math.min(上限, 幅 / 実幅);
730
740
  }
@@ -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 { 字引きを作る, type CdlLang } from "../lang";
4
4
  import {
5
5
  textWidth,
@@ -50,6 +50,10 @@ type ShapeProps = {
50
50
  lang?: CdlLang;
51
51
  };
52
52
 
53
+ type TextMeasuredShapeProps = ShapeProps & {
54
+ textMetrics?: CdlTextMetrics;
55
+ };
56
+
53
57
  const commonFrame = (active: boolean) => ({
54
58
  fill: "var(--cdl-node-fill, #ffffff)",
55
59
  stroke: active ? "var(--cdl-tone-accent, #2d6a8f)" : "var(--cdl-divider, #d4d4d8)",
@@ -345,7 +349,12 @@ export function ShapeTerminalNode({ node, active, lang }: ShapeProps): JSX.Eleme
345
349
  }
346
350
 
347
351
  /** shape-code-block = code editor (tab + gutter + 4 syntax-colored stub lines) */
348
- export function ShapeCodeBlockNode({ node, active, lang }: ShapeProps): JSX.Element {
352
+ export function ShapeCodeBlockNode({
353
+ node,
354
+ active,
355
+ lang,
356
+ textMetrics = "proportional",
357
+ }: TextMeasuredShapeProps): JSX.Element {
349
358
  const 字引 = 字引きを作る(lang);
350
359
  const frame = commonFrame(active);
351
360
  const 線 = frame.strokeWidth / 2;
@@ -364,7 +373,10 @@ export function ShapeCodeBlockNode({ node, active, lang }: ShapeProps): JSX.Elem
364
373
  * 固定のままだと、控えより長い値を渡した図で字が札からはみ出す。 本体の幅を上限に置くのは、
365
374
  * 札が本体より広くなると `path` で描く角丸が本体の右端を越えるため。
366
375
  */
367
- const タブ幅 = Math.min(Math.max(100, textWidth(札, 札の字, 0) + 24), Math.max(0, w));
376
+ const タブ幅 = Math.min(
377
+ Math.max(100, textWidth(札, 札の字, 0, textMetrics) + 24),
378
+ Math.max(0, w),
379
+ );
368
380
  const タブ高 = 24;
369
381
  const 角 = 10;
370
382
  const 溝 = 32;
@@ -422,7 +434,7 @@ export function ShapeCodeBlockNode({ node, active, lang }: ShapeProps): JSX.Elem
422
434
  { text: 札, size: 札の字, 帯: { 幅: タブ幅, 高さ: タブ高 } },
423
435
  { text: "1", size: 行番号の字, 帯: { 幅: 溝 - 持ち上げ, 高さ: 行間 } },
424
436
  ];
425
- const 倍返し = 打ち消しの倍率(倍, 打ち消す字ら);
437
+ const 倍返し = 打ち消しの倍率(倍, 打ち消す字ら, textMetrics);
426
438
  const tabW = タブ幅 * 倍;
427
439
  const tabH = タブ高 * 倍;
428
440
  const 丸 = 角 * 倍;
@@ -456,7 +468,12 @@ export function ShapeCodeBlockNode({ node, active, lang }: ShapeProps): JSX.Elem
456
468
  data-cdl-counter-scaled="1"
457
469
  x={x + tabW / 2}
458
470
  y={y + tabH / 2 + 4 * 倍}
459
- fontSize={札の字 * 倍 * 倍返し * 幅の倍率(札, 札の字 * 倍 * 倍返し, 0, tabW)}
471
+ fontSize={
472
+ 札の字 *
473
+ 倍 *
474
+ 倍返し *
475
+ 幅の倍率(札, 札の字 * 倍 * 倍返し, 0, tabW, textMetrics)
476
+ }
460
477
  fontFamily={lineFont}
461
478
  textAnchor="middle"
462
479
  fill="#e6edf3"
@@ -538,7 +555,12 @@ export function ShapeCodeBlockNode({ node, active, lang }: ShapeProps): JSX.Elem
538
555
  }
539
556
 
540
557
  /** shape-kanban-card = kanban ticket (top color bar + 3 stub line) */
541
- export function ShapeKanbanCardNode({ node, active, lang }: ShapeProps): JSX.Element {
558
+ export function ShapeKanbanCardNode({
559
+ node,
560
+ active,
561
+ lang,
562
+ textMetrics = "proportional",
563
+ }: TextMeasuredShapeProps): JSX.Element {
542
564
  const 字引 = 字引きを作る(lang);
543
565
  const frame = commonFrame(active);
544
566
  const 線 = frame.strokeWidth / 2;
@@ -628,7 +650,7 @@ export function ShapeKanbanCardNode({ node, active, lang }: ShapeProps): JSX.Ele
628
650
  * = 書いてもいない語が図の一部として読める状態だった (見本帳で 3 件)。
629
651
  */
630
652
  const 状態 = 書いた値か控え(node.eyebrow, 字引("kanbanCard.status"));
631
- const 状態の倍率 = 幅の倍率(状態, PILL字, PILL字間, PILL字幅);
653
+ const 状態の倍率 = 幅の倍率(状態, PILL字, PILL字間, PILL字幅, textMetrics);
632
654
  const 説明の字 = 名前.size * 説明字比;
633
655
  // 札の内側の幅。 左右の余白は札と同じ倍率で縮むので、倍率を掛けてから引く
634
656
  const 使える幅 = Math.max(0, w - 左右余白 * 倍 * 2);
@@ -707,7 +729,7 @@ export function ShapeKanbanCardNode({ node, active, lang }: ShapeProps): JSX.Ele
707
729
  <text
708
730
  x={x + w / 2}
709
731
  y={名前.y + 名前.size * 説明基準比}
710
- fontSize={説明の字 * 幅の倍率(説明, 説明の字, 0, 使える幅)}
732
+ fontSize={説明の字 * 幅の倍率(説明, 説明の字, 0, 使える幅, textMetrics)}
711
733
  textAnchor="middle"
712
734
  fill="var(--cdl-text-dim, #5a6270)"
713
735
  >
@@ -0,0 +1,29 @@
1
+ import type { JSX } from "react";
2
+
3
+ import type { LaidNode } from "../types";
4
+ import { renderShapeWithOutsideLabels } from "./dyn-shape";
5
+ import { nodeAccent } from "./node-tone";
6
+
7
+ const STATION_STROKE_ACTIVE = 4;
8
+ const STATION_STROKE_IDLE = 3;
9
+ const STATION_DEFAULT = "var(--cdl-tone-accent, #2d6a8f)";
10
+
11
+ /** 路線図の線路上に置く駅。名前と説明は丸を隠さないよう外側に置く。 */
12
+ export function StationNode({ node, active }: { node: LaidNode; active: boolean }): JSX.Element {
13
+ const strokeWidth = active ? STATION_STROKE_ACTIVE : STATION_STROKE_IDLE;
14
+ // 線は箱の辺で止まるため、枠線の外縁を箱の辺へ合わせると線路と丸の間が空かない。
15
+ const radius = Math.max(0, Math.min(node.w, node.h) / 2 - strokeWidth / 2);
16
+ const 形 = (
17
+ <circle
18
+ data-cdl-role="node-body"
19
+ data-cdl-mark="station"
20
+ cx={node.cx}
21
+ cy={node.cy}
22
+ r={radius}
23
+ fill="var(--cdl-node-fill, #ffffff)"
24
+ stroke={nodeAccent(node, STATION_DEFAULT)}
25
+ strokeWidth={strokeWidth}
26
+ />
27
+ );
28
+ return renderShapeWithOutsideLabels(形, node, "middle");
29
+ }
@@ -5,6 +5,8 @@
5
5
  * (`row-align.ts`) の両方が使うが、 前者から取ると React まで引き込まれる。 測定の検査で
6
6
  * 読込みの時間が計測に乗り、 図の走査時間の予算 (100ms) を超えた (実測 = 226ms)。
7
7
  */
8
+ import type { CdlTextMetrics } from "../types";
9
+ import { measureAdvanceWidth } from "../layout/text-width";
8
10
 
9
11
  /**
10
12
  * 字 1 つが字の大きさに対して占める横幅。
@@ -40,8 +42,8 @@
40
42
  * ASCII の内側は分類を残す。 ここは `W` 1.038 と `;` 0.343 で 3 倍開いており、 最大に寄せると
41
43
  * 普通の名前が 1.5 倍の見積りになって過剰に縮む。
42
44
  *
43
- * 等幅の書体なら全字 0.62 で足りるが、 `shape-` はどれも `font-family` を指定せず継承した
44
- * 比例書体で描くため、 等幅の値は使わない。
45
+ * `textMetrics: "monospace"` の図は JetBrains Mono の実測値を持つ `fontFamily: "mono"` の
46
+ * 経路で測る。 比例幅の図はこの階級表を使い、従来の見積りを保つ。
45
47
  */
46
48
  const WIDE_RATIO = 1.04;
47
49
  const NARROW_RATIO = 0.37;
@@ -58,7 +60,19 @@ const NARROW_CHARS = "ijlI.,:;'! ";
58
60
  * `字送り` (`letter-spacing`) は字ごとに加わるため字数を掛ける。 最後の 1 字ぶんは実装に
59
61
  * よって付かないが、 広い側に外す方を採る。
60
62
  */
61
- export function textWidth(s: string, size: number, 字送り = 0): number {
63
+ export function textWidth(
64
+ s: string,
65
+ size: number,
66
+ 字送り = 0,
67
+ textMetrics: CdlTextMetrics = "proportional",
68
+ ): number {
69
+ if (textMetrics === "monospace") {
70
+ return measureAdvanceWidth(s, {
71
+ fontSize: size,
72
+ letterSpacing: 字送り,
73
+ fontFamily: "mono",
74
+ });
75
+ }
62
76
  let 幅 = 0;
63
77
  let 字数 = 0;
64
78
  for (const c of s) {