Better zoom/pan behavior, handle images of different aspects.

Nearest neighbor drawing, so zooming is pixalated.
This commit is contained in:
Bill Thiede 2023-02-19 14:31:39 -08:00
parent a0aae68a97
commit c4140a0618
2 changed files with 157 additions and 79 deletions

View File

@ -31,6 +31,7 @@ features = [
"CanvasRenderingContext2d", "CanvasRenderingContext2d",
"HtmlImageElement", "HtmlImageElement",
"ImageData", "ImageData",
"ContextAttributes2d",
] ]
[profile.release] [profile.release]

View File

@ -6,13 +6,13 @@
use std::time::Duration; use std::time::Duration;
use chrono::{Local, NaiveDateTime, TimeZone}; use chrono::{Local, NaiveDateTime, TimeZone};
use log::Level; use log::{info, Level};
use seed::{prelude::*, *}; use seed::{prelude::*, *};
use thiserror::Error; use thiserror::Error;
use wasm_timer::{SystemTime, UNIX_EPOCH}; 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)] #[derive(Clone, Error, Debug)]
pub enum AppError { pub enum AppError {
@ -27,7 +27,9 @@ pub enum AppError {
// `init` describes what should happen when your app started. // `init` describes what should happen when your app started.
fn init(_: Url, orders: &mut impl Orders<Msg>) -> Model { fn init(_: Url, orders: &mut impl Orders<Msg>) -> Model {
let timer_handle = orders.stream_with_handle(streams::interval(1000, || Msg::OnCheck)); 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 { Model {
timer_handle, timer_handle,
data: None, data: None,
@ -66,8 +68,10 @@ enum Msg {
Rendered, Rendered,
Received(Data), Received(Data),
Reset, Reset,
SetZoom(f64, [i32; 2]),
Zoom(f64, [i32; 2]), Zoom(f64, [i32; 2]),
Move([f64; 2]), Move([f64; 2]),
UrlChanged(subs::UrlChanged),
} }
// `update` describes how to handle each `Msg`. // `update` describes how to handle each `Msg`.
@ -127,6 +131,14 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
model.zoom = 1.; model.zoom = 1.;
model.offset = [0., 0.]; 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) => { Msg::Zoom(scale, at) => {
let old_zoom = model.zoom; let old_zoom = model.zoom;
model.zoom += scale; model.zoom += scale;
@ -135,12 +147,16 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
} }
let z_rat = model.zoom / old_zoom; let z_rat = model.zoom / old_zoom;
let at = [at[0] as f64, at[1] as f64]; 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 = [ model.offset = [
at[0] - (z_rat) * (at[0] - model.offset[0]), at[0] - (z_rat) * (at[0] - model.offset[0]),
at[1] - (z_rat) * (at[1] - model.offset[1]), 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::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}; use serde::{Deserialize, Serialize};
@ -206,8 +222,9 @@ fn view_metadata_table(data: &Data) -> Node<Msg> {
let dt = NaiveDateTime::from_timestamp_opt(data.timestamp, 0).unwrap(); let dt = NaiveDateTime::from_timestamp_opt(data.timestamp, 0).unwrap();
let dt = Local.from_local_datetime(&dt).unwrap(); let dt = Local.from_local_datetime(&dt).unwrap();
let rendered_at = dt.format("%Y-%m-%d %H:%M:%S").to_string();
div![ div![
div!["Rendered @ ", dt.format("%Y-%m-%d %H:%M:%S").to_string()], div!["Rendered @ ", &rendered_at],
div![ div![
C![IF!(fresh => "fresh")], C![IF!(fresh => "fresh")],
humantime::format_duration(d).to_string(), humantime::format_duration(d).to_string(),
@ -217,6 +234,7 @@ fn view_metadata_table(data: &Data) -> Node<Msg> {
C!["table", "is-striped", "is-narrow"], C!["table", "is-striped", "is-narrow"],
tbody![ tbody![
tr![th!["Name"], th!["Value"]], tr![th!["Name"], th!["Value"]],
row("render_time_seconds", data.render_time_seconds),
row("subsamples", data.scene.subsamples), row("subsamples", data.scene.subsamples),
row("adaptive_subsampling", data.scene.adaptive_subsampling), row("adaptive_subsampling", data.scene.adaptive_subsampling),
row("num_threads", data.scene.num_threads), row("num_threads", data.scene.num_threads),
@ -241,19 +259,14 @@ fn draw(
} }
}; };
#[derive(Serialize)] let mut ca2 = ContextAttributes2d::new();
struct ContextOptions { // Chrome warning:
#[serde(rename = "willReadFrequently")] // Canvas2D: Multiple readback operations using getImageData are faster with the
will_read_frequently: bool, // 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 let ctx = canvas
.get_context_with_context_options( .get_context_with_context_options("2d", &ca2)
"2d",
&wasm_bindgen::JsValue::from_serde(&ContextOptions {
will_read_frequently: true,
})
.unwrap(),
)
.expect("Problem getting canvas context") .expect("Problem getting canvas context")
.expect("The canvas context is empty") .expect("The canvas context is empty")
.dyn_into::<web_sys::CanvasRenderingContext2d>() .dyn_into::<web_sys::CanvasRenderingContext2d>()
@ -274,6 +287,8 @@ fn draw(
let dy = offset[1]; let dy = offset[1];
let dw = dw * zoom; let dw = dw * zoom;
let dh = dh * 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( 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, &image, sx, sy, sw, sh, dx, dy, dw, dh,
) )
@ -285,12 +300,21 @@ fn view_image(
im: &ImageMetadata, im: &ImageMetadata,
image_canvas: &ElRef<HtmlCanvasElement>, image_canvas: &ElRef<HtmlCanvasElement>,
image_ref: &ElRef<HtmlImageElement>, image_ref: &ElRef<HtmlImageElement>,
timestamp: i64,
) -> Node<Msg> { ) -> Node<Msg> {
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() { 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![ Some(attrs![
At::Width => px(canvas.client_width()), At::Width => px(width),
At::Height => px(canvas.client_height()), At::Height => px(height),
]) ])
} else { } else {
None None
@ -321,10 +345,11 @@ fn view_image(
}) })
}), }),
], ],
button![C!["button"], "View image", ev(Ev::Click, go)],
img![ img![
el_ref(image_ref), el_ref(image_ref),
attrs! { attrs! {
At::Src => url At::Src => &url
}, },
style! { style! {
St::Display => "none", 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<Msg> {
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( fn view_data(
data: &Data, data: &Data,
image_canvas: &ElRef<HtmlCanvasElement>, image_canvas: &ElRef<HtmlCanvasElement>,
@ -340,74 +462,29 @@ fn view_data(
zoom: f64, zoom: f64,
offset: [f64; 2], offset: [f64; 2],
) -> Node<Msg> { ) -> Node<Msg> {
let mut width = 128; let mut canvas_width = 128;
let mut height = 128; let mut canvas_height = 128;
if let Some(c) = image_canvas.get() { if let Some(c) = image_canvas.get() {
width = c.width() as i32; canvas_width = c.width() as i32;
height = c.height() as i32; canvas_height = c.height() as i32;
} }
let zoom_in = 0.1; let im = data.image_metadata.iter().nth(0);
let zoom_out = -0.1; let image_size = im.map(|im| im.size).unwrap_or((512, 512));
div![ div![
C!["columns"], 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![ div![
C!["column"], C!["column"],
div![ div![
C!["tabs"], C!["tabs"],
ul![data.image_metadata.iter().map(|im| li![a![&im.name]])] ul![data.image_metadata.iter().map(|im| li![a![&im.name]])]
], ],
data.image_metadata im.map(|im| view_image(im, image_canvas, image_ref, data.timestamp))
.iter() ],
.nth(0) div![
.map(|im| view_image(im, image_canvas, image_ref)) C!["column", "is-narrow"],
] view_metadata_table(data),
view_debug_buttons(image_size, (canvas_width, canvas_height), zoom, offset),
],
] ]
} }