"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 `
${getCategoricalDisplayLabel(currentMetricKey, value)}
`; }) .join(""); legend.innerHTML = `

${metric.label}

${rows}
Filter: ${currentCategoricalFilter === "all" ? metric.allOptionLabel || "All categories" : getCategoricalDisplayLabel(currentMetricKey, currentCategoricalFilter)} Editable county records: ${COUNTY_OVERRIDE_RECORD_COUNT}
`; return; } const { min, max } = clampRanges(); const swatches = metric.palette.map((color) => `
`).join(""); legend.innerHTML = `

${metric.label}

${swatches}
Filter: ${formatMetricValue(currentMetricKey, min)} ${formatMetricValue(currentMetricKey, max)}
`; } // Build the selected-county details panel HTML. function buildCountyDetailsBody(meta, stats) { const metricRows = COUNTY_DETAIL_METRIC_KEYS.map((metricKey) => buildMetricDetailRow(metricKey, stats)).join(""); const sourceTag = getVisibleSourceTag(stats.source); return `

${meta.displayName}

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 = `

Selected County

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 = "

Selected County

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}
${METRICS[currentMetricKey].label}: ${formatMetricValue( currentMetricKey, stats[currentMetricKey] )}`, { autoPan: false } ) .openPopup(); } // Attach hover and click behavior to one county layer. function wireCountyInteractions(feature, layer) { const countyFips = normalizeFips(feature.id, 5); const meta = countyMeta.get(countyFips); const tooltipLabel = meta ? meta.displayName : countyFips; layer.on("mouseover", () => { if (areCountyTooltipsSuppressed) { closeActiveCountyTooltip(); return; } openCountyTooltip(layer, tooltipLabel, countyFips); }); layer.on("mouseout", () => { closeActiveCountyTooltip(); }); layer.on("click", (event) => { if (event.originalEvent?.detail > 1) { return; } if (event.originalEvent) { L.DomEvent.stopPropagation(event.originalEvent); } const wasAlreadySelected = selectedCountyFips === countyFips; const shouldAutoFocus = map.getZoom() <= STATE_FOCUS_MAX_ZOOM; selectedCountyFips = countyFips; selectedCountyLayer = layer; closeActiveCountyTooltip(); if (!wasAlreadySelected && shouldAutoFocus) { focusCounty(layer); } updateCountyDetails(countyFips); restyleAllCounties(); restoreSelectedCountyFocus(); openCountyPopup(layer, countyFips); }); } // Populate the group select element from the configured metric groups. function configureMetricGroupControl() { const groupOptions = METRIC_GROUPS.map((group) => ``).join(""); metricGroupSelect.innerHTML = groupOptions; metricGroupSelect.value = currentMetricGroupKey; } // Populate the metric select element from the active group. function configureMetricControl() { const group = getCurrentMetricGroup(); const metricKeys = group?.metricKeys || []; if (!metricKeys.includes(currentMetricKey)) { currentMetricKey = metricKeys[0] || DATA_METRIC_KEYS[0]; } metricSelectWrap.classList.toggle("is-hidden", metricKeys.length <= 1); metricSelect.innerHTML = metricKeys .map((metricKey) => ``) .join(""); metricSelect.value = currentMetricKey; } // Read a persisted panel state, falling back when storage is blocked or absent. function readStoredPanelState(storageKey, defaultExpanded) { try { const storedValue = window.localStorage.getItem(storageKey); if (storedValue === "true") { return true; } if (storedValue === "false") { return false; } } catch (error) { return defaultExpanded; } return defaultExpanded; } // Persist a panel state when localStorage is available. function writeStoredPanelState(storageKey, isExpanded) { try { window.localStorage.setItem(storageKey, String(isExpanded)); } catch (error) { // Some privacy modes block localStorage; the panel still works for this session. } } // Apply collapsed or expanded state to a map overlay panel. function setCollapsiblePanelState(panel, toggle, isExpanded, expandedLabel, collapsedLabel) { panel.classList.toggle("is-collapsed", !isExpanded); toggle.setAttribute("aria-expanded", String(isExpanded)); toggle.textContent = isExpanded ? expandedLabel : collapsedLabel; } // Wire one collapsible map overlay panel and remember its state. function configureCollapsiblePanel(panel, toggle, options) { if (!panel || !toggle) { return; } const initialExpanded = readStoredPanelState(options.storageKey, options.defaultExpanded); setCollapsiblePanelState(panel, toggle, initialExpanded, options.expandedLabel, options.collapsedLabel); L.DomEvent.disableClickPropagation(panel); L.DomEvent.disableScrollPropagation(panel); toggle.addEventListener("click", () => { const nextExpanded = panel.classList.contains("is-collapsed"); setCollapsiblePanelState(panel, toggle, nextExpanded, options.expandedLabel, options.collapsedLabel); writeStoredPanelState(options.storageKey, nextExpanded); }); } // Configure the county metrics and legend overlay panels. function configureMapOverlayPanels() { configureCollapsiblePanel(metricInfoPanel, metricInfoToggle, { storageKey: PANEL_STATE_STORAGE_KEYS.metricInfo, defaultExpanded: true, expandedLabel: "Hide County Metrics", collapsedLabel: "Show County Metrics" }); configureCollapsiblePanel(legendInfoPanel, legendInfoToggle, { storageKey: PANEL_STATE_STORAGE_KEYS.legendInfo, defaultExpanded: false, expandedLabel: "Hide Legend", collapsedLabel: "Show Legend" }); } // Populate the categorical filter from values present in the data. function configureCategoricalFilterControl() { if (!koppenFilterSelect) { return; } const metric = getCurrentMetric(); const values = getUniqueCategoryValuesInData(currentMetricKey); const options = [``]; values.forEach((value) => { options.push(``); }); koppenFilterSelect.innerHTML = options.join(""); if (!values.includes(currentCategoricalFilter)) { currentCategoricalFilter = "all"; } koppenFilterSelect.value = currentCategoricalFilter; } // Reset range sliders for the currently selected numeric metric. function configureRangeControls() { if (isCurrentMetricCategorical()) { return; } const metric = getCurrentMetric(); configureRangeInput(minRange, metric, metric.min); configureRangeInput(maxRange, metric, metric.max); updateRangeLabels(); } // Apply min, max, step, and value attributes to one range input. function configureRangeInput(input, metric, value) { input.min = String(metric.min); input.max = String(metric.max); input.step = String(metric.sliderStep ?? 1); input.value = String(value); } // Show either numeric range controls or the categorical filter. function updateFilterControlVisibility() { const useCategorical = isCurrentMetricCategorical(); rangeWraps.forEach((wrap) => { wrap.classList.toggle("is-hidden", useCategorical); }); if (koppenFilterWrap) { koppenFilterWrap.classList.toggle("is-hidden", !useCategorical); } const filterLabel = koppenFilterWrap?.querySelector("label"); if (filterLabel && useCategorical) { filterLabel.textContent = `${getCurrentMetric().label} Filter`; } } // Handle a metric dropdown change and rebuild the matching controls. function onMetricChange() { currentMetricKey = metricSelect.value; currentMetricGroupKey = getMetricGroupKeyForMetric(currentMetricKey); metricGroupSelect.value = currentMetricGroupKey; configureMetricControl(); updateFilterControlsForMetricChange(); } // Handle a group dropdown change and use its first metric when needed. function onMetricGroupChange() { currentMetricGroupKey = metricGroupSelect.value; const group = getCurrentMetricGroup(); currentMetricKey = group.metricKeys[0] || DATA_METRIC_KEYS[0]; configureMetricControl(); updateFilterControlsForMetricChange(); } // Rebuild filters and map styling after a group or metric selection changes. function updateFilterControlsForMetricChange() { updateFilterControlVisibility(); if (isCurrentMetricCategorical()) { configureCategoricalFilterControl(); } else { configureRangeControls(); } restyleAllCounties(); } // Clear county selection, focus styling, popup, and selected-county details. function clearSelection() { selectedCountyFips = null; selectedCountyLayer = null; blurFocusedMapFeature(); map.closePopup(); restyleAllCounties(); } // Clear selection when the user clicks map background rather than a county. function clearSelectionFromMapBackground(event) { const target = event.originalEvent?.target; const clickedInteractiveLayer = typeof target?.closest === "function" && target.closest(".leaflet-interactive"); if (clickedInteractiveLayer) { return; } if (!selectedCountyFips) { blurFocusedMapFeature(); return; } clearSelection(); } // Prevent attribution-control clicks from affecting map selection or zoom behavior. function guardAttributionControlInteraction() { const mapContainer = map.getContainer(); const attributionControl = mapContainer.querySelector(".leaflet-control-attribution"); if (!attributionControl || attributionControl.dataset.interactionGuarded === "true") { return; } attributionControl.dataset.interactionGuarded = "true"; L.DomEvent.disableClickPropagation(attributionControl); L.DomEvent.on(attributionControl, "dblclick", L.DomEvent.stopPropagation); L.DomEvent.on(attributionControl, "mousedown", L.DomEvent.stopPropagation); L.DomEvent.on(attributionControl, "mouseup", L.DomEvent.stopPropagation); } // Replace the active basemap with the requested tile provider. function setBaseLayer(providerKey) { if (baseLayer) { map.removeLayer(baseLayer); } const provider = TILE_PROVIDERS[providerKey]; baseLayer = L.tileLayer(provider.url, provider.options).addTo(map); window.requestAnimationFrame(guardAttributionControlInteraction); return baseLayer; } // Add a one-time basemap status message to the details panel. function addBasemapNotice(text) { if (document.getElementById("basemapNotice")) { return; } const notice = document.createElement("p"); notice.id = "basemapNotice"; notice.textContent = text; stateDetails.appendChild(notice); } // Choose the initial basemap and fallback behavior for blocked tile requests. function configureBaseLayer() { const openedFromFile = window.location.protocol === "file:"; if (openedFromFile) { setBaseLayer("esriWorldStreetMap"); addBasemapNotice("Opened from file://. Using a basemap that works without a Referer header."); return; } const osmLayer = setBaseLayer("openStreetMap"); let tileErrors = 0; let switchedToFallback = false; osmLayer.on("tileerror", () => { tileErrors += 1; if (switchedToFallback || tileErrors < 3) { return; } switchedToFallback = true; setBaseLayer("esriWorldStreetMap"); addBasemapNotice("OpenStreetMap tiles were blocked here. Basemap switched automatically."); }); } // Load county GeoJSON and build the Leaflet county layer. async function loadCounties() { try { const response = await fetch(US_COUNTIES_GEOJSON_URL); if (!response.ok) { throw new Error(`Failed to load counties: HTTP ${response.status}`); } const geojson = await response.json(); geojson.features = geojson.features.filter(prepareCountyFeature); applyNumericMetricBoundsFromData(); configureCategoricalFilterControl(); configureRangeControls(); updateFilterControlVisibility(); countiesLayer = L.geoJSON(geojson, { style: styleForCounty, onEachFeature: wireCountyInteractions }).addTo(map); restyleAllCounties(); } catch (error) { renderLoadError(error); } } // Render a load error without injecting dynamic text as HTML. function renderLoadError(error) { const message = error instanceof Error ? error.message : "Unable to load county data."; stateDetails.innerHTML = "

Load Error

"; const details = document.createElement("p"); details.textContent = message; stateDetails.appendChild(details); } // Normalize and register a county feature before it is added to the map. function prepareCountyFeature(feature) { const meta = buildCountyMeta(feature); if (!meta.countyFips || meta.stateFips === "72") { return false; } feature.id = meta.countyFips; normalizeAntimeridianFeature(feature); countyMeta.set(meta.countyFips, meta); countyStats.set(meta.countyFips, buildStatsForCounty(meta)); return true; } // Clear selection and return to the default national map view. function resetMapView() { clearSelection(); map.stop(); map.flyTo(DEFAULT_COUNTRY_VIEW.center, DEFAULT_COUNTRY_VIEW.zoom, { duration: 0.75 }); } // Attach DOM event listeners for controls outside the Leaflet map. function configureDomEventListeners() { metricGroupSelect.addEventListener("change", onMetricGroupChange); metricSelect.addEventListener("change", onMetricChange); minRange.addEventListener("input", restyleAllCounties); maxRange.addEventListener("input", restyleAllCounties); resetViewButton.addEventListener("click", resetMapView); clearSelectionButton.addEventListener("click", clearSelection); koppenFilterSelect?.addEventListener("change", () => { currentCategoricalFilter = koppenFilterSelect.value; restyleAllCounties(); }); } // Attach Leaflet map event listeners for redraws, clicks, drags, and tooltips. function configureMapEventListeners() { map.whenReady(guardAttributionControlInteraction); map.on("moveend zoomend popupclose", queueCountyLayerRedraw); map.on("click", (event) => { clearSelectionFromMapBackground(event); queueCountyLayerRedraw(); }); map.on("mousedown touchstart", beginCountyTooltipSuppression); map.on("dragstart zoomstart", markMapDragGesture); map.on("drag", () => { blurFocusedMapFeature(); closeSuppressedCountyTooltips(); }); map.on("mousemove touchmove", closeSuppressedCountyTooltips); map.on("mouseup touchend dragend zoomend", finishCountyTooltipSuppressionGesture); } // Configure controls, map behavior, base layers, and county data loading. async function initializeApp() { configureMetricGroupControl(); configureMetricControl(); configureMapOverlayPanels(); configureSourcesModal(); updateFilterControlVisibility(); configureBaseLayer(); configureDomEventListeners(); configureMapEventListeners(); try { await loadClimateData(); } catch (error) { renderLoadError(error); return; } loadCounties(); } initializeApp();