Algorithms
Spatial index
Which rectangles touch a point, a marquee or a viewport: a packed R-tree in two typed arrays.
A search
Move the pointer: the dashed box is the query. The index finds what it touches by examining a small fraction of its boxes, wherever the query is.
72 found · 173 of 2134 boxes examined
Code
"use client";
import { spatialIndexFrom, spatialSearch } from "@publictheta/ui/algorithms/spatial-index.ts";
import type { SpatialIndex } from "@publictheta/ui/algorithms/spatial-index.ts";
import { useMemo, useState } from "react";
import { Readout } from "../../ui/controls.tsx";
const WIDTH = 640;
const HEIGHT = 360;
const COUNT = 2000;
const QUERY = 56;
/** A small deterministic generator, so the picture is the same on every visit. */
function random(seed: number): () => number {
let state = seed;
return () => {
state = (state * 1664525 + 1013904223) % 4294967296;
return state / 4294967296;
};
}
/** A search that also reports how many boxes it read, by reading through a counting view. */
function search(index: SpatialIndex, query: readonly [number, number, number, number]) {
let reads = 0;
const boxes = new Proxy(index.boxes, {
get(target, key) {
if (typeof key === "string" && /^\d+$/.test(key)) reads += 1;
return Reflect.get(target, key, target) as unknown;
},
});
const found = new Set(spatialSearch({ ...index, boxes }, ...query));
return { found, examined: Math.ceil(reads / 4) };
}
export default function SpatialSearch() {
const { rectangles, index } = useMemo(() => {
const next = random(7);
const flat = new Float64Array(COUNT * 4);
for (let item = 0; item < COUNT; item += 1) {
const width = 4 + next() * 14;
const height = 4 + next() * 14;
const x = next() * (WIDTH - width);
const y = next() * (HEIGHT - height);
flat.set([x, y, x + width, y + height], item * 4);
}
return { rectangles: flat, index: spatialIndexFrom(flat) };
}, []);
const [centre, setCentre] = useState({ x: WIDTH / 2, y: HEIGHT / 2 });
const query = [centre.x - QUERY, centre.y - QUERY / 2, centre.x + QUERY, centre.y + QUERY / 2] as const;
const { found, examined } = search(index, query);
return (
<div className="w-full">
<svg
role="img"
aria-label="Two thousand rectangles and a query box that follows the pointer"
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
className="w-full touch-none rounded-lg bg-surface ring-1 ring-border-muted"
onPointerMove={(event) => {
const bounds = event.currentTarget.getBoundingClientRect();
setCentre({
x: ((event.clientX - bounds.left) / bounds.width) * WIDTH,
y: ((event.clientY - bounds.top) / bounds.height) * HEIGHT,
});
}}
>
{Array.from({ length: COUNT }, (_, item) => (
<rect
key={item}
x={rectangles[item * 4]}
y={rectangles[item * 4 + 1]}
width={rectangles[item * 4 + 2]! - rectangles[item * 4]!}
height={rectangles[item * 4 + 3]! - rectangles[item * 4 + 1]!}
fill={found.has(item) ? "var(--ui-accent)" : "var(--ui-surface-strong)"}
/>
))}
<rect
x={query[0]}
y={query[1]}
width={QUERY * 2}
height={QUERY}
fill="none"
stroke="var(--ui-fg-strong)"
strokeWidth="1.5"
strokeDasharray="4 3"
/>
</svg>
<Readout>
{found.size} found · {examined} of {index.boxes.length / 4} boxes examined
</Readout>
</div>
);
}Behaviour
A search finds exactly the rectangles a check of every one would
fc.assert(
fc.property(
fc.array(rectangle, { maxLength: 200 }),
fc.array(rectangle, { minLength: 1, maxLength: 8 }),
fc.constantFrom(2, 4, 16),
(rectangles, queries, nodeSize) => {
const flat = rectangles.flat();
const index = spatialIndexFrom(flat, nodeSize);
return queries.every((query) => {
const found = spatialSearch(index, ...query).toSorted((a, b) => a - b);
const expected = touching(flat, query);
return found.length === expected.length && found.every((item, i) => item === expected[i]);
});
},
),
);Rectangles given as corner and size make the index their corners would
const index = spatialIndexFromRects([
{ x: 0, y: 0, width: 10, height: 10 },
{ x: 20, y: 5, width: 10, height: 25 },
]);
expect(index).toEqual(spatialIndexFrom([0, 0, 10, 10, 20, 5, 30, 30]));
expect(spatialSearch(index, 25, 28, 26, 29)).toEqual([1]);Rectangles that only touch the query's edge are found
const index = spatialIndexFrom([0, 0, 10, 10, 20, 20, 30, 30]);
expect(spatialSearch(index, 10, 10, 20, 20).toSorted()).toEqual([0, 1]);
expect(spatialSearch(index, 11, 11, 19, 19)).toEqual([]);A point is a query of no size
const index = spatialIndexFrom([0, 0, 10, 10, 5, 5, 15, 15]);
expect(spatialSearch(index, 7, 7, 7, 7).toSorted()).toEqual([0, 1]);
expect(spatialSearch(index, 12, 12, 12, 12)).toEqual([1]);A rectangle of no area — a line or a point — is found where it lies, edges included
const index = spatialIndexFrom([5, 5, 5, 5, 0, 20, 10, 20]);
expect(spatialSearch(index, 5, 5, 5, 5)).toEqual([0]);
expect(spatialSearch(index, 0, 0, 5, 5)).toEqual([0]);
expect(spatialSearch(index, 5, 5, 9, 9)).toEqual([0]);
expect(spatialSearch(index, 10, 20, 30, 30)).toEqual([1]);
expect(spatialSearch(index, 3, 0, 4, 30)).toEqual([1]);
expect(spatialSearch(index, 5.5, 5, 6, 6)).toEqual([]);A rectangle given with its corners the other way round is the same rectangle
fc.assert(
fc.property(
fc.array(fc.tuple(rectangle, fc.boolean(), fc.boolean()), { maxLength: 60 }),
rectangle,
(rectangles, query) => {
const straight = rectangles.flatMap(([box]) => box);
const turned = rectangles.flatMap(([[minX, minY, maxX, maxY], flipX, flipY]) => [
flipX ? maxX : minX,
flipY ? maxY : minY,
flipX ? minX : maxX,
flipY ? minY : maxY,
]);
const found = spatialSearch(spatialIndexFrom(turned, 4), ...query).toSorted((a, b) => a - b);
const expected = touching(straight, query);
return found.length === expected.length && found.every((item, i) => item === expected[i]);
},
),
);A query given with its corners the other way round is the same query
const index = spatialIndexFrom([0, 0, 10, 10, 20, 20, 30, 30, 50, 50, 60, 60]);
expect(spatialSearch(index, 25, 25, 5, 5).toSorted()).toEqual([0, 1]);
expect(spatialSearch(index, 5, 25, 25, 5).toSorted()).toEqual([0, 1]);A rectangle may reach without limit
const index = spatialIndexFrom([-Infinity, 40, Infinity, 40, 0, 0, 10, 10, 20, 20, 30, 30]);
expect(spatialSearch(index, 1000, 0, 1001, 100)).toEqual([0]);
expect(spatialSearch(index, 5, 5, 6, 6)).toEqual([1]);
expect(spatialSearch(index, -Infinity, -Infinity, Infinity, Infinity).toSorted()).toEqual([0, 1, 2]);A rectangle with a side that is not a number is an error, and so is such a query
for (let side = 0; side < 4; side += 1) {
const rectangles = [0, 0, 10, 10, 20, 20, 30, 30];
rectangles[4 + side] = Number.NaN;
expect(() => spatialIndexFrom(rectangles)).toThrow(RangeError);
const query: [number, number, number, number] = [0, 0, 5, 5];
query[side] = Number.NaN;
expect(() => spatialSearch(spatialIndexFrom([0, 0, 10, 10]), ...query)).toThrow(RangeError);
}Rectangles are four numbers each; a list that ends partway through one is an error
expect(() => spatialIndexFrom([0, 0, 10, 10, 20, 20])).toThrow(RangeError);A node holds at least two boxes; a node size below that, or not whole, is an error
const rectangles = [0, 0, 10, 10, 20, 20, 30, 30, 40, 40, 50, 50];
for (const nodeSize of [1.5, Number.NaN, Infinity, -4, 1, 0]) {
expect(() => spatialIndexFrom(rectangles, nodeSize)).toThrow("two or more");
}
expect(spatialSearch(spatialIndexFrom(rectangles, 2), 25, 25, 45, 45).toSorted()).toEqual([1, 2]);An index holds up to 2^28 rectangles; building a larger one is an error
// Refused on its length alone, before anything is read or made.
const unread = new Proxy<ArrayLike<number>>(
{ length: (2 ** 28 + 1) * 4 },
{
get(target, key) {
if (key === "length") return target.length;
throw new Error("read");
},
},
);
expect(() => spatialIndexFrom(unread)).toThrow("at most 2^28 rectangles");A search begun in the middle of another disturbs neither
const rectangles: number[] = [];
for (let item = 0; item < 400; item += 1) {
const x = (item % 20) * 10;
const y = Math.floor(item / 20) * 10;
rectangles.push(x, y, x + 8, y + 8);
}
const index = spatialIndexFrom(rectangles, 2);
const alone = spatialSearch(index, 15, 15, 150, 150);
const inner: number[][] = [];
// An array that searches again each time a result is added to it.
const outer: number[] = [];
const searching = new Proxy(outer, {
get(target, key) {
if (key !== "push") return Reflect.get(target, key, target) as unknown;
return (item: number) => {
inner.push(spatialSearch(index, 100, 100, 190, 190));
return target.push(item);
};
},
});
spatialSearch(index, 15, 15, 150, 150, searching);
expect(outer).toEqual(alone);
expect(inner).toHaveLength(alone.length);
for (const found of inner) expect(found).toEqual(spatialSearch(index, 100, 100, 190, 190));An index of nothing finds nothing; an index of one finds it
expect(spatialSearch(spatialIndexFrom([]), 0, 0, 100, 100)).toEqual([]);
expect(spatialSearch(spatialIndexFrom([1, 1, 2, 2]), 0, 0, 100, 100)).toEqual([0]);Results are appended to the array given, so one array can be reused
const index = spatialIndexFrom([0, 0, 10, 10]);
const results = [99];
expect(spatialSearch(index, 0, 0, 5, 5, results)).toBe(results);
expect(results).toEqual([99, 0]);A small query among a hundred thousand rectangles examines a few hundred boxes
// A 400 by 250 grid of 8-unit squares on 10-unit centres.
const rectangles = new Float64Array(100_000 * 4);
for (let item = 0; item < 100_000; item += 1) {
const x = (item % 400) * 10;
const y = Math.floor(item / 400) * 10;
rectangles.set([x, y, x + 8, y + 8], item * 4);
}
const index = spatialIndexFrom(rectangles);
const boxes = counted(index.boxes);
const found = spatialSearch({ ...index, boxes: boxes.array }, 1995, 1245, 2025, 1275);
// Four columns and four rows of squares reach into the query.
expect(found).toHaveLength(16);
// Four numbers to a box, at most; a rejected box is given up on sooner.
expect(boxes.accesses() / 4).toBeLessThan(400);Rectangles without limit among them do not slow the search for the rest
// The same grid, given in a scattered order so that only the index's own ordering helps.
const rectangles = new Float64Array(100_004 * 4);
for (let item = 0; item < 100_000; item += 1) {
const x = (item % 400) * 10;
const y = Math.floor(item / 400) * 10;
rectangles.set([x, y, x + 8, y + 8], ((item * 7919) % 100_000) * 4);
}
// Guides across the whole canvas and down it, and rays off to each side.
rectangles.set([-Infinity, 5000, Infinity, 5000, 9000, -Infinity, 9000, Infinity], 100_000 * 4);
rectangles.set([0, 6000, Infinity, 6000, -Infinity, -Infinity, 0, 7000], 100_002 * 4);
const index = spatialIndexFrom(rectangles);
const boxes = counted(index.boxes);
expect(spatialSearch({ ...index, boxes: boxes.array }, 1995, 1245, 2025, 1275)).toHaveLength(16);
expect(boxes.accesses() / 4).toBeLessThan(400);A query over everything finds everything, however many nodes wait to be opened
const rectangles = new Float64Array(100_000 * 4);
for (let item = 0; item < 100_000; item += 1) {
const x = (item % 400) * 10;
const y = Math.floor(item / 400) * 10;
rectangles.set([x, y, x + 8, y + 8], item * 4);
}
for (const nodeSize of [2, 16, 64]) {
const found = spatialSearch(spatialIndexFrom(rectangles, nodeSize), 0, 0, 4000, 2500);
expect(new Set(found).size).toBe(100_000);
expect(found).toHaveLength(100_000);
}React
Install
npx shadcn@latest add https://ui.publictheta.com/r/spatial-index.jsonSource
src/algorithms/spatial-index.ts
/**
* A spatial index over rectangles: which shapes does this point, this marquee,
* this viewport touch? A packed R-tree, built once from all the rectangles,
* sorted along a Hilbert curve so that rectangles near each other on the canvas
* are near each other in memory.
*
* The whole tree is two typed arrays. A search touches only the nodes whose
* boxes overlap the query, and allocates nothing but its results: the nodes it
* has still to open wait on one stack that every search shares.
*
* A rectangle or a query may be given with either corner first, may have no
* area, and may reach to infinity; a side that is not a number is a mistake in
* the caller and throws a `RangeError`. An index holds up to 2^28 rectangles,
* so that every position in it is a 32-bit integer.
*/
export interface SpatialIndex {
/** Every node's box, `[minX, minY, maxX, maxY]`: leaves first, the root last. */
readonly boxes: Float64Array;
/** For a leaf, the rectangle's number; for a branch, where its children start in `boxes`. */
readonly indices: Uint32Array;
/** Where each level of the tree ends in `boxes`. */
readonly levels: readonly number[];
readonly nodeSize: number;
readonly count: number;
}
const HILBERT_MAX = 65535;
const MAX_COUNT = 2 ** 28;
// Positions of nodes still to open, for every search in progress: one begun
// inside another works above what the outer one has pushed.
let stack = new Uint32Array(64);
let top = 0;
/** The distance along a Hilbert curve of order 16 at which the cell (x, y) lies. */
function hilbert(x: number, y: number): number {
let distance = 0;
for (let side = 32768; side > 0; side = Math.floor(side / 2)) {
const right = (x & side) > 0 ? 1 : 0;
const up = (y & side) > 0 ? 1 : 0;
distance += side * side * ((3 * right) ^ up);
if (up === 0) {
if (right === 1) {
x = HILBERT_MAX - x;
y = HILBERT_MAX - y;
}
const swap = x;
x = y;
y = swap;
}
}
return distance;
}
/**
* The index for rectangles given flat, `[minX0, minY0, maxX0, maxY0, minX1, …]`,
* with at least two boxes to a node. O(n log n).
*/
export function spatialIndexFrom(rectangles: ArrayLike<number>, nodeSize = 16): SpatialIndex {
if (!Number.isInteger(nodeSize) || nodeSize < 2) {
// One box to a node would never narrow to a root.
throw new RangeError(`A node holds a whole number of boxes, two or more, not ${nodeSize}.`);
}
const count = rectangles.length / 4;
if (!Number.isInteger(count)) {
throw new RangeError(`Rectangles are four numbers each; ${rectangles.length} is not a multiple of four.`);
}
if (count > MAX_COUNT) throw new RangeError(`A spatial index holds at most 2^28 rectangles, not ${count}.`);
// Each level holds a node per `nodeSize` of the level below, up to one root.
const levels: number[] = [count * 4];
let nodes = count;
let width = count;
while (width > 1) {
width = Math.ceil(width / nodeSize);
nodes += width;
levels.push(nodes * 4);
}
const boxes = new Float64Array(Math.max(nodes, 1) * 4);
const indices = new Uint32Array(Math.max(nodes, 1));
if (count === 0) {
boxes.fill(Number.NaN);
return { boxes, indices, levels, nodeSize, count };
}
// The extent of the centres that are somewhere: a rectangle reaching to
// infinity has none, and must not flatten the curve for the rest.
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (let i = 0; i < rectangles.length; i += 4) {
const x0 = rectangles[i]!;
const y0 = rectangles[i + 1]!;
const x1 = rectangles[i + 2]!;
const y1 = rectangles[i + 3]!;
// A box with such a side would be passed by no query, and so would its parents.
if (x0 !== x0 || y0 !== y0 || x1 !== x1 || y1 !== y1) {
throw new RangeError(`Rectangle ${i / 4} has a side that is not a number.`);
}
const x = (x0 + x1) / 2;
const y = (y0 + y1) / 2;
// Only a finite number less itself is zero.
if (x - x === 0) {
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
}
if (y - y === 0) {
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
}
}
// Order the rectangles by where their centres fall along the curve.
const scaleX = HILBERT_MAX / (maxX - minX || 1);
const scaleY = HILBERT_MAX / (maxY - minY || 1);
const distances = new Float64Array(count);
for (let item = 0; item < count; item += 1) {
const x = Math.floor(scaleX * ((rectangles[item * 4]! + rectangles[item * 4 + 2]!) / 2 - minX));
const y = Math.floor(scaleY * ((rectangles[item * 4 + 1]! + rectangles[item * 4 + 3]!) / 2 - minY));
// A centre that is nowhere becomes cell 0 under `&`.
distances[item] = hilbert(x, y);
}
// Sorted in place: the array was made here, and a copy would only be thrown away.
// oxlint-disable-next-line unicorn/no-array-sort
const sorted = Uint32Array.from({ length: count }, (_, item) => item).sort(
(a, b) => distances[a]! - distances[b]!,
);
for (let position = 0; position < count; position += 1) {
const item = sorted[position]!;
indices[position] = item;
// Either corner may come first; a box is stored smallest first.
boxes[position * 4] = Math.min(rectangles[item * 4]!, rectangles[item * 4 + 2]!);
boxes[position * 4 + 1] = Math.min(rectangles[item * 4 + 1]!, rectangles[item * 4 + 3]!);
boxes[position * 4 + 2] = Math.max(rectangles[item * 4]!, rectangles[item * 4 + 2]!);
boxes[position * 4 + 3] = Math.max(rectangles[item * 4 + 1]!, rectangles[item * 4 + 3]!);
}
// Each level's nodes are the unions of runs of `nodeSize` boxes below them.
let write = count * 4;
let read = 0;
for (let level = 0; level < levels.length - 1; level += 1) {
const end = levels[level]!;
while (read < end) {
const first = read;
let nodeMinX = Infinity;
let nodeMinY = Infinity;
let nodeMaxX = -Infinity;
let nodeMaxY = -Infinity;
for (let child = 0; child < nodeSize && read < end; child += 1, read += 4) {
nodeMinX = Math.min(nodeMinX, boxes[read]!);
nodeMinY = Math.min(nodeMinY, boxes[read + 1]!);
nodeMaxX = Math.max(nodeMaxX, boxes[read + 2]!);
nodeMaxY = Math.max(nodeMaxY, boxes[read + 3]!);
}
indices[write / 4] = first;
boxes[write] = nodeMinX;
boxes[write + 1] = nodeMinY;
boxes[write + 2] = nodeMaxX;
boxes[write + 3] = nodeMaxY;
write += 4;
}
}
return { boxes, indices, levels, nodeSize, count };
}
/** The index for rectangles given as corner and size, as an interface holds them. O(n log n). */
export function spatialIndexFromRects(
rects: ArrayLike<{
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}>,
nodeSize = 16,
): SpatialIndex {
const flat = new Float64Array(rects.length * 4);
for (let item = 0; item < rects.length; item += 1) {
const { x, y, width, height } = rects[item]!;
flat[item * 4] = x;
flat[item * 4 + 1] = y;
flat[item * 4 + 2] = x + width;
flat[item * 4 + 3] = y + height;
}
return spatialIndexFrom(flat, nodeSize);
}
/**
* The numbers of the rectangles that touch the query box, appended to `into`,
* in no particular order. Touching edges count, so a point finds what it lies
* on the edge of.
*/
export function spatialSearch(
{ boxes, indices, levels, nodeSize, count }: SpatialIndex,
minX: number,
minY: number,
maxX: number,
maxY: number,
into: number[] = [],
): number[] {
if (minX > maxX) {
const swap = minX;
minX = maxX;
maxX = swap;
}
if (minY > maxY) {
const swap = minY;
minY = maxY;
maxY = swap;
}
// Only a side that is not a number is left out of order by the swaps.
if (!(minX <= maxX && minY <= maxY)) throw new RangeError("A query has a side that is not a number.");
if (count === 0) return into;
const leaves = count * 4;
const base = top;
// Every level may leave a node's worth of boxes waiting.
const room = base + levels.length * nodeSize;
if (stack.length < room) {
const grown = new Uint32Array(Math.max(room, stack.length * 2));
grown.set(stack);
stack = grown;
}
// A search begun inside this one may replace `stack` with a longer copy; this
// one has made its room and keeps to the array it has.
const pending = stack;
let node = boxes.length - 4;
// The root's own box is tested first, as a node of one.
let end = boxes.length;
try {
for (;;) {
for (let position = node; position < end; position += 4) {
if (
maxX < boxes[position]! ||
maxY < boxes[position + 1]! ||
minX > boxes[position + 2]! ||
minY > boxes[position + 3]!
) {
continue;
}
if (position < leaves) into.push(indices[position / 4]!);
else pending[top++] = indices[position / 4]!;
}
if (top === base) return into;
node = pending[--top]!;
// A node's children run to `nodeSize` boxes, or to the end of their level.
let level = 0;
while (levels[level]! <= node) level += 1;
end = Math.min(node + nodeSize * 4, levels[level]!);
}
} finally {
// Whatever was thrown from `into` or `boxes`, later searches start clean.
top = base;
}
}