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.json
Source

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;
  }
}