refactored-recon/NAVI-DIRECTIONS-REDESIGN.md
Matt 82d19e7fb8 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.
2026-04-26 19:58:55 +00:00

27 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
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 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 with resolved decisions and single-panel architecture (Phases k-o). Implementation to follow in dedicated session.