Complete Köppen-Geiger filter review with Mixed climate class

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>
This commit is contained in:
2026-09-14 02:54:25 -04:00
co-authored by Claude Opus 5
parent 92fbbfb2e9
commit 4d2b3e3d44
20 changed files with 6400 additions and 3450 deletions
+354 -16
View File
@@ -1,6 +1,6 @@
"use strict";
const APP_ASSET_VERSION = "solar-resource-clear-sky-ghi-reduction-20260715";
const APP_ASSET_VERSION = "koppen-mixed-cleanup-20260914";
const US_COUNTIES_GEOJSON_URL = `data/geojson-counties-fips.json?v=${APP_ASSET_VERSION}`;
const CLIMATE_DATA_CSV_URL = `data/climate-data.csv?v=${APP_ASSET_VERSION}`;
const NO_DATA_FILL_COLOR = "#d9dde8";
@@ -102,9 +102,34 @@ const KOPPEN_CLASS_META = {
Dfc: { label: "Subarctic", color: "#007D7D" },
Dfd: { label: "Extremely Cold Subarctic", color: "#00465F" },
ET: { label: "Tundra", color: "#B2B2B2" },
EF: { label: "Ice Cap", color: "#666666" }
EF: { label: "Ice Cap", color: "#666666" },
Mixed: { label: "Mixed Climate", color: "#9aa3b2" }
};
// Counties with no predominant Koppen-Geiger class; see docs/koppen-mixed-display-plan.md.
const KOPPEN_MIXED_CLASS = "Mixed";
// Standard style: share of each stripe pair given to the primary (top) class; the secondary class
// gets the rest.
const KOPPEN_MIXED_PRIMARY_STRIPE_FRACTION = 0.8;
// Stripe pairs per 100 miles, measured across the stripes at DEFAULT_COUNTRY_VIEW. Stripes then
// follow the map, so a county keeps the same number of stripes at every zoom.
const KOPPEN_MIXED_LINE_PAIRS_PER_100_MILES = 5;
// Below this width (zoomed far out), stripes switch to the far-out style.
const KOPPEN_MIXED_MIN_PAIR_WIDTH_PX = 5;
// Far-out style: the map-following width doubled until it reaches the minimum (fewer lines),
// with a thicker secondary.
const KOPPEN_MIXED_FAR_PRIMARY_STRIPE_FRACTION = 0.7;
// Length of the crossfade when the stripe size changes (the far-out zooms and their switch to standard).
const KOPPEN_MIXED_STYLE_FADE_MS = 250;
// SVG rotation for the stripes; -45 draws them from upper left to lower right.
const KOPPEN_MIXED_STRIPE_ROTATION_DEGREES = -45;
const KOPPEN_MIXED_LEGEND_PRIMARY_GRAY = "#d4d8df";
const KOPPEN_MIXED_LEGEND_SECONDARY_GRAY = "#6b7383";
const METERS_PER_MILE = 1609.344;
// Web Mercator ground resolution at zoom 0 on the equator, for 256-pixel tiles.
const WEB_MERCATOR_METERS_PER_PIXEL_AT_ZOOM_0 = 156543.03392;
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const MONTH_CATEGORY_META = {
January: { label: "January", color: "#355c9a" },
February: { label: "February", color: "#4d79b8" },
@@ -129,7 +154,7 @@ const METRICS = {
allOptionLabel: "All Koppen Classes",
source: {
description:
"Beck et al. 2023 Koppen-Geiger maps. County values are generated offline by assigning each county its majority Koppen-Geiger raster class.",
"Beck et al. 2023 Koppen-Geiger maps (1991-2020, 1 km). County class shares are area-weighted. A county shows 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 Climate, striped in the colors of its top two classes.",
links: [
{
label: "Beck et al. 2023 Koppen-Geiger",
@@ -491,6 +516,9 @@ let didMapDragGesture = false;
let clearTooltipSuppressionOnNextMove = false;
let activeCountyTooltip = null;
let focusedElementBeforeSourcesModal = null;
let koppenStripeDefs = null;
let koppenStripeStyle = null;
let koppenStripeFadeFrame = null;
// Find a required DOM element and fail early if the page markup is missing it.
function getRequiredElement(id) {
@@ -716,6 +744,10 @@ function normalizeKoppenCode(code) {
return null;
}
if (trimmed.toLowerCase() === KOPPEN_MIXED_CLASS.toLowerCase()) {
return KOPPEN_MIXED_CLASS;
}
if (!/^[A-Za-z]{2,3}$/.test(trimmed)) {
return null;
}
@@ -891,6 +923,10 @@ function sanitizeOverrideRecord(rawRecord) {
}
});
// Stripe classes for Mixed Koppen counties; blank for predominant counties.
sanitizedRecord.koppenPrimaryClass = normalizeKoppenCode(rawRecord.koppenPrimaryClass);
sanitizedRecord.koppenSecondaryClass = normalizeKoppenCode(rawRecord.koppenSecondaryClass);
return sanitizedRecord;
}
@@ -981,6 +1017,12 @@ function getUniqueCategoryValuesInData(metricKey) {
if (stats[metricKey]) {
valueSet.add(stats[metricKey]);
}
// Classes that appear only as stripe colors in Mixed counties are still listed.
const stripeClasses = metricKey === "koppenZone" ? getKoppenStripeClasses(stats) : null;
if (stripeClasses) {
valueSet.add(stripeClasses.primary);
valueSet.add(stripeClasses.secondary);
}
});
const categoryOrder = Object.keys(METRICS[metricKey]?.categoryMeta || {});
@@ -1009,7 +1051,7 @@ function getCategoricalDisplayLabel(metricKey, value) {
if (!meta) {
return value;
}
return metricKey === "koppenZone" ? `${value} - ${meta.label}` : meta.label;
return metricKey === "koppenZone" && value !== KOPPEN_MIXED_CLASS ? `${value} - ${meta.label}` : meta.label;
}
// Set numeric metric slider bounds from the loaded county data range.
@@ -1115,6 +1157,298 @@ function colorForValue(metricKey, value) {
return metric.palette[index];
}
// Convert the Mixed line pair density into one stripe pair's width in pixels at the default view.
function getKoppenMixedStripePairWidthPx() {
const latitudeRadians = (DEFAULT_COUNTRY_VIEW.center[0] * Math.PI) / 180;
const metersPerPixel =
(WEB_MERCATOR_METERS_PER_PIXEL_AT_ZOOM_0 * Math.cos(latitudeRadians)) / 2 ** DEFAULT_COUNTRY_VIEW.zoom;
const pixelsPer100Miles = (100 * METERS_PER_MILE) / metersPerPixel;
return pixelsPer100Miles / KOPPEN_MIXED_LINE_PAIRS_PER_100_MILES;
}
// Return a Mixed county's primary and secondary Koppen classes, or null when it is not striped.
function getKoppenStripeClasses(stats) {
if (stats?.koppenZone !== KOPPEN_MIXED_CLASS) {
return null;
}
const primary = stats.koppenPrimaryClass;
const secondary = stats.koppenSecondaryClass;
const isStripeClass = (code) => Boolean(KOPPEN_CLASS_META[code]) && code !== KOPPEN_MIXED_CLASS;
return isStripeClass(primary) && isStripeClass(secondary) ? { primary, secondary } : null;
}
// Build the SVG pattern id for one primary/secondary stripe pair.
function getKoppenStripePatternId(primary, secondary) {
return `koppen-mixed-${primary}-${secondary}`;
}
// Return the stripe style for a zoom level. Every width is the reference width times a power of
// two, so the old and new layouts of any zoom change fit one pattern tile for the crossfade.
function getKoppenStripeStyle(zoom) {
const zoomSteps = zoom - DEFAULT_COUNTRY_VIEW.zoom;
// Stripes follow the map: the width doubles with each zoom-in step, so a county's stripe count stays constant.
const followedWidth = getKoppenMixedStripePairWidthPx() * 2 ** zoomSteps;
if (followedWidth < KOPPEN_MIXED_MIN_PAIR_WIDTH_PX) {
// Far-out: double the map-following width (half as many lines) until it reaches the minimum.
let pairWidth = followedWidth * 2;
while (pairWidth < KOPPEN_MIXED_MIN_PAIR_WIDTH_PX) {
pairWidth *= 2;
}
return { name: "far", zoom, pairWidth, primaryFraction: KOPPEN_MIXED_FAR_PRIMARY_STRIPE_FRACTION };
}
return { name: "standard", zoom, pairWidth: followedWidth, primaryFraction: KOPPEN_MIXED_PRIMARY_STRIPE_FRACTION };
}
// Build one diagonal stripe pattern: a primary-color tile with secondary-color bands on top.
function buildKoppenStripePattern(patternId, primaryColor, secondaryColor) {
const pattern = document.createElementNS(SVG_NAMESPACE, "pattern");
pattern.setAttribute("id", patternId);
pattern.setAttribute("patternUnits", "userSpaceOnUse");
pattern.setAttribute("patternTransform", `rotate(${KOPPEN_MIXED_STRIPE_ROTATION_DEGREES})`);
const background = document.createElementNS(SVG_NAMESPACE, "rect");
background.setAttribute("class", "koppen-stripe-primary");
background.setAttribute("x", "0");
background.setAttribute("y", "0");
background.setAttribute("fill", primaryColor);
const secondaries = document.createElementNS(SVG_NAMESPACE, "g");
secondaries.setAttribute("class", "koppen-stripe-secondaries");
secondaries.setAttribute("fill", secondaryColor);
pattern.append(background, secondaries);
return pattern;
}
// Return a style's secondary bands repeated across a tile, as [from, to] pixel ranges.
function getKoppenSecondaryBands(pairWidth, primaryFraction, tileWidth) {
const bands = [];
const pairCount = Math.round(tileWidth / pairWidth);
for (let index = 0; index < pairCount; index += 1) {
bands.push([(index + primaryFraction) * pairWidth, (index + 1) * pairWidth]);
}
return bands;
}
// Split a tile into bands that are secondary in both layouts ("shared"), only the old one, or only the new one.
function classifyKoppenStripeBands(oldBands, newBands, tileWidth) {
const isInside = (bands, x) => bands.some(([from, to]) => x > from && x < to);
const edges = Array.from(new Set([0, tileWidth, ...oldBands.flat(), ...newBands.flat()])).sort((a, b) => a - b);
const segments = [];
for (let index = 0; index < edges.length - 1; index += 1) {
const from = edges[index];
const to = edges[index + 1];
if (to - from < 1e-6) {
continue;
}
const middle = (from + to) / 2;
const isOld = isInside(oldBands, middle);
const isNew = isInside(newBands, middle);
const role = isOld && isNew ? "shared" : isOld ? "old" : isNew ? "new" : null;
if (!role) {
continue;
}
const last = segments[segments.length - 1];
if (last && last.role === role && Math.abs(last.to - from) < 1e-6) {
last.to = to;
} else {
segments.push({ role, from, to });
}
}
return segments;
}
// Draw secondary bands in every stripe pattern, sized to one tile. New-only bands start hidden.
function drawKoppenStripeBands(tileWidth, segments) {
koppenStripeDefs?.querySelectorAll("pattern").forEach((pattern) => {
pattern.setAttribute("width", String(tileWidth));
pattern.setAttribute("height", String(tileWidth));
const background = pattern.querySelector(".koppen-stripe-primary");
background.setAttribute("width", String(tileWidth));
background.setAttribute("height", String(tileWidth));
const rects = segments.map(({ role, from, to }) => {
const rect = document.createElementNS(SVG_NAMESPACE, "rect");
rect.setAttribute("x", String(from));
rect.setAttribute("y", "0");
rect.setAttribute("width", String(to - from));
rect.setAttribute("height", String(tileWidth));
rect.setAttribute("data-role", role);
rect.setAttribute("fill-opacity", role === "new" ? "0" : "1");
return rect;
});
pattern.querySelector(".koppen-stripe-secondaries").replaceChildren(...rects);
});
anchorKoppenStripePatterns();
}
// Anchor every stripe pattern to the map projection's fixed origin rather than Leaflet's pixel
// origin, which moves on every zoom, so the stripes stay fixed to the ground. The offset is reduced
// to within one tile, which draws identical stripes and avoids precision loss at deep zooms.
function anchorKoppenStripePatterns() {
if (!koppenStripeDefs) {
return;
}
// Leaflet's SVG coordinates are projected pixels minus the pixel origin, so the projection's
// origin sits at minus the pixel origin. Express that point in the rotated pattern's coordinates.
const origin = map.getPixelOrigin();
const angle = (-KOPPEN_MIXED_STRIPE_ROTATION_DEGREES * Math.PI) / 180;
const offsetX = -origin.x * Math.cos(angle) + origin.y * Math.sin(angle);
const offsetY = -origin.x * Math.sin(angle) - origin.y * Math.cos(angle);
koppenStripeDefs.querySelectorAll("pattern").forEach((pattern) => {
const tileWidth = Number(pattern.getAttribute("width"));
if (!(tileWidth > 0)) {
return;
}
const shiftX = ((offsetX % tileWidth) + tileWidth) % tileWidth;
const shiftY = ((offsetY % tileWidth) + tileWidth) % tileWidth;
pattern.setAttribute(
"patternTransform",
`rotate(${KOPPEN_MIXED_STRIPE_ROTATION_DEGREES}) translate(${shiftX} ${shiftY})`
);
});
}
// Draw a stripe style's final layout: one secondary band per stripe pair.
function applyKoppenStripeStyle(style) {
const bandStart = style.pairWidth * style.primaryFraction;
drawKoppenStripeBands(style.pairWidth, [{ role: "shared", from: bandStart, to: style.pairWidth }]);
koppenStripeStyle = style;
}
// Crossfade from the old stripes, exactly as the zoom animation left them, to a new style.
// Bands secondary in both layouts stay solid; old-only bands fade out while new-only bands fade in.
function crossfadeKoppenStripes(oldPairWidth, oldPrimaryFraction, style) {
const tileWidth = Math.max(oldPairWidth, style.pairWidth);
const segments = classifyKoppenStripeBands(
getKoppenSecondaryBands(oldPairWidth, oldPrimaryFraction, tileWidth),
getKoppenSecondaryBands(style.pairWidth, style.primaryFraction, tileWidth),
tileWidth
);
drawKoppenStripeBands(tileWidth, segments);
koppenStripeStyle = style;
const oldBands = koppenStripeDefs.querySelectorAll('rect[data-role="old"]');
const newBands = koppenStripeDefs.querySelectorAll('rect[data-role="new"]');
const fadeStart = performance.now();
const fadeStep = (now) => {
// The frame timestamp can fall slightly before fadeStart, so clamp to 0..1.
const progress = Math.min(Math.max((now - fadeStart) / KOPPEN_MIXED_STYLE_FADE_MS, 0), 1);
oldBands.forEach((rect) => rect.setAttribute("fill-opacity", String(1 - progress)));
newBands.forEach((rect) => rect.setAttribute("fill-opacity", String(progress)));
if (progress < 1) {
koppenStripeFadeFrame = requestAnimationFrame(fadeStep);
} else {
koppenStripeFadeFrame = null;
applyKoppenStripeStyle(style);
}
};
koppenStripeFadeFrame = requestAnimationFrame(fadeStep);
}
// True when two widths differ by a whole power of two, so both layouts fit one tile.
function canCrossfadeKoppenStripes(widthA, widthB) {
const exponent = Math.log2(Math.max(widthA, widthB) / Math.min(widthA, widthB));
return Math.abs(exponent - Math.round(exponent)) < 0.01;
}
// Resize stripes after a zoom, crossfading from the old layout when the stripe size changes.
function updateKoppenStripesForZoom() {
if (!koppenStripeDefs) {
return;
}
const zoom = map.getZoom();
const style = getKoppenStripeStyle(zoom);
const previous = koppenStripeStyle;
if (koppenStripeFadeFrame !== null) {
cancelAnimationFrame(koppenStripeFadeFrame);
koppenStripeFadeFrame = null;
}
if (!previous) {
applyKoppenStripeStyle(style);
return;
}
// Leaflet's zoom animation stretched the old stripes by 2 to the power of the zoom change.
const oldPairWidth = previous.pairWidth * 2 ** (zoom - previous.zoom);
// Fade only when the stripe size changes; otherwise the new layout is drawn instantly.
const sizeChanges = Math.abs(oldPairWidth - style.pairWidth) >= 0.01;
if (!sizeChanges || !canCrossfadeKoppenStripes(oldPairWidth, style.pairWidth)) {
applyKoppenStripeStyle(style);
return;
}
crossfadeKoppenStripes(oldPairWidth, previous.primaryFraction, style);
}
// Create one stripe pattern for each primary/secondary pair used by Mixed counties.
function createKoppenStripePatterns() {
if (!koppenStripeDefs) {
// Patterns live in a hidden SVG; url(#id) fills resolve them anywhere in the page.
const svg = document.createElementNS(SVG_NAMESPACE, "svg");
svg.setAttribute("width", "0");
svg.setAttribute("height", "0");
svg.setAttribute("aria-hidden", "true");
svg.style.position = "absolute";
koppenStripeDefs = document.createElementNS(SVG_NAMESPACE, "defs");
svg.appendChild(koppenStripeDefs);
document.body.appendChild(svg);
map.on("zoomend", updateKoppenStripesForZoom);
// Leaflet can also move its pixel origin without a zoom (after long pans or re-centring).
map.on("viewreset moveend", anchorKoppenStripePatterns);
}
const patterns = new Map();
countyStats.forEach((stats) => {
const classes = getKoppenStripeClasses(stats);
if (!classes) {
return;
}
const patternId = getKoppenStripePatternId(classes.primary, classes.secondary);
if (!patterns.has(patternId)) {
const primaryColor = KOPPEN_CLASS_META[classes.primary].color;
const secondaryColor = KOPPEN_CLASS_META[classes.secondary].color;
patterns.set(patternId, buildKoppenStripePattern(patternId, primaryColor, secondaryColor));
}
});
koppenStripeDefs.replaceChildren(...patterns.values());
applyKoppenStripeStyle(getKoppenStripeStyle(map.getZoom()));
}
// Pick a county's map fill: a stripe pattern for Mixed Koppen counties, otherwise the value color.
function fillForCounty(stats) {
if (currentMetricKey === "koppenZone") {
const classes = getKoppenStripeClasses(stats);
if (classes) {
return `url(#${getKoppenStripePatternId(classes.primary, classes.secondary)})`;
}
}
return colorForValue(currentMetricKey, stats[currentMetricKey]);
}
// Build the Mixed Climate legend swatch: gray stripes in the same split as the map.
function buildKoppenMixedLegendSwatchBackground() {
const periodPx = 6;
const primaryPx = periodPx * KOPPEN_MIXED_PRIMARY_STRIPE_FRACTION;
return (
`repeating-linear-gradient(45deg, ${KOPPEN_MIXED_LEGEND_PRIMARY_GRAY} 0 ${primaryPx}px, ` +
`${KOPPEN_MIXED_LEGEND_SECONDARY_GRAY} ${primaryPx}px ${periodPx}px)`
);
}
// Format a county's metric value, naming the stripe classes for Mixed Koppen counties.
function formatCountyMetricValue(metricKey, stats) {
const text = formatMetricValue(metricKey, stats[metricKey]);
const classes = metricKey === "koppenZone" ? getKoppenStripeClasses(stats) : null;
return classes ? `${text} (${classes.primary} primary, ${classes.secondary} secondary)` : text;
}
// Check whether a county should appear active under the current filter.
function shouldFeaturePassFilter(stats) {
if (isCurrentMetricCategorical()) {
@@ -1122,7 +1456,12 @@ function shouldFeaturePassFilter(stats) {
if (!categoryValue) {
return false;
}
return currentCategoricalFilter === "all" || categoryValue === currentCategoricalFilter;
if (currentCategoricalFilter === "all" || categoryValue === currentCategoricalFilter) {
return true;
}
// A Mixed Koppen county also matches its primary and secondary classes.
const stripeClasses = currentMetricKey === "koppenZone" ? getKoppenStripeClasses(stats) : null;
return Boolean(stripeClasses) && [stripeClasses.primary, stripeClasses.secondary].includes(currentCategoricalFilter);
}
const rawValue = stats[currentMetricKey];
@@ -1150,13 +1489,8 @@ function styleForCounty(feature) {
}
const inRange = shouldFeaturePassFilter(stats);
const metricValue = stats[currentMetricKey];
return buildCountyStyle(
isSelected,
inRange ? colorForValue(currentMetricKey, metricValue) : NO_DATA_FILL_COLOR,
inRange ? 0.72 : 0.25
);
return buildCountyStyle(isSelected, inRange ? fillForCounty(stats) : NO_DATA_FILL_COLOR, inRange ? 0.72 : 0.25);
}
// Apply shared stroke styling around a county fill style.
@@ -1180,10 +1514,13 @@ function updateLegend() {
const rows = values
.map((value) => {
const selectedClass = currentCategoricalFilter === value ? " legend-koppen-row-selected" : "";
const isMixed = currentMetricKey === "koppenZone" && value === KOPPEN_MIXED_CLASS;
const swatchBackground = isMixed ? buildKoppenMixedLegendSwatchBackground() : colorForValue(currentMetricKey, value);
const mixedNote = isMixed ? "<br><small>Stripes show each county's top two classes</small>" : "";
return `
<div class="legend-koppen-row${selectedClass}">
<span class="legend-koppen-dot" style="background:${colorForValue(currentMetricKey, value)}"></span>
<span>${getCategoricalDisplayLabel(currentMetricKey, value)}</span>
<span class="legend-koppen-dot" style="background:${swatchBackground}"></span>
<span>${getCategoricalDisplayLabel(currentMetricKey, value)}${mixedNote}</span>
</div>
`;
})
@@ -1228,7 +1565,7 @@ function buildCountyDetailsBody(meta, stats) {
// Build one metric row for the selected-county details panel.
function buildMetricDetailRow(metricKey, stats) {
return `<p><strong>${getMetricConfig(metricKey).label}:</strong> ${formatMetricValue(metricKey, stats[metricKey])}</p>`;
return `<p><strong>${getMetricConfig(metricKey).label}:</strong> ${formatCountyMetricValue(metricKey, stats)}</p>`;
}
// Update the selected-county panel for the current selection state.
@@ -1487,9 +1824,9 @@ function openCountyPopup(layer, countyFips) {
layer
.bindPopup(
`<strong>${meta.displayName}</strong><br>${METRICS[currentMetricKey].label}: ${formatMetricValue(
`<strong>${meta.displayName}</strong><br>${METRICS[currentMetricKey].label}: ${formatCountyMetricValue(
currentMetricKey,
stats[currentMetricKey]
stats
)}`,
{ autoPan: false }
)
@@ -1822,6 +2159,7 @@ async function loadCounties() {
const geojson = await response.json();
geojson.features = geojson.features.filter(prepareCountyFeature);
createKoppenStripePatterns();
applyNumericMetricBoundsFromData();
configureCategoricalFilterControl();