Files
PolyWeather/frontend/public/static/app.js
T

2088 lines
68 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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:
'&copy; <a href="https://www.openstreetmap.org/">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
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 = `
<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,
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 = `
<div class="city-item-main">
<span class="risk-dot ${city.risk_level}"></span>
<span class="city-name-text">${city.display_name}</span>
<span class="city-temp" id="temp-${cityId}">--</span>
</div>
<div class="city-item-info">
<span class="city-local-time" id="time-${cityId}"></span>
<span class="city-max-info" id="max-${cityId}"></span>
</div>
`;
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 = `
<div class="wind-info">
<span class="wind-arrow" style="transform: rotate(${rot}deg)">&#8599;</span>
<span class="wind-speed">${speed}</span>
</div>
`;
}
const iconHtml = `
<div class="nearby-marker">
<span class="nearby-name">${st.name}</span>
<span class="nearby-temp">${st.temp}</span><span class="nearby-unit">${sym}</span>
${windHtml}
</div>
`;
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 = `<span>${weatherIcon} ${weatherText}</span>`;
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(`<span>\u2708\ufe0f METAR ${cur.obs_time}${ageStr}</span>`);
}
if (cur.wx_desc) {
const trans = translateMETAR(cur.wx_desc);
parts.push(`<span>${trans.icon} ${trans.label}</span>`);
} else if (cur.cloud_desc) {
parts.push(`<span>\u2601\ufe0f ${cur.cloud_desc}</span>`);
}
if (cur.wind_speed_kt != null) {
parts.push(`<span>\ud83d\udca8 ${cur.wind_speed_kt}kt</span>`);
}
if (cur.visibility_mi != null) {
parts.push(`<span>\ud83d\udc41\ufe0f ${cur.visibility_mi}mi</span>`);
}
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(
`<span style="color:#eab308;font-weight:600;background:rgba(234, 179, 8, 0.1);padding:2px 8px;border-radius:12px;border:1px solid rgba(234, 179, 8, 0.3);">\u2b50 MGM \u5b9e\u6d4b: ${data.mgm.temp}${sym}${mgmTimeStr}</span>`,
);
}
const trend = data.trend || {};
if (trend.is_dead_market) {
parts.push('<span class="dead-market">\u2620\ufe0f \u6b7b\u76d8</span>');
} 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(
`<span class="trend-badge ${trend.direction}">${labels[trend.direction] || trend.direction}</span>`,
);
}
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 = '<div style="color:var(--text-muted);font-size:13px;">\u6682\u65e0\u6982\u7387\u6570\u636e</div>';
return;
}
let html = "";
if (mu != null) {
html += `<div style="font-size:11px;color:var(--text-muted);margin-bottom:6px;">\u52a8\u6001\u5206\u5e03\u4e2d\u5fc3 \u03bc = ${mu}${data.temp_symbol || "\u00b0C"}</div>`;
}
probs.forEach((p, i) => {
const pct = Math.round(Number(p.probability || 0) * 100);
html += `
<div class="prob-row">
<div class="prob-label">${p.value}${data.temp_symbol || "\u00B0C"}</div>
<div class="prob-bar-track">
<div class="prob-bar-fill rank-${i}" style="width:0%">${pct}%</div>
</div>
</div>
`;
});
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 (
{
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[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 = '<div style="color:var(--text-muted);font-size:13px;">\u6682\u65e0\u591a\u6a21\u578b\u6570\u636e</div>';
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 += `
<div class="model-row">
<div class="model-name" title="${escapeHtml(name)}">${escapeHtml(name)}</div>
<div class="model-bar-track">
<div class="model-bar-fill" style="width:${pct}%">${val}${data.temp_symbol || "?C"}</div>
<div class="model-bar-fill" style="width:${pct}%">${val}${data.temp_symbol || "\u00B0C"}</div>
${deb != null ? `<div class="model-deb-line" style="left:${((Number(deb) - minVal) / range) * 100}%"></div>` : ""}
</div>
</div>
`;
});
if (deb != null) {
const pct = ((Number(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 || "\u00B0C"}</div>
</div>
</div>
`;
}
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 =
'<div style="color:var(--text-muted);font-size:13px;">\u6682\u65e0\u591a\u65e5\u9884\u62a5</div>';
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 += `
<div class="forecast-day ${isToday ? "today" : ""} ${isSelected ? "selected" : ""}"
onclick="switchForecastDate('${data.name}', '${d.date}')"
style="cursor:pointer;">
<div class="f-date">${dateLabel}</div>
<div class="f-temp">${d.max_temp}${sym}</div>
</div>
`;
});
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) => `<span>${p}</span>`).join("");
}
function renderAI(data) {
const container = document.getElementById("aiAnalysis");
if (!container) return;
const analysis = data?.ai_analysis;
const placeholder =
'<span class="ai-placeholder">暂无 AI 分析,当前以结构化气象与模型数据为主。</span>';
if (!analysis) {
container.innerHTML = placeholder;
return;
}
if (typeof analysis === "string") {
const trimmed = analysis.trim();
container.innerHTML = trimmed
? escapeHtml(trimmed).replace(/\n/g, "<br>")
: 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
? `<ul class="ai-list">${bullets
.map((item) => `<li>${escapeHtml(String(item))}</li>`)
.join("")}</ul>`
: "";
container.innerHTML = `
${summary ? `<div class="ai-summary">${escapeHtml(String(summary)).replace(/\n/g, "<br>")}</div>` : ""}
${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 '<div style="color:var(--text-muted);font-size:13px;">\u6682\u65e0\u8be5\u65e5\u671f\u7ed3\u7b97\u6982\u7387\u5206\u5e03</div>';
}
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 `
<div class="prob-row">
<div class="prob-label">${escapeHtml(String(label))}</div>
<div class="prob-bar-track">
<div class="prob-bar-fill rank-${i}" style="width:${Math.max(pct, 8)}%">${pct}%</div>
</div>
</div>
`;
})
.join("");
}
function buildModelBarsHtml(models, deb, sym) {
if (!models || Object.keys(models).length === 0) {
return '<div style="color:var(--text-muted);font-size:13px;">\u6682\u65e0\u591a\u6a21\u578b\u9884\u62a5</div>';
}
const numericValues = Object.values(models)
.filter((v) => Number.isFinite(Number(v)))
.map(Number);
if (!numericValues.length) {
return '<div style="color:var(--text-muted);font-size:13px;">\u6682\u65e0\u591a\u6a21\u578b\u9884\u62a5</div>';
}
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 += `
<div class="model-row">
<div class="model-name" title="${escapeHtml(name)}">${escapeHtml(name)}</div>
<div class="model-bar-track">
<div class="model-bar-fill" style="width:${pct}%">${numVal}${sym}</div>
${deb != null ? `<div class="model-deb-line" style="left:${((Number(deb) - minVal) / range) * 100}%"></div>` : ""}
</div>
</div>
`;
});
if (deb != null && Number.isFinite(Number(deb))) {
const pct = ((Number(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}%">${Number(deb)}${sym}</div>
</div>
</div>
`;
}
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 `
<div class="future-trend-card">
<div class="future-trend-label">${escapeHtml(metric.label)}</div>
<div class="future-trend-value${toneClass}">${escapeHtml(metric.value)}</div>
<div class="future-trend-note">${escapeHtml(metric.note)}</div>
</div>
`;
})
.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 `
<div><strong>目标日期:</strong>${escapeHtml(dateStr)}</div>
<div><strong>峰值窗口:</strong>暂无足够的小时级 forecast 数据,无法生成目标日午后峰值窗口判断。</div>
`;
}
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 `
<div><strong>目标日期:</strong>${escapeHtml(dateStr)}</div>
<div><strong>峰值窗口:</strong>${escapeHtml(start.label)} - ${escapeHtml(end.label)}(优先取 12:00-18:00</div>
<div><strong>峰值预判:</strong>${Number.isFinite(peakTemp) ? `${peakTemp.toFixed(1)}${sym}` : "--"} @ ${escapeHtml(peakPoint?.label || "--")}</div>
<div><strong>窗口温度:</strong>${Number.isFinite(startTemp) ? `${startTemp.toFixed(1)}${sym}` : "--"}${Number.isFinite(endTemp) ? `${endTemp.toFixed(1)}${sym}` : "--"}${formatDelta(windowDelta, sym)}</div>
<div><strong>露点变化:</strong>${formatDelta(windowDewDelta, sym)},用于判断午后暖湿输入是否增强。</div>
<div><strong>风向演变:</strong>${bucketLabel(firstBucket)}${bucketLabel(lastBucket)},关注峰值前后是否转南风或回摆北风。</div>
<div><strong>气压变化:</strong>${formatDelta(windowPressureDelta, " hPa")},上升更偏向冷空气压入。</div>
<div><strong>降水 / 云量:</strong>${Math.round(maxPrecip)}% / ${Math.round(maxCloud)}%,用于判断峰值时段是否受云系压制。</div>
`;
}
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(
`<div><strong>\u5f53\u524d\u4e3b\u7ad9\uff1a</strong>${current.temp ?? "--"}${data.temp_symbol || "\u00B0C"} @ ${current.obs_time || "--"}</div>`,
);
lines.push(
`<div><strong>\u539f\u59cb METAR\uff1a</strong>${escapeHtml(current.raw_metar || "\u6682\u65e0")}</div>`,
);
lines.push(
`<div><strong>\u8fd1 0-2 \u5c0f\u65f6\uff1a</strong>${formatDelta(shortDelta, data.temp_symbol || "\u00B0C")}\uff0c\u4f9d\u636e\u6700\u8fd1 METAR \u5e8f\u5217\u5224\u65ad\u77ed\u65f6\u52a8\u91cf\u3002</div>`,
);
lines.push(
`<div><strong>${sourceLabel}\uff1a</strong>${nearbyCount} \u4e2a\u7ad9\u70b9\u53c2\u4e0e\u90bb\u8fd1\u76d1\u63a7\u3002</div>`,
);
if (nearbyLead) {
const tone =
nearbyLead.diff > 0
? "\u504f\u6696"
: nearbyLead.diff < 0
? "\u504f\u51b7"
: "\u6301\u5e73";
lines.push(
`<div><strong>\u9886\u5148\u7ad9\uff1a</strong>${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</div>`,
);
}
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 = `
<div class="h-stat-card"><span class="label">\u76ee\u6807\u65e5\u9884\u62a5</span><span class="val">${forecastEntry?.max_temp ?? "--"}${sym}</span></div>
<div class="h-stat-card"><span class="label">DEB \u9884\u62a5</span><span class="val">${deb ?? "--"}${sym}</span></div>
<div class="h-stat-card"><span class="label">\u52a8\u6001\u5206\u5e03\u4e2d\u5fc3</span><span class="val">${mu != null ? `${mu.toFixed(1)}${sym}` : "--"}</span></div>
<div class="h-stat-card"><span class="label">\u8d8b\u52bf\u8bc4\u5206</span><span class="val">${front.score > 0 ? "+" : ""}${front.score}</span></div>
`;
probBars.innerHTML = buildProbabilityBarsHtml(probabilities);
modelBars.innerHTML = buildModelBarsHtml(models, deb, sym);
trendGrid.innerHTML = buildTrendCards(front.metrics);
if (nowcastTitle) {
nowcastTitle.innerHTML =
dateStr === data.local_date
? "&#9201; 未来 0-2 小时临近判断"
: "&#9201; 目标日午后峰值窗口";
}
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 =
'<span style="color:var(--text-muted)">暂无风险档案</span>';
return;
}
container.innerHTML = `
<div class="risk-row"><span class="risk-label">\u{1F4CD} 机场</span><span>${risk.airport} (${risk.icao})</span></div>
<div class="risk-row"><span class="risk-label">\u{1F4CF} 距离</span><span>${risk.distance_km}km</span></div>
${risk.warning ? `<div class="risk-row"><span class="risk-label">\u26A0\uFE0F 注意</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"));
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 =
'<span style="color:var(--text-muted)">\u6b63\u5728\u83b7\u53d6\u5386\u53f2\u6570\u636e...</span>';
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 =
'<span style="color:var(--text-muted)">\u8fd115\u5929\u6682\u65e0\u8be5\u57ce\u5e02\u5386\u53f2\u6570\u636e</span>';
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 = `
<div class="h-stat-card"><span class="label">DEB \u7ed3\u7b97\u80dc\u7387 (WU)</span><span class="val">${hitRate === "--" ? "--" : `${hitRate}%`}</span></div>
<div class="h-stat-card"><span class="label">DEB MAE</span><span class="val">${debMae}&deg;</span></div>
<div class="h-stat-card"><span class="label">\u8fd115\u5929\u5df2\u7ed3\u7b97\u6837\u672c</span><span class="val">${settledData.length}\u5929</span></div>
`;
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 =
'<span style="color:var(--text-danger)">\u83b7\u53d6\u5386\u53f2\u4fe1\u606f\u5931\u8d25</span>';
}
}
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();
});