@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,8 +1,15 @@
1
1
  import type { JSX } from "react";
2
- import type { LaidNode, TreeNodePayload } from "../types";
2
+ import type { CdlTextMetrics, LaidNode, TreeNodePayload } from "../types";
3
3
  import { TONE } from "../render/tone";
4
4
  import { resolveTreeData } from "../render/payload-binding";
5
- import { 使える幅, 幅に収める, 箱に積む, type 積む行, type 積んだ行 } from "../kinds/box-lines";
5
+ import {
6
+ 使える幅,
7
+ 幅に収める,
8
+ 箱に積む,
9
+ 箱の字を測る,
10
+ type 積む行,
11
+ type 積んだ行,
12
+ } from "../kinds/box-lines";
6
13
  import { measureTextWidth } from "../layout/text-width";
7
14
  import { 進みを畳む, 伸ばすdash, 段の伸び } from "./draw-progress";
8
15
 
@@ -157,10 +164,16 @@ export const 木の枝の太さ = (段: number): number => Math.max(1.1, 2.6 -
157
164
  export function 木の中身(
158
165
  node: LaidNode,
159
166
  stateValues: Record<string, string> = {},
167
+ textMetrics: CdlTextMetrics = "proportional",
160
168
  ): { nodes: LayoutNode[]; edges: LayoutEdge[] } {
161
169
  // 名前は状態を読める (#467)。 解決できない名前は書いたまま残して印を付ける
162
170
  const treeNodes = resolveTreeData(node.treeData ?? [], stateValues);
163
- return computeTreeLayout(treeNodes, node.w - PAD_LEFT - PAD_RIGHT, node.h - PAD_TOP - PAD_BOTTOM);
171
+ return computeTreeLayout(
172
+ treeNodes,
173
+ node.w - PAD_LEFT - PAD_RIGHT,
174
+ node.h - PAD_TOP - PAD_BOTTOM,
175
+ textMetrics,
176
+ );
164
177
  }
165
178
 
166
179
  /**
@@ -182,6 +195,7 @@ export function TreeNode({
182
195
  stateValues = {},
183
196
  drawing = false,
184
197
  progress = 1,
198
+ textMetrics = "proportional",
185
199
  }: {
186
200
  node: LaidNode;
187
201
  active: boolean;
@@ -190,12 +204,15 @@ export function TreeNode({
190
204
  drawing?: boolean;
191
205
  /** phase の進み (0→1)。 `drawing` が true の時だけ読む */
192
206
  progress?: number;
207
+ /** 図全体の字幅規則。省略時は比例幅 */
208
+ textMetrics?: CdlTextMetrics;
193
209
  }): JSX.Element {
194
210
  const x0 = node.cx - node.w / 2;
195
211
  const y0 = node.cy - node.h / 2;
196
212
  const gradientId = `tree-root-grad-${node.id}`;
197
213
 
198
- const layout = 木の中身(node, stateValues);
214
+ const layout = 木の中身(node, stateValues, textMetrics);
215
+ const 測る = 箱の字を測る(textMetrics);
199
216
 
200
217
  /**
201
218
  * 枝がどこまで伸びたか (0..1、 cdl#520)。 描く指定が無ければ常に 1 = 全部出ている。
@@ -273,7 +290,7 @@ export function TreeNode({
273
290
  const isRoot = n.depth === 0;
274
291
  // 補足は箱の中の 2 行目に出す (#471)。 箱の高さは変えないため、 入らない時は落ちる
275
292
  const 材料 = 木の行(n);
276
- const 行 = 箱に積む(材料, n.w, n.h);
293
+ const 行 = 箱に積む(材料, n.w, n.h, 測る);
277
294
  // **判定は行数でなく「補足を渡したか」 で行う**。 行数で見ると、 箱が低くて補足が落ちた時に
278
295
  // 従来の 1 行の経路へ戻り、 幅で切っていない名前が箱から横にはみ出す (#471 Round 1)
279
296
  const 補足あり = (n.subtitle ?? "").trim() !== "";
@@ -283,7 +300,7 @@ export function TreeNode({
283
300
  const 名前 = 材料[0]!;
284
301
  const 収めた名前 = 補足あり
285
302
  ? undefined
286
- : 幅に収める(名前.text, 名前.fontSize, 使える幅(n.w));
303
+ : 幅に収める(名前.text, 名前.fontSize, 使える幅(n.w), 測る);
287
304
  return (
288
305
  <g
289
306
  key={`n-${n.id}`}
@@ -395,6 +412,7 @@ export function computeTreeLayout(
395
412
  treeNodes: TreeNodePayload[],
396
413
  canvasW: number,
397
414
  canvasH: number,
415
+ textMetrics: CdlTextMetrics = "proportional",
398
416
  ): {
399
417
  nodes: LayoutNode[];
400
418
  edges: LayoutEdge[];
@@ -480,7 +498,7 @@ export function computeTreeLayout(
480
498
  const 名前 = 材料[0]!;
481
499
  // `使える幅` が引く左右の余白を足し戻す = 余白の値をここに書き写さない
482
500
  const 要る =
483
- measureTextWidth(名前.text, { fontSize: 名前.fontSize, fontWeight: 600 }) +
501
+ measureTextWidth(名前.text, { fontSize: 名前.fontSize, fontWeight: 600, textMetrics }) +
484
502
  (nodeW - 使える幅(nodeW));
485
503
  return Math.min(Math.max(要る, nodeW), slotW * 0.92);
486
504
  };
@@ -1,4 +1,4 @@
1
- import type { BBox, LaidEdge, LaidLane, LaidNode } from "../types";
1
+ import type { BBox, CdlTextMetrics, LaidEdge, LaidLane, LaidNode } from "../types";
2
2
  import { CHAIN_SHIFT_MAX_ACCUMULATED, computeLabelBBoxWorld, EDGE_STUB_OUT, hasRenderedLabel, LABEL_TO_PATH_CLEARANCE, requiredNearClearance } from "./spec";
3
3
  import { DIST_LABEL_PATH_MAX } from "./clearance-constants";
4
4
  import { footerShapeDrops } from "./footer-shape";
@@ -22,7 +22,12 @@ const NEAR_MISS_MARGIN = LANE_LABEL_CLEARANCE_MARGIN;
22
22
  /**
23
23
  * 全要素の AABB を集約。 engine が「キャンバスに何があるか」 を全部把握する SSOT。
24
24
  */
25
- export function collectBBoxes(lanes: LaidLane[], nodes: LaidNode[], edges: LaidEdge[]): BBox[] {
25
+ export function collectBBoxes(
26
+ lanes: LaidLane[],
27
+ nodes: LaidNode[],
28
+ edges: LaidEdge[],
29
+ textMetrics: CdlTextMetrics = "proportional",
30
+ ): BBox[] {
26
31
  const out: BBox[] = [];
27
32
 
28
33
  for (const lane of lanes) {
@@ -32,7 +37,7 @@ export function collectBBoxes(lanes: LaidLane[], nodes: LaidNode[], edges: LaidE
32
37
  id: lane.id,
33
38
  x: lane.x + 16,
34
39
  y: lane.y + 8,
35
- w: measureTextWidth(lane.label, { fontSize: 20, letterSpacing: 2 }) + 24,
40
+ w: measureTextWidth(lane.label, { fontSize: 20, letterSpacing: 2, textMetrics }) + 24,
36
41
  h: 28,
37
42
  });
38
43
  }
@@ -62,7 +67,7 @@ export function collectBBoxes(lanes: LaidLane[], nodes: LaidNode[], edges: LaidE
62
67
  // だったが、 render 側は boxH = 68 + yOffset shifted で描画するため実測 bbox と layout 予測が
63
68
  // 乖離、 near-collision detection の偽陰性を生んでいた。 本 SSOT 統一で render 実 rect 位置と
64
69
  // 一致する。
65
- const bbox = computeLabelBBoxWorld(e);
70
+ const bbox = computeLabelBBoxWorld(e, textMetrics);
66
71
  out.push({
67
72
  kind: "edge-label",
68
73
  id: e.id,
@@ -688,7 +693,7 @@ export function detectCollisions(
688
693
  /**
689
694
  * path d 文字列を、 描画する線分の列に分解
690
695
  *
691
- * 曲線 (Q / C) は端点を結ぶ弦に落とす。 交差判定には十分だが、 弦は実際の曲線が通る場所とは
696
+ * 曲線 (Q / C / A) は端点を結ぶ弦に落とす。 交差判定には十分だが、 弦は実際の曲線が通る場所とは
692
697
  * ずれる。 「この線分は本当に真っ直ぐか」 を知る必要がある呼出側のために、 由来の command
693
698
  * (`cmd`) と制御点 (`ctrl`、 曲線のみ) を併せて返す。 弦を直線として扱うと、 端点が同じ高さで
694
699
  * 大きく膨らむ曲線を「水平な直線」 と誤認する。
@@ -700,7 +705,7 @@ export function extractPathSegments(
700
705
  y1: number;
701
706
  x2: number;
702
707
  y2: number;
703
- cmd: "L" | "Q" | "C";
708
+ cmd: "L" | "Q" | "C" | "A";
704
709
  ctrl?: Array<{ x: number; y: number }>;
705
710
  }> {
706
711
  const segs: Array<{
@@ -708,10 +713,10 @@ export function extractPathSegments(
708
713
  y1: number;
709
714
  x2: number;
710
715
  y2: number;
711
- cmd: "L" | "Q" | "C";
716
+ cmd: "L" | "Q" | "C" | "A";
712
717
  ctrl?: Array<{ x: number; y: number }>;
713
718
  }> = [];
714
- const commandRegex = /([MLCQZmlcqz])([^MLCQZmlcqz]*)/g;
719
+ const commandRegex = /([MLCQAZmlcqaz])([^MLCQAZmlcqaz]*)/g;
715
720
  let cur: { x: number; y: number } = { x: 0, y: 0 };
716
721
  let m: RegExpExecArray | null;
717
722
  while ((m = commandRegex.exec(d)) !== null) {
@@ -719,7 +724,7 @@ export function extractPathSegments(
719
724
  const args = (m[2] ?? "").trim().split(/[\s,]+/).filter(Boolean).map(parseFloat);
720
725
  // 引数が足りない command は読み飛ばす。 `args[n]!` で埋めると undefined が座標に入り、
721
726
  // 以降の segment が全て NaN になる = 距離判定が黙って対象外になる (#370)。
722
- const need = cmd === "M" || cmd === "L" ? 2 : cmd === "Q" ? 4 : cmd === "C" ? 6 : 0;
727
+ const need = cmd === "M" || cmd === "L" ? 2 : cmd === "Q" ? 4 : cmd === "C" ? 6 : cmd === "A" ? 7 : 0;
723
728
  if (need > 0 && (args.length < need || !args.slice(0, need).every(Number.isFinite))) continue;
724
729
  if (cmd === "M") {
725
730
  cur = { x: args[0]!, y: args[1]! };
@@ -741,13 +746,17 @@ export function extractPathSegments(
741
746
  ctrl: [{ x: args[0]!, y: args[1]! }, { x: args[2]!, y: args[3]! }],
742
747
  });
743
748
  cur = next;
749
+ } else if (cmd === "A") {
750
+ const next = { x: args[5]!, y: args[6]! };
751
+ segs.push({ x1: cur.x, y1: cur.y, x2: next.x, y2: next.y, cmd: "A" });
752
+ cur = next;
744
753
  }
745
754
  }
746
755
  return segs;
747
756
  }
748
757
 
749
758
  /**
750
- * 曲線 (Q / C) を、 実際に描かれる線に沿った折れ線に開く。
759
+ * 曲線 (Q / C) を、 実際に描かれる線に沿った折れ線に開く。円弧 (A) は弦のまま返す。
751
760
  *
752
761
  * `extractPathSegments` は曲線を端点どうしの弦に落とす。 交差判定はそれで足りるが、
753
762
  * 「線からどれだけ離れているか」 を測る用途では弦は使えない = 弦は描かれていないため、
@@ -788,7 +797,7 @@ export function extractPathSegments(
788
797
  * 無い形を勝手に作ることになる。
789
798
  *
790
799
  * @param segs `extractPathSegments()` の出力
791
- * @returns 直線のみからなる segment 列。 `L` はそのまま。 曲線は `FLATTEN_MIN_DIVISIONS` 以上
800
+ * @returns 直線のみからなる segment 列。 `L` と `A` の弦はそのまま。`Q` / `C` は `FLATTEN_MIN_DIVISIONS` 以上
792
801
  * `FLATTEN_MAX_DIVISIONS` 以下の本数に開く。 誤差が `FLATTEN_TOLERANCE` 以下に収まるのは、
793
802
  * 必要な分割数が上限以下に収まる曲線 (`dev ≤ 4 · 許容差 · 上限²`) に限る。 上限を超える
794
803
  * 曲線では保証されず、 許容差を超えることがある (幅 4e7 の対称な弧では式の上限が 0.596
@@ -804,7 +813,7 @@ export function flattenPathSegments(
804
813
  y1: number;
805
814
  x2: number;
806
815
  y2: number;
807
- cmd: "L" | "Q" | "C";
816
+ cmd: "L" | "Q" | "C" | "A";
808
817
  ctrl?: ReadonlyArray<{ x: number; y: number }>;
809
818
  }>,
810
819
  ): Array<{ x1: number; y1: number; x2: number; y2: number }> {
@@ -1345,6 +1354,7 @@ export function resolveEdgeLabelOverlapsWithChain(
1345
1354
  fixedObstacles: BBox[],
1346
1355
  minClearance = 16,
1347
1356
  maxIter = 20,
1357
+ textMetrics: CdlTextMetrics = "proportional",
1348
1358
  ): LaidEdge[] {
1349
1359
  if (edges.length === 0) return edges;
1350
1360
 
@@ -1352,7 +1362,7 @@ export function resolveEdgeLabelOverlapsWithChain(
1352
1362
  // 本 function / render/edges.tsx / predict-bbox.ts 4 箇所の drift を解消)。 shift 探索中の
1353
1363
  // offsetX / offsetY 追加は label 位置探索の temporal offset なので helper 呼出後に加算する。
1354
1364
  const labelBBox = (e: LaidEdge, offsetX: number, offsetY: number): BBox => {
1355
- const base = computeLabelBBoxWorld(e);
1365
+ const base = computeLabelBBoxWorld(e, textMetrics);
1356
1366
  return {
1357
1367
  kind: "edge-label",
1358
1368
  id: e.id,
@@ -1540,6 +1550,7 @@ export function resolveEdgeLabelOverlapsWithChainAndPropagate(
1540
1550
  * などで label が sibling path 近傍 (LABEL_INIT_CLEARANCE 26) にある正常状態を誤検知しない。
1541
1551
  */
1542
1552
  pathLabelClearance = LABEL_TO_PATH_CLEARANCE,
1553
+ textMetrics: CdlTextMetrics = "proportional",
1543
1554
  ): {
1544
1555
  edges: LaidEdge[];
1545
1556
  nodeShifts: Map<string, { dx: number; dy: number }>;
@@ -1560,7 +1571,7 @@ export function resolveEdgeLabelOverlapsWithChainAndPropagate(
1560
1571
 
1561
1572
  // label bbox 生成 helper (spec.ts SSOT、 shift offset 込み)。
1562
1573
  const labelBBox = (e: LaidEdge, offsetX: number, offsetY: number): BBox => {
1563
- const base = computeLabelBBoxWorld(e);
1574
+ const base = computeLabelBBoxWorld(e, textMetrics);
1564
1575
  return {
1565
1576
  kind: "edge-label",
1566
1577
  id: e.id,
@@ -2357,16 +2368,17 @@ export function resolveEdgeLabelOverlapsWithChainAndPropagate(
2357
2368
  }
2358
2369
 
2359
2370
  /** 札の逃がし先を測るのに使う、 線の区間と外接矩形。 同じ図の中で使い回す (#810)。 */
2360
- interface 線の測り方 {
2371
+ export interface 線の測り方 {
2361
2372
  readonly 区間: (id: string, d: string) => Seg[];
2362
2373
  readonly 遠い: (box: BBox, edgeId: string, d: string, 余裕: number) => boolean;
2363
2374
  }
2364
2375
 
2365
2376
  /**
2366
- * 線の測り方を作る。 逃がし pass と、 門が「なぜ動かせなかったか」 を説明する側
2367
- * (`札が動けない理由`) の両方が使う = 2 箇所で別に書くと、 片方だけ直してずれる。
2377
+ * 線の測り方を作る。 逃がし pass、 門が「なぜ動かせなかったか」 を説明する側
2378
+ * (`札が動けない理由`)、 終点をずらした線の札の追従 (`followLabelsToFannedTails`、 #1002) が
2379
+ * 同じ測り方を引く = どれかに別の測り方を書くと、 置けるかの判断がずれる。
2368
2380
  */
2369
- function 線の測り方を作る(): 線の測り方 {
2381
+ export function 線の測り方を作る(): 線の測り方 {
2370
2382
  /*
2371
2383
  * 線の区間は **曲線を開いた形** で持つ (`detectCollisions` の札 branch と同じ)。
2372
2384
  *
@@ -2453,7 +2465,7 @@ export interface 札の移動を塞ぐ相手 {
2453
2465
  }
2454
2466
 
2455
2467
  /** 札を動かす判定に使う、 図の中の相手 */
2456
- interface 札の周り {
2468
+ export interface 札の周り {
2457
2469
  readonly 箱たち: readonly BBox[];
2458
2470
  readonly labels: readonly BBox[];
2459
2471
  readonly edges: readonly LaidEdge[];
@@ -2464,10 +2476,11 @@ interface 札の周り {
2464
2476
  /**
2465
2477
  * 札 1 つを動かした先が、 いまより近づく相手を作らないか。 作るなら最初の 1 つを返す。
2466
2478
  *
2467
- * 逃がし pass の `安全か` と、 門の説明 (`札が動けない理由`) が同じものを引く (#810)。
2468
- * 2 箇所で別に書くと、 「pass は動かせないのに説明は動かせると言う」 ずれが生まれる。
2479
+ * 逃がし pass の `安全か`、 門の説明 (`札が動けない理由`)、 終点をずらした線の札の追従
2480
+ * (`followLabelsToFannedTails`、 #1002) が同じものを引く (#810)。 別に書くと、 追従で置いた札を
2481
+ * 逃がし pass が押し直すか、 「pass は動かせないのに説明は動かせると言う」 ずれが生まれる。
2469
2482
  */
2470
- function 札の移動を塞ぐ相手を探す(
2483
+ export function 札の移動を塞ぐ相手を探す(
2471
2484
  周り: 札の周り,
2472
2485
  label: BBox,
2473
2486
  行き先: BBox,
@@ -2591,13 +2604,18 @@ export function 札が動けない理由(
2591
2604
  相手: BBox,
2592
2605
  edges: readonly LaidEdge[],
2593
2606
  obstacles: readonly BBox[],
2607
+ textMetrics: CdlTextMetrics = "proportional",
2594
2608
  ): string | null {
2595
2609
  const edgeById = new Map(edges.map((e) => [e.id, e] as const));
2596
2610
  const 自分 = edgeById.get(labelId);
2597
2611
  if (!自分) return null;
2598
2612
  const labels: BBox[] = edges
2599
2613
  .filter((e) => e.label?.trim() || e.sub?.trim())
2600
- .map((e) => ({ kind: "edge-label" as const, id: e.id, ...computeLabelBBoxWorld(e) }));
2614
+ .map((e) => ({
2615
+ kind: "edge-label" as const,
2616
+ id: e.id,
2617
+ ...computeLabelBBoxWorld(e, textMetrics),
2618
+ }));
2601
2619
  const label = labels.find((l) => l.id === labelId);
2602
2620
  if (!label) return null;
2603
2621
  const 測り方 = 線の測り方を作る();
@@ -2649,10 +2667,14 @@ export function 札が動けない理由(
2649
2667
  *
2650
2668
  * ## なぜ最終 pass が要るか
2651
2669
  *
2652
- * `resolveEdgeLabelOverlapsWithChainAndPropagate` は経路を動かす 4 つの pass
2670
+ * `resolveEdgeLabelOverlapsWithChainAndPropagate` の chain 伝搬後に、 経路を動かす 4 つの pass
2653
2671
  * (`bowSharedStraightPaths` / `pullOutgoingFirstCorner` / `fanIncomingEndpoints` /
2654
- * `separateSharedStraightRuns`) の **前** に走る。 札の初期位置は経路から決まるため、
2655
- * 後の pass が経路を動かすと札も一緒に動く。 動いた後を見る者が誰も居なかった。
2672
+ * `separateSharedStraightRuns`) が走る。 4 つとも経路を動かすが、 札も動かすのは
2673
+ * `pullOutgoingFirstCorner` だけで、 動かす区間に一番近い札に限る (`edges.ts` の `labelも動かす`)。
2674
+ *
2675
+ * `bowSharedStraightPaths` と `separateSharedStraightRuns` は札を動かさず、 `fanIncomingEndpoints` が
2676
+ * 残した札は `followLabelsToFannedTails` が追従させる。 追従も `札の移動を塞ぐ相手を探す` で
2677
+ * 置けるかを決める = 別の判定で余白を割る位置へ置くと、 この pass が同じ札を押し直す (#1002)。
2656
2678
  *
2657
2679
  * 0.30.0 で `pullOutgoingFirstCorner` (#651) と `fanIncomingEndpoints` (#649) が経路を
2658
2680
  * 大きく動かすようになり、 消費側の見本帳で札が 11 件動いた (実測 = 最大 217 world)。
@@ -2684,6 +2706,7 @@ export function resolveLabelClearanceAfterPathPasses(
2684
2706
  edges: LaidEdge[],
2685
2707
  obstacles: BBox[],
2686
2708
  maxIter = 8,
2709
+ textMetrics: CdlTextMetrics = "proportional",
2687
2710
  ): LaidEdge[] {
2688
2711
  if (edges.length === 0) return edges;
2689
2712
 
@@ -2707,7 +2730,7 @@ export function resolveLabelClearanceAfterPathPasses(
2707
2730
  const out: BBox[] = [];
2708
2731
  for (const e of edges) {
2709
2732
  if (!labeledIds.has(e.id)) continue;
2710
- const base = computeLabelBBoxWorld(e);
2733
+ const base = computeLabelBBoxWorld(e, textMetrics);
2711
2734
  const s = getShift(e.id);
2712
2735
  out.push({ kind: "edge-label", id: e.id, x: base.x + s.dx, y: base.y + s.dy, w: base.w, h: base.h });
2713
2736
  }
@@ -2886,4 +2909,3 @@ function applyEdgeLabelAndPathShifts(
2886
2909
  return next;
2887
2910
  });
2888
2911
  }
2889
-
@@ -0,0 +1,126 @@
1
+ import type { CdlEdge, CdlTextMetrics, LaidNode, Side } from "../types";
2
+ import { computeLabelBoxW, EDGE_STUB_OUT, labelPathClearanceHorizontal, labelPillH } from "./spec";
3
+
4
+ type 箱 = Pick<LaidNode, "cx" | "cy" | "w" | "h">;
5
+
6
+ type 曲線の結果 = {
7
+ d: string;
8
+ fromSide: Side;
9
+ toSide: Side;
10
+ labelX: number;
11
+ labelY: number;
12
+ labelAnchor: "middle";
13
+ };
14
+
15
+ /** `routePath` が横の直線を選ぶ値と揃え、既存の直線判定との境を増やさない。 */
16
+ export const SAME_HEIGHT_THRESHOLD = 1;
17
+
18
+ /**
19
+ * 2 つの箱を、両端で水平になる線で結ぶ。
20
+ *
21
+ * 行き先が左右へ離れていれば向かい合う左右の辺を使い、横の範囲が重なる時は両方の右辺を
22
+ * 使う。 2 点が同じ高さなら曲線にしても形が変わらないため、余分な制御点を持たない直線にする。
23
+ * 張り出しには `EDGE_STUB_OUT` の下限を置く。 制御点が端点に重なって接線が縦へ倒れるのを防ぎ、
24
+ * 箱を出てすぐ曲がらないことで、どの箱から出た線かを読めるようにするため。
25
+ *
26
+ * 札は曲線の中点から接線の法線方向へ離す。 線に沿ってずらすより、線と札の間の隙間を直接
27
+ * 保てるため。 この道筋は書き手が形を選ぶためのもので、途中の箱は避けない。
28
+ */
29
+ export function curvePath(
30
+ from: 箱,
31
+ to: 箱,
32
+ edge?: Pick<CdlEdge, "label" | "sub" | "style" | "overlay">,
33
+ labelMarginExtra = 0,
34
+ textMetrics: CdlTextMetrics = "proportional",
35
+ ): 曲線の結果 {
36
+ const fromLeft = from.cx - from.w / 2;
37
+ const fromRight = from.cx + from.w / 2;
38
+ const toLeft = to.cx - to.w / 2;
39
+ const toRight = to.cx + to.w / 2;
40
+
41
+ let fromSide: Side;
42
+ let toSide: Side;
43
+ let x1: number;
44
+ let x2: number;
45
+
46
+ if (toLeft >= fromRight) {
47
+ fromSide = "right";
48
+ toSide = "left";
49
+ x1 = fromRight;
50
+ x2 = toLeft;
51
+ } else if (toRight <= fromLeft) {
52
+ fromSide = "left";
53
+ toSide = "right";
54
+ x1 = fromLeft;
55
+ x2 = toRight;
56
+ } else {
57
+ fromSide = "right";
58
+ toSide = "right";
59
+ x1 = fromRight;
60
+ x2 = toRight;
61
+ }
62
+
63
+ const y1 = from.cy;
64
+ const y2 = to.cy;
65
+ const sameHeight = Math.abs(y2 - y1) < SAME_HEIGHT_THRESHOLD;
66
+
67
+ let d: string;
68
+ let midX: number;
69
+ let midY: number;
70
+ let tangentX: number;
71
+ let tangentY: number;
72
+
73
+ if (sameHeight) {
74
+ d = `M ${x1} ${y1} L ${x2} ${y2}`;
75
+ midX = (x1 + x2) / 2;
76
+ midY = (y1 + y2) / 2;
77
+ tangentX = x2 - x1;
78
+ tangentY = y2 - y1;
79
+ } else {
80
+ let c1x: number;
81
+ let c2x: number;
82
+ if (fromSide !== toSide) {
83
+ const direction = x2 > x1 ? 1 : -1;
84
+ const bulge = Math.max(Math.abs(x2 - x1) / 2, EDGE_STUB_OUT);
85
+ c1x = x1 + direction * bulge;
86
+ c2x = x2 - direction * bulge;
87
+ } else {
88
+ const bulgeX = Math.max(x1, x2) + EDGE_STUB_OUT;
89
+ c1x = bulgeX;
90
+ c2x = bulgeX;
91
+ }
92
+ d = `M ${x1} ${y1} C ${c1x} ${y1}, ${c2x} ${y2}, ${x2} ${y2}`;
93
+ midX = (x1 + 3 * c1x + 3 * c2x + x2) / 8;
94
+ midY = (y1 + 3 * y1 + 3 * y2 + y2) / 8;
95
+ tangentX = x2 + c2x - c1x - x1;
96
+ tangentY = y2 + y2 - y1 - y1;
97
+ }
98
+
99
+ if (edge?.overlay) {
100
+ return { d, fromSide, toSide, labelX: midX, labelY: midY, labelAnchor: "middle" };
101
+ }
102
+
103
+ const tangentLength = Math.hypot(tangentX, tangentY);
104
+ let normalX = tangentY / tangentLength;
105
+ let normalY = -tangentX / tangentLength;
106
+ if (normalY > Number.EPSILON || (Math.abs(normalY) <= Number.EPSILON && normalX < 0)) {
107
+ normalX *= -1;
108
+ normalY *= -1;
109
+ }
110
+
111
+ const labelW = computeLabelBoxW(edge?.label, edge?.sub, textMetrics);
112
+ const labelH = labelPillH(edge?.sub);
113
+ const distance =
114
+ labelPathClearanceHorizontal(edge) +
115
+ (Math.abs(normalX) * labelW + Math.abs(normalY) * labelH) / 2 +
116
+ labelMarginExtra;
117
+
118
+ return {
119
+ d,
120
+ fromSide,
121
+ toSide,
122
+ labelX: midX + normalX * distance,
123
+ labelY: midY + normalY * distance,
124
+ labelAnchor: "middle",
125
+ };
126
+ }