UX Improvements

This commit is contained in:
Brennan Wilkes (Text Groove) 2026-02-09 22:13:16 -08:00
parent 13e691f0d0
commit 23dbd34006

View file

@ -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;