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
13e691f0d0
commit
23dbd34006
1 changed files with 92 additions and 108 deletions
|
|
@ -11,12 +11,40 @@ let CHART = null;
|
||||||
|
|
||||||
/* ---------------- Static marker lines ---------------- */
|
/* ---------------- Static marker lines ---------------- */
|
||||||
|
|
||||||
const BC_STORE_KEYS = new Set(["vessel", "tudor", "bcl", "strath", "gull", "vintagespirits", "legacy"]);
|
// --- Province store matching (robust to labels like "Vessel Liquor", "BCL", etc.) ---
|
||||||
|
const BC_STORE_NAMES = new Set([
|
||||||
|
"bcl",
|
||||||
|
"tudorhouse",
|
||||||
|
"vesselliquor",
|
||||||
|
"strathliquor",
|
||||||
|
"gullliquor",
|
||||||
|
"vintagespirits",
|
||||||
|
"legacyliquor",
|
||||||
|
]);
|
||||||
|
|
||||||
function normStoreLabel(s) {
|
function normStoreLabel(s) {
|
||||||
return String(s || "")
|
return String(s || "")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]+/g, "");
|
.replace(/&/g, "and")
|
||||||
|
.replace(/[^a-z0-9]+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBcStoreLabel(label) {
|
||||||
|
const n = normStoreLabel(label);
|
||||||
|
if (BC_STORE_NAMES.has(n)) return true;
|
||||||
|
|
||||||
|
// extra fuzzy contains for safety
|
||||||
|
if (n.includes("vessel")) return true;
|
||||||
|
if (n.includes("tudor")) return true;
|
||||||
|
if (n === "bcl") return true;
|
||||||
|
if (n.includes("strath")) return true;
|
||||||
|
if (n.includes("gull")) return true;
|
||||||
|
if (n.includes("vintagespirits")) return true;
|
||||||
|
if (n.includes("legacy")) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function meanFinite(arr) {
|
function meanFinite(arr) {
|
||||||
|
|
@ -79,27 +107,22 @@ const StaticMarkerLinesPlugin = {
|
||||||
const { ctx } = chart;
|
const { ctx } = chart;
|
||||||
const { left, right, top, bottom } = area;
|
const { left, right, top, bottom } = area;
|
||||||
|
|
||||||
const lineWidth = Number.isFinite(opts?.lineWidth) ? opts.lineWidth : 2;
|
const lineWidth = Number.isFinite(opts?.lineWidth) ? opts.lineWidth : 1.25; // thinner
|
||||||
const baseAlpha = Number.isFinite(opts?.alpha) ? opts.alpha : 0.22; // faint
|
const baseAlpha = Number.isFinite(opts?.alpha) ? opts.alpha : 0.38; // brighter than before
|
||||||
const labelAlpha = Number.isFinite(opts?.labelAlpha) ? opts.labelAlpha : 0.75;
|
const strokeStyle = String(opts?.color || "#7f8da3"); // light grey-blue
|
||||||
const strokeStyle = String(opts?.color || "#9aa4b2"); // light grey-blue
|
|
||||||
|
// "marker on Y axis" text
|
||||||
const font =
|
const font =
|
||||||
opts?.font ||
|
opts?.font ||
|
||||||
"12px system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif";
|
"600 11px system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif";
|
||||||
|
const labelColor = String(opts?.labelColor || "#556274");
|
||||||
// Put labels "on the Y axis" (left side of plot) with a small background pill
|
const axisInset = Number.isFinite(opts?.axisInset) ? opts.axisInset : 2;
|
||||||
const labelPadX = 6;
|
|
||||||
const labelPadY = 3;
|
|
||||||
const labelInset = 4; // from chartArea.left
|
|
||||||
const labelBgAlpha = Number.isFinite(opts?.labelBgAlpha) ? opts.labelBgAlpha : 0.9;
|
|
||||||
const labelBg = String(opts?.labelBg || "rgba(255,255,255,0.92)");
|
|
||||||
const labelBorderAlpha = Number.isFinite(opts?.labelBorderAlpha) ? opts.labelBorderAlpha : 0.25;
|
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.setLineDash([]); // SOLID
|
ctx.setLineDash([]); // SOLID
|
||||||
ctx.lineWidth = lineWidth;
|
ctx.lineWidth = lineWidth;
|
||||||
ctx.font = font;
|
ctx.font = font;
|
||||||
ctx.textBaseline = "middle";
|
ctx.fillStyle = labelColor;
|
||||||
|
|
||||||
for (const m of markers) {
|
for (const m of markers) {
|
||||||
const yVal = Number(m?.y);
|
const yVal = Number(m?.y);
|
||||||
|
|
@ -116,67 +139,27 @@ const StaticMarkerLinesPlugin = {
|
||||||
ctx.lineTo(right, py);
|
ctx.lineTo(right, py);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// label at Y axis (left edge)
|
// tiny tick mark on the y-axis edge
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(left, py);
|
||||||
|
ctx.lineTo(left + 6, py);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// label just to the left of the plot area, aligned like an axis marker
|
||||||
const text = String(m?.text || "");
|
const text = String(m?.text || "");
|
||||||
if (text) {
|
if (text) {
|
||||||
const label = `${text}`;
|
ctx.globalAlpha = 0.95;
|
||||||
const value = `$${yVal.toFixed(2)}`;
|
ctx.fillStyle = String(m?.labelColor || labelColor);
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
// two-line label: name + value
|
ctx.textAlign = "right";
|
||||||
const line1 = label;
|
ctx.fillText(text, left - axisInset, py);
|
||||||
const line2 = value;
|
|
||||||
|
|
||||||
const w1 = ctx.measureText(line1).width;
|
|
||||||
const w2 = ctx.measureText(line2).width;
|
|
||||||
const w = Math.max(w1, w2);
|
|
||||||
|
|
||||||
const boxW = w + labelPadX * 2;
|
|
||||||
const boxH = 24; // fits 2 lines at 12px-ish
|
|
||||||
const boxX = left + labelInset;
|
|
||||||
const boxY = py - boxH / 2;
|
|
||||||
|
|
||||||
// keep box within chartArea
|
|
||||||
const clampedY = Math.min(Math.max(boxY, top), bottom - boxH);
|
|
||||||
|
|
||||||
// background pill
|
|
||||||
ctx.save();
|
|
||||||
ctx.globalAlpha = labelBgAlpha;
|
|
||||||
ctx.fillStyle = labelBg;
|
|
||||||
roundRect(ctx, boxX, clampedY, boxW, boxH, 6);
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
// subtle border
|
|
||||||
ctx.globalAlpha = labelBorderAlpha;
|
|
||||||
ctx.strokeStyle = String(m?.color || strokeStyle);
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// text
|
|
||||||
ctx.globalAlpha = Number.isFinite(m?.labelAlpha) ? m.labelAlpha : labelAlpha;
|
|
||||||
ctx.fillStyle = String(m?.labelColor || "#2b2f36");
|
|
||||||
ctx.textBaseline = "alphabetic";
|
|
||||||
ctx.fillText(line1, boxX + labelPadX, clampedY + 11);
|
|
||||||
ctx.fillText(line2, boxX + labelPadX, clampedY + 22);
|
|
||||||
ctx.restore();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
function roundRect(ctx2, x, y, w, h, r) {
|
|
||||||
const rr = Math.min(r, w / 2, h / 2);
|
|
||||||
ctx2.beginPath();
|
|
||||||
ctx2.moveTo(x + rr, y);
|
|
||||||
ctx2.arcTo(x + w, y, x + w, y + h, rr);
|
|
||||||
ctx2.arcTo(x + w, y + h, x, y + h, rr);
|
|
||||||
ctx2.arcTo(x, y + h, x, y, rr);
|
|
||||||
ctx2.arcTo(x, y, x + w, y, rr);
|
|
||||||
ctx2.closePath();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export function destroyChart() {
|
export function destroyChart() {
|
||||||
if (CHART) {
|
if (CHART) {
|
||||||
CHART.destroy();
|
CHART.destroy();
|
||||||
|
|
@ -899,20 +882,22 @@ export async function renderItem($app, skuInput) {
|
||||||
.map((s) => ({ label: s.label, mean: meanFinite(s.values) }))
|
.map((s) => ({ label: s.label, mean: meanFinite(s.values) }))
|
||||||
.filter((x) => Number.isFinite(x.mean));
|
.filter((x) => Number.isFinite(x.mean));
|
||||||
|
|
||||||
const bcMeans = storeMeans.filter((x) => BC_STORE_KEYS.has(normStoreLabel(x.label)));
|
const bcMeans = storeMeans.filter((x) => isBcStoreLabel(x.label));
|
||||||
const abMeans = storeMeans.filter((x) => !BC_STORE_KEYS.has(normStoreLabel(x.label)));
|
const abMeans = storeMeans.filter((x) => !isBcStoreLabel(x.label));
|
||||||
|
|
||||||
const markers = [];
|
const markers = [];
|
||||||
|
|
||||||
if (bcMeans.length >= 3) {
|
if (bcMeans.length >= 3) {
|
||||||
const y = medianFinite(bcMeans.map((x) => x.mean));
|
const y = medianFinite(bcMeans.map((x) => x.mean));
|
||||||
if (Number.isFinite(y)) markers.push({ y, text: "BC median" });
|
if (Number.isFinite(y)) markers.push({ y: Math.round(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)
|
if (abMeans.length >= 3) {
|
||||||
|
const y = medianFinite(abMeans.map((x) => x.mean));
|
||||||
|
if (Number.isFinite(y)) markers.push({ y: Math.round(y), text: "AB Median" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target price: pick 3 lowest per-store mins (distinct stores), then average (>=3 stores)
|
||||||
const storeMins = seriesSorted
|
const storeMins = seriesSorted
|
||||||
.map((s) => ({ label: s.label, min: minFinite(s.values) }))
|
.map((s) => ({ label: s.label, min: minFinite(s.values) }))
|
||||||
.filter((x) => Number.isFinite(x.min))
|
.filter((x) => Number.isFinite(x.min))
|
||||||
|
|
@ -920,50 +905,50 @@ export async function renderItem($app, skuInput) {
|
||||||
|
|
||||||
if (storeMins.length >= 3) {
|
if (storeMins.length >= 3) {
|
||||||
const t = (storeMins[0].min + storeMins[1].min + storeMins[2].min) / 3;
|
const t = (storeMins[0].min + storeMins[1].min + storeMins[2].min) / 3;
|
||||||
if (Number.isFinite(t)) markers.push({ y: t, text: "Target" });
|
if (Number.isFinite(t)) markers.push({ y: Math.round(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 },
|
||||||
// keep instance plugin for v3+, and also safe for many v2 builds
|
|
||||||
plugins: [StaticMarkerLinesPlugin],
|
plugins: [StaticMarkerLinesPlugin],
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
interaction: { mode: "nearest", intersect: false },
|
interaction: { mode: "nearest", intersect: false },
|
||||||
|
|
||||||
// v2 fallback (plugin reads this)
|
// v2 fallback (plugin reads this)
|
||||||
staticMarkerLines: {
|
staticMarkerLines: {
|
||||||
markers,
|
markers,
|
||||||
color: "#9aa4b2",
|
color: "#7f8da3",
|
||||||
alpha: 0.20,
|
alpha: 0.38,
|
||||||
lineWidth: 2,
|
lineWidth: 1.25,
|
||||||
labelAlpha: 0.85,
|
labelColor: "#556274",
|
||||||
labelBg: "rgba(255,255,255,0.92)",
|
axisInset: 2,
|
||||||
labelBgAlpha: 0.95,
|
},
|
||||||
labelBorderAlpha: 0.22,
|
|
||||||
},
|
|
||||||
|
|
||||||
plugins: {
|
|
||||||
// v3+ (plugin reads this too)
|
|
||||||
staticMarkerLines: {
|
|
||||||
markers,
|
|
||||||
color: "#9aa4b2",
|
|
||||||
alpha: 0.20,
|
|
||||||
lineWidth: 2,
|
|
||||||
labelAlpha: 0.85,
|
|
||||||
labelBg: "rgba(255,255,255,0.92)",
|
|
||||||
labelBgAlpha: 0.95,
|
|
||||||
labelBorderAlpha: 0.22,
|
|
||||||
},
|
|
||||||
|
|
||||||
legend: { display: true },
|
|
||||||
tooltip: { /* unchanged */ },
|
|
||||||
},
|
|
||||||
|
|
||||||
|
plugins: {
|
||||||
|
// v3+ (plugin reads this too)
|
||||||
|
staticMarkerLines: {
|
||||||
|
markers,
|
||||||
|
color: "#7f8da3",
|
||||||
|
alpha: 0.38,
|
||||||
|
lineWidth: 1.25,
|
||||||
|
labelColor: "#556274",
|
||||||
|
axisInset: 2,
|
||||||
|
},
|
||||||
|
legend: { display: true },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => {
|
||||||
|
const v = ctx.parsed?.y;
|
||||||
|
if (!Number.isFinite(v)) return `${ctx.dataset.label}: (no data)`;
|
||||||
|
return `${ctx.dataset.label}: $${v.toFixed(2)}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: { ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 12 }, grid: { display: false } },
|
x: { ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 12 }, grid: { display: false } },
|
||||||
y: {
|
y: {
|
||||||
|
|
@ -977,7 +962,6 @@ plugins: {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const yScale = CHART.scales?.y;
|
const yScale = CHART.scales?.y;
|
||||||
const tickCount = yScale?.ticks?.length || 0;
|
const tickCount = yScale?.ticks?.length || 0;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue