@cardenelabs/cdl 0.22.0 → 0.24.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.
@@ -10,6 +10,7 @@ import {
10
10
  segmentsCrossRect,
11
11
  segmentsExcludingOwnPath,
12
12
  } from "./collisions";
13
+ import { countEdgeCrossings } from "../visual-validate";
13
14
  import {
14
15
  computeLabelBBoxWorld,
15
16
  computeLabelBoxW,
@@ -22,6 +23,7 @@ import {
22
23
  spreadWithMinimalShift,
23
24
  PARALLEL_EDGE_BOW,
24
25
  SYMMETRIC_PAIR_SHIFT,
26
+ ARROW_ENDPOINT_CENTER_TOL,
25
27
  } from "./spec";
26
28
  import { isSelfLoop, layoutSelfLoops } from "./self-loop";
27
29
  import { DEFAULT_LABEL_MARGIN } from "./tokens";
@@ -2547,3 +2549,628 @@ function placeAvoidingSiblingPaths(args: {
2547
2549
  // 跨りが減らないなら動かさない (無駄に動かして自分の線から離さない)。
2548
2550
  return chosen.cross < beforeCrossTotal || chosen.move === 0 ? chosen.ys : [...wants];
2549
2551
  }
2552
+
2553
+ /** 折れ線に落とした後の線分。 `flattenPathSegments` の返り値と同じ形。 */
2554
+ type FlatSeg = { x1: number; y1: number; x2: number; y2: number };
2555
+
2556
+ /** 札を線に沿って動かす時の刻み (world unit)。 */
2557
+ const LABEL_SLIDE_STEP = 24;
2558
+ /** 札の縁から確保したい余白の候補 (world unit)。 大きい方から順に試す。 */
2559
+ const LABEL_SLIDE_CLEARANCES = [32, 24, 16, 8, 0] as const;
2560
+ /**
2561
+ * 札を線分に載せる時、 線分の端から空けておく距離 (world unit)。
2562
+ *
2563
+ * 線分の端は箱の縁か折れ位置なので、 ここを詰めると札が箱に寄る。
2564
+ * 箱との必要余白と同じ値にして、 端に寄った置き方が最初から候補に入らないようにする。
2565
+ */
2566
+ const LABEL_SLIDE_END_MARGIN = CLEARANCE_NODE_LABEL;
2567
+
2568
+ /** 札の外接矩形。 中心 (cx, cy) と大きさから作る。 */
2569
+ function labelRectAt(cx: number, cy: number, w: number, h: number): Rect {
2570
+ return { x: cx - w / 2, y: cy - h / 2, w, h };
2571
+ }
2572
+
2573
+ /** 矩形を四方へ `pad` だけ広げる。 */
2574
+ function inflate(r: Rect, pad: number): Rect {
2575
+ return { x: r.x - pad, y: r.y - pad, w: r.w + pad * 2, h: r.h + pad * 2 };
2576
+ }
2577
+
2578
+ /**
2579
+ * 札を自分の線に沿って動かし、 他の線と箱から離れた位置へ置き直す (#601)。
2580
+ *
2581
+ * 札の位置は `routePath` が経路の中点で決め、 `resolveEdgeLabelOverlapsWithChain` が縦にだけ
2582
+ * ずらす。 線に沿って動かす経路が無いので、 中点の周りが混んでいる図では逃げ場が無く、
2583
+ * 別の線に被ったまま残る。
2584
+ *
2585
+ * **動かすのはいま被っている札だけ**。 被っていない札まで動かすと、 既に落ち着いている図の
2586
+ * 座標が理由なく変わる。
2587
+ *
2588
+ * 余白は `LABEL_SLIDE_CLEARANCES` を大きい方から試し、 その余白を確保できる位置のうち
2589
+ * 今の位置に一番近いものを採る。 どの余白でも置けない場合は動かさない。
2590
+ */
2591
+ export function slideLabelsAlongOwnPath(edges: LaidEdge[], nodes: readonly LaidNode[]): void {
2592
+ const withLabel = edges.filter((e) => hasRenderedLabel(e));
2593
+ if (withLabel.length === 0) return;
2594
+ const segsById = new Map<string, FlatSeg[]>();
2595
+ for (const e of edges) segsById.set(e.id, flattenPathSegments(extractPathSegments(e.d)));
2596
+ const nodeRects = nodes.map(nodeRect);
2597
+
2598
+ for (const e of withLabel) {
2599
+ const w = computeLabelBoxW(e.label, e.sub);
2600
+ const h = labelPillH(e.sub);
2601
+ if (w === 0 || h === 0) continue;
2602
+ const own = segsById.get(e.id) ?? [];
2603
+ // 自分の線は避ける相手に入れない。 札の下地が線を隠す前提の意匠なので、
2604
+ // 自分の線の上に載るのは正しい置き方 (docs/design/notation/presets/er-demo/note.md)。
2605
+ const others: FlatSeg[] = [];
2606
+ for (const o of edges) {
2607
+ if (o.id === e.id) continue;
2608
+ const segs = segsById.get(o.id);
2609
+ if (segs) others.push(...segs);
2610
+ }
2611
+ // pad は線から空ける余白。 箱からは `CLEARANCE_NODE_LABEL` まで空けたいが、
2612
+ // 置ける場所が無い時は段階的に諦める (置き直さないよりは寄っていても離れた方が良い)。
2613
+ const blocked = (cx: number, cy: number, pad: number): boolean => {
2614
+ const base = labelRectAt(cx, cy, w, h);
2615
+ if (segmentsCrossRect(others, inflate(base, pad))) return true;
2616
+ const nodePad = Math.min(pad, CLEARANCE_NODE_LABEL);
2617
+ return nodeRects.some((n) => rectsOverlap(inflate(base, nodePad), n));
2618
+ };
2619
+ // いま被っていないなら動かさない。
2620
+ if (!blocked(e.labelX, e.labelY, 0)) continue;
2621
+
2622
+ let placed: { x: number; y: number } | null = null;
2623
+ for (const pad of LABEL_SLIDE_CLEARANCES) {
2624
+ let best: { x: number; y: number; dist: number } | null = null;
2625
+ for (const s of own) {
2626
+ const dx = s.x2 - s.x1;
2627
+ const dy = s.y2 - s.y1;
2628
+ const len = Math.hypot(dx, dy);
2629
+ if (len < LABEL_SLIDE_STEP) continue;
2630
+ // 札は進行方向へ「幅か高さ」 の半分だけ張り出す。 その分だけ端から離す。
2631
+ const half = (Math.abs(dx) >= Math.abs(dy) ? w : h) / 2 + LABEL_SLIDE_END_MARGIN;
2632
+ if (len < half * 2) continue;
2633
+ const from = half / len;
2634
+ const to = 1 - half / len;
2635
+ const steps = Math.max(1, Math.floor((len * (to - from)) / LABEL_SLIDE_STEP));
2636
+ for (let k = 0; k <= steps; k++) {
2637
+ const t = from + ((to - from) * k) / steps;
2638
+ const cx = s.x1 + dx * t;
2639
+ const cy = s.y1 + dy * t;
2640
+ if (blocked(cx, cy, pad)) continue;
2641
+ const dist = Math.hypot(cx - e.labelX, cy - e.labelY);
2642
+ if (!best || dist < best.dist) best = { x: cx, y: cy, dist };
2643
+ }
2644
+ }
2645
+ if (best) {
2646
+ placed = { x: best.x, y: best.y };
2647
+ break;
2648
+ }
2649
+ }
2650
+ if (!placed) continue;
2651
+ e.labelX = placed.x;
2652
+ e.labelY = placed.y;
2653
+ e.labelAnchor = "middle";
2654
+ }
2655
+ }
2656
+
2657
+ /** 跨ぐ弧の半径 (world unit)。 */
2658
+ const HOP_RADIUS = 11;
2659
+ /** 端からこの距離より内側の交点は跨がない。 端の印が立つ場所と重なるため。 */
2660
+ const HOP_END_KEEP = 26;
2661
+ /** これより近い交点は 1 つに畳む。 曲線を折れ線に落とす都合で 1 箇所が複数に割れるため。 */
2662
+ const HOP_MERGE = HOP_RADIUS * 2 + 6;
2663
+
2664
+ /** 線分どうしの交点。 端点を含まない内側で交わる時だけ返す。 */
2665
+ function segmentCrossPoint(
2666
+ a: { x1: number; y1: number; x2: number; y2: number },
2667
+ b: { x1: number; y1: number; x2: number; y2: number },
2668
+ ): { x: number; y: number; t: number } | null {
2669
+ const dxa = a.x2 - a.x1;
2670
+ const dya = a.y2 - a.y1;
2671
+ const dxb = b.x2 - b.x1;
2672
+ const dyb = b.y2 - b.y1;
2673
+ const den = dxa * dyb - dya * dxb;
2674
+ if (Math.abs(den) < 1e-9) return null;
2675
+ const t = ((b.x1 - a.x1) * dyb - (b.y1 - a.y1) * dxb) / den;
2676
+ const u = ((b.x1 - a.x1) * dya - (b.y1 - a.y1) * dxa) / den;
2677
+ if (t <= 0.001 || t >= 0.999 || u <= 0.001 || u >= 0.999) return null;
2678
+ return { x: a.x1 + dxa * t, y: a.y1 + dya * t, t };
2679
+ }
2680
+
2681
+ /** 経路の始点と終点。 端の印が立つ場所を交点の除外に使う。 */
2682
+ function pathEnds(segs: readonly { x1: number; y1: number; x2: number; y2: number }[]): Array<{ x: number; y: number }> {
2683
+ const first = segs[0];
2684
+ const last = segs[segs.length - 1];
2685
+ if (!first || !last) return [];
2686
+ return [
2687
+ { x: first.x1, y: first.y1 },
2688
+ { x: last.x2, y: last.y2 },
2689
+ ];
2690
+ }
2691
+
2692
+ /**
2693
+ * 交わる線の、 上を通る側に弧を差し込んで跨がせる (#608)。
2694
+ *
2695
+ * engine は交差を数えるだけで、 交わった所の描き方を持たなかった。 2 本が同じ点で重なるので
2696
+ * 十字に潰れ、 下をくぐる線が切れたようにも見える。 破線どうしだと刻みが噛み合って、
2697
+ * その一点だけ塗り潰されたように見える。
2698
+ *
2699
+ * **横に走る側が跨ぐ**。 図は横に読むので、 縦線を跨ぐ方が視線の流れを切らない。
2700
+ * 両方が縦なら、 後から出た方が右へ膨らむ。
2701
+ *
2702
+ * 差し込むのは直線 (`L`) の区間だけ。 角の丸め (`Q`) と弓 (`C`) は形そのものが意味を持つ。
2703
+ *
2704
+ * 端から `HOP_END_KEEP` の内側にある交点は跨がない = そこは端の印が立つ場所で、 弧と重なる。
2705
+ */
2706
+ export function hopEdgeCrossings(edges: LaidEdge[]): void {
2707
+ if (edges.length < 2) return;
2708
+ const flat = new Map<string, FlatSeg[]>();
2709
+ const ends = new Map<string, Array<{ x: number; y: number }>>();
2710
+ for (const e of edges) {
2711
+ const segs = flattenPathSegments(extractPathSegments(e.d));
2712
+ flat.set(e.id, segs);
2713
+ ends.set(e.id, pathEnds(segs));
2714
+ }
2715
+
2716
+ for (const e of edges) {
2717
+ // 部分経路が 2 つ以上ある形 (`M` が複数) は組み直せないので触らない。
2718
+ if ((e.d.match(/M/g) ?? []).length !== 1) continue;
2719
+ const own = extractPathSegments(e.d);
2720
+ if (own.length === 0) continue;
2721
+ const myEnds = ends.get(e.id) ?? [];
2722
+
2723
+ /** 区間 index → その区間で跨ぐ点 */
2724
+ const hops = new Map<number, Array<{ x: number; y: number; t: number }>>();
2725
+ for (let i = 0; i < own.length; i++) {
2726
+ const seg = own[i]!;
2727
+ if (seg.cmd !== "L") continue;
2728
+ const dx = seg.x2 - seg.x1;
2729
+ const dy = seg.y2 - seg.y1;
2730
+ const horizontal = Math.abs(dy) < 0.5 && Math.abs(dx) >= 0.5;
2731
+ const vertical = Math.abs(dx) < 0.5 && Math.abs(dy) >= 0.5;
2732
+ if (!horizontal && !vertical) continue;
2733
+ if (Math.hypot(dx, dy) < HOP_RADIUS * 2 + 4) continue;
2734
+
2735
+ const found: Array<{ x: number; y: number; t: number }> = [];
2736
+ for (const other of edges) {
2737
+ if (other.id === e.id) continue;
2738
+ const otherEnds = ends.get(other.id) ?? [];
2739
+ for (const o of flat.get(other.id) ?? []) {
2740
+ const odx = o.x2 - o.x1;
2741
+ const ody = o.y2 - o.y1;
2742
+ const otherHorizontal = Math.abs(ody) < 0.5 && Math.abs(odx) >= 0.5;
2743
+ // 横が縦を跨ぐ。 両方が縦なら後から出た方が跨ぐ。 それ以外は相手側が跨ぐ。
2744
+ if (horizontal && otherHorizontal) continue;
2745
+ if (vertical && otherHorizontal) continue;
2746
+ if (vertical && !otherHorizontal && e.id <= other.id) continue;
2747
+ const c = segmentCrossPoint(seg, o);
2748
+ if (!c) continue;
2749
+ const nearEnd = [...myEnds, ...otherEnds].some(
2750
+ (p) => Math.hypot(c.x - p.x, c.y - p.y) < HOP_END_KEEP,
2751
+ );
2752
+ if (nearEnd) continue;
2753
+ if (found.some((f) => Math.hypot(f.x - c.x, f.y - c.y) < HOP_MERGE)) continue;
2754
+ found.push(c);
2755
+ }
2756
+ }
2757
+ if (found.length > 0) hops.set(i, found.sort((a, b) => a.t - b.t));
2758
+ }
2759
+ if (hops.size === 0) continue;
2760
+
2761
+ const head = own[0]!;
2762
+ const parts: string[] = [`M ${head.x1} ${head.y1}`];
2763
+ for (let i = 0; i < own.length; i++) {
2764
+ const seg = own[i]!;
2765
+ if (seg.cmd === "Q") {
2766
+ const c = seg.ctrl?.[0];
2767
+ parts.push(c ? `Q ${c.x} ${c.y}, ${seg.x2} ${seg.y2}` : `L ${seg.x2} ${seg.y2}`);
2768
+ continue;
2769
+ }
2770
+ if (seg.cmd === "C") {
2771
+ const c1 = seg.ctrl?.[0];
2772
+ const c2 = seg.ctrl?.[1];
2773
+ parts.push(
2774
+ c1 && c2
2775
+ ? `C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${seg.x2} ${seg.y2}`
2776
+ : `L ${seg.x2} ${seg.y2}`,
2777
+ );
2778
+ continue;
2779
+ }
2780
+ const list = hops.get(i);
2781
+ if (list) {
2782
+ const dx = seg.x2 - seg.x1;
2783
+ const dy = seg.y2 - seg.y1;
2784
+ const len = Math.hypot(dx, dy);
2785
+ const ux = dx / len;
2786
+ const uy = dy / len;
2787
+ // 横は上へ、 縦は右へ膨らむ。
2788
+ const sweep = Math.abs(uy) < 0.5 ? (ux > 0 ? 1 : 0) : uy > 0 ? 1 : 0;
2789
+ for (const p of list) {
2790
+ parts.push(`L ${round1(p.x - ux * HOP_RADIUS)} ${round1(p.y - uy * HOP_RADIUS)}`);
2791
+ parts.push(
2792
+ `A ${HOP_RADIUS} ${HOP_RADIUS} 0 0 ${sweep} ${round1(p.x + ux * HOP_RADIUS)} ${round1(p.y + uy * HOP_RADIUS)}`,
2793
+ );
2794
+ }
2795
+ }
2796
+ parts.push(`L ${seg.x2} ${seg.y2}`);
2797
+ }
2798
+ e.d = parts.join(" ");
2799
+ }
2800
+ }
2801
+
2802
+ /** 小数第 1 位まで。 経路の文字列が無用に長くならないようにする。 */
2803
+ function round1(v: number): number {
2804
+ return Math.round(v * 10) / 10;
2805
+ }
2806
+
2807
+ /** 通り道が重なった時にずらす量 (world unit)。 */
2808
+ const SHARED_RUN_SEPARATION = 26;
2809
+ /** これより長く並んで走っている区間だけをずらす (world unit)。 */
2810
+ const SHARED_RUN_MIN = 40;
2811
+ /** 経路を組み直す時の角の丸め半径 (world unit)。 `VERTICAL_DETOUR_CORNER` と同じ。 */
2812
+ const SHARED_RUN_CORNER = VERTICAL_DETOUR_CORNER;
2813
+ /** 同じ直線上とみなす座標の差 (world unit)。 */
2814
+ const SHARED_RUN_EPS = 0.5;
2815
+
2816
+ /** 経路の角の点。 */
2817
+ type Vertex = { x: number; y: number };
2818
+
2819
+ /**
2820
+ * 経路を「角の点の並び」 に開く。 開けない形なら `null` を返す。
2821
+ *
2822
+ * 丸めた角は `L (手前の切り口) Q (角), (向こうの切り口)` の形で書かれており、 `Q` の制御点が
2823
+ * 角そのものになる。 折れずに続く `L` は角ではないので点にしない。
2824
+ *
2825
+ * 開けない形は 3 つ。 部分経路が 2 つ以上ある (`M` が複数)、 弓 (`C`) を含む、 `Q` に制御点が
2826
+ * 無い。 いずれも触らずに返す = 形そのものが意味を持つため、 組み直すと別の図になる。
2827
+ */
2828
+ function pathVertices(d: string): Vertex[] | null {
2829
+ if ((d.match(/M/g) ?? []).length !== 1) return null;
2830
+ const segs = extractPathSegments(d);
2831
+ if (segs.length === 0) return null;
2832
+ const first = segs[0]!;
2833
+ const out: Vertex[] = [{ x: first.x1, y: first.y1 }];
2834
+ for (let i = 0; i < segs.length; i++) {
2835
+ const s = segs[i]!;
2836
+ if (s.cmd === "C") return null;
2837
+ if (s.cmd === "Q") {
2838
+ const c = s.ctrl?.[0];
2839
+ if (!c) return null;
2840
+ out.push({ x: c.x, y: c.y });
2841
+ continue;
2842
+ }
2843
+ // 丸めていない角も点にする。 次の `L` と向きが変われば、 その継ぎ目が角。
2844
+ const next = segs[i + 1];
2845
+ if (!next || next.cmd !== "L") continue;
2846
+ const cross = (s.x2 - s.x1) * (next.y2 - next.y1) - (s.y2 - s.y1) * (next.x2 - next.x1);
2847
+ if (Math.abs(cross) > 1e-6) out.push({ x: s.x2, y: s.y2 });
2848
+ }
2849
+ const last = segs[segs.length - 1]!;
2850
+ out.push({ x: last.x2, y: last.y2 });
2851
+ // 同じ点が続くと向きが決まらず、 組み直しで角が消える。
2852
+ return out.filter((p, i) => i === 0 || Math.hypot(p.x - out[i - 1]!.x, p.y - out[i - 1]!.y) > 1e-6);
2853
+ }
2854
+
2855
+ /**
2856
+ * 角の点の並びから経路の文字列を組む。 `pathVertices` の逆。
2857
+ *
2858
+ * 丸めの半径は前後の区間の半分を超えない。 超えると切り口が区間からはみ出し、 角が裏返る。
2859
+ * 丸める余地が無い角は直角のまま繋ぐ。
2860
+ */
2861
+ function verticesToPath(pts: readonly Vertex[]): string {
2862
+ const head = pts[0]!;
2863
+ const parts: string[] = [`M ${round1(head.x)} ${round1(head.y)}`];
2864
+ for (let i = 1; i < pts.length - 1; i++) {
2865
+ const v = pts[i]!;
2866
+ const prev = pts[i - 1]!;
2867
+ const next = pts[i + 1]!;
2868
+ const lenIn = Math.hypot(v.x - prev.x, v.y - prev.y);
2869
+ const lenOut = Math.hypot(next.x - v.x, next.y - v.y);
2870
+ const r = Math.min(SHARED_RUN_CORNER, lenIn / 2, lenOut / 2);
2871
+ if (r < 0.5 || lenIn === 0 || lenOut === 0) {
2872
+ parts.push(`L ${round1(v.x)} ${round1(v.y)}`);
2873
+ continue;
2874
+ }
2875
+ const ax = v.x - ((v.x - prev.x) / lenIn) * r;
2876
+ const ay = v.y - ((v.y - prev.y) / lenIn) * r;
2877
+ const bx = v.x + ((next.x - v.x) / lenOut) * r;
2878
+ const by = v.y + ((next.y - v.y) / lenOut) * r;
2879
+ parts.push(`L ${round1(ax)} ${round1(ay)}`);
2880
+ parts.push(`Q ${round1(v.x)} ${round1(v.y)}, ${round1(bx)} ${round1(by)}`);
2881
+ }
2882
+ const tail = pts[pts.length - 1]!;
2883
+ parts.push(`L ${round1(tail.x)} ${round1(tail.y)}`);
2884
+ return parts.join(" ");
2885
+ }
2886
+
2887
+ /** 区間 `k` (点 k と点 k+1 の間) が軸に沿っているか。 `ax` = 0 なら縦、 1 なら横。 */
2888
+ function runIsAligned(pts: readonly Vertex[], k: number, ax: 0 | 1): boolean {
2889
+ const a = pts[k];
2890
+ const b = pts[k + 1];
2891
+ if (!a || !b) return false;
2892
+ const 定 = ax === 0 ? Math.abs(a.x - b.x) : Math.abs(a.y - b.y);
2893
+ const 伸 = ax === 0 ? Math.abs(a.y - b.y) : Math.abs(a.x - b.x);
2894
+ return 定 < SHARED_RUN_EPS && 伸 > 1;
2895
+ }
2896
+
2897
+ /** 区間 `k` の、 軸に沿った向きの範囲。 */
2898
+ function runSpan(pts: readonly Vertex[], k: number, ax: 0 | 1): { lo: number; hi: number } {
2899
+ const a = pts[k]!;
2900
+ const b = pts[k + 1]!;
2901
+ const p = ax === 0 ? a.y : a.x;
2902
+ const q = ax === 0 ? b.y : b.x;
2903
+ return { lo: Math.min(p, q), hi: Math.max(p, q) };
2904
+ }
2905
+
2906
+ /**
2907
+ * 同じ通り道を並んで走る区間を、 後から出る線の側へずらす (#610)。
2908
+ *
2909
+ * 線が箱を避ける通り道は「塞ぐ箱の端 + 余白」 で決まるので、 同じ箱を避ける線は必ず同じ列を
2910
+ * 通る。 engine は重なりを検知して警告に出すが、 直す仕掛けを持っていなかった。
2911
+ *
2912
+ * 実害は見た目だけではない。 破線どうしが重なると刻みが噛み合って隙間が埋まり、 実線に見える。
2913
+ * ER 図では実線が「識別する関係」 を指すので、 関係の種類そのものが違って読める。
2914
+ *
2915
+ * **動かすのは後から出る線**。 先に出た線を動かすと、 図に線を 1 本足しただけで既にある線が
2916
+ * 動く。 後ろだけを動かせば、 足した線が避ける形になる。
2917
+ *
2918
+ * **端点を含む区間は動かさない**。 動かすと線の根元が箱の辺の中央から外れ、
2919
+ * `arrow-endpoint-center` と `fan-origin-single-point` が守っている形が崩れる。
2920
+ *
2921
+ * **同じ節から出る線どうしは重ねたまま残す** (#211)。 幹線から順に降りる 1 本の線として読め、
2922
+ * 読み手は共有した起点から分岐を辿れる。 分けると図幅が広がるだけで得るものがない。
2923
+ * 起点が違う線は辿る手掛かりが無いので、 重なると 2 本あることすら読めない。
2924
+ *
2925
+ * ずらした先が箱に当たるなら反対側を試し、 両側とも当たるならずらさない = 重なりの方が
2926
+ * 貫通より軽い。
2927
+ */
2928
+ export function separateSharedStraightRuns(edges: LaidEdge[], nodes: readonly LaidNode[]): void {
2929
+ if (edges.length < 2) return;
2930
+
2931
+ const verts = new Map<string, Vertex[]>();
2932
+ for (const e of edges) {
2933
+ const v = pathVertices(e.d);
2934
+ if (v && v.length >= 2) verts.set(e.id, v);
2935
+ }
2936
+ if (verts.size < 2) return;
2937
+
2938
+ const moved = new Set<string>();
2939
+
2940
+ /** ずらした後の 3 区間が箱を貫通しないか。 自分が繋ぐ箱は当たり判定から外す。 */
2941
+ const fits = (e: LaidEdge, pts: readonly Vertex[], k: number, ax: 0 | 1, delta: number): boolean => {
2942
+ const 試し = pts.map((p, i) =>
2943
+ i === k || i === k + 1 ? (ax === 0 ? { x: p.x + delta, y: p.y } : { x: p.x, y: p.y + delta }) : p,
2944
+ );
2945
+ // 動く区間と、 その両隣 (長さだけ変わる) を見る。
2946
+ const segs: Array<{ x1: number; y1: number; x2: number; y2: number }> = [];
2947
+ for (let i = Math.max(0, k - 1); i <= Math.min(試し.length - 2, k + 1); i++) {
2948
+ const a = 試し[i]!;
2949
+ const b = 試し[i + 1]!;
2950
+ segs.push({ x1: a.x, y1: a.y, x2: b.x, y2: b.y });
2951
+ }
2952
+ return !nodes.some((n) => n.id !== e.from && n.id !== e.to && segmentsCrossRect(segs, nodeRect(n)));
2953
+ };
2954
+
2955
+ for (let i = 0; i < edges.length; i++) {
2956
+ for (let j = i + 1; j < edges.length; j++) {
2957
+ const 前 = edges[i]!;
2958
+ const 後 = edges[j]!;
2959
+ const A = verts.get(前.id);
2960
+ const B = verts.get(後.id);
2961
+ if (!A || !B) continue;
2962
+ // 同じ節から出る線は幹線として重ねる (#211)。
2963
+ if (前.from === 後.from) continue;
2964
+ for (const ax of [0, 1] as const) {
2965
+ for (let ka = 0; ka + 1 < A.length; ka++) {
2966
+ if (!runIsAligned(A, ka, ax)) continue;
2967
+ for (let kb = 0; kb + 1 < B.length; kb++) {
2968
+ if (!runIsAligned(B, kb, ax)) continue;
2969
+ // 端点を含む区間は動かさない (線の根元が辺の中央から外れる)。
2970
+ if (kb === 0 || kb + 1 === B.length - 1) continue;
2971
+ const 座標A = ax === 0 ? A[ka]!.x : A[ka]!.y;
2972
+ const 座標B = ax === 0 ? B[kb]!.x : B[kb]!.y;
2973
+ if (Math.abs(座標A - 座標B) > SHARED_RUN_EPS) continue;
2974
+ const sa = runSpan(A, ka, ax);
2975
+ const sb = runSpan(B, kb, ax);
2976
+ if (Math.min(sa.hi, sb.hi) - Math.max(sa.lo, sb.lo) < SHARED_RUN_MIN) continue;
2977
+
2978
+ for (const delta of [SHARED_RUN_SEPARATION, -SHARED_RUN_SEPARATION]) {
2979
+ if (!fits(後, B, kb, ax, delta)) continue;
2980
+ if (ax === 0) {
2981
+ B[kb]!.x += delta;
2982
+ B[kb + 1]!.x += delta;
2983
+ } else {
2984
+ B[kb]!.y += delta;
2985
+ B[kb + 1]!.y += delta;
2986
+ }
2987
+ moved.add(後.id);
2988
+ break;
2989
+ }
2990
+ }
2991
+ }
2992
+ }
2993
+ }
2994
+ }
2995
+
2996
+ for (const e of edges) {
2997
+ if (!moved.has(e.id)) continue;
2998
+ e.d = verticesToPath(verts.get(e.id)!);
2999
+ }
3000
+ }
3001
+
3002
+ /** 同じ辺に入る線を離す間隔 (world unit)。 箱の行 1 つ分。 */
3003
+ const ENDPOINT_FAN_STEP = 56;
3004
+ /** これより詰まるなら離さない (world unit)。 離しても線どうしを見分けられない。 */
3005
+ const ENDPOINT_FAN_MIN = 20;
3006
+ /** 終点の手前で辺に沿って動くために取る助走の長さ (world unit)。 */
3007
+ const ENDPOINT_FAN_RUNUP = 40;
3008
+
3009
+ /**
3010
+ * 同じ節の同じ辺に **入る** 線の終点を、 辺に沿って離す (#611)。
3011
+ *
3012
+ * 複数の線が 1 つの辺に入ると、 終点が辺の中央 1 点に集まって根元が重なる。 重なった区間は
3013
+ * 1 本に見え、 破線どうしなら刻みが噛み合って実線に見える。
3014
+ *
3015
+ * **出る側は触らない**。 engine は出る側と入る側で別の規則を持っている。
3016
+ * `fan-origin-single-point` は出る側の起点が 1 点 (±0.5) に収束することを要求し、 その形は
3017
+ * 「1 つの節から複数の線が発散する」 という意味を持つ (`CAR-429`)。 入る側には対応する要求が
3018
+ * 無く、 `arrow-endpoint-center` が 4 隅を避けることだけを見る。 重なりは意図した形ではなく
3019
+ * 副産物なので、 入る側だけを直す。
3020
+ *
3021
+ * **経路が確定した後に当てる**。 終点を動かしてから経路を引かせると、 引き方の分岐そのものが
3022
+ * 変わる = 真横に引けるようになった線が箱の迂回をやめ、 実測で `edge-node-cross` が 0 件から
3023
+ * 7 件に増えた。 引き終わった経路の末尾だけを差し替えれば、 迂回の判断は動かない。
3024
+ *
3025
+ * 末尾は「辺に沿って助走 → 辺へ垂直に入る」 の 2 区間にする。 最後の区間を辺に垂直なまま
3026
+ * 残すのは、 矢頭が最後の区間の向きで描かれるため = 辺に平行にすると節に刺さらなくなる。
3027
+ *
3028
+ * 離す量は辺に収まる範囲で決める。 4 隅に刺さらないよう両端に `ARROW_ENDPOINT_CENTER_TOL`
3029
+ * ぶんの余白を残し、 その中で `ENDPOINT_FAN_STEP` ずつ離す。 本数が多くて収まらない時は間隔を
3030
+ * 詰め、 詰めても見分けられないなら離さない。
3031
+ *
3032
+ * 差し替えた末尾が箱に当たる線と、 交差を増やす線は元のままにする (どちらも重なりより重い)。
3033
+ *
3034
+ * **束の中で離れるのは 1 本前後にとどまる**。 複数の線は辺の中央へ向かって収束してから
3035
+ * 分かれるので、 分かれる点が交差として数えられる。 端点の接触を除く数え方も試したが、
3036
+ * 離れる本数は増えないまま別の図で交差が出た。 収束する前の段階で分けるには経路の引き方
3037
+ * そのものを変える必要があり、 それは後処理では届かない。
3038
+ */
3039
+ export function fanIncomingEndpoints(edges: LaidEdge[], nodes: readonly LaidNode[]): void {
3040
+ if (edges.length < 2) return;
3041
+ const nmap = new Map(nodes.map((n) => [n.id, n]));
3042
+
3043
+ /** 同じ 2 点を結ぶ線の本数。 その組は弓が扱うので、 ここでは触らない。 */
3044
+ const 同じ組の数 = new Map<string, number>();
3045
+ for (const e of edges) 同じ組の数.set(`${e.from}->${e.to}`, (同じ組の数.get(`${e.from}->${e.to}`) ?? 0) + 1);
3046
+
3047
+ /** 同じ節の同じ辺に入る線。 */
3048
+ const 束 = new Map<string, LaidEdge[]>();
3049
+ for (const e of edges) {
3050
+ if (!nmap.has(e.to)) continue;
3051
+ // 同じ 2 点を結ぶ線は `bowSharedStraightPaths` が弓で分ける範囲。 `#941` は
3052
+ // 「どの幅でも箱に当たるなら分離せず重なりを残す」 と決めており、 末尾で分けると
3053
+ // その判断を迂回してしまう。
3054
+ if ((同じ組の数.get(`${e.from}->${e.to}`) ?? 0) > 1) continue;
3055
+ const key = `${e.to}::${e.toSide}`;
3056
+ const list = 束.get(key) ?? [];
3057
+ list.push(e);
3058
+ 束.set(key, list);
3059
+ }
3060
+
3061
+ for (const [, list] of 束) {
3062
+ if (list.length < 2) continue;
3063
+ const n = nmap.get(list[0]!.to)!;
3064
+ const 横 = list[0]!.toSide === "left" || list[0]!.toSide === "right";
3065
+ const 辺の長さ = 横 ? n.h : n.w;
3066
+ // 4 隅に刺さらない範囲。 `arrow-endpoint-center` が見ているのと同じ余白を空ける。
3067
+ const 使える幅 = 辺の長さ - 2 * ARROW_ENDPOINT_CENTER_TOL;
3068
+ if (使える幅 <= 0) continue;
3069
+ const 間隔 = Math.min(ENDPOINT_FAN_STEP, 使える幅 / (list.length - 1));
3070
+ if (間隔 < ENDPOINT_FAN_MIN) continue;
3071
+
3072
+ // 来た高さの順に割り当てる。 上を走ってきた線を上の端点へ入れれば、 束の中で交差しない。
3073
+ // 図の並び順で割り当てると、 上を走る線が下の端点へ回って必ず交差する。
3074
+ const 並び = list
3075
+ .map((e) => ({ e, 深さ: 辺から離れた側の位置(e, 横) ?? 0 }))
3076
+ .sort((a, b) => a.深さ - b.深さ || (a.e.id < b.e.id ? -1 : 1));
3077
+
3078
+ const 中心 = ((並び.length - 1) * 間隔) / 2;
3079
+ for (let k = 0; k < 並び.length; k++) {
3080
+ const d = k * 間隔 - 中心;
3081
+ if (Math.abs(d) < 0.001) continue;
3082
+ const e = 並び[k]!.e;
3083
+ const 差替 = 末尾を辺に沿ってずらす(e, 横, d, nodes);
3084
+ if (差替 === null) continue;
3085
+ // 交差が増えるなら戻す。 判定は軸と同じ数え方を使う = 独自の数え方は軸と食い違い、
3086
+ // 軸が 3 件を数えている状況で 0 件と判定して通してしまった (実測)。
3087
+ const 前 = e.d;
3088
+ const 前の交差 = countEdgeCrossings(edges).count;
3089
+ e.d = 差替;
3090
+ if (countEdgeCrossings(edges).count > 前の交差) e.d = 前;
3091
+ }
3092
+ }
3093
+ }
3094
+
3095
+ /**
3096
+ * 経路が辺へ入る直前に、 辺からどれだけ離れた側を走っていたか。
3097
+ *
3098
+ * 終点の座標では順が決まらない (辺に垂直に入るので全て同じ)。 終点から辺の外へ戻って、
3099
+ * 最初に辺沿い方向へ動いた区間の始点を見る = その線が「上から来たか下から来たか」 を表す。
3100
+ *
3101
+ * 辺沿いに動く区間が 1 つも無い (真っ直ぐ入るだけ) なら `null`。
3102
+ */
3103
+ function 辺から離れた側の位置(e: LaidEdge, 横: boolean): number | null {
3104
+ const segs = extractPathSegments(e.d);
3105
+ for (let i = segs.length - 1; i >= 0; i--) {
3106
+ const s = segs[i]!;
3107
+ const 辺沿いに動く = 横 ? Math.abs(s.y2 - s.y1) > 0.5 : Math.abs(s.x2 - s.x1) > 0.5;
3108
+ if (辺沿いに動く) return 横 ? s.y1 : s.x1;
3109
+ }
3110
+ return null;
3111
+ }
3112
+
3113
+ /**
3114
+ * 経路の末尾を「辺に沿って助走 → 辺へ垂直に入る」 の形に差し替える。
3115
+ *
3116
+ * 差し替えられない形なら `null` を返す。 弓 (`C`) を含む経路、 部分経路が 2 つ以上ある形、
3117
+ * 最後が辺へ垂直に入っていない形、 助走を取れないほど短い経路、 差し替えた先が箱に当たる
3118
+ * 場合が該当する。
3119
+ */
3120
+ function 末尾を辺に沿ってずらす(
3121
+ e: LaidEdge,
3122
+ 横: boolean,
3123
+ d: number,
3124
+ nodes: readonly LaidNode[],
3125
+ ): string | null {
3126
+ if ((e.d.match(/M/g) ?? []).length !== 1) return null;
3127
+ const segs = extractPathSegments(e.d);
3128
+ if (segs.length === 0) return null;
3129
+ if (segs.some((s) => s.cmd === "C")) return null;
3130
+
3131
+ const last = segs[segs.length - 1]!;
3132
+ if (last.cmd !== "L") return null;
3133
+ // 最後の区間は辺に垂直に入っている必要がある。 斜めに入る形は矢頭の向きが決まらない。
3134
+ const 垂直に入る = 横
3135
+ ? Math.abs(last.y2 - last.y1) < 0.5 && Math.abs(last.x2 - last.x1) > 0.5
3136
+ : Math.abs(last.x2 - last.x1) < 0.5 && Math.abs(last.y2 - last.y1) > 0.5;
3137
+ if (!垂直に入る) return null;
3138
+
3139
+ const 長さ = Math.hypot(last.x2 - last.x1, last.y2 - last.y1);
3140
+ if (長さ < ENDPOINT_FAN_RUNUP + 1) return null;
3141
+
3142
+ // 助走を始める点。 終点から辺の外へ `ENDPOINT_FAN_RUNUP` 戻る。
3143
+ const ux = (last.x1 - last.x2) / 長さ;
3144
+ const uy = (last.y1 - last.y2) / 長さ;
3145
+ const 折れ1 = { x: last.x2 + ux * ENDPOINT_FAN_RUNUP, y: last.y2 + uy * ENDPOINT_FAN_RUNUP };
3146
+ const 折れ2 = 横 ? { x: 折れ1.x, y: 折れ1.y + d } : { x: 折れ1.x + d, y: 折れ1.y };
3147
+ const 終点 = 横 ? { x: last.x2, y: last.y2 + d } : { x: last.x2 + d, y: last.y2 };
3148
+
3149
+ // 差し替えた 3 区間が箱に当たらないこと。 自分が繋ぐ箱は当たり判定から外す。
3150
+ const 新区間 = [
3151
+ { x1: last.x1, y1: last.y1, x2: 折れ1.x, y2: 折れ1.y },
3152
+ { x1: 折れ1.x, y1: 折れ1.y, x2: 折れ2.x, y2: 折れ2.y },
3153
+ { x1: 折れ2.x, y1: 折れ2.y, x2: 終点.x, y2: 終点.y },
3154
+ ];
3155
+ if (nodes.some((n) => n.id !== e.from && n.id !== e.to && segmentsCrossRect(新区間, nodeRect(n)))) {
3156
+ return null;
3157
+ }
3158
+
3159
+ // 最後の区間より前はそのまま書き戻す。 角の丸めの制御点も形ごと残す。
3160
+ const head = segs[0]!;
3161
+ const parts: string[] = [`M ${head.x1} ${head.y1}`];
3162
+ for (let i = 0; i < segs.length - 1; i++) {
3163
+ const s = segs[i]!;
3164
+ if (s.cmd === "Q") {
3165
+ const c = s.ctrl?.[0];
3166
+ parts.push(c ? `Q ${c.x} ${c.y}, ${s.x2} ${s.y2}` : `L ${s.x2} ${s.y2}`);
3167
+ continue;
3168
+ }
3169
+ parts.push(`L ${s.x2} ${s.y2}`);
3170
+ }
3171
+ // 助走の折れは丸めない。 短い 2 折れなので、 丸めると切り口が区間からはみ出す。
3172
+ parts.push(`L ${round1(折れ1.x)} ${round1(折れ1.y)}`);
3173
+ parts.push(`L ${round1(折れ2.x)} ${round1(折れ2.y)}`);
3174
+ parts.push(`L ${round1(終点.x)} ${round1(終点.y)}`);
3175
+ return parts.join(" ");
3176
+ }