diff --git a/Cargo.lock b/Cargo.lock index 2ac941d..fd5d563 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4173,6 +4173,7 @@ version = "0.1.0" dependencies = [ "egui", "plotx-figure", + "unicode-width", "windows-sys 0.61.2", ] diff --git a/Cargo.toml b/Cargo.toml index 8f0644d..f266edc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ semver = "1" sha2 = "0.10" ureq = { version = "2", default-features = false, features = ["tls"] } thiserror = "2.0" +unicode-width = "0.2" statrs = { version = "0.18", default-features = false } tukey_test = "0.2" directories = "6" diff --git a/crates/app/src/ui/canvas/geometry.rs b/crates/app/src/ui/canvas/geometry.rs index 194611c..e096638 100644 --- a/crates/app/src/ui/canvas/geometry.rs +++ b/crates/app/src/ui/canvas/geometry.rs @@ -221,12 +221,11 @@ pub(crate) fn plot_under_cursor( let Some(plot_object) = canvas.object(id).and_then(|object| object.plot()) else { continue; }; - let plot = plotx_render::Projector::new( - &plot_object.figure, - outer, - &plotx_render::Margins::for_figure(&plot_object.figure).scaled(zoom), - ) - .plot; + let layout = + plotx_render::axis_layout(&plot_object.figure, outer.width / zoom, outer.height / zoom); + let plot = + plotx_render::Projector::new(&plot_object.figure, outer, &layout.margins.scaled(zoom)) + .plot; return Some((id, outer_rect, plot)); } None @@ -241,13 +240,11 @@ pub(crate) fn plot_inner_rect( let canvas = app.doc.canvases.get(ci)?; let outer = object_screen_rect(app.session.board, canvas, object_id, screen)?; let plot_object = canvas.object(object_id).and_then(|object| object.plot())?; + let zoom = app.session.board.zoom; + let layout = + plotx_render::axis_layout(&plot_object.figure, outer.width / zoom, outer.height / zoom); Some( - plotx_render::Projector::new( - &plot_object.figure, - outer, - &plotx_render::Margins::for_figure(&plot_object.figure).scaled(app.session.board.zoom), - ) - .plot, + plotx_render::Projector::new(&plot_object.figure, outer, &layout.margins.scaled(zoom)).plot, ) } diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index c49f4b5..ed108c7 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -216,11 +216,9 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { .and_then(|object| object.plot()) .unwrap() .figure; - let proj = plotx_render::Projector::new( - fig, - outer, - &plotx_render::Margins::for_figure(fig).scaled(app.session.board.zoom), - ); + let zoom = app.session.board.zoom; + let layout = plotx_render::axis_layout(fig, outer.width / zoom, outer.height / zoom); + let proj = plotx_render::Projector::new(fig, outer, &layout.margins.scaled(zoom)); proj.plot }; diff --git a/crates/app/src/ui/canvas/phase.rs b/crates/app/src/ui/canvas/phase.rs index c7f73c8..40f779f 100644 --- a/crates/app/src/ui/canvas/phase.rs +++ b/crates/app/src/ui/canvas/phase.rs @@ -70,12 +70,9 @@ pub(crate) fn handle_phase_before_paint( else { return; }; - let plot = plotx_render::Projector::new( - figure, - outer, - &plotx_render::Margins::for_figure(figure).scaled(app.session.board.zoom), - ) - .plot; + let zoom = app.session.board.zoom; + let layout = plotx_render::axis_layout(figure, outer.width / zoom, outer.height / zoom); + let plot = plotx_render::Projector::new(figure, outer, &layout.margins.scaled(zoom)).plot; let axis = app.doc.datasets[di].active_phase_axis(app.session.ui.phase_axis); let Some(pivot_ppm) = displayed_phase_pivot_ppm(app, di, axis) else { return; diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 0a6a712..618b54f 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -19,6 +19,7 @@ emf = ["dep:windows-sys"] [dependencies] plotx-figure.workspace = true +unicode-width.workspace = true egui = { workspace = true, optional = true } [target.'cfg(windows)'.dependencies] diff --git a/crates/render/src/emf.rs b/crates/render/src/emf.rs index 8cc71c0..5f7feba 100644 --- a/crates/render/src/emf.rs +++ b/crates/render/src/emf.rs @@ -3,9 +3,9 @@ //! traversal one function per function. use crate::{ - AXIS_LINE_WIDTH, Document, DocumentItem, DocumentObject, DocumentOverlay, LegendMark, Margins, + AXIS_LINE_WIDTH, Document, DocumentItem, DocumentObject, DocumentOverlay, LegendMark, OUTER_PAD, OverlayAlign, OverlayKind, OverlayShapeKind, Projector, Rect, TICK_LABEL_PAD, - TICK_LENGTH, arrow_head, axis_ticks_for, error_bar_segments, heatmap_cells, integral, + TICK_LENGTH, arrow_head, axis_layout, error_bar_segments, heatmap_cells, integral, legend_entries, polygon_outline, projection_points, }; use plotx_figure::{AxisFrame, AxisTrace, Color, Figure, SeriesKind}; @@ -186,7 +186,8 @@ fn write_overlay(dc: &mut Dc, overlay: &DocumentOverlay<'_>) { fn write_figure(dc: &mut Dc, fig: &Figure, outer: Rect) { let ty = fig.typography; - let margins = Margins::for_figure(fig); + let layout = axis_layout(fig, outer.width, outer.height); + let margins = layout.margins; let proj = Projector::new(fig, outer, &margins); let plot = proj.plot; @@ -204,17 +205,7 @@ fn write_figure(dc: &mut Dc, fig: &Figure, outer: Rect) { } let hidden_frame = fig.axis_frame == AxisFrame::Hidden; - // Empty tick sets make every tick/label loop below a no-op for a hidden frame. - let empty_ticks = crate::AxisTicks { - values: Vec::new(), - labels: Vec::new(), - scale_exponent: None, - }; - let (x_ticks, y_ticks) = if hidden_frame { - (empty_ticks.clone(), empty_ticks) - } else { - (axis_ticks_for(&fig.x, 8), axis_ticks_for(&fig.y, 5)) - }; + let (x_ticks, y_ticks) = (layout.x_ticks, layout.y_ticks); if fig.show_grid && !hidden_frame { for &xt in &x_ticks.values { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a52d61b..4417c6e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -5,6 +5,9 @@ pub mod contour; pub mod integral; pub mod svg; +mod ticks; + +pub use ticks::{AxisLayout, AxisTicks, axis_layout, axis_ticks, axis_ticks_for, ticks}; #[cfg(feature = "screen")] pub mod screen; @@ -12,7 +15,7 @@ pub mod screen; #[cfg(all(windows, feature = "emf"))] pub mod emf; -use plotx_figure::{Axis, AxisFrame, AxisTrace, Color, ErrorBar, Figure, HeatmapGrid, Polygon}; +use plotx_figure::{AxisTrace, Color, ErrorBar, Figure, HeatmapGrid, Polygon}; /// Fraction of the plot dimension reserved for a marginal axis-projection band. /// A fraction (not an absolute size) so bands scale with zoom like the margins. @@ -153,6 +156,7 @@ impl Rect { } } +#[derive(Debug, Clone, PartialEq)] pub struct Margins { pub left: f32, pub right: f32, @@ -172,61 +176,11 @@ impl Default for Margins { } impl Margins { - /// Publication-sized margins derived from the labels that will actually be - /// drawn. This keeps the y title a stable distance from the widest tick - /// label instead of retaining whitespace sized for a larger preview font. + /// Publication-sized margins for the figure's intrinsic width and height. + /// Renderers targeting a different rectangle should use [`axis_layout`] + /// so margins and adaptive tick sets are computed for the same dimensions. pub fn for_figure(fig: &Figure) -> Self { - let ty = fig.typography; - if fig.axis_frame == AxisFrame::Hidden { - // No ticks or axis titles to clear — only the outer pad and title. - let title_clearance = if fig.title.trim().is_empty() { - 0.0 - } else { - ty.title_pt + AXIS_LABEL_GAP - }; - return Self { - left: OUTER_PAD, - right: OUTER_PAD, - top: OUTER_PAD + title_clearance, - bottom: OUTER_PAD, - }; - } - let x_ticks = axis_ticks_for(&fig.x, 8); - let y_ticks = axis_ticks_for(&fig.y, 5); - let widest_y_tick = y_ticks - .labels - .iter() - .map(|label| estimated_text_width(label, ty.tick_pt)) - .fold(0.0, f32::max); - let widest_x_tick = x_ticks - .labels - .iter() - .map(|label| estimated_text_width(label, ty.tick_pt)) - .fold(0.0, f32::max); - - let left = - OUTER_PAD + ty.label_pt + AXIS_LABEL_GAP + widest_y_tick + TICK_LENGTH + TICK_LABEL_PAD; - let right = (OUTER_PAD + widest_x_tick * 0.5).max(8.0); - let title_clearance = if fig.title.trim().is_empty() { - 0.0 - } else { - ty.title_pt + AXIS_LABEL_GAP - }; - let top = OUTER_PAD + title_clearance + y_ticks.multiplier_clearance(ty.tick_pt); - let bottom = OUTER_PAD - + x_ticks.multiplier_clearance(ty.tick_pt) - + ty.label_pt - + AXIS_LABEL_GAP - + ty.tick_pt - + TICK_LABEL_PAD - + TICK_LENGTH; - - Self { - left, - right, - top, - bottom, - } + axis_layout(fig, fig.width, fig.height).margins } pub fn scaled(&self, s: f32) -> Margins { @@ -470,173 +424,5 @@ fn axis_intensity_bounds(axis: &plotx_figure::Axis, trace: &AxisTrace) -> (f64, if lo.is_finite() { (lo, hi) } else { (0.0, 1.0) } } -/// Up to `target` "nice" tick values covering `[min, max]`, using 1/2/5×10ⁿ -/// rounding so ticks land on human-friendly numbers. -pub fn ticks(min: f64, max: f64, target: usize) -> Vec { - let (lo, hi) = if min <= max { (min, max) } else { (max, min) }; - let span = hi - lo; - if !span.is_finite() || span <= 0.0 || target == 0 { - return vec![lo]; - } - let raw_step = span / target as f64; - let mag = 10f64.powf(raw_step.log10().floor()); - let norm = raw_step / mag; - let nice = if norm < 1.5 { - 1.0 - } else if norm < 3.0 { - 2.0 - } else if norm < 7.0 { - 5.0 - } else { - 10.0 - }; - let step = nice * mag; - - let eps = step * 1e-9; - let first = ((lo - eps) / step).ceil() * step; - let mut out = Vec::new(); - let mut v = first; - let mut guard = 0; - while v <= hi + eps && guard < 1000 { - out.push(if v.abs() < step * 1e-9 { 0.0 } else { v }); // snap fp crud to 0 - v += step; - guard += 1; - } - out -} - -/// Tick positions plus labels formatted as one axis-wide system. Decimal -/// precision follows the tick interval, and very large or small values share a -/// single power-of-ten multiplier instead of repeating scientific notation. -#[derive(Debug, Clone, PartialEq)] -pub struct AxisTicks { - pub values: Vec, - pub labels: Vec, - pub scale_exponent: Option, -} - -impl AxisTicks { - pub fn multiplier(&self) -> Option { - self.scale_exponent - .map(|exponent| format!("×10{}", superscript(exponent))) - } - - /// Margin height reserved for the multiplier's own text row, set in the - /// figure's tick font size. - pub fn multiplier_clearance(&self, tick_pt: f32) -> f32 { - if self.scale_exponent.is_some() { - tick_pt + AXIS_LABEL_GAP - } else { - 0.0 - } - } -} - -fn estimated_text_width(text: &str, font_size: f32) -> f32 { - text.chars() - .map(|ch| match ch { - '0'..='9' => 0.56, - '.' | ',' => 0.28, - '-' | '−' => 0.36, - _ => 0.58, - }) - .sum::() - * font_size -} - -/// Ticks for an axis honoring its ordinal mode: categorical axes label integer -/// slot positions with their category names (thinned to stay legible), numeric -/// axes fall through to [`axis_ticks`]. -pub fn axis_ticks_for(axis: &Axis, target: usize) -> AxisTicks { - let Some(names) = &axis.categories else { - return axis_ticks(axis.min, axis.max, target); - }; - let (lo, hi) = (axis.min.min(axis.max), axis.min.max(axis.max)); - let visible: Vec<(f64, &str)> = names - .iter() - .enumerate() - .map(|(i, name)| (i as f64, name.as_str())) - .filter(|(v, _)| *v >= lo - 1e-9 && *v <= hi + 1e-9) - .collect(); - // Label every k-th slot when there are more categories than tick budget. - let stride = visible.len().div_ceil(target.max(1)).max(1); - let mut values = Vec::new(); - let mut labels = Vec::new(); - for (v, name) in visible.iter().step_by(stride) { - values.push(*v); - labels.push((*name).to_owned()); - } - AxisTicks { - values, - labels, - scale_exponent: None, - } -} - -pub fn axis_ticks(min: f64, max: f64, target: usize) -> AxisTicks { - let values = ticks(min, max, target); - let max_abs = min.abs().max(max.abs()); - let exponent = if max_abs.is_finite() && max_abs > 0.0 { - max_abs.log10().floor() as i32 - } else { - 0 - }; - let scale_exponent = (exponent >= 4 || exponent <= -4).then_some(exponent); - let scale = scale_exponent.map_or(1.0, |value| 10f64.powi(value)); - let scaled_step = values - .windows(2) - .map(|pair| ((pair[1] - pair[0]) / scale).abs()) - .find(|step| step.is_finite() && *step > 0.0) - .unwrap_or(1.0); - let precision = decimal_places(scaled_step); - let zero_threshold = 0.5 * 10f64.powi(-(precision as i32)); - let labels = values - .iter() - .map(|value| { - let scaled = value / scale; - let clean = if scaled.abs() < zero_threshold { - 0.0 - } else { - scaled - }; - format!("{clean:.precision$}") - }) - .collect(); - - AxisTicks { - values, - labels, - scale_exponent, - } -} - -fn decimal_places(step: f64) -> usize { - if !step.is_finite() || step <= 0.0 { - return 0; - } - (-(step.log10().floor() as i32)).clamp(0, 8) as usize -} - -fn superscript(value: i32) -> String { - value - .to_string() - .chars() - .map(|ch| match ch { - '-' => '⁻', - '0' => '⁰', - '1' => '¹', - '2' => '²', - '3' => '³', - '4' => '⁴', - '5' => '⁵', - '6' => '⁶', - '7' => '⁷', - '8' => '⁸', - '9' => '⁹', - _ => ch, - }) - .collect() -} - #[cfg(test)] mod tests; diff --git a/crates/render/src/screen.rs b/crates/render/src/screen.rs index c85ecce..0b18898 100644 --- a/crates/render/src/screen.rs +++ b/crates/render/src/screen.rs @@ -1,9 +1,8 @@ use crate::{ AXIS_LINE_WIDTH, Document, DocumentItem, DocumentObject, DocumentOverlay, DocumentViewport, - LegendMark, Margins, OUTER_PAD, OverlayAlign, OverlayKind, OverlayShape, OverlayShapeKind, - OverlayText, Projector, Rect, TICK_LABEL_PAD, TICK_LENGTH, arrow_head, axis_ticks_for, - error_bar_segments, heatmap_cells, integral, legend_entries, polygon_outline, - projection_points, + LegendMark, OUTER_PAD, OverlayAlign, OverlayKind, OverlayShape, OverlayShapeKind, OverlayText, + Projector, Rect, TICK_LABEL_PAD, TICK_LENGTH, arrow_head, axis_layout, error_bar_segments, + heatmap_cells, integral, legend_entries, polygon_outline, projection_points, }; use egui::{Align2, Color32, FontId, Pos2, Sense, Shape, Stroke, StrokeKind, Ui, Vec2}; use plotx_figure::{AxisFrame, AxisTrace, Color, Figure, SeriesKind}; @@ -33,7 +32,8 @@ pub fn show(ui: &mut Ui, fig: &Figure) { /// it here, so the whole figure stays proportional at any zoom. pub fn paint(painter: &egui::Painter, outer: Rect, fig: &Figure, scale: f32) { let ty = fig.typography; - let margins = Margins::for_figure(fig).scaled(scale); + let layout = axis_layout(fig, outer.width / scale, outer.height / scale); + let margins = layout.margins.scaled(scale); let proj = Projector::new(fig, outer, &margins); let plot = proj.plot; @@ -62,18 +62,7 @@ pub fn paint(painter: &egui::Painter, outer: Rect, fig: &Figure, scale: f32) { } let hidden_frame = fig.axis_frame == AxisFrame::Hidden; - // A hidden frame draws no ticks, so empty tick sets let every tick/label - // loop below no-op without further guards. - let empty_ticks = crate::AxisTicks { - values: Vec::new(), - labels: Vec::new(), - scale_exponent: None, - }; - let (x_ticks, y_ticks) = if hidden_frame { - (empty_ticks.clone(), empty_ticks) - } else { - (axis_ticks_for(&fig.x, 8), axis_ticks_for(&fig.y, 5)) - }; + let (x_ticks, y_ticks) = (layout.x_ticks, layout.y_ticks); if fig.show_grid && !hidden_frame { let grid_stroke = Stroke::new(1.0 * scale, col(Color::GRID)); diff --git a/crates/render/src/svg.rs b/crates/render/src/svg.rs index 27c9060..5bc9595 100644 --- a/crates/render/src/svg.rs +++ b/crates/render/src/svg.rs @@ -1,7 +1,7 @@ use crate::{ - AXIS_LINE_WIDTH, Document, DocumentItem, DocumentObject, DocumentOverlay, LegendMark, Margins, + AXIS_LINE_WIDTH, Document, DocumentItem, DocumentObject, DocumentOverlay, LegendMark, OUTER_PAD, OverlayAlign, OverlayKind, OverlayShapeKind, Projector, Rect, TICK_LABEL_PAD, - TICK_LENGTH, arrow_head, axis_ticks_for, error_bar_segments, heatmap_cells, integral, + TICK_LENGTH, arrow_head, axis_layout, error_bar_segments, heatmap_cells, integral, legend_entries, polygon_outline, projection_points, }; use plotx_figure::{AxisFrame, AxisTrace, Figure, SeriesKind}; @@ -167,7 +167,8 @@ fn write_figure(s: &mut String, fig: &Figure, outer: Rect, clip_id: &str) { let ty = fig.typography; let w = outer.width; let h = outer.height; - let margins = Margins::for_figure(fig); + let layout = axis_layout(fig, outer.width, outer.height); + let margins = layout.margins; let proj = Projector::new(fig, outer, &margins); let plot = proj.plot; @@ -192,17 +193,7 @@ fn write_figure(s: &mut String, fig: &Figure, outer: Rect, clip_id: &str) { } let hidden_frame = fig.axis_frame == AxisFrame::Hidden; - // Empty tick sets make every tick/label loop below a no-op for a hidden frame. - let empty_ticks = crate::AxisTicks { - values: Vec::new(), - labels: Vec::new(), - scale_exponent: None, - }; - let (x_ticks, y_ticks) = if hidden_frame { - (empty_ticks.clone(), empty_ticks) - } else { - (axis_ticks_for(&fig.x, 8), axis_ticks_for(&fig.y, 5)) - }; + let (x_ticks, y_ticks) = (layout.x_ticks, layout.y_ticks); if fig.show_grid && !hidden_frame { let grid = plotx_figure::Color::GRID.to_hex(); @@ -275,7 +266,7 @@ fn write_figure(s: &mut String, fig: &Figure, outer: Rect, clip_id: &str) { width = AXIS_LINE_WIDTH, y = plot.bottom() + TICK_LENGTH + TICK_LABEL_PAD + ty.tick_pt, font = ty.tick_pt, - lab = label, + lab = escape(label), ); } let y_tick_x = y_axis_x - TICK_LENGTH - TICK_LABEL_PAD; @@ -289,7 +280,7 @@ fn write_figure(s: &mut String, fig: &Figure, outer: Rect, clip_id: &str) { width = AXIS_LINE_WIDTH, x = y_tick_x, font = ty.tick_pt, - lab = label, + lab = escape(label), ); } if let Some(multiplier) = y_ticks.multiplier() { @@ -687,11 +678,17 @@ mod tests { fn escapes_xml_special_chars() { let fig = Figure::new( "A & B ", - Axis::new("x", 0.0, 1.0), - Axis::new("y", 0.0, 1.0), + Axis::categorical("x", vec!["A & B".into(), "".into()]), + Axis::categorical("y", vec!["north & south".into(), "".into()]), ); let out = export(&fig); assert!(out.contains("A & B <test>")); + assert!(out.contains("A & B")); + assert!(out.contains("<ctrl>")); + assert!(out.contains("north & south")); + assert!(out.contains("<root>")); + assert!(!out.contains(">A & B<")); + assert!(!out.contains("><")); } #[test] @@ -742,4 +739,28 @@ mod tests { assert_eq!(boxed.matches(" = layout + .x_ticks + .values + .iter() + .zip(&layout.x_ticks.labels) + .map(|(&value, label)| { + let (center, _) = proj.project([value, fig.y.min]); + let half = estimated_text_width(label, fig.typography.tick_pt) * 0.5; + (center - half, center + half) + }) + .collect(); + intervals.sort_by(|a, b| a.0.total_cmp(&b.0)); + assert!( + intervals.windows(2).all(|pair| pair[0].1 <= pair[1].0), + "overlapping labels at width {width}: {intervals:?}" + ); + } +} + +#[test] +fn left_x_endpoint_stays_clear_when_y_ticks_are_dropped() { + let fig = Figure::new( + "", + Axis::new("x", -8_000.0, 0.0), + Axis::new("intensity", 0.0, 10.0), + ); + let layout = axis_layout(&fig, 400.0, 55.0); + assert!(layout.y_ticks.labels.is_empty()); + + let proj = Projector::new(&fig, Rect::new(0.0, 0.0, 400.0, 55.0), &layout.margins); + let leftmost_label_edge = layout + .x_ticks + .values + .iter() + .zip(&layout.x_ticks.labels) + .map(|(&value, label)| { + let (center, _) = proj.project([value, fig.y.min]); + center - estimated_text_width(label, fig.typography.tick_pt) * 0.5 + }) + .fold(f32::INFINITY, f32::min); + let y_title_lane_right = OUTER_PAD + fig.typography.label_pt; + assert!( + leftmost_label_edge >= y_title_lane_right + AXIS_LABEL_GAP - 1e-3, + "x label edge {leftmost_label_edge} entered y-title lane ending at {y_title_lane_right}" + ); +} + +#[test] +fn categorical_y_first_pass_covers_labels_selected_by_later_strides() { + let longest = "a very long category selected only by the second pass"; + let fig = Figure::new( + "", + Axis::new("x", -8_000.0, 0.0), + Axis::categorical( + "group", + vec![ + "a".into(), + "b".into(), + "c".into(), + longest.into(), + "e".into(), + "f".into(), + ], + ), + ); + let layout = axis_layout(&fig, 160.0, 65.0); + + assert!(layout.y_ticks.labels.iter().any(|label| label == longest)); + assert!( + layout.x_ticks.labels.is_empty(), + "the conservative first pass must reject x labels before the later y stride widens margins" + ); +} + +#[test] +fn adaptive_tick_counts_grow_monotonically_and_reach_the_old_wide_budget() { + let fig = Figure::new("", Axis::new("x", 0.0, 10.0), Axis::new("y", 0.0, 10.0)); + let counts: Vec = [24.0, 60.0, 100.0, 160.0, 240.0, 400.0, 800.0] + .into_iter() + .map(|width| axis_layout(&fig, width, 600.0).x_ticks.values.len()) + .collect(); + assert!( + counts.windows(2).all(|pair| pair[0] <= pair[1]), + "tick counts were not monotonic: {counts:?}" + ); + + let wide = axis_layout(&fig, 800.0, 600.0); + assert_eq!(wide.x_ticks, axis_ticks_for(&fig.x, 8)); + assert_eq!(wide.y_ticks, axis_ticks_for(&fig.y, 5)); +} + +#[test] +fn tiny_figures_drop_ticks_but_keep_a_finite_axis_rect() { + let fig = Figure::new("", Axis::new("x", 0.0, 10.0), Axis::new("y", 0.0, 10.0)); + let layout = axis_layout(&fig, 24.0, 24.0); + assert!(layout.x_ticks.values.is_empty()); + assert!(layout.y_ticks.values.is_empty()); + + let plot = Projector::new(&fig, Rect::new(0.0, 0.0, 24.0, 24.0), &layout.margins).plot; + assert!(plot.left.is_finite() && plot.top.is_finite()); + assert!(plot.width > 0.0 && plot.height > 0.0); +} + +#[test] +fn categorical_layout_accounts_for_long_names() { + let short = Figure::new( + "", + Axis::categorical("group", (0..8).map(|i| format!("c{i}")).collect()), + Axis::new("y", 0.0, 1.0), + ); + let long = Figure::new( + "", + Axis::categorical( + "group", + (0..8).map(|i| format!("long category name {i}")).collect(), + ), + Axis::new("y", 0.0, 1.0), + ); + let short_ticks = axis_layout(&short, 420.0, 300.0).x_ticks; + let long_ticks = axis_layout(&long, 420.0, 300.0).x_ticks; + assert_eq!(short_ticks.values.len(), 8); + assert!(long_ticks.values.len() < short_ticks.values.len()); +} + +#[test] +fn east_asian_wide_categories_use_full_em_width_for_thinning() { + let fig = Figure::new( + "", + Axis::categorical( + "group", + ["分类一", "分类二", "分类三", "分类四", "分类五", "分类六"] + .into_iter() + .map(str::to_owned) + .collect(), + ), + Axis::new("y", 0.0, 1.0), + ); + let layout = axis_layout(&fig, 165.0, 150.0); + assert!( + layout.x_ticks.labels.len() < 6, + "full-width category labels must be thinned: {:?}", + layout.x_ticks.labels + ); + + let tick_pt = fig.typography.tick_pt; + assert!((estimated_text_width("分类一", tick_pt) - 3.0 * tick_pt).abs() < 1e-3); + assert!((estimated_text_width("𠀀", tick_pt) - tick_pt).abs() < 1e-3); + assert!((estimated_text_width("e\u{301}", tick_pt) - 0.58 * tick_pt).abs() < 1e-3); +} + +#[test] +fn aspect_lock_and_projection_bands_constrain_the_adaptive_budget() { + use plotx_figure::AxisTrace; + + let base = Figure::new("", Axis::new("x", 0.0, 10.0), Axis::new("y", 0.0, 10.0)); + let base_count = axis_layout(&base, 600.0, 140.0).x_ticks.values.len(); + + let mut constrained = base.clone(); + constrained.lock_aspect = true; + constrained.left_projection = Some(AxisTrace { + points: vec![[0.0, 0.0], [10.0, 1.0]], + color: Color::TRACE, + width: 1.0, + }); + let constrained_count = axis_layout(&constrained, 600.0, 140.0).x_ticks.values.len(); + assert!(constrained_count < base_count); +} + #[test] fn hidden_frame_collapses_margins_to_outer_pad() { let mut fig = Figure::new("", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)); diff --git a/crates/render/src/ticks.rs b/crates/render/src/ticks.rs new file mode 100644 index 0000000..2dadd7d --- /dev/null +++ b/crates/render/src/ticks.rs @@ -0,0 +1,405 @@ +use crate::{AXIS_LABEL_GAP, Margins, OUTER_PAD, Projector, Rect, TICK_LABEL_PAD, TICK_LENGTH}; +use plotx_figure::{Axis, AxisFrame, Figure}; +use unicode_width::UnicodeWidthChar; + +const MAX_X_TARGET: usize = 8; +const MAX_Y_TARGET: usize = 5; +const LABEL_GAP_FACTOR: f32 = 0.8; +const Y_TICK_SPACING_FACTOR: f32 = 1.8; + +/// Margins and tick sets computed together for a particular output size. +#[derive(Debug, Clone, PartialEq)] +pub struct AxisLayout { + pub margins: Margins, + pub x_ticks: AxisTicks, + pub y_ticks: AxisTicks, +} + +/// Compute axes in page units, adapting tick density to the final plot rect. +/// +/// The first pass uses the normal maximum tick budgets to establish margins. +/// The second and final pass chooses ticks from the resulting plot size and +/// recomputes margins. Avoiding another geometry pass keeps the result stable; +/// the second-pass margins can only make the available plot area less +/// constrained than the conservative first pass. +pub fn axis_layout(fig: &Figure, outer_width: f32, outer_height: f32) -> AxisLayout { + if fig.axis_frame == AxisFrame::Hidden { + let x_ticks = AxisTicks::empty(); + let y_ticks = AxisTicks::empty(); + return AxisLayout { + margins: margins_for_ticks(fig, &x_ticks, &y_ticks), + x_ticks, + y_ticks, + }; + } + + let initial_x = axis_ticks_for(&fig.x, MAX_X_TARGET); + let initial_y = axis_ticks_for(&fig.y, MAX_Y_TARGET); + // Categorical thinning can select a different subset at a lower target. + // Size the first pass for every visible category so no second-pass label + // can make the final margins grow and invalidate the fit checks below. + // This deliberately reserves names that may not ultimately be drawn; on a + // narrow figure that conservative cost can reduce the x tick budget. + let initial_widths = TickLabelWidths { + x: widest_layout_label(&fig.x, &initial_x, fig.typography.tick_pt), + y: widest_layout_label(&fig.y, &initial_y, fig.typography.tick_pt), + }; + let initial_margins = + margins_for_ticks_with_widths(fig, &initial_x, &initial_y, initial_widths); + let outer = Rect::new(0.0, 0.0, outer_width.max(0.0), outer_height.max(0.0)); + let plot = Projector::new(fig, outer, &initial_margins).plot; + let gap = fig.typography.tick_pt * LABEL_GAP_FACTOR; + + let x_ticks = adaptive_x_ticks(fig, plot.width, gap, initial_widths.x); + let y_ticks = adaptive_y_ticks(fig, plot.height); + let margins = margins_for_ticks(fig, &x_ticks, &y_ticks); + + AxisLayout { + margins, + x_ticks, + y_ticks, + } +} + +fn adaptive_x_ticks(fig: &Figure, plot_width: f32, gap: f32, widest: f32) -> AxisTicks { + let slot_width = widest + gap; + if !plot_width.is_finite() || slot_width <= 0.0 || plot_width < 2.0 * slot_width { + return AxisTicks::empty(); + } + + let target = ((plot_width / slot_width).floor() as usize).clamp(2, MAX_X_TARGET); + for candidate in (2..=target).rev() { + let ticks = axis_ticks_for(&fig.x, candidate); + if horizontal_labels_fit(&fig.x, &ticks, plot_width, fig.typography.tick_pt, gap) { + return ticks; + } + } + AxisTicks::empty() +} + +fn adaptive_y_ticks(fig: &Figure, plot_height: f32) -> AxisTicks { + let spacing = fig.typography.tick_pt * Y_TICK_SPACING_FACTOR; + if !plot_height.is_finite() || spacing <= 0.0 || plot_height < 2.0 * spacing { + return AxisTicks::empty(); + } + + let target = ((plot_height / spacing).floor() as usize).clamp(2, MAX_Y_TARGET); + for candidate in (2..=target).rev() { + let ticks = axis_ticks_for(&fig.y, candidate); + if vertical_labels_fit(&fig.y, &ticks, plot_height, fig.typography.tick_pt) { + return ticks; + } + } + AxisTicks::empty() +} + +fn widest_layout_label(axis: &Axis, ticks: &AxisTicks, tick_pt: f32) -> f32 { + if axis.categories.is_some() { + visible_categories(axis) + .map(|(_, label)| estimated_text_width(label, tick_pt)) + .fold(0.0, f32::max) + } else { + widest_tick_label(ticks, tick_pt) + } +} + +fn widest_tick_label(ticks: &AxisTicks, tick_pt: f32) -> f32 { + ticks + .labels + .iter() + .map(|label| estimated_text_width(label, tick_pt)) + .fold(0.0, f32::max) +} + +fn horizontal_labels_fit( + axis: &Axis, + ticks: &AxisTicks, + width: f32, + tick_pt: f32, + gap: f32, +) -> bool { + let mut intervals: Vec<(f32, f32)> = ticks + .values + .iter() + .zip(&ticks.labels) + .map(|(&value, label)| { + let center = axis.normalize(value) as f32 * width; + let half = estimated_text_width(label, tick_pt) * 0.5; + (center - half, center + half) + }) + .collect(); + intervals.sort_by(|a, b| a.0.total_cmp(&b.0)); + intervals + .windows(2) + .all(|pair| pair[0].1 + gap <= pair[1].0) +} + +fn vertical_labels_fit(axis: &Axis, ticks: &AxisTicks, height: f32, tick_pt: f32) -> bool { + let mut centers: Vec = ticks + .values + .iter() + .map(|&value| axis.normalize(value) as f32 * height) + .collect(); + centers.sort_by(f32::total_cmp); + centers + .windows(2) + .all(|pair| pair[1] - pair[0] >= tick_pt * Y_TICK_SPACING_FACTOR) +} + +pub(crate) fn margins_for_ticks(fig: &Figure, x_ticks: &AxisTicks, y_ticks: &AxisTicks) -> Margins { + let widths = TickLabelWidths { + x: widest_tick_label(x_ticks, fig.typography.tick_pt), + y: widest_tick_label(y_ticks, fig.typography.tick_pt), + }; + margins_for_ticks_with_widths(fig, x_ticks, y_ticks, widths) +} + +#[derive(Clone, Copy)] +struct TickLabelWidths { + x: f32, + y: f32, +} + +fn margins_for_ticks_with_widths( + fig: &Figure, + x_ticks: &AxisTicks, + y_ticks: &AxisTicks, + widths: TickLabelWidths, +) -> Margins { + let ty = fig.typography; + if fig.axis_frame == AxisFrame::Hidden { + let title_clearance = if fig.title.trim().is_empty() { + 0.0 + } else { + ty.title_pt + AXIS_LABEL_GAP + }; + return Margins { + left: OUTER_PAD, + right: OUTER_PAD, + top: OUTER_PAD + title_clearance, + bottom: OUTER_PAD, + }; + } + + let y_tick_clearance = if y_ticks.labels.is_empty() { + 0.0 + } else { + widths.y + TICK_LENGTH + TICK_LABEL_PAD + }; + let x_tick_clearance = if x_ticks.labels.is_empty() { + 0.0 + } else { + ty.tick_pt + TICK_LABEL_PAD + TICK_LENGTH + }; + + // Keep a left-end x label out of the rotated y-title lane even when the + // y ticks themselves have been dropped on a short panel. + let x_endpoint_clearance = if x_ticks.labels.is_empty() { + 0.0 + } else { + widths.x * 0.5 + }; + let axis_title_clearance = OUTER_PAD + ty.label_pt + AXIS_LABEL_GAP; + let left = axis_title_clearance + y_tick_clearance.max(x_endpoint_clearance); + let right = (OUTER_PAD + widths.x * 0.5).max(8.0); + let title_clearance = if fig.title.trim().is_empty() { + 0.0 + } else { + ty.title_pt + AXIS_LABEL_GAP + }; + let top = OUTER_PAD + title_clearance + y_ticks.multiplier_clearance(ty.tick_pt); + let bottom = OUTER_PAD + + x_ticks.multiplier_clearance(ty.tick_pt) + + ty.label_pt + + AXIS_LABEL_GAP + + x_tick_clearance; + + Margins { + left, + right, + top, + bottom, + } +} + +/// Up to `target` "nice" tick values covering `[min, max]`, using 1/2/5×10ⁿ +/// rounding so ticks land on human-friendly numbers. +pub fn ticks(min: f64, max: f64, target: usize) -> Vec { + let (lo, hi) = if min <= max { (min, max) } else { (max, min) }; + let span = hi - lo; + if !span.is_finite() || span <= 0.0 || target == 0 { + return vec![lo]; + } + let raw_step = span / target as f64; + let mag = 10f64.powf(raw_step.log10().floor()); + let norm = raw_step / mag; + let nice = if norm < 1.5 { + 1.0 + } else if norm < 3.0 { + 2.0 + } else if norm < 7.0 { + 5.0 + } else { + 10.0 + }; + let step = nice * mag; + + let eps = step * 1e-9; + let first = ((lo - eps) / step).ceil() * step; + let mut out = Vec::new(); + let mut value = first; + let mut guard = 0; + while value <= hi + eps && guard < 1000 { + out.push(if value.abs() < step * 1e-9 { + 0.0 + } else { + value + }); + value += step; + guard += 1; + } + out +} + +/// Tick positions plus labels formatted as one axis-wide system. +#[derive(Debug, Clone, PartialEq)] +pub struct AxisTicks { + pub values: Vec, + pub labels: Vec, + pub scale_exponent: Option, +} + +impl AxisTicks { + pub fn empty() -> Self { + Self { + values: Vec::new(), + labels: Vec::new(), + scale_exponent: None, + } + } + + pub fn multiplier(&self) -> Option { + self.scale_exponent + .map(|exponent| format!("×10{}", superscript(exponent))) + } + + pub fn multiplier_clearance(&self, tick_pt: f32) -> f32 { + if self.scale_exponent.is_some() { + tick_pt + AXIS_LABEL_GAP + } else { + 0.0 + } + } +} + +pub(crate) fn estimated_text_width(text: &str, font_size: f32) -> f32 { + text.chars() + .map(|ch| match ch { + '0'..='9' => 0.56, + '.' | ',' => 0.28, + '-' | '−' => 0.36, + _ => match ch.width() { + None | Some(0) => 0.0, + Some(1) => 0.58, + Some(_) => 1.0, + }, + }) + .sum::() + * font_size +} + +/// Ticks for an axis honoring its ordinal mode. +pub fn axis_ticks_for(axis: &Axis, target: usize) -> AxisTicks { + if axis.categories.is_none() { + return axis_ticks(axis.min, axis.max, target); + } + let visible: Vec<(f64, &str)> = visible_categories(axis).collect(); + let stride = visible.len().div_ceil(target.max(1)).max(1); + let mut values = Vec::new(); + let mut labels = Vec::new(); + for (value, name) in visible.iter().step_by(stride) { + values.push(*value); + labels.push((*name).to_owned()); + } + AxisTicks { + values, + labels, + scale_exponent: None, + } +} + +fn visible_categories(axis: &Axis) -> impl Iterator { + let (lo, hi) = (axis.min.min(axis.max), axis.min.max(axis.max)); + axis.categories + .iter() + .flat_map(|names| names.iter().enumerate()) + .filter_map(move |(index, name)| { + let value = index as f64; + (value >= lo - 1e-9 && value <= hi + 1e-9).then_some((value, name.as_str())) + }) +} + +pub fn axis_ticks(min: f64, max: f64, target: usize) -> AxisTicks { + let values = ticks(min, max, target); + let max_abs = min.abs().max(max.abs()); + let exponent = if max_abs.is_finite() && max_abs > 0.0 { + max_abs.log10().floor() as i32 + } else { + 0 + }; + let scale_exponent = (exponent >= 4 || exponent <= -4).then_some(exponent); + let scale = scale_exponent.map_or(1.0, |value| 10f64.powi(value)); + let scaled_step = values + .windows(2) + .map(|pair| ((pair[1] - pair[0]) / scale).abs()) + .find(|step| step.is_finite() && *step > 0.0) + .unwrap_or(1.0); + let precision = decimal_places(scaled_step); + let zero_threshold = 0.5 * 10f64.powi(-(precision as i32)); + let labels = values + .iter() + .map(|value| { + let scaled = value / scale; + let clean = if scaled.abs() < zero_threshold { + 0.0 + } else { + scaled + }; + format!("{clean:.precision$}") + }) + .collect(); + + AxisTicks { + values, + labels, + scale_exponent, + } +} + +fn decimal_places(step: f64) -> usize { + if !step.is_finite() || step <= 0.0 { + return 0; + } + (-(step.log10().floor() as i32)).clamp(0, 8) as usize +} + +fn superscript(value: i32) -> String { + value + .to_string() + .chars() + .map(|ch| match ch { + '-' => '⁻', + '0' => '⁰', + '1' => '¹', + '2' => '²', + '3' => '³', + '4' => '⁴', + '5' => '⁵', + '6' => '⁶', + '7' => '⁷', + '8' => '⁸', + '9' => '⁹', + _ => ch, + }) + .collect() +} diff --git a/docs/src/content/docs/guides/layout-and-export.md b/docs/src/content/docs/guides/layout-and-export.md index b03eaaa..06d6772 100644 --- a/docs/src/content/docs/guides/layout-and-export.md +++ b/docs/src/content/docs/guides/layout-and-export.md @@ -53,11 +53,12 @@ as a color overlay. ## Plot styling and typography PlotX styles plots for print automatically: clean bottom-and-left axes with -outward ticks, tick precision that follows the zoom, and NMR isotope numbers -set as superscripts. New dataset pages start at the 89 × 60 mm single-column -size, so a plot spanning the page already shows text at its printed journal -size; assemble multi-panel figures on a wider page later, keeping each panel -at its natural size. +outward ticks, tick precision that follows the data range, tick density that +automatically thins as a panel narrows, and NMR isotope numbers set as +superscripts. New dataset pages start at the 89 × 60 mm single-column size, so +a plot spanning the page already shows text at its printed journal size; +assemble multi-panel figures on a wider page later, keeping each panel at its +natural size. What you control directly: diff --git a/docs/src/content/docs/zh-cn/guides/layout-and-export.md b/docs/src/content/docs/zh-cn/guides/layout-and-export.md index c1d73c2..9572496 100644 --- a/docs/src/content/docs/zh-cn/guides/layout-and-export.md +++ b/docs/src/content/docs/zh-cn/guides/layout-and-export.md @@ -46,9 +46,10 @@ ACS、Elsevier、PNAS 和 IEEE,数值取自各出版社的作图规范)、 ## 作图样式与排印 PlotX 自动按印刷习惯设定图形样式:只保留左轴和底轴、向外的短刻度、随 -缩放调整的刻度精度,以及以上标显示的 NMR 核素质量数。新数据集默认使用 -89 × 60 mm 单栏画布:单个图占满画布时,屏幕上看到的字号就是期刊印出的 -字号;多分图的组合图之后再拼合到更宽的页面上,各分图保持原始尺寸。 +数据范围调整的刻度精度、图框变窄时自动抽稀的刻度密度,以及以上标显示的 +NMR 核素质量数。新数据集默认使用 89 × 60 mm 单栏画布:单个图占满画布 +时,屏幕上看到的字号就是期刊印出的字号;多分图的组合图之后再拼合到更宽 +的页面上,各分图保持原始尺寸。 你可以直接控制的部分: