@buley/hexgrid-3d 3.0.1 → 3.2.0

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