// AtlasGL — map UI with custom amount input, FLAT·GLOBE toggle, country filter panel. // Mobile-responsive: 2-row top bar on screens < 640px. var S = { root: { position:'absolute', inset:0, display:'flex', flexDirection:'column' }, topBar: { display:'grid', gridTemplateColumns:'1fr auto 1fr', alignItems:'center', padding:'10px 14px', gap:12, borderBottom:'1px solid var(--line-soft)', background:'var(--bg)', position:'relative', zIndex:20, flexShrink:0 }, topLeft: { display:'flex', alignItems:'center', gap:10 }, topCenter: { display:'flex', justifyContent:'center' }, topRight: { display:'flex', alignItems:'center', gap:8, justifyContent:'flex-end', flexWrap:'wrap' }, iconBtn: { width:30, height:30, display:'grid', placeItems:'center', background:'transparent', border:'1px solid var(--line-soft)', color:'var(--fg-dim)', cursor:'pointer', flexShrink:0 }, miniBtn: { width:24, height:24, display:'grid', placeItems:'center', background:'transparent', border:'1px solid var(--line-soft)', color:'var(--fg-dim)', cursor:'pointer', flexShrink:0 }, brandMini: { display:'flex', alignItems:'center', gap:8, fontFamily:"'JetBrains Mono', monospace", fontSize:11, letterSpacing:'0.16em', color:'var(--fg)', whiteSpace:'nowrap' }, divider: { width:1, height:16, background:'var(--line-soft)', flexShrink:0 }, liveTag: { display:'flex', alignItems:'center', gap:7, fontFamily:"'JetBrains Mono', monospace", fontSize:10, letterSpacing:'0.12em', color:'var(--fg-dim)', whiteSpace:'nowrap' }, liveDot: { width:6, height:6, borderRadius:99, background:'var(--accent)', flexShrink:0 }, searchBox: { display:'flex', alignItems:'center', gap:8, width:360, maxWidth:'38vw', padding:'7px 10px', border:'1px solid var(--line-soft)', background:'var(--bg-elev)' }, searchInput: { flex:1, background:'transparent', border:'none', outline:'none', color:'var(--fg)', fontSize:12, fontFamily:'inherit' }, clearBtn: { background:'transparent', border:'none', color:'var(--fg-dim)', cursor:'pointer', fontSize:16, lineHeight:1, padding:0, width:16, height:16 }, amountGroup: { display:'flex', alignItems:'stretch', border:'1px solid var(--line-soft)' }, amountInput: { width:54, padding:'5px 6px', background:'var(--bg-elev)', border:'none', outline:'none', color:'var(--fg)', fontFamily:"'JetBrains Mono', monospace", fontSize:12, textAlign:'right' }, amountX: { padding:'5px 7px 5px 2px', background:'var(--bg-elev)', fontFamily:"'JetBrains Mono', monospace", fontSize:11, color:'var(--fg-faint)', display:'flex', alignItems:'center' }, baseBtn: { display:'flex', alignItems:'center', gap:7, padding:'0 10px', background:'var(--accent)', color:'oklch(0.18 0.01 250)', border:'none', cursor:'pointer', fontFamily:"'JetBrains Mono', monospace", height:30 }, baseBtnLabel: { fontSize:8, letterSpacing:'0.16em', opacity:0.7 }, baseBtnFlag: { fontSize:13, lineHeight:1 }, baseBtnCode: { fontSize:12, fontWeight:600, letterSpacing:'0.05em' }, baseBtnSymbol: { fontSize:12, fontFamily:"'Instrument Serif', serif" }, modeToggle: { display:'flex', border:'1px solid var(--line-soft)', overflow:'hidden', height:30 }, mapWrap: { position:'relative', flex:1, overflow:'hidden', background:'var(--ocean)' }, chip: { position:'absolute', left:0, top:0, padding:'5px 8px', display:'flex', flexDirection:'column', alignItems:'flex-start', minWidth:56, cursor:'pointer', transition:'opacity 220ms ease', touchAction:'manipulation' }, chipMobile: { position:'absolute', left:0, top:0, padding:'6px 9px', display:'flex', flexDirection:'column', alignItems:'flex-start', minWidth:64, cursor:'pointer', transition:'opacity 220ms ease', touchAction:'manipulation' }, chipCode: { fontFamily:"'JetBrains Mono', monospace", fontSize:9, letterSpacing:'0.12em', color:'var(--fg-dim)' }, chipCodeM: { fontFamily:"'JetBrains Mono', monospace", fontSize:10, letterSpacing:'0.1em', color:'var(--fg-dim)' }, chipVal: { fontFamily:"'JetBrains Mono', monospace", fontSize:13, fontWeight:500, fontVariantNumeric:'tabular-nums', transition:'color 600ms ease' }, chipValM: { fontFamily:"'JetBrains Mono', monospace", fontSize:13, fontWeight:500, fontVariantNumeric:'tabular-nums', transition:'color 600ms ease' }, chipBase: { position:'absolute', top:-11, left:'50%', transform:'translateX(-50%)', padding:'2px 6px 1px', background:'var(--accent)', color:'oklch(0.18 0.01 250)', fontSize:8, letterSpacing:'0.2em', fontFamily:"'JetBrains Mono', monospace", fontWeight:700, lineHeight:1, boxShadow:'0 2px 6px rgba(0,0,0,0.5)', zIndex:4, whiteSpace:'nowrap' }, detail: { position:'absolute', width:268, background:'var(--bg-elev)', border:'1px solid var(--line)', padding:14, zIndex:10, boxShadow:'0 18px 50px rgba(0,0,0,0.5)', pointerEvents:'none' }, detailHeader:{ display:'flex', justifyContent:'space-between', alignItems:'flex-start', paddingBottom:10, borderBottom:'1px solid var(--line-soft)' }, detailCountry:{ fontSize:14, fontWeight:500, color:'var(--fg)', display:'flex', alignItems:'center' }, detailCcy: { fontFamily:"'JetBrains Mono', monospace", fontSize:9, color:'var(--fg-faint)', letterSpacing:'0.1em', marginTop:3 }, detailSymbol:{ fontFamily:"'Instrument Serif', serif", fontSize:28, lineHeight:1, color:'var(--accent)' }, detailBig: { padding:'12px 0 10px' }, detailBigUnit:{ fontFamily:"'JetBrains Mono', monospace", fontSize:9, letterSpacing:'0.14em', color:'var(--fg-faint)', marginBottom:3 }, detailBigVal:{ fontFamily:"'Instrument Serif', serif", fontSize:30, fontWeight:400, letterSpacing:'-0.01em', fontVariantNumeric:'tabular-nums', color:'var(--fg)' }, detailRow: { display:'flex', justifyContent:'space-between', padding:'5px 0', fontSize:11, color:'var(--fg-dim)', borderTop:'1px solid var(--line-soft)' }, mono: { fontFamily:"'JetBrains Mono', monospace", color:'var(--fg)' }, sparkWrap: { marginTop:10, paddingTop:8, borderTop:'1px solid var(--line-soft)' }, sparkLegend: { display:'flex', justifyContent:'space-between', marginTop:4, fontFamily:"'JetBrains Mono', monospace", fontSize:9, letterSpacing:'0.1em', color:'var(--fg-faint)' }, detailFooter:{ marginTop:10, paddingTop:8, borderTop:'1px solid var(--line-soft)', fontFamily:"'JetBrains Mono', monospace", fontSize:8, letterSpacing:'0.1em', color:'var(--fg-faint)', textTransform:'uppercase' }, legend: { position:'absolute', left:14, bottom:48, padding:12, background:'var(--bg-elev)', border:'1px solid var(--line-soft)', display:'flex', flexDirection:'column', gap:5 }, legendTitle: { fontFamily:"'JetBrains Mono', monospace", fontSize:8, letterSpacing:'0.18em', color:'var(--fg-faint)', marginBottom:3 }, legendRow: { display:'flex', alignItems:'center', gap:7, fontSize:10, color:'var(--fg-dim)' }, legendDot: { width:7, height:7, borderRadius:99 }, legendHint: { marginTop:5, fontFamily:"'JetBrains Mono', monospace", fontSize:8, letterSpacing:'0.08em', color:'var(--fg-faint)' }, zoomPanel: { position:'absolute', right:14, bottom:48, display:'flex', flexDirection:'column', background:'var(--bg-elev)', border:'1px solid var(--line-soft)', overflow:'hidden' }, zoomBtn: { width:32, height:32, display:'grid', placeItems:'center', background:'transparent', border:'none', cursor:'pointer', color:'var(--fg)', fontSize:15, fontFamily:"'JetBrains Mono', monospace", borderBottom:'1px solid var(--line-soft)' }, zoomDivider: { height:3, background:'var(--bg)' }, ticker: { position:'absolute', left:0, right:0, bottom:0, display:'flex', alignItems:'center', gap:14, padding:'6px 14px', paddingBottom:'max(6px, env(safe-area-inset-bottom, 6px))', background:'var(--bg)', borderTop:'1px solid var(--line-soft)', height:32, overflow:'hidden', zIndex:5 }, tickerLabel: { fontFamily:"'JetBrains Mono', monospace", fontSize:9, letterSpacing:'0.18em', color:'var(--accent)', flexShrink:0 }, tickerTrack: { flex:1, overflow:'hidden', position:'relative' }, tickerScroll:{ display:'flex', gap:32, whiteSpace:'nowrap', animation:'tickerScroll 60s linear infinite' }, tickerItem: { display:'inline-flex', alignItems:'center', gap:9, fontFamily:"'JetBrains Mono', monospace", fontSize:10 }, tickerCode: { color:'var(--fg-faint)', letterSpacing:'0.1em' }, tickerVal: { color:'var(--fg)', fontVariantNumeric:'tabular-nums' }, loadingOverlay:{ position:'absolute', inset:0, display:'grid', placeItems:'center', zIndex:40, background:'color-mix(in oklch, var(--bg) 80%, transparent)', backdropFilter:'blur(2px)' }, loadingSpinner:{ width:24, height:24, border:'2px solid var(--line)', borderTopColor:'var(--accent)', borderRadius:'50%', animation:'spin 900ms linear infinite' }, loadingText: { marginTop:10, fontFamily:"'JetBrains Mono', monospace", fontSize:9, letterSpacing:'0.18em', color:'var(--fg-dim)' }, errorBanner: { position:'absolute', top:10, left:'50%', transform:'translateX(-50%)', zIndex:30, padding:'7px 12px', background:'var(--bg-elev)', border:'1px solid var(--down)', color:'var(--fg)', fontSize:11, display:'flex', alignItems:'center', gap:10 }, errorBtn: { background:'var(--down)', color:'oklch(0.18 0.01 250)', border:'none', padding:'3px 8px', cursor:'pointer', fontFamily:"'JetBrains Mono', monospace", fontSize:9, letterSpacing:'0.1em' }, }; (function() { var s = document.createElement('style'); s.textContent = '@keyframes pulse{0%{box-shadow:0 0 0 0 var(--accent)}70%{box-shadow:0 0 0 8px transparent}100%{box-shadow:0 0 0 0 transparent}}\n@keyframes baseBreath{0%,100%{box-shadow:0 0 0 0 color-mix(in oklch, var(--accent) 60%, transparent), 0 2px 12px rgba(0,0,0,0.4)}50%{box-shadow:0 0 0 6px color-mix(in oklch, var(--accent) 0%, transparent), 0 2px 16px color-mix(in oklch, var(--accent) 35%, transparent)}}\n@keyframes slideUp{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}\n@keyframes tickerScroll{0%{transform:translateX(0)}100%{transform:translateX(-50%)}}\n@keyframes spin{to{transform:rotate(360deg)}}\ninput[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}\ninput[type=number]{-moz-appearance:textfield}\n::selection{background:var(--accent-soft);color:var(--fg)}\ninput::placeholder{color:var(--fg-faint)}\nbutton:hover{filter:brightness(1.08)}'; document.head.appendChild(s); }()); function useClock() { function fmt() { var d = new Date(); var h = d.getHours().toString().padStart(2,'0'); var m = d.getMinutes().toString().padStart(2,'0'); var off = -d.getTimezoneOffset(); var sign = off >= 0 ? '+' : '-'; var oh = Math.floor(Math.abs(off)/60); var om = Math.abs(off)%60; return h+':'+m+' GMT'+sign+oh+(om?':'+String(om).padStart(2,'0'):''); } var [t, setT] = React.useState(fmt); React.useEffect(function(){ var id = setInterval(function(){ setT(fmt()); }, 30000); return function(){ clearInterval(id); }; }, []); return t; } function useWindowSize() { var [w, setW] = React.useState(function() { return window.innerWidth; }); React.useEffect(function() { function handle() { setW(window.innerWidth); } window.addEventListener('resize', handle); return function() { window.removeEventListener('resize', handle); }; }, []); return w; } function modeBtn(active) { return { padding:'4px 10px', background:active?'var(--accent)':'var(--bg-soft)', color:active?'oklch(0.18 0.01 250)':'var(--fg)', border:'none', cursor:'pointer', fontFamily:"'JetBrains Mono', monospace", fontSize:9, letterSpacing:'0.14em', height:'100%', display:'flex', alignItems:'center', gap:5, transition:'background 140ms ease' }; } // Region mapping by ISO country code — used for preset filters & section headers. var REGION_MAP = { AMERICAS: ['US','CA','MX','CU','BR','AR','CL','CO','PE','VE','UY','BO','PY','EC','GT','CR','PA','DO','JM','TT','HT','HN','NI','SV'], EUROPE: ['GB','IE','IS','FR','DE','IT','ES','PT','NL','BE','LU','AT','CH','SE','NO','DK','FI','PL','CZ','SK','HU','RO','BG','HR','SI','RS','BA','MK','AL','GR','EE','LV','LT','UA','BY','MD','RU','MT','CY','EU'], 'ASIA-PAC': ['JP','CN','HK','TW','KR','IN','ID','TH','SG','MY','PH','VN','AU','NZ','PK','BD','LK','NP','MM','KH','LA','MN','FJ'], AFRICA: ['ZA','EG','NG','KE','MA','GH','TN','UG','TZ','DZ','ET','RW','BW','ZM','ZW','NA','CI','SN','CM','AO'], 'MID-EAST': ['AE','SA','IL','QA','KW','BH','OM','JO','LB','IQ','IR','YE','TR','SY'], }; var MAJORS = ['US','EU','GB','JP','CH','CA','AU','NZ']; var REGION_ORDER = ['AMERICAS','EUROPE','ASIA-PAC','MID-EAST','AFRICA']; // Build country → region lookup for section grouping var _regionLookup = {}; REGION_ORDER.forEach(function(r){ REGION_MAP[r].forEach(function(cc){ _regionLookup[cc] = r; }); }); function FilterPanel({ visibleCountries, setVisibleCountries, onClose, isMobile }) { var allCodes = window.COUNTRIES.map(function(c){ return c.code; }); var [q, setQ] = React.useState(''); // Group countries by region var grouped = React.useMemo(function(){ var g = {}; REGION_ORDER.forEach(function(r){ g[r] = []; }); g.OTHER = []; window.COUNTRIES.forEach(function(c){ var r = _regionLookup[c.code] || 'OTHER'; g[r].push(c); }); REGION_ORDER.concat(['OTHER']).forEach(function(r){ g[r].sort(function(a,b){ return a.name.localeCompare(b.name); }); }); return g; }, []); var ql = q.trim().toLowerCase(); function matchesSearch(c){ if (!ql) return true; return c.name.toLowerCase().includes(ql) || c.code.toLowerCase().includes(ql) || c.ccy.toLowerCase().includes(ql); } function isOn(code){ return visibleCountries===null || visibleCountries.has(code); } function setMany(codes, on){ var base = visibleCountries===null ? new Set(allCodes) : new Set(visibleCountries); codes.forEach(function(c){ if(on) base.add(c); else base.delete(c); }); setVisibleCountries(base.size===allCodes.length ? null : base); } function toggle(code){ setMany([code], !isOn(code)); } function setExact(codes){ var s = new Set(codes); setVisibleCountries(s.size===allCodes.length ? null : s); } var visCount = visibleCountries===null ? allCodes.length : visibleCountries.size; // Preset chips var presets = [ { id:'all', label:'ALL', on: visCount===allCodes.length, action: function(){ setVisibleCountries(null); } }, { id:'none', label:'NONE', on: visCount===0, action: function(){ setVisibleCountries(new Set()); } }, { id:'majors', label:'MAJORS', on: false, action: function(){ setExact(MAJORS); } }, ].concat(REGION_ORDER.map(function(r){ return { id:r, label:r, on: false, action: function(){ setExact(REGION_MAP[r].filter(function(c){ return allCodes.indexOf(c)>=0; })); }, }; })); function RegionSection(r){ var rows = grouped[r] || []; var filtered = rows.filter(matchesSearch); if (!filtered.length) return null; var onCount = rows.filter(function(c){ return isOn(c.code); }).length; var allOn = onCount === rows.length; var label = r === 'OTHER' ? 'OTHER' : r; return (
{label} · {onCount}/{rows.length}
{filtered.map(function(c){ var on = isOn(c.code); return ( ); })}
); } return (
CURRENCIES {visCount} / {allCodes.length}
{/* Preset chips */}
{presets.map(function(p){ return ( ); })}
{/* Search */}
{q && }
{/* Grouped flag grid */}
{REGION_ORDER.map(RegionSection)} {RegionSection('OTHER')}
); } function Ticker({ base, amount, noiseRef }) { var [, forceUpdate] = React.useReducer(function(x) { return x+1; }, 0); React.useEffect(function() { window.__tickerNoiseListener = forceUpdate; return function() { window.__tickerNoiseListener = null; }; }, []); var picks = ['EUR','GBP','JPY','CNY','INR','AUD','CAD','CHF','SEK','MXN','BRL','ZAR','TRY','KRW','HKD','SGD']; return (
FX · {amount!==1?amount+'x ':''}{base}
{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 = (
0){ setAmount(v); setDraft(String(v)); } else setDraft(String(amount)); }} onKeyDown={function(e){ if(e.key==='Enter'){ var v=parseFloat(e.target.value); if(isFinite(v)&&v>0){ setAmount(v); setDraft(String(v)); } } }} style={{ ...S.amountInput, width:isMobile?52:54, height:isMobile?34:'auto', fontSize:isMobile?13:12 }} title="Amount"/> ×
); var baseEl = (
{switcherOpen&&( isMobile ? (
) : (
) )}
); var filterEl = ( ); var modeEl = isMobile ? ( /* Mobile: single toggle button — tap to cycle flat ↔ globe */ ) : ( /* Desktop: two-button toggle */
); // ────────────────────────────────────────────────────────────────────────── var sendEl = ( ); var pair=hovered||pinned; return (
{isMobile ? ( /* ── Mobile top bar ── */
ATLAS
{/* Refresh button */} {amountEl} {baseEl} {/* ⋯ overflow menu — opens bottom sheet on mobile */}
) : ( /* ── Desktop top bar: 3-column grid ── */
ATLAS / FX
{statusLabel} · {clock}
{amountEl} {baseEl} {filterEl} {modeEl} {sendEl}
)}
{showLoader&&( )} {showGlobeLoader&&!showLoader&&( )} {ratesState.status==='error'&&(
Live rates unavailable — showing cached data.
)} {/* Chips overlay — ChipCard components mounted once, stable refs, positions imperative */}
{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 */}
{/* Drag handle */}
{/* Row 1: flag + name + symbol + close */}
{window.flagEmoji(pair.code)}
{pair.name}
{ccy?ccy.name+' · ':''}{pair.ccy}
{ccy?ccy.symbol:''}
{/* Row 2: rate + trend */}
{amount} {base} =
{window.formatRate(val)}{pair.ccy}
=0?'var(--up)':'var(--down)' }}>{trend>=0?'▲':'▼'} {Math.abs(pct).toFixed(2)}%
inv {window.formatRate(inv)} {base}
{/* Set as base — placed first after rate so it's always visible */} {pair.ccy!==base&&( )}
); } // ── Desktop: full detail card ────────────────────────────────────── var detailStyle = { ...S.detail, left:'auto', right:filterOpen?304:20, top:68, transform:'none' }; return (
{window.flagEmoji(pair.code)}{pair.name}
{ccy?ccy.name:pair.ccy} · {pair.ccy}
{ccy?ccy.symbol:''}
{amount} {base} =
{window.formatRate(val)} {pair.ccy}
Inverse{window.formatRate(inv)} {base}
24h trend=0?'var(--up)':'var(--down)' }}>{trend>=0?'▲':'▼'} {pct.toFixed(3)}%
Session hi{window.formatRate(val*(1+mx-last))}
Session lo{window.formatRate(val*(1+mn-last))}
=0?'var(--up)':'var(--down)'} strokeWidth="1.2"/>
24H{spark.length} ticks
{pinned&&pinned.code===pair.code?'Pinned — click chip to release':'Click chip to pin'}
); })()} {/* Zoom controls */}
{zoomPct}%
{/* Legend — desktop only */} {!isMobile&&(
LEGEND
Base currency
Appreciating
Depreciating
{mapMode==='globe'?'Drag to rotate':'Scroll to zoom · Drag to pan'}
)} {filterOpen&&}
{window.Remittance && } {/* ─── Mobile bottom sheet menu ─── */} {isMobile && (
{/* Drag handle */}
{/* Header */}
MENU
{/* Action rows */} {[ { key:'mode', label:'Map mode', sub: mapMode==='flat'?'Flat · tap to rotate globe':'Globe · tap for flat map', icon: mapMode==='flat' ? : , onClick: function(){ setMapMode(mapMode==='flat'?'globe':'flat'); setMobileMenuOpen(false); } }, { key:'filter', label:'Currencies', sub: (visibleCountries===null ? window.COUNTRIES.length : visibleCountries.size) + ' of ' + window.COUNTRIES.length + ' visible', icon: , onClick: function(){ setFilterOpen(true); setMobileMenuOpen(false); } }, { key:'send', label:'Send money', sub:'Compare remittance providers', icon: , onClick: function(){ setSendOpen(true); setMobileMenuOpen(false); } }, ].map(function(row, i){ return ( ); })}
)}
); } window.AtlasGL = AtlasGL;