diff --git a/viz/app/stats_page.js b/viz/app/stats_page.js index a839329..a3058ad 100644 --- a/viz/app/stats_page.js +++ b/viz/app/stats_page.js @@ -1,31 +1,35 @@ import { esc } from "./dom.js"; -import { fetchJson, inferGithubOwnerRepo, githubFetchFileAtSha, githubListCommits } from "./api.js"; +import { + fetchJson, + inferGithubOwnerRepo, + githubFetchFileAtSha, + githubListCommits, +} from "./api.js"; let _chart = null; - const STORE_LABELS = { - bcl: "BCL", - bsw: "BSW", - coop: "Co-op World of Whisky", - craftcellars: "Craft Cellars", - gull: "Gull Liquor", - kegncork: "Keg N Cork", - kwm: "Kensington Wine Market", - legacy: "Legacy Liquor", - legacyliquor: "Legacy Liquor", - maltsandgrains: "Malts & Grains", - sierrasprings: "Sierra Springs", - strath: "Strath Liquor", - tudor: "Tudor House", - vessel: "Vessel Liquor", - vintage: "Vintage Spirits", - willowpark: "Willow Park", - }; - + bcl: "BCL", + bsw: "BSW", + coop: "Co-op World of Whisky", + craftcellars: "Craft Cellars", + gull: "Gull Liquor", + kegncork: "Keg N Cork", + kwm: "Kensington Wine Market", + legacy: "Legacy Liquor", + legacyliquor: "Legacy Liquor", + maltsandgrains: "Malts & Grains", + sierrasprings: "Sierra Springs", + strath: "Strath Liquor", + tudor: "Tudor House", + vessel: "Vessel Liquor", + vintage: "Vintage Spirits", + willowpark: "Willow Park", +}; + function displayStoreName(storeKey) { -const k = String(storeKey || "").toLowerCase(); -return STORE_LABELS[k] || storeKey; + const k = String(storeKey || "").toLowerCase(); + return STORE_LABELS[k] || storeKey; } export function destroyStatsChart() { @@ -41,7 +45,8 @@ function ensureChartJs() { return new Promise((resolve, reject) => { const s = document.createElement("script"); // UMD build -> window.Chart - s.src = "https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"; + s.src = + "https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"; s.async = true; s.onload = () => resolve(window.Chart); s.onerror = () => reject(new Error("Failed to load Chart.js")); @@ -91,41 +96,101 @@ function makeLimiter(max) { }); } -function statsCacheKey(group, size, latestSha) { - return `stviz:v1:stats:common:${group}:${size}:${latestSha}`; +function tokenizeQuery(q) { + return String(q || "") + .toLowerCase() + .split(/\s+/) + .map((s) => s.trim()) + .filter(Boolean); } -function loadStatsCache(group, size, latestSha) { - try { - const raw = localStorage.getItem(statsCacheKey(group, size, latestSha)); - if (!raw) return null; - const obj = JSON.parse(raw); - if (!obj || !Array.isArray(obj.labels) || !Array.isArray(obj.stores) || typeof obj.seriesByStore !== "object") return null; - const savedAt = Number(obj.savedAt || 0); - if (!Number.isFinite(savedAt) || Date.now() - savedAt > 7 * 24 * 3600 * 1000) return null; - return obj; - } catch { - return null; +function matchesAllTokens(haystack, tokens) { + if (!tokens.length) return true; + const h = String(haystack || "").toLowerCase(); + for (const t of tokens) { + if (!h.includes(t)) return false; } + return true; } -function saveStatsCache(group, size, latestSha, payload) { - try { - localStorage.setItem( - statsCacheKey(group, size, latestSha), - JSON.stringify({ savedAt: Date.now(), ...payload }) - ); - } catch {} +function rowSearchText(r) { + const rep = r?.representative || {}; + return [ + r?.canonSku, + rep?.name, + rep?.skuRaw, + rep?.skuKey, + rep?.categoryLabel, + rep?.storeLabel, + rep?.storeKey, + ] + .map((x) => String(x || "").trim()) + .filter(Boolean) + .join(" | ") + .toLowerCase(); } -function relReportPath(group, size) { - return `reports/common_listings_${group}_top${size}.json`; +// Prefer representative.priceNum; else cheapest.priceNum; else median(storePrices) +function rowPriceNum(r, stores) { + const rep = r?.representative; + const ch = r?.cheapest; + + const a = rep && Number.isFinite(rep.priceNum) ? rep.priceNum : null; + if (a !== null) return a; + + const b = ch && Number.isFinite(ch.priceNum) ? ch.priceNum : null; + if (b !== null) return b; + + const sp = r && typeof r === "object" ? r.storePrices : null; + if (!sp || typeof sp !== "object") return null; + + const prices = []; + for (const s of stores) { + const p = sp[s]; + if (Number.isFinite(p)) prices.push(p); + } + prices.sort((x, y) => x - y); + const med = medianOfSorted(prices); + return Number.isFinite(med) ? med : null; } +/* ---------------- price slider mapping (store-page style) ---------------- */ + +function stepForPrice(p, boundMax) { + const x = Number.isFinite(p) ? p : boundMax; + if (x < 120) return 5; + if (x < 250) return 10; + if (x < 600) return 25; + return 100; +} +function roundToStep(p, boundMax) { + const step = stepForPrice(p, boundMax); + return Math.round(p / step) * step; +} + +function clamp(n, lo, hi) { + return Math.max(lo, Math.min(hi, n)); +} + +function formatDollars(p) { + if (!Number.isFinite(p)) return ""; + return `$${Math.round(p)}`; +} + +/* ---------------- report filtering + series ---------------- */ + // avg over SKUs that store has a price for: ((storePrice - medianPrice) / medianPrice) * 100 -function computeDailyStoreSeriesFromReport(report) { - const stores = Array.isArray(report?.stores) ? report.stores.map(String) : []; +function computeDailyStoreSeriesFromReport(report, filter) { + const stores = Array.isArray(filter?.stores) + ? filter.stores.map(String) + : Array.isArray(report?.stores) + ? report.stores.map(String) + : []; + const rows = Array.isArray(report?.rows) ? report.rows : []; + const tokens = Array.isArray(filter?.tokens) ? filter.tokens : []; + const minP = Number.isFinite(filter?.minPrice) ? filter.minPrice : null; + const maxP = Number.isFinite(filter?.maxPrice) ? filter.maxPrice : null; const sum = new Map(); const cnt = new Map(); @@ -134,8 +199,25 @@ function computeDailyStoreSeriesFromReport(report) { cnt.set(s, 0); } + let usedRows = 0; + for (const r of rows) { - const sp = r && typeof r === "object" ? r.storePrices : null; + if (!r || typeof r !== "object") continue; + + if (tokens.length) { + if (!matchesAllTokens(rowSearchText(r), tokens)) continue; + } + + if (minP !== null || maxP !== null) { + const rp = rowPriceNum(r, stores); + // store-page behavior: "no price" rows pass the filter (they won't contribute anyway) + if (rp !== null) { + if (minP !== null && rp < minP) continue; + if (maxP !== null && rp > maxP) continue; + } + } + + const sp = r.storePrices; if (!sp || typeof sp !== "object") continue; const prices = []; @@ -148,6 +230,8 @@ function computeDailyStoreSeriesFromReport(report) { const med = medianOfSorted(prices); if (!isFinitePos(med)) continue; + usedRows++; + for (const s of stores) { const p = sp[s]; if (!Number.isFinite(p)) continue; @@ -162,7 +246,11 @@ function computeDailyStoreSeriesFromReport(report) { const c = cnt.get(s) || 0; out[s] = c > 0 ? (sum.get(s) || 0) / c : null; } - return { stores, valuesByStore: out }; + return { stores, valuesByStore: out, usedRows, totalRows: rows.length }; +} + +function relReportPath(group, size) { + return `reports/common_listings_${group}_top${size}.json`; } /* ---------------- commits manifest ---------------- */ @@ -186,7 +274,6 @@ async function loadCommitsFallback({ owner, repo, branch, relPath }) { let apiCommits = await githubListCommits({ owner, repo, branch, path: relPath }); apiCommits = Array.isArray(apiCommits) ? apiCommits : []; - // newest -> oldest from API; we want newest-per-day then oldest -> newest const byDate = new Map(); for (const c of apiCommits) { const sha = String(c?.sha || ""); @@ -195,11 +282,14 @@ async function loadCommitsFallback({ owner, repo, branch, relPath }) { if (!sha || !d) continue; if (!byDate.has(d)) byDate.set(d, { sha, date: d, ts }); } - return [...byDate.values()].reverse(); } -async function buildStatsSeries({ group, size, onStatus }) { +/* ---------------- raw series cache (network only once per group/size) ---------------- */ + +const RAW_SERIES_CACHE = new Map(); // key: `${group}:${size}` -> { latestSha, labels, stores, commits, reportsByIdx } + +async function loadRawSeries({ group, size, onStatus }) { const rel = relReportPath(group, size); const gh = inferGithubOwnerRepo(); const owner = gh.owner; @@ -207,12 +297,11 @@ async function buildStatsSeries({ group, size, onStatus }) { const branch = "data"; const manifest = await loadCommonCommitsManifest(); - let commits = Array.isArray(manifest?.files?.[rel]) ? manifest.files[rel] : null; - // Fallback if manifest missing/empty if (!commits || !commits.length) { - if (typeof onStatus === "function") onStatus(`Commits manifest missing for ${rel}; using GitHub API fallback…`); + if (typeof onStatus === "function") + onStatus(`Commits manifest missing for ${rel}; using GitHub API fallback…`); commits = await loadCommitsFallback({ owner, repo, branch, relPath: rel }); } @@ -222,49 +311,40 @@ async function buildStatsSeries({ group, size, onStatus }) { const latestSha = String(latest?.sha || ""); if (!latestSha) throw new Error(`Invalid latest sha for ${rel}`); - const cached = loadStatsCache(group, size, latestSha); - if (cached) return { latestSha, labels: cached.labels, stores: cached.stores, seriesByStore: cached.seriesByStore }; + const cacheKey = `${group}:${size}`; + const cached = RAW_SERIES_CACHE.get(cacheKey); + if (cached && cached.latestSha === latestSha && cached.labels?.length === commits.length) { + return cached; + } const NET_CONCURRENCY = 10; const limitNet = makeLimiter(NET_CONCURRENCY); if (typeof onStatus === "function") onStatus(`Loading stores…`); - const newestReport = await limitNet(() => githubFetchFileAtSha({ owner, repo, sha: latestSha, path: rel })); + const newestReport = await limitNet(() => + githubFetchFileAtSha({ owner, repo, sha: latestSha, path: rel }) + ); - const stores = Array.isArray(newestReport?.stores) ? newestReport.stores.map(String) : []; + const stores = Array.isArray(newestReport?.stores) + ? newestReport.stores.map(String) + : []; if (!stores.length) throw new Error(`No stores found in ${rel} at ${latestSha.slice(0, 7)}`); const labels = commits.map((c) => String(c.date || "")).filter(Boolean); - - const seriesByStore = {}; - for (const s of stores) seriesByStore[s] = new Array(labels.length).fill(null); + const shaByIdx = commits.map((c) => String(c.sha || "")); if (typeof onStatus === "function") onStatus(`Loading ${labels.length} day(s)…`); - const shaByIdx = commits.map((c) => String(c.sha || "")); - const fileJsonCache = new Map(); - - async function loadReportAtSha(sha) { - if (fileJsonCache.has(sha)) return fileJsonCache.get(sha); - const obj = await githubFetchFileAtSha({ owner, repo, sha, path: rel }); - fileJsonCache.set(sha, obj); - return obj; - } + const reportsByIdx = new Array(shaByIdx.length).fill(null); let done = 0; await Promise.all( shaByIdx.map((sha, idx) => limitNet(async () => { try { - const report = await loadReportAtSha(sha); - const { valuesByStore } = computeDailyStoreSeriesFromReport(report); - - for (const s of stores) { - const v = valuesByStore[s]; - seriesByStore[s][idx] = Number.isFinite(v) ? v : null; - } + reportsByIdx[idx] = await githubFetchFileAtSha({ owner, repo, sha, path: rel }); } catch { - // leave nulls + reportsByIdx[idx] = null; } finally { done++; if (typeof onStatus === "function" && (done % 10 === 0 || done === shaByIdx.length)) { @@ -275,15 +355,73 @@ async function buildStatsSeries({ group, size, onStatus }) { ) ); - saveStatsCache(group, size, latestSha, { labels, stores, seriesByStore }); - return { latestSha, labels, stores, seriesByStore }; + const out = { latestSha, labels, stores, commits, reportsByIdx }; + RAW_SERIES_CACHE.set(cacheKey, out); + return out; } -/* ---------------- render ---------------- */ +function computePriceBoundsFromReport(report, stores) { + const rows = Array.isArray(report?.rows) ? report.rows : []; + let mn = null; + let mx = null; + + for (const r of rows) { + const p = rowPriceNum(r, stores); + if (!Number.isFinite(p) || p <= 0) continue; + mn = mn === null ? p : Math.min(mn, p); + mx = mx === null ? p : Math.max(mx, p); + } + return { min: mn, max: mx }; +} + +function computeSeriesFromRaw(raw, filter) { + const labels = raw.labels; + const stores = raw.stores; + const reportsByIdx = raw.reportsByIdx; + + const seriesByStore = {}; + for (const s of stores) seriesByStore[s] = new Array(labels.length).fill(null); + + // counts based on newest day only (for status) + let newestUsed = 0; + let newestTotal = 0; + + for (let i = 0; i < reportsByIdx.length; i++) { + const rep = reportsByIdx[i]; + if (!rep) continue; + + const daily = computeDailyStoreSeriesFromReport(rep, { + ...filter, + stores, + }); + + for (const s of stores) { + const v = daily.valuesByStore[s]; + seriesByStore[s][i] = Number.isFinite(v) ? v : null; + } + + if (i === reportsByIdx.length - 1) { + newestUsed = daily.usedRows; + newestTotal = daily.totalRows; + } + } + + return { labels, stores, seriesByStore, newestUsed, newestTotal }; +} + +/* ---------------- prefs ---------------- */ const LS_GROUP = "stviz:v1:stats:group"; const LS_SIZE = "stviz:v1:stats:size"; +const LS_Q = "stviz:v1:stats:q"; +function lsMinKey(group, size) { + return `stviz:v1:stats:minPrice:${group}:${size}`; +} +function lsMaxKey(group, size) { + return `stviz:v1:stats:maxPrice:${group}:${size}`; +} + function loadPrefs() { let group = "all"; let size = "250"; @@ -303,6 +441,8 @@ function savePrefs(group, size) { } catch {} } +/* ---------------- render ---------------- */ + export async function renderStats($app) { destroyStatsChart(); @@ -310,55 +450,82 @@ export async function renderStats($app) { $app.innerHTML = `
-
+
-
+
-
-

Store Price Index

-
Loading…
-
+

Store Price Index

+
Loading…
+
-
+
- -
-
-
+
-
+
+
+
+
+ + +
+ +
+
Price
+ +
+ + +
+ +
+
+
+
+
+
+ +
- -
+
+
- `; + `; - - const $status = document.getElementById("statsStatus"); const $group = document.getElementById("statsGroup"); const $size = document.getElementById("statsSize"); + const $q = document.getElementById("statsQ"); + const $clear = document.getElementById("statsClear"); + + const $minR = document.getElementById("statsMinPrice"); + const $maxR = document.getElementById("statsMaxPrice"); + const $priceLabel = document.getElementById("statsPriceLabel"); + const $priceWrap = document.getElementById("statsPriceWrap"); + if ($group) $group.value = pref.group; if ($size) $size.value = String(pref.size); @@ -370,7 +537,144 @@ export async function renderStats($app) { location.hash = "#/"; }); - const rerender = async () => { + // current UI state (derived after load) + let boundMin = 25; + let boundMax = 1000; + + let selectedMinPrice = boundMin; + let selectedMaxPrice = boundMax; + + function priceFromT(t) { + t = clamp(t, 0, 1); + if (boundMax <= boundMin) return boundMin; + const ratio = boundMax / boundMin; + return boundMin * Math.exp(Math.log(ratio) * t); + } + function tFromPrice(price) { + if (!Number.isFinite(price)) return 1; + if (boundMax <= boundMin) return 1; + const p = clamp(price, boundMin, boundMax); + const ratio = boundMax / boundMin; + return Math.log(p / boundMin) / Math.log(ratio); + } + function clampAndRound(p) { + const c = clamp(p, boundMin, boundMax); + const r = roundToStep(c, boundMax); + return clamp(r, boundMin, boundMax); + } + function setSliderFromPrice($el, price) { + const t = tFromPrice(price); + $el.value = String(Math.round(t * 1000)); + } + function priceFromSlider($el) { + const v = Number($el.value); + const t = Number.isFinite(v) ? v / 1000 : 1; + return priceFromT(t); + } + function updateRangeZ() { + // help when thumbs overlap + const a = Number($minR.value); + const b = Number($maxR.value); + if (a >= b - 10) { + $minR.style.zIndex = "5"; + $maxR.style.zIndex = "4"; + } else { + $minR.style.zIndex = "4"; + $maxR.style.zIndex = "5"; + } + } + + function updatePriceLabel() { + if (!$priceLabel) return; + $priceLabel.textContent = `${formatDollars(selectedMinPrice)} – ${formatDollars(selectedMaxPrice)}`; + } + + function saveFilterPrefs(group, size) { + try { + localStorage.setItem(LS_Q, String($q?.value || "")); + localStorage.setItem(lsMinKey(group, size), String(selectedMinPrice)); + localStorage.setItem(lsMaxKey(group, size), String(selectedMaxPrice)); + } catch {} + } + + function loadFilterPrefs(group, size) { + let q = ""; + let minP = null; + let maxP = null; + + try { + q = String(localStorage.getItem(LS_Q) || ""); + const a = localStorage.getItem(lsMinKey(group, size)); + const b = localStorage.getItem(lsMaxKey(group, size)); + minP = a !== null ? Number(a) : null; + maxP = b !== null ? Number(b) : null; + if (!Number.isFinite(minP)) minP = null; + if (!Number.isFinite(maxP)) maxP = null; + } catch {} + + return { q, minP, maxP }; + } + + async function drawOrUpdateChart({ labels, stores, seriesByStore }) { + const Chart = await ensureChartJs(); + const canvas = document.getElementById("statsChart"); + if (!canvas) return; + + const datasets = stores.map((s) => ({ + label: displayStoreName(s), + data: Array.isArray(seriesByStore[s]) + ? seriesByStore[s] + : labels.map(() => null), + spanGaps: false, + tension: 0.15, + })); + + if (_chart) { + _chart.data.labels = labels; + _chart.data.datasets = datasets; + _chart.update("none"); + return; + } + + const ctx = canvas.getContext("2d"); + _chart = new Chart(ctx, { + type: "line", + data: { labels, datasets }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + interaction: { mode: "nearest", intersect: false }, + plugins: { + legend: { display: true }, + tooltip: { + callbacks: { + label: (ctx2) => { + const v = ctx2.parsed?.y; + if (!Number.isFinite(v)) return `${ctx2.dataset.label}: (no data)`; + return `${ctx2.dataset.label}: ${v.toFixed(2)}%`; + }, + }, + }, + }, + scales: { + x: { + title: { display: true, text: "Date" }, + ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 12 }, + }, + y: { + title: { display: true, text: "Avg % vs per-SKU median" }, + ticks: { callback: (v) => `${Number(v).toFixed(0)}%`, maxTicksLimit: 12 }, + }, + }, + }, + }); + } + + let raw = null; // loaded series (reports cached in-memory) + let applyTimer = null; + + async function rerender(loadOnly = false) { destroyStatsChart(); const group = String($group?.value || "all"); @@ -378,66 +682,188 @@ export async function renderStats($app) { savePrefs(group, size); try { - onStatus("Loading chart…"); - const Chart = await ensureChartJs(); - const canvas = document.getElementById("statsChart"); - if (!canvas) return; + onStatus("Loading…"); + raw = await loadRawSeries({ group, size, onStatus }); - const { labels, stores, seriesByStore, latestSha } = await buildStatsSeries({ - group, - size, - onStatus, + // bounds based on newest day report (last) + const newestReport = raw.reportsByIdx[raw.reportsByIdx.length - 1]; + const b = computePriceBoundsFromReport(newestReport, raw.stores); + + // dynamic floor: if we have a real min, use it (but keep >= 1); else default to 25 + const floor = Number.isFinite(b.min) && b.min > 0 ? Math.max(1, Math.floor(b.min)) : 25; + boundMin = floor; + boundMax = Number.isFinite(b.max) && b.max > boundMin ? Math.ceil(b.max) : Math.max(boundMin, 1000); + + // hydrate UI from prefs (and clamp to bounds) + const saved = loadFilterPrefs(group, size); + if ($q) $q.value = saved.q || ""; + + // if no prices, disable slider + if (!Number.isFinite(b.max)) { + $minR.disabled = true; + $maxR.disabled = true; + $priceWrap.title = "No priced items in this dataset."; + selectedMinPrice = boundMin; + selectedMaxPrice = boundMax; + setSliderFromPrice($minR, boundMin); + setSliderFromPrice($maxR, boundMax); + updatePriceLabel(); + } else { + $minR.disabled = false; + $maxR.disabled = false; + $priceWrap.title = ""; + + const wantMin = saved.minP !== null ? saved.minP : boundMin; + const wantMax = saved.maxP !== null ? saved.maxP : boundMax; + + selectedMinPrice = clampAndRound(wantMin); + selectedMaxPrice = clampAndRound(wantMax); + + if (selectedMinPrice > selectedMaxPrice) selectedMinPrice = selectedMaxPrice; + + setSliderFromPrice($minR, selectedMinPrice); + setSliderFromPrice($maxR, selectedMaxPrice); + updateRangeZ(); + updatePriceLabel(); + } + + if (loadOnly) return; + + // apply filters and draw + const tokens = tokenizeQuery($q?.value || ""); + const series = computeSeriesFromRaw(raw, { + tokens, + minPrice: selectedMinPrice, + maxPrice: selectedMaxPrice, }); - const datasets = stores.map((s) => ({ - label: displayStoreName(s), - data: Array.isArray(seriesByStore[s]) ? seriesByStore[s] : labels.map(() => null), - spanGaps: false, - tension: 0.15, - })); + await drawOrUpdateChart(series); - const ctx = canvas.getContext("2d"); - _chart = new Chart(ctx, { - type: "line", - data: { labels, datasets }, - options: { - responsive: true, - maintainAspectRatio: false, - animation: false, - interaction: { mode: "nearest", intersect: false }, - plugins: { - legend: { display: true }, - tooltip: { - callbacks: { - label: (ctx2) => { - const v = ctx2.parsed?.y; - if (!Number.isFinite(v)) return `${ctx2.dataset.label}: (no data)`; - return `${ctx2.dataset.label}: ${v.toFixed(2)}%`; - }, - }, - }, - }, - scales: { - x: { title: { display: true, text: "Date" }, ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 12 } }, - y: { - title: { display: true, text: "Avg % vs per-SKU median" }, - ticks: { callback: (v) => `${Number(v).toFixed(0)}%`, maxTicksLimit: 12 }, - }, - }, - }, - }); + const rel = relReportPath(group, size); + onStatus( + `Loaded ${series.labels.length} day(s). Filtered SKUs: ${series.newestUsed}/${series.newestTotal}. Source=${esc( + rel + )} @ ${esc(raw.latestSha.slice(0, 7))}` + ); - onStatus(`Loaded ${labels.length} day(s). Source=${esc(relReportPath(group, size))} @ ${esc(latestSha.slice(0, 7))}`); + saveFilterPrefs(group, size); } catch (e) { const msg = esc(e?.message || String(e)); onStatus(`Error: ${msg}`); const card = $app.querySelector(".card"); if (card) card.innerHTML = `
Chart unavailable: ${msg}
`; } - }; + } - $group?.addEventListener("change", () => rerender()); - $size?.addEventListener("change", () => rerender()); + function applyFiltersDebounced(ms) { + if (applyTimer) clearTimeout(applyTimer); + applyTimer = setTimeout(async () => { + if (!raw) return; - await rerender(); + const group = String($group?.value || "all"); + const size = Number($size?.value || 250); + + const tokens = tokenizeQuery($q?.value || ""); + + const series = computeSeriesFromRaw(raw, { + tokens, + minPrice: selectedMinPrice, + maxPrice: selectedMaxPrice, + }); + + await drawOrUpdateChart(series); + + onStatus( + `Loaded ${series.labels.length} day(s). Filtered SKUs: ${series.newestUsed}/${series.newestTotal}. Source=${esc( + relReportPath(group, size) + )} @ ${esc(raw.latestSha.slice(0, 7))}` + ); + + saveFilterPrefs(group, size); + }, ms); + } + + function setSelectedRangeFromSliders(which) { + if ($minR.disabled || $maxR.disabled) return; + + const rawMin = priceFromSlider($minR); + const rawMax = priceFromSlider($maxR); + + let nextMin = clampAndRound(rawMin); + let nextMax = clampAndRound(rawMax); + + // prevent crossing (keep the one being dragged “authoritative”) + if (nextMin > nextMax) { + if (which === "min") nextMax = nextMin; + else nextMin = nextMax; + } + + selectedMinPrice = nextMin; + selectedMaxPrice = nextMax; + + // snap sliders to rounded values for stability + setSliderFromPrice($minR, selectedMinPrice); + setSliderFromPrice($maxR, selectedMaxPrice); + updateRangeZ(); + updatePriceLabel(); + } + + // initial load + await rerender(false); + + // dropdowns: reload raw series + rehydrate filters (clamped) + $group?.addEventListener("change", async () => { + onStatus("Loading…"); + await rerender(false); + }); + $size?.addEventListener("change", async () => { + onStatus("Loading…"); + await rerender(false); + }); + + // search: realtime + let tq = null; + $q?.addEventListener("input", () => { + if (tq) clearTimeout(tq); + tq = setTimeout(() => applyFiltersDebounced(0), 60); + }); + + // sliders: realtime + let tp = null; + $minR?.addEventListener("input", () => { + setSelectedRangeFromSliders("min"); + if (tp) clearTimeout(tp); + tp = setTimeout(() => applyFiltersDebounced(0), 40); + }); + $maxR?.addEventListener("input", () => { + setSelectedRangeFromSliders("max"); + if (tp) clearTimeout(tp); + tp = setTimeout(() => applyFiltersDebounced(0), 40); + }); + + // on change: snap and apply + $minR?.addEventListener("change", () => { + setSelectedRangeFromSliders("min"); + applyFiltersDebounced(0); + }); + $maxR?.addEventListener("change", () => { + setSelectedRangeFromSliders("max"); + applyFiltersDebounced(0); + }); + + // clear: reset query + full range + $clear?.addEventListener("click", () => { + if ($q) $q.value = ""; + + selectedMinPrice = boundMin; + selectedMaxPrice = boundMax; + + setSliderFromPrice($minR, selectedMinPrice); + setSliderFromPrice($maxR, selectedMaxPrice); + updateRangeZ(); + updatePriceLabel(); + + applyFiltersDebounced(0); + $q?.focus(); + }); } diff --git a/viz/style.css b/viz/style.css index dabc971..f9d178e 100644 --- a/viz/style.css +++ b/viz/style.css @@ -487,3 +487,27 @@ a.skuLink:hover { text-decoration: underline; cursor: pointer; } background: rgba(200, 120, 20, 0.12); border-color: rgba(200, 120, 20, 0.28); } + +/* --- Stats page: dual range slider --- */ +.rangeDual { + position: relative; + flex: 1 1 auto; + width: 100%; + height: 18px; +} + +.rangeDual input[type="range"] { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 18px; + margin: 0; + background: transparent; + accent-color: #9aa3b2; + pointer-events: none; /* allow both sliders to be interactable via thumbs */ +} + +/* thumbs must still receive pointer events */ +.rangeDual input[type="range"]::-webkit-slider-thumb { pointer-events: all; } +.rangeDual input[type="range"]::-moz-range-thumb { pointer-events: all; }