@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.
Files changed (46) hide show
  1. package/.eslintrc.json +28 -0
  2. package/LICENSE +39 -0
  3. package/README.md +291 -0
  4. package/examples/basic-usage.tsx +52 -0
  5. package/package.json +65 -0
  6. package/public/hexgrid-worker.js +1763 -0
  7. package/rust/Cargo.toml +41 -0
  8. package/rust/src/lib.rs +740 -0
  9. package/rust/src/math.rs +574 -0
  10. package/rust/src/spatial.rs +245 -0
  11. package/rust/src/statistics.rs +496 -0
  12. package/src/HexGridEnhanced.ts +16 -0
  13. package/src/Snapshot.ts +1402 -0
  14. package/src/adapters.ts +65 -0
  15. package/src/algorithms/AdvancedStatistics.ts +328 -0
  16. package/src/algorithms/BayesianStatistics.ts +317 -0
  17. package/src/algorithms/FlowField.ts +126 -0
  18. package/src/algorithms/FluidSimulation.ts +99 -0
  19. package/src/algorithms/GraphAlgorithms.ts +184 -0
  20. package/src/algorithms/OutlierDetection.ts +391 -0
  21. package/src/algorithms/ParticleSystem.ts +85 -0
  22. package/src/algorithms/index.ts +13 -0
  23. package/src/compat.ts +96 -0
  24. package/src/components/HexGrid.tsx +31 -0
  25. package/src/components/NarrationOverlay.tsx +221 -0
  26. package/src/components/index.ts +2 -0
  27. package/src/features.ts +125 -0
  28. package/src/index.ts +30 -0
  29. package/src/math/HexCoordinates.ts +15 -0
  30. package/src/math/Matrix4.ts +35 -0
  31. package/src/math/Quaternion.ts +37 -0
  32. package/src/math/SpatialIndex.ts +114 -0
  33. package/src/math/Vector3.ts +69 -0
  34. package/src/math/index.ts +11 -0
  35. package/src/note-adapter.ts +124 -0
  36. package/src/ontology-adapter.ts +77 -0
  37. package/src/stores/index.ts +1 -0
  38. package/src/stores/uiStore.ts +85 -0
  39. package/src/types/index.ts +3 -0
  40. package/src/types.ts +152 -0
  41. package/src/utils/image-utils.ts +25 -0
  42. package/src/wasm/HexGridWasmWrapper.ts +753 -0
  43. package/src/wasm/index.ts +7 -0
  44. package/src/workers/hexgrid-math.ts +177 -0
  45. package/src/workers/hexgrid-worker.worker.ts +1807 -0
  46. package/tsconfig.json +18 -0
@@ -0,0 +1,1807 @@
1
+ /// <reference lib="webworker" />
2
+
3
+ // Hexgrid web worker - clean, defensive implementation
4
+ // Features:
5
+ // - centroid-based cohesion bias (workerDebug.cohesionBoost)
6
+ // - conservative post-optimization merge (opt-in via workerDebug.enableMerges)
7
+ // - evolution throttling (workerDebug.evolutionIntervalMs)
8
+ // - defensive guards and error reporting via postMessage({type:'error', ...})
9
+
10
+ import {
11
+ getGridBounds as _getGridBounds,
12
+ distanceBetween as _distanceBetween,
13
+ calculateUvBoundsFromGridPosition as _calculateUvBoundsFromGridPosition,
14
+ calculateContiguity as _calculateContiguity,
15
+ calculatePhotoContiguity as _calculatePhotoContiguity,
16
+ } from './hexgrid-math'
17
+
18
+ export interface Photo { id: string; title?: string; alt?: string; imageUrl?: string; velocity?: number }
19
+ export interface Infection { photo: Photo; gridPosition: [number, number]; infectionTime: number; generation: number; uvBounds: [number, number, number, number]; scale: number; growthRate?: number; tilesX?: number; tilesY?: number }
20
+ export interface InfectionSystemState { infections: Map<number, Infection>; availableIndices: number[]; lastEvolutionTime: number; generation: number; tileCenters?: Array<{ photoId: string; clusterIndex: number; centers: Array<{ x: number; y: number; col: number; row: number }> }> }
21
+
22
+ const WORKER_ID = Math.random().toString(36).substring(7)
23
+ console.log('[hexgrid-worker] loaded id=', WORKER_ID)
24
+
25
+ const workerDebug: any = {
26
+ cohesionBoost: 6.0, // BOOSTED: strongly favor growth near cluster centroids to build larger regions
27
+ enableMerges: true, // ENABLED: merge small fragments into nearby larger clusters
28
+ mergeSmallComponentsThreshold: 20, // INCREASED: merge clusters of 20 hexes or fewer
29
+ mergeLogs: false,
30
+ evolutionIntervalMs: 30000,
31
+ debugLogs: false,
32
+ enableCellDeath: true, // ENABLED: allow fully surrounded cells to die and respawn with better positioning
33
+ cellDeathProbability: 0.05, // 5% chance per evolution for fully surrounded cells to reset
34
+ enableMutation: true, // ENABLED: allow dying cells to mutate into different photos
35
+ mutationProbability: 0.3, // 30% chance for a dying cell to respawn as a different photo
36
+ enableVirilityBoost: true, // ENABLED: boost infection rate based on photo velocity/upvotes
37
+ virilityMultiplier: 1.0, // Multiplier for virility effect (1.0 = normal, higher = more impact)
38
+ annealingRate: 2.0, // Multiplier for death/churn rates to help system escape local optima (1.0 = normal, higher = more reorganization)
39
+ enableEntropyDecay: true, // ENABLED: entropy decay - successful/dominant photos decay over time to allow new dominance
40
+ entropyDecayBaseRate: 0.02, // Base decay rate per generation (2% for highly dominant photos)
41
+ entropyDominanceThreshold: 0.15, // Territory share threshold to be considered "dominant" (15%)
42
+ entropySuccessVelocityThreshold: 50, // Velocity threshold to be considered "successful" (only successful photos decay)
43
+ entropyTimeMultiplier: 0.1 // Multiplier for time-as-dominant effect (0.1 = each generation as dominant adds 10% to decay rate)
44
+ }
45
+
46
+ // Tuning flags for cluster tiling behaviour
47
+ workerDebug.clusterPreserveAspect = true // when true, preserve cluster aspect ratio when mapping to tile grid
48
+ workerDebug.clusterDynamicTiling = true // when true, calculate tilesX/tilesY dynamically based on cluster aspect ratio
49
+ workerDebug.clusterAnchor = 'center' // 'center' or 'min' (used during aspect correction)
50
+ workerDebug.clusterGlobalAlign = false // when true, clusters snap to global tile anchor for better neighbor alignment
51
+ workerDebug.clusterUvInset = 0.0 // shrink UVs slightly to allow texture filtering/edge blending (0..0.5)
52
+ workerDebug.clusterJitter = 0.0 // small (0..0.5) fractional jitter applied to normalized coords before quantization
53
+ // adjacency mode for cluster tiling: 'hex' (6-way) or 'rect' (4-way). 'rect' gives raster-like, cohesive images
54
+ workerDebug.clusterAdjacency = 'rect'
55
+ // maximum number of tiles to allocate for a cluster when dynamically expanding (cap)
56
+ workerDebug.clusterMaxTiles = 128
57
+ // whether to 'contain' (fit whole image within cluster bounds) or 'cover' (fill cluster and allow cropping)
58
+ workerDebug.clusterFillMode = 'contain'
59
+ // scan order for filling tiles: 'row' = left->right each row, 'serpentine' = zig-zag per row
60
+ workerDebug.clusterScanMode = 'row'
61
+ // when true, compute tile centers using hex-row parity offsets so ordering follows hex staggering
62
+ workerDebug.clusterParityAware = true
63
+ // when true, include computed tile centers in evolved message for debug visualization
64
+ workerDebug.showTileCenters = false
65
+ // when true, enable direct hex lattice mapping fast-path (parity-correct row/col inference)
66
+ workerDebug.clusterHexLattice = true
67
+ // when true, horizontally nudge odd rows' UV sampling by half a tile width to compensate
68
+ // for physical hex center staggering (attempts to eliminate visible half-hex seams)
69
+ workerDebug.clusterParityUvShift = true
70
+ // when true, compact gaps in each row of the hex lattice for more contiguous image tiles
71
+ workerDebug.clusterCompactGaps = true
72
+
73
+ const cache: any = { neighborMap: new Map<number, number[]>(), gridBounds: null, photoClusters: new Map<string, number[]>(), connectedComponents: new Map<string, number[][]>(), gridPositions: new Map<number, [number, number]>(), lastInfectionCount: 0, lastGeneration: -1, isSpherical: false, cacheReady: false }
74
+ // Track dominance history for entropy decay: photoId -> generations as dominant
75
+ const dominanceHistory: Map<string, number> = new Map()
76
+
77
+ function safePostError(err: unknown) { try { self.postMessage({ type: 'error', error: err instanceof Error ? err.message : String(err) }) } catch (e) {} }
78
+
79
+ function getGridBounds(positions: [number, number, number][]) {
80
+ if (cache.gridBounds) return cache.gridBounds
81
+ const bounds = _getGridBounds(positions)
82
+ cache.gridBounds = bounds
83
+ return bounds
84
+ }
85
+
86
+ function distanceBetween(
87
+ a: [number, number, number],
88
+ b: [number, number, number],
89
+ bounds: { width: number; height: number },
90
+ isSpherical: boolean
91
+ ) {
92
+ return _distanceBetween(a, b, bounds, isSpherical)
93
+ }
94
+
95
+ function getNeighborsCached(index: number, positions: [number, number, number][], hexRadius: number): number[] {
96
+ // Immediate return if cached - no blocking
97
+ if (cache.neighborMap.has(index)) {
98
+ const cached = cache.neighborMap.get(index)!
99
+ if (Array.isArray(cached)) return cached
100
+ // Invalid cache entry - clear it and recompute
101
+ cache.neighborMap.delete(index)
102
+ }
103
+
104
+ // Validate inputs before computation
105
+ if (!positions || !Array.isArray(positions) || positions.length === 0) {
106
+ console.warn('[getNeighborsCached] Invalid positions array, returning empty')
107
+ return []
108
+ }
109
+ if (typeof index !== 'number' || index < 0 || index >= positions.length) {
110
+ console.warn('[getNeighborsCached] Invalid index', index, 'for positions length', positions.length)
111
+ return []
112
+ }
113
+ if (typeof hexRadius !== 'number' || hexRadius <= 0) {
114
+ console.warn('[getNeighborsCached] Invalid hexRadius', hexRadius)
115
+ return []
116
+ }
117
+
118
+ const out: number[] = []
119
+ const pos = positions[index]
120
+ if (!pos) {
121
+ console.warn('[getNeighborsCached] No position at index', index)
122
+ return out
123
+ }
124
+
125
+ try {
126
+ const bounds = getGridBounds(positions)
127
+ const threshold = Math.sqrt(3) * hexRadius * 1.15
128
+ const isSpherical = !!cache.isSpherical
129
+
130
+ // Fast path: check only nearby candidates (≈6 neighbors for hex grid)
131
+ // For hex grids, each hex has at most 6 neighbors
132
+ // Limit search to reduce O(n²) to O(n)
133
+ const maxNeighbors = 10 // Safety margin for irregular grids
134
+
135
+ for (let j = 0; j < positions.length; j++) {
136
+ if (j === index) continue
137
+ const p2 = positions[j]
138
+ if (!p2) continue
139
+ const d = distanceBetween(pos, p2, bounds, isSpherical)
140
+ if (d <= threshold) {
141
+ out.push(j)
142
+ // Early exit if we found enough neighbors
143
+ if (out.length >= maxNeighbors) break
144
+ }
145
+ }
146
+
147
+ cache.neighborMap.set(index, out)
148
+ } catch (e) {
149
+ console.error('[getNeighborsCached] Error computing neighbors:', e)
150
+ return []
151
+ }
152
+
153
+ return out
154
+ }
155
+
156
+ // Calculate UV bounds for a tile based on its grid position within a tilesX x tilesY grid
157
+ // V=1.0 represents the top of the texture in this codebase
158
+ function calculateUvBoundsFromGridPosition(
159
+ gridCol: number,
160
+ gridRow: number,
161
+ tilesX: number,
162
+ tilesY: number
163
+ ): [number, number, number, number] {
164
+ return _calculateUvBoundsFromGridPosition(gridCol, gridRow, tilesX, tilesY)
165
+ }
166
+
167
+ function findConnectedComponents(indices: number[], positions: [number, number, number][], hexRadius: number): number[][] {
168
+ // Immediate synchronous check - if this doesn't log, the function isn't being called or is blocked
169
+ const startMarker = performance.now()
170
+ console.log('[findConnectedComponents] FUNCTION ENTERED - indices.length=', indices.length, 'positions.length=', positions.length, 'hexRadius=', hexRadius, 'marker=', startMarker)
171
+
172
+ // Validate inputs immediately
173
+ if (!indices || !Array.isArray(indices)) {
174
+ console.error('[findConnectedComponents] Invalid indices:', indices)
175
+ return []
176
+ }
177
+ if (!positions || !Array.isArray(positions)) {
178
+ console.error('[findConnectedComponents] Invalid positions:', positions)
179
+ return []
180
+ }
181
+ if (typeof hexRadius !== 'number' || hexRadius <= 0) {
182
+ console.error('[findConnectedComponents] Invalid hexRadius:', hexRadius)
183
+ return []
184
+ }
185
+
186
+ console.log('[findConnectedComponents] About to enter try block')
187
+
188
+ // Add immediate log after try block entry to confirm execution reaches here
189
+ let tryBlockEntered = false
190
+ try {
191
+ tryBlockEntered = true
192
+ console.log('[findConnectedComponents] ✅ TRY BLOCK ENTERED - marker=', performance.now() - startMarker, 'ms')
193
+ console.log('[findConnectedComponents] Inside try block - Starting with', indices.length, 'indices')
194
+ const set = new Set(indices);
195
+ const visited = new Set<number>();
196
+ const comps: number[][] = []
197
+ let componentCount = 0
198
+ for (const start of indices) {
199
+ if (visited.has(start)) continue
200
+ componentCount++
201
+ console.log('[findConnectedComponents] Starting component', componentCount, 'from index', start)
202
+ const q = [start];
203
+ visited.add(start);
204
+ const comp: number[] = []
205
+ let iterations = 0
206
+ const maxIterations = indices.length * 10 // Safety limit
207
+ while (q.length > 0) {
208
+ iterations++
209
+ if (iterations > maxIterations) {
210
+ console.error('[findConnectedComponents] Safety limit reached! indices=', indices.length, 'component=', componentCount, 'iterations=', iterations)
211
+ break
212
+ }
213
+ if (iterations % 100 === 0) {
214
+ console.log('[findConnectedComponents] Component', componentCount, 'iteration', iterations, 'queue length', q.length)
215
+ }
216
+ const cur = q.shift()!
217
+ if (cur === undefined || cur === null) {
218
+ console.error('[findConnectedComponents] Invalid cur value:', cur)
219
+ break
220
+ }
221
+ comp.push(cur)
222
+ try {
223
+ const neighbors = getNeighborsCached(cur, positions, hexRadius)
224
+ if (!Array.isArray(neighbors)) {
225
+ console.error('[findConnectedComponents] getNeighborsCached returned non-array:', typeof neighbors, neighbors)
226
+ continue
227
+ }
228
+ for (const n of neighbors) {
229
+ if (typeof n !== 'number' || isNaN(n)) {
230
+ console.error('[findConnectedComponents] Invalid neighbor index:', n, 'type:', typeof n)
231
+ continue
232
+ }
233
+ if (!visited.has(n) && set.has(n)) {
234
+ visited.add(n);
235
+ q.push(n)
236
+ }
237
+ }
238
+ } catch (e) {
239
+ console.error('[findConnectedComponents] Error getting neighbors for index', cur, ':', e)
240
+ continue
241
+ }
242
+ }
243
+ console.log('[findConnectedComponents] Component', componentCount, 'complete:', comp.length, 'nodes,', iterations, 'iterations')
244
+ comps.push(comp)
245
+ }
246
+ console.log('[findConnectedComponents] Complete:', comps.length, 'components found')
247
+ const elapsed = performance.now() - startMarker
248
+ console.log('[findConnectedComponents] ✅ RETURNING - elapsed=', elapsed, 'ms, components=', comps.length)
249
+ return comps
250
+ } catch (e) {
251
+ const elapsed = performance.now() - startMarker
252
+ console.error('[findConnectedComponents] ERROR after', elapsed, 'ms:', e, 'indices.length=', indices.length, 'tryBlockEntered=', tryBlockEntered)
253
+ // If we never entered the try block, something is seriously wrong
254
+ if (!tryBlockEntered) {
255
+ console.error('[findConnectedComponents] CRITICAL: Try block never entered! This suggests a hang before try block.')
256
+ }
257
+ throw e
258
+ } finally {
259
+ const elapsed = performance.now() - startMarker
260
+ if (elapsed > 1000) {
261
+ console.warn('[findConnectedComponents] ⚠️ Function took', elapsed, 'ms to complete')
262
+ }
263
+ }
264
+ }
265
+
266
+ function calculatePhotoCentroids(
267
+ infections: Map<number, Infection>,
268
+ positions: [number, number, number][],
269
+ hexRadius: number
270
+ ) {
271
+ try {
272
+ console.log('[calculatePhotoCentroids] Starting with', infections.size, 'infections')
273
+ const byPhoto = new Map<string, number[]>()
274
+ for (const [idx, inf] of infections) {
275
+ if (!inf || !inf.photo) continue
276
+ const arr = byPhoto.get(inf.photo.id) || [];
277
+ arr.push(idx);
278
+ byPhoto.set(inf.photo.id, arr)
279
+ }
280
+ console.log('[calculatePhotoCentroids] Grouped into', byPhoto.size, 'photos')
281
+ const centroids = new Map<string, [number, number][]>()
282
+ let photoNum = 0
283
+ for (const [photoId, inds] of byPhoto) {
284
+ photoNum++
285
+ console.log('[calculatePhotoCentroids] Processing photo', photoNum, '/', byPhoto.size, 'photoId=', photoId, 'indices=', inds.length)
286
+ try {
287
+ console.log('[calculatePhotoCentroids] About to call findConnectedComponents with', inds.length, 'indices')
288
+ const callStartTime = performance.now()
289
+ let comps: number[][]
290
+ try {
291
+ // Add a pre-call validation to ensure we're not calling with invalid data
292
+ if (!inds || inds.length === 0) {
293
+ console.warn('[calculatePhotoCentroids] Empty indices array, skipping findConnectedComponents')
294
+ comps = []
295
+ } else if (!positions || positions.length === 0) {
296
+ console.warn('[calculatePhotoCentroids] Empty positions array, skipping findConnectedComponents')
297
+ comps = []
298
+ } else {
299
+ comps = findConnectedComponents(inds, positions, hexRadius)
300
+ const callElapsed = performance.now() - callStartTime
301
+ console.log('[calculatePhotoCentroids] findConnectedComponents RETURNED with', comps.length, 'components after', callElapsed, 'ms')
302
+ }
303
+ } catch (e) {
304
+ const callElapsed = performance.now() - callStartTime
305
+ console.error('[calculatePhotoCentroids] findConnectedComponents threw error after', callElapsed, 'ms:', e)
306
+ // Return empty components on error to allow evolution to continue
307
+ comps = []
308
+ }
309
+ console.log('[calculatePhotoCentroids] findConnectedComponents returned', comps.length, 'components')
310
+ console.log('[calculatePhotoCentroids] Found', comps.length, 'components for photo', photoId)
311
+ const cs: [number, number][] = []
312
+ for (const comp of comps) {
313
+ let sx = 0, sy = 0
314
+ for (const i of comp) { const p = positions[i]; if (p) { sx += p[0]; sy += p[1] } }
315
+ if (comp.length > 0) cs.push([sx / comp.length, sy / comp.length])
316
+ }
317
+ centroids.set(photoId, cs)
318
+ } catch (e) {
319
+ console.error('[calculatePhotoCentroids] Error processing photo', photoId, ':', e)
320
+ centroids.set(photoId, [])
321
+ }
322
+ }
323
+ console.log('[calculatePhotoCentroids] Completed, returning', centroids.size, 'photo centroids')
324
+ return centroids
325
+ } catch (e) {
326
+ console.error('[calculatePhotoCentroids] FATAL ERROR:', e)
327
+ throw e
328
+ }
329
+ }
330
+
331
+ function calculateContiguity(indices: number[], positions: [number, number, number][], hexRadius: number) {
332
+ const getNeighbors = (index: number) => getNeighborsCached(index, positions, hexRadius)
333
+ return _calculateContiguity(indices, positions, hexRadius, getNeighbors)
334
+ }
335
+
336
+ // Assign cluster-aware grid positions so each hex in a cluster shows a different part of the image
337
+ // Returns tile centers for debug visualization when workerDebug.showTileCenters is enabled
338
+ function assignClusterGridPositions(
339
+ infections: Map<number, Infection>,
340
+ positions: [number, number, number][],
341
+ hexRadius: number
342
+ ): Array<{ photoId: string; clusterIndex: number; centers: Array<{ x: number; y: number; col: number; row: number }> }> {
343
+ const debugCenters: Array<{ photoId: string; clusterIndex: number; centers: Array<{ x: number; y: number; col: number; row: number }> }> = []
344
+
345
+ try {
346
+ console.log('[assignClusterGridPositions] Starting with', infections.size, 'infections')
347
+
348
+ // Group infections by photo
349
+ const byPhoto = new Map<string, number[]>()
350
+ for (const [idx, inf] of infections) {
351
+ if (!inf || !inf.photo) continue
352
+ const arr = byPhoto.get(inf.photo.id) || []
353
+ arr.push(idx)
354
+ byPhoto.set(inf.photo.id, arr)
355
+ }
356
+
357
+ console.log('[assignClusterGridPositions] Processing', byPhoto.size, 'unique photos')
358
+
359
+ // Cluster size analytics
360
+ let totalClusters = 0
361
+ let clusterSizes: number[] = []
362
+
363
+ // Process each photo's clusters
364
+ for (const [photoId, indices] of byPhoto) {
365
+ // Find connected components (separate clusters of the same photo)
366
+ const components = findConnectedComponents(indices, positions, hexRadius)
367
+
368
+ totalClusters += components.length
369
+ for (const comp of components) {
370
+ if (comp && comp.length > 0) clusterSizes.push(comp.length)
371
+ }
372
+
373
+ console.log('[assignClusterGridPositions] Photo', photoId.substring(0, 8), 'has', components.length, 'clusters, sizes:', components.map(c => c.length).join(','))
374
+
375
+ // Process each cluster separately
376
+ let clusterIndex = 0
377
+ for (const cluster of components) {
378
+ if (!cluster || cluster.length === 0) continue
379
+
380
+ // Get the tiling configuration from the first infection in the cluster
381
+ const firstInf = infections.get(cluster[0])
382
+ if (!firstInf) continue
383
+
384
+ // --- Hex lattice mapping fast-path -------------------------------------------------
385
+ // If enabled, derive tile coordinates directly from inferred axial-like row/col indices
386
+ // instead of using normalized bounding boxes + spatial nearest matching. This produces
387
+ // contiguous, parity-correct tiling where adjacent hexes map to adjacent UV tiles.
388
+ if (workerDebug.clusterHexLattice) {
389
+ try {
390
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
391
+ for (const idx of cluster) {
392
+ const p = positions[idx]; if (!p) continue
393
+ if (p[0] < minX) minX = p[0]; if (p[0] > maxX) maxX = p[0]
394
+ if (p[1] < minY) minY = p[1]; if (p[1] > maxY) maxY = p[1]
395
+ }
396
+ const clusterWidth = Math.max(0, maxX - minX)
397
+ const clusterHeight = Math.max(0, maxY - minY)
398
+
399
+ // Infer spacings from hexRadius (flat-top hex layout):
400
+ const horizSpacing = Math.sqrt(3) * hexRadius
401
+ const vertSpacing = 1.5 * hexRadius
402
+
403
+ // Build lattice coordinates (rowIndex, colIndex) respecting row parity offset.
404
+ const latticeCoords = new Map<number, { row: number; col: number }>()
405
+ let minRow = Infinity, maxRow = -Infinity, minCol = Infinity, maxCol = -Infinity
406
+ for (const id of cluster) {
407
+ const p = positions[id]; if (!p) continue
408
+ const rowF = (p[1] - minY) / vertSpacing
409
+ const row = Math.round(rowF)
410
+ // Row parity offset: odd rows in generatePixelScreen are shifted +0.5 * horizSpacing.
411
+ const rowOffset = (row % 2 === 1) ? (horizSpacing * 0.5) : 0
412
+ const colF = (p[0] - (minX + rowOffset)) / horizSpacing
413
+ const col = Math.round(colF)
414
+ latticeCoords.set(id, { row, col })
415
+ if (row < minRow) minRow = row; if (row > maxRow) maxRow = row
416
+ if (col < minCol) minCol = col; if (col > maxCol) maxCol = col
417
+ }
418
+
419
+ const latticeRows = maxRow - minRow + 1
420
+ const latticeCols = maxCol - minCol + 1
421
+
422
+ // Initial tile grid matches lattice extents.
423
+ let tilesX = latticeCols
424
+ let tilesY = latticeRows
425
+
426
+ // If we have more hexes than lattice cells due to rounding collisions, expand.
427
+ const rawTileCount = tilesX * tilesY
428
+ if (cluster.length > rawTileCount) {
429
+ // Simple expansion: grow columns while respecting max cap.
430
+ const MAX_TILES = typeof workerDebug.clusterMaxTiles === 'number' && workerDebug.clusterMaxTiles > 0 ? Math.floor(workerDebug.clusterMaxTiles) : 128
431
+ while (tilesX * tilesY < cluster.length && tilesX * tilesY < MAX_TILES) {
432
+ if (tilesX <= tilesY) tilesX++; else tilesY++
433
+ }
434
+ }
435
+
436
+ console.log('[assignClusterGridPositions][hex-lattice] cluster', photoId.substring(0,8), 'size', cluster.length,
437
+ 'latticeCols', latticeCols, 'latticeRows', latticeRows, 'tilesX', tilesX, 'tilesY', tilesY)
438
+
439
+ // Build optional serpentine ordering for assignment uniqueness (not strictly needed since lattice mapping is direct)
440
+ const serpentine = (workerDebug.clusterScanMode === 'serpentine')
441
+
442
+ // Assign each infection a gridPosition derived from lattice coordinates compressed into tile grid domain.
443
+ // Enhancement: compact gaps in each row for more contiguous image mapping
444
+ const compactGaps = workerDebug.clusterCompactGaps !== false
445
+
446
+ // Build row-by-row column mapping to handle gaps
447
+ const rowColMap = new Map<number, Map<number, number>>() // row -> (oldCol -> newCol)
448
+ if (compactGaps) {
449
+ for (let row = minRow; row <= maxRow; row++) {
450
+ const colsInRow = Array.from(latticeCoords.entries())
451
+ .filter(([_, lc]) => lc.row === row)
452
+ .map(([_, lc]) => lc.col)
453
+ .sort((a, b) => a - b)
454
+
455
+ const colMap = new Map<number, number>()
456
+ colsInRow.forEach((oldCol, newIdx) => {
457
+ colMap.set(oldCol, newIdx)
458
+ })
459
+ rowColMap.set(row, colMap)
460
+ }
461
+ }
462
+
463
+ // Collision detection: track which tiles are occupied
464
+ const tileOccupancy = new Map<string, number>() // "col,row" -> nodeId
465
+ const tileKey = (c: number, r: number) => `${c},${r}`
466
+
467
+ for (const id of cluster) {
468
+ const inf = infections.get(id); if (!inf) continue
469
+ const lc = latticeCoords.get(id); if (!lc) continue
470
+
471
+ let gridCol = compactGaps && rowColMap.has(lc.row)
472
+ ? (rowColMap.get(lc.row)!.get(lc.col) ?? (lc.col - minCol))
473
+ : (lc.col - minCol)
474
+ let gridRow = lc.row - minRow
475
+
476
+ if (serpentine && (gridRow % 2 === 1)) {
477
+ gridCol = (tilesX - 1) - gridCol
478
+ }
479
+
480
+ // Clamp to valid range
481
+ if (gridCol < 0) gridCol = 0; if (gridCol >= tilesX) gridCol = tilesX - 1
482
+ if (gridRow < 0) gridRow = 0; if (gridRow >= tilesY) gridRow = tilesY - 1
483
+
484
+ // Collision resolution: if tile is occupied, find nearest free tile
485
+ const key = tileKey(gridCol, gridRow)
486
+ if (tileOccupancy.has(key)) {
487
+ const nodePos = positions[id]
488
+ let bestCol = gridCol, bestRow = gridRow
489
+ let bestDist = Infinity
490
+
491
+ // Search in expanding radius for free tile
492
+ for (let radius = 1; radius <= Math.max(tilesX, tilesY); radius++) {
493
+ let found = false
494
+ for (let dc = -radius; dc <= radius; dc++) {
495
+ for (let dr = -radius; dr <= radius; dr++) {
496
+ if (Math.abs(dc) !== radius && Math.abs(dr) !== radius) continue // Only check perimeter
497
+ const testCol = gridCol + dc
498
+ const testRow = gridRow + dr
499
+ if (testCol < 0 || testCol >= tilesX || testRow < 0 || testRow >= tilesY) continue
500
+ const testKey = tileKey(testCol, testRow)
501
+ if (!tileOccupancy.has(testKey)) {
502
+ // Calculate distance to this tile's center
503
+ const tileU = (testCol + 0.5) / tilesX
504
+ const tileV = (testRow + 0.5) / tilesY
505
+ const tileCenterX = minX + tileU * clusterWidth
506
+ const tileCenterY = minY + tileV * clusterHeight
507
+ const dist = Math.hypot(nodePos[0] - tileCenterX, nodePos[1] - tileCenterY)
508
+ if (dist < bestDist) {
509
+ bestDist = dist
510
+ bestCol = testCol
511
+ bestRow = testRow
512
+ found = true
513
+ }
514
+ }
515
+ }
516
+ }
517
+ if (found) break
518
+ }
519
+ gridCol = bestCol
520
+ gridRow = bestRow
521
+ }
522
+
523
+ tileOccupancy.set(tileKey(gridCol, gridRow), id)
524
+
525
+ // Optionally support vertical anchor flip
526
+ if (workerDebug.clusterAnchor === 'max') {
527
+ gridRow = Math.max(0, tilesY - 1 - gridRow)
528
+ }
529
+
530
+ let uvBounds = calculateUvBoundsFromGridPosition(gridCol, gridRow, tilesX, tilesY)
531
+ const inset = Math.max(0, Math.min(0.49, Number(workerDebug.clusterUvInset) || 0))
532
+ if (inset > 0) {
533
+ const u0 = uvBounds[0], v0 = uvBounds[1], u1 = uvBounds[2], v1 = uvBounds[3]
534
+ const du = (u1 - u0) * inset
535
+ const dv = (v1 - v0) * inset
536
+ uvBounds = [u0 + du, v0 + dv, u1 - du, v1 - dv]
537
+ }
538
+ // Optional parity UV shift: shift odd rows horizontally by half a tile width in UV space.
539
+ // Enhanced: use precise hex geometry for sub-pixel accuracy
540
+ if (workerDebug.clusterParityUvShift && (gridRow % 2 === 1)) {
541
+ // Use actual lattice row parity from hex geometry, not tile row
542
+ const hexRowParity = lc.row % 2
543
+ const shift = hexRowParity === 1 ? 0.5 / tilesX : 0
544
+ let u0 = uvBounds[0] + shift
545
+ let u1 = uvBounds[2] + shift
546
+ // Wrap within [0,1]
547
+ if (u0 >= 1) u0 -= 1
548
+ if (u1 > 1) u1 -= 1
549
+ // Guard against pathological wrapping inversion (should not occur with shift<tileWidth)
550
+ if (u1 < u0) {
551
+ // If inverted due to wrapping edge case, clamp instead of wrap
552
+ u0 = Math.min(u0, 1 - (1 / tilesX))
553
+ u1 = Math.min(1, u0 + (1 / tilesX))
554
+ }
555
+ uvBounds = [u0, uvBounds[1], u1, uvBounds[3]]
556
+ }
557
+ infections.set(id, { ...inf, gridPosition: [gridCol, gridRow], uvBounds, tilesX, tilesY })
558
+ }
559
+
560
+ // Advance cluster index and continue to next cluster (skip legacy logic)
561
+ clusterIndex++
562
+ continue
563
+ } catch (e) {
564
+ console.warn('[assignClusterGridPositions][hex-lattice] failed, falling back to legacy path:', e)
565
+ // fall through to existing (spatial) logic
566
+ }
567
+ }
568
+
569
+ // Find the bounding box of this cluster in grid space first
570
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
571
+ for (const idx of cluster) {
572
+ const pos = positions[idx]
573
+ if (!pos) continue
574
+ minX = Math.min(minX, pos[0])
575
+ maxX = Math.max(maxX, pos[0])
576
+ minY = Math.min(minY, pos[1])
577
+ maxY = Math.max(maxY, pos[1])
578
+ }
579
+
580
+ const clusterWidth = Math.max(0, maxX - minX)
581
+ const clusterHeight = Math.max(0, maxY - minY)
582
+
583
+ console.log('[assignClusterGridPositions] Cluster bounds:', {
584
+ photoId: photoId.substring(0, 8),
585
+ clusterIndex,
586
+ hexCount: cluster.length,
587
+ minX: minX.toFixed(2),
588
+ maxX: maxX.toFixed(2),
589
+ minY: minY.toFixed(2),
590
+ maxY: maxY.toFixed(2),
591
+ width: clusterWidth.toFixed(2),
592
+ height: clusterHeight.toFixed(2)
593
+ })
594
+
595
+ // Calculate optimal tilesX and tilesY based on cluster aspect ratio
596
+ // This ensures the tile grid matches the spatial layout of the cluster
597
+ const clusterAspect = clusterHeight > 0 ? clusterWidth / clusterHeight : 1.0
598
+ const targetTileCount = 16 // Target ~16 tiles total for good image distribution
599
+
600
+ console.log('[assignClusterGridPositions] Cluster aspect:', clusterAspect.toFixed(3), '(width/height)')
601
+
602
+ let tilesX: number
603
+ let tilesY: number
604
+
605
+ if (cluster.length === 1) {
606
+ // Single hexagon: use 1x1
607
+ tilesX = 1
608
+ tilesY = 1
609
+ } else if (workerDebug.clusterDynamicTiling !== false) {
610
+ // Dynamic tiling: match cluster aspect ratio
611
+ // sqrt(tilesX * tilesY) = sqrt(targetTileCount)
612
+ // tilesX / tilesY = clusterAspect
613
+ // => tilesX = clusterAspect * tilesY
614
+ // => clusterAspect * tilesY * tilesY = targetTileCount
615
+ // => tilesY = sqrt(targetTileCount / clusterAspect)
616
+ tilesY = Math.max(1, Math.round(Math.sqrt(targetTileCount / clusterAspect)))
617
+ tilesX = Math.max(1, Math.round(clusterAspect * tilesY))
618
+
619
+ // Clamp to reasonable range
620
+ tilesX = Math.max(1, Math.min(8, tilesX))
621
+ tilesY = Math.max(1, Math.min(8, tilesY))
622
+ } else {
623
+ // Fallback to fixed tiling from infection config
624
+ tilesX = Math.max(1, firstInf.tilesX || 4)
625
+ tilesY = Math.max(1, firstInf.tilesY || 4)
626
+ }
627
+
628
+ // If the cluster contains more hexes than tiles, expand the tile grid
629
+ // to avoid many hexes mapping to the same UV tile (which causes repeating
630
+ // image patches). Preserve the tile aspect ratio but scale up the total
631
+ // tile count to be at least cluster.length, clamped to a safe maximum.
632
+ try {
633
+ const currentTileCount = tilesX * tilesY
634
+ const requiredTiles = Math.max(currentTileCount, cluster.length)
635
+ const MAX_TILES = typeof workerDebug.clusterMaxTiles === 'number' && workerDebug.clusterMaxTiles > 0 ? Math.max(1, Math.floor(workerDebug.clusterMaxTiles)) : 64
636
+ const targetTiles = Math.min(requiredTiles, MAX_TILES)
637
+
638
+ if (targetTiles > currentTileCount) {
639
+ // preserve aspect ratio roughly: ratio = tilesX / tilesY
640
+ const ratio = tilesX / Math.max(1, tilesY)
641
+ // compute new tilesY from targetTiles and ratio
642
+ let newTilesY = Math.max(1, Math.round(Math.sqrt(targetTiles / Math.max(1e-9, ratio))))
643
+ let newTilesX = Math.max(1, Math.round(ratio * newTilesY))
644
+ // if rounding produced fewer tiles than needed, bump progressively
645
+ while (newTilesX * newTilesY < targetTiles) {
646
+ if (newTilesX <= newTilesY) newTilesX++
647
+ else newTilesY++
648
+ if (newTilesX * newTilesY >= MAX_TILES) break
649
+ }
650
+ // clamp to reasonable maxima
651
+ newTilesX = Math.max(1, Math.min(16, newTilesX))
652
+ newTilesY = Math.max(1, Math.min(16, newTilesY))
653
+ tilesX = newTilesX
654
+ tilesY = newTilesY
655
+ console.log('[assignClusterGridPositions] Expanded tile grid to', tilesX, 'x', tilesY, '=', tilesX * tilesY, 'tiles')
656
+ }
657
+ } catch (e) {
658
+ // if anything goes wrong, keep original tilesX/tilesY
659
+ }
660
+
661
+ console.log('[assignClusterGridPositions] Final tile dimensions:', tilesX, 'x', tilesY, '=', tilesX * tilesY, 'tiles for', cluster.length, 'hexes')
662
+
663
+ // Single-hex or degenerate clusters: assign a deterministic tile so single hexes don't all use [0,0]
664
+ if (cluster.length === 1 || clusterWidth < 1e-6 || clusterHeight < 1e-6) {
665
+ const idx = cluster[0]
666
+ const inf = infections.get(idx)
667
+ if (!inf) continue
668
+ // Deterministic hash from index to pick a tile
669
+ const h = (idx * 2654435761) >>> 0
670
+ const gridCol = h % tilesX
671
+ let gridRow = ((h >>> 8) % tilesY)
672
+ // If configured, allow anchoring to the bottom of the image (flip vertical tile index)
673
+ if (workerDebug.clusterAnchor === 'max') {
674
+ gridRow = Math.max(0, tilesY - 1 - gridRow)
675
+ }
676
+ const uvBounds = calculateUvBoundsFromGridPosition(gridCol, gridRow, tilesX, tilesY)
677
+ infections.set(idx, { ...inf, gridPosition: [gridCol, gridRow], uvBounds })
678
+ continue
679
+ }
680
+
681
+ // Optionally preserve aspect ratio when mapping cluster to tile grid
682
+ const preserveAspect = !!workerDebug.clusterPreserveAspect
683
+ let normMinX = minX, normMinY = minY, normWidth = clusterWidth, normHeight = clusterHeight
684
+
685
+ if (preserveAspect) {
686
+ const clusterAspect = clusterWidth / clusterHeight
687
+ const tileAspect = tilesX / tilesY
688
+ const fillMode = workerDebug.clusterFillMode || 'contain'
689
+ if (fillMode === 'contain') {
690
+ // current behavior: pad shorter dimension so the whole image fits (no cropping)
691
+ if (clusterAspect > tileAspect) {
692
+ const effectiveHeight = clusterWidth / tileAspect
693
+ const pad = effectiveHeight - clusterHeight
694
+ if (workerDebug.clusterAnchor === 'min') {
695
+ normMinY = minY
696
+ } else {
697
+ normMinY = minY - pad / 2
698
+ }
699
+ normHeight = effectiveHeight
700
+ } else if (clusterAspect < tileAspect) {
701
+ const effectiveWidth = clusterHeight * tileAspect
702
+ const pad = effectiveWidth - clusterWidth
703
+ if (workerDebug.clusterAnchor === 'min') {
704
+ normMinX = minX
705
+ } else {
706
+ normMinX = minX - pad / 2
707
+ }
708
+ normWidth = effectiveWidth
709
+ }
710
+ } else {
711
+ // 'cover' mode: scale so tile grid fully covers cluster bounds, allowing cropping
712
+ if (clusterAspect > tileAspect) {
713
+ // cluster is wider than tile grid: scale width down (crop left/right)
714
+ const effectiveWidth = clusterHeight * tileAspect
715
+ const crop = clusterWidth - effectiveWidth
716
+ if (workerDebug.clusterAnchor === 'min') {
717
+ normMinX = minX + crop // crop from right
718
+ } else {
719
+ normMinX = minX + crop / 2
720
+ }
721
+ normWidth = effectiveWidth
722
+ } else if (clusterAspect < tileAspect) {
723
+ // cluster is taller than tile grid: scale height down (crop top/bottom)
724
+ const effectiveHeight = clusterWidth / tileAspect
725
+ const crop = clusterHeight - effectiveHeight
726
+ if (workerDebug.clusterAnchor === 'min') {
727
+ normMinY = minY + crop
728
+ } else {
729
+ normMinY = minY + crop / 2
730
+ }
731
+ normHeight = effectiveHeight
732
+ }
733
+ }
734
+ }
735
+
736
+ // Assign grid positions using preferred-quantized -> nearest-free strategy
737
+ // Guard tiny normalized dimensions to avoid degenerate quantization
738
+ // This produces contiguous tiling for clusters and avoids many hexes
739
+ // quantizing into the same UV tile.
740
+ try {
741
+ const clusterSet = new Set(cluster)
742
+
743
+ // Helper: tile bounds check
744
+ const inTileBounds = (c: number, r: number) => c >= 0 && c < tilesX && r >= 0 && r < tilesY
745
+
746
+ // Tile occupancy map key
747
+ const tileKey = (c: number, r: number) => `${c},${r}`
748
+
749
+ // Pre-allocate occupancy map and assignment map
750
+ const occupied = new Map<string, boolean>()
751
+ const assignment = new Map<number, [number, number]>()
752
+
753
+ // Choose origin by cluster centroid (closest hex to centroid)
754
+ let cx = 0, cy = 0
755
+ for (const id of cluster) { const p = positions[id]; cx += p[0]; cy += p[1] }
756
+ cx /= cluster.length; cy /= cluster.length
757
+ let originIndex = cluster[0]
758
+ let bestD = Infinity
759
+ for (const id of cluster) {
760
+ const p = positions[id]
761
+ const d = Math.hypot(p[0] - cx, p[1] - cy)
762
+ if (d < bestD) { bestD = d; originIndex = id }
763
+ }
764
+
765
+ // Tile-first scanline assignment: build tiles in row-major order, then pick nearest unassigned node
766
+ const startCol = Math.floor(tilesX / 2)
767
+ const startRow = Math.floor(tilesY / 2)
768
+
769
+ // Ensure normalized dims aren't tiny
770
+ const MIN_NORM = 1e-6
771
+ if (normWidth < MIN_NORM) normWidth = MIN_NORM
772
+ if (normHeight < MIN_NORM) normHeight = MIN_NORM
773
+
774
+ // Build tile list in row-major or serpentine order depending on config
775
+ const tiles: [number, number][] = []
776
+ const scanMode = (workerDebug.clusterScanMode || 'row')
777
+ for (let r = 0; r < tilesY; r++) {
778
+ if (scanMode === 'serpentine' && (r % 2 === 1)) {
779
+ // right-to-left on odd rows for serpentine
780
+ for (let c = tilesX - 1; c >= 0; c--) tiles.push([c, r])
781
+ } else {
782
+ for (let c = 0; c < tilesX; c++) tiles.push([c, r])
783
+ }
784
+ }
785
+
786
+ // Helper: compute tile center in cluster-space
787
+ const parityAware = !!workerDebug.clusterParityAware
788
+
789
+ // compute physical horizontal offset for hex parity from cluster geometry
790
+ const hexSpacingFactor = Number(workerDebug.hexSpacing) || 1
791
+ // initial fallback spacing based on configured hexRadius
792
+ let realHorizSpacing = Math.sqrt(3) * hexRadius * hexSpacingFactor
793
+
794
+ // Try to infer horizontal spacing from actual node positions in the cluster.
795
+ // Group nodes into approximate rows and measure adjacent x-deltas.
796
+ try {
797
+ const rowBuckets = new Map<number, number[]>()
798
+ for (const id of cluster) {
799
+ const p = positions[id]
800
+ if (!p) continue
801
+ // ratio across normalized height
802
+ const ratio = (p[1] - normMinY) / Math.max(1e-9, normHeight)
803
+ let r = Math.floor(ratio * tilesY)
804
+ r = Math.max(0, Math.min(tilesY - 1, r))
805
+ const arr = rowBuckets.get(r) || []
806
+ arr.push(p[0])
807
+ rowBuckets.set(r, arr)
808
+ }
809
+ const diffs: number[] = []
810
+ for (const xs of rowBuckets.values()) {
811
+ if (!xs || xs.length < 2) continue
812
+ xs.sort((a, b) => a - b)
813
+ for (let i = 1; i < xs.length; i++) diffs.push(xs[i] - xs[i - 1])
814
+ }
815
+ if (diffs.length > 0) {
816
+ diffs.sort((a, b) => a - b)
817
+ const mid = Math.floor(diffs.length / 2)
818
+ realHorizSpacing = diffs.length % 2 === 1 ? diffs[mid] : ((diffs[mid - 1] + diffs[mid]) / 2)
819
+ if (!isFinite(realHorizSpacing) || realHorizSpacing <= 0) realHorizSpacing = Math.sqrt(3) * hexRadius * hexSpacingFactor
820
+ }
821
+ } catch (e) {
822
+ // fallback to default computed spacing
823
+ realHorizSpacing = Math.sqrt(3) * hexRadius * hexSpacingFactor
824
+ }
825
+
826
+ // tile center calculation: simple regular grid, no parity offset
827
+ // The hex positions already have natural staggering, so tile centers should be regular
828
+ const tileCenter = (col: number, row: number) => {
829
+ const u = (col + 0.5) / tilesX
830
+ const v = (row + 0.5) / tilesY
831
+ const x = normMinX + u * normWidth
832
+ const y = normMinY + v * normHeight
833
+ return [x, y]
834
+ }
835
+
836
+ console.log('[assignClusterGridPositions] Normalized bounds for tiling:', {
837
+ normMinX: normMinX.toFixed(2),
838
+ normMinY: normMinY.toFixed(2),
839
+ normWidth: normWidth.toFixed(2),
840
+ normHeight: normHeight.toFixed(2),
841
+ preserveAspect,
842
+ fillMode: workerDebug.clusterFillMode
843
+ })
844
+
845
+ // SPATIAL assignment: each hex gets the tile whose center is spatially nearest
846
+ // This guarantees perfect alignment between hex positions and tile centers
847
+ // Build centers map first
848
+ const centers: { t: [number, number]; x: number; y: number }[] = []
849
+ for (let r = 0; r < tilesY; r++) for (let c = 0; c < tilesX; c++) {
850
+ const [x, y] = tileCenter(c, r)
851
+ centers.push({ t: [c, r], x, y })
852
+ }
853
+
854
+ // Optionally collect centers for debug visualization
855
+ if (workerDebug.showTileCenters) {
856
+ debugCenters.push({
857
+ photoId,
858
+ clusterIndex,
859
+ centers: centers.map(c => ({ x: c.x, y: c.y, col: c.t[0], row: c.t[1] }))
860
+ })
861
+ }
862
+
863
+ // Assign each hex to its nearest tile center (purely spatial)
864
+ // Log a few examples to verify the mapping
865
+ const assignmentSamples: Array<{nodeId: number, nodeX: number, nodeY: number, tileCol: number, tileRow: number, centerX: number, centerY: number, dist: number}> = []
866
+
867
+ for (const nodeId of cluster) {
868
+ const nodePos = positions[nodeId]
869
+ if (!nodePos) continue
870
+
871
+ let nearestTile: [number, number] = centers[0].t
872
+ let nearestDist = Infinity
873
+ let nearestCenter: {x: number, y: number} = centers[0]
874
+
875
+ for (const c of centers) {
876
+ const dist = Math.hypot(nodePos[0] - c.x, nodePos[1] - c.y)
877
+ if (dist < nearestDist) {
878
+ nearestDist = dist
879
+ nearestTile = c.t
880
+ nearestCenter = c
881
+ }
882
+ }
883
+
884
+ assignment.set(nodeId, nearestTile)
885
+ occupied.set(tileKey(nearestTile[0], nearestTile[1]), true)
886
+
887
+ // Sample first few for debugging
888
+ if (assignmentSamples.length < 5) {
889
+ assignmentSamples.push({
890
+ nodeId,
891
+ nodeX: nodePos[0],
892
+ nodeY: nodePos[1],
893
+ tileCol: nearestTile[0],
894
+ tileRow: nearestTile[1],
895
+ centerX: nearestCenter.x,
896
+ centerY: nearestCenter.y,
897
+ dist: nearestDist
898
+ })
899
+ }
900
+ }
901
+
902
+ console.log('[assignClusterGridPositions] Spatially assigned', cluster.length, 'hexes to nearest tile centers')
903
+ console.log('[assignClusterGridPositions] Sample assignments:', assignmentSamples.map(s =>
904
+ `node#${s.nodeId} at (${s.nodeX.toFixed(1)},${s.nodeY.toFixed(1)}) → tile[${s.tileCol},${s.tileRow}] center(${s.centerX.toFixed(1)},${s.centerY.toFixed(1)}) dist=${s.dist.toFixed(1)}`
905
+ ).join('\n '))
906
+
907
+ // Optional: Neighborhood-aware refinement to reduce visual seams
908
+ // For each hex, check if its neighbors suggest a better tile assignment for visual continuity
909
+ if (workerDebug.clusterNeighborAware !== false) {
910
+ const maxIterations = 3 // Multiple passes to propagate improvements
911
+ for (let iter = 0; iter < maxIterations; iter++) {
912
+ let adjustments = 0
913
+ for (const nodeId of cluster) {
914
+ const currentTile = assignment.get(nodeId)
915
+ if (!currentTile) continue
916
+
917
+ // Get neighbors within this cluster
918
+ const neighbors = getNeighborsCached(nodeId, positions, hexRadius)
919
+ const clusterNeighbors = neighbors.filter(n => clusterSet.has(n) && assignment.has(n))
920
+ if (clusterNeighbors.length === 0) continue
921
+
922
+ // Collect neighbor tiles and compute centroid
923
+ const neighborTiles: Array<[number, number]> = []
924
+ for (const n of clusterNeighbors) {
925
+ const nt = assignment.get(n)
926
+ if (nt) neighborTiles.push(nt)
927
+ }
928
+
929
+ if (neighborTiles.length === 0) continue
930
+
931
+ // Compute average neighbor tile position
932
+ let avgCol = 0, avgRow = 0
933
+ for (const [c, r] of neighborTiles) {
934
+ avgCol += c
935
+ avgRow += r
936
+ }
937
+ avgCol /= neighborTiles.length
938
+ avgRow /= neighborTiles.length
939
+
940
+ // Find the tile closest to the neighbor average that's spatially near this node
941
+ const nodePos = positions[nodeId]
942
+ if (!nodePos) continue
943
+
944
+ let bestAlternative: [number, number] | null = null
945
+ let bestScore = Infinity
946
+
947
+ // Consider tiles in a local neighborhood around current tile
948
+ const searchRadius = 2
949
+ for (let dc = -searchRadius; dc <= searchRadius; dc++) {
950
+ for (let dr = -searchRadius; dr <= searchRadius; dr++) {
951
+ const candidateCol = Math.max(0, Math.min(tilesX - 1, currentTile[0] + dc))
952
+ const candidateRow = Math.max(0, Math.min(tilesY - 1, currentTile[1] + dr))
953
+ const candidate: [number, number] = [candidateCol, candidateRow]
954
+
955
+ // Score: distance to neighbor tile average + spatial distance to tile center
956
+ const tileDist = Math.hypot(candidateCol - avgCol, candidateRow - avgRow)
957
+ const [cx, cy] = tileCenter(candidateCol, candidateRow)
958
+ const spatialDist = Math.hypot(nodePos[0] - cx, nodePos[1] - cy)
959
+ const score = tileDist * 0.7 + spatialDist * 0.3
960
+
961
+ if (score < bestScore) {
962
+ bestScore = score
963
+ bestAlternative = candidate
964
+ }
965
+ }
966
+ }
967
+
968
+ // If we found a better tile and it's different from current, update
969
+ if (bestAlternative && (bestAlternative[0] !== currentTile[0] || bestAlternative[1] !== currentTile[1])) {
970
+ assignment.set(nodeId, bestAlternative)
971
+ adjustments++
972
+ }
973
+ }
974
+
975
+ if (adjustments === 0) break // Converged
976
+ console.log('[assignClusterGridPositions] Neighbor-aware refinement iteration', iter + 1, ':', adjustments, 'adjustments')
977
+ }
978
+ }
979
+
980
+ // Finally write assignments back into infections with UV bounds/inset
981
+ const inset = Math.max(0, Math.min(0.49, Number(workerDebug.clusterUvInset) || 0))
982
+ for (const id of cluster) {
983
+ const inf = infections.get(id)
984
+ if (!inf) continue
985
+ let assignedTile = assignment.get(id) || [0, 0]
986
+ // Support bottom anchoring: flip the vertical tile index when 'max' is configured
987
+ if (workerDebug.clusterAnchor === 'max') {
988
+ assignedTile = [assignedTile[0], Math.max(0, tilesY - 1 - assignedTile[1])]
989
+ }
990
+ let uvBounds = calculateUvBoundsFromGridPosition(assignedTile[0], assignedTile[1], tilesX, tilesY)
991
+ if (inset > 0) {
992
+ const u0 = uvBounds[0], v0 = uvBounds[1], u1 = uvBounds[2], v1 = uvBounds[3]
993
+ const du = (u1 - u0) * inset
994
+ const dv = (v1 - v0) * inset
995
+ uvBounds = [u0 + du, v0 + dv, u1 - du, v1 - dv]
996
+ }
997
+ infections.set(id, { ...inf, gridPosition: [assignedTile[0], assignedTile[1]], uvBounds, tilesX, tilesY })
998
+ }
999
+ console.log('[assignClusterGridPositions] Assigned grid positions to', cluster.length, 'hexes in cluster (BFS)')
1000
+ } catch (e) {
1001
+ console.error('[assignClusterGridPositions] BFS assignment failed, falling back to quantization', e)
1002
+ // fallback: leave previous behavior (quantization) to avoid breaking
1003
+ }
1004
+ clusterIndex++
1005
+ }
1006
+ }
1007
+
1008
+ // Log cluster statistics
1009
+ if (clusterSizes.length > 0) {
1010
+ clusterSizes.sort((a, b) => b - a) // descending
1011
+ const avgSize = clusterSizes.reduce((sum, s) => sum + s, 0) / clusterSizes.length
1012
+ const medianSize = clusterSizes[Math.floor(clusterSizes.length / 2)]
1013
+ const maxSize = clusterSizes[0]
1014
+ const smallClusters = clusterSizes.filter(s => s <= 3).length
1015
+ console.log('[assignClusterGridPositions] CLUSTER STATS: total=', totalClusters, 'avg=', avgSize.toFixed(1), 'median=', medianSize, 'max=', maxSize, 'small(≤3)=', smallClusters, '/', totalClusters, '(', (100 * smallClusters / totalClusters).toFixed(0), '%)')
1016
+ }
1017
+
1018
+ console.log('[assignClusterGridPositions] Complete')
1019
+ } catch (e) {
1020
+ console.error('[assignClusterGridPositions] Error:', e)
1021
+ }
1022
+
1023
+ return debugCenters
1024
+ }
1025
+
1026
+ function postOptimizationMerge(infections: Map<number, Infection>, positions: [number, number, number][], hexRadius: number, debug = false) {
1027
+ try {
1028
+ if (!workerDebug || !workerDebug.enableMerges) { if (debug && workerDebug.mergeLogs) console.log('[merge] disabled'); return }
1029
+ const threshold = typeof workerDebug.mergeSmallComponentsThreshold === 'number' ? workerDebug.mergeSmallComponentsThreshold : 3
1030
+ const byPhoto = new Map<string, number[]>()
1031
+ for (const [idx, inf] of infections) { const arr = byPhoto.get(inf.photo.id) || []; arr.push(idx); byPhoto.set(inf.photo.id, arr) }
1032
+ let merges = 0
1033
+ for (const [photoId, inds] of byPhoto) {
1034
+ const comps = findConnectedComponents(inds, positions, hexRadius)
1035
+ const small = comps.filter(c => c.length > 0 && c.length <= threshold)
1036
+ const big = comps.filter(c => c.length > threshold)
1037
+ if (small.length === 0 || big.length === 0) continue
1038
+ const bounds = getGridBounds(positions)
1039
+ for (const s of small) {
1040
+ let best: number[] | null = null; let bestD = Infinity
1041
+ for (const b of big) {
1042
+ let sx = 0, sy = 0, bx = 0, by = 0
1043
+ for (const i of s) { const p = positions[i]; if (p) { sx += p[0]; sy += p[1] } }
1044
+ for (const i of b) { const p = positions[i]; if (p) { bx += p[0]; by += p[1] } }
1045
+ const scx = sx / s.length, scy = sy / s.length, bcx = bx / b.length, bcy = by / b.length
1046
+ const dx = Math.abs(scx - bcx)
1047
+ const dy = Math.abs(scy - bcy)
1048
+ let effDx = dx
1049
+ let effDy = dy
1050
+ if (cache.isSpherical && bounds.width > 0 && bounds.height > 0) {
1051
+ if (effDx > bounds.width / 2) effDx = bounds.width - effDx
1052
+ if (effDy > bounds.height / 2) effDy = bounds.height - effDy
1053
+ }
1054
+ const d = Math.sqrt(effDx * effDx + effDy * effDy)
1055
+ if (d < bestD) { bestD = d; best = b }
1056
+ }
1057
+ if (!best) continue
1058
+ const recipientId = infections.get(best[0])?.photo.id
1059
+ if (!recipientId) continue
1060
+ const before = calculateContiguity(best, positions, hexRadius)
1061
+ const after = calculateContiguity([...best, ...s], positions, hexRadius)
1062
+ if (after > before + 1) {
1063
+ for (const idx of s) { const inf = infections.get(idx); if (!inf) continue; infections.set(idx, { ...inf, photo: infections.get(best[0])!.photo }) }
1064
+ merges++
1065
+ if (debug && workerDebug.mergeLogs) console.log(`[merge] moved ${s.length} -> ${recipientId}`)
1066
+ }
1067
+ }
1068
+ }
1069
+ } catch (e) { if (debug) console.warn('[merge] failed', e) }
1070
+ }
1071
+
1072
+ function normalizePrevState(prevState: any) : { infections: Map<number, Infection>, availableIndices: number[], generation?: number } {
1073
+ try {
1074
+ if (!prevState) return { infections: new Map<number, Infection>(), availableIndices: [] }
1075
+ let infectionsMap: Map<number, Infection>
1076
+ if (prevState.infections instanceof Map) {
1077
+ infectionsMap = prevState.infections
1078
+ } else if (Array.isArray(prevState.infections)) {
1079
+ try { infectionsMap = new Map<number, Infection>(prevState.infections) } catch (e) { infectionsMap = new Map<number, Infection>() }
1080
+ } else if (typeof prevState.infections === 'object' && prevState.infections !== null && typeof prevState.infections.entries === 'function') {
1081
+ try { infectionsMap = new Map<number, Infection>(Array.from(prevState.infections.entries())) } catch (e) { infectionsMap = new Map<number, Infection>() }
1082
+ } else {
1083
+ infectionsMap = new Map<number, Infection>()
1084
+ }
1085
+ const available = Array.isArray(prevState.availableIndices) ? prevState.availableIndices : []
1086
+ return { infections: infectionsMap, availableIndices: available, generation: prevState.generation }
1087
+ } catch (e) {
1088
+ safePostError(e)
1089
+ return { infections: new Map<number, Infection>(), availableIndices: [] }
1090
+ }
1091
+ }
1092
+
1093
+ function evolveInfectionSystem(prevState: any, positions: [number, number, number][], photos: Photo[], hexRadius: number, currentTime: number, debug = false): InfectionSystemState | null {
1094
+ try {
1095
+ console.log('[evolve] Step 1: Validating positions...')
1096
+ if (!positions || positions.length === 0) {
1097
+ safePostError(new Error('positions required for evolve'))
1098
+ return null
1099
+ }
1100
+ console.log('[evolve] Step 2: Normalizing state...')
1101
+ const normalized = normalizePrevState(prevState)
1102
+ const infectionsMap: Map<number, Infection> = normalized.infections
1103
+ const availableSet = new Set<number>(Array.isArray(normalized.availableIndices) ? normalized.availableIndices : [])
1104
+ console.log('[evolve] Step 3: Cleaning infections...')
1105
+ for (const [idx, inf] of infectionsMap) { if (!inf || !inf.photo) { infectionsMap.delete(idx); availableSet.add(idx) } }
1106
+
1107
+ console.log('[evolve] Step 4: Calculating centroids...')
1108
+ const centroids = calculatePhotoCentroids(infectionsMap, positions, hexRadius)
1109
+ console.log('[evolve] Step 5: Creating new state copies...')
1110
+ const newInfections = new Map(infectionsMap)
1111
+ const newAvailable = new Set(availableSet)
1112
+ const generation = (prevState && typeof prevState.generation === 'number') ? prevState.generation + 1 : 0
1113
+
1114
+ console.log('[evolve] Step 6: Growth step - processing', infectionsMap.size, 'infections...')
1115
+ // Skip growth step if we have no infections or no photos
1116
+ if (infectionsMap.size === 0 || photos.length === 0) {
1117
+ console.log('[evolve] Skipping growth - no infections or no photos')
1118
+ } else {
1119
+ // Cell death step: allow fully surrounded cells to die and respawn for optimization
1120
+ if (workerDebug.enableCellDeath && typeof workerDebug.cellDeathProbability === 'number') {
1121
+ // Apply annealing rate to base death probability
1122
+ const annealingRate = typeof workerDebug.annealingRate === 'number' && workerDebug.annealingRate > 0
1123
+ ? workerDebug.annealingRate
1124
+ : 1.0
1125
+ const baseDeathProb = Math.max(0, Math.min(1, workerDebug.cellDeathProbability * annealingRate))
1126
+ const mutationEnabled = !!workerDebug.enableMutation
1127
+ const baseMutationProb = mutationEnabled && typeof workerDebug.mutationProbability === 'number'
1128
+ ? Math.max(0, Math.min(1, workerDebug.mutationProbability))
1129
+ : 0
1130
+ let deathCount = 0
1131
+ let mutationCount = 0
1132
+ let invaderExpulsions = 0
1133
+
1134
+ // Calculate cluster sizes for mutation scaling
1135
+ const clusterSizes = new Map<string, number>()
1136
+ for (const [_, inf] of infectionsMap) {
1137
+ clusterSizes.set(inf.photo.id, (clusterSizes.get(inf.photo.id) || 0) + 1)
1138
+ }
1139
+
1140
+ for (const [idx, inf] of infectionsMap) {
1141
+ const neighbors = getNeighborsCached(idx, positions, hexRadius)
1142
+ const totalNeighbors = neighbors.length
1143
+
1144
+ // Count neighbors with the same photo (affinity)
1145
+ const samePhotoNeighbors = neighbors.filter(n => {
1146
+ const nInf = newInfections.get(n)
1147
+ return nInf && nInf.photo.id === inf.photo.id
1148
+ })
1149
+
1150
+ // Calculate affinity ratio: 1.0 = all same photo, 0.0 = none same photo
1151
+ const affinityRatio = totalNeighbors > 0 ? samePhotoNeighbors.length / totalNeighbors : 0
1152
+
1153
+ // Count hostile (different photo) neighbors and diversity
1154
+ const hostileNeighbors = totalNeighbors - samePhotoNeighbors.length
1155
+ const hostileRatio = totalNeighbors > 0 ? hostileNeighbors / totalNeighbors : 0
1156
+
1157
+ // Calculate diversity: how many unique different photo types surround this cell
1158
+ const uniqueHostilePhotos = new Set<string>()
1159
+ for (const n of neighbors) {
1160
+ const nInf = newInfections.get(n)
1161
+ if (nInf && nInf.photo.id !== inf.photo.id) {
1162
+ uniqueHostilePhotos.add(nInf.photo.id)
1163
+ }
1164
+ }
1165
+ const diversityCount = uniqueHostilePhotos.size
1166
+ const maxDiversity = 6 // hex grid max neighbors
1167
+ const diversityRatio = diversityCount / maxDiversity
1168
+
1169
+ // Affinity-adjusted death probability with boundary pressure:
1170
+ // - High affinity (well-integrated) = low death rate
1171
+ // - Low affinity (invader) = high death rate
1172
+ // - Partial hostile neighbors = MUCH higher death rate (boundary warfare)
1173
+ // - Solitary cells = VERY high death rate
1174
+ //
1175
+ // Base formula: deathProb = baseDeathProb * (1 - affinityRatio)^2
1176
+ // Boundary pressure: if 1-5 hostile neighbors, apply exponential penalty
1177
+ let affinityPenalty = Math.pow(1 - affinityRatio, 2)
1178
+
1179
+ // Solitary cell penalty: cells with 0-1 same neighbors are extremely vulnerable
1180
+ // Diversity amplifies this: being alone among many different photos is worst case
1181
+ if (samePhotoNeighbors.length <= 1) {
1182
+ // Base 10x penalty, increased by diversity: 2-6 different neighbors = 1.5x-3x additional multiplier
1183
+ // Formula: 10 × (1 + diversityRatio × 2)
1184
+ // 1 hostile type: 10x penalty
1185
+ // 3 hostile types (50% diversity): 20x penalty
1186
+ // 6 hostile types (100% diversity): 30x penalty
1187
+ const diversityPenalty = 1 + diversityRatio * 2
1188
+ affinityPenalty *= (10 * diversityPenalty)
1189
+ }
1190
+
1191
+ // Boundary warfare multiplier: cells partially surrounded by enemies are in danger
1192
+ if (hostileNeighbors > 0 && hostileNeighbors < totalNeighbors) {
1193
+ // Peak danger at 50% hostile (3/6 neighbors): apply up to 4x multiplier
1194
+ // Formula: 1 + 3 * sin(hostileRatio * π) creates a bell curve peaking at 0.5
1195
+ const boundaryPressure = 1 + 3 * Math.sin(hostileRatio * Math.PI)
1196
+ affinityPenalty *= boundaryPressure
1197
+ }
1198
+
1199
+ const adjustedDeathProb = Math.min(1, baseDeathProb * affinityPenalty)
1200
+
1201
+ // Calculate mutation probability based on cluster size and virility
1202
+ // Larger, more popular clusters spawn more mutations
1203
+ let mutationProb = baseMutationProb
1204
+ if (mutationEnabled && photos.length > 1) {
1205
+ const clusterSize = clusterSizes.get(inf.photo.id) || 1
1206
+ const velocity = typeof inf.photo.velocity === 'number' ? inf.photo.velocity : 0
1207
+
1208
+ // Cluster size multiplier: larger clusters spawn more mutations (1-100 cells → 1x-10x)
1209
+ const clusterMultiplier = Math.min(10, Math.log10(clusterSize + 1) + 1)
1210
+
1211
+ // Virility multiplier: popular photos spawn more mutations (0-100 velocity → 1x-3x)
1212
+ const virilityMultiplier = 1 + (Math.min(100, Math.max(0, velocity)) / 100) * 2
1213
+
1214
+ // Combined mutation rate
1215
+ mutationProb = Math.min(1, baseMutationProb * clusterMultiplier * virilityMultiplier)
1216
+ }
1217
+
1218
+ // Only consider cells with at least some neighbors (avoid isolated cells)
1219
+ if (totalNeighbors >= 1 && Math.random() < adjustedDeathProb) {
1220
+ const isInvader = affinityRatio < 0.5 // Less than half neighbors are same photo
1221
+ // Check for mutation: respawn as a different photo instead of just dying
1222
+ if (mutationEnabled && Math.random() < mutationProb && photos.length > 1) {
1223
+ // Pick a random photo from the pool that's different from current
1224
+ const otherPhotos = photos.filter(p => p.id !== inf.photo.id)
1225
+ if (otherPhotos.length > 0) {
1226
+ const newPhoto = otherPhotos[Math.floor(Math.random() * otherPhotos.length)]
1227
+ const tilesX = 4
1228
+ const tilesY = 4
1229
+ const uvBounds = calculateUvBoundsFromGridPosition(0, 0, tilesX, tilesY)
1230
+ // Mutate: replace with new photo instead of dying
1231
+ newInfections.set(idx, {
1232
+ photo: newPhoto,
1233
+ gridPosition: [0, 0],
1234
+ infectionTime: currentTime,
1235
+ generation,
1236
+ uvBounds: uvBounds,
1237
+ scale: 0.4,
1238
+ growthRate: 0.08,
1239
+ tilesX: tilesX,
1240
+ tilesY: tilesY
1241
+ })
1242
+ mutationCount++
1243
+ } else {
1244
+ // No other photos available, just die normally
1245
+ newInfections.delete(idx)
1246
+ newAvailable.add(idx)
1247
+ deathCount++
1248
+ }
1249
+ } else {
1250
+ // Normal death: remove and make available for respawn
1251
+ newInfections.delete(idx)
1252
+ newAvailable.add(idx)
1253
+ deathCount++
1254
+ if (isInvader) invaderExpulsions++
1255
+ }
1256
+ }
1257
+ }
1258
+ if (deathCount > 0 || mutationCount > 0 || invaderExpulsions > 0) {
1259
+ console.log('[evolve] Cell death: removed', deathCount, 'cells (', invaderExpulsions, 'invaders expelled), mutated', mutationCount, 'cells')
1260
+ }
1261
+ }
1262
+
1263
+ // Growth step: prefer neighbors that increase contiguity and are closer to centroids
1264
+ let growthIterations = 0
1265
+ for (const [idx, inf] of infectionsMap) {
1266
+ growthIterations++
1267
+ if (growthIterations % 10 === 0) console.log('[evolve] Growth iteration', growthIterations, '/', infectionsMap.size)
1268
+ const neighbors = getNeighborsCached(idx, positions, hexRadius)
1269
+ for (const n of neighbors) {
1270
+ if (!newAvailable.has(n)) continue
1271
+ let base = 0.5 // BOOSTED from 0.3 to encourage more aggressive growth
1272
+ const sameNeighbors = getNeighborsCached(n, positions, hexRadius).filter(x => newInfections.has(x) && newInfections.get(x)!.photo.id === inf.photo.id).length
1273
+ if (sameNeighbors >= 2) base = 0.95; else if (sameNeighbors === 1) base = 0.75 // BOOSTED to favor contiguous growth
1274
+
1275
+ // Virility boost: photos with higher velocity (upvotes/engagement) grow faster
1276
+ if (workerDebug.enableVirilityBoost && typeof inf.photo.velocity === 'number' && inf.photo.velocity > 0) {
1277
+ const virilityMult = typeof workerDebug.virilityMultiplier === 'number' ? workerDebug.virilityMultiplier : 1.0
1278
+ // Normalize velocity to a 0-1 range (assuming velocity is already normalized or 0-100)
1279
+ // Then apply as a percentage boost: velocity=100 -> 100% boost (2x), velocity=50 -> 50% boost (1.5x)
1280
+ const normalizedVelocity = Math.min(1, Math.max(0, inf.photo.velocity / 100))
1281
+ const virilityBoost = 1 + (normalizedVelocity * virilityMult)
1282
+ base *= virilityBoost
1283
+ }
1284
+
1285
+ // Centroid cohesion bias
1286
+ try {
1287
+ const cList = centroids.get(inf.photo.id) || []
1288
+ if (cList.length > 0) {
1289
+ const bounds = getGridBounds(positions); let minD = Infinity; const p = positions[n]
1290
+ for (const c of cList) {
1291
+ const dx = Math.abs(p[0] - c[0])
1292
+ const dy = Math.abs(p[1] - c[1])
1293
+ let effDx = dx
1294
+ let effDy = dy
1295
+ if (cache.isSpherical && bounds.width > 0 && bounds.height > 0) {
1296
+ if (effDx > bounds.width / 2) effDx = bounds.width - effDx
1297
+ if (effDy > bounds.height / 2) effDy = bounds.height - effDy
1298
+ }
1299
+ const d = Math.sqrt(effDx * effDx + effDy * effDy)
1300
+ if (d < minD) minD = d
1301
+ }
1302
+ const radius = Math.max(1, hexRadius * 3)
1303
+ const distFactor = Math.max(0, Math.min(1, 1 - (minD / radius)))
1304
+ const boost = typeof workerDebug.cohesionBoost === 'number' ? workerDebug.cohesionBoost : 0.6
1305
+ base *= (1 + distFactor * boost)
1306
+ }
1307
+ } catch (e) { if (debug) console.warn('cohesion calc failed', e) }
1308
+
1309
+ if (Math.random() < Math.min(0.999, base)) {
1310
+ const tilesX = inf.tilesX || 4
1311
+ const tilesY = inf.tilesY || 4
1312
+ const uvBounds = calculateUvBoundsFromGridPosition(0, 0, tilesX, tilesY)
1313
+ newInfections.set(n, { photo: inf.photo, gridPosition: [0, 0], infectionTime: currentTime, generation, uvBounds: uvBounds, scale: 0.4, growthRate: inf.growthRate || 0.08, tilesX: tilesX, tilesY: tilesY })
1314
+ newAvailable.delete(n)
1315
+ }
1316
+ }
1317
+ }
1318
+ }
1319
+
1320
+ console.log('[evolve] Step 6.5: Entropy decay - applying decay to dominant successful photos...')
1321
+ // Entropy decay: successful/dominant photos decay over time to allow new dominance to emerge
1322
+ if (workerDebug.enableEntropyDecay && newInfections.size > 0) {
1323
+ // Calculate current territory shares
1324
+ const territoryCounts = new Map<string, number>()
1325
+ const photoVelocities = new Map<string, number>()
1326
+
1327
+ for (const [_, inf] of newInfections) {
1328
+ territoryCounts.set(inf.photo.id, (territoryCounts.get(inf.photo.id) || 0) + 1)
1329
+ if (typeof inf.photo.velocity === 'number') {
1330
+ photoVelocities.set(inf.photo.id, inf.photo.velocity)
1331
+ }
1332
+ }
1333
+
1334
+ const totalTerritory = newInfections.size
1335
+ if (totalTerritory > 0) {
1336
+ const dominanceThreshold = typeof workerDebug.entropyDominanceThreshold === 'number'
1337
+ ? workerDebug.entropyDominanceThreshold
1338
+ : 0.15
1339
+ const successThreshold = typeof workerDebug.entropySuccessVelocityThreshold === 'number'
1340
+ ? workerDebug.entropySuccessVelocityThreshold
1341
+ : 50
1342
+ const baseDecayRate = typeof workerDebug.entropyDecayBaseRate === 'number'
1343
+ ? workerDebug.entropyDecayBaseRate
1344
+ : 0.02
1345
+ const timeMultiplier = typeof workerDebug.entropyTimeMultiplier === 'number'
1346
+ ? workerDebug.entropyTimeMultiplier
1347
+ : 0.1
1348
+
1349
+ // Update dominance history and identify dominant successful photos
1350
+ const dominantSuccessfulPhotos = new Set<string>()
1351
+
1352
+ for (const [photoId, territory] of territoryCounts) {
1353
+ const territoryShare = territory / totalTerritory
1354
+ const velocity = photoVelocities.get(photoId) || 0
1355
+
1356
+ // Check if photo is dominant (above threshold) and successful (velocity above threshold)
1357
+ if (territoryShare >= dominanceThreshold && velocity >= successThreshold) {
1358
+ // Update dominance history: increment generations as dominant
1359
+ const generationsAsDominant = (dominanceHistory.get(photoId) || 0) + 1
1360
+ dominanceHistory.set(photoId, generationsAsDominant)
1361
+ dominantSuccessfulPhotos.add(photoId)
1362
+ } else {
1363
+ // Reset dominance history if no longer dominant or successful
1364
+ dominanceHistory.delete(photoId)
1365
+ }
1366
+ }
1367
+
1368
+ // Apply entropy decay to cells from dominant successful photos
1369
+ let entropyDecayCount = 0
1370
+ const cellsToDecay: number[] = []
1371
+
1372
+ for (const [idx, inf] of newInfections) {
1373
+ if (dominantSuccessfulPhotos.has(inf.photo.id)) {
1374
+ const photoId = inf.photo.id
1375
+ const territory = territoryCounts.get(photoId) || 0
1376
+ const territoryShare = territory / totalTerritory
1377
+ const velocity = photoVelocities.get(photoId) || 0
1378
+ const generationsAsDominant = dominanceHistory.get(photoId) || 0
1379
+
1380
+ // Calculate decay probability based on:
1381
+ // 1. Territory share (dominance) - more dominant = more decay
1382
+ // 2. Velocity (success) - more successful = more decay (but only if already dominant)
1383
+ // 3. Time as dominant - longer = more decay
1384
+
1385
+ // Normalize territory share: 0.15 (threshold) -> 0.0, 1.0 -> 1.0
1386
+ const normalizedDominance = Math.max(0, (territoryShare - dominanceThreshold) / (1 - dominanceThreshold))
1387
+
1388
+ // Normalize velocity: 50 (threshold) -> 0.0, 100 -> 1.0
1389
+ const normalizedSuccess = Math.max(0, Math.min(1, (velocity - successThreshold) / (100 - successThreshold)))
1390
+
1391
+ // Time multiplier: each generation as dominant adds to decay rate
1392
+ const timeFactor = 1 + (generationsAsDominant * timeMultiplier)
1393
+
1394
+ // Combined decay probability
1395
+ // Base rate scaled by dominance, success, and time
1396
+ const decayProb = baseDecayRate * normalizedDominance * (1 + normalizedSuccess) * timeFactor
1397
+
1398
+ // Cap at reasonable maximum (e.g., 10% per generation)
1399
+ const cappedDecayProb = Math.min(0.1, decayProb)
1400
+
1401
+ if (Math.random() < cappedDecayProb) {
1402
+ cellsToDecay.push(idx)
1403
+ }
1404
+ }
1405
+ }
1406
+
1407
+ // Apply decay: remove cells and make them available for new infections
1408
+ for (const idx of cellsToDecay) {
1409
+ newInfections.delete(idx)
1410
+ newAvailable.add(idx)
1411
+ entropyDecayCount++
1412
+ }
1413
+
1414
+ if (entropyDecayCount > 0) {
1415
+ console.log('[evolve] Entropy decay: removed', entropyDecayCount, 'cells from dominant successful photos')
1416
+ }
1417
+ }
1418
+ }
1419
+
1420
+ console.log('[evolve] Step 7: Deterministic fill - processing', newAvailable.size, 'available positions...')
1421
+ // Skip deterministic fill if we have no photos or no existing infections to base decisions on
1422
+ if (photos.length === 0 || newInfections.size === 0) {
1423
+ console.log('[evolve] Skipping deterministic fill - no photos or no infections')
1424
+ } else {
1425
+ // Deterministic fill for holes with >=2 same-photo neighbors
1426
+ let fillIterations = 0
1427
+ for (const a of Array.from(newAvailable)) {
1428
+ fillIterations++
1429
+ if (fillIterations % 50 === 0) console.log('[evolve] Fill iteration', fillIterations, '/', newAvailable.size)
1430
+ const neighbors = getNeighborsCached(a, positions, hexRadius)
1431
+ const counts = new Map<string, number>()
1432
+ for (const n of neighbors) { const inf = newInfections.get(n); if (!inf) continue; counts.set(inf.photo.id, (counts.get(inf.photo.id) || 0) + 1) }
1433
+ let bestId: string | undefined; let best = 0
1434
+ for (const [pid, c] of counts) if (c > best) { best = c; bestId = pid }
1435
+ if (bestId && best >= 2) {
1436
+ const src = photos.find(p => p.id === bestId) || Array.from(infectionsMap.values())[0]?.photo
1437
+ if (src) {
1438
+ const tilesX = 4
1439
+ const tilesY = 4
1440
+ const uvBounds = calculateUvBoundsFromGridPosition(0, 0, tilesX, tilesY)
1441
+ newInfections.set(a, { photo: src, gridPosition: [0, 0], infectionTime: currentTime, generation, uvBounds: uvBounds, scale: 0.35, growthRate: 0.08, tilesX: tilesX, tilesY: tilesY })
1442
+ newAvailable.delete(a)
1443
+ }
1444
+ }
1445
+ }
1446
+ }
1447
+
1448
+ console.log('[evolve] Step 8: Optimization merge pass...')
1449
+ // Conservative merge pass (opt-in)
1450
+ postOptimizationMerge(newInfections, positions, hexRadius, !!workerDebug.mergeLogs)
1451
+
1452
+ console.log('[evolve] Step 9: Assigning cluster-aware grid positions...')
1453
+ // Make clusters self-aware by assigning grid positions based on spatial layout
1454
+ const tileCenters = assignClusterGridPositions(newInfections, positions, hexRadius)
1455
+
1456
+ console.log('[evolve] Step 10: Returning result - generation', generation, 'infections', newInfections.size)
1457
+ return { infections: newInfections, availableIndices: Array.from(newAvailable), lastEvolutionTime: currentTime, generation, tileCenters }
1458
+ } catch (e) { safePostError(e); return null }
1459
+ }
1460
+
1461
+ let lastEvolutionAt = 0
1462
+
1463
+ function mergeDebugFromPayload(d: any) {
1464
+ if (!d || typeof d !== 'object') return
1465
+ // Map main-thread naming (evolveIntervalMs) into worker's evolutionIntervalMs
1466
+ if (typeof d.evolveIntervalMs === 'number') d.evolutionIntervalMs = d.evolveIntervalMs
1467
+ // Merge into workerDebug
1468
+ try { Object.assign(workerDebug, d) } catch (e) {}
1469
+ }
1470
+
1471
+ self.onmessage = function (ev: MessageEvent) {
1472
+ const raw = ev.data
1473
+ try {
1474
+ if (!raw || typeof raw !== 'object') return
1475
+
1476
+ const type = raw.type
1477
+ const payload = raw.data ?? raw
1478
+
1479
+ if (type === 'setDataAndConfig' || type === 'setDebug') {
1480
+ // Accept either { type:'setDataAndConfig', data: { photos, debug } } or { type:'setDebug', debug }
1481
+ const dbg = payload.debug ?? raw.debug ?? payload
1482
+ mergeDebugFromPayload(dbg)
1483
+
1484
+ // Pre-build neighbor cache if positions are provided
1485
+ if (type === 'setDataAndConfig') {
1486
+ const incomingIsSpherical = typeof payload.isSpherical === 'boolean' ? Boolean(payload.isSpherical) : cache.isSpherical
1487
+ const shouldUpdateTopology = typeof payload.isSpherical === 'boolean' && incomingIsSpherical !== cache.isSpherical
1488
+ if (shouldUpdateTopology) invalidateCaches(incomingIsSpherical)
1489
+ else invalidateCaches()
1490
+
1491
+ const positions = payload.positions
1492
+ if (!positions || !Array.isArray(positions)) return
1493
+ const hexRadius = typeof payload.hexRadius === 'number' ? payload.hexRadius : 24
1494
+ console.log('[hexgrid-worker] Pre-building neighbor cache for', positions.length, 'positions...')
1495
+ const startTime = Date.now()
1496
+
1497
+ // Build ALL neighbor relationships in one O(n²) pass instead of n×O(n) passes
1498
+ try {
1499
+ const bounds = getGridBounds(positions)
1500
+ const threshold = Math.sqrt(3) * hexRadius * 1.15
1501
+ const isSpherical = !!cache.isSpherical
1502
+
1503
+ // Initialize empty arrays for all positions
1504
+ for (let i = 0; i < positions.length; i++) {
1505
+ cache.neighborMap.set(i, [])
1506
+ }
1507
+
1508
+ // Single pass: check each pair once and add bidirectional neighbors
1509
+ for (let i = 0; i < positions.length; i++) {
1510
+ const pos1 = positions[i]
1511
+ if (!pos1) continue
1512
+
1513
+ // Only check j > i to avoid duplicate checks
1514
+ for (let j = i + 1; j < positions.length; j++) {
1515
+ const pos2 = positions[j]
1516
+ if (!pos2) continue
1517
+
1518
+ const d = distanceBetween(pos1, pos2, bounds, isSpherical)
1519
+ if (d <= threshold) {
1520
+ // Add bidirectional neighbors
1521
+ cache.neighborMap.get(i)!.push(j)
1522
+ cache.neighborMap.get(j)!.push(i)
1523
+ }
1524
+ }
1525
+
1526
+ // Log progress every 100 positions
1527
+ if ((i + 1) % 100 === 0) {
1528
+ console.log('[hexgrid-worker] Processed', i + 1, '/', positions.length, 'positions')
1529
+ }
1530
+ }
1531
+
1532
+ const elapsed = Date.now() - startTime
1533
+ console.log('[hexgrid-worker] ✅ Neighbor cache built in', elapsed, 'ms - ready for evolution!')
1534
+ // Mark cache as ready
1535
+ cache.cacheReady = true
1536
+ // Notify main thread that cache is ready
1537
+ try { self.postMessage({ type: 'cache-ready', data: { elapsed, positions: positions.length } }) } catch (e) {}
1538
+ } catch (e) {
1539
+ console.error('[hexgrid-worker] Error during cache pre-build:', e)
1540
+ // Mark cache as ready anyway to allow evolution to proceed
1541
+ cache.cacheReady = true
1542
+ }
1543
+ }
1544
+
1545
+ return
1546
+ }
1547
+
1548
+ if (type === 'evolve') {
1549
+ // Check if neighbor cache is ready before processing evolve
1550
+ if (!cache.cacheReady) {
1551
+ console.log('[hexgrid-worker] ⏸️ Evolve message received but cache not ready yet - deferring...')
1552
+ // Defer this evolve message by re-posting it after a short delay
1553
+ setTimeout(() => {
1554
+ try { self.postMessage({ type: 'deferred-evolve', data: { reason: 'cache-not-ready' } }) } catch (e) {}
1555
+ // Re-process the message
1556
+ self.onmessage!(ev)
1557
+ }, 100)
1558
+ return
1559
+ }
1560
+
1561
+ // Normalize payload shape: support { data: { prevState, positions, photos, hexRadius, debug } }
1562
+ mergeDebugFromPayload(payload.debug || payload);
1563
+ // Diagnostic: log that an evolve was received and the available payload keys (only when debugLogs enabled)
1564
+ try {
1565
+ if (workerDebug && workerDebug.debugLogs) {
1566
+ console.log('[hexgrid-worker] evolve received, payload keys=', Object.keys(payload || {}), 'workerDebug.evolutionIntervalMs=', workerDebug.evolutionIntervalMs, 'workerDebug.evolveIntervalMs=', workerDebug.evolveIntervalMs)
1567
+ }
1568
+ } catch (e) {}
1569
+ const now = Date.now();
1570
+ const interval = typeof workerDebug.evolutionIntervalMs === 'number' ? workerDebug.evolutionIntervalMs : (typeof workerDebug.evolveIntervalMs === 'number' ? workerDebug.evolveIntervalMs : 60000);
1571
+ console.log('[hexgrid-worker] Throttle check: interval=', interval, 'lastEvolutionAt=', lastEvolutionAt, 'now=', now, 'diff=', now - lastEvolutionAt, 'willThrottle=', (now - lastEvolutionAt < interval))
1572
+ // Throttle: if we're within the interval, notify (debug) and skip processing
1573
+ const reason = payload.reason || (raw && raw.reason)
1574
+ const bypassThrottle = reason === 'photos-init' || reason === 'reset'
1575
+ // Clear, high-signal log for build verification: reports whether the current evolve will bypass the worker throttle
1576
+ console.log('[hexgrid-worker] THROTTLE DECISION', { interval, lastEvolutionAt, now, diff: now - lastEvolutionAt, willThrottle: (!bypassThrottle && (now - lastEvolutionAt < interval)), reason, bypassThrottle })
1577
+ // Throttle: if we're within the interval and not bypassed, notify (debug) and skip processing
1578
+ if (!bypassThrottle && now - lastEvolutionAt < interval) {
1579
+ console.log('[hexgrid-worker] ⛔ THROTTLED - skipping evolution processing')
1580
+ if (workerDebug && workerDebug.debugLogs) {
1581
+ try { self.postMessage({ type: 'throttled-evolve', data: { receivedAt: now, nextAvailableAt: lastEvolutionAt + interval, payloadKeys: Object.keys(payload || {}), reason } }) } catch (e) {}
1582
+ }
1583
+ return
1584
+ }
1585
+ // Mark processed time and send ack for an evolve we will process
1586
+ lastEvolutionAt = now
1587
+ console.log('[hexgrid-worker] ✅ PROCESSING evolution - lastEvolutionAt updated to', now)
1588
+ try {
1589
+ if (workerDebug && workerDebug.debugLogs) {
1590
+ try { self.postMessage({ type: 'ack-evolve', data: { receivedAt: now, payloadKeys: Object.keys(payload || {}) } }) } catch (e) {}
1591
+ }
1592
+ } catch (e) {}
1593
+
1594
+ // Emit a lightweight processing marker so the client can see evolve processing started
1595
+ try {
1596
+ if (workerDebug && workerDebug.debugLogs) {
1597
+ try { self.postMessage({ type: 'processing-evolve', data: { startedAt: now, payloadKeys: Object.keys(payload || {}) } }) } catch (e) {}
1598
+ }
1599
+ } catch (e) {}
1600
+
1601
+ const state = payload.prevState ?? payload.state ?? raw.state ?? null
1602
+ const positions = payload.positions ?? raw.positions ?? []
1603
+ const photos = payload.photos ?? raw.photos ?? []
1604
+ const hexRadius = typeof payload.hexRadius === 'number' ? payload.hexRadius : (typeof raw.hexRadius === 'number' ? raw.hexRadius : 16)
1605
+
1606
+ if (typeof payload.isSpherical === 'boolean' && Boolean(payload.isSpherical) !== cache.isSpherical) {
1607
+ invalidateCaches(Boolean(payload.isSpherical))
1608
+ }
1609
+
1610
+ console.log('[hexgrid-worker] 🔧 About to call evolveInfectionSystem')
1611
+ console.log('[hexgrid-worker] - state generation:', state?.generation)
1612
+ console.log('[hexgrid-worker] - state infections:', state?.infections?.length || state?.infections?.size || 0)
1613
+ console.log('[hexgrid-worker] - positions:', positions?.length || 0)
1614
+ console.log('[hexgrid-worker] - photos:', photos?.length || 0)
1615
+ console.log('[hexgrid-worker] - hexRadius:', hexRadius)
1616
+
1617
+ let res
1618
+ let timeoutId
1619
+ let timedOut = false
1620
+
1621
+ // Set a watchdog timer to detect hangs (10 seconds)
1622
+ timeoutId = setTimeout(() => {
1623
+ timedOut = true
1624
+ console.error('[hexgrid-worker] ⏱️ TIMEOUT: evolveInfectionSystem is taking too long (>10s)! Possible infinite loop.')
1625
+ try { self.postMessage({ type: 'error', error: 'Evolution timeout - possible infinite loop' }) } catch (e) {}
1626
+ }, 10000)
1627
+
1628
+ try {
1629
+ console.log('[hexgrid-worker] 🚀 Calling evolveInfectionSystem NOW...')
1630
+ const startTime = Date.now()
1631
+ res = evolveInfectionSystem(state, positions, photos, hexRadius, now, !!workerDebug.debugLogs)
1632
+ const elapsed = Date.now() - startTime
1633
+ clearTimeout(timeoutId)
1634
+ console.log('[hexgrid-worker] ✅ evolveInfectionSystem RETURNED successfully in', elapsed, 'ms')
1635
+ } catch (err) {
1636
+ clearTimeout(timeoutId)
1637
+ console.error('[hexgrid-worker] ❌ FATAL: evolveInfectionSystem threw an error:', err)
1638
+ console.error('[hexgrid-worker] Error stack:', err instanceof Error ? err.stack : 'no stack')
1639
+ safePostError(err)
1640
+ return
1641
+ }
1642
+
1643
+ if (timedOut) {
1644
+ console.error('[hexgrid-worker] ⏱️ Function eventually returned but after timeout was triggered')
1645
+ }
1646
+
1647
+ if (!res) {
1648
+ console.log('[hexgrid-worker] ❌ evolveInfectionSystem returned null!')
1649
+ return
1650
+ }
1651
+ console.log('[hexgrid-worker] ✅ Evolution complete! New generation=', res.generation, 'infections=', res.infections.size)
1652
+ try {
1653
+ const payload: any = { infections: Array.from(res.infections.entries()), availableIndices: res.availableIndices, lastEvolutionTime: res.lastEvolutionTime, generation: res.generation }
1654
+ if (res.tileCenters && res.tileCenters.length > 0) {
1655
+ payload.tileCenters = res.tileCenters
1656
+ console.log('[hexgrid-worker] Including', res.tileCenters.length, 'tile center sets in evolved message')
1657
+ }
1658
+ self.postMessage({ type: 'evolved', data: payload })
1659
+ // Record posted generation/infection count so later auto-triggers can avoid regressing
1660
+ try { cache.lastGeneration = res.generation; cache.lastInfectionCount = res.infections ? res.infections.size : 0 } catch (e) {}
1661
+ } catch (e) {
1662
+ console.error('[hexgrid-worker] ❌ Failed to post evolved message:', e)
1663
+ }
1664
+ console.log('[hexgrid-worker] 📤 Posted evolved message back to main thread')
1665
+
1666
+ // Emit a completion marker so the client can confirm the evolve finished end-to-end
1667
+ try {
1668
+ if (workerDebug && workerDebug.debugLogs) {
1669
+ try { self.postMessage({ type: 'evolved-complete', data: { finishedAt: Date.now(), generation: res.generation, lastEvolutionTime: res.lastEvolutionTime } }) } catch (e) {}
1670
+ }
1671
+ } catch (e) {}
1672
+ return
1673
+ }
1674
+
1675
+ if (type === 'optimize') {
1676
+ try {
1677
+ const infectionsArr = payload.infections || raw.infections || []
1678
+ const infections = new Map<number, Infection>(infectionsArr)
1679
+ const positions = payload.positions ?? raw.positions
1680
+ const hexRadius = typeof payload.hexRadius === 'number' ? payload.hexRadius : (typeof raw.hexRadius === 'number' ? raw.hexRadius : 16)
1681
+ postOptimizationMerge(infections, positions, hexRadius, !!workerDebug.mergeLogs)
1682
+ try { self.postMessage({ type: 'optimized', data: { infections: Array.from(infections.entries()) } }) } catch (e) {}
1683
+ } catch (e) { safePostError(e) }
1684
+ return
1685
+ }
1686
+ } catch (err) { safePostError(err) }
1687
+ }
1688
+
1689
+ // Additional helpers that the optimizer uses (kept separate and consistent)
1690
+
1691
+ function calculatePhotoContiguityCached(
1692
+ photoIdOrPhoto: string | Photo,
1693
+ indices: number[],
1694
+ positions: [number, number, number][],
1695
+ hexRadius: number,
1696
+ debugLogs: boolean = true
1697
+ ): number {
1698
+ const photoId = typeof photoIdOrPhoto === 'string' ? photoIdOrPhoto : (photoIdOrPhoto as Photo).id
1699
+ return calculatePhotoContiguity(photoId, indices, positions, hexRadius, debugLogs)
1700
+ }
1701
+
1702
+ function calculatePhotoContiguity(
1703
+ photoId: string,
1704
+ indices: number[],
1705
+ positions: [number, number, number][],
1706
+ hexRadius: number,
1707
+ debugLogs: boolean = true
1708
+ ): number {
1709
+ const getNeighbors = (index: number) => getNeighborsCached(index, positions, hexRadius)
1710
+ return _calculatePhotoContiguity(indices, positions, hexRadius, getNeighbors)
1711
+ }
1712
+
1713
+ function calculateSwappedContiguityCached(
1714
+ photoId: string,
1715
+ indices: number[],
1716
+ positions: [number, number, number][],
1717
+ hexRadius: number,
1718
+ fromIndex: number,
1719
+ toIndex: number,
1720
+ infections: Map<number, Infection>,
1721
+ debugLogs: boolean = true
1722
+ ): number {
1723
+ const tempIndices = [...indices]
1724
+ const fromPos = tempIndices.indexOf(fromIndex)
1725
+ const toPos = tempIndices.indexOf(toIndex)
1726
+ if (fromPos !== -1) tempIndices[fromPos] = toIndex
1727
+ if (toPos !== -1) tempIndices[toPos] = fromIndex
1728
+ return calculatePhotoContiguity(photoId, tempIndices, positions, hexRadius, debugLogs)
1729
+ }
1730
+
1731
+ function analyzeLocalEnvironment(
1732
+ centerIndex: number,
1733
+ infections: Map<number, Infection>,
1734
+ positions: [number, number, number][],
1735
+ hexRadius: number,
1736
+ radius: number = 2,
1737
+ debugLogs: boolean = true
1738
+ ) {
1739
+ const centerPos = positions[centerIndex]
1740
+ const localIndices: number[] = []
1741
+ const visited = new Set<number>()
1742
+ const queue: Array<[number, number]> = [[centerIndex, 0]]
1743
+
1744
+ while (queue.length > 0) {
1745
+ const [currentIndex, distance] = queue.shift()!
1746
+ if (visited.has(currentIndex) || distance > radius) continue
1747
+ visited.add(currentIndex)
1748
+ localIndices.push(currentIndex)
1749
+ if (distance < radius) {
1750
+ const neighbors = getNeighborsCached(currentIndex, positions, hexRadius)
1751
+ for (const neighborIndex of neighbors) {
1752
+ if (!visited.has(neighborIndex)) queue.push([neighborIndex, distance + 1])
1753
+ }
1754
+ }
1755
+ }
1756
+
1757
+ let infectedCount = 0
1758
+ const photoCounts = new Map<string, number>()
1759
+ const clusterSizes = new Map<string, number>()
1760
+ let boundaryPressure = 0
1761
+ let totalVariance = 0
1762
+
1763
+ for (const index of localIndices) {
1764
+ const infection = infections.get(index)
1765
+ if (infection) {
1766
+ infectedCount++
1767
+ const photoId = infection.photo.id
1768
+ photoCounts.set(photoId, (photoCounts.get(photoId) || 0) + 1)
1769
+ clusterSizes.set(photoId, (clusterSizes.get(photoId) || 0) + 1)
1770
+ } else {
1771
+ boundaryPressure += 0.1
1772
+ }
1773
+ }
1774
+
1775
+ const totalPhotos = photoCounts.size
1776
+ const avgPhotoCount = infectedCount / Math.max(totalPhotos, 1)
1777
+ for (const count of photoCounts.values()) totalVariance += Math.pow(count - avgPhotoCount, 2)
1778
+ const localVariance = totalVariance / Math.max(infectedCount, 1)
1779
+
1780
+ let dominantPhoto: Photo | null = null
1781
+ let maxCount = 0
1782
+ for (const [photoId, count] of photoCounts) {
1783
+ if (count > maxCount) {
1784
+ maxCount = count
1785
+ for (const infection of infections.values()) {
1786
+ if (infection.photo.id === photoId) { dominantPhoto = infection.photo; break }
1787
+ }
1788
+ }
1789
+ }
1790
+
1791
+ const density = infectedCount / Math.max(localIndices.length, 1)
1792
+ const stability = dominantPhoto ? (maxCount / Math.max(infectedCount, 1)) : 0
1793
+
1794
+ return { density, stability, dominantPhoto, clusterSizes, boundaryPressure, localVariance }
1795
+ }
1796
+
1797
+ function invalidateCaches(isSpherical?: boolean) {
1798
+ cache.neighborMap.clear()
1799
+ cache.gridBounds = null
1800
+ cache.photoClusters.clear()
1801
+ cache.connectedComponents.clear()
1802
+ cache.gridPositions.clear()
1803
+ cache.cacheReady = false
1804
+ if (typeof isSpherical === 'boolean') cache.isSpherical = isSpherical
1805
+ }
1806
+
1807
+ console.log('[hexgrid-worker] ready')