diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 6879577..f235dc5 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -2007,8 +2007,14 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // needs to display — the label width sets a minimum edge length so // it always fits in the border-to-border gap (edgeGeometry.minEdgeLength). const label = getRelationshipConfig(d.type as RelationshipType)?.label || ''; - const labelW = label.length * 6.2 + 28; // 10px/600 text + icon + padding - const minLen = minEdgeLength(getNodeDimensions(d.source), getNodeDimensions(d.target), labelW); + // Slightly generous estimate of the rendered label box (10px/600 text + // + icon + padding) so the gap never UNDER-shoots the real label. + const labelW = label.length * 7 + 34; + // Pass the edge direction so the minimum is just the per-angle border + // reach + label + a small margin (not an oversized half-diagonal buffer). + const dx = (d.target.x || 0) - (d.source.x || 0); + const dy = (d.target.y || 0) - (d.source.y || 0); + const minLen = minEdgeLength(getNodeDimensions(d.source), getNodeDimensions(d.target), labelW, dx, dy); d._minLen = minLen; // cached for the hard min-edge constraint below return Math.max(preferred, minLen); }) diff --git a/packages/web/src/lib/__tests__/edgeGeometry.test.ts b/packages/web/src/lib/__tests__/edgeGeometry.test.ts index e080b68..9c4bfda 100644 --- a/packages/web/src/lib/__tests__/edgeGeometry.test.ts +++ b/packages/web/src/lib/__tests__/edgeGeometry.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { rectBorderPoint, edgeBorderEndpoints, halfDiagonal, minEdgeLength, clampToMinNeighbors } from '../edgeGeometry'; +import { rectBorderPoint, edgeBorderEndpoints, halfDiagonal, minEdgeLength, clampToMinNeighbors, borderReach, LABEL_MARGIN } from '../edgeGeometry'; describe('rectBorderPoint', () => { const dims = { width: 100, height: 60 }; // hw=50, hh=30 @@ -56,21 +56,45 @@ describe('edgeBorderEndpoints', () => { }); }); +describe('borderReach', () => { + const dims = { width: 170, height: 106 }; // hw=85, hh=53 + it('is the half-width for a horizontal edge', () => { + expect(borderReach(dims, 1, 0)).toBeCloseTo(85, 6); + }); + it('is the half-height for a vertical edge', () => { + expect(borderReach(dims, 0, 1)).toBeCloseTo(53, 6); + }); + it('never exceeds the half-diagonal (corner is the worst case)', () => { + for (const a of [0.2, 0.9, 1.4, 2.2]) { + expect(borderReach(dims, Math.cos(a), Math.sin(a))).toBeLessThanOrEqual(halfDiagonal(dims) + 1e-9); + } + }); +}); + describe('minEdgeLength', () => { it('is zero when there is no label', () => { expect(minEdgeLength({ width: 170, height: 105 }, { width: 170, height: 105 }, 0)).toBe(0); }); - it('guarantees the label fits in the border gap at any angle', () => { + it('leaves only a SMALL margin around the label, not an oversized buffer', () => { const a = { width: 170, height: 105 }; const b = { width: 160, height: 100 }; const labelW = 104; - const min = minEdgeLength(a, b, labelW, 16); - expect(min).toBeCloseTo(halfDiagonal(a) + halfDiagonal(b) + 104 + 16, 6); - // At the worst angle (toward a corner) the projections sum to the two half - // diagonals; the remaining gap must still cover the label. - const gap = min - halfDiagonal(a) - halfDiagonal(b); - expect(gap).toBeGreaterThanOrEqual(labelW); + // horizontal edge: the border-to-border gap should equal labelW + margin exactly. + const min = minEdgeLength(a, b, labelW, 1, 0); + const gap = min - borderReach(a, 1, 0) - borderReach(b, 1, 0); + expect(gap).toBeCloseTo(labelW + LABEL_MARGIN, 6); + // and it must be far tighter than the old half-diagonal buffer. + expect(min).toBeLessThan(halfDiagonal(a) + halfDiagonal(b) + labelW); + }); + + it('keeps the gap == label + margin at a vertical angle too', () => { + const a = { width: 170, height: 105 }; + const b = { width: 170, height: 105 }; + const labelW = 90; + const min = minEdgeLength(a, b, labelW, 0, 1); + const gap = min - borderReach(a, 0, 1) - borderReach(b, 0, 1); + expect(gap).toBeCloseTo(labelW + LABEL_MARGIN, 6); }); }); diff --git a/packages/web/src/lib/edgeGeometry.ts b/packages/web/src/lib/edgeGeometry.ts index 1c9485e..0d6f654 100644 --- a/packages/web/src/lib/edgeGeometry.ts +++ b/packages/web/src/lib/edgeGeometry.ts @@ -57,6 +57,25 @@ export function halfDiagonal(dims: Dims): number { return Math.hypot(dims.width, dims.height) / 2; } +/** A small margin kept around an edge label (px). */ +export const LABEL_MARGIN = 14; + +/** + * Distance from a card's center to where the edge crosses its border, along + * direction (dx,dy) — i.e. how much of the edge the card actually covers at + * this angle (NOT the worst-case corner). With direction unknown (0,0), falls + * back to the half short-side. Mirrors rectBorderPoint's reach. + */ +export function borderReach(dims: Dims, dx: number, dy: number): number { + const hw = dims.width / 2; + const hh = dims.height / 2; + if (dx === 0 && dy === 0) return Math.min(hw, hh); + const sx = dx !== 0 ? hw / Math.abs(dx) : Infinity; + const sy = dy !== 0 ? hh / Math.abs(dy) : Infinity; + const s = Math.min(sx, sy); + return Math.hypot(dx * s, dy * s); +} + export interface MinNeighbor { x: number; y: number; minLen: number; } /** @@ -90,20 +109,23 @@ export function clampToMinNeighbors(target: Pt, neighbors: MinNeighbor[], iterat } /** - * Minimum CENTER-to-CENTER distance so the edge label always fits in the - * border-to-border gap, at ANY angle. The visible gap = centerLen − proj(src) − - * proj(tgt); projection peaks at the half-diagonal (edge toward a corner), so - * requiring centerLen ≥ halfDiag(src) + halfDiag(tgt) + labelWidth + pad - * guarantees gap ≥ labelWidth regardless of how the nodes are oriented. - * - * Returns 0 for a zero-width label (no constraint). + * Minimum CENTER-to-CENTER distance so the edge label fits in the + * border-to-border gap with just a small margin — NOT an oversized buffer. + * The visible gap = centerLen − reach(src) − reach(tgt); using the per-angle + * border reach (pass the edge direction dx,dy) makes the gap = labelWidth + + * margin exactly. Returns 0 for a zero-width label (no constraint). */ export function minEdgeLength( sourceDims: Dims, targetDims: Dims, labelWidth: number, - pad = 16 + dx = 0, + dy = 0, + margin = LABEL_MARGIN ): number { if (!(labelWidth > 0)) return 0; - return halfDiagonal(sourceDims) + halfDiagonal(targetDims) + labelWidth + pad; + // Center distance whose BORDER-TO-BORDER gap is exactly labelWidth + a small + // margin. We use the per-angle border reach (not the half-diagonal), so the + // visible edge is just long enough for the label — no excessive buffer. + return borderReach(sourceDims, dx, dy) + borderReach(targetDims, dx, dy) + labelWidth + margin; }