Files
forex_dashboard/app.js
T
2026-06-13 01:57:27 +08:00

574 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
/* =========================================================================
FX PULSE — dashboard logic
---------------------------------------------------------------------------
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);