feat: implement real-time observation patch normalization and live temperature threshold visualization logic

This commit is contained in:
2569718930@qq.com
2026-05-27 10:17:33 +08:00
parent e6a673e27d
commit 820dabfbf3
23 changed files with 709 additions and 29 deletions
@@ -53,6 +53,8 @@ const PEAK_GLOW_BADGE_CLASS = {
cooling: "border-slate-200 bg-slate-100 text-slate-500",
} as const;
const PROBABILITY_REFRESH_AFTER_PATCH_MS = 60_000;
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" : "接近峰值";
@@ -125,6 +127,7 @@ export function LiveTemperatureThresholdChart({
const hasLoadedHourlyDetailRef = useRef(false);
const lastPatchAtRef = useRef<number>(Date.now());
const lastAppliedPatchRevisionRef = useRef<number>(0);
const lastProbabilityRefreshAtRef = useRef<number>(0);
const localDayRolloverFetchDateRef = useRef<string>("");
const [showRunwayDetails, setShowRunwayDetails] = useState<boolean>(true);
@@ -150,6 +153,7 @@ export function LiveTemperatureThresholdChart({
hasLoadedHourlyDetailRef.current = false;
lastPatchAtRef.current = Date.now();
lastAppliedPatchRevisionRef.current = 0;
lastProbabilityRefreshAtRef.current = 0;
localDayRolloverFetchDateRef.current = "";
setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds));
}, [city]);
@@ -222,7 +226,33 @@ export function LiveTemperatureThresholdChart({
const tempValue = validNumber(latestPatch.changes.temp);
if (tempValue !== null) setLiveTemp(tempValue);
setHourly((prev) => mergePatchIntoHourly(prev ?? seedHourlyForecastFromRow(row), latestPatch));
}, [latestPatch, row]);
const hasObservationChange =
tempValue !== null ||
Array.isArray(latestPatch.changes.runway_points) ||
Boolean(latestPatch.changes.amos);
if (!hasObservationChange || !shouldPollLiveChart({ city, compact, isActive, isMaximized })) return;
const now = Date.now();
if (now - lastProbabilityRefreshAtRef.current < PROBABILITY_REFRESH_AFTER_PATCH_MS) return;
lastProbabilityRefreshAtRef.current = now;
let cancelled = false;
const refreshProbabilityOverlayAfterPatch = () => {
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
.then((data) => {
if (cancelled || !data) return;
hasLoadedHourlyDetailRef.current = true;
setHourly(data);
})
.catch(() => {});
};
refreshProbabilityOverlayAfterPatch();
return () => {
cancelled = true;
};
}, [latestPatch, row, city, targetResolution, compact, isActive, isMaximized]);
useEffect(() => {
if (!resyncVersion || !city) return;
@@ -285,6 +315,45 @@ export function LiveTemperatureThresholdChart({
};
}, [city, compact, isActive, isMaximized, targetResolution]);
useEffect(() => {
if (!shouldPollLiveChart({ city, compact, isActive, isMaximized })) return;
let cancelled = false;
const refreshForegroundFullDetail = () => {
lastPatchAtRef.current = Date.now();
fetch(`/api/city/${encodeURIComponent(city)}/summary`)
.then((res) => (res.ok ? res.json() : null))
.then((payload) => {
if (cancelled || !payload) return;
const temp = validNumber(payload?.current?.temp);
if (temp !== null) setLiveTemp(temp);
})
.catch(() => {});
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
.then((data) => {
if (cancelled || !data) return;
hasLoadedHourlyDetailRef.current = true;
setHourly(data);
})
.catch(() => {});
};
const handleVisibilityChange = () => {
if (document.visibilityState !== "visible") return;
refreshForegroundFullDetail();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("focus", refreshForegroundFullDetail);
return () => {
cancelled = true;
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("focus", refreshForegroundFullDetail);
};
}, [city, compact, isActive, isMaximized, targetResolution]);
useEffect(() => {
if (!city || !currentCityLocalDate) return;
const loadedLocalDate = hourly?.localDate || row?.local_date || "";
@@ -318,7 +387,7 @@ export function LiveTemperatureThresholdChart({
}, [hourly, currentCityLocalDate, row?.local_date]);
const chartLocalDate = chartHourly?.localDate || row?.local_date || currentCityLocalDate;
const { data, series } = useMemo(() => buildFullDayChartData(row, chartHourly, isEn), [row, chartHourly, isEn]);
const { data, series, probabilityOverlay } = useMemo(() => buildFullDayChartData(row, chartHourly, isEn), [row, chartHourly, isEn]);
const peakGlow = useMemo(() => getPeakGlowState(row, data, series), [row, data, series]);
const autoWindowRange = useMemo(
@@ -473,10 +542,13 @@ export function LiveTemperatureThresholdChart({
return list.sort((a, b) => a.threshold - b.threshold);
}, [row, allRows]);
const intDegreeTicks = useMemo(() => buildIntDegreeTicks(activeSeries, zoomedData), [activeSeries, zoomedData]);
const intDegreeTicks = useMemo(
() => buildIntDegreeTicks(activeSeries, zoomedData, probabilityOverlay),
[activeSeries, zoomedData, probabilityOverlay],
);
const chartDomain = useMemo(
() => buildChartDomain(activeSeries, zoomedData),
[activeSeries, zoomedData],
() => buildChartDomain(activeSeries, zoomedData, probabilityOverlay),
[activeSeries, zoomedData, probabilityOverlay],
);
const subtitle = row ? (isEn ? "Live & Forecast" : "实测与预测") : "";
@@ -669,6 +741,7 @@ export function LiveTemperatureThresholdChart({
cityThresholds={cityThresholds}
chartSeries={chartSeries}
activeSeries={activeSeries}
probabilityOverlay={probabilityOverlay}
zoomedData={zoomedData}
chartDomain={chartDomain}
intDegreeTicks={intDegreeTicks}
@@ -15,7 +15,7 @@ import {
} from "recharts";
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { TemperatureTooltipContent } from "@/components/dashboard/scan-terminal/TemperatureTooltipContent";
import type { EvidenceSeries } from "@/components/dashboard/scan-terminal/temperature-chart-logic";
import type { EvidenceSeries, ProbabilityOverlay } from "@/components/dashboard/scan-terminal/temperature-chart-logic";
type CityThreshold = {
threshold: number;
@@ -32,6 +32,7 @@ export function TemperatureChartCanvas({
cityThresholds,
chartSeries,
activeSeries,
probabilityOverlay,
zoomedData,
chartDomain,
intDegreeTicks,
@@ -55,6 +56,7 @@ export function TemperatureChartCanvas({
cityThresholds: CityThreshold[];
chartSeries: EvidenceSeries[];
activeSeries: EvidenceSeries[];
probabilityOverlay: ProbabilityOverlay | null;
zoomedData: Array<Record<string, any>>;
chartDomain: [number, number] | ["auto", "auto"];
intDegreeTicks: number[] | null;
@@ -160,6 +162,30 @@ export function TemperatureChartCanvas({
<span>{isEn ? "Show Runway Details" : "显示跑道明细"}</span>
</label>
)}
{probabilityOverlay && (
<span
className={clsx(
"inline-flex items-center gap-1.5 rounded border border-violet-200 bg-violet-50 px-1.5 py-0.5 text-[10px] font-bold text-violet-700",
canToggleRunwayDetails ? "" : "ml-auto",
)}
title={
probabilityOverlay.muLine
? probabilityOverlay.muLine.label
: isEn
? "Legacy Gaussian probability bands"
: "Legacy 高斯概率温度带"
}
>
<span className="h-2 w-2 rounded-full bg-violet-500/70" />
<span>{isEn ? "Gaussian" : "高斯概率"}</span>
{probabilityOverlay.muLine && (
<span className="font-mono text-violet-600">
μ {probabilityOverlay.muLine.value.toFixed(1)}{tempSymbol}
</span>
)}
</span>
)}
</div>
<div ref={chartHostRef} className="relative min-h-[220px] flex-1">
{canRenderChart && (
@@ -191,6 +217,16 @@ export function TemperatureChartCanvas({
domain={chartDomain}
ticks={intDegreeTicks ?? undefined}
/>
{timeframe === "1D" && probabilityOverlay?.bands.map((band) => (
<ReferenceArea
key={band.key}
y1={band.lower}
y2={band.upper}
strokeOpacity={0}
fill="#8b5cf6"
fillOpacity={band.opacity}
/>
))}
{timeframe === "1D" && cityThresholds.map((t, idx) => {
const isSelected = row && (Number(row.target_threshold ?? row.target_value) === t.threshold);
const labelText = isEn
@@ -213,6 +249,20 @@ export function TemperatureChartCanvas({
/>
);
})}
{timeframe === "1D" && probabilityOverlay?.muLine && (
<ReferenceLine
y={probabilityOverlay.muLine.value}
stroke="#7c3aed"
strokeDasharray="2 3"
strokeWidth={1.4}
label={{
value: compact ? undefined : probabilityOverlay.muLine.label,
fill: "#7c3aed",
fontSize: 9,
position: "insideTopLeft",
}}
/>
)}
<Tooltip
filterNull={false}
cursor={{ stroke: "#94a3b8", strokeWidth: 1 }}
@@ -152,6 +152,19 @@ export function runTests() {
!resyncBlock.includes("setIsHourlyLoading(true)"),
"SSE replay resync should refresh full detail in the background without showing the loading overlay",
);
assert(
chart.includes("visibilitychange") &&
chart.includes('document.visibilityState !== "visible"') &&
chart.includes("refreshForegroundFullDetail"),
"temperature chart must immediately refresh visible charts when the browser tab returns to the foreground",
);
const foregroundRefreshBlock = chart.match(/const refreshForegroundFullDetail = \(\) => \{[\s\S]*?\n \};/)?.[0] || "";
assert(
foregroundRefreshBlock.includes("ignoreCache: true") &&
foregroundRefreshBlock.includes("fetchHourlyForecastForCity") &&
!foregroundRefreshBlock.includes("setIsHourlyLoading(true)"),
"foreground resume refresh should update full detail immediately in the background without showing the loading overlay",
);
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(
@@ -176,6 +189,19 @@ export function runTests() {
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(
chart.includes("PROBABILITY_REFRESH_AFTER_PATCH_MS") &&
chart.includes("lastProbabilityRefreshAtRef") &&
chart.includes("refreshProbabilityOverlayAfterPatch"),
"temperature chart must trigger a throttled background probability refresh after live observation patches",
);
const patchEffectBlock = chart.match(/useEffect\(\(\) => \{\s*if \(!latestPatch[\s\S]*?\}, \[latestPatch, row, city, targetResolution, compact, isActive, isMaximized\]\);/)?.[0] || "";
assert(
patchEffectBlock.includes("refreshProbabilityOverlayAfterPatch") &&
patchEffectBlock.includes("ignoreCache: true") &&
!patchEffectBlock.includes("setIsHourlyLoading(true)"),
"live patch probability refresh must recompute legacy Gaussian in the background without showing a loading overlay",
);
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(
@@ -10,7 +10,7 @@ import {
__mergePatchIntoHourlyForTest,
} from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
function assert(condition: unknown, message: string) {
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
@@ -802,6 +802,52 @@ export function runTests() {
"latest airport/METAR report should be appended to the live chart series even when history stops earlier",
);
const torontoCanonicalPatchHourly = __mergePatchIntoHourlyForTest(
{
localTime: "19:15",
localDate: "2026-05-27",
times: ["10:00", "13:00", "16:00", "19:00"],
temps: [23, 26, 27, 26],
airportPrimaryTodayObs: [],
} as any,
{
type: "city_observation_patch.v1",
city: "toronto",
revision: 13,
changes: {
temp: 26,
source: "metar",
observed_at_utc: "2026-05-27T23:16:00Z",
observed_at_local: "2026-05-27T19:16:00-04:00",
city_local_date: "2026-05-27",
city_timezone: "America/Toronto",
},
} as any,
);
assert(
torontoCanonicalPatchHourly,
"v1 canonical patch should merge into hourly forecast",
);
const torontoCanonicalPatchChart = __buildTemperatureChartDataForTest(
{
city: "toronto",
local_date: "2026-05-27",
local_time: "19:16",
tz_offset_seconds: -4 * 60 * 60,
temp_symbol: "°C",
} as any,
torontoCanonicalPatchHourly as any,
"1D",
);
assert(
torontoCanonicalPatchHourly.localDate === "2026-05-27",
"v1 canonical patch should update hourly localDate from city_local_date",
);
assert(
torontoCanonicalPatchChart.data.some((point) => point.label === "19:16:00" && point.madis === 26),
"v1 canonical patch observed_at_utc should render at the city-local chart time",
);
const newYorkMinuteStream = __buildTemperatureChartDataForTest(
{
city: "new york",
@@ -1039,4 +1085,45 @@ export function runTests() {
assert(bandPoints.length >= 2, "runway_band tuples should be binned into data slots");
const firstBand = bandPoints[0].runway_band;
assert(Array.isArray(firstBand) && firstBand[0] === 24.0 && firstBand[1] === 26.0, "runway_band tuple values should match input limits");
// ── Legacy Gaussian probability overlay test ──
const gaussianOverlayChart = __buildTemperatureChartDataForTest(
{
city: "toronto",
local_date: "2026-05-27",
local_time: "14:00",
tz_offset_seconds: -4 * 60 * 60,
temp_symbol: "°C",
} as any,
{
localDate: "2026-05-27",
localTime: "14:00",
times: ["10:00", "14:00", "18:00"],
temps: [24, 27, 23],
probabilities: {
mu: 27.4,
engine: "legacy",
distribution_all: [
{ value: 26, probability: 0.18, range: "[25.5~26.5)" },
{ value: 27, probability: 0.42, range: "[26.5~27.5)" },
{ value: 28, probability: 0.31, range: "[27.5~28.5)" },
],
},
} as any,
"1D",
) as any;
const gaussianOverlay = gaussianOverlayChart.probabilityOverlay;
assert(gaussianOverlay, "legacy Gaussian probabilities should be exposed as a chart overlay");
assert(gaussianOverlay.muLine?.value === 27.4, "legacy Gaussian μ should become a reference line");
assert(
gaussianOverlay.bands.some(
(band: any) => band.value === 27 && band.lower === 26.5 && band.upper === 27.5 && band.probability === 0.42,
),
"legacy Gaussian buckets should become horizontal probability temperature bands",
);
assert(
!gaussianOverlayChart.series.some((series: any) => String(series.key || "").includes("probability")),
"legacy Gaussian probability distribution should not be rendered as a time-series line",
);
}
@@ -5,6 +5,7 @@ import type {
ScanOpportunityRow,
ForecastDay,
DailyModelForecast,
ProbabilityBucket,
} from "@/lib/dashboard-types";
import { buildDebBaselinePath } from "@/lib/temperature-chart-paths";
import { DASHBOARD_REFRESH_POLICY_MS } from "@/lib/refresh-policy";
@@ -209,6 +210,35 @@ type EvidenceSeries = {
values: Array<number | null>;
};
type LegacyGaussianProbabilitySource = {
mu?: number | null;
engine?: string | null;
calibration_mode?: string | null;
distribution?: ProbabilityBucket[];
distribution_all?: ProbabilityBucket[];
};
type ProbabilityTemperatureBand = {
key: string;
value: number;
lower: number;
upper: number;
probability: number;
label: string;
opacity: number;
};
type ProbabilityMuLine = {
value: number;
label: string;
};
type ProbabilityOverlay = {
engine: string | null;
muLine: ProbabilityMuLine | null;
bands: ProbabilityTemperatureBand[];
};
type PeakGlowState = "none" | "watch" | "near_peak" | "breakout" | "cooling";
type PeakGlowMeta = {
@@ -674,6 +704,7 @@ type HourlyForecast = {
airportPrimary?: AirportCurrentConditions | null;
forecastDaily?: ForecastDay[];
multiModelDaily?: Record<string, DailyModelForecast>;
probabilities?: LegacyGaussianProbabilitySource | null;
settlementTodayObs?: ObsPoint[];
settlementStationLabel?: string | null;
metarTodayObs?: ObsPoint[];
@@ -697,6 +728,11 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca
airportPrimary: null,
forecastDaily: [],
multiModelDaily: {},
probabilities: {
engine: row.probability_engine || null,
distribution: row.distribution_preview || [],
distribution_all: row.distribution_full || row.distribution_preview || [],
},
settlementTodayObs: row.settlement_today_obs || row.metar_context?.settlement_today_obs || undefined,
metarTodayObs: row.metar_today_obs || row.metar_context?.today_obs || row.metar_recent_obs || row.metar_context?.recent_obs || undefined,
airportPrimaryTodayObs: undefined,
@@ -726,6 +762,7 @@ function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForec
airportPrimary: json.airport_primary || null,
forecastDaily: json.forecast?.daily || [],
multiModelDaily: json.multi_model_daily || {},
probabilities: json.probabilities || null,
settlementTodayObs: (json as any).timeseries?.settlement_today_obs || (json as any)?.settlement_today_obs || undefined,
settlementStationLabel: (json as any)?.settlement_station?.settlement_station_label || null,
metarTodayObs: (json as any).timeseries?.metar_today_obs || (json as any)?.metar_today_obs || undefined,
@@ -883,7 +920,8 @@ function mergePatchIntoHourly(
): HourlyForecast {
const changes = patch.changes || {};
const tempValue = validNumber(changes.temp);
const obsTime = typeof changes.obs_time === "string" ? changes.obs_time : null;
const observedAtUtc = typeof changes.observed_at_utc === "string" ? changes.observed_at_utc : null;
const obsTime = observedAtUtc || (typeof changes.obs_time === "string" ? changes.obs_time : null);
const source = typeof changes.source === "string" ? changes.source : "";
const explicitHourlyPatch = changes.hourly && typeof changes.hourly === "object"
? changes.hourly as Partial<NonNullable<HourlyForecast>>
@@ -899,6 +937,7 @@ function mergePatchIntoHourly(
temps: [],
forecastDaily: [],
multiModelDaily: {},
probabilities: null,
}),
...explicitHourlyPatch,
};
@@ -906,6 +945,9 @@ function mergePatchIntoHourly(
if (typeof (changes as any).local_date === "string") {
next.localDate = (changes as any).local_date;
}
if (typeof (changes as any).city_local_date === "string") {
next.localDate = (changes as any).city_local_date;
}
if (changes.amos && typeof changes.amos === "object") {
const oldAmos = prev?.amos || {};
@@ -976,7 +1018,7 @@ function mergePatchIntoHourly(
if (tempValue !== null) {
next.airportCurrent = {
...(next.airportCurrent || {}),
obs_time: next.airportCurrent?.obs_time ?? null,
obs_time: obsTime || next.airportCurrent?.obs_time || null,
temp: tempValue,
max_so_far: Math.max(
tempValue,
@@ -985,7 +1027,7 @@ function mergePatchIntoHourly(
};
next.airportPrimary = {
...(next.airportPrimary || {}),
obs_time: next.airportPrimary?.obs_time ?? null,
obs_time: obsTime || next.airportPrimary?.obs_time || null,
temp: tempValue,
max_so_far: Math.max(
tempValue,
@@ -1327,11 +1369,90 @@ function addHourlyTimesToTimeline(
});
}
function probabilityBucketValue(bucket: ProbabilityBucket) {
return validNumber(bucket.value ?? (bucket as any).temp ?? (bucket as any).temperature);
}
function probabilityBucketProbability(bucket: ProbabilityBucket) {
const raw = validNumber(bucket.probability ?? (bucket as any).model_probability);
if (raw === null) return null;
return raw > 1 ? raw / 100 : raw;
}
function probabilityBucketRange(bucket: ProbabilityBucket, value: number) {
const rawRange = String(bucket.range || bucket.bucket || "").trim();
const rangeMatch = rawRange.match(/(-?\d+(?:\.\d+)?)\s*~\s*(-?\d+(?:\.\d+)?)/);
if (rangeMatch) {
const lower = Number(rangeMatch[1]);
const upper = Number(rangeMatch[2]);
if (Number.isFinite(lower) && Number.isFinite(upper) && upper > lower) {
return { lower, upper };
}
}
return {
lower: Number((value - 0.5).toFixed(2)),
upper: Number((value + 0.5).toFixed(2)),
};
}
function buildLegacyGaussianProbabilityOverlay(
row: ScanOpportunityRow | null,
hourly: HourlyForecast,
): ProbabilityOverlay | null {
const source = hourly?.probabilities || null;
const rowBuckets = ((row as any)?.distribution_full || (row as any)?.distribution_preview || []) as ProbabilityBucket[];
const buckets = (
source?.distribution_all?.length
? source.distribution_all
: source?.distribution?.length
? source.distribution
: rowBuckets
) || [];
const engine = source?.engine || row?.probability_engine || (buckets.length ? "legacy" : null);
if (engine && String(engine).toLowerCase() !== "legacy") return null;
const tempSymbol = row?.temp_symbol || "°C";
const bands = buckets
.map((bucket, index) => {
const value = probabilityBucketValue(bucket);
const probability = probabilityBucketProbability(bucket);
if (value === null || probability === null || probability <= 0) return null;
const { lower, upper } = probabilityBucketRange(bucket, value);
return {
key: `legacy_probability_${value}_${index}`,
value,
lower,
upper,
probability,
label: `${value}${tempSymbol} ${Math.round(probability * 100)}%`,
opacity: Number(Math.min(0.16, Math.max(0.035, 0.04 + probability * 0.22)).toFixed(3)),
};
})
.filter((band): band is ProbabilityTemperatureBand => band !== null)
.sort((a, b) => a.value - b.value);
const mu = validNumber(source?.mu);
const muLine = mu === null
? null
: {
value: mu,
label: `Gaussian μ ${mu.toFixed(1)}${tempSymbol}`,
};
if (!bands.length && !muLine) return null;
return {
engine: engine || "legacy",
muLine,
bands,
};
}
function buildFullDayChartData(
row: ScanOpportunityRow | null,
hourly: HourlyForecast,
isEn: boolean,
): { data: Array<Record<string, any>>; series: EvidenceSeries[] } {
): { data: Array<Record<string, any>>; series: EvidenceSeries[]; probabilityOverlay: ProbabilityOverlay | null } {
const tzOffset = row?.tz_offset_seconds ?? 0;
const localDateStr = resolveChartLocalDate(row, hourly);
const localDayBounds = getLocalDayBounds(localDateStr);
@@ -1591,7 +1712,9 @@ function buildFullDayChartData(
return point;
});
return { data, series };
const probabilityOverlay = buildLegacyGaussianProbabilityOverlay(row, hourly);
return { data, series, probabilityOverlay };
}
// ── Model summary cards (daily high point predictions) ─────────────────
@@ -1613,13 +1736,27 @@ function buildModelSummaryCards(row: ScanOpportunityRow | null): EvidenceSeries[
// ── Integer-degree ticks for Y-axis ──────────────────────────────────
function buildIntDegreeTicks(series: EvidenceSeries[], data?: Array<Record<string, string | number | null>>): number[] | null {
function probabilityOverlayValues(probabilityOverlay?: ProbabilityOverlay | null) {
if (!probabilityOverlay) return [];
return [
...(probabilityOverlay.muLine ? [probabilityOverlay.muLine.value] : []),
...probabilityOverlay.bands.flatMap((band) => [band.lower, band.upper]),
];
}
function buildIntDegreeTicks(
series: EvidenceSeries[],
data?: Array<Record<string, string | number | null>>,
probabilityOverlay?: ProbabilityOverlay | null,
): number[] | null {
const vals = data?.length
? data.flatMap((point) => series.map((s) => point[s.key])).filter((v): v is number => validNumber(v) !== null)
: series.flatMap((s) => s.values).filter((v): v is number => validNumber(v) !== null);
if (!vals.length) return null;
const min = Math.floor(Math.min(...vals));
const max = Math.ceil(Math.max(...vals));
const overlayVals = probabilityOverlayValues(probabilityOverlay);
const allVals = [...vals, ...overlayVals];
if (!allVals.length) return null;
const min = Math.floor(Math.min(...allVals));
const max = Math.ceil(Math.max(...allVals));
const ticks: number[] = [];
for (let d = min; d <= max; d++) ticks.push(d);
return ticks.length > 0 ? ticks : null;
@@ -1628,13 +1765,16 @@ function buildIntDegreeTicks(series: EvidenceSeries[], data?: Array<Record<strin
function buildChartDomain(
series: EvidenceSeries[],
data?: Array<Record<string, string | number | null>>,
probabilityOverlay?: ProbabilityOverlay | null,
): [number, number] | ["auto", "auto"] {
const vals = data?.length
? data.flatMap((point) => series.map((s) => point[s.key])).filter((v): v is number => validNumber(v) !== null)
: series.flatMap((s) => s.values).filter((v): v is number => validNumber(v) !== null);
if (!vals.length) return ["auto", "auto"];
const min = Math.min(...vals);
const max = Math.max(...vals);
const overlayVals = probabilityOverlayValues(probabilityOverlay);
const allVals = [...vals, ...overlayVals];
if (!allVals.length) return ["auto", "auto"];
const min = Math.min(...allVals);
const max = Math.max(...allVals);
const span = Math.max(1, max - min);
const pad = Math.max(0.5, span * 0.08);
return [Number((min - pad).toFixed(1)), Number((max + pad).toFixed(1))];
@@ -1913,4 +2053,4 @@ export {
validNumber,
};
export type { EvidenceSeries, HourlyForecast, PeakGlowMeta, PeakGlowState };
export type { EvidenceSeries, HourlyForecast, PeakGlowMeta, PeakGlowState, ProbabilityOverlay };