Classify each county by area-weighted Köppen class shares: a county is predominantly its top class when that class covers at least 50% of its land and leads the runner-up by at least 5 percentage points; otherwise it is Mixed (133 of 3,143 counties in the 50 states and DC). - Add build_county_koppen_metric.py (writes data/metrics/koppen.csv) and apply_koppen_metric_to_climate_data.py (writes koppenZone plus koppenPrimaryClass/koppenSecondaryClass for Mixed counties). - Move shared helpers into scripts/common/ (county loading, Köppen legend, area-weighted raster shares); fix the 180th-meridian raster window for Aleutians West. - Add check_climate_data.py to validate the app CSV. - Draw Mixed counties in app.js as diagonal stripes of their top two classes, fixed to the ground and following the map at every zoom, with a crossfade only when the stripe size changes. Filtering a class also matches Mixed counties where it is primary or secondary. - Document the rule, display, and pipeline plan in docs/ and update the README and data-source notes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 KiB
Mixed Climate Display
Status (2026-09-14): Complete. The app draws Mixed counties as stripes, the
pipeline writes the two stripe-class columns, and data/climate-data.csv has
been updated (142 counties Mixed, 133 of them in the 50 states and DC). The
display was reviewed in the browser and refined (see the history in section 7),
and the project documentation was updated (section 6).
Scope: Show Köppen-Geiger "Mixed" counties as striped on the map, filter them by their top two classes, and carry the two stripe classes from the pipeline into the app. The classification rule itself is described in filter-calculations.md §1; overall progress is tracked in pipeline-plan.md.
1. Design
- Only Mixed counties are striped. A county is Mixed when its top class covers less than 50% of its land or leads the runner-up by less than 5 percentage points. Every other county keeps its solid class color.
- Stripe colors are the county's top class (primary) and runner-up class (secondary), using the existing Köppen class colors.
- Stripes are diagonal at 45°, from upper left to lower right, and run unbroken to the county boundary. Neighboring Mixed counties share the same angle and spacing, so stripes line up across borders. The county border is drawn on top.
- Stripes follow the map. The density is exact at the default view
(
DEFAULT_COUNTRY_VIEW, the project's single reference scale). From there the stripe pair width doubles with each zoom-in step, all the way to the map's deepest zoom (19), so the number of stripes in a county stays the same at every zoom. - Far-out style. When stripes would drop below 5 pixels per pair (zoom 4 and below), the map-following width is doubled until it reaches 5 pixels (at least once, so there are half as many lines), giving 8.53-pixel pairs split 70/30 so the secondary stays visible.
- Stripes are fixed to the ground. Each pattern is anchored to the map projection's fixed origin, so a stripe stays on the same ground at every zoom from 5 to 19. At the far-out zooms every other stripe stays put while the crossfade blends the rest.
- Fades happen only when the stripe size changes (the switch between the standard and far-out styles, and zoom steps within the far-out range). The fade is a 250 ms true crossfade that runs just after Leaflet's zoom animation: the old stripes, exactly as the animation left them, blend into the new ones with no jump and no gap.
- Filtering. Choosing a class shows counties that are predominantly that class plus Mixed counties where it is the primary or secondary class. Third and lower classes do not match. One "Mixed Climate" option shows all Mixed counties; there are no per-pair Mixed options.
- Every class on the map is listed. Classes that appear only as stripe colors (today Dsc and Dwc, both in Alaska) are listed in the dropdown and legend as regular classes.
- Legend. The "Mixed Climate" row has a swatch of two neutral grays in the standard split (lighter gray primary, darker gray secondary), generated from the split setting so it always matches the map, and a note that stripes show each county's top two classes.
- Deferred: a donut chart of every class share (like Europa Universalis 5).
Settings
All stripe settings sit together near the top of app.js:
| Setting | Value | Controls |
|---|---|---|
KOPPEN_MIXED_PRIMARY_STRIPE_FRACTION |
0.8 | Primary share of each stripe pair, standard style (zoom 5 and closer) |
KOPPEN_MIXED_LINE_PAIRS_PER_100_MILES |
5 | Stripe pairs per 100 miles, measured across the stripes at the default view |
KOPPEN_MIXED_MIN_PAIR_WIDTH_PX |
5 | Below this pair width, stripes switch to the far-out style |
KOPPEN_MIXED_FAR_PRIMARY_STRIPE_FRACTION |
0.7 | Primary share of each stripe pair, far-out style |
KOPPEN_MIXED_STYLE_FADE_MS |
250 | Length of the crossfade |
KOPPEN_MIXED_STRIPE_ROTATION_DEGREES |
-45 | Stripe angle (SVG rotation; -45 runs upper left to lower right) |
KOPPEN_MIXED_LEGEND_PRIMARY_GRAY, KOPPEN_MIXED_LEGEND_SECONDARY_GRAY |
#d4d8df, #6b7383 |
Legend swatch colors |
2. Data
data/climate-data.csv has two columns directly after koppenZone:
| Column | Filled when | Value |
|---|---|---|
koppenPrimaryClass |
koppenZone is Mixed |
Top class, e.g. Csb |
koppenSecondaryClass |
koppenZone is Mixed |
Runner-up class, e.g. Dsb |
Both are blank for predominant counties. The values come from koppenTopClass
and koppenSecondClass in data/metrics/koppen.csv.
3. How the stripes are drawn
The map uses Leaflet 1.9.4 with its default SVG renderer. Each ordered
primary/secondary pair gets one SVG <pattern>, kept in a small hidden SVG added
to the page; fill references such as url(#id) resolve anywhere in the page, so
Leaflet's own SVG elements are not modified. The 133 Mixed counties in the
50 states and DC use 50 pairs. Order matters: Csb/Dsb and Dsb/Csb give the
primary share to different colors.
- Pattern contents. A primary-color background and a group of secondary-color bands. Normally the group has one band per stripe pair; during a crossfade it holds shared, old-only, and new-only bands.
- Coordinates.
patternUnits="userSpaceOnUse", so every county is filled in the map's coordinate space and stripes align across borders. - Anchoring.
patternTransform="rotate(-45) translate(x y)". Leaflet's SVG coordinates start from a pixel origin that moves on every zoom, so the translate places the pattern's origin at the map projection's fixed origin, reduced to within one tile to avoid precision loss at deep zooms. - Fill. A Mixed county's style sets
fillColor: "url(#koppen-mixed-<primary>-<secondary>)".
Pair width at the default view. Leaflet uses Web Mercator, where
\text{meters per pixel} = \frac{156{,}543.03 \times \cos(\text{latitude})}{2^{\text{zoom}}}.
At the default view (latitude 39.5°, zoom 5) that is about 3,775 m per pixel, so 100 miles (160,934 m) is about 42.6 pixels. At 5 pairs per 100 miles, one stripe pair is about 8.5 pixels: about 6.8 pixels of primary color and 1.7 pixels of secondary color. The app computes this from the settings.
| Zoom | Style | Pair width | Split |
|---|---|---|---|
| 2–4 | Far-out | 8.53 px | 70/30 |
| 5 (default) | Standard | 8.53 px | 80/20 |
| 6 | Standard | 17.05 px | 80/20 |
| 7 | Standard | 34.11 px | 80/20 |
| 8 | Standard | 68.2 px | 80/20 |
| 9–19 | Standard | Doubling each step, to 139,704 px at zoom 19 | 80/20 |
Zooming. During Leaflet's zoom animation the whole SVG layer is scaled with the map, so the stripes stretch with it. When the zoom ends, the stripes are recomputed. Because every width is the reference width times a power of two, the old stripes (previous width × 2 to the power of the zoom change) and the new stripes fit one pattern tile. If the size is unchanged, the new layout is drawn directly; if it changed, bands secondary in both layouts stay solid, old-only bands fade out, and new-only bands fade in.
4. App code (app.js)
| Area | Functions and settings |
|---|---|
| Stripe settings | The KOPPEN_MIXED_* constants near the top of the file (section 1) |
| Mixed class and parsing | KOPPEN_CLASS_META (Mixed, labelled "Mixed Climate", sorted last), normalizeKoppenCode, sanitizeOverrideRecord (reads the two stripe-class columns) |
| Stripe styles | getKoppenMixedStripePairWidthPx, getKoppenStripeStyle, getKoppenStripeClasses, getKoppenStripePatternId |
| Patterns | createKoppenStripePatterns, buildKoppenStripePattern, drawKoppenStripeBands, applyKoppenStripeStyle, anchorKoppenStripePatterns |
| Zooming and crossfade | updateKoppenStripesForZoom, crossfadeKoppenStripes, getKoppenSecondaryBands, classifyKoppenStripeBands, canCrossfadeKoppenStripes |
| Fill, filter, and labels | fillForCounty, shouldFeaturePassFilter, getUniqueCategoryValuesInData, getCategoricalDisplayLabel, formatCountyMetricValue |
| Legend | updateLegend, buildKoppenMixedLegendSwatchBackground |
The hover tooltip shows only the county name, and styles.css is unchanged.
After changing app.js, bump APP_ASSET_VERSION and the app.js?v= query in
index.html so browsers load the new files.
5. Pipeline
scripts/build_county_koppen_metric.pywritesdata/metrics/koppen.csvwith each county's class, top and runner-up class, and their shares.scripts/apply_koppen_metric_to_climate_data.pywriteskoppenZone,koppenPrimaryClass, andkoppenSecondaryClassintodata/climate-data.csv, adding the two stripe columns afterkoppenZoneif missing and filling them only for Mixed counties.--dry-runreports changes without writing.scripts/check_climate_data.pyrequires the two stripe columns: filled if and only ifkoppenZoneisMixed, with valid and different Köppen codes.- Tests:
tests/test_koppen_metric.pyandtests/test_check_climate_data.py.
6. Documentation (stage 5, done 2026-09-14)
filter-calculations.md§1 describes the applied rule, the stripe columns, and the filter; the old method is kept as a short "Previous method" note. Review findings 2 and 4 are marked resolved for Köppen.README.mdandscripts/county_data_sources.mddescribe the new classification and list the Köppen build, apply, and check commands.
7. History
Decisions that were reversed or refined during the browser review:
- 2026-09-13 — Stripes follow the map. Stripes were first a fixed width on screen; the owner wanted the number of stripes in a county not to change with zoom.
- 2026-09-13 — True crossfade. The first fade faded the stripes out and back in, which briefly left no stripes; it was replaced by a crossfade from the old stripes to the new ones.
- 2026-09-13 — Crossfade after the zoom animation. Starting zoom-in fades together with the animation was tried and reverted: the perceived slowness had been the map's own zoom animation.
- 2026-09-13/14 — Near style removed. A thicker 70/30 split from zoom 9 was added, then made instant (fades happen only when the stripe size changes), set to 80/20 by the owner, and finally removed.
- 2026-09-14 — Stripes fixed to the ground. Stripes slid across the land on each zoom because patterns were anchored to Leaflet's per-zoom pixel origin.
- 2026-09-14 — No deepest-zoom limit. A 60-pixel cap, later a 1,100-pixel safety limit with halving, was removed: any cap forces stripes to thin or subdivide on screen, and tests showed no performance cost without one.
- 2026-09-14 — Zoom response setting removed. The fixed-to-the-ground anchoring and the crossfade both require stripes to follow the map exactly.
8. Puerto Rico
The app already leaves Puerto Rico off the map: prepareCountyFeature in
app.js drops counties with state FIPS 72, and the dropdown and legend are built
from the counties on the map. The rows remain in data/climate-data.csv and
data/metrics/koppen.csv. Whether to also remove them from the data files is a
separate decision.