use egui::{Color32, Rect};
use crate::core::backend::ItemHandle;
use crate::core::colormap::Colormap;
use crate::core::marker::Marker;
use crate::core::roi::{DEFAULT_ROI_COLOR, ManagedRoi};
use crate::core::shape::{Line, Shape};
use crate::core::transform::{Axis, Margins, Scale, Transform, keep_aspect_limits};
use crate::core::triangles::Triangles;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct AxisConstraints {
pub min_range: Option<f64>,
pub max_range: Option<f64>,
pub min_pos: Option<f64>,
pub max_pos: Option<f64>,
}
impl AxisConstraints {
pub fn apply(self, lo: f64, hi: f64) -> (f64, f64) {
let mut span = hi - lo;
if span <= 0.0 {
return (lo, hi);
}
let mut effective_max_range = self.max_range;
if let (Some(max_range), Some(min_pos), Some(max_pos)) =
(self.max_range, self.min_pos, self.max_pos)
{
effective_max_range = Some(max_range.min(max_pos - min_pos));
}
if let Some(min) = self.min_range
&& span < min
{
span = min;
}
if let Some(max) = effective_max_range
&& span > max
{
span = max;
}
let mid = (lo + hi) * 0.5;
let mut new_lo = mid - span * 0.5;
let mut new_hi = mid + span * 0.5;
let below = self.min_pos.filter(|&min_pos| new_lo < min_pos);
let above = self.max_pos.filter(|&max_pos| new_hi > max_pos);
match (below, above) {
(Some(min_pos), Some(max_pos)) => {
new_lo = min_pos;
new_hi = max_pos;
}
(Some(min_pos), None) => {
let shift = min_pos - new_lo;
new_lo += shift;
new_hi += shift;
}
(None, Some(max_pos)) => {
let shift = max_pos - new_hi;
new_lo += shift;
new_hi += shift;
}
(None, None) => {}
}
if new_lo >= new_hi {
return (lo, hi);
}
(new_lo, new_hi)
}
pub fn is_unconstrained(self) -> bool {
self.min_range.is_none()
&& self.max_range.is_none()
&& self.min_pos.is_none()
&& self.max_pos.is_none()
}
}
pub type PlotId = u64;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TickMode {
#[default]
Numeric,
TimeSeries,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum GraphGrid {
None,
#[default]
Major,
MajorAndMinor,
}
impl GraphGrid {
pub fn major(self) -> bool {
matches!(self, Self::Major | Self::MajorAndMinor)
}
pub fn minor(self) -> bool {
matches!(self, Self::MajorAndMinor)
}
}
pub fn resolved_axis_label(default_label: Option<&str>, active_label: Option<&str>) -> String {
fn non_empty(s: Option<&str>) -> Option<&str> {
s.filter(|l| !l.is_empty())
}
non_empty(active_label)
.or(non_empty(default_label))
.unwrap_or("")
.to_string()
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DirtyState {
#[default]
Clean,
Overlay,
Full,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct DataRange {
pub x: Option<(f64, f64)>,
pub y: Option<(f64, f64)>,
pub y2: Option<(f64, f64)>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct DataMargins {
pub x_min: f64,
pub x_max: f64,
pub y_min: f64,
pub y_max: f64,
}
impl DataMargins {
fn expand_axis(lo: f64, hi: f64, low_ratio: f64, high_ratio: f64, is_log: bool) -> (f64, f64) {
if !is_log {
let range = hi - lo;
(lo - low_ratio * range, hi + high_ratio * range)
} else if lo > 0.0 && hi > 0.0 {
let lo_log = lo.log10();
let hi_log = hi.log10();
let range_log = hi_log - lo_log;
(
10f64.powf(lo_log - low_ratio * range_log),
10f64.powf(hi_log + high_ratio * range_log),
)
} else {
(lo, hi)
}
}
}
pub struct Plot {
pub id: PlotId,
pub data_background: Color32,
pub limits: (f64, f64, f64, f64),
pub margins: Margins,
pub colormap: Option<Colormap>,
pub show_colorbar: bool,
pub home_limits: Option<(f64, f64, f64, f64)>,
pub x_scale: Scale,
pub y_scale: Scale,
pub x_inverted: bool,
pub y_inverted: bool,
pub keep_aspect: bool,
pub y2: Option<(f64, f64)>,
pub crosshair: bool,
pub rois: Vec<ManagedRoi>,
pub roi_color: Color32,
current_roi: Option<usize>,
pub markers: Vec<Marker>,
pub marker_handles: Vec<ItemHandle>,
pub shapes: Vec<Shape>,
pub triangles: Vec<Triangles>,
pub title: Option<String>,
pub x_label: Option<String>,
pub y_label: Option<String>,
pub y2_label: Option<String>,
pub active_x_label: Option<String>,
pub active_y_label: Option<String>,
pub active_y2_label: Option<String>,
pub foreground: Option<Color32>,
pub grid_color: Option<Color32>,
pub grid: GraphGrid,
pub x_constraints: AxisConstraints,
pub y_constraints: AxisConstraints,
pub x_max_ticks: Option<usize>,
pub y_max_ticks: Option<usize>,
limits_history: Vec<LimitsHistoryEntry>,
x_autoscale: bool,
y_autoscale: bool,
y2_autoscale: bool,
data_range: Option<DataRange>,
data_margins: DataMargins,
axes_displayed: bool,
dirty: DirtyState,
autoreplot: bool,
x_tick_mode: TickMode,
lines: Vec<Line>,
}
type LimitsHistoryEntry = ((f64, f64, f64, f64), Option<(f64, f64)>);
impl Plot {
pub fn new(id: PlotId) -> Self {
Self {
id,
data_background: Color32::from_rgb(16, 16, 24),
limits: (0.0, 1.0, 0.0, 1.0),
margins: Margins::ZERO,
colormap: None,
show_colorbar: true,
home_limits: None,
x_scale: Scale::Linear,
y_scale: Scale::Linear,
x_inverted: false,
y_inverted: false,
keep_aspect: false,
y2: None,
crosshair: false,
rois: Vec::new(),
roi_color: DEFAULT_ROI_COLOR,
current_roi: None,
markers: Vec::new(),
marker_handles: Vec::new(),
shapes: Vec::new(),
triangles: Vec::new(),
title: None,
x_label: None,
y_label: None,
y2_label: None,
active_x_label: None,
active_y_label: None,
active_y2_label: None,
foreground: None,
grid_color: None,
grid: GraphGrid::Major,
x_constraints: AxisConstraints::default(),
y_constraints: AxisConstraints::default(),
x_max_ticks: None,
y_max_ticks: None,
limits_history: Vec::new(),
x_autoscale: true,
y_autoscale: true,
y2_autoscale: true,
data_range: None,
data_margins: DataMargins::default(),
axes_displayed: true,
dirty: DirtyState::Clean,
autoreplot: true,
x_tick_mode: TickMode::Numeric,
lines: Vec::new(),
}
}
pub fn current_roi(&self) -> Option<usize> {
self.current_roi
}
pub fn set_current_roi(&mut self, index: Option<usize>) {
self.current_roi = match index {
Some(i) if i < self.rois.len() => Some(i),
_ => None,
};
self.sync_roi_selection();
}
fn sync_roi_selection(&mut self) {
let current = self.current_roi;
for (i, r) in self.rois.iter_mut().enumerate() {
r.selected = Some(i) == current;
}
}
pub fn remove_roi(&mut self, index: usize) {
if index >= self.rois.len() {
return;
}
self.rois.remove(index);
self.current_roi = match self.current_roi {
Some(c) if c == index => None,
Some(c) if c > index => Some(c - 1),
other => other,
};
self.sync_roi_selection();
}
pub fn clear_rois(&mut self) {
self.rois.clear();
self.current_roi = None;
}
pub fn push_limits(&mut self) {
self.limits_history.push((self.limits, self.y2));
}
pub fn zoom_back(&mut self) -> bool {
if let Some((limits, y2)) = self.limits_history.pop() {
self.limits = limits;
self.y2 = y2;
true
} else {
false
}
}
pub fn clear_limits_history(&mut self) {
self.limits_history.clear();
}
pub fn limits_history_len(&self) -> usize {
self.limits_history.len()
}
pub fn x_autoscale(&self) -> bool {
self.x_autoscale
}
pub fn set_x_autoscale(&mut self, on: bool) {
self.x_autoscale = on;
}
pub fn y_autoscale(&self) -> bool {
self.y_autoscale
}
pub fn set_y_autoscale(&mut self, on: bool) {
self.y_autoscale = on;
}
pub fn y2_autoscale(&self) -> bool {
self.y2_autoscale
}
pub fn set_y2_autoscale(&mut self, on: bool) {
self.y2_autoscale = on;
}
pub fn data_range(&self) -> DataRange {
self.data_range.unwrap_or_default()
}
pub fn set_data_range(&mut self, range: DataRange) {
self.data_range = Some(range);
}
pub fn reset_zoom(&mut self) {
self.reset_zoom_to_data_range(self.data_range());
}
pub fn data_margins(&self) -> DataMargins {
self.data_margins
}
pub fn set_data_margins(&mut self, margins: DataMargins) {
self.data_margins = margins;
}
pub fn axes_displayed(&self) -> bool {
self.axes_displayed
}
pub fn set_axes_displayed(&mut self, displayed: bool) {
if displayed != self.axes_displayed {
self.axes_displayed = displayed;
self.set_dirty(false);
}
}
pub fn dirty(&self) -> DirtyState {
self.dirty
}
pub fn set_dirty(&mut self, overlay_only: bool) {
self.dirty = if self.dirty == DirtyState::Clean && overlay_only {
DirtyState::Overlay
} else {
DirtyState::Full
};
}
pub fn replot(&mut self) {
self.dirty = DirtyState::Clean;
}
pub fn autoreplot(&self) -> bool {
self.autoreplot
}
pub fn set_autoreplot(&mut self, autoreplot: bool) {
self.autoreplot = autoreplot;
}
pub fn x_tick_mode(&self) -> TickMode {
self.x_tick_mode
}
pub fn set_x_tick_mode(&mut self, mode: TickMode) {
self.x_tick_mode = mode;
}
pub fn add_line(&mut self, line: Line) {
self.lines.push(line);
}
pub fn lines(&self) -> &[Line] {
&self.lines
}
pub fn lines_mut(&mut self) -> &mut Vec<Line> {
&mut self.lines
}
pub fn x_axis_label(&self, active_label: Option<&str>) -> String {
resolved_axis_label(self.x_label.as_deref(), active_label)
}
pub fn y_axis_label(&self, active_label: Option<&str>) -> String {
resolved_axis_label(self.y_label.as_deref(), active_label)
}
pub fn y2_axis_label(&self, active_label: Option<&str>) -> String {
resolved_axis_label(self.y2_label.as_deref(), active_label)
}
pub fn displayed_x_label(&self) -> Option<String> {
let label = self.x_axis_label(self.active_x_label.as_deref());
(!label.is_empty()).then_some(label)
}
pub fn displayed_y_label(&self) -> Option<String> {
let label = self.y_axis_label(self.active_y_label.as_deref());
(!label.is_empty()).then_some(label)
}
pub fn displayed_y2_label(&self) -> Option<String> {
let label = self.y2_axis_label(self.active_y2_label.as_deref());
(!label.is_empty()).then_some(label)
}
pub fn grid_color(&self) -> Option<Color32> {
self.grid_color
}
pub fn set_grid_color(&mut self, color: Option<Color32>) {
if self.grid_color != color {
self.grid_color = color;
self.set_dirty(false);
}
}
pub fn effective_grid_color(&self, foreground: Color32) -> Color32 {
self.grid_color.unwrap_or(foreground)
}
pub fn reset_zoom_to_data_range(&mut self, data: DataRange) {
let (mut x_min, mut x_max, mut y_min, mut y_max) = self.limits;
let mut y2 = self.y2;
let x_auto = self.x_autoscale || (self.x_scale == Scale::Log10 && x_min <= 0.0);
let y_log_force = self.y_scale == Scale::Log10
&& (y_min <= 0.0 || self.y2.map(|(lo, _)| lo <= 0.0).unwrap_or(false));
let y_auto = self.y_autoscale || y_log_force;
let y2_auto = self.y2_autoscale || y_log_force;
let mut x_refit = false;
let mut y_refit = false;
let mut y2_refit = false;
if x_auto && let Some((dmin, dmax)) = data.x {
x_min = dmin;
x_max = dmax;
x_refit = true;
}
if y_auto && let Some((dmin, dmax)) = data.y {
y_min = dmin;
y_max = dmax;
y_refit = true;
}
if y2_auto && let Some((dmin, dmax)) = data.y2 {
y2 = Some((dmin, dmax));
y2_refit = true;
}
let m = self.data_margins;
let x_is_log = self.x_scale == Scale::Log10;
let y_is_log = self.y_scale == Scale::Log10;
if x_refit {
(x_min, x_max) = DataMargins::expand_axis(x_min, x_max, m.x_min, m.x_max, x_is_log);
}
if y_refit {
(y_min, y_max) = DataMargins::expand_axis(y_min, y_max, m.y_min, m.y_max, y_is_log);
}
if y2_refit && let Some((lo, hi)) = y2 {
y2 = Some(DataMargins::expand_axis(lo, hi, m.y_min, m.y_max, y_is_log));
}
self.limits = (x_min, x_max, y_min, y_max);
self.y2 = y2;
}
pub fn transform(&self, area: Rect) -> Transform {
let linear = self.x_scale == Scale::Linear && self.y_scale == Scale::Linear;
let (x_min, x_max, y_min, y_max) = if self.keep_aspect && linear {
keep_aspect_limits(self.limits, area)
} else {
self.limits
};
let x = Axis {
min: x_min,
max: x_max,
scale: self.x_scale,
inverted: self.x_inverted,
};
let y = Axis {
min: y_min,
max: y_max,
scale: self.y_scale,
inverted: self.y_inverted,
};
Transform::with_axes(x, y, area)
}
pub fn transform_y2(&self, area: Rect) -> Option<Transform> {
let (y2_min, y2_max) = self.y2?;
let left = self.transform(area);
let y2 = Axis::linear(y2_min, y2_max);
Some(Transform::with_axes(left.x, y2, area))
}
}
#[cfg(test)]
mod tests {
use super::*;
use egui::pos2;
fn area() -> Rect {
Rect::from_min_max(pos2(0.0, 0.0), pos2(200.0, 100.0))
}
#[test]
fn axis_constraints_unconstrained_is_passthrough() {
let c = AxisConstraints::default();
assert_eq!(c.apply(0.0, 10.0), (0.0, 10.0));
assert!(c.is_unconstrained());
}
#[test]
fn axis_constraints_min_range_widens_span() {
let c = AxisConstraints {
min_range: Some(5.0),
..Default::default()
};
let (lo, hi) = c.apply(0.0, 2.0);
assert!((hi - lo - 5.0).abs() < 1e-10, "span={}", hi - lo);
assert!(((lo + hi) / 2.0 - 1.0).abs() < 1e-10); }
#[test]
fn axis_constraints_max_range_narrows_span() {
let c = AxisConstraints {
max_range: Some(5.0),
..Default::default()
};
let (lo, hi) = c.apply(0.0, 10.0);
assert!((hi - lo - 5.0).abs() < 1e-10, "span={}", hi - lo);
assert!(((lo + hi) / 2.0 - 5.0).abs() < 1e-10);
}
#[test]
fn axis_constraints_min_pos_shifts_window_right() {
let c = AxisConstraints {
min_pos: Some(2.0),
..Default::default()
};
let (lo, hi) = c.apply(0.0, 4.0);
assert!((lo - 2.0).abs() < 1e-10, "lo={lo}");
assert!((hi - 6.0).abs() < 1e-10, "hi={hi}");
}
#[test]
fn axis_constraints_max_pos_shifts_window_left() {
let c = AxisConstraints {
max_pos: Some(8.0),
..Default::default()
};
let (lo, hi) = c.apply(6.0, 12.0);
assert!((hi - 8.0).abs() < 1e-10, "hi={hi}");
assert!((lo - 2.0).abs() < 1e-10, "lo={lo}");
}
#[test]
fn axis_constraints_view_wider_than_window_snaps_to_window() {
let c = AxisConstraints {
min_pos: Some(0.0),
max_pos: Some(10.0),
..Default::default()
};
let (lo, hi) = c.apply(-5.0, 15.0);
assert!((lo - 0.0).abs() < 1e-10, "lo={lo}");
assert!((hi - 10.0).abs() < 1e-10, "hi={hi}");
}
#[test]
fn axis_constraints_max_range_capped_to_window_keeps_offcenter_in_bounds() {
let c = AxisConstraints {
min_pos: Some(0.0),
max_pos: Some(10.0),
max_range: Some(100.0),
..Default::default()
};
let (lo, hi) = c.apply(2.0, 22.0);
assert!((lo - 0.0).abs() < 1e-10, "lo={lo}");
assert!((hi - 10.0).abs() < 1e-10, "hi={hi}");
}
#[test]
fn axis_constraints_one_end_out_shifts_within_both_bounds() {
let c = AxisConstraints {
min_pos: Some(0.0),
max_pos: Some(10.0),
..Default::default()
};
let (lo, hi) = c.apply(-2.0, 3.0);
assert!((lo - 0.0).abs() < 1e-10, "lo={lo}");
assert!((hi - 5.0).abs() < 1e-10, "hi={hi}");
}
#[test]
fn axis_constraints_degenerate_span_is_passthrough() {
let c = AxisConstraints {
min_range: Some(1.0),
..Default::default()
};
assert_eq!(c.apply(5.0, 3.0), (5.0, 3.0));
}
#[test]
fn transform_y2_is_none_without_y2_axis() {
let plot = Plot::new(0);
assert!(plot.transform_y2(area()).is_none());
}
#[test]
fn limits_history_starts_empty() {
let plot = Plot::new(0);
assert_eq!(plot.limits_history_len(), 0);
}
#[test]
fn limits_history_push_then_zoom_back_restores_previous() {
let mut plot = Plot::new(0);
plot.limits = (0.0, 1.0, 0.0, 1.0);
plot.y2 = Some((0.0, 2.0));
plot.push_limits();
assert_eq!(plot.limits_history_len(), 1);
plot.limits = (0.25, 0.75, 0.25, 0.75);
plot.y2 = Some((0.5, 1.5));
assert!(plot.zoom_back());
assert_eq!(plot.limits, (0.0, 1.0, 0.0, 1.0));
assert_eq!(plot.y2, Some((0.0, 2.0)));
assert_eq!(plot.limits_history_len(), 0);
}
#[test]
fn zoom_back_on_empty_history_returns_false_and_keeps_view() {
let mut plot = Plot::new(0);
plot.limits = (1.0, 2.0, 3.0, 4.0);
assert!(!plot.zoom_back());
assert_eq!(plot.limits, (1.0, 2.0, 3.0, 4.0));
}
#[test]
fn limits_history_is_lifo_and_unbounded() {
let mut plot = Plot::new(0);
for i in 0..1000 {
plot.limits = (i as f64, i as f64 + 1.0, 0.0, 1.0);
plot.push_limits();
}
assert_eq!(plot.limits_history_len(), 1000);
assert!(plot.zoom_back());
assert_eq!(plot.limits, (999.0, 1000.0, 0.0, 1.0));
assert!(plot.zoom_back());
assert_eq!(plot.limits, (998.0, 999.0, 0.0, 1.0));
assert_eq!(plot.limits_history_len(), 998);
}
#[test]
fn clear_limits_history_empties_the_stack() {
let mut plot = Plot::new(0);
plot.push_limits();
plot.push_limits();
assert_eq!(plot.limits_history_len(), 2);
plot.clear_limits_history();
assert_eq!(plot.limits_history_len(), 0);
assert!(!plot.zoom_back());
}
#[test]
fn transform_y2_shares_left_x_and_maps_its_own_y() {
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 100.0);
plot.y2 = Some((-1.0, 1.0));
let left = plot.transform(area());
let right = plot.transform_y2(area()).expect("y2 transform");
assert_eq!(left.x, right.x);
let bottom = right.data_to_pixel(0.0, -1.0).y;
let top = right.data_to_pixel(0.0, 1.0).y;
assert!((bottom - area().bottom()).abs() <= 1e-3, "{bottom}");
assert!((top - area().top()).abs() <= 1e-3, "{top}");
}
#[test]
fn autoscale_defaults_on_for_all_axes() {
let plot = Plot::new(0);
assert!(plot.x_autoscale());
assert!(plot.y_autoscale());
assert!(plot.y2_autoscale());
}
#[test]
fn reset_zoom_refits_only_autoscale_on_axes() {
let mut plot = Plot::new(0);
plot.limits = (0.0, 1.0, 0.0, 1.0);
plot.set_x_autoscale(false);
plot.set_y_autoscale(true);
plot.reset_zoom_to_data_range(DataRange {
x: Some((10.0, 20.0)),
y: Some((-5.0, 5.0)),
y2: None,
});
assert_eq!(plot.limits, (0.0, 1.0, -5.0, 5.0));
}
#[test]
fn reset_zoom_refits_x_when_only_x_autoscale_on() {
let mut plot = Plot::new(0);
plot.limits = (0.0, 1.0, 0.0, 1.0);
plot.set_x_autoscale(true);
plot.set_y_autoscale(false);
plot.reset_zoom_to_data_range(DataRange {
x: Some((10.0, 20.0)),
y: Some((-5.0, 5.0)),
y2: None,
});
assert_eq!(plot.limits, (10.0, 20.0, 0.0, 1.0));
}
#[test]
fn reset_zoom_with_all_autoscale_off_is_noop() {
let mut plot = Plot::new(0);
plot.limits = (0.0, 1.0, 0.0, 1.0);
plot.y2 = Some((0.0, 2.0));
plot.set_x_autoscale(false);
plot.set_y_autoscale(false);
plot.set_y2_autoscale(false);
plot.reset_zoom_to_data_range(DataRange {
x: Some((10.0, 20.0)),
y: Some((-5.0, 5.0)),
y2: Some((-1.0, 1.0)),
});
assert_eq!(plot.limits, (0.0, 1.0, 0.0, 1.0));
assert_eq!(plot.y2, Some((0.0, 2.0)));
}
#[test]
fn reset_zoom_autoscale_on_axis_with_no_data_is_preserved() {
let mut plot = Plot::new(0);
plot.limits = (3.0, 7.0, 2.0, 8.0);
plot.reset_zoom_to_data_range(DataRange {
x: None,
y: Some((-1.0, 1.0)),
y2: None,
});
assert_eq!(plot.limits, (3.0, 7.0, -1.0, 1.0));
}
#[test]
fn reset_zoom_log_axis_forces_autoscale_when_lower_limit_nonpositive() {
let mut plot = Plot::new(0);
plot.x_scale = Scale::Log10;
plot.limits = (-1.0, 100.0, 0.0, 1.0);
plot.set_x_autoscale(false);
plot.set_y_autoscale(false);
plot.reset_zoom_to_data_range(DataRange {
x: Some((1.0, 1000.0)),
y: Some((-5.0, 5.0)),
y2: None,
});
assert_eq!(plot.limits.0, 1.0);
assert_eq!(plot.limits.1, 1000.0);
assert_eq!((plot.limits.2, plot.limits.3), (0.0, 1.0));
}
#[test]
fn grid_color_defaults_none_and_follows_foreground() {
let plot = Plot::new(0);
assert_eq!(plot.grid_color(), None);
let fg = Color32::from_rgb(200, 200, 200);
assert_eq!(plot.effective_grid_color(fg), fg);
}
#[test]
fn grid_color_explicit_overrides_foreground() {
let mut plot = Plot::new(0);
let grid = Color32::from_rgb(64, 64, 64);
let fg = Color32::from_rgb(200, 200, 200);
plot.set_grid_color(Some(grid));
assert_eq!(plot.grid_color(), Some(grid));
assert_eq!(plot.effective_grid_color(fg), grid);
}
#[test]
fn set_grid_color_change_marks_full_dirty() {
let mut plot = Plot::new(0);
plot.set_grid_color(None);
assert_eq!(plot.dirty(), DirtyState::Clean);
plot.set_grid_color(Some(Color32::RED));
assert_eq!(plot.dirty(), DirtyState::Full);
}
#[test]
fn axis_label_active_curve_wins_over_default() {
assert_eq!(
resolved_axis_label(Some("Energy"), Some("curve X")),
"curve X"
);
}
#[test]
fn axis_label_falls_back_to_default_when_no_active() {
assert_eq!(resolved_axis_label(Some("Energy"), None), "Energy");
assert_eq!(resolved_axis_label(None, Some("curve X")), "curve X");
}
#[test]
fn axis_label_empty_when_neither_set() {
assert_eq!(resolved_axis_label(None, None), "");
}
#[test]
fn axis_label_empty_active_falls_back_to_default() {
assert_eq!(resolved_axis_label(Some("Energy"), Some("")), "Energy");
assert_eq!(resolved_axis_label(Some("Energy"), Some("Time")), "Time");
assert_eq!(resolved_axis_label(Some(""), Some("")), "");
assert_eq!(resolved_axis_label(None, Some("")), "");
}
#[test]
fn plot_axis_label_active_overrides_default() {
let mut plot = Plot::new(0);
plot.x_label = Some("X axis".to_string());
assert_eq!(plot.x_axis_label(Some("curve")), "curve");
assert_eq!(plot.x_axis_label(None), "X axis");
assert_eq!(plot.y_axis_label(Some("intensity")), "intensity");
assert_eq!(plot.y2_axis_label(None), "");
}
#[test]
fn displayed_labels_resolve_active_override_against_default() {
let mut plot = Plot::new(0);
plot.x_label = Some("Energy".to_string());
plot.y_label = Some("Counts".to_string());
assert_eq!(plot.displayed_x_label().as_deref(), Some("Energy"));
assert_eq!(plot.displayed_y_label().as_deref(), Some("Counts"));
assert_eq!(plot.displayed_y2_label(), None);
plot.active_x_label = Some("Time".to_string());
plot.active_y_label = Some("Intensity".to_string());
assert_eq!(plot.displayed_x_label().as_deref(), Some("Time"));
assert_eq!(plot.displayed_y_label().as_deref(), Some("Intensity"));
plot.active_x_label = Some(String::new());
plot.active_y2_label = Some("Right".to_string());
assert_eq!(plot.displayed_x_label().as_deref(), Some("Energy"));
assert_eq!(plot.displayed_y2_label().as_deref(), Some("Right"));
}
#[test]
fn dirty_defaults_clean_and_autoreplot_on_and_axes_displayed() {
let plot = Plot::new(0);
assert_eq!(plot.dirty(), DirtyState::Clean);
assert!(plot.autoreplot());
assert!(plot.axes_displayed());
}
#[test]
fn dirty_clean_overlay_only_becomes_overlay() {
let mut plot = Plot::new(0);
plot.set_dirty(true);
assert_eq!(plot.dirty(), DirtyState::Overlay);
}
#[test]
fn dirty_clean_full_becomes_full() {
let mut plot = Plot::new(0);
plot.set_dirty(false);
assert_eq!(plot.dirty(), DirtyState::Full);
}
#[test]
fn dirty_overlay_then_overlay_only_escalates_to_full() {
let mut plot = Plot::new(0);
plot.set_dirty(true);
assert_eq!(plot.dirty(), DirtyState::Overlay);
plot.set_dirty(true);
assert_eq!(plot.dirty(), DirtyState::Full);
}
#[test]
fn dirty_full_then_overlay_only_stays_full() {
let mut plot = Plot::new(0);
plot.set_dirty(false);
plot.set_dirty(true);
assert_eq!(plot.dirty(), DirtyState::Full);
}
#[test]
fn replot_clears_dirty_to_clean() {
let mut plot = Plot::new(0);
plot.set_dirty(false);
assert_eq!(plot.dirty(), DirtyState::Full);
plot.replot();
assert_eq!(plot.dirty(), DirtyState::Clean);
}
#[test]
fn set_axes_displayed_change_marks_full_dirty() {
let mut plot = Plot::new(0);
plot.set_axes_displayed(true);
assert_eq!(plot.dirty(), DirtyState::Clean);
plot.set_axes_displayed(false);
assert!(!plot.axes_displayed());
assert_eq!(plot.dirty(), DirtyState::Full);
}
#[test]
fn lines_start_empty_and_append() {
let mut plot = Plot::new(0);
assert!(plot.lines().is_empty());
plot.add_line(Line::new(f64::INFINITY, 3.0));
plot.add_line(Line::new(0.0, 1.0));
assert_eq!(plot.lines().len(), 2);
plot.lines_mut()[1].intercept = 2.0;
assert_eq!(plot.lines()[1].intercept, 2.0);
assert!(!plot.lines()[0].slope.is_finite());
}
#[test]
fn tick_mode_defaults_numeric_and_sets_x_only() {
let mut plot = Plot::new(0);
assert_eq!(plot.x_tick_mode(), TickMode::Numeric);
plot.set_x_tick_mode(TickMode::TimeSeries);
assert_eq!(plot.x_tick_mode(), TickMode::TimeSeries);
plot.set_x_tick_mode(TickMode::Numeric);
assert_eq!(plot.x_tick_mode(), TickMode::Numeric);
}
#[test]
fn set_autoreplot_toggles() {
let mut plot = Plot::new(0);
plot.set_autoreplot(false);
assert!(!plot.autoreplot());
plot.set_autoreplot(true);
assert!(plot.autoreplot());
}
#[test]
fn show_colorbar_defaults_true() {
let mut plot = Plot::new(0);
assert!(plot.show_colorbar);
plot.show_colorbar = false;
assert!(!plot.show_colorbar);
}
#[test]
fn data_margins_default_zero_and_noop() {
let mut plot = Plot::new(0);
assert_eq!(plot.data_margins(), DataMargins::default());
plot.set_data_range(DataRange {
x: Some((0.0, 10.0)),
y: Some((0.0, 10.0)),
y2: None,
});
plot.reset_zoom();
assert_eq!(plot.limits, (0.0, 10.0, 0.0, 10.0));
}
#[test]
fn data_margins_linear_left_expands_xmin_by_ratio_of_range() {
let mut plot = Plot::new(0);
plot.set_data_margins(DataMargins {
x_min: 0.1,
..Default::default()
});
plot.set_data_range(DataRange {
x: Some((0.0, 10.0)),
y: Some((0.0, 10.0)),
y2: None,
});
plot.reset_zoom();
assert!(
(plot.limits.0 - (-1.0)).abs() < 1e-9,
"xmin={}",
plot.limits.0
);
assert_eq!(plot.limits.1, 10.0);
assert_eq!((plot.limits.2, plot.limits.3), (0.0, 10.0));
}
#[test]
fn data_margins_log_expands_in_log_space() {
let mut plot = Plot::new(0);
plot.x_scale = Scale::Log10;
plot.set_data_margins(DataMargins {
x_min: 0.1,
..Default::default()
});
plot.set_data_range(DataRange {
x: Some((1.0, 100.0)),
y: Some((1.0, 100.0)),
y2: None,
});
plot.reset_zoom();
let expected = 10f64.powf(-0.2);
assert!(
(plot.limits.0 - expected).abs() < 1e-9,
"xmin={} expected={expected}",
plot.limits.0
);
assert_eq!(plot.limits.1, 100.0);
}
#[test]
fn data_margins_log_skips_nonpositive_bound() {
let (lo, hi) = DataMargins::expand_axis(0.0, 100.0, 0.1, 0.1, true);
assert_eq!((lo, hi), (0.0, 100.0));
}
#[test]
fn data_margins_only_applied_to_refit_axes() {
let mut plot = Plot::new(0);
plot.limits = (5.0, 6.0, 0.0, 0.0);
plot.set_x_autoscale(false);
plot.set_data_margins(DataMargins {
x_min: 0.5,
y_min: 0.1,
..Default::default()
});
plot.set_data_range(DataRange {
x: Some((0.0, 10.0)),
y: Some((0.0, 10.0)),
y2: None,
});
plot.reset_zoom();
assert_eq!((plot.limits.0, plot.limits.1), (5.0, 6.0));
assert!(
(plot.limits.2 - (-1.0)).abs() < 1e-9,
"ymin={}",
plot.limits.2
);
}
#[test]
fn data_range_is_empty_until_set() {
let plot = Plot::new(0);
let r = plot.data_range();
assert_eq!(r, DataRange::default());
assert!(r.x.is_none() && r.y.is_none() && r.y2.is_none());
}
#[test]
fn reset_zoom_uses_cached_data_range() {
let mut plot = Plot::new(0);
plot.limits = (0.0, 1.0, 0.0, 1.0);
plot.set_data_range(DataRange {
x: Some((2.0, 4.0)),
y: Some((6.0, 8.0)),
y2: None,
});
plot.reset_zoom();
assert_eq!(plot.limits, (2.0, 4.0, 6.0, 8.0));
}
#[test]
fn reset_zoom_refits_y2_independently() {
let mut plot = Plot::new(0);
plot.limits = (0.0, 1.0, 0.0, 1.0);
plot.y2 = Some((0.0, 1.0));
plot.set_x_autoscale(false);
plot.set_y_autoscale(false);
plot.set_y2_autoscale(true);
plot.reset_zoom_to_data_range(DataRange {
x: Some((10.0, 20.0)),
y: Some((-5.0, 5.0)),
y2: Some((100.0, 200.0)),
});
assert_eq!(plot.limits, (0.0, 1.0, 0.0, 1.0));
assert_eq!(plot.y2, Some((100.0, 200.0)));
}
#[test]
fn transform_y2_shares_aspect_expanded_x() {
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
plot.keep_aspect = true;
plot.y2 = Some((0.0, 5.0));
let left = plot.transform(area());
let right = plot.transform_y2(area()).expect("y2 transform");
assert_eq!(left.x, right.x);
assert!(left.x.min < 0.0 && left.x.max > 10.0, "{:?}", left.x);
}
fn point_roi(i: usize) -> ManagedRoi {
ManagedRoi::new(crate::core::roi::Roi::Point {
x: i as f64,
y: 0.0,
})
}
#[test]
fn roi_color_defaults_to_silx_red() {
assert_eq!(Plot::new(0).roi_color, Color32::RED);
}
#[test]
fn set_current_roi_highlights_exactly_one() {
let mut plot = Plot::new(0);
plot.rois = (0..3).map(point_roi).collect();
plot.set_current_roi(Some(1));
assert_eq!(plot.current_roi(), Some(1));
assert!(!plot.rois[0].selected);
assert!(plot.rois[1].selected);
assert!(!plot.rois[2].selected);
plot.set_current_roi(Some(2));
assert!(!plot.rois[1].selected);
assert!(plot.rois[2].selected);
plot.set_current_roi(None);
assert_eq!(plot.current_roi(), None);
assert!(plot.rois.iter().all(|r| !r.selected));
}
#[test]
fn set_current_roi_out_of_range_clears_selection() {
let mut plot = Plot::new(0);
plot.rois = vec![point_roi(0)];
plot.set_current_roi(Some(1));
assert_eq!(plot.current_roi(), None);
assert!(!plot.rois[0].selected);
}
#[test]
fn remove_roi_adjusts_current_index() {
let mut plot = Plot::new(0);
plot.rois = (0..3).map(point_roi).collect();
plot.set_current_roi(Some(2));
plot.remove_roi(0);
assert_eq!(plot.current_roi(), Some(1));
assert!(plot.rois[1].selected);
plot.set_current_roi(Some(1));
plot.remove_roi(1);
assert_eq!(plot.current_roi(), None);
assert!(plot.rois.iter().all(|r| !r.selected));
plot.rois = (0..3).map(point_roi).collect();
plot.set_current_roi(Some(0));
plot.remove_roi(2);
assert_eq!(plot.current_roi(), Some(0));
assert!(plot.rois[0].selected);
}
#[test]
fn clear_rois_resets_current() {
let mut plot = Plot::new(0);
plot.rois = (0..3).map(point_roi).collect();
plot.set_current_roi(Some(1));
plot.clear_rois();
assert_eq!(plot.current_roi(), None);
assert!(plot.rois.is_empty());
}
}