client work - source selector
This commit is contained in:
parent
c2a3ddb1ec
commit
310a354f2f
5 changed files with 341 additions and 29 deletions
70
config.toml
70
config.toml
|
@ -1,4 +1,4 @@
|
|||
[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
|
||||
|
@ -13,4 +13,72 @@ 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
|
||||
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
|
|
@ -1,7 +1,7 @@
|
|||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::f64::consts::PI;
|
||||
use std::io::{BufWriter, Cursor, Read};
|
||||
use std::ops::{Add, Mul, Sub};
|
||||
use std::ops::{Add, Div, Mul, Sub};
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
use actix_web::error::UrlencodedError::ContentType;
|
||||
|
@ -19,7 +19,7 @@ use wxbox_grib2::GribMessage;
|
|||
use wxbox_grib2::wgs84::LatLong;
|
||||
use wxbox_pal::{Color, ColorPalette, Palette};
|
||||
use crate::AppState;
|
||||
use tracing::debug;
|
||||
use tracing::{debug, info};
|
||||
use crate::config::Grib2Source;
|
||||
use crate::pixmap::Pixmap;
|
||||
|
||||
|
@ -75,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>) -> Pixmap {
|
||||
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;
|
||||
|
@ -98,11 +98,24 @@ pub async fn render(xtile: f64, ytile: f64, z: i32, tilesize: usize, pal: Palett
|
|||
}).map(|u| u as f64);
|
||||
|
||||
let color = match nearest {
|
||||
Some(c) if Some(c) == no_coverage => Color { red: 0, green: 0, blue: 0, alpha: 255 },
|
||||
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: 255 },
|
||||
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 }
|
||||
};
|
||||
|
@ -214,22 +227,48 @@ impl Mul for ColorF64 {
|
|||
}
|
||||
}
|
||||
}
|
||||
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, settings: &TileRequestOptions) -> Pixmap {
|
||||
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 a_b = 1.0;
|
||||
let mut c_s: ColorF64 = data.get(x, y).into();
|
||||
let a_s = settings.data_transparency;
|
||||
|
||||
let mut co = c_s * a_s + c_b * a_b * (1.0 - a_s);
|
||||
co.alpha = 1.0;
|
||||
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.set(x, y, co.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -289,7 +328,7 @@ pub async fn source(path: actix_web::web::Path<(i32, u32, u32)>, req: Query<Quer
|
|||
).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
|
||||
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)
|
||||
|
@ -303,8 +342,7 @@ pub async fn source(path: actix_web::web::Path<(i32, u32, u32)>, req: Query<Quer
|
|||
return HttpResponse::new(StatusCode::NOT_FOUND)
|
||||
};
|
||||
|
||||
let image = merge(base_layer, data_layer, &settings);
|
||||
//let image = base_layer;
|
||||
let image = merge(base_layer, data_layer);
|
||||
|
||||
let mut buf: Vec<u8> = vec![];
|
||||
// borrow checker insanity
|
||||
|
|
|
@ -1,14 +1,20 @@
|
|||
mod toggle_switch;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::env::var;
|
||||
use egui::{Align2, Frame, Window};
|
||||
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
|
||||
map_memory: MapMemory,
|
||||
tile_request_options: TileRequestOptions,
|
||||
|
||||
rf_color: Color32
|
||||
}
|
||||
|
||||
pub struct DynamicUrlSource {
|
||||
|
@ -45,15 +51,19 @@ impl TileSource for DynamicUrlSource {
|
|||
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(&TileRequestOptions {
|
||||
baselayer: "osm".to_string(),
|
||||
|
||||
data: "grib2/noaa_mrms_merged_composite_reflectivity_qc".to_string(),
|
||||
data_transparency: 0.9,
|
||||
}),
|
||||
DynamicUrlSource::new_from(&req_options),
|
||||
HttpOptions {
|
||||
cache: if cfg!(target_arch = "wasm32") {
|
||||
None
|
||||
|
@ -64,7 +74,9 @@ impl WxboxApp {
|
|||
},
|
||||
ctx.clone()
|
||||
),
|
||||
map_memory: MapMemory::default()
|
||||
map_memory: MapMemory::default(),
|
||||
tile_request_options: req_options,
|
||||
rf_color: Color32::from_hex("#8d00a0ff").unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -79,12 +91,19 @@ impl eframe::App for WxboxApp {
|
|||
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 = &mut self.provider;
|
||||
let tiles = provider;
|
||||
let attribution = tiles.attribution();
|
||||
|
||||
let map = walkers::Map::new(Some(tiles), &mut self.map_memory, position);
|
||||
let map = walkers::Map::new(Some(tiles), map_memory, position);
|
||||
|
||||
ui.add(map);
|
||||
|
||||
|
@ -100,9 +119,82 @@ impl eframe::App for WxboxApp {
|
|||
});
|
||||
|
||||
Window::new("wxbox")
|
||||
.resizable(false)
|
||||
.show(ui.ctx(), |ui| {
|
||||
ui.label("Welcome to wxbox!")
|
||||
})
|
||||
});
|
||||
|
||||
let mut need_to_reset_for_next_frame = false;
|
||||
|
||||
Window::new("Datasource")
|
||||
.resizable(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;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Window::new("Datasource Settings")
|
||||
.resizable(false)
|
||||
.show(ui.ctx(), |ui| {
|
||||
let mut ui_builder = UiBuilder::new();
|
||||
ui.scope_builder(ui_builder, |ui| {
|
||||
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)
|
||||
}
|
|
@ -5,5 +5,8 @@ pub struct TileRequestOptions {
|
|||
pub baselayer: String,
|
||||
|
||||
pub data: String,
|
||||
pub data_transparency: f64
|
||||
pub data_transparency: f64,
|
||||
|
||||
pub show_range_folded: bool,
|
||||
pub range_folded_color: u32
|
||||
}
|
Loading…
Reference in a new issue