// WorldMapGL — MapLibre GL (flat) + globe.gl (globe) // mapMode="flat" → Mercator, renderWorldCopies:false // mapMode="globe" → globe.gl 3D sphere, drag-to-rotate, night-sky bg, projected chips // Globe mode uses an earth-night texture for continents (light & cached by the // browser after first paint) instead of 200 tessellated country polygons. // This replaces ~800KB of GeoJSON + ~500ms of main-thread triangulation // with a single ~150KB image the GPU handles. // ── Global chip sets (used by imperative position loops) ────────────────── // Three tiers of chip visibility: // alwaysSet (zoomed out, ~0-33%): G7 + BRICS + a handful of regional anchors — ~14 chips, uncluttered // midSet (zoomed ~33-66%): adds top-40 economies — ~30 chips, still readable // all (zoomed 66%+): every country with a chip ref window.__chipAlwaysSet = new Set([ "US","CA","BR","GB","DE","FR","RU","CN","JP","IN","AU","ZA","TR","SA" ]); window.__chipMidSet = new Set([ // alwaysSet "US","CA","BR","GB","DE","FR","RU","CN","JP","IN","AU","ZA","TR","SA", // + second-tier economies & regional anchors "MX","AR","CL","CO","PE","ES","IT","CH","SE","NO","PL","UA","RO","HU", "AE","IL","EG","NG","KE","MA","KR","ID","TH","VN","MY","SG","PH","HK","PK","NZ","BD" ]); // ── O(1) country lookups — built once, used in hot loops ────────────────── window.__countryByCode = {}; window.__countryByCcy = {}; if (window.COUNTRIES) { window.COUNTRIES.forEach(function(c) { window.__countryByCode[c.code] = c; window.__countryByCcy[c.ccy] = c; }); } // Pre-compute arrays of country objects for each tier so hot loops can // iterate without allocating (no Array.from / .map / .filter each frame). window.__chipAlwaysArr = []; window.__chipMidArr = []; if (window.COUNTRIES) { window.COUNTRIES.forEach(function(c) { if (window.__chipAlwaysSet.has(c.code)) window.__chipAlwaysArr.push(c); if (window.__chipMidSet.has(c.code)) window.__chipMidArr.push(c); }); } function WorldMapGL({ base, hovered, onHoverCountry, onLeaveCountry, onClickCountry, search, mapMode, mapApiRef, }) { mapMode = mapMode || "flat"; const containerRef = React.useRef(null); const mapRef = React.useRef(null); const overlayRef = React.useRef(null); const [ready, setReady] = React.useState(false); const [spaceOpacity, setSpaceOpacity] = React.useState(1); // mapModeRef — lets the flat render callback read current mapMode without stale closure const mapModeRef = React.useRef(mapMode); React.useEffect(function() { mapModeRef.current = mapMode; // On mode switch: clear ALL chip positions so ghosts from the other mode // don't linger. The active mode's loop will re-position its chips next tick. var refs = window.__chipRefs || {}; var codes = Object.keys(refs); for (var i = 0; i < codes.length; i++) { refs[codes[i]].style.transform = 'translate3d(-9999px,-9999px,0)'; } // Invalidate cached key so flat tick re-projects on next render if (window.__resetFlatKey) window.__resetFlatKey(); }, [mapMode]); const mapStyle = React.useMemo(function() { return buildMapStyle(); }, []); // ── Init MapLibre once ───────────────────────────────────────────────────── React.useEffect(function() { if (!window.maplibregl) { console.error("maplibregl not loaded"); return; } var map = new window.maplibregl.Map({ container: containerRef.current, style: mapStyle, center: [10, 20], zoom: 1.4, minZoom: 0.8, maxZoom: 10, renderWorldCopies: false, attributionControl: false, dragRotate: false, pitchWithRotate: false, touchPitch: false, fadeDuration: 120, antialias: false, // fix #2: kill MSAA overhead }); mapRef.current = map; // Silence known-benign MapLibre style-race errors; log anything else map.on("error", function(ev) { var msg = ev && ev.error && ev.error.message || ''; if (msg.indexOf("non-existing layer") !== -1) return; if (msg.indexOf("does not exist in the map's style") !== -1) return; console.warn("MapLibre:", msg); }); if (window.deck && window.deck.MapboxOverlay) { var overlay = new window.deck.MapboxOverlay({ interleaved: true, layers: [] }); map.addControl(overlay); overlayRef.current = overlay; } // Direct DOM chip updater — runs synchronously on MapLibre render event. // Bypasses React scheduler entirely: chip transforms update in the same JS // task as MapLibre's WebGL frame → zero lag, zero wiggle. var lastFlatKey = ''; var flatPrevPositioned = {}; function updateFlatDom() { // Tick runs for BOTH flat and globe now — MapLibre's setProjection handles the sphere, // map.project([lng,lat]) is projection-aware so chip positioning just works. var m = mapRef.current; if (!m) return; var rect = containerRef.current && containerRef.current.getBoundingClientRect(); if (!rect || !rect.width) return; var zoom = m.getZoom(); var g = window.__chipGlobals || {}; // Skip if nothing affecting chip positions has changed var ctr = m.getCenter(); var key = (ctr.lng*100|0)+','+(ctr.lat*100|0)+','+(zoom*10|0)+'|'+(g.base||'')+'|'+(g.hovered||'')+'|'+(g.search||'')+'|'+(g.hasFilter?'F':'A'); if (key === lastFlatKey) return; lastFlatKey = key; window.__chipZoom = zoom; // expose for React opacity logic in atlas_gl // Fire tier listener when zoom crosses a threshold — so chipShouldShow re-runs var newTier = zoom >= 10 ? 3 : zoom >= 3.56 ? 2 : 1; if (newTier !== window.__chipTier) { window.__chipTier = newTier; if (window.__atlasTierListener) window.__atlasTierListener(); } var isMobileLayout = window.innerWidth < 640; // zoom range: 0.8 (min) → 10 (max). Two tiers: // desktop: 0-29% majors → 30%+ all chips (threshold 3.56) // mobile: 0-24% majors → 25%+ all chips (threshold 3.10) var showAll = zoom >= (isMobileLayout ? 3.10 : 3.56); var refs = window.__chipRefs || {}; var s = (g.search || '').toLowerCase(); var countries = window.COUNTRIES; // Two-pass rendering with collision: priority chips first, others only if they don't overlap. // Chip footprint: desktop ~88×34, mobile ~100×44 (larger tap target). var CHIP_W = isMobileLayout ? 100 : 88; var CHIP_H = isMobileLayout ? 44 : 34; // Pad collision test slightly so chips don't kiss edges var PAD = 4; var placed = []; // {x1,y1,x2,y2} function overlaps(x, y) { var x1 = x - PAD, y1 = y - PAD, x2 = x + CHIP_W + PAD, y2 = y + CHIP_H + PAD; for (var k = 0; k < placed.length; k++) { var r2 = placed[k]; if (x2 < r2.x1 || x1 > r2.x2 || y2 < r2.y1 || y1 > r2.y2) continue; return true; } return false; } function priorityOf(c, searchMatch) { if (g.base === c.ccy) return 4; if (g.hovered === c.code) return 3; if (searchMatch) return 2; if (window.__chipAlwaysSet.has(c.code)) return 1; return 0; } // Build a priority-ordered candidate list var candidates = []; for (var j = 0; j < countries.length; j++) { var c = countries[j]; var el = refs[c.code]; if (!el) continue; var p = m.project([c.lon, c.lat]); // Globe horizon culling: back-of-sphere points still project inside the disc. // Use angular distance from map center — if > 85° the point is on the hidden hemisphere. var isGlobe = mapModeRef.current === 'globe'; var onscreen; if (isGlobe) { var mc = m.getCenter(); var lat1 = mc.lat * Math.PI / 180, lat2 = c.lat * Math.PI / 180; var dLng = (c.lon - mc.lng) * Math.PI / 180; var cosAngle = Math.sin(lat1)*Math.sin(lat2) + Math.cos(lat1)*Math.cos(lat2)*Math.cos(dLng); // cos(72°) ≈ 0.309 — only show chips whose centroid is clearly on the near hemisphere. // Tighter than 85° to prevent limb flicker (e.g. JPY showing when viewing N America). onscreen = cosAngle > 0.309; } else { onscreen = p.x > -100 && p.x < rect.width+100 && p.y > -100 && p.y < rect.height+100; } if (!onscreen) continue; var searchMatch = s && (c.code.toLowerCase().indexOf(s) !== -1 || c.ccy.toLowerCase().indexOf(s) !== -1 || c.name.toLowerCase().indexOf(s) !== -1); var isBaseCcy = g.base === c.ccy; // Explicit filter bypasses zoom tiers — user picked these countries, always show them. var show = g.hasFilter || showAll || isBaseCcy || (g.hovered === c.code) || searchMatch || window.__chipAlwaysSet.has(c.code); if (!show) continue; candidates.push({ c: c, el: el, x: p.x + 10, y: p.y - 40, pri: priorityOf(c, searchMatch) }); } candidates.sort(function(a, b){ return b.pri - a.pri; }); var flatNowPositioned = {}; for (var q = 0; q < candidates.length; q++) { var cand = candidates[q]; // Priority ≥2 (base/hovered/search) always shown — no collision check // Also skip collision when showAll or hasFilter is active — user wants every visible country rendered if (showAll || g.hasFilter || cand.pri >= 2 || !overlaps(cand.x, cand.y)) { // Integer-floor: GPU compositor fast-path on pixel-aligned transforms var cx = cand.x|0, cy = cand.y|0; cand.el.style.transform = 'translate3d('+cx+'px,'+cy+'px,0)'; placed.push({ x1: cand.x, y1: cand.y, x2: cand.x + CHIP_W, y2: cand.y + CHIP_H }); flatNowPositioned[cand.c.code] = true; } } // Off-screen only chips that WERE positioned last frame but aren't now var fpKeys = Object.keys(flatPrevPositioned); for (var fpi = 0; fpi < fpKeys.length; fpi++) { if (!flatNowPositioned[fpKeys[fpi]]) { var felOff = refs[fpKeys[fpi]]; if (felOff) felOff.style.transform = 'translate3d(-9999px,-9999px,0)'; } } flatPrevPositioned = flatNowPositioned; } // Expose so atlas_gl can trigger initial positioning after React commits refs window.__updateFlatDom = updateFlatDom; window.__resetFlatKey = function() { lastFlatKey = ''; flatPrevPositioned = {}; }; map.on("load", function() { setReady(true); // Force a render after load so updateFlatDom fires once all tiles are ready map.triggerRepaint(); if (mapApiRef) { mapApiRef.current = { flyTo: function(opts) { map.flyTo(opts); }, zoomIn: function() { map.zoomIn(); }, zoomOut: function() { map.zoomOut(); }, reset: function() { map.flyTo({ center: [10, 20], zoom: 1.4, duration: 900 }); }, getZoom: function() { return map.getZoom(); }, }; } }); map.on("render", updateFlatDom); // Fade space chrome (moon/stars/satellites) as user zooms in — at ground level there's no sky map.on("zoom", function() { var z = map.getZoom(); var mob = window.innerWidth < 640; // Mobile: fade much earlier since viewport is tight — full at ≤1.4, gone by 2.2 // Desktop: full at ≤1.8, gone by 3.2 var zMin = mob ? 1.4 : 1.8; var zMax = mob ? 2.2 : 3.2; var o = z <= zMin ? 1 : z >= zMax ? 0 : 1 - (z - zMin) / (zMax - zMin); setSpaceOpacity(o); }); return function() { map.off("render", updateFlatDom); map.remove(); mapRef.current = null; overlayRef.current = null; setReady(false); }; }, []); // eslint-disable-line // ── Resize MapLibre when returning to flat ───────────────────────────────── React.useEffect(function() { if (mapMode !== "flat") return; var map = mapRef.current; if (!map) return; var t = setTimeout(function() { try { map.resize(); if (window.__resetFlatKey) window.__resetFlatKey(); // reset AFTER resize so next render uses correct dimensions map.triggerRepaint(); } catch(e) {} }, 60); return function() { clearTimeout(t); }; }, [mapMode]); // ── Base highlight via MapLibre paint expressions (flat + globe) ───────── // Previously used deck.gl but it doesn't project onto the sphere. MapLibre's // native circle layer with feature-state + match expression handles both. React.useEffect(function() { var m = mapRef.current; if (!m || !ready) return; try { // Enlarge + recolor dot for base country m.setPaintProperty('country-dots', 'circle-radius', [ "interpolate",["linear"],["zoom"], 0.5, ["case", ["==", ["get","ccy"], base], 5, 1.8], 3, ["case", ["==", ["get","ccy"], base], 6.5, 2.6], 6, ["case", ["==", ["get","ccy"], base], 8.5, 3.4] ]); m.setPaintProperty('country-dots', 'circle-color', [ "case", ["==", ["get","ccy"], base], "#e2a23f", "#fde68a" ]); m.setPaintProperty('country-dots', 'circle-stroke-color', [ "case", ["==", ["get","ccy"], base], "rgba(226,162,63,0.5)", "rgba(251,191,36,0.7)" ]); m.setPaintProperty('country-dots', 'circle-stroke-width', [ "case", ["==", ["get","ccy"], base], 4, 1.2 ]); // Enlarge the glow halo for the base country too m.setPaintProperty('country-dots-glow', 'circle-radius', [ "interpolate",["linear"],["zoom"], 0.5, ["case", ["==", ["get","ccy"], base], 16, 7], 3, ["case", ["==", ["get","ccy"], base], 22, 10], 6, ["case", ["==", ["get","ccy"], base], 30, 14] ]); m.setPaintProperty('country-dots-glow', 'circle-opacity', [ "case", ["==", ["get","ccy"], base], 0.35, 0.18 ]); } catch(e) {} // Clear any leftover deck.gl layers (no longer used) if (overlayRef.current) { try { overlayRef.current.setProps({ layers: [] }); } catch(e) {} } }, [base, ready, mapMode]); // ── Pulsing halo on base-currency dot ──────────────────────────────────── // Soft "breathing" glow at 2.5s cadence. Draws the eye to the anchor // currency without being distracting. Amber, matches existing glow palette. React.useEffect(function() { var m = mapRef.current; if (!m || !ready) return; try { m.setFilter('base-pulse', ["==", ["get","ccy"], base]); } catch(e) { return; } var raf; var t0 = performance.now(); var tick = function() { var mm = mapRef.current; if (!mm) return; var t = (performance.now() - t0) / 1000; // Sine wave period ≈ 2.5s. Radius breathes 18→36, opacity 0.12→0.35. var phase = 0.5 + 0.5 * Math.sin((t * Math.PI * 2) / 2.5); var zoom = mm.getZoom() || 1.4; // Scale pulse with zoom so it's visible at low zoom but not huge at high zoom var zoomScale = 0.8 + 0.6 * Math.min(1, Math.max(0, (zoom - 0.8) / 4)); var r = (18 + 18 * phase) * zoomScale; var o = 0.12 + 0.23 * phase; try { mm.setPaintProperty('base-pulse', 'circle-radius', r); mm.setPaintProperty('base-pulse', 'circle-opacity', o); } catch(e) {} raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return function() { if (raf) cancelAnimationFrame(raf); }; }, [base, ready]); // ── MapLibre projection toggle (flat mercator ⇄ globe) ──────────────────── // MapLibre v5 has native globe projection — same map instance, same style, // same vector tiles, same chip-position logic. Switching modes is just a // projection change. Atmosphere + stars are built in. React.useEffect(function() { var m = mapRef.current; if (!m || !ready) return; try { m.setProjection( mapMode === 'globe' ? { type: 'globe' } : { type: 'mercator' } ); // Re-center + reset pitch on mode switch if (mapMode === 'globe') { m.easeTo({ center: [10, 20], zoom: 1.4, pitch: 0, bearing: 0, duration: 600 }); } } catch(e) { console.warn('projection toggle failed:', e); } }, [mapMode, ready]); var bgColor = mapMode === "globe" ? "#000008" : "#0e1420"; var isGlobeMode = mapMode === "globe"; return (