983 lines
46 KiB
HTML
983 lines
46 KiB
HTML
<!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 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 & 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 & 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>
|