mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-03-25 09:25:51 +00:00
UX Improvements
This commit is contained in:
parent
01624daf7f
commit
57ea7f9002
1 changed files with 148 additions and 10 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue