feat: implement interactive temperature chart dashboard with runway trend visualization and multi-source data collection support
This commit is contained in:
@@ -390,6 +390,7 @@ export function LiveTemperatureThresholdChart({
|
||||
|
||||
const cityThresholds = useMemo(() => {
|
||||
if (!row || !allRows || !allRows.length) return [];
|
||||
const tempSymbol = row.temp_symbol || "°C";
|
||||
const cityKey = String(row.city || "").toLowerCase().trim();
|
||||
const sameCityRows = allRows.filter(
|
||||
(r) => String(r.city || "").toLowerCase().trim() === cityKey
|
||||
@@ -409,7 +410,7 @@ export function LiveTemperatureThresholdChart({
|
||||
|
||||
list.push({
|
||||
threshold: t,
|
||||
label: r.target_label || `${t}°C`,
|
||||
label: r.target_label || `${t}${tempSymbol}`,
|
||||
isBreached,
|
||||
kind,
|
||||
});
|
||||
@@ -564,6 +565,7 @@ export function LiveTemperatureThresholdChart({
|
||||
isEn={isEn}
|
||||
compact={compact}
|
||||
timeframe={timeframe}
|
||||
tempSymbol={row?.temp_symbol || "°C"}
|
||||
runwayHeaderLabel={runwayHeaderLabel}
|
||||
metarHeaderLabel={metarHeaderLabel}
|
||||
runwayHighLabel={runwayHighLabel}
|
||||
@@ -583,11 +585,11 @@ export function LiveTemperatureThresholdChart({
|
||||
/>
|
||||
|
||||
{timeframe === "1D" && !compact && (
|
||||
<TemperatureRunwayDetails isEn={isEn} plates={runwayPlates} />
|
||||
<TemperatureRunwayDetails isEn={isEn} plates={runwayPlates} tempSymbol={row?.temp_symbol || "°C"} />
|
||||
)}
|
||||
|
||||
{timeframe === "1D" && !compact && (
|
||||
<ModelCurvesSummary isEn={isEn} activeSeries={activeSeries} />
|
||||
<ModelCurvesSummary isEn={isEn} activeSeries={activeSeries} tempSymbol={row?.temp_symbol || "°C"} />
|
||||
)}
|
||||
|
||||
<TemperatureChartCanvas
|
||||
|
||||
@@ -6,9 +6,11 @@ import { temp } from "@/components/dashboard/scan-terminal/utils";
|
||||
export function ModelCurvesSummary({
|
||||
isEn,
|
||||
activeSeries,
|
||||
tempSymbol,
|
||||
}: {
|
||||
isEn: boolean;
|
||||
activeSeries: EvidenceSeries[];
|
||||
tempSymbol: string;
|
||||
}) {
|
||||
const modelSeries = activeSeries.filter((s) => s.key.startsWith("model_curve_"));
|
||||
if (!modelSeries.length) return null;
|
||||
@@ -25,7 +27,7 @@ export function ModelCurvesSummary({
|
||||
<span key={s.key} className="inline-flex items-center gap-1.5 font-mono">
|
||||
<span className="h-2 w-2 rounded-full shrink-0" style={{ backgroundColor: s.color }} />
|
||||
<span className="text-slate-700 font-bold">{s.label}</span>
|
||||
<span className="text-slate-500">{temp(stats.latest)}</span>
|
||||
<span className="text-slate-500">{temp(stats.latest, tempSymbol)}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -73,6 +73,7 @@ export function TemperatureChartCanvas({
|
||||
}) {
|
||||
const chartHostRef = useRef<HTMLDivElement | null>(null);
|
||||
const [chartSize, setChartSize] = useState({ width: 0, height: 0 });
|
||||
const tempSymbol = row?.temp_symbol || "°C";
|
||||
|
||||
useEffect(() => {
|
||||
const host = chartHostRef.current;
|
||||
@@ -177,7 +178,7 @@ export function TemperatureChartCanvas({
|
||||
<YAxis
|
||||
orientation="right"
|
||||
tick={{ fontSize: 9, fill: "#64748b" }}
|
||||
tickFormatter={(v) => `${Number(v).toFixed(0)}°`}
|
||||
tickFormatter={(v) => `${Number(v).toFixed(0)}${tempSymbol}`}
|
||||
axisLine={{ stroke: "#cbd5e1" }}
|
||||
tickLine={false}
|
||||
domain={chartDomain}
|
||||
@@ -186,8 +187,8 @@ export function TemperatureChartCanvas({
|
||||
{timeframe === "1D" && cityThresholds.map((t, idx) => {
|
||||
const isSelected = row && (Number(row.target_threshold ?? row.target_value) === t.threshold);
|
||||
const labelText = isEn
|
||||
? `${t.kind === "gte" ? "≥" : "≤"} ${t.threshold.toFixed(1)}° [${t.isBreached ? "Excluded" : "Active"}]`
|
||||
: `${t.kind === "gte" ? "≥" : "≤"} ${t.threshold.toFixed(1)}° [${t.isBreached ? "已排除" : "活跃"}]`;
|
||||
? `${t.kind === "gte" ? "≥" : "≤"} ${t.threshold.toFixed(1)}${tempSymbol} [${t.isBreached ? "Excluded" : "Active"}]`
|
||||
: `${t.kind === "gte" ? "≥" : "≤"} ${t.threshold.toFixed(1)}${tempSymbol} [${t.isBreached ? "已排除" : "活跃"}]`;
|
||||
|
||||
return (
|
||||
<ReferenceLine
|
||||
@@ -221,17 +222,18 @@ export function TemperatureChartCanvas({
|
||||
payload={props.payload as ReadonlyArray<{ payload?: Record<string, any> }> | undefined}
|
||||
data={zoomedData}
|
||||
series={activeSeries}
|
||||
tempSymbol={tempSymbol}
|
||||
/>
|
||||
)}
|
||||
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)}°`;
|
||||
return `${low.toFixed(1)}${tempSymbol} - ${high.toFixed(1)}${tempSymbol}`;
|
||||
}
|
||||
}
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? `${num.toFixed(2)}°` : String(value);
|
||||
return Number.isFinite(num) ? `${num.toFixed(2)}${tempSymbol}` : String(value);
|
||||
}}
|
||||
/>
|
||||
{hasRunwayData && (
|
||||
|
||||
@@ -16,9 +16,11 @@ type RunwayPlate = {
|
||||
export function TemperatureRunwayDetails({
|
||||
isEn,
|
||||
plates,
|
||||
tempSymbol,
|
||||
}: {
|
||||
isEn: boolean;
|
||||
plates: RunwayPlate[];
|
||||
tempSymbol: string;
|
||||
}) {
|
||||
if (!plates.length) return null;
|
||||
|
||||
@@ -52,13 +54,13 @@ export function TemperatureRunwayDetails({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>TDZ: <strong>{plate.tdzTemp !== null ? `${plate.tdzTemp.toFixed(1)}°C` : "--"}</strong></div>
|
||||
<div>MID: <strong>{plate.midTemp !== null ? `${plate.midTemp.toFixed(1)}°C` : "--"}</strong></div>
|
||||
<div>END: <strong>{plate.endTemp !== null ? `${plate.endTemp.toFixed(1)}°C` : "--"}</strong></div>
|
||||
<div>max: <strong>{plate.maxTemp !== null ? `${plate.maxTemp.toFixed(1)}°C` : "--"}</strong></div>
|
||||
<div>high: <strong>{plate.dailyHigh !== null ? `${plate.dailyHigh.toFixed(1)}°C` : "--"}</strong></div>
|
||||
<div>TDZ: <strong>{plate.tdzTemp !== null ? `${plate.tdzTemp.toFixed(1)}${tempSymbol}` : "--"}</strong></div>
|
||||
<div>MID: <strong>{plate.midTemp !== null ? `${plate.midTemp.toFixed(1)}${tempSymbol}` : "--"}</strong></div>
|
||||
<div>END: <strong>{plate.endTemp !== null ? `${plate.endTemp.toFixed(1)}${tempSymbol}` : "--"}</strong></div>
|
||||
<div>max: <strong>{plate.maxTemp !== null ? `${plate.maxTemp.toFixed(1)}${tempSymbol}` : "--"}</strong></div>
|
||||
<div>high: <strong>{plate.dailyHigh !== null ? `${plate.dailyHigh.toFixed(1)}${tempSymbol}` : "--"}</strong></div>
|
||||
<div className={clsx(plate.trend_15m !== null && plate.trend_15m > 0 ? "text-orange-600 font-bold" : "text-slate-500")}>
|
||||
15m: <strong>{plate.trend_15m !== null ? `${plate.trend_15m >= 0 ? "+" : ""}${plate.trend_15m.toFixed(1)}°C` : "--"}</strong>
|
||||
15m: <strong>{plate.trend_15m !== null ? `${plate.trend_15m >= 0 ? "+" : ""}${plate.trend_15m.toFixed(1)}${tempSymbol}` : "--"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -7,6 +7,7 @@ export function TemperatureStatsBars({
|
||||
isEn,
|
||||
compact,
|
||||
timeframe,
|
||||
tempSymbol,
|
||||
runwayHeaderLabel,
|
||||
metarHeaderLabel,
|
||||
runwayHighLabel,
|
||||
@@ -27,6 +28,7 @@ export function TemperatureStatsBars({
|
||||
isEn: boolean;
|
||||
compact: boolean;
|
||||
timeframe: string;
|
||||
tempSymbol: string;
|
||||
runwayHeaderLabel: string;
|
||||
metarHeaderLabel: string;
|
||||
runwayHighLabel: string;
|
||||
@@ -51,18 +53,18 @@ export function TemperatureStatsBars({
|
||||
<div className="flex items-center gap-4 text-[11px]">
|
||||
<span className="font-semibold text-slate-500">
|
||||
{isEn ? "Runway" : runwayHeaderLabel}:{" "}
|
||||
<strong className="text-[#009688] font-mono">{temp(displayRunwayTemp)}</strong>
|
||||
<strong className="text-[#009688] font-mono">{temp(displayRunwayTemp, tempSymbol)}</strong>
|
||||
</span>
|
||||
<span className="text-slate-300">|</span>
|
||||
<span className="font-semibold text-slate-500">
|
||||
{isEn ? "METAR" : (isShenzhen ? "当日最高" : metarHeaderLabel)}:{" "}
|
||||
<strong className="text-blue-600 font-mono">{temp(observedHighMetar)}</strong>
|
||||
<strong className="text-blue-600 font-mono">{temp(observedHighMetar, tempSymbol)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-4 text-[11px]">
|
||||
<span className="font-semibold text-slate-500">
|
||||
DEB: <strong className="text-orange-600 font-mono">{temp(debVal)}</strong>
|
||||
DEB: <strong className="text-orange-600 font-mono">{temp(debVal, tempSymbol)}</strong>
|
||||
</span>
|
||||
{modelMin !== null && modelMax !== null && (
|
||||
<>
|
||||
@@ -70,7 +72,7 @@ export function TemperatureStatsBars({
|
||||
<span className="font-semibold text-slate-500">
|
||||
{isEn ? "Models" : "多模型"}:{" "}
|
||||
<strong className="text-slate-700 font-mono">
|
||||
{temp(modelMin)} - {temp(modelMax)}
|
||||
{temp(modelMin, tempSymbol)} - {temp(modelMax, tempSymbol)}
|
||||
</strong>
|
||||
</span>
|
||||
</>
|
||||
@@ -94,7 +96,7 @@ export function TemperatureStatsBars({
|
||||
{isEn ? "Runway Live (1m)" : `${runwayHeaderLabel}`}
|
||||
</span>
|
||||
<span className="text-2xl font-bold font-mono text-[#009688] mt-1">
|
||||
{temp(displayRunwayTemp)}
|
||||
{temp(displayRunwayTemp, tempSymbol)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
@@ -102,7 +104,7 @@ export function TemperatureStatsBars({
|
||||
{isEn ? "METAR Settlement · Daily High" : `${metarHeaderLabel} · 当日最高`}
|
||||
</span>
|
||||
<span className="text-2xl font-bold font-mono text-blue-600 mt-1">
|
||||
{temp(observedHighMetar)}
|
||||
{temp(observedHighMetar, tempSymbol)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,7 +115,7 @@ export function TemperatureStatsBars({
|
||||
DEB Max
|
||||
</span>
|
||||
<span className="text-2xl font-bold font-mono text-orange-600 mt-1">
|
||||
{temp(debVal)}
|
||||
{temp(debVal, tempSymbol)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
@@ -121,7 +123,7 @@ export function TemperatureStatsBars({
|
||||
{isEn ? "Model Range" : "多模型区间"}
|
||||
</span>
|
||||
<span className="text-2xl font-bold font-mono text-slate-700 mt-1">
|
||||
{modelMin !== null && modelMax !== null ? `${temp(modelMin)} - ${temp(modelMax)}` : "--"}
|
||||
{modelMin !== null && modelMax !== null ? `${temp(modelMin, tempSymbol)} - ${temp(modelMax, tempSymbol)}` : "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,13 +134,13 @@ export function TemperatureStatsBars({
|
||||
{isEn ? "Daily Peak" : "当日最高气温"}
|
||||
</span>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs font-mono text-slate-600">
|
||||
<span>{isEn ? "Runway" : runwayHighLabel}: <strong className="text-[#009688]">{temp(observedHighRunway)}</strong></span>
|
||||
<span>{isEn ? "Runway" : runwayHighLabel}: <strong className="text-[#009688]">{temp(observedHighRunway, tempSymbol)}</strong></span>
|
||||
<span>|</span>
|
||||
<span>{isEn ? "METAR" : metarHighLabel}: <strong className="text-blue-600">{temp(observedHighMetar)}</strong></span>
|
||||
<span>{isEn ? "METAR" : metarHighLabel}: <strong className="text-blue-600">{temp(observedHighMetar, tempSymbol)}</strong></span>
|
||||
{wundergroundDailyHigh !== null && (
|
||||
<>
|
||||
<span>|</span>
|
||||
<span>WU: <strong className="text-purple-600">{temp(wundergroundDailyHigh)}</strong></span>
|
||||
<span>WU: <strong className="text-purple-600">{temp(wundergroundDailyHigh, tempSymbol)}</strong></span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -152,7 +154,7 @@ export function TemperatureStatsBars({
|
||||
{isEn ? "Model Range" : "模型区间"}
|
||||
</span>
|
||||
<strong className="text-slate-800 font-bold">
|
||||
{modelMin !== null && modelMax !== null ? `${temp(modelMin)} - ${temp(modelMax)}` : "--"}
|
||||
{modelMin !== null && modelMax !== null ? `${temp(modelMin, tempSymbol)} - ${temp(modelMax, tempSymbol)}` : "--"}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
@@ -160,7 +162,7 @@ export function TemperatureStatsBars({
|
||||
DEB
|
||||
</span>
|
||||
<strong className="text-blue-600 font-bold">
|
||||
{temp(debVal)}
|
||||
{temp(debVal, tempSymbol)}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
@@ -168,7 +170,7 @@ export function TemperatureStatsBars({
|
||||
{isEn ? "Spread" : "分歧"}
|
||||
</span>
|
||||
<strong className={clsx("font-bold", spreadLabel === "高分歧" ? "text-amber-600" : "text-slate-600")}>
|
||||
{spread !== null ? `${spread.toFixed(1)}°C` : "--"}
|
||||
{spread !== null ? `${spread.toFixed(1)}${tempSymbol}` : "--"}
|
||||
{spreadLabel && ` · ${isEn ? spreadLabelEn : spreadLabel}`}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
@@ -31,12 +31,14 @@ export function TemperatureTooltipContent({
|
||||
payload,
|
||||
data,
|
||||
series,
|
||||
tempSymbol = "°C",
|
||||
}: {
|
||||
active?: boolean;
|
||||
label?: string | number;
|
||||
payload?: ReadonlyArray<{ payload?: Record<string, any> }>;
|
||||
data: Array<Record<string, any>>;
|
||||
series: TooltipSeries[];
|
||||
tempSymbol?: string;
|
||||
}) {
|
||||
if (!active || !payload?.length || !series.length) return null;
|
||||
const activePoint = payload[0]?.payload || {};
|
||||
@@ -60,7 +62,7 @@ export function TemperatureTooltipContent({
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
|
||||
<span className="font-semibold">{item.label}</span>
|
||||
</span>
|
||||
<strong className="font-mono text-slate-900">{item.value.toFixed(2)}°</strong>
|
||||
<strong className="font-mono text-slate-900">{item.value.toFixed(2)}{tempSymbol}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -6,12 +6,23 @@ import {
|
||||
} from "@/lib/refresh-policy";
|
||||
import { scanTerminalQueryPolicy } from "@/components/dashboard/scan-terminal/scan-terminal-client";
|
||||
import { __shouldPollLiveChartForTest } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
|
||||
import {
|
||||
MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS,
|
||||
__resetHourlyDetailRequestQueueForTest,
|
||||
__runQueuedHourlyDetailRequestForTest,
|
||||
} from "@/components/dashboard/scan-terminal/temperature-chart-logic";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
async function flushMicrotasks() {
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runTests() {
|
||||
assert(DASHBOARD_REFRESH_POLICY_MS.observation === 60_000, "observation layer should refresh every 60 seconds");
|
||||
assert(DASHBOARD_REFRESH_POLICY_MS.scanRows === 5 * 60_000, "region/city rows should refresh every 5 minutes");
|
||||
assert(DASHBOARD_REFRESH_POLICY_MS.marketOverview === 10 * 60_000, "market overview should refresh every 10 minutes");
|
||||
@@ -68,4 +79,55 @@ export function runTests() {
|
||||
chartSource.includes("setHourly(seedHourlyForecastFromRow(row))"),
|
||||
"terminal charts should render from row data immediately and dedupe concurrent city detail requests",
|
||||
);
|
||||
|
||||
__resetHourlyDetailRequestQueueForTest();
|
||||
let activeRequests = 0;
|
||||
let maxActiveRequests = 0;
|
||||
let startedRequests = 0;
|
||||
const releases: Array<(() => void) | undefined> = [];
|
||||
const requestCount = MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS + 3;
|
||||
|
||||
const requests = Array.from({ length: requestCount }, (_, index) =>
|
||||
__runQueuedHourlyDetailRequestForTest(
|
||||
() =>
|
||||
new Promise<number>((resolve) => {
|
||||
startedRequests += 1;
|
||||
activeRequests += 1;
|
||||
maxActiveRequests = Math.max(maxActiveRequests, activeRequests);
|
||||
releases[index] = () => {
|
||||
activeRequests -= 1;
|
||||
resolve(index);
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await flushMicrotasks();
|
||||
assert(
|
||||
startedRequests === MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS,
|
||||
"city detail queue should start only the configured number of concurrent requests",
|
||||
);
|
||||
assert(
|
||||
maxActiveRequests === MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS,
|
||||
"city detail queue must cap simultaneous full-detail requests",
|
||||
);
|
||||
|
||||
releases[0]?.();
|
||||
await flushMicrotasks();
|
||||
assert(
|
||||
startedRequests === MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS + 1,
|
||||
"city detail queue should start the next pending request when one active request finishes",
|
||||
);
|
||||
|
||||
for (let index = 1; index < requestCount; index += 1) {
|
||||
await flushMicrotasks();
|
||||
assert(Boolean(releases[index]), `city detail queue should eventually start queued request #${index}`);
|
||||
releases[index]?.();
|
||||
}
|
||||
const results = await Promise.all(requests);
|
||||
assert(
|
||||
results.length === requestCount && results.every((value, index) => value === index),
|
||||
"city detail queue should resolve every queued request in order",
|
||||
);
|
||||
__resetHourlyDetailRequestQueueForTest();
|
||||
}
|
||||
|
||||
@@ -91,12 +91,16 @@ export function runTests() {
|
||||
const chartStatsPath = path.join(process.cwd(), "components", "dashboard", "scan-terminal", "TemperatureStatsBars.tsx");
|
||||
const chartRunwayPath = path.join(process.cwd(), "components", "dashboard", "scan-terminal", "TemperatureRunwayDetails.tsx");
|
||||
const chartTooltipPath = path.join(process.cwd(), "components", "dashboard", "scan-terminal", "TemperatureTooltipContent.tsx");
|
||||
const chartSummaryPath = path.join(process.cwd(), "components", "dashboard", "scan-terminal", "ModelCurvesSummary.tsx");
|
||||
assert(fs.existsSync(chartCanvasPath), "temperature chart Recharts canvas must live in TemperatureChartCanvas.tsx");
|
||||
assert(fs.existsSync(chartStatsPath), "temperature chart stat bars must live in TemperatureStatsBars.tsx");
|
||||
assert(fs.existsSync(chartRunwayPath), "temperature chart runway detail panel must live in TemperatureRunwayDetails.tsx");
|
||||
assert(fs.existsSync(chartTooltipPath), "temperature chart tooltip must live in TemperatureTooltipContent.tsx");
|
||||
assert(fs.existsSync(chartSummaryPath), "temperature chart model summary must live in ModelCurvesSummary.tsx");
|
||||
const chartCanvas = fs.readFileSync(chartCanvasPath, "utf8");
|
||||
const chartTooltip = fs.readFileSync(chartTooltipPath, "utf8");
|
||||
const chartRunway = fs.readFileSync(chartRunwayPath, "utf8");
|
||||
const chartSummary = fs.readFileSync(chartSummaryPath, "utf8");
|
||||
const chartLogicPath = path.join(process.cwd(), "components", "dashboard", "scan-terminal", "temperature-chart-logic.ts");
|
||||
assert(fs.existsSync(chartLogicPath), "temperature chart pure data logic must live in temperature-chart-logic.ts");
|
||||
const chartLogic = fs.readFileSync(chartLogicPath, "utf8");
|
||||
@@ -112,6 +116,18 @@ export function runTests() {
|
||||
assert(chart.includes("TemperatureChartCanvas"), "temperature chart shell must compose the extracted chart canvas");
|
||||
assert(chart.includes("TemperatureStatsBars"), "temperature chart shell must compose the extracted stat bars");
|
||||
assert(chart.includes("TemperatureRunwayDetails"), "temperature chart shell must compose the extracted runway panel");
|
||||
assert(chart.includes("tempSymbol={row?.temp_symbol || \"°C\"}"), "temperature chart shell must pass the city temperature unit into stat bars");
|
||||
assert(chart.includes("<TemperatureRunwayDetails") && chart.includes("tempSymbol={row?.temp_symbol || \"°C\"}"), "temperature chart shell must pass the city unit into runway details");
|
||||
assert(chart.includes("<ModelCurvesSummary") && chart.includes("tempSymbol={row?.temp_symbol || \"°C\"}"), "temperature chart shell must pass the city unit into model summaries");
|
||||
const chartStats = fs.readFileSync(chartStatsPath, "utf8");
|
||||
assert(chartStats.includes("tempSymbol"), "temperature stat bars must accept the city temperature unit");
|
||||
assert(chartStats.includes("temp(displayRunwayTemp, tempSymbol)"), "temperature stat bars must render live observations with the city unit");
|
||||
assert(chartRunway.includes("tempSymbol") && !chartRunway.includes("}°C`"), "runway detail rows must render values with the city unit");
|
||||
assert(chartSummary.includes("tempSymbol") && chartSummary.includes("temp(stats.latest, tempSymbol)"), "model curve summaries must render values with the city unit");
|
||||
assert(chartCanvas.includes("const tempSymbol = row?.temp_symbol || \"°C\""), "temperature chart canvas must derive the city unit from the row");
|
||||
assert(chartCanvas.includes("tempSymbol={tempSymbol}"), "temperature chart canvas must pass the city unit into tooltips");
|
||||
assert(chartCanvas.includes("tickFormatter={(v) => `${Number(v).toFixed(0)}${tempSymbol}`}"), "temperature y-axis ticks must include °C/°F instead of a bare degree mark");
|
||||
assert(chartTooltip.includes("tempSymbol"), "temperature tooltip must accept the city temperature unit");
|
||||
assert(!chart.includes("from \"recharts\""), "LiveTemperatureThresholdChart.tsx must not import Recharts directly");
|
||||
assert(!chart.includes("function TemperatureTooltipContent"), "LiveTemperatureThresholdChart.tsx must not define tooltip content inline");
|
||||
assert(chartCanvas.includes("TemperatureTooltipContent"), "temperature chart canvas must use a custom tooltip content component");
|
||||
|
||||
@@ -203,6 +203,9 @@ const MAX_OBS_POINTS = 1440;
|
||||
const HOURLY_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
|
||||
const _hourlyCache = new Map<string, { ts: number; data: HourlyForecast }>();
|
||||
const _hourlyRequestCache = new Map<string, Promise<HourlyForecast>>();
|
||||
const MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS = 3;
|
||||
let _hourlyActiveDetailRequests = 0;
|
||||
const _hourlyDetailRequestQueue: Array<() => void> = [];
|
||||
const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"];
|
||||
|
||||
const SESSION_CACHE_PREFIX = "polyweather_city_detail_v1:";
|
||||
@@ -231,6 +234,34 @@ function writeSessionCache(city: string, data: HourlyForecast) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function drainHourlyDetailRequestQueue() {
|
||||
while (
|
||||
_hourlyActiveDetailRequests < MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS &&
|
||||
_hourlyDetailRequestQueue.length > 0
|
||||
) {
|
||||
const start = _hourlyDetailRequestQueue.shift();
|
||||
if (start) start();
|
||||
}
|
||||
}
|
||||
|
||||
function runQueuedHourlyDetailRequest<T>(task: () => Promise<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const start = () => {
|
||||
_hourlyActiveDetailRequests += 1;
|
||||
Promise.resolve()
|
||||
.then(task)
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
_hourlyActiveDetailRequests = Math.max(0, _hourlyActiveDetailRequests - 1);
|
||||
drainHourlyDetailRequestQueue();
|
||||
});
|
||||
};
|
||||
|
||||
_hourlyDetailRequestQueue.push(start);
|
||||
drainHourlyDetailRequestQueue();
|
||||
});
|
||||
}
|
||||
|
||||
export function clearCityDetailCache() {
|
||||
_hourlyCache.clear();
|
||||
_hourlyRequestCache.clear();
|
||||
@@ -246,6 +277,13 @@ export function clearCityDetailCache() {
|
||||
}
|
||||
}
|
||||
|
||||
function __resetHourlyDetailRequestQueueForTest() {
|
||||
_hourlyActiveDetailRequests = 0;
|
||||
_hourlyDetailRequestQueue.length = 0;
|
||||
}
|
||||
|
||||
const __runQueuedHourlyDetailRequestForTest = runQueuedHourlyDetailRequest;
|
||||
|
||||
function validNumber(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
@@ -619,20 +657,22 @@ async function fetchHourlyForecastForCity(
|
||||
const pending = _hourlyRequestCache.get(requestKey);
|
||||
if (pending) return pending;
|
||||
|
||||
const request = fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false&resolution=${resParam}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<CityDetail>;
|
||||
})
|
||||
.then((json) => {
|
||||
const data = parseHourlyForecastFromCityDetail(json);
|
||||
if (!data) return null;
|
||||
_hourlyCache.set(cacheKey, { ts: Date.now(), data });
|
||||
writeSessionCache(cacheKey, data);
|
||||
return data;
|
||||
const request = runQueuedHourlyDetailRequest(() =>
|
||||
fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false&resolution=${resParam}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<CityDetail>;
|
||||
})
|
||||
.then((json) => {
|
||||
const data = parseHourlyForecastFromCityDetail(json);
|
||||
if (!data) return null;
|
||||
_hourlyCache.set(cacheKey, { ts: Date.now(), data });
|
||||
writeSessionCache(cacheKey, data);
|
||||
return data;
|
||||
}),
|
||||
)
|
||||
.finally(() => {
|
||||
_hourlyRequestCache.delete(requestKey);
|
||||
});
|
||||
@@ -1597,8 +1637,11 @@ function binObservationsToSlots(
|
||||
}
|
||||
|
||||
export {
|
||||
MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS,
|
||||
HOURLY_CACHE_TTL_MS,
|
||||
_hourlyCache,
|
||||
__resetHourlyDetailRequestQueueForTest,
|
||||
__runQueuedHourlyDetailRequestForTest,
|
||||
buildChartDomain,
|
||||
buildFullDayChartData,
|
||||
getDebPeakWindowRange,
|
||||
|
||||
@@ -32,6 +32,15 @@ def _safe_float(value: Any) -> Optional[float]:
|
||||
return None
|
||||
|
||||
|
||||
def _display_temp_from_c(temp_c: Any, use_fahrenheit: bool) -> Optional[float]:
|
||||
numeric = _safe_float(temp_c)
|
||||
if numeric is None:
|
||||
return None
|
||||
if use_fahrenheit:
|
||||
return round(numeric * 9.0 / 5.0 + 32.0, 1)
|
||||
return round(numeric, 1)
|
||||
|
||||
|
||||
def _parse_obs_datetime(
|
||||
value: Any,
|
||||
epoch_value: Any = None,
|
||||
@@ -248,11 +257,18 @@ def _airport_primary_from_raw(city: str, raw: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
# High-frequency realtime sources take priority over plain METAR.
|
||||
madis = raw.get("madis_hfmetar_current") or {}
|
||||
if madis.get("temp_c") is not None:
|
||||
madis_temp_c = _safe_float(madis.get("temp_c"))
|
||||
madis_temp = _safe_float(madis.get("temp"))
|
||||
if madis_temp is None and madis_temp_c is not None:
|
||||
madis_temp = _display_temp_from_c(
|
||||
madis_temp_c,
|
||||
bool(meta.get("use_fahrenheit")),
|
||||
)
|
||||
if madis_temp is not None:
|
||||
return _normalize_station_row(
|
||||
station_code=meta.get("icao") or madis.get("icao"),
|
||||
station_label=meta.get("airport_name") or meta.get("icao"),
|
||||
temp=madis["temp_c"],
|
||||
temp=madis_temp,
|
||||
obs_time=madis.get("obs_time") or metar.get("observation_time"),
|
||||
source_code="madis_hfmetar",
|
||||
source_label="NOAA MADIS",
|
||||
@@ -260,6 +276,8 @@ def _airport_primary_from_raw(city: str, raw: Dict[str, Any]) -> Dict[str, Any]:
|
||||
is_airport_station=True,
|
||||
is_settlement_anchor=False,
|
||||
extra={
|
||||
"temp_c": madis_temp_c,
|
||||
"unit": "fahrenheit" if bool(meta.get("use_fahrenheit")) else "celsius",
|
||||
"max_so_far": _safe_float(current.get("max_temp_so_far")),
|
||||
"max_temp_time": current.get("max_temp_time"),
|
||||
"obs_age_min": None,
|
||||
|
||||
@@ -925,6 +925,16 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
def _uses_fahrenheit(self, city_lower: str) -> bool:
|
||||
return city_lower in self.US_CITIES
|
||||
|
||||
@staticmethod
|
||||
def _display_temp_from_c(temp_c: Any, use_fahrenheit: bool) -> Optional[float]:
|
||||
try:
|
||||
numeric = float(temp_c)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if use_fahrenheit:
|
||||
return round(numeric * 9.0 / 5.0 + 32.0, 1)
|
||||
return round(numeric, 1)
|
||||
|
||||
def _supports_aviationweather(self, city_lower: str) -> bool:
|
||||
city_meta = self.CITY_REGISTRY.get(str(city_lower or "").strip().lower(), {}) or {}
|
||||
settlement_source = str(city_meta.get("settlement_source") or "").strip().lower()
|
||||
@@ -1451,7 +1461,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
logger.warning("AMSC AWOS attach failed city={}: {}", city_lower, exc)
|
||||
|
||||
def _attach_madis_hfmetar_data(
|
||||
self, results: Dict, city_lower: str
|
||||
self, results: Dict, city_lower: str, use_fahrenheit: bool
|
||||
) -> None:
|
||||
"""Fetch MADIS HFMETAR data and attach matching US station observations."""
|
||||
# Only run for US cities (ICAO starts with K)
|
||||
@@ -1476,19 +1486,33 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
# Find this city's station in the MADIS results
|
||||
for obs in obs_list:
|
||||
if obs["icao"] == icao:
|
||||
temp_c = self._display_temp_from_c(obs.get("temp_c"), False)
|
||||
display_temp = self._display_temp_from_c(
|
||||
temp_c,
|
||||
use_fahrenheit,
|
||||
)
|
||||
if temp_c is None or display_temp is None:
|
||||
continue
|
||||
display_unit = "fahrenheit" if use_fahrenheit else "celsius"
|
||||
# Inject into results so it flows through to airport_primary
|
||||
results["madis_hfmetar_current"] = obs
|
||||
results["madis_hfmetar_current"] = {
|
||||
**obs,
|
||||
"temp_c": temp_c,
|
||||
"temp": display_temp,
|
||||
"unit": display_unit,
|
||||
}
|
||||
self._emit_temperature_patch_if_changed(
|
||||
city_lower,
|
||||
obs.get("temp_c"),
|
||||
display_temp,
|
||||
obs.get("obs_time"),
|
||||
source="madis_hfmetar",
|
||||
extra={"temp_c": temp_c, "unit": display_unit},
|
||||
)
|
||||
try:
|
||||
DBManager().append_airport_obs(
|
||||
icao=icao,
|
||||
city=city_lower,
|
||||
temp_c=obs["temp_c"],
|
||||
temp_c=temp_c,
|
||||
wind_kt=obs.get("wind_kt"),
|
||||
pressure_hpa=obs.get("pressure_hpa"),
|
||||
obs_time=obs["obs_time"] or datetime.now().isoformat(),
|
||||
@@ -1686,7 +1710,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
)
|
||||
self._attach_korean_amos_data(results, city_lower, use_fahrenheit)
|
||||
self._attach_china_amsc_awos_data(results, city_lower, use_fahrenheit)
|
||||
self._attach_madis_hfmetar_data(results, city_lower)
|
||||
self._attach_madis_hfmetar_data(results, city_lower, use_fahrenheit)
|
||||
self._attach_singapore_mss_data(results, city_lower)
|
||||
self._attach_israel_ims_data(results, city_lower)
|
||||
self._attach_saudi_ncm_data(results, city_lower)
|
||||
@@ -1738,7 +1762,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
)
|
||||
self._attach_korean_amos_data(results, city_lower, use_fahrenheit)
|
||||
self._attach_china_amsc_awos_data(results, city_lower, use_fahrenheit)
|
||||
self._attach_madis_hfmetar_data(results, city_lower)
|
||||
self._attach_madis_hfmetar_data(results, city_lower, use_fahrenheit)
|
||||
self._attach_singapore_mss_data(results, city_lower)
|
||||
self._attach_israel_ims_data(results, city_lower)
|
||||
self._attach_saudi_ncm_data(results, city_lower)
|
||||
|
||||
@@ -344,6 +344,33 @@ def test_moscow_provider_uses_realtime_metar_cluster_not_station_archive_rows():
|
||||
assert snapshot["official_nearby"][0]["is_airport_station"] is True
|
||||
|
||||
|
||||
def test_us_madis_airport_primary_uses_city_display_unit():
|
||||
raw = {
|
||||
"madis_hfmetar_current": {
|
||||
"icao": "KHOU",
|
||||
"temp_c": 19.4,
|
||||
"temp": 66.9,
|
||||
"obs_time": "2026-05-27T17:00:00+00:00",
|
||||
"wind_kt": 8.0,
|
||||
},
|
||||
"metar": {
|
||||
"observation_time": "2026-05-27T17:00:00+00:00",
|
||||
"current": {
|
||||
"temp": 66.9,
|
||||
"max_temp_so_far": 84.9,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
snapshot = build_country_network_snapshot("houston", raw)
|
||||
airport_primary = snapshot["airport_primary_current"]
|
||||
|
||||
assert airport_primary["source_code"] == "madis_hfmetar"
|
||||
assert airport_primary["source_label"] == "NOAA MADIS"
|
||||
assert airport_primary["temp"] == 66.9
|
||||
assert airport_primary["temp_c"] == 19.4
|
||||
|
||||
|
||||
def test_city_detail_payload_exposes_airport_and_official_network_layers():
|
||||
payload = _build_city_detail_payload(
|
||||
{
|
||||
|
||||
@@ -53,6 +53,60 @@ def test_multi_model_order_includes_legacy_and_new_sources():
|
||||
assert "jma_seamless" in OPEN_METEO_MULTI_MODEL_ORDER
|
||||
|
||||
|
||||
def test_madis_patch_uses_city_display_unit_for_us(monkeypatch):
|
||||
collector = WeatherDataCollector({})
|
||||
emitted = []
|
||||
stored = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
collector,
|
||||
"fetch_madis_hfmetar",
|
||||
lambda: [
|
||||
{
|
||||
"icao": "KHOU",
|
||||
"temp_c": 19.4,
|
||||
"obs_time": "2026-05-27T17:00:00+00:00",
|
||||
"wind_kt": 8.0,
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
collector,
|
||||
"_emit_temperature_patch_if_changed",
|
||||
lambda city, temp, obs_time=None, **kwargs: emitted.append(
|
||||
{
|
||||
"city": city,
|
||||
"temp": temp,
|
||||
"obs_time": obs_time,
|
||||
**kwargs,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
class FakeDBManager:
|
||||
def append_airport_obs(self, **kwargs):
|
||||
stored.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.data_collection.weather_sources.DBManager",
|
||||
lambda: FakeDBManager(),
|
||||
)
|
||||
|
||||
results = {}
|
||||
collector._attach_madis_hfmetar_data(
|
||||
results,
|
||||
"houston",
|
||||
use_fahrenheit=True,
|
||||
)
|
||||
|
||||
assert results["madis_hfmetar_current"]["temp"] == 66.9
|
||||
assert results["madis_hfmetar_current"]["temp_c"] == 19.4
|
||||
assert emitted[0]["temp"] == 66.9
|
||||
assert emitted[0]["extra"]["temp_c"] == 19.4
|
||||
assert emitted[0]["extra"]["unit"] == "fahrenheit"
|
||||
assert stored[0]["temp_c"] == 19.4
|
||||
|
||||
|
||||
def test_fetch_all_sources_prioritizes_multi_model_before_forecast(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("OPEN_METEO_DISK_CACHE_PATH", str(tmp_path / "om-cache.json"))
|
||||
collector = WeatherDataCollector({})
|
||||
|
||||
Reference in New Issue
Block a user