feat: Introduce a web interface with an interactive map and implement a Dynamic Ensemble Blending (DEB) algorithm for weather prediction.
This commit is contained in:
@@ -0,0 +1,706 @@
|
||||
/**
|
||||
* PolyWeather 地图 — 前端应用
|
||||
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
* Leaflet 地图 + 详情面板 + Chart.js 温度走势
|
||||
*/
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// State
|
||||
// ──────────────────────────────────────────────────────────
|
||||
let map = null;
|
||||
let markers = {}; // cityName → Leaflet marker
|
||||
let cityDataCache = {}; // cityName → API response
|
||||
let selectedCity = null;
|
||||
let tempChart = null;
|
||||
const AUTO_REFRESH_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Map Setup
|
||||
// ──────────────────────────────────────────────────────────
|
||||
function initMap() {
|
||||
map = L.map("map", {
|
||||
center: [30, 10],
|
||||
zoom: 3,
|
||||
minZoom: 2,
|
||||
maxZoom: 12,
|
||||
zoomControl: true,
|
||||
attributionControl: true,
|
||||
});
|
||||
|
||||
// CartoDB Dark Matter tiles (free, dark theme)
|
||||
L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", {
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
subdomains: "abcd",
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Markers
|
||||
// ──────────────────────────────────────────────────────────
|
||||
function createMarkerIcon(city) {
|
||||
const riskClass = `risk-${city.risk_level}`;
|
||||
const label = city.display_name;
|
||||
// Short name for marker
|
||||
const shortName = label.length > 10 ? label.substring(0, 8) + "…" : label;
|
||||
const tempText = city._temp !== undefined ? `${city._temp}` : "—";
|
||||
|
||||
const html = `
|
||||
<div class="city-marker" data-city="${city.name}">
|
||||
<div class="marker-bubble ${riskClass}">${tempText}</div>
|
||||
<div class="marker-name">${shortName}</div>
|
||||
</div>
|
||||
`;
|
||||
return L.divIcon({
|
||||
html: html,
|
||||
className: "",
|
||||
iconSize: [60, 40],
|
||||
iconAnchor: [30, 40],
|
||||
});
|
||||
}
|
||||
|
||||
function addCityMarkers(cities) {
|
||||
cities.forEach((city) => {
|
||||
const icon = createMarkerIcon(city);
|
||||
const marker = L.marker([city.lat, city.lon], { icon })
|
||||
.addTo(map)
|
||||
.on("click", () => loadCityDetail(city.name));
|
||||
|
||||
markers[city.name] = { marker, city };
|
||||
});
|
||||
|
||||
document.getElementById("cityCount").textContent = cities.length;
|
||||
}
|
||||
|
||||
function updateMarkerTemp(cityName, temp) {
|
||||
const entry = markers[cityName];
|
||||
if (!entry) return;
|
||||
entry.city._temp = temp;
|
||||
entry.marker.setIcon(createMarkerIcon(entry.city));
|
||||
}
|
||||
|
||||
function setSelectedMarker(cityName) {
|
||||
// Remove previous selection
|
||||
Object.values(markers).forEach(({ marker }) => {
|
||||
const el = marker.getElement();
|
||||
if (el) el.querySelector(".city-marker")?.classList.remove("selected");
|
||||
});
|
||||
// Add selection
|
||||
const entry = markers[cityName];
|
||||
if (entry) {
|
||||
const el = entry.marker.getElement();
|
||||
if (el) el.querySelector(".city-marker")?.classList.add("selected");
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// City List Sidebar
|
||||
// ──────────────────────────────────────────────────────────
|
||||
function buildCityList(cities) {
|
||||
const container = document.getElementById("cityListItems");
|
||||
container.innerHTML = "";
|
||||
|
||||
// Sort: high risk first, then medium, then low
|
||||
const order = { high: 0, medium: 1, low: 2 };
|
||||
const sorted = [...cities].sort(
|
||||
(a, b) => (order[a.risk_level] ?? 3) - (order[b.risk_level] ?? 3),
|
||||
);
|
||||
|
||||
sorted.forEach((city) => {
|
||||
const div = document.createElement("div");
|
||||
div.className = "city-item";
|
||||
div.id = `city-item-${city.name.replace(/\s/g, "-")}`;
|
||||
div.innerHTML = `
|
||||
<span class="risk-dot ${city.risk_level}"></span>
|
||||
<span class="city-name-text">${city.display_name}</span>
|
||||
<span class="city-temp" id="temp-${city.name.replace(/\s/g, "-")}">—</span>
|
||||
`;
|
||||
div.addEventListener("click", () => {
|
||||
loadCityDetail(city.name);
|
||||
map.flyTo([city.lat, city.lon], 6, { duration: 1 });
|
||||
});
|
||||
container.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function setActiveCityItem(cityName) {
|
||||
document
|
||||
.querySelectorAll(".city-item")
|
||||
.forEach((el) => el.classList.remove("active"));
|
||||
const id = `city-item-${cityName.replace(/\s/g, "-")}`;
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.classList.add("active");
|
||||
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
}
|
||||
|
||||
function updateCityListTemp(cityName, temp, symbol) {
|
||||
const id = `temp-${cityName.replace(/\s/g, "-")}`;
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.textContent = `${temp}${symbol}`;
|
||||
el.classList.add("loaded");
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// API Calls
|
||||
// ──────────────────────────────────────────────────────────
|
||||
async function fetchCities() {
|
||||
try {
|
||||
const res = await fetch("/api/cities");
|
||||
const data = await res.json();
|
||||
return data.cities || [];
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch cities:", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCityDetail(cityName) {
|
||||
const urlName = cityName.replace(/\s/g, "-");
|
||||
const res = await fetch(`/api/city/${encodeURIComponent(urlName)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Load & Render City Detail
|
||||
// ──────────────────────────────────────────────────────────
|
||||
async function loadCityDetail(cityName) {
|
||||
selectedCity = cityName;
|
||||
setActiveCityItem(cityName);
|
||||
setSelectedMarker(cityName);
|
||||
showLoading(true);
|
||||
|
||||
try {
|
||||
const data = await fetchCityDetail(cityName);
|
||||
cityDataCache[cityName] = data;
|
||||
renderPanel(data);
|
||||
// Update marker temperature
|
||||
if (data.current?.temp != null) {
|
||||
updateMarkerTemp(cityName, data.current.temp);
|
||||
updateCityListTemp(cityName, data.current.temp, data.temp_symbol);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to load ${cityName}:`, e);
|
||||
alert(`加载 ${cityName} 数据失败:${e.message}`);
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function showLoading(show) {
|
||||
document.getElementById("loading").classList.toggle("hidden", !show);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Panel Rendering
|
||||
// ──────────────────────────────────────────────────────────
|
||||
function renderPanel(data) {
|
||||
const panel = document.getElementById("panel");
|
||||
panel.classList.remove("hidden");
|
||||
// Trigger reflow for animation
|
||||
requestAnimationFrame(() => panel.classList.add("visible"));
|
||||
|
||||
// Header
|
||||
document.getElementById("panelCityName").textContent =
|
||||
`${data.risk?.emoji || "🏙️"} ${data.display_name}`;
|
||||
document.getElementById("panelLocalTime").textContent =
|
||||
`🕐 ${data.local_time || "—"} 当地时间`;
|
||||
|
||||
const badge = document.getElementById("panelRiskBadge");
|
||||
badge.textContent =
|
||||
{
|
||||
high: "🔴 高危",
|
||||
medium: "🟡 中危",
|
||||
low: "🟢 低危",
|
||||
}[data.risk?.level] || "未知";
|
||||
badge.className = `risk-badge ${data.risk?.level || "low"}`;
|
||||
|
||||
// Hero
|
||||
renderHero(data);
|
||||
// Chart
|
||||
renderChart(data);
|
||||
// Probabilities
|
||||
renderProbabilities(data);
|
||||
// Multi-model
|
||||
renderModels(data);
|
||||
// Forecast
|
||||
renderForecast(data);
|
||||
// AI
|
||||
renderAI(data);
|
||||
// Risk
|
||||
renderRisk(data);
|
||||
}
|
||||
|
||||
function renderHero(data) {
|
||||
const cur = data.current || {};
|
||||
const sym = data.temp_symbol || "°C";
|
||||
|
||||
document.getElementById("heroTemp").textContent =
|
||||
cur.temp != null ? cur.temp.toFixed(1) : "—";
|
||||
document.getElementById("heroUnit").textContent = sym;
|
||||
|
||||
document.getElementById("heroMax").textContent =
|
||||
cur.max_so_far != null
|
||||
? `${cur.max_so_far}${sym} @${cur.max_temp_time || "—"}`
|
||||
: "—";
|
||||
document.getElementById("heroWU").textContent =
|
||||
cur.wu_settlement != null ? `${cur.wu_settlement}${sym}` : "—";
|
||||
document.getElementById("heroDEB").textContent =
|
||||
data.deb?.prediction != null ? `${data.deb.prediction}${sym}` : "—";
|
||||
|
||||
// Sub info
|
||||
const parts = [];
|
||||
if (cur.obs_time) {
|
||||
let ageStr = "";
|
||||
if (cur.obs_age_min != null && cur.obs_age_min >= 30) {
|
||||
ageStr = ` (${cur.obs_age_min}分钟前)`;
|
||||
}
|
||||
parts.push(`<span>✈️ METAR ${cur.obs_time}${ageStr}</span>`);
|
||||
}
|
||||
if (cur.cloud_desc) parts.push(`<span>☁️ ${cur.cloud_desc}</span>`);
|
||||
if (cur.wind_speed_kt != null) {
|
||||
parts.push(`<span>💨 ${cur.wind_speed_kt}kt</span>`);
|
||||
}
|
||||
if (cur.visibility_mi != null) {
|
||||
parts.push(`<span>👁️ ${cur.visibility_mi}mi</span>`);
|
||||
}
|
||||
|
||||
// Trend badge
|
||||
const trend = data.trend || {};
|
||||
if (trend.is_dead_market) {
|
||||
parts.push('<span class="dead-market">☠️ 死盘</span>');
|
||||
} else if (trend.direction && trend.direction !== "unknown") {
|
||||
const labels = {
|
||||
rising: "📈 升温中",
|
||||
falling: "📉 降温中",
|
||||
stagnant: "⏸️ 已停滞",
|
||||
mixed: "📊 波动中",
|
||||
};
|
||||
parts.push(
|
||||
`<span class="trend-badge ${trend.direction}">${labels[trend.direction] || trend.direction}</span>`,
|
||||
);
|
||||
}
|
||||
|
||||
document.getElementById("heroSub").innerHTML = parts.join("");
|
||||
}
|
||||
|
||||
function renderChart(data) {
|
||||
const hourly = data.hourly || {};
|
||||
const times = hourly.times || [];
|
||||
const temps = hourly.temps || [];
|
||||
|
||||
if (times.length === 0) {
|
||||
document.getElementById("chartLegend").textContent = "暂无小时数据";
|
||||
return;
|
||||
}
|
||||
|
||||
// Find current hour index
|
||||
const curHour = data.local_time
|
||||
? data.local_time.split(":")[0] + ":00"
|
||||
: null;
|
||||
const curIdx = curHour ? times.indexOf(curHour) : -1;
|
||||
|
||||
// Forecast vs actual split
|
||||
const actualTemps = temps.map((t, i) =>
|
||||
curIdx >= 0 && i <= curIdx ? t : null,
|
||||
);
|
||||
const forecastTemps = temps.map((t, i) =>
|
||||
curIdx < 0 || i >= curIdx ? t : null,
|
||||
);
|
||||
|
||||
const ctx = document.getElementById("tempChart").getContext("2d");
|
||||
if (tempChart) tempChart.destroy();
|
||||
|
||||
const validTemps = temps.filter((t) => t != null);
|
||||
const minTemp = Math.floor(Math.min(...validTemps)) - 1;
|
||||
const maxTemp = Math.ceil(Math.max(...validTemps)) + 1;
|
||||
|
||||
tempChart = new Chart(ctx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: times,
|
||||
datasets: [
|
||||
{
|
||||
label: "实测",
|
||||
data: actualTemps,
|
||||
borderColor: "#22d3ee",
|
||||
backgroundColor: "rgba(34, 211, 238, 0.1)",
|
||||
borderWidth: 2.5,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
spanGaps: false,
|
||||
},
|
||||
{
|
||||
label: "预报",
|
||||
data: forecastTemps,
|
||||
borderColor: "rgba(99, 102, 241, 0.6)",
|
||||
borderWidth: 1.5,
|
||||
borderDash: [5, 3],
|
||||
pointRadius: 0,
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
spanGaps: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { intersect: false, mode: "index" },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(15, 23, 42, 0.9)",
|
||||
borderColor: "rgba(99, 102, 241, 0.3)",
|
||||
borderWidth: 1,
|
||||
titleFont: { family: "Inter", size: 12 },
|
||||
bodyFont: { family: "Inter", size: 12 },
|
||||
callbacks: {
|
||||
label: (ctx) =>
|
||||
`${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}${data.temp_symbol}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
ticks: {
|
||||
color: "#64748b",
|
||||
font: { size: 10, family: "Inter" },
|
||||
maxRotation: 0,
|
||||
callback: (val, idx) => (idx % 3 === 0 ? times[idx] : ""),
|
||||
},
|
||||
},
|
||||
y: {
|
||||
min: minTemp,
|
||||
max: maxTemp,
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
ticks: {
|
||||
color: "#64748b",
|
||||
font: { size: 10, family: "Inter" },
|
||||
callback: (v) => v + (data.temp_symbol || "°"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// DEB prediction line annotation
|
||||
if (data.deb?.prediction != null) {
|
||||
const debY = data.deb.prediction;
|
||||
tempChart.options.plugins.annotation = {
|
||||
annotations: {
|
||||
debLine: {
|
||||
type: "line",
|
||||
yMin: debY,
|
||||
yMax: debY,
|
||||
borderColor: "#34d399",
|
||||
borderWidth: 1,
|
||||
borderDash: [4, 4],
|
||||
},
|
||||
},
|
||||
};
|
||||
tempChart.update();
|
||||
}
|
||||
|
||||
// METAR recent points overlay
|
||||
const legend = document.getElementById("chartLegend");
|
||||
if (data.trend?.recent?.length) {
|
||||
const recentStr = data.trend.recent
|
||||
.slice(0, 4)
|
||||
.map((r) => `${r.temp}${data.temp_symbol}@${r.time}`)
|
||||
.join(" → ");
|
||||
legend.textContent = `METAR 趋势:${recentStr}`;
|
||||
} else {
|
||||
legend.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
function renderProbabilities(data) {
|
||||
const container = document.getElementById("probBars");
|
||||
const probs = data.probabilities?.distribution || [];
|
||||
const mu = data.probabilities?.mu;
|
||||
|
||||
if (probs.length === 0) {
|
||||
container.innerHTML =
|
||||
'<div style="color:var(--text-muted);font-size:13px;">暂无概率数据</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = "";
|
||||
if (mu != null) {
|
||||
html += `<div style="font-size:11px;color:var(--text-muted);margin-bottom:6px;">期望值 μ = ${mu}${data.temp_symbol}</div>`;
|
||||
}
|
||||
|
||||
probs.forEach((p, i) => {
|
||||
const pct = Math.round(p.probability * 100);
|
||||
const width = Math.max(pct, 8);
|
||||
html += `
|
||||
<div class="prob-row">
|
||||
<div class="prob-label">${p.value}${data.temp_symbol}</div>
|
||||
<div class="prob-bar-track">
|
||||
<div class="prob-bar-fill rank-${i}" style="width:0%">${pct}%</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
|
||||
// Animate bars
|
||||
requestAnimationFrame(() => {
|
||||
container.querySelectorAll(".prob-bar-fill").forEach((bar, i) => {
|
||||
const pct = Math.round(probs[i].probability * 100);
|
||||
bar.style.width = Math.max(pct, 8) + "%";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderModels(data) {
|
||||
const container = document.getElementById("modelBars");
|
||||
const models = data.multi_model || {};
|
||||
const deb = data.deb?.prediction;
|
||||
|
||||
if (Object.keys(models).length === 0) {
|
||||
container.innerHTML =
|
||||
'<div style="color:var(--text-muted);font-size:13px;">暂无多模型数据</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const values = Object.values(models).filter((v) => v != null);
|
||||
const minVal = Math.min(...values) - 1;
|
||||
const maxVal = Math.max(...values) + 1;
|
||||
const range = maxVal - minVal;
|
||||
|
||||
let html = "";
|
||||
const sorted = Object.entries(models).sort(
|
||||
(a, b) => (b[1] || 0) - (a[1] || 0),
|
||||
);
|
||||
|
||||
sorted.forEach(([name, val]) => {
|
||||
if (val == null) return;
|
||||
const pct = ((val - minVal) / range) * 100;
|
||||
const shortName = name.length > 10 ? name.substring(0, 9) + "…" : name;
|
||||
html += `
|
||||
<div class="model-row">
|
||||
<div class="model-name" title="${name}">${shortName}</div>
|
||||
<div class="model-bar-track">
|
||||
<div class="model-bar-fill" style="width:${pct}%">${val}${data.temp_symbol}</div>
|
||||
${deb != null ? `<div class="model-deb-line" style="left:${((deb - minVal) / range) * 100}%"></div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
// DEB row
|
||||
if (deb != null) {
|
||||
const pct = ((deb - minVal) / range) * 100;
|
||||
html += `
|
||||
<div class="model-row" style="margin-top:6px;border-top:1px solid rgba(255,255,255,0.06);padding-top:6px;">
|
||||
<div class="model-name" style="color:var(--accent-cyan);font-weight:700;">DEB</div>
|
||||
<div class="model-bar-track">
|
||||
<div class="model-bar-fill deb" style="width:${pct}%">${deb}${data.temp_symbol}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderForecast(data) {
|
||||
const container = document.getElementById("forecastTable");
|
||||
const daily = data.forecast?.daily || [];
|
||||
const sym = data.temp_symbol || "°C";
|
||||
|
||||
if (daily.length === 0) {
|
||||
container.innerHTML =
|
||||
'<div style="color:var(--text-muted);font-size:13px;">暂无预报</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = "";
|
||||
daily.forEach((d, i) => {
|
||||
const isToday = i === 0;
|
||||
const dateLabel = isToday ? "今天" : d.date.substring(5);
|
||||
html += `
|
||||
<div class="forecast-day ${isToday ? "today" : ""}">
|
||||
<div class="f-date">${dateLabel}</div>
|
||||
<div class="f-temp">${d.max_temp}${sym}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
|
||||
// Sun info
|
||||
const sunEl = document.getElementById("sunInfo");
|
||||
const parts = [];
|
||||
if (data.forecast?.sunrise) parts.push(`🌅 ${data.forecast.sunrise}`);
|
||||
if (data.forecast?.sunset) parts.push(`🌇 ${data.forecast.sunset}`);
|
||||
if (data.forecast?.sunshine_hours)
|
||||
parts.push(`☀️ ${data.forecast.sunshine_hours}h`);
|
||||
sunEl.innerHTML = parts.map((p) => `<span>${p}</span>`).join("");
|
||||
}
|
||||
|
||||
function renderAI(data) {
|
||||
const container = document.getElementById("aiAnalysis");
|
||||
const text = data.ai_analysis || "";
|
||||
|
||||
if (!text) {
|
||||
container.innerHTML = '<span class="ai-placeholder">AI 分析暂不可用</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
// The AI output may contain HTML tags like <b>
|
||||
container.innerHTML = text;
|
||||
}
|
||||
|
||||
function renderRisk(data) {
|
||||
const container = document.getElementById("riskInfo");
|
||||
const risk = data.risk || {};
|
||||
|
||||
if (!risk.airport) {
|
||||
container.innerHTML =
|
||||
'<span style="color:var(--text-muted)">无风险档案</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="risk-row"><span class="risk-label">📍 机场</span><span>${risk.airport} (${risk.icao})</span></div>
|
||||
<div class="risk-row"><span class="risk-label">📏 距离</span><span>${risk.distance_km}km</span></div>
|
||||
${risk.warning ? `<div class="risk-row"><span class="risk-label">⚠️ 注意</span><span>${risk.warning}</span></div>` : ""}
|
||||
`;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Panel Controls
|
||||
// ──────────────────────────────────────────────────────────
|
||||
function closePanel() {
|
||||
const panel = document.getElementById("panel");
|
||||
panel.classList.remove("visible");
|
||||
setTimeout(() => panel.classList.add("hidden"), 400);
|
||||
selectedCity = null;
|
||||
setSelectedMarker(null);
|
||||
document
|
||||
.querySelectorAll(".city-item")
|
||||
.forEach((el) => el.classList.remove("active"));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Auto-Refresh
|
||||
// ──────────────────────────────────────────────────────────
|
||||
function startAutoRefresh() {
|
||||
setInterval(async () => {
|
||||
if (selectedCity) {
|
||||
// Invalidate cache
|
||||
delete cityDataCache[selectedCity];
|
||||
try {
|
||||
const data = await fetchCityDetail(selectedCity);
|
||||
cityDataCache[selectedCity] = data;
|
||||
renderPanel(data);
|
||||
if (data.current?.temp != null) {
|
||||
updateMarkerTemp(selectedCity, data.current.temp);
|
||||
updateCityListTemp(selectedCity, data.current.temp, data.temp_symbol);
|
||||
}
|
||||
flashLiveBadge();
|
||||
} catch (e) {
|
||||
console.warn("Auto-refresh failed:", e);
|
||||
}
|
||||
}
|
||||
}, AUTO_REFRESH_MS);
|
||||
}
|
||||
|
||||
function flashLiveBadge() {
|
||||
const badge = document.getElementById("liveBadge");
|
||||
badge.style.transform = "scale(1.1)";
|
||||
setTimeout(() => {
|
||||
badge.style.transform = "scale(1)";
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Background Progressive Loading
|
||||
// ──────────────────────────────────────────────────────────
|
||||
async function loadAllCitiesProgressively(cities) {
|
||||
// 延迟 1 秒后开始后台加载,避免阻塞初始渲染
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
|
||||
for (const city of cities) {
|
||||
// Skip if already clicked/loaded or selected
|
||||
if (!cityDataCache[city.name]) {
|
||||
try {
|
||||
const urlName = city.name.replace(/\s/g, "-");
|
||||
const res = await fetch(`/api/city/${encodeURIComponent(urlName)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
cityDataCache[city.name] = data;
|
||||
|
||||
// 如果用户目前没有点击它,仅更新标记和列表
|
||||
if (data.current?.temp != null) {
|
||||
updateMarkerTemp(city.name, data.current.temp);
|
||||
updateCityListTemp(city.name, data.current.temp, data.temp_symbol);
|
||||
}
|
||||
|
||||
// 如果恰好在这个时候用户选中了这个城市,顺便刷新面板
|
||||
if (selectedCity === city.name) {
|
||||
renderPanel(data);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Background load failed for ${city.name}`, e);
|
||||
}
|
||||
// 间隔 800ms,避免瞬间并发轰炸后端 API
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Init
|
||||
// ──────────────────────────────────────────────────────────
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
initMap();
|
||||
|
||||
// Panel close
|
||||
document.getElementById("panelClose").addEventListener("click", closePanel);
|
||||
|
||||
// Escape key
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closePanel();
|
||||
});
|
||||
|
||||
// Refresh all button
|
||||
document
|
||||
.getElementById("refreshAllBtn")
|
||||
.addEventListener("click", async () => {
|
||||
const btn = document.getElementById("refreshAllBtn");
|
||||
btn.classList.add("spinning");
|
||||
cityDataCache = {};
|
||||
if (selectedCity) {
|
||||
await loadCityDetail(selectedCity);
|
||||
}
|
||||
btn.classList.remove("spinning");
|
||||
});
|
||||
|
||||
// Load cities
|
||||
const cities = await fetchCities();
|
||||
if (cities.length > 0) {
|
||||
addCityMarkers(cities);
|
||||
buildCityList(cities);
|
||||
|
||||
// Fit map to show all markers
|
||||
const bounds = cities.map((c) => [c.lat, c.lon]);
|
||||
map.fitBounds(bounds, { padding: [60, 60], maxZoom: 4 });
|
||||
|
||||
// 启动后台渐进式预加载所有城市的温度
|
||||
loadAllCitiesProgressively(cities);
|
||||
}
|
||||
|
||||
startAutoRefresh();
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PolyWeather — 天气衍生品智能地图</title>
|
||||
<meta name="description" content="Polymarket 天气衍生品交易智能地图。实时 METAR、DEB 融合预报与 AI 分析。">
|
||||
|
||||
<!-- Leaflet -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
|
||||
<!-- Google Fonts: Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
|
||||
rel="stylesheet">
|
||||
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- ── Map ── -->
|
||||
<div id="map"></div>
|
||||
|
||||
<!-- ── Header Overlay ── -->
|
||||
<header id="header">
|
||||
<div class="brand">
|
||||
<h1>🌡️ PolyWeather</h1>
|
||||
<span class="subtitle">天气衍生品智能分析</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="live-badge" id="liveBadge">
|
||||
<span class="pulse-dot"></span>
|
||||
<span>实时</span>
|
||||
</div>
|
||||
<button class="refresh-btn" id="refreshAllBtn" title="刷新所有数据">⟳</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── City List Sidebar (left) ── -->
|
||||
<nav id="cityList" class="city-list">
|
||||
<div class="city-list-header">
|
||||
<span>🏙️ 监控城市</span>
|
||||
<span class="city-count" id="cityCount">0</span>
|
||||
</div>
|
||||
<div id="cityListItems" class="city-list-items">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ── Detail Panel (right) ── -->
|
||||
<aside id="panel" class="detail-panel hidden">
|
||||
<div class="panel-header">
|
||||
<button class="panel-close" id="panelClose">✕</button>
|
||||
<div class="panel-title-area">
|
||||
<h2 id="panelCityName">—</h2>
|
||||
<div class="panel-meta">
|
||||
<span id="panelRiskBadge" class="risk-badge">—</span>
|
||||
<span id="panelLocalTime" class="local-time">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="panelContent" class="panel-body">
|
||||
<!-- ── Temperature Hero ── -->
|
||||
<section class="hero-section">
|
||||
<div class="hero-temp">
|
||||
<span class="hero-value" id="heroTemp">—</span>
|
||||
<span class="hero-unit" id="heroUnit">°C</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<div class="hero-item">
|
||||
<span class="label">📈 今日最高</span>
|
||||
<span class="value" id="heroMax">—</span>
|
||||
</div>
|
||||
<div class="hero-item">
|
||||
<span class="label">🎯 WU 结算</span>
|
||||
<span class="value highlight" id="heroWU">—</span>
|
||||
</div>
|
||||
<div class="hero-item">
|
||||
<span class="label">🧬 DEB 预测</span>
|
||||
<span class="value" id="heroDEB">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-sub" id="heroSub">
|
||||
<!-- obs time, cloud, wind -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Trend Sparkline ── -->
|
||||
<section class="chart-section">
|
||||
<h3>📊 今日温度走势</h3>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="tempChart"></canvas>
|
||||
</div>
|
||||
<div class="chart-legend" id="chartLegend"></div>
|
||||
</section>
|
||||
|
||||
<!-- ── Probability Bars ── -->
|
||||
<section class="prob-section">
|
||||
<h3>🎲 结算概率分布</h3>
|
||||
<div id="probBars" class="prob-bars">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Multi-Model Comparison ── -->
|
||||
<section class="models-section">
|
||||
<h3>🔬 多模型预报</h3>
|
||||
<div id="modelBars" class="model-bars">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Forecast Table ── -->
|
||||
<section class="forecast-section">
|
||||
<h3>📅 多日预报</h3>
|
||||
<div id="forecastTable" class="forecast-table"></div>
|
||||
<div class="sun-info" id="sunInfo"></div>
|
||||
</section>
|
||||
|
||||
<!-- ── AI Analysis ── -->
|
||||
<section class="ai-section">
|
||||
<h3>🤖 AI 深度分析</h3>
|
||||
<div id="aiAnalysis" class="ai-box">
|
||||
<span class="ai-placeholder">点击城市后加载...</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Risk Profile ── -->
|
||||
<section class="risk-section">
|
||||
<h3>⚠️ 数据偏差风险</h3>
|
||||
<div id="riskInfo" class="risk-info"></div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ── Loading Overlay ── -->
|
||||
<div id="loading" class="loading-overlay hidden">
|
||||
<div class="loading-spinner"></div>
|
||||
<span>正在获取气象数据,请稍候...</span>
|
||||
</div>
|
||||
|
||||
<!-- Leaflet JS -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<!-- Chart.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,968 @@
|
||||
/* ──────────────────────────────────────────────────────────
|
||||
PolyWeather Map — Premium Dark Theme
|
||||
────────────────────────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
/* Core palette */
|
||||
--bg-primary: #0a0e1a;
|
||||
--bg-secondary: #111827;
|
||||
--bg-card: rgba(17, 24, 39, 0.85);
|
||||
--bg-glass: rgba(15, 23, 42, 0.75);
|
||||
--border-glass: rgba(99, 102, 241, 0.15);
|
||||
--border-subtle: rgba(255, 255, 255, 0.06);
|
||||
|
||||
/* Text */
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
|
||||
/* Accents */
|
||||
--accent-cyan: #22d3ee;
|
||||
--accent-blue: #6366f1;
|
||||
--accent-green: #34d399;
|
||||
--accent-orange: #fb923c;
|
||||
--accent-red: #f87171;
|
||||
--accent-yellow: #fbbf24;
|
||||
--accent-purple: #a78bfa;
|
||||
|
||||
/* Risk colors */
|
||||
--risk-high: #ef4444;
|
||||
--risk-medium: #f59e0b;
|
||||
--risk-low: #22c55e;
|
||||
|
||||
/* Spacing */
|
||||
--panel-width: 420px;
|
||||
--header-height: 56px;
|
||||
--sidebar-width: 200px;
|
||||
|
||||
/* Effects */
|
||||
--glass-blur: 20px;
|
||||
--shadow-lg: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
--shadow-glow-cyan: 0 0 20px rgba(34, 211, 238, 0.3);
|
||||
--shadow-glow-blue: 0 0 20px rgba(99, 102, 241, 0.3);
|
||||
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* ── Reset & Base ── */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
"Inter",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
/* ── Map ── */
|
||||
#map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Remove Leaflet default styling for cleaner look */
|
||||
.leaflet-control-attribution {
|
||||
background: var(--bg-glass) !important;
|
||||
color: var(--text-muted) !important;
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border-subtle) !important;
|
||||
font-size: 10px !important;
|
||||
border-radius: 6px !important;
|
||||
padding: 2px 8px !important;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
.leaflet-control-zoom {
|
||||
border: none !important;
|
||||
box-shadow: var(--shadow-lg) !important;
|
||||
}
|
||||
.leaflet-control-zoom a {
|
||||
background: var(--bg-glass) !important;
|
||||
color: var(--text-primary) !important;
|
||||
backdrop-filter: blur(12px) !important;
|
||||
border: 1px solid var(--border-glass) !important;
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
line-height: 36px !important;
|
||||
font-size: 16px !important;
|
||||
border-radius: 8px !important;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.leaflet-control-zoom a:hover {
|
||||
background: rgba(99, 102, 241, 0.2) !important;
|
||||
border-color: var(--accent-blue) !important;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
#header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--header-height);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(10, 14, 26, 0.95) 0%,
|
||||
rgba(10, 14, 26, 0.7) 100%
|
||||
);
|
||||
backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid var(--border-glass);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.live-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-green);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.pulse-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-green);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
box-shadow: 0 0 0 6px rgba(34, 197, 94, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-glass);
|
||||
background: var(--bg-glass);
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.refresh-btn:hover {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
border-color: var(--accent-blue);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.refresh-btn.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── City List Sidebar ── */
|
||||
.city-list {
|
||||
position: fixed;
|
||||
top: calc(var(--header-height) + 12px);
|
||||
left: 12px;
|
||||
width: var(--sidebar-width);
|
||||
max-height: calc(100vh - var(--header-height) - 24px);
|
||||
z-index: 900;
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--border-glass);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.city-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.city-count {
|
||||
background: var(--accent-blue);
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.city-list-items {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 4px;
|
||||
}
|
||||
.city-list-items::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.city-list-items::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.city-list-items::-webkit-scrollbar-thumb {
|
||||
background: var(--border-glass);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.city-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.city-item:hover {
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-glass);
|
||||
}
|
||||
.city-item.active {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.city-item .risk-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.city-item .risk-dot.high {
|
||||
background: var(--risk-high);
|
||||
box-shadow: 0 0 6px var(--risk-high);
|
||||
}
|
||||
.city-item .risk-dot.medium {
|
||||
background: var(--risk-medium);
|
||||
box-shadow: 0 0 6px var(--risk-medium);
|
||||
}
|
||||
.city-item .risk-dot.low {
|
||||
background: var(--risk-low);
|
||||
box-shadow: 0 0 6px var(--risk-low);
|
||||
}
|
||||
|
||||
.city-item .city-temp {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
opacity: 0;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.city-item .city-temp.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Detail Panel ── */
|
||||
.detail-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: var(--panel-width);
|
||||
height: 100vh;
|
||||
z-index: 950;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(10, 14, 26, 0.92) 0%,
|
||||
rgba(15, 23, 42, 0.95) 100%
|
||||
);
|
||||
backdrop-filter: blur(24px);
|
||||
border-left: 1px solid var(--border-glass);
|
||||
box-shadow: -10px 0 60px rgba(0, 0, 0, 0.5);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.detail-panel.visible {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 20px 20px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-glass);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.panel-close:hover {
|
||||
background: rgba(248, 113, 113, 0.15);
|
||||
border-color: var(--accent-red);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.panel-title-area h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.panel-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.risk-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.risk-badge.high {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: var(--risk-high);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
.risk-badge.medium {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: var(--risk-medium);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
.risk-badge.low {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--risk-low);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.local-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 0 20px 24px;
|
||||
}
|
||||
.panel-body::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.panel-body::-webkit-scrollbar-thumb {
|
||||
background: var(--border-glass);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.panel-body section {
|
||||
padding: 18px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.panel-body section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.panel-body h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* ── Hero Section ── */
|
||||
.hero-section {
|
||||
text-align: center;
|
||||
padding-top: 12px !important;
|
||||
}
|
||||
|
||||
.hero-temp {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.hero-value {
|
||||
font-size: 56px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1;
|
||||
background: linear-gradient(135deg, #fff 30%, var(--accent-cyan));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.hero-unit {
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.hero-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.hero-item {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
padding: 10px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.hero-item .label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.hero-item .value {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.hero-item .value.highlight {
|
||||
color: var(--accent-cyan);
|
||||
text-shadow: 0 0 12px rgba(34, 211, 238, 0.3);
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hero-sub span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Chart Section ── */
|
||||
.chart-wrapper {
|
||||
height: 180px;
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Probability Bars ── */
|
||||
.prob-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.prob-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.prob-label {
|
||||
width: 80px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.prob-bar-track {
|
||||
flex: 1;
|
||||
height: 28px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.prob-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
transition: width 0.8s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.prob-bar-fill.rank-0 {
|
||||
background: linear-gradient(90deg, var(--accent-blue), var(--accent-cyan));
|
||||
box-shadow: 0 0 12px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
.prob-bar-fill.rank-1 {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(99, 102, 241, 0.6),
|
||||
rgba(34, 211, 238, 0.5)
|
||||
);
|
||||
}
|
||||
.prob-bar-fill.rank-2 {
|
||||
background: rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
.prob-bar-fill.rank-3 {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
/* ── Model Bars ── */
|
||||
.model-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.model-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.model-name {
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.model-bar-track {
|
||||
flex: 1;
|
||||
height: 20px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.model-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, var(--accent-purple), var(--accent-blue));
|
||||
transition: width 0.6s ease-out;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-right: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.model-bar-fill.deb {
|
||||
background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green));
|
||||
box-shadow: 0 0 8px rgba(34, 211, 238, 0.3);
|
||||
}
|
||||
|
||||
.model-deb-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--accent-cyan);
|
||||
box-shadow: 0 0 6px var(--accent-cyan);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* ── Forecast Table ── */
|
||||
.forecast-table {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.forecast-day {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.forecast-day:hover {
|
||||
border-color: var(--border-glass);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.forecast-day .f-date {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.forecast-day .f-temp {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.forecast-day.today {
|
||||
border-color: var(--accent-blue);
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
.forecast-day.today .f-date {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.sun-info {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── AI Section ── */
|
||||
.ai-box {
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
border: 1px solid rgba(99, 102, 241, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ai-placeholder {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Risk Section ── */
|
||||
.risk-info {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.8;
|
||||
}
|
||||
.risk-info .risk-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.risk-info .risk-label {
|
||||
color: var(--text-muted);
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
/* ── Custom Map Markers ── */
|
||||
.city-marker {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.city-marker:hover {
|
||||
transform: scale(1.15);
|
||||
z-index: 1000 !important;
|
||||
}
|
||||
|
||||
.marker-bubble {
|
||||
min-width: 44px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-family: "Inter", sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: white;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.marker-bubble::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 6px solid;
|
||||
border-top-color: inherit;
|
||||
}
|
||||
|
||||
.marker-bubble.risk-high {
|
||||
background: linear-gradient(135deg, #dc2626, #ef4444);
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
.marker-bubble.risk-high::after {
|
||||
border-top-color: #ef4444;
|
||||
}
|
||||
|
||||
.marker-bubble.risk-medium {
|
||||
background: linear-gradient(135deg, #d97706, #f59e0b);
|
||||
border-color: rgba(245, 158, 11, 0.5);
|
||||
}
|
||||
.marker-bubble.risk-medium::after {
|
||||
border-top-color: #f59e0b;
|
||||
}
|
||||
|
||||
.marker-bubble.risk-low {
|
||||
background: linear-gradient(135deg, #059669, #10b981);
|
||||
border-color: rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
.marker-bubble.risk-low::after {
|
||||
border-top-color: #10b981;
|
||||
}
|
||||
|
||||
.marker-name {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Marker glow animation for selected city */
|
||||
.city-marker.selected .marker-bubble {
|
||||
animation: markerGlow 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes markerGlow {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 4px 24px rgba(99, 102, 241, 0.5),
|
||||
0 0 40px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Loading Overlay ── */
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background: rgba(10, 14, 26, 0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.loading-overlay.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border-glass);
|
||||
border-top-color: var(--accent-cyan);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ── Trend badge ── */
|
||||
.trend-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.trend-badge.rising {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: var(--accent-green);
|
||||
border: 1px solid rgba(34, 197, 94, 0.25);
|
||||
}
|
||||
.trend-badge.falling {
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
color: var(--accent-red);
|
||||
border: 1px solid rgba(248, 113, 113, 0.25);
|
||||
}
|
||||
.trend-badge.stagnant {
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
color: var(--accent-yellow);
|
||||
border: 1px solid rgba(251, 191, 36, 0.25);
|
||||
}
|
||||
.trend-badge.mixed {
|
||||
background: rgba(167, 139, 250, 0.12);
|
||||
color: var(--accent-purple);
|
||||
border: 1px solid rgba(167, 139, 250, 0.25);
|
||||
}
|
||||
|
||||
.dead-market {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: rgba(248, 113, 113, 0.15);
|
||||
color: var(--accent-red);
|
||||
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||
animation: deadPulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes deadPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 900px) {
|
||||
.city-list {
|
||||
display: none;
|
||||
}
|
||||
.detail-panel {
|
||||
width: 100%;
|
||||
}
|
||||
:root {
|
||||
--panel-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.subtitle {
|
||||
display: none;
|
||||
}
|
||||
.brand h1 {
|
||||
font-size: 16px;
|
||||
}
|
||||
.hero-value {
|
||||
font-size: 42px;
|
||||
}
|
||||
.hero-details {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
.hero-item .value {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user