This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
A single self-contained HTML page (index-ch.html) showing an interactive choropleth map of
Switzerland’s 26 cantons over a live satellite basemap, with a popup-menu toggle between nine
metrics: average taxable income, life expectancy at birth, total fertility rate, mean annual
temperature, annual precipitation, population density, foreign resident share, unemployment
rate, and a live current-temperature reading fetched client-side from Open-Meteo. The page
supports DE/FR/IT/RM/EN language switching (RM = Rumantsch Grischun, added alongside the other
three Swiss national languages — see the language-toggle note below) and a manual light/dark
theme toggle (with a glow accent
in dark mode). There is no build step, no server-side code, and no package.json — but unlike the
original pure-SVG version of this page, index-ch.html is not fully self-contained anymore:
it fetch()es its own data (map_data_ch.json, ch_cantons_latlon.geojson) at runtime alongside
loading Leaflet (from a CDN) and satellite tiles, so it needs both network access and to be served
over http(s):// — a plain file:// double-click open no longer works at all (browsers block
fetch() of local files under file://; see Architecture and Running/previewing below). It is
deployed via GitHub Pages at https://helmutqualtinger.github.io/Svizzera-Maps/.
An education metric (“PISA results”) was requested and investigated but deliberately not added: the OECD’s PISA study for Switzerland is only published nationally — cantonal “PISA kantonal” add-on results only ever covered 13 of the 26 cantons, were discontinued after 2012, and the EDK’s own PISA-2009 cantonal report explicitly states rankings are not statistically defensible. The natural substitute (BFS’s Maturitätsquote) also turned out to have no clean, complete, single-year, all-26-canton table available. Don’t re-add a PISA/education metric without first re-checking whether either of those has changed.
This repo was split out of a sibling project, Italia-Maps, which has the identical feature set
for Italy’s 107 provinces (index.html there). The two pages were generated by mirroring one
another’s structure line-for-line rather than by sharing code, so they can and do drift
independently — if you’re porting a fix from one to the other, check Italia-Maps/index.html
for the equivalent logic rather than assuming it doesn’t exist here.
There is no build or test command. To preview locally:
python3 -m http.server 8731
# open http://localhost:8731/index-ch.html
A local server is now required, not just preferred — index-ch.html fetches
map_data_ch.json and ch_cantons_latlon.geojson at runtime, and browsers block fetch() of
local files under a plain file:// double-click open (CORS). This is a change from the original
pure-inline-SVG version of this page, which really was fully self-contained.
Everything lives in index-ch.html: CSS in a <style> block, then a <script> block. MAP_DATA_CH
(per-canton stats, plus a legacy path SVG d-string per feature that is no longer read by any
code — see below) and CANTON_GEOJSON (real WGS84 lon/lat polygon/multipolygon boundaries for
all 26 cantons) both start as let ... = null; and get populated by init(), an async function
at the bottom of the script that fetch()es map_data_ch.json and ch_cantons_latlon.geojson in
parallel (Promise.all) at page load, .json()-parses both responses, then does the rest of the
data-dependent setup (building allFeatures, creating the Leaflet map/layers, fitBounds, and
finally calling renderAll() and loadLiveTemperatures()) — all of that used to run synchronously
at the top level before this refactor; it’s now deferred inside init() specifically so it only
runs once real data exists, since running it against null/empty data would throw. On fetch
failure (bad path, offline, non-200 status) init() catches the error, logs it, and replaces the
map container’s content with a plain error message rather than leaving a broken/blank page — check
that error path if this is ever iterated on further. Everything that does not need the fetched
data — I18N, RAMP/rampColor(), all the function declarations (paint(), applyTexts(),
selectCanton(), etc. — safe to define before data exists since JS function declarations are
hoisted and their bodies only run later, by which point init() has populated everything they
close over), theme setup, and all addEventListener registrations on the static HTML controls —
still runs synchronously at script-load time as before, so the UI chrome (dropdown, toggles, theme)
is interactive immediately; only the map itself shows a “Lade Karte…” placeholder (in #map-svg,
overwritten automatically once L.map() initializes there) until the fetch resolves.
The map is a Leaflet map (CSS + JS loaded from unpkg.com, pinned to
leaflet@1.9.4 with a Subresource Integrity hash on both <link> and <script> — this is the
one external CDN dependency the page has) rendered into #map-svg, which is a plain <div>
despite the id (the id predates the Leaflet migration and was kept rather than touching every
CSS/JS reference to it). The base layer is Esri World Imagery satellite tiles
(server.arcgisonline.com, no API key needed, maxNativeZoom: 17 with Leaflet upscaling beyond
that); the 26 cantons are drawn as a semi-transparent L.geoJSON overlay on top of it
(path.canton { fill-opacity: 0.45; }, so the satellite imagery shows through the choropleth
fill). Pan/zoom is bounded to Switzerland: map.setMinZoom() is pinned to whatever zoom
fitBounds() picked at load (so users can zoom in but not back out past the initial view), and
map.setMaxBounds() is the canton layer’s bounds padded by 0.5.
CANTON_GEOJSON was built by converting the same mikpan/ch-maps TopoJSON used for the legacy
pre-projected path strings into real lat/lon GeoJSON (npx topojson-client), then matching
each resulting polygon to a canton abbreviation by testing which canton capital’s coordinates
(from capital_coords_by_canton.json) fall inside it — the same technique the original path
data used, just applied to real coordinates instead of pre-projected pixel ones. See
ch_cantons_latlon.geojson (~366 KB) — regenerate it the same way if the upstream TopoJSON ever
changes. Because the map is Leaflet-driven now, MAP_DATA_CH.features[].path is dead weight kept
only because map_data_ch.json still has it as a data file; nothing in index-ch.html reads it —
don’t spend time re-deriving or “fixing” it if it looks stale.
Leaflet’s own internal panes (tile pane, overlay pane, marker pane, etc.) use z-index values from
200 to 700, and those values are not scoped to the map container unless that container
establishes its own CSS stacking context. #map-svg sets position: relative; z-index: 0;
specifically so those panes stay contained inside it instead of rendering on top of the metric
dropdown menu or the tooltip (both of which live outside #map-svg in the DOM but would
otherwise share the same ambient stacking context). If the map ever starts painting over UI
chrome that should be above it, this is the first thing to check.
Individual canton layers are looked up by paths[acr] (a Leaflet Layer, not a raw DOM node —
call .getElement() to get the underlying <path> for classList operations, e.g. the
highlighted class toggled by selectCanton()/renderRankList()) and colored via
layer.setStyle({ fillColor }) in paint(), not by setting a fill attribute directly.
CSS class and JS identifiers use canton (path.canton, selectCanton()), not province.
Key JS pieces in index-ch.html:
I18N — one object per language (de/fr/it/rm/en) holding every UI string plus a
nested metrics map (income, lifeExpectancy, fertility, temperature, precipitation,
populationDensity, foreignerShare, unemploymentRate, currentTemperature) with each
metric’s label, formatter, and unit label. Adding a metric means adding an entry to metrics
in all five language blocks, plus the matching field on every feature in MAP_DATA_CH, plus
a <button data-metric="..."> in #metric-menu, plus a row in the hardcoded stat-row
block inside selectCanton() with a matching ...RowLabel key in all five I18N blocks (easy
to miss — unlike the tooltip’s secondary lines, which are generated automatically from
Object.keys(L.metrics), the side-panel detail rows in selectCanton() are hand-listed one
<div class="stat-row"> per metric). Adding a language means adding a new top-level key with
every field the other four have. Number formatting uses the -CH locale variants
(de-CH/fr-CH/it-CH/en-CH), so large numbers render with Switzerland’s apostrophe
thousands separator (e.g. CHF 110'007) rather than a period or comma; income is prefixed
CHF . The rm block is the one exception: 'rm-CH' resolves in browsers’ Intl to a bare
de fallback with a period separator, not the Swiss apostrophe one, so its fmt functions and
the taxpayersLabel locale ternary deliberately use 'de-CH' instead — verify this again if
ICU/browser locale data for Romansh ever improves, since 'rm-CH' would then be more correct.currentLang — persisted to localStorage['lang'], defaulting to the browser’s language if
it’s one of de/fr/it/en. t() returns the active language’s I18N entry;
applyTexts() pushes it into the DOM (labels, placeholders, footer, <title>, lang attr).RAMP / rampColor() — a diverging red→neutral→blue color scale (bad→good) shared by all
metrics. INVERT_METRICS (temperature, currentTemperature, unemploymentRate) flips which
end of the ramp a high value maps to: temperature is inverted since “high = good” doesn’t hold
for it the way it does for income/life expectancy/fertility; unemploymentRate is inverted so
a high unemployment rate lands on the same “red” end of the ramp a low income would, i.e.
inverted to read as an economic-hardship indicator with the same polarity as income, not
because “high = bad” is asserted as objectively true for every metric (foreignerShare and
populationDensity, for instance, are deliberately left un-inverted — descriptive magnitudes
with no inherent good/bad valence, same treatment fertility/density already had). colorFor()
and the legend gradient (LEGEND_GRADIENT / LEGEND_GRADIENT_REV) both consult it. The
rank-view legend labels (rankNumLabel) are intentionally invert-independent — rank 1 and rank
N always sit on the same left/right side of the bar regardless of INVERT_METRICS, only the
color at each end flips.paint() — recomputes stats for the active metric and repaints canton fills, legend, stat
cards, and the rank list. Called on every metric/view/language toggle.currentView ('value' vs 'rank') — switches coloring between raw value and percentile
rank, independent of which metric is selected.#metric-dropdown / #metric-trigger / #metric-menu),
not a segmented button row — opens on trigger click, closes on outside click, Escape, or item
selection. The Wert/Rang (value/rank) toggle is still a two-button segmented control.#lang-toggle mixes two button styles: four emoji-flag buttons (🇩🇪🇫🇷🇮🇹🇬🇧, sized via the
plain #lang-toggle button rule) and one plain-text button for Romansh (RM, class="lang-text"
on top of that, with its own smaller/bold font rule) — there is no real Romansh national flag to
use as an emoji, and CH’s own flag would be confusing since it’s already the whole page’s
subject, so a text abbreviation was used instead, matching how admin.ch’s own language switchers
handle this. If you add a 6th language with the same problem, follow the lang-text pattern
rather than picking an inaccurate flag.rm) translation was produced by researching real Rumantsch Grischun usage
(Wikipedia RM edition, BFS’s own RM pages, Pledari Grond) rather than translating from scratch,
but not every phrase in it is source-verified — the demographic/geographic core (population
density, life expectancy, temperature, precipitation, canton, the Zürich/Ticino/Zentralschweiz/
Ostschweiz region names) is confirmed against real RM text; the economic/statistics-agency
vocabulary (income, unemployment rate, “seasonally adjusted”, the Espace Mittelland/
Genferseeregion region names) is grammatically well-formed but constructed, not confirmed
against a real source. Treat it as good-faith best-effort, not verified-correct — a native
speaker or Lia Rumantscha review would be worth doing before leaning on it as authoritative.theme persisted to localStorage['theme'], defaulting to prefers-color-scheme.
Applied via data-theme="light"|"dark" on <html>; the dark-mode CSS custom properties
--glow/--glow-strong drive the glow accents (transparent in light mode, so the same
box-shadow/filter rules are safe in both themes). Do not add a CSS transition on
box-shadow for glow-bearing elements — Chromium fails to animate a box-shadow whose value is
entirely var()-driven and gets stuck at the pre-toggle value (confirmed via
getComputedStyle); the glow is applied instantly instead.currentTemperature (“Temperatur jetzt”) is the one metric fetched live, client-side, from
Open-Meteo’s forecast API — everything else is baked into MAP_DATA_CH at data-prep time.
Each feature carries lat/lon (the canton capital’s geocoded coordinates);
loadLiveTemperatures() batches all 26 into a single Open-Meteo request
(?latitude=lat1,lat2,…&longitude=lon1,lon2,… — Open-Meteo returns one array entry per
coordinate pair, in request order) rather than 26 separate calls, both for speed and so the
free API doesn’t rate-limit/block the page. The result is cached in
localStorage['svizzeraMaps.liveTemp.v1'] for LIVE_TEMP_TTL_MS (1 hour); a fresh fetch only
happens once that expires. On fetch failure it falls back to a stale cache if one exists,
otherwise leaves whatever value is already in memory alone — never crashes or blanks the map.
Every feature’s currentTemperature is seeded to its static temperature (the historical
average) at page load specifically so nothing in paint()/metricStats()/colorFor() ever
has to null-check it; the live fetch just overwrites that placeholder once it lands, then calls
paint() again if that metric is active. updateLiveStatus() (called from paint()) shows a
loading-vs-updated-at-HH:MM string next to the toolbar (#live-status) whenever this metric is
selected. If you add another live-fetched metric, follow the same seed-with-a-static-fallback
pattern rather than threading null checks through the shared rendering code — that’s what makes
one async metric coexist with six synchronous ones without special-casing everywhere.
The cache key is namespaced (svizzeraMaps..., not italiaMaps...) deliberately: several
canton abbreviations collide with Italian province sigle that mean something completely
different (AG, AR, BS, FR, GE, GR, LU all exist on both maps), so sharing a cache
key with Italia-Maps would silently apply, say, Ticino’s live temperature to Italy’s
Agrigento.region field holds the BFS “Grossregion” (Zürich, Espace Mittelland,
Nordwestschweiz, Zentralschweiz, Ostschweiz, Genferseeregion, Ticino) — the official
NUTS-2-equivalent grouping of cantons.MAP_DATA_CH.width/.height (760/492, Switzerland’s landscape-oriented bounding box in
the old pre-projected pixel space) are leftover from the pre-Leaflet SVG renderer and are not
read by any code anymore — same status as MAP_DATA_CH.features[].path above.Two of these are fetched by index-ch.html itself at runtime (see Architecture above); the rest
are human-readable source data and provenance only — edit them for reference, but nothing reads
them automatically.
Fetched at runtime, directly, no build step:
map_data_ch.json — fetched by init() and assigned to MAP_DATA_CH (legacy canton SVG paths
lat/lon per canton). Edit this file directly to change a static
metric value or add a canton field — no splice/regeneration step needed anymore, the page just
re-fetches it on next load. currentTemperature is not in here — it’s fetched live separately
(see above).ch_cantons_latlon.geojson — fetched by init() and assigned to CANTON_GEOJSON (real WGS84
lon/lat canton boundaries used by the Leaflet map — see Architecture above for how it was
built). {acr, name} properties only; join against MAP_DATA_CH by acr for stats.Source/provenance only, not read by the page:
income_by_canton.json, life_expectancy_by_canton.json, fertility_by_canton.json,
temperature_by_canton.json, precipitation_by_canton.json, density_by_canton.json,
population_by_canton.json, foreigner_share_by_canton.json, unemployment_by_canton.json —
one metric each (population is the raw input density was computed from, not a metric on the
map itself), keyed by canton abbreviation (e.g. "ZH", "TI"), as pulled from source.capital_coords_by_canton.json — {acr: {lat, lon}} for each canton capital, geocoded via
Open-Meteo’s geocoding API (geocoding-api.open-meteo.com/v1/search) and disambiguated by
matching the returned admin1 region against the canton. Folded into map_data_ch.json’s
lat/lon fields and queried by the live-temperature fetch — re-geocode from here if you need
to regenerate map_data_ch.json. Note Sion’s geocoding result only appears without a
language query parameter (the German-language search returns no Swiss result for it).og-image-source.html / og-image.png — the Open Graph / Twitter Card preview image (a
1200×630 static HTML card with an income-colored mini map of the cantons, screenshotted to
size). Regenerate both if the color ramp or headline copy changes — there is no build script;
re-derive the colored SVG paths from map_data_ch.json (same RAMP/rampColor() formula as
index-ch.html, income metric, not inverted) and re-screenshot at exactly 1200×630, e.g.:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--headless=new --window-size=1200,630 \
--screenshot=og-image.png "file://$(pwd)/og-image-source.html"
estv.admin.ch/estv/de/home/die-estv/steuerstatistiken-estv/allgemeine-steuerstatistiken/direkte-bundessteuer/dbst-np-kanton-ab-1983.html).
Total taxable income (Steuerbares Einkommen, summed across both the “mit Belastung” and “ohne
Belastung durch die direkte Bundessteuer” taxpayer groups) ÷ total number of taxpayers in that
same combined group. 2019 is the most recent tax period for which ESTV publishes this
per-canton “Tabelle 1a” breakdown in this series; later years (2020–2022) are on the same ESTV
page but under differently-named table variants that don’t expose the same clean
totals-with-and-without-liability split, so 2019 was used for consistency across all 26
cantons rather than mixing vintages.de.wikipedia.org/wiki/Liste_der_Schweizer_Kantone_nach_Lebenserwartung,
itself sourced from BFS).dam-api.bfs.admin.ch/hub/api/dam/assets/36142182/master, sources BEVNAT/ESPOP/STATPOP). Uses
the 2024 value (the most recent year with data for all 26 cantons); 2023 values are the natural
fallback if this table is ever re-pulled and 2024 is missing for some cantons.data.geo.admin.ch/ch.meteoschweiz.klima/normwerte/normwerte.zip, files tre200m0 and
rre150m0). MeteoSwiss’s normal-values network doesn’t have a station in every canton capital,
so a nearest-available substitute station was used where needed — clearly worth checking before
reusing this data:
de.wikipedia.org/wiki/Kantone_der_Schweiz (itself BFS-sourced: ESPOP quarterly population
estimate and BFS/swisstopo land area figures). Heavily right-skewed (Basel-Stadt ≈5,450/km² vs.
Graubünden ≈29/km²) — the “Rank” view reads much better than “Value” for this metric.api.open-meteo.com/v1/forecast, current=temperature_2m),
fetched live in the visitor’s browser — see the loadLiveTemperatures() note above.
Coordinates are the canton-capital points from capital_coords_by_canton.json.foreignerShare, “Ausländeranteil”): BFS PxWeb table
px-x-0102010000_101 (“Ständige und nichtständige Wohnbevölkerung nach Jahr, Kanton,
Bevölkerungstyp, Staatsangehörigkeit, Geschlecht und Alter”,
pxweb.bfs.admin.ch/api/v1/de/px-x-0102010000_101/px-x-0102010000_101.px), filtered to 2024,
permanent resident population (“Ständige Wohnbevölkerung”), foreign nationality ÷ total, ×100.
This is nationality-based, not birth-based — naturalized Swiss citizens count as Swiss, matching
how the figure is conventionally reported in Swiss media/BFS releases. Not inverted in
INVERT_METRICS (see the RAMP/INVERT_METRICS bullet above for why).unemploymentRate, “Arbeitslosenquote”): SECO (Staatssekretariat für
Wirtschaft), “Die Lage auf dem Arbeitsmarkt” monthly release, Tabelle 10
(“Arbeitslosenquote nach Kantonen”), June 2026 figures — the same monthly series that feeds
amstat.ch. This is the registered/administrative unemployment rate (registered unemployed ÷
resident labor force per canton), not the broader ILO survey-based rate (which SECO only
publishes nationally). Not seasonally adjusted — if this data is ever refreshed, prefer
comparing the same month year-over-year rather than mixing months. VD/GE/SH/NE include people
also drawing cantonal unemployment/social assistance in their denominator per SECO’s own
footnote; excluding them would lower those four cantons’ rates by 0.1–0.5 points. Inverted in
INVERT_METRICS (see the RAMP/INVERT_METRICS bullet above for why).mikpan/ch-maps GitHub repository (topo/ch-cantons.json). Two different
derived products are baked from this same source file, independently: (1) the legacy SVG
path strings in map_data_ch.json/MAP_DATA_CH — simplified with mapshaper (~25% point
retention) and re-projected (equirectangular, cos-latitude corrected) into pixel space; these
are no longer read by any code (see Architecture above); (2) ch_cantons_latlon.geojson/
CANTON_GEOJSON — the same TopoJSON converted straight to real WGS84 lon/lat GeoJSON via
npx topojson-client (no simplification, full precision), which is what the live Leaflet map
actually renders. Both products independently identify each polygon by testing whether that
canton’s known capital coordinates (capital_coords_by_canton.json) fall inside it, rather than
trusting the source file’s numeric id (1–26, the standard BFS canton numbers, 1=Zürich …
26=Jura) blindly — the two checks have always agreed when done, but re-verify if you ever
re-derive either product from a different upstream source.MAP_DATA_CH and CANTON_GEOJSON are not inlined into index-ch.html anymore (that was an
earlier design, before this section was rewritten — see git history if you need the old
splice-based regeneration recipe) — init() fetches map_data_ch.json and
ch_cantons_latlon.geojson directly at page load. This means updating the data is now just:
map_data_ch.json directly (add a metric field to every one of the 26 features, refresh a
value, etc.) — and update the matching standalone *_by_canton.json source file for
provenance, same as before.ch_cantons_latlon.geojson only needs regenerating if the upstream mikpan/ch-maps boundaries
themselves change (see the Canton boundaries provenance entry above for how it was built) —
otherwise leave it alone.