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

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

3212
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

14
Cargo.toml Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

52
src/data.rs Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@

1
src/light.rs Normal file
View File

@@ -0,0 +1 @@

27
src/location_string.rs Normal file
View 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
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";
}

14
src/start.html Normal file
View 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>