@cardenelabs/cdl 0.122.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 (64) hide show
  1. package/CHANGELOG.md +16 -1
  2. package/README.md +64 -0
  3. package/dist/index.cjs +1065 -617
  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 +1065 -618
  8. package/dist/index.js.map +1 -1
  9. package/dist/react.cjs +834 -403
  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 +834 -403
  14. package/dist/react.js.map +1 -1
  15. package/dist/{render-U_pC-Ksh.d.cts → render-CVczwfRb.d.cts} +20 -2
  16. package/dist/{render-U_pC-Ksh.d.ts → render-CVczwfRb.d.ts} +20 -2
  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 +70 -25
  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 +19 -0
  64. package/src/visual-validate.ts +67 -17
@@ -1,4 +1,13 @@
1
- import type { BBox, CdlDiagram, CdlEdge, LaidEdge, LaidLane, LaidNode, Side } from "../types";
1
+ import type {
2
+ BBox,
3
+ CdlDiagram,
4
+ CdlEdge,
5
+ CdlTextMetrics,
6
+ LaidEdge,
7
+ LaidLane,
8
+ LaidNode,
9
+ Side,
10
+ } from "../types";
2
11
  import {
3
12
  CLEARANCE_LABEL_LABEL,
4
13
  CLEARANCE_NODE_LABEL,
@@ -211,7 +220,12 @@ function detourColumnFor(blockers: LaidNode[], side: DetourSide): number {
211
220
  : Math.max(...blockers.map((n) => n.cx + n.w / 2)) + VERTICAL_DETOUR_GAP;
212
221
  }
213
222
 
214
- export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane[] = []): LaidEdge[] {
223
+ export function layoutEdges(
224
+ diag: CdlDiagram,
225
+ nodes: LaidNode[],
226
+ lanes: LaidLane[] = [],
227
+ textMetrics: CdlTextMetrics = "proportional",
228
+ ): LaidEdge[] {
215
229
  // labelMargin = author 追加余白、 path 中点 → label の垂直距離に加算 (上向き label は -=、 下向きは +=)。
216
230
  // viewport.labelMargin 明示時はその差分、 未指定なら viewport.gap → DEFAULT_LABEL_MARGIN の順で fallback。
217
231
  const labelMarginExtra =
@@ -258,7 +272,7 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
258
272
  const to = nmap.get(e.to);
259
273
  if (!from) throw new Error(`cdl layout: edge "${e.id}" の from "${e.from}" が node に無い`);
260
274
  if (!to) throw new Error(`cdl layout: edge "${e.id}" の to "${e.to}" が node に無い`);
261
- const c = curvePath(from, to, e, labelMarginExtra);
275
+ const c = curvePath(from, to, e, labelMarginExtra, textMetrics);
262
276
  positionedEdges.push({
263
277
  ...e,
264
278
  d: c.d,
@@ -273,7 +287,7 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
273
287
  const to = nmap.get(e.to);
274
288
  if (!from) throw new Error(`cdl layout: edge "${e.id}" の from "${e.from}" が node に無い`);
275
289
  if (!to) throw new Error(`cdl layout: edge "${e.id}" の to "${e.to}" が node に無い`);
276
- const m = metroPath(from, to, e, labelMarginExtra);
290
+ const m = metroPath(from, to, e, labelMarginExtra, textMetrics);
277
291
  positionedEdges.push({
278
292
  ...e,
279
293
  d: m.d,
@@ -329,7 +343,7 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
329
343
  使う(le.from, le.fromSide);
330
344
  使う(le.to, le.toSide);
331
345
  }
332
- const loopEdges = layoutSelfLoops(selfLoopEdges, nmap, occupiedBySide);
346
+ const loopEdges = layoutSelfLoops(selfLoopEdges, nmap, occupiedBySide, textMetrics);
333
347
 
334
348
  // CAR-429 (CAR-426-B) ... 全 edge の起点 / 終点 / L 字 corner は node 辺 Y 中央固定 (offset=0 SSOT)。
335
349
  // 旧 fan-in / fan-out / parallel の sidepoint 分散 (±60/±120) は削除済で、
@@ -687,7 +701,7 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
687
701
  if (others.some((n) => segmentsCrossRect(segs, nodeRect(n)))) return true;
688
702
 
689
703
  if (!hasRenderedLabel(c.e)) return false;
690
- const boxW = computeLabelBoxW(c.e.label, c.e.sub);
704
+ const boxW = computeLabelBoxW(c.e.label, c.e.sub, textMetrics);
691
705
  const boxH = labelPillH(c.e.sub);
692
706
  const centerX = columnX - (labelPathClearanceHorizontal(c.e) + boxW / 2);
693
707
  const pill = { x: centerX - boxW / 2, y: (exitY + returnY) / 2 - boxH / 2, w: boxW, h: boxH };
@@ -872,6 +886,7 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
872
886
  { x: endpointOffset.fromDx, y: endpointOffset.fromDy },
873
887
  { x: endpointOffset.toDx, y: endpointOffset.toDy },
874
888
  avoidRects,
889
+ textMetrics,
875
890
  );
876
891
  const routed1 = route([]);
877
892
  const { d, labelX, labelY, labelAnchor, labelAltX } = routed1;
@@ -953,7 +968,7 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
953
968
  const pillById = new Map<string, Rect>();
954
969
  for (const r of list) {
955
970
  if (!hasRenderedLabel(r.e)) continue;
956
- const w = computeLabelBoxW(r.e.label, r.e.sub);
971
+ const w = computeLabelBoxW(r.e.label, r.e.sub, textMetrics);
957
972
  const h = labelPillH(r.e.sub);
958
973
  pillById.set(r.e.id, { x: r.baseLabelX - w / 2, y: r.baseLabelY - h / 2, w, h });
959
974
  }
@@ -961,7 +976,7 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
961
976
  // 迂回線がその pill を貫く
962
977
  for (const le of positionedEdges) {
963
978
  if (!hasRenderedLabel(le)) continue;
964
- const w = computeLabelBoxW(le.label, le.sub);
979
+ const w = computeLabelBoxW(le.label, le.sub, textMetrics);
965
980
  const h = labelPillH(le.sub);
966
981
  pillById.set(le.id, { x: le.labelX - w / 2, y: le.labelY - h / 2, w, h });
967
982
  }
@@ -1018,7 +1033,7 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
1018
1033
 
1019
1034
  for (const r of flippable) {
1020
1035
  const ownSegs = segsByEdgeId.get(r.e.id)!;
1021
- const boxW = computeLabelBoxW(r.e.label, r.e.sub);
1036
+ const boxW = computeLabelBoxW(r.e.label, r.e.sub, textMetrics);
1022
1037
  const boxH = labelPillH(r.e.sub);
1023
1038
  const pillAt = (centerX: number) => ({
1024
1039
  x: centerX - boxW / 2,
@@ -1094,7 +1109,7 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
1094
1109
  // 横の重なりで連結成分に割り、 成分ごとに縦を解く。
1095
1110
  const boxes = list.map((r) => ({
1096
1111
  cx: r.baseLabelX,
1097
- halfW: computeLabelBoxW(r.e.label, r.e.sub) / 2,
1112
+ halfW: computeLabelBoxW(r.e.label, r.e.sub, textMetrics) / 2,
1098
1113
  }));
1099
1114
  const overlapsX = (i: number, j: number): boolean =>
1100
1115
  Math.abs(boxes[i]!.cx - boxes[j]!.cx) < boxes[i]!.halfW + boxes[j]!.halfW + CLEARANCE_LABEL_LABEL;
@@ -1172,7 +1187,7 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
1172
1187
  r,
1173
1188
  baseX: r.baseLabelX,
1174
1189
  baseY: r.baseLabelY,
1175
- w: computeLabelBoxW(r.e.label, r.e.sub),
1190
+ w: computeLabelBoxW(r.e.label, r.e.sub, textMetrics),
1176
1191
  h: labelPillH(r.e.sub),
1177
1192
  }));
1178
1193
  items.sort((a, b) => a.baseX - b.baseX);
@@ -1374,6 +1389,7 @@ export function bowSharedStraightPaths(
1374
1389
  edges: LaidEdge[],
1375
1390
  nodes: LaidNode[],
1376
1391
  skipIds: ReadonlySet<string>,
1392
+ textMetrics: CdlTextMetrics = "proportional",
1377
1393
  ): void {
1378
1394
  /**
1379
1395
  * 向きを無視した線分の key。 A→B と B→A を同じ群にする。
@@ -1445,7 +1461,7 @@ export function bowSharedStraightPaths(
1445
1461
  const groupIds = new Set(group.map((g) => g.e.id));
1446
1462
  const labelRects = edges
1447
1463
  .filter((x) => hasRenderedLabel(x) && !groupIds.has(x.id))
1448
- .map((x) => computeLabelBBoxWorld(x));
1464
+ .map((x) => computeLabelBBoxWorld(x, textMetrics));
1449
1465
  const nodeRects = nodes.map((n) => ({ x: n.cx - n.w / 2, y: n.cy - n.h / 2, w: n.w, h: n.h }));
1450
1466
  const build = (seg: Entry["seg"], offset: number): string => {
1451
1467
  // 2 次曲線は制御点の半分の高さを通るので、 狙いの膨らみの 2 倍を制御点に与える。
@@ -1646,6 +1662,7 @@ function routePath(
1646
1662
  * `widenDetourStubs` が同じ引数に本 list を足して呼び直す。
1647
1663
  */
1648
1664
  avoidRects: readonly Rect[] = [],
1665
+ textMetrics: CdlTextMetrics = "proportional",
1649
1666
  ): {
1650
1667
  d: string;
1651
1668
  labelX: number;
@@ -1669,7 +1686,7 @@ function routePath(
1669
1686
  // label 幅 = SSOT = spec.ts の computeLabelBoxW (CAR-431 で collections.ts labelBBox /
1670
1687
  // render/edges.tsx / predict-bbox.ts の 4 箇所計算を集約)。 L 字 midX / midY を label 収納可能な
1671
1688
  // 位置に押し出す判定で使う。 CLEARANCE_NODE_LABEL は clearance-constants.ts SSOT import。
1672
- const labelBoxW = edge ? computeLabelBoxW(edge.label, edge.sub) : 0;
1689
+ const labelBoxW = edge ? computeLabelBoxW(edge.label, edge.sub, textMetrics) : 0;
1673
1690
  const labelHalfW = labelBoxW / 2;
1674
1691
  // CAR-518 SSOT ... solid = LABEL_INIT_CLEARANCE (34、 CAR-630 で 26 → 34 復帰)、 dotted-flow = LABEL_INIT_CLEARANCE_DOTTED_FLOW (62、 CAR-630 で 54 → 62 復帰)。
1675
1692
  // routePath 内 labelY 計算 8 箇所で参照、 style 分岐を helper 経由で単一 SSOT 化する。
@@ -2418,7 +2435,11 @@ function routePath(
2418
2435
  * @param edges 確定済 path を持つ辺。 `labelY` のみを書き換える
2419
2436
  * @param nodes 節。 間隔が悪化する置き方を捨てるために使う
2420
2437
  */
2421
- export function repositionParallelLabels(edges: LaidEdge[], nodes: LaidNode[]): void {
2438
+ export function repositionParallelLabels(
2439
+ edges: LaidEdge[],
2440
+ nodes: LaidNode[],
2441
+ textMetrics: CdlTextMetrics = "proportional",
2442
+ ): void {
2422
2443
  // 図の全ての線 (実際に描かれる形)。 1 度だけ開いて使い回す。
2423
2444
  const allSegs = new Map(
2424
2445
  edges.map((e) => [e.id, flattenPathSegments(extractPathSegments(e.d))] as const),
@@ -2451,7 +2472,7 @@ export function repositionParallelLabels(edges: LaidEdge[], nodes: LaidNode[]):
2451
2472
  const pairOrder = [...pair].sort(compareSharedPairOrder);
2452
2473
 
2453
2474
  // 横に重なる label ごとに塊を作る (1 つだけの塊も含む)。
2454
- const boxes = pair.map((e) => computeLabelBBoxWorld(e));
2475
+ const boxes = pair.map((e) => computeLabelBBoxWorld(e, textMetrics));
2455
2476
  const componentOf = pair.map((_, i) => i);
2456
2477
  for (let i = 0; i < pair.length; i++) {
2457
2478
  for (let j = i + 1; j < pair.length; j++) {
@@ -2479,6 +2500,7 @@ export function repositionParallelLabels(edges: LaidEdge[], nodes: LaidNode[]):
2479
2500
  allEdges: edges,
2480
2501
  allSegs,
2481
2502
  xRange,
2503
+ textMetrics,
2482
2504
  });
2483
2505
  for (let i = 0; i < members.length; i++) members[i]!.labelY = placed[i]!;
2484
2506
  }
@@ -2511,11 +2533,21 @@ function placeAvoidingSiblingPaths(args: {
2511
2533
  allEdges: LaidEdge[];
2512
2534
  allSegs: Map<string, Array<{ x1: number; y1: number; x2: number; y2: number }>>;
2513
2535
  xRange: Map<string, { min: number; max: number }>;
2536
+ textMetrics?: CdlTextMetrics;
2514
2537
  }): number[] {
2515
- const { members, pair, pairOrder, nodes, allEdges, allSegs, xRange } = args;
2538
+ const {
2539
+ members,
2540
+ pair,
2541
+ pairOrder,
2542
+ nodes,
2543
+ allEdges,
2544
+ allSegs,
2545
+ xRange,
2546
+ textMetrics = "proportional",
2547
+ } = args;
2516
2548
  const sizes = members.map((e) => labelPillH(e.sub));
2517
2549
  const wants = members.map((e) => e.labelY);
2518
- const boxes = members.map((e) => computeLabelBBoxWorld(e));
2550
+ const boxes = members.map((e) => computeLabelBBoxWorld(e, textMetrics));
2519
2551
  const all = members.map((_, i) => i);
2520
2552
  const spread = (idx: number[]): number[] =>
2521
2553
  spreadWithMinimalShift(
@@ -2799,7 +2831,11 @@ function inflate(r: Rect, pad: number): Rect {
2799
2831
  * 余白は `LABEL_SLIDE_CLEARANCES` を大きい方から試し、 その余白を確保できる位置のうち
2800
2832
  * 今の位置に一番近いものを採る。 どの余白でも置けない場合は動かさない。
2801
2833
  */
2802
- export function slideLabelsAlongOwnPath(edges: LaidEdge[], nodes: readonly LaidNode[]): void {
2834
+ export function slideLabelsAlongOwnPath(
2835
+ edges: LaidEdge[],
2836
+ nodes: readonly LaidNode[],
2837
+ textMetrics: CdlTextMetrics = "proportional",
2838
+ ): void {
2803
2839
  const withLabel = edges.filter((e) => hasRenderedLabel(e));
2804
2840
  if (withLabel.length === 0) return;
2805
2841
  const segsById = new Map<string, FlatSeg[]>();
@@ -2807,7 +2843,7 @@ export function slideLabelsAlongOwnPath(edges: LaidEdge[], nodes: readonly LaidN
2807
2843
  const nodeRects = nodes.map(nodeRect);
2808
2844
 
2809
2845
  for (const e of withLabel) {
2810
- const w = computeLabelBoxW(e.label, e.sub);
2846
+ const w = computeLabelBoxW(e.label, e.sub, textMetrics);
2811
2847
  const h = labelPillH(e.sub);
2812
2848
  if (w === 0 || h === 0) continue;
2813
2849
  const own = segsById.get(e.id) ?? [];
@@ -3232,7 +3268,11 @@ export function separateSharedStraightRuns(edges: LaidEdge[], nodes: readonly La
3232
3268
  * 起点は `fan-origin-single-point` のため動かさず、 最初の折れ直後の区間を平行移動する。
3233
3269
  * 1 本しか出ない辺は `detourStub` が決めた箱との中間位置をそのまま保つ。
3234
3270
  */
3235
- export function pullOutgoingFirstCorner(edges: LaidEdge[], nodes: readonly LaidNode[]): void {
3271
+ export function pullOutgoingFirstCorner(
3272
+ edges: LaidEdge[],
3273
+ nodes: readonly LaidNode[],
3274
+ textMetrics: CdlTextMetrics = "proportional",
3275
+ ): void {
3236
3276
  if (edges.length < 2) return;
3237
3277
 
3238
3278
  const 束 = new Map<string, LaidEdge[]>();
@@ -3303,10 +3343,10 @@ export function pullOutgoingFirstCorner(edges: LaidEdge[], nodes: readonly LaidN
3303
3343
  // 旧経路に重なっていた札を先に確定する。 経路を動かした後では重なりが消え、 札だけが
3304
3344
  // 元の位置に取り残される。
3305
3345
  if (!札を旧経路へ追従済み) {
3306
- slideLabelsAlongOwnPath(edges, nodes);
3346
+ slideLabelsAlongOwnPath(edges, nodes, textMetrics);
3307
3347
  札を旧経路へ追従済み = true;
3308
3348
  }
3309
- const labelBox = computeLabelBBoxWorld(e);
3349
+ const labelBox = computeLabelBBoxWorld(e, textMetrics);
3310
3350
  const label = { x: labelBox.x + labelBox.w / 2, y: labelBox.y + labelBox.h / 2 };
3311
3351
  const 線分までの距離2 = (a: Vertex, b: Vertex): number => {
3312
3352
  const dx = b.x - a.x;
@@ -3510,13 +3550,14 @@ export function followLabelsToFannedTails(
3510
3550
  edges: LaidEdge[],
3511
3551
  obstacles: readonly BBox[],
3512
3552
  shifts: ReadonlyMap<string, { dx: number; dy: number }>,
3553
+ textMetrics: CdlTextMetrics = "proportional",
3513
3554
  ): void {
3514
3555
  if (shifts.size === 0) return;
3515
3556
  const 測り方 = 線の測り方を作る();
3516
3557
  const labels: BBox[] = edges.filter(hasRenderedLabel).map((e) => ({
3517
3558
  kind: "edge-label",
3518
3559
  id: e.id,
3519
- ...computeLabelBBoxWorld(e),
3560
+ ...computeLabelBBoxWorld(e, textMetrics),
3520
3561
  }));
3521
3562
  const labelIndexByEdgeId = new Map(labels.map((label, index) => [label.id, index] as const));
3522
3563
  const edgeById = new Map(edges.map((e) => [e.id, e] as const));
@@ -3555,7 +3596,7 @@ export function followLabelsToFannedTails(
3555
3596
  labelY: e.labelY + 移動.dy,
3556
3597
  labelAnchor: e.labelAnchor,
3557
3598
  };
3558
- const 候補aの箱 = computeLabelBBoxWorld({ ...e, ...候補a });
3599
+ const 候補aの箱 = computeLabelBBoxWorld({ ...e, ...候補a }, textMetrics);
3559
3600
  const 最後は縦 = Math.abs(最後.x2 - 最後.x1) <= Math.abs(最後.y2 - 最後.y1);
3560
3601
  const 候補 = [
3561
3602
  候補a,
@@ -3571,7 +3612,11 @@ export function followLabelsToFannedTails(
3571
3612
  ];
3572
3613
 
3573
3614
  for (const c of 候補) {
3574
- const 札: BBox = { kind: "edge-label", id: e.id, ...computeLabelBBoxWorld({ ...e, ...c }) };
3615
+ const 札: BBox = {
3616
+ kind: "edge-label",
3617
+ id: e.id,
3618
+ ...computeLabelBBoxWorld({ ...e, ...c }, textMetrics),
3619
+ };
3575
3620
  if (segmentsCrossRect([最後], 札)) continue;
3576
3621
  if (札の移動を塞ぐ相手を探す(周り, 元の札, 札) !== null) continue;
3577
3622
 
@@ -17,7 +17,7 @@
17
17
  * 使う。 弦の向きを使うと、大きく膨らむ曲線で線が端へ入ってくる向きと別の向きが出る。
18
18
  */
19
19
  import { EDGE_LABEL_TEXT } from "../label-text";
20
- import { EDGE_HEAD_MARKER_SIZE } from "../types";
20
+ import { EDGE_HEAD_MARKER_SIZE, type CdlTextMetrics } from "../types";
21
21
  import { extractPathSegments } from "./collisions";
22
22
  import { hasRenderedLabel } from "./spec";
23
23
  import { measureTextWidth } from "./text-width";
@@ -171,6 +171,7 @@ export function 端の字を置く(
171
171
  labelY?: number;
172
172
  },
173
173
  端: 線の端 = "head",
174
+ textMetrics: CdlTextMetrics = "proportional",
174
175
  ): 端の字の置き場所 | null {
175
176
  const text = (端 === "head" ? edge.headLabel : edge.tailLabel)?.trim();
176
177
  if (!text) return null;
@@ -183,6 +184,7 @@ export function 端の字を置く(
183
184
  fontSize: 端の字の大きさ,
184
185
  fontWeight: EDGE_LABEL_TEXT.sub.fontWeight,
185
186
  fontFamily: "mono",
187
+ textMetrics,
186
188
  });
187
189
  const h = 箱の高さ;
188
190
  // 札の中心。 札を描かない線と、位置がまだ無い線は `null`
@@ -1,4 +1,4 @@
1
- import type { CdlDiagram, CdlEdge, LaidLane, LaidNode } from "../types";
1
+ import type { CdlDiagram, CdlEdge, CdlTextMetrics, LaidLane, LaidNode } from "../types";
2
2
  import { CLEARANCE_NODE_LABEL } from "./clearance-constants";
3
3
  import { computeLabelBoxW, EDGE_STUB_OUT, targetNodeNodeClearance } from "./spec";
4
4
  import { CONTAIN_PAD, DEFAULT_LANE_GAP, HEADER_RESERVE, LANE_LABEL_H, LANE_PAD_MIN } from "./tokens";
@@ -194,6 +194,7 @@ export function expandLaneGapsForEdgeLabels(
194
194
  lanes: LaidLane[],
195
195
  nodes: LaidNode[],
196
196
  edges: readonly CdlEdge[],
197
+ textMetrics: CdlTextMetrics = "proportional",
197
198
  ): LaidLane[] {
198
199
  if (lanes.length < 2) return lanes;
199
200
  // canvas pivot 新 spec (dragon canvas pivot spec §layout-role-conversion)。
@@ -240,7 +241,7 @@ export function expandLaneGapsForEdgeLabels(
240
241
  const fi = laneIndexById.get(fromLane);
241
242
  const ti = laneIndexById.get(toLane);
242
243
  if (fi === undefined || ti === undefined || fi === ti) continue;
243
- const labelBoxW = computeLabelBoxW(edge.label, edge.sub);
244
+ const labelBoxW = computeLabelBoxW(edge.label, edge.sub, textMetrics);
244
245
  if (labelBoxW === 0) continue;
245
246
  const lo = Math.min(fi, ti);
246
247
  const hi = Math.max(fi, ti);
@@ -1,4 +1,4 @@
1
- import type { CdlEdge, LaidNode, Side } from "../types";
1
+ import type { CdlEdge, CdlTextMetrics, LaidNode, Side } from "../types";
2
2
  import { SAME_HEIGHT_THRESHOLD } from "./curve-path";
3
3
  import { computeLabelBoxW, EDGE_STUB_OUT, labelPathClearanceHorizontal, labelPillH } from "./spec";
4
4
 
@@ -49,6 +49,7 @@ export function metroPath(
49
49
  to: 箱,
50
50
  edge?: Pick<CdlEdge, "label" | "sub" | "style" | "overlay">,
51
51
  labelMarginExtra = 0,
52
+ textMetrics: CdlTextMetrics = "proportional",
52
53
  ): 路線図の線の結果 {
53
54
  const fromLeft = from.cx - from.w / 2;
54
55
  const fromRight = from.cx + from.w / 2;
@@ -166,7 +167,7 @@ export function metroPath(
166
167
  normalX *= -1;
167
168
  normalY *= -1;
168
169
  }
169
- const labelW = computeLabelBoxW(edge?.label, edge?.sub);
170
+ const labelW = computeLabelBoxW(edge?.label, edge?.sub, textMetrics);
170
171
  const labelH = labelPillH(edge?.sub);
171
172
  const distance =
172
173
  labelPathClearanceHorizontal(edge) +
@@ -1,5 +1,5 @@
1
1
  import { requiredRowsWidth } from "../kinds/row-align";
2
- import type { CdlDiagram, LaidLane, LaidNode } from "../types";
2
+ import type { CdlDiagram, CdlTextMetrics, LaidLane, LaidNode } from "../types";
3
3
  import { 端の字に要る線の長さ } from "./head-label";
4
4
  import {
5
5
  hasRenderedLabel,
@@ -77,10 +77,14 @@ function autoStorageHeight(
77
77
  *
78
78
  * 著者が n.w を明示している場合は尊重 (snapshot 互換 / catalog thumbnail 縮小指定を壊さない)。
79
79
  */
80
- function autoRowsWidth(n: { kind: string; rows?: string[]; w?: number }, defaultW: number): number {
80
+ function autoRowsWidth(
81
+ n: { kind: string; rows?: string[]; w?: number },
82
+ defaultW: number,
83
+ textMetrics: CdlTextMetrics = "proportional",
84
+ ): number {
81
85
  if (n.w != null) return n.w;
82
86
  if (!n.rows || n.rows.length === 0) return defaultW;
83
- const required = requiredRowsWidth(n.rows, n.kind);
87
+ const required = requiredRowsWidth(n.rows, n.kind, textMetrics);
84
88
  return Math.max(defaultW, required);
85
89
  }
86
90
 
@@ -171,7 +175,11 @@ function requiredStackGapForEdgeLabels(diag: CdlDiagram, allStacks: readonly num
171
175
  return required;
172
176
  }
173
177
 
174
- export function layoutNodes(diag: CdlDiagram, lanes: LaidLane[]): LaidNode[] {
178
+ export function layoutNodes(
179
+ diag: CdlDiagram,
180
+ lanes: LaidLane[],
181
+ textMetrics: CdlTextMetrics,
182
+ ): LaidNode[] {
175
183
  const laneMap = new Map(lanes.map((l) => [l.id, l]));
176
184
 
177
185
  // 全 lane を跨いで stack 番号で row を共有する。
@@ -232,7 +240,7 @@ export function layoutNodes(diag: CdlDiagram, lanes: LaidLane[]): LaidNode[] {
232
240
  }
233
241
  const size = NODE_SIZE[n.kind];
234
242
  if (n.posX !== undefined && n.posY !== undefined) {
235
- const w = n.posW ?? n.w ?? autoRowsWidth(n, size.w);
243
+ const w = n.posW ?? n.w ?? autoRowsWidth(n, size.w, textMetrics);
236
244
  const h = n.posH ?? n.h ?? rowH.get(n.stack) ?? size.h;
237
245
  out.push({
238
246
  ...n,
@@ -270,7 +278,7 @@ export function layoutNodes(diag: CdlDiagram, lanes: LaidLane[]): LaidNode[] {
270
278
  const h = n.h ?? (自前の高さ ? 箱の高さ(n, size.h) : 段の高さ);
271
279
  const cy = 段の芯;
272
280
  // n.w 明示時は尊重、 未指定で rows[] を持つ node は内容長に合わせて auto 拡張。
273
- const w = autoRowsWidth(n, size.w);
281
+ const w = autoRowsWidth(n, size.w, textMetrics);
274
282
  out.push({
275
283
  ...n,
276
284
  cx,
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { computeLabelBBoxWorld } from "./spec";
12
- import type { LaidEdge, LaidNode } from "../types";
12
+ import type { CdlTextMetrics, LaidEdge, LaidNode } from "../types";
13
13
 
14
14
  /**
15
15
  * Edge label の world 単位 bbox 予測 (spec.ts SSOT の re-export)。
@@ -22,8 +22,11 @@ import type { LaidEdge, LaidNode } from "../types";
22
22
  * @param edge LaidEdge (labelX, labelY, labelAnchor, label, sub 既確定)
23
23
  * @returns { x, y, w, h } の world 座標 bbox (render 実 rect 位置と一致)
24
24
  */
25
- export function predictLabelBBoxWorld(edge: LaidEdge): { x: number; y: number; w: number; h: number } {
26
- return computeLabelBBoxWorld(edge);
25
+ export function predictLabelBBoxWorld(
26
+ edge: LaidEdge,
27
+ textMetrics: CdlTextMetrics = "proportional",
28
+ ): { x: number; y: number; w: number; h: number } {
29
+ return computeLabelBBoxWorld(edge, textMetrics);
27
30
  }
28
31
 
29
32
  /**
@@ -1,4 +1,4 @@
1
- import type { CdlEdge, LaidEdge, LaidNode, Side } from "../types";
1
+ import type { CdlEdge, CdlTextMetrics, LaidEdge, LaidNode, Side } from "../types";
2
2
  import { CLEARANCE_LABEL_LABEL } from "./clearance-constants";
3
3
  import { computeLabelBoxW, LABEL_TO_PATH_CLEARANCE, labelPillH } from "./spec";
4
4
 
@@ -120,7 +120,12 @@ export function pickFreeSides(occupied: ReadonlySet<Side> | undefined, count: nu
120
120
  *
121
121
  * 1 本だけの時は積む必要が無いので、辺の中央の真横に 1 つ置くだけになる。
122
122
  */
123
- export function routeSelfLoopGroup(edges: CdlEdge[], node: LaidNode, side: Side): LaidEdge[] {
123
+ export function routeSelfLoopGroup(
124
+ edges: CdlEdge[],
125
+ node: LaidNode,
126
+ side: Side,
127
+ textMetrics: CdlTextMetrics = "proportional",
128
+ ): LaidEdge[] {
124
129
  const horizontal = side === "left" || side === "right";
125
130
  const g = sideGeometry(node, side);
126
131
  const span = fitSpan(horizontal ? node.h : node.w, SELF_LOOP_SPAN);
@@ -135,7 +140,7 @@ export function routeSelfLoopGroup(edges: CdlEdge[], node: LaidNode, side: Side)
135
140
 
136
141
  // 積む向きの語の大きさ。 横の辺なら縦に積むので高さ、縦の辺なら横に積むので幅
137
142
  const 語幅 = edges.map((e) =>
138
- horizontal ? labelPillH(e.sub) : computeLabelBoxW(e.label, e.sub),
143
+ horizontal ? labelPillH(e.sub) : computeLabelBoxW(e.label, e.sub, textMetrics),
139
144
  );
140
145
  const 位置: number[] = [];
141
146
  let 積 = 0;
@@ -181,6 +186,7 @@ export function layoutSelfLoops(
181
186
  edges: CdlEdge[],
182
187
  nodes: Map<string, LaidNode>,
183
188
  occupiedBySide: ReadonlyMap<string, ReadonlySet<Side>>,
189
+ textMetrics: CdlTextMetrics = "proportional",
184
190
  ): LaidEdge[] {
185
191
  // 辺の割り当ては節ごとに 1 度で決める。 輪を 1 本ずつ処理して選び直すと、2 本目が
186
192
  // 1 本目の辺を「空いている」 と見て同じ場所に重なる
@@ -213,5 +219,5 @@ export function layoutSelfLoops(
213
219
  }
214
220
  // 語の置き場所は束の中の並びで決まるので、束ごとにまとめて経路にする。
215
221
  // 返す並びは呼出側が id で引き直すため、ここでは束の順で構わない
216
- return [...束.values()].flatMap((b) => routeSelfLoopGroup(b.edges, b.node, b.side));
222
+ return [...束.values()].flatMap((b) => routeSelfLoopGroup(b.edges, b.node, b.side, textMetrics));
217
223
  }
@@ -18,7 +18,7 @@
18
18
  import { CLEARANCE_LABEL_LABEL, CLEARANCE_NODE_LABEL } from "./clearance-constants";
19
19
  import { measureTextWidth } from "./text-width";
20
20
  import { EDGE_LABEL_TEXT } from "../label-text";
21
- import type { CdlEdge, LaidEdge, LaidLane, LaidNode } from "../types";
21
+ import type { CdlEdge, CdlTextMetrics, LaidEdge, LaidLane, LaidNode } from "../types";
22
22
 
23
23
  // ─── edge label pill bbox SSOT (CAR-431 で 3 定義箇所を集約) ──
24
24
 
@@ -965,10 +965,21 @@ export function requiredVerticalLabelGap(pillHs: readonly number[]): number {
965
965
  * @param sub sub text (undefined なら 0)
966
966
  * @returns pill 全体の world 幅 (偶数)
967
967
  */
968
- export function computeLabelBoxW(label: string | undefined, sub: string | undefined): number {
969
- const mainW = measureTextWidth(label ?? "", { fontSize: EDGE_LABEL_TEXT.main.fontSize });
968
+ export function computeLabelBoxW(
969
+ label: string | undefined,
970
+ sub: string | undefined,
971
+ textMetrics: CdlTextMetrics = "proportional",
972
+ ): number {
973
+ const mainW = measureTextWidth(label ?? "", {
974
+ fontSize: EDGE_LABEL_TEXT.main.fontSize,
975
+ textMetrics,
976
+ });
970
977
  const subW = sub
971
- ? measureTextWidth(sub, { fontSize: EDGE_LABEL_TEXT.sub.fontSize, fontFamily: "mono" })
978
+ ? measureTextWidth(sub, {
979
+ fontSize: EDGE_LABEL_TEXT.sub.fontSize,
980
+ fontFamily: "mono",
981
+ textMetrics,
982
+ })
972
983
  : 0;
973
984
  const raw = Math.max(mainW, subW) + LABEL_PILL_PAD_X * 2;
974
985
  // 切り上げる前に浮動小数の誤差を落とす。 文字幅は係数の掛け算と足し算を重ねるので、
@@ -999,8 +1010,11 @@ export function computeLabelBoxW(label: string | undefined, sub: string | undefi
999
1010
  * @param edge LaidEdge (labelX / labelY / labelAnchor / label / sub 既確定)
1000
1011
  * @returns { x, y, w, h } の world 座標 bbox (render 実 rect 位置と一致)
1001
1012
  */
1002
- export function computeLabelBBoxWorld(edge: LaidEdge): { x: number; y: number; w: number; h: number } {
1003
- const boxW = computeLabelBoxW(edge.label, edge.sub);
1013
+ export function computeLabelBBoxWorld(
1014
+ edge: LaidEdge,
1015
+ textMetrics: CdlTextMetrics = "proportional",
1016
+ ): { x: number; y: number; w: number; h: number } {
1017
+ const boxW = computeLabelBoxW(edge.label, edge.sub, textMetrics);
1004
1018
  const boxH = edge.sub ? LABEL_PILL_H_WITH_SUB : LABEL_PILL_H_MAIN_ONLY;
1005
1019
  const anchor = edge.labelAnchor;
1006
1020
  const boxLeftX = anchor === "start" ? 0 : anchor === "end" ? -boxW : -boxW / 2;
@@ -19,6 +19,8 @@
19
19
  * Bold は Regular に対し英字で +8-15%、 CJK は同じ、 記号は char により大差 (bold ならではの pen 太り)。
20
20
  */
21
21
 
22
+ import type { CdlTextMetrics } from "../types";
23
+
22
24
  type FontFamily = "sans" | "mono";
23
25
 
24
26
  /**
@@ -39,6 +41,8 @@ interface MeasureOptions {
39
41
  letterSpacing?: number;
40
42
  fontFamily?: FontFamily;
41
43
  bound?: WidthBound;
44
+ /** 図全体の字幅規則。`monospace` は `fontFamily: "mono"` と同じ経路で測る */
45
+ textMetrics?: CdlTextMetrics;
42
46
  }
43
47
 
44
48
  /**
@@ -208,22 +212,20 @@ function defaultAdvanceEm(ch: string, code: number): number {
208
212
  }
209
213
 
210
214
  /**
211
- * 文字列の SVG render pixel 幅を実測係数で算出する。
215
+ * 最低幅を当てる前の SVG render pixel 幅を実測係数で算出する。
212
216
  *
213
- * @param text 対象文字列 (空文字は最低幅にクランプされる)
217
+ * @param text 対象文字列 (空文字は 0)
214
218
  * @param opts fontSize / fontWeight / letterSpacing / fontFamily の描画 context
215
219
  *
216
220
  * default = fontSize 22, fontWeight 700, letterSpacing 0, fontFamily "sans" (Inter Bold)。
217
221
  * edge label main text の caller (collisions / predict-bbox / edges) と一致する。
218
222
  * sub text (fontSize 19, fontFamily "mono") / lane label (fontSize 20, letterSpacing 2) は
219
223
  * opts で明示的に渡す。
220
- *
221
- * 最低幅 = fontSize × 1.2 (超短 label で bbox が細くなり shift 判定不安定になる問題を防ぐ)。
222
224
  */
223
- export function measureTextWidth(text: string, opts: MeasureOptions = {}): number {
225
+ export function measureAdvanceWidth(text: string, opts: MeasureOptions = {}): number {
224
226
  const fontSize = opts.fontSize ?? 22;
225
227
  const letterSpacing = opts.letterSpacing ?? 0;
226
- const fontFamily = opts.fontFamily ?? "sans";
228
+ const fontFamily = opts.textMetrics === "monospace" ? "mono" : (opts.fontFamily ?? "sans");
227
229
  const bound = opts.bound ?? "layout";
228
230
  let advanceEmSum = 0;
229
231
  let charCount = 0;
@@ -236,7 +238,7 @@ export function measureTextWidth(text: string, opts: MeasureOptions = {}): numbe
236
238
  // 割り戻さない = 測っていない字を狭く見積もると、はみ出す題を見落とす
237
239
  const 割り戻し =
238
240
  bound === "drawn" && !isEmojiGrapheme(ch, code)
239
- ? isAscii(code)
241
+ ? isAscii(code) && fontFamily !== "mono"
240
242
  ? DRAWN_ASCII_RATIO
241
243
  : isCJK(code)
242
244
  ? DRAWN_CJK_RATIO
@@ -248,6 +250,10 @@ export function measureTextWidth(text: string, opts: MeasureOptions = {}): numbe
248
250
  continue;
249
251
  }
250
252
  if (fontFamily === "mono") {
253
+ // `DRAWN_ASCII_RATIO` (1.16) は Inter の比例幅表が実描画より広い分を見本帳の題で測った値。
254
+ // 0.63 は JetBrains Mono を実際に描いて測った値なので、割ると 0.543em になって等幅の
255
+ // 実描画 (0.6em 前後) より狭く見積もる。すると、はみ出す題を見落とし、木の箱の字を
256
+ // 収めたつもりで箱から出す。
251
257
  advanceEmSum += (isCJK(code) ? 1.08 : JETBRAINS_MONO_ADVANCE_EM) / 割り戻し;
252
258
  continue;
253
259
  }
@@ -262,7 +268,18 @@ export function measureTextWidth(text: string, opts: MeasureOptions = {}): numbe
262
268
  const table = ch.length === 1 ? INTER_BOLD_ADVANCE_EM[ch] : undefined;
263
269
  advanceEmSum += (table !== undefined ? table : defaultAdvanceEm(ch, code)) / 割り戻し;
264
270
  }
265
- const w = advanceEmSum * fontSize + letterSpacing * charCount;
271
+ return advanceEmSum * fontSize + letterSpacing * charCount;
272
+ }
273
+
274
+ /**
275
+ * 文字列の SVG render pixel 幅を実測係数で算出する。
276
+ *
277
+ * 最低幅 = fontSize × 1.2 (超短 label で bbox が細くなり shift 判定不安定になる問題を防ぐ)。
278
+ */
279
+ export function measureTextWidth(text: string, opts: MeasureOptions = {}): number {
280
+ const fontSize = opts.fontSize ?? 22;
281
+ const bound = opts.bound ?? "layout";
282
+ const w = measureAdvanceWidth(text, opts);
266
283
  // 最低幅は組み方のための下駄 (短い札で bbox が細くなり判定が揺れるのを防ぐ)。
267
284
  // 実描画に合わせる時は当てない = 1 字の題が 1.2 em で描かれることは無い
268
285
  return bound === "drawn" ? w : Math.max(w, fontSize * 1.2);