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