Files
PolyWeather/extension/sidepanel.js
T
2026-06-14 23:13:37 +08:00

1312 lines
44 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.
const DEFAULT_CONFIG = {
apiBase: "https://polyweather.top",
authToken: "",
selectedCity: "",
siteBase: "https://polyweather.top"
};
const CACHE_VERSION = "v3";
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)",
noaa: "NOAA 官方时序",
wunderground: "WU/weather.com 历史参考页",
city: "城市",
refresh: "刷新数据",
cityProfile: "城市档案",
distance: "站点距离",
obsUpdate: "观测更新",
nearbyStations: "周边站点",
intradayTrend: "今日日内走势(简版)",
directionTitle: "方向判断",
forecast: "多日预报",
openFull: "打开网站查看更多",
openFullHint: "插件只提供基础判断;更多分析请到网站查看。",
noTrendData: "暂无趋势数据",
noForecast: "暂无多日预报",
noContinuousObs: "暂无连续观测",
nearbyMonitoringSuffix: "个参与监控",
today: "今天",
omSeries: "OM预测",
noaaSettlementRef: "NOAA 结算参考",
noaaSettlementLegend:
"该城市按 NOAA 最终完成质控后的最高整度读数结算;图中曲线仅作结算参考。",
loadCityDetailFailed: "加载城市详情失败",
refreshFailed: "刷新温度数据失败",
initFailed: "初始化失败",
publicReadHint: "当前插件是公开读模式,Token 可留空。请检查后端是否仍开启了接口鉴权。",
publicModeHint: "公开模式只需配置 API BaseToken 可留空。",
freshnessRecent: "数据约 {minutes} 分钟前更新。",
freshnessWarn: "数据已 {minutes} 分钟未更新,建议点右上角刷新。",
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: "未来几小时更像震荡整理,短时升降温方向不够清晰。"
},
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)",
noaa: "NOAA official timeseries",
wunderground: "WU/weather.com history reference",
city: "City",
refresh: "Refresh data",
cityProfile: "City Profile",
distance: "Station Distance",
obsUpdate: "Observation Update",
nearbyStations: "Nearby Stations",
intradayTrend: "Today's Intraday Trend",
directionTitle: "Direction Bias",
forecast: "Forecast",
openFull: "Open Website for More",
openFullHint: "The extension only provides a basic bias. Visit the site for more analysis.",
noTrendData: "No trend data available",
noForecast: "No multi-day forecast",
noContinuousObs: "No continuous observations",
nearbyMonitoringSuffix: " stations monitored",
today: "Today",
omSeries: "OM Forecast",
noaaSettlementRef: "NOAA Settlement Reference",
noaaSettlementLegend:
"This city settles on NOAA using the finalized highest rounded reading; the plotted line is only a settlement reference.",
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.",
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.",
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."
}
};
function t(key) {
return I18N[locale][key] || I18N.zh[key] || key;
}
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"),
openFullHint: document.getElementById("openFullHint"),
loadingOverlay: document.getElementById("loadingOverlay"),
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"),
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"),
freshnessHint: document.getElementById("freshnessHint")
};
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");
}
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");
}
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");
}
function tf(key, params = {}) {
let text = t(key);
for (const [name, value] of Object.entries(params)) {
text = text.replace(`{${name}}`, String(value));
}
return text;
}
function clearError() {
els.errorBox.textContent = "";
els.errorBox.classList.add("hidden");
}
function setLoading(loading, text = t("loadingWeather")) {
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();
if (low === "low") return t("riskLow");
if (low === "high") return t("riskHigh");
return t("riskMedium");
}
function getSettlementSourceDisplay(detail) {
const source = String(detail?.current?.settlement_source || "").toLowerCase();
const sourceLabel = String(detail?.current?.settlement_source_label || "").trim();
if (source === "hko") {
return {
label: t("settlementSource"),
value: t("hko")
};
}
if (source === "cwa") {
return {
label: t("settlementSource"),
value: t("cwa")
};
}
if (source === "noaa") {
return {
label: t("settlementSource"),
value: t("noaa")
};
}
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
};
}
const airport = detail?.risk?.airport || "--";
const icao = detail?.risk?.icao ? ` (${detail.risk.icao})` : "";
return {
label: t("settlementAirport"),
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) {
if (index === 0) return t("today");
const str = String(day || "");
const m = str.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!m) return str || "--";
return `${m[2]}/${m[3]}`;
}
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 });
}
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) {
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
? 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";
const sourceCode = String(detail?.current?.settlement_source || "").toLowerCase();
const obsSeriesLabel = sourceCode === "noaa"
? t("noaaSettlementRef")
: String(detail?.current?.settlement_source_label || "OBS").toUpperCase();
if (!points.length) {
setChartHover([], tempSymbol);
ctx.fillStyle = "#8ba0be";
ctx.font = "14px Inter, sans-serif";
ctx.fillText(t("noTrendData"), 18, 40);
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);
hoverPoints.push({ x, y, time: p.t, value: p.v, series: t("omSeries") });
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);
}
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);
}
}
function renderForecast(detail) {
const symbol = detail?.temp_symbol || "°C";
const daily = Array.isArray(detail?.forecast?.daily) ? detail.forecast.daily : [];
const dailyDeb = detail?.multi_model_daily || {};
els.forecastRow.innerHTML = "";
for (let i = 0; i < Math.min(daily.length, 6); i += 1) {
const day = daily[i];
const debValue = dailyDeb?.[day?.date]?.deb?.prediction;
const displayTemp = debValue ?? day?.max_temp;
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";
v.textContent = formatTemp(displayTemp, symbol);
card.appendChild(v);
els.forecastRow.appendChild(card);
}
if (!daily.length) {
els.forecastRow.textContent = t("noForecast");
}
}
function renderDetail(detail) {
state.detail = detail;
renderRiskBadge(detail);
renderFreshness(detail);
const tempSymbol = detail?.temp_symbol || "°C";
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;
els.nearbyValue.textContent = locale === "zh"
? `${nearby} ${t("nearbyMonitoringSuffix")}`
: `${nearby}${t("nearbyMonitoringSuffix")}`;
drawTrendChart(detail);
renderDecision(detail);
renderForecast(detail);
const sourceCode = String(detail?.current?.settlement_source || "").toLowerCase();
const sourceTag = sourceCode === "noaa"
? t("noaaSettlementRef")
: String(detail?.current?.settlement_source_label || "").toUpperCase() || "OBS";
const obs = getObservationRows(detail);
if (obs.length >= 2) {
const first = obs[0];
const last = obs[obs.length - 1];
els.chartLegend.textContent =
sourceCode === "noaa"
? `${sourceTag}: ${first.temp}${tempSymbol}@${first.time} -> ${last.temp}${tempSymbol}@${last.time} | ${t("noaaSettlementLegend")}`
: `${sourceTag}: ${first.temp}${tempSymbol}@${first.time} -> ${last.temp}${tempSymbol}@${last.time}`;
} else {
els.chartLegend.textContent =
sourceCode === "noaa"
? `${sourceTag}: ${t("noContinuousObs")} | ${t("noaaSettlementLegend")}`
: `${sourceTag}: ${t("noContinuousObs")}`;
}
}
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 : []
},
multi_model_daily:
timeseries.multi_model_daily && typeof timeseries.multi_model_daily === "object"
? timeseries.multi_model_daily
: {},
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
};
}
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");
if (els.decisionTitle) els.decisionTitle.textContent = t("directionTitle");
if (els.forecastTitle) els.forecastTitle.textContent = t("forecast");
if (els.openFullBtn) els.openFullBtn.textContent = t("openFull");
if (els.openFullHint) els.openFullHint.textContent = t("openFullHint");
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");
}
}
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 };
}
setLoading(true, forceRefresh ? t("loadingWeatherRefresh") : t("loadingWeather"));
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 };
}
setLoading(true, t("loadingCities"));
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);
}
}
function getActiveTabInfo() {
return new Promise((resolve) => {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const first = Array.isArray(tabs) && tabs.length ? tabs[0] : null;
resolve({
url: String(first?.url || ""),
title: String(first?.title || "")
});
});
});
}
async function syncCityFromActiveUrl() {
if (state.syncBusy || !state.cities.length) return;
state.syncBusy = true;
try {
const { url, title } = await getActiveTabInfo();
if (!url) return;
if (url === state.lastActiveUrl) return;
state.lastActiveUrl = url;
const inferred = inferCityFromUrl(url) || matchCityInText(title);
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);
chrome.tabs.create({ url: siteBase });
}
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) {
showError(`${t("loadCityDetailFailed")}: ${err.message}`);
}
});
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) {
showError(`${t("refreshFailed")}: ${err.message}`);
} 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();
applyStaticTranslations();
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(
`${t("initFailed")}: ${msg}\n${t("publicReadHint")}`
);
return;
}
showError(`${t("initFailed")}: ${msg}\n${t("publicModeHint")}`);
}
}
boot();