recon/docs/NAVI-DIRECTIONS-REDESIGN.md
Matt 4f96d8f6fe docs: Navi directions UX redesign with radial map menu
Design document covering:
- Current state analysis and failure modes
- New DirectionsPanel with visible From/To inputs
- RadialMenu component for map right-click/long-press
- Interaction flows for all directions scenarios
- Mobile considerations (bottom sheet, long-press timing)
- Implementation sequence (10 phases)
- Open questions for Matt

Implementation deferred to dedicated session.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-26 04:58:01 +00:00

579 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 400-500ms (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
}
```
### 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) |
| **Save place** | Opens save dialog (auth required) |
5. Release outside or Escape → dismisses without action
### Click map 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
### 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 | 400-500ms |
### Conflict Avoidance
Long-press must NOT fire during active pan:
- Track touch start position
- If touch moves >5px before timer fires, cancel long-press
- Pan gesture takes priority
### Geometry
```
Outer radius: ~80px from center
Inner radius: ~40px (center disc)
Wedge angle: 60° each (6 wedges)
Gap between wedges: 2px
```
### 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 | — | — |
### 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 needed:** Bottom sheet vs side panel
| Option | Pros | Cons |
|--------|------|------|
| Bottom sheet | Familiar (Google Maps), thumb-friendly | Complex sheet state management |
| Side panel | Consistent with desktop, more vertical space | Covers more map, less thumb-friendly |
**Recommendation:** Bottom sheet with three states: collapsed (summary only), half (inputs + summary), full (inputs + maneuvers).
### Long-press Timing
**Decision needed:** Exact timing
| Duration | Feel |
|----------|------|
| 400ms | Snappy, risk of accidental trigger |
| 450ms | Balanced |
| 500ms | Deliberate, slightly sluggish |
**Recommendation:** Start with 450ms, tune based on testing.
### Radial Sizing
Mobile radial should be 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. Open Questions
### For Matt to decide:
1. **Bottom sheet vs side panel on mobile?**
- Bottom sheet recommended but adds complexity
2. **Long-press timing exactly?**
- 400ms / 450ms / 500ms
- Recommend 450ms
3. **Should "Save place" wedge be visible to guests or hidden?**
- Visible with login prompt = more discoverable
- Hidden = cleaner for guests
- Recommend: visible, shows "Sign in to save" toast
4. **Inner ring of secondary actions in radial v2?**
- Could add less-common actions in inner ring
- Recommend: stay single-ring for v1, evaluate need later
5. **What does "Drop pin" persistence look like?**
- Session only (lost on refresh)
- localStorage (persists locally)
- Auth-only saved (sync across devices)
- Recommend: session-only for v1, localStorage for v2
6. **Radial on map click during active input?**
- Option A: No radial, click fills input directly
- Option B: Radial appears, "Use this location" wedge fills input
- Recommend: Option A (direct fill) for simplicity
---
## 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
<svg viewBox="0 0 200 200">
<!-- Wedge paths -->
<g class="wedges">
<path d="M100,100 L100,20 A80,80 0 0,1 169,60 Z" class="wedge" data-action="drop-pin" />
<path d="M100,100 L169,60 A80,80 0 0,1 169,140 Z" class="wedge" data-action="to-here" />
<!-- ... 4 more wedges ... -->
</g>
<!-- Center disc -->
<circle cx="100" cy="100" r="40" class="center-disc" />
<text x="100" y="95" class="coords">43.6166</text>
<text x="100" y="110" class="coords">-116.2008</text>
<text x="100" y="125" class="label">Loading...</text>
<!-- Icons (positioned in wedge centers) -->
<g class="icons">
<use href="#pin-icon" x="100" y="40" />
<!-- ... more icons ... -->
</g>
</svg>
```
---
*Document created 2026-04-26. Implementation to follow in dedicated session.*