break everything
This commit is contained in:
parent
01d7181aaf
commit
181db4e881
43 changed files with 3620 additions and 1193 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
||||||
*/target
|
*/target
|
||||||
target
|
target
|
||||||
node_modules
|
node_modules
|
||||||
|
.cache
|
|
@ -6,6 +6,12 @@
|
||||||
<sourceFolder url="file://$MODULE_DIR$/wxbox-tiler/src" isTestSource="false" />
|
<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-eccodes-sys/src" isTestSource="false" />
|
||||||
<sourceFolder url="file://$MODULE_DIR$/wxbox-web/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$/.tmp" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||||
|
|
3376
Cargo.lock
generated
3376
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -1,6 +1,6 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
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]
|
[profile.release]
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
@ -9,4 +9,4 @@ lto = "fat"
|
||||||
[profile.dev.package.image]
|
[profile.dev.package.image]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
[profile.dev.package.png]
|
[profile.dev.package.png]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
[sources.grib2.noaa_mrms_merged_composite_reflectivity_qc]
|
[sources.grib2.noaa_mrms_merged_composite_reflectivity_qc]
|
||||||
from = "https://mrms.ncep.noaa.gov/data/2D/HAWAII/MergedReflectivityQCComposite/MRMS_MergedReflectivityQCComposite.latest.grib2.gz"
|
from = "https://mrms.ncep.noaa.gov/data/2D/MergedReflectivityQCComposite/MRMS_MergedReflectivityQCComposite.latest.grib2.gz"
|
||||||
needs_gzip = true
|
needs_gzip = true
|
||||||
valid_for = 120
|
valid_for = 120
|
||||||
palette = """
|
palette = """
|
||||||
|
|
|
@ -21,6 +21,9 @@ tracing = "0.1"
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = "0.3"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
wxbox_common = { path = "../wxbox_common" }
|
||||||
|
image = "0.25"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
approx = "0.5"
|
approx = "0.5"
|
||||||
|
|
|
@ -36,7 +36,7 @@ async fn main() -> std::io::Result<()> {
|
||||||
});
|
});
|
||||||
HttpServer::new(move || {
|
HttpServer::new(move || {
|
||||||
App::new()
|
App::new()
|
||||||
.service(sources::grib2::grib2_source)
|
.service(sources::grib2::source)
|
||||||
.app_data(data.clone())
|
.app_data(data.clone())
|
||||||
})
|
})
|
||||||
.bind(("::", 8080))?
|
.bind(("::", 8080))?
|
||||||
|
|
|
@ -6,14 +6,19 @@ use std::time::SystemTime;
|
||||||
use actix_web::error::UrlencodedError::ContentType;
|
use actix_web::error::UrlencodedError::ContentType;
|
||||||
use actix_web::http::StatusCode;
|
use actix_web::http::StatusCode;
|
||||||
use actix_web::HttpResponse;
|
use actix_web::HttpResponse;
|
||||||
use actix_web::web::Data;
|
use actix_web::web::{Data, Query};
|
||||||
use flate2::read::GzDecoder;
|
use flate2::read::GzDecoder;
|
||||||
use png::{BitDepth, ColorType, Encoder};
|
use png::{BitDepth, ColorType, Encoder};
|
||||||
|
use serde::Deserialize;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
use wxbox_common::TileRequestOptions;
|
||||||
|
use image::{ImageFormat, ImageReader};
|
||||||
|
use reqwest::ClientBuilder;
|
||||||
use wxbox_grib2::GribMessage;
|
use wxbox_grib2::GribMessage;
|
||||||
use wxbox_grib2::wgs84::LatLong;
|
use wxbox_grib2::wgs84::LatLong;
|
||||||
use wxbox_pal::{Color, ColorPalette, Palette};
|
use wxbox_pal::{Color, ColorPalette, Palette};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
use tracing::debug;
|
||||||
use crate::config::Grib2Source;
|
use crate::config::Grib2Source;
|
||||||
use crate::pixmap::Pixmap;
|
use crate::pixmap::Pixmap;
|
||||||
|
|
||||||
|
@ -69,7 +74,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 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>) -> Pixmap {
|
||||||
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;
|
||||||
|
@ -106,12 +111,127 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn merge(base: Pixmap, data: Pixmap, settings: &TileRequestOptions) -> Pixmap {
|
||||||
|
let mut new = Pixmap::new();
|
||||||
|
|
||||||
|
let a = settings.data_transparency;
|
||||||
|
|
||||||
|
for x in 0..256 {
|
||||||
|
for y in 0..256 {
|
||||||
|
let lower = base.get(x, y);
|
||||||
|
let upper = data.get(x, y);
|
||||||
|
|
||||||
|
let lr = colorf64(lower.red);
|
||||||
|
let lg = colorf64(lower.green);
|
||||||
|
let lb = colorf64(lower.blue);
|
||||||
|
|
||||||
|
let ur = colorf64(upper.red);
|
||||||
|
let ug = colorf64(upper.green);
|
||||||
|
let ub = colorf64(lower.blue);
|
||||||
|
|
||||||
|
new.set(x, y, Color {
|
||||||
|
red: coloru8(lr + (lr - ur * a).abs()),
|
||||||
|
green: coloru8(lg + (lg - ug * a).abs()),
|
||||||
|
blue: coloru8(lb + (lb - ub * a).abs()),
|
||||||
|
alpha: 255
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(255 - x as u32, y 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).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, &settings);
|
||||||
|
let image = base_layer;
|
||||||
|
|
||||||
let mut buf: Vec<u8> = vec![];
|
let mut buf: Vec<u8> = vec![];
|
||||||
// borrow checker insanity
|
// borrow checker insanity
|
||||||
{
|
{
|
||||||
let mut cur: Cursor<_> = Cursor::new(&mut buf);
|
let mut cur: Cursor<_> = Cursor::new(&mut buf);
|
||||||
let w = &mut BufWriter::new(&mut cur);
|
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_color(ColorType::Rgba);
|
||||||
encoder.set_depth(BitDepth::Eight);
|
encoder.set_depth(BitDepth::Eight);
|
||||||
encoder.set_source_gamma(png::ScaledFloat::from_scaled(45455));
|
encoder.set_source_gamma(png::ScaledFloat::from_scaled(45455));
|
||||||
|
@ -128,36 +248,14 @@ pub async fn render(xtile: f64, ytile: f64, z: i32, tilesize: usize, pal: Palett
|
||||||
writer.finish().unwrap();
|
writer.finish().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
buf
|
HttpResponse::Ok()
|
||||||
}
|
.insert_header(actix_web::http::header::ContentType(mime::IMAGE_PNG))
|
||||||
|
// TODO: use the timestamp in the grib2 ID section
|
||||||
#[actix_web::get("/grib2/{id}/{z}/{x}/{y}.png")]
|
//.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()))
|
||||||
pub async fn grib2_source(path: actix_web::web::Path<(String, i32, u32, u32)>, data: Data<AppState>) -> HttpResponse {
|
.insert_header(("Access-Control-Allow-Origin", "*"))
|
||||||
if let Some(known_source) = data.config.sources.grib2.get(&path.0) {
|
.insert_header(("Access-Control-Expose-Headers", "*"))
|
||||||
reload_if_required(
|
.insert_header(("Access-Control-Allow-Headers", "*"))
|
||||||
&known_source.from,
|
.body(buf)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
#[macro_export]
|
#[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"
|
96
wxbox_client/src/lib.rs
Normal file
96
wxbox_client/src/lib.rs
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::env::var;
|
||||||
|
use egui::{Align2, Frame, Window};
|
||||||
|
use egui::Context;
|
||||||
|
use walkers::{HttpOptions, HttpTiles, MapMemory, Position, TileId, Tiles};
|
||||||
|
use walkers::sources::{Attribution, TileSource};
|
||||||
|
use wxbox_common::TileRequestOptions;
|
||||||
|
|
||||||
|
pub struct WxboxApp {
|
||||||
|
provider: HttpTiles,
|
||||||
|
map_memory: MapMemory
|
||||||
|
}
|
||||||
|
|
||||||
|
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={}",
|
||||||
|
var("TILER_BASE_URL").unwrap(),
|
||||||
|
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);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
provider: HttpTiles::with_options(
|
||||||
|
DynamicUrlSource::new_from(&TileRequestOptions {
|
||||||
|
baselayer: "osm".to_string(),
|
||||||
|
|
||||||
|
data: "grib2/noaa_mrms_merged_composite_reflectivity_qc".to_string(),
|
||||||
|
data_transparency: 0.5,
|
||||||
|
}),
|
||||||
|
Default::default(),
|
||||||
|
ctx.clone()
|
||||||
|
),
|
||||||
|
map_memory: MapMemory::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 position = Position::from_lat_lon(44.967243, -103.771556);
|
||||||
|
|
||||||
|
let tiles = &mut self.provider;
|
||||||
|
let attribution = tiles.attribution();
|
||||||
|
|
||||||
|
let map = walkers::Map::new(Some(tiles), &mut self.map_memory, position);
|
||||||
|
|
||||||
|
ui.add(map);
|
||||||
|
|
||||||
|
Window::new("Attribution")
|
||||||
|
.collapsible(false)
|
||||||
|
.resizable(false)
|
||||||
|
.title_bar(false)
|
||||||
|
.anchor(Align2::LEFT_TOP, [10.0, 10.0])
|
||||||
|
.show(ui.ctx(), |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.hyperlink_to(attribution.text, attribution.url);
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
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()))))
|
||||||
|
)
|
||||||
|
}
|
6
wxbox_client_wasm/Cargo.toml
Normal file
6
wxbox_client_wasm/Cargo.toml
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
[package]
|
||||||
|
name = "wxbox_client_wasm"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
14
wxbox_client_wasm/src/lib.rs
Normal file
14
wxbox_client_wasm/src/lib.rs
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
pub fn add(left: u64, right: u64) -> u64 {
|
||||||
|
left + right
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn it_works() {
|
||||||
|
let result = add(2, 2);
|
||||||
|
assert_eq!(result, 4);
|
||||||
|
}
|
||||||
|
}
|
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 = "1"
|
9
wxbox_common/src/lib.rs
Normal file
9
wxbox_common/src/lib.rs
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct TileRequestOptions {
|
||||||
|
pub baselayer: String,
|
||||||
|
|
||||||
|
pub data: String,
|
||||||
|
pub data_transparency: f64
|
||||||
|
}
|
Loading…
Reference in a new issue