{picks.concat(picks).map(function(code,i){
var c=window.COUNTRIES.find(function(x){ return x.ccy===code; });
var n=c&&noiseRef.current[c.code];
var val=window.RatesService.convert(base,code)*(1+(n?n.v:0))*amount;
var dir=(n?n.dir:null)||((code.charCodeAt(0)+code.charCodeAt(1))%2===0?'up':'down');
return ({amount!==1?amount+' ':''}{base}/{code}{window.formatRate(val)}{dir==='up'?'▲':'▼'});
})}
);
}
// ── CountryLabel ─────────────────────────────────────────────────────────────
// Text-only label positioned beneath each country centroid on the globe.
// Same imperative-transform pattern as ChipCard.
var CountryLabel = React.memo(function CountryLabel(props) {
var c = props.country;
var domRef = React.useRef(null);
React.useLayoutEffect(function() {
var el = domRef.current;
if (!el) return;
if (!window.__labelRefs) window.__labelRefs = {};
el.style.transform = 'translate3d(-9999px,-9999px,0)';
window.__labelRefs[c.code] = el;
return function() { delete window.__labelRefs[c.code]; };
}, []); // eslint-disable-line
return (
{c.name}
);
});
// ── ChipCard ─────────────────────────────────────────────────────────────────
// Separate component so React.useLayoutEffect gives a STABLE ref registration.
// Inline ref callbacks re-fire on every parent re-render (React creates a new
// function reference each time), resetting the imperatively-set transform.
// useLayoutEffect([]) fires exactly once on mount — never clobbers the transform.
var ChipCard = React.memo(function ChipCard(props) {
var c=props.country, visible=props.visible, isBase=props.isBase;
var base=props.base, amount=props.amount, noiseRef=props.noiseRef;
var onEnter=props.onEnter, onLeave=props.onLeave, onClick=props.onClick;
var isMobile=props.isMobile;
var domRef = React.useRef(null);
var valRef = React.useRef(null); // .val text node for direct updates
var arrowRef = React.useRef(null); // ▲/▼ for direct color/content updates
// Register DOM node once on mount — imperative code owns transform from here on
React.useLayoutEffect(function() {
var el = domRef.current;
if (!el) return;
if (!window.__chipRefs) window.__chipRefs = {};
el.style.transform = 'translate3d(-9999px,-9999px,0)';
window.__chipRefs[c.code] = el;
return function() { delete window.__chipRefs[c.code]; };
}, []); // eslint-disable-line
// Imperative noise updater — bypass React, write directly to DOM.
// Called by the noise generator every 4s for ~12 random chips.
React.useEffect(function() {
if (!window.__chipNoiseListeners) window.__chipNoiseListeners = {};
window.__chipNoiseListeners[c.code] = function() {
var valEl = valRef.current, arrEl = arrowRef.current, chipEl = domRef.current;
if (!valEl) return;
var n = noiseRef.current[c.code];
var rate = window.RatesService.convert(base, c.ccy);
var val = rate * (1 + (n ? n.v : 0)) * amount;
var dir = n && n.dir || 'up';
var isUp = dir === 'up';
var justTicked = n && (Date.now() - (n.t || 0) < 200);
valEl.textContent = window.formatRate(val);
valEl.style.color = (n && (Date.now() - (n.t || 0) < 1200)) ? (isUp ? 'var(--up)' : 'var(--down)') : 'var(--fg)';
if (arrEl) {
arrEl.textContent = isUp ? '▲' : '▼';
arrEl.style.color = isUp ? 'var(--up)' : 'var(--down)';
}
if (justTicked && chipEl) {
chipEl.classList.remove('chip-flash-up', 'chip-flash-down');
void chipEl.offsetWidth; // reflow to restart animation
chipEl.classList.add(isUp ? 'chip-flash-up' : 'chip-flash-down');
}
setTimeout(function() {
if (valRef.current) valRef.current.style.color = 'var(--fg)';
}, 1200);
};
return function() { delete window.__chipNoiseListeners[c.code]; };
}, [base, amount]); // re-bind if base/amount changes (closure captures them)
// Initial render values (React path runs on base/amount changes)
var n=noiseRef.current[c.code];
var rate=window.RatesService.convert(base,c.ccy);
var val=rate*(1+(n?n.v:0))*amount;
var rawDir=n?n.dir:null;
var dir=rawDir||((c.code.charCodeAt(0)+c.code.charCodeAt(1))%2===0?'up':'down');
var isUp=dir==='up'; var flashed=n&&(Date.now()-(n.t||0)<1200);
return (
{c.ccy}
{window.formatRate(val)}
{!isBase&&{isUp?'▲':'▼'}}
{isBase&&
BASE
}
);
});
function AtlasGL({ base, setBase, onExit, mapMode, setMapMode, amount, setAmount, visibleCountries, setVisibleCountries }) {
var clock = useClock();
var [hovered,setHovered]=React.useState(null); var [pinned,setPinned]=React.useState(null);
var [search,setSearch]=React.useState(''); var [switcherOpen,setSwitcherOpen]=React.useState(false);
var [filterOpen,setFilterOpen]=React.useState(false); var [draft,setDraft]=React.useState(String(amount));
var [mobileMenuOpen,setMobileMenuOpen]=React.useState(false);
var [sendOpen,setSendOpen]=React.useState(false);
var [sendPrefill,setSendPrefill]=React.useState(null);
React.useEffect(function(){ setDraft(String(amount)); },[amount]);
var winW=useWindowSize(); var isMobile=winW<640;
var [ratesState,setRatesState]=React.useState(window.RatesService.state);
React.useEffect(function(){ return window.RatesService.subscribe(setRatesState); },[]);
// Keep loader visible for at least 2.2s so the dot-matrix assembly reads
var [loaderMinDone, setLoaderMinDone] = React.useState(false);
var [justRefreshed, setJustRefreshed] = React.useState(false);
var [isRefreshing, setIsRefreshing] = React.useState(false);
React.useEffect(function(){
var id = setTimeout(function(){ setLoaderMinDone(true); }, 1800);
return function(){ clearTimeout(id); };
}, []);
// No loader on flat → globe switch. Globe builds fast enough that a loader
// feels worse than a brief black frame. Only the entry loader (home → map) shows.
var showGlobeLoader = false;
var showLoader = !loaderMinDone || ratesState.status==='loading' || ratesState.status==='idle' || (ratesState.status==='error' && !ratesState.rates);
// ── Keyboard shortcuts ───────────────────────────────────────────────────
// F / G → switch flat / globe
// / → focus the search input
// Esc → clear search (if focused in search) or close open overlays
// Shortcuts are suppressed when typing in inputs (except Esc which is contextual)
var searchInputRef = React.useRef(null);
React.useEffect(function(){
function isTyping(el) {
if (!el) return false;
var tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable;
}
function onKey(e) {
var active = document.activeElement;
var typing = isTyping(active);
if (e.key === 'Escape') {
if (typing && active === searchInputRef.current) {
setSearch('');
try { active.blur(); } catch(err) {}
e.preventDefault();
return;
}
if (filterOpen) { setFilterOpen(false); e.preventDefault(); return; }
if (switcherOpen) { setSwitcherOpen(false); e.preventDefault(); return; }
if (pinned) { setPinned(null); e.preventDefault(); return; }
if (search) { setSearch(''); e.preventDefault(); return; }
return;
}
if (typing) return;
if (e.key === '/' || e.key === 's' || e.key === 'S') {
if (searchInputRef.current) {
try { searchInputRef.current.focus(); searchInputRef.current.select(); } catch(err) {}
e.preventDefault();
}
return;
}
if (e.key === 'f' || e.key === 'F') { setMapMode('flat'); e.preventDefault(); return; }
if (e.key === 'g' || e.key === 'G') { setMapMode('globe'); e.preventDefault(); return; }
}
window.addEventListener('keydown', onKey);
return function(){ window.removeEventListener('keydown', onKey); };
}, [filterOpen, switcherOpen, pinned, search, setMapMode]);
// ── Rate freshness indicator ─────────────────────────────────────────────
// Green if <5 min old, amber if 5–30 min, red if older (or error).
// Tick every 15s so the color updates without waiting for new rates.
var [freshnessTick, setFreshnessTick] = React.useState(0);
React.useEffect(function(){
var id = setInterval(function(){ setFreshnessTick(function(t){ return t+1; }); }, 15000);
return function(){ clearInterval(id); };
}, []);
var freshness = React.useMemo(function(){
if (ratesState.status === 'loading' || ratesState.status === 'idle') return { color:'var(--accent)', label:'CONNECTING…', age:null };
if (ratesState.status === 'error') return { color:'var(--down)', label:'OFFLINE', age:null };
var ageMs = Date.now() - (ratesState.updatedAt || 0);
var ageMin = Math.floor(ageMs / 60000);
var color, label;
function fmtAge(m){
if (m <= 0) return 'JUST NOW';
if (m < 60) return m + 'm ago';
var h = Math.floor(m/60);
if (h < 24) return h + 'h ago';
var d = Math.floor(h/24);
return d + 'd ago';
}
if (ratesState.status === 'stale' || ageMin >= 30) { color = 'var(--down)'; label = 'STALE · ' + fmtAge(ageMin); }
else if (ageMin >= 5) { color = 'var(--warn, #e2a23f)'; label = fmtAge(ageMin); }
else { color = 'var(--up)'; label = ageMin <= 0 ? 'LIVE' : fmtAge(ageMin); }
return { color: color, label: label, age: ageMin };
// eslint-disable-next-line
}, [ratesState.status, ratesState.updatedAt, freshnessTick]);
// ── Globe tier listener — re-render when zoom tier changes so new chips fade in
React.useEffect(function(){
// Tier listener — called by MapLibre tick when zoom crosses a tier threshold
// so React re-evaluates chipShouldShow for every chip.
window.__atlasTierListener = function(){
setFreshnessTick(function(t){ return t+1; });
};
return function(){ window.__atlasTierListener = null; };
}, []);
// ── Zoom % indicator — polls camera every 100ms while zoom controls visible
// Flat: zoom ∈ [0.8, 10] → 0-100%. Globe: altitude ∈ [0.5, 2.5] → 100-0% (inverted)
var [zoomPct, setZoomPct] = React.useState(0);
React.useEffect(function(){
var id = setInterval(function(){
// Both flat + globe now use MapLibre zoom: [0.8, 10] → 0-100%
var z = window.__chipZoom || 1.4;
var zc = Math.max(0.8, Math.min(10, z));
var pct = Math.round(100 * (zc - 0.8) / (10 - 0.8));
setZoomPct(function(prev){ return prev === pct ? prev : pct; });
}, 150);
return function(){ clearInterval(id); };
}, [mapMode]);
var noiseRef=React.useRef({});
if(Object.keys(noiseRef.current).length===0){
window.COUNTRIES.forEach(function(c){ var d=(Math.random()-0.5)*0.003; noiseRef.current[c.code]={v:0,dir:d>0?'up':'down',t:0}; });
}
React.useEffect(function(){
var id=setInterval(function(){
var codes=window.COUNTRIES.map(function(c){ return c.code; });
var ls=window.__chipNoiseListeners||{};
for(var i=0;i<12;i++){
var code=codes[(Math.random()*codes.length)|0];
var prior=noiseRef.current[code]?noiseRef.current[code].v:0;
var delta=(Math.random()-0.5)*0.003;
noiseRef.current[code]={v:prior+delta,dir:delta>0?'up':'down',t:Date.now()};
if(ls[code]) ls[code]();
}
if(window.__tickerNoiseListener) window.__tickerNoiseListener();
},4000);
return function(){ clearInterval(id); };
},[]);
var sparkRef=React.useRef(null);
if(!sparkRef.current){ sparkRef.current={}; window.COUNTRIES.forEach(function(c){ var a=[],v=0; for(var i=0;i<24;i++){ v+=(Math.random()-0.5)*0.02; a.push(v); } sparkRef.current[c.code]=a; }); }
var baseMeta=window.CURRENCIES.find(function(c){ return c.code===base; });
var mapApi=React.useRef(null);
var statusLabel = freshness.label;
var statusColor = freshness.color;
var filterCount=visibleCountries===null?null:visibleCountries.size;
// ── Sync globals for imperative chip position loops in map_gl.jsx ─────────
React.useEffect(function(){
if(!window.__chipGlobals) window.__chipGlobals={};
window.__chipGlobals.base = base;
window.__chipGlobals.hovered = hovered ? hovered.code : null;
window.__chipGlobals.search = search;
window.__chipGlobals.hasFilter = visibleCountries !== null; // user-explicit filter bypasses zoom tiers
// Re-run flat DOM positioning immediately after globals update so chips
// at the new base/hover/search appear in the right place without waiting
// for the next map render event.
requestAnimationFrame(function(){
if(window.__updateFlatDom) window.__updateFlatDom();
});
// Invalidate globe's cached key so it re-projects chips this tick
window.__globeForceKey = (window.__globeForceKey || 0) + 1;
},[base, hovered, search, mapMode, visibleCountries]);
// ── Chip visibility — used by React for opacity management ────────────────
function chipShouldShow(c, isBase, isHovered, searchMatch) {
var zoom = window.__chipZoom || 1;
var alwaysSet = window.__chipAlwaysSet;
// If user has an explicit filter set, show every surviving chip regardless of zoom.
// (The filter already culled chips they don't want at the render-tree level.)
if (visibleCountries !== null) return true;
// Two-tier logic:
// desktop: 0-29% majors only → 30%+ all chips (threshold 3.56)
// mobile: 0-24% majors only → 25%+ all chips (threshold 3.10)
var isMobile = typeof window !== 'undefined' && window.innerWidth < 640;
var showAll = zoom >= (isMobile ? 3.10 : 3.56);
return showAll || isBase || isHovered || searchMatch ||
(alwaysSet && alwaysSet.has(c.code));
}
// ── Shared control elements (sized for mobile or desktop) ──────────────────
var amountEl = (
{window.COUNTRIES.map(function(c){
if(visibleCountries!==null&&!visibleCountries.has(c.code)) return null;
var isBase=c.ccy===base;
var isHovered=!!(hovered&&hovered.code===c.code)||!!(pinned&&pinned.code===c.code);
var searchMatch=!!(search&&(c.name.toLowerCase().indexOf(search.toLowerCase())!==-1||c.code.toLowerCase().indexOf(search.toLowerCase())!==-1||c.ccy.toLowerCase().indexOf(search.toLowerCase())!==-1));
var visible=chipShouldShow(c,isBase,isHovered,searchMatch);
return ;
})}
{/* Detail card */}
{pair&&(function(){
var ccy=window.CURRENCIES.find(function(x){ return x.code===pair.ccy; });
var pn=noiseRef.current[pair.code]; var val=window.RatesService.convert(base,pair.ccy)*(1+(pn?pn.v:0))*amount;
var inv=amount/val; var spark=sparkRef.current[pair.code]||[];
var mn=Math.min.apply(null,spark),mx=Math.max.apply(null,spark),range=mx-mn||1;
var last=spark[spark.length-1],prev=spark[spark.length-2],trend=last-prev,pct=(trend/(Math.abs(prev)||1))*100;
if (isMobile) {
// ── Mobile: slide-up bottom sheet ─────────────────────────────
return (
{/* Backdrop */}
{/* Sheet */}