Native avalanche danger map on Mapbox (issue 05, Part B) - #1183
Open
busbyk wants to merge 5 commits into
Open
Conversation
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>
Contributor
|
Preview deployment: https://issue-05b-native-danger-map.preview.avy-fx.org |
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
Issue 05 Part B — the native avalanche danger map. Follows #1176 (Part A, the warnings banner) onto the same base branch.
Replaces the legacy
mapwidget on the home page with a native Mapbox map, behind the per-tenantdangerMapflag. Ships dark — the flag isfalsefor 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
ShapeSourceper zone and the afp widget does the same withzone-source-${index}/zone-layer-${index}; this uses a singlegeojsonsource with data-driven paint, so hover and the warning flash aresetFeatureState+feature-stateexpressions rather thansetPaintPropertyacross per-zone layer ids. The cost is that flash and hover are rewritten rather than ported. The benefit is that zones arrive pre-styled fromdecorateZoneFeatures, 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
fillOpacityand 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 indocs/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:
MapLayerSourceadapter — 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.getMapLayernow takes an optionalday, which the NAC API has always supported and we never passed — cheap now, expensive to retrofit onto issue 09's date picker.getForecastZoneDangermoves fromnac.tstodangerMap/mapLayer.ts.mapLayerFeatureSchemanow keeps the feature's top-levelid. It was modelling{type, geometry, properties}only and dropping the numeric zone id — which is exactly whatsetFeatureStatekeys on, so this is a hard prerequisite for the rendering above. NopromoteId/generateIdneeded; upstream already sends stable ids.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.DangerMap.client.tsxplus one hook per concern inuseDangerMap.ts(construction, data, layers, flash, pointer), paint definitions inzoneLayers.ts, and the/api/[center]/danger-mapdata endpoint.Two deliberate departures from the stored config, both recorded in the parity ledger:
saturationis 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.zoomis 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:
force-staticon 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. NoRevalidateOnView— Mapbox is browser-only so the map is a client component and can just fetch live.requestAnimationFrameat the widget's ~20fps rather than its 50mssetInterval, so it pauses with the tab instead of burning a timer in the background.<canvas>, so today's danger would otherwise be unreachable by keyboard or screen reader.ZoneListrenders the same zones as a visually hidden list of links, focusable so a keyboard user can see where they are.allCentersadded as optional to the danger-map config schema — SNFAC and SAC set it, NWAC has no key at all..env.examplenow carries NAC's publicpk.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
NAC_HOSTat a backend with live danger, or use the?day=YYYY-MM-DDsupport on the endpoint, to see it populated.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.tsc,lint, 810 tests,drift:checkpass.Screenshots / Demo video
To add.
Migration Explanation
One additive migration,
20260807_190252_native_danger_map_flag—ALTER TABLE settings ADD native_products_danger_map integer DEFAULT false. Same shape as Part A'snative_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
fallowCI 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 atc73a246a: 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-verifyfor that reason, with every check run manually instead. Worth its own clean-up PR.og/route.tsx's pre-existing 325-line cyclomatic-44GET(flagged only because this PR changed one import line in the file), and threeAvalancheCenterTypemembers that are load-bearing throughz.nativeEnum— fallow offers removing them as an auto-fix, which would make zod reject every non-USFS center.src/utilities/apiResponses.tsis 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.?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.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.