Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- app.py +50 -3
- index.html +250 -94
- matchday/render.py +252 -127
app.py
CHANGED
|
@@ -19,6 +19,7 @@ import json
|
|
| 19 |
import logging
|
| 20 |
import os
|
| 21 |
import sys
|
|
|
|
| 22 |
from datetime import date
|
| 23 |
from pathlib import Path
|
| 24 |
|
|
@@ -75,6 +76,27 @@ def _ev(**payload) -> str:
|
|
| 75 |
return json.dumps(payload, ensure_ascii=False)
|
| 76 |
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
def _args_to_trip(a: BuildTripPackagesArgs) -> TripRequest:
|
| 79 |
return TripRequest(
|
| 80 |
origin_airport=a.origin_airport,
|
|
@@ -139,8 +161,15 @@ async def plan_trip(user_text: str) -> str:
|
|
| 139 |
r1: dict = {"tool_calls": []}
|
| 140 |
if agent is not None:
|
| 141 |
yield _ev(type="commentary", text="🤖 Nemotron is choosing your best options…")
|
|
|
|
| 142 |
try:
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
except Exception as exc:
|
| 145 |
logger.warning("agent round 1 failed (%s).", exc)
|
| 146 |
r1 = {"tool_calls": []}
|
|
@@ -178,8 +207,15 @@ async def plan_trip(user_text: str) -> str:
|
|
| 178 |
type="commentary",
|
| 179 |
text="✈️ Scanning airlines · 🏨 Finding hotels near BC Place · 🌤️ Checking the match-day forecast…",
|
| 180 |
)
|
|
|
|
| 181 |
try:
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
except Exception as exc:
|
| 184 |
yield _ev(type="error", text=f"⚠️ {exc}")
|
| 185 |
return
|
|
@@ -187,7 +223,18 @@ async def plan_trip(user_text: str) -> str:
|
|
| 187 |
yield _ev(type="commentary", text="🗺️ Scoring 3 packages — Nemotron is writing your comparison…")
|
| 188 |
explanation = ""
|
| 189 |
if agent is not None:
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
# Final: the full Layla-competitive render (status + cards + map + timeline).
|
| 193 |
# leaflet_preloaded=True → the frontend already loaded Leaflet in <head>; the
|
|
|
|
| 19 |
import logging
|
| 20 |
import os
|
| 21 |
import sys
|
| 22 |
+
import time
|
| 23 |
from datetime import date
|
| 24 |
from pathlib import Path
|
| 25 |
|
|
|
|
| 76 |
return json.dumps(payload, ensure_ascii=False)
|
| 77 |
|
| 78 |
|
| 79 |
+
async def _pulse(coro, holder, message, interval: int = 9):
|
| 80 |
+
"""Run ``coro`` to completion, yielding a commentary heartbeat every
|
| 81 |
+
``interval`` seconds (carrying elapsed seconds) so the SSE stream is never
|
| 82 |
+
silent during a long Modal cold-start or SerpApi phase. Stashes the result
|
| 83 |
+
in ``holder['result']``; re-raises if ``coro`` raised. Usage::
|
| 84 |
+
|
| 85 |
+
h = {}
|
| 86 |
+
async for beat in _pulse(coro, h, msg):
|
| 87 |
+
yield beat
|
| 88 |
+
value = h["result"]
|
| 89 |
+
"""
|
| 90 |
+
task = asyncio.ensure_future(coro)
|
| 91 |
+
start = time.monotonic()
|
| 92 |
+
while True:
|
| 93 |
+
done, _ = await asyncio.wait({task}, timeout=interval)
|
| 94 |
+
if task in done:
|
| 95 |
+
holder["result"] = task.result()
|
| 96 |
+
return
|
| 97 |
+
yield _ev(type="commentary", text=f"{message} ({int(time.monotonic() - start)}s)")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
def _args_to_trip(a: BuildTripPackagesArgs) -> TripRequest:
|
| 101 |
return TripRequest(
|
| 102 |
origin_airport=a.origin_airport,
|
|
|
|
| 161 |
r1: dict = {"tool_calls": []}
|
| 162 |
if agent is not None:
|
| 163 |
yield _ev(type="commentary", text="🤖 Nemotron is choosing your best options…")
|
| 164 |
+
h1 = {}
|
| 165 |
try:
|
| 166 |
+
async for beat in _pulse(
|
| 167 |
+
agent.run([{"role": "user", "content": user_text}]),
|
| 168 |
+
h1,
|
| 169 |
+
"🤖 Nemotron is choosing your best options…",
|
| 170 |
+
):
|
| 171 |
+
yield beat
|
| 172 |
+
r1 = h1["result"]
|
| 173 |
except Exception as exc:
|
| 174 |
logger.warning("agent round 1 failed (%s).", exc)
|
| 175 |
r1 = {"tool_calls": []}
|
|
|
|
| 207 |
type="commentary",
|
| 208 |
text="✈️ Scanning airlines · 🏨 Finding hotels near BC Place · 🌤️ Checking the match-day forecast…",
|
| 209 |
)
|
| 210 |
+
hb = {}
|
| 211 |
try:
|
| 212 |
+
async for beat in _pulse(
|
| 213 |
+
build_trip_packages(trip),
|
| 214 |
+
hb,
|
| 215 |
+
"✈️ Scanning airlines · 🏨 hotels near BC Place · 🌤️ weather",
|
| 216 |
+
):
|
| 217 |
+
yield beat
|
| 218 |
+
result = hb["result"]
|
| 219 |
except Exception as exc:
|
| 220 |
yield _ev(type="error", text=f"⚠️ {exc}")
|
| 221 |
return
|
|
|
|
| 223 |
yield _ev(type="commentary", text="🗺️ Scoring 3 packages — Nemotron is writing your comparison…")
|
| 224 |
explanation = ""
|
| 225 |
if agent is not None:
|
| 226 |
+
he = {}
|
| 227 |
+
try:
|
| 228 |
+
async for beat in _pulse(
|
| 229 |
+
_agent_explain(agent, user_text, trip, result),
|
| 230 |
+
he,
|
| 231 |
+
"🗺️ Scoring 3 packages — Nemotron is writing your comparison…",
|
| 232 |
+
):
|
| 233 |
+
yield beat
|
| 234 |
+
explanation = he["result"]
|
| 235 |
+
except Exception as exc:
|
| 236 |
+
logger.warning("explanation round failed (%s).", exc)
|
| 237 |
+
explanation = ""
|
| 238 |
|
| 239 |
# Final: the full Layla-competitive render (status + cards + map + timeline).
|
| 240 |
# leaflet_preloaded=True → the frontend already loaded Leaflet in <head>; the
|
index.html
CHANGED
|
@@ -8,116 +8,174 @@
|
|
| 8 |
<!-- Leaflet preloaded in <head> so the injected map's inline init runs instantly (N1). -->
|
| 9 |
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
| 10 |
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
<style>
|
| 13 |
:root{
|
| 14 |
-
--violet:#7c3aed; --violet-d:#6d28d9; --
|
| 15 |
-
--
|
| 16 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
}
|
| 18 |
*{box-sizing:border-box;}
|
| 19 |
html,body{margin:0;height:100%;}
|
| 20 |
body{
|
| 21 |
font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
| 22 |
-
color:var(--ink); background:
|
|
|
|
| 23 |
}
|
| 24 |
-
::-webkit-scrollbar{width:
|
| 25 |
-
::-webkit-scrollbar-thumb{background:#d1d5db;border-radius:
|
|
|
|
| 26 |
|
| 27 |
/* ── Top bar ─────────────────────────────────────────── */
|
| 28 |
.topbar{
|
| 29 |
-
display:flex;align-items:center;gap:14px;padding:
|
| 30 |
-
|
| 31 |
-
|
| 32 |
}
|
| 33 |
-
.topbar .logo{font-size:21px;font-weight:800;letter-spacing:-.
|
| 34 |
-
.topbar .
|
|
|
|
|
|
|
| 35 |
.topbar .sp{flex:1;}
|
| 36 |
-
.topbar .pill{font-size:
|
| 37 |
-
padding:
|
|
|
|
| 38 |
.topbar .pill b{color:#c4b5fd;}
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
/* ── 3-column layout ─────────────────────────────────── */
|
| 41 |
-
.app{display:grid;grid-template-columns:minmax(
|
| 42 |
-
height:calc(100vh -
|
| 43 |
.col{display:flex;flex-direction:column;min-height:0;min-width:0;}
|
| 44 |
-
.chat{border-right:1px solid var(--line);}
|
| 45 |
.sidebar{border-left:1px solid var(--line);background:var(--panel);overflow:auto;}
|
| 46 |
|
| 47 |
/* ── Chat column ─────────────────────────────────────── */
|
| 48 |
-
#chat{flex:1;overflow-y:auto;padding:
|
| 49 |
-
.hero{background:var(--
|
| 50 |
-
|
| 51 |
-
.hero
|
| 52 |
-
.
|
|
|
|
|
|
|
| 53 |
.msg.user{align-self:flex-end;flex-direction:row-reverse;}
|
| 54 |
-
.a-avatar{width:
|
| 55 |
-
display:flex;align-items:center;justify-content:center;font-size:15px;flex:0 0
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
| 59 |
.a-content{font-size:14px;line-height:1.5;}
|
| 60 |
.a-beat{display:flex;align-items:center;gap:9px;font-size:13.5px;color:var(--ink2);
|
| 61 |
-
font-weight:
|
| 62 |
-
.spin{display:inline-block;animation:spin 1s linear infinite;color:var(--violet);}
|
| 63 |
@keyframes spin{to{transform:rotate(360deg);}}
|
| 64 |
@keyframes fade{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:none;}}
|
|
|
|
|
|
|
| 65 |
|
| 66 |
-
.green{background:#
|
| 67 |
-
padding:10px 13px;font-size:14px;font-weight:
|
| 68 |
-
.clarify{background:#eff6ff;border:1px solid #bfdbfe;color:#1e3a8a;border-radius:12px;padding:12px 14px;font-size:14.5px;}
|
| 69 |
-
.err{background:#fef2f2;border:1px solid #fecaca;color:#991b1b;border-radius:12px;padding:11px 13px;font-size:14px;}
|
| 70 |
-
.
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
/* input row */
|
| 74 |
-
.composer{border-top:1px solid var(--line);padding:12px 14px;background:
|
| 75 |
-
.composer textarea{width:100%;border:1px solid #
|
| 76 |
-
font:inherit;font-size:14px;resize:none;outline:none;min-height:
|
| 77 |
-
|
| 78 |
-
.composer
|
| 79 |
-
.
|
| 80 |
-
.
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
/* ── Result column ───────────────────────────────────── */
|
| 90 |
-
.result{overflow-y:auto;}
|
| 91 |
-
#result{padding:
|
| 92 |
.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;
|
| 93 |
-
height:100%;text-align:center;color:var(--mut2);padding:30px;}
|
| 94 |
-
.empty .big{font-size:
|
| 95 |
-
.empty h3{margin:0 0
|
| 96 |
-
.empty p{margin:0;font-size:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
/* ── Sidebar ─────────────────────────────────────────── */
|
| 99 |
-
.sidebar .sec{padding:16px 16px
|
| 100 |
-
.sidebar h4{margin:0 0
|
| 101 |
.brain{display:flex;gap:10px;align-items:flex-start;}
|
| 102 |
-
.brain .b{font-size:20px;}
|
| 103 |
-
.brain .t{font-size:12.5px;line-height:1.
|
| 104 |
.brain .t b{color:var(--violet);}
|
| 105 |
-
.legend-row{display:flex;align-items:center;gap:
|
| 106 |
-
.dot{width:9px;height:9px;border-radius:50%;display:inline-block;}
|
| 107 |
-
.dot.live{background:var(--emerald);}
|
| 108 |
.dot.fallback{background:var(--amber);}
|
| 109 |
.badges{display:flex;flex-wrap:wrap;gap:5px;}
|
| 110 |
-
.badge{font-size:10.5px;font-weight:
|
| 111 |
-
border-radius:
|
| 112 |
-
.fact{font-size:12.5px;color:var(--ink2);line-height:1.5;margin:
|
| 113 |
.fact b{color:var(--ink);}
|
| 114 |
|
| 115 |
/* full-screen map */
|
| 116 |
#matchday-map.fs{position:fixed!important;inset:0!important;z-index:99998!important;
|
| 117 |
height:100vh!important;width:100vw!important;border-radius:0!important;}
|
| 118 |
#md-fs-close{position:fixed;top:16px;right:18px;z-index:99999;background:#fff;color:var(--ink);
|
| 119 |
-
font-weight:700;border:0;border-radius:
|
| 120 |
-
box-shadow:0
|
| 121 |
#md-fs-close.show{display:block;}
|
| 122 |
|
| 123 |
@media (max-width:980px){
|
|
@@ -130,10 +188,10 @@
|
|
| 130 |
</head>
|
| 131 |
<body>
|
| 132 |
<header class="topbar">
|
| 133 |
-
<div class="logo">⚽ MatchDay</div>
|
| 134 |
<div class="tag">Your AI trip planner for the <b>2026 FIFA World Cup · Vancouver</b></div>
|
| 135 |
<div class="sp"></div>
|
| 136 |
-
<span class="pill">Brain <b>Nemotron-3-Nano-30B</b> · Hands <b>Python</b> · <b>gradio.Server</b></span>
|
| 137 |
</header>
|
| 138 |
|
| 139 |
<div class="app">
|
|
@@ -157,7 +215,7 @@
|
|
| 157 |
aria-label="Describe your trip"></textarea>
|
| 158 |
<div class="row">
|
| 159 |
<button class="primary" id="send" onclick="runTrip()">🏈 Plan my trip</button>
|
| 160 |
-
<span class="hint">Enter to send ·
|
| 161 |
</div>
|
| 162 |
</div>
|
| 163 |
</section>
|
|
@@ -168,7 +226,7 @@
|
|
| 168 |
<div class="empty">
|
| 169 |
<div class="big">🏟️</div>
|
| 170 |
<h3>Your trip packages will appear here</h3>
|
| 171 |
-
<p>
|
| 172 |
</div>
|
| 173 |
</div>
|
| 174 |
</section>
|
|
@@ -181,7 +239,7 @@
|
|
| 181 |
<div class="b">🧠</div>
|
| 182 |
<div class="t"><b>Brain:</b> Nemotron-3-Nano-30B (3B-active MoE) on Modal A100. It chooses the tools and writes the comparisons. It <b>never</b> calls an API or names a price.</div>
|
| 183 |
</div>
|
| 184 |
-
<div class="brain" style="margin-top:
|
| 185 |
<div class="b">🤲</div>
|
| 186 |
<div class="t"><b>Hands:</b> deterministic Python calls SerpApi / Open-Meteo / OpenStreetMap, scores packages with a fixed formula, and tags every value with provenance.</div>
|
| 187 |
</div>
|
|
@@ -219,11 +277,25 @@ const EXAMPLES = [
|
|
| 219 |
"Take me from Ottawa to Vancouver for Canada vs Qatar on 2026-06-26, budget",
|
| 220 |
"From Halifax, Canada vs Morocco June 18, couple, luxury",
|
| 221 |
];
|
| 222 |
-
// Pre-fill the first example for instant demo.
|
| 223 |
document.getElementById("prompt").value = EXAMPLES[0];
|
| 224 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
let running = false;
|
| 226 |
-
let resultHandled = false;
|
|
|
|
|
|
|
|
|
|
| 227 |
const chatEl = document.getElementById("chat");
|
| 228 |
const resultEl = document.getElementById("result");
|
| 229 |
const promptEl = document.getElementById("prompt");
|
|
@@ -245,16 +317,23 @@ function addAssistantBubble(){
|
|
| 245 |
`<div class="a-avatar">🤖</div>
|
| 246 |
<div class="a-body">
|
| 247 |
<div class="a-content"></div>
|
| 248 |
-
<div class="a-beat"><span class="spin">◐</span><span class="beat-text">Reading your trip request…</span></div>
|
| 249 |
</div>`;
|
| 250 |
chatEl.appendChild(m); chatEl.scrollTop = chatEl.scrollHeight;
|
| 251 |
return m;
|
| 252 |
}
|
| 253 |
-
function
|
| 254 |
-
const beat = bubble.querySelector(".
|
| 255 |
-
if (
|
| 256 |
-
|
| 257 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
}
|
| 259 |
function hideBeat(bubble){
|
| 260 |
const beat = bubble.querySelector(".a-beat");
|
|
@@ -262,26 +341,72 @@ function hideBeat(bubble){
|
|
| 262 |
}
|
| 263 |
function esc(s){ const d=document.createElement("div"); d.textContent=s==null?"":String(s); return d.innerHTML; }
|
| 264 |
|
| 265 |
-
// ──
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
function handleEvent(ev, bubble){
|
| 267 |
if (!ev) return;
|
| 268 |
if (ev.type === "commentary"){
|
| 269 |
-
|
|
|
|
| 270 |
} else if (ev.type === "greenlight"){
|
|
|
|
| 271 |
bubble.querySelector(".a-content").innerHTML =
|
| 272 |
`<div class="green">✅ Planning your trip: ${esc(ev.text)}</div>`;
|
|
|
|
| 273 |
} else if (ev.type === "clarify"){
|
| 274 |
bubble.querySelector(".a-content").innerHTML = `<div class="clarify">💬 ${esc(ev.text)}</div>`;
|
| 275 |
-
hideBeat(bubble);
|
| 276 |
} else if (ev.type === "error"){
|
| 277 |
bubble.querySelector(".a-content").innerHTML = `<div class="err">${esc(ev.text)}</div>`;
|
| 278 |
-
hideBeat(bubble);
|
| 279 |
} else if (ev.type === "result"){
|
| 280 |
-
if (resultHandled) return;
|
| 281 |
resultHandled = true;
|
| 282 |
-
|
|
|
|
| 283 |
bubble.querySelector(".a-content").innerHTML =
|
| 284 |
-
`<div class="green">✅ Built 3 ranked packages
|
| 285 |
resultEl.innerHTML = ev.html;
|
| 286 |
activateScripts(resultEl); // re-init the injected Leaflet map
|
| 287 |
if (ev.explanation && ev.explanation.trim()){
|
|
@@ -290,7 +415,7 @@ function handleEvent(ev, bubble){
|
|
| 290 |
const safe = esc(ev.explanation).replace(/\n/g,"<br>");
|
| 291 |
nb.innerHTML =
|
| 292 |
`<div class="a-avatar">🤖</div><div class="a-body">
|
| 293 |
-
<div class="a-content"><b style="color:#
|
| 294 |
<div class="nemotron-note">written by Nemotron-3-Nano-30B · grounded in the scored data</div>
|
| 295 |
</div></div>`;
|
| 296 |
chatEl.appendChild(nb);
|
|
@@ -298,6 +423,12 @@ function handleEvent(ev, bubble){
|
|
| 298 |
chatEl.scrollTop = chatEl.scrollHeight;
|
| 299 |
}
|
| 300 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
|
| 302 |
function activateScripts(root){
|
| 303 |
// innerHTML-inserted <script> tags don't auto-run — re-create them so the
|
|
@@ -309,38 +440,63 @@ function activateScripts(root){
|
|
| 309 |
});
|
| 310 |
}
|
| 311 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
async function runTrip(forcedText){
|
| 313 |
if (running) return;
|
| 314 |
const text = (forcedText == null ? promptEl.value : forcedText).trim();
|
| 315 |
if (!text) return;
|
| 316 |
-
running = true; sendBtn.disabled = true;
|
|
|
|
| 317 |
addUserBubble(text);
|
| 318 |
const bubble = addAssistantBubble();
|
|
|
|
|
|
|
| 319 |
|
|
|
|
| 320 |
try{
|
| 321 |
const resp = await fetch("/gradio_api/call/plan_trip", {
|
| 322 |
method: "POST", headers: {"Content-Type":"application/json"},
|
| 323 |
body: JSON.stringify({ data: [text] }),
|
| 324 |
});
|
|
|
|
| 325 |
const j = await resp.json();
|
| 326 |
const eventId = j && j.event_id;
|
| 327 |
-
if (!eventId){
|
| 328 |
|
| 329 |
const es = new EventSource("/gradio_api/call/plan_trip/" + eventId);
|
| 330 |
es.addEventListener("generating", (e)=>{
|
| 331 |
-
try{ const arr = JSON.parse(e.data); handleEvent(JSON.parse(arr[0]), bubble); }
|
|
|
|
| 332 |
});
|
| 333 |
es.addEventListener("complete", (e)=>{
|
|
|
|
| 334 |
try{ const arr = JSON.parse(e.data); handleEvent(arr && arr[0] ? JSON.parse(arr[0]) : null, bubble); }catch(_){}
|
| 335 |
es.close(); finish();
|
| 336 |
});
|
| 337 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
}catch(err){
|
| 339 |
-
|
| 340 |
-
|
| 341 |
}
|
| 342 |
}
|
| 343 |
-
function finish(){ running = false; sendBtn.disabled = false; resultHandled = false; }
|
| 344 |
|
| 345 |
// ── Full-screen map (Layla frame 24) ───────────────────
|
| 346 |
function matchdayFullscreen(on){
|
|
|
|
| 8 |
<!-- Leaflet preloaded in <head> so the injected map's inline init runs instantly (N1). -->
|
| 9 |
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
| 10 |
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
| 11 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 12 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 13 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
| 14 |
|
| 15 |
<style>
|
| 16 |
:root{
|
| 17 |
+
--violet:#7c3aed; --violet-d:#6d28d9; --violet-l:#ede9fe;
|
| 18 |
+
--emerald:#10b981; --emerald-d:#047857;
|
| 19 |
+
--amber:#f59e0b;
|
| 20 |
+
--ink:#0f172a; --ink2:#374151; --mut:#6b7280; --mut2:#9ca3af;
|
| 21 |
+
--line:#e8eaee; --line2:#eef0f3; --panel:#f9fafb; --gray:#f3f4f6;
|
| 22 |
+
--bg:#f7f8fa; --surface:#ffffff;
|
| 23 |
+
--shadow-sm:0 1px 2px rgba(17,24,39,.04),0 2px 6px -2px rgba(17,24,39,.08);
|
| 24 |
+
--shadow-md:0 1px 2px rgba(17,24,39,.04),0 8px 22px -12px rgba(17,24,39,.16);
|
| 25 |
}
|
| 26 |
*{box-sizing:border-box;}
|
| 27 |
html,body{margin:0;height:100%;}
|
| 28 |
body{
|
| 29 |
font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
| 30 |
+
color:var(--ink); background:var(--bg); -webkit-font-smoothing:antialiased;
|
| 31 |
+
text-rendering:optimizeLegibility;
|
| 32 |
}
|
| 33 |
+
::-webkit-scrollbar{width:10px;height:10px;}
|
| 34 |
+
::-webkit-scrollbar-thumb{background:#d1d5db;border-radius:8px;border:2px solid var(--bg);}
|
| 35 |
+
::-webkit-scrollbar-thumb:hover{background:#b9c0c9;}
|
| 36 |
|
| 37 |
/* ── Top bar ─────────────────────────────────────────── */
|
| 38 |
.topbar{
|
| 39 |
+
display:flex;align-items:center;gap:14px;padding:13px 22px;
|
| 40 |
+
background:linear-gradient(100deg,#0f172a 0%,#1e293b 45%,#1e3a8a 100%);color:#fff;
|
| 41 |
+
position:relative;z-index:5;box-shadow:0 2px 14px rgba(15,23,42,.18);
|
| 42 |
}
|
| 43 |
+
.topbar .logo{font-size:21px;font-weight:800;letter-spacing:-.025em;display:flex;align-items:center;gap:9px;}
|
| 44 |
+
.topbar .logo .ball{filter:drop-shadow(0 2px 4px rgba(0,0,0,.3));}
|
| 45 |
+
.topbar .tag{font-size:13px;opacity:.82;font-weight:500;}
|
| 46 |
+
.topbar .tag b{color:#c4b5fd;font-weight:600;}
|
| 47 |
.topbar .sp{flex:1;}
|
| 48 |
+
.topbar .pill{font-size:11.5px;font-weight:600;background:rgba(255,255,255,.10);
|
| 49 |
+
padding:6px 12px;border-radius:999px;border:1px solid rgba(255,255,255,.16);
|
| 50 |
+
display:inline-flex;align-items:center;gap:8px;}
|
| 51 |
.topbar .pill b{color:#c4b5fd;}
|
| 52 |
+
.topbar .live-dot{width:7px;height:7px;border-radius:50%;background:#34d399;
|
| 53 |
+
box-shadow:0 0 0 0 rgba(52,211,153,.6);animation:pulse 2s infinite;}
|
| 54 |
+
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(52,211,153,.5);}70%{box-shadow:0 0 0 7px rgba(52,211,153,0);}100%{box-shadow:0 0 0 0 rgba(52,211,153,0);}}
|
| 55 |
|
| 56 |
/* ── 3-column layout ─────────────────────────────────── */
|
| 57 |
+
.app{display:grid;grid-template-columns:minmax(330px,35%) minmax(0,45%) minmax(190px,21%);
|
| 58 |
+
height:calc(100vh - 57px);}
|
| 59 |
.col{display:flex;flex-direction:column;min-height:0;min-width:0;}
|
| 60 |
+
.chat{border-right:1px solid var(--line);background:var(--bg);}
|
| 61 |
.sidebar{border-left:1px solid var(--line);background:var(--panel);overflow:auto;}
|
| 62 |
|
| 63 |
/* ── Chat column ─────────────────────────────────────── */
|
| 64 |
+
#chat{flex:1;overflow-y:auto;padding:20px 18px 10px;display:flex;flex-direction:column;gap:15px;}
|
| 65 |
+
.hero{background:var(--surface);border:1px solid var(--line2);border-radius:16px;padding:17px 18px;
|
| 66 |
+
box-shadow:var(--shadow-sm);}
|
| 67 |
+
.hero h2{margin:0 0 7px;font-size:15.5px;font-weight:800;letter-spacing:-.01em;}
|
| 68 |
+
.hero p{margin:0;font-size:13px;color:var(--mut);line-height:1.55;}
|
| 69 |
+
|
| 70 |
+
.msg{display:flex;gap:10px;max-width:94%;animation:fade .3s ease;}
|
| 71 |
.msg.user{align-self:flex-end;flex-direction:row-reverse;}
|
| 72 |
+
.a-avatar{width:31px;height:31px;border-radius:50%;background:linear-gradient(135deg,#1e3a8a,#312e81);
|
| 73 |
+
color:#fff;display:flex;align-items:center;justify-content:center;font-size:15px;flex:0 0 31px;
|
| 74 |
+
box-shadow:var(--shadow-sm);}
|
| 75 |
+
.a-body{background:var(--surface);border:1px solid var(--line2);border-radius:16px;
|
| 76 |
+
padding:11px 14px;min-width:0;box-shadow:var(--shadow-sm);}
|
| 77 |
+
.msg.user .a-body{background:linear-gradient(135deg,var(--violet),var(--violet-d));color:#fff;border-color:transparent;box-shadow:0 6px 16px -6px rgba(124,58,237,.5);}
|
| 78 |
.a-content{font-size:14px;line-height:1.5;}
|
| 79 |
.a-beat{display:flex;align-items:center;gap:9px;font-size:13.5px;color:var(--ink2);
|
| 80 |
+
font-weight:600;padding-top:3px;flex-wrap:wrap;}
|
| 81 |
+
.spin{display:inline-block;animation:spin 1s linear infinite;color:var(--violet);font-size:14px;}
|
| 82 |
@keyframes spin{to{transform:rotate(360deg);}}
|
| 83 |
@keyframes fade{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:none;}}
|
| 84 |
+
.elapsed{font-size:12px;color:var(--violet);font-weight:700;font-variant-numeric:tabular-nums;
|
| 85 |
+
background:var(--violet-l);padding:1px 8px;border-radius:999px;}
|
| 86 |
|
| 87 |
+
.green{background:linear-gradient(135deg,#ecfdf5,#d1fae5);border:1px solid #a7f3d0;color:#065f46;
|
| 88 |
+
border-radius:12px;padding:10px 13px;font-size:14px;font-weight:700;}
|
| 89 |
+
.clarify{background:#eff6ff;border:1px solid #bfdbfe;color:#1e3a8a;border-radius:12px;padding:12px 14px;font-size:14.5px;line-height:1.5;}
|
| 90 |
+
.err{background:#fef2f2;border:1px solid #fecaca;color:#991b1b;border-radius:12px;padding:11px 13px;font-size:14px;line-height:1.5;}
|
| 91 |
+
.retry-btn{margin-top:9px;background:#fff;border:1px solid var(--violet);color:var(--violet);
|
| 92 |
+
border-radius:9px;padding:7px 14px;font:inherit;font-weight:700;font-size:13px;cursor:pointer;
|
| 93 |
+
display:inline-flex;align-items:center;gap:6px;transition:.15s;}
|
| 94 |
+
.retry-btn:hover{background:var(--violet-l);}
|
| 95 |
+
.nemotron-note{font-size:11px;color:var(--mut2);margin-top:8px;font-weight:600;letter-spacing:.02em;}
|
| 96 |
|
| 97 |
/* input row */
|
| 98 |
+
.composer{border-top:1px solid var(--line);padding:12px 16px 14px;background:var(--surface);}
|
| 99 |
+
.composer textarea{width:100%;border:1px solid #d6d9df;border-radius:13px;padding:12px 14px;
|
| 100 |
+
font:inherit;font-size:14px;resize:none;outline:none;min-height:50px;background:var(--bg);
|
| 101 |
+
transition:.15s;line-height:1.45;}
|
| 102 |
+
.composer textarea:focus{border-color:var(--violet);box-shadow:0 0 0 4px rgba(124,58,237,.13);background:#fff;}
|
| 103 |
+
.composer .row{display:flex;align-items:center;gap:9px;margin-top:9px;}
|
| 104 |
+
.examples{display:flex;flex-wrap:wrap;gap:7px;padding:0 18px 8px;}
|
| 105 |
+
.chip{font-size:12px;background:var(--surface);border:1px solid var(--line2);border-radius:999px;
|
| 106 |
+
padding:6px 12px;color:var(--ink2);cursor:pointer;transition:.15s;box-shadow:var(--shadow-sm);}
|
| 107 |
+
.chip:hover{border-color:var(--violet);color:var(--violet);transform:translateY(-1px);}
|
| 108 |
+
button.primary{background:linear-gradient(135deg,var(--violet),var(--violet-d));color:#fff;border:0;
|
| 109 |
+
border-radius:11px;padding:10px 17px;font:inherit;font-weight:700;cursor:pointer;
|
| 110 |
+
display:inline-flex;align-items:center;gap:7px;box-shadow:0 6px 16px -6px rgba(124,58,237,.55);
|
| 111 |
+
transition:.15s;}
|
| 112 |
+
button.primary:hover{transform:translateY(-1px);box-shadow:0 10px 22px -6px rgba(124,58,237,.6);}
|
| 113 |
+
button.primary:disabled{opacity:.55;cursor:not-allowed;transform:none;}
|
| 114 |
+
.hint{font-size:11.5px;color:var(--mut2);}
|
| 115 |
|
| 116 |
/* ── Result column ───────────────────────────────────── */
|
| 117 |
+
.result{overflow-y:auto;background:var(--bg);}
|
| 118 |
+
#result{padding:18px 20px 28px;}
|
| 119 |
.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;
|
| 120 |
+
height:100%;text-align:center;color:var(--mut2);padding:40px 30px;}
|
| 121 |
+
.empty .big{font-size:50px;margin-bottom:14px;filter:drop-shadow(0 6px 10px rgba(0,0,0,.08));}
|
| 122 |
+
.empty h3{margin:0 0 8px;font-size:17px;color:var(--ink2);font-weight:700;}
|
| 123 |
+
.empty p{margin:0;font-size:13.5px;max-width:340px;line-height:1.55;}
|
| 124 |
+
|
| 125 |
+
/* ── "Building your trip" skeleton state (premium wait UX) ── */
|
| 126 |
+
.building{padding:2px 0;}
|
| 127 |
+
.build-head{display:flex;align-items:center;gap:11px;background:linear-gradient(135deg,#0f172a,#1e3a8a);
|
| 128 |
+
color:#fff;border-radius:16px;padding:14px 17px;margin-bottom:18px;box-shadow:var(--shadow-md);
|
| 129 |
+
flex-wrap:wrap;}
|
| 130 |
+
.build-head .spin{color:#a5b4fc;}
|
| 131 |
+
.build-text{font-size:14px;font-weight:700;flex:1;min-width:120px;}
|
| 132 |
+
.build-head .elapsed{background:rgba(255,255,255,.14);color:#fff;border:1px solid rgba(255,255,255,.18);}
|
| 133 |
+
.cold-hint{font-size:12.5px;color:#c7d2fe;background:rgba(255,255,255,.08);border-radius:10px;
|
| 134 |
+
padding:8px 12px;margin-top:8px;line-height:1.45;width:100%;border:1px solid rgba(255,255,255,.10);}
|
| 135 |
+
|
| 136 |
+
@keyframes shimmer{100%{transform:translateX(100%);}}
|
| 137 |
+
.sk{position:relative;overflow:hidden;background:#e9ecf2;border-radius:13px;}
|
| 138 |
+
.sk::after{content:"";position:absolute;inset:0;transform:translateX(-100%);
|
| 139 |
+
background:linear-gradient(90deg,transparent,rgba(255,255,255,.65),transparent);animation:shimmer 1.5s infinite;}
|
| 140 |
+
.sk-h{height:13px;margin-bottom:10px;}
|
| 141 |
+
.sk-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(270px,1fr));gap:15px;margin-bottom:22px;}
|
| 142 |
+
.sk-card{background:var(--surface);border:1px solid var(--line2);border-radius:18px;padding:18px;box-shadow:var(--shadow-sm);overflow:hidden;}
|
| 143 |
+
.sk-card .sk{margin-bottom:9px;}
|
| 144 |
+
.sk-card .sk.big{height:30px;width:60%;margin-bottom:14px;}
|
| 145 |
+
.sk-card .sk.line{height:12px;}
|
| 146 |
+
.sk-card .sk.line.w80{width:80%;}
|
| 147 |
+
.sk-card .sk.line.w60{width:60%;}
|
| 148 |
+
.sk-map{height:300px;border-radius:18px;margin-bottom:22px;}
|
| 149 |
+
.sk-tlrow{background:var(--surface);border:1px solid var(--line2);border-radius:16px;padding:15px;
|
| 150 |
+
display:flex;gap:14px;align-items:center;margin-bottom:11px;box-shadow:var(--shadow-sm);overflow:hidden;}
|
| 151 |
+
.sk-tlrow .sq{width:32px;height:32px;border-radius:10px;flex:0 0 32px;}
|
| 152 |
+
.sk-tlrow .ln{flex:1;}
|
| 153 |
+
.sk-tlrow .ln .sk{height:12px;margin-bottom:7px;}
|
| 154 |
+
.sk-tlrow .ln .sk.w50{width:50%;}
|
| 155 |
|
| 156 |
/* ── Sidebar ─────────────────────────────────────────── */
|
| 157 |
+
.sidebar .sec{padding:16px 16px 15px;border-bottom:1px solid var(--line);}
|
| 158 |
+
.sidebar h4{margin:0 0 9px;font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--mut);font-weight:800;}
|
| 159 |
.brain{display:flex;gap:10px;align-items:flex-start;}
|
| 160 |
+
.brain .b{font-size:20px;line-height:1.2;}
|
| 161 |
+
.brain .t{font-size:12.5px;line-height:1.5;color:var(--ink2);}
|
| 162 |
.brain .t b{color:var(--violet);}
|
| 163 |
+
.legend-row{display:flex;align-items:center;gap:9px;font-size:12.5px;color:var(--ink2);margin:6px 0;}
|
| 164 |
+
.dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex:0 0 9px;}
|
| 165 |
+
.dot.live{background:var(--emerald);box-shadow:0 0 0 2px rgba(16,185,129,.2);}
|
| 166 |
.dot.fallback{background:var(--amber);}
|
| 167 |
.badges{display:flex;flex-wrap:wrap;gap:5px;}
|
| 168 |
+
.badge{font-size:10.5px;font-weight:700;background:#eef2ff;color:#3730a3;border:1px solid #c7d2fe;
|
| 169 |
+
border-radius:7px;padding:3px 8px;}
|
| 170 |
+
.fact{font-size:12.5px;color:var(--ink2);line-height:1.5;margin:5px 0;}
|
| 171 |
.fact b{color:var(--ink);}
|
| 172 |
|
| 173 |
/* full-screen map */
|
| 174 |
#matchday-map.fs{position:fixed!important;inset:0!important;z-index:99998!important;
|
| 175 |
height:100vh!important;width:100vw!important;border-radius:0!important;}
|
| 176 |
#md-fs-close{position:fixed;top:16px;right:18px;z-index:99999;background:#fff;color:var(--ink);
|
| 177 |
+
font-weight:700;border:0;border-radius:11px;padding:9px 15px;cursor:pointer;font-size:14px;
|
| 178 |
+
box-shadow:0 6px 18px rgba(0,0,0,.25);display:none;}
|
| 179 |
#md-fs-close.show{display:block;}
|
| 180 |
|
| 181 |
@media (max-width:980px){
|
|
|
|
| 188 |
</head>
|
| 189 |
<body>
|
| 190 |
<header class="topbar">
|
| 191 |
+
<div class="logo"><span class="ball">⚽</span> MatchDay</div>
|
| 192 |
<div class="tag">Your AI trip planner for the <b>2026 FIFA World Cup · Vancouver</b></div>
|
| 193 |
<div class="sp"></div>
|
| 194 |
+
<span class="pill"><span class="live-dot"></span>Brain <b>Nemotron-3-Nano-30B</b> · Hands <b>Python</b> · <b>gradio.Server</b></span>
|
| 195 |
</header>
|
| 196 |
|
| 197 |
<div class="app">
|
|
|
|
| 215 |
aria-label="Describe your trip"></textarea>
|
| 216 |
<div class="row">
|
| 217 |
<button class="primary" id="send" onclick="runTrip()">🏈 Plan my trip</button>
|
| 218 |
+
<span class="hint">Enter to send · Shift+Enter for a newline</span>
|
| 219 |
</div>
|
| 220 |
</div>
|
| 221 |
</section>
|
|
|
|
| 226 |
<div class="empty">
|
| 227 |
<div class="big">🏟️</div>
|
| 228 |
<h3>Your trip packages will appear here</h3>
|
| 229 |
+
<p>Send your request and Nemotron picks the tools while Python scores real flights, hotels, weather and nearby spots — ranked on an interactive map.</p>
|
| 230 |
</div>
|
| 231 |
</div>
|
| 232 |
</section>
|
|
|
|
| 239 |
<div class="b">🧠</div>
|
| 240 |
<div class="t"><b>Brain:</b> Nemotron-3-Nano-30B (3B-active MoE) on Modal A100. It chooses the tools and writes the comparisons. It <b>never</b> calls an API or names a price.</div>
|
| 241 |
</div>
|
| 242 |
+
<div class="brain" style="margin-top:11px;">
|
| 243 |
<div class="b">🤲</div>
|
| 244 |
<div class="t"><b>Hands:</b> deterministic Python calls SerpApi / Open-Meteo / OpenStreetMap, scores packages with a fixed formula, and tags every value with provenance.</div>
|
| 245 |
</div>
|
|
|
|
| 277 |
"Take me from Ottawa to Vancouver for Canada vs Qatar on 2026-06-26, budget",
|
| 278 |
"From Halifax, Canada vs Morocco June 18, couple, luxury",
|
| 279 |
];
|
|
|
|
| 280 |
document.getElementById("prompt").value = EXAMPLES[0];
|
| 281 |
|
| 282 |
+
// Phased progress messages (frontend-driven, carry perceived progress while the
|
| 283 |
+
// backend stream is silent during the Modal cold-start gap).
|
| 284 |
+
const PHASES = [
|
| 285 |
+
{t:0, m:"Reading your trip request…"},
|
| 286 |
+
{t:4, m:"🤖 Nemotron is choosing the right tools…"},
|
| 287 |
+
{t:14, m:"✈️ Scanning airlines for the cheapest route…"},
|
| 288 |
+
{t:30, m:"🏨 Finding hotels closest to BC Place…"},
|
| 289 |
+
{t:48, m:"🌤️ Checking the match-day forecast…"},
|
| 290 |
+
{t:68, m:"🗺️ Scoring 3 packages & ranking them…"},
|
| 291 |
+
{t:95, m:"✍️ Nemotron is writing your comparison…"},
|
| 292 |
+
];
|
| 293 |
+
|
| 294 |
let running = false;
|
| 295 |
+
let resultHandled = false; // the result event repeats on `complete` — render once
|
| 296 |
+
let elapsedTimer = null;
|
| 297 |
+
let lastRealAt = 0; // timestamp of last real stream event (ms)
|
| 298 |
+
let lastRealMsg = "";
|
| 299 |
const chatEl = document.getElementById("chat");
|
| 300 |
const resultEl = document.getElementById("result");
|
| 301 |
const promptEl = document.getElementById("prompt");
|
|
|
|
| 317 |
`<div class="a-avatar">🤖</div>
|
| 318 |
<div class="a-body">
|
| 319 |
<div class="a-content"></div>
|
| 320 |
+
<div class="a-beat"><span class="spin">◐</span><span class="beat-text">Reading your trip request…</span><span class="elapsed" style="display:none">· 0:00</span></div>
|
| 321 |
</div>`;
|
| 322 |
chatEl.appendChild(m); chatEl.scrollTop = chatEl.scrollHeight;
|
| 323 |
return m;
|
| 324 |
}
|
| 325 |
+
function setStatus(bubble, text){
|
| 326 |
+
const beat = bubble.querySelector(".beat-text");
|
| 327 |
+
if (beat) beat.textContent = text;
|
| 328 |
+
const bh = document.querySelector(".build-text");
|
| 329 |
+
if (bh) bh.textContent = text;
|
| 330 |
+
}
|
| 331 |
+
function setElapsed(bubble, secs){
|
| 332 |
+
const fmt = Math.floor(secs/60) + ":" + String(secs%60).padStart(2,"0");
|
| 333 |
+
const e = bubble.querySelector(".elapsed");
|
| 334 |
+
if (e){ e.textContent = "· " + fmt; e.style.display = "inline-block"; }
|
| 335 |
+
const be = document.querySelector(".build-head .elapsed");
|
| 336 |
+
if (be) be.textContent = "· " + fmt;
|
| 337 |
}
|
| 338 |
function hideBeat(bubble){
|
| 339 |
const beat = bubble.querySelector(".a-beat");
|
|
|
|
| 341 |
}
|
| 342 |
function esc(s){ const d=document.createElement("div"); d.textContent=s==null?"":String(s); return d.innerHTML; }
|
| 343 |
|
| 344 |
+
// ── Skeleton "building your trip" state ────────────────
|
| 345 |
+
function showSkeleton(bubble){
|
| 346 |
+
resultEl.innerHTML =
|
| 347 |
+
`<div class="building">
|
| 348 |
+
<div class="build-head">
|
| 349 |
+
<span class="spin">◐</span>
|
| 350 |
+
<span class="build-text">Reading your trip request…</span>
|
| 351 |
+
<span class="elapsed">· 0:00</span>
|
| 352 |
+
<div class="cold-hint" style="display:none"></div>
|
| 353 |
+
</div>
|
| 354 |
+
<div class="sk-cards">
|
| 355 |
+
${`<div class="sk-card"><div class="sk big"></div><div class="sk line w80"></div><div class="sk line w60"></div><div class="sk line"></div><div class="sk line w80"></div></div>`.repeat(3)}
|
| 356 |
+
</div>
|
| 357 |
+
<div class="sk sk-map"></div>
|
| 358 |
+
${`<div class="sk-tlrow"><div class="sk sq"></div><div class="ln"><div class="sk w50"></div><div class="sk w80"></div></div></div>`.repeat(4)}
|
| 359 |
+
</div>`;
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
// ── Elapsed timer + phased progress + cold-start honesty ─
|
| 363 |
+
function startTimer(bubble){
|
| 364 |
+
stopTimer();
|
| 365 |
+
const t0 = performance.now();
|
| 366 |
+
elapsedTimer = setInterval(()=>{
|
| 367 |
+
const secs = Math.floor((performance.now()-t0)/1000);
|
| 368 |
+
setElapsed(bubble, secs);
|
| 369 |
+
// phased fallback if no real event recently
|
| 370 |
+
if (performance.now() - lastRealAt > 6000){
|
| 371 |
+
let ph = PHASES[0].m;
|
| 372 |
+
for (const p of PHASES){ if (secs >= p.t) ph = p.m; }
|
| 373 |
+
setStatus(bubble, ph);
|
| 374 |
+
}
|
| 375 |
+
// cold-start honesty after ~20s
|
| 376 |
+
const hint = document.querySelector(".cold-hint");
|
| 377 |
+
if (hint && secs >= 20 && hint.dataset.shown !== "1"){
|
| 378 |
+
hint.dataset.shown = "1";
|
| 379 |
+
hint.style.display = "block";
|
| 380 |
+
hint.textContent = "⏳ Warming up Nemotron-3-Nano-30B on Modal — the first query after idle can take ~1–2 min. It's working; the packages will stream in as soon as they're scored.";
|
| 381 |
+
}
|
| 382 |
+
}, 1000);
|
| 383 |
+
}
|
| 384 |
+
function stopTimer(){ if (elapsedTimer){ clearInterval(elapsedTimer); elapsedTimer = null; } }
|
| 385 |
+
|
| 386 |
+
// ── Streaming via raw SSE ───────────────────────────────
|
| 387 |
function handleEvent(ev, bubble){
|
| 388 |
if (!ev) return;
|
| 389 |
if (ev.type === "commentary"){
|
| 390 |
+
lastRealAt = performance.now(); lastRealMsg = ev.text || "";
|
| 391 |
+
setStatus(bubble, lastRealMsg);
|
| 392 |
} else if (ev.type === "greenlight"){
|
| 393 |
+
lastRealAt = performance.now();
|
| 394 |
bubble.querySelector(".a-content").innerHTML =
|
| 395 |
`<div class="green">✅ Planning your trip: ${esc(ev.text)}</div>`;
|
| 396 |
+
setStatus(bubble, "✈️ Scanning airlines · 🏨 hotels near BC Place · 🌤️ weather…");
|
| 397 |
} else if (ev.type === "clarify"){
|
| 398 |
bubble.querySelector(".a-content").innerHTML = `<div class="clarify">💬 ${esc(ev.text)}</div>`;
|
| 399 |
+
hideBeat(bubble); stopTimer(); clearSkeleton();
|
| 400 |
} else if (ev.type === "error"){
|
| 401 |
bubble.querySelector(".a-content").innerHTML = `<div class="err">${esc(ev.text)}</div>`;
|
| 402 |
+
hideBeat(bubble); stopTimer(); clearSkeleton();
|
| 403 |
} else if (ev.type === "result"){
|
| 404 |
+
if (resultHandled) return; // `complete` re-emits the final yield — render once
|
| 405 |
resultHandled = true;
|
| 406 |
+
lastRealAt = performance.now();
|
| 407 |
+
hideBeat(bubble); stopTimer();
|
| 408 |
bubble.querySelector(".a-content").innerHTML =
|
| 409 |
+
`<div class="green">✅ Built 3 ranked packages — see them on the map →</div>`;
|
| 410 |
resultEl.innerHTML = ev.html;
|
| 411 |
activateScripts(resultEl); // re-init the injected Leaflet map
|
| 412 |
if (ev.explanation && ev.explanation.trim()){
|
|
|
|
| 415 |
const safe = esc(ev.explanation).replace(/\n/g,"<br>");
|
| 416 |
nb.innerHTML =
|
| 417 |
`<div class="a-avatar">🤖</div><div class="a-body">
|
| 418 |
+
<div class="a-content"><b style="color:#3730a3">🤖 Nemotron compares your options</b><br>${safe}
|
| 419 |
<div class="nemotron-note">written by Nemotron-3-Nano-30B · grounded in the scored data</div>
|
| 420 |
</div></div>`;
|
| 421 |
chatEl.appendChild(nb);
|
|
|
|
| 423 |
chatEl.scrollTop = chatEl.scrollHeight;
|
| 424 |
}
|
| 425 |
}
|
| 426 |
+
function clearSkeleton(){
|
| 427 |
+
const b = resultEl.querySelector(".building");
|
| 428 |
+
if (b && !resultHandled){
|
| 429 |
+
resultEl.innerHTML = `<div class="empty"><div class="big">🏟️</div><h3>Your trip packages will appear here</h3><p>Send your request and Nemotron picks the tools while Python scores real flights, hotels, weather and nearby spots.</p></div>`;
|
| 430 |
+
}
|
| 431 |
+
}
|
| 432 |
|
| 433 |
function activateScripts(root){
|
| 434 |
// innerHTML-inserted <script> tags don't auto-run — re-create them so the
|
|
|
|
| 440 |
});
|
| 441 |
}
|
| 442 |
|
| 443 |
+
function showStreamError(bubble, text, retryText){
|
| 444 |
+
stopTimer();
|
| 445 |
+
hideBeat(bubble);
|
| 446 |
+
clearSkeleton();
|
| 447 |
+
const c = bubble.querySelector(".a-content");
|
| 448 |
+
c.innerHTML = `<div class="err">⚠️ ${esc(text||"Connection to the agent was interrupted.")}</div>
|
| 449 |
+
<button class="retry-btn" type="button">↻ Try again</button>`;
|
| 450 |
+
c.querySelector(".retry-btn").addEventListener("click", ()=>{ runTrip(retryText); });
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
async function runTrip(forcedText){
|
| 454 |
if (running) return;
|
| 455 |
const text = (forcedText == null ? promptEl.value : forcedText).trim();
|
| 456 |
if (!text) return;
|
| 457 |
+
running = true; sendBtn.disabled = true; resultHandled = false;
|
| 458 |
+
lastRealAt = 0; lastRealMsg = "";
|
| 459 |
addUserBubble(text);
|
| 460 |
const bubble = addAssistantBubble();
|
| 461 |
+
showSkeleton(bubble);
|
| 462 |
+
startTimer(bubble);
|
| 463 |
|
| 464 |
+
let completed = false;
|
| 465 |
try{
|
| 466 |
const resp = await fetch("/gradio_api/call/plan_trip", {
|
| 467 |
method: "POST", headers: {"Content-Type":"application/json"},
|
| 468 |
body: JSON.stringify({ data: [text] }),
|
| 469 |
});
|
| 470 |
+
if (!resp.ok){ throw new Error("Server returned HTTP " + resp.status); }
|
| 471 |
const j = await resp.json();
|
| 472 |
const eventId = j && j.event_id;
|
| 473 |
+
if (!eventId){ throw new Error("No event id returned from the queue."); }
|
| 474 |
|
| 475 |
const es = new EventSource("/gradio_api/call/plan_trip/" + eventId);
|
| 476 |
es.addEventListener("generating", (e)=>{
|
| 477 |
+
try{ const arr = JSON.parse(e.data); handleEvent(JSON.parse(arr[0]), bubble); }
|
| 478 |
+
catch(err){ console.warn("MatchDay: skipped a malformed stream chunk", err); }
|
| 479 |
});
|
| 480 |
es.addEventListener("complete", (e)=>{
|
| 481 |
+
completed = true;
|
| 482 |
try{ const arr = JSON.parse(e.data); handleEvent(arr && arr[0] ? JSON.parse(arr[0]) : null, bubble); }catch(_){}
|
| 483 |
es.close(); finish();
|
| 484 |
});
|
| 485 |
+
// gradio keepalive (ignored) — just mark the stream alive
|
| 486 |
+
es.addEventListener("heartbeat", ()=>{ lastRealAt = performance.now(); });
|
| 487 |
+
es.addEventListener("error", ()=>{
|
| 488 |
+
es.close();
|
| 489 |
+
if (completed) return; // clean end — EventSource fires error on close
|
| 490 |
+
if (resultHandled) return; // already rendered; nothing to do
|
| 491 |
+
showStreamError(bubble, "Connection to the agent dropped mid-stream.", text);
|
| 492 |
+
finish();
|
| 493 |
+
});
|
| 494 |
}catch(err){
|
| 495 |
+
showStreamError(bubble, err && err.message ? err.message : "Request failed.", text);
|
| 496 |
+
finish();
|
| 497 |
}
|
| 498 |
}
|
| 499 |
+
function finish(){ running = false; sendBtn.disabled = false; resultHandled = false; stopTimer(); }
|
| 500 |
|
| 501 |
// ── Full-screen map (Layla frame 24) ───────────────────
|
| 502 |
function matchdayFullscreen(on){
|
matchday/render.py
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 1 |
-
"""HTML rendering for MatchDay — Layla-competitive cards + Leaflet map.
|
| 2 |
|
| 3 |
-
Pure functions: a ``TripPackageResult`` -> HTML strings for
|
| 4 |
Every price / hotel / weather reading carries a provenance badge (I4) so judges
|
| 5 |
can verify nothing is hallucinated: "● live" (serpapi/openmeteo/osm) vs
|
| 6 |
-
"example
|
| 7 |
-
|
|
|
|
| 8 |
"""
|
| 9 |
from __future__ import annotations
|
| 10 |
|
|
@@ -17,10 +18,21 @@ from matchday.trip_tool import TripPackageResult
|
|
| 17 |
BC_PLACE_LAT = 49.2827
|
| 18 |
BC_PLACE_LON = -123.1207
|
| 19 |
|
|
|
|
| 20 |
_LABEL_COLOR = {
|
| 21 |
"Cheapest": "#16a34a",
|
| 22 |
"Safest Arrival": "#2563eb",
|
| 23 |
-
"Closest to Stadium": "#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
}
|
| 25 |
|
| 26 |
# WMO weather code -> emoji (subset; unknown -> 🌡️).
|
|
@@ -31,45 +43,115 @@ _WMO_ICON: dict[int, str] = {
|
|
| 31 |
95: "⛈️", 96: "⛈️", 99: "⛈️",
|
| 32 |
}
|
| 33 |
|
| 34 |
-
|
| 35 |
_CSS = """
|
| 36 |
<style>
|
| 37 |
-
.md-wrap{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
.md-
|
| 41 |
-
|
| 42 |
-
.md-
|
| 43 |
-
.md-
|
| 44 |
-
.md-
|
| 45 |
-
.md-
|
| 46 |
-
.md-
|
| 47 |
-
.md-
|
| 48 |
-
.md-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
.md-
|
| 52 |
-
.md-
|
| 53 |
-
.md-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
.md-
|
| 57 |
-
|
| 58 |
-
.md-
|
| 59 |
-
.md-
|
| 60 |
-
.md-
|
| 61 |
-
.md-
|
| 62 |
-
.md-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
.md-
|
| 66 |
-
.md-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
.md-
|
| 70 |
-
.md-
|
| 71 |
-
.md-
|
| 72 |
-
.md-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
</style>
|
| 74 |
"""
|
| 75 |
|
|
@@ -81,107 +163,145 @@ def _e(x: Any) -> str:
|
|
| 81 |
def _prov_badge(source: str | None) -> str:
|
| 82 |
s = (source or "").lower()
|
| 83 |
if s == "fallback":
|
| 84 |
-
return '<span class="md-
|
| 85 |
if s in ("serpapi", "openmeteo", "osm"):
|
| 86 |
-
return '<span class="md-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
|
| 90 |
-
def
|
| 91 |
if not pkg.weather:
|
| 92 |
-
return
|
|
|
|
|
|
|
|
|
|
| 93 |
w = pkg.weather[0]
|
| 94 |
icon = _WMO_ICON.get(w.weather_code, "🌡️")
|
| 95 |
return (
|
| 96 |
-
f'<
|
| 97 |
-
f'{w.
|
| 98 |
-
f'{_prov_badge(w.source)}</
|
| 99 |
)
|
| 100 |
|
| 101 |
|
| 102 |
-
def render_card(pkg: ScoredPackage) -> str:
|
| 103 |
-
|
|
|
|
|
|
|
| 104 |
f = pkg.flight
|
| 105 |
h = pkg.hotel
|
| 106 |
-
hotel_html = (
|
| 107 |
-
f'<div class="md-row">🏨 <b>{_e(h.name)}</b> · '
|
| 108 |
-
f"{(h.total_price_cad is not None) and ('$'+format(round(h.total_price_cad), ',')) or 'price n/a'} total · "
|
| 109 |
-
f'{(h.rating is not None) and (f"{h.rating:g}★") or ""} · '
|
| 110 |
-
f'{(h.distance_to_stadium_km is not None) and (f"{h.distance_to_stadium_km:g} km to BC Place") or ""} '
|
| 111 |
-
f"{_prov_badge(h.source)}</div>"
|
| 112 |
-
) if h else '<div class="md-row">🏨 <b>No hotel found</b> — flight-only package</div>'
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
buffer = pkg.arrival_buffer_hours
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
return f"""
|
| 118 |
-
<
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
<
|
| 122 |
-
</div>
|
| 123 |
-
<div class="md-card-body">
|
| 124 |
-
<div class="md-row">✈️ <b>{_e(f.airline)} {_e(f.flight_number)}</b> · {_e(f.origin)}→{_e(f.destination)} ·
|
| 125 |
-
lands {f.arrival_time.strftime('%H:%M')} {_prov_badge(f.source)}</div>
|
| 126 |
-
{hotel_html}
|
| 127 |
-
{_weather_line(pkg)}
|
| 128 |
-
<div class="md-row">⏱️ <b>Arrival buffer:</b> {buf_txt}</div>
|
| 129 |
-
<div class="md-row">🚶 <b>Hotel→stadium:</b> {pkg.hotel_to_stadium_min} min walk · {len(pkg.amenities)} nearby spots</div>
|
| 130 |
</div>
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
"""
|
| 133 |
|
| 134 |
|
| 135 |
def render_cards(result: TripPackageResult) -> str:
|
| 136 |
if not result.packages:
|
| 137 |
-
return '<div class="md-row">No packages could be formed from the available data.</div>'
|
| 138 |
-
cards = "".join(render_card(p) for p in result.packages)
|
| 139 |
-
return f'{_CSS}<div class="md-wrap"><div class="md-cards">{cards}</div></div>'
|
| 140 |
|
| 141 |
|
| 142 |
def render_status_bar(result: TripPackageResult) -> str:
|
| 143 |
cls = result.status
|
| 144 |
-
notices = " · ".join(result.degradation_notices) if result.degradation_notices else "all systems
|
|
|
|
| 145 |
return (
|
| 146 |
-
f'{_CSS}<div class="md-wrap"><
|
| 147 |
-
f"
|
| 148 |
-
f"</div></div>"
|
| 149 |
)
|
| 150 |
|
| 151 |
|
| 152 |
def _js_markers(result: TripPackageResult) -> str:
|
| 153 |
-
"""Build Leaflet JS
|
| 154 |
lines: list[str] = []
|
| 155 |
-
# BC Place stadium
|
| 156 |
lines.append(
|
|
|
|
| 157 |
f"L.marker([{BC_PLACE_LAT},{BC_PLACE_LON}],{{icon:stadiumIcon}})"
|
| 158 |
-
f".addTo(map).bindPopup('<b>BC Place Stadium</b><br>Match venue');"
|
| 159 |
)
|
| 160 |
-
seen: set[tuple[float, float]] =
|
| 161 |
for p in result.packages:
|
| 162 |
h = p.hotel
|
| 163 |
if h and h.latitude and h.longitude and (h.latitude, h.longitude) not in seen:
|
| 164 |
seen.add((h.latitude, h.longitude))
|
|
|
|
| 165 |
name = escape(h.name)
|
| 166 |
lines.append(
|
| 167 |
-
f"
|
| 168 |
-
f".
|
|
|
|
| 169 |
)
|
| 170 |
lines.append(
|
| 171 |
f"L.polyline([[{h.latitude},{h.longitude}],[{BC_PLACE_LAT},{BC_PLACE_LON}]],"
|
| 172 |
-
f"{{color:'
|
| 173 |
)
|
| 174 |
-
# POIs (
|
| 175 |
for p in result.packages:
|
| 176 |
-
for a in (p.amenities or [])[:
|
| 177 |
if a.latitude and a.longitude and (a.latitude, a.longitude) not in seen:
|
| 178 |
seen.add((a.latitude, a.longitude))
|
| 179 |
lines.append(
|
| 180 |
-
f"
|
|
|
|
| 181 |
f".addTo(map).bindPopup('{escape(a.category)} · {escape(a.name)}');"
|
| 182 |
)
|
| 183 |
if p.amenities:
|
| 184 |
break
|
|
|
|
|
|
|
|
|
|
| 185 |
return "\n".join(lines)
|
| 186 |
|
| 187 |
|
|
@@ -195,23 +315,28 @@ def render_map(result: TripPackageResult, leaflet_preloaded: bool = False) -> st
|
|
| 195 |
return f"""
|
| 196 |
{_CSS}
|
| 197 |
<div class="md-wrap">
|
| 198 |
-
{_cdn}
|
| 199 |
-
|
|
|
|
| 200 |
<button class="md-fs-btn" type="button" onclick="window.matchdayFullscreen&&matchdayFullscreen()">⛶ Full screen</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
</div>
|
| 202 |
-
<div id="matchday-map"></div>
|
| 203 |
<script>
|
| 204 |
(function(){{
|
| 205 |
var el=document.getElementById('matchday-map');
|
| 206 |
if(!el||window.L===undefined){{return;}}
|
| 207 |
if(el._lmap){{el._lmap.remove();}}
|
| 208 |
-
var map=L.map('matchday-map').setView([{BC_PLACE_LAT},{BC_PLACE_LON}],15);
|
| 209 |
-
el._lmap=map;
|
| 210 |
-
|
| 211 |
-
L.
|
| 212 |
-
var stadiumIcon=L.divIcon({{html:'🏟️',iconSize:[26,26],iconAnchor:[13,13]}});
|
| 213 |
-
var hotelIcon=L.divIcon({{html:'🏨',iconSize:[22,22],iconAnchor:[11,11]}});
|
| 214 |
-
var poiIcon=L.divIcon({{html:'📍',iconSize:[16,16],iconAnchor:[8,8]}});
|
| 215 |
{_js_markers(result)}
|
| 216 |
}})();
|
| 217 |
</script>
|
|
@@ -220,11 +345,7 @@ def render_map(result: TripPackageResult, leaflet_preloaded: bool = False) -> st
|
|
| 220 |
|
| 221 |
|
| 222 |
def render_timeline(trip, result: TripPackageResult) -> str:
|
| 223 |
-
"""Day-by-day
|
| 224 |
-
|
| 225 |
-
Uses the top package's flight/hotel to anchor Day 1; the match day is the
|
| 226 |
-
highlighted centerpiece.
|
| 227 |
-
"""
|
| 228 |
from datetime import timedelta
|
| 229 |
|
| 230 |
top = result.packages[0] if result.packages else None
|
|
@@ -235,46 +356,50 @@ def render_timeline(trip, result: TripPackageResult) -> str:
|
|
| 235 |
idx += 1
|
| 236 |
if d == trip.check_in and top:
|
| 237 |
hotel = top.hotel.name if top.hotel else "your hotel"
|
| 238 |
-
icon, title, body, cls = (
|
| 239 |
-
"✈️", f"Day {idx} · {d:%a %b %d}",
|
| 240 |
-
f"
|
| 241 |
-
f"({_e(top.flight.origin)}→YVR
|
| 242 |
-
f"check in to {_e(hotel)}.",
|
| 243 |
"",
|
| 244 |
)
|
| 245 |
elif d == trip.match_date:
|
| 246 |
-
icon, title, body, cls = (
|
| 247 |
-
"🏟️", f"Day {idx} · {d:%a %b %d} — MATCH DAY",
|
| 248 |
-
f"<b>{_e(trip.match_name)}</b> at BC Place
|
| 249 |
-
"
|
| 250 |
-
"match",
|
| 251 |
)
|
| 252 |
elif d < trip.match_date:
|
| 253 |
-
icon, title, body, cls = (
|
| 254 |
-
"🏙️", f"Day {idx} · {d:%a %b %d}",
|
| 255 |
-
"
|
| 256 |
"",
|
| 257 |
)
|
| 258 |
else:
|
| 259 |
-
icon, title, body, cls = (
|
| 260 |
-
"🗺️", f"Day {idx} · {d:%a %b %d}",
|
| 261 |
-
"
|
| 262 |
"",
|
| 263 |
)
|
| 264 |
items.append(
|
| 265 |
-
f'<div class="md-
|
| 266 |
-
f'<div
|
| 267 |
-
f'<div class="md-
|
|
|
|
| 268 |
)
|
| 269 |
d += timedelta(days=1)
|
| 270 |
return (
|
| 271 |
-
f'{_CSS}<div class="md-wrap">
|
| 272 |
-
f'<div class="md-
|
| 273 |
)
|
| 274 |
|
| 275 |
|
| 276 |
def render_full(result: TripPackageResult, trip=None, leaflet_preloaded: bool = False) -> str:
|
| 277 |
-
out =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
if trip is not None:
|
| 279 |
out += render_timeline(trip, result)
|
| 280 |
return out
|
|
|
|
| 1 |
+
"""HTML rendering for MatchDay — premium, Layla-competitive cards + Leaflet map.
|
| 2 |
|
| 3 |
+
Pure functions: a ``TripPackageResult`` -> HTML strings for the result panel.
|
| 4 |
Every price / hotel / weather reading carries a provenance badge (I4) so judges
|
| 5 |
can verify nothing is hallucinated: "● live" (serpapi/openmeteo/osm) vs
|
| 6 |
+
"example" (fallback). The look is a refined travel-product aesthetic
|
| 7 |
+
(Off-Brand spirit) — white surfaces, layered soft shadows, restrained accent
|
| 8 |
+
pops, generous whitespace, a flat CARTO map — not stock Gradio chrome.
|
| 9 |
"""
|
| 10 |
from __future__ import annotations
|
| 11 |
|
|
|
|
| 18 |
BC_PLACE_LAT = 49.2827
|
| 19 |
BC_PLACE_LON = -123.1207
|
| 20 |
|
| 21 |
+
# Per-label accent + matching soft tint (avoids color-mix for max compatibility).
|
| 22 |
_LABEL_COLOR = {
|
| 23 |
"Cheapest": "#16a34a",
|
| 24 |
"Safest Arrival": "#2563eb",
|
| 25 |
+
"Closest to Stadium": "#7c3aed",
|
| 26 |
+
}
|
| 27 |
+
_LABEL_TINT = {
|
| 28 |
+
"Cheapest": "#dcfce7",
|
| 29 |
+
"Safest Arrival": "#dbeafe",
|
| 30 |
+
"Closest to Stadium": "#ede9fe",
|
| 31 |
+
}
|
| 32 |
+
_LABEL_ICON = {
|
| 33 |
+
"Cheapest": "💰",
|
| 34 |
+
"Safest Arrival": "🛬",
|
| 35 |
+
"Closest to Stadium": "📍",
|
| 36 |
}
|
| 37 |
|
| 38 |
# WMO weather code -> emoji (subset; unknown -> 🌡️).
|
|
|
|
| 43 |
95: "⛈️", 96: "⛈️", 99: "⛈️",
|
| 44 |
}
|
| 45 |
|
|
|
|
| 46 |
_CSS = """
|
| 47 |
<style>
|
| 48 |
+
.md-wrap{font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#111827;-webkit-font-smoothing:antialiased;}
|
| 49 |
+
|
| 50 |
+
/* ── Provenance pills (the "no hallucinated prices" differentiator) ── */
|
| 51 |
+
.md-pv{display:inline-flex;align-items:center;gap:4px;font-size:10px;font-weight:800;padding:1px 7px 1px 5px;
|
| 52 |
+
border-radius:999px;letter-spacing:.03em;text-transform:uppercase;vertical-align:middle;white-space:nowrap;}
|
| 53 |
+
.md-pv::before{content:"";width:6px;height:6px;border-radius:50%;display:inline-block;}
|
| 54 |
+
.md-pv-live{background:#ecfdf5;color:#047857;}
|
| 55 |
+
.md-pv-live::before{background:#10b981;box-shadow:0 0 0 2px rgba(16,185,129,.2);}
|
| 56 |
+
.md-pv-ex{background:#fffbeb;color:#b45309;}
|
| 57 |
+
.md-pv-ex::before{background:#f59e0b;}
|
| 58 |
+
.md-pv-other{background:#f3f4f6;color:#4b5563;}
|
| 59 |
+
.md-pv-other::before{background:#9ca3af;}
|
| 60 |
+
|
| 61 |
+
/* ── Section header ── */
|
| 62 |
+
.md-sec-h{display:flex;align-items:center;gap:12px;margin:26px 2px 14px;}
|
| 63 |
+
.md-sec-h .md-sec-t{font-size:12.5px;font-weight:800;letter-spacing:.07em;text-transform:uppercase;color:#6b7280;white-space:nowrap;}
|
| 64 |
+
.md-sec-h .md-sec-line{flex:1;height:1px;background:linear-gradient(90deg,#eef0f3,transparent);}
|
| 65 |
+
|
| 66 |
+
/* ── Status pill ── */
|
| 67 |
+
.md-status{display:inline-flex;align-items:center;gap:8px;font-size:12.5px;font-weight:700;padding:7px 14px;
|
| 68 |
+
border-radius:999px;margin:2px 0 4px;}
|
| 69 |
+
.md-status.complete{background:#ecfdf5;color:#047857;border:1px solid #a7f3d0;}
|
| 70 |
+
.md-status.partial{background:#fffbeb;color:#b45309;border:1px solid #fde68a;}
|
| 71 |
+
.md-status.failed{background:#fef2f2;color:#b91c1c;border:1px solid #fecaca;}
|
| 72 |
+
.md-status .md-dot{width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 0 3px rgba(16,185,129,.15);}
|
| 73 |
+
.md-status .md-status-sub{font-weight:500;opacity:.85;margin-left:2px;}
|
| 74 |
+
|
| 75 |
+
/* ── Package cards ── */
|
| 76 |
+
.md-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(290px,1fr));gap:16px;margin:6px 0 2px;}
|
| 77 |
+
.md-card{position:relative;background:#fff;border:1px solid #eef0f3;border-radius:18px;overflow:hidden;
|
| 78 |
+
box-shadow:0 1px 2px rgba(17,24,39,.04),0 8px 22px -12px rgba(17,24,39,.16);
|
| 79 |
+
display:flex;flex-direction:column;transition:transform .18s ease,box-shadow .18s ease;}
|
| 80 |
+
.md-card:hover{transform:translateY(-2px);box-shadow:0 2px 6px rgba(17,24,39,.06),0 16px 34px -14px rgba(17,24,39,.22);}
|
| 81 |
+
.md-card::before{content:"";position:absolute;left:0;top:0;bottom:0;width:5px;background:var(--accent,#7c3aed);}
|
| 82 |
+
.md-card.is-top{border-color:#ddd6fe;box-shadow:0 2px 6px rgba(124,58,237,.10),0 20px 42px -14px rgba(124,58,237,.32);}
|
| 83 |
+
.md-topflag{position:absolute;top:15px;right:15px;background:linear-gradient(135deg,#7c3aed,#6d28d9);color:#fff;
|
| 84 |
+
font-size:10px;font-weight:800;letter-spacing:.04em;padding:4px 10px 4px 8px;border-radius:999px;
|
| 85 |
+
box-shadow:0 6px 14px -4px rgba(124,58,237,.5);display:inline-flex;align-items:center;gap:4px;}
|
| 86 |
+
|
| 87 |
+
.md-card-top{display:flex;align-items:center;gap:9px;padding:17px 18px 0 22px;}
|
| 88 |
+
.md-chip{display:inline-flex;align-items:center;gap:6px;background:var(--tint,#ede9fe);color:var(--accent,#7c3aed);
|
| 89 |
+
font-size:11px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;padding:5px 11px;border-radius:999px;}
|
| 90 |
+
.md-chip .md-chip-ico{font-size:13px;}
|
| 91 |
+
|
| 92 |
+
.md-price{display:flex;align-items:baseline;gap:5px;margin:9px 0 0 22px;}
|
| 93 |
+
.md-cur{font-size:15px;font-weight:700;color:#9ca3af;}
|
| 94 |
+
.md-amount{font-size:33px;font-weight:800;letter-spacing:-.025em;color:#111827;line-height:1;}
|
| 95 |
+
.md-price-sub{color:#9ca3af;font-size:11.5px;font-weight:600;margin:4px 0 0 22px;}
|
| 96 |
+
|
| 97 |
+
.md-facts{list-style:none;margin:15px 0 0;padding:0 22px 18px;display:grid;gap:12px;}
|
| 98 |
+
.md-facts li{display:flex;gap:12px;align-items:flex-start;font-size:13.5px;line-height:1.4;}
|
| 99 |
+
.md-facts .md-fico{font-size:16px;flex:0 0 22px;text-align:center;margin-top:1px;line-height:1;}
|
| 100 |
+
.md-facts .md-fbody{min-width:0;}
|
| 101 |
+
.md-facts .md-fbody b{color:#111827;font-weight:700;display:block;}
|
| 102 |
+
.md-facts .md-fbody .md-fdet{display:block;color:#6b7280;font-size:12.5px;margin-top:2px;}
|
| 103 |
+
|
| 104 |
+
/* ── Map ── */
|
| 105 |
+
.md-map-wrap{position:relative;border:1px solid #eef0f3;border-radius:18px;overflow:hidden;
|
| 106 |
+
box-shadow:0 1px 2px rgba(17,24,39,.04),0 8px 22px -12px rgba(17,24,39,.16);}
|
| 107 |
+
#matchday-map{height:430px;background:#eef0f3;}
|
| 108 |
+
.md-map-tag{position:absolute;top:12px;left:12px;z-index:500;background:#fff;border:1px solid #eef0f3;border-radius:999px;
|
| 109 |
+
padding:6px 13px;font-size:12px;font-weight:700;color:#374151;box-shadow:0 2px 10px rgba(17,24,39,.10);
|
| 110 |
+
display:inline-flex;align-items:center;gap:8px;}
|
| 111 |
+
.md-mdot{width:9px;height:9px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px rgba(17,24,39,.18);}
|
| 112 |
+
.md-fs-btn{position:absolute;top:12px;right:12px;z-index:500;background:#fff;border:1px solid #eef0f3;border-radius:10px;
|
| 113 |
+
padding:7px 12px;font:inherit;font-size:12px;font-weight:700;color:#374151;cursor:pointer;
|
| 114 |
+
box-shadow:0 2px 10px rgba(17,24,39,.10);display:inline-flex;align-items:center;gap:5px;}
|
| 115 |
+
.md-fs-btn:hover{background:#f7f8fa;}
|
| 116 |
+
.md-legend{display:flex;gap:18px;flex-wrap:wrap;margin:13px 3px 0;font-size:12px;color:#6b7280;}
|
| 117 |
+
.md-legend span{display:inline-flex;align-items:center;gap:7px;}
|
| 118 |
+
|
| 119 |
+
/* leaflet marker polish */
|
| 120 |
+
.md-pin{width:18px;height:18px;border-radius:50%;background:var(--c,#7c3aed);border:3px solid #fff;
|
| 121 |
+
box-shadow:0 2px 6px rgba(17,24,39,.35);}
|
| 122 |
+
.md-pin-stadium{width:30px;height:30px;background:#111827;border:4px solid #fff;display:flex;align-items:center;
|
| 123 |
+
justify-content:center;font-size:15px;}
|
| 124 |
+
.leaflet-popup-content-wrapper{border-radius:12px;box-shadow:0 8px 24px -8px rgba(17,24,39,.25);}
|
| 125 |
+
.leaflet-popup-content{margin:10px 13px;font-size:13px;font-family:Inter,sans-serif;}
|
| 126 |
+
.leaflet-control-zoom a{border-radius:8px!important;}
|
| 127 |
+
|
| 128 |
+
/* full-screen map (toggled from index.html) */
|
| 129 |
+
#matchday-map.fs{position:fixed!important;inset:0!important;z-index:99998!important;height:100vh!important;
|
| 130 |
+
width:100vw!important;border-radius:0!important;}
|
| 131 |
+
|
| 132 |
+
/* ── Day-by-day timeline ── */
|
| 133 |
+
.md-tl{display:grid;gap:11px;margin-top:6px;}
|
| 134 |
+
.md-day{position:relative;background:#fff;border:1px solid #eef0f3;border-radius:16px;padding:14px 16px 14px 60px;
|
| 135 |
+
box-shadow:0 1px 2px rgba(17,24,39,.04);transition:box-shadow .18s ease;}
|
| 136 |
+
.md-day:hover{box-shadow:0 4px 14px -8px rgba(17,24,39,.18);}
|
| 137 |
+
.md-day-ico{position:absolute;left:14px;top:14px;width:32px;height:32px;border-radius:10px;background:#f3f4f6;
|
| 138 |
+
display:flex;align-items:center;justify-content:center;font-size:16px;}
|
| 139 |
+
.md-day-lbl{font-size:10.5px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:#9ca3af;}
|
| 140 |
+
.md-day-title{font-size:15px;font-weight:700;color:#111827;margin:2px 0 4px;letter-spacing:-.01em;}
|
| 141 |
+
.md-day-body{font-size:13px;color:#6b7280;line-height:1.5;}
|
| 142 |
+
.md-day-body b{color:#374151;}
|
| 143 |
+
.md-day.is-match{border-color:#bbf7d0;background:linear-gradient(180deg,#f0fdf4,#fff 60%);
|
| 144 |
+
box-shadow:0 2px 8px rgba(16,185,129,.12);}
|
| 145 |
+
.md-day.is-match .md-day-ico{background:#dcfce7;}
|
| 146 |
+
.md-day.is-match .md-day-lbl{color:#047857;}
|
| 147 |
+
.md-day.is-match .md-day-title{color:#065f46;}
|
| 148 |
+
|
| 149 |
+
/* ── Explanation (Nemotron's comparison) ── */
|
| 150 |
+
.md-explain{background:linear-gradient(135deg,#eef2ff,#f5f3ff);border:1px solid #ddd6fe;border-radius:16px;
|
| 151 |
+
padding:16px 18px;margin-top:6px;color:#3730a3;font-size:13.5px;line-height:1.6;}
|
| 152 |
+
.md-explain b{color:#4c1d95;}
|
| 153 |
+
.md-explain-h{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:800;letter-spacing:.04em;
|
| 154 |
+
text-transform:uppercase;color:#6d28d9;margin-bottom:8px;}
|
| 155 |
</style>
|
| 156 |
"""
|
| 157 |
|
|
|
|
| 163 |
def _prov_badge(source: str | None) -> str:
|
| 164 |
s = (source or "").lower()
|
| 165 |
if s == "fallback":
|
| 166 |
+
return '<span class="md-pv md-pv-ex">example</span>'
|
| 167 |
if s in ("serpapi", "openmeteo", "osm"):
|
| 168 |
+
return '<span class="md-pv md-pv-live">live</span>'
|
| 169 |
+
if not s:
|
| 170 |
+
return ""
|
| 171 |
+
return f'<span class="md-pv md-pv-other">{_e(source)}</span>'
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _section(title: str) -> str:
|
| 175 |
+
return f'<div class="md-sec-h"><span class="md-sec-t">{_e(title)}</span><span class="md-sec-line"></span></div>'
|
| 176 |
|
| 177 |
|
| 178 |
+
def _weather_fact(pkg: ScoredPackage) -> str:
|
| 179 |
if not pkg.weather:
|
| 180 |
+
return (
|
| 181 |
+
'<li><span class="md-fico">🌤️</span><span class="md-fbody"><b>Forecast unavailable</b>'
|
| 182 |
+
'<span class="md-fdet">match-day weather could not be loaded</span></span></li>'
|
| 183 |
+
)
|
| 184 |
w = pkg.weather[0]
|
| 185 |
icon = _WMO_ICON.get(w.weather_code, "🌡️")
|
| 186 |
return (
|
| 187 |
+
f'<li><span class="md-fico">{icon}</span><span class="md-fbody"><b>{w.temp_min_c:g}–{w.temp_max_c:g}°C</b>'
|
| 188 |
+
f'<span class="md-fdet">{_e(w.date)} · match day · {w.precipitation_probability:g}% rain '
|
| 189 |
+
f'{_prov_badge(w.source)}</span></span></li>'
|
| 190 |
)
|
| 191 |
|
| 192 |
|
| 193 |
+
def render_card(pkg: ScoredPackage, is_top: bool = False) -> str:
|
| 194 |
+
accent = _LABEL_COLOR.get(pkg.label, "#7c3aed")
|
| 195 |
+
tint = _LABEL_TINT.get(pkg.label, "#ede9fe")
|
| 196 |
+
icon = _LABEL_ICON.get(pkg.label, "✅")
|
| 197 |
f = pkg.flight
|
| 198 |
h = pkg.hotel
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
|
| 200 |
+
# Hotel fact
|
| 201 |
+
if h:
|
| 202 |
+
bits = []
|
| 203 |
+
if h.total_price_cad is not None:
|
| 204 |
+
bits.append(f"${format(round(h.total_price_cad), ',')} total")
|
| 205 |
+
if h.rating is not None:
|
| 206 |
+
bits.append(f"{h.rating:g}★")
|
| 207 |
+
if h.distance_to_stadium_km is not None:
|
| 208 |
+
bits.append(f"{h.distance_to_stadium_km:g} km to BC Place")
|
| 209 |
+
hotel_fact = (
|
| 210 |
+
f'<li><span class="md-fico">🏨</span><span class="md-fbody"><b>{_e(h.name)}</b>'
|
| 211 |
+
f'<span class="md-fdet">{" · ".join(bits)} {_prov_badge(h.source)}</span></span></li>'
|
| 212 |
+
)
|
| 213 |
+
else:
|
| 214 |
+
hotel_fact = (
|
| 215 |
+
'<li><span class="md-fico">🏨</span><span class="md-fbody"><b>Flight-only package</b>'
|
| 216 |
+
'<span class="md-fdet">no hotel bundled for this option</span></span></li>'
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
# Arrival buffer
|
| 220 |
buffer = pkg.arrival_buffer_hours
|
| 221 |
+
if buffer >= 0:
|
| 222 |
+
buf_b, buf_d = f"+{buffer:g}h before kickoff", "arrival buffer"
|
| 223 |
+
else:
|
| 224 |
+
buf_b, buf_d = f"{buffer:g}h — lands AFTER kickoff", "⚠️ risky arrival"
|
| 225 |
+
|
| 226 |
+
topflag = '<span class="md-topflag">★ Best match</span>' if is_top else ""
|
| 227 |
|
| 228 |
return f"""
|
| 229 |
+
<article class="md-card{' is-top' if is_top else ''}" style="--accent:{accent};--tint:{tint}">
|
| 230 |
+
{topflag}
|
| 231 |
+
<div class="md-card-top">
|
| 232 |
+
<span class="md-chip"><span class="md-chip-ico">{icon}</span>{_e(pkg.label)}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
</div>
|
| 234 |
+
<div class="md-price"><span class="md-cur">CA$</span><span class="md-amount">{pkg.total_cost_cad:,.0f}</span></div>
|
| 235 |
+
<div class="md-price-sub">CAD · total for the trip</div>
|
| 236 |
+
<ul class="md-facts">
|
| 237 |
+
<li><span class="md-fico">✈️</span><span class="md-fbody"><b>{_e(f.airline)} {_e(f.flight_number)}</b>
|
| 238 |
+
<span class="md-fdet">{_e(f.origin)} → {_e(f.destination)} · lands {f.arrival_time:%H:%M} {_prov_badge(f.source)}</span></span></li>
|
| 239 |
+
{hotel_fact}
|
| 240 |
+
{_weather_fact(pkg)}
|
| 241 |
+
<li><span class="md-fico">⏱️</span><span class="md-fbody"><b>{buf_b}</b><span class="md-fdet">{buf_d}</span></span></li>
|
| 242 |
+
<li><span class="md-fico">🚶</span><span class="md-fbody"><b>{pkg.hotel_to_stadium_min} min walk</b>
|
| 243 |
+
<span class="md-fdet">hotel → BC Place · {len(pkg.amenities)} nearby spots</span></span></li>
|
| 244 |
+
</ul>
|
| 245 |
+
</article>
|
| 246 |
"""
|
| 247 |
|
| 248 |
|
| 249 |
def render_cards(result: TripPackageResult) -> str:
|
| 250 |
if not result.packages:
|
| 251 |
+
return '<div class="md-row" style="padding:14px;color:#6b7280">No packages could be formed from the available data.</div>'
|
| 252 |
+
cards = "".join(render_card(p, is_top=(i == 0)) for i, p in enumerate(result.packages))
|
| 253 |
+
return f'{_CSS}<div class="md-wrap">{_section("Your 3 ranked packages")}<div class="md-cards">{cards}</div></div>'
|
| 254 |
|
| 255 |
|
| 256 |
def render_status_bar(result: TripPackageResult) -> str:
|
| 257 |
cls = result.status
|
| 258 |
+
notices = " · ".join(result.degradation_notices) if result.degradation_notices else "all systems live"
|
| 259 |
+
label = {"complete": "Live data", "partial": "Partial data", "failed": "Degraded"}.get(cls, cls)
|
| 260 |
return (
|
| 261 |
+
f'{_CSS}<div class="md-wrap"><span class="md-status {cls}"><span class="md-dot"></span>{_e(label)}'
|
| 262 |
+
f'<span class="md-status-sub">· {len(result.packages)} packages · {_e(notices)}</span></span></div>'
|
|
|
|
| 263 |
)
|
| 264 |
|
| 265 |
|
| 266 |
def _js_markers(result: TripPackageResult) -> str:
|
| 267 |
+
"""Build Leaflet JS: stadium + hotel + POI markers + hotel→stadium lines."""
|
| 268 |
lines: list[str] = []
|
|
|
|
| 269 |
lines.append(
|
| 270 |
+
f"var bb=[[{BC_PLACE_LAT},{BC_PLACE_LON}]];"
|
| 271 |
f"L.marker([{BC_PLACE_LAT},{BC_PLACE_LON}],{{icon:stadiumIcon}})"
|
| 272 |
+
f".addTo(map).bindPopup('<b>🏟️ BC Place Stadium</b><br>Match venue · 7:00 PM PT');"
|
| 273 |
)
|
| 274 |
+
seen: set[tuple[float, float]] = {(BC_PLACE_LAT, BC_PLACE_LON)}
|
| 275 |
for p in result.packages:
|
| 276 |
h = p.hotel
|
| 277 |
if h and h.latitude and h.longitude and (h.latitude, h.longitude) not in seen:
|
| 278 |
seen.add((h.latitude, h.longitude))
|
| 279 |
+
accent = _LABEL_COLOR.get(p.label, "#7c3aed")
|
| 280 |
name = escape(h.name)
|
| 281 |
lines.append(
|
| 282 |
+
f"bb.push([{h.latitude},{h.longitude}]);"
|
| 283 |
+
f"L.marker([{h.latitude},{h.longitude}],{{icon:L.divIcon({{className:'',html:'<div class=\\\"md-pin\\\" style=\\\"--c:{accent}\\\"></div>',iconSize:[18,18],iconAnchor:[9,9],popupAnchor:[0,-8]}})}})"
|
| 284 |
+
f".addTo(map).bindPopup('<b>🏨 {name}</b><br>{escape(p.label)}');"
|
| 285 |
)
|
| 286 |
lines.append(
|
| 287 |
f"L.polyline([[{h.latitude},{h.longitude}],[{BC_PLACE_LAT},{BC_PLACE_LON}]],"
|
| 288 |
+
f"{{color:'{accent}',weight:2,opacity:.7,dashArray:'1 7',lineCap:'round'}}).addTo(map);"
|
| 289 |
)
|
| 290 |
+
# POIs (first package that has them)
|
| 291 |
for p in result.packages:
|
| 292 |
+
for a in (p.amenities or [])[:18]:
|
| 293 |
if a.latitude and a.longitude and (a.latitude, a.longitude) not in seen:
|
| 294 |
seen.add((a.latitude, a.longitude))
|
| 295 |
lines.append(
|
| 296 |
+
f"bb.push([{a.latitude},{a.longitude}]);"
|
| 297 |
+
f"L.marker([{a.latitude},{a.longitude}],{{icon:L.divIcon({{className:'',html:'<div class=\\\"md-pin\\\" style=\\\"--c:#9ca3af;width:10px;height:10px;border-width:2px\\\"></div>',iconSize:[10,10],iconAnchor:[5,5],popupAnchor:[0,-6]}})}})"
|
| 298 |
f".addTo(map).bindPopup('{escape(a.category)} · {escape(a.name)}');"
|
| 299 |
)
|
| 300 |
if p.amenities:
|
| 301 |
break
|
| 302 |
+
lines.append(
|
| 303 |
+
"try{map.fitBounds(bb,{padding:[34,34],maxZoom:16});}catch(e){}"
|
| 304 |
+
)
|
| 305 |
return "\n".join(lines)
|
| 306 |
|
| 307 |
|
|
|
|
| 315 |
return f"""
|
| 316 |
{_CSS}
|
| 317 |
<div class="md-wrap">
|
| 318 |
+
{_cdn}{_section("On the map")}
|
| 319 |
+
<div class="md-map-wrap">
|
| 320 |
+
<span class="md-map-tag"><span class="md-mdot" style="background:#111827"></span>BC Place</span>
|
| 321 |
<button class="md-fs-btn" type="button" onclick="window.matchdayFullscreen&&matchdayFullscreen()">⛶ Full screen</button>
|
| 322 |
+
<div id="matchday-map"></div>
|
| 323 |
+
</div>
|
| 324 |
+
<div class="md-legend">
|
| 325 |
+
<span><span class="md-mdot" style="background:#111827"></span>BC Place Stadium</span>
|
| 326 |
+
<span><span class="md-mdot" style="background:#16a34a"></span>Cheapest hotel</span>
|
| 327 |
+
<span><span class="md-mdot" style="background:#2563eb"></span>Safest-arrival hotel</span>
|
| 328 |
+
<span><span class="md-mdot" style="background:#7c3aed"></span>Closest hotel</span>
|
| 329 |
+
<span><span class="md-mdot" style="background:#9ca3af"></span>Nearby spots</span>
|
| 330 |
</div>
|
|
|
|
| 331 |
<script>
|
| 332 |
(function(){{
|
| 333 |
var el=document.getElementById('matchday-map');
|
| 334 |
if(!el||window.L===undefined){{return;}}
|
| 335 |
if(el._lmap){{el._lmap.remove();}}
|
| 336 |
+
var map=L.map('matchday-map',{{scrollWheelZoom:true,zoomControl:true}}).setView([{BC_PLACE_LAT},{BC_PLACE_LON}],15);
|
| 337 |
+
el._lmap=map; window._matchdayMap=map;
|
| 338 |
+
L.tileLayer('https://{{s}}.basemaps.cartocdn.com/light_all/{{z}}/{{x}}/{{y}}.png',{{subdomains:'abcd',maxZoom:20,attribution:'&copy; OpenStreetMap &copy; CARTO'}}).addTo(map);
|
| 339 |
+
var stadiumIcon=L.divIcon({{className:'',html:'<div class="md-pin md-pin-stadium">🏟️</div>',iconSize:[30,30],iconAnchor:[15,15],popupAnchor:[0,-12]}});
|
|
|
|
|
|
|
|
|
|
| 340 |
{_js_markers(result)}
|
| 341 |
}})();
|
| 342 |
</script>
|
|
|
|
| 345 |
|
| 346 |
|
| 347 |
def render_timeline(trip, result: TripPackageResult) -> str:
|
| 348 |
+
"""Day-by-day itinerary (A2): arrival → match day → explore."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
from datetime import timedelta
|
| 350 |
|
| 351 |
top = result.packages[0] if result.packages else None
|
|
|
|
| 356 |
idx += 1
|
| 357 |
if d == trip.check_in and top:
|
| 358 |
hotel = top.hotel.name if top.hotel else "your hotel"
|
| 359 |
+
icon, lbl, title, body, cls = (
|
| 360 |
+
"✈️", "Arrival", f"Day {idx} · {d:%a %b %d}",
|
| 361 |
+
f"Land via <b>{_e(top.flight.airline)} {_e(top.flight.flight_number)}</b> "
|
| 362 |
+
f"({_e(top.flight.origin)} → YVR) and check in to <b>{_e(hotel)}</b>. Settle in, grab dinner downtown.",
|
|
|
|
| 363 |
"",
|
| 364 |
)
|
| 365 |
elif d == trip.match_date:
|
| 366 |
+
icon, lbl, title, body, cls = (
|
| 367 |
+
"🏟️", "Match day", f"Day {idx} · {d:%a %b %d} — MATCH DAY",
|
| 368 |
+
f"<b>{_e(trip.match_name)}</b> at BC Place, 7:00 PM PT. Hit the fan zone first, "
|
| 369 |
+
"then walk from your hotel — it's minutes away.",
|
| 370 |
+
"is-match",
|
| 371 |
)
|
| 372 |
elif d < trip.match_date:
|
| 373 |
+
icon, lbl, title, body, cls = (
|
| 374 |
+
"🏙️", "Explore", f"Day {idx} · {d:%a %b %d}",
|
| 375 |
+
"Stanley Park seawall, Granville Island, Gastown — Vancouver is built for walking.",
|
| 376 |
"",
|
| 377 |
)
|
| 378 |
else:
|
| 379 |
+
icon, lbl, title, body, cls = (
|
| 380 |
+
"🗺️", "Heading home", f"Day {idx} · {d:%a %b %d}",
|
| 381 |
+
"Brunch and last sights, then back to YVR. Safe flight home.",
|
| 382 |
"",
|
| 383 |
)
|
| 384 |
items.append(
|
| 385 |
+
f'<div class="md-day {cls}"><div class="md-day-ico">{icon}</div>'
|
| 386 |
+
f'<div class="md-day-lbl">{lbl}</div>'
|
| 387 |
+
f'<div class="md-day-title">{title}</div>'
|
| 388 |
+
f'<div class="md-day-body">{body}</div></div>'
|
| 389 |
)
|
| 390 |
d += timedelta(days=1)
|
| 391 |
return (
|
| 392 |
+
f'{_CSS}<div class="md-wrap">{_section("Day-by-day itinerary")}'
|
| 393 |
+
f'<div class="md-tl">{"".join(items)}</div></div>'
|
| 394 |
)
|
| 395 |
|
| 396 |
|
| 397 |
def render_full(result: TripPackageResult, trip=None, leaflet_preloaded: bool = False) -> str:
|
| 398 |
+
out = (
|
| 399 |
+
render_status_bar(result)
|
| 400 |
+
+ render_cards(result)
|
| 401 |
+
+ render_map(result, leaflet_preloaded=leaflet_preloaded)
|
| 402 |
+
)
|
| 403 |
if trip is not None:
|
| 404 |
out += render_timeline(trip, result)
|
| 405 |
return out
|