From c4140a0618cebe643c2cc111ab0851c65d106f02 Mon Sep 17 00:00:00 2001 From: Bill Thiede Date: Sun, 19 Feb 2023 14:31:39 -0800 Subject: [PATCH] Better zoom/pan behavior, handle images of different aspects. Nearest neighbor drawing, so zooming is pixalated. --- Cargo.toml | 1 + src/lib.rs | 235 +++++++++++++++++++++++++++++++++++------------------ 2 files changed, 157 insertions(+), 79 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 41c2245..9214e95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ features = [ "CanvasRenderingContext2d", "HtmlImageElement", "ImageData", + "ContextAttributes2d", ] [profile.release] diff --git a/src/lib.rs b/src/lib.rs index e43d3dd..bd84712 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,13 +6,13 @@ use std::time::Duration; use chrono::{Local, NaiveDateTime, TimeZone}; -use log::Level; +use log::{info, Level}; use seed::{prelude::*, *}; use thiserror::Error; use wasm_timer::{SystemTime, UNIX_EPOCH}; -use web_sys::{HtmlCanvasElement, HtmlImageElement}; +use web_sys::{ContextAttributes2d, HtmlCanvasElement, HtmlImageElement}; -const ROOT_URL: &'static str = "/www/tracer/"; +const ROOT_URL: &'static str = "/www/tracer"; #[derive(Clone, Error, Debug)] pub enum AppError { @@ -27,7 +27,9 @@ pub enum AppError { // `init` describes what should happen when your app started. fn init(_: Url, orders: &mut impl Orders) -> Model { let timer_handle = orders.stream_with_handle(streams::interval(1000, || Msg::OnCheck)); - orders.after_next_render(|_| Msg::Rendered); + orders + .subscribe(Msg::UrlChanged) + .after_next_render(|_| Msg::Rendered); Model { timer_handle, data: None, @@ -66,8 +68,10 @@ enum Msg { Rendered, Received(Data), Reset, + SetZoom(f64, [i32; 2]), Zoom(f64, [i32; 2]), Move([f64; 2]), + UrlChanged(subs::UrlChanged), } // `update` describes how to handle each `Msg`. @@ -127,6 +131,14 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { model.zoom = 1.; model.offset = [0., 0.]; } + Msg::SetZoom(scale, offset) => { + if scale < 0.1 { + model.zoom = 0.1; + } else { + model.zoom = scale; + } + model.offset = [offset[0] as f64, offset[1] as f64]; + } Msg::Zoom(scale, at) => { let old_zoom = model.zoom; model.zoom += scale; @@ -135,12 +147,16 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { } let z_rat = model.zoom / old_zoom; let at = [at[0] as f64, at[1] as f64]; + // https://stackoverflow.com/questions/38281745/calculating-view-offset-for-zooming-in-at-the-position-of-the-mouse-cursor model.offset = [ at[0] - (z_rat) * (at[0] - model.offset[0]), at[1] - (z_rat) * (at[1] - model.offset[1]), ]; } Msg::Move(delta) => model.offset = [model.offset[0] + delta[0], model.offset[1] + delta[1]], + Msg::UrlChanged(subs::UrlChanged(url)) => { + info!("url changed {}", url); + } } } use serde::{Deserialize, Serialize}; @@ -206,8 +222,9 @@ fn view_metadata_table(data: &Data) -> Node { let dt = NaiveDateTime::from_timestamp_opt(data.timestamp, 0).unwrap(); let dt = Local.from_local_datetime(&dt).unwrap(); + let rendered_at = dt.format("%Y-%m-%d %H:%M:%S").to_string(); div![ - div!["Rendered @ ", dt.format("%Y-%m-%d %H:%M:%S").to_string()], + div!["Rendered @ ", &rendered_at], div![ C![IF!(fresh => "fresh")], humantime::format_duration(d).to_string(), @@ -217,6 +234,7 @@ fn view_metadata_table(data: &Data) -> Node { C!["table", "is-striped", "is-narrow"], tbody![ tr![th!["Name"], th!["Value"]], + row("render_time_seconds", data.render_time_seconds), row("subsamples", data.scene.subsamples), row("adaptive_subsampling", data.scene.adaptive_subsampling), row("num_threads", data.scene.num_threads), @@ -241,19 +259,14 @@ fn draw( } }; - #[derive(Serialize)] - struct ContextOptions { - #[serde(rename = "willReadFrequently")] - will_read_frequently: bool, - } + let mut ca2 = ContextAttributes2d::new(); + // Chrome warning: + // Canvas2D: Multiple readback operations using getImageData are faster with the + // willReadFrequently attribute set to true. See: + // https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently + ca2.will_read_frequently(true); let ctx = canvas - .get_context_with_context_options( - "2d", - &wasm_bindgen::JsValue::from_serde(&ContextOptions { - will_read_frequently: true, - }) - .unwrap(), - ) + .get_context_with_context_options("2d", &ca2) .expect("Problem getting canvas context") .expect("The canvas context is empty") .dyn_into::() @@ -274,6 +287,8 @@ fn draw( let dy = offset[1]; let dw = dw * zoom; let dh = dh * zoom; + // Use nearest neighbor resizing up. + ctx.set_image_smoothing_enabled(false); ctx.draw_image_with_html_image_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh( &image, sx, sy, sw, sh, dx, dy, dw, dh, ) @@ -285,12 +300,21 @@ fn view_image( im: &ImageMetadata, image_canvas: &ElRef, image_ref: &ElRef, + timestamp: i64, ) -> Node { - let url = format!("{ROOT_URL}/{}", im.image); + let url = format!("{ROOT_URL}/{}?t={}", im.image, timestamp); + let href = url.clone(); + let go = |_| Url::go_and_load_with_str(href); let attrs = if let Some(canvas) = image_canvas.get() { + // Width based on browser layout of canvas and it's parent (which would be set to + // 'width:100%' in CSS. + let width = canvas.client_width(); + // But we need to compute an aspect correct height, because the browser can't figure this + // out for us (because I don't know CSS that well). + let height = width as f32 * im.ratio; Some(attrs![ - At::Width => px(canvas.client_width()), - At::Height => px(canvas.client_height()), + At::Width => px(width), + At::Height => px(height), ]) } else { None @@ -321,10 +345,11 @@ fn view_image( }) }), ], + button![C!["button"], "View image", ev(Ev::Click, go)], img![ el_ref(image_ref), attrs! { - At::Src => url + At::Src => &url }, style! { St::Display => "none", @@ -333,6 +358,103 @@ fn view_image( ] } +fn view_debug_buttons( + image_size: (usize, usize), + canvas_size: (i32, i32), + zoom: f64, + offset: [f64; 2], +) -> Node { + let zoom_in = 0.1; + let zoom_out = -0.1; + let (image_width, image_height) = image_size; + let (canvas_width, canvas_height) = canvas_size; + let one2one = image_width as f64 / canvas_width as f64; + let one2one_offset = [ + (canvas_width - image_width as i32) / 2, + (canvas_height - image_height as i32) / 2, + ]; + div![ + p![format!( + "Z: {:.4} Off: {:.2}x{:.2}", + zoom, offset[0], offset[1] + )], + p![format!("Mid {}x{}", canvas_width / 2, canvas_height / 2)], + p![format!("Max {}x{}", canvas_width, canvas_height)], + div![ + C!["buttons", "has-addons"], + button![ + C!["button", "is-danger", "is-fullwidth"], + ev(Ev::Click, |_| Msg::Reset), + "Reset", + ], + ], + div![ + C!["buttons", "has-addons", "is-centered"], + button![ + C!["button"], + ev(Ev::Click, move |_| Msg::SetZoom(one2one, one2one_offset)), + "1:1", + ], + button![ + C!["button"], + ev(Ev::Click, move |_| Msg::SetZoom(1., [0, 0])), + "Fit", + ], + ], + div![ + C!["buttons", "has-addons", "is-centered"], + button![ + C!["button"], + ev(Ev::Click, move |_| Msg::Zoom(zoom_in, [0, 0])), + "+ @ 0x0", + ], + button![ + C!["button"], + ev(Ev::Click, move |_| Msg::Zoom(zoom_out, [0, 0])), + "- @ 0x0", + ], + ], + div![ + C!["buttons", "has-addons", "is-centered"], + button![ + C!["button"], + ev(Ev::Click, move |_| Msg::Zoom( + zoom_in, + [canvas_width / 2, canvas_height / 2] + )), + "+ @ mid", + ], + button![ + C!["button"], + ev(Ev::Click, move |_| Msg::Zoom( + zoom_out, + [canvas_width / 2, canvas_height / 2] + )), + "- @ mid", + ], + ], + div![ + C!["buttons", "has-addons", "is-centered"], + button![ + C!["button"], + ev(Ev::Click, move |_| Msg::Zoom( + zoom_in, + [canvas_width, canvas_height] + )), + "+ @ max", + ], + button![ + C!["button"], + ev(Ev::Click, move |_| Msg::Zoom( + zoom_out, + [canvas_width, canvas_height] + )), + "- @ max", + ], + ], + ] +} + fn view_data( data: &Data, image_canvas: &ElRef, @@ -340,74 +462,29 @@ fn view_data( zoom: f64, offset: [f64; 2], ) -> Node { - let mut width = 128; - let mut height = 128; + let mut canvas_width = 128; + let mut canvas_height = 128; if let Some(c) = image_canvas.get() { - width = c.width() as i32; - height = c.height() as i32; + canvas_width = c.width() as i32; + canvas_height = c.height() as i32; } - let zoom_in = 0.1; - let zoom_out = -0.1; + let im = data.image_metadata.iter().nth(0); + let image_size = im.map(|im| im.size).unwrap_or((512, 512)); div![ C!["columns"], - div![ - C!["column", "is-narrow"], - view_metadata_table(data), - p![format!( - "Z: {:.4} Off: {:.2}x{:.2}", - zoom, offset[0], offset[1] - )], - p![format!("Mid {}x{}", width / 2, height / 2)], - p![format!("Max {}x{}", width, height)], - button![ev(Ev::Click, |_| Msg::Reset), "Reset",], - div![ - button![ - ev(Ev::Click, move |_| Msg::Zoom(zoom_in, [0, 0])), - "+ @ 0x0", - ], - button![ - ev(Ev::Click, move |_| Msg::Zoom(zoom_out, [0, 0])), - "- @ 0x0", - ], - ], - div![ - button![ - ev(Ev::Click, move |_| Msg::Zoom( - zoom_in, - [width / 2, height / 2] - )), - "+ @ mid", - ], - button![ - ev(Ev::Click, move |_| Msg::Zoom( - zoom_out, - [width / 2, height / 2] - )), - "- @ mid", - ], - ], - div![ - button![ - ev(Ev::Click, move |_| Msg::Zoom(zoom_in, [width, height])), - "+ @ max", - ], - button![ - ev(Ev::Click, move |_| Msg::Zoom(zoom_out, [width, height])), - "- @ max", - ], - ], - ], div![ C!["column"], div![ C!["tabs"], ul![data.image_metadata.iter().map(|im| li![a![&im.name]])] ], - data.image_metadata - .iter() - .nth(0) - .map(|im| view_image(im, image_canvas, image_ref)) - ] + im.map(|im| view_image(im, image_canvas, image_ref, data.timestamp)) + ], + div![ + C!["column", "is-narrow"], + view_metadata_table(data), + view_debug_buttons(image_size, (canvas_width, canvas_height), zoom, offset), + ], ] }