siplot/core/plot.rs
1//! The plot model.
2//!
3//! Holds the identifier, data-area background, data limits, margins, and the
4//! optional colormap used to draw the colorbar. The item list, log/inverted
5//! axis flags, and dirty tracking are added in later steps
6//! (`doc/design.md` §1·§4·§11).
7
8use egui::{Color32, Rect};
9
10use crate::core::backend::ItemHandle;
11use crate::core::colormap::Colormap;
12use crate::core::dtime_ticks::TimeZone;
13use crate::core::marker::Marker;
14use crate::core::roi::{DEFAULT_ROI_COLOR, ManagedRoi};
15use crate::core::shape::{Line, Shape};
16use crate::core::transform::{Axis, AxisSide, Margins, Scale, Transform, keep_aspect_limits};
17use crate::core::triangles::Triangles;
18
19/// Per-axis pan/zoom range constraints mirroring silx
20/// `Axis.setRangeConstraints` / `Axis.setLimitsConstraints`.
21///
22/// All fields are optional; `None` means unconstrained. Applied by the
23/// interaction helpers after every pan/zoom so the display range always
24/// satisfies all set constraints.
25#[derive(Clone, Copy, Debug, Default, PartialEq)]
26pub struct AxisConstraints {
27 /// Minimum allowed span (display range). Prevents zooming in past this.
28 pub min_range: Option<f64>,
29 /// Maximum allowed span (display range). Prevents zooming out past this.
30 pub max_range: Option<f64>,
31 /// Minimum allowed lower bound. Prevents panning the view below this value.
32 pub min_pos: Option<f64>,
33 /// Maximum allowed upper bound. Prevents panning the view above this value.
34 pub max_pos: Option<f64>,
35}
36
37impl AxisConstraints {
38 /// Return `(lo, hi)` clamped so all set constraints are satisfied. The
39 /// span is corrected first (centered on the current midpoint), then the
40 /// position window is clamped.
41 ///
42 /// Mirrors silx `ViewConstraints` (`_utils/panzoom.py`) with
43 /// `allow_scaling=True` — the only mode siplot uses, since [`apply`]
44 /// runs after every pan/zoom. Two silx mechanisms are reproduced:
45 ///
46 /// - The `update` sanity check (panzoom.py:297-305): when `max_range`,
47 /// `min_pos` and `max_pos` are all set, the maximum span can never
48 /// exceed the `[min_pos, max_pos]` window width, so `max_range` is
49 /// capped to it.
50 /// - The `normalize` position clamp (panzoom.py:337-363): when **both**
51 /// ends fall outside `[min_pos, max_pos]` the view is wider than the
52 /// window, so it snaps to exactly `[min_pos, max_pos]` (the span adapts
53 /// to fit — silx's "adaptive expansion"). When only one end is out, the
54 /// whole window shifts to pull that end back in bounds.
55 ///
56 /// [`apply`]: AxisConstraints::apply
57 pub fn apply(self, lo: f64, hi: f64) -> (f64, f64) {
58 let mut span = hi - lo;
59 if span <= 0.0 {
60 return (lo, hi);
61 }
62
63 // 1. Cap the max span to the position window when both bounds and a
64 // max range are set (silx ViewConstraints.update, panzoom.py:297-305).
65 let mut effective_max_range = self.max_range;
66 if let (Some(max_range), Some(min_pos), Some(max_pos)) =
67 (self.max_range, self.min_pos, self.max_pos)
68 {
69 effective_max_range = Some(max_range.min(max_pos - min_pos));
70 }
71
72 // 2. Clamp the span.
73 if let Some(min) = self.min_range
74 && span < min
75 {
76 span = min;
77 }
78 if let Some(max) = effective_max_range
79 && span > max
80 {
81 span = max;
82 }
83
84 // 3. Re-center the clamped span on the original midpoint.
85 let mid = (lo + hi) * 0.5;
86 let mut new_lo = mid - span * 0.5;
87 let mut new_hi = mid + span * 0.5;
88
89 // 4. Clamp the position window (silx ViewConstraints.normalize,
90 // panzoom.py:337-363). Both ends out -> snap to the window so the
91 // span shrinks to fit; one end out -> shift the window to pull it in.
92 let below = self.min_pos.filter(|&min_pos| new_lo < min_pos);
93 let above = self.max_pos.filter(|&max_pos| new_hi > max_pos);
94 match (below, above) {
95 (Some(min_pos), Some(max_pos)) => {
96 new_lo = min_pos;
97 new_hi = max_pos;
98 }
99 (Some(min_pos), None) => {
100 let shift = min_pos - new_lo;
101 new_lo += shift;
102 new_hi += shift;
103 }
104 (None, Some(max_pos)) => {
105 let shift = max_pos - new_hi;
106 new_lo += shift;
107 new_hi += shift;
108 }
109 (None, None) => {}
110 }
111
112 // 5. Final sanity — keep lo < hi even if constraints are contradictory.
113 if new_lo >= new_hi {
114 return (lo, hi);
115 }
116
117 (new_lo, new_hi)
118 }
119
120 /// `true` when all fields are `None` (no constraints set).
121 pub fn is_unconstrained(self) -> bool {
122 self.min_range.is_none()
123 && self.max_range.is_none()
124 && self.min_pos.is_none()
125 && self.max_pos.is_none()
126 }
127}
128
129/// Identifier for a single `Plot` instance.
130///
131/// `egui_wgpu`'s `callback_resources` is a global type map, so multi-plot keeps
132/// per-plot GPU state separated by `PlotId` (`doc/design.md` §3.1·§12). The
133/// current steps handle a single plot, so no separation map exists yet.
134pub type PlotId = u64;
135
136/// Whether the X axis lays out regular numeric ticks or date-time ticks,
137/// mirroring silx `items.axis.TickMode` (`items/axis.py:43-47`). In silx only
138/// `XAxis` overrides `getTickMode` / `setTickMode` (`items/axis.py:391-403`),
139/// backed by `setXAxisTimeSeries`; `YAxis` inherits the base
140/// `Axis.setTickMode`, which raises `NotImplementedError` — there is no
141/// `setYAxisTimeSeries`. So the time-series tick mode is an X-axis-only concept.
142#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
143pub enum TickMode {
144 /// Ticks are regular numbers (silx `TickMode.DEFAULT = 0`). Zero behavior
145 /// change from the pre-existing numeric tick layout.
146 #[default]
147 Numeric,
148 /// Ticks are date-times: the axis data values are epoch seconds (UTC) and
149 /// labels are formatted via [`crate::core::dtime_ticks`] (silx
150 /// `TickMode.TIME_SERIES = 1`).
151 TimeSeries,
152}
153
154/// Grid lines drawn in the plot data area.
155#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
156pub enum GraphGrid {
157 /// No grid lines.
158 None,
159 /// Major tick grid lines only.
160 #[default]
161 Major,
162 /// Major and minor tick grid lines.
163 MajorAndMinor,
164}
165
166impl GraphGrid {
167 /// Whether major grid lines are drawn.
168 pub fn major(self) -> bool {
169 matches!(self, Self::Major | Self::MajorAndMinor)
170 }
171
172 /// Whether minor grid lines are drawn.
173 pub fn minor(self) -> bool {
174 matches!(self, Self::MajorAndMinor)
175 }
176}
177
178/// Resolve the label to display on an axis, mirroring silx
179/// `items/axis.py:187-218` (`Axis.getLabel` / `_setCurrentLabel`): the active
180/// item's per-axis label is shown when one is set, otherwise it falls back to
181/// the axis' own default label, otherwise an empty string.
182///
183/// `default_label` is the axis' own label (silx `_defaultLabel`, set via
184/// `setGraphXLabel`); `active_label` is the active curve/image's label for this
185/// axis (silx `getXLabel`/`getYLabel`). silx `_setActiveItem` calls
186/// `_setCurrentLabel(activeLabel)`, which displays `activeLabel` when non-empty
187/// and otherwise falls back to `_defaultLabel` — so the active curve's label
188/// *overrides* the graph default when present. A `Some("")` is treated the same
189/// as `None` (silx `_setCurrentLabel` treats an empty string as "no label").
190pub fn resolved_axis_label(default_label: Option<&str>, active_label: Option<&str>) -> String {
191 fn non_empty(s: Option<&str>) -> Option<&str> {
192 s.filter(|l| !l.is_empty())
193 }
194 non_empty(active_label)
195 .or(non_empty(default_label))
196 .unwrap_or("")
197 .to_string()
198}
199
200/// The plot's redraw-dirty state, mirroring silx `PlotWidget._dirty`
201/// (`_getDirtyPlot` returns `False | "overlay" | True`).
202#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
203pub enum DirtyState {
204 /// Nothing changed since the last replot (silx `False`).
205 #[default]
206 Clean,
207 /// Only the overlay changed; just the overlay needs redrawing
208 /// (silx `"overlay"`).
209 Overlay,
210 /// The full plot needs redrawing (silx `True`).
211 Full,
212}
213
214/// The full data range of a plot, mirroring silx `_PlotDataRange`
215/// (`PlotWidget.getDataRange`). Each member is the `(min, max)` data bounds for
216/// that axis, or `None` when no data is associated with the axis.
217#[derive(Clone, Copy, Debug, Default, PartialEq)]
218pub struct DataRange {
219 /// X-axis data bounds, or `None` when no item drives the X axis.
220 pub x: Option<(f64, f64)>,
221 /// Left Y-axis data bounds.
222 pub y: Option<(f64, f64)>,
223 /// Right (y2) Y-axis data bounds.
224 pub y2: Option<(f64, f64)>,
225}
226
227/// One of the additional stacked Y axes beyond the built-in left axis
228/// (`Plot::limits`) and right axis (`Plot::y2`), backing [`crate::YAxis::Extra`]
229/// (`Plot::extra`). Each carries its own data range, scale, on-screen side,
230/// autoscale flag, and label — the per-axis state the left/right axes spread
231/// across dedicated `Plot` fields, generalized to N axes for silx-style
232/// multi-axis plots. Curves bound to an axis with no `range` fall back to the
233/// left transform (so they still draw), mirroring the right axis' `y2 == None`
234/// fallback.
235#[derive(Clone, Debug, PartialEq)]
236pub struct ExtraAxis {
237 /// Data range `(min, max)`, or `None` until set explicitly or autoscaled.
238 pub range: Option<(f64, f64)>,
239 /// Linear or log10 scale, independent of the left/right axes.
240 pub scale: Scale,
241 /// Which gutter the ticks and label are drawn in; same-side extra axes stack
242 /// outward in creation order.
243 pub side: AxisSide,
244 /// Whether this axis refits to its curves' data on reset-zoom (silx
245 /// per-axis `setAutoScale`). Defaults to `true`.
246 pub autoscale: bool,
247 /// Axis label drawn rotated in the gutter outside the ticks, or `None`.
248 pub label: Option<String>,
249}
250
251impl ExtraAxis {
252 /// A linear, autoscaling extra axis on `side` with no range or label yet.
253 pub fn new(side: AxisSide) -> Self {
254 Self {
255 range: None,
256 scale: Scale::Linear,
257 side,
258 autoscale: true,
259 label: None,
260 }
261 }
262}
263
264/// Per-side data-margin ratios added around the visible data on reset-zoom
265/// (silx `PlotWidget.setDataMargins` / `_utils.addMarginsToLimits`).
266///
267/// Each field is a ratio of the data range applied to one limit. silx names
268/// these `(xMinMargin, xMaxMargin, yMinMargin, yMaxMargin)`; the field names
269/// here keep that axis/side mapping explicit:
270///
271/// - `x_min` — the X lower (left) side,
272/// - `x_max` — the X upper (right) side,
273/// - `y_min` — the Y lower (bottom) side,
274/// - `y_max` — the Y upper (top) side.
275///
276/// The Y margins also apply to the y2 axis, matching silx (the y2 branch in
277/// `addMarginsToLimits` reuses `yMinMargin`/`yMaxMargin`). Zero by default.
278#[derive(Clone, Copy, Debug, Default, PartialEq)]
279pub struct DataMargins {
280 /// X lower (left) margin ratio.
281 pub x_min: f64,
282 /// X upper (right) margin ratio.
283 pub x_max: f64,
284 /// Y lower (bottom) margin ratio.
285 pub y_min: f64,
286 /// Y upper (top) margin ratio.
287 pub y_max: f64,
288}
289
290impl DataMargins {
291 /// Expand `(lo, hi)` on a single axis by the low/high ratios, in log space
292 /// when `is_log` (silx `addMarginsToLimits`). For a log axis with a
293 /// non-positive bound the margin is skipped (silx "Do not apply margins if
294 /// limits < 0").
295 fn expand_axis(lo: f64, hi: f64, low_ratio: f64, high_ratio: f64, is_log: bool) -> (f64, f64) {
296 if !is_log {
297 let range = hi - lo;
298 (lo - low_ratio * range, hi + high_ratio * range)
299 } else if lo > 0.0 && hi > 0.0 {
300 let lo_log = lo.log10();
301 let hi_log = hi.log10();
302 let range_log = hi_log - lo_log;
303 (
304 10f64.powf(lo_log - low_ratio * range_log),
305 10f64.powf(hi_log + high_ratio * range_log),
306 )
307 } else {
308 (lo, hi)
309 }
310 }
311}
312
313/// One plot.
314pub struct Plot {
315 /// Instance identifier.
316 pub id: PlotId,
317 /// Data-area background color (maps to silx `setBackgroundColors`' data background).
318 pub data_background: Color32,
319 /// Data-space limits `(x_min, x_max, y_min, y_max)`.
320 pub limits: (f64, f64, f64, f64),
321 /// Margins reserving extra space inside the chrome gutters. Zero by default.
322 pub margins: Margins,
323 /// Colormap drawn as the colorbar (mirrors the displayed image's colormap).
324 /// `None` hides the colorbar (`doc/design.md` §5·§8).
325 pub colormap: Option<Colormap>,
326 /// Whether the built-in colorbar is drawn when a [`colormap`](Self::colormap)
327 /// is present. Defaults to `true`. Composite views that render their own
328 /// dedicated colorbar (e.g. `ImageView`, whose internal image plot still
329 /// carries the active image's colormap) set this `false` so the colorbar is
330 /// not drawn twice.
331 pub show_colorbar: bool,
332 /// When `true` (and a [`colormap`](Self::colormap) is shown), the colorbar is
333 /// the interactive pyqtgraph-style histogram colorbar (drag the handles to set
334 /// the colormap's `vmin`/`vmax`) instead of a static strip. Defaults to
335 /// `false`. The drag is surfaced via `PlotResponse::colorbar_dragged_levels`
336 /// for the caller to apply (the colormap and any GPU image re-upload are the
337 /// caller's, mirroring the marker/ROI single-owner pattern).
338 pub colorbar_interactive: bool,
339 /// `(counts, edges)` value-distribution histogram drawn beside the gradient
340 /// when the colorbar is interactive (see
341 /// [`crate::core::histogram::compute_histogram`]); `None` draws just the
342 /// gradient + handles.
343 pub colorbar_histogram: Option<(Vec<u64>, Vec<f64>)>,
344 /// The image value range `(min, max)` the interactive colorbar's axis spans
345 /// (handles move within it). `None` falls back to the colormap's
346 /// `vmin`/`vmax`.
347 pub colorbar_value_range: Option<(f64, f64)>,
348 /// Reserve the title gutter even when [`title`](Self::title) is `None`. Lets a
349 /// side panel (e.g. an `ImageView` profile) keep its data area aligned with a
350 /// reference plot that *does* carry a title, so the two data areas coincide
351 /// by construction. Defaults to `false`.
352 pub reserve_title_gutter: bool,
353 /// Reserve the X-axis-label gutter even when [`x_label`](Self::x_label) is
354 /// `None` (see [`reserve_title_gutter`](Self::reserve_title_gutter)).
355 pub reserve_x_label_gutter: bool,
356 /// Reserve the (left) Y-axis-label gutter even when [`y_label`](Self::y_label)
357 /// is `None` (see [`reserve_title_gutter`](Self::reserve_title_gutter)).
358 pub reserve_y_label_gutter: bool,
359 /// Limits to restore via the Reset Zoom context-menu item. The widget
360 /// captures the first observed `limits` here so the home view survives
361 /// pan/zoom (`doc/design.md` §8·§11.6). `None` until the first frame.
362 pub home_limits: Option<(f64, f64, f64, f64)>,
363 /// X-axis scale (linear or log10) (`doc/design.md` §13 A3).
364 pub x_scale: Scale,
365 /// Y-axis scale (linear or log10).
366 pub y_scale: Scale,
367 /// Reverse the X-axis on-screen direction (`doc/design.md` §13 A2).
368 pub x_inverted: bool,
369 /// Reverse the Y-axis on-screen direction.
370 pub y_inverted: bool,
371 /// Keep data square on screen by expanding the tighter axis' display range
372 /// (silx `setKeepDataAspectRatio`). Only honored when both axes are linear
373 /// (`doc/design.md` §13 A4).
374 pub keep_aspect: bool,
375 /// Secondary right Y axis limits `(y2_min, y2_max)`, or `None` for no y2
376 /// axis. Curves bound to [`crate::YAxis::Right`] are plotted against it and
377 /// its ticks are drawn in the right gutter (linear, `doc/design.md` §13 A5).
378 pub y2: Option<(f64, f64)>,
379 /// Additional stacked Y axes beyond the built-in left/right pair (silx-style
380 /// multi-axis). `extra[i]` backs [`crate::YAxis::Extra(i)`](crate::YAxis::Extra);
381 /// curves bound there plot against its range/scale and its ticks/label are
382 /// drawn stacked outward in its [`AxisSide`] gutter. Empty by default.
383 pub extra: Vec<ExtraAxis>,
384 /// Draw a crosshair + coordinate readout following the pointer when it is
385 /// over the data area (silx `setGraphCursor`, `doc/design.md` §13 C1).
386 pub crosshair: bool,
387 /// Regions of interest drawn over the data area with draggable edge
388 /// handles, each carrying its own appearance (color, name, selection,
389 /// line width/style, fill — silx `RegionOfInterest`). Dragging an edge
390 /// updates that ROI's geometry in place and the widget reports the changed
391 /// index (`doc/design.md` §13 C3).
392 pub rois: Vec<ManagedRoi>,
393 /// Default ROI outline color applied to ROIs without an explicit color
394 /// override (silx `RegionOfInterestManager.getColor`/`setColor`, default
395 /// red). The render resolves each ROI's color as
396 /// `managed.color.unwrap_or(roi_color)`.
397 pub roi_color: Color32,
398 /// Index of the current/highlighted ROI, or `None` (silx
399 /// `RegionOfInterestManager.getCurrentRoi`). Private so
400 /// [`Self::set_current_roi`] is the sole writer of each ROI's `selected`
401 /// flag, keeping "exactly the current ROI is highlighted" true by
402 /// construction.
403 current_roi: Option<usize>,
404 /// Point / line markers drawn over the data area (silx `addMarker`). Each is
405 /// a static overlay; the widget draws the list every frame.
406 pub markers: Vec<Marker>,
407 /// Backend item handles parallel to [`Self::markers`]: `marker_handles[i]` is
408 /// the [`ItemHandle`] of `markers[i]`. Both vectors are rebuilt together by
409 /// the backend's `sync_plot_items` (same length and order), so a marker drag
410 /// can map the dragged mirror index back to the owning backend item for
411 /// persistence. Empty until the first sync.
412 ///
413 /// INVARIANT: `marker_handles.len() == markers.len()` and
414 /// `marker_handles[i]` identifies `markers[i]`.
415 pub marker_handles: Vec<ItemHandle>,
416 /// Polygon / rectangle / polyline / line shapes drawn over the data area
417 /// (silx `addShape`). Static overlays drawn every frame.
418 pub shapes: Vec<Shape>,
419 /// Per-vertex-colored filled triangle meshes drawn in the data layer (silx
420 /// `addTriangles`). Drawn every frame under the chrome.
421 pub triangles: Vec<Triangles>,
422 /// Graph title, drawn centered above the data area (silx `setGraphTitle`,
423 /// `BackendBase.setGraphTitle`). `None` reserves no top space for it.
424 pub title: Option<String>,
425 /// X-axis label, drawn centered below the X tick labels (silx
426 /// `setGraphXLabel`). `None` reserves no extra bottom space.
427 pub x_label: Option<String>,
428 /// Left Y-axis label, drawn rotated at the far left (silx `setGraphYLabel`).
429 /// `None` reserves no extra left space.
430 pub y_label: Option<String>,
431 /// Right (y2) Y-axis label, drawn rotated at the far right; only shown when
432 /// a [`Self::y2`] axis exists. `None` reserves no extra right space.
433 pub y2_label: Option<String>,
434 /// Active curve's X label, overriding [`Self::x_label`] while that curve is
435 /// active (silx `Axis._currentLabel`, set by `_setActiveItem` from the active
436 /// curve's `getXLabel`). The high-level widget repopulates this each frame
437 /// from the active curve; `None` falls back to the default. See
438 /// [`Self::displayed_x_label`].
439 pub active_x_label: Option<String>,
440 /// Active curve's left-Y label, overriding [`Self::y_label`] (silx
441 /// `Axis._currentLabel`). Set only when the active curve is bound to the left
442 /// Y axis. See [`Self::active_x_label`].
443 pub active_y_label: Option<String>,
444 /// Active curve's right (y2) label, overriding [`Self::y2_label`] (silx
445 /// `Axis._currentLabel`). Set only when the active curve is bound to the right
446 /// Y axis. See [`Self::active_x_label`].
447 pub active_y2_label: Option<String>,
448 /// Foreground color override for axes/frame/ticks/labels (silx
449 /// `setForegroundColor`). `None` follows the egui theme's text color.
450 pub foreground: Option<Color32>,
451 /// Grid-line color override (silx `setGridColor`). `None` uses a faint tint
452 /// of the foreground color.
453 pub grid_color: Option<Color32>,
454 /// Grid lines drawn in the data area (`setGraphGrid`).
455 pub grid: GraphGrid,
456 /// Pan/zoom constraints for the X axis (silx `getXAxis().setRangeConstraints`).
457 pub x_constraints: AxisConstraints,
458 /// Pan/zoom constraints for the left Y axis (silx `getYAxis().setRangeConstraints`).
459 pub y_constraints: AxisConstraints,
460 /// Maximum number of major ticks on the X axis. `None` uses the default
461 /// (8). The chrome calls `nice_ticks` with this value, so the actual count
462 /// may be slightly lower to keep round step sizes.
463 pub x_max_ticks: Option<usize>,
464 /// Maximum number of major ticks on the left Y axis. `None` uses the
465 /// default (6).
466 pub y_max_ticks: Option<usize>,
467 /// Limits-history stack mirroring silx `LimitsHistory`. Each entry is a
468 /// full view snapshot `(x_min, x_max, y_min, y_max, y2)`. The widget pushes
469 /// before a zoom/box-zoom/pan; [`Self::zoom_back`] restores the most recent
470 /// entry. Like silx, the stack is unbounded (silx `LimitsHistory` is a plain
471 /// list with no depth cap).
472 limits_history: Vec<LimitsHistoryEntry>,
473 /// Whether the X axis refits to data on reset-zoom (silx
474 /// `Axis.setAutoScale` / `PlotWidget.setXAxisAutoScale`). Defaults to `true`.
475 x_autoscale: bool,
476 /// Whether the left Y axis refits to data on reset-zoom
477 /// (`setYAxisAutoScale`). Defaults to `true`.
478 y_autoscale: bool,
479 /// Whether the right (y2) Y axis refits to data on reset-zoom. Defaults to
480 /// `true`. silx ties y2 autoscale to the left Y axis flag; here it is
481 /// tracked separately so a caller can pin only the y2 range.
482 y2_autoscale: bool,
483 /// Cached per-axis data bounds, mirroring silx `PlotWidget._dataRange`
484 /// (returned by `getDataRange`). The high-level widget owns item data and
485 /// pushes the accumulated bounds here via [`Self::set_data_range`]; the model
486 /// layer holds no items, so this is `None` until populated.
487 data_range: Option<DataRange>,
488 /// Per-side data margins applied around the visible data on reset-zoom
489 /// (silx `setDataMargins`). Zero by default.
490 data_margins: DataMargins,
491 /// Whether the axes (frame, ticks, labels) are displayed (silx
492 /// `setAxesDisplayed` / `isAxesDisplayed`). Defaults to `true`. Wired: when
493 /// `false` the widget passes `axes_hidden` to `chrome::layout`, which
494 /// collapses the axis gutters to zero so the data area fills the rect
495 /// (silx `setAxesDisplayed(False)` -> `setAxesMargins(0,0,0,0)`).
496 axes_displayed: bool,
497 /// Redraw-dirty state (silx `_dirty`). Defaults to [`DirtyState::Clean`].
498 dirty: DirtyState,
499 /// Whether the plot is redrawn automatically on change (silx `_autoreplot`).
500 /// Defaults to `true`, matching silx after `_init`.
501 autoreplot: bool,
502 /// X-axis tick mode (silx `getXAxis().getTickMode`). Defaults to
503 /// [`TickMode::Numeric`] (zero behavior change). When [`TickMode::TimeSeries`]
504 /// the chrome formats the X tick labels as date-times. silx supports the
505 /// time-series mode on the X axis only (see [`TickMode`]), so there is no
506 /// Y-axis counterpart.
507 x_tick_mode: TickMode,
508 /// X-axis time zone used to lay out date-time ticks when
509 /// [`TickMode::TimeSeries`] is active (silx `getXAxis().setTimeZone`).
510 /// Defaults to [`TimeZone::Utc`], matching the previous UTC-only behavior.
511 x_time_zone: TimeZone,
512 /// Epoch offset added to X tick *values* before they are formatted as
513 /// date-times under [`TickMode::TimeSeries`] (siplot extension, no silx
514 /// counterpart). The stored X data values are interpreted as
515 /// `epoch - x_time_offset`, so a caller can upload small relative
516 /// coordinates — an `f32`-safe GPU vertex range — while the time ticks still
517 /// read absolute wall-clock. The curve vertex path packs positions as `f32`
518 /// (`render/gpu_curve.rs::pack`), so an absolute epoch (~1.7e9) would lose
519 /// ~128 s of precision and a few-second strip-chart window could not render.
520 /// Defaults to `0.0` (the X values *are* epoch seconds, so TimeSeries labels
521 /// are unchanged from the offset-free behavior).
522 x_time_offset: f64,
523 /// Infinite line items drawn over the data area (silx `Line`,
524 /// `items/shape.py:289`). Each is clipped to the current viewport and drawn
525 /// every frame.
526 lines: Vec<Line>,
527 /// Whether the arrow keys pan the data area when the plot is focused (silx
528 /// `setPanWithArrowKeys` / `isPanWithArrowKeys`). Defaults to `true`,
529 /// matching silx `PlotWidget._panWithArrowKeys = True`. When `false` the
530 /// widget ignores arrow-key presses (silx gates the same handler on
531 /// `if self._panWithArrowKeys` in `PlotWidget._handleArrowKey`).
532 pan_with_arrow_keys: bool,
533 /// Whether a box zoom changes the X axis (silx `ZoomEnabledAxesMenu` /
534 /// `Zoom.enabledAxes.xaxis`). Defaults to `true`. When `false` a box zoom
535 /// keeps the current X range, matching silx `Zoom._getAxesExtent`, which
536 /// replaces a disabled axis's selection extent with the full plot bounds so
537 /// that axis is left unchanged.
538 zoom_x_enabled: bool,
539 /// Whether a box zoom changes the (left) Y axis (silx
540 /// `Zoom.enabledAxes.yaxis`). Defaults to `true`. silx also tracks the right
541 /// (y2) axis here, but siplot's box zoom operates on the left axes only, so
542 /// there is no y2 counterpart.
543 zoom_y_enabled: bool,
544}
545
546/// One snapshot in [`Plot::limits_history`]: the left-axes limits plus the
547/// optional right (y2) axis range, mirroring silx `LimitsHistory`'s
548/// `(xmin, xmax, ymin, ymax, y2min, y2max)` tuple.
549type LimitsHistoryEntry = ((f64, f64, f64, f64), Option<(f64, f64)>);
550
551impl Plot {
552 /// Create a plot with the given id, a default dark background, unit limits,
553 /// no margins, and no colorbar.
554 pub fn new(id: PlotId) -> Self {
555 Self {
556 id,
557 data_background: Color32::from_rgb(16, 16, 24),
558 limits: (0.0, 1.0, 0.0, 1.0),
559 margins: Margins::ZERO,
560 colormap: None,
561 show_colorbar: true,
562 colorbar_interactive: false,
563 colorbar_histogram: None,
564 colorbar_value_range: None,
565 reserve_title_gutter: false,
566 reserve_x_label_gutter: false,
567 reserve_y_label_gutter: false,
568 home_limits: None,
569 x_scale: Scale::Linear,
570 y_scale: Scale::Linear,
571 x_inverted: false,
572 y_inverted: false,
573 keep_aspect: false,
574 y2: None,
575 extra: Vec::new(),
576 crosshair: false,
577 rois: Vec::new(),
578 roi_color: DEFAULT_ROI_COLOR,
579 current_roi: None,
580 markers: Vec::new(),
581 marker_handles: Vec::new(),
582 shapes: Vec::new(),
583 triangles: Vec::new(),
584 title: None,
585 x_label: None,
586 y_label: None,
587 y2_label: None,
588 active_x_label: None,
589 active_y_label: None,
590 active_y2_label: None,
591 foreground: None,
592 grid_color: None,
593 grid: GraphGrid::Major,
594 x_constraints: AxisConstraints::default(),
595 y_constraints: AxisConstraints::default(),
596 x_max_ticks: None,
597 y_max_ticks: None,
598 limits_history: Vec::new(),
599 x_autoscale: true,
600 y_autoscale: true,
601 y2_autoscale: true,
602 data_range: None,
603 data_margins: DataMargins::default(),
604 axes_displayed: true,
605 dirty: DirtyState::Clean,
606 autoreplot: true,
607 x_tick_mode: TickMode::Numeric,
608 x_time_zone: TimeZone::Utc,
609 x_time_offset: 0.0,
610 lines: Vec::new(),
611 pan_with_arrow_keys: true,
612 zoom_x_enabled: true,
613 zoom_y_enabled: true,
614 }
615 }
616
617 /// The index of the current/highlighted ROI, or `None` (silx
618 /// `RegionOfInterestManager.getCurrentRoi`).
619 pub fn current_roi(&self) -> Option<usize> {
620 self.current_roi
621 }
622
623 /// Set the current ROI by index, or `None` to clear it (silx
624 /// `RegionOfInterestManager.setCurrentRoi`): the previous current ROI loses
625 /// its highlight and the new one gains it. An out-of-range index clears the
626 /// selection. This is the sole writer of every ROI's `selected` flag, so the
627 /// invariant "exactly the current ROI is highlighted" holds by construction.
628 pub fn set_current_roi(&mut self, index: Option<usize>) {
629 self.current_roi = match index {
630 Some(i) if i < self.rois.len() => Some(i),
631 _ => None,
632 };
633 self.sync_roi_selection();
634 }
635
636 /// Mirror [`Self::current_roi`] onto each ROI's `selected` flag so exactly
637 /// the current ROI is highlighted (silx highlights only the current ROI).
638 fn sync_roi_selection(&mut self) {
639 let current = self.current_roi;
640 for (i, r) in self.rois.iter_mut().enumerate() {
641 r.selected = Some(i) == current;
642 }
643 }
644
645 /// Remove the ROI at `index`, adjusting [`Self::current_roi`] so it keeps
646 /// pointing at the same ROI (or clears when the current one is removed),
647 /// then re-syncing the `selected` flags (silx
648 /// `RegionOfInterestManager.removeRoi`). An out-of-range index is ignored.
649 /// This is the sole removal path, so the current-ROI invariant holds across
650 /// every removal (no caller pokes `rois`/`current_roi` directly).
651 pub fn remove_roi(&mut self, index: usize) {
652 if index >= self.rois.len() {
653 return;
654 }
655 self.rois.remove(index);
656 self.current_roi = match self.current_roi {
657 Some(c) if c == index => None,
658 Some(c) if c > index => Some(c - 1),
659 other => other,
660 };
661 self.sync_roi_selection();
662 }
663
664 /// Remove every ROI and clear the current selection (silx
665 /// `RegionOfInterestManager.clear`). Resetting `current_roi` to `None` keeps
666 /// the invariant: no current index dangles past the emptied collection.
667 pub fn clear_rois(&mut self) {
668 self.rois.clear();
669 self.current_roi = None;
670 }
671
672 /// Append the current view (left limits plus the y2 range) to the limits
673 /// history, mirroring silx `LimitsHistory.push`. The widget calls this
674 /// before applying a zoom/box-zoom/pan so [`Self::zoom_back`] can restore it.
675 pub fn push_limits(&mut self) {
676 self.limits_history.push((self.limits, self.y2));
677 }
678
679 /// Restore the most recently pushed view, mirroring silx
680 /// `LimitsHistory.pop`. Returns `true` if a stored view was restored, or
681 /// `false` if the history was empty (silx falls back to `resetZoom`; here
682 /// the caller decides, and `false` signals that nothing was restored).
683 pub fn zoom_back(&mut self) -> bool {
684 if let Some((limits, y2)) = self.limits_history.pop() {
685 self.limits = limits;
686 self.y2 = y2;
687 true
688 } else {
689 false
690 }
691 }
692
693 /// Clear the stored limits history, mirroring silx `LimitsHistory.clear`
694 /// (called on reset / zoom-mode change).
695 pub fn clear_limits_history(&mut self) {
696 self.limits_history.clear();
697 }
698
699 /// Number of stored history entries, mirroring `len(LimitsHistory)`.
700 pub fn limits_history_len(&self) -> usize {
701 self.limits_history.len()
702 }
703
704 /// Whether the X axis refits to data on reset-zoom (silx
705 /// `isXAxisAutoScale`).
706 pub fn x_autoscale(&self) -> bool {
707 self.x_autoscale
708 }
709
710 /// Set whether the X axis refits to data on reset-zoom
711 /// (silx `setXAxisAutoScale`).
712 pub fn set_x_autoscale(&mut self, on: bool) {
713 self.x_autoscale = on;
714 }
715
716 /// Whether the left Y axis refits to data on reset-zoom (silx
717 /// `isYAxisAutoScale`).
718 pub fn y_autoscale(&self) -> bool {
719 self.y_autoscale
720 }
721
722 /// Set whether the left Y axis refits to data on reset-zoom
723 /// (silx `setYAxisAutoScale`).
724 pub fn set_y_autoscale(&mut self, on: bool) {
725 self.y_autoscale = on;
726 }
727
728 /// Whether the right (y2) Y axis refits to data on reset-zoom.
729 pub fn y2_autoscale(&self) -> bool {
730 self.y2_autoscale
731 }
732
733 /// Set whether the right (y2) Y axis refits to data on reset-zoom.
734 pub fn set_y2_autoscale(&mut self, on: bool) {
735 self.y2_autoscale = on;
736 }
737
738 /// The cached per-axis data range, mirroring silx `getDataRange`. Returns a
739 /// [`DataRange`] with each member `None` until the high-level widget pushes
740 /// bounds via [`Self::set_data_range`]. silx lazily recomputes from items
741 /// here; this model layer holds no items, so an unset range reads as all
742 /// `None`.
743 pub fn data_range(&self) -> DataRange {
744 self.data_range.unwrap_or_default()
745 }
746
747 /// Store the accumulated per-axis data bounds (silx populates `_dataRange`
748 /// from its items in `_updateDataRange`). The high-level widget owns the
749 /// item data and calls this; [`Self::reset_zoom`] then refits from it.
750 pub fn set_data_range(&mut self, range: DataRange) {
751 self.data_range = Some(range);
752 }
753
754 /// Refit the view from the cached [`Self::data_range`], honoring the per-axis
755 /// autoscale flags (silx `PlotWidget.resetZoom` with `getDataRange()`).
756 /// Equivalent to `reset_zoom_to_data_range(self.data_range())`.
757 pub fn reset_zoom(&mut self) {
758 self.reset_zoom_to_data_range(self.data_range());
759 }
760
761 /// The per-side data margins applied around the data on reset-zoom (silx
762 /// `getDataMargins`).
763 pub fn data_margins(&self) -> DataMargins {
764 self.data_margins
765 }
766
767 /// Set the per-side data margins (silx `setDataMargins`). The ratios expand
768 /// each refit axis around its data range on the next reset-zoom; for log
769 /// axes they expand in log space.
770 pub fn set_data_margins(&mut self, margins: DataMargins) {
771 self.data_margins = margins;
772 }
773
774 /// Whether the axes (frame/ticks/labels) are displayed (silx
775 /// `isAxesDisplayed`).
776 pub fn axes_displayed(&self) -> bool {
777 self.axes_displayed
778 }
779
780 /// Show or hide the axes (silx `setAxesDisplayed`). When hidden, the widget
781 /// passes `axes_hidden` to `chrome::layout`, which drops the axis gutters to
782 /// zero (silx `setAxesMargins(0,0,0,0)`). Marks the plot dirty
783 /// (full redraw) when the value changes, mirroring silx
784 /// `setAxesDisplayed`'s `_setDirtyPlot()`.
785 pub fn set_axes_displayed(&mut self, displayed: bool) {
786 if displayed != self.axes_displayed {
787 self.axes_displayed = displayed;
788 self.set_dirty(false);
789 }
790 }
791
792 /// Whether the arrow keys pan the data area when the plot is focused (silx
793 /// `isPanWithArrowKeys`).
794 pub fn pan_with_arrow_keys(&self) -> bool {
795 self.pan_with_arrow_keys
796 }
797
798 /// Enable or disable arrow-key panning (silx `setPanWithArrowKeys`). Unlike
799 /// most setters this does not mark the plot dirty: it only changes how a
800 /// future key press is handled, never the current frame (silx
801 /// `setPanWithArrowKeys` sets the flag without `_setDirtyPlot`).
802 pub fn set_pan_with_arrow_keys(&mut self, pan: bool) {
803 self.pan_with_arrow_keys = pan;
804 }
805
806 /// Whether a box zoom changes the X axis (silx `Zoom.enabledAxes.xaxis`).
807 pub fn zoom_x_enabled(&self) -> bool {
808 self.zoom_x_enabled
809 }
810
811 /// Whether a box zoom changes the (left) Y axis (silx
812 /// `Zoom.enabledAxes.yaxis`).
813 pub fn zoom_y_enabled(&self) -> bool {
814 self.zoom_y_enabled
815 }
816
817 /// Choose which axes a box zoom affects (silx
818 /// `PlotInteraction.setZoomEnabledAxes`). A disabled axis keeps its current
819 /// range when a box zoom is applied. Like silx's setter this is a plain
820 /// flag set: it does not mark the plot dirty (it only changes how a future
821 /// box zoom is applied, not the current frame).
822 pub fn set_zoom_enabled_axes(&mut self, x_enabled: bool, y_enabled: bool) {
823 self.zoom_x_enabled = x_enabled;
824 self.zoom_y_enabled = y_enabled;
825 }
826
827 /// The current redraw-dirty state (silx `_getDirtyPlot`).
828 pub fn dirty(&self) -> DirtyState {
829 self.dirty
830 }
831
832 /// Mark the plot as needing redraw (silx `_setDirtyPlot`). `overlay_only`
833 /// requests an overlay-only redraw. The transition matches silx exactly:
834 /// from [`DirtyState::Clean`] an overlay-only mark becomes
835 /// [`DirtyState::Overlay`] and a full mark becomes [`DirtyState::Full`];
836 /// from any already-dirty state the mark escalates to [`DirtyState::Full`]
837 /// (an overlay-only mark cannot downgrade an already-pending full redraw).
838 pub fn set_dirty(&mut self, overlay_only: bool) {
839 self.dirty = if self.dirty == DirtyState::Clean && overlay_only {
840 DirtyState::Overlay
841 } else {
842 DirtyState::Full
843 };
844 }
845
846 /// Clear the dirty state to [`DirtyState::Clean`] (silx resets `_dirty` to
847 /// `False` in `_paintContext` after drawing). Call after a redraw has been
848 /// performed.
849 pub fn replot(&mut self) {
850 self.dirty = DirtyState::Clean;
851 }
852
853 /// Whether automatic replot is enabled (silx `getAutoReplot`).
854 pub fn autoreplot(&self) -> bool {
855 self.autoreplot
856 }
857
858 /// Enable or disable automatic replot (silx `setAutoReplot`). State only;
859 /// the render loop that would honor this is at the widget layer (deferred).
860 pub fn set_autoreplot(&mut self, autoreplot: bool) {
861 self.autoreplot = autoreplot;
862 }
863
864 /// The X-axis tick mode (silx `getXAxis().getTickMode`).
865 pub fn x_tick_mode(&self) -> TickMode {
866 self.x_tick_mode
867 }
868
869 /// Set the X-axis tick mode (silx `getXAxis().setTickMode`). With
870 /// [`TickMode::TimeSeries`] the chrome formats the X tick labels as
871 /// date-times (the data values are epoch seconds, UTC).
872 pub fn set_x_tick_mode(&mut self, mode: TickMode) {
873 self.x_tick_mode = mode;
874 }
875
876 /// The X-axis time zone for date-time ticks (silx
877 /// `getXAxis().getTimeZone`).
878 pub fn x_time_zone(&self) -> TimeZone {
879 self.x_time_zone
880 }
881
882 /// Set the X-axis time zone for date-time ticks (silx
883 /// `getXAxis().setTimeZone`). Only affects layout while the X tick mode is
884 /// [`TickMode::TimeSeries`]; the data values stay epoch seconds (UTC) and
885 /// the ticks are laid out in this zone's wall-clock calendar.
886 pub fn set_x_time_zone(&mut self, tz: TimeZone) {
887 self.x_time_zone = tz;
888 }
889
890 /// The X-axis epoch offset applied to date-time tick labels (siplot
891 /// extension; see the `x_time_offset` field docs).
892 pub fn x_time_offset(&self) -> f64 {
893 self.x_time_offset
894 }
895
896 /// Set the epoch offset added to X tick values before [`TickMode::TimeSeries`]
897 /// formatting, so a caller feeding relative (`f32`-safe) X coordinates still
898 /// gets absolute wall-clock tick labels. The stored X values are interpreted
899 /// as `epoch - offset`; `0.0` (the default) means the X values already are
900 /// epoch seconds. No effect outside the TimeSeries tick mode.
901 pub fn set_x_time_offset(&mut self, offset: f64) {
902 self.x_time_offset = offset;
903 }
904
905 /// Append an infinite line item (silx `addItem` of a `Line`). The widget
906 /// clips each line to the current viewport and draws it every frame.
907 pub fn add_line(&mut self, line: Line) {
908 self.lines.push(line);
909 }
910
911 /// The infinite line items (silx `Line` items).
912 pub fn lines(&self) -> &[Line] {
913 &self.lines
914 }
915
916 /// Mutable access to the infinite line items.
917 pub fn lines_mut(&mut self) -> &mut Vec<Line> {
918 &mut self.lines
919 }
920
921 /// The X-axis label to display, given the active curve's X label (silx
922 /// `Axis.getLabel`). The active curve's `active_label` overrides the default
923 /// [`Self::x_label`] when set, otherwise the default shows, otherwise empty.
924 /// See [`resolved_axis_label`].
925 pub fn x_axis_label(&self, active_label: Option<&str>) -> String {
926 resolved_axis_label(self.x_label.as_deref(), active_label)
927 }
928
929 /// The left-Y-axis label to display, given the active curve's Y label (silx
930 /// `Axis.getLabel`). See [`Self::x_axis_label`].
931 pub fn y_axis_label(&self, active_label: Option<&str>) -> String {
932 resolved_axis_label(self.y_label.as_deref(), active_label)
933 }
934
935 /// The right (y2) axis label to display, given the active curve's y2 label
936 /// (silx `Axis.getLabel`). See [`Self::x_axis_label`].
937 pub fn y2_axis_label(&self, active_label: Option<&str>) -> String {
938 resolved_axis_label(self.y2_label.as_deref(), active_label)
939 }
940
941 /// The X-axis label actually drawn this frame: the active curve's X label
942 /// ([`Self::active_x_label`], set by the widget from the active curve)
943 /// overriding the graph default [`Self::x_label`], or `None` when neither is
944 /// set (silx `Axis._currentLabel`). `None` means nothing is drawn.
945 pub fn displayed_x_label(&self) -> Option<String> {
946 let label = self.x_axis_label(self.active_x_label.as_deref());
947 (!label.is_empty()).then_some(label)
948 }
949
950 /// The left-Y-axis label actually drawn this frame (silx `Axis._currentLabel`).
951 /// See [`Self::displayed_x_label`].
952 pub fn displayed_y_label(&self) -> Option<String> {
953 let label = self.y_axis_label(self.active_y_label.as_deref());
954 (!label.is_empty()).then_some(label)
955 }
956
957 /// The right (y2) axis label actually drawn this frame (silx
958 /// `Axis._currentLabel`). See [`Self::displayed_x_label`].
959 pub fn displayed_y2_label(&self) -> Option<String> {
960 let label = self.y2_axis_label(self.active_y2_label.as_deref());
961 (!label.is_empty()).then_some(label)
962 }
963
964 /// Whether the title gutter must be reserved this frame: a title is present,
965 /// or [`reserve_title_gutter`](Self::reserve_title_gutter) forces it (e.g. an
966 /// `ImageView` profile aligning to a titled reference plot). Single source of
967 /// truth for the chrome request bool, so the three gutters stay parallel.
968 pub fn needs_title_gutter(&self) -> bool {
969 self.title.is_some() || self.reserve_title_gutter
970 }
971
972 /// Whether the X-axis-label gutter must be reserved this frame. See
973 /// [`needs_title_gutter`](Self::needs_title_gutter).
974 pub fn needs_x_label_gutter(&self) -> bool {
975 self.x_label.is_some() || self.reserve_x_label_gutter
976 }
977
978 /// Whether the (left) Y-axis-label gutter must be reserved this frame. See
979 /// [`needs_title_gutter`](Self::needs_title_gutter).
980 pub fn needs_y_label_gutter(&self) -> bool {
981 self.y_label.is_some() || self.reserve_y_label_gutter
982 }
983
984 /// The explicit grid-line color override (silx `getGridColor`). `None` means
985 /// the grid follows the foreground color; see [`Self::effective_grid_color`].
986 pub fn grid_color(&self) -> Option<Color32> {
987 self.grid_color
988 }
989
990 /// Set (or clear, with `None`) the grid-line color override (silx
991 /// `setGridColor`). Marks the plot dirty on change, mirroring silx's
992 /// `_foregroundColorsUpdated` -> `_setDirtyPlot()`. Wired: the widget passes
993 /// this through `chrome::Theme::with_overrides(foreground, grid_color)`
994 /// (`plot_widget.rs`), so the grid renders with this color independently of
995 /// the foreground (axis/frame) color; see [`Self::effective_grid_color`].
996 pub fn set_grid_color(&mut self, color: Option<Color32>) {
997 if self.grid_color != color {
998 self.grid_color = color;
999 self.set_dirty(false);
1000 }
1001 }
1002
1003 /// Resolve the color the grid lines should use given the resolved
1004 /// `foreground` color, mirroring silx `_foregroundColorsUpdated`: the
1005 /// explicit [`Self::grid_color`] when set, otherwise `foreground`.
1006 pub fn effective_grid_color(&self, foreground: Color32) -> Color32 {
1007 self.grid_color.unwrap_or(foreground)
1008 }
1009
1010 /// Refit the view to `data` honoring the per-axis autoscale flags, mirroring
1011 /// silx `PlotWidget.resetZoom`. An axis whose autoscale flag is off keeps its
1012 /// current display range; an axis whose flag is on is refit to its data
1013 /// bounds (when present).
1014 ///
1015 /// silx also forces autoscale on a log axis whose current lower limit is
1016 /// `<= 0` (so toggling to log re-fits to positive data); that rule is applied
1017 /// here per axis via the [`Scale::Log10`] check (matches
1018 /// `PlotWidget.resetZoom`:3377-3382). Axes with no data bounds and autoscale
1019 /// off are left untouched.
1020 ///
1021 /// This is the pure model operation; the high-level widget owns the actual
1022 /// `data` accumulation (its `DataBounds`) and calls this with the current
1023 /// range.
1024 pub fn reset_zoom_to_data_range(&mut self, data: DataRange) {
1025 let (mut x_min, mut x_max, mut y_min, mut y_max) = self.limits;
1026 let mut y2 = self.y2;
1027
1028 // Force autoscale on a log axis whose lower limit is <= 0 (silx
1029 // resetZoom:3377-3382).
1030 let x_auto = self.x_autoscale || (self.x_scale == Scale::Log10 && x_min <= 0.0);
1031 let y_log_force = self.y_scale == Scale::Log10
1032 && (y_min <= 0.0 || self.y2.map(|(lo, _)| lo <= 0.0).unwrap_or(false));
1033 let y_auto = self.y_autoscale || y_log_force;
1034 let y2_auto = self.y2_autoscale || y_log_force;
1035
1036 // Track which axes are refit; only those receive data margins, matching
1037 // silx (pinned axes are restored after _forceResetZoom without margins).
1038 let mut x_refit = false;
1039 let mut y_refit = false;
1040 let mut y2_refit = false;
1041
1042 if x_auto && let Some((dmin, dmax)) = data.x {
1043 x_min = dmin;
1044 x_max = dmax;
1045 x_refit = true;
1046 }
1047 if y_auto && let Some((dmin, dmax)) = data.y {
1048 y_min = dmin;
1049 y_max = dmax;
1050 y_refit = true;
1051 }
1052 if y2_auto && let Some((dmin, dmax)) = data.y2 {
1053 y2 = Some((dmin, dmax));
1054 y2_refit = true;
1055 }
1056
1057 // Expand refit axes by the data margins (silx applies margins=True in
1058 // setLimits during _forceResetZoom; addMarginsToLimits respects log
1059 // axes and the shared y/y2 margin ratios).
1060 let m = self.data_margins;
1061 let x_is_log = self.x_scale == Scale::Log10;
1062 let y_is_log = self.y_scale == Scale::Log10;
1063 if x_refit {
1064 (x_min, x_max) = DataMargins::expand_axis(x_min, x_max, m.x_min, m.x_max, x_is_log);
1065 }
1066 if y_refit {
1067 (y_min, y_max) = DataMargins::expand_axis(y_min, y_max, m.y_min, m.y_max, y_is_log);
1068 }
1069 if y2_refit && let Some((lo, hi)) = y2 {
1070 // y2 axis uses the Y margin ratios and the Y log flag (silx reuses
1071 // yMinMargin/yMaxMargin and isYLog for the y2 branch).
1072 y2 = Some(DataMargins::expand_axis(lo, hi, m.y_min, m.y_max, y_is_log));
1073 }
1074
1075 self.limits = (x_min, x_max, y_min, y_max);
1076 self.y2 = y2;
1077 }
1078
1079 /// Build the data↔screen transform for the given data-area rect, honoring
1080 /// the per-axis scale, inversion, and (linear-only) aspect-ratio lock.
1081 ///
1082 /// Aspect correction is derived here from the stable requested `limits`, so
1083 /// it is the same view used for rendering, chrome, and pointer mapping —
1084 /// and resizing never compounds the expansion (`doc/design.md` §13 A4).
1085 pub fn transform(&self, area: Rect) -> Transform {
1086 let linear = self.x_scale == Scale::Linear && self.y_scale == Scale::Linear;
1087 let (x_min, x_max, y_min, y_max) = if self.keep_aspect && linear {
1088 keep_aspect_limits(self.limits, area)
1089 } else {
1090 self.limits
1091 };
1092 let x = Axis {
1093 min: x_min,
1094 max: x_max,
1095 scale: self.x_scale,
1096 inverted: self.x_inverted,
1097 };
1098 let y = Axis {
1099 min: y_min,
1100 max: y_max,
1101 scale: self.y_scale,
1102 inverted: self.y_inverted,
1103 };
1104 Transform::with_axes(x, y, area)
1105 }
1106
1107 /// Build the transform for the secondary right (y2) axis, sharing the left
1108 /// transform's X axis exactly (including any aspect expansion) so curves on
1109 /// both axes stay aligned in X. `None` when the plot has no y2 axis. The y2
1110 /// axis is linear, non-inverted (`doc/design.md` §13 A5).
1111 pub fn transform_y2(&self, area: Rect) -> Option<Transform> {
1112 let (y2_min, y2_max) = self.y2?;
1113 let left = self.transform(area);
1114 let y2 = Axis::linear(y2_min, y2_max);
1115 Some(Transform::with_axes(left.x, y2, area))
1116 }
1117
1118 /// Append an extra Y axis on `side` (range/label unset, linear, autoscaling)
1119 /// and return its index, usable as [`crate::YAxis::Extra(index)`](crate::YAxis::Extra).
1120 pub fn add_extra_axis(&mut self, side: AxisSide) -> usize {
1121 self.extra.push(ExtraAxis::new(side));
1122 self.extra.len() - 1
1123 }
1124
1125 /// The extra axes in creation order (silx-style multi-axis).
1126 pub fn extra_axes(&self) -> &[ExtraAxis] {
1127 &self.extra
1128 }
1129
1130 /// Shared read access to extra axis `index`, or `None` when unknown.
1131 pub fn extra_axis(&self, index: usize) -> Option<&ExtraAxis> {
1132 self.extra.get(index)
1133 }
1134
1135 /// Mutable access to extra axis `index`, or `None` when unknown.
1136 pub fn extra_axis_mut(&mut self, index: usize) -> Option<&mut ExtraAxis> {
1137 self.extra.get_mut(index)
1138 }
1139
1140 /// Build the transform for extra axis `index`, sharing the left transform's
1141 /// X axis exactly (including any aspect expansion) so curves on every axis
1142 /// stay aligned in X, and using the extra axis' own range and scale as Y.
1143 /// `None` when the index is unknown or the axis has no range yet (the caller
1144 /// then falls back to the left transform, matching [`Self::transform_y2`]).
1145 pub fn transform_extra(&self, index: usize, area: Rect) -> Option<Transform> {
1146 let ax = self.extra.get(index)?;
1147 let (min, max) = ax.range?;
1148 let left = self.transform(area);
1149 let y = Axis {
1150 min,
1151 max,
1152 scale: ax.scale,
1153 inverted: false,
1154 };
1155 Some(Transform::with_axes(left.x, y, area))
1156 }
1157
1158 /// Refit each autoscale-on extra axis to its data bounds, the multi-axis
1159 /// sibling of the left/right refit in [`Self::reset_zoom_to_data_range`].
1160 /// `data[i]` is extra axis `i`'s data bounds; a missing or `None` entry
1161 /// leaves that axis' range unchanged. Refit axes are expanded by the Y data
1162 /// margins (reusing the y2 margin/log rules), and a log axis whose lower
1163 /// bound is non-positive is force-refit so toggling it to log re-fits to
1164 /// positive data (matching the y2 branch of `reset_zoom_to_data_range`).
1165 pub fn reset_extra_axes_to(&mut self, data: &[Option<(f64, f64)>]) {
1166 let m = self.data_margins;
1167 for (i, ax) in self.extra.iter_mut().enumerate() {
1168 let is_log = ax.scale == Scale::Log10;
1169 let log_force = is_log && ax.range.map(|(lo, _)| lo <= 0.0).unwrap_or(true);
1170 if !(ax.autoscale || log_force) {
1171 continue;
1172 }
1173 if let Some(Some((lo, hi))) = data.get(i).copied() {
1174 ax.range = Some(DataMargins::expand_axis(lo, hi, m.y_min, m.y_max, is_log));
1175 }
1176 }
1177 }
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182 use super::*;
1183 use egui::pos2;
1184
1185 fn area() -> Rect {
1186 Rect::from_min_max(pos2(0.0, 0.0), pos2(200.0, 100.0))
1187 }
1188
1189 #[test]
1190 fn axis_constraints_unconstrained_is_passthrough() {
1191 let c = AxisConstraints::default();
1192 assert_eq!(c.apply(0.0, 10.0), (0.0, 10.0));
1193 assert!(c.is_unconstrained());
1194 }
1195
1196 #[test]
1197 fn axis_constraints_min_range_widens_span() {
1198 let c = AxisConstraints {
1199 min_range: Some(5.0),
1200 ..Default::default()
1201 };
1202 // Current span is 2.0, below min; should be widened to 5.0 centered on 1.0.
1203 let (lo, hi) = c.apply(0.0, 2.0);
1204 assert!((hi - lo - 5.0).abs() < 1e-10, "span={}", hi - lo);
1205 assert!(((lo + hi) / 2.0 - 1.0).abs() < 1e-10); // centered on original mid
1206 }
1207
1208 #[test]
1209 fn axis_constraints_max_range_narrows_span() {
1210 let c = AxisConstraints {
1211 max_range: Some(5.0),
1212 ..Default::default()
1213 };
1214 // Current span is 10.0, above max; should be narrowed to 5.0 centered on 5.0.
1215 let (lo, hi) = c.apply(0.0, 10.0);
1216 assert!((hi - lo - 5.0).abs() < 1e-10, "span={}", hi - lo);
1217 assert!(((lo + hi) / 2.0 - 5.0).abs() < 1e-10);
1218 }
1219
1220 #[test]
1221 fn axis_constraints_min_pos_shifts_window_right() {
1222 let c = AxisConstraints {
1223 min_pos: Some(2.0),
1224 ..Default::default()
1225 };
1226 // View [0, 4] would place lo below min_pos=2; shift right so lo=2.
1227 let (lo, hi) = c.apply(0.0, 4.0);
1228 assert!((lo - 2.0).abs() < 1e-10, "lo={lo}");
1229 assert!((hi - 6.0).abs() < 1e-10, "hi={hi}");
1230 }
1231
1232 #[test]
1233 fn axis_constraints_max_pos_shifts_window_left() {
1234 let c = AxisConstraints {
1235 max_pos: Some(8.0),
1236 ..Default::default()
1237 };
1238 // View [6, 12] places hi above max_pos=8; shift left so hi=8.
1239 let (lo, hi) = c.apply(6.0, 12.0);
1240 assert!((hi - 8.0).abs() < 1e-10, "hi={hi}");
1241 assert!((lo - 2.0).abs() < 1e-10, "lo={lo}");
1242 }
1243
1244 #[test]
1245 fn axis_constraints_view_wider_than_window_snaps_to_window() {
1246 let c = AxisConstraints {
1247 min_pos: Some(0.0),
1248 max_pos: Some(10.0),
1249 ..Default::default()
1250 };
1251 // View [-5, 15] (span 20) is wider than the [0, 10] window with both
1252 // ends out; silx normalize snaps it to exactly the window (span 10).
1253 let (lo, hi) = c.apply(-5.0, 15.0);
1254 assert!((lo - 0.0).abs() < 1e-10, "lo={lo}");
1255 assert!((hi - 10.0).abs() < 1e-10, "hi={hi}");
1256 }
1257
1258 #[test]
1259 fn axis_constraints_max_range_capped_to_window_keeps_offcenter_in_bounds() {
1260 let c = AxisConstraints {
1261 min_pos: Some(0.0),
1262 max_pos: Some(10.0),
1263 max_range: Some(100.0),
1264 ..Default::default()
1265 };
1266 // max_range=100 is capped to the 10-wide window (silx update sanity),
1267 // so the span-20 off-center view [2, 22] shrinks to the window and
1268 // shifts fully in bounds instead of overshooting the far edge.
1269 let (lo, hi) = c.apply(2.0, 22.0);
1270 assert!((lo - 0.0).abs() < 1e-10, "lo={lo}");
1271 assert!((hi - 10.0).abs() < 1e-10, "hi={hi}");
1272 }
1273
1274 #[test]
1275 fn axis_constraints_one_end_out_shifts_within_both_bounds() {
1276 let c = AxisConstraints {
1277 min_pos: Some(0.0),
1278 max_pos: Some(10.0),
1279 ..Default::default()
1280 };
1281 // View [-2, 3] (span 5) fits the window; only the low end is out, so
1282 // the window shifts right to [0, 5] without touching the span.
1283 let (lo, hi) = c.apply(-2.0, 3.0);
1284 assert!((lo - 0.0).abs() < 1e-10, "lo={lo}");
1285 assert!((hi - 5.0).abs() < 1e-10, "hi={hi}");
1286 }
1287
1288 #[test]
1289 fn axis_constraints_degenerate_span_is_passthrough() {
1290 let c = AxisConstraints {
1291 min_range: Some(1.0),
1292 ..Default::default()
1293 };
1294 // Already-invalid spans return unchanged (guard against further corruption).
1295 assert_eq!(c.apply(5.0, 3.0), (5.0, 3.0));
1296 }
1297
1298 #[test]
1299 fn transform_y2_is_none_without_y2_axis() {
1300 let plot = Plot::new(0);
1301 assert!(plot.transform_y2(area()).is_none());
1302 }
1303
1304 #[test]
1305 fn limits_history_starts_empty() {
1306 let plot = Plot::new(0);
1307 assert_eq!(plot.limits_history_len(), 0);
1308 }
1309
1310 #[test]
1311 fn limits_history_push_then_zoom_back_restores_previous() {
1312 let mut plot = Plot::new(0);
1313 plot.limits = (0.0, 1.0, 0.0, 1.0);
1314 plot.y2 = Some((0.0, 2.0));
1315 // Push the initial view, then change the view (as a zoom would).
1316 plot.push_limits();
1317 assert_eq!(plot.limits_history_len(), 1);
1318 plot.limits = (0.25, 0.75, 0.25, 0.75);
1319 plot.y2 = Some((0.5, 1.5));
1320 // zoom_back restores the pushed view (limits AND y2) and pops the entry.
1321 assert!(plot.zoom_back());
1322 assert_eq!(plot.limits, (0.0, 1.0, 0.0, 1.0));
1323 assert_eq!(plot.y2, Some((0.0, 2.0)));
1324 assert_eq!(plot.limits_history_len(), 0);
1325 }
1326
1327 #[test]
1328 fn zoom_back_on_empty_history_returns_false_and_keeps_view() {
1329 // Boundary: empty stack -> zoom_back is a no-op returning false (silx
1330 // pop() returns False on empty history).
1331 let mut plot = Plot::new(0);
1332 plot.limits = (1.0, 2.0, 3.0, 4.0);
1333 assert!(!plot.zoom_back());
1334 assert_eq!(plot.limits, (1.0, 2.0, 3.0, 4.0));
1335 }
1336
1337 #[test]
1338 fn limits_history_is_lifo_and_unbounded() {
1339 // silx LimitsHistory is a plain list (no depth cap); pushes stack LIFO.
1340 let mut plot = Plot::new(0);
1341 for i in 0..1000 {
1342 plot.limits = (i as f64, i as f64 + 1.0, 0.0, 1.0);
1343 plot.push_limits();
1344 }
1345 assert_eq!(plot.limits_history_len(), 1000);
1346 // Pop order is last-in-first-out.
1347 assert!(plot.zoom_back());
1348 assert_eq!(plot.limits, (999.0, 1000.0, 0.0, 1.0));
1349 assert!(plot.zoom_back());
1350 assert_eq!(plot.limits, (998.0, 999.0, 0.0, 1.0));
1351 assert_eq!(plot.limits_history_len(), 998);
1352 }
1353
1354 #[test]
1355 fn clear_limits_history_empties_the_stack() {
1356 let mut plot = Plot::new(0);
1357 plot.push_limits();
1358 plot.push_limits();
1359 assert_eq!(plot.limits_history_len(), 2);
1360 plot.clear_limits_history();
1361 assert_eq!(plot.limits_history_len(), 0);
1362 assert!(!plot.zoom_back());
1363 }
1364
1365 #[test]
1366 fn transform_y2_shares_left_x_and_maps_its_own_y() {
1367 let mut plot = Plot::new(0);
1368 plot.limits = (0.0, 10.0, 0.0, 100.0);
1369 plot.y2 = Some((-1.0, 1.0));
1370 let left = plot.transform(area());
1371 let right = plot.transform_y2(area()).expect("y2 transform");
1372
1373 // X axis is shared exactly, so curves on both axes align in X.
1374 assert_eq!(left.x, right.x);
1375 // The right axis maps its own y2 range: y2_min at the bottom edge, y2_max
1376 // at the top edge of the same area.
1377 let bottom = right.data_to_pixel(0.0, -1.0).y;
1378 let top = right.data_to_pixel(0.0, 1.0).y;
1379 assert!((bottom - area().bottom()).abs() <= 1e-3, "{bottom}");
1380 assert!((top - area().top()).abs() <= 1e-3, "{top}");
1381 }
1382
1383 #[test]
1384 fn autoscale_defaults_on_for_all_axes() {
1385 let plot = Plot::new(0);
1386 assert!(plot.x_autoscale());
1387 assert!(plot.y_autoscale());
1388 assert!(plot.y2_autoscale());
1389 }
1390
1391 #[test]
1392 fn reset_zoom_refits_only_autoscale_on_axes() {
1393 // X autoscale off: X range preserved; Y autoscale on: Y refit to data.
1394 let mut plot = Plot::new(0);
1395 plot.limits = (0.0, 1.0, 0.0, 1.0);
1396 plot.set_x_autoscale(false);
1397 plot.set_y_autoscale(true);
1398 plot.reset_zoom_to_data_range(DataRange {
1399 x: Some((10.0, 20.0)),
1400 y: Some((-5.0, 5.0)),
1401 y2: None,
1402 });
1403 // X preserved (autoscale off), Y refit (autoscale on).
1404 assert_eq!(plot.limits, (0.0, 1.0, -5.0, 5.0));
1405 }
1406
1407 #[test]
1408 fn reset_zoom_refits_x_when_only_x_autoscale_on() {
1409 let mut plot = Plot::new(0);
1410 plot.limits = (0.0, 1.0, 0.0, 1.0);
1411 plot.set_x_autoscale(true);
1412 plot.set_y_autoscale(false);
1413 plot.reset_zoom_to_data_range(DataRange {
1414 x: Some((10.0, 20.0)),
1415 y: Some((-5.0, 5.0)),
1416 y2: None,
1417 });
1418 // X refit, Y preserved.
1419 assert_eq!(plot.limits, (10.0, 20.0, 0.0, 1.0));
1420 }
1421
1422 #[test]
1423 fn reset_zoom_with_all_autoscale_off_is_noop() {
1424 let mut plot = Plot::new(0);
1425 plot.limits = (0.0, 1.0, 0.0, 1.0);
1426 plot.y2 = Some((0.0, 2.0));
1427 plot.set_x_autoscale(false);
1428 plot.set_y_autoscale(false);
1429 plot.set_y2_autoscale(false);
1430 plot.reset_zoom_to_data_range(DataRange {
1431 x: Some((10.0, 20.0)),
1432 y: Some((-5.0, 5.0)),
1433 y2: Some((-1.0, 1.0)),
1434 });
1435 // Nothing changes: every axis pinned.
1436 assert_eq!(plot.limits, (0.0, 1.0, 0.0, 1.0));
1437 assert_eq!(plot.y2, Some((0.0, 2.0)));
1438 }
1439
1440 #[test]
1441 fn reset_zoom_autoscale_on_axis_with_no_data_is_preserved() {
1442 // Boundary: autoscale on but no data bounds -> range left untouched.
1443 let mut plot = Plot::new(0);
1444 plot.limits = (3.0, 7.0, 2.0, 8.0);
1445 plot.reset_zoom_to_data_range(DataRange {
1446 x: None,
1447 y: Some((-1.0, 1.0)),
1448 y2: None,
1449 });
1450 // X has no data -> preserved; Y refit.
1451 assert_eq!(plot.limits, (3.0, 7.0, -1.0, 1.0));
1452 }
1453
1454 #[test]
1455 fn reset_zoom_log_axis_forces_autoscale_when_lower_limit_nonpositive() {
1456 // X is log with a <= 0 lower limit and autoscale OFF; silx forces it on.
1457 let mut plot = Plot::new(0);
1458 plot.x_scale = Scale::Log10;
1459 plot.limits = (-1.0, 100.0, 0.0, 1.0);
1460 plot.set_x_autoscale(false);
1461 plot.set_y_autoscale(false);
1462 plot.reset_zoom_to_data_range(DataRange {
1463 x: Some((1.0, 1000.0)),
1464 y: Some((-5.0, 5.0)),
1465 y2: None,
1466 });
1467 // X refit despite autoscale off (forced by log + nonpositive lower).
1468 assert_eq!(plot.limits.0, 1.0);
1469 assert_eq!(plot.limits.1, 1000.0);
1470 // Y stays pinned.
1471 assert_eq!((plot.limits.2, plot.limits.3), (0.0, 1.0));
1472 }
1473
1474 #[test]
1475 fn grid_color_defaults_none_and_follows_foreground() {
1476 let plot = Plot::new(0);
1477 assert_eq!(plot.grid_color(), None);
1478 let fg = Color32::from_rgb(200, 200, 200);
1479 // No explicit grid color -> effective is the foreground.
1480 assert_eq!(plot.effective_grid_color(fg), fg);
1481 }
1482
1483 #[test]
1484 fn grid_color_explicit_overrides_foreground() {
1485 let mut plot = Plot::new(0);
1486 let grid = Color32::from_rgb(64, 64, 64);
1487 let fg = Color32::from_rgb(200, 200, 200);
1488 plot.set_grid_color(Some(grid));
1489 assert_eq!(plot.grid_color(), Some(grid));
1490 // Explicit grid color wins over foreground.
1491 assert_eq!(plot.effective_grid_color(fg), grid);
1492 }
1493
1494 #[test]
1495 fn set_grid_color_change_marks_full_dirty() {
1496 let mut plot = Plot::new(0);
1497 // Setting to the same value (None) is a no-op for dirty.
1498 plot.set_grid_color(None);
1499 assert_eq!(plot.dirty(), DirtyState::Clean);
1500 // A real change marks dirty (silx _foregroundColorsUpdated).
1501 plot.set_grid_color(Some(Color32::RED));
1502 assert_eq!(plot.dirty(), DirtyState::Full);
1503 }
1504
1505 #[test]
1506 fn axis_label_active_curve_wins_over_default() {
1507 // silx _setActiveItem: the active curve's label overrides the graph
1508 // default (_setCurrentLabel displays the active label when non-empty).
1509 assert_eq!(
1510 resolved_axis_label(Some("Energy"), Some("curve X")),
1511 "curve X"
1512 );
1513 }
1514
1515 #[test]
1516 fn axis_label_falls_back_to_default_when_no_active() {
1517 // No active curve label -> the axis' own default label.
1518 assert_eq!(resolved_axis_label(Some("Energy"), None), "Energy");
1519 // Active label only -> active label.
1520 assert_eq!(resolved_axis_label(None, Some("curve X")), "curve X");
1521 }
1522
1523 #[test]
1524 fn axis_label_empty_when_neither_set() {
1525 assert_eq!(resolved_axis_label(None, None), "");
1526 }
1527
1528 #[test]
1529 fn axis_label_empty_active_falls_back_to_default() {
1530 // silx _setCurrentLabel treats "" as no label -> falls back to default.
1531 assert_eq!(resolved_axis_label(Some("Energy"), Some("")), "Energy");
1532 // Active label wins over a default even when the default is set.
1533 assert_eq!(resolved_axis_label(Some("Energy"), Some("Time")), "Time");
1534 // Both empty / unset -> empty.
1535 assert_eq!(resolved_axis_label(Some(""), Some("")), "");
1536 assert_eq!(resolved_axis_label(None, Some("")), "");
1537 }
1538
1539 #[test]
1540 fn plot_axis_label_active_overrides_default() {
1541 let mut plot = Plot::new(0);
1542 plot.x_label = Some("X axis".to_string());
1543 // Active curve label overrides the explicit default (silx semantics).
1544 assert_eq!(plot.x_axis_label(Some("curve")), "curve");
1545 // Default shows when there is no active label.
1546 assert_eq!(plot.x_axis_label(None), "X axis");
1547 // No default on y -> active curve label.
1548 assert_eq!(plot.y_axis_label(Some("intensity")), "intensity");
1549 // No default, no active -> empty.
1550 assert_eq!(plot.y2_axis_label(None), "");
1551 }
1552
1553 #[test]
1554 fn displayed_labels_resolve_active_override_against_default() {
1555 let mut plot = Plot::new(0);
1556 // Defaults set, no active override -> defaults are displayed.
1557 plot.x_label = Some("Energy".to_string());
1558 plot.y_label = Some("Counts".to_string());
1559 assert_eq!(plot.displayed_x_label().as_deref(), Some("Energy"));
1560 assert_eq!(plot.displayed_y_label().as_deref(), Some("Counts"));
1561 // y2 has neither default nor override -> nothing drawn.
1562 assert_eq!(plot.displayed_y2_label(), None);
1563
1564 // Active overrides win over the defaults (silx _setActiveItem).
1565 plot.active_x_label = Some("Time".to_string());
1566 plot.active_y_label = Some("Intensity".to_string());
1567 assert_eq!(plot.displayed_x_label().as_deref(), Some("Time"));
1568 assert_eq!(plot.displayed_y_label().as_deref(), Some("Intensity"));
1569
1570 // An empty override falls back to the default; an active y2 override with
1571 // no y2 default still drives the y2 label.
1572 plot.active_x_label = Some(String::new());
1573 plot.active_y2_label = Some("Right".to_string());
1574 assert_eq!(plot.displayed_x_label().as_deref(), Some("Energy"));
1575 assert_eq!(plot.displayed_y2_label().as_deref(), Some("Right"));
1576 }
1577
1578 #[test]
1579 fn dirty_defaults_clean_and_autoreplot_on_and_axes_displayed() {
1580 let plot = Plot::new(0);
1581 assert_eq!(plot.dirty(), DirtyState::Clean);
1582 assert!(plot.autoreplot());
1583 assert!(plot.axes_displayed());
1584 }
1585
1586 #[test]
1587 fn dirty_clean_overlay_only_becomes_overlay() {
1588 let mut plot = Plot::new(0);
1589 plot.set_dirty(true);
1590 assert_eq!(plot.dirty(), DirtyState::Overlay);
1591 }
1592
1593 #[test]
1594 fn dirty_clean_full_becomes_full() {
1595 let mut plot = Plot::new(0);
1596 plot.set_dirty(false);
1597 assert_eq!(plot.dirty(), DirtyState::Full);
1598 }
1599
1600 #[test]
1601 fn dirty_overlay_then_overlay_only_escalates_to_full() {
1602 // silx: once dirty, even an overlay-only mark sets _dirty = True.
1603 let mut plot = Plot::new(0);
1604 plot.set_dirty(true);
1605 assert_eq!(plot.dirty(), DirtyState::Overlay);
1606 plot.set_dirty(true);
1607 assert_eq!(plot.dirty(), DirtyState::Full);
1608 }
1609
1610 #[test]
1611 fn dirty_full_then_overlay_only_stays_full() {
1612 let mut plot = Plot::new(0);
1613 plot.set_dirty(false);
1614 plot.set_dirty(true);
1615 assert_eq!(plot.dirty(), DirtyState::Full);
1616 }
1617
1618 #[test]
1619 fn replot_clears_dirty_to_clean() {
1620 let mut plot = Plot::new(0);
1621 plot.set_dirty(false);
1622 assert_eq!(plot.dirty(), DirtyState::Full);
1623 plot.replot();
1624 assert_eq!(plot.dirty(), DirtyState::Clean);
1625 }
1626
1627 #[test]
1628 fn set_axes_displayed_change_marks_full_dirty() {
1629 let mut plot = Plot::new(0);
1630 // No change -> no dirty.
1631 plot.set_axes_displayed(true);
1632 assert_eq!(plot.dirty(), DirtyState::Clean);
1633 // Change -> full dirty.
1634 plot.set_axes_displayed(false);
1635 assert!(!plot.axes_displayed());
1636 assert_eq!(plot.dirty(), DirtyState::Full);
1637 }
1638
1639 #[test]
1640 fn pan_with_arrow_keys_defaults_true_and_set_does_not_dirty() {
1641 let mut plot = Plot::new(0);
1642 // silx PlotWidget._panWithArrowKeys = True.
1643 assert!(plot.pan_with_arrow_keys(), "default enabled");
1644
1645 // setPanWithArrowKeys is a plain flag set: it never marks the plot dirty
1646 // (it only changes how a future key press is handled, not the frame).
1647 plot.set_pan_with_arrow_keys(false);
1648 assert!(!plot.pan_with_arrow_keys());
1649 assert_eq!(plot.dirty(), DirtyState::Clean, "toggling must not dirty");
1650
1651 plot.set_pan_with_arrow_keys(true);
1652 assert!(plot.pan_with_arrow_keys());
1653 assert_eq!(plot.dirty(), DirtyState::Clean);
1654 }
1655
1656 #[test]
1657 fn zoom_enabled_axes_default_true_and_set_does_not_dirty() {
1658 let mut plot = Plot::new(0);
1659 // silx ZoomEnabledAxesMenu: all axes checked by default.
1660 assert!(plot.zoom_x_enabled());
1661 assert!(plot.zoom_y_enabled());
1662
1663 // setZoomEnabledAxes is a plain flag set (it only changes how a future
1664 // box zoom applies), so it must not mark the plot dirty.
1665 plot.set_zoom_enabled_axes(true, false);
1666 assert!(plot.zoom_x_enabled());
1667 assert!(!plot.zoom_y_enabled());
1668 assert_eq!(plot.dirty(), DirtyState::Clean);
1669
1670 plot.set_zoom_enabled_axes(false, true);
1671 assert!(!plot.zoom_x_enabled());
1672 assert!(plot.zoom_y_enabled());
1673 assert_eq!(plot.dirty(), DirtyState::Clean);
1674 }
1675
1676 #[test]
1677 fn lines_start_empty_and_append() {
1678 let mut plot = Plot::new(0);
1679 assert!(plot.lines().is_empty());
1680 plot.add_line(Line::new(f64::INFINITY, 3.0));
1681 plot.add_line(Line::new(0.0, 1.0));
1682 assert_eq!(plot.lines().len(), 2);
1683 // lines_mut allows in-place edits.
1684 plot.lines_mut()[1].intercept = 2.0;
1685 assert_eq!(plot.lines()[1].intercept, 2.0);
1686 assert!(!plot.lines()[0].slope.is_finite());
1687 }
1688
1689 #[test]
1690 fn tick_mode_defaults_numeric_and_sets_x_only() {
1691 let mut plot = Plot::new(0);
1692 assert_eq!(plot.x_tick_mode(), TickMode::Numeric);
1693 plot.set_x_tick_mode(TickMode::TimeSeries);
1694 assert_eq!(plot.x_tick_mode(), TickMode::TimeSeries);
1695 plot.set_x_tick_mode(TickMode::Numeric);
1696 assert_eq!(plot.x_tick_mode(), TickMode::Numeric);
1697 }
1698
1699 #[test]
1700 fn time_zone_defaults_utc_and_round_trips() {
1701 let mut plot = Plot::new(0);
1702 assert_eq!(plot.x_time_zone(), TimeZone::Utc);
1703 let jst = TimeZone::FixedOffset {
1704 seconds_east: 32400,
1705 };
1706 plot.set_x_time_zone(jst);
1707 assert_eq!(plot.x_time_zone(), jst);
1708 plot.set_x_time_zone(TimeZone::Utc);
1709 assert_eq!(plot.x_time_zone(), TimeZone::Utc);
1710 }
1711
1712 #[test]
1713 fn set_autoreplot_toggles() {
1714 let mut plot = Plot::new(0);
1715 plot.set_autoreplot(false);
1716 assert!(!plot.autoreplot());
1717 plot.set_autoreplot(true);
1718 assert!(plot.autoreplot());
1719 }
1720
1721 #[test]
1722 fn show_colorbar_defaults_true() {
1723 // Backward compat: every plot draws its colorbar when it has a colormap
1724 // unless a caller (e.g. ImageView's internal image plot) opts out.
1725 let mut plot = Plot::new(0);
1726 assert!(plot.show_colorbar);
1727 plot.show_colorbar = false;
1728 assert!(!plot.show_colorbar);
1729 }
1730
1731 #[test]
1732 fn data_margins_default_zero_and_noop() {
1733 let mut plot = Plot::new(0);
1734 assert_eq!(plot.data_margins(), DataMargins::default());
1735 plot.set_data_range(DataRange {
1736 x: Some((0.0, 10.0)),
1737 y: Some((0.0, 10.0)),
1738 y2: None,
1739 });
1740 plot.reset_zoom();
1741 // No margins -> exact data bounds.
1742 assert_eq!(plot.limits, (0.0, 10.0, 0.0, 10.0));
1743 }
1744
1745 #[test]
1746 fn data_margins_linear_left_expands_xmin_by_ratio_of_range() {
1747 // 0.1 left margin on a [0, 10] range expands xmin by 10% of 10 = 1.
1748 let mut plot = Plot::new(0);
1749 plot.set_data_margins(DataMargins {
1750 x_min: 0.1,
1751 ..Default::default()
1752 });
1753 plot.set_data_range(DataRange {
1754 x: Some((0.0, 10.0)),
1755 y: Some((0.0, 10.0)),
1756 y2: None,
1757 });
1758 plot.reset_zoom();
1759 assert!(
1760 (plot.limits.0 - (-1.0)).abs() < 1e-9,
1761 "xmin={}",
1762 plot.limits.0
1763 );
1764 // xmax untouched (no right margin), y untouched (no y margins).
1765 assert_eq!(plot.limits.1, 10.0);
1766 assert_eq!((plot.limits.2, plot.limits.3), (0.0, 10.0));
1767 }
1768
1769 #[test]
1770 fn data_margins_log_expands_in_log_space() {
1771 // Log X over [1, 100] (2 decades). A 0.1 left margin expands xmin by 10%
1772 // of the 2-decade range in log space: 10^(0 - 0.1*2) = 10^-0.2.
1773 let mut plot = Plot::new(0);
1774 plot.x_scale = Scale::Log10;
1775 plot.set_data_margins(DataMargins {
1776 x_min: 0.1,
1777 ..Default::default()
1778 });
1779 plot.set_data_range(DataRange {
1780 x: Some((1.0, 100.0)),
1781 y: Some((1.0, 100.0)),
1782 y2: None,
1783 });
1784 plot.reset_zoom();
1785 let expected = 10f64.powf(-0.2);
1786 assert!(
1787 (plot.limits.0 - expected).abs() < 1e-9,
1788 "xmin={} expected={expected}",
1789 plot.limits.0
1790 );
1791 assert_eq!(plot.limits.1, 100.0);
1792 }
1793
1794 #[test]
1795 fn data_margins_log_skips_nonpositive_bound() {
1796 // Boundary: log axis with a non-positive lower bound -> margin skipped
1797 // (silx "Do not apply margins if limits < 0"), but the bound itself is
1798 // still the refit value.
1799 let (lo, hi) = DataMargins::expand_axis(0.0, 100.0, 0.1, 0.1, true);
1800 assert_eq!((lo, hi), (0.0, 100.0));
1801 }
1802
1803 #[test]
1804 fn data_margins_only_applied_to_refit_axes() {
1805 // X autoscale off -> X keeps its range and gets NO margin even though a
1806 // left margin is set; Y refit and margined.
1807 let mut plot = Plot::new(0);
1808 plot.limits = (5.0, 6.0, 0.0, 0.0);
1809 plot.set_x_autoscale(false);
1810 plot.set_data_margins(DataMargins {
1811 x_min: 0.5,
1812 y_min: 0.1,
1813 ..Default::default()
1814 });
1815 plot.set_data_range(DataRange {
1816 x: Some((0.0, 10.0)),
1817 y: Some((0.0, 10.0)),
1818 y2: None,
1819 });
1820 plot.reset_zoom();
1821 // X pinned, no margin applied.
1822 assert_eq!((plot.limits.0, plot.limits.1), (5.0, 6.0));
1823 // Y refit with 0.1 bottom margin: ymin = 0 - 0.1*10 = -1.
1824 assert!(
1825 (plot.limits.2 - (-1.0)).abs() < 1e-9,
1826 "ymin={}",
1827 plot.limits.2
1828 );
1829 }
1830
1831 #[test]
1832 fn data_range_is_empty_until_set() {
1833 let plot = Plot::new(0);
1834 let r = plot.data_range();
1835 assert_eq!(r, DataRange::default());
1836 assert!(r.x.is_none() && r.y.is_none() && r.y2.is_none());
1837 }
1838
1839 #[test]
1840 fn reset_zoom_uses_cached_data_range() {
1841 let mut plot = Plot::new(0);
1842 plot.limits = (0.0, 1.0, 0.0, 1.0);
1843 plot.set_data_range(DataRange {
1844 x: Some((2.0, 4.0)),
1845 y: Some((6.0, 8.0)),
1846 y2: None,
1847 });
1848 plot.reset_zoom();
1849 assert_eq!(plot.limits, (2.0, 4.0, 6.0, 8.0));
1850 }
1851
1852 #[test]
1853 fn reset_zoom_refits_y2_independently() {
1854 let mut plot = Plot::new(0);
1855 plot.limits = (0.0, 1.0, 0.0, 1.0);
1856 plot.y2 = Some((0.0, 1.0));
1857 plot.set_x_autoscale(false);
1858 plot.set_y_autoscale(false);
1859 plot.set_y2_autoscale(true);
1860 plot.reset_zoom_to_data_range(DataRange {
1861 x: Some((10.0, 20.0)),
1862 y: Some((-5.0, 5.0)),
1863 y2: Some((100.0, 200.0)),
1864 });
1865 // Only y2 refit.
1866 assert_eq!(plot.limits, (0.0, 1.0, 0.0, 1.0));
1867 assert_eq!(plot.y2, Some((100.0, 200.0)));
1868 }
1869
1870 #[test]
1871 fn transform_y2_shares_aspect_expanded_x() {
1872 // With the aspect lock on, the left transform's X is expanded; the y2
1873 // transform must inherit that same expanded X (not the raw limits).
1874 let mut plot = Plot::new(0);
1875 plot.limits = (0.0, 10.0, 0.0, 10.0);
1876 plot.keep_aspect = true;
1877 plot.y2 = Some((0.0, 5.0));
1878 let left = plot.transform(area());
1879 let right = plot.transform_y2(area()).expect("y2 transform");
1880 assert_eq!(left.x, right.x);
1881 // Sanity: the lock actually widened X beyond the raw [0, 10].
1882 assert!(left.x.min < 0.0 && left.x.max > 10.0, "{:?}", left.x);
1883 }
1884
1885 // --- extra (stacked) Y axes ---
1886
1887 #[test]
1888 fn add_extra_axis_returns_sequential_indices_and_keeps_side() {
1889 let mut plot = Plot::new(0);
1890 assert_eq!(plot.add_extra_axis(AxisSide::Right), 0);
1891 assert_eq!(plot.add_extra_axis(AxisSide::Left), 1);
1892 assert_eq!(plot.extra_axes().len(), 2);
1893 assert_eq!(plot.extra_axis(0).unwrap().side, AxisSide::Right);
1894 assert_eq!(plot.extra_axis(1).unwrap().side, AxisSide::Left);
1895 // A fresh extra axis has no range, is linear, and autoscales.
1896 let ax = plot.extra_axis(0).unwrap();
1897 assert_eq!(ax.range, None);
1898 assert_eq!(ax.scale, Scale::Linear);
1899 assert!(ax.autoscale);
1900 assert!(plot.extra_axis(2).is_none());
1901 }
1902
1903 #[test]
1904 fn transform_extra_shares_left_x_and_maps_its_own_y() {
1905 let mut plot = Plot::new(0);
1906 plot.limits = (0.0, 10.0, 0.0, 1.0);
1907 let i = plot.add_extra_axis(AxisSide::Right);
1908 // No range yet -> no transform (caller falls back to the left axis).
1909 assert!(plot.transform_extra(i, area()).is_none());
1910 plot.extra_axis_mut(i).unwrap().range = Some((-1.0, 1.0));
1911 let left = plot.transform(area());
1912 let extra = plot.transform_extra(i, area()).expect("extra transform");
1913 // Shares the left X axis exactly; maps its own Y range with the low value
1914 // at the bottom edge and the high value at the top edge.
1915 assert_eq!(left.x, extra.x);
1916 let bottom = extra.data_to_pixel(0.0, -1.0);
1917 let top = extra.data_to_pixel(0.0, 1.0);
1918 assert!((bottom.y - area().bottom()).abs() < 1e-3, "{bottom:?}");
1919 assert!((top.y - area().top()).abs() < 1e-3, "{top:?}");
1920 // An unknown index yields no transform.
1921 assert!(plot.transform_extra(99, area()).is_none());
1922 }
1923
1924 #[test]
1925 fn transform_extra_honors_its_own_log_scale() {
1926 let mut plot = Plot::new(0);
1927 plot.limits = (0.0, 10.0, 0.0, 1.0);
1928 let i = plot.add_extra_axis(AxisSide::Right);
1929 {
1930 let ax = plot.extra_axis_mut(i).unwrap();
1931 ax.range = Some((1.0, 1000.0));
1932 ax.scale = Scale::Log10;
1933 }
1934 let t = plot.transform_extra(i, area()).expect("extra transform");
1935 // Three decades: 10 lands one-third up from the bottom edge.
1936 let mid = t.data_to_pixel(0.0, 10.0).y;
1937 let expected = area().bottom() + (area().top() - area().bottom()) / 3.0;
1938 assert!((mid - expected).abs() < 1e-2, "{mid} vs {expected}");
1939 }
1940
1941 #[test]
1942 fn reset_extra_axes_to_refits_only_autoscale_on_axes() {
1943 let mut plot = Plot::new(0);
1944 let a = plot.add_extra_axis(AxisSide::Right); // autoscale on (default)
1945 let b = plot.add_extra_axis(AxisSide::Left);
1946 plot.extra_axis_mut(a).unwrap().range = Some((0.0, 1.0));
1947 plot.extra_axis_mut(b).unwrap().range = Some((0.0, 1.0));
1948 plot.extra_axis_mut(b).unwrap().autoscale = false;
1949 plot.reset_extra_axes_to(&[Some((100.0, 200.0)), Some((-9.0, 9.0))]);
1950 // a refit to its data; b pinned (autoscale off).
1951 assert_eq!(plot.extra_axis(a).unwrap().range, Some((100.0, 200.0)));
1952 assert_eq!(plot.extra_axis(b).unwrap().range, Some((0.0, 1.0)));
1953 }
1954
1955 #[test]
1956 fn reset_extra_axes_to_leaves_axis_with_no_data_unchanged() {
1957 let mut plot = Plot::new(0);
1958 let a = plot.add_extra_axis(AxisSide::Right);
1959 plot.extra_axis_mut(a).unwrap().range = Some((0.0, 1.0));
1960 // No data for this axis (empty slice / None entry) -> unchanged.
1961 plot.reset_extra_axes_to(&[None]);
1962 assert_eq!(plot.extra_axis(a).unwrap().range, Some((0.0, 1.0)));
1963 plot.reset_extra_axes_to(&[]);
1964 assert_eq!(plot.extra_axis(a).unwrap().range, Some((0.0, 1.0)));
1965 }
1966
1967 #[test]
1968 fn reset_extra_axes_to_force_refits_nonpositive_log_axis() {
1969 let mut plot = Plot::new(0);
1970 let a = plot.add_extra_axis(AxisSide::Right);
1971 {
1972 let ax = plot.extra_axis_mut(a).unwrap();
1973 ax.scale = Scale::Log10;
1974 ax.autoscale = false; // even pinned, a log axis with lo<=0 refits
1975 ax.range = Some((-5.0, 5.0));
1976 }
1977 plot.reset_extra_axes_to(&[Some((1.0, 1000.0))]);
1978 assert_eq!(plot.extra_axis(a).unwrap().range, Some((1.0, 1000.0)));
1979 }
1980
1981 // --- current-ROI selection invariant (Plot is the single owner) ---
1982
1983 fn point_roi(i: usize) -> ManagedRoi {
1984 ManagedRoi::new(crate::core::roi::Roi::Point {
1985 x: i as f64,
1986 y: 0.0,
1987 })
1988 }
1989
1990 #[test]
1991 fn roi_color_defaults_to_silx_red() {
1992 assert_eq!(Plot::new(0).roi_color, Color32::RED);
1993 }
1994
1995 #[test]
1996 fn set_current_roi_highlights_exactly_one() {
1997 let mut plot = Plot::new(0);
1998 plot.rois = (0..3).map(point_roi).collect();
1999
2000 plot.set_current_roi(Some(1));
2001 assert_eq!(plot.current_roi(), Some(1));
2002 assert!(!plot.rois[0].selected);
2003 assert!(plot.rois[1].selected);
2004 assert!(!plot.rois[2].selected);
2005
2006 // Switching the current ROI moves the single highlight.
2007 plot.set_current_roi(Some(2));
2008 assert!(!plot.rois[1].selected);
2009 assert!(plot.rois[2].selected);
2010
2011 // Clearing removes every highlight.
2012 plot.set_current_roi(None);
2013 assert_eq!(plot.current_roi(), None);
2014 assert!(plot.rois.iter().all(|r| !r.selected));
2015 }
2016
2017 #[test]
2018 fn set_current_roi_out_of_range_clears_selection() {
2019 let mut plot = Plot::new(0);
2020 plot.rois = vec![point_roi(0)];
2021 plot.set_current_roi(Some(1));
2022 assert_eq!(plot.current_roi(), None);
2023 assert!(!plot.rois[0].selected);
2024 }
2025
2026 #[test]
2027 fn remove_roi_adjusts_current_index() {
2028 let mut plot = Plot::new(0);
2029 plot.rois = (0..3).map(point_roi).collect();
2030
2031 // Current after the removed index shifts down by one.
2032 plot.set_current_roi(Some(2));
2033 plot.remove_roi(0);
2034 assert_eq!(plot.current_roi(), Some(1));
2035 assert!(plot.rois[1].selected);
2036
2037 // Removing the current ROI clears the selection.
2038 plot.set_current_roi(Some(1));
2039 plot.remove_roi(1);
2040 assert_eq!(plot.current_roi(), None);
2041 assert!(plot.rois.iter().all(|r| !r.selected));
2042
2043 // Current before the removed index is unaffected.
2044 plot.rois = (0..3).map(point_roi).collect();
2045 plot.set_current_roi(Some(0));
2046 plot.remove_roi(2);
2047 assert_eq!(plot.current_roi(), Some(0));
2048 assert!(plot.rois[0].selected);
2049 }
2050
2051 #[test]
2052 fn clear_rois_resets_current() {
2053 let mut plot = Plot::new(0);
2054 plot.rois = (0..3).map(point_roi).collect();
2055 plot.set_current_roi(Some(1));
2056 plot.clear_rois();
2057 assert_eq!(plot.current_roi(), None);
2058 assert!(plot.rois.is_empty());
2059 }
2060
2061 #[test]
2062 fn needs_gutter_defaults_false_without_label_or_reserve() {
2063 let plot = Plot::new(0);
2064 assert!(!plot.needs_title_gutter());
2065 assert!(!plot.needs_x_label_gutter());
2066 assert!(!plot.needs_y_label_gutter());
2067 }
2068
2069 #[test]
2070 fn needs_gutter_true_when_label_present() {
2071 let mut plot = Plot::new(0);
2072 plot.title = Some("T".into());
2073 plot.x_label = Some("X".into());
2074 plot.y_label = Some("Y".into());
2075 assert!(plot.needs_title_gutter());
2076 assert!(plot.needs_x_label_gutter());
2077 assert!(plot.needs_y_label_gutter());
2078 }
2079
2080 #[test]
2081 fn needs_gutter_true_when_reserve_flag_set_without_label() {
2082 // The ImageView-profile path: labels cleared (no text drawn), gutter
2083 // still reserved so the data area aligns with the reference plot.
2084 let mut plot = Plot::new(0);
2085 assert!(plot.title.is_none() && plot.x_label.is_none() && plot.y_label.is_none());
2086 plot.reserve_title_gutter = true;
2087 plot.reserve_x_label_gutter = true;
2088 plot.reserve_y_label_gutter = true;
2089 assert!(plot.needs_title_gutter());
2090 assert!(plot.needs_x_label_gutter());
2091 assert!(plot.needs_y_label_gutter());
2092 // Reservation must not synthesise label text.
2093 assert!(plot.displayed_x_label().is_none());
2094 assert!(plot.displayed_y_label().is_none());
2095 }
2096}