feat: add weekly reward loop, i18n support, and scan terminal dashboard components
This commit is contained in:
@@ -822,8 +822,13 @@ function PolyWeatherTerminal({
|
||||
>
|
||||
{visibleSlots.map((cityInSlot, slotIndex) => {
|
||||
const isSlotActive = activeSlotIndex === slotIndex;
|
||||
const rowForSlot = cityInSlot
|
||||
? filteredRegionRows.find(
|
||||
(r) => String(r.city || "").toLowerCase() === cityInSlot
|
||||
) || null
|
||||
: null;
|
||||
|
||||
if (!cityInSlot) {
|
||||
if (!cityInSlot || !rowForSlot) {
|
||||
return (
|
||||
<div key={slotIndex} className="relative h-full">
|
||||
<EmptySlotCard
|
||||
@@ -850,10 +855,6 @@ function PolyWeatherTerminal({
|
||||
);
|
||||
}
|
||||
|
||||
const rowForSlot = filteredRegionRows.find(
|
||||
(r) => String(r.city || "").toLowerCase() === cityInSlot
|
||||
) || null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={slotIndex}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
fetchHourlyForecastForCity,
|
||||
getActiveTemperatureSeries,
|
||||
getDebPeakWindowRange,
|
||||
getPeakGlowState,
|
||||
getLiveObservationLabels,
|
||||
getObservationDisplayMetrics,
|
||||
getVisibleTemperatureSeries,
|
||||
@@ -35,6 +36,46 @@ import {
|
||||
} from "@/components/dashboard/scan-terminal/temperature-chart-logic";
|
||||
export { clearCityDetailCache } from "@/components/dashboard/scan-terminal/temperature-chart-logic";
|
||||
|
||||
const PEAK_GLOW_PANEL_CLASS = {
|
||||
none: "",
|
||||
watch: "peak-glow-card peak-glow-watch",
|
||||
near_peak: "peak-glow-card peak-glow-near",
|
||||
breakout: "peak-glow-card peak-glow-breakout",
|
||||
cooling: "peak-glow-card peak-glow-cooling",
|
||||
} as const;
|
||||
|
||||
const PEAK_GLOW_BADGE_CLASS = {
|
||||
none: "",
|
||||
watch: "border-amber-200 bg-amber-50 text-amber-700",
|
||||
near_peak: "border-orange-200 bg-orange-50 text-orange-700",
|
||||
breakout: "border-rose-200 bg-rose-50 text-rose-700",
|
||||
cooling: "border-slate-200 bg-slate-100 text-slate-500",
|
||||
} as const;
|
||||
|
||||
function peakGlowLabel(state: keyof typeof PEAK_GLOW_PANEL_CLASS, isEn: boolean) {
|
||||
if (state === "watch") return isEn ? "Watch" : "关注";
|
||||
if (state === "near_peak") return isEn ? "Near peak" : "接近峰值";
|
||||
if (state === "breakout") return isEn ? "Breakout" : "突破";
|
||||
if (state === "cooling") return isEn ? "Cooling" : "降温";
|
||||
return "";
|
||||
}
|
||||
|
||||
function peakGlowTitle(
|
||||
state: keyof typeof PEAK_GLOW_PANEL_CLASS,
|
||||
distanceToDeb: number | null,
|
||||
isEn: boolean,
|
||||
) {
|
||||
const label = peakGlowLabel(state, isEn);
|
||||
if (!label) return "";
|
||||
if (distanceToDeb === null) return label;
|
||||
const absDistance = Math.abs(distanceToDeb).toFixed(1);
|
||||
if (state === "breakout" && distanceToDeb <= 0) {
|
||||
return isEn ? `${label}: ${absDistance}° above DEB` : `${label}:高于 DEB ${absDistance}°`;
|
||||
}
|
||||
if (state === "cooling") return isEn ? `${label}: peak likely passed` : `${label}:峰值可能已过`;
|
||||
return isEn ? `${label}: ${absDistance}° below DEB` : `${label}:距 DEB ${absDistance}°`;
|
||||
}
|
||||
|
||||
function formatCityLocalDate(tzOffsetSeconds: number | null | undefined) {
|
||||
const cityOffsetMs = (tzOffsetSeconds ?? 0) * 1000;
|
||||
const cityNow = new Date(Date.now() + cityOffsetMs + new Date().getTimezoneOffset() * 60_000);
|
||||
@@ -273,6 +314,7 @@ export function LiveTemperatureThresholdChart({
|
||||
const chartLocalDate = chartHourly?.localDate || row?.local_date || currentCityLocalDate;
|
||||
|
||||
const { data, series } = useMemo(() => buildFullDayChartData(row, chartHourly, isEn), [row, chartHourly, isEn]);
|
||||
const peakGlow = useMemo(() => getPeakGlowState(row, data, series), [row, data, series]);
|
||||
|
||||
const autoWindowRange = useMemo(
|
||||
() => (viewMode === "auto" ? getDebPeakWindowRange(data, series) : null),
|
||||
@@ -442,6 +484,17 @@ export function LiveTemperatureThresholdChart({
|
||||
</button>
|
||||
<span className="text-slate-400 font-normal">·</span>
|
||||
<span className="text-slate-500 font-normal">{subtitle}</span>
|
||||
{peakGlow.state !== "none" && (
|
||||
<span
|
||||
className={clsx(
|
||||
"ml-1 rounded border px-1.5 py-0.5 text-[9px] font-black normal-case tracking-normal",
|
||||
PEAK_GLOW_BADGE_CLASS[peakGlow.state],
|
||||
)}
|
||||
title={peakGlowTitle(peakGlow.state, peakGlow.distanceToDeb, isEn)}
|
||||
>
|
||||
{peakGlowLabel(peakGlow.state, isEn)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : isEn ? (
|
||||
"Temperature Chart"
|
||||
@@ -559,7 +612,11 @@ export function LiveTemperatureThresholdChart({
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel title={panelTitle} actions={timeframeActions}>
|
||||
<Panel
|
||||
title={panelTitle}
|
||||
actions={timeframeActions}
|
||||
className={PEAK_GLOW_PANEL_CLASS[peakGlow.state]}
|
||||
>
|
||||
<div className="flex h-full min-h-[300px] flex-col">
|
||||
<TemperatureStatsBars
|
||||
isEn={isEn}
|
||||
@@ -641,5 +698,6 @@ export const __getActiveTemperatureSeriesForTest = getActiveTemperatureSeries;
|
||||
export const __getDebPeakWindowRangeForTest = getDebPeakWindowRange;
|
||||
export const __getLiveObservationLabelsForTest = getLiveObservationLabels;
|
||||
export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics;
|
||||
export const __getPeakGlowStateForTest = getPeakGlowState;
|
||||
export const __shouldPollLiveChartForTest = shouldPollLiveChart;
|
||||
export const __mergePatchIntoHourlyForTest = mergePatchIntoHourly;
|
||||
|
||||
+99
@@ -4,6 +4,7 @@ import {
|
||||
__getDebPeakWindowRangeForTest,
|
||||
__getLiveObservationLabelsForTest,
|
||||
__getObservationDisplayMetricsForTest,
|
||||
__getPeakGlowStateForTest,
|
||||
__getVisibleTemperatureSeriesForTest,
|
||||
__isTemperatureSeriesVisibleByDefaultForTest,
|
||||
__mergePatchIntoHourlyForTest,
|
||||
@@ -22,6 +23,69 @@ function runwayKey(rwy: string) {
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const peakGlowSeries = [
|
||||
{
|
||||
key: "hourly_forecast",
|
||||
label: "DEB Forecast",
|
||||
source: "DEB Hourly",
|
||||
color: "#f97316",
|
||||
values: [26, 29, 32, 32, 30],
|
||||
},
|
||||
{
|
||||
key: "madis",
|
||||
label: "METAR",
|
||||
source: "METAR",
|
||||
color: "#0284c7",
|
||||
values: [26.0, 29.0, null, 30.4, null],
|
||||
},
|
||||
] as any;
|
||||
const peakGlowData = [
|
||||
{ ts: Date.UTC(2026, 4, 27, 10, 0), hourly_forecast: 26, madis: 26.0 },
|
||||
{ ts: Date.UTC(2026, 4, 27, 11, 0), hourly_forecast: 29, madis: 29.0 },
|
||||
{ ts: Date.UTC(2026, 4, 27, 12, 0), hourly_forecast: 32, madis: null },
|
||||
{ ts: Date.UTC(2026, 4, 27, 13, 0), hourly_forecast: 32, madis: 30.4 },
|
||||
{ ts: Date.UTC(2026, 4, 27, 14, 0), hourly_forecast: 30, madis: null },
|
||||
] as any;
|
||||
|
||||
assert(
|
||||
__getPeakGlowStateForTest({ temp_symbol: "°C" } as any, peakGlowData, peakGlowSeries).state === "near_peak",
|
||||
"city chart should enter near-peak glow when live temperature is within 2C of DEB and not cooling",
|
||||
);
|
||||
assert(
|
||||
__getPeakGlowStateForTest({ temp_symbol: "°C" } as any, peakGlowData, [
|
||||
peakGlowSeries[0],
|
||||
{ ...peakGlowSeries[1], values: [26.0, 29.0, null, 29.2, null] },
|
||||
] as any).state === "watch",
|
||||
"city chart should enter watch glow when live temperature is within 3C of DEB but not near peak",
|
||||
);
|
||||
assert(
|
||||
__getPeakGlowStateForTest({ temp_symbol: "°C" } as any, peakGlowData, [
|
||||
peakGlowSeries[0],
|
||||
{ ...peakGlowSeries[1], values: [26.0, 29.0, null, 32.1, null] },
|
||||
] as any).state === "breakout",
|
||||
"city chart should use breakout glow when live temperature reaches or exceeds DEB peak",
|
||||
);
|
||||
assert(
|
||||
__getPeakGlowStateForTest({ temp_symbol: "°C" } as any, [
|
||||
{ ts: Date.UTC(2026, 4, 27, 10, 0), hourly_forecast: 26, madis: 26.0 },
|
||||
{ ts: Date.UTC(2026, 4, 27, 11, 0), hourly_forecast: 30, madis: 30.0 },
|
||||
{ ts: Date.UTC(2026, 4, 27, 12, 0), hourly_forecast: 32, madis: 31.8 },
|
||||
{ ts: Date.UTC(2026, 4, 27, 13, 0), hourly_forecast: 30, madis: 30.8 },
|
||||
{ ts: Date.UTC(2026, 4, 27, 14, 0), hourly_forecast: 27, madis: 30.2 },
|
||||
] as any, [
|
||||
{ ...peakGlowSeries[0], values: [26, 30, 32, 30, 27] },
|
||||
{ ...peakGlowSeries[1], values: [26.0, 30.0, 31.8, 30.8, 30.2] },
|
||||
] as any).state === "cooling",
|
||||
"city chart should stop hot glow and show cooling state after peak when live temperature rolls over",
|
||||
);
|
||||
assert(
|
||||
__getPeakGlowStateForTest({ temp_symbol: "°F" } as any, peakGlowData, [
|
||||
{ ...peakGlowSeries[0], values: [79, 84, 90, 90, 86] },
|
||||
{ ...peakGlowSeries[1], values: [80, 84, null, 86.2, null] },
|
||||
] as any).state === "watch",
|
||||
"US Fahrenheit charts should convert Celsius thresholds before deciding peak glow state",
|
||||
);
|
||||
|
||||
const guangzhou = {
|
||||
city: "guangzhou",
|
||||
local_date: "2026-05-25",
|
||||
@@ -433,6 +497,41 @@ export function runTests() {
|
||||
assert(madisSeries.label.includes("MADIS"), "US MADIS series should be labeled as NOAA MADIS instead of plain METAR");
|
||||
assert(madisSeries.values.filter((value: number | null) => value !== null).length >= 2, "MADIS series should keep sub-hourly observations");
|
||||
|
||||
const torontoWithLatestAirportReport = __buildTemperatureChartDataForTest(
|
||||
{
|
||||
city: "toronto",
|
||||
local_date: "2026-05-27",
|
||||
local_time: "19:16",
|
||||
tz_offset_seconds: -4 * 60 * 60,
|
||||
airport: "CYYZ",
|
||||
temp_symbol: "°C",
|
||||
} as any,
|
||||
{
|
||||
localTime: "19:16",
|
||||
times: ["10:00", "13:00", "16:00", "19:00"],
|
||||
temps: [23, 26, 27, 26],
|
||||
airportPrimary: {
|
||||
source_code: "metar",
|
||||
source_label: "METAR",
|
||||
temp: 26,
|
||||
obs_time: "2026-05-27T23:16:00Z",
|
||||
},
|
||||
airportPrimaryTodayObs: [
|
||||
["2026-05-27T21:00:00Z", 27],
|
||||
["2026-05-27T22:00:00Z", 28],
|
||||
["2026-05-27T23:00:00Z", 27],
|
||||
],
|
||||
} as any,
|
||||
"1D",
|
||||
);
|
||||
const latestAirportPoint = torontoWithLatestAirportReport.data.find(
|
||||
(point) => point.label === "19:16:00" && point.madis === 26,
|
||||
);
|
||||
assert(
|
||||
latestAirportPoint,
|
||||
"latest airport/METAR report should be appended to the live chart series even when history stops earlier",
|
||||
);
|
||||
|
||||
const newYorkMinuteStream = __buildTemperatureChartDataForTest(
|
||||
{
|
||||
city: "new york",
|
||||
|
||||
@@ -41,4 +41,9 @@ export function runTests() {
|
||||
selectorSource.includes("grid grid-cols-3"),
|
||||
"grid selector must expose at most a 3 by 3 chart layout",
|
||||
);
|
||||
assert(
|
||||
dashboardSource.includes("if (!cityInSlot || !rowForSlot)") &&
|
||||
dashboardSource.includes("handleSelectCityForSlot(slotIndex, null);"),
|
||||
"stale saved chart slots must render the empty city picker instead of a row=null Temperature Chart",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -187,6 +187,20 @@ type EvidenceSeries = {
|
||||
values: Array<number | null>;
|
||||
};
|
||||
|
||||
type PeakGlowState = "none" | "watch" | "near_peak" | "breakout" | "cooling";
|
||||
|
||||
type PeakGlowMeta = {
|
||||
state: PeakGlowState;
|
||||
currentTemp: number | null;
|
||||
debPeak: number | null;
|
||||
distanceToDeb: number | null;
|
||||
trend30m: number | null;
|
||||
trend60m: number | null;
|
||||
observedHigh: number | null;
|
||||
peakStartTs: number | null;
|
||||
peakEndTs: number | null;
|
||||
};
|
||||
|
||||
type RunwayHistorySeries = {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -435,6 +449,36 @@ function normObs(
|
||||
.slice(-limit);
|
||||
}
|
||||
|
||||
function appendLatestAirportObservation(
|
||||
points: RawObsPoint[] | null | undefined,
|
||||
...currentSources: Array<AirportCurrentConditions | null | undefined>
|
||||
): RawObsPoint[] {
|
||||
const merged = [...(points || [])];
|
||||
const seen = new Set(
|
||||
merged
|
||||
.map(normalizeRawObsPoint)
|
||||
.filter((point): point is ObsPoint => point !== null)
|
||||
.map((point) => `${String(point.time || "")}:${validNumber(point.temp) ?? ""}`),
|
||||
);
|
||||
|
||||
currentSources.forEach((source) => {
|
||||
const temp = validNumber(source?.temp);
|
||||
const time =
|
||||
(source as any)?.obs_time ??
|
||||
(source as any)?.observation_time ??
|
||||
(source as any)?.timestamp ??
|
||||
(source as any)?.time ??
|
||||
null;
|
||||
if (temp === null || !time) return;
|
||||
const key = `${String(time)}:${temp}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
merged.push({ time: String(time), temp });
|
||||
});
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function seriesStats(values: Array<number | null>) {
|
||||
const nums = values.filter((v): v is number => validNumber(v) !== null);
|
||||
const latest = nums.length ? nums[nums.length - 1] : null;
|
||||
@@ -479,7 +523,12 @@ function getObservationDisplayMetrics(
|
||||
const localDateStr = resolveChartLocalDate(row, hourly);
|
||||
const settlementObs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset, MAX_OBS_POINTS, localDateStr);
|
||||
const metarObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset, MAX_OBS_POINTS, localDateStr);
|
||||
const madisObs = normObs(hourly?.airportPrimaryTodayObs, tzOffset, MAX_OBS_POINTS, localDateStr);
|
||||
const madisObs = normObs(
|
||||
appendLatestAirportObservation(hourly?.airportPrimaryTodayObs, hourly?.airportPrimary, hourly?.airportCurrent),
|
||||
tzOffset,
|
||||
MAX_OBS_POINTS,
|
||||
localDateStr,
|
||||
);
|
||||
const latestSettlement = latestObservationValue(settlementObs);
|
||||
const latestMetar = latestObservationValue(metarObs);
|
||||
const latestMadis = latestObservationValue(madisObs);
|
||||
@@ -1236,7 +1285,15 @@ function buildFullDayChartData(
|
||||
normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset, MAX_OBS_POINTS, localDateStr),
|
||||
localDayBounds,
|
||||
);
|
||||
const madisObs = filterTimelinePointsToLocalDay(normObs(hourly?.airportPrimaryTodayObs, tzOffset, MAX_OBS_POINTS, localDateStr), localDayBounds);
|
||||
const madisObs = filterTimelinePointsToLocalDay(
|
||||
normObs(
|
||||
appendLatestAirportObservation(hourly?.airportPrimaryTodayObs, hourly?.airportPrimary, hourly?.airportCurrent),
|
||||
tzOffset,
|
||||
MAX_OBS_POINTS,
|
||||
localDateStr,
|
||||
),
|
||||
localDayBounds,
|
||||
);
|
||||
const runwayHistorySeries = filterRunwayHistoryToLocalDay(
|
||||
buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr),
|
||||
localDayBounds,
|
||||
@@ -1535,6 +1592,163 @@ function latestLiveObservationTimestamp(
|
||||
return latest;
|
||||
}
|
||||
|
||||
function chartDeltaForCelsius(row: ScanOpportunityRow | null, deltaC: number) {
|
||||
const symbol = String(row?.temp_symbol || "").toUpperCase();
|
||||
return symbol.includes("F") ? deltaC * 1.8 : deltaC;
|
||||
}
|
||||
|
||||
function getLiveObservationPoints(
|
||||
data: Array<Record<string, any>>,
|
||||
series: EvidenceSeries[],
|
||||
) {
|
||||
const liveSeries = series.filter(isLiveObservationSeries);
|
||||
const points: Array<{ ts: number; temp: number }> = [];
|
||||
data.forEach((row, index) => {
|
||||
const ts = typeof row?.ts === "number" ? row.ts : null;
|
||||
if (ts === null) return;
|
||||
const values = liveSeries
|
||||
.map((item) => validNumber(item.values[index]))
|
||||
.filter((value): value is number => value !== null);
|
||||
if (!values.length) return;
|
||||
points.push({ ts, temp: Math.max(...values) });
|
||||
});
|
||||
return points.sort((left, right) => left.ts - right.ts);
|
||||
}
|
||||
|
||||
function getDebPeakProfile(
|
||||
row: ScanOpportunityRow | null,
|
||||
data: Array<Record<string, any>>,
|
||||
series: EvidenceSeries[],
|
||||
) {
|
||||
const debSeries = series.find((item) => item.key === "hourly_forecast");
|
||||
if (!debSeries) return null;
|
||||
const debPoints = debSeries.values
|
||||
.map((value, index) => {
|
||||
const ts = typeof data[index]?.ts === "number" ? data[index].ts : null;
|
||||
const temp = validNumber(value);
|
||||
return ts === null || temp === null ? null : { index, ts, temp };
|
||||
})
|
||||
.filter((point): point is { index: number; ts: number; temp: number } => point !== null);
|
||||
if (!debPoints.length) return null;
|
||||
const peak = debPoints.reduce((best, point) => (point.temp > best.temp ? point : best), debPoints[0]);
|
||||
let peakIndex = debPoints.findIndex((point) => point.index === peak.index);
|
||||
if (peakIndex < 0) peakIndex = 0;
|
||||
|
||||
const hotThreshold = peak.temp - chartDeltaForCelsius(row, 2);
|
||||
let hotStartIndex = peakIndex;
|
||||
let hotEndIndex = peakIndex;
|
||||
while (hotStartIndex > 0 && debPoints[hotStartIndex - 1].temp >= hotThreshold) {
|
||||
hotStartIndex -= 1;
|
||||
}
|
||||
while (hotEndIndex < debPoints.length - 1 && debPoints[hotEndIndex + 1].temp >= hotThreshold) {
|
||||
hotEndIndex += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
peak,
|
||||
hotStartTs: debPoints[hotStartIndex].ts,
|
||||
hotEndTs: debPoints[hotEndIndex].ts,
|
||||
};
|
||||
}
|
||||
|
||||
function pointAtOrBefore(
|
||||
points: Array<{ ts: number; temp: number }>,
|
||||
targetTs: number,
|
||||
): { ts: number; temp: number } | null {
|
||||
let match: { ts: number; temp: number } | null = null;
|
||||
for (const point of points) {
|
||||
if (point.ts <= targetTs) match = point;
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
function getPeakGlowState(
|
||||
row: ScanOpportunityRow | null,
|
||||
data: Array<Record<string, any>>,
|
||||
series: EvidenceSeries[],
|
||||
): PeakGlowMeta {
|
||||
const empty: PeakGlowMeta = {
|
||||
state: "none",
|
||||
currentTemp: null,
|
||||
debPeak: null,
|
||||
distanceToDeb: null,
|
||||
trend30m: null,
|
||||
trend60m: null,
|
||||
observedHigh: null,
|
||||
peakStartTs: null,
|
||||
peakEndTs: null,
|
||||
};
|
||||
const livePoints = getLiveObservationPoints(data, series);
|
||||
const latest = livePoints[livePoints.length - 1] || null;
|
||||
const debProfile = getDebPeakProfile(row, data, series);
|
||||
if (!latest || !debProfile) return empty;
|
||||
|
||||
const peakStartTs = debProfile.hotStartTs;
|
||||
const peakEndTs = debProfile.hotEndTs;
|
||||
const previousLivePoints = livePoints.filter((point) => point.ts < latest.ts);
|
||||
const previousHigh = previousLivePoints.length
|
||||
? Math.max(...previousLivePoints.map((point) => point.temp))
|
||||
: null;
|
||||
const observedHigh = Math.max(...livePoints.map((point) => point.temp));
|
||||
const trend30Base = pointAtOrBefore(livePoints, latest.ts - 30 * 60 * 1000);
|
||||
const trend60Base = pointAtOrBefore(livePoints, latest.ts - 60 * 60 * 1000);
|
||||
const trend30m = trend30Base ? latest.temp - trend30Base.temp : null;
|
||||
const trend60m = trend60Base ? latest.temp - trend60Base.temp : null;
|
||||
const distanceToDeb = debProfile.peak.temp - latest.temp;
|
||||
|
||||
const metaBase = {
|
||||
currentTemp: latest.temp,
|
||||
debPeak: debProfile.peak.temp,
|
||||
distanceToDeb,
|
||||
trend30m,
|
||||
trend60m,
|
||||
observedHigh,
|
||||
peakStartTs,
|
||||
peakEndTs,
|
||||
};
|
||||
|
||||
const nearThreshold = chartDeltaForCelsius(row, 2);
|
||||
const watchThreshold = chartDeltaForCelsius(row, 3);
|
||||
const breakoutNearThreshold = chartDeltaForCelsius(row, 0.5);
|
||||
const flatTrendFloor = -chartDeltaForCelsius(row, 0.2);
|
||||
const coolingDrop = -chartDeltaForCelsius(row, 0.5);
|
||||
const breakoutStep = chartDeltaForCelsius(row, 0.1);
|
||||
const phase = String((row as any)?.window_phase || "").toLowerCase();
|
||||
const afterPeak =
|
||||
phase === "post_peak" ||
|
||||
(peakEndTs !== null && latest.ts > peakEndTs);
|
||||
const isCooling =
|
||||
afterPeak &&
|
||||
((trend60m !== null && trend60m <= coolingDrop) ||
|
||||
(previousHigh !== null && latest.temp <= previousHigh + coolingDrop));
|
||||
if (isCooling) return { state: "cooling", ...metaBase };
|
||||
|
||||
const isBreakout =
|
||||
latest.temp >= debProfile.peak.temp ||
|
||||
(distanceToDeb <= breakoutNearThreshold &&
|
||||
previousHigh !== null &&
|
||||
latest.temp > previousHigh + breakoutStep);
|
||||
if (isBreakout) return { state: "breakout", ...metaBase };
|
||||
|
||||
if (
|
||||
distanceToDeb <= nearThreshold &&
|
||||
(trend30m === null || trend30m >= flatTrendFloor)
|
||||
) {
|
||||
return { state: "near_peak", ...metaBase };
|
||||
}
|
||||
|
||||
const isNearPeakTime =
|
||||
peakStartTs !== null &&
|
||||
peakEndTs !== null &&
|
||||
latest.ts <= peakEndTs &&
|
||||
peakStartTs - latest.ts <= 3 * 60 * 60 * 1000;
|
||||
if (distanceToDeb <= watchThreshold || isNearPeakTime) {
|
||||
return { state: "watch", ...metaBase };
|
||||
}
|
||||
|
||||
return { state: "none", ...metaBase };
|
||||
}
|
||||
|
||||
function getDebPeakWindowRange(
|
||||
data: Array<Record<string, any>>,
|
||||
series: EvidenceSeries[],
|
||||
@@ -1645,6 +1859,7 @@ export {
|
||||
buildChartDomain,
|
||||
buildFullDayChartData,
|
||||
getDebPeakWindowRange,
|
||||
getPeakGlowState,
|
||||
buildIntDegreeTicks,
|
||||
buildModelSummaryCards,
|
||||
buildRunwayPlates,
|
||||
@@ -1664,4 +1879,4 @@ export {
|
||||
validNumber,
|
||||
};
|
||||
|
||||
export type { EvidenceSeries, HourlyForecast };
|
||||
export type { EvidenceSeries, HourlyForecast, PeakGlowMeta, PeakGlowState };
|
||||
|
||||
Reference in New Issue
Block a user