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
105 changes: 105 additions & 0 deletions web/src/shared/utils/graph/hulls.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, [number, number]>) {
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])
})
})
73 changes: 50 additions & 23 deletions web/src/shared/utils/graph/hulls.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,35 @@
import { polygonHull, polygonCentroid } from 'd3-polygon'
import { polygonHull } from 'd3-polygon'
import type { Position } from './types'

export interface ClusterHull {
points: Position[]
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<string, Position> }[],
Expand All @@ -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[] {
Expand All @@ -62,4 +86,7 @@ function padRectangle(points: [number, number][]): Position[] {
{ x: maxX, y: maxY },
{ x: minX, y: maxY },
]
}
}

/** Exported for unit tests. */
export const _test = { HULL_PADDING, NODE_HALF_SIZE, HULL_MARGIN, padAroundNodes }
Loading