Spatial Hashing
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.
spatial-hash.ts
import type { Tracer } from "../trace";/** A point on the plane; a Vec2 will do. */export interface SpatialPoint { readonly x: number; readonly y: number;}/** One cell of the grid, by its integer column and row. */export interface SpatialCell { readonly cx: number; readonly column: number; readonly cy: number; readonly row: number;}/** A point the query reached and how far away it was. */export interface SpatialHit { readonly index: number; readonly distance: number;}/** * A spatial hash: the plane cut into square cells of one size, each * cell a bucket of the points inside it, keyed by its integer column * and row. Inserting a point is one floor division per axis; finding * the points within a radius of a place probes only the cells the * radius touches and measures the points in them, so a query costs * the candidates in those cells rather than every point there is. * The grid is a map, not an array, so an empty region costs nothing * and the plane has no edges. A cell as wide as the radius probes at * most nine cells; a far smaller cell probes many empty ones, a far * larger one measures far more candidates than hits. * * Time: O(1) to insert; a query is O(cells probed + candidates), near * O(1) for a sparse, even scatter and O(n) for n points in one cell. * Space: O(n + occupied cells). */export class SpatialHash { private readonly buckets = new Map<string, number[]>(); private readonly points: SpatialPoint[] = []; constructor( readonly cellSize: number, private readonly tracer?: Tracer, ) { if (!(cellSize > 0) || !Number.isFinite(cellSize)) { throw new RangeError(`cellSize must be positive, got ${cellSize}`); } } /** How many points are in the hash. */ get size(): number { return this.points.length; } /** The cell a point falls in: the floor of each coordinate over the cell size. */ cellOf(point: SpatialPoint): SpatialCell { return { cx: Math.floor(point.x / this.cellSize), cy: Math.floor(point.y / this.cellSize) }; return { column: Math.floor(point.x / this.cellSize), row: Math.floor(point.y / this.cellSize) }; } /** Every occupied cell with how many points it holds, in insertion order of first occupancy. */ cells(): { cell: SpatialCell; count: number }[] { return [...this.buckets.entries()].map(([key, ids]) => ({ cell: SpatialHash.parse(key), count: ids.length })); return [...this.buckets.entries()].map(([key, bucket]) => ({ cell: SpatialHash.parse(key), count: bucket.length })); } /** The point at an index. */ at(index: number): SpatialPoint { const point = this.points[index]; if (!point) throw new RangeError(`no point ${index}`); return point; } /** Files a point under its cell; returns its index. */ insert(point: SpatialPoint): number { const index = this.points.length; this.points.push(point); const { cx, cy } = this.cellOf(point); const { column, row } = this.cellOf(point); const key = SpatialHash.key(cx, cy); const key = SpatialHash.key(column, row); const ids = this.buckets.get(key) ?? []; const bucket = this.buckets.get(key) ?? []; ids.push(index); bucket.push(index); this.buckets.set(key, ids); this.buckets.set(key, bucket); // ▸ file the point under the cell its coordinates floor to this.tracer?.emit("bucket", { index, x: point.x, y: point.y, cx, cy, size: ids.length }); this.tracer?.emit("bucket", { index, x: point.x, y: point.y, column, row, size: bucket.length }); return index; } /** * The points within `radius` of `point`, by index with their * distances, probing only the cells the radius reaches. Candidates * come out in cell order, row by row. */ query(point: SpatialPoint, radius: number): SpatialHit[] { if (!(radius >= 0)) throw new RangeError(`radius must not be negative, got ${radius}`); const low = this.cellOf({ x: point.x - radius, y: point.y - radius }); const high = this.cellOf({ x: point.x + radius, y: point.y + radius }); const hits: SpatialHit[] = []; let probed = 0; let candidates = 0; for (let cy = low.cy; cy <= high.cy; cy++) { for (let row = low.row; row <= high.row; row++) { for (let cx = low.cx; cx <= high.cx; cx++) { for (let column = low.column; column <= high.column; column++) { const ids = this.buckets.get(SpatialHash.key(cx, cy)) ?? []; const bucket = this.buckets.get(SpatialHash.key(column, row)) ?? []; probed += 1; // ▸ probe one cell the radius touches, row by row this.tracer?.emit("probe", { cx, cy, size: ids.length, probed }); this.tracer?.emit("probe", { column, row, size: bucket.length, probed }); for (const index of ids) { for (const index of bucket) { const p = this.points[index]!; const stored = this.points[index]!; const d = Math.hypot(p.x - point.x, p.y - point.y); const howFar = Math.hypot(stored.x - point.x, stored.y - point.y); const within = d <= radius; const within = howFar <= radius; candidates += 1; // ▸ measure a point in the cell against the probe this.tracer?.emit("candidate", { index, x: p.x, y: p.y, distance: d, within, candidates }); this.tracer?.emit("candidate", { index, x: stored.x, y: stored.y, distance: howFar, within, candidates }); if (within) { hits.push({ index, distance: d }); hits.push({ index, distance: howFar }); // ▸ within the radius: keep it this.tracer?.emit("hit", { index, distance: d, hits: hits.length }); this.tracer?.emit("hit", { index, distance: howFar, hits: hits.length }); } } } } return hits; } /** The same answer by measuring every point: the check a hash is judged against. */ static near(points: readonly SpatialPoint[], point: SpatialPoint, radius: number): SpatialHit[] { const hits: SpatialHit[] = []; points.forEach((p, index) => { points.forEach((stored, index) => { const distance = Math.hypot(p.x - point.x, p.y - point.y); const distance = Math.hypot(stored.x - point.x, stored.y - point.y); if (distance <= radius) hits.push({ index, distance }); }); return hits; } private static key(cx: number, cy: number): string { private static key(column: number, row: number): string { return `${cx},${cy}`; } private static parse(key: string): SpatialCell { const [cx, cy] = key.split(",").map(Number); const [column, row] = key.split(",").map(Number); return { cx: cx ?? 0, cy: cy ?? 0 }; return { column: column ?? 0, row: row ?? 0 }; }}1 hit from 1 candidate in 9 cells probed, where measuring every point would have taken 24 checks. 21 cells hold anything at all.