From f36e1bf8c1f43126c1e1d2926816ad411e992e40 Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Wed, 22 Jul 2026 13:27:35 +0800 Subject: [PATCH] fix(render): adapt axis ticks to available plot space Centralize axis tick and margin calculation across screen, SVG, and EMF renderers. Thin labels according to the final plot rectangle, account for categorical and East Asian text widths, and drop ticks gracefully when the available space is insufficient. Keep canvas interaction geometry aligned with rendering, escape categorical labels in SVG output, and document the adaptive behavior in English and Simplified Chinese. --- Cargo.lock | 1 + Cargo.toml | 1 + crates/app/src/ui/canvas/geometry.rs | 21 +- crates/app/src/ui/canvas/mod.rs | 8 +- crates/app/src/ui/canvas/phase.rs | 9 +- crates/render/Cargo.toml | 1 + crates/render/src/emf.rs | 19 +- crates/render/src/lib.rs | 232 +--------- crates/render/src/screen.rs | 23 +- crates/render/src/svg.rs | 57 ++- crates/render/src/tests.rs | 179 ++++++++ crates/render/src/ticks.rs | 405 ++++++++++++++++++ .../content/docs/guides/layout-and-export.md | 11 +- .../docs/zh-cn/guides/layout-and-export.md | 7 +- 14 files changed, 671 insertions(+), 303 deletions(-) create mode 100644 crates/render/src/ticks.rs 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 单栏画布:单个图占满画布 +时,屏幕上看到的字号就是期刊印出的字号;多分图的组合图之后再拼合到更宽 +的页面上,各分图保持原始尺寸。 你可以直接控制的部分: