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