Files
forex_dashboard/forex-dashboard.html
T
2026-06-06 18:27:58 +08:00

983 lines
46 KiB
HTML
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.
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FX Pulse — Foreign Exchange Dashboard</title>
<!-- ===== Chart libraries (CDN, charts only) ===== -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/luxon@3.4.4/build/global/luxon.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@1.3.1/dist/chartjs-adapter-luxon.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-chart-financial@0.2.1/dist/chartjs-chart-financial.min.js"></script>
<style>
/* =========================================================================
THEME TOKENS — cool palette only (navy, deep blue, cyan, teal, slate,
ice blue, soft mint). No purple anywhere.
========================================================================= */
:root{
--c-cyan:#22d3ee; --c-teal:#2dd4bf; --c-blue:#3b82f6;
--c-mint:#6ee7b7; --c-sky:#38bdf8; --c-slate:#64748b;
--c-up:#2dd4bf; --c-down:#5b8bbf; /* cool up/down for candles */
--radius:18px;
--transition:.25s cubic-bezier(.4,0,.2,1);
--font:"Segoe UI",system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif;
}
/* ---------- Dark theme (default) : professional, finance-focused -------- */
html[data-theme="dark"]{
--bg-0:#070d1a; --bg-1:#0b1428; --bg-grad:radial-gradient(1200px 600px at 80% -10%,#0f2748 0,transparent 60%),radial-gradient(900px 500px at -10% 110%,#062a33 0,transparent 55%);
--card:rgba(17,29,53,.62);
--card-solid:#111d35;
--border:rgba(120,170,225,.14);
--text:#e8f1ff; --text-dim:#9fb3d1; --text-faint:#6c84a6;
--accent:var(--c-cyan); --accent-2:var(--c-teal);
--shadow:0 18px 45px -20px rgba(0,0,0,.75);
--pos:#34d399; --neg:#5b8bbf; --grid:rgba(120,170,225,.10);
--input:rgba(8,16,32,.7);
}
/* ---------- Light theme : clean & airy ---------------------------------- */
html[data-theme="light"]{
--bg-0:#eef4fb; --bg-1:#f6fafe; --bg-grad:radial-gradient(1100px 600px at 85% -10%,#dceefb 0,transparent 60%),radial-gradient(900px 500px at -10% 110%,#d9f7f0 0,transparent 55%);
--card:rgba(255,255,255,.72);
--card-solid:#ffffff;
--border:rgba(40,90,140,.14);
--text:#0c2440; --text-dim:#3f5b7f; --text-faint:#73879f;
--accent:#0e9bb8; --accent-2:#0d9488;
--shadow:0 16px 38px -22px rgba(20,60,110,.40);
--pos:#0d9488; --neg:#4f7eb0; --grid:rgba(40,90,140,.10);
--input:rgba(255,255,255,.85);
}
*{box-sizing:border-box;margin:0;padding:0}
body{
font-family:var(--font);
color:var(--text);
background:var(--bg-0);
background-image:var(--bg-grad);
background-attachment:fixed;
min-height:100vh;
line-height:1.45;
transition:background var(--transition),color var(--transition);
-webkit-font-smoothing:antialiased;
}
/* ===== Layout shell ===== */
.wrap{max-width:1320px;margin:0 auto;padding:22px clamp(14px,3vw,34px) 60px}
/* ===== Glass card ===== */
.card{
background:var(--card);
border:1px solid var(--border);
border-radius:var(--radius);
box-shadow:var(--shadow);
backdrop-filter:blur(14px) saturate(140%);
-webkit-backdrop-filter:blur(14px) saturate(140%);
padding:20px;
transition:transform var(--transition),box-shadow var(--transition),border var(--transition);
}
.card:hover{transform:translateY(-3px);box-shadow:0 24px 55px -22px rgba(0,0,0,.6)}
.card h2,.card h3{font-size:.78rem;letter-spacing:.12em;text-transform:uppercase;color:var(--text-dim);font-weight:700;margin-bottom:14px;display:flex;align-items:center;gap:8px}
/* ===== Header ===== */
header.topbar{
display:flex;flex-wrap:wrap;align-items:center;gap:14px;
margin-bottom:22px;
}
.brand{display:flex;align-items:center;gap:12px;margin-right:auto}
.logo{
width:44px;height:44px;border-radius:13px;flex:none;
background:linear-gradient(135deg,var(--c-cyan),var(--c-teal));
display:grid;place-items:center;color:#04222b;font-weight:900;font-size:1.2rem;
box-shadow:0 8px 22px -6px var(--c-cyan);
}
.brand h1{font-size:1.25rem;letter-spacing:.3px}
.brand small{display:block;color:var(--text-faint);font-size:.72rem;letter-spacing:.18em;text-transform:uppercase}
.controls{display:flex;flex-wrap:wrap;align-items:center;gap:10px}
.field{display:flex;flex-direction:column;gap:3px}
.field label{font-size:.62rem;letter-spacing:.14em;text-transform:uppercase;color:var(--text-faint);padding-left:4px}
select,input,button{font-family:inherit;color:var(--text)}
select,input{
background:var(--input);border:1px solid var(--border);border-radius:11px;
padding:9px 12px;font-size:.9rem;min-width:120px;outline:none;transition:border var(--transition),box-shadow var(--transition);
}
select:focus,input:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent)}
.btn{
cursor:pointer;border:1px solid var(--border);border-radius:11px;
background:var(--input);padding:9px 14px;font-size:.85rem;font-weight:600;
display:inline-flex;align-items:center;gap:7px;transition:all var(--transition);
}
.btn:hover{border-color:var(--accent);color:var(--accent);transform:translateY(-1px)}
.btn.primary{background:linear-gradient(135deg,var(--c-cyan),var(--c-teal));color:#03222b;border:none}
.btn.primary:hover{filter:brightness(1.08);color:#03222b}
.btn.icon{padding:9px 11px}
#refreshBtn.spin svg{animation:spin 1s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.updated{font-size:.72rem;color:var(--text-faint);display:flex;align-items:center;gap:6px}
.dot{width:8px;height:8px;border-radius:50%;background:var(--pos);box-shadow:0 0 8px var(--pos)}
/* ===== Grid ===== */
.grid{display:grid;gap:18px}
.top-grid{grid-template-columns:minmax(0,1.25fr) minmax(0,1.15fr) minmax(0,.9fr);margin-bottom:18px;align-items:start}
.chart-grid{grid-template-columns:1fr 1fr;margin-bottom:18px}
.bottom-grid{grid-template-columns:1.4fr 1fr;margin-bottom:18px}
@media(max-width:1040px){.top-grid,.chart-grid,.bottom-grid{grid-template-columns:1fr}}
/* ===== Current value card ===== */
.pair-row{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
.pair-row .pair{font-size:1rem;color:var(--text-dim);font-weight:600}
.rate{font-size:clamp(2.2rem,5vw,3rem);font-weight:800;letter-spacing:-1px;line-height:1.1;
background:linear-gradient(120deg,var(--accent),var(--accent-2));-webkit-background-clip:text;background-clip:text;color:transparent}
.sub{margin-top:6px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;color:var(--text-dim);font-size:.9rem}
.change{font-weight:700}
.change.up{color:var(--pos)} .change.down{color:var(--neg)}
.badge{display:inline-flex;align-items:center;gap:6px;font-size:.72rem;font-weight:700;
padding:5px 11px;border-radius:999px;letter-spacing:.04em;border:1px solid transparent}
.badge.up{background:color-mix(in srgb,var(--pos) 16%,transparent);color:var(--pos);border-color:color-mix(in srgb,var(--pos) 35%,transparent)}
.badge.down{background:color-mix(in srgb,var(--neg) 16%,transparent);color:var(--neg);border-color:color-mix(in srgb,var(--neg) 35%,transparent)}
.badge.flat{background:color-mix(in srgb,var(--c-slate) 20%,transparent);color:var(--text-dim);border-color:color-mix(in srgb,var(--c-slate) 35%,transparent)}
.mini-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:18px}
.mini{background:var(--input);border:1px solid var(--border);border-radius:12px;padding:10px}
.mini .k{font-size:.62rem;text-transform:uppercase;letter-spacing:.1em;color:var(--text-faint)}
.mini .v{font-size:1rem;font-weight:700;margin-top:3px}
/* ===== AI / pattern lists ===== */
.insight{display:flex;gap:11px;padding:11px 0;border-bottom:1px dashed var(--border)}
.insight:last-child{border-bottom:none}
.insight .ic{flex:none;width:30px;height:30px;border-radius:9px;display:grid;place-items:center;font-size:.95rem;
background:color-mix(in srgb,var(--accent) 16%,transparent);color:var(--accent)}
.insight p{font-size:.86rem;color:var(--text);line-height:1.4}
.insight p span{color:var(--text-faint);display:block;font-size:.74rem;margin-top:2px}
.pattern{display:flex;align-items:center;gap:10px;padding:9px 0;border-bottom:1px dashed var(--border)}
.pattern:last-child{border-bottom:none}
.tag{font-size:.66rem;font-weight:800;letter-spacing:.05em;padding:4px 9px;border-radius:8px;flex:none;text-transform:uppercase}
.tag.t-up{background:color-mix(in srgb,var(--c-teal) 20%,transparent);color:var(--c-teal)}
.tag.t-down{background:color-mix(in srgb,var(--c-down) 22%,transparent);color:var(--neg)}
.tag.t-flat{background:color-mix(in srgb,var(--c-slate) 22%,transparent);color:var(--text-dim)}
.tag.t-vol{background:color-mix(in srgb,var(--c-sky) 22%,transparent);color:var(--c-sky)}
.tag.t-break{background:color-mix(in srgb,var(--c-cyan) 22%,transparent);color:var(--c-cyan)}
.pattern small{color:var(--text-dim);font-size:.8rem}
/* ===== Charts ===== */
.chart-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:10px}
.chart-box{position:relative;height:320px}
.range-btns{display:flex;gap:6px;background:var(--input);padding:4px;border-radius:11px;border:1px solid var(--border)}
.range-btns button{border:none;background:transparent;color:var(--text-dim);font-size:.78rem;font-weight:600;
padding:5px 11px;border-radius:8px;cursor:pointer;transition:all var(--transition)}
.range-btns button.active{background:linear-gradient(135deg,var(--c-cyan),var(--c-teal));color:#04222b}
.est-note{font-size:.7rem;color:var(--text-faint);font-style:italic;margin-top:8px}
/* ===== Heatmap ===== */
.heat-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:10px}
.heat-cell{border-radius:13px;padding:14px 12px;color:#eafcff;position:relative;overflow:hidden;
border:1px solid rgba(255,255,255,.08);transition:transform var(--transition)}
.heat-cell:hover{transform:scale(1.04)}
.heat-cell .code{font-weight:800;font-size:1.05rem;letter-spacing:.04em}
.heat-cell .pct{font-size:.78rem;opacity:.92;font-weight:600;margin-top:3px}
.heat-cell .lbl{font-size:.62rem;opacity:.8;text-transform:uppercase;letter-spacing:.08em;margin-top:6px}
.heat-legend{display:flex;align-items:center;gap:10px;margin-top:14px;font-size:.72rem;color:var(--text-faint)}
.heat-bar{flex:1;height:9px;border-radius:6px;background:linear-gradient(90deg,#0b2233,#0e4a5a,#138a8a,#1fb6a6,#7ff0c8)}
/* ===== Calculator ===== */
.calc-grid{display:grid;grid-template-columns:1fr auto 1fr;gap:9px;align-items:end}
@media(max-width:540px){.calc-grid{grid-template-columns:1fr}}
.calc-grid select,.calc-grid input{min-width:0}
.calc-swap{align-self:end;margin-bottom:2px}
.calc-result{margin-top:12px;background:var(--input);border:1px solid var(--border);border-radius:14px;padding:12px;text-align:center}
.calc-result .big{font-size:clamp(1.3rem,3vw,1.65rem);font-weight:800;
background:linear-gradient(120deg,var(--accent),var(--accent-2));-webkit-background-clip:text;background-clip:text;color:transparent}
.calc-result .rate-line{font-size:.78rem;color:var(--text-faint);margin-top:5px}
/* ===== Table ===== */
.table-tools{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px}
.table-tools input{flex:1;min-width:160px}
.table-scroll{max-height:430px;overflow:auto;border-radius:12px}
table{width:100%;border-collapse:collapse;font-size:.86rem}
thead th{position:sticky;top:0;background:var(--card-solid);text-align:left;padding:11px 12px;
font-size:.66rem;letter-spacing:.08em;text-transform:uppercase;color:var(--text-faint);cursor:pointer;user-select:none;white-space:nowrap;border-bottom:1px solid var(--border)}
thead th:hover{color:var(--accent)}
thead th .arr{opacity:.5;font-size:.7rem}
tbody td{padding:10px 12px;border-bottom:1px solid var(--border)}
tbody tr{transition:background var(--transition)}
tbody tr:hover{background:color-mix(in srgb,var(--accent) 8%,transparent)}
tbody td.code{font-weight:700}
tbody td.name{color:var(--text-dim)}
.num{text-align:right;font-variant-numeric:tabular-nums}
.row-strong{box-shadow:inset 3px 0 0 var(--c-teal)}
.row-weak{box-shadow:inset 3px 0 0 var(--c-down)}
.pill{font-size:.68rem;font-weight:700;padding:3px 8px;border-radius:7px}
.pill.up{background:color-mix(in srgb,var(--pos) 16%,transparent);color:var(--pos)}
.pill.down{background:color-mix(in srgb,var(--neg) 18%,transparent);color:var(--neg)}
.pill.flat{background:color-mix(in srgb,var(--c-slate) 20%,transparent);color:var(--text-dim)}
.lead{font-size:.64rem;font-weight:800;letter-spacing:.06em;margin-left:6px;padding:2px 6px;border-radius:6px;text-transform:uppercase}
.lead.s{background:color-mix(in srgb,var(--c-teal) 22%,transparent);color:var(--c-teal)}
.lead.w{background:color-mix(in srgb,var(--c-down) 24%,transparent);color:var(--neg)}
/* ===== Loading / skeleton / error ===== */
.skeleton{background:linear-gradient(90deg,var(--input) 25%,color-mix(in srgb,var(--accent) 10%,var(--input)) 50%,var(--input) 75%);
background-size:200% 100%;animation:shimmer 1.3s infinite;border-radius:8px;color:transparent!important}
@keyframes shimmer{to{background-position:-200% 0}}
#overlay{position:fixed;inset:0;background:color-mix(in srgb,var(--bg-0) 70%,transparent);backdrop-filter:blur(3px);
display:none;place-items:center;z-index:50}
#overlay.show{display:grid}
.loader{display:flex;flex-direction:column;align-items:center;gap:14px;color:var(--text-dim);font-size:.85rem}
.ring{width:46px;height:46px;border-radius:50%;border:3px solid var(--border);border-top-color:var(--accent);animation:spin .8s linear infinite}
#toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%) translateY(120%);
background:var(--card-solid);border:1px solid var(--neg);color:var(--text);padding:13px 18px;border-radius:13px;
box-shadow:var(--shadow);z-index:60;transition:transform .35s ease;max-width:90vw;font-size:.86rem;display:flex;gap:10px;align-items:center}
#toast.show{transform:translateX(-50%) translateY(0)}
#toast .x{cursor:pointer;color:var(--text-faint)}
footer{text-align:center;color:var(--text-faint);font-size:.74rem;margin-top:30px;line-height:1.6}
footer a{color:var(--accent);text-decoration:none}
</style>
</head>
<body>
<div class="wrap">
<!-- ============================ HEADER ============================ -->
<header class="topbar">
<div class="brand">
<div class="logo"></div>
<div>
<h1>FX&nbsp;Pulse</h1>
<small>Foreign Exchange Dashboard</small>
</div>
</div>
<div class="controls">
<div class="field">
<label for="baseSel">Base</label>
<select id="baseSel" aria-label="Base currency"></select>
</div>
<div class="field">
<label for="targetSel">Target</label>
<select id="targetSel" aria-label="Target currency"></select>
</div>
<button class="btn icon" id="refreshBtn" title="Refresh data" aria-label="Refresh">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15"/></svg>
</button>
<button class="btn icon" id="themeBtn" title="Toggle theme" aria-label="Toggle theme">
<span id="themeIcon">🌙</span>
</button>
</div>
</header>
<div class="updated" style="margin:-8px 0 18px 2px">
<span class="dot"></span> Last updated: <strong id="updatedAt"></strong>
<span id="srcLabel" style="margin-left:6px"></span>
</div>
<!-- ====================== TOP ROW : value / AI / calculator ====================== -->
<section class="grid top-grid">
<!-- Current value -->
<article class="card" id="valueCard">
<h2>📈 Current Value</h2>
<div class="pair-row">
<span class="pair" id="pairLabel">—/—</span>
<span class="badge flat" id="trendBadge">Stable</span>
</div>
<div class="rate" id="rateBig"></div>
<div class="sub">
<span class="change flat" id="changeVal"></span>
<span class="pair" id="changeNote">daily change</span>
</div>
<div class="mini-stats">
<div class="mini"><div class="k">7D High</div><div class="v" id="m7high"></div></div>
<div class="mini"><div class="k">7D Low</div><div class="v" id="m7low"></div></div>
<div class="mini"><div class="k">Volatility</div><div class="v" id="mvol"></div></div>
</div>
</article>
<!-- AI insight -->
<article class="card" id="aiCard">
<h2>🤖 AI Insight</h2>
<div id="aiList">
<div class="insight"><div class="ic"></div><p>Analyzing market data…</p></div>
</div>
</article>
<!-- Calculator -->
<article class="card" id="calcCard">
<h2>🧮 Currency Calculator</h2>
<div class="calc-grid">
<div class="field">
<label for="calcAmount">Amount</label>
<input type="number" id="calcAmount" value="100" min="0" step="any" />
<select id="calcFrom" style="margin-top:8px"></select>
</div>
<button class="btn icon calc-swap" id="calcSwap" title="Swap currencies"></button>
<div class="field">
<label for="calcTo">Converts to</label>
<select id="calcTo"></select>
</div>
</div>
<div class="calc-result">
<div class="big" id="calcOut"></div>
<div class="rate-line" id="calcRate"></div>
</div>
</article>
</section>
<!-- ====================== CHART ROW : candlestick / line ====================== -->
<section class="grid chart-grid">
<article class="card">
<div class="chart-head">
<h2 style="margin:0">🕯️ Candlestick — Daily Estimated Candles</h2>
</div>
<div class="chart-box"><canvas id="candleChart"></canvas></div>
<p class="est-note">* Estimated candles: previous-day rate = open, current-day rate = close; high/low approximated from daily range &amp; recent volatility. Not exchange-provided OHLC.</p>
</article>
<article class="card">
<div class="chart-head">
<h2 style="margin:0">📉 Historical Rate</h2>
<div class="range-btns" id="rangeBtns">
<button data-d="7">7D</button>
<button data-d="14">14D</button>
<button data-d="30" class="active">30D</button>
<button data-d="90">90D</button>
</div>
</div>
<div class="chart-box"><canvas id="lineChart"></canvas></div>
<p class="est-note">Solid line = exchange rate · dashed line = 7-period moving average.</p>
</article>
</section>
<!-- ====================== HEATMAP + PATTERNS ====================== -->
<section class="grid bottom-grid">
<article class="card">
<h2>🌡️ Currency Strength Heatmap <span style="text-transform:none;letter-spacing:0;font-weight:500;color:var(--text-faint)">(7-day, basket-weighted)</span></h2>
<div class="heat-grid" id="heatGrid"></div>
<div class="heat-legend"><span>Weak</span><div class="heat-bar"></div><span>Strong</span></div>
</article>
<!-- Patterns -->
<article class="card" id="patternCard">
<h2>🔎 Pattern &amp; Trend Analysis</h2>
<div id="patternList">
<div class="pattern"><span class="tag t-flat">scan</span><small>Detecting patterns…</small></div>
</div>
</article>
</section>
<!-- ====================== TABLE ====================== -->
<section class="card">
<h2>💱 Exchange Rates <span style="text-transform:none;letter-spacing:0;font-weight:500;color:var(--text-faint)" id="tableBaseLbl"></span></h2>
<div class="table-tools">
<input type="search" id="tableSearch" placeholder="Search currency code or name…" />
<span class="updated" id="tableCount"></span>
</div>
<div class="table-scroll">
<table id="rateTable">
<thead>
<tr>
<th data-sort="code">Code <span class="arr"></span></th>
<th data-sort="name">Name <span class="arr"></span></th>
<th data-sort="rate" class="num">Rate <span class="arr"></span></th>
<th data-sort="change" class="num">Change % <span class="arr"></span></th>
<th data-sort="change">Trend</th>
</tr>
</thead>
<tbody id="rateBody"></tbody>
</table>
</div>
</section>
<footer>
Data: <a href="https://github.com/fawazahmed0/exchange-api" target="_blank" rel="noopener">fawazahmed0/exchange-api</a> · free, daily rates · automatic CDN fallback.<br/>
Candlestick OHLC is estimated from daily values for illustration only. Not financial advice.
</footer>
</div>
<!-- Loading overlay + error toast -->
<div id="overlay"><div class="loader"><div class="ring"></div><span id="loadMsg">Fetching exchange data…</span></div></div>
<div id="toast"><span id="toastMsg"></span><span class="x" onclick="document.getElementById('toast').classList.remove('show')"></span></div>
<script>
"use strict";
/* =========================================================================
FX PULSE — single-file 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);
</script>
</body>
</html>