UX Improvements

This commit is contained in:
Brennan Wilkes (Text Groove) 2026-02-09 21:54:26 -08:00
parent 01624daf7f
commit 57ea7f9002

View file

@ -9,6 +9,107 @@ import { buildStoreColorMap, storeColor, datasetStrokeWidth, lighten } from "./s
let CHART = null;
/* ---------------- Static marker lines ---------------- */
const BC_STORE_KEYS = new Set(["vessel", "tudor", "bcl", "strath", "gull", "vintagespirits", "legacy"]);
function normStoreLabel(s) {
return String(s || "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "");
}
function meanFinite(arr) {
if (!Array.isArray(arr)) return null;
let sum = 0,
n = 0;
for (const v of arr) {
if (Number.isFinite(v)) {
sum += v;
n++;
}
}
return n ? sum / n : null;
}
function minFinite(arr) {
if (!Array.isArray(arr)) return null;
let m = null;
for (const v of arr) {
if (Number.isFinite(v)) m = m === null ? v : Math.min(m, v);
}
return m;
}
function medianFinite(nums) {
const a = (Array.isArray(nums) ? nums : [])
.filter((v) => Number.isFinite(v))
.slice()
.sort((x, y) => x - y);
const n = a.length;
if (!n) return null;
const mid = Math.floor(n / 2);
return n % 2 ? a[mid] : (a[mid - 1] + a[mid]) / 2;
}
const StaticMarkerLinesPlugin = {
id: "staticMarkerLines",
afterDatasetsDraw(chart, _args, opts) {
const markers = Array.isArray(opts?.markers) ? opts.markers : [];
if (!markers.length) return;
const y = chart?.scales?.y;
const area = chart?.chartArea;
if (!y || !area) return;
const { ctx } = chart;
const { left, right, top, bottom } = area;
ctx.save();
ctx.lineWidth = Number.isFinite(opts?.lineWidth) ? opts.lineWidth : 1;
ctx.setLineDash(Array.isArray(opts?.dash) ? opts.dash : [6, 6]);
ctx.font =
opts?.font ||
"12px system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif";
ctx.textBaseline = "bottom";
for (const m of markers) {
const yVal = Number(m?.y);
if (!Number.isFinite(yVal)) continue;
const py = y.getPixelForValue(yVal);
if (!Number.isFinite(py) || py < top || py > bottom) continue;
ctx.globalAlpha = Number.isFinite(m?.alpha)
? m.alpha
: Number.isFinite(opts?.alpha)
? opts.alpha
: 0.35;
ctx.strokeStyle = String(m?.color || opts?.color || "rgba(0,0,0,0.9)");
ctx.beginPath();
ctx.moveTo(left, py);
ctx.lineTo(right, py);
ctx.stroke();
const text = String(m?.text || "");
if (text) {
const label = `${text} $${yVal.toFixed(2)}`;
ctx.globalAlpha = Number.isFinite(m?.labelAlpha)
? m.labelAlpha
: Number.isFinite(opts?.labelAlpha)
? opts.labelAlpha
: 0.55;
ctx.fillStyle = String(m?.labelColor || opts?.labelColor || "rgba(0,0,0,0.9)");
const w = ctx.measureText(label).width;
ctx.fillText(label, Math.max(left + 4, right - 4 - w), py - 3);
}
}
ctx.restore();
},
};
export function destroyChart() {
if (CHART) {
CHART.destroy();
@ -697,7 +798,8 @@ export async function renderItem($app, skuInput) {
return { s, v: Number.isFinite(lastVal) ? lastVal : null };
})
.sort((a, b) => {
const av = a.v, bv = b.v;
const av = a.v,
bv = b.v;
if (av === null && bv === null) return a.s.label.localeCompare(b.s.label);
if (av === null) return 1;
if (bv === null) return -1;
@ -724,16 +826,52 @@ export async function renderItem($app, skuInput) {
};
});
// --- Compute marker values ---
// Province medians: per-store mean over time, then median across stores (>=3 stores)
const storeMeans = seriesSorted
.map((s) => ({ label: s.label, mean: meanFinite(s.values) }))
.filter((x) => Number.isFinite(x.mean));
const bcMeans = storeMeans.filter((x) => BC_STORE_KEYS.has(normStoreLabel(x.label)));
const abMeans = storeMeans.filter((x) => !BC_STORE_KEYS.has(normStoreLabel(x.label)));
const markers = [];
if (bcMeans.length >= 3) {
const y = medianFinite(bcMeans.map((x) => x.mean));
if (Number.isFinite(y)) markers.push({ y, text: "BC median" });
}
if (abMeans.length >= 3) {
const y = medianFinite(abMeans.map((x) => x.mean));
if (Number.isFinite(y)) markers.push({ y, text: "AB median" });
}
// Target price: pick 3 lowest per-store mins (distinct stores by construction), then average (>=3 stores)
const storeMins = seriesSorted
.map((s) => ({ label: s.label, min: minFinite(s.values) }))
.filter((x) => Number.isFinite(x.min))
.sort((a, b) => a.min - b.min);
if (storeMins.length >= 3) {
const t = (storeMins[0].min + storeMins[1].min + storeMins[2].min) / 3;
if (Number.isFinite(t)) markers.push({ y: t, text: "Target" });
}
const ctx = $canvas.getContext("2d");
CHART = new Chart(ctx, {
type: "line",
data: { labels, datasets },
plugins: [StaticMarkerLinesPlugin],
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "nearest", intersect: false },
plugins: {
staticMarkerLines: {
markers,
dash: [6, 6],
alpha: 0.28, // faint lines
labelAlpha: 0.55,
},
legend: { display: true },
tooltip: {
callbacks: {