Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 9 additions & 12 deletions crates/app/src/ui/canvas/geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
}

Expand Down
8 changes: 3 additions & 5 deletions crates/app/src/ui/canvas/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
};

Expand Down
9 changes: 3 additions & 6 deletions crates/app/src/ui/canvas/phase.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions crates/render/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
19 changes: 5 additions & 14 deletions crates/render/src/emf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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;

Expand All @@ -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 {
Expand Down
232 changes: 9 additions & 223 deletions crates/render/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@
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;

#[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.
Expand Down Expand Up @@ -153,6 +156,7 @@ impl Rect {
}
}

#[derive(Debug, Clone, PartialEq)]
pub struct Margins {
pub left: f32,
pub right: f32,
Expand All @@ -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 {
Expand Down Expand Up @@ -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<f64> {
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<f64>,
pub labels: Vec<String>,
pub scale_exponent: Option<i32>,
}

impl AxisTicks {
pub fn multiplier(&self) -> Option<String> {
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::<f32>()
* 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;
Loading
Loading