2026-03-18 12:51:24 +08:00
|
|
|
|
const DEFAULT_CONFIG = {
|
|
|
|
|
|
apiBase: "https://polyweather-pro.vercel.app",
|
|
|
|
|
|
authToken: "",
|
|
|
|
|
|
selectedCity: "",
|
|
|
|
|
|
siteBase: "https://polyweather-pro.vercel.app"
|
|
|
|
|
|
};
|
2026-04-19 03:19:23 +08:00
|
|
|
|
const CACHE_VERSION = "v3";
|
2026-03-20 18:43:01 +08:00
|
|
|
|
const locale = String(navigator.language || "en").toLowerCase().startsWith("zh")
|
|
|
|
|
|
? "zh"
|
|
|
|
|
|
: "en";
|
|
|
|
|
|
const I18N = {
|
|
|
|
|
|
zh: {
|
|
|
|
|
|
loadingWeather: "正在加载温度数据...",
|
|
|
|
|
|
loadingWeatherRefresh: "正在刷新最新温度数据...",
|
|
|
|
|
|
loadingCities: "正在加载城市列表...",
|
|
|
|
|
|
riskLow: "低风险",
|
|
|
|
|
|
riskMedium: "中风险",
|
|
|
|
|
|
riskHigh: "高风险",
|
|
|
|
|
|
settlementSource: "结算源",
|
|
|
|
|
|
settlementAirport: "结算机场",
|
|
|
|
|
|
hko: "香港天文台 (HKO)",
|
|
|
|
|
|
cwa: "交通部中央气象署 (CWA)",
|
2026-04-03 01:53:28 +08:00
|
|
|
|
noaa: "NOAA 官方时序",
|
2026-04-19 03:19:23 +08:00
|
|
|
|
wunderground: "WU/weather.com 历史参考页",
|
2026-03-20 18:43:01 +08:00
|
|
|
|
city: "城市",
|
|
|
|
|
|
refresh: "刷新数据",
|
|
|
|
|
|
cityProfile: "城市档案",
|
|
|
|
|
|
distance: "站点距离",
|
|
|
|
|
|
obsUpdate: "观测更新",
|
|
|
|
|
|
nearbyStations: "周边站点",
|
|
|
|
|
|
intradayTrend: "今日日内走势(简版)",
|
2026-03-23 18:28:11 +08:00
|
|
|
|
directionTitle: "方向判断",
|
2026-03-20 18:43:01 +08:00
|
|
|
|
forecast: "多日预报",
|
2026-03-23 18:28:11 +08:00
|
|
|
|
openFull: "打开网站查看更多",
|
|
|
|
|
|
openFullHint: "插件只提供基础判断;更多分析请到网站查看。",
|
2026-03-20 18:43:01 +08:00
|
|
|
|
noTrendData: "暂无趋势数据",
|
|
|
|
|
|
noForecast: "暂无多日预报",
|
|
|
|
|
|
noContinuousObs: "暂无连续观测",
|
|
|
|
|
|
nearbyMonitoringSuffix: "个参与监控",
|
|
|
|
|
|
today: "今天",
|
2026-03-21 20:56:21 +08:00
|
|
|
|
omSeries: "OM预测",
|
2026-04-03 01:53:28 +08:00
|
|
|
|
noaaSettlementRef: "NOAA 结算参考",
|
2026-03-23 14:39:41 +08:00
|
|
|
|
noaaSettlementLegend:
|
2026-04-03 01:53:28 +08:00
|
|
|
|
"该城市按 NOAA 最终完成质控后的最高整度读数结算;图中曲线仅作结算参考。",
|
2026-03-20 18:43:01 +08:00
|
|
|
|
loadCityDetailFailed: "加载城市详情失败",
|
|
|
|
|
|
refreshFailed: "刷新温度数据失败",
|
|
|
|
|
|
initFailed: "初始化失败",
|
|
|
|
|
|
publicReadHint: "当前插件是公开读模式,Token 可留空。请检查后端是否仍开启了接口鉴权。",
|
2026-03-21 19:15:21 +08:00
|
|
|
|
publicModeHint: "公开模式只需配置 API Base;Token 可留空。",
|
|
|
|
|
|
freshnessRecent: "数据约 {minutes} 分钟前更新。",
|
|
|
|
|
|
freshnessWarn: "数据已 {minutes} 分钟未更新,建议点右上角刷新。",
|
2026-03-23 18:28:11 +08:00
|
|
|
|
freshnessStale: "数据已 {minutes} 分钟未更新,当前结果可能偏旧,请立即刷新。",
|
|
|
|
|
|
directionWarmer: "未来偏升温",
|
|
|
|
|
|
directionCooler: "未来偏降温",
|
|
|
|
|
|
directionFlat: "方向不清",
|
|
|
|
|
|
confidenceHigh: "高置信",
|
|
|
|
|
|
confidenceMedium: "中置信",
|
|
|
|
|
|
confidenceLow: "低置信",
|
|
|
|
|
|
confidenceNeutral: "待观察",
|
|
|
|
|
|
decisionWindow: "未来约 {hours} 小时",
|
|
|
|
|
|
reasonDebAbove: "DEB 路径高于当前实测,未来窗口上沿约 {delta}{symbol}。",
|
|
|
|
|
|
reasonDebBelow: "DEB 路径低于当前实测,未来窗口下沿约 {delta}{symbol}。",
|
|
|
|
|
|
reasonObsAboveOm: "当前实测高于 OM 基线约 {delta}{symbol},短时偏暖。",
|
|
|
|
|
|
reasonObsBelowOm: "当前实测低于 OM 基线约 {delta}{symbol},短时偏冷。",
|
|
|
|
|
|
reasonNearPeak: "当前已接近日内高点,继续上冲空间有限。",
|
|
|
|
|
|
reasonRoomToPeak: "当前距日内预测高点仍有约 {delta}{symbol} 空间。",
|
|
|
|
|
|
reasonRangeWide: "未来窗口振幅偏大,波动仍可能反复。",
|
|
|
|
|
|
reasonRangeTight: "未来窗口振幅较小,更像缓慢推进而非急变。",
|
|
|
|
|
|
reasonNoObs: "当前连续实测偏少,方向判断主要依赖短窗预测。",
|
|
|
|
|
|
decisionSummaryWarmer: "未来几小时更可能缓慢抬升,板面更容易向高温侧移动。",
|
|
|
|
|
|
decisionSummaryCooler: "未来几小时更可能回落,板面更容易向低温侧移动。",
|
|
|
|
|
|
decisionSummaryFlat: "未来几小时更像震荡整理,短时升降温方向不够清晰。"
|
2026-03-20 18:43:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
en: {
|
|
|
|
|
|
loadingWeather: "Loading weather data...",
|
|
|
|
|
|
loadingWeatherRefresh: "Refreshing latest weather data...",
|
|
|
|
|
|
loadingCities: "Loading city list...",
|
|
|
|
|
|
riskLow: "Low Risk",
|
|
|
|
|
|
riskMedium: "Medium Risk",
|
|
|
|
|
|
riskHigh: "High Risk",
|
|
|
|
|
|
settlementSource: "Settlement Source",
|
|
|
|
|
|
settlementAirport: "Settlement Airport",
|
|
|
|
|
|
hko: "Hong Kong Observatory (HKO)",
|
|
|
|
|
|
cwa: "Central Weather Administration (CWA)",
|
2026-04-03 01:53:28 +08:00
|
|
|
|
noaa: "NOAA official timeseries",
|
2026-04-19 03:19:23 +08:00
|
|
|
|
wunderground: "WU/weather.com history reference",
|
2026-03-20 18:43:01 +08:00
|
|
|
|
city: "City",
|
|
|
|
|
|
refresh: "Refresh data",
|
|
|
|
|
|
cityProfile: "City Profile",
|
|
|
|
|
|
distance: "Station Distance",
|
|
|
|
|
|
obsUpdate: "Observation Update",
|
|
|
|
|
|
nearbyStations: "Nearby Stations",
|
|
|
|
|
|
intradayTrend: "Today's Intraday Trend",
|
2026-03-23 18:28:11 +08:00
|
|
|
|
directionTitle: "Direction Bias",
|
2026-03-20 18:43:01 +08:00
|
|
|
|
forecast: "Forecast",
|
2026-03-23 18:28:11 +08:00
|
|
|
|
openFull: "Open Website for More",
|
|
|
|
|
|
openFullHint: "The extension only provides a basic bias. Visit the site for more analysis.",
|
2026-03-20 18:43:01 +08:00
|
|
|
|
noTrendData: "No trend data available",
|
|
|
|
|
|
noForecast: "No multi-day forecast",
|
|
|
|
|
|
noContinuousObs: "No continuous observations",
|
|
|
|
|
|
nearbyMonitoringSuffix: " stations monitored",
|
|
|
|
|
|
today: "Today",
|
2026-03-21 20:56:21 +08:00
|
|
|
|
omSeries: "OM Forecast",
|
2026-04-03 01:53:28 +08:00
|
|
|
|
noaaSettlementRef: "NOAA Settlement Reference",
|
2026-03-23 14:39:41 +08:00
|
|
|
|
noaaSettlementLegend:
|
2026-04-03 01:53:28 +08:00
|
|
|
|
"This city settles on NOAA using the finalized highest rounded reading; the plotted line is only a settlement reference.",
|
2026-03-20 18:43:01 +08:00
|
|
|
|
loadCityDetailFailed: "Failed to load city detail",
|
|
|
|
|
|
refreshFailed: "Failed to refresh weather data",
|
|
|
|
|
|
initFailed: "Initialization failed",
|
|
|
|
|
|
publicReadHint: "The extension is in public read mode. Token can be empty. Check whether the backend still requires auth.",
|
2026-03-21 19:15:21 +08:00
|
|
|
|
publicModeHint: "In public mode only API Base is required; Token can be empty.",
|
|
|
|
|
|
freshnessRecent: "Data updated about {minutes} min ago.",
|
|
|
|
|
|
freshnessWarn: "Data is {minutes} min old. Consider refreshing.",
|
2026-03-23 18:28:11 +08:00
|
|
|
|
freshnessStale: "Data is {minutes} min old and may be stale. Refresh now.",
|
|
|
|
|
|
directionWarmer: "Bias Warmer",
|
|
|
|
|
|
directionCooler: "Bias Cooler",
|
|
|
|
|
|
directionFlat: "Direction Unclear",
|
|
|
|
|
|
confidenceHigh: "High Conviction",
|
|
|
|
|
|
confidenceMedium: "Medium Conviction",
|
|
|
|
|
|
confidenceLow: "Low Conviction",
|
|
|
|
|
|
confidenceNeutral: "Watch",
|
|
|
|
|
|
decisionWindow: "Next ~{hours}h",
|
|
|
|
|
|
reasonDebAbove: "DEB path sits above current observation, with about {delta}{symbol} upside in the window.",
|
|
|
|
|
|
reasonDebBelow: "DEB path sits below current observation, with about {delta}{symbol} downside in the window.",
|
|
|
|
|
|
reasonObsAboveOm: "Current observation runs about {delta}{symbol} above the OM baseline, keeping the short-term tone warmer.",
|
|
|
|
|
|
reasonObsBelowOm: "Current observation runs about {delta}{symbol} below the OM baseline, keeping the short-term tone cooler.",
|
|
|
|
|
|
reasonNearPeak: "Current temperature is already close to the intraday peak, limiting further upside.",
|
|
|
|
|
|
reasonRoomToPeak: "There is still about {delta}{symbol} room to the projected intraday peak.",
|
|
|
|
|
|
reasonRangeWide: "The next-window range is wide, so the path can still swing around.",
|
|
|
|
|
|
reasonRangeTight: "The next-window range is tight, pointing to a gradual move rather than a sharp break.",
|
|
|
|
|
|
reasonNoObs: "Continuous observations are sparse, so the bias relies more on the short-window forecast.",
|
|
|
|
|
|
decisionSummaryWarmer: "The next few hours are more likely to drift warmer, so the board should lean toward the hotter side.",
|
|
|
|
|
|
decisionSummaryCooler: "The next few hours are more likely to ease lower, so the board should lean toward the cooler side.",
|
|
|
|
|
|
decisionSummaryFlat: "The next few hours look more range-bound, so there is no clear temperature direction yet."
|
2026-03-20 18:43:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function t(key) {
|
|
|
|
|
|
return I18N[locale][key] || I18N.zh[key] || key;
|
|
|
|
|
|
}
|
2026-03-18 12:51:24 +08:00
|
|
|
|
|
|
|
|
|
|
let state = {
|
|
|
|
|
|
config: { ...DEFAULT_CONFIG },
|
|
|
|
|
|
cities: [],
|
|
|
|
|
|
detail: null,
|
|
|
|
|
|
lastActiveUrl: "",
|
|
|
|
|
|
syncBusy: false,
|
|
|
|
|
|
loadingCount: 0,
|
|
|
|
|
|
chartHover: {
|
|
|
|
|
|
points: [],
|
|
|
|
|
|
tempSymbol: "°C"
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const els = {
|
|
|
|
|
|
citySelect: document.getElementById("citySelect"),
|
|
|
|
|
|
refreshBtn: document.getElementById("refreshBtn"),
|
|
|
|
|
|
riskBadge: document.getElementById("riskBadge"),
|
|
|
|
|
|
settlementLabel: document.getElementById("settlementLabel"),
|
|
|
|
|
|
settlementValue: document.getElementById("settlementValue"),
|
|
|
|
|
|
distanceValue: document.getElementById("distanceValue"),
|
|
|
|
|
|
obsTimeValue: document.getElementById("obsTimeValue"),
|
|
|
|
|
|
nearbyValue: document.getElementById("nearbyValue"),
|
|
|
|
|
|
trendCanvas: document.getElementById("trendCanvas"),
|
|
|
|
|
|
chartTooltip: document.getElementById("chartTooltip"),
|
|
|
|
|
|
chartLegend: document.getElementById("chartLegend"),
|
|
|
|
|
|
forecastRow: document.getElementById("forecastRow"),
|
|
|
|
|
|
errorBox: document.getElementById("errorBox"),
|
|
|
|
|
|
openFullBtn: document.getElementById("openFullBtn"),
|
2026-03-23 18:28:11 +08:00
|
|
|
|
openFullHint: document.getElementById("openFullHint"),
|
2026-03-18 12:51:24 +08:00
|
|
|
|
loadingOverlay: document.getElementById("loadingOverlay"),
|
2026-03-20 18:43:01 +08:00
|
|
|
|
loadingText: document.getElementById("loadingText"),
|
|
|
|
|
|
cityLabel: document.getElementById("cityLabel"),
|
|
|
|
|
|
profileTitle: document.getElementById("profileTitle"),
|
|
|
|
|
|
distanceLabel: document.getElementById("distanceLabel"),
|
|
|
|
|
|
obsTimeLabel: document.getElementById("obsTimeLabel"),
|
|
|
|
|
|
nearbyLabel: document.getElementById("nearbyLabel"),
|
|
|
|
|
|
trendTitle: document.getElementById("trendTitle"),
|
2026-03-23 18:28:11 +08:00
|
|
|
|
decisionTitle: document.getElementById("decisionTitle"),
|
|
|
|
|
|
decisionDirection: document.getElementById("decisionDirection"),
|
|
|
|
|
|
decisionWindow: document.getElementById("decisionWindow"),
|
|
|
|
|
|
decisionConfidence: document.getElementById("decisionConfidence"),
|
|
|
|
|
|
decisionSummary: document.getElementById("decisionSummary"),
|
|
|
|
|
|
decisionReasons: document.getElementById("decisionReasons"),
|
|
|
|
|
|
forecastTitle: document.getElementById("forecastTitle"),
|
2026-03-21 19:15:21 +08:00
|
|
|
|
freshnessHint: document.getElementById("freshnessHint")
|
2026-03-18 12:51:24 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeBase(url) {
|
|
|
|
|
|
return String(url || "").trim().replace(/\/+$/, "");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildCacheKey(kind, cityName = "") {
|
|
|
|
|
|
const apiBase = normalizeBase(state.config?.apiBase || DEFAULT_CONFIG.apiBase);
|
|
|
|
|
|
const city = String(cityName || "").trim().toLowerCase();
|
|
|
|
|
|
return `polyweather:${CACHE_VERSION}:${apiBase}:${kind}:${city}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function getLocalValue(key) {
|
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
|
chrome.storage.local.get([key], (items) => {
|
|
|
|
|
|
resolve(items?.[key] ?? null);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function setLocalValue(key, value) {
|
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
|
chrome.storage.local.set({ [key]: value }, resolve);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeForMatch(value) {
|
|
|
|
|
|
let out = decodeURIComponent(String(value || "")).toLowerCase();
|
|
|
|
|
|
try {
|
|
|
|
|
|
out = out.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
|
|
|
|
|
} catch (_e) {
|
|
|
|
|
|
// Ignore unicode normalization failures.
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function escapeRegExp(value) {
|
|
|
|
|
|
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getCityAliasTokens(rawCityName) {
|
|
|
|
|
|
const normalized = normalizeForMatch(rawCityName).trim().replace(/\s+/g, " ");
|
|
|
|
|
|
const compact = normalized.replace(/\s+/g, "");
|
|
|
|
|
|
const hyphen = normalized.replace(/\s+/g, "-");
|
|
|
|
|
|
const aliases = new Set([normalized, compact, hyphen]);
|
|
|
|
|
|
|
|
|
|
|
|
if (normalized === "hong kong") {
|
|
|
|
|
|
aliases.add("hongkong");
|
|
|
|
|
|
aliases.add("hong-kong");
|
|
|
|
|
|
aliases.add("hk");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (normalized === "taipei") {
|
|
|
|
|
|
aliases.add("taipei-city");
|
|
|
|
|
|
aliases.add("tp");
|
|
|
|
|
|
aliases.add("tpe");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (normalized === "new york") {
|
|
|
|
|
|
aliases.add("new-york");
|
|
|
|
|
|
aliases.add("nyc");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (normalized === "sao paulo") {
|
|
|
|
|
|
aliases.add("sao-paulo");
|
|
|
|
|
|
aliases.add("saopaulo");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (normalized === "tel aviv") {
|
|
|
|
|
|
aliases.add("tel-aviv");
|
|
|
|
|
|
aliases.add("telaviv");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (normalized === "buenos aires") {
|
|
|
|
|
|
aliases.add("buenos-aires");
|
|
|
|
|
|
aliases.add("buenosaires");
|
|
|
|
|
|
}
|
2026-03-30 01:40:37 +08:00
|
|
|
|
if (normalized === "aurora") {
|
|
|
|
|
|
aliases.add("denver");
|
|
|
|
|
|
aliases.add("denver-co");
|
|
|
|
|
|
aliases.add("buckley");
|
|
|
|
|
|
aliases.add("kbkf");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (normalized === "los angeles") {
|
|
|
|
|
|
aliases.add("los-angeles");
|
|
|
|
|
|
aliases.add("lax");
|
|
|
|
|
|
aliases.add("klax");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (normalized === "san francisco") {
|
|
|
|
|
|
aliases.add("san-francisco");
|
|
|
|
|
|
aliases.add("sfo");
|
|
|
|
|
|
aliases.add("ksfo");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (normalized === "austin") {
|
|
|
|
|
|
aliases.add("aus");
|
|
|
|
|
|
aliases.add("kaus");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (normalized === "houston") {
|
|
|
|
|
|
aliases.add("hou");
|
|
|
|
|
|
aliases.add("hobby");
|
|
|
|
|
|
aliases.add("khou");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (normalized === "mexico city") {
|
|
|
|
|
|
aliases.add("mexicocity");
|
|
|
|
|
|
aliases.add("ciudad-de-mexico");
|
|
|
|
|
|
aliases.add("cdmx");
|
|
|
|
|
|
aliases.add("mmmx");
|
|
|
|
|
|
}
|
2026-03-18 12:51:24 +08:00
|
|
|
|
|
|
|
|
|
|
return [...aliases].filter((item) => item && item.length >= 2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getCityAliasIndex() {
|
|
|
|
|
|
const out = [];
|
|
|
|
|
|
for (const city of state.cities) {
|
|
|
|
|
|
const names = [
|
|
|
|
|
|
String(city?.name || ""),
|
|
|
|
|
|
String(city?.display_name || "")
|
|
|
|
|
|
];
|
|
|
|
|
|
const seen = new Set();
|
|
|
|
|
|
for (const name of names) {
|
|
|
|
|
|
for (const alias of getCityAliasTokens(name)) {
|
|
|
|
|
|
if (seen.has(alias)) continue;
|
|
|
|
|
|
seen.add(alias);
|
|
|
|
|
|
out.push({ alias, cityName: city.name });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
out.sort((a, b) => b.alias.length - a.alias.length);
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function matchCityInText(text) {
|
|
|
|
|
|
const target = normalizeForMatch(text);
|
|
|
|
|
|
if (!target) return "";
|
|
|
|
|
|
const aliasIndex = getCityAliasIndex();
|
|
|
|
|
|
for (const row of aliasIndex) {
|
|
|
|
|
|
const alias = row.alias;
|
|
|
|
|
|
if (!alias) continue;
|
|
|
|
|
|
if (/^[a-z0-9-]+$/.test(alias)) {
|
|
|
|
|
|
const re = new RegExp(`(^|[^a-z0-9])${escapeRegExp(alias)}([^a-z0-9]|$)`);
|
|
|
|
|
|
if (re.test(target)) return row.cityName;
|
|
|
|
|
|
} else if (target.includes(alias)) {
|
|
|
|
|
|
return row.cityName;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function inferCityFromUrl(url) {
|
|
|
|
|
|
if (!url) return "";
|
|
|
|
|
|
let parsed = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
parsed = new URL(url);
|
|
|
|
|
|
} catch (_e) {
|
|
|
|
|
|
parsed = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (parsed) {
|
|
|
|
|
|
const queryCity = parsed.searchParams.get("city") || parsed.searchParams.get("c");
|
|
|
|
|
|
if (queryCity) {
|
|
|
|
|
|
const byQuery = matchCityInText(queryCity);
|
|
|
|
|
|
if (byQuery) return byQuery;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const hostPath = `${parsed.hostname} ${parsed.pathname} ${parsed.hash || ""}`;
|
|
|
|
|
|
const byPath = matchCityInText(hostPath);
|
|
|
|
|
|
if (byPath) return byPath;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return matchCityInText(url);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function showError(message) {
|
|
|
|
|
|
els.errorBox.textContent = message;
|
|
|
|
|
|
els.errorBox.classList.remove("hidden");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 19:15:21 +08:00
|
|
|
|
function tf(key, params = {}) {
|
|
|
|
|
|
let text = t(key);
|
|
|
|
|
|
for (const [name, value] of Object.entries(params)) {
|
|
|
|
|
|
text = text.replace(`{${name}}`, String(value));
|
|
|
|
|
|
}
|
|
|
|
|
|
return text;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 12:51:24 +08:00
|
|
|
|
function clearError() {
|
|
|
|
|
|
els.errorBox.textContent = "";
|
|
|
|
|
|
els.errorBox.classList.add("hidden");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 18:43:01 +08:00
|
|
|
|
function setLoading(loading, text = t("loadingWeather")) {
|
2026-03-18 12:51:24 +08:00
|
|
|
|
if (!els.loadingOverlay) return;
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
|
state.loadingCount += 1;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
state.loadingCount = Math.max(0, state.loadingCount - 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
const isVisible = state.loadingCount > 0;
|
|
|
|
|
|
els.loadingOverlay.classList.toggle("hidden", !isVisible);
|
|
|
|
|
|
if (isVisible && els.loadingText) {
|
|
|
|
|
|
els.loadingText.textContent = text;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hideChartTooltip() {
|
|
|
|
|
|
if (!els.chartTooltip) return;
|
|
|
|
|
|
els.chartTooltip.classList.add("hidden");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setChartHover(points, tempSymbol) {
|
|
|
|
|
|
state.chartHover = {
|
|
|
|
|
|
points: Array.isArray(points) ? points : [],
|
|
|
|
|
|
tempSymbol: tempSymbol || "°C"
|
|
|
|
|
|
};
|
|
|
|
|
|
if (!state.chartHover.points.length) {
|
|
|
|
|
|
hideChartTooltip();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onTrendCanvasHover(event) {
|
|
|
|
|
|
const canvas = els.trendCanvas;
|
|
|
|
|
|
const tooltip = els.chartTooltip;
|
|
|
|
|
|
const hoverPoints = state.chartHover?.points || [];
|
|
|
|
|
|
if (!canvas || !tooltip || !hoverPoints.length) {
|
|
|
|
|
|
hideChartTooltip();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
|
|
|
|
const scaleX = canvas.width / Math.max(rect.width, 1);
|
|
|
|
|
|
const scaleY = canvas.height / Math.max(rect.height, 1);
|
|
|
|
|
|
const x = (event.clientX - rect.left) * scaleX;
|
|
|
|
|
|
const y = (event.clientY - rect.top) * scaleY;
|
|
|
|
|
|
if (x < 0 || y < 0 || x > canvas.width || y > canvas.height) {
|
|
|
|
|
|
hideChartTooltip();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let nearest = null;
|
|
|
|
|
|
let nearestDistSq = Number.POSITIVE_INFINITY;
|
|
|
|
|
|
for (const p of hoverPoints) {
|
|
|
|
|
|
const dx = p.x - x;
|
|
|
|
|
|
const dy = p.y - y;
|
|
|
|
|
|
const distSq = dx * dx + dy * dy;
|
|
|
|
|
|
if (distSq < nearestDistSq) {
|
|
|
|
|
|
nearestDistSq = distSq;
|
|
|
|
|
|
nearest = p;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!nearest) {
|
|
|
|
|
|
hideChartTooltip();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const symbol = state.chartHover?.tempSymbol || "°C";
|
|
|
|
|
|
tooltip.textContent = `${nearest.series} ${nearest.time} ${nearest.value.toFixed(1)}${symbol}`;
|
|
|
|
|
|
tooltip.classList.remove("hidden");
|
|
|
|
|
|
|
|
|
|
|
|
const wrap = canvas.parentElement;
|
|
|
|
|
|
if (!wrap) return;
|
|
|
|
|
|
const wrapRect = wrap.getBoundingClientRect();
|
|
|
|
|
|
const localX = event.clientX - wrapRect.left;
|
|
|
|
|
|
const localY = event.clientY - wrapRect.top;
|
|
|
|
|
|
let left = localX + 10;
|
|
|
|
|
|
let top = localY - 28;
|
|
|
|
|
|
|
|
|
|
|
|
const tipW = tooltip.offsetWidth || 120;
|
|
|
|
|
|
const tipH = tooltip.offsetHeight || 28;
|
|
|
|
|
|
if (left + tipW + 8 > wrap.clientWidth) left = wrap.clientWidth - tipW - 8;
|
|
|
|
|
|
if (left < 8) left = 8;
|
|
|
|
|
|
if (top < 8) top = localY + 12;
|
|
|
|
|
|
if (top + tipH + 8 > wrap.clientHeight) top = wrap.clientHeight - tipH - 8;
|
|
|
|
|
|
|
|
|
|
|
|
tooltip.style.left = `${Math.round(left)}px`;
|
|
|
|
|
|
tooltip.style.top = `${Math.round(top)}px`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setRefreshing(isRefreshing) {
|
|
|
|
|
|
if (!els.refreshBtn) return;
|
|
|
|
|
|
els.refreshBtn.disabled = Boolean(isRefreshing);
|
|
|
|
|
|
els.refreshBtn.classList.toggle("spinning", Boolean(isRefreshing));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function riskText(level) {
|
|
|
|
|
|
const low = String(level || "medium").toLowerCase();
|
2026-03-20 18:43:01 +08:00
|
|
|
|
if (low === "low") return t("riskLow");
|
|
|
|
|
|
if (low === "high") return t("riskHigh");
|
|
|
|
|
|
return t("riskMedium");
|
2026-03-18 12:51:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getSettlementSourceDisplay(detail) {
|
|
|
|
|
|
const source = String(detail?.current?.settlement_source || "").toLowerCase();
|
2026-04-03 01:53:28 +08:00
|
|
|
|
const sourceLabel = String(detail?.current?.settlement_source_label || "").trim();
|
2026-03-18 12:51:24 +08:00
|
|
|
|
if (source === "hko") {
|
|
|
|
|
|
return {
|
2026-03-20 18:43:01 +08:00
|
|
|
|
label: t("settlementSource"),
|
|
|
|
|
|
value: t("hko")
|
2026-03-18 12:51:24 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (source === "cwa") {
|
|
|
|
|
|
return {
|
2026-03-20 18:43:01 +08:00
|
|
|
|
label: t("settlementSource"),
|
|
|
|
|
|
value: t("cwa")
|
2026-03-18 12:51:24 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-03-23 14:39:41 +08:00
|
|
|
|
if (source === "noaa") {
|
|
|
|
|
|
return {
|
|
|
|
|
|
label: t("settlementSource"),
|
|
|
|
|
|
value: t("noaa")
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-04-03 01:53:28 +08:00
|
|
|
|
if (source === "wunderground") {
|
|
|
|
|
|
const stationLabel = sourceLabel || t("wunderground");
|
|
|
|
|
|
const station = String(detail?.current?.station_code || detail?.risk?.icao || "").trim();
|
|
|
|
|
|
return {
|
|
|
|
|
|
label: t("settlementSource"),
|
|
|
|
|
|
value: station ? `${stationLabel} (${station})` : stationLabel
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-03-18 12:51:24 +08:00
|
|
|
|
const airport = detail?.risk?.airport || "--";
|
|
|
|
|
|
const icao = detail?.risk?.icao ? ` (${detail.risk.icao})` : "";
|
|
|
|
|
|
return {
|
2026-03-20 18:43:01 +08:00
|
|
|
|
label: t("settlementAirport"),
|
2026-03-18 12:51:24 +08:00
|
|
|
|
value: `${airport}${icao}`
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatTemp(v, symbol) {
|
|
|
|
|
|
const n = Number(v);
|
|
|
|
|
|
if (!Number.isFinite(n)) return "--";
|
|
|
|
|
|
return `${n.toFixed(1)}${symbol || "°C"}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatForecastDate(day, index) {
|
2026-03-20 18:43:01 +08:00
|
|
|
|
if (index === 0) return t("today");
|
2026-03-18 12:51:24 +08:00
|
|
|
|
const str = String(day || "");
|
|
|
|
|
|
const m = str.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
|
|
|
|
if (!m) return str || "--";
|
|
|
|
|
|
return `${m[2]}/${m[3]}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 19:15:21 +08:00
|
|
|
|
function parseIsoDate(value) {
|
|
|
|
|
|
const text = String(value || "").trim();
|
|
|
|
|
|
if (!text) return null;
|
|
|
|
|
|
const date = new Date(text);
|
|
|
|
|
|
if (Number.isNaN(date.getTime())) return null;
|
|
|
|
|
|
return date;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderFreshness(detail) {
|
|
|
|
|
|
if (!els.freshnessHint) return;
|
|
|
|
|
|
const updatedAt = parseIsoDate(detail?.updated_at);
|
|
|
|
|
|
if (!updatedAt) {
|
|
|
|
|
|
els.freshnessHint.classList.add("hidden");
|
|
|
|
|
|
els.freshnessHint.classList.remove("stale");
|
|
|
|
|
|
els.freshnessHint.textContent = "";
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const minutes = Math.max(
|
|
|
|
|
|
0,
|
|
|
|
|
|
Math.round((Date.now() - updatedAt.getTime()) / 60000)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (minutes < 8) {
|
|
|
|
|
|
els.freshnessHint.classList.add("hidden");
|
|
|
|
|
|
els.freshnessHint.classList.remove("stale");
|
|
|
|
|
|
els.freshnessHint.textContent = "";
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const isStale = minutes >= 20;
|
|
|
|
|
|
els.freshnessHint.classList.remove("hidden");
|
|
|
|
|
|
els.freshnessHint.classList.toggle("stale", isStale);
|
|
|
|
|
|
els.freshnessHint.textContent = isStale
|
|
|
|
|
|
? tf("freshnessStale", { minutes })
|
|
|
|
|
|
: tf("freshnessWarn", { minutes });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 12:51:24 +08:00
|
|
|
|
function parseTimeToMinute(value) {
|
|
|
|
|
|
const text = String(value || "");
|
|
|
|
|
|
const m = text.match(/(\d{1,2}):(\d{2})/);
|
|
|
|
|
|
if (!m) return Number.NaN;
|
|
|
|
|
|
const hh = Number(m[1]);
|
|
|
|
|
|
const mm = Number(m[2]);
|
|
|
|
|
|
if (!Number.isFinite(hh) || !Number.isFinite(mm)) return Number.NaN;
|
|
|
|
|
|
if (hh < 0 || hh > 23 || mm < 0 || mm > 59) return Number.NaN;
|
|
|
|
|
|
return hh * 60 + mm;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getObservationRows(detail) {
|
2026-04-05 10:26:28 +08:00
|
|
|
|
const sourceCode = String(detail?.current?.settlement_source || "").toLowerCase();
|
|
|
|
|
|
const useSettlementSource = sourceCode && sourceCode !== "wunderground";
|
|
|
|
|
|
const obsSource = useSettlementSource && Array.isArray(detail?.settlement_today_obs) && detail.settlement_today_obs.length
|
2026-03-18 12:51:24 +08:00
|
|
|
|
? detail.settlement_today_obs
|
|
|
|
|
|
: Array.isArray(detail?.metar_today_obs)
|
|
|
|
|
|
? detail.metar_today_obs
|
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
|
|
const rows = [];
|
|
|
|
|
|
for (const row of obsSource) {
|
|
|
|
|
|
const temp = Number(row?.temp);
|
|
|
|
|
|
const time = String(row?.time || "");
|
|
|
|
|
|
if (!Number.isFinite(temp) || !time) continue;
|
|
|
|
|
|
rows.push({
|
|
|
|
|
|
temp,
|
|
|
|
|
|
time,
|
|
|
|
|
|
minute: parseTimeToMinute(time)
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const sortable = rows.every((row) => Number.isFinite(row.minute));
|
|
|
|
|
|
if (sortable) {
|
|
|
|
|
|
rows.sort((a, b) => a.minute - b.minute);
|
|
|
|
|
|
}
|
|
|
|
|
|
return rows;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function getConfig() {
|
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
|
chrome.storage.sync.get(DEFAULT_CONFIG, (items) => {
|
|
|
|
|
|
resolve({
|
|
|
|
|
|
apiBase: normalizeBase(items.apiBase || DEFAULT_CONFIG.apiBase),
|
|
|
|
|
|
siteBase: normalizeBase(items.siteBase || items.apiBase || DEFAULT_CONFIG.siteBase),
|
|
|
|
|
|
authToken: String(items.authToken || ""),
|
|
|
|
|
|
selectedCity: String(items.selectedCity || "")
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveConfigPatch(patch) {
|
|
|
|
|
|
const next = { ...state.config, ...patch };
|
|
|
|
|
|
state.config = next;
|
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
|
chrome.storage.sync.set(next, resolve);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function getCachedCities() {
|
|
|
|
|
|
const cached = await getLocalValue(buildCacheKey("cities"));
|
|
|
|
|
|
const cities = cached?.cities;
|
|
|
|
|
|
return Array.isArray(cities) && cities.length ? cities : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function setCachedCities(cities) {
|
|
|
|
|
|
if (!Array.isArray(cities) || !cities.length) return;
|
|
|
|
|
|
await setLocalValue(buildCacheKey("cities"), {
|
|
|
|
|
|
updated_at: new Date().toISOString(),
|
|
|
|
|
|
cities
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function getCachedDetail(cityName) {
|
|
|
|
|
|
if (!cityName) return null;
|
|
|
|
|
|
const cached = await getLocalValue(buildCacheKey("detail", cityName));
|
|
|
|
|
|
return cached?.detail || null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function setCachedDetail(cityName, detail) {
|
|
|
|
|
|
if (!cityName || !detail || typeof detail !== "object") return;
|
|
|
|
|
|
await setLocalValue(buildCacheKey("detail", cityName), {
|
|
|
|
|
|
updated_at: new Date().toISOString(),
|
|
|
|
|
|
detail
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function setSelectedCity(cityName, options = {}) {
|
|
|
|
|
|
const { persist = true, reloadDetail = true } = options;
|
|
|
|
|
|
const target = String(cityName || "");
|
|
|
|
|
|
if (!target) return;
|
|
|
|
|
|
if (!state.cities.find((city) => city.name === target)) return;
|
|
|
|
|
|
|
|
|
|
|
|
const changed = state.config.selectedCity !== target;
|
|
|
|
|
|
state.config.selectedCity = target;
|
|
|
|
|
|
if (changed && persist) {
|
|
|
|
|
|
await saveConfigPatch({ selectedCity: target });
|
|
|
|
|
|
}
|
|
|
|
|
|
renderCitySelect();
|
|
|
|
|
|
if (reloadDetail) {
|
|
|
|
|
|
await loadDetail(target);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function apiGet(path) {
|
|
|
|
|
|
const headers = { Accept: "application/json" };
|
|
|
|
|
|
if (state.config.authToken) {
|
|
|
|
|
|
headers.Authorization = `Bearer ${state.config.authToken}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
const url = `${normalizeBase(state.config.apiBase)}${path}`;
|
|
|
|
|
|
const res = await fetch(url, { headers, cache: "no-store" });
|
|
|
|
|
|
const text = await res.text();
|
|
|
|
|
|
let data = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
data = text ? JSON.parse(text) : null;
|
|
|
|
|
|
} catch (_e) {
|
|
|
|
|
|
data = text;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
const errMsg = typeof data === "object" && data
|
|
|
|
|
|
? JSON.stringify(data)
|
|
|
|
|
|
: String(data || res.statusText);
|
|
|
|
|
|
throw new Error(`HTTP ${res.status}: ${errMsg}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
return data;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderCitySelect() {
|
|
|
|
|
|
const current = state.config.selectedCity;
|
|
|
|
|
|
els.citySelect.innerHTML = "";
|
|
|
|
|
|
for (const c of state.cities) {
|
|
|
|
|
|
const op = document.createElement("option");
|
|
|
|
|
|
op.value = c.name;
|
|
|
|
|
|
op.textContent = c.display_name || c.name;
|
|
|
|
|
|
if (c.name === current) op.selected = true;
|
|
|
|
|
|
els.citySelect.appendChild(op);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderRiskBadge(detail) {
|
|
|
|
|
|
const lvl = String(detail?.risk?.level || "medium").toLowerCase();
|
|
|
|
|
|
els.riskBadge.classList.remove("low", "medium", "high");
|
|
|
|
|
|
els.riskBadge.classList.add(lvl === "low" || lvl === "high" ? lvl : "medium");
|
|
|
|
|
|
els.riskBadge.textContent = riskText(lvl);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function extractTrendSeries(detail) {
|
|
|
|
|
|
const hourly = detail?.hourly || {};
|
|
|
|
|
|
const times = Array.isArray(hourly.times) ? hourly.times : [];
|
|
|
|
|
|
const temps = Array.isArray(hourly.temps) ? hourly.temps : [];
|
|
|
|
|
|
const trend = [];
|
|
|
|
|
|
for (let i = 0; i < times.length; i += 1) {
|
|
|
|
|
|
const t = Number(temps[i]);
|
|
|
|
|
|
if (!Number.isFinite(t)) continue;
|
|
|
|
|
|
const timeText = String(times[i]);
|
|
|
|
|
|
trend.push({ t: timeText, v: t, m: parseTimeToMinute(timeText) });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const obs = [];
|
|
|
|
|
|
for (const row of getObservationRows(detail)) {
|
|
|
|
|
|
obs.push({ t: row.time, v: row.temp, m: row.minute });
|
|
|
|
|
|
}
|
|
|
|
|
|
return { trend, obs };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function drawTrendChart(detail) {
|
|
|
|
|
|
const canvas = els.trendCanvas;
|
|
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
|
|
|
|
const width = canvas.width;
|
|
|
|
|
|
const height = canvas.height;
|
|
|
|
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
|
|
|
|
|
|
|
|
const { trend, obs } = extractTrendSeries(detail);
|
|
|
|
|
|
const points = [...trend, ...obs];
|
|
|
|
|
|
const hoverPoints = [];
|
|
|
|
|
|
const tempSymbol = detail?.temp_symbol || "°C";
|
2026-03-23 14:39:41 +08:00
|
|
|
|
const sourceCode = String(detail?.current?.settlement_source || "").toLowerCase();
|
|
|
|
|
|
const obsSeriesLabel = sourceCode === "noaa"
|
|
|
|
|
|
? t("noaaSettlementRef")
|
2026-04-05 10:26:28 +08:00
|
|
|
|
: String(detail?.current?.settlement_source_label || "OBS").toUpperCase();
|
2026-03-18 12:51:24 +08:00
|
|
|
|
if (!points.length) {
|
|
|
|
|
|
setChartHover([], tempSymbol);
|
|
|
|
|
|
ctx.fillStyle = "#8ba0be";
|
|
|
|
|
|
ctx.font = "14px Inter, sans-serif";
|
2026-03-20 18:43:01 +08:00
|
|
|
|
ctx.fillText(t("noTrendData"), 18, 40);
|
2026-03-18 12:51:24 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const minVal = Math.min(...points.map((p) => p.v));
|
|
|
|
|
|
const maxVal = Math.max(...points.map((p) => p.v));
|
|
|
|
|
|
const vPad = Math.max(1, (maxVal - minVal) * 0.2);
|
|
|
|
|
|
const yMin = minVal - vPad;
|
|
|
|
|
|
const yMax = maxVal + vPad;
|
|
|
|
|
|
const inner = { left: 38, top: 16, right: width - 12, bottom: height - 30 };
|
|
|
|
|
|
const w = inner.right - inner.left;
|
|
|
|
|
|
const h = inner.bottom - inner.top;
|
|
|
|
|
|
|
|
|
|
|
|
const minuteValues = points.map((p) => p.m).filter((v) => Number.isFinite(v));
|
|
|
|
|
|
const canUseMinuteAxis = minuteValues.length >= 2 && Math.max(...minuteValues) > Math.min(...minuteValues);
|
|
|
|
|
|
const minMinute = canUseMinuteAxis ? Math.min(...minuteValues) : 0;
|
|
|
|
|
|
const maxMinute = canUseMinuteAxis ? Math.max(...minuteValues) : 0;
|
|
|
|
|
|
|
|
|
|
|
|
function xFromIndex(idx, total) {
|
|
|
|
|
|
if (total <= 1) return inner.left;
|
|
|
|
|
|
return inner.left + (idx / (total - 1)) * w;
|
|
|
|
|
|
}
|
|
|
|
|
|
function xFromMinute(minute) {
|
|
|
|
|
|
if (!canUseMinuteAxis || !Number.isFinite(minute)) return inner.left;
|
|
|
|
|
|
return inner.left + ((minute - minMinute) / (maxMinute - minMinute)) * w;
|
|
|
|
|
|
}
|
|
|
|
|
|
function yFromValue(v) {
|
|
|
|
|
|
return inner.bottom - ((v - yMin) / (yMax - yMin)) * h;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ctx.strokeStyle = "rgba(255,255,255,0.08)";
|
|
|
|
|
|
ctx.lineWidth = 1;
|
|
|
|
|
|
for (let i = 0; i < 4; i += 1) {
|
|
|
|
|
|
const y = inner.top + (h / 3) * i;
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
ctx.moveTo(inner.left, y);
|
|
|
|
|
|
ctx.lineTo(inner.right, y);
|
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ctx.fillStyle = "#7f95b2";
|
|
|
|
|
|
ctx.font = "12px Inter, sans-serif";
|
|
|
|
|
|
for (let i = 0; i < 4; i += 1) {
|
|
|
|
|
|
const val = yMax - ((yMax - yMin) / 3) * i;
|
|
|
|
|
|
const y = inner.top + (h / 3) * i + 4;
|
|
|
|
|
|
ctx.fillText(`${val.toFixed(0)}°`, 6, y);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (trend.length) {
|
|
|
|
|
|
ctx.strokeStyle = "#facc15";
|
|
|
|
|
|
ctx.lineWidth = 2.5;
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
trend.forEach((p, idx) => {
|
|
|
|
|
|
const x = canUseMinuteAxis ? xFromMinute(p.m) : xFromIndex(idx, trend.length);
|
|
|
|
|
|
const y = yFromValue(p.v);
|
2026-03-21 20:56:21 +08:00
|
|
|
|
hoverPoints.push({ x, y, time: p.t, value: p.v, series: t("omSeries") });
|
2026-03-18 12:51:24 +08:00
|
|
|
|
if (idx === 0) ctx.moveTo(x, y);
|
|
|
|
|
|
else ctx.lineTo(x, y);
|
|
|
|
|
|
});
|
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (obs.length) {
|
|
|
|
|
|
ctx.strokeStyle = "rgba(34, 211, 238, 0.9)";
|
|
|
|
|
|
ctx.lineWidth = 1.8;
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
for (let i = 0; i < obs.length; i += 1) {
|
|
|
|
|
|
const x = canUseMinuteAxis ? xFromMinute(obs[i].m) : xFromIndex(i, obs.length);
|
|
|
|
|
|
const y = yFromValue(obs[i].v);
|
|
|
|
|
|
hoverPoints.push({ x, y, time: obs[i].t, value: obs[i].v, series: obsSeriesLabel });
|
|
|
|
|
|
if (i === 0) ctx.moveTo(x, y);
|
|
|
|
|
|
else ctx.lineTo(x, y);
|
|
|
|
|
|
}
|
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
|
|
|
|
|
|
|
// Keep dots sparse to avoid a "full row of points" look.
|
|
|
|
|
|
const markerStep = Math.max(1, Math.ceil(obs.length / 12));
|
|
|
|
|
|
ctx.fillStyle = "#22d3ee";
|
|
|
|
|
|
for (let i = 0; i < obs.length; i += markerStep) {
|
|
|
|
|
|
const x = canUseMinuteAxis ? xFromMinute(obs[i].m) : xFromIndex(i, obs.length);
|
|
|
|
|
|
const y = yFromValue(obs[i].v);
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
ctx.arc(x, y, 3.2, 0, Math.PI * 2);
|
|
|
|
|
|
ctx.fill();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Always draw last marker for latest observation.
|
|
|
|
|
|
const lastObs = obs[obs.length - 1];
|
|
|
|
|
|
const lastX = canUseMinuteAxis ? xFromMinute(lastObs.m) : xFromIndex(obs.length - 1, obs.length);
|
|
|
|
|
|
const lastY = yFromValue(lastObs.v);
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
ctx.arc(lastX, lastY, 3.6, 0, Math.PI * 2);
|
|
|
|
|
|
ctx.fill();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const ticks = trend.length ? trend : obs;
|
|
|
|
|
|
ctx.fillStyle = "#7f95b2";
|
|
|
|
|
|
ctx.font = "11px Inter, sans-serif";
|
|
|
|
|
|
const step = Math.max(1, Math.floor(ticks.length / 4));
|
|
|
|
|
|
for (let i = 0; i < ticks.length; i += step) {
|
|
|
|
|
|
const x = canUseMinuteAxis ? xFromMinute(ticks[i].m) : xFromIndex(i, ticks.length);
|
|
|
|
|
|
const text = String(ticks[i].t).slice(0, 5);
|
|
|
|
|
|
ctx.fillText(text, x - 12, height - 8);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setChartHover(hoverPoints, tempSymbol);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-23 18:28:11 +08:00
|
|
|
|
function renderDecision(detail) {
|
|
|
|
|
|
if (!els.decisionDirection || !els.decisionConfidence || !els.decisionSummary || !els.decisionReasons) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const symbol = detail?.temp_symbol || "°C";
|
|
|
|
|
|
const currentTemp = Number(detail?.current?.temp);
|
|
|
|
|
|
const maxSoFar = Number(detail?.current?.max_temp_so_far);
|
|
|
|
|
|
const trendRows = extractTrendSeries(detail).trend;
|
|
|
|
|
|
const obsRows = getObservationRows(detail);
|
|
|
|
|
|
const obsLatest = obsRows.length ? obsRows[obsRows.length - 1] : null;
|
|
|
|
|
|
const currentMinute = Number.isFinite(obsLatest?.minute)
|
|
|
|
|
|
? obsLatest.minute
|
|
|
|
|
|
: parseTimeToMinute(detail?.current?.obs_time);
|
|
|
|
|
|
const futureRows = trendRows.filter((row) => Number.isFinite(row.m) && Number.isFinite(currentMinute) ? row.m >= currentMinute : true);
|
|
|
|
|
|
const shortWindow = futureRows.slice(0, 4);
|
|
|
|
|
|
const hours = Math.max(1, Math.min(4, shortWindow.length || 4));
|
|
|
|
|
|
const baseTemp = Number.isFinite(currentTemp)
|
|
|
|
|
|
? currentTemp
|
|
|
|
|
|
: (obsLatest && Number.isFinite(obsLatest.temp) ? obsLatest.temp : NaN);
|
|
|
|
|
|
|
|
|
|
|
|
let directionKey = "directionFlat";
|
|
|
|
|
|
let summaryKey = "decisionSummaryFlat";
|
|
|
|
|
|
let confidenceKey = "confidenceNeutral";
|
|
|
|
|
|
let confidenceClass = "neutral";
|
|
|
|
|
|
const reasons = [];
|
|
|
|
|
|
|
|
|
|
|
|
const futureTemps = shortWindow.map((row) => Number(row.v)).filter(Number.isFinite);
|
|
|
|
|
|
const futureMax = futureTemps.length ? Math.max(...futureTemps) : Number.NaN;
|
|
|
|
|
|
const futureMin = futureTemps.length ? Math.min(...futureTemps) : Number.NaN;
|
|
|
|
|
|
const futureEnd = futureTemps.length ? futureTemps[futureTemps.length - 1] : Number.NaN;
|
|
|
|
|
|
const projectedDelta = Number.isFinite(baseTemp) && Number.isFinite(futureEnd)
|
|
|
|
|
|
? futureEnd - baseTemp
|
|
|
|
|
|
: Number.NaN;
|
|
|
|
|
|
const windowAmplitude = Number.isFinite(futureMax) && Number.isFinite(futureMin)
|
|
|
|
|
|
? futureMax - futureMin
|
|
|
|
|
|
: Number.NaN;
|
|
|
|
|
|
|
|
|
|
|
|
if (Number.isFinite(projectedDelta)) {
|
|
|
|
|
|
if (projectedDelta >= 0.8) {
|
|
|
|
|
|
directionKey = "directionWarmer";
|
|
|
|
|
|
summaryKey = "decisionSummaryWarmer";
|
|
|
|
|
|
confidenceKey = projectedDelta >= 1.8 ? "confidenceHigh" : projectedDelta >= 1.2 ? "confidenceMedium" : "confidenceLow";
|
|
|
|
|
|
confidenceClass = projectedDelta >= 1.8 ? "high" : projectedDelta >= 1.2 ? "medium" : "low";
|
|
|
|
|
|
} else if (projectedDelta <= -0.8) {
|
|
|
|
|
|
directionKey = "directionCooler";
|
|
|
|
|
|
summaryKey = "decisionSummaryCooler";
|
|
|
|
|
|
const absDelta = Math.abs(projectedDelta);
|
|
|
|
|
|
confidenceKey = absDelta >= 1.8 ? "confidenceHigh" : absDelta >= 1.2 ? "confidenceMedium" : "confidenceLow";
|
|
|
|
|
|
confidenceClass = absDelta >= 1.8 ? "high" : absDelta >= 1.2 ? "medium" : "low";
|
|
|
|
|
|
} else {
|
|
|
|
|
|
confidenceKey = Math.abs(projectedDelta) >= 0.4 ? "confidenceLow" : "confidenceNeutral";
|
|
|
|
|
|
confidenceClass = Math.abs(projectedDelta) >= 0.4 ? "low" : "neutral";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (Number.isFinite(baseTemp) && Number.isFinite(futureMax)) {
|
|
|
|
|
|
const upside = futureMax - baseTemp;
|
|
|
|
|
|
const downside = baseTemp - futureMin;
|
|
|
|
|
|
if (directionKey === "directionWarmer" && upside > 0.3) {
|
|
|
|
|
|
reasons.push(tf("reasonDebAbove", { delta: upside.toFixed(1), symbol }));
|
|
|
|
|
|
} else if (directionKey === "directionCooler" && downside > 0.3) {
|
|
|
|
|
|
reasons.push(tf("reasonDebBelow", { delta: downside.toFixed(1), symbol }));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (Number.isFinite(baseTemp) && trendRows.length) {
|
|
|
|
|
|
const nearest = trendRows.reduce((best, row) => {
|
|
|
|
|
|
if (!Number.isFinite(row.m) || !Number.isFinite(currentMinute)) return best;
|
|
|
|
|
|
if (!best) return row;
|
|
|
|
|
|
return Math.abs(row.m - currentMinute) < Math.abs(best.m - currentMinute) ? row : best;
|
|
|
|
|
|
}, null);
|
|
|
|
|
|
if (nearest && Number.isFinite(nearest.v)) {
|
|
|
|
|
|
const omGap = baseTemp - nearest.v;
|
|
|
|
|
|
if (omGap >= 0.8) {
|
|
|
|
|
|
reasons.push(tf("reasonObsAboveOm", { delta: omGap.toFixed(1), symbol }));
|
|
|
|
|
|
} else if (omGap <= -0.8) {
|
|
|
|
|
|
reasons.push(tf("reasonObsBelowOm", { delta: Math.abs(omGap).toFixed(1), symbol }));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (Number.isFinite(baseTemp) && Number.isFinite(maxSoFar)) {
|
|
|
|
|
|
const gapToPeak = maxSoFar - baseTemp;
|
|
|
|
|
|
if (gapToPeak <= 0.6) {
|
|
|
|
|
|
reasons.push(t("reasonNearPeak"));
|
|
|
|
|
|
} else if (gapToPeak >= 1.2) {
|
|
|
|
|
|
reasons.push(tf("reasonRoomToPeak", { delta: gapToPeak.toFixed(1), symbol }));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (Number.isFinite(windowAmplitude)) {
|
|
|
|
|
|
if (windowAmplitude >= 2.2) reasons.push(t("reasonRangeWide"));
|
|
|
|
|
|
else if (windowAmplitude <= 1.0) reasons.push(t("reasonRangeTight"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!obsRows.length) {
|
|
|
|
|
|
reasons.push(t("reasonNoObs"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
els.decisionDirection.textContent = t(directionKey);
|
|
|
|
|
|
els.decisionWindow.textContent = tf("decisionWindow", { hours });
|
|
|
|
|
|
els.decisionConfidence.textContent = t(confidenceKey);
|
|
|
|
|
|
els.decisionConfidence.classList.remove("high", "medium", "low", "neutral");
|
|
|
|
|
|
els.decisionConfidence.classList.add(confidenceClass);
|
|
|
|
|
|
els.decisionSummary.textContent = t(summaryKey);
|
|
|
|
|
|
els.decisionReasons.innerHTML = "";
|
|
|
|
|
|
for (const reason of reasons.slice(0, 3)) {
|
|
|
|
|
|
const li = document.createElement("li");
|
|
|
|
|
|
li.textContent = reason;
|
|
|
|
|
|
els.decisionReasons.appendChild(li);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 12:51:24 +08:00
|
|
|
|
function renderForecast(detail) {
|
|
|
|
|
|
const symbol = detail?.temp_symbol || "°C";
|
|
|
|
|
|
const daily = Array.isArray(detail?.forecast?.daily) ? detail.forecast.daily : [];
|
2026-03-23 21:41:27 +08:00
|
|
|
|
const dailyDeb = detail?.multi_model_daily || {};
|
2026-03-18 12:51:24 +08:00
|
|
|
|
els.forecastRow.innerHTML = "";
|
|
|
|
|
|
for (let i = 0; i < Math.min(daily.length, 6); i += 1) {
|
|
|
|
|
|
const day = daily[i];
|
2026-03-23 21:41:27 +08:00
|
|
|
|
const debValue = dailyDeb?.[day?.date]?.deb?.prediction;
|
|
|
|
|
|
const displayTemp = debValue ?? day?.max_temp;
|
2026-03-18 12:51:24 +08:00
|
|
|
|
const card = document.createElement("div");
|
|
|
|
|
|
card.className = `forecast-card ${i === 0 ? "today" : ""}`;
|
|
|
|
|
|
|
|
|
|
|
|
const d = document.createElement("div");
|
|
|
|
|
|
d.className = "f-date";
|
|
|
|
|
|
d.textContent = formatForecastDate(day?.date, i);
|
|
|
|
|
|
card.appendChild(d);
|
|
|
|
|
|
|
|
|
|
|
|
const v = document.createElement("div");
|
|
|
|
|
|
v.className = "f-temp";
|
2026-03-23 21:41:27 +08:00
|
|
|
|
v.textContent = formatTemp(displayTemp, symbol);
|
2026-03-18 12:51:24 +08:00
|
|
|
|
card.appendChild(v);
|
|
|
|
|
|
|
|
|
|
|
|
els.forecastRow.appendChild(card);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!daily.length) {
|
2026-03-20 18:43:01 +08:00
|
|
|
|
els.forecastRow.textContent = t("noForecast");
|
2026-03-18 12:51:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderDetail(detail) {
|
|
|
|
|
|
state.detail = detail;
|
|
|
|
|
|
renderRiskBadge(detail);
|
2026-03-21 19:15:21 +08:00
|
|
|
|
renderFreshness(detail);
|
2026-04-03 01:53:28 +08:00
|
|
|
|
const tempSymbol = detail?.temp_symbol || "°C";
|
2026-03-18 12:51:24 +08:00
|
|
|
|
|
|
|
|
|
|
const profile = getSettlementSourceDisplay(detail);
|
|
|
|
|
|
els.settlementLabel.textContent = profile.label;
|
|
|
|
|
|
els.settlementValue.textContent = profile.value;
|
|
|
|
|
|
els.distanceValue.textContent = Number.isFinite(Number(detail?.risk?.distance_km))
|
|
|
|
|
|
? `${Number(detail.risk.distance_km)} km`
|
|
|
|
|
|
: "--";
|
|
|
|
|
|
els.obsTimeValue.textContent = detail?.current?.obs_time || "--";
|
|
|
|
|
|
const nearby = Array.isArray(detail?.mgm_nearby) ? detail.mgm_nearby.length : 0;
|
2026-03-20 18:43:01 +08:00
|
|
|
|
els.nearbyValue.textContent = locale === "zh"
|
|
|
|
|
|
? `${nearby} ${t("nearbyMonitoringSuffix")}`
|
|
|
|
|
|
: `${nearby}${t("nearbyMonitoringSuffix")}`;
|
2026-03-18 12:51:24 +08:00
|
|
|
|
|
|
|
|
|
|
drawTrendChart(detail);
|
2026-03-23 18:28:11 +08:00
|
|
|
|
renderDecision(detail);
|
2026-03-18 12:51:24 +08:00
|
|
|
|
renderForecast(detail);
|
|
|
|
|
|
|
2026-03-23 14:39:41 +08:00
|
|
|
|
const sourceCode = String(detail?.current?.settlement_source || "").toLowerCase();
|
|
|
|
|
|
const sourceTag = sourceCode === "noaa"
|
|
|
|
|
|
? t("noaaSettlementRef")
|
|
|
|
|
|
: String(detail?.current?.settlement_source_label || "").toUpperCase() || "OBS";
|
2026-03-18 12:51:24 +08:00
|
|
|
|
const obs = getObservationRows(detail);
|
|
|
|
|
|
if (obs.length >= 2) {
|
|
|
|
|
|
const first = obs[0];
|
|
|
|
|
|
const last = obs[obs.length - 1];
|
2026-03-23 14:39:41 +08:00
|
|
|
|
els.chartLegend.textContent =
|
|
|
|
|
|
sourceCode === "noaa"
|
2026-04-03 01:53:28 +08:00
|
|
|
|
? `${sourceTag}: ${first.temp}${tempSymbol}@${first.time} -> ${last.temp}${tempSymbol}@${last.time} | ${t("noaaSettlementLegend")}`
|
|
|
|
|
|
: `${sourceTag}: ${first.temp}${tempSymbol}@${first.time} -> ${last.temp}${tempSymbol}@${last.time}`;
|
2026-03-18 12:51:24 +08:00
|
|
|
|
} else {
|
2026-03-23 14:39:41 +08:00
|
|
|
|
els.chartLegend.textContent =
|
|
|
|
|
|
sourceCode === "noaa"
|
|
|
|
|
|
? `${sourceTag}: ${t("noContinuousObs")} | ${t("noaaSettlementLegend")}`
|
|
|
|
|
|
: `${sourceTag}: ${t("noContinuousObs")}`;
|
2026-03-18 12:51:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeAggregateDetail(payload) {
|
|
|
|
|
|
const overview = payload?.overview || {};
|
|
|
|
|
|
const risk = payload?.risk || {};
|
|
|
|
|
|
const officialCurrent = payload?.official?.metar?.current || {};
|
|
|
|
|
|
const timeseries = payload?.timeseries || {};
|
|
|
|
|
|
const probs = payload?.probabilities || {};
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
name: overview.name || payload.city || "",
|
|
|
|
|
|
display_name: overview.display_name || overview.name || payload.city || "",
|
|
|
|
|
|
lat: overview.lat,
|
|
|
|
|
|
lon: overview.lon,
|
|
|
|
|
|
temp_symbol: overview.temp_symbol || "°C",
|
|
|
|
|
|
local_time: overview.local_time,
|
|
|
|
|
|
local_date: overview.local_date,
|
|
|
|
|
|
risk: {
|
|
|
|
|
|
level: risk.level || overview.risk_level || "medium",
|
|
|
|
|
|
emoji: risk.emoji,
|
|
|
|
|
|
airport: risk.airport || overview.airport,
|
|
|
|
|
|
icao: risk.icao || overview.icao,
|
|
|
|
|
|
distance_km: risk.distance_km,
|
|
|
|
|
|
warning: risk.warning || overview.risk_warning
|
|
|
|
|
|
},
|
|
|
|
|
|
current: {
|
|
|
|
|
|
...(officialCurrent || {}),
|
|
|
|
|
|
settlement_source: overview.settlement_source || officialCurrent?.settlement_source,
|
|
|
|
|
|
settlement_source_label:
|
|
|
|
|
|
overview.settlement_source_label || officialCurrent?.settlement_source_label
|
|
|
|
|
|
},
|
|
|
|
|
|
mgm_nearby: payload?.official?.mgm_nearby || [],
|
|
|
|
|
|
forecast: {
|
|
|
|
|
|
daily: Array.isArray(timeseries.forecast_daily) ? timeseries.forecast_daily : []
|
|
|
|
|
|
},
|
2026-03-23 21:41:27 +08:00
|
|
|
|
multi_model_daily:
|
|
|
|
|
|
timeseries.multi_model_daily && typeof timeseries.multi_model_daily === "object"
|
|
|
|
|
|
? timeseries.multi_model_daily
|
|
|
|
|
|
: {},
|
2026-03-18 12:51:24 +08:00
|
|
|
|
hourly: timeseries.hourly || { times: [], temps: [] },
|
|
|
|
|
|
metar_today_obs: timeseries.metar_today_obs || [],
|
|
|
|
|
|
settlement_today_obs: timeseries.settlement_today_obs || [],
|
|
|
|
|
|
probabilities: probs || { mu: null, distribution: [] },
|
|
|
|
|
|
updated_at: payload?.fetched_at
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 18:43:01 +08:00
|
|
|
|
function applyStaticTranslations() {
|
|
|
|
|
|
document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
|
|
|
|
|
|
if (els.cityLabel) els.cityLabel.textContent = t("city");
|
|
|
|
|
|
if (els.profileTitle) els.profileTitle.textContent = t("cityProfile");
|
|
|
|
|
|
if (els.distanceLabel) els.distanceLabel.textContent = t("distance");
|
|
|
|
|
|
if (els.obsTimeLabel) els.obsTimeLabel.textContent = t("obsUpdate");
|
|
|
|
|
|
if (els.nearbyLabel) els.nearbyLabel.textContent = t("nearbyStations");
|
|
|
|
|
|
if (els.trendTitle) els.trendTitle.textContent = t("intradayTrend");
|
2026-03-23 18:28:11 +08:00
|
|
|
|
if (els.decisionTitle) els.decisionTitle.textContent = t("directionTitle");
|
2026-03-20 18:43:01 +08:00
|
|
|
|
if (els.forecastTitle) els.forecastTitle.textContent = t("forecast");
|
|
|
|
|
|
if (els.openFullBtn) els.openFullBtn.textContent = t("openFull");
|
2026-03-23 18:28:11 +08:00
|
|
|
|
if (els.openFullHint) els.openFullHint.textContent = t("openFullHint");
|
2026-03-20 18:43:01 +08:00
|
|
|
|
if (els.refreshBtn) {
|
|
|
|
|
|
els.refreshBtn.title = t("refresh");
|
|
|
|
|
|
els.refreshBtn.setAttribute("aria-label", t("refresh"));
|
|
|
|
|
|
}
|
|
|
|
|
|
if (els.loadingText && state.loadingCount === 0) {
|
|
|
|
|
|
els.loadingText.textContent = t("loadingWeather");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 12:51:24 +08:00
|
|
|
|
async function loadDetail(cityName, options = {}) {
|
|
|
|
|
|
const { forceRefresh = false } = options;
|
|
|
|
|
|
const targetCity = String(cityName || "");
|
|
|
|
|
|
if (!targetCity) return { fromCache: false };
|
|
|
|
|
|
|
|
|
|
|
|
const cachedDetail = await getCachedDetail(targetCity);
|
|
|
|
|
|
if (!forceRefresh && cachedDetail) {
|
|
|
|
|
|
renderDetail(cachedDetail);
|
|
|
|
|
|
return { fromCache: true };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 18:43:01 +08:00
|
|
|
|
setLoading(true, forceRefresh ? t("loadingWeatherRefresh") : t("loadingWeather"));
|
2026-03-18 12:51:24 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const encoded = encodeURIComponent(targetCity);
|
|
|
|
|
|
const suffix = forceRefresh ? "?force_refresh=1" : "";
|
|
|
|
|
|
let detail = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
// Preferred legacy endpoint: already matches frontend card schema.
|
|
|
|
|
|
detail = await apiGet(`/api/city/${encoded}${suffix}`);
|
|
|
|
|
|
} catch (_legacyErr) {
|
|
|
|
|
|
// Fallback to aggregate endpoint and normalize structure.
|
|
|
|
|
|
const payload = await apiGet(`/api/city/${encoded}/detail${suffix}`);
|
|
|
|
|
|
detail =
|
|
|
|
|
|
payload && payload.overview && payload.timeseries
|
|
|
|
|
|
? normalizeAggregateDetail(payload)
|
|
|
|
|
|
: payload;
|
|
|
|
|
|
}
|
|
|
|
|
|
renderDetail(detail);
|
|
|
|
|
|
await setCachedDetail(targetCity, detail);
|
|
|
|
|
|
return { fromCache: false };
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (cachedDetail) {
|
|
|
|
|
|
renderDetail(cachedDetail);
|
|
|
|
|
|
}
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadCities(options = {}) {
|
|
|
|
|
|
const { forceRefresh = false } = options;
|
|
|
|
|
|
|
|
|
|
|
|
const applyCities = async (cities) => {
|
|
|
|
|
|
state.cities = cities;
|
|
|
|
|
|
if (!state.config.selectedCity || !cities.find((c) => c.name === state.config.selectedCity)) {
|
|
|
|
|
|
const preferred = cities.find((c) => c.is_major) || cities[0];
|
|
|
|
|
|
await setSelectedCity(preferred.name, { persist: true, reloadDetail: false });
|
|
|
|
|
|
} else {
|
|
|
|
|
|
renderCitySelect();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const cachedCities = await getCachedCities();
|
|
|
|
|
|
if (!forceRefresh && cachedCities) {
|
|
|
|
|
|
await applyCities(cachedCities);
|
|
|
|
|
|
return { fromCache: true };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 18:43:01 +08:00
|
|
|
|
setLoading(true, t("loadingCities"));
|
2026-03-18 12:51:24 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const list = await apiGet("/api/cities");
|
|
|
|
|
|
const cities = Array.isArray(list)
|
|
|
|
|
|
? list
|
|
|
|
|
|
: Array.isArray(list?.cities)
|
|
|
|
|
|
? list.cities
|
|
|
|
|
|
: [];
|
|
|
|
|
|
if (!Array.isArray(cities)) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Invalid /api/cities response: ${
|
|
|
|
|
|
typeof list === "string" ? list : JSON.stringify(list)
|
|
|
|
|
|
}`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!cities.length) throw new Error("No cities returned.");
|
|
|
|
|
|
await applyCities(cities);
|
|
|
|
|
|
await setCachedCities(cities);
|
|
|
|
|
|
return { fromCache: false };
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (cachedCities) {
|
|
|
|
|
|
await applyCities(cachedCities);
|
|
|
|
|
|
return { fromCache: true };
|
|
|
|
|
|
}
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 01:40:37 +08:00
|
|
|
|
function getActiveTabInfo() {
|
2026-03-18 12:51:24 +08:00
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
|
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
|
|
|
|
|
const first = Array.isArray(tabs) && tabs.length ? tabs[0] : null;
|
2026-03-30 01:40:37 +08:00
|
|
|
|
resolve({
|
|
|
|
|
|
url: String(first?.url || ""),
|
|
|
|
|
|
title: String(first?.title || "")
|
|
|
|
|
|
});
|
2026-03-18 12:51:24 +08:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function syncCityFromActiveUrl() {
|
|
|
|
|
|
if (state.syncBusy || !state.cities.length) return;
|
|
|
|
|
|
state.syncBusy = true;
|
|
|
|
|
|
try {
|
2026-03-30 01:40:37 +08:00
|
|
|
|
const { url, title } = await getActiveTabInfo();
|
2026-03-18 12:51:24 +08:00
|
|
|
|
if (!url) return;
|
|
|
|
|
|
if (url === state.lastActiveUrl) return;
|
|
|
|
|
|
state.lastActiveUrl = url;
|
|
|
|
|
|
|
2026-03-30 01:40:37 +08:00
|
|
|
|
const inferred = inferCityFromUrl(url) || matchCityInText(title);
|
2026-03-18 12:51:24 +08:00
|
|
|
|
if (!inferred) return;
|
|
|
|
|
|
if (inferred === state.config.selectedCity) return;
|
|
|
|
|
|
await setSelectedCity(inferred, { persist: true, reloadDetail: true });
|
|
|
|
|
|
} catch (_err) {
|
|
|
|
|
|
// URL sync failure should never block panel rendering.
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
state.syncBusy = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function bindUrlSync() {
|
|
|
|
|
|
const trigger = () => {
|
|
|
|
|
|
void syncCityFromActiveUrl();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
chrome.tabs.onActivated.addListener(trigger);
|
|
|
|
|
|
chrome.tabs.onUpdated.addListener((_tabId, changeInfo, tab) => {
|
|
|
|
|
|
if (!tab?.active) return;
|
|
|
|
|
|
if (changeInfo.url || changeInfo.status === "complete") {
|
|
|
|
|
|
trigger();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener("focus", trigger);
|
|
|
|
|
|
document.addEventListener("visibilitychange", () => {
|
|
|
|
|
|
if (!document.hidden) trigger();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
setInterval(trigger, 4000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openMainSite(view) {
|
|
|
|
|
|
const siteBase = normalizeBase(state.config.siteBase || state.config.apiBase);
|
2026-03-23 18:28:11 +08:00
|
|
|
|
chrome.tabs.create({ url: siteBase });
|
2026-03-18 12:51:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function bindEvents() {
|
|
|
|
|
|
els.citySelect.addEventListener("change", async (event) => {
|
|
|
|
|
|
const value = String(event.target.value || "");
|
|
|
|
|
|
clearError();
|
|
|
|
|
|
try {
|
|
|
|
|
|
await setSelectedCity(value, { persist: true, reloadDetail: true });
|
|
|
|
|
|
} catch (err) {
|
2026-03-20 18:43:01 +08:00
|
|
|
|
showError(`${t("loadCityDetailFailed")}: ${err.message}`);
|
2026-03-18 12:51:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (els.refreshBtn) {
|
|
|
|
|
|
els.refreshBtn.addEventListener("click", async () => {
|
|
|
|
|
|
const city = String(state.config.selectedCity || "");
|
|
|
|
|
|
if (!city) return;
|
|
|
|
|
|
clearError();
|
|
|
|
|
|
setRefreshing(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await loadDetail(city, { forceRefresh: true });
|
|
|
|
|
|
} catch (err) {
|
2026-03-20 18:43:01 +08:00
|
|
|
|
showError(`${t("refreshFailed")}: ${err.message}`);
|
2026-03-18 12:51:24 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
setRefreshing(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (els.trendCanvas) {
|
|
|
|
|
|
els.trendCanvas.addEventListener("mousemove", onTrendCanvasHover);
|
|
|
|
|
|
els.trendCanvas.addEventListener("mouseleave", hideChartTooltip);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
els.openFullBtn.addEventListener("click", () => openMainSite("dashboard"));
|
|
|
|
|
|
bindUrlSync();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function boot() {
|
|
|
|
|
|
bindEvents();
|
2026-03-20 18:43:01 +08:00
|
|
|
|
applyStaticTranslations();
|
2026-03-18 12:51:24 +08:00
|
|
|
|
clearError();
|
|
|
|
|
|
try {
|
|
|
|
|
|
state.config = await getConfig();
|
|
|
|
|
|
await loadCities();
|
|
|
|
|
|
await syncCityFromActiveUrl();
|
|
|
|
|
|
await loadDetail(state.config.selectedCity);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
const msg = String(err?.message || err || "");
|
|
|
|
|
|
if (msg.includes("HTTP 401")) {
|
|
|
|
|
|
showError(
|
2026-03-20 18:43:01 +08:00
|
|
|
|
`${t("initFailed")}: ${msg}\n${t("publicReadHint")}`
|
2026-03-18 12:51:24 +08:00
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-20 18:43:01 +08:00
|
|
|
|
showError(`${t("initFailed")}: ${msg}\n${t("publicModeHint")}`);
|
2026-03-18 12:51:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
boot();
|