Files
forex_dashboard/app.js
T

574 lines
25 KiB
JavaScript
Raw Normal View History

2026-06-06 18:27:58 +08:00
"use strict";
/* =========================================================================
FX PULSE — dashboard logic
2026-06-06 18:27:58 +08:00
---------------------------------------------------------------------------
Sections:
1. Config & state
2. API layer (fetch with CDN fallback + caching)
3. Math/analysis helpers (MA, volatility, slope, patterns)
4. Rendering (current value, table, heatmap, charts, AI, patterns)
5. Calculator
6. Theme + events + init
========================================================================= */
/* -------------------- 1. CONFIG & STATE -------------------- */
const MAJORS = ["usd","eur","gbp","jpy","aud","cad","chf","cny","sgd","php"];
const FALLBACK_NAMES = {usd:"US Dollar",eur:"Euro",gbp:"British Pound",jpy:"Japanese Yen",
aud:"Australian Dollar",cad:"Canadian Dollar",chf:"Swiss Franc",cny:"Chinese Yuan",
sgd:"Singapore Dollar",php:"Philippine Peso",hkd:"Hong Kong Dollar",nzd:"New Zealand Dollar",
inr:"Indian Rupee",krw:"South Korean Won",thb:"Thai Baht",myr:"Malaysian Ringgit"};
const state = {
base:"usd",
target:"php",
range:30,
names:{}, // code -> name
latest:null, // latest base file: {date, base:{...}}
yesterday:null, // most recent prior day base file
series:[], // [{date, rate}] for selected target over range
candles:[], // [{x,o,h,l,c}]
table:[], // computed table rows
sort:{key:"code",dir:1},
cache:new Map(), // url -> json
};
/* -------------------- 2. API LAYER -------------------- */
// Build the candidate URLs (primary jsDelivr, then pages.dev fallback).
function urlsLatest(base){
return [
`https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/${base}.json`,
`https://latest.currency-api.pages.dev/v1/currencies/${base}.json`
];
}
function urlsDated(base,date){
return [
`https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@${date}/v1/currencies/${base}.json`,
`https://${date}.currency-api.pages.dev/v1/currencies/${base}.json`
];
}
function urlsNames(){
return [
`https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies.json`,
`https://latest.currency-api.pages.dev/v1/currencies.json`
];
}
// Try each URL in order (with a hard timeout so a stalled request can't hang
// the dashboard); first success wins. Throws if all fail.
async function fetchJson(urls,timeoutMs=10000){
const key = urls[0];
if(state.cache.has(key)) return state.cache.get(key);
let lastErr;
for(const u of urls){
const ctrl=new AbortController();
const timer=setTimeout(()=>ctrl.abort(),timeoutMs);
try{
const res = await fetch(u,{signal:ctrl.signal});
clearTimeout(timer);
if(!res.ok) throw new Error("HTTP "+res.status);
const json = await res.json();
state.cache.set(key,json);
return json;
}catch(e){ clearTimeout(timer); lastErr = e; }
}
throw lastErr || new Error("All endpoints failed");
}
// Run async tasks with a concurrency cap (avoids jsDelivr burst throttling
// when fetching many date-tagged files for the historical series).
async function mapLimit(items,limit,fn){
const out=new Array(items.length);
let idx=0;
async function worker(){ while(idx<items.length){ const i=idx++; out[i]=await fn(items[i],i); } }
await Promise.all(Array.from({length:Math.min(limit,items.length)},worker));
return out;
}
// All historical dates are derived from the *latest data date* (not the wall
// clock) so the API and the dashboard stay in sync.
function latestDate(){ return state.latest?.date ? new Date(state.latest.date+"T00:00:00Z") : new Date(); }
function ymdMinus(baseDate,n){ const d=new Date(baseDate); d.setUTCDate(d.getUTCDate()-n); return d.toISOString().slice(0,10); }
// Latest rates for a base currency.
async function getLatest(base){ return fetchJson(urlsLatest(base)); }
// Rates for a specific date; returns null if unavailable (no throw).
async function getDated(base,date){
try{ return await fetchJson(urlsDated(base,date)); }
catch{ return null; }
}
// Find the most recent available historical file before the latest date,
// scanning back up to `maxBack` days.
async function getRecentPast(base,startOff,maxBack){
const ld=latestDate();
for(let i=startOff;i<startOff+maxBack;i++){
const data = await getDated(base, ymdMinus(ld,i));
if(data && data[base]) return data;
}
return null;
}
/* -------------------- 3. ANALYSIS HELPERS -------------------- */
const mean = a => a.reduce((s,x)=>s+x,0)/(a.length||1);
function stdev(a){ const m=mean(a); return Math.sqrt(mean(a.map(x=>(x-m)**2))); }
// Simple moving average series (window w) aligned to input length (null padded).
function sma(arr,w){
return arr.map((_,i)=> i<w-1 ? null : mean(arr.slice(i-w+1,i+1)));
}
// Daily % returns.
function returns(arr){ const r=[]; for(let i=1;i<arr.length;i++) r.push((arr[i]-arr[i-1])/arr[i-1]); return r; }
// Normalised linear-regression slope (% per step) for trend strength.
function slopePct(arr){
const n=arr.length; if(n<2) return 0;
const xs=arr.map((_,i)=>i), mx=mean(xs), my=mean(arr);
let num=0,den=0;
for(let i=0;i<n;i++){ num+=(xs[i]-mx)*(arr[i]-my); den+=(xs[i]-mx)**2; }
const slope = den? num/den : 0;
return (slope/my)*100; // % of mean per step
}
// Build estimated daily candles from a close series.
function buildCandles(series){
if(series.length<2) return [];
const closes = series.map(p=>p.rate);
const rets = returns(closes);
const vol = stdev(rets); // typical daily move (fraction)
const out=[];
for(let i=1;i<series.length;i++){
const open=closes[i-1], close=closes[i];
const body=Math.abs(close-open);
// wick estimate: at least the body, padded by recent volatility
const wick = Math.max(body*0.6, close*vol*0.9);
const hi = Math.max(open,close)+wick;
const lo = Math.min(open,close)-wick;
out.push({ x: luxon.DateTime.fromISO(series[i].date).toMillis(), o:open, h:hi, l:lo, c:close });
}
return out;
}
// Detect market patterns from a close series. Returns [{tag,cls,text}].
function detectPatterns(series){
const closes = series.map(p=>p.rate);
if(closes.length<5) return [{tag:"info",cls:"t-flat",text:"Not enough history to analyse patterns."}];
const out=[];
const rets=returns(closes);
const vol=stdev(rets)*100;
const slope=slopePct(closes);
const last=closes[closes.length-1];
const sShort=sma(closes,5), sLong=sma(closes,Math.min(10,closes.length-1));
const maShort=sShort[sShort.length-1], maLong=sLong[sLong.length-1];
// Trend (via regression slope)
if(slope>0.12) out.push({tag:"Uptrend",cls:"t-up",text:`Rate is rising ~${slope.toFixed(2)}% per day on a regression fit.`});
else if(slope<-0.12) out.push({tag:"Downtrend",cls:"t-down",text:`Rate is falling ~${Math.abs(slope).toFixed(2)}% per day on a regression fit.`});
else out.push({tag:"Sideways",cls:"t-flat",text:"No clear direction — rate is moving sideways."});
// Breakout: last value beyond recent range (exclude last point)
const prior=closes.slice(0,-1);
const hiP=Math.max(...prior), loP=Math.min(...prior);
if(last>hiP) out.push({tag:"Breakout ↑",cls:"t-break",text:"Closed above its recent high — upside breakout."});
else if(last<loP) out.push({tag:"Breakout ↓",cls:"t-break",text:"Closed below its recent low — downside breakout."});
// Volatility spike: recent vol vs earlier vol
if(rets.length>=8){
const half=Math.floor(rets.length/2);
const recent=stdev(rets.slice(half))*100, early=stdev(rets.slice(0,half))*100;
if(recent>early*1.6 && recent>0.25)
out.push({tag:"Volatility spike",cls:"t-vol",text:`Recent swings (${recent.toFixed(2)}%) are well above the earlier ${early.toFixed(2)}% baseline.`});
}
// MA crossover (golden/death) in the last step
if(maShort!=null && maLong!=null){
const pShort=sShort[sShort.length-2], pLong=sLong[sLong.length-2];
if(pShort!=null && pLong!=null){
if(pShort<=pLong && maShort>maLong) out.push({tag:"Bullish crossover",cls:"t-up",text:"Short MA crossed above long MA — bullish signal."});
else if(pShort>=pLong && maShort<maLong) out.push({tag:"Bearish crossover",cls:"t-down",text:"Short MA crossed below long MA — bearish signal."});
}
}
return out;
}
// Generate human-readable rule-based AI insights.
function buildInsights(series,base,target){
const pair=`${base.toUpperCase()}/${target.toUpperCase()}`;
const closes=series.map(p=>p.rate);
const out=[];
if(closes.length<3){ return [{ic:"️",t:"Limited history available for "+pair+".",s:""}]; }
const slope=slopePct(closes);
const totalChg=((closes[closes.length-1]-closes[0])/closes[0])*100;
const vol=stdev(returns(closes))*100;
const ma7=sma(closes,Math.min(7,closes.length))[closes.length-1];
const last=closes[closes.length-1];
// 1. Directional pressure
if(slope>0.12) out.push({ic:"📈",t:`${pair} is showing upward pressure, up ${totalChg.toFixed(2)}% over the last ${closes.length} days.`,s:"Buyers currently in control."});
else if(slope<-0.12) out.push({ic:"📉",t:`${pair} is under downward pressure, down ${Math.abs(totalChg).toFixed(2)}% over the last ${closes.length} days.`,s:"Sellers currently in control."});
else out.push({ic:"➡️",t:`${pair} is broadly flat (${totalChg>=0?"+":""}${totalChg.toFixed(2)}%) — a consolidation phase.`,s:"Direction unclear; range-bound."});
// 2. MA momentum
if(ma7!=null){
if(last>ma7*1.001) out.push({ic:"🟢",t:`Price is above its ${Math.min(7,closes.length)}-day moving average, suggesting bullish momentum.`,s:`Last ${last.toFixed(4)} vs MA ${ma7.toFixed(4)}.`});
else if(last<ma7*0.999) out.push({ic:"🔵",t:`Price is below its ${Math.min(7,closes.length)}-day moving average, suggesting bearish momentum.`,s:`Last ${last.toFixed(4)} vs MA ${ma7.toFixed(4)}.`});
}
// 3. Volatility read
if(vol>0.6) out.push({ic:"⚡",t:"Volatility is higher than usual, so short-term movement may be unstable.",s:`Avg daily swing ≈ ${vol.toFixed(2)}%.`});
else out.push({ic:"🛡️",t:"Volatility is subdued — movements are relatively calm and orderly.",s:`Avg daily swing ≈ ${vol.toFixed(2)}%.`});
return out;
}
/* -------------------- 4. RENDERING -------------------- */
const $ = id => document.getElementById(id);
const fmt = (v,d=4)=> v==null||isNaN(v) ? "—" : Number(v).toLocaleString(undefined,{maximumFractionDigits:d,minimumFractionDigits:Math.min(2,d)});
const nameOf = c => state.names[c] || FALLBACK_NAMES[c] || c.toUpperCase();
function trendClass(p){ return p>0.05?"up":p<-0.05?"down":"flat"; }
function trendWord(p){ return p>0.05?"Uptrend":p<-0.05?"Downtrend":"Stable"; }
// --- Current value card ---
function renderCurrent(){
const {base,target,latest,yesterday,series}=state;
const rate = latest?.[base]?.[target];
$("pairLabel").textContent = `${base.toUpperCase()}/${target.toUpperCase()}`;
$("rateBig").textContent = fmt(rate,4);
// daily change vs most recent prior day
let chg=null;
const prev = yesterday?.[base]?.[target];
if(prev) chg = ((rate-prev)/prev)*100;
const cv=$("changeVal");
if(chg==null){ cv.textContent="n/a"; cv.className="change flat"; }
else{ cv.textContent=`${chg>=0?"▲ +":"▼ "}${chg.toFixed(3)}%`; cv.className="change "+trendClass(chg); }
const badge=$("trendBadge");
badge.textContent=trendWord(chg??0);
badge.className="badge "+trendClass(chg??0);
// 7d stats from series
const closes=series.map(p=>p.rate);
if(closes.length){
$("m7high").textContent=fmt(Math.max(...closes),4);
$("m7low").textContent=fmt(Math.min(...closes),4);
$("mvol").textContent=(stdev(returns(closes))*100).toFixed(2)+"%";
}
}
// --- Table ---
function computeTable(){
const {base,latest,yesterday}=state;
const cur=latest?.[base]||{};
const prev=yesterday?.[base]||{};
const rows=[];
for(const code in cur){
if(code===base) continue;
const rate=cur[code];
const p=prev[code];
const change = (p!=null && p!==0) ? ((rate-p)/p)*100 : null;
rows.push({code,name:nameOf(code),rate,change});
}
// mark strongest / weakest by change among comparable rows
const withChg=rows.filter(r=>r.change!=null);
if(withChg.length){
let s=withChg[0], w=withChg[0];
for(const r of withChg){ if(r.change>s.change)s=r; if(r.change<w.change)w=r; }
s.lead="s"; w.lead="w";
}
state.table=rows;
}
function renderTable(){
const q=$("tableSearch").value.trim().toLowerCase();
const {key,dir}=state.sort;
let rows=state.table.slice();
if(q) rows=rows.filter(r=>r.code.includes(q)||r.name.toLowerCase().includes(q));
rows.sort((a,b)=>{
let av=a[key], bv=b[key];
if(key==="change"){ av=av??-Infinity; bv=bv??-Infinity; }
if(typeof av==="string") return av.localeCompare(bv)*dir;
return (av-bv)*dir;
});
const body=$("rateBody");
body.innerHTML = rows.map(r=>{
const cls = trendClass(r.change??0);
const sign = r.change==null?"":(r.change>=0?"+":"");
const lead = r.lead==="s"?`<span class="lead s">Strong</span>`:r.lead==="w"?`<span class="lead w">Weak</span>`:"";
const rowCls = r.lead==="s"?"row-strong":r.lead==="w"?"row-weak":"";
const arrow = r.change==null?"·":r.change>0.05?"▲":r.change<-0.05?"▼":"—";
return `<tr class="${rowCls}">
<td class="code">${r.code.toUpperCase()}${lead}</td>
<td class="name">${r.name}</td>
<td class="num">${fmt(r.rate,r.rate<10?5:2)}</td>
<td class="num"><span class="pill ${cls}">${r.change==null?"n/a":sign+r.change.toFixed(2)+"%"}</span></td>
<td>${arrow} ${trendWord(r.change??0)}</td>
</tr>`;
}).join("");
$("tableCount").textContent=`${rows.length} currencies`;
$("tableBaseLbl").textContent=`(1 ${state.base.toUpperCase()} = …)`;
// header arrows
document.querySelectorAll("thead th[data-sort]").forEach(th=>{
const a=th.querySelector(".arr");
if(!a) return;
a.textContent = th.dataset.sort===key ? (dir>0?"▲":"▼") : "";
});
}
// --- AI + patterns ---
function renderAI(){
const ins=buildInsights(state.series,state.base,state.target);
$("aiList").innerHTML = ins.map(i=>`<div class="insight"><div class="ic">${i.ic}</div><p>${i.t}<span>${i.s||""}</span></p></div>`).join("");
}
function renderPatterns(){
const ps=detectPatterns(state.series);
$("patternList").innerHTML = ps.map(p=>`<div class="pattern"><span class="tag ${p.cls}">${p.tag}</span><small>${p.text}</small></div>`).join("");
}
// --- Heatmap (basket-weighted 7-day strength from USD cross rates) ---
async function renderHeatmap(){
const grid=$("heatGrid");
grid.innerHTML = MAJORS.map(()=>`<div class="heat-cell skeleton" style="height:78px"></div>`).join("");
// Cross rate C->O derived from USD file: rate = usd[O]/usd[C]
const now = await getLatest("usd");
const past = await getRecentPast("usd",7,7);
if(!now?.usd || !past?.usd){ grid.innerHTML=`<div style="color:var(--text-faint);font-size:.8rem">Heatmap data unavailable.</div>`; return; }
const N=now.usd, P=past.usd;
const valNow=c=> c==="usd"?1:N[c], valPast=c=> c==="usd"?1:P[c];
const strength={};
for(const c of MAJORS){
const diffs=[];
for(const o of MAJORS){
if(o===c) continue;
const rNow=valNow(o)/valNow(c), rPast=valPast(o)/valPast(c);
if(rPast) diffs.push(((rNow-rPast)/rPast)*100);
}
strength[c]=mean(diffs);
}
const vals=Object.values(strength);
const lo=Math.min(...vals), hi=Math.max(...vals), span=(hi-lo)||1;
// cool color ramp: muted navy (weak) -> bright mint/cyan (strong). No purple.
const ramp=t=>{
const stops=[[11,34,51],[14,74,90],[19,138,138],[31,182,166],[127,240,200]];
const x=t*(stops.length-1), i=Math.min(stops.length-2,Math.floor(x)), f=x-i;
const c=stops[i].map((v,k)=>Math.round(v+(stops[i+1][k]-v)*f));
return `rgb(${c[0]},${c[1]},${c[2]})`;
};
const ordered=[...MAJORS].sort((a,b)=>strength[b]-strength[a]);
grid.innerHTML = ordered.map((c,idx)=>{
const t=(strength[c]-lo)/span;
const tag = idx===0?"Strongest":idx===ordered.length-1?"Weakest":(strength[c]>=0?"Firm":"Soft");
return `<div class="heat-cell" style="background:${ramp(t)};${t<0.4?'color:#cfe8f2':''}">
<div class="code">${c.toUpperCase()}</div>
<div class="pct">${strength[c]>=0?"+":""}${strength[c].toFixed(2)}%</div>
<div class="lbl">${tag}</div></div>`;
}).join("");
}
/* -------------------- CHARTS -------------------- */
let candleChart, lineChart;
function themeColors(){
const cs=getComputedStyle(document.documentElement);
return {
text:cs.getPropertyValue("--text-dim").trim(),
grid:cs.getPropertyValue("--grid").trim(),
up:cs.getPropertyValue("--c-up").trim(),
down:cs.getPropertyValue("--c-down").trim(),
accent:cs.getPropertyValue("--accent").trim(),
accent2:cs.getPropertyValue("--accent-2").trim(),
slate:cs.getPropertyValue("--c-slate").trim(),
};
}
function renderCandles(){
const tc=themeColors();
state.candles=buildCandles(state.series);
const ctx=$("candleChart");
if(candleChart) candleChart.destroy();
candleChart=new Chart(ctx,{
type:"candlestick",
data:{datasets:[{
label:`${state.base.toUpperCase()}/${state.target.toUpperCase()} (est.)`,
data:state.candles,
// chartjs-chart-financial uses the *plural* keys; keeps candles on the
// cool palette (teal = up, slate-blue = down) — no warm reds.
backgroundColors:{up:tc.up,down:tc.down,unchanged:tc.slate},
borderColors:{up:tc.up,down:tc.down,unchanged:tc.slate},
}]},
options:{
responsive:true,maintainAspectRatio:false,
plugins:{legend:{display:false},tooltip:{callbacks:{
label:c=>{const o=c.raw;return [`O ${fmt(o.o,4)}`,`H ${fmt(o.h,4)}`,`L ${fmt(o.l,4)}`,`C ${fmt(o.c,4)}`];}
}}},
scales:{
x:{type:"time",time:{unit:state.range>30?"week":"day"},grid:{color:tc.grid},ticks:{color:tc.text,maxRotation:0,autoSkip:true,maxTicksLimit:8}},
y:{grid:{color:tc.grid},ticks:{color:tc.text}}
}
}
});
}
function renderLine(){
const tc=themeColors();
const labels=state.series.map(p=>p.date);
const data=state.series.map(p=>p.rate);
const ma=sma(data,7);
const ctx=$("lineChart");
if(lineChart) lineChart.destroy();
// gradient fill under the line
const g=ctx.getContext("2d").createLinearGradient(0,0,0,320);
g.addColorStop(0,"rgba(34,211,238,.30)"); g.addColorStop(1,"rgba(34,211,238,0)");
lineChart=new Chart(ctx,{
type:"line",
data:{labels,datasets:[
{label:"Rate",data,borderColor:tc.accent,backgroundColor:g,fill:true,tension:.3,pointRadius:0,borderWidth:2.4},
{label:"7-day MA",data:ma,borderColor:tc.accent2,borderDash:[6,5],fill:false,tension:.3,pointRadius:0,borderWidth:1.8}
]},
options:{
responsive:true,maintainAspectRatio:false,
interaction:{mode:"index",intersect:false},
plugins:{legend:{display:true,labels:{color:tc.text,boxWidth:14,usePointStyle:true}},
tooltip:{callbacks:{label:c=>`${c.dataset.label}: ${fmt(c.parsed.y,5)}`}}},
scales:{
x:{grid:{color:tc.grid},ticks:{color:tc.text,maxRotation:0,autoSkip:true,maxTicksLimit:8}},
y:{grid:{color:tc.grid},ticks:{color:tc.text}}
}
}
});
}
/* -------------------- 5. CALCULATOR -------------------- */
async function runCalc(){
const amt=parseFloat($("calcAmount").value)||0;
const from=$("calcFrom").value, to=$("calcTo").value;
try{
const data=await getLatest(from);
const rate = from===to ? 1 : data?.[from]?.[to];
if(rate==null){ $("calcOut").textContent="—"; $("calcRate").textContent="rate unavailable"; return; }
const res=amt*rate;
$("calcOut").textContent=`${fmt(res, res<10?4:2)} ${to.toUpperCase()}`;
$("calcRate").textContent=`1 ${from.toUpperCase()} = ${fmt(rate,6)} ${to.toUpperCase()}`;
}catch{ $("calcRate").textContent="rate unavailable"; }
}
/* -------------------- DATA FETCH ORCHESTRATION -------------------- */
// Fetch the rate series for the selected target across the chosen range.
// Dates are counted back from the latest data date; requests are throttled.
async function fetchSeries(){
const {base,target,range}=state;
const ld=latestDate();
const step = range>60 ? 3 : 1; // sample longer ranges to limit requests
const offsets=[]; for(let off=0;off<=range;off+=step) offsets.push(off);
const results = await mapLimit(offsets,6, async off=>{
if(off===0){ const d=state.latest||await getLatest(base); return d?{date:d.date,rate:d[base]?.[target]}:null; }
const d=await getDated(base, ymdMinus(ld,off));
return d && d[base]?.[target]!=null ? {date:d.date,rate:d[base][target]} : null;
});
// dedupe by date + sort ascending
const map=new Map();
results.filter(Boolean).forEach(p=>{ if(p.rate!=null) map.set(p.date,p.rate); });
state.series=[...map.entries()].map(([date,rate])=>({date,rate})).sort((a,b)=>a.date<b.date?-1:1);
}
// Refresh only the series-dependent widgets (line, candles, AI, patterns, value stats).
async function refreshSeries(){
await fetchSeries();
renderLine(); renderCandles(); renderAI(); renderPatterns(); renderCurrent();
}
// Full refresh. Core widgets (value, table, calculator) render first from the
// two base files; charts + heatmap then load progressively in the background.
async function refreshAll(){
showLoading(true,"Fetching exchange data…");
$("refreshBtn").classList.add("spin");
try{
state.cache.clear();
state.latest = await getLatest(state.base);
state.yesterday = await getRecentPast(state.base,1,7);
computeTable(); renderTable();
renderCurrent(); // show rate + daily change immediately
stampUpdated();
await runCalc();
showLoading(false); // core dashboard is now usable
// Progressive load — failures here never block the core dashboard.
await Promise.allSettled([ refreshSeries(), renderHeatmap() ]);
}catch(e){
showError("Could not load exchange data ("+(e.message||"unknown")+"). Primary & fallback endpoints unreachable.");
}finally{
showLoading(false);
$("refreshBtn").classList.remove("spin");
}
}
/* -------------------- 6. THEME / EVENTS / INIT -------------------- */
function stampUpdated(){
const t=new Date();
$("updatedAt").textContent=t.toLocaleString(undefined,{hour:"2-digit",minute:"2-digit",second:"2-digit",day:"2-digit",month:"short"});
const dataDate = state.latest?.date ? ` · data date ${state.latest.date}` : "";
$("srcLabel").textContent=dataDate;
}
function showLoading(on,msg){ if(msg)$("loadMsg").textContent=msg; $("overlay").classList.toggle("show",on); }
function showError(msg){ $("toastMsg").textContent=msg; const t=$("toast"); t.classList.add("show"); clearTimeout(showError._t); showError._t=setTimeout(()=>t.classList.remove("show"),7000); }
function applyTheme(theme){
document.documentElement.setAttribute("data-theme",theme);
$("themeIcon").textContent = theme==="dark"?"🌙":"☀️";
localStorage.setItem("fxpulse-theme",theme);
// re-render charts so they pick up theme colors
if(state.series.length){ renderLine(); renderCandles(); }
}
function fillSelect(sel,selected){
const codes=Object.keys(state.names).sort();
sel.innerHTML=codes.map(c=>`<option value="${c}" ${c===selected?"selected":""}>${c.toUpperCase()}${nameOf(c)}</option>`).join("");
}
function bindEvents(){
$("baseSel").addEventListener("change",e=>{ state.base=e.target.value; refreshAll(); });
$("targetSel").addEventListener("change",e=>{ state.target=e.target.value; refreshSeries(); });
$("refreshBtn").addEventListener("click",refreshAll);
$("themeBtn").addEventListener("click",()=>{
applyTheme(document.documentElement.getAttribute("data-theme")==="dark"?"light":"dark");
});
// range buttons
$("rangeBtns").addEventListener("click",e=>{
const b=e.target.closest("button"); if(!b) return;
state.range=+b.dataset.d;
document.querySelectorAll("#rangeBtns button").forEach(x=>x.classList.toggle("active",x===b));
showLoading(true,"Loading "+state.range+"-day history…");
refreshSeries().finally(()=>showLoading(false));
});
// table search + sort
$("tableSearch").addEventListener("input",renderTable);
document.querySelectorAll("thead th[data-sort]").forEach(th=>{
th.addEventListener("click",()=>{
const k=th.dataset.sort;
if(state.sort.key===k) state.sort.dir*=-1; else state.sort={key:k,dir:k==="code"||k==="name"?1:-1};
renderTable();
});
});
// calculator
["input","change"].forEach(ev=>{
$("calcAmount").addEventListener(ev,runCalc);
$("calcFrom").addEventListener("change",runCalc);
$("calcTo").addEventListener("change",runCalc);
});
$("calcSwap").addEventListener("click",()=>{
const f=$("calcFrom").value; $("calcFrom").value=$("calcTo").value; $("calcTo").value=f; runCalc();
});
}
async function init(){
// theme first (no flash)
applyTheme(localStorage.getItem("fxpulse-theme")||"dark");
// currency names for selectors
try{ state.names=await fetchJson(urlsNames()); }
catch{ state.names={...FALLBACK_NAMES}; showError("Currency list endpoint failed — using a reduced fallback list."); }
fillSelect($("baseSel"),state.base);
fillSelect($("targetSel"),state.target);
fillSelect($("calcFrom"),"usd");
fillSelect($("calcTo"),"eur");
bindEvents();
await refreshAll();
}
// Wait for Chart.js (and financial plugin) then boot.
window.addEventListener("DOMContentLoaded",init);