use crate::scale::Scale;
pub const DEFAULT_ZOOM_STEP: f64 = 1.2;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum OverscrollPolicy {
Clamp,
Allow { margin_fraction: Option<f64> },
}
impl Default for OverscrollPolicy {
fn default() -> Self {
OverscrollPolicy::Clamp
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ViewportConfig {
pub min_span: Option<f64>,
pub max_span: Option<f64>,
pub zoom_step: f64,
pub overscroll: OverscrollPolicy,
pub pan_sensitivity: f64,
}
impl Default for ViewportConfig {
fn default() -> Self {
Self {
min_span: None,
max_span: None,
zoom_step: DEFAULT_ZOOM_STEP,
overscroll: OverscrollPolicy::default(),
pan_sensitivity: 1.0,
}
}
}
fn sanitize_domain(domain: (f64, f64)) -> (f64, f64) {
let (min, max) = domain;
if !min.is_finite() || !max.is_finite() || min > max {
let anchor = if min.is_finite() { min } else { 0.0 };
(anchor, anchor + 1.0)
} else {
(min, max)
}
}
fn sanitize_config(config: ViewportConfig) -> ViewportConfig {
let zoom_step = if config.zoom_step.is_finite() && config.zoom_step > 1.0 { config.zoom_step } else { DEFAULT_ZOOM_STEP };
let min_span = config.min_span.filter(|s| s.is_finite() && *s > 0.0);
let max_span = config.max_span.filter(|s| s.is_finite() && *s > 0.0);
let pan_sensitivity = if config.pan_sensitivity.is_finite() { config.pan_sensitivity } else { 1.0 };
ViewportConfig { min_span, max_span, zoom_step, overscroll: config.overscroll, pan_sensitivity }
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Viewport {
data_min: f64,
data_max: f64,
initial_min: f64,
initial_max: f64,
window_min: f64,
window_max: f64,
config: ViewportConfig,
}
impl Viewport {
pub fn new(data_domain: (f64, f64)) -> Self {
Self::with_config(data_domain, ViewportConfig::default())
}
pub fn with_config(data_domain: (f64, f64), config: ViewportConfig) -> Self {
let (data_min, data_max) = sanitize_domain(data_domain);
let mut viewport = Self {
data_min,
data_max,
initial_min: data_min,
initial_max: data_max,
window_min: data_min,
window_max: data_max,
config: sanitize_config(config),
};
viewport.clamp_window();
viewport
}
pub fn config(&self) -> ViewportConfig {
self.config
}
pub fn set_config(&mut self, config: ViewportConfig) {
self.config = sanitize_config(config);
self.clamp_window();
}
pub fn data_domain(&self) -> (f64, f64) {
(self.data_min, self.data_max)
}
pub fn window(&self) -> (f64, f64) {
(self.window_min, self.window_max)
}
pub fn span(&self) -> f64 {
self.window_max - self.window_min
}
pub fn set_data_domain(&mut self, data_domain: (f64, f64)) {
let (data_min, data_max) = sanitize_domain(data_domain);
self.data_min = data_min;
self.data_max = data_max;
self.clamp_window();
}
pub fn pan(&mut self, delta: f64) {
if !delta.is_finite() {
return;
}
self.window_min += delta;
self.window_max += delta;
self.clamp_window();
}
pub fn pan_px(&mut self, delta_px: f64, plot_width_px: f64) {
if !delta_px.is_finite() || plot_width_px.abs() < f64::EPSILON {
return;
}
let domain_per_px = self.span() / plot_width_px;
self.pan(delta_px * domain_per_px * self.config.pan_sensitivity);
}
pub fn zoom_at(&mut self, focal: f64, factor: f64) {
if !focal.is_finite() || !factor.is_finite() || factor <= 0.0 {
return;
}
let span = self.span();
if span <= 0.0 {
return;
}
let frac = ((focal - self.window_min) / span).clamp(0.0, 1.0);
let new_span = span / factor;
self.window_min = focal - frac * new_span;
self.window_max = self.window_min + new_span;
self.clamp_window();
}
pub fn zoom_in(&mut self, focal: Option<f64>) {
let focal = focal.unwrap_or_else(|| (self.window_min + self.window_max) / 2.0);
self.zoom_at(focal, self.config.zoom_step);
}
pub fn zoom_out(&mut self, focal: Option<f64>) {
let focal = focal.unwrap_or_else(|| (self.window_min + self.window_max) / 2.0);
self.zoom_at(focal, 1.0 / self.config.zoom_step);
}
pub fn fit_to_data(&mut self) {
self.window_min = self.data_min;
self.window_max = self.data_max;
let floor = self.safety_floor();
if self.window_max - self.window_min < floor {
let center = (self.window_min + self.window_max) / 2.0;
self.window_min = center - floor / 2.0;
self.window_max = center + floor / 2.0;
}
}
pub fn reset(&mut self) {
self.window_min = self.initial_min;
self.window_max = self.initial_max;
self.clamp_window();
}
fn safety_floor(&self) -> f64 {
(self.data_min.abs().max(self.data_max.abs()).max(1.0)) * 1e-12
}
fn clamp_window(&mut self) {
let data_span = (self.data_max - self.data_min).max(0.0);
let floor = self.safety_floor();
let min_span = self.config.min_span.unwrap_or(floor).max(floor);
let clamp_edges = matches!(self.config.overscroll, OverscrollPolicy::Clamp);
let implicit_max = if clamp_edges { data_span.max(min_span) } else { f64::INFINITY };
let max_span = self.config.max_span.unwrap_or(f64::INFINITY).min(implicit_max).max(min_span);
let mut span = (self.window_max - self.window_min).max(floor);
if span < min_span || span > max_span {
let center = (self.window_min + self.window_max) / 2.0;
span = span.clamp(min_span, max_span);
self.window_min = center - span / 2.0;
self.window_max = center + span / 2.0;
}
match self.config.overscroll {
OverscrollPolicy::Clamp => {
if span >= data_span {
let center = (self.data_min + self.data_max) / 2.0;
self.window_min = center - span / 2.0;
self.window_max = center + span / 2.0;
} else {
if self.window_min < self.data_min {
self.window_min = self.data_min;
self.window_max = self.data_min + span;
}
if self.window_max > self.data_max {
self.window_max = self.data_max;
self.window_min = self.data_max - span;
}
}
}
OverscrollPolicy::Allow { margin_fraction: Some(fraction) } => {
let margin = fraction.max(0.0) * span;
let lo = self.data_min - margin;
let hi = self.data_max + margin;
if self.window_min < lo {
self.window_min = lo;
self.window_max = lo + span;
}
if self.window_max > hi {
self.window_max = hi;
self.window_min = hi - span;
}
}
OverscrollPolicy::Allow { margin_fraction: None } => {
}
}
}
}
pub fn windowed_scale(full_scale: &dyn Scale, viewport: &Viewport) -> Option<Box<dyn Scale>> {
let (min, max) = viewport.window();
full_scale.windowed(min, max)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scale::LinearScale;
#[test]
fn new_viewport_shows_the_whole_data_domain() {
let vp = Viewport::new((0.0, 100.0));
assert_eq!(vp.window(), (0.0, 100.0));
assert_eq!(vp.data_domain(), (0.0, 100.0));
}
#[test]
fn pan_shifts_the_window_by_the_given_delta_when_it_stays_in_bounds() {
let mut vp = Viewport::new((0.0, 1000.0));
vp.zoom_at(500.0, 5.0); let (before_min, before_max) = vp.window();
vp.pan(10.0);
let (after_min, after_max) = vp.window();
assert!((after_min - (before_min + 10.0)).abs() < 1e-9);
assert!((after_max - (before_max + 10.0)).abs() < 1e-9);
}
#[test]
fn clamp_overscroll_never_pans_past_either_data_edge() {
let mut vp = Viewport::new((0.0, 100.0));
vp.zoom_at(50.0, 4.0); vp.pan(-10_000.0); let (min, _) = vp.window();
assert!((min - 0.0).abs() < 1e-9, "Clamp must stop exactly at the left data edge, got min={min}");
vp.pan(10_000.0); let (_, max) = vp.window();
assert!((max - 100.0).abs() < 1e-9, "Clamp must stop exactly at the right data edge, got max={max}");
}
#[test]
fn allow_overscroll_with_no_margin_pans_freely_past_the_edges() {
let config = ViewportConfig { overscroll: OverscrollPolicy::Allow { margin_fraction: None }, ..ViewportConfig::default() };
let mut vp = Viewport::with_config((0.0, 100.0), config);
vp.zoom_at(50.0, 4.0);
vp.pan(-10_000.0);
let (min, _) = vp.window();
assert!(min < -1000.0, "unlimited overscroll must allow panning far past the left edge, got min={min}");
}
#[test]
fn allow_overscroll_with_a_margin_caps_how_far_past_the_edge_it_can_go() {
let config = ViewportConfig { overscroll: OverscrollPolicy::Allow { margin_fraction: Some(1.0) }, ..ViewportConfig::default() };
let mut vp = Viewport::with_config((0.0, 100.0), config);
vp.zoom_at(50.0, 4.0); let span = vp.span();
vp.pan(-10_000.0);
let (min, _) = vp.window();
assert!((min - (0.0 - span)).abs() < 1e-6, "a margin_fraction of 1.0 must cap overscroll at exactly one window-span past the edge, got min={min}");
}
#[test]
fn zoom_at_keeps_the_focal_domain_value_at_the_same_fractional_position() {
let mut vp = Viewport::new((0.0, 1000.0));
let focal = 300.0;
let (w0, w1) = vp.window();
let frac_before = (focal - w0) / (w1 - w0);
vp.zoom_at(focal, 3.0);
let (w0, w1) = vp.window();
let frac_after = (focal - w0) / (w1 - w0);
assert!((frac_before - frac_after).abs() < 1e-9, "zoom_at must keep the focal value's fractional window position fixed");
}
#[test]
fn zoom_at_with_factor_greater_than_one_narrows_the_window() {
let mut vp = Viewport::new((0.0, 1000.0));
let before = vp.span();
vp.zoom_at(500.0, 2.0);
let after = vp.span();
assert!(after < before, "factor > 1.0 must narrow the window (before={before}, after={after})");
}
#[test]
fn zoom_in_and_zoom_out_use_the_configured_zoom_step_and_are_inverses() {
let config = ViewportConfig { zoom_step: 2.0, ..ViewportConfig::default() };
let mut vp = Viewport::with_config((0.0, 1_000_000.0), config);
vp.zoom_at(500_000.0, 100.0); let span0 = vp.span();
vp.zoom_in(None);
let span1 = vp.span();
assert!((span1 - span0 / 2.0).abs() < 1e-6, "zoom_in must narrow by exactly the configured zoom_step");
vp.zoom_out(None);
let span2 = vp.span();
assert!((span2 - span0).abs() < 1e-6, "zoom_out must exactly undo the matching zoom_in");
}
#[test]
fn non_finite_or_non_positive_zoom_step_falls_back_to_the_default() {
let config = ViewportConfig { zoom_step: 0.5, ..ViewportConfig::default() };
assert_eq!(Viewport::with_config((0.0, 10.0), config).config().zoom_step, DEFAULT_ZOOM_STEP);
let config = ViewportConfig { zoom_step: f64::NAN, ..ViewportConfig::default() };
assert_eq!(Viewport::with_config((0.0, 10.0), config).config().zoom_step, DEFAULT_ZOOM_STEP);
}
#[test]
fn min_span_prevents_zooming_in_past_the_configured_limit() {
let config = ViewportConfig { min_span: Some(10.0), ..ViewportConfig::default() };
let mut vp = Viewport::with_config((0.0, 1000.0), config);
vp.zoom_at(500.0, 1000.0); assert!(vp.span() >= 10.0 - 1e-9, "span must never go below the configured min_span, got {}", vp.span());
}
#[test]
fn max_span_prevents_zooming_out_past_the_configured_limit_even_under_allow_overscroll() {
let config = ViewportConfig {
max_span: Some(50.0),
overscroll: OverscrollPolicy::Allow { margin_fraction: None },
..ViewportConfig::default()
};
let mut vp = Viewport::with_config((0.0, 1000.0), config);
vp.zoom_at(500.0, 100.0); vp.zoom_at(500.0, 0.001); assert!(vp.span() <= 50.0 + 1e-9, "span must never exceed the configured max_span, got {}", vp.span());
}
#[test]
fn under_clamp_the_span_can_never_exceed_the_data_domain_even_with_a_larger_max_span_configured() {
let config = ViewportConfig { max_span: Some(10_000.0), overscroll: OverscrollPolicy::Clamp, ..ViewportConfig::default() };
let mut vp = Viewport::with_config((0.0, 100.0), config);
vp.zoom_at(50.0, 10.0);
vp.zoom_at(50.0, 0.0001); assert!(vp.span() <= 100.0 + 1e-6, "Clamp must cap the span at the data domain's own span regardless of a larger configured max_span");
}
#[test]
fn fit_to_data_shows_the_full_current_data_domain_regardless_of_a_narrower_max_span() {
let config = ViewportConfig { max_span: Some(1.0), ..ViewportConfig::default() };
let mut vp = Viewport::with_config((0.0, 100.0), config);
vp.fit_to_data();
assert_eq!(vp.window(), (0.0, 100.0), "fit_to_data is a deliberate escape hatch past a configured max_span");
}
#[test]
fn fit_to_data_tracks_the_live_data_domain_after_set_data_domain() {
let mut vp = Viewport::new((0.0, 100.0));
vp.zoom_at(50.0, 5.0);
vp.set_data_domain((0.0, 500.0));
vp.fit_to_data();
assert_eq!(vp.window(), (0.0, 500.0), "fit_to_data must reflect the NEW data domain, not the one captured at construction");
}
#[test]
fn reset_returns_to_the_view_captured_at_construction_even_after_the_data_domain_changed() {
let mut vp = Viewport::new((0.0, 100.0));
vp.zoom_at(50.0, 5.0);
vp.set_data_domain((0.0, 500.0)); vp.reset();
assert_eq!(vp.window(), (0.0, 100.0), "reset must return to the ORIGINAL construction-time view, independent of set_data_domain");
}
#[test]
fn set_data_domain_re_clamps_a_window_that_no_longer_fits_the_shrunk_domain() {
let mut vp = Viewport::new((0.0, 1000.0));
vp.zoom_at(900.0, 2.0); vp.set_data_domain((0.0, 500.0)); let (min, max) = vp.window();
assert!(min >= 0.0 - 1e-9 && max <= 500.0 + 1e-9, "window must be re-clamped inside the NEW, smaller domain, got ({min}, {max})");
}
#[test]
fn zero_extent_domain_does_not_panic_and_produces_a_usable_nonzero_window() {
let vp = Viewport::new((5.0, 5.0));
let (min, max) = vp.window();
assert!(min.is_finite() && max.is_finite());
assert!(max > min, "even a single-point domain must resolve to a strictly positive window span");
}
#[test]
fn zero_extent_domain_zoom_and_pan_do_not_panic() {
let mut vp = Viewport::new((5.0, 5.0));
vp.pan(1.0);
vp.zoom_at(5.0, 2.0);
vp.zoom_in(None);
vp.zoom_out(None);
vp.fit_to_data();
vp.reset();
let (min, max) = vp.window();
assert!(min.is_finite() && max.is_finite());
}
#[test]
fn non_finite_or_reversed_domain_falls_back_to_a_safe_unit_domain() {
let vp = Viewport::new((f64::NAN, 10.0));
let (min, max) = vp.data_domain();
assert!(min.is_finite() && max.is_finite() && max > min);
let vp = Viewport::new((10.0, -10.0)); let (min, max) = vp.data_domain();
assert!(min.is_finite() && max.is_finite() && max > min);
}
#[test]
fn windowed_scale_rebuilds_the_scale_over_the_viewports_own_window() {
let full = LinearScale::new(0.0, 1000.0);
let mut vp = Viewport::new((0.0, 1000.0));
vp.zoom_at(500.0, 4.0);
let windowed = windowed_scale(&full, &vp).expect("LinearScale supports windowing");
assert_eq!(windowed.domain(), vp.window());
}
}