Spaces:
Paused
Paused
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>MicroClimate-X — Hybrid Microclimate Risk</title> | |
| <meta name="description" content="Intelligent meteorological analysis for complex terrain." /> | |
| <!-- CDN deps --> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> | |
| <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> | |
| <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script> | |
| <style> | |
| :root { color-scheme: dark; } | |
| body { background:#0b0f17; color:#cbd5e1; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; } | |
| .mono { font-family: ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace; } | |
| .panel { background:#111827; border:1px solid #1f2937; border-radius:14px; } | |
| .leaflet-container { background:#0b0f17; } | |
| .ring-gauge { transition: stroke-dashoffset 0.8s ease, stroke 0.4s ease; } | |
| .log-line { animation: fadeUp 0.25s ease-out both; } | |
| @keyframes fadeUp { from { opacity:0; transform: translateY(4px); } to { opacity:1; transform:none; } } | |
| .veto-row { animation: blink 1.2s ease-in-out infinite; } | |
| @keyframes blink { 0%,100%{opacity:1} 50%{opacity:.55} } | |
| .kbd { background:#1f2937; border:1px solid #374151; padding:1px 6px; border-radius:6px; font-size:11px; } | |
| .mini-gauge-svg { transition: stroke-dashoffset 0.6s ease, stroke 0.4s ease; } | |
| .pill { padding: 4px 10px; border-radius: 999px; font-size: 11px; border:1px solid #374151; | |
| color:#94a3b8; transition: background .15s, color .15s, border-color .15s; cursor:pointer; } | |
| .pill:hover { color:#e2e8f0; } | |
| .pill:focus-visible { outline: 2px solid #34d399; outline-offset: 2px; } | |
| .pill-active { background:#34d399; color:#052e2b; border-color:#34d399; font-weight:600; } | |
| .rule-badge { padding:2px 6px; font-size:10px; border-radius:6px; background:#1f2937; | |
| border:1px solid #374151; color:#94a3b8; font-family: ui-monospace, monospace; } | |
| .rule-badge-fired { background: #422006; border-color: #b45309; color: #fbbf24; } | |
| /* Loading spinner — overlays panels while a request is in flight. */ | |
| .spinner { width:18px; height:18px; border:2px solid #1f2937; border-top-color:#34d399; | |
| border-radius:50%; animation: spin 0.75s linear infinite; display:inline-block; } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| /* Toast — bottom-right notification stack. */ | |
| #toast-host { position: fixed; bottom: 18px; right: 18px; z-index: 9999; | |
| display: flex; flex-direction: column; gap: 8px; pointer-events: none; } | |
| .toast { pointer-events: auto; min-width: 240px; max-width: 360px; | |
| background:#1f2937; border:1px solid #374151; border-radius:10px; | |
| padding: 10px 14px; color:#e2e8f0; font-size:12px; | |
| box-shadow: 0 6px 16px rgba(0,0,0,.5); | |
| animation: slideIn .25s ease-out; } | |
| .toast-err { border-color: #b91c1c; background:#2a0f10; } | |
| .toast-ok { border-color: #047857; background:#062b22; } | |
| @keyframes slideIn { from { transform: translateX(20px); opacity:0; } } | |
| /* Mobile-first compaction. */ | |
| @media (max-width: 640px) { | |
| .pill { padding: 3px 7px; font-size: 10px; } | |
| .activity-bar { gap: 4px; } | |
| } | |
| </style> | |
| </head> | |
| <body class="min-h-screen"> | |
| <div id="app" class="min-h-screen flex flex-col"> | |
| <!-- ─── Header ─────────────────────────────────────── --> | |
| <header class="border-b border-slate-800 px-5 py-3 flex items-center justify-between"> | |
| <div class="flex items-center gap-3"> | |
| <div class="w-9 h-9 rounded-lg bg-emerald-500/15 ring-1 ring-emerald-400/40 flex items-center justify-center text-emerald-300 font-bold">μ</div> | |
| <div> | |
| <div class="text-slate-100 font-semibold tracking-tight">MicroClimate-X</div> | |
| <div class="text-[11px] text-slate-500 mono">Hybrid Microclimate Risk Engine · UKM FYP</div> | |
| </div> | |
| </div> | |
| <div class="flex items-center gap-3 flex-wrap activity-bar"> | |
| <!-- Demo scenarios --> | |
| <select v-model="selectedScenario" | |
| @change="onScenarioChange" | |
| class="bg-slate-800 text-slate-200 border border-slate-700 rounded-md text-[11px] px-2 py-1 focus:outline-none focus:ring-1 focus:ring-emerald-400" | |
| :aria-label="t.scenarios"> | |
| <option value="" disabled>{{ t.scenarios }}</option> | |
| <option v-for="s in SCENARIOS" :key="s.key" :value="s.key">{{ t.scenarioLabels[s.key] }}</option> | |
| </select> | |
| <!-- Activity selector --> | |
| <div class="flex items-center gap-1"> | |
| <span class="text-[11px] text-slate-500 mr-1 hidden md:inline">{{ t.activity }}</span> | |
| <button v-for="a in ACTIVITIES" :key="a" | |
| @click="setActivity(a)" | |
| :aria-label="t.activities[a]" | |
| :aria-pressed="activity===a" | |
| :class="['pill', activity===a && 'pill-active']"> | |
| {{ t.activities[a] }} | |
| </button> | |
| </div> | |
| <span class="text-slate-700">|</span> | |
| <button @click="lang='en'" :class="['px-2.5 py-1 rounded text-xs ring-1 ring-slate-700', | |
| lang==='en' ? 'bg-emerald-500/20 text-emerald-300 ring-emerald-500/40' : 'text-slate-400 hover:text-slate-200']">EN</button> | |
| <button @click="lang='zh'" :class="['px-2.5 py-1 rounded text-xs ring-1 ring-slate-700', | |
| lang==='zh' ? 'bg-emerald-500/20 text-emerald-300 ring-emerald-500/40' : 'text-slate-400 hover:text-slate-200']">中文</button> | |
| <span v-if="loading" class="spinner ml-1" :title="t.loading"></span> | |
| <div class="hidden md:flex items-center text-[11px] text-slate-500 gap-2"> | |
| <span class="kbd">click</span> | |
| <span>{{ t.clickHint }}</span> | |
| </div> | |
| </div> | |
| </header> | |
| <!-- ─── Main ───────────────────────────────────────── --> | |
| <main class="flex-1 grid grid-cols-12 gap-3 p-3"> | |
| <!-- Map --> | |
| <section class="col-span-12 lg:col-span-7 panel overflow-hidden" style="min-height: 60vh;"> | |
| <div id="map" class="w-full h-full" style="min-height: 60vh;"></div> | |
| </section> | |
| <!-- Right column --> | |
| <aside class="col-span-12 lg:col-span-5 flex flex-col gap-3"> | |
| <!-- Score card --> | |
| <div class="panel p-5"> | |
| <div class="flex items-center gap-5"> | |
| <!-- Gauge --> | |
| <div class="relative w-28 h-28 shrink-0"> | |
| <svg viewBox="0 0 120 120" class="w-full h-full -rotate-90"> | |
| <circle cx="60" cy="60" r="52" stroke="#1f2937" stroke-width="10" fill="none"/> | |
| <circle cx="60" cy="60" r="52" :stroke="riskColor" stroke-width="10" fill="none" | |
| stroke-linecap="round" | |
| :stroke-dasharray="2 * Math.PI * 52" | |
| :stroke-dashoffset="2 * Math.PI * 52 * (1 - riskFraction)" | |
| class="ring-gauge"/> | |
| </svg> | |
| <div class="absolute inset-0 flex items-center justify-center flex-col"> | |
| <div class="text-3xl font-semibold tracking-tight" :style="{color: riskColor}">{{ display.risk_score ?? '—' }}</div> | |
| <div class="text-[10px] uppercase tracking-widest text-slate-500 mono">risk</div> | |
| </div> | |
| </div> | |
| <!-- Meta --> | |
| <div class="flex-1 min-w-0"> | |
| <div class="text-xs uppercase tracking-widest text-slate-500 mono">{{ t.status }}</div> | |
| <div class="text-xl font-medium" :style="{color: riskColor}">{{ riskLevelText }}</div> | |
| <div class="mt-2 text-xs text-slate-400 mono break-words"> | |
| <span v-if="display.latitude != null">{{ display.latitude.toFixed(4) }}, {{ display.longitude.toFixed(4) }}</span> | |
| <span v-else>{{ t.awaiting }}</span> | |
| </div> | |
| <div class="mt-1 text-[11px] text-slate-500 mono"> | |
| <span v-if="display.terrain">{{ t.terrain }}: <span class="text-slate-300">{{ display.terrain }}</span></span> | |
| <span v-if="display.elevation_m != null"> · {{ Math.round(display.elevation_m) }} m</span> | |
| <span v-if="display.cached" class="ml-2 text-emerald-400">⚡ cached ({{ display.cache_ttl }} s)</span> | |
| </div> | |
| </div> | |
| </div> | |
| <p v-if="display.advice_en || display.advice_zh" | |
| class="mt-4 text-sm leading-relaxed border-l-2 pl-3" | |
| :style="{borderColor: riskColor}"> | |
| {{ lang === 'zh' ? display.advice_zh : display.advice_en }} | |
| </p> | |
| </div> | |
| <!-- Sub-hazard mini-gauges (D5 §3.7 / P4.3) --> | |
| <div class="panel p-4"> | |
| <div class="flex items-center justify-between text-xs uppercase tracking-widest text-slate-500 mono mb-3"> | |
| <span>{{ t.subHazards }}</span> | |
| <span class="text-slate-600">P4.3 · P4.4</span> | |
| </div> | |
| <div class="grid grid-cols-4 gap-2"> | |
| <div v-for="h in HAZARDS" :key="h.key" | |
| class="flex flex-col items-center gap-1" | |
| :title="t.hazardTooltip[h.key]" | |
| :aria-label="t.hazards[h.key] + ' ' + (display.hazard_subscores?.[h.key] ?? '?') + '/100'"> | |
| <div class="relative w-14 h-14"> | |
| <svg viewBox="0 0 60 60" class="w-full h-full -rotate-90"> | |
| <circle cx="30" cy="30" r="24" stroke="#1f2937" stroke-width="6" fill="none"/> | |
| <circle cx="30" cy="30" r="24" :stroke="subHazardColor(display.hazard_subscores?.[h.key])" | |
| stroke-width="6" fill="none" stroke-linecap="round" | |
| :stroke-dasharray="2 * Math.PI * 24" | |
| :stroke-dashoffset="2 * Math.PI * 24 * (1 - ((display.hazard_subscores?.[h.key] ?? 0) / 100))" | |
| class="mini-gauge-svg"/> | |
| </svg> | |
| <div class="absolute inset-0 flex items-center justify-center"> | |
| <div class="text-[13px] font-semibold mono" | |
| :style="{color: subHazardColor(display.hazard_subscores?.[h.key])}"> | |
| {{ display.hazard_subscores?.[h.key] ?? '–' }} | |
| </div> | |
| </div> | |
| </div> | |
| <div class="text-[10px] text-slate-400 text-center leading-tight"> | |
| {{ t.hazards[h.key] }} | |
| </div> | |
| </div> | |
| </div> | |
| <div class="mt-3 flex items-center justify-between text-[10px] text-slate-500 mono"> | |
| <span>Activity weight: {{ t.activities[display.activity || activity] }}</span> | |
| <span>Composite ← max-dominant + 0.2 · others</span> | |
| </div> | |
| </div> | |
| <!-- D5 §3.7.2 Decision Table R1-R4 --> | |
| <div class="panel p-4"> | |
| <div class="flex items-center justify-between text-xs uppercase tracking-widest text-slate-500 mono mb-2"> | |
| <span>{{ t.decisionTable }}</span> | |
| <span class="text-slate-600">§3.7.2 Table 4.2</span> | |
| </div> | |
| <div class="flex gap-1.5 mb-2"> | |
| <span v-for="r in ['R1','R2','R3','R4']" :key="r" | |
| :class="['rule-badge', ruleFired(r) && 'rule-badge-fired']">{{ r }}</span> | |
| </div> | |
| <div v-if="display.decision_table_matches && display.decision_table_matches.length"> | |
| <p v-for="m in display.decision_table_matches" :key="m.rule" | |
| class="text-[12px] text-amber-200 leading-snug pl-1"> | |
| <span class="mono text-amber-400">{{ m.rule }}</span> | |
| · {{ lang === 'zh' ? m.conclusion_zh : m.conclusion_en }} | |
| </p> | |
| </div> | |
| <p v-else class="text-[11px] text-slate-500">{{ t.noRuleFired }}</p> | |
| </div> | |
| <!-- Veto block --> | |
| <div v-if="display.veto_triggers && display.veto_triggers.length" | |
| class="panel p-4 border-red-500/40"> | |
| <div class="text-xs uppercase tracking-widest text-red-400 mono mb-2">{{ t.veto }}</div> | |
| <ul class="space-y-1.5"> | |
| <li v-for="v in display.veto_triggers" :key="v.rule" | |
| class="veto-row text-sm text-red-200 flex gap-2"> | |
| <span class="text-red-400 mono shrink-0">▶</span> | |
| <span>{{ lang === 'zh' ? v.message_zh : v.message_en }}</span> | |
| </li> | |
| </ul> | |
| </div> | |
| <!-- ML probability bar --> | |
| <div class="panel p-4"> | |
| <div class="flex items-center justify-between text-xs text-slate-400 mono mb-2"> | |
| <span>{{ t.mlProb }}</span> | |
| <span class="text-slate-200">{{ display.ml_rain_probability != null ? (display.ml_rain_probability * 100).toFixed(1) + '%' : '—' }}</span> | |
| </div> | |
| <div class="w-full h-2 bg-slate-800 rounded-full overflow-hidden"> | |
| <div class="h-full transition-all duration-700" :style="{ | |
| width: ((display.ml_rain_probability ?? 0) * 100) + '%', | |
| background: 'linear-gradient(90deg, #34d399, #fbbf24, #ef4444)' | |
| }"></div> | |
| </div> | |
| <div class="mt-1 text-[10px] text-slate-500 mono">Engine A · Random Forest</div> | |
| </div> | |
| <!-- XAI log --> | |
| <div class="panel p-4 flex-1 min-h-[200px] flex flex-col"> | |
| <div class="flex items-center justify-between text-xs uppercase tracking-widest text-slate-500 mono mb-2"> | |
| <span>{{ t.inferenceLog }}</span> | |
| <span class="text-slate-600">XAI</span> | |
| </div> | |
| <div ref="logScroll" class="flex-1 overflow-auto text-[12px] mono space-y-1 pr-1"> | |
| <div v-if="!display.inference_log || !display.inference_log.length" class="text-slate-600">{{ t.awaiting }}</div> | |
| <div v-for="(s, i) in display.inference_log" :key="i" | |
| class="log-line flex gap-2" | |
| :class="logColor(s.kind)"> | |
| <span class="shrink-0 w-12 text-slate-500">[{{ s.kind }}]</span> | |
| <span>{{ lang === 'zh' ? s.text_zh : s.text_en }}</span> | |
| </div> | |
| </div> | |
| </div> | |
| </aside> | |
| </main> | |
| <!-- ─── Footer ─────────────────────────────────────── --> | |
| <footer class="px-5 py-2 text-[11px] mono text-slate-600 border-t border-slate-800 flex flex-wrap items-center justify-between gap-2"> | |
| <span>API: <span class="text-slate-400">{{ apiBase }}</span></span> | |
| <span>{{ t.disclaimer }}</span> | |
| </footer> | |
| <!-- ─── Toast host ─────────────────────────────────── --> | |
| <div id="toast-host" aria-live="polite"> | |
| <div v-for="t in toasts" :key="t.id" | |
| :class="['toast', t.kind === 'error' ? 'toast-err' : 'toast-ok']" | |
| role="status"> | |
| {{ t.text }} | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const { createApp, reactive, computed, ref, onMounted, watch, nextTick } = Vue; | |
| const ACTIVITIES = ["hiker", "driver", "construction", "general"]; | |
| const HAZARDS = [ | |
| { key: "rainfall" }, | |
| { key: "fog" }, | |
| { key: "wind_gust" }, | |
| { key: "thunderstorm" }, | |
| ]; | |
| const SCENARIOS = [ | |
| { key: "genting", lat: 3.4225, lon: 101.7935 }, // Genting Highlands | |
| { key: "cameron", lat: 4.4710, lon: 101.3779 }, // Cameron valley | |
| { key: "kinabalu", lat: 6.0747, lon: 116.5586 }, // Mt Kinabalu | |
| { key: "everest", lat: 27.9881, lon: 86.9250 }, // Mt Everest (OOD) | |
| { key: "singapore", lat: 1.3521, lon: 103.8198 }, // Flat tropical | |
| ]; | |
| const I18N = { | |
| en: { | |
| clickHint: "any point on the map for analysis", | |
| status: "Status", | |
| awaiting: "Click any coordinate to analyse…", | |
| terrain: "Terrain", | |
| mlProb: "Rain probability (next hour)", | |
| veto: "Veto triggers", | |
| inferenceLog: "Inference log", | |
| disclaimer: "Decision-support only. Always consult official forecasts.", | |
| levels: { Safe:"Safe", Caution:"Caution", Warning:"Warning", Danger:"Danger" }, | |
| activity: "Activity", | |
| activities: { hiker:"🥾 Hiker", driver:"🚗 Driver", construction:"🏗️ Construction", general:"🧭 General" }, | |
| subHazards: "Hazard breakdown", | |
| hazards: { rainfall:"Rainfall", fog:"Fog", wind_gust:"Wind gust", thunderstorm:"Thunderstorm" }, | |
| hazardTooltip: { | |
| rainfall: "Macro rain probability + terrain amplification.", | |
| fog: "Humidity, dew-point depression, cloud cover & basin geometry.", | |
| wind_gust: "Sustained wind + ridge/pass acceleration.", | |
| thunderstorm: "CAPE instability + falling pressure precursor.", | |
| }, | |
| decisionTable: "Decision table", | |
| noRuleFired: "No D5 §3.7.2 rule fired for this scenario.", | |
| scenarios: "Quick scenarios", | |
| scenarioLabels: { | |
| genting: "🇲🇾 Genting Highlands · slope", | |
| cameron: "🇲🇾 Cameron Highlands · valley", | |
| kinabalu: "🇲🇾 Mt Kinabalu · 4 095 m peak", | |
| everest: "🏔️ Mt Everest · 8 848 m (OOD)", | |
| singapore: "🌴 Singapore · flat tropical", | |
| }, | |
| loading: "Loading…", | |
| errorTitle: "Request failed", | |
| }, | |
| zh: { | |
| clickHint: "点击地图任意位置开始分析", | |
| status: "状态", | |
| awaiting: "请在地图上点击任意坐标开始分析…", | |
| terrain: "地形", | |
| mlProb: "未来一小时降雨概率", | |
| veto: "一票否决", | |
| inferenceLog: "推理日志", | |
| disclaimer: "仅供辅助决策,请同时参考官方气象预报。", | |
| levels: { Safe:"安全", Caution:"注意", Warning:"警告", Danger:"危险" }, | |
| activity: "活动", | |
| activities: { hiker:"🥾 徒步", driver:"🚗 驾驶", construction:"🏗️ 施工", general:"🧭 通用" }, | |
| subHazards: "分项灾害评分", | |
| hazards: { rainfall:"降雨", fog:"雾", wind_gust:"阵风", thunderstorm:"雷暴" }, | |
| hazardTooltip: { | |
| rainfall: "宏观降雨概率 + 地形放大。", | |
| fog: "湿度、露点温差、云量、盆地汇雾。", | |
| wind_gust: "持续风速 + 山脊/山口加速。", | |
| thunderstorm: "CAPE 不稳定 + 气压骤降前兆。", | |
| }, | |
| decisionTable: "决策表", | |
| noRuleFired: "当前场景未触发 D5 §3.7.2 中的任何规则。", | |
| scenarios: "快速场景", | |
| scenarioLabels: { | |
| genting: "🇲🇾 云顶高原 · 山坡", | |
| cameron: "🇲🇾 金马仑高原 · 山谷", | |
| kinabalu: "🇲🇾 神山 · 4 095 m 山峰", | |
| everest: "🏔️ 珠穆朗玛 · 8 848 m (OOD)", | |
| singapore: "🌴 新加坡 · 热带平原", | |
| }, | |
| loading: "加载中…", | |
| errorTitle: "请求失败", | |
| }, | |
| }; | |
| createApp({ | |
| setup() { | |
| const lang = ref(localStorage.getItem("mcx_lang") || "en"); | |
| const activity = ref(localStorage.getItem("mcx_activity") || "hiker"); | |
| const loading = ref(false); | |
| const toasts = reactive([]); | |
| const selectedScenario = ref(""); | |
| const t = computed(() => I18N[lang.value]); | |
| watch(lang, v => localStorage.setItem("mcx_lang", v)); | |
| const apiBase = (() => { | |
| const meta = document.querySelector('meta[name="api-base"]'); | |
| if (meta) return meta.content; | |
| if (location.protocol === "file:") return "http://localhost:8000"; | |
| return location.origin; | |
| })(); | |
| const display = reactive({ | |
| latitude: null, | |
| longitude: null, | |
| elevation_m: null, | |
| terrain: null, | |
| ml_rain_probability: null, | |
| risk_score: null, | |
| risk_level: null, | |
| veto_triggers: [], | |
| inference_log: [], | |
| advice_en: "", | |
| advice_zh: "", | |
| cached: false, | |
| cache_ttl: 0, | |
| hazard_subscores: null, | |
| decision_table_matches: [], | |
| activity: null, | |
| }); | |
| const riskFraction = computed(() => | |
| display.risk_score == null ? 0 : Math.max(0, Math.min(1, display.risk_score / 100)) | |
| ); | |
| const riskColor = computed(() => { | |
| const s = display.risk_score ?? -1; | |
| if (s < 0) return "#475569"; | |
| if (s >= 80) return "#ef4444"; | |
| if (s >= 55) return "#f97316"; | |
| if (s >= 30) return "#fbbf24"; | |
| return "#34d399"; | |
| }); | |
| const riskLevelText = computed(() => { | |
| if (!display.risk_level) return "—"; | |
| return t.value.levels[display.risk_level] || display.risk_level; | |
| }); | |
| const logColor = (kind) => ({ | |
| info: "text-slate-300", | |
| ml: "text-cyan-300", | |
| rule: "text-amber-300", | |
| veto: "text-red-400 font-medium", | |
| score: "text-emerald-300", | |
| hazard: "text-violet-300", | |
| table: "text-amber-200 font-medium", | |
| activity: "text-emerald-200", | |
| }[kind] || "text-slate-400"); | |
| const subHazardColor = (score) => { | |
| if (score == null) return "#475569"; | |
| if (score >= 80) return "#ef4444"; | |
| if (score >= 55) return "#f97316"; | |
| if (score >= 30) return "#fbbf24"; | |
| return "#34d399"; | |
| }; | |
| const ruleFired = (rule) => | |
| (display.decision_table_matches || []).some(m => m.rule === rule); | |
| const logScroll = ref(null); | |
| watch(() => display.inference_log, async () => { | |
| await nextTick(); | |
| if (logScroll.value) logScroll.value.scrollTop = logScroll.value.scrollHeight; | |
| }, { deep: true }); | |
| let map, marker; | |
| function pushToast(text, kind = "ok", ttl = 4500) { | |
| const item = { id: Date.now() + Math.random(), text, kind }; | |
| toasts.push(item); | |
| setTimeout(() => { | |
| const i = toasts.findIndex(x => x.id === item.id); | |
| if (i >= 0) toasts.splice(i, 1); | |
| }, ttl); | |
| } | |
| async function fetchPrediction(lat, lon) { | |
| // typewriter — pre-clear and show "thinking" | |
| display.inference_log = [ | |
| { kind: "info", | |
| text_en: `Querying (${lat.toFixed(4)}, ${lon.toFixed(4)}) for activity=${activity.value}…`, | |
| text_zh: `查询坐标 (${lat.toFixed(4)}, ${lon.toFixed(4)}),活动类型 ${activity.value}…` }, | |
| ]; | |
| loading.value = true; | |
| try { | |
| const r = await fetch( | |
| `${apiBase}/api/predict?lat=${lat}&lon=${lon}&activity=${encodeURIComponent(activity.value)}` | |
| ); | |
| if (!r.ok) { | |
| let body; | |
| try { body = await r.json(); } catch (_) { body = {}; } | |
| const msg = body?.detail || `HTTP ${r.status}`; | |
| throw new Error(msg); | |
| } | |
| const data = await r.json(); | |
| Object.assign(display, data); | |
| const full = data.inference_log || []; | |
| display.inference_log = []; | |
| for (let i = 0; i < full.length; i++) { | |
| await new Promise(res => setTimeout(res, 90)); | |
| display.inference_log.push(full[i]); | |
| } | |
| } catch (err) { | |
| const text = (lang.value === "zh" | |
| ? `${t.value.errorTitle}:${err.message}` | |
| : `${t.value.errorTitle}: ${err.message}`); | |
| pushToast(text, "error", 6000); | |
| display.inference_log.push({ | |
| kind: "veto", | |
| text_en: `Request failed: ${err.message}. Is the API running on ${apiBase}?`, | |
| text_zh: `请求失败:${err.message}。请确认 API 是否运行在 ${apiBase}。`, | |
| }); | |
| } finally { | |
| loading.value = false; | |
| } | |
| } | |
| function onClick(e) { | |
| const { lat, lng } = e.latlng; | |
| display.latitude = lat; | |
| display.longitude = lng; | |
| if (marker) marker.setLatLng([lat, lng]); | |
| else marker = L.marker([lat, lng]).addTo(map); | |
| fetchPrediction(lat, lng); | |
| } | |
| function setActivity(a) { | |
| activity.value = a; | |
| localStorage.setItem("mcx_activity", a); | |
| if (display.latitude != null && display.longitude != null) { | |
| fetchPrediction(display.latitude, display.longitude); | |
| } | |
| } | |
| function onScenarioChange() { | |
| const s = SCENARIOS.find(x => x.key === selectedScenario.value); | |
| if (!s) return; | |
| display.latitude = s.lat; | |
| display.longitude = s.lon; | |
| map.flyTo([s.lat, s.lon], 10, { duration: 1.2 }); | |
| if (marker) marker.setLatLng([s.lat, s.lon]); | |
| else marker = L.marker([s.lat, s.lon]).addTo(map); | |
| fetchPrediction(s.lat, s.lon); | |
| } | |
| async function checkBackendHealth() { | |
| try { | |
| const r = await fetch(`${apiBase}/api/health`, { cache: "no-store" }); | |
| if (!r.ok) throw new Error(`HTTP ${r.status}`); | |
| const h = await r.json(); | |
| if (!h.ml_loaded) { | |
| pushToast( | |
| lang.value === "zh" | |
| ? "未检测到训练模型,正在使用启发式回退。运行 make train 后即可启用 Random Forest。" | |
| : "No trained model found — running on heuristic fallback. Run `make train` to enable Random Forest.", | |
| "error", 7000); | |
| } | |
| } catch (_e) { | |
| // The first /api/predict call will surface its own error toast. | |
| } | |
| } | |
| onMounted(() => { | |
| map = L.map("map", { | |
| center: [3.4225, 101.7935], // Genting Highlands | |
| zoom: 9, | |
| zoomControl: true, | |
| }); | |
| const dark = L.tileLayer( | |
| "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", | |
| { attribution: "© OpenStreetMap, © CARTO", maxZoom: 19 } | |
| ); | |
| const topo = L.tileLayer( | |
| "https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", | |
| { attribution: "© OpenTopoMap, © OpenStreetMap", maxZoom: 17 } | |
| ); | |
| dark.addTo(map); | |
| L.control.layers( | |
| { "Dark": dark, "Topographic": topo }, | |
| {}, { position: "bottomleft", collapsed: true } | |
| ).addTo(map); | |
| map.on("click", onClick); | |
| checkBackendHealth(); | |
| // Auto-trigger an initial demo query for Genting Highlands. | |
| setTimeout(() => onClick({ latlng: { lat: 3.4225, lng: 101.7935 } }), 600); | |
| }); | |
| return { | |
| lang, t, display, riskFraction, riskColor, riskLevelText, | |
| logColor, logScroll, apiBase, | |
| activity, setActivity, subHazardColor, ruleFired, | |
| ACTIVITIES, HAZARDS, SCENARIOS, | |
| selectedScenario, onScenarioChange, | |
| loading, toasts, | |
| }; | |
| }, | |
| }).mount("#app"); | |
| </script> | |
| </body> | |
| </html> | |