diff --git a/frontend/components/dashboard/DetailPanel.tsx b/frontend/components/dashboard/DetailPanel.tsx index d24de9dc..18c2c46c 100644 --- a/frontend/components/dashboard/DetailPanel.tsx +++ b/frontend/components/dashboard/DetailPanel.tsx @@ -13,11 +13,9 @@ import { CityDetail } from "@/lib/dashboard-types"; import { trackAppEvent } from "@/lib/app-analytics"; import { getTodayPolymarketUrl } from "@/lib/polymarket-market-links"; import { getTemperatureChartData } from "@/lib/chart-utils"; -import { - getCityProfileStats, - getRiskBadgeLabel, -} from "@/lib/dashboard-utils"; +import { getCityProfileStats } from "@/lib/dashboard-utils"; import { normalizeObservationSourceLabel } from "@/lib/source-labels"; +import { getRiskBadgeLabel } from "@/lib/weather-summary-utils"; function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) { const { locale, t } = useI18n(); diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx index ca803453..9d02c0f7 100644 --- a/frontend/components/dashboard/FutureForecastModal.tsx +++ b/frontend/components/dashboard/FutureForecastModal.tsx @@ -26,11 +26,11 @@ import { import { getTemperatureChartData } from "@/lib/chart-utils"; import { getFutureModalView, - getWeatherSummary, } from "@/lib/dashboard-utils"; import { getModelView, getProbabilityView } from "@/lib/model-utils"; import { getTodayPaceView } from "@/lib/pace-utils"; import { dashboardClient } from "@/lib/dashboard-client"; +import { getWeatherSummary } from "@/lib/weather-summary-utils"; import { normalizeObservationSourceCode, normalizeObservationSourceLabel, diff --git a/frontend/components/dashboard/PanelSections.tsx b/frontend/components/dashboard/PanelSections.tsx index 46bb0c42..139b1aab 100644 --- a/frontend/components/dashboard/PanelSections.tsx +++ b/frontend/components/dashboard/PanelSections.tsx @@ -13,16 +13,16 @@ import { ProbabilityBucket, } from "@/lib/dashboard-types"; import { getTemperatureChartData } from "@/lib/chart-utils"; -import { - getHeroMetaItems, - getRiskBadgeLabel, - getWeatherSummary, -} from "@/lib/dashboard-utils"; import { getModelView, getProbabilityView } from "@/lib/model-utils"; import { normalizeObservationSourceCode, normalizeObservationSourceLabel, } from "@/lib/source-labels"; +import { + getHeroMetaItems, + getRiskBadgeLabel, + getWeatherSummary, +} from "@/lib/weather-summary-utils"; function EmptyState({ text }: { text: string }) { return ( diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts index 0fcdb183..fed8ab3b 100644 --- a/frontend/lib/dashboard-utils.ts +++ b/frontend/lib/dashboard-utils.ts @@ -20,46 +20,23 @@ import { } from "@/lib/temperature-utils"; import { formatTafMarkerType } from "@/lib/taf-utils"; import { normalizeHm } from "@/lib/time-utils"; +import { getWeatherSummary } from "@/lib/weather-summary-utils"; export { getTemperatureChartData } from "@/lib/chart-utils"; export { getModelView, getProbabilityView } from "@/lib/model-utils"; export { getTodayPaceView } from "@/lib/pace-utils"; +export { + getHeroMetaItems, + getRiskBadgeLabel, + getWeatherSummary, + translateMetar, +} from "@/lib/weather-summary-utils"; export { formatTemperatureValue, normalizeTemperatureLabel, normalizeTemperatureSymbol, } from "@/lib/temperature-utils"; -const METAR_WX_MAP: Record< - string, - { en: string; icon: string; zh: string } -> = { - VCSH: { en: "Showers nearby", icon: "🌦️", zh: "附近有阵雨" }, - SHRA: { en: "Rain showers", icon: "🌦️", zh: "阵雨" }, - "-SHRA": { en: "Light rain showers", icon: "🌦️", zh: "小阵雨" }, - "+SHRA": { en: "Heavy rain showers", icon: "⛈️", zh: "强阵雨" }, - VCRA: { en: "Rain nearby", icon: "🌧️", zh: "附近有降雨" }, - TSRA: { en: "Thunderstorms with rain", icon: "⛈️", zh: "雷雨" }, - "-TSRA": { en: "Light thunderstorms with rain", icon: "⛈️", zh: "小雷雨" }, - "+TSRA": { en: "Heavy thunderstorms with rain", icon: "⛈️", zh: "强雷雨" }, - RA: { en: "Rain", icon: "🌧️", zh: "降雨" }, - "-RA": { en: "Light rain", icon: "🌦️", zh: "小雨" }, - "+RA": { en: "Heavy rain", icon: "⛈️", zh: "强降雨" }, - SN: { en: "Snow", icon: "❄️", zh: "降雪" }, - "-SN": { en: "Light snow", icon: "🌨️", zh: "小雪" }, - "+SN": { en: "Heavy snow", icon: "🌨️", zh: "大雪" }, - DZ: { en: "Drizzle", icon: "🌦️", zh: "毛毛雨" }, - FG: { en: "Fog", icon: "🌫️", zh: "雾" }, - VCFG: { en: "Fog nearby", icon: "🌫️", zh: "附近有雾" }, - MIFG: { en: "Shallow fog", icon: "🌫️", zh: "浅雾" }, - BR: { en: "Mist", icon: "🌫️", zh: "薄雾" }, - HZ: { en: "Haze", icon: "🌫️", zh: "霾" }, - TS: { en: "Thunderstorm", icon: "⛈️", zh: "雷暴" }, - VCTS: { en: "Nearby thunderstorm", icon: "⛈️", zh: "附近雷暴" }, - SQ: { en: "Squall", icon: "💨", zh: "飑线" }, - GS: { en: "Hail", icon: "🌨️", zh: "冰雹" }, -}; - function isEnglish(locale: Locale) { return locale === "en-US"; } @@ -94,159 +71,6 @@ function getLocalizedDynamicCommentary( }; } -function normalizeCloudSummary( - cloudDesc: string | null | undefined, - locale: Locale, -): { icon: string; text: string } { - const raw = String(cloudDesc || "").trim(); - if (!raw) { - return { icon: "🔍", text: isEnglish(locale) ? "Unknown" : "未知" }; - } - - const lower = raw.toLowerCase(); - if ( - raw.includes("晴") || - raw.includes("晴朗") || - lower.includes("clear") || - lower.includes("sunny") - ) { - return { icon: "☀️", text: isEnglish(locale) ? "Clear" : "晴朗" }; - } - if (raw.includes("阴") || lower.includes("overcast")) { - return { icon: "☁️", text: isEnglish(locale) ? "Overcast" : "阴天" }; - } - if (raw.includes("多云") || lower.includes("cloud")) { - return { icon: "☁️", text: isEnglish(locale) ? "Cloudy" : "多云" }; - } - if (raw.includes("少云") || lower.includes("few")) { - return { icon: "🌤️", text: isEnglish(locale) ? "Mostly clear" : "少云" }; - } - if (raw.includes("散云") || lower.includes("scattered")) { - return { icon: "⛅", text: isEnglish(locale) ? "Partly cloudy" : "散云" }; - } - return { icon: "🔍", text: raw }; -} - -export function translateMetar(code?: string | null, locale: Locale = "zh-CN") { - if (!code) return null; - const metarCode = String(code); - for (const [key, value] of Object.entries(METAR_WX_MAP)) { - if (metarCode.includes(key)) { - return { - icon: value.icon, - label: isEnglish(locale) ? value.en : value.zh, - }; - } - } - return { icon: "🔍", label: metarCode }; -} - -export function getRiskBadgeLabel( - level?: string | null, - locale: Locale = "zh-CN", -) { - if (isEnglish(locale)) { - return ( - { - high: "🔴 High Risk", - low: "🟢 Low Risk", - medium: "🟠 Medium Risk", - }[String(level || "low")] || "Unknown Risk" - ); - } - return ( - { - high: "🔴 高风险", - low: "🟢 低风险", - medium: "🟠 中风险", - }[String(level || "low")] || "未知风险" - ); -} - -export function getWeatherSummary(detail: CityDetail, locale: Locale = "zh-CN") { - const current = detail.current || {}; - const cloud = normalizeCloudSummary(current.cloud_desc, locale); - let weatherText = cloud.text; - let weatherIcon = cloud.icon; - - if (current.wx_desc) { - const translated = translateMetar(current.wx_desc, locale); - if (translated) { - weatherText = translated.label; - weatherIcon = translated.icon; - } - } - - return { weatherIcon, weatherText }; -} - -export function getHeroMetaItems(detail: CityDetail, locale: Locale = "zh-CN") { - const current = detail.current || {}; - const parts: string[] = []; - const sourceTag = getRealtimeObservationTag(detail); - const suppressAnkaraMgmObservation = isTurkishMgmCity(detail); - - if (current.obs_time) { - const ageText = - current.obs_age_min != null && current.obs_age_min >= 30 - ? isEnglish(locale) - ? ` (${current.obs_age_min} min ago)` - : `(${current.obs_age_min} 分钟前)` - : ""; - parts.push(`✈️ ${sourceTag} ${current.obs_time}${ageText}`); - } - - if (current.wx_desc) { - const translated = translateMetar(current.wx_desc, locale); - if (translated) { - parts.push(`${translated.icon} ${translated.label}`); - } - } else if (current.cloud_desc) { - const cloud = normalizeCloudSummary(current.cloud_desc, locale); - parts.push(`${cloud.icon} ${cloud.text}`); - } - - if (current.wind_speed_kt != null) { - parts.push(`💨 ${current.wind_speed_kt}kt`); - } - - if (current.visibility_mi != null) { - parts.push(`👁️ ${current.visibility_mi}mi`); - } - - if (!suppressAnkaraMgmObservation && detail.mgm?.temp != null) { - const timeMatch = detail.mgm.time?.match(/T?(\d{2}:\d{2})/); - const timeText = timeMatch ? ` @${timeMatch[1]}` : ""; - parts.push( - isEnglish(locale) - ? `🛰 MGM Obs: ${detail.mgm.temp}${detail.temp_symbol}${timeText}` - : `🛰 MGM 实测: ${detail.mgm.temp}${detail.temp_symbol}${timeText}`, - ); - } - - const trend = detail.trend || {}; - if (trend.is_dead_market) { - parts.push(isEnglish(locale) ? "☠️ Flat market" : "☠️ 死盘"); - } else if (trend.direction && trend.direction !== "unknown") { - const labels: Record = isEnglish(locale) - ? { - falling: "📉 Cooling", - mixed: "📊 Choppy", - rising: "📈 Warming", - stagnant: "⏸ Flat", - } - : { - falling: "📉 降温中", - mixed: "📊 波动中", - rising: "📈 升温中", - stagnant: "⏸ 持平", - }; - parts.push(labels[trend.direction] || trend.direction); - } - - return parts; -} - export function parseAiAnalysis(analysis: CityDetail["ai_analysis"]) { const fallback = { bullets: [] as string[], diff --git a/frontend/lib/weather-summary-utils.ts b/frontend/lib/weather-summary-utils.ts new file mode 100644 index 00000000..ab26e4de --- /dev/null +++ b/frontend/lib/weather-summary-utils.ts @@ -0,0 +1,193 @@ +import type { CityDetail } from "@/lib/dashboard-types"; +import type { Locale } from "@/lib/i18n"; +import { + getRealtimeObservationTag, + isTurkishMgmCity, +} from "@/lib/observation-source-utils"; + +const METAR_WX_MAP: Record< + string, + { en: string; icon: string; zh: string } +> = { + VCSH: { en: "Showers nearby", icon: "🌦️", zh: "附近有阵雨" }, + SHRA: { en: "Rain showers", icon: "🌦️", zh: "阵雨" }, + "-SHRA": { en: "Light rain showers", icon: "🌦️", zh: "小阵雨" }, + "+SHRA": { en: "Heavy rain showers", icon: "⛈️", zh: "强阵雨" }, + VCRA: { en: "Rain nearby", icon: "🌧️", zh: "附近有降雨" }, + TSRA: { en: "Thunderstorms with rain", icon: "⛈️", zh: "雷雨" }, + "-TSRA": { en: "Light thunderstorms with rain", icon: "⛈️", zh: "小雷雨" }, + "+TSRA": { en: "Heavy thunderstorms with rain", icon: "⛈️", zh: "强雷雨" }, + RA: { en: "Rain", icon: "🌧️", zh: "降雨" }, + "-RA": { en: "Light rain", icon: "🌦️", zh: "小雨" }, + "+RA": { en: "Heavy rain", icon: "⛈️", zh: "强降雨" }, + SN: { en: "Snow", icon: "❄️", zh: "降雪" }, + "-SN": { en: "Light snow", icon: "🌨️", zh: "小雪" }, + "+SN": { en: "Heavy snow", icon: "🌨️", zh: "大雪" }, + DZ: { en: "Drizzle", icon: "🌦️", zh: "毛毛雨" }, + FG: { en: "Fog", icon: "🌫️", zh: "雾" }, + VCFG: { en: "Fog nearby", icon: "🌫️", zh: "附近有雾" }, + MIFG: { en: "Shallow fog", icon: "🌫️", zh: "浅雾" }, + BR: { en: "Mist", icon: "🌫️", zh: "薄雾" }, + HZ: { en: "Haze", icon: "🌫️", zh: "霾" }, + TS: { en: "Thunderstorm", icon: "⛈️", zh: "雷暴" }, + VCTS: { en: "Nearby thunderstorm", icon: "⛈️", zh: "附近雷暴" }, + SQ: { en: "Squall", icon: "💨", zh: "飑线" }, + GS: { en: "Hail", icon: "🌨️", zh: "冰雹" }, +}; + +function isEnglish(locale: Locale) { + return locale === "en-US"; +} + +function normalizeCloudSummary( + cloudDesc: string | null | undefined, + locale: Locale, +): { icon: string; text: string } { + const raw = String(cloudDesc || "").trim(); + if (!raw) { + return { icon: "🔍", text: isEnglish(locale) ? "Unknown" : "未知" }; + } + + const lower = raw.toLowerCase(); + if ( + raw.includes("晴") || + raw.includes("晴朗") || + lower.includes("clear") || + lower.includes("sunny") + ) { + return { icon: "☀️", text: isEnglish(locale) ? "Clear" : "晴朗" }; + } + if (raw.includes("阴") || lower.includes("overcast")) { + return { icon: "☁️", text: isEnglish(locale) ? "Overcast" : "阴天" }; + } + if (raw.includes("多云") || lower.includes("cloud")) { + return { icon: "☁️", text: isEnglish(locale) ? "Cloudy" : "多云" }; + } + if (raw.includes("少云") || lower.includes("few")) { + return { icon: "🌤️", text: isEnglish(locale) ? "Mostly clear" : "少云" }; + } + if (raw.includes("散云") || lower.includes("scattered")) { + return { icon: "⛅", text: isEnglish(locale) ? "Partly cloudy" : "散云" }; + } + return { icon: "🔍", text: raw }; +} + +export function translateMetar(code?: string | null, locale: Locale = "zh-CN") { + if (!code) return null; + const metarCode = String(code); + for (const [key, value] of Object.entries(METAR_WX_MAP)) { + if (metarCode.includes(key)) { + return { + icon: value.icon, + label: isEnglish(locale) ? value.en : value.zh, + }; + } + } + return { icon: "🔍", label: metarCode }; +} + +export function getRiskBadgeLabel( + level?: string | null, + locale: Locale = "zh-CN", +) { + if (isEnglish(locale)) { + return ( + { + high: "🔴 High Risk", + low: "🟢 Low Risk", + medium: "🟠 Medium Risk", + }[String(level || "low")] || "Unknown Risk" + ); + } + return ( + { + high: "🔴 高风险", + low: "🟢 低风险", + medium: "🟠 中风险", + }[String(level || "low")] || "未知风险" + ); +} + +export function getWeatherSummary(detail: CityDetail, locale: Locale = "zh-CN") { + const current = detail.current || {}; + const cloud = normalizeCloudSummary(current.cloud_desc, locale); + let weatherText = cloud.text; + let weatherIcon = cloud.icon; + + if (current.wx_desc) { + const translated = translateMetar(current.wx_desc, locale); + if (translated) { + weatherText = translated.label; + weatherIcon = translated.icon; + } + } + + return { weatherIcon, weatherText }; +} + +export function getHeroMetaItems(detail: CityDetail, locale: Locale = "zh-CN") { + const current = detail.current || {}; + const parts: string[] = []; + const sourceTag = getRealtimeObservationTag(detail); + const suppressAnkaraMgmObservation = isTurkishMgmCity(detail); + + if (current.obs_time) { + const ageText = + current.obs_age_min != null && current.obs_age_min >= 30 + ? isEnglish(locale) + ? ` (${current.obs_age_min} min ago)` + : `(${current.obs_age_min} 分钟前)` + : ""; + parts.push(`✈️ ${sourceTag} ${current.obs_time}${ageText}`); + } + + if (current.wx_desc) { + const translated = translateMetar(current.wx_desc, locale); + if (translated) { + parts.push(`${translated.icon} ${translated.label}`); + } + } else if (current.cloud_desc) { + const cloud = normalizeCloudSummary(current.cloud_desc, locale); + parts.push(`${cloud.icon} ${cloud.text}`); + } + + if (current.wind_speed_kt != null) { + parts.push(`💨 ${current.wind_speed_kt}kt`); + } + + if (current.visibility_mi != null) { + parts.push(`👁️ ${current.visibility_mi}mi`); + } + + if (!suppressAnkaraMgmObservation && detail.mgm?.temp != null) { + const timeMatch = detail.mgm.time?.match(/T?(\d{2}:\d{2})/); + const timeText = timeMatch ? ` @${timeMatch[1]}` : ""; + parts.push( + isEnglish(locale) + ? `🛰 MGM Obs: ${detail.mgm.temp}${detail.temp_symbol}${timeText}` + : `🛰 MGM 实测: ${detail.mgm.temp}${detail.temp_symbol}${timeText}`, + ); + } + + const trend = detail.trend || {}; + if (trend.is_dead_market) { + parts.push(isEnglish(locale) ? "☠️ Flat market" : "☠️ 死盘"); + } else if (trend.direction && trend.direction !== "unknown") { + const labels: Record = isEnglish(locale) + ? { + falling: "📉 Cooling", + mixed: "📊 Choppy", + rising: "📈 Warming", + stagnant: "⏸ Flat", + } + : { + falling: "📉 降温中", + mixed: "📊 波动中", + rising: "📈 升温中", + stagnant: "⏸ 持平", + }; + parts.push(labels[trend.direction] || trend.direction); + } + + return parts; +}