"""Custom app-shell frontend for the ``gradio.Server`` backend. Off-Brand: instead of Gradio's default column layout, the page is a hand-built HTML/CSS/JS app-shell — fixed left control panel · large map · green route-summary bar · scrollable itinerary · mobile bottom-drawer — served from ``app.get("/")`` and talking to ``@app.api`` endpoints through ``@gradio/client``. Visual identity is preserved by REUSE, not reimplementation: the existing :data:`design.DR_CSS` (tokens, sliders, segmented toggle, coral CTA, green summary banner, dashed-rail itinerary, framed map window) is injected verbatim and the same ``#dr-*`` element ids are kept, so every color/font/treatment carries over. Only the spatial layout (``APP_SHELL_CSS``) and the vanilla-JS interactivity (autocomplete, localStorage profile, sliders, the 4-step loader) are new. """ from __future__ import annotations from discoverroute import config from discoverroute.ui import design from discoverroute.ui import map as mapui _MONTHS = ["", "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] def _provenance_line() -> str: """Honest data-freshness + attribution line for the results sheet footer.""" when = "" d = config.DATA_BUILD_DATE if d: try: y, m, _ = d.split("-") when = f" · snapshot {_MONTHS[int(m)]} {y}" except Exception: # noqa: BLE001 when = f" · snapshot {d}" return (f"Places from OpenStreetMap{when} · open/close times are best-effort " f"(often unlisted) · © OpenStreetMap contributors (ODbL)") # --------------------------------------------------------------- app-shell CSS APP_SHELL_CSS = """ *{ box-sizing:border-box; } html,body{ margin:0; height:100%; } body{ font-family:'DM Sans',ui-sans-serif,system-ui,sans-serif; color:var(--dr-ink); background:radial-gradient(1100px 520px at 88% -8%,#FBEFD6 0%,transparent 60%),var(--dr-cream); } .app-shell{ display:grid; grid-template-columns:340px 1fr; grid-template-rows:1fr; height:100vh; height:100dvh; width:100%; background:var(--dr-cream); } /* ---- brand header (top of the left panel — replaces the old blue ribbon) ---- */ .brand{ display:flex; align-items:center; gap:10px; padding:0 0 14px; margin-bottom:6px; border-bottom:1px solid var(--dr-line); } .brand .logo{ width:34px; height:34px; border-radius:11px; flex-shrink:0; display:grid; place-items:center; font-size:18px; background:linear-gradient(135deg,#2F5DF4,#5C7DF8); box-shadow:0 6px 14px -6px rgba(47,93,244,.7); } .brand .bname{ font-family:'Fredoka',sans-serif; font-weight:700; font-size:19px; letter-spacing:-.01em; color:var(--dr-ink); line-height:1; } .brand .titan-chip{ margin-left:auto; display:inline-flex; align-items:center; gap:6px; background:#EAF6EF; color:var(--dr-grass-d); border-radius:999px; padding:4px 10px; font-size:10px; font-family:'Fredoka',sans-serif; font-weight:600; white-space:nowrap; } .brand .titan-chip::before{ content:''; width:6px; height:6px; border-radius:50%; background:var(--dr-grass); box-shadow:0 0 6px var(--dr-grass); } /* ---- left control panel (340px, scrollable, sticky CTA) ---- */ .left-panel{ grid-column:1; grid-row:1; width:340px; height:100%; overflow:hidden; border-right:1px solid var(--dr-line); background:linear-gradient(180deg,#FBF3E2,var(--dr-cream) 140px); display:flex; flex-direction:column; } .panel-scroll{ flex:1 1 auto; min-height:0; overflow-y:auto; overflow-x:hidden; padding:20px 18px 10px; scrollbar-width:thin; scrollbar-color:var(--dr-line) transparent; } .panel-scroll::-webkit-scrollbar{ width:9px; } .panel-scroll::-webkit-scrollbar-thumb{ background:var(--dr-line); border-radius:9px; border:3px solid transparent; background-clip:content-box; } .dr-control{ margin-bottom:15px; } .dr-label{ display:block; font-family:'Fredoka',sans-serif; font-weight:600; font-size:13.5px; color:var(--dr-ink); margin-bottom:6px; } .dr-label .opt-tag{ font-family:'DM Sans',sans-serif; font-weight:500; font-size:10.5px; color:var(--dr-soft); text-transform:uppercase; letter-spacing:.05em; margin-left:6px; } .dr-help{ font-size:11.5px; color:var(--dr-soft); margin-top:4px; line-height:1.4; } .left-panel input[type=text]{ width:100%; padding:11px 13px; font-size:14px; } /* a leading pin/flag glyph for the start & destination fields */ .field-wrap{ position:relative; } .field-wrap > input[type=text]{ padding-left:34px; } /* city picker
Tell me the mood — I'll read it and pick places to match.
{_vibe_chips()}
Pick the city to explore — Start & Destination should be places within it.
Try a landmark (e.g. "British Museum"), not a street address.
direct 2× longer 0.5
classic hidden gems 0.3
Manual taste optional
Only used when Vibe and your saved profile are both empty.
0.5
0.5
⭐ My taste profile saved on this device
Reads your vibe · threads a detour · writes the itinerary
""" def index_html() -> str: """Assemble the full single-page app served at ``/``.""" empty = mapui.empty_map() return f""" DiscoverRoute · Paris {design.DR_HEAD}
{_left_panel()}
{empty}
Mapping the streets…
{_loading_inner()}
""" _TEASER_PATH = "M26,120 C66,112 70,66 116,70 C150,73 168,44 206,58 C236,69 246,40 262,34" def _loading_inner() -> str: """Live-map teaser: a little route threads itself between popping pins while we plan — a playful preview of the real map so the wait feels like progress.""" return f"""
Scouting your wander…
reading your vibe · scoring 30,000 places · threading the detour
""" def _app_js() -> str: return r""" import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; const $ = (id) => document.getElementById(id); const md = (s) => (window.marked ? window.marked.parse(s || "") : (s || "")); let appClient = null; async function client() { if (!appClient) appClient = await Client.connect(window.location.origin); return appClient; } /* ---------- taste profile via localStorage (replaces gr.BrowserState) ---------- */ const PKEY = "discoverroute_profile"; function readProfile() { try { return JSON.parse(localStorage.getItem(PKEY)) || { standing_text: "", saved_categories: [] }; } catch (e) { return { standing_text: "", saved_categories: [] }; } } function writeProfile(p) { localStorage.setItem(PKEY, JSON.stringify(p)); renderSaved(); } function renderSaved() { const p = readProfile(); const cats = p.saved_categories || []; let txt = "_No saved places yet. Plan a route, then ⭐ save its places._"; if (cats.length) { const counts = {}; cats.forEach(c => counts[c] = (counts[c] || 0) + 1); txt = "**Saved taste:** " + Object.entries(counts) .sort((a, b) => b[1] - a[1]) .map(([c, n]) => `${c.replace(/_/g, " ")} ×${n}`).join(", "); } $("dr-saved").innerHTML = md(txt); if ($("dr-profile-text") && !$("dr-profile-text").value) $("dr-profile-text").value = p.standing_text || ""; } /* ---------- toast ---------- */ function toast(msg) { const t = document.createElement("div"); t.textContent = msg; t.style.cssText = "position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:999;" + "background:#2B2620;color:#fff;padding:11px 18px;border-radius:14px;font-size:13.5px;" + "box-shadow:0 12px 30px -12px rgba(0,0,0,.5);font-family:'DM Sans',sans-serif;"; document.body.appendChild(t); setTimeout(() => t.remove(), 2600); } /* ---------- sliders ---------- */ [["dr-budget-i", "dr-budget-o"], ["dr-adv-i", "dr-adv-o"], ["dr-green-i", "dr-green-o"], ["dr-quiet-i", "dr-quiet-o"]].forEach(([i, o]) => { const inp = $(i); if (!inp) return; const out = $(o); inp.addEventListener("input", () => out.textContent = (+inp.value).toFixed(2).replace(/\.?0+$/, "") || "0"); }); /* ---------- mode segmented toggle (mouse + keyboard) ---------- */ let mode = "walk"; function pickMode(l) { document.querySelectorAll("#dr-mode label").forEach(x => { x.classList.remove("selected"); x.setAttribute("aria-pressed", "false"); }); l.classList.add("selected"); l.setAttribute("aria-pressed", "true"); mode = l.dataset.v; } document.querySelectorAll("#dr-mode label").forEach(l => { l.addEventListener("click", () => pickMode(l)); l.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); pickMode(l); } }); }); /* ---------- vibe preset chips (fill the field on click) ---------- */ const vibeInput = $("dr-vibe"); function syncVibeChips() { const v = (vibeInput.value || "").trim().toLowerCase(); document.querySelectorAll("#dr-vibe-chips .chip").forEach(c => c.classList.toggle("on", (c.dataset.vibe || "").toLowerCase() === v)); } document.querySelectorAll("#dr-vibe-chips .chip").forEach(c => { c.addEventListener("click", () => { vibeInput.value = c.dataset.vibe || ""; syncVibeChips(); vibeInput.focus(); }); }); vibeInput.addEventListener("input", syncVibeChips); /* ---------- autocomplete combobox (replaces gr.Dropdown) ---------- */ function wireCombo(inputId, listId) { const input = $(inputId), list = $(listId); let timer = null, items = [], active = -1; const close = () => { list.classList.remove("open"); active = -1; }; input.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(async () => { try { const c = await client(); const r = await c.predict("/suggest", { query: input.value }); items = (r.data && r.data[0]) || []; if (!items.length) return close(); list.innerHTML = items.map((s, i) => `
${s.replace(/`).join(""); list.querySelectorAll("div").forEach(d => d.addEventListener("mousedown", (e) => { e.preventDefault(); input.value = items[+d.dataset.i]; close(); })); list.classList.add("open"); } catch (e) { /* suggestions are best-effort */ } }, 160); }); input.addEventListener("keydown", (e) => { if (!list.classList.contains("open")) return; const els = list.querySelectorAll("div"); if (e.key === "ArrowDown") { active = Math.min(active + 1, els.length - 1); e.preventDefault(); } else if (e.key === "ArrowUp") { active = Math.max(active - 1, 0); e.preventDefault(); } else if (e.key === "Enter" && active >= 0) { input.value = items[active]; close(); e.preventDefault(); return; } else if (e.key === "Escape") { return close(); } els.forEach((d, i) => d.classList.toggle("active", i === active)); }); input.addEventListener("blur", () => setTimeout(close, 120)); } wireCombo("dr-start", "dr-start-list"); wireCombo("dr-dest", "dr-dest-list"); /* ---------- loader (live-map teaser; CSS-animated while visible) ---------- */ function hideOnboard() { const o = $("dr-onboard"); if (o) o.style.display = "none"; } function startLoading() { hideOnboard(); hideSheet(); $("dr-loading").classList.add("on"); $("dr-summary").innerHTML = ""; $("dr-itin").innerHTML = ""; $("dr-interp").innerHTML = ""; $("dr-options").innerHTML = ""; $("dr-nodetour").innerHTML = ""; } function stopLoading() { $("dr-loading").classList.remove("on"); } /* ---------- render ---------- */ const REDUCE = window.matchMedia("(prefers-reduced-motion: reduce)").matches; let lastAlts = [], lastCats = [], geo = null, curExport = null; function renderMap(html) { $("dr-map").innerHTML = '
' + html + "
"; } /* ---------- export to maps (client-side; data already in the /plan payload) ---------- */ function _ll(p) { return (+p[0]).toFixed(6) + "," + (+p[1]).toFixed(6); } function buildGmaps(start, end, wps, mode) { const travel = mode === "bike" ? "bicycling" : "walking"; let u = "https://www.google.com/maps/dir/?api=1&origin=" + _ll(start) + "&destination=" + _ll(end) + "&travelmode=" + travel; const w = (wps || []).slice(0, 9).map(p => p.lat.toFixed(6) + "," + p.lon.toFixed(6)); if (w.length) u += "&waypoints=" + encodeURIComponent(w.join("|")); return u; } function buildApple(start, end) { return "https://maps.apple.com/?saddr=" + _ll(start) + "&daddr=" + _ll(end) + "&dirflg=w"; } function _xml(s) { return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function buildGpx(name, start, end, wps, coords) { const L = ['', '', "" + _xml(name) + ""]; if (start) L.push('Start'); (wps || []).forEach(p => L.push('' + _xml(p.name) + "")); if (end) L.push('Destination'); L.push("" + _xml(name) + ""); (coords || []).forEach(c => L.push('')); L.push(""); return L.join("\n"); } function refreshExport() { const row = $("export-row"); if (!row) return; if (!geo || !geo.start || !geo.end || !curExport) { row.hidden = true; return; } row.hidden = false; const wps = curExport.waypoints || []; $("ex-gmaps").href = buildGmaps(geo.start, geo.end, wps, geo.mode); $("ex-apple").href = buildApple(geo.start, geo.end); $("ex-note").textContent = (wps.length > 9 ? "Google Maps fits 9 stops — the GPX keeps all " + wps.length + ". " : "") + "Apple Maps shows start → end only; the GPX keeps every stop."; } function downloadGpx() { if (!geo || !curExport) return; const gpx = buildGpx("WanderLust route", geo.start, geo.end, curExport.waypoints, curExport.coords); const blob = new Blob([gpx], { type: "application/gpx+xml" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "wanderlust-route.gpx"; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 1500); } function enter(el) { if (!el || REDUCE) return; try { el.animate([{ transform: "translateY(10px)", opacity: 0.001 }, { transform: "none", opacity: 1 }], { duration: 420, easing: "cubic-bezier(.34,1.56,.64,1)" }); } catch (e) {} } // Organic squash-and-pop on tap/click — springy overshoot, settles back. function bounce(el) { if (!el || REDUCE) return; try { el.animate( [{ transform: "scale(1)" }, { transform: "scale(.90)" }, { transform: "scale(1.06)" }, { transform: "scale(.985)" }, { transform: "scale(1)" }], { duration: 440, easing: "cubic-bezier(.34,1.56,.64,1)" }); } catch (e) {} } function selectAlt(idx) { const a = lastAlts[idx]; if (!a) return; renderMap(a.map_html); $("dr-summary").innerHTML = md(a.summary_md); $("dr-itin").innerHTML = md(a.itinerary_md); enter($("dr-summary")); enter($("dr-itin")); document.querySelectorAll("#dr-options .opt").forEach((o, i) => o.classList.toggle("selected", i === idx)); curExport = a.export || null; refreshExport(); } function renderResult(d) { hideOnboard(); if (d.error) { hideSheet(); renderMap(d.map_html); toast(d.error); return; } geo = { start: d.start, end: d.end, mode: d.mode, start_label: d.start_label, end_label: d.end_label }; $("dr-interp").innerHTML = md(d.interpretation_md); enter($("dr-interp")); if (d.no_detour) { renderMap(d.map_html); $("dr-summary").innerHTML = md(d.summary_md); $("dr-itin").innerHTML = md(d.itinerary_md); $("dr-nodetour").innerHTML = d.nodetour_html || ""; lastAlts = []; lastCats = []; curExport = d.export || null; refreshExport(); showSheet(); return; } $("dr-nodetour").innerHTML = ""; lastAlts = d.alternatives || []; lastCats = d.last_cats || []; if (lastAlts.length > 1) { $("dr-options").innerHTML = lastAlts.map((a, i) => `
${a.label}
`).join(""); document.querySelectorAll("#dr-options .opt").forEach(o => o.addEventListener("click", () => selectAlt(+o.dataset.i))); } else { $("dr-options").innerHTML = ""; } selectAlt(0); showSheet(); } /* ---------- plan ---------- */ async function plan() { closeDrawer(); // mobile: reveal the full-screen map + route // map-press bounce (reused micro-interaction) const mapEl = $("dr-map"); if (mapEl) mapEl.animate( [{ transform: "scale(1)" }, { transform: "scale(.99)" }, { transform: "scale(1)" }], { duration: 260, easing: "cubic-bezier(.34,1.56,.64,1)" }); startLoading(); try { const c = await client(); const r = await c.predict("/plan", { start: $("dr-start").value, dest: $("dr-dest").value, mode, budget: +$("dr-budget-i").value, vibe: $("dr-vibe").value, adventurousness: +$("dr-adv-i").value, prefer_green: +$("dr-green-i").value, prefer_quiet: +$("dr-quiet-i").value, profile: JSON.stringify(readProfile()), city: $("dr-city").value, }); renderResult((r.data && r.data[0]) || {}); } catch (e) { toast("Something went wrong planning the route."); console.error(e); } finally { stopLoading(); } } $("dr-plan-btn").addEventListener("click", plan); /* ---------- profile buttons ---------- */ $("dr-save").addEventListener("click", () => { const p = readProfile(); p.standing_text = $("dr-profile-text").value || ""; writeProfile(p); toast("Saved your standing preferences ✨"); }); $("dr-clear").addEventListener("click", () => { writeProfile({ standing_text: "", saved_categories: [] }); $("dr-profile-text").value = ""; toast("Profile cleared 🧹"); }); $("dr-save-places").addEventListener("click", () => { if (!lastCats.length) return toast("Plan a route first — then I can save its places."); const p = readProfile(); p.saved_categories = (p.saved_categories || []).concat(lastCats); writeProfile(p); toast("Saved this route's places to your taste ✨"); }); /* ---------- mobile drawer ---------- */ const openDrawer = () => $("left-panel").classList.add("open"); const closeDrawer = () => $("left-panel").classList.remove("open"); $("fab").addEventListener("click", openDrawer); $("drawer-close").addEventListener("click", closeDrawer); /* Keep the bottom drawer usable when the on-screen keyboard opens. On phones the keyboard shrinks the *visual* viewport (and on iOS overlays a fixed element) so a dvh-sized drawer gets crushed or hidden. We size the drawer to the visible area above the keyboard and scroll the focused field into view. */ (function () { const vv = window.visualViewport; if (!vv) return; const panel = $("left-panel"); const fit = () => { if (window.innerWidth > 768 || !panel.classList.contains("open")) { panel.style.maxHeight = ""; return; // desktop / closed: CSS rules } panel.style.maxHeight = Math.round(vv.height * 0.94) + "px"; }; vv.addEventListener("resize", fit); vv.addEventListener("scroll", fit); document.addEventListener("focusin", (e) => { if (window.innerWidth > 768) return; const field = e.target.closest && e.target.closest(".left-panel input, .left-panel textarea"); if (!field) return; openDrawer(); fit(); // let the keyboard animate in, then center the field within the scroll area setTimeout(() => field.scrollIntoView({ block: "center", behavior: "smooth" }), 280); }); document.addEventListener("focusout", () => { if (window.innerWidth <= 768) setTimeout(fit, 100); }); })(); /* ---------- floating results sheet (collapsible overlay) ---------- */ function showSheet() { const s = $("results-sheet"); if (s) { s.hidden = false; s.classList.remove("collapsed"); } } function hideSheet() { const s = $("results-sheet"); if (s) s.hidden = true; } $("sheet-head").addEventListener("click", () => $("results-sheet").classList.toggle("collapsed")); $("ex-gpx").addEventListener("click", downloadGpx); /* ---------- organic bounce on interaction (delegated, capture phase) ---------- */ document.addEventListener("click", (e) => { const el = e.target.closest( "#dr-plan button, .vibe-chips .chip, #dr-mode label, #dr-options .opt, " + ".dr-row button, .dr-star, details.dr-collapse > summary, #fab, .drawer-close"); if (el) bounce(el); }, true); $("drawer-close").addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); closeDrawer(); } }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeDrawer(); }); /* Enter in any text field plans the route (except while a suggestion list is open). */ ["dr-vibe", "dr-start", "dr-dest"].forEach(id => { const el = $(id); if (!el) return; el.addEventListener("keydown", (e) => { if (e.key !== "Enter") return; const lists = document.querySelectorAll(".combo-list.open"); if (lists.length) return; // let the combo handle its own Enter e.preventDefault(); plan(); }); }); syncVibeChips(); renderSaved(); """