From 82d19e7fb808c342993f0d56dd4802e240c33955 Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 26 Apr 2026 19:58:55 +0000 Subject: [PATCH] design: extend with single-panel architecture (Phases k-o) Adds the broader panel design that consolidates today's two-panel layout (Routes/Contacts + floating PlaceDetail) into one always-visible left column with state-driven content. Defines five panel states (IDLE, PREVIEW, ROUTING, PREVIEW+ROUTING, ROUTE_CALCULATED), shared place-card component, search bar behavior including aspirational search history, and implementation phases k-o. --- NAVI-DIRECTIONS-REDESIGN.md | 1509 +++++++++++++++++++---------------- 1 file changed, 842 insertions(+), 667 deletions(-) diff --git a/NAVI-DIRECTIONS-REDESIGN.md b/NAVI-DIRECTIONS-REDESIGN.md index 0274b93..932b155 100644 --- a/NAVI-DIRECTIONS-REDESIGN.md +++ b/NAVI-DIRECTIONS-REDESIGN.md @@ -1,667 +1,842 @@ -# Navi Directions UX Redesign - -**Status:** Draft -**Author:** Claude + Matt -**Date:** 2026-04-26 -**Implementation:** Deferred to dedicated session - ---- - -## 1. Current State - -### Components - -| Component | File | Role | -|-----------|------|------| -| SearchBar | `SearchBar.jsx` | Overloaded: search, add stop, set origin (hidden modes) | -| StopList | `StopList.jsx` | Drag-drop reordering of stops | -| GpsOriginItem | `GpsOriginItem.jsx` | "Your location" row when GPS granted | -| StopItem | `StopItem.jsx` | Individual stop with delete button | -| ModeSelector | `ModeSelector.jsx` | auto/pedestrian/bicycle toggle | -| ManeuverList | `ManeuverList.jsx` | Turn-by-turn directions display | -| PlaceDetail | `PlaceDetail.jsx` | "Directions" button for selected place | - -### State Model - -```javascript -stops: [] // Array of {id, lat, lon, name, source, matchCode} -gpsOrigin: true // Use GPS as origin when available -pendingDestination: null // Place waiting for origin (GPS-denied flow) -route: null // Valhalla trip response -routeLoading: false -routeError: null -``` - -### Failure Modes - -1. **No visible from/to inputs** — Users cannot see or directly edit origin/destination -2. **SearchBar hidden mode-switching** — Three different behaviors based on invisible state: - - Normal: opens place detail - - With `pendingDestination`: first result becomes origin - - After adding stops: unclear which role next selection plays -3. **GPS-denied flow uses ephemeral toast** — "Set a starting point" disappears, no persistent UI guidance -4. **No swap button** — Cannot reverse route direction -5. **No map context menu** — Right-click/long-press does nothing -6. **No waypoint addition UI** — Only drag-drop reordering, no insert-between -7. **Place panel "Directions" silently sets up route** — Based on hidden state, no confirmation - ---- - -## 2. Design Principles - -1. **Direct manipulation over hidden modes** — Every action should have visible UI -2. **Two visible inputs always** — When in directions mode, From and To fields are always visible -3. **Spatial interactions over linear** — Radial menu for map interactions, not dropdowns -4. **Same gesture model everywhere** — Right-click (desktop) = long-press (mobile) -5. **Preserve existing state model** — `stops[]` array stays, just better UI on top - ---- - -## 3. Visual Mockup — Directions Panel - -``` -┌─────────────────────────────────────┐ -│ DIRECTIONS │ -├─────────────────────────────────────┤ -│ │ -│ From: [📍 Your location ][×] │ -│ ──────────────────────── │ -│ [⇅] │ ← Swap button -│ ──────────────────────── │ -│ To: [Coffee shop on Main St][×] │ -│ │ -│ [+ Add stop] │ -│ │ -├─────────────────────────────────────┤ -│ [🚗 Auto] [🚶 Walk] [🚲 Bike] │ -├─────────────────────────────────────┤ -│ ┌─────────────────────────────┐ │ -│ │ 12 min · 4.2 mi │ │ -│ │ via W Main St │ │ -│ └─────────────────────────────┘ │ -│ │ -│ ▼ Turn-by-turn (expandable) │ -│ → Head north on Oak Ave │ -│ ↱ Turn right onto Main St │ -│ ◉ Arrive at destination │ -│ │ -└─────────────────────────────────────┘ -``` - -### Input States - -**From field:** -- GPS granted: Shows "📍 Your location" pill with clear button -- GPS denied/cleared: Empty, placeholder "Starting point..." -- Filled: Shows place name with clear button - -**To field:** -- Empty: Placeholder "Destination..." -- Filled: Shows place name with clear button - -**Active input:** -- Blue border highlight -- Search dropdown appears on typing -- Map click populates this field - ---- - -## 4. Visual Mockup — Radial Map Menu - -``` - Drop pin - 🔴 - ╱ ╲ - ╱ ╲ - Directions ╱ ╲ Directions - from here 🟢──────────────🔵 to here - │ 43.6166 │ - │ -116.2008 │ - │ [loading…] │ ← Center disc with coords/label - Add as 🟡──────────────🟣 Save place - stop ╲ ╱ - ╲ ╱ - ╲ ╱ - 🟠 - What's here -``` - -### Wedge Layout (60° each) - -| Position | Action | Icon | Color | -|----------|--------|------|-------| -| Top | Drop pin | Pin | Red | -| Top-right | Directions to here | Arrow-in | Blue | -| Bottom-right | Save place | Star + 🔒 | Purple | -| Bottom | What's here | Info | Orange | -| Bottom-left | Add as stop | Plus | Yellow | -| Top-left | Directions from here | Arrow-out | Green | - -### Behavior - -- **Trigger:** Right-click (desktop) or long-press 450ms (mobile) -- **Center disc:** ~40px diameter, shows coordinates immediately, reverse-geocoded label async -- **Wedge highlight:** On hover (desktop) or drag-over (mobile) -- **Commit:** Release on wedge (mobile) or click wedge (desktop) -- **Cancel:** Release outside, Escape key, tap elsewhere - ---- - -## 5. Component Breakdown - -### DirectionsPanel - -Replaces current Panel directions mode. - -``` -Props: none (reads from store) -State: none (all in global store) -Children: - - LocationInput (from) - - SwapButton - - LocationInput (to) - - WaypointList (if stops.length > 2) - - AddStopButton - - ModeSelector - - RouteSummary - - ManeuverList (collapsible) -``` - -### LocationInput - -Reusable component for from, to, and waypoint inputs. - -``` -Props: - - slot: 'from' | 'to' | `waypoint:${index}` - - value: { lat, lon, name, source } | null - - placeholder: string - - showGpsPill: boolean - - onClear: () => void - -Features: - - Search-as-you-type (Photon geocoder) - - GPS pill state with clear button - - Active-input visual state (blue border) - - Reverse-geocoded labels for coord-only entries - - Dropdown for search results -``` - -### SwapButton - -Simple button between From and To inputs. - -``` -Props: none -Action: Swaps stops[0] and stops[stops.length - 1] -Visual: ⇅ icon, hover highlight -``` - -### WaypointList - -Refactored from existing StopList, preserves drag-drop. - -``` -Props: none (reads stops from store) -Features: - - Only renders stops[1..n-1] (middle waypoints) - - Drag-drop reordering via @dnd-kit - - Delete button per waypoint - - "Via" label prefix -``` - -### RadialMenu - -New general-purpose component. - -``` -Props: - - open: boolean - - x: number (screen X) - - y: number (screen Y) - - lat: number - - lon: number - - wedges: Array<{ id, icon, label, action: (lat, lon) => void }> - - onClose: () => void - -Features: - - Configurable wedge count and actions - - Async center label (reverse geocode) - - Keyboard dismissal (Escape) - - Touch-friendly sizing on mobile - - Fade in/out animations -``` - ---- - -## 6. State Model - -### Existing (unchanged) - -```javascript -stops: [] // Origin = stops[0], destination = stops[last], waypoints in between -gpsOrigin: boolean // Whether GPS should be used as origin -route: object | null // Valhalla trip response -routeLoading: boolean -routeError: string | null -``` - -### New - -```javascript -activeInputSlot: 'from' | 'to' | `waypoint:${N}` | null -// Which input is currently focused/active for map-click-to-fill - -radialMenuState: { - open: boolean, - x: number, // Screen coordinates - y: number, - lat: number, // Map coordinates - lon: number, - label: string | null // Reverse-geocoded, async populated -} - -// Pin persistence (three-tier) -transientPins: [] // In-memory, lost on refresh -// localStorage: 'navi_saved_pins' key for guest saves -// Backend sync: future work for authed users -``` - -### Removed - -```javascript -pendingDestination: null // No longer needed — explicit inputs replace hidden state -``` - ---- - -## 7. Interaction Flows - -### Open directions tab fresh - -1. From field shows GPS pill if `geoPermission === 'granted'`, else empty -2. To field is empty, focused by default -3. No route calculated yet - -### Click "Directions" from place panel - -1. Directions panel opens (if not already) -2. To field auto-fills with selected place -3. From field: - - If GPS granted: shows GPS pill - - Else: empty, receives focus -4. Route calculates if both filled - -### Type in input - -1. Input receives focus, becomes `activeInputSlot` -2. Photon search fires on debounce (300ms) -3. Dropdown shows results -4. Select result → populates input, clears dropdown -5. Route recalculates - -### Right-click / long-press on map - -1. Radial menu appears centered on click point -2. Center disc shows coordinates immediately -3. Reverse geocode fires async, populates label -4. User hovers/drags to wedge: - -| Wedge | Action | -|-------|--------| -| **Directions from here** | Opens directions if closed, fills From with coords, focuses To | -| **Directions to here** | Opens directions if closed, fills To with coords, focuses From if empty | -| **Add as stop** | Inserts new stop before destination | -| **What's here** | Reverse geocode → opens place panel | -| **Drop pin** | Creates transient marker (session-only by default) | -| **Save place** | Guest: opens login flow; Authed: opens save dialog | - -5. Release outside or Escape → dismisses without action - -### Map click with active input - -When directions panel is open and an input is focused (`activeInputSlot !== null`): - -1. Single click on map -2. Clicked coordinates populate the active input -3. Reverse geocode fires to get display name -4. Input loses focus, `activeInputSlot = null` -5. Route recalculates - -### Map long-press / right-click with active input - -Even when an input is active: - -1. Long-press / right-click opens radial menu (overrides click-to-fill) -2. User can select "Directions from here" or "Directions to here" -3. Explicitly overrides the active input — intentional action takes priority - -### Map click with no active input - -1. No action (map interaction only) -2. Future v2 consideration: reverse-geocode and show place panel - -### Swap button - -1. Click swap button -2. `stops[0]` and `stops[stops.length - 1]` swap positions -3. If GPS was origin, GPS pill moves to destination (unusual but allowed) -4. Route recalculates - ---- - -## 8. Place Panel "Directions" Handoff - -**Current behavior:** Calls `startDirections(place)` with complex conditional logic, may show toast. - -**New behavior:** - -```javascript -handleDirections = () => { - // Always open directions panel - setActiveTab('directions') - - // Fill destination - setStop(stops.length, { // Appends or replaces last - lat: place.lat, - lon: place.lon, - name: place.name, - source: place.source - }) - - // Handle origin - if (geoPermission === 'granted') { - setGpsOrigin(true) // GPS pill in From - } else if (stops.length === 0) { - setActiveInputSlot('from') // Focus From input - } - - // Close place panel - clearSelectedPlace() -} -``` - -**No toast needed** — UI is self-explanatory with visible From/To fields. - ---- - -## 9. Radial Menu Specifics - -### Trigger - -| Platform | Gesture | Duration | -|----------|---------|----------| -| Desktop | Right-click | Instant | -| Mobile | Long-press | 450ms | - -### Conflict Avoidance - -Long-press must NOT fire during active pan: -- Track touch start position -- If touch moves >5-10px before timer fires, cancel long-press -- Pan gesture takes priority -- Matches iOS Safari default contextmenu synthesis behavior - -### Geometry - -``` -Outer radius: ~80px from center (desktop), ~100px (mobile) -Inner radius: ~40px (center disc, desktop), ~50px (mobile) -Wedge angle: 60° each (6 wedges) -Gap between wedges: 2px -Minimum touch target: 48px per wedge -``` - -### Visual States - -| Element | Default | Hover/Active | Selected | -|---------|---------|--------------|----------| -| Wedge background | `rgba(0,0,0,0.7)` | `rgba(0,0,0,0.85)` | Wedge accent color | -| Wedge icon | White, 50% opacity | White, 100% opacity | White | -| Wedge label | Hidden | Shown (tooltip) | Shown | -| Center disc | Dark, coords visible | — | — | -| Save place (guest) | Lock icon overlay | — | — | - -### Animation - -- **Fade in:** <100ms ease-out -- **Fade out:** <150ms ease-in -- **Wedge hover:** Instant background change -- **Center label:** Fade in when reverse geocode completes - ---- - -## 10. Mobile Considerations - -### Panel Layout - -**Decision: Bottom sheet on mobile (<768px), side panel on desktop.** - -Bottom sheet states: -- **Peek:** Route summary only (collapsed) -- **Half:** Inputs + summary visible -- **Full:** Inputs + turn-by-turn maneuvers - -Drag handle at top for resize between states. - -**Implementation notes:** -- Use React state for sheet position: `sheetState: 'peek' | 'half' | 'full'` -- CSS transforms for smooth transitions: `transform: translateY()` -- Touch gesture detection for drag-to-resize - -### Long-press Timing - -**Decision: 450ms with 5-10px movement threshold.** - -If finger moves more than threshold during press window, abort long-press and treat as pan. This matches iOS Safari's default contextmenu synthesis behavior. - -**Implementation notes:** -- Create reusable `useLongPress` hook -- Track `touchstart` position -- `setTimeout` for 450ms -- Clear timeout on `touchmove` if delta > threshold -- Fire callback on timeout completion - -### Radial Sizing - -Mobile radial larger for finger touch: -- Outer radius: ~100px (vs 80px desktop) -- Center disc: ~50px (vs 40px desktop) -- Minimum wedge touch target: 48px - -### Compact Directions Mode - -When route is calculated and user is navigating: -1. Collapse From/To inputs to single-line summary -2. Show prominent next maneuver -3. Expand on tap to edit inputs -4. Maneuver list scrollable - -### Keyboard Awareness - -- Detect keyboard open via `visualViewport` API -- Shift panel content up to keep active input visible -- Don't let keyboard overlap input being typed in - ---- - -## 11. Place Panel Restructure - -**Out of scope for this document.** - -Separate session will address: -- Cleaner info card layout (Google Maps style) -- Better visual hierarchy -- Action button placement -- No new data sources, just CSS/JSX polish - ---- - -## 12. Out of Scope (Future Phases) - -| Feature | Notes | -|---------|-------| -| Saved routes | Auth required, dedicated work | -| Route alternatives | Valhalla supports, surface in v2 | -| Avoid tolls/highways | Valhalla supports via costing options | -| Real-time rerouting | Requires location tracking loop | -| Multi-modal | Drive + transit + walk hybrids | -| Traffic-aware routing | Requires traffic data source | -| Offline routing | Requires local Valhalla instance | - ---- - -## 13. Implementation Sequence - -| Phase | Task | Depends On | -|-------|------|------------| -| **a** | Build RadialMenu component (general-purpose, no actions wired) | — | -| **b** | Wire "What's here" action to validate trigger + reverse-geocode flow | a | -| **c** | Refactor SearchBar to single-mode (search-only, remove pending* logic) | — | -| **d** | Build LocationInput component (reusable) | c | -| **e** | Build DirectionsPanel layout with two LocationInputs | d | -| **f** | Wire remaining radial actions to directions flow | b, e | -| **g** | Wire place panel "Directions" handoff to new flow | e | -| **h** | Add SwapButton | e | -| **i** | Add map-click-to-fill-active-input | e | -| **j** | Mobile polish (long-press timing, bottom sheet, keyboard) | a-i | - -**Estimated phases:** 10 discrete tasks, can be done incrementally. - ---- - -## 14. Resolved Decisions - -### 1. Mobile Layout - -**Decision:** Bottom sheet on mobile (<768px breakpoint), side panel on desktop. - -Three sheet states: -- **Peek:** Summary only (route time/distance) -- **Half:** Inputs + summary visible -- **Full:** Inputs + turn-by-turn maneuvers - -Drag-to-resize between states via handle at top of sheet. - -**Implementation notes:** -- `sheetState` in store: `'peek' | 'half' | 'full'` -- CSS transforms (`translateY`) for smooth animation -- Touch gesture handler for drag detection -- Snap to nearest state on release - -### 2. Long-press Timing - -**Decision:** 450ms with 5-10px movement threshold. - -If finger moves more than threshold during the press window, abort long-press and treat as pan gesture. This matches iOS Safari's default contextmenu synthesis behavior. - -**Implementation notes:** -- Create `useLongPress(callback, delay = 450)` hook -- Track `touchstart` coordinates -- On `touchmove`, check if `Math.hypot(dx, dy) > threshold` -- If exceeded, clear timeout (abort) -- If timeout fires without abort, invoke callback with coords - -### 3. Save Place for Guests - -**Decision:** Visible wedge with subtle lock icon overlay to indicate auth required. - -Tapping as guest opens login flow with return-to-action after auth completes. - -**Implementation notes:** -- "Save place" wedge always visible -- Lock icon (🔒) overlaid at 50% opacity on wedge icon -- On tap: check auth state - - Authed: open save dialog - - Guest: trigger login modal with `returnAction: 'save-place'` -- After successful auth, resume save flow - -### 4. Radial Ring Structure - -**Decision:** Single ring only. Six wedges maximum. - -Future expansion via contextual wedge sets (different actions based on what was clicked — e.g., clicking on a route segment could show "Avoid this road" instead of "Drop pin") rather than deeper nested rings. - -**Implementation notes:** -- `wedges` prop on RadialMenu is array of 6 max -- Context-aware wedge selection handled by parent component -- No inner ring in v1; revisit if six actions prove insufficient - -### 5. Drop Pin Persistence - -**Decision:** Three-tier model. - -| Tier | Scope | Storage | Behavior | -|------|-------|---------|----------| -| Transient | Session | In-memory (`transientPins[]`) | Default. Lost on refresh. | -| Guest save | Device | `localStorage` (`navi_saved_pins`) | Survives refresh, lost on cache clear or other device. | -| Authed sync | Account | Backend API | Syncs across devices. Future work. | - -**Implementation notes:** -- Transient pins: `transientPins: []` in store -- Guest save: on "Save" tap, copy pin to localStorage -- localStorage schema: `[{ id, lat, lon, name, createdAt }]` -- Backend sync: TODO placeholder; initial implementation is localStorage only -- Authed users see "Sync to account" option (greyed out with "Coming soon" for v1) - -### 6. Radial vs Click During Active Input - -**Decision:** Different gestures, different behaviors. - -| State | Gesture | Behavior | -|-------|---------|----------| -| Active input | Map click | Fills active input with click coords (reverse-geocoded) | -| Active input | Map long-press / right-click | Opens radial (explicit override allowed) | -| No active input | Map click | No action (v2: reverse-geocode + place panel) | -| No active input | Map long-press / right-click | Opens radial (primary use case) | - -**Implementation notes:** -- Click handler checks `activeInputSlot` - - If set: fill input, reverse-geocode for label - - If null: no-op (or v2 place panel) -- Long-press / contextmenu handler always opens radial -- Radial "Directions from/to here" explicitly sets input, overriding prior value - ---- - -## Appendix A: Current Code References - -| File | Lines | Relevance | -|------|-------|-----------| -| `store.js` | 72-86 | `startDirections()` logic to replace | -| `store.js` | 16-34 | `stops[]` management to preserve | -| `SearchBar.jsx` | 140-170 | `pendingDestination` logic to remove | -| `PlaceDetail.jsx` | 574-579 | `handleDirections()` to rewrite | -| `App.jsx` | 31-66 | Route fetch effect to preserve | -| `api.js` | 29-56 | `requestRoute()` unchanged | - ---- - -## Appendix B: Radial Menu SVG Structure - -```svg - - - - - - - - - - - 43.6166 - -116.2008 - Loading... - - - - - - - -``` - ---- - -*Document created 2026-04-26. Updated 2026-04-26 with resolved decisions. Implementation to follow in dedicated session.* +# Navi Directions UX Redesign + +**Status:** Draft +**Author:** Claude + Matt +**Date:** 2026-04-26 +**Implementation:** Deferred to dedicated session + +--- + +## 1. Current State + +### Components + +| Component | File | Role | +|-----------|------|------| +| SearchBar | `SearchBar.jsx` | Overloaded: search, add stop, set origin (hidden modes) | +| StopList | `StopList.jsx` | Drag-drop reordering of stops | +| GpsOriginItem | `GpsOriginItem.jsx` | "Your location" row when GPS granted | +| StopItem | `StopItem.jsx` | Individual stop with delete button | +| ModeSelector | `ModeSelector.jsx` | auto/pedestrian/bicycle toggle | +| ManeuverList | `ManeuverList.jsx` | Turn-by-turn directions display | +| PlaceDetail | `PlaceDetail.jsx` | "Directions" button for selected place | + +### State Model + +```javascript +stops: [] // Array of {id, lat, lon, name, source, matchCode} +gpsOrigin: true // Use GPS as origin when available +pendingDestination: null // Place waiting for origin (GPS-denied flow) +route: null // Valhalla trip response +routeLoading: false +routeError: null +``` + +### Failure Modes + +1. **No visible from/to inputs** — Users cannot see or directly edit origin/destination +2. **SearchBar hidden mode-switching** — Three different behaviors based on invisible state: + - Normal: opens place detail + - With `pendingDestination`: first result becomes origin + - After adding stops: unclear which role next selection plays +3. **GPS-denied flow uses ephemeral toast** — "Set a starting point" disappears, no persistent UI guidance +4. **No swap button** — Cannot reverse route direction +5. **No map context menu** — Right-click/long-press does nothing +6. **No waypoint addition UI** — Only drag-drop reordering, no insert-between +7. **Place panel "Directions" silently sets up route** — Based on hidden state, no confirmation + +--- + +## 2. Design Principles + +1. **Direct manipulation over hidden modes** — Every action should have visible UI +2. **Two visible inputs always** — When in directions mode, From and To fields are always visible +3. **Spatial interactions over linear** — Radial menu for map interactions, not dropdowns +4. **Same gesture model everywhere** — Right-click (desktop) = long-press (mobile) +5. **Preserve existing state model** — `stops[]` array stays, just better UI on top + +--- + +## 3. Visual Mockup — Directions Panel + +``` +┌─────────────────────────────────────┐ +│ DIRECTIONS │ +├─────────────────────────────────────┤ +│ │ +│ From: [📍 Your location ][×] │ +│ ──────────────────────── │ +│ [⇅] │ ← Swap button +│ ──────────────────────── │ +│ To: [Coffee shop on Main St][×] │ +│ │ +│ [+ Add stop] │ +│ │ +├─────────────────────────────────────┤ +│ [🚗 Auto] [🚶 Walk] [🚲 Bike] │ +├─────────────────────────────────────┤ +│ ┌─────────────────────────────┐ │ +│ │ 12 min · 4.2 mi │ │ +│ │ via W Main St │ │ +│ └─────────────────────────────┘ │ +│ │ +│ ▼ Turn-by-turn (expandable) │ +│ → Head north on Oak Ave │ +│ ↱ Turn right onto Main St │ +│ ◉ Arrive at destination │ +│ │ +└─────────────────────────────────────┘ +``` + +### Input States + +**From field:** +- GPS granted: Shows "📍 Your location" pill with clear button +- GPS denied/cleared: Empty, placeholder "Starting point..." +- Filled: Shows place name with clear button + +**To field:** +- Empty: Placeholder "Destination..." +- Filled: Shows place name with clear button + +**Active input:** +- Blue border highlight +- Search dropdown appears on typing +- Map click populates this field + +--- + +## 4. Visual Mockup — Radial Map Menu + +``` + Drop pin + 🔴 + ╱ ╲ + ╱ ╲ + Directions ╱ ╲ Directions + from here 🟢──────────────🔵 to here + │ 43.6166 │ + │ -116.2008 │ + │ [loading…] │ ← Center disc with coords/label + Add as 🟡──────────────🟣 Save place + stop ╲ ╱ + ╲ ╱ + ╲ ╱ + 🟠 + What's here +``` + +### Wedge Layout (60° each) + +| Position | Action | Icon | Color | +|----------|--------|------|-------| +| Top | Drop pin | Pin | Red | +| Top-right | Directions to here | Arrow-in | Blue | +| Bottom-right | Save place | Star + 🔒 | Purple | +| Bottom | What's here | Info | Orange | +| Bottom-left | Add as stop | Plus | Yellow | +| Top-left | Directions from here | Arrow-out | Green | + +### Behavior + +- **Trigger:** Right-click (desktop) or long-press 450ms (mobile) +- **Center disc:** ~40px diameter, shows coordinates immediately, reverse-geocoded label async +- **Wedge highlight:** On hover (desktop) or drag-over (mobile) +- **Commit:** Release on wedge (mobile) or click wedge (desktop) +- **Cancel:** Release outside, Escape key, tap elsewhere + +--- + +## 5. Component Breakdown + +### DirectionsPanel + +Replaces current Panel directions mode. + +``` +Props: none (reads from store) +State: none (all in global store) +Children: + - LocationInput (from) + - SwapButton + - LocationInput (to) + - WaypointList (if stops.length > 2) + - AddStopButton + - ModeSelector + - RouteSummary + - ManeuverList (collapsible) +``` + +### LocationInput + +Reusable component for from, to, and waypoint inputs. + +``` +Props: + - slot: 'from' | 'to' | `waypoint:${index}` + - value: { lat, lon, name, source } | null + - placeholder: string + - showGpsPill: boolean + - onClear: () => void + +Features: + - Search-as-you-type (Photon geocoder) + - GPS pill state with clear button + - Active-input visual state (blue border) + - Reverse-geocoded labels for coord-only entries + - Dropdown for search results +``` + +### SwapButton + +Simple button between From and To inputs. + +``` +Props: none +Action: Swaps stops[0] and stops[stops.length - 1] +Visual: ⇅ icon, hover highlight +``` + +### WaypointList + +Refactored from existing StopList, preserves drag-drop. + +``` +Props: none (reads stops from store) +Features: + - Only renders stops[1..n-1] (middle waypoints) + - Drag-drop reordering via @dnd-kit + - Delete button per waypoint + - "Via" label prefix +``` + +### RadialMenu + +New general-purpose component. + +``` +Props: + - open: boolean + - x: number (screen X) + - y: number (screen Y) + - lat: number + - lon: number + - wedges: Array<{ id, icon, label, action: (lat, lon) => void }> + - onClose: () => void + +Features: + - Configurable wedge count and actions + - Async center label (reverse geocode) + - Keyboard dismissal (Escape) + - Touch-friendly sizing on mobile + - Fade in/out animations +``` + +--- + +## 6. State Model + +### Existing (unchanged) + +```javascript +stops: [] // Origin = stops[0], destination = stops[last], waypoints in between +gpsOrigin: boolean // Whether GPS should be used as origin +route: object | null // Valhalla trip response +routeLoading: boolean +routeError: string | null +``` + +### New + +```javascript +activeInputSlot: 'from' | 'to' | `waypoint:${N}` | null +// Which input is currently focused/active for map-click-to-fill + +radialMenuState: { + open: boolean, + x: number, // Screen coordinates + y: number, + lat: number, // Map coordinates + lon: number, + label: string | null // Reverse-geocoded, async populated +} + +// Pin persistence (three-tier) +transientPins: [] // In-memory, lost on refresh +// localStorage: 'navi_saved_pins' key for guest saves +// Backend sync: future work for authed users +``` + +### Removed + +```javascript +pendingDestination: null // No longer needed — explicit inputs replace hidden state +``` + +--- + +## 7. Interaction Flows + +### Open directions tab fresh + +1. From field shows GPS pill if `geoPermission === 'granted'`, else empty +2. To field is empty, focused by default +3. No route calculated yet + +### Click "Directions" from place panel + +1. Directions panel opens (if not already) +2. To field auto-fills with selected place +3. From field: + - If GPS granted: shows GPS pill + - Else: empty, receives focus +4. Route calculates if both filled + +### Type in input + +1. Input receives focus, becomes `activeInputSlot` +2. Photon search fires on debounce (300ms) +3. Dropdown shows results +4. Select result → populates input, clears dropdown +5. Route recalculates + +### Right-click / long-press on map + +1. Radial menu appears centered on click point +2. Center disc shows coordinates immediately +3. Reverse geocode fires async, populates label +4. User hovers/drags to wedge: + +| Wedge | Action | +|-------|--------| +| **Directions from here** | Opens directions if closed, fills From with coords, focuses To | +| **Directions to here** | Opens directions if closed, fills To with coords, focuses From if empty | +| **Add as stop** | Inserts new stop before destination | +| **What's here** | Reverse geocode → opens place panel | +| **Drop pin** | Creates transient marker (session-only by default) | +| **Save place** | Guest: opens login flow; Authed: opens save dialog | + +5. Release outside or Escape → dismisses without action + +### Map click with active input + +When directions panel is open and an input is focused (`activeInputSlot !== null`): + +1. Single click on map +2. Clicked coordinates populate the active input +3. Reverse geocode fires to get display name +4. Input loses focus, `activeInputSlot = null` +5. Route recalculates + +### Map long-press / right-click with active input + +Even when an input is active: + +1. Long-press / right-click opens radial menu (overrides click-to-fill) +2. User can select "Directions from here" or "Directions to here" +3. Explicitly overrides the active input — intentional action takes priority + +### Map click with no active input + +1. No action (map interaction only) +2. Future v2 consideration: reverse-geocode and show place panel + +### Swap button + +1. Click swap button +2. `stops[0]` and `stops[stops.length - 1]` swap positions +3. If GPS was origin, GPS pill moves to destination (unusual but allowed) +4. Route recalculates + +--- + +## 8. Place Panel "Directions" Handoff + +**Current behavior:** Calls `startDirections(place)` with complex conditional logic, may show toast. + +**New behavior:** + +```javascript +handleDirections = () => { + // Always open directions panel + setActiveTab('directions') + + // Fill destination + setStop(stops.length, { // Appends or replaces last + lat: place.lat, + lon: place.lon, + name: place.name, + source: place.source + }) + + // Handle origin + if (geoPermission === 'granted') { + setGpsOrigin(true) // GPS pill in From + } else if (stops.length === 0) { + setActiveInputSlot('from') // Focus From input + } + + // Close place panel + clearSelectedPlace() +} +``` + +**No toast needed** — UI is self-explanatory with visible From/To fields. + +--- + +## 9. Radial Menu Specifics + +### Trigger + +| Platform | Gesture | Duration | +|----------|---------|----------| +| Desktop | Right-click | Instant | +| Mobile | Long-press | 450ms | + +### Conflict Avoidance + +Long-press must NOT fire during active pan: +- Track touch start position +- If touch moves >5-10px before timer fires, cancel long-press +- Pan gesture takes priority +- Matches iOS Safari default contextmenu synthesis behavior + +### Geometry + +``` +Outer radius: ~80px from center (desktop), ~100px (mobile) +Inner radius: ~40px (center disc, desktop), ~50px (mobile) +Wedge angle: 60° each (6 wedges) +Gap between wedges: 2px +Minimum touch target: 48px per wedge +``` + +### Visual States + +| Element | Default | Hover/Active | Selected | +|---------|---------|--------------|----------| +| Wedge background | `rgba(0,0,0,0.7)` | `rgba(0,0,0,0.85)` | Wedge accent color | +| Wedge icon | White, 50% opacity | White, 100% opacity | White | +| Wedge label | Hidden | Shown (tooltip) | Shown | +| Center disc | Dark, coords visible | — | — | +| Save place (guest) | Lock icon overlay | — | — | + +### Animation + +- **Fade in:** <100ms ease-out +- **Fade out:** <150ms ease-in +- **Wedge hover:** Instant background change +- **Center label:** Fade in when reverse geocode completes + +--- + +## 10. Mobile Considerations + +### Panel Layout + +**Decision: Bottom sheet on mobile (<768px), side panel on desktop.** + +Bottom sheet states: +- **Peek:** Route summary only (collapsed) +- **Half:** Inputs + summary visible +- **Full:** Inputs + turn-by-turn maneuvers + +Drag handle at top for resize between states. + +**Implementation notes:** +- Use React state for sheet position: `sheetState: 'peek' | 'half' | 'full'` +- CSS transforms for smooth transitions: `transform: translateY()` +- Touch gesture detection for drag-to-resize + +### Long-press Timing + +**Decision: 450ms with 5-10px movement threshold.** + +If finger moves more than threshold during press window, abort long-press and treat as pan. This matches iOS Safari's default contextmenu synthesis behavior. + +**Implementation notes:** +- Create reusable `useLongPress` hook +- Track `touchstart` position +- `setTimeout` for 450ms +- Clear timeout on `touchmove` if delta > threshold +- Fire callback on timeout completion + +### Radial Sizing + +Mobile radial larger for finger touch: +- Outer radius: ~100px (vs 80px desktop) +- Center disc: ~50px (vs 40px desktop) +- Minimum wedge touch target: 48px + +### Compact Directions Mode + +When route is calculated and user is navigating: +1. Collapse From/To inputs to single-line summary +2. Show prominent next maneuver +3. Expand on tap to edit inputs +4. Maneuver list scrollable + +### Keyboard Awareness + +- Detect keyboard open via `visualViewport` API +- Shift panel content up to keep active input visible +- Don't let keyboard overlap input being typed in + +--- + +## 11. Place Panel Restructure + +**Out of scope for this document.** + +Separate session will address: +- Cleaner info card layout (Google Maps style) +- Better visual hierarchy +- Action button placement +- No new data sources, just CSS/JSX polish + +--- + +## 12. Out of Scope (Future Phases) + +| Feature | Notes | +|---------|-------| +| Saved routes | Auth required, dedicated work | +| Route alternatives | Valhalla supports, surface in v2 | +| Avoid tolls/highways | Valhalla supports via costing options | +| Real-time rerouting | Requires location tracking loop | +| Multi-modal | Drive + transit + walk hybrids | +| Traffic-aware routing | Requires traffic data source | +| Offline routing | Requires local Valhalla instance | +| Search history backend persistence | Frontend localStorage first | +| Saved places browsing UI | Future dedicated work | +| Contacts view redesign | Separate from route-building | + +--- + +## 13. Implementation Sequence + +| Phase | Task | Depends On | +|-------|------|------------| +| **a** | Build RadialMenu component (general-purpose, no actions wired) | — | +| **b** | Wire "What's here" action to validate trigger + reverse-geocode flow | a | +| **c** | Refactor SearchBar to single-mode (search-only, remove pending* logic) | — | +| **d** | Build LocationInput component (reusable) | c | +| **e** | Build DirectionsPanel layout with two LocationInputs | d | +| **f** | Wire remaining radial actions to directions flow | b, e | +| **g** | Wire place panel "Directions" handoff to new flow | e | +| **h** | Add SwapButton | e | +| **i** | Add map-click-to-fill-active-input | e | +| **j** | Mobile polish (long-press timing, bottom sheet, keyboard) | a-i | + +### Phases k-o: Single-Panel Architecture + +| Phase | Task | Depends On | +|-------|------|------------| +| **k** | Refactor Panel.jsx to single-column state-driven content, remove right PlaceDetail panel | — | +| **l** | Build shared place card component (preview + stop cards) | k | +| **m** | Wire panel state transitions (IDLE → PREVIEW → ROUTING → ROUTE_CALCULATED) | l | +| **n** | Mobile bottom sheet state mapping | m | +| **o** | Search history (frontend localStorage scope first) | m | + +**Estimated phases:** 15 discrete tasks, can be done incrementally. + +--- + +## 14. Resolved Decisions + +### 1. Mobile Layout + +**Decision:** Bottom sheet on mobile (<768px breakpoint), side panel on desktop. + +Three sheet states: +- **Peek:** Summary only (route time/distance) +- **Half:** Inputs + summary visible +- **Full:** Inputs + turn-by-turn maneuvers + +Drag-to-resize between states via handle at top of sheet. + +**Implementation notes:** +- `sheetState` in store: `'peek' | 'half' | 'full'` +- CSS transforms (`translateY`) for smooth animation +- Touch gesture handler for drag detection +- Snap to nearest state on release + +### 2. Long-press Timing + +**Decision:** 450ms with 5-10px movement threshold. + +If finger moves more than threshold during the press window, abort long-press and treat as pan gesture. This matches iOS Safari's default contextmenu synthesis behavior. + +**Implementation notes:** +- Create `useLongPress(callback, delay = 450)` hook +- Track `touchstart` coordinates +- On `touchmove`, check if `Math.hypot(dx, dy) > threshold` +- If exceeded, clear timeout (abort) +- If timeout fires without abort, invoke callback with coords + +### 3. Save Place for Guests + +**Decision:** Visible wedge with subtle lock icon overlay to indicate auth required. + +Tapping as guest opens login flow with return-to-action after auth completes. + +**Implementation notes:** +- "Save place" wedge always visible +- Lock icon (🔒) overlaid at 50% opacity on wedge icon +- On tap: check auth state + - Authed: open save dialog + - Guest: trigger login modal with `returnAction: 'save-place'` +- After successful auth, resume save flow + +### 4. Radial Ring Structure + +**Decision:** Single ring only. Six wedges maximum. + +Future expansion via contextual wedge sets (different actions based on what was clicked — e.g., clicking on a route segment could show "Avoid this road" instead of "Drop pin") rather than deeper nested rings. + +**Implementation notes:** +- `wedges` prop on RadialMenu is array of 6 max +- Context-aware wedge selection handled by parent component +- No inner ring in v1; revisit if six actions prove insufficient + +### 5. Drop Pin Persistence + +**Decision:** Three-tier model. + +| Tier | Scope | Storage | Behavior | +|------|-------|---------|----------| +| Transient | Session | In-memory (`transientPins[]`) | Default. Lost on refresh. | +| Guest save | Device | `localStorage` (`navi_saved_pins`) | Survives refresh, lost on cache clear or other device. | +| Authed sync | Account | Backend API | Syncs across devices. Future work. | + +**Implementation notes:** +- Transient pins: `transientPins: []` in store +- Guest save: on "Save" tap, copy pin to localStorage +- localStorage schema: `[{ id, lat, lon, name, createdAt }]` +- Backend sync: TODO placeholder; initial implementation is localStorage only +- Authed users see "Sync to account" option (greyed out with "Coming soon" for v1) + +### 6. Radial vs Click During Active Input + +**Decision:** Different gestures, different behaviors. + +| State | Gesture | Behavior | +|-------|---------|----------| +| Active input | Map click | Fills active input with click coords (reverse-geocoded) | +| Active input | Map long-press / right-click | Opens radial (explicit override allowed) | +| No active input | Map click | No action (v2: reverse-geocode + place panel) | +| No active input | Map long-press / right-click | Opens radial (primary use case) | + +**Implementation notes:** +- Click handler checks `activeInputSlot` + - If set: fill input, reverse-geocode for label + - If null: no-op (or v2 place panel) +- Long-press / contextmenu handler always opens radial +- Radial "Directions from/to here" explicitly sets input, overriding prior value + +--- + +## 15. Panel Architecture (Single-Column Model) + +This section defines the broader panel design that consolidates today's +two-panel layout (Routes/Contacts + floating PlaceDetail) into one +always-visible left column with state-driven content. + +### Overview + +The left panel is one always-visible column with state-driven content. +The right-side PlaceDetail panel is **removed**; its content moves into +the left panel. + +**Width:** ~360px desktop. Mobile: panel becomes bottom sheet per +Section 10. + +**Visual style:** Panel is always anchored at the left edge of the +viewport with consistent panel background. When idle (no preview, no +route), only the search bar is visible at the top — the rest of the +panel is empty space. The panel never disappears; it just shows less +or more content depending on state. + +### Panel States + +Panel states are mutually exclusive content modes: + +#### IDLE (no preview, no route) + +- Search bar at top +- Empty body (or subtle empty-state prompt: "Search or click a place + to begin") +- Recent activity / search history (when implemented) + +#### PREVIEW (place selected via map click or search, not committed to route) + +- Search bar at top +- Preview card: full place detail (name, type, coords, elevation, + land class, about, contact, links) +- Action buttons inside or below the card: [Directions] [Add stop] + [Save] [Share] [x] +- Click another place: preview replaced (previous lost — search + history retains it) +- Click x or Escape: preview dismissed, return to IDLE + +#### ROUTING (1+ stops, no preview) + +- Search bar at top +- Section: "Route" with stop cards +- Each stop card: collapsed by default (header: pin + name + + drag handle + remove); expandable to show full detail +- Drag-to-reorder via card headers (preserves existing StopList) +- Per-card actions: Save, Share, Remove +- Bottom: [Get Directions] (when 2+ stops with valid mode), mode + selector (auto/walk/bike), trip summary placeholder + +#### PREVIEW + ROUTING (place clicked while route exists) + +- Search bar at top +- Preview card directly below search +- Route section below preview +- Both visible simultaneously +- Multiple cards can be expanded at once (preview + a stop) + +#### ROUTE_CALCULATED (after route fetch) + +- Search bar at top +- Route summary (total time, distance) +- Mode selector +- Stop cards (collapsed; expand for detail) +- Turn-by-turn maneuvers +- Preview can appear on top if user clicks a place + +### Search Bar Behavior + +- Pinned to top of panel +- Always for browsing (search -> preview, never auto-add as stop) + +#### Search History (aspirational — design now, implement later) + +- Empty search -> dropdown shows 5 most recent searches +- Authed: persisted to backend +- Guest: localStorage +- Typing: recent matches sorted to top of search results + +### Card Pattern + +Shared between preview and stop cards. Both use the same component, +parameterized by role. + +#### Header (always visible) + +- Pin/marker icon (color varies by role — preview vs stop number) +- Place name +- Expand/collapse chevron +- For stops: drag handle, remove (x) + +#### Body (collapsed by default for stops, expanded by default for preview) + +- Type / category +- Coordinates + elevation + land class +- About (description, Wikipedia excerpt) +- Contact (phone, website, hours) +- Links (Wikipedia, OSM, Wikidata) +- Action buttons: + - Preview: [Directions] [Add stop] [Save] [Share] + - Stop: [Save] [Share] + +### State Transitions + +``` +IDLE -> PREVIEW : click place / search-select +PREVIEW -> IDLE : x button / Escape +PREVIEW -> PREVIEW (different) : click another place +PREVIEW -> ROUTING : "Add stop" on preview +PREVIEW -> ROUTE_CALCULATED : "Directions" (becomes destination, + route auto-calculates if From available) +ROUTING -> IDLE : remove all stops +ROUTING -> PREVIEW + ROUTING : click place +ROUTING -> ROUTE_CALCULATED : "Get Directions" / auto-route trigger +PREVIEW + ROUTING -> ROUTING : dismiss preview +PREVIEW + ROUTING -> ROUTING : "Add stop" on preview (adds new stop) +ROUTE_CALCULATED -> ROUTING : edit route +ROUTE_CALCULATED -> IDLE : clear route +``` + +### Routes / Contacts Tabs + +Tentative: Routes is default panel. Contacts is a separate view +accessible via a tab or icon at the top. Contacts view replaces +route/preview content with a contact list — does not combine with +route-building. + +### Map Zoom-to-Feature + +With single panel, padding becomes simpler — just one panel width +(~360px) on the left. Top/right/bottom can be small (~50px each). + +### Mobile Bottom Sheet State Mapping + +Same logic as desktop, layout rotated: + +| State | Sheet Position | +|---------------------|----------------| +| IDLE | peek (search bar visible) | +| PREVIEW | half (preview card) | +| ROUTING | half (stops list) | +| PREVIEW + ROUTING | full (both, scrollable) | +| ROUTE_CALCULATED | half/full (summary + maneuvers) | + +--- + +## 16. Open Questions + +### From Single-Panel Architecture + +- Routes/Contacts tab location and behavior in single-panel model +- Preview-of-already-routed-place: probably auto-expands the existing + stop card, no duplicate preview +- Preview card position confirmed above route section (per mock) + + +--- + +## Appendix A: Current Code References + +| File | Lines | Relevance | +|------|-------|-----------| +| `store.js` | 72-86 | `startDirections()` logic to replace | +| `store.js` | 16-34 | `stops[]` management to preserve | +| `SearchBar.jsx` | 140-170 | `pendingDestination` logic to remove | +| `PlaceDetail.jsx` | 574-579 | `handleDirections()` to rewrite | +| `App.jsx` | 31-66 | Route fetch effect to preserve | +| `api.js` | 29-56 | `requestRoute()` unchanged | + +--- + +## Appendix B: Radial Menu SVG Structure + +```svg + + + + + + + + + + + 43.6166 + -116.2008 + Loading... + + + + + + + +``` + +--- + +*Document created 2026-04-26. Updated with resolved decisions and single-panel architecture (Phases k-o). Implementation to follow in dedicated session.*