模型点预测卡片只显示温度值,移除无意义的 max/15m
This commit is contained in:
@@ -14,7 +14,8 @@ import {
|
|||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||||
|
import { buildDebBaselinePath } from "@/lib/temperature-chart-paths";
|
||||||
import { Panel } from "@/components/dashboard/scan-terminal/Panel";
|
import { Panel } from "@/components/dashboard/scan-terminal/Panel";
|
||||||
import { rowName, temp } from "@/components/dashboard/scan-terminal/utils";
|
import { rowName, temp } from "@/components/dashboard/scan-terminal/utils";
|
||||||
|
|
||||||
@@ -47,6 +48,8 @@ const DAILY_CHART_HOURS = Array.from(
|
|||||||
(_, index) => `${String(index).padStart(2, "0")}:00`,
|
(_, index) => `${String(index).padStart(2, "0")}:00`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const VISIBLE_WINDOW_HOURS = 12;
|
||||||
|
|
||||||
function validNumber(value: unknown): number | null {
|
function validNumber(value: unknown): number | null {
|
||||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||||
}
|
}
|
||||||
@@ -85,18 +88,30 @@ function seriesStats(values: Array<number | null>) {
|
|||||||
return { latest, high, delta15 };
|
return { latest, high, delta15 };
|
||||||
}
|
}
|
||||||
|
|
||||||
type HourlyForecast = { times: string[]; temps: Array<number | null> } | null;
|
type HourlyForecast = {
|
||||||
|
forecastTodayHigh?: number | null;
|
||||||
|
localTime?: string | null;
|
||||||
|
times: string[];
|
||||||
|
temps: Array<number | null>;
|
||||||
|
} | null;
|
||||||
|
|
||||||
function buildModelCurves(row: ScanOpportunityRow | null, length: number, hourly: HourlyForecast) {
|
function buildModelCurves(row: ScanOpportunityRow | null, length: number, hourly: HourlyForecast) {
|
||||||
const result: EvidenceSeries[] = [];
|
const result: EvidenceSeries[] = [];
|
||||||
// Use hourly forecast data if available (DEB-blended ensemble curve)
|
// Use hourly forecast data if available. Daily model highs are not plotted
|
||||||
|
// as curves because they are single terminal values, not a time series.
|
||||||
if (hourly?.times?.length && hourly?.temps?.length) {
|
if (hourly?.times?.length && hourly?.temps?.length) {
|
||||||
|
const debPath = buildDebBaselinePath(
|
||||||
|
hourly.times,
|
||||||
|
hourly.temps,
|
||||||
|
row?.deb_prediction,
|
||||||
|
hourly.localTime || row?.local_time,
|
||||||
|
hourly.forecastTodayHigh,
|
||||||
|
);
|
||||||
const values = Array.from({ length }, (): number | null => null);
|
const values = Array.from({ length }, (): number | null => null);
|
||||||
hourly.times.forEach((t, i) => {
|
hourly.times.forEach((t, i) => {
|
||||||
const hour = /(\d{1,2}):/.exec(String(t || ""))?.[1];
|
const h = parseHourOfDay(t);
|
||||||
const h = hour ? parseInt(hour, 10) : null;
|
|
||||||
if (h !== null && h >= 0 && h < length && i < hourly.temps.length) {
|
if (h !== null && h >= 0 && h < length && i < hourly.temps.length) {
|
||||||
values[h] = validNumber(hourly.temps[i]);
|
values[h] = validNumber(debPath.debTemps[i]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (values.some((v) => v !== null)) {
|
if (values.some((v) => v !== null)) {
|
||||||
@@ -111,25 +126,22 @@ function buildModelCurves(row: ScanOpportunityRow | null, length: number, hourly
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback: show model daily-high points as anchors
|
return result;
|
||||||
const modelEntries = Object.entries(row?.model_cluster_sources || {})
|
}
|
||||||
|
|
||||||
|
function buildModelSummaryCards(row: ScanOpportunityRow | null): EvidenceSeries[] {
|
||||||
|
return Object.entries(row?.model_cluster_sources || {})
|
||||||
.map(([label, value]) => [label, validNumber(value)] as const)
|
.map(([label, value]) => [label, validNumber(value)] as const)
|
||||||
.filter((entry): entry is readonly [string, number] => entry[1] !== null)
|
.filter((entry): entry is readonly [string, number] => entry[1] !== null)
|
||||||
.slice(0, 3);
|
.slice(0, 4)
|
||||||
modelEntries.forEach(([label, value], index) => {
|
.map(([label, value], index) => ({
|
||||||
// Place anchor points at peak hours (14-17 local)
|
key: `model_summary_${index}`,
|
||||||
const values = Array.from({ length }, (): number | null => null);
|
|
||||||
for (let h = 14; h <= 17; h++) values[h] = value;
|
|
||||||
result.push({
|
|
||||||
key: `model_${index}`,
|
|
||||||
label,
|
label,
|
||||||
source: "Multi-model",
|
source: "Multi-model daily high",
|
||||||
color: ["#2563eb", "#14b8a6", "#7c3aed"][index] || "#64748b",
|
color: ["#2563eb", "#14b8a6", "#7c3aed", "#64748b"][index] || "#64748b",
|
||||||
dashed: true,
|
dashed: true,
|
||||||
values,
|
values: [value],
|
||||||
});
|
}));
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractRunwayPointSeries(row: ScanOpportunityRow | null, length: number): EvidenceSeries[] {
|
function extractRunwayPointSeries(row: ScanOpportunityRow | null, length: number): EvidenceSeries[] {
|
||||||
@@ -243,6 +255,74 @@ function buildEvidenceChart(row: ScanOpportunityRow | null, hourly: HourlyForeca
|
|||||||
return { data, series };
|
return { data, series };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentHourForWindow(row: ScanOpportunityRow | null, hourly: HourlyForecast) {
|
||||||
|
return parseHourOfDay(hourly?.localTime || row?.local_time) ?? new Date().getHours();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMovingWindowData(
|
||||||
|
data: Array<Record<string, string | number | null>>,
|
||||||
|
row: ScanOpportunityRow | null,
|
||||||
|
hourly: HourlyForecast,
|
||||||
|
) {
|
||||||
|
if (!data.length) return data;
|
||||||
|
const currentHour = currentHourForWindow(row, hourly);
|
||||||
|
const endHour = Math.min(23, Math.max(VISIBLE_WINDOW_HOURS - 1, currentHour + 4));
|
||||||
|
const startHour = Math.max(0, endHour - VISIBLE_WINDOW_HOURS + 1);
|
||||||
|
return data.slice(startHour, endHour + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTemperatureOptionsFromText(value?: string | null) {
|
||||||
|
const raw = String(value || "");
|
||||||
|
const matches = raw.match(/-?\d+(?:\.\d+)?/g) || [];
|
||||||
|
return matches
|
||||||
|
.map((item) => Number(item))
|
||||||
|
.filter((item) => Number.isFinite(item) && item > -80 && item < 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMarketTemperatureOptions(row: ScanOpportunityRow | null) {
|
||||||
|
const buckets = row?.distribution_full?.length
|
||||||
|
? row.distribution_full
|
||||||
|
: row?.distribution_preview;
|
||||||
|
const values = new Set<number>();
|
||||||
|
(buckets || []).forEach((bucket) => {
|
||||||
|
const value = validNumber(bucket.value);
|
||||||
|
if (value !== null) values.add(value);
|
||||||
|
parseTemperatureOptionsFromText(bucket.label).forEach((item) => values.add(item));
|
||||||
|
});
|
||||||
|
[
|
||||||
|
row?.target_lower,
|
||||||
|
row?.target_upper,
|
||||||
|
row?.target_value,
|
||||||
|
row?.target_threshold,
|
||||||
|
].forEach((value) => {
|
||||||
|
const numeric = validNumber(value);
|
||||||
|
if (numeric !== null) values.add(numeric);
|
||||||
|
});
|
||||||
|
parseTemperatureOptionsFromText(row?.target_label).forEach((item) => values.add(item));
|
||||||
|
parseTemperatureOptionsFromText(row?.market_question).forEach((item) => values.add(item));
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
if (sorted.length) return sorted;
|
||||||
|
const threshold = validNumber(row?.target_threshold) ?? validNumber(row?.target_value);
|
||||||
|
if (threshold === null) return null;
|
||||||
|
return [threshold - 2, threshold - 1, threshold, threshold + 1, threshold + 2];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChartDomain(
|
||||||
|
marketTicks: number[] | null,
|
||||||
|
series: EvidenceSeries[],
|
||||||
|
): [number, number] | ["auto", "auto"] {
|
||||||
|
const values = series
|
||||||
|
.flatMap((item) => item.values)
|
||||||
|
.filter((value): value is number => validNumber(value) !== null);
|
||||||
|
const domainValues = [...(marketTicks || []), ...values];
|
||||||
|
if (!domainValues.length) return ["auto", "auto"];
|
||||||
|
const min = Math.min(...domainValues);
|
||||||
|
const max = Math.max(...domainValues);
|
||||||
|
const span = Math.max(1, max - min);
|
||||||
|
const padding = Math.max(0.5, span * 0.08);
|
||||||
|
return [Number((min - padding).toFixed(1)), Number((max + padding).toFixed(1))];
|
||||||
|
}
|
||||||
|
|
||||||
export function LiveTemperatureThresholdChart({
|
export function LiveTemperatureThresholdChart({
|
||||||
isEn,
|
isEn,
|
||||||
row,
|
row,
|
||||||
@@ -257,19 +337,21 @@ export function LiveTemperatureThresholdChart({
|
|||||||
setHourly(null);
|
setHourly(null);
|
||||||
if (!city) return;
|
if (!city) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetch(`/api/city/${encodeURIComponent(city)}/summary`, {
|
fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=panel&force_refresh=false`, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
})
|
})
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
return res.json() as Promise<{ timeseries?: { hourly?: { times?: string[]; temps?: Array<number | null> } } }>;
|
return res.json() as Promise<CityDetail>;
|
||||||
})
|
})
|
||||||
.then((json) => {
|
.then((json) => {
|
||||||
if (cancelled || !json?.timeseries?.hourly) return;
|
if (cancelled || !json?.hourly) return;
|
||||||
setHourly({
|
setHourly({
|
||||||
times: json.timeseries.hourly.times || [],
|
forecastTodayHigh: json.forecast?.today_high ?? null,
|
||||||
temps: json.timeseries.hourly.temps || [],
|
localTime: json.local_time || null,
|
||||||
|
times: json.hourly.times || [],
|
||||||
|
temps: json.hourly.temps || [],
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -277,24 +359,17 @@ export function LiveTemperatureThresholdChart({
|
|||||||
}, [city]);
|
}, [city]);
|
||||||
|
|
||||||
const { data, series } = useMemo(() => buildEvidenceChart(row, hourly), [row, hourly]);
|
const { data, series } = useMemo(() => buildEvidenceChart(row, hourly), [row, hourly]);
|
||||||
|
const visibleData = useMemo(() => buildMovingWindowData(data, row, hourly), [data, row, hourly]);
|
||||||
const threshold = validNumber(row?.target_threshold) ?? validNumber(row?.target_value);
|
const threshold = validNumber(row?.target_threshold) ?? validNumber(row?.target_value);
|
||||||
const tableRows = series.slice(0, 5).map((item) => ({ ...item, ...seriesStats(item.values) }));
|
const modelSummaryCards = useMemo(() => buildModelSummaryCards(row), [row]);
|
||||||
const marketTemperatures = useMemo(() => {
|
const tableRows = [...series, ...modelSummaryCards]
|
||||||
const buckets = row?.distribution_full?.length
|
.slice(0, 5)
|
||||||
? row.distribution_full
|
.map((item) => ({ ...item, ...seriesStats(item.values) }));
|
||||||
: row?.distribution_preview;
|
const marketTemperatureTicks = useMemo(() => buildMarketTemperatureOptions(row), [row]);
|
||||||
if (!buckets?.length) return null;
|
const chartDomain = useMemo(
|
||||||
const temps = buckets
|
() => buildChartDomain(marketTemperatureTicks, series),
|
||||||
.map((b) => validNumber(b.value))
|
[marketTemperatureTicks, series],
|
||||||
.filter((v): v is number => v !== null)
|
);
|
||||||
.sort((a, b) => a - b);
|
|
||||||
if (!temps.length) return null;
|
|
||||||
const padding = temps.length > 1 ? temps[1] - temps[0] : 2;
|
|
||||||
return {
|
|
||||||
ticks: temps,
|
|
||||||
domain: [temps[0] - padding, temps[temps.length - 1] + padding] as [number, number],
|
|
||||||
};
|
|
||||||
}, [row]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel title={isEn ? "Live Temperature Trend & Option Threshold Lines" : "实时气温走势与期权阈值线"}>
|
<Panel title={isEn ? "Live Temperature Trend & Option Threshold Lines" : "实时气温走势与期权阈值线"}>
|
||||||
@@ -326,10 +401,16 @@ export function LiveTemperatureThresholdChart({
|
|||||||
<span className="h-1.5 w-4 rounded-full" style={{ backgroundColor: item.color }} />
|
<span className="h-1.5 w-4 rounded-full" style={{ backgroundColor: item.color }} />
|
||||||
<span className="truncate font-black text-slate-700">{item.label}</span>
|
<span className="truncate font-black text-slate-700">{item.label}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 grid grid-cols-3 gap-1 font-mono text-[10px] text-slate-600">
|
<div className="mt-1 font-mono text-[10px] text-slate-600">
|
||||||
<span>now: {temp(item.latest)}</span>
|
{item.key.startsWith("model_summary_") ? (
|
||||||
<span>max: {temp(item.high)}</span>
|
<span>{temp(item.latest)}</span>
|
||||||
<span>15m: {item.delta15 === null ? "--" : `${item.delta15 >= 0 ? "+" : ""}${item.delta15.toFixed(1)}°`}</span>
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-1">
|
||||||
|
<span>now: {temp(item.latest)}</span>
|
||||||
|
<span>max: {temp(item.high)}</span>
|
||||||
|
<span>15m: {item.delta15 === null ? "--" : `${item.delta15 >= 0 ? "+" : ""}${item.delta15.toFixed(1)}°`}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -340,16 +421,16 @@ export function LiveTemperatureThresholdChart({
|
|||||||
{rowName(row)} <span className="ml-1 text-teal-600">{row?.target_label || row?.market_direction || ""}</span>
|
{rowName(row)} <span className="ml-1 text-teal-600">{row?.target_label || row?.market_direction || ""}</span>
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<ReLineChart data={data} margin={{ top: 16, right: 28, left: 8, bottom: 8 }}>
|
<ReLineChart data={visibleData} margin={{ top: 16, right: 28, left: 8, bottom: 8 }}>
|
||||||
<CartesianGrid stroke="#dbe6ef" strokeDasharray="2 2" />
|
<CartesianGrid stroke="#dbe6ef" strokeDasharray="2 2" />
|
||||||
<XAxis dataKey="label" tick={{ fontSize: 10, fill: "#64748b" }} tickLine={false} axisLine={{ stroke: "#cbd5e1" }} interval={2} />
|
<XAxis dataKey="label" tick={{ fontSize: 10, fill: "#64748b" }} tickLine={false} axisLine={{ stroke: "#cbd5e1" }} interval={0} />
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: 10, fill: "#64748b" }}
|
tick={{ fontSize: 10, fill: "#64748b" }}
|
||||||
tickFormatter={(v) => `${Number(v).toFixed(1)}°`}
|
tickFormatter={(v) => `${Number(v).toFixed(1)}°`}
|
||||||
axisLine={{ stroke: "#cbd5e1" }}
|
axisLine={{ stroke: "#cbd5e1" }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
domain={marketTemperatures?.domain ?? ["auto", "auto"]}
|
domain={chartDomain}
|
||||||
ticks={marketTemperatures?.ticks}
|
ticks={marketTemperatureTicks || undefined}
|
||||||
/>
|
/>
|
||||||
{threshold !== null && (
|
{threshold !== null && (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
|
|||||||
Reference in New Issue
Block a user