Spaces:
Running on Zero
Running on Zero
Commit ·
a82c73b
1
Parent(s): 10cf611
update readme
Browse files- README.md +102 -52
- src/discoverroute/ui/shell.py +324 -116
README.md
CHANGED
|
@@ -1,78 +1,128 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
emoji: 🗺️
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.18.0
|
| 8 |
app_file: app.py
|
| 9 |
-
pinned:
|
| 10 |
-
license:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
#
|
| 14 |
|
| 15 |
-
|
| 16 |
-
DiscoverRoute returns a walkable/bikeable route that **deliberately detours past
|
| 17 |
-
places matching your taste** — within a travel-time budget — plus a narrated
|
| 18 |
-
itinerary explaining why each place is on the path.
|
| 19 |
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
|
| 25 |
-
|
| 26 |
|
| 27 |
-
|
| 28 |
-
serendipity, alternative routes, offline geocoding, and a fully custom UI (the
|
| 29 |
-
"clay sticker" design). 42 tests passing, live-verified, deploy-ready. See `PROGRESS.md`
|
| 30 |
-
for the per-brick build log, `FIELD_NOTES.md` for the build story, and
|
| 31 |
-
`DEPLOY.md` to push.
|
| 32 |
|
| 33 |
-
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
-
|
| 36 |
-
- **Vibe → route**: free-text mood (e.g. "quiet green wander") is matched to OSM
|
| 37 |
-
categories by sentence embeddings (`bge-small-en-v1.5`) and reshapes the route.
|
| 38 |
-
- **Detour budget**: a single slider trading extra time for discovery; the route
|
| 39 |
-
never exceeds `(1 + budget) ×` the direct time. Budget 0 = the plain route.
|
| 40 |
-
- **Diversity by design**: a submodular orienteering solver favours a park + a
|
| 41 |
-
viewpoint + a bookshop over five cafés, within budget.
|
| 42 |
-
- **Adventurousness**: low → well-documented places; high → injects hidden gems.
|
| 43 |
-
- **Grounded narration**: an itinerary that names only real waypoints, behind a
|
| 44 |
-
hard zero-hallucination gate (optional in-Space MiniCPM5-1B enhancer — a ≤4B
|
| 45 |
-
Tiny Titan on ZeroGPU — gated by the same).
|
| 46 |
-
- **Alternative routes**: up to three genuinely distinct options.
|
| 47 |
-
- **Persistent taste profile**: standing preferences + saved places, per device,
|
| 48 |
-
blended with each trip's mood. No accounts.
|
| 49 |
|
| 50 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
```bash
|
| 53 |
uv venv --python 3.11
|
| 54 |
-
uv pip install -e .
|
| 55 |
-
uv pip install -e ".[ml,dev]" # + vibe interpretation / narration + tests
|
| 56 |
|
| 57 |
-
# one-time offline data prep for Paris (downloads OSM, builds graph + POIs)
|
| 58 |
-
|
|
|
|
| 59 |
|
| 60 |
-
#
|
| 61 |
-
.venv/bin/python app.py
|
| 62 |
```
|
| 63 |
|
| 64 |
-
|
|
|
|
| 65 |
|
| 66 |
-
|
| 67 |
-
routing graph → extract POIs with features + confidence.
|
| 68 |
|
| 69 |
-
|
| 70 |
-
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
orienteering solver is a custom greedy submodular heuristic).
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: WanderLust
|
| 3 |
emoji: 🗺️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.18.0
|
| 8 |
app_file: app.py
|
| 9 |
+
pinned: true
|
| 10 |
+
license: apache-2.0
|
| 11 |
+
short_description: Discovery routing — get from A to B through places you'll love
|
| 12 |
+
tags:
|
| 13 |
+
- backyard-ai-track
|
| 14 |
+
- badge-off-brand
|
| 15 |
+
- badge-tiny-titan
|
| 16 |
+
- badge-best-agent
|
| 17 |
+
- badge-best-demo
|
| 18 |
+
- openbmb
|
| 19 |
---
|
| 20 |
|
| 21 |
+
# WanderLust
|
| 22 |
|
| 23 |
+
### Spend your extra time on discovery.
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
+
Most navigation apps answer one question: *what's the fastest way there?* **WanderLust
|
| 26 |
+
asks a better one: what if those few extra minutes were a gift?** You tell it where you're
|
| 27 |
+
going and the kind of moment you're after — "a slow Sunday-morning kind of walk,"
|
| 28 |
+
"bookshops and quiet streets," "a lively café crawl" — and it threads you from A to B
|
| 29 |
+
**past the places you'll actually love**, then tells you, in your own words, why each
|
| 30 |
+
one is on your path. Same destination. A walk you'll remember instead of one you'll forget.
|
| 31 |
|
| 32 |
+
> One-liner: **WanderLust turns any walk from A to B into a personal discovery — routing
|
| 33 |
+
> you through places that match your taste, not just the fastest path.** Single city today: **Paris**.
|
| 34 |
|
| 35 |
+
---
|
| 36 |
|
| 37 |
+
## 🔗 Links (judges start here)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
+
- **🎬 Demo video:** _TBA_
|
| 40 |
+
- **📣 Social post:** _TBA_
|
| 41 |
+
- **🧑🤝🧑 Team:** [Ishrat Jahan Ananya](TBA) · [Tristan Leduc](TBA)
|
| 42 |
|
| 43 |
+
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
+
## Why the AI is load-bearing
|
| 46 |
+
|
| 47 |
+
WanderLust **cannot work without the model.** The hard problem isn't routing — it's the
|
| 48 |
+
gap between how people describe what they want and what a router can optimize. A user
|
| 49 |
+
types a fuzzy, open-ended vibe ("somewhere that feels like a slow Sunday morning"), and
|
| 50 |
+
**MiniCPM5-1B converts it into concrete, scored routing weights** across 17 place
|
| 51 |
+
categories plus quiet/green/lively modifiers. No lookup table, keyword list, or rule
|
| 52 |
+
engine can map unbounded human mood onto a route — the model *is* the bridge. It then
|
| 53 |
+
writes the itinerary that explains why each chosen stop matches what you asked for,
|
| 54 |
+
grounded so it can only name real places on your route.
|
| 55 |
+
|
| 56 |
+
(When no GPU is allocated, the app degrades gracefully to an embedding + keyword
|
| 57 |
+
interpreter and a deterministic template — it never breaks — but the model is what makes
|
| 58 |
+
the experience feel like it read your mind.)
|
| 59 |
+
|
| 60 |
+
## The tech, one sentence each
|
| 61 |
+
|
| 62 |
+
- **Model — `openbmb/MiniCPM5-1B`:** a 1B-parameter model does two jobs — vibe → routing
|
| 63 |
+
weights (JSON), and route → first-person itinerary narration.
|
| 64 |
+
- **ZeroGPU:** the model runs **inside the Space** on HF ZeroGPU via `@spaces.GPU`,
|
| 65 |
+
weights pulled from the Hub — no external inference API, nothing leaves the Space.
|
| 66 |
+
- **OpenStreetMap + OSMnx:** the Paris walking/biking graph and ~30k POIs, pre-built and
|
| 67 |
+
cached offline (Git LFS) so the demo city is instant.
|
| 68 |
+
- **Routing — classical, exact:** a `networkx` + SciPy multi-source Dijkstra travel-time
|
| 69 |
+
matrix, solved by a custom **orienteering** (prize-collecting TSP) heuristic with
|
| 70 |
+
submodular diversity — so you get a park + a viewpoint + a bookshop, not five cafés.
|
| 71 |
+
- **Frontend — Gradio `gr.Server`:** a hand-built HTML/CSS/JS app-shell on Gradio's
|
| 72 |
+
FastAPI backend, called from the browser via `@gradio/client` — **no default Gradio UI**.
|
| 73 |
+
|
| 74 |
+
## What it does
|
| 75 |
+
|
| 76 |
+
- **Plain vs. discovery route** drawn on one map, with the exact time the detour buys you.
|
| 77 |
+
- **Vibe → route:** free-text mood reshapes which places the route seeks out.
|
| 78 |
+
- **Detour budget:** one slider trades extra time for discovery; the route never exceeds
|
| 79 |
+
`(1 + budget) ×` the direct time. Budget 0 = the plain fastest route.
|
| 80 |
+
- **Adventurousness:** low → well-documented places; high → injects hidden gems.
|
| 81 |
+
- **Grounded narration:** an itinerary naming only real waypoints, behind a hard
|
| 82 |
+
zero-hallucination gate.
|
| 83 |
+
- **Alternative routes:** up to three genuinely distinct options.
|
| 84 |
+
- **Persistent taste profile:** standing preferences + saved places, per device, blended
|
| 85 |
+
with each trip's mood. No accounts.
|
| 86 |
+
|
| 87 |
+
## Badges we're claiming (and why)
|
| 88 |
+
|
| 89 |
+
| Badge | Justification |
|
| 90 |
+
|---|---|
|
| 91 |
+
| **Off-Brand** | Custom `gr.Server` app-shell — hand-built frontend, **zero default Gradio components**. |
|
| 92 |
+
| **Tiny Titan** | **MiniCPM5-1B — 1B parameters** (well under the 4B cap), running in-Space on ZeroGPU. |
|
| 93 |
+
| **Best Agent** | A four-stage pipeline: **vibe→weights extraction → POI scoring → orienteering solve → grounded narration.** |
|
| 94 |
+
| **Best Demo** | End-to-end Paris route from a single free-text vibe — see the demo video above. |
|
| 95 |
+
| **openbmb** | Built on OpenBMB's MiniCPM5-1B as the core reasoning model. |
|
| 96 |
+
|
| 97 |
+
> We also run **OSM-only with the model in-Space** (no external/cloud APIs at request
|
| 98 |
+
> time) and **log every inference call** to `logs/traces.jsonl` (optionally pushed to an
|
| 99 |
+
> HF Dataset) — supporting the *Off the Grid* and *Open Trace* narratives.
|
| 100 |
+
|
| 101 |
+
## Run it locally
|
| 102 |
|
| 103 |
```bash
|
| 104 |
uv venv --python 3.11
|
| 105 |
+
uv pip install -e ".[ml,dev]" # routing + vibe interpretation/narration + tests
|
|
|
|
| 106 |
|
| 107 |
+
# one-time offline data prep for Paris (downloads OSM, builds the graph + POIs)
|
| 108 |
+
python -m discoverroute.data.build_graph
|
| 109 |
+
python -m discoverroute.data.build_pois
|
| 110 |
|
| 111 |
+
python app.py # serves the gr.Server app-shell on :7860
|
|
|
|
| 112 |
```
|
| 113 |
|
| 114 |
+
`65 tests passing` · See `DEPLOY.md` to push to a Space, `FIELD_NOTES.md` for the build
|
| 115 |
+
story, `PROGRESS.md` for the per-feature log.
|
| 116 |
|
| 117 |
+
## Architecture
|
|
|
|
| 118 |
|
| 119 |
+
**Offline (built once for Paris, cached):** OSM extract → walk/bike routing graph →
|
| 120 |
+
POIs with feature priors + confidence.
|
| 121 |
|
| 122 |
+
**Runtime (per request):** interpret vibe → score corridor POIs → solve the detour
|
| 123 |
+
(orienteering) → trace a real polyline → narrate + overlay on the map.
|
|
|
|
| 124 |
|
| 125 |
+
The model is load-bearing only in **interpretation and narration**; routing is pure
|
| 126 |
+
classical algorithms. Geocoding is local-first — named Paris places resolve against the
|
| 127 |
+
cached POI table with no network call (`DISCOVERROUTE_OFFLINE=1` forbids the Nominatim
|
| 128 |
+
fallback entirely). Map data © OpenStreetMap contributors (ODbL).
|
src/discoverroute/ui/shell.py
CHANGED
|
@@ -25,76 +25,139 @@ body{ font-family:'DM Sans',ui-sans-serif,system-ui,sans-serif; color:var(--dr-i
|
|
| 25 |
background:radial-gradient(1100px 520px at 88% -8%,#FBEFD6 0%,transparent 60%),var(--dr-cream); }
|
| 26 |
|
| 27 |
.app-shell{
|
| 28 |
-
display:grid; grid-template-columns:
|
| 29 |
height:100vh; width:100%; background:var(--dr-cream);
|
| 30 |
}
|
| 31 |
|
| 32 |
-
/* ---- hero (compact
|
| 33 |
-
.hero{ grid-column:1 / -1; grid-row:1; display:flex; align-items:center; gap:
|
| 34 |
background:linear-gradient(120deg,#2F5DF4,#5C7DF8); color:#fff;
|
| 35 |
-
padding:
|
| 36 |
-
|
| 37 |
-
.hero
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
.hero .loc-pill
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
.hero h1 .accent{ color:#FFE0A0; }
|
| 44 |
-
.hero p{ margin:0 0
|
| 45 |
-
.hero .badges{ display:flex; flex-wrap:wrap; gap:
|
| 46 |
-
.hero .badges span{ background:rgba(255,255,255,.
|
| 47 |
font-size:11.5px; font-family:'Fredoka',sans-serif; font-weight:500; }
|
| 48 |
-
.hero .hero-art{ flex-shrink:0; width:
|
| 49 |
-
|
| 50 |
-
.hero .
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
| 52 |
font-weight:600; letter-spacing:.02em; }
|
|
|
|
|
|
|
| 53 |
|
| 54 |
-
/* ---- left control panel (
|
| 55 |
-
.left-panel{ grid-column:1; grid-row:2; width:
|
| 56 |
-
|
|
|
|
| 57 |
display:flex; flex-direction:column; }
|
| 58 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
.dr-label{ display:block; font-family:'Fredoka',sans-serif; font-weight:600; font-size:13.5px;
|
| 60 |
-
color:var(--dr-ink); margin-bottom:
|
| 61 |
-
.dr-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
.combo{ position:relative; }
|
| 64 |
-
.combo-list{ position:absolute; z-index:
|
| 65 |
-
background:var(--dr-paper); border:
|
| 66 |
-
box-shadow:0
|
| 67 |
-
.combo-list.open{ display:block; }
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
| 69 |
.combo-list div:hover,.combo-list div.active{ background:#F1FAF4; }
|
| 70 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
.dr-slider input[type=range]{ flex:1; }
|
| 72 |
.dr-slider output{ font-family:'JetBrains Mono',monospace; font-size:12.5px; color:var(--dr-soft);
|
| 73 |
-
min-width:
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
details.dr-collapse summary::-webkit-details-marker{ display:none; }
|
|
|
|
|
|
|
|
|
|
| 78 |
details.dr-collapse[open] summary{ border-bottom:1.5px dashed var(--dr-line); }
|
| 79 |
-
details.dr-collapse .collapse-body{ padding:
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
| 83 |
.dr-note{ font-size:12.5px; color:var(--dr-soft); margin:8px 0; }
|
| 84 |
.dr-row{ display:flex; gap:8px; }
|
| 85 |
-
.dr-row button{ flex:1; padding:
|
| 86 |
-
background:var(--dr-paper); font-family:'Fredoka',sans-serif; font-weight:600; cursor:pointer;
|
| 87 |
-
color:var(--dr-ink); }
|
| 88 |
-
.dr-star{ width:100%; margin-top:8px; padding:9px; border-radius:12px; border:1px solid var(--dr-line);
|
| 89 |
background:var(--dr-paper); font-family:'Fredoka',sans-serif; font-weight:600; cursor:pointer;
|
| 90 |
-
color:var(--dr-ink); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
/* ---- right column: map · summary bar · itinerary ---- */
|
| 93 |
.right-col{ grid-column:2; grid-row:2; display:flex; flex-direction:column; height:100%;
|
| 94 |
-
min-width:0; padding:
|
| 95 |
-
.map-container{ flex:1 1 0; min-height:
|
| 96 |
#dr-map{ height:100%; }
|
| 97 |
-
#dr-map .map-inner{ position:absolute; inset:
|
| 98 |
#dr-map .map-inner > div,
|
| 99 |
#dr-map iframe,
|
| 100 |
#dr-map .folium-map,
|
|
@@ -104,33 +167,53 @@ details.dr-collapse .collapse-body{ padding:12px 14px; }
|
|
| 104 |
#dr-summary{ margin:0; }
|
| 105 |
/* the styled result blocks only exist once a route is planned */
|
| 106 |
#dr-summary:empty, #dr-interp:empty, #dr-itin:empty, #dr-nodetour:empty{ display:none; }
|
| 107 |
-
.itinerary-panel{ flex:0
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
#dr-options .opt:hover{ transform:translateY(-3px); }
|
| 113 |
#dr-options .opt.selected{ border-color:var(--dr-grass); background:#F1FAF4;
|
| 114 |
box-shadow:0 10px 26px -14px rgba(47,164,99,.6); }
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
/* ---- loading: stride + 4-step stepper (State 2) ---- */
|
| 117 |
-
#dr-loading{ position:absolute; inset:
|
| 118 |
place-items:center; border-radius:0 0 26px 26px;
|
| 119 |
background:radial-gradient(700px 320px at 50% 0%,#FBEFD6 0%,transparent 70%),#F6ECD9; }
|
| 120 |
-
#dr-loading.on{ display:grid; }
|
| 121 |
-
|
| 122 |
-
.stepper
|
| 123 |
-
|
|
|
|
| 124 |
.stepper .step.active{ opacity:1; }
|
| 125 |
-
.stepper .step .pip{ width:
|
| 126 |
-
transition:background .4s ease, transform .4s var(--dr-spring); }
|
| 127 |
-
.stepper .step.active .pip{ background:var(--dr-grass); transform:scale(1.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
.stepper .step .lbl{ font-size:11.5px; font-family:'DM Sans',sans-serif; color:var(--dr-ink);
|
| 129 |
-
text-align:center; }
|
| 130 |
-
.stepper .bar{ flex:1; height:3px; min-width:
|
|
|
|
| 131 |
.stepper .bar.fill{ background:var(--dr-grass); }
|
| 132 |
/* State 1 — non-Paris graph load (inert for the Paris demo, built per brief) */
|
| 133 |
-
#dr-mapping{ position:absolute; inset:
|
| 134 |
background:#F6ECD9; }
|
| 135 |
#dr-mapping.on{ display:grid; }
|
| 136 |
#dr-mapping .pulse{ width:18px;height:18px;border-radius:50%;background:var(--dr-grass);
|
|
@@ -139,6 +222,7 @@ details.dr-collapse .collapse-body{ padding:12px 14px; }
|
|
| 139 |
|
| 140 |
/* no-detour sticker slot */
|
| 141 |
#dr-nodetour:empty{ display:none; }
|
|
|
|
| 142 |
|
| 143 |
/* mobile FAB (hidden on desktop) */
|
| 144 |
.fab{ display:none; }
|
|
@@ -148,24 +232,32 @@ details.dr-collapse .collapse-body{ padding:12px 14px; }
|
|
| 148 |
.app-shell{ grid-template-columns:1fr; grid-template-rows:auto 1fr; height:100vh; }
|
| 149 |
.hero{ max-height:none; }
|
| 150 |
.hero .hero-art{ display:none; }
|
|
|
|
| 151 |
.right-col{ grid-column:1; grid-row:2; padding:0; gap:0; }
|
| 152 |
.map-container{ min-height:0; }
|
| 153 |
#dr-map .map-inner, #dr-loading, #dr-mapping{ inset:0; }
|
| 154 |
.itinerary-panel{ max-height:34vh; padding:0 14px 14px; }
|
| 155 |
.route-summary-bar{ padding:0 12px; }
|
| 156 |
.left-panel{ position:fixed; left:0; right:0; bottom:0; top:auto; z-index:200; width:100%;
|
| 157 |
-
height:auto; max-height:
|
| 158 |
-
border-top-right-radius:
|
| 159 |
-
transform:translateY(110%); transition:transform .32s var(--dr-spring);
|
|
|
|
| 160 |
.left-panel.open{ transform:translateY(0); }
|
| 161 |
.fab{ display:grid; place-items:center; position:fixed; right:18px; bottom:18px; z-index:210;
|
| 162 |
-
width:
|
| 163 |
-
background:var(--dr-coral); color:#fff; font-size:
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
| 166 |
}
|
| 167 |
-
.drawer-close{ display:none;
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
@media (prefers-reduced-motion:reduce){ *{ animation:none !important; transition:none !important; } }
|
| 171 |
"""
|
|
@@ -192,38 +284,67 @@ def _compact_hero() -> str:
|
|
| 192 |
"""
|
| 193 |
|
| 194 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
def _left_panel() -> str:
|
| 196 |
-
return """
|
| 197 |
<aside class="left-panel" id="left-panel">
|
| 198 |
-
<div class="drawer-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
|
| 200 |
<div class="dr-control combo">
|
| 201 |
<label class="dr-label" for="dr-start">Start</label>
|
| 202 |
-
<
|
| 203 |
-
|
|
|
|
|
|
|
| 204 |
<div class="combo-list" id="dr-start-list"></div>
|
| 205 |
-
<div class="dr-help">
|
| 206 |
</div>
|
| 207 |
|
| 208 |
<div class="dr-control combo">
|
| 209 |
<label class="dr-label" for="dr-dest">Destination</label>
|
| 210 |
-
<
|
| 211 |
-
|
|
|
|
|
|
|
| 212 |
<div class="combo-list" id="dr-dest-list"></div>
|
| 213 |
</div>
|
| 214 |
|
| 215 |
-
<div class="dr-control">
|
| 216 |
-
<label class="dr-label" for="dr-vibe">Vibe (free text)</label>
|
| 217 |
-
<input type="text" id="dr-vibe" class="dr-field"
|
| 218 |
-
placeholder="e.g. 'quiet green wander' or 'lively café crawl'">
|
| 219 |
-
</div>
|
| 220 |
-
|
| 221 |
<div class="dr-control">
|
| 222 |
<label class="dr-label">Mode</label>
|
| 223 |
<div id="dr-mode" class="dr-seg">
|
| 224 |
<div class="wrap">
|
| 225 |
-
<label class="selected" data-v="walk">walk</label>
|
| 226 |
-
<label data-v="bike">bike</label>
|
| 227 |
</div>
|
| 228 |
</div>
|
| 229 |
</div>
|
|
@@ -231,52 +352,54 @@ def _left_panel() -> str:
|
|
| 231 |
<div class="dr-control">
|
| 232 |
<label class="dr-label" for="dr-budget-i">Detour budget</label>
|
| 233 |
<div id="dr-budget" class="dr-slider">
|
| 234 |
-
<input type="range" id="dr-budget-i" min="0" max="2" step="0.1" value="0.5"
|
|
|
|
| 235 |
<output id="dr-budget-o">0.5</output>
|
| 236 |
</div>
|
| 237 |
-
<div class="
|
| 238 |
</div>
|
| 239 |
|
| 240 |
<div class="dr-control">
|
| 241 |
<label class="dr-label" for="dr-adv-i">Adventurousness</label>
|
| 242 |
<div id="dr-adv" class="dr-slider">
|
| 243 |
-
<input type="range" id="dr-adv-i" min="0" max="1" step="0.05" value="0.3"
|
|
|
|
| 244 |
<output id="dr-adv-o">0.3</output>
|
| 245 |
</div>
|
| 246 |
-
<div class="
|
| 247 |
</div>
|
| 248 |
|
| 249 |
<details class="dr-collapse">
|
| 250 |
-
<summary>Manual taste
|
| 251 |
<div class="collapse-body">
|
|
|
|
|
|
|
| 252 |
<div class="dr-control">
|
| 253 |
<label class="dr-label" for="dr-green-i">Prefer green</label>
|
| 254 |
<div id="dr-green" class="dr-slider green">
|
| 255 |
-
<input type="range" id="dr-green-i" min="0" max="1" step="0.05" value="0.5"
|
|
|
|
| 256 |
<output id="dr-green-o">0.5</output>
|
| 257 |
</div>
|
| 258 |
</div>
|
| 259 |
-
<div class="dr-control">
|
| 260 |
<label class="dr-label" for="dr-quiet-i">Prefer quiet</label>
|
| 261 |
<div id="dr-quiet" class="dr-slider green">
|
| 262 |
-
<input type="range" id="dr-quiet-i" min="0" max="1" step="0.05" value="0.5"
|
|
|
|
| 263 |
<output id="dr-quiet-o">0.5</output>
|
| 264 |
</div>
|
| 265 |
</div>
|
| 266 |
</div>
|
| 267 |
</details>
|
| 268 |
|
| 269 |
-
<div class="dr-cta-wrap">
|
| 270 |
-
<div id="dr-plan"><button type="button" id="dr-plan-btn">Plan route</button></div>
|
| 271 |
-
</div>
|
| 272 |
-
|
| 273 |
<details class="dr-collapse">
|
| 274 |
-
<summary>⭐ My taste profile
|
| 275 |
<div class="collapse-body">
|
| 276 |
<label class="dr-label" for="dr-profile-text">Standing preferences</label>
|
| 277 |
<input type="text" id="dr-profile-text" class="dr-field"
|
| 278 |
placeholder="e.g. 'I always love bookshops, gardens, and old churches'">
|
| 279 |
-
<div class="dr-row" style="margin-top:
|
| 280 |
<button type="button" id="dr-save">Save</button>
|
| 281 |
<button type="button" id="dr-clear">Clear</button>
|
| 282 |
</div>
|
|
@@ -284,6 +407,12 @@ def _left_panel() -> str:
|
|
| 284 |
<button type="button" class="dr-star" id="dr-save-places">⭐ Save this route's places</button>
|
| 285 |
</div>
|
| 286 |
</details>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
</aside>
|
| 288 |
"""
|
| 289 |
|
|
@@ -315,6 +444,15 @@ def index_html() -> str:
|
|
| 315 |
</div>
|
| 316 |
<div class="route-summary-bar"><div id="dr-summary"></div></div>
|
| 317 |
<div class="itinerary-panel">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
<div id="dr-options"></div>
|
| 319 |
<div id="dr-nodetour"></div>
|
| 320 |
<div id="dr-interp"></div>
|
|
@@ -322,7 +460,7 @@ def index_html() -> str:
|
|
| 322 |
</div>
|
| 323 |
</main>
|
| 324 |
</div>
|
| 325 |
-
<button class="fab" id="fab" aria-label="Open controls">
|
| 326 |
|
| 327 |
<script type="module">{_app_js()}</script>
|
| 328 |
</body>
|
|
@@ -341,16 +479,14 @@ def _loading_inner() -> str:
|
|
| 341 |
f'<div class="lbl">{label}</div></div>'
|
| 342 |
)
|
| 343 |
stepper = '<div class="stepper">' + "".join(pips) + "</div>"
|
| 344 |
-
# Reuse the existing stride mascot markup from LOADING_HTML's inner SVG block.
|
| 345 |
return f"""
|
| 346 |
-
<div style="text-align:center;">
|
| 347 |
-
<div style="font-family:'Fredoka',system-ui,sans-serif;font-weight:600;font-size:
|
| 348 |
color:#2B2620;">Scouting your wander…</div>
|
|
|
|
|
|
|
| 349 |
{stepper}
|
| 350 |
</div>
|
| 351 |
-
<style>
|
| 352 |
-
@keyframes drBounce {{ 0%,100% {{ transform:translateY(0) }} 50% {{ transform:translateY(-9px) }} }}
|
| 353 |
-
</style>
|
| 354 |
"""
|
| 355 |
|
| 356 |
|
|
@@ -409,15 +545,37 @@ function toast(msg) {
|
|
| 409 |
inp.addEventListener("input", () => out.textContent = (+inp.value).toFixed(2).replace(/\.?0+$/, "") || "0");
|
| 410 |
});
|
| 411 |
|
| 412 |
-
/* ---------- mode segmented toggle ---------- */
|
| 413 |
let mode = "walk";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
document.querySelectorAll("#dr-mode label").forEach(l => {
|
| 415 |
-
l.addEventListener("click", () =>
|
| 416 |
-
|
| 417 |
-
|
| 418 |
});
|
| 419 |
});
|
| 420 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
/* ---------- autocomplete combobox (replaces gr.Dropdown) ---------- */
|
| 422 |
function wireCombo(inputId, listId) {
|
| 423 |
const input = $(inputId), list = $(listId);
|
|
@@ -457,12 +615,17 @@ wireCombo("dr-dest", "dr-dest-list");
|
|
| 457 |
/* ---------- 4-step loader (paced to the pipeline phases) ---------- */
|
| 458 |
let stepTimer = null;
|
| 459 |
function setStep(n) {
|
| 460 |
-
document.querySelectorAll(".stepper .step").forEach(s =>
|
| 461 |
-
|
|
|
|
|
|
|
|
|
|
| 462 |
document.querySelectorAll(".stepper .bar").forEach(b =>
|
| 463 |
b.classList.toggle("fill", +b.dataset.bar <= n));
|
| 464 |
}
|
|
|
|
| 465 |
function startLoading() {
|
|
|
|
| 466 |
$("dr-loading").classList.add("on");
|
| 467 |
$("dr-summary").innerHTML = ""; $("dr-itin").innerHTML = "";
|
| 468 |
$("dr-interp").innerHTML = ""; $("dr-options").innerHTML = ""; $("dr-nodetour").innerHTML = "";
|
|
@@ -472,19 +635,38 @@ function startLoading() {
|
|
| 472 |
function stopLoading() { clearInterval(stepTimer); setStep(3); $("dr-loading").classList.remove("on"); }
|
| 473 |
|
| 474 |
/* ---------- render ---------- */
|
|
|
|
| 475 |
let lastAlts = [], lastCats = [];
|
| 476 |
function renderMap(html) { $("dr-map").innerHTML = '<div class="map-inner">' + html + "</div>"; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
function selectAlt(idx) {
|
| 478 |
const a = lastAlts[idx]; if (!a) return;
|
| 479 |
renderMap(a.map_html);
|
| 480 |
$("dr-summary").innerHTML = md(a.summary_md);
|
| 481 |
$("dr-itin").innerHTML = md(a.itinerary_md);
|
|
|
|
| 482 |
document.querySelectorAll("#dr-options .opt").forEach((o, i) =>
|
| 483 |
o.classList.toggle("selected", i === idx));
|
| 484 |
}
|
| 485 |
function renderResult(d) {
|
|
|
|
| 486 |
if (d.error) { renderMap(d.map_html); toast("Hmm — " + d.error.split(".")[0] + "."); return; }
|
| 487 |
$("dr-interp").innerHTML = md(d.interpretation_md);
|
|
|
|
| 488 |
if (d.no_detour) {
|
| 489 |
renderMap(d.map_html);
|
| 490 |
$("dr-summary").innerHTML = md(d.summary_md);
|
|
@@ -547,8 +729,34 @@ $("dr-save-places").addEventListener("click", () => {
|
|
| 547 |
});
|
| 548 |
|
| 549 |
/* ---------- mobile drawer ---------- */
|
| 550 |
-
|
| 551 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 552 |
|
|
|
|
| 553 |
renderSaved();
|
| 554 |
"""
|
|
|
|
| 25 |
background:radial-gradient(1100px 520px at 88% -8%,#FBEFD6 0%,transparent 60%),var(--dr-cream); }
|
| 26 |
|
| 27 |
.app-shell{
|
| 28 |
+
display:grid; grid-template-columns:340px 1fr; grid-template-rows:auto 1fr;
|
| 29 |
height:100vh; width:100%; background:var(--dr-cream);
|
| 30 |
}
|
| 31 |
|
| 32 |
+
/* ---- hero (compact but roomy — content must never clip) ---- */
|
| 33 |
+
.hero{ grid-column:1 / -1; grid-row:1; display:flex; align-items:center; gap:22px;
|
| 34 |
background:linear-gradient(120deg,#2F5DF4,#5C7DF8); color:#fff;
|
| 35 |
+
padding:18px 34px; max-height:180px; overflow:hidden; position:relative;
|
| 36 |
+
box-shadow:0 10px 30px -18px rgba(33,74,208,.7); z-index:3; }
|
| 37 |
+
.hero::after{ content:''; position:absolute; inset:0; pointer-events:none;
|
| 38 |
+
background:radial-gradient(420px 180px at 96% -30%,rgba(255,255,255,.16),transparent 70%); }
|
| 39 |
+
.hero .hero-body{ flex:1; min-width:0; position:relative; z-index:1; }
|
| 40 |
+
.hero .loc-pill{ display:inline-flex; align-items:center; gap:7px; background:rgba(255,255,255,.18);
|
| 41 |
+
border-radius:999px; padding:4px 12px; font-family:'Fredoka',sans-serif; font-weight:600;
|
| 42 |
+
font-size:11.5px; letter-spacing:.05em; text-transform:uppercase; }
|
| 43 |
+
.hero .loc-pill .dot{ width:7px;height:7px;border-radius:50%;background:#FFC247;
|
| 44 |
+
box-shadow:0 0 0 0 rgba(255,194,71,.7); animation:drBeacon 2.4s ease-out infinite; }
|
| 45 |
+
@keyframes drBeacon{ 0%{ box-shadow:0 0 0 0 rgba(255,194,71,.6); }
|
| 46 |
+
70%,100%{ box-shadow:0 0 0 7px rgba(255,194,71,0); } }
|
| 47 |
+
.hero h1{ font-family:'Fredoka',sans-serif; font-weight:700; font-size:30px; letter-spacing:-.025em;
|
| 48 |
+
margin:7px 0 4px; line-height:1.04; color:#fff; }
|
| 49 |
.hero h1 .accent{ color:#FFE0A0; }
|
| 50 |
+
.hero p{ margin:0 0 9px; max-width:64ch; color:#EAF0FF; font-size:13px; line-height:1.35; }
|
| 51 |
+
.hero .badges{ display:flex; flex-wrap:wrap; gap:8px; }
|
| 52 |
+
.hero .badges span{ background:rgba(255,255,255,.14); border-radius:999px; padding:5px 12px;
|
| 53 |
font-size:11.5px; font-family:'Fredoka',sans-serif; font-weight:500; }
|
| 54 |
+
.hero .hero-art{ flex-shrink:0; width:84px; position:relative; z-index:1;
|
| 55 |
+
animation:drFloat 6s ease-in-out infinite; }
|
| 56 |
+
.hero .hero-art svg{ width:84px; height:auto; display:block; }
|
| 57 |
+
@keyframes drFloat{ 0%,100%{ transform:translateY(0); } 50%{ transform:translateY(-7px); } }
|
| 58 |
+
.hero .titan-chip{ position:absolute; top:12px; right:20px; z-index:2;
|
| 59 |
+
display:inline-flex; align-items:center; gap:6px; background:rgba(255,255,255,.18);
|
| 60 |
+
border-radius:999px; padding:4px 12px; font-size:10.5px; font-family:'Fredoka',sans-serif;
|
| 61 |
font-weight:600; letter-spacing:.02em; }
|
| 62 |
+
.hero .titan-chip::before{ content:''; width:6px; height:6px; border-radius:50%;
|
| 63 |
+
background:#9BF0BE; box-shadow:0 0 6px #6FE39C; }
|
| 64 |
|
| 65 |
+
/* ---- left control panel (340px, scrollable, sticky CTA) ---- */
|
| 66 |
+
.left-panel{ grid-column:1; grid-row:2; width:340px; height:100%; overflow:hidden;
|
| 67 |
+
border-right:1px solid var(--dr-line);
|
| 68 |
+
background:linear-gradient(180deg,#FBF3E2,var(--dr-cream) 140px);
|
| 69 |
display:flex; flex-direction:column; }
|
| 70 |
+
.panel-scroll{ flex:1 1 auto; min-height:0; overflow-y:auto; overflow-x:hidden;
|
| 71 |
+
padding:20px 18px 10px; scrollbar-width:thin; scrollbar-color:var(--dr-line) transparent; }
|
| 72 |
+
.panel-scroll::-webkit-scrollbar{ width:9px; }
|
| 73 |
+
.panel-scroll::-webkit-scrollbar-thumb{ background:var(--dr-line); border-radius:9px;
|
| 74 |
+
border:3px solid transparent; background-clip:content-box; }
|
| 75 |
+
.dr-control{ margin-bottom:15px; }
|
| 76 |
.dr-label{ display:block; font-family:'Fredoka',sans-serif; font-weight:600; font-size:13.5px;
|
| 77 |
+
color:var(--dr-ink); margin-bottom:6px; }
|
| 78 |
+
.dr-label .opt-tag{ font-family:'DM Sans',sans-serif; font-weight:500; font-size:10.5px;
|
| 79 |
+
color:var(--dr-soft); text-transform:uppercase; letter-spacing:.05em; margin-left:6px; }
|
| 80 |
+
.dr-help{ font-size:11.5px; color:var(--dr-soft); margin-top:4px; line-height:1.4; }
|
| 81 |
+
.left-panel input[type=text]{ width:100%; padding:11px 13px; font-size:14px; }
|
| 82 |
+
|
| 83 |
+
/* a leading pin/flag glyph for the start & destination fields */
|
| 84 |
+
.field-wrap{ position:relative; }
|
| 85 |
+
.field-wrap > input[type=text]{ padding-left:34px; }
|
| 86 |
+
.field-wrap::before{ content:attr(data-glyph); position:absolute; left:12px; top:50%;
|
| 87 |
+
transform:translateY(-50%); font-size:14px; pointer-events:none; z-index:2; line-height:1; }
|
| 88 |
+
|
| 89 |
.combo{ position:relative; }
|
| 90 |
+
.combo-list{ position:absolute; z-index:60; left:0; right:0; top:calc(100% + 5px);
|
| 91 |
+
background:var(--dr-paper); border:1.5px solid var(--dr-line); border-radius:16px;
|
| 92 |
+
box-shadow:0 18px 38px -16px rgba(43,38,32,.45); overflow:hidden; display:none; padding:5px; }
|
| 93 |
+
.combo-list.open{ display:block; animation:drPop .16s var(--dr-spring); }
|
| 94 |
+
@keyframes drPop{ from{ transform:translateY(-4px); opacity:.4; } to{ transform:none; opacity:1; } }
|
| 95 |
+
.combo-list div{ padding:9px 11px; font-size:13.5px; cursor:pointer; border-radius:11px;
|
| 96 |
+
display:flex; align-items:center; gap:8px; transition:background .12s; }
|
| 97 |
+
.combo-list div::before{ content:'📍'; font-size:11px; opacity:.55; }
|
| 98 |
.combo-list div:hover,.combo-list div.active{ background:#F1FAF4; }
|
| 99 |
+
.combo-list div.active{ box-shadow:inset 0 0 0 1.5px rgba(47,164,99,.4); }
|
| 100 |
+
|
| 101 |
+
/* ---- VIBE — the star input ---- */
|
| 102 |
+
.vibe-block{ background:var(--dr-paper); border:1.5px solid var(--dr-line); border-radius:20px;
|
| 103 |
+
padding:14px 14px 12px; margin-bottom:16px;
|
| 104 |
+
box-shadow:0 14px 34px -22px rgba(43,38,32,.4); position:relative; }
|
| 105 |
+
.vibe-block::before{ content:'⭐ start here'; position:absolute; top:-9px; left:14px;
|
| 106 |
+
background:var(--dr-sun); color:#5A3D00; font-family:'Fredoka',sans-serif; font-weight:600;
|
| 107 |
+
font-size:10px; letter-spacing:.04em; text-transform:uppercase; padding:2px 9px;
|
| 108 |
+
border-radius:999px; box-shadow:0 4px 10px -4px rgba(255,194,71,.9); }
|
| 109 |
+
.vibe-block .dr-label{ font-size:15px; margin-top:2px; }
|
| 110 |
+
.vibe-block input[type=text]{ font-size:15px !important; }
|
| 111 |
+
.vibe-chips{ display:flex; flex-wrap:wrap; gap:7px; margin-top:10px; }
|
| 112 |
+
.vibe-chips .chip{ border:1.5px solid var(--dr-line); background:#FFFDF8; color:var(--dr-ink);
|
| 113 |
+
border-radius:999px; padding:6px 12px; font-size:12px; font-family:'DM Sans',sans-serif;
|
| 114 |
+
font-weight:500; cursor:pointer; transition:all .18s var(--dr-spring); }
|
| 115 |
+
.vibe-chips .chip:hover{ transform:translateY(-2px); border-color:var(--dr-coral);
|
| 116 |
+
background:#FFF1ED; box-shadow:0 8px 16px -10px rgba(255,106,82,.7); }
|
| 117 |
+
.vibe-chips .chip:active{ transform:translateY(0); }
|
| 118 |
+
.vibe-chips .chip.on{ border-color:var(--dr-coral); background:var(--dr-coral); color:#fff;
|
| 119 |
+
box-shadow:0 8px 18px -8px rgba(255,106,82,.8); }
|
| 120 |
+
|
| 121 |
+
.dr-slider{ display:flex; align-items:center; gap:12px; }
|
| 122 |
.dr-slider input[type=range]{ flex:1; }
|
| 123 |
.dr-slider output{ font-family:'JetBrains Mono',monospace; font-size:12.5px; color:var(--dr-soft);
|
| 124 |
+
min-width:34px; text-align:right; padding:2px 7px; background:#FFFDF8; border:1px solid var(--dr-line);
|
| 125 |
+
border-radius:8px; }
|
| 126 |
+
.slider-ends{ display:flex; justify-content:space-between; font-size:10.5px; color:var(--dr-soft);
|
| 127 |
+
margin-top:3px; letter-spacing:.02em; }
|
| 128 |
+
|
| 129 |
+
details.dr-collapse{ padding:0; margin-bottom:14px; }
|
| 130 |
+
details.dr-collapse summary{ list-style:none; cursor:pointer; padding:12px 15px;
|
| 131 |
+
font-family:'Fredoka',sans-serif; font-weight:600; font-size:13px; color:var(--dr-ink);
|
| 132 |
+
display:flex; align-items:center; justify-content:space-between; gap:8px; }
|
| 133 |
details.dr-collapse summary::-webkit-details-marker{ display:none; }
|
| 134 |
+
details.dr-collapse summary::after{ content:'▾'; color:var(--dr-soft); font-size:12px;
|
| 135 |
+
transition:transform .2s ease; }
|
| 136 |
+
details.dr-collapse[open] summary::after{ transform:rotate(180deg); }
|
| 137 |
details.dr-collapse[open] summary{ border-bottom:1.5px dashed var(--dr-line); }
|
| 138 |
+
details.dr-collapse .collapse-body{ padding:13px 15px; }
|
| 139 |
+
|
| 140 |
+
.dr-cta-wrap{ flex:0 0 auto; padding:12px 18px 16px; background:var(--dr-cream);
|
| 141 |
+
border-top:1px solid var(--dr-line); box-shadow:0 -10px 22px -18px rgba(43,38,32,.5); }
|
| 142 |
+
#dr-plan button{ width:100%; padding:15px; cursor:pointer; }
|
| 143 |
+
.cta-hint{ text-align:center; font-size:11px; color:var(--dr-soft); margin-top:8px; }
|
| 144 |
.dr-note{ font-size:12.5px; color:var(--dr-soft); margin:8px 0; }
|
| 145 |
.dr-row{ display:flex; gap:8px; }
|
| 146 |
+
.dr-row button{ flex:1; padding:9px; border-radius:12px; border:1.5px solid var(--dr-line);
|
|
|
|
|
|
|
|
|
|
| 147 |
background:var(--dr-paper); font-family:'Fredoka',sans-serif; font-weight:600; cursor:pointer;
|
| 148 |
+
color:var(--dr-ink); transition:all .16s var(--dr-spring); }
|
| 149 |
+
.dr-row button:hover{ transform:translateY(-2px); border-color:var(--dr-cobalt); }
|
| 150 |
+
.dr-star{ width:100%; margin-top:10px; padding:10px; border-radius:12px; border:1.5px dashed var(--dr-line);
|
| 151 |
+
background:#FFFDF8; font-family:'Fredoka',sans-serif; font-weight:600; cursor:pointer;
|
| 152 |
+
color:var(--dr-ink); transition:all .16s var(--dr-spring); }
|
| 153 |
+
.dr-star:hover{ transform:translateY(-2px); border-color:var(--dr-sun); background:#FFF8E8; }
|
| 154 |
|
| 155 |
/* ---- right column: map · summary bar · itinerary ---- */
|
| 156 |
.right-col{ grid-column:2; grid-row:2; display:flex; flex-direction:column; height:100%;
|
| 157 |
+
min-width:0; padding:16px 18px 0; gap:14px; overflow:hidden; }
|
| 158 |
+
.map-container{ flex:1 1 0; min-height:240px; position:relative; }
|
| 159 |
#dr-map{ height:100%; }
|
| 160 |
+
#dr-map .map-inner{ position:absolute; inset:46px 0 0 0; }
|
| 161 |
#dr-map .map-inner > div,
|
| 162 |
#dr-map iframe,
|
| 163 |
#dr-map .folium-map,
|
|
|
|
| 167 |
#dr-summary{ margin:0; }
|
| 168 |
/* the styled result blocks only exist once a route is planned */
|
| 169 |
#dr-summary:empty, #dr-interp:empty, #dr-itin:empty, #dr-nodetour:empty{ display:none; }
|
| 170 |
+
.itinerary-panel{ flex:0 1 auto; min-height:0; max-height:46vh; overflow-y:auto; padding-bottom:16px;
|
| 171 |
+
scrollbar-width:thin; scrollbar-color:var(--dr-line) transparent; }
|
| 172 |
+
.itinerary-panel::-webkit-scrollbar{ width:9px; }
|
| 173 |
+
.itinerary-panel::-webkit-scrollbar-thumb{ background:var(--dr-line); border-radius:9px;
|
| 174 |
+
border:3px solid transparent; background-clip:content-box; }
|
| 175 |
+
#dr-options{ display:flex; flex-wrap:wrap; gap:10px; margin-bottom:14px; }
|
| 176 |
+
#dr-options:empty{ display:none; }
|
| 177 |
+
#dr-options .opt{ border:2px solid var(--dr-line); border-radius:var(--dr-r); padding:11px 14px;
|
| 178 |
+
background:var(--dr-paper); color:var(--dr-ink); cursor:pointer; font-size:12.5px; font-weight:500;
|
| 179 |
+
box-shadow:0 6px 16px -14px rgba(43,38,32,.5);
|
| 180 |
+
transition:transform .2s var(--dr-spring), border-color .2s, box-shadow .2s; }
|
| 181 |
#dr-options .opt:hover{ transform:translateY(-3px); }
|
| 182 |
#dr-options .opt.selected{ border-color:var(--dr-grass); background:#F1FAF4;
|
| 183 |
box-shadow:0 10px 26px -14px rgba(47,164,99,.6); }
|
| 184 |
|
| 185 |
+
/* onboarding hint shown in the itinerary slot before the first plan */
|
| 186 |
+
.onboard{ background:#FFFDF8; border:1.5px dashed var(--dr-line); border-radius:var(--dr-r);
|
| 187 |
+
padding:16px 18px; display:flex; gap:13px; align-items:flex-start; }
|
| 188 |
+
.onboard .ob-emoji{ font-size:22px; line-height:1; flex-shrink:0; margin-top:1px; }
|
| 189 |
+
.onboard h3{ font-family:'Fredoka',sans-serif; font-weight:600; font-size:15px; margin:0 0 3px;
|
| 190 |
+
color:var(--dr-ink); }
|
| 191 |
+
.onboard p{ margin:0; font-size:12.5px; color:var(--dr-soft); line-height:1.5; }
|
| 192 |
+
|
| 193 |
/* ---- loading: stride + 4-step stepper (State 2) ---- */
|
| 194 |
+
#dr-loading{ position:absolute; inset:46px 0 0 0; z-index:20; display:none;
|
| 195 |
place-items:center; border-radius:0 0 26px 26px;
|
| 196 |
background:radial-gradient(700px 320px at 50% 0%,#FBEFD6 0%,transparent 70%),#F6ECD9; }
|
| 197 |
+
#dr-loading.on{ display:grid; animation:drFade .25s ease; }
|
| 198 |
+
@keyframes drFade{ from{ opacity:0; } to{ opacity:1; } }
|
| 199 |
+
.stepper{ display:flex; align-items:flex-start; gap:0; margin-top:22px; }
|
| 200 |
+
.stepper .step{ display:flex; flex-direction:column; align-items:center; gap:8px; width:118px;
|
| 201 |
+
opacity:.38; transition:opacity .4s ease; }
|
| 202 |
.stepper .step.active{ opacity:1; }
|
| 203 |
+
.stepper .step .pip{ width:16px; height:16px; border-radius:50%; background:#CFE8D8;
|
| 204 |
+
transition:background .4s ease, transform .4s var(--dr-spring); position:relative; }
|
| 205 |
+
.stepper .step.active .pip{ background:var(--dr-grass); transform:scale(1.18);
|
| 206 |
+
box-shadow:0 4px 10px -3px rgba(47,164,99,.7); }
|
| 207 |
+
.stepper .step.current .pip::after{ content:''; position:absolute; inset:-5px; border-radius:50%;
|
| 208 |
+
border:2px solid var(--dr-grass); opacity:.5; animation:drRing 1.1s ease-out infinite; }
|
| 209 |
+
@keyframes drRing{ 0%{ transform:scale(.7); opacity:.6; } 100%{ transform:scale(1.5); opacity:0; } }
|
| 210 |
.stepper .step .lbl{ font-size:11.5px; font-family:'DM Sans',sans-serif; color:var(--dr-ink);
|
| 211 |
+
text-align:center; line-height:1.25; }
|
| 212 |
+
.stepper .bar{ flex:1; height:3px; min-width:22px; margin-top:7px; background:#CFE8D8;
|
| 213 |
+
border-radius:3px; transition:background .4s ease; }
|
| 214 |
.stepper .bar.fill{ background:var(--dr-grass); }
|
| 215 |
/* State 1 — non-Paris graph load (inert for the Paris demo, built per brief) */
|
| 216 |
+
#dr-mapping{ position:absolute; inset:46px 0 0 0; z-index:21; display:none; place-items:center;
|
| 217 |
background:#F6ECD9; }
|
| 218 |
#dr-mapping.on{ display:grid; }
|
| 219 |
#dr-mapping .pulse{ width:18px;height:18px;border-radius:50%;background:var(--dr-grass);
|
|
|
|
| 222 |
|
| 223 |
/* no-detour sticker slot */
|
| 224 |
#dr-nodetour:empty{ display:none; }
|
| 225 |
+
#dr-nodetour{ margin-bottom:14px; }
|
| 226 |
|
| 227 |
/* mobile FAB (hidden on desktop) */
|
| 228 |
.fab{ display:none; }
|
|
|
|
| 232 |
.app-shell{ grid-template-columns:1fr; grid-template-rows:auto 1fr; height:100vh; }
|
| 233 |
.hero{ max-height:none; }
|
| 234 |
.hero .hero-art{ display:none; }
|
| 235 |
+
.hero .titan-chip{ position:static; margin-top:8px; align-self:flex-start; }
|
| 236 |
.right-col{ grid-column:1; grid-row:2; padding:0; gap:0; }
|
| 237 |
.map-container{ min-height:0; }
|
| 238 |
#dr-map .map-inner, #dr-loading, #dr-mapping{ inset:0; }
|
| 239 |
.itinerary-panel{ max-height:34vh; padding:0 14px 14px; }
|
| 240 |
.route-summary-bar{ padding:0 12px; }
|
| 241 |
.left-panel{ position:fixed; left:0; right:0; bottom:0; top:auto; z-index:200; width:100%;
|
| 242 |
+
height:auto; max-height:84vh; border-right:none; border-top-left-radius:26px;
|
| 243 |
+
border-top-right-radius:26px; box-shadow:0 -18px 44px -20px rgba(43,38,32,.4);
|
| 244 |
+
transform:translateY(110%); transition:transform .32s var(--dr-spring);
|
| 245 |
+
padding-top:8px; }
|
| 246 |
.left-panel.open{ transform:translateY(0); }
|
| 247 |
.fab{ display:grid; place-items:center; position:fixed; right:18px; bottom:18px; z-index:210;
|
| 248 |
+
width:62px; height:62px; border-radius:50%; border:none; cursor:pointer;
|
| 249 |
+
background:var(--dr-coral); color:#fff; font-size:15px; font-family:'Fredoka',sans-serif;
|
| 250 |
+
font-weight:600; line-height:1.05; text-align:center;
|
| 251 |
+
box-shadow:0 12px 26px -8px rgba(255,106,82,.85); transition:transform .18s var(--dr-spring); }
|
| 252 |
+
.fab:hover{ transform:translateY(-3px); }
|
| 253 |
+
.drawer-close{ display:flex; }
|
| 254 |
}
|
| 255 |
+
.drawer-close{ display:none; align-items:center; justify-content:center; width:44px; height:44px;
|
| 256 |
+
margin:0 auto 4px; border-radius:50%; background:#FFFDF8; border:1.5px solid var(--dr-line);
|
| 257 |
+
font-size:22px; color:var(--dr-soft); cursor:pointer; }
|
| 258 |
+
.drawer-grip{ display:none; }
|
| 259 |
+
@media (max-width:768px){ .drawer-grip{ display:block; width:42px; height:5px; border-radius:999px;
|
| 260 |
+
background:var(--dr-line); margin:2px auto 8px; } }
|
| 261 |
|
| 262 |
@media (prefers-reduced-motion:reduce){ *{ animation:none !important; transition:none !important; } }
|
| 263 |
"""
|
|
|
|
| 284 |
"""
|
| 285 |
|
| 286 |
|
| 287 |
+
_VIBE_PRESETS = [
|
| 288 |
+
"quiet green wander",
|
| 289 |
+
"lively café crawl",
|
| 290 |
+
"bookshops & old churches",
|
| 291 |
+
"morning coffee crawl",
|
| 292 |
+
"riverside stroll",
|
| 293 |
+
"art & history",
|
| 294 |
+
]
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def _vibe_chips() -> str:
|
| 298 |
+
chips = "".join(
|
| 299 |
+
f'<button type="button" class="chip" data-vibe="{v.replace(chr(34), "")}">{v}</button>'
|
| 300 |
+
for v in _VIBE_PRESETS
|
| 301 |
+
)
|
| 302 |
+
return f'<div class="vibe-chips" id="dr-vibe-chips">{chips}</div>'
|
| 303 |
+
|
| 304 |
+
|
| 305 |
def _left_panel() -> str:
|
| 306 |
+
return f"""
|
| 307 |
<aside class="left-panel" id="left-panel">
|
| 308 |
+
<div class="drawer-grip"></div>
|
| 309 |
+
<div class="drawer-close" id="drawer-close" role="button" tabindex="0"
|
| 310 |
+
aria-label="Close controls">×</div>
|
| 311 |
+
|
| 312 |
+
<div class="panel-scroll">
|
| 313 |
+
<div class="vibe-block">
|
| 314 |
+
<div class="dr-control combo" style="margin-bottom:0;">
|
| 315 |
+
<label class="dr-label" for="dr-vibe">What's your vibe today?</label>
|
| 316 |
+
<input type="text" id="dr-vibe" class="dr-field"
|
| 317 |
+
placeholder="Describe the wander you're after…">
|
| 318 |
+
<div class="dr-help">Tell me the mood — I'll read it and pick places to match.</div>
|
| 319 |
+
</div>
|
| 320 |
+
{_vibe_chips()}
|
| 321 |
+
</div>
|
| 322 |
|
| 323 |
<div class="dr-control combo">
|
| 324 |
<label class="dr-label" for="dr-start">Start</label>
|
| 325 |
+
<div class="field-wrap" data-glyph="📍">
|
| 326 |
+
<input type="text" id="dr-start" class="dr-field" autocomplete="off"
|
| 327 |
+
value="Place de la République, Paris">
|
| 328 |
+
</div>
|
| 329 |
<div class="combo-list" id="dr-start-list"></div>
|
| 330 |
+
<div class="dr-help">Try a landmark or place name — not a street address.</div>
|
| 331 |
</div>
|
| 332 |
|
| 333 |
<div class="dr-control combo">
|
| 334 |
<label class="dr-label" for="dr-dest">Destination</label>
|
| 335 |
+
<div class="field-wrap" data-glyph="🏁">
|
| 336 |
+
<input type="text" id="dr-dest" class="dr-field" autocomplete="off"
|
| 337 |
+
value="Jardin du Luxembourg, Paris">
|
| 338 |
+
</div>
|
| 339 |
<div class="combo-list" id="dr-dest-list"></div>
|
| 340 |
</div>
|
| 341 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
<div class="dr-control">
|
| 343 |
<label class="dr-label">Mode</label>
|
| 344 |
<div id="dr-mode" class="dr-seg">
|
| 345 |
<div class="wrap">
|
| 346 |
+
<label class="selected" data-v="walk" role="button" tabindex="0">🚶 walk</label>
|
| 347 |
+
<label data-v="bike" role="button" tabindex="0">🚲 bike</label>
|
| 348 |
</div>
|
| 349 |
</div>
|
| 350 |
</div>
|
|
|
|
| 352 |
<div class="dr-control">
|
| 353 |
<label class="dr-label" for="dr-budget-i">Detour budget</label>
|
| 354 |
<div id="dr-budget" class="dr-slider">
|
| 355 |
+
<input type="range" id="dr-budget-i" min="0" max="2" step="0.1" value="0.5"
|
| 356 |
+
aria-label="Detour budget">
|
| 357 |
<output id="dr-budget-o">0.5</output>
|
| 358 |
</div>
|
| 359 |
+
<div class="slider-ends"><span>straight there</span><span>up to 2× longer</span></div>
|
| 360 |
</div>
|
| 361 |
|
| 362 |
<div class="dr-control">
|
| 363 |
<label class="dr-label" for="dr-adv-i">Adventurousness</label>
|
| 364 |
<div id="dr-adv" class="dr-slider">
|
| 365 |
+
<input type="range" id="dr-adv-i" min="0" max="1" step="0.05" value="0.3"
|
| 366 |
+
aria-label="Adventurousness">
|
| 367 |
<output id="dr-adv-o">0.3</output>
|
| 368 |
</div>
|
| 369 |
+
<div class="slider-ends"><span>well-known</span><span>hidden gems</span></div>
|
| 370 |
</div>
|
| 371 |
|
| 372 |
<details class="dr-collapse">
|
| 373 |
+
<summary>Manual taste <span class="opt-tag">optional</span></summary>
|
| 374 |
<div class="collapse-body">
|
| 375 |
+
<div class="dr-help" style="margin:0 0 10px;">Only used when Vibe and your saved
|
| 376 |
+
profile are both empty.</div>
|
| 377 |
<div class="dr-control">
|
| 378 |
<label class="dr-label" for="dr-green-i">Prefer green</label>
|
| 379 |
<div id="dr-green" class="dr-slider green">
|
| 380 |
+
<input type="range" id="dr-green-i" min="0" max="1" step="0.05" value="0.5"
|
| 381 |
+
aria-label="Prefer green">
|
| 382 |
<output id="dr-green-o">0.5</output>
|
| 383 |
</div>
|
| 384 |
</div>
|
| 385 |
+
<div class="dr-control" style="margin-bottom:0;">
|
| 386 |
<label class="dr-label" for="dr-quiet-i">Prefer quiet</label>
|
| 387 |
<div id="dr-quiet" class="dr-slider green">
|
| 388 |
+
<input type="range" id="dr-quiet-i" min="0" max="1" step="0.05" value="0.5"
|
| 389 |
+
aria-label="Prefer quiet">
|
| 390 |
<output id="dr-quiet-o">0.5</output>
|
| 391 |
</div>
|
| 392 |
</div>
|
| 393 |
</div>
|
| 394 |
</details>
|
| 395 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
<details class="dr-collapse">
|
| 397 |
+
<summary>⭐ My taste profile <span class="opt-tag">saved on this device</span></summary>
|
| 398 |
<div class="collapse-body">
|
| 399 |
<label class="dr-label" for="dr-profile-text">Standing preferences</label>
|
| 400 |
<input type="text" id="dr-profile-text" class="dr-field"
|
| 401 |
placeholder="e.g. 'I always love bookshops, gardens, and old churches'">
|
| 402 |
+
<div class="dr-row" style="margin-top:10px;">
|
| 403 |
<button type="button" id="dr-save">Save</button>
|
| 404 |
<button type="button" id="dr-clear">Clear</button>
|
| 405 |
</div>
|
|
|
|
| 407 |
<button type="button" class="dr-star" id="dr-save-places">⭐ Save this route's places</button>
|
| 408 |
</div>
|
| 409 |
</details>
|
| 410 |
+
</div>
|
| 411 |
+
|
| 412 |
+
<div class="dr-cta-wrap">
|
| 413 |
+
<div id="dr-plan"><button type="button" id="dr-plan-btn">Plan my route →</button></div>
|
| 414 |
+
<div class="cta-hint">Reads your vibe · threads a detour · writes the itinerary</div>
|
| 415 |
+
</div>
|
| 416 |
</aside>
|
| 417 |
"""
|
| 418 |
|
|
|
|
| 444 |
</div>
|
| 445 |
<div class="route-summary-bar"><div id="dr-summary"></div></div>
|
| 446 |
<div class="itinerary-panel">
|
| 447 |
+
<div id="dr-onboard" class="onboard">
|
| 448 |
+
<div class="ob-emoji">🥐</div>
|
| 449 |
+
<div>
|
| 450 |
+
<h3>Ready when you are.</h3>
|
| 451 |
+
<p>Pick a vibe (or tap a chip), keep or tweak the Start & Destination, then
|
| 452 |
+
hit <strong>Plan my route</strong>. I'll thread a detour past places that match —
|
| 453 |
+
and tell you why each one made the cut.</p>
|
| 454 |
+
</div>
|
| 455 |
+
</div>
|
| 456 |
<div id="dr-options"></div>
|
| 457 |
<div id="dr-nodetour"></div>
|
| 458 |
<div id="dr-interp"></div>
|
|
|
|
| 460 |
</div>
|
| 461 |
</main>
|
| 462 |
</div>
|
| 463 |
+
<button class="fab" id="fab" aria-label="Open route controls">Plan</button>
|
| 464 |
|
| 465 |
<script type="module">{_app_js()}</script>
|
| 466 |
</body>
|
|
|
|
| 479 |
f'<div class="lbl">{label}</div></div>'
|
| 480 |
)
|
| 481 |
stepper = '<div class="stepper">' + "".join(pips) + "</div>"
|
|
|
|
| 482 |
return f"""
|
| 483 |
+
<div style="text-align:center; max-width:540px; padding:0 16px;">
|
| 484 |
+
<div style="font-family:'Fredoka',system-ui,sans-serif;font-weight:600;font-size:21px;
|
| 485 |
color:#2B2620;">Scouting your wander…</div>
|
| 486 |
+
<div style="font-family:'DM Sans',system-ui,sans-serif;font-size:13px;color:#6B6256;
|
| 487 |
+
margin-top:6px;">reading your vibe · scoring 30,000 places · threading the detour</div>
|
| 488 |
{stepper}
|
| 489 |
</div>
|
|
|
|
|
|
|
|
|
|
| 490 |
"""
|
| 491 |
|
| 492 |
|
|
|
|
| 545 |
inp.addEventListener("input", () => out.textContent = (+inp.value).toFixed(2).replace(/\.?0+$/, "") || "0");
|
| 546 |
});
|
| 547 |
|
| 548 |
+
/* ---------- mode segmented toggle (mouse + keyboard) ---------- */
|
| 549 |
let mode = "walk";
|
| 550 |
+
function pickMode(l) {
|
| 551 |
+
document.querySelectorAll("#dr-mode label").forEach(x => {
|
| 552 |
+
x.classList.remove("selected"); x.setAttribute("aria-pressed", "false");
|
| 553 |
+
});
|
| 554 |
+
l.classList.add("selected"); l.setAttribute("aria-pressed", "true"); mode = l.dataset.v;
|
| 555 |
+
}
|
| 556 |
document.querySelectorAll("#dr-mode label").forEach(l => {
|
| 557 |
+
l.addEventListener("click", () => pickMode(l));
|
| 558 |
+
l.addEventListener("keydown", (e) => {
|
| 559 |
+
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); pickMode(l); }
|
| 560 |
});
|
| 561 |
});
|
| 562 |
|
| 563 |
+
/* ---------- vibe preset chips (fill the field on click) ---------- */
|
| 564 |
+
const vibeInput = $("dr-vibe");
|
| 565 |
+
function syncVibeChips() {
|
| 566 |
+
const v = (vibeInput.value || "").trim().toLowerCase();
|
| 567 |
+
document.querySelectorAll("#dr-vibe-chips .chip").forEach(c =>
|
| 568 |
+
c.classList.toggle("on", (c.dataset.vibe || "").toLowerCase() === v));
|
| 569 |
+
}
|
| 570 |
+
document.querySelectorAll("#dr-vibe-chips .chip").forEach(c => {
|
| 571 |
+
c.addEventListener("click", () => {
|
| 572 |
+
vibeInput.value = c.dataset.vibe || "";
|
| 573 |
+
syncVibeChips();
|
| 574 |
+
vibeInput.focus();
|
| 575 |
+
});
|
| 576 |
+
});
|
| 577 |
+
vibeInput.addEventListener("input", syncVibeChips);
|
| 578 |
+
|
| 579 |
/* ---------- autocomplete combobox (replaces gr.Dropdown) ---------- */
|
| 580 |
function wireCombo(inputId, listId) {
|
| 581 |
const input = $(inputId), list = $(listId);
|
|
|
|
| 615 |
/* ---------- 4-step loader (paced to the pipeline phases) ---------- */
|
| 616 |
let stepTimer = null;
|
| 617 |
function setStep(n) {
|
| 618 |
+
document.querySelectorAll(".stepper .step").forEach(s => {
|
| 619 |
+
const i = +s.dataset.step;
|
| 620 |
+
s.classList.toggle("active", i <= n);
|
| 621 |
+
s.classList.toggle("current", i === n);
|
| 622 |
+
});
|
| 623 |
document.querySelectorAll(".stepper .bar").forEach(b =>
|
| 624 |
b.classList.toggle("fill", +b.dataset.bar <= n));
|
| 625 |
}
|
| 626 |
+
function hideOnboard() { const o = $("dr-onboard"); if (o) o.style.display = "none"; }
|
| 627 |
function startLoading() {
|
| 628 |
+
hideOnboard();
|
| 629 |
$("dr-loading").classList.add("on");
|
| 630 |
$("dr-summary").innerHTML = ""; $("dr-itin").innerHTML = "";
|
| 631 |
$("dr-interp").innerHTML = ""; $("dr-options").innerHTML = ""; $("dr-nodetour").innerHTML = "";
|
|
|
|
| 635 |
function stopLoading() { clearInterval(stepTimer); setStep(3); $("dr-loading").classList.remove("on"); }
|
| 636 |
|
| 637 |
/* ---------- render ---------- */
|
| 638 |
+
const REDUCE = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
| 639 |
let lastAlts = [], lastCats = [];
|
| 640 |
function renderMap(html) { $("dr-map").innerHTML = '<div class="map-inner">' + html + "</div>"; }
|
| 641 |
+
function enter(el) {
|
| 642 |
+
if (!el || REDUCE) return;
|
| 643 |
+
try { el.animate([{ transform: "translateY(10px)", opacity: 0.001 }, { transform: "none", opacity: 1 }],
|
| 644 |
+
{ duration: 420, easing: "cubic-bezier(.34,1.56,.64,1)" }); } catch (e) {}
|
| 645 |
+
}
|
| 646 |
+
// Organic squash-and-pop on tap/click — springy overshoot, settles back.
|
| 647 |
+
function bounce(el) {
|
| 648 |
+
if (!el || REDUCE) return;
|
| 649 |
+
try {
|
| 650 |
+
el.animate(
|
| 651 |
+
[{ transform: "scale(1)" }, { transform: "scale(.90)" },
|
| 652 |
+
{ transform: "scale(1.06)" }, { transform: "scale(.985)" }, { transform: "scale(1)" }],
|
| 653 |
+
{ duration: 440, easing: "cubic-bezier(.34,1.56,.64,1)" });
|
| 654 |
+
} catch (e) {}
|
| 655 |
+
}
|
| 656 |
function selectAlt(idx) {
|
| 657 |
const a = lastAlts[idx]; if (!a) return;
|
| 658 |
renderMap(a.map_html);
|
| 659 |
$("dr-summary").innerHTML = md(a.summary_md);
|
| 660 |
$("dr-itin").innerHTML = md(a.itinerary_md);
|
| 661 |
+
enter($("dr-summary")); enter($("dr-itin"));
|
| 662 |
document.querySelectorAll("#dr-options .opt").forEach((o, i) =>
|
| 663 |
o.classList.toggle("selected", i === idx));
|
| 664 |
}
|
| 665 |
function renderResult(d) {
|
| 666 |
+
hideOnboard();
|
| 667 |
if (d.error) { renderMap(d.map_html); toast("Hmm — " + d.error.split(".")[0] + "."); return; }
|
| 668 |
$("dr-interp").innerHTML = md(d.interpretation_md);
|
| 669 |
+
enter($("dr-interp"));
|
| 670 |
if (d.no_detour) {
|
| 671 |
renderMap(d.map_html);
|
| 672 |
$("dr-summary").innerHTML = md(d.summary_md);
|
|
|
|
| 729 |
});
|
| 730 |
|
| 731 |
/* ---------- mobile drawer ---------- */
|
| 732 |
+
const openDrawer = () => $("left-panel").classList.add("open");
|
| 733 |
+
const closeDrawer = () => $("left-panel").classList.remove("open");
|
| 734 |
+
$("fab").addEventListener("click", openDrawer);
|
| 735 |
+
$("drawer-close").addEventListener("click", closeDrawer);
|
| 736 |
+
|
| 737 |
+
/* ---------- organic bounce on interaction (delegated, capture phase) ---------- */
|
| 738 |
+
document.addEventListener("click", (e) => {
|
| 739 |
+
const el = e.target.closest(
|
| 740 |
+
"#dr-plan button, .vibe-chips .chip, #dr-mode label, #dr-options .opt, " +
|
| 741 |
+
".dr-row button, .dr-star, details.dr-collapse > summary, #fab, .drawer-close");
|
| 742 |
+
if (el) bounce(el);
|
| 743 |
+
}, true);
|
| 744 |
+
$("drawer-close").addEventListener("keydown", (e) => {
|
| 745 |
+
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); closeDrawer(); }
|
| 746 |
+
});
|
| 747 |
+
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeDrawer(); });
|
| 748 |
+
|
| 749 |
+
/* Enter in any text field plans the route (except while a suggestion list is open). */
|
| 750 |
+
["dr-vibe", "dr-start", "dr-dest"].forEach(id => {
|
| 751 |
+
const el = $(id); if (!el) return;
|
| 752 |
+
el.addEventListener("keydown", (e) => {
|
| 753 |
+
if (e.key !== "Enter") return;
|
| 754 |
+
const lists = document.querySelectorAll(".combo-list.open");
|
| 755 |
+
if (lists.length) return; // let the combo handle its own Enter
|
| 756 |
+
e.preventDefault(); plan();
|
| 757 |
+
});
|
| 758 |
+
});
|
| 759 |
|
| 760 |
+
syncVibeChips();
|
| 761 |
renderSaved();
|
| 762 |
"""
|