use leptos::prelude::*;
use wasm_bindgen::JsCast;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ZoomTransform {
pub x_min: f64,
pub x_max: f64,
pub y_min: f64,
pub y_max: f64,
}
impl ZoomTransform {
pub fn from_domain(x_min: f64, x_max: f64, y_min: f64, y_max: f64) -> Self {
Self {
x_min,
x_max,
y_min,
y_max,
}
}
pub fn zoom(&self, factor: f64, center_x: f64, center_y: f64) -> Self {
let x_range = self.x_max - self.x_min;
let y_range = self.y_max - self.y_min;
let domain_cx = self.x_min + center_x * x_range;
let domain_cy = self.y_min + center_y * y_range;
let new_x_range = x_range / factor;
let new_y_range = y_range / factor;
let new_x_min = domain_cx - center_x * new_x_range;
let new_x_max = new_x_min + new_x_range;
let new_y_min = domain_cy - center_y * new_y_range;
let new_y_max = new_y_min + new_y_range;
Self {
x_min: new_x_min,
x_max: new_x_max,
y_min: new_y_min,
y_max: new_y_max,
}
}
pub fn zoom_to_box(&self, x1: f64, y1: f64, x2: f64, y2: f64) -> Self {
let x_range = self.x_max - self.x_min;
let y_range = self.y_max - self.y_min;
let start_x = x1.min(x2);
let end_x = x1.max(x2);
let start_y_norm = y1.min(y2); let end_y_norm = y1.max(y2);
let new_y_max = self.y_max - start_y_norm * y_range;
let new_y_min = self.y_max - end_y_norm * y_range;
let new_x_min = self.x_min + start_x * x_range;
let new_x_max = self.x_min + end_x * x_range;
Self {
x_min: new_x_min,
x_max: new_x_max,
y_min: new_y_min,
y_max: new_y_max,
}
}
pub fn pan(&self, dx: f64, dy: f64) -> Self {
Self {
x_min: self.x_min + dx,
x_max: self.x_max + dx,
y_min: self.y_min + dy,
y_max: self.y_max + dy,
}
}
pub fn reset(original: &ZoomTransform) -> Self {
*original
}
}
#[component]
pub fn ZoomPan(
transform: RwSignal<ZoomTransform>,
#[prop(into)]
original: Signal<ZoomTransform>,
#[prop(into)]
inner_width: Signal<f64>,
#[prop(into)]
inner_height: Signal<f64>,
#[prop(default = true)]
enable_zoom: bool,
#[prop(default = true)]
_enable_pan: bool, #[prop(optional)]
set_cursor: Option<WriteSignal<Option<(f64, f64)>>>,
#[prop(into, optional)]
zoom_fill: Option<Signal<String>>,
#[prop(into, optional)]
zoom_stroke: Option<Signal<String>>,
) -> impl IntoView {
let zoom_fill = move || {
zoom_fill
.map(|s| s.get())
.unwrap_or_else(|| "rgba(66,135,245,0.2)".to_string())
};
let zoom_stroke = move || {
zoom_stroke
.map(|s| s.get())
.unwrap_or_else(|| "rgba(66,135,245,0.8)".to_string())
};
let (selection_start, set_selection_start) = signal(None::<(f64, f64)>);
let (selection_current, set_selection_current) = signal(None::<(f64, f64)>);
let _is_selecting = Memo::new(move |_| selection_start.get().is_some());
let selection_rect = Memo::new(move |_| {
let start = selection_start.get();
let current = selection_current.get();
match (start, current) {
(Some((x1, y1)), Some((x2, y2))) => {
let w = inner_width.get();
let h = inner_height.get();
let x = x1.min(x2) * w;
let y = y1.min(y2) * h;
let width = (x2 - x1).abs() * w;
let height = (y2 - y1).abs() * h;
Some((x, y, width, height))
}
_ => None,
}
});
let on_mousedown = move |ev: web_sys::MouseEvent| {
if !enable_zoom {
return;
}
if !ev.ctrl_key() {
return;
}
let rect = ev
.target()
.unwrap()
.unchecked_into::<web_sys::Element>()
.get_bounding_client_rect();
let x = ev.client_x() as f64 - rect.left();
let y = ev.client_y() as f64 - rect.top();
let w = rect.width();
let h = rect.height();
let norm_x = (x / w).clamp(0.0, 1.0);
let norm_y = (y / h).clamp(0.0, 1.0);
set_selection_start.set(Some((norm_x, norm_y)));
set_selection_current.set(Some((norm_x, norm_y)));
if let Some(setter) = set_cursor {
setter.set(Some((norm_x, norm_y)));
}
ev.prevent_default();
};
let on_mousemove = move |ev: web_sys::MouseEvent| {
let rect = ev
.target()
.unwrap()
.unchecked_into::<web_sys::Element>()
.get_bounding_client_rect();
let x = ev.client_x() as f64 - rect.left();
let y = ev.client_y() as f64 - rect.top();
let w = rect.width();
let h = rect.height();
let norm_x = (x / w).clamp(0.0, 1.0);
let norm_y = (y / h).clamp(0.0, 1.0);
if let Some(setter) = set_cursor {
setter.set(Some((norm_x, norm_y)));
}
if selection_start.get().is_some() {
set_selection_current.set(Some((norm_x, norm_y)));
}
};
let on_mouseup = move |ev: web_sys::MouseEvent| {
if let (Some((x1, y1)), Some((x2, y2))) = (selection_start.get(), selection_current.get()) {
if (x1 - x2).abs() > 0.01 && (y1 - y2).abs() > 0.01 {
transform.update(|t| {
*t = t.zoom_to_box(x1, y1, x2, y2);
});
}
set_selection_start.set(None);
set_selection_current.set(None);
ev.prevent_default();
}
};
let on_mouseleave = move |_| {
set_selection_start.set(None);
set_selection_current.set(None);
if let Some(setter) = set_cursor {
setter.set(None);
}
};
let on_dblclick = move |ev: web_sys::MouseEvent| {
transform.set(original.get());
ev.prevent_default();
};
view! {
<>
<rect
width=move || inner_width.get()
height=move || inner_height.get()
fill="transparent"
style="cursor: cell; pointer-events: all;"
on:mousedown=on_mousedown
on:mousemove=on_mousemove
on:mouseup=on_mouseup
on:mouseleave=on_mouseleave
on:dblclick=on_dblclick
/>
{move || {
selection_rect
.get()
.map(|(x, y, w, h)| {
view! {
<rect
x=x
y=y
width=w
height=h
fill=zoom_fill()
stroke=zoom_stroke()
stroke-width="1"
style="pointer-events: none;"
/>
}
})
}}
</>
}
}