Auth session 持久化 + 多分辨率跑道图表 + 拖拽缩放

- middleware: refreshMiddlewareSession 前置刷新 Supabase token
- backend-auth: getUser() 优先触发 refresh,再 getSession 拿新 token
- Dashboard: onAuthStateChange 实时更新 proAccess + 15min getSession 心跳
- detail proxy: 新增 city/[name]/detail 代理,透传 resolution 参数
- city_payloads: aggregate_runway_history + build_runway_band_history 跑道聚合
- chart: 10m→1m 自适应分辨率 + zoom 拖拽 + 跑道温区 Area + Runway Details 开关
This commit is contained in:
2569718930@qq.com
2026-05-26 20:37:00 +08:00
parent 88b80d66e8
commit d029d5c4e7
11 changed files with 604 additions and 91 deletions
@@ -18,6 +18,7 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "rea
import type { CityListItem, ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types";
import { getInitialLocaleFromNavigator } from "@/lib/i18n";
import { isBrowserLocalFullAccess } from "@/lib/local-dev-access";
import { getSupabaseBrowserClient, hasSupabasePublicEnv } from "@/lib/supabase/client";
import { sortRowsByUserTime } from "@/components/dashboard/scan-terminal/decision-utils";
import { ProductAccessRequired } from "@/components/dashboard/scan-terminal/ProductAccessRequired";
import {
@@ -915,6 +916,65 @@ function ScanTerminalScreen() {
const [proAccess, setProAccess] = useState<ProAccessState>(() =>
createEmptyAccess(true),
);
// Listen to Supabase auth events (e.g. token refreshed, signed out)
useEffect(() => {
if (!hasSupabasePublicEnv()) return;
try {
const supabase = getSupabaseBrowserClient();
const {
data: { subscription },
} = supabase.auth.onAuthStateChange(async (event, session) => {
if (event === "SIGNED_OUT") {
setProAccess(createEmptyAccess(false));
} else if (event === "TOKEN_REFRESHED" || event === "SIGNED_IN") {
try {
const response = await fetch("/api/auth/me", {
cache: "no-store",
headers: { Accept: "application/json" },
});
if (response.ok) {
const payload = await response.json();
setProAccess({
loading: false,
authenticated: Boolean(payload.authenticated),
userId: payload.user_id ?? null,
subscriptionActive: payload.subscription_active === true,
subscriptionPlanCode: payload.subscription_plan_code ?? null,
subscriptionExpiresAt: payload.subscription_expires_at ?? null,
subscriptionTotalExpiresAt:
payload.subscription_total_expires_at ??
payload.subscription_expires_at ??
null,
subscriptionQueuedDays: Math.max(
0,
Number(payload.subscription_queued_days ?? 0),
),
points: Number(payload.points ?? 0),
error: null,
});
}
} catch {}
}
});
return () => {
subscription.unsubscribe();
};
} catch {}
}, []);
// Periodically touch the session to trigger background refresh if near expiry
useEffect(() => {
if (!hasSupabasePublicEnv()) return;
const supabase = getSupabaseBrowserClient();
const interval = setInterval(async () => {
try {
await supabase.auth.getSession();
} catch {}
}, 15 * 60 * 1000); // every 15 minutes
return () => clearInterval(interval);
}, []);
const [locale, setLocale] = useState<"zh-CN" | "en-US">("zh-CN");
const isEn = locale === "en-US";
const toggleLocale = () =>
@@ -5,8 +5,10 @@ import { useEffect, useMemo, useRef, useState } from "react";
import {
CartesianGrid,
Line,
LineChart as ReLineChart,
Area,
ComposedChart as ReComposedChart,
ReferenceLine,
ReferenceArea,
ResponsiveContainer,
Tooltip,
XAxis,
@@ -454,6 +456,7 @@ type HourlyForecast = {
temps: Array<number | null>;
modelCurves?: Record<string, Array<number | null>>;
runwayPlateHistory?: Record<string, Array<Record<string, unknown>>>;
runwayBandHistory?: Array<{ time: string; high_temp: number; low_temp: number; avg_temp: number }>;
amos?: AmosData | null;
airportCurrent?: AirportCurrentConditions | null;
airportPrimary?: AirportCurrentConditions | null;
@@ -474,6 +477,7 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca
temps: [],
modelCurves: undefined,
runwayPlateHistory: (row as any)?.runway_plate_history || undefined,
runwayBandHistory: undefined,
amos: null,
airportCurrent: null,
airportPrimary: null,
@@ -487,6 +491,7 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca
type HourlyForecastFetchOptions = {
ignoreCache?: boolean;
resolution?: string;
};
function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForecast {
@@ -500,6 +505,7 @@ function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForec
temps: hourlySource.temps || [],
modelCurves: (json.models_hourly ?? (json as any)?.timeseries?.models_hourly)?.curves || undefined,
runwayPlateHistory: (json as any)?.runway_plate_history || (json.amos as any)?.runway_plate_history || undefined,
runwayBandHistory: (json as any)?.runway_band_history || undefined,
amos: json.amos || null,
airportCurrent: json.airport_current || null,
airportPrimary: json.airport_primary || null,
@@ -515,24 +521,27 @@ async function fetchHourlyForecastForCity(
city: string,
options: HourlyForecastFetchOptions = {},
): Promise<HourlyForecast> {
const resParam = options.resolution || "10m";
const cacheKey = `${city}:${resParam}`;
if (!options.ignoreCache) {
const cached = _hourlyCache.get(city);
const cached = _hourlyCache.get(cacheKey);
if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) {
return cached.data;
}
const sessionCached = readSessionCache(city);
const sessionCached = readSessionCache(cacheKey);
if (sessionCached) {
_hourlyCache.set(city, sessionCached);
_hourlyCache.set(cacheKey, sessionCached);
return sessionCached.data;
}
}
const requestKey = options.ignoreCache ? `${city}:live` : city;
const requestKey = options.ignoreCache ? `${city}:${resParam}:live` : `${city}:${resParam}`;
const pending = _hourlyRequestCache.get(requestKey);
if (pending) return pending;
const request = fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false`, {
const request = fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false&resolution=${resParam}`, {
headers: { Accept: "application/json" },
})
.then(async (res) => {
@@ -542,8 +551,8 @@ async function fetchHourlyForecastForCity(
.then((json) => {
const data = parseHourlyForecastFromCityDetail(json);
if (!data) return null;
_hourlyCache.set(city, { ts: Date.now(), data });
writeSessionCache(city, data);
_hourlyCache.set(cacheKey, { ts: Date.now(), data });
writeSessionCache(cacheKey, data);
return data;
})
.finally(() => {
@@ -816,7 +825,8 @@ function format3DayTimestamp(ts: number): string {
function build3DayChartData(
row: ScanOpportunityRow | null,
hourly: HourlyForecast,
): { data: Array<Record<string, string | number | null>>; series: EvidenceSeries[] } {
isEn: boolean,
): { data: Array<Record<string, any>>; series: EvidenceSeries[] } {
const tzOffset = row?.tz_offset_seconds ?? 0;
const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10);
@@ -827,6 +837,52 @@ function build3DayChartData(
const series: EvidenceSeries[] = [];
const na = (): Array<number | null> => new Array(n).fill(null);
// ── Runway history series ──
const runwayHistorySeries = buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr);
runwayHistorySeries.forEach((rhs) => {
const binned = binObservationsToSlots(slots, rhs.points);
if (!binned.some((v) => v !== null)) return;
series.push({
key: rhs.key,
label: rhs.label,
source: "",
color: rhs.color,
featured: rhs.isSettlement,
dashed: !rhs.isSettlement,
values: binned,
});
});
// ── Runway band & max series ──
const normBandObs = (hourly?.runwayBandHistory || []).map((pt) => {
try {
const ts = getCityLocalUtcTimestamp(pt.time, tzOffset, localDateStr);
if (ts === null) return null;
return {
ts,
high: pt.high_temp,
low: pt.low_temp,
avg: pt.avg_temp
};
} catch {
return null;
}
}).filter((v): v is NonNullable<typeof v> => v !== null);
const bandVals = binBandObservationsToSlots(slots, normBandObs);
const maxVals = bandVals.map((val) => val ? val[1] : null);
if (maxVals.some((v) => v !== null)) {
series.push({
key: "runway_max",
label: isEn ? "Runway Max" : "跑道最高温",
source: "Runway Max",
color: "#009688",
featured: true,
values: maxVals,
});
}
// DEB forecast curve (from hourly.times & hourly.temps)
if (hourly?.times?.length && hourly?.temps?.length) {
const debVals = na();
@@ -901,9 +957,10 @@ function build3DayChartData(
// Build data rows
const data = slots.map((ts, i) => {
const point: Record<string, string | number | null> = {
const point: Record<string, any> = {
label: format3DayTimestamp(ts),
ts,
runway_band: bandVals[i] ?? null,
};
series.forEach((s) => {
point[s.key] = s.values[i] ?? null;
@@ -1004,10 +1061,27 @@ function buildDailyChartData(
return { data, series: activeSeries };
}
function binBandObservationsToSlots(
slots: number[],
obs: Array<{ ts: number; high: number; low: number; avg: number }>,
): Array<[number, number] | null> {
const result: Array<[number, number] | null> = new Array(slots.length).fill(null);
for (const point of obs) {
for (let i = slots.length - 1; i >= 0; i--) {
if (point.ts >= slots[i]) {
result[i] = [point.low, point.high];
break;
}
}
}
return result;
}
function buildFullDayChartData(
row: ScanOpportunityRow | null,
hourly: HourlyForecast,
): { data: Array<Record<string, string | number | null>>; series: EvidenceSeries[] } {
isEn: boolean,
): { data: Array<Record<string, any>>; series: EvidenceSeries[] } {
const tzOffset = row?.tz_offset_seconds ?? 0;
const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10);
@@ -1052,6 +1126,36 @@ function buildFullDayChartData(
});
});
// ── Runway band & max series ──
const normBandObs = (hourly?.runwayBandHistory || []).map((pt) => {
try {
const ts = getCityLocalUtcTimestamp(pt.time, tzOffset, localDateStr);
if (ts === null) return null;
return {
ts,
high: pt.high_temp,
low: pt.low_temp,
avg: pt.avg_temp
};
} catch {
return null;
}
}).filter((v): v is NonNullable<typeof v> => v !== null);
const bandVals = binBandObservationsToSlots(slots, normBandObs);
const maxVals = bandVals.map((val) => val ? val[1] : null);
if (maxVals.some((v) => v !== null)) {
series.push({
key: "runway_max",
label: isEn ? "Runway Max" : "跑道最高温",
source: "Runway Max",
color: "#009688",
featured: true,
values: maxVals,
});
}
// ── Settlement observations ──
// Determine if this is an HKO-sourced city to force the label
const isHKOCity = settlementCityKey === 'hongkong' || settlementCityKey === 'laufaushan'
@@ -1180,9 +1284,10 @@ function buildFullDayChartData(
// ── Build data rows ──
const data = slots.map((ts, i) => {
const point: Record<string, string | number | null> = {
const point: Record<string, any> = {
label: formatTimestamp(ts),
ts,
runway_band: bandVals[i] ?? null,
};
series.forEach((s) => { point[s.key] = s.values[i] ?? null; });
return point;
@@ -1304,6 +1409,19 @@ export function LiveTemperatureThresholdChart({
const lastPatchAtRef = useRef<number>(Date.now());
const lastAppliedPatchRevisionRef = useRef<number>(0);
const [showRunwayDetails, setShowRunwayDetails] = useState<boolean>(false);
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");
useEffect(() => {
setUserToggledKeys({});
setZoomRange(null);
lastPatchAtRef.current = Date.now();
lastAppliedPatchRevisionRef.current = 0;
}, [city, timeframe]);
useEffect(() => {
setUserToggledKeys({});
lastPatchAtRef.current = Date.now();
@@ -1313,14 +1431,15 @@ export function LiveTemperatureThresholdChart({
useEffect(() => {
if (!city) return;
const cacheKey = `${city}:${targetResolution}`;
// Check in-memory cache first
let cached = _hourlyCache.get(city);
let cached = _hourlyCache.get(cacheKey);
if (!cached) {
// Fallback to session cache
const sessionEntry = readSessionCache(city);
const sessionEntry = readSessionCache(cacheKey);
if (sessionEntry) {
cached = sessionEntry;
_hourlyCache.set(city, sessionEntry);
_hourlyCache.set(cacheKey, sessionEntry);
}
}
@@ -1336,7 +1455,7 @@ export function LiveTemperatureThresholdChart({
const delay = isActive ? 0 : (slotIndex ? 300 + slotIndex * 250 : 350);
const timer = setTimeout(() => {
fetchHourlyForecastForCity(city)
fetchHourlyForecastForCity(city, { resolution: targetResolution })
.then((data) => {
if (cancelled || !data) return;
setHourly(data);
@@ -1348,7 +1467,7 @@ export function LiveTemperatureThresholdChart({
cancelled = true;
clearTimeout(timer);
};
}, [city, row, isActive, slotIndex]);
}, [city, row, isActive, slotIndex, targetResolution]);
useEffect(() => {
if (!latestPatch || latestPatch.revision <= lastAppliedPatchRevisionRef.current) return;
@@ -1400,10 +1519,37 @@ export function LiveTemperatureThresholdChart({
const { data, series } = useMemo(() => {
if (timeframe === "3D") {
return build3DayChartData(row, hourly);
return build3DayChartData(row, hourly, isEn);
}
return buildFullDayChartData(row, hourly);
}, [row, hourly, timeframe]);
return buildFullDayChartData(row, hourly, isEn);
}, [row, hourly, timeframe, isEn]);
const zoomedData = useMemo(() => {
if (!zoomRange || data.length === 0) return data;
const [start, end] = zoomRange;
return data.slice(start, end + 1);
}, [data, zoomRange]);
useEffect(() => {
if (timeframe === "3D") {
setTargetResolution("30m");
} else if (zoomRange && data.length > 0) {
const zoomedData = data.slice(zoomRange[0], zoomRange[1] + 1);
if (zoomedData.length > 0) {
const startTs = zoomedData[0].ts;
const endTs = zoomedData[zoomedData.length - 1].ts;
if (endTs - startTs <= 2 * 60 * 60 * 1000) {
setTargetResolution("1m");
} else {
setTargetResolution("10m");
}
} else {
setTargetResolution("10m");
}
} else {
setTargetResolution("10m");
}
}, [timeframe, zoomRange, data]);
const tzOffset = row?.tz_offset_seconds ?? 0;
const settlementObs = useMemo(() => {
@@ -1433,8 +1579,16 @@ export function LiveTemperatureThresholdChart({
};
const activeSeries = useMemo(() => {
return getVisibleTemperatureSeries(city, chartSeries, userToggledKeys);
}, [chartSeries, userToggledKeys, city]);
const rawVisible = getVisibleTemperatureSeries(city, chartSeries, userToggledKeys);
return rawVisible.filter((s) => {
const isIndividualRunway = s.key.startsWith("runway_") && s.key !== "runway_max";
if (showRunwayDetails) {
return s.key !== "runway_max";
} else {
return !isIndividualRunway;
}
});
}, [chartSeries, userToggledKeys, city, showRunwayDetails]);
const normalizedKey = normalizeCityKey(row?.city);
const runwaySensorCities = new Set([
@@ -1540,10 +1694,10 @@ export function LiveTemperatureThresholdChart({
return list.sort((a, b) => a.threshold - b.threshold);
}, [row, allRows]);
const intDegreeTicks = useMemo(() => buildIntDegreeTicks(activeSeries, data), [activeSeries, data]);
const intDegreeTicks = useMemo(() => buildIntDegreeTicks(activeSeries, zoomedData), [activeSeries, zoomedData]);
const chartDomain = useMemo(
() => buildChartDomain(activeSeries, data),
[activeSeries, data],
() => buildChartDomain(activeSeries, zoomedData),
[activeSeries, zoomedData],
);
const subtitle = row
@@ -1580,6 +1734,15 @@ export function LiveTemperatureThresholdChart({
const timeframeActions = (
<div className="flex items-center gap-1.5">
{zoomRange && (
<button
type="button"
onClick={() => setZoomRange(null)}
className="px-2 py-0.5 text-[9px] font-bold rounded bg-slate-100 hover:bg-slate-200 text-slate-700 border border-slate-300 shadow-sm transition-all cursor-pointer"
>
{isEn ? "Reset Zoom" : "重置缩放"}
</button>
)}
<div className="flex items-center gap-1 rounded bg-[#eef2f6] p-0.5 border border-slate-200">
{(["1D", "3D"] as const).map((tf) => (
<button
@@ -1587,7 +1750,7 @@ export function LiveTemperatureThresholdChart({
type="button"
onClick={() => setTimeframe(tf)}
className={clsx(
"px-2 py-0.5 text-[9px] font-bold rounded transition-all",
"px-2 py-0.5 text-[9px] font-bold rounded transition-all cursor-pointer",
timeframe === tf
? "bg-white text-blue-600 shadow-sm border border-slate-200/50"
: "text-slate-500 hover:text-slate-800"
@@ -1635,7 +1798,45 @@ export function LiveTemperatureThresholdChart({
</div>
)}
</div>
);
); const handleMouseDown = (e: any) => {
if (compact || !e) return;
if (typeof e.activeTooltipIndex === "number") {
setRefAreaLeft(e.activeTooltipIndex);
setRefAreaRight(e.activeTooltipIndex);
}
};
const handleMouseMove = (e: any) => {
if (compact || !e || refAreaLeft === null) return;
if (typeof e.activeTooltipIndex === "number") {
setRefAreaRight(e.activeTooltipIndex);
}
};
const handleMouseUp = () => {
if (refAreaLeft === null || refAreaRight === null) {
setRefAreaLeft(null);
setRefAreaRight(null);
return;
}
let leftIdx = refAreaLeft;
let rightIdx = refAreaRight;
if (leftIdx > rightIdx) {
[leftIdx, rightIdx] = [rightIdx, leftIdx];
}
if (rightIdx - leftIdx >= 1) {
const originalStartIndex = zoomRange ? zoomRange[0] : 0;
const newStart = originalStartIndex + leftIdx;
const newEnd = originalStartIndex + rightIdx;
setZoomRange([newStart, newEnd]);
}
setRefAreaLeft(null);
setRefAreaRight(null);
};
return (
<Panel title={panelTitle} actions={timeframeActions}>
@@ -1852,36 +2053,66 @@ export function LiveTemperatureThresholdChart({
{/* Chart */}
<div className="relative min-h-0 flex-1 p-2">
{/* Interactive legend */}
<div className="flex flex-wrap gap-x-4 gap-y-1 px-3 py-1.5 text-[11px] border-b border-[#e2e8f0] bg-white">
{chartSeries.length > 1 && chartSeries.map((s) => (
<button
key={s.key}
type="button"
onClick={() => {
setUserToggledKeys((prev) => ({
...prev,
[s.key]: !isSeriesVisible(s.key),
}));
}}
className={clsx(
"inline-flex items-center gap-1.5 font-mono cursor-pointer transition-opacity hover:opacity-80",
!isSeriesVisible(s.key) && "opacity-40 line-through"
)}
>
<span className="h-2 w-2 rounded-full shrink-0" style={{ backgroundColor: s.color }} />
<span className="text-slate-700 font-bold">{s.label}</span>
</button>
))}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 px-3 py-1.5 text-[11px] border-b border-[#e2e8f0] bg-white">
{chartSeries.length > 1 &&
chartSeries
.filter((s) => {
const isIndividualRunway = s.key.startsWith("runway_") && s.key !== "runway_max";
if (showRunwayDetails) {
return s.key !== "runway_max";
} else {
return !isIndividualRunway;
}
})
.map((s) => (
<button
key={s.key}
type="button"
onClick={() => {
setUserToggledKeys((prev) => ({
...prev,
[s.key]: !isSeriesVisible(s.key),
}));
}}
className={clsx(
"inline-flex items-center gap-1.5 font-mono cursor-pointer transition-opacity hover:opacity-80",
!isSeriesVisible(s.key) && "opacity-40 line-through"
)}
>
<span className="h-2 w-2 rounded-full shrink-0" style={{ backgroundColor: s.color }} />
<span className="text-slate-700 font-bold">{s.label}</span>
</button>
))}
{/* "Show Runway Details" toggle switch */}
{hasRunwayData && (
<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"
checked={showRunwayDetails}
onChange={(e) => setShowRunwayDetails(e.target.checked)}
className="rounded border-slate-300 text-teal-600 focus:ring-teal-500 h-3.5 w-3.5 cursor-pointer"
/>
<span>{isEn ? "Show Runway Details" : "显示跑道明细"}</span>
</label>
)}
</div>
<ResponsiveContainer width="100%" height="100%">
<ReLineChart data={data} margin={{ top: 16, right: compact ? 20 : 44, left: 4, bottom: 8 }}>
<ReComposedChart
data={zoomedData}
margin={{ top: 16, right: compact ? 20 : 44, left: 4, bottom: 8 }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onDoubleClick={() => setZoomRange(null)}
>
<CartesianGrid stroke="#dbe6ef" strokeDasharray="2 2" />
<XAxis
dataKey="label"
tick={{ fontSize: 9, fill: "#64748b" }}
tickLine={false}
axisLine={{ stroke: "#cbd5e1" }}
interval={Math.max(1, Math.floor(data.length / (compact ? 6 : 10)))}
interval={Math.max(1, Math.floor(zoomedData.length / (compact ? 6 : 10)))}
/>
<YAxis
orientation="right"
@@ -1921,8 +2152,38 @@ export function LiveTemperatureThresholdChart({
fontSize: 11,
boxShadow: "0 8px 24px rgba(15,23,42,.12)",
}}
formatter={(value: unknown) => `${Number(value).toFixed(2)}°`}
formatter={(value: unknown) => {
if (Array.isArray(value)) {
const [low, high] = value;
if (typeof low === "number" && typeof high === "number") {
return `${low.toFixed(1)}° - ${high.toFixed(1)}°`;
}
}
const num = Number(value);
return Number.isFinite(num) ? `${num.toFixed(2)}°` : String(value);
}}
/>
{/* Runway Temperature Band (low-high range area) */}
{hasRunwayData && (
<Area
dataKey="runway_band"
name={isEn ? "Runway Range" : "跑道区间"}
stroke="none"
fill="#009688"
fillOpacity={showRunwayDetails ? 0.08 : 0.18}
connectNulls={true}
isAnimationActive={false}
/>
)}
{refAreaLeft !== null && refAreaRight !== null && zoomedData[refAreaLeft] && zoomedData[refAreaRight] && (
<ReferenceArea
x1={zoomedData[refAreaLeft].label}
x2={zoomedData[refAreaRight].label}
strokeOpacity={0.3}
fill="#3b82f6"
fillOpacity={0.15}
/>
)}
{activeSeries.map((item) => (
<Line
key={item.key}
@@ -1938,7 +2199,7 @@ export function LiveTemperatureThresholdChart({
isAnimationActive={false}
/>
))}
</ReLineChart>
</ReComposedChart>
</ResponsiveContainer>
</div>
</div>
@@ -1950,8 +2211,9 @@ export function __buildTemperatureChartDataForTest(
row: ScanOpportunityRow | null,
hourly: HourlyForecast,
timeframe: "1D" | "3D" = "1D",
isEn = false,
) {
return timeframe === "3D" ? build3DayChartData(row, hourly) : buildFullDayChartData(row, hourly);
return timeframe === "3D" ? build3DayChartData(row, hourly, isEn) : buildFullDayChartData(row, hourly, isEn);
}
export const __isTemperatureSeriesVisibleByDefaultForTest = isTemperatureSeriesVisibleByDefault;
@@ -310,4 +310,35 @@ export function runTests() {
Math.min(...shanghaiDebValues) > 20,
"DEB curve should not be pulled into an impossible negative range by stale row deb_prediction=0",
);
// ── Runway range band and runway_max test ──
const shanghaiWithBand = __buildTemperatureChartDataForTest(
{
city: "shanghai",
local_date: "2026-05-26",
local_time: "14:00",
tz_offset_seconds: 8 * 60 * 60,
} as any,
{
localTime: "14:00",
times: ["00:00", "12:00", "18:00"],
temps: [24.2, 31.5, 26.5],
runwayBandHistory: [
{ time: "2026-05-26T00:00:00+08:00", high_temp: 26.0, low_temp: 24.0, avg_temp: 25.0 },
{ time: "2026-05-26T12:00:00+08:00", high_temp: 32.0, low_temp: 29.0, avg_temp: 30.5 },
]
} as any,
"1D",
);
const runwayMaxSeries = seriesByKey(shanghaiWithBand.series, "runway_max") as any;
assert(runwayMaxSeries, "runway_max series should be present when runwayBandHistory is provided");
assert(runwayMaxSeries.color === "#009688", "runway_max series should use the primary teal color");
assert(runwayMaxSeries.featured === true, "runway_max series should be featured");
// Verify that runway_band exists on some data rows
const bandPoints = shanghaiWithBand.data.filter((d) => d.runway_band !== null);
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");
}