@gridworkjs/quadtree 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ <p align="center">
2
+ <img src="logo.svg" width="256" height="256" alt="@gridworkjs/quadtree">
3
+ </p>
4
+
5
+ <h1 align="center">@gridworkjs/quadtree</h1>
6
+
7
+ <p align="center">quadtree spatial index for sparse, uneven point and region data</p>
8
+
9
+ ## install
10
+
11
+ ```
12
+ npm install @gridworkjs/quadtree
13
+ ```
14
+
15
+ ## usage
16
+
17
+ ```js
18
+ import { createQuadtree } from '@gridworkjs/quadtree'
19
+ import { point, rect, bounds } from '@gridworkjs/core'
20
+
21
+ // create a quadtree with a bounds accessor
22
+ const tree = createQuadtree(item => bounds(item.position))
23
+
24
+ // insert items - any shape, the accessor extracts bounds
25
+ tree.insert({ id: 1, position: point(10, 20) })
26
+ tree.insert({ id: 2, position: point(50, 60) })
27
+ tree.insert({ id: 3, position: rect(70, 70, 90, 90) })
28
+
29
+ // search for items intersecting a region
30
+ tree.search({ minX: 0, minY: 0, maxX: 55, maxY: 65 })
31
+ // => [{ id: 1, ... }, { id: 2, ... }]
32
+
33
+ // also accepts geometry objects as queries
34
+ tree.search(rect(0, 0, 55, 65))
35
+
36
+ // find nearest neighbors
37
+ tree.nearest({ x: 12, y: 22 }, 2)
38
+ // => [{ id: 1, ... }, { id: 2, ... }]
39
+
40
+ // remove by identity
41
+ tree.remove(item)
42
+
43
+ tree.size // number of items
44
+ tree.bounds // current root bounds
45
+ tree.clear()
46
+ ```
47
+
48
+ ## options
49
+
50
+ ```js
51
+ createQuadtree(accessor, {
52
+ bounds: { minX: 0, minY: 0, maxX: 1000, maxY: 1000 }, // world bounds (optional, auto-grows)
53
+ maxItems: 16, // items per node before splitting
54
+ maxDepth: 8 // maximum tree depth
55
+ })
56
+ ```
57
+
58
+ If `bounds` is omitted, the tree auto-creates bounds from the first insert and grows as needed.
59
+
60
+ ## API
61
+
62
+ ### `createQuadtree(accessor, options?)`
63
+
64
+ Creates a new quadtree. The `accessor` function maps each item to its bounding box (`{ minX, minY, maxX, maxY }`). Use `bounds()` from `@gridworkjs/core` to convert geometries.
65
+
66
+ Returns a spatial index implementing the gridwork protocol.
67
+
68
+ ### `index.insert(item)`
69
+
70
+ Adds an item to the tree.
71
+
72
+ ### `index.remove(item)`
73
+
74
+ Removes an item by identity (`===`). Returns `true` if found and removed.
75
+
76
+ ### `index.search(query)`
77
+
78
+ Returns all items whose bounds intersect the query. Accepts bounds objects or geometry objects (point, rect, circle).
79
+
80
+ ### `index.nearest(point, k?)`
81
+
82
+ Returns the `k` nearest items to the given point, sorted by distance. Defaults to `k=1`. Accepts `{ x, y }` or a point geometry.
83
+
84
+ ### `index.clear()`
85
+
86
+ Removes all items. Preserves fixed bounds if provided at construction.
87
+
88
+ ### `index.size`
89
+
90
+ Number of items in the tree.
91
+
92
+ ### `index.bounds`
93
+
94
+ Current root bounds, or `null` if empty and no fixed bounds were set.
95
+
96
+ ## license
97
+
98
+ MIT
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@gridworkjs/quadtree",
3
+ "version": "1.0.0",
4
+ "description": "quadtree spatial index for sparse, uneven point and region data",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "types": "./types/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./types/index.d.ts",
11
+ "default": "./src/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "test": "node --test",
16
+ "prepublishOnly": "npx tsc --declaration --allowJs --emitDeclarationOnly --skipLibCheck --target es2020 --module nodenext --moduleResolution nodenext --strict false --esModuleInterop true --outDir ./types src/index.js"
17
+ },
18
+ "keywords": [
19
+ "quadtree",
20
+ "spatial",
21
+ "spatial-index",
22
+ "gridwork",
23
+ "point",
24
+ "region",
25
+ "search",
26
+ "nearest-neighbor"
27
+ ],
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/gridworkjs/quadtree.git"
32
+ },
33
+ "files": [
34
+ "src/",
35
+ "types/"
36
+ ],
37
+ "dependencies": {
38
+ "@gridworkjs/core": "^1.0.0"
39
+ },
40
+ "devDependencies": {
41
+ "typescript": "^5.9.3"
42
+ }
43
+ }
package/src/index.js ADDED
@@ -0,0 +1 @@
1
+ export { createQuadtree } from './quadtree.js'
@@ -0,0 +1,312 @@
1
+ import {
2
+ SPATIAL_INDEX, bounds as toBounds,
3
+ intersects, contains, distanceToPoint
4
+ } from '@gridworkjs/core'
5
+
6
+ /**
7
+ * @typedef {{ minX: number, minY: number, maxX: number, maxY: number }} Bounds
8
+ * @typedef {{ x: number, y: number }} Point
9
+ * @typedef {(item: T) => Bounds} Accessor
10
+ * @template T
11
+ */
12
+
13
+ /**
14
+ * @typedef {object} QuadtreeOptions
15
+ * @property {Bounds} [bounds] - fixed world bounds (auto-grows if omitted)
16
+ * @property {number} [maxItems=16] - items per node before splitting
17
+ * @property {number} [maxDepth=8] - maximum tree depth
18
+ */
19
+
20
+ /**
21
+ * @typedef {object} SpatialIndex
22
+ * @property {true} [SPATIAL_INDEX]
23
+ * @property {number} size
24
+ * @property {Bounds | null} bounds
25
+ * @property {(item: any) => void} insert
26
+ * @property {(item: any) => boolean} remove
27
+ * @property {(query: Bounds | object) => any[]} search
28
+ * @property {(point: Point, k?: number) => any[]} nearest
29
+ * @property {() => void} clear
30
+ */
31
+
32
+ function createNode(bounds) {
33
+ return { bounds, items: [], children: null }
34
+ }
35
+
36
+ function childBounds(bounds) {
37
+ const midX = (bounds.minX + bounds.maxX) / 2
38
+ const midY = (bounds.minY + bounds.maxY) / 2
39
+ return [
40
+ { minX: bounds.minX, minY: bounds.minY, maxX: midX, maxY: midY },
41
+ { minX: midX, minY: bounds.minY, maxX: bounds.maxX, maxY: midY },
42
+ { minX: bounds.minX, minY: midY, maxX: midX, maxY: bounds.maxY },
43
+ { minX: midX, minY: midY, maxX: bounds.maxX, maxY: bounds.maxY }
44
+ ]
45
+ }
46
+
47
+ function split(node, maxItems, maxDepth, depth) {
48
+ const quads = childBounds(node.bounds)
49
+ node.children = quads.map(b => createNode(b))
50
+
51
+ const kept = []
52
+ for (const entry of node.items) {
53
+ if (!pushDown(node.children, entry, maxItems, maxDepth, depth + 1)) {
54
+ kept.push(entry)
55
+ }
56
+ }
57
+ node.items = kept
58
+ }
59
+
60
+ function pushDown(children, entry, maxItems, maxDepth, depth) {
61
+ for (const child of children) {
62
+ if (contains(child.bounds, entry.bounds)) {
63
+ insertEntry(child, entry, maxItems, maxDepth, depth)
64
+ return true
65
+ }
66
+ }
67
+ return false
68
+ }
69
+
70
+ function insertEntry(node, entry, maxItems, maxDepth, depth) {
71
+ if (node.children) {
72
+ if (!pushDown(node.children, entry, maxItems, maxDepth, depth + 1)) {
73
+ node.items.push(entry)
74
+ }
75
+ return
76
+ }
77
+
78
+ node.items.push(entry)
79
+
80
+ if (node.items.length > maxItems && depth < maxDepth) {
81
+ split(node, maxItems, maxDepth, depth)
82
+ }
83
+ }
84
+
85
+ function searchNode(node, query, results) {
86
+ if (!intersects(node.bounds, query)) return
87
+
88
+ for (const entry of node.items) {
89
+ if (intersects(entry.bounds, query)) {
90
+ results.push(entry.item)
91
+ }
92
+ }
93
+
94
+ if (node.children) {
95
+ for (const child of node.children) {
96
+ searchNode(child, query, results)
97
+ }
98
+ }
99
+ }
100
+
101
+ function removeEntry(node, item, itemBounds) {
102
+ const idx = node.items.findIndex(e => e.item === item)
103
+ if (idx !== -1) {
104
+ node.items.splice(idx, 1)
105
+ return true
106
+ }
107
+
108
+ if (node.children) {
109
+ for (const child of node.children) {
110
+ if (intersects(child.bounds, itemBounds)) {
111
+ if (removeEntry(child, item, itemBounds)) {
112
+ tryCollapse(node)
113
+ return true
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ return false
120
+ }
121
+
122
+ function tryCollapse(node) {
123
+ if (!node.children) return
124
+ let total = node.items.length
125
+ for (const child of node.children) {
126
+ if (child.children) return
127
+ total += child.items.length
128
+ }
129
+ if (total === 0) {
130
+ node.children = null
131
+ }
132
+ }
133
+
134
+ function growToContain(root, target) {
135
+ while (!contains(root.bounds, target)) {
136
+ const { minX, minY, maxX, maxY } = root.bounds
137
+ const w = Math.max(maxX - minX, 1)
138
+ const h = Math.max(maxY - minY, 1)
139
+
140
+ const tcx = (target.minX + target.maxX) / 2
141
+ const rcx = (minX + maxX) / 2
142
+ const tcy = (target.minY + target.maxY) / 2
143
+ const rcy = (minY + maxY) / 2
144
+
145
+ let newBounds, quadrant
146
+
147
+ if (tcx < rcx && tcy < rcy) {
148
+ newBounds = { minX: minX - w, minY: minY - h, maxX, maxY }
149
+ quadrant = 3
150
+ } else if (tcx >= rcx && tcy < rcy) {
151
+ newBounds = { minX, minY: minY - h, maxX: maxX + w, maxY }
152
+ quadrant = 2
153
+ } else if (tcx < rcx && tcy >= rcy) {
154
+ newBounds = { minX: minX - w, minY, maxX, maxY: maxY + h }
155
+ quadrant = 1
156
+ } else {
157
+ newBounds = { minX, minY, maxX: maxX + w, maxY: maxY + h }
158
+ quadrant = 0
159
+ }
160
+
161
+ const newRoot = createNode(newBounds)
162
+ const quads = childBounds(newBounds)
163
+ newRoot.children = quads.map((b, i) => i === quadrant ? root : createNode(b))
164
+ root = newRoot
165
+ }
166
+ return root
167
+ }
168
+
169
+ function normalizeBounds(input) {
170
+ if (input != null && typeof input === 'object' &&
171
+ 'minX' in input && 'minY' in input && 'maxX' in input && 'maxY' in input) {
172
+ return input
173
+ }
174
+ return toBounds(input)
175
+ }
176
+
177
+ function heapPush(heap, entry) {
178
+ heap.push(entry)
179
+ let i = heap.length - 1
180
+ while (i > 0) {
181
+ const p = (i - 1) >> 1
182
+ if (heap[p].dist <= heap[i].dist) break
183
+ ;[heap[p], heap[i]] = [heap[i], heap[p]]
184
+ i = p
185
+ }
186
+ }
187
+
188
+ function heapPop(heap) {
189
+ const top = heap[0]
190
+ const last = heap.pop()
191
+ if (heap.length > 0) {
192
+ heap[0] = last
193
+ let i = 0
194
+ for (;;) {
195
+ let s = i
196
+ const l = 2 * i + 1
197
+ const r = 2 * i + 2
198
+ if (l < heap.length && heap[l].dist < heap[s].dist) s = l
199
+ if (r < heap.length && heap[r].dist < heap[s].dist) s = r
200
+ if (s === i) break
201
+ ;[heap[i], heap[s]] = [heap[s], heap[i]]
202
+ i = s
203
+ }
204
+ }
205
+ return top
206
+ }
207
+
208
+ function nearestSearch(root, px, py, k) {
209
+ if (!root || k <= 0) return []
210
+
211
+ const results = []
212
+ const heap = []
213
+
214
+ heapPush(heap, { kind: 'node', node: root, dist: distanceToPoint(root.bounds, px, py) })
215
+
216
+ while (heap.length > 0 && results.length < k) {
217
+ const cur = heapPop(heap)
218
+
219
+ if (cur.kind === 'item') {
220
+ results.push(cur.item)
221
+ continue
222
+ }
223
+
224
+ const node = cur.node
225
+
226
+ for (const entry of node.items) {
227
+ heapPush(heap, { kind: 'item', item: entry.item, dist: distanceToPoint(entry.bounds, px, py) })
228
+ }
229
+
230
+ if (node.children) {
231
+ for (const child of node.children) {
232
+ heapPush(heap, { kind: 'node', node: child, dist: distanceToPoint(child.bounds, px, py) })
233
+ }
234
+ }
235
+ }
236
+
237
+ return results
238
+ }
239
+
240
+ /**
241
+ * Creates a quadtree spatial index.
242
+ *
243
+ * @param {(item: any) => Bounds} accessor - maps items to their bounding boxes
244
+ * @param {QuadtreeOptions} [options]
245
+ * @returns {SpatialIndex}
246
+ */
247
+ export function createQuadtree(accessor, options = {}) {
248
+ const maxItems = options.maxItems ?? 16
249
+ const maxDepth = options.maxDepth ?? 8
250
+ const fixedBounds = options.bounds ? normalizeBounds(options.bounds) : null
251
+
252
+ let root = fixedBounds ? createNode(fixedBounds) : null
253
+ let size = 0
254
+
255
+ const index = {
256
+ [SPATIAL_INDEX]: true,
257
+
258
+ get size() { return size },
259
+
260
+ get bounds() { return root ? root.bounds : null },
261
+
262
+ insert(item) {
263
+ const itemBounds = accessor(item)
264
+ const entry = { item, bounds: itemBounds }
265
+
266
+ if (!root) {
267
+ const { minX, minY, maxX, maxY } = itemBounds
268
+ const w = Math.max(maxX - minX, 1)
269
+ const h = Math.max(maxY - minY, 1)
270
+ root = createNode({
271
+ minX: minX - w, minY: minY - h,
272
+ maxX: maxX + w, maxY: maxY + h
273
+ })
274
+ }
275
+
276
+ if (!contains(root.bounds, itemBounds)) {
277
+ root = growToContain(root, itemBounds)
278
+ }
279
+
280
+ insertEntry(root, entry, maxItems, maxDepth, 0)
281
+ size++
282
+ },
283
+
284
+ remove(item) {
285
+ if (!root) return false
286
+ const itemBounds = accessor(item)
287
+ const removed = removeEntry(root, item, itemBounds)
288
+ if (removed) size--
289
+ return removed
290
+ },
291
+
292
+ search(query) {
293
+ if (!root) return []
294
+ const queryBounds = normalizeBounds(query)
295
+ const results = []
296
+ searchNode(root, queryBounds, results)
297
+ return results
298
+ },
299
+
300
+ nearest(point, k = 1) {
301
+ if (!root) return []
302
+ return nearestSearch(root, point.x, point.y, k)
303
+ },
304
+
305
+ clear() {
306
+ root = fixedBounds ? createNode(fixedBounds) : null
307
+ size = 0
308
+ }
309
+ }
310
+
311
+ return index
312
+ }
@@ -0,0 +1 @@
1
+ export { createQuadtree } from "./quadtree.js";
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Creates a quadtree spatial index.
3
+ *
4
+ * @param {(item: any) => Bounds} accessor - maps items to their bounding boxes
5
+ * @param {QuadtreeOptions} [options]
6
+ * @returns {SpatialIndex}
7
+ */
8
+ export function createQuadtree(accessor: (item: any) => Bounds, options?: QuadtreeOptions): SpatialIndex;
9
+ export type Bounds<T> = {
10
+ minX: number;
11
+ minY: number;
12
+ maxX: number;
13
+ maxY: number;
14
+ };
15
+ export type Point<T> = {
16
+ x: number;
17
+ y: number;
18
+ };
19
+ export type Accessor<T> = (item: T) => Bounds;
20
+ export type QuadtreeOptions = {
21
+ /**
22
+ * - fixed world bounds (auto-grows if omitted)
23
+ */
24
+ bounds?: Bounds;
25
+ /**
26
+ * - items per node before splitting
27
+ */
28
+ maxItems?: number;
29
+ /**
30
+ * - maximum tree depth
31
+ */
32
+ maxDepth?: number;
33
+ };
34
+ export type SpatialIndex = {
35
+ SPATIAL_INDEX?: true;
36
+ size: number;
37
+ bounds: Bounds | null;
38
+ insert: (item: any) => void;
39
+ remove: (item: any) => boolean;
40
+ search: (query: Bounds | object) => any[];
41
+ nearest: (point: Point, k?: number) => any[];
42
+ clear: () => void;
43
+ };