@cardenelabs/cdl 0.121.0 → 0.123.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +51 -1
  2. package/README.md +137 -0
  3. package/SPEC.md +1 -1
  4. package/dist/index.cjs +1513 -667
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +51 -33
  7. package/dist/index.d.ts +51 -33
  8. package/dist/index.js +1513 -668
  9. package/dist/index.js.map +1 -1
  10. package/dist/react.cjs +1271 -444
  11. package/dist/react.cjs.map +1 -1
  12. package/dist/react.d.cts +1 -1
  13. package/dist/react.d.ts +1 -1
  14. package/dist/react.js +1271 -444
  15. package/dist/react.js.map +1 -1
  16. package/dist/{render-A5AKjNuy.d.cts → render-CVczwfRb.d.cts} +40 -7
  17. package/dist/{render-A5AKjNuy.d.ts → render-CVczwfRb.d.ts} +40 -7
  18. package/package.json +9 -12
  19. package/src/builder.ts +8 -2
  20. package/src/index.ts +3 -0
  21. package/src/kinds/box-lines.ts +17 -4
  22. package/src/kinds/chart-bar.tsx +4 -0
  23. package/src/kinds/chart-gauge.tsx +20 -9
  24. package/src/kinds/chart-pie.tsx +67 -17
  25. package/src/kinds/chart-radial.tsx +7 -2
  26. package/src/kinds/chart-slope.tsx +26 -7
  27. package/src/kinds/chart-stacked-bar.tsx +8 -4
  28. package/src/kinds/chart-stat.tsx +19 -4
  29. package/src/kinds/chart-waffle.tsx +7 -2
  30. package/src/kinds/dyn-shape.tsx +22 -9
  31. package/src/kinds/funnel.tsx +9 -2
  32. package/src/kinds/generic.tsx +5 -2
  33. package/src/kinds/mind-map.tsx +26 -10
  34. package/src/kinds/quadrant.tsx +14 -4
  35. package/src/kinds/row-align.ts +19 -7
  36. package/src/kinds/sequence-board-metrics.ts +13 -3
  37. package/src/kinds/sequence-board.tsx +4 -2
  38. package/src/kinds/shape-basement.tsx +57 -32
  39. package/src/kinds/shape-blockchain.tsx +83 -34
  40. package/src/kinds/shape-commerce.tsx +65 -24
  41. package/src/kinds/shape-finance.tsx +90 -26
  42. package/src/kinds/shape-hardware.tsx +48 -17
  43. package/src/kinds/shape-region.tsx +16 -6
  44. package/src/kinds/shape-software.tsx +30 -8
  45. package/src/kinds/station.tsx +29 -0
  46. package/src/kinds/text-width.ts +17 -3
  47. package/src/kinds/tree.tsx +25 -7
  48. package/src/layout/collisions.ts +50 -28
  49. package/src/layout/curve-path.ts +126 -0
  50. package/src/layout/edges.ts +271 -58
  51. package/src/layout/head-label.ts +11 -5
  52. package/src/layout/lanes.ts +3 -2
  53. package/src/layout/metro-path.ts +185 -0
  54. package/src/layout/nodes.ts +14 -6
  55. package/src/layout/predict-bbox.ts +6 -3
  56. package/src/layout/self-loop.ts +10 -4
  57. package/src/layout/spec.ts +36 -7
  58. package/src/layout/text-width.ts +25 -8
  59. package/src/layout/tokens.ts +2 -0
  60. package/src/layout-with-validation.ts +10 -5
  61. package/src/layout.ts +55 -21
  62. package/src/presets.ts +69 -43
  63. package/src/render/edges.tsx +19 -3
  64. package/src/render/interactive-panel.tsx +34 -7
  65. package/src/render/nodes.tsx +100 -48
  66. package/src/render/stage.tsx +3 -0
  67. package/src/text-metrics.ts +9 -0
  68. package/src/types.ts +41 -4
  69. package/src/visual-validate.ts +91 -21
@@ -1,4 +1,13 @@
1
- import type { 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,
@@ -8,9 +17,14 @@ import {
8
17
  extractPathSegments,
9
18
  flattenPathSegments,
10
19
  measurePathLabelGapSegs,
20
+ 札の移動を塞ぐ相手を探す,
11
21
  segmentsCrossRect,
12
22
  segmentsExcludingOwnPath,
23
+ type 札の周り,
24
+ 線の測り方を作る,
13
25
  } from "./collisions";
26
+ import { curvePath } from "./curve-path";
27
+ import { metroPath } from "./metro-path";
14
28
  import { countEdgeCrossings } from "../visual-validate";
15
29
  import {
16
30
  computeLabelBBoxWorld,
@@ -34,6 +48,8 @@ import { DEFAULT_LABEL_MARGIN } from "./tokens";
34
48
  const VERTICAL_DETOUR_GAP = 50;
35
49
  /** 縦 detour の角の丸め半径 (world unit)。 */
36
50
  const VERTICAL_DETOUR_CORNER = 14;
51
+ /** overlay の札を「自分の線の上に載せた札」 とみなす、 札の中心から自分の線までの距離の上限 (world unit)。 */
52
+ const OVERLAY_LABEL_ON_PATH_TOLERANCE = 1;
37
53
 
38
54
  /**
39
55
  * 縦に進む区間の**進む向き** (下へ = 1、 上へ = -1)。 角の丸めの位置を決める時に使う。
@@ -204,7 +220,12 @@ function detourColumnFor(blockers: LaidNode[], side: DetourSide): number {
204
220
  : Math.max(...blockers.map((n) => n.cx + n.w / 2)) + VERTICAL_DETOUR_GAP;
205
221
  }
206
222
 
207
- 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[] {
208
229
  // labelMargin = author 追加余白、 path 中点 → label の垂直距離に加算 (上向き label は -=、 下向きは +=)。
209
230
  // viewport.labelMargin 明示時はその差分、 未指定なら viewport.gap → DEFAULT_LABEL_MARGIN の順で fallback。
210
231
  const labelMarginExtra =
@@ -214,6 +235,7 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
214
235
  // label が lane の枠線を跨ぐかを見る 2 か所 (左への迂回と label の反転) が使う縦の範囲 (#863)。
215
236
  const laneRangeById = new Map(lanes.map((l) => [l.id, laneVerticalRange(l, nodes)]));
216
237
 
238
+ // 経路を書き手が決めた線 = 座標を固定した線、曲線の線、路線図の線。
217
239
  // canvas pivot 新 spec (dragon canvas pivot spec §layout-role-conversion)。
218
240
  // posX1 / posY1 / posX2 / posY2 全て set 済 edge は「user が固定した絶対 path」 = 直線 path
219
241
  // (posX1, posY1) → (posX2, posY2) を最終 path とする。 label 位置は path 中点、 fromSide / toSide
@@ -245,6 +267,36 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
245
267
  });
246
268
  } else if (isSelfLoop(e)) {
247
269
  selfLoopEdges.push(e);
270
+ } else if (e.routing === "curve") {
271
+ const from = nmap.get(e.from);
272
+ const to = nmap.get(e.to);
273
+ if (!from) throw new Error(`cdl layout: edge "${e.id}" の from "${e.from}" が node に無い`);
274
+ if (!to) throw new Error(`cdl layout: edge "${e.id}" の to "${e.to}" が node に無い`);
275
+ const c = curvePath(from, to, e, labelMarginExtra, textMetrics);
276
+ positionedEdges.push({
277
+ ...e,
278
+ d: c.d,
279
+ fromSide: c.fromSide,
280
+ toSide: c.toSide,
281
+ labelX: c.labelX + (e.labelOffsetX ?? 0),
282
+ labelY: c.labelY + (e.labelOffsetY ?? 0),
283
+ labelAnchor: c.labelAnchor,
284
+ });
285
+ } else if (e.routing === "metro") {
286
+ const from = nmap.get(e.from);
287
+ const to = nmap.get(e.to);
288
+ if (!from) throw new Error(`cdl layout: edge "${e.id}" の from "${e.from}" が node に無い`);
289
+ if (!to) throw new Error(`cdl layout: edge "${e.id}" の to "${e.to}" が node に無い`);
290
+ const m = metroPath(from, to, e, labelMarginExtra, textMetrics);
291
+ positionedEdges.push({
292
+ ...e,
293
+ d: m.d,
294
+ fromSide: m.fromSide,
295
+ toSide: m.toSide,
296
+ labelX: m.labelX + (e.labelOffsetX ?? 0),
297
+ labelY: m.labelY + (e.labelOffsetY ?? 0),
298
+ labelAnchor: m.labelAnchor,
299
+ });
248
300
  } else {
249
301
  autoEdges.push(e);
250
302
  }
@@ -291,7 +343,7 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
291
343
  使う(le.from, le.fromSide);
292
344
  使う(le.to, le.toSide);
293
345
  }
294
- const loopEdges = layoutSelfLoops(selfLoopEdges, nmap, occupiedBySide);
346
+ const loopEdges = layoutSelfLoops(selfLoopEdges, nmap, occupiedBySide, textMetrics);
295
347
 
296
348
  // CAR-429 (CAR-426-B) ... 全 edge の起点 / 終点 / L 字 corner は node 辺 Y 中央固定 (offset=0 SSOT)。
297
349
  // 旧 fan-in / fan-out / parallel の sidepoint 分散 (±60/±120) は削除済で、
@@ -649,7 +701,7 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
649
701
  if (others.some((n) => segmentsCrossRect(segs, nodeRect(n)))) return true;
650
702
 
651
703
  if (!hasRenderedLabel(c.e)) return false;
652
- const boxW = computeLabelBoxW(c.e.label, c.e.sub);
704
+ const boxW = computeLabelBoxW(c.e.label, c.e.sub, textMetrics);
653
705
  const boxH = labelPillH(c.e.sub);
654
706
  const centerX = columnX - (labelPathClearanceHorizontal(c.e) + boxW / 2);
655
707
  const pill = { x: centerX - boxW / 2, y: (exitY + returnY) / 2 - boxH / 2, w: boxW, h: boxH };
@@ -834,6 +886,7 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
834
886
  { x: endpointOffset.fromDx, y: endpointOffset.fromDy },
835
887
  { x: endpointOffset.toDx, y: endpointOffset.toDy },
836
888
  avoidRects,
889
+ textMetrics,
837
890
  );
838
891
  const routed1 = route([]);
839
892
  const { d, labelX, labelY, labelAnchor, labelAltX } = routed1;
@@ -915,7 +968,7 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
915
968
  const pillById = new Map<string, Rect>();
916
969
  for (const r of list) {
917
970
  if (!hasRenderedLabel(r.e)) continue;
918
- const w = computeLabelBoxW(r.e.label, r.e.sub);
971
+ const w = computeLabelBoxW(r.e.label, r.e.sub, textMetrics);
919
972
  const h = labelPillH(r.e.sub);
920
973
  pillById.set(r.e.id, { x: r.baseLabelX - w / 2, y: r.baseLabelY - h / 2, w, h });
921
974
  }
@@ -923,7 +976,7 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
923
976
  // 迂回線がその pill を貫く
924
977
  for (const le of positionedEdges) {
925
978
  if (!hasRenderedLabel(le)) continue;
926
- const w = computeLabelBoxW(le.label, le.sub);
979
+ const w = computeLabelBoxW(le.label, le.sub, textMetrics);
927
980
  const h = labelPillH(le.sub);
928
981
  pillById.set(le.id, { x: le.labelX - w / 2, y: le.labelY - h / 2, w, h });
929
982
  }
@@ -980,7 +1033,7 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
980
1033
 
981
1034
  for (const r of flippable) {
982
1035
  const ownSegs = segsByEdgeId.get(r.e.id)!;
983
- const boxW = computeLabelBoxW(r.e.label, r.e.sub);
1036
+ const boxW = computeLabelBoxW(r.e.label, r.e.sub, textMetrics);
984
1037
  const boxH = labelPillH(r.e.sub);
985
1038
  const pillAt = (centerX: number) => ({
986
1039
  x: centerX - boxW / 2,
@@ -1056,7 +1109,7 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
1056
1109
  // 横の重なりで連結成分に割り、 成分ごとに縦を解く。
1057
1110
  const boxes = list.map((r) => ({
1058
1111
  cx: r.baseLabelX,
1059
- halfW: computeLabelBoxW(r.e.label, r.e.sub) / 2,
1112
+ halfW: computeLabelBoxW(r.e.label, r.e.sub, textMetrics) / 2,
1060
1113
  }));
1061
1114
  const overlapsX = (i: number, j: number): boolean =>
1062
1115
  Math.abs(boxes[i]!.cx - boxes[j]!.cx) < boxes[i]!.halfW + boxes[j]!.halfW + CLEARANCE_LABEL_LABEL;
@@ -1134,7 +1187,7 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
1134
1187
  r,
1135
1188
  baseX: r.baseLabelX,
1136
1189
  baseY: r.baseLabelY,
1137
- w: computeLabelBoxW(r.e.label, r.e.sub),
1190
+ w: computeLabelBoxW(r.e.label, r.e.sub, textMetrics),
1138
1191
  h: labelPillH(r.e.sub),
1139
1192
  }));
1140
1193
  items.sort((a, b) => a.baseX - b.baseX);
@@ -1336,6 +1389,7 @@ export function bowSharedStraightPaths(
1336
1389
  edges: LaidEdge[],
1337
1390
  nodes: LaidNode[],
1338
1391
  skipIds: ReadonlySet<string>,
1392
+ textMetrics: CdlTextMetrics = "proportional",
1339
1393
  ): void {
1340
1394
  /**
1341
1395
  * 向きを無視した線分の key。 A→B と B→A を同じ群にする。
@@ -1407,7 +1461,7 @@ export function bowSharedStraightPaths(
1407
1461
  const groupIds = new Set(group.map((g) => g.e.id));
1408
1462
  const labelRects = edges
1409
1463
  .filter((x) => hasRenderedLabel(x) && !groupIds.has(x.id))
1410
- .map((x) => computeLabelBBoxWorld(x));
1464
+ .map((x) => computeLabelBBoxWorld(x, textMetrics));
1411
1465
  const nodeRects = nodes.map((n) => ({ x: n.cx - n.w / 2, y: n.cy - n.h / 2, w: n.w, h: n.h }));
1412
1466
  const build = (seg: Entry["seg"], offset: number): string => {
1413
1467
  // 2 次曲線は制御点の半分の高さを通るので、 狙いの膨らみの 2 倍を制御点に与える。
@@ -1577,7 +1631,7 @@ function sidePoint(n: LaidNode, side: Side): { x: number; y: number } {
1577
1631
  /**
1578
1632
  * orthogonal routing。
1579
1633
  * - 横水平 (left/right pair で y 揃い) → 真横直線
1580
- * - 横水平 (y 不一致) → S 字 (cubic bezier)
1634
+ * - 横水平 (y 不一致) → 角を丸めた L 字
1581
1635
  * - 縦垂直 (top/bottom pair で x 揃い) → 真縦直線
1582
1636
  * - 縦垂直 (x 不一致) → L 字 (途中で 90° 曲げ + 角丸)
1583
1637
  * - その他 (top→right 等の交差) → L 字
@@ -1608,6 +1662,7 @@ function routePath(
1608
1662
  * `widenDetourStubs` が同じ引数に本 list を足して呼び直す。
1609
1663
  */
1610
1664
  avoidRects: readonly Rect[] = [],
1665
+ textMetrics: CdlTextMetrics = "proportional",
1611
1666
  ): {
1612
1667
  d: string;
1613
1668
  labelX: number;
@@ -1631,7 +1686,7 @@ function routePath(
1631
1686
  // label 幅 = SSOT = spec.ts の computeLabelBoxW (CAR-431 で collections.ts labelBBox /
1632
1687
  // render/edges.tsx / predict-bbox.ts の 4 箇所計算を集約)。 L 字 midX / midY を label 収納可能な
1633
1688
  // 位置に押し出す判定で使う。 CLEARANCE_NODE_LABEL は clearance-constants.ts SSOT import。
1634
- const labelBoxW = edge ? computeLabelBoxW(edge.label, edge.sub) : 0;
1689
+ const labelBoxW = edge ? computeLabelBoxW(edge.label, edge.sub, textMetrics) : 0;
1635
1690
  const labelHalfW = labelBoxW / 2;
1636
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 復帰)。
1637
1692
  // routePath 内 labelY 計算 8 箇所で参照、 style 分岐を helper 経由で単一 SSOT 化する。
@@ -2380,7 +2435,11 @@ function routePath(
2380
2435
  * @param edges 確定済 path を持つ辺。 `labelY` のみを書き換える
2381
2436
  * @param nodes 節。 間隔が悪化する置き方を捨てるために使う
2382
2437
  */
2383
- export function repositionParallelLabels(edges: LaidEdge[], nodes: LaidNode[]): void {
2438
+ export function repositionParallelLabels(
2439
+ edges: LaidEdge[],
2440
+ nodes: LaidNode[],
2441
+ textMetrics: CdlTextMetrics = "proportional",
2442
+ ): void {
2384
2443
  // 図の全ての線 (実際に描かれる形)。 1 度だけ開いて使い回す。
2385
2444
  const allSegs = new Map(
2386
2445
  edges.map((e) => [e.id, flattenPathSegments(extractPathSegments(e.d))] as const),
@@ -2413,7 +2472,7 @@ export function repositionParallelLabels(edges: LaidEdge[], nodes: LaidNode[]):
2413
2472
  const pairOrder = [...pair].sort(compareSharedPairOrder);
2414
2473
 
2415
2474
  // 横に重なる label ごとに塊を作る (1 つだけの塊も含む)。
2416
- const boxes = pair.map((e) => computeLabelBBoxWorld(e));
2475
+ const boxes = pair.map((e) => computeLabelBBoxWorld(e, textMetrics));
2417
2476
  const componentOf = pair.map((_, i) => i);
2418
2477
  for (let i = 0; i < pair.length; i++) {
2419
2478
  for (let j = i + 1; j < pair.length; j++) {
@@ -2441,6 +2500,7 @@ export function repositionParallelLabels(edges: LaidEdge[], nodes: LaidNode[]):
2441
2500
  allEdges: edges,
2442
2501
  allSegs,
2443
2502
  xRange,
2503
+ textMetrics,
2444
2504
  });
2445
2505
  for (let i = 0; i < members.length; i++) members[i]!.labelY = placed[i]!;
2446
2506
  }
@@ -2473,11 +2533,21 @@ function placeAvoidingSiblingPaths(args: {
2473
2533
  allEdges: LaidEdge[];
2474
2534
  allSegs: Map<string, Array<{ x1: number; y1: number; x2: number; y2: number }>>;
2475
2535
  xRange: Map<string, { min: number; max: number }>;
2536
+ textMetrics?: CdlTextMetrics;
2476
2537
  }): number[] {
2477
- 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;
2478
2548
  const sizes = members.map((e) => labelPillH(e.sub));
2479
2549
  const wants = members.map((e) => e.labelY);
2480
- const boxes = members.map((e) => computeLabelBBoxWorld(e));
2550
+ const boxes = members.map((e) => computeLabelBBoxWorld(e, textMetrics));
2481
2551
  const all = members.map((_, i) => i);
2482
2552
  const spread = (idx: number[]): number[] =>
2483
2553
  spreadWithMinimalShift(
@@ -2761,7 +2831,11 @@ function inflate(r: Rect, pad: number): Rect {
2761
2831
  * 余白は `LABEL_SLIDE_CLEARANCES` を大きい方から試し、 その余白を確保できる位置のうち
2762
2832
  * 今の位置に一番近いものを採る。 どの余白でも置けない場合は動かさない。
2763
2833
  */
2764
- 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 {
2765
2839
  const withLabel = edges.filter((e) => hasRenderedLabel(e));
2766
2840
  if (withLabel.length === 0) return;
2767
2841
  const segsById = new Map<string, FlatSeg[]>();
@@ -2769,7 +2843,7 @@ export function slideLabelsAlongOwnPath(edges: LaidEdge[], nodes: readonly LaidN
2769
2843
  const nodeRects = nodes.map(nodeRect);
2770
2844
 
2771
2845
  for (const e of withLabel) {
2772
- const w = computeLabelBoxW(e.label, e.sub);
2846
+ const w = computeLabelBoxW(e.label, e.sub, textMetrics);
2773
2847
  const h = labelPillH(e.sub);
2774
2848
  if (w === 0 || h === 0) continue;
2775
2849
  const own = segsById.get(e.id) ?? [];
@@ -2887,10 +2961,12 @@ export function hopEdgeCrossings(edges: LaidEdge[]): void {
2887
2961
  }
2888
2962
 
2889
2963
  for (const e of edges) {
2964
+ if (e.routing === "metro") continue;
2890
2965
  // 部分経路が 2 つ以上ある形 (`M` が複数) は組み直せないので触らない。
2891
2966
  if ((e.d.match(/M/g) ?? []).length !== 1) continue;
2892
2967
  const own = extractPathSegments(e.d);
2893
2968
  if (own.length === 0) continue;
2969
+ if (own.some((s) => s.cmd === "A")) continue;
2894
2970
  const myEnds = ends.get(e.id) ?? [];
2895
2971
 
2896
2972
  /** 区間 index → その区間で跨ぐ点 */
@@ -2995,7 +3071,7 @@ type Vertex = { x: number; y: number };
2995
3071
  * 丸めた角は `L (手前の切り口) Q (角), (向こうの切り口)` の形で書かれており、 `Q` の制御点が
2996
3072
  * 角そのものになる。 折れずに続く `L` は角ではないので点にしない。
2997
3073
  *
2998
- * 開けない形は 3 つ。 部分経路が 2 つ以上ある (`M` が複数)、 弓 (`C`) を含む、 `Q` に制御点が
3074
+ * 開けない形は 3 つ。 部分経路が 2 つ以上ある (`M` が複数)、 弓か円弧 (`C` / `A`) を含む、 `Q` に制御点が
2999
3075
  * 無い。 いずれも触らずに返す = 形そのものが意味を持つため、 組み直すと別の図になる。
3000
3076
  */
3001
3077
  function pathVertices(d: string): Vertex[] | null {
@@ -3006,7 +3082,7 @@ function pathVertices(d: string): Vertex[] | null {
3006
3082
  const out: Vertex[] = [{ x: first.x1, y: first.y1 }];
3007
3083
  for (let i = 0; i < segs.length; i++) {
3008
3084
  const s = segs[i]!;
3009
- if (s.cmd === "C") return null;
3085
+ if (s.cmd === "C" || s.cmd === "A") return null;
3010
3086
  if (s.cmd === "Q") {
3011
3087
  const c = s.ctrl?.[0];
3012
3088
  if (!c) return null;
@@ -3106,7 +3182,7 @@ export function separateSharedStraightRuns(edges: LaidEdge[], nodes: readonly La
3106
3182
  const v = pathVertices(e.d);
3107
3183
  if (v && v.length >= 2) verts.set(e.id, v);
3108
3184
  }
3109
- if (verts.size < 2) return;
3185
+ if (verts.size === 0) return;
3110
3186
 
3111
3187
  const moved = new Set<string>();
3112
3188
 
@@ -3129,36 +3205,50 @@ export function separateSharedStraightRuns(edges: LaidEdge[], nodes: readonly La
3129
3205
  for (let j = i + 1; j < edges.length; j++) {
3130
3206
  const 前 = edges[i]!;
3131
3207
  const 後 = edges[j]!;
3132
- const A = verts.get(前.id);
3133
- const B = verts.get(後.id);
3134
- if (!A || !B) continue;
3208
+ if (前.routing === "metro" && 後.routing === "metro") continue;
3209
+ // 書き手が形を決めた路線図の線と重なる時は、自動線の方を動かす。
3210
+ const 固定 = 後.routing === "metro" ? 後 : 前;
3211
+ const 動かす = 後.routing === "metro" ? 前 : 後;
3212
+ const B = verts.get(動かす.id);
3213
+ if (!B) continue;
3214
+ const 固定の角 = verts.get(固定.id);
3215
+ const 固定の経路 = 固定の角
3216
+ ? [固定の角]
3217
+ : 固定.routing === "metro"
3218
+ ? extractPathSegments(固定.d)
3219
+ .filter((s) => s.cmd === "L")
3220
+ .map((s) => [{ x: s.x1, y: s.y1 }, { x: s.x2, y: s.y2 }])
3221
+ : [];
3222
+ if (固定の経路.length === 0) continue;
3135
3223
  // 同じ節から出る線は幹線として重ねる (#211)。
3136
- if (前.from === 後.from) continue;
3224
+ if (固定.from === 動かす.from) continue;
3137
3225
  for (const ax of [0, 1] as const) {
3138
- for (let ka = 0; ka + 1 < A.length; ka++) {
3139
- if (!runIsAligned(A, ka, ax)) continue;
3140
- for (let kb = 0; kb + 1 < B.length; kb++) {
3141
- if (!runIsAligned(B, kb, ax)) continue;
3142
- // 端点を含む区間は動かさない (線の根元が辺の中央から外れる)。
3143
- if (kb === 0 || kb + 1 === B.length - 1) continue;
3144
- const 座標A = ax === 0 ? A[ka]!.x : A[ka]!.y;
3145
- const 座標B = ax === 0 ? B[kb]!.x : B[kb]!.y;
3146
- if (Math.abs(座標A - 座標B) > SHARED_RUN_EPS) continue;
3147
- const sa = runSpan(A, ka, ax);
3148
- const sb = runSpan(B, kb, ax);
3149
- if (Math.min(sa.hi, sb.hi) - Math.max(sa.lo, sb.lo) < SHARED_RUN_MIN) continue;
3150
-
3151
- for (const delta of [SHARED_RUN_SEPARATION, -SHARED_RUN_SEPARATION]) {
3152
- if (!fits(後, B, kb, ax, delta)) continue;
3153
- if (ax === 0) {
3154
- B[kb]!.x += delta;
3155
- B[kb + 1]!.x += delta;
3156
- } else {
3157
- B[kb]!.y += delta;
3158
- B[kb + 1]!.y += delta;
3226
+ for (const A of 固定の経路) {
3227
+ for (let ka = 0; ka + 1 < A.length; ka++) {
3228
+ if (!runIsAligned(A, ka, ax)) continue;
3229
+ for (let kb = 0; kb + 1 < B.length; kb++) {
3230
+ if (!runIsAligned(B, kb, ax)) continue;
3231
+ // 端点を含む区間は動かさない (線の根元が辺の中央から外れる)。
3232
+ if (kb === 0 || kb + 1 === B.length - 1) continue;
3233
+ const 座標A = ax === 0 ? A[ka]!.x : A[ka]!.y;
3234
+ const 座標B = ax === 0 ? B[kb]!.x : B[kb]!.y;
3235
+ if (Math.abs(座標A - 座標B) > SHARED_RUN_EPS) continue;
3236
+ const sa = runSpan(A, ka, ax);
3237
+ const sb = runSpan(B, kb, ax);
3238
+ if (Math.min(sa.hi, sb.hi) - Math.max(sa.lo, sb.lo) < SHARED_RUN_MIN) continue;
3239
+
3240
+ for (const delta of [SHARED_RUN_SEPARATION, -SHARED_RUN_SEPARATION]) {
3241
+ if (!fits(動かす, B, kb, ax, delta)) continue;
3242
+ if (ax === 0) {
3243
+ B[kb]!.x += delta;
3244
+ B[kb + 1]!.x += delta;
3245
+ } else {
3246
+ B[kb]!.y += delta;
3247
+ B[kb + 1]!.y += delta;
3248
+ }
3249
+ moved.add(動かす.id);
3250
+ break;
3159
3251
  }
3160
- moved.add(後.id);
3161
- break;
3162
3252
  }
3163
3253
  }
3164
3254
  }
@@ -3178,7 +3268,11 @@ export function separateSharedStraightRuns(edges: LaidEdge[], nodes: readonly La
3178
3268
  * 起点は `fan-origin-single-point` のため動かさず、 最初の折れ直後の区間を平行移動する。
3179
3269
  * 1 本しか出ない辺は `detourStub` が決めた箱との中間位置をそのまま保つ。
3180
3270
  */
3181
- 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 {
3182
3276
  if (edges.length < 2) return;
3183
3277
 
3184
3278
  const 束 = new Map<string, LaidEdge[]>();
@@ -3195,16 +3289,25 @@ export function pullOutgoingFirstCorner(edges: LaidEdge[], nodes: readonly LaidN
3195
3289
  let 札を旧経路へ追従済み = false;
3196
3290
  for (const list of 束.values()) {
3197
3291
  if (list.length < 2) continue;
3292
+ type 最初の折れ候補 = { e: LaidEdge; pts: Vertex[] | null; 最初の長さ: number };
3198
3293
  const 並び = list
3199
- .flatMap((e) => {
3294
+ .map((e): 最初の折れ候補 | null => {
3200
3295
  const pts = pathVertices(e.d);
3201
- if (!pts || pts.length < 2) return [];
3202
- return [{ e, pts, 最初の長さ: Math.hypot(pts[1]!.x - pts[0]!.x, pts[1]!.y - pts[0]!.y) }];
3296
+ if (pts && pts.length >= 2) {
3297
+ return { e, pts, 最初の長さ: Math.hypot(pts[1]!.x - pts[0]!.x, pts[1]!.y - pts[0]!.y) };
3298
+ }
3299
+ if (e.routing !== "metro") return null;
3300
+ const first = extractPathSegments(e.d)[0];
3301
+ if (!first) return null;
3302
+ return { e, pts: null, 最初の長さ: Math.hypot(first.x2 - first.x1, first.y2 - first.y1) };
3203
3303
  })
3304
+ .filter((x): x is 最初の折れ候補 => x !== null)
3204
3305
  .sort((a, b) => b.最初の長さ - a.最初の長さ);
3205
3306
 
3206
3307
  // 最長の線を引いても共有区間は縮まらないので、 2 本目以降だけを動かす。
3207
3308
  for (const { e, pts, 最初の長さ } of 並び.slice(1)) {
3309
+ if (e.routing === "metro") continue;
3310
+ if (!pts) continue;
3208
3311
  // 同じ 2 節を結ぶ線の分離は `bowSharedStraightPaths` の範囲。 迂回経路は重ねて残す。
3209
3312
  if ((同じ組の数.get(`${e.from}->${e.to}`) ?? 0) > 1) continue;
3210
3313
  if (pts.length < 3 || 最初の長さ <= EDGE_STUB_OUT + VERTICAL_DETOUR_CORNER) continue;
@@ -3240,10 +3343,10 @@ export function pullOutgoingFirstCorner(edges: LaidEdge[], nodes: readonly LaidN
3240
3343
  // 旧経路に重なっていた札を先に確定する。 経路を動かした後では重なりが消え、 札だけが
3241
3344
  // 元の位置に取り残される。
3242
3345
  if (!札を旧経路へ追従済み) {
3243
- slideLabelsAlongOwnPath(edges, nodes);
3346
+ slideLabelsAlongOwnPath(edges, nodes, textMetrics);
3244
3347
  札を旧経路へ追従済み = true;
3245
3348
  }
3246
- const labelBox = computeLabelBBoxWorld(e);
3349
+ const labelBox = computeLabelBBoxWorld(e, textMetrics);
3247
3350
  const label = { x: labelBox.x + labelBox.w / 2, y: labelBox.y + labelBox.h / 2 };
3248
3351
  const 線分までの距離2 = (a: Vertex, b: Vertex): number => {
3249
3352
  const dx = b.x - a.x;
@@ -3306,9 +3409,16 @@ const MIXED_ROOT_OVERLAP_MIN = 24;
3306
3409
  * 分かれるので、 分かれる点が交差として数えられる。 端点の接触を除く数え方も試したが、
3307
3410
  * 離れる本数は増えないまま別の図で交差が出た。 収束する前の段階で分けるには経路の引き方
3308
3411
  * そのものを変える必要があり、 それは後処理では届かない。
3412
+ *
3413
+ * @returns 差し替えを残した線ごとの終点移動量。 札は経路を動かす pass が全て終わった後に
3414
+ * `followLabelsToFannedTails` がこの量へ追従させる (#1000)。
3309
3415
  */
3310
- export function fanIncomingEndpoints(edges: LaidEdge[], nodes: readonly LaidNode[]): void {
3311
- if (edges.length < 2) return;
3416
+ export function fanIncomingEndpoints(
3417
+ edges: LaidEdge[],
3418
+ nodes: readonly LaidNode[],
3419
+ ): ReadonlyMap<string, { dx: number; dy: number }> {
3420
+ const 終点の移動 = new Map<string, { dx: number; dy: number }>();
3421
+ if (edges.length < 2) return 終点の移動;
3312
3422
  const nmap = new Map(nodes.map((n) => [n.id, n]));
3313
3423
  const 平坦な区間 = new Map(edges.map((e) => [e.id, flattenPathSegments(extractPathSegments(e.d))] as const));
3314
3424
 
@@ -3399,6 +3509,7 @@ export function fanIncomingEndpoints(edges: LaidEdge[], nodes: readonly LaidNode
3399
3509
  : k * 間隔 - 中心;
3400
3510
  if (Math.abs(d) < 0.001) continue;
3401
3511
  const e = 並び[k]!.e;
3512
+ if (e.routing === "metro") continue;
3402
3513
  // 交差が増えるなら戻す。 判定は軸と同じ数え方を使う = 独自の数え方は軸と食い違い、
3403
3514
  // 軸が 3 件を数えている状況で 0 件と判定して通してしまった (実測)。
3404
3515
  const 前 = e.d;
@@ -3412,6 +3523,108 @@ export function fanIncomingEndpoints(edges: LaidEdge[], nodes: readonly LaidNode
3412
3523
  if (countEdgeCrossings(edges).count <= 前の交差) break;
3413
3524
  e.d = 前;
3414
3525
  }
3526
+ if (e.d !== 前) 終点の移動.set(e.id, 横 ? { dx: 0, dy: d } : { dx: d, dy: 0 });
3527
+ }
3528
+ }
3529
+
3530
+ return 終点の移動;
3531
+ }
3532
+
3533
+ /**
3534
+ * 同じ辺へ入るために終点をずらした線の札を、 最後の区間から離れた位置へ追従させる (#1000)。
3535
+ *
3536
+ * 経路を動かす pass が全て終わった後の形で、 最後の区間を覆っている札だけを見る。
3537
+ * まず終点と同じ量だけ動かし、 置けなければ最後の区間の反対側へ折り返す。
3538
+ * 追従側で足す条件は、 候補が最後の区間と交わらないことだけ。
3539
+ *
3540
+ * 置けるかは後段の `resolveLabelClearanceAfterPathPasses` と同じ
3541
+ * `札の移動を塞ぐ相手を探す` で決める。 別の判定で余白を割る位置へ置くと、 後段が同じ札を
3542
+ * 押し直すため (#1002)。
3543
+ *
3544
+ * 自分の線上に載っている overlay は判定の先頭で外す。 線から押し出された overlay は追従させる =
3545
+ * 一律に外すと、 #1000 で直した「押し出された overlay の札が最後の区間を覆う」 形が戻る。
3546
+ *
3547
+ * @param obstacles 箱と縦列の題の矩形。 後段の逃がし pass に渡すものと同じ集まり。
3548
+ */
3549
+ export function followLabelsToFannedTails(
3550
+ edges: LaidEdge[],
3551
+ obstacles: readonly BBox[],
3552
+ shifts: ReadonlyMap<string, { dx: number; dy: number }>,
3553
+ textMetrics: CdlTextMetrics = "proportional",
3554
+ ): void {
3555
+ if (shifts.size === 0) return;
3556
+ const 測り方 = 線の測り方を作る();
3557
+ const labels: BBox[] = edges.filter(hasRenderedLabel).map((e) => ({
3558
+ kind: "edge-label",
3559
+ id: e.id,
3560
+ ...computeLabelBBoxWorld(e, textMetrics),
3561
+ }));
3562
+ const labelIndexByEdgeId = new Map(labels.map((label, index) => [label.id, index] as const));
3563
+ const edgeById = new Map(edges.map((e) => [e.id, e] as const));
3564
+ const 周り: 札の周り = {
3565
+ 箱たち: obstacles.filter((o) => o.kind === "node" || o.kind === "lane-label"),
3566
+ labels,
3567
+ edges,
3568
+ edgeById,
3569
+ 測り方,
3570
+ };
3571
+
3572
+ for (const e of edges) {
3573
+ const 移動 = shifts.get(e.id);
3574
+ if (!移動 || !hasRenderedLabel(e)) continue;
3575
+
3576
+ const labelIndex = labelIndexByEdgeId.get(e.id);
3577
+ if (labelIndex === undefined) continue;
3578
+ const 元の札 = labels[labelIndex]!;
3579
+ const 自分の区間 = 測り方.区間(e.id, e.d);
3580
+ if (
3581
+ e.overlay &&
3582
+ measurePathLabelGapSegs(自分の区間, {
3583
+ x: 元の札.x + 元の札.w / 2,
3584
+ y: 元の札.y + 元の札.h / 2,
3585
+ w: 0,
3586
+ h: 0,
3587
+ }) <= OVERLAY_LABEL_ON_PATH_TOLERANCE
3588
+ ) continue;
3589
+
3590
+ const 最後 = 自分の区間.at(-1);
3591
+ if (!最後) continue;
3592
+ if (!segmentsCrossRect([最後], 元の札)) continue;
3593
+
3594
+ const 候補a = {
3595
+ labelX: e.labelX + 移動.dx,
3596
+ labelY: e.labelY + 移動.dy,
3597
+ labelAnchor: e.labelAnchor,
3598
+ };
3599
+ const 候補aの箱 = computeLabelBBoxWorld({ ...e, ...候補a }, textMetrics);
3600
+ const 最後は縦 = Math.abs(最後.x2 - 最後.x1) <= Math.abs(最後.y2 - 最後.y1);
3601
+ const 候補 = [
3602
+ 候補a,
3603
+ {
3604
+ labelX: 最後は縦
3605
+ ? 最後.x1 * 2 - (候補aの箱.x + 候補aの箱.w / 2)
3606
+ : 候補aの箱.x + 候補aの箱.w / 2,
3607
+ labelY: 最後は縦
3608
+ ? 候補aの箱.y + 候補aの箱.h / 2
3609
+ : 最後.y1 * 2 - (候補aの箱.y + 候補aの箱.h / 2),
3610
+ labelAnchor: "middle" as const,
3611
+ },
3612
+ ];
3613
+
3614
+ for (const c of 候補) {
3615
+ const 札: BBox = {
3616
+ kind: "edge-label",
3617
+ id: e.id,
3618
+ ...computeLabelBBoxWorld({ ...e, ...c }, textMetrics),
3619
+ };
3620
+ if (segmentsCrossRect([最後], 札)) continue;
3621
+ if (札の移動を塞ぐ相手を探す(周り, 元の札, 札) !== null) continue;
3622
+
3623
+ e.labelX = c.labelX;
3624
+ e.labelY = c.labelY;
3625
+ e.labelAnchor = c.labelAnchor;
3626
+ labels[labelIndex] = 札;
3627
+ break;
3415
3628
  }
3416
3629
  }
3417
3630
  }
@@ -3437,7 +3650,7 @@ function 辺から離れた側の位置(e: LaidEdge, 横: boolean): number | nul
3437
3650
  /**
3438
3651
  * 経路の末尾を「辺に沿って助走 → 辺へ垂直に入る」 の形に差し替える。
3439
3652
  *
3440
- * 差し替えられない形なら `null` を返す。 弓 (`C`) を含む経路、 部分経路が 2 つ以上ある形、
3653
+ * 差し替えられない形なら `null` を返す。 弓か円弧 (`C` / `A`) を含む経路、 部分経路が 2 つ以上ある形、
3441
3654
  * 最後が辺へ垂直に入っていない形、 助走を取れないほど短い経路、 差し替えた先が箱に当たる
3442
3655
  * 場合が該当する。
3443
3656
  */
@@ -3451,7 +3664,7 @@ function 末尾を辺に沿ってずらす(
3451
3664
  if ((e.d.match(/M/g) ?? []).length !== 1) return null;
3452
3665
  const segs = extractPathSegments(e.d);
3453
3666
  if (segs.length === 0) return null;
3454
- if (segs.some((s) => s.cmd === "C")) return null;
3667
+ if (segs.some((s) => s.cmd === "C" || s.cmd === "A")) return null;
3455
3668
 
3456
3669
  const last = segs[segs.length - 1]!;
3457
3670
  if (last.cmd !== "L") return null;
@@ -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";
@@ -132,12 +132,12 @@ function 向きを取る(end: 点, 候補: ReadonlyArray<点 | undefined>): { en
132
132
  }
133
133
 
134
134
  /**
135
- * 端の点と、線がその端へ入ってくる向き。
135
+ * 端の点と、線がその端へ入ってくる向き。 SVG が端の印を回す向きと同じ規則で取る。
136
136
  *
137
137
  * 行き先の端は最後の区間から、出どころの端は最初の区間を逆にたどって取る。 端の区間の長さが
138
138
  * 0 の時は隣の区間へ進む。 向きが取れない (区間が無い / 全て長さ 0) 時は `null`。
139
139
  */
140
- function 端での向き(d: string, 端: 線の端): { end: 点; ux: number; uy: number } | null {
140
+ export function 線の端の向き(d: string, 端: 線の端): { end: 点; ux: number; uy: number } | null {
141
141
  const segs = extractPathSegments(d);
142
142
  if (segs.length === 0) return null;
143
143
  if (端 === "head") {
@@ -146,7 +146,11 @@ function 端での向き(d: string, 端: 線の端): { end: 点; ux: number; uy:
146
146
  const 候補 = segs
147
147
  .slice()
148
148
  .reverse()
149
- .flatMap((s) => [s.cmd === "Q" ? s.ctrl?.[0] : s.cmd === "C" ? s.ctrl?.[1] : undefined, { x: s.x1, y: s.y1 }]);
149
+ .flatMap((s) => {
150
+ const 制御点 =
151
+ s.cmd === "C" ? [s.ctrl?.[1], s.ctrl?.[0]] : s.cmd === "Q" ? [s.ctrl?.[0]] : [];
152
+ return [...制御点, { x: s.x1, y: s.y1 }];
153
+ });
150
154
  return 向きを取る(end, 候補);
151
155
  }
152
156
  const first = segs[0]!;
@@ -167,10 +171,11 @@ export function 端の字を置く(
167
171
  labelY?: number;
168
172
  },
169
173
  端: 線の端 = "head",
174
+ textMetrics: CdlTextMetrics = "proportional",
170
175
  ): 端の字の置き場所 | null {
171
176
  const text = (端 === "head" ? edge.headLabel : edge.tailLabel)?.trim();
172
177
  if (!text) return null;
173
- const 向き = 端での向き(edge.d, 端);
178
+ const 向き = 線の端の向き(edge.d, 端);
174
179
  if (!向き) return null;
175
180
  const { end, ux, uy } = 向き;
176
181
  const bx = end.x - ux * 端の字を戻す距離;
@@ -179,6 +184,7 @@ export function 端の字を置く(
179
184
  fontSize: 端の字の大きさ,
180
185
  fontWeight: EDGE_LABEL_TEXT.sub.fontWeight,
181
186
  fontFamily: "mono",
187
+ textMetrics,
182
188
  });
183
189
  const h = 箱の高さ;
184
190
  // 札の中心。 札を描かない線と、位置がまだ無い線は `null`