From a42407cd305d3e767a8486cae1802e38c4d45899 Mon Sep 17 00:00:00 2001 From: junzhuo Date: Wed, 6 May 2026 22:35:52 +0800 Subject: [PATCH] feat: add data and axes transforms --- examples/transform.rs | 154 ++++++++++++++ src/axis_scale.rs | 34 +--- src/camera.rs | 18 ++ src/lib.rs | 2 + src/plot_renderer/canvas.rs | 49 +++-- src/plot_renderer/mod.rs | 16 +- src/plot_renderer/shader.rs | 15 +- src/plot_state.rs | 200 ++++++++++++++---- src/plot_widget.rs | 92 ++++++++- src/plot_widget_builder.rs | 12 +- src/reference_lines.rs | 38 +++- src/series.rs | 61 +++++- src/transform.rs | 393 ++++++++++++++++++++++++++++++++++++ 13 files changed, 985 insertions(+), 99 deletions(-) create mode 100644 examples/transform.rs create mode 100644 src/transform.rs diff --git a/examples/transform.rs b/examples/transform.rs new file mode 100644 index 0000000..b48c05b --- /dev/null +++ b/examples/transform.rs @@ -0,0 +1,154 @@ +//! Example demonstrating coordinate transforms. +//! +//! The plot axes stay linear. Each series or reference line can choose how its +//! own x/y values are converted before drawing. `Transform::axes()` uses +//! normalized plot positions, so `0.4` means 40% across the plot area. + +use std::f64::consts::{E, TAU}; + +use iced::Element; +use iced_plot::{ + Color, HLine, LineStyle, MarkerStyle, PlotUiMessage, PlotWidget, PlotWidgetBuilder, + PositionTransform, Series, Transform, VLine, +}; + +fn main() -> iced::Result { + iced::application(new, update, view) + .font(include_bytes!("fonts/FiraCodeNerdFont-Regular.ttf")) + .default_font(iced::Font::with_name("FiraCode Nerd Font")) + .run() +} + +fn update(widget: &mut PlotWidget, message: PlotUiMessage) { + widget.update(message); +} + +fn view(widget: &PlotWidget) -> Element<'_, PlotUiMessage> { + widget.view() +} + +fn sample(count: usize, mut f: impl FnMut(f64) -> [f64; 2]) -> Vec<[f64; 2]> { + (0..count) + .map(|i| { + let t = i as f64 / (count.saturating_sub(1).max(1)) as f64; + f(t) + }) + .collect() +} + +fn new() -> PlotWidget { + let identity = Series::line_only( + sample(160, |t| { + let x = t * 2.0; + [x, (TAU * t).sin() * 0.55] + }), + LineStyle::solid().with_pixel_width(2.0), + ) + .with_transform(PositionTransform::new(Some(Transform::identity()), None)) + .with_label("identity data") + .with_color(Color::from_rgb(0.2, 0.6, 1.0)); + + let affine_y = Series::line_only( + sample(160, |t| { + let x = t * 2.0; + let normalized = ((TAU * t).cos() * 0.5 + 0.5).clamp(0.0, 1.0); + [x, normalized] + }), + LineStyle::dashed(8.0).with_pixel_width(2.0), + ) + .with_transform_y(Transform::affine(2.0, -1.0)) + .with_label("y affine: y * 2 - 1") + .with_color(Color::from_rgb(0.9, 0.45, 0.15)); + + let log_x = Series::line_only( + sample(160, |t| { + let x_plot = t * 2.0; + [10.0_f64.powf(x_plot), 1.05 + (TAU * t).sin() * 0.25] + }), + LineStyle::solid().with_pixel_width(2.0), + ) + .with_transform_x(Transform::log(10.0)) + .with_label("x log10(raw)") + .with_color(Color::from_rgb(0.35, 0.85, 0.4)); + + let exp_y = Series::line_only( + sample(160, |t| { + let x = t * 2.0; + let y_after_transform = 1.25 + x * 0.45; + [x, y_after_transform.ln()] + }), + LineStyle::dotted(5.0).with_pixel_width(2.5), + ) + .with_transform_y(Transform::exp(E)) + .with_label("y exp(raw)") + .with_color(Color::from_rgb(0.85, 0.2, 0.55)); + + let composed_x = Series::line_only( + sample(160, |t| [t, 2.45 + (TAU * t).cos() * 0.22]), + LineStyle::dashed(5.0).with_pixel_width(2.0), + ) + .with_transform_x(Transform::affine(99.0, 1.0).then(Transform::log(10.0))) + .with_label("x (raw * 99 + 1) then log10") + .with_color(Color::from_rgb(0.65, 0.45, 0.95)); + + let axes_line = Series::line_only( + vec![[0.05, 0.88], [0.95, 0.88]], + LineStyle::solid().with_pixel_width(3.0), + ) + .with_axes_transform() + .with_label("axes line at 88%") + .with_color(Color::from_rgb(0.0, 0.9, 0.95)); + + let mixed_axes_x = Series::line_only( + vec![[0.5, -1.25], [0.5, 3.15]], + LineStyle::dotted(3.0).with_pixel_width(3.0), + ) + .with_transform_x(Transform::axes()) + .with_label("x axes=50%, y data") + .with_color(Color::from_rgb(0.95, 0.8, 0.15)); + + let axes_marker = Series::new( + vec![[0.92, 0.14]], + MarkerStyle::star(14.0), + LineStyle::solid(), + ) + .with_transform(PositionTransform::new( + Some(Transform::axes()), + Some(Transform::axes()), + )) + .with_label("right-lower marker") + .with_color(Color::from_rgb(1.0, 0.2, 0.2)); + + let center_vline = VLine::new(0.25) + .with_axes_transform() + .with_label("vline x=25% axes") + .with_color(Color::from_rgb(0.8, 0.8, 0.8)) + .with_width(1.5) + .with_style(LineStyle::dashed(4.0)); + + let center_hline = HLine::new(0.5) + .with_axes_transform() + .with_label("hline y=50% axes") + .with_color(Color::from_rgb(0.8, 0.8, 0.8)) + .with_width(1.5) + .with_style(LineStyle::dashed(4.0)); + + PlotWidgetBuilder::new() + .with_x_label("plot x after transform") + .with_y_label("plot y after transform") + .with_x_lim(-0.2, 2.2) + .with_y_lim(-1.4, 3.3) + .add_series(identity) + .add_series(affine_y) + .add_series(log_x) + .add_series(exp_y) + .add_series(composed_x) + .add_series(axes_line) + .add_series(mixed_axes_x) + .add_series(axes_marker) + .add_vline(center_vline) + .add_hline(center_hline) + .with_cursor_overlay(true) + .build() + .unwrap() +} diff --git a/src/axis_scale.rs b/src/axis_scale.rs index aef250c..f2dfd34 100644 --- a/src/axis_scale.rs +++ b/src/axis_scale.rs @@ -17,47 +17,19 @@ pub enum AxisScale { impl AxisScale { /// Transform raw data value into plot-space value. pub(crate) fn data_to_plot(self, value: f64) -> Option { - match self { - Self::Linear => value.is_finite().then_some(value), - Self::Log { base } => (value.is_finite() && value > 0.0) - .then(|| value.log(base)) - .filter(|v| v.is_finite()), - } + crate::transform::data_value_to_plot(value, self, None) } /// Transform plot-space value into raw data value. pub(crate) fn plot_to_data(self, value: f64) -> Option { - match self { - Self::Linear => value.is_finite().then_some(value), - Self::Log { base } => { - if !value.is_finite() { - return None; - } - let out = base.powf(value); - (out.is_finite() && out > 0.0).then_some(out) - } - } + crate::transform::plot_value_to_data(value, self) } } -pub(crate) fn data_point_to_plot( - point: [f64; 2], - x_scale: AxisScale, - y_scale: AxisScale, -) -> Option<[f64; 2]> { - Some([ - x_scale.data_to_plot(point[0])?, - y_scale.data_to_plot(point[1])?, - ]) -} - pub(crate) fn plot_point_to_data( point: [f64; 2], x_scale: AxisScale, y_scale: AxisScale, ) -> Option<[f64; 2]> { - Some([ - x_scale.plot_to_data(point[0])?, - y_scale.plot_to_data(point[1])?, - ]) + crate::transform::plot_point_to_data(point, x_scale, y_scale) } diff --git a/src/camera.rs b/src/camera.rs index 21246b7..e88fd77 100644 --- a/src/camera.rs +++ b/src/camera.rs @@ -106,6 +106,24 @@ impl Camera { self.position - self.render_offset } + pub(crate) fn x_range(&self) -> [f64; 2] { + [ + self.position.x - self.half_extents.x, + self.position.x + self.half_extents.x, + ] + } + + pub(crate) fn y_range(&self) -> [f64; 2] { + [ + self.position.y - self.half_extents.y, + self.position.y + self.half_extents.y, + ] + } + + pub(crate) fn axis_ranges(&self) -> ([f64; 2], [f64; 2]) { + (self.x_range(), self.y_range()) + } + /// Convert screen coordinates to render coordinates (without offset) pub fn screen_to_render(&self, screen_pos: DVec2, screen_size: DVec2) -> DVec2 { let ndc_x = (screen_pos.x / screen_size.x) * 2.0 - 1.0; diff --git a/src/lib.rs b/src/lib.rs index 53f3955..37864f4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,6 +41,7 @@ pub(crate) mod reference_lines; pub(crate) mod series; pub(crate) mod style; pub(crate) mod ticks; +pub(crate) mod transform; // Iced re-exports. pub use iced::Color; @@ -63,3 +64,4 @@ pub use ticks::{ Tick, TickFormatter, TickProducer, default_formatter, default_tick_producer, log_formatter, log_tick_producer, }; +pub use transform::{CoordinateSystem, PositionTransform, Transform}; diff --git a/src/plot_renderer/canvas.rs b/src/plot_renderer/canvas.rs index 48a198b..d7bc95f 100644 --- a/src/plot_renderer/canvas.rs +++ b/src/plot_renderer/canvas.rs @@ -9,6 +9,7 @@ use crate::{ plot_state::PlotState, plot_widget::{world_to_screen_position_x, world_to_screen_position_y}, point::{MARKER_SIZE_WORLD, MarkerType}, + transform::{data_point_to_plot_with_transform, data_value_to_plot_with_axis_range}, }; use iced::{ Color, Rectangle, @@ -177,13 +178,17 @@ fn draw_lines(frame: &mut Frame, state: &PlotState, bounds: Rectangle) { fn draw_reference_lines(frame: &mut Frame, state: &PlotState, bounds: Rectangle) { for vline in state.vlines.iter() { - let Some(x) = world_to_screen_position_x( - state.x_axis_scale.data_to_plot(vline.x).unwrap_or_default(), - &state.camera, - &bounds, + let Some(vx_plot) = data_value_to_plot_with_axis_range( + vline.x, + state.x_axis_scale, + vline.transform.as_ref(), + Some(state.camera.x_range()), ) else { continue; }; + let Some(x) = world_to_screen_position_x(vx_plot, &state.camera, &bounds) else { + continue; + }; draw_styled_line_segment( frame, iced::Point::new(x, 0.0), @@ -200,13 +205,17 @@ fn draw_reference_lines(frame: &mut Frame, state: &PlotState, bounds: Rectangle) } for hline in state.hlines.iter() { - let Some(y) = world_to_screen_position_y( - state.y_axis_scale.data_to_plot(hline.y).unwrap_or_default(), - &state.camera, - &bounds, + let Some(hy_plot) = data_value_to_plot_with_axis_range( + hline.y, + state.y_axis_scale, + hline.transform.as_ref(), + Some(state.camera.y_range()), ) else { continue; }; + let Some(y) = world_to_screen_position_y(hy_plot, &state.camera, &bounds) else { + continue; + }; draw_styled_line_segment( frame, iced::Point::new(0.0, y), @@ -280,10 +289,26 @@ fn draw_highlights(frame: &mut Frame, state: &PlotState, bounds: Rectangle) { if marker_style.marker_type == MarkerType::Square && let Size::World(size) = marker_style.size { - let top_left = - world_to_canvas_point([highlight.x, highlight.y + size], &state.camera, &bounds); - let bottom_right = - world_to_canvas_point([highlight.x + size, highlight.y], &state.camera, &bounds); + let Some(top_left_plot) = data_point_to_plot_with_transform( + [highlight.x, highlight.y + size], + state.x_axis_scale, + state.y_axis_scale, + &highlight.transform, + Some(state.camera.axis_ranges()), + ) else { + continue; + }; + let Some(bottom_right_plot) = data_point_to_plot_with_transform( + [highlight.x + size, highlight.y], + state.x_axis_scale, + state.y_axis_scale, + &highlight.transform, + Some(state.camera.axis_ranges()), + ) else { + continue; + }; + let top_left = world_to_canvas_point(top_left_plot, &state.camera, &bounds); + let bottom_right = world_to_canvas_point(bottom_right_plot, &state.camera, &bounds); frame.fill_rectangle( iced::Point::new( top_left.x.min(bottom_right.x), diff --git a/src/plot_renderer/mod.rs b/src/plot_renderer/mod.rs index cc01398..f8fd573 100644 --- a/src/plot_renderer/mod.rs +++ b/src/plot_renderer/mod.rs @@ -4,8 +4,8 @@ mod shader; pub(crate) use shader::{PlotRenderer, RenderParams}; use crate::{ - axis_scale::data_point_to_plot, plot_state::PlotState, plot_widget::HighlightPoint, - series::Size, + plot_state::PlotState, plot_widget::HighlightPoint, series::Size, + transform::data_point_to_plot_with_transform, }; use iced::Color; @@ -72,10 +72,12 @@ fn highlight_marker_plot_position( highlight: &HighlightPoint, state: &PlotState, ) -> Option<[f64; 2]> { - data_point_to_plot( + data_point_to_plot_with_transform( [highlight.x, highlight.y], state.x_axis_scale, state.y_axis_scale, + &highlight.transform, + Some(state.camera.axis_ranges()), ) } @@ -89,5 +91,11 @@ fn highlight_mask_plot_position(highlight: &HighlightPoint, state: &PlotState) - world[1] += half; } - data_point_to_plot(world, state.x_axis_scale, state.y_axis_scale) + data_point_to_plot_with_transform( + world, + state.x_axis_scale, + state.y_axis_scale, + &highlight.transform, + Some(state.camera.axis_ranges()), + ) } diff --git a/src/plot_renderer/shader.rs b/src/plot_renderer/shader.rs index cd1d267..807e6d4 100644 --- a/src/plot_renderer/shader.rs +++ b/src/plot_renderer/shader.rs @@ -5,6 +5,7 @@ use super::{ }; use crate::LineStyle; use crate::picking::PickingPass; +use crate::transform::data_value_to_plot_with_axis_range; use crate::{LineType, Size, camera::CameraUniform, grid::Grid, plot_state::PlotState}; use iced::widget::shader::Viewport; use iced::{Rectangle, wgpu::*}; @@ -1035,7 +1036,12 @@ impl PlotRenderer { // Add vertical lines for vline in state.vlines.iter() { - let Some(vx_plot) = state.x_axis_scale.data_to_plot(vline.x) else { + let Some(vx_plot) = data_value_to_plot_with_axis_range( + vline.x, + state.x_axis_scale, + vline.transform.as_ref(), + Some(state.camera.x_range()), + ) else { continue; }; // Check if the stroked vline still overlaps the viewport. @@ -1071,7 +1077,12 @@ impl PlotRenderer { // Add horizontal lines for hline in state.hlines.iter() { - let Some(hy_plot) = state.y_axis_scale.data_to_plot(hline.y) else { + let Some(hy_plot) = data_value_to_plot_with_axis_range( + hline.y, + state.y_axis_scale, + hline.transform.as_ref(), + Some(state.camera.y_range()), + ) else { continue; }; // Check if the stroked hline still overlaps the viewport. diff --git a/src/plot_state.rs b/src/plot_state.rs index 98b870d..bb2e9c9 100644 --- a/src/plot_state.rs +++ b/src/plot_state.rs @@ -10,12 +10,13 @@ use iced::{ use crate::{ AxisLink, AxisScale, DragEvent, HLine, HoverPickEvent, LineStyle, PlotWidget, Point, ShapeId, Size, VLine, - axis_scale::{data_point_to_plot, plot_point_to_data}, + axis_scale::plot_point_to_data, camera::Camera, picking::PickingState, plot_widget::{HighlightPoint, world_to_screen_position_x, world_to_screen_position_y}, style::GridStyle, ticks::{PositionedTick, TickFormatter, TickProducer}, + transform::{data_point_to_plot_with_transform, data_value_to_plot_with_axis_range}, }; #[derive(Clone)] @@ -134,7 +135,7 @@ impl PlotState { pub(crate) fn sync_highlighted_points_from_widget(&mut self, widget: &PlotWidget) -> bool { let highlighted_points: Vec<_> = widget .visible_highlighted_points() - .map(|(highlight_point, _)| *highlight_point) + .map(|(highlight_point, _)| highlight_point.clone()) .collect(); if self.highlighted_points.as_ref() != highlighted_points.as_slice() { @@ -151,8 +152,11 @@ impl PlotState { let mut points = Vec::new(); let mut point_colors = Vec::new(); let mut series_spans = Vec::new(); - let mut data_min: Option = None; - let mut data_max: Option = None; + let mut data_min_x: Option = None; + let mut data_max_x: Option = None; + let mut data_min_y: Option = None; + let mut data_max_y: Option = None; + let axis_ranges = self.camera.axis_ranges(); // Process each series for (id, series) in &widget.series { @@ -167,17 +171,41 @@ impl PlotState { let start = points.len(); let mut point_indices = Vec::new(); + let x_uses_axes = series + .transform + .x + .as_ref() + .is_some_and(|transform| transform.uses_axes_coordinates()); + let y_uses_axes = series + .transform + .y + .as_ref() + .is_some_and(|transform| transform.uses_axes_coordinates()); // Add points and track bounds for (pos_index, &pos) in series.positions.iter().enumerate() { - let Some(transformed) = - data_point_to_plot(pos, widget.x_axis_scale, widget.y_axis_scale) - else { + let Some(transformed) = data_point_to_plot_with_transform( + pos, + widget.x_axis_scale, + widget.y_axis_scale, + &series.transform, + Some(axis_ranges), + ) else { continue; }; - let p = DVec2::new(transformed[0], transformed[1]); - data_min = Some(data_min.map_or(p, |m| m.min(p))); - data_max = Some(data_max.map_or(p, |m| m.max(p))); + + if !x_uses_axes { + data_min_x = + Some(data_min_x.map_or(transformed[0], |min| min.min(transformed[0]))); + data_max_x = + Some(data_max_x.map_or(transformed[0], |max| max.max(transformed[0]))); + } + if !y_uses_axes { + data_min_y = + Some(data_min_y.map_or(transformed[1], |min| min.min(transformed[1]))); + data_max_y = + Some(data_max_y.map_or(transformed[1], |max| max.max(transformed[1]))); + } // Only create points if we have markers OR lines (lines need points for geometry) if series.marker_style.is_some() || series.line_style.is_some() { @@ -224,17 +252,27 @@ impl PlotState { if let Some(size) = series.marker_style.as_ref().and_then(|m| match m.size { Size::World(size) => Some(size), Size::Pixels(_) => None, - }) && let Some(data_max) = &mut data_max - { - if widget.x_axis_scale == AxisScale::Linear { - data_max.x += size; + }) { + if !x_uses_axes + && widget.x_axis_scale == AxisScale::Linear + && let Some(max) = &mut data_max_x + { + *max += size; } - if widget.y_axis_scale == AxisScale::Linear { - data_max.y += size; + if !y_uses_axes + && widget.y_axis_scale == AxisScale::Linear + && let Some(max) = &mut data_max_y + { + *max += size; } } } + let data_min = (data_min_x.is_some() || data_min_y.is_some()) + .then(|| DVec2::new(data_min_x.unwrap_or(-1.0), data_min_y.unwrap_or(-1.0))); + let data_max = (data_max_x.is_some() || data_max_y.is_some()) + .then(|| DVec2::new(data_max_x.unwrap_or(1.0), data_max_y.unwrap_or(1.0))); + // Filter visible reference lines let vlines: Vec<_> = widget .vlines @@ -262,8 +300,16 @@ impl PlotState { && !widget.hidden_shapes.contains(&fill.end) }) .filter_map(|(_, fill)| { - build_fill_span(widget, fill.begin, fill.end, fill.color, x_domain, y_domain) - .filter(|span| !span.vertices.is_empty()) + build_fill_span( + widget, + fill.begin, + fill.end, + fill.color, + x_domain, + y_domain, + axis_ranges, + ) + .filter(|span| !span.vertices.is_empty()) }) .collect(); @@ -702,8 +748,8 @@ pub(crate) struct FillSpan { enum FillEndpoint<'a> { Series(&'a crate::Series), - HLine(f64), - VLine(f64), + HLine(&'a HLine), + VLine(&'a VLine), } fn resolve_fill_endpoint<'a>(widget: &'a PlotWidget, id: ShapeId) -> Option> { @@ -711,10 +757,10 @@ fn resolve_fill_endpoint<'a>(widget: &'a PlotWidget, id: ShapeId) -> Option Vec<[f64; 2]> { series .positions .iter() - .filter_map(|&p| data_point_to_plot(p, x_axis_scale, y_axis_scale)) + .filter_map(|&p| { + data_point_to_plot_with_transform( + p, + x_axis_scale, + y_axis_scale, + &series.transform, + Some(axis_ranges), + ) + }) .collect() } @@ -846,6 +901,7 @@ fn build_fill_span( color: Color, x_domain: Option<(f64, f64)>, y_domain: Option<(f64, f64)>, + axis_ranges: ([f64; 2], [f64; 2]), ) -> Option { let begin_endpoint = resolve_fill_endpoint(widget, begin)?; let end_endpoint = resolve_fill_endpoint(widget, end)?; @@ -858,11 +914,13 @@ fn build_fill_span( sa, widget.x_axis_scale, widget.y_axis_scale, + axis_ranges, )); let b = monotonic_increasing_x(transformed_series_points( sb, widget.x_axis_scale, widget.y_axis_scale, + axis_ranges, )); if a.len() < 2 || b.len() < 2 { return None; @@ -918,11 +976,20 @@ fn build_fill_span( } } } - (FillEndpoint::Series(series), FillEndpoint::HLine(y_data)) - | (FillEndpoint::HLine(y_data), FillEndpoint::Series(series)) => { - let y_plot = widget.y_axis_scale.data_to_plot(y_data)?; - let points = - transformed_series_points(series, widget.x_axis_scale, widget.y_axis_scale); + (FillEndpoint::Series(series), FillEndpoint::HLine(hline)) + | (FillEndpoint::HLine(hline), FillEndpoint::Series(series)) => { + let y_plot = data_value_to_plot_with_axis_range( + hline.y, + widget.y_axis_scale, + hline.transform.as_ref(), + Some(axis_ranges.1), + )?; + let points = transformed_series_points( + series, + widget.x_axis_scale, + widget.y_axis_scale, + axis_ranges, + ); for segment in points.windows(2) { let p0 = segment[0]; let p1 = segment[1]; @@ -931,11 +998,20 @@ fn build_fill_span( push_quad_as_triangles(&mut vertices, p0, q0, p1, q1); } } - (FillEndpoint::Series(series), FillEndpoint::VLine(x_data)) - | (FillEndpoint::VLine(x_data), FillEndpoint::Series(series)) => { - let x_plot = widget.x_axis_scale.data_to_plot(x_data)?; - let points = - transformed_series_points(series, widget.x_axis_scale, widget.y_axis_scale); + (FillEndpoint::Series(series), FillEndpoint::VLine(vline)) + | (FillEndpoint::VLine(vline), FillEndpoint::Series(series)) => { + let x_plot = data_value_to_plot_with_axis_range( + vline.x, + widget.x_axis_scale, + vline.transform.as_ref(), + Some(axis_ranges.0), + )?; + let points = transformed_series_points( + series, + widget.x_axis_scale, + widget.y_axis_scale, + axis_ranges, + ); for segment in points.windows(2) { let p0 = segment[0]; let p1 = segment[1]; @@ -944,16 +1020,36 @@ fn build_fill_span( push_quad_as_triangles(&mut vertices, p0, q0, p1, q1); } } - (FillEndpoint::HLine(y0_data), FillEndpoint::HLine(y1_data)) => { + (FillEndpoint::HLine(hline0), FillEndpoint::HLine(hline1)) => { let (x0, x1) = x_domain?; - let y0 = widget.y_axis_scale.data_to_plot(y0_data)?; - let y1 = widget.y_axis_scale.data_to_plot(y1_data)?; + let y0 = data_value_to_plot_with_axis_range( + hline0.y, + widget.y_axis_scale, + hline0.transform.as_ref(), + Some(axis_ranges.1), + )?; + let y1 = data_value_to_plot_with_axis_range( + hline1.y, + widget.y_axis_scale, + hline1.transform.as_ref(), + Some(axis_ranges.1), + )?; push_quad_as_triangles(&mut vertices, [x0, y0], [x0, y1], [x1, y0], [x1, y1]); } - (FillEndpoint::VLine(x0_data), FillEndpoint::VLine(x1_data)) => { + (FillEndpoint::VLine(vline0), FillEndpoint::VLine(vline1)) => { let (y0, y1) = y_domain?; - let x0 = widget.x_axis_scale.data_to_plot(x0_data)?; - let x1 = widget.x_axis_scale.data_to_plot(x1_data)?; + let x0 = data_value_to_plot_with_axis_range( + vline0.x, + widget.x_axis_scale, + vline0.transform.as_ref(), + Some(axis_ranges.0), + )?; + let x1 = data_value_to_plot_with_axis_range( + vline1.x, + widget.x_axis_scale, + vline1.transform.as_ref(), + Some(axis_ranges.0), + )?; push_quad_as_triangles(&mut vertices, [x0, y0], [x1, y0], [x0, y1], [x1, y1]); } _ => { @@ -998,3 +1094,29 @@ pub(crate) struct PanState { pub(crate) struct DragState { pub(crate) active: bool, } + +#[cfg(test)] +mod tests { + use glam::DVec2; + + use super::*; + use crate::Series; + + #[test] + fn axes_transform_series_maps_to_camera_range_and_skips_autoscale_bounds() { + let mut widget = PlotWidget::new(); + widget + .add_series(Series::circles(vec![[0.4, 0.6]], 5.0).with_axes_transform()) + .unwrap(); + + let mut state = PlotState::default(); + state.camera.position = DVec2::new(10.0, 20.0); + state.camera.half_extents = DVec2::new(5.0, 10.0); + + state.rebuild_from_widget(&widget); + + assert_eq!(state.points[0].position, [9.0, 22.0]); + assert_eq!(state.data_min, None); + assert_eq!(state.data_max, None); + } +} diff --git a/src/plot_widget.rs b/src/plot_widget.rs index eb8b0a9..654754a 100644 --- a/src/plot_widget.rs +++ b/src/plot_widget.rs @@ -1,3 +1,4 @@ +use core::fmt; use std::{ collections::{HashMap, HashSet}, sync::{ @@ -24,9 +25,9 @@ use indexmap::IndexMap; use crate::{ AxisScale, DragEvent, Fill, HLine, HoverPickEvent, MarkerStyle, PlotUiMessage, PointId, Series, - Size, TooltipContext, VLine, axes_labels, + Size, TooltipContext, Transform, VLine, axes_labels, axis_link::AxisLink, - axis_scale::{data_point_to_plot, plot_point_to_data}, + axis_scale::plot_point_to_data, camera::Camera, controls::PlotControls, default_style, @@ -38,6 +39,7 @@ use crate::{ series::{SeriesError, ShapeId}, style::{PlotStyle, StyleFn}, ticks::{self, PositionedTick, TickFormatter, TickProducer}, + transform::{PositionTransform, data_point_to_plot_with_transform}, }; const PLOT_CONTENT_PADDING: f32 = 2.0; @@ -325,9 +327,16 @@ impl PlotWidget { camera_bounds: &(Camera, Rectangle), x_axis_scale: AxisScale, y_axis_scale: AxisScale, + transform: &PositionTransform, ) -> Option<[f32; 2]> { - let world = data_point_to_plot(world, x_axis_scale, y_axis_scale)?; let (camera, bounds) = camera_bounds; + let world = data_point_to_plot_with_transform( + world, + x_axis_scale, + y_axis_scale, + transform, + Some(camera.axis_ranges()), + )?; if let (Some(screen_x), Some(screen_y)) = ( world_to_screen_position_x(world[0], camera, bounds), world_to_screen_position_y(world[1], camera, bounds), @@ -369,6 +378,7 @@ impl PlotWidget { camera_bounds, self.x_axis_scale, self.y_axis_scale, + &highlight_point.transform, ); } } @@ -496,6 +506,7 @@ impl PlotWidget { let mut highlight_point = HighlightPoint { x: position[0], y: position[1], + transform: series.transform.clone(), color: series .point_colors .as_ref() @@ -518,6 +529,7 @@ impl PlotWidget { camera_bounds, self.x_axis_scale, self.y_axis_scale, + &series.transform, ), text, }); @@ -1054,6 +1066,35 @@ impl PlotWidget { || self.vlines.contains_key(&id) || self.hlines.contains_key(&id) } + + fn shape_uses_axes_transform(&self, id: ShapeId) -> bool { + self.series + .get(&id) + .map(|series| &series.transform) + .is_some_and(PositionTransform::uses_axes_coordinates) + || self + .vlines + .get(&id) + .and_then(|vline| vline.transform.as_ref()) + .is_some_and(|transform| transform.uses_axes_coordinates()) + || self + .hlines + .get(&id) + .and_then(|hline| hline.transform.as_ref()) + .is_some_and(|transform| transform.uses_axes_coordinates()) + } + + fn has_visible_dynamic_geometry_transforms(&self) -> bool { + self.series.iter().any(|(id, series)| { + !self.hidden_shapes.contains(id) && series.transform.uses_axes_coordinates() + }) || self.fills.iter().any(|(id, fill)| { + !self.hidden_shapes.contains(id) + && !self.hidden_shapes.contains(&fill.begin) + && !self.hidden_shapes.contains(&fill.end) + && (self.shape_uses_axes_transform(fill.begin) + || self.shape_uses_axes_transform(fill.end)) + }) + } } #[doc(hidden)] @@ -1451,6 +1492,14 @@ fn update_plot_program( invalidation.all(); } + if (state.camera != prev_camera || state.bounds != prev_bounds) + && widget.has_visible_dynamic_geometry_transforms() + { + state.rebuild_from_widget(widget); + effects.needs_redraw = true; + invalidation.all(); + } + // Hover/pick highlight mask boxes are baked in clip space. Rebuild them through the // existing highlight_version path whenever camera or viewport bounds change. if !state.highlighted_points.is_empty() @@ -1722,16 +1771,19 @@ static NEXT_ID: AtomicU64 = AtomicU64::new(1); /// /// + `x` and `y` to change the position of the highlight point (not recommended); /// + `color` to change the color of the highlight point; +/// + `transform` to change how the highlight x/y values are interpreted before drawing; /// + `marker_style` to change the marker style of the highlight point; /// + `mask_padding` to change the mask padding of the highlight point; /// /// to change the highlight point. -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub struct HighlightPoint { - /// Data-space coordinates + /// Raw x value for the highlight point. pub x: f64, - /// Data-space coordinates + /// Raw y value for the highlight point. pub y: f64, + /// How to interpret or convert the x/y values before drawing. + pub transform: PositionTransform, pub color: Color, /// Optional marker style for the series. If None, no markers are drawn. pub marker_style: Option, @@ -1739,6 +1791,22 @@ pub struct HighlightPoint { pub mask_padding: Option, } +pub struct PositionDisplay<'a> { + pos: f64, + transform: &'a Option, +} +impl fmt::Display for PositionDisplay<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(transform) = self.transform + && transform.uses_axes_coordinates() + { + write!(f, "{:.1}%", self.pos * 100.0) + } else { + write!(f, "{:.2}", self.pos) + } + } +} + impl HighlightPoint { /// Resize the marker of the highlight point. /// For both pixel-based and world-based markers, the size will be multiplied by the factor. @@ -1754,6 +1822,18 @@ impl HighlightPoint { } } } + pub fn display_x(&self) -> PositionDisplay<'_> { + PositionDisplay { + pos: self.x, + transform: &self.transform.x, + } + } + pub fn display_y(&self) -> PositionDisplay<'_> { + PositionDisplay { + pos: self.y, + transform: &self.transform.y, + } + } } /// Convert world position to screen position diff --git a/src/plot_widget_builder.rs b/src/plot_widget_builder.rs index 33d9f9e..7f58acc 100644 --- a/src/plot_widget_builder.rs +++ b/src/plot_widget_builder.rs @@ -346,11 +346,17 @@ impl PlotWidgetBuilder { point: &mut HighlightPoint, ) -> Option { if ctx.series_label.is_empty() { - Some(format!("x: {:.2}, y: {:.2}", point.x, point.y)) + Some(format!( + "x: {}, y: {}", + point.display_x(), + point.display_y() + )) } else { Some(format!( - "{}\nx: {:.2}, y: {:.2}", - ctx.series_label, point.x, point.y + "{}\nx: {}, y: {}", + ctx.series_label, + point.display_x(), + point.display_y() )) } } diff --git a/src/reference_lines.rs b/src/reference_lines.rs index a39f8fe..591b542 100644 --- a/src/reference_lines.rs +++ b/src/reference_lines.rs @@ -1,4 +1,4 @@ -use crate::{Color, LineStyle, LineType, Size, series::ShapeId}; +use crate::{Color, LineStyle, LineType, Size, series::ShapeId, transform::Transform}; /// A vertical line at a fixed x-coordinate. #[derive(Debug, Clone)] @@ -7,6 +7,8 @@ pub struct VLine { pub id: ShapeId, /// The x-coordinate where the vertical line is drawn. pub x: f64, + /// How to interpret or convert the x value before drawing. + pub transform: Option, /// Optional label for the line (appears in legend if provided). pub label: Option, /// Color of the line. @@ -21,6 +23,7 @@ impl VLine { Self { id: ShapeId::new(), x, + transform: None, label: None, color: Color::from_rgb(0.5, 0.5, 0.5), line_style: LineStyle::default(), @@ -42,6 +45,21 @@ impl VLine { self } + /// Set how this reference line interprets or converts its x value before drawing. + /// + /// For normal data values, conversion runs before the plot's x-axis scale. + /// `Transform::axes()` uses normalized plot positions instead. + pub fn with_transform(mut self, transform: Transform) -> Self { + self.transform = Some(transform); + self + } + + /// Interpret the x position as a normalized plot coordinate. + pub fn with_axes_transform(mut self) -> Self { + self.transform = Some(Transform::axes()); + self + } + /// Set the line width in pixels. pub fn with_width(mut self, width: f32) -> Self { self.line_style.width = Size::Pixels(width.max(0.5)); @@ -79,6 +97,8 @@ pub struct HLine { pub id: ShapeId, /// The y-coordinate where the horizontal line is drawn. pub y: f64, + /// How to interpret or convert the y value before drawing. + pub transform: Option, /// Optional label for the line (appears in legend if provided). pub label: Option, /// Color of the line. @@ -93,6 +113,7 @@ impl HLine { Self { id: ShapeId::new(), y, + transform: None, label: None, color: Color::from_rgb(0.5, 0.5, 0.5), line_style: LineStyle::default(), @@ -114,6 +135,21 @@ impl HLine { self } + /// Set how this reference line interprets or converts its y value before drawing. + /// + /// For normal data values, conversion runs before the plot's y-axis scale. + /// `Transform::axes()` uses normalized plot positions instead. + pub fn with_transform(mut self, transform: Transform) -> Self { + self.transform = Some(transform); + self + } + + /// Interpret the y position as a normalized plot coordinate. + pub fn with_axes_transform(mut self) -> Self { + self.transform = Some(Transform::axes()); + self + } + /// Set the line width in pixels. pub fn with_width(mut self, width: f32) -> Self { self.line_style.width = Size::Pixels(width.max(0.5)); diff --git a/src/series.rs b/src/series.rs index 89eda52..cf67694 100644 --- a/src/series.rs +++ b/src/series.rs @@ -2,7 +2,12 @@ use core::fmt; use iced::Rectangle; -use crate::{Color, camera::Camera, point::MarkerType}; +use crate::{ + Color, + camera::Camera, + point::MarkerType, + transform::{PositionTransform, Transform}, +}; /// Line styling options for series connections. /// @@ -288,6 +293,9 @@ pub struct Series { /// Series point positions. pub positions: Vec<[f64; 2]>, + /// How this series interprets or converts point positions before drawing. + pub transform: PositionTransform, + /// Optional per-point colors. Must match the length of `positions` if set. pub point_colors: Option>, @@ -315,6 +323,7 @@ impl Series { Self { id: ShapeId::new(), positions, + transform: PositionTransform::default(), point_colors: None, label: None, color: Color::from_rgb(0.3, 0.3, 0.9), @@ -329,6 +338,7 @@ impl Series { Self { id: ShapeId::new(), positions, + transform: PositionTransform::default(), point_colors: None, label: None, color: Color::from_rgb(0.3, 0.3, 0.9), @@ -343,6 +353,7 @@ impl Series { Self { id: ShapeId::new(), positions, + transform: PositionTransform::default(), point_colors: None, label: None, color: Color::from_rgb(0.3, 0.3, 0.9), @@ -399,6 +410,54 @@ impl Series { self } + /// Set how this series interprets or converts x/y values before drawing. + /// + /// For normal data values, conversion runs before the plot's axis scale. + /// `Transform::axes()` uses normalized plot positions instead. + pub fn with_transform(mut self, transform: PositionTransform) -> Self { + self.transform = transform; + self + } + + /// Set how this series interprets or converts x values before drawing. + /// + /// For normal data values, conversion runs before the plot's x-axis scale. + /// `Transform::axes()` uses normalized plot positions instead. + pub fn with_transform_x(mut self, transform: Transform) -> Self { + self.transform.x = Some(transform); + self + } + + /// Set how this series interprets or converts y values before drawing. + /// + /// For normal data values, conversion runs before the plot's y-axis scale. + /// `Transform::axes()` uses normalized plot positions instead. + pub fn with_transform_y(mut self, transform: Transform) -> Self { + self.transform.y = Some(transform); + self + } + + /// Interpret all positions as normalized plot coordinates. + /// + /// `(0.0, 0.0)` is the lower-left of the plot area and `(1.0, 1.0)` is + /// the upper-right. + pub fn with_axes_transform(mut self) -> Self { + self.transform = PositionTransform::axes(); + self + } + + /// Set how this series interprets or converts x values before drawing. + pub fn with_x_transform(mut self, transform: Transform) -> Self { + self.transform.x = Some(transform); + self + } + + /// Set how this series interprets or converts y values before drawing. + pub fn with_y_transform(mut self, transform: Transform) -> Self { + self.transform.y = Some(transform); + self + } + /// Enable or disable interactive hover/pick behavior for this series. pub fn with_pickable(mut self, pickable: bool) -> Self { self.pickable = pickable; diff --git a/src/transform.rs b/src/transform.rs new file mode 100644 index 0000000..633b482 --- /dev/null +++ b/src/transform.rs @@ -0,0 +1,393 @@ +//! Coordinate transforms for series and reference lines. +//! +//! A [`Transform`] describes how one x or y value should be interpreted before +//! it is drawn. Most values use data coordinates: the raw value is optionally +//! converted, then the plot axis scale is applied. `Transform::axes()` instead +//! uses normalized plot coordinates, where `0.0` is the low edge of the plot and +//! `1.0` is the high edge. + +use crate::axis_scale::AxisScale; + +/// The source coordinate system consumed by a [`Transform`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CoordinateSystem { + /// Data coordinates: convert the raw value, then apply the axis scale. + #[default] + Data, + /// Axes coordinates, where `0.0` is the low edge of the plot and `1.0` is the high edge. + Axes, +} + +#[derive(Debug, Clone, PartialEq)] +enum TransformOperation { + Identity, + Affine { scale: f64, translate: f64 }, + Log { base: f64 }, + Exp { base: f64 }, + Then(Box, Box), +} + +impl Default for TransformOperation { + fn default() -> Self { + Self::Identity + } +} + +impl TransformOperation { + fn then(self, next: TransformOperation) -> Self { + match (self, next) { + (Self::Identity, next) => next, + (this, Self::Identity) => this, + (this, next) => Self::Then(Box::new(this), Box::new(next)), + } + } + + fn transform_value(&self, value: f64) -> Option { + match self { + Self::Identity => value.is_finite().then_some(value), + Self::Affine { scale, translate } => { + if !(value.is_finite() && scale.is_finite() && translate.is_finite()) { + return None; + } + let out = value.mul_add(*scale, *translate); + out.is_finite().then_some(out) + } + Self::Log { base } => { + if !(value.is_finite() && value > 0.0 && valid_log_base(*base)) { + return None; + } + let out = value.log(*base); + out.is_finite().then_some(out) + } + Self::Exp { base } => { + if !(value.is_finite() && valid_log_base(*base)) { + return None; + } + let out = base.powf(value); + (out.is_finite() && out > 0.0).then_some(out) + } + Self::Then(first, second) => second.transform_value(first.transform_value(value)?), + } + } + + fn inverted(&self) -> Option { + match self { + Self::Identity => Some(Self::Identity), + Self::Affine { scale, translate } => { + if !scale.is_finite() || scale.abs() <= f64::EPSILON || !translate.is_finite() { + return None; + } + Some(Self::Affine { + scale: 1.0 / scale, + translate: -translate / scale, + }) + } + Self::Log { base } => Some(Self::Exp { base: *base }), + Self::Exp { base } => Some(Self::Log { base: *base }), + Self::Then(first, second) => Some(second.inverted()?.then(first.inverted()?)), + } + } +} + +/// A one-dimensional coordinate transform for a series or reference line. +/// +/// For normal data coordinates, the transform is a value converter that runs +/// before the plot's axis scale. For example, `Transform::affine(2.0, -1.0)` +/// draws `raw * 2 - 1`, then lets the x/y axis scale map that value into plot +/// space. +/// +/// `Transform::axes()` is different: it treats the input as a normalized +/// position inside the plot area. `0.4` means 40% from the low edge, so the +/// position stays fixed while the user pans or zooms. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Transform { + coordinate_system: CoordinateSystem, + operation: TransformOperation, +} + +impl From for Transform { + fn from(scale: AxisScale) -> Self { + match scale { + AxisScale::Linear => Self::identity(), + AxisScale::Log { base } => Self::log(base), + } + } +} + +impl Transform { + /// Leave a data value unchanged before the axis scale is applied. + pub const fn identity() -> Self { + Self { + coordinate_system: CoordinateSystem::Data, + operation: TransformOperation::Identity, + } + } + + /// Interpret values as normalized positions inside the plot area. + /// + /// `0.0` is the low edge of the axis and `1.0` is the high edge. A point + /// using `PositionTransform::axes()` therefore remains fixed in the plot + /// area during pan and zoom. + pub const fn axes() -> Self { + Self { + coordinate_system: CoordinateSystem::Axes, + operation: TransformOperation::Identity, + } + } + + /// Convert a data value with `value * scale + translate` before axis scaling. + pub const fn affine(scale: f64, translate: f64) -> Self { + Self { + coordinate_system: CoordinateSystem::Data, + operation: TransformOperation::Affine { scale, translate }, + } + } + + /// Convert a data value with `log_base(value)` before axis scaling. + pub const fn log(base: f64) -> Self { + Self { + coordinate_system: CoordinateSystem::Data, + operation: TransformOperation::Log { base }, + } + } + + /// Convert a data value with `base.powf(value)` before axis scaling. + pub const fn exp(base: f64) -> Self { + Self { + coordinate_system: CoordinateSystem::Data, + operation: TransformOperation::Exp { base }, + } + } + + /// Return the transform's source coordinate system. + pub fn coordinate_system(&self) -> CoordinateSystem { + self.coordinate_system + } + + /// Run this value conversion, then run another one. + /// + /// The returned transform applies `self` first, then `next`. The source + /// coordinate system of `self` is retained. + pub fn then(self, next: Transform) -> Self { + Self { + coordinate_system: self.coordinate_system, + operation: self.operation.then(next.operation), + } + } + + /// Convert a raw value using only this transform's operation. + pub fn transform_value(&self, value: f64) -> Option { + self.operation.transform_value(value) + } + + /// Create the inverse operation, if it is representable. + pub fn inverted(&self) -> Option { + Some(Self { + coordinate_system: self.coordinate_system, + operation: self.operation.inverted()?, + }) + } + + /// Convert a data-coordinate value into plot-space. + /// + /// This applies the transform first, then the axis scale. `Transform::axes()` + /// needs the current axis range, which is only available internally during + /// rendering. + pub fn transform_data(&self, pos: f64, axis_scale: AxisScale) -> Option { + data_value_to_plot(pos, axis_scale, Some(self)) + } + + /// Convert a value into a normalized `[0, 1]` position along an axis. + /// + /// Data-coordinate transforms use `axis_range` as raw data coordinates. + /// Axes-coordinate transforms already store normalized positions. + pub fn transform_position( + &self, + pos: f64, + axis_scale: AxisScale, + axis_range: [f64; 2], + ) -> Option { + if self.coordinate_system == CoordinateSystem::Axes { + return self.transform_value(pos); + } + + let pos = self.transform_data(pos, axis_scale)?; + let min = self.transform_data(axis_range[0], axis_scale)?; + let max = self.transform_data(axis_range[1], axis_scale)?; + let span = max - min; + (span.is_finite() && span.abs() > f64::EPSILON).then_some((pos - min) / span) + } + + pub(crate) fn uses_axes_coordinates(&self) -> bool { + self.coordinate_system == CoordinateSystem::Axes + } +} + +/// Separate x and y coordinate transforms for a two-dimensional point. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct PositionTransform { + /// How to interpret or convert x values. `None` means normal data coordinates. + pub x: Option, + /// How to interpret or convert y values. `None` means normal data coordinates. + pub y: Option, +} + +impl PositionTransform { + /// Create a new x/y transform pair. + pub fn new(x: Option, y: Option) -> Self { + Self { x, y } + } + + /// Use normal data coordinates for both axes. + pub const fn identity() -> Self { + Self { x: None, y: None } + } + + /// Interpret both axes as normalized plot positions. + pub const fn axes() -> Self { + Self { + x: Some(Transform::axes()), + y: Some(Transform::axes()), + } + } + + /// Convert a raw point into plot-space coordinates. + pub fn transform_point( + &self, + point: [f64; 2], + x_axis_scale: AxisScale, + y_axis_scale: AxisScale, + ) -> Option<[f64; 2]> { + data_point_to_plot_with_transform(point, x_axis_scale, y_axis_scale, self, None) + } + + pub(crate) fn uses_axes_coordinates(&self) -> bool { + self.x + .as_ref() + .is_some_and(Transform::uses_axes_coordinates) + || self + .y + .as_ref() + .is_some_and(Transform::uses_axes_coordinates) + } +} + +pub(crate) fn data_value_to_plot( + value: f64, + axis_scale: AxisScale, + transform: Option<&Transform>, +) -> Option { + data_value_to_plot_with_axis_range(value, axis_scale, transform, None) +} + +pub(crate) fn data_value_to_plot_with_axis_range( + value: f64, + axis_scale: AxisScale, + transform: Option<&Transform>, + axis_range: Option<[f64; 2]>, +) -> Option { + let Some(transform) = transform else { + return Transform::from(axis_scale).transform_value(value); + }; + + let value = transform.transform_value(value)?; + match transform.coordinate_system { + CoordinateSystem::Data => Transform::from(axis_scale).transform_value(value), + CoordinateSystem::Axes => { + let [min, max] = axis_range?; + if !(min.is_finite() && max.is_finite()) { + return None; + } + Some(min + value * (max - min)) + } + } +} + +pub(crate) fn plot_value_to_data(value: f64, axis_scale: AxisScale) -> Option { + Transform::from(axis_scale) + .inverted()? + .transform_value(value) +} + +pub(crate) fn data_point_to_plot_with_transform( + point: [f64; 2], + x_scale: AxisScale, + y_scale: AxisScale, + transform: &PositionTransform, + axis_ranges: Option<([f64; 2], [f64; 2])>, +) -> Option<[f64; 2]> { + Some([ + data_value_to_plot_with_axis_range( + point[0], + x_scale, + transform.x.as_ref(), + axis_ranges.map(|ranges| ranges.0), + )?, + data_value_to_plot_with_axis_range( + point[1], + y_scale, + transform.y.as_ref(), + axis_ranges.map(|ranges| ranges.1), + )?, + ]) +} + +pub(crate) fn plot_point_to_data( + point: [f64; 2], + x_scale: AxisScale, + y_scale: AxisScale, +) -> Option<[f64; 2]> { + Some([ + plot_value_to_data(point[0], x_scale)?, + plot_value_to_data(point[1], y_scale)?, + ]) +} + +fn valid_log_base(base: f64) -> bool { + base.is_finite() && base > 0.0 && (base - 1.0).abs() > f64::EPSILON +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn affine_transform_normalizes_axis_position() { + let transform = Transform::affine(2.0, 10.0); + assert_eq!( + transform.transform_position(5.0, AxisScale::Linear, [0.0, 10.0]), + Some(0.5) + ); + } + + #[test] + fn axis_scale_is_applied_after_data_transform() { + let transform = Transform::affine(10.0, 0.0); + assert_eq!( + transform.transform_data(10.0, AxisScale::Log { base: 10.0 }), + Some(2.0) + ); + } + + #[test] + fn composite_transform_inverts_in_reverse_order() { + let transform = Transform::affine(2.0, 10.0).then(Transform::log(10.0)); + let inverted = transform + .inverted() + .expect("transform should be invertible"); + let value = transform.transform_value(45.0).unwrap(); + let round_trip = inverted.transform_value(value).unwrap(); + assert!((round_trip - 45.0).abs() < 1e-12); + } + + #[test] + fn axes_transform_maps_normalized_value_into_axis_range() { + let value = data_value_to_plot_with_axis_range( + 0.4, + AxisScale::Log { base: 10.0 }, + Some(&Transform::axes()), + Some([10.0, 20.0]), + ); + assert_eq!(value, Some(14.0)); + } +}