|
| 1 | +import { describe, it, expect } from "vitest" |
| 2 | +import { quadtree } from "d3-quadtree" |
| 3 | +import { findHitPointInQuadtree } from "./quadtreeHitTest" |
| 4 | + |
| 5 | +interface TestPoint { x: number; y: number; r: number; id: string } |
| 6 | + |
| 7 | +function makeQt(points: TestPoint[]) { |
| 8 | + return quadtree<TestPoint>().x(p => p.x).y(p => p.y).addAll(points) |
| 9 | +} |
| 10 | + |
| 11 | +describe("findHitPointInQuadtree", () => { |
| 12 | + it("returns the point under the cursor for a simple uniform-radius case", () => { |
| 13 | + const points: TestPoint[] = [ |
| 14 | + { x: 10, y: 10, r: 5, id: "a" }, |
| 15 | + { x: 50, y: 50, r: 5, id: "b" }, |
| 16 | + { x: 90, y: 90, r: 5, id: "c" } |
| 17 | + ] |
| 18 | + const qt = makeQt(points) |
| 19 | + const hit = findHitPointInQuadtree(qt, 51, 51, 30, 5) |
| 20 | + expect(hit?.node.id).toBe("b") |
| 21 | + }) |
| 22 | + |
| 23 | + it("returns null when no point is within its own hit radius", () => { |
| 24 | + const points: TestPoint[] = [{ x: 10, y: 10, r: 5, id: "a" }] |
| 25 | + const qt = makeQt(points) |
| 26 | + // Cursor is 200px away — far outside any possible hit radius |
| 27 | + const hit = findHitPointInQuadtree(qt, 200, 200, 30, 5) |
| 28 | + expect(hit).toBeNull() |
| 29 | + }) |
| 30 | + |
| 31 | + it("prefers a farther large-radius point over a nearer small-radius miss (the core bug)", () => { |
| 32 | + // Without the visit-all-candidates approach, quadtree.find would return |
| 33 | + // the nearest point (`small`), reject it for distance > hitRadius, and |
| 34 | + // miss the bigger point behind it that *does* contain the cursor. |
| 35 | + // getHitRadius(r, 30) = max(r + 5, 12, 30). |
| 36 | + const points: TestPoint[] = [ |
| 37 | + { x: 10, y: 0, r: 1, id: "small" }, // dist 40, hitRadius 30 → MISS |
| 38 | + { x: 110, y: 0, r: 80, id: "big" } // dist 60, hitRadius 85 → HIT |
| 39 | + ] |
| 40 | + const qt = makeQt(points) |
| 41 | + const hit = findHitPointInQuadtree(qt, 50, 0, 30, 80) |
| 42 | + expect(hit?.node.id).toBe("big") |
| 43 | + }) |
| 44 | + |
| 45 | + it("among multiple hits returns the closest by distance", () => { |
| 46 | + const points: TestPoint[] = [ |
| 47 | + { x: 100, y: 100, r: 30, id: "far" }, // 30px away, hitRadius ≥ 35 |
| 48 | + { x: 108, y: 100, r: 20, id: "near" } // 22px away, hitRadius ≥ 25 |
| 49 | + ] |
| 50 | + const qt = makeQt(points) |
| 51 | + const hit = findHitPointInQuadtree(qt, 130, 100, 30, 30) |
| 52 | + expect(hit?.node.id).toBe("near") |
| 53 | + }) |
| 54 | + |
| 55 | + it("prunes subtrees outside the search radius (perf smoke — no bound violation)", () => { |
| 56 | + // Build a large scatter with only one point near the cursor. If pruning |
| 57 | + // works, we're still correct; if it doesn't, we're also correct but slow. |
| 58 | + // This test just confirms correctness at scale. |
| 59 | + const points: TestPoint[] = [] |
| 60 | + for (let i = 0; i < 10000; i++) { |
| 61 | + points.push({ x: 1000 + (i % 100), y: 1000 + Math.floor(i / 100), r: 2, id: `bg${i}` }) |
| 62 | + } |
| 63 | + points.push({ x: 50, y: 50, r: 5, id: "target" }) |
| 64 | + const qt = makeQt(points) |
| 65 | + const hit = findHitPointInQuadtree(qt, 50, 50, 30, 5) |
| 66 | + expect(hit?.node.id).toBe("target") |
| 67 | + }) |
| 68 | + |
| 69 | + it("handles co-located points via the leaf linked list", () => { |
| 70 | + // d3-quadtree stores points at identical coordinates as a `.next` list. |
| 71 | + const points: TestPoint[] = [ |
| 72 | + { x: 50, y: 50, r: 5, id: "a" }, |
| 73 | + { x: 50, y: 50, r: 5, id: "b" }, |
| 74 | + { x: 50, y: 50, r: 5, id: "c" } |
| 75 | + ] |
| 76 | + const qt = makeQt(points) |
| 77 | + const hit = findHitPointInQuadtree(qt, 50, 50, 30, 5) |
| 78 | + // Any of the three is an acceptable hit — they're all at dist=0. |
| 79 | + expect(hit?.node.id).toMatch(/^[abc]$/) |
| 80 | + expect(hit?.distance).toBe(0) |
| 81 | + }) |
| 82 | +}) |
0 commit comments