Skip to content

Native weather station map (#1221) - #1289

Merged
busbyk merged 20 commits into
native-product-pagesfrom
busbyk/native-weather-station-map
Sep 23, 2026
Merged

busbyk merged 20 commits into
native-product-pagesfrom
busbyk/native-weather-station-map

Conversation

@busbyk

@busbyk busbyk commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Description

Replaces the last stations widget surface, /[center]/weather/stations/map, with a native Mapbox map of the center's SnowObs weather stations, behind a new per-tenant nativeProducts.stationMap flag (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:

  • Every station the center's SnowObs token tracks, across all sources, as colored markers, with the 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 (carried 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. 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.
  • 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-to-Mapbox zoom correction.
  • A center's alternate_zones KML (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-map route (the token never reaches the browser; stations are the product, webcams and zones degrade independently) into a normalized model in src/services/snowobs/stationMap/. Any center with platforms.stations is served from its own token, and the link-through reads the center's stationPages collection (#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: the stationMap rollout flag.
  • Docs: 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.
  • Tests: unit coverage for every pure module; client tests for cards, markers, filters, prefs; SnowObs and KML mocks in the e2e harness with a station-map spec (widget tenant, native tenant with alternate zones, phone width).

How to test

  1. pnpm seed, then pnpm dev. .env needs NEXT_PUBLIC_MAPBOX_TOKEN (the public one from .env.example).
  2. sac is seeded native for the station map: 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.
  3. 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.
  4. dvac, seeded with the flag off, is the unchanged widget.
  5. Navigate from a native danger-map home page to the station map via the header nav (was the unmount crash).
  6. Phone width: filters collapse behind one button, the map runs edge to edge, the card is a bottom sheet.
  7. 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: additive ALTER TABLE settings ADD native_products_station_map integer DEFAULT false. No rows rewritten; explanation file beside the migration.

Future enhancements / Questions

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

@busbyk busbyk added the visual-recap Generate an interactive visual recap for this PR (opt-in; nothing runs without it) label Sep 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Here's a visual recap of what changed:

Visual recap

Open the full interactive recap

Large diff — this recap is a summarized view (top files + schema/API deltas).

@busbyk
busbyk force-pushed the native-product-pages branch from a433623 to 0fdf865 Compare September 14, 2026 20:10
@busbyk
busbyk force-pushed the busbyk/native-weather-station-map branch from d7b2f5a to 0682635 Compare September 14, 2026 20:23
@busbyk
busbyk force-pushed the busbyk/native-weather-station-map branch from 19a2fb1 to 83864a8 Compare September 14, 2026 20:55
@busbyk busbyk mentioned this pull request Sep 15, 2026
29 tasks
@busbyk
busbyk force-pushed the native-product-pages branch 2 times, most recently from 78ef28f to 6e14c93 Compare September 21, 2026 18:35
@busbyk
busbyk force-pushed the busbyk/native-weather-station-map branch from 688c23b to 1588316 Compare September 23, 2026 01:53
@busbyk
busbyk force-pushed the native-product-pages branch from 1c9252c to efc7eb6 Compare September 23, 2026 03:09
busbyk and others added 8 commits September 22, 2026 20:10
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]>
busbyk and others added 10 commits September 22, 2026 20:11
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
busbyk force-pushed the busbyk/native-weather-station-map branch from 1588316 to 4f66c3f Compare September 23, 2026 03:16
… 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]>
`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]>
@busbyk
busbyk merged commit 7fac2a5 into native-product-pages Sep 23, 2026
7 of 8 checks passed
@busbyk
busbyk deleted the busbyk/native-weather-station-map branch September 23, 2026 05:20

This branch was successfully deployed

1 active deployment
Preview 25537815 Deployed Sep 23, 2026 by busbyk via deploy #2431
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

visual-recap Generate an interactive visual recap for this PR (opt-in; nothing runs without it)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant