feat: implement Telegram push utility and temperature threshold visualization components

This commit is contained in:
2569718930@qq.com
2026-05-27 08:53:36 +08:00
parent bbd7c768f8
commit bef3c610b7
6 changed files with 106 additions and 96 deletions
@@ -28,6 +28,7 @@ import {
isTemperatureSeriesVisibleByDefault,
mergePatchIntoHourly,
normObs,
prefersHighFrequencyRunwayResolution,
readSessionCache,
seedHourlyForecastFromRow,
shouldPollLiveChart,
@@ -130,7 +131,9 @@ export function LiveTemperatureThresholdChart({
const [refAreaLeft, setRefAreaLeft] = useState<number | null>(null);
const [refAreaRight, setRefAreaRight] = useState<number | null>(null);
const [zoomRange, setZoomRange] = useState<[number, number] | null>(null);
const [targetResolution, setTargetResolution] = useState<string>("10m");
const [targetResolution, setTargetResolution] = useState<string>(() =>
prefersHighFrequencyRunwayResolution(row, null) ? "1m" : "10m",
);
const [currentCityLocalDate, setCurrentCityLocalDate] = useState(() =>
formatCityLocalDate(row?.tz_offset_seconds),
);
@@ -138,8 +141,9 @@ export function LiveTemperatureThresholdChart({
useEffect(() => {
setUserToggledKeys({});
setZoomRange(null);
setViewMode("auto");
setViewMode("full");
setShowRunwayDetails(true);
setTargetResolution(prefersHighFrequencyRunwayResolution(row, null) ? "1m" : "10m");
setHourly(seedHourlyForecastFromRow(row));
setLiveTemp(null);
setIsHourlyLoading(Boolean(city));
@@ -246,7 +250,7 @@ export function LiveTemperatureThresholdChart({
const refreshFullDetail = () => {
lastPatchAtRef.current = Date.now();
fetchHourlyForecastForCity(city, { ignoreCache: true })
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
.then((data) => {
if (cancelled || !data) return;
hasLoadedHourlyDetailRef.current = true;
@@ -279,7 +283,7 @@ export function LiveTemperatureThresholdChart({
cancelled = true;
clearInterval(id);
};
}, [city, compact, isActive, isMaximized]);
}, [city, compact, isActive, isMaximized, targetResolution]);
useEffect(() => {
if (!city || !currentCityLocalDate) return;
@@ -323,6 +327,10 @@ export function LiveTemperatureThresholdChart({
);
const visibleRange = zoomRange ?? autoWindowRange;
const visibleRangeKey = visibleRange ? `${visibleRange[0]}:${visibleRange[1]}` : "full";
const shouldUseRunwayResolution = useMemo(
() => prefersHighFrequencyRunwayResolution(row, chartHourly),
[row, chartHourly],
);
const zoomedData = useMemo(() => {
if (!visibleRange || data.length === 0) return data;
@@ -331,6 +339,9 @@ export function LiveTemperatureThresholdChart({
}, [data, visibleRangeKey]);
const nextTargetResolution = useMemo(() => {
if (shouldUseRunwayResolution) {
return "1m";
}
if (visibleRange && data.length > 0) {
const zoomedData = data.slice(visibleRange[0], visibleRange[1] + 1);
if (zoomedData.length > 0) {
@@ -342,7 +353,7 @@ export function LiveTemperatureThresholdChart({
}
}
return "10m";
}, [data, visibleRangeKey]);
}, [data, visibleRangeKey, shouldUseRunwayResolution]);
useEffect(() => {
if (targetResolution !== nextTargetResolution) {
@@ -114,6 +114,13 @@ export function TemperatureChartCanvas({
const canRenderChart = chartSize.width > 0 && chartSize.height > 0;
const chartWidth = Math.max(1, chartSize.width);
const chartHeight = Math.max(220, chartSize.height);
const individualRunwaySeriesCount = chartSeries.filter(
(series) => series.key.startsWith("runway_") && series.key !== "runway_max",
).length;
const canToggleRunwayDetails =
hasRunwayData &&
individualRunwaySeriesCount > 1 &&
chartSeries.some((series) => series.key === "runway_max");
return (
<div className="relative flex min-h-[240px] flex-1 flex-col p-2">
@@ -142,7 +149,7 @@ export function TemperatureChartCanvas({
</button>
))}
{hasRunwayData && (
{canToggleRunwayDetails && (
<label className="inline-flex items-center gap-1.5 ml-auto cursor-pointer text-slate-600 hover:text-slate-800 font-semibold select-none">
<input
type="checkbox"
@@ -61,9 +61,9 @@ export async function runTests() {
"selected city chart should consume SSE patches and use a 2-minute no-patch fallback",
);
assert(
chartSource.includes("fetchHourlyForecastForCity(city, { ignoreCache: true })") &&
chartSource.includes("fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })") &&
chartSource.includes("setHourly(data)"),
"visible chart fallback must refresh the full city detail payload when SSE patches stop",
"visible chart fallback must refresh the full city detail payload at the current chart resolution when SSE patches stop",
);
assert(
__shouldPollLiveChartForTest({ city: "shanghai", compact: true, isActive: false, isMaximized: false }) === true,
@@ -154,6 +154,10 @@ export function runTests() {
);
assert(chart.includes("viewMode"), "temperature chart must expose a view mode for DEB-peak auto view versus full-day view");
assert(chart.includes('useState<"auto" | "full">("full")'), "temperature chart must default every city panel to the all-day view");
assert(
chart.includes('setViewMode("full")') && !chart.includes('setViewMode("auto")'),
"temperature chart must reset city changes to the all-day view instead of silently switching back to the DEB peak window",
);
assert(chart.includes("getDebPeakWindowRange"), "temperature chart must still derive the optional Peak view from the DEB peak window");
assert(
chart.includes('isEn ? "Peak" : "高温"') && chart.includes('isEn ? "All Day" : "全天"'),
@@ -168,6 +172,10 @@ export function runTests() {
chart.includes("targetResolution !== nextTargetResolution"),
"temperature chart must guard target-resolution state updates to prevent render/update loops",
);
assert(
chart.includes("prefersHighFrequencyRunwayResolution") && chart.includes('return "1m";'),
"runway charts must request 1-minute detail resolution so historical runway lines match live SSE patch cadence",
);
assert(!chartCanvas.includes("ResponsiveContainer"), "temperature chart canvas must not mount Recharts through ResponsiveContainer at 0x0");
assert(chartCanvas.includes("ResizeObserver"), "temperature chart canvas must measure its host with ResizeObserver");
assert(
@@ -178,6 +186,10 @@ export function runTests() {
chartCanvas.includes("width={chartWidth}") && chartCanvas.includes("height={chartHeight}"),
"temperature chart canvas must pass explicit positive width/height to Recharts",
);
assert(
chartCanvas.includes("canToggleRunwayDetails") && chartCanvas.includes("individualRunwaySeriesCount > 1"),
"single-runway charts must not show the runway-detail toggle because aggregate and individual views are visually redundant",
);
assert(!chart.includes("3D"), "temperature chart UI must not expose a 3D/future-forecast mode");
assert(!chart.includes("build3DayChartData"), "temperature chart component must not render future prediction curves");
assert(
@@ -33,6 +33,10 @@ function normalizeCityKey(value?: string | null) {
return String(value || "").trim().toLowerCase().replace(/[\s_-]+/g, "");
}
function hasRecordEntries(value: unknown) {
return Boolean(value && typeof value === "object" && Object.keys(value as Record<string, unknown>).length > 0);
}
function pairKey(pair: [string, string]) {
return pair.map(normalizeRunwayLabel).sort().join("/");
}
@@ -52,6 +56,19 @@ function isTemperatureSeriesVisibleByDefault(city: string, seriesKey: string) {
return true;
}
function prefersHighFrequencyRunwayResolution(
row: ScanOpportunityRow | null,
hourly: HourlyForecast,
) {
const cityKey = normalizeCityKey(row?.city);
if ((SETTLEMENT_RUNWAY_PAIRS[cityKey] || []).length > 0) return true;
if (hasRecordEntries((row as any)?.runway_plate_history)) return true;
if (hasRecordEntries(hourly?.runwayPlateHistory)) return true;
if ((hourly?.runwayBandHistory || []).length > 0) return true;
if (((hourly?.amos?.runway_obs as any)?.runway_pairs || []).length > 0) return true;
return false;
}
function getVisibleTemperatureSeries(
city: string,
series: EvidenceSeries[],
@@ -1888,6 +1905,7 @@ export {
mergePatchIntoHourly,
normObs,
normalizeCityKey,
prefersHighFrequencyRunwayResolution,
readSessionCache,
seedHourlyForecastFromRow,
seriesStats,