走势图重构:全天 48 槽位 X 轴、交互图例切换、Brush 缩放
This commit is contained in:
@@ -7,7 +7,6 @@ import { ExternalLink } from "lucide-react";
|
|||||||
import {
|
import {
|
||||||
Brush,
|
Brush,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Legend,
|
|
||||||
Line,
|
Line,
|
||||||
LineChart as ReLineChart,
|
LineChart as ReLineChart,
|
||||||
ReferenceLine,
|
ReferenceLine,
|
||||||
@@ -379,12 +378,10 @@ function buildRunwayHistorySeries(
|
|||||||
.filter((series): series is RunwayHistorySeries => series !== null);
|
.filter((series): series is RunwayHistorySeries => series !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Build aligned data rows for the sliding-window chart ────────────────
|
function buildFullDayChartData(
|
||||||
|
|
||||||
function buildSlidingChartData(
|
|
||||||
row: ScanOpportunityRow | null,
|
row: ScanOpportunityRow | null,
|
||||||
hourly: HourlyForecast,
|
hourly: HourlyForecast,
|
||||||
) {
|
): { data: Array<Record<string, string | number | null>>; series: EvidenceSeries[] } {
|
||||||
const tzOffset = row?.tz_offset_seconds ?? 0;
|
const tzOffset = row?.tz_offset_seconds ?? 0;
|
||||||
const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10);
|
const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
@@ -392,123 +389,60 @@ function buildSlidingChartData(
|
|||||||
const metarObs = normObs(row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset);
|
const metarObs = normObs(row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset);
|
||||||
const runwayHistorySeries = buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr);
|
const runwayHistorySeries = buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr);
|
||||||
|
|
||||||
// Collect all timestamps from observations + forecasts
|
const slots = generateFullDaySlots(localDateStr);
|
||||||
const allTimes = new Set<number>();
|
if (!slots.length) return { data: [], series: [] };
|
||||||
|
const slotLabels = slots.map(formatTimestamp);
|
||||||
const pushObs = (obs: ReturnType<typeof normObs>) => {
|
const n = slots.length;
|
||||||
obs.forEach((o) => allTimes.add(o.ts));
|
|
||||||
};
|
|
||||||
pushObs(settlementObs);
|
|
||||||
pushObs(metarObs);
|
|
||||||
runwayHistorySeries.forEach((item) => {
|
|
||||||
item.points.forEach((point) => allTimes.add(point.ts));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Forecast timestamps
|
|
||||||
const forecastTimes: number[] = [];
|
|
||||||
if (hourly?.times?.length && hourly?.temps?.length) {
|
|
||||||
hourly.times.forEach((t, i) => {
|
|
||||||
const ts = getCityLocalUtcTimestamp(t, tzOffset, localDateStr);
|
|
||||||
if (ts !== null && i < hourly.temps.length) {
|
|
||||||
allTimes.add(ts);
|
|
||||||
forecastTimes.push(ts);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort timestamps
|
|
||||||
const sorted = [...allTimes].sort((a, b) => a - b);
|
|
||||||
if (!sorted.length) return { data: [], series: [] };
|
|
||||||
|
|
||||||
// Build a lookup: timestamp → index in the sorted array
|
|
||||||
const tsToIdx = new Map<number, number>();
|
|
||||||
sorted.forEach((ts, i) => tsToIdx.set(ts, i));
|
|
||||||
|
|
||||||
const n = sorted.length;
|
|
||||||
const na = (): Array<number | null> => Array.from({ length: n }, () => null);
|
|
||||||
|
|
||||||
const series: EvidenceSeries[] = [];
|
const series: EvidenceSeries[] = [];
|
||||||
|
const na = (): Array<number | null> => new Array(n).fill(null);
|
||||||
|
|
||||||
runwayHistorySeries.forEach((item) => {
|
// ── Runway history series ──
|
||||||
const vals = na();
|
runwayHistorySeries.forEach((rhs) => {
|
||||||
item.points.forEach((o) => {
|
const binned = binObservationsToSlots(slots, rhs.points);
|
||||||
const idx = tsToIdx.get(o.ts);
|
if (!binned.some((v) => v !== null)) return;
|
||||||
if (idx !== undefined) vals[idx] = o.value;
|
series.push({
|
||||||
|
key: rhs.key,
|
||||||
|
label: rhs.label,
|
||||||
|
source: "AMOS/AMSC",
|
||||||
|
color: rhs.color,
|
||||||
|
featured: rhs.isSettlement,
|
||||||
|
dashed: !rhs.isSettlement,
|
||||||
|
values: binned,
|
||||||
});
|
});
|
||||||
if (vals.some((v) => v !== null)) {
|
});
|
||||||
|
|
||||||
|
// ── Settlement observations ──
|
||||||
|
if (!runwayHistorySeries.length && settlementObs.length) {
|
||||||
|
const svals = binObservationsToSlots(slots, settlementObs);
|
||||||
|
if (svals.some((v) => v !== null)) {
|
||||||
series.push({
|
series.push({
|
||||||
key: item.key,
|
key: "settlement",
|
||||||
label: item.label,
|
label: row?.metar_context?.station_label || row?.metar_context?.station || "Settlement",
|
||||||
source: "Runway",
|
source: row?.metar_context?.station || row?.airport || "Settlement",
|
||||||
color: item.color,
|
color: "#009688",
|
||||||
dashed: !item.isSettlement,
|
featured: true,
|
||||||
featured: item.isSettlement,
|
values: svals,
|
||||||
curve: "monotone",
|
|
||||||
connectNulls: true,
|
|
||||||
showDot: item.isSettlement,
|
|
||||||
values: vals,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Settlement
|
|
||||||
const sVals = na();
|
|
||||||
settlementObs.forEach((o) => {
|
|
||||||
const idx = tsToIdx.get(o.ts);
|
|
||||||
if (idx !== undefined) sVals[idx] = o.value;
|
|
||||||
});
|
|
||||||
if (!runwayHistorySeries.length && sVals.some((v) => v !== null)) {
|
|
||||||
const cityKey = String(row?.city || "").toLowerCase().trim();
|
|
||||||
const runwaySensorCities = new Set([
|
|
||||||
'beijing', 'shanghai', 'guangzhou', 'shenzhen', 'qingdao',
|
|
||||||
'chengdu', 'chongqing', 'wuhan', // AMSC runway sensors
|
|
||||||
'seoul', 'busan', // AMOS runway sensors
|
|
||||||
]);
|
|
||||||
const isHKO = cityKey === 'hong kong' || cityKey === 'lau fau shan' || cityKey.includes('hongkong') || cityKey.includes('laufau');
|
|
||||||
const isTokyo = cityKey === 'tokyo';
|
|
||||||
const isSingapore = cityKey === 'singapore';
|
|
||||||
const isWeatherStation = !runwaySensorCities.has(cityKey)
|
|
||||||
&& !isHKO && !isTokyo && !isSingapore;
|
|
||||||
|
|
||||||
const runwayHeaderLabel = isHKO ? '参考站点 (1分钟)'
|
|
||||||
: isTokyo ? '机场气象站 (10分钟)'
|
|
||||||
: isSingapore ? '航站楼温度'
|
|
||||||
: isWeatherStation ? '气象站实测'
|
|
||||||
: '跑道实测 (1分钟)';
|
|
||||||
|
|
||||||
series.push({
|
|
||||||
key: "settlement",
|
|
||||||
label: runwayHeaderLabel,
|
|
||||||
source: row?.metar_context?.station || row?.airport || "Settlement",
|
|
||||||
color: "#009688",
|
|
||||||
featured: true,
|
|
||||||
curve: "monotone",
|
|
||||||
connectNulls: true,
|
|
||||||
values: sVals,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// METAR
|
// ── METAR ──
|
||||||
const mVals = na();
|
if (metarObs.length) {
|
||||||
metarObs.forEach((o) => {
|
const mvals = binObservationsToSlots(slots, metarObs);
|
||||||
const idx = tsToIdx.get(o.ts);
|
if (mvals.some((v) => v !== null)) {
|
||||||
if (idx !== undefined) mVals[idx] = o.value;
|
series.push({
|
||||||
});
|
key: "metar",
|
||||||
if (mVals.some((v) => v !== null)) {
|
label: "METAR",
|
||||||
series.push({
|
source: row?.airport || "METAR",
|
||||||
key: "metar",
|
color: "#0ea5e9",
|
||||||
label: "METAR",
|
dashed: true,
|
||||||
source: row?.airport || "METAR",
|
values: mvals,
|
||||||
color: "#0ea5e9",
|
});
|
||||||
dashed: true,
|
}
|
||||||
curve: "stepAfter",
|
|
||||||
connectNulls: true,
|
|
||||||
showDot: true,
|
|
||||||
values: mVals,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DEB forecast curve
|
// ── DEB forecast curve ──
|
||||||
if (hourly?.times?.length && hourly?.temps?.length) {
|
if (hourly?.times?.length && hourly?.temps?.length) {
|
||||||
const debPath = buildDebBaselinePath(
|
const debPath = buildDebBaselinePath(
|
||||||
hourly.times,
|
hourly.times,
|
||||||
@@ -520,9 +454,10 @@ function buildSlidingChartData(
|
|||||||
const debVals = na();
|
const debVals = na();
|
||||||
hourly.times.forEach((t, i) => {
|
hourly.times.forEach((t, i) => {
|
||||||
const ts = getCityLocalUtcTimestamp(t, tzOffset, localDateStr);
|
const ts = getCityLocalUtcTimestamp(t, tzOffset, localDateStr);
|
||||||
const idx = ts !== null ? tsToIdx.get(ts) : undefined;
|
if (ts === null) return;
|
||||||
if (idx !== undefined && i < debPath.debTemps.length) {
|
const slotIdx = slots.findIndex((s) => s === ts);
|
||||||
debVals[idx] = validNumber(debPath.debTemps[i]);
|
if (slotIdx >= 0 && i < debPath.debTemps.length) {
|
||||||
|
debVals[slotIdx] = validNumber(debPath.debTemps[i]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (debVals.some((v) => v !== null)) {
|
if (debVals.some((v) => v !== null)) {
|
||||||
@@ -533,32 +468,22 @@ function buildSlidingChartData(
|
|||||||
color: "#f97316",
|
color: "#f97316",
|
||||||
featured: true,
|
featured: true,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
curve: "monotone",
|
|
||||||
connectNulls: true,
|
|
||||||
values: debVals,
|
values: debVals,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-model hourly curves
|
// Per-model curves
|
||||||
if (hourly.modelCurves) {
|
if (hourly.modelCurves) {
|
||||||
const modelColors = ["#2563eb", "#7c3aed", "#059669", "#d97706", "#dc2626", "#0891b2"];
|
const modelColors = ["#2563eb", "#7c3aed", "#059669", "#d97706", "#dc2626", "#0891b2"];
|
||||||
Object.keys(hourly.modelCurves).forEach((model, idx) => {
|
Object.keys(hourly.modelCurves).forEach((model, idx) => {
|
||||||
const modelTemps = hourly.modelCurves![model];
|
const modelTemps = hourly.modelCurves![model];
|
||||||
if (!modelTemps?.length) return;
|
if (!modelTemps?.length) return;
|
||||||
const finiteModelTemps = modelTemps
|
|
||||||
.map(validNumber)
|
|
||||||
.filter((v): v is number => v !== null);
|
|
||||||
if (
|
|
||||||
finiteModelTemps.length < 2 ||
|
|
||||||
Math.max(...finiteModelTemps) - Math.min(...finiteModelTemps) < 0.05
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const vals = na();
|
const vals = na();
|
||||||
hourly.times.forEach((t, i) => {
|
hourly.times.forEach((t, i) => {
|
||||||
const ts = getCityLocalUtcTimestamp(t, tzOffset, localDateStr);
|
const ts = getCityLocalUtcTimestamp(t, tzOffset, localDateStr);
|
||||||
const x = ts !== null ? tsToIdx.get(ts) : undefined;
|
if (ts === null) return;
|
||||||
if (x !== undefined && i < modelTemps.length) vals[x] = validNumber(modelTemps[i]);
|
const slotIdx = slots.findIndex((s) => s === ts);
|
||||||
|
if (slotIdx >= 0 && i < modelTemps.length) vals[slotIdx] = validNumber(modelTemps[i]);
|
||||||
});
|
});
|
||||||
if (vals.some((v) => v !== null)) {
|
if (vals.some((v) => v !== null)) {
|
||||||
series.push({
|
series.push({
|
||||||
@@ -568,8 +493,6 @@ function buildSlidingChartData(
|
|||||||
color: modelColors[idx % modelColors.length],
|
color: modelColors[idx % modelColors.length],
|
||||||
dashed: true,
|
dashed: true,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
curve: "monotone",
|
|
||||||
connectNulls: true,
|
|
||||||
values: vals,
|
values: vals,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -577,87 +500,34 @@ function buildSlidingChartData(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: if no series, use current temp as a flat line
|
// ── Fallback ──
|
||||||
if (!series.length) {
|
if (!series.length) {
|
||||||
const fallback = validNumber(row?.current_temp) ?? validNumber(row?.deb_prediction) ?? validNumber(row?.target_threshold);
|
const fb = validNumber(row?.current_temp) ?? validNumber(row?.deb_prediction) ?? validNumber(row?.target_threshold);
|
||||||
if (fallback !== null) {
|
if (fb !== null) {
|
||||||
const vals = na().map(() => fallback);
|
|
||||||
series.push({
|
series.push({
|
||||||
key: "current",
|
key: "current",
|
||||||
label: "Current",
|
label: "Current reference",
|
||||||
source: "Live",
|
source: row?.metar_context?.source || "Live",
|
||||||
color: "#009688",
|
color: "#009688",
|
||||||
featured: true,
|
featured: true,
|
||||||
curve: "monotone",
|
values: Array.from({ length: n }, () => fb),
|
||||||
connectNulls: true,
|
|
||||||
values: vals,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build data rows: one per timestamp
|
// ── Build data rows ──
|
||||||
const data = sorted.map((ts, i) => {
|
const data = slots.map((ts, i) => {
|
||||||
const point: Record<string, string | number | null> = {
|
const point: Record<string, string | number | null> = {
|
||||||
label: formatTimestamp(ts),
|
label: formatTimestamp(ts),
|
||||||
ts,
|
ts,
|
||||||
};
|
};
|
||||||
series.forEach((s) => { point[s.key] = s.values[i]; });
|
series.forEach((s) => { point[s.key] = s.values[i] ?? null; });
|
||||||
return point;
|
return point;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { data, series };
|
return { data, series };
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasNumericValue(row: Record<string, string | number | null>, keys: string[]) {
|
|
||||||
return keys.some((key) => validNumber(row[key]) !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRollingWindowData(
|
|
||||||
data: Array<Record<string, string | number | null>>,
|
|
||||||
series: EvidenceSeries[],
|
|
||||||
row: ScanOpportunityRow | null,
|
|
||||||
hourly: HourlyForecast,
|
|
||||||
) {
|
|
||||||
if (data.length <= 1) return data;
|
|
||||||
|
|
||||||
const liveKeys = series
|
|
||||||
.filter((item) => item.key !== "hourly_forecast" && !item.key.startsWith("model_curve_"))
|
|
||||||
.map((item) => item.key);
|
|
||||||
const forecastKeys = series
|
|
||||||
.filter((item) => item.key === "hourly_forecast" || item.key.startsWith("model_curve_"))
|
|
||||||
.map((item) => item.key);
|
|
||||||
|
|
||||||
const timestampRows = data
|
|
||||||
.filter((point) => typeof point.ts === "number")
|
|
||||||
.sort((a, b) => Number(a.ts) - Number(b.ts));
|
|
||||||
if (!timestampRows.length) return data;
|
|
||||||
|
|
||||||
const latestLiveTs = [...timestampRows]
|
|
||||||
.reverse()
|
|
||||||
.find((point) => hasNumericValue(point, liveKeys))?.ts as number | undefined;
|
|
||||||
|
|
||||||
const tzOffset = row?.tz_offset_seconds ?? 0;
|
|
||||||
const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10);
|
|
||||||
const currentLocalTs = getCityLocalUtcTimestamp(
|
|
||||||
hourly?.localTime || row?.local_time,
|
|
||||||
tzOffset,
|
|
||||||
localDateStr,
|
|
||||||
);
|
|
||||||
const maxDataTs = Number(timestampRows[timestampRows.length - 1].ts);
|
|
||||||
const anchor = latestLiveTs ?? currentLocalTs ?? maxDataTs;
|
|
||||||
const afterMs = latestLiveTs ? ROLLING_WINDOW_AFTER_LIVE_MS : ROLLING_WINDOW_AFTER_FORECAST_MS;
|
|
||||||
const start = anchor - ROLLING_WINDOW_BEFORE_MS;
|
|
||||||
const end = anchor + afterMs;
|
|
||||||
|
|
||||||
const visible = timestampRows.filter((point) => {
|
|
||||||
const ts = Number(point.ts);
|
|
||||||
if (ts < start || ts > end) return false;
|
|
||||||
return hasNumericValue(point, liveKeys) || hasNumericValue(point, forecastKeys);
|
|
||||||
});
|
|
||||||
|
|
||||||
return visible.length >= 2 ? visible : timestampRows.slice(-120);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Model summary cards (daily high point predictions) ─────────────────
|
// ── Model summary cards (daily high point predictions) ─────────────────
|
||||||
|
|
||||||
function buildModelSummaryCards(row: ScanOpportunityRow | null): EvidenceSeries[] {
|
function buildModelSummaryCards(row: ScanOpportunityRow | null): EvidenceSeries[] {
|
||||||
@@ -707,10 +577,10 @@ function buildMarketTemperatureOptions(row: ScanOpportunityRow | null) {
|
|||||||
function buildChartDomain(
|
function buildChartDomain(
|
||||||
ticks: number[] | null,
|
ticks: number[] | null,
|
||||||
series: EvidenceSeries[],
|
series: EvidenceSeries[],
|
||||||
visibleData?: Array<Record<string, string | number | null>>,
|
data?: Array<Record<string, string | number | null>>,
|
||||||
): [number, number] | ["auto", "auto"] {
|
): [number, number] | ["auto", "auto"] {
|
||||||
const vals = visibleData?.length
|
const vals = data?.length
|
||||||
? visibleData.flatMap((point) => series.map((s) => point[s.key])).filter((v): v is number => validNumber(v) !== null)
|
? data.flatMap((point) => series.map((s) => point[s.key])).filter((v): v is number => validNumber(v) !== null)
|
||||||
: series.flatMap((s) => s.values).filter((v): v is number => validNumber(v) !== null);
|
: series.flatMap((s) => s.values).filter((v): v is number => validNumber(v) !== null);
|
||||||
const all = [...(ticks || []), ...vals];
|
const all = [...(ticks || []), ...vals];
|
||||||
if (!all.length) return ["auto", "auto"];
|
if (!all.length) return ["auto", "auto"];
|
||||||
@@ -803,11 +673,8 @@ export function LiveTemperatureThresholdChart({
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [city]);
|
}, [city]);
|
||||||
|
|
||||||
const { data, series } = useMemo(() => buildSlidingChartData(row, hourly), [row, hourly]);
|
const { data, series } = useMemo(() => buildFullDayChartData(row, hourly), [row, hourly]);
|
||||||
const visibleData = useMemo(
|
const [hiddenSeriesKeys, setHiddenSeriesKeys] = useState<Set<string>>(new Set());
|
||||||
() => buildRollingWindowData(data, series, row, hourly),
|
|
||||||
[data, series, row, hourly],
|
|
||||||
);
|
|
||||||
|
|
||||||
const tzOffset = row?.tz_offset_seconds ?? 0;
|
const tzOffset = row?.tz_offset_seconds ?? 0;
|
||||||
const settlementObs = useMemo(() => {
|
const settlementObs = useMemo(() => {
|
||||||
@@ -818,6 +685,14 @@ export function LiveTemperatureThresholdChart({
|
|||||||
const hasRunwayData = runwayPlates.length > 0;
|
const hasRunwayData = runwayPlates.length > 0;
|
||||||
const settlementPlate = useMemo(() => runwayPlates.find((p) => p.isSettlement), [runwayPlates]);
|
const settlementPlate = useMemo(() => runwayPlates.find((p) => p.isSettlement), [runwayPlates]);
|
||||||
|
|
||||||
|
const chartSeries = useMemo(() => {
|
||||||
|
return series.filter((item) => !hasRunwayData || !item.key.startsWith("model_curve_"));
|
||||||
|
}, [series, hasRunwayData]);
|
||||||
|
|
||||||
|
const activeSeries = useMemo(() => {
|
||||||
|
return chartSeries.filter((s) => !hiddenSeriesKeys.has(s.key));
|
||||||
|
}, [chartSeries, hiddenSeriesKeys]);
|
||||||
|
|
||||||
const cityKey = String(row?.city || "").toLowerCase().trim();
|
const cityKey = String(row?.city || "").toLowerCase().trim();
|
||||||
const runwaySensorCities = new Set([
|
const runwaySensorCities = new Set([
|
||||||
'beijing', 'shanghai', 'guangzhou', 'shenzhen', 'qingdao',
|
'beijing', 'shanghai', 'guangzhou', 'shenzhen', 'qingdao',
|
||||||
@@ -904,8 +779,8 @@ export function LiveTemperatureThresholdChart({
|
|||||||
|
|
||||||
const marketTicks = useMemo(() => buildMarketTemperatureOptions(row), [row]);
|
const marketTicks = useMemo(() => buildMarketTemperatureOptions(row), [row]);
|
||||||
const chartDomain = useMemo(
|
const chartDomain = useMemo(
|
||||||
() => buildChartDomain(marketTicks, series, visibleData),
|
() => buildChartDomain(marketTicks, series, data),
|
||||||
[marketTicks, series, visibleData],
|
[marketTicks, series, data],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1068,15 +943,39 @@ export function LiveTemperatureThresholdChart({
|
|||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Interactive legend */}
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1 px-3 py-1.5 text-[11px] border-b border-slate-200 bg-white">
|
||||||
|
{chartSeries.length > 1 && chartSeries.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setHiddenSeriesKeys((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(s.key)) next.delete(s.key);
|
||||||
|
else next.add(s.key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
"inline-flex items-center gap-1.5 font-mono cursor-pointer transition-opacity hover:opacity-80",
|
||||||
|
hiddenSeriesKeys.has(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>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<ReLineChart data={visibleData} margin={{ top: 16, right: 28, left: 8, bottom: 8 }}>
|
<ReLineChart data={data} margin={{ top: 16, right: 28, left: 8, bottom: 8 }}>
|
||||||
<CartesianGrid stroke="#dbe6ef" strokeDasharray="2 2" />
|
<CartesianGrid stroke="#dbe6ef" strokeDasharray="2 2" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
tick={{ fontSize: 10, fill: "#64748b" }}
|
tick={{ fontSize: 10, fill: "#64748b" }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={{ stroke: "#cbd5e1" }}
|
axisLine={{ stroke: "#cbd5e1" }}
|
||||||
interval={Math.max(1, Math.floor(visibleData.length / 8))}
|
interval={Math.max(1, Math.floor(data.length / 8))}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: 10, fill: "#64748b" }}
|
tick={{ fontSize: 10, fill: "#64748b" }}
|
||||||
@@ -1117,29 +1016,30 @@ export function LiveTemperatureThresholdChart({
|
|||||||
}}
|
}}
|
||||||
formatter={(value: unknown) => `${Number(value).toFixed(2)}°`}
|
formatter={(value: unknown) => `${Number(value).toFixed(2)}°`}
|
||||||
/>
|
/>
|
||||||
<Legend
|
{activeSeries.map((item) => (
|
||||||
verticalAlign="bottom"
|
|
||||||
height={series.length > 5 ? 56 : 36}
|
|
||||||
iconType="plainline"
|
|
||||||
wrapperStyle={{ fontSize: 11 }}
|
|
||||||
/>
|
|
||||||
{series
|
|
||||||
.filter((item) => !hasRunwayData || !item.key.startsWith("model_curve_"))
|
|
||||||
.map((item) => (
|
|
||||||
<Line
|
<Line
|
||||||
key={item.key}
|
key={item.key}
|
||||||
type={item.curve || (item.smooth ? "monotone" : "linear")}
|
type={item.smooth ? "monotone" : "linear"}
|
||||||
dataKey={item.key}
|
dataKey={item.key}
|
||||||
name={item.label}
|
name={item.label}
|
||||||
stroke={item.color}
|
stroke={item.color}
|
||||||
strokeWidth={item.featured ? 2 : 1}
|
strokeWidth={item.featured ? 2 : 1}
|
||||||
strokeDasharray={item.dashed ? "4 3" : undefined}
|
strokeDasharray={item.dashed ? "4 3" : undefined}
|
||||||
dot={item.showDot ? { r: 2.5, fill: item.color } : false}
|
dot={false}
|
||||||
activeDot={{ r: item.featured ? 5 : 4 }}
|
activeDot={{ r: item.featured ? 5 : 4 }}
|
||||||
connectNulls={item.connectNulls ?? true}
|
connectNulls={true}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
<Brush
|
||||||
|
dataKey="label"
|
||||||
|
height={20}
|
||||||
|
stroke="#64748b"
|
||||||
|
fill="#f8fafc"
|
||||||
|
travellerWidth={8}
|
||||||
|
startIndex={0}
|
||||||
|
endIndex={data.length - 1}
|
||||||
|
/>
|
||||||
</ReLineChart>
|
</ReLineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user