feat: weather graphs

This commit is contained in:
Mark
2026-06-04 12:06:22 +02:00
commit 0f7d3a2a83
14 changed files with 25296 additions and 0 deletions

515
src/main.rs Normal file
View File

@@ -0,0 +1,515 @@
mod data;
mod dwd;
mod location_string;
use std::{borrow::Cow, collections::HashMap, time::Duration};
use chrono::{DateTime, Local, NaiveDate, NaiveTime};
use rocket::{
get,
http::{Accept, Status},
response::content::{RawHtml, RawJson},
routes,
};
use serde::Serialize;
use tokio::sync::Mutex;
use crate::{
data::Data,
dwd::{DwdStation, ForecastData, ForecastDatas, TimeStepper},
location_string::LocationString,
};
const WEATHER_MAX_STATIONS: usize = 100;
type State = rocket::State<Mutex<Data>>;
const HTML_START: &str = include_str!("start.html");
const HTML_END: &str = include_str!("end.html");
#[rocket::launch]
fn rocket() -> _ {
rocket::build()
.manage(Mutex::new(crate::data::Data::new()))
.mount("/", routes![index, weather_index, weather_overview])
}
type Page = Result<Result<RawHtml<String>, String>, Result<RawJson<String>, (Status, String)>>;
fn html(title: &str, str: &str) -> Page {
Ok(Ok(RawHtml(
HTML_START.replace("%TITLE%", title) + str + HTML_END,
)))
}
fn term(str: String) -> Page {
Ok(Err(str))
}
fn json<T: Serialize>(json: T) -> Page {
Err(Ok(RawJson(serde_json::to_string(&json).unwrap())))
}
#[get("/")]
async fn index() -> Page {
html("getenv", include_str!("index.html"))
}
fn dist2(a: &DwdStation, lat: f64, lon: f64) -> f64 {
let ax = (a.pos.0 - lat).abs();
let ay = (a.pos.1 - lon).abs();
ax * ax + ay * ay
}
#[get("/weather?<q>")]
async fn weather_index(q: Option<&str>, data: &State) -> Page {
let mut body = r#"<form action="." method="get"><input name="q" type="text"></form>
<div id="stations">
"#
.to_owned();
#[allow(clippy::type_complexity)]
fn for_station<'a>(
prev: &mut Option<(&'a str, &'a str, usize, (f64, f64, f64))>,
station: Option<(&'a str, &'a DwdStation)>,
body: &mut String,
) {
if let Some((prev_key, prev_name, count, pos)) = prev {
if station.is_none_or(|(next_key, _)| next_key != *prev_key) {
*body += &format!("<div><code>[{count}]</code> ");
*body += r#"<a href="./"#;
html_escape::encode_double_quoted_attribute_to_string(
url_escape::encode_component(prev_name),
body,
);
*body += r#"/">"#;
html_escape::encode_safe_to_string(prev_name, body);
*body += r#"</a> <code><a href="./?q="#;
let pos0 = pos.0.to_string();
let pos1 = pos.1.to_string();
url_escape::encode_component_to_string(
format!(
"{}{pos0}{}{pos1}",
if pos0.starts_with('-') { "" } else { "+" },
if pos1.starts_with('-') { "" } else { "+" },
),
body,
);
*body += &format!(r#"">{}m</a></code>"#, pos.2);
*body += "</div>";
*prev = station
.as_ref()
.map(|(name, station)| (*name, station.name.as_str(), 1, station.pos));
} else {
*count += 1;
}
} else if let Some((name, station)) = station {
*prev = Some((name, station.name.as_str(), 1, station.pos));
}
}
if let Some(q) = q.map(|q| q.trim()).filter(|q| !q.is_empty()) {
if let Some(q) = location_string::parse(q) {
let mut prev = None;
let lock = data.lock().await;
match q {
LocationString::Name(q) => {
let q1 = (q.to_lowercase(), 0);
let mut q2 = (q1.0.clone(), usize::MAX);
q2.0.push(char::MAX);
for ((name, _), station) in lock.dwd_stations.range(q1..=q2) {
for_station(&mut prev, Some((name.as_str(), station)), &mut body);
}
}
LocationString::Station(q) => {
for ((name, _), station) in lock
.dwd_stations
.iter()
.filter(|(_, station)| q.contains(&station.station))
{
for_station(&mut prev, Some((name.as_str(), station)), &mut body);
}
}
LocationString::Coords(lat, lon) => {
let mut list = lock.dwd_stations.iter().collect::<Vec<_>>();
list.sort_by(|(_, a), (_, b)| {
dist2(a, lat, lon).total_cmp(&dist2(b, lat, lon))
});
for ((name, _), station) in list.into_iter().take(100) {
for_station(&mut prev, Some((name.as_str(), station)), &mut body);
}
}
}
for_station(&mut prev, None, &mut body);
} else {
return Err(Err((
Status::BadRequest,
"invalid query, should either be =id1,id2, +lat+lon, or (the start of) a station name"
.to_owned(),
)));
}
}
body += "</div>";
html("getenv weather", &body)
}
enum DocFormat {
Html,
Text,
Json,
}
fn prefer_format(accept: &Accept) -> DocFormat {
let weight_html = accept
.iter()
.filter(|t| t.is_html() && !t.is_any())
.map(|t| t.weight_or(1.0))
.max_by(|a, b| a.total_cmp(b));
let weight_plain = accept
.iter()
.filter(|t| t.is_plain() && !t.is_any())
.map(|t| t.weight_or(1.0))
.max_by(|a, b| a.total_cmp(b));
let weight_json = accept
.iter()
.filter(|t| t.is_json() && !t.is_any())
.map(|t| t.weight_or(1.0))
.max_by(|a, b| a.total_cmp(b));
let max = [weight_html, weight_plain, weight_json]
.into_iter()
.flatten()
.max_by(|a, b| a.total_cmp(b));
if max.is_none() || weight_plain == max {
DocFormat::Text
} else if weight_html == max {
DocFormat::Html
} else {
DocFormat::Json
}
}
#[get("/weather/<q>", format = "text/html")]
async fn weather_overview(q: &str, data: &State, accept: &Accept) -> Page {
if let Some(q) = location_string::parse(q) {
let mut lock = data.lock().await;
let Data {
dwd_cache,
dwd_stations,
..
} = &mut *lock;
let stations = match &q {
LocationString::Name(q) => {
let q = q.to_lowercase();
dwd_stations
.range((q.clone(), 0)..=(q, usize::MAX))
.map(|(_, station)| station.station.as_str())
.take(WEATHER_MAX_STATIONS)
.collect()
}
LocationString::Station(q) => q
.iter()
.map(|s| s.as_str())
.take(WEATHER_MAX_STATIONS)
.collect(),
LocationString::Coords(lat, lon) => vec![
dwd_stations
.values()
.min_by(|a, b| dist2(a, *lat, *lon).total_cmp(&dist2(b, *lat, *lon)))
.unwrap()
.station
.as_str(),
],
};
if stations.is_empty() {
return Err(Err((
Status::NotFound,
"could not find any such station".to_owned(),
)));
}
match crate::dwd::forecast(&stations, dwd_cache).await {
Ok(forecast) => {
fn forecasts(
forecast: &HashMap<String, ForecastDatas>,
) -> impl Iterator<Item = &ForecastData> {
forecast
.iter()
.flat_map(|(_, forecast)| forecast.forecasts())
}
let mut timer = TimeStepper::invalid();
for forecast in forecasts(&forecast) {
timer.add(forecast.time_start(), forecast.time_step());
}
if timer.count() == 0 {
return Err(Err((
Status::InternalServerError,
"no forecasts found".to_owned(),
)));
}
match prefer_format(accept) {
DocFormat::Html | DocFormat::Text | DocFormat::Json => {
let mut body = String::new();
let mut i = 0;
let mut i = || {
i += 1;
i
};
diagram_head(&mut body);
diagram(
("Temperature", "°C", 5.0),
(i(), Some(0.0), Some(0.0)),
&[
(-25.0, "#80B"),
(-5.0, "#00A"),
(15.0, "#080"),
(35.0, "#FF0"),
],
|time| {
avg(forecasts(&forecast)
.flat_map(move |forecast| forecast.temperature(time)))
},
timer.clone(),
&mut body,
);
diagram(
("Precipitation", "mm/h", 1.0),
(i(), Some(0.0), None),
&[(0.0, "#333"), (1.0, "#446"), (5.0, "#22A"), (20.0, "#00F")],
|time| {
avg(forecasts(&forecast)
.flat_map(move |forecast| forecast.precipitation(time)))
},
timer.clone(),
&mut body,
);
diagram(
("Sunshine", "min/day", 10.0),
(i(), Some(0.0), None),
&[(0.0, "#333"), (60.0, "#DD0"), (360.0, "#F90")],
|time| {
avg(forecasts(&forecast)
.flat_map(move |forecast| forecast.sunshine(time)))
},
timer.clone(),
&mut body,
);
diagram(
("Humidity", "%", 20.0),
(i(), Some(0.0), Some(100.0)),
&[(0.0, "#884"), (40.0, "#333"), (100.0, "#A0A")],
|time| {
avg(forecasts(&forecast)
.flat_map(move |forecast| forecast.humidity(time)))
},
timer.clone(),
&mut body,
);
fn avg(iter: impl Iterator<Item = f64>) -> Option<f64> {
let mut avg = None;
for (i, v) in iter.enumerate() {
avg = Some((avg.unwrap_or(0.0) * i as f64 + v) / (i as f64 + 1.0));
}
avg
}
body += "</div>\n";
html("getenv weather", &body)
}
}
}
Err(e) => Err(Err((Status::InternalServerError, e))),
}
} else {
Err(Err((
Status::BadRequest,
"invalid location, should either be =id1,id2, +lat+lon, or a station name".to_owned(),
)))
}
}
fn diagram_head(body: &mut String) {
*body += r#"<style>
a {
display: block;
}
.forecast {
max-width: 100%;
overflow-x: scroll;
}
.diagram {
height: 10em;
margin: 0px;
border: 0px;
padding: 0px;
stroke-width: 8;
}
path {
stroke-width: 24;
}
</style>
<form action=".." method="get"><input name="q" type="text"></form><div class="forecast">
"#;
}
fn diagram(
(title, unit, step_align): (&str, &str, f64),
(dindex, fmin, fmax): (usize, Option<f64>, Option<f64>),
gradient: &[(f64, &str)],
f: impl Fn(f64) -> Option<f64>,
mut timer: TimeStepper,
body: &mut String,
) {
let mut peek_timer = timer.clone();
let start = peek_timer.step();
let (mut min, mut max) = if start.is_finite()
&& let Some(value) = f(start)
{
(value, value)
} else {
eprintln!("no data points");
return;
};
let mut end = start;
while let time = peek_timer.step()
&& time.is_finite()
&& let Some(value) = f(time)
{
end = time;
if value > max {
max = value;
} else if value < min {
min = value;
}
}
if end <= start || min >= max {
eprintln!("just one data point");
return;
}
let (dmin, dmax) = (min, max);
if let Some(fmin) = fmin
&& min > fmin
{
min = fmin;
}
if let Some(fmax) = fmax
&& max < fmax
{
max = fmax;
}
*body += "<div>";
html_escape::encode_safe_to_string(format!("{title} ({dmin:.1} - {dmax:.1} {unit})"), body);
*body += "</div>";
let scale_time = |t: f64| (t - start) / 86.4;
let scale_value = |v: f64| (max - v) * 1000.0 / (max - min);
let scaled_end = scale_time(end);
*body += &format!(
r##"<svg class="diagram" xmlns="http://www.w3.org/2000/svg" viewBox="-500 -50 {} 1200">"##,
scaled_end + 500.0,
);
let shape_head = format!(r##"<path stroke="url(#gra{dindex})" fill="transparent" d=""##);
for i in (min / step_align).ceil() as i128..=(max / step_align).floor() as i128 {
let v = i as f64 * step_align;
let y = scale_value(v);
*body += &format!(
r##"<text x="-100" text-anchor="end" y="{}" fill="white" font-size="70">{v} {unit}</text><line x1="0" x2="{}" y1="{y}" y2="{y}" stroke="#FFF7" stroke-dasharray="55.55555 111.11111"/>"##,
y + 25.0,
scaled_end,
);
}
let mut date = (DateTime::UNIX_EPOCH + Duration::from_secs_f64(start))
.with_timezone(&Local)
.date_naive();
loop {
let day_start = date
.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
.and_local_timezone(Local)
.earliest()
.unwrap()
.signed_duration_since(DateTime::UNIX_EPOCH)
.as_seconds_f64();
if let Some(succ) = date.succ_opt() {
date = succ;
} else {
break;
}
if day_start > end {
break;
} else if day_start < start {
continue;
}
let x = scale_time(day_start);
*body += &format!(r#"<line x1="{x}" x2="{x}" y1="1040" y2="1120" stroke="white"/>"#);
let x = scale_time(day_start + 3.0 * 3600.0);
*body += &format!(
r#"<text x="{x}" y="1120" fill="white" font-size="80">{}</text>"#,
date.format_with_items(
[
chrono::format::Item::Fixed(chrono::format::Fixed::ShortWeekdayName),
chrono::format::Item::Literal(", "),
chrono::format::Item::Numeric(
chrono::format::Numeric::Day,
chrono::format::Pad::None
),
chrono::format::Item::Literal("."),
chrono::format::Item::Numeric(
chrono::format::Numeric::Month,
chrono::format::Pad::None
),
chrono::format::Item::Literal("."),
]
.into_iter()
)
);
}
*body += &format!(
r#"
<linearGradient id="gra{dindex}" x1="0" y1="0" x2="1" y2="0">"#
);
let mut gradient_timer = timer.clone();
while let time = gradient_timer.step()
&& time.is_finite()
&& let Some(value) = f(time)
{
let color = if let Some((i, (v2, c2))) =
gradient.iter().enumerate().find(|(_, (v, _))| *v >= value)
{
if i > 0 {
let (v1, c1) = gradient[i - 1];
Cow::Owned(format!(
"color-mix({c1} {:.2}%, {c2} {:.2}%)",
100.0 * (v2 - value) / (v2 - v1),
100.0 * (value - v1) / (v2 - v1),
))
} else {
Cow::Borrowed(*c2)
}
} else {
Cow::Borrowed(gradient.last().unwrap().1)
};
*body += &format!(
r##"<stop offset="{}" stop-color="{color}"/>"##,
scale_time(time) / scaled_end,
);
}
// <stop offset="0%" stop-color="red"></stop>
// <stop offset="50%" stop-color="black" stop-opacity="0"></stop>
// <stop offset="100%" stop-color="blue"></stop>
// let mut pv = 1.0;
// for (v, color) in std::iter::once((0.0, gradient.first().unwrap().1))
// .chain(gradient.iter().map(|(v, c)| ((*v - min) / (max - min), *c)))
// .chain(std::iter::once((1.0, gradient.last().unwrap().1)))
// {
// if v == pv {
// continue;
// }
// pv = v;
// *body += &format!(r##"<stop offset="{v}" stop-color="{color}"/>"##);
// }
*body += r#"</linearGradient>
"#;
*body += &shape_head;
let mut first = true;
while let time = timer.step()
&& time.is_finite()
&& let Some(value) = f(time)
{
*body += &if first {
first = false;
format!("M {} {}", scale_time(time), scale_value(value))
} else {
format!(" L {} {}", scale_time(time), scale_value(value))
};
}
*body += "\"/></svg>\n";
}