From 51e0fd99ba3681dd2e5fdf41a1fbb4ddab2d773f Mon Sep 17 00:00:00 2001 From: Vedant Madane <6527493+VedantMadane@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:33:41 +0530 Subject: [PATCH 1/2] fix: Use rectangle hull fallback for near-collinear clusters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detect thin convex hulls (low area/perimeter²) common in 3-node layouts - Fall back to padded bounding rectangle instead of centroid expansion overshoot Fixes #2 Signed-off-by: Vedant Madane <6527493+VedantMadane@users.noreply.github.com> --- web/src/shared/utils/graph/hulls.ts | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/web/src/shared/utils/graph/hulls.ts b/web/src/shared/utils/graph/hulls.ts index 369c652..a42d329 100644 --- a/web/src/shared/utils/graph/hulls.ts +++ b/web/src/shared/utils/graph/hulls.ts @@ -8,6 +8,8 @@ export interface ClusterHull { const HULL_PADDING = 36 const HULL_COLOR_COUNT = 5 +/** If the thinness ratio (area / perimeter^2) is below this, treat as near-collinear. */ +const COLLINEAR_AREA_RATIO = 1e-4 /** * Computes a padded convex hull polygon per multi-node cluster, using each @@ -16,6 +18,10 @@ const HULL_COLOR_COUNT = 5 * 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. + * + * Near-collinear 3+ node layouts (common for tiny force-simulated clusters) + * also use the rectangle fallback: centroid padding on a razor-thin hull + * produces huge perpendicular overshoot. */ export function computeClusterHulls( componentLayouts: { ids: string[]; positions: Map }[], @@ -30,7 +36,7 @@ export function computeClusterHulls( const hull = polygonHull(points) const colorIndex = index % HULL_COLOR_COUNT - if (!hull) { + if (!hull || isNearlyCollinear(hull)) { return { points: padRectangle(points), colorIndex } } @@ -41,6 +47,23 @@ export function computeClusterHulls( }) } +function isNearlyCollinear(hull: [number, number][]): boolean { + if (hull.length < 3) return true + // Shoelace area + let area2 = 0 + let peri = 0 + for (let i = 0; i < hull.length; i++) { + const [x1, y1] = hull[i] + const [x2, y2] = hull[(i + 1) % hull.length] + area2 += x1 * y2 - x2 * y1 + peri += Math.hypot(x2 - x1, y2 - y1) + } + const area = Math.abs(area2) / 2 + if (peri <= 0) return true + // Dimensionless thinness; collinear/flat hulls have near-zero area. + return area / (peri * peri) < COLLINEAR_AREA_RATIO +} + function padPoint(x: number, y: number, centroidX: number, centroidY: number): [number, number] { const dx = x - centroidX const dy = y - centroidY @@ -62,4 +85,4 @@ function padRectangle(points: [number, number][]): Position[] { { x: maxX, y: maxY }, { x: minX, y: maxY }, ] -} \ No newline at end of file +} From 843a77cc8659a683a7751af2f59047a970cbc7f2 Mon Sep 17 00:00:00 2001 From: Vedant Madane <6527493+VedantMadane@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:33:26 +0530 Subject: [PATCH 2/2] fix: Guarantee hull margin around every node via square inflation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centroid radial padding under-cleared hull edges, so nodes could sit on the boundary; the thin-hull rectangle fallback also left only ~14px past the 22px node radius. Expand each node to a square of half-side (node radius + 20px margin) and take the convex hull instead — that guarantees clearance on every node (including edge-sitting ones) and naturally becomes a padded AABB for near-collinear clusters without perpendicular overshoot. Add unit tests covering the 3-node line, 4-node triangle, and collinear height bound. Signed-off-by: Vedant Madane <6527493+VedantMadane@users.noreply.github.com> --- web/src/shared/utils/graph/hulls.test.ts | 105 +++++++++++++++++++++++ web/src/shared/utils/graph/hulls.ts | 88 ++++++++++--------- 2 files changed, 151 insertions(+), 42 deletions(-) create mode 100644 web/src/shared/utils/graph/hulls.test.ts 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 a42d329..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,22 +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 -/** If the thinness ratio (area / perimeter^2) is below this, treat as near-collinear. */ -const COLLINEAR_AREA_RATIO = 1e-4 /** * 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. * - * Near-collinear 3+ node layouts (common for tiny force-simulated clusters) - * also use the rectangle fallback: centroid padding on a razor-thin hull - * produces huge perpendicular overshoot. + * 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 }[], @@ -33,42 +41,35 @@ export function computeClusterHulls( return [pos.x, pos.y] }) - const hull = polygonHull(points) const colorIndex = index % HULL_COLOR_COUNT - - if (!hull || isNearlyCollinear(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 isNearlyCollinear(hull: [number, number][]): boolean { - if (hull.length < 3) return true - // Shoelace area - let area2 = 0 - let peri = 0 - for (let i = 0; i < hull.length; i++) { - const [x1, y1] = hull[i] - const [x2, y2] = hull[(i + 1) % hull.length] - area2 += x1 * y2 - x2 * y1 - peri += Math.hypot(x2 - x1, y2 - y1) +/** + * 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 area = Math.abs(area2) / 2 - if (peri <= 0) return true - // Dimensionless thinness; collinear/flat hulls have near-zero area. - return area / (peri * peri) < COLLINEAR_AREA_RATIO -} -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] + 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[] { @@ -86,3 +87,6 @@ function padRectangle(points: [number, number][]): Position[] { { x: minX, y: maxY }, ] } + +/** Exported for unit tests. */ +export const _test = { HULL_PADDING, NODE_HALF_SIZE, HULL_MARGIN, padAroundNodes } \ No newline at end of file