feat: implement Telegram push utility and temperature threshold visualization components
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user