cool things

This commit is contained in:
core 2024-10-27 21:19:36 -04:00
parent bff5de2e05
commit f42a4a739a
Signed by: core
GPG Key ID: FDBF740DADDCEECF
13 changed files with 422 additions and 236 deletions

View File

@ -0,0 +1,9 @@
pub const DEFAULT_REFLECTIVITY_PALETTE: &str = r#"Color: 10 164 164 255 100 100 192
Color: 20 64 128 255 32 64 128
Color: 30 0 255 0 0 128 0
Color: 40 255 255 0 255 128 0
Color: 50 255 0 0 160 0 0
Color: 60 255 0 255 128 0 128
Color: 70 255 255 255 128 128 128
Color: 80 128 128 128
"#;

View File

@ -1,7 +1,9 @@
pub mod parser;
pub mod default_palettes;
use ordered_float::OrderedFloat; pub use ordered_float::OrderedFloat;
#[derive(Copy, Clone)] #[derive(Copy, Clone, Debug, PartialEq)]
pub struct Color { pub struct Color {
pub red: u8, pub red: u8,
pub green: u8, pub green: u8,
@ -11,35 +13,23 @@ pub struct Color {
pub type Palette = Vec<((OrderedFloat<f64>, OrderedFloat<f64>), (Color, Color))>; pub type Palette = Vec<((OrderedFloat<f64>, OrderedFloat<f64>), (Color, Color))>;
#[macro_export]
macro_rules! c { macro_rules! c {
($r:expr,$g:expr,$b:expr) => { ($r:expr,$g:expr,$b:expr) => {
Color { red: $r, green: $g, blue: $b, alpha: 255 } crate::Color { red: $r, green: $g, blue: $b, alpha: 255 }
}; };
($r:expr,$g:expr,$b:expr,$a:expr) => { ($r:expr,$g:expr,$b:expr,$a:expr) => {
Color { red: $r, green: $g, blue: $b, alpha: $a } crate::Color { red: $r, green: $g, blue: $b, alpha: $a }
}; };
} }
#[macro_export]
macro_rules! r { macro_rules! r {
($f:expr,$t:expr) => { ($f:expr,$t:expr) => {
(OrderedFloat($f), OrderedFloat($t)) (crate::OrderedFloat($f), crate::OrderedFloat($t))
}; };
} }
pub fn create_test_palette() -> Palette {
let mut out = vec![];
out.push((r!(f64::NEG_INFINITY, 10.0), (c!(164, 164, 255), c!(164, 164, 255))));
out.push((r!(10.0, 20.0), (c!(64, 128, 255, 128), c!(32, 64, 128, 192))));
out.push((r!(20.0, 30.0), (c!(0, 255, 0, 192), c!(0, 128, 0, 192))));
out.push((r!(30.0, 45.0), (c!(255, 255, 0, 192), c!(255, 128, 0, 192))));
out.push((r!(45.0, 60.0), (c!(255, 0, 0, 192), c!(160, 0, 0, 192))));
out.push((r!(60.0, 70.0), (c!(255, 0, 255, 192), c!(128, 0, 128, 192))));
out.push((r!(70.0, 80.0), (c!(255, 255, 255, 192), c!(128, 128, 128, 192))));
out.push((r!(80.0, f64::INFINITY), (c!(128, 128, 128, 192), c!(128, 128, 128, 192))));
out
}
pub trait ColorPalette { pub trait ColorPalette {
fn colorize(&self, at: f64) -> Color; fn colorize(&self, at: f64) -> Color;
} }

164
wxbox-pal/src/parser.rs Normal file
View File

@ -0,0 +1,164 @@
use std::num::{ParseFloatError, ParseIntError};
use std::str::FromStr;
use thiserror::Error;
use crate::{c, Palette, r};
#[derive(Error, Debug, Clone)]
pub enum PaletteParseError {
#[error("unknown command `{0}`")]
UnknownCommand(String),
#[error("invalid `color:` command, expected 5 or 8 tokens but found `{0}`")]
IncorrectColorCommandLength(usize),
#[error("invalid `color4:` command, expected 6 or 10 tokens but found `{0}`")]
IncorrectColor4CommandLength(usize),
#[error("invalid float")]
InvalidFloat(#[from] ParseFloatError),
#[error("invalid int")]
InvalidInt(#[from] ParseIntError),
#[error("palette is empty")]
PaletteEmpty
}
pub fn parse(pal_str: &str) -> Result<Palette, PaletteParseError> {
// to lowercase
let input_lowercase = pal_str.to_lowercase();
// iterate over each line
// will be used to build the ranged palette
// value r1 g1 b1 a1 r2 g2 b2 a2
let mut parsed_data: Vec<(f64, u8, u8, u8, u8, u8, u8, u8, u8)> = vec![];
for line in input_lowercase.lines() {
let tokens = line.split_whitespace().collect::<Vec<_>>();
match tokens[0] {
"color:" => {
if tokens.len() == 5 {
// color: val r g b (4 tkns + command = 5 total)
let value = f64::from_str(tokens[1])?;
let r = u8::from_str(tokens[2])?;
let g = u8::from_str(tokens[3])?;
let b = u8::from_str(tokens[4])?;
parsed_data.push((value, r, g, b, 255, r, g, b, 255));
} else if tokens.len() == 8 {
// color: val r1 g1 b1 r2 b2 g2 (7 tkns + command = 8 total)
let value = f64::from_str(tokens[1])?;
let r1 = u8::from_str(tokens[2])?;
let g1 = u8::from_str(tokens[3])?;
let b1 = u8::from_str(tokens[4])?;
let r2 = u8::from_str(tokens[5])?;
let g2 = u8::from_str(tokens[6])?;
let b2 = u8::from_str(tokens[7])?;
parsed_data.push((value, r1, g1, b1, 255, r2, g2, b2, 255));
} else {
return Err(PaletteParseError::IncorrectColorCommandLength(tokens.len()));
}
},
"color4:" => {
if tokens.len() == 6 {
// color4: val r g b a (5 tkns + command = 6 total)
let value = f64::from_str(tokens[1])?;
let r = u8::from_str(tokens[2])?;
let g = u8::from_str(tokens[3])?;
let b = u8::from_str(tokens[4])?;
let a = u8::from_str(tokens[5])?;
parsed_data.push((value, r, g, b, a, r, g, b, a));
} else if tokens.len() == 10 {
// color4: val r1 g1 b1 a1 r2 b2 g2 a2 (9 tkns + command = 10 total)
let value = f64::from_str(tokens[1])?;
let r1 = u8::from_str(tokens[2])?;
let g1 = u8::from_str(tokens[3])?;
let b1 = u8::from_str(tokens[4])?;
let a1 = u8::from_str(tokens[5])?;
let r2 = u8::from_str(tokens[6])?;
let g2 = u8::from_str(tokens[7])?;
let b2 = u8::from_str(tokens[8])?;
let a2 = u8::from_str(tokens[9])?;
parsed_data.push((value, r1, g1, b1, a1, r2, g2, b2, a2));
} else {
return Err(PaletteParseError::IncorrectColorCommandLength(tokens.len()));
}
},
"product:" => {}, // valid but ignored
"units:" => {}, // valid but ignored
"step:" => {}, // valid but ignored
unknown => return Err(PaletteParseError::UnknownCommand(unknown.to_string()))
}
}
if parsed_data.is_empty() {
return Err(PaletteParseError::PaletteEmpty)
}
let mut intermediate_with_infinities_added: Vec<(f64, u8, u8, u8, u8, u8, u8, u8, u8)> = vec![];
intermediate_with_infinities_added.push(
(
f64::NEG_INFINITY,
parsed_data[0].1,
parsed_data[0].2,
parsed_data[0].3,
parsed_data[0].4,
parsed_data[0].1,
parsed_data[0].2,
parsed_data[0].3,
parsed_data[0].4,
)
);
intermediate_with_infinities_added.append(&mut parsed_data);
intermediate_with_infinities_added.push(
(
f64::INFINITY,
intermediate_with_infinities_added[intermediate_with_infinities_added.len()-1].1,
intermediate_with_infinities_added[intermediate_with_infinities_added.len()-1].2,
intermediate_with_infinities_added[intermediate_with_infinities_added.len()-1].3,
intermediate_with_infinities_added[intermediate_with_infinities_added.len()-1].4,
intermediate_with_infinities_added[intermediate_with_infinities_added.len()-1].1,
intermediate_with_infinities_added[intermediate_with_infinities_added.len()-1].2,
intermediate_with_infinities_added[intermediate_with_infinities_added.len()-1].3,
intermediate_with_infinities_added[intermediate_with_infinities_added.len()-1].4,
)
);
let mut output = vec![];
for range in intermediate_with_infinities_added.windows(2) {
output.push(
(r!(range[0].0, range[1].0), (c!(range[0].1, range[0].2, range[0].3, range[0].4), c!(range[0].5, range[0].6, range[0].7, range[0].8)))
)
}
Ok(output)
}
#[cfg(test)]
mod tests {
use crate::parser::parse;
use crate::{c, r};
#[test]
fn almanydesigns_radaromega_nws_default() {
assert_eq!(
parse(r#"color: -30 165 165 165 8 230 230
color: 10 0 165 255 0 8 197
color: 20 16 255 8 10 126 3
color: 35 251 238 0 210 112 2
color: 50 255 0 0 171 0 1
color: 65 247 1 249 136 63 174
color: 75 255 255 255 184 184 184
color: 85 184 184 184
color: 95 184 184 184"#).unwrap(),
vec![
(r!(f64::NEG_INFINITY, -30.0), (c!(165, 165, 165), c!(165, 165, 165))),
(r!(-30.0, 10.0), (c!(165, 165, 165), c!(8, 230, 230))),
(r!(10.0, 20.0), (c!(0, 165, 255), c!(0, 8, 197))),
(r!(20.0, 35.0), (c!(16, 255, 8), c!(10, 126, 3))),
(r!(35.0, 50.0), (c!(251, 238, 0), c!(210, 112, 2))),
(r!(50.0, 65.0), (c!(255, 0, 0), c!(171, 0, 1))),
(r!(65.0, 75.0), (c!(247, 1, 249), c!(136, 63, 174))),
(r!(75.0, 85.0), (c!(255, 255, 255), c!(184, 184, 184))),
(r!(85.0, 95.0), (c!(184, 184, 184), c!(184, 184, 184))),
(r!(95.0, f64::INFINITY), (c!(184, 184, 184), c!(184, 184, 184)))
]
)
}
}

View File

@ -2,7 +2,6 @@ pub(crate) mod sources;
mod grib2; mod grib2;
mod pixmap; mod pixmap;
mod tile;
use std::collections::{BTreeMap}; use std::collections::{BTreeMap};
use std::fmt::Debug; use std::fmt::Debug;

View File

@ -1,7 +1,7 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::f64::consts::PI; use std::f64::consts::PI;
use std::io::{BufWriter, Cursor, Read}; use std::io::{BufWriter, Cursor, Read};
use std::time::SystemTime; use std::time::{SystemTime, UNIX_EPOCH};
use eccodes::{CodesHandle, FallibleStreamingIterator, ProductKind}; use eccodes::{CodesHandle, FallibleStreamingIterator, ProductKind};
use flate2::read::GzDecoder; use flate2::read::GzDecoder;
use ndarray::Zip; use ndarray::Zip;
@ -14,7 +14,9 @@ use crate::LutKey;
use crate::pixmap::Pixmap; use crate::pixmap::Pixmap;
pub async fn needs_reload(lct: &RwLock<BTreeMap<LutKey, SystemTime>>, lutkey: &LutKey, valid_for: u64) -> bool { pub async fn needs_reload(lct: &RwLock<BTreeMap<LutKey, SystemTime>>, lutkey: &LutKey, valid_for: u64) -> bool {
eprintln!("debug: try lock needs_reload() lut_cache_timestamps start");
let lct_reader = lct.read().await; let lct_reader = lct.read().await;
eprintln!("debug: try lock needs_reload() lut_cache_timestamps locked");
if let Some(t) = lct_reader.get(&lutkey) { if let Some(t) = lct_reader.get(&lutkey) {
let dur = SystemTime::now().duration_since(*t).expect("time went backwards").as_secs(); let dur = SystemTime::now().duration_since(*t).expect("time went backwards").as_secs();
@ -86,11 +88,15 @@ pub fn eccodes_remap(data: Vec<u8>) -> LookupTable2D {
pub async fn reload_if_required(from: &str, needs_gzip: bool, valid_for: u64, lut_key: LutKey, lct_cache: &RwLock<BTreeMap<LutKey, SystemTime>>, lut_cache: &RwLock<BTreeMap<LutKey, LookupTable2D>>) { pub async fn reload_if_required(from: &str, needs_gzip: bool, valid_for: u64, lut_key: LutKey, lct_cache: &RwLock<BTreeMap<LutKey, SystemTime>>, lut_cache: &RwLock<BTreeMap<LutKey, LookupTable2D>>) {
if needs_reload(&lct_cache, &lut_key, valid_for).await { if needs_reload(&lct_cache, &lut_key, valid_for).await {
eprintln!("debug: try lock reload_if_required() lut_cache_timestamps start WRITE");
let mut lct_writer = lct_cache.write().await; let mut lct_writer = lct_cache.write().await;
eprintln!("debug: try lock reload_if_required() lut_cache_timestamps locked WRITE");
let map = eccodes_remap(load(from, needs_gzip).await); let map = eccodes_remap(load(from, needs_gzip).await);
eprintln!("debug: try lock reload_if_required() lut_cache start");
lut_cache.write().await.insert(lut_key, map); lut_cache.write().await.insert(lut_key, map);
eprintln!("debug: try lock reload_if_required() lut_cache locked");
lct_writer.insert(lut_key, SystemTime::now()); lct_writer.insert(lut_key, SystemTime::now());
} }
} }
@ -98,7 +104,7 @@ pub async fn reload_if_required(from: &str, needs_gzip: bool, valid_for: u64, lu
const TWO_PI: f64 = PI * 2.0; const TWO_PI: f64 = PI * 2.0;
const HALF_PI: f64 = PI / 2.0; const HALF_PI: f64 = PI / 2.0;
pub fn render(xtile: f64, ytile: f64, z: i32, tilesize: usize, pal: Palette, map: &LookupTable2D) -> Vec<u8> { pub fn render(xtile: f64, ytile: f64, z: i32, tilesize: usize, pal: Palette, map: &LookupTable2D, missing: Option<f64>, range_folded: Option<f64>, no_coverage: Option<f64>) -> Vec<u8> {
let mut image: Pixmap = Pixmap::new(); let mut image: Pixmap = Pixmap::new();
let denominator = 2.0_f64.powi(z) * tilesize as f64; let denominator = 2.0_f64.powi(z) * tilesize as f64;
@ -132,8 +138,9 @@ pub fn render(xtile: f64, ytile: f64, z: i32, tilesize: usize, pal: Palette, map
} }
let color = match value { let color = match value {
-999.0 => Color { red: 0, green: 0, blue: 0, alpha: 30 }, c if Some(*c) == no_coverage => Color { red: 0, green: 0, blue: 0, alpha: 30 },
-99.0 => Color { red: 0, green: 0, blue: 0, alpha: 0 }, c if Some(*c) == missing => Color { red: 0, green: 0, blue: 0, alpha: 0 },
c if Some(*c) == range_folded => Color { red: 141, green: 0, blue: 160, alpha: 0 },
value_at_pos => { value_at_pos => {
pal.colorize(*value_at_pos) pal.colorize(*value_at_pos)
} }
@ -169,7 +176,7 @@ pub fn render(xtile: f64, ytile: f64, z: i32, tilesize: usize, pal: Palette, map
#[macro_export] #[macro_export]
macro_rules! grib2_handler { macro_rules! grib2_handler {
(mount $f:ident, at: $path:expr, from: $from:expr, needs_gzip: $needs_gzip:expr, valid_for: $valid_for:expr, lut_with: $lut_key:expr, palette: $pal:expr) => { (mount $f:ident, at: $path:expr, from: $from:expr, needs_gzip: $needs_gzip:expr, valid_for: $valid_for:expr, lut_with: $lut_key:expr, palette: $pal:expr, missing: $missing:expr, range_folded: $rf:expr, no_coverage: $nc:expr) => {
#[::actix_web::get($path)] #[::actix_web::get($path)]
async fn $f(path: ::actix_web::web::Path<(i32,u32,u32)>, data: ::actix_web::web::Data<crate::AppState>) -> ::actix_web::HttpResponse { async fn $f(path: ::actix_web::web::Path<(i32,u32,u32)>, data: ::actix_web::web::Data<crate::AppState>) -> ::actix_web::HttpResponse {
crate::sources::grib2::reload_if_required( crate::sources::grib2::reload_if_required(
@ -180,10 +187,17 @@ macro_rules! grib2_handler {
&data.lut_cache_timestamps, &data.lut_cache_timestamps,
&data.lut_cache &data.lut_cache
).await; ).await;
eprintln!("debug: try lock handler lut_cache_timestamps start");
let lct_reader = data.lut_cache_timestamps.read().await;
eprintln!("debug: try lock handler lut_cache_timestamps locked");
if let Some(map) = data.lut_cache.read().await.get(&$lut_key) { if let Some(map) = data.lut_cache.read().await.get(&$lut_key) {
::actix_web::HttpResponse::Ok() ::actix_web::HttpResponse::Ok()
.insert_header(::actix_web::http::header::ContentType(::mime::IMAGE_PNG)) .insert_header(::actix_web::http::header::ContentType(::mime::IMAGE_PNG))
.body(crate::sources::grib2::render(path.1 as f64, path.2 as f64, path.0, 256, $pal, map)) .insert_header(("x-wxbox-tiler-data-valid-time", lct_reader.get(&$lut_key).expect("impossible").duration_since(::std::time::UNIX_EPOCH).expect("time went backwards").as_secs().to_string()))
.insert_header(("Access-Control-Allow-Origin", "*"))
.insert_header(("Access-Control-Expose-Headers", "*"))
.insert_header(("Access-Control-Allow-Headers", "*"))
.body(crate::sources::grib2::render(path.1 as f64, path.2 as f64, path.0, 256, $pal, map, $missing, $rf, $nc))
} else { } else {
::actix_web::HttpResponse::new(::actix_web::http::StatusCode::NOT_FOUND) ::actix_web::HttpResponse::new(::actix_web::http::StatusCode::NOT_FOUND)
} }

View File

@ -1,4 +1,3 @@
use wxbox_pal::create_test_palette;
use crate::{grib2_handler, LutKey}; use crate::{grib2_handler, LutKey};
grib2_handler! { grib2_handler! {
@ -8,5 +7,8 @@ grib2_handler! {
needs_gzip: true, needs_gzip: true,
valid_for: 120, valid_for: 120,
lut_with: LutKey::NoaaMrmsMergedCompositeReflectivityQc, lut_with: LutKey::NoaaMrmsMergedCompositeReflectivityQc,
palette: create_test_palette() palette: wxbox_pal::parser::parse(wxbox_pal::default_palettes::DEFAULT_REFLECTIVITY_PALETTE).unwrap(),
missing: Some(-99.0),
range_folded: None,
no_coverage: Some(-999.0)
} }

View File

@ -1,184 +0,0 @@
//! Tilemath adapted from maplibre/martin
use std::f64::consts::PI;
pub const EARTH_CIRCUMFERENCE: f64 = 40_075_016.685_578_5;
pub const EARTH_RADIUS: f64 = EARTH_CIRCUMFERENCE / 2.0 / PI;
pub const MAX_ZOOM: u8 = 30;
/// Convert longitude and latitude to a tile (x,y) coordinates for a given zoom
#[must_use]
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_sign_loss)]
pub fn tile_index(lng: f64, lat: f64, zoom: u8) -> (u32, u32) {
let tile_size = EARTH_CIRCUMFERENCE / f64::from(1_u32 << zoom);
let (x, y) = wgs84_to_webmercator(lng, lat);
let col = (((x - (EARTH_CIRCUMFERENCE * -0.5)).abs() / tile_size) as u32).min((1 << zoom) - 1);
let row = ((((EARTH_CIRCUMFERENCE * 0.5) - y).abs() / tile_size) as u32).min((1 << zoom) - 1);
(col, row)
}
/// Convert min/max XYZ tile coordinates to a bounding box values.
/// The result is `[min_lng, min_lat, max_lng, max_lat]`
#[must_use]
pub fn xyz_to_bbox(zoom: u8, min_x: u32, min_y: u32, max_x: u32, max_y: u32) -> [f64; 4] {
assert!(zoom <= MAX_ZOOM, "zoom {zoom} must be <= {MAX_ZOOM}");
let tile_length = EARTH_CIRCUMFERENCE / f64::from(1_u32 << zoom);
let left_down_bbox = tile_bbox(min_x, max_y, tile_length);
let right_top_bbox = tile_bbox(max_x, min_y, tile_length);
let (min_lng, min_lat) = webmercator_to_wgs84(left_down_bbox[0], left_down_bbox[1]);
let (max_lng, max_lat) = webmercator_to_wgs84(right_top_bbox[2], right_top_bbox[3]);
[min_lng, min_lat, max_lng, max_lat]
}
#[allow(clippy::cast_lossless)]
fn tile_bbox(x: u32, y: u32, tile_length: f64) -> [f64; 4] {
let min_x = EARTH_CIRCUMFERENCE * -0.5 + x as f64 * tile_length;
let max_y = EARTH_CIRCUMFERENCE * 0.5 - y as f64 * tile_length;
[min_x, max_y - tile_length, min_x + tile_length, max_y]
}
/// Convert bounding box to a tile box `(min_x, min_y, max_x, max_y)` for a given zoom
#[must_use]
pub fn bbox_to_xyz(left: f64, bottom: f64, right: f64, top: f64, zoom: u8) -> (u32, u32, u32, u32) {
let (min_col, min_row) = tile_index(left, top, zoom);
let (max_col, max_row) = tile_index(right, bottom, zoom);
(min_col, min_row, max_col, max_row)
}
/// Compute precision of a zoom level, i.e. how many decimal digits of the longitude and latitude are relevant
#[must_use]
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
pub fn get_zoom_precision(zoom: u8) -> usize {
assert!(zoom < MAX_ZOOM, "zoom {zoom} must be <= {MAX_ZOOM}");
let lng_delta = webmercator_to_wgs84(EARTH_CIRCUMFERENCE / f64::from(1_u32 << zoom), 0.0).0;
let log = lng_delta.log10() - 0.5;
if log > 0.0 {
0
} else {
-log.ceil() as usize
}
}
#[must_use]
pub fn webmercator_to_wgs84(x: f64, y: f64) -> (f64, f64) {
let lng = (x / EARTH_RADIUS).to_degrees();
let lat = f64::atan(f64::sinh(y / EARTH_RADIUS)).to_degrees();
(lng, lat)
}
/// transform WGS84 to `WebMercator`
// from https://github.com/Esri/arcgis-osm-editor/blob/e4b9905c264aa22f8eeb657efd52b12cdebea69a/src/OSMWeb10_1/Utils/WebMercator.cs
#[must_use]
pub fn wgs84_to_webmercator(lon: f64, lat: f64) -> (f64, f64) {
let x = lon * PI / 180.0 * EARTH_RADIUS;
let rad = lat * PI / 180.0;
let sin = rad.sin();
let y = EARTH_RADIUS / 2.0 * ((1.0 + sin) / (1.0 - sin)).ln();
(x, y)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unreadable_literal)]
use std::fs::read;
use approx::assert_relative_eq;
use insta::assert_snapshot;
use super::*;
#[test]
fn test_xyz_to_bbox() {
// you could easily get test cases from maptiler: https://www.maptiler.com/google-maps-coordinates-tile-bounds-projection/#4/-118.82/71.02
let bbox = xyz_to_bbox(0, 0, 0, 0, 0);
assert_relative_eq!(bbox[0], -180.0, epsilon = f64::EPSILON * 2.0);
assert_relative_eq!(bbox[1], -85.0511287798066, epsilon = f64::EPSILON * 2.0);
assert_relative_eq!(bbox[2], 180.0, epsilon = f64::EPSILON * 2.0);
assert_relative_eq!(bbox[3], 85.0511287798066, epsilon = f64::EPSILON * 2.0);
let bbox = xyz_to_bbox(1, 0, 0, 0, 0);
assert_relative_eq!(bbox[0], -180.0, epsilon = f64::EPSILON * 2.0);
assert_relative_eq!(bbox[1], 0.0, epsilon = f64::EPSILON * 2.0);
assert_relative_eq!(bbox[2], 0.0, epsilon = f64::EPSILON * 2.0);
assert_relative_eq!(bbox[3], 85.0511287798066, epsilon = f64::EPSILON * 2.0);
let bbox = xyz_to_bbox(5, 1, 1, 2, 2);
assert_relative_eq!(bbox[0], -168.75, epsilon = f64::EPSILON * 2.0);
assert_relative_eq!(bbox[1], 81.09321385260837, epsilon = f64::EPSILON * 2.0);
assert_relative_eq!(bbox[2], -146.25, epsilon = f64::EPSILON * 2.0);
assert_relative_eq!(bbox[3], 83.97925949886205, epsilon = f64::EPSILON * 2.0);
let bbox = xyz_to_bbox(5, 1, 3, 2, 5);
assert_relative_eq!(bbox[0], -168.75, epsilon = f64::EPSILON * 2.0);
assert_relative_eq!(bbox[1], 74.01954331150226, epsilon = f64::EPSILON * 2.0);
assert_relative_eq!(bbox[2], -146.25, epsilon = f64::EPSILON * 2.0);
assert_relative_eq!(bbox[3], 81.09321385260837, epsilon = f64::EPSILON * 2.0);
}
#[test]
fn test_box_to_xyz() {
fn tst(left: f64, bottom: f64, right: f64, top: f64, zoom: u8) -> String {
let (x0, y0, x1, y1) = bbox_to_xyz(left, bottom, right, top, zoom);
format!("({x0}, {y0}, {x1}, {y1})")
}
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 0), @"(0, 0, 0, 0)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 1), @"(0, 1, 0, 1)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 2), @"(0, 3, 0, 3)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 3), @"(0, 7, 0, 7)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 4), @"(0, 14, 1, 15)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 5), @"(0, 29, 2, 31)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 6), @"(0, 58, 5, 63)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 7), @"(0, 116, 11, 126)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 8), @"(0, 233, 23, 253)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 9), @"(0, 466, 47, 507)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 10), @"(1, 933, 94, 1014)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 11), @"(3, 1866, 188, 2029)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 12), @"(6, 3732, 377, 4059)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 13), @"(12, 7465, 755, 8119)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 14), @"(25, 14931, 1510, 16239)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 15), @"(51, 29863, 3020, 32479)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 16), @"(102, 59727, 6041, 64958)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 17), @"(204, 119455, 12083, 129917)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 18), @"(409, 238911, 24166, 259834)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 19), @"(819, 477823, 48332, 519669)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 20), @"(1638, 955647, 96665, 1039339)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 21), @"(3276, 1911295, 193331, 2078678)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 22), @"(6553, 3822590, 386662, 4157356)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 23), @"(13107, 7645181, 773324, 8314713)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 24), @"(26214, 15290363, 1546649, 16629427)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 25), @"(52428, 30580726, 3093299, 33258855)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 26), @"(104857, 61161453, 6186598, 66517711)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 27), @"(209715, 122322907, 12373196, 133035423)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 28), @"(419430, 244645814, 24746393, 266070846)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 29), @"(838860, 489291628, 49492787, 532141692)");
assert_snapshot!(tst(-179.43749999999955,-84.76987877980656,-146.8124999999996,-81.37446385260833, 30), @"(1677721, 978583256, 98985574, 1064283385)");
}
#[test]
fn meter_to_lng_lat() {
let (lng, lat) = webmercator_to_wgs84(-20037508.34, -20037508.34);
assert_relative_eq!(lng, -179.9999999749437, epsilon = f64::EPSILON * 2.0);
assert_relative_eq!(lat, -85.05112877764508, epsilon = f64::EPSILON * 2.0);
let (lng, lat) = webmercator_to_wgs84(20037508.34, 20037508.34);
assert_relative_eq!(lng, 179.9999999749437, epsilon = f64::EPSILON * 2.0);
assert_relative_eq!(lat, 85.05112877764508, epsilon = f64::EPSILON * 2.0);
let (lng, lat) = webmercator_to_wgs84(0.0, 0.0);
assert_relative_eq!(lng, 0.0, epsilon = f64::EPSILON * 2.0);
assert_relative_eq!(lat, 0.0, epsilon = f64::EPSILON * 2.0);
let (lng, lat) = webmercator_to_wgs84(3000.0, 9000.0);
assert_relative_eq!(lng, 0.026949458523585632, epsilon = f64::EPSILON * 2.0);
assert_relative_eq!(lat, 0.08084834874097367, epsilon = f64::EPSILON * 2.0);
}
}

View File

@ -0,0 +1,12 @@
export function strToColor(str: string): string {
let hash = 0;
str.split('').forEach(char => {
hash = char.charCodeAt(0) + ((hash << 5) - hash)
})
let colour = '#'
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff
colour += value.toString(16).padStart(2, '0')
}
return colour
}

View File

@ -0,0 +1,55 @@
:root {
--size-0: 0px;
--size-px: 1px;
--size-0_5: 0.125rem;
--size-1: 0.25rem;
--size-1_5: 0.375rem;
--size-2: 0.5rem;
--size-2_5: 0.625rem;
--size-3: 0.75rem;
--size-3_5: 0.875rem;
--size-4: 1rem;
--size-5: 1.25rem;
--size-6: 1.5rem;
--size-7: 1.75rem;
--size-8: 2rem;
--size-9: 2.25rem;
--size-10: 2.5rem;
--size-11: 2.75rem;
--size-12: 3rem;
--size-14: 3.5rem;
--size-16: 4rem;
--size-20: 5rem;
--size-24: 6rem;
--size-28: 7rem;
--size-32: 8rem;
--size-36: 9rem;
--size-40: 10rem;
--size-44: 11rem;
--size-48: 12rem;
--size-52: 13rem;
--size-56: 14rem;
--size-60: 15rem;
--size-64: 16rem;
--size-72: 18rem;
--size-80: 20rem;
--size-96: 24rem;
}
html {
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-size: 1rem;
line-height: 1.5rem;
margin: 0;
}
body {
margin: 0;
background-color: #000;
color: #fff;
}
.text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}

View File

@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import type {ActionReturn} from 'svelte/action'; import type {ActionReturn} from 'svelte/action';
import type { TileLayer, Map as LeafletMap } from 'leaflet';
import { tilerLayerAttribution, tilerLayerUrl } from '$lib/map/layer';
import type * as Leaflet from 'leaflet'; import type * as Leaflet from 'leaflet';
import {type Control, type Map as LeafletMap, type TileLayer} from 'leaflet';
import {tilerLayerAttribution, tilerLayerUrl} from '$lib/map/layer';
import {untrack} from "svelte";
import {SvelteDate} from "svelte/reactivity";
interface Props { interface Props {
map: LeafletMap | null; map: LeafletMap | null;
@ -10,8 +12,9 @@
baseLayer: 'osm'; baseLayer: 'osm';
dataLayer: 'noaa_mrms_merged_composite_reflectivity_qc' | null; dataLayer: 'noaa_mrms_merged_composite_reflectivity_qc' | null;
overlayLayers: string[]; overlayLayers: string[];
reload: boolean;
} }
let { map = $bindable(null), selected, baseLayer, dataLayer }: Props = $props(); let { map = $bindable(null), selected, baseLayer, dataLayer, reload = $bindable(false) }: Props = $props();
let mapContainerElement: HTMLElement; let mapContainerElement: HTMLElement;
// await import('leaflet') done at runtime // await import('leaflet') done at runtime
@ -20,6 +23,35 @@
let layer0: TileLayer; let layer0: TileLayer;
// Data layer - composite reflectivity, velocity (the actual data) // Data layer - composite reflectivity, velocity (the actual data)
let layer1: TileLayer; let layer1: TileLayer;
let attrControl: Control.Attribution | null = $state(null);
let dataValidity: SvelteDate | null = $state(null);
// redrawing
$effect(() => {
if (!L) return;
if (!map) return;
if (reload == true) {
untrack(() => {reload = false});
if (!dataLayer) return;
if (layer1) {
layer1.setUrl(tilerLayerUrl(dataLayer) + "?r=" + Math.random())
layer1.redraw();
}
if (dataLayer) {
fetch(tilerLayerUrl(dataLayer).replace("{z}", "0").replace("{x}", "0").replace("{y}", "0")).then((r) => {
let headerval = r.headers.get("x-wxbox-tiler-data-valid-time");
if (!headerval) {
return;
}
let valid_from = Number.parseInt(headerval);
dataValidity = new SvelteDate(valid_from * 1000);
})
}
}
})
// Layer0 (base) updating // Layer0 (base) updating
$effect(() => { $effect(() => {
@ -58,9 +90,52 @@
attribution: tilerLayerAttribution(dataLayer) attribution: tilerLayerAttribution(dataLayer)
}); });
layer1.addTo(map); layer1.addTo(map);
// fetch the data validity
fetch(tilerLayerUrl(dataLayer).replace("{z}", "0").replace("{x}", "0").replace("{y}", "0")).then((r) => {
let headerval = r.headers.get("x-wxbox-tiler-data-valid-time");
if (!headerval) {
return;
}
let valid_from = Number.parseInt(headerval);
dataValidity = new SvelteDate(valid_from * 1000);
});
} else {
dataValidity = null;
} }
}); });
const ukrainianFlag = '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="8" viewBox="0 0 12 8" class="leaflet-attribution-flag"><path fill="#4C7BE1" d="M0 0h12v4H0z"/><path fill="#FFD500" d="M0 4h12v3H0z"/><path fill="#E0BC00" d="M0 7h12v1H0z"/></svg>';
// Attribution control & data validity
$effect(() => {
if (!L) return;
if (!map) return;
let basePrefix = `<a href="https://leafletjs.com" title="A JavaScript library for interactive maps">${ukrainianFlag}Leaflet</a>`;
if (!attrControl) {
attrControl = L.control.attribution({
prefix: basePrefix
});
}
if (!attrControl) return;
attrControl.addTo(map);
let prefix = basePrefix;
if (!dataValidity) {
prefix += " | Data validity unknown";
} else {
prefix += ` | Data valid from ${dataValidity.toLocaleTimeString('en-US')}`
}
if (!attrControl) {
return;
}
attrControl.setPrefix(prefix);
})
// ran when the div below (see use:mapAction) is created // ran when the div below (see use:mapAction) is created
async function mapAction(): Promise<ActionReturn> { async function mapAction(): Promise<ActionReturn> {
// dynamically imports leaflet, as it's a browser lib // dynamically imports leaflet, as it's a browser lib
@ -72,7 +147,8 @@
map = L.map(mapContainerElement, { map = L.map(mapContainerElement, {
// geo center of CONUS // geo center of CONUS
center: [39.83, -98.583], center: [39.83, -98.583],
zoom: 5 zoom: 5,
attributionControl: false
}); });
if (!map) return {}; if (!map) return {};
@ -85,10 +161,10 @@
.map { .map {
flex: 1; flex: 1;
box-sizing: border-box; box-sizing: border-box;
border: 2px solid transparent; border: 3px solid #000;
} }
.mapselected { .mapselected {
box-sizing: border-box; box-sizing: border-box;
border: 2px solid red; border: 3px solid green;
} }
</style> </style>

View File

@ -32,3 +32,19 @@
</button> </button>
{/if} {/if}
{/each} {/each}
<style>
button {
box-sizing: border-box;
border: 2px inset darkgreen;
padding: var(--size-1) var(--size-3);
background-color: green;
color: #fff;
cursor: pointer;
}
button[disabled] {
background-color: darkgreen;
color: green;
cursor: not-allowed;
}
</style>

View File

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import "$lib/global.css";
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';

View File

@ -6,6 +6,7 @@
import type { View } from '$lib/map'; import type { View } from '$lib/map';
import type { Map as LMap } from 'leaflet'; import type { Map as LMap } from 'leaflet';
import { syncMaps } from '$lib/map/sync.svelte.js'; import { syncMaps } from '$lib/map/sync.svelte.js';
import {strToColor} from "$lib/color";
let map1: LMap | null = $state(null); let map1: LMap | null = $state(null);
let map2: LMap | null = $state(null); let map2: LMap | null = $state(null);
@ -298,13 +299,37 @@
let status: string = $derived.by(() => { let status: string = $derived.by(() => {
return mode + ' ' + pane; return mode + ' ' + pane;
}); });
let reload1: boolean = $state(false);
let reload2: boolean = $state(false);
let reload3: boolean = $state(false);
let reload4: boolean = $state(false);
let timeUntilReload: number = $state(60);
let reloadInterval: ReturnType<typeof setInterval> | null = null;
$effect(() => {
if (!reloadInterval) {
reloadInterval = setInterval(() => {
timeUntilReload--;
if (timeUntilReload === 0) {
timeUntilReload = 60;
reload1 = true;
reload2 = true;
reload3 = true;
reload4 = true;
}
}, 1000);
}
})
</script> </script>
<div class="outercontainer"> <div class="outercontainer">
<div class="toolbar"> <div class="toolbar">
<h1>wxbox</h1> <h1>wxbox</h1>
<span>{status}</span> <span class="status">{status}</span>
<ButtonBar menu={registry[mode]} /> <ButtonBar menu={registry[mode]} />
<span class="dataState">data reload in {timeUntilReload}s</span>
</div> </div>
<div class="container"> <div class="container">
<Map <Map
@ -313,6 +338,7 @@
baseLayer={baseLayer.map1} baseLayer={baseLayer.map1}
dataLayer={dataLayer.map1} dataLayer={dataLayer.map1}
overlayLayers={overlayLayers.map1} overlayLayers={overlayLayers.map1}
bind:reload={reload1}
/> />
{#if view === 'two' || view === 'four'} {#if view === 'two' || view === 'four'}
<Map <Map
@ -321,6 +347,7 @@
baseLayer={baseLayer.map2} baseLayer={baseLayer.map2}
dataLayer={dataLayer.map2} dataLayer={dataLayer.map2}
overlayLayers={overlayLayers.map2} overlayLayers={overlayLayers.map2}
bind:reload={reload2}
/> />
{/if} {/if}
</div> </div>
@ -332,6 +359,7 @@
baseLayer={baseLayer.map3} baseLayer={baseLayer.map3}
dataLayer={dataLayer.map3} dataLayer={dataLayer.map3}
overlayLayers={overlayLayers.map3} overlayLayers={overlayLayers.map3}
bind:reload={reload3}
/> />
<Map <Map
selected={pane === 'map4'} selected={pane === 'map4'}
@ -339,29 +367,30 @@
baseLayer={baseLayer.map4} baseLayer={baseLayer.map4}
dataLayer={dataLayer.map4} dataLayer={dataLayer.map4}
overlayLayers={overlayLayers.map4} overlayLayers={overlayLayers.map4}
bind:reload={reload4}
/> />
</div> </div>
{/if} {/if}
<div class="footer"> <div class="footer text-sm">
<p>built with &lt;3</p> <p>built with &lt;3</p>
<p>coredoes.dev :)</p> <p>u8.lc coredoes.dev :)</p>
</div> </div>
</div> </div>
<style> <style>
.toolbar { .toolbar h1 {
font-size: 1rem;
line-height: 1.5rem;
margin: 0; margin: 0;
font-size: 12px; }
.toolbar {
margin: var(--size-1) var(--size-2);
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center;
gap: var(--size-2);
} }
.toolbar h1 {
margin: 0;
}
:global(html body) {
margin: 0;
padding: 0;
}
.container { .container {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -373,9 +402,12 @@
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
} }
.footer { .footer {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-left: var(--size-2);
margin-right: var(--size-2);
} }
.footer p { .footer p {
margin: 0; margin: 0;