diff --git a/src/views/graph_view/camera.rs b/src/views/graph_view/camera.rs new file mode 100644 index 0000000..8822bc5 --- /dev/null +++ b/src/views/graph_view/camera.rs @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: 2025 Menno van der Graaf +// SPDX-License-Identifier: MIT + +use crate::views::graph_view::controls::ControlEvent; +use crate::views::utils::CanvasSpace; +use euclid::{Scale, Size2D, Transform2D, Vector2D}; + +/// This represents the view's content coordinate space, dynamic axes depending on the content size +pub struct ContentSpace; + +impl ContentSpace { + /// The offset of the content from the border, half a unit + pub const PADDING: Vector2D = Vector2D::new(0.5, 0.5); + + pub fn add_padding(size: Size2D) -> Size2D { + // Calculate the additional size of the padding on all sides + let total_padding_x = Self::PADDING.x * 2.0; + let total_padding_y = Self::PADDING.y * 2.0; + Size2D::new(size.width + total_padding_x, size.height + total_padding_y) + } +} + +/// This represents the OpenGL clip space, where the canvas x and y are both represented in [-1.0, 1.0] +struct ClipSpace; + +impl ClipSpace { + /// The clip-space coordinate space starts at [-1.0, -1.0] + const CLIP_SPACE_OFFSET: Vector2D = Vector2D::new(-1.0, -1.0); + pub fn transform_from_canvas( + size: Size2D, + ) -> Transform2D { + let scale_x = 2.0 / size.width; + let scale_y = 2.0 / size.height; + Transform2D::scale(scale_x, scale_y).then_translate(Self::CLIP_SPACE_OFFSET) + } + pub fn transform_from_content( + canvas_size: Size2D, + content_size: Size2D, + zoom: Scale, + translation: Vector2D, + ) -> Transform2D { + let content_scale_x = (2.0 * zoom.get()) / content_size.width; + let content_scale_y = content_scale_x * (canvas_size.width / canvas_size.height); + Transform2D::scale(content_scale_x, content_scale_y) + .pre_translate(ContentSpace::PADDING) + .then_translate(translation) + } +} + +/// The decay of the drag velocity, in clipspace per second +const TRANSLATION_DRAG: f32 = 0.5; + +/// Zero velocity +const TRANSLATION_VELOCITY_ZERO: Vector2D = Vector2D::new(0.0, 0.0); + +/// The velocity of a user scrolling +const _ZOOM_SPEED: Scale = Scale::new(1.0); + +/// The minimum zoom level, this fits the whole contents into the clip-space, with some padding. +const _ZOOM_MINIMUM: Scale = Scale::new(1.0); + +/// The maximum zoom level +const _ZOOM_MAXIMUM: Scale = Scale::new(5.0); + +pub struct FlickableCamera { + canvas_size: Size2D, + content_size: Size2D, + canvas_to_clip: Transform2D, + zoom: Scale, + translation: Vector2D, + translation_velocity: Vector2D, +} + +impl FlickableCamera { + pub fn new() -> Self { + Self { + canvas_size: Size2D::zero(), + content_size: Size2D::zero(), + canvas_to_clip: Transform2D::identity(), + zoom: Scale::identity(), + translation: ClipSpace::CLIP_SPACE_OFFSET, + translation_velocity: Vector2D::zero(), + } + } + + pub fn handle_pointer_event(&mut self, event: ControlEvent) { + match event { + ControlEvent::Down(_coordinates) => { + // Cancel a flick if it was still going + self.translation_velocity = Vector2D::zero(); + } + ControlEvent::Move(delta_coordinates) => { + // TODO(Menno 04.05.2025) Clamp this translation + self.accumulate_translation(self.canvas_to_clip.transform_vector(Vector2D::new( + delta_coordinates.x as f32, + -delta_coordinates.y as f32, + ))); + } + ControlEvent::Up(velocity) => { + // Store the current drag velocity so that the chart can be flicked + self.translation_velocity = self + .canvas_to_clip + .transform_vector(Vector2D::new(velocity.x as f32, velocity.y as f32)); + } + } + } + + pub fn resize_canvas(&mut self, size: Size2D) { + self.canvas_to_clip = ClipSpace::transform_from_canvas(size); + } + + pub fn resize_content(&mut self, size: Size2D) { + // Store the content's size with padding applied + self.content_size = ContentSpace::add_padding(size); + } + + fn _accumulate_zoom(&mut self, zoom_movement: f32, target_x: f32, target_y: f32) { + let target_begin = self + .canvas_to_clip + .transform_vector(Vector2D::new(target_x, target_y)); + let transform_begin = ClipSpace::transform_from_content( + self.canvas_size, + self.content_size, + self.zoom, + self.translation, + ) + .inverse() + .unwrap(); + let _target_content = transform_begin.transform_vector(target_begin); + + // Convert zoom movement from canvas pixels to clip space delta + let _zoom_movement_clip = self + .canvas_to_clip + .transform_vector(Vector2D::new(0.0, zoom_movement)); + + // TODO(Menno 10.05.2025) Apply the zoom and readjust the translation so that the target x and y remain at the + // same content space point. + } + + fn accumulate_translation(&mut self, translation: Vector2D) { + // TODO(Menno 04.05.2025) Clamp this translation + self.translation += translation; + } + + pub fn view_transform(&mut self) -> [f32; 9] { + let transform = ClipSpace::transform_from_content( + self.canvas_size, + self.content_size, + self.zoom, + self.translation, + ); + let [m11, m12, m21, m22, m31, m32] = transform.to_array(); + [m11, m12, 0.0, m21, m22, 0.0, m31, m32, 1.0] + } +} diff --git a/src/views/graph_view/controls.rs b/src/views/graph_view/controls.rs index 26e1389..9a22ce1 100644 --- a/src/views/graph_view/controls.rs +++ b/src/views/graph_view/controls.rs @@ -5,14 +5,19 @@ use crate::views::pointer_handler::{MouseHandler, PointerEvent}; use crate::views::utils::{Coordinates, Delta}; use std::cell::RefCell; use std::rc::{Rc, Weak}; +use std::time::Duration; use wasm_bindgen::JsValue; use web_sys::HtmlElement; +const REST_TIME_THRESHOLD: Duration = Duration::from_millis(10); + pub struct Controls { on_event_cb: Box, // TODO(Menno 04.09.2025) Track multiple pointers for gestures drag_pointer_index: Option, previous_drag_coordinates: Coordinates, + previous_drag_timestamp: Duration, + drag_velocity: Delta, _pointer_handler: Rc>, } @@ -20,9 +25,12 @@ pub struct Controls { pub type OnPointerEventCb = dyn FnMut(ControlEvent); pub enum ControlEvent { + /// Contains the contact point on the canvas Down(Coordinates), + /// Contains the delta in pixels since the last event Move(Delta), - Up(), + /// Contains the current drag velocity in pixels per seconds + Up(Delta), } impl Controls { @@ -36,6 +44,8 @@ impl Controls { on_event_cb, drag_pointer_index: None, previous_drag_coordinates: Coordinates::zero(), + previous_drag_timestamp: Duration::from_secs(0), + drag_velocity: Delta::zero(), _pointer_handler: MouseHandler::new( target, Box::new(move |event| -> bool { @@ -50,24 +60,37 @@ impl Controls { fn handle_event(&mut self, event: PointerEvent) -> bool { let mut handled = false; match event { - PointerEvent::Down((index, _timestamp, coordinates)) => { + PointerEvent::Down((index, timestamp, coordinates)) => { if self.drag_pointer_index.is_none() { self.drag_pointer_index = Some(index); self.previous_drag_coordinates = coordinates; + self.previous_drag_timestamp = timestamp; + self.drag_velocity = Delta::zero(); (self.on_event_cb)(ControlEvent::Down(coordinates)); handled = true; } } - PointerEvent::Up((index, _timestamp, _coordinates)) => { + PointerEvent::Up((index, timestamp, _coordinates)) => { if self.drag_pointer_index == Some(index) { self.drag_pointer_index = None; - (self.on_event_cb)(ControlEvent::Up()); + + // If the last move event was a while ago, then we ignore the velocity. + let time_delta = timestamp - self.previous_drag_timestamp; + if time_delta > REST_TIME_THRESHOLD { + self.drag_velocity = Delta::zero(); + } + + (self.on_event_cb)(ControlEvent::Up(self.drag_velocity)); handled = true; } } - PointerEvent::Move((index, _timestamp, coordinates)) => { + PointerEvent::Move((index, timestamp, coordinates)) => { if self.drag_pointer_index == Some(index) { let delta = coordinates - self.previous_drag_coordinates; + let delta_time = timestamp - self.previous_drag_timestamp; + self.drag_velocity = delta / delta_time.as_secs_f64(); + self.previous_drag_timestamp = timestamp; + (self.on_event_cb)(ControlEvent::Move(delta)); self.previous_drag_coordinates = coordinates; handled = true; diff --git a/src/views/graph_view/mod.rs b/src/views/graph_view/mod.rs index b837286..b583119 100644 --- a/src/views/graph_view/mod.rs +++ b/src/views/graph_view/mod.rs @@ -5,11 +5,13 @@ use crate::board::BoardId; use crate::graph::Graph; use crate::views::frame_scheduler::{FrameScheduler, OnFrameCb}; use crate::views::graph_view::arrangement::Arrangement; +use crate::views::graph_view::camera::FlickableCamera; use crate::views::graph_view::controls::{ControlEvent, Controls}; use crate::views::graph_view::renderer::Renderer; use crate::views::resize_observer::ResizeObserver; -use crate::views::utils::get_element_of_type; -use euclid::{Scale, Size2D, Transform2D, Vector2D}; +use crate::views::utils::{get_element_of_type, CanvasSpace}; +use euclid::approxeq::ApproxEq; +use euclid::Size2D; use std::cell::RefCell; use std::rc::{Rc, Weak}; use std::time::Duration; @@ -17,63 +19,10 @@ use wasm_bindgen::JsValue; use web_sys::HtmlCanvasElement; pub mod arrangement; +mod camera; mod controls; mod renderer; -/// This represents the view's content coordinate space, dynamic axes depending on the content size -struct ContentSpace; - -impl ContentSpace { - /// The offset of the content from the border, half a unit - pub const PADDING: Vector2D = Vector2D::new(0.5, 0.5); - - pub fn add_padding(size: Size2D) -> Size2D { - // Calculate the additional size of the padding on all sides - let total_padding_x = Self::PADDING.x * 2.0; - let total_padding_y = Self::PADDING.y * 2.0; - Size2D::new(size.width + total_padding_x, size.height + total_padding_y) - } -} - -/// This represents the OpenGL clip space, where the canvas x and y are both represented in [-1.0, 1.0] -struct ClipSpace; - -impl ClipSpace { - /// The clip-space coordinate space starts at [-1.0, -1.0] - const CLIP_SPACE_OFFSET: Vector2D = Vector2D::new(-1.0, -1.0); - pub fn transform_from_canvas( - size: Size2D, - ) -> Transform2D { - let scale_x = 2.0 / size.width; - let scale_y = 2.0 / size.height; - Transform2D::scale(scale_x, scale_y).then_translate(Self::CLIP_SPACE_OFFSET) - } - pub fn transform_from_content( - canvas_size: Size2D, - content_size: Size2D, - zoom: Scale, - translation: Vector2D, - ) -> Transform2D { - let content_scale_x = (2.0 * zoom.get()) / content_size.width; - let content_scale_y = content_scale_x * (canvas_size.width / canvas_size.height); - Transform2D::scale(content_scale_x, content_scale_y) - .pre_translate(ContentSpace::PADDING) - .then_translate(translation) - } -} - -/// This represents the Canvas coordinate system, where the canvas is represented in [0, pixel size] -struct CanvasSpace; - -/// The velocity of a user scrolling -const _ZOOM_SPEED: Scale = Scale::new(1.0); - -/// The minimum zoom level, this fits the whole contents into the clip-space, with some padding. -const _ZOOM_MINIMUM: Scale = Scale::new(1.0); - -/// The maximum zoom level -const _ZOOM_MAXIMUM: Scale = Scale::new(5.0); - pub struct GraphView { _self_ref: Weak>, frame_scheduler: FrameScheduler, @@ -82,11 +31,8 @@ pub struct GraphView { canvas: HtmlCanvasElement, canvas_needs_size_update: bool, canvas_size: Size2D, - content_size: Size2D, - canvas_to_clip: Transform2D, - zoom: Scale, - translation: Vector2D, - view_transform: [f32; 9], + previous_frame_timestamp: Option, + camera: FlickableCamera, renderer: Renderer, } @@ -122,27 +68,25 @@ impl GraphView { _controls: Controls::new( &canvas, Box::new(move |event: ControlEvent| { - self_ref_for_mouse_event_cb - .upgrade() - .unwrap() - .borrow_mut() - .handle_pointer_event(event) + let self_ref_rc = self_ref_for_mouse_event_cb.upgrade().unwrap(); + let mut self_ref = self_ref_rc.borrow_mut(); + self_ref.camera.handle_pointer_event(event); + self_ref + .frame_scheduler + .schedule() + .expect("Could not schedule frame"); }), ) .expect("Could not create graph controls"), canvas, canvas_needs_size_update: false, canvas_size: Size2D::zero(), - content_size: Size2D::zero(), - canvas_to_clip: Transform2D::identity(), - zoom: Scale::identity(), - translation: ClipSpace::CLIP_SPACE_OFFSET, - view_transform: [0.0; 9], + previous_frame_timestamp: None, + camera: FlickableCamera::new(), renderer, }) }); - view.borrow_mut().recalculate_view_transform(); Ok(view) } @@ -150,8 +94,7 @@ impl GraphView { self.canvas_needs_size_update = true; self.renderer.set_viewport(width as i32, height as i32); self.canvas_size = Size2D::new(width as f32, height as f32); - self.canvas_to_clip = ClipSpace::transform_from_canvas(self.canvas_size); - self.recalculate_view_transform(); + self.camera.resize_canvas(self.canvas_size); self.schedule_draw(); } @@ -159,14 +102,30 @@ impl GraphView { self.frame_scheduler.schedule().unwrap(); } - fn draw(&mut self, _timestamp: Duration) { + fn draw(&mut self, timestamp: Duration) { + let previous_timestamp = self.previous_frame_timestamp.get_or_insert(timestamp); + let delta_time = previous_timestamp.as_secs_f32() - timestamp.as_secs_f32(); + // Update translation in case of flick + // self.translation += self.translation_velocity * delta_time; + // self.translation_velocity -= TRANSLATION_DRAG * delta_time; + + // Resize canvas if needed if self.canvas_needs_size_update { self.canvas_needs_size_update = false; self.canvas.set_width(self.canvas_size.width as u32); self.canvas.set_height(self.canvas_size.height as u32); } - self.renderer.draw(&self.view_transform) + // Draw + self.renderer.draw(self.camera.view_transform()); + + // Schedule next draw if needed + // if !self + // .translation_velocity + // .approx_eq(&TRANSLATION_VELOCITY_ZERO) + // { + self.schedule_draw(); + // } } pub fn set_data(&mut self, graph: &Graph, active_state: BoardId) { @@ -176,66 +135,10 @@ impl GraphView { // Upload the data to the GPU let vertices_array = unsafe { js_sys::Float32Array::view(&arrangement.points) }; self.renderer.set_data(&vertices_array); - - // Store the content's size with padding applied - self.content_size = ContentSpace::add_padding(Size2D::new( + self.camera.resize_content(Size2D::new( arrangement.width as f32, arrangement.height as f32, )); - self.recalculate_view_transform(); self.schedule_draw(); } - - fn handle_pointer_event(&mut self, event: ControlEvent) { - match event { - ControlEvent::Down(_coordinates) => {} - ControlEvent::Move(coordinates) => { - self.handle_translation(Vector2D::new(coordinates.x as f32, -coordinates.y as f32)) - } - ControlEvent::Up() => {} - } - } - - fn _accumulate_zoom(&mut self, zoom_movement: f32, target_x: f32, target_y: f32) { - let target_begin = self - .canvas_to_clip - .transform_vector(Vector2D::new(target_x, target_y)); - let transform_begin = ClipSpace::transform_from_content( - self.canvas_size, - self.content_size, - self.zoom, - self.translation, - ) - .inverse() - .unwrap(); - let _target_content = transform_begin.transform_vector(target_begin); - - // Convert zoom movement from canvas pixels to clip space delta - let _zoom_movement_clip = self - .canvas_to_clip - .transform_vector(Vector2D::new(0.0, zoom_movement)); - - // TODO(Menno 10.05.2025) Apply the zoom and readjust the translation so that the target x and y remain at the - // same content space point. - self.recalculate_view_transform(); - self.schedule_draw(); - } - - fn handle_translation(&mut self, translation: Vector2D) { - // TODO(Menno 04.05.2025) Clamp this translation - self.translation += self.canvas_to_clip.transform_vector(translation); - self.recalculate_view_transform(); - self.schedule_draw(); - } - - fn recalculate_view_transform(&mut self) { - let transform = ClipSpace::transform_from_content( - self.canvas_size, - self.content_size, - self.zoom, - self.translation, - ); - let [m11, m12, m21, m22, m31, m32] = transform.to_array(); - self.view_transform = [m11, m12, 0.0, m21, m22, 0.0, m31, m32, 1.0]; - } } diff --git a/src/views/graph_view/renderer.rs b/src/views/graph_view/renderer.rs index 73d68ff..8459721 100644 --- a/src/views/graph_view/renderer.rs +++ b/src/views/graph_view/renderer.rs @@ -167,7 +167,7 @@ impl Renderer { self.gl.viewport(0, 0, width, height); } - pub fn draw(&mut self, view_transform: &[f32; 9]) { + pub fn draw(&mut self, view_transform: [f32; 9]) { // Prepare state self.gl.use_program(Some(&self.shaders)); self.gl.bind_vertex_array(Some(&self.vao)); @@ -176,7 +176,7 @@ impl Renderer { self.gl.uniform_matrix3fv_with_f32_array( Some(&self.view_transform_location), false, - view_transform, + &view_transform, ); // Clear screen and draw points