删除 Rust 监控项目及旧文档,已用 Python 重写替代

This commit is contained in:
2569718930@qq.com
2026-05-13 23:22:13 +08:00
parent a221b3e969
commit 1493c72138
11 changed files with 0 additions and 2290 deletions
-282
View File
@@ -1,282 +0,0 @@
# 市场监控频道 — Rust 独立网页版
## 架构
```
浏览器 http://VPS:3001
Rust 独立进程 (Axum + Askama + HTMX)
│ 直接读 SQLite
polyweather.db ← Python 采集管道写入
└ airport_obs_log 表
└ deb_predictions 表(今日最高实测)
```
零 Python 依赖。Rust 直接从 SQLite 拿温度数据,自给自足。
## 技术选型
| 层 | 库 | 理由 |
|---|---|---|
| HTTP 服务 | **Axum** 0.8 | Tokio 生态,类型安全路由 |
| 模板引擎 | **Askama** 0.12 | 编译期检查模板,零运行时开销 |
| SQLite | **rusqlite** | 读 airport_obs_log 拿温度趋势 |
| 自动更新 | **HTMX** | 一个 `<script>` 标签,`hx-get` + `hx-trigger` 轮询 |
| 样式 | 手写 CSS | 暗色主题,无框架依赖 |
编译出来 ~10MB 二进制,内存 < 25MB。
## 展示字段(精简)
| 字段 | 来源 | 说明 |
|------|------|------|
| 城市名 + 机场名 | config 写死 | 11 个城市固定映射 |
| 当地时间 | 后台实时计算 | UTC + 时区偏移 |
| 当前温度 | `airport_obs_log` 最近一条 | 大字显示 |
| 今日实测最高 | DB 或配置文件 | 日常刷新 |
| 趋势 | 最近 6 条 obs 线性拟合 | 升 ↑ / 降 ↓ / 平 → |
| 跑道温度 | 首尔/釜山 AMOS 数据 | 有多条跑道时展示 |
| 新高温标记 | 当前 >= 今日最高 + 0.3 | 蓝紫色角标 |
## 页面布局
一屏扫完 11 城。
```
┌──────────────────────────────────────────────────────────────────┐
│ 🔥 市场监控 刷新 14:30:00 │
├──────────────────────────────────────────────────────────────────┤
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ 首尔 / Incheon │ │ 东京 / Haneda │ │ 釜山 / Gimhae │ │
│ │ 13:30 KST │ │ 14:30 JST │ │ 14:30 KST │ │
│ │ │ │ │ │ │ │
│ │ 28.5°C │ │ 31.2°C ◆新高 │ │ 25.8°C ↓ │ │
│ │ 今日最高 29.1°C │ │ 今日最高 31.2°C │ │ 今日最高 26.5°C │ │
│ │ ↑ │ │ ↑ │ │ │ │
│ │ │ │ │ │ │ │
│ │ 18L/36R 28.5°C │ │ │ │ 18L/36R 25.8°C │ │
│ │ 18R/36L 27.9°C │ │ │ │ 18R/36L 25.1°C │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ 安卡拉 / Esenboğa│ │赫尔辛基 / Vantaa │ │阿姆斯特丹/Schiphol│ │
│ │ ... │ │ ... │ │ ... │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │伊斯坦布尔/Airport│ │ 巴黎 /Le Bourget │ │ 香港 / Observatory│ │
│ │ ... │ │ ... │ │ ... │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ 流浮山/LauFauShan│ │ 台北 / Songshan │ │
│ │ ... │ │ ... │ │
│ └──────────────────┘ └──────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
### 单张卡片
```
┌──────────────────────────────┐
│ 首尔 / Incheon │ ← 城市名 / 机场名
│ 13:30 KST │ ← 当地时间
│ │
│ 28.5°C │ ← 大字当前温度
│ │
│ 今日最高 29.1°C ↑ │ ← 最高温 + 趋势箭头
│ │
│ ── 跑道 ── │ ← 首尔/釜山有
│ 18L/36R 28.5°C │
│ 18R/36L 27.9°C │
└──────────────────────────────┘
```
温度较高或触达新高时,温度数字用暖色高亮。趋势指示用箭头(↑ 绿色 / ↓ 蓝色 / → 灰色)。
## 数据源
直接读 SQLite `airport_obs_log` 表:
```sql
SELECT temp_c, obs_time, created_at
FROM airport_obs_log
WHERE icao = ? AND created_at > datetime('now', '-2 hours')
ORDER BY created_at DESC
LIMIT 12;
```
- 最新一条 → 当前温度
- 最近 6 条 → 算趋势(线性拟合斜率)
- 今日最高从 DB 或单独配置拿
11 个城市的 ICAO 映射写死在 Rust config 里(与 telegram_push.py 一致):
```rust
const CITIES: &[(&str, &str, &str, &str, i32)] = &[
("seoul", "首尔", "RKSI", "Incheon", 9),
("busan", "釜山", "RKPK", "Gimhae", 9),
("tokyo", "东京", "44166", "Haneda", 9),
("ankara", "安卡拉", "17128", "Esenboğa", 3),
("helsinki", "赫尔辛基", "EFHK", "Vantaa", 3),
("amsterdam","阿姆斯特丹", "EHAM", "Schiphol", 2),
("istanbul", "伊斯坦布尔", "17058", "Airport", 3),
("paris", "巴黎", "LFPB", "Le Bourget", 2),
("hong kong","香港", "HKO", "Observatory", 8),
("lau fau shan","流浮山", "LFS", "Lau Fau Shan", 8),
("taipei", "台北", "466920", "Songshan", 8),
];
```
## Rust 项目结构
```
monitoring-web/ ← 放在 PolyWeather 仓库根目录下
├── Cargo.toml
├── src/
│ ├── main.rs ← Axum 启动,路由注册
│ ├── db.rs ← SQLite 查询(rusqlite
│ ├── model.rs ← CitySnapshot 数据结构
│ ├── trend.rs ← 温度趋势计算
│ └── templates/
│ └── monitor.html ← Askama 模板(完整页面)
└── static/
└── style.css
```
### `Cargo.toml`
```toml
[package]
name = "market-monitor"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
askama = { version = "0.12", features = ["with-axum"] }
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
tower-http = { version = "0.6", features = ["fs"] }
tracing = "0.1"
tracing-subscriber = "0.3"
chrono = "0.4"
```
### 核心逻辑伪代码
```rust
// main.rs
#[tokio::main]
async fn main() {
let db_path = std::env::var("POLYWEATHER_DB_PATH")
.unwrap_or_else(|_| "data/polyweather.db".into());
let state = Arc::new(AppState { db_path });
let app = Router::new()
.route("/", get(index)) // 完整页面
.route("/api/data", get(cards)) // HTMX 轮询
.nest_service("/static", ServeDir::new("static"))
.with_state(state);
let addr = std::env::var("MONITOR_LISTEN_ADDR")
.unwrap_or_else(|_| "0.0.0.0:3001".into());
tracing::info!("市场监控: http://{}", addr);
axum::serve(TcpListener::bind(&addr).await.unwrap(), app).await.unwrap();
}
// GET / → 渲染完整 HTML 页面
async fn index(state: State<Arc<AppState>>) -> Html<String> {
let cities = load_all_cities(&state.db_path).await;
let tmpl = MonitorTemplate { cities, full_page: true };
Html(tmpl.render().unwrap())
}
// GET /api/data → HTMX 轮询,只返回卡片网格(无完整 HTML 壳)
async fn cards(state: State<Arc<AppState>>) -> Html<String> {
let cities = load_all_cities(&state.db_path).await;
let tmpl = MonitorTemplate { cities, full_page: false };
Html(tmpl.render().unwrap())
}
```
### DB 查询
```rust
// db.rs
fn get_recent_obs(db_path: &str, icao: &str, limit: usize) -> Vec<Obs> {
let conn = Connection::open(db_path)?;
let mut stmt = conn.prepare(
"SELECT temp_c, obs_time, created_at
FROM airport_obs_log
WHERE icao = ?1 AND created_at > datetime('now', '-3 hours')
ORDER BY created_at DESC
LIMIT ?2"
)?;
// ...
}
```
### 趋势计算
```rust
// trend.rs
enum Trend { Rising, Falling, Flat }
fn calc_trend(obs: &[Obs]) -> Trend {
// 最近 6 条温度做线性回归
// 斜率 > +0.2 → Rising (↑)
// 斜率 < -0.2 → Falling (↓)
// 否则 → Flat (→)
}
```
### HTMX 轮询
```html
<!-- monitor.html -->
<div id="card-grid"
hx-get="/api/data"
hx-trigger="every 60s"
hx-swap="outerHTML"
hx-indicator="#spinner">
{% for city in cities %}
<div class="card">
<!-- 卡片内容 -->
</div>
{% endfor %}
</div>
<div id="spinner" class="htmx-indicator">刷新中...</div>
```
HTMX 从 CDN 加载,一个 `<script>` 标签搞定。
## 部署
```bash
cd monitoring-web
cargo build --release
# 环境变量
export POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
export MONITOR_LISTEN_ADDR=0.0.0.0:3001
./target/release/market-monitor &
```
systemd unit 或 supervisor 托管即可。不占资源,VPS 上完全无感。
## 与 Push 的对比
| | Telegram Push | 网页监控 |
|---|---|---|
| 目的 | 精准触达:该不该发消息 | 全景扫描:一眼看 11 城 |
| 逻辑 | DEB + 三条件 + 去重 | 温度 + 趋势 + 最高 |
| 频率 | 条件触发 | 60s 轮询 |
| 数据源 | `_analyze()` 完整分析 | SQLite 直接读 |
| 用户 | 频道订阅者 | 打开网页的任何人 |
两者互补,不冲突。Push 仍然跑,网页给想主动扫一眼的人。
-1
View File
@@ -1 +0,0 @@
target/
-1173
View File
File diff suppressed because it is too large Load Diff
-17
View File
@@ -1,17 +0,0 @@
[package]
name = "market-monitor"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
askama = "0.12"
askama_axum = "0.4"
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower-http = { version = "0.6", features = ["fs"] }
tracing = "0.1"
tracing-subscriber = "0.3"
chrono = "0.4"
-17
View File
@@ -1,17 +0,0 @@
[Unit]
Description=PolyWeather Market Monitor Web
After=network.target
[Service]
Type=simple
WorkingDirectory=/root/PolyWeather/monitoring-web
Environment=POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
Environment=MONITOR_LISTEN_ADDR=0.0.0.0:3001
ExecStart=/root/PolyWeather/monitoring-web/target/release/market-monitor
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
-87
View File
@@ -1,87 +0,0 @@
use rusqlite::Connection;
#[derive(Debug, Clone)]
pub struct ObsRow {
pub temp_c: Option<f64>,
#[allow(dead_code)]
pub obs_time: Option<String>,
pub created_at: Option<String>,
}
/// Get today's running max temperature from intraday snapshots.
pub fn get_daily_max(db_path: &str, city_key: &str) -> Option<(f64, Option<String>)> {
let conn = Connection::open(db_path).ok()?;
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
let mut stmt = conn
.prepare(
"SELECT max_so_far, snapshot_time FROM intraday_path_snapshots_store \
WHERE city = ?1 AND target_date = ?2 \
ORDER BY id DESC LIMIT 1"
)
.ok()?;
stmt.query_row(rusqlite::params![city_key, today], |row| {
Ok((row.get(0)?, row.get(1)?))
})
.ok()
}
/// Get runway-level observations for Seoul/Busan (stored as ICAO_RWY_N).
pub fn get_runway_temps(db_path: &str, icao: &str) -> Vec<(String, f64)> {
let conn = match Connection::open(db_path) {
Ok(c) => c,
Err(_) => return vec![],
};
let pattern = format!("{}_RWY_%", icao.to_uppercase());
let mut stmt = match conn.prepare(
"SELECT icao, temp_c FROM airport_obs_log \
WHERE icao LIKE ?1 AND created_at > datetime('now', '-120 minutes') \
ORDER BY icao, created_at DESC"
) {
Ok(s) => s,
Err(_) => return vec![],
};
let mut results: Vec<(String, f64)> = vec![];
let rows = stmt.query_map(rusqlite::params![pattern], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?))
});
if let Ok(iter) = rows {
let mut seen = std::collections::HashSet::new();
for r in iter.filter_map(|r| r.ok()) {
// Take only the first (most recent) per unique icao
if seen.insert(r.0.clone()) {
results.push(r);
}
}
}
results
}
/// Get recent temperature observations for an ICAO station.
pub fn get_recent_obs(db_path: &str, icao: &str, minutes: i32, limit: usize) -> Vec<ObsRow> {
let conn = match Connection::open(db_path) {
Ok(c) => c,
Err(_) => return vec![],
};
let sql = format!(
"SELECT temp_c, obs_time, created_at FROM airport_obs_log \
WHERE icao = ?1 AND created_at > datetime('now', '{} minutes') \
ORDER BY created_at DESC LIMIT ?2",
-minutes
);
let mut stmt = match conn.prepare(&sql) {
Ok(s) => s,
Err(_) => return vec![],
};
let rows = stmt
.query_map(rusqlite::params![icao.to_uppercase(), limit as i64], |row| {
Ok(ObsRow {
temp_c: row.get(0)?,
obs_time: row.get(1)?,
created_at: row.get(2)?,
})
})
.ok()
.map(|iter| iter.filter_map(|r| r.ok()).collect())
.unwrap_or_default();
rows
}
-233
View File
@@ -1,233 +0,0 @@
mod db;
mod model;
mod trend;
use std::sync::Arc;
use askama_axum::Template;
use axum::{
extract::State,
response::{Html, IntoResponse},
routing::get,
Router,
};
use chrono::Utc;
use model::CitySnapshot;
use tower_http::services::ServeDir;
use tracing_subscriber;
// ── city config ──
// (key, zh_name, en_name, icao, airport_en, utc_offset_hours, threshold, tz_abbr, runway_count)
const CITIES: &[(&str, &str, &str, &str, &str, i32, f64, &str, usize)] = &[
("seoul", "首尔", "Seoul", "RKSI", "Incheon", 9, 3.0, "KST", 2),
("busan", "釜山", "Busan", "RKPK", "Gimhae", 9, 2.0, "KST", 2),
("tokyo", "东京", "Tokyo", "44166", "Haneda", 9, 2.0, "JST", 0),
("ankara", "安卡拉", "Ankara", "17128", "Esenboğa", 3, 3.0, "TRT", 0),
("helsinki", "赫尔辛基", "Helsinki", "EFHK", "Vantaa", 3, 2.0, "EEST", 0),
("amsterdam","阿姆斯特丹","Amsterdam","EHAM","Schiphol",2,2.0,"CEST", 0),
("istanbul","伊斯坦布尔","Istanbul","17058","Airport",3,3.0,"TRT", 0),
("paris", "巴黎", "Paris", "LFPB", "Le Bourget", 2, 3.0, "CEST", 0),
("hong kong","香港","Hong Kong","HKO","Observatory",8,1.5,"HKT", 0),
("lau fau shan","流浮山","Lau Fau Shan","LFS","Lau Fau Shan",8,1.5,"HKT", 0),
("taipei", "台北", "Taipei", "466920", "Songshan", 8, 1.5, "TST", 0),
];
// ── app state ──
struct AppState {
db_path: String,
}
// ── templates ──
#[derive(Template)]
#[template(path = "monitor.html")]
struct MonitorTemplate {
cities: Vec<CitySnapshot>,
full_page: bool,
generated_at: String,
}
// ── obs_time parsing ──
fn parse_obs_time(raw: &str) -> Option<chrono::DateTime<Utc>> {
let s = raw.trim();
// ISO with timezone: "2026-05-13T14:30:00+08:00" or "2026-05-13T14:30:00Z"
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
return Some(dt.into());
}
// ISO naive: "2026-05-13T14:30:00"
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
return Some(dt.and_utc());
}
// ISO with microseconds: "2026-05-13T14:30:00.123456"
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") {
return Some(dt.and_utc());
}
// Epoch seconds (METAR cluster)
if let Ok(secs) = s.parse::<f64>() {
if (1_000_000_000.0..3_000_000_000.0).contains(&secs) {
return chrono::DateTime::from_timestamp(secs as i64, 0);
}
if (100_000.0..1_000_000_000.0).contains(&secs) {
return chrono::DateTime::from_timestamp(secs as i64, 0);
}
}
None
}
// ── data loading ──
fn load_city_snapshot(db_path: &str, _idx: usize, (key, _zh, en, icao, airport, tz, thresh, tz_abbr, rw_count): &(&str, &str, &str, &str, &str, i32, f64, &str, usize)) -> CitySnapshot {
let now_utc = Utc::now();
let offset = chrono::FixedOffset::east_opt(*tz * 3600).unwrap();
// Recent obs for temp + trend
let obs = db::get_recent_obs(db_path, icao, 120, 12);
let temps: Vec<f64> = obs.iter().filter_map(|o| o.temp_c).collect();
let current_temp = temps.first().copied();
// Observation time from the most recent record's obs_time field
let local_time = obs.first().and_then(|o| {
o.obs_time.as_ref().and_then(|raw| parse_obs_time(raw))
})
.or_else(|| {
// Fallback: parse created_at
obs.first().and_then(|o| {
o.created_at.as_ref().and_then(|ts| {
chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%d %H:%M:%S").ok()
.map(|dt| dt.and_utc())
})
})
})
.map(|obs_utc| {
let local = obs_utc.with_timezone(&offset);
format!("{} {}", local.format("%H:%M"), tz_abbr)
})
.unwrap_or_else(|| {
let local = now_utc + chrono::Duration::hours(*tz as i64);
format!("{} {}", local.format("%H:%M"), tz_abbr)
});
// Age of most recent observation
let obs_age_min = obs.first().and_then(|o| {
o.created_at.as_ref().and_then(|ts| {
chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%d %H:%M:%S")
.ok()
.map(|dt| {
let obs_utc = dt.and_utc();
(now_utc - obs_utc).num_minutes().max(0)
})
})
});
// Trend from last 6 points
let trend_data: Vec<f64> = temps.iter().take(6).copied().collect();
let trend = trend::calc_trend(&trend_data);
// Today's max: from intraday_path_snapshots_store (实时更新)
let (today_max, max_time) = db::get_daily_max(db_path, key)
.map(|(t, mt)| (Some(t), mt))
.unwrap_or((None, None));
let new_high = match (current_temp, today_max) {
(Some(ct), Some(tm)) => ct >= tm + 0.3,
_ => false,
};
// Runway data: query all ICAO_RWY_* for this airport
let runway_pairs: Vec<(String, f64)> = if *rw_count > 0 {
db::get_runway_temps(db_path, icao)
.into_iter()
.map(|(rw_icao, temp)| {
let label = rw_icao.strip_prefix(&format!("{}_RWY_", icao.to_uppercase()))
.map(|n| format!("RWY {}", n))
.unwrap_or_else(|| rw_icao);
(label, temp)
})
.collect()
} else {
vec![]
};
CitySnapshot {
name: key.to_string(),
en_name: en.to_string(),
airport: airport.to_string(),
icao: icao.to_string(),
local_time,
current_temp,
today_max,
max_time,
trend,
new_high,
runway_pairs,
gap: None,
threshold: *thresh,
time_ok: false,
temp_ok: false,
trend_ok: false,
in_window: false,
obs_age_min,
temp_warm: current_temp.map_or(false, |t| t >= 30.0),
}
}
fn load_all_cities(db_path: &str) -> Vec<CitySnapshot> {
let mut cities: Vec<CitySnapshot> = CITIES
.iter()
.enumerate()
.map(|(i, c)| load_city_snapshot(db_path, i, c))
.collect();
// 按当前温度从高到低排序,无数据的排最后
cities.sort_by(|a, b| {
b.current_temp
.partial_cmp(&a.current_temp)
.unwrap_or(std::cmp::Ordering::Less)
});
cities
}
// ── routes ──
async fn index(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let cities = load_all_cities(&state.db_path);
let tmpl = MonitorTemplate {
cities,
full_page: true,
generated_at: Utc::now().format("%H:%M:%S UTC").to_string(),
};
Html(tmpl.render().unwrap_or_else(|e| format!("Template error: {e}")))
}
async fn api_data(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let cities = load_all_cities(&state.db_path);
let tmpl = MonitorTemplate {
cities,
full_page: false,
generated_at: Utc::now().format("%H:%M:%S UTC").to_string(),
};
Html(tmpl.render().unwrap_or_else(|e| format!("Template error: {e}")))
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let db_path = std::env::var("POLYWEATHER_DB_PATH")
.unwrap_or_else(|_| "/var/lib/polyweather/polyweather.db".into());
let listen = std::env::var("MONITOR_LISTEN_ADDR")
.unwrap_or_else(|_| "0.0.0.0:3001".into());
tracing::info!("DB path: {db_path}");
tracing::info!("市场监控页面: http://{listen}");
let state = Arc::new(AppState { db_path });
let app = Router::new()
.route("/", get(index))
.route("/api/data", get(api_data))
.nest_service("/static", ServeDir::new("static"))
.with_state(state);
let listener = tokio::net::TcpListener::bind(&listen).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
-53
View File
@@ -1,53 +0,0 @@
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct CitySnapshot {
pub name: String,
pub en_name: String,
pub airport: String,
pub icao: String,
pub local_time: String,
pub current_temp: Option<f64>,
pub today_max: Option<f64>,
pub max_time: Option<String>,
pub trend: Trend,
pub new_high: bool,
pub runway_pairs: Vec<(String, f64)>,
pub gap: Option<f64>,
pub threshold: f64,
pub time_ok: bool,
pub temp_ok: bool,
pub trend_ok: bool,
pub in_window: bool,
pub obs_age_min: Option<i64>,
pub temp_warm: bool,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Trend {
Rising,
Falling,
Flat,
Unknown,
}
impl Trend {
pub fn symbol(&self) -> &str {
match self {
Trend::Rising => "",
Trend::Falling => "",
Trend::Flat => "",
Trend::Unknown => "",
}
}
pub fn css_class(&self) -> &str {
match self {
Trend::Rising => "rising",
Trend::Falling => "falling",
Trend::Flat => "flat",
Trend::Unknown => "",
}
}
}
-36
View File
@@ -1,36 +0,0 @@
use crate::model::Trend;
/// Calculate temperature trend from recent observations.
/// Uses linear regression slope. Observations are newest-first.
pub fn calc_trend(temps: &[f64]) -> Trend {
let n = temps.len();
if n < 4 {
return Trend::Unknown;
}
// Reverse to oldest-first for regression
let values: Vec<f64> = temps.iter().rev().copied().collect();
let n_f = n as f64;
let mean_x = (n_f - 1.0) / 2.0;
let mean_y = values.iter().sum::<f64>() / n_f;
let mut num = 0.0;
let mut den = 0.0;
for (i, &y) in values.iter().enumerate() {
let x = i as f64;
num += (x - mean_x) * (y - mean_y);
den += (x - mean_x) * (x - mean_x);
}
if den == 0.0 {
return Trend::Flat;
}
let slope = num / den;
// Slope is °C per observation interval (~10 min per obs from METAR cluster)
// Threshold: > +0.2 → rising, < -0.2 → falling
if slope > 0.2 {
Trend::Rising
} else if slope < -0.2 {
Trend::Falling
} else {
Trend::Flat
}
}
-213
View File
@@ -1,213 +0,0 @@
* { margin:0; padding:0; box-sizing:border-box; }
body {
background: #0f1117;
color: #c8cdd4;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
min-height: 100vh;
}
.page {
max-width: 1500px;
margin: 0 auto;
padding: 24px 28px;
}
.header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 28px;
padding-bottom: 14px;
border-bottom: 1px solid #1e2130;
}
.header h1 {
font-size: 24px;
font-weight: 600;
color: #e8eaed;
letter-spacing: 0.02em;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.notify-btn {
background: none;
border: 1px solid #2a2e40;
border-radius: 6px;
padding: 3px 8px;
font-size: 16px;
cursor: pointer;
transition: border-color 0.2s;
}
.notify-btn:hover { border-color: #4a5160; }
.notify-btn.muted { opacity: 0.4; }
/* ── grid ── */
.card-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
}
@media (max-width: 1100px) { .card-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 600px) { .card-grid { grid-template-columns: 1fr; } }
/* ── card ── */
.card {
background: #161822;
border: 1px solid #1e2130;
border-radius: 12px;
padding: 22px 26px;
transition: border-color 0.3s, box-shadow 0.3s;
position: relative;
}
.card:hover {
border-color: #2a2e40;
}
/* ── card-top ── */
.card-top {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
font-size: 15px;
flex-wrap: wrap;
}
.city-name {
color: #e0e3e8;
font-weight: 700;
}
.airport {
color: #6a7180;
font-weight: 400;
}
.local-time {
margin-left: auto;
color: #4a5160;
font-variant-numeric: tabular-nums;
font-size: 14px;
}
.badge {
font-size: 12px;
padding: 2px 7px;
border-radius: 4px;
font-weight: 600;
}
.new-high {
background: rgba(124, 58, 237, 0.18);
color: #a78bfa;
}
.new-high-card {
border-color: rgba(124, 58, 237, 0.30);
}
/* ── temp ── */
.card-temp {
margin: 8px 0 10px;
font-weight: 700;
line-height: 1.15;
}
.temp-value {
font-size: 52px;
color: #e8eaed;
letter-spacing: -0.03em;
}
.temp-value.na {
color: #3a4050;
font-size: 32px;
}
.temp-value.warm {
color: #f59e0b;
}
.temp-value.new-high-val {
color: #c084fc;
}
.temp-unit {
font-size: 22px;
color: #5a6170;
margin-left: 3px;
}
/* ── meta ── */
.card-meta {
display: flex;
flex-direction: column;
gap: 5px;
margin-bottom: 2px;
}
.meta-row {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 14px;
}
.meta-row .label {
color: #4a5160;
}
.meta-row .value {
color: #9aa0b0;
font-variant-numeric: tabular-nums;
}
.meta-row .value.na {
color: #3a4050;
}
.meta-row .trend {
margin-left: auto;
}
/* ── trend ── */
.trend { font-size: 18px; font-weight: 700; }
.trend.rising { color: #34d399; }
.trend.falling { color: #60a5fa; }
.trend.flat { color: #5a6170; }
/* ── runway ── */
.card-runway { margin-top: 12px; }
.runway-divider {
height: 1px;
background: #1e2130;
margin-bottom: 8px;
}
.runway-row {
display: flex;
justify-content: space-between;
font-size: 13px;
margin-bottom: 2px;
}
.runway-label { color: #4a5160; }
.runway-temp { color: #7a8290; font-variant-numeric: tabular-nums; }
/* ── obs age ── */
.obs-age { font-size: 13px; color: #5a6170; }
.max-time { font-size: 12px; color: #4a5160; margin-left: 2px; }
/* ── HTMX indicator ── */
.htmx-indicator {
text-align: center;
padding: 14px;
font-size: 14px;
color: #3a4050;
display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator { display: block; }
-178
View File
@@ -1,178 +0,0 @@
{% if full_page %}
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Market Monitor — PolyWeather</title>
<link rel="stylesheet" href="/static/style.css">
<meta http-equiv="refresh" content="120">
</head>
<body>
<div class="page">
<header class="header">
<h1>🔥 Market Monitor</h1>
<div class="header-right">
<button id="notify-toggle" class="notify-btn" onclick="toggleNotify()" title="新高提醒">
<span id="notify-icon">🔔</span>
</button>
</div>
</header>
{% endif %}
<div id="card-grid" class="card-grid" hx-get="/api/data" hx-trigger="every 30s" hx-swap="outerHTML"
hx-indicator="#spinner">
<span class="updated" style="display:block;grid-column:1/-1;text-align:right;margin-bottom:2px">
{{ generated_at }}</span>
{% for c in cities %}
<div class="card{% if c.new_high %} new-high-card{% endif %}" data-new-high="{{ c.new_high }}">
<div class="card-top">
<span class="city-name">{{ c.en_name }}</span>
<span class="airport">/ {{ c.airport }}</span>
<span class="local-time">{{ c.local_time }}</span>
</div>
<div class="card-temp">
{% match c.current_temp %}
{% when Some with (t) %}
<span class="temp-value{% if c.temp_warm %} warm{% endif %}{% if c.new_high %} new-high-val{% endif %}">{{
"{:.1}"|format(t) }}</span><span class="temp-unit">°C</span>
{% when None %}
<span class="temp-value na">--</span>
{% endmatch %}
</div>
<div class="card-meta">
<div class="meta-row">
<span class="label">High</span>
{% match c.today_max %}
{% when Some with (m) %}
<span class="value">{{ "{:.1}"|format(m) }}°C</span>
{% match c.max_time %}
{% when Some with (mt) %}
<span class="value max-time">{{ mt }}</span>
{% when None %}
{% endmatch %}
{% when None %}
<span class="value na">--</span>
{% endmatch %}
<span class="trend {{ c.trend.css_class() }}">{{ c.trend.symbol() }}</span>
</div>
<div class="meta-row">
<span class="label">Obs</span>
{% match c.obs_age_min %}
{% when Some with (m) %}
<span class="value obs-age">{{ m }} min ago</span>
{% when None %}
<span class="value na">--</span>
{% endmatch %}
<span class="trend {{ c.trend.css_class() }}">{{ c.trend.symbol() }}</span>
</div>
</div>
{% if !c.runway_pairs.is_empty() %}
<div class="card-runway">
<div class="runway-divider"></div>
{% for (label, temp) in c.runway_pairs %}
<div class="runway-row">
<span class="runway-label">{{ label }}</span>
<span class="runway-temp">{{ "{:.1}"|format(temp) }}°C</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
<div id="spinner" class="htmx-indicator">Refreshing…</div>
{% if full_page %}
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script>
// ── Notification ──
var NOTIFY_ENABLED = localStorage.getItem('monitor_notify') !== 'off';
var NOTIFIED_HIGHS = JSON.parse(localStorage.getItem('monitor_notified_highs') || '{}');
function toggleNotify() {
NOTIFY_ENABLED = !NOTIFY_ENABLED;
localStorage.setItem('monitor_notify', NOTIFY_ENABLED ? 'on' : 'off');
updateNotifyIcon();
if (NOTIFY_ENABLED && Notification.permission === 'default') {
Notification.requestPermission();
}
}
function updateNotifyIcon() {
var icon = document.getElementById('notify-icon');
var btn = document.getElementById('notify-toggle');
if (NOTIFY_ENABLED) {
icon.textContent = '🔔';
btn.classList.remove('muted');
} else {
icon.textContent = '🔕';
btn.classList.add('muted');
}
}
function fireNotification(enName, temp) {
if (!NOTIFY_ENABLED) return;
if (Notification.permission !== 'granted') return;
var key = enName + '|' + temp;
var today = new Date().toDateString();
// Reset notified list on new day
if (NOTIFIED_HIGHS._day !== today) {
NOTIFIED_HIGHS = { _day: today };
}
if (NOTIFIED_HIGHS[key]) return; // already notified
NOTIFIED_HIGHS[key] = true;
localStorage.setItem('monitor_notified_highs', JSON.stringify(NOTIFIED_HIGHS));
new Notification('🔴 New High — ' + enName, {
body: temp + '°C\nNew daily high.',
icon: '/static/favicon.png',
tag: key,
requireInteraction: true,
});
}
updateNotifyIcon();
// ── Flash on temp change + notifications ──
(function () {
let prev = {};
document.body.addEventListener('htmx:afterSwap', function (evt) {
if (evt.detail.target.id !== 'card-grid') return;
document.querySelectorAll('.card').forEach(function (card) {
var name = card.querySelector('.city-name').textContent;
var tv = card.querySelector('.temp-value').textContent;
var old = prev[name];
prev[name] = tv;
// Flash if changed
if (old && old !== tv && tv !== '--') {
card.style.transition = 'none';
card.style.boxShadow = '0 0 14px rgba(52,211,153,0.40)';
requestAnimationFrame(function () {
card.style.transition = 'box-shadow 2s ease-out';
card.style.boxShadow = '';
});
}
// Notification for new high
if (card.dataset.newHigh === 'true') {
fireNotification(name, tv);
}
});
});
})();
</script>
</div>
</body>
</html>
{% endif %}