Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
// Thermograph climate-score subpage — for one location, shows how far the last 6
// years have drifted from the full 45-year record, per metric and season, with a
// weighted overall score and a button-revealed summary. Talks to /api/v2/score.
import { loadLastLocation , saveLastLocation , locHash } from "./nav.js" ;
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" ;
2026-07-19 23:57:55 +00:00
import { TIER _COLORS , esc , placeLabel } from "./shared.js" ;
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
2026-07-19 23:57:55 +00:00
// Display order (site convention leads with Precip).
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
const METRIC _ORDER = [ "precip" , "tmax" , "tmin" , "feels" , "humid" , "wetbulb" , "wind" , "gust" ] ;
const SEASON _NAMES = { djf : "Winter" , mam : "Spring" , jja : "Summer" , son : "Fall" } ;
const SEASON _ORDER = [ "djf" , "mam" , "jja" , "son" ] ;
const tintColor = ( cls ) => TIER _COLORS [ cls ] || "" ;
const tintClass = ( cls ) => ( cls ? ` t- ${ cls } ` : "t-none" ) ;
let selected = null ; // {lat, lon}
let lastData = null ; // most recent /score response, for repainting on a unit switch
const placeholder = document . getElementById ( "score-placeholder" ) ;
const scoreHead = document . getElementById ( "score-head" ) ;
const scoreBody = document . getElementById ( "score-body" ) ;
// ---- location picker ----
const findBtn = document . getElementById ( "find-btn" ) ;
const locLabel = document . getElementById ( "loc-label" ) ;
initFindButton ( findBtn , "Find a location" , ( ) => selected ,
( lat , lon ) => {
selected = { lat , lon } ;
locLabel . textContent = ` ${ lat . toFixed ( 3 ) } , ${ lon . toFixed ( 3 ) } ` ;
locLabel . hidden = false ;
fetchScore ( ) ;
} ) ;
// ---- fetch + render ----
let seqCounter = 0 ; // supersedes an in-flight load when the user picks a new spot
async function fetchScore ( ) {
if ( ! selected ) return ;
history . replaceState ( null , "" , locHash ( selected . lat , selected . lon ) ) ;
placeholder . hidden = true ;
scoreHead . hidden = false ;
const seq = ++ seqCounter ;
// Delay the spinner: a cache-served render lands in a few ms, so blanking the
// page immediately would just flash. Only a slow (network) load shows it.
const spin = setTimeout ( ( ) => {
if ( seq !== seqCounter ) return ;
scoreHead . innerHTML = ` <p class="spinner">Scoring 45 years of climate…</p> ` ;
scoreBody . innerHTML = "" ;
} , 150 ) ;
let data ;
try {
// Stale-while-revalidate: a stale cached copy renders immediately; the update
// callback repaints if the background revalidation finds new data.
data = await getJSON (
` api/v2/score?lat= ${ selected . lat } &lon= ${ selected . lon } ` , TTL . score ,
false , ( upd ) => { if ( seq === seqCounter ) finishScore ( upd ) ; } ) ;
} catch ( err ) {
clearTimeout ( spin ) ;
if ( seq !== seqCounter ) return ;
scoreHead . innerHTML = ` <p class="error"> ${ esc ( err . message ) } </p> ` ;
scoreBody . innerHTML = "" ;
return ;
}
clearTimeout ( spin ) ;
if ( seq !== seqCounter ) return ;
finishScore ( data ) ;
}
function finishScore ( data ) {
saveLastLocation ( selected . lat , selected . lon ) ;
render ( data ) ;
prefetchViews ( selected . lat , selected . lon , [ ] ) ; // warm the other views
}
onUnitChange ( ( ) => { if ( lastData ) render ( lastData ) ; } ) ;
function render ( data ) {
lastData = data ;
const place = placeLabel ( data ) ;
locLabel . textContent = place ;
locLabel . hidden = false ;
setFindLabel ( findBtn , "Change location" ) ;
const s = data . scores ;
if ( s . unavailable ) {
scoreHead . innerHTML = ` <div class="score-hero"><h2> ${ esc ( place ) } </h2>
< p class = "score-sub" > $ { esc ( s . unavailable ) } < / p > < / d i v > ` ;
scoreBody . innerHTML = "" ;
return ;
}
const annual = s . slices . annual ;
const ov = annual . overall ;
const rr = s . recent _range , br = s . baseline _range ;
const recentYrs = ` ${ rr [ 0 ] . slice ( 0 , 4 ) } – ${ rr [ 1 ] . slice ( 0 , 4 ) } ` ;
const baseYrs = ` ${ br [ 0 ] . slice ( 0 , 4 ) } – ${ br [ 1 ] . slice ( 0 , 4 ) } ` ;
// ---- hero: the overall annual score ----
scoreHead . innerHTML = `
< div class = "score-hero" style = "--cat:${tintColor(ov.class)}" >
< p class = "score-eyebrow" > Climate - shift score · $ { esc ( place ) } < / p >
< div class = "score-hero-row" >
< span class = "score-num" > $ { ov . score } < / s p a n >
< div class = "score-hero-label" >
< span class = "score-grade" > $ { esc ( ov . grade ) } < / s p a n >
2026-07-19 23:37:42 +00:00
< span class = "score-sub" > Net change · $ { recentYrs } vs $ { baseYrs } · $ { s . n _recent . toLocaleString ( ) } recent days scored < / s p a n >
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
< / d i v >
< / d i v >
2026-07-19 23:37:42 +00:00
< p class = "score-note" > A net - change total across all metrics : 0 = no change from the long - term
normal · 100 = an extreme shift . Temperature , feels - like and humidity are weighted most . < / p >
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
< / d i v > ` ;
// ---- per-metric cards (annual) ----
const cards = METRIC _ORDER . map ( ( k ) => metricCard ( annual . metrics [ k ] ) ) . join ( "" ) ;
2026-07-19 23:37:42 +00:00
const wbNote = wetbulbNote ( annual . metrics . wetbulb , baseYrs ) ;
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
2026-07-19 23:41:03 +00:00
// ---- by-season table (seasons × metrics, columns aligned) ----
const seasonHead = ` <tr><th></th><th>Overall</th> ${ METRIC _ORDER . map ( ( k ) =>
` <th> ${ shortLabel ( k ) } </th> ` ) . join ( "" ) } < / t r > ` ;
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
const seasonRows = SEASON _ORDER . map ( ( sk ) => {
const sl = s . slices [ sk ] ;
const oc = sl . overall ;
2026-07-19 23:41:03 +00:00
const overallCell = oc
? ` <td class=" ${ tintClass ( oc . class ) } "><strong> ${ oc . score } </strong></td> `
: ` <td class="t-none">—</td> ` ;
const cells = METRIC _ORDER . map ( ( k ) => seasonCell ( sl . metrics [ k ] ) ) . join ( "" ) ;
return ` <tr><th> ${ SEASON _NAMES [ sk ] } </th> ${ overallCell } ${ cells } </tr> ` ;
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
} ) . join ( "" ) ;
scoreBody . innerHTML = `
< section class = "normals" > $ { cards } < / s e c t i o n >
2026-07-19 23:37:42 +00:00
$ { wbNote }
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
< section class = "score-section" >
< p class = "section-title" > By season < / p >
2026-07-19 23:41:03 +00:00
< div class = "score-table-wrap" > < table class = "score-table season-table" >
< thead > $ { seasonHead } < / t h e a d > < t b o d y > $ { s e a s o n R o w s } < / t b o d y > < / t a b l e > < / d i v >
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
< / s e c t i o n >
< button type = "button" class = "summary-toggle" aria - expanded = "false" aria - controls = "score-summary" >
Summary < span class = "summary-caret" aria - hidden = "true" > ▾ < / s p a n > < / b u t t o n >
< section id = "score-summary" hidden > $ { summaryHTML ( s ) } < / s e c t i o n > ` ;
wireSummaryToggle ( ) ;
}
2026-07-19 23:37:42 +00:00
// Explains what wet bulb is and reports the share of heat-stress ("wet-bulb")
// days vs normal days, recent vs baseline. Re-rendered on unit change.
function wetbulbNote ( m , baseYrs ) {
const def = ` <strong>Wet bulb</strong> is how cool evaporation — like sweat — can make you in the
current air . When it climbs , sweat stops shedding heat and high temperatures turn dangerous . ` ;
if ( ! m || m . score == null || ! m . freq ) {
return ` <p class="score-callout"> ${ def } </p> ` ;
}
const thr = fmtTemp ( m . freq . threshold _f ) ;
const { f6 , f45 , d } = m . freq ;
const delta = d ? ` <span class="pq-d ${ d >= 0 ? "pq-up" : "pq-down" } ">( ${ d >= 0 ? "+" : "" } ${ d } pts)</span> ` : "" ;
return ` <p class="score-callout"> ${ def }
Heat - stress days ( peak wet bulb ≥ $ { esc ( String ( thr ) ) } ) : < strong > $ { f6 } % < / s t r o n g > o f r e c e n t d a y s
vs < strong > $ { f45 } % < / s t r o n g > a c r o s s $ { b a s e Y r s } $ { d e l t a } — t h e r e s t a r e n o r m a l . < / p > ` ;
}
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
// One metric score card (reuses .normal-card). Null metrics show a muted reason.
function metricCard ( m ) {
if ( ! m ) return "" ;
if ( m . score == null ) {
return ` <section class="normal-card">
< div class = "nc-head" > < h3 > $ { esc ( m . label ) } < / h 3 > < / d i v >
< div class = "big" style = "--cat:var(--muted)" > — < / d i v >
< div class = "range" > $ { esc ( m . reason || "no data" ) } < / d i v >
< / s e c t i o n > ` ;
}
return ` <section class="normal-card" style="--cat: ${ tintColor ( m . class ) } ">
< div class = "nc-head" > < h3 > $ { esc ( m . label ) } < / h 3 > < s p a n c l a s s = " n c - g r a d e " > $ { e s c ( m . g r a d e ) } < / s p a n > < / d i v >
< div class = "big" > $ { m . score } < / d i v >
< div class = "range" > $ { scoreDirection ( m ) } < / d i v >
< / s e c t i o n > ` ;
}
// A short "shifted N pts warmer" line for a metric card.
function scoreDirection ( m ) {
if ( m . score < 15 ) return "steady vs the long-term normal" ;
const pts = Math . abs ( m . bias ) . toFixed ( 0 ) ;
return ` ~ ${ pts } pts ${ esc ( m . direction ) } ` ;
}
2026-07-19 23:41:03 +00:00
function seasonCell ( m ) {
if ( ! m || m . score == null ) return ` <td class="t-none">—</td> ` ;
return ` <td class=" ${ tintClass ( m . class ) } " title=" ${ esc ( m . label ) } : ${ esc ( m . grade ) } "> ${ m . score } </td> ` ;
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
}
const shortLabel = ( k ) => ( { precip : "Precip" , tmax : "High" , tmin : "Low" , feels : "Feels" ,
humid : "Humid" , wetbulb : "Wet bulb" , wind : "Wind" , gust : "Gust" } ) [ k ] || k ;
// ---- summary (revealed by the button) ----
function summaryHTML ( s ) {
return sentencesHTML ( s . slices . annual ) + scoreTableHTML ( s ) + perQTableHTML ( s . slices . annual ) ;
}
// A plain-language sentence per metric that actually shifted.
function sentencesHTML ( annual ) {
const lines = METRIC _ORDER
. map ( ( k ) => annual . metrics [ k ] )
. filter ( ( m ) => m && m . score != null && m . score >= 15 )
. map ( ( m ) => {
const mid = m . per _q && m . per _q . find ( ( q ) => q . q === 50 ) ;
2026-07-19 23:57:55 +00:00
const where = mid ? ` At the median, recent readings sit about ${ Math . abs ( mid . d ) } points ${ mid . d >= 0 ? "higher" : "lower" } than the seasonal norm. ` : "" ;
return ` <li><strong> ${ esc ( m . label ) } :</strong> ${ esc ( m . grade ) } — about ${ Math . abs ( m . bias ) . toFixed ( 0 ) } percentile points ${ esc ( m . direction ) } on average. ${ where } </li> ` ;
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
} ) ;
if ( ! lines . length ) return ` <p class="score-summary-lead">Every metric is close to its long-term normal here — no notable recent drift.</p> ` ;
return ` <p class="score-summary-lead">What has shifted the most:</p><ul class="score-sentences"> ${ lines . join ( "" ) } </ul> ` ;
}
// Metrics × slices table of scores, each cell tinted by its tier.
function scoreTableHTML ( s ) {
const slices = [ "annual" , ... SEASON _ORDER ] ;
const head = ` <tr><th>Metric</th> ${ slices . map ( ( sk ) =>
` <th> ${ sk === "annual" ? "Annual" : SEASON _NAMES [ sk ] } </th> ` ) . join ( "" ) } < / t r > ` ;
const rows = METRIC _ORDER . map ( ( k ) => {
const cells = slices . map ( ( sk ) => {
const m = s . slices [ sk ] . metrics [ k ] ;
if ( ! m || m . score == null ) return ` <td class="t-none">—</td> ` ;
return ` <td class=" ${ tintClass ( m . class ) } " title=" ${ esc ( m . grade ) } "> ${ m . score } </td> ` ;
} ) . join ( "" ) ;
const label = s . slices . annual . metrics [ k ] ? . label || k ;
return ` <tr><th> ${ esc ( label ) } </th> ${ cells } </tr> ` ;
} ) . join ( "" ) ;
const overallCells = slices . map ( ( sk ) => {
const o = s . slices [ sk ] . overall ;
if ( ! o ) return ` <td class="t-none">—</td> ` ;
return ` <td class=" ${ tintClass ( o . class ) } "><strong> ${ o . score } </strong></td> ` ;
} ) . join ( "" ) ;
const overallRow = ` <tr class="score-table-overall"><th>Overall</th> ${ overallCells } </tr> ` ;
return ` <p class="section-title">All scores</p>
< div class = "score-table-wrap" > < table class = "score-table" >
< thead > $ { head } < / t h e a d > < t b o d y > $ { r o w s } $ { o v e r a l l R o w } < / t b o d y > < / t a b l e > < / d i v > ` ;
}
2026-07-19 23:57:55 +00:00
// The per-percentile detail: for each band, the mean shift (in percentile points)
// averaged across the four seasons — the differentials the score is built from.
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
function perQTableHTML ( annual ) {
const rows = METRIC _ORDER
. map ( ( k ) => annual . metrics [ k ] )
. filter ( ( m ) => m && m . score != null && m . per _q && m . per _q . length )
. map ( ( m ) => {
const cells = m . per _q . map ( ( q ) =>
2026-07-19 23:57:55 +00:00
` <td>p ${ q . q } : <span class="pq-d ${ q . d >= 0 ? "pq-up" : "pq-down" } "> ${ q . d >= 0 ? "+" : "" } ${ q . d } </span></td> ` ) . join ( "" ) ;
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
return ` <tr><th> ${ esc ( m . label ) } </th> ${ cells } </tr> ` ;
} ) . join ( "" ) ;
if ( ! rows ) return "" ;
2026-07-19 23:57:55 +00:00
return ` <p class="section-title">Shift by percentile band</p>
< p class = "score-note" > The mean shift at each percentile band , averaged across all four seasons ( percentile points ; ▲ higher / ▼ lower than the seasonal norm ) . < / p >
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
< div class = "score-table-wrap" > < table class = "score-table pq-table" > < tbody > $ { rows } < / t b o d y > < / t a b l e > < / d i v > ` ;
}
// Mirror of shared.js initSeasonExpand: flip [hidden] + aria-expanded.
function wireSummaryToggle ( ) {
const btn = scoreBody . querySelector ( ".summary-toggle" ) ;
const panel = scoreBody . querySelector ( "#score-summary" ) ;
if ( ! btn || ! panel ) return ;
btn . addEventListener ( "click" , ( ) => {
const show = panel . hidden ;
panel . hidden = ! show ;
btn . setAttribute ( "aria-expanded" , String ( show ) ) ;
} ) ;
}
// ---- restore (hash from a shared link, else the last place you looked at) ----
( function restore ( ) {
const loc = loadLastLocation ( ) ;
if ( loc ) {
selected = { lat : loc . lat , lon : loc . lon } ;
fetchScore ( ) ;
}
} ) ( ) ;