feat: weather graphs
This commit is contained in:
515
src/main.rs
Normal file
515
src/main.rs
Normal 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";
|
||||
}
|
||||
Reference in New Issue
Block a user