From b497e16b46a1e81b06a6e3a8fbc6c295d38703ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariusz=20R=C3=B3=C5=BCycki?= Date: Sun, 10 Dec 2023 14:27:14 +0100 Subject: [PATCH] webui: Embed visualizer in WebUI --- Cargo.lock | 2 ++ visualizer/src/lib.rs | 2 ++ webapi/DEPLOYMENT.md | 10 +++++++ webapi/README.md | 6 +++++ webui/Cargo.toml | 4 +++ webui/src/api.rs | 43 ++++++++++++++++++++++++----- webui/src/dummy.rs | 21 +++++++++++++++ webui/src/main.rs | 34 ++++++++++++++++++++--- webui/src/visualizer.rs | 60 +++++++++++++++++++++++++++++++++++++++++ webui/style_dark.css | 21 ++++++++++++--- 10 files changed, 188 insertions(+), 15 deletions(-) create mode 100644 webui/src/dummy.rs create mode 100644 webui/src/visualizer.rs diff --git a/Cargo.lock b/Cargo.lock index e13b865..27f1834 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4871,8 +4871,10 @@ dependencies = [ "instant", "lightfx 0.1.0", "log", + "rustmas-visualizer", "serde", "serde_json", + "url", "wasm-bindgen", "wasm-bindgen-futures", "wasm-logger", diff --git a/visualizer/src/lib.rs b/visualizer/src/lib.rs index b279e72..3875237 100644 --- a/visualizer/src/lib.rs +++ b/visualizer/src/lib.rs @@ -57,6 +57,8 @@ pub fn run(frames_endpoint: Url, points: Vec<(f32, f32, f32)>) { primary_window: Some(Window { title: "Rustmas Visualizer".to_string(), present_mode: PresentMode::AutoNoVsync, + canvas: Some("#visualizer".into()), + fit_canvas_to_parent: true, ..default() }), ..default() diff --git a/webapi/DEPLOYMENT.md b/webapi/DEPLOYMENT.md index 7517937..ee5a921 100644 --- a/webapi/DEPLOYMENT.md +++ b/webapi/DEPLOYMENT.md @@ -101,6 +101,16 @@ rustup target add wasm32-unknown-unknown trunk build --release ``` +You can also include a visualizer embedded in the UI by using the `visualizer` feature: + +``` +trunk build --release --features visualizer +``` + +This will make the compiled WASM file significantly larger and will impact loading time, +so it is turned off by default. The visualizer will also only show up on large displays +(tablets, computer screens), and not on a phone. + Reverse proxy ------------- diff --git a/webapi/README.md b/webapi/README.md index 6eae6c2..48fc019 100644 --- a/webapi/README.md +++ b/webapi/README.md @@ -69,6 +69,12 @@ trunk serve --features local The `local` feature will connect WebUI to a locally running WebAPI. +You can also include a visualizer embedded in the UI by using the `visualizer` feature: + +``` +trunk serve --features local,visualizer +``` + Deployment ---------- diff --git a/webui/Cargo.toml b/webui/Cargo.toml index 4ca83b3..6aafe91 100644 --- a/webui/Cargo.toml +++ b/webui/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] lightfx = { path = "../lightfx" } animation-api = { path = "../animation-api" } +rustmas-visualizer = { path = "../visualizer", optional = true } gloo-net = "0.2" gloo-utils = "0.1" @@ -20,9 +21,12 @@ web-sys = { version = "0.3.60", features = [ "FormData", "HtmlFormElement", "HtmlSelectElement", + "Screen", ] } yew = "0.19" +url = "2.5.0" [features] default = [] local = [] +visualizer = ["rustmas-visualizer"] diff --git a/webui/src/api.rs b/webui/src/api.rs index 2e6beff..7076f51 100644 --- a/webui/src/api.rs +++ b/webui/src/api.rs @@ -4,10 +4,11 @@ use animation_api::parameter_schema::ParametersSchema; use gloo_net::http::Request; use serde::Deserialize; use serde_json::json; +use url::Url; #[derive(Clone, PartialEq)] pub struct Gateway { - endpoint: String, + endpoint: Url, } #[derive(Debug)] @@ -41,6 +42,12 @@ fn extract_response(res: ApiResponse) -> Result { } } +#[cfg(feature = "visualizer")] +#[derive(Deserialize)] +pub struct GetPointsResponse { + points: Vec<(f32, f32, f32)>, +} + #[derive(Clone, Deserialize)] pub struct AnimationEntry { pub id: String, @@ -65,14 +72,36 @@ struct GetParamsResponse { } impl Gateway { - pub fn new(endpoint: &str) -> Self { - Self { - endpoint: endpoint.to_owned(), - } + pub fn new(endpoint: Url) -> Self { + Self { endpoint } } fn url(&self, path: &str) -> String { - format!("{}/{}", self.endpoint, path) + self.endpoint.join(path).unwrap().to_string() + } + + #[cfg(feature = "visualizer")] + pub fn frames(&self) -> Url { + let mut endpoint = self.endpoint.clone(); + endpoint.set_scheme("ws").unwrap(); + endpoint.join("frames").unwrap() + } + + #[cfg(feature = "visualizer")] + pub async fn get_points(&self) -> Result> { + Ok(Request::get(&self.url("points")) + .send() + .await + .map_err(|e| GatewayError::RequestError { + reason: e.to_string(), + })? + .json::>() + .await + .map_err(|e| GatewayError::InvalidResponse { + reason: e.to_string(), + }) + .and_then(extract_response)? + .points) } pub async fn restart_events(&self) -> Result<()> { @@ -215,7 +244,7 @@ impl Gateway { impl Default for Gateway { fn default() -> Self { Self { - endpoint: "http://localhost/".to_owned(), + endpoint: Url::parse("http://localhost/").unwrap(), } } } diff --git a/webui/src/dummy.rs b/webui/src/dummy.rs new file mode 100644 index 0000000..d47e187 --- /dev/null +++ b/webui/src/dummy.rs @@ -0,0 +1,21 @@ +use yew::{html, prelude::Html, Component, Context}; + +#[derive(Default)] +pub struct Dummy {} + +impl Component for Dummy { + type Message = (); + type Properties = (); + + fn create(_ctx: &Context) -> Self { + Default::default() + } + + fn update(&mut self, _ctx: &Context, _msg: Self::Message) -> bool { + false + } + + fn view(&self, _ctx: &Context) -> Html { + html!() + } +} diff --git a/webui/src/main.rs b/webui/src/main.rs index 6d10585..bb13549 100644 --- a/webui/src/main.rs +++ b/webui/src/main.rs @@ -1,13 +1,24 @@ mod api; mod controls; +#[cfg(feature = "visualizer")] +mod visualizer; + +#[cfg(not(feature = "visualizer"))] +mod dummy; + use std::error::Error; use api::Gateway; use log::error; +use url::Url; use yew::prelude::*; use crate::controls::ParameterControlList; +#[cfg(not(feature = "visualizer"))] +use crate::dummy::Dummy as Visualizer; +#[cfg(feature = "visualizer")] +use crate::visualizer::Visualizer; enum Msg { LoadedAnimations(Vec), @@ -32,11 +43,14 @@ impl Component for AnimationSelector { type Properties = (); fn create(ctx: &Context) -> Self { - let api = if cfg!(feature = "local") { - api::Gateway::new("http://127.0.0.1:8081") + let api_url = if cfg!(feature = "local") { + Url::parse("http://127.0.0.1:8081").unwrap() + } else if let Some(url) = web_sys::window().and_then(|w| w.location().href().ok()) { + Url::parse(&url).and_then(|u| u.join("api/")).unwrap() } else { - api::Gateway::new("/api") + Url::parse("http://127.0.0.1:8081").unwrap() }; + let api = api::Gateway::new(api_url); { let api = api.clone(); @@ -140,9 +154,14 @@ impl Component for AnimationSelector { } } + #[allow(clippy::let_unit_value)] fn view(&self, ctx: &Context) -> Html { let link = ctx.link(); let animations = self.animations.clone(); + let width = web_sys::window() + .and_then(|w| w.screen().ok()) + .and_then(|s| s.avail_width().ok()) + .unwrap_or_default(); html! { context={self.api.clone()}> <> @@ -161,6 +180,13 @@ impl Component for AnimationSelector { } + { + if width > 640 { + html!() + } else { + html!() + } + } {if let Some(parameters) = &self.parameters { html! { Result<(), Box> { - wasm_logger::init(wasm_logger::Config::default()); + wasm_logger::init(wasm_logger::Config::new(log::Level::Error)); yew::start_app::(); Ok(()) diff --git a/webui/src/visualizer.rs b/webui/src/visualizer.rs new file mode 100644 index 0000000..dff429a --- /dev/null +++ b/webui/src/visualizer.rs @@ -0,0 +1,60 @@ +use log::error; +use yew::{html, prelude::Html, Callback, Component, Context}; + +use crate::api; + +#[derive(Default)] +pub struct Visualizer {} + +pub enum Msg { + PointsLoaded(Vec<(f32, f32, f32)>), +} + +fn get_api(ctx: &Context) -> api::Gateway { + ctx.link() + .context::(Callback::noop()) + .expect("gateway to be created") + .0 +} + +impl Component for Visualizer { + type Message = Msg; + type Properties = (); + + fn create(_ctx: &Context) -> Self { + Default::default() + } + + fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { + match msg { + Msg::PointsLoaded(points) => { + let api = get_api(ctx); + wasm_bindgen_futures::spawn_local(async move { + rustmas_visualizer::run(api.frames(), points); + }); + false + } + } + } + + fn rendered(&mut self, ctx: &Context, first_render: bool) { + if first_render { + let api = get_api(ctx); + let points_loaded = ctx.link().callback(Msg::PointsLoaded); + wasm_bindgen_futures::spawn_local(async move { + match api.get_points().await { + Ok(points) => points_loaded.emit(points), + Err(e) => error!("Failed to load points for visualizer, reason: {}", e), + } + }) + } + } + + fn view(&self, _ctx: &Context) -> Html { + html! { +
+ +
+ } + } +} diff --git a/webui/style_dark.css b/webui/style_dark.css index a9b9531..c54c607 100644 --- a/webui/style_dark.css +++ b/webui/style_dark.css @@ -41,13 +41,13 @@ header h1 { display: flex; flex-grow: 1; background-color: black; + max-height: calc(100vh - 3rem); + max-width: 100vw; } nav { - width: 20vw; - min-width: 240px; + flex: 0 1 360px; background-color: black; - position: relative; } nav::after { @@ -62,7 +62,9 @@ nav::after { } nav ul { + height: 100%; list-style-type: none; + overflow-y: auto; } nav a { @@ -87,9 +89,20 @@ nav hr { } .parameter-control-list { - width: 50vw; + flex: 1 0 400px; padding: 0 3rem; margin-bottom: 2rem; + max-height: 100%; + overflow-y: auto; +} + +.visualizer-container { + flex: 6 6; +} + +#visualizer { + width: 100% !important; + height: 100% !important; } .parameter-control-list p {