From faa5bc94a71aed644e87719f4df22370993c1e6f Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sat, 11 Apr 2026 10:16:00 +0800 Subject: [PATCH] Highlight precipitation windows in front trend signals --- frontend/lib/dashboard-utils.ts | 129 ++++++++++++++++++++++++++++++-- 1 file changed, 121 insertions(+), 8 deletions(-) diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts index b09eae36..716e63ea 100644 --- a/frontend/lib/dashboard-utils.ts +++ b/frontend/lib/dashboard-utils.ts @@ -1854,6 +1854,107 @@ export function computeFrontTrendSignal( (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); @@ -1981,7 +2082,11 @@ export function computeFrontTrendSignal( } if (precipMax >= 50) { - parts.push("Precipitation risk is high enough to watch for cloud/rain suppression."); + parts.push( + primaryPrecipWindow + ? `Model precipitation risk concentrates in ${precipWindowLabel}.` + : "Precipitation risk is high enough to watch for cloud/rain suppression.", + ); } if (!parts.length) { @@ -2023,7 +2128,11 @@ export function computeFrontTrendSignal( } if (precipMax >= 50) { - parts.push("降水概率已足以关注云雨压温。"); + parts.push( + primaryPrecipWindow + ? `模型降水窗口主要落在 ${precipWindowLabel}。` + : "降水概率已足以关注云雨压温。", + ); } if (!parts.length) { @@ -2175,13 +2284,13 @@ export function computeFrontTrendSignal( const precipNote = (() => { if (precipMax >= 60) { return isEnglish(locale) - ? "Precipitation risk is high enough that cloud/rain suppression can materially change the peak outcome." - : "降水概率已高到足以显著改变峰值兑现结果,需要重点防压温。"; + ? `${precipWindowSummary} Cloud/rain suppression can materially change the peak outcome.` + : `${precipWindowSummary} 降水风险已高到足以显著改变峰值兑现结果,需要重点防压温。`; } if (precipMax >= 40) { return isEnglish(locale) - ? "Precipitation risk is meaningful; watch whether cloud and showers interrupt daytime heating." - : "降水概率已有存在感,需要关注云系和阵雨是否打断白天增温。"; + ? `${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." @@ -2215,10 +2324,12 @@ export function computeFrontTrendSignal( value: `${bucketLabel(firstBucket, locale)} -> ${bucketLabel(lastBucket, locale)}`, }, { - label: isEnglish(locale) ? "Precip probability" : "降水概率", + label: isEnglish(locale) ? "Precip window" : "降水窗口", note: precipNote, tone: precipMax >= 50 ? "cold" : "", - value: `${Math.round(precipMax)}%`, + value: primaryPrecipWindow + ? `${precipWindowLabel} · ${Math.round(precipMax)}%` + : `${Math.round(precipMax)}%`, }, { label: isEnglish(locale) ? "Forecast cloud-cover delta" : "预测云量增幅", @@ -2253,6 +2364,8 @@ export function computeFrontTrendSignal( metrics, upperAirMetrics, upperAirSummary, + precipOverlapLevel, + precipWindowLabel, precipMax, score, summary: combinedSummary || backendSummary || summary,