2026-07-11 02:58:56 +00:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "utf-8" / >
< meta name = "viewport" content = "width=device-width, initial-scale=1" / >
< title > Thermograph — compare comfort< / title >
SEO: crawlable programmatic climate pages + technical hygiene (#96)
* SEO: generate curated city set for crawlable climate pages
gen_cities.py reuses the GeoNames index places.py already parses to select the top
~500 metros by population, assigns each a stable URL-safe slug (dropping admin1 when
it repeats the city name), and writes committed backend/cities.json. cities.py loads
it lazily with slug lookup, all_slugs(), display_name(), and by_country() grouping
for the upcoming hub + sitemap.
* SEO: rendering core, robots.txt, sitemap.xml, and metadata hygiene
- content.py: Jinja2 environment + HTML responder (ETag/304), dynamic /robots.txt
(disallows /api and /alerts, points at the sitemap) and /sitemap.xml (enumerates
the home/static pages plus every city, month, and records URL from cities.py).
Registered on the app before the StaticFiles mount so the routes win.
- templates/base.html.j2: shared layout with unique title/description, self-
referential canonical, Open Graph, favicon/manifest, header nav (adds a Climate
link) and a footer link graph.
- Give each existing page a unique <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; add WebApplication JSON-LD to the home page.
- Pin jinja2.
* SEO: server-rendered per-city climate page (/climate/{slug})
The keystone crawlable page: for a city it snaps to the grid cell, loads the
archive (fetching once if missing, self-healing), and renders as real HTML — a
'how today compares' block (grade + percentile per metric from grade_day, tinted
by tier), a monthly normals table (climatology at each month's 15th, shown in °F
and °C), all-time records (new grading.all_time_records helper), a breadcrumb,
Dataset+Place+BreadcrumbList JSON-LD, self-referential canonical, and links into
the interactive tool + month/records pages. Content-page CSS added to style.css
(renamed the table class to avoid colliding with the app's .normals flex row).
* SEO: month (/climate/{slug}/{month}) and records (/climate/{slug}/records) pages
Month pages render the exact-month long-tail ('average weather in {city} in
{month}') with that month's average high/low, typical p10-p90 range, month-specific
records, and prev/next month links. Records pages show all-time record highs/lows
per metric with dates (grading.all_time_records). Shared _resolve_city helper; the
literal /records route is registered before the {month} param and month names are
validated (unknown month -> 404).
* SEO: climate hub, weather glossary, and about/methodology pages
- /climate: crawlable directory of all ~500 cities grouped by country — the
internal-link graph that lets search engines discover every city page.
- /glossary + /glossary/{term}: plain-language definitions (climate normal,
percentile, temperature anomaly, feels-like, heat index, wind chill, humidity,
reanalysis) with DefinedTerm JSON-LD and cross-links into the tool.
- /about: methodology page (ERA5 data source, 45-year baseline, +/-7-day window,
percentile grading) for E-E-A-T. All linked from the shared footer.
* SEO: archive warmer, content-page tests, and deploy docs
- warm_cities.py: paced, idempotent offline warmer that pre-fetches each city
cell's archive so /climate pages serve from cache and a crawl can't burst the
archive quota (pages self-heal if hit before warming).
- tests/test_content.py: city-set slug uniqueness/lookup, robots.txt, sitemap
enumerating city/month/records URLs, and that a rendered city page carries the
stats + canonical + Dataset JSON-LD in the HTML; plus month/records/hub/glossary/
about routing and 404s.
- DEPLOY.md: document the content pages, the warm step, and submitting the sitemap.
2026-07-15 23:53:11 +00:00
< meta name = "description" content = "Compare how well two or more places hit your comfort temperature over any date range, side by side, each graded against its own ~45-year climate history." / >
2026-07-11 15:59:14 +00:00
< meta name = "theme-color" content = "#f0803c" / >
<!-- Link previews (Discord, Slack, iMessage…). The server fills the og:url /
og:image origins in from this request's scheme://host + base path —
crawlers need absolute URLs and don't run JS. -->
< meta property = "og:type" content = "website" / >
< meta property = "og:site_name" content = "Thermograph" / >
< meta property = "og:title" content = "Thermograph — compare comfort" / >
SEO: crawlable programmatic climate pages + technical hygiene (#96)
* SEO: generate curated city set for crawlable climate pages
gen_cities.py reuses the GeoNames index places.py already parses to select the top
~500 metros by population, assigns each a stable URL-safe slug (dropping admin1 when
it repeats the city name), and writes committed backend/cities.json. cities.py loads
it lazily with slug lookup, all_slugs(), display_name(), and by_country() grouping
for the upcoming hub + sitemap.
* SEO: rendering core, robots.txt, sitemap.xml, and metadata hygiene
- content.py: Jinja2 environment + HTML responder (ETag/304), dynamic /robots.txt
(disallows /api and /alerts, points at the sitemap) and /sitemap.xml (enumerates
the home/static pages plus every city, month, and records URL from cities.py).
Registered on the app before the StaticFiles mount so the routes win.
- templates/base.html.j2: shared layout with unique title/description, self-
referential canonical, Open Graph, favicon/manifest, header nav (adds a Climate
link) and a footer link graph.
- Give each existing page a unique <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; add WebApplication JSON-LD to the home page.
- Pin jinja2.
* SEO: server-rendered per-city climate page (/climate/{slug})
The keystone crawlable page: for a city it snaps to the grid cell, loads the
archive (fetching once if missing, self-healing), and renders as real HTML — a
'how today compares' block (grade + percentile per metric from grade_day, tinted
by tier), a monthly normals table (climatology at each month's 15th, shown in °F
and °C), all-time records (new grading.all_time_records helper), a breadcrumb,
Dataset+Place+BreadcrumbList JSON-LD, self-referential canonical, and links into
the interactive tool + month/records pages. Content-page CSS added to style.css
(renamed the table class to avoid colliding with the app's .normals flex row).
* SEO: month (/climate/{slug}/{month}) and records (/climate/{slug}/records) pages
Month pages render the exact-month long-tail ('average weather in {city} in
{month}') with that month's average high/low, typical p10-p90 range, month-specific
records, and prev/next month links. Records pages show all-time record highs/lows
per metric with dates (grading.all_time_records). Shared _resolve_city helper; the
literal /records route is registered before the {month} param and month names are
validated (unknown month -> 404).
* SEO: climate hub, weather glossary, and about/methodology pages
- /climate: crawlable directory of all ~500 cities grouped by country — the
internal-link graph that lets search engines discover every city page.
- /glossary + /glossary/{term}: plain-language definitions (climate normal,
percentile, temperature anomaly, feels-like, heat index, wind chill, humidity,
reanalysis) with DefinedTerm JSON-LD and cross-links into the tool.
- /about: methodology page (ERA5 data source, 45-year baseline, +/-7-day window,
percentile grading) for E-E-A-T. All linked from the shared footer.
* SEO: archive warmer, content-page tests, and deploy docs
- warm_cities.py: paced, idempotent offline warmer that pre-fetches each city
cell's archive so /climate pages serve from cache and a crawl can't burst the
archive quota (pages self-heal if hit before warming).
- tests/test_content.py: city-set slug uniqueness/lookup, robots.txt, sitemap
enumerating city/month/records URLs, and that a rendered city page carries the
stats + canonical + Dataset JSON-LD in the HTML; plus month/records/hub/glossary/
about routing and 404s.
- DEPLOY.md: document the content pages, the warm step, and submitting the sitemap.
2026-07-15 23:53:11 +00:00
< meta property = "og:description" content = "Compare how well two or more places hit your comfort temperature over any date range, side by side, each graded against its own ~45-year climate history." / >
2026-07-11 15:59:14 +00:00
< meta property = "og:url" content = "__ORIGIN__/compare" / >
2026-07-18 04:26:42 +00:00
< meta property = "og:image" content = "__ORIGIN__/logo.png?v=2" / >
2026-07-11 15:59:14 +00:00
< meta property = "og:image:width" content = "512" / >
< meta property = "og:image:height" content = "512" / >
< meta property = "og:image:alt" content = "Thermograph logo" / >
< meta name = "twitter:card" content = "summary" / >
SEO: crawlable programmatic climate pages + technical hygiene (#96)
* SEO: generate curated city set for crawlable climate pages
gen_cities.py reuses the GeoNames index places.py already parses to select the top
~500 metros by population, assigns each a stable URL-safe slug (dropping admin1 when
it repeats the city name), and writes committed backend/cities.json. cities.py loads
it lazily with slug lookup, all_slugs(), display_name(), and by_country() grouping
for the upcoming hub + sitemap.
* SEO: rendering core, robots.txt, sitemap.xml, and metadata hygiene
- content.py: Jinja2 environment + HTML responder (ETag/304), dynamic /robots.txt
(disallows /api and /alerts, points at the sitemap) and /sitemap.xml (enumerates
the home/static pages plus every city, month, and records URL from cities.py).
Registered on the app before the StaticFiles mount so the routes win.
- templates/base.html.j2: shared layout with unique title/description, self-
referential canonical, Open Graph, favicon/manifest, header nav (adds a Climate
link) and a footer link graph.
- Give each existing page a unique <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; add WebApplication JSON-LD to the home page.
- Pin jinja2.
* SEO: server-rendered per-city climate page (/climate/{slug})
The keystone crawlable page: for a city it snaps to the grid cell, loads the
archive (fetching once if missing, self-healing), and renders as real HTML — a
'how today compares' block (grade + percentile per metric from grade_day, tinted
by tier), a monthly normals table (climatology at each month's 15th, shown in °F
and °C), all-time records (new grading.all_time_records helper), a breadcrumb,
Dataset+Place+BreadcrumbList JSON-LD, self-referential canonical, and links into
the interactive tool + month/records pages. Content-page CSS added to style.css
(renamed the table class to avoid colliding with the app's .normals flex row).
* SEO: month (/climate/{slug}/{month}) and records (/climate/{slug}/records) pages
Month pages render the exact-month long-tail ('average weather in {city} in
{month}') with that month's average high/low, typical p10-p90 range, month-specific
records, and prev/next month links. Records pages show all-time record highs/lows
per metric with dates (grading.all_time_records). Shared _resolve_city helper; the
literal /records route is registered before the {month} param and month names are
validated (unknown month -> 404).
* SEO: climate hub, weather glossary, and about/methodology pages
- /climate: crawlable directory of all ~500 cities grouped by country — the
internal-link graph that lets search engines discover every city page.
- /glossary + /glossary/{term}: plain-language definitions (climate normal,
percentile, temperature anomaly, feels-like, heat index, wind chill, humidity,
reanalysis) with DefinedTerm JSON-LD and cross-links into the tool.
- /about: methodology page (ERA5 data source, 45-year baseline, +/-7-day window,
percentile grading) for E-E-A-T. All linked from the shared footer.
* SEO: archive warmer, content-page tests, and deploy docs
- warm_cities.py: paced, idempotent offline warmer that pre-fetches each city
cell's archive so /climate pages serve from cache and a crawl can't burst the
archive quota (pages self-heal if hit before warming).
- tests/test_content.py: city-set slug uniqueness/lookup, robots.txt, sitemap
enumerating city/month/records URLs, and that a rendered city page carries the
stats + canonical + Dataset JSON-LD in the HTML; plus month/records/hub/glossary/
about routing and 404s.
- DEPLOY.md: document the content pages, the warm step, and submitting the sitemap.
2026-07-15 23:53:11 +00:00
< link rel = "canonical" href = "__ORIGIN__/compare" / >
2026-07-18 04:26:42 +00:00
< link rel = "icon" href = "favicon.svg" type = "image/svg+xml" / >
< link rel = "icon" href = "favicon-48.png" type = "image/png" sizes = "48x48" / >
< link rel = "icon" href = "favicon-32.png" type = "image/png" sizes = "32x32" / >
< link rel = "icon" href = "favicon-16.png" type = "image/png" sizes = "16x16" / >
< link rel = "apple-touch-icon" href = "apple-touch-icon.png" / >
2026-07-15 23:21:06 +00:00
< link rel = "manifest" href = "manifest.webmanifest" / >
2026-07-11 02:58:56 +00:00
< link rel = "stylesheet" href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" / >
< link rel = "stylesheet" href = "style.css" / >
< / head >
< body >
< header >
< div class = "brand" >
< div >
Make the brand lockup a home link on every page (#182)
The mark and wordmark were already a home link on the Jinja pages (homepage,
about, climate, glossary, privacy), but the five static tool pages — calendar,
day, compare, legend, alerts — built their header separately and left the brand
as a bare <h1>, so clicking it did nothing.
Wrap the lockup in `<a href="./">` there, matching the relative form the nav
already uses: it resolves to the app root whether the app is served at the
domain root or under a base path.
The link wraps the whole lockup, so the mark is clickable too, not just the
word. Its colour/decoration moves from `.site-name a` up to the shared
`.brand h1 a, .brand .site-name a` rule so it covers both the static pages'
<h1> form and the homepage's <p>, with an accent hover so the affordance is
visible.
Adds a parametrized test over every page: the two header implementations are
easy to change independently, so this asserts each one carries a home link that
wraps the mark.
2026-07-18 08:06:14 +00:00
< h1 > < a href = "./" > < span class = "logo" > < svg viewBox = "0 0 512 512" width = "28" height = "28" aria-hidden = "true" > < rect x = "32" y = "32" width = "448" height = "448" rx = "96" fill = "#10141B" stroke = "#fff" stroke-opacity = ".08" stroke-width = "4" / > < rect x = "64" y = "170" width = "384" height = "48" rx = "8" fill = "#27131C" / > < rect x = "64" y = "222" width = "384" height = "48" rx = "8" fill = "#38322E" / > < rect x = "64" y = "274" width = "384" height = "48" rx = "8" fill = "#1A2D27" / > < rect x = "64" y = "326" width = "384" height = "48" rx = "8" fill = "#26333F" / > < rect x = "64" y = "378" width = "384" height = "48" rx = "8" fill = "#18293A" / > < line x1 = "64" y1 = "132" x2 = "448" y2 = "132" stroke = "#46586E" stroke-width = "8" stroke-dasharray = "26 20" / > < path d = "M64 410H126V386H160" fill = "none" stroke = "var(--cold)" stroke-width = "34" / > < path d = "M160 403V352H244V368H282" fill = "none" stroke = "var(--cool)" stroke-width = "34" / > < path d = "M282 385V320H312" fill = "none" stroke = "var(--normal)" stroke-width = "34" / > < path d = "M312 337V248H338" fill = "none" stroke = "var(--warm)" stroke-width = "34" / > < path d = "M338 265V196H366" fill = "none" stroke = "var(--hot)" stroke-width = "34" / > < path d = "M366 213V132H396" fill = "none" stroke = "var(--very-hot)" stroke-width = "34" / > < rect x = "382" y = "100" width = "64" height = "64" rx = "12" fill = "var(--rec-hot)" stroke = "#10141B" stroke-width = "12" / > < / svg > < / span > Thermograph< / a > < / h1 >
2026-07-11 02:58:56 +00:00
< p class = "tag" > Which place best hits your comfort temperature over a date range< / p >
< / div >
2026-07-16 05:42:59 +00:00
< details class = "nav-menu" >
< summary class = "nav-toggle" aria-label = "Menu" > < svg viewBox = "0 0 24 24" width = "22" height = "22" aria-hidden = "true" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" > < path d = "M4 7h16M4 12h16M4 17h16" / > < / svg > < / summary >
2026-07-16 12:15:00 +00:00
< div class = "nav-panel" >
2026-07-16 05:42:59 +00:00
< nav class = "view-nav" aria-label = "Views" >
2026-07-11 02:58:56 +00:00
< a href = "./" data-view = "map" > Weekly< / a >
< a href = "calendar" data-view = "calendar" > Calendar< / a >
2026-07-16 12:34:04 +00:00
< a href = "day" data-view = "day" > Day Detail< / a >
2026-07-11 02:58:56 +00:00
< a href = "compare" data-view = "compare" class = "active" > Compare< / a >
2026-07-16 06:01:58 +00:00
< a href = "climate" > Climate< / a >
2026-07-16 23:41:24 +00:00
< a href = "alerts" > Alerts< / a >
2026-07-11 02:58:56 +00:00
< / nav >
2026-07-16 12:15:00 +00:00
< / div >
2026-07-16 05:42:59 +00:00
< / details >
2026-07-11 02:58:56 +00:00
< / div >
< / header >
< main >
<!-- Locations to line up against each other. -->
< section class = "controls" >
< div class = "cmp-locs" >
< span class = "cal-filter-label" > Locations< / span >
< ul id = "cmp-loc-list" class = "cmp-loc-list" > < / ul >
2026-07-11 03:17:17 +00:00
< div class = "cmp-loc-actions" >
< button type = "button" id = "cmp-add" class = "find-btn" > < / button >
< button type = "button" id = "cmp-refresh" class = "cmp-refresh-btn" hidden > Refresh comparison ↻< / button >
< / div >
2026-07-11 02:58:56 +00:00
< / div >
< / section >
<!-- The comfort target, what temperature to judge by, the tolerance band, and
the date range. Comfort/band/basis recompute instantly (no refetch); only
the date range or the location set trigger a data load. -->
2026-07-12 07:11:21 +00:00
<!-- Comfort/tolerance target — the primary knob, kept inline above the results
(never inside the mobile filter sheet). Recomputes the ranking instantly. -->
< div id = "cmp-comfort" class = "cmp-comfort-wrap" hidden >
< div class = "cmp-range-labels" >
< span class = "cmp-rl cmp-rl-comfort" > Comfort < b id = "cmp-comfort-val" > < / b > < / span >
< span class = "cmp-rl cmp-rl-tol" > Tolerance < b id = "cmp-tol-val" > < / b > < / span >
< / div >
< div class = "cmp-ticks" id = "cmp-ticks" aria-hidden = "true" >
< span class = "cmp-tick tol" id = "cmp-tick-tlo" > < / span >
< span class = "cmp-tick" id = "cmp-tick-lo" > < / span >
< span class = "cmp-tick" id = "cmp-tick-hi" > < / span >
< span class = "cmp-tick tol" id = "cmp-tick-thi" > < / span >
< / div >
< div class = "dual-range quad-range" id = "cmp-slicer" >
< div class = "dual-track" >
< span class = "dual-fill dual-fill-tol" id = "cmp-fill-tol" > < / span >
< span class = "dual-fill" id = "cmp-fill" > < / span >
2026-07-11 22:33:38 +00:00
< / div >
2026-07-12 07:11:21 +00:00
< input id = "cmp-tlo" type = "range" min = "30" max = "100" step = "1" class = "thumb-tol" aria-label = "Tolerance range lower bound" / >
< input id = "cmp-lo" type = "range" min = "30" max = "100" step = "1" aria-label = "Comfort range lower bound" / >
< input id = "cmp-hi" type = "range" min = "30" max = "100" step = "1" aria-label = "Comfort range upper bound" / >
< input id = "cmp-thi" type = "range" min = "30" max = "100" step = "1" class = "thumb-tol" aria-label = "Tolerance range upper bound" / >
2026-07-11 02:58:56 +00:00
< / div >
2026-07-12 07:11:21 +00:00
< span class = "hint" > Inner handles set your comfort range; outer handles set the tolerance range. Days in tolerance still count toward the score, scaled by how far past comfort they fall.< / span >
2026-07-12 07:45:12 +00:00
<!-- Which temperature to judge each day by — a scoring control, so it sits with
the comfort slider above the results, not in the filter sheet. -->
< div class = "cmp-basis-block" >
2026-07-11 02:58:56 +00:00
< span class = "cal-filter-label" > Judge each day by its< / span >
< div class = "metric-toggle" id = "cmp-basis" role = "group" aria-label = "Temperature to compare" >
< button type = "button" data-basis = "tmax" class = "active" > High< / button >
< button type = "button" data-basis = "mean" > Mean< / button >
< button type = "button" data-basis = "tmin" > Low< / button >
< button type = "button" data-basis = "feels" > Feels< / button >
< / div >
< / div >
2026-07-12 07:45:12 +00:00
< / div >
2026-07-11 02:58:56 +00:00
2026-07-12 07:45:12 +00:00
< div id = "cmp-params" class = "cmp-params" hidden >
2026-07-12 05:33:09 +00:00
< div class = "cmp-param cmp-season" id = "cmp-season" >
< span class = "cal-filter-label" > Time of year< / span >
< div class = "cmp-season-dd" id = "cmp-season-dd" > < / div >
< span class = "hint" > Count only days in these seasons/months — re-ranks instantly.< / span >
< / div >
2026-07-11 02:58:56 +00:00
< div class = "cmp-param cmp-range cal-range" id = "cmp-range" >
< div class = "cal-range-head" >
< span class = "cal-filter-label" > Date range< / span >
< span class = "hint" > any span; longer loads take a moment< / span >
< / div >
< label > From < input id = "cmp-start" type = "month" / > < / label >
< label > To < input id = "cmp-end" type = "month" / > < / label >
2026-07-12 06:32:10 +00:00
< span class = "hint" > Editing the range arms Load — tap it to reload.< / span >
< button type = "button" id = "cmp-refresh-sheet" class = "cmp-refresh-btn" hidden > Load / Refresh ↻< / button >
2026-07-11 02:58:56 +00:00
< / div >
2026-07-12 07:45:12 +00:00
<!-- A synced copy of the "Distribution by" selector (below), for the phone
sheet; buttons are cloned from the inline one in JS. Hidden on desktop. -->
< div class = "cmp-param cmp-dist-metric-block" >
< span class = "cal-filter-label" > Distribution by< / span >
< div class = "metric-toggle" id = "cmp-dist-metric-sheet" role = "group" aria-label = "Distribution metric (in filters)" > < / div >
< / div >
2026-07-11 02:58:56 +00:00
< / div >
< div id = "cmp-head" class = "cal-head" hidden > < / div >
< div class = "placeholder" id = "cmp-placeholder" >
< p > Add two or more places to see which one best matches your comfort temperature
across a date range — the share of days that land in your comfort band, how
often it runs colder or warmer, and by how much.< / p >
< / div >
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
<!-- Baseline: every place's typical temperature on one shared axis, so their
baselines line up against each other and the shaded comfort range. -->
< section id = "cmp-baseline" class = "cmp-baseline" hidden > < / section >
2026-07-11 02:58:56 +00:00
< div id = "cmp-results" class = "cmp-results" > < / div >
2026-07-11 22:33:38 +00:00
<!-- Climate distribution, independent of the comfort filter: how each place's
days spread across a metric's Record-Low→Record-High categories, mirroring
the calendar's totals strip. Its own metric + share/count toggles. -->
< section id = "cmp-dist" class = "cmp-dist" hidden >
< div class = "ct-head" >
< div class = "cmp-dist-controls" >
< span class = "cal-filter-label" > Distribution by< / span >
< div class = "metric-toggle" id = "cmp-dist-metric" role = "group" aria-label = "Distribution metric" >
< button type = "button" data-metric = "tmax" class = "active" > High< / button >
< button type = "button" data-metric = "tmin" > Low< / button >
< button type = "button" data-metric = "feels" > Feels< / button >
< button type = "button" data-metric = "humid" > Humid< / button >
< button type = "button" data-metric = "wind" > Wind< / button >
< button type = "button" data-metric = "gust" > Gust< / button >
< button type = "button" data-metric = "precip" > Precip< / button >
< button type = "button" data-metric = "dsr" > Dry streak< / button >
< / div >
< / div >
< label class = "ct-count-toggle" > < input type = "checkbox" id = "cmp-dist-count" / > Show count< / label >
< / div >
< div id = "cmp-dist-body" class = "cmp-dist-body" > < / div >
< / section >
2026-07-11 02:58:56 +00:00
< / main >
< script src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" > < / script >
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
< script type = "module" src = "compare.js" > < / script >
2026-07-11 02:58:56 +00:00
< / body >
< / html >