use std::fmt;
use std::mem;
use std::ops::{Deref, DerefMut};
use std::path::{Path, PathBuf};
use egui::Color32;
use egui_wgpu::RenderState;
use crate::core::backend::{
Backend, CurveColor, CurveSpec, ImagePixelsSpec, ImageSpec, ItemHandle, MarkerSpec, PickResult,
ShapeSpec, TriangleSpec,
};
use crate::core::calibration::Calibration;
use crate::core::colormap::{AutoscaleMode, Colormap, ColormapName, Normalization};
use crate::core::items::{Baseline, ErrorBars, LineStyle, ScalarMask, Symbol};
use crate::core::marker::{Marker, MarkerConstraint, MarkerKind};
use crate::core::plot::{DataMargins, DataRange, GraphGrid, Plot, PlotId};
use crate::core::roi::{
ManagedRoi, Roi, RoiInteractionMode, RoiLineStyle, arc_inner_radius, arc_outer_radius,
};
use crate::core::scatter_viz::{GridImage, ScatterLineProfile};
use crate::core::shape::{Shape, ShapeKind};
use crate::core::sift_align::{
AffineTransformation, MatchedKeypoint, SiftAlignment, sift_auto_align,
};
use crate::core::transform::{AxisSide, Margins, Scale, YAxis, clamp_axis_limits};
use crate::core::triangles::Triangles;
use crate::render::backend_wgpu::WgpuBackend;
use crate::render::gpu_curve::CurveData;
use crate::render::gpu_image::{AggregationMode, ImageData, ImagePixels, InterpolationMode};
use crate::render::save::{SaveError, SaveFormat};
use crate::widget::interaction::{DrawEvent, DrawMode, DrawParams, MouseButton, RoiDrawKind};
use crate::widget::plot_widget::{PlotInteractionMode, PlotResponse, PlotView};
use crate::widget::position_info::{
PICK_OFFSET, SNAP_THRESHOLD_DIST, Snap, SnapItem, SnapItemKind, SnappingMode,
pick_filled_histogram, pick_polyline_indices, snapping_candidates,
};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ProfileMode {
#[default]
None,
Horizontal,
Vertical,
Line,
Rectangle,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MedianFilterParams {
pub kernel_width: usize,
pub conditional: bool,
}
impl Default for MedianFilterParams {
fn default() -> Self {
Self {
kernel_width: 3,
conditional: false,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PlotDataError {
ImageDataLength { expected: usize, actual: usize },
HistogramLength { bins: usize, edges: usize },
ProfileRow { row: u32, height: u32 },
ProfileColumn { column: u32, width: u32 },
}
impl fmt::Display for PlotDataError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ImageDataLength { expected, actual } => {
write!(
f,
"image data length {actual} does not match expected {expected}"
)
}
Self::HistogramLength { bins, edges } => {
write!(
f,
"histogram with {bins} bins requires {bins_plus_one} edges",
bins_plus_one = bins + 1
)?;
write!(f, ", got {edges}")
}
Self::ProfileRow { row, height } => {
write!(f, "profile row {row} is outside image height {height}")
}
Self::ProfileColumn { column, width } => {
write!(f, "profile column {column} is outside image width {width}")
}
}
}
}
impl std::error::Error for PlotDataError {}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ValueStats {
pub count: usize,
pub finite_count: usize,
pub min: Option<f64>,
pub max: Option<f64>,
pub mean: Option<f64>,
}
impl ValueStats {
pub fn from_f64(values: &[f64]) -> Self {
let mut finite_count = 0usize;
let mut sum = 0.0f64;
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
for &v in values {
if v.is_finite() {
finite_count += 1;
sum += v;
min = min.min(v);
max = max.max(v);
}
}
Self {
count: values.len(),
finite_count,
min: (finite_count > 0).then_some(min),
max: (finite_count > 0).then_some(max),
mean: (finite_count > 0).then(|| sum / finite_count as f64),
}
}
pub fn from_f32(values: &[f32]) -> Self {
let widened: Vec<f64> = values.iter().map(|&v| v as f64).collect();
Self::from_f64(&widened)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CurveStats {
pub x: ValueStats,
pub y: ValueStats,
pub y_axis: YAxis,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ImageStats {
pub width: u32,
pub height: u32,
pub pixel_count: usize,
pub scalar: Option<ValueStats>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ImageGeometry {
pub origin: (f64, f64),
pub scale: (f64, f64),
pub alpha: f32,
}
impl Default for ImageGeometry {
fn default() -> Self {
Self {
origin: (0.0, 0.0),
scale: (1.0, 1.0),
alpha: 1.0,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ItemStats {
Curve(CurveStats),
Image(ImageStats),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PlotItemKind {
Curve,
Histogram,
Scatter,
Image,
Mask,
Triangles,
Shape,
Marker,
}
impl PlotItemKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Curve => "curve",
Self::Histogram => "histogram",
Self::Scatter => "scatter",
Self::Image => "image",
Self::Mask => "mask",
Self::Triangles => "triangles",
Self::Shape => "shape",
Self::Marker => "marker",
}
}
pub fn is_curve_like(self) -> bool {
matches!(self, Self::Curve | Self::Histogram | Self::Scatter)
}
pub fn is_image_like(self) -> bool {
matches!(self, Self::Image | Self::Mask)
}
}
fn snap_item_kind(kind: PlotItemKind) -> SnapItemKind {
match kind {
PlotItemKind::Curve => SnapItemKind::Curve,
PlotItemKind::Histogram => SnapItemKind::Histogram,
PlotItemKind::Scatter => SnapItemKind::Scatter,
_ => SnapItemKind::Other,
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum PlotEvent {
ItemAdded {
handle: ItemHandle,
kind: PlotItemKind,
},
ItemUpdated {
handle: ItemHandle,
kind: PlotItemKind,
},
ItemRemoved {
handle: ItemHandle,
kind: PlotItemKind,
},
ActiveItemChanged {
previous: Option<ItemHandle>,
current: Option<ItemHandle>,
},
LimitsChanged {
x: (f64, f64),
y: (f64, f64),
y2: Option<(f64, f64)>,
},
RoiAdded { index: usize },
RoiChanged { index: usize },
RoiCreated { index: usize },
DrawingProgress {
mode: DrawMode,
points: Vec<(f64, f64)>,
},
DrawingFinished { mode: DrawMode, params: DrawParams },
RoiAboutToBeRemoved { index: usize },
RoisCleared,
CurrentRoiChanged {
previous: Option<usize>,
current: Option<usize>,
},
RoiInteractionModeChanged {
index: usize,
mode: RoiInteractionMode,
},
MarkerMoved { handle: ItemHandle },
MarkerDragStarted { handle: ItemHandle },
MarkerDragFinished { handle: ItemHandle },
CurveClicked {
handle: ItemHandle,
index: usize,
x: f64,
y: f64,
button: MouseButton,
},
ImageClicked {
handle: ItemHandle,
col: u32,
row: u32,
button: MouseButton,
},
ItemClicked {
handle: ItemHandle,
button: MouseButton,
},
ItemHovered {
handle: ItemHandle,
kind: PlotItemKind,
label: Option<String>,
x: f64,
y: f64,
xpixel: f32,
ypixel: f32,
draggable: bool,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LegendAction {
SetActive,
MapToLeft,
MapToRight,
TogglePoints,
ToggleLines,
Remove,
Rename,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct LegendResponse {
pub selected: Option<ItemHandle>,
pub activated: Option<ItemHandle>,
pub visibility_changed: Option<ItemHandle>,
pub context_action: Option<(ItemHandle, LegendAction)>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ToolbarResponse {
pub reset_zoom: bool,
pub interaction_mode_changed: bool,
pub cursor_changed: bool,
pub grid_changed: bool,
pub minor_grid_changed: bool,
pub aspect_changed: bool,
pub x_log_changed: bool,
pub y_log_changed: bool,
pub autoscale_x_changed: bool,
pub autoscale_y_changed: bool,
pub x_inverted_changed: bool,
pub y_inverted_changed: bool,
pub show_axis_changed: bool,
pub curve_style_changed: bool,
pub zoom_in: bool,
pub zoom_out: bool,
pub zoom_back: bool,
pub zoom_axes_changed: bool,
pub save: bool,
pub copy: bool,
pub print: bool,
}
pub struct PlotWithToolbarResponse {
pub toolbar: ToolbarResponse,
pub plot: PlotResponse,
}
pub type PlotWindow = PlotWidget;
const DEFAULT_SAVE_SIZE: (u32, u32) = (1024, 768);
const DEFAULT_SAVE_DPI: u32 = 96;
fn ordered_limits(a: f64, b: f64) -> (f64, f64) {
if a <= b { (a, b) } else { (b, a) }
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ToolbarIcon {
Home,
Select,
Pan,
Zoom,
Cursor,
Grid,
MinorGrid,
Aspect,
LogX,
LogY,
InvertX,
InvertY,
ShowAxis,
CurveStyle,
ZoomIn,
ZoomOut,
ZoomBack,
Save,
Copy,
Print,
AutoscaleX,
AutoscaleY,
MedianFilter,
PixelHistogram,
}
impl ToolbarIcon {
fn size(self) -> egui::Vec2 {
match self {
Self::LogX | Self::LogY => egui::vec2(34.0, 24.0),
_ => egui::vec2(28.0, 24.0),
}
}
}
fn expected_image_len(width: u32, height: u32) -> usize {
(width as usize).saturating_mul(height as usize)
}
fn image_pixels_bit_equal(a: Option<&RetainedItemData>, b: Option<&RetainedItemData>) -> bool {
match (a, b) {
(
Some(RetainedItemData::Image { data: da, .. }),
Some(RetainedItemData::Image { data: db, .. }),
) => {
da.len() == db.len()
&& da
.iter()
.zip(db.iter())
.all(|(x, y)| x.to_bits() == y.to_bits())
}
_ => false,
}
}
fn force_odd(n: usize) -> usize {
if n <= 1 {
1
} else if n % 2 == 1 {
n
} else {
n + 1
}
}
fn print_temp_png_path(dir: &Path, pid: u32) -> PathBuf {
dir.join(format!("rsplot-print-{pid}.png"))
}
fn validate_image_len(width: u32, height: u32, actual: usize) -> Result<usize, PlotDataError> {
let expected = expected_image_len(width, height);
if actual == expected {
Ok(expected)
} else {
Err(PlotDataError::ImageDataLength { expected, actual })
}
}
fn toolbar_icon_button(
ui: &mut egui::Ui,
icon: ToolbarIcon,
selected: bool,
tooltip: &str,
) -> egui::Response {
let (rect, response) = ui.allocate_exact_size(icon.size(), egui::Sense::click());
let response = response.on_hover_text(tooltip);
if ui.is_rect_visible(rect) {
draw_toolbar_button(ui, rect, &response, selected, icon);
}
response
}
fn draw_toolbar_button(
ui: &egui::Ui,
rect: egui::Rect,
response: &egui::Response,
selected: bool,
icon: ToolbarIcon,
) {
let visuals = ui.style().interact_selectable(response, selected);
let painter = ui.painter();
let button_rect = rect.shrink(1.0);
if selected || response.hovered() || response.has_focus() {
painter.rect_filled(button_rect, 2.0, visuals.weak_bg_fill);
painter.rect_stroke(
button_rect,
2.0,
visuals.bg_stroke,
egui::StrokeKind::Inside,
);
}
let color = if !ui.is_enabled() {
ui.visuals().weak_text_color()
} else if selected {
ui.visuals().selection.stroke.color
} else {
visuals.fg_stroke.color
};
draw_toolbar_icon(painter, rect.shrink(5.0), icon, color);
}
fn draw_toolbar_icon(painter: &egui::Painter, rect: egui::Rect, icon: ToolbarIcon, color: Color32) {
let stroke = egui::Stroke::new(1.6, color);
match icon {
ToolbarIcon::Home => draw_home_icon(painter, rect, stroke),
ToolbarIcon::Select => draw_select_icon(painter, rect, stroke),
ToolbarIcon::Pan => draw_pan_icon(painter, rect, stroke),
ToolbarIcon::Zoom => draw_zoom_icon(painter, rect, stroke),
ToolbarIcon::Cursor => draw_cursor_icon(painter, rect, stroke),
ToolbarIcon::Grid => draw_grid_icon(painter, rect, stroke, 3),
ToolbarIcon::MinorGrid => draw_grid_icon(painter, rect, stroke, 4),
ToolbarIcon::Aspect => draw_center_text(painter, rect, "1:1", 11.0, color),
ToolbarIcon::LogX => draw_log_icon(painter, rect, false, stroke),
ToolbarIcon::LogY => draw_log_icon(painter, rect, true, stroke),
ToolbarIcon::InvertX => draw_axis_icon(painter, rect, "X", false, stroke),
ToolbarIcon::InvertY => draw_axis_icon(painter, rect, "Y", true, stroke),
ToolbarIcon::ShowAxis => draw_show_axis_icon(painter, rect, stroke),
ToolbarIcon::CurveStyle => draw_curve_style_icon(painter, rect, stroke),
ToolbarIcon::ZoomIn => draw_zoom_step_icon(painter, rect, stroke, true),
ToolbarIcon::ZoomOut => draw_zoom_step_icon(painter, rect, stroke, false),
ToolbarIcon::ZoomBack => draw_zoom_back_icon(painter, rect, stroke),
ToolbarIcon::Save => draw_save_icon(painter, rect, stroke),
ToolbarIcon::Copy => draw_copy_icon(painter, rect, stroke),
ToolbarIcon::Print => draw_print_icon(painter, rect, stroke),
ToolbarIcon::AutoscaleX => draw_autoscale_icon(painter, rect, "X", false, stroke),
ToolbarIcon::AutoscaleY => draw_autoscale_icon(painter, rect, "Y", true, stroke),
ToolbarIcon::MedianFilter => draw_median_filter_icon(painter, rect, stroke),
ToolbarIcon::PixelHistogram => draw_pixel_histogram_icon(painter, rect, stroke),
}
}
fn draw_pixel_histogram_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
let base = rect.bottom();
let bar_w = rect.width() / 4.0;
let heights = [0.4_f32, 0.75, 1.0];
for (i, h) in heights.iter().enumerate() {
let x = rect.left() + bar_w * (i as f32 * 1.2 + 0.2);
let top = base - rect.height() * h;
let bar = egui::Rect::from_min_max(egui::pos2(x, top), egui::pos2(x + bar_w * 0.8, base));
painter.rect_stroke(bar, 0.0, stroke, egui::StrokeKind::Inside);
}
}
fn draw_median_filter_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
let grid = rect.shrink(1.0);
let third_x = grid.width() / 3.0;
let third_y = grid.height() / 3.0;
painter.rect_stroke(grid, 0.0, stroke, egui::StrokeKind::Inside);
for i in 1..3 {
let x = grid.left() + third_x * i as f32;
painter.line_segment(
[egui::pos2(x, grid.top()), egui::pos2(x, grid.bottom())],
stroke,
);
let y = grid.top() + third_y * i as f32;
painter.line_segment(
[egui::pos2(grid.left(), y), egui::pos2(grid.right(), y)],
stroke,
);
}
let center = egui::Rect::from_min_size(
egui::pos2(grid.left() + third_x, grid.top() + third_y),
egui::vec2(third_x, third_y),
);
painter.rect_filled(center.shrink(1.0), 0.0, stroke.color);
}
fn draw_axis_arrow_icon(
painter: &egui::Painter,
rect: egui::Rect,
label: &str,
vertical: bool,
inward: bool,
font_size: f32,
stroke: egui::Stroke,
) {
let a = 2.0;
let font = egui::FontId::proportional(font_size);
if vertical {
let x = rect.left() + 2.5;
let (ytop, ybot) = (rect.top() + 1.5, rect.bottom() - 1.5);
painter.line_segment([egui::pos2(x, ytop), egui::pos2(x, ybot)], stroke);
if inward {
painter.line_segment([egui::pos2(x, ytop + a), egui::pos2(x - a, ytop)], stroke);
painter.line_segment([egui::pos2(x, ytop + a), egui::pos2(x + a, ytop)], stroke);
painter.line_segment([egui::pos2(x, ybot - a), egui::pos2(x - a, ybot)], stroke);
painter.line_segment([egui::pos2(x, ybot - a), egui::pos2(x + a, ybot)], stroke);
} else {
painter.line_segment([egui::pos2(x, ytop), egui::pos2(x - a, ytop + a)], stroke);
painter.line_segment([egui::pos2(x, ytop), egui::pos2(x + a, ytop + a)], stroke);
painter.line_segment([egui::pos2(x, ybot), egui::pos2(x - a, ybot - a)], stroke);
painter.line_segment([egui::pos2(x, ybot), egui::pos2(x + a, ybot - a)], stroke);
}
let lx = (x + a + rect.right()) * 0.5;
painter.text(
egui::pos2(lx, rect.center().y),
egui::Align2::CENTER_CENTER,
label,
font,
stroke.color,
);
} else {
let y = rect.bottom() - 2.0;
let (xl, xr) = (rect.left() + 1.5, rect.right() - 1.5);
painter.line_segment([egui::pos2(xl, y), egui::pos2(xr, y)], stroke);
if inward {
painter.line_segment([egui::pos2(xl + a, y), egui::pos2(xl, y - a)], stroke);
painter.line_segment([egui::pos2(xl + a, y), egui::pos2(xl, y + a)], stroke);
painter.line_segment([egui::pos2(xr - a, y), egui::pos2(xr, y - a)], stroke);
painter.line_segment([egui::pos2(xr - a, y), egui::pos2(xr, y + a)], stroke);
} else {
painter.line_segment([egui::pos2(xl, y), egui::pos2(xl + a, y - a)], stroke);
painter.line_segment([egui::pos2(xl, y), egui::pos2(xl + a, y + a)], stroke);
painter.line_segment([egui::pos2(xr, y), egui::pos2(xr - a, y - a)], stroke);
painter.line_segment([egui::pos2(xr, y), egui::pos2(xr - a, y + a)], stroke);
}
let ly = (rect.top() + (y - a)) * 0.5;
painter.text(
egui::pos2(rect.center().x, ly),
egui::Align2::CENTER_CENTER,
label,
font,
stroke.color,
);
}
}
fn draw_autoscale_icon(
painter: &egui::Painter,
rect: egui::Rect,
axis: &str,
vertical: bool,
stroke: egui::Stroke,
) {
draw_axis_arrow_icon(painter, rect, axis, vertical, false, 11.0, stroke);
}
fn draw_copy_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
let back = egui::Rect::from_min_max(
egui::pos2(rect.left() + 2.0, rect.top() + 2.0),
egui::pos2(rect.right() - 5.0, rect.bottom() - 5.0),
);
let front = egui::Rect::from_min_max(
egui::pos2(rect.left() + 5.0, rect.top() + 5.0),
egui::pos2(rect.right() - 2.0, rect.bottom() - 2.0),
);
painter.rect_stroke(back, 1.0, stroke, egui::StrokeKind::Inside);
painter.rect_stroke(front, 1.0, stroke, egui::StrokeKind::Inside);
}
fn draw_save_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
let body = egui::Rect::from_min_max(
egui::pos2(rect.left() + 2.0, rect.top() + 2.0),
egui::pos2(rect.right() - 2.0, rect.bottom() - 2.0),
);
painter.rect_stroke(body, 1.0, stroke, egui::StrokeKind::Inside);
let label = egui::Rect::from_min_max(
egui::pos2(body.left() + 3.0, body.top()),
egui::pos2(body.right() - 3.0, body.top() + body.height() * 0.35),
);
painter.rect_stroke(label, 0.0, stroke, egui::StrokeKind::Inside);
let notch = egui::Rect::from_min_max(
egui::pos2(body.right() - 6.0, body.top() + 1.0),
egui::pos2(body.right() - 3.0, body.top() + body.height() * 0.3),
);
painter.rect_filled(notch, 0.0, stroke.color);
}
fn draw_print_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
let cx_left = rect.left() + 5.0;
let cx_right = rect.right() - 5.0;
let feed = egui::Rect::from_min_max(
egui::pos2(cx_left + 2.0, rect.top() + 2.0),
egui::pos2(cx_right - 2.0, rect.top() + rect.height() * 0.32),
);
painter.rect_stroke(feed, 0.0, stroke, egui::StrokeKind::Inside);
let body = egui::Rect::from_min_max(
egui::pos2(cx_left, rect.top() + rect.height() * 0.34),
egui::pos2(cx_right, rect.bottom() - rect.height() * 0.18),
);
painter.rect_stroke(body, 1.0, stroke, egui::StrokeKind::Inside);
let tray = egui::Rect::from_min_max(
egui::pos2(cx_left + 2.0, rect.bottom() - rect.height() * 0.34),
egui::pos2(cx_right - 2.0, rect.bottom() - 2.0),
);
painter.rect_stroke(tray, 0.0, stroke, egui::StrokeKind::Inside);
}
fn draw_zoom_back_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
let c = rect.center();
let left = egui::pos2(rect.left() + 2.0, c.y);
let right = egui::pos2(rect.right() - 2.0, c.y);
painter.line_segment([left, right], stroke);
let arrow = 4.0;
painter.line_segment([left, left + egui::vec2(arrow, -arrow)], stroke);
painter.line_segment([left, left + egui::vec2(arrow, arrow)], stroke);
}
fn draw_zoom_step_icon(
painter: &egui::Painter,
rect: egui::Rect,
stroke: egui::Stroke,
plus: bool,
) {
let radius = rect.width().min(rect.height()) * 0.28;
let center = egui::pos2(rect.left() + radius + 2.0, rect.top() + radius + 2.0);
painter.circle_stroke(center, radius, stroke);
painter.line_segment(
[
center + egui::vec2(radius * 0.7, radius * 0.7),
egui::pos2(rect.right() - 2.0, rect.bottom() - 2.0),
],
stroke,
);
let bar = radius * 0.55;
painter.line_segment(
[center - egui::vec2(bar, 0.0), center + egui::vec2(bar, 0.0)],
stroke,
);
if plus {
painter.line_segment(
[center - egui::vec2(0.0, bar), center + egui::vec2(0.0, bar)],
stroke,
);
}
}
fn draw_curve_style_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
let y_top = rect.top() + rect.height() * 0.35;
let y_bot = rect.top() + rect.height() * 0.65;
let left = rect.left() + 1.0;
let right = rect.right() - 1.0;
let mut x = left;
while x < right {
let seg_end = (x + 3.0).min(right);
painter.line_segment([egui::pos2(x, y_top), egui::pos2(seg_end, y_top)], stroke);
x += 5.0;
}
let mut x = left;
while x < right {
painter.line_segment([egui::pos2(x, y_bot), egui::pos2(x + 1.0, y_bot)], stroke);
x += 3.0;
}
}
fn draw_show_axis_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
let origin = egui::pos2(rect.left() + 3.0, rect.bottom() - 3.0);
let x_end = egui::pos2(rect.right() - 1.0, rect.bottom() - 3.0);
let y_end = egui::pos2(rect.left() + 3.0, rect.top() + 1.0);
painter.line_segment([origin, x_end], stroke);
painter.line_segment([origin, y_end], stroke);
let arrow = 3.0;
painter.line_segment([x_end, x_end + egui::vec2(-arrow, -arrow)], stroke);
painter.line_segment([x_end, x_end + egui::vec2(-arrow, arrow)], stroke);
painter.line_segment([y_end, y_end + egui::vec2(-arrow, arrow)], stroke);
painter.line_segment([y_end, y_end + egui::vec2(arrow, arrow)], stroke);
}
fn draw_home_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
let top = egui::pos2(rect.center().x, rect.top());
let left_roof = egui::pos2(rect.left(), rect.center().y - 1.0);
let right_roof = egui::pos2(rect.right(), rect.center().y - 1.0);
painter.line_segment([left_roof, top], stroke);
painter.line_segment([top, right_roof], stroke);
let house = egui::Rect::from_min_max(
egui::pos2(rect.left() + 3.0, rect.center().y - 1.0),
egui::pos2(rect.right() - 3.0, rect.bottom()),
);
painter.rect_stroke(house, 1.0, stroke, egui::StrokeKind::Inside);
}
fn draw_select_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
let points = vec![
egui::pos2(rect.left() + 2.0, rect.top() + 1.0),
egui::pos2(rect.left() + 2.0, rect.bottom() - 2.0),
egui::pos2(rect.left() + 7.0, rect.bottom() - 6.0),
egui::pos2(rect.left() + 10.0, rect.bottom() - 1.0),
egui::pos2(rect.left() + 13.0, rect.bottom() - 2.5),
egui::pos2(rect.left() + 10.0, rect.bottom() - 7.0),
egui::pos2(rect.right() - 2.0, rect.bottom() - 7.0),
];
painter.add(egui::Shape::convex_polygon(
points,
Color32::TRANSPARENT,
stroke,
));
}
fn draw_pan_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
let c = rect.center();
let arrow = 3.0;
painter.line_segment(
[egui::pos2(rect.left(), c.y), egui::pos2(rect.right(), c.y)],
stroke,
);
painter.line_segment(
[egui::pos2(c.x, rect.top()), egui::pos2(c.x, rect.bottom())],
stroke,
);
for (tip, a, b) in [
(
egui::pos2(rect.left(), c.y),
egui::pos2(rect.left() + arrow, c.y - arrow),
egui::pos2(rect.left() + arrow, c.y + arrow),
),
(
egui::pos2(rect.right(), c.y),
egui::pos2(rect.right() - arrow, c.y - arrow),
egui::pos2(rect.right() - arrow, c.y + arrow),
),
(
egui::pos2(c.x, rect.top()),
egui::pos2(c.x - arrow, rect.top() + arrow),
egui::pos2(c.x + arrow, rect.top() + arrow),
),
(
egui::pos2(c.x, rect.bottom()),
egui::pos2(c.x - arrow, rect.bottom() - arrow),
egui::pos2(c.x + arrow, rect.bottom() - arrow),
),
] {
painter.line_segment([tip, a], stroke);
painter.line_segment([tip, b], stroke);
}
}
fn draw_zoom_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
let radius = rect.width().min(rect.height()) * 0.28;
let center = egui::pos2(rect.left() + radius + 2.0, rect.top() + radius + 2.0);
painter.circle_stroke(center, radius, stroke);
painter.line_segment(
[
center + egui::vec2(radius * 0.7, radius * 0.7),
egui::pos2(rect.right() - 2.0, rect.bottom() - 2.0),
],
stroke,
);
}
fn draw_cursor_icon(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
let center = rect.center();
painter.line_segment(
[
egui::pos2(rect.left(), center.y),
egui::pos2(rect.right(), center.y),
],
stroke,
);
painter.line_segment(
[
egui::pos2(center.x, rect.top()),
egui::pos2(center.x, rect.bottom()),
],
stroke,
);
painter.circle_stroke(center, rect.width().min(rect.height()) * 0.28, stroke);
}
fn draw_grid_icon(
painter: &egui::Painter,
rect: egui::Rect,
stroke: egui::Stroke,
divisions: usize,
) {
painter.rect_stroke(rect, 0.0, stroke, egui::StrokeKind::Inside);
for index in 1..divisions {
let t = index as f32 / divisions as f32;
let x = egui::lerp(rect.left()..=rect.right(), t);
let y = egui::lerp(rect.top()..=rect.bottom(), t);
painter.line_segment(
[egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())],
stroke,
);
painter.line_segment(
[egui::pos2(rect.left(), y), egui::pos2(rect.right(), y)],
stroke,
);
}
}
fn draw_log_icon(painter: &egui::Painter, rect: egui::Rect, vertical: bool, stroke: egui::Stroke) {
draw_axis_arrow_icon(painter, rect, "log", vertical, false, 9.0, stroke);
}
fn draw_axis_icon(
painter: &egui::Painter,
rect: egui::Rect,
axis: &str,
vertical: bool,
stroke: egui::Stroke,
) {
draw_axis_arrow_icon(painter, rect, axis, vertical, true, 11.0, stroke);
}
fn draw_center_text(
painter: &egui::Painter,
rect: egui::Rect,
text: &str,
size: f32,
color: Color32,
) {
painter.text(
rect.center(),
egui::Align2::CENTER_CENTER,
text,
egui::FontId::proportional(size),
color,
);
}
fn mask_rgba_pixels(mask: &[bool], color: Color32) -> Vec<[u8; 4]> {
let rgba = color.to_srgba_unmultiplied();
mask.iter()
.map(|masked| if *masked { rgba } else { [0, 0, 0, 0] })
.collect()
}
pub fn histogram_step_values(
edges: &[f64],
counts: &[f64],
) -> Result<(Vec<f64>, Vec<f64>), PlotDataError> {
if edges.len() != counts.len() + 1 {
return Err(PlotDataError::HistogramLength {
bins: counts.len(),
edges: edges.len(),
});
}
if counts.is_empty() {
return Ok((Vec::new(), Vec::new()));
}
let mut x = Vec::with_capacity(counts.len() * 2);
let mut y = Vec::with_capacity(counts.len() * 2);
for (index, count) in counts.iter().copied().enumerate() {
x.push(edges[index]);
y.push(count);
x.push(edges[index + 1]);
y.push(count);
}
Ok((x, y))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum HistogramAlign {
Left,
#[default]
Center,
Right,
}
pub fn histogram_edges(positions: &[f64], align: HistogramAlign) -> Vec<f64> {
if positions.is_empty() {
return Vec::new();
}
let n = positions.len();
match align {
HistogramAlign::Left => {
let width = if n > 1 {
positions[n - 1] - positions[n - 2]
} else {
1.0
};
let mut edges = positions.to_vec();
edges.push(positions[n - 1] + width);
edges
}
HistogramAlign::Right => {
let width = if n > 1 {
positions[1] - positions[0]
} else {
1.0
};
let mut edges = Vec::with_capacity(n + 1);
edges.push(positions[0] - width);
edges.extend_from_slice(positions);
edges
}
HistogramAlign::Center => {
let right = histogram_edges(positions, HistogramAlign::Left);
let mut widths: Vec<f64> = right.windows(2).map(|w| (w[1] - w[0]) / 2.0).collect();
if let Some(&last) = widths.last() {
widths.push(last);
}
right
.iter()
.zip(widths.iter())
.map(|(&edge, &width)| edge - width)
.collect()
}
}
}
pub fn revert_compute_edges(edges: &[f64], align: HistogramAlign) -> Vec<f64> {
if edges.len() < 2 {
return Vec::new();
}
match align {
HistogramAlign::Left => edges[..edges.len() - 1].to_vec(),
HistogramAlign::Right => edges[1..].to_vec(),
HistogramAlign::Center => edges.windows(2).map(|w| (w[0] + w[1]) / 2.0).collect(),
}
}
pub fn pick_histogram(
edges: &[f64],
values: &[f64],
baseline: f64,
x_data: f64,
y_data: f64,
) -> Option<usize> {
if values.is_empty() || edges.len() != values.len() + 1 {
return None;
}
let xmin = edges[0];
let xmax = edges[edges.len() - 1];
let mut vmin = f64::INFINITY;
let mut vmax = f64::NEG_INFINITY;
for &v in values {
if v.is_finite() {
vmin = vmin.min(v);
vmax = vmax.max(v);
}
}
if !vmin.is_finite() {
return None;
}
let ymin = vmin.min(0.0);
let ymax = vmax.max(0.0);
if x_data <= xmin || x_data >= xmax || y_data <= ymin || y_data >= ymax {
return None;
}
let index = edges
.partition_point(|&e| e < x_data)
.saturating_sub(1)
.min(values.len() - 1);
let value = values[index];
let hit = (baseline <= value && baseline <= y_data && y_data <= value)
|| (value < baseline && value <= y_data && y_data <= baseline);
if hit { Some(index) } else { None }
}
pub fn horizontal_profile_values(
width: u32,
height: u32,
data: &[f32],
row: u32,
) -> Result<Vec<f64>, PlotDataError> {
validate_image_len(width, height, data.len())?;
if row >= height {
return Err(PlotDataError::ProfileRow { row, height });
}
let width = width as usize;
let start = row as usize * width;
Ok(data[start..start + width]
.iter()
.map(|value| *value as f64)
.collect())
}
pub fn vertical_profile_values(
width: u32,
height: u32,
data: &[f32],
column: u32,
) -> Result<Vec<f64>, PlotDataError> {
validate_image_len(width, height, data.len())?;
if column >= width {
return Err(PlotDataError::ProfileColumn { column, width });
}
let width = width as usize;
let column = column as usize;
Ok((0..height as usize)
.map(|row| data[row * width + column] as f64)
.collect())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ProfileMethod {
#[default]
Mean,
Sum,
}
pub fn aligned_profile_values(
width: u32,
height: u32,
data: &[f32],
position: f64,
roi_width: u32,
horizontal: bool,
method: ProfileMethod,
) -> Result<Vec<f64>, PlotDataError> {
validate_image_len(width, height, data.len())?;
let w = width as usize;
let h = height as usize;
if w == 0 || h == 0 {
return Ok(Vec::new());
}
let (band_dim, profile_len) = if horizontal { (h, w) } else { (w, h) };
let band = (roi_width.max(1) as usize).min(band_dim);
let start_f = position.trunc() + 0.5 - band as f64 / 2.0;
let start = (start_f.trunc() as i64).clamp(0, (band_dim - band) as i64) as usize;
let end = start + band;
let profile = (0..profile_len)
.map(|p| {
let mut sum = 0.0;
let mut count = 0usize;
for b in start..end {
let idx = if horizontal { b * w + p } else { p * w + b };
let v = data[idx] as f64;
if !v.is_nan() {
sum += v;
count += 1;
}
}
match method {
ProfileMethod::Mean if count == 0 => f64::NAN,
ProfileMethod::Mean => sum / count as f64,
ProfileMethod::Sum => sum,
}
})
.collect();
Ok(profile)
}
pub fn line_profile_values(
width: u32,
height: u32,
data: &[f32],
start: (f64, f64),
end: (f64, f64),
) -> Result<(Vec<f64>, Vec<f64>), PlotDataError> {
validate_image_len(width, height, data.len())?;
let (x0, y0) = start;
let (x1, y1) = end;
let dx = x1 - x0;
let dy = y1 - y0;
let dist = (dx * dx + dy * dy).sqrt();
let n_points = dist.ceil() as usize + 1;
let mut x_vals = Vec::with_capacity(n_points);
let mut y_vals = Vec::with_capacity(n_points);
let w = width as i64;
let h = height as i64;
for i in 0..n_points {
let t = if n_points > 1 {
i as f64 / (n_points - 1) as f64
} else {
0.0
};
let x = x0 + t * dx;
let y = y0 + t * dy;
let col = x.round() as i64;
let row = y.round() as i64;
let val = if col >= 0 && col < w && row >= 0 && row < h {
data[(row as usize) * (width as usize) + (col as usize)] as f64
} else {
f64::NAN
};
x_vals.push(t * dist);
y_vals.push(val);
}
Ok((x_vals, y_vals))
}
fn bilinear_sample(width: usize, height: usize, data: &[f32], col: f64, row: f64) -> f64 {
let d0 = row.clamp(0.0, height as f64 - 1.0);
let d1 = col.clamp(0.0, width as f64 - 1.0);
let r0 = d0.floor();
let r1 = d0.ceil();
let c0 = d1.floor();
let c1 = d1.ceil();
let (i0, i1) = (r0 as usize, r1 as usize); let (j0, j1) = (c0 as usize, c1 as usize); let at = |i: usize, j: usize| data[i * width + j] as f64;
if i0 == i1 && j0 == j1 {
at(i0, j0)
} else if i0 == i1 {
at(i0, j0) * (c1 - d1) + at(i0, j1) * (d1 - c0)
} else if j0 == j1 {
at(i0, j0) * (r1 - d0) + at(i1, j0) * (d0 - r0)
} else {
at(i0, j0) * (r1 - d0) * (c1 - d1)
+ at(i1, j0) * (d0 - r0) * (c1 - d1)
+ at(i0, j1) * (r1 - d0) * (d1 - c0)
+ at(i1, j1) * (d0 - r0) * (d1 - c0)
}
}
pub fn line_profile_band(
width: u32,
height: u32,
data: &[f32],
start: (f64, f64),
end: (f64, f64),
linewidth: u32,
method: ProfileMethod,
) -> Result<(Vec<f64>, Vec<f64>), PlotDataError> {
validate_image_len(width, height, data.len())?;
let w = width as usize;
let h = height as usize;
let (src_col0, src_row0) = start;
let (dst_col, dst_row) = end;
if src_row0 == dst_row && src_col0 == dst_col {
return Ok((
vec![0.0],
vec![bilinear_sample(w, h, data, src_col0, src_row0)],
));
}
let lw = linewidth.max(1) as usize;
let d_row = dst_row - src_row0;
let d_col = dst_col - src_col0;
let length = (d_row * d_row + d_col * d_col).sqrt();
let row_width = d_col / length;
let col_width = -d_row / length;
let count = (length + 1.0).ceil() as usize; let denom = (count - 1) as f64; let step_row = d_row / denom;
let step_col = d_col / denom;
let src_row = src_row0 - row_width * (lw as f64 - 1.0) / 2.0;
let src_col = src_col0 - col_width * (lw as f64 - 1.0) / 2.0;
let mut x_vals = Vec::with_capacity(count);
let mut y_vals = Vec::with_capacity(count);
for i in 0..count {
let row = src_row + i as f64 * step_row;
let col = src_col + i as f64 * step_col;
let mut sum = 0.0;
let mut cnt = 0usize;
for j in 0..lw {
let nr = row + j as f64 * row_width;
let nc = col + j as f64 * col_width;
if nc >= 0.0 && nc < width as f64 && nr >= 0.0 && nr < height as f64 {
let val = bilinear_sample(w, h, data, nc, nr);
if val.is_finite() {
cnt += 1;
sum += val;
}
}
}
let value = match (cnt > 0, method) {
(true, ProfileMethod::Mean) => sum / cnt as f64,
(true, ProfileMethod::Sum) => sum,
(false, ProfileMethod::Mean) => f64::NAN,
(false, ProfileMethod::Sum) => 0.0,
};
x_vals.push(i as f64 / denom * length);
y_vals.push(value);
}
Ok((x_vals, y_vals))
}
fn aligned_partial_profile(
width: usize,
height: usize,
data: &[f32],
row_range: (i64, i64),
col_range: (i64, i64),
axis: u8,
method: ProfileMethod,
) -> Vec<f64> {
let profile_range = if axis == 0 { col_range } else { row_range };
let profile_length = (profile_range.1 - profile_range.0).unsigned_abs() as usize;
if profile_length == 0 {
return Vec::new();
}
let row_start = row_range.0.clamp(0, height as i64) as usize;
let row_end = row_range.1.clamp(0, height as i64) as usize;
let col_start = col_range.0.clamp(0, width as i64) as usize;
let col_end = col_range.1.clamp(0, width as i64) as usize;
let mut profile = vec![0.0f64; profile_length];
let offset = (-profile_range.0.min(0)) as usize;
if axis == 0 {
let band = row_end.saturating_sub(row_start);
if band > 0 {
for (k, col) in (col_start..col_end).enumerate() {
let sum: f64 = (row_start..row_end)
.map(|row| data[row * width + col] as f64)
.sum();
let v = match method {
ProfileMethod::Mean => sum / band as f64,
ProfileMethod::Sum => sum,
};
if let Some(slot) = profile.get_mut(offset + k) {
*slot = v;
}
}
}
} else {
let band = col_end.saturating_sub(col_start);
if band > 0 {
for (k, row) in (row_start..row_end).enumerate() {
let sum: f64 = (col_start..col_end)
.map(|col| data[row * width + col] as f64)
.sum();
let v = match method {
ProfileMethod::Mean => sum / band as f64,
ProfileMethod::Sum => sum,
};
if let Some(slot) = profile.get_mut(offset + k) {
*slot = v;
}
}
}
}
profile
}
pub fn free_line_profile(
width: u32,
height: u32,
data: &[f32],
start: (f64, f64),
end: (f64, f64),
linewidth: u32,
method: ProfileMethod,
) -> Result<(Vec<f64>, Vec<f64>), PlotDataError> {
validate_image_len(width, height, data.len())?;
let (sc, sr) = start;
let (ec, er) = end;
let roi_width = linewidth.max(1) as f64;
let aligned =
(sr.trunc() as i64) == (er.trunc() as i64) || (sc.trunc() as i64) == (ec.trunc() as i64);
if !aligned {
let (mut a, mut b) = ((sc, sr), (ec, er));
if a.0 > b.0 || (a.0 == b.0 && a.1 > b.1) {
std::mem::swap(&mut a, &mut b);
}
let (_arc, values) = line_profile_band(
width,
height,
data,
(a.0 - 0.5, a.1 - 0.5),
(b.0 - 0.5, b.1 - 0.5),
linewidth,
method,
)?;
let x = linspace_inclusive(a.0, b.0, values.len());
return Ok((x, values));
}
let mut s = (sr.trunc() as i64, sc.trunc() as i64); let mut e = (er.trunc() as i64, ec.trunc() as i64);
if s.0 > e.0 || s.1 > e.1 {
std::mem::swap(&mut s, &mut e);
}
let (w, h) = (width as usize, height as usize);
let (profile, start_coord) = if s.0 == e.0 {
let row_range = (
(s.0 as f64 + 0.5 - 0.5 * roi_width).trunc() as i64,
(s.0 as f64 + 0.5 + 0.5 * roi_width).trunc() as i64,
);
let col_range = (s.1, e.1 + 1);
(
aligned_partial_profile(w, h, data, row_range, col_range, 0, method),
s.1,
)
} else {
let row_range = (s.0, e.0 + 1);
let col_range = (
(s.1 as f64 + 0.5 - 0.5 * roi_width).trunc() as i64,
(s.1 as f64 + 0.5 + 0.5 * roi_width).trunc() as i64,
);
(
aligned_partial_profile(w, h, data, row_range, col_range, 1, method),
s.0,
)
};
let x = (0..profile.len())
.map(|i| (start_coord + i as i64) as f64)
.collect();
Ok((x, profile))
}
fn linspace_inclusive(start: f64, stop: f64, n: usize) -> Vec<f64> {
match n {
0 => Vec::new(),
1 => vec![start],
_ => {
let step = (stop - start) / (n - 1) as f64;
(0..n).map(|i| start + step * i as f64).collect()
}
}
}
pub fn rect_profile_values(
width: u32,
height: u32,
data: &[f32],
rect: (f64, f64, f64, f64),
horizontal: bool,
method: ProfileMethod,
) -> Result<(Vec<f64>, Vec<f64>), PlotDataError> {
validate_image_len(width, height, data.len())?;
let (x_min, x_max, y_min, y_max) = rect;
let col_min = x_min.round().max(0.0) as usize;
let col_max = x_max.round().min(width as f64 - 1.0) as usize;
let row_min = y_min.round().max(0.0) as usize;
let row_max = y_max.round().min(height as f64 - 1.0) as usize;
if col_min > col_max
|| row_min > row_max
|| col_max >= width as usize
|| row_max >= height as usize
{
return Ok((vec![], vec![]));
}
let reduce = |sum: f64, count: usize| match method {
ProfileMethod::Mean if count == 0 => f64::NAN,
ProfileMethod::Mean => sum / count as f64,
ProfileMethod::Sum => sum,
};
if horizontal {
let mut x_vals = Vec::with_capacity(col_max - col_min + 1);
let mut y_vals = Vec::with_capacity(col_max - col_min + 1);
for col in col_min..=col_max {
let mut sum = 0.0;
let mut count = 0usize;
for row in row_min..=row_max {
let v = data[row * width as usize + col] as f64;
if !v.is_nan() {
sum += v;
count += 1;
}
}
x_vals.push(col as f64);
y_vals.push(reduce(sum, count));
}
Ok((x_vals, y_vals))
} else {
let mut x_vals = Vec::with_capacity(row_max - row_min + 1);
let mut y_vals = Vec::with_capacity(row_max - row_min + 1);
for row in row_min..=row_max {
let mut sum = 0.0;
let mut count = 0usize;
for col in col_min..=col_max {
let v = data[row * width as usize + col] as f64;
if !v.is_nan() {
sum += v;
count += 1;
}
}
x_vals.push(row as f64);
y_vals.push(reduce(sum, count));
}
Ok((x_vals, y_vals))
}
}
fn fmt_stat(value: Option<f64>) -> String {
value.map_or_else(|| "n/a".to_owned(), |value| format!("{value:.6}"))
}
fn show_value_stats(ui: &mut egui::Ui, label: &str, stats: ValueStats) {
ui.label(format!(
"{label}: n={} finite={} min={} max={} mean={}",
stats.count,
stats.finite_count,
fmt_stat(stats.min),
fmt_stat(stats.max),
fmt_stat(stats.mean)
));
}
#[derive(Clone, Copy, Debug, Default)]
struct Bounds1D {
min: f64,
max: f64,
}
impl Bounds1D {
fn new(min: f64, max: f64) -> Option<Self> {
(min.is_finite() && max.is_finite()).then(|| Self {
min: min.min(max),
max: min.max(max),
})
}
fn include(&mut self, other: Self) {
self.min = self.min.min(other.min);
self.max = self.max.max(other.max);
}
}
#[derive(Clone, Debug, Default)]
struct DataBounds {
x: Option<Bounds1D>,
y_left: Option<Bounds1D>,
y_right: Option<Bounds1D>,
extra: Vec<Option<Bounds1D>>,
}
impl DataBounds {
fn include(&mut self, x: Bounds1D, y: Bounds1D, axis: YAxis) {
include_axis(&mut self.x, x);
match axis {
YAxis::Left => include_axis(&mut self.y_left, y),
YAxis::Right => include_axis(&mut self.y_right, y),
YAxis::Extra(n) => {
if self.extra.len() <= n {
self.extra.resize(n + 1, None);
}
include_axis(&mut self.extra[n], y);
}
}
}
fn include_bounds(&mut self, other: &Self) {
if let Some(x) = other.x {
include_axis(&mut self.x, x);
}
if let Some(y) = other.y_left {
include_axis(&mut self.y_left, y);
}
if let Some(y) = other.y_right {
include_axis(&mut self.y_right, y);
}
for (n, slot) in other.extra.iter().enumerate() {
if let Some(y) = slot {
if self.extra.len() <= n {
self.extra.resize(n + 1, None);
}
include_axis(&mut self.extra[n], *y);
}
}
}
}
fn include_axis(slot: &mut Option<Bounds1D>, bounds: Bounds1D) {
match slot {
Some(existing) => existing.include(bounds),
None => *slot = Some(bounds),
}
}
fn extra_data_ranges(bounds: &DataBounds) -> Vec<Option<(f64, f64)>> {
bounds
.extra
.iter()
.map(|b| b.map(|b| (b.min, b.max)))
.collect()
}
fn raw_data_range_from_bounds(bounds: &DataBounds) -> DataRange {
DataRange {
x: bounds.x.map(|b| (b.min, b.max)),
y: bounds.y_left.map(|b| (b.min, b.max)),
y2: bounds.y_right.map(|b| (b.min, b.max)),
}
}
fn log_filtered_curve_range(
x: &[f64],
y: &[f64],
x_log: bool,
y_log: bool,
want_x: bool,
) -> Option<(f64, f64)> {
let mut range: Option<(f64, f64)> = None;
for (&xv, &yv) in x.iter().zip(y) {
if (x_log && xv <= 0.0) || (y_log && yv <= 0.0) {
continue;
}
let v = if want_x { xv } else { yv };
if !v.is_finite() {
continue;
}
range = Some(match range {
None => (v, v),
Some((lo, hi)) => (lo.min(v), hi.max(v)),
});
}
range
}
fn record_log_range(
record: &ItemRecord,
x_log: bool,
y_log: bool,
want_x: bool,
) -> Option<(f64, f64)> {
let xb = record.bounds.x?;
let yb = record.bounds.y_left.or(record.bounds.y_right)?;
if (!x_log || xb.min > 0.0) && (!y_log || yb.min > 0.0) {
let b = if want_x { xb } else { record.bounds.y_left? };
return Some((b.min, b.max));
}
if !want_x && record.bounds.y_left.is_none() {
return None;
}
match &record.data {
Some(RetainedItemData::Curve { x, y }) => {
log_filtered_curve_range(x, y, x_log, y_log, want_x)
}
Some(RetainedItemData::Histogram { edges, counts, .. }) => {
let (sx, sy) = histogram_step_values(edges, counts).ok()?;
log_filtered_curve_range(&sx, &sy, x_log, y_log, want_x)
}
Some(RetainedItemData::Scatter { x, y, .. }) => {
log_filtered_curve_range(x, y, x_log, y_log, want_x)
}
_ => None,
}
}
fn finite_bounds(values: &[f64]) -> Option<Bounds1D> {
values
.iter()
.copied()
.filter(|v| v.is_finite())
.fold(None, |bounds, value| match bounds {
Some(mut bounds) => {
bounds.include(Bounds1D::new(value, value).expect("finite value"));
Some(bounds)
}
None => Bounds1D::new(value, value),
})
}
fn image_bounds(image: &ImageSpec<'_>) -> Option<(Bounds1D, Bounds1D)> {
let (width, height) = match image {
ImageSpec {
pixels: ImagePixelsSpec::Scalar { width, height, .. },
..
}
| ImageSpec {
pixels: ImagePixelsSpec::Rgba { width, height, .. },
..
} => (*width, *height),
};
let x0 = image.origin.0;
let y0 = image.origin.1;
let x1 = x0 + image.scale.0 * width as f64;
let y1 = y0 + image.scale.1 * height as f64;
Some((Bounds1D::new(x0, x1)?, Bounds1D::new(y0, y1)?))
}
fn error_adjusted_bounds(values: &[f64], error: Option<&ErrorBars>) -> Option<Bounds1D> {
let Some(error) = error else {
return finite_bounds(values);
};
let mut min_ = f64::INFINITY;
let mut max_ = f64::NEG_INFINITY;
for (i, &v) in values.iter().enumerate() {
let (lower, upper) = error.magnitudes(i);
let low = if lower.is_nan() { v } else { v - lower };
if low.is_finite() {
min_ = min_.min(low);
}
let high = if upper.is_nan() { v } else { v + upper };
if high.is_finite() {
max_ = max_.max(high);
}
}
Bounds1D::new(min_, max_)
}
fn curve_spec_bounds(spec: &CurveSpec<'_>) -> DataBounds {
let mut bounds = DataBounds::default();
if let (Some(x), Some(y)) = (
error_adjusted_bounds(spec.x, spec.x_error.as_ref()),
error_adjusted_bounds(spec.y, spec.y_error.as_ref()),
) {
bounds.include(x, y, spec.y_axis);
}
bounds
}
fn curve_spec_stats(spec: &CurveSpec<'_>) -> ItemStats {
ItemStats::Curve(CurveStats {
x: ValueStats::from_f64(spec.x),
y: ValueStats::from_f64(spec.y),
y_axis: spec.y_axis,
})
}
fn curve_spec_retained_data(spec: &CurveSpec<'_>) -> RetainedItemData {
RetainedItemData::Curve {
x: spec.x.to_vec(),
y: spec.y.to_vec(),
}
}
fn histogram_retained_data(
edges: &[f64],
counts: &[f64],
align: HistogramAlign,
) -> RetainedItemData {
RetainedItemData::Histogram {
edges: edges.to_vec(),
counts: counts.to_vec(),
x: revert_compute_edges(edges, align),
centers: revert_compute_edges(edges, HistogramAlign::Center),
}
}
fn apply_curve_alpha(color: Color32, alpha: f32) -> Color32 {
crate::core::color::scale_alpha(color, alpha)
}
fn compose_per_point_alpha(colors: &mut [Color32], alpha: &[f64]) {
for (color, &a) in colors.iter_mut().zip(alpha) {
let [r, g, b, sa] = color.to_srgba_unmultiplied();
let sa = ((a.clamp(0.0, 1.0) as f32) * (sa as f32)).round() as u8;
*color = Color32::from_rgba_unmultiplied(r, g, b, sa);
}
}
fn point_colors(values: &[f64], colormap: &Colormap, alpha: Option<&[f64]>) -> Vec<Color32> {
let mut colors: Vec<Color32> = values
.iter()
.map(|&v| {
let [r, g, b, a] = colormap.lut[colormap.lut_index(v)];
Color32::from_rgba_unmultiplied(r, g, b, a)
})
.collect();
if let Some(alpha) = alpha {
compose_per_point_alpha(&mut colors, alpha);
}
colors
}
fn clamp_alpha(mut alpha: Vec<f64>) -> Vec<f64> {
for a in &mut alpha {
*a = a.clamp(0.0, 1.0);
}
alpha
}
fn curve_data_from_spec_hl(spec: &CurveSpec<'_>) -> CurveData {
let color = match &spec.color {
CurveColor::Uniform(color) => apply_curve_alpha(*color, spec.alpha),
CurveColor::PerVertex(colors) => colors
.first()
.copied()
.map(|color| apply_curve_alpha(color, spec.alpha))
.unwrap_or(Color32::WHITE),
};
let mut curve = CurveData::new(spec.x.to_vec(), spec.y.to_vec(), color)
.with_width(spec.line_width)
.with_line_style(spec.line_style.clone())
.with_marker_size(spec.symbol_size)
.with_y_axis(spec.y_axis);
if let CurveColor::PerVertex(colors) = &spec.color {
curve = curve.with_colors(
colors
.iter()
.copied()
.map(|color| apply_curve_alpha(color, spec.alpha))
.collect(),
);
}
if let Some(gap_color) = spec.gap_color {
curve = curve.with_gap_color(apply_curve_alpha(gap_color, spec.alpha));
}
if let Some(symbol) = spec.symbol {
curve = curve.with_symbol(symbol);
}
if let Some(error) = &spec.x_error {
curve = curve.with_x_error(error.clone());
}
if let Some(error) = &spec.y_error {
curve = curve.with_y_error(error.clone());
}
if spec.fill {
curve = curve.with_fill(spec.baseline.clone());
}
curve
}
fn retained_data_to_stats_input(
data: &RetainedItemData,
) -> crate::widget::stats_widget::StatsInput<'_> {
use crate::widget::stats_widget::StatsInput;
match data {
RetainedItemData::Curve { x, y } => StatsInput::Curve { xs: x, ys: y },
RetainedItemData::Histogram { x, counts, .. } => StatsInput::Histogram { xs: x, counts },
RetainedItemData::Scatter { x, y, values } => StatsInput::Scatter {
xs: x,
ys: y,
values,
},
RetainedItemData::Image {
data,
width,
height,
origin,
scale,
..
} => StatsInput::Image {
data,
width: *width,
height: *height,
origin: *origin,
scale: *scale,
},
}
}
fn autoscaled_colormap(base: &Colormap, mode: AutoscaleMode, pixels: &[f64]) -> Colormap {
let (vmin, vmax) = base.autoscale_range(mode, pixels);
let mut cm = base.clone();
cm.vmin = vmin;
cm.vmax = vmax;
cm
}
fn retained_curve_xy(data: &RetainedItemData) -> Option<(&[f64], &[f64])> {
match data {
RetainedItemData::Curve { x, y } => Some((x, y)),
RetainedItemData::Histogram {
centers, counts, ..
} => Some((centers, counts)),
RetainedItemData::Scatter { .. } | RetainedItemData::Image { .. } => None,
}
}
fn roi_stats_rows(
rois: &[ManagedRoi],
data: &RetainedItemData,
) -> Vec<crate::widget::roi_stats_widget::RoiStatsRow> {
use crate::widget::roi_stats::{curve_roi_stats, image_roi_stats};
use crate::widget::roi_stats_widget::RoiStatsRow;
rois.iter()
.enumerate()
.map(|(index, managed)| {
let stats = match data {
RetainedItemData::Image {
data,
width,
height,
origin,
scale,
..
} => {
let pixels: Vec<f32> = data.iter().map(|&v| v as f32).collect();
image_roi_stats(
&managed.roi,
&pixels,
*width,
*height,
[origin.0, origin.1],
[scale.0, scale.1],
)
}
RetainedItemData::Curve { x, y } => curve_roi_stats(&managed.roi, x, y),
RetainedItemData::Histogram { x, counts, .. } => {
curve_roi_stats(&managed.roi, x, counts)
}
RetainedItemData::Scatter { x, values, .. } => {
curve_roi_stats(&managed.roi, x, values)
}
};
let label = if managed.name.is_empty() {
format!("ROI {index}")
} else {
managed.name.clone()
};
RoiStatsRow { label, stats }
})
.collect()
}
fn curve_roi_rows(
rois: &[ManagedRoi],
x: &[f64],
y: &[f64],
) -> Vec<crate::widget::curves_roi_widget::CurveRoiRow> {
use crate::widget::curves_roi_widget::CurveRoiRow;
use crate::widget::roi_stats::{curve_roi_counts, roi_x_span};
rois.iter()
.enumerate()
.filter_map(|(index, managed)| {
let (from, to) = roi_x_span(&managed.roi)?;
let counts = curve_roi_counts(&managed.roi, x, y)?;
let label = if managed.name.is_empty() {
format!("ROI {index}")
} else {
managed.name.clone()
};
Some(CurveRoiRow {
label,
from,
to,
counts,
})
})
.collect()
}
fn image_spec_retained_data(spec: &ImageSpec<'_>) -> Option<RetainedItemData> {
match &spec.pixels {
ImagePixelsSpec::Scalar {
width,
height,
data,
colormap,
} => Some(RetainedItemData::Image {
data: data.iter().map(|&v| v as f64).collect(),
width: *width as usize,
height: *height as usize,
origin: spec.origin,
scale: spec.scale,
colormap: colormap.clone(),
alpha: spec.alpha,
interpolation: spec.interpolation,
aggregation: spec.aggregation,
aggregation_block: spec.aggregation_block,
alpha_map: spec.alpha_map.map(|a| a.to_vec()),
}),
ImagePixelsSpec::Rgba { .. } => None,
}
}
fn image_spec_bounds(spec: &ImageSpec<'_>) -> DataBounds {
let mut bounds = DataBounds::default();
if let Some((x, y)) = image_bounds(spec) {
bounds.include(x, y, YAxis::Left);
}
bounds
}
fn image_spec_stats(spec: &ImageSpec<'_>) -> ItemStats {
let (width, height, scalar) = match &spec.pixels {
ImagePixelsSpec::Scalar {
width,
height,
data,
..
} => (*width, *height, Some(ValueStats::from_f32(data))),
ImagePixelsSpec::Rgba { width, height, .. } => (*width, *height, None),
};
ItemStats::Image(ImageStats {
width,
height,
pixel_count: expected_image_len(width, height),
scalar,
})
}
fn rgba_to_color32(rgba: [u8; 4]) -> Color32 {
Color32::from_rgba_unmultiplied(rgba[0], rgba[1], rgba[2], rgba[3])
}
fn fallback_legend_color(kind: PlotItemKind) -> Color32 {
match kind {
PlotItemKind::Curve => Color32::LIGHT_BLUE,
PlotItemKind::Histogram => Color32::LIGHT_GREEN,
PlotItemKind::Scatter => Color32::LIGHT_BLUE,
PlotItemKind::Image => Color32::GRAY,
PlotItemKind::Mask => Color32::from_rgba_unmultiplied(255, 80, 80, 160),
PlotItemKind::Triangles => Color32::LIGHT_BLUE,
PlotItemKind::Shape => Color32::WHITE,
PlotItemKind::Marker => Color32::YELLOW,
}
}
fn curve_spec_legend_visual(spec: &CurveSpec<'_>, kind: PlotItemKind) -> LegendVisual {
let color = match spec.color {
CurveColor::Uniform(color) => color,
CurveColor::PerVertex(colors) => colors
.first()
.copied()
.unwrap_or_else(|| fallback_legend_color(kind)),
};
LegendVisual::curve(color, spec.line_style.clone(), spec.symbol)
}
fn image_spec_legend_visual(spec: &ImageSpec<'_>, kind: PlotItemKind) -> LegendVisual {
match &spec.pixels {
ImagePixelsSpec::Scalar { colormap, .. } => LegendVisual::with_secondary(
rgba_to_color32(colormap.lut[48]),
rgba_to_color32(colormap.lut[208]),
),
ImagePixelsSpec::Rgba { data, .. } => {
let color = data
.iter()
.copied()
.find(|rgba| rgba[3] != 0)
.map(rgba_to_color32)
.unwrap_or_else(|| fallback_legend_color(kind));
LegendVisual::new(color)
}
}
}
fn triangle_spec_legend_visual(spec: &TriangleSpec<'_>) -> LegendVisual {
LegendVisual::new(
spec.colors
.first()
.copied()
.unwrap_or_else(|| fallback_legend_color(PlotItemKind::Triangles)),
)
}
fn shape_spec_legend_visual(spec: &ShapeSpec<'_>) -> LegendVisual {
LegendVisual::new(spec.color)
}
fn marker_spec_legend_visual(spec: &MarkerSpec<'_>) -> LegendVisual {
LegendVisual::new(spec.color)
}
fn xy_bounds(x: &[f64], y: &[f64], axis: YAxis) -> DataBounds {
let mut bounds = DataBounds::default();
if let (Some(x), Some(y)) = (finite_bounds(x), finite_bounds(y)) {
bounds.include(x, y, axis);
}
bounds
}
fn active_axis_label_overrides(
x_label: Option<&str>,
y_label: Option<&str>,
y_axis: YAxis,
) -> (Option<String>, Option<String>, Option<String>) {
let x = x_label.map(ToOwned::to_owned);
let y = y_label.map(ToOwned::to_owned);
match y_axis {
YAxis::Left => (x, y, None),
YAxis::Right => (x, None, y),
YAxis::Extra(_) => (x, None, None),
}
}
fn curve_spec_from_data(curve: &CurveData) -> CurveSpec<'_> {
CurveSpec {
x: &curve.x,
y: &curve.y,
color: curve
.colors
.as_deref()
.map_or(CurveColor::Uniform(curve.color), CurveColor::PerVertex),
gap_color: curve.gap_color,
symbol: curve.symbol,
line_width: curve.width,
line_style: curve.line_style.clone(),
y_axis: curve.y_axis,
x_error: curve.x_error.clone(),
y_error: curve.y_error.clone(),
fill: curve.fill,
alpha: 1.0,
symbol_size: curve.marker_size,
baseline: curve.baseline.clone(),
x_label: None,
y_label: None,
}
}
fn image_spec_from_data(image: &ImageData) -> ImageSpec<'_> {
match &image.pixels {
ImagePixels::Scalar { data, colormap } => ImageSpec {
pixels: ImagePixelsSpec::Scalar {
width: image.width,
height: image.height,
data,
colormap: colormap.clone(),
},
origin: image.origin,
scale: image.scale,
alpha: image.alpha,
interpolation: image.interpolation,
aggregation: AggregationMode::None,
aggregation_block: (1, 1),
alpha_map: image.alpha_map.as_deref(),
},
ImagePixels::Rgba { data } => ImageSpec {
pixels: ImagePixelsSpec::Rgba {
width: image.width,
height: image.height,
data,
},
origin: image.origin,
scale: image.scale,
alpha: image.alpha,
interpolation: image.interpolation,
aggregation: AggregationMode::None,
aggregation_block: (1, 1),
alpha_map: None,
},
}
}
fn apply_image_mask(
width: u32,
height: u32,
data: &[f32],
mask: &ScalarMask,
) -> Result<Vec<f32>, PlotDataError> {
let expected = (width as usize).saturating_mul(height as usize);
if data.len() != expected {
return Err(PlotDataError::ImageDataLength {
expected,
actual: data.len(),
});
}
if mask.width() != width as usize || mask.height() != height as usize {
return Err(PlotDataError::ImageDataLength {
expected,
actual: mask.width().saturating_mul(mask.height()),
});
}
Ok(mask.apply(data))
}
fn triangle_spec_from_data(triangles: &Triangles) -> TriangleSpec<'_> {
TriangleSpec {
x: &triangles.x,
y: &triangles.y,
triangles: &triangles.indices,
colors: &triangles.colors,
alpha: triangles.alpha,
}
}
fn shape_spec_from_data(shape: &Shape) -> ShapeSpec<'_> {
ShapeSpec {
x: &shape.x,
y: &shape.y,
kind: shape.kind,
color: shape.color,
fill: shape.fill,
overlay: false,
line_style: shape.line_style.clone(),
line_width: shape.line_width,
gap_color: shape.gap_color,
}
}
fn marker_spec_from_data(marker: &Marker) -> MarkerSpec<'_> {
let (x, y, symbol, symbol_size) = match marker.kind {
MarkerKind::Point { x, y, symbol, size } => (Some(x), Some(y), Some(symbol), size),
MarkerKind::VLine { x } => (Some(x), None, None, 0.0),
MarkerKind::HLine { y } => (None, Some(y), None, 0.0),
};
MarkerSpec {
x,
y,
text: marker.text.as_deref(),
color: marker.color,
symbol,
symbol_size,
line_style: marker.line_style.clone(),
line_width: marker.line_width,
y_axis: marker.y_axis,
bg_color: marker.bgcolor,
is_draggable: marker.is_draggable,
constraint: marker.constraint,
}
}
#[derive(Clone, Debug)]
enum RetainedItemData {
Curve { x: Vec<f64>, y: Vec<f64> },
Histogram {
edges: Vec<f64>,
counts: Vec<f64>,
x: Vec<f64>,
centers: Vec<f64>,
},
Scatter {
x: Vec<f64>,
y: Vec<f64>,
values: Vec<f64>,
},
Image {
data: Vec<f64>,
width: usize,
height: usize,
origin: (f64, f64),
scale: (f64, f64),
colormap: Box<Colormap>,
alpha: f32,
interpolation: InterpolationMode,
aggregation: AggregationMode,
aggregation_block: (u32, u32),
alpha_map: Option<Vec<f32>>,
},
}
#[derive(Clone, Debug)]
struct ImageDisplayAttrs {
origin: (f64, f64),
scale: (f64, f64),
alpha: f32,
interpolation: InterpolationMode,
aggregation: AggregationMode,
aggregation_block: (u32, u32),
alpha_map: Option<Vec<f32>>,
}
impl ImageDisplayAttrs {
fn apply<'a>(&'a self, spec: &mut ImageSpec<'a>) {
spec.origin = self.origin;
spec.scale = self.scale;
spec.alpha = self.alpha;
spec.interpolation = self.interpolation;
spec.aggregation = self.aggregation;
spec.aggregation_block = self.aggregation_block;
spec.alpha_map = self.alpha_map.as_deref();
}
}
impl RetainedItemData {
fn image_display_attrs(&self) -> Option<ImageDisplayAttrs> {
match self {
RetainedItemData::Image {
origin,
scale,
alpha,
interpolation,
aggregation,
aggregation_block,
alpha_map,
..
} => Some(ImageDisplayAttrs {
origin: *origin,
scale: *scale,
alpha: *alpha,
interpolation: *interpolation,
aggregation: *aggregation,
aggregation_block: *aggregation_block,
alpha_map: alpha_map.clone(),
}),
RetainedItemData::Curve { .. }
| RetainedItemData::Histogram { .. }
| RetainedItemData::Scatter { .. } => None,
}
}
}
#[derive(Clone, Debug)]
struct ItemRecord {
handle: ItemHandle,
kind: PlotItemKind,
bounds: DataBounds,
legend: Option<String>,
x_label: Option<String>,
y_label: Option<String>,
stats: Option<ItemStats>,
visual: LegendVisual,
data: Option<RetainedItemData>,
curve_data: Option<CurveData>,
hidden_symbol: Option<Symbol>,
hidden_line_style: Option<LineStyle>,
}
#[derive(Clone, Debug)]
struct LegendVisual {
color: Color32,
secondary: Option<Color32>,
line_style: LineStyle,
symbol: Option<Symbol>,
}
const LEGEND_ROW_HEIGHT: f32 = 24.0;
const LEGEND_SWATCH_WIDTH: f32 = 54.0;
const LEGEND_CHECK_WIDTH: f32 = 22.0;
const LEGEND_ROW_MIN_WIDTH: f32 = 1.0;
impl LegendVisual {
fn new(color: Color32) -> Self {
Self {
color,
secondary: None,
line_style: LineStyle::Solid,
symbol: None,
}
}
fn with_secondary(color: Color32, secondary: Color32) -> Self {
Self {
color,
secondary: Some(secondary),
line_style: LineStyle::Solid,
symbol: None,
}
}
fn curve(color: Color32, line_style: LineStyle, symbol: Option<Symbol>) -> Self {
Self {
color,
secondary: None,
line_style,
symbol,
}
}
}
fn legend_row_width(available_width: f32) -> f32 {
available_width.max(LEGEND_ROW_MIN_WIDTH)
}
const DEFAULT_RESTORE_SYMBOL: Symbol = Symbol::Point;
const DEFAULT_RESTORE_LINE_STYLE: LineStyle = LineStyle::Solid;
fn set_symbol_visibility(
current: Option<Symbol>,
visible: bool,
cache: &mut Option<Symbol>,
) -> Option<Symbol> {
match (visible, current) {
(true, Some(symbol)) => Some(symbol),
(true, None) => Some(cache.take().unwrap_or(DEFAULT_RESTORE_SYMBOL)),
(false, Some(symbol)) => {
*cache = Some(symbol);
None
}
(false, None) => None,
}
}
fn set_line_visibility(
current: LineStyle,
visible: bool,
cache: &mut Option<LineStyle>,
) -> LineStyle {
match (visible, current.draws_line()) {
(true, true) => current,
(true, false) => cache.take().unwrap_or(DEFAULT_RESTORE_LINE_STYLE),
(false, true) => {
*cache = Some(current);
LineStyle::None
}
(false, false) => current,
}
}
#[derive(Clone, Debug, PartialEq, Default)]
pub struct CurveStyle {
pub color: Option<Color32>,
pub line_width: Option<f32>,
pub line_style: Option<LineStyle>,
pub symbol: Option<Symbol>,
pub symbol_size: Option<f32>,
pub gap_color: Option<Color32>,
}
fn current_curve_style(base: &CurveData, highlight: &CurveStyle, highlighted: bool) -> CurveData {
let mut resolved = base.clone();
if highlighted {
if let Some(color) = highlight.color {
resolved.color = color;
}
if let Some(width) = highlight.line_width {
resolved.width = width;
}
if let Some(line_style) = highlight.line_style.clone() {
resolved.line_style = line_style;
}
if let Some(symbol) = highlight.symbol {
resolved.symbol = Some(symbol);
}
if let Some(symbol_size) = highlight.symbol_size {
resolved.marker_size = symbol_size;
}
if let Some(gap_color) = highlight.gap_color {
resolved.gap_color = Some(gap_color);
}
}
resolved
}
struct LegendRowResult {
row_clicked: bool,
eye_clicked: bool,
row_response: egui::Response,
}
fn legend_row_response(
ui: &mut egui::Ui,
width: f32,
kind: PlotItemKind,
label: &str,
active: bool,
visible: bool,
visual: LegendVisual,
) -> LegendRowResult {
let (rect, row_response) =
ui.allocate_exact_size(egui::vec2(width, LEGEND_ROW_HEIGHT), egui::Sense::click());
let eye_rect = egui::Rect::from_min_max(
egui::pos2(rect.right() - LEGEND_CHECK_WIDTH, rect.top()),
rect.right_bottom(),
);
let eye_response = ui.interact(eye_rect, row_response.id.with("eye"), egui::Sense::click());
if ui.is_rect_visible(rect) {
draw_legend_row(
ui,
LegendRowDraw {
rect,
response: &row_response,
kind,
label,
active,
visible,
visual,
},
);
}
LegendRowResult {
row_clicked: row_response.clicked() && !eye_response.clicked(),
eye_clicked: eye_response.clicked(),
row_response,
}
}
struct LegendRowDraw<'a> {
rect: egui::Rect,
response: &'a egui::Response,
kind: PlotItemKind,
label: &'a str,
active: bool,
visible: bool,
visual: LegendVisual,
}
fn draw_legend_row(ui: &egui::Ui, p: LegendRowDraw<'_>) {
let LegendRowDraw {
rect,
response,
kind,
label,
active,
visible,
visual,
} = p;
let visuals = ui.visuals();
let row_rect = rect.shrink2(egui::vec2(1.0, 0.0));
let row_clip = row_rect.intersect(ui.clip_rect());
let painter = ui.painter().with_clip_rect(row_clip);
let fill = if active {
visuals.selection.bg_fill
} else if response.hovered() {
visuals.widgets.hovered.weak_bg_fill
} else {
Color32::TRANSPARENT
};
painter.rect_filled(row_rect, 0.0, fill);
let check_rect = egui::Rect::from_min_max(
egui::pos2(row_rect.right() - LEGEND_CHECK_WIDTH, row_rect.top()),
row_rect.right_bottom(),
);
let swatch_right = (row_rect.left() + 4.0 + LEGEND_SWATCH_WIDTH)
.min(check_rect.left() - 4.0)
.max(row_rect.left() + 4.0);
let swatch_rect = egui::Rect::from_min_max(
egui::pos2(row_rect.left() + 4.0, row_rect.top() + 4.0),
egui::pos2(swatch_right, row_rect.bottom() - 4.0),
);
if swatch_rect.width() >= 8.0 {
draw_legend_swatch(&painter, swatch_rect, kind, visual);
}
let text_color = if active {
visuals.selection.stroke.color
} else {
visuals.text_color()
};
let text_left = swatch_rect.right() + 6.0;
let text_right = check_rect.left() - 2.0;
if text_right > text_left {
let text_clip = egui::Rect::from_min_max(
egui::pos2(text_left, row_rect.top()),
egui::pos2(text_right, row_rect.bottom()),
)
.intersect(row_clip);
painter.with_clip_rect(text_clip).text(
egui::pos2(text_left, row_rect.center().y),
egui::Align2::LEFT_CENTER,
label,
egui::FontId::proportional(12.0),
text_color,
);
}
draw_legend_eye(
&painter,
check_rect,
visible,
active,
visuals.widgets.inactive.fg_stroke.color,
);
painter.line_segment(
[
egui::pos2(row_rect.left(), row_rect.bottom()),
egui::pos2(row_rect.right(), row_rect.bottom()),
],
egui::Stroke::new(1.0, visuals.widgets.noninteractive.bg_stroke.color),
);
}
fn draw_legend_swatch(
painter: &egui::Painter,
rect: egui::Rect,
kind: PlotItemKind,
visual: LegendVisual,
) {
match kind {
PlotItemKind::Curve => {
let y = rect.center().y;
let a = egui::pos2(rect.left() + 4.0, y);
let b = egui::pos2(rect.right() - 4.0, y);
if visual.line_style.draws_line() {
let stroke = egui::Stroke::new(2.0, visual.color);
match visual.line_style.painter_dashes(stroke.width) {
None => {
painter.line_segment([a, b], stroke);
}
Some((dashes, gaps, offset)) => {
for shape in egui::Shape::dashed_line_with_offset(
&[a, b],
stroke,
&dashes,
&gaps,
offset,
) {
painter.add(shape);
}
}
}
}
if let Some(symbol) = visual.symbol {
draw_legend_symbol(painter, rect.center(), 4.0, symbol, visual.color);
}
}
PlotItemKind::Histogram => {
let fill = visual.color.linear_multiply(0.45);
let bar_width = rect.width() / 5.0;
for (index, height) in [0.35, 0.65, 0.9, 0.55].iter().copied().enumerate() {
let left = rect.left() + 4.0 + index as f32 * bar_width;
let right = left + bar_width * 0.7;
let top = rect.bottom() - 3.0 - rect.height() * height;
let bar = egui::Rect::from_min_max(
egui::pos2(left, top),
egui::pos2(right, rect.bottom() - 3.0),
);
painter.rect_filled(bar, 0.0, fill);
painter.rect_stroke(
bar,
0.0,
egui::Stroke::new(1.0, visual.color),
egui::StrokeKind::Inside,
);
}
}
PlotItemKind::Scatter => {
for t in [0.25, 0.5, 0.75] {
let center = egui::pos2(
egui::lerp(rect.left() + 6.0..=rect.right() - 6.0, t),
egui::lerp(rect.bottom() - 4.0..=rect.top() + 4.0, t),
);
painter.circle_filled(center, 3.0, visual.color);
}
}
PlotItemKind::Image | PlotItemKind::Mask => {
if let Some(secondary) = visual.secondary {
let split = rect.center().x;
painter.rect_filled(
egui::Rect::from_min_max(rect.left_top(), egui::pos2(split, rect.bottom())),
0.0,
visual.color,
);
painter.rect_filled(
egui::Rect::from_min_max(egui::pos2(split, rect.top()), rect.right_bottom()),
0.0,
secondary,
);
} else {
painter.rect_filled(rect.shrink(2.0), 0.0, visual.color);
}
}
PlotItemKind::Triangles => {
let points = vec![
egui::pos2(rect.left() + 7.0, rect.bottom() - 4.0),
egui::pos2(rect.center().x, rect.top() + 4.0),
egui::pos2(rect.right() - 7.0, rect.bottom() - 4.0),
];
painter.add(egui::Shape::convex_polygon(
points,
visual.color.linear_multiply(0.45),
egui::Stroke::new(1.0, visual.color),
));
}
PlotItemKind::Shape => {
let shape = rect.shrink2(egui::vec2(12.0, 3.0));
painter.rect_stroke(
shape,
0.0,
egui::Stroke::new(1.5, visual.color),
egui::StrokeKind::Inside,
);
}
PlotItemKind::Marker => {
let center = rect.center();
let stroke = egui::Stroke::new(1.8, visual.color);
painter.line_segment(
[
egui::pos2(center.x - 7.0, center.y),
egui::pos2(center.x + 7.0, center.y),
],
stroke,
);
painter.line_segment(
[
egui::pos2(center.x, center.y - 7.0),
egui::pos2(center.x, center.y + 7.0),
],
stroke,
);
}
}
}
fn draw_legend_symbol(
painter: &egui::Painter,
center: egui::Pos2,
half: f32,
symbol: Symbol,
color: Color32,
) {
let c = center;
let stroke = egui::Stroke::new(1.5, color);
let caret = |apex: egui::Pos2, arm_a: egui::Pos2, arm_b: egui::Pos2| {
painter.line_segment([apex, arm_a], stroke);
painter.line_segment([apex, arm_b], stroke);
};
match symbol {
Symbol::Circle => {
painter.circle_filled(c, half, color);
}
Symbol::Point => {
painter.circle_filled(c, half * 0.6, color);
}
Symbol::Pixel => {
painter.rect_filled(
egui::Rect::from_center_size(c, egui::Vec2::splat(half * 0.9)),
0.0,
color,
);
}
Symbol::Square => {
painter.rect_filled(
egui::Rect::from_center_size(c, egui::Vec2::splat(half * 2.0)),
0.0,
color,
);
}
Symbol::Diamond => {
painter.add(egui::Shape::convex_polygon(
vec![
egui::pos2(c.x, c.y - half),
egui::pos2(c.x + half, c.y),
egui::pos2(c.x, c.y + half),
egui::pos2(c.x - half, c.y),
],
color,
egui::Stroke::NONE,
));
}
Symbol::Triangle => {
painter.add(egui::Shape::convex_polygon(
vec![
egui::pos2(c.x, c.y - half),
egui::pos2(c.x + half, c.y + half),
egui::pos2(c.x - half, c.y + half),
],
color,
egui::Stroke::NONE,
));
}
Symbol::Cross => {
painter.line_segment(
[
egui::pos2(c.x - half, c.y - half),
egui::pos2(c.x + half, c.y + half),
],
stroke,
);
painter.line_segment(
[
egui::pos2(c.x - half, c.y + half),
egui::pos2(c.x + half, c.y - half),
],
stroke,
);
}
Symbol::Plus => {
painter.line_segment(
[egui::pos2(c.x, c.y - half), egui::pos2(c.x, c.y + half)],
stroke,
);
painter.line_segment(
[egui::pos2(c.x - half, c.y), egui::pos2(c.x + half, c.y)],
stroke,
);
}
Symbol::VerticalLine => {
painter.line_segment(
[egui::pos2(c.x, c.y - half), egui::pos2(c.x, c.y + half)],
stroke,
);
}
Symbol::HorizontalLine => {
painter.line_segment(
[egui::pos2(c.x - half, c.y), egui::pos2(c.x + half, c.y)],
stroke,
);
}
Symbol::TickLeft => {
painter.line_segment([egui::pos2(c.x - half, c.y), c], stroke);
}
Symbol::TickRight => {
painter.line_segment([c, egui::pos2(c.x + half, c.y)], stroke);
}
Symbol::TickUp => {
painter.line_segment([egui::pos2(c.x, c.y - half), c], stroke);
}
Symbol::TickDown => {
painter.line_segment([c, egui::pos2(c.x, c.y + half)], stroke);
}
Symbol::CaretLeft => caret(
egui::pos2(c.x - half, c.y),
egui::pos2(c.x + half, c.y - half),
egui::pos2(c.x + half, c.y + half),
),
Symbol::CaretRight => caret(
egui::pos2(c.x + half, c.y),
egui::pos2(c.x - half, c.y - half),
egui::pos2(c.x - half, c.y + half),
),
Symbol::CaretUp => caret(
egui::pos2(c.x, c.y - half),
egui::pos2(c.x - half, c.y + half),
egui::pos2(c.x + half, c.y + half),
),
Symbol::CaretDown => caret(
egui::pos2(c.x, c.y + half),
egui::pos2(c.x - half, c.y - half),
egui::pos2(c.x + half, c.y - half),
),
Symbol::Heart => {
let hump = half * 0.5;
let hy = c.y - half * 0.35;
painter.circle_filled(egui::pos2(c.x - half * 0.45, hy), hump, color);
painter.circle_filled(egui::pos2(c.x + half * 0.45, hy), hump, color);
painter.add(egui::Shape::convex_polygon(
vec![
egui::pos2(c.x - half * 0.95, hy),
egui::pos2(c.x + half * 0.95, hy),
egui::pos2(c.x, c.y + half * 0.95),
],
color,
egui::Stroke::NONE,
));
}
}
}
fn draw_legend_eye(
painter: &egui::Painter,
rect: egui::Rect,
visible: bool,
active: bool,
color: Color32,
) {
let cx = rect.center().x;
let cy = rect.center().y;
let r = 3.5_f32;
let eye_color = if active {
color
} else {
crate::core::color::with_alpha(color, 180)
};
if visible {
painter.circle_stroke(egui::pos2(cx, cy), r, egui::Stroke::new(1.5, eye_color));
painter.circle_filled(egui::pos2(cx, cy), r * 0.45, eye_color);
} else {
let dim = crate::core::color::with_alpha(color, 80);
painter.circle_stroke(egui::pos2(cx, cy), r, egui::Stroke::new(1.5, dim));
painter.line_segment(
[egui::pos2(cx - r * 1.3, cy), egui::pos2(cx + r * 1.3, cy)],
egui::Stroke::new(1.5, dim),
);
}
}
type LimitsSnapshot = ((f64, f64, f64, f64), Option<(f64, f64)>);
pub struct PlotWidget {
backend: WgpuBackend,
item_records: Vec<ItemRecord>,
data_bounds: DataBounds,
default_colormap: Colormap,
auto_reset_zoom: bool,
interaction_mode: PlotInteractionMode,
active_item: Option<ItemHandle>,
active_curve_style: CurveStyle,
active_curve_handling: bool,
default_plot_lines: bool,
default_plot_points: bool,
events: Vec<PlotEvent>,
rename_state: Option<(ItemHandle, String)>,
print_dialog: crate::widget::print_dialog::PrintDialog,
ruler_active: bool,
ruler_roi: Option<usize>,
ruler_prev_mode: Option<PlotInteractionMode>,
median_filter_original: Option<(ItemHandle, Vec<f64>)>,
last_pixels_per_point: f32,
}
impl PlotWidget {
pub fn new(render_state: &RenderState, id: PlotId) -> Self {
Self::from_backend(WgpuBackend::new(render_state, id))
}
pub fn from_backend(backend: WgpuBackend) -> Self {
Self {
backend,
item_records: Vec::new(),
data_bounds: DataBounds::default(),
default_colormap: Colormap::new(ColormapName::Gray, 0.0, 1.0),
auto_reset_zoom: true,
interaction_mode: PlotInteractionMode::Zoom,
active_item: None,
active_curve_style: CurveStyle {
line_width: Some(2.0),
..CurveStyle::default()
},
active_curve_handling: true,
default_plot_lines: true,
default_plot_points: false,
events: Vec::new(),
rename_state: None,
print_dialog: crate::widget::print_dialog::PrintDialog::new(),
ruler_active: false,
ruler_roi: None,
ruler_prev_mode: None,
median_filter_original: None,
last_pixels_per_point: 1.0,
}
}
pub fn show(&mut self, ui: &mut egui::Ui) -> PlotResponse {
self.show_inner(ui, None)
}
pub fn show_with_context_menu(
&mut self,
ui: &mut egui::Ui,
mut menu_ext: impl FnMut(&mut egui::Ui),
) -> PlotResponse {
self.show_inner(ui, Some(&mut menu_ext))
}
fn show_inner(
&mut self,
ui: &mut egui::Ui,
menu_ext: Option<&mut dyn FnMut(&mut egui::Ui)>,
) -> PlotResponse {
let before = self.limits_snapshot();
self.last_pixels_per_point = ui.ctx().pixels_per_point();
self.sync_active_axis_labels();
let mut view = PlotView::new();
if let Some(ext) = menu_ext {
view = view.with_context_menu(ext);
}
let response =
view.show_with_interaction(ui, self.backend.plot_mut(), self.interaction_mode);
self.backend
.set_plot_bounds_in_pixels(response.transform.area);
self.select_item_from_plot_response(&response);
self.emit_item_pointer_events(&response);
self.push_limits_changed_if(before);
if let Some(index) = response.roi_changed {
self.events.push(PlotEvent::RoiChanged { index });
}
if let Some(index) = response.roi_created {
self.events.push(PlotEvent::RoiAdded { index });
self.events.push(PlotEvent::RoiCreated { index });
}
if self.ruler_active {
if response.roi_created.is_some() {
if let Some(old) = self.ruler_roi.take() {
self.remove_roi(old);
}
let index = self.backend.plot().rois.len().saturating_sub(1);
self.ruler_roi = Some(index);
self.relabel_ruler(index);
} else if let Some(index) = response.roi_changed
&& Some(index) == self.ruler_roi
{
self.relabel_ruler(index);
}
}
if let Some(index) = response.roi_make_current {
self.set_current_roi(Some(index));
}
if let Some((index, mode)) = response.roi_set_interaction_mode {
self.set_roi_interaction_mode(index, mode);
}
if let Some(index) = response.roi_removed {
self.remove_roi(index);
}
match response.draw_event.clone() {
Some(DrawEvent::InProgress { mode, points }) => {
self.events
.push(PlotEvent::DrawingProgress { mode, points });
}
Some(DrawEvent::Finished { mode, params }) => {
self.events
.push(PlotEvent::DrawingFinished { mode, params });
}
None => {}
}
if let Some(handle) = response.marker_drag_started {
self.events.push(PlotEvent::MarkerDragStarted { handle });
}
if let Some(handle) = response.marker_moved {
let plot = self.backend.plot();
let moved = plot
.marker_handles
.iter()
.position(|&h| h == handle)
.and_then(|index| plot.markers.get(index).cloned());
if let Some(marker) = moved {
self.backend.update_marker(handle, marker);
self.events.push(PlotEvent::MarkerMoved { handle });
}
}
if let Some(handle) = response.marker_drag_finished {
self.events.push(PlotEvent::MarkerDragFinished { handle });
}
response
}
fn sync_active_axis_labels(&mut self) {
let (x, y, y2) = self
.active_curve()
.and_then(|handle| self.item_record(handle))
.map(|record| {
let y_axis = record
.curve_data
.as_ref()
.map(|data| data.y_axis)
.unwrap_or(YAxis::Left);
active_axis_label_overrides(
record.x_label.as_deref(),
record.y_label.as_deref(),
y_axis,
)
})
.unwrap_or((None, None, None));
let plot = self.backend.plot_mut();
plot.active_x_label = x;
plot.active_y_label = y;
plot.active_y2_label = y2;
}
pub fn plot(&self) -> &Plot {
self.backend.plot()
}
pub fn plot_mut(&mut self) -> &mut Plot {
self.backend.plot_mut()
}
pub fn backend(&self) -> &WgpuBackend {
&self.backend
}
pub fn backend_mut(&mut self) -> &mut WgpuBackend {
&mut self.backend
}
pub fn set_auto_reset_zoom(&mut self, on: bool) {
self.auto_reset_zoom = on;
}
pub fn auto_reset_zoom(&self) -> bool {
self.auto_reset_zoom
}
pub fn set_interaction_mode(&mut self, mode: PlotInteractionMode) {
if mode == PlotInteractionMode::Zoom {
self.backend.plot_mut().clear_limits_history();
}
self.interaction_mode = mode;
}
pub fn interaction_mode(&self) -> PlotInteractionMode {
self.interaction_mode
}
pub fn set_roi_create_mode(&mut self, kind: RoiDrawKind) {
self.set_interaction_mode(PlotInteractionMode::RoiCreate(kind));
}
pub fn roi_creation_message(&self) -> Option<String> {
self.interaction_mode
.roi_creation_message(self.backend.plot().rois.len())
}
pub fn events(&self) -> &[PlotEvent] {
&self.events
}
pub fn drain_events(&mut self) -> Vec<PlotEvent> {
mem::take(&mut self.events)
}
fn limits_snapshot(&self) -> LimitsSnapshot {
(self.backend.plot().limits, self.backend.plot().y2)
}
fn push_limits_changed_if(&mut self, before: LimitsSnapshot) {
let after = self.limits_snapshot();
if before != after {
let ((xmin, xmax, ymin, ymax), y2) = after;
self.events.push(PlotEvent::LimitsChanged {
x: (xmin, xmax),
y: (ymin, ymax),
y2,
});
}
}
fn select_item_from_plot_response(&mut self, response: &PlotResponse) {
if !response.response.clicked_by(egui::PointerButton::Primary) {
return;
}
let Some(pos) = response.response.interact_pointer_pos() else {
return;
};
if !response.transform.area.contains(pos) {
return;
}
if let Some(handle) = self.pick_topmost_item(pos) {
self.set_active_item(Some(handle));
}
}
fn pick_topmost(&self, pos: egui::Pos2) -> Option<(ItemHandle, PickResult)> {
self.backend
.items_back_to_front()
.into_iter()
.rev()
.find_map(|handle| {
self.backend
.pick_item(pos, handle)
.map(|pick| (handle, pick))
})
}
fn pick_topmost_item(&self, pos: egui::Pos2) -> Option<ItemHandle> {
self.pick_topmost(pos).map(|(handle, _)| handle)
}
fn click_event_for_pick(
handle: ItemHandle,
pick: &PickResult,
button: MouseButton,
) -> PlotEvent {
match *pick {
PickResult::CurvePoint { index, x, y, .. } => PlotEvent::CurveClicked {
handle,
index,
x,
y,
button,
},
PickResult::ImagePixel { col, row } => PlotEvent::ImageClicked {
handle,
col,
row,
button,
},
PickResult::Item { .. } => PlotEvent::ItemClicked { handle, button },
}
}
fn emit_item_pointer_events(&mut self, response: &PlotResponse) {
use crate::widget::interaction::PlotPointerEvent;
let Some(event) = response.pointer_event.as_ref() else {
return;
};
match *event {
PlotPointerEvent::Clicked { button, pixel, .. } => {
let pos = egui::pos2(pixel.0, pixel.1);
if let Some((handle, pick)) = self.pick_topmost(pos) {
self.events
.push(Self::click_event_for_pick(handle, &pick, button));
}
}
PlotPointerEvent::Moved {
button: None,
data,
pixel,
} => {
let pos = egui::pos2(pixel.0, pixel.1);
if let Some((handle, _)) = self.pick_topmost(pos)
&& let Some(kind) = self.item_kind(handle)
{
let label = self.item_legend(handle).map(str::to_owned);
let draggable = self.backend.marker(handle).is_some_and(|m| m.is_draggable);
self.events.push(PlotEvent::ItemHovered {
handle,
kind,
label,
x: data.0,
y: data.1,
xpixel: pixel.0,
ypixel: pixel.1,
draggable,
});
}
}
_ => {}
}
}
fn set_limits_internal(
&mut self,
xmin: f64,
xmax: f64,
ymin: f64,
ymax: f64,
y2: Option<(f64, f64)>,
) {
let before = self.limits_snapshot();
self.backend.set_limits(xmin, xmax, ymin, ymax, y2);
self.push_limits_changed_if(before);
}
fn record_item(
&mut self,
handle: ItemHandle,
kind: PlotItemKind,
bounds: DataBounds,
stats: Option<ItemStats>,
visual: LegendVisual,
) {
self.item_records.push(ItemRecord {
handle,
kind,
bounds,
legend: None,
x_label: None,
y_label: None,
stats,
visual,
data: None,
curve_data: None,
hidden_symbol: None,
hidden_line_style: None,
});
self.events.push(PlotEvent::ItemAdded { handle, kind });
if self.active_item.is_none() {
self.set_active_item(Some(handle));
}
self.recompute_data_bounds();
self.apply_auto_limits();
}
fn update_item_record(
&mut self,
handle: ItemHandle,
kind: PlotItemKind,
bounds: DataBounds,
stats: Option<ItemStats>,
visual: LegendVisual,
) {
if let Some(record) = self
.item_records
.iter_mut()
.find(|record| record.handle == handle)
{
record.kind = kind;
record.bounds = bounds;
record.stats = stats;
record.visual = visual;
self.events.push(PlotEvent::ItemUpdated { handle, kind });
} else {
self.item_records.push(ItemRecord {
handle,
kind,
bounds,
legend: None,
x_label: None,
y_label: None,
stats,
visual,
data: None,
curve_data: None,
hidden_symbol: None,
hidden_line_style: None,
});
self.events.push(PlotEvent::ItemAdded { handle, kind });
}
self.recompute_data_bounds();
self.apply_auto_limits();
}
fn recompute_data_bounds(&mut self) {
let mut bounds = DataBounds::default();
for record in &self.item_records {
bounds.include_bounds(&record.bounds);
}
self.data_bounds = bounds;
self.backend
.plot_mut()
.set_data_range(raw_data_range_from_bounds(&self.data_bounds));
}
fn remove_records_by_kinds(&mut self, predicate: impl Fn(PlotItemKind) -> bool) {
let removed: Vec<(ItemHandle, PlotItemKind)> = self
.item_records
.iter()
.filter_map(|record| predicate(record.kind).then_some((record.handle, record.kind)))
.collect();
for (handle, _) in &removed {
self.backend.remove(*handle);
}
self.item_records.retain(|record| !predicate(record.kind));
for (handle, kind) in removed {
self.events.push(PlotEvent::ItemRemoved { handle, kind });
}
self.clear_active_if_missing();
self.recompute_data_bounds();
self.apply_auto_limits();
}
fn has_item(&self, handle: ItemHandle) -> bool {
self.item_records
.iter()
.any(|record| record.handle == handle)
}
fn item_record(&self, handle: ItemHandle) -> Option<&ItemRecord> {
self.item_records
.iter()
.find(|record| record.handle == handle)
}
fn item_record_mut(&mut self, handle: ItemHandle) -> Option<&mut ItemRecord> {
self.item_records
.iter_mut()
.find(|record| record.handle == handle)
}
fn set_retained_data(&mut self, handle: ItemHandle, data: Option<RetainedItemData>) {
let invalidate = match &self.median_filter_original {
Some((captured, _)) if *captured == handle => {
!image_pixels_bit_equal(self.retained_data(handle), data.as_ref())
}
_ => false,
};
if invalidate {
self.median_filter_original = None;
}
if let Some(record) = self.item_record_mut(handle) {
record.data = data;
}
}
fn retained_data(&self, handle: ItemHandle) -> Option<&RetainedItemData> {
self.item_record(handle)
.and_then(|record| record.data.as_ref())
}
fn set_record_curve_data(&mut self, handle: ItemHandle, curve_data: Option<CurveData>) {
if let Some(record) = self.item_record_mut(handle) {
record.curve_data = curve_data;
}
}
fn record_curve_data(&self, handle: ItemHandle) -> Option<&CurveData> {
self.item_record(handle)
.and_then(|record| record.curve_data.as_ref())
}
fn is_highlighted_curve(&self, handle: ItemHandle) -> bool {
self.active_curve_handling
&& self.active_item == Some(handle)
&& self.item_kind(handle) == Some(PlotItemKind::Curve)
}
fn sync_curve_highlight(&mut self, handle: ItemHandle) {
let Some(base) = self.record_curve_data(handle).cloned() else {
return;
};
let highlighted = self.is_highlighted_curve(handle);
let effective = current_curve_style(&base, &self.active_curve_style, highlighted);
self.backend
.update_curve(handle, curve_spec_from_data(&effective));
}
fn clear_active_if_missing(&mut self) {
if self
.active_item
.is_some_and(|handle| !self.has_item(handle))
{
let previous = self.active_item.take();
self.events.push(PlotEvent::ActiveItemChanged {
previous,
current: None,
});
}
}
pub fn add_curve(&mut self, x: &[f64], y: &[f64], color: Color32) -> ItemHandle {
self.add_curve_spec(CurveSpec::new(x, y, color))
}
pub fn add_curve_with_legend(
&mut self,
x: &[f64],
y: &[f64],
color: Color32,
legend: impl Into<String>,
) -> ItemHandle {
let handle = self.add_curve(x, y, color);
self.set_item_legend(handle, legend);
handle
}
pub fn add_curve_data(&mut self, curve: &CurveData) -> ItemHandle {
self.add_curve_spec(curve_spec_from_data(curve))
}
pub fn add_curve_data_with_legend(
&mut self,
curve: &CurveData,
legend: impl Into<String>,
) -> ItemHandle {
let handle = self.add_curve_data(curve);
self.set_item_legend(handle, legend);
handle
}
pub fn add_curve_spec(&mut self, spec: CurveSpec<'_>) -> ItemHandle {
self.add_curve_spec_as_kind(spec, PlotItemKind::Curve)
}
fn add_curve_spec_as_kind(&mut self, spec: CurveSpec<'_>, kind: PlotItemKind) -> ItemHandle {
let bounds = curve_spec_bounds(&spec);
let stats = Some(curve_spec_stats(&spec));
let visual = curve_spec_legend_visual(&spec, kind);
let data = curve_spec_retained_data(&spec);
let curve_data = curve_data_from_spec_hl(&spec);
let x_label = spec.x_label.map(ToOwned::to_owned);
let y_label = spec.y_label.map(ToOwned::to_owned);
let handle = self.backend.add_curve(spec);
self.record_item(handle, kind, bounds, stats, visual);
self.set_retained_data(handle, Some(data));
self.set_record_curve_data(handle, Some(curve_data));
if let Some(record) = self.item_record_mut(handle) {
record.x_label = x_label;
record.y_label = y_label;
}
handle
}
pub fn update_curve_spec(&mut self, handle: ItemHandle, spec: CurveSpec<'_>) -> bool {
let bounds = curve_spec_bounds(&spec);
let stats = Some(curve_spec_stats(&spec));
let kind = self
.item_kind(handle)
.filter(|kind| kind.is_curve_like())
.unwrap_or(PlotItemKind::Curve);
let visual = curve_spec_legend_visual(&spec, kind);
let data = curve_spec_retained_data(&spec);
let curve_data = curve_data_from_spec_hl(&spec);
if self.backend.update_curve(handle, spec) {
self.update_item_record(handle, kind, bounds, stats, visual);
self.set_retained_data(handle, Some(data));
self.set_record_curve_data(handle, Some(curve_data));
if self.is_highlighted_curve(handle) {
self.sync_curve_highlight(handle);
}
true
} else {
false
}
}
pub fn update_curve_data(&mut self, handle: ItemHandle, curve: &CurveData) -> bool {
self.update_curve_spec(handle, curve_spec_from_data(curve))
}
pub fn add_scatter(&mut self, x: &[f64], y: &[f64], color: Color32) -> ItemHandle {
self.add_scatter_with_symbol(x, y, color, Symbol::Circle, 7.0)
}
pub fn add_scatter_with_legend(
&mut self,
x: &[f64],
y: &[f64],
color: Color32,
legend: impl Into<String>,
) -> ItemHandle {
let handle = self.add_scatter(x, y, color);
self.set_item_legend(handle, legend);
handle
}
pub fn add_scatter_with_symbol(
&mut self,
x: &[f64],
y: &[f64],
color: Color32,
symbol: Symbol,
symbol_size: f32,
) -> ItemHandle {
let mut spec = CurveSpec::new(x, y, color);
spec.line_style = LineStyle::None;
spec.line_width = 0.0;
spec.symbol = Some(symbol);
spec.symbol_size = symbol_size;
self.add_curve_spec_as_kind(spec, PlotItemKind::Scatter)
}
pub fn add_histogram(
&mut self,
edges: &[f64],
counts: &[f64],
color: Color32,
) -> Result<ItemHandle, PlotDataError> {
self.add_histogram_with_align(edges, counts, color, HistogramAlign::Center)
}
fn add_histogram_with_align(
&mut self,
edges: &[f64],
counts: &[f64],
color: Color32,
align: HistogramAlign,
) -> Result<ItemHandle, PlotDataError> {
let (x, y) = histogram_step_values(edges, counts)?;
let mut spec = CurveSpec::new(&x, &y, color);
spec.fill = true;
spec.baseline = Baseline::Scalar(0.0);
let handle = self.add_curve_spec_as_kind(spec, PlotItemKind::Histogram);
self.set_retained_data(handle, Some(histogram_retained_data(edges, counts, align)));
Ok(handle)
}
pub fn add_histogram_with_legend(
&mut self,
edges: &[f64],
counts: &[f64],
color: Color32,
legend: impl Into<String>,
) -> Result<ItemHandle, PlotDataError> {
let handle = self.add_histogram(edges, counts, color)?;
self.set_item_legend(handle, legend);
Ok(handle)
}
pub fn add_histogram_aligned(
&mut self,
positions: &[f64],
counts: &[f64],
color: Color32,
align: HistogramAlign,
) -> Result<ItemHandle, PlotDataError> {
let edges = histogram_edges(positions, align);
self.add_histogram_with_align(&edges, counts, color, align)
}
pub fn add_histogram_aligned_with_legend(
&mut self,
positions: &[f64],
counts: &[f64],
color: Color32,
align: HistogramAlign,
legend: impl Into<String>,
) -> Result<ItemHandle, PlotDataError> {
let handle = self.add_histogram_aligned(positions, counts, color, align)?;
self.set_item_legend(handle, legend);
Ok(handle)
}
pub fn add_image(
&mut self,
width: u32,
height: u32,
data: &[f32],
colormap: Colormap,
) -> ItemHandle {
self.add_image_spec(ImageSpec::scalar(width, height, data, colormap))
}
pub fn try_add_image(
&mut self,
width: u32,
height: u32,
data: &[f32],
colormap: Colormap,
) -> Result<ItemHandle, PlotDataError> {
validate_image_len(width, height, data.len())?;
Ok(self.add_image(width, height, data, colormap))
}
pub fn add_image_default(&mut self, width: u32, height: u32, data: &[f32]) -> ItemHandle {
self.add_image(width, height, data, self.default_colormap.clone())
}
pub fn try_add_image_default(
&mut self,
width: u32,
height: u32,
data: &[f32],
) -> Result<ItemHandle, PlotDataError> {
self.try_add_image(width, height, data, self.default_colormap.clone())
}
pub fn add_image_with_geometry(
&mut self,
width: u32,
height: u32,
data: &[f32],
colormap: Colormap,
geometry: ImageGeometry,
) -> Result<ItemHandle, PlotDataError> {
validate_image_len(width, height, data.len())?;
let mut spec = ImageSpec::scalar(width, height, data, colormap);
spec.origin = geometry.origin;
spec.scale = geometry.scale;
spec.alpha = geometry.alpha;
Ok(self.add_image_spec(spec))
}
pub fn add_image_with_legend(
&mut self,
width: u32,
height: u32,
data: &[f32],
legend: impl Into<String>,
) -> ItemHandle {
let handle = self.add_image_default(width, height, data);
self.set_item_legend(handle, legend);
handle
}
pub fn add_rgba_image(&mut self, width: u32, height: u32, data: &[[u8; 4]]) -> ItemHandle {
self.add_image_spec(ImageSpec::rgba(width, height, data))
}
pub fn add_rgba_image_with_legend(
&mut self,
width: u32,
height: u32,
data: &[[u8; 4]],
legend: impl Into<String>,
) -> ItemHandle {
let handle = self.add_rgba_image(width, height, data);
self.set_item_legend(handle, legend);
handle
}
pub fn try_add_rgba_image(
&mut self,
width: u32,
height: u32,
data: &[[u8; 4]],
) -> Result<ItemHandle, PlotDataError> {
validate_image_len(width, height, data.len())?;
Ok(self.add_rgba_image(width, height, data))
}
pub fn add_rgba_image_with_geometry(
&mut self,
width: u32,
height: u32,
data: &[[u8; 4]],
geometry: ImageGeometry,
) -> Result<ItemHandle, PlotDataError> {
validate_image_len(width, height, data.len())?;
let mut spec = ImageSpec::rgba(width, height, data);
spec.origin = geometry.origin;
spec.scale = geometry.scale;
spec.alpha = geometry.alpha;
Ok(self.add_image_spec(spec))
}
pub fn add_image_data(&mut self, image: &ImageData) -> ItemHandle {
self.add_image_spec(image_spec_from_data(image))
}
pub fn add_image_spec(&mut self, spec: ImageSpec<'_>) -> ItemHandle {
self.add_image_spec_as_kind(spec, PlotItemKind::Image)
}
fn add_image_spec_as_kind(&mut self, spec: ImageSpec<'_>, kind: PlotItemKind) -> ItemHandle {
let bounds = image_spec_bounds(&spec);
let stats = Some(image_spec_stats(&spec));
let visual = image_spec_legend_visual(&spec, kind);
let data = image_spec_retained_data(&spec);
let handle = self.backend.add_image(spec);
self.record_item(handle, kind, bounds, stats, visual);
self.set_retained_data(handle, data);
handle
}
pub fn update_image_spec(&mut self, handle: ItemHandle, spec: ImageSpec<'_>) -> bool {
let bounds = image_spec_bounds(&spec);
let stats = Some(image_spec_stats(&spec));
let kind = self
.item_kind(handle)
.filter(|kind| kind.is_image_like())
.unwrap_or(PlotItemKind::Image);
let visual = image_spec_legend_visual(&spec, kind);
let data = image_spec_retained_data(&spec);
if self.backend.update_image(handle, spec) {
self.update_item_record(handle, kind, bounds, stats, visual);
self.set_retained_data(handle, data);
true
} else {
false
}
}
pub fn try_update_image(
&mut self,
handle: ItemHandle,
width: u32,
height: u32,
data: &[f32],
colormap: Colormap,
) -> Result<bool, PlotDataError> {
validate_image_len(width, height, data.len())?;
Ok(self.update_image_spec(handle, ImageSpec::scalar(width, height, data, colormap)))
}
pub fn try_update_rgba_image(
&mut self,
handle: ItemHandle,
width: u32,
height: u32,
data: &[[u8; 4]],
) -> Result<bool, PlotDataError> {
validate_image_len(width, height, data.len())?;
Ok(self.update_image_spec(handle, ImageSpec::rgba(width, height, data)))
}
pub fn update_image_data(&mut self, handle: ItemHandle, image: &ImageData) -> bool {
self.update_image_spec(handle, image_spec_from_data(image))
}
pub fn add_mask(
&mut self,
width: u32,
height: u32,
mask: &[bool],
color: Color32,
) -> Result<ItemHandle, PlotDataError> {
self.add_mask_with_geometry(width, height, mask, color, ImageGeometry::default())
}
pub fn add_mask_with_geometry(
&mut self,
width: u32,
height: u32,
mask: &[bool],
color: Color32,
geometry: ImageGeometry,
) -> Result<ItemHandle, PlotDataError> {
validate_image_len(width, height, mask.len())?;
let rgba = mask_rgba_pixels(mask, color);
let mut spec = ImageSpec::rgba(width, height, &rgba);
spec.origin = geometry.origin;
spec.scale = geometry.scale;
spec.alpha = geometry.alpha;
Ok(self.add_image_spec_as_kind(spec, PlotItemKind::Mask))
}
pub fn add_rgba_mask(
&mut self,
width: u32,
height: u32,
pixels: &[[u8; 4]],
) -> Result<ItemHandle, PlotDataError> {
validate_image_len(width, height, pixels.len())?;
let spec = ImageSpec::rgba(width, height, pixels);
Ok(self.add_image_spec_as_kind(spec, PlotItemKind::Mask))
}
pub fn add_mask_with_legend(
&mut self,
width: u32,
height: u32,
mask: &[bool],
color: Color32,
legend: impl Into<String>,
) -> Result<ItemHandle, PlotDataError> {
let handle = self.add_mask(width, height, mask, color)?;
self.set_item_legend(handle, legend);
Ok(handle)
}
pub fn add_horizontal_profile_curve(
&mut self,
width: u32,
height: u32,
data: &[f32],
row: u32,
color: Color32,
) -> Result<ItemHandle, PlotDataError> {
let y = horizontal_profile_values(width, height, data, row)?;
let x: Vec<f64> = (0..width).map(|col| col as f64).collect();
Ok(self.add_curve(&x, &y, color))
}
pub fn add_vertical_profile_curve(
&mut self,
width: u32,
height: u32,
data: &[f32],
column: u32,
color: Color32,
) -> Result<ItemHandle, PlotDataError> {
let y = vertical_profile_values(width, height, data, column)?;
let x: Vec<f64> = (0..height).map(|row| row as f64).collect();
Ok(self.add_curve(&x, &y, color))
}
pub fn add_triangles(&mut self, spec: TriangleSpec<'_>) -> ItemHandle {
let bounds = xy_bounds(spec.x, spec.y, YAxis::Left);
let visual = triangle_spec_legend_visual(&spec);
let handle = self.backend.add_triangles(spec);
self.record_item(handle, PlotItemKind::Triangles, bounds, None, visual);
handle
}
pub fn add_triangles_data(&mut self, triangles: &Triangles) -> ItemHandle {
self.add_triangles(triangle_spec_from_data(triangles))
}
pub fn add_triangles_data_with_legend(
&mut self,
triangles: &Triangles,
legend: impl Into<String>,
) -> ItemHandle {
let handle = self.add_triangles_data(triangles);
self.set_item_legend(handle, legend);
handle
}
pub fn add_shape(&mut self, spec: ShapeSpec<'_>) -> ItemHandle {
let bounds = xy_bounds(spec.x, spec.y, YAxis::Left);
let visual = shape_spec_legend_visual(&spec);
let handle = self.backend.add_shape(spec);
self.record_item(handle, PlotItemKind::Shape, bounds, None, visual);
handle
}
pub fn add_shape_data(&mut self, shape: &Shape) -> ItemHandle {
self.add_shape(shape_spec_from_data(shape))
}
pub fn add_shape_data_with_legend(
&mut self,
shape: &Shape,
legend: impl Into<String>,
) -> ItemHandle {
let handle = self.add_shape_data(shape);
self.set_item_legend(handle, legend);
handle
}
pub fn add_rectangle(
&mut self,
x0: f64,
y0: f64,
x1: f64,
y1: f64,
color: Color32,
fill: bool,
) -> ItemHandle {
let x = [x0, x1];
let y = [y0, y1];
self.add_shape(ShapeSpec {
x: &x,
y: &y,
kind: ShapeKind::Rectangle,
color,
fill,
overlay: false,
line_style: LineStyle::Solid,
line_width: 1.0,
gap_color: None,
})
}
pub fn add_marker(&mut self, spec: MarkerSpec<'_>) -> ItemHandle {
let visual = marker_spec_legend_visual(&spec);
let handle = self.backend.add_marker(spec);
self.record_item(
handle,
PlotItemKind::Marker,
DataBounds::default(),
None,
visual,
);
handle
}
pub fn add_marker_data(&mut self, marker: &Marker) -> ItemHandle {
self.add_marker(marker_spec_from_data(marker))
}
pub fn add_marker_data_with_legend(
&mut self,
marker: &Marker,
legend: impl Into<String>,
) -> ItemHandle {
let handle = self.add_marker_data(marker);
self.set_item_legend(handle, legend);
handle
}
pub fn add_point_marker(
&mut self,
x: f64,
y: f64,
color: Color32,
symbol: Symbol,
) -> ItemHandle {
self.add_marker(MarkerSpec {
x: Some(x),
y: Some(y),
text: None,
color,
symbol: Some(symbol),
symbol_size: 8.0,
line_style: LineStyle::Solid,
line_width: 1.0,
y_axis: YAxis::Left,
bg_color: None,
is_draggable: false,
constraint: MarkerConstraint::None,
})
}
pub fn add_x_marker(&mut self, x: f64, color: Color32) -> ItemHandle {
self.add_marker(MarkerSpec {
x: Some(x),
y: None,
text: None,
color,
symbol: None,
symbol_size: 0.0,
line_style: LineStyle::Solid,
line_width: 1.0,
y_axis: YAxis::Left,
bg_color: None,
is_draggable: false,
constraint: MarkerConstraint::None,
})
}
pub fn add_y_marker(&mut self, y: f64, color: Color32, axis: YAxis) -> ItemHandle {
self.add_marker(MarkerSpec {
x: None,
y: Some(y),
text: None,
color,
symbol: None,
symbol_size: 0.0,
line_style: LineStyle::Solid,
line_width: 1.0,
y_axis: axis,
bg_color: None,
is_draggable: false,
constraint: MarkerConstraint::None,
})
}
pub fn marker_position(&self, handle: ItemHandle) -> Option<(f64, f64)> {
self.backend.marker(handle).map(Marker::position)
}
pub fn set_marker_position(&mut self, handle: ItemHandle, x: f64, y: f64) -> bool {
let Some(mut marker) = self.backend.marker(handle).cloned() else {
return false;
};
marker.drag(marker.position(), (x, y));
self.backend.update_marker(handle, marker);
self.events.push(PlotEvent::MarkerMoved { handle });
true
}
pub fn remove(&mut self, handle: ItemHandle) -> bool {
let kind = self.item_record(handle).map(|record| record.kind);
let removed = self.backend.remove(handle);
if removed {
self.item_records.retain(|record| record.handle != handle);
if self
.median_filter_original
.as_ref()
.is_some_and(|(h, _)| *h == handle)
{
self.median_filter_original = None;
}
if let Some(kind) = kind {
self.events.push(PlotEvent::ItemRemoved { handle, kind });
}
self.clear_active_if_missing();
self.recompute_data_bounds();
self.apply_auto_limits();
}
removed
}
pub fn clear(&mut self) {
let removed: Vec<(ItemHandle, PlotItemKind)> = self
.item_records
.iter()
.map(|record| (record.handle, record.kind))
.collect();
self.backend.clear_items();
self.item_records.clear();
self.median_filter_original = None;
for (handle, kind) in removed {
self.events.push(PlotEvent::ItemRemoved { handle, kind });
}
self.clear_active_if_missing();
self.recompute_data_bounds();
}
pub fn clear_curves(&mut self) {
self.remove_records_by_kinds(PlotItemKind::is_curve_like);
}
pub fn clear_images(&mut self) {
self.remove_records_by_kinds(PlotItemKind::is_image_like);
}
pub fn clear_items(&mut self) {
self.remove_records_by_kinds(|kind| {
matches!(kind, PlotItemKind::Shape | PlotItemKind::Triangles)
});
}
pub fn clear_markers(&mut self) {
self.remove_records_by_kinds(|kind| kind == PlotItemKind::Marker);
}
pub fn clear_histograms(&mut self) {
self.remove_records_by_kinds(|kind| kind == PlotItemKind::Histogram);
}
pub fn clear_scatters(&mut self) {
self.remove_records_by_kinds(|kind| kind == PlotItemKind::Scatter);
}
pub fn clear_masks(&mut self) {
self.remove_records_by_kinds(|kind| kind == PlotItemKind::Mask);
}
fn remove_if_kind(
&mut self,
handle: ItemHandle,
predicate: impl Fn(PlotItemKind) -> bool,
) -> bool {
if self.item_kind(handle).is_some_and(predicate) {
self.remove(handle)
} else {
false
}
}
pub fn remove_curve(&mut self, handle: ItemHandle) -> bool {
self.remove_if_kind(handle, PlotItemKind::is_curve_like)
}
pub fn remove_image(&mut self, handle: ItemHandle) -> bool {
self.remove_if_kind(handle, PlotItemKind::is_image_like)
}
pub fn remove_histogram(&mut self, handle: ItemHandle) -> bool {
self.remove_if_kind(handle, |kind| kind == PlotItemKind::Histogram)
}
pub fn remove_scatter(&mut self, handle: ItemHandle) -> bool {
self.remove_if_kind(handle, |kind| kind == PlotItemKind::Scatter)
}
pub fn remove_mask(&mut self, handle: ItemHandle) -> bool {
self.remove_if_kind(handle, |kind| kind == PlotItemKind::Mask)
}
pub fn remove_marker(&mut self, handle: ItemHandle) -> bool {
self.remove_if_kind(handle, |kind| kind == PlotItemKind::Marker)
}
pub fn remove_overlay_item(&mut self, handle: ItemHandle) -> bool {
self.remove_if_kind(handle, |kind| {
matches!(kind, PlotItemKind::Shape | PlotItemKind::Triangles)
})
}
pub fn get_items(&self) -> Vec<ItemHandle> {
self.backend.items_back_to_front()
}
pub fn get_all_curves(&self) -> Vec<ItemHandle> {
self.handles_by_predicate(PlotItemKind::is_curve_like)
}
pub fn get_all_images(&self) -> Vec<ItemHandle> {
self.handles_by_predicate(PlotItemKind::is_image_like)
}
pub fn get_all_markers(&self) -> Vec<ItemHandle> {
self.handles_by_kind(PlotItemKind::Marker)
}
pub fn get_all_histograms(&self) -> Vec<ItemHandle> {
self.handles_by_kind(PlotItemKind::Histogram)
}
pub fn get_all_scatters(&self) -> Vec<ItemHandle> {
self.handles_by_kind(PlotItemKind::Scatter)
}
pub fn get_all_masks(&self) -> Vec<ItemHandle> {
self.handles_by_kind(PlotItemKind::Mask)
}
fn handles_by_kind(&self, kind: PlotItemKind) -> Vec<ItemHandle> {
self.handles_by_predicate(|record_kind| record_kind == kind)
}
fn handles_by_predicate(&self, predicate: impl Fn(PlotItemKind) -> bool) -> Vec<ItemHandle> {
self.item_records
.iter()
.filter_map(|record| predicate(record.kind).then_some(record.handle))
.collect()
}
fn handle_by_legend_and_kind(
&self,
legend: &str,
predicate: impl Fn(PlotItemKind) -> bool,
) -> Option<ItemHandle> {
self.item_records.iter().find_map(|record| {
(record.legend.as_deref() == Some(legend) && predicate(record.kind))
.then_some(record.handle)
})
}
pub fn item_by_legend(&self, legend: &str) -> Option<ItemHandle> {
self.handle_by_legend_and_kind(legend, |_| true)
}
pub fn curve_by_legend(&self, legend: &str) -> Option<ItemHandle> {
self.handle_by_legend_and_kind(legend, PlotItemKind::is_curve_like)
}
pub fn image_by_legend(&self, legend: &str) -> Option<ItemHandle> {
self.handle_by_legend_and_kind(legend, PlotItemKind::is_image_like)
}
pub fn histogram_by_legend(&self, legend: &str) -> Option<ItemHandle> {
self.handle_by_legend_and_kind(legend, |kind| kind == PlotItemKind::Histogram)
}
pub fn scatter_by_legend(&self, legend: &str) -> Option<ItemHandle> {
self.handle_by_legend_and_kind(legend, |kind| kind == PlotItemKind::Scatter)
}
pub fn mask_by_legend(&self, legend: &str) -> Option<ItemHandle> {
self.handle_by_legend_and_kind(legend, |kind| kind == PlotItemKind::Mask)
}
pub fn item_kind(&self, handle: ItemHandle) -> Option<PlotItemKind> {
self.item_record(handle).map(|record| record.kind)
}
pub fn set_item_legend(&mut self, handle: ItemHandle, legend: impl Into<String>) -> bool {
let Some(record) = self.item_record_mut(handle) else {
return false;
};
record.legend = Some(legend.into());
let kind = record.kind;
self.events.push(PlotEvent::ItemUpdated { handle, kind });
true
}
pub fn clear_item_legend(&mut self, handle: ItemHandle) -> bool {
let Some(record) = self.item_record_mut(handle) else {
return false;
};
record.legend = None;
let kind = record.kind;
self.events.push(PlotEvent::ItemUpdated { handle, kind });
true
}
pub fn item_legend(&self, handle: ItemHandle) -> Option<&str> {
self.item_record(handle)
.and_then(|record| record.legend.as_deref())
}
pub fn set_curve_x_label(&mut self, handle: ItemHandle, label: impl Into<String>) -> bool {
let Some(record) = self.item_record_mut(handle) else {
return false;
};
record.x_label = Some(label.into());
let kind = record.kind;
self.events.push(PlotEvent::ItemUpdated { handle, kind });
true
}
pub fn clear_curve_x_label(&mut self, handle: ItemHandle) -> bool {
let Some(record) = self.item_record_mut(handle) else {
return false;
};
record.x_label = None;
let kind = record.kind;
self.events.push(PlotEvent::ItemUpdated { handle, kind });
true
}
pub fn curve_x_label(&self, handle: ItemHandle) -> Option<&str> {
self.item_record(handle)
.and_then(|record| record.x_label.as_deref())
}
pub fn set_curve_y_label(&mut self, handle: ItemHandle, label: impl Into<String>) -> bool {
let Some(record) = self.item_record_mut(handle) else {
return false;
};
record.y_label = Some(label.into());
let kind = record.kind;
self.events.push(PlotEvent::ItemUpdated { handle, kind });
true
}
pub fn clear_curve_y_label(&mut self, handle: ItemHandle) -> bool {
let Some(record) = self.item_record_mut(handle) else {
return false;
};
record.y_label = None;
let kind = record.kind;
self.events.push(PlotEvent::ItemUpdated { handle, kind });
true
}
pub fn curve_y_label(&self, handle: ItemHandle) -> Option<&str> {
self.item_record(handle)
.and_then(|record| record.y_label.as_deref())
}
fn legend_label(&self, record: &ItemRecord) -> String {
record
.legend
.clone()
.unwrap_or_else(|| format!("{} #{}", record.kind.as_str(), record.handle))
}
pub fn active_item(&self) -> Option<ItemHandle> {
self.active_item
}
pub fn set_active_item(&mut self, item: Option<ItemHandle>) -> bool {
if item.is_some_and(|handle| !self.has_item(handle)) {
return false;
}
if self.active_item == item {
return true;
}
let previous = self.active_item;
self.active_item = item;
if let Some(previous) = previous {
self.sync_curve_highlight(previous);
}
if let Some(current) = item {
self.sync_curve_highlight(current);
}
self.events.push(PlotEvent::ActiveItemChanged {
previous,
current: item,
});
true
}
pub fn active_curve(&self) -> Option<ItemHandle> {
self.active_item.filter(|handle| {
self.item_kind(*handle)
.is_some_and(PlotItemKind::is_curve_like)
})
}
pub fn active_curve_style(&self) -> &CurveStyle {
&self.active_curve_style
}
pub fn is_active_curve_handling(&self) -> bool {
self.active_curve_handling
}
pub fn set_active_curve_style(&mut self, style: CurveStyle) {
self.active_curve_style = style;
if let Some(handle) = self.active_curve() {
self.sync_curve_highlight(handle);
}
}
pub fn set_active_curve_handling(&mut self, enabled: bool) {
self.active_curve_handling = enabled;
if let Some(handle) = self.active_curve() {
self.sync_curve_highlight(handle);
}
}
pub fn active_curve_line_style(&self) -> Option<LineStyle> {
let handle = self.active_curve()?;
self.record_curve_data(handle)
.map(|data| data.line_style.clone())
}
pub fn is_default_plot_lines(&self) -> bool {
self.default_plot_lines
}
pub fn is_default_plot_points(&self) -> bool {
self.default_plot_points
}
pub fn set_default_plot_lines(&mut self, flag: bool) -> usize {
self.default_plot_lines = flag;
let line_style = if flag {
LineStyle::Solid
} else {
LineStyle::None
};
let mut changed = 0;
for handle in self.handles_by_kind(PlotItemKind::Curve) {
let Some(mut data) = self.record_curve_data(handle).cloned() else {
continue;
};
if data.line_style == line_style {
continue;
}
data.line_style = line_style.clone();
if self.update_curve_data(handle, &data) {
changed += 1;
}
}
changed
}
pub fn set_default_plot_points(&mut self, flag: bool) -> usize {
self.default_plot_points = flag;
let symbol = if flag { Some(Symbol::Circle) } else { None };
let mut changed = 0;
for handle in self.handles_by_kind(PlotItemKind::Curve) {
let Some(mut data) = self.record_curve_data(handle).cloned() else {
continue;
};
if data.symbol == symbol {
continue;
}
data.symbol = symbol;
if self.update_curve_data(handle, &data) {
changed += 1;
}
}
changed
}
pub fn cycle_curve_style(&mut self) -> (bool, bool) {
let next = crate::widget::actions::control::next_curve_style_state((
self.default_plot_lines,
self.default_plot_points,
));
self.set_default_plot_lines(next.0);
self.set_default_plot_points(next.1);
next
}
pub fn set_curve_y_axis(&mut self, handle: ItemHandle, axis: YAxis) -> bool {
let Some(mut data) = self.record_curve_data(handle).cloned() else {
return false;
};
data.y_axis = axis;
if !self.update_curve_data(handle, &data) {
return false;
}
self.recompute_data_bounds();
self.apply_auto_limits();
true
}
pub fn set_curve_points_visible(&mut self, handle: ItemHandle, visible: bool) -> bool {
let Some(mut data) = self.record_curve_data(handle).cloned() else {
return false;
};
let mut cache = self
.item_record(handle)
.and_then(|record| record.hidden_symbol);
let next = set_symbol_visibility(data.symbol, visible, &mut cache);
data.symbol = next;
if !self.update_curve_data(handle, &data) {
return false;
}
if let Some(record) = self.item_record_mut(handle) {
record.hidden_symbol = cache;
}
true
}
pub fn set_curve_lines_visible(&mut self, handle: ItemHandle, visible: bool) -> bool {
let Some(mut data) = self.record_curve_data(handle).cloned() else {
return false;
};
let mut cache = self
.item_record(handle)
.and_then(|record| record.hidden_line_style.clone());
let next = set_line_visibility(data.line_style.clone(), visible, &mut cache);
data.line_style = next;
if !self.update_curve_data(handle, &data) {
return false;
}
if let Some(record) = self.item_record_mut(handle) {
record.hidden_line_style = cache;
}
true
}
fn symbol_bearing_handles(&self) -> Vec<ItemHandle> {
self.item_records
.iter()
.filter(|record| record.curve_data.is_some())
.map(|record| record.handle)
.collect()
}
pub fn set_all_symbols(&mut self, symbol: Option<Symbol>) -> usize {
let mut changed = 0;
for handle in self.symbol_bearing_handles() {
let Some(mut data) = self.record_curve_data(handle).cloned() else {
continue;
};
if data.symbol == symbol {
continue;
}
data.symbol = symbol;
if self.update_curve_data(handle, &data) {
changed += 1;
}
}
changed
}
pub fn set_all_symbol_sizes(&mut self, size: f32) -> usize {
let mut changed = 0;
for handle in self.symbol_bearing_handles() {
let Some(mut data) = self.record_curve_data(handle).cloned() else {
continue;
};
if data.marker_size == size {
continue;
}
data.marker_size = size;
if self.update_curve_data(handle, &data) {
changed += 1;
}
}
changed
}
pub fn set_active_curve(&mut self, item: Option<ItemHandle>) -> bool {
if item.is_some_and(|handle| {
!self
.item_kind(handle)
.is_some_and(PlotItemKind::is_curve_like)
}) {
return false;
}
self.set_active_item(item)
}
pub fn active_image(&self) -> Option<ItemHandle> {
self.active_item.filter(|handle| {
self.item_kind(*handle)
.is_some_and(PlotItemKind::is_image_like)
})
}
pub fn set_active_image(&mut self, item: Option<ItemHandle>) -> bool {
if item.is_some_and(|handle| {
!self
.item_kind(handle)
.is_some_and(PlotItemKind::is_image_like)
}) {
return false;
}
self.set_active_item(item)
}
pub fn set_item_visible(&mut self, handle: ItemHandle, visible: bool) -> bool {
self.backend.set_item_visible(handle, visible)
}
pub fn is_item_visible(&self, handle: ItemHandle) -> bool {
self.backend.is_item_visible(handle)
}
pub fn set_item_z(&mut self, handle: ItemHandle, z: f32) -> bool {
self.backend.set_item_z(handle, z)
}
pub fn item_z_value(&self, handle: ItemHandle) -> f32 {
self.backend.item_z(handle)
}
pub fn item_stats(&self, handle: ItemHandle) -> Option<&ItemStats> {
self.item_record(handle)
.and_then(|record| record.stats.as_ref())
}
pub fn curve_stats(&self, handle: ItemHandle) -> Option<&CurveStats> {
match self.item_stats(handle)? {
ItemStats::Curve(stats) => Some(stats),
ItemStats::Image(_) => None,
}
}
pub fn image_stats(&self, handle: ItemHandle) -> Option<&ImageStats> {
match self.item_stats(handle)? {
ItemStats::Curve(_) => None,
ItemStats::Image(stats) => Some(stats),
}
}
pub fn show_legend(&mut self, ui: &mut egui::Ui) -> LegendResponse {
let rows: Vec<(ItemHandle, PlotItemKind, String, bool, bool, LegendVisual)> = self
.item_records
.iter()
.map(|record| {
let visible = self.backend.is_item_visible(record.handle);
(
record.handle,
record.kind,
self.legend_label(record),
self.active_item == Some(record.handle),
visible,
record.visual.clone(),
)
})
.collect();
let mut out = LegendResponse::default();
if rows.is_empty() {
ui.label("no items");
return out;
}
egui::Frame::new()
.inner_margin(1)
.stroke(ui.visuals().widgets.noninteractive.bg_stroke)
.show(ui, |ui| {
ui.spacing_mut().item_spacing = egui::Vec2::ZERO;
let width = legend_row_width(ui.available_width());
for (handle, kind, label, active, visible, visual) in rows {
let result =
legend_row_response(ui, width, kind, &label, active, visible, visual);
if result.row_clicked {
out.selected = Some(handle);
if self.set_active_item(Some(handle)) {
out.activated = Some(handle);
}
}
if result.eye_clicked {
self.backend.set_item_visible(handle, !visible);
out.visibility_changed = Some(handle);
}
result.row_response.context_menu(|ui| {
if let Some(action) = self.legend_context_menu_ui(ui, handle, kind, active)
{
out.context_action = Some((handle, action));
}
});
}
});
self.show_rename_popup(ui);
out
}
fn legend_context_menu_ui(
&mut self,
ui: &mut egui::Ui,
handle: ItemHandle,
kind: PlotItemKind,
active: bool,
) -> Option<LegendAction> {
let mut fired: Option<LegendAction> = None;
if ui
.add_enabled(!active, egui::Button::new("Set Active"))
.clicked()
{
self.set_active_item(Some(handle));
fired = Some(LegendAction::SetActive);
ui.close();
}
if matches!(kind, PlotItemKind::Curve) {
let (y_axis, symbol_visible, line_visible) = self
.record_curve_data(handle)
.map(|data| {
(
data.y_axis,
data.symbol.is_some(),
data.line_style.draws_line(),
)
})
.unwrap_or((YAxis::Left, false, false));
if ui
.add_enabled(y_axis != YAxis::Left, egui::Button::new("Map to Y Left"))
.clicked()
{
self.set_curve_y_axis(handle, YAxis::Left);
fired = Some(LegendAction::MapToLeft);
ui.close();
}
if ui
.add_enabled(y_axis != YAxis::Right, egui::Button::new("Map to Y Right"))
.clicked()
{
self.set_curve_y_axis(handle, YAxis::Right);
fired = Some(LegendAction::MapToRight);
ui.close();
}
let mut points = symbol_visible;
if ui.checkbox(&mut points, "Points").clicked() {
self.set_curve_points_visible(handle, points);
fired = Some(LegendAction::TogglePoints);
ui.close();
}
let mut lines = line_visible;
if ui.checkbox(&mut lines, "Lines").clicked() {
self.set_curve_lines_visible(handle, lines);
fired = Some(LegendAction::ToggleLines);
ui.close();
}
}
ui.separator();
if ui.button("Rename").clicked() {
let current = self
.item_record(handle)
.map(|record| self.legend_label(record))
.unwrap_or_default();
self.rename_state = Some((handle, current));
fired = Some(LegendAction::Rename);
ui.close();
}
if ui.button("Remove").clicked() {
self.remove(handle);
fired = Some(LegendAction::Remove);
ui.close();
}
fired
}
fn show_rename_popup(&mut self, ui: &mut egui::Ui) {
let Some((handle, mut buffer)) = self.rename_state.take() else {
return;
};
let mut keep_open = true;
let mut apply = false;
let id = ui.id().with(("legend_rename", handle));
let signals = crate::widget::detached::show_detached(
ui.ctx(),
id,
"Rename",
egui::vec2(260.0, 110.0),
None,
|ui| {
let edit = ui.add(egui::TextEdit::singleline(&mut buffer).desired_width(200.0));
if edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
apply = true;
}
ui.horizontal(|ui| {
if ui.button("Apply").clicked() {
apply = true;
}
if ui.button("Cancel").clicked() {
keep_open = false;
}
});
},
);
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
keep_open = false;
}
if apply {
self.set_item_legend(handle, buffer.clone());
keep_open = false;
}
if signals.close_requested {
keep_open = false;
}
if keep_open {
self.rename_state = Some((handle, buffer));
}
}
pub fn show_stats(&self, ui: &mut egui::Ui, handle: ItemHandle) -> bool {
let Some(record) = self.item_record(handle) else {
return false;
};
ui.label(self.legend_label(record));
match record.stats {
Some(ItemStats::Curve(stats)) => {
show_value_stats(ui, "x", stats.x);
show_value_stats(ui, "y", stats.y);
ui.label(format!("axis: {:?}", stats.y_axis));
}
Some(ItemStats::Image(stats)) => {
ui.label(format!("size: {} x {}", stats.width, stats.height));
ui.label(format!("pixels: {}", stats.pixel_count));
if let Some(scalar) = stats.scalar {
show_value_stats(ui, "value", scalar);
}
}
None => {
ui.label("no retained statistics");
}
}
true
}
pub fn show_active_stats(&self, ui: &mut egui::Ui) -> bool {
self.active_item
.is_some_and(|handle| self.show_stats(ui, handle))
}
fn stats_input(
&self,
handle: ItemHandle,
) -> Option<crate::widget::stats_widget::StatsInput<'_>> {
self.retained_data(handle).map(retained_data_to_stats_input)
}
pub fn feed_active_stats(
&self,
stats: &mut crate::widget::stats_widget::StatsWidget,
viewport: Option<((f64, f64), (f64, f64))>,
) -> bool {
let Some(handle) = self.active_item else {
stats.recompute(&[], viewport);
return false;
};
let label = self
.item_record(handle)
.map(|record| self.legend_label(record))
.unwrap_or_else(|| "item".to_owned());
match self.stats_input(handle) {
Some(input) => {
stats.recompute(&[(label.as_str(), input)], viewport);
true
}
None => {
stats.recompute(&[], viewport);
false
}
}
}
pub fn feed_all_stats(
&self,
stats: &mut crate::widget::stats_widget::StatsWidget,
viewport: Option<((f64, f64), (f64, f64))>,
) -> usize {
let labeled = self.all_stats_labeled_data();
let inputs: Vec<(&str, crate::widget::stats_widget::StatsInput<'_>)> = labeled
.iter()
.map(|(label, data)| (label.as_str(), retained_data_to_stats_input(data)))
.collect();
stats.recompute(&inputs, viewport);
inputs.len()
}
fn all_stats_labeled_data(&self) -> Vec<(String, &RetainedItemData)> {
self.item_records
.iter()
.filter_map(|record| {
record
.data
.as_ref()
.map(|data| (self.legend_label(record), data))
})
.collect()
}
pub fn feed_roi_stats(
&self,
widget: &mut crate::widget::roi_stats_widget::RoiStatsWidget,
) -> bool {
match self
.active_item
.and_then(|handle| self.retained_data(handle))
{
Some(data) => {
widget.set_rows(roi_stats_rows(self.rois(), data));
true
}
None => {
widget.set_rows(Vec::new());
false
}
}
}
pub fn show_roi_stats_widget(
&self,
ui: &mut egui::Ui,
widget: &mut crate::widget::roi_stats_widget::RoiStatsWidget,
) {
self.feed_roi_stats(widget);
widget.ui(ui);
}
pub fn feed_curves_roi_stats(
&self,
widget: &mut crate::widget::curves_roi_widget::CurvesRoiWidget,
) -> bool {
match self
.active_item
.and_then(|handle| self.retained_data(handle))
{
Some(RetainedItemData::Curve { x, y }) => {
widget.set_rows(curve_roi_rows(self.rois(), x, y));
true
}
_ => {
widget.set_rows(Vec::new());
false
}
}
}
pub fn show_curves_roi_widget(
&self,
ui: &mut egui::Ui,
widget: &mut crate::widget::curves_roi_widget::CurvesRoiWidget,
) {
self.feed_curves_roi_stats(widget);
widget.ui(ui);
}
pub fn set_fit_target(
&self,
fit: &mut crate::widget::fit_widget::FitWidget,
handle: ItemHandle,
) -> bool {
match self.retained_data(handle).and_then(retained_curve_xy) {
Some((x, y)) => {
fit.set_data(x, y);
fit.set_fit_range(Some(self.x_limits()));
true
}
None => false,
}
}
pub fn set_active_fit_target(&self, fit: &mut crate::widget::fit_widget::FitWidget) -> bool {
self.active_item
.is_some_and(|handle| self.set_fit_target(fit, handle))
}
pub fn sync_fit_overlay(
&mut self,
fit: &crate::widget::fit_widget::FitWidget,
source: ItemHandle,
) -> Option<ItemHandle> {
let record = self.item_record(source)?;
let legend = format!("Fit <{}>", self.legend_label(record));
let y_axis = match &record.stats {
Some(ItemStats::Curve(stats)) => stats.y_axis,
_ => YAxis::Left,
};
let existing = self.curve_by_legend(&legend);
match fit.fit_curve() {
Some((x, y)) => {
let handle = match existing {
Some(handle) => {
let curve = CurveData::new(x.to_vec(), y.to_vec(), Color32::RED);
self.update_curve_data(handle, &curve);
self.set_item_visible(handle, true);
handle
}
None => {
let handle = self.add_curve_with_legend(x, y, Color32::RED, legend);
let x_label = self.graph_x_label().map(ToOwned::to_owned);
let y_label = self.graph_y_label(y_axis).map(ToOwned::to_owned);
if let Some(rec) = self.item_record_mut(handle) {
rec.x_label = x_label;
rec.y_label = y_label;
}
handle
}
};
self.set_curve_y_axis(handle, y_axis);
Some(handle)
}
None => {
if let Some(handle) = existing {
self.set_item_visible(handle, false);
}
existing
}
}
}
pub fn show_active_stats_widget(
&self,
ui: &mut egui::Ui,
stats: &mut crate::widget::stats_widget::StatsWidget,
viewport: Option<((f64, f64), (f64, f64))>,
) {
match self.active_item.and_then(|handle| {
self.stats_input(handle).map(|input| {
let label = self
.item_record(handle)
.map(|record| self.legend_label(record))
.unwrap_or_else(|| "item".to_owned());
(label, input)
})
}) {
Some((label, input)) => {
stats.ui(ui, &[(label.as_str(), input)], viewport);
}
None => {
stats.ui(ui, &[], viewport);
}
}
}
pub fn show_all_stats_widget(
&self,
ui: &mut egui::Ui,
stats: &mut crate::widget::stats_widget::StatsWidget,
viewport: Option<((f64, f64), (f64, f64))>,
) {
let labeled = self.all_stats_labeled_data();
let inputs: Vec<(&str, crate::widget::stats_widget::StatsInput<'_>)> = labeled
.iter()
.map(|(label, data)| (label.as_str(), retained_data_to_stats_input(data)))
.collect();
stats.ui(ui, &inputs, viewport);
}
pub fn show_toolbar(&mut self, ui: &mut egui::Ui) -> ToolbarResponse {
let mut out = ToolbarResponse::default();
ui.scope(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
ui.horizontal_wrapped(|ui| {
self.show_toolbar_controls(ui, &mut out);
});
});
out
}
pub fn show_limits_toolbar(&mut self, ui: &mut egui::Ui) {
let (xmin0, xmax0, ymin0, ymax0) = self.backend.plot().limits;
ui.horizontal(|ui| {
ui.label("Limits:");
ui.label("X:");
let mut xmin = xmin0;
let mut xmax = xmax0;
let x_changed = ui.add(egui::DragValue::new(&mut xmin)).changed()
| ui.add(egui::DragValue::new(&mut xmax)).changed();
if x_changed {
let (lo, hi) = ordered_limits(xmin, xmax);
self.set_graph_x_limits(lo, hi);
}
ui.label("Y:");
let mut ymin = ymin0;
let mut ymax = ymax0;
let y_changed = ui.add(egui::DragValue::new(&mut ymin)).changed()
| ui.add(egui::DragValue::new(&mut ymax)).changed();
if y_changed {
let (lo, hi) = ordered_limits(ymin, ymax);
self.set_graph_y_limits(lo, hi, YAxis::Left);
}
});
}
pub fn show_toolbar_with<R>(
&mut self,
ui: &mut egui::Ui,
add_contents: impl FnOnce(&mut egui::Ui, &mut Self) -> R,
) -> (ToolbarResponse, R) {
let mut out = ToolbarResponse::default();
let mut extra = None;
ui.scope(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
ui.horizontal_wrapped(|ui| {
self.show_toolbar_controls(ui, &mut out);
ui.separator();
extra = Some(add_contents(ui, self));
});
});
(
out,
extra.expect("egui horizontal layout closure should run exactly once"),
)
}
pub fn show_with_toolbar(&mut self, ui: &mut egui::Ui) -> PlotWithToolbarResponse {
let toolbar = self.show_toolbar(ui);
let plot = self.show(ui);
PlotWithToolbarResponse { toolbar, plot }
}
pub fn show_with_toolbar_with<R>(
&mut self,
ui: &mut egui::Ui,
add_toolbar_contents: impl FnOnce(&mut egui::Ui, &mut Self) -> R,
) -> (PlotWithToolbarResponse, R) {
let (toolbar, extra) = self.show_toolbar_with(ui, add_toolbar_contents);
let plot = self.show(ui);
(PlotWithToolbarResponse { toolbar, plot }, extra)
}
pub fn show_profile_toolbar(&self, ui: &mut egui::Ui) -> ProfileMode {
let id = egui::Id::new(self.backend().plot().id).with("profile_mode");
let mut mode = ui
.data(|d| d.get_temp::<ProfileMode>(id))
.unwrap_or_default();
ui.horizontal(|ui| {
if ui
.selectable_label(mode == ProfileMode::None, "○")
.on_hover_text("No profile")
.clicked()
{
mode = ProfileMode::None;
}
if ui
.selectable_label(mode == ProfileMode::Horizontal, "H")
.on_hover_text("Horizontal profile (row slice)")
.clicked()
{
mode = ProfileMode::Horizontal;
}
if ui
.selectable_label(mode == ProfileMode::Vertical, "V")
.on_hover_text("Vertical profile (column slice)")
.clicked()
{
mode = ProfileMode::Vertical;
}
if ui
.selectable_label(mode == ProfileMode::Line, "L")
.on_hover_text("Line profile (draw line ROI)")
.clicked()
{
mode = ProfileMode::Line;
}
if ui
.selectable_label(mode == ProfileMode::Rectangle, "R")
.on_hover_text("Rectangle profile (draw rect ROI)")
.clicked()
{
mode = ProfileMode::Rectangle;
}
});
ui.data_mut(|d| d.insert_temp(id, mode));
mode
}
fn show_toolbar_controls(&mut self, ui: &mut egui::Ui, out: &mut ToolbarResponse) {
if toolbar_icon_button(ui, ToolbarIcon::Home, false, "Reset zoom").clicked() {
self.reset_zoom();
out.reset_zoom = true;
}
if toolbar_icon_button(ui, ToolbarIcon::ZoomIn, false, "Zoom in").clicked() {
crate::widget::actions::control::zoom_in(self);
out.zoom_in = true;
}
if toolbar_icon_button(ui, ToolbarIcon::ZoomOut, false, "Zoom out").clicked() {
crate::widget::actions::control::zoom_out(self);
out.zoom_out = true;
}
if toolbar_icon_button(ui, ToolbarIcon::ZoomBack, false, "Zoom back").clicked() {
crate::widget::actions::control::zoom_back(self);
out.zoom_back = true;
}
ui.separator();
let mode = self.interaction_mode();
if toolbar_icon_button(
ui,
ToolbarIcon::Select,
mode == PlotInteractionMode::Select,
"Select items and edit handles",
)
.clicked()
{
self.set_interaction_mode(PlotInteractionMode::Select);
out.interaction_mode_changed = true;
}
if toolbar_icon_button(
ui,
ToolbarIcon::Zoom,
mode == PlotInteractionMode::Zoom,
"Box zoom",
)
.clicked()
{
self.set_interaction_mode(PlotInteractionMode::Zoom);
out.interaction_mode_changed = true;
}
let mut zoom_x = self.plot().zoom_x_enabled();
let mut zoom_y = self.plot().zoom_y_enabled();
ui.menu_button("Zoom axes", |ui| {
let cx = ui.checkbox(&mut zoom_x, "X axis").changed();
let cy = ui.checkbox(&mut zoom_y, "Y axis").changed();
if cx || cy {
self.plot_mut().set_zoom_enabled_axes(zoom_x, zoom_y);
out.zoom_axes_changed = true;
}
})
.response
.on_hover_text("Choose which axes a box zoom affects");
if toolbar_icon_button(
ui,
ToolbarIcon::Pan,
mode == PlotInteractionMode::Pan,
"Pan",
)
.clicked()
{
self.set_interaction_mode(PlotInteractionMode::Pan);
out.interaction_mode_changed = true;
}
ui.separator();
let mut x_inv = self.is_x_inverted();
if toolbar_icon_button(ui, ToolbarIcon::InvertX, x_inv, "Invert X axis").clicked() {
x_inv = !x_inv;
self.set_x_inverted(x_inv);
out.x_inverted_changed = true;
}
let mut y_inv = self.is_y_inverted();
if toolbar_icon_button(ui, ToolbarIcon::InvertY, y_inv, "Invert Y axis").clicked() {
y_inv = !y_inv;
self.set_y_inverted(y_inv);
out.y_inverted_changed = true;
}
ui.separator();
let mut x_log = self.is_x_logarithmic();
if toolbar_icon_button(ui, ToolbarIcon::LogX, x_log, "Toggle X log scale").clicked() {
x_log = !x_log;
self.set_x_log(x_log);
out.x_log_changed = true;
}
let mut y_log = self.is_y_logarithmic();
if toolbar_icon_button(ui, ToolbarIcon::LogY, y_log, "Toggle Y log scale").clicked() {
y_log = !y_log;
self.set_y_log(y_log);
out.y_log_changed = true;
}
ui.separator();
let x_auto = self.plot().x_autoscale();
if toolbar_icon_button(
ui,
ToolbarIcon::AutoscaleX,
x_auto,
"Auto-scale X axis on reset zoom",
)
.clicked()
{
crate::widget::actions::control::toggle_x_autoscale(self);
out.autoscale_x_changed = true;
}
let y_auto = self.plot().y_autoscale();
if toolbar_icon_button(
ui,
ToolbarIcon::AutoscaleY,
y_auto,
"Auto-scale Y axis on reset zoom",
)
.clicked()
{
crate::widget::actions::control::toggle_y_autoscale(self);
out.autoscale_y_changed = true;
}
ui.separator();
let mut grid = self.graph_grid();
if toolbar_icon_button(ui, ToolbarIcon::Grid, grid, "Toggle grid").clicked() {
grid = !grid;
self.set_graph_grid_mode(if grid {
GraphGrid::MajorAndMinor
} else {
GraphGrid::None
});
out.grid_changed = true;
}
let mut minor = self.graph_minor_grid();
let minor_response = ui
.add_enabled_ui(grid, |ui| {
toolbar_icon_button(ui, ToolbarIcon::MinorGrid, minor, "Toggle minor grid")
})
.inner;
if minor_response.clicked() {
minor = !minor;
self.set_graph_minor_grid(minor);
out.minor_grid_changed = true;
}
ui.separator();
let mut aspect = self.is_keep_data_aspect_ratio();
if toolbar_icon_button(ui, ToolbarIcon::Aspect, aspect, "Keep data aspect ratio").clicked()
{
aspect = !aspect;
self.set_keep_data_aspect_ratio(aspect);
out.aspect_changed = true;
}
let mut cursor = self.graph_cursor();
if toolbar_icon_button(ui, ToolbarIcon::Cursor, cursor, "Show cursor coordinates").clicked()
{
cursor = !cursor;
self.set_graph_cursor(cursor);
out.cursor_changed = true;
}
ui.separator();
let show_axis = self.plot().axes_displayed();
if toolbar_icon_button(ui, ToolbarIcon::ShowAxis, show_axis, "Show/hide axes").clicked() {
crate::widget::actions::control::show_axis_toggle(self);
out.show_axis_changed = true;
}
let has_curve = self.active_curve().is_some();
let curve_style_response = ui
.add_enabled_ui(has_curve, |ui| {
toolbar_icon_button(
ui,
ToolbarIcon::CurveStyle,
false,
"Cycle active curve line style",
)
})
.inner;
if curve_style_response.clicked() {
crate::widget::actions::control::curve_style_cycle(self);
out.curve_style_changed = true;
}
ui.separator();
if toolbar_icon_button(ui, ToolbarIcon::Save, false, "Save figure or curve data").clicked()
{
let _ = self.save_dialog(DEFAULT_SAVE_SIZE);
out.save = true;
}
if toolbar_icon_button(ui, ToolbarIcon::Copy, false, "Copy figure to clipboard").clicked() {
let _ = self.copy_to_clipboard(DEFAULT_SAVE_SIZE);
out.copy = true;
}
if toolbar_icon_button(ui, ToolbarIcon::Print, false, "Print figure").clicked() {
self.print_dialog.open_with_system_printers();
out.print = true;
}
if let Some(action) = self.print_dialog.show(ui.ctx()) {
match action {
crate::widget::print_dialog::PrintDialogAction::Print { printer } => {
let _ = self.print_graph_to(&printer, DEFAULT_SAVE_SIZE);
}
crate::widget::print_dialog::PrintDialogAction::SaveToFile => {
let _ = self.save_dialog(DEFAULT_SAVE_SIZE);
}
}
}
}
pub fn set_limits(
&mut self,
xmin: f64,
xmax: f64,
ymin: f64,
ymax: f64,
y2: Option<(f64, f64)>,
) {
self.set_limits_internal(xmin, xmax, ymin, ymax, y2);
}
pub fn reset_zoom(&mut self) {
self.reset_zoom_to_data();
}
pub fn zoom_back(&mut self) -> bool {
let before = self.limits_snapshot();
let restored = self.backend.plot_mut().zoom_back();
self.push_limits_changed_if(before);
restored
}
pub fn x_limits(&self) -> (f64, f64) {
self.backend.x_limits()
}
pub fn get_graph_x_limits(&self) -> (f64, f64) {
self.x_limits()
}
pub fn set_graph_x_limits(&mut self, xmin: f64, xmax: f64) {
let (_, _, ymin, ymax) = self.backend.plot().limits;
self.set_limits_internal(xmin, xmax, ymin, ymax, self.backend.plot().y2);
}
pub fn y_limits(&self, axis: YAxis) -> Option<(f64, f64)> {
self.backend.y_limits(axis)
}
pub fn get_graph_y_limits(&self, axis: YAxis) -> Option<(f64, f64)> {
self.y_limits(axis)
}
pub fn set_graph_y_limits(&mut self, ymin: f64, ymax: f64, axis: YAxis) {
match axis {
YAxis::Left => {
let (xmin, xmax, _, _) = self.backend.plot().limits;
self.set_limits_internal(xmin, xmax, ymin, ymax, self.backend.plot().y2);
}
YAxis::Right => {
let before = self.limits_snapshot();
self.backend.plot_mut().y2 = Some((ymin, ymax));
self.push_limits_changed_if(before);
}
YAxis::Extra(n) => {
if let Some(ax) = self.backend.plot_mut().extra_axis_mut(n) {
ax.range = Some((ymin, ymax));
}
}
}
}
pub fn add_extra_y_axis(&mut self, side: AxisSide) -> usize {
self.backend.plot_mut().add_extra_axis(side)
}
pub fn extra_y_axis_count(&self) -> usize {
self.backend.plot().extra_axes().len()
}
pub fn set_extra_y_autoscale(&mut self, index: usize, on: bool) -> bool {
match self.backend.plot_mut().extra_axis_mut(index) {
Some(ax) => {
ax.autoscale = on;
true
}
None => false,
}
}
pub fn set_extra_y_log(&mut self, index: usize, on: bool) -> bool {
match self.backend.plot_mut().extra_axis_mut(index) {
Some(ax) => {
ax.scale = if on { Scale::Log10 } else { Scale::Linear };
true
}
None => false,
}
}
pub fn is_extra_y_log(&self, index: usize) -> bool {
self.backend
.plot()
.extra_axis(index)
.map(|ax| ax.scale == Scale::Log10)
.unwrap_or(false)
}
pub fn set_x_log(&mut self, on: bool) {
if self.is_x_logarithmic() == on {
return;
}
self.backend.set_x_log(on);
if !on {
return;
}
let (vmin, vmax) = self.x_limits();
if vmin > 0.0 {
return;
}
let (lo, hi) = match self.positive_data_range(true) {
None => (1.0, 100.0),
Some((dmin, dmax)) => {
if vmax > 0.0 && dmin < vmax {
(dmin, vmax)
} else {
(dmin, dmax)
}
}
};
let (lo, hi) = clamp_axis_limits(lo, hi, true);
self.set_graph_x_limits(lo, hi);
}
pub fn set_graph_x_log(&mut self, on: bool) {
self.set_x_log(on);
}
pub fn is_x_logarithmic(&self) -> bool {
self.backend.plot().x_scale == Scale::Log10
}
pub fn is_graph_x_log(&self) -> bool {
self.is_x_logarithmic()
}
pub fn set_y_log(&mut self, on: bool) {
if self.is_y_logarithmic() == on {
return;
}
self.backend.set_y_log(on);
if !on {
return;
}
let (_, _, vmin, vmax) = self.backend.plot().limits;
if vmin > 0.0 {
return;
}
let (lo, hi) = match self.positive_data_range(false) {
None => (1.0, 100.0),
Some((dmin, dmax)) => {
if vmax > 0.0 && dmin < vmax {
(dmin, vmax)
} else {
(dmin, dmax)
}
}
};
let (lo, hi) = clamp_axis_limits(lo, hi, true);
self.set_graph_y_limits(lo, hi, YAxis::Left);
}
pub fn set_graph_y_log(&mut self, on: bool) {
self.set_y_log(on);
}
pub fn is_y_logarithmic(&self) -> bool {
self.backend.plot().y_scale == Scale::Log10
}
pub fn is_graph_y_log(&self) -> bool {
self.is_y_logarithmic()
}
pub fn set_x_inverted(&mut self, on: bool) {
self.backend.set_x_inverted(on);
}
pub fn is_x_inverted(&self) -> bool {
self.backend.plot().x_inverted
}
pub fn set_x_min_range(&mut self, min: Option<f64>) {
self.backend.plot_mut().x_constraints.min_range = min;
}
pub fn set_x_max_range(&mut self, max: Option<f64>) {
self.backend.plot_mut().x_constraints.max_range = max;
}
pub fn set_x_min_pos(&mut self, min: Option<f64>) {
self.backend.plot_mut().x_constraints.min_pos = min;
}
pub fn set_x_max_pos(&mut self, max: Option<f64>) {
self.backend.plot_mut().x_constraints.max_pos = max;
}
pub fn set_y_min_range(&mut self, min: Option<f64>) {
self.backend.plot_mut().y_constraints.min_range = min;
}
pub fn set_y_max_range(&mut self, max: Option<f64>) {
self.backend.plot_mut().y_constraints.max_range = max;
}
pub fn set_y_min_pos(&mut self, min: Option<f64>) {
self.backend.plot_mut().y_constraints.min_pos = min;
}
pub fn set_y_max_pos(&mut self, max: Option<f64>) {
self.backend.plot_mut().y_constraints.max_pos = max;
}
pub fn x_constraints(&self) -> crate::core::plot::AxisConstraints {
self.backend.plot().x_constraints
}
pub fn y_constraints(&self) -> crate::core::plot::AxisConstraints {
self.backend.plot().y_constraints
}
pub fn set_y_inverted(&mut self, on: bool) {
self.backend.set_y_inverted(on);
}
pub fn is_y_inverted(&self) -> bool {
self.backend.plot().y_inverted
}
pub fn set_x_tick_count(&mut self, n: Option<usize>) {
self.backend.plot_mut().x_max_ticks = n;
}
pub fn x_tick_count(&self) -> Option<usize> {
self.backend.plot().x_max_ticks
}
pub fn set_y_tick_count(&mut self, n: Option<usize>) {
self.backend.plot_mut().y_max_ticks = n;
}
pub fn y_tick_count(&self) -> Option<usize> {
self.backend.plot().y_max_ticks
}
pub fn set_keep_data_aspect_ratio(&mut self, on: bool) {
if self.is_keep_data_aspect_ratio() == on {
return;
}
self.backend.set_keep_data_aspect_ratio(on);
self.force_reset_zoom();
}
pub fn is_keep_data_aspect_ratio(&self) -> bool {
self.backend.plot().keep_aspect
}
pub fn set_axes_margins(&mut self, margins: Margins) {
self.backend.set_axes_margins(margins);
}
pub fn axes_margins(&self) -> Margins {
self.backend.plot().margins
}
pub fn set_graph_title(&mut self, title: impl Into<String>) {
let title = title.into();
self.backend.set_title(Some(&title));
}
pub fn graph_title(&self) -> Option<&str> {
self.backend.plot().title.as_deref()
}
pub fn clear_graph_title(&mut self) {
self.backend.set_title(None);
}
pub fn set_graph_x_label(&mut self, label: impl Into<String>) {
let label = label.into();
self.backend.set_x_label(Some(&label));
}
pub fn graph_x_label(&self) -> Option<&str> {
self.backend.plot().x_label.as_deref()
}
pub fn clear_graph_x_label(&mut self) {
self.backend.set_x_label(None);
}
pub fn set_graph_y_label(&mut self, label: impl Into<String>, axis: YAxis) {
let label = label.into();
self.backend.set_y_label(Some(&label), axis);
}
pub fn graph_y_label(&self, axis: YAxis) -> Option<&str> {
match axis {
YAxis::Left => self.backend.plot().y_label.as_deref(),
YAxis::Right => self.backend.plot().y2_label.as_deref(),
YAxis::Extra(n) => self
.backend
.plot()
.extra_axis(n)
.and_then(|a| a.label.as_deref()),
}
}
pub fn clear_graph_y_label(&mut self, axis: YAxis) {
self.backend.set_y_label(None, axis);
}
pub fn set_foreground_colors(&mut self, foreground: Color32, grid: Color32) {
self.backend.set_foreground_colors(foreground, grid);
}
pub fn set_background_colors(&mut self, background: Color32, data_background: Color32) {
self.backend
.set_background_colors(background, data_background);
}
pub fn data_background_color(&self) -> Color32 {
self.backend.plot().data_background
}
pub fn foreground_color(&self) -> Option<Color32> {
self.backend.plot().foreground
}
pub fn grid_color(&self) -> Option<Color32> {
self.backend.plot().grid_color
}
pub fn set_graph_grid(&mut self, on: bool) {
self.backend.plot_mut().grid = if on {
GraphGrid::Major
} else {
GraphGrid::None
};
}
pub fn graph_grid(&self) -> bool {
self.backend.plot().grid.major()
}
pub fn set_graph_grid_mode(&mut self, mode: GraphGrid) {
self.backend.plot_mut().grid = mode;
}
pub fn graph_grid_mode(&self) -> GraphGrid {
self.backend.plot().grid
}
pub fn show_colorbar(&self) -> bool {
self.backend.plot().show_colorbar
}
pub fn set_show_colorbar(&mut self, show: bool) {
self.backend.plot_mut().show_colorbar = show;
}
pub fn interactive_colorbar(&self) -> bool {
self.backend.plot().colorbar_interactive
}
pub fn set_interactive_colorbar(&mut self, interactive: bool) {
self.backend.plot_mut().colorbar_interactive = interactive;
}
pub fn set_colorbar_histogram(&mut self, histogram: Option<(Vec<u64>, Vec<f64>)>) {
self.backend.plot_mut().colorbar_histogram = histogram;
}
pub fn set_colorbar_value_range(&mut self, range: Option<(f64, f64)>) {
self.backend.plot_mut().colorbar_value_range = range;
}
pub fn set_graph_minor_grid(&mut self, on: bool) {
self.backend.plot_mut().grid = if on {
GraphGrid::MajorAndMinor
} else if self.graph_grid() {
GraphGrid::Major
} else {
GraphGrid::None
};
}
pub fn graph_minor_grid(&self) -> bool {
self.backend.plot().grid.minor()
}
pub fn default_colormap(&self) -> &Colormap {
&self.default_colormap
}
pub fn set_default_colormap(&mut self, colormap: Colormap) {
self.default_colormap = colormap;
}
pub fn colorbar_colormap(&self) -> Option<&Colormap> {
self.backend.plot().colormap.as_ref()
}
pub fn get_image_pixels_raw(&self) -> Option<Vec<f64>> {
match self
.active_item
.and_then(|handle| self.retained_data(handle))
{
Some(RetainedItemData::Image { data, .. }) => Some(data.clone()),
_ => None,
}
}
pub fn autoscale_active_image(&mut self, mode: AutoscaleMode) -> Option<(f64, f64)> {
let handle = self.active_item?;
let (data, width, height, base, attrs) = match self.retained_data(handle)? {
retained @ RetainedItemData::Image {
data,
width,
height,
colormap,
..
} => (
data.clone(),
*width,
*height,
(**colormap).clone(),
retained.image_display_attrs()?,
),
RetainedItemData::Curve { .. }
| RetainedItemData::Histogram { .. }
| RetainedItemData::Scatter { .. } => return None,
};
let cm = autoscaled_colormap(&base, mode, &data);
let limits = (cm.vmin, cm.vmax);
let pixels: Vec<f32> = data.iter().map(|&v| v as f32).collect();
let mut spec = ImageSpec::scalar(width as u32, height as u32, &pixels, cm);
attrs.apply(&mut spec);
self.update_image_spec(handle, spec);
Some(limits)
}
pub fn set_active_image_levels(&mut self, vmin: f64, vmax: f64) -> bool {
let Some(handle) = self.active_item else {
return false;
};
let (data, width, height, mut cm, attrs) = match self.retained_data(handle) {
Some(
retained @ RetainedItemData::Image {
data,
width,
height,
colormap,
..
},
) => (
data.clone(),
*width,
*height,
(**colormap).clone(),
retained.image_display_attrs().expect("image has attrs"),
),
_ => return false,
};
cm.vmin = vmin;
cm.vmax = vmax;
let pixels: Vec<f32> = data.iter().map(|&v| v as f32).collect();
let mut spec = ImageSpec::scalar(width as u32, height as u32, &pixels, cm);
attrs.apply(&mut spec);
self.update_image_spec(handle, spec)
}
pub fn image_alpha(&self, handle: ItemHandle) -> Option<f32> {
match self.retained_data(handle) {
Some(RetainedItemData::Image { alpha, .. }) => Some(*alpha),
_ => None,
}
}
pub fn set_image_alpha(&mut self, handle: ItemHandle, alpha: f32) -> bool {
let (data, width, height, cm, mut attrs) = match self.retained_data(handle) {
Some(
retained @ RetainedItemData::Image {
data,
width,
height,
colormap,
..
},
) => (
data.clone(),
*width,
*height,
(**colormap).clone(),
retained.image_display_attrs().expect("image has attrs"),
),
_ => return false,
};
attrs.alpha = alpha.clamp(0.0, 1.0);
let pixels: Vec<f32> = data.iter().map(|&v| v as f32).collect();
let mut spec = ImageSpec::scalar(width as u32, height as u32, &pixels, cm);
attrs.apply(&mut spec);
self.update_image_spec(handle, spec)
}
pub fn active_image_handle(&self) -> Option<ItemHandle> {
let handle = self.active_item?;
matches!(
self.retained_data(handle),
Some(RetainedItemData::Image { .. })
)
.then_some(handle)
}
pub fn active_image_alpha(&self) -> Option<f32> {
self.active_item.and_then(|handle| self.image_alpha(handle))
}
pub fn set_active_image_alpha(&mut self, alpha: f32) -> bool {
match self.active_item {
Some(handle) => self.set_image_alpha(handle, alpha),
None => false,
}
}
pub fn apply_median_filter(&mut self, kernel_width: usize, conditional: bool) -> bool {
self.apply_median_filter_kernel(kernel_width, kernel_width, conditional)
}
pub fn apply_median_filter_1d(&mut self, kernel_width: usize, conditional: bool) -> bool {
self.apply_median_filter_kernel(kernel_width, 1, conditional)
}
fn apply_median_filter_kernel(
&mut self,
kernel_h: usize,
kernel_w: usize,
conditional: bool,
) -> bool {
let kernel_h = force_odd(kernel_h);
let kernel_w = force_odd(kernel_w);
let handle = match self.active_item {
Some(h) => h,
None => return false,
};
let (data, width, height, colormap, attrs) = match self.retained_data(handle) {
Some(
retained @ RetainedItemData::Image {
data,
width,
height,
colormap,
..
},
) => (
data.clone(),
*width,
*height,
(**colormap).clone(),
retained.image_display_attrs().expect("image has attrs"),
),
_ => return false,
};
let original = match &self.median_filter_original {
Some((h, orig)) if *h == handle => orig.clone(),
_ => data,
};
let filtered = crate::widget::actions::analysis::median_filter_2d(
&original,
width,
height,
kernel_h,
kernel_w,
conditional,
);
let pixels: Vec<f32> = filtered.iter().map(|&v| v as f32).collect();
let mut spec = ImageSpec::scalar(width as u32, height as u32, &pixels, colormap);
attrs.apply(&mut spec);
self.update_image_spec(handle, spec);
self.median_filter_original = Some((handle, original));
true
}
pub fn active_image_histogram(
&self,
n_bins: Option<usize>,
) -> Option<crate::widget::actions::analysis::PixelHistogram> {
let pixels = self.get_image_pixels_raw()?;
crate::widget::actions::analysis::pixel_intensity_histogram(&pixels, n_bins)
}
pub fn set_graph_cursor(&mut self, on: bool) {
self.backend.plot_mut().crosshair = on;
}
pub fn graph_cursor(&self) -> bool {
self.backend.plot().crosshair
}
pub fn data_to_pixel(&self, x: f64, y: f64, axis: YAxis) -> Option<egui::Pos2> {
self.backend.data_to_pixel(x, y, axis)
}
pub fn pixel_to_data(&self, p: egui::Pos2, axis: YAxis) -> Option<(f64, f64)> {
self.backend.pixel_to_data(p, axis)
}
pub fn plot_bounds_in_pixels(&self) -> Option<egui::Rect> {
self.backend.plot_bounds_in_pixels()
}
pub fn snap_cursor(&self, cursor: [f64; 2], mode: SnappingMode) -> Option<Snap> {
let items: Vec<SnapItem> = self
.item_records
.iter()
.map(|record| SnapItem {
kind: snap_item_kind(record.kind),
visible: self.is_item_visible(record.handle),
has_symbol: record
.curve_data
.as_ref()
.and_then(|curve| curve.symbol)
.is_some(),
active: self.active_item == Some(record.handle),
})
.collect();
let candidates = snapping_candidates(mode, &items);
if candidates.is_empty() {
return None;
}
let cursor_px = self.data_to_pixel(cursor[0], cursor[1], YAxis::Left)?;
let (cx, cy) = (f64::from(cursor_px.x), f64::from(cursor_px.y));
let mut best_sq = (SNAP_THRESHOLD_DIST * f64::from(self.last_pixels_per_point)).powi(2);
let mut best: Option<Snap> = None;
for &index in &candidates {
let record = &self.item_records[index];
let Some(curve) = record.curve_data.as_ref() else {
continue;
};
let axis = curve.y_axis;
match &record.data {
Some(RetainedItemData::Histogram {
edges,
counts,
centers,
..
}) => {
let Baseline::Scalar(baseline) = curve.baseline else {
continue;
};
let Some((hx, hy)) = self.pixel_to_data(cursor_px, axis) else {
continue;
};
if let Some(bin) = pick_filled_histogram(edges, counts, baseline, hx, hy) {
return Some(Snap {
index: bin,
data: [centers[bin], counts[bin]],
});
}
}
Some(RetainedItemData::Curve { x, y })
| Some(RetainedItemData::Scatter { x, y, .. }) => {
let has_line = curve.line_style != LineStyle::None;
let has_symbol = curve.symbol.is_some();
if !has_line && !has_symbol {
continue; }
let mut offset = PICK_OFFSET;
if has_symbol {
offset = offset.max(f64::from(curve.marker_size) / 2.0);
}
if has_line {
offset = offset.max(f64::from(curve.width) / 2.0);
}
let corner = |px: f64, py: f64| {
let p = match self.plot_bounds_in_pixels() {
Some(rect) => egui::pos2(
px.clamp(f64::from(rect.min.x), f64::from(rect.max.x) - 1.0) as f32,
py.clamp(f64::from(rect.min.y), f64::from(rect.max.y) - 1.0) as f32,
),
None => egui::pos2(px as f32, py as f32),
};
self.pixel_to_data(p, axis)
};
let (Some((bx0, by0)), Some((bx1, by1))) = (
corner(cx - offset, cy - offset),
corner(cx + offset, cy + offset),
) else {
continue;
};
let picked = pick_polyline_indices(
x,
y,
has_line,
bx0.min(bx1),
bx0.max(bx1),
by0.min(by1),
by0.max(by1),
);
let mut item_best: Option<(usize, f64)> = None;
for &pi in &picked {
let Some(p) = self.data_to_pixel(x[pi], y[pi], axis) else {
continue;
};
let (dx, dy) = (f64::from(p.x) - cx, f64::from(p.y) - cy);
let sq = dx * dx + dy * dy;
if sq.is_finite() && item_best.is_none_or(|(_, b)| sq < b) {
item_best = Some((pi, sq));
}
}
if let Some((pi, sq)) = item_best
&& sq <= best_sq
{
best = Some(Snap {
index: pi,
data: [x[pi], y[pi]],
});
best_sq = sq;
}
}
_ => {}
}
}
best
}
pub fn add_roi(&mut self, roi: Roi) -> usize {
self.backend.plot_mut().rois.push(ManagedRoi::new(roi));
let index = self.backend.plot().rois.len() - 1;
self.events.push(PlotEvent::RoiAdded { index });
index
}
pub fn rois(&self) -> &[ManagedRoi] {
&self.backend.plot().rois
}
pub fn rois_mut(&mut self) -> &mut [ManagedRoi] {
&mut self.backend.plot_mut().rois
}
pub fn clear_rois(&mut self) {
self.backend.plot_mut().clear_rois();
self.ruler_roi = None;
self.events.push(PlotEvent::RoisCleared);
}
pub fn remove_roi(&mut self, index: usize) {
if index >= self.backend.plot().rois.len() {
return; }
self.events.push(PlotEvent::RoiAboutToBeRemoved { index });
self.backend.plot_mut().remove_roi(index);
self.ruler_roi = match self.ruler_roi {
Some(r) if r == index => None,
Some(r) if r > index => Some(r - 1),
other => other,
};
}
pub fn add_managed_roi(&mut self, managed: ManagedRoi) -> usize {
self.backend.plot_mut().rois.push(managed);
let index = self.backend.plot().rois.len() - 1;
self.events.push(PlotEvent::RoiAdded { index });
index
}
pub fn ruler_active(&self) -> bool {
self.ruler_active
}
pub fn ruler_roi(&self) -> Option<usize> {
self.ruler_roi
}
pub fn set_ruler_active(&mut self, active: bool) {
if active == self.ruler_active {
return;
}
self.ruler_active = active;
if let Some(index) = self.ruler_roi {
self.set_roi_visible(index, active);
}
if active {
self.ruler_prev_mode = Some(self.interaction_mode);
self.set_interaction_mode(PlotInteractionMode::RoiCreate(RoiDrawKind::Line));
} else {
let restore = self
.ruler_prev_mode
.take()
.unwrap_or(PlotInteractionMode::Zoom);
self.set_interaction_mode(restore);
}
}
fn relabel_ruler(&mut self, index: usize) {
let label = match self.backend.plot().rois.get(index).map(|r| &r.roi) {
Some(Roi::Line { start, end }) => {
crate::widget::tool_buttons::RulerToolButton::distance_text(
[start.0, start.1],
[end.0, end.1],
)
}
_ => return,
};
self.set_roi_name(index, label);
}
#[must_use]
pub fn roi_interaction_mode(&self, index: usize) -> Option<RoiInteractionMode> {
self.backend.plot().rois.get(index)?.interaction_mode()
}
pub fn set_roi_interaction_mode(&mut self, index: usize, mode: RoiInteractionMode) -> bool {
let Some(roi) = self.backend.plot_mut().rois.get_mut(index) else {
return false;
};
if roi.set_interaction_mode(mode) {
self.events
.push(PlotEvent::RoiInteractionModeChanged { index, mode });
true
} else {
false
}
}
pub fn set_roi_color(&mut self, index: usize, color: Color32) {
if let Some(r) = self.backend.plot_mut().rois.get_mut(index) {
r.color = Some(color);
}
}
pub fn set_roi_name(&mut self, index: usize, name: impl Into<String>) {
if let Some(r) = self.backend.plot_mut().rois.get_mut(index) {
r.name = name.into();
}
}
pub fn set_roi_line_width(&mut self, index: usize, width: f32) {
if let Some(r) = self.backend.plot_mut().rois.get_mut(index) {
r.line_width = width;
}
}
pub fn set_roi_line_style(&mut self, index: usize, style: RoiLineStyle) {
if let Some(r) = self.backend.plot_mut().rois.get_mut(index) {
r.line_style = style;
}
}
pub fn set_roi_line_gap_color(&mut self, index: usize, gap_color: Option<Color32>) {
if let Some(r) = self.backend.plot_mut().rois.get_mut(index) {
r.gap_color = gap_color;
}
}
pub fn set_roi_fill(&mut self, index: usize, fill: bool) {
if let Some(r) = self.backend.plot_mut().rois.get_mut(index) {
r.fill = fill;
}
}
pub fn set_roi_visible(&mut self, index: usize, visible: bool) {
if let Some(r) = self.backend.plot_mut().rois.get_mut(index) {
r.visible = visible;
}
}
pub fn current_roi(&self) -> Option<usize> {
self.backend.plot().current_roi()
}
pub fn set_current_roi(&mut self, index: Option<usize>) {
let previous = self.backend.plot().current_roi();
self.backend.plot_mut().set_current_roi(index);
let current = self.backend.plot().current_roi();
if current != previous {
self.events
.push(PlotEvent::CurrentRoiChanged { previous, current });
}
}
pub fn show_roi_manager(&mut self, ui: &mut egui::Ui) -> Option<usize> {
let mut added: Option<usize> = None;
let mut remove_idx: Option<usize> = None;
let mut make_current: Option<usize> = None;
let mut rename: Option<(usize, String)> = None;
let current = self.current_roi();
egui::ScrollArea::vertical()
.max_height(200.0)
.show(ui, |ui| {
egui::Grid::new("roi_manager_table")
.num_columns(3)
.striped(true)
.show(ui, |ui| {
ui.label("Name");
ui.label("Region");
ui.label("");
ui.end_row();
for (i, managed) in self.backend.plot().rois.iter().enumerate() {
let mut name = managed.name.clone();
if ui
.add(
egui::TextEdit::singleline(&mut name)
.desired_width(90.0)
.hint_text("(unnamed)"),
)
.changed()
{
rename = Some((i, name));
}
let desc = roi_description(&managed.roi);
if ui
.selectable_label(current == Some(i), desc)
.on_hover_text("Make current")
.clicked()
{
make_current = Some(i);
}
if ui.small_button("×").on_hover_text("Remove").clicked() {
remove_idx = Some(i);
}
ui.end_row();
}
});
});
if let Some((idx, name)) = rename {
self.set_roi_name(idx, name);
}
if let Some(idx) = make_current {
self.set_current_roi(Some(idx));
}
if let Some(idx) = remove_idx {
self.remove_roi(idx);
}
let (x0, x1, y0, y1) = self.backend.plot().limits;
let cx = (x0 + x1) * 0.5;
let cy = (y0 + y1) * 0.5;
let dx = (x1 - x0) * 0.2;
let dy = (y1 - y0) * 0.2;
ui.horizontal_wrapped(|ui| {
if ui.button("+ Rect").clicked() {
let idx = self.add_roi(Roi::Rect {
x: (cx - dx, cx + dx),
y: (cy - dy, cy + dy),
});
added = Some(idx);
}
if ui.button("+ HRange").clicked() {
let idx = self.add_roi(Roi::HRange {
y: (cy - dy, cy + dy),
});
added = Some(idx);
}
if ui.button("+ VRange").clicked() {
let idx = self.add_roi(Roi::VRange {
x: (cx - dx, cx + dx),
});
added = Some(idx);
}
if ui.button("+ Point").clicked() {
let idx = self.add_roi(Roi::Point { x: cx, y: cy });
added = Some(idx);
}
if ui.button("+ Line").clicked() {
let idx = self.add_roi(Roi::Line {
start: (cx - dx, cy),
end: (cx + dx, cy),
});
added = Some(idx);
}
});
if !self.backend.plot().rois.is_empty() && ui.button("Clear all").clicked() {
self.clear_rois();
}
added
}
pub fn pick_item(&self, p: egui::Pos2, item: ItemHandle) -> Option<PickResult> {
self.backend.pick_item(p, item)
}
pub fn items_back_to_front(&self) -> Vec<ItemHandle> {
self.backend.items_back_to_front()
}
pub fn replot(&mut self) {
self.backend.replot();
}
pub fn save_graph(&self, path: &Path, size: (u32, u32)) -> Result<(), SaveError> {
self.backend.save_graph(path, size)
}
pub fn save_graph_with_format(
&self,
path: &Path,
size: (u32, u32),
format: SaveFormat,
dpi: u32,
) -> Result<(), SaveError> {
self.backend.save_graph_with_format(path, size, format, dpi)
}
pub fn save_to_path(&self, path: &Path, size: (u32, u32)) -> Result<bool, SaveError> {
use crate::widget::actions::io::{SaveTarget, curve_to_csv};
match SaveTarget::from_path(path) {
Some(SaveTarget::Figure(format)) => {
self.save_graph_with_format(path, size, format, DEFAULT_SAVE_DPI)?;
Ok(true)
}
Some(SaveTarget::CurveCsv) => {
let handle = self.active_curve().or_else(|| {
self.item_records
.iter()
.find(|r| r.kind == PlotItemKind::Curve && r.data.is_some())
.map(|r| r.handle)
});
let Some(handle) = handle else {
return Ok(false);
};
let Some((x, y)) = self.retained_data(handle).and_then(retained_curve_xy) else {
return Ok(false);
};
let record = self.item_record(handle);
let plot = self.backend.plot();
let xlabel = record
.and_then(|r| r.x_label.clone())
.or_else(|| plot.displayed_x_label())
.unwrap_or_default();
let ylabel = record
.and_then(|r| r.y_label.clone())
.or_else(|| plot.displayed_y_label())
.unwrap_or_default();
let curve_data = record.and_then(|r| r.curve_data.as_ref());
let csv = curve_to_csv(
x,
y,
&xlabel,
&ylabel,
curve_data.and_then(|c| c.x_error.as_ref()),
curve_data.and_then(|c| c.y_error.as_ref()),
);
std::fs::write(path, csv)?;
Ok(true)
}
None => Ok(false),
}
}
pub fn save_dialog(&self, size: (u32, u32)) -> Result<bool, SaveError> {
let Some(path) = rfd::FileDialog::new()
.add_filter("PNG figure", &["png"])
.add_filter("PPM figure", &["ppm"])
.add_filter("SVG figure", &["svg"])
.add_filter("TIFF figure", &["tif", "tiff"])
.add_filter("JPEG figure", &["jpg", "jpeg"])
.add_filter("EPS figure", &["eps"])
.add_filter("PDF figure", &["pdf"])
.add_filter("Curve CSV", &["csv"])
.save_file()
else {
return Ok(false);
};
self.save_to_path(&path, size)
}
pub fn save_rois_to_path(&self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
crate::core::roi_io::save_rois(path, self.rois())
}
pub fn load_rois_from_path(
&mut self,
path: impl AsRef<std::path::Path>,
) -> std::io::Result<()> {
let loaded = crate::core::roi_io::load_rois(path)?;
self.clear_rois(); for roi in loaded {
self.add_managed_roi(roi); }
Ok(())
}
pub fn save_rois_dialog(&self) -> std::io::Result<bool> {
let Some(path) = rfd::FileDialog::new()
.add_filter("rsplot ROIs", &["rois", "txt"])
.save_file()
else {
return Ok(false);
};
self.save_rois_to_path(&path)?;
Ok(true)
}
pub fn load_rois_dialog(&mut self) -> std::io::Result<bool> {
let Some(path) = rfd::FileDialog::new()
.add_filter("rsplot ROIs", &["rois", "txt"])
.pick_file()
else {
return Ok(false);
};
self.load_rois_from_path(&path)?;
Ok(true)
}
pub fn copy_to_clipboard(&self, size: (u32, u32)) -> Result<bool, SaveError> {
use crate::widget::actions::io::{decode_png_to_rgba, rgba_to_clipboard_image};
let mut path = std::env::temp_dir();
path.push(format!("rsplot-copy-{}.png", std::process::id()));
self.save_graph(&path, size)?;
let png = std::fs::read(&path)?;
let _ = std::fs::remove_file(&path);
let (w, h, rgba) = decode_png_to_rgba(&png)?;
let Some(image) = rgba_to_clipboard_image(&rgba, w, h) else {
return Err(SaveError::Readback("clipboard image shaping failed".into()));
};
let mut clipboard = arboard::Clipboard::new()
.map_err(|e| SaveError::Readback(format!("clipboard open: {e}")))?;
clipboard
.set_image(image)
.map_err(|e| SaveError::Readback(format!("clipboard set_image: {e}")))?;
Ok(true)
}
pub fn print_graph(&self, size: (u32, u32)) -> Result<bool, SaveError> {
let Some(printer) = printers::get_default_printer() else {
return Ok(false);
};
self.print_to_printer(&printer, size)
}
pub fn print_graph_to(&self, printer_name: &str, size: (u32, u32)) -> Result<bool, SaveError> {
let Some(printer) = printers::get_printer_by_name(printer_name) else {
return Ok(false);
};
self.print_to_printer(&printer, size)
}
fn print_to_printer(
&self,
printer: &printers::common::base::printer::Printer,
size: (u32, u32),
) -> Result<bool, SaveError> {
let path = print_temp_png_path(&std::env::temp_dir(), std::process::id());
self.save_graph(&path, size)?;
let submit = printer.print_file(
&path.to_string_lossy(),
printers::common::base::job::PrinterJobOptions::none(),
);
let _ = std::fs::remove_file(&path);
submit.map_err(|e| SaveError::Readback(format!("print submit: {}", e.message)))?;
Ok(true)
}
pub fn reset_zoom_to_data(&mut self) {
self.apply_limits_from_data_bounds();
}
fn apply_auto_limits(&mut self) {
if self.auto_reset_zoom && !self.data_bounds_empty() {
self.apply_limits_from_data_bounds();
}
}
fn positive_data_range(&self, want_x: bool) -> Option<(f64, f64)> {
let x_log = self.backend.plot().x_scale == Scale::Log10;
let y_log = self.backend.plot().y_scale == Scale::Log10;
let mut merged: Option<(f64, f64)> = None;
for record in &self.item_records {
if let Some((lo, hi)) = record_log_range(record, x_log, y_log, want_x) {
merged = Some(match merged {
None => (lo, hi),
Some((mlo, mhi)) => (mlo.min(lo), mhi.max(hi)),
});
}
}
merged
}
fn force_reset_zoom(&mut self) {
let extra = extra_data_ranges(&self.data_bounds);
if !extra.is_empty() {
self.backend.plot_mut().reset_extra_axes_to(&extra);
}
let range = raw_data_range_from_bounds(&self.data_bounds);
let before = self.limits_snapshot();
self.backend
.plot_mut()
.force_reset_zoom_to_data_range(range);
self.push_limits_changed_if(before);
}
fn data_bounds_empty(&self) -> bool {
self.data_bounds.x.is_none()
&& self.data_bounds.y_left.is_none()
&& self.data_bounds.y_right.is_none()
&& self.data_bounds.extra.iter().all(Option::is_none)
}
fn apply_limits_from_data_bounds(&mut self) {
let extra = extra_data_ranges(&self.data_bounds);
if !extra.is_empty() {
self.backend.plot_mut().reset_extra_axes_to(&extra);
}
let range = raw_data_range_from_bounds(&self.data_bounds);
let before = self.limits_snapshot();
self.backend.plot_mut().reset_zoom_to_data_range(range);
self.push_limits_changed_if(before);
}
}
pub struct Plot1D {
inner: PlotWidget,
}
impl Plot1D {
pub fn new(render_state: &RenderState, id: PlotId) -> Self {
let mut inner = PlotWidget::new(render_state, id);
inner.set_graph_x_label("X");
inner.set_graph_y_label("Y", YAxis::Left);
inner.set_graph_grid(true);
Self { inner }
}
pub fn add_histogram(
&mut self,
edges: &[f64],
counts: &[f64],
color: Color32,
) -> Result<ItemHandle, PlotDataError> {
self.inner.add_histogram(edges, counts, color)
}
pub fn add_scatter(&mut self, x: &[f64], y: &[f64], color: Color32) -> ItemHandle {
self.inner.add_scatter(x, y, color)
}
pub fn add_horizontal_profile_curve(
&mut self,
width: u32,
height: u32,
data: &[f32],
row: u32,
color: Color32,
) -> Result<ItemHandle, PlotDataError> {
self.inner
.add_horizontal_profile_curve(width, height, data, row, color)
}
pub fn add_vertical_profile_curve(
&mut self,
width: u32,
height: u32,
data: &[f32],
column: u32,
color: Color32,
) -> Result<ItemHandle, PlotDataError> {
self.inner
.add_vertical_profile_curve(width, height, data, column, color)
}
pub fn into_inner(self) -> PlotWidget {
self.inner
}
}
impl Deref for Plot1D {
type Target = PlotWidget;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl DerefMut for Plot1D {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
pub struct Plot2D {
inner: PlotWidget,
}
impl Plot2D {
pub fn new(render_state: &RenderState, id: PlotId) -> Self {
let mut inner = PlotWidget::new(render_state, id);
inner.set_graph_x_label("Columns");
inner.set_graph_y_label("Rows", YAxis::Left);
inner.set_graph_grid(false);
inner.set_keep_data_aspect_ratio(true);
inner.set_y_inverted(true);
Self { inner }
}
pub fn add_default_image(&mut self, width: u32, height: u32, data: &[f32]) -> ItemHandle {
self.inner.add_image_default(width, height, data)
}
pub fn try_add_default_image(
&mut self,
width: u32,
height: u32,
data: &[f32],
) -> Result<ItemHandle, PlotDataError> {
self.inner.try_add_image_default(width, height, data)
}
pub fn try_add_masked_image(
&mut self,
width: u32,
height: u32,
data: &[f32],
mask: &ScalarMask,
) -> Result<ItemHandle, PlotDataError> {
let masked = apply_image_mask(width, height, data, mask)?;
self.inner.try_add_image_default(width, height, &masked)
}
pub fn add_mask(
&mut self,
width: u32,
height: u32,
mask: &[bool],
color: Color32,
) -> Result<ItemHandle, PlotDataError> {
self.inner.add_mask(width, height, mask, color)
}
pub fn add_mask_with_geometry(
&mut self,
width: u32,
height: u32,
mask: &[bool],
color: Color32,
geometry: ImageGeometry,
) -> Result<ItemHandle, PlotDataError> {
self.inner
.add_mask_with_geometry(width, height, mask, color, geometry)
}
pub fn horizontal_profile(
&self,
width: u32,
height: u32,
data: &[f32],
row: u32,
) -> Result<Vec<f64>, PlotDataError> {
horizontal_profile_values(width, height, data, row)
}
pub fn vertical_profile(
&self,
width: u32,
height: u32,
data: &[f32],
column: u32,
) -> Result<Vec<f64>, PlotDataError> {
vertical_profile_values(width, height, data, column)
}
pub fn profile_at_cursor(
&self,
plot_response: &PlotResponse,
pixels: &[f32],
width: u32,
height: u32,
mode: ProfileMode,
) -> Option<(Vec<f64>, Vec<f64>)> {
if mode == ProfileMode::None {
return None;
}
let hover_px = plot_response.response.hover_pos()?;
let (data_x, data_y) = plot_response.transform.pixel_to_data(hover_px);
let col = data_x.floor() as i64;
let row = data_y.floor() as i64;
match mode {
ProfileMode::None => None,
ProfileMode::Horizontal => {
if row < 0 || row >= height as i64 {
return None;
}
horizontal_profile_values(width, height, pixels, row as u32)
.ok()
.map(|y| {
let x: Vec<f64> = (0..width as usize).map(|i| i as f64).collect();
(x, y)
})
}
ProfileMode::Vertical => {
if col < 0 || col >= width as i64 {
return None;
}
vertical_profile_values(width, height, pixels, col as u32)
.ok()
.map(|y| {
let x: Vec<f64> = (0..height as usize).map(|i| i as f64).collect();
(x, y)
})
}
_ => None,
}
}
pub fn show_median_filter(
&mut self,
ui: &mut egui::Ui,
params: &mut MedianFilterParams,
) -> bool {
ui.horizontal(|ui| {
ui.label("Kernel width:");
let mut width = params.kernel_width.max(1);
if ui
.add(egui::DragValue::new(&mut width).range(1..=99).speed(2.0))
.changed()
{
params.kernel_width = force_odd(width);
}
});
ui.checkbox(&mut params.conditional, "Conditional")
.on_hover_text("Replace a pixel only if it is the window min or max");
let mut applied = false;
let has_image = self.get_image_pixels_raw().is_some();
if ui
.add_enabled(has_image, egui::Button::new("Apply"))
.on_hover_text("Replace the active image with its median-filtered copy")
.clicked()
{
applied = self.apply_median_filter(params.kernel_width, params.conditional);
}
applied
}
pub fn show_median_filter_toolbar(&mut self, ui: &mut egui::Ui) -> bool {
let plot_id = self.backend().plot().id;
let open_id = egui::Id::new(plot_id).with("median_filter_open");
let params_id = egui::Id::new(plot_id).with("median_filter_params");
let mut open = ui.data(|d| d.get_temp::<bool>(open_id)).unwrap_or(false);
let has_image = self.get_image_pixels_raw().is_some();
let button = ui
.add_enabled_ui(has_image, |ui| {
toolbar_icon_button(ui, ToolbarIcon::MedianFilter, open, "Median filter")
})
.inner;
if button.clicked() {
open = !open;
}
let mut applied = false;
if open {
let mut params = ui
.data(|d| d.get_temp::<MedianFilterParams>(params_id))
.unwrap_or_default();
let signals = crate::widget::detached::show_detached(
ui.ctx(),
open_id.with("window"),
"Median filter",
egui::vec2(320.0, 220.0),
None,
|ui| {
applied = self.show_median_filter(ui, &mut params);
},
);
ui.data_mut(|d| d.insert_temp(params_id, params));
if signals.close_requested {
open = false;
}
}
ui.data_mut(|d| d.insert_temp(open_id, open));
applied
}
pub fn symbol_tool_button(&mut self, ui: &mut egui::Ui) {
let plot_id = self.backend().plot().id;
let size_id = egui::Id::new(plot_id).with("symbol_tool_size");
let mut size = ui.data(|d| d.get_temp::<f32>(size_id)).unwrap_or(7.0);
ui.menu_button("Symbol", |ui| {
ui.horizontal(|ui| {
ui.label("Size:");
if ui
.add(egui::DragValue::new(&mut size).range(1.0..=20.0).speed(0.5))
.on_hover_text("Marker size for every curve/scatter")
.changed()
{
self.set_all_symbol_sizes(size);
}
});
ui.separator();
if ui.button("None").clicked() {
self.set_all_symbols(None);
ui.close();
}
for symbol in Symbol::ALL {
if ui.button(symbol.name()).clicked() {
self.set_all_symbols(Some(symbol));
ui.close();
}
}
});
ui.data_mut(|d| d.insert_temp(size_id, size));
}
pub fn show_pixel_histogram(
&mut self,
ui: &mut egui::Ui,
n_bins: &mut Option<usize>,
) -> Option<crate::widget::actions::analysis::PixelHistogram> {
let histogram = self.active_image_histogram(*n_bins);
let Some(histo) = histogram else {
ui.label("No image with finite pixels.");
return None;
};
let mut bins = n_bins.unwrap_or(histo.n_bins).max(2);
ui.horizontal(|ui| {
ui.label("Bins:");
if ui
.add(egui::DragValue::new(&mut bins).range(2..=1024))
.changed()
{
*n_bins = Some(bins.max(2));
}
});
let histo = if *n_bins == Some(histo.n_bins) || n_bins.is_none() {
histo
} else {
match self.active_image_histogram(*n_bins) {
Some(h) => h,
None => return Some(histo),
}
};
use crate::widget::stats_widget::format_g_python;
ui.label(format!(
"min {} max {} mean {} std {} sum {}",
format_g_python(histo.min, 5),
format_g_python(histo.max, 5),
format_g_python(histo.mean, 5),
format_g_python(histo.std, 5),
format_g_python(histo.sum, 5),
));
let max_count = histo.counts.iter().copied().max().unwrap_or(0).max(1) as f32;
let desired = egui::vec2(ui.available_width().max(120.0), 120.0);
let (rect, _resp) = ui.allocate_exact_size(desired, egui::Sense::hover());
if ui.is_rect_visible(rect) {
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 0.0, ui.visuals().extreme_bg_color);
let n = histo.counts.len().max(1);
let bar_w = rect.width() / n as f32;
let fill = Color32::from_rgb(0x66, 0xaa, 0xd7); for (i, &count) in histo.counts.iter().enumerate() {
let h = (count as f32 / max_count) * rect.height();
let x0 = rect.left() + i as f32 * bar_w;
let bar = egui::Rect::from_min_max(
egui::pos2(x0, rect.bottom() - h),
egui::pos2(x0 + bar_w, rect.bottom()),
);
painter.rect_filled(bar.shrink(0.5), 0.0, fill);
}
}
Some(histo)
}
pub fn show_pixel_histogram_toolbar(&mut self, ui: &mut egui::Ui) -> bool {
let plot_id = self.backend().plot().id;
let open_id = egui::Id::new(plot_id).with("pixel_histogram_open");
let bins_id = egui::Id::new(plot_id).with("pixel_histogram_bins");
let mut open = ui.data(|d| d.get_temp::<bool>(open_id)).unwrap_or(false);
let has_image = self.get_image_pixels_raw().is_some();
let button = ui
.add_enabled_ui(has_image, |ui| {
toolbar_icon_button(ui, ToolbarIcon::PixelHistogram, open, "Pixel intensity")
})
.inner;
if button.clicked() {
open = !open;
}
if open {
let mut n_bins = ui.data(|d| d.get_temp::<Option<usize>>(bins_id)).flatten();
let signals = crate::widget::detached::show_detached(
ui.ctx(),
open_id.with("window"),
"Pixel intensity",
egui::vec2(480.0, 360.0),
None,
|ui| {
self.show_pixel_histogram(ui, &mut n_bins);
},
);
ui.data_mut(|d| d.insert_temp(bins_id, n_bins));
if signals.close_requested {
open = false;
}
}
ui.data_mut(|d| d.insert_temp(open_id, open));
open
}
pub fn into_inner(self) -> PlotWidget {
self.inner
}
}
impl Deref for Plot2D {
type Target = PlotWidget;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl DerefMut for Plot2D {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum CompareMode {
OnlyA,
OnlyB,
#[default]
HalfHalf,
SplitHorizontal,
Subtract,
RedBlueGray,
RedBlueGrayNeg,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum CompareAlignment {
#[default]
Origin,
Center,
Stretch,
Auto,
}
const KEYPOINT_COLOR: Color32 = Color32::from_rgb(255, 0, 255);
pub struct CompareImages {
inner: PlotWidget,
width_a: u32,
height_a: u32,
width_b: u32,
height_b: u32,
data_a: Vec<f32>,
data_b: Vec<f32>,
colormap: Colormap,
composite_handle: Option<ItemHandle>,
split: f32,
mode: CompareMode,
alignment: CompareAlignment,
dirty: bool,
cursor: Option<[f64; 2]>,
separator: Option<ItemHandle>,
separator_horizontal: bool,
separator_dragging: bool,
composite_w: u32,
composite_h: u32,
auto: Option<SiftAlignment>,
keypoints_visible: bool,
keypoint_overlay: Option<ItemHandle>,
}
impl CompareImages {
pub fn new(render_state: &RenderState, id: PlotId) -> Self {
let mut inner = PlotWidget::new(render_state, id);
inner.set_keep_data_aspect_ratio(true);
Self {
inner,
width_a: 0,
height_a: 0,
width_b: 0,
height_b: 0,
data_a: Vec::new(),
data_b: Vec::new(),
colormap: Colormap::autoscale(ColormapName::Gray),
composite_handle: None,
split: 0.5,
mode: CompareMode::HalfHalf,
alignment: CompareAlignment::default(),
dirty: false,
cursor: None,
separator: None,
separator_horizontal: false,
separator_dragging: false,
composite_w: 0,
composite_h: 0,
auto: None,
keypoints_visible: false,
keypoint_overlay: None,
}
}
pub fn set_images(
&mut self,
shape_a: (u32, u32),
data_a: &[f32],
shape_b: (u32, u32),
data_b: &[f32],
colormap: Colormap,
) -> Result<(), PlotDataError> {
let (width_a, height_a) = shape_a;
let (width_b, height_b) = shape_b;
let expected_a = (width_a as usize).saturating_mul(height_a as usize);
if data_a.len() != expected_a {
return Err(PlotDataError::ImageDataLength {
expected: expected_a,
actual: data_a.len(),
});
}
let expected_b = (width_b as usize).saturating_mul(height_b as usize);
if data_b.len() != expected_b {
return Err(PlotDataError::ImageDataLength {
expected: expected_b,
actual: data_b.len(),
});
}
self.width_a = width_a;
self.height_a = height_a;
self.width_b = width_b;
self.height_b = height_b;
self.data_a = data_a.to_vec();
self.data_b = data_b.to_vec();
self.colormap = colormap;
self.dirty = true;
Ok(())
}
pub fn alignment(&self) -> CompareAlignment {
self.alignment
}
pub fn set_alignment(&mut self, alignment: CompareAlignment) {
if alignment != self.alignment {
self.alignment = alignment;
self.dirty = true;
}
}
pub fn transformation(&self) -> Option<AffineTransformation> {
self.auto.as_ref().map(|a| a.transformation)
}
pub fn keypoints_visible(&self) -> bool {
self.keypoints_visible
}
pub fn set_keypoints_visible(&mut self, visible: bool) {
if visible != self.keypoints_visible {
self.keypoints_visible = visible;
self.dirty = true;
}
}
pub fn matched_keypoints(&self) -> &[MatchedKeypoint] {
self.auto
.as_ref()
.map(|a| a.matches.as_slice())
.unwrap_or(&[])
}
pub fn split(&self) -> f32 {
self.split
}
pub fn set_split(&mut self, split: f32) {
let clamped = split.clamp(0.0, 1.0);
if (clamped - self.split).abs() > 1e-6 {
self.split = clamped;
self.dirty = true;
}
}
pub fn mode(&self) -> CompareMode {
self.mode
}
pub fn set_mode(&mut self, mode: CompareMode) {
if mode != self.mode {
self.mode = mode;
self.dirty = true;
}
}
pub fn show_toolbar(&mut self, ui: &mut egui::Ui) -> CompareMode {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
for (label, tooltip, m) in [
("A", "Show only image A", CompareMode::OnlyA),
("B", "Show only image B", CompareMode::OnlyB),
(
"½",
"Vertical split: A left / B right (drag the separator or slider)",
CompareMode::HalfHalf,
),
(
"═",
"Horizontal split: A top / B bottom (drag the separator or slider)",
CompareMode::SplitHorizontal,
),
("A-B", "Subtract: A minus B", CompareMode::Subtract),
(
"R/B",
"Composite: A in red, B in blue, half-sum in green",
CompareMode::RedBlueGray,
),
(
"R/B⁻",
"Negative composite: red-blue channels inverted",
CompareMode::RedBlueGrayNeg,
),
] {
if ui
.selectable_label(self.mode == m, label)
.on_hover_text(tooltip)
.clicked()
&& self.mode != m
{
self.mode = m;
self.dirty = true;
}
}
let is_split = matches!(
self.mode,
CompareMode::HalfHalf | CompareMode::SplitHorizontal
);
if is_split && !self.data_a.is_empty() {
ui.add_space(4.0);
if ui
.add(egui::Slider::new(&mut self.split, 0.0..=1.0).text("split"))
.changed()
{
self.dirty = true;
}
}
ui.add_space(8.0);
ui.label("align:");
for (label, tooltip, a) in [
(
"orig",
"Align both images at the top-left origin",
CompareAlignment::Origin,
),
(
"ctr",
"Center both images on the common grid",
CompareAlignment::Center,
),
(
"fit",
"Stretch image B to image A's shape (bilinear)",
CompareAlignment::Stretch,
),
(
"auto",
"Auto-align image B to A by SIFT keypoint registration",
CompareAlignment::Auto,
),
] {
if ui
.selectable_label(self.alignment == a, label)
.on_hover_text(tooltip)
.clicked()
&& self.alignment != a
{
self.alignment = a;
self.dirty = true;
}
}
ui.add_space(8.0);
let mut visible = self.keypoints_visible;
if ui
.checkbox(&mut visible, "kp")
.on_hover_text("Show the matched SIFT keypoints (AUTO alignment)")
.changed()
{
self.set_keypoints_visible(visible);
}
});
self.mode
}
pub fn show(&mut self, ui: &mut egui::Ui) -> PlotResponse {
if self.dirty && !self.data_a.is_empty() {
if self.alignment == CompareAlignment::Auto {
self.auto = self.compute_auto_alignment();
if self.auto.is_none() {
self.alignment = CompareAlignment::Origin;
}
} else {
self.auto = None;
}
let (composite, cw, ch) = self.build_composite();
self.composite_w = cw;
self.composite_h = ch;
if let Some(handle) = self.composite_handle {
self.inner
.try_update_rgba_image(handle, cw, ch, &composite)
.ok();
} else {
let handle = self.inner.add_rgba_image(cw, ch, &composite);
self.composite_handle = Some(handle);
}
self.sync_keypoint_overlay();
self.dirty = false;
}
self.sync_separator();
let response = self.inner.show(ui);
self.read_separator_drag(&response);
if let Some(cursor) = cursor_from_pointer_event(response.pointer_event.as_ref()) {
self.cursor = Some(cursor);
}
response
}
fn sync_separator(&mut self) {
let want = if self.data_a.is_empty() {
None
} else {
match self.mode {
CompareMode::HalfHalf => Some(false),
CompareMode::SplitHorizontal => Some(true),
_ => None,
}
};
let Some(horizontal) = want else {
if let Some(handle) = self.separator.take() {
self.inner.remove(handle);
}
self.separator_dragging = false;
return;
};
if self.separator.is_some() && self.separator_horizontal != horizontal {
if let Some(handle) = self.separator.take() {
self.inner.remove(handle);
}
self.separator_dragging = false;
}
let (x, y) = self.separator_position(horizontal);
match self.separator {
Some(handle) => {
if !self.separator_dragging {
self.inner.set_marker_position(handle, x, y);
}
}
None => {
let marker = if horizontal {
Marker::hline(y)
} else {
Marker::vline(x)
}
.with_color(Color32::BLUE)
.with_draggable(true);
self.separator = Some(self.inner.add_marker_data(&marker));
self.separator_horizontal = horizontal;
}
}
}
fn separator_position(&self, horizontal: bool) -> (f64, f64) {
if horizontal {
(0.0, self.split as f64 * self.composite_h as f64)
} else {
(self.split as f64 * self.composite_w as f64, 0.0)
}
}
fn read_separator_drag(&mut self, response: &PlotResponse) {
let Some(sep) = self.separator else { return };
if response.marker_drag_started == Some(sep) {
self.separator_dragging = true;
}
if (response.marker_moved == Some(sep) || response.marker_drag_finished == Some(sep))
&& let Some((x, y)) = self.inner.marker_position(sep)
{
let (pos, extent) = if self.separator_horizontal {
(y, self.composite_h)
} else {
(x, self.composite_w)
};
if extent > 0 {
self.set_split((pos / extent as f64) as f32);
}
}
if response.marker_drag_finished == Some(sep) {
self.separator_dragging = false;
}
}
pub fn data_to_pixel(&self, x: f64, y: f64, axis: YAxis) -> Option<egui::Pos2> {
self.inner.data_to_pixel(x, y, axis)
}
pub fn raw_pixel_data(&self, x: f64, y: f64) -> (Option<f32>, Option<f32>) {
let ((xa, ya), (xb, yb)) = match (self.alignment, self.auto.as_ref()) {
(CompareAlignment::Auto, Some(al)) => {
let [[a, b], [c, d]] = al.matrix;
let (tx, ty) = al.offset;
((x, y), (a * x + b * y + tx, c * x + d * y + ty))
}
_ => compare_aligned_coords(
self.alignment,
x,
y,
self.width_a,
self.height_a,
self.width_b,
self.height_b,
),
};
(
compare_pixel_at(self.width_a, self.height_a, &self.data_a, xa, ya),
compare_pixel_at(self.width_b, self.height_b, &self.data_b, xb, yb),
)
}
pub fn show_status_bar(&self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 12.0;
let (a_text, b_text) = match self.cursor {
Some([x, y]) => {
ui.label(format!("X: {x:.1} Y: {y:.1}"));
let (a, b) = self.raw_pixel_data(x, y);
(
format_compare_value(self.data_a.is_empty(), a),
format_compare_value(self.data_b.is_empty(), b),
)
}
None => ("NA".to_string(), "NA".to_string()),
};
ui.label(format!("ImageA: {a_text}"));
ui.label(format!("ImageB: {b_text}"));
if let Some(t) = self.transformation() {
ui.label(format!("Align: {}", align_summary(&t)))
.on_hover_text(align_tooltip(&t));
}
});
}
fn compute_auto_alignment(&self) -> Option<SiftAlignment> {
sift_auto_align(
&self.data_a,
self.width_a as usize,
self.height_a as usize,
&self.data_b,
self.width_b as usize,
self.height_b as usize,
)
}
fn sync_keypoint_overlay(&mut self) {
if let Some(handle) = self.keypoint_overlay.take() {
self.inner.remove(handle);
}
if !self.keypoints_visible {
return;
}
let Some(al) = self.auto.as_ref() else { return };
if al.matches.is_empty() {
return;
}
let xs: Vec<f64> = al.matches.iter().map(|m| m.ax as f64).collect();
let ys: Vec<f64> = al.matches.iter().map(|m| m.ay as f64).collect();
let handle =
self.inner
.add_scatter_with_symbol(&xs, &ys, KEYPOINT_COLOR, Symbol::Cross, 6.0);
self.keypoint_overlay = Some(handle);
}
fn build_composite(&self) -> (Vec<[u8; 4]>, u32, u32) {
let (data1, data2, cw, ch) = match (self.alignment, self.auto.as_ref()) {
(CompareAlignment::Auto, Some(al)) => {
let cw = al.width as u32;
let ch = al.height as u32;
let data1 = margin_image(
&self.data_a,
self.width_a as usize,
self.height_a as usize,
al.width,
al.height,
false,
);
(data1, al.aligned.clone(), cw, ch)
}
_ => align_compare_images(
self.alignment,
&self.data_a,
self.width_a,
self.height_a,
&self.data_b,
self.width_b,
self.height_b,
),
};
let w = cw as usize;
let h = ch as usize;
let colormap = seal_compare_colormap(&self.colormap, &data1, &data2);
let pixels = match self.mode {
CompareMode::OnlyA => colormap_to_rgba(cw, &data1, &colormap),
CompareMode::OnlyB => colormap_to_rgba(cw, &data2, &colormap),
CompareMode::HalfHalf => {
let rgba_a = colormap_to_rgba(cw, &data1, &colormap);
let rgba_b = colormap_to_rgba(cw, &data2, &colormap);
let split_col = (self.split * cw as f32).round() as usize;
split_composite(&rgba_a, &rgba_b, w, h, split_col, false)
}
CompareMode::SplitHorizontal => {
let rgba_a = colormap_to_rgba(cw, &data1, &colormap);
let rgba_b = colormap_to_rgba(cw, &data2, &colormap);
let split_row = (self.split * ch as f32).round() as usize;
split_composite(&rgba_a, &rgba_b, w, h, split_row, true)
}
CompareMode::Subtract => data1
.iter()
.zip(data2.iter())
.map(|(&a, &b)| {
let diff = (a - b).clamp(-1.0, 1.0);
if diff > 0.0 {
[(diff * 255.0) as u8, 0, 0, 255]
} else if diff < 0.0 {
[0, 0, ((-diff) * 255.0) as u8, 255]
} else {
[128, 128, 128, 255]
}
})
.collect(),
CompareMode::RedBlueGray => red_blue_gray_composite(&data1, &data2, &colormap, false),
CompareMode::RedBlueGrayNeg => red_blue_gray_composite(&data1, &data2, &colormap, true),
};
(pixels, cw, ch)
}
}
pub fn compare_pixel_at(width: u32, height: u32, data: &[f32], x: f64, y: f64) -> Option<f32> {
if x < 0.0 || y < 0.0 {
return None;
}
let col = x as usize;
let row = y as usize;
if col >= width as usize || row >= height as usize {
return None;
}
data.get(row * width as usize + col).copied()
}
fn margin_image(
src: &[f32],
src_w: usize,
src_h: usize,
dst_w: usize,
dst_h: usize,
center: bool,
) -> Vec<f32> {
let mut out = vec![0.0f32; dst_w * dst_h];
if src_w == 0 || src_h == 0 || src_w > dst_w || src_h > dst_h {
return out;
}
let (pos_row, pos_col) = if center {
(dst_h / 2 - src_h / 2, dst_w / 2 - src_w / 2)
} else {
(0, 0)
};
for r in 0..src_h {
let dst_base = (pos_row + r) * dst_w + pos_col;
let src_base = r * src_w;
out[dst_base..dst_base + src_w].copy_from_slice(&src[src_base..src_base + src_w]);
}
out
}
fn rescale_array(src: &[f32], src_w: usize, src_h: usize, dst_w: usize, dst_h: usize) -> Vec<f32> {
let mut out = vec![0.0f32; dst_w * dst_h];
if src_w == 0 || src_h == 0 || dst_w == 0 || dst_h == 0 {
return out;
}
let row_scale = if dst_h > 1 {
(src_h - 1) as f64 / (dst_h - 1) as f64
} else {
0.0
};
let col_scale = if dst_w > 1 {
(src_w - 1) as f64 / (dst_w - 1) as f64
} else {
0.0
};
let sample = |row: f64, col: f64| -> f32 {
let row = row.clamp(0.0, (src_h - 1) as f64);
let col = col.clamp(0.0, (src_w - 1) as f64);
let r0 = row.floor() as usize;
let c0 = col.floor() as usize;
let r1 = (r0 + 1).min(src_h - 1);
let c1 = (c0 + 1).min(src_w - 1);
let fr = row - r0 as f64;
let fc = col - c0 as f64;
let at = |r: usize, c: usize| src[r * src_w + c] as f64;
let top = at(r0, c0) * (1.0 - fc) + at(r0, c1) * fc;
let bot = at(r1, c0) * (1.0 - fc) + at(r1, c1) * fc;
(top * (1.0 - fr) + bot * fr) as f32
};
for or in 0..dst_h {
for oc in 0..dst_w {
out[or * dst_w + oc] = sample(or as f64 * row_scale, oc as f64 * col_scale);
}
}
out
}
fn align_compare_images(
mode: CompareAlignment,
a: &[f32],
wa: u32,
ha: u32,
b: &[f32],
wb: u32,
hb: u32,
) -> (Vec<f32>, Vec<f32>, u32, u32) {
match mode {
CompareAlignment::Origin | CompareAlignment::Center => {
let cw = wa.max(wb);
let ch = ha.max(hb);
let center = matches!(mode, CompareAlignment::Center);
let (cwu, chu) = (cw as usize, ch as usize);
let d1 = margin_image(a, wa as usize, ha as usize, cwu, chu, center);
let d2 = margin_image(b, wb as usize, hb as usize, cwu, chu, center);
(d1, d2, cw, ch)
}
CompareAlignment::Stretch => {
let d2 = rescale_array(b, wb as usize, hb as usize, wa as usize, ha as usize);
(a.to_vec(), d2, wa, ha)
}
CompareAlignment::Auto => {
let cw = wa.max(wb);
let ch = ha.max(hb);
let (cwu, chu) = (cw as usize, ch as usize);
let d1 = margin_image(a, wa as usize, ha as usize, cwu, chu, false);
let d2 = margin_image(b, wb as usize, hb as usize, cwu, chu, false);
(d1, d2, cw, ch)
}
}
}
fn compare_aligned_coords(
mode: CompareAlignment,
x: f64,
y: f64,
wa: u32,
ha: u32,
wb: u32,
hb: u32,
) -> ((f64, f64), (f64, f64)) {
match mode {
CompareAlignment::Origin => ((x, y), (x, y)),
CompareAlignment::Center => {
let xx = wa.max(wb) as f64;
let yy = ha.max(hb) as f64;
let xa = x - (xx - wa as f64) * 0.5;
let xb = x - (xx - wb as f64) * 0.5;
let ya = y - (yy - ha as f64) * 0.5;
let yb = y - (yy - hb as f64) * 0.5;
((xa, ya), (xb, yb))
}
CompareAlignment::Stretch => {
let xb = x * wb as f64 / wa as f64;
let yb = y * hb as f64 / ha as f64;
((x, y), (xb, yb))
}
CompareAlignment::Auto => ((x, y), (x, y)),
}
}
fn format_compare_value(no_image: bool, value: Option<f32>) -> String {
if no_image {
"no image".to_string()
} else {
match value {
Some(v) => format!("{v:.6}"),
None => "NA".to_string(),
}
}
}
fn isclose(a: f64, b: f64, atol: f64) -> bool {
(a - b).abs() <= atol + 1e-5 * b.abs()
}
fn align_summary(t: &AffineTransformation) -> String {
let notable_translation = !isclose(t.tx, 0.0, 0.01) || !isclose(t.ty, 0.0, 0.01);
let notable_scale = !isclose(t.sx, 1.0, 0.01) || !isclose(t.sy, 1.0, 0.01);
let notable_rotation = !isclose(t.rotation, 0.0, 0.01);
let mut parts = Vec::new();
if notable_translation {
parts.push("Translation");
}
if notable_scale {
parts.push("Scale");
}
if notable_rotation {
parts.push("Rotation");
}
if !parts.is_empty() {
return parts.join("+");
}
let any_translation = !isclose(t.tx, 0.0, 1e-8) || !isclose(t.ty, 0.0, 1e-8);
let any_scale = !isclose(t.sx, 1.0, 1e-8) || !isclose(t.sy, 1.0, 1e-8);
let any_rotation = !isclose(t.rotation, 0.0, 1e-8);
if any_translation || any_scale || any_rotation {
"No big changes".to_string()
} else {
"No changes".to_string()
}
}
fn align_tooltip(t: &AffineTransformation) -> String {
let mut lines = Vec::new();
if !isclose(t.tx, 0.0, 1e-8) {
lines.push(format!("Translation x: {:.3}px", t.tx));
}
if !isclose(t.ty, 0.0, 1e-8) {
lines.push(format!("Translation y: {:.3}px", t.ty));
}
if !isclose(t.sx, 1.0, 1e-8) {
lines.push(format!("Scale x: {:.3}", t.sx));
}
if !isclose(t.sy, 1.0, 1e-8) {
lines.push(format!("Scale y: {:.3}", t.sy));
}
if !isclose(t.rotation, 0.0, 1e-8) {
lines.push(format!(
"Rotation: {:.3}deg",
t.rotation * 180.0 / std::f64::consts::PI
));
}
if lines.is_empty() {
"No transformation".to_string()
} else {
lines.join("\n")
}
}
impl Deref for CompareImages {
type Target = PlotWidget;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl DerefMut for CompareImages {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
fn colormap_to_rgba(_width: u32, data: &[f32], colormap: &Colormap) -> Vec<[u8; 4]> {
data.iter()
.map(|&v| colormap.lut[colormap.lut_index(v as f64)])
.collect()
}
fn seal_compare_colormap(base: &Colormap, data1: &[f32], data2: &[f32]) -> Colormap {
let combined: Vec<f64> = data1
.iter()
.chain(data2.iter())
.map(|&v| f64::from(v))
.collect();
base.resolved(AutoscaleMode::MinMax, &combined)
}
fn red_blue_gray_composite(
data_a: &[f32],
data_b: &[f32],
colormap: &Colormap,
neg: bool,
) -> Vec<[u8; 4]> {
let byte = |v: f32| colormap.lut_index(v as f64) as u8;
data_a
.iter()
.zip(data_b.iter())
.map(|(&va, &vb)| {
let a = byte(va);
let b = byte(vb);
let g = a / 2 + b / 2;
if neg {
[255 - b, 255 - g, 255 - a, 255]
} else {
[a, g, b, 255]
}
})
.collect()
}
fn split_composite(
a: &[[u8; 4]],
b: &[[u8; 4]],
width: usize,
height: usize,
split: usize,
horizontal: bool,
) -> Vec<[u8; 4]> {
let mut out = vec![[0u8; 4]; width * height];
for row in 0..height {
let base = row * width;
for col in 0..width {
let i = base + col;
let use_a = if horizontal { row < split } else { col < split };
out[i] = if use_a { a[i] } else { b[i] };
}
}
out
}
const COLORBAR_WIDTH: f32 = 70.0;
const INTERACTIVE_COLORBAR_WIDTH: f32 = 175.0;
fn image_view_colorbar(colormap: &Colormap) -> crate::widget::colorbar::ColorBarWidget {
crate::widget::colorbar::ColorBarWidget::new(colormap.clone())
}
fn scatter_view_colorbar(
colormap: Option<&Colormap>,
) -> Option<crate::widget::colorbar::ColorBarWidget> {
colormap.map(image_view_colorbar)
}
fn scatter_masked_selection(mask: &[u8]) -> Vec<bool> {
mask.iter().map(|&level| level != 0).collect()
}
fn cursor_from_pointer_event(
event: Option<&crate::widget::interaction::PlotPointerEvent>,
) -> Option<[f64; 2]> {
use crate::widget::interaction::PlotPointerEvent;
match event? {
PlotPointerEvent::Moved { data, .. }
| PlotPointerEvent::Clicked { data, .. }
| PlotPointerEvent::DoubleClicked { data, .. } => Some([data.0, data.1]),
PlotPointerEvent::LimitsChanged { .. } => None,
}
}
fn image_view_profile_values(
mode: ProfileMode,
width: u32,
height: u32,
pixels: &[f32],
start: (f64, f64),
end: (f64, f64),
) -> Option<(Vec<f64>, Vec<f64>)> {
match mode {
ProfileMode::None => None,
ProfileMode::Line => line_profile_values(width, height, pixels, start, end).ok(),
ProfileMode::Horizontal => {
let row = end.1.floor();
if row < 0.0 || row >= height as f64 {
return None;
}
horizontal_profile_values(width, height, pixels, row as u32)
.ok()
.map(|y| {
let x: Vec<f64> = (0..width as usize).map(|i| i as f64).collect();
(x, y)
})
}
ProfileMode::Vertical => {
let col = end.0.floor();
if col < 0.0 || col >= width as f64 {
return None;
}
vertical_profile_values(width, height, pixels, col as u32)
.ok()
.map(|y| {
let x: Vec<f64> = (0..height as usize).map(|i| i as f64).collect();
(x, y)
})
}
ProfileMode::Rectangle => {
let rect = (
start.0.min(end.0),
start.0.max(end.0),
start.1.min(end.1),
start.1.max(end.1),
);
rect_profile_values(width, height, pixels, rect, true, ProfileMethod::Mean).ok()
}
}
}
fn profile_roi_from_drag(mode: ProfileMode, start: (f64, f64), end: (f64, f64)) -> Option<Roi> {
match mode {
ProfileMode::None => None,
ProfileMode::Line => Some(Roi::Line { start, end }),
ProfileMode::Horizontal => {
let row = end.1.floor();
Some(Roi::HRange { y: (row, row) })
}
ProfileMode::Vertical => {
let col = end.0.floor();
Some(Roi::VRange { x: (col, col) })
}
ProfileMode::Rectangle => Some(Roi::Rect {
x: (start.0.min(end.0), start.0.max(end.0)),
y: (start.1.min(end.1), start.1.max(end.1)),
}),
}
}
fn image_view_should_paint_mask(mode: PlotInteractionMode, mask_enabled: bool) -> bool {
mask_enabled && mode == PlotInteractionMode::MaskDraw
}
fn scalar_mask_from_level_buffer(width: u32, height: u32, levels: &[u8]) -> ScalarMask {
let mut mask = ScalarMask::new(width as usize, height as usize);
mask.set_mask_data(levels, width as usize);
mask
}
#[allow(clippy::too_many_arguments)]
fn image_view_image_spec<'a>(
width: u32,
height: u32,
pixels: &'a [f32],
colormap: &Colormap,
alpha: f32,
interpolation: InterpolationMode,
aggregation: AggregationMode,
aggregation_block: (u32, u32),
) -> ImageSpec<'a> {
let mut spec = ImageSpec::scalar(width, height, pixels, colormap.clone());
spec.alpha = alpha;
spec.interpolation = interpolation;
spec.aggregation = aggregation;
spec.aggregation_block = aggregation_block;
spec
}
pub struct ImageView {
image_plot: Plot2D,
histo_h: Plot1D,
histo_v: Plot1D,
sync_x: crate::widget::sync::SyncAxes,
sync_y: crate::widget::sync::SyncAxes,
image_handle: Option<ItemHandle>,
histo_h_curve: Option<ItemHandle>,
histo_v_curve: Option<ItemHandle>,
width: u32,
height: u32,
pixels: Vec<f32>,
colormap: Colormap,
alpha: crate::widget::alpha_slider::AlphaSlider,
interpolation: InterpolationMode,
aggregation: AggregationMode,
aggregation_block: (u32, u32),
position_info: crate::widget::position_info::PositionInfo,
cursor: Option<[f64; 2]>,
radar: crate::widget::radar_view::RadarView,
profile_mode: ProfileMode,
profile_window: crate::widget::profile_window::ProfileWindow,
profile_drag_start: Option<(f64, f64)>,
show_colorbar: bool,
show_side_histograms: bool,
interactive_colorbar: bool,
value_histogram: Option<(Vec<u64>, Vec<f64>)>,
value_range: (f64, f64),
histogram_norm: Option<Normalization>,
mask: crate::widget::mask_tools::MaskToolsWidget,
}
fn colorbar_column_width(show: bool, has_colorbar: bool) -> f32 {
if show && has_colorbar {
COLORBAR_WIDTH
} else {
0.0
}
}
fn row_content_width(avail_x: f32, side_columns: f32, gaps: u32, spacing: f32) -> f32 {
(avail_x - side_columns - gaps as f32 * spacing).max(0.0)
}
fn finite_minmax(data: &[f64]) -> Option<(f64, f64)> {
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for &v in data {
if v.is_finite() {
lo = lo.min(v);
hi = hi.max(v);
}
}
(lo.is_finite() && hi.is_finite()).then_some((lo, hi))
}
fn side_histogram_extent(show: bool, requested: f32) -> f32 {
if show { requested } else { 0.0 }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageHistogramAxis {
X,
Y,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ImageProfileHistogram {
pub data: Vec<f64>,
pub extent: (f64, f64),
}
fn image_column_sums(pixels: &[f32], w: usize, h: usize) -> Vec<f64> {
(0..w)
.map(|col| (0..h).map(|row| pixels[row * w + col] as f64).sum())
.collect()
}
fn image_row_sums(pixels: &[f32], w: usize, h: usize) -> Vec<f64> {
(0..h)
.map(|row| (0..w).map(|col| pixels[row * w + col] as f64).sum())
.collect()
}
fn image_value_at(
x: f64,
y: f64,
pixels: &[f32],
width: usize,
height: usize,
) -> Option<(f64, f64, f64)> {
if width == 0 || height == 0 || pixels.len() < width * height {
return None;
}
if x < 0.0 || y < 0.0 {
return None;
}
let col = x as usize;
let row = y as usize;
if col >= width || row >= height {
return None;
}
Some((col as f64, row as f64, pixels[row * width + col] as f64))
}
impl ImageView {
pub fn new(render_state: &RenderState, image_id: PlotId) -> Self {
let mut image_plot = Plot2D::new(render_state, image_id);
image_plot.set_graph_cursor(true);
image_plot.set_keep_data_aspect_ratio(true);
image_plot.set_show_colorbar(false);
let mut histo_h = Plot1D::new(render_state, image_id + 1);
histo_h.clear_graph_x_label();
histo_h.clear_graph_y_label(YAxis::Left);
histo_h.set_graph_grid(false);
histo_h.plot_mut().reserve_y_label_gutter = true;
histo_h.plot_mut().set_data_margins(DataMargins {
x_min: 0.0,
x_max: 0.0,
y_min: 0.1,
y_max: 0.1,
});
let mut histo_v = Plot1D::new(render_state, image_id + 2);
histo_v.clear_graph_x_label();
histo_v.clear_graph_y_label(YAxis::Left);
histo_v.set_graph_grid(false);
histo_v.plot_mut().reserve_x_label_gutter = true;
histo_v.plot_mut().set_data_margins(DataMargins {
x_min: 0.1,
x_max: 0.1,
y_min: 0.0,
y_max: 0.0,
});
Self {
image_plot,
histo_h,
histo_v,
sync_x: crate::widget::sync::SyncAxes::new().with_sync_y(false),
sync_y: crate::widget::sync::SyncAxes::new().with_sync_x(false),
image_handle: None,
histo_h_curve: None,
histo_v_curve: None,
width: 0,
height: 0,
pixels: Vec::new(),
colormap: Colormap::viridis(0.0, 1.0),
alpha: crate::widget::alpha_slider::AlphaSlider::default(),
interpolation: InterpolationMode::default(),
aggregation: AggregationMode::default(),
aggregation_block: (1, 1),
position_info: crate::widget::position_info::PositionInfo::with_xy(),
cursor: None,
radar: crate::widget::radar_view::RadarView::default(),
profile_mode: ProfileMode::None,
profile_window: crate::widget::profile_window::ProfileWindow::new(
render_state,
image_id + 3,
),
profile_drag_start: None,
show_colorbar: true,
show_side_histograms: true,
interactive_colorbar: false,
value_histogram: None,
value_range: (0.0, 1.0),
histogram_norm: None,
mask: crate::widget::mask_tools::MaskToolsWidget::new(0, 0),
}
}
pub fn show_colorbar(&self) -> bool {
self.show_colorbar
}
pub fn set_show_colorbar(&mut self, show: bool) {
self.show_colorbar = show;
}
pub fn interactive_colorbar(&self) -> bool {
self.interactive_colorbar
}
pub fn set_interactive_colorbar(&mut self, interactive: bool) {
self.interactive_colorbar = interactive;
}
pub fn is_side_histogram_displayed(&self) -> bool {
self.show_side_histograms
}
pub fn set_side_histogram_displayed(&mut self, show: bool) {
self.show_side_histograms = show;
}
pub fn set_image(
&mut self,
width: u32,
height: u32,
pixels: &[f32],
colormap: Colormap,
) -> Result<(), PlotDataError> {
let expected = (width as usize).saturating_mul(height as usize);
if pixels.len() != expected {
return Err(PlotDataError::ImageDataLength {
expected,
actual: pixels.len(),
});
}
self.width = width;
self.height = height;
self.pixels = pixels.to_vec();
self.histogram_norm = None;
self.value_histogram = None;
self.colormap = colormap.clone();
self.image_plot.set_default_colormap(colormap);
if self.mask.width != width || self.mask.height != height {
self.mask.reset_geometry(width, height);
}
self.mask.set_overlay_color_for_colormap(&self.colormap);
self.radar
.set_data_bounds(0.0, width as f64, 0.0, height as f64);
self.upload_image();
self.rebuild_histograms();
self.profile_window
.refresh_image(self.width, self.height, &self.pixels);
Ok(())
}
fn upload_image(&mut self) {
if self.width == 0 || self.pixels.is_empty() {
return;
}
let masked: Option<Vec<f32>> = if self.mask.width == self.width
&& self.mask.height == self.height
&& self.mask.mask.iter().any(|&level| level != 0)
{
let scalar_mask =
scalar_mask_from_level_buffer(self.width, self.height, &self.mask.mask);
Some(scalar_mask.apply(&self.pixels))
} else {
None
};
let pixels: &[f32] = masked.as_deref().unwrap_or(&self.pixels);
let spec = image_view_image_spec(
self.width,
self.height,
pixels,
&self.colormap,
self.alpha.alpha(),
self.interpolation,
self.aggregation,
self.aggregation_block,
);
if let Some(handle) = self.image_handle {
self.image_plot.update_image_spec(handle, spec);
} else {
let h = self.image_plot.add_image_spec(spec);
self.image_handle = Some(h);
}
}
fn ensure_value_histogram(&mut self) {
let norm = self.colormap.normalization;
if self.histogram_norm == Some(norm) {
return;
}
let data: Vec<f64> = self.pixels.iter().map(|&p| p as f64).collect();
let log = norm == Normalization::Log;
self.value_histogram = crate::core::histogram::compute_histogram(&data, None, log);
self.value_range = finite_minmax(&data).unwrap_or((self.colormap.vmin, self.colormap.vmax));
self.histogram_norm = Some(norm);
}
pub fn alpha(&self) -> f32 {
self.alpha.alpha()
}
pub fn set_alpha(&mut self, alpha: f32) {
self.alpha.set_alpha(alpha);
self.upload_image();
}
pub fn interpolation(&self) -> InterpolationMode {
self.interpolation
}
pub fn set_interpolation(&mut self, interpolation: InterpolationMode) {
if interpolation != self.interpolation {
self.interpolation = interpolation;
self.upload_image();
}
}
pub fn aggregation(&self) -> AggregationMode {
self.aggregation
}
pub fn aggregation_block(&self) -> (u32, u32) {
self.aggregation_block
}
pub fn set_aggregation(&mut self, mode: AggregationMode, block: (u32, u32)) {
let block = (block.0.max(1), block.1.max(1));
if mode != self.aggregation || block != self.aggregation_block {
self.aggregation = mode;
self.aggregation_block = block;
self.upload_image();
}
}
pub fn show_toolbar(&mut self, ui: &mut egui::Ui) {
ui.horizontal_wrapped(|ui| {
let mut interpolation = self.interpolation;
egui::ComboBox::from_label("interp")
.selected_text(match interpolation {
InterpolationMode::Nearest => "nearest",
InterpolationMode::Linear => "linear",
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut interpolation, InterpolationMode::Nearest, "nearest");
ui.selectable_value(&mut interpolation, InterpolationMode::Linear, "linear");
});
if interpolation != self.interpolation {
self.set_interpolation(interpolation);
}
let mut aggregation = self.aggregation;
egui::ComboBox::from_label("agg")
.selected_text(match aggregation {
AggregationMode::None => "none",
AggregationMode::Max => "max",
AggregationMode::Mean => "mean",
AggregationMode::Min => "min",
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut aggregation, AggregationMode::None, "none");
ui.selectable_value(&mut aggregation, AggregationMode::Max, "max");
ui.selectable_value(&mut aggregation, AggregationMode::Mean, "mean");
ui.selectable_value(&mut aggregation, AggregationMode::Min, "min");
});
let mut block = self.aggregation_block;
let bx = ui.add(
egui::DragValue::new(&mut block.0)
.range(1..=64)
.prefix("bx "),
);
let by = ui.add(
egui::DragValue::new(&mut block.1)
.range(1..=64)
.prefix("by "),
);
if aggregation != self.aggregation || bx.changed() || by.changed() {
self.set_aggregation(aggregation, block);
}
if ui
.selectable_label(self.show_colorbar, "colorbar")
.on_hover_text("Show/hide the colorbar")
.clicked()
{
crate::widget::actions::control::image_colorbar_toggle(self);
}
});
let response = self.alpha.ui(ui);
if response.changed() {
self.upload_image();
}
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
ui.label("profile:");
for (label, tooltip, mode) in [
("—", "Row profile (horizontal)", ProfileMode::Horizontal),
("|", "Column profile (vertical)", ProfileMode::Vertical),
("/", "Line profile (drag)", ProfileMode::Line),
("□", "Rectangle profile (drag)", ProfileMode::Rectangle),
] {
if ui
.selectable_label(self.profile_mode == mode, label)
.on_hover_text(tooltip)
.clicked()
{
let next = if self.profile_mode == mode {
ProfileMode::None
} else {
mode
};
self.set_profile_mode(next);
}
}
});
let in_mask_draw = self.image_plot.interaction_mode() == PlotInteractionMode::MaskDraw;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
ui.label("mask:");
if ui
.selectable_label(in_mask_draw, "✏")
.on_hover_text("Draw mask (pencil): primary drag paints the mask")
.clicked()
{
self.set_mask_draw(!in_mask_draw);
}
if in_mask_draw {
ui.selectable_value(
&mut self.mask.active_tool,
crate::widget::mask_tools::MaskTool::Pencil,
"pencil",
)
.on_hover_text("Paint mask");
ui.selectable_value(
&mut self.mask.active_tool,
crate::widget::mask_tools::MaskTool::Eraser,
"eraser",
)
.on_hover_text("Erase mask");
ui.add(egui::Slider::new(&mut self.mask.brush_size, 1..=50).text("brush"));
ui.add(
egui::DragValue::new(&mut self.mask.brush_size)
.range(1..=1024)
.speed(1.0),
)
.on_hover_text("Brush width in pixels (1-1024)");
if ui.button("clear mask").clicked() {
self.mask.clear_all();
self.mask.commit();
self.upload_image();
}
if ui
.button("invert")
.on_hover_text("Invert the current mask level")
.clicked()
{
self.mask.invert();
self.mask.commit();
self.upload_image();
}
let mask_matches_image = self.mask.width == self.width
&& self.mask.height == self.height
&& !self.pixels.is_empty();
if ui
.add_enabled(mask_matches_image, egui::Button::new("mask non-finite"))
.on_hover_text("Mask all NaN / infinite pixels at the current level")
.clicked()
{
self.mask.mask_not_finite(&self.pixels);
self.mask.commit();
self.upload_image();
}
}
});
if in_mask_draw {
use crate::widget::mask_tools::ThresholdMode;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
ui.label("threshold:");
egui::ComboBox::from_id_salt("mask_threshold_mode")
.selected_text(match self.mask.threshold_mode {
ThresholdMode::Below => "below",
ThresholdMode::Between => "between",
ThresholdMode::Above => "above",
})
.show_ui(ui, |ui| {
ui.selectable_value(
&mut self.mask.threshold_mode,
ThresholdMode::Below,
"below",
);
ui.selectable_value(
&mut self.mask.threshold_mode,
ThresholdMode::Between,
"between",
);
ui.selectable_value(
&mut self.mask.threshold_mode,
ThresholdMode::Above,
"above",
);
});
let mode = self.mask.threshold_mode;
let show_min = matches!(mode, ThresholdMode::Below | ThresholdMode::Between);
let show_max = matches!(mode, ThresholdMode::Between | ThresholdMode::Above);
if show_min {
ui.label("min");
ui.add(egui::DragValue::new(&mut self.mask.threshold_min).speed(0.1));
}
if show_max {
ui.label("max");
ui.add(egui::DragValue::new(&mut self.mask.threshold_max).speed(0.1));
}
let apply_label = match mode {
ThresholdMode::Below => "Mask below",
ThresholdMode::Between => "Mask between",
ThresholdMode::Above => "Mask above",
};
let mask_matches_image = self.mask.width == self.width
&& self.mask.height == self.height
&& !self.pixels.is_empty();
let (min, max) = (self.mask.threshold_min, self.mask.threshold_max);
if ui
.add_enabled(mask_matches_image, egui::Button::new(apply_label))
.on_hover_text("Mask pixels matching the threshold at the current level")
.clicked()
{
self.mask.update_threshold(&self.pixels, mode, min, max);
self.mask.commit();
self.upload_image();
}
if ui
.button("Min-max from colormap")
.on_hover_text("Copy the colormap's value range into the threshold fields")
.clicked()
{
self.mask.threshold_min = self.colormap.vmin as f32;
self.mask.threshold_max = self.colormap.vmax as f32;
}
});
}
}
pub fn set_mask_draw(&mut self, on: bool) {
if on {
crate::widget::actions::mode::mask_draw_mode(&mut self.image_plot);
if !matches!(
self.mask.active_tool,
crate::widget::mask_tools::MaskTool::Pencil
| crate::widget::mask_tools::MaskTool::Eraser
) {
self.mask.active_tool = crate::widget::mask_tools::MaskTool::Pencil;
}
} else {
crate::widget::actions::mode::zoom_mode(&mut self.image_plot);
self.mask.active_tool = crate::widget::mask_tools::MaskTool::None;
}
}
pub fn is_mask_draw(&self) -> bool {
self.image_plot.interaction_mode() == PlotInteractionMode::MaskDraw
}
pub fn mask(&self) -> &crate::widget::mask_tools::MaskToolsWidget {
&self.mask
}
pub fn mask_mut(&mut self) -> &mut crate::widget::mask_tools::MaskToolsWidget {
&mut self.mask
}
pub fn colormap(&self) -> &Colormap {
&self.colormap
}
pub fn colorbar(&self) -> crate::widget::colorbar::ColorBarWidget {
image_view_colorbar(&self.colormap)
}
pub fn show(&mut self, ui: &mut egui::Ui, histo_height: Option<f32>, histo_width: Option<f32>) {
let show_histos = self.show_side_histograms;
let histo_h_h = side_histogram_extent(show_histos, histo_height.unwrap_or(200.0));
let histo_v_w = side_histogram_extent(show_histos, histo_width.unwrap_or(200.0));
self.sync_x
.sync(&mut [self.image_plot.plot_mut(), self.histo_h.plot_mut()]);
self.sync_y
.sync(&mut [self.image_plot.plot_mut(), self.histo_v.plot_mut()]);
self.histo_v.plot_mut().reserve_title_gutter = self.image_plot.graph_title().is_some();
let avail = ui.available_size();
let colorbar_w = colorbar_column_width(self.show_colorbar, true);
let colorbar_w = if self.interactive_colorbar && colorbar_w > 0.0 {
INTERACTIVE_COLORBAR_WIDTH
} else {
colorbar_w
};
if self.interactive_colorbar && colorbar_w > 0.0 {
self.ensure_value_histogram();
}
let spacing = ui.spacing().item_spacing.x;
let row_gaps = u32::from(show_histos) + u32::from(colorbar_w > 0.0);
let img_w = row_content_width(avail.x, histo_v_w + colorbar_w, row_gaps, spacing);
if show_histos {
ui.horizontal(|ui| {
ui.allocate_ui(egui::vec2(img_w, histo_h_h), |ui| {
self.histo_h.show(ui);
});
let (xmin, xmax) = self.image_plot.x_limits();
if let Some((ymin, ymax)) = self.image_plot.y_limits(YAxis::Left) {
self.radar.set_viewport_limits(xmin, xmax, ymin, ymax);
}
let radar = self.radar.ui(
ui,
egui::vec2((avail.x - img_w - spacing).max(0.0), histo_h_h),
);
if let Some((rx0, rx1, ry0, ry1)) = radar.dragged_limits {
self.image_plot.set_limits(rx0, rx1, ry0, ry1, None);
}
});
}
let img_h = avail.y - histo_h_h;
let mut dragged_levels: Option<(f64, f64)> = None;
let response = ui.horizontal(|ui| {
let response = ui
.allocate_ui(egui::vec2(img_w, img_h), |ui| self.image_plot.show(ui))
.inner;
if show_histos {
ui.allocate_ui(egui::vec2(histo_v_w, img_h), |ui| {
self.histo_v.show(ui);
});
}
if colorbar_w > 0.0 {
if self.interactive_colorbar {
let guides = response.transform.area;
let bar = crate::widget::histogram_colorbar::HistogramColorBar::new(
self.colormap.clone(),
)
.with_data_range(self.value_range)
.with_histogram(self.value_histogram.clone())
.with_levels(self.colormap.vmin, self.colormap.vmax)
.with_bar_bounds(guides.top(), guides.bottom());
dragged_levels = bar.ui(ui, egui::vec2(colorbar_w, img_h)).dragged_levels;
} else {
self.colorbar().ui(ui, egui::vec2(colorbar_w, img_h));
}
}
response
});
if let Some((vmin, vmax)) = dragged_levels {
self.colormap.vmin = vmin;
self.colormap.vmax = vmax;
self.image_plot.set_default_colormap(self.colormap.clone());
self.upload_image();
}
let plot_response = response.inner;
if let Some(cursor) = cursor_from_pointer_event(plot_response.pointer_event.as_ref()) {
self.cursor = Some(cursor);
}
self.position_info.ui(ui, self.cursor);
self.handle_mask_paint(&plot_response);
self.handle_mask_shape_draw(ui, &plot_response);
self.draw_brush_preview(ui, &plot_response);
self.handle_profile_drag(&plot_response);
self.profile_window.show(ui.ctx());
}
fn handle_mask_paint(&mut self, plot_response: &PlotResponse) {
let mode = self.image_plot.interaction_mode();
let mask_enabled =
self.mask.width == self.width && self.mask.height == self.height && self.width != 0;
if !image_view_should_paint_mask(mode, mask_enabled) {
return;
}
let before = self.mask.mask.clone();
self.mask.handle_interaction(plot_response);
if self.mask.mask != before {
self.upload_image();
}
}
fn handle_mask_shape_draw(&mut self, ui: &egui::Ui, plot_response: &PlotResponse) {
let mode = self.image_plot.interaction_mode();
let mask_enabled =
self.mask.width == self.width && self.mask.height == self.height && self.width != 0;
if !image_view_should_paint_mask(mode, mask_enabled)
|| self.mask.active_tool.draw_mode().is_none()
{
self.mask.cancel_shape_draw();
return;
}
let before = self.mask.mask.clone();
let event = self.mask.handle_shape_draw(plot_response);
if self.mask.mask != before {
self.upload_image();
}
self.mask
.paint_shape_preview(ui, plot_response, event.as_ref());
}
fn draw_brush_preview(&self, ui: &egui::Ui, plot_response: &PlotResponse) {
let mode = self.image_plot.interaction_mode();
let mask_enabled =
self.mask.width == self.width && self.mask.height == self.height && self.width != 0;
if !image_view_should_paint_mask(mode, mask_enabled) {
return;
}
self.mask.paint_brush_preview(ui, plot_response);
}
fn handle_profile_drag(&mut self, plot_response: &PlotResponse) {
if self.profile_mode == ProfileMode::None || self.pixels.is_empty() {
self.profile_drag_start = None;
return;
}
let response = &plot_response.response;
let transform = &plot_response.transform;
if response.drag_started()
&& let Some(p) = response.interact_pointer_pos()
{
self.profile_drag_start = Some(transform.pixel_to_data(p));
}
if response.dragged()
&& let (Some(start), Some(p)) =
(self.profile_drag_start, response.interact_pointer_pos())
{
let end = transform.pixel_to_data(p);
if let Some(roi) = profile_roi_from_drag(self.profile_mode, start, end) {
let x_label = self
.image_plot
.graph_x_label()
.unwrap_or("Columns")
.to_string();
let y_label = self
.image_plot
.graph_y_label(YAxis::Left)
.unwrap_or("Rows")
.to_string();
self.profile_window.update_profile(
self.width,
self.height,
&self.pixels,
&roi,
&x_label,
&y_label,
);
self.profile_window.set_open(true);
}
}
if response.drag_stopped() {
self.profile_drag_start = None;
}
}
pub fn profile_mode(&self) -> ProfileMode {
self.profile_mode
}
pub fn set_profile_mode(&mut self, mode: ProfileMode) {
self.profile_mode = mode;
if mode == ProfileMode::None {
self.profile_drag_start = None;
self.profile_window.set_open(false);
}
}
pub fn profile_values(
&self,
mode: ProfileMode,
start: (f64, f64),
end: (f64, f64),
) -> Option<(Vec<f64>, Vec<f64>)> {
image_view_profile_values(mode, self.width, self.height, &self.pixels, start, end)
}
pub fn radar(&self) -> &crate::widget::radar_view::RadarView {
&self.radar
}
pub fn cursor(&self) -> Option<[f64; 2]> {
self.cursor
}
pub fn position_info(&self) -> &crate::widget::position_info::PositionInfo {
&self.position_info
}
pub fn position_info_mut(&mut self) -> &mut crate::widget::position_info::PositionInfo {
&mut self.position_info
}
pub fn position_info_values(&self) -> Vec<String> {
self.position_info.values(self.cursor)
}
pub fn image_plot(&self) -> &Plot2D {
&self.image_plot
}
pub fn image_plot_mut(&mut self) -> &mut Plot2D {
&mut self.image_plot
}
pub fn histogram(&self, axis: ImageHistogramAxis) -> Option<ImageProfileHistogram> {
if self.width == 0 || self.height == 0 || self.pixels.is_empty() {
return None;
}
let w = self.width as usize;
let h = self.height as usize;
let (data, extent) = match axis {
ImageHistogramAxis::X => (image_column_sums(&self.pixels, w, h), (0.0, w as f64)),
ImageHistogramAxis::Y => (image_row_sums(&self.pixels, w, h), (0.0, h as f64)),
};
Some(ImageProfileHistogram { data, extent })
}
pub fn value_changed(&self) -> Option<(f64, f64, f64)> {
let [x, y] = self.cursor?;
image_value_at(
x,
y,
&self.pixels,
self.width as usize,
self.height as usize,
)
}
fn rebuild_histograms(&mut self) {
if self.width == 0 || self.pixels.is_empty() {
return;
}
let w = self.width as usize;
let h = self.height as usize;
let col_sums = image_column_sums(&self.pixels, w, h);
let row_sums = image_row_sums(&self.pixels, w, h);
let col_x: Vec<f64> = (0..w).map(|i| i as f64).collect();
let row_y: Vec<f64> = (0..h).map(|i| i as f64).collect();
if let Some(h) = self.histo_h_curve {
self.histo_h
.update_curve_data(h, &CurveData::new(col_x, col_sums, Color32::YELLOW));
} else {
let h =
self.histo_h
.add_curve_with_legend(&col_x, &col_sums, Color32::YELLOW, "col sums");
self.histo_h_curve = Some(h);
}
if let Some(h) = self.histo_v_curve {
self.histo_v
.update_curve_data(h, &CurveData::new(row_sums, row_y, Color32::LIGHT_BLUE));
} else {
let h = self.histo_v.add_curve_with_legend(
&row_sums,
&row_y,
Color32::LIGHT_BLUE,
"row sums",
);
self.histo_v_curve = Some(h);
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ScatterVisualization {
#[default]
Points,
Solid,
IrregularGrid,
RegularGrid,
BinnedStatistic,
}
impl ScatterVisualization {
pub const ALL: [ScatterVisualization; 5] = [
ScatterVisualization::Points,
ScatterVisualization::Solid,
ScatterVisualization::RegularGrid,
ScatterVisualization::IrregularGrid,
ScatterVisualization::BinnedStatistic,
];
pub fn label(self) -> &'static str {
match self {
ScatterVisualization::Points => "Points",
ScatterVisualization::Solid => "Solid",
ScatterVisualization::RegularGrid => "Regular Grid",
ScatterVisualization::IrregularGrid => "Irregular Grid",
ScatterVisualization::BinnedStatistic => "Binned Statistic",
}
}
}
fn regular_grid_image(x: &[f64], y: &[f64], values: &[f64]) -> Option<GridImage> {
let grid = crate::core::scatter_viz::detect_regular_grid(x, y)?;
let (mut rows, mut cols) = grid.shape;
let n = values.len();
if n > rows * cols {
match grid.order {
crate::core::scatter_viz::GridMajorOrder::Row => {
rows = n.div_ceil(cols.max(1));
}
crate::core::scatter_viz::GridMajorOrder::Column => {
cols = n.div_ceil(rows.max(1));
}
}
}
if rows == 0 || cols == 0 {
return None;
}
let (xb, xe) = grid_axis_bounds(x);
let (yb, ye) = grid_axis_bounds(y);
let mut sx = if cols > 1 {
(xe - xb) / (cols - 1) as f64
} else {
0.0
};
let mut sy = if rows > 1 {
(ye - yb) / (rows - 1) as f64
} else {
0.0
};
match (sx == 0.0, sy == 0.0) {
(true, true) => {
sx = 1.0;
sy = 1.0;
}
(true, false) => sx = sy,
(false, true) => sy = sx,
(false, false) => {}
}
let origin = (xb - 0.5 * sx, yb - 0.5 * sy);
let mut data = vec![f64::NAN; rows * cols];
match grid.order {
crate::core::scatter_viz::GridMajorOrder::Row => {
for (i, &v) in values.iter().enumerate().take(rows * cols) {
data[i] = v;
}
}
crate::core::scatter_viz::GridMajorOrder::Column => {
for (i, &v) in values.iter().enumerate().take(rows * cols) {
let r = i % rows;
let c = i / rows;
data[r * cols + c] = v;
}
}
}
Some(GridImage {
data,
shape: (rows, cols),
origin,
scale: (sx, sy),
})
}
fn grid_axis_bounds(coord: &[f64]) -> (f64, f64) {
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
for &v in coord {
if v.is_finite() {
min = min.min(v);
max = max.max(v);
}
}
if min > max {
return (0.0, 0.0);
}
let first = coord.first().copied().unwrap_or(min);
if (first - min) <= (max - first) {
(min, max)
} else {
(max, min)
}
}
fn binned_statistic_image(bs: &crate::core::scatter_viz::BinnedStatistic) -> GridImage {
GridImage {
data: bs.select(crate::core::scatter_viz::BinnedStatisticFunction::Mean),
shape: bs.shape,
origin: bs.origin,
scale: bs.scale,
}
}
fn scatter_grid_image(
mode: ScatterVisualization,
x: &[f64],
y: &[f64],
values: &[f64],
resolution: (usize, usize),
) -> Option<GridImage> {
let (rows, cols) = resolution;
match mode {
ScatterVisualization::Points
| ScatterVisualization::Solid
| ScatterVisualization::IrregularGrid => None,
ScatterVisualization::RegularGrid => regular_grid_image(x, y, values),
ScatterVisualization::BinnedStatistic => {
crate::core::scatter_viz::binned_statistic(x, y, values, rows, cols)
.as_ref()
.map(binned_statistic_image)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ScatterPick {
pub index: usize,
pub x: f64,
pub y: f64,
pub value: f64,
}
const SCATTER_PICK_RADIUS_PX: f32 = crate::core::marker::DEFAULT_MARKER_SIZE;
pub fn scatter_pick_pixels(
cursor: (f32, f32),
points: &[(f32, f32)],
radius: f32,
) -> Option<usize> {
let r2 = radius * radius;
let mut best: Option<(usize, f32)> = None;
for (i, &(px, py)) in points.iter().enumerate() {
let d2 = (px - cursor.0).powi(2) + (py - cursor.1).powi(2);
if d2 > r2 {
continue;
}
let take = match best {
None => true,
Some((_, best_d2)) => d2 <= best_d2,
};
if take {
best = Some((i, d2));
}
}
best.map(|(i, _)| i)
}
fn nearest_candidate_in_data(
candidates: &[usize],
xs: &[f64],
ys: &[f64],
cx: f64,
cy: f64,
) -> Option<usize> {
let mut best: Option<(usize, f64)> = None;
for &i in candidates {
let d2 = (xs[i] - cx).powi(2) + (ys[i] - cy).powi(2);
let take = match best {
None => true,
Some((_, best_d2)) => d2 <= best_d2,
};
if take {
best = Some((i, d2));
}
}
best.map(|(i, _)| i)
}
pub fn scatter_position_info(
pick: Option<ScatterPick>,
) -> crate::widget::position_info::PositionInfo {
use crate::widget::position_info::{Converter, PositionInfo, format_value};
let columns: Vec<(String, Converter)> = vec![
(
"X".to_owned(),
Box::new(move |x, _| match pick {
Some(p) => format_value(p.x),
None => format_value(x),
}),
),
(
"Y".to_owned(),
Box::new(move |_, y| match pick {
Some(p) => format_value(p.y),
None => format_value(y),
}),
),
(
"Data".to_owned(),
Box::new(move |_, _| match pick {
Some(p) => format_value(p.value),
None => "-".to_owned(),
}),
),
(
"Index".to_owned(),
Box::new(move |_, _| match pick {
Some(p) => p.index.to_string(),
None => "-".to_owned(),
}),
),
];
PositionInfo::new(columns)
}
const SCATTER_PROFILE_NPOINTS: usize = 1024;
pub struct ScatterView {
inner: PlotWidget,
scatter_handle: Option<ItemHandle>,
grid_handle: Option<ItemHandle>,
triangles_handle: Option<ItemHandle>,
irregular_grid_mesh: Option<Triangles>,
colormap: Option<Colormap>,
points: Option<(Vec<f64>, Vec<f64>, Vec<f64>)>,
visualization: ScatterVisualization,
grid_resolution: (usize, usize),
mask: crate::widget::scatter_mask::ScatterMaskWidget,
show_colorbar: bool,
alpha: Option<Vec<f64>>,
cursor: Option<[f64; 2]>,
profile_window: crate::widget::profile_window::ProfileWindow,
profile_mode: bool,
profile_drag_start: Option<(f64, f64)>,
}
impl ScatterView {
pub fn new(render_state: &RenderState, id: PlotId) -> Self {
let mut inner = PlotWidget::new(render_state, id);
inner.set_graph_cursor(true);
Self {
inner,
scatter_handle: None,
grid_handle: None,
triangles_handle: None,
irregular_grid_mesh: None,
colormap: None,
points: None,
visualization: ScatterVisualization::Points,
grid_resolution: (100, 100),
mask: crate::widget::scatter_mask::ScatterMaskWidget::new(0),
show_colorbar: true,
alpha: None,
cursor: None,
profile_window: crate::widget::profile_window::ProfileWindow::new(render_state, id + 1),
profile_mode: false,
profile_drag_start: None,
}
}
pub fn show_colorbar(&self) -> bool {
self.show_colorbar
}
pub fn set_show_colorbar(&mut self, show: bool) {
self.show_colorbar = show;
}
pub fn alpha(&self) -> Option<&[f64]> {
self.alpha.as_deref()
}
#[must_use]
pub fn with_alpha(mut self, alpha: Vec<f64>) -> Self {
self.alpha = Some(clamp_alpha(alpha));
self
}
pub fn set_alpha(&mut self, alpha: Vec<f64>) {
self.alpha = Some(clamp_alpha(alpha));
self.rebuild_visualization();
}
pub fn clear_alpha(&mut self) {
self.alpha = None;
self.rebuild_visualization();
}
pub fn set_data(
&mut self,
x: &[f64],
y: &[f64],
values: &[f64],
colormap: Colormap,
) -> Result<(), PlotDataError> {
if x.len() != y.len() || x.len() != values.len() {
return Err(PlotDataError::ImageDataLength {
expected: x.len(),
actual: if y.len() != x.len() {
y.len()
} else {
values.len()
},
});
}
if self.mask.len() != x.len() {
self.mask.reset_len(x.len());
}
self.points = Some((x.to_vec(), y.to_vec(), values.to_vec()));
self.colormap = Some(colormap);
self.rebuild_visualization();
Ok(())
}
pub fn visualization(&self) -> ScatterVisualization {
self.visualization
}
pub fn line_profile(
&self,
start: (f64, f64),
end: (f64, f64),
n_points: usize,
) -> Option<ScatterLineProfile> {
let (x, y, values) = self.points.as_ref()?;
let profile =
crate::core::scatter_viz::scatter_line_profile(x, y, values, start, end, n_points);
if profile.values.iter().all(Option::is_none) {
return None;
}
Some(profile)
}
pub fn show_line_profile(
&mut self,
start: (f64, f64),
end: (f64, f64),
n_points: usize,
) -> bool {
let Some(profile) = self.line_profile(start, end, n_points) else {
return false;
};
let (coords, value, axis) = profile.dominant_axis_curve();
let (Some(&first), Some(&last)) = (profile.points.first(), profile.points.last()) else {
return false;
};
let src_x = self.inner.graph_x_label().unwrap_or("").to_string();
let src_y = self
.inner
.graph_y_label(YAxis::Left)
.unwrap_or("")
.to_string();
let labels = crate::widget::profile_window::scatter_profile_labels(
first, last, axis, &src_x, &src_y,
);
self.profile_window.set_profile_curve(
"Profile",
Color32::from_rgb(31, 119, 180),
coords,
value,
&labels,
);
self.profile_window.set_open(true);
true
}
pub fn profile_window(&self) -> &crate::widget::profile_window::ProfileWindow {
&self.profile_window
}
pub fn profile_window_mut(&mut self) -> &mut crate::widget::profile_window::ProfileWindow {
&mut self.profile_window
}
pub fn profile_mode(&self) -> bool {
self.profile_mode
}
pub fn set_profile_mode(&mut self, enabled: bool) {
if self.profile_mode == enabled {
return;
}
self.profile_mode = enabled;
if enabled {
self.inner.set_interaction_mode(PlotInteractionMode::Select);
} else {
self.inner.set_interaction_mode(PlotInteractionMode::Zoom);
self.profile_drag_start = None;
self.profile_window.set_open(false);
}
}
fn handle_profile_drag(&mut self, plot_response: &PlotResponse) {
if !self.profile_mode || self.points.is_none() {
self.profile_drag_start = None;
return;
}
let response = &plot_response.response;
let transform = &plot_response.transform;
if response.drag_started()
&& let Some(p) = response.interact_pointer_pos()
{
self.profile_drag_start = Some(transform.pixel_to_data(p));
}
if response.dragged()
&& let (Some(start), Some(p)) =
(self.profile_drag_start, response.interact_pointer_pos())
{
let end = transform.pixel_to_data(p);
self.show_line_profile(start, end, SCATTER_PROFILE_NPOINTS);
}
if response.drag_stopped() {
self.profile_drag_start = None;
}
}
pub fn set_visualization(&mut self, mode: ScatterVisualization) {
if self.visualization == mode {
return;
}
self.visualization = mode;
self.rebuild_visualization();
}
pub fn grid_resolution(&self) -> (usize, usize) {
self.grid_resolution
}
pub fn set_grid_resolution(&mut self, rows: usize, cols: usize) {
self.grid_resolution = (rows, cols);
self.rebuild_visualization();
}
pub fn grid_image(&self) -> Option<GridImage> {
let (x, y, values) = self.points.as_ref()?;
scatter_grid_image(self.visualization, x, y, values, self.grid_resolution)
}
fn rebuild_visualization(&mut self) {
let Some((x, y, values)) = self.points.clone() else {
return;
};
let Some(colormap) = self.colormap.clone() else {
return;
};
self.irregular_grid_mesh = None;
match self.visualization {
ScatterVisualization::Points => {
if let Some(h) = self.grid_handle.take() {
self.inner.remove(h);
}
if let Some(h) = self.triangles_handle.take() {
self.inner.remove(h);
}
let colors = point_colors(&values, &colormap, self.alpha.as_deref());
let mut spec = CurveSpec::new(&x, &y, Color32::WHITE);
spec.color = crate::core::backend::CurveColor::PerVertex(&colors);
spec.line_style = LineStyle::None;
spec.symbol = Some(crate::core::items::Symbol::Circle);
spec.symbol_size = 6.0;
let handle = if let Some(h) = self.scatter_handle {
self.inner.update_curve_spec(h, spec);
h
} else {
let h = self.inner.add_curve_spec(spec);
self.scatter_handle = Some(h);
self.inner.set_item_legend(h, "scatter");
h
};
self.inner.set_retained_data(
handle,
Some(RetainedItemData::Scatter {
x: x.clone(),
y: y.clone(),
values: values.clone(),
}),
);
}
ScatterVisualization::Solid => {
if let Some(h) = self.scatter_handle.take() {
self.inner.remove(h);
}
if let Some(h) = self.grid_handle.take() {
self.inner.remove(h);
}
let colors = point_colors(&values, &colormap, self.alpha.as_deref());
let Some(tri) = crate::core::scatter_viz::solid_triangles(&x, &y, &colors) else {
if let Some(h) = self.triangles_handle.take() {
self.inner.remove(h);
}
return;
};
if let Some(h) = self.triangles_handle.take() {
self.inner.remove(h);
}
let h = self.inner.add_triangles_data(&tri);
self.triangles_handle = Some(h);
self.inner.set_item_legend(h, "scatter solid");
}
ScatterVisualization::IrregularGrid => {
if let Some(h) = self.scatter_handle.take() {
self.inner.remove(h);
}
if let Some(h) = self.grid_handle.take() {
self.inner.remove(h);
}
let colors = point_colors(&values, &colormap, self.alpha.as_deref());
let Some(tri) = crate::core::scatter_viz::irregular_grid_triangles(&x, &y, &colors)
else {
if let Some(h) = self.triangles_handle.take() {
self.inner.remove(h);
}
return;
};
if let Some(h) = self.triangles_handle.take() {
self.inner.remove(h);
}
let h = self.inner.add_triangles_data(&tri);
self.triangles_handle = Some(h);
self.inner.set_item_legend(h, "scatter irregular grid");
self.irregular_grid_mesh = Some(tri);
}
mode => {
if let Some(h) = self.scatter_handle.take() {
self.inner.remove(h);
}
if let Some(h) = self.triangles_handle.take() {
self.inner.remove(h);
}
let Some(grid) = scatter_grid_image(mode, &x, &y, &values, self.grid_resolution)
else {
if let Some(h) = self.grid_handle.take() {
self.inner.remove(h);
}
return;
};
let pixels: Vec<f32> = grid.data.iter().map(|&v| v as f32).collect();
let geometry = ImageGeometry {
origin: grid.origin,
scale: grid.scale,
alpha: 1.0,
};
let mut spec =
ImageSpec::scalar(grid.shape.1 as u32, grid.shape.0 as u32, &pixels, colormap);
spec.origin = geometry.origin;
spec.scale = geometry.scale;
spec.alpha = geometry.alpha;
if let Some(h) = self.grid_handle {
self.inner.update_image_spec(h, spec);
} else {
let h = self.inner.add_image_spec(spec);
self.grid_handle = Some(h);
self.inner.set_item_legend(h, "scatter grid");
}
}
}
}
pub fn colormap(&self) -> Option<&Colormap> {
self.colormap.as_ref()
}
pub fn colorbar(&self) -> Option<crate::widget::colorbar::ColorBarWidget> {
scatter_view_colorbar(self.colormap.as_ref())
}
pub fn scatter_mask(&self) -> &crate::widget::scatter_mask::ScatterMaskWidget {
&self.mask
}
pub fn scatter_mask_mut(&mut self) -> &mut crate::widget::scatter_mask::ScatterMaskWidget {
&mut self.mask
}
pub fn masked_selection(&self) -> Vec<bool> {
scatter_masked_selection(&self.mask.mask)
}
pub fn selection_mask(&self) -> &[u8] {
&self.mask.mask
}
pub fn set_selection_mask(&mut self, mask: &[u8]) -> Result<usize, PlotDataError> {
if mask.len() != self.mask.mask.len() {
return Err(PlotDataError::ImageDataLength {
expected: self.mask.mask.len(),
actual: mask.len(),
});
}
self.mask.mask.copy_from_slice(mask);
self.mask.commit();
Ok(mask.len())
}
pub fn cursor(&self) -> Option<[f64; 2]> {
self.cursor
}
pub fn show_position_info(&mut self, ui: &mut egui::Ui, response: &PlotResponse) {
if let Some(cursor) = cursor_from_pointer_event(response.pointer_event.as_ref()) {
self.cursor = Some(cursor);
}
let pick = self.cursor.and_then(|[cx, cy]| {
use crate::core::scatter_viz;
let (xs, ys, vs) = self.points.as_ref()?;
let i = match self.visualization {
ScatterVisualization::RegularGrid => {
let image = regular_grid_image(xs, ys, vs)?;
let order = scatter_viz::detect_regular_grid(xs, ys)?.order;
scatter_viz::regular_grid_pick(&image, order, xs.len(), cx, cy)?
}
ScatterVisualization::BinnedStatistic => {
let (rows, cols) = self.grid_resolution;
let bs = scatter_viz::binned_statistic(xs, ys, vs, rows, cols)?;
let candidates = bs.pick(xs, ys, cx, cy)?;
nearest_candidate_in_data(&candidates, xs, ys, cx, cy)?
}
ScatterVisualization::IrregularGrid => {
let mesh = self.irregular_grid_mesh.as_ref()?;
scatter_viz::irregular_grid_pick(mesh, cx, cy)?
}
ScatterVisualization::Points | ScatterVisualization::Solid => {
let cursor_px = response.transform.data_to_pixel(cx, cy);
let points_px: Vec<(f32, f32)> = xs
.iter()
.zip(ys)
.map(|(&x, &y)| {
let p = response.transform.data_to_pixel(x, y);
(p.x, p.y)
})
.collect();
scatter_pick_pixels(
(cursor_px.x, cursor_px.y),
&points_px,
SCATTER_PICK_RADIUS_PX,
)?
}
};
Some(ScatterPick {
index: i,
x: xs[i],
y: ys[i],
value: vs[i],
})
});
scatter_position_info(pick).ui(ui, self.cursor);
}
pub fn mask_rectangle(&mut self, anchor: (f64, f64), size: (f64, f64), mask: bool) {
let (px, py) = self.mask_point_coords();
let level = self.mask.level;
self.mask.update_rectangle(
level,
(anchor.1 as f32, anchor.0 as f32),
(size.1 as f32, size.0 as f32),
&px,
&py,
mask,
);
self.mask.commit();
}
pub fn mask_polygon(&mut self, vertices: &[(f64, f64)], mask: bool) {
let (px, py) = self.mask_point_coords();
let verts: Vec<(f32, f32)> = vertices
.iter()
.map(|&(x, y)| (y as f32, x as f32))
.collect();
let level = self.mask.level;
self.mask.update_polygon(level, &verts, &px, &py, mask);
self.mask.commit();
}
fn mask_point_coords(&self) -> (Vec<f32>, Vec<f32>) {
match &self.points {
Some((x, y, _)) => (
x.iter().map(|&v| v as f32).collect(),
y.iter().map(|&v| v as f32).collect(),
),
None => (Vec::new(), Vec::new()),
}
}
fn mask_values(&self) -> Vec<f32> {
match &self.points {
Some((_, _, v)) => v.iter().map(|&val| val as f32).collect(),
None => Vec::new(),
}
}
pub fn show_mask_tools(&mut self, ui: &mut egui::Ui) -> bool {
let before = self.mask.mask.clone();
ui.horizontal(|ui| {
ui.label("Mask level:");
let mut level = self.mask.level;
if ui
.add(egui::DragValue::new(&mut level).range(1..=255))
.changed()
{
self.mask.level = level;
}
});
ui.horizontal(|ui| {
if ui.button("Clear level").clicked() {
self.mask.clear();
self.mask.commit();
}
if ui.button("Clear all").clicked() {
self.mask.clear_all();
self.mask.commit();
}
if ui.button("Invert").clicked() {
self.mask.invert();
self.mask.commit();
}
});
ui.horizontal(|ui| {
if ui
.add_enabled(self.mask.can_undo(), egui::Button::new("Undo"))
.clicked()
{
self.mask.undo();
}
if ui
.add_enabled(self.mask.can_redo(), egui::Button::new("Redo"))
.clicked()
{
self.mask.redo();
}
if ui.button("Mask non-finite").clicked() {
let values = self.mask_values();
self.mask.mask_not_finite(&values);
self.mask.commit();
}
});
let changed = self.mask.mask != before;
ui.label(format!(
"{} / {} points masked",
self.masked_selection().iter().filter(|&&m| m).count(),
self.mask.len()
));
changed
}
pub fn show_toolbar(&mut self, ui: &mut egui::Ui) -> ToolbarResponse {
let show_colorbar = self.show_colorbar;
let current_viz = self.visualization;
let mut toggle = false;
let mut picked_viz = current_viz;
let (out, ()) = self.inner.show_toolbar_with(ui, |ui, _| {
ui.separator();
if ui
.selectable_label(show_colorbar, "colorbar")
.on_hover_text("Show/hide the colorbar")
.clicked()
{
toggle = true;
}
ui.separator();
egui::ComboBox::from_id_salt("scatter_visualization")
.selected_text(current_viz.label())
.show_ui(ui, |ui| {
for mode in ScatterVisualization::ALL {
ui.selectable_value(&mut picked_viz, mode, mode.label());
}
});
});
if toggle {
crate::widget::actions::control::scatter_colorbar_toggle(self);
}
self.set_visualization(picked_viz);
out
}
pub fn show(&mut self, ui: &mut egui::Ui) -> PlotResponse {
let avail = ui.available_size();
let colorbar = self.colorbar();
let colorbar_w = colorbar_column_width(self.show_colorbar, colorbar.is_some());
let spacing = ui.spacing().item_spacing.x;
let plot_w = row_content_width(avail.x, colorbar_w, u32::from(colorbar_w > 0.0), spacing);
let response = ui
.horizontal(|ui| {
let response = ui
.allocate_ui(egui::vec2(plot_w, avail.y), |ui| self.inner.show(ui))
.inner;
if colorbar_w > 0.0
&& let Some(bar) = colorbar
{
bar.ui(ui, egui::vec2(colorbar_w, avail.y));
}
response
})
.inner;
self.handle_profile_drag(&response);
self.profile_window.show(ui.ctx());
response
}
}
impl Deref for ScatterView {
type Target = PlotWidget;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl DerefMut for ScatterView {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StackPerspective {
#[default]
Axis0,
Axis1,
Axis2,
}
impl StackPerspective {
pub fn axis(self) -> usize {
match self {
StackPerspective::Axis0 => 0,
StackPerspective::Axis1 => 1,
StackPerspective::Axis2 => 2,
}
}
pub fn display_axes(self) -> (usize, usize) {
match self {
StackPerspective::Axis0 => (1, 2),
StackPerspective::Axis1 => (0, 2),
StackPerspective::Axis2 => (0, 1),
}
}
}
pub fn stack_frame_count(shape: [usize; 3], perspective: StackPerspective) -> usize {
shape[perspective.axis()]
}
pub fn stack_frame(
data: &[f32],
shape: [usize; 3],
perspective: StackPerspective,
index: usize,
) -> Option<(u32, u32, Vec<f32>)> {
let [d0, d1, d2] = shape;
if data.len() != d0.checked_mul(d1)?.checked_mul(d2)? {
return None;
}
if index >= shape[perspective.axis()] {
return None;
}
let (height_axis, width_axis) = perspective.display_axes();
let height = shape[height_axis];
let width = shape[width_axis];
let at = |i: usize, j: usize, k: usize| data[(i * d1 + j) * d2 + k];
let mut pixels = Vec::with_capacity(width.saturating_mul(height));
for row in 0..height {
for col in 0..width {
let value = match perspective {
StackPerspective::Axis0 => at(index, row, col),
StackPerspective::Axis1 => at(row, index, col),
StackPerspective::Axis2 => at(row, col, index),
};
pixels.push(value);
}
}
Some((width as u32, height as u32, pixels))
}
#[derive(Debug, Clone, PartialEq)]
pub struct StackProfile {
pub frame_count: usize,
pub profile_len: usize,
pub values: Vec<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StackProfileDimension {
#[default]
OneD,
TwoD,
}
fn stack_profile_with<F>(
data: &[f32],
shape: [usize; 3],
perspective: StackPerspective,
mut extract: F,
) -> Option<StackProfile>
where
F: FnMut(u32, u32, &[f32]) -> Option<Vec<f64>>,
{
let frame_count = stack_frame_count(shape, perspective);
if frame_count == 0 {
return None;
}
let mut values: Vec<f64> = Vec::new();
let mut profile_len: Option<usize> = None;
for index in 0..frame_count {
let (w, h, pixels) = stack_frame(data, shape, perspective, index)?;
let profile = extract(w, h, &pixels)?;
match profile_len {
None => profile_len = Some(profile.len()),
Some(len) if len != profile.len() => return None,
_ => {}
}
values.extend(profile);
}
Some(StackProfile {
frame_count,
profile_len: profile_len.unwrap_or(0),
values,
})
}
pub fn stack_aligned_profile(
data: &[f32],
shape: [usize; 3],
perspective: StackPerspective,
position: f64,
roi_width: u32,
horizontal: bool,
method: ProfileMethod,
) -> Option<StackProfile> {
stack_profile_with(data, shape, perspective, |w, h, pixels| {
aligned_profile_values(w, h, pixels, position, roi_width, horizontal, method).ok()
})
}
pub fn stack_line_profile(
data: &[f32],
shape: [usize; 3],
perspective: StackPerspective,
start: (f64, f64),
end: (f64, f64),
line_width: u32,
method: ProfileMethod,
) -> Option<StackProfile> {
stack_profile_with(data, shape, perspective, |w, h, pixels| {
free_line_profile(w, h, pixels, start, end, line_width, method)
.ok()
.map(|(_positions, values)| values)
})
}
pub fn default_dimension_label(axis: usize) -> String {
format!("Dimension {axis}")
}
pub fn dimension_axis_labels(
perspective: StackPerspective,
labels: &[String; 3],
) -> (String, String) {
let (height_axis, width_axis) = perspective.display_axes();
(labels[width_axis].clone(), labels[height_axis].clone())
}
pub fn calibrations_axes_order(
perspective: StackPerspective,
calibrations: &[Calibration; 3],
) -> (Calibration, Calibration, Calibration) {
let (height_axis, width_axis) = perspective.display_axes(); (
calibrations[width_axis], calibrations[height_axis], calibrations[perspective.axis()], )
}
pub fn calibrated_image_geometry(
perspective: StackPerspective,
calibrations: &[Calibration; 3],
) -> ((f64, f64), (f64, f64)) {
let (xcalib, ycalib, _zcalib) = calibrations_axes_order(perspective, calibrations);
let origin = (xcalib.apply(0.0), ycalib.apply(0.0));
let scale = (xcalib.slope(), ycalib.slope());
(origin, scale)
}
pub fn calibrated_image_z(
index: usize,
perspective: StackPerspective,
calibrations: &[Calibration; 3],
) -> f64 {
let (_xcalib, _ycalib, zcalib) = calibrations_axes_order(perspective, calibrations);
zcalib.apply(index as f64)
}
pub struct StackView {
inner: Plot2D,
width: u32,
height: u32,
frames: Vec<Vec<f32>>,
colormap: Colormap,
image_handle: Option<ItemHandle>,
current_frame: usize,
dirty: bool,
volume: Option<(Vec<f32>, [usize; 3])>,
perspective: StackPerspective,
dim_labels: [String; 3],
calibrations: [Calibration; 3],
aggregation: AggregationMode,
aggregation_block: (u32, u32),
profile_mode: ProfileMode,
profile_dimension: StackProfileDimension,
profile_drag_start: Option<(f64, f64)>,
profile_window: crate::widget::profile_window::ProfileWindow,
stack_profile_window: crate::widget::stack_profile_window::StackProfileWindow,
}
impl StackView {
pub fn new(render_state: &RenderState, id: PlotId) -> Self {
let mut inner = Plot2D::new(render_state, id);
inner.set_keep_data_aspect_ratio(true);
inner.set_graph_cursor(true);
Self {
inner,
width: 0,
height: 0,
frames: Vec::new(),
colormap: Colormap::viridis(0.0, 1.0),
image_handle: None,
current_frame: 0,
dirty: false,
volume: None,
perspective: StackPerspective::default(),
dim_labels: [
default_dimension_label(0),
default_dimension_label(1),
default_dimension_label(2),
],
calibrations: [Calibration::None; 3],
aggregation: AggregationMode::None,
aggregation_block: (1, 1),
profile_mode: ProfileMode::None,
profile_dimension: StackProfileDimension::default(),
profile_drag_start: None,
profile_window: crate::widget::profile_window::ProfileWindow::new(render_state, id + 1),
stack_profile_window: crate::widget::stack_profile_window::StackProfileWindow::new(
render_state,
id + 2,
),
}
}
pub fn set_stack(
&mut self,
width: u32,
height: u32,
frames: Vec<Vec<f32>>,
colormap: Colormap,
) -> Result<(), PlotDataError> {
let expected = (width as usize).saturating_mul(height as usize);
for frame in &frames {
if frame.len() != expected {
return Err(PlotDataError::ImageDataLength {
expected,
actual: frame.len(),
});
}
}
self.width = width;
self.height = height;
self.frames = frames;
self.colormap = colormap;
self.current_frame = 0;
self.dirty = true;
self.volume = None;
Ok(())
}
pub fn set_volume(
&mut self,
data: Vec<f32>,
shape: [usize; 3],
colormap: Colormap,
) -> Result<(), PlotDataError> {
let [d0, d1, d2] = shape;
let expected = d0.saturating_mul(d1).saturating_mul(d2);
if data.len() != expected {
return Err(PlotDataError::ImageDataLength {
expected,
actual: data.len(),
});
}
self.volume = Some((data, shape));
self.colormap = colormap;
self.rebuild_volume_frames();
Ok(())
}
pub fn perspective(&self) -> StackPerspective {
self.perspective
}
pub fn set_perspective(&mut self, perspective: StackPerspective) {
if perspective == self.perspective {
return;
}
self.perspective = perspective;
if self.volume.is_some() {
self.rebuild_volume_frames();
self.inner.reset_zoom();
}
}
fn rebuild_volume_frames(&mut self) {
let Some((data, shape)) = self.volume.as_ref() else {
return;
};
let (data, shape) = (data.clone(), *shape);
let n = stack_frame_count(shape, self.perspective);
let mut frames = Vec::with_capacity(n);
let (mut width, mut height) = (0u32, 0u32);
for index in 0..n {
if let Some((w, h, pixels)) = stack_frame(&data, shape, self.perspective, index) {
width = w;
height = h;
frames.push(pixels);
}
}
self.width = width;
self.height = height;
self.frames = frames;
self.current_frame = 0;
if let Some(handle) = self.image_handle.take() {
self.inner.remove_image(handle);
}
self.dirty = true;
self.apply_axis_labels();
}
pub fn dimension_labels(&self) -> &[String; 3] {
&self.dim_labels
}
pub fn set_dimension_labels(&mut self, labels: [&str; 3]) {
for (i, label) in labels.iter().enumerate() {
self.dim_labels[i] = if label.is_empty() {
default_dimension_label(i)
} else {
(*label).to_string()
};
}
self.apply_axis_labels();
}
fn apply_axis_labels(&mut self) {
let (x_label, y_label) = dimension_axis_labels(self.perspective, &self.dim_labels);
self.inner.set_graph_x_label(x_label);
self.inner.set_graph_y_label(y_label, YAxis::Left);
}
pub fn calibrations(&self) -> &[Calibration; 3] {
&self.calibrations
}
pub fn calibrations_axes(&self) -> (Calibration, Calibration, Calibration) {
calibrations_axes_order(self.perspective, &self.calibrations)
}
pub fn set_calibrations(&mut self, calibrations: [Calibration; 3]) {
if calibrations == self.calibrations {
return;
}
self.calibrations = calibrations;
if let Some(handle) = self.image_handle.take() {
self.inner.remove_image(handle);
}
self.dirty = true;
if !self.frames.is_empty() {
self.inner.reset_zoom();
}
}
pub fn image_z(&self, index: usize) -> f64 {
calibrated_image_z(index, self.perspective, &self.calibrations)
}
pub fn stack_aligned_profile(
&self,
position: f64,
roi_width: u32,
horizontal: bool,
method: ProfileMethod,
) -> Option<StackProfile> {
let (data, shape) = self.volume.as_ref()?;
stack_aligned_profile(
data,
*shape,
self.perspective,
position,
roi_width,
horizontal,
method,
)
}
pub fn stack_line_profile(
&self,
start: (f64, f64),
end: (f64, f64),
line_width: u32,
method: ProfileMethod,
) -> Option<StackProfile> {
let (data, shape) = self.volume.as_ref()?;
stack_line_profile(
data,
*shape,
self.perspective,
start,
end,
line_width,
method,
)
}
pub fn profile_mode(&self) -> ProfileMode {
self.profile_mode
}
pub fn set_profile_mode(&mut self, mode: ProfileMode) {
self.profile_mode = mode;
if mode == ProfileMode::None {
self.profile_drag_start = None;
self.profile_window.set_open(false);
self.stack_profile_window.set_open(false);
}
}
pub fn profile_dimension(&self) -> StackProfileDimension {
self.profile_dimension
}
pub fn set_profile_dimension(&mut self, dimension: StackProfileDimension) {
if dimension == self.profile_dimension {
return;
}
self.profile_dimension = dimension;
match dimension {
StackProfileDimension::OneD => self.stack_profile_window.set_open(false),
StackProfileDimension::TwoD => self.profile_window.set_open(false),
}
}
pub fn profile_window(&self) -> &crate::widget::profile_window::ProfileWindow {
&self.profile_window
}
pub fn profile_window_mut(&mut self) -> &mut crate::widget::profile_window::ProfileWindow {
&mut self.profile_window
}
pub fn stack_profile_window(&self) -> &crate::widget::stack_profile_window::StackProfileWindow {
&self.stack_profile_window
}
pub fn stack_profile_window_mut(
&mut self,
) -> &mut crate::widget::stack_profile_window::StackProfileWindow {
&mut self.stack_profile_window
}
pub fn show_profile(&mut self, start: (f64, f64), end: (f64, f64)) -> bool {
if self.frames.is_empty() {
return false;
}
match self.profile_dimension {
StackProfileDimension::OneD => {
let Some(roi) = profile_roi_from_drag(self.profile_mode, start, end) else {
return false;
};
let frame = &self.frames[self.current_frame];
let x_label = self.inner.graph_x_label().unwrap_or("").to_string();
let y_label = self
.inner
.graph_y_label(YAxis::Left)
.unwrap_or("")
.to_string();
self.profile_window.update_profile(
self.width,
self.height,
frame,
&roi,
&x_label,
&y_label,
);
self.profile_window.set_open(true);
true
}
StackProfileDimension::TwoD => {
let line_width = self.profile_window.line_width();
let method = self.profile_window.method();
let profile = match self.profile_mode {
ProfileMode::Line => self.stack_line_profile(start, end, line_width, method),
ProfileMode::Horizontal => {
self.stack_aligned_profile(end.1.floor(), line_width, true, method)
}
ProfileMode::Vertical => {
self.stack_aligned_profile(end.0.floor(), line_width, false, method)
}
ProfileMode::Rectangle | ProfileMode::None => None,
};
let Some(profile) = profile else {
return false;
};
self.stack_profile_window
.set_profile(&profile, self.colormap.clone());
self.stack_profile_window.set_open(true);
true
}
}
}
fn handle_profile_drag(&mut self, plot_response: &PlotResponse) {
if self.profile_mode == ProfileMode::None || self.frames.is_empty() {
self.profile_drag_start = None;
return;
}
let response = &plot_response.response;
let transform = &plot_response.transform;
if response.drag_started()
&& let Some(p) = response.interact_pointer_pos()
{
self.profile_drag_start = Some(transform.pixel_to_data(p));
}
if response.dragged()
&& let (Some(start), Some(p)) =
(self.profile_drag_start, response.interact_pointer_pos())
{
let end = transform.pixel_to_data(p);
self.show_profile(start, end);
}
if response.drag_stopped() {
self.profile_drag_start = None;
}
}
pub fn show_profile3d_toolbar(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
let mut mode = self.profile_mode;
if ui
.selectable_label(mode == ProfileMode::None, "○")
.on_hover_text("No profile")
.clicked()
{
mode = ProfileMode::None;
}
if ui
.selectable_label(mode == ProfileMode::Horizontal, "H")
.on_hover_text("Horizontal line profile over the stack")
.clicked()
{
mode = ProfileMode::Horizontal;
}
if ui
.selectable_label(mode == ProfileMode::Vertical, "V")
.on_hover_text("Vertical line profile over the stack")
.clicked()
{
mode = ProfileMode::Vertical;
}
if ui
.selectable_label(mode == ProfileMode::Line, "L")
.on_hover_text("Line profile over the stack (draw a line)")
.clicked()
{
mode = ProfileMode::Line;
}
if mode != self.profile_mode {
self.set_profile_mode(mode);
}
ui.separator();
ui.label("Profile:");
let mut dimension = self.profile_dimension;
if ui
.selectable_label(dimension == StackProfileDimension::OneD, "1D")
.on_hover_text("Profile of the current frame")
.clicked()
{
dimension = StackProfileDimension::OneD;
}
if ui
.selectable_label(dimension == StackProfileDimension::TwoD, "2D")
.on_hover_text("Profile stacked over all frames")
.clicked()
{
dimension = StackProfileDimension::TwoD;
}
if dimension != self.profile_dimension {
self.set_profile_dimension(dimension);
}
});
}
pub fn frame_count(&self) -> usize {
self.frames.len()
}
pub fn frame(&self) -> usize {
self.current_frame
}
pub fn set_frame(&mut self, index: usize) {
let clamped = index.min(self.frames.len().saturating_sub(1));
if clamped != self.current_frame {
self.current_frame = clamped;
self.dirty = true;
}
}
pub fn set_colormap(&mut self, colormap: Colormap) {
self.colormap = colormap;
self.dirty = true;
}
pub fn perspective_ui(&mut self, ui: &mut egui::Ui) {
if self.volume.is_none() {
return;
}
let labels = self.dim_labels.clone();
let mut selected = self.perspective;
egui::ComboBox::from_label("Browse dimension")
.selected_text(labels[selected.axis()].clone())
.show_ui(ui, |ui| {
for option in [
StackPerspective::Axis0,
StackPerspective::Axis1,
StackPerspective::Axis2,
] {
ui.selectable_value(&mut selected, option, labels[option.axis()].clone());
}
});
self.set_perspective(selected);
}
pub fn aggregation(&self) -> AggregationMode {
self.aggregation
}
pub fn aggregation_block(&self) -> (u32, u32) {
self.aggregation_block
}
pub fn set_aggregation(&mut self, mode: AggregationMode, block: (u32, u32)) {
let block = (block.0.max(1), block.1.max(1));
if mode != self.aggregation || block != self.aggregation_block {
self.aggregation = mode;
self.aggregation_block = block;
self.dirty = true;
}
}
pub fn show_frame_controls(&mut self, ui: &mut egui::Ui) {
if self.frames.is_empty() {
return;
}
let n = self.frames.len();
ui.horizontal(|ui| {
if ui.button("◀").on_hover_text("Previous frame").clicked() && self.current_frame > 0
{
self.current_frame -= 1;
self.dirty = true;
}
let mut idx = self.current_frame;
if ui
.add(egui::Slider::new(&mut idx, 0..=n.saturating_sub(1)).text("frame"))
.changed()
{
self.current_frame = idx;
self.dirty = true;
}
if ui.button("▶").on_hover_text("Next frame").clicked() && self.current_frame + 1 < n
{
self.current_frame += 1;
self.dirty = true;
}
ui.label(format!("{}/{}", self.current_frame + 1, n));
let mut aggregation = self.aggregation;
egui::ComboBox::from_label("agg")
.selected_text(match aggregation {
AggregationMode::None => "none",
AggregationMode::Max => "max",
AggregationMode::Mean => "mean",
AggregationMode::Min => "min",
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut aggregation, AggregationMode::None, "none");
ui.selectable_value(&mut aggregation, AggregationMode::Max, "max");
ui.selectable_value(&mut aggregation, AggregationMode::Mean, "mean");
ui.selectable_value(&mut aggregation, AggregationMode::Min, "min");
});
let mut block = self.aggregation_block;
let bx = ui.add(
egui::DragValue::new(&mut block.0)
.range(1..=64)
.prefix("bx "),
);
let by = ui.add(
egui::DragValue::new(&mut block.1)
.range(1..=64)
.prefix("by "),
);
if aggregation != self.aggregation || bx.changed() || by.changed() {
self.set_aggregation(aggregation, block);
}
});
}
pub fn show(&mut self, ui: &mut egui::Ui) -> PlotResponse {
if self.dirty && !self.frames.is_empty() {
let frame = &self.frames[self.current_frame];
let (origin, scale) = calibrated_image_geometry(self.perspective, &self.calibrations);
let mut spec = ImageSpec::scalar(self.width, self.height, frame, self.colormap.clone());
spec.origin = origin;
spec.scale = scale;
spec.aggregation = self.aggregation;
spec.aggregation_block = self.aggregation_block;
if let Some(handle) = self.image_handle {
self.inner.update_image_spec(handle, spec);
} else {
self.image_handle = Some(self.inner.add_image_spec(spec));
}
self.profile_window
.refresh_image(self.width, self.height, frame);
self.dirty = false;
}
let response = self.inner.show(ui);
self.handle_profile_drag(&response);
self.profile_window.show(ui.ctx());
self.stack_profile_window.show(ui.ctx());
response
}
}
impl Deref for StackView {
type Target = Plot2D;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl DerefMut for StackView {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
fn roi_description(roi: &Roi) -> String {
match roi {
Roi::Rect { x, y } => format!(
"Rect x=[{:.3}, {:.3}] y=[{:.3}, {:.3}]",
x.0, x.1, y.0, y.1
),
Roi::HRange { y } => format!("HRange y=[{:.3}, {:.3}]", y.0, y.1),
Roi::VRange { x } => format!("VRange x=[{:.3}, {:.3}]", x.0, x.1),
Roi::HLine { y } => format!("HLine y={y:.3}"),
Roi::VLine { x } => format!("VLine x={x:.3}"),
Roi::Point { x, y } => format!("Point ({x:.3}, {y:.3})"),
Roi::Line { start, end } => format!(
"Line ({:.3},{:.3}) → ({:.3},{:.3})",
start.0, start.1, end.0, end.1
),
Roi::Polygon { vertices } => format!("Polygon {} vertices", vertices.len()),
Roi::Cross { center } => format!("Cross ({:.3}, {:.3})", center.0, center.1),
Roi::Circle { center, radius } => {
format!(
"Circle c=({:.3}, {:.3}) r={radius:.3}",
center.0, center.1
)
}
Roi::Ellipse {
center,
radii,
orientation,
} => format!(
"Ellipse c=({:.3}, {:.3}) r=({:.3}, {:.3}) θ={:.1}°",
center.0,
center.1,
radii.0,
radii.1,
orientation.to_degrees()
),
Roi::Arc {
center,
radius,
weight,
start_angle,
end_angle,
} => format!(
"Arc c=({:.3}, {:.3}) r=[{:.3}, {:.3}] θ=[{:.3}, {:.3}]",
center.0,
center.1,
arc_inner_radius(*radius, *weight),
arc_outer_radius(*radius, *weight),
start_angle,
end_angle
),
Roi::Band { begin, end, width } => format!(
"Band ({:.3},{:.3}) → ({:.3},{:.3}) w={width:.3}",
begin.0, begin.1, end.0, end.1
),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_volume() -> (Vec<f32>, [usize; 3]) {
let shape = [2usize, 3, 4];
let [d0, d1, d2] = shape;
let mut data = vec![0.0f32; d0 * d1 * d2];
for i in 0..d0 {
for j in 0..d1 {
for k in 0..d2 {
data[(i * d1 + j) * d2 + k] = (100 * i + 10 * j + k) as f32;
}
}
}
(data, shape)
}
#[test]
fn stack_frame_count_is_the_browsed_dimension() {
let shape = [2usize, 3, 4];
assert_eq!(stack_frame_count(shape, StackPerspective::Axis0), 2);
assert_eq!(stack_frame_count(shape, StackPerspective::Axis1), 3);
assert_eq!(stack_frame_count(shape, StackPerspective::Axis2), 4);
}
#[test]
fn stack_perspective_display_axes_are_non_browsed_ascending() {
assert_eq!(StackPerspective::Axis0.display_axes(), (1, 2));
assert_eq!(StackPerspective::Axis1.display_axes(), (0, 2));
assert_eq!(StackPerspective::Axis2.display_axes(), (0, 1));
}
#[test]
fn stack_frame_axis0_browses_dim0_without_transpose() {
let (data, shape) = sample_volume();
let (w, h, pixels) = stack_frame(&data, shape, StackPerspective::Axis0, 1).unwrap();
assert_eq!((w, h), (4, 3));
assert_eq!(
pixels,
vec![
100.0, 101.0, 102.0, 103.0, 110.0, 111.0, 112.0, 113.0, 120.0, 121.0, 122.0, 123.0
]
);
}
#[test]
fn stack_frame_axis1_transposes_1_0_2() {
let (data, shape) = sample_volume();
let (w, h, pixels) = stack_frame(&data, shape, StackPerspective::Axis1, 2).unwrap();
assert_eq!((w, h), (4, 2));
assert_eq!(
pixels,
vec![20.0, 21.0, 22.0, 23.0, 120.0, 121.0, 122.0, 123.0]
);
}
#[test]
fn stack_frame_axis2_transposes_2_0_1() {
let (data, shape) = sample_volume();
let (w, h, pixels) = stack_frame(&data, shape, StackPerspective::Axis2, 3).unwrap();
assert_eq!((w, h), (3, 2));
assert_eq!(pixels, vec![3.0, 13.0, 23.0, 103.0, 113.0, 123.0]);
}
#[test]
fn stack_frame_rejects_length_mismatch_and_out_of_range() {
let (data, shape) = sample_volume();
assert!(stack_frame(&data[..23], shape, StackPerspective::Axis0, 0).is_none());
assert!(stack_frame(&data, shape, StackPerspective::Axis0, 2).is_none());
}
#[test]
fn stack_aligned_profile_horizontal_stacks_each_frame_row() {
let (data, shape) = sample_volume(); let sp = stack_aligned_profile(
&data,
shape,
StackPerspective::Axis0,
1.0,
1,
true,
ProfileMethod::Mean,
)
.unwrap();
assert_eq!(sp.frame_count, 2);
assert_eq!(sp.profile_len, 4);
assert_eq!(
sp.values,
vec![10.0, 11.0, 12.0, 13.0, 110.0, 111.0, 112.0, 113.0]
);
}
#[test]
fn stack_line_profile_separates_frames() {
let (data, shape) = sample_volume();
let sp = stack_line_profile(
&data,
shape,
StackPerspective::Axis0,
(0.0, 0.0),
(3.0, 0.0),
1,
ProfileMethod::Mean,
)
.unwrap();
assert_eq!(sp.frame_count, 2);
assert!(sp.profile_len > 0);
assert_eq!(sp.values.len(), 2 * sp.profile_len);
let (frame0, frame1) = sp.values.split_at(sp.profile_len);
assert!(frame0.iter().all(|&v| v < 100.0), "frame0 {frame0:?}");
assert!(frame1.iter().all(|&v| v >= 100.0), "frame1 {frame1:?}");
}
#[test]
fn stack_line_profile_honors_width_and_method() {
let (data, shape) = sample_volume();
let w1 = stack_line_profile(
&data,
shape,
StackPerspective::Axis0,
(0.5, 1.0),
(3.5, 1.0),
1,
ProfileMethod::Mean,
)
.unwrap();
let w2 = stack_line_profile(
&data,
shape,
StackPerspective::Axis0,
(0.5, 1.0),
(3.5, 1.0),
2,
ProfileMethod::Sum,
)
.unwrap();
assert_eq!(w1.frame_count, w2.frame_count);
assert_ne!(
w1.values, w2.values,
"width/method must change the stacked line profile"
);
}
#[test]
fn stack_profile_rejects_shape_mismatch() {
let (data, shape) = sample_volume();
assert!(
stack_aligned_profile(
&data[..23],
shape,
StackPerspective::Axis0,
1.0,
1,
true,
ProfileMethod::Mean,
)
.is_none()
);
}
#[test]
fn stack_profile_empty_stack_is_none() {
assert!(
stack_aligned_profile(
&[],
[0, 3, 4],
StackPerspective::Axis0,
0.0,
1,
true,
ProfileMethod::Mean,
)
.is_none()
);
}
#[test]
fn dimension_axis_labels_use_width_for_x_and_height_for_y() {
let labels = ["z".to_string(), "y".to_string(), "x".to_string()];
assert_eq!(
dimension_axis_labels(StackPerspective::Axis0, &labels),
("x".to_string(), "y".to_string())
);
assert_eq!(
dimension_axis_labels(StackPerspective::Axis1, &labels),
("x".to_string(), "z".to_string())
);
assert_eq!(
dimension_axis_labels(StackPerspective::Axis2, &labels),
("y".to_string(), "z".to_string())
);
}
#[test]
fn calibrations_axes_order_maps_x_to_width_y_to_height_z_to_browsed() {
let calibs = [
Calibration::linear(0.0, 1.0), Calibration::linear(10.0, 2.0), Calibration::linear(100.0, 3.0), ];
let (x, y, z) = calibrations_axes_order(StackPerspective::Axis0, &calibs);
assert_eq!(x, calibs[2]);
assert_eq!(y, calibs[1]);
assert_eq!(z, calibs[0]);
let (x, y, z) = calibrations_axes_order(StackPerspective::Axis1, &calibs);
assert_eq!(x, calibs[2]);
assert_eq!(y, calibs[0]);
assert_eq!(z, calibs[1]);
let (x, y, z) = calibrations_axes_order(StackPerspective::Axis2, &calibs);
assert_eq!(x, calibs[1]);
assert_eq!(y, calibs[0]);
assert_eq!(z, calibs[2]);
}
#[test]
fn calibrated_image_geometry_uses_intercept_for_origin_and_slope_for_scale() {
let calibs = [
Calibration::None,
Calibration::linear(10.0, 2.0),
Calibration::linear(100.0, 3.0),
];
let (origin, scale) = calibrated_image_geometry(StackPerspective::Axis0, &calibs);
assert_eq!(origin, (100.0, 10.0));
assert_eq!(scale, (3.0, 2.0));
}
#[test]
fn calibrated_image_geometry_defaults_to_identity() {
let calibs = [Calibration::None; 3];
let (origin, scale) = calibrated_image_geometry(StackPerspective::Axis0, &calibs);
assert_eq!(origin, (0.0, 0.0));
assert_eq!(scale, (1.0, 1.0));
}
#[test]
fn calibrated_image_z_applies_browsed_dim_calibration() {
let calibs = [
Calibration::linear(5.0, 0.5),
Calibration::None,
Calibration::None,
];
assert_eq!(calibrated_image_z(0, StackPerspective::Axis0, &calibs), 5.0);
assert_eq!(calibrated_image_z(4, StackPerspective::Axis0, &calibs), 7.0);
assert_eq!(calibrated_image_z(4, StackPerspective::Axis1, &calibs), 4.0);
}
#[test]
fn default_dimension_labels_drive_axis_labels_per_perspective() {
let labels = [
default_dimension_label(0),
default_dimension_label(1),
default_dimension_label(2),
];
assert_eq!(labels, ["Dimension 0", "Dimension 1", "Dimension 2"]);
assert_eq!(
dimension_axis_labels(StackPerspective::Axis0, &labels),
("Dimension 2".to_string(), "Dimension 1".to_string())
);
assert_eq!(
dimension_axis_labels(StackPerspective::Axis2, &labels),
("Dimension 1".to_string(), "Dimension 0".to_string())
);
}
#[test]
fn ordered_limits_swaps_reversed_bounds() {
assert_eq!(ordered_limits(1.0, 5.0), (1.0, 5.0));
assert_eq!(ordered_limits(5.0, 1.0), (1.0, 5.0));
assert_eq!(ordered_limits(2.0, 2.0), (2.0, 2.0));
}
#[test]
fn scatter_pick_returns_nearest_point_within_radius() {
let points = [(10.0, 0.0), (3.0, 4.0), (100.0, 100.0)];
assert_eq!(scatter_pick_pixels((0.0, 0.0), &points, 8.0), Some(1));
}
#[test]
fn scatter_pick_none_when_all_outside_radius() {
let points = [(100.0, 0.0), (0.0, 100.0)];
assert_eq!(scatter_pick_pixels((0.0, 0.0), &points, 8.0), None);
}
#[test]
fn scatter_pick_ties_resolve_to_highest_index() {
let points = [(5.0, 0.0), (5.0, 0.0)];
assert_eq!(scatter_pick_pixels((0.0, 0.0), &points, 8.0), Some(1));
}
#[test]
fn nearest_candidate_in_data_picks_closest_then_highest_index() {
let xs = [0.0, 1.0, 2.0, 3.0];
let ys = [0.0, 0.0, 0.0, 0.0];
assert_eq!(
nearest_candidate_in_data(&[0, 1, 2], &xs, &ys, 1.9, 0.0),
Some(2)
);
assert_eq!(nearest_candidate_in_data(&[], &xs, &ys, 0.0, 0.0), None);
}
#[test]
fn nearest_candidate_in_data_ties_resolve_to_highest_index() {
let xs = [5.0, 5.0];
let ys = [0.0, 0.0];
assert_eq!(
nearest_candidate_in_data(&[0, 1], &xs, &ys, 0.0, 0.0),
Some(1)
);
}
#[test]
fn scatter_position_info_snaps_to_pick() {
let pick = Some(ScatterPick {
index: 7,
x: 1.5,
y: 2.5,
value: 3.5,
});
let cols = scatter_position_info(pick).values(Some([9.0, 9.0]));
assert_eq!(cols, vec!["1.5", "2.5", "3.5", "7"]);
}
#[test]
fn scatter_position_info_falls_back_without_pick() {
let cols = scatter_position_info(None).values(Some([1.5, 2.5]));
assert_eq!(cols, vec!["1.5", "2.5", "-", "-"]);
let cols = scatter_position_info(None).values(None);
assert_eq!(cols, vec!["------", "------", "------", "------"]);
}
#[test]
fn active_axis_label_overrides_routes_y_by_axis() {
use crate::core::transform::YAxis;
assert_eq!(
active_axis_label_overrides(Some("Time"), Some("Counts"), YAxis::Left),
(Some("Time".to_string()), Some("Counts".to_string()), None)
);
assert_eq!(
active_axis_label_overrides(Some("Time"), Some("Counts"), YAxis::Right),
(Some("Time".to_string()), None, Some("Counts".to_string()))
);
assert_eq!(
active_axis_label_overrides(None, None, YAxis::Left),
(None, None, None)
);
}
#[test]
fn set_symbol_visibility_hide_then_show_is_lossless() {
let mut cache = None;
let hidden = set_symbol_visibility(Some(Symbol::Diamond), false, &mut cache);
assert_eq!(hidden, None);
assert_eq!(cache, Some(Symbol::Diamond));
let shown = set_symbol_visibility(hidden, true, &mut cache);
assert_eq!(shown, Some(Symbol::Diamond));
assert_eq!(cache, None);
}
#[test]
fn set_line_visibility_hide_then_show_is_lossless() {
let mut cache = None;
let hidden = set_line_visibility(LineStyle::Dashed, false, &mut cache);
assert_eq!(hidden, LineStyle::None);
assert_eq!(cache, Some(LineStyle::Dashed));
let shown = set_line_visibility(hidden, true, &mut cache);
assert_eq!(shown, LineStyle::Dashed);
assert_eq!(cache, None);
}
#[test]
fn curve_legend_visual_carries_line_style_and_symbol() {
let x = [0.0, 1.0];
let y = [0.0, 1.0];
let mut dashed = CurveSpec::new(&x, &y, Color32::RED);
dashed.line_style = LineStyle::Dashed;
dashed.symbol = Some(Symbol::Square);
let v = curve_spec_legend_visual(&dashed, PlotItemKind::Curve);
assert_eq!(v.color, Color32::RED);
assert_eq!(v.line_style, LineStyle::Dashed);
assert_eq!(v.symbol, Some(Symbol::Square));
let mut markers = CurveSpec::new(&x, &y, Color32::BLUE);
markers.line_style = LineStyle::None;
markers.symbol = Some(Symbol::Circle);
let v = curve_spec_legend_visual(&markers, PlotItemKind::Scatter);
assert_eq!(v.line_style, LineStyle::None);
assert!(!v.line_style.draws_line());
assert_eq!(v.symbol, Some(Symbol::Circle));
let v =
curve_spec_legend_visual(&CurveSpec::new(&x, &y, Color32::GREEN), PlotItemKind::Curve);
assert_eq!(v.line_style, LineStyle::Solid);
assert_eq!(v.symbol, None);
}
#[test]
fn set_symbol_visibility_no_op_when_already_in_state() {
let mut cache = Some(Symbol::Square);
let out = set_symbol_visibility(Some(Symbol::Circle), true, &mut cache);
assert_eq!(out, Some(Symbol::Circle));
assert_eq!(cache, Some(Symbol::Square));
let mut cache = Some(Symbol::Diamond);
let out = set_symbol_visibility(None, false, &mut cache);
assert_eq!(out, None);
assert_eq!(cache, Some(Symbol::Diamond));
}
#[test]
fn set_line_visibility_no_op_when_already_in_state() {
let mut cache = Some(LineStyle::Dotted);
let out = set_line_visibility(LineStyle::Dashed, true, &mut cache);
assert_eq!(out, LineStyle::Dashed);
assert_eq!(cache, Some(LineStyle::Dotted));
let mut cache = Some(LineStyle::Dashed);
let out = set_line_visibility(LineStyle::None, false, &mut cache);
assert_eq!(out, LineStyle::None);
assert_eq!(cache, Some(LineStyle::Dashed));
}
fn highlight_base() -> CurveData {
let mut base = CurveData::new(vec![0.0, 1.0], vec![0.0, 1.0], Color32::RED);
base.width = 1.0;
base.line_style = LineStyle::Solid;
base.symbol = Some(Symbol::Circle);
base.marker_size = 7.0;
base.gap_color = Some(Color32::BLUE);
base
}
#[test]
fn current_curve_style_not_highlighted_returns_base_unchanged() {
let base = highlight_base();
let highlight = CurveStyle {
color: Some(Color32::GREEN),
line_width: Some(9.0),
line_style: Some(LineStyle::Dashed),
symbol: Some(Symbol::Square),
symbol_size: Some(99.0),
gap_color: Some(Color32::WHITE),
};
let resolved = current_curve_style(&base, &highlight, false);
assert_eq!(resolved.color, base.color);
assert_eq!(resolved.width, base.width);
assert_eq!(resolved.line_style, base.line_style);
assert_eq!(resolved.symbol, base.symbol);
assert_eq!(resolved.marker_size, base.marker_size);
assert_eq!(resolved.gap_color, base.gap_color);
}
#[test]
fn current_curve_style_default_highlight_overrides_only_width() {
let base = highlight_base();
let highlight = CurveStyle {
line_width: Some(2.0),
..CurveStyle::default()
};
let resolved = current_curve_style(&base, &highlight, true);
assert_eq!(resolved.width, 2.0);
assert_eq!(resolved.color, base.color);
assert_eq!(resolved.line_style, base.line_style);
assert_eq!(resolved.symbol, base.symbol);
assert_eq!(resolved.marker_size, base.marker_size);
assert_eq!(resolved.gap_color, base.gap_color);
}
#[test]
fn current_curve_style_per_field_override_color_and_line_style_only() {
let base = highlight_base();
let highlight = CurveStyle {
color: Some(Color32::GREEN),
line_style: Some(LineStyle::Dashed),
..CurveStyle::default()
};
let resolved = current_curve_style(&base, &highlight, true);
assert_eq!(resolved.color, Color32::GREEN);
assert_eq!(resolved.line_style, LineStyle::Dashed);
assert_eq!(resolved.width, base.width); assert_eq!(resolved.symbol, base.symbol);
assert_eq!(resolved.marker_size, base.marker_size);
assert_eq!(resolved.gap_color, base.gap_color);
}
#[test]
fn default_active_curve_style_is_width_two_only() {
let default_style = CurveStyle {
line_width: Some(2.0),
..CurveStyle::default()
};
assert_eq!(default_style.line_width, Some(2.0));
assert_eq!(default_style.color, None);
assert_eq!(default_style.line_style, None);
assert_eq!(default_style.symbol, None);
assert_eq!(default_style.symbol_size, None);
assert_eq!(default_style.gap_color, None);
}
#[test]
fn set_symbol_visibility_show_from_never_cached_uses_default() {
let mut cache = None;
let out = set_symbol_visibility(None, true, &mut cache);
assert_eq!(out, Some(Symbol::Point));
assert_eq!(out, Some(DEFAULT_RESTORE_SYMBOL));
assert_eq!(cache, None);
}
#[test]
fn set_line_visibility_show_from_never_cached_uses_default() {
let mut cache = None;
let out = set_line_visibility(LineStyle::None, true, &mut cache);
assert_eq!(out, LineStyle::Solid);
assert_eq!(out, DEFAULT_RESTORE_LINE_STYLE);
assert_eq!(cache, None);
}
#[test]
fn clamp_alpha_clamps_out_of_range_entries() {
assert_eq!(
clamp_alpha(vec![1.5, -0.5, 0.25, 1.0, 0.0]),
vec![1.0, 0.0, 0.25, 1.0, 0.0]
);
}
#[test]
fn split_composite_vertical_splits_columns() {
let a = vec![[1u8, 1, 1, 1]; 6];
let b = vec![[2u8, 2, 2, 2]; 6];
let out = split_composite(&a, &b, 3, 2, 2, false);
assert_eq!(out[0], a[0]);
assert_eq!(out[1], a[1]);
assert_eq!(out[2], b[2]);
assert_eq!(out[3], a[3]);
assert_eq!(out[4], a[4]);
assert_eq!(out[5], b[5]);
}
#[test]
fn split_composite_horizontal_splits_rows() {
let a = vec![[1u8, 1, 1, 1]; 6];
let b = vec![[2u8, 2, 2, 2]; 6];
let out = split_composite(&a, &b, 3, 2, 1, true);
assert_eq!(&out[0..3], &[a[0], a[1], a[2]]);
assert_eq!(&out[3..6], &[b[3], b[4], b[5]]);
}
#[test]
fn split_composite_extremes_show_one_image() {
let a = vec![[1u8, 1, 1, 1]; 6];
let b = vec![[2u8, 2, 2, 2]; 6];
assert!(
split_composite(&a, &b, 3, 2, 0, false)
.iter()
.all(|&p| p == b[0])
);
assert!(
split_composite(&a, &b, 3, 2, 0, true)
.iter()
.all(|&p| p == b[0])
);
assert!(
split_composite(&a, &b, 3, 2, 3, false)
.iter()
.all(|&p| p == a[0])
);
assert!(
split_composite(&a, &b, 3, 2, 2, true)
.iter()
.all(|&p| p == a[0])
);
}
#[test]
fn red_blue_gray_composite_matches_silx_channel_layout() {
let cm = Colormap::viridis(0.0, 1.0);
let data_a = vec![0.0f32, 1.0];
let data_b = vec![1.0f32, 0.0];
let byte = |v: f32| cm.lut_index(v as f64) as u8;
let (a0, b0) = (byte(0.0), byte(1.0));
let (a1, b1) = (byte(1.0), byte(0.0));
let pos = red_blue_gray_composite(&data_a, &data_b, &cm, false);
assert_eq!(pos[0], [a0, a0 / 2 + b0 / 2, b0, 255]);
assert_eq!(pos[1], [a1, a1 / 2 + b1 / 2, b1, 255]);
assert!(pos[0][2] > pos[0][0]);
assert!(pos[1][0] > pos[1][2]);
let neg = red_blue_gray_composite(&data_a, &data_b, &cm, true);
assert_eq!(neg[0], [255 - b0, 255 - (a0 / 2 + b0 / 2), 255 - a0, 255]);
assert_eq!(neg[1], [255 - b1, 255 - (a1 / 2 + b1 / 2), 255 - a1, 255]);
}
#[test]
fn seal_compare_colormap_autoscales_over_both_images() {
let base = Colormap::autoscale(ColormapName::Gray);
let sealed = seal_compare_colormap(&base, &[10.0, 20.0], &[5.0, 40.0]);
assert_eq!((sealed.vmin, sealed.vmax), (5.0, 40.0));
assert!(sealed.is_autoscale());
}
#[test]
fn seal_compare_colormap_keeps_a_pinned_range() {
let base = Colormap::viridis(0.0, 1.0);
let sealed = seal_compare_colormap(&base, &[100.0, 200.0], &[300.0, 400.0]);
assert_eq!((sealed.vmin, sealed.vmax), (0.0, 1.0));
}
#[test]
fn seal_compare_colormap_includes_zero_margin_padding() {
let base = Colormap::autoscale(ColormapName::Gray);
let sealed = seal_compare_colormap(&base, &[0.0, 30.0, 20.0], &[25.0, 15.0]);
assert_eq!((sealed.vmin, sealed.vmax), (0.0, 30.0));
}
#[test]
fn compose_per_point_alpha_multiplies_color_alpha() {
let mut colors = vec![Color32::from_rgba_unmultiplied(10, 20, 30, 200)];
compose_per_point_alpha(&mut colors, &[0.5]);
assert_eq!(colors[0], Color32::from_rgba_unmultiplied(10, 20, 30, 100));
}
#[test]
fn compose_per_point_alpha_clamps_each_entry() {
let mut colors = vec![
Color32::from_rgba_unmultiplied(10, 20, 30, 200),
Color32::from_rgba_unmultiplied(40, 50, 60, 200),
];
compose_per_point_alpha(&mut colors, &[2.0, -1.0]);
assert_eq!(colors[0], Color32::from_rgba_unmultiplied(10, 20, 30, 200));
assert_eq!(colors[1], Color32::from_rgba_unmultiplied(40, 50, 60, 0));
}
#[test]
fn compose_per_point_alpha_handles_length_mismatch() {
let mut colors = vec![
Color32::from_rgba_unmultiplied(0, 0, 0, 200),
Color32::from_rgba_unmultiplied(0, 0, 0, 200),
];
compose_per_point_alpha(&mut colors, &[0.5]);
assert_eq!(colors[0].a(), 100);
assert_eq!(colors[1].a(), 200);
let mut colors = vec![Color32::from_rgba_unmultiplied(0, 0, 0, 200)];
compose_per_point_alpha(&mut colors, &[0.5, 0.25, 0.1]);
assert_eq!(colors[0].a(), 100);
}
fn data_bounds(x: (f64, f64), y_left: (f64, f64), y_right: Option<(f64, f64)>) -> DataBounds {
DataBounds {
x: Some(Bounds1D::new(x.0, x.1).unwrap()),
y_left: Some(Bounds1D::new(y_left.0, y_left.1).unwrap()),
y_right: y_right.map(|(lo, hi)| Bounds1D::new(lo, hi).unwrap()),
extra: Vec::new(),
}
}
fn apply_widget_reset(plot: &mut Plot, bounds: DataBounds) {
plot.reset_zoom_to_data_range(raw_data_range_from_bounds(&bounds));
}
#[test]
fn widget_reset_keeps_x_and_refits_y_when_only_y_autoscale_on() {
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);
apply_widget_reset(&mut plot, data_bounds((10.0, 20.0), (-5.0, 5.0), None));
assert_eq!(plot.limits, (0.0, 1.0, -5.0, 5.0));
}
#[test]
fn widget_autoscale_refit_does_not_arm_scroll_guard() {
let mut plot = Plot::new(0);
plot.set_x_autoscale(true);
plot.set_y_autoscale(true);
assert!(!plot.reset_scroll_guard, "fresh plot is unarmed");
apply_widget_reset(&mut plot, data_bounds((10.0, 20.0), (-5.0, 5.0), None));
assert!(
!plot.reset_scroll_guard,
"autoscale refit on content change must not arm the wheel-zoom guard"
);
}
#[test]
fn widget_reset_keeps_y_and_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);
apply_widget_reset(&mut plot, data_bounds((10.0, 20.0), (-5.0, 5.0), None));
assert_eq!(plot.limits, (10.0, 20.0, 0.0, 1.0));
}
#[test]
fn widget_reset_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);
apply_widget_reset(
&mut plot,
data_bounds((10.0, 20.0), (-5.0, 5.0), 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 widget_reset_repairs_single_point_data_via_check_axis_limits() {
let mut plot = Plot::new(0);
plot.set_x_autoscale(true);
plot.set_y_autoscale(true);
apply_widget_reset(&mut plot, data_bounds((4.0, 4.0), (-1.0, 1.0), None));
let (x0, x1, y0, y1) = plot.limits;
assert!(
(x0 - 3.6).abs() <= 1e-12 && (x1 - 4.4).abs() <= 1e-12,
"{x0} {x1}"
);
assert_eq!((y0, y1), (-1.0, 1.0));
}
#[test]
fn raw_data_range_from_bounds_keeps_raw_bounds_unpadded() {
let bounds = DataBounds {
x: Some(Bounds1D::new(4.0, 4.0).unwrap()),
y_left: Some(Bounds1D::new(-5.0, 5.0).unwrap()),
y_right: None,
extra: Vec::new(),
};
let range = raw_data_range_from_bounds(&bounds);
assert_eq!(range.x, Some((4.0, 4.0)), "single point must stay (v, v)");
assert_eq!(range.y, Some((-5.0, 5.0)));
assert_eq!(range.y2, None);
}
#[test]
fn recompute_data_bounds_populates_live_data_range_cache() {
let mut plot = Plot::new(0);
assert_eq!(
plot.data_range(),
DataRange::default(),
"empty before any data"
);
let bounds = data_bounds((10.0, 20.0), (-5.0, 5.0), Some((-1.0, 1.0)));
plot.set_data_range(raw_data_range_from_bounds(&bounds));
let range = plot.data_range();
assert_eq!(range.x, Some((10.0, 20.0)));
assert_eq!(range.y, Some((-5.0, 5.0)));
assert_eq!(range.y2, Some((-1.0, 1.0)));
}
#[test]
fn save_to_path_dispatch_resolves_format_per_extension() {
use crate::widget::actions::io::SaveTarget;
assert_eq!(
SaveTarget::from_path(Path::new("/tmp/fig.png")),
Some(SaveTarget::Figure(SaveFormat::Png))
);
assert_eq!(
SaveTarget::from_path(Path::new("/tmp/fig.ppm")),
Some(SaveTarget::Figure(SaveFormat::Ppm))
);
assert_eq!(
SaveTarget::from_path(Path::new("/tmp/fig.svg")),
Some(SaveTarget::Figure(SaveFormat::Svg))
);
assert_eq!(
SaveTarget::from_path(Path::new("/tmp/fig.tif")),
Some(SaveTarget::Figure(SaveFormat::Tiff))
);
assert_eq!(
SaveTarget::from_path(Path::new("/tmp/fig.tiff")),
Some(SaveTarget::Figure(SaveFormat::Tiff))
);
assert_eq!(
SaveTarget::from_path(Path::new("/tmp/fig.eps")),
Some(SaveTarget::Figure(SaveFormat::Eps))
);
assert_eq!(
SaveTarget::from_path(Path::new("/tmp/fig.pdf")),
Some(SaveTarget::Figure(SaveFormat::Pdf))
);
assert_eq!(
SaveTarget::from_path(Path::new("/tmp/curve.csv")),
Some(SaveTarget::CurveCsv)
);
assert_eq!(
SaveTarget::from_path(Path::new("/tmp/fig.jpeg")),
Some(SaveTarget::Figure(SaveFormat::Jpeg))
);
assert_eq!(SaveTarget::from_path(Path::new("/tmp/fig.ps")), None);
assert_eq!(SaveTarget::from_path(Path::new("/tmp/noext")), None);
}
#[test]
fn print_temp_png_path_is_process_unique_under_dir() {
let dir = Path::new("/tmp/rsplot-test");
let p = print_temp_png_path(dir, 4242);
assert_eq!(p, Path::new("/tmp/rsplot-test/rsplot-print-4242.png"));
assert!(p.starts_with(dir));
assert_eq!(p.extension().and_then(|e| e.to_str()), Some("png"));
assert_ne!(p, print_temp_png_path(dir, 4243));
}
#[test]
fn colorbar_column_width_reserves_only_when_shown_and_available() {
assert_eq!(colorbar_column_width(true, true), COLORBAR_WIDTH);
assert_eq!(colorbar_column_width(false, true), 0.0);
assert_eq!(colorbar_column_width(true, false), 0.0);
assert_eq!(colorbar_column_width(false, false), 0.0);
}
#[test]
fn row_content_width_subtracts_columns_and_gaps() {
let w = row_content_width(1000.0, 200.0 + 175.0, 2, 8.0);
assert_eq!(w + 200.0 + 175.0 + 2.0 * 8.0, 1000.0);
assert_eq!(row_content_width(1000.0, 0.0, 0, 8.0), 1000.0);
assert_eq!(row_content_width(100.0, 375.0, 2, 8.0), 0.0);
}
#[test]
fn side_histogram_extent_reserves_only_when_shown() {
assert_eq!(side_histogram_extent(true, 200.0), 200.0);
assert_eq!(side_histogram_extent(true, 80.0), 80.0);
assert_eq!(side_histogram_extent(false, 200.0), 0.0);
assert_eq!(side_histogram_extent(false, 80.0), 0.0);
}
#[test]
fn image_column_and_row_sums_match_silx_profile() {
let pixels = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0];
let (w, h) = (3usize, 2usize);
assert_eq!(image_column_sums(&pixels, w, h), vec![5.0, 7.0, 9.0]);
assert_eq!(image_row_sums(&pixels, w, h), vec![6.0, 15.0]);
}
#[test]
fn image_profile_sums_have_one_entry_per_index() {
let pixels = vec![1.0f32; 12]; let (w, h) = (4usize, 3usize);
assert_eq!(image_column_sums(&pixels, w, h).len(), w);
assert_eq!(image_row_sums(&pixels, w, h).len(), h);
assert!(
image_column_sums(&pixels, w, h)
.iter()
.all(|&s| s == h as f64)
);
assert!(image_row_sums(&pixels, w, h).iter().all(|&s| s == w as f64));
}
#[test]
fn image_value_at_maps_cursor_to_pixel_like_silx() {
let pixels = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0];
let (w, h) = (3usize, 2usize);
assert_eq!(
image_value_at(0.4, 0.9, &pixels, w, h),
Some((0.0, 0.0, 1.0))
);
assert_eq!(
image_value_at(2.7, 1.2, &pixels, w, h),
Some((2.0, 1.0, 6.0))
);
assert_eq!(
image_value_at(1.0, 1.0, &pixels, w, h),
Some((1.0, 1.0, 5.0))
);
}
#[test]
fn image_value_at_returns_none_off_image() {
let pixels = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0];
let (w, h) = (3usize, 2usize);
assert_eq!(image_value_at(-0.1, 0.5, &pixels, w, h), None);
assert_eq!(image_value_at(0.5, -0.1, &pixels, w, h), None);
assert_eq!(image_value_at(3.0, 0.5, &pixels, w, h), None);
assert_eq!(image_value_at(0.5, 2.0, &pixels, w, h), None);
assert_eq!(image_value_at(0.5, 0.5, &[], 0, 0), None);
}
#[test]
fn colorbar_toggle_frees_and_restores_the_column() {
let mut show = true;
assert_eq!(
colorbar_column_width(show, true),
COLORBAR_WIDTH,
"shown reserves the column"
);
show = !show; assert_eq!(
colorbar_column_width(show, true),
0.0,
"toggling off frees the column"
);
show = !show; assert_eq!(
colorbar_column_width(show, true),
COLORBAR_WIDTH,
"toggling back on reserves it again"
);
}
#[test]
fn finite_bounds_ignores_non_finite_values() {
let bounds = finite_bounds(&[f64::NAN, 2.0, -1.0, f64::INFINITY]).unwrap();
assert_eq!((bounds.min, bounds.max), (-1.0, 2.0));
}
#[test]
fn error_adjusted_bounds_none_falls_back_to_finite_bounds() {
let v = [1.0, 5.0, 3.0];
let b = error_adjusted_bounds(&v, None).unwrap();
assert_eq!((b.min, b.max), (1.0, 5.0));
}
#[test]
fn error_adjusted_bounds_widens_by_lower_and_upper() {
let v = [1.0, 5.0, 3.0];
let err = ErrorBars::Symmetric(0.5);
let b = error_adjusted_bounds(&v, Some(&err)).unwrap();
assert_eq!((b.min, b.max), (0.5, 5.5));
let err = ErrorBars::Asymmetric {
lower: vec![2.0, 0.0, 0.0],
upper: vec![0.0, 1.0, 0.0],
};
let b = error_adjusted_bounds(&v, Some(&err)).unwrap();
assert_eq!((b.min, b.max), (-1.0, 6.0));
}
#[test]
fn error_adjusted_bounds_nan_error_contributes_bare_value() {
let v = [1.0, 5.0];
let err = ErrorBars::Asymmetric {
lower: vec![f64::NAN, 0.0],
upper: vec![0.0, f64::NAN],
};
let b = error_adjusted_bounds(&v, Some(&err)).unwrap();
assert_eq!((b.min, b.max), (1.0, 5.0));
}
#[test]
fn error_adjusted_bounds_negative_error_is_clipped() {
let v = [2.0, 4.0];
let err = ErrorBars::Symmetric(-1.0);
let b = error_adjusted_bounds(&v, Some(&err)).unwrap();
assert_eq!((b.min, b.max), (2.0, 4.0));
}
#[test]
fn log_filtered_curve_range_drops_nonpositive_on_log_axes() {
let x = [-1.0, 2.0, 3.0, 4.0];
let y = [5.0, -1.0, 1.0, 2.0];
assert_eq!(
log_filtered_curve_range(&x, &y, true, false, true),
Some((2.0, 4.0))
);
assert_eq!(
log_filtered_curve_range(&x, &y, true, false, false),
Some((-1.0, 2.0))
);
assert_eq!(
log_filtered_curve_range(&x, &y, true, true, true),
Some((3.0, 4.0))
);
assert_eq!(
log_filtered_curve_range(&[1.0, 2.0], &[-1.0, 0.0], false, true, false),
None
);
}
#[test]
fn data_bounds_tracks_left_and_right_y_separately() {
let mut bounds = DataBounds::default();
bounds.include(
Bounds1D::new(0.0, 10.0).unwrap(),
Bounds1D::new(-1.0, 1.0).unwrap(),
YAxis::Left,
);
bounds.include(
Bounds1D::new(5.0, 20.0).unwrap(),
Bounds1D::new(100.0, 200.0).unwrap(),
YAxis::Right,
);
let raw = |b: Bounds1D| (b.min, b.max);
assert_eq!(raw(bounds.x.unwrap()), (0.0, 20.0));
assert_eq!(raw(bounds.y_left.unwrap()), (-1.0, 1.0));
assert_eq!(raw(bounds.y_right.unwrap()), (100.0, 200.0));
}
#[test]
fn histogram_step_values_builds_2n_stair_without_baseline_anchors() {
let (x, y) = histogram_step_values(&[0.0, 1.0, 3.0], &[2.0, 4.0]).unwrap();
assert_eq!(x, vec![0.0, 1.0, 1.0, 3.0]);
assert_eq!(y, vec![2.0, 2.0, 4.0, 4.0]);
assert_eq!(x.len(), 4); }
#[test]
fn histogram_step_values_validates_edges() {
assert_eq!(
histogram_step_values(&[0.0, 1.0], &[2.0, 4.0]).unwrap_err(),
PlotDataError::HistogramLength { bins: 2, edges: 2 }
);
}
#[test]
fn histogram_edges_left_treats_positions_as_left_edges() {
assert_eq!(
histogram_edges(&[0.0, 1.0, 2.0], HistogramAlign::Left),
vec![0.0, 1.0, 2.0, 3.0]
);
}
#[test]
fn histogram_edges_right_treats_positions_as_right_edges() {
assert_eq!(
histogram_edges(&[1.0, 2.0, 3.0], HistogramAlign::Right),
vec![0.0, 1.0, 2.0, 3.0]
);
}
#[test]
fn histogram_edges_center_puts_positions_at_bin_centres() {
assert_eq!(
histogram_edges(&[1.0, 2.0, 3.0], HistogramAlign::Center),
vec![0.5, 1.5, 2.5, 3.5]
);
}
#[test]
fn histogram_edges_single_position_uses_unit_gap() {
assert_eq!(
histogram_edges(&[5.0], HistogramAlign::Left),
vec![5.0, 6.0]
);
assert_eq!(
histogram_edges(&[5.0], HistogramAlign::Right),
vec![4.0, 5.0]
);
assert_eq!(
histogram_edges(&[5.0], HistogramAlign::Center),
vec![4.5, 5.5]
);
}
#[test]
fn histogram_edges_nonuniform_center_uses_following_gap() {
assert_eq!(
histogram_edges(&[0.0, 1.0, 3.0], HistogramAlign::Center),
vec![-0.5, 0.0, 2.0, 4.0]
);
}
#[test]
fn histogram_edges_empty_is_empty() {
assert!(histogram_edges(&[], HistogramAlign::Center).is_empty());
}
#[test]
fn pick_histogram_locates_bin_and_checks_fill() {
let edges = [0.0, 1.0, 2.0, 3.0];
let values = [2.0, -1.0, 3.0];
assert_eq!(pick_histogram(&edges, &values, 0.0, 0.5, 1.0), Some(0));
assert_eq!(pick_histogram(&edges, &values, 0.0, 0.5, 2.5), None);
assert_eq!(pick_histogram(&edges, &values, 0.0, 1.5, -0.5), Some(1));
assert_eq!(pick_histogram(&edges, &values, 0.0, 1.5, 0.5), None);
assert_eq!(pick_histogram(&edges, &values, 0.0, 2.5, 2.0), Some(2));
}
#[test]
fn pick_histogram_outside_bbox_is_none() {
let edges = [0.0, 1.0, 2.0, 3.0];
let values = [2.0, 1.0, 3.0];
assert_eq!(pick_histogram(&edges, &values, 0.0, -0.1, 1.0), None);
assert_eq!(pick_histogram(&edges, &values, 0.0, 3.1, 1.0), None);
assert_eq!(pick_histogram(&edges, &values, 0.0, 0.5, -0.1), None);
assert_eq!(pick_histogram(&edges, &values, 0.0, 0.5, 3.1), None);
assert_eq!(pick_histogram(&edges, &values, 0.0, 0.0, 1.0), None);
assert_eq!(pick_histogram(&edges, &[1.0, 2.0], 0.0, 0.5, 1.0), None);
assert_eq!(pick_histogram(&[], &[], 0.0, 0.5, 1.0), None);
}
#[test]
fn pick_histogram_honours_nonzero_baseline() {
let edges = [0.0, 1.0, 2.0];
let values = [8.0, 3.0];
assert_eq!(pick_histogram(&edges, &values, 5.0, 0.5, 6.0), Some(0));
assert_eq!(pick_histogram(&edges, &values, 5.0, 1.5, 4.0), Some(1));
assert_eq!(pick_histogram(&edges, &values, 5.0, 0.5, 4.0), None);
}
#[test]
fn aligned_histogram_edges_feed_valid_step_values() {
let positions = [1.0, 2.0, 3.0];
let counts = [5.0, 6.0, 7.0];
let edges = histogram_edges(&positions, HistogramAlign::Center);
let (x, y) = histogram_step_values(&edges, &counts).unwrap();
assert_eq!(x, vec![0.5, 1.5, 1.5, 2.5, 2.5, 3.5]);
assert_eq!(y, vec![5.0, 5.0, 6.0, 6.0, 7.0, 7.0]);
}
#[test]
fn profile_helpers_extract_rows_and_columns() {
let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
assert_eq!(
horizontal_profile_values(3, 2, &data, 1).unwrap(),
vec![4.0, 5.0, 6.0]
);
assert_eq!(
vertical_profile_values(3, 2, &data, 2).unwrap(),
vec![3.0, 6.0]
);
}
#[test]
fn aligned_profile_width_one_mean_matches_single_line() {
let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
assert_eq!(
aligned_profile_values(3, 2, &data, 0.0, 1, true, ProfileMethod::Mean).unwrap(),
vec![1.0, 2.0, 3.0]
);
assert_eq!(
aligned_profile_values(3, 2, &data, 1.0, 1, false, ProfileMethod::Mean).unwrap(),
vec![2.0, 5.0]
);
}
#[test]
fn aligned_profile_full_band_mean_and_sum() {
let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
assert_eq!(
aligned_profile_values(3, 2, &data, 0.5, 2, true, ProfileMethod::Mean).unwrap(),
vec![2.5, 3.5, 4.5]
);
assert_eq!(
aligned_profile_values(3, 2, &data, 0.5, 2, true, ProfileMethod::Sum).unwrap(),
vec![5.0, 7.0, 9.0]
);
assert_eq!(
aligned_profile_values(3, 2, &data, 1.0, 3, false, ProfileMethod::Sum).unwrap(),
vec![6.0, 15.0]
);
}
#[test]
fn aligned_profile_is_nan_aware_like_nanmean_nansum() {
let data = [1.0, 2.0, 3.0, f32::NAN, 5.0, 6.0];
assert_eq!(
aligned_profile_values(3, 2, &data, 0.5, 2, true, ProfileMethod::Mean).unwrap(),
vec![1.0, 3.5, 4.5]
);
assert_eq!(
aligned_profile_values(3, 2, &data, 0.5, 2, true, ProfileMethod::Sum).unwrap(),
vec![1.0, 7.0, 9.0]
);
let all_nan = [f32::NAN, 2.0, 3.0, f32::NAN, 5.0, 6.0];
let sum = aligned_profile_values(3, 2, &all_nan, 0.5, 2, true, ProfileMethod::Sum).unwrap();
assert_eq!(sum[0], 0.0);
let mean =
aligned_profile_values(3, 2, &all_nan, 0.5, 2, true, ProfileMethod::Mean).unwrap();
assert!(mean[0].is_nan());
}
#[test]
fn rect_profile_mean_and_sum_reduce_the_band() {
let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let rect = (0.0, 2.0, 0.0, 1.0);
let (x, y) = rect_profile_values(3, 2, &data, rect, true, ProfileMethod::Mean).unwrap();
assert_eq!(x, vec![0.0, 1.0, 2.0]);
assert_eq!(y, vec![2.5, 3.5, 4.5]);
let (_, y_sum) = rect_profile_values(3, 2, &data, rect, true, ProfileMethod::Sum).unwrap();
assert_eq!(y_sum, vec![5.0, 7.0, 9.0]);
let (xr, yr) = rect_profile_values(3, 2, &data, rect, false, ProfileMethod::Sum).unwrap();
assert_eq!(xr, vec![0.0, 1.0]);
assert_eq!(yr, vec![6.0, 15.0]);
}
#[test]
fn rect_profile_is_nan_aware() {
let data = [1.0, 2.0, 3.0, f32::NAN, 5.0, 6.0];
let rect = (0.0, 2.0, 0.0, 1.0);
let (_, y) = rect_profile_values(3, 2, &data, rect, true, ProfileMethod::Mean).unwrap();
assert_eq!(y, vec![1.0, 3.5, 4.5]);
let (_, ys) = rect_profile_values(3, 2, &data, rect, true, ProfileMethod::Sum).unwrap();
assert_eq!(ys, vec![1.0, 7.0, 9.0]);
}
#[test]
fn line_profile_band_width_one_samples_along_row() {
let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let (x, y) =
line_profile_band(3, 2, &data, (0.0, 0.0), (2.0, 0.0), 1, ProfileMethod::Mean).unwrap();
assert_eq!(x, vec![0.0, 1.0, 2.0]);
assert_eq!(y, vec![1.0, 2.0, 3.0]);
}
#[test]
fn line_profile_band_width_two_averages_perpendicular_rows() {
let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let (_, mean) =
line_profile_band(3, 2, &data, (0.0, 0.5), (2.0, 0.5), 2, ProfileMethod::Mean).unwrap();
assert_eq!(mean, vec![2.5, 3.5, 4.5]);
let (_, sum) =
line_profile_band(3, 2, &data, (0.0, 0.5), (2.0, 0.5), 2, ProfileMethod::Sum).unwrap();
assert_eq!(sum, vec![5.0, 7.0, 9.0]);
}
#[test]
fn line_profile_band_vertical_width_one() {
let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let (x, y) =
line_profile_band(3, 2, &data, (0.0, 0.0), (0.0, 1.0), 1, ProfileMethod::Mean).unwrap();
assert_eq!(x, vec![0.0, 1.0]);
assert_eq!(y, vec![1.0, 4.0]);
}
#[test]
fn line_profile_band_diagonal_is_linear_on_gradient() {
let data = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
let (_, y) =
line_profile_band(3, 3, &data, (0.0, 0.0), (2.0, 2.0), 1, ProfileMethod::Mean).unwrap();
assert_eq!(y.len(), 4);
let diffs: Vec<f64> = y.windows(2).map(|w| w[1] - w[0]).collect();
for d in &diffs {
assert!((d - diffs[0]).abs() < 1e-9, "diffs not constant: {diffs:?}");
}
assert!((y[0] - 0.0).abs() < 1e-9);
assert!((y[3] - 8.0).abs() < 1e-9);
}
#[test]
fn line_profile_band_degenerate_returns_single_sample() {
let data = [1.0, 2.0, 3.0, 4.0];
let (x, y) =
line_profile_band(2, 2, &data, (1.0, 1.0), (1.0, 1.0), 1, ProfileMethod::Mean).unwrap();
assert_eq!(x, vec![0.0]);
assert_eq!(y, vec![4.0]); }
#[test]
fn line_profile_band_validates_length() {
assert_eq!(
line_profile_band(
2,
2,
&[1.0, 2.0, 3.0],
(0.0, 0.0),
(1.0, 1.0),
1,
ProfileMethod::Mean
)
.unwrap_err(),
PlotDataError::ImageDataLength {
expected: 4,
actual: 3,
}
);
}
#[test]
fn free_line_profile_general_case_applies_the_minus_half_shift() {
let data = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
let start = (0.7, 1.2);
let end = (2.4, 2.3);
let (free_x, free_y) =
free_line_profile(3, 3, &data, start, end, 1, ProfileMethod::Mean).unwrap();
let (_shift_x, shift_y) = line_profile_band(
3,
3,
&data,
(start.0 - 0.5, start.1 - 0.5),
(end.0 - 0.5, end.1 - 0.5),
1,
ProfileMethod::Mean,
)
.unwrap();
assert_eq!(free_y, shift_y);
let (_u_x, unshifted_y) =
line_profile_band(3, 3, &data, start, end, 1, ProfileMethod::Mean).unwrap();
assert_ne!(free_y, unshifted_y);
assert_eq!(free_x, linspace_inclusive(start.0, end.0, free_y.len()));
assert_eq!(free_x.first(), Some(&0.7));
assert_eq!(free_x.last(), Some(&2.4));
}
#[test]
fn free_line_profile_general_case_orders_endpoints_left_to_right() {
let data = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
let forward =
free_line_profile(3, 3, &data, (0.7, 1.2), (2.4, 2.3), 1, ProfileMethod::Mean).unwrap();
let reversed =
free_line_profile(3, 3, &data, (2.4, 2.3), (0.7, 1.2), 1, ProfileMethod::Mean).unwrap();
assert_eq!(forward, reversed);
}
#[test]
fn free_line_profile_row_aligned_uses_integer_rectangle() {
let data = [0.0, 1.0, 2.0, 10.0, 11.0, 12.0, 20.0, 21.0, 22.0];
let (x, y) =
free_line_profile(3, 3, &data, (0.0, 1.0), (2.0, 1.0), 1, ProfileMethod::Mean).unwrap();
assert_eq!(x, vec![0.0, 1.0, 2.0]);
assert_eq!(y, vec![10.0, 11.0, 12.0]);
let (_, ys) =
free_line_profile(3, 3, &data, (0.0, 1.0), (2.0, 1.0), 3, ProfileMethod::Sum).unwrap();
assert_eq!(ys, vec![30.0, 33.0, 36.0]);
}
#[test]
fn free_line_profile_column_aligned_uses_integer_rectangle() {
let data = [0.0, 1.0, 2.0, 10.0, 11.0, 12.0, 20.0, 21.0, 22.0];
let (x, y) =
free_line_profile(3, 3, &data, (1.0, 0.0), (1.0, 2.0), 1, ProfileMethod::Mean).unwrap();
assert_eq!(x, vec![0.0, 1.0, 2.0]);
assert_eq!(y, vec![1.0, 11.0, 21.0]);
}
#[test]
fn free_line_profile_aligned_zero_pads_out_of_image() {
let data = [0.0, 1.0, 2.0, 10.0, 11.0, 12.0, 20.0, 21.0, 22.0];
let (_, y) =
free_line_profile(3, 3, &data, (0.0, 1.0), (4.0, 1.0), 1, ProfileMethod::Mean).unwrap();
assert_eq!(y, vec![10.0, 11.0, 12.0, 0.0, 0.0]);
}
#[test]
fn free_line_profile_aligned_coords_offset_by_the_start_pixel() {
let data = [
0.0, 1.0, 2.0, 3.0, 10.0, 11.0, 12.0, 13.0, 20.0, 21.0, 22.0, 23.0, 30.0, 31.0, 32.0, 33.0,
];
let (rx, _) =
free_line_profile(4, 4, &data, (1.0, 2.0), (3.0, 2.0), 1, ProfileMethod::Mean).unwrap();
assert_eq!(rx, vec![1.0, 2.0, 3.0]);
let (cx, _) =
free_line_profile(4, 4, &data, (2.0, 1.0), (2.0, 3.0), 1, ProfileMethod::Mean).unwrap();
assert_eq!(cx, vec![1.0, 2.0, 3.0]);
}
#[test]
fn aligned_profile_band_clamps_to_image_edges() {
let data = [10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0];
assert_eq!(
aligned_profile_values(2, 4, &data, 3.0, 2, true, ProfileMethod::Mean).unwrap(),
vec![15.0, 16.0]
);
assert_eq!(
aligned_profile_values(2, 4, &data, 0.0, 2, true, ProfileMethod::Mean).unwrap(),
vec![11.0, 12.0]
);
assert_eq!(
aligned_profile_values(2, 4, &data, 1.0, 10, true, ProfileMethod::Mean).unwrap(),
vec![13.0, 14.0]
);
}
#[test]
fn aligned_profile_validates_length() {
assert_eq!(
aligned_profile_values(2, 2, &[1.0, 2.0, 3.0], 0.0, 1, true, ProfileMethod::Mean)
.unwrap_err(),
PlotDataError::ImageDataLength {
expected: 4,
actual: 3,
}
);
}
#[test]
fn profile_helpers_validate_shape_and_index() {
assert_eq!(
horizontal_profile_values(2, 2, &[1.0, 2.0, 3.0], 0).unwrap_err(),
PlotDataError::ImageDataLength {
expected: 4,
actual: 3,
}
);
assert_eq!(
horizontal_profile_values(2, 2, &[1.0, 2.0, 3.0, 4.0], 2).unwrap_err(),
PlotDataError::ProfileRow { row: 2, height: 2 }
);
assert_eq!(
vertical_profile_values(2, 2, &[1.0, 2.0, 3.0, 4.0], 2).unwrap_err(),
PlotDataError::ProfileColumn {
column: 2,
width: 2,
}
);
}
#[test]
fn value_stats_ignore_non_finite_values() {
let stats = ValueStats::from_f64(&[1.0, f64::NAN, 4.0, f64::INFINITY]);
assert_eq!(stats.count, 4);
assert_eq!(stats.finite_count, 2);
assert_eq!(stats.min, Some(1.0));
assert_eq!(stats.max, Some(4.0));
assert_eq!(stats.mean, Some(2.5));
}
#[test]
fn value_stats_from_f32_matches_f64_semantics() {
let stats = ValueStats::from_f32(&[1.0, f32::NAN, 4.0, f32::INFINITY]);
assert_eq!(stats.count, 4);
assert_eq!(stats.finite_count, 2);
assert_eq!(stats.min, Some(1.0));
assert_eq!(stats.max, Some(4.0));
assert_eq!(stats.mean, Some(2.5));
}
#[test]
fn image_view_alpha_propagates_to_image_spec() {
let pixels = [1.0_f32, 2.0, 3.0, 4.0];
let cmap = Colormap::viridis(0.0, 4.0);
let mut slider = crate::widget::alpha_slider::AlphaSlider::default();
slider.set_alpha(0.25);
let spec = image_view_image_spec(
2,
2,
&pixels,
&cmap,
slider.alpha(),
InterpolationMode::default(),
AggregationMode::default(),
(1, 1),
);
assert_eq!(spec.alpha, slider.alpha());
assert!((spec.alpha - 64.0 / 255.0).abs() < 1e-6);
}
#[test]
fn image_view_interpolation_aggregation_update_spec() {
let pixels = [1.0_f32, 2.0, 3.0, 4.0];
let cmap = Colormap::viridis(0.0, 4.0);
let spec = image_view_image_spec(
2,
2,
&pixels,
&cmap,
1.0,
InterpolationMode::Linear,
AggregationMode::Mean,
(2, 3),
);
assert_eq!(spec.interpolation, InterpolationMode::Linear);
assert_eq!(spec.aggregation, AggregationMode::Mean);
assert_eq!(spec.aggregation_block, (2, 3));
}
#[test]
fn image_view_profile_drag_samples_expected_values() {
let pixels = [1.0_f32, 2.0, 3.0, 4.0, 5.0, 6.0];
let (hx, hy) = image_view_profile_values(
ProfileMode::Horizontal,
3,
2,
&pixels,
(0.0, 1.0),
(2.0, 1.0),
)
.unwrap();
assert_eq!(hx, vec![0.0, 1.0, 2.0]);
assert_eq!(hy, vec![4.0, 5.0, 6.0]);
let (vx, vy) =
image_view_profile_values(ProfileMode::Vertical, 3, 2, &pixels, (2.0, 0.0), (2.0, 1.0))
.unwrap();
assert_eq!(vx, vec![0.0, 1.0]);
assert_eq!(vy, vec![3.0, 6.0]);
let (_, ly) =
image_view_profile_values(ProfileMode::Line, 3, 2, &pixels, (0.0, 0.0), (2.0, 0.0))
.unwrap();
assert_eq!(ly, vec![1.0, 2.0, 3.0]);
assert!(
image_view_profile_values(ProfileMode::None, 3, 2, &pixels, (0.0, 0.0), (2.0, 0.0))
.is_none()
);
}
#[test]
fn profile_roi_from_drag_matches_mode() {
assert_eq!(
profile_roi_from_drag(ProfileMode::Line, (1.0, 2.0), (3.0, 4.0)),
Some(Roi::Line {
start: (1.0, 2.0),
end: (3.0, 4.0)
})
);
assert_eq!(
profile_roi_from_drag(ProfileMode::Horizontal, (0.0, 1.7), (5.0, 1.7)),
Some(Roi::HRange { y: (1.0, 1.0) })
);
assert_eq!(
profile_roi_from_drag(ProfileMode::Vertical, (2.9, 0.0), (2.9, 4.0)),
Some(Roi::VRange { x: (2.0, 2.0) })
);
assert_eq!(
profile_roi_from_drag(ProfileMode::Rectangle, (4.0, 5.0), (1.0, 2.0)),
Some(Roi::Rect {
x: (1.0, 4.0),
y: (2.0, 5.0)
})
);
assert_eq!(
profile_roi_from_drag(ProfileMode::None, (0.0, 0.0), (1.0, 1.0)),
None
);
}
#[test]
fn click_event_for_pick_maps_each_pick_variant() {
let handle: ItemHandle = 7;
assert_eq!(
PlotWidget::click_event_for_pick(
handle,
&PickResult::CurvePoint {
index: 4,
x: 1.5,
y: -2.0,
distance_px: 3.0,
},
MouseButton::Left,
),
PlotEvent::CurveClicked {
handle,
index: 4,
x: 1.5,
y: -2.0,
button: MouseButton::Left,
}
);
assert_eq!(
PlotWidget::click_event_for_pick(
handle,
&PickResult::ImagePixel { col: 12, row: 9 },
MouseButton::Middle,
),
PlotEvent::ImageClicked {
handle,
col: 12,
row: 9,
button: MouseButton::Middle,
}
);
assert_eq!(
PlotWidget::click_event_for_pick(
handle,
&PickResult::Item { handle: 99 },
MouseButton::Right,
),
PlotEvent::ItemClicked {
handle,
button: MouseButton::Right,
}
);
}
#[test]
fn radar_drag_output_maps_to_image_plot_limits() {
use crate::widget::radar_view::{DataRect, RadarView, clamp_viewport};
let mut radar = RadarView::default();
radar.set_data_bounds(0.0, 100.0, 0.0, 80.0);
radar.set_viewport(DataRect::new(0.0, 0.0, 20.0, 16.0));
let moved = DataRect {
left: radar.viewport.left + 30.0,
top: radar.viewport.top + 20.0,
width: radar.viewport.width,
height: radar.viewport.height,
};
let clamped = clamp_viewport(moved, &radar.data_extent);
let (x0, x1, y0, y1) = clamped.limits();
assert_eq!((x0, x1, y0, y1), (30.0, 50.0, 20.0, 36.0));
assert!(x1 > x0 && y1 > y0);
}
#[test]
fn image_view_position_info_reads_hover_cursor() {
use crate::widget::interaction::PlotPointerEvent;
let moved = PlotPointerEvent::Moved {
button: None,
data: (12.5, -3.0),
pixel: (40.0, 60.0),
};
let cursor = cursor_from_pointer_event(Some(&moved));
assert_eq!(cursor, Some([12.5, -3.0]));
let info = crate::widget::position_info::PositionInfo::with_xy();
let values = info.values(cursor);
assert_eq!(values, vec!["12.5".to_owned(), "-3".to_owned()]);
let clicked = PlotPointerEvent::Clicked {
button: crate::widget::interaction::MouseButton::Left,
data: (4.0, 8.0),
pixel: (10.0, 20.0),
};
assert_eq!(cursor_from_pointer_event(Some(&clicked)), Some([4.0, 8.0]));
let double = PlotPointerEvent::DoubleClicked {
button: crate::widget::interaction::MouseButton::Left,
data: (-1.5, 2.25),
pixel: (10.0, 20.0),
};
assert_eq!(cursor_from_pointer_event(Some(&double)), Some([-1.5, 2.25]));
let limits = PlotPointerEvent::LimitsChanged {
x: (0.0, 1.0),
y: (0.0, 1.0),
y2: None,
};
assert_eq!(cursor_from_pointer_event(Some(&limits)), None);
assert_eq!(cursor_from_pointer_event(None), None);
assert_eq!(
info.values(None),
vec!["------".to_owned(), "------".to_owned()]
);
}
#[test]
fn image_view_colorbar_tracks_colormap_limits() {
let cmap = Colormap::viridis(-3.0, 9.5);
let bar = image_view_colorbar(&cmap);
assert_eq!(bar.colormap.vmin, -3.0);
assert_eq!(bar.colormap.vmax, 9.5);
assert_eq!(
bar.orientation,
crate::widget::colorbar::ColorBarOrientation::Vertical
);
}
#[test]
fn scatter_view_colorbar_tracks_value_colormap_limits() {
assert!(
scatter_view_colorbar(None).is_none(),
"no colorbar before set_data"
);
let cmap = Colormap::viridis(2.5, 41.0);
let bar = scatter_view_colorbar(Some(&cmap)).expect("colorbar after set_data");
assert_eq!(bar.colormap.vmin, 2.5);
assert_eq!(bar.colormap.vmax, 41.0);
assert_eq!(
bar.orientation,
crate::widget::colorbar::ColorBarOrientation::Vertical
);
}
#[test]
fn scatter_points_mode_has_no_grid_image() {
let x = [0.0, 1.0, 0.0];
let y = [0.0, 0.0, 1.0];
let v = [1.0, 2.0, 3.0];
assert!(scatter_grid_image(ScatterVisualization::Points, &x, &y, &v, (4, 4)).is_none());
}
#[test]
fn scatter_irregular_grid_mode_has_no_grid_image() {
let x = [0.0, 4.0, 0.0];
let y = [0.0, 0.0, 4.0];
let v = [0.0, 4.0, 0.0];
assert!(
scatter_grid_image(ScatterVisualization::IrregularGrid, &x, &y, &v, (4, 4)).is_none()
);
}
#[test]
fn scatter_binned_statistic_mode_means_and_nan_empty() {
let x = [0.0, 0.5, 2.0];
let y = [0.0, 0.5, 2.0];
let v = [10.0, 30.0, 7.0];
let img = scatter_grid_image(ScatterVisualization::BinnedStatistic, &x, &y, &v, (2, 2))
.expect("binned");
assert_eq!(img.shape, (2, 2));
assert!((img.get(0, 0).unwrap() - 20.0).abs() < 1e-12);
assert!(img.get(0, 1).unwrap().is_nan());
assert!((img.get(1, 1).unwrap() - 7.0).abs() < 1e-12);
assert_eq!(img.origin, (0.0, 0.0));
assert_eq!(img.scale, (1.0, 1.0));
}
#[test]
fn scatter_regular_grid_mode_reshapes_row_major() {
let x = [0.0, 1.0, 2.0, 0.0, 1.0, 2.0];
let y = [0.0, 0.0, 0.0, 1.0, 1.0, 1.0];
let v = [10.0, 11.0, 12.0, 20.0, 21.0, 22.0];
let img = scatter_grid_image(ScatterVisualization::RegularGrid, &x, &y, &v, (0, 0))
.expect("grid detected");
assert_eq!(img.shape, (2, 3));
assert_eq!(img.get(0, 0), Some(10.0));
assert_eq!(img.get(0, 2), Some(12.0));
assert_eq!(img.get(1, 0), Some(20.0));
assert_eq!(img.get(1, 2), Some(22.0));
assert_eq!(img.scale, (1.0, 1.0));
assert_eq!(img.origin, (-0.5, -0.5));
}
#[test]
fn scatter_regular_grid_mode_column_major_transposes() {
let mut x = Vec::new();
let mut y = Vec::new();
let mut v = Vec::new();
for c in 0..2 {
for r in 0..3 {
x.push(c as f64);
y.push(r as f64);
v.push((c * 10 + r) as f64); }
}
let img = scatter_grid_image(ScatterVisualization::RegularGrid, &x, &y, &v, (0, 0))
.expect("grid detected");
assert_eq!(img.shape, (3, 2));
assert_eq!(img.get(0, 0), Some(0.0));
assert_eq!(img.get(0, 1), Some(10.0));
assert_eq!(img.get(2, 0), Some(2.0));
assert_eq!(img.get(2, 1), Some(12.0));
}
#[test]
fn scatter_regular_grid_mode_trailing_cells_are_nan() {
let x = [0.0, 1.0, 2.0, 3.0, 4.0];
let y = [0.0, 0.0, 0.0, 0.0, 0.0];
let v = [1.0, 2.0, 3.0, 4.0, 5.0];
let img = scatter_grid_image(ScatterVisualization::RegularGrid, &x, &y, &v, (0, 0))
.expect("line detected");
assert_eq!(img.shape, (1, 5));
assert_eq!(img.get(0, 0), Some(1.0));
assert_eq!(img.get(0, 4), Some(5.0));
}
#[test]
fn scatter_masked_selection_flags_nonzero_levels() {
let mask = [0u8, 1, 0, 3, 0];
assert_eq!(
scatter_masked_selection(&mask),
vec![false, true, false, true, false]
);
assert!(scatter_masked_selection(&[]).is_empty());
}
#[test]
fn scatter_mask_rectangle_selection_applies_to_points() {
let px: Vec<f32> = vec![0.0, 1.0, 2.0, 3.0, 4.0];
let py: Vec<f32> = vec![0.0, 0.0, 0.0, 0.0, 0.0];
let mut mask = crate::widget::scatter_mask::ScatterMaskWidget::new(px.len());
mask.update_rectangle(1, (-0.5, 0.5), (1.0, 2.0), &px, &py, true);
let selection = scatter_masked_selection(&mask.mask);
assert_eq!(selection, vec![false, true, true, false, false]);
}
fn ramp_colormap() -> Colormap {
let mut lut = [[0u8; 4]; 256];
for (i, entry) in lut.iter_mut().enumerate() {
*entry = [i as u8, 255 - i as u8, 0, 255];
}
Colormap::viridis(0.0, 4.0).with_lut(lut)
}
fn ramp_color_at(cmap: &Colormap, v: f64) -> Color32 {
let [r, g, b, a] = cmap.lut[cmap.lut_index(v)];
Color32::from_rgba_unmultiplied(r, g, b, a)
}
#[test]
fn point_colors_maps_values_through_colormap_lut() {
let cmap = ramp_colormap();
let values = [0.0, 2.0, 4.0];
let colors = point_colors(&values, &cmap, None);
assert_eq!(
colors,
vec![
ramp_color_at(&cmap, 0.0),
ramp_color_at(&cmap, 2.0),
ramp_color_at(&cmap, 4.0),
]
);
assert_eq!(colors[0], Color32::from_rgba_unmultiplied(0, 255, 0, 255));
assert_eq!(colors[2], Color32::from_rgba_unmultiplied(255, 0, 0, 255));
}
#[test]
fn point_colors_composes_per_point_alpha() {
let cmap = ramp_colormap();
let values = [0.0, 2.0, 4.0];
let alpha = [0.5, 0.25, 1.0];
let with_alpha = point_colors(&values, &cmap, Some(&alpha));
let mut expected = point_colors(&values, &cmap, None);
compose_per_point_alpha(&mut expected, &alpha);
assert_eq!(with_alpha, expected);
assert_eq!(with_alpha[0].to_srgba_unmultiplied()[3], 128);
assert_eq!(with_alpha[1].to_srgba_unmultiplied()[3], 64);
assert_eq!(with_alpha[2].to_srgba_unmultiplied()[3], 255);
}
#[test]
fn solid_path_colors_identical_to_points_path() {
let cmap = ramp_colormap();
let values = [0.0, 1.0, 3.0, 4.0];
let alpha = [1.0, 0.5, 0.25, 0.75];
let shared = point_colors(&values, &cmap, Some(&alpha));
let mut points_inline: Vec<Color32> =
values.iter().map(|&v| ramp_color_at(&cmap, v)).collect();
compose_per_point_alpha(&mut points_inline, &alpha);
assert_eq!(shared, points_inline);
}
#[test]
fn solid_path_builds_triangles_with_per_vertex_colors() {
let cmap = ramp_colormap();
let x = [0.0, 4.0, 0.0, 4.0];
let y = [0.0, 0.0, 4.0, 4.0];
let values = [0.0, 2.0, 4.0, 1.0];
let colors = point_colors(&values, &cmap, None);
let tri = crate::core::scatter_viz::solid_triangles(&x, &y, &colors)
.expect("four non-collinear points triangulate");
assert_eq!(tri.colors, colors);
assert_eq!(tri.x, x.to_vec());
assert_eq!(tri.y, y.to_vec());
assert!(
tri.indices.len() >= 2,
"square triangulates into >= 2 triangles, got {}",
tri.indices.len()
);
}
#[test]
fn solid_path_degenerate_input_yields_no_triangles() {
let cmap = ramp_colormap();
let x2 = [0.0, 1.0];
let y2 = [0.0, 1.0];
let c2 = point_colors(&[0.0, 4.0], &cmap, None);
assert!(crate::core::scatter_viz::solid_triangles(&x2, &y2, &c2).is_none());
let xc = [0.0, 1.0, 2.0];
let yc = [0.0, 1.0, 2.0];
let cc = point_colors(&[0.0, 2.0, 4.0], &cmap, None);
assert!(crate::core::scatter_viz::solid_triangles(&xc, &yc, &cc).is_none());
}
#[test]
fn live_curve_stats_match_core_stats_engine() {
use crate::widget::stats_widget::{StatsWidget, UpdateMode};
let xs = vec![0.0, 1.0, 2.0, 3.0, 4.0];
let ys = vec![2.0, -1.0, 5.0, f64::NAN, 0.5];
let data = RetainedItemData::Curve {
x: xs.clone(),
y: ys.clone(),
};
let input = retained_data_to_stats_input(&data);
let mut w = StatsWidget::new();
w.set_update_mode(UpdateMode::Auto);
w.recompute(&[("curve", input)], None);
let rows = w.rows();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].0, "curve");
let core =
crate::core::stats::Stats::for_curve(&xs, &ys, crate::core::stats::StatScope::All);
assert_eq!(format!("{:?}", rows[0].1), format!("{core:?}"));
assert!(core.mean.unwrap().is_nan(), "NaN reached the mean");
}
#[test]
fn live_image_stats_match_core_stats_engine() {
use crate::widget::stats_widget::{StatsWidget, UpdateMode};
let pixels = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let data = RetainedItemData::Image {
data: pixels.clone(),
width: 3,
height: 2,
origin: (0.0, 0.0),
scale: (1.0, 1.0),
colormap: Box::new(Colormap::viridis(0.0, 1.0)),
alpha: 1.0,
interpolation: InterpolationMode::Nearest,
aggregation: AggregationMode::None,
aggregation_block: (1, 1),
alpha_map: None,
};
let input = retained_data_to_stats_input(&data);
let mut w = StatsWidget::new();
w.set_update_mode(UpdateMode::Auto);
w.recompute(&[("image", input)], None);
let rows = w.rows();
assert_eq!(rows.len(), 1);
let core = crate::core::stats::Stats::for_image(
&pixels,
3,
2,
(0.0, 0.0),
(1.0, 1.0),
crate::core::stats::StatScope::All,
);
assert_eq!(rows[0].1, core);
}
#[test]
fn histogram_stats_run_over_raw_counts_not_step_polyline() {
use crate::widget::stats_widget::{StatsWidget, UpdateMode};
let edges = [0.0, 1.0, 2.0, 3.0];
let counts = [2.0, 4.0, 6.0];
let data = histogram_retained_data(&edges, &counts, HistogramAlign::Center);
let input = retained_data_to_stats_input(&data);
let mut w = StatsWidget::new();
w.set_update_mode(UpdateMode::Auto);
w.recompute(&[("histogram", input)], None);
let rows = w.rows();
assert_eq!(rows.len(), 1);
let s = &rows[0].1;
assert_eq!(s.count, 3, "N raw counts, not the 2N step vertices");
assert_eq!(s.sum, Some(12.0), "sum is Σcounts exactly, not 2·Σcounts");
assert_eq!(s.mean, Some(4.0));
assert_eq!(s.min, Some(2.0));
assert_eq!(s.max, Some(6.0));
assert!((s.com.x.unwrap() - 22.0 / 12.0).abs() < 1e-12);
assert_eq!(s.coord_min.x, Some(0.5));
assert_eq!(s.coord_max.x, Some(2.5));
}
#[test]
fn revert_compute_edges_recovers_positions_per_alignment() {
let positions = [1.0, 2.0, 3.0, 4.0];
for align in [
HistogramAlign::Left,
HistogramAlign::Center,
HistogramAlign::Right,
] {
let edges = histogram_edges(&positions, align);
let back = revert_compute_edges(&edges, align);
assert_eq!(back.len(), positions.len(), "{align:?}");
for (b, p) in back.iter().zip(&positions) {
assert!((b - p).abs() < 1e-12, "{align:?}: {back:?}");
}
}
let edges = [0.0, 1.0, 3.0];
assert_eq!(
revert_compute_edges(&edges, HistogramAlign::Left),
[0.0, 1.0]
);
assert_eq!(
revert_compute_edges(&edges, HistogramAlign::Right),
[1.0, 3.0]
);
assert_eq!(
revert_compute_edges(&edges, HistogramAlign::Center),
[0.5, 2.0]
);
assert!(revert_compute_edges(&[1.0], HistogramAlign::Center).is_empty());
}
#[test]
fn histogram_retained_data_precomputes_anchors_and_centers() {
let edges = [0.0, 2.0, 6.0];
let counts = [10.0, 20.0];
let data = histogram_retained_data(&edges, &counts, HistogramAlign::Left);
let RetainedItemData::Histogram {
edges: e,
counts: c,
x,
centers,
} = &data
else {
panic!("histogram retained data");
};
assert_eq!(e.as_slice(), &edges);
assert_eq!(c.as_slice(), &counts);
assert_eq!(x.as_slice(), &[0.0, 2.0]);
assert_eq!(centers.as_slice(), &[1.0, 4.0]);
assert_eq!(
retained_curve_xy(&data),
Some((centers.as_slice(), counts.as_slice()))
);
}
#[test]
fn scatter_retained_data_feeds_value_array_stats() {
use crate::widget::stats_widget::{StatsWidget, UpdateMode};
let data = RetainedItemData::Scatter {
x: vec![0.0, 1.0, 2.0],
y: vec![10.0, 11.0, 12.0],
values: vec![5.0, 1.0, 3.0],
};
let input = retained_data_to_stats_input(&data);
let mut w = StatsWidget::new();
w.set_update_mode(UpdateMode::Auto);
w.recompute(&[("scatter", input)], None);
let rows = w.rows();
assert_eq!(rows.len(), 1);
let core = crate::core::stats::Stats::for_scatter(
&[0.0, 1.0, 2.0],
&[10.0, 11.0, 12.0],
&[5.0, 1.0, 3.0],
crate::core::stats::StatScope::All,
);
assert_eq!(rows[0].1, core);
assert_eq!(rows[0].1.max, Some(5.0));
assert_eq!(rows[0].1.coord_max.x, Some(0.0));
assert_eq!(rows[0].1.coord_max.y, Some(10.0));
assert_eq!(retained_curve_xy(&data), None, "scatter is not fittable");
}
#[test]
fn image_display_attrs_round_trip_preserves_every_reupload_field() {
let pixels = [1.0f32, 2.0, 3.0, 4.0];
let am = [0.0f32, 0.25, 0.75, 1.0];
let mut spec = ImageSpec::scalar(2, 2, &pixels, Colormap::viridis(0.0, 4.0));
spec.origin = (5.0, 6.0);
spec.scale = (2.0, 3.0);
spec.alpha = 0.4;
spec.interpolation = InterpolationMode::Linear;
spec.aggregation = AggregationMode::Max;
spec.aggregation_block = (2, 2);
spec.alpha_map = Some(&am);
let retained = image_spec_retained_data(&spec).expect("scalar image retains data");
let attrs = retained
.image_display_attrs()
.expect("image has display attrs");
let mut rebuilt = ImageSpec::scalar(2, 2, &pixels, Colormap::viridis(0.0, 4.0));
assert_eq!(rebuilt.interpolation, InterpolationMode::Nearest);
assert_eq!(rebuilt.aggregation, AggregationMode::None);
assert_eq!(rebuilt.alpha, 1.0);
assert!(rebuilt.alpha_map.is_none());
attrs.apply(&mut rebuilt);
assert_eq!(rebuilt.origin, (5.0, 6.0));
assert_eq!(rebuilt.scale, (2.0, 3.0));
assert_eq!(rebuilt.alpha, 0.4);
assert_eq!(rebuilt.interpolation, InterpolationMode::Linear);
assert_eq!(rebuilt.aggregation, AggregationMode::Max);
assert_eq!(rebuilt.aggregation_block, (2, 2));
assert_eq!(rebuilt.alpha_map, Some(am.as_slice()));
}
#[test]
fn roi_stats_rows_image_match_image_roi_stats_per_roi() {
use crate::widget::roi_stats::image_roi_stats;
let pixels = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let data = RetainedItemData::Image {
data: pixels.clone(),
width: 3,
height: 2,
origin: (0.0, 0.0),
scale: (1.0, 1.0),
colormap: Box::new(Colormap::viridis(0.0, 1.0)),
alpha: 1.0,
interpolation: InterpolationMode::Nearest,
aggregation: AggregationMode::None,
aggregation_block: (1, 1),
alpha_map: None,
};
let mut named = ManagedRoi::new(Roi::Rect {
x: (0.0, 2.0),
y: (0.0, 1.0),
});
named.name = "left".to_owned();
let rois = vec![
named,
ManagedRoi::new(Roi::Rect {
x: (1.0, 3.0),
y: (0.0, 2.0),
}),
];
let rows = roi_stats_rows(&rois, &data);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].label, "left");
assert_eq!(rows[1].label, "ROI 1");
let f32_pixels: Vec<f32> = pixels.iter().map(|&v| v as f32).collect();
for (row, managed) in rows.iter().zip(rois.iter()) {
let expected = image_roi_stats(&managed.roi, &f32_pixels, 3, 2, [0.0, 0.0], [1.0, 1.0]);
assert_eq!(row.stats, expected);
}
}
#[test]
fn roi_stats_rows_curve_match_curve_roi_stats_per_roi() {
use crate::widget::roi_stats::curve_roi_stats;
let x = vec![0.0, 1.0, 2.0, 3.0, 4.0];
let y = vec![10.0, 20.0, 30.0, 40.0, 50.0];
let data = RetainedItemData::Curve {
x: x.clone(),
y: y.clone(),
};
let rois = vec![
ManagedRoi::new(Roi::VRange { x: (1.0, 3.0) }),
ManagedRoi::new(Roi::Rect {
x: (0.0, 2.0),
y: (0.0, 100.0),
}),
];
let rows = roi_stats_rows(&rois, &data);
assert_eq!(rows.len(), 2);
for (row, managed) in rows.iter().zip(rois.iter()) {
assert_eq!(row.stats, curve_roi_stats(&managed.roi, &x, &y));
}
}
#[test]
fn roi_stats_rows_empty_without_rois() {
let data = RetainedItemData::Curve {
x: vec![0.0, 1.0],
y: vec![1.0, 2.0],
};
assert!(roi_stats_rows(&[], &data).is_empty());
}
#[test]
fn curve_roi_rows_match_curve_roi_counts_and_skip_non_curve_rois() {
use crate::widget::roi_stats::{curve_roi_counts, roi_x_span};
let x = vec![0.0, 1.0, 2.0, 3.0, 4.0];
let y = vec![0.0, 0.0, 10.0, 0.0, 0.0];
let mut named = ManagedRoi::new(Roi::VRange { x: (0.0, 4.0) });
named.name = "peak".to_owned();
let rois = vec![
named,
ManagedRoi::new(Roi::HRange { y: (0.0, 5.0) }),
ManagedRoi::new(Roi::Rect {
x: (1.0, 3.0),
y: (-100.0, 100.0),
}),
];
let rows = curve_roi_rows(&rois, &x, &y);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].label, "peak");
assert_eq!(
(rows[0].from, rows[0].to),
roi_x_span(&rois[0].roi).unwrap()
);
assert_eq!(
rows[0].counts,
curve_roi_counts(&rois[0].roi, &x, &y).unwrap()
);
assert_eq!(rows[1].label, "ROI 2");
assert_eq!(
(rows[1].from, rows[1].to),
roi_x_span(&rois[2].roi).unwrap()
);
assert_eq!(
rows[1].counts,
curve_roi_counts(&rois[2].roi, &x, &y).unwrap()
);
}
#[test]
fn curve_roi_rows_empty_without_rois() {
let x = vec![0.0, 1.0, 2.0];
let y = vec![1.0, 2.0, 3.0];
assert!(curve_roi_rows(&[], &x, &y).is_empty());
}
#[test]
fn fit_target_feeds_curve_xy_not_image() {
let xs = vec![1.0, 2.0, 3.0, 4.0];
let ys = vec![10.0, 20.0, 30.0, 40.0];
let curve = RetainedItemData::Curve {
x: xs.clone(),
y: ys.clone(),
};
let (fx, fy) = retained_curve_xy(&curve).expect("curve feeds its xy");
assert_eq!(fx, xs.as_slice());
assert_eq!(fy, ys.as_slice());
let image = RetainedItemData::Image {
data: vec![1.0, 2.0, 3.0, 4.0],
width: 2,
height: 2,
origin: (0.0, 0.0),
scale: (1.0, 1.0),
colormap: Box::new(Colormap::viridis(0.0, 1.0)),
alpha: 1.0,
interpolation: InterpolationMode::Nearest,
aggregation: AggregationMode::None,
aggregation_block: (1, 1),
alpha_map: None,
};
assert!(retained_curve_xy(&image).is_none());
}
#[test]
fn raw_pixel_stddev3_autoscale_matches_mean_plus_minus_3std() {
let pixels = [-1.0, 0.0, 1.0, f64::NAN];
let base = Colormap::viridis(7.0, 9.0); let cm = autoscaled_colormap(&base, AutoscaleMode::Stddev3, &pixels);
let (evmin, evmax) = AutoscaleMode::Stddev3.range(
Normalization::Linear,
&pixels,
crate::core::colormap::DEFAULT_PERCENTILES,
);
assert_eq!(cm.vmin, evmin);
assert_eq!(cm.vmax, evmax);
assert_eq!((cm.vmin, cm.vmax), (-1.0, 1.0));
assert_eq!(cm.lut, base.lut);
}
#[test]
fn raw_pixel_percentile_autoscale_matches_percentile_bounds() {
let pixels = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, f64::NAN];
let mut base = Colormap::viridis(0.0, 1.0);
base.set_autoscale_percentiles(10.0, 90.0);
let cm = autoscaled_colormap(&base, AutoscaleMode::Percentile, &pixels);
let (evmin, evmax) =
AutoscaleMode::Percentile.range(Normalization::Linear, &pixels, (10.0, 90.0));
assert_eq!(cm.vmin, evmin);
assert_eq!(cm.vmax, evmax);
assert!((cm.vmin - 0.9).abs() < 1e-12, "vmin {}", cm.vmin);
assert!((cm.vmax - 8.1).abs() < 1e-12, "vmax {}", cm.vmax);
}
#[test]
fn masked_image_yields_nan_at_masked_pixels_before_upload() {
let data = [1.0_f32, 2.0, 3.0, 4.0];
let mut mask = ScalarMask::new(2, 2);
mask.set_mask_data(&[0, 1, 1, 0], 2);
let out = apply_image_mask(2, 2, &data, &mask).unwrap();
assert_eq!(out[0], 1.0);
assert!(out[1].is_nan());
assert!(out[2].is_nan());
assert_eq!(out[3], 4.0);
}
#[test]
fn masked_image_validates_shape() {
let mask = ScalarMask::new(2, 2);
assert_eq!(
apply_image_mask(2, 2, &[1.0, 2.0, 3.0], &mask).unwrap_err(),
PlotDataError::ImageDataLength {
expected: 4,
actual: 3,
}
);
let small = ScalarMask::new(1, 1);
assert_eq!(
apply_image_mask(2, 2, &[1.0, 2.0, 3.0, 4.0], &small).unwrap_err(),
PlotDataError::ImageDataLength {
expected: 4,
actual: 1,
}
);
}
#[test]
fn image_view_paints_mask_only_in_mask_draw_mode() {
for mode in [
PlotInteractionMode::Pan,
PlotInteractionMode::Zoom,
PlotInteractionMode::Select,
] {
assert!(
!image_view_should_paint_mask(mode, true),
"{mode:?} must not paint even with the mask panel enabled",
);
}
assert!(
!image_view_should_paint_mask(PlotInteractionMode::MaskDraw, false),
"MaskDraw must not paint when the mask panel is disabled",
);
assert!(
image_view_should_paint_mask(PlotInteractionMode::MaskDraw, true),
"MaskDraw with the mask panel enabled is the only painting state",
);
}
#[test]
fn painted_level_buffer_round_trips_to_reupload_mask() {
let levels = [0u8, 1, 0, 0, 7, 0]; let scalar_mask = scalar_mask_from_level_buffer(2, 3, &levels);
assert_eq!(scalar_mask.width(), 2);
assert_eq!(scalar_mask.height(), 3);
for (i, &lvl) in levels.iter().enumerate() {
let col = i % 2;
let row = i / 2;
assert_eq!(
scalar_mask.is_masked(col, row),
lvl != 0,
"pixel ({col},{row}) level {lvl}",
);
}
let pixels = [10.0_f32, 20.0, 30.0, 40.0, 50.0, 60.0];
let masked = scalar_mask.apply(&pixels);
for (i, (&lvl, &out)) in levels.iter().zip(masked.iter()).enumerate() {
if lvl != 0 {
assert!(out.is_nan(), "masked pixel {i} (level {lvl}) must be NaN");
} else {
assert_eq!(out, pixels[i], "unmasked pixel {i} unchanged");
}
}
let mut direct = ScalarMask::new(2, 3);
direct.set_mask_data(&levels, 2);
assert_eq!(scalar_mask, direct);
}
#[test]
fn scalar_mask_from_level_buffer_clips_oversized_to_image_shape() {
let oversized = [1u8; 10]; let mask = scalar_mask_from_level_buffer(2, 2, &oversized);
assert_eq!(mask.width(), 2);
assert_eq!(mask.height(), 2);
assert_eq!(mask.get_mask_data().len(), 4);
assert!(mask.get_mask_data().iter().all(|&m| m != 0));
}
#[test]
fn value_stats_match_core_stats_engine_on_finite_data() {
let run_core = |data: &[f64]| {
crate::core::stats::Stats::for_image(
data,
data.len(),
1,
(0.0, 0.0),
(1.0, 1.0),
crate::core::stats::StatScope::All,
)
};
let finite = [3.0, -2.0, 7.5, 0.0, -2.0];
let vs = ValueStats::from_f64(&finite);
let core = run_core(&finite);
assert_eq!(vs.count, core.count);
assert_eq!(vs.finite_count, core.included_count);
assert_eq!(vs.min, core.min);
assert_eq!(vs.max, core.max);
assert_eq!(vs.mean, core.mean);
let with_nan = [3.0, f64::NAN, 1.0];
let vs = ValueStats::from_f64(&with_nan);
let core = run_core(&with_nan);
assert_eq!(vs.finite_count, 2);
assert_eq!(vs.mean, Some(2.0), "items panel stays finite");
assert!(core.mean.unwrap().is_nan(), "stats table propagates NaN");
}
#[test]
fn mask_rgba_pixels_are_transparent_where_false() {
let color = Color32::from_rgba_unmultiplied(10, 20, 30, 40);
let pixels = mask_rgba_pixels(&[true, false, true], color);
let rgba = color.to_srgba_unmultiplied();
assert_eq!(pixels, vec![rgba, [0, 0, 0, 0], rgba]);
}
#[test]
fn legend_row_width_stays_within_parent_width() {
assert_eq!(legend_row_width(180.0), 180.0);
assert_eq!(legend_row_width(80.0), 80.0);
assert_eq!(legend_row_width(0.0), LEGEND_ROW_MIN_WIDTH);
}
#[test]
fn scatter_visualization_catalog_is_silx_order_with_unique_labels() {
assert_eq!(
ScatterVisualization::ALL,
[
ScatterVisualization::Points,
ScatterVisualization::Solid,
ScatterVisualization::RegularGrid,
ScatterVisualization::IrregularGrid,
ScatterVisualization::BinnedStatistic,
]
);
assert_eq!(
ScatterVisualization::default(),
ScatterVisualization::Points
);
let labels: Vec<&str> = ScatterVisualization::ALL
.iter()
.map(|m| m.label())
.collect();
let mut deduped = labels.clone();
deduped.sort_unstable();
deduped.dedup();
assert_eq!(labels.len(), deduped.len(), "labels must be unique");
}
#[test]
fn compare_pixel_at_looks_up_origin_aligned_pixel() {
let data = [0.0f32, 1.0, 2.0, 3.0, 4.0, 5.0];
assert_eq!(compare_pixel_at(3, 2, &data, 0.0, 0.0), Some(0.0));
assert_eq!(compare_pixel_at(3, 2, &data, 2.9, 1.9), Some(5.0));
assert_eq!(compare_pixel_at(3, 2, &data, 1.7, 0.2), Some(1.0));
assert_eq!(compare_pixel_at(3, 2, &data, 3.0, 0.0), None);
assert_eq!(compare_pixel_at(3, 2, &data, 0.0, 2.0), None);
assert_eq!(compare_pixel_at(3, 2, &data, -0.1, 0.0), None);
assert_eq!(compare_pixel_at(3, 2, &[], 0.0, 0.0), None);
}
#[test]
fn format_compare_value_matches_silx_status_bar() {
assert_eq!(format_compare_value(true, None), "no image");
assert_eq!(format_compare_value(true, Some(1.0)), "no image");
assert_eq!(format_compare_value(false, None), "NA");
assert_eq!(format_compare_value(false, Some(1.5)), "1.500000");
assert_eq!(format_compare_value(false, Some(0.0)), "0.000000");
}
#[test]
fn margin_image_origin_anchors_top_left() {
let src = [1.0_f32, 2.0, 3.0, 4.0]; let out = margin_image(&src, 2, 2, 4, 3, false);
assert_eq!(
out,
vec![
1.0, 2.0, 0.0, 0.0, 3.0, 4.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, ]
);
}
#[test]
fn margin_image_center_uses_silx_floor_offset() {
let src = [1.0_f32, 2.0, 3.0, 4.0];
let out = margin_image(&src, 2, 2, 4, 4, true);
assert_eq!(
out,
vec![
0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 0.0, 0.0, 3.0, 4.0, 0.0, 0.0, 0.0, 0.0, 0.0, ]
);
}
#[test]
fn rescale_array_identity_returns_input() {
let src = [1.0_f32, 2.0, 3.0, 4.0, 5.0, 6.0]; let out = rescale_array(&src, 3, 2, 3, 2);
assert_eq!(out, src.to_vec());
}
#[test]
fn rescale_array_upscales_bilinearly() {
let src = [0.0_f32, 10.0];
let out = rescale_array(&src, 2, 1, 3, 1);
assert_eq!(out, vec![0.0, 5.0, 10.0]);
}
#[test]
fn align_compare_images_origin_pads_to_max_grid() {
let a = [9.0_f32];
let b = [1.0_f32, 2.0, 3.0, 4.0];
let (d1, d2, cw, ch) = align_compare_images(CompareAlignment::Origin, &a, 1, 1, &b, 2, 2);
assert_eq!((cw, ch), (2, 2));
assert_eq!(d1, vec![9.0, 0.0, 0.0, 0.0]); assert_eq!(d2, vec![1.0, 2.0, 3.0, 4.0]); }
#[test]
fn align_compare_images_center_centers_smaller() {
let a = [7.0_f32];
let b: Vec<f32> = (1..=9).map(|v| v as f32).collect();
let (d1, d2, cw, ch) = align_compare_images(CompareAlignment::Center, &a, 1, 1, &b, 3, 3);
assert_eq!((cw, ch), (3, 3));
assert_eq!(
d1,
vec![0.0, 0.0, 0.0, 0.0, 7.0, 0.0, 0.0, 0.0, 0.0] );
assert_eq!(d2, b); }
#[test]
fn align_compare_images_stretch_resamples_b_to_a_shape() {
let a = [1.0_f32, 2.0, 3.0];
let b = [0.0_f32, 10.0];
let (d1, d2, cw, ch) = align_compare_images(CompareAlignment::Stretch, &a, 3, 1, &b, 2, 1);
assert_eq!((cw, ch), (3, 1));
assert_eq!(d1, a.to_vec());
assert_eq!(d2, vec![0.0, 5.0, 10.0]);
}
#[test]
fn compare_aligned_coords_remaps_per_mode() {
assert_eq!(
compare_aligned_coords(CompareAlignment::Origin, 4.0, 5.0, 2, 2, 6, 6),
((4.0, 5.0), (4.0, 5.0))
);
assert_eq!(
compare_aligned_coords(CompareAlignment::Center, 3.0, 3.0, 2, 2, 6, 6),
((1.0, 1.0), (3.0, 3.0))
);
assert_eq!(
compare_aligned_coords(CompareAlignment::Stretch, 2.0, 1.0, 4, 2, 8, 6),
((2.0, 1.0), (4.0, 3.0))
);
}
#[test]
fn align_summary_classifies_the_sift_transform() {
let id = AffineTransformation {
tx: 0.0,
ty: 0.0,
sx: 1.0,
sy: 1.0,
rotation: 0.0,
};
assert_eq!(align_summary(&id), "No changes");
let tiny = AffineTransformation { tx: 0.001, ..id };
assert_eq!(align_summary(&tiny), "No big changes");
let shift = AffineTransformation {
tx: 4.0,
ty: 3.0,
..id
};
assert_eq!(align_summary(&shift), "Translation");
let all = AffineTransformation {
tx: 4.0,
ty: 0.0,
sx: 1.2,
sy: 1.0,
rotation: 0.3,
};
assert_eq!(align_summary(&all), "Translation+Scale+Rotation");
}
#[test]
fn align_tooltip_lists_nonidentity_components() {
let id = AffineTransformation {
tx: 0.0,
ty: 0.0,
sx: 1.0,
sy: 1.0,
rotation: 0.0,
};
assert_eq!(align_tooltip(&id), "No transformation");
let t = AffineTransformation {
tx: 4.0,
ty: -2.0,
sx: 1.0,
sy: 1.0,
rotation: std::f64::consts::FRAC_PI_2, };
assert_eq!(
align_tooltip(&t),
"Translation x: 4.000px\nTranslation y: -2.000px\nRotation: 90.000deg"
);
}
}