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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user