diff --git a/viz/app/item_page.js b/viz/app/item_page.js index 3ea09cf..cf1a209 100644 --- a/viz/app/item_page.js +++ b/viz/app/item_page.js @@ -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;