1/* iCafe Gold â front-end (Phase 1 baseline) 2 - auto-refresh board every 60s from REST (reads cache; never hits source from browser) 3 - calculator realtime 4 - SPDR holdings chart (Chart.js lazy-loaded), range tabs 1m/3m/1y */ 5(function () { 6 "use strict"; 7 var CFG = window.ICF_GOLD || {}; 8 9 /* ---------- helpers ---------- */ 10 function $(sel, ctx) { return (ctx || document).querySelector(sel); } 11 function $all(sel, ctx) { return Array.prototype.slice.call((ctx || document).querySelectorAll(sel)); } 12 function num(v) { return parseFloat(String(v == null ? "" : v).replace(/[^0-9.\-]/g, "")) || 0; } 13 function fmt(n, d) { d = d == null ? 2 : d; return n.toLocaleString("en-US", { minimumFractionDigits: d, maximumFractionDigits: d }); } 14 15 /* ---------- auto-refresh board ---------- */ 16 function setText(root, key, val) { 17 $all('[data-icf="' + key + '"]', root).forEach(function (el) { if (val != null && val !== "") el.textContent = val; }); 18 } 19 function applyData(d) { 20 $all("[data-icf-gold-board]").forEach(function (root) { 21 var t = d.thai || {}, s = d.spdr || {}; 22 setText(root, "bar_buy", t.bar_buy); setText(root, "bar_sell", t.bar_sell); 23 setText(root, "orn_buy", t.orn_buy); setText(root, "orn_sell", t.orn_sell); 24 setText(root, "stamp", t.date); 25 var chg = $('[data-icf="change"]', root); 26 if (chg && t.change != null) { 27 chg.textContent = "à¹à¸à¸¥à¸µà¹à¸¢à¸à¹à¸à¸¥à¸à¸§à¸±à¸à¸à¸µà¹: " + t.change + " à¸à¸²à¸"; 28 chg.classList.toggle("is-up", String(t.change).indexOf("-") !== 0); 29 chg.classList.toggle("is-down", String(t.change).indexOf("-") === 0); 30 } 31 setText(root, "spdr_current", s.current); setText(root, "spdr_d1", s.diff_1d_f); 32 setText(root, "spdr_d7", s.diff_7d_f); setText(root, "spdr_date", s.date); 33 setText(root, "spot", d.spot); setText(root, "fx", d.fx); setText(root, "spot_baht", d.spot_baht); 34 }); 35 // keep calculators in sync with refreshed prices 36 if (d.thai) { 37 $all(".icf-gold-calc").forEach(function (c) { 38 if (d.thai.bar_buy) c.dataset.barBuy = num(d.thai.bar_buy); 39 if (d.thai.bar_sell) c.dataset.barSell = num(d.thai.bar_sell); 40 if (d.thai.orn_buy) c.dataset.ornBuy = num(d.thai.orn_buy); 41 if (d.thai.orn_sell) c.dataset.ornSell = num(d.thai.orn_sell); 42 recalc(c); 43 }); 44 } 45 } 46 function refresh() { 47 if (!CFG.rest) return; 48 fetch(CFG.rest, { credentials: "same-origin" }) 49 .then(function (r) { return r.ok ? r.json() : null; }) 50 .then(function (d) { if (d) applyData(d); }) 51 .catch(function () {}); 52 } 53 54 /* ---------- calculator ---------- */ 55 function recalc(c) { 56 var type = c.dataset.type || "bar"; 57 var w = num(c.dataset.weight || (c.querySelector(".icf-gold-slider") ? c.querySelector(".icf-gold-slider").value : 1)); 58 var buy = type === "orn" ? num(c.dataset.ornBuy) : num(c.dataset.barBuy); 59 var sell = type === "orn" ? num(c.dataset.ornSell) : num(c.dataset.barSell); 60 var outBuy = c.querySelector(".icf-gold-calc-buy"); 61 var outSell = c.querySelector(".icf-gold-calc-sell"); 62 if (outBuy) outBuy.textContent = buy > 0 ? fmt(buy * w, 0) + " ฿" : "â"; 63 if (outSell) outSell.textContent = sell > 0 ? fmt(sell * w, 0) + " ฿" : "â"; 64 } 65 function initCalc(c) { 66 c.dataset.type = "bar"; 67 var slider = c.querySelector(".icf-gold-slider"); 68 var out = c.querySelector(".icf-gold-weight-out"); 69 if (slider) { 70 slider.addEventListener("input", function () { 71 c.dataset.weight = slider.value; 72 if (out) out.textContent = parseFloat(slider.value).toFixed(2); 73 recalc(c); 74 }); 75 } 76 $all(".icf-gold-type", c).forEach(function (btn) { 77 btn.addEventListener("click", function () {
78 $all(".icf-gold-type", c).forEach(function (b) { b.classList.remove("is-active"); }); 79 btn.classList.add("is-active"); 80 c.dataset.type = btn.dataset.type; 81 recalc(c); 82 }); 83 }); 84 // tab switching (now / retro) + retro calc 85 var tabs = $all(".icf-gold-calc-tab", c); 86 if (tabs.length) { 87 tabs.forEach(function (btn) { 88 btn.addEventListener("click", function () { 89 tabs.forEach(function (b) { b.classList.remove("is-active"); }); 90 btn.classList.add("is-active"); 91 var which = btn.dataset.calcTab; 92 $all(".icf-gold-calc-panel", c).forEach(function (p) { 93 p.hidden = (p.dataset.calcPanel !== which); 94 }); 95 }); 96 }); 97 initAcalc(c); 98 } 99 recalc(c); 100 } 101 102 /* ---------- retro profit calculator (uses gold-close history) ---------- */ 103 function initAcalc(c) { 104 if (!CFG.goldClose) return; 105 var amount = c.querySelector(".icf-gold-acalc-amount"); 106 var dateIn = c.querySelector(".icf-gold-acalc-date"); 107 var res = c.querySelector(".icf-gold-acalc-result"); 108 var msg = c.querySelector(".icf-gold-acalc-msg"); 109 if (!amount || !dateIn) return; 110 var series = null; 111 112 function nearestClose(dStr) { 113 // latest entry with date <= chosen; fallback earliest 114 var pick = null; 115 for (var i = 0; i < series.length; i++) { 116 if (series[i].date <= dStr) pick = series[i]; else break; 117 } 118 return pick || series[0]; 119 } 120 function compute() { 121 if (!series || !series.length) return; 122 var amt = parseFloat(amount.value); 123 var d = dateIn.value; 124 if (!amt || amt <= 0 || !d) { if (res) res.hidden = true; return; } 125 var buy = nearestClose(d); 126 var now = series[series.length - 1]; 127 if (!buy || !now || !buy.close) { if (res) res.hidden = true; return; } 128 var ratio = now.close / buy.close; 129 var valueNow = amt * ratio; 130 var pl = valueNow - amt; 131 var plPct = (ratio - 1) * 100; 132 if (msg) { 133 msg.hidden = (buy.date === d); 134 if (buy.date !== d) msg.textContent = "à¹à¸à¹à¸£à¸²à¸à¸²à¸§à¸±à¸à¸à¸³à¸à¸²à¸£à¹à¸à¸¥à¹à¹à¸à¸µà¸¢à¸: " + buy.date; 135 } 136 c.querySelector(".icf-gold-acalc-pbuy").textContent = fmt(buy.close, 2) + " USD (" + buy.date + ")"; 137 c.querySelector(".icf-gold-acalc-pnow").textContent = fmt(now.close, 2) + " USD (" + now.date + ")"; 138 c.querySelector(".icf-gold-acalc-value").textContent = fmt(valueNow, 0) + " ฿"; 139 var plEl = c.querySelector(".icf-gold-acalc-pl"); 140 var up = pl > 0, dn = pl < 0; 141 plEl.className = "icf-gold-acalc-pl " + (up ? "is-up" : dn ? "is-down" : ""); 142 plEl.textContent = (up ? "â² +" : dn ? "â¼ " : "⢠") + fmt(pl, 0) + " ฿ (" + (up ? "+" : "") + plPct.toFixed(2) + "%)"; 143 if (res) res.hidden = false; 144 } 145 fetch(CFG.goldClose + "?range=1y", { credentials: "same-origin" }) 146 .then(function (r) { return r.ok ? r.json() : null; }) 147 .then(function (j) { 148 if (!j || !j.series || !j.series.length) { if (msg) { msg.hidden = false; msg.textContent = "à¸à¹à¸à¸¡à¸¹à¸¥à¸£à¸²à¸à¸²à¸¢à¹à¸à¸à¸«à¸¥à¸±à¸à¹à¸¡à¹à¸à¸£à¹à¸à¸¡"; } return; } 149 series = j.series; 150 var first = series[0].date, last = series[series.length - 1].date; 151 dateIn.min = first; dateIn.max = last; 152 // default ~6 months back (or earliest) 153 if (!dateIn.value) { 154 var mid = series[Math.max(0, series.length - 126)]; 155 dateIn.value = mid ? mid.date : first; 156 } 157 amount.addEventListener("input", compute); 158 dateIn.addEventListener("change", compute); 159 dateIn.addEventListener("input", compute); 160 compute(); 161 }) 162 .catch(function () { if (msg) { msg.hidden = false; msg.textContent = "à¹à¸«à¸¥à¸à¸à¹à¸à¸¡à¸¹à¸¥à¸£à¸²à¸à¸²à¸¢à¹à¸à¸à¸«à¸¥à¸±à¸à¹à¸¡à¹à¹à¸à¹"; } }); 163 } 164 165 /* ---------- price vs 52-week high/low (gold-close) ---------- */ 166 function initAth(box) { 167 if (!CFG.goldClose) return; 168 var loading = box.querySelector('[data-ath="loading"]'); 169 var set = function (k, v) { var e = box.querySelector('[data-ath="' + k + '"]'); if (e) e.textContent = v; }; 170 fetch(CFG.goldClose + "?range=1y", { credentials: "same-origin" }) 171 .then(function (r) { return r.ok ? r.json() : null; }) 172 .then(function (j) { 173 if (!j || !j.series || !j.series.length) { if (loading) loading.textContent = "à¸à¹à¸à¸¡à¸¹à¸¥à¸£à¸²à¸à¸²à¹à¸¡à¹à¸à¸£à¹à¸à¸¡"; return; } 174 var s = j.series, hi = s[0], lo = s[0], cur = s[s.length - 1];
175 s.forEach(function (p) { if (p.close > hi.close) hi = p; if (p.close < lo.close) lo = p; }); 176 var range = hi.close - lo.close; 177 var pos = range > 0 ? ((cur.close - lo.close) / range) * 100 : 50; 178 pos = Math.max(0, Math.min(100, pos)); 179 var fromHigh = hi.close > 0 ? ((hi.close - cur.close) / hi.close) * 100 : 0; 180 set("current", fmt(cur.close, 2)); 181 set("low", fmt(lo.close, 2)); set("lowdate", "(" + lo.date + ")"); 182 set("high", fmt(hi.close, 2)); set("highdate", "(" + hi.date + ")"); 183 var frm = box.querySelector('[data-ath="fromhigh"]'); 184 if (frm) { 185 if (fromHigh <= 0.05) { frm.textContent = "à¸à¸µà¹à¸à¸¸à¸à¸ªà¸¹à¸à¸ªà¸¸à¸ 52 สัà¸à¸à¸²à¸«à¹"; frm.className = "icf-gold-ath__frm is-up"; } 186 else { frm.textContent = "à¸à¹à¸³à¸à¸§à¹à¸²à¸à¸¸à¸à¸ªà¸¹à¸à¸ªà¸¸à¸ " + fromHigh.toFixed(1) + "%"; frm.className = "icf-gold-ath__frm is-down"; } 187 } 188 var dot = box.querySelector('[data-ath="dot"]'); 189 if (dot) dot.style.left = pos.toFixed(1) + "%"; 190 if (loading) loading.hidden = true; 191 192 // reuse for context card (no extra query) 193 var ctxA = document.querySelector('[data-ctx="ath"]'); 194 if (ctxA) { 195 if (fromHigh <= 0.05) ctxA.innerHTML = '<em class="icf-gold-delta is-up">ATH</em>'; 196 else ctxA.innerHTML = '<em class="icf-gold-delta is-down">â¼ -' + fromHigh.toFixed(1) + '%</em>'; 197 } 198 }) 199 .catch(function () { if (loading) loading.textContent = "à¹à¸«à¸¥à¸à¸à¹à¸à¸¡à¸¹à¸¥à¸£à¸²à¸à¸²à¹à¸¡à¹à¹à¸à¹"; }); 200 } 201 202 /* ---------- chart (Chart.js lazy) ---------- */ 203 var chartLib = null; 204 function loadChartLib() { 205 if (window.Chart) { return Promise.resolve(window.Chart); } 206 if (chartLib) { return chartLib; } 207 chartLib = new Promise(function (resolve, reject) { 208 var s = document.createElement("script"); 209 s.src = "https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"; 210 s.onload = function () { resolve(window.Chart); }; 211 s.onerror = reject; 212 document.head.appendChild(s); 213 }); 214 return chartLib; 215 } 216 function initChart(box) { 217 if (!CFG.spdr) return; 218 var canvas = box.querySelector(".icf-gold-chart-canvas"); 219 var empty = box.querySelector(".icf-gold-chart-empty"); 220 var inst = null, full = []; 221 function draw(range) { 222 loadChartLib().then(function (Chart) { 223 var data = full.slice(-range); 224 var labels = data.map(function (p) { return new Date(p.t * 1000).toLocaleDateString("th-TH", { day: "2-digit", month: "short" }); }); 225 var vals = data.map(function (p) { return p.v; }); 226 if (inst) { inst.data.labels = labels; inst.data.datasets[0].data = vals; inst.update(); return; } 227 inst = new Chart(canvas.getContext("2d"), { 228 type: "line", 229 data: { labels: labels, datasets: [{ label: "Holdings (t)", data: vals, borderColor: "#b8943f", backgroundColor: "rgba(184,148,63,.12)", fill: true, tension: .25, pointRadius: 0, borderWidth: 2 }] }, 230 options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { color: "#9a927f", maxTicksLimit: 8 }, grid: { display: false } }, y: { ticks: { color: "#9a927f" }, grid: { color: "rgba(255,255,255,.05)" } } } } 231 }); 232 }); 233 } 234 if (empty) empty.hidden = false; 235 fetch(CFG.spdr, { credentials: "same-origin" }) 236 .then(function (r) { return r.ok ? r.json() : null; }) 237 .then(function (j) { 238 if (empty) empty.hidden = true; 239 if (!j || !j.series) return; 240 full = j.series; 241 draw(30);
242 $all(".icf-gold-range", box).forEach(function (btn) { 243 btn.addEventListener("click", function () { 244 $all(".icf-gold-range", box).forEach(function (b) { b.classList.remove("is-active"); }); 245 btn.classList.add("is-active"); 246 draw(parseInt(btn.dataset.range, 10) || 30); 247 }); 248 }); 249 }) 250 .catch(function () { if (empty) { empty.hidden = false; empty.textContent = "à¹à¸«à¸¥à¸à¸à¹à¸à¸¡à¸¹à¸¥à¸à¸£à¸²à¸à¹à¸¡à¹à¹à¸à¹"; } }); 251 } 252 253 /* ---------- SPDR page chart (REST history, ranges 1m/3m/1y) ---------- */ 254 function initSpdrChart(box) { 255 if (!CFG.spdrHistory) return; 256 var canvas = box.querySelector(".icf-gold-chart-canvas"); 257 var empty = box.querySelector(".icf-gold-chart-empty"); 258 var inst = null, cacheH = {}, cacheC = {}; 259 // project close-price series onto SPDR dates (null where missing -> spanGaps) 260 function alignClose(series, closeSeries) { 261 if (!closeSeries || !closeSeries.length) return null; 262 var m = {}, any = false; 263 closeSeries.forEach(function (p) { m[p.date] = p.close; }); 264 var out = series.map(function (p) { 265 var v = (m[p.date] != null) ? m[p.date] : null; 266 if (v != null) any = true; 267 return v; 268 }); 269 return any ? out : null; 270 } 271 function render(series, closeSeries) { 272 loadChartLib().then(function (Chart) { 273 var labels = series.map(function (p) { return new Date(p.date + "T00:00:00").toLocaleDateString("th-TH", { day: "2-digit", month: "short", year: "2-digit" }); }); 274 var vals = series.map(function (p) { return p.tonnes; }); 275 var closeVals = alignClose(series, closeSeries); 276 var hasClose = !!closeVals; 277 // zoom left Y so daily holding variation is visible (values never near 0) 278 var mn = Math.min.apply(null, vals), mx = Math.max.apply(null, vals); 279 var pad = Math.max(5, (mx - mn) * 0.3); 280 var ymin = Math.max(0, Math.floor((mn - pad) / 10) * 10); 281 var ymax = Math.ceil((mx + pad) / 10) * 10; 282 if (inst) { 283 inst.data.labels = labels; 284 inst.data.datasets[0].data = vals; 285 inst.data.datasets[1].data = hasClose ? closeVals : []; 286 inst.data.datasets[1].hidden = !hasClose; 287 inst.options.scales.y.min = ymin; 288 inst.options.scales.y.max = ymax; 289 inst.options.scales.y1.display = hasClose; 290 inst.update(); 291 return; 292 } 293 inst = new Chart(canvas.getContext("2d"), { 294 type: "bar", 295 data: { 296 labels: labels, 297 datasets: [ 298 { type: "bar", label: "à¸à¸·à¸à¸à¸£à¸à¸ (à¸à¸±à¸)", data: vals, backgroundColor: "#b8943f", borderColor: "#b8943f", borderRadius: 3, borderSkipped: false, barPercentage: 0.62, categoryPercentage: 0.86, order: 2, yAxisID: "y" }, 299 { type: "line", label: "ราà¸à¸²à¸à¸à¸ Close (USD)", data: hasClose ? closeVals : [], hidden: !hasClose, borderColor: "#f5edd6", backgroundColor: "transparent", borderWidth: 2, tension: 0.25, pointRadius: 0, spanGaps: true, fill: false, order: 1, yAxisID: "y1" } 300 ] 301 }, 302 options: { 303 responsive: true, maintainAspectRatio: false, 304 interaction: { mode: "index", intersect: false }, 305 plugins: { 306 legend: { display: true, position: "top", labels: { color: "#d8cfb6", usePointStyle: true, boxWidth: 10, padding: 14, font: { size: 12 } } }, 307 tooltip: { 308 backgroundColor: "#141318", borderColor: "rgba(184,148,63,.5)", borderWidth: 1, titleColor: "#f5edd6", bodyColor: "#f5edd6", padding: 10, 309 callbacks: { 310 label: function (ctx) { 311 var v = ctx.parsed.y; if (v == null) return null; 312 var num = Number(v).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 313 return (ctx.dataset.label || "") + ": " + num + (ctx.dataset.yAxisID === "y1" ? " USD" : " à¸à¸±à¸"); 314 } 315 } 316 } 317 }, 318 scales: { 319 x: { ticks: { color: "#9a927f", maxTicksLimit: 8, maxRotation: 0, autoSkip: true }, grid: { display: false } }, 320 y: { position: "left", min: ymin, max: ymax, ticks: { color: "#c8a44f" }, grid: { color: "rgba(255,255,255,.05)" }, title: { display: true, text: "à¸à¸·à¸à¸à¸£à¸à¸ (à¸à¸±à¸)", color: "#c8a44f", font: { size: 11 } } }, 321 y1: { position: "right", display: hasClose, beginAtZero: false, ticks: { color: "#e8e0c8" }, grid: { drawOnChartArea: false }, title: { display: true, text: "ราà¸à¸²à¸à¸à¸ Close (USD)", color: "#e8e0c8", font: { size: 11 } } } 322 } 323 } 324 }); 325 }); 326 } 327 function fetchSeries(url, range, store) { 328 if (store[range]) return Promise.resolve(store[range]); 329 return fetch(url + "?range=" + encodeURIComponent(range), { credentials: "same-origin" }) 330 .then(function (r) { return r.ok ? r.json() : null; }) 331 .then(function (j) { var s = (j && j.series) ? j.series : null; if (s) store[range] = s; return s; }) 332 .catch(function () { return null; }); 333 } 334 function load(range) { 335 if (empty) { empty.hidden = false; empty.textContent = "à¸à¸³à¸¥à¸±à¸à¹à¸«à¸¥à¸à¸à¹à¸à¸¡à¸¹à¸¥à¸à¸£à¸²à¸â¦"; } 336 var pH = fetchSeries(CFG.spdrHistory, range, cacheH); 337 // close line is optional: missing/empty -> bars only (no error) 338 var pC = CFG.goldClose ? fetchSeries(CFG.goldClose, range, cacheC) : Promise.resolve(null); 339 Promise.all([pH, pC]).then(function (res) { 340 if (empty) empty.hidden = true; 341 if (!res[0]) { if (empty) { empty.hidden = false; empty.textContent = "à¹à¸«à¸¥à¸à¸à¹à¸à¸¡à¸¹à¸¥à¸à¸£à¸²à¸à¹à¸¡à¹à¹à¸à¹"; } return; } 342 render(res[0], res[1]); 343 }); 344 } 345 load("1m");
346 $all(".icf-gold-range", box).forEach(function (btn) { 347 btn.addEventListener("click", function () { 348 $all(".icf-gold-range", box).forEach(function (b) { b.classList.remove("is-active"); }); 349 btn.classList.add("is-active"); 350 load(btn.dataset.range || "1m"); 351 }); 352 }); 353 } 354 355 function fmtThaiDelta(val, suffix) { 356 var n = parseInt(String(val == null ? "" : val).replace(/[^0-9-]/g, ""), 10) || 0; 357 var up = n > 0, dn = n < 0; 358 var arr = up ? "â²" : (dn ? "â¼" : "â¬"); 359 var num = up ? "+" + n : String(n); 360 return { text: arr + num + (suffix || ""), cls: up ? "is-up" : (dn ? "is-down" : ""), n: n, arr: arr, num: num }; 361 } 362 363 /* ---------- strip auto-refresh ---------- */ 364 function refreshStrip() { 365 if (!CFG.rest) return; 366 fetch(CFG.rest, { credentials: "same-origin" }) 367 .then(function (r) { return r.ok ? r.json() : null; }) 368 .then(function (d) { 369 if (!d) return; 370 $all("[data-icf-gold-strip]").forEach(function (root) { 371 var t = d.thai || {}; 372 setText(root, "bar_buy", t.bar_buy); setText(root, "bar_sell", t.bar_sell); 373 setText(root, "spot", d.spot); 374 var chEl = $('[data-icf="thai_change"]', root); 375 if (chEl && t.change != null && t.change !== "") { 376 var ch = fmtThaiDelta(t.change, " à¸à¸²à¸"); 377 chEl.className = "icf-gold-stripx__chg icf-gold-delta " + ch.cls; 378 chEl.textContent = ch.text; 379 } 380 var ctEl = $('[data-icf="thai_change_today"]', root); 381 if (ctEl && t.change_today != null) { 382 var ct = fmtThaiDelta(t.change_today, ""); 383 ctEl.className = "icf-gold-stripx__chg icf-gold-delta " + ct.cls; 384 ctEl.textContent = " · วัà¸à¸à¸µà¹ " + ct.arr + ct.num; 385 } 386 }); 387 }).catch(function () {}); 388 if (!CFG.spdrLatest) return; 389 fetch(CFG.spdrLatest, { credentials: "same-origin" }) 390 .then(function (r) { return r.ok ? r.json() : null; }) 391 .then(function (s) { 392 if (!s) return;
393 $all("[data-icf-gold-strip]").forEach(function (root) { 394 if (s.tonnes != null) setText(root, "spdr_tonnes", fmt(s.tonnes, 2)); 395 var dl = $('[data-icf="spdr_delta"]', root); 396 if (dl && s.change != null) { 397 var c = parseFloat(s.change) || 0, up = c > 0, dn = c < 0; 398 dl.innerHTML = '<em class="icf-gold-delta ' + (up ? "is-up" : dn ? "is-down" : "") + '">' + (up ? "â² +" : dn ? "â¼ " : "⢠") + fmt(c, 2) + '</em>'; 399 } 400 }); 401 }).catch(function () {}); 402 } 403 404 /* ---------- sidebar nav (in-page anchors) ---------- */ 405 function initNav(nav) { 406 var toggle = nav.querySelector(".icf-gold-nav__toggle"); 407 var links = $all(".icf-gold-nav__inner a", nav); 408 if (toggle) { 409 toggle.addEventListener("click", function () { 410 var open = nav.classList.toggle("is-open"); 411 toggle.setAttribute("aria-expanded", open ? "true" : "false"); 412 }); 413 } 414 links.forEach(function (a) { 415 a.addEventListener("click", function () { 416 // close the collapsed menu on mobile after picking a section 417 if (window.matchMedia("(max-width: 760px)").matches) { 418 nav.classList.remove("is-open"); 419 if (toggle) toggle.setAttribute("aria-expanded", "false"); 420 } 421 }); 422 }); 423 // active link highlight on scroll 424 var targets = links 425 .map(function (a) { var id = a.getAttribute("href"); return id && id.charAt(0) === "#" ? document.getElementById(id.slice(1)) : null; }) 426 .filter(Boolean); 427 if (targets.length && "IntersectionObserver" in window) { 428 var io = new IntersectionObserver(function (entries) { 429 entries.forEach(function (e) { 430 if (!e.isIntersecting) return; 431 links.forEach(function (a) { 432 a.classList.toggle("is-active", a.getAttribute("href") === "#" + e.target.id); 433 }); 434 }); 435 }, { rootMargin: "-40% 0px -55% 0px", threshold: 0 }); 436 targets.forEach(function (t) { io.observe(t); }); 437 } 438 } 439 440 /* ---------- SPDR sentiment gauge (computed from SPDR 30-day flow) ---------- */ 441 function sentiClamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } 442 function sentiPolar(cx, cy, r, deg) { 443 var a = deg * Math.PI / 180; 444 return { x: cx + r * Math.cos(a), y: cy - r * Math.sin(a) }; 445 } 446 function sentiArc(cx, cy, r, a0, a1) { 447 var s = sentiPolar(cx, cy, r, a0), e = sentiPolar(cx, cy, r, a1); 448 var large = Math.abs(a1 - a0) > 180 ? 1 : 0; 449 return "M" + s.x.toFixed(2) + " " + s.y.toFixed(2) + " A " + r + " " + r + " 0 " + large + " 1 " + e.x.toFixed(2) + " " + e.y.toFixed(2); 450 } 451 function sentiZone(score) { 452 if (score <= 25) return { key: "fear2", label: "à¸à¸¥à¸±à¸§à¸¡à¸²à¸", color: "#c0392b" }; 453 if (score <= 45) return { key: "fear1", label: "à¸à¹à¸à¸à¸à¹à¸²à¸à¸à¸¥à¸±à¸§", color: "#e2504a" }; 454 if (score <= 55) return { key: "neutral", label: "à¹à¸à¹à¸à¸à¸¥à¸²à¸", color: "#d4b86a" }; 455 if (score <= 75) return { key: "greed1", label: "à¹à¸¥à¸ ", color: "#9bcf6a" }; 456 return { key: "greed2", label: "à¹à¸¥à¸ มาà¸", color: "#5dca8a" }; 457 } 458 function sentiSummary(zone) { 459 switch (zone.key) { 460 case "fear2": return "à¸à¸à¸à¸à¸¸à¸ SPDR à¹à¸à¸à¸²à¸¢à¸à¸à¸à¸«à¸à¸±à¸ â à¸à¸²à¸£à¸¡à¸à¹à¸à¸¥à¸²à¸à¸ªà¸à¸²à¸à¸±à¸à¸à¸¢à¸¹à¹à¹à¸à¸ าวะà¸à¸¥à¸±à¸§à¸¡à¸²à¸"; 461 case "fear1": return "à¸à¸à¸à¸à¸¸à¸ SPDR à¸à¸¢à¸à¸¢à¸à¸²à¸¢à¸ªà¸¸à¸à¸à¸´ â à¸à¸²à¸£à¸¡à¸à¹à¸à¸¥à¸²à¸à¹à¸à¸à¹à¸à¸à¸²à¸à¸£à¸°à¸¡à¸±à¸à¸£à¸°à¸§à¸±à¸/à¸à¸¥à¸±à¸§"; 462 case "neutral": return "à¸à¸£à¸°à¹à¸ªà¹à¸à¸´à¸à¹à¸à¹à¸²-à¸à¸à¸à¹à¸à¸¥à¹à¸ªà¸¡à¸à¸¸à¸¥ â à¸à¸²à¸£à¸¡à¸à¹à¸à¸¥à¸²à¸à¹à¸à¹à¸à¸à¸¥à¸²à¸"; 463 case "greed1": return "à¸à¸à¸à¸à¸¸à¸ SPDR à¸à¸¢à¸à¸¢à¸ªà¸°à¸ªà¸¡à¸à¸à¸ â à¸à¸²à¸£à¸¡à¸à¹à¸à¸¥à¸²à¸à¹à¸à¸à¹à¸à¸à¸²à¸à¹à¸¥à¸ "; 464 default: return "à¹à¸à¸´à¸à¹à¸«à¸¥à¹à¸à¹à¸²à¸à¸à¸à¸«à¸à¸±à¸ â à¸à¸²à¸£à¸¡à¸à¹à¸à¸¥à¸²à¸à¸ªà¸à¸²à¸à¸±à¸à¸à¸¢à¸¹à¹à¹à¸à¸ าวะà¹à¸¥à¸ มาà¸"; 465 } 466 } 467 function buildGauge(host, score, zone) { 468 var NS = "http://www.w3.org/2000/svg"; 469 var cx = 100, cy = 100, r = 80, sw = 16; 470 var bounds = [0, 25, 45, 55, 75, 100]; 471 var colors = ["#c0392b", "#e2504a", "#d4b86a", "#9bcf6a", "#5dca8a"]; 472 var ang = function (v) { return 180 - (v / 100) * 180; }; 473 var svg = document.createElementNS(NS, "svg"); 474 svg.setAttribute("viewBox", "0 0 200 124");
475 svg.setAttribute("class", "icf-gold-senti__svg"); 476 for (var i = 0; i < colors.length; i++) { 477 var p = document.createElementNS(NS, "path"); 478 p.setAttribute("d", sentiArc(cx, cy, r, ang(bounds[i]), ang(bounds[i + 1]))); 479 p.setAttribute("fill", "none"); 480 p.setAttribute("stroke", colors[i]); 481 p.setAttribute("stroke-width", sw); 482 svg.appendChild(p); 483 } 484 var na = ang(sentiClamp(score, 0, 100)); 485 var tip = sentiPolar(cx, cy, r - 6, na); 486 var needle = document.createElementNS(NS, "line"); 487 needle.setAttribute("x1", cx); needle.setAttribute("y1", cy); 488 needle.setAttribute("x2", tip.x.toFixed(2)); needle.setAttribute("y2", tip.y.toFixed(2)); 489 needle.setAttribute("stroke", "#f5edd6"); needle.setAttribute("stroke-width", 3); 490 needle.setAttribute("stroke-linecap", "round"); 491 svg.appendChild(needle); 492 var hub = document.createElementNS(NS, "circle"); 493 hub.setAttribute("cx", cx); hub.setAttribute("cy", cy); hub.setAttribute("r", 6); 494 hub.setAttribute("fill", "#f5edd6"); 495 svg.appendChild(hub); 496 var val = document.createElementNS(NS, "text"); 497 val.setAttribute("x", cx); val.setAttribute("y", 90); 498 val.setAttribute("text-anchor", "middle"); val.setAttribute("class", "icf-gold-senti__val"); 499 val.setAttribute("fill", zone.color); val.textContent = String(score); 500 svg.appendChild(val); 501 var lbl = document.createElementNS(NS, "text"); 502 lbl.setAttribute("x", cx); lbl.setAttribute("y", 116); 503 lbl.setAttribute("text-anchor", "middle"); lbl.setAttribute("class", "icf-gold-senti__zone"); 504 lbl.setAttribute("fill", zone.color); lbl.textContent = zone.label; 505 svg.appendChild(lbl); 506 host.innerHTML = ""; 507 host.appendChild(svg); 508 } 509 function initSentiment(box) { 510 if (!CFG.spdrHistory) return; 511 var host = box.querySelector("[data-senti-gauge]"); 512 fetch(CFG.spdrHistory + "?range=1m", { credentials: "same-origin" }) 513 .then(function (r) { return r.ok ? r.json() : null; }) 514 .then(function (j) { 515 if (!j || !j.series || !j.series.length) { if (host) host.innerHTML = '<div class="icf-gold-senti__loading">à¸à¹à¸à¸¡à¸¹à¸¥à¹à¸¡à¹à¸à¸£à¹à¸à¸¡</div>'; return; } 516 var s = j.series; 517 var valid = s.map(function (p) { return (p.change == null ? null : parseFloat(p.change)); }) 518 .filter(function (c) { return c != null && !isNaN(c); }); 519 var net = valid.reduce(function (a, c) { return a + c; }, 0); 520 var up = valid.filter(function (c) { return c > 0; }).length; 521 var down = valid.filter(function (c) { return c < 0; }).length; 522 var start = parseFloat(s[0].tonnes) || 0; 523 var nChg = valid.length || 1; 524 var avg30 = net / nChg; 525 var last7 = valid.slice(-7); 526 var avg7 = last7.length ? last7.reduce(function (a, c) { return a + c; }, 0) / last7.length : avg30; 527 var momPerDay = avg7 - avg30; 528 529 // sentiment 0-100: 50% net flow · 30% buy:sell ratio · 20% momentum 530 var netPct = start > 0 ? (net / start * 100) : 0; 531 var netScore = sentiClamp(50 + (netPct / 6) * 50, 0, 100); // ±6%/mo saturates 532 var dirTotal = up + down; 533 var ratioScore = dirTotal > 0 ? sentiClamp((up / dirTotal) * 100, 0, 100) : 50; 534 var momScore = sentiClamp(50 + (momPerDay / 3) * 50, 0, 100); // ±3 t/day saturates 535 var score = sentiClamp(Math.round(0.50 * netScore + 0.30 * ratioScore + 0.20 * momScore), 0, 100); 536 var zone = sentiZone(score); 537 538 if (host) buildGauge(host, score, zone); 539 540 var netEl = box.querySelector('[data-senti="net"]'); 541 if (netEl) { 542 var ncls = net > 0 ? "is-up" : (net < 0 ? "is-down" : ""); 543 var narr = net > 0 ? "â² +" : (net < 0 ? "â¼ " : "⢠"); 544 netEl.innerHTML = '<em class="icf-gold-delta ' + ncls + '">' + narr + fmt(net, 2) + ' à¸à¸±à¸</em>'; 545 } 546 var ratioEl = box.querySelector('[data-senti="ratio"]'); 547 if (ratioEl) ratioEl.innerHTML = '<em class="icf-gold-delta is-up">' + up + '</em> : <em class="icf-gold-delta is-down">' + down + '</em>'; 548 var momEl = box.querySelector('[data-senti="momentum"]'); 549 if (momEl) { 550 var momTxt = momPerDay > 0.05 ? "â² à¹à¸£à¸´à¹à¸¡à¸à¸°à¸¥à¸/à¸à¸·à¹à¸" : (momPerDay < -0.05 ? "â¼ à¹à¸£à¹à¸à¸à¸²à¸¢" : "⢠à¸à¸£à¸à¸à¸±à¸§"); 551 var momCls = momPerDay > 0.05 ? "is-up" : (momPerDay < -0.05 ? "is-down" : ""); 552 momEl.innerHTML = '<em class="icf-gold-delta ' + momCls + '">' + momTxt + ' (' + (momPerDay >= 0 ? "+" : "") + momPerDay.toFixed(2) + ' t/วัà¸)</em>'; 553 } 554 var sumEl = box.querySelector('[data-senti="summary"]'); 555 if (sumEl) sumEl.textContent = sentiSummary(zone); 556
557 // reuse for context card (no extra query) 558 var ctxS = document.querySelector('[data-ctx="senti"]'); 559 if (ctxS) { ctxS.textContent = score + "/100"; ctxS.style.color = zone.color; } 560 var ctxSL = document.querySelector('[data-ctx="sentilabel"]'); 561 if (ctxSL) ctxSL.textContent = zone.label; 562 }) 563 .catch(function () { if (host) host.innerHTML = '<div class="icf-gold-senti__loading">à¸à¸³à¸à¸§à¸à¹à¸¡à¹à¹à¸à¹</div>'; }); 564 } 565 566 /* ---------- share buttons (copy link) ---------- */ 567 function initShare(box) { 568 var btn = box.querySelector(".icf-gold-share__copy"); 569 if (!btn) return; 570 function fallback(text, cb) { 571 var ta = document.createElement("textarea"); 572 ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0"; 573 document.body.appendChild(ta); ta.focus(); ta.select(); 574 try { document.execCommand("copy"); } catch (e) {} 575 document.body.removeChild(ta); cb(); 576 } 577 btn.addEventListener("click", function () { 578 var url = btn.getAttribute("data-share-copy"); 579 var label = btn.querySelector("span:last-child"); 580 var done = function () { 581 if (!label) return; 582 var orig = label.textContent; label.textContent = "à¸à¸±à¸à¸¥à¸à¸à¹à¸¥à¹à¸§ â"; 583 setTimeout(function () { label.textContent = orig; }, 1800); 584 }; 585 if (navigator.clipboard && navigator.clipboard.writeText) { 586 navigator.clipboard.writeText(url).then(done).catch(function () { fallback(url, done); }); 587 } else { fallback(url, done); } 588 }); 589 } 590 591 /* ---------- stale-data badge (client-side, business-day aware) ---------- */ 592 function initStale(box) { 593 var ds = box.getAttribute("data-spdr-date"); 594 try { 595 var qp = new URLSearchParams(location.search).get("icf_asof"); 596 if (qp) ds = qp; // debug/test override (badge reference date only) 597 } catch (e) {} 598 if (!ds) return; 599 var d = new Date(ds + "T00:00:00"); 600 if (isNaN(d.getTime())) return; 601 var today = new Date(); today.setHours(0, 0, 0, 0); 602 var n = 0, cur = new Date(d); 603 while (cur < today) { 604 cur.setDate(cur.getDate() + 1); 605 var wd = cur.getDay(); 606 if (wd !== 0 && wd !== 6) n++; // skip Sat/Sun 607 } 608 if (n > 2) { 609 var badge = box.querySelector("[data-icf-stale-badge]"); 610 if (badge) badge.hidden = false; 611 } 612 } 613 614 /* ---------- boot ---------- */ 615 function boot() { 616 $all(".icf-gold-calc").forEach(initCalc); 617 $all("[data-icf-gold-chart]").forEach(initChart); 618 $all("[data-icf-gold-spdr-chart]").forEach(initSpdrChart);
619 $all("[data-icf-gold-sentiment]").forEach(initSentiment); 620 $all("[data-icf-gold-ath]").forEach(initAth); 621 $all("[data-icf-gold-share]").forEach(initShare); 622 $all("[data-icf-stale]").forEach(initStale); 623 $all("[data-icf-gold-nav]").forEach(initNav); 624 if ($("[data-icf-gold-board]")) { 625 var every = (CFG.refresh || 60) * 1000; 626 setInterval(refresh, every); 627 } 628 if ($("[data-icf-gold-strip]")) { 629 setInterval(refreshStrip, (CFG.refresh || 60) * 1000); 630 } 631 } 632 if (document.readyState === "loading") { 633 document.addEventListener("DOMContentLoaded", boot); 634 } else { 635 boot(); 636 } 637})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.