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