microclimate-x / frontend /index.html
W1nd5pac's picture
Deploy 2026-05-20T06:52:08Z — 11e81c5
4eefabb verified
<!DOCTYPE html>
<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>