Fix 1: Never zoom out when clicking a feature - preserves user's
intentional zoom level by checking cameraForBounds before fitBounds
Fix 2: Single-click to switch between features - clicking outside
the current feature's circle clears selection and selects new feature
Fix 3: View mode toggle reflects saved state on load - initialize
viewMode from localStorage on store creation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
MultiPolygon coordinates need .flat(2) not .flat(1) to get actual
coordinate pairs. With flat(1), we were iterating over rings instead
of coordinates, causing invalid lat values > 90.
Also added bounds validation before fitBounds to catch future issues.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Three bugs fixed:
1. Map mode now restores all hidden layers - using separate tracking
arrays for fills, lines, and symbols that persist across mode switches
2. Satellite mode now hides ALL vector layers (fills, lines, symbols)
for true satellite-only view
3. Hybrid mode keeps lines and symbols visible for road/label overlay
Each mode switch first restores all layers to a clean slate before
hiding the appropriate ones for that mode.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The background layer type was not being hidden, causing it to cover
the satellite imagery. Now hideVectorFills hides fill, fill-extrusion,
AND background layer types.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add viewMode state to store with localStorage persistence
- Add satellite layer functions to MapView (ESRI World Imagery via nginx proxy)
- Add view mode segmented control in LayerControl popover
- Add view-mode-control CSS styles
- Hide/show vector fills and lines based on view mode
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Root cause: Vite's bundling of namedTheme through the registry re-export
broke MapLibre's Web Worker, silently preventing all GeoJSON rendering
(routes, boundaries, measure tool). Fixed by importing namedTheme
directly from protomaps-themes-base in MapView.jsx.
Also guards queryRenderedFeatures calls for optional overlay layers
(USFS trails, BLM roads) that may not exist in the current style.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add highlight section to overlay config for all themes with
theme-appropriate colors:
- dark: muted olive-green (#7a9a6b)
- light: forest green (#4a7040)
- clean: Google blue (#1a73e8)
- cyberpunk: electric cyan (#00f0ff)
Update addBoundaryLayer() to read config from
getOverlayConfig(themeId, "highlight") for consistent styling
across all themes.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
All overlay layer add functions now read colors, opacities, and widths
from the theme registry instead of hardcoded dark/light branches.
registry.js changes:
- Add complete darkOverlay and lightOverlay config objects
- Each overlay layer has its own config section:
- hillshade: exaggeration, illuminationDirection, shadowColor, highlightColor
- traffic: opacity
- contours: colors, opacities, widths (with opacityMod), label styling
- contoursTest: cascades from contours, overrides colors
- contoursTest10ft: cascades from contours, overrides colors
- publicLands: per-category fill/outline colors and opacities, labels
- usfsTrails: roads/trails colors by use type, labels
- blmTrails: route colors by use class, labels
- Add getOverlayConfig(themeId, layerKey) function
- Contour variants cascade missing keys from same theme's contours
- Width values use self-documenting object format: { z11: 0.5, z14: 1.0 }
MapView.jsx changes:
- All 8 overlay add functions now take themeId parameter
- Functions call getOverlayConfig() to get merged config
- No hardcoded color/opacity/width values remain in overlay functions
- Theme switching re-adds all active overlays with new theme config
This is a refactor - light and dark themes render identically to before.
Custom themes can now override individual overlay styling values.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Introduces src/themes/registry.js with:
- getTheme(id) - lookup theme config by ID
- getThemeColors(id) - get flavor object (namedTheme for built-ins, custom colors for others)
- getThemeSprite(id) - get sprite URL with fallback for custom themes
- themeList() - list available themes for UI
Updates MapView.jsx:
- Import registry functions instead of namedTheme directly
- buildStyle() uses getThemeColors() and getThemeSprite()
- Overlay add functions use isCurrentThemeDark() helper that checks
registry dark flag instead of string comparison
Reference files:
- dark-flavor-reference.json - full namedTheme('dark') output (73 flat keys + pois + landcover)
- light-flavor-reference.json - full namedTheme('light') output
- README.md - schema documentation for creating custom themes
This is a refactor only - light/dark themes render identically to before.
Custom themes can now be added to registry.js with full flavor objects.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- 4WD High Clearance: bright orange
- 4WD Low: yellow/gold (was tan)
- ATV: red (was rust)
- Single Track: purple (was dark red)
- 2WD/Easy: light yellow (was light tan)
- Non-motorized: brighter green
- Snow: blue (unchanged)
Colors now spread across spectrum for easier distinction.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Exclude ARTERIAL, COLLECTOR, LOCAL, paved, highways
- Keep RESOURCE, UNKNOWN, and empty functional class
- Most BLM data has empty func class, so whitelist was too strict
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Only show routes with RESOURCE functional class
- Removes all urban, local, and unknown roads
- Keeps backcountry access routes only
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Exclude ARTERIAL and COLLECTOR functional classes
- Exclude any route with HWY_CLASS (highways)
- Keeps RESOURCE, LOCAL, and UNKNOWN in rural areas
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add 14px wide transparent hit layers below visible styled layers
- Click events now target hit layers for easier selection
- Cursor changes to pointer on hover over hit layers
- Keeps visual styling thin while providing fat click targets
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add USFS trails/roads as toggleable map layer via PMTiles
- Trails: dashed brown lines, roads: solid khaki lines
- Labels at zoom 12+ for trail and road names
- Click handler shows popup with trail/road info
- Feature-flag gated with has_usfs_trails (default false)
- Add Trails toggle to Layer Control panel
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Pressing Escape when a place is selected now:
- Closes the place card
- Clears the selected place state
- Removes the boundary outline from the map
- Clears the selected label highlight
Same behavior as clicking empty map area.
Instead of changing entire layer paint properties (which highlights all
labels in the layer), use MapLibre case expressions to target only the
specific feature by name. This prevents highlighting ALL labels when
hovering/selecting one.
Expression format:
["case", ["==", ["get", "name"], featureName], highlightColor, originalColor]
Fixes text duplication at z14+ on small places.
Two fixes for rendering at close zoom (z14+):
1. Boundary rendering:
- Add subtle fill layer with 0.05 opacity (barely visible tint)
- Insert fill and outline layers BELOW symbol layers using
firstSymbolId so labels render on top
- Dashed outline remains primary indicator
2. Highlight text duplication:
- Remove separate hover-hl-* and selected-hl-* symbol layers
that were creating duplicate/ghost text
- Instead, modify ORIGINAL layer paint properties directly using
setPaintProperty on text-color, text-halo-color, text-halo-width
- Store original paint values for restoration on clear
- Single symbol layer per label = no duplication
Test at z14+ on small places like Rock Creek Park - no text ghosting.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Remove the clear-then-set pattern when clicking a new labeled feature.
Previously, clicking Kimberly after Twin Falls would:
1. Clear Twin Falls boundary
2. Set new place
3. Wait for API to return
4. Set Kimberly boundary
This caused the old boundary to disappear before the new one was ready,
requiring two clicks.
Now when clicking a new labeled feature:
1. Set new place immediately
2. When API returns, updateBoundary(newData) replaces old data in-place
The GeoJSON source only holds one dataset - setting new data naturally
replaces old data. Explicit clear only needed when deselecting or
clicking empty map.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The useEffect-based boundary rendering was unreliable due to React's
state lifecycle - the effect would fire before boundary data arrived
from the API, then not re-trigger properly when data was populated.
New approach:
- Remove the boundary useEffect entirely
- Define updateBoundary function in map load handler
- Store function reference in Zustand store and local ref
- PlaceCard calls updateBoundary(geometry) directly when API returns
- Click handlers call updateBoundary(null) to clear
This bypasses React's render cycle - the map library handles its own
state and we tell it what to draw when we have the data.
Test sequence:
- Click Twin Falls → boundary shows on first click
- Click Kimberly → boundary shows on first click
- Switch between them → old clears, new shows
- Click empty map → boundary clears
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The boundary useEffect was only triggered when selectedPlace changed,
but boundary data arrives asynchronously from /api/place fetch. By the
time fetchPlaceDetails completed and enriched selectedPlace with
boundary, the useEffect had already fired and saw no boundary.
Fix: add selectedPlace?.boundary to the dependency array so the effect
re-runs when boundary data is populated by the API response.
Test sequence:
- Click Twin Falls → boundary shows on first click
- Click Kimberly → boundary shows on first click (was broken)
- Click empty map → boundary clears
- Click Twin Falls again → boundary shows on first click
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Clear boundary source data when:
- Clicking outside the marker circle (deselecting)
- Clicking a new place (before setting new boundary)
- Clicking empty map area (generic map click)
This ensures:
1. Re-clicking the same city after dismissing re-renders boundary
(selectedPlace is properly cleared, triggering fresh useEffect)
2. Clicking away doesn't leave stale boundary outline
(boundary source explicitly cleared to empty FeatureCollection)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Fixes three critical bugs:
1. CRASH: Guard addSource/addLayer calls with existence checks to
prevent "Source already exists" errors in React strict mode
double-mount scenarios
2. BOUNDARY: Wrap boundary update logic in a function and properly
handle async style loading - check isStyleLoaded() and use
map.once('load') as fallback
3. FONTS: Use 'Noto Sans Regular' for highlight layers instead of
'Noto Sans Medium'/'Noto Sans Bold' which 404 on protomaps CDN
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Replace feature-state based highlighting with filter-based approach
using dedicated highlight layers. PMTiles don't have feature IDs,
causing setFeatureState to silently fail. The new approach:
- Creates hover-hl-* and selected-hl-* layers per source-layer
- Uses EMPTY_FILTER to hide layers by default
- Updates filter to match feature name when highlighting
- Preserves all existing functionality (zoom, boundary, place card)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add visual hover feedback on map labels (cities, POIs, regions)
using MapLibre feature-state paint property expressions
- Hover: cursor pointer + brighter text + subtle halo glow
- Click highlight: accent color text + accent glow halo
- Highlight persists until place card closed or different feature clicked
- Remove DOM overlay marker for feature mode (use native paint instead)
- Consolidate interactive layers into INTERACTIVE_LAYERS constant
- Re-apply styles on theme change
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add whiteSpace: nowrap to Get Directions button to prevent text wrap
- Add ScaleControl (imperial units) to bottom-right of map
- Add dark theme styling for scale bar
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The load event only fires once during map init. When route state
changes before style is fully loaded, the once(load) handler
never executes because load already fired. idle fires after every
render cycle, ensuring the route draws once the style is ready.
B14: Comment out RadialMenu render — all 5 wedges show "coming soon"
toasts with no functional actions. Code preserved for when
actions are wired.
B5: Add detailed JSDoc to fetchAuthState explaining the
redirect:manual pattern and its dependencies on Caddy/Authentik
configuration.
B9: Investigated — user-select CSS errors come from MapLibre's own
maplibre-gl.css, not our code. WONTFIX (library issue).
Caddy changes (CT 101):
B6: Fixed recon.echo6.co header stripping bug — same pattern as navi
B8: Added /api/traffic/* to @authed_user (TomTom API must be authed)
B1: Add ResizeObserver to MapView.jsx to handle layout settling.
The map canvas had 0 height at init because layout hadnt settled
when the useEffect fired. ResizeObserver calls map.resize() on
any container size change.
B11: Use native outpost start URL for login initiation:
/outpost.goauthentik.io/start?rd=%2F
This properly triggers auth flow and redirects to / after login.
Removed the Caddy /login handler that wasnt redirecting correctly.
- MapView.jsx: extract addBoundaryLayer function, use getComputedStyle
for accent color (MapLibre rejects CSS vars in paint properties)
- PlaceCard.jsx: gate fetchNearbyContacts on auth.authenticated
- PlaceDetail.jsx: gate fetchNearbyContacts on auth.authenticated
- api.js: replace invalid timeout option with AbortSignal.timeout()
- RadialMenu.jsx: remove user-select from SVG style (Firefox rejects)
- Panel.jsx: add Cancel button for pending directions state
Three regressions fixed:
1. mapCenter is now initialized on map 'load' event, not just 'moveend'.
Searches immediately after page load now correctly include viewport
bias instead of falling back to default Twin Falls coords.
2. setThemeOverride had stray code from startDirections that wiped
stops and added undefined place data. Toggling theme cleared the
active route. Restored setThemeOverride to its correct
theme-only implementation.
3. usePanelState returned ROUTE_CALCULATED before checking selectedPlace,
so preview cards could never appear alongside a calculated route.
Refactored to decouple preview state from route state - preview
renders whenever selectedPlace exists, independent of route state.
Major refactor consolidating two-panel layout (Routes/Contacts + floating
PlaceDetail) into one 400px left column with state-driven content.
Architecture:
- New PlaceCard component for preview and stop cards (collapsible)
- Panel states: IDLE, PREVIEW, ROUTING, PREVIEW_ROUTING, ROUTE_CALCULATED
- usePanelState selector in store.js derives state from selectedPlace/stops/route
- StopList now renders stops as PlaceCard with variant=stop
- PlaceDetail.jsx removed from App.jsx (content moved to PlaceCard)
UX refinements:
- Panel width 400px (was 360px) to fit buttons on one line
- Map zoom padding updated to 420px for wider panel
- Body text bumped to text-sm (14px) for readability
- Get Directions button hidden when 2+ stops (route auto-calculates)
- PlaceCard title prefers feature name (raw.name) over formatted address
- Preview card shows above route during PREVIEW_ROUTING state
- Directions flow no longer shows toast when GPS denied
Three improvements:
1. When a place has a boundary polygon (from Nominatim), render its
outline on the map using a dashed accent line. Falls back to the
existing pulsing ring for places without polygons.
2. Selecting a feature now smoothly zooms the map to fit:
- With polygon: fitBounds to polygon bbox
- Without polygon: zoom level based on feature kind (city=11,
region=7, POI=16, etc.)
Terrain clicks do not change zoom.
3. Wikidata IDs render as styled 'View on Wikidata' links instead
of raw 'Wikidata: Qxxxxx' strings.
- Snap selection to feature geometry when clicking labeled places
- Add wikidata enrichment for basemap labels (population, description)
- Differentiate visual feedback: reticle marker vs pulsing highlight
- Clear previous feature highlight when selection changes
- Store selection mode (reticle/feature) and feature info in state
Frontend: MapView click handler, PlaceDetail wikidata fetch, CSS
Backend: /api/place/wikidata/<id> route for Wikidata API lookups
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Click handler now queries rendered features at click point and uses
the highest-priority labeled feature (POI > locality > region) as
the place identifier. Previously, clicks went straight to coord-based
reverse-geocode, causing 'Twin Falls' label clicks to resolve to
whatever feature was nearest (e.g., a radio tower).
Falls back to reverse-geocode when no labeled feature exists at the
click point. Two-click selection model and hover affordance unchanged.
POIs, city names, and other labeled features now show a pointer cursor
on hover, signaling that they are clickable. Matches the standard
map-app interaction pattern (Google Maps, etc.).
Implemented via MapLibre mouseenter/mouseleave handlers for interactive
layers: pois, places_locality, places_region, places_country,
places_subplace.
Replaces 'every click selects something' with a deliberate two-click
flow:
- First click drops marker (existing circle plus new precise center dot)
and opens place panel
- Second click INSIDE the marker circle opens the radial menu
- Second click OUTSIDE the circle deselects without selecting the new
spot — requires another click to select
The 4px filled center dot at exact click coordinates gives precise
visual feedback for GPS-coord readout. The existing circle's radius
defines the same-spot tolerance, visually showing the radial-trigger
hit area.
Right-click radial unchanged. Search-dropdown selection drops a marker
for consistency.
Left-click on the map already reverse-geocodes and opens the place
panel, making the 'What's here' wedge redundant. Radial drops from
6 to 5 wedges (72° each).
Remaining wedges (all stubs except they show toast):
- Drop pin
- Directions to here
- Save place (auth-gated)
- Add as stop
- Directions from here