(markets: ProcessedMarket[])
| 159 | // Uses FIXED geographic offsets — zoom-in naturally separates bubbles via map projection. |
| 160 | // No zoom dependency: avoids the "separate then collapse" problem of recalculating on zoom. |
| 161 | function offsetColocated(markets: ProcessedMarket[]): ProcessedMarket[] { |
| 162 | // Group nearby markets using a grid with 0.5° cells |
| 163 | const cellSize = 0.5; |
| 164 | const groups = new Map<string, ProcessedMarket[]>(); |
| 165 | for (const m of markets) { |
| 166 | if (!m.coords) continue; |
| 167 | const key = `${Math.floor(m.coords[0] / cellSize)},${Math.floor(m.coords[1] / cellSize)}`; |
| 168 | const arr = groups.get(key) || []; |
| 169 | arr.push(m); |
| 170 | groups.set(key, arr); |
| 171 | } |
| 172 | |
| 173 | const goldenAngle = Math.PI * (3 - Math.sqrt(5)); |
| 174 | |
| 175 | const result: ProcessedMarket[] = []; |
| 176 | for (const m of markets) { |
| 177 | if (!m.coords) { |
| 178 | result.push(m); |
| 179 | continue; |
| 180 | } |
| 181 | const key = `${Math.floor(m.coords[0] / cellSize)},${Math.floor(m.coords[1] / cellSize)}`; |
| 182 | const group = groups.get(key)!; |
| 183 | if (group.length <= 1) { |
| 184 | result.push(m); |
| 185 | continue; |
| 186 | } |
| 187 | const idx = group.indexOf(m); |
| 188 | // Tighter spacing to keep bubbles near their country centroid. |
| 189 | const spacing = group.length > 50 ? 0.4 : group.length > 15 ? 0.3 : group.length > 5 ? 0.2 : 0.15; |
| 190 | // Start from idx+1 so first item is NOT at center (avoids pile-up at origin) |
| 191 | const n = idx + 1; |
| 192 | const angle = n * goldenAngle; |
| 193 | const r = spacing * Math.sqrt(n); |
| 194 | const offsetLat = r * Math.cos(angle); |
| 195 | const offsetLng = r * Math.sin(angle); |
| 196 | result.push({ |
| 197 | ...m, |
| 198 | coords: [m.coords[0] + offsetLat, m.coords[1] + offsetLng] as [number, number], |
| 199 | }); |
| 200 | } |
| 201 | return result; |
| 202 | } |
| 203 | |
| 204 | function setsEqual<T>(a?: Set<T>, b?: Set<T>): boolean { |
| 205 | if (a === b) return true; |
no test coverage detected