@gridworkjs/hashgrid 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,102 @@
1
+ <p align="center">
2
+ <img src="logo.svg" width="256" height="256" alt="@gridworkjs/hashgrid">
3
+ </p>
4
+
5
+ <h1 align="center">@gridworkjs/hashgrid</h1>
6
+
7
+ <p align="center">Spatial hash grid for uniform distributions and fast neighbor lookups</p>
8
+
9
+ ## Install
10
+
11
+ ```
12
+ npm install @gridworkjs/hashgrid
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```js
18
+ import { createHashGrid } from '@gridworkjs/hashgrid'
19
+ import { point, rect, bounds } from '@gridworkjs/core'
20
+
21
+ // create a hash grid - cellSize should match your data's density
22
+ const grid = createHashGrid(item => bounds(item.position), { cellSize: 50 })
23
+
24
+ // insert items
25
+ grid.insert({ id: 1, position: point(10, 20) })
26
+ grid.insert({ id: 2, position: point(50, 60) })
27
+ grid.insert({ id: 3, position: rect(70, 70, 90, 90) })
28
+
29
+ // search for items intersecting a region
30
+ grid.search({ minX: 0, minY: 0, maxX: 55, maxY: 65 })
31
+ // => [{ id: 1, ... }, { id: 2, ... }]
32
+
33
+ // also accepts geometry objects as queries
34
+ grid.search(rect(0, 0, 55, 65))
35
+
36
+ // find nearest neighbors
37
+ grid.nearest({ x: 12, y: 22 }, 2)
38
+ // => [{ id: 1, ... }, { id: 2, ... }]
39
+
40
+ // remove by identity
41
+ grid.remove(item)
42
+
43
+ grid.size // number of items
44
+ grid.bounds // bounding box of all items
45
+ grid.clear()
46
+ ```
47
+
48
+ ## When to Use a Hash Grid
49
+
50
+ Hash grids are ideal when your data is **uniformly distributed** and items are roughly the same size. They offer O(1) cell lookups, making them excellent for collision detection, particle systems, and game engines.
51
+
52
+ If your data is clustered or varies widely in size, consider `@gridworkjs/quadtree` or `@gridworkjs/rtree` instead.
53
+
54
+ ## Choosing a Cell Size
55
+
56
+ The `cellSize` parameter controls the width and height of each grid cell. For best performance:
57
+
58
+ - Set `cellSize` to roughly the size of your items or the typical query range
59
+ - Too small: items span many cells, increasing memory and insert cost
60
+ - Too large: cells contain many items, reducing search selectivity
61
+
62
+ ## API
63
+
64
+ ### `createHashGrid(accessor, options)`
65
+
66
+ Creates a new hash grid. The `accessor` function maps each item to its bounding box (`{ minX, minY, maxX, maxY }`). Use `bounds()` from `@gridworkjs/core` to convert geometries.
67
+
68
+ The `cellSize` option is required.
69
+
70
+ Returns a spatial index implementing the gridwork protocol.
71
+
72
+ ### `grid.insert(item)`
73
+
74
+ Adds an item to the grid. The item is placed into every cell its bounding box overlaps.
75
+
76
+ ### `grid.remove(item)`
77
+
78
+ Removes an item by identity (`===`). Returns `true` if found and removed.
79
+
80
+ ### `grid.search(query)`
81
+
82
+ Returns all items whose bounds intersect the query. Accepts bounds objects or geometry objects (point, rect, circle).
83
+
84
+ ### `grid.nearest(point, k?)`
85
+
86
+ Returns the `k` nearest items to the given point, sorted by distance. Defaults to `k=1`. Accepts `{ x, y }` or a point geometry.
87
+
88
+ ### `grid.clear()`
89
+
90
+ Removes all items from the grid.
91
+
92
+ ### `grid.size`
93
+
94
+ Number of items in the grid.
95
+
96
+ ### `grid.bounds`
97
+
98
+ Bounding box of all items, or `null` if empty.
99
+
100
+ ## License
101
+
102
+ MIT
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@gridworkjs/hashgrid",
3
+ "version": "1.0.0",
4
+ "description": "Spatial hash grid for uniform distributions and fast neighbor lookups",
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
+ "hashgrid",
20
+ "spatial-hash",
21
+ "spatial",
22
+ "spatial-index",
23
+ "gridwork",
24
+ "uniform",
25
+ "collision",
26
+ "search",
27
+ "nearest-neighbor"
28
+ ],
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/gridworkjs/hashgrid.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
+ }
@@ -0,0 +1,324 @@
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
+ /**
14
+ * @typedef {object} HashGridOptions
15
+ * @property {number} cellSize - Width and height of each grid cell
16
+ */
17
+
18
+ function validateAccessorBounds(b) {
19
+ if (b === null || typeof b !== 'object') {
20
+ throw new Error('accessor must return a bounds object')
21
+ }
22
+ if (!Number.isFinite(b.minX) || !Number.isFinite(b.minY) ||
23
+ !Number.isFinite(b.maxX) || !Number.isFinite(b.maxY)) {
24
+ throw new Error('accessor returned non-finite bounds')
25
+ }
26
+ if (b.minX > b.maxX || b.minY > b.maxY) {
27
+ throw new Error('accessor returned inverted bounds (minX > maxX or minY > maxY)')
28
+ }
29
+ }
30
+
31
+ function normalizeBounds(input) {
32
+ if (input != null && typeof input === 'object' &&
33
+ 'minX' in input && 'minY' in input && 'maxX' in input && 'maxY' in input) {
34
+ return input
35
+ }
36
+ return toBounds(input)
37
+ }
38
+
39
+ function cellKey(cx, cy) {
40
+ return cx + ',' + cy
41
+ }
42
+
43
+ function heapPush(heap, entry) {
44
+ heap.push(entry)
45
+ let i = heap.length - 1
46
+ while (i > 0) {
47
+ const p = (i - 1) >> 1
48
+ if (heap[p].dist <= heap[i].dist) break
49
+ ;[heap[p], heap[i]] = [heap[i], heap[p]]
50
+ i = p
51
+ }
52
+ }
53
+
54
+ function heapPop(heap) {
55
+ const top = heap[0]
56
+ const last = heap.pop()
57
+ if (heap.length > 0) {
58
+ heap[0] = last
59
+ let i = 0
60
+ for (;;) {
61
+ let s = i
62
+ const l = 2 * i + 1
63
+ const r = 2 * i + 2
64
+ if (l < heap.length && heap[l].dist < heap[s].dist) s = l
65
+ if (r < heap.length && heap[r].dist < heap[s].dist) s = r
66
+ if (s === i) break
67
+ ;[heap[i], heap[s]] = [heap[s], heap[i]]
68
+ i = s
69
+ }
70
+ }
71
+ return top
72
+ }
73
+
74
+ /**
75
+ * Creates a spatial hash grid index.
76
+ *
77
+ * @param {(item: any) => Bounds | object} accessor - Maps items to their bounding boxes or geometries
78
+ * @param {HashGridOptions} options
79
+ * @returns {import('@gridworkjs/core').SpatialIndex}
80
+ */
81
+ export function createHashGrid(accessor, options = {}) {
82
+ const cellSize = options.cellSize
83
+
84
+ if (cellSize == null) {
85
+ throw new Error('cellSize is required')
86
+ }
87
+ if (!Number.isFinite(cellSize) || cellSize <= 0) {
88
+ throw new Error('cellSize must be a positive finite number')
89
+ }
90
+ if (typeof accessor !== 'function') {
91
+ throw new Error('accessor must be a function')
92
+ }
93
+
94
+ const invCellSize = 1 / cellSize
95
+ const cells = new Map()
96
+ let size = 0
97
+ let totalBounds = null
98
+
99
+ function toCell(v) {
100
+ return Math.floor(v * invCellSize)
101
+ }
102
+
103
+ function cellRange(b) {
104
+ return {
105
+ minCX: Math.floor(b.minX * invCellSize),
106
+ minCY: Math.floor(b.minY * invCellSize),
107
+ maxCX: Math.floor(b.maxX * invCellSize),
108
+ maxCY: Math.floor(b.maxY * invCellSize)
109
+ }
110
+ }
111
+
112
+ function addToCell(cx, cy, entry) {
113
+ const key = cellKey(cx, cy)
114
+ let bucket = cells.get(key)
115
+ if (!bucket) {
116
+ bucket = []
117
+ cells.set(key, bucket)
118
+ }
119
+ bucket.push(entry)
120
+ }
121
+
122
+ function removeFromCell(cx, cy, entry) {
123
+ const key = cellKey(cx, cy)
124
+ const bucket = cells.get(key)
125
+ if (!bucket) return
126
+ const idx = bucket.indexOf(entry)
127
+ if (idx !== -1) {
128
+ bucket.splice(idx, 1)
129
+ if (bucket.length === 0) cells.delete(key)
130
+ }
131
+ }
132
+
133
+ function recalcBounds() {
134
+ if (size === 0) {
135
+ totalBounds = null
136
+ return
137
+ }
138
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
139
+ const seen = new Set()
140
+ for (const bucket of cells.values()) {
141
+ for (const entry of bucket) {
142
+ if (seen.has(entry)) continue
143
+ seen.add(entry)
144
+ const b = entry.bounds
145
+ if (b.minX < minX) minX = b.minX
146
+ if (b.minY < minY) minY = b.minY
147
+ if (b.maxX > maxX) maxX = b.maxX
148
+ if (b.maxY > maxY) maxY = b.maxY
149
+ }
150
+ }
151
+ totalBounds = { minX, minY, maxX, maxY }
152
+ }
153
+
154
+ const index = {
155
+ [SPATIAL_INDEX]: true,
156
+
157
+ get size() { return size },
158
+
159
+ get bounds() { return totalBounds },
160
+
161
+ insert(item) {
162
+ const raw = accessor(item)
163
+ const itemBounds = normalizeBounds(raw)
164
+ validateAccessorBounds(itemBounds)
165
+
166
+ const entry = { item, bounds: itemBounds }
167
+ const { minCX, minCY, maxCX, maxCY } = cellRange(itemBounds)
168
+
169
+ for (let cx = minCX; cx <= maxCX; cx++) {
170
+ for (let cy = minCY; cy <= maxCY; cy++) {
171
+ addToCell(cx, cy, entry)
172
+ }
173
+ }
174
+
175
+ if (totalBounds === null) {
176
+ totalBounds = { ...itemBounds }
177
+ } else {
178
+ if (itemBounds.minX < totalBounds.minX) totalBounds.minX = itemBounds.minX
179
+ if (itemBounds.minY < totalBounds.minY) totalBounds.minY = itemBounds.minY
180
+ if (itemBounds.maxX > totalBounds.maxX) totalBounds.maxX = itemBounds.maxX
181
+ if (itemBounds.maxY > totalBounds.maxY) totalBounds.maxY = itemBounds.maxY
182
+ }
183
+
184
+ size++
185
+ },
186
+
187
+ remove(item) {
188
+ const raw = accessor(item)
189
+ const itemBounds = normalizeBounds(raw)
190
+ const { minCX, minCY, maxCX, maxCY } = cellRange(itemBounds)
191
+
192
+ let entry = null
193
+ const firstKey = cellKey(minCX, minCY)
194
+ const bucket = cells.get(firstKey)
195
+ if (bucket) {
196
+ entry = bucket.find(e => e.item === item) ?? null
197
+ }
198
+
199
+ if (!entry) {
200
+ for (let cx = minCX; cx <= maxCX && !entry; cx++) {
201
+ for (let cy = minCY; cy <= maxCY && !entry; cy++) {
202
+ if (cx === minCX && cy === minCY) continue
203
+ const b = cells.get(cellKey(cx, cy))
204
+ if (b) entry = b.find(e => e.item === item) ?? null
205
+ }
206
+ }
207
+ }
208
+
209
+ if (!entry) return false
210
+
211
+ for (let cx = minCX; cx <= maxCX; cx++) {
212
+ for (let cy = minCY; cy <= maxCY; cy++) {
213
+ removeFromCell(cx, cy, entry)
214
+ }
215
+ }
216
+
217
+ size--
218
+ recalcBounds()
219
+ return true
220
+ },
221
+
222
+ search(query) {
223
+ if (size === 0) return []
224
+ const queryBounds = normalizeBounds(query)
225
+ const { minCX, minCY, maxCX, maxCY } = cellRange(queryBounds)
226
+
227
+ const results = []
228
+ const seen = new Set()
229
+
230
+ for (let cx = minCX; cx <= maxCX; cx++) {
231
+ for (let cy = minCY; cy <= maxCY; cy++) {
232
+ const bucket = cells.get(cellKey(cx, cy))
233
+ if (!bucket) continue
234
+ for (const entry of bucket) {
235
+ if (seen.has(entry)) continue
236
+ seen.add(entry)
237
+ if (intersects(entry.bounds, queryBounds)) {
238
+ results.push(entry.item)
239
+ }
240
+ }
241
+ }
242
+ }
243
+
244
+ return results
245
+ },
246
+
247
+ nearest(queryPoint, k = 1) {
248
+ if (size === 0 || k <= 0) return []
249
+
250
+ const px = queryPoint.x
251
+ const py = queryPoint.y
252
+
253
+ const results = []
254
+ const seen = new Set()
255
+ let ring = 0
256
+ const centerCX = toCell(px)
257
+ const centerCY = toCell(py)
258
+
259
+ const heap = []
260
+
261
+ // expand in rings until we have k candidates and the ring is far enough
262
+ while (true) {
263
+ const minCX = centerCX - ring
264
+ const maxCX = centerCX + ring
265
+ const minCY = centerCY - ring
266
+ const maxCY = centerCY + ring
267
+
268
+ for (let cx = minCX; cx <= maxCX; cx++) {
269
+ for (let cy = minCY; cy <= maxCY; cy++) {
270
+ if (ring > 0 && cx > minCX && cx < maxCX && cy > minCY && cy < maxCY) continue
271
+ const bucket = cells.get(cellKey(cx, cy))
272
+ if (!bucket) continue
273
+ for (const entry of bucket) {
274
+ if (seen.has(entry)) continue
275
+ seen.add(entry)
276
+ heapPush(heap, { dist: distanceToPoint(entry.bounds, px, py), item: entry.item })
277
+ }
278
+ }
279
+ }
280
+
281
+ // the minimum possible distance to any item in the next ring
282
+ const nextRingDist = ring * cellSize
283
+ const haveCandidates = heap.length >= k
284
+
285
+ if (haveCandidates) {
286
+ let kthDist = peekKth(heap, k)
287
+ if (kthDist <= nextRingDist) break
288
+ }
289
+
290
+ // if we've checked all cells, stop
291
+ if (seen.size >= size) break
292
+ ring++
293
+ }
294
+
295
+ while (heap.length > 0 && results.length < k) {
296
+ results.push(heapPop(heap).item)
297
+ }
298
+
299
+ return results
300
+ },
301
+
302
+ clear() {
303
+ cells.clear()
304
+ size = 0
305
+ totalBounds = null
306
+ }
307
+ }
308
+
309
+ return index
310
+ }
311
+
312
+ function peekKth(heap, k) {
313
+ // extract k items, record the kth distance, then put them back
314
+ if (heap.length < k) return Infinity
315
+ const extracted = []
316
+ for (let i = 0; i < k; i++) {
317
+ extracted.push(heapPop(heap))
318
+ }
319
+ const kthDist = extracted[extracted.length - 1].dist
320
+ for (const e of extracted) {
321
+ heapPush(heap, e)
322
+ }
323
+ return kthDist
324
+ }
package/src/index.js ADDED
@@ -0,0 +1 @@
1
+ export { createHashGrid } from './hashgrid.js'
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Creates a spatial hash grid index.
3
+ *
4
+ * @param {(item: any) => Bounds | object} accessor - Maps items to their bounding boxes or geometries
5
+ * @param {HashGridOptions} options
6
+ * @returns {import('@gridworkjs/core').SpatialIndex}
7
+ */
8
+ export function createHashGrid(accessor: (item: any) => Bounds | object, options?: HashGridOptions): any;
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 HashGridOptions = {
21
+ /**
22
+ * - Width and height of each grid cell
23
+ */
24
+ cellSize: number;
25
+ };
@@ -0,0 +1 @@
1
+ export { createHashGrid } from "./hashgrid.js";