feat+fix(dashboard): price chart on signal-card click + fmtSpd speed display cleanup

This commit is contained in:
Rene Fichtmueller 2026-06-05 22:43:21 +00:00
parent 8432a44574
commit 8b232d942d

View File

@ -722,17 +722,16 @@
<!-- Auth guard — redirect to login if no valid token --> <!-- Auth guard — redirect to login if no valid token -->
<script> <script>
// fmtSpd: "1.00" → "1G", "10.00" → "10G", "2.5" → "2.5G", "1600" → "1.6T" // fmtSpd: clean speed display — 1.00->1G, 400.00->400G, 1600->1.6T, 2.5->2.5G
// 1.6 Tbit/s (1600 Gbps) behält die Dezimalstelle, alle ganzzahligen Speeds nicht.
function fmtSpd(gbps) { function fmtSpd(gbps) {
if (gbps === null || gbps === undefined || gbps === '') return '?'; if (gbps == null || gbps === '') return '?';
var n = parseFloat(gbps); var n = parseFloat(gbps);
if (isNaN(n) || n === 0) return '?'; if (isNaN(n) || n === 0) return '?';
if (n >= 1000) { if (n >= 1000) {
var t = n / 1000; var t = n / 1000;
return ((t * 10) % 10 === 0 ? Math.round(t) : t.toFixed(1)) + 'T'; return ((t * 10) % 10 === 0 ? String(Math.round(t)) : t.toFixed(1)) + 'T';
} }
return ((n * 10) % 10 === 0 ? Math.round(n) : n) + 'G'; return ((n * 10) % 10 === 0 ? String(Math.round(n)) : String(n)) + 'G';
} }
// ── Token storage helpers — never store plaintext ────────────────────────── // ── Token storage helpers — never store plaintext ──────────────────────────
@ -4655,7 +4654,7 @@ async function openTxDetail(id) {
+ '<td style="color:' + compColor + ';font-size:0.7rem;padding:2px 0">' + esc(compVal || '—') + '</td></tr>'; + '<td style="color:' + compColor + ';font-size:0.7rem;padding:2px 0">' + esc(compVal || '—') + '</td></tr>';
} }
var mySpeed = t.speed_gbps >= 1000 ? (t.speed_gbps / 1000).toFixed(1).replace('.0','') + 'T' : fmtSpd(t.speed_gbps); var mySpeed = t.speed_gbps >= 1000 ? (t.speed_gbps / 1000).toFixed(1).replace('.0','') + 'T' : t.speed_gbps + 'G';
var compSpeed = p.comp_speed_gbps ? (p.comp_speed_gbps >= 1000 ? (p.comp_speed_gbps/1000).toFixed(1).replace('.0','')+'T' : p.comp_speed_gbps+'G') : null; var compSpeed = p.comp_speed_gbps ? (p.comp_speed_gbps >= 1000 ? (p.comp_speed_gbps/1000).toFixed(1).replace('.0','')+'T' : p.comp_speed_gbps+'G') : null;
h += '<div style="border:1px solid var(--border);border-radius:8px;margin-bottom:0.6rem;overflow:hidden">'; h += '<div style="border:1px solid var(--border);border-radius:8px;margin-bottom:0.6rem;overflow:hidden">';
@ -5266,7 +5265,7 @@ async function openSwitchDetail(id) {
h += '<div class="panel-stat"><div class="panel-stat-label">Category</div><div class="panel-stat-val" style="font-size:1rem">' + esc(s.category || '—') + '</div></div>'; h += '<div class="panel-stat"><div class="panel-stat-label">Category</div><div class="panel-stat-val" style="font-size:1rem">' + esc(s.category || '—') + '</div></div>';
h += '<div class="panel-stat"><div class="panel-stat-label">Total Ports</div><div class="panel-stat-val">' + esc(s.total_ports || '—') + '</div></div>'; h += '<div class="panel-stat"><div class="panel-stat-label">Total Ports</div><div class="panel-stat-val">' + esc(s.total_ports || '—') + '</div></div>';
h += '<div class="panel-stat"><div class="panel-stat-label">Switching Capacity</div><div class="panel-stat-val">' + (s.switching_capacity_tbps ? s.switching_capacity_tbps + ' <small>Tbps</small>' : '—') + '</div></div>'; h += '<div class="panel-stat"><div class="panel-stat-label">Switching Capacity</div><div class="panel-stat-val">' + (s.switching_capacity_tbps ? s.switching_capacity_tbps + ' <small>Tbps</small>' : '—') + '</div></div>';
h += '<div class="panel-stat"><div class="panel-stat-label">Max Speed</div><div class="panel-stat-val">' + (fmtSpd(s.max_speed_gbps)) + '</div></div>'; h += '<div class="panel-stat"><div class="panel-stat-label">Max Speed</div><div class="panel-stat-val">' + (s.max_speed_gbps >= 1000 ? (s.max_speed_gbps/1000) + 'T' : (s.max_speed_gbps || '—') + 'G') + '</div></div>';
h += '</div>'; h += '</div>';
h += '<div class="panel-section">Specifications</div>'; h += '<div class="panel-section">Specifications</div>';
@ -5870,7 +5869,7 @@ async function loadStandardsList() {
var ff = Array.isArray(s.form_factors) ? s.form_factors[0] : (s.form_factors || s.form_factor || ''); var ff = Array.isArray(s.form_factors) ? s.form_factors[0] : (s.form_factors || s.form_factor || '');
return '<span style="background:' + heat + '22;color:' + heat + ';padding:2px 10px;border-radius:10px;font-size:0.72rem;font-weight:600;cursor:pointer" ' return '<span style="background:' + heat + '22;color:' + heat + ';padding:2px 10px;border-radius:10px;font-size:0.72rem;font-weight:600;cursor:pointer" '
+ 'title="Composite score: ' + score.toFixed(2) + '">' + 'title="Composite score: ' + score.toFixed(2) + '">'
+ esc((s.speed_gbps ? fmtSpd(s.speed_gbps) + ' ' : '') + (ff || s.name || s.category || '')) + esc((s.speed_gbps ? s.speed_gbps + 'G ' : '') + (ff || s.name || s.category || ''))
+ '</span>'; + '</span>';
}).join('') }).join('')
+ '</div></div>'; + '</div></div>';
@ -6060,7 +6059,7 @@ async function openFormFactorDetail(name) {
var descFull = f.description || ''; var descFull = f.description || '';
var descDE = descFull.split('//')[0].trim(); var descDE = descFull.split('//')[0].trim();
var descEN = descFull.includes('//') ? descFull.split('//')[1].trim() : ''; var descEN = descFull.includes('//') ? descFull.split('//')[1].trim() : '';
var maxSpd = fmtSpd(f.max_speed_gbps); var maxSpd = f.max_speed_gbps >= 1000 ? (f.max_speed_gbps/1000) + 'T' : (f.max_speed_gbps || '?') + 'G';
var supersedes = Array.isArray(f.supersedes) ? f.supersedes.filter(Boolean) : []; var supersedes = Array.isArray(f.supersedes) ? f.supersedes.filter(Boolean) : [];
var hype = FF_HYPE[f.name] || { phase:'—', pct:50, signal:'—', sigCol:'#888', sigLbl:'Keine Daten' }; var hype = FF_HYPE[f.name] || { phase:'—', pct:50, signal:'—', sigCol:'#888', sigLbl:'Keine Daten' };
var useCases = FF_USE_CASES[f.name] || []; var useCases = FF_USE_CASES[f.name] || [];
@ -8262,7 +8261,7 @@ function renderSignals(filterSig) {
if (r.image_r2_key) { if (r.image_r2_key) {
imgHtml = '<img src="https://pub-placeholder.r2.dev/' + esc(r.image_r2_key) + '" style="width:36px;height:36px;object-fit:contain;border-radius:4px;margin-right:0.5rem;flex-shrink:0" onerror="this.style.display=\'none\'">'; imgHtml = '<img src="https://pub-placeholder.r2.dev/' + esc(r.image_r2_key) + '" style="width:36px;height:36px;object-fit:contain;border-radius:4px;margin-right:0.5rem;flex-shrink:0" onerror="this.style.display=\'none\'">';
} }
return '<div class="signal-card ' + sigClass + '" style="cursor:pointer" onclick="openSignalPriceChart(\''+esc(String(r.transceiver_id||r.id||''))+'\',\''+esc(String(r.standard_name||r.part_number||''))+'\')">' return '<div class="signal-card ' + sigClass + '" style="cursor:pointer" onclick="openSignalPriceChart(\'' + esc(String(r.transceiver_id||r.id||'')) + '\',\'' + esc(String(r.standard_name||r.part_number||'')) + '\\')">'
+ '<div style="display:flex;align-items:flex-start;gap:0.25rem;margin-bottom:0.5rem">' + '<div style="display:flex;align-items:flex-start;gap:0.25rem;margin-bottom:0.5rem">'
+ imgHtml + imgHtml
+ '<div style="flex:1;min-width:0">' + '<div style="flex:1;min-width:0">'
@ -8480,7 +8479,7 @@ function filterVelocity(cls) {
+ '<td style="padding:7px 6px"><span style="font-size:1rem" title="' + esc(r.velocity_class) + '">' + velIcon + '</span></td>' + '<td style="padding:7px 6px"><span style="font-size:1rem" title="' + esc(r.velocity_class) + '">' + velIcon + '</span></td>'
+ '<td style="padding:7px 6px"><div style="font-weight:600;font-size:0.82rem">' + esc(productName || '—') + '</div>' + '<td style="padding:7px 6px"><div style="font-weight:600;font-size:0.82rem">' + esc(productName || '—') + '</div>'
+ '<div style="font-size:0.68rem;color:var(--text-dim)">' + esc(r.sku) + (r.vendor_name ? ' · ' + esc(r.vendor_name) : '') + '</div></td>' + '<div style="font-size:0.68rem;color:var(--text-dim)">' + esc(r.sku) + (r.vendor_name ? ' · ' + esc(r.vendor_name) : '') + '</div></td>'
+ '<td style="padding:7px 6px;font-size:0.75rem;color:var(--text-dim)">' + esc(r.form_factor || '—') + (r.speed_gbps ? ' ' + fmtSpd(r.speed_gbps) : '') + '</td>' + '<td style="padding:7px 6px;font-size:0.75rem;color:var(--text-dim)">' + esc(r.form_factor || '—') + (r.speed_gbps ? ' ' + r.speed_gbps + 'G' : '') + '</td>'
+ '<td style="padding:7px 6px;text-align:right;font-weight:700;color:' + velColor + '">' + (demand12 > 0 ? demand12.toLocaleString(undefined,{maximumFractionDigits:0}) : '—') + '</td>' + '<td style="padding:7px 6px;text-align:right;font-weight:700;color:' + velColor + '">' + (demand12 > 0 ? demand12.toLocaleString(undefined,{maximumFractionDigits:0}) : '—') + '</td>'
+ '<td style="padding:7px 6px;text-align:right;color:var(--text-dim)">' + (demand3 > 0 ? demand3.toLocaleString(undefined,{maximumFractionDigits:0}) : '—') + '</td>' + '<td style="padding:7px 6px;text-align:right;color:var(--text-dim)">' + (demand3 > 0 ? demand3.toLocaleString(undefined,{maximumFractionDigits:0}) : '—') + '</td>'
+ '<td style="padding:7px 6px;text-align:right">' + trendHtml + '</td>' + '<td style="padding:7px 6px;text-align:right">' + trendHtml + '</td>'
@ -10669,7 +10668,7 @@ async function runBulkPrice() {
+ '</div>' + '</div>'
+ '<div style="display:flex;gap:0.4rem">' + '<div style="display:flex;gap:0.4rem">'
+ (r.form_factor ? '<span class="b b-blue">' + esc(r.form_factor) + '</span>' : '') + (r.form_factor ? '<span class="b b-blue">' + esc(r.form_factor) + '</span>' : '')
+ (r.speed_gbps ? '<span class="b b-neutral">' + r.speed_gbps + 'G</span>' : '') + (r.speed_gbps ? '<span class="b b-neutral">' + fmtSpd(r.speed_gbps) + '</span>' : '')
+ '</div>' + '</div>'
+ '</div>'; + '</div>';
if (!r.prices || !r.prices.length) { if (!r.prices || !r.prices.length) {
@ -11012,7 +11011,7 @@ function renderWatchlist() {
return '<div style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:0.7rem 0.85rem;margin-bottom:0.5rem;display:flex;align-items:center;gap:0.75rem">' return '<div style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:0.7rem 0.85rem;margin-bottom:0.5rem;display:flex;align-items:center;gap:0.75rem">'
+ '<div style="flex:1;cursor:pointer" onclick="openTxDetail(\'' + esc(String(t.id || t.transceiver_id)) + '\')">' + '<div style="flex:1;cursor:pointer" onclick="openTxDetail(\'' + esc(String(t.id || t.transceiver_id)) + '\')">'
+ '<div style="font-size:0.82rem;font-weight:600;color:var(--text-bright)">' + esc(t.part_number || t.model_name || String(t.id)) + '</div>' + '<div style="font-size:0.82rem;font-weight:600;color:var(--text-bright)">' + esc(t.part_number || t.model_name || String(t.id)) + '</div>'
+ (t.form_factor ? '<div style="font-size:0.7rem;color:var(--text-dim)">' + esc(t.form_factor) + (t.speed_gbps ? ' · ' + fmtSpd(t.speed_gbps) : '') + '</div>' : '') + (t.form_factor ? '<div style="font-size:0.7rem;color:var(--text-dim)">' + esc(t.form_factor) + (t.speed_gbps ? ' · ' + t.speed_gbps + 'G' : '') + '</div>' : '')
+ (t.street_price_usd ? '<div style="font-size:0.78rem;color:var(--green);font-family:var(--mono);font-weight:700">$' + parseFloat(t.street_price_usd).toFixed(2) + '</div>' : '') + (t.street_price_usd ? '<div style="font-size:0.78rem;color:var(--green);font-family:var(--mono);font-weight:700">$' + parseFloat(t.street_price_usd).toFixed(2) + '</div>' : '')
+ '</div>' + '</div>'
+ '<button onclick="toggleWatchlistItem(\'' + esc(String(t.id || t.transceiver_id)) + '\')" style="background:none;border:none;cursor:pointer;color:#f59e0b;font-size:1.1rem" title="Remove"></button>' + '<button onclick="toggleWatchlistItem(\'' + esc(String(t.id || t.transceiver_id)) + '\')" style="background:none;border:none;cursor:pointer;color:#f59e0b;font-size:1.1rem" title="Remove"></button>'
@ -11099,7 +11098,7 @@ async function runGlobalSearch() {
html += '<div onclick="closeGlobalSearch();openTxDetail(\'' + esc(String(t.id)) + '\')" style="cursor:pointer;padding:8px 10px;border-radius:7px;margin-bottom:3px;background:var(--surface2);border:1px solid var(--border);display:flex;align-items:center;gap:0.75rem">' html += '<div onclick="closeGlobalSearch();openTxDetail(\'' + esc(String(t.id)) + '\')" style="cursor:pointer;padding:8px 10px;border-radius:7px;margin-bottom:3px;background:var(--surface2);border:1px solid var(--border);display:flex;align-items:center;gap:0.75rem">'
+ '<span style="font-size:0.75rem;color:var(--text-dim)">🔌</span>' + '<span style="font-size:0.75rem;color:var(--text-dim)">🔌</span>'
+ '<div><div style="font-size:0.85rem;font-weight:600;color:var(--text-bright)">' + esc(t.part_number || t.model_name) + '</div>' + '<div><div style="font-size:0.85rem;font-weight:600;color:var(--text-bright)">' + esc(t.part_number || t.model_name) + '</div>'
+ '<div style="font-size:0.7rem;color:var(--text-dim)">' + esc(t.form_factor || '') + (t.speed_gbps ? ' · ' + fmtSpd(t.speed_gbps) : '') + '</div></div>' + '<div style="font-size:0.7rem;color:var(--text-dim)">' + esc(t.form_factor || '') + (t.speed_gbps ? ' · ' + t.speed_gbps + 'G' : '') + '</div></div>'
+ (t.street_price_usd ? '<div style="margin-left:auto;font-family:var(--mono);font-size:0.8rem;color:var(--green);font-weight:700">$' + parseFloat(t.street_price_usd).toFixed(2) + '</div>' : '') + (t.street_price_usd ? '<div style="margin-left:auto;font-family:var(--mono);font-size:0.8rem;color:var(--green);font-weight:700">$' + parseFloat(t.street_price_usd).toFixed(2) + '</div>' : '')
+ '</div>'; + '</div>';
}); });
@ -12258,129 +12257,88 @@ function roiCard(title, val, sub, color) {
} }
/* ── PRICE HISTORY CHART (signal-card click) ─────────────────────────── */ /* ── PRICE HISTORY CHART (signal-card click) ─────────────────────────── */
async function openSignalPriceChart(txId, partNumber) { async function openSignalPriceChart(txId, partNumber) {
var modal = document.createElement('div'); var modal = document.createElement('div');
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:9000;display:flex;align-items:center;justify-content:center;padding:1rem'; modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:9000;display:flex;align-items:center;justify-content:center;padding:1rem';
modal.innerHTML = '<div style="background:var(--surface);border-radius:12px;padding:1.5rem;max-width:680px;width:100%;box-shadow:0 8px 40px rgba(0,0,0,0.35);position:relative">' modal.innerHTML = '<div style="background:var(--surface);border-radius:12px;padding:1.5rem;max-width:680px;width:100%;box-shadow:0 8px 40px rgba(0,0,0,0.35);position:relative">'
+ '<button onclick="this.closest('[style*=fixed]').remove()" style="position:absolute;top:0.75rem;right:0.9rem;background:none;border:none;font-size:1.4rem;cursor:pointer;color:var(--text-dim)">×</button>' + '<button onclick="this.closest(\'[style*=fixed]\').remove()" style="position:absolute;top:0.75rem;right:0.9rem;background:none;border:none;font-size:1.4rem;cursor:pointer;color:var(--text-dim)">x</button>'
+ '<div style="font-weight:700;font-size:1rem;margin-bottom:1rem;color:var(--text-bright)" id="ph-title">Price History — ' + (partNumber || txId) + '</div>' + '<div style="font-weight:700;font-size:1rem;margin-bottom:1rem;color:var(--text-bright)" id="ph-title">Price History</div>'
+ '<div id="ph-body" style="min-height:220px;display:flex;align-items:center;justify-content:center;color:var(--text-dim)">Loading</div>' + '<div id="ph-body" style="min-height:220px;display:flex;align-items:center;justify-content:center;color:var(--text-dim)">Loading...</div>'
+ '</div>'; + '</div>';
modal.onclick = function(e) { if (e.target === modal) modal.remove(); }; modal.onclick = function(e) { if (e.target === modal) modal.remove(); };
document.body.appendChild(modal); document.body.appendChild(modal);
try { try {
var tok = (window.loadToken ? window.loadToken() : '') || ''; var tok = (window.loadToken ? window.loadToken() : '') || '';
var d = await (await fetch('/api/price-history/' + txId + '?days=180', { headers: { 'Authorization': 'Bearer ' + tok } })).json(); var d = await (await fetch('/api/price-history/' + txId + '?days=180', { headers: { 'Authorization': 'Bearer ' + tok } })).json();
var tx = d.transceiver || {}; var tx = d.transceiver || {};
document.getElementById('ph-title').textContent = 'Price History — ' + (tx.part_number || partNumber || txId) document.getElementById('ph-title').textContent = 'Price History — ' + (tx.part_number || partNumber || txId)
+ (tx.form_factor ? ' (' + tx.form_factor + (tx.speed_gbps ? ' · ' + fmtSpd(tx.speed_gbps) : '') + ')' : ''); + (tx.form_factor ? ' (' + tx.form_factor + (tx.speed_gbps ? ' · ' + fmtSpd(tx.speed_gbps) : '') + ')' : '');
var series = d.series || []; var series = d.series || [];
if (!series.length) { if (!series.length) {
document.getElementById('ph-body').innerHTML = '<div style="color:var(--text-dim);text-align:center;padding:2rem">No price data available for this product.</div>'; document.getElementById('ph-body').innerHTML = '<div style="color:var(--text-dim);text-align:center;padding:2rem">No price data available.</div>';
return; return;
} }
var vendors = {}, cur = d.current_prices || [];
// Build vendor legend + aggregate to daily median per vendor
var vendors = {}, cur = (d.current_prices || []);
series.forEach(function(r) { if (r.source_vendor) vendors[r.source_vendor] = r.currency; }); series.forEach(function(r) { if (r.source_vendor) vendors[r.source_vendor] = r.currency; });
var vendorList = Object.keys(vendors).slice(0, 6); var vendorList = Object.keys(vendors).slice(0, 6);
var COLORS = ['#6366f1','#22c55e','#f59e0b','#ef4444','#0ea5e9','#a855f7']; var COLORS = ['#6366f1','#22c55e','#f59e0b','#ef4444','#0ea5e9','#a855f7'];
var dates = [], seen = {};
// Date-bucketed: one entry per day per vendor → collect dates
var dates = [];
var seen = {};
series.forEach(function(r) { var d2 = r.day ? r.day.slice(0,10) : ''; if (d2 && !seen[d2]) { seen[d2]=1; dates.push(d2); } }); series.forEach(function(r) { var d2 = r.day ? r.day.slice(0,10) : ''; if (d2 && !seen[d2]) { seen[d2]=1; dates.push(d2); } });
dates.sort(); dates.sort();
// For each vendor, build a price array indexed by date
var vData = {}; var vData = {};
vendorList.forEach(function(v) { vData[v] = {}; }); vendorList.forEach(function(v) { vData[v] = {}; });
series.forEach(function(r) { series.forEach(function(r) {
if (r.source_vendor && vData[r.source_vendor] !== undefined && r.day) if (r.source_vendor && vData[r.source_vendor] !== undefined && r.day)
vData[r.source_vendor][r.day.slice(0,10)] = parseFloat(r.price_avg || r.price_min); vData[r.source_vendor][r.day.slice(0,10)] = parseFloat(r.price_avg || r.price_min || 0);
}); });
var allP = [];
// Determine Y range vendorList.forEach(function(v) { Object.values(vData[v]).forEach(function(p) { allP.push(p); }); });
var allPrices = []; var pMin = Math.min.apply(null,allP), pMax = Math.max.apply(null,allP), pRange = pMax-pMin||1;
vendorList.forEach(function(v) { Object.values(vData[v]).forEach(function(p) { allPrices.push(p); }); }); var W=620,H=200,PAD={l:52,r:12,t:10,b:36};
var pMin = Math.min.apply(null, allPrices), pMax = Math.max.apply(null, allPrices); var iW=W-PAD.l-PAD.r, iH=H-PAD.t-PAD.b;
var pRange = pMax - pMin || 1; function xOf(i){ return PAD.l+(dates.length<2?iW/2:i/(dates.length-1)*iW); }
var W = 620, H = 200, PAD = { l:52, r:12, t:10, b:36 }; function yOf(p){ return PAD.t+iH-((p-pMin)/pRange*iH); }
var iW = W - PAD.l - PAD.r, iH = H - PAD.t - PAD.b; var currency = series[0] ? (series[0].currency||'') : '';
var gridSvg='';
function xOf(i) { return PAD.l + (dates.length < 2 ? iW/2 : i / (dates.length-1) * iW); } for(var gi=0;gi<=4;gi++){
function yOf(p) { return PAD.t + iH - ((p - pMin) / pRange * iH); } var gy=PAD.t+gi*iH/4, gv=pMax-gi*pRange/4;
gridSvg+='<line x1="'+PAD.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-PAD.r)+'" y2="'+gy.toFixed(1)+'" stroke="var(--border)" stroke-width="1"/>';
var currency = series[0] ? (series[0].currency || '') : ''; gridSvg+='<text x="'+(PAD.l-5)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="9" fill="var(--text-dim)">'+(gv>=1000?(gv/1000).toFixed(1)+'k':gv.toFixed(0))+'</text>';
// Grid lines (Y)
var nGridY = 4;
var gridSvg = '';
for (var gi = 0; gi <= nGridY; gi++) {
var gy = PAD.t + gi * iH / nGridY;
var gv = pMax - gi * pRange / nGridY;
gridSvg += '<line x1="' + PAD.l + '" y1="' + gy.toFixed(1) + '" x2="' + (W-PAD.r) + '" y2="' + gy.toFixed(1) + '" stroke="var(--border)" stroke-width="1"/>';
gridSvg += '<text x="' + (PAD.l-5) + '" y="' + (gy+4).toFixed(1) + '" text-anchor="end" font-size="9" fill="var(--text-dim)">'
+ (gv >= 1000 ? (gv/1000).toFixed(1)+'k' : gv.toFixed(0)) + '</text>';
} }
var xLabels='';
// X axis labels (up to 6 evenly spaced) var nXL=Math.min(6,dates.length);
var xLabels = ''; for(var xi=0;xi<nXL;xi++){
var nXLabels = Math.min(6, dates.length); var didx=Math.round(xi*(dates.length-1)/Math.max(nXL-1,1));
for (var xi = 0; xi < nXLabels; xi++) { xLabels+='<text x="'+xOf(didx).toFixed(1)+'" y="'+(H-4)+'" text-anchor="middle" font-size="9" fill="var(--text-dim)">'+dates[didx].slice(5)+'</text>';
var didx = Math.round(xi * (dates.length-1) / Math.max(nXLabels-1,1));
var lx = xOf(didx);
var ldate = dates[didx] || '';
xLabels += '<text x="' + lx.toFixed(1) + '" y="' + (H-4) + '" text-anchor="middle" font-size="9" fill="var(--text-dim)">' + ldate.slice(5) + '</text>';
} }
var linesSvg='';
// Polylines per vendor vendorList.forEach(function(v,vi){
var linesSvg = ''; var pts=dates.map(function(d3,di){ var p=vData[v][d3]; return p!==undefined?(xOf(di).toFixed(1)+','+yOf(p).toFixed(1)):null; }).filter(Boolean);
vendorList.forEach(function(v, vi) { if(!pts.length) return;
var pts = dates.map(function(d3, di) { linesSvg+='<polyline points="'+pts.join(' ')+'" fill="none" stroke="'+COLORS[vi%COLORS.length]+'" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>';
var p = vData[v][d3]; var lp=pts[pts.length-1].split(',');
return p !== undefined ? (xOf(di).toFixed(1) + ',' + yOf(p).toFixed(1)) : null; linesSvg+='<circle cx="'+lp[0]+'" cy="'+lp[1]+'" r="3" fill="'+COLORS[vi%COLORS.length]+'"/>';
}).filter(Boolean);
if (!pts.length) return;
linesSvg += '<polyline points="' + pts.join(' ') + '" fill="none" stroke="' + COLORS[vi % COLORS.length] + '" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>';
// Last point dot
var lastPt = pts[pts.length-1].split(',');
linesSvg += '<circle cx="' + lastPt[0] + '" cy="' + lastPt[1] + '" r="3" fill="' + COLORS[vi % COLORS.length] + '"/>';
}); });
var legend=vendorList.map(function(v,vi){
// Legend var lp=Object.values(vData[v]).slice(-1)[0];
var legend = vendorList.map(function(v, vi) {
var lastP = Object.values(vData[v]).slice(-1)[0];
return '<span style="display:inline-flex;align-items:center;gap:3px;font-size:0.68rem;margin-right:0.6rem">' return '<span style="display:inline-flex;align-items:center;gap:3px;font-size:0.68rem;margin-right:0.6rem">'
+ '<span style="display:inline-block;width:12px;height:3px;background:' + COLORS[vi%COLORS.length] + ';border-radius:2px"></span>' +'<span style="display:inline-block;width:12px;height:3px;background:'+COLORS[vi%COLORS.length]+';border-radius:2px"></span>'
+ esc(v) + (lastP ? ' ' + lastP.toFixed(0) + ' ' + currency : '') + '</span>'; +esc(v)+(lp?' '+lp.toFixed(0)+' '+currency:'')+'</span>';
}).join(''); }).join('');
var bestHtml='';
// Current best price if(cur.length) bestHtml='<div style="margin-top:0.6rem;font-size:0.72rem;color:var(--text-dim)">Best now: '
var bestHtml = ''; +cur.slice(0,3).map(function(c){ return '<b style="color:var(--text)">'+c.best_price+' '+c.currency+'</b> @ '+esc(c.source_vendor); }).join(' · ')+'</div>';
if (cur.length) { document.getElementById('ph-body').innerHTML=
bestHtml = '<div style="margin-top:0.6rem;font-size:0.72rem;color:var(--text-dim)">Best now: ' '<svg viewBox="0 0 '+W+' '+H+'" style="width:100%;height:auto;display:block">'+gridSvg+linesSvg+xLabels
+ cur.slice(0,3).map(function(c) { return '<b style="color:var(--text)">' + c.best_price + ' ' + c.currency + '</b> @ ' + esc(c.source_vendor); }).join(' · ') +'<text x="'+(W/2)+'" y="'+(H-2)+'" text-anchor="middle" font-size="9" fill="var(--text-dim)">'+currency+'</text></svg>'
+ '</div>'; +'<div style="margin-top:0.4rem;line-height:1.8">'+legend+'</div>'+bestHtml;
}
document.getElementById('ph-body').innerHTML =
'<svg viewBox="0 0 ' + W + ' ' + H + '" style="width:100%;height:auto;display:block">'
+ gridSvg + linesSvg + xLabels
+ '<text x="' + (W/2) + '" y="' + (H-2) + '" text-anchor="middle" font-size="9" fill="var(--text-dim)">' + currency + '</text>'
+ '</svg>'
+ '<div style="margin-top:0.4rem;line-height:1.8">' + legend + '</div>'
+ bestHtml;
} catch(e) { } catch(e) {
var b = document.getElementById('ph-body'); var b=document.getElementById('ph-body');
if (b) b.innerHTML = '<div style="color:var(--text-dim);padding:1rem">Failed to load price history: ' + esc(e.message) + '</div>'; if(b) b.innerHTML='<div style="color:var(--text-dim);padding:1rem">Error: '+esc(e.message)+'</div>';
} }
} }
/* ── END PRICE HISTORY CHART ─────────────────────────────────────────────── */ /* ── END PRICE HISTORY CHART ─────────────────────────────────────────── */
</script> </script>
<script src="/dashboard/hot-topics.js"></script> <script src="/dashboard/hot-topics.js"></script>
</body> </body>