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:
@@ -48,6 +48,7 @@ export async function GET(
|
||||
const depth = req.nextUrl.searchParams.get("depth");
|
||||
const marketSlug = req.nextUrl.searchParams.get("market_slug");
|
||||
const targetDate = req.nextUrl.searchParams.get("target_date");
|
||||
const resolution = req.nextUrl.searchParams.get("resolution");
|
||||
const searchParams = new URLSearchParams({
|
||||
force_refresh: forceRefresh,
|
||||
});
|
||||
@@ -60,6 +61,9 @@ export async function GET(
|
||||
if (targetDate) {
|
||||
searchParams.set("target_date", targetDate);
|
||||
}
|
||||
if (resolution) {
|
||||
searchParams.set("resolution", resolution);
|
||||
}
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`;
|
||||
|
||||
try {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+31
@@ -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");
|
||||
}
|
||||
|
||||
@@ -41,19 +41,14 @@ export async function buildBackendRequestHeaders(
|
||||
const incomingAuth = extractBearerToken(request.headers.get("authorization"));
|
||||
const includeSupabaseIdentity = options?.includeSupabaseIdentity !== false;
|
||||
if (hasSupabaseServerEnv() && includeSupabaseIdentity) {
|
||||
const supabase = createSupabaseRouteClient(request, new NextResponse(null, { status: 200 }));
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
let user = session?.user ?? null;
|
||||
if (session) {
|
||||
const {
|
||||
data: { user: validated },
|
||||
} = await supabase.auth.getUser();
|
||||
user = validated ?? session.user;
|
||||
}
|
||||
|
||||
const passthroughResponse = new NextResponse(null, { status: 200 });
|
||||
const supabase = createSupabaseRouteClient(request, passthroughResponse);
|
||||
|
||||
// Always call getUser() to ensure token is validated and refreshed if expired
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
const forwardedUserId = String(user?.id || "").trim();
|
||||
const forwardedEmail = String(user?.email || "").trim();
|
||||
if (forwardedUserId) {
|
||||
@@ -67,6 +62,11 @@ export async function buildBackendRequestHeaders(
|
||||
headers.set("Authorization", `Bearer ${incomingAuth}`);
|
||||
return { headers, response: passthroughResponse, authUserId: forwardedUserId || null, authEmail: forwardedEmail || null };
|
||||
}
|
||||
|
||||
// Call getSession() to get the updated access token (after getUser() has refreshed it if needed)
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
const accessToken = session?.access_token || "";
|
||||
if (accessToken) {
|
||||
// Fallback to cookie-backed session when request does not carry bearer.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createServerClient, type CookieOptions } from "@supabase/ssr";
|
||||
import type { NextRequest, NextResponse } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
type CookieAdapter = {
|
||||
getAll: () => { name: string; value: string }[];
|
||||
@@ -68,3 +68,46 @@ export function createSupabaseRouteClient(
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshMiddlewareSession(request: NextRequest) {
|
||||
let response = NextResponse.next({
|
||||
request: {
|
||||
headers: request.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasSupabaseServerEnv()) {
|
||||
return { response, user: null };
|
||||
}
|
||||
|
||||
const supabase = createSupabaseServerClient({
|
||||
getAll() {
|
||||
return request.cookies.getAll().map((item) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
}));
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
for (const cookie of cookiesToSet) {
|
||||
request.cookies.set(cookie.name, cookie.value);
|
||||
}
|
||||
response = NextResponse.next({
|
||||
request: {
|
||||
headers: request.headers,
|
||||
},
|
||||
});
|
||||
for (const cookie of cookiesToSet) {
|
||||
response.cookies.set(cookie.name, cookie.value, cookie.options);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
return { response, user };
|
||||
} catch {
|
||||
return { response, user: null };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-24
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
createSupabaseMiddlewareClient,
|
||||
hasSupabaseServerEnv,
|
||||
refreshMiddlewareSession,
|
||||
} from "@/lib/supabase/server";
|
||||
import { isLocalFullAccessHost } from "@/lib/local-dev-access";
|
||||
|
||||
@@ -69,13 +69,7 @@ async function handleTerminalGate(request: NextRequest): Promise<NextResponse> {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const response = NextResponse.next({
|
||||
request: { headers: request.headers },
|
||||
});
|
||||
const supabase = createSupabaseMiddlewareClient(request, response);
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
const { response, user } = await refreshMiddlewareSession(request);
|
||||
|
||||
if (user) {
|
||||
// Authenticated — pass through. Terminal client handles subscription gate.
|
||||
@@ -96,15 +90,7 @@ async function handleSupabaseAuthGate(request: NextRequest) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const response = NextResponse.next({
|
||||
request: {
|
||||
headers: request.headers,
|
||||
},
|
||||
});
|
||||
const supabase = createSupabaseMiddlewareClient(request, response);
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
const { response, user } = await refreshMiddlewareSession(request);
|
||||
|
||||
if (user) {
|
||||
return response;
|
||||
@@ -134,13 +120,7 @@ async function handleSupabaseOptionalSession(request: NextRequest) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const response = NextResponse.next({
|
||||
request: {
|
||||
headers: request.headers,
|
||||
},
|
||||
});
|
||||
const supabase = createSupabaseMiddlewareClient(request, response);
|
||||
await supabase.auth.getUser();
|
||||
const { response } = await refreshMiddlewareSession(request);
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
@@ -2079,11 +2079,13 @@ def _build_city_detail_payload(
|
||||
data: Dict[str, Any],
|
||||
market_slug: Optional[str] = None,
|
||||
target_date: Optional[str] = None,
|
||||
resolution: Optional[str] = "10m",
|
||||
) -> Dict[str, Any]:
|
||||
return _city_payload_detail(
|
||||
data,
|
||||
market_slug=market_slug,
|
||||
target_date=target_date,
|
||||
resolution=resolution,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@ async def city_detail_aggregate(
|
||||
force_refresh: bool = False,
|
||||
market_slug: Optional[str] = None,
|
||||
target_date: Optional[str] = None,
|
||||
resolution: Optional[str] = "10m",
|
||||
):
|
||||
return await get_city_detail_aggregate_payload(
|
||||
request,
|
||||
@@ -148,6 +149,7 @@ async def city_detail_aggregate(
|
||||
force_refresh=force_refresh,
|
||||
market_slug=market_slug,
|
||||
target_date=target_date,
|
||||
resolution=resolution,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -142,6 +142,7 @@ async def get_city_detail_aggregate_payload(
|
||||
force_refresh: bool = False,
|
||||
market_slug: Optional[str] = None,
|
||||
target_date: Optional[str] = None,
|
||||
resolution: Optional[str] = "10m",
|
||||
) -> Dict[str, Any]:
|
||||
legacy_routes._assert_entitlement(request)
|
||||
city = legacy_routes._normalize_city_or_404(name)
|
||||
@@ -162,6 +163,7 @@ async def get_city_detail_aggregate_payload(
|
||||
data,
|
||||
market_slug,
|
||||
target_date,
|
||||
resolution,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, Optional, List
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
|
||||
from web.core import _is_excluded_model_name
|
||||
|
||||
@@ -34,10 +36,134 @@ def build_city_summary_payload(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _parse_time_val(val: str) -> Optional[datetime]:
|
||||
if not val:
|
||||
return None
|
||||
try:
|
||||
val = str(val).strip().replace("Z", "+00:00")
|
||||
if "T" in val:
|
||||
return datetime.fromisoformat(val)
|
||||
else:
|
||||
return datetime.fromisoformat(val)
|
||||
except Exception:
|
||||
try:
|
||||
val_clean = re.sub(r'\.\d+', '', val)
|
||||
return datetime.strptime(val_clean, "%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def aggregate_runway_history(raw_history: Dict[str, List[Dict[str, Any]]], resolution: str) -> Dict[str, List[Dict[str, Any]]]:
|
||||
if not raw_history:
|
||||
return {}
|
||||
if not resolution or resolution == "1m":
|
||||
return raw_history
|
||||
|
||||
try:
|
||||
if resolution.endswith("m"):
|
||||
minutes = int(resolution[:-1])
|
||||
elif resolution.endswith("h"):
|
||||
minutes = int(resolution[:-1]) * 60
|
||||
else:
|
||||
minutes = 10
|
||||
except Exception:
|
||||
minutes = 10
|
||||
|
||||
seconds = minutes * 60
|
||||
aggregated = {}
|
||||
for rwy, points in raw_history.items():
|
||||
if not points:
|
||||
continue
|
||||
|
||||
buckets = {}
|
||||
for pt in points:
|
||||
t_str = pt.get("time") or pt.get("timestamp")
|
||||
temp = pt.get("temp") or pt.get("temp_c") or pt.get("value")
|
||||
if temp is None or not isinstance(t_str, str):
|
||||
continue
|
||||
dt = _parse_time_val(t_str)
|
||||
if not dt:
|
||||
continue
|
||||
|
||||
ts = int(dt.timestamp())
|
||||
bucket_ts = (ts // seconds) * seconds
|
||||
|
||||
if bucket_ts not in buckets:
|
||||
buckets[bucket_ts] = []
|
||||
buckets[bucket_ts].append(temp)
|
||||
|
||||
bucket_points = []
|
||||
for bucket_ts in sorted(buckets.keys()):
|
||||
temps = buckets[bucket_ts]
|
||||
close_temp = temps[-1]
|
||||
bucket_dt = datetime.fromtimestamp(bucket_ts, tz=timezone.utc)
|
||||
bucket_points.append({
|
||||
"time": bucket_dt.isoformat(),
|
||||
"temp": round(close_temp, 1)
|
||||
})
|
||||
aggregated[rwy] = bucket_points
|
||||
|
||||
return aggregated
|
||||
|
||||
|
||||
def build_runway_band_history(raw_history: Dict[str, List[Dict[str, Any]]], resolution: str) -> List[Dict[str, Any]]:
|
||||
if not raw_history:
|
||||
return []
|
||||
|
||||
try:
|
||||
if resolution.endswith("m"):
|
||||
minutes = int(resolution[:-1])
|
||||
elif resolution.endswith("h"):
|
||||
minutes = int(resolution[:-1]) * 60
|
||||
else:
|
||||
minutes = 10
|
||||
except Exception:
|
||||
minutes = 10
|
||||
|
||||
seconds = minutes * 60
|
||||
buckets = {}
|
||||
for rwy, points in raw_history.items():
|
||||
for pt in points:
|
||||
t_str = pt.get("time") or pt.get("timestamp")
|
||||
temp = pt.get("temp") or pt.get("temp_c") or pt.get("value")
|
||||
if temp is None or not isinstance(t_str, str):
|
||||
continue
|
||||
dt = _parse_time_val(t_str)
|
||||
if not dt:
|
||||
continue
|
||||
|
||||
ts = int(dt.timestamp())
|
||||
bucket_ts = (ts // seconds) * seconds
|
||||
|
||||
if bucket_ts not in buckets:
|
||||
buckets[bucket_ts] = []
|
||||
buckets[bucket_ts].append(temp)
|
||||
|
||||
band_history = []
|
||||
for bucket_ts in sorted(buckets.keys()):
|
||||
temps = buckets[bucket_ts]
|
||||
if not temps:
|
||||
continue
|
||||
high_temp = max(temps)
|
||||
low_temp = min(temps)
|
||||
avg_temp = sum(temps) / len(temps)
|
||||
|
||||
bucket_dt = datetime.fromtimestamp(bucket_ts, tz=timezone.utc)
|
||||
band_history.append({
|
||||
"time": bucket_dt.isoformat(),
|
||||
"high_temp": round(high_temp, 1),
|
||||
"low_temp": round(low_temp, 1),
|
||||
"avg_temp": round(avg_temp, 1),
|
||||
})
|
||||
|
||||
return band_history
|
||||
|
||||
|
||||
def build_city_detail_payload(
|
||||
data: Dict[str, Any],
|
||||
market_slug: Optional[str] = None,
|
||||
target_date: Optional[str] = None,
|
||||
resolution: Optional[str] = "10m",
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"city": data.get("name"),
|
||||
@@ -125,7 +251,8 @@ def build_city_detail_payload(
|
||||
or _build_intraday_meteorology(data),
|
||||
"vertical_profile_signal": data.get("vertical_profile_signal") or {},
|
||||
"taf": data.get("taf") or {},
|
||||
"runway_plate_history": data.get("runway_plate_history") or {},
|
||||
"runway_plate_history": aggregate_runway_history(data.get("runway_plate_history") or {}, resolution),
|
||||
"runway_band_history": build_runway_band_history(data.get("runway_plate_history") or {}, resolution),
|
||||
|
||||
"risk": data.get("risk"),
|
||||
"settlement_station": data.get("settlement_station") or {},
|
||||
|
||||
Reference in New Issue
Block a user