Render every percentile through one rule (#186)
The homepage strip floored percentiles into 1-99 while the Day page rendered
"100th pct" for the same reading. Rather than teach the Day page a second copy
of the rule, put it in grading.pct_ordinal() and have every surface defer to it:
the Day page, the calendar tooltip, the chart labels, the city pages and the
strip.
Two bugs fell out of unifying them:
- The city pages rendered `{{ c.percentile }}th pct` against a float, so all
~1000 of them showed "97.1th pct", "66.4th pct", "16.5th pct" — and would say
"1th"/"2th"/"3th" for the low tail. Live in prod.
- Python's round() is half-to-even and JavaScript's Math.round is half-up, so
16.5 gave "16th" server-side and "17th" client-side. The Python helper uses
floor(x + 0.5) to match the frontend exactly; a cross-language check over every
.5 boundary now agrees on all of them.
The frontend mirrors the rule as pctOrd() in shared.js, replacing the bare ord()
at all five percentile call sites (day.js, calendar.js, chart.js x2, app.js).
ord() stays as the general ordinal helper it always was.
This commit is contained in:
parent
82c776f5ba
commit
086a301387
5 changed files with 22 additions and 9 deletions
|
|
@ -7,7 +7,7 @@ import { initFindButton, setFindLabel } from "./mappicker.js";
|
|||
import { W, PW, H, PL, PR, plotTop, plotBot, xLabY, setChartWidth, chartPalette,
|
||||
tempChart, precipChart, dryChart, attachChartHover } from "./chart.js";
|
||||
import { track } from "./digest.js";
|
||||
import { TIER_COLORS, SCALE_TEMP, drynessColor, ord, esc, todayISO, placeLabel,
|
||||
import { TIER_COLORS, SCALE_TEMP, drynessColor, pctOrd, esc, todayISO, placeLabel,
|
||||
fmtPrecip, fmtWind, fmtHumid } from "./shared.js";
|
||||
|
||||
let selected = null; // {lat, lon}
|
||||
|
|
@ -124,7 +124,7 @@ function updateHero(data) {
|
|||
document.getElementById("hero-where").textContent = `Today in ${placeLabel(data)}`;
|
||||
document.getElementById("hero-band").textContent = g.grade;
|
||||
document.getElementById("hero-detail").textContent =
|
||||
`${targetDay.tmax === g ? "High temp" : "Low temp"} in the ${ord(Math.round(g.percentile))} percentile`;
|
||||
`${targetDay.tmax === g ? "High temp" : "Low temp"} in the ${pctOrd(g.percentile)} percentile`;
|
||||
// Band colour rides the same token set as everything else; the band word is
|
||||
// always present too, so colour is never the only signal.
|
||||
card.style.setProperty("--cat", TIER_COLORS[g.class] || "");
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { fmtTemp, onUnitChange } from "./units.js";
|
|||
import "./account.js"; // header sign-in entry + notification bell
|
||||
import { TTL, chunkedFetch, prefetchViews } from "./cache.js";
|
||||
import { initFindButton, setFindLabel } from "./mappicker.js";
|
||||
import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, ord,
|
||||
import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, pctOrd,
|
||||
fmtPrecip, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd,
|
||||
CHUNK_MONTHS, buildChunks, clickOpensPicker, metricBuckets, distStrip,
|
||||
seasonFilterDropdown, seasonSummaryText, syncSeasonChecks,
|
||||
|
|
@ -620,7 +620,7 @@ function attachHover(byDate) {
|
|||
const line = (key, name, g, fmt, color) => {
|
||||
const rc = key === activeKey ? " tt-r-active" : "";
|
||||
if (!g) return `<span class="tt-name${rc}">${name}</span><span class="tt-val${rc}">—</span><span class="tt-cat${rc}"></span>`;
|
||||
const pct = g.pct == null ? "" : `<span class="tt-pct"> · ${ord(g.pct)} pct</span>`;
|
||||
const pct = g.pct == null ? "" : `<span class="tt-pct"> · ${pctOrd(g.pct)} pct</span>`;
|
||||
const catCls = key === activeKey ? "tt-cat tt-cat-active" : "tt-cat";
|
||||
const style = color ? ` style="--cat:${color}"` : "";
|
||||
return `<span class="tt-name${rc}">${name}</span><span class="tt-val${rc}">${fmt(g.v)}${pct}</span><span class="${catCls}${rc}"${style}>${g.g}</span>`;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// palette, and the pointer-driven hover. Extracted from app.js so any page can
|
||||
// plot a metric series; app.js keeps only its page orchestration (buildChart).
|
||||
import { fmtTemp, toUnit } from "./units.js";
|
||||
import { TIER_COLORS, drynessColor, ord } from "./shared.js";
|
||||
import { TIER_COLORS, drynessColor, pctOrd } from "./shared.js";
|
||||
|
||||
// #rrggbb -> rgba() with alpha, so band tints derive from the same site colors.
|
||||
const hexA = (hex, a) => {
|
||||
|
|
@ -165,7 +165,7 @@ function lineChart(cfg) {
|
|||
const vy = (y - plotTop < 26 ? y + 15 : y - 15).toFixed(1); // flip below near the top
|
||||
const pv = getPct ? getPct(d) : null;
|
||||
const pct = pv == null ? "" :
|
||||
`<tspan x="${x}" dy="9" font-size="7.5" font-weight="600" fill="${C.muted}">${ord(pv)}</tspan>`;
|
||||
`<tspan x="${x}" dy="9" font-size="7.5" font-weight="600" fill="${C.muted}">${pctOrd(pv)}</tspan>`;
|
||||
return `<text x="${x}" y="${vy}" text-anchor="middle" font-size="8.5" font-weight="700" fill="${C.ink}" stroke="${C.surface}" stroke-width="2.4" paint-order="stroke" stroke-linejoin="round">${fmtLabel(v)}${pct}</text>`;
|
||||
}).join("");
|
||||
|
||||
|
|
@ -247,7 +247,7 @@ export function attachChartHover(wrap, days) {
|
|||
const C = chartPalette();
|
||||
const pctLine = (label, g, unit, color) => {
|
||||
if (!g) return `<div><b style="color:${color}">${label}</b> —</div>`;
|
||||
const pct = g.percentile == null ? "" : ` · ${ord(g.percentile)} pct`;
|
||||
const pct = g.percentile == null ? "" : ` · ${pctOrd(g.percentile)} pct`;
|
||||
const gc = TIER_COLORS[g.class] || "";
|
||||
// "°" marks a temperature line — show it in the active unit; others are as-is.
|
||||
const val = unit === "°" ? Math.round(toUnit(g.value)) : g.value;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { fmtTemp, onUnitChange } from "./units.js";
|
|||
import "./account.js"; // header sign-in entry + notification bell
|
||||
import { getJSON, TTL, prefetchViews } from "./cache.js";
|
||||
import { initFindButton, setFindLabel } from "./mappicker.js";
|
||||
import { TIER_COLORS, PRECIP_COLORS, DRY_COLOR, ord, fmtPrecip, fmtWind, fmtHumid,
|
||||
import { TIER_COLORS, PRECIP_COLORS, DRY_COLOR, pctOrd, fmtPrecip, fmtWind, fmtHumid,
|
||||
todayISO, weatherType, placeLabel } from "./shared.js";
|
||||
|
||||
// Color a tier the same way the calendar cell would be colored for it.
|
||||
|
|
@ -158,7 +158,7 @@ function ladderCard(title, m, fmt, kind) {
|
|||
|
||||
let summary;
|
||||
if (obs) {
|
||||
const pct = obs.percentile == null ? "" : ` · ${ord(obs.percentile)} pct`;
|
||||
const pct = obs.percentile == null ? "" : ` · ${pctOrd(obs.percentile)} pct`;
|
||||
summary = `<span class="day-obs" style="--cat:${tierColor(obs.class)}">${fmt(obs.value)}</span>
|
||||
<span class="day-obs-meta">${obs.grade}${pct}</span>`;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -180,6 +180,19 @@ export const ord = (n) => {
|
|||
const r = Math.round(n), s = ["th", "st", "nd", "rd"], v = r % 100;
|
||||
return r + (s[(v - 20) % 10] || s[v] || s[0]);
|
||||
};
|
||||
|
||||
// A percentile as a display ordinal. Mirrors grading.pct_ordinal() in the
|
||||
// backend — floored into 1..99, because an empirical percentile is a rank
|
||||
// against the sample and can round to 100 (or 0), and "100th percentile" reads
|
||||
// as a measurement error rather than "as extreme as it has ever been".
|
||||
// Every percentile shown anywhere goes through this, so the Day page, the
|
||||
// calendar tooltip, the chart, the city pages and the homepage strip can't
|
||||
// disagree about the same reading.
|
||||
export const pctOrd = (n) => {
|
||||
const v = Number(n);
|
||||
if (!Number.isFinite(v)) return "—";
|
||||
return ord(Math.min(99, Math.max(1, Math.round(v))));
|
||||
};
|
||||
export const todayISO = () => new Date().toISOString().slice(0, 10);
|
||||
export const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
// The heading label for a graded response: the resolved place name, or the
|
||||
|
|
|
|||
Loading…
Reference in a new issue