File size: 11,392 Bytes
0dc7194 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 | /**
* Modulo de visualizacion del mapa mundial interactivo con Leaflet.js.
*
* Responsabilidades:
* - init(containerId, markets, signals, onSelect) → renderiza mapa con burbujas.
* - updateBubble(marketId, newPrice) → ajusta radio de la burbuja.
* - highlightMarket(marketId) → resalta burbuja y abre popup.
*
* Burbujas:
* - Color = senal IA (verde bullish, rojo bearish, naranja neutral).
* - Radio = volumen del mercado.
* - Label = countryCode (ISO2).
*
* Seguridad:
* - Todos los textos del popup se crean con textContent (evita XSS).
* - No se usa innerHTML con datos externos.
*
* Tile layer: CartoDB Dark Matter (requiere conexion a internet).
*
* Consumido por:
* - app.js → init() y eventos de socket.io (market_update, ai_signal).
*/
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { getCoordsByCode, detectCountryInText } from './capitals.js'
let mapInstance = null
let bubbles = {} // marketId -> marcador de circulo
// Hubs globales para mercados sin pais. Cubre TODOS los continentes para
// que las bubbles no se concentren en US/EU/Asia. Cada hub tiene tambien un
// "spread" propio para que multiples markets en el mismo hub se repartan.
const FINANCIAL_HUBS = [
// America del Norte
[40.71, -74.0], // Nueva York
[37.77, -122.42], // San Francisco
[41.88, -87.63], // Chicago
[43.65, -79.38], // Toronto
[19.43, -99.13], // Ciudad de Mexico
// America Latina
[-23.55, -46.63], // Sao Paulo
[-34.61, -58.38], // Buenos Aires
[-33.45, -70.66], // Santiago de Chile
[4.71, -74.07], // Bogota
[10.49, -66.88], // Caracas
// Europa
[51.5, -0.13], // Londres
[50.11, 8.68], // Frankfurt
[48.85, 2.35], // Paris
[40.42, -3.7], // Madrid
[41.9, 12.5], // Roma
[47.37, 8.54], // Zurich
[52.37, 4.9], // Amsterdam
[55.75, 37.62], // Moscu
[50.45, 30.52], // Kyiv
[41.0, 28.98], // Estambul
// Africa
[-26.2, 28.05], // Johannesburgo
[6.45, 3.4], // Lagos
[30.04, 31.24], // Cairo
[-1.29, 36.82], // Nairobi
[33.97, -6.85], // Rabat
// Oriente Medio
[25.2, 55.27], // Dubai
[24.71, 46.68], // Riad
[32.07, 34.78], // Tel Aviv
// Asia
[22.3, 114.17], // Hong Kong
[35.68, 139.69], // Tokio
[1.35, 103.82], // Singapur
[31.23, 121.47], // Shanghai
[19.08, 72.88], // Mumbai
[37.57, 126.98], // Seul
[13.76, 100.5], // Bangkok
[-6.21, 106.85], // Yakarta
[25.03, 121.56], // Taipei
// Oceania
[-33.87, 151.21], // Sydney
[-37.81, 144.96], // Melbourne
[-36.85, 174.76], // Auckland
]
// Hash determinista para jitter reproducible por marketId
function hashCode(str) {
let h = 0
const s = String(str)
for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0
return Math.abs(h)
}
// Tamano aproximado de cada pais (radio en grados, lat/lng).
// Paises grandes → mas jitter para repartir bubbles por su territorio.
// Default 3.5° si no esta listado.
// Bounding-box approximado de cada pais (semi-eje lat, semi-eje lng en grados).
// Paises grandes → caja grande para repartir las bubbles por todo el territorio.
const COUNTRY_JITTER = {
US: [14, 32], // EEUU: NY (-74) a LA (-118), TX (29) a MN (49)
RU: [12, 55], // Rusia: cruza 11 husos horarios
CA: [11, 35],
CN: [13, 25],
BR: [13, 18],
AU: [11, 22],
IN: [10, 14],
AR: [14, 10],
MX: [8, 13],
KZ: [7, 22],
DZ: [10, 11],
SA: [9, 11],
CD: [9, 9],
GB: [4, 4],
DE: [4, 5],
FR: [5, 5],
ES: [5, 6],
IT: [5, 5],
JP: [6, 8],
TR: [4, 9],
IR: [7, 9],
IL: [1.2, 1.2],
UA: [5, 9],
KR: [2.5, 2.5],
ZA: [6, 8],
NG: [5, 6],
EG: [5, 6],
ID: [6, 22],
}
// Distribucion UNIFORME en un rectangulo (no en elipse polar, que clumpea
// hacia el centro). Cada market id mapea a un (dx,dy) deterministico que
// cubre toda la bounding box del pais.
function jitter(coords, marketId, amount = 4) {
const h = hashCode(marketId)
const [latR, lngR] = Array.isArray(amount) ? amount : [amount, amount]
// Dos canales de 16 bits del hash → coordenadas uniformes en [-1, 1]
const u = ((h & 0xFFFF) / 0xFFFF) * 2 - 1
const v = (((h >> 16) & 0xFFFF) / 0xFFFF) * 2 - 1
return [coords[0] + v * latR, coords[1] + u * lngR]
}
function pickFinancialHub(marketId) {
const idx = hashCode(marketId) % FINANCIAL_HUBS.length
return FINANCIAL_HUBS[idx]
}
function getCoords(market) {
// 1. countryCode ISO2 explicito + jitter proporcional al tamano del pais
const byCode = getCoordsByCode(market.countryCode)
if (byCode) {
const amount = COUNTRY_JITTER[market.countryCode] || 3.5
return jitter(byCode, market.id, amount)
}
// 2. heuristica de texto: detectamos el codigo y reaplicamos el lookup
const byText = detectCountryInText(market.question)
if (byText) return jitter(byText, market.id, 4)
// 3. mercado sin pais: distribuir entre hubs financieros globales
return jitter(pickFinancialHub(market.id), market.id, 3)
}
function getSignalColor(signal) {
if (signal === 'bullish') return '#22d37a'
if (signal === 'bearish') return '#f04040'
return '#f0a020'
}
function getRadius(volumeEur) {
const v = volumeEur || 0
if (v > 2e6) return 18
if (v > 1e6) return 14
if (v > 500000) return 11
if (v > 200000) return 8
return 6
}
export function init(containerId, markets, signals, onSelect) {
window.__onSelectMarket = onSelect
const container = document.getElementById(containerId)
if (!container) return
mapInstance = L.map(container, {
zoomControl: false,
attributionControl: false,
minZoom: 2,
maxZoom: 6,
worldCopyJump: true,
}).setView([25, 10], 2)
// Capa de tiles oscura (CartoDB Dark Matter)
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '©OpenStreetMap ©CartoDB',
subdomains: 'abcd',
maxZoom: 19,
}).addTo(mapInstance)
markets.forEach((m) => {
const sig = signals.find((s) => s.marketId === m.id) || { signal: 'neutral' }
const color = getSignalColor(sig.signal)
const coords = getCoords(m)
const radius = getRadius(m.volumeEur)
const circle = L.circleMarker(coords, {
radius,
fillColor: color,
color: color,
weight: 1.5,
opacity: 0.6,
fillOpacity: 0.22,
}).addTo(mapInstance)
const inner = L.circleMarker(coords, {
radius: Math.max(3, radius * 0.45),
fillColor: color,
color: 'transparent',
fillOpacity: 0.8,
interactive: false,
}).addTo(mapInstance)
// Construye texto de etiqueta de forma segura via textContent antes de obtener outerHTML
const labelSpan = document.createElement('span')
labelSpan.className = 'map-label-text'
labelSpan.textContent = m.countryCode || 'GL'
const label = L.marker(coords, {
icon: L.divIcon({
className: 'map-label',
html: labelSpan.outerHTML,
iconSize: [40, 14],
iconAnchor: [20, -radius - 4],
}),
interactive: false,
}).addTo(mapInstance)
// Construye DOM del popup — textContent previene cualquier inyeccion HTML desde datos del mercado
const popup = document.createElement('div')
popup.className = 'map-popup'
const popupCat = document.createElement('div')
popupCat.className = 'map-popup-cat'
popupCat.textContent = `${m.category || 'General'} · ${m.countryCode || 'GL'}`
const popupQ = document.createElement('div')
popupQ.className = 'map-popup-q'
popupQ.textContent = m.question
const popupPrices = document.createElement('div')
popupPrices.className = 'map-popup-prices'
const yesSpan = document.createElement('span')
yesSpan.className = 'text-green'
yesSpan.textContent = `SÍ ${Math.round((m.yesPrice || 0) * 100)}¢`
const noSpan = document.createElement('span')
noSpan.className = 'text-red'
noSpan.textContent = `NO ${Math.round((m.noPrice || 0) * 100)}¢`
popupPrices.append(yesSpan, noSpan)
popup.append(popupCat, popupQ, popupPrices)
circle.bindPopup(popup, { closeButton: false, offset: [0, -4] })
circle.on('click', () => {
onSelect(m.id)
})
bubbles[m.id] = { circle, inner, label, color }
})
}
export function updateBubble(marketId) {
const b = bubbles[marketId]
if (!b) return
// Ajusta ligeramente el radio basado en nueva actividad (comportamiento simulado)
const newRadius = b.circle.getRadius() + (Math.random() > 0.5 ? 0.5 : -0.5)
b.circle.setRadius(Math.max(5, Math.min(22, newRadius)))
}
export function highlightMarket(marketId) {
Object.values(bubbles).forEach((b) => {
b.circle.setStyle({ weight: 1.5, opacity: 0.6 })
})
const b = bubbles[marketId]
if (b) {
b.circle.setStyle({ weight: 3, opacity: 1, color: '#4a9eff' })
b.circle.openPopup()
}
}
export function updateMarkers(markets, signals) {
if (!mapInstance) return
// Limpiar burbujas existentes
Object.values(bubbles).forEach((b) => {
mapInstance.removeLayer(b.circle)
mapInstance.removeLayer(b.inner)
mapInstance.removeLayer(b.label)
})
bubbles = {}
// Re-renderizar solo los mercados filtrados
markets.forEach((m) => {
const sig = signals.find((s) => s.marketId === m.id) || { signal: 'neutral' }
const color = getSignalColor(sig.signal)
const coords = getCoords(m)
const radius = getRadius(m.volumeEur)
const circle = L.circleMarker(coords, {
radius,
fillColor: color,
color: color,
weight: 1.5,
opacity: 0.6,
fillOpacity: 0.22,
}).addTo(mapInstance)
const inner = L.circleMarker(coords, {
radius: Math.max(3, radius * 0.45),
fillColor: color,
color: 'transparent',
fillOpacity: 0.8,
interactive: false,
}).addTo(mapInstance)
const labelSpan = document.createElement('span')
labelSpan.className = 'map-label-text'
labelSpan.textContent = m.countryCode || 'GL'
const label = L.marker(coords, {
icon: L.divIcon({
className: 'map-label',
html: labelSpan.outerHTML,
iconSize: [40, 14],
iconAnchor: [20, -radius - 4],
}),
interactive: false,
}).addTo(mapInstance)
const popup = document.createElement('div')
popup.className = 'map-popup'
const popupCat = document.createElement('div')
popupCat.className = 'map-popup-cat'
popupCat.textContent = `${m.category || 'General'} · ${m.countryCode || 'GL'}`
const popupQ = document.createElement('div')
popupQ.className = 'map-popup-q'
popupQ.textContent = m.question
const popupPrices = document.createElement('div')
popupPrices.className = 'map-popup-prices'
const yesSpan = document.createElement('span')
yesSpan.className = 'text-green'
yesSpan.textContent = `SÍ ${Math.round((m.yesPrice || 0) * 100)}¢`
const noSpan = document.createElement('span')
noSpan.className = 'text-red'
noSpan.textContent = `NO ${Math.round((m.noPrice || 0) * 100)}¢`
popupPrices.append(yesSpan, noSpan)
popup.append(popupCat, popupQ, popupPrices)
circle.bindPopup(popup, { closeButton: false, offset: [0, -4] })
circle.on('click', () => {
if (window.__onSelectMarket) window.__onSelectMarket(m.id)
})
bubbles[m.id] = { circle, inner, label, color }
})
}
|