Compare commits
8 commits
Author | SHA1 | Date | |
---|---|---|---|
074ced2e6e | |||
1ee6707d39 | |||
d27ba95d32 | |||
310a354f2f | |||
c2a3ddb1ec | |||
fbe4b57bb4 | |||
4cc5663070 | |||
181db4e881 |
48 changed files with 4197 additions and 1207 deletions
3
.cargo/config.toml
Normal file
3
.cargo/config.toml
Normal file
|
@ -0,0 +1,3 @@
|
|||
[profile.dev]
|
||||
opt-level = 1
|
||||
incremental = true
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -1,3 +1,5 @@
|
|||
*/target
|
||||
target
|
||||
node_modules
|
||||
node_modules
|
||||
.cache
|
||||
wxbox_client_wasm/dist
|
|
@ -6,6 +6,12 @@
|
|||
<sourceFolder url="file://$MODULE_DIR$/wxbox-tiler/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/wxbox-eccodes-sys/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/wxbox-web/wxbox-eccodes-sys/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/wxbox-grib2/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/wxbox_client/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/wxbox_client_native/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/wxbox_client_wasm/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/wxbox_common/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/wxbox_web/src" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||
|
|
3405
Cargo.lock
generated
3405
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -1,6 +1,6 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = [ "wxbox-grib2","wxbox-pal","wxbox-tiler"]
|
||||
members = [ "wxbox-grib2","wxbox-pal","wxbox-tiler", "wxbox_client", "wxbox_client_native", "wxbox_client_wasm", "wxbox_common"]
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
@ -9,4 +9,4 @@ lto = "fat"
|
|||
[profile.dev.package.image]
|
||||
opt-level = 3
|
||||
[profile.dev.package.png]
|
||||
opt-level = 3
|
||||
opt-level = 3
|
||||
|
|
BIN
MRMS_MergedRhoHV_03.00.latest.grib2
Normal file
BIN
MRMS_MergedRhoHV_03.00.latest.grib2
Normal file
Binary file not shown.
94
config.toml
94
config.toml
|
@ -1,4 +1,72 @@
|
|||
[sources.grib2.noaa_mrms_merged_composite_reflectivity_qc]
|
||||
[sources.grib2.noaa_mrms_merged_composite_reflectivity_qc_CONUS]
|
||||
from = "https://mrms.ncep.noaa.gov/data/2D/MergedReflectivityQCComposite/MRMS_MergedReflectivityQCComposite.latest.grib2.gz"
|
||||
needs_gzip = true
|
||||
valid_for = 120
|
||||
palette = """
|
||||
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
|
||||
"""
|
||||
missing = -99.0
|
||||
no_coverage = -999.0
|
||||
|
||||
[sources.grib2.noaa_mrms_merged_composite_reflectivity_qc_ALASKA]
|
||||
from = "https://mrms.ncep.noaa.gov/data/2D/ALASKA/MergedReflectivityQCComposite/MRMS_MergedReflectivityQCComposite.latest.grib2.gz"
|
||||
needs_gzip = true
|
||||
valid_for = 120
|
||||
palette = """
|
||||
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
|
||||
"""
|
||||
missing = -99.0
|
||||
no_coverage = -999.0
|
||||
|
||||
[sources.grib2.noaa_mrms_merged_composite_reflectivity_qc_CARIB]
|
||||
from = "https://mrms.ncep.noaa.gov/data/2D/CARIB/MergedReflectivityQCComposite/MRMS_MergedReflectivityQCComposite.latest.grib2.gz"
|
||||
needs_gzip = true
|
||||
valid_for = 120
|
||||
palette = """
|
||||
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
|
||||
"""
|
||||
missing = -99.0
|
||||
no_coverage = -999.0
|
||||
|
||||
[sources.grib2.noaa_mrms_merged_composite_reflectivity_qc_GUAM]
|
||||
from = "https://mrms.ncep.noaa.gov/data/2D/GUAM/MergedReflectivityQCComposite/MRMS_MergedReflectivityQCComposite.latest.grib2.gz"
|
||||
needs_gzip = true
|
||||
valid_for = 120
|
||||
palette = """
|
||||
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
|
||||
"""
|
||||
missing = -99.0
|
||||
no_coverage = -999.0
|
||||
|
||||
[sources.grib2.noaa_mrms_merged_composite_reflectivity_qc_HAWAII]
|
||||
from = "https://mrms.ncep.noaa.gov/data/2D/HAWAII/MergedReflectivityQCComposite/MRMS_MergedReflectivityQCComposite.latest.grib2.gz"
|
||||
needs_gzip = true
|
||||
valid_for = 120
|
||||
|
@ -13,4 +81,28 @@ Color: 70 255 255 255 128 128 128
|
|||
Color: 80 128 128 128
|
||||
"""
|
||||
missing = -99.0
|
||||
no_coverage = -999.0
|
||||
|
||||
[sources.grib2.noaa_mrms_merged_rhohv_3km_CONUS]
|
||||
from = "https://mrms.ncep.noaa.gov/3DRhoHV/MergedRhoHV_03.00/MRMS_MergedRhoHV_03.00.latest.grib2.gz"
|
||||
needs_gzip = true
|
||||
valid_for = 300
|
||||
palette = """
|
||||
Color: 0.20 149 148 156
|
||||
Color: 0.45 22 20 140
|
||||
Color: 0.65 9 2 233
|
||||
Color: 0.75 137 135 214
|
||||
Color: 0.80 92 255 89
|
||||
Color: 0.85 139 207 2
|
||||
Color: 0.90 255 251 0
|
||||
Color: 0.93 255 196 0
|
||||
Color: 0.95 255 137 3
|
||||
Color: 0.96 255 43 0
|
||||
Color: 0.97 227 0 0
|
||||
Color: 0.98 161 0 0
|
||||
Color: 0.99 121 0 90
|
||||
Color: 1.00 250 172 209
|
||||
Color: 1.05 119 0 125
|
||||
"""
|
||||
missing = -99.0
|
||||
no_coverage = -999.0
|
|
@ -6,7 +6,7 @@ use std::fmt::{Debug, Formatter};
|
|||
use std::io::{Cursor, Read};
|
||||
use std::time::Instant;
|
||||
use image::codecs::png::PngDecoder;
|
||||
use image::{DynamicImage, ImageBuffer, ImageDecoder, ImageFormat, ImageReader, Luma};
|
||||
use image::{DynamicImage, GenericImageView, ImageBuffer, ImageDecoder, ImageFormat, ImageReader, Luma};
|
||||
use tracing::{debug, warn};
|
||||
use crate::error::GribError;
|
||||
use crate::LatLongVectorRelativity::{EasterlyAndNortherly, IncreasingXY};
|
||||
|
@ -104,6 +104,8 @@ impl GribMessage {
|
|||
let bitmap = bitmap.ok_or(GribError::MissingBitmap)?;
|
||||
let data = data.ok_or(GribError::MissingData)?;
|
||||
|
||||
debug!("{:?}", data_representation);
|
||||
|
||||
data_representation.load_data(data.data.clone())?;
|
||||
|
||||
Ok(Self {
|
||||
|
@ -315,7 +317,7 @@ pub struct GridpointPNGDataRepresentation {
|
|||
pub depth: u8,
|
||||
pub type_of_values: u8,
|
||||
|
||||
pub image: Option<ImageBuffer<Luma<u16>, Vec<u16>>>
|
||||
pub image: Option<DynamicImage>
|
||||
}
|
||||
impl GridpointPNGDataRepresentation {
|
||||
fn parse<R: Read>(_length: u32, nommer: &mut NomReader<R>) -> Result<Self, GribError> {
|
||||
|
@ -339,7 +341,7 @@ impl GridpointPNGDataRepresentation {
|
|||
|
||||
let image = image_reader.decode()?;
|
||||
|
||||
self.image = Some(image.to_luma16());
|
||||
self.image = Some(image);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@ -347,17 +349,50 @@ impl GridpointPNGDataRepresentation {
|
|||
fn get_image_coordinate(&self, x: u32, y: u32) -> Option<f32> {
|
||||
match &self.image {
|
||||
Some(i) => {
|
||||
if x >= i.width() || y >= i.height() {
|
||||
None
|
||||
} else {
|
||||
let datapoint = i.get_pixel(x, y).0[0] as f32;
|
||||
let diff = datapoint * 2.0_f32.powi(self.binary_scale_factor as i32);
|
||||
let dig_factor = 10_f32.powi(-(self.decimal_scale_factor as i32));
|
||||
let value = (self.reference_value + diff) * dig_factor;
|
||||
Some(value)
|
||||
match self.depth {
|
||||
1 | 2 | 4 | 8 | 16 => {
|
||||
if x >= i.width() || y >= i.height() {
|
||||
None
|
||||
} else {
|
||||
let datapoint = i.as_luma16().unwrap().get_pixel(x, y).0[0] as f32;
|
||||
let diff = datapoint * 2.0_f32.powi(self.binary_scale_factor as i32);
|
||||
let dig_factor = 10_f32.powi(-(self.decimal_scale_factor as i32));
|
||||
let value = (self.reference_value + diff) * dig_factor;
|
||||
Some(value)
|
||||
}
|
||||
},
|
||||
24 => {
|
||||
if x >= i.width() || y >= i.height() {
|
||||
None
|
||||
} else {
|
||||
let datapoint_channels = i.as_rgb8().unwrap().get_pixel(x, y).0;
|
||||
|
||||
let datapoint = u32::from_be_bytes([0, datapoint_channels[0], datapoint_channels[1], datapoint_channels[2]]) as f32;
|
||||
|
||||
let diff = datapoint * 2.0_f32.powi(self.binary_scale_factor as i32);
|
||||
let dig_factor = 10_f32.powi(-(self.decimal_scale_factor as i32));
|
||||
let value = (self.reference_value + diff) * dig_factor;
|
||||
Some(value)
|
||||
}
|
||||
},
|
||||
32 => {
|
||||
if x >= i.width() || y >= i.height() {
|
||||
None
|
||||
} else {
|
||||
let datapoint_channels = i.as_rgba8().unwrap().get_pixel(x, y).0;
|
||||
|
||||
let datapoint = u32::from_be_bytes(datapoint_channels) as f32;
|
||||
|
||||
let diff = datapoint * 2.0_f32.powi(self.binary_scale_factor as i32);
|
||||
let dig_factor = 10_f32.powi(-(self.decimal_scale_factor as i32));
|
||||
let value = (self.reference_value + diff) * dig_factor;
|
||||
Some(value)
|
||||
}
|
||||
},
|
||||
_ => panic!("unsupported bit depth")
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
None => None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,6 +21,9 @@ tracing = "0.1"
|
|||
tracing-subscriber = "0.3"
|
||||
toml = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
wxbox_common = { path = "../wxbox_common" }
|
||||
image = "0.25"
|
||||
|
||||
[dev-dependencies]
|
||||
approx = "0.5"
|
||||
|
|
|
@ -36,7 +36,7 @@ async fn main() -> std::io::Result<()> {
|
|||
});
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
.service(sources::grib2::grib2_source)
|
||||
.service(sources::grib2::source)
|
||||
.app_data(data.clone())
|
||||
})
|
||||
.bind(("::", 8080))?
|
||||
|
|
|
@ -1,19 +1,25 @@
|
|||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::f64::consts::PI;
|
||||
use std::io::{BufWriter, Cursor, Read};
|
||||
use std::ops::{Add, Div, Mul, Sub};
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
use actix_web::error::UrlencodedError::ContentType;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::HttpResponse;
|
||||
use actix_web::web::Data;
|
||||
use actix_web::web::{Data, Query};
|
||||
use flate2::read::GzDecoder;
|
||||
use png::{BitDepth, ColorType, Encoder};
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::RwLock;
|
||||
use wxbox_common::TileRequestOptions;
|
||||
use image::{ImageFormat, ImageReader};
|
||||
use reqwest::ClientBuilder;
|
||||
use wxbox_grib2::GribMessage;
|
||||
use wxbox_grib2::wgs84::LatLong;
|
||||
use wxbox_pal::{Color, ColorPalette, Palette};
|
||||
use crate::AppState;
|
||||
use tracing::{debug, info};
|
||||
use crate::config::Grib2Source;
|
||||
use crate::pixmap::Pixmap;
|
||||
|
||||
|
@ -69,7 +75,7 @@ pub async fn reload_if_required(from: &str, needs_gzip: bool, valid_for: u64, lu
|
|||
const TWO_PI: f64 = PI * 2.0;
|
||||
const HALF_PI: f64 = PI / 2.0;
|
||||
|
||||
pub async fn render(xtile: f64, ytile: f64, z: i32, tilesize: usize, pal: Palette, map: &Arc<RwLock<GribMessage>>, missing: Option<f64>, range_folded: Option<f64>, no_coverage: Option<f64>) -> Vec<u8> {
|
||||
pub async fn render(xtile: f64, ytile: f64, z: i32, tilesize: usize, pal: Palette, map: &Arc<RwLock<GribMessage>>, missing: Option<f64>, range_folded: Option<f64>, no_coverage: Option<f64>, options: &TileRequestOptions) -> Pixmap {
|
||||
let mut image: Pixmap = Pixmap::new();
|
||||
|
||||
let denominator = 2.0_f64.powi(z) * tilesize as f64;
|
||||
|
@ -94,9 +100,22 @@ pub async fn render(xtile: f64, ytile: f64, z: i32, tilesize: usize, pal: Palett
|
|||
let color = match nearest {
|
||||
Some(c) if Some(c) == no_coverage => Color { red: 0, green: 0, blue: 0, alpha: 30 },
|
||||
Some(c) if Some(c) == missing => Color { red: 0, green: 0, blue: 0, alpha: 0 },
|
||||
Some(c) if Some(c) == range_folded => Color { red: 141, green: 0, blue: 160, alpha: 0 },
|
||||
Some(c) if Some(c) == range_folded => {
|
||||
if options.show_range_folded {
|
||||
let color_raw = options.range_folded_color.to_be_bytes();
|
||||
Color { red: color_raw[0], green: color_raw[1], blue: color_raw[2], alpha: color_raw[3] }
|
||||
} else {
|
||||
Color { red: 0, green: 0, blue: 0, alpha: 0 }
|
||||
}
|
||||
},
|
||||
Some(value_at_pos) => {
|
||||
pal.colorize(value_at_pos)
|
||||
let mut c = pal.colorize(value_at_pos);
|
||||
if c.red == 0 && c.green == 0 && c.blue == 0 {
|
||||
c.alpha = 0;
|
||||
} else {
|
||||
c.alpha = coloru8(options.data_transparency);
|
||||
}
|
||||
c
|
||||
},
|
||||
None => Color { red: 0, green: 0, blue: 0, alpha: 30 }
|
||||
};
|
||||
|
@ -106,12 +125,231 @@ pub async fn render(xtile: f64, ytile: f64, z: i32, tilesize: usize, pal: Palett
|
|||
}
|
||||
}
|
||||
|
||||
image
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QueryReq {
|
||||
settings: String
|
||||
}
|
||||
|
||||
fn colorf64(i: u8) -> f64 {
|
||||
i as f64 / u8::MAX as f64
|
||||
}
|
||||
fn coloru8(i: f64) -> u8 {
|
||||
(i * u8::MAX as f64).floor() as u8
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ColorF64 {
|
||||
pub red: f64,
|
||||
pub green: f64,
|
||||
pub blue: f64,
|
||||
pub alpha: f64
|
||||
}
|
||||
impl From<Color> for ColorF64 {
|
||||
fn from(value: Color) -> Self {
|
||||
Self {
|
||||
red: colorf64(value.red),
|
||||
green: colorf64(value.green),
|
||||
blue: colorf64(value.blue),
|
||||
alpha: colorf64(value.alpha)
|
||||
}
|
||||
}
|
||||
}
|
||||
impl From<ColorF64> for Color {
|
||||
fn from(value: ColorF64) -> Self {
|
||||
Self {
|
||||
red: coloru8(value.red),
|
||||
green: coloru8(value.green),
|
||||
blue: coloru8(value.blue),
|
||||
alpha: coloru8(value.alpha)
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Add for ColorF64 {
|
||||
type Output = ColorF64;
|
||||
|
||||
fn add(self, rhs: Self) -> Self::Output {
|
||||
Self {
|
||||
red: self.red + rhs.red,
|
||||
green: self.green + rhs.green,
|
||||
blue: self.blue + rhs.blue,
|
||||
alpha: self.alpha + rhs.alpha
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Sub for ColorF64 {
|
||||
type Output = ColorF64;
|
||||
|
||||
fn sub(self, rhs: Self) -> Self::Output {
|
||||
Self {
|
||||
red: self.red - rhs.red,
|
||||
green: self.green - rhs.green,
|
||||
blue: self.blue - rhs.blue,
|
||||
alpha: self.alpha - rhs.alpha
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Sub<ColorF64> for f64 {
|
||||
type Output = ColorF64;
|
||||
|
||||
fn sub(self, rhs: ColorF64) -> Self::Output {
|
||||
ColorF64 {
|
||||
red: rhs.red - self,
|
||||
green: rhs.green - self,
|
||||
blue: rhs.blue - self,
|
||||
alpha: rhs.alpha - self
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Mul<f64> for ColorF64 {
|
||||
type Output = Self;
|
||||
|
||||
fn mul(self, rhs: f64) -> Self::Output {
|
||||
Self {
|
||||
red: self.red * rhs,
|
||||
blue: self.blue * rhs,
|
||||
green: self.green * rhs,
|
||||
alpha: self.alpha * rhs
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Mul for ColorF64 {
|
||||
type Output = ColorF64;
|
||||
|
||||
fn mul(self, rhs: Self) -> Self::Output {
|
||||
Self {
|
||||
red: self.red * rhs.red,
|
||||
green: self.green * rhs.green,
|
||||
blue: self.blue * rhs.blue,
|
||||
alpha: self.alpha * rhs.alpha
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Div for ColorF64 {
|
||||
type Output = ColorF64;
|
||||
|
||||
fn div(self, rhs: Self) -> Self::Output {
|
||||
Self {
|
||||
red: self.red / rhs.red,
|
||||
green: self.green / rhs.green,
|
||||
blue: self.blue / rhs.blue,
|
||||
alpha: self.alpha / rhs.alpha
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Div<f64> for ColorF64 {
|
||||
type Output = ColorF64;
|
||||
|
||||
fn div(self, rhs: f64) -> Self::Output {
|
||||
Self {
|
||||
red: self.red / rhs,
|
||||
green: self.green / rhs,
|
||||
blue: self.blue / rhs,
|
||||
alpha: self.alpha / rhs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub fn merge(base: Pixmap, data: Pixmap) -> Pixmap {
|
||||
let mut new = Pixmap::new();
|
||||
|
||||
for x in 0..256 {
|
||||
for y in 0..256 {
|
||||
let mut c_b: ColorF64 = base.get(x, y).into();
|
||||
let mut c_s: ColorF64 = data.get(x, y).into();
|
||||
|
||||
if c_s.red == 0.0 && c_s.green == 0.0 && c_s.blue == 0.0 && c_s.alpha == 0.0 {
|
||||
new.set(x, y, c_b.into());
|
||||
} else {
|
||||
let mut co = (c_s * c_s.alpha + c_b * c_b.alpha * (1.0 - c_s.alpha)) / (c_s.alpha + c_b.alpha * (1.0 - c_s.alpha));
|
||||
co.alpha = 1.0;
|
||||
|
||||
new.set(x, y, co.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
new
|
||||
}
|
||||
|
||||
#[actix_web::get("/{z}/{x}/{y}.png")]
|
||||
pub async fn source(path: actix_web::web::Path<(i32, u32, u32)>, req: Query<QueryReq>, data: Data<AppState>) -> HttpResponse {
|
||||
let settings: TileRequestOptions = serde_json::from_str(&req.settings).unwrap();
|
||||
|
||||
// todo: load the base layer from external source
|
||||
let base_layer = Pixmap::new();
|
||||
|
||||
let base_layer: Pixmap = if settings.baselayer == "osm" {
|
||||
let client = ClientBuilder::new()
|
||||
.user_agent(format!("wxbox-tiler/{}", env!("CARGO_PKG_VERSION")))
|
||||
.build().unwrap();
|
||||
let body = client.get(format!("https://tile.openstreetmap.org/{}/{}/{}.png", path.0, path.1, path.2))
|
||||
.send()
|
||||
.await.unwrap().bytes().await.unwrap();
|
||||
let mut img = ImageReader::new(Cursor::new(body.to_vec()));
|
||||
img.set_format(ImageFormat::Png);
|
||||
let img = img.decode().unwrap();
|
||||
let rgb = img.into_rgba8();
|
||||
// copy it into a pixmap
|
||||
let mut map = Pixmap::new();
|
||||
for x in 0..256_usize {
|
||||
for y in 0..256_usize {
|
||||
let pix = rgb.get_pixel(y as u32, x as u32);
|
||||
map.set(x, y, Color {
|
||||
red: pix[0],
|
||||
green: pix[1],
|
||||
blue: pix[2],
|
||||
alpha: pix[3]
|
||||
});
|
||||
}
|
||||
}
|
||||
map
|
||||
} else {
|
||||
debug!("not found baselayer");
|
||||
return HttpResponse::new(StatusCode::NOT_FOUND)
|
||||
};
|
||||
|
||||
// data layer
|
||||
// valid types:
|
||||
// - grib2/
|
||||
|
||||
let data_layer: Pixmap = if settings.data.starts_with("grib2/") {
|
||||
if let Some(known_source) = data.config.sources.grib2.get(settings.data.strip_prefix("grib2/").unwrap()) {
|
||||
reload_if_required(
|
||||
&known_source.from,
|
||||
known_source.needs_gzip,
|
||||
known_source.valid_for.into(),
|
||||
&settings.data,
|
||||
&data.grib2_cache_timestamps,
|
||||
&data.grib2_cache
|
||||
).await;
|
||||
let lct_reader = data.grib2_cache_timestamps.read().await;
|
||||
if let Some(grib2) = data.grib2_cache.read().await.get(&settings.data) {
|
||||
crate::sources::grib2::render(path.1 as f64, path.2 as f64, path.0, 256, wxbox_pal::parser::parse(&known_source.palette).unwrap(), grib2, known_source.missing, known_source.range_folded, known_source.no_coverage, &settings).await
|
||||
} else {
|
||||
debug!("not found grib2 after reload in base cache");
|
||||
return HttpResponse::new(StatusCode::NOT_FOUND)
|
||||
}
|
||||
} else {
|
||||
debug!("not found grib2 in configuration");
|
||||
return HttpResponse::new(StatusCode::NOT_FOUND)
|
||||
}
|
||||
} else {
|
||||
debug!("not found datalayer registry");
|
||||
return HttpResponse::new(StatusCode::NOT_FOUND)
|
||||
};
|
||||
|
||||
let image = merge(base_layer, data_layer);
|
||||
|
||||
let mut buf: Vec<u8> = vec![];
|
||||
// borrow checker insanity
|
||||
{
|
||||
let mut cur: Cursor<_> = Cursor::new(&mut buf);
|
||||
let w = &mut BufWriter::new(&mut cur);
|
||||
let mut encoder = Encoder::new(w, tilesize as u32, tilesize as u32);
|
||||
let mut encoder = Encoder::new(w, 256, 256);
|
||||
encoder.set_color(ColorType::Rgba);
|
||||
encoder.set_depth(BitDepth::Eight);
|
||||
encoder.set_source_gamma(png::ScaledFloat::from_scaled(45455));
|
||||
|
@ -128,36 +366,14 @@ pub async fn render(xtile: f64, ytile: f64, z: i32, tilesize: usize, pal: Palett
|
|||
writer.finish().unwrap();
|
||||
}
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
#[actix_web::get("/grib2/{id}/{z}/{x}/{y}.png")]
|
||||
pub async fn grib2_source(path: actix_web::web::Path<(String, i32, u32, u32)>, data: Data<AppState>) -> HttpResponse {
|
||||
if let Some(known_source) = data.config.sources.grib2.get(&path.0) {
|
||||
reload_if_required(
|
||||
&known_source.from,
|
||||
known_source.needs_gzip,
|
||||
known_source.valid_for.into(),
|
||||
&path.0,
|
||||
&data.grib2_cache_timestamps,
|
||||
&data.grib2_cache
|
||||
).await;
|
||||
let lct_reader = data.grib2_cache_timestamps.read().await;
|
||||
if let Some(grib2) = data.grib2_cache.read().await.get(&path.0) {
|
||||
HttpResponse::Ok()
|
||||
.insert_header(actix_web::http::header::ContentType(mime::IMAGE_PNG))
|
||||
// TODO: use the timestamp in the grib2 ID section
|
||||
.insert_header(("x-wxbox-tiler-data-valid-time", lct_reader.get(&path.0).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.2 as f64, path.3 as f64, path.1, 256, wxbox_pal::parser::parse(&known_source.palette).unwrap(), grib2, known_source.missing, known_source.range_folded, known_source.no_coverage).await)
|
||||
} else {
|
||||
HttpResponse::new(StatusCode::NOT_FOUND)
|
||||
}
|
||||
} else {
|
||||
HttpResponse::new(StatusCode::NOT_FOUND)
|
||||
}
|
||||
HttpResponse::Ok()
|
||||
.insert_header(actix_web::http::header::ContentType(mime::IMAGE_PNG))
|
||||
// TODO: use the timestamp in the grib2 ID section
|
||||
//.insert_header(("x-wxbox-tiler-data-valid-time", lct_reader.get(&settings.data).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(buf)
|
||||
}
|
||||
/*
|
||||
#[macro_export]
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
node_modules
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
.dockerignore
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
LICENSE
|
||||
.vscode
|
||||
Makefile
|
||||
helm-charts
|
||||
.env
|
||||
.editorconfig
|
||||
.idea
|
||||
cogerage*
|
||||
target*
|
|
@ -1,2 +0,0 @@
|
|||
# Base url of your wxbox-tiler instance
|
||||
PUBLIC_TILER_URL_BASE=""
|
21
wxbox-web/.gitignore
vendored
21
wxbox-web/.gitignore
vendored
|
@ -1,21 +0,0 @@
|
|||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
|
@ -1 +0,0 @@
|
|||
engine-strict=true
|
|
@ -1,4 +0,0 @@
|
|||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
FROM oven/bun:1 AS base
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
FROM base AS install
|
||||
RUN mkdir -p /tmp/dev
|
||||
COPY package.json bun.lockb /tmp/dev
|
||||
RUN cd /tmp/dev && bun install --frozen-lockfile
|
||||
|
||||
RUN mkdir -p /tmp/prod
|
||||
COPY package.json bun.lockb /tmp/prod
|
||||
RUN cd /tmp/prod && bun install --frozen-lockfile --production
|
||||
|
||||
FROM base AS release
|
||||
COPY --from=install /tmp/dev/node_modules node_modules
|
||||
COPY . .
|
||||
|
||||
ENV NODE_ENV=production
|
||||
RUN bun test
|
||||
RUN bun run build
|
||||
|
||||
USER bun
|
||||
EXPOSE 3000/tcp
|
||||
ENTRYPOINT [ "bun", "--bun", "run", "./build" ]
|
|
@ -1,38 +0,0 @@
|
|||
# create-svelte
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```bash
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
Binary file not shown.
|
@ -1,33 +0,0 @@
|
|||
import prettier from 'eslint-config-prettier';
|
||||
import js from '@eslint/js';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import globals from 'globals';
|
||||
import ts from 'typescript-eslint';
|
||||
|
||||
export default ts.config(
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs['flat/recommended'],
|
||||
prettier,
|
||||
...svelte.configs['flat/prettier'],
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte'],
|
||||
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: ts.parser
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['build/', '.svelte-kit/', 'dist/']
|
||||
}
|
||||
);
|
|
@ -1,37 +0,0 @@
|
|||
{
|
||||
"name": "wxbox-web",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.3.1",
|
||||
"@sveltejs/adapter-node": "^5.2.11",
|
||||
"@sveltejs/kit": "^2.7.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/leaflet": "^1.9.14",
|
||||
"eslint": "^9.13.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.46.0",
|
||||
"globals": "^15.11.0",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-svelte": "^3.2.7",
|
||||
"svelte": "^5.1.3",
|
||||
"svelte-check": "^4.0.5",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.11.0",
|
||||
"vite": "^5.4.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.sync": "^0.2.4"
|
||||
}
|
||||
}
|
13
wxbox-web/src/app.d.ts
vendored
13
wxbox-web/src/app.d.ts
vendored
|
@ -1,13 +0,0 @@
|
|||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
|
@ -1,12 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
|
@ -1,12 +0,0 @@
|
|||
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
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
: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;
|
||||
}
|
|
@ -1,170 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type {ActionReturn} from 'svelte/action';
|
||||
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 {
|
||||
map: LeafletMap | null;
|
||||
selected: boolean;
|
||||
baseLayer: 'osm';
|
||||
dataLayer: 'grib2/noaa_mrms_merged_composite_reflectivity_qc' | null;
|
||||
overlayLayers: string[];
|
||||
reload: boolean;
|
||||
}
|
||||
let { map = $bindable(null), selected, baseLayer, dataLayer, reload = $bindable(false) }: Props = $props();
|
||||
|
||||
let mapContainerElement: HTMLElement;
|
||||
// await import('leaflet') done at runtime
|
||||
let L: Leaflet | null = $state(null);
|
||||
// Base layer - openstreetmap, carto, etc
|
||||
let layer0: TileLayer;
|
||||
// Data layer - composite reflectivity, velocity (the actual data)
|
||||
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
|
||||
$effect(() => {
|
||||
// if leaflet hasn't been imported yet, skip
|
||||
// this also sets a dependency, so we'll be re-ran once it has
|
||||
if (!L) return;
|
||||
if (!map) return;
|
||||
|
||||
// if there is already a layer0 and a map, remove the old one
|
||||
if (layer0 && map) {
|
||||
layer0.removeFrom(map);
|
||||
}
|
||||
|
||||
// OpenStreetMap
|
||||
if (baseLayer === 'osm') {
|
||||
layer0 = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
});
|
||||
layer0.addTo(map);
|
||||
}
|
||||
});
|
||||
|
||||
// Layer1 (data) updating
|
||||
$effect(() => {
|
||||
if (!L) return;
|
||||
if (!map) return;
|
||||
|
||||
// remove existing layer1, if there is one
|
||||
if (layer1 && map) {
|
||||
layer1.removeFrom(map);
|
||||
}
|
||||
|
||||
if (dataLayer) {
|
||||
layer1 = L.tileLayer(tilerLayerUrl(dataLayer), {
|
||||
attribution: tilerLayerAttribution(dataLayer)
|
||||
});
|
||||
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
|
||||
async function mapAction(): Promise<ActionReturn> {
|
||||
// dynamically imports leaflet, as it's a browser lib
|
||||
L = await import('leaflet');
|
||||
// imports leaflet.sync, for syncing
|
||||
await import('leaflet.sync');
|
||||
|
||||
// create the map
|
||||
map = L.map(mapContainerElement, {
|
||||
// geo center of CONUS
|
||||
center: [39.83, -98.583],
|
||||
zoom: 5,
|
||||
attributionControl: false
|
||||
});
|
||||
|
||||
if (!map) return {};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="map" class:mapselected={selected} bind:this={mapContainerElement} use:mapAction></div>
|
||||
|
||||
<style>
|
||||
.map {
|
||||
flex: 1;
|
||||
box-sizing: border-box;
|
||||
border: 3px solid #000;
|
||||
}
|
||||
.mapselected {
|
||||
box-sizing: border-box;
|
||||
border: 3px solid green;
|
||||
}
|
||||
</style>
|
|
@ -1,2 +0,0 @@
|
|||
// There is no good-looking three-pane view
|
||||
export type View = 'one' | 'two' | 'four';
|
|
@ -1,32 +0,0 @@
|
|||
import { env } from '$env/dynamic/public';
|
||||
|
||||
export interface MutexLayerSet<ValidOpts> {
|
||||
map1: ValidOpts;
|
||||
map2: ValidOpts;
|
||||
map3: ValidOpts;
|
||||
map4: ValidOpts;
|
||||
}
|
||||
export interface OverlayLayerSet<ValidOpts> {
|
||||
map1: ValidOpts[];
|
||||
map2: ValidOpts[];
|
||||
map3: ValidOpts[];
|
||||
map4: ValidOpts[];
|
||||
}
|
||||
|
||||
export type BaseLayers = 'osm';
|
||||
export type DataLayers = 'grib2/noaa_mrms_merged_composite_reflectivity_qc';
|
||||
|
||||
export function tilerLayerUrl(id: DataLayers): string {
|
||||
if (!env.PUBLIC_TILER_URL_BASE) {
|
||||
throw new Error('PUBLIC_TILER_URL_BASE env var not set!');
|
||||
}
|
||||
const base = new URL(env.PUBLIC_TILER_URL_BASE);
|
||||
return (base + `${id}/{z}/{x}/{y}.png`).toString();
|
||||
}
|
||||
export function tilerLayerAttribution(id: DataLayers): string {
|
||||
let base;
|
||||
if (id === 'grib2/noaa_mrms_merged_composite_reflectivity_qc') {
|
||||
base = '© NOAA';
|
||||
}
|
||||
return base + ', © wxbox';
|
||||
}
|
|
@ -1,60 +0,0 @@
|
|||
import type { Map as LMap } from 'leaflet';
|
||||
import type { View } from '$lib/map';
|
||||
|
||||
export function syncMaps(
|
||||
view: View,
|
||||
map1: LMap | null,
|
||||
map2: LMap | null,
|
||||
map3: LMap | null,
|
||||
map4: LMap | null
|
||||
) {
|
||||
// resize the shown maps
|
||||
if (view === 'one' && map1) {
|
||||
map1.invalidateSize();
|
||||
} else if (view === 'two') {
|
||||
if (map1) map1.invalidateSize();
|
||||
if (map2) map2.invalidateSize();
|
||||
} else if (view === 'four') {
|
||||
if (map1) map1.invalidateSize();
|
||||
if (map2) map2.invalidateSize();
|
||||
if (map3) map3.invalidateSize();
|
||||
if (map4) map4.invalidateSize();
|
||||
}
|
||||
|
||||
if (map1 && map2) {
|
||||
map2.setView(map1.getCenter(), map1.getZoom());
|
||||
// @ts-expect-error leaflet.sync does not provide typedefs
|
||||
map1.sync(map2);
|
||||
}
|
||||
if (map1 && map3) {
|
||||
map3.setView(map1.getCenter(), map1.getZoom());
|
||||
// @ts-expect-error leaflet.sync does not provide typedefs
|
||||
map1.sync(map3);
|
||||
}
|
||||
if (map1 && map4) {
|
||||
map4.setView(map1.getCenter(), map1.getZoom());
|
||||
// @ts-expect-error leaflet.sync does not provide typedefs
|
||||
map1.sync(map4);
|
||||
}
|
||||
|
||||
// @ts-expect-error leaflet.sync does not provide typedefs
|
||||
if (map2 && map1) map2.sync(map1);
|
||||
// @ts-expect-error leaflet.sync does not provide typedefs
|
||||
if (map2 && map3) map2.sync(map3);
|
||||
// @ts-expect-error leaflet.sync does not provide typedefs
|
||||
if (map2 && map4) map2.sync(map4);
|
||||
|
||||
// @ts-expect-error leaflet.sync does not provide typedefs
|
||||
if (map3 && map1) map3.sync(map1);
|
||||
// @ts-expect-error leaflet.sync does not provide typedefs
|
||||
if (map3 && map2) map3.sync(map2);
|
||||
// @ts-expect-error leaflet.sync does not provide typedefs
|
||||
if (map3 && map4) map3.sync(map4);
|
||||
|
||||
// @ts-expect-error leaflet.sync does not provide typedefs
|
||||
if (map4 && map1) map4.sync(map1);
|
||||
// @ts-expect-error leaflet.sync does not provide typedefs
|
||||
if (map4 && map2) map4.sync(map2);
|
||||
// @ts-expect-error leaflet.sync does not provide typedefs
|
||||
if (map4 && map3) map4.sync(map3);
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { MenuItem } from '$lib/menubar/buttonlib';
|
||||
|
||||
interface Props {
|
||||
menu: MenuItem[];
|
||||
}
|
||||
let { menu }: Props = $props();
|
||||
|
||||
function key(e: KeyboardEvent) {
|
||||
let k = e.key;
|
||||
for (let menuItem of menu) {
|
||||
if (k === menuItem.keyboard) {
|
||||
menuItem.action();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={key} />
|
||||
|
||||
{#each menu as menuItem}
|
||||
{#if menuItem.visible}
|
||||
{@const index = menuItem.display.indexOf(menuItem.keyboard)}
|
||||
<button disabled={menuItem.disabled} onclick={menuItem.action}>
|
||||
{#if index !== -1}
|
||||
{menuItem.display.substring(0, index)}<u>{menuItem.display.charAt(index)}</u
|
||||
>{menuItem.display.substring(index + 1)}
|
||||
{:else}
|
||||
{menuItem.display} (<u>{menuItem.keyboard}</u>)
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/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>
|
|
@ -1,22 +0,0 @@
|
|||
export type Mode =
|
||||
| 'global'
|
||||
| 'view'
|
||||
| 'paneSelect'
|
||||
| 'pane'
|
||||
| 'baseLayerSelect'
|
||||
| 'dataLayerSelect'
|
||||
| 'overlayLayerSelect'
|
||||
| 'dataNOAA'
|
||||
| 'dataNOAAMRMS';
|
||||
|
||||
export interface MenuItem {
|
||||
display: string;
|
||||
keyboard: string;
|
||||
disabled: boolean;
|
||||
visible: boolean;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
export type MenuRegistry = {
|
||||
[id in Mode]: MenuItem[];
|
||||
};
|
|
@ -1,13 +0,0 @@
|
|||
<script lang="ts">
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import "$lib/global.css";
|
||||
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
|
@ -1,415 +0,0 @@
|
|||
<script lang="ts">
|
||||
import Map from '$lib/map/Map.svelte';
|
||||
import type { MenuRegistry, Mode } from '$lib/menubar/buttonlib';
|
||||
import ButtonBar from '$lib/menubar/ButtonBar.svelte';
|
||||
import type { BaseLayers, DataLayers, MutexLayerSet, OverlayLayerSet } from '$lib/map/layer';
|
||||
import type { View } from '$lib/map';
|
||||
import type { Map as LMap } from 'leaflet';
|
||||
import { syncMaps } from '$lib/map/sync.svelte.js';
|
||||
import {strToColor} from "$lib/color";
|
||||
|
||||
let map1: LMap | null = $state(null);
|
||||
let map2: LMap | null = $state(null);
|
||||
let map3: LMap | null = $state(null);
|
||||
let map4: LMap | null = $state(null);
|
||||
|
||||
let view: View = $state('one');
|
||||
|
||||
$effect(() => {
|
||||
syncMaps(view, map1, map2, map3, map4);
|
||||
});
|
||||
|
||||
let pane: 'map1' | 'map2' | 'map3' | 'map4' = $state('map1');
|
||||
|
||||
let baseLayer: MutexLayerSet<BaseLayers> = $state({
|
||||
map1: 'osm',
|
||||
map2: 'osm',
|
||||
map3: 'osm',
|
||||
map4: 'osm'
|
||||
});
|
||||
let dataLayer: MutexLayerSet<DataLayers | null> = $state({
|
||||
map1: null,
|
||||
map2: null,
|
||||
map3: null,
|
||||
map4: null
|
||||
});
|
||||
let overlayLayers: OverlayLayerSet<string> = $state({
|
||||
map1: [],
|
||||
map2: [],
|
||||
map3: [],
|
||||
map4: []
|
||||
});
|
||||
|
||||
let mode: Mode = $state('global');
|
||||
let registry: MenuRegistry = $derived({
|
||||
global: [
|
||||
{
|
||||
display: 'view',
|
||||
keyboard: 'v',
|
||||
disabled: false,
|
||||
visible: true,
|
||||
action: () => {
|
||||
mode = 'view';
|
||||
}
|
||||
},
|
||||
{
|
||||
display: 'pane',
|
||||
keyboard: 'p',
|
||||
disabled: false,
|
||||
visible: true,
|
||||
action: () => {
|
||||
mode = 'paneSelect';
|
||||
}
|
||||
}
|
||||
],
|
||||
view: [
|
||||
{
|
||||
display: 'back',
|
||||
keyboard: 'Escape',
|
||||
disabled: false,
|
||||
visible: true,
|
||||
action: () => {
|
||||
mode = 'global';
|
||||
}
|
||||
},
|
||||
{
|
||||
display: 'view: 1',
|
||||
keyboard: '1',
|
||||
disabled: view === 'one',
|
||||
visible: true,
|
||||
action: () => {
|
||||
view = 'one';
|
||||
}
|
||||
},
|
||||
{
|
||||
display: 'view: 2',
|
||||
keyboard: '2',
|
||||
disabled: view === 'two',
|
||||
visible: true,
|
||||
action: () => {
|
||||
view = 'two';
|
||||
}
|
||||
},
|
||||
{
|
||||
display: 'view: 4',
|
||||
keyboard: '4',
|
||||
disabled: view === 'four',
|
||||
visible: true,
|
||||
action: () => {
|
||||
view = 'four';
|
||||
}
|
||||
}
|
||||
],
|
||||
paneSelect: [
|
||||
{
|
||||
display: 'back',
|
||||
keyboard: 'Escape',
|
||||
disabled: false,
|
||||
visible: true,
|
||||
action: () => {
|
||||
mode = 'global';
|
||||
}
|
||||
},
|
||||
{
|
||||
display: 'pane: 1',
|
||||
keyboard: '1',
|
||||
disabled: pane === 'map1',
|
||||
visible: true,
|
||||
action: () => {
|
||||
pane = 'map1';
|
||||
}
|
||||
},
|
||||
{
|
||||
display: 'pane: 2',
|
||||
keyboard: '2',
|
||||
disabled: pane === 'map2',
|
||||
visible: view === 'two' || view === 'four',
|
||||
action: () => {
|
||||
pane = 'map2';
|
||||
}
|
||||
},
|
||||
{
|
||||
display: 'pane: 3',
|
||||
keyboard: '3',
|
||||
disabled: pane === 'map3',
|
||||
visible: view === 'four',
|
||||
action: () => {
|
||||
pane = 'map3';
|
||||
}
|
||||
},
|
||||
{
|
||||
display: 'pane: 4',
|
||||
keyboard: '4',
|
||||
disabled: pane === 'map4',
|
||||
visible: view === 'four',
|
||||
action: () => {
|
||||
pane = 'map4';
|
||||
}
|
||||
},
|
||||
{
|
||||
display: 'select',
|
||||
keyboard: 'Enter',
|
||||
disabled: false,
|
||||
visible: true,
|
||||
action: () => {
|
||||
mode = 'pane';
|
||||
}
|
||||
}
|
||||
],
|
||||
pane: [
|
||||
{
|
||||
display: 'back',
|
||||
keyboard: 'Escape',
|
||||
disabled: false,
|
||||
visible: true,
|
||||
action: () => {
|
||||
mode = 'paneSelect';
|
||||
}
|
||||
},
|
||||
{
|
||||
display: 'base layer',
|
||||
keyboard: 'b',
|
||||
disabled: false,
|
||||
visible: true,
|
||||
action: () => {
|
||||
mode = 'baseLayerSelect';
|
||||
}
|
||||
},
|
||||
{
|
||||
display: 'data layer',
|
||||
keyboard: 'd',
|
||||
disabled: false,
|
||||
visible: true,
|
||||
action: () => {
|
||||
mode = 'dataLayerSelect';
|
||||
}
|
||||
},
|
||||
{
|
||||
display: 'overlays',
|
||||
keyboard: 'o',
|
||||
disabled: false,
|
||||
visible: true,
|
||||
action: () => {
|
||||
mode = 'overlayLayerSelect';
|
||||
}
|
||||
}
|
||||
],
|
||||
baseLayerSelect: [
|
||||
{
|
||||
display: 'back',
|
||||
keyboard: 'Escape',
|
||||
disabled: false,
|
||||
visible: true,
|
||||
action: () => {
|
||||
mode = 'pane';
|
||||
}
|
||||
},
|
||||
{
|
||||
display: 'OpenStreetMap',
|
||||
keyboard: 'o',
|
||||
disabled: false,
|
||||
visible: true,
|
||||
action: () => {
|
||||
baseLayer[pane] = 'osm';
|
||||
}
|
||||
}
|
||||
],
|
||||
dataLayerSelect: [
|
||||
{
|
||||
display: 'back',
|
||||
keyboard: 'Escape',
|
||||
disabled: false,
|
||||
visible: true,
|
||||
action: () => {
|
||||
mode = 'pane';
|
||||
}
|
||||
},
|
||||
{
|
||||
display: 'none',
|
||||
keyboard: '0',
|
||||
disabled: false,
|
||||
visible: true,
|
||||
action: () => {
|
||||
dataLayer[pane] = null;
|
||||
}
|
||||
},
|
||||
{
|
||||
display: 'noaa',
|
||||
keyboard: 'n',
|
||||
disabled: false,
|
||||
visible: true,
|
||||
action: () => {
|
||||
mode = 'dataNOAA';
|
||||
}
|
||||
}
|
||||
],
|
||||
dataNOAA: [
|
||||
{
|
||||
display: 'back',
|
||||
keyboard: 'Escape',
|
||||
disabled: false,
|
||||
visible: true,
|
||||
action: () => {
|
||||
mode = 'dataLayerSelect';
|
||||
}
|
||||
},
|
||||
{
|
||||
display: 'multi-radar multi-sensor',
|
||||
keyboard: 'm',
|
||||
disabled: false,
|
||||
visible: true,
|
||||
action: () => {
|
||||
mode = 'dataNOAAMRMS';
|
||||
}
|
||||
}
|
||||
],
|
||||
dataNOAAMRMS: [
|
||||
{
|
||||
display: 'back',
|
||||
keyboard: 'Escape',
|
||||
disabled: false,
|
||||
visible: true,
|
||||
action: () => {
|
||||
mode = 'dataNOAA';
|
||||
}
|
||||
},
|
||||
{
|
||||
display: 'composite reflectivity - merged qc',
|
||||
keyboard: 'r',
|
||||
disabled: dataLayer[pane] === 'grib2/noaa_mrms_merged_composite_reflectivity_qc',
|
||||
visible: true,
|
||||
action: () => {
|
||||
dataLayer[pane] = 'grib2/noaa_mrms_merged_composite_reflectivity_qc';
|
||||
}
|
||||
}
|
||||
],
|
||||
overlayLayerSelect: [
|
||||
{
|
||||
display: 'back',
|
||||
keyboard: 'Escape',
|
||||
disabled: false,
|
||||
visible: true,
|
||||
action: () => {
|
||||
mode = 'pane';
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let status: string = $derived.by(() => {
|
||||
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>
|
||||
|
||||
<div class="outercontainer">
|
||||
<div class="toolbar">
|
||||
<h1>wxbox</h1>
|
||||
<span class="status">{status}</span>
|
||||
<ButtonBar menu={registry[mode]} />
|
||||
<span class="dataState">data reload in {timeUntilReload}s</span>
|
||||
</div>
|
||||
<div class="container">
|
||||
<Map
|
||||
selected={pane === 'map1'}
|
||||
bind:map={map1}
|
||||
baseLayer={baseLayer.map1}
|
||||
dataLayer={dataLayer.map1}
|
||||
overlayLayers={overlayLayers.map1}
|
||||
bind:reload={reload1}
|
||||
/>
|
||||
{#if view === 'two' || view === 'four'}
|
||||
<Map
|
||||
selected={pane === 'map2'}
|
||||
bind:map={map2}
|
||||
baseLayer={baseLayer.map2}
|
||||
dataLayer={dataLayer.map2}
|
||||
overlayLayers={overlayLayers.map2}
|
||||
bind:reload={reload2}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if view === 'four'}
|
||||
<div class="container">
|
||||
<Map
|
||||
selected={pane === 'map3'}
|
||||
bind:map={map3}
|
||||
baseLayer={baseLayer.map3}
|
||||
dataLayer={dataLayer.map3}
|
||||
overlayLayers={overlayLayers.map3}
|
||||
bind:reload={reload3}
|
||||
/>
|
||||
<Map
|
||||
selected={pane === 'map4'}
|
||||
bind:map={map4}
|
||||
baseLayer={baseLayer.map4}
|
||||
dataLayer={dataLayer.map4}
|
||||
overlayLayers={overlayLayers.map4}
|
||||
bind:reload={reload4}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="footer text-sm">
|
||||
<p>built with <3</p>
|
||||
<p>u8.lc & coredoes.dev :)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toolbar h1 {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
.toolbar {
|
||||
margin: var(--size-1) var(--size-2);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--size-2);
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex: 1;
|
||||
}
|
||||
.outercontainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-left: var(--size-2);
|
||||
margin-right: var(--size-2);
|
||||
}
|
||||
.footer p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
Binary file not shown.
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,18 +0,0 @@
|
|||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://svelte.dev/docs/kit/integrations
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
});
|
12
wxbox_client/Cargo.toml
Normal file
12
wxbox_client/Cargo.toml
Normal file
|
@ -0,0 +1,12 @@
|
|||
[package]
|
||||
name = "wxbox_client"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
walkers = "0.32"
|
||||
eframe = "0.30"
|
||||
egui = "0.30"
|
||||
egui_extras = "0.30"
|
||||
wxbox_common = { path = "../wxbox_common" }
|
||||
serde_json = "1"
|
212
wxbox_client/src/lib.rs
Normal file
212
wxbox_client/src/lib.rs
Normal file
|
@ -0,0 +1,212 @@
|
|||
mod toggle_switch;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::env::var;
|
||||
use egui::{Align2, CollapsingHeader, Color32, Frame, UiBuilder, Window};
|
||||
use egui::Context;
|
||||
use walkers::{HttpOptions, HttpTiles, MapMemory, Position, TileId, Tiles};
|
||||
use walkers::sources::{Attribution, TileSource};
|
||||
use wxbox_common::TileRequestOptions;
|
||||
use crate::toggle_switch::toggle;
|
||||
|
||||
pub struct WxboxApp {
|
||||
provider: HttpTiles,
|
||||
map_memory: MapMemory,
|
||||
tile_request_options: TileRequestOptions,
|
||||
|
||||
rf_color: Color32
|
||||
}
|
||||
|
||||
pub struct DynamicUrlSource {
|
||||
pub url_query: String
|
||||
}
|
||||
impl DynamicUrlSource {
|
||||
pub fn new_from(options: &TileRequestOptions) -> Self {
|
||||
Self {
|
||||
url_query: serde_json::to_string(options).unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
impl TileSource for DynamicUrlSource {
|
||||
fn tile_url(&self, tile_id: TileId) -> String {
|
||||
format!(
|
||||
"{}/{}/{}/{}.png?settings={}",
|
||||
env!("TILER_BASE_URL"),
|
||||
tile_id.zoom, tile_id.x, tile_id.y,
|
||||
self.url_query
|
||||
)
|
||||
}
|
||||
|
||||
fn attribution(&self) -> Attribution {
|
||||
Attribution {
|
||||
text: "OpenStreetMap contributors, NOAA, wxbox",
|
||||
url: "https://copyright.wxbox.e3t.cc",
|
||||
logo_light: None,
|
||||
logo_dark: None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl WxboxApp {
|
||||
pub fn new(ctx: Context) -> Self {
|
||||
egui_extras::install_image_loaders(&ctx);
|
||||
|
||||
let req_options = TileRequestOptions {
|
||||
baselayer: "osm".to_string(),
|
||||
|
||||
data: "grib2/noaa_mrms_merged_composite_reflectivity_qc_CONUS".to_string(),
|
||||
data_transparency: 0.9,
|
||||
show_range_folded: false,
|
||||
range_folded_color: 0x8d00a0ff,
|
||||
};
|
||||
|
||||
Self {
|
||||
provider: HttpTiles::with_options(
|
||||
DynamicUrlSource::new_from(&req_options),
|
||||
HttpOptions {
|
||||
cache: if cfg!(target_arch = "wasm32") {
|
||||
None
|
||||
} else {
|
||||
Some(".cache".into())
|
||||
},
|
||||
user_agent: None,
|
||||
},
|
||||
ctx.clone()
|
||||
),
|
||||
map_memory: MapMemory::default(),
|
||||
tile_request_options: req_options,
|
||||
rf_color: Color32::from_hex("#8d00a0ff").unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for WxboxApp {
|
||||
fn update(&mut self, ctx: &Context, frame: &mut eframe::Frame) {
|
||||
let rimless = Frame {
|
||||
fill: ctx.style().visuals.panel_fill,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
egui::CentralPanel::default()
|
||||
.frame(rimless)
|
||||
.show(ctx, |ui| {
|
||||
let Self {
|
||||
provider,
|
||||
map_memory,
|
||||
tile_request_options,
|
||||
rf_color
|
||||
} = self;
|
||||
|
||||
let position = Position::from_lat_lon(44.967243, -103.771556);
|
||||
|
||||
let tiles = provider;
|
||||
let attribution = tiles.attribution();
|
||||
|
||||
let map = walkers::Map::new(Some(tiles), map_memory, position);
|
||||
|
||||
ui.add(map);
|
||||
|
||||
Window::new("Attribution")
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.title_bar(false)
|
||||
.anchor(Align2::RIGHT_BOTTOM, [-10.0, -10.0])
|
||||
.show(ui.ctx(), |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.hyperlink_to(attribution.text, attribution.url);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("wxbox is made with <3 by ");
|
||||
ui.hyperlink_to("core", "https://coredoes.dev");
|
||||
ui.label(" and ");
|
||||
ui.hyperlink_to("tm85", "https://u8.lc");
|
||||
});
|
||||
});
|
||||
|
||||
Window::new("wxbox")
|
||||
.resizable(false)
|
||||
.show(ui.ctx(), |ui| {
|
||||
ui.heading("Welcome to wxbox!");
|
||||
ui.label("We're glad you're here; Open up the Datasource panel to get started.");
|
||||
ui.label("If you're using a mouse, you can click and drag to move around and Ctrl+Scroll to zoom.");
|
||||
ui.label("On a touch-based device like a trackpad or touchscreen, you can tap/click and drag to move around and two-finger pinch to zoom.");
|
||||
ui.label("You can close this window by clicking the arrow to the left of it's title.");
|
||||
});
|
||||
|
||||
let mut need_to_reset_for_next_frame = false;
|
||||
|
||||
Window::new("Datasource")
|
||||
.resizable(false)
|
||||
.default_open(false)
|
||||
.show(ui.ctx(), |ui| {
|
||||
ui.collapsing("NOAA", |ui| {
|
||||
ui.collapsing("Multi-Radar Multi-Sensor", |ui| {
|
||||
for location in ["CONUS", "ALASKA", "CARIB", "GUAM", "HAWAII"] {
|
||||
ui.collapsing(location, |ui| {
|
||||
ui.collapsing("Composite Reflectivity", |ui| {
|
||||
if ui.radio_value(&mut tile_request_options.data, format!("grib2/noaa_mrms_merged_composite_reflectivity_qc_{}", location), "Composite Reflectivity (QCd)").changed() {
|
||||
need_to_reset_for_next_frame = true;
|
||||
}
|
||||
});
|
||||
|
||||
if location == "CONUS" {
|
||||
ui.collapsing("RhoHV (Correlation Coefficient)", |ui| {
|
||||
if ui.radio_value(&mut tile_request_options.data, format!("grib2/noaa_mrms_merged_rhohv_3km_{}", location), "RhoHV @ 3.00 KM").changed() {
|
||||
need_to_reset_for_next_frame = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
egui::Grid::new("dsconfig_grid")
|
||||
.num_columns(2)
|
||||
.spacing([40.0, 4.0])
|
||||
.striped(true)
|
||||
.show(ui, |ui| {
|
||||
ui.label("Data opacity");
|
||||
if ui.add(egui::Slider::new(&mut tile_request_options.data_transparency, 0.0..=1.0).suffix("%").custom_formatter(
|
||||
|u, v| {
|
||||
egui::emath::format_with_decimals_in_range(u * 100.0, v)
|
||||
}
|
||||
)).changed() {
|
||||
need_to_reset_for_next_frame = true;
|
||||
}
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Show range folded areas");
|
||||
if ui.add(toggle(&mut tile_request_options.show_range_folded)).changed() {
|
||||
need_to_reset_for_next_frame = true;
|
||||
}
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Range folded color");
|
||||
if ui.color_edit_button_srgba(rf_color).changed() {
|
||||
let color = rf_color.to_array();
|
||||
let single_number = u32::from_be_bytes(color);
|
||||
tile_request_options.range_folded_color = single_number;
|
||||
need_to_reset_for_next_frame = true;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if need_to_reset_for_next_frame {
|
||||
*tiles = HttpTiles::with_options(
|
||||
DynamicUrlSource::new_from(tile_request_options),
|
||||
HttpOptions {
|
||||
cache: if cfg!(target_arch = "wasm32") {
|
||||
None
|
||||
} else {
|
||||
Some(".cache".into())
|
||||
},
|
||||
user_agent: None,
|
||||
},
|
||||
ctx.clone()
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
111
wxbox_client/src/toggle_switch.rs
Normal file
111
wxbox_client/src/toggle_switch.rs
Normal file
|
@ -0,0 +1,111 @@
|
|||
//! Source code example of how to create your own widget.
|
||||
//! This is meant to be read as a tutorial, hence the plethora of comments.
|
||||
|
||||
/// iOS-style toggle switch:
|
||||
///
|
||||
/// ``` text
|
||||
/// _____________
|
||||
/// / /.....\
|
||||
/// | |.......|
|
||||
/// \_______\_____/
|
||||
/// ```
|
||||
///
|
||||
/// ## Example:
|
||||
/// ``` ignore
|
||||
/// toggle_ui(ui, &mut my_bool);
|
||||
/// ```
|
||||
pub fn toggle_ui(ui: &mut egui::Ui, on: &mut bool) -> egui::Response {
|
||||
// Widget code can be broken up in four steps:
|
||||
// 1. Decide a size for the widget
|
||||
// 2. Allocate space for it
|
||||
// 3. Handle interactions with the widget (if any)
|
||||
// 4. Paint the widget
|
||||
|
||||
// 1. Deciding widget size:
|
||||
// You can query the `ui` how much space is available,
|
||||
// but in this example we have a fixed size widget based on the height of a standard button:
|
||||
let desired_size = ui.spacing().interact_size.y * egui::vec2(2.0, 1.0);
|
||||
|
||||
// 2. Allocating space:
|
||||
// This is where we get a region of the screen assigned.
|
||||
// We also tell the Ui to sense clicks in the allocated region.
|
||||
let (rect, mut response) = ui.allocate_exact_size(desired_size, egui::Sense::click());
|
||||
|
||||
// 3. Interact: Time to check for clicks!
|
||||
if response.clicked() {
|
||||
*on = !*on;
|
||||
response.mark_changed(); // report back that the value changed
|
||||
}
|
||||
|
||||
// Attach some meta-data to the response which can be used by screen readers:
|
||||
response.widget_info(|| {
|
||||
egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), *on, "")
|
||||
});
|
||||
|
||||
// 4. Paint!
|
||||
// Make sure we need to paint:
|
||||
if ui.is_rect_visible(rect) {
|
||||
// Let's ask for a simple animation from egui.
|
||||
// egui keeps track of changes in the boolean associated with the id and
|
||||
// returns an animated value in the 0-1 range for how much "on" we are.
|
||||
let how_on = ui.ctx().animate_bool_responsive(response.id, *on);
|
||||
// We will follow the current style by asking
|
||||
// "how should something that is being interacted with be painted?".
|
||||
// This will, for instance, give us different colors when the widget is hovered or clicked.
|
||||
let visuals = ui.style().interact_selectable(&response, *on);
|
||||
// All coordinates are in absolute screen coordinates so we use `rect` to place the elements.
|
||||
let rect = rect.expand(visuals.expansion);
|
||||
let radius = 0.5 * rect.height();
|
||||
ui.painter()
|
||||
.rect(rect, radius, visuals.bg_fill, visuals.bg_stroke);
|
||||
// Paint the circle, animating it from left to right with `how_on`:
|
||||
let circle_x = egui::lerp((rect.left() + radius)..=(rect.right() - radius), how_on);
|
||||
let center = egui::pos2(circle_x, rect.center().y);
|
||||
ui.painter()
|
||||
.circle(center, 0.75 * radius, visuals.bg_fill, visuals.fg_stroke);
|
||||
}
|
||||
|
||||
// All done! Return the interaction response so the user can check what happened
|
||||
// (hovered, clicked, ...) and maybe show a tooltip:
|
||||
response
|
||||
}
|
||||
|
||||
/// Here is the same code again, but a bit more compact:
|
||||
#[allow(dead_code)]
|
||||
fn toggle_ui_compact(ui: &mut egui::Ui, on: &mut bool) -> egui::Response {
|
||||
let desired_size = ui.spacing().interact_size.y * egui::vec2(2.0, 1.0);
|
||||
let (rect, mut response) = ui.allocate_exact_size(desired_size, egui::Sense::click());
|
||||
if response.clicked() {
|
||||
*on = !*on;
|
||||
response.mark_changed();
|
||||
}
|
||||
response.widget_info(|| {
|
||||
egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), *on, "")
|
||||
});
|
||||
|
||||
if ui.is_rect_visible(rect) {
|
||||
let how_on = ui.ctx().animate_bool_responsive(response.id, *on);
|
||||
let visuals = ui.style().interact_selectable(&response, *on);
|
||||
let rect = rect.expand(visuals.expansion);
|
||||
let radius = 0.5 * rect.height();
|
||||
ui.painter()
|
||||
.rect(rect, radius, visuals.bg_fill, visuals.bg_stroke);
|
||||
let circle_x = egui::lerp((rect.left() + radius)..=(rect.right() - radius), how_on);
|
||||
let center = egui::pos2(circle_x, rect.center().y);
|
||||
ui.painter()
|
||||
.circle(center, 0.75 * radius, visuals.bg_fill, visuals.fg_stroke);
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
// A wrapper that allows the more idiomatic usage pattern: `ui.add(toggle(&mut my_bool))`
|
||||
/// iOS-style toggle switch.
|
||||
///
|
||||
/// ## Example:
|
||||
/// ``` ignore
|
||||
/// ui.add(toggle(&mut my_bool));
|
||||
/// ```
|
||||
pub fn toggle(on: &mut bool) -> impl egui::Widget + '_ {
|
||||
move |ui: &mut egui::Ui| toggle_ui(ui, on)
|
||||
}
|
10
wxbox_client_native/Cargo.toml
Normal file
10
wxbox_client_native/Cargo.toml
Normal file
|
@ -0,0 +1,10 @@
|
|||
[package]
|
||||
name = "wxbox_client_native"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
wxbox_client = { path = "../wxbox_client" }
|
||||
eframe = "0.30"
|
||||
egui = "0.30"
|
||||
tracing-subscriber = "0.3"
|
10
wxbox_client_native/src/main.rs
Normal file
10
wxbox_client_native/src/main.rs
Normal file
|
@ -0,0 +1,10 @@
|
|||
use wxbox_client::WxboxApp;
|
||||
|
||||
fn main() -> Result<(), eframe::Error> {
|
||||
tracing_subscriber::fmt::init();
|
||||
eframe::run_native(
|
||||
"wxbox",
|
||||
Default::default(),
|
||||
Box::new(|cc| Ok(Box::new(WxboxApp::new(cc.egui_ctx.clone()))))
|
||||
)
|
||||
}
|
12
wxbox_client_wasm/Cargo.toml
Normal file
12
wxbox_client_wasm/Cargo.toml
Normal file
|
@ -0,0 +1,12 @@
|
|||
[package]
|
||||
name = "wxbox_client_wasm"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
console_error_panic_hook = "0.1"
|
||||
wasm-tracing = "1"
|
||||
eframe = "0.30"
|
||||
wxbox_client = { path = "../wxbox_client" }
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = "0.3"
|
33
wxbox_client_wasm/index.html
Normal file
33
wxbox_client_wasm/index.html
Normal file
|
@ -0,0 +1,33 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>wxbox</title>
|
||||
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||
|
||||
<style>
|
||||
html, body {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
canvas {
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="target"></canvas>
|
||||
</body>
|
||||
</html>
|
30
wxbox_client_wasm/src/main.rs
Normal file
30
wxbox_client_wasm/src/main.rs
Normal file
|
@ -0,0 +1,30 @@
|
|||
use wxbox_client::WxboxApp;
|
||||
use web_sys::wasm_bindgen::JsCast;
|
||||
|
||||
fn main() {
|
||||
console_error_panic_hook::set_once();
|
||||
wasm_tracing::set_as_global_default();
|
||||
|
||||
let web_options = eframe::WebOptions::default();
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let document = web_sys::window()
|
||||
.expect("no window?")
|
||||
.document()
|
||||
.expect("no document?");
|
||||
|
||||
let canvas = document
|
||||
.get_element_by_id("target")
|
||||
.expect("failed to find target canvas for rendering")
|
||||
.dyn_into::<web_sys::HtmlCanvasElement>()
|
||||
.expect("#target is not a canvas");
|
||||
|
||||
eframe::WebRunner::new()
|
||||
.start(
|
||||
canvas,
|
||||
web_options,
|
||||
Box::new(|cc| Ok(Box::new(WxboxApp::new(cc.egui_ctx.clone()))))
|
||||
)
|
||||
.await
|
||||
.expect("failed to start eframe")
|
||||
})
|
||||
}
|
7
wxbox_common/Cargo.toml
Normal file
7
wxbox_common/Cargo.toml
Normal file
|
@ -0,0 +1,7 @@
|
|||
[package]
|
||||
name = "wxbox_common"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
12
wxbox_common/src/lib.rs
Normal file
12
wxbox_common/src/lib.rs
Normal file
|
@ -0,0 +1,12 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TileRequestOptions {
|
||||
pub baselayer: String,
|
||||
|
||||
pub data: String,
|
||||
pub data_transparency: f64,
|
||||
|
||||
pub show_range_folded: bool,
|
||||
pub range_folded_color: u32
|
||||
}
|
Loading…
Reference in a new issue