= [
+ ["当前主站", `${detail.current?.temp ?? "--"}${detail.temp_symbol} @ ${detail.current?.obs_time || "--"}`],
+ ["原始 METAR", detail.current?.raw_metar || "暂无"],
+ ["近 0-2 小时", `${formatDelta(shortDelta, detail.temp_symbol)},依据最近 METAR 序列判断短时动量。`],
+ [sourceLabel, `${nearby.length} 个站点参与邻近监控。`],
+ ];
+
+ if (nearbyLead) {
+ const tone =
+ nearbyLead.diff > 0 ? "偏暖" : nearbyLead.diff < 0 ? "偏冷" : "持平";
+ rows.push([
+ "领先站",
+ `${nearbyLead.name} ${nearbyLead.temp}${detail.temp_symbol},相对主站 ${formatDelta(nearbyLead.diff, detail.temp_symbol)}(${tone})。`,
+ ]);
+ }
+
+ return rows;
+}
+
+export function getHistorySummary(
+ history: HistoryPoint[],
+ cityLocalDate?: string | null,
+) {
+ const cutoff = new Date();
+ cutoff.setHours(0, 0, 0, 0);
+ cutoff.setDate(cutoff.getDate() - 14);
+
+ const recentData = history.filter((row) => {
+ if (!row?.date) return false;
+ const rowDate = new Date(`${row.date}T00:00:00`);
+ return !Number.isNaN(rowDate.getTime()) && rowDate >= cutoff;
+ });
+
+ const settledData = recentData.filter((row) => {
+ if (!row?.date) return false;
+ return cityLocalDate
+ ? row.date < cityLocalDate
+ : row.date < new Date().toISOString().slice(0, 10);
+ });
+
+ let hits = 0;
+ const debErrors: number[] = [];
+ settledData.forEach((row) => {
+ if (row.actual != null && row.deb != null) {
+ debErrors.push(Math.abs(row.actual - row.deb));
+ if (Math.round(row.actual) === Math.round(row.deb)) {
+ hits += 1;
+ }
+ }
+ });
+
+ return {
+ dates: recentData.map((row) => row.date),
+ debMae: debErrors.length
+ ? Number(
+ (
+ debErrors.reduce((sum, value) => sum + value, 0) / debErrors.length
+ ).toFixed(1),
+ )
+ : null,
+ debs: recentData.map((row) => row.deb),
+ hitRate: debErrors.length
+ ? Number(((hits / debErrors.length) * 100).toFixed(0))
+ : null,
+ mgms: recentData.map((row) => row.mgm ?? null),
+ recentData,
+ settledCount: settledData.length,
+ actuals: recentData.map((row) => row.actual),
+ };
+}
+
+export function getCityProfileStats(detail: CityDetail) {
+ const risk = detail.risk || {};
+ const current = detail.current || {};
+ const nearbyCount = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby.length : 0;
+
+ return [
+ {
+ label: "结算机场",
+ value: risk.airport && risk.icao ? `${risk.airport} (${risk.icao})` : "暂无档案",
+ },
+ {
+ label: "站点距离",
+ value:
+ risk.distance_km != null && Number.isFinite(Number(risk.distance_km))
+ ? `${risk.distance_km} km`
+ : "未标注",
+ },
+ {
+ label: "观测更新",
+ value: current.obs_time || detail.updated_at || "未提供",
+ },
+ {
+ label: "周边站点",
+ value: nearbyCount > 0 ? `${nearbyCount} 个参与监控` : "暂无周边站",
+ },
+ ];
+}
+
+export function getSettlementRiskNarrative(detail: CityDetail) {
+ const risk = detail.risk || {};
+ const lines: string[] = [];
+
+ if (risk.warning) {
+ lines.push(`当前主要风险是:${risk.warning}`);
+ }
+
+ if (risk.distance_km != null) {
+ if (risk.distance_km >= 60) {
+ lines.push("结算机场与城市核心区域距离偏大,盘面温度与结算值可能出现明显背离。");
+ } else if (risk.distance_km >= 25) {
+ lines.push("结算机场与城区存在可感知距离,午后峰值和夜间降温节奏需要优先看机场站。");
+ } else {
+ lines.push("结算机场距离较近,城市体感与结算温度通常更同步。");
+ }
+ }
+
+ if (detail.name === "ankara") {
+ lines.push("Ankara 需要重点看 LTAC / Esenboğa 与 MGM 周边站联动,不能只看城区体感。");
+ }
+
+ if (detail.current?.obs_age_min != null) {
+ if (detail.current.obs_age_min >= 45) {
+ lines.push(`当前 METAR 已有 ${detail.current.obs_age_min} 分钟时滞,临近判断要结合周边站而不是只看主站快照。`);
+ } else {
+ lines.push("当前主站观测较新,短时判断可以把主站温度作为主要锚点。");
+ }
+ }
+
+ return lines;
+}
+
+export function getClimateDrivers(detail: CityDetail) {
+ const drivers: Array<{ label: string; text: string }> = [];
+ const lat = Math.abs(Number(detail.lat));
+ const current = detail.current || {};
+ const temp = Number(current.temp);
+ const dewPoint = Number(current.dewpoint);
+ const humidity = Number(current.humidity);
+ const windSpeed = Number(current.wind_speed_kt);
+ const nearbyCount = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby.length : 0;
+
+ if (lat >= 50) {
+ drivers.push({
+ label: "高纬冷空气",
+ text: "这座城市处在较高纬度,气温更容易受冷空气南下、短波槽和日照角度变化影响,波动通常偏快。",
+ });
+ } else if (lat >= 35) {
+ drivers.push({
+ label: "中纬度西风带",
+ text: "这座城市主要受中纬度西风带和锋面活动控制,升温或降温往往来自气团切换,而不是单一的日照变化。",
+ });
+ } else if (lat >= 20) {
+ drivers.push({
+ label: "副热带高压",
+ text: "这座城市更容易受到副热带高压、晴空辐射和低层暖平流影响,午后冲高能力通常比高纬城市更强。",
+ });
+ } else {
+ drivers.push({
+ label: "热带水汽与对流",
+ text: "这座城市更偏热带环境,温度与体感常受水汽输送、云对流和阵雨触发影响,不完全由晴空辐射主导。",
+ });
+ }
+
+ if (Number.isFinite(windSpeed) && windSpeed >= 12) {
+ drivers.push({
+ label: "平流输送",
+ text: `当前风速约 ${windSpeed}kt,说明低层输送比较明显,盘面短时方向更容易被外来气团带动。`,
+ });
+ } else if (detail.trend?.is_dead_market) {
+ drivers.push({
+ label: "本地辐射主导",
+ text: "近期更像本地辐射和地表热量收支在主导,若无新气团介入,温度节奏通常更平滑。",
+ });
+ }
+
+ if (
+ Number.isFinite(temp) &&
+ Number.isFinite(dewPoint) &&
+ temp - dewPoint <= 3
+ ) {
+ drivers.push({
+ label: "湿度与云量约束",
+ text: "当前温度和露点接近,说明低层湿度较高。午后峰值容易受云量和降水触发抑制。",
+ });
+ } else if (Number.isFinite(humidity) && humidity >= 70) {
+ drivers.push({
+ label: "湿层偏厚",
+ text: "相对湿度偏高,说明局地升温效率会受到水汽和云层反馈影响,冲高空间要比干空气场景更小心。",
+ });
+ } else {
+ drivers.push({
+ label: "干暖边界层",
+ text: "低层空气相对偏干,晴空时段的升温效率通常更高,午后冲顶更依赖辐射和风向切换。",
+ });
+ }
+
+ if (nearbyCount >= 4) {
+ drivers.push({
+ label: "局地差异",
+ text: "周边可用站点较多,说明地形、城区热岛或下垫面差异可能明显,结算站与城区体感需要分开看。",
+ });
+ }
+
+ return drivers;
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 854c85ad..1f6e228a 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"@radix-ui/react-slot": "^1.1.2",
"@vercel/analytics": "^1.6.1",
+ "chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"leaflet": "^1.9.4",
@@ -528,6 +529,11 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@kurkle/color": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmmirror.com/@kurkle/color/-/color-0.3.4.tgz",
+ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
+ },
"node_modules/@next/env": {
"version": "15.5.12",
"resolved": "https://registry.npmmirror.com/@next/env/-/env-15.5.12.tgz",
@@ -974,6 +980,17 @@
}
]
},
+ "node_modules/chart.js": {
+ "version": "4.5.1",
+ "resolved": "https://registry.npmmirror.com/chart.js/-/chart.js-4.5.1.tgz",
+ "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
+ "dependencies": {
+ "@kurkle/color": "^0.3.0"
+ },
+ "engines": {
+ "pnpm": ">=8"
+ }
+ },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
@@ -2334,6 +2351,11 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "@kurkle/color": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmmirror.com/@kurkle/color/-/color-0.3.4.tgz",
+ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
+ },
"@next/env": {
"version": "15.5.12",
"resolved": "https://registry.npmmirror.com/@next/env/-/env-15.5.12.tgz",
@@ -2567,6 +2589,14 @@
"resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz",
"integrity": "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw=="
},
+ "chart.js": {
+ "version": "4.5.1",
+ "resolved": "https://registry.npmmirror.com/chart.js/-/chart.js-4.5.1.tgz",
+ "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
+ "requires": {
+ "@kurkle/color": "^0.3.0"
+ }
+ },
"chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index bb8a9992..452374a6 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -11,6 +11,7 @@
"dependencies": {
"@radix-ui/react-slot": "^1.1.2",
"@vercel/analytics": "^1.6.1",
+ "chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"leaflet": "^1.9.4",
diff --git a/frontend/public/scenery/ankara.jpg b/frontend/public/scenery/ankara.jpg
new file mode 100644
index 00000000..a518e058
Binary files /dev/null and b/frontend/public/scenery/ankara.jpg differ
diff --git a/frontend/public/scenery/chicago.jpg b/frontend/public/scenery/chicago.jpg
new file mode 100644
index 00000000..2a92067c
Binary files /dev/null and b/frontend/public/scenery/chicago.jpg differ
diff --git a/frontend/public/scenery/city-default.jpg b/frontend/public/scenery/city-default.jpg
new file mode 100644
index 00000000..0daf7fa6
Binary files /dev/null and b/frontend/public/scenery/city-default.jpg differ
diff --git a/frontend/public/scenery/london.jpg b/frontend/public/scenery/london.jpg
new file mode 100644
index 00000000..626ccbd3
Binary files /dev/null and b/frontend/public/scenery/london.jpg differ
diff --git a/frontend/public/scenery/lucknow.jpg b/frontend/public/scenery/lucknow.jpg
new file mode 100644
index 00000000..66282b3c
Binary files /dev/null and b/frontend/public/scenery/lucknow.jpg differ
diff --git a/frontend/public/scenery/munich.jpg b/frontend/public/scenery/munich.jpg
new file mode 100644
index 00000000..8a19667f
Binary files /dev/null and b/frontend/public/scenery/munich.jpg differ
diff --git a/frontend/public/scenery/new-york.jpg b/frontend/public/scenery/new-york.jpg
new file mode 100644
index 00000000..6dbbba7e
Binary files /dev/null and b/frontend/public/scenery/new-york.jpg differ
diff --git a/frontend/public/scenery/paris.jpg b/frontend/public/scenery/paris.jpg
new file mode 100644
index 00000000..45780b7d
Binary files /dev/null and b/frontend/public/scenery/paris.jpg differ
diff --git a/frontend/public/scenery/sao-paulo.jpg b/frontend/public/scenery/sao-paulo.jpg
new file mode 100644
index 00000000..cf58e550
Binary files /dev/null and b/frontend/public/scenery/sao-paulo.jpg differ
diff --git a/frontend/public/scenery/seoul.jpg b/frontend/public/scenery/seoul.jpg
new file mode 100644
index 00000000..e79a13fa
Binary files /dev/null and b/frontend/public/scenery/seoul.jpg differ
diff --git a/frontend/public/scenery/toronto.jpg b/frontend/public/scenery/toronto.jpg
new file mode 100644
index 00000000..59aac405
Binary files /dev/null and b/frontend/public/scenery/toronto.jpg differ
diff --git a/frontend/public/static/app.js b/frontend/public/static/app.js
deleted file mode 100644
index 77172315..00000000
--- a/frontend/public/static/app.js
+++ /dev/null
@@ -1,2087 +0,0 @@
-/**
- * PolyWeather legacy dashboard client
- * Leaflet map + detail panel + Chart.js charts
- */
-
-// ------------------------------------------------------------
-// State
-// ------------------------------------------------------------
-let map = null;
-let markers = {}; // cityName -> Leaflet marker
-let cityDataCache = {}; // cityName -> API response
-const CACHE_KEY = "polyWeather_v1";
-
-try {
- const cachedStr = sessionStorage.getItem(CACHE_KEY);
- if (cachedStr) {
- const parsed = JSON.parse(cachedStr);
- if (Date.now() - parsed.timestamp < 5 * 60 * 1000) {
- cityDataCache = parsed.data || {};
- } else {
- sessionStorage.removeItem(CACHE_KEY);
- }
- }
-} catch (e) {
- console.warn("Restore cache failed", e);
-}
-
-function saveCache() {
- try {
- sessionStorage.setItem(
- CACHE_KEY,
- JSON.stringify({ timestamp: Date.now(), data: cityDataCache }),
- );
- } catch (e) {}
-}
-
-let selectedCity = null;
-let tempChart = null;
-let futureForecastChart = null;
-const AUTO_REFRESH_MS = 60 * 60 * 1000; // 1 hour
-let selectedForecastDate = null;
-let nearbyLayerGroup = null;
-let autoNearbyCity = null;
-let autoNearbyLoading = false;
-const AUTO_NEARBY_MIN_ZOOM = 8;
-const AUTO_NEARBY_MAX_DISTANCE_M = 120000;
-
-// ------------------------------------------------------------
-// Map setup
-// ------------------------------------------------------------
-function initMap() {
- map = L.map("map", {
- center: [30, 10],
- zoom: 3,
- minZoom: 2,
- maxZoom: 12,
- zoomControl: false,
- attributionControl: true,
- });
-
- // Move zoom control to bottom right to avoid overlapping with city list
- L.control.zoom({ position: "bottomright" }).addTo(map);
-
- // CartoDB Dark Matter tiles (free, dark theme)
- L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", {
- attribution:
- '© OSM © CARTO',
- subdomains: "abcd",
- maxZoom: 19,
- }).addTo(map);
-
- nearbyLayerGroup = L.layerGroup().addTo(map);
-
- // Close panel and clear selection when clicking on empty map space
- map.on("click", () => {
- closePanel();
- });
-
- // Handle zoom-based visibility for local stations and minor cities
- map.on("zoomend", updateMapVisibility);
- map.on("moveend", maybeAutoShowNearbyStations);
-}
-
-function updateMapVisibility() {
- if (!map) return;
- const zoom = map.getZoom();
-
- // 1. Handle Nearby Individual Stations (very high zoom only)
- // These are the "Ankara-style" local station markers
- if (zoom < 7) {
- if (map.hasLayer(nearbyLayerGroup)) map.removeLayer(nearbyLayerGroup);
- } else {
- if (!map.hasLayer(nearbyLayerGroup)) map.addLayer(nearbyLayerGroup);
- }
-
- // 2. Keep all primary city markers visible at all zoom levels.
- // This avoids cities like Ankara disappearing when zoomed out.
- Object.values(markers).forEach(({ marker }) => {
- if (!map.hasLayer(marker)) map.addLayer(marker);
- });
-
- maybeAutoShowNearbyStations();
-}
-
-function getNearestCityForCenter() {
- if (!map) return null;
- const center = map.getCenter();
- let best = null;
-
- Object.entries(markers).forEach(([cityName, entry]) => {
- const city = entry.city;
- if (city?.lat == null || city?.lon == null) return;
- const distance = map.distance(center, L.latLng(city.lat, city.lon));
- if (distance > AUTO_NEARBY_MAX_DISTANCE_M) return;
- if (!best || distance < best.distance) {
- best = { cityName, distance };
- }
- });
-
- return best?.cityName || null;
-}
-
-async function maybeAutoShowNearbyStations() {
- if (!map || !nearbyLayerGroup) return;
- if (map.getZoom() < AUTO_NEARBY_MIN_ZOOM) {
- autoNearbyCity = null;
- nearbyLayerGroup.clearLayers();
- return;
- }
-
- const targetCity = getNearestCityForCenter();
- if (!targetCity) {
- autoNearbyCity = null;
- nearbyLayerGroup.clearLayers();
- return;
- }
-
- if (
- autoNearbyCity === targetCity &&
- nearbyLayerGroup.getLayers().length > 0
- ) {
- return;
- }
-
- autoNearbyCity = targetCity;
-
- if (cityDataCache[targetCity]) {
- renderNearbyStations(cityDataCache[targetCity], true);
- return;
- }
-
- if (autoNearbyLoading) return;
- autoNearbyLoading = true;
- try {
- const data = await fetchCityDetail(targetCity, false);
- cityDataCache[targetCity] = data;
- saveCache();
- renderNearbyStations(data, true);
- if (data.current?.temp != null) {
- updateMarkerTemp(targetCity, data.current.temp);
- updateCityListInfo(data);
- }
- } catch (e) {
- console.error(`Auto nearby load failed for ${targetCity}:`, e);
- } finally {
- autoNearbyLoading = false;
- }
-}
-
-// ------------------------------------------------------------
-// Markers
-// ------------------------------------------------------------
-function createMarkerIcon(city) {
- const riskClass = `risk-${city.risk_level}`;
- const label = city.display_name;
- const unitSym = city.temp_unit === "fahrenheit" ? "°F" : "°C";
- const shortName = label.length > 10 ? `${label.substring(0, 8)}…` : label;
- const tempText = city._temp !== undefined ? `${city._temp}${unitSym}` : "--";
-
- const html = `
-
-
${tempText}
-
${shortName}
-
- `;
- return L.divIcon({
- html,
- className: "",
- iconSize: [80, 44],
- iconAnchor: [40, 22],
- });
-}
-
-function addCityMarkers(cities) {
- cities.forEach((city) => {
- const icon = createMarkerIcon(city);
- const marker = L.marker([city.lat, city.lon], { icon })
- .addTo(map)
- .on("click", () => {
- focusCityMarker(city);
- loadCityDetail(city.name);
- });
-
- markers[city.name] = { marker, city };
- });
-
- document.getElementById("cityCount").textContent = cities.length;
- updateMapVisibility();
-}
-
-function focusCityMarker(city) {
- if (city?.lat == null || city?.lon == null) return;
- map.flyTo([city.lat, city.lon], 6, {
- animate: true,
- duration: 1.05,
- easeLinearity: 0.2,
- });
-}
-
-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");
- if (!container) return;
-
- container.innerHTML = "";
- const countEl = document.getElementById("cityCount");
- if (countEl) countEl.textContent = String(cities.length);
-
- 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";
- const cityId = city.name.replace(/\s/g, "-");
- div.id = `city-item-${cityId}`;
- div.innerHTML = `
-
-
- ${city.display_name}
- --
-
-
-
-
-
- `;
- div.addEventListener("click", () => {
- focusCityMarker(city);
- loadCityDetail(city.name);
- });
- 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 updateCityListInfo(cityData) {
- const cityName = cityData.name;
- const cityId = cityName.replace(/\s/g, "-");
- const temp = cityData.current?.temp;
-
- const tempEl = document.getElementById(`temp-${cityId}`);
- if (tempEl) {
- tempEl.textContent =
- temp != null ? `${temp}${cityData.temp_symbol || "\u00B0C"}` : "--";
- if (temp != null) tempEl.classList.add("loaded");
- }
-
- const timeEl = document.getElementById(`time-${cityId}`);
- if (timeEl && cityData.local_time) {
- timeEl.textContent = `\u23f0 ${cityData.local_time}`;
- }
-
- const maxEl = document.getElementById(`max-${cityId}`);
- if (maxEl && cityData.current?.max_temp_time) {
- maxEl.textContent = `\u5cf0\u503c @${cityData.current.max_temp_time}`;
- }
-}
-
-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, force = false) {
- const urlName = cityName.replace(/\s/g, "-");
- const res = await fetch(
- `/api/city/${encodeURIComponent(urlName)}?force_refresh=${force}`,
- );
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- return await res.json();
-}
-
-function pickAnkaraNearbyStations(allNearby) {
- const preferredNames = [
- "Airport (MGM/17128)",
- "Ankara (B\u00f6lge/Center)",
- "Ankara (Bolge/Center)",
- "Etimesgut",
- "Pursaklar",
- "Cubuk",
- "\u00c7ubuk",
- "Kalecik",
- ];
-
- const picks = preferredNames
- .map((name) => allNearby.find((st) => st?.name === name))
- .filter(Boolean);
-
- return picks.length ? picks : allNearby;
-}
-
-function renderNearbyStations(data, preserveView = false) {
- if (!nearbyLayerGroup) return;
- nearbyLayerGroup.clearLayers();
-
- const allNearby = Array.isArray(data.mgm_nearby) ? data.mgm_nearby : [];
- const isAnkara = String(data.name || "").toLowerCase() === "ankara";
- const nearbyStations = isAnkara
- ? pickAnkaraNearbyStations(allNearby)
- : allNearby;
-
- if (!nearbyStations.length) {
- if (!preserveView && data.lat != null && data.lon != null) {
- map.flyTo([data.lat, data.lon], 10, {
- animate: true,
- duration: 1.5,
- easeLinearity: 0.25,
- });
- }
- return;
- }
-
- const latLngs = [];
- if (data.lat != null && data.lon != null) {
- latLngs.push([data.lat, data.lon]);
- }
-
- nearbyStations.forEach((st) => {
- const sym = data.temp_symbol || "\u00b0C";
- let windHtml = "";
- if (st.wind_dir != null) {
- const rot = (parseFloat(st.wind_dir) + 180) % 360;
- const speedRaw = parseFloat(st.wind_speed ?? st.wind_speed_kt);
- const speed = !Number.isNaN(speedRaw)
- ? `${speedRaw.toFixed(1)}k`
- : "";
- windHtml = `
-
- ↗
- ${speed}
-
- `;
- }
-
- const iconHtml = `
-
- ${st.name}
- ${st.temp}${sym}
- ${windHtml}
-
- `;
-
- const icon = L.divIcon({
- html: iconHtml,
- className: "",
- iconSize: null,
- iconAnchor: [0, 0],
- });
-
- L.marker([st.lat, st.lon], { icon }).addTo(nearbyLayerGroup);
- latLngs.push([st.lat, st.lon]);
- });
-
- if (preserveView) return;
-
- if (latLngs.length > 1) {
- map.flyToBounds(L.latLngBounds(latLngs), {
- padding: [40, 40],
- duration: 1.5,
- easeLinearity: 0.25,
- maxZoom: 10,
- });
- } else if (data.lat != null && data.lon != null) {
- map.flyTo([data.lat, data.lon], 10, {
- animate: true,
- duration: 1.5,
- easeLinearity: 0.25,
- });
- }
-}
-
-async function loadCityDetail(cityName, force = false) {
- selectedCity = cityName;
- selectedForecastDate = null;
- setActiveCityItem(cityName);
- setSelectedMarker(cityName);
-
- if (!force && cityDataCache[cityName]) {
- const cachedData = cityDataCache[cityName];
- renderPanel(cachedData);
- renderNearbyStations(cachedData);
- return;
- }
-
- showLoading(true);
-
- try {
- const data = await fetchCityDetail(cityName, force);
- cityDataCache[cityName] = data;
- saveCache();
- renderPanel(data);
- renderNearbyStations(data);
-
- if (data.current?.temp != null) {
- updateMarkerTemp(cityName, data.current.temp);
- updateCityListInfo(data);
- }
- } catch (e) {
- console.error(`Failed to load ${cityName}:`, e);
- alert(`\u52a0\u8f7d ${cityName} \u6570\u636e\u5931\u8d25\uff1a${e.message}`);
- } finally {
- showLoading(false);
- }
-}
-
-function showLoading(show) {
- const loading = document.getElementById("loading");
- if (loading) loading.classList.toggle("hidden", !show);
-}
-
-const METAR_WX_MAP = {
- RA: { label: "\u964d\u96e8", icon: "\ud83c\udf27\ufe0f" },
- "-RA": { label: "\u5c0f\u96e8", icon: "\ud83c\udf26\ufe0f" },
- "+RA": { label: "\u5f3a\u964d\u96e8", icon: "\u26c8\ufe0f" },
- SN: { label: "\u964d\u96ea", icon: "\u2744\ufe0f" },
- "-SN": { label: "\u5c0f\u96ea", icon: "\ud83c\udf28\ufe0f" },
- "+SN": { label: "\u5927\u96ea", icon: "\ud83c\udf28\ufe0f" },
- DZ: { label: "\u6bdb\u6bdb\u96e8", icon: "\ud83c\udf26\ufe0f" },
- FG: { label: "\u96fe", icon: "\ud83c\udf2b\ufe0f" },
- BR: { label: "\u8584\u96fe", icon: "\ud83c\udf2b\ufe0f" },
- HZ: { label: "\u973e", icon: "\ud83c\udf2b\ufe0f" },
- TS: { label: "\u96f7\u66b4", icon: "\u26c8\ufe0f" },
- VCTS: { label: "\u9644\u8fd1\u96f7\u66b4", icon: "\u26c8\ufe0f" },
- SQ: { label: "\u98d1", icon: "\ud83d\udca8" },
- GS: { label: "\u51b0\u96f9", icon: "\ud83c\udf28\ufe0f" },
-};
-
-function translateMETAR(code) {
- if (!code) return null;
- for (const [key, val] of Object.entries(METAR_WX_MAP)) {
- if (String(code).includes(key)) return val;
- }
- return { label: code, icon: "\ud83c\udf24\ufe0f" };
-}
-
-function renderPanel(data) {
- const panel = document.getElementById("panel");
- if (!panel) return;
-
- panel.classList.remove("hidden");
- requestAnimationFrame(() => panel.classList.add("visible"));
-
- const panelCityName = document.getElementById("panelCityName");
- const panelLocalTime = document.getElementById("panelLocalTime");
- const badge = document.getElementById("panelRiskBadge");
-
- if (panelCityName) {
- panelCityName.textContent = `${data.risk?.emoji || "\ud83c\udfd9\ufe0f"} ${data.display_name}`;
- }
- if (panelLocalTime) {
- panelLocalTime.textContent = `\u23f0 ${data.local_time || "--:--"} \u5f53\u5730\u65f6\u95f4`;
- }
- if (badge) {
- badge.textContent =
- {
- high: "\ud83d\udd34 \u9ad8\u5371",
- medium: "\ud83d\udfe1 \u4e2d\u5371",
- low: "\ud83d\udfe2 \u4f4e\u5371",
- }[data.risk?.level] || "\u672a\u77e5";
- badge.className = `risk-badge ${data.risk?.level || "low"}`;
- }
-
- renderHero(data);
- renderChart(data);
- if (!selectedForecastDate) selectedForecastDate = data.local_date;
- renderProbabilities(data);
- renderModels(data);
- renderForecast(data);
- renderAI(data);
- renderRisk(data);
-}
-
-function renderHero(data) {
- const cur = data.current || {};
- const sym = data.temp_symbol || "\u00b0C";
- const displayTemp = cur.temp;
-
- let weatherText = cur.cloud_desc || "\u672a\u77e5";
- let weatherIcon =
- {
- "\u591a\u4e91": "\u2601\ufe0f",
- "\u9634\u5929": "\u2601\ufe0f",
- "\u5c11\u4e91": "\ud83c\udf24\ufe0f",
- "\u6563\u4e91": "\u2601\ufe0f",
- "\u6674": "\u2600\ufe0f",
- "\u6674\u6717": "\u2600\ufe0f",
- }[cur.cloud_desc] || "\ud83c\udf24\ufe0f";
-
- if (cur.wx_desc) {
- const translated = translateMETAR(cur.wx_desc);
- if (translated) {
- weatherText = translated.label;
- weatherIcon = translated.icon;
- }
- }
-
- const heroWeather = document.getElementById("heroWeather");
- const heroTemp = document.getElementById("heroTemp");
- const heroUnit = document.getElementById("heroUnit");
- const maxTimeEl = document.getElementById("heroMaxTime");
- const heroCurrent = document.getElementById("heroCurrent");
- const heroWU = document.getElementById("heroWU");
- const heroDEB = document.getElementById("heroDEB");
- const heroSub = document.getElementById("heroSub");
-
- if (heroWeather) heroWeather.innerHTML = `${weatherIcon} ${weatherText}`;
- if (heroTemp) heroTemp.textContent = displayTemp != null ? displayTemp.toFixed(1) : "--";
- if (heroUnit) heroUnit.textContent = sym;
-
- const isMax = cur.max_so_far != null && cur.temp != null && cur.max_so_far <= cur.temp;
- if (maxTimeEl) {
- maxTimeEl.textContent =
- isMax && cur.max_temp_time
- ? `\u8be5\u57ce\u5e02\u4eca\u65e5\u6700\u9ad8\u6e29\u51fa\u73b0\u4e8e\u5f53\u5730\u65f6\u95f4 ${cur.max_temp_time}`
- : "";
- }
-
- if (heroCurrent) {
- heroCurrent.textContent =
- cur.temp != null ? `${cur.temp}${sym} @${cur.obs_time || "--:--"}` : "--";
- }
- if (heroWU) {
- heroWU.textContent = cur.wu_settlement != null ? `${cur.wu_settlement}${sym}` : "--";
- }
- if (heroDEB) {
- heroDEB.textContent =
- data.deb?.prediction != null ? `${data.deb.prediction}${sym}` : "--";
- }
-
- const parts = [];
- if (cur.obs_time) {
- const ageStr =
- cur.obs_age_min != null && cur.obs_age_min >= 30
- ? ` (${cur.obs_age_min}\u5206\u949f\u524d)`
- : "";
- parts.push(`\u2708\ufe0f METAR ${cur.obs_time}${ageStr}`);
- }
- if (cur.wx_desc) {
- const trans = translateMETAR(cur.wx_desc);
- parts.push(`${trans.icon} ${trans.label}`);
- } else if (cur.cloud_desc) {
- parts.push(`\u2601\ufe0f ${cur.cloud_desc}`);
- }
- if (cur.wind_speed_kt != null) {
- parts.push(`\ud83d\udca8 ${cur.wind_speed_kt}kt`);
- }
- if (cur.visibility_mi != null) {
- parts.push(`\ud83d\udc41\ufe0f ${cur.visibility_mi}mi`);
- }
- if (data.mgm?.temp != null) {
- let mgmTimeStr = "";
- if (data.mgm.time && data.mgm.time.includes(":")) {
- const match = data.mgm.time.match(/T?(\d{2}:\d{2})/);
- if (match) mgmTimeStr = ` @${match[1]}`;
- }
- parts.push(
- `\u2b50 MGM \u5b9e\u6d4b: ${data.mgm.temp}${sym}${mgmTimeStr}`,
- );
- }
-
- const trend = data.trend || {};
- if (trend.is_dead_market) {
- parts.push('\u2620\ufe0f \u6b7b\u76d8');
- } else if (trend.direction && trend.direction !== "unknown") {
- const labels = {
- rising: "\ud83d\udcc8 \u5347\u6e29\u4e2d",
- falling: "\ud83d\udcc9 \u964d\u6e29\u4e2d",
- stagnant: "\u23f8 \u5df2\u505c\u6ede",
- mixed: "\ud83d\udcca \u6ce2\u52a8\u4e2d",
- };
- parts.push(
- `${labels[trend.direction] || trend.direction}`,
- );
- }
-
- if (heroSub) heroSub.innerHTML = parts.join("");
-}
-
-function renderChart(data) {
- const hourly = data.hourly || {};
- const times = hourly.times || [];
- const temps = hourly.temps || [];
- const chartLegend = document.getElementById("chartLegend");
-
- if (!times.length) {
- if (chartLegend) chartLegend.textContent = "\u6682\u65e0\u5c0f\u65f6\u6570\u636e";
- return;
- }
-
- const curHour = data.local_time ? `${data.local_time.split(":")[0]}:00` : null;
- const curIdx = curHour ? times.indexOf(curHour) : -1;
- const omMax = data.forecast?.today_high;
- const debMax = data.deb?.prediction;
- const offset = debMax != null && omMax != null ? debMax - omMax : 0;
- const debTemps = temps.map((t) => (t != null ? +(t + offset).toFixed(1) : null));
- const debPast = debTemps.map((t, i) => (curIdx >= 0 && i <= curIdx ? t : null));
- const debFuture = debTemps.map((t, i) => (curIdx < 0 || i >= curIdx ? t : null));
-
- const metarPoints = new Array(times.length).fill(null);
- const metarSrc = data.metar_today_obs?.length
- ? data.metar_today_obs
- : data.trend?.recent || [];
- metarSrc.forEach((r) => {
- const parts = String(r.time || "").split(":");
- let h = parseInt(parts[0], 10);
- const m = parseInt(parts[1] || "0", 10);
- if (Number.isNaN(h)) return;
- if (m >= 30) h = (h + 1) % 24;
- const hourKey = `${String(h).padStart(2, "0")}:00`;
- const idx = times.indexOf(hourKey);
- if (idx >= 0 && metarPoints[idx] === null) metarPoints[idx] = r.temp;
- });
-
- const mgmPoints = new Array(times.length).fill(null);
- if (data.mgm?.temp != null && data.mgm?.time) {
- const timeMatch = data.mgm.time.match(/T?(\d{2}):(\d{2})/);
- if (timeMatch) {
- let h = parseInt(timeMatch[1], 10);
- const m = parseInt(timeMatch[2], 10);
- if (m >= 30) h = (h + 1) % 24;
- const hourKey = `${String(h).padStart(2, "0")}:00`;
- const idx = times.indexOf(hourKey);
- if (idx >= 0) mgmPoints[idx] = data.mgm.temp;
- }
- }
-
- const ctxEl = document.getElementById("tempChart");
- if (!ctxEl) return;
- const ctx = ctxEl.getContext("2d");
- if (tempChart) tempChart.destroy();
-
- const mgmHourlyPoints = new Array(times.length).fill(null);
- let hasMgmHourly = false;
- if (Array.isArray(data.mgm?.hourly)) {
- data.mgm.hourly.forEach((hData) => {
- const match = String(hData.time || "").match(/T?(\d{2}):(\d{2})/);
- if (!match) return;
- const hourKey = `${match[1]}:00`;
- const idx = times.indexOf(hourKey);
- if (idx >= 0) {
- mgmHourlyPoints[idx] = hData.temp;
- hasMgmHourly = true;
- }
- });
- }
-
- const allVals = [
- ...debTemps.filter((t) => t != null),
- ...metarPoints.filter((t) => t != null),
- ...mgmPoints.filter((t) => t != null),
- ...mgmHourlyPoints.filter((t) => t != null),
- ];
-
- if (!allVals.length) {
- if (chartLegend) chartLegend.textContent = "\u6682\u65e0\u56fe\u8868\u6570\u636e";
- return;
- }
-
- const minTemp = Math.floor(Math.min(...allVals)) - 1;
- const maxTemp = Math.ceil(Math.max(...allVals)) + 1;
- const datasets = [];
-
- if (hasMgmHourly) {
- datasets.push({
- label: "MGM\u9884\u62a5",
- data: mgmHourlyPoints,
- borderColor: "rgba(234, 179, 8, 0.8)",
- backgroundColor: "rgba(234, 179, 8, 0.05)",
- borderWidth: 2,
- pointRadius: 3,
- pointHoverRadius: 6,
- fill: false,
- tension: 0.3,
- spanGaps: true,
- });
- } else {
- datasets.push({
- label: "DEB\u9884\u671f",
- data: debPast,
- borderColor: "rgba(52, 211, 153, 0.6)",
- backgroundColor: "rgba(52, 211, 153, 0.05)",
- borderWidth: 1.5,
- pointRadius: 0,
- pointHoverRadius: 3,
- fill: true,
- tension: 0.3,
- spanGaps: false,
- skipTooltip: true,
- });
- datasets.push({
- label: "DEB\u9884\u62a5",
- data: debFuture,
- borderColor: "rgba(52, 211, 153, 0.35)",
- borderWidth: 1.5,
- borderDash: [5, 3],
- pointRadius: 0,
- fill: false,
- tension: 0.3,
- spanGaps: false,
- });
- }
-
- datasets.push({
- label: "METAR\u5b9e\u6d4b",
- data: metarPoints,
- borderColor: "#22d3ee",
- backgroundColor: "#22d3ee",
- borderWidth: 0,
- pointRadius: 5,
- pointHoverRadius: 7,
- pointStyle: "circle",
- fill: false,
- order: 0,
- });
-
- if (mgmPoints.some((t) => t != null)) {
- datasets.push({
- label: "MGM\u5b9e\u6d4b",
- data: mgmPoints,
- borderColor: "#facc15",
- backgroundColor: "#facc15",
- borderWidth: 0,
- pointRadius: 7,
- pointHoverRadius: 9,
- pointStyle: "star",
- fill: false,
- showLine: false,
- order: -1,
- });
- }
-
- if (!hasMgmHourly && Math.abs(offset) > 0.3) {
- datasets.push({
- label: "OM\u539f\u59cb",
- data: temps,
- borderColor: "rgba(99, 102, 241, 0.2)",
- borderWidth: 1,
- borderDash: [2, 4],
- pointRadius: 0,
- fill: false,
- tension: 0.3,
- });
- }
-
- tempChart = new Chart(ctx, {
- type: "line",
- data: { labels: times, datasets },
- options: {
- responsive: true,
- maintainAspectRatio: false,
- interaction: { intersect: false, mode: "index" },
- plugins: {
- legend: { display: false },
- tooltip: {
- backgroundColor: "rgba(15, 23, 42, 0.9)",
- borderColor: "rgba(52, 211, 153, 0.3)",
- borderWidth: 1,
- titleFont: { family: "Inter", size: 12 },
- bodyFont: { family: "Inter", size: 12 },
- filter: (item) => item.parsed.y != null && !item.dataset.skipTooltip,
- callbacks: {
- label: (ctx) =>
- `${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}${data.temp_symbol || "\u00B0C"}`,
- },
- },
- },
- 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 || "\u00B0C"}`,
- },
- },
- },
- },
- });
-
- const legendParts = [];
- if (data.mgm?.temp != null)
- legendParts.push(`MGM: ${data.mgm.temp}${data.temp_symbol || "\u00B0C"}`);
- if (!hasMgmHourly && debMax != null && omMax != null && Math.abs(offset) > 0.3) {
- const sign = offset > 0 ? "+" : "";
- legendParts.push(`DEB\u504f\u79fb ${sign}${offset.toFixed(1)}\u00b0 vs OM`);
- }
- if (hasMgmHourly) legendParts.push("\u5df2\u4f7f\u7528 MGM \u5c0f\u65f6\u9884\u62a5\u66ff\u4ee3 DEB \u66f2\u7ebf");
- if (data.trend?.recent?.length) {
- const recentStr = [...data.trend.recent]
- .slice(0, 4)
- .reverse()
- .map((r) => `${r.temp}${data.temp_symbol || "\u00B0C"}@${r.time}`)
- .join(" \u2192 ");
- legendParts.push(`METAR: ${recentStr}`);
- }
- if (chartLegend) chartLegend.textContent = legendParts.join(" | ") || "";
-}
-
-function renderProbabilities(data) {
- const container = document.getElementById("probBars");
- if (!container) return;
-
- const targetDate = selectedForecastDate || data.local_date;
- let probs = [];
- let mu = null;
-
- if (targetDate === data.local_date) {
- probs = data.probabilities?.distribution || [];
- mu = data.probabilities?.mu;
- } else if (data.multi_model_daily && data.multi_model_daily[targetDate]) {
- probs = data.multi_model_daily[targetDate].probabilities || [];
- mu = data.multi_model_daily[targetDate].deb?.prediction;
- }
-
- if (!probs.length) {
- container.innerHTML = '\u6682\u65e0\u6982\u7387\u6570\u636e
';
- return;
- }
-
- let html = "";
- if (mu != null) {
- html += `\u52a8\u6001\u5206\u5e03\u4e2d\u5fc3 \u03bc = ${mu}${data.temp_symbol || "\u00b0C"}
`;
- }
-
- probs.forEach((p, i) => {
- const pct = Math.round(Number(p.probability || 0) * 100);
- html += `
-
-
${p.value}${data.temp_symbol || "\u00B0C"}
-
-
- `;
- });
-
- container.innerHTML = html;
- requestAnimationFrame(() => {
- container.querySelectorAll(".prob-bar-fill").forEach((bar, i) => {
- const pct = Math.round(Number(probs[i].probability || 0) * 100);
- bar.style.width = `${Math.max(pct, 8)}%`;
- });
- });
-}
-
-function escapeHtml(value) {
- return String(value ?? "").replace(/[&<>"']/g, (ch) => {
- return (
- {
- "&": "&",
- "<": "<",
- ">": ">",
- '"': """,
- "'": "'",
- }[ch] || ch
- );
- });
-}
-
-function formatCents(price) {
- const n = Number(price);
- if (!Number.isFinite(n)) return "--";
- const cents = Math.round(n * 1000) / 10;
- return Number.isInteger(cents) ? `${cents.toFixed(0)}c` : `${cents.toFixed(1)}c`;
-}
-
-function renderModels(data) {
- const container = document.getElementById("modelBars");
- if (!container) return;
-
- const targetDate = selectedForecastDate || data.local_date;
- let models = {};
- let deb = null;
-
- if (data.multi_model_daily && data.multi_model_daily[targetDate]) {
- models = data.multi_model_daily[targetDate].models || {};
- deb = data.multi_model_daily[targetDate].deb?.prediction;
- } else {
- models = data.multi_model || {};
- deb = data.deb?.prediction;
- }
-
- if (!Object.keys(models).length) {
- container.innerHTML = '\u6682\u65e0\u591a\u6a21\u578b\u6570\u636e
';
- return;
- }
-
- const values = Object.values(models).filter((v) => v != null).map(Number);
- const minVal = Math.min(...values) - 1;
- const maxVal = Math.max(...values) + 1;
- const range = Math.max(maxVal - minVal, 1);
-
- let html = "";
- Object.entries(models)
- .sort((a, b) => Number(b[1] || 0) - Number(a[1] || 0))
- .forEach(([name, val]) => {
- if (val == null) return;
- const pct = ((Number(val) - minVal) / range) * 100;
- html += `
-
-
${escapeHtml(name)}
-
-
${val}${data.temp_symbol || "?C"}
-
${val}${data.temp_symbol || "\u00B0C"}
- ${deb != null ? `
` : ""}
-
-
- `;
- });
-
- if (deb != null) {
- const pct = ((Number(deb) - minVal) / range) * 100;
- html += `
-
-
DEB
-
-
${deb}${data.temp_symbol || "\u00B0C"}
-
-
- `;
- }
-
- container.innerHTML = html;
-}
-
-function renderForecast(data) {
- const container = document.getElementById("forecastTable");
- const daily = data.forecast?.daily || [];
- const sym = data.temp_symbol || "\u00B0C";
- const todayDate = data.local_date;
-
- if (daily.length === 0) {
- container.innerHTML =
- '\u6682\u65e0\u591a\u65e5\u9884\u62a5
';
- return;
- }
-
- let html = "";
- daily.forEach((d, i) => {
- const isToday = d.date === todayDate || i === 0;
- const isSelected = isToday;
- const dateLabel = isToday
- ? "\u4eca\u5929"
- : d.date.substring(5).replace("-", "/");
-
- html += `
-
-
${dateLabel}
-
${d.max_temp}${sym}
-
- `;
- });
- container.innerHTML = html;
-
- const sunEl = document.getElementById("sunInfo");
- const parts = [];
- if (data.forecast?.sunrise) parts.push(`\u{1F305} ${data.forecast.sunrise}`);
- if (data.forecast?.sunset) parts.push(`\u{1F307} ${data.forecast.sunset}`);
- if (data.forecast?.sunshine_hours) parts.push(`\u2600\uFE0F ${data.forecast.sunshine_hours}h`);
- sunEl.innerHTML = parts.map((p) => `${p}`).join("");
-}
-
-function renderAI(data) {
- const container = document.getElementById("aiAnalysis");
- if (!container) return;
-
- const analysis = data?.ai_analysis;
- const placeholder =
- '暂无 AI 分析,当前以结构化气象与模型数据为主。';
-
- if (!analysis) {
- container.innerHTML = placeholder;
- return;
- }
-
- if (typeof analysis === "string") {
- const trimmed = analysis.trim();
- container.innerHTML = trimmed
- ? escapeHtml(trimmed).replace(/\n/g, "
")
- : placeholder;
- return;
- }
-
- const summary = analysis.summary || analysis.text || analysis.message || "";
- const bullets = Array.isArray(analysis.highlights)
- ? analysis.highlights
- : Array.isArray(analysis.points)
- ? analysis.points
- : [];
-
- if (!summary && !bullets.length) {
- container.innerHTML = placeholder;
- return;
- }
-
- const bulletHtml = bullets.length
- ? `${bullets
- .map((item) => `- ${escapeHtml(String(item))}
`)
- .join("")}
`
- : "";
-
- container.innerHTML = `
- ${summary ? `${escapeHtml(String(summary)).replace(/\n/g, "
")}
` : ""}
- ${bulletHtml}
- `;
-}
-
-function switchForecastDate(cityName, dateStr) {
- if (selectedCity !== cityName) return;
-
- const data = cityDataCache[cityName];
- if (!data) return;
-
- if (dateStr === data.local_date) {
- selectedForecastDate = dateStr;
- renderModels(data);
- renderProbabilities(data);
- renderForecast(data);
- return;
- }
-
- openFutureForecastModal(data, dateStr);
-}
-
-function buildProbabilityBarsHtml(probabilities) {
- if (!probabilities || probabilities.length === 0) {
- return '\u6682\u65e0\u8be5\u65e5\u671f\u7ed3\u7b97\u6982\u7387\u5206\u5e03
';
- }
-
- return probabilities
- .slice(0, 6)
- .map((p, i) => {
- const label = p.label ?? p.bucket ?? (p.range ? `${p.value}${p.unit || ""} ${p.range}` : `${p.value}`);
- const pct = Math.round(Number(p.probability || 0) * 100);
- return `
-
-
${escapeHtml(String(label))}
-
-
- `;
- })
- .join("");
-}
-
-function buildModelBarsHtml(models, deb, sym) {
- if (!models || Object.keys(models).length === 0) {
- return '\u6682\u65e0\u591a\u6a21\u578b\u9884\u62a5
';
- }
-
- const numericValues = Object.values(models)
- .filter((v) => Number.isFinite(Number(v)))
- .map(Number);
- if (!numericValues.length) {
- return '\u6682\u65e0\u591a\u6a21\u578b\u9884\u62a5
';
- }
-
- const comparisonValues = deb != null ? [...numericValues, Number(deb)] : numericValues;
- const minVal = Math.min(...comparisonValues) - 1;
- const maxVal = Math.max(...comparisonValues) + 1;
- const range = Math.max(maxVal - minVal, 1);
-
- let html = "";
- Object.entries(models)
- .sort((a, b) => Number(b[1] || 0) - Number(a[1] || 0))
- .forEach(([name, val]) => {
- if (!Number.isFinite(Number(val))) return;
- const numVal = Number(val);
- const pct = ((numVal - minVal) / range) * 100;
- html += `
-
-
${escapeHtml(name)}
-
-
${numVal}${sym}
- ${deb != null ? `
` : ""}
-
-
- `;
- });
-
- if (deb != null && Number.isFinite(Number(deb))) {
- const pct = ((Number(deb) - minVal) / range) * 100;
- html += `
-
- `;
- }
-
- return html;
-}
-
-function getFutureSlice(data, dateStr) {
- const hourly = data.hourly_next_48h || {};
- const times = hourly.times || [];
- const out = [];
-
- for (let i = 0; i < times.length; i += 1) {
- const ts = times[i];
- if (!ts || !String(ts).startsWith(dateStr)) continue;
- out.push({
- time: ts,
- label: String(ts).split("T")[1]?.slice(0, 5) || ts,
- temp: hourly.temps?.[i] ?? null,
- dewPoint: hourly.dew_point?.[i] ?? null,
- pressure: hourly.pressure_msl?.[i] ?? null,
- windSpeed: hourly.wind_speed_10m?.[i] ?? null,
- windDir: hourly.wind_direction_10m?.[i] ?? null,
- precipProb: hourly.precipitation_probability?.[i] ?? null,
- cloudCover: hourly.cloud_cover?.[i] ?? null,
- radiation: hourly.radiation?.[i] ?? null,
- });
- }
-
- return out;
-}
-
-function trendBucketFromDir(dir) {
- const n = Number(dir);
- if (!Number.isFinite(n)) return null;
- if (n >= 135 && n <= 240) return "southerly";
- if (n >= 290 || n <= 45) return "northerly";
- if (n > 45 && n < 135) return "easterly";
- return "westerly";
-}
-
-function bucketLabel(bucket) {
- return {
- southerly: "\u5357 / \u897f\u5357\u98ce",
- northerly: "\u5317 / \u897f\u5317\u98ce",
- easterly: "\u4e1c\u98ce",
- westerly: "\u897f\u98ce",
- }[bucket] || "\u98ce\u5411\u4e0d\u660e";
-}
-
-function formatDelta(value, suffix = "") {
- const n = Number(value);
- if (!Number.isFinite(n)) return "--";
- const sign = n > 0 ? "+" : "";
- return `${sign}${n.toFixed(1)}${suffix}`;
-}
-
-function buildTrendCards(metrics) {
- return metrics
- .map((metric) => {
- const toneClass = metric.tone ? ` ${metric.tone}` : "";
- return `
-
-
${escapeHtml(metric.label)}
-
${escapeHtml(metric.value)}
-
${escapeHtml(metric.note)}
-
- `;
- })
- .join("");
-}
-
-function computeProbabilityMu(probabilities) {
- if (!Array.isArray(probabilities) || !probabilities.length) return null;
- let weighted = 0;
- let total = 0;
- probabilities.forEach((item) => {
- const value = Number(item.value);
- const prob = Number(item.probability);
- if (!Number.isFinite(value) || !Number.isFinite(prob)) return;
- weighted += value * prob;
- total += prob;
- });
- if (!total) return null;
- return weighted / total;
-}
-
-function getForecastTextForDate(data, dateStr) {
- const weatherGov = data.source_forecasts?.weather_gov || {};
- const periods = Array.isArray(weatherGov.forecast_periods)
- ? weatherGov.forecast_periods
- : [];
-
- return periods.filter((period) =>
- String(period.start_time || "").startsWith(dateStr),
- );
-}
-
-function computeFrontTrendSignal(data, dateStr) {
- const slice = getFutureSlice(data, dateStr);
- const currentTemp = Number(data.current?.temp);
- const currentDew = Number(data.current?.dewpoint);
-
- if (!slice.length) {
- return {
- score: 0,
- label: "\u76d1\u63a7\u4e2d",
- confidence: "low",
- summary:
- "\u672a\u6765 48 \u5c0f\u65f6\u7ed3\u6784\u5316\u6570\u636e\u4e0d\u8db3\uff0c\u6682\u65f6\u53ea\u4fdd\u7559\u57fa\u7840\u76d1\u63a7\u3002",
- metrics: [],
- directionLabel: "\u98ce\u5411\u4e0d\u660e",
- precipMax: 0,
- weatherGovPeriods: [],
- };
- }
-
- const first = slice[0];
- const last = slice[slice.length - 1];
- const firstTemp = Number.isFinite(first.temp) ? Number(first.temp) : currentTemp;
- const lastTemp = Number.isFinite(last.temp) ? Number(last.temp) : firstTemp;
- const tempDelta =
- Number.isFinite(firstTemp) && Number.isFinite(lastTemp)
- ? lastTemp - firstTemp
- : 0;
-
- const firstDew = Number.isFinite(first.dewPoint)
- ? Number(first.dewPoint)
- : currentDew;
- const lastDew = Number.isFinite(last.dewPoint)
- ? Number(last.dewPoint)
- : firstDew;
- const dewDelta =
- Number.isFinite(firstDew) && Number.isFinite(lastDew)
- ? lastDew - firstDew
- : 0;
-
- const firstPressure = Number.isFinite(first.pressure)
- ? Number(first.pressure)
- : null;
- const lastPressure = Number.isFinite(last.pressure)
- ? Number(last.pressure)
- : firstPressure;
- const pressureDelta =
- Number.isFinite(firstPressure) && Number.isFinite(lastPressure)
- ? lastPressure - firstPressure
- : 0;
-
- const firstCloud = Number.isFinite(first.cloudCover)
- ? Number(first.cloudCover)
- : null;
- const lastCloud = Number.isFinite(last.cloudCover)
- ? Number(last.cloudCover)
- : firstCloud;
- const cloudDelta =
- Number.isFinite(firstCloud) && Number.isFinite(lastCloud)
- ? lastCloud - firstCloud
- : 0;
-
- const precipMax = slice.reduce(
- (max, point) => Math.max(max, Number(point.precipProb) || 0),
- 0,
- );
- const firstBucket = trendBucketFromDir(first.windDir);
- const lastBucket = trendBucketFromDir(last.windDir);
- const weatherGovPeriods = getForecastTextForDate(data, dateStr);
- const weatherGovText = weatherGovPeriods
- .map(
- (period) =>
- `${period.short_forecast || ""} ${period.detailed_forecast || ""}`.toLowerCase(),
- )
- .join(" ");
-
- let warmScore = 0;
- let coldScore = 0;
-
- if (tempDelta >= 2) warmScore += 24;
- else if (tempDelta >= 0.8) warmScore += 12;
- if (tempDelta <= -2) coldScore += 24;
- else if (tempDelta <= -0.8) coldScore += 12;
-
- if (dewDelta >= 1.2) warmScore += 14;
- if (dewDelta <= -1.2) coldScore += 10;
-
- if (Number.isFinite(pressureDelta) && pressureDelta >= 1.2) coldScore += 16;
- if (Number.isFinite(pressureDelta) && pressureDelta <= -1.0) warmScore += 8;
-
- if (lastBucket === "southerly") warmScore += 14;
- if (firstBucket !== lastBucket && lastBucket === "southerly") warmScore += 10;
- if (lastBucket === "northerly") coldScore += 14;
- if (firstBucket !== lastBucket && lastBucket === "northerly") coldScore += 10;
-
- if (cloudDelta >= 15 && tempDelta >= 0) warmScore += 6;
- if (cloudDelta >= 15 && tempDelta < 0) coldScore += 8;
- if (precipMax >= 40) coldScore += 8;
-
- if (
- weatherGovText.includes("cold front") ||
- weatherGovText.includes("temperatures falling")
- ) {
- coldScore += 18;
- }
- if (weatherGovText.includes("warm front") || weatherGovText.includes("warmer")) {
- warmScore += 18;
- }
- if (weatherGovText.includes("thunder") || weatherGovText.includes("snow")) {
- coldScore += 8;
- }
-
- const score = Math.max(-100, Math.min(100, warmScore - coldScore));
- let label = "\u76d1\u63a7\u4e2d";
- if (score >= 18) label = "\u6696\u950b / \u6696\u5e73\u6d41\u503e\u5411";
- else if (score <= -18) label = "\u51b7\u950b / \u51b7\u5e73\u6d41\u503e\u5411";
-
- const absScore = Math.abs(score);
- const confidence = absScore >= 45 ? "high" : absScore >= 22 ? "medium" : "low";
-
- const metrics = [
- {
- label: "\u6e29\u5ea6\u53d8\u5316",
- value: formatDelta(tempDelta, data.temp_symbol || "\u00B0C"),
- note: "Open-Meteo \u672a\u6765\u5c0f\u65f6\u6e29\u5ea6\u53d8\u5316",
- tone: tempDelta >= 0.8 ? "warm" : tempDelta <= -0.8 ? "cold" : "",
- },
- {
- label: "\u9732\u70b9\u53d8\u5316",
- value: formatDelta(dewDelta, data.temp_symbol || "\u00B0C"),
- note: "\u9732\u70b9\u4e0a\u5347\u66f4\u504f\u5411\u6696\u6e7f\u5e73\u6d41",
- tone: dewDelta >= 0.8 ? "warm" : dewDelta <= -0.8 ? "cold" : "",
- },
- {
- label: "\u6c14\u538b\u53d8\u5316",
- value: formatDelta(pressureDelta, " hPa"),
- note: "\u6c14\u538b\u56de\u5347\u66f4\u504f\u5411\u51b7\u7a7a\u6c14\u5165\u4fb5",
- tone: pressureDelta >= 1 ? "cold" : pressureDelta <= -1 ? "warm" : "",
- },
- {
- label: "\u98ce\u5411\u6f14\u53d8",
- value: `${bucketLabel(firstBucket)} \u2192 ${bucketLabel(lastBucket)}`,
- note: "\u5173\u6ce8\u662f\u5426\u8f6c\u5357\u98ce\u6216\u8f6c\u5317\u98ce",
- },
- {
- label: "\u964d\u6c34\u6982\u7387",
- value: `${Math.round(precipMax)}%`,
- note: "weather.gov / Open-Meteo \u964d\u6c34\u63d0\u793a",
- tone: precipMax >= 50 ? "cold" : "",
- },
- {
- label: "\u4e91\u91cf\u53d8\u5316",
- value: formatDelta(cloudDelta, "%"),
- note: "\u4e91\u91cf\u62ac\u5347\u4f46\u672a\u964d\u6e29\uff0c\u5e38\u89c1\u4e8e\u6696\u5e73\u6d41\u524d\u6bb5",
- tone:
- cloudDelta >= 15 && tempDelta >= 0
- ? "warm"
- : cloudDelta >= 15 && tempDelta < 0
- ? "cold"
- : "",
- },
- ];
-
- const usesMeteoblue = data.name !== "ankara" && Boolean(data.source_forecasts?.meteoblue);
- let summary = usesMeteoblue
- ? "\u7ed3\u6784\u5316\u6765\u6e90\u4ee5 weather.gov\u3001Open-Meteo\u3001Meteoblue \u4e3a\u4e3b\uff0c\u7528\u4e8e\u5224\u65ad\u672a\u6765 6-48 \u5c0f\u65f6\u51b7\u6696\u5e73\u6d41\u8d8b\u52bf\u3002"
- : "\u7ed3\u6784\u5316\u6765\u6e90\u4ee5 weather.gov \u4e0e Open-Meteo \u4e3a\u4e3b\uff0c\u7528\u4e8e\u5224\u65ad\u672a\u6765 6-48 \u5c0f\u65f6\u51b7\u6696\u5e73\u6d41\u8d8b\u52bf\u3002";
- if (label === "\u6696\u950b / \u6696\u5e73\u6d41\u503e\u5411") {
- summary =
- "\u98ce\u5411\u66f4\u504f\u5357 / \u897f\u5357\uff0c\u9732\u70b9\u4e0e\u6e29\u5ea6\u6574\u4f53\u62ac\u5347\uff0c\u672a\u6765 6-48 \u5c0f\u65f6\u504f\u5411\u6696\u5e73\u6d41\u3002";
- } else if (label === "\u51b7\u950b / \u51b7\u5e73\u6d41\u503e\u5411") {
- summary =
- "\u6e29\u5ea6\u4e0b\u6ed1\u3001\u6c14\u538b\u56de\u5347\u6216\u98ce\u5411\u8f6c\u5317\uff0c\u672a\u6765 6-48 \u5c0f\u65f6\u66f4\u50cf\u51b7\u950b\u6216\u51b7\u5e73\u6d41\u538b\u5236\u3002";
- }
-
- return {
- score,
- label,
- confidence,
- summary,
- metrics,
- directionLabel: bucketLabel(lastBucket),
- precipMax,
- weatherGovPeriods,
- };
-}
-
-function buildShortTermNowcast(data, dateStr, slice) {
- if (dateStr && dateStr !== data.local_date) {
- const futureSlice = Array.isArray(slice) && slice.length
- ? slice
- : getFutureSlice(data, dateStr);
- const peakCandidates = futureSlice.filter((point) => {
- const hour = Number.parseInt(String(point.label || "").split(":")[0], 10);
- return Number.isFinite(hour) && hour >= 12 && hour <= 18;
- });
- const targetSlice = peakCandidates.length ? peakCandidates : futureSlice;
- const maxIndex = targetSlice.reduce((bestIdx, point, idx, arr) => {
- const temp = Number(point.temp);
- const bestTemp = Number(arr[bestIdx]?.temp);
- if (!Number.isFinite(temp)) return bestIdx;
- if (!Number.isFinite(bestTemp) || temp > bestTemp) return idx;
- return bestIdx;
- }, 0);
- const peakSlice = targetSlice.slice(
- Math.max(0, maxIndex - 1),
- Math.min(targetSlice.length, maxIndex + 2),
- );
- if (!peakSlice.length) {
- return `
- 目标日期:${escapeHtml(dateStr)}
- 峰值窗口:暂无足够的小时级 forecast 数据,无法生成目标日午后峰值窗口判断。
- `;
- }
-
- const sym = data.temp_symbol || "\u00B0C";
- const start = peakSlice[0];
- const end = peakSlice[peakSlice.length - 1];
- const startTemp = Number(start.temp);
- const endTemp = Number(end.temp);
- const startDew = Number(start.dewPoint);
- const endDew = Number(end.dewPoint);
- const startPressure = Number(start.pressure);
- const endPressure = Number(end.pressure);
- const precipValues = peakSlice
- .map((point) => Number(point.precipProb))
- .filter((v) => Number.isFinite(v));
- const cloudValues = peakSlice
- .map((point) => Number(point.cloudCover))
- .filter((v) => Number.isFinite(v));
- const maxPrecip = precipValues.length ? Math.max(...precipValues) : 0;
- const maxCloud = cloudValues.length ? Math.max(...cloudValues) : 0;
- const firstBucket = trendBucketFromDir(start.windDir);
- const lastBucket = trendBucketFromDir(end.windDir);
- const windowDelta =
- Number.isFinite(startTemp) && Number.isFinite(endTemp)
- ? endTemp - startTemp
- : null;
- const windowDewDelta =
- Number.isFinite(startDew) && Number.isFinite(endDew)
- ? endDew - startDew
- : null;
- const windowPressureDelta =
- Number.isFinite(startPressure) && Number.isFinite(endPressure)
- ? endPressure - startPressure
- : null;
- const peakPoint = targetSlice[maxIndex] || peakSlice[peakSlice.length - 1];
- const peakTemp = Number(peakPoint?.temp);
-
- return `
- 目标日期:${escapeHtml(dateStr)}
- 峰值窗口:${escapeHtml(start.label)} - ${escapeHtml(end.label)}(优先取 12:00-18:00)
- 峰值预判:${Number.isFinite(peakTemp) ? `${peakTemp.toFixed(1)}${sym}` : "--"} @ ${escapeHtml(peakPoint?.label || "--")}
- 窗口温度:${Number.isFinite(startTemp) ? `${startTemp.toFixed(1)}${sym}` : "--"} → ${Number.isFinite(endTemp) ? `${endTemp.toFixed(1)}${sym}` : "--"}(${formatDelta(windowDelta, sym)})
- 露点变化:${formatDelta(windowDewDelta, sym)},用于判断午后暖湿输入是否增强。
- 风向演变:${bucketLabel(firstBucket)} → ${bucketLabel(lastBucket)},关注峰值前后是否转南风或回摆北风。
- 气压变化:${formatDelta(windowPressureDelta, " hPa")},上升更偏向冷空气压入。
- 降水 / 云量:${Math.round(maxPrecip)}% / ${Math.round(maxCloud)}%,用于判断峰值时段是否受云系压制。
- `;
- }
-
- const current = data.current || {};
- const recent = Array.isArray(data.metar_recent_obs)
- ? data.metar_recent_obs.slice(-4)
- : [];
- const nearby = Array.isArray(data.mgm_nearby) ? data.mgm_nearby : [];
- const sourceLabel =
- data.name === "ankara"
- ? "MGM \u5468\u8fb9\u7ad9"
- : "METAR \u5468\u8fb9\u7ad9";
- const nearbyCount = nearby.length;
- const currentTemp = Number(current.temp);
- const recentTemps = recent
- .map((point) => Number(point.temp))
- .filter((v) => Number.isFinite(v));
- const baseline = recentTemps.length ? recentTemps[0] : currentTemp;
- const shortDelta =
- Number.isFinite(currentTemp) && Number.isFinite(baseline)
- ? currentTemp - baseline
- : 0;
-
- let nearbyLead = null;
- nearby.forEach((station) => {
- const temp = Number(station.temp);
- if (!Number.isFinite(temp) || !Number.isFinite(currentTemp)) return;
- const diff = temp - currentTemp;
- if (!nearbyLead || Math.abs(diff) > Math.abs(nearbyLead.diff)) {
- nearbyLead = {
- name: station.name || station.icao || "\u5468\u8fb9\u7ad9",
- diff,
- temp,
- };
- }
- });
-
- const lines = [];
- lines.push(
- `\u5f53\u524d\u4e3b\u7ad9\uff1a${current.temp ?? "--"}${data.temp_symbol || "\u00B0C"} @ ${current.obs_time || "--"}
`,
- );
- lines.push(
- `\u539f\u59cb METAR\uff1a${escapeHtml(current.raw_metar || "\u6682\u65e0")}
`,
- );
- lines.push(
- `\u8fd1 0-2 \u5c0f\u65f6\uff1a${formatDelta(shortDelta, data.temp_symbol || "\u00B0C")}\uff0c\u4f9d\u636e\u6700\u8fd1 METAR \u5e8f\u5217\u5224\u65ad\u77ed\u65f6\u52a8\u91cf\u3002
`,
- );
- lines.push(
- `${sourceLabel}\uff1a${nearbyCount} \u4e2a\u7ad9\u70b9\u53c2\u4e0e\u90bb\u8fd1\u76d1\u63a7\u3002
`,
- );
-
- if (nearbyLead) {
- const tone =
- nearbyLead.diff > 0
- ? "\u504f\u6696"
- : nearbyLead.diff < 0
- ? "\u504f\u51b7"
- : "\u6301\u5e73";
- lines.push(
- `\u9886\u5148\u7ad9\uff1a${escapeHtml(nearbyLead.name)} ${nearbyLead.temp}${data.temp_symbol || "\u00B0C"}\uff0c\u76f8\u5bf9\u4e3b\u7ad9 ${formatDelta(nearbyLead.diff, data.temp_symbol || "\u00B0C")}\uff08${tone}\uff09\u3002
`,
- );
- }
-
- return lines.join("");
-}
-
-function renderFutureForecastChart(data, dateStr, slice) {
- const canvas = document.getElementById("futureForecastChart");
- if (!canvas) return;
-
- if (futureForecastChart) {
- futureForecastChart.destroy();
- futureForecastChart = null;
- }
-
- const ctx = canvas.getContext("2d");
- const labels = slice.map((point) => point.label);
- const sym = data.temp_symbol || "\u00B0C";
-
- futureForecastChart = new Chart(ctx, {
- type: "line",
- data: {
- labels,
- datasets: [
- {
- label: "Open-Meteo \u6e29\u5ea6",
- data: slice.map((point) => point.temp),
- borderColor: "#22d3ee",
- backgroundColor: "rgba(34, 211, 238, 0.08)",
- tension: 0.28,
- pointRadius: 2,
- fill: false,
- },
- {
- label: "\u9732\u70b9",
- data: slice.map((point) => point.dewPoint),
- borderColor: "#a78bfa",
- backgroundColor: "transparent",
- borderDash: [5, 4],
- tension: 0.24,
- pointRadius: 0,
- fill: false,
- },
- ],
- },
- options: {
- responsive: true,
- maintainAspectRatio: false,
- interaction: { intersect: false, mode: "index" },
- plugins: {
- legend: {
- labels: {
- color: "#94a3b8",
- font: { family: "Inter", size: 11 },
- },
- },
- tooltip: {
- backgroundColor: "rgba(15, 23, 42, 0.96)",
- borderColor: "rgba(34, 211, 238, 0.2)",
- borderWidth: 1,
- callbacks: {
- label: (ctx) =>
- `${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}${sym}`,
- },
- },
- },
- scales: {
- x: {
- grid: { color: "rgba(255,255,255,0.04)" },
- ticks: {
- color: "#64748b",
- font: { family: "Inter", size: 10 },
- maxRotation: 0,
- },
- },
- y: {
- grid: { color: "rgba(255,255,255,0.04)" },
- ticks: {
- color: "#64748b",
- font: { family: "Inter", size: 10 },
- callback: (value) => `${value}${sym}`,
- },
- },
- },
- },
- });
-}
-
-function openFutureForecastModal(data, dateStr) {
- const modal = document.getElementById("futureForecastModal");
- const title = document.getElementById("futureForecastTitle");
- const stats = document.getElementById("futureForecastStats");
- const probBars = document.getElementById("futureProbBars");
- const modelBars = document.getElementById("futureModelBars");
- const trendGrid = document.getElementById("futureTrendGrid");
- const nowcast = document.getElementById("futureNowcast");
- const nowcastTitle = document.getElementById("futureNowcastTitle");
- const sym = data.temp_symbol || "\u00B0C";
-
- const forecastEntry = (data.forecast?.daily || []).find((item) => item.date === dateStr) || null;
- const dailyModel = data.multi_model_daily?.[dateStr] || {};
- const probabilities = dailyModel.probabilities || [];
- const mu = computeProbabilityMu(probabilities);
- const deb = dailyModel.deb?.prediction ?? forecastEntry?.max_temp ?? null;
- const models = dailyModel.models || {};
- const slice = getFutureSlice(data, dateStr);
- const front = computeFrontTrendSignal(data, dateStr);
- const weatherGovPeriods = front.weatherGovPeriods || [];
- const meteoblueHighs = data.source_forecasts?.meteoblue?.daily_highs || [];
- const meteoblueIndex = (data.forecast?.daily || []).findIndex((item) => item.date === dateStr);
- const meteoblueHigh = meteoblueIndex >= 0 ? meteoblueHighs[meteoblueIndex] : null;
- const hasMeteoblue =
- data.name !== "ankara" &&
- meteoblueHigh != null &&
- Number.isFinite(Number(meteoblueHigh));
- title.textContent = `${data.display_name.toUpperCase()} \u00B7 ${dateStr} \u672a\u6765\u65e5\u671f\u5206\u6790`;
-
- stats.innerHTML = `
- \u76ee\u6807\u65e5\u9884\u62a5${forecastEntry?.max_temp ?? "--"}${sym}
- DEB \u9884\u62a5${deb ?? "--"}${sym}
- \u52a8\u6001\u5206\u5e03\u4e2d\u5fc3${mu != null ? `${mu.toFixed(1)}${sym}` : "--"}
- \u8d8b\u52bf\u8bc4\u5206${front.score > 0 ? "+" : ""}${front.score}
- `;
-
- probBars.innerHTML = buildProbabilityBarsHtml(probabilities);
- modelBars.innerHTML = buildModelBarsHtml(models, deb, sym);
- trendGrid.innerHTML = buildTrendCards(front.metrics);
- if (nowcastTitle) {
- nowcastTitle.innerHTML =
- dateStr === data.local_date
- ? "⏱ 未来 0-2 小时临近判断"
- : "⏱ 目标日午后峰值窗口";
- }
- nowcast.innerHTML = buildShortTermNowcast(data, dateStr, slice);
-
- renderFutureForecastChart(data, dateStr, slice);
- modal.classList.remove("hidden");
-}
-
-function closeFutureForecastModal() {
- document.getElementById("futureForecastModal")?.classList.add("hidden");
- if (futureForecastChart) {
- futureForecastChart.destroy();
- futureForecastChart = null;
- }
-}
-
-function renderRisk(data) {
- const container = document.getElementById("riskInfo");
- const risk = data.risk || {};
-
- if (!risk.airport) {
- container.innerHTML =
- '暂无风险档案';
- return;
- }
-
- container.innerHTML = `
- \u{1F4CD} 机场${risk.airport} (${risk.icao})
- \u{1F4CF} 距离${risk.distance_km}km
- ${risk.warning ? `\u26A0\uFE0F 注意${risk.warning}
` : ""}
- `;
-}
-
-// ------------------------------------------------------------
-// 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"));
- maybeAutoShowNearbyStations();
-}
-
-// ------------------------------------------------------------
-// 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);
- updateCityListInfo(data);
- }
- 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;
- saveCache();
-
- // If the user is not focused on this city, only refresh marker and list info
- if (data.current?.temp != null) {
- updateMarkerTemp(city.name, data.current.temp);
- updateCityListInfo(data);
- }
-
- // If the user is currently focused on this city, refresh the panel too
- if (selectedCity === city.name) {
- renderPanel(data);
- }
- }
- } catch (e) {
- console.warn(`Background load failed for ${city.name}`, e);
- }
- // Delay 2000ms to avoid bursty background API requests and keep the UI responsive
- await new Promise((r) => setTimeout(r, 2000));
- }
- }
-}
-
-// ------------------------------------------------------------
-// History Chart Logic
-// ------------------------------------------------------------
-let historyChartInst = null;
-
-async function openHistoryModal() {
- if (!selectedCity) return;
-
- const modal = document.getElementById("historyModal");
- const title = document.getElementById("historyModalTitle");
- const statsDiv = document.getElementById("historyStats");
-
- modal.classList.remove("hidden");
- title.textContent = `\u5386\u53f2\u51c6\u786e\u7387\u5bf9\u8d26 - ${selectedCity.toUpperCase()}`;
- statsDiv.innerHTML =
- '\u6b63\u5728\u83b7\u53d6\u5386\u53f2\u6570\u636e...';
-
- try {
- const res = await fetch(`/api/history/${encodeURIComponent(selectedCity)}`);
- const json = await res.json();
- const data = json.history || [];
- const cutoff = new Date();
- cutoff.setHours(0, 0, 0, 0);
- cutoff.setDate(cutoff.getDate() - 14);
- const recentData = data.filter((row) => {
- if (!row?.date) return false;
- const rowDate = new Date(`${row.date}T00:00:00`);
- return !Number.isNaN(rowDate.getTime()) && rowDate >= cutoff;
- });
-
- if (recentData.length === 0) {
- statsDiv.innerHTML =
- '\u8fd115\u5929\u6682\u65e0\u8be5\u57ce\u5e02\u5386\u53f2\u6570\u636e';
- if (historyChartInst) historyChartInst.destroy();
- return;
- }
-
- let hits = 0;
- const debErrors = [];
- const dates = [];
- const actuals = [];
- const debs = [];
- const mgms = [];
- const cityLocalDate = cityDataCache?.[selectedCity]?.local_date || null;
- const settledData = recentData.filter((row) => {
- if (!row?.date) return false;
- return cityLocalDate
- ? row.date < cityLocalDate
- : row.date < new Date().toISOString().slice(0, 10);
- });
-
- recentData.forEach((row) => {
- dates.push(row.date);
- actuals.push(row.actual);
- debs.push(row.deb);
- mgms.push(row.mgm);
- });
-
- settledData.forEach((row) => {
- if (row.actual != null && row.deb != null) {
- debErrors.push(Math.abs(row.actual - row.deb));
- if (Math.round(row.actual) === Math.round(row.deb)) {
- hits++;
- }
- }
- });
-
- const hitRate = debErrors.length
- ? ((hits / debErrors.length) * 100).toFixed(0)
- : "--";
- const debMae = debErrors.length
- ? (debErrors.reduce((a, b) => a + b, 0) / debErrors.length).toFixed(1)
- : "--";
- const hasMgm =
- selectedCity === "ankara" && mgms.some((value) => value != null);
-
- statsDiv.innerHTML = `
- DEB \u7ed3\u7b97\u80dc\u7387 (WU)${hitRate === "--" ? "--" : `${hitRate}%`}
- DEB MAE${debMae}°
- \u8fd115\u5929\u5df2\u7ed3\u7b97\u6837\u672c${settledData.length}\u5929
- `;
-
- const datasets = [
- {
- label: "\u5b9e\u6d4b\u6700\u9ad8\u6e29",
- data: actuals,
- borderColor: "#f87171",
- backgroundColor: "rgba(248, 113, 113, 0.1)",
- borderWidth: 2,
- tension: 0.2,
- pointRadius: 4,
- pointBackgroundColor: "#f87171",
- pointBorderColor: "#fff",
- zIndex: 10,
- },
- {
- label: "DEB \u878d\u5408",
- data: debs,
- borderColor: "#34d399",
- backgroundColor: "transparent",
- borderWidth: 2,
- borderDash: [5, 4],
- tension: 0.2,
- pointRadius: 3,
- },
- ];
-
- if (hasMgm) {
- datasets.push({
- label: "MGM \u5b98\u65b9\u9884\u62a5",
- data: mgms,
- borderColor: "#fb923c",
- backgroundColor: "transparent",
- borderWidth: 2,
- tension: 0.2,
- pointRadius: 3,
- });
- }
-
- if (historyChartInst) historyChartInst.destroy();
- const ctx = document.getElementById("historyChart").getContext("2d");
-
- historyChartInst = new Chart(ctx, {
- type: "line",
- data: {
- labels: dates,
- datasets,
- },
- options: {
- responsive: true,
- maintainAspectRatio: false,
- interaction: { mode: "index", intersect: false },
- plugins: {
- legend: {
- labels: { color: "#94a3b8", font: { family: "Inter", size: 12 } },
- },
- tooltip: {
- backgroundColor: "rgba(15, 23, 42, 0.9)",
- borderColor: "rgba(255, 255, 255, 0.1)",
- borderWidth: 1,
- titleFont: { family: "Inter" },
- bodyFont: { family: "Inter" },
- callbacks: {
- label: (ctx) =>
- `${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}°`,
- },
- },
- },
- scales: {
- x: {
- grid: { color: "rgba(255,255,255,0.04)" },
- ticks: { color: "#64748b", font: { family: "Inter", size: 10 } },
- },
- y: {
- grid: { color: "rgba(255,255,255,0.04)" },
- ticks: { color: "#64748b", font: { family: "Inter", size: 10 } },
- },
- },
- },
- });
- } catch (e) {
- console.error("Failed to load history", e);
- statsDiv.innerHTML =
- '\u83b7\u53d6\u5386\u53f2\u4fe1\u606f\u5931\u8d25';
- }
-}
-
-function closeHistoryModal() {
- document.getElementById("historyModal").classList.add("hidden");
-}
-
-document.addEventListener("DOMContentLoaded", async () => {
- initMap();
-
- // Panel close
- document.getElementById("panelClose").addEventListener("click", closePanel);
-
- // Escape key
- document.addEventListener("keydown", (e) => {
- if (e.key === "Escape") closePanel();
- });
-
- // Modal Event Listeners
- const histModal = document.getElementById("historyModal");
- const futureModal = document.getElementById("futureForecastModal");
- const guideModal = document.getElementById("guideModal");
- document
- .getElementById("btnShowHistory")
- .addEventListener("click", openHistoryModal);
- document
- .getElementById("historyModalClose")
- .addEventListener("click", closeHistoryModal);
- histModal.addEventListener("click", (e) => {
- if (e.target.id === "historyModal") closeHistoryModal();
- });
- document
- .getElementById("futureForecastClose")
- .addEventListener("click", closeFutureForecastModal);
- futureModal.addEventListener("click", (e) => {
- if (e.target.id === "futureForecastModal") closeFutureForecastModal();
- });
-
- document.getElementById("btnShowGuide").addEventListener("click", () => {
- guideModal.classList.remove("hidden");
- });
- document.getElementById("guideModalClose").addEventListener("click", () => {
- guideModal.classList.add("hidden");
- });
- guideModal.addEventListener("click", (e) => {
- if (e.target.id === "guideModal") guideModal.classList.add("hidden");
- });
-
- // Refresh all button
- document
- .getElementById("refreshAllBtn")
- .addEventListener("click", async () => {
- const btn = document.getElementById("refreshAllBtn");
- btn.classList.add("spinning");
- cityDataCache = {};
- saveCache();
- if (selectedCity) {
- await loadCityDetail(selectedCity, true);
- }
- btn.classList.remove("spinning");
- });
-
- // Load cities
- const cities = await fetchCities();
- if (cities.length > 0) {
- cities.forEach((c) => {
- const cached = cityDataCache[c.name];
- if (cached && cached.current?.temp != null) {
- c._temp = cached.current.temp;
- }
- });
-
- addCityMarkers(cities);
- buildCityList(cities);
-
- cities.forEach((c) => {
- if (cityDataCache[c.name]) {
- updateCityListInfo(cityDataCache[c.name]);
- }
- });
-
- // Fit map to show all markers
- const bounds = cities.map((c) => [c.lat, c.lon]);
- map.fitBounds(bounds, { padding: [60, 60], maxZoom: 4 });
-
- // Warm cache in the background after boot
- loadAllCitiesProgressively(cities);
- }
-
- startAutoRefresh();
-});
-
-