#![warn(missing_docs)]
use crate::plot::scale::Scale;
use crate::plot::series::SeriesBounds;
use crate::plot::spec::{Mark, PlotSpec};
use crate::plot::view::{AxisView, PlotView};
use crate::tree::Rect;
pub const FIT_PADDING: f64 = 0.05;
const GUTTER_BOTTOM: f32 = 28.0;
const MARGIN_TOP: f32 = 10.0;
const MARGIN_RIGHT: f32 = 12.0;
pub const GUTTER_LEFT_MIN: f32 = 40.0;
const Y_TICK_LABEL_SIZE: f32 = 11.0;
const Y_LABEL_GAP: f32 = 12.0;
const Y_TICK_TARGET: usize = 6;
pub fn left_gutter(spec: &PlotSpec, view: &PlotView) -> f32 {
let ys = spec.y.scale;
let mut widest = 0.0_f32;
for t in ys.ticks((view.y.min, view.y.max), Y_TICK_TARGET) {
let w = crate::text::metrics::line_width(
&t.label,
Y_TICK_LABEL_SIZE,
crate::tree::FontWeight::default(),
false,
);
widest = widest.max(w);
}
(widest + Y_LABEL_GAP).max(GUTTER_LEFT_MIN)
}
pub fn data_rect(node_rect: Rect, gutter_left: f32) -> Rect {
let x = node_rect.x + gutter_left;
let y = node_rect.y + MARGIN_TOP;
let w = (node_rect.w - gutter_left - MARGIN_RIGHT).max(0.0);
let h = (node_rect.h - MARGIN_TOP - GUTTER_BOTTOM).max(0.0);
Rect::new(x, y, w, h)
}
pub fn data_bounds(spec: &PlotSpec) -> SeriesBounds {
let mut bounds = SeriesBounds::default();
for mark in &spec.marks {
bounds = bounds.union(series_of(mark).bounds());
}
bounds
}
fn series_of(mark: &Mark) -> &crate::plot::series::SeriesHandle {
match mark {
Mark::Line(m) => &m.series,
Mark::Scatter(m) => &m.series,
}
}
pub fn autofit(bounds: SeriesBounds, xs: Scale, ys: Scale) -> PlotView {
PlotView::fit(
bounds.x.unwrap_or((0.0, 1.0)),
bounds.y.unwrap_or((0.0, 1.0)),
FIT_PADDING,
xs,
ys,
)
}
pub fn visible_y(spec: &PlotSpec, x: AxisView) -> Option<(f64, f64)> {
let (lo, hi) = (x.min.min(x.max), x.min.max(x.max));
let mut acc: Option<(f64, f64)> = None;
for mark in &spec.marks {
let (samples, _) = series_of(mark).snapshot();
for s in samples.iter() {
if s.x.is_finite() && s.y.is_finite() && s.x >= lo && s.x <= hi {
acc = Some(match acc {
Some((ylo, yhi)) => (ylo.min(s.y), yhi.max(s.y)),
None => (s.y, s.y),
});
}
}
}
acc
}
pub fn pad_y(span: (f64, f64), scale: Scale) -> AxisView {
AxisView::fit(span, FIT_PADDING, scale)
}
pub fn resolve_view(
spec: &PlotSpec,
persisted: Option<PlotView>,
autoscale_x: bool,
autoscale_y: bool,
) -> PlotView {
let bounds = data_bounds(spec);
let fit = autofit(bounds, spec.x.scale, spec.y.scale);
let mut view = persisted.unwrap_or(fit);
if autoscale_x && bounds.x.is_some() {
view = view.with_x(fit.x);
}
if autoscale_y && let Some(span) = visible_y(spec, view.x) {
view = view.with_y(pad_y(span, spec.y.scale));
}
view
}
pub fn x_scale(spec: &PlotSpec) -> Scale {
spec.x.scale
}
pub fn y_scale(spec: &PlotSpec) -> Scale {
spec.y.scale
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plot::series::{Sample, SeriesHandle};
use crate::plot::spec::line;
fn spec_with(samples: Vec<Sample>) -> PlotSpec {
let h = SeriesHandle::new(samples);
PlotSpec::new().add_mark(line(&h))
}
#[test]
fn data_rect_insets_gutters() {
let g = 52.0;
let r = data_rect(Rect::new(0.0, 0.0, 200.0, 100.0), g);
assert_eq!(r.x, g);
assert_eq!(r.y, MARGIN_TOP);
assert_eq!(r.w, 200.0 - g - MARGIN_RIGHT);
assert_eq!(r.h, 100.0 - MARGIN_TOP - GUTTER_BOTTOM);
}
#[test]
fn data_rect_clamps_to_nonnegative() {
let r = data_rect(Rect::new(0.0, 0.0, 10.0, 10.0), 52.0);
assert_eq!(r.w, 0.0);
assert_eq!(r.h, 0.0);
}
#[test]
fn left_gutter_grows_for_wide_labels_and_floors() {
let wide = spec_with(vec![Sample::new(0.0, 0.0), Sample::new(1.0, 1_000_000.0)]);
let narrow = spec_with(vec![Sample::new(0.0, 0.0), Sample::new(1.0, 9.0)]);
let view_w = resolve_view(&wide, None, true, true);
let view_n = resolve_view(&narrow, None, true, true);
let g_wide = left_gutter(&wide, &view_w);
let g_narrow = left_gutter(&narrow, &view_n);
assert!(
g_wide > g_narrow,
"wide labels widen the gutter: {g_wide} vs {g_narrow}"
);
assert!(
g_narrow >= GUTTER_LEFT_MIN,
"floored at the minimum: {g_narrow}"
);
}
#[test]
fn data_bounds_union_over_marks() {
let a = SeriesHandle::new(vec![Sample::new(0.0, 1.0), Sample::new(5.0, 3.0)]);
let b = SeriesHandle::new(vec![Sample::new(-2.0, 0.0), Sample::new(3.0, 9.0)]);
let spec = PlotSpec::new().line(&a).line(&b);
let bounds = data_bounds(&spec);
assert_eq!(bounds.x, Some((-2.0, 5.0)));
assert_eq!(bounds.y, Some((0.0, 9.0)));
}
#[test]
fn autofit_pads_the_window() {
let bounds = SeriesBounds {
x: Some((0.0, 100.0)),
y: Some((0.0, 10.0)),
};
let v = autofit(bounds, Scale::linear(), Scale::linear());
assert!(v.x.min < 0.0 && v.x.max > 100.0);
assert!(v.y.min < 0.0 && v.y.max > 10.0);
}
#[test]
fn visible_y_only_counts_in_window() {
let spec = spec_with(vec![
Sample::new(0.0, 1.0),
Sample::new(5.0, 100.0), Sample::new(1.0, 2.0),
]);
let span = visible_y(&spec, AxisView::new(-0.5, 1.5)).unwrap();
assert_eq!(span, (1.0, 2.0)); }
#[test]
fn resolve_view_autoscales_y_to_visible() {
let spec = spec_with(vec![Sample::new(0.0, 0.0), Sample::new(10.0, 1000.0)]);
let persisted = PlotView::new(AxisView::new(-1.0, 1.0), AxisView::new(-5.0, 5.0));
let v = resolve_view(&spec, Some(persisted), false, true);
assert!(v.y.max < 100.0, "y autoscaled to visible: {:?}", v.y);
}
#[test]
fn resolve_view_autofits_when_unpersisted() {
let spec = spec_with(vec![Sample::new(0.0, 0.0), Sample::new(4.0, 8.0)]);
let v = resolve_view(&spec, None, true, true);
assert!(v.x.min < 0.0 && v.x.max > 4.0);
}
}
#[cfg(test)]
mod log_fit_tests {
use super::*;
use crate::plot::series::{Sample, SeriesHandle};
use crate::plot::spec::line;
use crate::tree::Rect;
#[test]
fn log_y_autofit_keeps_marks_spread_and_ticks_sane() {
let samples: Vec<Sample> = (0..149)
.map(|i| {
let x = f64::from(i) * (36000.0 / 148.0);
let y = 65536.0 * (1.0f64 / 65536.0).powf(f64::from(i) / 148.0);
Sample::new(x, y)
})
.collect();
let h = SeriesHandle::new(samples);
let spec = PlotSpec::new()
.x(Scale::linear())
.y(Scale::log())
.add_mark(line(&h));
let view = resolve_view(&spec, None, true, true);
assert!(
view.y.min > 0.0,
"log-y window stays positive: {:?}",
view.y
);
let rect = Rect::new(0.0, 0.0, 400.0, 300.0);
let (xs, ys) = (Scale::linear(), Scale::log());
let top = view.project((0.0, 65536.0), xs, ys, rect).1;
let bottom = view.project((36000.0, 1.0), xs, ys, rect).1;
assert!(
(bottom - top).abs() > rect.h * 0.8,
"marks span the rect: {top} .. {bottom}"
);
let ticks = ys.ticks((view.y.min, view.y.max), 6);
let values: Vec<f64> = ticks.iter().map(|t| t.value).collect();
assert_eq!(values, vec![1.0, 10.0, 100.0, 1000.0, 10000.0, 100000.0]);
assert!(ticks.iter().all(|t| t.label != "0"), "labels: {ticks:?}");
}
}
#[cfg(test)]
mod x_autoscale_tests {
use super::*;
use crate::plot::Sample;
use crate::plot::series::SeriesHandle;
fn spec_of(h: &SeriesHandle) -> PlotSpec {
PlotSpec::new().line(h)
}
#[test]
fn resolve_view_x_autoscale_tracks_growing_data() {
let h = SeriesHandle::new(vec![Sample::new(0.0, 0.0), Sample::new(1.0, 1.0)]);
let spec = spec_of(&h);
let first = resolve_view(&spec, None, true, true);
assert!(first.x.max < 2.0, "seeded around the initial extent");
h.append(&[Sample::new(100.0, 5.0)]);
let next = resolve_view(&spec, Some(first), true, true);
assert!(
next.x.max > 100.0,
"x window follows the data: {:?}",
next.x
);
}
#[test]
fn resolve_view_manual_x_holds_the_window() {
let h = SeriesHandle::new(vec![Sample::new(0.0, 0.0), Sample::new(1.0, 1.0)]);
let spec = spec_of(&h);
let first = resolve_view(&spec, None, false, true);
h.append(&[Sample::new(100.0, 5.0)]);
let next = resolve_view(&spec, Some(first), false, true);
assert_eq!(next.x, first.x, "manual x window is sticky");
}
}