567 lines
20 KiB
Rust
567 lines
20 KiB
Rust
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
|
|
};
|
|
let additional = |date| {
|
|
forecast
|
|
.values()
|
|
.flat_map(|forecast| forecast.days.iter())
|
|
.find(|day| day.date == date)
|
|
.map(|day| {
|
|
(
|
|
day.sunrise
|
|
.and_then(|rise| day.sunset.map(|set| (rise, set)))
|
|
.map(|(rise, set)| (rise / 1000.0, set / 1000.0)),
|
|
day.moonrise
|
|
.and_then(|rise| day.moonset.map(|set| (rise, set)))
|
|
.map(|(rise, set)| (rise / 1000.0, set / 1000.0)),
|
|
)
|
|
})
|
|
};
|
|
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)))
|
|
},
|
|
additional,
|
|
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)))
|
|
},
|
|
additional,
|
|
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)))
|
|
},
|
|
additional,
|
|
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)))
|
|
},
|
|
additional,
|
|
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: 12em;
|
|
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>,
|
|
additional: impl Fn(String) -> Option<(Option<(f64, f64)>, Option<(f64, 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 -150 {} 1300">"##,
|
|
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 {
|
|
fn unix_day_start(date: NaiveDate) -> f64 {
|
|
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()
|
|
}
|
|
let day_start = unix_day_start(date);
|
|
let (date, day_end) = if let Some(succ) = date.succ_opt() {
|
|
(std::mem::replace(&mut date, succ), unix_day_start(succ))
|
|
} else {
|
|
break;
|
|
};
|
|
if day_start > end {
|
|
break;
|
|
} else if day_start < start {
|
|
continue;
|
|
}
|
|
if let Some((sun, moon)) = additional(date.to_string()) {
|
|
for (risenset, color1, color2, y) in
|
|
[(sun, "#FF08", "#FB08", 150), (moon, "#88F8", "#00F6", 130)]
|
|
{
|
|
if let Some((rise, set)) = risenset {
|
|
for (t, color) in [(rise, color1), (set, color2)] {
|
|
let x = scale_time(t);
|
|
*body += &format!(
|
|
r#"<line x1="{x}" x2="{x}" y1="-{y}" y2="-100" stroke="{color}"/>"#,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (i, x) in (-2i32..)
|
|
.map(|i| (i, day_end + (i as f64 - 24.0) * 3600.0))
|
|
.skip_while(|(_, t)| *t <= day_start)
|
|
.take_while(|(_, t)| *t < day_end && *t <= end)
|
|
.map(|(i, t)| (i, scale_time(t)))
|
|
{
|
|
*body += &format!(
|
|
r##"<line x1="{x}" x2="{x}" y1="-100" y2="-{}" stroke="#FFF8"/>"##,
|
|
match i {
|
|
6 | 12 | 18 => "50",
|
|
_ => "80",
|
|
}
|
|
);
|
|
}
|
|
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";
|
|
}
|