"use strict"; const APP_ASSET_VERSION = "precip-months-cloudiness-20260601"; 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"; const DEFAULT_COUNTY_STROKE_COLOR = "#13305a"; const SELECTED_COUNTY_STROKE_COLOR = "#071833"; const DEFAULT_COUNTRY_VIEW = { center: [39.5, -98.35], zoom: 5 }; const STATE_FOCUS_MAX_ZOOM = 8; const STATE_FIPS_TO_ABBR = { "01": "AL", "02": "AK", "04": "AZ", "05": "AR", "06": "CA", "08": "CO", "09": "CT", "10": "DE", "11": "DC", "12": "FL", "13": "GA", "15": "HI", "16": "ID", "17": "IL", "18": "IN", "19": "IA", "20": "KS", "21": "KY", "22": "LA", "23": "ME", "24": "MD", "25": "MA", "26": "MI", "27": "MN", "28": "MS", "29": "MO", "30": "MT", "31": "NE", "32": "NV", "33": "NH", "34": "NJ", "35": "NM", "36": "NY", "37": "NC", "38": "ND", "39": "OH", "40": "OK", "41": "OR", "42": "PA", "44": "RI", "45": "SC", "46": "SD", "47": "TN", "48": "TX", "49": "UT", "50": "VT", "51": "VA", "53": "WA", "54": "WV", "55": "WI", "56": "WY", "60": "AS", "66": "GU", "69": "MP", "72": "PR", "78": "VI" }; const KOPPEN_CLASS_META = { Af: { label: "Tropical Rainforest", color: "#0000FF" }, Am: { label: "Tropical Monsoon", color: "#0078FF" }, Aw: { label: "Tropical Savanna", color: "#46AAFA" }, BWh: { label: "Hot Desert", color: "#FF0000" }, BWk: { label: "Cold Desert", color: "#FF9696" }, BSh: { label: "Hot Semi-Arid", color: "#EEA722" }, BSk: { label: "Cold Semi-Arid", color: "#FADD6B" }, Csa: { label: "Hot-Summer Mediterranean", color: "#FCFF2D" }, Csb: { label: "Warm-Summer Mediterranean", color: "#C5C821" }, Csc: { label: "Cold-Summer Mediterranean", color: "#949616" }, Cwa: { label: "Monsoon-Influenced Humid Subtropical", color: "#96FF96" }, Cwb: { label: "Subtropical Highland (Dry Winter)", color: "#64C864" }, Cwc: { label: "Cold Subtropical Highland (Dry Winter)", color: "#329632" }, Cfa: { label: "Humid Subtropical", color: "#C8FF50" }, Cfb: { label: "Temperate Oceanic or Subtropical Highland", color: "#64FF50" }, Cfc: { label: "Subpolar Oceanic", color: "#32C800" }, Dsa: { label: "Hot-Summer Continental (Dry Summer)", color: "#FF00FF" }, Dsb: { label: "Warm-Summer Continental (Dry Summer)", color: "#C800C8" }, Dsc: { label: "Subarctic (Dry Summer)", color: "#963296" }, Dsd: { label: "Very Cold Continental (Dry Summer)", color: "#966496" }, Dwa: { label: "Hot-Summer Continental (Dry Winter)", color: "#AAAFFF" }, Dwb: { label: "Warm-Summer Continental (Dry Winter)", color: "#5A78DC" }, Dwc: { label: "Subarctic (Dry Winter)", color: "#4B50B4" }, Dwd: { label: "Very Cold Continental (Dry Winter)", color: "#320087" }, Dfa: { label: "Hot-Summer Humid Continental", color: "#00FFFF" }, Dfb: { label: "Warm-Summer Humid Continental", color: "#37C8FF" }, Dfc: { label: "Subarctic", color: "#007D7D" }, Dfd: { label: "Extremely Cold Subarctic", color: "#00465F" }, ET: { label: "Tundra", color: "#B2B2B2" }, EF: { label: "Ice Cap", color: "#666666" } }; const MONTH_CATEGORY_META = { January: { label: "January", color: "#355c9a" }, February: { label: "February", color: "#4d79b8" }, March: { label: "March", color: "#6aa7b8" }, April: { label: "April", color: "#79b56b" }, May: { label: "May", color: "#b7c85a" }, June: { label: "June", color: "#f0c54d" }, July: { label: "July", color: "#e89a3c" }, August: { label: "August", color: "#d66b3d" }, September: { label: "September", color: "#b85b73" }, October: { label: "October", color: "#8f62a6" }, November: { label: "November", color: "#6670a9" }, December: { label: "December", color: "#4f6f8f" } }; const METRICS = { koppenZone: { label: "Koppen-Geiger Climate Class", type: "categorical", unit: "class", categoryMeta: KOPPEN_CLASS_META, 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.", links: [ { label: "Beck et al. 2023 Koppen-Geiger", url: "https://www.nature.com/articles/s41597-023-02549-6" } ] } }, avgTempF: { label: "Annual Avg Temperature (Normals)", type: "numeric", min: 30, max: 80, unit: "F", palette: ["#eef6ff", "#c0ddff", "#8dbfff", "#4f93e8", "#1f67d2"], source: { description: "NOAA NCEI 1991-2020 U.S. Climate Normals. Annual mean temperature is county-averaged from gridded normals and stored in Fahrenheit.", links: [ { label: "NOAA NCEI U.S. Climate Normals", url: "https://www.ncei.noaa.gov/products/land-based-station/us-climate-normals" } ] } }, avgDiurnalTempRangeF: { label: "Diurnal Temperature Range", type: "numeric", min: 14, max: 33, boundsStep: 0.1, sliderStep: 0.1, unit: "F", palette: ["#f2f7f5", "#bcdccf", "#83bba6", "#4b927d", "#1f5f54"], source: { description: "NOAA nClimGrid-Daily county area averages, 1991-2020. This metric is the average daily Tmax minus Tmin, expressed as a Fahrenheit temperature difference.", links: [ { label: "NOAA nClimGrid-Daily", url: "https://www.ncei.noaa.gov/products/land-based-station/nclimgrid-daily" } ] } }, annualPrecipIn: { label: "Annual Precipitation (Normals)", type: "numeric", min: 8, max: 80, unit: "in", palette: ["#edf7ea", "#b8dfae", "#7fca76", "#4ba64d", "#2f7b37"], source: { description: "NOAA NCEI 1991-2020 U.S. Climate Normals. Annual precipitation totals are county-averaged from gridded normals and stored in inches.", links: [ { label: "NOAA NCEI U.S. Climate Normals", url: "https://www.ncei.noaa.gov/products/land-based-station/us-climate-normals" } ] } }, seasonalityIndex: { label: "Seasonality Index", type: "numeric", min: 15, max: 90, unit: "index", palette: ["#fff2ea", "#ffd0b5", "#ffae7f", "#f57c41", "#cc5016"], source: { description: "Derived from NOAA NCEI 1991-2020 monthly precipitation normals as the coefficient of variation of monthly totals, expressed as an index.", links: [ { label: "NOAA NCEI U.S. Climate Normals", url: "https://www.ncei.noaa.gov/products/land-based-station/us-climate-normals" } ] } }, wettestPrecipMonth: { label: "Wettest Month", type: "categorical", unit: "month", categoryMeta: MONTH_CATEGORY_META, allOptionLabel: "All Months", source: { description: "Derived from NOAA nClimGrid monthly precipitation totals, 1991-2020. Each county is assigned the month with the highest county-averaged monthly precipitation normal.", links: [ { label: "NOAA nClimGrid Monthly", url: "https://www.ncei.noaa.gov/products/land-based-station/nclimgrid" } ] } }, driestPrecipMonth: { label: "Driest Month", type: "categorical", unit: "month", categoryMeta: MONTH_CATEGORY_META, allOptionLabel: "All Months", source: { description: "Derived from NOAA nClimGrid monthly precipitation totals, 1991-2020. Each county is assigned the month with the lowest county-averaged monthly precipitation normal.", links: [ { label: "NOAA nClimGrid Monthly", url: "https://www.ncei.noaa.gov/products/land-based-station/nclimgrid" } ] } }, absoluteExtremeDays: { label: "Absolute Extreme Days / Year", type: "numeric", min: 0, max: 150, boundsStep: 1, sliderStep: 1, unit: "days", palette: ["#fff9e6", "#ffe49a", "#ffd067", "#e7af20", "#b88409"], source: { description: "NOAA nClimGrid-Daily county area averages, 1991-2025. Counts days where daily Tmax is at least 95 F or daily Tmin is at most 0 F, then averages by year.", links: [ { label: "NOAA nClimGrid-Daily", url: "https://www.ncei.noaa.gov/products/land-based-station/nclimgrid-daily" } ] } }, humidHeatDays: { label: "Heat Index", type: "numeric", min: 0, max: 180, boundsStep: 1, sliderStep: 0.1, unit: "days", palette: ["#fff7ec", "#fdd49e", "#fdbb84", "#ef6548", "#b30000"], source: { description: "Heat Index is estimated from NOAA nClimGrid-Daily county Tmax and gridMET daily minimum relative humidity (rmin), 1991-2020. This metric is the average annual count of days where estimated daily Heat Index is at least 90 F; county-vintage joins and proxies are documented in the humidHeatSourceFips and humidHeatFipsAdjustment fields.", links: [ { label: "NOAA National Weather Service Heat Index", url: "https://www.weather.gov/safety/heat-index" }, { label: "NOAA nClimGrid-Daily", url: "https://www.ncei.noaa.gov/products/land-based-station/nclimgrid-daily" }, { label: "gridMET Climatology Lab", url: "https://www.climatologylab.org/gridmet.html" } ] } }, avgSolarGhiKwhM2Day: { label: "Avg Daily Solar Irradiance (GHI)", type: "numeric", min: 2.5, max: 6.5, boundsStep: 0.1, sliderStep: 0.1, unit: "kWh/m2/day", palette: ["#fff8d8", "#f8df83", "#e8b94e", "#c9892b", "#985f1b"], source: { description: "NREL National Solar Radiation Database TMY PSM v4. County polygon archive summaries use area-weighted GHI; representative-point values are kept as fallback where polygon summaries are unavailable.", links: [ { label: "NREL NSRDB", url: "https://nsrdb.nrel.gov/" } ] } }, cloudinessIndexPct: { label: "Cloudiness Index", type: "numeric", min: 0.05, max: 0.6, boundsStep: 0.01, sliderStep: 0.01, unit: "ratio", palette: ["#fff7bc", "#d9f0d3", "#a6cee3", "#6baed6", "#4b5563"], source: { description: "NREL National Solar Radiation Database TMY PSM v4 representative-point summary. The index is 1 minus the daylight average of observed GHI divided by modeled Clearsky GHI, clamped to a 0-1 range; higher values indicate more sunlight reduction relative to clear-sky conditions.", links: [ { label: "NREL NSRDB", url: "https://nsrdb.nrel.gov/" } ] } }, avgSummerSpecificHumidityGKg: { label: "Summer Specific Humidity", type: "numeric", min: 4, max: 20, boundsStep: 0.1, sliderStep: 0.1, unit: "g/kg", palette: ["#f6f0d6", "#d7dfad", "#9cc895", "#58a9a3", "#2e6f8e"], source: { description: "gridMET daily specific humidity (sph), 1991-2020. Values are county-cell-weighted averages for June, July, and August, converted from kg/kg to grams of water vapor per kilogram of air.", links: [ { label: "gridMET Climatology Lab", url: "https://www.climatologylab.org/gridmet.html" }, { label: "gridMET Data Catalog", url: "https://www.northwestknowledge.net/metdata/data/" } ] } } }; const DETAIL_METRICS = METRICS; const DATA_METRIC_KEYS = Object.keys(METRICS); const COUNTY_DETAIL_METRIC_KEYS = [...DATA_METRIC_KEYS]; const METRIC_GROUPS = [ { key: "koppenGeigerClassification", label: "Koppen-Geiger Classification", metricKeys: ["koppenZone"] }, { key: "temperatureExtremes", label: "Temperature & Extremes", metricKeys: ["avgTempF", "avgDiurnalTempRangeF", "absoluteExtremeDays", "humidHeatDays"] }, { key: "precipitationMoisture", label: "Precipitation & Moisture", metricKeys: ["annualPrecipIn", "seasonalityIndex", "wettestPrecipMonth", "driestPrecipMonth", "avgSummerSpecificHumidityGKg"] }, { key: "solarExposure", label: "Solar Exposure", metricKeys: ["avgSolarGhiKwhM2Day", "cloudinessIndexPct"] } ].map((group) => ({ ...group, metricKeys: group.metricKeys.filter((metricKey) => DATA_METRIC_KEYS.includes(metricKey)) })); const METRIC_GROUP_BY_KEY = new Map(METRIC_GROUPS.map((group) => [group.key, group])); const BASE_SOURCE_ENTRIES = [ { title: "Basemap", description: "Primary tiles are OpenStreetMap contributors. If those tiles are blocked, the app switches to an Esri World Street Map fallback using Esri, DeLorme, HERE, USGS, Intermap, iPC, NRCAN, Esri Japan, METI, Esri China (Hong Kong), Esri (Thailand), MapmyIndia, and Tomtom sources.", links: [ { label: "OpenStreetMap copyright", url: "https://www.openstreetmap.org/copyright" } ] }, { title: "County Geometry", description: "County boundaries come from U.S. Census Bureau TIGER/Line files, loaded in the browser through the Plotly county GeoJSON dataset. Puerto Rico rows are filtered out to match the current app scope.", links: [ { label: "U.S. Census Bureau TIGER/Line", url: "https://www.census.gov/geographies/mapping-files/time-series/geo/tiger-line-file.html" }, { label: "Plotly datasets", url: "https://github.com/plotly/datasets" } ] } ]; const map = L.map("map", { zoomControl: true, minZoom: 2, worldCopyJump: false, inertia: false }).setView(DEFAULT_COUNTRY_VIEW.center, DEFAULT_COUNTRY_VIEW.zoom); const metricGroupSelect = getRequiredElement("metricGroupSelect"); const metricSelect = getRequiredElement("metricSelect"); const metricSelectWrap = getRequiredElement("metricSelectWrap"); const minRange = getRequiredElement("minRange"); const maxRange = getRequiredElement("maxRange"); const minValue = getRequiredElement("minValue"); const maxValue = getRequiredElement("maxValue"); const legend = getRequiredElement("legend"); const stateDetails = getRequiredElement("stateDetails"); const metricInfoPanel = document.getElementById("metricInfoPanel"); const metricInfoToggle = document.getElementById("metricInfoToggle"); const legendInfoPanel = document.getElementById("legendInfoPanel"); const legendInfoToggle = document.getElementById("legendInfoToggle"); const resetViewButton = getRequiredElement("resetViewButton"); const clearSelectionButton = getRequiredElement("clearSelectionButton"); const sourcesButton = getRequiredElement("sourcesButton"); const sourcesModal = getRequiredElement("sourcesModal"); const sourcesCloseButton = getRequiredElement("sourcesCloseButton"); const sourcesModalContent = getRequiredElement("sourcesModalContent"); const rangeWraps = Array.from(document.querySelectorAll(".range-wrap")); const koppenFilterWrap = document.getElementById("koppenFilterWrap"); const koppenFilterSelect = document.getElementById("koppenFilterSelect"); const PANEL_STATE_STORAGE_KEYS = { metricInfo: "climateExplorer.metricInfoExpanded", legendInfo: "climateExplorer.legendInfoExpanded" }; const TILE_PROVIDERS = { openStreetMap: { url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", options: { maxZoom: 19, attribution: '© OpenStreetMap contributors' } }, esriWorldStreetMap: { url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}", options: { maxZoom: 19, attribution: "Sources: Esri, DeLorme, HERE, USGS, Intermap, iPC, NRCAN, Esri Japan, METI, Esri China (Hong Kong), Esri (Thailand), MapmyIndia, Tomtom" } } }; const LOCAL_CSV_LAUNCH_MESSAGE = "This app loads data/climate-data.csv with fetch(), so it must be opened from http:// or https://. Run .\\serve.ps1 from the project folder, then open http://localhost:8000/."; let COUNTY_CLIMATE_OVERRIDES = {}; let COUNTY_OVERRIDE_RECORD_COUNT = 0; let countiesLayer; let selectedCountyFips = null; let selectedCountyLayer = null; let countyStats = new Map(); let countyMeta = new Map(); let currentMetricKey = DATA_METRIC_KEYS[0]; let currentMetricGroupKey = getMetricGroupKeyForMetric(currentMetricKey); let currentCategoricalFilter = "all"; let baseLayer = null; let redrawQueued = false; let areCountyTooltipsSuppressed = false; let didMapDragGesture = false; let clearTooltipSuppressionOnNextMove = false; let activeCountyTooltip = null; let focusedElementBeforeSourcesModal = null; // Find a required DOM element and fail early if the page markup is missing it. function getRequiredElement(id) { const element = document.getElementById(id); if (!element) { throw new Error(`Missing required element: #${id}`); } return element; } // Return source entries in the same order the user sees map metrics. function getSourceEntries() { const metricEntries = METRIC_GROUPS.flatMap((group) => group.metricKeys) .map((metricKey) => METRICS[metricKey]) .map((metric) => ({ title: metric.label, description: metric.source.description, links: metric.source.links })); return [...BASE_SOURCE_ENTRIES, ...metricEntries]; } // Normalize source text for the selected-county panel. function getVisibleSourceTag(sourceText) { return String(sourceText); } // Render the scrollable sources dialog without injecting source text as HTML. function renderSourcesModalContent() { sourcesModalContent.replaceChildren(); getSourceEntries().forEach((source) => { const entry = document.createElement("article"); entry.className = "source-entry"; const title = document.createElement("h3"); title.textContent = source.title; entry.appendChild(title); const description = document.createElement("p"); description.textContent = source.description; entry.appendChild(description); if (source.links.length) { const links = document.createElement("p"); links.className = "source-links"; links.appendChild(document.createTextNode("Reference: ")); source.links.forEach((link, index) => { if (index > 0) { links.appendChild(document.createTextNode("; ")); } const anchor = document.createElement("a"); anchor.href = link.url; anchor.textContent = link.label; links.appendChild(anchor); }); entry.appendChild(links); } sourcesModalContent.appendChild(entry); }); } // Open the sources modal and move focus into it. function openSourcesModal() { focusedElementBeforeSourcesModal = document.activeElement; renderSourcesModalContent(); sourcesModal.hidden = false; document.body.classList.add("is-sources-modal-open"); sourcesCloseButton.focus(); } // Close the sources modal and restore focus to the button that opened it. function closeSourcesModal() { sourcesModal.hidden = true; document.body.classList.remove("is-sources-modal-open"); if (focusedElementBeforeSourcesModal instanceof HTMLElement) { focusedElementBeforeSourcesModal.focus(); } } // Keep keyboard focus inside the sources dialog while it is open. function trapSourcesModalFocus(event) { if (event.key !== "Tab" || sourcesModal.hidden) { return; } const focusableElements = Array.from(sourcesModal.querySelectorAll("a[href], button:not([disabled])")); if (!focusableElements.length) { return; } const firstFocusable = focusableElements[0]; const lastFocusable = focusableElements[focusableElements.length - 1]; if (event.shiftKey && document.activeElement === firstFocusable) { event.preventDefault(); lastFocusable.focus(); return; } if (!event.shiftKey && document.activeElement === lastFocusable) { event.preventDefault(); firstFocusable.focus(); } } // Wire the sources modal button, backdrop, close button, and keyboard behavior. function configureSourcesModal() { sourcesButton.addEventListener("click", openSourcesModal); sourcesCloseButton.addEventListener("click", closeSourcesModal); sourcesModal.addEventListener("click", (event) => { if (event.target instanceof HTMLElement && event.target.hasAttribute("data-sources-close")) { closeSourcesModal(); } }); document.addEventListener("keydown", (event) => { if (sourcesModal.hidden) { return; } if (event.key === "Escape") { closeSourcesModal(); return; } trapSourcesModalFocus(event); }); } // Check whether the active map metric uses class categories instead of numbers. function isCurrentMetricCategorical() { return METRICS[currentMetricKey].type === "categorical"; } // Convert state or county IDs into zero-padded FIPS (Federal Information Processing Standards, such as 12 for FL or 01 for AL) strings. function normalizeFips(value, width) { const asText = String(value).trim(); const digitsOnly = asText.replace(/\D/g, ""); if (!digitsOnly) { return ""; } return digitsOnly.padStart(width, "0").slice(-width); } // Re-key editable county records by normalized five-digit county FIPS. function normalizeCountyOverrideMap(records) { const normalized = {}; Object.entries(records).forEach(([rawFips, record]) => { const countyFips = normalizeFips(rawFips, 5); if (countyFips) { normalized[countyFips] = record; } }); return normalized; } // Identify a GeoJSON coordinate pair such as [longitude, latitude]. function isCoordinatePosition(value) { return Array.isArray(value) && typeof value[0] === "number" && typeof value[1] === "number"; } // Walk nested GeoJSON coordinates and collect the minimum and maximum longitude. function collectLongitudeBounds(coordinates, bounds = { min: Infinity, max: -Infinity }) { if (isCoordinatePosition(coordinates)) { bounds.min = Math.min(bounds.min, coordinates[0]); bounds.max = Math.max(bounds.max, coordinates[0]); return bounds; } if (!Array.isArray(coordinates)) { return bounds; } coordinates.forEach((item) => collectLongitudeBounds(item, bounds)); return bounds; } // Move positive longitudes to the western map copy for antimeridian-crossing counties. function shiftPositiveLongitudes(coordinates) { if (isCoordinatePosition(coordinates)) { const [lon, ...rest] = coordinates; return [lon > 0 ? lon - 360 : lon, ...rest]; } if (!Array.isArray(coordinates)) { return coordinates; } return coordinates.map((item) => shiftPositiveLongitudes(item)); } // Normalize counties split by the antimeridian so Leaflet draws them on one map loop. function normalizeAntimeridianFeature(feature) { if (!feature.geometry || !feature.geometry.coordinates) { return; } const bounds = collectLongitudeBounds(feature.geometry.coordinates); if (!Number.isFinite(bounds.min) || !Number.isFinite(bounds.max) || bounds.max - bounds.min <= 180) { return; } feature.geometry = Object.assign({}, feature.geometry, { coordinates: shiftPositiveLongitudes(feature.geometry.coordinates) }); } // Clean and validate a Koppen-Geiger climate class code. function normalizeKoppenCode(code) { if (typeof code !== "string") { return null; } const trimmed = code.trim().replace(/\s+/g, ""); if (!trimmed) { return null; } if (!/^[A-Za-z]{2,3}$/.test(trimmed)) { return null; } const canonical = `${trimmed[0].toUpperCase()}${trimmed.slice(1)}`; return canonical; } // Clean categorical values against the configured category table for a metric. function normalizeCategoricalMetricValue(metricKey, value) { if (value === null || value === undefined) { return null; } const text = String(value).trim(); if (!text) { return null; } if (metricKey === "koppenZone") { return normalizeKoppenCode(text); } const categoryMeta = METRICS[metricKey]?.categoryMeta || {}; const matchedKey = Object.keys(categoryMeta).find((categoryKey) => categoryKey.toLowerCase() === text.toLowerCase()); return matchedKey || null; } // Trim string values while keeping a fallback for empty or non-string input. function getTrimmedText(value, fallback) { if (typeof value !== "string") { return fallback; } const trimmed = value.trim(); return trimmed ? trimmed : fallback; } // Parse numeric data fields while preserving blanks and missing values as null. function parseNullableNumber(value) { if (value === null || value === undefined) { return null; } if (typeof value === "string" && value.trim() === "") { return null; } const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } // Parse RFC 4180-style CSV text into rows while preserving blank cells. function parseCsvRows(csvText) { const rows = []; let row = []; let cell = ""; let isQuoted = false; for (let index = 0; index < csvText.length; index += 1) { const char = csvText[index]; if (isQuoted) { if (char === "\"") { if (csvText[index + 1] === "\"") { cell += "\""; index += 1; } else { isQuoted = false; } } else { cell += char; } continue; } if (char === "\"") { isQuoted = true; continue; } if (char === ",") { row.push(cell); cell = ""; continue; } if (char === "\r") { if (csvText[index + 1] === "\n") { continue; } row.push(cell); rows.push(row); row = []; cell = ""; continue; } if (char === "\n") { row.push(cell); rows.push(row); row = []; cell = ""; continue; } cell += char; } if (cell || row.length) { row.push(cell); rows.push(row); } return rows.filter((cells) => cells.some((value) => value.trim() !== "")); } // Convert the editable CSV file into the same county-FIPS keyed record map used by the app. function parseClimateDataCsv(csvText) { const [headers, ...rows] = parseCsvRows(csvText); if (!headers || !headers.includes("countyFips")) { throw new Error("Climate data CSV is missing a countyFips header."); } const records = {}; rows.forEach((cells) => { const rawRecord = headers.reduce((record, header, index) => { record[header] = cells[index] ?? ""; return record; }, {}); const countyFips = normalizeFips(rawRecord.countyFips, 5); if (countyFips) { records[countyFips] = rawRecord; } }); return normalizeCountyOverrideMap(records); } // Load editable county climate records from CSV before county polygons are styled. async function loadClimateData() { if (window.location.protocol === "file:") { throw new Error(LOCAL_CSV_LAUNCH_MESSAGE); } const response = await fetch(CLIMATE_DATA_CSV_URL, { cache: "no-store" }); if (!response.ok) { throw new Error(`Failed to load ${CLIMATE_DATA_CSV_URL}: HTTP ${response.status}`); } COUNTY_CLIMATE_OVERRIDES = parseClimateDataCsv(await response.text()); COUNTY_OVERRIDE_RECORD_COUNT = Object.keys(COUNTY_CLIMATE_OVERRIDES).length; } // Convert an editable county record into the app's normalized climate stats shape. function sanitizeOverrideRecord(rawRecord) { if (!rawRecord || typeof rawRecord !== "object") { return null; } const sourceText = getTrimmedText(rawRecord.source, "editable-data"); const sanitizedRecord = { source: sourceText }; DATA_METRIC_KEYS.forEach((metricKey) => { if (METRICS[metricKey].type === "categorical") { sanitizedRecord[metricKey] = normalizeCategoricalMetricValue(metricKey, rawRecord[metricKey]); } else { sanitizedRecord[metricKey] = parseNullableNumber(rawRecord[metricKey]); } }); return sanitizedRecord; } // Extract display metadata and FIPS identifiers from a county GeoJSON feature. function buildCountyMeta(feature) { const properties = feature.properties || {}; const stateFips = normalizeFips(properties.STATE, 2); const countyCode = normalizeFips(properties.COUNTY, 3); const featureId = normalizeFips(feature.id || `${stateFips}${countyCode}`, 5); const countyName = getTrimmedText(properties.NAME, `County ${featureId}`); const countyType = getTrimmedText(properties.LSAD, "County"); const stateAbbr = STATE_FIPS_TO_ABBR[stateFips] || `State ${stateFips}`; return { countyFips: featureId, stateFips, stateAbbr, countyName, countyType, displayName: `${countyName}, ${stateAbbr}` }; } // Resolve a county's climate stats from editable data or a missing-data fallback. function buildStatsForCounty(meta) { const override = sanitizeOverrideRecord(COUNTY_CLIMATE_OVERRIDES[meta.countyFips]); if (override) { return override; } return buildEmptyCountyStats("missing-county-record"); } // Build a county stats object with every metric set to null. function buildEmptyCountyStats(source) { return COUNTY_DETAIL_METRIC_KEYS.reduce( (record, metricKey) => { record[metricKey] = null; return record; }, { source } ); } // Return the metric configuration currently selected in the UI. function getCurrentMetric() { return METRICS[currentMetricKey]; } // Return the group that owns a metric, falling back to the first configured group. function getMetricGroupKeyForMetric(metricKey) { const group = METRIC_GROUPS.find((candidate) => candidate.metricKeys.includes(metricKey)); return group?.key || METRIC_GROUPS[0]?.key || ""; } // Return the active metric group configuration. function getCurrentMetricGroup() { return METRIC_GROUP_BY_KEY.get(currentMetricGroupKey) || METRIC_GROUPS[0]; } // Return metric display metadata for map and detail-only metrics. function getMetricConfig(metricKey) { return DETAIL_METRICS[metricKey] || METRICS[metricKey]; } // Round a value outward to a clean slider boundary while preserving full data coverage. function roundMetricBound(value, step, direction) { const decimalPlaces = String(step).split(".")[1]?.length ?? 0; const rounded = direction === "down" ? Math.floor(value / step) * step : Math.ceil(value / step) * step; return Number(rounded.toFixed(decimalPlaces)); } // Collect the category values that are present in loaded county records for a metric. function getUniqueCategoryValuesInData(metricKey) { const valueSet = new Set(); countyStats.forEach((stats) => { if (stats[metricKey]) { valueSet.add(stats[metricKey]); } }); const categoryOrder = Object.keys(METRICS[metricKey]?.categoryMeta || {}); return Array.from(valueSet).sort((a, b) => { const aIndex = categoryOrder.indexOf(a); const bIndex = categoryOrder.indexOf(b); if (aIndex !== -1 && bIndex !== -1) { return aIndex - bIndex; } if (aIndex !== -1) { return -1; } if (bIndex !== -1) { return 1; } return String(a).localeCompare(String(b)); }); } // Format a categorical value with its configured descriptive name. function getCategoricalDisplayLabel(metricKey, value) { if (!value) { return "No data"; } const meta = METRICS[metricKey]?.categoryMeta?.[value]; if (!meta) { return value; } return metricKey === "koppenZone" ? `${value} - ${meta.label}` : meta.label; } // Set numeric metric slider bounds from the loaded county data range. function applyNumericMetricBoundsFromData() { const numericMetricKeys = DATA_METRIC_KEYS.filter((metricKey) => METRICS[metricKey].type === "numeric"); numericMetricKeys.forEach((metricKey) => { const values = Array.from(countyStats.values()) .map((stats) => stats[metricKey]) .filter((value) => value !== null && value !== undefined) .map((value) => Number(value)) .filter((value) => Number.isFinite(value)); if (!values.length) { return; } const min = Math.min(...values); const max = Math.max(...values); const boundsStep = METRICS[metricKey].boundsStep ?? 1; const nextMin = roundMetricBound(min, boundsStep, "down"); const nextMax = roundMetricBound(max, boundsStep, "up"); METRICS[metricKey].min = nextMin; METRICS[metricKey].max = nextMax <= nextMin ? nextMin + boundsStep : nextMax; }); } // Keep the min and max range sliders in a valid order. function clampRanges() { const metric = getCurrentMetric(); if (metric.type !== "numeric") { return { min: 0, max: 0 }; } let min = Number(minRange.value); let max = Number(maxRange.value); if (min > max) { if (document.activeElement === minRange) { max = min; maxRange.value = String(max); } else { min = max; minRange.value = String(min); } } return { min, max }; } // Convert a metric value into readable UI text with the right unit. function formatMetricValue(metricKey, value) { if (value === null || value === undefined || Number.isNaN(value)) { return "No data"; } const metric = getMetricConfig(metricKey); if (metric.type === "categorical") { return getCategoricalDisplayLabel(metricKey, value); } if (metric.unit === "index") { return `${Math.round(value)}`; } if (metric.unit === "days") { const rounded = Number.isInteger(value) ? value : Math.round(value * 10) / 10; return `${rounded} days`; } if (metric.unit === "kWh/m2/day") { return `${Math.round(value * 100) / 100} kWh/m2/day`; } if (metric.unit === "in") { return `${Math.round(value * 10) / 10} in`; } if (metric.unit === "g/kg") { return `${Math.round(value * 100) / 100} g/kg`; } if (metric.unit === "ratio") { return `${Math.round(value * 1000) / 1000}`; } return `${Math.round(value * 10) / 10} ${metric.unit}`; } // Pick the map fill color for a metric value. function colorForValue(metricKey, value) { const metric = METRICS[metricKey]; if (metric?.type === "categorical") { const meta = metric.categoryMeta?.[value]; return meta ? meta.color : "#7f90ad"; } const ratio = (value - metric.min) / (metric.max - metric.min); const index = Math.max(0, Math.min(metric.palette.length - 1, Math.floor(ratio * metric.palette.length))); return metric.palette[index]; } // Check whether a county should appear active under the current filter. function shouldFeaturePassFilter(stats) { if (isCurrentMetricCategorical()) { const categoryValue = stats[currentMetricKey]; if (!categoryValue) { return false; } return currentCategoricalFilter === "all" || categoryValue === currentCategoricalFilter; } const rawValue = stats[currentMetricKey]; if (rawValue === null || rawValue === undefined) { return false; } const metricValue = Number(rawValue); if (!Number.isFinite(metricValue)) { return false; } const { min, max } = clampRanges(); return metricValue >= min && metricValue <= max; } // Build the Leaflet style object for a county feature. function styleForCounty(feature) { const countyFips = normalizeFips(feature.id, 5); const stats = countyStats.get(countyFips); const isSelected = selectedCountyFips === countyFips; if (!stats) { return buildCountyStyle(isSelected, NO_DATA_FILL_COLOR, 0.35); } const inRange = shouldFeaturePassFilter(stats); const metricValue = stats[currentMetricKey]; return buildCountyStyle( isSelected, inRange ? colorForValue(currentMetricKey, metricValue) : NO_DATA_FILL_COLOR, inRange ? 0.72 : 0.25 ); } // Apply shared stroke styling around a county fill style. function buildCountyStyle(isSelected, fillColor, fillOpacity) { return { color: isSelected ? SELECTED_COUNTY_STROKE_COLOR : DEFAULT_COUNTY_STROKE_COLOR, weight: isSelected ? 2 : 0.6, fillColor, fillOpacity, dashArray: isSelected ? "" : "2", opacity: 1 }; } // Redraw the legend content for the currently selected metric and filters. function updateLegend() { const metric = getCurrentMetric(); if (metric.type === "categorical") { const values = getUniqueCategoryValuesInData(currentMetricKey); const rows = values .map((value) => { const selectedClass = currentCategoricalFilter === value ? " legend-koppen-row-selected" : ""; return `
County FIPS: ${meta.countyFips}
${metricRows}Data Source Tag: ${sourceTag}
`; } // Build one metric row for the selected-county details panel. function buildMetricDetailRow(metricKey, stats) { return `${getMetricConfig(metricKey).label}: ${formatMetricValue(metricKey, stats[metricKey])}
`; } // Update the selected-county panel for the current selection state. function updateCountyDetails(countyFips) { if (!countyFips) { stateDetails.innerHTML = `Click a county to inspect climate details.
Editable data file: data/climate-data.csv
County polygons loaded: ${countyStats.size}
Editable county records loaded: ${COUNTY_OVERRIDE_RECORD_COUNT}
`; return; } const stats = countyStats.get(countyFips); const meta = countyMeta.get(countyFips); if (!stats || !meta) { stateDetails.innerHTML = "No climate record found.
"; return; } stateDetails.innerHTML = buildCountyDetailsBody(meta, stats); } // Batch county redraws into one animation-frame callback. function queueCountyLayerRedraw() { if (!countiesLayer || redrawQueued) { return; } redrawQueued = true; window.requestAnimationFrame(() => { redrawQueued = false; countiesLayer.eachLayer((layer) => { if (typeof layer.redraw === "function") { layer.redraw(); } }); }); } // Remove the manually managed county hover tooltip. function closeActiveCountyTooltip() { if (activeCountyTooltip && map.hasLayer(activeCountyTooltip)) { map.removeLayer(activeCountyTooltip); } activeCountyTooltip = null; } // Close both manual and Leaflet-bound county tooltips. function closeCountyTooltips() { closeActiveCountyTooltip(); if (!countiesLayer) { return; } countiesLayer.eachLayer((layer) => { if (typeof layer.closeTooltip === "function") { layer.closeTooltip(); } }); } // Reset all county paths to their current data-driven style. function resetCountyStyles() { if (countiesLayer) { countiesLayer.setStyle(styleForCounty); } } // Remove keyboard focus from Leaflet map paths during drag interactions. function blurFocusedMapFeature() { const activeElement = document.activeElement; if (!activeElement || activeElement === document.body) { return; } const mapContainer = map.getContainer(); if (mapContainer.contains(activeElement) && activeElement.classList.contains("leaflet-interactive")) { activeElement.blur(); } } // Move keyboard focus back to the selected county path. function focusSelectedCountyLayer() { if (!selectedCountyLayer || !selectedCountyFips || typeof selectedCountyLayer.getElement !== "function") { return; } const selectedElement = selectedCountyLayer.getElement(); if (selectedElement && typeof selectedElement.focus === "function") { selectedElement.focus({ preventScroll: true }); } } // Restore selected-county focus after map movement settles. function restoreSelectedCountyFocus() { blurFocusedMapFeature(); if (selectedCountyLayer && selectedCountyFips) { window.setTimeout(focusSelectedCountyLayer, 0); } } // Toggle the global county-tooltip suppression state during map gestures. function setCountyTooltipSuppression(nextIsSuppressed) { areCountyTooltipsSuppressed = nextIsSuppressed; map.getContainer().classList.toggle("is-map-dragging", nextIsSuppressed); if (nextIsSuppressed) { blurFocusedMapFeature(); closeCountyTooltips(); resetCountyStyles(); } } // Start suppressing county hover UI when a pointer gesture begins. function beginCountyTooltipSuppression() { didMapDragGesture = false; clearTooltipSuppressionOnNextMove = false; setCountyTooltipSuppression(true); } // Mark the current pointer gesture as a map drag or zoom. function markMapDragGesture() { didMapDragGesture = true; clearTooltipSuppressionOnNextMove = false; setCountyTooltipSuppression(true); } // Finish a pointer gesture and decide when hover tooltips can return. function finishCountyTooltipSuppressionGesture() { closeCountyTooltips(); restoreSelectedCountyFocus(); if (didMapDragGesture) { clearTooltipSuppressionOnNextMove = true; return; } setCountyTooltipSuppression(false); } // Keep tooltips closed until the first mouse movement after a drag. function closeSuppressedCountyTooltips() { if (!areCountyTooltipsSuppressed) { return; } closeCountyTooltips(); if (clearTooltipSuppressionOnNextMove) { clearTooltipSuppressionOnNextMove = false; didMapDragGesture = false; window.setTimeout(() => setCountyTooltipSuppression(false), 0); } } // Show the simple county hover tooltip when hover UI is allowed. function openCountyTooltip(layer, label, countyFips) { if (areCountyTooltipsSuppressed || selectedCountyFips === countyFips) { closeActiveCountyTooltip(); return; } const bounds = layer.getBounds?.(); if (!bounds || !bounds.isValid()) { return; } if (!activeCountyTooltip) { activeCountyTooltip = L.tooltip({ direction: "top", opacity: 0.95, permanent: false, interactive: false }); } activeCountyTooltip .setLatLng(bounds.getCenter()) .setContent(label) .addTo(map); } // Refresh county styles, legend text, detail text, and range labels together. function restyleAllCounties() { if (countiesLayer) { countiesLayer.setStyle(styleForCounty); queueCountyLayerRedraw(); } updateLegend(); updateCountyDetails(selectedCountyFips); updateRangeLabels(); } // Update the visible min and max labels below the range sliders. function updateRangeLabels() { if (isCurrentMetricCategorical()) { minValue.textContent = ""; maxValue.textContent = ""; return; } const { min, max } = clampRanges(); minValue.textContent = formatMetricValue(currentMetricKey, min); maxValue.textContent = formatMetricValue(currentMetricKey, max); } // Zoom the map to a county without animation. function focusCounty(layer) { map.stop(); map.fitBounds(layer.getBounds(), { padding: [24, 24], maxZoom: STATE_FOCUS_MAX_ZOOM, animate: false }); queueCountyLayerRedraw(); } // Open the selected-county popup with the current metric summary. function openCountyPopup(layer, countyFips) { const stats = countyStats.get(countyFips); const meta = countyMeta.get(countyFips); if (!stats || !meta) { return; } layer .bindPopup( `${meta.displayName}