Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/web/src/components/InteractiveGraphVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
})
Expand Down
40 changes: 32 additions & 8 deletions packages/web/src/lib/__tests__/edgeGeometry.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
});
});

Expand Down
40 changes: 31 additions & 9 deletions packages/web/src/lib/edgeGeometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

/**
Expand Down Expand Up @@ -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;
}
Loading