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>; 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, String>, Result, (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(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?")] async fn weather_index(q: Option<&str>, data: &State) -> Page { let mut body = r#"
"# .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!("
[{count}] "); *body += r#""#; html_escape::encode_safe_to_string(prev_name, body); *body += r#" {}m"#, pos.2); *body += "
"; *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::>(); 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 += "
"; 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/", 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, ) -> impl Iterator { 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) -> Option { 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 += "\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#"
"#; } fn diagram( (title, unit, step_align): (&str, &str, f64), (dindex, fmin, fmax): (usize, Option, Option), gradient: &[(f64, &str)], f: impl Fn(f64) -> Option, 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 += "
"; html_escape::encode_safe_to_string(format!("{title} ({dmin:.1} - {dmax:.1} {unit})"), body); *body += "
"; 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##""##, scaled_end + 500.0, ); let shape_head = format!(r##"{v} {unit}"##, 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#""#, ); } } } } 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##""##, match i { 6 | 12 | 18 => "50", _ => "80", } ); } let x = scale_time(day_start); *body += &format!(r#""#); let x = scale_time(day_start + 3.0 * 3600.0); *body += &format!( r#"{}"#, 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#" "# ); 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##""##, scale_time(time) / scaled_end, ); } // // // // 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##""##); // } *body += r#" "#; *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 += "\"/>\n"; }