| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import L from 'leaflet' |
| import 'leaflet/dist/leaflet.css' |
| import { getCoordsByCode, detectCountryInText } from './capitals.js' |
|
|
| let mapInstance = null |
| let bubbles = {} |
|
|
| |
| |
| |
| const FINANCIAL_HUBS = [ |
| |
| [40.71, -74.0], |
| [37.77, -122.42], |
| [41.88, -87.63], |
| [43.65, -79.38], |
| [19.43, -99.13], |
| |
| [-23.55, -46.63], |
| [-34.61, -58.38], |
| [-33.45, -70.66], |
| [4.71, -74.07], |
| [10.49, -66.88], |
| |
| [51.5, -0.13], |
| [50.11, 8.68], |
| [48.85, 2.35], |
| [40.42, -3.7], |
| [41.9, 12.5], |
| [47.37, 8.54], |
| [52.37, 4.9], |
| [55.75, 37.62], |
| [50.45, 30.52], |
| [41.0, 28.98], |
| |
| [-26.2, 28.05], |
| [6.45, 3.4], |
| [30.04, 31.24], |
| [-1.29, 36.82], |
| [33.97, -6.85], |
| |
| [25.2, 55.27], |
| [24.71, 46.68], |
| [32.07, 34.78], |
| |
| [22.3, 114.17], |
| [35.68, 139.69], |
| [1.35, 103.82], |
| [31.23, 121.47], |
| [19.08, 72.88], |
| [37.57, 126.98], |
| [13.76, 100.5], |
| [-6.21, 106.85], |
| [25.03, 121.56], |
| |
| [-33.87, 151.21], |
| [-37.81, 144.96], |
| [-36.85, 174.76], |
| ] |
|
|
| |
| 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) |
| } |
|
|
| |
| |
| |
| |
| |
| const COUNTRY_JITTER = { |
| US: [14, 32], |
| RU: [12, 55], |
| 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], |
| } |
|
|
| |
| |
| |
| function jitter(coords, marketId, amount = 4) { |
| const h = hashCode(marketId) |
| const [latR, lngR] = Array.isArray(amount) ? amount : [amount, amount] |
| |
| 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) { |
| |
| const byCode = getCoordsByCode(market.countryCode) |
| if (byCode) { |
| const amount = COUNTRY_JITTER[market.countryCode] || 3.5 |
| return jitter(byCode, market.id, amount) |
| } |
|
|
| |
| const byText = detectCountryInText(market.question) |
| if (byText) return jitter(byText, market.id, 4) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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', () => { |
| onSelect(m.id) |
| }) |
|
|
| bubbles[m.id] = { circle, inner, label, color } |
| }) |
| } |
|
|
| export function updateBubble(marketId) { |
| const b = bubbles[marketId] |
| if (!b) return |
| |
| 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 |
|
|
| |
| Object.values(bubbles).forEach((b) => { |
| mapInstance.removeLayer(b.circle) |
| mapInstance.removeLayer(b.inner) |
| mapInstance.removeLayer(b.label) |
| }) |
| bubbles = {} |
|
|
| |
| 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 } |
| }) |
| } |
|
|