新增实时滚动温度走势图:API端点+前端组件

后端 city_realtime_stream.py:循环缓冲区(deque maxlen=1440),best_temp() METAR优先。路由 /api/city/{name}/realtime-stream 返回 {points, thresholds}。前端 RealtimeScrollChart:每30秒轮询,一条温度线+多条阈值横线,横轴随时间推进。
This commit is contained in:
2569718930@qq.com
2026-05-25 06:43:42 +08:00
parent 414708794d
commit 9978ac3e01
4 changed files with 299 additions and 3 deletions
@@ -341,12 +341,20 @@ export function LiveTemperatureThresholdChart({
isEn: boolean;
row: ScanOpportunityRow | null;
}) {
const hourlyCache = new Map<string, { ts: number; data: HourlyForecast }>();
const HOURLY_CACHE_TTL_MS = 30 * 60 * 1000; // 30 min
const [hourly, setHourly] = useState<HourlyForecast>(null);
const city = String(row?.city || "").toLowerCase().trim();
useEffect(() => {
setHourly(null);
if (!city) return;
const cached = hourlyCache.get(city);
if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) {
setHourly(cached.data);
return;
}
setHourly(null);
let cancelled = false;
fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=panel&force_refresh=false`, {
cache: "no-store",
@@ -358,13 +366,15 @@ export function LiveTemperatureThresholdChart({
})
.then((json) => {
if (cancelled || !json?.hourly) return;
setHourly({
const data: HourlyForecast = {
forecastTodayHigh: json.forecast?.today_high ?? null,
localTime: json.local_time || null,
times: json.hourly.times || [],
temps: json.hourly.temps || [],
modelCurves: json.models_hourly?.curves || undefined,
});
};
hourlyCache.set(city, { ts: Date.now(), data });
setHourly(data);
})
.catch(() => {});
return () => { cancelled = true; };
@@ -0,0 +1,160 @@
"use client";
import { useEffect, useState } from "react";
import {
CartesianGrid,
Line,
LineChart as ReLineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Panel } from "@/components/dashboard/scan-terminal/Panel";
type StreamPoint = { timestamp: string; temp: number; source: string };
type Threshold = { label: string; threshold_c: number; breached: boolean };
type StreamPayload = {
points: StreamPoint[];
thresholds: Threshold[];
};
const POLL_INTERVAL_MS = 30_000;
export function RealtimeScrollChart({
city,
isEn,
}: {
city: string;
isEn: boolean;
}) {
const [payload, setPayload] = useState<StreamPayload>({ points: [], thresholds: [] });
useEffect(() => {
if (!city) return;
let cancelled = false;
const fetchStream = () => {
fetch(`/api/city/${encodeURIComponent(city)}/realtime-stream`, {
cache: "no-store",
headers: { Accept: "application/json" },
})
.then(async (res) => {
if (!res.ok) return null;
return res.json() as Promise<StreamPayload>;
})
.then((data) => {
if (cancelled || !data) return;
setPayload(data);
})
.catch(() => {});
};
fetchStream();
const interval = setInterval(fetchStream, POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [city]);
const { points, thresholds } = payload;
const latestTemp = points.length ? points[points.length - 1].temp : null;
const breached = thresholds.filter((t) => t.breached);
const domainMin = thresholds.length
? Math.min(...thresholds.map((t) => t.threshold_c)) - 2
: "auto";
const domainMax = thresholds.length
? Math.max(...thresholds.map((t) => t.threshold_c)) + 2
: "auto";
return (
<Panel title={isEn ? "Realtime Scrolling Temperature" : "实时滚动温度"}>
<div className="flex h-full min-h-[300px] flex-col">
{/* Status bar */}
<div className="shrink-0 flex items-center gap-4 border-b border-slate-200 bg-white px-3 py-1.5 text-[10px]">
<span className="font-black text-slate-600">
{isEn ? "Latest" : "最新"}:{" "}
<span className="font-mono text-teal-700">
{latestTemp !== null ? `${latestTemp.toFixed(1)}°` : "--"}
</span>
</span>
<span className="text-slate-400">
{isEn ? "Points" : "数据点"}: {points.length}
</span>
{breached.length > 0 && (
<span className="font-black text-amber-600">
{isEn ? "Breached" : "已触发"}: {breached.map((t) => t.label).join(", ")}
</span>
)}
</div>
{/* Chart */}
<div className="min-h-0 flex-1 p-2">
{points.length < 2 ? (
<div className="flex h-full items-center justify-center text-xs text-slate-400">
{isEn ? "Collecting data..." : "数据采集中..."}
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<ReLineChart data={points} margin={{ top: 8, right: 24, left: 0, bottom: 0 }}>
<CartesianGrid stroke="#e2e8f0" strokeDasharray="2 2" />
<XAxis
dataKey="timestamp"
tick={{ fontSize: 10, fill: "#94a3b8" }}
tickLine={false}
axisLine={{ stroke: "#cbd5e1" }}
interval={Math.max(1, Math.floor(points.length / 6))}
/>
<YAxis
tick={{ fontSize: 10, fill: "#64748b" }}
tickFormatter={(v) => `${Number(v).toFixed(1)}°`}
axisLine={{ stroke: "#cbd5e1" }}
tickLine={false}
domain={[domainMin, domainMax]}
width={40}
/>
<Tooltip
contentStyle={{
border: "1px solid #cbd5e1",
borderRadius: 4,
fontSize: 11,
}}
formatter={(value: unknown) => [`${Number(value).toFixed(2)}°`, "Temp"]}
labelFormatter={(label) => `${label}`}
/>
{/* Temperature line */}
<Line
type="linear"
dataKey="temp"
stroke="#009688"
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
{/* Threshold lines */}
{thresholds.map((t) => (
<ReferenceLine
key={t.label}
y={t.threshold_c}
stroke={t.breached ? "#f97316" : "#94a3b8"}
strokeDasharray="4 4"
strokeWidth={1}
label={{
value: t.label,
fill: t.breached ? "#f97316" : "#94a3b8",
fontSize: 9,
position: "right",
}}
/>
))}
</ReLineChart>
</ResponsiveContainer>
)}
</div>
</div>
</Panel>
);
}
+8
View File
@@ -11,6 +11,7 @@ from web.services.city_api import (
get_city_summary_payload,
list_cities_payload,
)
from web.services.city_realtime_stream import get_realtime_stream_payload
router = APIRouter(tags=["city"])
@@ -208,3 +209,10 @@ async def city_holders(
"available": True,
"condition_id": condition_id,
}
@router.get("/api/city/{name}/realtime-stream")
async def city_realtime_stream(name: str):
"""Return a rolling window of recent temperature readings + market
threshold lines for the scrolling realtime chart."""
return get_realtime_stream_payload(name)
+118
View File
@@ -0,0 +1,118 @@
"""Lightweight realtime temperature stream for scrolling chart.
Maintains per-city deque buffers (max 1440 points) fed by _analyze()
refreshes. The /api/city/{name}/realtime-stream endpoint reads from
these buffers and returns a simple {points, thresholds} payload that
the frontend RealtimeScrollChart polls every 30 seconds.
"""
from __future__ import annotations
import collections
import threading
import time
from typing import Any, Dict, List, Optional
from web.analysis_service import _analyze
from web.core import CITIES
# Per-city ring buffers: city_name → deque of {timestamp, temp, source}
_STREAM_BUFFERS: Dict[str, collections.deque] = {}
_BUFFER_LOCK = threading.Lock()
_MAXLEN = 1440
def _best_temp(data: Dict[str, Any]) -> Optional[float]:
"""METAR-first, then runway sensor, then settlement current."""
airport = data.get("airport_current") or {}
t = airport.get("current", {}).get("temp") if isinstance(airport, dict) else None
if t is not None:
return float(t)
# AMOS / runway sensor
amos = data.get("amos") or {}
if isinstance(amos, dict):
runway_obs = amos.get("runway_obs") or {}
temps = runway_obs.get("temperatures") if isinstance(runway_obs, dict) else []
if isinstance(temps, list) and temps:
for pair in temps:
vals = pair if isinstance(pair, list) else []
for v in vals:
try:
if v is not None:
return float(v)
except (TypeError, ValueError):
continue
# Settlement source
curr = data.get("current") or {}
if isinstance(curr, dict) and curr.get("temp") is not None:
return float(curr["temp"])
return None
def _extract_thresholds(data: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Extract threshold lines from market data."""
thresholds: List[Dict[str, Any]] = []
dist = (data.get("probabilities") or {}).get("distribution") or []
current_temp = _best_temp(data)
for bucket in dist:
if not isinstance(bucket, dict):
continue
temp_val = bucket.get("temp")
if temp_val is None:
continue
try:
t = float(temp_val)
except (TypeError, ValueError):
continue
label = str(bucket.get("label") or f"{t}°C")
thresholds.append({
"label": label,
"threshold_c": t,
"breached": current_temp is not None and current_temp >= t,
})
# Sort by temperature ascending
thresholds.sort(key=lambda x: float(x["threshold_c"]))
return thresholds
def capture_sample(city: str) -> None:
"""Record one sample for *city* into its ring buffer."""
try:
data = _analyze(city, force_refresh=False, detail_mode="panel")
except Exception:
return
temp = _best_temp(data)
if temp is None:
return
ts = time.strftime("%H:%M:%S")
point = {"timestamp": ts, "temp": round(temp, 1), "source": "metar"}
with _BUFFER_LOCK:
buf = _STREAM_BUFFERS.get(city)
if buf is None:
buf = collections.deque(maxlen=_MAXLEN)
_STREAM_BUFFERS[city] = buf
buf.append(point)
def get_realtime_stream_payload(city: str) -> Dict[str, Any]:
"""Return {points, thresholds} for the scrolling chart."""
# Capture a fresh sample
capture_sample(city)
with _BUFFER_LOCK:
buf = _STREAM_BUFFERS.get(city)
points = list(buf) if buf else []
# Build thresholds from cached analysis
try:
data = _analyze(city, force_refresh=False, detail_mode="panel")
thresholds = _extract_thresholds(data)
except Exception:
thresholds = []
return {"points": points, "thresholds": thresholds}