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;
|
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() {
|
export function destroyChart() {
|
||||||
if (CHART) {
|
if (CHART) {
|
||||||
CHART.destroy();
|
CHART.destroy();
|
||||||
|
|
@ -697,7 +798,8 @@ export async function renderItem($app, skuInput) {
|
||||||
return { s, v: Number.isFinite(lastVal) ? lastVal : null };
|
return { s, v: Number.isFinite(lastVal) ? lastVal : null };
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.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 && bv === null) return a.s.label.localeCompare(b.s.label);
|
||||||
if (av === null) return 1;
|
if (av === null) return 1;
|
||||||
if (bv === 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");
|
const ctx = $canvas.getContext("2d");
|
||||||
CHART = new Chart(ctx, {
|
CHART = new Chart(ctx, {
|
||||||
type: "line",
|
type: "line",
|
||||||
data: { labels, datasets },
|
data: { labels, datasets },
|
||||||
|
plugins: [StaticMarkerLinesPlugin],
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
interaction: { mode: "nearest", intersect: false },
|
interaction: { mode: "nearest", intersect: false },
|
||||||
plugins: {
|
plugins: {
|
||||||
|
staticMarkerLines: {
|
||||||
|
markers,
|
||||||
|
dash: [6, 6],
|
||||||
|
alpha: 0.28, // faint lines
|
||||||
|
labelAlpha: 0.55,
|
||||||
|
},
|
||||||
legend: { display: true },
|
legend: { display: true },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue