feat: weather graphs
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
3212
Cargo.lock
generated
Normal file
3212
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
Cargo.toml
Normal file
14
Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "getenv"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.44"
|
||||
html-escape = "0.2.13"
|
||||
reqwest = { version = "0.13.4", features = ["deflate", "brotli", "gzip", "multipart", "zstd"] }
|
||||
rocket = "0.5.1"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.150"
|
||||
tokio = { version = "1.52.3", features = ["full"] }
|
||||
url-escape = "0.1.1"
|
||||
1
LICENSE
Normal file
1
LICENSE
Normal file
@@ -0,0 +1 @@
|
||||
./lexikon_internet_utf8.txt is licensed under CC-BY-4.0 by dwd.de
|
||||
21074
lexikon_internet_utf8.txt
Normal file
21074
lexikon_internet_utf8.txt
Normal file
File diff suppressed because it is too large
Load Diff
52
src/data.rs
Normal file
52
src/data.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::dwd::{DwdCache, DwdStation};
|
||||
|
||||
pub struct Data {
|
||||
pub dwd_cache: DwdCache,
|
||||
pub dwd_stations: BTreeMap<(String, usize), DwdStation>,
|
||||
pub dwd_stations_pos_range: (f64, f64, f64, f64, f64, f64),
|
||||
}
|
||||
|
||||
impl Data {
|
||||
pub fn new() -> Self {
|
||||
let dwd_stations = crate::dwd::load_stations();
|
||||
let dwd_stations_pos_range = (
|
||||
dwd_stations
|
||||
.values()
|
||||
.map(|v| v.pos.0)
|
||||
.min_by(|a, b| a.total_cmp(b))
|
||||
.unwrap(),
|
||||
dwd_stations
|
||||
.values()
|
||||
.map(|v| v.pos.0)
|
||||
.max_by(|a, b| a.total_cmp(b))
|
||||
.unwrap(),
|
||||
dwd_stations
|
||||
.values()
|
||||
.map(|v| v.pos.1)
|
||||
.min_by(|a, b| a.total_cmp(b))
|
||||
.unwrap(),
|
||||
dwd_stations
|
||||
.values()
|
||||
.map(|v| v.pos.1)
|
||||
.max_by(|a, b| a.total_cmp(b))
|
||||
.unwrap(),
|
||||
dwd_stations
|
||||
.values()
|
||||
.map(|v| v.pos.2)
|
||||
.min_by(|a, b| a.total_cmp(b))
|
||||
.unwrap(),
|
||||
dwd_stations
|
||||
.values()
|
||||
.map(|v| v.pos.2)
|
||||
.max_by(|a, b| a.total_cmp(b))
|
||||
.unwrap(),
|
||||
);
|
||||
Self {
|
||||
dwd_cache: DwdCache::default(),
|
||||
dwd_stations,
|
||||
dwd_stations_pos_range,
|
||||
}
|
||||
}
|
||||
}
|
||||
355
src/dwd.rs
Normal file
355
src/dwd.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use reqwest::Url;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::data::Data;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DwdCache {}
|
||||
|
||||
pub type Forecast = HashMap<String, ForecastDatas>;
|
||||
#[derive(Deserialize)]
|
||||
pub struct ForecastDatas {
|
||||
forecast0: Option<ForecastData>,
|
||||
forecast1: Option<ForecastData>,
|
||||
forecast2: Option<ForecastData>,
|
||||
forecast3: Option<ForecastData>,
|
||||
forecast4: Option<ForecastData>,
|
||||
forecast5: Option<ForecastData>,
|
||||
forecast6: Option<ForecastData>,
|
||||
forecast7: Option<ForecastData>,
|
||||
forecast8: Option<ForecastData>,
|
||||
forecast9: Option<ForecastData>,
|
||||
}
|
||||
impl ForecastDatas {
|
||||
pub fn forecasts(&self) -> impl Iterator<Item = &ForecastData> {
|
||||
[
|
||||
self.forecast0.as_ref(),
|
||||
self.forecast1.as_ref(),
|
||||
self.forecast2.as_ref(),
|
||||
self.forecast3.as_ref(),
|
||||
self.forecast4.as_ref(),
|
||||
self.forecast5.as_ref(),
|
||||
self.forecast6.as_ref(),
|
||||
self.forecast7.as_ref(),
|
||||
self.forecast8.as_ref(),
|
||||
self.forecast9.as_ref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
}
|
||||
pub fn forecasts_mut(&mut self) -> impl Iterator<Item = &mut ForecastData> {
|
||||
[
|
||||
self.forecast0.as_mut(),
|
||||
self.forecast1.as_mut(),
|
||||
self.forecast2.as_mut(),
|
||||
self.forecast3.as_mut(),
|
||||
self.forecast4.as_mut(),
|
||||
self.forecast5.as_mut(),
|
||||
self.forecast6.as_mut(),
|
||||
self.forecast7.as_mut(),
|
||||
self.forecast8.as_mut(),
|
||||
self.forecast9.as_mut(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct ForecastData {
|
||||
#[serde(rename = "start")]
|
||||
time_start: f64,
|
||||
#[serde(rename = "timeStep")]
|
||||
time_step: f64,
|
||||
#[serde(rename = "temperature")]
|
||||
temperature: Option<Vec<f64>>,
|
||||
#[serde(rename = "windSpeed")]
|
||||
wind_speed: Option<Vec<f64>>,
|
||||
#[serde(rename = "windDirection")]
|
||||
wind_direction: Option<Vec<f64>>,
|
||||
#[serde(rename = "windGust")]
|
||||
wind_gust: Option<Vec<f64>>,
|
||||
#[serde(rename = "precipitationTotal")]
|
||||
precipitation_total: Option<Vec<f64>>,
|
||||
#[serde(rename = "precipitationProbability")]
|
||||
precipitation_probability: Option<Vec<f64>>,
|
||||
#[serde(rename = "sunshine")]
|
||||
sunshine: Option<Vec<f64>>,
|
||||
#[serde(rename = "humidity")]
|
||||
humidity: Option<Vec<f64>>,
|
||||
#[serde(rename = "surfacePressure")]
|
||||
pressure: Option<Vec<f64>>,
|
||||
}
|
||||
impl ForecastData {
|
||||
pub fn time_start(&self) -> f64 {
|
||||
self.time_start / 1000.0
|
||||
}
|
||||
pub fn time_step(&self) -> f64 {
|
||||
self.time_step / 1000.0
|
||||
}
|
||||
pub fn clean(&mut self) {
|
||||
fn clean(data: &mut Option<Vec<f64>>) {
|
||||
if let Some(data) = data {
|
||||
fn bad(v: f64) -> bool {
|
||||
matches!(v, 32767.0)
|
||||
}
|
||||
let val = data.iter().copied().find(|v| !bad(*v)).unwrap_or(0.0);
|
||||
for i in 0..data.len() {
|
||||
if bad(data[i]) {
|
||||
data[i] = if i > 0 { data[i - 1] } else { val };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
clean(&mut self.temperature);
|
||||
clean(&mut self.wind_speed);
|
||||
clean(&mut self.wind_direction);
|
||||
clean(&mut self.wind_gust);
|
||||
clean(&mut self.precipitation_total);
|
||||
clean(&mut self.precipitation_probability);
|
||||
clean(&mut self.sunshine);
|
||||
clean(&mut self.humidity);
|
||||
clean(&mut self.pressure);
|
||||
}
|
||||
fn time_to_index(&self, time: f64) -> Result<(usize, f64), f64> {
|
||||
let start = self.time_start();
|
||||
let step = self.time_step();
|
||||
if time < start {
|
||||
Err(time - start)
|
||||
} else {
|
||||
let idx = (time - start) / step;
|
||||
Ok((idx.floor() as usize, idx % 1.0))
|
||||
}
|
||||
}
|
||||
fn get_from_opt_vec(&self, time: f64, data: &Option<Vec<f64>>) -> Option<f64> {
|
||||
let data = data.as_ref()?;
|
||||
let (i, f) = self.time_to_index(time).ok()?;
|
||||
Some(if f < 0.05 {
|
||||
*data.get(i)?
|
||||
} else if f > 0.95 {
|
||||
*data.get(i + 1)?
|
||||
} else {
|
||||
let v1 = *data.get(i)?;
|
||||
let v2 = *data.get(i + 1)?;
|
||||
lerp(v1, v2, f)
|
||||
})
|
||||
}
|
||||
/// Returns the temperature in °C
|
||||
pub fn temperature(&self, time: f64) -> Option<f64> {
|
||||
Some(self.get_from_opt_vec(time, &self.temperature)? / 10.0)
|
||||
}
|
||||
/// Returns the precipitation amount in mm/h
|
||||
pub fn precipitation(&self, time: f64) -> Option<f64> {
|
||||
Some(self.get_from_opt_vec(time, &self.precipitation_total)? / 10.0)
|
||||
}
|
||||
/// Returns the sunshine amount in min/day
|
||||
pub fn sunshine(&self, time: f64) -> Option<f64> {
|
||||
Some(self.get_from_opt_vec(time, &self.sunshine)? / 10.0)
|
||||
}
|
||||
/// Returns the humidity in percent
|
||||
pub fn humidity(&self, time: f64) -> Option<f64> {
|
||||
Some(self.get_from_opt_vec(time, &self.humidity)? / 10.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn forecast(ids: &[&str], cache: &mut DwdCache) -> Result<Forecast, String> {
|
||||
dbg!(ids);
|
||||
let mut url =
|
||||
Url::parse("https://app-prod-ws.warnwetter.de/v30/stationOverviewExtended").unwrap();
|
||||
url.set_query(Some(&format!("stationIds={}", ids.join(","))));
|
||||
let response = reqwest::get(url)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
eprintln!("{e}");
|
||||
"could not make request to dwd server".to_owned()
|
||||
})?
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
eprintln!("{e}");
|
||||
"reading response from dwd server failed".to_owned()
|
||||
})?;
|
||||
eprintln!("{response}");
|
||||
let mut forecast = serde_json::from_str::<Forecast>(&response).map_err(|e| {
|
||||
eprintln!("{e}");
|
||||
"parsing response from dwd server failed".to_owned()
|
||||
})?;
|
||||
for forecast in forecast.values_mut() {
|
||||
for forecast in forecast.forecasts_mut() {
|
||||
forecast.clean();
|
||||
}
|
||||
}
|
||||
Ok(forecast)
|
||||
}
|
||||
|
||||
fn lerp(a: f64, b: f64, f: f64) -> f64 {
|
||||
a * (1.0 - f) + b * f
|
||||
}
|
||||
|
||||
pub fn load_stations() -> BTreeMap<(String, usize), DwdStation> {
|
||||
let text = std::fs::read_to_string("lexikon_internet_utf8.txt").unwrap();
|
||||
let mut lines = text.lines();
|
||||
let columns = lines.next().unwrap().trim();
|
||||
let mut map = BTreeMap::new();
|
||||
let spaces = lines
|
||||
.next()
|
||||
.unwrap()
|
||||
.trim()
|
||||
.chars()
|
||||
.enumerate()
|
||||
.filter_map(|(i, ch)| match ch {
|
||||
' ' => Some(i),
|
||||
'-' => None,
|
||||
_ => panic!("invalid format"),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for (i, line) in lines.enumerate() {
|
||||
let mut name = None;
|
||||
let mut station = None;
|
||||
let mut lat = None;
|
||||
let mut lon = None;
|
||||
let mut hgt = None;
|
||||
let line = line.trim_start();
|
||||
let mut indices = line.char_indices().enumerate();
|
||||
let mut p = (0, 0);
|
||||
for (i, idx) in spaces.iter().copied().enumerate() {
|
||||
for _ in p.1..idx {
|
||||
indices.next();
|
||||
}
|
||||
let e = indices.next().unwrap().1.0;
|
||||
let value = line[p.0..e].trim();
|
||||
p = (
|
||||
e + line[e..].chars().next().map_or(0, |ch| ch.len_utf8()),
|
||||
idx + 1,
|
||||
);
|
||||
match i {
|
||||
0 => {
|
||||
name = Some(value.to_owned());
|
||||
}
|
||||
3 => {
|
||||
station = Some(value.to_owned());
|
||||
}
|
||||
4 => {
|
||||
lat = Some(value.parse().unwrap());
|
||||
}
|
||||
5 => {
|
||||
lon = Some(value.parse().unwrap());
|
||||
}
|
||||
6 => {
|
||||
hgt = Some(value.parse().unwrap());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
map.insert(
|
||||
(name.as_ref().unwrap().to_lowercase(), i),
|
||||
DwdStation {
|
||||
name: name.unwrap(),
|
||||
station: station.unwrap(),
|
||||
pos: (lat.unwrap(), lon.unwrap(), hgt.unwrap()),
|
||||
},
|
||||
);
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
pub struct DwdStation {
|
||||
pub name: String,
|
||||
pub station: String,
|
||||
pub pos: (f64, f64, f64),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TimeStepper {
|
||||
counters: Vec<TimeStepCounter>,
|
||||
}
|
||||
impl TimeStepper {
|
||||
pub fn new(start: f64, step: f64) -> Self {
|
||||
Self {
|
||||
counters: vec![(start, step.abs()).into()],
|
||||
}
|
||||
}
|
||||
/// Creates a new value, but doing anything to it
|
||||
/// except calling `add` or `add_once` will likely panic.
|
||||
pub fn invalid() -> Self {
|
||||
Self { counters: vec![] }
|
||||
}
|
||||
pub fn count(&self) -> usize {
|
||||
self.counters.len()
|
||||
}
|
||||
pub fn add(&mut self, start: f64, step: f64) -> &mut Self {
|
||||
let counter = (start, step.abs()).into();
|
||||
match self.counters.binary_search(&counter) {
|
||||
Ok(i) | Err(i) => self.counters.insert(i, counter),
|
||||
}
|
||||
self
|
||||
}
|
||||
pub fn add_once(&mut self, time: f64) -> &mut Self {
|
||||
self.add(time, f64::INFINITY)
|
||||
}
|
||||
/// Filter `step()` to only return values `>= min`.
|
||||
///
|
||||
/// You should probably read a datapoint at `min` unless
|
||||
/// the next value returned by `step()` happens to be `min`.
|
||||
pub fn after(&mut self, min: f64) -> &mut Self {
|
||||
for counter in self.counters.iter_mut() {
|
||||
while counter.next < min {
|
||||
counter.next += counter.step;
|
||||
}
|
||||
}
|
||||
self.counters.sort();
|
||||
self
|
||||
}
|
||||
/// Return the smallest value in this stepper
|
||||
/// that has not been seen yet.
|
||||
///
|
||||
/// This can return the same or a similar values multiple times.
|
||||
/// If no reachable value exists because only
|
||||
/// `add_once` was used, this returns `f64::INFINITY`.
|
||||
pub fn step(&mut self) -> f64 {
|
||||
let TimeStepCounter { next, step } = self.counters.pop().unwrap();
|
||||
if step.is_finite() {
|
||||
self.add(next + step, step);
|
||||
} else if self.counters.is_empty() {
|
||||
self.add_once(f64::INFINITY);
|
||||
}
|
||||
next
|
||||
}
|
||||
pub fn peek(&self) -> f64 {
|
||||
let TimeStepCounter { next, step } = self.counters.last().unwrap();
|
||||
*next
|
||||
}
|
||||
}
|
||||
impl From<(f64, f64)> for TimeStepCounter {
|
||||
fn from(value: (f64, f64)) -> Self {
|
||||
Self {
|
||||
next: value.0,
|
||||
step: value.1,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct TimeStepCounter {
|
||||
next: f64,
|
||||
step: f64,
|
||||
}
|
||||
impl PartialOrd for TimeStepCounter {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
impl Ord for TimeStepCounter {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.next
|
||||
.total_cmp(&other.next)
|
||||
.then(self.step.total_cmp(&other.step))
|
||||
.reverse()
|
||||
}
|
||||
}
|
||||
impl PartialEq for TimeStepCounter {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.cmp(other).is_eq()
|
||||
}
|
||||
}
|
||||
impl Eq for TimeStepCounter {}
|
||||
7
src/end.html
Normal file
7
src/end.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<em style="display: block; font-size: xx-small; margin-top: 3rem;">
|
||||
the displayed data is currently obtained, possibly modified or cached, from:
|
||||
<a style="display: inline;" href="https://www.dwd.de/">DWD</a>'s <a style="display: inline;" href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a> data,
|
||||
or internally calculated/measured. for details, <a style="display: inline;" href="https://tomatenmhark.org/info/me/">contact me</a>
|
||||
or check the <a style="display: inline;" href="https://tomatenmhark.org/gitea/mark/getenv/">source code</a>.</em>
|
||||
</body>
|
||||
</html>
|
||||
22
src/index.html
Normal file
22
src/index.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<style>
|
||||
code {
|
||||
color: #FFFFFF80;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
code:hover {
|
||||
color: #FFF;
|
||||
}
|
||||
code > a {
|
||||
color: #BBF;
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
<div><pre>
|
||||
<code>int main() {</code>
|
||||
<code>getenv("<a href="./weather/">weather</a>"); // thx, dwd</code>
|
||||
<code>getenv("<a href="./light/">light</a>"); // TODO: sun and moon</code>
|
||||
<a href="/"><code>return 0;</code></a>
|
||||
<code>}</code>
|
||||
</pre></div>
|
||||
1
src/index.txt
Normal file
1
src/index.txt
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
src/light.rs
Normal file
1
src/light.rs
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
27
src/location_string.rs
Normal file
27
src/location_string.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
#[derive(Debug)]
|
||||
pub enum LocationString {
|
||||
Name(String),
|
||||
Station(Vec<String>),
|
||||
Coords(f64, f64),
|
||||
}
|
||||
|
||||
pub fn parse(str: &str) -> Option<LocationString> {
|
||||
if str.starts_with(['+', '-']) {
|
||||
if let Some(i) = str[1..].find(['+', '-'])
|
||||
&& let (Ok(lat), Ok(lon)) = (
|
||||
str[..1 + i].trim().replace(',', ".").parse(),
|
||||
str[1 + i..].trim().replace(',', ".").parse(),
|
||||
)
|
||||
{
|
||||
Some(LocationString::Coords(lat, lon))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else if let Some(station) = str.strip_prefix('=') {
|
||||
Some(LocationString::Station(
|
||||
station.split(',').map(|s| s.trim().to_owned()).collect(),
|
||||
))
|
||||
} else {
|
||||
Some(LocationString::Name(str.to_owned()))
|
||||
}
|
||||
}
|
||||
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";
|
||||
}
|
||||
14
src/start.html
Normal file
14
src/start.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>%TITLE%</title>
|
||||
<style>
|
||||
body {
|
||||
background: black;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
Reference in New Issue
Block a user