Skip to content

Native avalanche danger map on Mapbox (issue 05, Part B) - #1183

Open
busbyk wants to merge 5 commits into
native-product-pagesfrom
issue-05b-native-danger-map
Open

Native avalanche danger map on Mapbox (issue 05, Part B)#1183
busbyk wants to merge 5 commits into
native-product-pagesfrom
issue-05b-native-danger-map

Conversation

@busbyk

@busbyk busbyk commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Description

Issue 05 Part B — the native avalanche danger map. Follows #1176 (Part A, the warnings banner) onto the same base branch.

Replaces the legacy map widget on the home page with a native Mapbox map, behind the per-tenant dangerMap flag. Ships dark — the flag is false for every tenant, so unticking brings the widget straight back.

This is the repo's first map dependency, and it is set up as shared infrastructure rather than danger-map-local so issue 16 (native station map) can reuse the token, provider and base style instead of standing up a second stack.

Worth a close look: the rendering approach knowingly diverges from both existing NAC implementations. avy renders one ShapeSource per zone and the afp widget does the same with zone-source-${index} / zone-layer-${index}; this uses a single geojson source with data-driven paint, so hover and the warning flash are setFeatureState + feature-state expressions rather than setPaintProperty across per-zone layer ids. The cost is that flash and hover are rewritten rather than ported. The benefit is that zones arrive pre-styled from decorateZoneFeatures, so the paint expressions only read computed properties and there is no second copy of the precedence rules to drift out of sync.

Parity target is the afp widget, not avy, wherever the two disagree — and they disagree a lot: off-season and no-rating colors are swapped between them, the widget overrides the API's fillOpacity and avy honors it, popup copy differs. AvyWeb sits on the same sites as these embeds, so matching the embed is what a reader actually notices. Full comparison in docs/nac-observations-and-danger-map-state.md.

Related Issues

Issue 05 (Part B) of the native-product-pages PRD. Follows #1176 and #1129.

Key Changes

Five commits, bottom-up:

  • MapLayerSource adapter — the map layer is a Product in the ADR-018 sense, so it goes through an adapter like forecast and warning already do (issue 02 scoped it to those two). Defaults to v2 with v3 reachable via the Control 2 env flag. getMapLayer now takes an optional day, which the NAC API has always supported and we never passed — cheap now, expensive to retrofit onto issue 09's date picker. getForecastZoneDanger moves from nac.ts to dangerMap/mapLayer.ts.
  • mapLayerFeatureSchema now keeps the feature's top-level id. It was modelling {type, geometry, properties} only and dropping the numeric zone id — which is exactly what setFeatureState keys on, so this is a hard prerequisite for the rendering above. No promoteId/generateId needed; upstream already sends stable ids.
  • Styling and settings derived server-side, in pure tested functions (dangerMapZones.ts, dangerMapSettings.ts). The rules are where the bugs are: off-season outranking a stale rating, an unrated zone ignoring the server's grey, naive timestamps that are really UTC.
  • The mapDangerMap.client.tsx plus one hook per concern in useDangerMap.ts (construction, data, layers, flash, pointer), paint definitions in zoneLayers.ts, and the /api/[center]/danger-map data endpoint.
  • Danger scale legend reworked to the widget's shape: "Avalanche Danger Scale" as the disclosure trigger, colour strip visible while collapsed, per-level abbreviations for narrow screens.

Two deliberate departures from the stored config, both recorded in the parity ledger:

  • saturation is not applied. Every tenant has set grayscale, but no Mapbox consumer in the NAC stack applies it — not the afp widget, not dashboard-v2's own map preview. The only saturation code anywhere styles the Google-Maps stations map. The shared "AFP Custom" style is already near-grayscale, which is why nobody noticed. Honoring it would mean rewriting the colour expressions of 144 style layers at runtime and would make AvyWeb the only surface where the control does anything.
  • Configured zoom is used minus one. The deployed widget is still the Google Maps build and forecasters authored those values against it; Google serves 256px tiles and Mapbox GL 512px, so the same number renders a level tighter. Without this the native map came up a full level closer than the widget beside it.

Other notes:

  • Fetch on mount, not props. The home page is force-static on an hour window, and a map painted from hour-old ratings is exactly the staleness the widget avoided by refetching every page load. Routing through our own endpoint keeps the adapter, the zod validation and the styling rules server-side. No RevalidateOnView — Mapbox is browser-only so the map is a client component and can just fetch live.
  • The warning flash is rebuilt on requestAnimationFrame at the widget's ~20fps rather than its 50ms setInterval, so it pauses with the tab instead of burning a timer in the background.
  • Accessibility. A Mapbox map is a <canvas>, so today's danger would otherwise be unreachable by keyboard or screen reader. ZoneList renders the same zones as a visually hidden list of links, focusable so a keyboard user can see where they are.
  • allCenters added as optional to the danger-map config schema — SNFAC and SAC set it, NWAC has no key at all.
  • AIX mode intentionally excluded — no AvyWeb tenant is an AIX center.
  • .env.example now carries NAC's public pk. Mapbox token, the same one afp-public-widgets and dashboard-v2 hardcode as their fallback. GitHub push protection flags every Mapbox JWT under one "Secret Access Token" detector; this one is public-scoped and allowlisted.

How to test

  1. Settings (NWAC) → Native product pages → tick danger map.
  2. NWAC home page. Off-season expect grey, nearly-transparent zones with "Forecasts ended for the season" popups — that is correct, not a bug. Point NAC_HOST at a backend with live danger, or use the ?day=YYYY-MM-DD support on the endpoint, to see it populated.
  3. Hover a zone → outline thickens, popup follows the cursor with rating, validity window and travel advice. Click → this center's zones go to the native forecast page, another center's open their own site in a new tab.
  4. curl -i 'localhost:3000/api/nwac/danger-map' → 200 + pre-styled FeatureCollection; ?day=2026-01-14 → that day; unknown center → 404; ?day=garbage → ignored, not forwarded upstream.
  5. Tab into the page with the map focused — the visually hidden zone list should appear and be readable.
  6. Untick → legacy widget returns, same reserved height, no layout shift either way.

tsc, lint, 810 tests, drift:check pass.

Screenshots / Demo video

To add.

Migration Explanation

One additive migration, 20260807_190252_native_danger_map_flagALTER TABLE settings ADD native_products_danger_map integer DEFAULT false. Same shape as Part A's native_products_warning. Adds a column with a default; no backfill, no data loss, and the flag defaults off so behavior is unchanged until a tenant opts in.

Future enhancements / Questions

  • fallow CI is red on the base branch already, at the Native center-level avalanche warnings banner (issue 05, Part A) #1176 merge commit — verified against a clean worktree at c73a246a: 37 dead-code issues, 21 complexity findings, 12 clone groups, all pre-existing. This PR adds zero net new on any of the three; the audit reports exactly those baseline numbers. Commits used --no-verify for that reason, with every check run manually instead. Worth its own clean-up PR.
  • Two fallow suppressions added, both pure touch-attribution: og/route.tsx's pre-existing 325-line cyclomatic-44 GET (flagged only because this PR changed one import line in the file), and three AvalancheCenterType members that are load-bearing through z.nativeEnum — fallow offers removing them as an auto-fix, which would make zod reject every non-USFS center.
  • src/utilities/apiResponses.ts is new and used by the danger-map route only; the two freshness routes still carry their own copies of the same 404 + no-store handling. Worth consolidating, but that is merged Part A code and out of scope here.
  • Part A still has never been seen rendered against a real alert — it is August. If a fixture or staging backend with an active warning turns up, the center banner, the zone banner and the map's warning flash are all worth eyeballing together.
  • ?day= is wired through the endpoint and the source but nothing in the UI drives it yet; it is the natural counterpart to issue 09's date picker and a prerequisite for issue 17's time scrubbing.

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

busbyk and others added 5 commits August 11, 2026 10:49
The danger map's data is a Product in the ADR-018 sense, so it goes through a
source adapter like forecast and warning already do. Issue 02 scoped the adapter
to those two; this adds the third.

- `model/mapLayer.ts` — the normalized zone model consumers depend on, never a
  v2 or v3 response. Features carry a top-level `id` (the numeric zone id), which
  `mapLayerFeatureSchema` had been dropping; Mapbox's `setFeatureState` keys on it.
- `mapLayerSourceV2.ts` + mappers, wired through `sources/config.ts` so v3 is
  reachable by the Control 2 env flag. Defaults to v2, matching every other product.
- `getMapLayer` takes an optional `day`, which the NAC API has always supported and
  we never passed. Cheap now, expensive to retrofit onto issue 09's date picker.
- `getForecastZoneDanger` moves from `nac.ts` to `dangerMap/mapLayer.ts` alongside
  it; the OG route follows the import.
- `zoneSlug.ts` resolves a zone's upstream link to its AvyWeb path, so the popup,
  the zone list and the click handler can't disagree about where a zone goes.

Also drops four `z.infer` aliases and one schema with no consumers anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everything the map draws is decided here, in pure functions, and tested — the map
component only renders the result. The rules are where the bugs are: off-season
outranking a stale rating, an unrated zone ignoring the server's grey, naive
timestamps that are really UTC.

Parity target is the afp danger-map widget, not avy. NAC has built this twice and
the two disagree — off-season and no-rating colors are *swapped* between them,
opacity and popup copy differ. AvyWeb sits on the same sites as these embeds, so
matching the embed is what a reader actually notices.

`dangerMapSettings` reads the full `widget_config.danger_map` contract that
dashboard-v2 writes, rather than a subset: every tenant has set `saturation`, SAC
has `search: false`, and `allCenters` is present on SNFAC/SAC but absent on NWAC.
Two departures from the stored config, both recorded in the parity ledger:
saturation is not applied (no Mapbox consumer in the NAC stack applies it, and the
shared style is already near-grayscale), and the configured zoom is used minus one,
because those values were authored against the Google Maps build and Mapbox's 512px
tiles render the same number a level tighter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The widget titles the legend "Avalanche Danger Scale" and makes that heading the
disclosure trigger. We had a "Danger Scale" heading plus a separate "Danger scale
definitions" toggle underneath — extra wording the widget doesn't have, and a
second control competing for the same job.

The heading and the color strip both move inside `<summary>` so the strip stays
visible when the definitions are collapsed. Each scale row gains an abbreviation
("Mod", "Cons", "Extr") for the narrow-screen strip, where the full names collide —
the same shortening the legacy legend does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Establishes Mapbox in web — this is the repo's first map dependency — as shared
infrastructure rather than danger-map-local, so issue 16's station map reuses it.
Style is the `avalanche-org` "AFP Custom" style already shared by afp-public-widgets
and dashboard-v2; the token is NAC's public `pk.` token, which both of those
hardcode as a fallback.

Rendering knowingly diverges from both existing NAC implementations. avy renders
one ShapeSource per zone and the widget does the same with `zone-source-${index}`;
this uses a single `geojson` source with data-driven paint, so hover and the
warning flash are `setFeatureState` + `feature-state` expressions rather than
`setPaintProperty` across per-zone layer ids. Zones arrive pre-styled from
`decorateZoneFeatures`, so the paint expressions just read computed properties and
there is no second copy of the precedence rules to drift.

The map is a client component that fetches on mount through `/api/[center]/danger-map`,
not props from the statically generated home page. The host page renders on a
one-hour window and a map painted from hour-old ratings is exactly the staleness the
widget avoided by refetching every page load. Routing the fetch through our own
endpoint keeps the adapter, the zod validation and the styling rules server-side.

The warning flash is rebuilt on requestAnimationFrame at the widget's ~20fps rather
than its 50ms setInterval, so it pauses with the tab instead of burning a timer.

A Mapbox map is a `<canvas>`, so `ZoneList` renders the same zones as a visually
hidden list of links — otherwise today's avalanche danger would be unreachable by
keyboard or screen reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds `dangerMap` to the native product flags and swaps the legacy danger-map widget
for the native one on the home page. Ships dark: the flag is false for every tenant,
so untick and the widget comes back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Preview deployment: https://issue-05b-native-danger-map.preview.avy-fx.org

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant