import { Locale } from "@/lib/i18n"; import { AiAnalysisStructured, CityDetail, NearbyStation, } from "@/lib/dashboard-types"; import { getNoaaStationCode, getNoaaStationName, getObservationSourceCode, getObservationSourceTag, getRealtimeObservationTag, isTurkishMgmCity, } from "@/lib/observation-source-utils"; import { formatTemperatureValue, normalizeTemperatureLabel, normalizeTemperatureSymbol, } from "@/lib/temperature-utils"; import { formatTafMarkerType } from "@/lib/taf-utils"; import { normalizeHm } from "@/lib/time-utils"; import { getWeatherSummary } from "@/lib/weather-summary-utils"; import { getDisplayAirportPrimary } from "@/lib/airport-observation-display"; 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"; function isEnglish(locale: Locale) { return locale === "en-US"; } function containsCjk(text: string) { return /[\u3400-\u9fff]/.test(text); } function getLocalizedDynamicCommentary( detail: CityDetail, locale: Locale, ): { headline: string; bullets: string[]; source: string } { const commentary = detail.dynamic_commentary || {}; const preferEnglish = isEnglish(locale); const rawHeadline = preferEnglish ? String(commentary.headline_en || "").trim() : String(commentary.headline_zh || "").trim(); const rawBullets = preferEnglish ? commentary.bullets_en : commentary.bullets_zh; const bullets = Array.isArray(rawBullets) ? rawBullets.map((item) => String(item || "").trim()).filter(Boolean) : []; const fallbackHeadline = String(commentary.summary || "").trim(); const fallbackBullets = Array.isArray(commentary.notes) ? commentary.notes.map((item) => String(item || "").trim()).filter(Boolean) : []; return { headline: rawHeadline || fallbackHeadline, bullets: bullets.length > 0 ? bullets : fallbackBullets, source: String(commentary.source || "").trim(), }; } export function parseAiAnalysis(analysis: CityDetail["ai_analysis"]) { const fallback = { bullets: [] as string[], summary: "", }; if (!analysis) return fallback; if (typeof analysis === "string") { return { bullets: [], summary: analysis.trim(), }; } const structured = analysis as AiAnalysisStructured; return { bullets: Array.isArray(structured.highlights) ? structured.highlights : Array.isArray(structured.points) ? structured.points : [], summary: structured.summary || structured.text || structured.message || "", }; } export function getAirportNarrative( detail: CityDetail, locale: Locale = "zh-CN", ) { const parsed = parseAiAnalysis(detail.ai_analysis); if (!isEnglish(locale)) return parsed; const englishSummary = containsCjk(parsed.summary) ? "" : parsed.summary.trim(); const englishBullets = parsed.bullets .map((item) => String(item || "").trim()) .filter((item) => item && !containsCjk(item)); if (englishSummary || englishBullets.length > 0) { return { bullets: englishBullets, summary: englishSummary, }; } const sourceLabel = String(detail.current?.settlement_source_label || "").trim() || String(detail.risk?.icao || "").trim() || String(detail.risk?.airport || "").trim() || String(detail.display_name || detail.name || "").trim() || "Airport"; const currentTemp = Number(detail.current?.temp); const tempText = Number.isFinite(currentTemp) ? `${currentTemp}${detail.temp_symbol || "°C"}` : null; const obsTime = String(detail.current?.obs_time || "").trim(); const weatherText = getWeatherSummary(detail, locale).weatherText.toLowerCase(); const windBucket = bucketLabel( trendBucketFromDir(detail.current?.wind_dir ?? null), locale, ); const windSpeedKt = Number(detail.current?.wind_speed_kt); const tafSummary = String(detail.taf?.signal?.summary_en || "").trim(); const windPhrase = Number.isFinite(windSpeedKt) ? `${windBucket} around ${windSpeedKt} kt` : `${windBucket} prevailing`; const summaryParts = [ tempText ? `${sourceLabel} reports ${tempText}${obsTime ? ` at ${obsTime}` : ""}, ${weatherText}.` : `${sourceLabel} reports ${weatherText}${obsTime ? ` at ${obsTime}` : ""}.`, `${windPhrase}.`, tafSummary, ].filter(Boolean); const bullets: string[] = []; const rawMetar = String(detail.current?.raw_metar || "").trim(); if (rawMetar) { bullets.push(`Latest METAR: ${rawMetar}`); } if (tafSummary) { bullets.push(`TAF signal: ${tafSummary}`); } if (detail.taf?.raw_taf) { bullets.push(`TAF available for airport-side timing checks.`); } return { bullets, summary: summaryParts.join(" "), }; } export function pickAnkaraNearbyStations(stations: NearbyStation[]) { const preferredNames = [ "Airport (MGM/17128)", "Ankara (Bölge/Center)", "Ankara (Bolge/Center)", "Etimesgut", "Pursaklar", "Cubuk", "Çubuk", "Kalecik", ]; const picks = preferredNames .map((name) => stations.find((station) => station?.name === name)) .filter(Boolean) as NearbyStation[]; return picks.length ? picks : stations; } function distanceKm( lat1: number, lon1: number, lat2: number, lon2: number, ) { const toRad = (deg: number) => (deg * Math.PI) / 180; const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } export function pickMapNearbyStations(detail: CityDetail) { const stations = Array.isArray(detail.official_nearby) ? detail.official_nearby : Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby : []; const city = String(detail.name || detail.display_name || "") .trim() .toLowerCase(); if (city === "seoul" || city === "busan") { return []; } if (city === "ankara") { return pickAnkaraNearbyStations(stations); } if (city === "istanbul" && Number.isFinite(detail.lat) && Number.isFinite(detail.lon)) { const preferredTokens = [ "havalimani", "havalimanı", "arnavutkoy", "arnavutköy", "liman feneri", ]; const scored = stations .map((station) => { const lat = Number(station.lat); const lon = Number(station.lon); if (!Number.isFinite(lat) || !Number.isFinite(lon)) { return null; } const name = String(station.name || "").toLowerCase(); const preferred = preferredTokens.some((token) => name.includes(token)); return { preferred, station, km: distanceKm(Number(detail.lat), Number(detail.lon), lat, lon), }; }) .filter(Boolean) as Array<{ preferred: boolean; station: NearbyStation; km: number; }>; const closePreferred = scored .filter((row) => row.preferred && row.km <= 18) .sort((a, b) => a.km - b.km) .map((row) => row.station); const closeFallback = scored .filter((row) => row.km <= 8) .sort((a, b) => a.km - b.km) .map((row) => row.station); const merged = [...closePreferred, ...closeFallback].filter( (station, index, list) => list.findIndex( (row) => row.name === station.name && row.lat === station.lat && row.lon === station.lon, ) === index, ); return merged.slice(0, 3); } return stations; } export function getFutureSlice(detail: CityDetail, dateStr: string) { const hourly = detail.hourly_next_48h || {}; const times = hourly.times || []; const slice: Array<{ cloudCover: number | null; dewPoint: number | null; label: string; precipProb: number | null; pressure: number | null; radiation: number | null; temp: number | null; time: string; windDir: number | null; windSpeed: number | null; }> = []; for (let index = 0; index < times.length; index += 1) { const timestamp = times[index]; if (!timestamp || !String(timestamp).startsWith(dateStr)) continue; slice.push({ cloudCover: hourly.cloud_cover?.[index] ?? null, dewPoint: hourly.dew_point?.[index] ?? null, label: String(timestamp).split("T")[1]?.slice(0, 5) || timestamp, precipProb: hourly.precipitation_probability?.[index] ?? null, pressure: hourly.pressure_msl?.[index] ?? null, radiation: hourly.radiation?.[index] ?? null, temp: hourly.temps?.[index] ?? null, time: timestamp, windDir: hourly.wind_direction_10m?.[index] ?? null, windSpeed: hourly.wind_speed_10m?.[index] ?? null, }); } return slice; } function trendBucketFromDir(direction?: number | null) { const value = Number(direction); if (!Number.isFinite(value)) return null; if (value >= 135 && value <= 240) return "southerly"; if (value >= 290 || value <= 45) return "northerly"; if (value > 45 && value < 135) return "easterly"; return "westerly"; } function bucketLabel(bucket: string | null, locale: Locale = "zh-CN") { if (isEnglish(locale)) { return ( { southerly: "S / SW wind", northerly: "N / NW wind", easterly: "E wind", westerly: "W wind", }[bucket || ""] || "Unknown wind direction" ); } return ( { southerly: "南 / 西南风", northerly: "北 / 西北风", easterly: "东风", westerly: "西风", }[bucket || ""] || "风向不明" ); } export function wuRound(value: number | null | undefined) { const numeric = Number(value); if (!Number.isFinite(numeric)) return null; return numeric >= 0 ? Math.floor(numeric + 0.5) : Math.ceil(numeric - 0.5); } export function formatDelta(value: number | null | undefined, suffix = "") { const numeric = Number(value); if (!Number.isFinite(numeric)) return "--"; const sign = numeric > 0 ? "+" : ""; return `${sign}${numeric.toFixed(1)}${suffix}`; } function getForecastTextForDate(detail: CityDetail, dateStr: string) { const periods = detail.source_forecasts?.weather_gov?.forecast_periods || []; return periods.filter((period) => String(period.start_time || "").startsWith(dateStr), ); } export function computeFrontTrendSignal( detail: CityDetail, dateStr: string, locale: Locale = "zh-CN", ) { const upperAirSignal = detail.vertical_profile_signal || {}; const tafSignal = detail.taf?.signal || {}; const upperAirTradeCue = upperAirSignal.source ? upperAirSignal.heating_setup === "supportive" ? { label: isEnglish(locale) ? "Trade cue" : "交易动作", note: isEnglish(locale) ? "The setup still supports further warming. Do not call the high too early." : "结构仍支持继续升温,别太早押高温见顶。", tone: "warm", value: isEnglish(locale) ? "Lean warmer" : "偏暖侧", } : upperAirSignal.heating_setup === "suppressed" ? { label: isEnglish(locale) ? "Trade cue" : "交易动作", note: isEnglish(locale) ? "Further upside looks less reliable. Do not chase the high blindly." : "高温继续上冲的把握不大,别盲目追热。", tone: "cold", value: isEnglish(locale) ? "Lean cautious" : "偏谨慎", } : { label: isEnglish(locale) ? "Trade cue" : "交易动作", note: isEnglish(locale) ? "The picture is still mixed. Let the next move decide first." : "现在还看不出明确方向,先等下一步走势确认。", tone: "", value: isEnglish(locale) ? "Wait / confirm" : "先观察", } : null; const baseUpperAirSummary = upperAirSignal.source ? (() => { const hasMetrics = upperAirSignal.cape_max != null || upperAirSignal.cin_min != null || upperAirSignal.boundary_layer_height_max != null || upperAirSignal.shear_10m_180m_max != null; if (!hasMetrics) { return isEnglish(locale) ? "Upper-air inputs are incomplete. For now, trade direction should rely more on surface structure." : "高空输入还不完整,当前交易方向先更多参考近地面结构信号。"; } if (upperAirSignal.heating_setup === "supportive") { return isEnglish(locale) ? "Upper-air structure still favors further warming. Do not call the high too early." : "高空结构仍偏向继续增温,别太早押高温见顶。"; } if (upperAirSignal.heating_setup === "suppressed") { return isEnglish(locale) ? "Upper-air structure leans toward capping the afternoon high. Further upside looks less reliable." : "高空结构更偏向压住午后峰值,高温继续上冲的把握不大。"; } return isEnglish(locale) ? "Upper-air structure is fairly neutral. It does not provide a clean edge yet." : "高空结构整体偏中性,暂时还给不出明确方向。"; })() : ""; const tafSummary = tafSignal.available && dateStr === detail.local_date ? isEnglish(locale) ? String(tafSignal.summary_en || "").trim() : String(tafSignal.summary_zh || "").trim() : ""; let upperAirSummary = baseUpperAirSummary; let tafMetric: | { label: string; note: string; tone?: string; value: string; } | null = null; let upperAirMetrics: Array<{ label: string; note: string; tone?: string; value: string; }> = []; const localizedCommentary = dateStr === detail.local_date ? getLocalizedDynamicCommentary(detail, locale) : { headline: "", bullets: [], source: "" }; const backendSummary = localizedCommentary.headline && (!isEnglish(locale) || !containsCjk(localizedCommentary.headline)) ? localizedCommentary.headline : ""; const backendNotes = localizedCommentary.bullets.filter( (note) => !isEnglish(locale) || !containsCjk(note), ); const slice = getFutureSlice(detail, dateStr); const currentTemp = Number(detail.current?.temp); const currentDew = Number(detail.current?.dewpoint); if (!slice.length) { const fallbackSummary = backendSummary || (isEnglish(locale) ? "Insufficient intraday structured data. Keep baseline monitoring." : "当日日内结构化数据不足,暂时只保留基础监控。"); return { confidence: "low", label: isEnglish(locale) ? "Monitoring" : "监控中", metrics: [] as Array<{ label: string; note: string; tone?: string; value: string; }>, upperAirMetrics, upperAirSummary, precipMax: 0, score: 0, summary: fallbackSummary, summaryLines: [fallbackSummary], weatherGovPeriods: [] as ReturnType, }; } const normalizeHm = (value: unknown): string | null => { const match = String(value || "").match(/(\d{1,2}):(\d{2})/); if (!match) return null; const hour = Number.parseInt(match[1], 10); const minute = Number.parseInt(match[2], 10); if ( !Number.isFinite(hour) || !Number.isFinite(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59 ) { return null; } return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; }; const hmToMinutes = (value: string | null) => { if (!value) return null; const [hourPart, minutePart] = value.split(":"); const hour = Number.parseInt(hourPart || "", 10); const minute = Number.parseInt(minutePart || "", 10); if ( !Number.isFinite(hour) || !Number.isFinite(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59 ) { return null; } return hour * 60 + minute; }; const pointMinutes = (point: { label?: string }) => hmToMinutes(normalizeHm(point.label)); const isTargetToday = dateStr === detail.local_date; const currentHm = normalizeHm(detail.local_time); const sunsetHm = normalizeHm(detail.forecast?.sunset); const peakFirstHour = Number(detail.peak?.first_h); const peakLastHour = Number(detail.peak?.last_h); const hasPeakWindow = Number.isFinite(peakFirstHour) && Number.isFinite(peakLastHour) && peakFirstHour >= 0 && peakLastHour >= peakFirstHour; const currentMinutes = hmToMinutes(currentHm); const sunsetMinutes = hmToMinutes(sunsetHm); const peakWindowStartMinutes = hasPeakWindow ? Math.max(0, (peakFirstHour - 2) * 60) : null; const peakWindowEndMinutes = hasPeakWindow ? Math.min(23 * 60 + 59, (peakLastHour + 1) * 60) : null; const canUseSunsetWindow = isTargetToday && currentMinutes !== null && sunsetMinutes !== null && sunsetMinutes > currentMinutes; const tafMarkers = Array.isArray(tafSignal.markers) ? tafSignal.markers : []; const markerSummary = ( marker: | { marker_type?: string | null; start_local?: string | null; end_local?: string | null; suppression_level?: string | null; summary_zh?: string | null; summary_en?: string | null; } | null | undefined, ) => !marker ? "" : isEnglish(locale) ? String(marker.summary_en || "").trim() : String(marker.summary_zh || "").trim(); const currentTafMarker = tafSignal.available && currentMinutes !== null ? tafMarkers.find((marker) => { const start = hmToMinutes(normalizeHm(marker?.start_local)); const end = hmToMinutes(normalizeHm(marker?.end_local)); if (start === null || end === null) return false; return currentMinutes >= start && currentMinutes <= end; }) : null; const nextTafMarker = tafSignal.available && currentMinutes !== null && !currentTafMarker ? tafMarkers.find((marker) => { const start = hmToMinutes(normalizeHm(marker?.start_local)); return start !== null && start > currentMinutes; }) : null; const sameMarker = ( left: | { marker_type?: string | null; start_local?: string | null; end_local?: string | null } | null | undefined, right: | { marker_type?: string | null; start_local?: string | null; end_local?: string | null } | null | undefined, ) => !!left && !!right && String(left.marker_type || "") === String(right.marker_type || "") && String(left.start_local || "") === String(right.start_local || "") && String(left.end_local || "") === String(right.end_local || ""); const formatTafSegmentBody = ( marker: | { marker_type?: string | null; start_local?: string | null; end_local?: string | null; suppression_level?: string | null; } | null | undefined, kind: "current" | "next" | "peak", ) => { if (!marker) return ""; const range = `${normalizeHm(marker.start_local) || "--:--"}-${normalizeHm(marker.end_local) || "--:--"}`; const typeLabel = formatTafMarkerType( String(marker.marker_type || ""), locale, ); const level = String(marker.suppression_level || "low").toLowerCase(); const statusText = isEnglish(locale) ? level === "high" ? "shows shower or thunderstorm disruption" : level === "medium" ? "shows cloud or light-rain disruption" : "stays relatively stable" : level === "high" ? "有阵雨或雷暴扰动" : level === "medium" ? "有云量或弱降水扰动" : "以稳定为主"; return isEnglish(locale) ? `${typeLabel} (${range}), ${statusText}.` : `${typeLabel}(${range}),${statusText}。`; }; const peakWindowTafMarker = tafSignal.available && peakWindowStartMinutes !== null && peakWindowEndMinutes !== null ? tafMarkers.find((marker) => { const start = hmToMinutes(normalizeHm(marker?.start_local)); const end = hmToMinutes(normalizeHm(marker?.end_local)); if (start === null || end === null) return false; return start <= peakWindowEndMinutes && end >= peakWindowStartMinutes; }) : null; const peakTafSummary = markerSummary(peakWindowTafMarker); const peakTafStillRelevant = peakWindowEndMinutes === null || currentMinutes === null || currentMinutes < peakWindowEndMinutes; const effectivePeakTafSummary = peakTafStillRelevant && peakTafSummary && !sameMarker(peakWindowTafMarker, currentTafMarker) && !sameMarker(peakWindowTafMarker, nextTafMarker) ? peakTafSummary : ""; const currentTafLine = currentTafMarker ? isEnglish(locale) ? `Use current TAF as primary: ${formatTafSegmentBody(currentTafMarker, "current")}` : `以当前 TAF 为准:${formatTafSegmentBody(currentTafMarker, "current")}` : nextTafMarker ? isEnglish(locale) ? `Use next TAF segment as primary: ${formatTafSegmentBody(nextTafMarker, "next")}` : `以下一段 TAF 为准:${formatTafSegmentBody(nextTafMarker, "next")}` : ""; const currentTafLineForSummary = currentTafLine.replace(/^Use (current|next) TAF (as primary|segment as primary):\s*/i, "").replace(/^以(当前|下一段) TAF 为准:/, ""); const peakTafLine = effectivePeakTafSummary && peakWindowTafMarker ? isEnglish(locale) ? `Peak-window reference: ${formatTafSegmentBody(peakWindowTafMarker, "peak")}` : `峰值窗口参考:${formatTafSegmentBody(peakWindowTafMarker, "peak")}` : ""; const peakTafLineForSummary = peakTafLine.replace(/^Peak-window reference:\s*/i, "").replace(/^峰值窗口参考:/, ""); const tafFallbackSummary = currentTafLine || peakTafLine ? "" : tafSummary; const tafPrimarySummary = currentTafMarker ? isEnglish(locale) ? `Use current TAF as primary: ${currentTafLineForSummary}` : `以当前 TAF 为准:${currentTafLineForSummary}` : nextTafMarker ? isEnglish(locale) ? `Use next TAF segment as primary: ${currentTafLineForSummary}` : `以下一段 TAF 为准:${currentTafLineForSummary}` : ""; const tafReferenceSummary = peakTafLineForSummary && peakTafLineForSummary !== currentTafLineForSummary ? isEnglish(locale) ? `Peak-window reference: ${peakTafLineForSummary}` : `峰值窗口参考:${peakTafLineForSummary}` : ""; upperAirSummary = [ baseUpperAirSummary, tafPrimarySummary, tafReferenceSummary, tafFallbackSummary, ] .filter(Boolean) .join(isEnglish(locale) ? " " : ""); tafMetric = tafSignal.available && dateStr === detail.local_date ? { label: isEnglish(locale) ? "Airport TAF" : "机场预报", note: tafPrimarySummary || tafReferenceSummary || tafFallbackSummary || (isEnglish(locale) ? "Airport TAF is available for the current peak window." : "当前峰值窗口已接入机场 TAF 预报。"), tone: tafSignal.suppression_level === "high" ? "cold" : tafSignal.suppression_level === "low" ? "warm" : "", value: tafSignal.suppression_level === "high" ? isEnglish(locale) ? "Suppression watch" : "防压温" : tafSignal.suppression_level === "medium" ? isEnglish(locale) ? "Watch clouds/rain" : "看云雨" : isEnglish(locale) ? "Mostly stable" : "暂稳", } : null; upperAirMetrics = upperAirSignal.source ? [ ...(upperAirTradeCue ? [upperAirTradeCue] : []), { label: isEnglish(locale) ? "Peak setup" : "冲高环境", note: upperAirSignal.heating_setup === "supportive" ? isEnglish(locale) ? "Still supportive of more daytime heating. Do not call the high too early." : "结构仍支持白天继续升温,别太早押高温见顶。" : upperAirSignal.heating_setup === "suppressed" ? isEnglish(locale) ? "Leans toward capping the afternoon peak. Further upside looks less reliable." : "更偏向压住午后峰值,高温继续上冲的把握不大。" : isEnglish(locale) ? "Neutral on its own. It does not provide a clear directional edge yet." : "单看这层偏中性,暂时还没有明确方向优势。", tone: upperAirSignal.heating_setup === "supportive" ? "warm" : upperAirSignal.heating_setup === "suppressed" ? "cold" : "", value: upperAirSignal.heating_setup === "supportive" ? isEnglish(locale) ? "Supportive" : "偏支持" : upperAirSignal.heating_setup === "suppressed" ? isEnglish(locale) ? "Suppressed" : "偏压制" : isEnglish(locale) ? "Neutral" : "中性", }, { label: isEnglish(locale) ? "Peak suppression risk" : "压温风险", note: upperAirSignal.cape_max != null || upperAirSignal.cin_min != null ? isEnglish(locale) ? `How likely clouds or showers are to cap the high. CAPE ${Math.round(Number(upperAirSignal.cape_max ?? 0))}, CIN ${Number(upperAirSignal.cin_min ?? 0).toFixed(0)}.` : `看云和阵雨有多大概率把峰值压住。CAPE ${Math.round(Number(upperAirSignal.cape_max ?? 0))},CIN ${Number(upperAirSignal.cin_min ?? 0).toFixed(0)}。` : isEnglish(locale) ? "Estimated from the next 48h upper-air profile." : "根据未来 48 小时高空剖面估算。", tone: upperAirSignal.suppression_risk === "high" ? "cold" : upperAirSignal.suppression_risk === "low" ? "warm" : "", value: upperAirSignal.suppression_risk === "high" ? isEnglish(locale) ? "High" : "高" : upperAirSignal.suppression_risk === "medium" ? isEnglish(locale) ? "Medium" : "中" : isEnglish(locale) ? "Low" : "低", }, { label: isEnglish(locale) ? "Afternoon disruption" : "午后扰动", note: upperAirSignal.lifted_index_min != null ? isEnglish(locale) ? `How easily the afternoon can turn noisy. Lifted Index ${Number(upperAirSignal.lifted_index_min).toFixed(1)}.` : `看午后是否容易突然起云、起对流,把走势搅乱。Lifted Index ${Number(upperAirSignal.lifted_index_min).toFixed(1)}。` : isEnglish(locale) ? "Uses instability and lifted-index structure." : "结合不稳定能量与抬升指数判断。", tone: upperAirSignal.trigger_risk === "high" ? "cold" : upperAirSignal.trigger_risk === "low" ? "warm" : "", value: upperAirSignal.trigger_risk === "high" ? isEnglish(locale) ? "High" : "高" : upperAirSignal.trigger_risk === "medium" ? isEnglish(locale) ? "Medium" : "中" : isEnglish(locale) ? "Low" : "低", }, { label: isEnglish(locale) ? "Heating efficiency" : "冲高效率", note: upperAirSignal.boundary_layer_height_max != null ? isEnglish(locale) ? `How efficiently surface warmth can keep translating upward. Mixing depth peaks near ${Math.round(Number(upperAirSignal.boundary_layer_height_max))} m.` : `看地面热量能不能持续往上送,决定冲高效率。混合层高度峰值约 ${Math.round(Number(upperAirSignal.boundary_layer_height_max))} 米。` : isEnglish(locale) ? "Tracks daytime mixing depth." : "跟踪白天混合层深度。", tone: upperAirSignal.mixing_strength === "strong" ? "warm" : upperAirSignal.mixing_strength === "weak" ? "cold" : "", value: upperAirSignal.mixing_strength === "strong" ? isEnglish(locale) ? "Strong" : "强" : upperAirSignal.mixing_strength === "medium" ? isEnglish(locale) ? "Medium" : "中" : isEnglish(locale) ? "Weak" : "弱", }, ...(tafMetric ? [tafMetric] : []), ] : tafMetric ? [tafMetric] : []; const futureSlice = isTargetToday && currentMinutes !== null ? slice.filter((point) => { const minutes = pointMinutes(point); return minutes === null ? true : minutes >= currentMinutes; }) : slice; const untilSunsetSlice = canUseSunsetWindow && sunsetMinutes !== null ? futureSlice.filter((point) => { const minutes = pointMinutes(point); return minutes === null ? false : minutes <= sunsetMinutes; }) : futureSlice; const aroundPeakSlice = isTargetToday && peakWindowStartMinutes !== null && peakWindowEndMinutes !== null ? futureSlice.filter((point) => { const minutes = pointMinutes(point); return minutes === null ? false : minutes >= peakWindowStartMinutes && minutes <= peakWindowEndMinutes; }) : []; const workingSlice = aroundPeakSlice.length >= 2 ? aroundPeakSlice : untilSunsetSlice.length >= 2 ? untilSunsetSlice : futureSlice.length >= 2 ? futureSlice : slice; const usingPeakWindow = aroundPeakSlice.length >= 2; const usingSunsetWindow = canUseSunsetWindow && untilSunsetSlice.length >= 2; const first = workingSlice[0] || slice[0]; const last = workingSlice[workingSlice.length - 1] || slice[slice.length - 1]; const effectiveHours = Math.max(1, workingSlice.length); const windowLabel = `${first?.label || "--"}-${last?.label || "--"}`; const windowText = isEnglish(locale) ? usingPeakWindow ? `today ${windowLabel} (~${effectiveHours}h, around peak window)` : usingSunsetWindow ? `today ${windowLabel} (~${effectiveHours}h, now -> sunset)` : isTargetToday ? `today ${windowLabel} (~${effectiveHours}h)` : `daily ${windowLabel} (~${effectiveHours}h)` : usingPeakWindow ? `今日 ${windowLabel}(约 ${effectiveHours} 小时,围绕峰值窗口)` : usingSunsetWindow ? `今日 ${windowLabel}(约 ${effectiveHours} 小时,当前至日落)` : isTargetToday ? `今日 ${windowLabel}(约 ${effectiveHours} 小时)` : `当日日内 ${windowLabel}(约 ${effectiveHours} 小时)`; const firstTemp = Number.isFinite(Number(first.temp)) ? Number(first.temp) : currentTemp; const lastTemp = Number.isFinite(Number(last.temp)) ? Number(last.temp) : firstTemp; const tempDelta = Number.isFinite(firstTemp) && Number.isFinite(lastTemp) ? lastTemp - firstTemp : 0; const firstDew = Number.isFinite(Number(first.dewPoint)) ? Number(first.dewPoint) : currentDew; const lastDew = Number.isFinite(Number(last.dewPoint)) ? Number(last.dewPoint) : firstDew; const dewDelta = Number.isFinite(firstDew) && Number.isFinite(lastDew) ? lastDew - firstDew : 0; const firstPressure = Number.isFinite(Number(first.pressure)) ? Number(first.pressure) : null; const lastPressure = Number.isFinite(Number(last.pressure)) ? Number(last.pressure) : firstPressure; const pressureDelta = Number.isFinite(Number(firstPressure)) && Number.isFinite(Number(lastPressure)) ? Number(lastPressure) - Number(firstPressure) : 0; const firstCloud = Number.isFinite(Number(first.cloudCover)) ? Number(first.cloudCover) : null; const lastCloud = Number.isFinite(Number(last.cloudCover)) ? Number(last.cloudCover) : firstCloud; const cloudDelta = Number.isFinite(Number(firstCloud)) && Number.isFinite(Number(lastCloud)) ? Number(lastCloud) - Number(firstCloud) : 0; const precipMax = slice.reduce( (max, point) => Math.max(max, Number(point.precipProb) || 0), 0, ); const precipWindowSource = futureSlice.length >= 2 ? futureSlice : slice; const precipCandidates = precipWindowSource .map((point) => ({ label: normalizeHm(point.label), minute: pointMinutes(point), precip: Number(point.precipProb) || 0, })) .filter( (point): point is { label: string; minute: number; precip: number } => point.label != null && point.minute != null, ); const precipThreshold = precipMax >= 70 ? 50 : precipMax >= 40 ? 40 : 0; const precipWindows: Array<{ start: number; end: number; startLabel: string; endLabel: string; peak: number; }> = []; if (precipThreshold > 0) { let active: | { start: number; end: number; startLabel: string; endLabel: string; peak: number; } | null = null; for (const point of precipCandidates) { if (point.precip >= precipThreshold) { if (!active) { active = { start: point.minute, end: point.minute, startLabel: point.label, endLabel: point.label, peak: point.precip, }; } else { active.end = point.minute; active.endLabel = point.label; active.peak = Math.max(active.peak, point.precip); } } else if (active) { precipWindows.push(active); active = null; } } if (active) precipWindows.push(active); } const primaryPrecipWindow = precipWindows.length > 0 ? [...precipWindows].sort((left, right) => { if (right.peak !== left.peak) return right.peak - left.peak; return (right.end - right.start) - (left.end - left.start); })[0] : null; const precipWindowLabel = primaryPrecipWindow ? `${primaryPrecipWindow.startLabel}-${primaryPrecipWindow.endLabel}` : ""; const precipPeakOverlap = primaryPrecipWindow && peakWindowStartMinutes != null && peakWindowEndMinutes != null ? Math.max( 0, Math.min(primaryPrecipWindow.end, peakWindowEndMinutes) - Math.max(primaryPrecipWindow.start, peakWindowStartMinutes), ) : 0; const precipOverlapLevel = primaryPrecipWindow && peakWindowStartMinutes != null && peakWindowEndMinutes != null ? precipPeakOverlap >= 120 ? "high" : precipPeakOverlap > 0 ? "medium" : "low" : "unknown"; const precipWindowSummary = (() => { if (!primaryPrecipWindow) { return isEnglish(locale) ? "No concentrated model precipitation window is visible yet." : "模型里暂时还看不出明显集中的降水窗口。"; } const overlapText = isEnglish(locale) ? precipOverlapLevel === "high" ? "It overlaps heavily with the peak window." : precipOverlapLevel === "medium" ? "It overlaps part of the peak window." : "It sits mostly outside the peak window." : precipOverlapLevel === "high" ? "与峰值窗口重叠较高。" : precipOverlapLevel === "medium" ? "与峰值窗口部分重叠。" : "主要落在峰值窗口之外。"; return isEnglish(locale) ? `Model precipitation window is ${precipWindowLabel}, peaking near ${Math.round(primaryPrecipWindow.peak)}%. ${overlapText}` : `模型降水窗口在 ${precipWindowLabel},峰值约 ${Math.round(primaryPrecipWindow.peak)}%。${overlapText}`; })(); const firstBucket = trendBucketFromDir(first.windDir); const lastBucket = trendBucketFromDir(last.windDir); const weatherGovPeriods = getForecastTextForDate(detail, dateStr); const weatherGovText = weatherGovPeriods .map( (period) => `${period.short_forecast || ""} ${period.detailed_forecast || ""}`.toLowerCase(), ) .join(" "); let warmScore = 0; let coldScore = 0; if (tempDelta >= 2) warmScore += 24; else if (tempDelta >= 0.8) warmScore += 12; if (tempDelta <= -2) coldScore += 24; else if (tempDelta <= -0.8) coldScore += 12; if (dewDelta >= 1.2) warmScore += 14; if (dewDelta <= -1.2) coldScore += 10; if (pressureDelta >= 1.2) coldScore += 16; if (pressureDelta <= -1.0) warmScore += 8; if (lastBucket === "southerly") warmScore += 14; if (firstBucket !== lastBucket && lastBucket === "southerly") warmScore += 10; if (lastBucket === "northerly") coldScore += 14; if (firstBucket !== lastBucket && lastBucket === "northerly") coldScore += 10; if (cloudDelta >= 15 && tempDelta >= 0) warmScore += 6; if (cloudDelta >= 15 && tempDelta < 0) coldScore += 8; if (precipMax >= 40) coldScore += 8; if ( weatherGovText.includes("cold front") || weatherGovText.includes("temperatures falling") ) { coldScore += 18; } if (weatherGovText.includes("warm front") || weatherGovText.includes("warmer")) { warmScore += 18; } if (weatherGovText.includes("thunder") || weatherGovText.includes("snow")) { coldScore += 8; } const score = Math.max(-100, Math.min(100, warmScore - coldScore)); const warmLabel = isEnglish(locale) ? "Near-term warming bias" : "未来偏升温"; const coldLabel = isEnglish(locale) ? "Near-term cooling bias" : "未来偏降温"; const monitorLabel = isEnglish(locale) ? "Direction unclear" : "方向不清"; const label = score >= 18 ? warmLabel : score <= -18 ? coldLabel : monitorLabel; const confidence = Math.abs(score) >= 45 ? "high" : Math.abs(score) >= 22 ? "medium" : "low"; const directionalLead = (() => { if (isEnglish(locale)) { if (score >= 18 && tempDelta >= 0.5) { return `Over ${windowText}, temperatures still lean warmer.`; } if (score <= -18 && tempDelta <= -0.5) { return `Over ${windowText}, temperatures still lean cooler.`; } if (score >= 18) { return `Over ${windowText}, the structure still leans warmer, but the warming pace is not strong yet.`; } if (score <= -18) { return `Over ${windowText}, the structure still leans cooler, but the cooling pace is not decisive yet.`; } if (tempDelta >= 0.8) { return `Over ${windowText}, temperatures still lean warmer, but confidence is limited.`; } if (tempDelta <= -0.8) { return `Over ${windowText}, temperatures still lean cooler, but confidence is limited.`; } return `Over ${windowText}, temperatures are more likely to stay range-bound for now.`; } if (score >= 18 && tempDelta >= 0.5) { return `${windowText}偏增温,后续更可能继续往上走。`; } if (score <= -18 && tempDelta <= -0.5) { return `${windowText}偏降温,后续更可能继续往下走。`; } if (score >= 18) { return `${windowText}仍偏增温,但增温兑现力度暂时不算强。`; } if (score <= -18) { return `${windowText}仍偏降温,但降温兑现力度暂时不算强。`; } if (tempDelta >= 0.8) { return `${windowText}略偏增温,但结构信号置信度有限。`; } if (tempDelta <= -0.8) { return `${windowText}略偏降温,但结构信号置信度有限。`; } return `${windowText}更像震荡整理,短时升降温方向暂不清晰。`; })(); const summary = (() => { const parts: string[] = []; if (isEnglish(locale)) { parts.push(directionalLead); if (lastBucket === "southerly" && firstBucket !== "southerly") { parts.push("Low-level wind turns more southerly."); } else if (lastBucket === "northerly" && firstBucket !== "northerly") { parts.push("Low-level wind shifts toward a northerly regime."); } if (tempDelta >= 0.8) { parts.push(`Temperature rises by ${formatDelta(tempDelta, detail.temp_symbol)}.`); } else if (tempDelta <= -0.8) { parts.push(`Temperature eases by ${formatDelta(tempDelta, detail.temp_symbol)}.`); } if (dewDelta >= 0.8) { parts.push("Dew point is lifting, suggesting moisture transport is strengthening."); } else if (dewDelta <= -0.8) { parts.push("Dew point is falling, so low-level air is turning drier."); } if (cloudDelta >= 15) { parts.push("Cloud cover is building."); } else if (cloudDelta <= -15) { parts.push("Cloud cover is easing."); } if (pressureDelta >= 1) { parts.push("Pressure rebound argues for a cooler push."); } else if (pressureDelta <= -1) { parts.push("Pressure is softening, which is less hostile to warming."); } if (precipMax >= 50) { parts.push( primaryPrecipWindow ? `Model precipitation risk concentrates in ${precipWindowLabel}.` : "Precipitation risk is high enough to watch for cloud/rain suppression.", ); } if (!parts.length) { parts.push(`Structured trend is mixed, so the core judgement still centers on ${windowText}.`); } else { parts.push(`Core judgement remains focused on ${windowText}.`); } } else { parts.push(directionalLead); if (lastBucket === "southerly" && firstBucket !== "southerly") { parts.push("低层风向更偏南,暖空气输送权重上升。"); } else if (lastBucket === "northerly" && firstBucket !== "northerly") { parts.push("低层风向转偏北,冷空气影响权重上升。"); } if (tempDelta >= 0.8) { parts.push(`气温抬升 ${formatDelta(tempDelta, detail.temp_symbol)}。`); } else if (tempDelta <= -0.8) { parts.push(`气温回落 ${formatDelta(tempDelta, detail.temp_symbol)}。`); } if (dewDelta >= 0.8) { parts.push("露点同步上升,说明暖湿输送在增强。"); } else if (dewDelta <= -0.8) { parts.push("露点回落,低层空气在转干。"); } if (cloudDelta >= 15) { parts.push("云量正在增多。"); } else if (cloudDelta <= -15) { parts.push("云量正在回落。"); } if (pressureDelta >= 1) { parts.push("气压回升,更偏向冷空气压入。"); } else if (pressureDelta <= -1) { parts.push("气压走低,对增温压制减弱。"); } if (precipMax >= 50) { parts.push( primaryPrecipWindow ? `模型降水窗口主要落在 ${precipWindowLabel}。` : "降水概率已足以关注云雨压温。", ); } if (!parts.length) { parts.push(`结构信号分化较大,核心仍围绕${windowText}观察。`); } else { parts.push(`核心判断窗口仍以${windowText}为主。`); } } return parts.join(isEnglish(locale) ? " " : ""); })(); const tafContrastSummary = tafSignal.available && dateStr === detail.local_date ? (() => { const tafSuppression = String( tafSignal.suppression_level || "low", ).toLowerCase(); const isCoolingBias = score <= -18; const isWarmingBias = score >= 18; if (tafSuppression === "low" && isCoolingBias) { return isEnglish(locale) ? "TAF is not adding a new cloud/rain suppression signal, but the near-surface window is already leaning cooler, so the current cooling bias still comes mainly from surface structure." : "TAF 没有新增云雨压温利空,但当前峰值窗口里的近地面结构已经偏弱,所以这次偏降温判断仍主要来自近地面信号。"; } if (tafSuppression === "low" && isWarmingBias) { return isEnglish(locale) ? "TAF is not adding a new cloud/rain cap, and the warmer bias still comes mainly from the surface window." : "TAF 没有新增云雨压温约束,当前偏升温判断仍主要来自近地面窗口。"; } if (tafSuppression === "medium" && isCoolingBias) { return isEnglish(locale) ? "TAF is not the only driver here; it only reinforces part of the cooling-side case, while the main tilt still comes from the surface window." : "这次偏降温不只是 TAF 在起作用;TAF 只是加强了部分冷侧判断,主方向仍来自近地面窗口。"; } return ""; })() : ""; const tafSummaryLineBody = [ tafPrimarySummary, tafReferenceSummary, tafFallbackSummary, tafContrastSummary, ] .filter(Boolean) .join(isEnglish(locale) ? " " : ""); const surfaceSummaryLine = summary ? isEnglish(locale) ? `Surface: ${summary}` : `近地面:${summary}` : ""; const tafSummaryLine = tafSummaryLineBody ? isEnglish(locale) ? `Airport TAF: ${tafSummaryLineBody}` : `机场 TAF:${tafSummaryLineBody}` : ""; const summaryLines = [surfaceSummaryLine, tafSummaryLine].filter(Boolean); const combinedSummary = summaryLines.join(isEnglish(locale) ? " " : ""); const cloudNote = (() => { if (cloudDelta >= 15 && tempDelta >= 0.8 && dewDelta >= 0.8) { return isEnglish(locale) ? "Clouds are increasing while temperature and dew point still rise; this usually fits ongoing warm-moist transport rather than immediate cooling." : "云量上升时温度和露点仍在抬升,更像暖湿输送持续中,而不是立刻转凉。"; } if (cloudDelta >= 15 && tempDelta >= 0 && lastBucket === "southerly") { return isEnglish(locale) ? "Clouds are building without clear cooling, and the low-level wind still leans southerly; watch for warm advection to continue." : "云量增多但未明显降温,且低层风仍偏南,需继续关注暖平流是否延续。"; } if (cloudDelta >= 15 && tempDelta < 0 && precipMax >= 40) { return isEnglish(locale) ? "Clouds are thickening while temperature eases and precipitation risk is elevated; cloud/rain suppression is becoming more likely." : "云量增厚且气温回落,同时降水概率偏高,更像云雨压温开始生效。"; } if (cloudDelta >= 15 && tempDelta < 0 && pressureDelta >= 1) { return isEnglish(locale) ? "Clouds are increasing while temperature softens and pressure rebounds; watch for cold-air push or frontal suppression." : "云量上升同时气温走弱、气压回升,需留意冷空气压入或锋面压温。"; } if (cloudDelta <= -15 && tempDelta >= 0.8) { return isEnglish(locale) ? "Cloud cover is easing while temperature rises; daytime heating efficiency is improving." : "云量回落且温度抬升,白天增温效率在改善。"; } return isEnglish(locale) ? "Read forecast cloud-cover increase together with temperature, dew point, wind, and precipitation; it does not override the current observed sky condition." : "这里显示的是预测窗口内的云量增幅,需要结合温度、露点、风向和降水一起看,不能覆盖当前实况的天空状况。"; })(); const dewNote = (() => { if (dewDelta >= 1.2 && tempDelta >= 0.8) { return isEnglish(locale) ? "Dew point and temperature rise together, which usually supports strengthening warm-moist transport." : "露点和温度同步抬升,更偏向暖湿输送增强。"; } if (dewDelta >= 1.2 && precipMax >= 40) { return isEnglish(locale) ? "Moisture is building while precipitation risk is already notable; watch for showers to cap daytime heating." : "水汽在累积且降水风险已抬升,需关注阵雨对午后增温的压制。"; } if (dewDelta <= -1.2 && tempDelta <= 0) { return isEnglish(locale) ? "Drier low-level air is arriving together with softer temperature, which leans away from warm-moist support." : "低层空气在转干且温度偏弱,暖湿支撑正在减弱。"; } return isEnglish(locale) ? "Use dew-point change to judge whether low-level warm-moist transport is strengthening or fading." : "露点变化主要用于判断低层暖湿输送是在增强还是减弱。"; })(); const pressureNote = (() => { if (pressureDelta >= 1.2 && tempDelta <= -0.8) { return isEnglish(locale) ? "Pressure rebound with cooling usually points to a cooler push or frontal suppression." : "气压回升且温度走弱,更像冷空气压入或锋面压温。"; } if (pressureDelta <= -1.0 && tempDelta >= 0.8) { return isEnglish(locale) ? "Pressure is softening while temperature rises, a setup less hostile to warming." : "气压走低同时温度抬升,对增温的压制相对减弱。"; } return isEnglish(locale) ? "Pressure change is used as a supporting signal for cold-air push versus warming resilience." : "气压变化更适合作为冷空气压入或增温韧性的辅助判断。"; })(); const windNote = (() => { if (firstBucket !== lastBucket && lastBucket === "southerly") { return isEnglish(locale) ? "Wind turns toward a southerly regime, which is more favorable for warming." : "风向转偏南,更有利于增温。"; } if (firstBucket !== lastBucket && lastBucket === "northerly") { return isEnglish(locale) ? "Wind turns toward a northerly regime, which is more favorable for cooling." : "风向转偏北,更有利于降温。"; } if (lastBucket === "southerly") { return isEnglish(locale) ? "Low-level flow remains southerly, so warm advection has not been disrupted." : "低层风维持偏南,暖平流支撑尚未被破坏。"; } if (lastBucket === "northerly") { return isEnglish(locale) ? "Low-level flow remains northerly, so cooling-side support is still present." : "低层风维持偏北,降温侧支撑仍在。"; } return isEnglish(locale) ? "Wind-direction change matters most when it crosses into southerly or northerly buckets." : "风向变化最关键的是是否跨入偏南或偏北风桶。"; })(); const precipNote = (() => { if (precipMax >= 60) { return isEnglish(locale) ? `${precipWindowSummary} Cloud/rain suppression can materially change the peak outcome.` : `${precipWindowSummary} 降水风险已高到足以显著改变峰值兑现结果,需要重点防压温。`; } if (precipMax >= 40) { return isEnglish(locale) ? `${precipWindowSummary} Watch whether cloud and showers interrupt daytime heating.` : `${precipWindowSummary} 需要关注云系和阵雨是否打断白天增温。`; } return isEnglish(locale) ? "Precipitation risk remains limited and is used mainly as a suppression check." : "降水风险暂时有限,主要作为压温风险校验项。"; })(); const metrics = [ { label: isEnglish(locale) ? "Temperature delta" : "温度变化", note: isEnglish(locale) ? `Official Open-Meteo hourly data; window: ${windowText}` : `官方 Open-Meteo 小时数据;计算窗口:${windowText}`, tone: tempDelta >= 0.8 ? "warm" : tempDelta <= -0.8 ? "cold" : "", value: formatDelta(tempDelta, detail.temp_symbol), }, { label: isEnglish(locale) ? "Dew point delta" : "露点变化", note: dewNote, tone: dewDelta >= 0.8 ? "warm" : dewDelta <= -0.8 ? "cold" : "", value: formatDelta(dewDelta, detail.temp_symbol), }, { label: isEnglish(locale) ? "Pressure delta" : "气压变化", note: pressureNote, tone: pressureDelta >= 1 ? "cold" : pressureDelta <= -1 ? "warm" : "", value: formatDelta(pressureDelta, " hPa"), }, { label: isEnglish(locale) ? "Wind-direction evolution" : "风向演变", note: windNote, value: `${bucketLabel(firstBucket, locale)} -> ${bucketLabel(lastBucket, locale)}`, }, { label: isEnglish(locale) ? "Precip window" : "降水窗口", note: precipNote, tone: precipMax >= 50 ? "cold" : "", value: primaryPrecipWindow ? `${precipWindowLabel} · ${Math.round(precipMax)}%` : `${Math.round(precipMax)}%`, }, { label: isEnglish(locale) ? "Forecast cloud-cover delta" : "预测云量增幅", note: cloudNote, tone: cloudDelta >= 15 && tempDelta >= 0 ? "warm" : cloudDelta >= 15 && tempDelta < 0 ? "cold" : "", value: formatDelta(cloudDelta, "%"), }, ]; if (backendNotes.length) { const normalizedSummary = backendSummary.trim(); const alignedNotes = backendNotes.filter( (note) => String(note || "").trim() !== normalizedSummary, ); alignedNotes.slice(0, metrics.length).forEach((note, index) => { if (!note) return; metrics[index] = { ...metrics[index], note, }; }); } return { confidence, label, metrics, upperAirMetrics, upperAirSummary, precipOverlapLevel, precipWindowLabel, precipMax, score, summary: combinedSummary || backendSummary || summary, summaryLines, weatherGovPeriods, }; } export function getFutureModalView( detail: CityDetail, dateStr: string, locale: Locale = "zh-CN", ) { const forecastEntry = detail.forecast?.daily?.find((item) => item.date === dateStr) || null; const dailyModel = detail.multi_model_daily?.[dateStr] || {}; const probabilities = dailyModel.probabilities || []; const totalProbability = probabilities.reduce((sum, item) => { const probability = Number(item.probability); return Number.isFinite(probability) ? sum + probability : sum; }, 0); const weightedProbability = probabilities.reduce((sum, item) => { const value = Number(item.value); const probability = Number(item.probability); if (!Number.isFinite(value) || !Number.isFinite(probability)) { return sum; } return sum + value * probability; }, 0); const mu = totalProbability > 0 ? weightedProbability / totalProbability : null; const deb = dailyModel.deb?.prediction ?? forecastEntry?.max_temp ?? null; return { deb, forecastEntry, front: computeFrontTrendSignal(detail, dateStr, locale), models: dailyModel.models || {}, mu: Number.isFinite(Number(mu)) ? Number(mu) : null, probabilities, slice: getFutureSlice(detail, dateStr), }; } export function getShortTermNowcastLines( detail: CityDetail, dateStr: string, locale: Locale = "zh-CN", ) { const slice = getFutureSlice(detail, dateStr); if (dateStr !== detail.local_date) { const afternoon = slice.filter((point) => { const hour = Number.parseInt(String(point.label).split(":")[0], 10); return Number.isFinite(hour) && hour >= 12 && hour <= 18; }); const target = afternoon.length ? afternoon : slice; if (!target.length) { return [ [isEnglish(locale) ? "Target date" : "目标日期", dateStr], [ isEnglish(locale) ? "Peak window" : "峰值窗口", isEnglish(locale) ? "No sufficient hourly forecast data for target-day peak-window diagnostics." : "暂无足够的小时级 forecast 数据,无法生成目标日午后峰值窗口判断。", ], ] as const; } const maxIndex = target.reduce((bestIndex, point, index, array) => { const temp = Number(point.temp); const bestTemp = Number(array[bestIndex]?.temp); if (!Number.isFinite(temp)) return bestIndex; if (!Number.isFinite(bestTemp) || temp > bestTemp) return index; return bestIndex; }, 0); const peakSlice = target.slice( Math.max(0, maxIndex - 1), Math.min(target.length, maxIndex + 2), ); const start = peakSlice[0]; const end = peakSlice[peakSlice.length - 1]; const peakPoint = target[maxIndex] || end; const startTemp = Number(start.temp); const endTemp = Number(end.temp); const startDew = Number(start.dewPoint); const endDew = Number(end.dewPoint); const startPressure = Number(start.pressure); const endPressure = Number(end.pressure); const precipValues = peakSlice .map((point) => Number(point.precipProb)) .filter(Number.isFinite); const cloudValues = peakSlice .map((point) => Number(point.cloudCover)) .filter(Number.isFinite); const maxPrecip = precipValues.length ? Math.max(...precipValues) : 0; const maxCloud = cloudValues.length ? Math.max(...cloudValues) : 0; return [ [isEnglish(locale) ? "Target date" : "目标日期", dateStr], [ isEnglish(locale) ? "Peak window" : "峰值窗口", isEnglish(locale) ? `${start.label} - ${end.label} (prefer 12:00-18:00)` : `${start.label} - ${end.label}(优先取 12:00-18:00)`, ], [ isEnglish(locale) ? "Peak estimate" : "峰值预估", `${Number.isFinite(Number(peakPoint.temp)) ? Number(peakPoint.temp).toFixed(1) : "--"}${detail.temp_symbol} @ ${peakPoint.label || "--"}`, ], [ isEnglish(locale) ? "Window temperature" : "窗口温度", `${Number.isFinite(startTemp) ? startTemp.toFixed(1) : "--"}${detail.temp_symbol} -> ${Number.isFinite(endTemp) ? endTemp.toFixed(1) : "--"}${detail.temp_symbol} (${formatDelta(endTemp - startTemp, detail.temp_symbol)})`, ], [ isEnglish(locale) ? "Dew-point delta" : "露点变化", isEnglish(locale) ? `${formatDelta(endDew - startDew, detail.temp_symbol)} for diagnosing warm/wet transport in afternoon.` : `${formatDelta(endDew - startDew, detail.temp_symbol)},用于判断午后暖湿输送是否增强。`, ], [ isEnglish(locale) ? "Wind shift" : "风向演变", isEnglish(locale) ? `${bucketLabel(trendBucketFromDir(start.windDir), locale)} -> ${bucketLabel(trendBucketFromDir(end.windDir), locale)} around peak window.` : `${bucketLabel(trendBucketFromDir(start.windDir), locale)} -> ${bucketLabel(trendBucketFromDir(end.windDir), locale)},关注峰值前后是否转南风或回摆北风。`, ], [ isEnglish(locale) ? "Pressure delta" : "气压变化", isEnglish(locale) ? `${formatDelta(endPressure - startPressure, " hPa")} (higher pressure usually favors cold-air push).` : `${formatDelta(endPressure - startPressure, " hPa")},上升更偏向冷空气压入。`, ], [ isEnglish(locale) ? "Precip / cloud" : "降水 / 云量", isEnglish(locale) ? `${Math.round(maxPrecip)}% / ${Math.round(maxCloud)}% for cloud-suppression judgement around peak hours.` : `${Math.round(maxPrecip)}% / ${Math.round(maxCloud)}%,用于判断峰值时段是否受云系压制。`, ], ] as const; } const recent = Array.isArray(detail.metar_recent_obs) ? detail.metar_recent_obs.slice(-4) : []; const nearby = Array.isArray(detail.official_nearby) && detail.official_nearby.length ? detail.official_nearby : Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby : []; const nearbySource = String(detail.nearby_source || "").toLowerCase(); const sourceLabel = nearbySource === "mgm" || isTurkishMgmCity(detail) ? isEnglish(locale) ? "MGM nearby stations" : "MGM 周边站" : nearbySource === "official_cluster" ? isEnglish(locale) ? "Official nearby stations" : "官方周边站" : isEnglish(locale) ? "METAR nearby stations" : "METAR 周边站"; const currentTemp = Number(detail.current?.temp); const recentTemps = recent .map((point) => Number(point.temp)) .filter((value) => Number.isFinite(value)); const baseline = recentTemps.length ? recentTemps[0] : currentTemp; const shortDelta = Number.isFinite(currentTemp) && Number.isFinite(baseline) ? currentTemp - baseline : 0; let nearbyLead: { diff: number; name: string; temp: number; syncText: string } | null = null; for (const station of nearby) { const temp = Number(station.temp); if (station.usable_for_intraday === false) continue; if (!Number.isFinite(temp) || !Number.isFinite(currentTemp)) continue; const diff = temp - currentTemp; const syncStatus = String(station.sync_status || "").toLowerCase(); const syncDelta = Number(station.time_delta_vs_anchor_minutes); const syncText = syncStatus === "synced" ? isEnglish(locale) ? "time-synced" : "时间同步" : syncStatus === "near_realtime" || syncStatus === "lagged" ? Number.isFinite(syncDelta) ? isEnglish(locale) ? `${Math.round(syncDelta)} min offset` : `时间差 ${Math.round(syncDelta)} 分钟` : isEnglish(locale) ? "not fully synchronized" : "非完全同步" : syncStatus === "unknown" ? isEnglish(locale) ? "timing unverified" : "时间待校验" : ""; if (!nearbyLead || Math.abs(diff) > Math.abs(nearbyLead.diff)) { nearbyLead = { diff, name: station.name || station.icao || (isEnglish(locale) ? "Nearby station" : "周边站"), temp, syncText, }; } } const usableNearbyCount = nearby.filter((station) => station.usable_for_intraday !== false).length; const rows: Array = [ [ isEnglish(locale) ? "Primary station" : "当前主站", `${detail.current?.temp ?? "--"}${detail.temp_symbol} @ ${detail.current?.obs_time || "--"}`, ], [ isEnglish(locale) ? "Raw METAR" : "原始 METAR", detail.current?.raw_metar || (isEnglish(locale) ? "N/A" : "暂无"), ], [ isEnglish(locale) ? "Next 0-2h" : "近 0-2 小时", isEnglish(locale) ? `${formatDelta(shortDelta, detail.temp_symbol)} based on latest METAR sequence short-term momentum.` : `${formatDelta(shortDelta, detail.temp_symbol)},依据最近 METAR 序列判断短时动量。`, ], [ sourceLabel, isEnglish(locale) ? `${usableNearbyCount}/${nearby.length} stations usable for the nearby scan; station timestamps may differ.` : `${usableNearbyCount}/${nearby.length} 个站点可参与邻近监控;周边站观测时间可能不同步。`, ], ]; if (nearbyLead) { const tone = isEnglish(locale) ? nearbyLead.diff > 0 ? "warmer" : nearbyLead.diff < 0 ? "cooler" : "flat" : nearbyLead.diff > 0 ? "偏暖" : nearbyLead.diff < 0 ? "偏冷" : "持平"; rows.push([ isEnglish(locale) ? "Leading station" : "领先站", isEnglish(locale) ? `${nearbyLead.name} ${nearbyLead.temp}${detail.temp_symbol}, relative to primary station ${formatDelta(nearbyLead.diff, detail.temp_symbol)} (${tone})${nearbyLead.syncText ? `; ${nearbyLead.syncText}` : ""}.` : `${nearbyLead.name} ${nearbyLead.temp}${detail.temp_symbol},相对主站 ${formatDelta(nearbyLead.diff, detail.temp_symbol)}(${tone})${nearbyLead.syncText ? `;${nearbyLead.syncText}` : ""}。`, ]); } return rows; } function toFiniteNumber(value: unknown): number | null { const numeric = Number(value); return Number.isFinite(numeric) ? numeric : null; } function asRecord(value: unknown): Record | null { return value && typeof value === "object" ? (value as Record) : null; } function firstFiniteNumber(values: unknown[]) { for (const value of values) { const numeric = toFiniteNumber(value); if (numeric != null) return numeric; } return null; } function firstNonEmptyString(values: unknown[]) { for (const value of values) { const text = String(value ?? "").trim(); if (text) return text; } return null; } function formatObservationUpdate(value: unknown, locale: Locale) { const raw = String(value ?? "").trim(); if (!raw) return null; const looksDated = /^\d{4}-\d{2}-\d{2}/.test(raw) || raw.includes("T") || /[+-]\d{2}:?\d{2}$/.test(raw); if (looksDated) { const parsed = new Date(raw); if (!Number.isNaN(parsed.getTime())) { return new Intl.DateTimeFormat(isEnglish(locale) ? "en-US" : "zh-CN", { day: "2-digit", hour: "2-digit", hour12: false, minute: "2-digit", month: "2-digit", }) .format(parsed) .replace(",", ""); } } return normalizeHm(raw) || raw; } function localObservationTimeCandidate(value: unknown) { const raw = String(value ?? "").trim(); if (!raw || raw.includes("T") || /^\d{4}-\d{2}-\d{2}/.test(raw)) { return ""; } return normalizeHm(raw) || raw; } function getOfficialObservationCandidates(detail: CityDetail) { const officialNearby = Array.isArray(detail.official_nearby) ? detail.official_nearby : []; const mgmNearby = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby : []; const officialAnchor = officialNearby.find( (station) => station.is_settlement_anchor || station.is_airport_station, ) || officialNearby[0]; return { centerStation: detail.center_station_candidate, mgmNearby, officialAnchor, officialNearby, }; } function getObservedTemperatureProfile(detail: CityDetail, locale: Locale) { const { mgmNearby } = getOfficialObservationCandidates(detail); const displayAirportPrimary = getDisplayAirportPrimary(detail); const currentSource = String( detail.current?.settlement_source || detail.current?.settlement_source_label || "", ) .trim() .toLowerCase(); const isNmcCurrent = currentSource === "nmc" || currentSource.includes("nmc"); const isNmcAirport = String(displayAirportPrimary?.source_label || "") .trim() .toLowerCase() .includes("nmc"); const candidates = [ { sourceLabel: detail.airport_current?.source_label || "METAR", temp: detail.airport_current?.temp, }, { sourceLabel: displayAirportPrimary?.source_label || "METAR", temp: isNmcAirport ? null : displayAirportPrimary?.temp, }, { sourceLabel: detail.current?.settlement_source_label, temp: isNmcCurrent ? null : detail.current?.temp, }, { sourceLabel: mgmNearby[0]?.source_label, temp: mgmNearby[0]?.temp, }, ]; const selected = candidates.find((candidate) => Number.isFinite(Number(candidate.temp)), ); if (!selected) { return isEnglish(locale) ? "Unavailable" : "未提供"; } const observedTemp = Number(selected.temp); const sourceLabel = firstNonEmptyString([ selected.sourceLabel, getObservationSourceTag(detail), ]); const rounded = Math.abs(observedTemp - Math.round(observedTemp)) < 0.05 ? String(Math.round(observedTemp)) : observedTemp.toFixed(1); return `${rounded}${detail.temp_symbol || "°C"}${ sourceLabel ? ` · ${sourceLabel}` : "" }`; } function getObservationUpdateProfile(detail: CityDetail, locale: Locale) { const { mgmNearby } = getOfficialObservationCandidates(detail); const mgmFirstRecord = asRecord(mgmNearby[0]); const displayAirportPrimary = getDisplayAirportPrimary(detail); const currentSource = String( detail.current?.settlement_source || detail.current?.settlement_source_label || "", ) .trim() .toLowerCase(); const isNmcCurrent = currentSource === "nmc" || currentSource.includes("nmc"); const rawValue = firstNonEmptyString([ isNmcCurrent ? "" : detail.current?.obs_time, localObservationTimeCandidate(displayAirportPrimary?.obs_time), localObservationTimeCandidate(detail.airport_current?.obs_time), localObservationTimeCandidate(mgmFirstRecord?.obs_time), localObservationTimeCandidate(mgmFirstRecord?.time), displayAirportPrimary?.report_time, detail.airport_current?.report_time, isNmcCurrent ? "" : detail.current?.report_time, mgmFirstRecord?.report_time, detail.updated_at, ]); return ( formatObservationUpdate(rawValue, locale) || (isEnglish(locale) ? "Unavailable" : "未提供") ); } export function getCityProfileStats(detail: CityDetail, locale: Locale = "zh-CN") { const risk = detail.risk || {}; const nearbyCount = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby.length : 0; const nearbySource = String(detail.nearby_source || "").trim().toLowerCase(); const sourceCode = getObservationSourceCode(detail); const isOfficialSource = sourceCode === "hko" || sourceCode === "cwa" || sourceCode === "noaa"; const sourceDisplay = (() => { if (sourceCode === "hko") { return isEnglish(locale) ? "Hong Kong Observatory (HKO)" : "香港天文台 (HKO)"; } if (sourceCode === "cwa") { return isEnglish(locale) ? "Central Weather Administration (CWA)" : "交通部中央气象署 (CWA)"; } if (sourceCode === "noaa") { const noaaCode = getNoaaStationCode(detail); const noaaName = getNoaaStationName(detail); return isEnglish(locale) ? `${noaaName}${noaaCode ? ` (${noaaCode})` : ""}` : `${noaaName}${noaaCode ? `(${noaaCode})` : ""}`; } if (sourceCode === "wunderground") { const icao = String(detail.risk?.icao || detail.current?.station_code || "") .trim() .toUpperCase(); const stationName = String( detail.current?.station_name || detail.risk?.airport || "", ).trim(); return `${stationName || icao || "Airport"}${icao ? ` (${icao} METAR)` : " METAR"}`; } const stationName = String( detail.current?.station_name || detail.risk?.airport || "", ).trim(); const stationCode = String( detail.current?.station_code || detail.risk?.icao || "", ).trim(); if (stationName) { return `${stationName}${stationCode ? ` (${stationCode})` : ""}`; } const tag = getObservationSourceTag(detail); if (sourceCode === "mgm") { return isEnglish(locale) ? `MGM (${tag})` : `MGM (${tag})`; } if (risk.airport && risk.icao) return `${risk.airport} (${risk.icao})`; if (risk.airport) return String(risk.airport); return isEnglish(locale) ? "No profile" : "暂无档案"; })(); const rows = [ { label: isOfficialSource ? isEnglish(locale) ? "Settlement station" : "结算站点" : isEnglish(locale) ? "Settlement airport" : "结算机场", value: sourceDisplay, }, { label: isOfficialSource ? isEnglish(locale) ? "Reference distance" : "参考距离" : isEnglish(locale) ? "Station distance" : "站点距离", value: risk.distance_km != null && Number.isFinite(Number(risk.distance_km)) ? `${risk.distance_km} km` : isEnglish(locale) ? "Not marked" : "未标注", }, { label: isEnglish(locale) ? "Observed temp" : "实测温度", value: getObservedTemperatureProfile(detail, locale), }, { label: isEnglish(locale) ? "Observation update" : "观测更新", value: getObservationUpdateProfile(detail, locale), }, { label: isEnglish(locale) ? "Nearby stations" : "周边站点", value: nearbyCount > 0 ? isEnglish(locale) ? `${nearbyCount} participating stations` : `${nearbyCount} 个参与监控` : isEnglish(locale) ? "No nearby stations" : "暂无周边站", }, ]; if (nearbyCount > 0) { rows.push({ label: isEnglish(locale) ? "Nearby source" : "周边站来源", value: nearbySource === "kma" ? isEnglish(locale) ? "KMA official stations" : "KMA 官方站" : nearbySource === "official_cluster" ? isEnglish(locale) ? "Official station cluster" : "官方站簇" : nearbySource === "mgm" ? "MGM" : isEnglish(locale) ? "Airport / METAR network" : "机场 / METAR 网络", }); } if (nearbySource === "kma" && detail.airport_current?.temp != null) { const airportLabel = String( detail.airport_current.station_label || detail.airport_current.station_code || detail.risk?.airport || "", ).trim() || (isEnglish(locale) ? "Airport station" : "机场主站"); const airportObsTime = String(detail.airport_current.obs_time || "").trim() || (isEnglish(locale) ? "pending" : "待更新"); const airportHigh = detail.airport_current.max_so_far != null ? `${detail.airport_current.max_so_far}${detail.temp_symbol || ""}${ detail.airport_current.max_temp_time ? ` @ ${detail.airport_current.max_temp_time}` : "" }` : isEnglish(locale) ? "Unavailable" : "未提供"; rows.push({ label: isEnglish(locale) ? "Airport reference" : "机场主站参考", value: `${airportLabel}: ${detail.airport_current.temp}${ detail.temp_symbol || "" } @ ${airportObsTime}`, }); rows.push({ label: isEnglish(locale) ? "Airport high" : "机场目前最高温", value: airportHigh, }); } return rows; } export function getSettlementRiskNarrative( detail: CityDetail, locale: Locale = "zh-CN", ) { const risk = detail.risk || {}; const sourceCode = getObservationSourceCode(detail); const stationTerm = sourceCode === "hko" || sourceCode === "cwa" || sourceCode === "noaa" ? isEnglish(locale) ? "settlement reference station" : "结算参考站" : isEnglish(locale) ? "settlement airport" : "结算机场"; const lines: string[] = []; if (risk.warning) { lines.push( isEnglish(locale) ? `Current key risk: ${risk.warning}` : `当前主要风险是:${risk.warning}`, ); } if (risk.distance_km != null) { if (risk.distance_km >= 60) { lines.push( isEnglish(locale) ? `The ${stationTerm} is far from urban core; market feel and settlement value may diverge significantly.` : `${stationTerm}与城市核心区域距离偏大,盘面温度与结算值可能出现明显背离。`, ); } else if (risk.distance_km >= 25) { lines.push( isEnglish(locale) ? `The ${stationTerm} has material distance from downtown; peak/overnight rhythm should prioritize the settlement station.` : `${stationTerm}与城区存在可感知距离,午后峰值和夜间降温节奏需要优先看结算站。`, ); } else { lines.push( isEnglish(locale) ? `The ${stationTerm} is close enough; city feel and settlement temperature are usually more synchronized.` : `${stationTerm}距离较近,城市体感与结算温度通常更同步。`, ); } } if (isTurkishMgmCity(detail)) { lines.push( isEnglish(locale) ? "For Turkish MGM-supported cities, focus on the airport station plus MGM nearby-station linkage, not urban sensation alone." : "对接入 MGM 的土耳其城市,需要重点看机场站与 MGM 周边站联动,不能只看城区体感。", ); } if (detail.current?.obs_age_min != null) { if (detail.current.obs_age_min >= 45) { lines.push( isEnglish(locale) ? `Current METAR is ${detail.current.obs_age_min} minutes old. Blend nearby stations for nowcast instead of single-station snapshot.` : `当前 METAR 已有 ${detail.current.obs_age_min} 分钟时滞,临近判断要结合周边站而不是只看主站快照。`, ); } else { lines.push( isEnglish(locale) ? "Primary station observation is fresh enough; short-term judgement can anchor on it." : "当前主站观测较新,短时判断可以把主站温度作为主要锚点。", ); } } return lines; } export function getClimateDrivers(detail: CityDetail, locale: Locale = "zh-CN") { const drivers: Array<{ label: string; text: string }> = []; const lat = Math.abs(Number(detail.lat)); const nearbyCount = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby.length : 0; const distanceKm = Number(detail.risk?.distance_km); if (lat >= 50) { drivers.push({ label: isEnglish(locale) ? "High-latitude cold air" : "高纬冷空气", text: isEnglish(locale) ? "At higher latitude, temperature rhythm is more affected by cold-air surges, trough passage, and seasonal radiation angle." : "该城市位于较高纬度,温度变化更容易受到冷空气南下、短波槽和日照角度变化影响。", }); } else if (lat >= 35) { drivers.push({ label: isEnglish(locale) ? "Mid-latitude westerlies" : "中纬西风带", text: isEnglish(locale) ? "Temperature shifts are often controlled by frontal transitions rather than pure daytime radiation." : "该城市主要受中纬西风带和锋面活动控制,升降温常来自气团切换,而不是单一日照变化。", }); } else if (lat >= 20) { drivers.push({ label: isEnglish(locale) ? "Subtropical highs" : "副热带高压", text: isEnglish(locale) ? "Subtropical ridge, clear-sky radiation and low-level warm advection often dominate warming efficiency." : "该城市更容易受副热带高压、晴空辐射和低层暖平流影响,午后增温能力通常更强。", }); } else { drivers.push({ label: isEnglish(locale) ? "Tropical moisture & convection" : "热带水汽与对流", text: isEnglish(locale) ? "Temperature and feels-like are often modulated by moisture transport, cloud convection and showers." : "该城市偏热带环境,温度与体感常受水汽输送、云对流和阵雨触发影响。", }); } drivers.push({ label: isEnglish(locale) ? "Dry-wet boundary layer" : "干湿边界层", text: isEnglish(locale) ? "Boundary-layer humidity controls daytime warming efficiency; dry boundary warms faster, wet boundary is more cloud/precip-sensitive." : "低层干湿状态会决定午后升温效率。干空气通常升温更快,湿空气更容易受云量和降水过程抑制。", }); drivers.push({ label: isEnglish(locale) ? "Advection transport" : "平流输送", text: isEnglish(locale) ? "Short-term trend is usually driven by low-level air-mass transport. Persistent wind origin tends to sustain thermal direction." : "短时趋势常由低层气团输送控制。若风向持续来自同一侧,温度通常更容易沿该方向延续。", }); if (Number.isFinite(distanceKm) && distanceKm >= 25) { drivers.push({ label: isEnglish(locale) ? "Station representativeness" : "站点代表性", text: isEnglish(locale) ? "When settlement station is not near city core, perceived temperature and settlement value may diverge." : "结算站与城市核心区存在一定距离时,体感温度和结算温度可能分离,评估时应优先以结算站观测为准。", }); } if (nearbyCount >= 4) { drivers.push({ label: isEnglish(locale) ? "Local heterogeneity" : "局地差异", text: isEnglish(locale) ? "More nearby stations suggest terrain/urban-heat heterogeneity; settlement station and downtown sensation should be evaluated separately." : "周边可用站点较多,说明地形、城区热岛或下垫面差异可能明显,结算站与城区体感需要分开评估。", }); } return drivers; }