refactored-recon/NAVI-DIRECTIONS-REDESIGN.md
Matt c121559e01 design: resolve open questions in directions redesign
Six open questions answered:
- Mobile uses bottom sheet (768px breakpoint), desktop uses side panel
- Long-press timing: 450ms + 5-10px movement threshold
- Save place wedge visible to guests with login prompt
- Single-ring radial; future expansion via contextual wedge sets
- Three-tier pin persistence: transient → localStorage → backend
- Map click fills active input; long-press still opens radial
2026-04-26 05:14:22 +00:00

21 KiB
Raw Blame History

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

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 modelstops[] 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)

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

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

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
  1. 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:

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 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. Updated 2026-04-26 with resolved decisions. Implementation to follow in dedicated session.