feat(dashboard): interactive price history chart with hover tooltip
Replace 260×60 sparkline with full 520×200 SVG line chart: - Multi-vendor colored polylines (up to 8, MAGATAMA indigo palette) - USD-normalized prices (EUR×1.08, GBP×1.27) - Y/X axes with grid lines, date labels, price labels - Hover vertical cursor + floating tooltip per-day vendor prices - Click-to-toggle vendor legend - 7d / 14d / 30d time range selector with live API re-fetch - Current best prices table below the chart - End-point dots per vendor line
This commit is contained in:
parent
bcab2b97af
commit
d7c1c351fe
@ -1,6 +1,7 @@
|
|||||||
# TIP Changelog
|
# TIP Changelog
|
||||||
|
|
||||||
Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}`
|
Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}`
|
||||||
|
{"d":"2026-05-14","t":"UI","m":"Price History Chart: replaced 260×60px sparkline with full interactive SVG chart (520×200px). Features: multi-vendor colored polylines with end-point dots, Y-axis labels (USD normalized), X-axis date ticks (MM-DD), horizontal grid lines, hover cursor line + floating tooltip showing all vendor prices for hovered day, vendor legend with click-to-toggle visibility, time range selector (7d/14d/30d) with live re-fetch, current best prices table (FX-normalized to USD). FX normalization: EUR×1.08, GBP×1.27. Supports up to 8 vendors (indigo/orange/green/yellow/blue/red/purple/cyan palette). No API changes — existing GET /api/price-history/:id endpoint already returned price_max/min/avg per vendor per day."}
|
||||||
{"d":"2026-05-14","t":"FEAT","m":"Procurement: 5 neue Intelligence-Sektionen. (E) 🟢 Buy-Now Intel — Top buy_now Reorder Signals aus 211k preberechneten Signalen, filterbar nach Form Factor, Signalstärke-Balken, Preis/Stock-Trend, Gründe als Tooltip. API: GET /api/procurement/reorder-top. (A) 💰 Arbitrage — FX-Preis vs. Competitor-Preis für 59k Equivalenz-Paare mit Preisdaten auf beiden Seiten, normalisiert auf USD (EUR×1.08, GBP×1.27), sortiert nach Ersparnis-%. API: GET /api/procurement/arbitrage. (B) 🖥 Switch Compat — Suche nach Switch-Modell (Cisco, Juniper, Arista etc.), zeigt alle kompatiblen Transceiver mit Preis + Verifikationsmethode. 58k Compatibility-Rows, 429 Switches. API: GET /api/procurement/switch-compat?search=. (C) ⚠️ Supply Squeeze — Multi-Signal-Detektor: 4 parallele Quellen (Preis-Momentum 30d vs 60d, Hype-Phase, AI-Cluster-Transceiver-Nachfrage, Stock-Level-Verteilung). Severity: critical/warning/watch. API: GET /api/procurement/supply-squeeze. (D) 🪦 Dead Stock Revival — 7.297 Dead-Stock-SKUs gegen Hype-Cycle-Phasen: zeigt welche Lagerhüter in Technologieklassen liegen die gerade aufsteigen (ascending hype phases, score >30). API: GET /api/procurement/dead-stock-revival."}
|
{"d":"2026-05-14","t":"FEAT","m":"Procurement: 5 neue Intelligence-Sektionen. (E) 🟢 Buy-Now Intel — Top buy_now Reorder Signals aus 211k preberechneten Signalen, filterbar nach Form Factor, Signalstärke-Balken, Preis/Stock-Trend, Gründe als Tooltip. API: GET /api/procurement/reorder-top. (A) 💰 Arbitrage — FX-Preis vs. Competitor-Preis für 59k Equivalenz-Paare mit Preisdaten auf beiden Seiten, normalisiert auf USD (EUR×1.08, GBP×1.27), sortiert nach Ersparnis-%. API: GET /api/procurement/arbitrage. (B) 🖥 Switch Compat — Suche nach Switch-Modell (Cisco, Juniper, Arista etc.), zeigt alle kompatiblen Transceiver mit Preis + Verifikationsmethode. 58k Compatibility-Rows, 429 Switches. API: GET /api/procurement/switch-compat?search=. (C) ⚠️ Supply Squeeze — Multi-Signal-Detektor: 4 parallele Quellen (Preis-Momentum 30d vs 60d, Hype-Phase, AI-Cluster-Transceiver-Nachfrage, Stock-Level-Verteilung). Severity: critical/warning/watch. API: GET /api/procurement/supply-squeeze. (D) 🪦 Dead Stock Revival — 7.297 Dead-Stock-SKUs gegen Hype-Cycle-Phasen: zeigt welche Lagerhüter in Technologieklassen liegen die gerade aufsteigen (ascending hype phases, score >30). API: GET /api/procurement/dead-stock-revival."}
|
||||||
{"d":"2026-05-14","t":"FEAT","m":"Crawler Intelligence: Data Quality panel. New GET /api/scrapers/data-quality endpoint — 4 parallel queries over 200,617 transceiver_verification_evidence rows: (1) coverage breakdown (price 11,366/18,146 = 62%, image 12,333/68%, details 17,085/94%, competitor_match 399/2%, quarantined 1,193); (2) all 10 evidence types with count + avg confidence + product count + last seen; (3) robot/scraper contributions table (17 robots ranked by output); (4) daily activity last 14 days. Dashboard Crawler Intelligence tab: new 🔬 Data Quality section with coverage progress bars (color-coded ≥80% green / ≥50% amber / red), evidence type table, SVG sparkline bar chart for 14-day activity, robot contributions table with live/stale dot indicators."}
|
{"d":"2026-05-14","t":"FEAT","m":"Crawler Intelligence: Data Quality panel. New GET /api/scrapers/data-quality endpoint — 4 parallel queries over 200,617 transceiver_verification_evidence rows: (1) coverage breakdown (price 11,366/18,146 = 62%, image 12,333/68%, details 17,085/94%, competitor_match 399/2%, quarantined 1,193); (2) all 10 evidence types with count + avg confidence + product count + last seen; (3) robot/scraper contributions table (17 robots ranked by output); (4) daily activity last 14 days. Dashboard Crawler Intelligence tab: new 🔬 Data Quality section with coverage progress bars (color-coded ≥80% green / ≥50% amber / red), evidence type table, SVG sparkline bar chart for 14-day activity, robot contributions table with live/stale dot indicators."}
|
||||||
{"d":"2026-05-14","t":"FEAT","m":"Dynamic Hype Cycle + Market Signal Engine: Hype Cycle tab is now fully data-driven. New GET /api/hype-cycle/market-signals endpoint blends 6 real data sources into a composite Market Signal Score (0–100) per technology: (1) hype_score from Norton-Bass model (30% weight), (2) hyperscaler CapEx YoY avg (Microsoft +68.8%, Alphabet +107.4%, Meta +46.8%), (3) price observation activity ratio 30d vs prior 30d, (4) AI cluster estimated transceiver demand (90d window), (5) eBay secondary market sell-through velocity, (6) internal fast-mover demand trend. Score thresholds: ≥70 green, ≥50 yellow, ≥30 orange, <30 gray. Recommendation engine: buildRecommendation(phase, signalScore, capexYoyAvg, speedGbps) maps hype phase × capex boom × speed class → Buy/Hold/Watch label with color + detail tooltip. Dashboard: Hype Cycle table shows Market Signal ● LIVE column (score + progress bar) + Recommendation column (emoji label, tooltip with reasoning). Market Context cards row above table shows Top Signal, CapEx Boom %, Fast Movers signal, eBay Velocity. New Hyperscaler CapEx panel (SEC filing data) + eBay Secondary Market panel at bottom of hype tab. Procurement: new 🛒 eBay Market sub-section with per-form-factor sell-through grid. All 6 queries run in parallel via Promise.all()."}
|
{"d":"2026-05-14","t":"FEAT","m":"Dynamic Hype Cycle + Market Signal Engine: Hype Cycle tab is now fully data-driven. New GET /api/hype-cycle/market-signals endpoint blends 6 real data sources into a composite Market Signal Score (0–100) per technology: (1) hype_score from Norton-Bass model (30% weight), (2) hyperscaler CapEx YoY avg (Microsoft +68.8%, Alphabet +107.4%, Meta +46.8%), (3) price observation activity ratio 30d vs prior 30d, (4) AI cluster estimated transceiver demand (90d window), (5) eBay secondary market sell-through velocity, (6) internal fast-mover demand trend. Score thresholds: ≥70 green, ≥50 yellow, ≥30 orange, <30 gray. Recommendation engine: buildRecommendation(phase, signalScore, capexYoyAvg, speedGbps) maps hype phase × capex boom × speed class → Buy/Hold/Watch label with color + detail tooltip. Dashboard: Hype Cycle table shows Market Signal ● LIVE column (score + progress bar) + Recommendation column (emoji label, tooltip with reasoning). Market Context cards row above table shows Top Signal, CapEx Boom %, Fast Movers signal, eBay Velocity. New Hyperscaler CapEx panel (SEC filing data) + eBay Secondary Market panel at bottom of hype tab. Procurement: new 🛒 eBay Market sub-section with per-form-factor sell-through grid. All 6 queries run in parallel via Promise.all()."}
|
||||||
|
|||||||
@ -4191,74 +4191,207 @@ async function openTxDetail(id) {
|
|||||||
el('panel-content').insertAdjacentHTML('beforeend', ch);
|
el('panel-content').insertAdjacentHTML('beforeend', ch);
|
||||||
}).catch(function() {});
|
}).catch(function() {});
|
||||||
|
|
||||||
// Async: load price history chart (last 30 days)
|
// Async: load price history chart — full interactive SVG, multi-vendor, axes, hover tooltip
|
||||||
(function loadPriceHistoryPanel(txId) {
|
(function initPriceHistory(txId) {
|
||||||
var ph = '<div class="panel-section">📈 Price History <span style="font-size:0.7rem;font-weight:400;color:var(--text-dim)">(30 days)</span></div>';
|
var W = 520, H = 200, PL = 58, PR = 12, PT = 12, PB = 28;
|
||||||
ph += '<div id="price-history-inner" style="min-height:60px;font-size:0.78rem;color:var(--text-dim)">Loading…</div>';
|
var COLORS = ['#6366f1','#f97316','#10b981','#f59e0b','#3b82f6','#ef4444','#a855f7','#06b6d4'];
|
||||||
|
var FX = { USD: 1.0, EUR: 1.08, GBP: 1.27 };
|
||||||
|
var plotW = W - PL - PR, plotH = H - PT - PB;
|
||||||
|
|
||||||
|
var ph = '<div class="panel-section" style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.4rem">'
|
||||||
|
+ '<span>📈 Price History <span id="ph-lbl" style="font-size:0.7rem;font-weight:400;color:var(--text-dim)"></span></span>'
|
||||||
|
+ '<span style="display:flex;gap:0.25rem">'
|
||||||
|
+ [7,14,30].map(function(d){ return '<button id="ph-b'+d+'" onclick="phRange('+d+')" style="padding:2px 8px;border-radius:4px;border:1px solid var(--border);font-size:0.68rem;cursor:pointer;background:'+(d===30?'var(--accent)':'var(--surface2)')+';color:'+(d===30?'#fff':'var(--text)')+'">'+d+'d</button>'; }).join('')
|
||||||
|
+ '</span></div>'
|
||||||
|
+ '<div style="position:relative;margin:0.4rem 0 0.2rem">'
|
||||||
|
+ '<div id="ph-inner" style="min-height:80px;color:var(--text-dim);font-size:0.78rem">Loading…</div>'
|
||||||
|
+ '<div id="ph-tip" style="display:none;position:fixed;background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:5px 9px;font-size:0.7rem;pointer-events:none;z-index:9999;box-shadow:0 2px 10px rgba(0,0,0,0.2)"></div>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div id="ph-leg" style="display:flex;flex-wrap:wrap;gap:0.3rem 0.7rem;margin-bottom:0.3rem"></div>'
|
||||||
|
+ '<div id="ph-cur" style="font-size:0.72rem;margin-bottom:0.2rem"></div>';
|
||||||
el('panel-content').insertAdjacentHTML('beforeend', ph);
|
el('panel-content').insertAdjacentHTML('beforeend', ph);
|
||||||
api('/api/price-history/' + txId + '?days=30').then(function(phd) {
|
|
||||||
var inner = document.getElementById('price-history-inner');
|
window._ph = { days: 30, hidden: new Set(), data: null };
|
||||||
|
|
||||||
|
window.phRange = function(d) {
|
||||||
|
window._ph.days = d;
|
||||||
|
[7,14,30].forEach(function(x) {
|
||||||
|
var b = document.getElementById('ph-b'+x);
|
||||||
|
if (b) { b.style.background = (x===d ? 'var(--accent)' : 'var(--surface2)'); b.style.color = (x===d ? '#fff' : 'var(--text)'); }
|
||||||
|
});
|
||||||
|
phFetch(d);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.phVendor = function(v) {
|
||||||
|
if (window._ph.hidden.has(v)) window._ph.hidden.delete(v); else window._ph.hidden.add(v);
|
||||||
|
if (window._ph.data) phDraw(window._ph.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
function phFetch(days) {
|
||||||
|
var inner = document.getElementById('ph-inner');
|
||||||
|
if (inner) inner.textContent = 'Loading…';
|
||||||
|
api('/api/price-history/' + txId + '?days=' + days)
|
||||||
|
.then(function(d) { window._ph.data = d; phDraw(d); })
|
||||||
|
.catch(function() { var i = document.getElementById('ph-inner'); if (i) i.textContent = 'Price history unavailable.'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function phDraw(phd) {
|
||||||
|
var inner = document.getElementById('ph-inner');
|
||||||
|
var legEl = document.getElementById('ph-leg');
|
||||||
|
var curEl = document.getElementById('ph-cur');
|
||||||
|
var lblEl = document.getElementById('ph-lbl');
|
||||||
if (!inner) return;
|
if (!inner) return;
|
||||||
|
|
||||||
var series = phd.series || [];
|
var series = phd.series || [];
|
||||||
if (!series.length) { inner.textContent = 'No price data in the last 30 days.'; return; }
|
if (!series.length) {
|
||||||
|
inner.innerHTML = '<div style="padding:1rem 0;color:var(--text-dim)">No price data for this period.</div>';
|
||||||
|
if (legEl) legEl.innerHTML = '';
|
||||||
|
if (curEl) curEl.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Group series by vendor
|
// Per-vendor data normalised to USD
|
||||||
var byVendor = {};
|
var byV = {};
|
||||||
series.forEach(function(row) {
|
series.forEach(function(r) {
|
||||||
var v = row.source_vendor || 'Unknown';
|
var v = r.source_vendor || 'Unknown';
|
||||||
if (!byVendor[v]) byVendor[v] = [];
|
if (!byV[v]) byV[v] = [];
|
||||||
byVendor[v].push(row);
|
var fx = FX[r.currency] || 1;
|
||||||
|
byV[v].push({ day: (r.day || '').substring(0, 10), avg: +r.price_avg * fx, min: +r.price_min * fx, orig: +r.price_avg, cur: r.currency });
|
||||||
|
});
|
||||||
|
var vendors = Object.keys(byV).slice(0, 8);
|
||||||
|
|
||||||
|
// All unique days sorted
|
||||||
|
var dayMap = {};
|
||||||
|
series.forEach(function(r) { dayMap[(r.day || '').substring(0, 10)] = 1; });
|
||||||
|
var allDays = Object.keys(dayMap).sort();
|
||||||
|
var xN = allDays.length;
|
||||||
|
if (!xN) { inner.textContent = 'No data.'; return; }
|
||||||
|
|
||||||
|
// Y range across visible vendors
|
||||||
|
var vis = vendors.filter(function(v) { return !window._ph.hidden.has(v); });
|
||||||
|
var vals = [];
|
||||||
|
(vis.length ? vis : vendors).forEach(function(v) { byV[v].forEach(function(p) { if (p.avg > 0) vals.push(p.avg); }); });
|
||||||
|
if (!vals.length) { inner.textContent = 'No data.'; return; }
|
||||||
|
|
||||||
|
var yMn = Math.min.apply(null, vals), yMx = Math.max.apply(null, vals);
|
||||||
|
var pad = (yMx - yMn) * 0.08 || yMx * 0.08 || 1;
|
||||||
|
yMn = Math.max(0, yMn - pad); yMx += pad;
|
||||||
|
var yR = yMx - yMn || 1;
|
||||||
|
|
||||||
|
function tx(i) { return PL + (xN < 2 ? plotW / 2 : (i / (xN - 1)) * plotW); }
|
||||||
|
function ty(v) { return PT + plotH - ((v - yMn) / yR) * plotH; }
|
||||||
|
|
||||||
|
// Horizontal grid lines + y-labels
|
||||||
|
var grid = '';
|
||||||
|
for (var g = 0; g <= 3; g++) {
|
||||||
|
var gv = yMn + (yR * g / 3), gy = ty(gv).toFixed(1);
|
||||||
|
grid += '<line x1="' + PL + '" y1="' + gy + '" x2="' + (W - PR) + '" y2="' + gy + '" stroke="var(--border)" stroke-width="0.5" stroke-dasharray="3,3"/>';
|
||||||
|
var lbl = gv >= 1000 ? (gv / 1000).toFixed(1) + 'k' : gv.toFixed(gv < 10 ? 2 : gv < 100 ? 1 : 0);
|
||||||
|
grid += '<text x="' + (PL - 4) + '" y="' + (parseFloat(gy) + 3.5) + '" text-anchor="end" fill="var(--text-dim)" font-size="9">' + lbl + '</text>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// X-axis date labels (max ~6)
|
||||||
|
var xlbl = '', xstep = Math.max(1, Math.floor(xN / 6));
|
||||||
|
for (var xi = 0; xi < xN; xi += xstep) {
|
||||||
|
xlbl += '<text x="' + tx(xi).toFixed(1) + '" y="' + (H - 6) + '" text-anchor="middle" fill="var(--text-dim)" font-size="9">' + (allDays[xi] || '').substring(5) + '</text>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vendor polylines with end-point dots
|
||||||
|
var lines = '';
|
||||||
|
vendors.forEach(function(v, vi) {
|
||||||
|
if (window._ph.hidden.has(v)) return;
|
||||||
|
var col = COLORS[vi % COLORS.length];
|
||||||
|
var pts = byV[v]
|
||||||
|
.map(function(p) { var i = allDays.indexOf(p.day); return (i < 0 || p.avg <= 0) ? null : tx(i).toFixed(1) + ',' + ty(p.avg).toFixed(1); })
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!pts.length) return;
|
||||||
|
if (pts.length === 1) {
|
||||||
|
lines += '<circle cx="' + pts[0].split(',')[0] + '" cy="' + pts[0].split(',')[1] + '" r="3.5" fill="' + col + '"/>';
|
||||||
|
} else {
|
||||||
|
lines += '<polyline points="' + pts.join(' ') + '" fill="none" stroke="' + col + '" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>';
|
||||||
|
var lp = pts[pts.length - 1].split(',');
|
||||||
|
lines += '<circle cx="' + lp[0] + '" cy="' + lp[1] + '" r="3" fill="' + col + '" stroke="var(--surface2)" stroke-width="1.5"/>';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build mini sparklines (SVG-based, per vendor)
|
// Hover cursor line + transparent interaction overlay
|
||||||
var vendorColors = ['#f97316','#3b82f6','#10b981','#a855f7','#f59e0b','#ef4444','#06b6d4','#84cc16'];
|
var hoverEls = '<line id="ph-hl" x1="0" y1="' + PT + '" x2="0" y2="' + (PT + plotH) + '" stroke="var(--text-dim)" stroke-width="1" stroke-dasharray="3,2" display="none"/>'
|
||||||
var vendors = Object.keys(byVendor).slice(0, 8);
|
+ '<rect id="ph-hr" x="' + PL + '" y="' + PT + '" width="' + plotW + '" height="' + plotH + '" fill="transparent" style="cursor:crosshair"/>';
|
||||||
|
|
||||||
// Collect all values for y-scale
|
inner.innerHTML = '<svg width="' + W + '" height="' + H + '" style="display:block;max-width:100%;overflow:visible">'
|
||||||
var allVals = series.map(function(r) { return parseFloat(r.price_avg); }).filter(function(v) { return !isNaN(v) && v > 0; });
|
+ '<rect width="' + W + '" height="' + H + '" rx="5" fill="var(--surface2)"/>'
|
||||||
var yMin = Math.min.apply(null, allVals);
|
+ grid + xlbl + lines + hoverEls + '</svg>';
|
||||||
var yMax = Math.max.apply(null, allVals);
|
|
||||||
var yRange = yMax - yMin || 1;
|
|
||||||
|
|
||||||
// Collect all days for x-scale
|
if (lblEl) lblEl.textContent = '(' + window._ph.days + 'd · USD normalized)';
|
||||||
var allDays = [];
|
|
||||||
series.forEach(function(r) { if (allDays.indexOf(r.day) === -1) allDays.push(r.day); });
|
|
||||||
allDays.sort();
|
|
||||||
var xCount = allDays.length || 1;
|
|
||||||
var W = 260, H = 60;
|
|
||||||
|
|
||||||
var svgLines = '';
|
// Vendor legend with click-to-toggle
|
||||||
vendors.forEach(function(vendor, vi) {
|
if (legEl) {
|
||||||
var points = byVendor[vendor];
|
legEl.innerHTML = vendors.map(function(v, vi) {
|
||||||
var coords = points.map(function(p) {
|
var col = COLORS[vi % COLORS.length], hidden = window._ph.hidden.has(v);
|
||||||
var xi = allDays.indexOf(p.day);
|
var last = byV[v][byV[v].length - 1];
|
||||||
var x = Math.round((xi / (xCount - 1 || 1)) * (W - 8) + 4);
|
var lv = last ? '~$' + last.avg.toFixed(last.avg < 10 ? 2 : 0) : '';
|
||||||
var y = Math.round(H - 4 - ((parseFloat(p.price_avg) - yMin) / yRange) * (H - 8));
|
var safe = v.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||||
return x + ',' + y;
|
return '<span onclick="phVendor(\'' + safe + '\')" style="cursor:pointer;display:inline-flex;align-items:center;gap:4px;font-size:0.7rem;opacity:' + (hidden ? '0.3' : '1') + '">'
|
||||||
}).join(' ');
|
+ '<span style="width:12px;height:2.5px;background:' + col + ';display:inline-block;border-radius:2px;flex-shrink:0"></span>'
|
||||||
svgLines += '<polyline points="' + coords + '" fill="none" stroke="' + vendorColors[vi % vendorColors.length] + '" stroke-width="1.5" stroke-linejoin="round"/>';
|
+ '<span>' + esc(v) + '</span>'
|
||||||
});
|
+ '<span style="color:var(--text-dim)">' + lv + '</span></span>';
|
||||||
|
|
||||||
var currency = series[0].currency || 'USD';
|
|
||||||
var legend = vendors.map(function(v, i) {
|
|
||||||
var last = byVendor[v][byVendor[v].length - 1];
|
|
||||||
var lastVal = last ? parseFloat(last.price_min).toFixed(2) : '—';
|
|
||||||
return '<span style="display:inline-flex;align-items:center;gap:3px;white-space:nowrap;font-size:0.68rem">'
|
|
||||||
+ '<span style="width:10px;height:2px;background:' + vendorColors[i % vendorColors.length] + ';display:inline-block;border-radius:1px"></span>'
|
|
||||||
+ esc(v) + ' ' + currency + ' ' + lastVal + '</span>';
|
|
||||||
}).join('');
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
var html = '<svg width="' + W + '" height="' + H + '" style="display:block;margin-bottom:6px;overflow:visible">'
|
// Current best prices
|
||||||
+ '<rect width="' + W + '" height="' + H + '" rx="4" fill="var(--surface2)"/>'
|
if (curEl && phd.current_prices && phd.current_prices.length) {
|
||||||
+ svgLines + '</svg>'
|
curEl.innerHTML = '<div style="font-size:0.7rem;color:var(--text-dim);margin-bottom:0.3rem">Best prices (last 7 days)</div>'
|
||||||
+ '<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-bottom:0.3rem">' + legend + '</div>'
|
+ '<div style="display:flex;flex-wrap:wrap;gap:0.3rem">'
|
||||||
+ '<div style="font-size:0.66rem;color:var(--text-dim)">' + currency + ' range: '
|
+ phd.current_prices.slice(0, 6).map(function(r) {
|
||||||
+ yMin.toFixed(2) + ' – ' + yMax.toFixed(2) + ' · ' + series.length + ' observations</div>';
|
var usd = (+r.best_price * (FX[r.currency] || 1)).toFixed(2);
|
||||||
inner.innerHTML = html;
|
return '<span class="b b-neutral" style="font-size:0.7rem">' + esc(r.source_vendor)
|
||||||
}).catch(function() {
|
+ ' <strong style="color:var(--text)">$' + usd + '</strong>'
|
||||||
var inner = document.getElementById('price-history-inner');
|
+ (r.currency !== 'USD' ? ' <span style="color:var(--text-dim)">(' + r.currency + ' ' + parseFloat(r.best_price).toFixed(2) + ')</span>' : '')
|
||||||
if (inner) inner.textContent = 'Price history not available.';
|
+ '</span>';
|
||||||
|
}).join('')
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hover interaction: vertical line + floating tooltip
|
||||||
|
var hr = document.getElementById('ph-hr');
|
||||||
|
var hl = document.getElementById('ph-hl');
|
||||||
|
var tip = document.getElementById('ph-tip');
|
||||||
|
if (!hr || !hl || !tip) return;
|
||||||
|
|
||||||
|
hr.addEventListener('mousemove', function(e) {
|
||||||
|
var svgEl = hr.closest('svg');
|
||||||
|
if (!svgEl) return;
|
||||||
|
var rect = svgEl.getBoundingClientRect();
|
||||||
|
var scaleX = W / (rect.width || W);
|
||||||
|
var mx = (e.clientX - rect.left) * scaleX;
|
||||||
|
var relX = mx - PL;
|
||||||
|
if (relX < 0 || relX > plotW) { hl.setAttribute('display', 'none'); tip.style.display = 'none'; return; }
|
||||||
|
|
||||||
|
var idx = Math.max(0, Math.min(Math.round((relX / plotW) * (xN - 1)), xN - 1));
|
||||||
|
var hDay = allDays[idx];
|
||||||
|
var hx = tx(idx);
|
||||||
|
hl.setAttribute('x1', hx.toFixed(1)); hl.setAttribute('x2', hx.toFixed(1)); hl.setAttribute('display', 'inline');
|
||||||
|
|
||||||
|
var rows = vendors.map(function(v, vi) {
|
||||||
|
var p = byV[v].find(function(q) { return q.day === hDay; });
|
||||||
|
if (!p || p.avg <= 0) return '';
|
||||||
|
var col = COLORS[vi % COLORS.length];
|
||||||
|
return '<div style="display:flex;align-items:center;gap:5px;white-space:nowrap">'
|
||||||
|
+ '<span style="width:7px;height:7px;border-radius:50%;background:' + col + ';flex-shrink:0"></span>'
|
||||||
|
+ '<span style="flex:1;color:var(--text-dim)">' + esc(v) + '</span>'
|
||||||
|
+ '<strong>$' + p.avg.toFixed(2) + '</strong></div>';
|
||||||
|
}).filter(Boolean);
|
||||||
|
|
||||||
|
if (!rows.length) { hl.setAttribute('display', 'none'); tip.style.display = 'none'; return; }
|
||||||
|
tip.innerHTML = '<div style="font-size:0.65rem;color:var(--text-dim);margin-bottom:3px">' + hDay + '</div>' + rows.join('');
|
||||||
|
tip.style.display = 'block';
|
||||||
|
tip.style.left = (e.clientX + 14) + 'px';
|
||||||
|
tip.style.top = (e.clientY - tip.offsetHeight / 2) + 'px';
|
||||||
});
|
});
|
||||||
|
hr.addEventListener('mouseleave', function() { hl.setAttribute('display', 'none'); tip.style.display = 'none'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
phFetch(30);
|
||||||
})(t.id);
|
})(t.id);
|
||||||
|
|
||||||
// Async: load cross-brand equivalences
|
// Async: load cross-brand equivalences
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user