// Point forecast panel for the Air Quality page: every model's forecast at one // coordinate — typed in, picked by clicking the map, or taken from the browser's // location — drawn as one stacked chart area per pollutant, one line per model. // // The server lists the models and the pollutants each supplies at // /data/air_quality_point_forecast/models, and answers one model per request at // /data/air_quality_point_forecast/?lat=&lon=. Every model is asked separately // so each line lands as soon as its own answer does — a slow upstream (Open-Meteo, // later Google) never holds back the NOAA lines, which are read from disk. // // Every pollutant is drawn at once rather than picked one at a time, so a checkbox // per line is what turns lines off; an area whose lines are all unchecked leaves // with them. Drawing is window.PointForecastChart (javascript/point_forecast_chart.js), // which rebuilds on each of those changes. // // The panel charts downtown Seattle on load, so it opens with a forecast rather // than an empty form. The selected coordinate is owned by AirQualityPage, which // also marks it on the map; this panel reports changes through onLocationChange. (() => { const INDEX_URL = '/data/air_quality_point_forecast/models'; const CHART_CONTAINER_ID = 'air_quality_point_chart'; // Six decimals is ~0.1 m: enough that a typed coordinate is never rounded in the // field it was typed into. formatCoordinate drops the trailing zeros a map click // would otherwise leave behind. const COORDINATE_DECIMALS = 6; const DEFAULT_LOCATION = { lat: 47.617012, lon: -122.319130 }; // The fields start as written rather than as formatCoordinate would render them, // so the trailing zero of the default longitude survives. const DEFAULT_LOCATION_TEXT = { lat: '47.617012', lon: '-122.319130' }; // Lines switched off until asked for. PM2.5, the AQI and smoke are what this page // is about; the rest are Open-Meteo extras that would otherwise open six areas // deep. Unticking them here only sets the starting state — the checkboxes above // the chart bring them back. const DEFAULT_HIDDEN_POLLUTANTS = ['pm10', 'o3', 'no2']; const formatCoordinate = (value) => String(Number(value.toFixed(COORDINATE_DECIMALS))); const hasValues = (entry) => !!(entry && entry.values && entry.values.some(value => value != null)); const AirQualityPointForecast = ({ T, DS, darkMode, location, onLocationChange }) => { const { Box, Button, TextField, Checkbox } = MaterialUI; const Chart = window.PointForecastChart; const [index, setIndex] = React.useState(null); const [indexError, setIndexError] = React.useState(null); const [latText, setLatText] = React.useState(DEFAULT_LOCATION_TEXT.lat); const [lonText, setLonText] = React.useState(DEFAULT_LOCATION_TEXT.lon); const [inputError, setInputError] = React.useState(null); const [locating, setLocating] = React.useState(false); // Lines switched off, keyed by Chart.lineKey(modelId, pollutantId). Kept across // queries: having hidden a line, you mean it for the next coordinate too. const [hidden, setHidden] = React.useState({}); // The coordinate the charts are showing, which lags the fields until submitted. const [query, setQuery] = React.useState(null); // model id -> { state: 'loading' | 'done' | 'error', data, error } const [results, setResults] = React.useState({}); // What the last render measured, so "everything is unchecked" can be said out // loud instead of showing a blank card. const [chartHeight, setChartHeight] = React.useState(0); // Every submit bumps this; an answer carrying an older number is for a // coordinate no longer on screen and is dropped. const queryCounterRef = React.useRef(0); React.useEffect(() => { let cancelled = false; fetch(INDEX_URL) .then(response => { const contentType = response.headers.get('content-type') || ''; if (!response.ok || contentType.indexOf('json') === -1) { throw new Error(`models index answered ${response.status}`); } return response.json(); }) .then(data => { if (!cancelled) setIndex(data); }) .catch(error => { if (!cancelled) setIndexError(error.message); }); return () => { cancelled = true; }; }, []); const pollutants = index ? index.pollutants : []; const runQuery = (lat, lon) => { if (!index) return; const queryId = ++queryCounterRef.current; setQuery({ id: queryId, lat, lon }); setResults(Object.fromEntries(index.models.map(model => [model.id, { state: 'loading' }]))); index.models.forEach(model => { const url = `/data/air_quality_point_forecast/${encodeURIComponent(model.id)}` + `?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}`; fetch(url) .then(response => { const contentType = response.headers.get('content-type') || ''; if (response.ok && contentType.indexOf('json') !== -1) return response.json(); // Errors come back as plain text "Error: ". return response.text().then(text => { throw new Error(text.replace(/^Error:\s*/, '').slice(0, 120) || `HTTP ${response.status}`); }); }) .then(data => { if (queryCounterRef.current !== queryId) return; setResults(current => ({ ...current, [model.id]: { state: 'done', data } })); }) .catch(error => { if (queryCounterRef.current !== queryId) return; setResults(current => ({ ...current, [model.id]: { state: 'error', error: error.message } })); }); }); }; // Read during an effect without making the effect depend on it. const chartedRef = React.useRef(null); chartedRef.current = query; // The selected coordinate drives the charts. A map click therefore refreshes // them on its own: it only sets the location, and this notices the charts are // showing somewhere else. Submitting the form and the location button set the // location and run the query together, so this finds them already in agreement // and does not fetch the same point twice. // // A field whose text already means the incoming number is left alone, so none of // that rewrites what was typed — "-122.319130" would come back "-122.31913". React.useEffect(() => { if (!location || !index) return; if (Number(latText) !== location.lat) setLatText(formatCoordinate(location.lat)); if (Number(lonText) !== location.lon) setLonText(formatCoordinate(location.lon)); setInputError(null); const charted = chartedRef.current; if (charted && charted.lat === location.lat && charted.lon === location.lon) return; runQuery(location.lat, location.lon); }, [location, index]); // Open on the default coordinate, with the minor pollutants switched off. Once // only: after this the panel is driven by the form, the map, the location button // and the checkboxes. const startedRef = React.useRef(false); React.useEffect(() => { if (!index || startedRef.current) return; startedRef.current = true; const off = {}; index.models.forEach(model => model.pollutants.forEach(id => { if (DEFAULT_HIDDEN_POLLUTANTS.indexOf(id) !== -1) off[Chart.lineKey(model.id, id)] = true; })); setHidden(off); onLocationChange(DEFAULT_LOCATION); }, [index]); const handleSubmit = (event) => { if (event) event.preventDefault(); const lat = Number(latText.trim()); const lon = Number(lonText.trim()); if (latText.trim() === '' || lonText.trim() === '' || !isFinite(lat) || !isFinite(lon)) { setInputError('Enter latitude and longitude as decimal degrees.'); return; } if (lat < -90 || lat > 90 || lon < -180 || lon > 180) { setInputError('Latitude must be within ±90 and longitude within ±180.'); return; } setInputError(null); onLocationChange({ lat, lon }); runQuery(lat, lon); }; const handleLocate = () => { if (!navigator.geolocation) { setInputError('This browser cannot share its location.'); return; } setLocating(true); navigator.geolocation.getCurrentPosition( position => { setLocating(false); const lat = Number(position.coords.latitude.toFixed(COORDINATE_DECIMALS)); const lon = Number(position.coords.longitude.toFixed(COORDINATE_DECIMALS)); setInputError(null); onLocationChange({ lat, lon }); runQuery(lat, lon); }, error => { setLocating(false); setInputError(`Could not get your location (${error.message}).`); }, { timeout: 15000, maximumAge: 5 * 60 * 1000 } ); }; // One rebuild covers a new query, an answer landing, a line being unchecked and // a theme change alike — the chart is built from exactly these four inputs. React.useEffect(() => { if (!query || !index) return; setChartHeight(Chart.render(CHART_CONTAINER_ID, { T, models: index.models, pollutants: index.pollutants, results, hidden })); }, [query, index, results, hidden, darkMode]); React.useEffect(() => () => Chart.destroy(), []); const formatRunTime = (ms) => { const date = new Date(ms); return `${date.toLocaleDateString([], { weekday: 'short' })} ` + date.toLocaleTimeString([], { hour: 'numeric', hour12: true }); }; // One line of status per model, covering everything that stops it drawing. const modelStatus = (model) => { const result = results[model.id]; if (!result || result.state === 'loading') return { text: 'Loading…', color: T.textMuted }; if (result.state === 'error') return { text: result.error, color: T.statusAlert }; const data = result.data; if (data.coverage === 'outside') return { text: 'Outside model coverage', color: T.textMuted }; if (data.coverage === 'unpublished') return { text: 'No forecast published yet', color: T.statusWarn }; // A run is published (the map shows it) but was rendered before point data was // saved; the server's next refresh re-renders it. if (data.coverage === 'no_point_data') { return { text: 'Point data not ready yet — try again in a few minutes', color: T.statusWarn }; } if (!model.pollutants.some(id => hasValues(data.series[id]))) { return { text: 'No data here', color: T.textMuted }; } if (data.init_time_ms) return { text: `Run ${formatRunTime(data.init_time_ms)}`, color: T.textBody }; if (data.as_of_ms) return { text: `Fetched ${formatRunTime(data.as_of_ms)}`, color: T.textBody }; return { text: 'OK', color: T.textBody }; }; // A line can only be toggled once its model has answered with values for it. const lineAvailable = (model, pollutant) => { const result = results[model.id]; if (!result || result.state !== 'done') return false; return hasValues(result.data.series[pollutant.id]); }; const toggleLine = (key) => setHidden(current => { const next = { ...current }; if (next[key]) delete next[key]; else next[key] = true; return next; }); const fieldSx = { width: { xs: '100%', sm: 150 }, '& .MuiOutlinedInput-root': { borderRadius: 0, fontFamily: DS.MONO, fontSize: '13px', background: T.card }, '& .MuiOutlinedInput-notchedOutline': { borderColor: T.divider }, '& .MuiInputLabel-root': { fontFamily: DS.MONO, fontSize: '12px' } }; const secondaryButtonSx = { ...DS.secondaryBtnSx(T), justifyContent: 'center', height: 40 }; const primaryButtonSx = { ...secondaryButtonSx, background: T.accent, color: T.onAccent, border: `1px solid ${T.accent}`, fontWeight: 700, '&:hover': { background: T.accent, border: `1px solid ${T.accent}`, opacity: 0.88 }, '&.Mui-disabled': { background: T.divider, color: T.textMuted, border: `1px solid ${T.divider}` } }; // Per model: a swatch in its line color, its status, and a checkbox per line it // can draw. This is the chart's legend as well as its control. const renderModelRow = (model) => { const status = modelStatus(model); const color = Chart.colorFor(index.models, model.id); const lines = pollutants.filter(entry => model.pollutants.indexOf(entry.id) !== -1); return ( {model.label} {/* Wraps rather than ellipsizing: a status such as "not ready yet" is only useful if its advice survives a phone-width row. */} {status.text} {lines.map(pollutant => { const key = Chart.lineKey(model.id, pollutant.id); const available = lineAvailable(model, pollutant); return ( toggleLine(key)} sx={{ padding: '2px', borderRadius: 0, color: T.textMuted, '&.Mui-checked': { color }, '& .MuiSvgIcon-root': { fontSize: 15 } }} /> {pollutant.label} ); })} ); }; return ( setLatText(event.target.value)} inputProps={{ inputMode: 'decimal' }} placeholder="47.617012" sx={fieldSx} /> setLonText(event.target.value)} inputProps={{ inputMode: 'decimal' }} placeholder="-122.319130" sx={fieldSx} /> {inputError ? {inputError} : 'Or click the map to chart a point.'} {indexError && ( The point forecast is unavailable ({indexError}). )} {query && index && ( {/* The legend leads: it says which models answered and which lines are drawn, so it belongs above the areas it describes rather than after a screen and a half of scrolling. */} {index.models.map(renderModelRow)} {chartHeight === 0 && ( No lines to draw — tick a measurement above. )} {/* Highcharts gets a plain div, for the same reason Leaflet does: it writes its own classes onto the element. */}
{index.models.map(model => model.attribution).join(' · ')} )} ); }; window.AirQualityPointForecast = AirQualityPointForecast; })();