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 ---------------- */
|
||||
|
||||
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) {
|
||||
return String(s || "")
|
||||
.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) {
|
||||
|
|
@ -79,27 +107,22 @@ const StaticMarkerLinesPlugin = {
|
|||
const { ctx } = chart;
|
||||
const { left, right, top, bottom } = area;
|
||||
|
||||
const lineWidth = Number.isFinite(opts?.lineWidth) ? opts.lineWidth : 2;
|
||||
const baseAlpha = Number.isFinite(opts?.alpha) ? opts.alpha : 0.22; // faint
|
||||
const labelAlpha = Number.isFinite(opts?.labelAlpha) ? opts.labelAlpha : 0.75;
|
||||
const strokeStyle = String(opts?.color || "#9aa4b2"); // light grey-blue
|
||||
const lineWidth = Number.isFinite(opts?.lineWidth) ? opts.lineWidth : 1.25; // thinner
|
||||
const baseAlpha = Number.isFinite(opts?.alpha) ? opts.alpha : 0.38; // brighter than before
|
||||
const strokeStyle = String(opts?.color || "#7f8da3"); // light grey-blue
|
||||
|
||||
// "marker on Y axis" text
|
||||
const font =
|
||||
opts?.font ||
|
||||
"12px system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif";
|
||||
|
||||
// Put labels "on the Y axis" (left side of plot) with a small background pill
|
||||
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;
|
||||
"600 11px system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif";
|
||||
const labelColor = String(opts?.labelColor || "#556274");
|
||||
const axisInset = Number.isFinite(opts?.axisInset) ? opts.axisInset : 2;
|
||||
|
||||
ctx.save();
|
||||
ctx.setLineDash([]); // SOLID
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.font = font;
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = labelColor;
|
||||
|
||||
for (const m of markers) {
|
||||
const yVal = Number(m?.y);
|
||||
|
|
@ -116,67 +139,27 @@ const StaticMarkerLinesPlugin = {
|
|||
ctx.lineTo(right, py);
|
||||
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 || "");
|
||||
if (text) {
|
||||
const label = `${text}`;
|
||||
const value = `$${yVal.toFixed(2)}`;
|
||||
|
||||
// two-line label: name + value
|
||||
const line1 = label;
|
||||
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.globalAlpha = 0.95;
|
||||
ctx.fillStyle = String(m?.labelColor || labelColor);
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText(text, left - axisInset, py);
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
if (CHART) {
|
||||
CHART.destroy();
|
||||
|
|
@ -899,20 +882,22 @@ export async function renderItem($app, skuInput) {
|
|||
.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 bcMeans = storeMeans.filter((x) => isBcStoreLabel(x.label));
|
||||
const abMeans = storeMeans.filter((x) => !isBcStoreLabel(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" });
|
||||
if (Number.isFinite(y)) markers.push({ y: Math.round(y), text: "BC 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
|
||||
.map((s) => ({ label: s.label, min: minFinite(s.values) }))
|
||||
.filter((x) => Number.isFinite(x.min))
|
||||
|
|
@ -920,50 +905,50 @@ export async function renderItem($app, skuInput) {
|
|||
|
||||
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" });
|
||||
if (Number.isFinite(t)) markers.push({ y: Math.round(t), text: "Target" });
|
||||
}
|
||||
|
||||
const ctx = $canvas.getContext("2d");
|
||||
|
||||
CHART = new Chart(ctx, {
|
||||
type: "line",
|
||||
data: { labels, datasets },
|
||||
// keep instance plugin for v3+, and also safe for many v2 builds
|
||||
plugins: [StaticMarkerLinesPlugin],
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: "nearest", intersect: false },
|
||||
|
||||
|
||||
// v2 fallback (plugin reads this)
|
||||
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,
|
||||
},
|
||||
|
||||
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 */ },
|
||||
},
|
||||
staticMarkerLines: {
|
||||
markers,
|
||||
color: "#7f8da3",
|
||||
alpha: 0.38,
|
||||
lineWidth: 1.25,
|
||||
labelColor: "#556274",
|
||||
axisInset: 2,
|
||||
},
|
||||
|
||||
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: {
|
||||
x: { ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 12 }, grid: { display: false } },
|
||||
y: {
|
||||
|
|
@ -977,7 +962,6 @@ plugins: {
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const yScale = CHART.scales?.y;
|
||||
const tickCount = yScale?.ticks?.length || 0;
|
||||
|
|
|
|||
Loading…
Reference in a new issue