@gridworkjs/kd 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,90 @@
1
+ <p align="center">
2
+ <img src="logo.svg" width="256" height="256" alt="@gridworkjs/kd">
3
+ </p>
4
+
5
+ <h1 align="center">@gridworkjs/kd</h1>
6
+
7
+ <p align="center">KD-tree spatial index for static point sets and nearest-neighbor queries</p>
8
+
9
+ ## Install
10
+
11
+ ```
12
+ npm install @gridworkjs/kd
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```js
18
+ import { createKdTree } from '@gridworkjs/kd'
19
+ import { point, bounds } from '@gridworkjs/core'
20
+
21
+ // index a set of restaurants by location
22
+ const tree = createKdTree(r => bounds(r.location))
23
+
24
+ tree.load([
25
+ { name: 'Corner Bistro', location: point(40.738, -74.005) },
26
+ { name: 'Katz Deli', location: point(40.722, -73.987) },
27
+ { name: 'Joe Pizza', location: point(40.730, -73.989) },
28
+ { name: 'Russ & Daughters', location: point(40.722, -73.988) }
29
+ ])
30
+
31
+ // find the 2 closest restaurants to your current location
32
+ tree.nearest({ x: 40.725, y: -73.990 }, 2)
33
+ // => [{ name: 'Joe Pizza', ... }, { name: 'Katz Deli', ... }]
34
+
35
+ // search a bounding box
36
+ tree.search({ minX: 40.720, minY: -73.990, maxX: 40.730, maxY: -73.985 })
37
+ // => [{ name: 'Katz Deli', ... }, { name: 'Russ & Daughters', ... }]
38
+ ```
39
+
40
+ ## When to Use a KD-tree
41
+
42
+ KD-trees excel at nearest-neighbor queries on point data. If you have a static dataset and your primary query is "find the k closest items to this point", a KD-tree will outperform other spatial indexes.
43
+
44
+ Use `load()` to build the tree from a complete dataset - this produces a balanced tree with optimal query performance. Dynamic `insert()` and `remove()` are supported but may unbalance the tree over time.
45
+
46
+ If your data changes frequently, consider `@gridworkjs/quadtree` (dynamic, sparse data) or `@gridworkjs/hashgrid` (uniform distributions). If your items are rectangles rather than points, `@gridworkjs/rtree` is a better fit.
47
+
48
+ ## API
49
+
50
+ ### `createKdTree(accessor)`
51
+
52
+ Creates a new KD-tree. The `accessor` function maps each item to its bounding box (`{ minX, minY, maxX, maxY }`). Use `bounds()` from `@gridworkjs/core` to convert geometries.
53
+
54
+ Returns a spatial index implementing the gridwork protocol.
55
+
56
+ ### `tree.load(items)`
57
+
58
+ Builds a balanced tree from an array of items, replacing any existing data. This is the preferred way to populate the tree - it produces optimal structure for queries.
59
+
60
+ ### `tree.insert(item)`
61
+
62
+ Adds a single item to the tree. For bulk data, prefer `load()`.
63
+
64
+ ### `tree.remove(item)`
65
+
66
+ Removes an item by identity (`===`). Returns `true` if found and removed.
67
+
68
+ ### `tree.search(query)`
69
+
70
+ Returns all items whose bounds intersect the query. Accepts bounds objects or geometry objects (point, rect, circle).
71
+
72
+ ### `tree.nearest(point, k?)`
73
+
74
+ Returns the `k` nearest items to the given point, sorted by distance. Defaults to `k=1`. Accepts `{ x, y }` or a point geometry.
75
+
76
+ ### `tree.clear()`
77
+
78
+ Removes all items from the tree.
79
+
80
+ ### `tree.size`
81
+
82
+ Number of items in the tree.
83
+
84
+ ### `tree.bounds`
85
+
86
+ Bounding box of all items, or `null` if empty.
87
+
88
+ ## License
89
+
90
+ MIT
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@gridworkjs/kd",
3
+ "version": "1.0.0",
4
+ "description": "KD-tree spatial index for static point sets and nearest-neighbor queries",
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
+ "kdtree",
20
+ "kd-tree",
21
+ "spatial",
22
+ "spatial-index",
23
+ "gridwork",
24
+ "nearest-neighbor",
25
+ "knn",
26
+ "point",
27
+ "search"
28
+ ],
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/gridworkjs/kd.git"
33
+ },
34
+ "files": [
35
+ "src/",
36
+ "types/"
37
+ ],
38
+ "dependencies": {
39
+ "@gridworkjs/core": "^1.0.0"
40
+ },
41
+ "devDependencies": {
42
+ "typescript": "^5.9.3"
43
+ }
44
+ }
package/src/index.js ADDED
@@ -0,0 +1 @@
1
+ export { createKdTree } from './kd.js'
package/src/kd.js ADDED
@@ -0,0 +1,349 @@
1
+ import {
2
+ SPATIAL_INDEX, bounds as toBounds,
3
+ intersects, 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
+ function validateAccessorBounds(b) {
14
+ if (b === null || typeof b !== 'object') {
15
+ throw new Error('accessor must return a bounds object')
16
+ }
17
+ if (!Number.isFinite(b.minX) || !Number.isFinite(b.minY) ||
18
+ !Number.isFinite(b.maxX) || !Number.isFinite(b.maxY)) {
19
+ throw new Error('accessor returned non-finite bounds')
20
+ }
21
+ if (b.minX > b.maxX || b.minY > b.maxY) {
22
+ throw new Error('accessor returned inverted bounds (minX > maxX or minY > maxY)')
23
+ }
24
+ }
25
+
26
+ function normalizeBounds(input) {
27
+ if (input != null && typeof input === 'object' &&
28
+ 'minX' in input && 'minY' in input && 'maxX' in input && 'maxY' in input) {
29
+ return input
30
+ }
31
+ return toBounds(input)
32
+ }
33
+
34
+ function centerDim(b, dim) {
35
+ return dim === 0 ? (b.minX + b.maxX) / 2 : (b.minY + b.maxY) / 2
36
+ }
37
+
38
+ function makeNode(entry, depth) {
39
+ const dim = depth % 2
40
+ return {
41
+ entry,
42
+ splitDim: dim,
43
+ splitVal: centerDim(entry.bounds, dim),
44
+ subtreeBounds: { ...entry.bounds },
45
+ left: null,
46
+ right: null
47
+ }
48
+ }
49
+
50
+ function expandBounds(target, source) {
51
+ if (source.minX < target.minX) target.minX = source.minX
52
+ if (source.minY < target.minY) target.minY = source.minY
53
+ if (source.maxX > target.maxX) target.maxX = source.maxX
54
+ if (source.maxY > target.maxY) target.maxY = source.maxY
55
+ }
56
+
57
+ function recomputeSubtreeBounds(node) {
58
+ node.subtreeBounds = { ...node.entry.bounds }
59
+ if (node.left) expandBounds(node.subtreeBounds, node.left.subtreeBounds)
60
+ if (node.right) expandBounds(node.subtreeBounds, node.right.subtreeBounds)
61
+ }
62
+
63
+ function buildSubtree(entries, depth) {
64
+ if (entries.length === 0) return null
65
+ if (entries.length === 1) return makeNode(entries[0], depth)
66
+
67
+ const dim = depth % 2
68
+ entries.sort((a, b) => centerDim(a.bounds, dim) - centerDim(b.bounds, dim))
69
+ const mid = entries.length >> 1
70
+
71
+ const node = makeNode(entries[mid], depth)
72
+ node.left = buildSubtree(entries.slice(0, mid), depth + 1)
73
+ node.right = buildSubtree(entries.slice(mid + 1), depth + 1)
74
+ recomputeSubtreeBounds(node)
75
+ return node
76
+ }
77
+
78
+ function insertInto(node, entry, depth) {
79
+ if (!node) return makeNode(entry, depth)
80
+
81
+ const val = centerDim(entry.bounds, node.splitDim)
82
+ if (val < node.splitVal) {
83
+ node.left = insertInto(node.left, entry, depth + 1)
84
+ } else {
85
+ node.right = insertInto(node.right, entry, depth + 1)
86
+ }
87
+
88
+ expandBounds(node.subtreeBounds, entry.bounds)
89
+ return node
90
+ }
91
+
92
+ function findMin(node, targetDim, depth) {
93
+ if (!node) return null
94
+ const dim = depth % 2
95
+
96
+ if (dim === targetDim) {
97
+ if (!node.left) return node.entry
98
+ return findMin(node.left, targetDim, depth + 1)
99
+ }
100
+
101
+ const leftMin = findMin(node.left, targetDim, depth + 1)
102
+ const rightMin = findMin(node.right, targetDim, depth + 1)
103
+
104
+ let best = node.entry
105
+ if (leftMin && centerDim(leftMin.bounds, targetDim) < centerDim(best.bounds, targetDim)) {
106
+ best = leftMin
107
+ }
108
+ if (rightMin && centerDim(rightMin.bounds, targetDim) < centerDim(best.bounds, targetDim)) {
109
+ best = rightMin
110
+ }
111
+ return best
112
+ }
113
+
114
+ function removeFrom(node, item, itemBounds, depth) {
115
+ if (!node) return { node: null, found: false }
116
+
117
+ if (node.entry.item === item) {
118
+ if (!node.left && !node.right) return { node: null, found: true }
119
+
120
+ const dim = node.splitDim
121
+ if (node.right) {
122
+ const rep = findMin(node.right, dim, depth + 1)
123
+ node.entry = rep
124
+ node.splitVal = centerDim(rep.bounds, dim)
125
+ const r = removeFrom(node.right, rep.item, rep.bounds, depth + 1)
126
+ node.right = r.node
127
+ } else {
128
+ const rep = findMin(node.left, dim, depth + 1)
129
+ node.entry = rep
130
+ node.splitVal = centerDim(rep.bounds, dim)
131
+ const r = removeFrom(node.left, rep.item, rep.bounds, depth + 1)
132
+ node.right = r.node
133
+ node.left = null
134
+ }
135
+ recomputeSubtreeBounds(node)
136
+ return { node, found: true }
137
+ }
138
+
139
+ const val = centerDim(itemBounds, node.splitDim)
140
+
141
+ if (val < node.splitVal) {
142
+ const r = removeFrom(node.left, item, itemBounds, depth + 1)
143
+ node.left = r.node
144
+ if (r.found) recomputeSubtreeBounds(node)
145
+ return { node, found: r.found }
146
+ }
147
+
148
+ if (val > node.splitVal) {
149
+ const r = removeFrom(node.right, item, itemBounds, depth + 1)
150
+ node.right = r.node
151
+ if (r.found) recomputeSubtreeBounds(node)
152
+ return { node, found: r.found }
153
+ }
154
+
155
+ // val === splitVal, could be on either side due to bulk build ordering
156
+ const r = removeFrom(node.right, item, itemBounds, depth + 1)
157
+ node.right = r.node
158
+ if (r.found) {
159
+ recomputeSubtreeBounds(node)
160
+ return { node, found: true }
161
+ }
162
+
163
+ const l = removeFrom(node.left, item, itemBounds, depth + 1)
164
+ node.left = l.node
165
+ if (l.found) recomputeSubtreeBounds(node)
166
+ return { node, found: l.found }
167
+ }
168
+
169
+ function searchTree(node, queryBounds, results) {
170
+ if (!node) return
171
+ if (!intersects(node.subtreeBounds, queryBounds)) return
172
+
173
+ if (intersects(node.entry.bounds, queryBounds)) {
174
+ results.push(node.entry.item)
175
+ }
176
+
177
+ searchTree(node.left, queryBounds, results)
178
+ searchTree(node.right, queryBounds, results)
179
+ }
180
+
181
+ // max-heap for knn (tracks the worst of the k best candidates)
182
+ function maxHeapPush(heap, entry) {
183
+ heap.push(entry)
184
+ let i = heap.length - 1
185
+ while (i > 0) {
186
+ const p = (i - 1) >> 1
187
+ if (heap[p].dist >= heap[i].dist) break
188
+ ;[heap[p], heap[i]] = [heap[i], heap[p]]
189
+ i = p
190
+ }
191
+ }
192
+
193
+ function maxHeapPop(heap) {
194
+ const top = heap[0]
195
+ const last = heap.pop()
196
+ if (heap.length > 0) {
197
+ heap[0] = last
198
+ let i = 0
199
+ for (;;) {
200
+ let s = i
201
+ const l = 2 * i + 1
202
+ const r = 2 * i + 2
203
+ if (l < heap.length && heap[l].dist > heap[s].dist) s = l
204
+ if (r < heap.length && heap[r].dist > heap[s].dist) s = r
205
+ if (s === i) break
206
+ ;[heap[i], heap[s]] = [heap[s], heap[i]]
207
+ i = s
208
+ }
209
+ }
210
+ return top
211
+ }
212
+
213
+ function nearestSearch(node, px, py, heap, k) {
214
+ if (!node) return
215
+
216
+ const subtreeDist = distanceToPoint(node.subtreeBounds, px, py)
217
+ if (heap.length >= k && subtreeDist >= heap[0].dist) return
218
+
219
+ const dist = distanceToPoint(node.entry.bounds, px, py)
220
+ if (heap.length < k) {
221
+ maxHeapPush(heap, { dist, item: node.entry.item })
222
+ } else if (dist < heap[0].dist) {
223
+ maxHeapPop(heap)
224
+ maxHeapPush(heap, { dist, item: node.entry.item })
225
+ }
226
+
227
+ const val = node.splitDim === 0 ? px : py
228
+ const near = val < node.splitVal ? node.left : node.right
229
+ const far = val < node.splitVal ? node.right : node.left
230
+
231
+ nearestSearch(near, px, py, heap, k)
232
+ nearestSearch(far, px, py, heap, k)
233
+ }
234
+
235
+ function computeBounds(node) {
236
+ if (!node) return null
237
+ return { ...node.subtreeBounds }
238
+ }
239
+
240
+ /**
241
+ * Creates a KD-tree spatial index. Optimized for static point sets and nearest-neighbor queries.
242
+ * Supports dynamic inserts and removes, but `load()` produces a balanced tree for best performance.
243
+ *
244
+ * @param {(item: any) => Bounds | object} accessor - Maps items to their bounding boxes or geometries
245
+ * @returns {import('@gridworkjs/core').SpatialIndex & { load: (items: any[]) => void }}
246
+ */
247
+ export function createKdTree(accessor) {
248
+ if (typeof accessor !== 'function') {
249
+ throw new Error('accessor must be a function')
250
+ }
251
+
252
+ let root = null
253
+ let size = 0
254
+ let totalBounds = null
255
+
256
+ const index = {
257
+ [SPATIAL_INDEX]: true,
258
+
259
+ get size() { return size },
260
+
261
+ get bounds() { return totalBounds },
262
+
263
+ insert(item) {
264
+ const raw = accessor(item)
265
+ const itemBounds = normalizeBounds(raw)
266
+ validateAccessorBounds(itemBounds)
267
+
268
+ const entry = { item, bounds: itemBounds }
269
+ root = insertInto(root, entry, 0)
270
+
271
+ if (totalBounds === null) {
272
+ totalBounds = { ...itemBounds }
273
+ } else {
274
+ expandBounds(totalBounds, itemBounds)
275
+ }
276
+
277
+ size++
278
+ },
279
+
280
+ load(items) {
281
+ root = null
282
+ size = 0
283
+ totalBounds = null
284
+
285
+ if (!items || items.length === 0) return
286
+
287
+ const entries = items.map(item => {
288
+ const raw = accessor(item)
289
+ const itemBounds = normalizeBounds(raw)
290
+ validateAccessorBounds(itemBounds)
291
+ return { item, bounds: itemBounds }
292
+ })
293
+
294
+ root = buildSubtree(entries, 0)
295
+ size = entries.length
296
+ totalBounds = computeBounds(root)
297
+ },
298
+
299
+ remove(item) {
300
+ if (size === 0) return false
301
+
302
+ const raw = accessor(item)
303
+ const itemBounds = normalizeBounds(raw)
304
+
305
+ const result = removeFrom(root, item, itemBounds, 0)
306
+ root = result.node
307
+
308
+ if (result.found) {
309
+ size--
310
+ totalBounds = computeBounds(root)
311
+ }
312
+
313
+ return result.found
314
+ },
315
+
316
+ search(query) {
317
+ if (size === 0) return []
318
+ const queryBounds = normalizeBounds(query)
319
+ const results = []
320
+ searchTree(root, queryBounds, results)
321
+ return results
322
+ },
323
+
324
+ nearest(queryPoint, k = 1) {
325
+ if (size === 0 || k <= 0) return []
326
+
327
+ const px = queryPoint.x
328
+ const py = queryPoint.y
329
+
330
+ const heap = []
331
+ nearestSearch(root, px, py, heap, k)
332
+
333
+ const results = []
334
+ while (heap.length > 0) {
335
+ results.push(maxHeapPop(heap).item)
336
+ }
337
+ results.reverse()
338
+ return results
339
+ },
340
+
341
+ clear() {
342
+ root = null
343
+ size = 0
344
+ totalBounds = null
345
+ }
346
+ }
347
+
348
+ return index
349
+ }
@@ -0,0 +1 @@
1
+ export { createKdTree } from "./kd.js";
package/types/kd.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Creates a KD-tree spatial index. Optimized for static point sets and nearest-neighbor queries.
3
+ * Supports dynamic inserts and removes, but `load()` produces a balanced tree for best performance.
4
+ *
5
+ * @param {(item: any) => Bounds | object} accessor - Maps items to their bounding boxes or geometries
6
+ * @returns {import('@gridworkjs/core').SpatialIndex & { load: (items: any[]) => void }}
7
+ */
8
+ export function createKdTree(accessor: (item: any) => Bounds | object): any & {
9
+ load: (items: any[]) => void;
10
+ };
11
+ export type Bounds<T> = {
12
+ minX: number;
13
+ minY: number;
14
+ maxX: number;
15
+ maxY: number;
16
+ };
17
+ export type Point<T> = {
18
+ x: number;
19
+ y: number;
20
+ };
21
+ export type Accessor<T> = (item: T) => Bounds;