diff --git a/web/src/shared/utils/graph/hulls.test.ts b/web/src/shared/utils/graph/hulls.test.ts new file mode 100644 index 0000000..759d263 --- /dev/null +++ b/web/src/shared/utils/graph/hulls.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { computeClusterHulls, _test } from './hulls' + +const { HULL_PADDING, NODE_HALF_SIZE, padAroundNodes } = _test + +function minDistancePointToSegment( + px: number, + py: number, + ax: number, + ay: number, + bx: number, + by: number, +): number { + const abx = bx - ax + const aby = by - ay + const apx = px - ax + const apy = py - ay + const abLen2 = abx * abx + aby * aby + if (abLen2 === 0) return Math.hypot(apx, apy) + let t = (apx * abx + apy * aby) / abLen2 + t = Math.max(0, Math.min(1, t)) + const qx = ax + t * abx + const qy = ay + t * aby + return Math.hypot(px - qx, py - qy) +} + +/** Minimum distance from a point to the boundary of a closed polygon. */ +function minDistanceToPolygon(px: number, py: number, poly: { x: number; y: number }[]): number { + let min = Infinity + for (let i = 0; i < poly.length; i++) { + const a = poly[i] + const b = poly[(i + 1) % poly.length] + min = Math.min(min, minDistancePointToSegment(px, py, a.x, a.y, b.x, b.y)) + } + return min +} + +function layoutOf(coords: Record) { + const ids = Object.keys(coords) + const positions = new Map(ids.map((id) => [id, { x: coords[id][0], y: coords[id][1] }])) + return { ids, positions } +} + +describe('computeClusterHulls', () => { + it('keeps at least HULL_PADDING clearance from every node on a 3-node line', () => { + // Near-collinear 3-node cluster (the overshoot case from #2) + const component = layoutOf({ + a: [0, 0], + b: [100, 2], + c: [200, -1], + }) + const hulls = computeClusterHulls([component], component.positions) + expect(hulls).toHaveLength(1) + const poly = hulls[0].points + + for (const id of component.ids) { + const p = component.positions.get(id)! + const dist = minDistanceToPolygon(p.x, p.y, poly) + expect(dist).toBeGreaterThanOrEqual(HULL_PADDING - 1e-6) + } + }) + + it('keeps clearance on a 4-node triangular cluster (edge nodes included)', () => { + // Three corners + one node near an edge (reviewer case: node on boundary) + const component = layoutOf({ + a: [0, 0], + b: [200, 0], + c: [80, 150], + d: [100, 5], // sits almost on the ab edge + }) + const hulls = computeClusterHulls([component], component.positions) + const poly = hulls[0].points + + for (const id of component.ids) { + const p = component.positions.get(id)! + const dist = minDistanceToPolygon(p.x, p.y, poly) + expect(dist).toBeGreaterThanOrEqual(HULL_PADDING - 1e-6) + } + // Visible gap outside the node circle + expect(HULL_PADDING - NODE_HALF_SIZE).toBeGreaterThanOrEqual(16) + }) + + it('does not produce huge perpendicular overshoot on a collinear triple', () => { + const poly = padAroundNodes([ + [0, 0], + [100, 0], + [200, 0], + ]) + const ys = poly.map((p) => p.y) + const height = Math.max(...ys) - Math.min(...ys) + // Square inflation → height exactly 2 * HULL_PADDING (no centroid blow-up) + expect(height).toBeCloseTo(2 * HULL_PADDING, 6) + // Span along x should be 200 + 2 * padding + const xs = poly.map((p) => p.x) + expect(Math.max(...xs) - Math.min(...xs)).toBeCloseTo(200 + 2 * HULL_PADDING, 6) + }) + + it('assigns rotating color indices per component', () => { + const a = layoutOf({ a: [0, 0], b: [10, 0] }) + const b = layoutOf({ c: [50, 50], d: [60, 50] }) + const packed = new Map([...a.positions, ...b.positions]) + const hulls = computeClusterHulls([a, b], packed) + expect(hulls.map((h) => h.colorIndex)).toEqual([0, 1]) + }) +}) \ No newline at end of file diff --git a/web/src/shared/utils/graph/hulls.ts b/web/src/shared/utils/graph/hulls.ts index 369c652..7b1ed6e 100644 --- a/web/src/shared/utils/graph/hulls.ts +++ b/web/src/shared/utils/graph/hulls.ts @@ -1,4 +1,4 @@ -import { polygonHull, polygonCentroid } from 'd3-polygon' +import { polygonHull } from 'd3-polygon' import type { Position } from './types' export interface ClusterHull { @@ -6,16 +6,30 @@ export interface ClusterHull { colorIndex: number } -const HULL_PADDING = 36 +// Half of the 44px session node rendered in SessionGraph / ClusterHulls. +// Hulls are computed from top-left anchors; ClusterHulls shifts by this +// amount so the path is centered on the visible circles. +const NODE_HALF_SIZE = 22 +// Extra air gap outside each node circle so the hull never appears to +// sit on (or clip through) the node boundary. +const HULL_MARGIN = 20 +/** Clearance from each node anchor to the hull — node radius + visible margin. */ +const HULL_PADDING = NODE_HALF_SIZE + HULL_MARGIN const HULL_COLOR_COUNT = 5 /** * Computes a padded convex hull polygon per multi-node cluster, using each - * cluster's final (packed) node positions. Padding is applied by pushing - * every hull point outward from the hull's centroid, so the shape visually - * surrounds the nodes with some margin rather than hugging them tightly. - * Clusters with only 2 nodes (no true hull, d3-polygon needs 3+ points) - * fall back to a small rectangle around the two points. + * cluster's final (packed) node positions. + * + * Padding is the convex hull of axis-aligned squares of half-side + * HULL_PADDING around every node anchor. That is equivalent to a Minkowski + * sum of the point set with a square, and it: + * - guarantees at least HULL_PADDING clearance from every node (so the + * rendered 44px circles never sit on the hull edge) + * - naturally becomes a padded bounding rectangle for near-collinear + * layouts, avoiding the huge perpendicular overshoot that centroid + * radial expansion produced on razor-thin hulls + * Clusters with only one distinct point still get a small square. */ export function computeClusterHulls( componentLayouts: { ids: string[]; positions: Map }[], @@ -27,25 +41,35 @@ export function computeClusterHulls( return [pos.x, pos.y] }) - const hull = polygonHull(points) const colorIndex = index % HULL_COLOR_COUNT - - if (!hull) { - return { points: padRectangle(points), colorIndex } - } - - const centroid = polygonCentroid(hull) - const padded = hull.map(([x, y]) => padPoint(x, y, centroid[0], centroid[1])) - - return { points: padded.map(([x, y]) => ({ x, y })), colorIndex } + return { points: padAroundNodes(points), colorIndex } }) } -function padPoint(x: number, y: number, centroidX: number, centroidY: number): [number, number] { - const dx = x - centroidX - const dy = y - centroidY - const length = Math.sqrt(dx * dx + dy * dy) || 1 - return [x + (dx / length) * HULL_PADDING, y + (dy / length) * HULL_PADDING] +/** + * Expand each node to a square of half-side HULL_PADDING and take the + * convex hull of all square corners. + */ +function padAroundNodes(points: [number, number][]): Position[] { + if (points.length === 0) return [] + + const inflated: [number, number][] = [] + for (const [x, y] of points) { + inflated.push( + [x - HULL_PADDING, y - HULL_PADDING], + [x + HULL_PADDING, y - HULL_PADDING], + [x + HULL_PADDING, y + HULL_PADDING], + [x - HULL_PADDING, y + HULL_PADDING], + ) + } + + const hull = polygonHull(inflated) + if (!hull) { + // Degenerate (should not happen with 4+ inflated corners); fall back + // to the AABB of the inflated set. + return padRectangle(points) + } + return hull.map(([x, y]) => ({ x, y })) } function padRectangle(points: [number, number][]): Position[] { @@ -62,4 +86,7 @@ function padRectangle(points: [number, number][]): Position[] { { x: maxX, y: maxY }, { x: minX, y: maxY }, ] -} \ No newline at end of file +} + +/** Exported for unit tests. */ +export const _test = { HULL_PADDING, NODE_HALF_SIZE, HULL_MARGIN, padAroundNodes } \ No newline at end of file