Quadtrees
in reviewThis lesson is complete and being validated before release - details may still change.
in reviewThis lesson is complete and being validated before release - details may still change.
quadtree.ts
import { Rectangle, type Point } from "../navigation/geometry";import type { Tracer } from "../trace";/** The four quadrants of a node, by index: the x bit is 1 for east, the y bit is 2 for north (y up). */export const QUADTREE_QUADRANTS = ["sw", "se", "nw", "ne"] as const;export type Quadrant = (typeof QUADTREE_QUADRANTS)[number];export interface QuadtreeOptions { /** How many points a leaf holds before it splits. */ capacity: number; /** A leaf this deep never splits, so coincident points have somewhere to live (default 8). */ maxDepth?: number;}/** A node of the tree: its box, the points it stores (indices into the tree's points), and its four children once split. */export interface QuadNode { readonly id: number; readonly depth: number; readonly parent: number; readonly bounds: Rectangle; readonly points: readonly number[]; /** The children by quadrant index, or null while the node is a leaf. */ readonly children: readonly [number, number, number, number] | null;}interface MutableNode { id: number; depth: number; parent: number; bounds: Rectangle; points: number[]; children: [number, number, number, number] | null;}/** * A point quadtree: a box that holds a few points, and splits into * four quadrants when it holds one too many, each quadrant a box that * does the same. Inserting a point walks down from the root to the * leaf whose box holds it; a leaf that is full splits first, handing * its points to the quadrants they fall in, and the walk goes on. * Querying a range walks the same tree, but skips every node whose * box misses the range, so only the points in nearby leaves are ever * checked. Where a spatial hash sizes its cells once, the tree sizes * them as the points demand: dense where the points crowd, coarse * where they are sparse. * * Time: O(log n) to insert or to reach one leaf on well-spread points, * O(k + visited nodes) for a query returning k points; the depth cap * keeps coincident points from splitting forever. */export class Quadtree { private readonly tree: MutableNode[] = []; private readonly stored: Point[] = []; readonly capacity: number; readonly maxDepth: number; constructor( readonly bounds: Rectangle, options: QuadtreeOptions, private readonly tracer?: Tracer, ) { if (!Number.isInteger(options.capacity) || options.capacity < 1) throw new RangeError(`capacity must be a positive integer, got ${options.capacity}`); const maxDepth = options.maxDepth ?? 8; if (!Number.isInteger(maxDepth) || maxDepth < 1) throw new RangeError(`maxDepth must be a positive integer, got ${maxDepth}`); this.capacity = options.capacity; this.maxDepth = maxDepth; this.tree.push({ id: 0, depth: 0, parent: -1, bounds, points: [], children: null }); } /** The nodes in the order they were made: the root first, each split's four children together. */ get nodes(): readonly QuadNode[] { return this.tree; } /** The points inserted so far, in order. */ get points(): readonly Point[] { return this.stored; } /** How deep the tree goes, the root being depth 0. */ get depth(): number { return this.tree.reduce((deepest, node) => Math.max(deepest, node.depth), 0); } /** Which quadrant of a box a point falls in: on the centre lines it goes east and north. */ static quadrant(bounds: Rectangle, p: Point): number { const { x, y } = bounds.centroid; return (p.x >= x ? 1 : 0) + (p.y >= y ? 2 : 0); } /** The box of one quadrant of a node's box. */ static quadrantBounds(bounds: Rectangle, q: number): Rectangle { static quadrantBounds(bounds: Rectangle, quadrantIndex: number): Rectangle { const w = bounds.width / 2; const h = bounds.height / 2; return new Rectangle(bounds.x + (q & 1 ? w : 0), bounds.y + (q & 2 ? h : 0), w, h); return new Rectangle(bounds.x + (quadrantIndex & 1 ? w : 0), bounds.y + (quadrantIndex & 2 ? h : 0), w, h); } /** Every point inside the range, by index, checking them all: what the tree saves. */ static bruteForce(points: readonly Point[], range: Rectangle): number[] { const hits: number[] = []; points.forEach((p, i) => { if (range.contains(p)) hits.push(i); }); return hits; } /** Inserts a point and returns the index it is stored under; a point outside the root's box is refused. */ insert(point: Point): number { if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) throw new RangeError("a point needs finite coordinates"); if (!this.bounds.contains(point)) throw new RangeError(`(${point.x}, ${point.y}) lies outside the tree's box`); const idx = this.stored.length; const pointIndex = this.stored.length; this.stored.push({ x: point.x, y: point.y }); let node = this.tree[0]!; for (;;) { while (node.children) { const q = Quadtree.quadrant(node.bounds, point); const quadrantIndex = Quadtree.quadrant(node.bounds, point); const child = this.tree[node.children[q]!]!; const child = this.tree[node.children[quadrantIndex]!]!; // ▸ the node has children: step into the quadrant the point falls in this.tracer?.emit("descend", { point: idx, from: node.id, to: child.id, depth: child.depth, quadrant: QUADTREE_QUADRANTS[q] }); this.tracer?.emit("descend", { point: pointIndex, from: node.id, to: child.id, depth: child.depth, quadrant: QUADTREE_QUADRANTS[quadrantIndex] }); node = child; } if (node.points.length < this.capacity || node.depth >= this.maxDepth) { node.points.push(idx); node.points.push(pointIndex); // ▸ a leaf with room, or one at the depth cap: the point lives here this.tracer?.emit("store", { point: idx, node: node.id, depth: node.depth, count: node.points.length }); this.tracer?.emit("store", { point: pointIndex, node: node.id, depth: node.depth, count: node.points.length }); return idx; return pointIndex; } this.split(node); } } /** Every stored point inside the range, by index, visiting only the nodes whose box overlaps it. */ query(range: Rectangle): number[] { const hits: number[] = []; this.search(this.tree[0]!, range, hits); return hits; } /** A full leaf becomes four quadrants, each taking the points that fall in it. */ private split(node: MutableNode): void { const kids: number[] = []; const childIds: number[] = []; for (let q = 0; q < 4; q++) { for (let quadrantIndex = 0; quadrantIndex < 4; quadrantIndex++) { const id = this.tree.length; kids.push(id); childIds.push(id); this.tree.push({ id, depth: node.depth + 1, parent: node.id, bounds: Quadtree.quadrantBounds(node.bounds, q), points: [], children: null }); this.tree.push({ id, depth: node.depth + 1, parent: node.id, bounds: Quadtree.quadrantBounds(node.bounds, quadrantIndex), points: [], children: null }); } const children: [number, number, number, number] = [kids[0]!, kids[1]!, kids[2]!, kids[3]!]; const children: [number, number, number, number] = [childIds[0]!, childIds[1]!, childIds[2]!, childIds[3]!]; for (const idx of node.points) { for (const pointIndex of node.points) { const q = Quadtree.quadrant(node.bounds, this.stored[idx]!); const quadrantIndex = Quadtree.quadrant(node.bounds, this.stored[pointIndex]!); this.tree[children[q]!]!.points.push(idx); this.tree[children[quadrantIndex]!]!.points.push(pointIndex); } const moved = node.points.length; node.points = []; node.children = children; // ▸ a full leaf becomes four quadrants, each taking the points that fall in it this.tracer?.emit("split", { node: node.id, depth: node.depth, children, moved }); } /** Whether two boxes share any point, edges included: a point on a node's edge is in that node and may be in the range. */ static touches(a: Rectangle, b: Rectangle): boolean { return a.x <= b.right && b.x <= a.right && a.y <= b.top && b.y <= a.top; } private search(node: MutableNode, range: Rectangle, hits: number[]): void { if (!Quadtree.touches(node.bounds, range)) { // ▸ the node's box misses the range: nothing in it can be a hit this.tracer?.emit("skip", { node: node.id, depth: node.depth }); return; } // ▸ the box touches the range: look at what is stored here, then at the children this.tracer?.emit("visit", { node: node.id, depth: node.depth, leaf: node.children === null, points: node.points.length }); for (const idx of node.points) { for (const pointIndex of node.points) { const p = this.stored[idx]!; const p = this.stored[pointIndex]!; const inside = range.contains(p); // ▸ a point in a touching box: is it actually inside the range? this.tracer?.emit("candidate", { point: idx, node: node.id, x: p.x, y: p.y, inside }); this.tracer?.emit("candidate", { point: pointIndex, node: node.id, x: p.x, y: p.y, inside }); if (!inside) continue; hits.push(idx); hits.push(pointIndex); // ▸ it is: one more hit this.tracer?.emit("hit", { point: idx, x: p.x, y: p.y, hits: hits.length }); this.tracer?.emit("hit", { point: pointIndex, x: p.x, y: p.y, hits: hits.length }); } if (node.children) for (const id of node.children) this.search(this.tree[id]!, range, hits); }}Done: 25 nodes to depth 3; the query checked 3 of 32 points and found 1 hit. Brute force would have checked all 32.