@buley/hexgrid-3d 1.0.0 → 1.1.1

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 (58) hide show
  1. package/build_log.txt +500 -0
  2. package/build_src_log.txt +8 -0
  3. package/examples/basic-usage.tsx +19 -19
  4. package/package.json +1 -1
  5. package/public/hexgrid-worker.js +2350 -1638
  6. package/site/.eslintrc.json +3 -0
  7. package/site/DEPLOYMENT.md +196 -0
  8. package/site/INDEX.md +127 -0
  9. package/site/QUICK_START.md +86 -0
  10. package/site/README.md +85 -0
  11. package/site/SITE_SUMMARY.md +180 -0
  12. package/site/next.config.js +12 -0
  13. package/site/package.json +26 -0
  14. package/site/src/app/docs/page.tsx +148 -0
  15. package/site/src/app/examples/page.tsx +133 -0
  16. package/site/src/app/globals.css +160 -0
  17. package/site/src/app/layout.tsx +29 -0
  18. package/site/src/app/page.tsx +163 -0
  19. package/site/tsconfig.json +29 -0
  20. package/site/vercel.json +6 -0
  21. package/src/Snapshot.ts +790 -585
  22. package/src/adapters/DashAdapter.ts +57 -0
  23. package/src/adapters.ts +16 -18
  24. package/src/algorithms/AdvancedStatistics.ts +58 -24
  25. package/src/algorithms/BayesianStatistics.ts +43 -12
  26. package/src/algorithms/FlowField.ts +30 -6
  27. package/src/algorithms/FlowField3D.ts +573 -0
  28. package/src/algorithms/FluidSimulation.ts +19 -3
  29. package/src/algorithms/FluidSimulation3D.ts +664 -0
  30. package/src/algorithms/GraphAlgorithms.ts +19 -12
  31. package/src/algorithms/OutlierDetection.ts +72 -38
  32. package/src/algorithms/ParticleSystem.ts +12 -2
  33. package/src/algorithms/ParticleSystem3D.ts +567 -0
  34. package/src/algorithms/index.ts +14 -8
  35. package/src/compat.ts +10 -10
  36. package/src/components/HexGrid.tsx +10 -23
  37. package/src/components/NarrationOverlay.tsx +140 -52
  38. package/src/components/index.ts +2 -1
  39. package/src/features.ts +31 -31
  40. package/src/index.ts +11 -11
  41. package/src/lib/narration.ts +17 -0
  42. package/src/lib/stats-tracker.ts +25 -0
  43. package/src/lib/theme-colors.ts +12 -0
  44. package/src/math/HexCoordinates.ts +849 -4
  45. package/src/math/Matrix4.ts +2 -12
  46. package/src/math/Vector3.ts +49 -1
  47. package/src/math/index.ts +6 -6
  48. package/src/note-adapter.ts +50 -42
  49. package/src/ontology-adapter.ts +30 -23
  50. package/src/stores/uiStore.ts +34 -34
  51. package/src/types/shared-utils.d.ts +10 -0
  52. package/src/types.ts +110 -98
  53. package/src/utils/image-utils.ts +9 -6
  54. package/src/wasm/HexGridWasmWrapper.ts +436 -388
  55. package/src/wasm/index.ts +2 -2
  56. package/src/workers/hexgrid-math.ts +40 -35
  57. package/src/workers/hexgrid-worker.worker.ts +1992 -1018
  58. package/tsconfig.json +21 -14
@@ -13,74 +13,117 @@ import {
13
13
  calculateUvBoundsFromGridPosition as _calculateUvBoundsFromGridPosition,
14
14
  calculateContiguity as _calculateContiguity,
15
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 }> }> }
16
+ } from './hexgrid-math';
17
+
18
+ export interface Photo {
19
+ id: string;
20
+ title?: string;
21
+ alt?: string;
22
+ imageUrl?: string;
23
+ velocity?: number;
24
+ }
25
+ export interface Infection {
26
+ photo: Photo;
27
+ gridPosition: [number, number];
28
+ infectionTime: number;
29
+ generation: number;
30
+ uvBounds: [number, number, number, number];
31
+ scale: number;
32
+ growthRate?: number;
33
+ tilesX?: number;
34
+ tilesY?: number;
35
+ }
36
+ export interface InfectionSystemState {
37
+ infections: Map<number, Infection>;
38
+ availableIndices: number[];
39
+ lastEvolutionTime: number;
40
+ generation: number;
41
+ tileCenters?: Array<{
42
+ photoId: string;
43
+ clusterIndex: number;
44
+ centers: Array<{ x: number; y: number; col: number; row: number }>;
45
+ }>;
46
+ }
21
47
 
22
- const WORKER_ID = Math.random().toString(36).substring(7)
23
- console.log('[hexgrid-worker] loaded id=', WORKER_ID)
48
+ const WORKER_ID = Math.random().toString(36).substring(7);
49
+ console.log('[hexgrid-worker] loaded id=', WORKER_ID);
24
50
 
25
51
  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
52
+ cohesionBoost: 6.0, // BOOSTED: strongly favor growth near cluster centroids to build larger regions
53
+ enableMerges: true, // ENABLED: merge small fragments into nearby larger clusters
54
+ mergeSmallComponentsThreshold: 20, // INCREASED: merge clusters of 20 hexes or fewer
29
55
  mergeLogs: false,
30
56
  evolutionIntervalMs: 30000,
31
57
  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
- }
58
+ enableCellDeath: true, // ENABLED: allow fully surrounded cells to die and respawn with better positioning
59
+ cellDeathProbability: 0.05, // 5% chance per evolution for fully surrounded cells to reset
60
+ enableMutation: true, // ENABLED: allow dying cells to mutate into different photos
61
+ mutationProbability: 0.3, // 30% chance for a dying cell to respawn as a different photo
62
+ enableVirilityBoost: true, // ENABLED: boost infection rate based on photo velocity/upvotes
63
+ virilityMultiplier: 1.0, // Multiplier for virility effect (1.0 = normal, higher = more impact)
64
+ annealingRate: 2.0, // Multiplier for death/churn rates to help system escape local optima (1.0 = normal, higher = more reorganization)
65
+ enableEntropyDecay: true, // ENABLED: entropy decay - successful/dominant photos decay over time to allow new dominance
66
+ entropyDecayBaseRate: 0.02, // Base decay rate per generation (2% for highly dominant photos)
67
+ entropyDominanceThreshold: 0.15, // Territory share threshold to be considered "dominant" (15%)
68
+ entropySuccessVelocityThreshold: 50, // Velocity threshold to be considered "successful" (only successful photos decay)
69
+ entropyTimeMultiplier: 0.1, // Multiplier for time-as-dominant effect (0.1 = each generation as dominant adds 10% to decay rate)
70
+ };
45
71
 
46
72
  // 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
73
+ workerDebug.clusterPreserveAspect = true; // when true, preserve cluster aspect ratio when mapping to tile grid
74
+ workerDebug.clusterDynamicTiling = true; // when true, calculate tilesX/tilesY dynamically based on cluster aspect ratio
75
+ workerDebug.clusterAnchor = 'center'; // 'center' or 'min' (used during aspect correction)
76
+ workerDebug.clusterGlobalAlign = false; // when true, clusters snap to global tile anchor for better neighbor alignment
77
+ workerDebug.clusterUvInset = 0.0; // shrink UVs slightly to allow texture filtering/edge blending (0..0.5)
78
+ workerDebug.clusterJitter = 0.0; // small (0..0.5) fractional jitter applied to normalized coords before quantization
53
79
  // adjacency mode for cluster tiling: 'hex' (6-way) or 'rect' (4-way). 'rect' gives raster-like, cohesive images
54
- workerDebug.clusterAdjacency = 'rect'
80
+ workerDebug.clusterAdjacency = 'rect';
55
81
  // maximum number of tiles to allocate for a cluster when dynamically expanding (cap)
56
- workerDebug.clusterMaxTiles = 128
82
+ workerDebug.clusterMaxTiles = 128;
57
83
  // whether to 'contain' (fit whole image within cluster bounds) or 'cover' (fill cluster and allow cropping)
58
- workerDebug.clusterFillMode = 'contain'
84
+ workerDebug.clusterFillMode = 'contain';
59
85
  // scan order for filling tiles: 'row' = left->right each row, 'serpentine' = zig-zag per row
60
- workerDebug.clusterScanMode = 'row'
86
+ workerDebug.clusterScanMode = 'row';
61
87
  // when true, compute tile centers using hex-row parity offsets so ordering follows hex staggering
62
- workerDebug.clusterParityAware = true
88
+ workerDebug.clusterParityAware = true;
63
89
  // when true, include computed tile centers in evolved message for debug visualization
64
- workerDebug.showTileCenters = false
90
+ workerDebug.showTileCenters = false;
65
91
  // when true, enable direct hex lattice mapping fast-path (parity-correct row/col inference)
66
- workerDebug.clusterHexLattice = true
92
+ workerDebug.clusterHexLattice = true;
67
93
  // when true, horizontally nudge odd rows' UV sampling by half a tile width to compensate
68
94
  // for physical hex center staggering (attempts to eliminate visible half-hex seams)
69
- workerDebug.clusterParityUvShift = true
95
+ workerDebug.clusterParityUvShift = true;
70
96
  // 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 }
97
+ workerDebug.clusterCompactGaps = true;
98
+
99
+ const cache: any = {
100
+ neighborMap: new Map<number, number[]>(),
101
+ gridBounds: null,
102
+ photoClusters: new Map<string, number[]>(),
103
+ connectedComponents: new Map<string, number[][]>(),
104
+ gridPositions: new Map<number, [number, number]>(),
105
+ lastInfectionCount: 0,
106
+ lastGeneration: -1,
107
+ isSpherical: false,
108
+ cacheReady: false,
109
+ };
74
110
  // Track dominance history for entropy decay: photoId -> generations as dominant
75
- const dominanceHistory: Map<string, number> = new Map()
111
+ const dominanceHistory: Map<string, number> = new Map();
76
112
 
77
- function safePostError(err: unknown) { try { self.postMessage({ type: 'error', error: err instanceof Error ? err.message : String(err) }) } catch (e) {} }
113
+ function safePostError(err: unknown) {
114
+ try {
115
+ self.postMessage({
116
+ type: 'error',
117
+ error: err instanceof Error ? err.message : String(err),
118
+ });
119
+ } catch (e) {}
120
+ }
78
121
 
79
122
  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
123
+ if (cache.gridBounds) return cache.gridBounds;
124
+ const bounds = _getGridBounds(positions);
125
+ cache.gridBounds = bounds;
126
+ return bounds;
84
127
  }
85
128
 
86
129
  function distanceBetween(
@@ -89,68 +132,79 @@ function distanceBetween(
89
132
  bounds: { width: number; height: number },
90
133
  isSpherical: boolean
91
134
  ) {
92
- return _distanceBetween(a, b, bounds, isSpherical)
135
+ return _distanceBetween(a, b, bounds, isSpherical);
93
136
  }
94
137
 
95
- function getNeighborsCached(index: number, positions: [number, number, number][], hexRadius: number): number[] {
138
+ function getNeighborsCached(
139
+ index: number,
140
+ positions: [number, number, number][],
141
+ hexRadius: number
142
+ ): number[] {
96
143
  // Immediate return if cached - no blocking
97
144
  if (cache.neighborMap.has(index)) {
98
- const cached = cache.neighborMap.get(index)!
99
- if (Array.isArray(cached)) return cached
145
+ const cached = cache.neighborMap.get(index)!;
146
+ if (Array.isArray(cached)) return cached;
100
147
  // Invalid cache entry - clear it and recompute
101
- cache.neighborMap.delete(index)
148
+ cache.neighborMap.delete(index);
102
149
  }
103
-
150
+
104
151
  // Validate inputs before computation
105
152
  if (!positions || !Array.isArray(positions) || positions.length === 0) {
106
- console.warn('[getNeighborsCached] Invalid positions array, returning empty')
107
- return []
153
+ console.warn(
154
+ '[getNeighborsCached] Invalid positions array, returning empty'
155
+ );
156
+ return [];
108
157
  }
109
158
  if (typeof index !== 'number' || index < 0 || index >= positions.length) {
110
- console.warn('[getNeighborsCached] Invalid index', index, 'for positions length', positions.length)
111
- return []
159
+ console.warn(
160
+ '[getNeighborsCached] Invalid index',
161
+ index,
162
+ 'for positions length',
163
+ positions.length
164
+ );
165
+ return [];
112
166
  }
113
167
  if (typeof hexRadius !== 'number' || hexRadius <= 0) {
114
- console.warn('[getNeighborsCached] Invalid hexRadius', hexRadius)
115
- return []
168
+ console.warn('[getNeighborsCached] Invalid hexRadius', hexRadius);
169
+ return [];
116
170
  }
117
-
118
- const out: number[] = []
119
- const pos = positions[index]
171
+
172
+ const out: number[] = [];
173
+ const pos = positions[index];
120
174
  if (!pos) {
121
- console.warn('[getNeighborsCached] No position at index', index)
122
- return out
175
+ console.warn('[getNeighborsCached] No position at index', index);
176
+ return out;
123
177
  }
124
-
178
+
125
179
  try {
126
- const bounds = getGridBounds(positions)
127
- const threshold = Math.sqrt(3) * hexRadius * 1.15
128
- const isSpherical = !!cache.isSpherical
129
-
180
+ const bounds = getGridBounds(positions);
181
+ const threshold = Math.sqrt(3) * hexRadius * 1.15;
182
+ const isSpherical = !!cache.isSpherical;
183
+
130
184
  // Fast path: check only nearby candidates (≈6 neighbors for hex grid)
131
185
  // For hex grids, each hex has at most 6 neighbors
132
186
  // Limit search to reduce O(n²) to O(n)
133
- const maxNeighbors = 10 // Safety margin for irregular grids
134
-
187
+ const maxNeighbors = 10; // Safety margin for irregular grids
188
+
135
189
  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)
190
+ if (j === index) continue;
191
+ const p2 = positions[j];
192
+ if (!p2) continue;
193
+ const d = distanceBetween(pos, p2, bounds, isSpherical);
140
194
  if (d <= threshold) {
141
- out.push(j)
195
+ out.push(j);
142
196
  // Early exit if we found enough neighbors
143
- if (out.length >= maxNeighbors) break
197
+ if (out.length >= maxNeighbors) break;
144
198
  }
145
199
  }
146
-
147
- cache.neighborMap.set(index, out)
200
+
201
+ cache.neighborMap.set(index, out);
148
202
  } catch (e) {
149
- console.error('[getNeighborsCached] Error computing neighbors:', e)
150
- return []
203
+ console.error('[getNeighborsCached] Error computing neighbors:', e);
204
+ return [];
151
205
  }
152
-
153
- return out
206
+
207
+ return out;
154
208
  }
155
209
 
156
210
  // Calculate UV bounds for a tile based on its grid position within a tilesX x tilesY grid
@@ -161,104 +215,190 @@ function calculateUvBoundsFromGridPosition(
161
215
  tilesX: number,
162
216
  tilesY: number
163
217
  ): [number, number, number, number] {
164
- return _calculateUvBoundsFromGridPosition(gridCol, gridRow, tilesX, tilesY)
218
+ return _calculateUvBoundsFromGridPosition(gridCol, gridRow, tilesX, tilesY);
165
219
  }
166
220
 
167
- function findConnectedComponents(indices: number[], positions: [number, number, number][], hexRadius: number): number[][] {
221
+ function findConnectedComponents(
222
+ indices: number[],
223
+ positions: [number, number, number][],
224
+ hexRadius: number
225
+ ): number[][] {
168
226
  // 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
-
227
+ const startMarker = performance.now();
228
+ console.log(
229
+ '[findConnectedComponents] FUNCTION ENTERED - indices.length=',
230
+ indices.length,
231
+ 'positions.length=',
232
+ positions.length,
233
+ 'hexRadius=',
234
+ hexRadius,
235
+ 'marker=',
236
+ startMarker
237
+ );
238
+
172
239
  // Validate inputs immediately
173
240
  if (!indices || !Array.isArray(indices)) {
174
- console.error('[findConnectedComponents] Invalid indices:', indices)
175
- return []
241
+ console.error('[findConnectedComponents] Invalid indices:', indices);
242
+ return [];
176
243
  }
177
244
  if (!positions || !Array.isArray(positions)) {
178
- console.error('[findConnectedComponents] Invalid positions:', positions)
179
- return []
245
+ console.error('[findConnectedComponents] Invalid positions:', positions);
246
+ return [];
180
247
  }
181
248
  if (typeof hexRadius !== 'number' || hexRadius <= 0) {
182
- console.error('[findConnectedComponents] Invalid hexRadius:', hexRadius)
183
- return []
249
+ console.error('[findConnectedComponents] Invalid hexRadius:', hexRadius);
250
+ return [];
184
251
  }
185
-
186
- console.log('[findConnectedComponents] About to enter try block')
187
-
252
+
253
+ console.log('[findConnectedComponents] About to enter try block');
254
+
188
255
  // Add immediate log after try block entry to confirm execution reaches here
189
- let tryBlockEntered = false
256
+ let tryBlockEntered = false;
190
257
  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
258
+ tryBlockEntered = true;
259
+ console.log(
260
+ '[findConnectedComponents] TRY BLOCK ENTERED - marker=',
261
+ performance.now() - startMarker,
262
+ 'ms'
263
+ );
264
+ console.log(
265
+ '[findConnectedComponents] Inside try block - Starting with',
266
+ indices.length,
267
+ 'indices'
268
+ );
269
+ const set = new Set(indices);
270
+ const visited = new Set<number>();
271
+ const comps: number[][] = [];
272
+ let componentCount = 0;
198
273
  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
274
+ if (visited.has(start)) continue;
275
+ componentCount++;
276
+ console.log(
277
+ '[findConnectedComponents] Starting component',
278
+ componentCount,
279
+ 'from index',
280
+ start
281
+ );
282
+ const q = [start];
283
+ visited.add(start);
284
+ const comp: number[] = [];
285
+ let iterations = 0;
286
+ const maxIterations = indices.length * 10; // Safety limit
207
287
  while (q.length > 0) {
208
- iterations++
288
+ iterations++;
209
289
  if (iterations > maxIterations) {
210
- console.error('[findConnectedComponents] Safety limit reached! indices=', indices.length, 'component=', componentCount, 'iterations=', iterations)
211
- break
290
+ console.error(
291
+ '[findConnectedComponents] Safety limit reached! indices=',
292
+ indices.length,
293
+ 'component=',
294
+ componentCount,
295
+ 'iterations=',
296
+ iterations
297
+ );
298
+ break;
212
299
  }
213
300
  if (iterations % 100 === 0) {
214
- console.log('[findConnectedComponents] Component', componentCount, 'iteration', iterations, 'queue length', q.length)
301
+ console.log(
302
+ '[findConnectedComponents] Component',
303
+ componentCount,
304
+ 'iteration',
305
+ iterations,
306
+ 'queue length',
307
+ q.length
308
+ );
215
309
  }
216
- const cur = q.shift()!
310
+ const cur = q.shift()!;
217
311
  if (cur === undefined || cur === null) {
218
- console.error('[findConnectedComponents] Invalid cur value:', cur)
219
- break
312
+ console.error('[findConnectedComponents] Invalid cur value:', cur);
313
+ break;
220
314
  }
221
- comp.push(cur)
315
+ comp.push(cur);
222
316
  try {
223
- const neighbors = getNeighborsCached(cur, positions, hexRadius)
317
+ const neighbors = getNeighborsCached(cur, positions, hexRadius);
224
318
  if (!Array.isArray(neighbors)) {
225
- console.error('[findConnectedComponents] getNeighborsCached returned non-array:', typeof neighbors, neighbors)
226
- continue
319
+ console.error(
320
+ '[findConnectedComponents] getNeighborsCached returned non-array:',
321
+ typeof neighbors,
322
+ neighbors
323
+ );
324
+ continue;
227
325
  }
228
326
  for (const n of neighbors) {
229
327
  if (typeof n !== 'number' || isNaN(n)) {
230
- console.error('[findConnectedComponents] Invalid neighbor index:', n, 'type:', typeof n)
231
- continue
328
+ console.error(
329
+ '[findConnectedComponents] Invalid neighbor index:',
330
+ n,
331
+ 'type:',
332
+ typeof n
333
+ );
334
+ continue;
232
335
  }
233
- if (!visited.has(n) && set.has(n)) {
234
- visited.add(n);
235
- q.push(n)
336
+ if (!visited.has(n) && set.has(n)) {
337
+ visited.add(n);
338
+ q.push(n);
236
339
  }
237
340
  }
238
341
  } catch (e) {
239
- console.error('[findConnectedComponents] Error getting neighbors for index', cur, ':', e)
240
- continue
342
+ console.error(
343
+ '[findConnectedComponents] Error getting neighbors for index',
344
+ cur,
345
+ ':',
346
+ e
347
+ );
348
+ continue;
241
349
  }
242
350
  }
243
- console.log('[findConnectedComponents] Component', componentCount, 'complete:', comp.length, 'nodes,', iterations, 'iterations')
244
- comps.push(comp)
351
+ console.log(
352
+ '[findConnectedComponents] Component',
353
+ componentCount,
354
+ 'complete:',
355
+ comp.length,
356
+ 'nodes,',
357
+ iterations,
358
+ 'iterations'
359
+ );
360
+ comps.push(comp);
245
361
  }
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
362
+ console.log(
363
+ '[findConnectedComponents] Complete:',
364
+ comps.length,
365
+ 'components found'
366
+ );
367
+ const elapsed = performance.now() - startMarker;
368
+ console.log(
369
+ '[findConnectedComponents] ✅ RETURNING - elapsed=',
370
+ elapsed,
371
+ 'ms, components=',
372
+ comps.length
373
+ );
374
+ return comps;
250
375
  } catch (e) {
251
- const elapsed = performance.now() - startMarker
252
- console.error('[findConnectedComponents] ERROR after', elapsed, 'ms:', e, 'indices.length=', indices.length, 'tryBlockEntered=', tryBlockEntered)
376
+ const elapsed = performance.now() - startMarker;
377
+ console.error(
378
+ '[findConnectedComponents] ERROR after',
379
+ elapsed,
380
+ 'ms:',
381
+ e,
382
+ 'indices.length=',
383
+ indices.length,
384
+ 'tryBlockEntered=',
385
+ tryBlockEntered
386
+ );
253
387
  // If we never entered the try block, something is seriously wrong
254
388
  if (!tryBlockEntered) {
255
- console.error('[findConnectedComponents] CRITICAL: Try block never entered! This suggests a hang before try block.')
389
+ console.error(
390
+ '[findConnectedComponents] CRITICAL: Try block never entered! This suggests a hang before try block.'
391
+ );
256
392
  }
257
- throw e
393
+ throw e;
258
394
  } finally {
259
- const elapsed = performance.now() - startMarker
395
+ const elapsed = performance.now() - startMarker;
260
396
  if (elapsed > 1000) {
261
- console.warn('[findConnectedComponents] ⚠️ Function took', elapsed, 'ms to complete')
397
+ console.warn(
398
+ '[findConnectedComponents] ⚠️ Function took',
399
+ elapsed,
400
+ 'ms to complete'
401
+ );
262
402
  }
263
403
  }
264
404
  }
@@ -269,68 +409,134 @@ function calculatePhotoCentroids(
269
409
  hexRadius: number
270
410
  ) {
271
411
  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)
412
+ console.log(
413
+ '[calculatePhotoCentroids] Starting with',
414
+ infections.size,
415
+ 'infections'
416
+ );
417
+ const byPhoto = new Map<string, number[]>();
418
+ for (const [idx, inf] of infections) {
419
+ if (!inf || !inf.photo) continue;
420
+ const arr = byPhoto.get(inf.photo.id) || [];
421
+ arr.push(idx);
422
+ byPhoto.set(inf.photo.id, arr);
279
423
  }
280
- console.log('[calculatePhotoCentroids] Grouped into', byPhoto.size, 'photos')
281
- const centroids = new Map<string, [number, number][]>()
282
- let photoNum = 0
424
+ console.log(
425
+ '[calculatePhotoCentroids] Grouped into',
426
+ byPhoto.size,
427
+ 'photos'
428
+ );
429
+ const centroids = new Map<string, [number, number][]>();
430
+ let photoNum = 0;
283
431
  for (const [photoId, inds] of byPhoto) {
284
- photoNum++
285
- console.log('[calculatePhotoCentroids] Processing photo', photoNum, '/', byPhoto.size, 'photoId=', photoId, 'indices=', inds.length)
432
+ photoNum++;
433
+ console.log(
434
+ '[calculatePhotoCentroids] Processing photo',
435
+ photoNum,
436
+ '/',
437
+ byPhoto.size,
438
+ 'photoId=',
439
+ photoId,
440
+ 'indices=',
441
+ inds.length
442
+ );
286
443
  try {
287
- console.log('[calculatePhotoCentroids] About to call findConnectedComponents with', inds.length, 'indices')
288
- const callStartTime = performance.now()
289
- let comps: number[][]
444
+ console.log(
445
+ '[calculatePhotoCentroids] About to call findConnectedComponents with',
446
+ inds.length,
447
+ 'indices'
448
+ );
449
+ const callStartTime = performance.now();
450
+ let comps: number[][];
290
451
  try {
291
452
  // Add a pre-call validation to ensure we're not calling with invalid data
292
453
  if (!inds || inds.length === 0) {
293
- console.warn('[calculatePhotoCentroids] Empty indices array, skipping findConnectedComponents')
294
- comps = []
454
+ console.warn(
455
+ '[calculatePhotoCentroids] Empty indices array, skipping findConnectedComponents'
456
+ );
457
+ comps = [];
295
458
  } else if (!positions || positions.length === 0) {
296
- console.warn('[calculatePhotoCentroids] Empty positions array, skipping findConnectedComponents')
297
- comps = []
459
+ console.warn(
460
+ '[calculatePhotoCentroids] Empty positions array, skipping findConnectedComponents'
461
+ );
462
+ comps = [];
298
463
  } 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')
464
+ comps = findConnectedComponents(inds, positions, hexRadius);
465
+ const callElapsed = performance.now() - callStartTime;
466
+ console.log(
467
+ '[calculatePhotoCentroids] findConnectedComponents RETURNED with',
468
+ comps.length,
469
+ 'components after',
470
+ callElapsed,
471
+ 'ms'
472
+ );
302
473
  }
303
474
  } catch (e) {
304
- const callElapsed = performance.now() - callStartTime
305
- console.error('[calculatePhotoCentroids] findConnectedComponents threw error after', callElapsed, 'ms:', e)
475
+ const callElapsed = performance.now() - callStartTime;
476
+ console.error(
477
+ '[calculatePhotoCentroids] findConnectedComponents threw error after',
478
+ callElapsed,
479
+ 'ms:',
480
+ e
481
+ );
306
482
  // Return empty components on error to allow evolution to continue
307
- comps = []
483
+ comps = [];
308
484
  }
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][] = []
485
+ console.log(
486
+ '[calculatePhotoCentroids] findConnectedComponents returned',
487
+ comps.length,
488
+ 'components'
489
+ );
490
+ console.log(
491
+ '[calculatePhotoCentroids] Found',
492
+ comps.length,
493
+ 'components for photo',
494
+ photoId
495
+ );
496
+ const cs: [number, number][] = [];
312
497
  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])
498
+ let sx = 0,
499
+ sy = 0;
500
+ for (const i of comp) {
501
+ const p = positions[i];
502
+ if (p) {
503
+ sx += p[0];
504
+ sy += p[1];
505
+ }
506
+ }
507
+ if (comp.length > 0) cs.push([sx / comp.length, sy / comp.length]);
316
508
  }
317
- centroids.set(photoId, cs)
509
+ centroids.set(photoId, cs);
318
510
  } catch (e) {
319
- console.error('[calculatePhotoCentroids] Error processing photo', photoId, ':', e)
320
- centroids.set(photoId, [])
511
+ console.error(
512
+ '[calculatePhotoCentroids] Error processing photo',
513
+ photoId,
514
+ ':',
515
+ e
516
+ );
517
+ centroids.set(photoId, []);
321
518
  }
322
519
  }
323
- console.log('[calculatePhotoCentroids] Completed, returning', centroids.size, 'photo centroids')
324
- return centroids
520
+ console.log(
521
+ '[calculatePhotoCentroids] Completed, returning',
522
+ centroids.size,
523
+ 'photo centroids'
524
+ );
525
+ return centroids;
325
526
  } catch (e) {
326
- console.error('[calculatePhotoCentroids] FATAL ERROR:', e)
327
- throw e
527
+ console.error('[calculatePhotoCentroids] FATAL ERROR:', e);
528
+ throw e;
328
529
  }
329
530
  }
330
531
 
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)
532
+ function calculateContiguity(
533
+ indices: number[],
534
+ positions: [number, number, number][],
535
+ hexRadius: number
536
+ ) {
537
+ const getNeighbors = (index: number) =>
538
+ getNeighborsCached(index, positions, hexRadius);
539
+ return _calculateContiguity(indices, positions, hexRadius, getNeighbors);
334
540
  }
335
541
 
336
542
  // Assign cluster-aware grid positions so each hex in a cluster shows a different part of the image
@@ -339,47 +545,70 @@ function assignClusterGridPositions(
339
545
  infections: Map<number, Infection>,
340
546
  positions: [number, number, number][],
341
547
  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
-
548
+ ): Array<{
549
+ photoId: string;
550
+ clusterIndex: number;
551
+ centers: Array<{ x: number; y: number; col: number; row: number }>;
552
+ }> {
553
+ const debugCenters: Array<{
554
+ photoId: string;
555
+ clusterIndex: number;
556
+ centers: Array<{ x: number; y: number; col: number; row: number }>;
557
+ }> = [];
558
+
345
559
  try {
346
- console.log('[assignClusterGridPositions] Starting with', infections.size, 'infections')
347
-
560
+ console.log(
561
+ '[assignClusterGridPositions] Starting with',
562
+ infections.size,
563
+ 'infections'
564
+ );
565
+
348
566
  // Group infections by photo
349
- const byPhoto = new Map<string, number[]>()
567
+ const byPhoto = new Map<string, number[]>();
350
568
  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)
569
+ if (!inf || !inf.photo) continue;
570
+ const arr = byPhoto.get(inf.photo.id) || [];
571
+ arr.push(idx);
572
+ byPhoto.set(inf.photo.id, arr);
355
573
  }
356
-
357
- console.log('[assignClusterGridPositions] Processing', byPhoto.size, 'unique photos')
358
-
574
+
575
+ console.log(
576
+ '[assignClusterGridPositions] Processing',
577
+ byPhoto.size,
578
+ 'unique photos'
579
+ );
580
+
359
581
  // Cluster size analytics
360
- let totalClusters = 0
361
- let clusterSizes: number[] = []
362
-
582
+ let totalClusters = 0;
583
+ let clusterSizes: number[] = [];
584
+
363
585
  // Process each photo's clusters
364
586
  for (const [photoId, indices] of byPhoto) {
365
587
  // Find connected components (separate clusters of the same photo)
366
- const components = findConnectedComponents(indices, positions, hexRadius)
367
-
368
- totalClusters += components.length
588
+ const components = findConnectedComponents(indices, positions, hexRadius);
589
+
590
+ totalClusters += components.length;
369
591
  for (const comp of components) {
370
- if (comp && comp.length > 0) clusterSizes.push(comp.length)
592
+ if (comp && comp.length > 0) clusterSizes.push(comp.length);
371
593
  }
372
-
373
- console.log('[assignClusterGridPositions] Photo', photoId.substring(0, 8), 'has', components.length, 'clusters, sizes:', components.map(c => c.length).join(','))
374
-
594
+
595
+ console.log(
596
+ '[assignClusterGridPositions] Photo',
597
+ photoId.substring(0, 8),
598
+ 'has',
599
+ components.length,
600
+ 'clusters, sizes:',
601
+ components.map((c) => c.length).join(',')
602
+ );
603
+
375
604
  // Process each cluster separately
376
- let clusterIndex = 0
605
+ let clusterIndex = 0;
377
606
  for (const cluster of components) {
378
- if (!cluster || cluster.length === 0) continue
607
+ if (!cluster || cluster.length === 0) continue;
379
608
 
380
609
  // Get the tiling configuration from the first infection in the cluster
381
- const firstInf = infections.get(cluster[0])
382
- if (!firstInf) continue
610
+ const firstInf = infections.get(cluster[0]);
611
+ if (!firstInf) continue;
383
612
 
384
613
  // --- Hex lattice mapping fast-path -------------------------------------------------
385
614
  // If enabled, derive tile coordinates directly from inferred axial-like row/col indices
@@ -387,225 +616,308 @@ function assignClusterGridPositions(
387
616
  // contiguous, parity-correct tiling where adjacent hexes map to adjacent UV tiles.
388
617
  if (workerDebug.clusterHexLattice) {
389
618
  try {
390
- let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
619
+ let minX = Infinity,
620
+ maxX = -Infinity,
621
+ minY = Infinity,
622
+ maxY = -Infinity;
391
623
  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]
624
+ const p = positions[idx];
625
+ if (!p) continue;
626
+ if (p[0] < minX) minX = p[0];
627
+ if (p[0] > maxX) maxX = p[0];
628
+ if (p[1] < minY) minY = p[1];
629
+ if (p[1] > maxY) maxY = p[1];
395
630
  }
396
- const clusterWidth = Math.max(0, maxX - minX)
397
- const clusterHeight = Math.max(0, maxY - minY)
631
+ const clusterWidth = Math.max(0, maxX - minX);
632
+ const clusterHeight = Math.max(0, maxY - minY);
398
633
 
399
634
  // Infer spacings from hexRadius (flat-top hex layout):
400
- const horizSpacing = Math.sqrt(3) * hexRadius
401
- const vertSpacing = 1.5 * hexRadius
635
+ const horizSpacing = Math.sqrt(3) * hexRadius;
636
+ const vertSpacing = 1.5 * hexRadius;
402
637
 
403
638
  // 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
639
+ const latticeCoords = new Map<
640
+ number,
641
+ { row: number; col: number }
642
+ >();
643
+ let minRow = Infinity,
644
+ maxRow = -Infinity,
645
+ minCol = Infinity,
646
+ maxCol = -Infinity;
406
647
  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)
648
+ const p = positions[id];
649
+ if (!p) continue;
650
+ const rowF = (p[1] - minY) / vertSpacing;
651
+ const row = Math.round(rowF);
410
652
  // 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
653
+ const rowOffset = row % 2 === 1 ? horizSpacing * 0.5 : 0;
654
+ const colF = (p[0] - (minX + rowOffset)) / horizSpacing;
655
+ const col = Math.round(colF);
656
+ latticeCoords.set(id, { row, col });
657
+ if (row < minRow) minRow = row;
658
+ if (row > maxRow) maxRow = row;
659
+ if (col < minCol) minCol = col;
660
+ if (col > maxCol) maxCol = col;
417
661
  }
418
662
 
419
- const latticeRows = maxRow - minRow + 1
420
- const latticeCols = maxCol - minCol + 1
663
+ const latticeRows = maxRow - minRow + 1;
664
+ const latticeCols = maxCol - minCol + 1;
421
665
 
422
666
  // Initial tile grid matches lattice extents.
423
- let tilesX = latticeCols
424
- let tilesY = latticeRows
667
+ let tilesX = latticeCols;
668
+ let tilesY = latticeRows;
425
669
 
426
670
  // If we have more hexes than lattice cells due to rounding collisions, expand.
427
- const rawTileCount = tilesX * tilesY
671
+ const rawTileCount = tilesX * tilesY;
428
672
  if (cluster.length > rawTileCount) {
429
673
  // 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++
674
+ const MAX_TILES =
675
+ typeof workerDebug.clusterMaxTiles === 'number' &&
676
+ workerDebug.clusterMaxTiles > 0
677
+ ? Math.floor(workerDebug.clusterMaxTiles)
678
+ : 128;
679
+ while (
680
+ tilesX * tilesY < cluster.length &&
681
+ tilesX * tilesY < MAX_TILES
682
+ ) {
683
+ if (tilesX <= tilesY) tilesX++;
684
+ else tilesY++;
433
685
  }
434
686
  }
435
687
 
436
- console.log('[assignClusterGridPositions][hex-lattice] cluster', photoId.substring(0,8), 'size', cluster.length,
437
- 'latticeCols', latticeCols, 'latticeRows', latticeRows, 'tilesX', tilesX, 'tilesY', tilesY)
688
+ console.log(
689
+ '[assignClusterGridPositions][hex-lattice] cluster',
690
+ photoId.substring(0, 8),
691
+ 'size',
692
+ cluster.length,
693
+ 'latticeCols',
694
+ latticeCols,
695
+ 'latticeRows',
696
+ latticeRows,
697
+ 'tilesX',
698
+ tilesX,
699
+ 'tilesY',
700
+ tilesY
701
+ );
438
702
 
439
703
  // Build optional serpentine ordering for assignment uniqueness (not strictly needed since lattice mapping is direct)
440
- const serpentine = (workerDebug.clusterScanMode === 'serpentine')
704
+ const serpentine = workerDebug.clusterScanMode === 'serpentine';
441
705
 
442
706
  // Assign each infection a gridPosition derived from lattice coordinates compressed into tile grid domain.
443
707
  // Enhancement: compact gaps in each row for more contiguous image mapping
444
- const compactGaps = workerDebug.clusterCompactGaps !== false
445
-
708
+ const compactGaps = workerDebug.clusterCompactGaps !== false;
709
+
446
710
  // Build row-by-row column mapping to handle gaps
447
- const rowColMap = new Map<number, Map<number, number>>() // row -> (oldCol -> newCol)
711
+ const rowColMap = new Map<number, Map<number, number>>(); // row -> (oldCol -> newCol)
448
712
  if (compactGaps) {
449
713
  for (let row = minRow; row <= maxRow; row++) {
450
714
  const colsInRow = Array.from(latticeCoords.entries())
451
715
  .filter(([_, lc]) => lc.row === row)
452
716
  .map(([_, lc]) => lc.col)
453
- .sort((a, b) => a - b)
454
-
455
- const colMap = new Map<number, number>()
717
+ .sort((a, b) => a - b);
718
+
719
+ const colMap = new Map<number, number>();
456
720
  colsInRow.forEach((oldCol, newIdx) => {
457
- colMap.set(oldCol, newIdx)
458
- })
459
- rowColMap.set(row, colMap)
721
+ colMap.set(oldCol, newIdx);
722
+ });
723
+ rowColMap.set(row, colMap);
460
724
  }
461
725
  }
462
-
726
+
463
727
  // 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
-
728
+ const tileOccupancy = new Map<string, number>(); // "col,row" -> nodeId
729
+ const tileKey = (c: number, r: number) => `${c},${r}`;
730
+
467
731
  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
732
+ const inf = infections.get(id);
733
+ if (!inf) continue;
734
+ const lc = latticeCoords.get(id);
735
+ if (!lc) continue;
736
+
737
+ let gridCol =
738
+ compactGaps && rowColMap.has(lc.row)
739
+ ? rowColMap.get(lc.row)!.get(lc.col) ?? lc.col - minCol
740
+ : lc.col - minCol;
741
+ let gridRow = lc.row - minRow;
742
+
743
+ if (serpentine && gridRow % 2 === 1) {
744
+ gridCol = tilesX - 1 - gridCol;
478
745
  }
479
-
746
+
480
747
  // 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
748
+ if (gridCol < 0) gridCol = 0;
749
+ if (gridCol >= tilesX) gridCol = tilesX - 1;
750
+ if (gridRow < 0) gridRow = 0;
751
+ if (gridRow >= tilesY) gridRow = tilesY - 1;
483
752
 
484
753
  // Collision resolution: if tile is occupied, find nearest free tile
485
- const key = tileKey(gridCol, gridRow)
754
+ const key = tileKey(gridCol, gridRow);
486
755
  if (tileOccupancy.has(key)) {
487
- const nodePos = positions[id]
488
- let bestCol = gridCol, bestRow = gridRow
489
- let bestDist = Infinity
490
-
756
+ const nodePos = positions[id];
757
+ let bestCol = gridCol,
758
+ bestRow = gridRow;
759
+ let bestDist = Infinity;
760
+
491
761
  // Search in expanding radius for free tile
492
- for (let radius = 1; radius <= Math.max(tilesX, tilesY); radius++) {
493
- let found = false
762
+ for (
763
+ let radius = 1;
764
+ radius <= Math.max(tilesX, tilesY);
765
+ radius++
766
+ ) {
767
+ let found = false;
494
768
  for (let dc = -radius; dc <= radius; dc++) {
495
769
  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)
770
+ if (Math.abs(dc) !== radius && Math.abs(dr) !== radius)
771
+ continue; // Only check perimeter
772
+ const testCol = gridCol + dc;
773
+ const testRow = gridRow + dr;
774
+ if (
775
+ testCol < 0 ||
776
+ testCol >= tilesX ||
777
+ testRow < 0 ||
778
+ testRow >= tilesY
779
+ )
780
+ continue;
781
+ const testKey = tileKey(testCol, testRow);
501
782
  if (!tileOccupancy.has(testKey)) {
502
783
  // 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)
784
+ const tileU = (testCol + 0.5) / tilesX;
785
+ const tileV = (testRow + 0.5) / tilesY;
786
+ const tileCenterX = minX + tileU * clusterWidth;
787
+ const tileCenterY = minY + tileV * clusterHeight;
788
+ const dist = Math.hypot(
789
+ nodePos[0] - tileCenterX,
790
+ nodePos[1] - tileCenterY
791
+ );
508
792
  if (dist < bestDist) {
509
- bestDist = dist
510
- bestCol = testCol
511
- bestRow = testRow
512
- found = true
793
+ bestDist = dist;
794
+ bestCol = testCol;
795
+ bestRow = testRow;
796
+ found = true;
513
797
  }
514
798
  }
515
799
  }
516
800
  }
517
- if (found) break
801
+ if (found) break;
518
802
  }
519
- gridCol = bestCol
520
- gridRow = bestRow
803
+ gridCol = bestCol;
804
+ gridRow = bestRow;
521
805
  }
522
-
523
- tileOccupancy.set(tileKey(gridCol, gridRow), id)
806
+
807
+ tileOccupancy.set(tileKey(gridCol, gridRow), id);
524
808
 
525
809
  // Optionally support vertical anchor flip
526
810
  if (workerDebug.clusterAnchor === 'max') {
527
- gridRow = Math.max(0, tilesY - 1 - gridRow)
811
+ gridRow = Math.max(0, tilesY - 1 - gridRow);
528
812
  }
529
813
 
530
- let uvBounds = calculateUvBoundsFromGridPosition(gridCol, gridRow, tilesX, tilesY)
531
- const inset = Math.max(0, Math.min(0.49, Number(workerDebug.clusterUvInset) || 0))
814
+ let uvBounds = calculateUvBoundsFromGridPosition(
815
+ gridCol,
816
+ gridRow,
817
+ tilesX,
818
+ tilesY
819
+ );
820
+ const inset = Math.max(
821
+ 0,
822
+ Math.min(0.49, Number(workerDebug.clusterUvInset) || 0)
823
+ );
532
824
  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]
825
+ const u0 = uvBounds[0],
826
+ v0 = uvBounds[1],
827
+ u1 = uvBounds[2],
828
+ v1 = uvBounds[3];
829
+ const du = (u1 - u0) * inset;
830
+ const dv = (v1 - v0) * inset;
831
+ uvBounds = [u0 + du, v0 + dv, u1 - du, v1 - dv];
537
832
  }
538
833
  // Optional parity UV shift: shift odd rows horizontally by half a tile width in UV space.
539
834
  // Enhanced: use precise hex geometry for sub-pixel accuracy
540
- if (workerDebug.clusterParityUvShift && (gridRow % 2 === 1)) {
835
+ if (workerDebug.clusterParityUvShift && gridRow % 2 === 1) {
541
836
  // 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
837
+ const hexRowParity = lc.row % 2;
838
+ const shift = hexRowParity === 1 ? 0.5 / tilesX : 0;
839
+ let u0 = uvBounds[0] + shift;
840
+ let u1 = uvBounds[2] + shift;
546
841
  // Wrap within [0,1]
547
- if (u0 >= 1) u0 -= 1
548
- if (u1 > 1) u1 -= 1
842
+ if (u0 >= 1) u0 -= 1;
843
+ if (u1 > 1) u1 -= 1;
549
844
  // Guard against pathological wrapping inversion (should not occur with shift<tileWidth)
550
845
  if (u1 < u0) {
551
846
  // 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))
847
+ u0 = Math.min(u0, 1 - 1 / tilesX);
848
+ u1 = Math.min(1, u0 + 1 / tilesX);
554
849
  }
555
- uvBounds = [u0, uvBounds[1], u1, uvBounds[3]]
850
+ uvBounds = [u0, uvBounds[1], u1, uvBounds[3]];
556
851
  }
557
- infections.set(id, { ...inf, gridPosition: [gridCol, gridRow], uvBounds, tilesX, tilesY })
852
+ infections.set(id, {
853
+ ...inf,
854
+ gridPosition: [gridCol, gridRow],
855
+ uvBounds,
856
+ tilesX,
857
+ tilesY,
858
+ });
558
859
  }
559
860
 
560
861
  // Advance cluster index and continue to next cluster (skip legacy logic)
561
- clusterIndex++
562
- continue
862
+ clusterIndex++;
863
+ continue;
563
864
  } catch (e) {
564
- console.warn('[assignClusterGridPositions][hex-lattice] failed, falling back to legacy path:', e)
865
+ console.warn(
866
+ '[assignClusterGridPositions][hex-lattice] failed, falling back to legacy path:',
867
+ e
868
+ );
565
869
  // fall through to existing (spatial) logic
566
870
  }
567
871
  }
568
872
 
569
873
  // Find the bounding box of this cluster in grid space first
570
- let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
874
+ let minX = Infinity,
875
+ maxX = -Infinity,
876
+ minY = Infinity,
877
+ maxY = -Infinity;
571
878
  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])
879
+ const pos = positions[idx];
880
+ if (!pos) continue;
881
+ minX = Math.min(minX, pos[0]);
882
+ maxX = Math.max(maxX, pos[0]);
883
+ minY = Math.min(minY, pos[1]);
884
+ maxY = Math.max(maxY, pos[1]);
578
885
  }
579
886
 
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,
887
+ const clusterWidth = Math.max(0, maxX - minX);
888
+ const clusterHeight = Math.max(0, maxY - minY);
889
+
890
+ console.log('[assignClusterGridPositions] Cluster bounds:', {
891
+ photoId: photoId.substring(0, 8),
892
+ clusterIndex,
586
893
  hexCount: cluster.length,
587
- minX: minX.toFixed(2),
588
- maxX: maxX.toFixed(2),
589
- minY: minY.toFixed(2),
894
+ minX: minX.toFixed(2),
895
+ maxX: maxX.toFixed(2),
896
+ minY: minY.toFixed(2),
590
897
  maxY: maxY.toFixed(2),
591
- width: clusterWidth.toFixed(2),
592
- height: clusterHeight.toFixed(2)
593
- })
594
-
898
+ width: clusterWidth.toFixed(2),
899
+ height: clusterHeight.toFixed(2),
900
+ });
901
+
595
902
  // Calculate optimal tilesX and tilesY based on cluster aspect ratio
596
903
  // 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
-
904
+ const clusterAspect =
905
+ clusterHeight > 0 ? clusterWidth / clusterHeight : 1.0;
906
+ const targetTileCount = 16; // Target ~16 tiles total for good image distribution
907
+
908
+ console.log(
909
+ '[assignClusterGridPositions] Cluster aspect:',
910
+ clusterAspect.toFixed(3),
911
+ '(width/height)'
912
+ );
913
+
914
+ let tilesX: number;
915
+ let tilesY: number;
916
+
605
917
  if (cluster.length === 1) {
606
918
  // Single hexagon: use 1x1
607
- tilesX = 1
608
- tilesY = 1
919
+ tilesX = 1;
920
+ tilesY = 1;
609
921
  } else if (workerDebug.clusterDynamicTiling !== false) {
610
922
  // Dynamic tiling: match cluster aspect ratio
611
923
  // sqrt(tilesX * tilesY) = sqrt(targetTileCount)
@@ -613,16 +925,19 @@ function assignClusterGridPositions(
613
925
  // => tilesX = clusterAspect * tilesY
614
926
  // => clusterAspect * tilesY * tilesY = targetTileCount
615
927
  // => 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
-
928
+ tilesY = Math.max(
929
+ 1,
930
+ Math.round(Math.sqrt(targetTileCount / clusterAspect))
931
+ );
932
+ tilesX = Math.max(1, Math.round(clusterAspect * tilesY));
933
+
619
934
  // Clamp to reasonable range
620
- tilesX = Math.max(1, Math.min(8, tilesX))
621
- tilesY = Math.max(1, Math.min(8, tilesY))
935
+ tilesX = Math.max(1, Math.min(8, tilesX));
936
+ tilesY = Math.max(1, Math.min(8, tilesY));
622
937
  } else {
623
938
  // Fallback to fixed tiling from infection config
624
- tilesX = Math.max(1, firstInf.tilesX || 4)
625
- tilesY = Math.max(1, firstInf.tilesY || 4)
939
+ tilesX = Math.max(1, firstInf.tilesX || 4);
940
+ tilesY = Math.max(1, firstInf.tilesY || 4);
626
941
  }
627
942
 
628
943
  // If the cluster contains more hexes than tiles, expand the tile grid
@@ -630,105 +945,146 @@ function assignClusterGridPositions(
630
945
  // image patches). Preserve the tile aspect ratio but scale up the total
631
946
  // tile count to be at least cluster.length, clamped to a safe maximum.
632
947
  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)
948
+ const currentTileCount = tilesX * tilesY;
949
+ const requiredTiles = Math.max(currentTileCount, cluster.length);
950
+ const MAX_TILES =
951
+ typeof workerDebug.clusterMaxTiles === 'number' &&
952
+ workerDebug.clusterMaxTiles > 0
953
+ ? Math.max(1, Math.floor(workerDebug.clusterMaxTiles))
954
+ : 64;
955
+ const targetTiles = Math.min(requiredTiles, MAX_TILES);
637
956
 
638
957
  if (targetTiles > currentTileCount) {
639
958
  // preserve aspect ratio roughly: ratio = tilesX / tilesY
640
- const ratio = tilesX / Math.max(1, tilesY)
959
+ const ratio = tilesX / Math.max(1, tilesY);
641
960
  // 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))
961
+ let newTilesY = Math.max(
962
+ 1,
963
+ Math.round(Math.sqrt(targetTiles / Math.max(1e-9, ratio)))
964
+ );
965
+ let newTilesX = Math.max(1, Math.round(ratio * newTilesY));
644
966
  // if rounding produced fewer tiles than needed, bump progressively
645
967
  while (newTilesX * newTilesY < targetTiles) {
646
- if (newTilesX <= newTilesY) newTilesX++
647
- else newTilesY++
648
- if (newTilesX * newTilesY >= MAX_TILES) break
968
+ if (newTilesX <= newTilesY) newTilesX++;
969
+ else newTilesY++;
970
+ if (newTilesX * newTilesY >= MAX_TILES) break;
649
971
  }
650
972
  // 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')
973
+ newTilesX = Math.max(1, Math.min(16, newTilesX));
974
+ newTilesY = Math.max(1, Math.min(16, newTilesY));
975
+ tilesX = newTilesX;
976
+ tilesY = newTilesY;
977
+ console.log(
978
+ '[assignClusterGridPositions] Expanded tile grid to',
979
+ tilesX,
980
+ 'x',
981
+ tilesY,
982
+ '=',
983
+ tilesX * tilesY,
984
+ 'tiles'
985
+ );
656
986
  }
657
987
  } catch (e) {
658
988
  // if anything goes wrong, keep original tilesX/tilesY
659
989
  }
660
990
 
661
- console.log('[assignClusterGridPositions] Final tile dimensions:', tilesX, 'x', tilesY, '=', tilesX * tilesY, 'tiles for', cluster.length, 'hexes')
991
+ console.log(
992
+ '[assignClusterGridPositions] Final tile dimensions:',
993
+ tilesX,
994
+ 'x',
995
+ tilesY,
996
+ '=',
997
+ tilesX * tilesY,
998
+ 'tiles for',
999
+ cluster.length,
1000
+ 'hexes'
1001
+ );
662
1002
 
663
1003
  // 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
1004
+ if (
1005
+ cluster.length === 1 ||
1006
+ clusterWidth < 1e-6 ||
1007
+ clusterHeight < 1e-6
1008
+ ) {
1009
+ const idx = cluster[0];
1010
+ const inf = infections.get(idx);
1011
+ if (!inf) continue;
668
1012
  // 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)
1013
+ const h = (idx * 2654435761) >>> 0;
1014
+ const gridCol = h % tilesX;
1015
+ let gridRow = (h >>> 8) % tilesY;
672
1016
  // If configured, allow anchoring to the bottom of the image (flip vertical tile index)
673
1017
  if (workerDebug.clusterAnchor === 'max') {
674
- gridRow = Math.max(0, tilesY - 1 - gridRow)
1018
+ gridRow = Math.max(0, tilesY - 1 - gridRow);
675
1019
  }
676
- const uvBounds = calculateUvBoundsFromGridPosition(gridCol, gridRow, tilesX, tilesY)
677
- infections.set(idx, { ...inf, gridPosition: [gridCol, gridRow], uvBounds })
678
- continue
1020
+ const uvBounds = calculateUvBoundsFromGridPosition(
1021
+ gridCol,
1022
+ gridRow,
1023
+ tilesX,
1024
+ tilesY
1025
+ );
1026
+ infections.set(idx, {
1027
+ ...inf,
1028
+ gridPosition: [gridCol, gridRow],
1029
+ uvBounds,
1030
+ });
1031
+ continue;
679
1032
  }
680
1033
 
681
1034
  // 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
1035
+ const preserveAspect = !!workerDebug.clusterPreserveAspect;
1036
+ let normMinX = minX,
1037
+ normMinY = minY,
1038
+ normWidth = clusterWidth,
1039
+ normHeight = clusterHeight;
684
1040
 
685
1041
  if (preserveAspect) {
686
- const clusterAspect = clusterWidth / clusterHeight
687
- const tileAspect = tilesX / tilesY
688
- const fillMode = workerDebug.clusterFillMode || 'contain'
1042
+ const clusterAspect = clusterWidth / clusterHeight;
1043
+ const tileAspect = tilesX / tilesY;
1044
+ const fillMode = workerDebug.clusterFillMode || 'contain';
689
1045
  if (fillMode === 'contain') {
690
1046
  // current behavior: pad shorter dimension so the whole image fits (no cropping)
691
1047
  if (clusterAspect > tileAspect) {
692
- const effectiveHeight = clusterWidth / tileAspect
693
- const pad = effectiveHeight - clusterHeight
1048
+ const effectiveHeight = clusterWidth / tileAspect;
1049
+ const pad = effectiveHeight - clusterHeight;
694
1050
  if (workerDebug.clusterAnchor === 'min') {
695
- normMinY = minY
1051
+ normMinY = minY;
696
1052
  } else {
697
- normMinY = minY - pad / 2
1053
+ normMinY = minY - pad / 2;
698
1054
  }
699
- normHeight = effectiveHeight
1055
+ normHeight = effectiveHeight;
700
1056
  } else if (clusterAspect < tileAspect) {
701
- const effectiveWidth = clusterHeight * tileAspect
702
- const pad = effectiveWidth - clusterWidth
1057
+ const effectiveWidth = clusterHeight * tileAspect;
1058
+ const pad = effectiveWidth - clusterWidth;
703
1059
  if (workerDebug.clusterAnchor === 'min') {
704
- normMinX = minX
1060
+ normMinX = minX;
705
1061
  } else {
706
- normMinX = minX - pad / 2
1062
+ normMinX = minX - pad / 2;
707
1063
  }
708
- normWidth = effectiveWidth
1064
+ normWidth = effectiveWidth;
709
1065
  }
710
1066
  } else {
711
1067
  // 'cover' mode: scale so tile grid fully covers cluster bounds, allowing cropping
712
1068
  if (clusterAspect > tileAspect) {
713
1069
  // cluster is wider than tile grid: scale width down (crop left/right)
714
- const effectiveWidth = clusterHeight * tileAspect
715
- const crop = clusterWidth - effectiveWidth
1070
+ const effectiveWidth = clusterHeight * tileAspect;
1071
+ const crop = clusterWidth - effectiveWidth;
716
1072
  if (workerDebug.clusterAnchor === 'min') {
717
- normMinX = minX + crop // crop from right
1073
+ normMinX = minX + crop; // crop from right
718
1074
  } else {
719
- normMinX = minX + crop / 2
1075
+ normMinX = minX + crop / 2;
720
1076
  }
721
- normWidth = effectiveWidth
1077
+ normWidth = effectiveWidth;
722
1078
  } else if (clusterAspect < tileAspect) {
723
1079
  // cluster is taller than tile grid: scale height down (crop top/bottom)
724
- const effectiveHeight = clusterWidth / tileAspect
725
- const crop = clusterHeight - effectiveHeight
1080
+ const effectiveHeight = clusterWidth / tileAspect;
1081
+ const crop = clusterHeight - effectiveHeight;
726
1082
  if (workerDebug.clusterAnchor === 'min') {
727
- normMinY = minY + crop
1083
+ normMinY = minY + crop;
728
1084
  } else {
729
- normMinY = minY + crop / 2
1085
+ normMinY = minY + crop / 2;
730
1086
  }
731
- normHeight = effectiveHeight
1087
+ normHeight = effectiveHeight;
732
1088
  }
733
1089
  }
734
1090
  }
@@ -738,152 +1094,184 @@ function assignClusterGridPositions(
738
1094
  // This produces contiguous tiling for clusters and avoids many hexes
739
1095
  // quantizing into the same UV tile.
740
1096
  try {
741
- const clusterSet = new Set(cluster)
1097
+ const clusterSet = new Set(cluster);
742
1098
 
743
1099
  // Helper: tile bounds check
744
- const inTileBounds = (c: number, r: number) => c >= 0 && c < tilesX && r >= 0 && r < tilesY
1100
+ const inTileBounds = (c: number, r: number) =>
1101
+ c >= 0 && c < tilesX && r >= 0 && r < tilesY;
745
1102
 
746
1103
  // Tile occupancy map key
747
- const tileKey = (c: number, r: number) => `${c},${r}`
1104
+ const tileKey = (c: number, r: number) => `${c},${r}`;
748
1105
 
749
1106
  // Pre-allocate occupancy map and assignment map
750
- const occupied = new Map<string, boolean>()
751
- const assignment = new Map<number, [number, number]>()
1107
+ const occupied = new Map<string, boolean>();
1108
+ const assignment = new Map<number, [number, number]>();
752
1109
 
753
1110
  // 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
1111
+ let cx = 0,
1112
+ cy = 0;
1113
+ for (const id of cluster) {
1114
+ const p = positions[id];
1115
+ cx += p[0];
1116
+ cy += p[1];
1117
+ }
1118
+ cx /= cluster.length;
1119
+ cy /= cluster.length;
1120
+ let originIndex = cluster[0];
1121
+ let bestD = Infinity;
759
1122
  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 }
1123
+ const p = positions[id];
1124
+ const d = Math.hypot(p[0] - cx, p[1] - cy);
1125
+ if (d < bestD) {
1126
+ bestD = d;
1127
+ originIndex = id;
1128
+ }
763
1129
  }
764
1130
 
765
1131
  // 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)
1132
+ const startCol = Math.floor(tilesX / 2);
1133
+ const startRow = Math.floor(tilesY / 2);
768
1134
 
769
1135
  // 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
1136
+ const MIN_NORM = 1e-6;
1137
+ if (normWidth < MIN_NORM) normWidth = MIN_NORM;
1138
+ if (normHeight < MIN_NORM) normHeight = MIN_NORM;
773
1139
 
774
1140
  // Build tile list in row-major or serpentine order depending on config
775
- const tiles: [number, number][] = []
776
- const scanMode = (workerDebug.clusterScanMode || 'row')
1141
+ const tiles: [number, number][] = [];
1142
+ const scanMode = workerDebug.clusterScanMode || 'row';
777
1143
  for (let r = 0; r < tilesY; r++) {
778
- if (scanMode === 'serpentine' && (r % 2 === 1)) {
1144
+ if (scanMode === 'serpentine' && r % 2 === 1) {
779
1145
  // right-to-left on odd rows for serpentine
780
- for (let c = tilesX - 1; c >= 0; c--) tiles.push([c, r])
1146
+ for (let c = tilesX - 1; c >= 0; c--) tiles.push([c, r]);
781
1147
  } else {
782
- for (let c = 0; c < tilesX; c++) tiles.push([c, r])
1148
+ for (let c = 0; c < tilesX; c++) tiles.push([c, r]);
783
1149
  }
784
1150
  }
785
1151
 
786
1152
  // Helper: compute tile center in cluster-space
787
- const parityAware = !!workerDebug.clusterParityAware
1153
+ const parityAware = !!workerDebug.clusterParityAware;
788
1154
 
789
1155
  // compute physical horizontal offset for hex parity from cluster geometry
790
- const hexSpacingFactor = Number(workerDebug.hexSpacing) || 1
1156
+ const hexSpacingFactor = Number(workerDebug.hexSpacing) || 1;
791
1157
  // initial fallback spacing based on configured hexRadius
792
- let realHorizSpacing = Math.sqrt(3) * hexRadius * hexSpacingFactor
1158
+ let realHorizSpacing = Math.sqrt(3) * hexRadius * hexSpacingFactor;
793
1159
 
794
1160
  // Try to infer horizontal spacing from actual node positions in the cluster.
795
1161
  // Group nodes into approximate rows and measure adjacent x-deltas.
796
1162
  try {
797
- const rowBuckets = new Map<number, number[]>()
1163
+ const rowBuckets = new Map<number, number[]>();
798
1164
  for (const id of cluster) {
799
- const p = positions[id]
800
- if (!p) continue
1165
+ const p = positions[id];
1166
+ if (!p) continue;
801
1167
  // 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)
1168
+ const ratio = (p[1] - normMinY) / Math.max(1e-9, normHeight);
1169
+ let r = Math.floor(ratio * tilesY);
1170
+ r = Math.max(0, Math.min(tilesY - 1, r));
1171
+ const arr = rowBuckets.get(r) || [];
1172
+ arr.push(p[0]);
1173
+ rowBuckets.set(r, arr);
808
1174
  }
809
- const diffs: number[] = []
1175
+ const diffs: number[] = [];
810
1176
  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])
1177
+ if (!xs || xs.length < 2) continue;
1178
+ xs.sort((a, b) => a - b);
1179
+ for (let i = 1; i < xs.length; i++) diffs.push(xs[i] - xs[i - 1]);
814
1180
  }
815
1181
  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
1182
+ diffs.sort((a, b) => a - b);
1183
+ const mid = Math.floor(diffs.length / 2);
1184
+ realHorizSpacing =
1185
+ diffs.length % 2 === 1
1186
+ ? diffs[mid]
1187
+ : (diffs[mid - 1] + diffs[mid]) / 2;
1188
+ if (!isFinite(realHorizSpacing) || realHorizSpacing <= 0)
1189
+ realHorizSpacing = Math.sqrt(3) * hexRadius * hexSpacingFactor;
820
1190
  }
821
1191
  } catch (e) {
822
1192
  // fallback to default computed spacing
823
- realHorizSpacing = Math.sqrt(3) * hexRadius * hexSpacingFactor
1193
+ realHorizSpacing = Math.sqrt(3) * hexRadius * hexSpacingFactor;
824
1194
  }
825
1195
 
826
1196
  // tile center calculation: simple regular grid, no parity offset
827
1197
  // The hex positions already have natural staggering, so tile centers should be regular
828
1198
  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
- })
1199
+ const u = (col + 0.5) / tilesX;
1200
+ const v = (row + 0.5) / tilesY;
1201
+ const x = normMinX + u * normWidth;
1202
+ const y = normMinY + v * normHeight;
1203
+ return [x, y];
1204
+ };
1205
+
1206
+ console.log(
1207
+ '[assignClusterGridPositions] Normalized bounds for tiling:',
1208
+ {
1209
+ normMinX: normMinX.toFixed(2),
1210
+ normMinY: normMinY.toFixed(2),
1211
+ normWidth: normWidth.toFixed(2),
1212
+ normHeight: normHeight.toFixed(2),
1213
+ preserveAspect,
1214
+ fillMode: workerDebug.clusterFillMode,
1215
+ }
1216
+ );
844
1217
 
845
1218
  // SPATIAL assignment: each hex gets the tile whose center is spatially nearest
846
1219
  // This guarantees perfect alignment between hex positions and tile centers
847
1220
  // 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
- }
1221
+ const centers: { t: [number, number]; x: number; y: number }[] = [];
1222
+ for (let r = 0; r < tilesY; r++)
1223
+ for (let c = 0; c < tilesX; c++) {
1224
+ const [x, y] = tileCenter(c, r);
1225
+ centers.push({ t: [c, r], x, y });
1226
+ }
853
1227
 
854
1228
  // Optionally collect centers for debug visualization
855
1229
  if (workerDebug.showTileCenters) {
856
1230
  debugCenters.push({
857
1231
  photoId,
858
1232
  clusterIndex,
859
- centers: centers.map(c => ({ x: c.x, y: c.y, col: c.t[0], row: c.t[1] }))
860
- })
1233
+ centers: centers.map((c) => ({
1234
+ x: c.x,
1235
+ y: c.y,
1236
+ col: c.t[0],
1237
+ row: c.t[1],
1238
+ })),
1239
+ });
861
1240
  }
862
1241
 
863
1242
  // Assign each hex to its nearest tile center (purely spatial)
864
1243
  // 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
-
1244
+ const assignmentSamples: Array<{
1245
+ nodeId: number;
1246
+ nodeX: number;
1247
+ nodeY: number;
1248
+ tileCol: number;
1249
+ tileRow: number;
1250
+ centerX: number;
1251
+ centerY: number;
1252
+ dist: number;
1253
+ }> = [];
1254
+
867
1255
  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
-
1256
+ const nodePos = positions[nodeId];
1257
+ if (!nodePos) continue;
1258
+
1259
+ let nearestTile: [number, number] = centers[0].t;
1260
+ let nearestDist = Infinity;
1261
+ let nearestCenter: { x: number; y: number } = centers[0];
1262
+
875
1263
  for (const c of centers) {
876
- const dist = Math.hypot(nodePos[0] - c.x, nodePos[1] - c.y)
1264
+ const dist = Math.hypot(nodePos[0] - c.x, nodePos[1] - c.y);
877
1265
  if (dist < nearestDist) {
878
- nearestDist = dist
879
- nearestTile = c.t
880
- nearestCenter = c
1266
+ nearestDist = dist;
1267
+ nearestTile = c.t;
1268
+ nearestCenter = c;
881
1269
  }
882
1270
  }
883
-
884
- assignment.set(nodeId, nearestTile)
885
- occupied.set(tileKey(nearestTile[0], nearestTile[1]), true)
886
-
1271
+
1272
+ assignment.set(nodeId, nearestTile);
1273
+ occupied.set(tileKey(nearestTile[0], nearestTile[1]), true);
1274
+
887
1275
  // Sample first few for debugging
888
1276
  if (assignmentSamples.length < 5) {
889
1277
  assignmentSamples.push({
@@ -894,339 +1282,569 @@ function assignClusterGridPositions(
894
1282
  tileRow: nearestTile[1],
895
1283
  centerX: nearestCenter.x,
896
1284
  centerY: nearestCenter.y,
897
- dist: nearestDist
898
- })
1285
+ dist: nearestDist,
1286
+ });
899
1287
  }
900
1288
  }
901
1289
 
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 '))
1290
+ console.log(
1291
+ '[assignClusterGridPositions] Spatially assigned',
1292
+ cluster.length,
1293
+ 'hexes to nearest tile centers'
1294
+ );
1295
+ console.log(
1296
+ '[assignClusterGridPositions] Sample assignments:',
1297
+ assignmentSamples
1298
+ .map(
1299
+ (s) =>
1300
+ `node#${s.nodeId} at (${s.nodeX.toFixed(1)},${s.nodeY.toFixed(
1301
+ 1
1302
+ )}) → tile[${s.tileCol},${
1303
+ s.tileRow
1304
+ }] center(${s.centerX.toFixed(1)},${s.centerY.toFixed(
1305
+ 1
1306
+ )}) dist=${s.dist.toFixed(1)}`
1307
+ )
1308
+ .join('\n ')
1309
+ );
906
1310
 
907
1311
  // Optional: Neighborhood-aware refinement to reduce visual seams
908
1312
  // For each hex, check if its neighbors suggest a better tile assignment for visual continuity
909
1313
  if (workerDebug.clusterNeighborAware !== false) {
910
- const maxIterations = 3 // Multiple passes to propagate improvements
1314
+ const maxIterations = 3; // Multiple passes to propagate improvements
911
1315
  for (let iter = 0; iter < maxIterations; iter++) {
912
- let adjustments = 0
1316
+ let adjustments = 0;
913
1317
  for (const nodeId of cluster) {
914
- const currentTile = assignment.get(nodeId)
915
- if (!currentTile) continue
916
-
1318
+ const currentTile = assignment.get(nodeId);
1319
+ if (!currentTile) continue;
1320
+
917
1321
  // 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
-
1322
+ const neighbors = getNeighborsCached(
1323
+ nodeId,
1324
+ positions,
1325
+ hexRadius
1326
+ );
1327
+ const clusterNeighbors = neighbors.filter(
1328
+ (n) => clusterSet.has(n) && assignment.has(n)
1329
+ );
1330
+ if (clusterNeighbors.length === 0) continue;
1331
+
922
1332
  // Collect neighbor tiles and compute centroid
923
- const neighborTiles: Array<[number, number]> = []
1333
+ const neighborTiles: Array<[number, number]> = [];
924
1334
  for (const n of clusterNeighbors) {
925
- const nt = assignment.get(n)
926
- if (nt) neighborTiles.push(nt)
1335
+ const nt = assignment.get(n);
1336
+ if (nt) neighborTiles.push(nt);
927
1337
  }
928
-
929
- if (neighborTiles.length === 0) continue
930
-
1338
+
1339
+ if (neighborTiles.length === 0) continue;
1340
+
931
1341
  // Compute average neighbor tile position
932
- let avgCol = 0, avgRow = 0
1342
+ let avgCol = 0,
1343
+ avgRow = 0;
933
1344
  for (const [c, r] of neighborTiles) {
934
- avgCol += c
935
- avgRow += r
1345
+ avgCol += c;
1346
+ avgRow += r;
936
1347
  }
937
- avgCol /= neighborTiles.length
938
- avgRow /= neighborTiles.length
939
-
1348
+ avgCol /= neighborTiles.length;
1349
+ avgRow /= neighborTiles.length;
1350
+
940
1351
  // 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
-
1352
+ const nodePos = positions[nodeId];
1353
+ if (!nodePos) continue;
1354
+
1355
+ let bestAlternative: [number, number] | null = null;
1356
+ let bestScore = Infinity;
1357
+
947
1358
  // Consider tiles in a local neighborhood around current tile
948
- const searchRadius = 2
1359
+ const searchRadius = 2;
949
1360
  for (let dc = -searchRadius; dc <= searchRadius; dc++) {
950
1361
  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
-
1362
+ const candidateCol = Math.max(
1363
+ 0,
1364
+ Math.min(tilesX - 1, currentTile[0] + dc)
1365
+ );
1366
+ const candidateRow = Math.max(
1367
+ 0,
1368
+ Math.min(tilesY - 1, currentTile[1] + dr)
1369
+ );
1370
+ const candidate: [number, number] = [
1371
+ candidateCol,
1372
+ candidateRow,
1373
+ ];
1374
+
955
1375
  // 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
-
1376
+ const tileDist = Math.hypot(
1377
+ candidateCol - avgCol,
1378
+ candidateRow - avgRow
1379
+ );
1380
+ const [cx, cy] = tileCenter(candidateCol, candidateRow);
1381
+ const spatialDist = Math.hypot(
1382
+ nodePos[0] - cx,
1383
+ nodePos[1] - cy
1384
+ );
1385
+ const score = tileDist * 0.7 + spatialDist * 0.3;
1386
+
961
1387
  if (score < bestScore) {
962
- bestScore = score
963
- bestAlternative = candidate
1388
+ bestScore = score;
1389
+ bestAlternative = candidate;
964
1390
  }
965
1391
  }
966
1392
  }
967
-
1393
+
968
1394
  // 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++
1395
+ if (
1396
+ bestAlternative &&
1397
+ (bestAlternative[0] !== currentTile[0] ||
1398
+ bestAlternative[1] !== currentTile[1])
1399
+ ) {
1400
+ assignment.set(nodeId, bestAlternative);
1401
+ adjustments++;
972
1402
  }
973
1403
  }
974
-
975
- if (adjustments === 0) break // Converged
976
- console.log('[assignClusterGridPositions] Neighbor-aware refinement iteration', iter + 1, ':', adjustments, 'adjustments')
1404
+
1405
+ if (adjustments === 0) break; // Converged
1406
+ console.log(
1407
+ '[assignClusterGridPositions] Neighbor-aware refinement iteration',
1408
+ iter + 1,
1409
+ ':',
1410
+ adjustments,
1411
+ 'adjustments'
1412
+ );
977
1413
  }
978
1414
  }
979
1415
 
980
1416
  // Finally write assignments back into infections with UV bounds/inset
981
- const inset = Math.max(0, Math.min(0.49, Number(workerDebug.clusterUvInset) || 0))
1417
+ const inset = Math.max(
1418
+ 0,
1419
+ Math.min(0.49, Number(workerDebug.clusterUvInset) || 0)
1420
+ );
982
1421
  for (const id of cluster) {
983
- const inf = infections.get(id)
984
- if (!inf) continue
985
- let assignedTile = assignment.get(id) || [0, 0]
1422
+ const inf = infections.get(id);
1423
+ if (!inf) continue;
1424
+ let assignedTile = assignment.get(id) || [0, 0];
986
1425
  // Support bottom anchoring: flip the vertical tile index when 'max' is configured
987
1426
  if (workerDebug.clusterAnchor === 'max') {
988
- assignedTile = [assignedTile[0], Math.max(0, tilesY - 1 - assignedTile[1])]
1427
+ assignedTile = [
1428
+ assignedTile[0],
1429
+ Math.max(0, tilesY - 1 - assignedTile[1]),
1430
+ ];
989
1431
  }
990
- let uvBounds = calculateUvBoundsFromGridPosition(assignedTile[0], assignedTile[1], tilesX, tilesY)
1432
+ let uvBounds = calculateUvBoundsFromGridPosition(
1433
+ assignedTile[0],
1434
+ assignedTile[1],
1435
+ tilesX,
1436
+ tilesY
1437
+ );
991
1438
  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]
1439
+ const u0 = uvBounds[0],
1440
+ v0 = uvBounds[1],
1441
+ u1 = uvBounds[2],
1442
+ v1 = uvBounds[3];
1443
+ const du = (u1 - u0) * inset;
1444
+ const dv = (v1 - v0) * inset;
1445
+ uvBounds = [u0 + du, v0 + dv, u1 - du, v1 - dv];
996
1446
  }
997
- infections.set(id, { ...inf, gridPosition: [assignedTile[0], assignedTile[1]], uvBounds, tilesX, tilesY })
1447
+ infections.set(id, {
1448
+ ...inf,
1449
+ gridPosition: [assignedTile[0], assignedTile[1]],
1450
+ uvBounds,
1451
+ tilesX,
1452
+ tilesY,
1453
+ });
998
1454
  }
999
- console.log('[assignClusterGridPositions] Assigned grid positions to', cluster.length, 'hexes in cluster (BFS)')
1455
+ console.log(
1456
+ '[assignClusterGridPositions] Assigned grid positions to',
1457
+ cluster.length,
1458
+ 'hexes in cluster (BFS)'
1459
+ );
1000
1460
  } catch (e) {
1001
- console.error('[assignClusterGridPositions] BFS assignment failed, falling back to quantization', e)
1461
+ console.error(
1462
+ '[assignClusterGridPositions] BFS assignment failed, falling back to quantization',
1463
+ e
1464
+ );
1002
1465
  // fallback: leave previous behavior (quantization) to avoid breaking
1003
1466
  }
1004
- clusterIndex++
1467
+ clusterIndex++;
1005
1468
  }
1006
1469
  }
1007
-
1470
+
1008
1471
  // Log cluster statistics
1009
1472
  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), '%)')
1473
+ clusterSizes.sort((a, b) => b - a); // descending
1474
+ const avgSize =
1475
+ clusterSizes.reduce((sum, s) => sum + s, 0) / clusterSizes.length;
1476
+ const medianSize = clusterSizes[Math.floor(clusterSizes.length / 2)];
1477
+ const maxSize = clusterSizes[0];
1478
+ const smallClusters = clusterSizes.filter((s) => s <= 3).length;
1479
+ console.log(
1480
+ '[assignClusterGridPositions] CLUSTER STATS: total=',
1481
+ totalClusters,
1482
+ 'avg=',
1483
+ avgSize.toFixed(1),
1484
+ 'median=',
1485
+ medianSize,
1486
+ 'max=',
1487
+ maxSize,
1488
+ 'small(≤3)=',
1489
+ smallClusters,
1490
+ '/',
1491
+ totalClusters,
1492
+ '(',
1493
+ ((100 * smallClusters) / totalClusters).toFixed(0),
1494
+ '%)'
1495
+ );
1016
1496
  }
1017
-
1018
- console.log('[assignClusterGridPositions] Complete')
1497
+
1498
+ console.log('[assignClusterGridPositions] Complete');
1019
1499
  } catch (e) {
1020
- console.error('[assignClusterGridPositions] Error:', e)
1500
+ console.error('[assignClusterGridPositions] Error:', e);
1021
1501
  }
1022
-
1023
- return debugCenters
1502
+
1503
+ return debugCenters;
1024
1504
  }
1025
1505
 
1026
- function postOptimizationMerge(infections: Map<number, Infection>, positions: [number, number, number][], hexRadius: number, debug = false) {
1506
+ function postOptimizationMerge(
1507
+ infections: Map<number, Infection>,
1508
+ positions: [number, number, number][],
1509
+ hexRadius: number,
1510
+ debug = false
1511
+ ) {
1027
1512
  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
1513
+ if (!workerDebug || !workerDebug.enableMerges) {
1514
+ if (debug && workerDebug.mergeLogs) console.log('[merge] disabled');
1515
+ return;
1516
+ }
1517
+ const threshold =
1518
+ typeof workerDebug.mergeSmallComponentsThreshold === 'number'
1519
+ ? workerDebug.mergeSmallComponentsThreshold
1520
+ : 3;
1521
+ const byPhoto = new Map<string, number[]>();
1522
+ for (const [idx, inf] of infections) {
1523
+ const arr = byPhoto.get(inf.photo.id) || [];
1524
+ arr.push(idx);
1525
+ byPhoto.set(inf.photo.id, arr);
1526
+ }
1527
+ let merges = 0;
1033
1528
  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)
1529
+ const comps = findConnectedComponents(inds, positions, hexRadius);
1530
+ const small = comps.filter((c) => c.length > 0 && c.length <= threshold);
1531
+ const big = comps.filter((c) => c.length > threshold);
1532
+ if (small.length === 0 || big.length === 0) continue;
1533
+ const bounds = getGridBounds(positions);
1039
1534
  for (const s of small) {
1040
- let best: number[] | null = null; let bestD = Infinity
1535
+ let best: number[] | null = null;
1536
+ let bestD = Infinity;
1041
1537
  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
1538
+ let sx = 0,
1539
+ sy = 0,
1540
+ bx = 0,
1541
+ by = 0;
1542
+ for (const i of s) {
1543
+ const p = positions[i];
1544
+ if (p) {
1545
+ sx += p[0];
1546
+ sy += p[1];
1547
+ }
1548
+ }
1549
+ for (const i of b) {
1550
+ const p = positions[i];
1551
+ if (p) {
1552
+ bx += p[0];
1553
+ by += p[1];
1554
+ }
1555
+ }
1556
+ const scx = sx / s.length,
1557
+ scy = sy / s.length,
1558
+ bcx = bx / b.length,
1559
+ bcy = by / b.length;
1560
+ const dx = Math.abs(scx - bcx);
1561
+ const dy = Math.abs(scy - bcy);
1562
+ let effDx = dx;
1563
+ let effDy = dy;
1050
1564
  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
1565
+ if (effDx > bounds.width / 2) effDx = bounds.width - effDx;
1566
+ if (effDy > bounds.height / 2) effDy = bounds.height - effDy;
1567
+ }
1568
+ const d = Math.sqrt(effDx * effDx + effDy * effDy);
1569
+ if (d < bestD) {
1570
+ bestD = d;
1571
+ best = b;
1053
1572
  }
1054
- const d = Math.sqrt(effDx * effDx + effDy * effDy)
1055
- if (d < bestD) { bestD = d; best = b }
1056
1573
  }
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)
1574
+ if (!best) continue;
1575
+ const recipientId = infections.get(best[0])?.photo.id;
1576
+ if (!recipientId) continue;
1577
+ const before = calculateContiguity(best, positions, hexRadius);
1578
+ const after = calculateContiguity(
1579
+ [...best, ...s],
1580
+ positions,
1581
+ hexRadius
1582
+ );
1062
1583
  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}`)
1584
+ for (const idx of s) {
1585
+ const inf = infections.get(idx);
1586
+ if (!inf) continue;
1587
+ infections.set(idx, {
1588
+ ...inf,
1589
+ photo: infections.get(best[0])!.photo,
1590
+ });
1591
+ }
1592
+ merges++;
1593
+ if (debug && workerDebug.mergeLogs)
1594
+ console.log(`[merge] moved ${s.length} -> ${recipientId}`);
1066
1595
  }
1067
1596
  }
1068
1597
  }
1069
- } catch (e) { if (debug) console.warn('[merge] failed', e) }
1598
+ } catch (e) {
1599
+ if (debug) console.warn('[merge] failed', e);
1600
+ }
1070
1601
  }
1071
1602
 
1072
- function normalizePrevState(prevState: any) : { infections: Map<number, Infection>, availableIndices: number[], generation?: number } {
1603
+ function normalizePrevState(prevState: any): {
1604
+ infections: Map<number, Infection>;
1605
+ availableIndices: number[];
1606
+ generation?: number;
1607
+ } {
1073
1608
  try {
1074
- if (!prevState) return { infections: new Map<number, Infection>(), availableIndices: [] }
1075
- let infectionsMap: Map<number, Infection>
1609
+ if (!prevState)
1610
+ return { infections: new Map<number, Infection>(), availableIndices: [] };
1611
+ let infectionsMap: Map<number, Infection>;
1076
1612
  if (prevState.infections instanceof Map) {
1077
- infectionsMap = prevState.infections
1613
+ infectionsMap = prevState.infections;
1078
1614
  } 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>() }
1615
+ try {
1616
+ infectionsMap = new Map<number, Infection>(prevState.infections);
1617
+ } catch (e) {
1618
+ infectionsMap = new Map<number, Infection>();
1619
+ }
1620
+ } else if (
1621
+ typeof prevState.infections === 'object' &&
1622
+ prevState.infections !== null &&
1623
+ typeof prevState.infections.entries === 'function'
1624
+ ) {
1625
+ try {
1626
+ infectionsMap = new Map<number, Infection>(
1627
+ Array.from(prevState.infections.entries())
1628
+ );
1629
+ } catch (e) {
1630
+ infectionsMap = new Map<number, Infection>();
1631
+ }
1082
1632
  } else {
1083
- infectionsMap = new Map<number, Infection>()
1633
+ infectionsMap = new Map<number, Infection>();
1084
1634
  }
1085
- const available = Array.isArray(prevState.availableIndices) ? prevState.availableIndices : []
1086
- return { infections: infectionsMap, availableIndices: available, generation: prevState.generation }
1635
+ const available = Array.isArray(prevState.availableIndices)
1636
+ ? prevState.availableIndices
1637
+ : [];
1638
+ return {
1639
+ infections: infectionsMap,
1640
+ availableIndices: available,
1641
+ generation: prevState.generation,
1642
+ };
1087
1643
  } catch (e) {
1088
- safePostError(e)
1089
- return { infections: new Map<number, Infection>(), availableIndices: [] }
1644
+ safePostError(e);
1645
+ return { infections: new Map<number, Infection>(), availableIndices: [] };
1090
1646
  }
1091
1647
  }
1092
1648
 
1093
- function evolveInfectionSystem(prevState: any, positions: [number, number, number][], photos: Photo[], hexRadius: number, currentTime: number, debug = false): InfectionSystemState | null {
1649
+ function evolveInfectionSystem(
1650
+ prevState: any,
1651
+ positions: [number, number, number][],
1652
+ photos: Photo[],
1653
+ hexRadius: number,
1654
+ currentTime: number,
1655
+ debug = false
1656
+ ): InfectionSystemState | null {
1094
1657
  try {
1095
- console.log('[evolve] Step 1: Validating positions...')
1658
+ console.log('[evolve] Step 1: Validating positions...');
1096
1659
  if (!positions || positions.length === 0) {
1097
- safePostError(new Error('positions required for evolve'))
1098
- return null
1660
+ safePostError(new Error('positions required for evolve'));
1661
+ return null;
1099
1662
  }
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...')
1663
+ console.log('[evolve] Step 2: Normalizing state...');
1664
+ const normalized = normalizePrevState(prevState);
1665
+ const infectionsMap: Map<number, Infection> = normalized.infections;
1666
+ const availableSet = new Set<number>(
1667
+ Array.isArray(normalized.availableIndices)
1668
+ ? normalized.availableIndices
1669
+ : []
1670
+ );
1671
+ console.log('[evolve] Step 3: Cleaning infections...');
1672
+ for (const [idx, inf] of infectionsMap) {
1673
+ if (!inf || !inf.photo) {
1674
+ infectionsMap.delete(idx);
1675
+ availableSet.add(idx);
1676
+ }
1677
+ }
1678
+
1679
+ console.log('[evolve] Step 4: Calculating centroids...');
1680
+ const centroids = calculatePhotoCentroids(
1681
+ infectionsMap,
1682
+ positions,
1683
+ hexRadius
1684
+ );
1685
+ console.log('[evolve] Step 5: Creating new state copies...');
1686
+ const newInfections = new Map(infectionsMap);
1687
+ const newAvailable = new Set(availableSet);
1688
+ const generation =
1689
+ prevState && typeof prevState.generation === 'number'
1690
+ ? prevState.generation + 1
1691
+ : 0;
1692
+
1693
+ console.log(
1694
+ '[evolve] Step 6: Growth step - processing',
1695
+ infectionsMap.size,
1696
+ 'infections...'
1697
+ );
1115
1698
  // Skip growth step if we have no infections or no photos
1116
1699
  if (infectionsMap.size === 0 || photos.length === 0) {
1117
- console.log('[evolve] Skipping growth - no infections or no photos')
1700
+ console.log('[evolve] Skipping growth - no infections or no photos');
1118
1701
  } else {
1119
1702
  // Cell death step: allow fully surrounded cells to die and respawn for optimization
1120
- if (workerDebug.enableCellDeath && typeof workerDebug.cellDeathProbability === 'number') {
1703
+ if (
1704
+ workerDebug.enableCellDeath &&
1705
+ typeof workerDebug.cellDeathProbability === 'number'
1706
+ ) {
1121
1707
  // 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
-
1708
+ const annealingRate =
1709
+ typeof workerDebug.annealingRate === 'number' &&
1710
+ workerDebug.annealingRate > 0
1711
+ ? workerDebug.annealingRate
1712
+ : 1.0;
1713
+ const baseDeathProb = Math.max(
1714
+ 0,
1715
+ Math.min(1, workerDebug.cellDeathProbability * annealingRate)
1716
+ );
1717
+ const mutationEnabled = !!workerDebug.enableMutation;
1718
+ const baseMutationProb =
1719
+ mutationEnabled && typeof workerDebug.mutationProbability === 'number'
1720
+ ? Math.max(0, Math.min(1, workerDebug.mutationProbability))
1721
+ : 0;
1722
+ let deathCount = 0;
1723
+ let mutationCount = 0;
1724
+ let invaderExpulsions = 0;
1725
+
1134
1726
  // Calculate cluster sizes for mutation scaling
1135
- const clusterSizes = new Map<string, number>()
1727
+ const clusterSizes = new Map<string, number>();
1136
1728
  for (const [_, inf] of infectionsMap) {
1137
- clusterSizes.set(inf.photo.id, (clusterSizes.get(inf.photo.id) || 0) + 1)
1729
+ clusterSizes.set(
1730
+ inf.photo.id,
1731
+ (clusterSizes.get(inf.photo.id) || 0) + 1
1732
+ );
1138
1733
  }
1139
-
1734
+
1140
1735
  for (const [idx, inf] of infectionsMap) {
1141
- const neighbors = getNeighborsCached(idx, positions, hexRadius)
1142
- const totalNeighbors = neighbors.length
1143
-
1736
+ const neighbors = getNeighborsCached(idx, positions, hexRadius);
1737
+ const totalNeighbors = neighbors.length;
1738
+
1144
1739
  // 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
-
1740
+ const samePhotoNeighbors = neighbors.filter((n) => {
1741
+ const nInf = newInfections.get(n);
1742
+ return nInf && nInf.photo.id === inf.photo.id;
1743
+ });
1744
+
1150
1745
  // Calculate affinity ratio: 1.0 = all same photo, 0.0 = none same photo
1151
- const affinityRatio = totalNeighbors > 0 ? samePhotoNeighbors.length / totalNeighbors : 0
1152
-
1746
+ const affinityRatio =
1747
+ totalNeighbors > 0 ? samePhotoNeighbors.length / totalNeighbors : 0;
1748
+
1153
1749
  // Count hostile (different photo) neighbors and diversity
1154
- const hostileNeighbors = totalNeighbors - samePhotoNeighbors.length
1155
- const hostileRatio = totalNeighbors > 0 ? hostileNeighbors / totalNeighbors : 0
1156
-
1750
+ const hostileNeighbors = totalNeighbors - samePhotoNeighbors.length;
1751
+ const hostileRatio =
1752
+ totalNeighbors > 0 ? hostileNeighbors / totalNeighbors : 0;
1753
+
1157
1754
  // Calculate diversity: how many unique different photo types surround this cell
1158
- const uniqueHostilePhotos = new Set<string>()
1755
+ const uniqueHostilePhotos = new Set<string>();
1159
1756
  for (const n of neighbors) {
1160
- const nInf = newInfections.get(n)
1757
+ const nInf = newInfections.get(n);
1161
1758
  if (nInf && nInf.photo.id !== inf.photo.id) {
1162
- uniqueHostilePhotos.add(nInf.photo.id)
1759
+ uniqueHostilePhotos.add(nInf.photo.id);
1163
1760
  }
1164
1761
  }
1165
- const diversityCount = uniqueHostilePhotos.size
1166
- const maxDiversity = 6 // hex grid max neighbors
1167
- const diversityRatio = diversityCount / maxDiversity
1168
-
1762
+ const diversityCount = uniqueHostilePhotos.size;
1763
+ const maxDiversity = 6; // hex grid max neighbors
1764
+ const diversityRatio = diversityCount / maxDiversity;
1765
+
1169
1766
  // Affinity-adjusted death probability with boundary pressure:
1170
1767
  // - High affinity (well-integrated) = low death rate
1171
1768
  // - Low affinity (invader) = high death rate
1172
1769
  // - Partial hostile neighbors = MUCH higher death rate (boundary warfare)
1173
1770
  // - Solitary cells = VERY high death rate
1174
- //
1771
+ //
1175
1772
  // Base formula: deathProb = baseDeathProb * (1 - affinityRatio)^2
1176
1773
  // Boundary pressure: if 1-5 hostile neighbors, apply exponential penalty
1177
- let affinityPenalty = Math.pow(1 - affinityRatio, 2)
1178
-
1774
+ let affinityPenalty = Math.pow(1 - affinityRatio, 2);
1775
+
1179
1776
  // Solitary cell penalty: cells with 0-1 same neighbors are extremely vulnerable
1180
1777
  // Diversity amplifies this: being alone among many different photos is worst case
1181
1778
  if (samePhotoNeighbors.length <= 1) {
1182
1779
  // Base 10x penalty, increased by diversity: 2-6 different neighbors = 1.5x-3x additional multiplier
1183
1780
  // Formula: 10 × (1 + diversityRatio × 2)
1184
1781
  // 1 hostile type: 10x penalty
1185
- // 3 hostile types (50% diversity): 20x penalty
1782
+ // 3 hostile types (50% diversity): 20x penalty
1186
1783
  // 6 hostile types (100% diversity): 30x penalty
1187
- const diversityPenalty = 1 + diversityRatio * 2
1188
- affinityPenalty *= (10 * diversityPenalty)
1784
+ const diversityPenalty = 1 + diversityRatio * 2;
1785
+ affinityPenalty *= 10 * diversityPenalty;
1189
1786
  }
1190
-
1787
+
1191
1788
  // Boundary warfare multiplier: cells partially surrounded by enemies are in danger
1192
1789
  if (hostileNeighbors > 0 && hostileNeighbors < totalNeighbors) {
1193
1790
  // Peak danger at 50% hostile (3/6 neighbors): apply up to 4x multiplier
1194
1791
  // 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
1792
+ const boundaryPressure = 1 + 3 * Math.sin(hostileRatio * Math.PI);
1793
+ affinityPenalty *= boundaryPressure;
1197
1794
  }
1198
-
1199
- const adjustedDeathProb = Math.min(1, baseDeathProb * affinityPenalty)
1200
-
1795
+
1796
+ const adjustedDeathProb = Math.min(
1797
+ 1,
1798
+ baseDeathProb * affinityPenalty
1799
+ );
1800
+
1201
1801
  // Calculate mutation probability based on cluster size and virility
1202
1802
  // Larger, more popular clusters spawn more mutations
1203
- let mutationProb = baseMutationProb
1803
+ let mutationProb = baseMutationProb;
1204
1804
  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
-
1805
+ const clusterSize = clusterSizes.get(inf.photo.id) || 1;
1806
+ const velocity =
1807
+ typeof inf.photo.velocity === 'number' ? inf.photo.velocity : 0;
1808
+
1208
1809
  // 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
-
1810
+ const clusterMultiplier = Math.min(
1811
+ 10,
1812
+ Math.log10(clusterSize + 1) + 1
1813
+ );
1814
+
1211
1815
  // 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
-
1816
+ const virilityMultiplier =
1817
+ 1 + (Math.min(100, Math.max(0, velocity)) / 100) * 2;
1818
+
1214
1819
  // Combined mutation rate
1215
- mutationProb = Math.min(1, baseMutationProb * clusterMultiplier * virilityMultiplier)
1820
+ mutationProb = Math.min(
1821
+ 1,
1822
+ baseMutationProb * clusterMultiplier * virilityMultiplier
1823
+ );
1216
1824
  }
1217
-
1825
+
1218
1826
  // Only consider cells with at least some neighbors (avoid isolated cells)
1219
1827
  if (totalNeighbors >= 1 && Math.random() < adjustedDeathProb) {
1220
- const isInvader = affinityRatio < 0.5 // Less than half neighbors are same photo
1828
+ const isInvader = affinityRatio < 0.5; // Less than half neighbors are same photo
1221
1829
  // Check for mutation: respawn as a different photo instead of just dying
1222
- if (mutationEnabled && Math.random() < mutationProb && photos.length > 1) {
1830
+ if (
1831
+ mutationEnabled &&
1832
+ Math.random() < mutationProb &&
1833
+ photos.length > 1
1834
+ ) {
1223
1835
  // Pick a random photo from the pool that's different from current
1224
- const otherPhotos = photos.filter(p => p.id !== inf.photo.id)
1836
+ const otherPhotos = photos.filter((p) => p.id !== inf.photo.id);
1225
1837
  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)
1838
+ const newPhoto =
1839
+ otherPhotos[Math.floor(Math.random() * otherPhotos.length)];
1840
+ const tilesX = 4;
1841
+ const tilesY = 4;
1842
+ const uvBounds = calculateUvBoundsFromGridPosition(
1843
+ 0,
1844
+ 0,
1845
+ tilesX,
1846
+ tilesY
1847
+ );
1230
1848
  // Mutate: replace with new photo instead of dying
1231
1849
  newInfections.set(idx, {
1232
1850
  photo: newPhoto,
@@ -1237,454 +1855,782 @@ function evolveInfectionSystem(prevState: any, positions: [number, number, numbe
1237
1855
  scale: 0.4,
1238
1856
  growthRate: 0.08,
1239
1857
  tilesX: tilesX,
1240
- tilesY: tilesY
1241
- })
1242
- mutationCount++
1858
+ tilesY: tilesY,
1859
+ });
1860
+ mutationCount++;
1243
1861
  } else {
1244
1862
  // No other photos available, just die normally
1245
- newInfections.delete(idx)
1246
- newAvailable.add(idx)
1247
- deathCount++
1863
+ newInfections.delete(idx);
1864
+ newAvailable.add(idx);
1865
+ deathCount++;
1248
1866
  }
1249
1867
  } else {
1250
1868
  // Normal death: remove and make available for respawn
1251
- newInfections.delete(idx)
1252
- newAvailable.add(idx)
1253
- deathCount++
1254
- if (isInvader) invaderExpulsions++
1869
+ newInfections.delete(idx);
1870
+ newAvailable.add(idx);
1871
+ deathCount++;
1872
+ if (isInvader) invaderExpulsions++;
1255
1873
  }
1256
1874
  }
1257
1875
  }
1258
1876
  if (deathCount > 0 || mutationCount > 0 || invaderExpulsions > 0) {
1259
- console.log('[evolve] Cell death: removed', deathCount, 'cells (', invaderExpulsions, 'invaders expelled), mutated', mutationCount, 'cells')
1877
+ console.log(
1878
+ '[evolve] Cell death: removed',
1879
+ deathCount,
1880
+ 'cells (',
1881
+ invaderExpulsions,
1882
+ 'invaders expelled), mutated',
1883
+ mutationCount,
1884
+ 'cells'
1885
+ );
1260
1886
  }
1261
1887
  }
1262
-
1888
+
1263
1889
  // Growth step: prefer neighbors that increase contiguity and are closer to centroids
1264
- let growthIterations = 0
1890
+ let growthIterations = 0;
1265
1891
  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
- }
1892
+ growthIterations++;
1893
+ if (growthIterations % 10 === 0)
1894
+ console.log(
1895
+ '[evolve] Growth iteration',
1896
+ growthIterations,
1897
+ '/',
1898
+ infectionsMap.size
1899
+ );
1900
+ const neighbors = getNeighborsCached(idx, positions, hexRadius);
1901
+ for (const n of neighbors) {
1902
+ if (!newAvailable.has(n)) continue;
1903
+ let base = 0.5; // BOOSTED from 0.3 to encourage more aggressive growth
1904
+ const sameNeighbors = getNeighborsCached(
1905
+ n,
1906
+ positions,
1907
+ hexRadius
1908
+ ).filter(
1909
+ (x) =>
1910
+ newInfections.has(x) &&
1911
+ newInfections.get(x)!.photo.id === inf.photo.id
1912
+ ).length;
1913
+ if (sameNeighbors >= 2) base = 0.95;
1914
+ else if (sameNeighbors === 1) base = 0.75; // BOOSTED to favor contiguous growth
1915
+
1916
+ // Virility boost: photos with higher velocity (upvotes/engagement) grow faster
1917
+ if (
1918
+ workerDebug.enableVirilityBoost &&
1919
+ typeof inf.photo.velocity === 'number' &&
1920
+ inf.photo.velocity > 0
1921
+ ) {
1922
+ const virilityMult =
1923
+ typeof workerDebug.virilityMultiplier === 'number'
1924
+ ? workerDebug.virilityMultiplier
1925
+ : 1.0;
1926
+ // Normalize velocity to a 0-1 range (assuming velocity is already normalized or 0-100)
1927
+ // Then apply as a percentage boost: velocity=100 -> 100% boost (2x), velocity=50 -> 50% boost (1.5x)
1928
+ const normalizedVelocity = Math.min(
1929
+ 1,
1930
+ Math.max(0, inf.photo.velocity / 100)
1931
+ );
1932
+ const virilityBoost = 1 + normalizedVelocity * virilityMult;
1933
+ base *= virilityBoost;
1934
+ }
1284
1935
 
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
1936
+ // Centroid cohesion bias
1937
+ try {
1938
+ const cList = centroids.get(inf.photo.id) || [];
1939
+ if (cList.length > 0) {
1940
+ const bounds = getGridBounds(positions);
1941
+ let minD = Infinity;
1942
+ const p = positions[n];
1943
+ for (const c of cList) {
1944
+ const dx = Math.abs(p[0] - c[0]);
1945
+ const dy = Math.abs(p[1] - c[1]);
1946
+ let effDx = dx;
1947
+ let effDy = dy;
1948
+ if (
1949
+ cache.isSpherical &&
1950
+ bounds.width > 0 &&
1951
+ bounds.height > 0
1952
+ ) {
1953
+ if (effDx > bounds.width / 2) effDx = bounds.width - effDx;
1954
+ if (effDy > bounds.height / 2) effDy = bounds.height - effDy;
1955
+ }
1956
+ const d = Math.sqrt(effDx * effDx + effDy * effDy);
1957
+ if (d < minD) minD = d;
1298
1958
  }
1299
- const d = Math.sqrt(effDx * effDx + effDy * effDy)
1300
- if (d < minD) minD = d
1959
+ const radius = Math.max(1, hexRadius * 3);
1960
+ const distFactor = Math.max(0, Math.min(1, 1 - minD / radius));
1961
+ const boost =
1962
+ typeof workerDebug.cohesionBoost === 'number'
1963
+ ? workerDebug.cohesionBoost
1964
+ : 0.6;
1965
+ base *= 1 + distFactor * boost;
1301
1966
  }
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)
1967
+ } catch (e) {
1968
+ if (debug) console.warn('cohesion calc failed', e);
1306
1969
  }
1307
- } catch (e) { if (debug) console.warn('cohesion calc failed', e) }
1308
1970
 
1309
1971
  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)
1972
+ const tilesX = inf.tilesX || 4;
1973
+ const tilesY = inf.tilesY || 4;
1974
+ const uvBounds = calculateUvBoundsFromGridPosition(
1975
+ 0,
1976
+ 0,
1977
+ tilesX,
1978
+ tilesY
1979
+ );
1980
+ newInfections.set(n, {
1981
+ photo: inf.photo,
1982
+ gridPosition: [0, 0],
1983
+ infectionTime: currentTime,
1984
+ generation,
1985
+ uvBounds: uvBounds,
1986
+ scale: 0.4,
1987
+ growthRate: inf.growthRate || 0.08,
1988
+ tilesX: tilesX,
1989
+ tilesY: tilesY,
1990
+ });
1991
+ newAvailable.delete(n);
1315
1992
  }
1316
1993
  }
1317
1994
  }
1318
1995
  }
1319
1996
 
1320
- console.log('[evolve] Step 6.5: Entropy decay - applying decay to dominant successful photos...')
1997
+ console.log(
1998
+ '[evolve] Step 6.5: Entropy decay - applying decay to dominant successful photos...'
1999
+ );
1321
2000
  // Entropy decay: successful/dominant photos decay over time to allow new dominance to emerge
1322
2001
  if (workerDebug.enableEntropyDecay && newInfections.size > 0) {
1323
2002
  // Calculate current territory shares
1324
- const territoryCounts = new Map<string, number>()
1325
- const photoVelocities = new Map<string, number>()
1326
-
2003
+ const territoryCounts = new Map<string, number>();
2004
+ const photoVelocities = new Map<string, number>();
2005
+
1327
2006
  for (const [_, inf] of newInfections) {
1328
- territoryCounts.set(inf.photo.id, (territoryCounts.get(inf.photo.id) || 0) + 1)
2007
+ territoryCounts.set(
2008
+ inf.photo.id,
2009
+ (territoryCounts.get(inf.photo.id) || 0) + 1
2010
+ );
1329
2011
  if (typeof inf.photo.velocity === 'number') {
1330
- photoVelocities.set(inf.photo.id, inf.photo.velocity)
2012
+ photoVelocities.set(inf.photo.id, inf.photo.velocity);
1331
2013
  }
1332
2014
  }
1333
-
1334
- const totalTerritory = newInfections.size
2015
+
2016
+ const totalTerritory = newInfections.size;
1335
2017
  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
-
2018
+ const dominanceThreshold =
2019
+ typeof workerDebug.entropyDominanceThreshold === 'number'
2020
+ ? workerDebug.entropyDominanceThreshold
2021
+ : 0.15;
2022
+ const successThreshold =
2023
+ typeof workerDebug.entropySuccessVelocityThreshold === 'number'
2024
+ ? workerDebug.entropySuccessVelocityThreshold
2025
+ : 50;
2026
+ const baseDecayRate =
2027
+ typeof workerDebug.entropyDecayBaseRate === 'number'
2028
+ ? workerDebug.entropyDecayBaseRate
2029
+ : 0.02;
2030
+ const timeMultiplier =
2031
+ typeof workerDebug.entropyTimeMultiplier === 'number'
2032
+ ? workerDebug.entropyTimeMultiplier
2033
+ : 0.1;
2034
+
1349
2035
  // Update dominance history and identify dominant successful photos
1350
- const dominantSuccessfulPhotos = new Set<string>()
1351
-
2036
+ const dominantSuccessfulPhotos = new Set<string>();
2037
+
1352
2038
  for (const [photoId, territory] of territoryCounts) {
1353
- const territoryShare = territory / totalTerritory
1354
- const velocity = photoVelocities.get(photoId) || 0
1355
-
2039
+ const territoryShare = territory / totalTerritory;
2040
+ const velocity = photoVelocities.get(photoId) || 0;
2041
+
1356
2042
  // Check if photo is dominant (above threshold) and successful (velocity above threshold)
1357
- if (territoryShare >= dominanceThreshold && velocity >= successThreshold) {
2043
+ if (
2044
+ territoryShare >= dominanceThreshold &&
2045
+ velocity >= successThreshold
2046
+ ) {
1358
2047
  // Update dominance history: increment generations as dominant
1359
- const generationsAsDominant = (dominanceHistory.get(photoId) || 0) + 1
1360
- dominanceHistory.set(photoId, generationsAsDominant)
1361
- dominantSuccessfulPhotos.add(photoId)
2048
+ const generationsAsDominant =
2049
+ (dominanceHistory.get(photoId) || 0) + 1;
2050
+ dominanceHistory.set(photoId, generationsAsDominant);
2051
+ dominantSuccessfulPhotos.add(photoId);
1362
2052
  } else {
1363
2053
  // Reset dominance history if no longer dominant or successful
1364
- dominanceHistory.delete(photoId)
2054
+ dominanceHistory.delete(photoId);
1365
2055
  }
1366
2056
  }
1367
-
2057
+
1368
2058
  // Apply entropy decay to cells from dominant successful photos
1369
- let entropyDecayCount = 0
1370
- const cellsToDecay: number[] = []
1371
-
2059
+ let entropyDecayCount = 0;
2060
+ const cellsToDecay: number[] = [];
2061
+
1372
2062
  for (const [idx, inf] of newInfections) {
1373
2063
  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
-
2064
+ const photoId = inf.photo.id;
2065
+ const territory = territoryCounts.get(photoId) || 0;
2066
+ const territoryShare = territory / totalTerritory;
2067
+ const velocity = photoVelocities.get(photoId) || 0;
2068
+ const generationsAsDominant = dominanceHistory.get(photoId) || 0;
2069
+
1380
2070
  // Calculate decay probability based on:
1381
2071
  // 1. Territory share (dominance) - more dominant = more decay
1382
2072
  // 2. Velocity (success) - more successful = more decay (but only if already dominant)
1383
2073
  // 3. Time as dominant - longer = more decay
1384
-
2074
+
1385
2075
  // Normalize territory share: 0.15 (threshold) -> 0.0, 1.0 -> 1.0
1386
- const normalizedDominance = Math.max(0, (territoryShare - dominanceThreshold) / (1 - dominanceThreshold))
1387
-
2076
+ const normalizedDominance = Math.max(
2077
+ 0,
2078
+ (territoryShare - dominanceThreshold) / (1 - dominanceThreshold)
2079
+ );
2080
+
1388
2081
  // Normalize velocity: 50 (threshold) -> 0.0, 100 -> 1.0
1389
- const normalizedSuccess = Math.max(0, Math.min(1, (velocity - successThreshold) / (100 - successThreshold)))
1390
-
2082
+ const normalizedSuccess = Math.max(
2083
+ 0,
2084
+ Math.min(
2085
+ 1,
2086
+ (velocity - successThreshold) / (100 - successThreshold)
2087
+ )
2088
+ );
2089
+
1391
2090
  // Time multiplier: each generation as dominant adds to decay rate
1392
- const timeFactor = 1 + (generationsAsDominant * timeMultiplier)
1393
-
2091
+ const timeFactor = 1 + generationsAsDominant * timeMultiplier;
2092
+
1394
2093
  // Combined decay probability
1395
2094
  // Base rate scaled by dominance, success, and time
1396
- const decayProb = baseDecayRate * normalizedDominance * (1 + normalizedSuccess) * timeFactor
1397
-
2095
+ const decayProb =
2096
+ baseDecayRate *
2097
+ normalizedDominance *
2098
+ (1 + normalizedSuccess) *
2099
+ timeFactor;
2100
+
1398
2101
  // Cap at reasonable maximum (e.g., 10% per generation)
1399
- const cappedDecayProb = Math.min(0.1, decayProb)
1400
-
2102
+ const cappedDecayProb = Math.min(0.1, decayProb);
2103
+
1401
2104
  if (Math.random() < cappedDecayProb) {
1402
- cellsToDecay.push(idx)
2105
+ cellsToDecay.push(idx);
1403
2106
  }
1404
2107
  }
1405
2108
  }
1406
-
2109
+
1407
2110
  // Apply decay: remove cells and make them available for new infections
1408
2111
  for (const idx of cellsToDecay) {
1409
- newInfections.delete(idx)
1410
- newAvailable.add(idx)
1411
- entropyDecayCount++
2112
+ newInfections.delete(idx);
2113
+ newAvailable.add(idx);
2114
+ entropyDecayCount++;
1412
2115
  }
1413
-
2116
+
1414
2117
  if (entropyDecayCount > 0) {
1415
- console.log('[evolve] Entropy decay: removed', entropyDecayCount, 'cells from dominant successful photos')
2118
+ console.log(
2119
+ '[evolve] Entropy decay: removed',
2120
+ entropyDecayCount,
2121
+ 'cells from dominant successful photos'
2122
+ );
1416
2123
  }
1417
2124
  }
1418
2125
  }
1419
2126
 
1420
- console.log('[evolve] Step 7: Deterministic fill - processing', newAvailable.size, 'available positions...')
2127
+ console.log(
2128
+ '[evolve] Step 7: Deterministic fill - processing',
2129
+ newAvailable.size,
2130
+ 'available positions...'
2131
+ );
1421
2132
  // Skip deterministic fill if we have no photos or no existing infections to base decisions on
1422
2133
  if (photos.length === 0 || newInfections.size === 0) {
1423
- console.log('[evolve] Skipping deterministic fill - no photos or no infections')
2134
+ console.log(
2135
+ '[evolve] Skipping deterministic fill - no photos or no infections'
2136
+ );
1424
2137
  } else {
1425
2138
  // Deterministic fill for holes with >=2 same-photo neighbors
1426
- let fillIterations = 0
2139
+ let fillIterations = 0;
1427
2140
  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
- }
2141
+ fillIterations++;
2142
+ if (fillIterations % 50 === 0)
2143
+ console.log(
2144
+ '[evolve] Fill iteration',
2145
+ fillIterations,
2146
+ '/',
2147
+ newAvailable.size
2148
+ );
2149
+ const neighbors = getNeighborsCached(a, positions, hexRadius);
2150
+ const counts = new Map<string, number>();
2151
+ for (const n of neighbors) {
2152
+ const inf = newInfections.get(n);
2153
+ if (!inf) continue;
2154
+ counts.set(inf.photo.id, (counts.get(inf.photo.id) || 0) + 1);
2155
+ }
2156
+ let bestId: string | undefined;
2157
+ let best = 0;
2158
+ for (const [pid, c] of counts)
2159
+ if (c > best) {
2160
+ best = c;
2161
+ bestId = pid;
2162
+ }
2163
+ if (bestId && best >= 2) {
2164
+ const src =
2165
+ photos.find((p) => p.id === bestId) ||
2166
+ Array.from(infectionsMap.values())[0]?.photo;
2167
+ if (src) {
2168
+ const tilesX = 4;
2169
+ const tilesY = 4;
2170
+ const uvBounds = calculateUvBoundsFromGridPosition(
2171
+ 0,
2172
+ 0,
2173
+ tilesX,
2174
+ tilesY
2175
+ );
2176
+ newInfections.set(a, {
2177
+ photo: src,
2178
+ gridPosition: [0, 0],
2179
+ infectionTime: currentTime,
2180
+ generation,
2181
+ uvBounds: uvBounds,
2182
+ scale: 0.35,
2183
+ growthRate: 0.08,
2184
+ tilesX: tilesX,
2185
+ tilesY: tilesY,
2186
+ });
2187
+ newAvailable.delete(a);
2188
+ }
1444
2189
  }
1445
2190
  }
1446
2191
  }
1447
2192
 
1448
- console.log('[evolve] Step 8: Optimization merge pass...')
2193
+ console.log('[evolve] Step 8: Optimization merge pass...');
1449
2194
  // Conservative merge pass (opt-in)
1450
- postOptimizationMerge(newInfections, positions, hexRadius, !!workerDebug.mergeLogs)
1451
-
1452
- console.log('[evolve] Step 9: Assigning cluster-aware grid positions...')
2195
+ postOptimizationMerge(
2196
+ newInfections,
2197
+ positions,
2198
+ hexRadius,
2199
+ !!workerDebug.mergeLogs
2200
+ );
2201
+
2202
+ console.log('[evolve] Step 9: Assigning cluster-aware grid positions...');
1453
2203
  // 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 }
2204
+ const tileCenters = assignClusterGridPositions(
2205
+ newInfections,
2206
+ positions,
2207
+ hexRadius
2208
+ );
2209
+
2210
+ console.log(
2211
+ '[evolve] Step 10: Returning result - generation',
2212
+ generation,
2213
+ 'infections',
2214
+ newInfections.size
2215
+ );
2216
+ return {
2217
+ infections: newInfections,
2218
+ availableIndices: Array.from(newAvailable),
2219
+ lastEvolutionTime: currentTime,
2220
+ generation,
2221
+ tileCenters,
2222
+ };
2223
+ } catch (e) {
2224
+ safePostError(e);
2225
+ return null;
2226
+ }
1459
2227
  }
1460
2228
 
1461
- let lastEvolutionAt = 0
2229
+ let lastEvolutionAt = 0;
1462
2230
 
1463
2231
  function mergeDebugFromPayload(d: any) {
1464
- if (!d || typeof d !== 'object') return
2232
+ if (!d || typeof d !== 'object') return;
1465
2233
  // Map main-thread naming (evolveIntervalMs) into worker's evolutionIntervalMs
1466
- if (typeof d.evolveIntervalMs === 'number') d.evolutionIntervalMs = d.evolveIntervalMs
2234
+ if (typeof d.evolveIntervalMs === 'number')
2235
+ d.evolutionIntervalMs = d.evolveIntervalMs;
1467
2236
  // Merge into workerDebug
1468
- try { Object.assign(workerDebug, d) } catch (e) {}
2237
+ try {
2238
+ Object.assign(workerDebug, d);
2239
+ } catch (e) {}
1469
2240
  }
1470
2241
 
1471
2242
  self.onmessage = function (ev: MessageEvent) {
1472
- const raw = ev.data
2243
+ const raw = ev.data;
1473
2244
  try {
1474
- if (!raw || typeof raw !== 'object') return
2245
+ if (!raw || typeof raw !== 'object') return;
1475
2246
 
1476
- const type = raw.type
1477
- const payload = raw.data ?? raw
2247
+ const type = raw.type;
2248
+ const payload = raw.data ?? raw;
1478
2249
 
1479
2250
  if (type === 'setDataAndConfig' || type === 'setDebug') {
1480
2251
  // Accept either { type:'setDataAndConfig', data: { photos, debug } } or { type:'setDebug', debug }
1481
- const dbg = payload.debug ?? raw.debug ?? payload
1482
- mergeDebugFromPayload(dbg)
1483
-
2252
+ const dbg = payload.debug ?? raw.debug ?? payload;
2253
+ mergeDebugFromPayload(dbg);
2254
+
1484
2255
  // Pre-build neighbor cache if positions are provided
1485
2256
  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
-
2257
+ const incomingIsSpherical =
2258
+ typeof payload.isSpherical === 'boolean'
2259
+ ? Boolean(payload.isSpherical)
2260
+ : cache.isSpherical;
2261
+ const shouldUpdateTopology =
2262
+ typeof payload.isSpherical === 'boolean' &&
2263
+ incomingIsSpherical !== cache.isSpherical;
2264
+ if (shouldUpdateTopology) invalidateCaches(incomingIsSpherical);
2265
+ else invalidateCaches();
2266
+
2267
+ const positions = payload.positions;
2268
+ if (!positions || !Array.isArray(positions)) return;
2269
+ const hexRadius =
2270
+ typeof payload.hexRadius === 'number' ? payload.hexRadius : 24;
2271
+ console.log(
2272
+ '[hexgrid-worker] Pre-building neighbor cache for',
2273
+ positions.length,
2274
+ 'positions...'
2275
+ );
2276
+ const startTime = Date.now();
2277
+
1497
2278
  // Build ALL neighbor relationships in one O(n²) pass instead of n×O(n) passes
1498
2279
  try {
1499
- const bounds = getGridBounds(positions)
1500
- const threshold = Math.sqrt(3) * hexRadius * 1.15
1501
- const isSpherical = !!cache.isSpherical
1502
-
2280
+ const bounds = getGridBounds(positions);
2281
+ const threshold = Math.sqrt(3) * hexRadius * 1.15;
2282
+ const isSpherical = !!cache.isSpherical;
2283
+
1503
2284
  // Initialize empty arrays for all positions
1504
2285
  for (let i = 0; i < positions.length; i++) {
1505
- cache.neighborMap.set(i, [])
2286
+ cache.neighborMap.set(i, []);
1506
2287
  }
1507
-
2288
+
1508
2289
  // Single pass: check each pair once and add bidirectional neighbors
1509
2290
  for (let i = 0; i < positions.length; i++) {
1510
- const pos1 = positions[i]
1511
- if (!pos1) continue
1512
-
2291
+ const pos1 = positions[i];
2292
+ if (!pos1) continue;
2293
+
1513
2294
  // Only check j > i to avoid duplicate checks
1514
2295
  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)
2296
+ const pos2 = positions[j];
2297
+ if (!pos2) continue;
2298
+
2299
+ const d = distanceBetween(pos1, pos2, bounds, isSpherical);
1519
2300
  if (d <= threshold) {
1520
2301
  // Add bidirectional neighbors
1521
- cache.neighborMap.get(i)!.push(j)
1522
- cache.neighborMap.get(j)!.push(i)
2302
+ cache.neighborMap.get(i)!.push(j);
2303
+ cache.neighborMap.get(j)!.push(i);
1523
2304
  }
1524
2305
  }
1525
-
2306
+
1526
2307
  // Log progress every 100 positions
1527
2308
  if ((i + 1) % 100 === 0) {
1528
- console.log('[hexgrid-worker] Processed', i + 1, '/', positions.length, 'positions')
2309
+ console.log(
2310
+ '[hexgrid-worker] Processed',
2311
+ i + 1,
2312
+ '/',
2313
+ positions.length,
2314
+ 'positions'
2315
+ );
1529
2316
  }
1530
2317
  }
1531
-
1532
- const elapsed = Date.now() - startTime
1533
- console.log('[hexgrid-worker] ✅ Neighbor cache built in', elapsed, 'ms - ready for evolution!')
2318
+
2319
+ const elapsed = Date.now() - startTime;
2320
+ console.log(
2321
+ '[hexgrid-worker] ✅ Neighbor cache built in',
2322
+ elapsed,
2323
+ 'ms - ready for evolution!'
2324
+ );
1534
2325
  // Mark cache as ready
1535
- cache.cacheReady = true
2326
+ cache.cacheReady = true;
1536
2327
  // Notify main thread that cache is ready
1537
- try { self.postMessage({ type: 'cache-ready', data: { elapsed, positions: positions.length } }) } catch (e) {}
2328
+ try {
2329
+ self.postMessage({
2330
+ type: 'cache-ready',
2331
+ data: { elapsed, positions: positions.length },
2332
+ });
2333
+ } catch (e) {}
1538
2334
  } catch (e) {
1539
- console.error('[hexgrid-worker] Error during cache pre-build:', e)
2335
+ console.error('[hexgrid-worker] Error during cache pre-build:', e);
1540
2336
  // Mark cache as ready anyway to allow evolution to proceed
1541
- cache.cacheReady = true
2337
+ cache.cacheReady = true;
1542
2338
  }
1543
2339
  }
1544
-
1545
- return
2340
+
2341
+ return;
1546
2342
  }
1547
2343
 
1548
2344
  if (type === 'evolve') {
1549
2345
  // Check if neighbor cache is ready before processing evolve
1550
2346
  if (!cache.cacheReady) {
1551
- console.log('[hexgrid-worker] ⏸️ Evolve message received but cache not ready yet - deferring...')
2347
+ console.log(
2348
+ '[hexgrid-worker] ⏸️ Evolve message received but cache not ready yet - deferring...'
2349
+ );
1552
2350
  // Defer this evolve message by re-posting it after a short delay
1553
2351
  setTimeout(() => {
1554
- try { self.postMessage({ type: 'deferred-evolve', data: { reason: 'cache-not-ready' } }) } catch (e) {}
2352
+ try {
2353
+ self.postMessage({
2354
+ type: 'deferred-evolve',
2355
+ data: { reason: 'cache-not-ready' },
2356
+ });
2357
+ } catch (e) {}
1555
2358
  // Re-process the message
1556
- self.onmessage!(ev)
1557
- }, 100)
1558
- return
2359
+ self.onmessage!(ev);
2360
+ }, 100);
2361
+ return;
1559
2362
  }
1560
-
2363
+
1561
2364
  // Normalize payload shape: support { data: { prevState, positions, photos, hexRadius, debug } }
1562
2365
  mergeDebugFromPayload(payload.debug || payload);
1563
2366
  // Diagnostic: log that an evolve was received and the available payload keys (only when debugLogs enabled)
1564
2367
  try {
1565
2368
  if (workerDebug && workerDebug.debugLogs) {
1566
- console.log('[hexgrid-worker] evolve received, payload keys=', Object.keys(payload || {}), 'workerDebug.evolutionIntervalMs=', workerDebug.evolutionIntervalMs, 'workerDebug.evolveIntervalMs=', workerDebug.evolveIntervalMs)
2369
+ console.log(
2370
+ '[hexgrid-worker] evolve received, payload keys=',
2371
+ Object.keys(payload || {}),
2372
+ 'workerDebug.evolutionIntervalMs=',
2373
+ workerDebug.evolutionIntervalMs,
2374
+ 'workerDebug.evolveIntervalMs=',
2375
+ workerDebug.evolveIntervalMs
2376
+ );
1567
2377
  }
1568
2378
  } catch (e) {}
1569
2379
  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))
2380
+ const interval =
2381
+ typeof workerDebug.evolutionIntervalMs === 'number'
2382
+ ? workerDebug.evolutionIntervalMs
2383
+ : typeof workerDebug.evolveIntervalMs === 'number'
2384
+ ? workerDebug.evolveIntervalMs
2385
+ : 60000;
2386
+ console.log(
2387
+ '[hexgrid-worker] Throttle check: interval=',
2388
+ interval,
2389
+ 'lastEvolutionAt=',
2390
+ lastEvolutionAt,
2391
+ 'now=',
2392
+ now,
2393
+ 'diff=',
2394
+ now - lastEvolutionAt,
2395
+ 'willThrottle=',
2396
+ now - lastEvolutionAt < interval
2397
+ );
1572
2398
  // 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
2399
+ const reason = payload.reason || (raw && raw.reason);
2400
+ const bypassThrottle = reason === 'photos-init' || reason === 'reset';
2401
+ // Clear, high-signal log for build verification: reports whether the current evolve will bypass the worker throttle
2402
+ console.log('[hexgrid-worker] THROTTLE DECISION', {
2403
+ interval,
2404
+ lastEvolutionAt,
2405
+ now,
2406
+ diff: now - lastEvolutionAt,
2407
+ willThrottle: !bypassThrottle && now - lastEvolutionAt < interval,
2408
+ reason,
2409
+ bypassThrottle,
2410
+ });
2411
+ // Throttle: if we're within the interval and not bypassed, notify (debug) and skip processing
2412
+ if (!bypassThrottle && now - lastEvolutionAt < interval) {
2413
+ console.log(
2414
+ '[hexgrid-worker] ⛔ THROTTLED - skipping evolution processing'
2415
+ );
2416
+ if (workerDebug && workerDebug.debugLogs) {
2417
+ try {
2418
+ self.postMessage({
2419
+ type: 'throttled-evolve',
2420
+ data: {
2421
+ receivedAt: now,
2422
+ nextAvailableAt: lastEvolutionAt + interval,
2423
+ payloadKeys: Object.keys(payload || {}),
2424
+ reason,
2425
+ },
2426
+ });
2427
+ } catch (e) {}
1584
2428
  }
2429
+ return;
2430
+ }
1585
2431
  // 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)
2432
+ lastEvolutionAt = now;
2433
+ console.log(
2434
+ '[hexgrid-worker] ✅ PROCESSING evolution - lastEvolutionAt updated to',
2435
+ now
2436
+ );
1588
2437
  try {
1589
2438
  if (workerDebug && workerDebug.debugLogs) {
1590
- try { self.postMessage({ type: 'ack-evolve', data: { receivedAt: now, payloadKeys: Object.keys(payload || {}) } }) } catch (e) {}
2439
+ try {
2440
+ self.postMessage({
2441
+ type: 'ack-evolve',
2442
+ data: {
2443
+ receivedAt: now,
2444
+ payloadKeys: Object.keys(payload || {}),
2445
+ },
2446
+ });
2447
+ } catch (e) {}
1591
2448
  }
1592
2449
  } catch (e) {}
1593
2450
 
1594
2451
  // Emit a lightweight processing marker so the client can see evolve processing started
1595
2452
  try {
1596
2453
  if (workerDebug && workerDebug.debugLogs) {
1597
- try { self.postMessage({ type: 'processing-evolve', data: { startedAt: now, payloadKeys: Object.keys(payload || {}) } }) } catch (e) {}
2454
+ try {
2455
+ self.postMessage({
2456
+ type: 'processing-evolve',
2457
+ data: { startedAt: now, payloadKeys: Object.keys(payload || {}) },
2458
+ });
2459
+ } catch (e) {}
1598
2460
  }
1599
2461
  } catch (e) {}
1600
2462
 
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))
2463
+ const state = payload.prevState ?? payload.state ?? raw.state ?? null;
2464
+ const positions = payload.positions ?? raw.positions ?? [];
2465
+ const photos = payload.photos ?? raw.photos ?? [];
2466
+ const hexRadius =
2467
+ typeof payload.hexRadius === 'number'
2468
+ ? payload.hexRadius
2469
+ : typeof raw.hexRadius === 'number'
2470
+ ? raw.hexRadius
2471
+ : 16;
2472
+
2473
+ if (
2474
+ typeof payload.isSpherical === 'boolean' &&
2475
+ Boolean(payload.isSpherical) !== cache.isSpherical
2476
+ ) {
2477
+ invalidateCaches(Boolean(payload.isSpherical));
1608
2478
  }
1609
2479
 
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
-
2480
+ console.log('[hexgrid-worker] 🔧 About to call evolveInfectionSystem');
2481
+ console.log('[hexgrid-worker] - state generation:', state?.generation);
2482
+ console.log(
2483
+ '[hexgrid-worker] - state infections:',
2484
+ state?.infections?.length || state?.infections?.size || 0
2485
+ );
2486
+ console.log('[hexgrid-worker] - positions:', positions?.length || 0);
2487
+ console.log('[hexgrid-worker] - photos:', photos?.length || 0);
2488
+ console.log('[hexgrid-worker] - hexRadius:', hexRadius);
2489
+
2490
+ let res;
2491
+ let timeoutId;
2492
+ let timedOut = false;
2493
+
1621
2494
  // Set a watchdog timer to detect hangs (10 seconds)
1622
2495
  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
-
2496
+ timedOut = true;
2497
+ console.error(
2498
+ '[hexgrid-worker] ⏱️ TIMEOUT: evolveInfectionSystem is taking too long (>10s)! Possible infinite loop.'
2499
+ );
2500
+ try {
2501
+ self.postMessage({
2502
+ type: 'error',
2503
+ error: 'Evolution timeout - possible infinite loop',
2504
+ });
2505
+ } catch (e) {}
2506
+ }, 10000);
2507
+
1628
2508
  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')
2509
+ console.log('[hexgrid-worker] 🚀 Calling evolveInfectionSystem NOW...');
2510
+ const startTime = Date.now();
2511
+ res = evolveInfectionSystem(
2512
+ state,
2513
+ positions,
2514
+ photos,
2515
+ hexRadius,
2516
+ now,
2517
+ !!workerDebug.debugLogs
2518
+ );
2519
+ const elapsed = Date.now() - startTime;
2520
+ clearTimeout(timeoutId);
2521
+ console.log(
2522
+ '[hexgrid-worker] ✅ evolveInfectionSystem RETURNED successfully in',
2523
+ elapsed,
2524
+ 'ms'
2525
+ );
1635
2526
  } 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
2527
+ clearTimeout(timeoutId);
2528
+ console.error(
2529
+ '[hexgrid-worker] FATAL: evolveInfectionSystem threw an error:',
2530
+ err
2531
+ );
2532
+ console.error(
2533
+ '[hexgrid-worker] Error stack:',
2534
+ err instanceof Error ? err.stack : 'no stack'
2535
+ );
2536
+ safePostError(err);
2537
+ return;
1641
2538
  }
1642
-
2539
+
1643
2540
  if (timedOut) {
1644
- console.error('[hexgrid-worker] ⏱️ Function eventually returned but after timeout was triggered')
2541
+ console.error(
2542
+ '[hexgrid-worker] ⏱️ Function eventually returned but after timeout was triggered'
2543
+ );
1645
2544
  }
1646
-
2545
+
1647
2546
  if (!res) {
1648
- console.log('[hexgrid-worker] ❌ evolveInfectionSystem returned null!')
1649
- return
2547
+ console.log('[hexgrid-worker] ❌ evolveInfectionSystem returned null!');
2548
+ return;
1650
2549
  }
1651
- console.log('[hexgrid-worker] ✅ Evolution complete! New generation=', res.generation, 'infections=', res.infections.size)
2550
+ console.log(
2551
+ '[hexgrid-worker] ✅ Evolution complete! New generation=',
2552
+ res.generation,
2553
+ 'infections=',
2554
+ res.infections.size
2555
+ );
1652
2556
  try {
1653
- const payload: any = { infections: Array.from(res.infections.entries()), availableIndices: res.availableIndices, lastEvolutionTime: res.lastEvolutionTime, generation: res.generation }
2557
+ const payload: any = {
2558
+ infections: Array.from(res.infections.entries()),
2559
+ availableIndices: res.availableIndices,
2560
+ lastEvolutionTime: res.lastEvolutionTime,
2561
+ generation: res.generation,
2562
+ };
1654
2563
  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')
2564
+ payload.tileCenters = res.tileCenters;
2565
+ console.log(
2566
+ '[hexgrid-worker] Including',
2567
+ res.tileCenters.length,
2568
+ 'tile center sets in evolved message'
2569
+ );
1657
2570
  }
1658
- self.postMessage({ type: 'evolved', data: payload })
2571
+ self.postMessage({ type: 'evolved', data: payload });
1659
2572
  // 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) {}
2573
+ try {
2574
+ cache.lastGeneration = res.generation;
2575
+ cache.lastInfectionCount = res.infections ? res.infections.size : 0;
2576
+ } catch (e) {}
1661
2577
  } catch (e) {
1662
- console.error('[hexgrid-worker] ❌ Failed to post evolved message:', e)
2578
+ console.error('[hexgrid-worker] ❌ Failed to post evolved message:', e);
1663
2579
  }
1664
- console.log('[hexgrid-worker] 📤 Posted evolved message back to main thread')
2580
+ console.log(
2581
+ '[hexgrid-worker] 📤 Posted evolved message back to main thread'
2582
+ );
1665
2583
 
1666
2584
  // Emit a completion marker so the client can confirm the evolve finished end-to-end
1667
2585
  try {
1668
2586
  if (workerDebug && workerDebug.debugLogs) {
1669
- try { self.postMessage({ type: 'evolved-complete', data: { finishedAt: Date.now(), generation: res.generation, lastEvolutionTime: res.lastEvolutionTime } }) } catch (e) {}
2587
+ try {
2588
+ self.postMessage({
2589
+ type: 'evolved-complete',
2590
+ data: {
2591
+ finishedAt: Date.now(),
2592
+ generation: res.generation,
2593
+ lastEvolutionTime: res.lastEvolutionTime,
2594
+ },
2595
+ });
2596
+ } catch (e) {}
1670
2597
  }
1671
2598
  } catch (e) {}
1672
- return
2599
+ return;
1673
2600
  }
1674
2601
 
1675
2602
  if (type === 'optimize') {
1676
2603
  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
2604
+ const infectionsArr = payload.infections || raw.infections || [];
2605
+ const infections = new Map<number, Infection>(infectionsArr);
2606
+ const positions = payload.positions ?? raw.positions;
2607
+ const hexRadius =
2608
+ typeof payload.hexRadius === 'number'
2609
+ ? payload.hexRadius
2610
+ : typeof raw.hexRadius === 'number'
2611
+ ? raw.hexRadius
2612
+ : 16;
2613
+ postOptimizationMerge(
2614
+ infections,
2615
+ positions,
2616
+ hexRadius,
2617
+ !!workerDebug.mergeLogs
2618
+ );
2619
+ try {
2620
+ self.postMessage({
2621
+ type: 'optimized',
2622
+ data: { infections: Array.from(infections.entries()) },
2623
+ });
2624
+ } catch (e) {}
2625
+ } catch (e) {
2626
+ safePostError(e);
2627
+ }
2628
+ return;
1685
2629
  }
1686
- } catch (err) { safePostError(err) }
1687
- }
2630
+ } catch (err) {
2631
+ safePostError(err);
2632
+ }
2633
+ };
1688
2634
 
1689
2635
  // Additional helpers that the optimizer uses (kept separate and consistent)
1690
2636
 
@@ -1695,8 +2641,17 @@ function calculatePhotoContiguityCached(
1695
2641
  hexRadius: number,
1696
2642
  debugLogs: boolean = true
1697
2643
  ): number {
1698
- const photoId = typeof photoIdOrPhoto === 'string' ? photoIdOrPhoto : (photoIdOrPhoto as Photo).id
1699
- return calculatePhotoContiguity(photoId, indices, positions, hexRadius, debugLogs)
2644
+ const photoId =
2645
+ typeof photoIdOrPhoto === 'string'
2646
+ ? photoIdOrPhoto
2647
+ : (photoIdOrPhoto as Photo).id;
2648
+ return calculatePhotoContiguity(
2649
+ photoId,
2650
+ indices,
2651
+ positions,
2652
+ hexRadius,
2653
+ debugLogs
2654
+ );
1700
2655
  }
1701
2656
 
1702
2657
  function calculatePhotoContiguity(
@@ -1706,8 +2661,9 @@ function calculatePhotoContiguity(
1706
2661
  hexRadius: number,
1707
2662
  debugLogs: boolean = true
1708
2663
  ): number {
1709
- const getNeighbors = (index: number) => getNeighborsCached(index, positions, hexRadius)
1710
- return _calculatePhotoContiguity(indices, positions, hexRadius, getNeighbors)
2664
+ const getNeighbors = (index: number) =>
2665
+ getNeighborsCached(index, positions, hexRadius);
2666
+ return _calculatePhotoContiguity(indices, positions, hexRadius, getNeighbors);
1711
2667
  }
1712
2668
 
1713
2669
  function calculateSwappedContiguityCached(
@@ -1720,12 +2676,18 @@ function calculateSwappedContiguityCached(
1720
2676
  infections: Map<number, Infection>,
1721
2677
  debugLogs: boolean = true
1722
2678
  ): 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)
2679
+ const tempIndices = [...indices];
2680
+ const fromPos = tempIndices.indexOf(fromIndex);
2681
+ const toPos = tempIndices.indexOf(toIndex);
2682
+ if (fromPos !== -1) tempIndices[fromPos] = toIndex;
2683
+ if (toPos !== -1) tempIndices[toPos] = fromIndex;
2684
+ return calculatePhotoContiguity(
2685
+ photoId,
2686
+ tempIndices,
2687
+ positions,
2688
+ hexRadius,
2689
+ debugLogs
2690
+ );
1729
2691
  }
1730
2692
 
1731
2693
  function analyzeLocalEnvironment(
@@ -1736,72 +2698,84 @@ function analyzeLocalEnvironment(
1736
2698
  radius: number = 2,
1737
2699
  debugLogs: boolean = true
1738
2700
  ) {
1739
- const centerPos = positions[centerIndex]
1740
- const localIndices: number[] = []
1741
- const visited = new Set<number>()
1742
- const queue: Array<[number, number]> = [[centerIndex, 0]]
2701
+ const centerPos = positions[centerIndex];
2702
+ const localIndices: number[] = [];
2703
+ const visited = new Set<number>();
2704
+ const queue: Array<[number, number]> = [[centerIndex, 0]];
1743
2705
 
1744
2706
  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)
2707
+ const [currentIndex, distance] = queue.shift()!;
2708
+ if (visited.has(currentIndex) || distance > radius) continue;
2709
+ visited.add(currentIndex);
2710
+ localIndices.push(currentIndex);
1749
2711
  if (distance < radius) {
1750
- const neighbors = getNeighborsCached(currentIndex, positions, hexRadius)
2712
+ const neighbors = getNeighborsCached(currentIndex, positions, hexRadius);
1751
2713
  for (const neighborIndex of neighbors) {
1752
- if (!visited.has(neighborIndex)) queue.push([neighborIndex, distance + 1])
2714
+ if (!visited.has(neighborIndex))
2715
+ queue.push([neighborIndex, distance + 1]);
1753
2716
  }
1754
2717
  }
1755
2718
  }
1756
2719
 
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
2720
+ let infectedCount = 0;
2721
+ const photoCounts = new Map<string, number>();
2722
+ const clusterSizes = new Map<string, number>();
2723
+ let boundaryPressure = 0;
2724
+ let totalVariance = 0;
1762
2725
 
1763
2726
  for (const index of localIndices) {
1764
- const infection = infections.get(index)
2727
+ const infection = infections.get(index);
1765
2728
  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)
2729
+ infectedCount++;
2730
+ const photoId = infection.photo.id;
2731
+ photoCounts.set(photoId, (photoCounts.get(photoId) || 0) + 1);
2732
+ clusterSizes.set(photoId, (clusterSizes.get(photoId) || 0) + 1);
1770
2733
  } else {
1771
- boundaryPressure += 0.1
2734
+ boundaryPressure += 0.1;
1772
2735
  }
1773
2736
  }
1774
2737
 
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)
2738
+ const totalPhotos = photoCounts.size;
2739
+ const avgPhotoCount = infectedCount / Math.max(totalPhotos, 1);
2740
+ for (const count of photoCounts.values())
2741
+ totalVariance += Math.pow(count - avgPhotoCount, 2);
2742
+ const localVariance = totalVariance / Math.max(infectedCount, 1);
1779
2743
 
1780
- let dominantPhoto: Photo | null = null
1781
- let maxCount = 0
2744
+ let dominantPhoto: Photo | null = null;
2745
+ let maxCount = 0;
1782
2746
  for (const [photoId, count] of photoCounts) {
1783
2747
  if (count > maxCount) {
1784
- maxCount = count
2748
+ maxCount = count;
1785
2749
  for (const infection of infections.values()) {
1786
- if (infection.photo.id === photoId) { dominantPhoto = infection.photo; break }
2750
+ if (infection.photo.id === photoId) {
2751
+ dominantPhoto = infection.photo;
2752
+ break;
2753
+ }
1787
2754
  }
1788
2755
  }
1789
2756
  }
1790
2757
 
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 }
2758
+ const density = infectedCount / Math.max(localIndices.length, 1);
2759
+ const stability = dominantPhoto ? maxCount / Math.max(infectedCount, 1) : 0;
2760
+
2761
+ return {
2762
+ density,
2763
+ stability,
2764
+ dominantPhoto,
2765
+ clusterSizes,
2766
+ boundaryPressure,
2767
+ localVariance,
2768
+ };
1795
2769
  }
1796
2770
 
1797
2771
  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
2772
+ cache.neighborMap.clear();
2773
+ cache.gridBounds = null;
2774
+ cache.photoClusters.clear();
2775
+ cache.connectedComponents.clear();
2776
+ cache.gridPositions.clear();
2777
+ cache.cacheReady = false;
2778
+ if (typeof isSpherical === 'boolean') cache.isSpherical = isSpherical;
1805
2779
  }
1806
2780
 
1807
- console.log('[hexgrid-worker] ready')
2781
+ console.log('[hexgrid-worker] ready');