新增实时滚动温度走势图: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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user