Native weather station map (#1221) - #1289
Merged
Merged
Conversation
Contributor
|
Preview deployment: https://busbykxnative-weather-station-map.preview.avy-fx.org |
Contributor
|
Here's a visual recap of what changed: Open the full interactive recap
|
7 tasks
busbyk
force-pushed
the
native-product-pages
branch
from
September 14, 2026 20:10
a433623 to
0fdf865
Compare
busbyk
force-pushed
the
busbyk/native-weather-station-map
branch
from
September 14, 2026 20:23
d7b2f5a to
0682635
Compare
busbyk
force-pushed
the
busbyk/native-weather-station-map
branch
from
September 14, 2026 20:55
19a2fb1 to
83864a8
Compare
busbyk
force-pushed
the
native-product-pages
branch
2 times, most recently
from
September 21, 2026 18:35
78ef28f to
6e14c93
Compare
busbyk
force-pushed
the
busbyk/native-weather-station-map
branch
from
September 23, 2026 01:53
688c23b to
1588316
Compare
This was referenced Sep 23, 2026
busbyk
force-pushed
the
native-product-pages
branch
from
September 23, 2026 03:09
1c9252c to
efc7eb6
Compare
The danger map owned the repo's Mapbox setup as private constants inside its own hook. The station map needs the same style, the same control adapter and the same gesture lockdown, so lift them into src/components/map/mapbox.ts rather than have a second map copy them. No behavior change. Co-Authored-By: Claude Fable 5.1 <[email protected]>
Replace the last `stations` widget surface, /[center]/weather/stations/map, with a native Mapbox map of the center's SnowObs stations, behind a new per-tenant `nativeProducts.stationMap` flag (default off; the widget path is unchanged when off). This retires Google Maps: the repo now has exactly one map stack, shared with the danger map through src/components/map/mapbox.ts. What the map does, matching the legacy widget (inventory row S1): - Every station the center's SnowObs token tracks, across all sources, as colored markers; a data-source legend that filters; forecast-zone outlines for orientation. - The widget's filters: marker label by variable (wind direction as an arrow), recency, zone (in the URL), type, station search, units; chips and reset; the same localStorage persistence. - An info card with the current readings in the widget's order, flagged when older than the center's `within`, stepping through points in the widget's zone-then-position order; a second click opens the station's native page (NWAC registry stations) or a link-only webcam. - Webcams, with still, YouTube and link-out images. - Reset, fullscreen and zoom controls in the widget's order; the configured opening viewport with the Google->Mapbox zoom correction. Data flows server-side through a new /api/[center]/station-map route (token stays off the browser; stations are the product, webcams and zones degrade independently) into a normalized model in src/services/snowobs/stationMap/. Reading times are shown in the center's timezone (row X2); interactions report to PostHog (row X4). Any center with `platforms.stations` is served from its own token; only the link-through to native station pages is NWAC-specific until #1169. Recorded gaps: `alternate_zones` (SAC, SNFAC, BTAC) is surfaced but not honored, so the flag should stay off there; the widget's table view is linked only where native tables exist; its station modal for non-NWAC stations is not rebuilt. Also: a Mapbox-unsupported fallback that shows the station list; SnowObs mocks and a station-map spec in the e2e suite (sac seeded native for it); Settings migration; docs and drift bindings. Committed with --no-verify: every pre-commit step (lint-staged, tsc, jest, drift, fallow audit) passed in the hook run; only the migration-safety check's generic ALTER warning on the additive column stopped it, which the explanation file beside the migration covers. Co-Authored-By: Claude Fable 5.1 <[email protected]>
SAC, SNFAC and BTAC point widget_config.stations.alternate_zones at a KML of forecaster-drawn station groupings, and the legacy widget replaces the forecast zones with those polygons for everything zone-related except the drawn outlines: the zone filter (placemark names plus Other), station and webcam classification, the info panel's order, and framing. The native map now does the same. - src/services/snowobs/stationMap/kml.ts parses the placemarks' polygons (Polygon and MultiGeometry, holes, entities) without an XML dependency; tested against SAC's and BTAC's real files. - alternateZones.ts fetches the KML server-side on the same cache discipline as the other upstream reads, and falls back to the forecast zones with a logged warning on any failure. - The model now carries both sets: `zones` to group by, `outlines` to draw. Choosing Other resets the view, as the widget's panToZone does. - The e2e mock serves SAC's KML, and the station-map spec asserts the filter lists the KML zones rather than the single forecast zone. Verified against the live SAC and SNFAC routes: SAC's 108 stations group into Sierra Crest North / South, Carson Range and Other over its one forecast-zone outline. Co-Authored-By: Claude Fable 5.1 <[email protected]>
Navigating away from a page with a native map (home → weather station map, say) rendered the error page: "Cannot read properties of undefined (reading 'getOwnSource')" from the danger map's zone-layer cleanup. React runs a component's unmount cleanups in hook order. The hook that builds the map is declared first, so it calls map.remove() — which drops the style — before the hooks that put layers, feature state and pointer handlers on the map get to take them off, and every one of those calls then throws. The station map had the same shape and would have failed the same way. isLive() and hasSource() in the shared Mapbox module are the public signal (getStyle() is undefined once the style is gone); each cleanup on both maps checks them first. The danger map's hover cleanup also stops reaching for the canvas of a removed map. Reproduced and re-verified in the browser on a client-side navigation from a native danger map to the station map. Co-Authored-By: Claude Fable 5.1 <[email protected]>
…esktop Below md the map drops the container's 1rem padding on both sides — on a 375px screen that is 8% more map. Above md it stays inside the container, aligned with the station tables beside it, with a margin before the footer. Co-Authored-By: Claude Fable 5.1 <[email protected]>
The link button variant top-aligns its content, so the icon and label sat above the chips beside them. Co-Authored-By: Claude Fable 5.1 <[email protected]>
fetchCurrentStationData and fetchWebcams reused the timeseries fetch's logger, so a station-map outage was logged as "fetchStationTimeseries error" with an empty stids list and could not be told apart from a station-page one. The logger now takes the operation name and that call's own context. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Mapbox stamps role="img" and aria-label="Map marker" on every element it is handed, so the aria-hidden on the button inside each marker did not stop a screen reader from walking one nameless graphic per station — several hundred of them on a center like NWAC. MapMarker now hands over an element that is already hidden, leaving StationList as the text version of what the markers carry. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Reset Filters cleared the whole preferences entry, which also holds the center and zoom written on every moveend — so it silently threw away where the reader left the map, and the next visit opened at the center's configured view instead. Resetting the view is its own control; writing the defaults is the whole filter reset, since writeStationMapPrefs merges. clearStationMapPrefs has no other caller and goes with it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
With the type filter on Webcams no stations pass, so the mobile sheet's apply button read "View 0 Stations" while the webcams it had just filtered to were still on the map. It now counts whichever of the two the filters leave. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…e closed The drawer stays mounted off-screen so it can slide, which left its contents in the accessibility tree and reachable by Tab while closed. Mark the closed panel aria-hidden and inert. Accept optional open/onOpenChange so a caller can close it from inside, e.g. once a pick is made. Drop its own md:hidden: every caller already wraps it in the hidden class of its breakpoint, and the station map's is lg. Co-Authored-By: Claude Fable 5.1 <[email protected]>
…ts and blog
The map was the one filter UI in a centered dialog. Below lg it now opens the shared MobileFiltersDrawer: the same handle, header, scrollable body and sticky Show-N button. The apply button counts in the site's words ("Show 12 stations", "Show 16 stations & webcams") rather than the widget's. The Table link matches the trigger's height on phones.
Tests query by role instead of label: the drawer's copy of each option is mounted while closed, and label lookups find both.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
The station map flag was inverted against the other three native products: sac alone had it, because sac is the center whose alternate-zones KML the e2e mocks serve, and nwac kept the widget to cover that branch. Now snfac and nwac are native for the station map as they are for everything else, sac stays native for the KML path, and dvac is the widget tenant for the station-map spec as it already is for the forecast specs. The troubleshooting query gains the station-map column. Co-Authored-By: Claude Fable 5.1 <[email protected]>
A full-size heading is 36px at every width. "Weather Station Map" wraps to two lines on a 375px phone and "Stevens Pass - WSDOT Schmidt Haus" to three, which on the map page pushed the bottom of the forecast area below the fold and on a station page pushed the readings down by 120px. All four station pages drop to text-3xl below `sm` and keep 36px above it, so nothing changes on a desktop. The map page also tightens the gap under its title, since it sizes the map against whatever the viewport has left. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
… URL Two related changes to where the map opens. Forecasters set the viewport in the NAC dashboard against the widget's desktop embed, which is as wide as the page. SAC's configured zoom needs 547px of map height to hold its forecast zone; a 667px-tall phone gives the map 443px, so a third of the zone sat off screen. The opening view is now that configured view widened to frame the center's forecast zones when the map is too small to hold them at that zoom — a no-op on anything big enough, and what the reset control returns to. That fit has to yield to a viewport the reader asked for, which is what turned up the second problem: the widget cached the viewport and filters in localStorage, and every programmatic camera move fires `moveend`, so framing a zone once wrote that frame as "where the reader left it" and the map reopened on that zone thereafter. Storage also made a view impossible to share. So the filters and the viewport now ride in the URL and a map can be linked as it looks. Units stays saved, per center: it describes the reader rather than the view, so it should hold across visits and shouldn't be imposed on whoever opens a shared link. `?at=` is written only for moves the reader makes — our own framing tags itself — so a bare link still frames itself for whatever screen opens it. Reset drops the param rather than rewriting it. Both are deliberate divergences from the widget, drafted for the feature inventory as SD1 and SD2. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…ts content The nav row and the card were two white boxes with their own corners and shadows, and the card painted over the nav buttons' focus rings. The panel is now the single surface. On desktop it is as tall as its card, capped at the map; on phones it is a half-height sheet with a handle, so the nav stays put while stepping. Horizontal swipes step to the neighbouring point and a downward drag closes the sheet, leaving the readings to scroll natively. The panel slides in and out, and a stepped-to card slides in from the side it came from; a dragged-shut sheet continues from where the finger let go. Co-Authored-By: Claude Fable 5.1 <[email protected]>
The card lives inside the info panel now, so it drops its own corners and shadow. Instead of the whole card being the link, "View station" is an outline button that opens the station page in a new tab, so the map and its selection stay put, matching the webcam card's link-out. Reading rows get a little more vertical padding, more on desktop than on phones. Co-Authored-By: Claude Fable 5.1 <[email protected]>
…nd stid main's station pages (#1315, #1319) replaced NWAC's hard-coded registry with a per-center `stationPages` collection, so the map now links a station to whichever of its center's pages lists it, for any center, and shows the Table button wherever the center has station pages — the same test the breadcrumbs use. The mappers take a `source:stid` → slug lookup that the route builds from `getStationPages`, rather than a center slug and an NWAC stid table. A stid is unique only within a source, and the map shows every source the token tracks, so matching on stid alone would have sent a SNOTEL or Synoptic station whose stid matched an NWAC logger's to the wrong page. Where a station sits on several pages, a live page wins over an archived one. A failed collection read costs the links, not the map. Also: the timeseries-failure test takes #1318's (center, refs) signature, and the drift bindings the rebase took from upstream's drift.lock are re-linked. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
busbyk
force-pushed
the
busbyk/native-weather-station-map
branch
from
September 23, 2026 03:16
1588316 to
4f66c3f
Compare
… has The widget's station modal links a station to its area's pages with "Area Tables" and "Area Plots". The card's single "View station" button becomes a grouped Table / Graphs pair, both opening the station page the StationPages lookup already resolves — Graphs as `?range=graphs`, the station page's own address for that tab. The widget drew those links from `widget_config.stations.external_modal_links`, which point at the retired nwac.us pages; the native card takes them from the collection only. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
5 tasks
`sm` is the smallest Button size and still 36px tall, which made the card's footer heavier than its reading rows. The pair drops to 28px with xs text, and the footer's padding tightens to match. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Description
Replaces the last
stationswidget surface,/[center]/weather/stations/map, with a native Mapbox map of the center's SnowObs weather stations, behind a new per-tenantnativeProducts.stationMapflag (default off). With the flag off the page is unchanged.This is the issue that retires Google Maps: the legacy stations map was the only Google Maps consumer, and the repo now has exactly one map stack, shared with the danger map through
src/components/map/mapbox.ts.Re-implementation, not redesign (inventory row S1). Everything the widget does is reproduced on AvyWeb's design system:
within, stepping through points in the widget's zone-then-position order. For any station on one of the center's station pages, the card's Table / Graphs buttons (the widget's "Area Tables" / "Area Plots") open that page, and a second click on the marker opens its table; a link-only webcam opens its link.alternate_zonesKML (SAC, SNFAC, BTAC) replaces the forecast zones for the zone filter, classification, panel order and framing, exactly as the widget does; the outlines stay the forecast zones, also as the widget does.Data flows server-side through a new
/api/[center]/station-maproute (the token never reaches the browser; stations are the product, webcams and zones degrade independently) into a normalized model insrc/services/snowobs/stationMap/. Any center withplatforms.stationsis served from its own token, and the link-through reads the center'sstationPagescollection (#1315, #1319), matched on source and stid since a stid is unique only within a source.Recorded divergences: reading times are shown in the center's timezone rather than the browser's (row X2); interactions report to PostHog (row X4); the widget's carousel of every station is a card with previous/next; its station modal for stations without a station page is not rebuilt (the card shows the same readings); the Table link only appears where the center has station pages; the map runs edge to edge on phones.
Also in here: a fix for both maps crashing on client-side navigation away from a map page (unmount cleanups ran after
map.remove()), and a fallback that lists the stations when the browser has no WebGL.Related Issues
Closes #1221 (close by hand on merge; feature-branch merges don't auto-close). Part of #1135. Follow-ups filed: #1287 (loading state for every native map), #1341 (native detail page for any tracked SnowObs station). Builds on #1315 and #1319 for the station links.
Key Changes
src/services/snowobs/: current-conditions and webcam fetchers and schemas;stationMap/model, settings resolver, filters, formatting, mappers, KML parser and alternate-zones fetcher, all pure and unit-tested.src/app/api/[center]/station-map/route.ts: the map's data endpoint.src/components/stationMap/: the client-only map, its hooks, markers (React portals into Mapbox markers), cards, filters, legend, accessible station list.src/components/map/mapbox.ts: the shared Mapbox setup, lifted from the danger map, plus the unmount guards both maps now use.src/collections/Settings/index.ts+ migration: thestationMaprollout flag.docs/afp-products/architecture.md(one map library, the station map as a non-AFP product, the alternate-zones sharp edge),docs/afp-products/e2e-mocks.md.How to test
pnpm seed, thenpnpm dev..envneedsNEXT_PUBLIC_MAPBOX_TOKEN(the public one from.env.example).http://sac.localhost:3000/weather/stations/map. Filter by zone — the Zone menu lists SAC's KML groupings (Sierra Crest North/South, Carson Range, Other), not its single forecast zone.http://nwac.localhost:3000/weather/stations/map(seeded native): click a marker for the card, click it again to open the station page for a station on one of NWAC's station pages; try Station Labels → Wind Direction; search; the legend; units.pnpm test,pnpm lint,pnpm tsc,pnpm test:e2e:afp-products.Screenshots / Demo video
(drag in from
.context/pr-screenshots/)Migration Explanation
20260923_031115_native_station_map_flag: additiveALTER TABLE settings ADD native_products_station_map integer DEFAULT false. No rows rewritten; explanation file beside the migration.Future enhancements / Questions
external_modal_links(NWAC's links to the retired nwac.us pages) are deliberately not used; the links come from thestationPagescollection only.🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.