// Air Quality Map — the NAQFC AQMv7 hourly PM2.5 forecast for the Pacific // Northwest, drawn over terrain and scrubbable forward in time. // // There is no air quality endpoint. The server renders the forecast into a tile // pyramid under /data/air_quality// and publishes a manifest describing // it; this page reads that manifest and hands it to window.AirQualityMap // (javascript/air_quality_map.js), which owns Leaflet. Bounds, zoom range, tile // URLs, the forecast hours and the color legend all come from the manifest, so // changing the map's region or its palette is a backend change alone. // // Styled in the Console Light design system, consumed from the shared // window.DesignSystem module (javascript/design_system.js — see // docs/design_style_guide.md). const AirQualityPage = () => { const { Box, Slider, useTheme } = MaterialUI; const DS = window.DesignSystem; const darkMode = useTheme().palette.mode === 'dark'; const T = React.useMemo(() => DS.tokens(darkMode), [darkMode]); const MAP_CONTAINER_ID = 'air_quality_map'; // A run is stale once it is older than the gap between cycles: NAQFC publishes // at 06z and 12z, so nothing should ever be more than ~18 hours old. const STALE_AFTER_MS = 18 * 60 * 60 * 1000; // How often status.json is polled. Fast while the server is actually working // so the percentage moves, slow the rest of the time — this runs for as long as // the page is open, and a run only lands twice a day. const STATUS_POLL_ACTIVE_MS = 1500; const STATUS_POLL_IDLE_MS = 20000; // Cold start: there is nothing on screen but a "waiting" panel, so check often // enough that the first render is noticed promptly — but not at the active rate, // since this is also the cadence when no run is coming at all. const STATUS_POLL_WAITING_MS = 4000; const [manifest, setManifest] = React.useState(null); const [loadError, setLoadError] = React.useState(null); const [hourPosition, setHourPosition] = React.useState(0); const [opacity, setOpacity] = React.useState(1); const [tilesLoading, setTilesLoading] = React.useState(false); // The refresh task's progress, or null whenever it is idle or unknown. const [refreshStatus, setRefreshStatus] = React.useState(null); // null until the first fetch answers; false means the server is reachable but // has not published a run yet (a cold start, mid first render). const [manifestReady, setManifestReady] = React.useState(null); const mapRef = React.useRef(null); // Read inside the status poll without making it a dependency, so the poll can // keep retrying the manifest until the first run lands. const manifestReadyRef = React.useRef(false); manifestReadyRef.current = manifestReady === true; // The map view is preserved across a run change, so finishing a render does not // yank the user back to the default extent. const savedViewRef = React.useRef(null); // Hours still ahead of now. The server renders from the hour it rendered in, // but the page may be open long after that, so the past is filtered again here // rather than trusting the manifest to have been built a moment ago. const hours = React.useMemo(() => { if (!manifest) return []; const now = Date.now(); const upcoming = manifest.hours.filter(hour => hour.valid_time_ms > now); // If a run has aged out entirely, show its last hour rather than nothing. return upcoming.length ? upcoming : manifest.hours.slice(-1); }, [manifest]); const fetchManifest = React.useCallback(() => ( fetch(`${window.AirQualityMap.MANIFEST_URL}?t=${Date.now()}`) .then(response => { // Before the very first run is published there is no manifest. The static // handler answers a missing file with a 200 and an HTML error page rather // than a 404, so neither the status code nor a bare .json() tells us // anything useful -- the content type is the only reliable signal, and // without this check the JSON parse error surfaces as "forecast // unavailable" on every cold start. if (!response.ok) return null; const contentType = response.headers.get('content-type') || ''; if (contentType.indexOf('json') === -1) return null; return response.json().catch(() => null); }) .then(data => { if (!data || !data.tiles) { // Nothing published yet: not an error, just not ready. setManifestReady(false); setLoadError(null); return; } setManifestReady(true); // Only replace it when the published render actually changed. Any new // object here tears the map down and rebuilds it, so an unconditional // set would reset the view on every poll. setManifest(current => ( current && current.run_id === data.run_id && current.generated_at_ms === data.generated_at_ms ? current : data )); setLoadError(null); }) ), []); React.useEffect(() => { let cancelled = false; fetchManifest().catch(error => { if (!cancelled) setLoadError(error.message); }); return () => { cancelled = true; }; }, [fetchManifest]); // Poll the refresh task's status so the badge can show how far along an update // is, and pick up the new forecast the moment one finishes publishing. React.useEffect(() => { let cancelled = false; let timer = null; let wasBusy = false; const isBusy = (data) => { if (!data || !data.state || data.state === 'idle') return false; // Nothing rewrites the file if the process is killed mid-render, so an old // timestamp means the work is gone, not still running. const staleAfterMs = (data.stale_after_s || 60) * 1000; return Date.now() - (data.updated_at_ms || 0) < staleAfterMs; }; const schedule = (delay) => { if (!cancelled) timer = setTimeout(poll, delay); }; const poll = () => { if (document.visibilityState === 'hidden') { schedule(STATUS_POLL_IDLE_MS); return; } fetch(`/data/air_quality/status.json?t=${Date.now()}`) .then(response => { // Same story as the manifest: before the first refresh has run there is // no status file, and a missing one comes back as a 200 HTML page. Guard // on the content type so "no status yet" takes the normal idle path // rather than the network-error path -- the retry that pulls in the // first manifest lives below, and the catch would skip it. if (!response.ok) return null; const contentType = response.headers.get('content-type') || ''; if (contentType.indexOf('json') === -1) return null; return response.json().catch(() => null); }) .then(data => { if (cancelled) return; const busy = isBusy(data); setRefreshStatus(busy ? data : null); // A render just finished: the manifest now points at a new set of // tiles, so pick it up rather than sitting on the superseded run. // While no run has ever been published, keep retrying regardless -- // that first manifest can appear without this page having witnessed // the busy->idle edge (it may have loaded after the render began, or // the render may have been running before the page opened). if ((wasBusy && !busy) || !manifestReadyRef.current) { fetchManifest().catch(() => {}); } wasBusy = busy; schedule(busy ? STATUS_POLL_ACTIVE_MS : (manifestReadyRef.current ? STATUS_POLL_IDLE_MS : STATUS_POLL_WAITING_MS)); }) .catch(() => { // A genuine network failure; the badge simply keeps displaying the run // time, and the next poll tries again. if (cancelled) return; setRefreshStatus(null); schedule(manifestReadyRef.current ? STATUS_POLL_IDLE_MS : STATUS_POLL_WAITING_MS); }); }; poll(); return () => { cancelled = true; clearTimeout(timer); }; }, [fetchManifest]); // Create the map once the manifest has arrived and the container is mounted. React.useEffect(() => { if (!manifest || !hours.length || mapRef.current) return; mapRef.current = window.AirQualityMap.create(MAP_CONTAINER_ID, manifest, { opacity, onLoadingChange: setTilesLoading, // Null on first load, so the map opens fitted to the region; set only when // a newly published run has torn the previous map down. view: savedViewRef.current }); mapRef.current.setHour((hours[hourPosition] || hours[0]).index); return () => { if (mapRef.current) { savedViewRef.current = mapRef.current.getView(); mapRef.current.destroy(); mapRef.current = null; } }; }, [manifest, hours]); // A new run publishes a different set of hours, and hours drop off the front as // they fall into the past, so the slider position can end up past the end. React.useEffect(() => { if (hours.length && hourPosition > hours.length - 1) { setHourPosition(hours.length - 1); } }, [hours, hourPosition]); React.useEffect(() => { if (mapRef.current && hours[hourPosition]) { mapRef.current.setHour(hours[hourPosition].index); } }, [hourPosition, hours]); React.useEffect(() => { if (mapRef.current) mapRef.current.setOpacity(opacity); }, [opacity]); const selectedHour = hours[hourPosition] || null; const formatTime = (ms, withDay) => { const date = new Date(ms); const time = date.toLocaleTimeString([], { hour: 'numeric', hour12: true }); if (!withDay) return time; return `${date.toLocaleDateString([], { weekday: 'short' })} ${time}`; }; const runAgeMs = manifest ? Date.now() - manifest.init_time_ms : 0; const stale = runAgeMs > STALE_AFTER_MS; // What the badge says while the server is fetching and rendering a new run. // The percentage is dropped for the phases that have no measurable progress, // so it never shows a number that is not really moving. const REFRESH_LABELS = { checking: 'Checking NOAA', downloading: 'Downloading', decoding: 'Decoding', rendering: 'Rendering' }; const refreshLabel = (status) => { const label = REFRESH_LABELS[status.state] || 'Updating'; return status.progress == null ? label : `${label} ${Math.round(status.progress * 100)}%`; }; // One mark per midnight, so the slider reads as days rather than a bare count // of hours. Marks are placed by position, matching the slider's index scale. const dayMarks = React.useMemo(() => hours.map((hour, index) => { if (index === 0) return null; const previous = new Date(hours[index - 1].valid_time_ms).getDate(); if (new Date(hour.valid_time_ms).getDate() === previous) return null; return { value: index, label: new Date(hour.valid_time_ms).toLocaleDateString([], { weekday: 'short' }) }; }).filter(Boolean), [hours]); // Flat slider: square thumb, 2px rail, no shadow or halo anywhere. const sliderSx = { color: T.accent, height: 2, padding: '13px 0', '& .MuiSlider-rail': { opacity: 1, background: T.divider, height: 2, borderRadius: 0 }, '& .MuiSlider-track': { border: 'none', height: 2, borderRadius: 0 }, '& .MuiSlider-thumb': { width: 10, height: 18, borderRadius: 0, background: T.accent, '&:hover, &.Mui-focusVisible, &.Mui-active': { boxShadow: 'none' } }, // '1px', not 1: MUI's sizing system reads a bare number between 0 and 1 as a // fraction, so `width: 1` makes every day tick 100% of the slider wide and // pushes the whole document into a horizontal scroll. '& .MuiSlider-mark': { background: T.divider, height: '6px', width: '1px', borderRadius: 0 }, '& .MuiSlider-markLabel': { fontFamily: DS.MONO, fontSize: '10px', color: T.textMuted, top: 26 } }; const renderMapPanel = () => ( {/* Leaflet gets a plain div, never a styled Box. Leaflet adds its own classes (leaflet-container et al.) to this element imperatively, and an sx prop would hand React a fresh emotion class whenever the theme changes — React then rewrites className, wiping Leaflet's classes with it, and the absolutely positioned panes escape across the whole page because the container has lost `position: relative`. A bare div has no className for React to rewrite. */}
{tilesLoading && ( Loading )} ); const renderControls = () => ( Forecast time {selectedHour ? formatTime(selectedHour.valid_time_ms, true) : '—'} setHourPosition(value)} valueLabelDisplay="off" sx={sliderSx} /> NOW {hours.length ? `+${hours.length - 1} HR` : ''} Layer opacity setOpacity(value)} valueLabelDisplay="off" sx={sliderSx} /> {Math.round(opacity * 100)}% ); const renderLegend = () => { const legend = manifest.legend; return ( {`PM2.5 air quality index (${legend.unit})`} {legend.categories.map((category, index) => ( {category.label} {category.min === 0 ? `0–${category.max}` : `${category.min}–${category.max}`} ))} ); }; const renderMessage = (title, body) => ( {title} {body} ); // Shown until a run has been published. On a cold start the server has to // download ~68 MB and render tens of thousands of tiles, which is minutes of // nothing to look at, so this reports the same phases the status badge does // rather than an indefinite "loading". const renderStartup = () => { const progress = refreshStatus && refreshStatus.progress != null ? refreshStatus.progress : null; let title = 'Loading'; let body = 'Checking for a published forecast…'; if (refreshStatus) { title = 'Preparing the first forecast'; body = 'The server is fetching the latest NAQFC run and rendering it into ' + 'map tiles. This takes a couple of minutes the first time; the map ' + 'appears as soon as it is done.'; } else if (manifestReady === false) { title = 'No forecast yet'; body = 'No forecast run has been published yet. The server checks NOAA for a ' + 'new run every hour — this page picks one up automatically as soon as ' + 'it has been rendered.'; } return ( {title} {refreshStatus && ( )} {body} {refreshStatus && ( {/* Flat determinate bar; an indeterminate phase (checking, decoding) shows the rail alone rather than a bar that lies about progress. */} {progress != null && ( )} {refreshStatus.detail && ( {refreshStatus.detail} )} )} ); }; return ( Hourly PM2.5 forecast from NOAA's National Air Quality Forecast Capability, over the Pacific Northwest. Drag the slider to move forward through the forecast. {loadError && renderMessage( 'Forecast unavailable', `The forecast could not be reached (${loadError}).` )} {!manifest && !loadError && renderStartup()} {manifest && ( } /> {renderMapPanel()} {renderControls()} {renderLegend()} )} ); }; // Export to global scope for browser-based JSX compilation window.AirQualityPage = AirQualityPage;