Svizzera-Maps

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

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.

Running / previewing

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.

Architecture

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:

Data files

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:

Source/provenance only, not read by the page:

Data provenance

Updating the data

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:

  1. Edit 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.
  2. Reload the page. No regeneration/splice step, no Python snippet — the fetched file is the data.

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.