From 41b86bcc80515725c70bfd5372f1a579b3a99dbd Mon Sep 17 00:00:00 2001 From: Inferencebot Date: Wed, 28 Oct 2026 04:08:18 +0000 Subject: [PATCH] panel: integer y-axis for people; round money and request ticks Nathan: the members axis should be whole numbers, since it counts people. It was drawing fractional ticks (19.17 people) because the 1.08 headroom factor fed straight into the label math. Ticks now come from a step, with the axis top set to step x 4, so labels land on round numbers: - counts (members, requests): whole numbers, rounded up to a half power of ten (requests read 3,000, not 2,888). Never below 1, so a near-empty series still gets an integer axis rather than fractions. - money: the 1/2/5 x 10^k rule, so ticks read $10.00 rather than $4.92. Rendered now: members 0 | 20 | 40 | 60 | 80; requests 0 | 3,000 | 6,000 | 9,000 | 12,000; spend $0.00 | $10.00 | $20.00 | $30.00 | $40.00. The node harness gained assertions for this, checked geometrically (tick values ascend while their screen positions descend, the zero tick sits lowest, ticks are evenly spaced) rather than assuming a text order, so a fractional people-axis or a misplaced label fails before a deploy. --- app/main.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/app/main.py b/app/main.py index a41397d..bea3866 100644 --- a/app/main.py +++ b/app/main.py @@ -521,6 +521,23 @@ function trendVal(row) { return row.requests; } +// Y-axis step. Counts are people and things, so their ticks must be whole +// numbers (a 1.08 headroom factor alone yields labels like 19.17 people). +// Money gets the classic 1/2/5 x 10^k rounding so ticks read $5.00, not $4.92. +function niceStep(raw) { + if (trendMetric === "spend") { + const mag = Math.pow(10, Math.floor(Math.log10(Math.max(raw, 1e-9)))); + const norm = raw / mag; + const nice = norm <= 1 ? 1 : (norm <= 2 ? 2 : (norm <= 5 ? 5 : 10)); + return nice * mag; + } + // Counts: whole numbers, rounded up to a half power of ten so ticks read + // 3,000 rather than 2,888. Never below 1, so a near-empty series still gets + // an integer axis instead of fractions. + const p = Math.pow(10, Math.floor(Math.log10(Math.max(raw, 1)))); + return Math.max(1, Math.ceil(raw / (p / 2)) * (p / 2)); +} + function trendLabel(v) { if (v === null || v === undefined) return "not measured"; if (trendMetric === "spend") return fmt(v); @@ -544,7 +561,10 @@ function renderTrends() { } const gaps = vals.length - have.length; const maxV = Math.max.apply(null, have); - const top = maxV <= 0 ? 1 : maxV * 1.08; // headroom so the peak clears the frame + // Axis top = one round step times four ticks: labels land on round numbers + // and the peak still clears the frame. + const step = niceStep((maxV <= 0 ? 1 : maxV * 1.08) / 4); + const top = step * 4; const W = 1000, H = 250, padL = 62, padR = 14, padT = 14, padB = 30; const plotW = W - padL - padR, plotH = H - padT - padB; @@ -557,7 +577,7 @@ function renderTrends() { parts.push(''); for (let g = 0; g <= 4; g++) { const yy = baseY - (plotH * g) / 4; - const lab = trendLabel((top * g) / 4); + const lab = trendLabel(step * g); parts.push(''); parts.push('' + lab + ''); }