This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
A single self-contained HTML page (index-at.html) showing an interactive choropleth map of
Austria’s 94 political districts (politische Bezirke, including Vienna as one Statutarstadt
and the 23 Statutarstädte/Bezirke of the other states), with a popup-menu toggle between eleven
metrics: average net income of employees, life expectancy at birth, total fertility rate, mean
annual temperature, annual precipitation, population density, a live current-temperature
reading fetched client-side from Open-Meteo, the strongest party in the 2024 Nationalratswahl
(National Council election), the unemployment rate, the foreign-population share, and a
maths education-standards score (published only per Bundesland, not per district — see
“Data provenance” below). The page supports DE/IT/EN language switching 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 — the page is opened directly in a browser or served as a
static file. It is deployed via GitHub Pages at https://helmutqualtinger.github.io/Austria-Maps/.
This repo mirrors the structure of two sibling projects, Italia-Maps (107 Italian provinces)
and Svizzera-Maps (26 Swiss cantons), at the next level down in feature/place count. All three
were generated by mirroring each other’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 others, check the
sibling repos 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 8732
# open http://localhost:8732/index-at.html
(file:// URLs work for a plain double-click open, but some browser extensions/tools can’t
navigate to file://, so prefer a local server when using browser automation.)
Everything lives in index-at.html: CSS in a <style> block, then a <script> block that
defines MAP_DATA_AT (district boundaries + per-district stats) inline as a JSON literal,
followed by the rendering/interaction logic. There is no framework, no build tool, and no
external CDN dependency — SVG district shapes are pre-projected into path d-strings at
data-prep time (see below), not computed in the browser. CSS class and JS identifiers use
district (path.district, selectDistrict()), matching canton in Svizzera-Maps and
province in Italia-Maps.
Key JS pieces in index-at.html (same pattern as the sibling repos):
I18N — one object per language (de/it/en) holding every UI string plus a nested
metrics map (income, lifeExpectancy, fertility, temperature, precipitation,
populationDensity, currentTemperature, strongestPartyShare, unemploymentRate,
foreignerShare, educationScore) with each metric’s label,
formatter, and unit label. Adding a metric means adding an entry to metrics in all three
language blocks, plus the matching field on every feature in MAP_DATA_AT, plus a
<button data-metric="..."> in #metric-menu. Adding a language means adding a new top-level
key with every field the other two have. Number formatting uses the -AT locale variants
(de-AT/it-AT/en-AT); income is suffixed/prefixed with € (Austria uses the euro, unlike
Svizzera-Maps’ CHF).currentLang — persisted to localStorage['lang'], defaulting to the browser’s language if
it’s one of de/it/en. t() returns the active language’s I18N entry; applyTexts()
pushes it into the DOM (labels, placeholders, footer, <title>, lang attr).RAMP / rampColor() — the identical diverging red→neutral→blue color scale (bad→good) used
by both sibling repos. INVERT_METRICS (currently just temperature) flips which end of the
ramp a high value maps to; colorFor() and the legend gradient both consult it.strongestPartyShare (label “Stärkste Partei”) is the one categorical metric — every other
metric is a continuous number colored by rampColor(), but this one colors each district by
which party won it. CATEGORICAL_METRICS marks it as such; colorFor() short-circuits to
PARTY_COLORS[f.strongestParty] for it instead of computing a ramp ratio. The “value” backing
the generic stats/rank/search machinery is f.strongestPartyShare (the winning party’s vote
share, a plain number, so metricStats() needs no special-casing), while f.strongestParty
(the party abbreviation, e.g. "FPÖ") drives both the swatch color and the display text via
the partyDisplay(f) helper ("FPÖ · 31,0 %"). paint() swaps the gradient #legend for a
#party-legend of per-party swatch+count chips when this metric is active. PARTY_COLORS only
has entries for parties that actually won at least one district in the source data (currently
FPÖ/ÖVP/SPÖ) — add an entry there if a data refresh ever produces a district won by another
party, or colorFor() will return undefined for it.paint() — recomputes stats for the active metric and repaints district 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. The Wert/Rang (value/rank) toggle is a two-button segmented control.theme persisted to localStorage['theme'], defaulting to prefers-color-scheme.
Applied via data-theme="light"|"dark" on <html>. 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; the glow is applied instantly.currentTemperature (“Temperatur jetzt”) is the one metric fetched live, client-side, from
Open-Meteo’s forecast API — everything else is baked into MAP_DATA_AT at data-prep time. Each
feature carries lat/lon (the district capital’s geocoded coordinates); loadLiveTemperatures()
batches all 94 into a single Open-Meteo request rather than 94 separate calls. The result is
cached in localStorage['austriaMaps.liveTemp.v1'] for LIVE_TEMP_TTL_MS (1 hour). On fetch
failure it falls back to a stale cache if one exists, otherwise leaves whatever value is
already in memory alone. Every feature’s currentTemperature is seeded to its static
temperature at page load so nothing in paint()/metricStats()/colorFor() ever has to
null-check it.region field holds the Bundesland (state) name.0 0 814 420 (Austria’s landscape-oriented bounding box, wider even than
Switzerland’s).SAT_IMG (defined right after RAMP, before MAP_DATA_AT) is a base64 JPEG data URI —
a satellite basemap rendered behind the district <path>s as an <image id="satellite-basemap">,
sized x=0 y=0 width=814 height=420 preserveAspectRatio="none". path.district carries
fill-opacity: 0.25 in CSS so the imagery shows through the metric colors; this does not
touch colorFor() itself, so the rank-list swatches and the party-legend chips (which also call
colorFor()/PARTY_COLORS but render as plain <span>s, not path.district) stay fully
opaque. The image was fetched once at data-prep time (not live — terrain doesn’t change) from
Esri’s World_Imagery/MapServer/export endpoint with bboxSR=4326&imageSR=4326 (plate-carrée,
not Web Mercator) and re-encoded at JPEG quality 70 to keep the embed size down. The bbox used —
lon 9.4589–17.3576, lat 46.3308–49.0747 — is not Austria’s literal bounding box; it’s the
inverse of an affine transform fitted (ordinary least squares, px = a·lon + b, py = c·lat + d)
from all 94 districts’ (lat, lon) capital coordinates against their SVG path centroids, i.e.
the exact lon/lat rectangle that the existing cos-latitude-corrected equirectangular projection
(see “District boundaries” below) maps onto the 0..814 × 0..420 viewBox. Fetching the image in
raw (uncorrected) plate-carrée and then stretching it non-uniformly to width=814 height=420
reproduces that same cos-latitude scaling for free — no need to bake the correction into the
fetch itself. If MAP_DATA_AT’s paths are ever re-projected or re-simplified, redo this
regression (fit against the new path centroids) and re-fetch SAT_IMG for the new bbox, or the
basemap will drift out of alignment with the district borders.index-at.html at runtime)index-at.html is fully self-contained — the JSON files below are kept as human-readable source
data and provenance, but the page does not fetch them:
map_data_at.json — the full dataset embedded in index-at.html (district SVG paths + all
static metrics + lat/lon per district, keyed by BKZ — Statistik Austria’s official
district code, e.g. "101" for Eisenstadt (Stadt), "900" for Wien). currentTemperature is
not in here — it’s fetched live at runtime.income_by_district.json, life_expectancy_by_district.json, fertility_by_district.json,
temperature_by_district.json, precipitation_by_district.json, density_by_district.json,
population_by_district.json, nrw2024_strongest_party_by_district.json,
unemployment_by_district.json, foreigner_share_by_district.json — one metric each, keyed
by BKZ. nrw2024_strongest_party_by_district.json holds {party, sharePct, partyFullDe} per
district for the 2024 Nationalratswahl.education_ikmplus_math_by_bundesland.json — the one metric keyed by Bundesland name
(region), not by BKZ, since it has no district-level granularity (see “Education standards
(maths)” under Data provenance below). Every district’s educationScore in
map_data_at.json is a straight copy of its Bundesland’s entry here.capital_coords_by_district.json — {bkz: {lat, lon}} for each district’s Bezirkshauptort
(or the Statutarstadt itself), geocoded via Open-Meteo’s geocoding API
(geocoding-api.open-meteo.com/v1/search), disambiguated by matching the returned admin1
against the district’s Bundesland. Two seats (Hermagor-Pressegger See → geocoded as
Hermagor; Rohrbach-Berg → geocoded as Rohrbach in Oberösterreich) needed their
pre-2015-merger municipality name since Open-Meteo’s geocoder doesn’t know the merged names.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 districts, 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_at.json (same RAMP/rampColor() formula as
index-at.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"
wko.at/oe/statistik/dashboards/dashboard-bezirksdaten-1.0.xlsx), sheet Daten, indicator
Arbeitnehmer / Netto_Schnitt (average net annual income of employees, ultimately from
Statistik Austria’s Lohnsteuerstatistik), most recent year 2024. Wien is a special case:
the WKO dashboard has no aggregate row for Vienna as a whole — only its 23 Gemeindebezirke
(BEZ-AT901…BEZ-AT923). Vienna’s income and taxpayers (the Arbeitnehmer /
Bruttobezüge Fall headcount) are computed as the case-weighted average/sum across those 23
sub-districts. If you refresh this metric, re-download the dashboard xlsx (it’s regenerated
periodically by WKO with the same URL) rather than assuming last session’s copy is current.Lebenserwartung_bei_der_geburt_2024_nach_regionalen_gliederungen.ods
(from the Sterbetafeln page under “Demographische Indikatoren und Tafeln”), section
“Politische Bezirke (inkl. Wiener Gemeindebezirke)” — this is the one dataset where Statistik
Austria does publish true political-district-level detail, separately for men and women;
lifeExpectancy in map_data_at.json is the simple average of the two (matching the “average
of both sexes” convention used by Svizzera-Maps). Rust (BKZ 102) has no reported value —
Statistik Austria suppresses life expectancy for populations under 5,000 — so it’s substituted
with the value from neighboring Eisenstadt-Umgebung (BKZ 103), documented inline in the
page’s footer text; if you regenerate this metric and Rust’s population later exceeds that
threshold, drop the substitution. Wien is again a special case: no single Vienna-wide row
exists in this table either, only its 23 Gemeindebezirke, so Vienna’s value is a
population-weighted average across them (weights from the WKO dashboard’s Bevölkerung /
Insgesamt indicator for those same 23 codes).Kap_H_Fertilitaet_2024.ods (from the “Demographische
Querschnittsindikatoren” page), sheet H10, “Politische Bezirke (inkl. Wiener
Gemeindebezirke)” — total fertility rate (Gesamtfertilitätsrate), 2024. Unlike life
expectancy, every district including Rust has a reported value here, so no substitution was
needed. Wien is again computed as a population-weighted average of its 23 Gemeindebezirke
(same weights as life expectancy), since this table also has no whole-city Vienna row.archive-api.open-meteo.com/v1/archive, ERA5-Land
reanalysis), daily=temperature_2m_mean,precipitation_sum, for each district capital’s
coordinates, 2015-01-01 to 2024-12-31: temperature is the mean of each year’s daily-mean
average across those 10 years, precipitation is the mean of each year’s annual sum. This is
a reanalysis-grid estimate at the capital’s exact coordinates, not a station reading — treat it
as indicative regional climate, not an official normal. If GeoSphere Austria later publishes a
usable bulk district-level climate-normals dataset, prefer that and update this section
accordingly (see how the Svizzera-Maps/Italia-Maps sibling repos handle official
station-based normals with documented nearest-station substitutions, for the target
methodology). The archive API supports comma-separated multi-coordinate requests (like the
live-temperature forecast endpoint) but is rate-limited more aggressively for large date
ranges — fetch in chunks of ~16 locations with a delay between chunks and retry-with-backoff on
the “Minutely API request limit exceeded” error if you regenerate this.Bevölkerung /
Insgesamt indicator (Statistik Austria population register, most recent available year, 2026
for most districts) ÷ district land area. Area comes from German Wikipedia’s
“Liste der Bezirke und Statutarstädte in Österreich” table (itself Statistik Austria-sourced,
“Stand: 1. Jänner 2024”), fetched via the Wikipedia API’s action=parse (not action=raw —
the area/population/density cells are populated by Wikidata-backed templates like ``
that only resolve in the rendered HTML, not the raw wikitext) and parsed with BeautifulSoup
(not pandas.read_html, which mis-parses the German decimal-comma numbers in this particular
table’s cell markup and silently drops digits). District identity across all these sources is
matched by BKZ (Statistik Austria’s official Bezirkskennziffer); Wikipedia’s table uses 901
for Wien where the boundary GeoJSON and the WKO dashboard use 900 — remap that if you
re-fetch from Wikipedia.api.open-meteo.com/v1/forecast, current=temperature_2m),
fetched live in the visitor’s browser — see the loadLiveTemperatures() note above.
Coordinates are the district-capital points from capital_coords_by_district.json.bundeswahlen.gv.at/2024/nr/{BKZ}.html, parsed from the exact vote-count table (not the
rounded percentage text — several districts, e.g. Amstetten/305 and Gmünd/309, are close enough
that rounded percentages tie and only the exact counts break it). Wien is a special case
again: this site has no single BKZ-900 district page for Vienna (it only breaks Vienna down by
its 23 Gemeindebezirke), so strongestParty/strongestPartyShare for Wien come from the same
site’s Bundesland-Wien aggregate page instead — the citywide combined result, consistent with
how this repo already treats Vienna as one aggregate district for its other metrics. Nationally
FPÖ placed first for the first time in the party’s history (52 of 94 districts), ÖVP second (34
districts), SPÖ third (8 districts, including Wien) — no other party won a district. If you
refresh this after a future election, re-derive PARTY_COLORS coverage (see above) in case a
district ever goes to a party outside that set.Jahr2025 of
001_amd-polbez_monate_2025.xlsx (ams.at, “Berichte und Auswertungen” → “Arbeitsmarktdaten
nach politischen Bezirken”, a new file published yearly at the same URL pattern with the year
swapped in), Jahresdurchschnitt (annual average) 2025, “Gesamt” Arbeitslosenquote column. This
table already reports Wien as a single BKZ-900 row (unlike income/life-expectancy/fertility/
strongest-party) — no Vienna aggregation needed. Values are a plain percentage
(unemploymentRate), and this is the one metric so far where a high raw value is treated as
the “bad” end of the ramp (INVERT_METRICS.unemploymentRate = true).Staatsangehörigkeit of
Bev_Staatsangeh_Geburtsland_Gebietseinheiten_2026.ods (from the “Bevölkerung nach
Staatsangehörigkeit/Geburtsland” page), section “Politische Bezirke bzw. Wiener
Gemeindebezirke”, population at 1 January 2026. foreignerShare = (Bevölkerung insgesamt −
Österreich) ÷ Bevölkerung insgesamt, i.e. the share holding a non-Austrian citizenship. Wien
is a special case again: this table only has Vienna’s 23 Gemeindebezirke (BKZ 901–923), no
BKZ-900 row, so Wien’s value is the sum of foreigners across those 23 rows ÷ their summed
total population (a plain aggregate ratio, not population-weighted-average of percentages,
since the underlying counts are already available). Not inverted — like populationDensity,
this is treated as a magnitude, not a value judgment.Bundesergebnisbericht_iKMPLUS_PRIM_2023-2025.pdf
(“Abb. 17: Punkte in Mathematik”, the “Verteilung nach Bundesland” table), mean maths
competency score (points) at the end of the 4th grade (Volksschule), iKMPLUS assessment cycle
2023–2025. This is the one metric in this repo published only at Bundesland (state)
granularity — nine values total, not 94. PISA results were the original ask, but PISA in
Austria is a national sample (~6,200 students, ~300 schools) with no sub-national breakdown at
all; the national Bildungsstandards/iKMPLUS census-based assessment was substituted instead
since every student is tested (not sampled), but its public reporting still stops at
Bundesland level, not district. Per explicit user instruction, every district’s
educationScore is simply its Bundesland’s value, duplicated across all districts sharing
that region — this is documented in-page (footer text, all three languages) so it’s not
mistaken for genuine district-level data. If IQS or a successor body ever publishes a
district-level breakdown, prefer that and drop the Bundesland-broadcast approach.bezirke_95_geo.json from the ginseng666/GeoJSON-TopoJSON-Austria
GitHub repository (2021/simplified-95/), itself derived from Austrian BEV administrative
boundary data, WGS84 geographic coordinates. That source file has 117 features, not 94: it
includes both a whole-city "Wien(Stadt)" polygon (iso: "900") and Vienna’s 23 individual
Gemeindebezirke ("Wien 1.,Innere Stadt" etc.) as separate features. This repo keeps only the
94 real political districts — the whole-Vienna polygon (900) plus the 93 non-Vienna
districts/Statutarstädte — and drops the 23 Gemeindebezirke-as-separate-map-features (they’re
still used as an intermediate for computing Vienna’s income/life-expectancy/fertility values,
see above, just not rendered as their own map shapes). Re-projected (equirectangular,
cos-latitude corrected at Austria’s mean latitude ≈47.7°N, same method as the sibling repos)
and simplified (Shapely simplify(0.003, preserve_topology=True), chosen to land at a similar
points-per-feature density as map_data.json’s province paths) into the SVG paths baked into
map_data_at.json.If you need to re-fetch or extend a metric from Statistik Austria: their OGD (Open Government
Data) catalog (data.statistik.gv.at/web/catalog.jsp) does not currently include a bulk
district-level life-expectancy or fertility dataset — both had to be pulled from the “regionale
Gliederungen” annex tables of the Sterbetafeln/Fertilität .ods releases instead (see above),
which are not part of the OGD catalog. Check that catalog first for anything new, but don’t
assume a district-level table exists there just because a Bundesland- or NUTS-level one does.
index-at.html from the JSON filesThere is no script in this repo that does this automatically — past sessions injected updated
map_data_at.json content into index-at.html via a one-off Python snippet that replaces the
const MAP_DATA_AT = …; literal in place (matching from "const MAP_DATA_AT = " to the
following ";\n\nfunction hexToRgb" — the embedded data blob sits between RAMP and the
hexToRgb/rgbToHex/rampColor/I18N block: RAMP first, then MAP_DATA_AT, then
everything else, the same relative order as the sibling repos use for RAMP/MAP_DATA_CH/
MAP_DATA). If you add a new metric or refresh a data year, update map_data_at.json first,
then re-run an equivalent replace rather than hand-editing the embedded JSON blob.