@buley/hexgrid-3d 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/.eslintrc.json +28 -0
- package/LICENSE +39 -0
- package/README.md +291 -0
- package/examples/basic-usage.tsx +52 -0
- package/package.json +65 -0
- package/public/hexgrid-worker.js +1763 -0
- package/rust/Cargo.toml +41 -0
- package/rust/src/lib.rs +740 -0
- package/rust/src/math.rs +574 -0
- package/rust/src/spatial.rs +245 -0
- package/rust/src/statistics.rs +496 -0
- package/src/HexGridEnhanced.ts +16 -0
- package/src/Snapshot.ts +1402 -0
- package/src/adapters.ts +65 -0
- package/src/algorithms/AdvancedStatistics.ts +328 -0
- package/src/algorithms/BayesianStatistics.ts +317 -0
- package/src/algorithms/FlowField.ts +126 -0
- package/src/algorithms/FluidSimulation.ts +99 -0
- package/src/algorithms/GraphAlgorithms.ts +184 -0
- package/src/algorithms/OutlierDetection.ts +391 -0
- package/src/algorithms/ParticleSystem.ts +85 -0
- package/src/algorithms/index.ts +13 -0
- package/src/compat.ts +96 -0
- package/src/components/HexGrid.tsx +31 -0
- package/src/components/NarrationOverlay.tsx +221 -0
- package/src/components/index.ts +2 -0
- package/src/features.ts +125 -0
- package/src/index.ts +30 -0
- package/src/math/HexCoordinates.ts +15 -0
- package/src/math/Matrix4.ts +35 -0
- package/src/math/Quaternion.ts +37 -0
- package/src/math/SpatialIndex.ts +114 -0
- package/src/math/Vector3.ts +69 -0
- package/src/math/index.ts +11 -0
- package/src/note-adapter.ts +124 -0
- package/src/ontology-adapter.ts +77 -0
- package/src/stores/index.ts +1 -0
- package/src/stores/uiStore.ts +85 -0
- package/src/types/index.ts +3 -0
- package/src/types.ts +152 -0
- package/src/utils/image-utils.ts +25 -0
- package/src/wasm/HexGridWasmWrapper.ts +753 -0
- package/src/wasm/index.ts +7 -0
- package/src/workers/hexgrid-math.ts +177 -0
- package/src/workers/hexgrid-worker.worker.ts +1807 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript wrapper for hexgrid-wasm
|
|
3
|
+
*
|
|
4
|
+
* Provides a seamless interface to the Rust/WASM module with
|
|
5
|
+
* automatic fallback to pure TypeScript implementations.
|
|
6
|
+
*
|
|
7
|
+
* @module wasm/HexGridWasmWrapper
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Types matching the Rust structs
|
|
11
|
+
export interface WasmCellState {
|
|
12
|
+
owner: number
|
|
13
|
+
population: number
|
|
14
|
+
infectedBy: number
|
|
15
|
+
infection: number
|
|
16
|
+
resistance: number
|
|
17
|
+
flags: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface WasmFluidSource {
|
|
21
|
+
x: number
|
|
22
|
+
y: number
|
|
23
|
+
radius: number
|
|
24
|
+
density: number
|
|
25
|
+
velocityX: number
|
|
26
|
+
velocityY: number
|
|
27
|
+
color?: [number, number, number]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// WASM module interface (generated by wasm-bindgen)
|
|
31
|
+
interface HexGridWasmModule {
|
|
32
|
+
HexGridWasm: {
|
|
33
|
+
new (width: number, height: number): HexGridWasmInstance
|
|
34
|
+
}
|
|
35
|
+
FlowFieldWasm: {
|
|
36
|
+
new (width: number, height: number): FlowFieldWasmInstance
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface HexGridWasmInstance {
|
|
41
|
+
get_owner(index: number): number
|
|
42
|
+
set_owner(index: number, owner: number): void
|
|
43
|
+
set_population(index: number, population: number): void
|
|
44
|
+
get_neighbors(index: number): Int32Array
|
|
45
|
+
step_infection(infectionRate: number, infectionThreshold: number): Uint32Array
|
|
46
|
+
find_connected_regions(owner: number): Uint32Array
|
|
47
|
+
find_border_cells(owner: number): Uint32Array
|
|
48
|
+
find_path(start: number, end: number, ownerFilter: number): Uint32Array
|
|
49
|
+
get_territory_counts(): Uint32Array
|
|
50
|
+
compute_gini(): number
|
|
51
|
+
compute_entropy(): number
|
|
52
|
+
kmeans_cluster(k: number, iterations: number): Uint32Array
|
|
53
|
+
size(): number
|
|
54
|
+
width(): number
|
|
55
|
+
height(): number
|
|
56
|
+
clear(): void
|
|
57
|
+
free(): void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface FlowFieldWasmInstance {
|
|
61
|
+
add_source(x: number, y: number, strength: number): void
|
|
62
|
+
add_vortex(x: number, y: number, strength: number): void
|
|
63
|
+
sample(x: number, y: number): Float32Array
|
|
64
|
+
compute_divergence(): Float32Array
|
|
65
|
+
compute_curl(): Float32Array
|
|
66
|
+
clear(): void
|
|
67
|
+
free(): void
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Wrapper class that provides TypeScript interface to WASM
|
|
72
|
+
* with automatic fallback
|
|
73
|
+
*/
|
|
74
|
+
export class HexGridWasmWrapper {
|
|
75
|
+
private static module: HexGridWasmModule | null = null
|
|
76
|
+
private static loading: Promise<HexGridWasmModule | null> | null = null
|
|
77
|
+
private static loadFailed = false
|
|
78
|
+
|
|
79
|
+
private wasmInstance: HexGridWasmInstance | null = null
|
|
80
|
+
private fallbackData: {
|
|
81
|
+
width: number
|
|
82
|
+
height: number
|
|
83
|
+
owners: Uint8Array
|
|
84
|
+
populations: Float32Array
|
|
85
|
+
neighborCache: Int32Array[]
|
|
86
|
+
} | null = null
|
|
87
|
+
|
|
88
|
+
private constructor(
|
|
89
|
+
private readonly width: number,
|
|
90
|
+
private readonly height: number
|
|
91
|
+
) {}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Load the WASM module
|
|
95
|
+
*/
|
|
96
|
+
static async loadModule(): Promise<boolean> {
|
|
97
|
+
if (this.loadFailed) return false
|
|
98
|
+
if (this.module) return true
|
|
99
|
+
|
|
100
|
+
if (this.loading) {
|
|
101
|
+
const result = await this.loading
|
|
102
|
+
return result !== null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
this.loading = (async () => {
|
|
106
|
+
try {
|
|
107
|
+
// Try to dynamically import the WASM module
|
|
108
|
+
const wasmModule = await import('../rust/pkg/hexgrid_wasm')
|
|
109
|
+
await wasmModule.default()
|
|
110
|
+
this.module = wasmModule as unknown as HexGridWasmModule
|
|
111
|
+
console.log('[HexGrid] WASM module loaded successfully')
|
|
112
|
+
return this.module
|
|
113
|
+
} catch (error) {
|
|
114
|
+
console.warn('[HexGrid] WASM module not available, using fallback:', error)
|
|
115
|
+
this.loadFailed = true
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
})()
|
|
119
|
+
|
|
120
|
+
const result = await this.loading
|
|
121
|
+
return result !== null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Create a new HexGrid wrapper
|
|
126
|
+
*/
|
|
127
|
+
static async create(width: number, height: number): Promise<HexGridWasmWrapper> {
|
|
128
|
+
const wrapper = new HexGridWasmWrapper(width, height)
|
|
129
|
+
|
|
130
|
+
const hasWasm = await this.loadModule()
|
|
131
|
+
|
|
132
|
+
if (hasWasm && this.module) {
|
|
133
|
+
wrapper.wasmInstance = new this.module.HexGridWasm(width, height)
|
|
134
|
+
} else {
|
|
135
|
+
// Initialize fallback data
|
|
136
|
+
const size = width * height
|
|
137
|
+
wrapper.fallbackData = {
|
|
138
|
+
width,
|
|
139
|
+
height,
|
|
140
|
+
owners: new Uint8Array(size),
|
|
141
|
+
populations: new Float32Array(size),
|
|
142
|
+
neighborCache: []
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Precompute neighbors
|
|
146
|
+
for (let y = 0; y < height; y++) {
|
|
147
|
+
for (let x = 0; x < width; x++) {
|
|
148
|
+
const neighbors = new Int32Array(6).fill(-1)
|
|
149
|
+
const offset = y % 2 === 1 ? 1 : 0
|
|
150
|
+
|
|
151
|
+
// Odd-r offset coordinates
|
|
152
|
+
const dirs = y % 2 === 1
|
|
153
|
+
? [[1, 0], [0, -1], [-1, -1], [-1, 0], [-1, 1], [0, 1]]
|
|
154
|
+
: [[1, 0], [1, -1], [0, -1], [-1, 0], [0, 1], [1, 1]]
|
|
155
|
+
|
|
156
|
+
for (let i = 0; i < 6; i++) {
|
|
157
|
+
const nx = x + dirs[i][0]
|
|
158
|
+
const ny = y + dirs[i][1]
|
|
159
|
+
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
|
|
160
|
+
neighbors[i] = ny * width + nx
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
wrapper.fallbackData.neighborCache.push(neighbors)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return wrapper
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Check if WASM is being used
|
|
174
|
+
*/
|
|
175
|
+
isUsingWasm(): boolean {
|
|
176
|
+
return this.wasmInstance !== null
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Get cell owner
|
|
181
|
+
*/
|
|
182
|
+
getOwner(index: number): number {
|
|
183
|
+
if (this.wasmInstance) {
|
|
184
|
+
return this.wasmInstance.get_owner(index)
|
|
185
|
+
}
|
|
186
|
+
return this.fallbackData?.owners[index] ?? 0
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Set cell owner
|
|
191
|
+
*/
|
|
192
|
+
setOwner(index: number, owner: number): void {
|
|
193
|
+
if (this.wasmInstance) {
|
|
194
|
+
this.wasmInstance.set_owner(index, owner)
|
|
195
|
+
} else if (this.fallbackData) {
|
|
196
|
+
this.fallbackData.owners[index] = owner
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Set cell population
|
|
202
|
+
*/
|
|
203
|
+
setPopulation(index: number, population: number): void {
|
|
204
|
+
if (this.wasmInstance) {
|
|
205
|
+
this.wasmInstance.set_population(index, population)
|
|
206
|
+
} else if (this.fallbackData) {
|
|
207
|
+
this.fallbackData.populations[index] = population
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Get neighbors of a cell
|
|
213
|
+
*/
|
|
214
|
+
getNeighbors(index: number): Int32Array {
|
|
215
|
+
if (this.wasmInstance) {
|
|
216
|
+
return this.wasmInstance.get_neighbors(index)
|
|
217
|
+
}
|
|
218
|
+
return this.fallbackData?.neighborCache[index] ?? new Int32Array(6).fill(-1)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Step the infection simulation
|
|
223
|
+
*/
|
|
224
|
+
stepInfection(infectionRate: number = 0.1, infectionThreshold: number = 1.0): number[] {
|
|
225
|
+
if (this.wasmInstance) {
|
|
226
|
+
return Array.from(this.wasmInstance.step_infection(infectionRate, infectionThreshold))
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Fallback implementation
|
|
230
|
+
const changed: number[] = []
|
|
231
|
+
const data = this.fallbackData!
|
|
232
|
+
const size = data.width * data.height
|
|
233
|
+
|
|
234
|
+
// Simple infection spread (basic fallback)
|
|
235
|
+
const newOwners = new Uint8Array(data.owners)
|
|
236
|
+
|
|
237
|
+
for (let i = 0; i < size; i++) {
|
|
238
|
+
if (data.owners[i] === 0) continue
|
|
239
|
+
|
|
240
|
+
const neighbors = data.neighborCache[i]
|
|
241
|
+
for (const ni of neighbors) {
|
|
242
|
+
if (ni < 0) continue
|
|
243
|
+
|
|
244
|
+
if (data.owners[ni] !== data.owners[i] && Math.random() < infectionRate) {
|
|
245
|
+
newOwners[ni] = data.owners[i]
|
|
246
|
+
changed.push(ni)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
data.owners = newOwners
|
|
252
|
+
return changed
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Find connected regions for an owner
|
|
257
|
+
*/
|
|
258
|
+
findConnectedRegions(owner: number): number[] {
|
|
259
|
+
if (this.wasmInstance) {
|
|
260
|
+
return Array.from(this.wasmInstance.find_connected_regions(owner))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Fallback: BFS implementation
|
|
264
|
+
const data = this.fallbackData!
|
|
265
|
+
const size = data.width * data.height
|
|
266
|
+
const regionIds = new Array(size).fill(0)
|
|
267
|
+
const visited = new Array(size).fill(false)
|
|
268
|
+
let currentRegion = 1
|
|
269
|
+
|
|
270
|
+
for (let start = 0; start < size; start++) {
|
|
271
|
+
if (visited[start] || data.owners[start] !== owner) continue
|
|
272
|
+
|
|
273
|
+
const queue = [start]
|
|
274
|
+
visited[start] = true
|
|
275
|
+
|
|
276
|
+
while (queue.length > 0) {
|
|
277
|
+
const idx = queue.shift()!
|
|
278
|
+
regionIds[idx] = currentRegion
|
|
279
|
+
|
|
280
|
+
for (const ni of data.neighborCache[idx]) {
|
|
281
|
+
if (ni < 0 || visited[ni] || data.owners[ni] !== owner) continue
|
|
282
|
+
visited[ni] = true
|
|
283
|
+
queue.push(ni)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
currentRegion++
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return regionIds
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Find border cells for an owner
|
|
295
|
+
*/
|
|
296
|
+
findBorderCells(owner: number): number[] {
|
|
297
|
+
if (this.wasmInstance) {
|
|
298
|
+
return Array.from(this.wasmInstance.find_border_cells(owner))
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const data = this.fallbackData!
|
|
302
|
+
const borders: number[] = []
|
|
303
|
+
|
|
304
|
+
for (let i = 0; i < data.owners.length; i++) {
|
|
305
|
+
if (data.owners[i] !== owner) continue
|
|
306
|
+
|
|
307
|
+
const isBorder = data.neighborCache[i].some(ni => {
|
|
308
|
+
if (ni < 0) return true
|
|
309
|
+
return data.owners[ni] !== owner
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
if (isBorder) borders.push(i)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return borders
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Find path between two cells
|
|
320
|
+
*/
|
|
321
|
+
findPath(start: number, end: number, ownerFilter: number = 0): number[] {
|
|
322
|
+
if (this.wasmInstance) {
|
|
323
|
+
return Array.from(this.wasmInstance.find_path(start, end, ownerFilter))
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Fallback: A* implementation
|
|
327
|
+
const data = this.fallbackData!
|
|
328
|
+
const gScore = new Map<number, number>()
|
|
329
|
+
const fScore = new Map<number, number>()
|
|
330
|
+
const cameFrom = new Map<number, number>()
|
|
331
|
+
const openSet = new Set<number>([start])
|
|
332
|
+
|
|
333
|
+
const heuristic = (a: number, b: number) => {
|
|
334
|
+
const ax = a % data.width
|
|
335
|
+
const ay = Math.floor(a / data.width)
|
|
336
|
+
const bx = b % data.width
|
|
337
|
+
const by = Math.floor(b / data.width)
|
|
338
|
+
return Math.abs(bx - ax) + Math.abs(by - ay)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
gScore.set(start, 0)
|
|
342
|
+
fScore.set(start, heuristic(start, end))
|
|
343
|
+
|
|
344
|
+
while (openSet.size > 0) {
|
|
345
|
+
// Find node with lowest fScore
|
|
346
|
+
let current = -1
|
|
347
|
+
let lowestF = Infinity
|
|
348
|
+
for (const node of openSet) {
|
|
349
|
+
const f = fScore.get(node) ?? Infinity
|
|
350
|
+
if (f < lowestF) {
|
|
351
|
+
lowestF = f
|
|
352
|
+
current = node
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (current === end) {
|
|
357
|
+
// Reconstruct path
|
|
358
|
+
const path: number[] = [current]
|
|
359
|
+
while (cameFrom.has(current)) {
|
|
360
|
+
current = cameFrom.get(current)!
|
|
361
|
+
path.unshift(current)
|
|
362
|
+
}
|
|
363
|
+
return path
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
openSet.delete(current)
|
|
367
|
+
|
|
368
|
+
for (const neighbor of data.neighborCache[current]) {
|
|
369
|
+
if (neighbor < 0) continue
|
|
370
|
+
if (ownerFilter !== 0 && data.owners[neighbor] !== ownerFilter) continue
|
|
371
|
+
|
|
372
|
+
const tentativeG = (gScore.get(current) ?? Infinity) + 1
|
|
373
|
+
|
|
374
|
+
if (tentativeG < (gScore.get(neighbor) ?? Infinity)) {
|
|
375
|
+
cameFrom.set(neighbor, current)
|
|
376
|
+
gScore.set(neighbor, tentativeG)
|
|
377
|
+
fScore.set(neighbor, tentativeG + heuristic(neighbor, end))
|
|
378
|
+
openSet.add(neighbor)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return [] // No path found
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Get territory counts
|
|
388
|
+
*/
|
|
389
|
+
getTerritoryCounts(): Map<number, number> {
|
|
390
|
+
if (this.wasmInstance) {
|
|
391
|
+
const counts = this.wasmInstance.get_territory_counts()
|
|
392
|
+
const result = new Map<number, number>()
|
|
393
|
+
for (let i = 0; i < counts.length; i++) {
|
|
394
|
+
if (counts[i] > 0) {
|
|
395
|
+
result.set(i, counts[i])
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return result
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const data = this.fallbackData!
|
|
402
|
+
const counts = new Map<number, number>()
|
|
403
|
+
for (const owner of data.owners) {
|
|
404
|
+
if (owner !== 0) {
|
|
405
|
+
counts.set(owner, (counts.get(owner) ?? 0) + 1)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return counts
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Compute Gini coefficient
|
|
413
|
+
*/
|
|
414
|
+
computeGini(): number {
|
|
415
|
+
if (this.wasmInstance) {
|
|
416
|
+
return this.wasmInstance.compute_gini()
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const populations = Array.from(this.fallbackData?.populations ?? [])
|
|
420
|
+
.filter(p => p > 0)
|
|
421
|
+
.sort((a, b) => a - b)
|
|
422
|
+
|
|
423
|
+
if (populations.length === 0) return 0
|
|
424
|
+
|
|
425
|
+
const n = populations.length
|
|
426
|
+
let sum = 0
|
|
427
|
+
|
|
428
|
+
for (let i = 0; i < n; i++) {
|
|
429
|
+
sum += (2 * (i + 1) - n - 1) * populations[i]
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const mean = populations.reduce((a, b) => a + b, 0) / n
|
|
433
|
+
if (mean === 0) return 0
|
|
434
|
+
|
|
435
|
+
return sum / (n * n * mean)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Compute Shannon entropy
|
|
440
|
+
*/
|
|
441
|
+
computeEntropy(): number {
|
|
442
|
+
if (this.wasmInstance) {
|
|
443
|
+
return this.wasmInstance.compute_entropy()
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const counts = this.getTerritoryCounts()
|
|
447
|
+
const total = Array.from(counts.values()).reduce((a, b) => a + b, 0)
|
|
448
|
+
if (total === 0) return 0
|
|
449
|
+
|
|
450
|
+
let entropy = 0
|
|
451
|
+
for (const count of counts.values()) {
|
|
452
|
+
if (count > 0) {
|
|
453
|
+
const p = count / total
|
|
454
|
+
entropy -= p * Math.log(p)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return entropy
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* K-means clustering
|
|
463
|
+
*/
|
|
464
|
+
kmeansCluster(k: number, iterations: number = 20): number[] {
|
|
465
|
+
if (this.wasmInstance) {
|
|
466
|
+
return Array.from(this.wasmInstance.kmeans_cluster(k, iterations))
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Fallback implementation
|
|
470
|
+
const data = this.fallbackData!
|
|
471
|
+
const positions: Array<{ x: number; y: number; idx: number }> = []
|
|
472
|
+
|
|
473
|
+
for (let i = 0; i < data.owners.length; i++) {
|
|
474
|
+
if (data.owners[i] !== 0) {
|
|
475
|
+
positions.push({
|
|
476
|
+
x: i % data.width,
|
|
477
|
+
y: Math.floor(i / data.width),
|
|
478
|
+
idx: i
|
|
479
|
+
})
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (positions.length === 0 || k === 0) {
|
|
484
|
+
return new Array(data.owners.length).fill(0)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Initialize centroids randomly
|
|
488
|
+
const centroids: Array<{ x: number; y: number }> = []
|
|
489
|
+
const shuffled = [...positions].sort(() => Math.random() - 0.5)
|
|
490
|
+
for (let i = 0; i < Math.min(k, shuffled.length); i++) {
|
|
491
|
+
centroids.push({ x: shuffled[i].x, y: shuffled[i].y })
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const assignments = new Array(positions.length).fill(0)
|
|
495
|
+
|
|
496
|
+
// Run iterations
|
|
497
|
+
for (let iter = 0; iter < iterations; iter++) {
|
|
498
|
+
// Assign points to nearest centroid
|
|
499
|
+
for (let i = 0; i < positions.length; i++) {
|
|
500
|
+
let minDist = Infinity
|
|
501
|
+
let bestCluster = 0
|
|
502
|
+
|
|
503
|
+
for (let j = 0; j < centroids.length; j++) {
|
|
504
|
+
const dx = positions[i].x - centroids[j].x
|
|
505
|
+
const dy = positions[i].y - centroids[j].y
|
|
506
|
+
const dist = dx * dx + dy * dy
|
|
507
|
+
|
|
508
|
+
if (dist < minDist) {
|
|
509
|
+
minDist = dist
|
|
510
|
+
bestCluster = j
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
assignments[i] = bestCluster
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Update centroids
|
|
518
|
+
const sums = centroids.map(() => ({ x: 0, y: 0, count: 0 }))
|
|
519
|
+
|
|
520
|
+
for (let i = 0; i < positions.length; i++) {
|
|
521
|
+
const cluster = assignments[i]
|
|
522
|
+
sums[cluster].x += positions[i].x
|
|
523
|
+
sums[cluster].y += positions[i].y
|
|
524
|
+
sums[cluster].count++
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
for (let j = 0; j < centroids.length; j++) {
|
|
528
|
+
if (sums[j].count > 0) {
|
|
529
|
+
centroids[j].x = sums[j].x / sums[j].count
|
|
530
|
+
centroids[j].y = sums[j].y / sums[j].count
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Return cluster assignments for all cells
|
|
536
|
+
const result = new Array(data.owners.length).fill(0)
|
|
537
|
+
for (let i = 0; i < positions.length; i++) {
|
|
538
|
+
result[positions[i].idx] = assignments[i]
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return result
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Get size
|
|
546
|
+
*/
|
|
547
|
+
size(): number {
|
|
548
|
+
return this.wasmInstance?.size() ?? (this.width * this.height)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Clear all cells
|
|
553
|
+
*/
|
|
554
|
+
clear(): void {
|
|
555
|
+
if (this.wasmInstance) {
|
|
556
|
+
this.wasmInstance.clear()
|
|
557
|
+
} else if (this.fallbackData) {
|
|
558
|
+
this.fallbackData.owners.fill(0)
|
|
559
|
+
this.fallbackData.populations.fill(0)
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Dispose resources
|
|
565
|
+
*/
|
|
566
|
+
dispose(): void {
|
|
567
|
+
if (this.wasmInstance) {
|
|
568
|
+
this.wasmInstance.free()
|
|
569
|
+
this.wasmInstance = null
|
|
570
|
+
}
|
|
571
|
+
this.fallbackData = null
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Flow Field WASM Wrapper
|
|
577
|
+
*/
|
|
578
|
+
export class FlowFieldWasmWrapper {
|
|
579
|
+
private wasmInstance: FlowFieldWasmInstance | null = null
|
|
580
|
+
private fallbackData: {
|
|
581
|
+
width: number
|
|
582
|
+
height: number
|
|
583
|
+
velocityX: Float32Array
|
|
584
|
+
velocityY: Float32Array
|
|
585
|
+
} | null = null
|
|
586
|
+
|
|
587
|
+
private constructor(
|
|
588
|
+
private readonly width: number,
|
|
589
|
+
private readonly height: number
|
|
590
|
+
) {}
|
|
591
|
+
|
|
592
|
+
static async create(width: number, height: number): Promise<FlowFieldWasmWrapper> {
|
|
593
|
+
const wrapper = new FlowFieldWasmWrapper(width, height)
|
|
594
|
+
|
|
595
|
+
const hasWasm = await HexGridWasmWrapper.loadModule()
|
|
596
|
+
|
|
597
|
+
if (hasWasm) {
|
|
598
|
+
try {
|
|
599
|
+
const module = await import('../rust/pkg/hexgrid_wasm')
|
|
600
|
+
wrapper.wasmInstance = new module.FlowFieldWasm(width, height)
|
|
601
|
+
} catch {
|
|
602
|
+
// Fall through to fallback
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (!wrapper.wasmInstance) {
|
|
607
|
+
const size = width * height
|
|
608
|
+
wrapper.fallbackData = {
|
|
609
|
+
width,
|
|
610
|
+
height,
|
|
611
|
+
velocityX: new Float32Array(size),
|
|
612
|
+
velocityY: new Float32Array(size)
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return wrapper
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
isUsingWasm(): boolean {
|
|
620
|
+
return this.wasmInstance !== null
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
addSource(x: number, y: number, strength: number): void {
|
|
624
|
+
if (this.wasmInstance) {
|
|
625
|
+
this.wasmInstance.add_source(x, y, strength)
|
|
626
|
+
return
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const data = this.fallbackData!
|
|
630
|
+
for (let j = 0; j < data.height; j++) {
|
|
631
|
+
for (let i = 0; i < data.width; i++) {
|
|
632
|
+
const dx = i - x
|
|
633
|
+
const dy = j - y
|
|
634
|
+
const distSq = dx * dx + dy * dy + 0.0001
|
|
635
|
+
const dist = Math.sqrt(distSq)
|
|
636
|
+
|
|
637
|
+
const idx = j * data.width + i
|
|
638
|
+
data.velocityX[idx] += strength * dx / (dist * distSq)
|
|
639
|
+
data.velocityY[idx] += strength * dy / (dist * distSq)
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
addVortex(x: number, y: number, strength: number): void {
|
|
645
|
+
if (this.wasmInstance) {
|
|
646
|
+
this.wasmInstance.add_vortex(x, y, strength)
|
|
647
|
+
return
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const data = this.fallbackData!
|
|
651
|
+
for (let j = 0; j < data.height; j++) {
|
|
652
|
+
for (let i = 0; i < data.width; i++) {
|
|
653
|
+
const dx = i - x
|
|
654
|
+
const dy = j - y
|
|
655
|
+
const distSq = dx * dx + dy * dy + 0.0001
|
|
656
|
+
|
|
657
|
+
const idx = j * data.width + i
|
|
658
|
+
data.velocityX[idx] += -strength * dy / distSq
|
|
659
|
+
data.velocityY[idx] += strength * dx / distSq
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
sample(x: number, y: number): [number, number] {
|
|
665
|
+
if (this.wasmInstance) {
|
|
666
|
+
const result = this.wasmInstance.sample(x, y)
|
|
667
|
+
return [result[0], result[1]]
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const data = this.fallbackData!
|
|
671
|
+
const x0 = Math.min(Math.floor(x), data.width - 2)
|
|
672
|
+
const y0 = Math.min(Math.floor(y), data.height - 2)
|
|
673
|
+
const x1 = x0 + 1
|
|
674
|
+
const y1 = y0 + 1
|
|
675
|
+
|
|
676
|
+
const tx = x - x0
|
|
677
|
+
const ty = y - y0
|
|
678
|
+
|
|
679
|
+
const i00 = y0 * data.width + x0
|
|
680
|
+
const i10 = y0 * data.width + x1
|
|
681
|
+
const i01 = y1 * data.width + x0
|
|
682
|
+
const i11 = y1 * data.width + x1
|
|
683
|
+
|
|
684
|
+
const vx = data.velocityX[i00] * (1 - tx) * (1 - ty)
|
|
685
|
+
+ data.velocityX[i10] * tx * (1 - ty)
|
|
686
|
+
+ data.velocityX[i01] * (1 - tx) * ty
|
|
687
|
+
+ data.velocityX[i11] * tx * ty
|
|
688
|
+
|
|
689
|
+
const vy = data.velocityY[i00] * (1 - tx) * (1 - ty)
|
|
690
|
+
+ data.velocityY[i10] * tx * (1 - ty)
|
|
691
|
+
+ data.velocityY[i01] * (1 - tx) * ty
|
|
692
|
+
+ data.velocityY[i11] * tx * ty
|
|
693
|
+
|
|
694
|
+
return [vx, vy]
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
computeDivergence(): Float32Array {
|
|
698
|
+
if (this.wasmInstance) {
|
|
699
|
+
return this.wasmInstance.compute_divergence()
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const data = this.fallbackData!
|
|
703
|
+
const div = new Float32Array(data.width * data.height)
|
|
704
|
+
|
|
705
|
+
for (let j = 1; j < data.height - 1; j++) {
|
|
706
|
+
for (let i = 1; i < data.width - 1; i++) {
|
|
707
|
+
const idx = j * data.width + i
|
|
708
|
+
const dudx = (data.velocityX[idx + 1] - data.velocityX[idx - 1]) * 0.5
|
|
709
|
+
const dvdy = (data.velocityY[idx + data.width] - data.velocityY[idx - data.width]) * 0.5
|
|
710
|
+
div[idx] = dudx + dvdy
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
return div
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
computeCurl(): Float32Array {
|
|
718
|
+
if (this.wasmInstance) {
|
|
719
|
+
return this.wasmInstance.compute_curl()
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const data = this.fallbackData!
|
|
723
|
+
const curl = new Float32Array(data.width * data.height)
|
|
724
|
+
|
|
725
|
+
for (let j = 1; j < data.height - 1; j++) {
|
|
726
|
+
for (let i = 1; i < data.width - 1; i++) {
|
|
727
|
+
const idx = j * data.width + i
|
|
728
|
+
const dvdx = (data.velocityY[idx + 1] - data.velocityY[idx - 1]) * 0.5
|
|
729
|
+
const dudy = (data.velocityX[idx + data.width] - data.velocityX[idx - data.width]) * 0.5
|
|
730
|
+
curl[idx] = dvdx - dudy
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return curl
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
clear(): void {
|
|
738
|
+
if (this.wasmInstance) {
|
|
739
|
+
this.wasmInstance.clear()
|
|
740
|
+
} else if (this.fallbackData) {
|
|
741
|
+
this.fallbackData.velocityX.fill(0)
|
|
742
|
+
this.fallbackData.velocityY.fill(0)
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
dispose(): void {
|
|
747
|
+
if (this.wasmInstance) {
|
|
748
|
+
this.wasmInstance.free()
|
|
749
|
+
this.wasmInstance = null
|
|
750
|
+
}
|
|
751
|
+
this.fallbackData = null
|
|
752
|
+
}
|
|
753
|
+
}
|