use uzor::render::RenderContext;
use uzor::types::Rect;
use crate::coord::PlotArea;
use crate::figure::{resolve_tick_count, FigureOverlay, MarginPolicy, TickCountPolicy};
use crate::guide::annotation::{draw_annotation_overlays, draw_annotation_underlays, Annotation};
use crate::guide::axis::AxisTickWeightStyle;
use crate::guide::grid::GridTickWeightStyle;
use crate::guide::{axis, grid, tooltip};
use crate::interact::hit::{self, HitZone};
use crate::mark::point::draw_points_sized;
use crate::mark::MarkStyle;
use crate::scale::linear::{format_value, nice_step};
use crate::scale::{LinearScale, Scale};
use crate::theme::FigureTheme;
const MARGIN_LEFT: f64 = 56.0;
const MARGIN_RIGHT: f64 = 8.0;
const MARGIN_BOTTOM: f64 = 28.0;
const TITLE_HEIGHT: f64 = 24.0;
const TARGET_X_TICKS: usize = 6;
const TARGET_Y_TICKS: usize = 5;
const DEFAULT_POINT_RADIUS: f64 = 3.5;
const DEFAULT_FILL_ALPHA: f64 = 0.85;
const HOVER_TOLERANCE_PX: f64 = 4.0;
const HOVER_HIGHLIGHT_ALPHA: f64 = 0.35;
const HOVER_HIGHLIGHT_EXTRA_RADIUS: f64 = 2.0;
#[derive(Debug, Clone, Copy)]
pub struct ScatterPoint {
pub x: f64,
pub y: f64,
pub value: Option<f64>,
}
impl ScatterPoint {
pub fn new(x: f64, y: f64) -> Self {
Self { x, y, value: None }
}
pub fn with_value(x: f64, y: f64, value: f64) -> Self {
Self { x, y, value: Some(value) }
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PointRadius {
Fixed(f64),
ValueMapped { min_radius: f64, max_radius: f64 },
}
impl Default for PointRadius {
fn default() -> Self {
PointRadius::Fixed(DEFAULT_POINT_RADIUS)
}
}
pub fn uniform_thin_indices(n: usize, max_points: usize) -> Vec<usize> {
if n == 0 || max_points == 0 {
return Vec::new();
}
if max_points >= n {
return (0..n).collect();
}
if max_points == 1 {
return vec![0];
}
let mut indices = Vec::with_capacity(max_points);
let mut last: Option<usize> = None;
for i in 0..max_points {
let t = i as f64 / (max_points - 1) as f64;
let idx = ((t * (n - 1) as f64).round() as usize).min(n - 1);
if last != Some(idx) {
indices.push(idx);
last = Some(idx);
}
}
indices
}
pub struct ScatterFigure {
pub points: Vec<ScatterPoint>,
pub title: Option<String>,
radius: PointRadius,
x_scale_override: Option<Box<dyn Scale>>,
thin_max: Option<usize>,
annotations: Vec<Annotation>,
margin_policy: MarginPolicy,
x_tick_policy: TickCountPolicy,
y_tick_policy: TickCountPolicy,
}
impl ScatterFigure {
pub fn new(points: Vec<ScatterPoint>) -> Self {
Self {
points,
title: None,
radius: PointRadius::default(),
x_scale_override: None,
thin_max: None,
annotations: Vec::new(),
margin_policy: MarginPolicy::default(),
x_tick_policy: TickCountPolicy::Fixed(TARGET_X_TICKS),
y_tick_policy: TickCountPolicy::Fixed(TARGET_Y_TICKS),
}
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_radius(mut self, radius: PointRadius) -> Self {
self.radius = radius;
self
}
pub fn with_x_scale(mut self, scale: impl Scale + 'static) -> Self {
self.x_scale_override = Some(Box::new(scale));
self
}
pub fn with_thinning(mut self, max_points: usize) -> Self {
self.thin_max = Some(max_points);
self
}
pub fn with_annotations(mut self, annotations: Vec<Annotation>) -> Self {
self.annotations = annotations;
self
}
pub fn with_margin_policy(mut self, policy: MarginPolicy) -> Self {
self.margin_policy = policy;
self
}
pub fn with_x_tick_policy(mut self, policy: TickCountPolicy) -> Self {
self.x_tick_policy = policy;
self
}
pub fn with_y_tick_policy(mut self, policy: TickCountPolicy) -> Self {
self.y_tick_policy = policy;
self
}
fn base_plot_rect(&self, rect: Rect) -> Rect {
let title_h = if self.title.is_some() { TITLE_HEIGHT } else { 0.0 };
Rect::new(
rect.x + MARGIN_LEFT,
rect.y + title_h,
(rect.width - MARGIN_LEFT - MARGIN_RIGHT).max(0.0),
(rect.height - title_h - MARGIN_BOTTOM).max(0.0),
)
}
pub fn plot_area(&self, rect: Rect) -> PlotArea {
PlotArea::new(self.base_plot_rect(rect))
}
pub fn x_scale(&self) -> Option<LinearScale> {
if self.points.len() < 2 {
return None;
}
let (mn, mx) = self.points.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(mn, mx), p| (mn.min(p.x), mx.max(p.x)));
Some(LinearScale::nice(mn, mx, TARGET_X_TICKS))
}
fn y_scale(&self) -> Option<LinearScale> {
if self.points.len() < 2 {
return None;
}
let (mn, mx) = self.points.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(mn, mx), p| (mn.min(p.y), mx.max(p.y)));
Some(LinearScale::nice(mn, mx, TARGET_Y_TICKS))
}
fn value_domain(&self) -> Option<(f64, f64)> {
let mut found = false;
let (mn, mx) = self.points.iter().filter_map(|p| p.value).fold((f64::INFINITY, f64::NEG_INFINITY), |(mn, mx), v| {
found = true;
(mn.min(v), mx.max(v))
});
if found {
Some((mn, mx))
} else {
None
}
}
fn point_radius(&self, p: &ScatterPoint, value_domain: Option<(f64, f64)>) -> f64 {
match self.radius {
PointRadius::Fixed(r) => r,
PointRadius::ValueMapped { min_radius, max_radius } => {
let (Some(v), Some((lo, hi))) = (p.value, value_domain) else { return min_radius };
let t = if (hi - lo).abs() < f64::EPSILON { 0.5 } else { ((v - lo) / (hi - lo)).clamp(0.0, 1.0) };
min_radius + t * (max_radius - min_radius)
}
}
}
fn rendered_indices(&self) -> Vec<usize> {
match self.thin_max {
Some(max) if self.points.len() > max => uniform_thin_indices(self.points.len(), max),
_ => (0..self.points.len()).collect(),
}
}
pub fn render(&self, ctx: &mut dyn RenderContext, rect: Rect, theme: &FigureTheme) {
self.render_with(ctx, rect, theme, &FigureOverlay::default());
}
pub fn render_with(&self, ctx: &mut dyn RenderContext, rect: Rect, theme: &FigureTheme, overlay: &FigureOverlay<'_>) {
ctx.set_fill_color(&theme.background);
ctx.fill_rect(rect.x, rect.y, rect.width, rect.height);
let y_scale_for_layout = self.y_scale();
let computed_x_scale = if self.x_scale_override.is_none() { self.x_scale() } else { None };
let x_scale: Option<&dyn Scale> = match (&self.x_scale_override, &computed_x_scale) {
(Some(s), _) => Some(s.as_ref()),
(None, Some(s)) => Some(s),
(None, None) => None,
};
let title_h = if self.title.is_some() { TITLE_HEIGHT } else { 0.0 };
let plot_height_estimate = (rect.height - title_h - MARGIN_BOTTOM).max(0.0);
let target_y_ticks = resolve_tick_count(self.y_tick_policy, plot_height_estimate);
let x_overhang = x_scale.map(|s| axis::measure_x_axis_extreme_overhang(ctx, s, theme, TARGET_X_TICKS)).unwrap_or((0.0, 0.0));
let margin_left = match (&y_scale_for_layout, self.margin_policy) {
(Some(y_scale), MarginPolicy::Measured) => {
MARGIN_LEFT.max(axis::measure_y_axis_gutter(ctx, y_scale, theme, target_y_ticks)).max(x_overhang.0)
}
_ => MARGIN_LEFT,
};
let margin_right = match self.margin_policy {
MarginPolicy::Measured => MARGIN_RIGHT.max(x_overhang.1),
MarginPolicy::Fixed => MARGIN_RIGHT,
};
let plot_width_estimate = (rect.width - margin_left - margin_right).max(0.0);
let target_x_ticks = resolve_tick_count(self.x_tick_policy, plot_width_estimate);
let base_rect = Rect::new(rect.x + margin_left, rect.y + title_h, plot_width_estimate, plot_height_estimate);
let area = PlotArea::new(base_rect);
if let (Some(x_scale), Some(y_scale)) = (x_scale, y_scale_for_layout) {
grid::draw_x_grid_weighted(ctx, &area, x_scale, theme, target_x_ticks, &GridTickWeightStyle::default());
grid::draw_y_grid(ctx, &area, &y_scale, theme, target_y_ticks);
draw_annotation_underlays(ctx, &area, &y_scale, theme, &self.annotations);
let indices = self.rendered_indices();
let value_domain = self.value_domain();
let sized: Vec<(f64, f64, f64)> = indices
.iter()
.map(|&i| {
let p = &self.points[i];
(p.x, p.y, self.point_radius(p, value_domain))
})
.collect();
let style = MarkStyle { color: theme.palette[0].clone(), fill_alpha: DEFAULT_FILL_ALPHA, ..Default::default() };
draw_points_sized(ctx, &area, x_scale, &y_scale, &sized, &style);
draw_annotation_overlays(ctx, &area, x_scale, &y_scale, theme, &self.annotations);
if let Some((hx, hy)) = overlay.hover_px {
if hit::hit_zone(&area, hx, hy) == HitZone::Plot {
let screen_points: Vec<(f64, f64)> = indices.iter().map(|&i| (self.points[i].x, self.points[i].y)).collect();
if let Some(local_i) = hit::nearest_point_xy(&area, x_scale, &y_scale, &screen_points, hx, hy) {
let orig_i = indices[local_i];
let p = &self.points[orig_i];
let sx = area.x(x_scale, p.x);
let sy = area.y(&y_scale, p.y);
let r = self.point_radius(p, value_domain);
let dist = ((sx - hx).powi(2) + (sy - hy).powi(2)).sqrt();
if dist <= r + HOVER_TOLERANCE_PX {
draw_points_sized(
ctx,
&area,
x_scale,
&y_scale,
&[(p.x, p.y, r + HOVER_HIGHLIGHT_EXTRA_RADIUS)],
&MarkStyle { color: theme.highlight.clone(), fill_alpha: HOVER_HIGHLIGHT_ALPHA, ..Default::default() },
);
let y_step = nice_step(y_scale.max - y_scale.min, target_y_ticks as f64);
let mut lines = vec![("x".to_owned(), x_scale.format_value(p.x)), ("y".to_owned(), format_value(p.y, y_step))];
if let Some(v) = p.value {
let v_step = value_domain.map(|(lo, hi)| nice_step(hi - lo, target_y_ticks as f64)).unwrap_or(1.0);
lines.push(("value".to_owned(), format_value(v, v_step)));
}
tooltip::draw_tooltip(ctx, theme, (sx, sy), &lines, area.rect);
}
}
}
}
axis::draw_x_axis_weighted(ctx, &area, x_scale, theme, target_x_ticks, &AxisTickWeightStyle::default());
axis::draw_y_axis(ctx, &area, &y_scale, theme, target_y_ticks);
}
if let Some(title) = &self.title {
crate::figure::draw_title(ctx, rect, title, theme);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn x_and_y_domain_span_the_full_point_set() {
let points = vec![ScatterPoint::new(-5.0, 100.0), ScatterPoint::new(20.0, -10.0), ScatterPoint::new(8.0, 40.0)];
let figure = ScatterFigure::new(points);
let x = figure.x_scale().expect("3 points");
let y = figure.y_scale().expect("3 points");
assert!(x.min <= -5.0 && x.max >= 20.0);
assert!(y.min <= -10.0 && y.max >= 100.0);
}
#[test]
fn y_domain_never_forces_a_zero_baseline() {
let points = vec![ScatterPoint::new(0.0, 500.0), ScatterPoint::new(1.0, 520.0)];
let figure = ScatterFigure::new(points);
let y = figure.y_scale().expect("2 points");
assert!(y.min > 0.0, "scatter Y domain must fit the data, never force a zero baseline (got min={})", y.min);
}
#[test]
fn fewer_than_two_points_has_no_domain() {
assert!(ScatterFigure::new(vec![ScatterPoint::new(1.0, 1.0)]).x_scale().is_none());
assert!(ScatterFigure::new(Vec::new()).y_scale().is_none());
}
#[test]
fn point_radius_fixed_ignores_value() {
let figure = ScatterFigure::new(Vec::new()).with_radius(PointRadius::Fixed(6.0));
let with_value = ScatterPoint::with_value(0.0, 0.0, 999.0);
let without_value = ScatterPoint::new(0.0, 0.0);
assert_eq!(figure.point_radius(&with_value, Some((0.0, 999.0))), 6.0);
assert_eq!(figure.point_radius(&without_value, None), 6.0);
}
#[test]
fn point_radius_value_mapped_interpolates_and_falls_back_for_missing_value() {
let figure = ScatterFigure::new(Vec::new()).with_radius(PointRadius::ValueMapped { min_radius: 2.0, max_radius: 10.0 });
let lo = ScatterPoint::with_value(0.0, 0.0, 0.0);
let hi = ScatterPoint::with_value(0.0, 0.0, 100.0);
let mid = ScatterPoint::with_value(0.0, 0.0, 50.0);
let missing = ScatterPoint::new(0.0, 0.0);
let domain = Some((0.0, 100.0));
assert!((figure.point_radius(&lo, domain) - 2.0).abs() < 1e-9);
assert!((figure.point_radius(&hi, domain) - 10.0).abs() < 1e-9);
assert!((figure.point_radius(&mid, domain) - 6.0).abs() < 1e-9);
assert_eq!(figure.point_radius(&missing, domain), 2.0, "a point with no value must fall back to min_radius");
}
#[test]
fn value_domain_spans_only_points_that_actually_carry_a_value() {
let points = vec![ScatterPoint::new(0.0, 0.0), ScatterPoint::with_value(1.0, 1.0, 5.0), ScatterPoint::with_value(2.0, 2.0, 15.0)];
let figure = ScatterFigure::new(points);
let (lo, hi) = figure.value_domain().expect("2 of 3 points carry a value");
assert!((lo - 5.0).abs() < 1e-9);
assert!((hi - 15.0).abs() < 1e-9);
}
#[test]
fn value_domain_is_none_when_no_point_carries_a_value() {
let points = vec![ScatterPoint::new(0.0, 0.0), ScatterPoint::new(1.0, 1.0)];
assert!(ScatterFigure::new(points).value_domain().is_none());
}
#[test]
fn uniform_thin_indices_identity_when_max_at_or_above_len() {
assert_eq!(uniform_thin_indices(5, 5), vec![0, 1, 2, 3, 4]);
assert_eq!(uniform_thin_indices(5, 50), vec![0, 1, 2, 3, 4]);
assert_eq!(uniform_thin_indices(0, 10), Vec::<usize>::new());
assert_eq!(uniform_thin_indices(10, 0), Vec::<usize>::new());
}
#[test]
fn uniform_thin_indices_keeps_first_and_last_and_is_strictly_ascending() {
let out = uniform_thin_indices(1000, 37);
assert_eq!(out[0], 0);
assert_eq!(*out.last().unwrap(), 999);
for w in out.windows(2) {
assert!(w[1] > w[0], "indices must be strictly ascending (never duplicated), got {out:?}");
}
assert!(out.len() <= 37);
}
#[test]
fn uniform_thin_indices_is_deterministic() {
let a = uniform_thin_indices(733, 40);
let b = uniform_thin_indices(733, 40);
assert_eq!(a, b);
}
#[test]
fn uniform_thin_indices_single_target_keeps_only_the_first_index() {
assert_eq!(uniform_thin_indices(10, 1), vec![0]);
}
#[test]
fn render_smoke_with_hover_thinning_value_mapped_radius_and_annotations() {
use uzor_export::{render_to_png, ExportSpec};
let points: Vec<ScatterPoint> = (0..200)
.map(|i| {
let x = i as f64;
let y = 10.0 + ((i * 37) % 53) as f64;
ScatterPoint::with_value(x, y, (i % 20) as f64)
})
.collect();
let figure = ScatterFigure::new(points)
.with_title("smoke")
.with_radius(PointRadius::ValueMapped { min_radius: 2.0, max_radius: 8.0 })
.with_thinning(60)
.with_annotations(vec![
Annotation::HBand { low: 20.0, high: 40.0, color: None, label: Some("range".to_owned()) },
Annotation::Callout { x: 100.0, y: 30.0, text: "note".to_owned() },
]);
let theme = FigureTheme::dark();
let rect = Rect::new(0.0, 0.0, 400.0, 300.0);
let overlay = FigureOverlay { hover_px: Some((200.0, 150.0)), brush: None, focus: None };
let spec = ExportSpec { width_px: 400, height_px: 300, dpr: 1.0, background: None };
let result = render_to_png(&spec, |ctx| {
figure.render_with(ctx, rect, &theme, &overlay);
});
assert!(result.is_ok());
}
#[test]
fn empty_figure_renders_without_panicking() {
use uzor_export::{render_to_png, ExportSpec};
let figure = ScatterFigure::new(Vec::new());
let theme = FigureTheme::dark();
let spec = ExportSpec { width_px: 200, height_px: 150, dpr: 1.0, background: None };
let result = render_to_png(&spec, |ctx| {
figure.render(ctx, Rect::new(0.0, 0.0, 200.0, 150.0), &theme);
});
assert!(result.is_ok());
}
#[test]
fn default_policies_match_the_pre_existing_constants() {
let figure = ScatterFigure::new(Vec::new());
assert_eq!(figure.margin_policy, MarginPolicy::Measured);
assert_eq!(figure.x_tick_policy, TickCountPolicy::Fixed(TARGET_X_TICKS));
assert_eq!(figure.y_tick_policy, TickCountPolicy::Fixed(TARGET_Y_TICKS));
}
#[test]
fn measured_margin_widens_for_a_deliberately_wide_y_label() {
use uzor_export::{render_to_png, ExportSpec};
let points = vec![ScatterPoint::new(0.0, 1.0), ScatterPoint::new(1.0, 999_999_999.0)];
let theme = FigureTheme::dark();
let spec = ExportSpec { width_px: 300, height_px: 200, dpr: 1.0, background: None };
let rect = Rect::new(0.0, 0.0, 300.0, 200.0);
let fixed = ScatterFigure::new(points.clone()).with_margin_policy(MarginPolicy::Fixed);
let measured = ScatterFigure::new(points).with_margin_policy(MarginPolicy::Measured);
let fixed_png = render_to_png(&spec, |ctx| fixed.render(ctx, rect, &theme)).expect("fixed render");
let measured_png = render_to_png(&spec, |ctx| measured.render(ctx, rect, &theme)).expect("measured render");
assert_ne!(fixed_png, measured_png, "Measured must render differently once a wide Y label would otherwise clip under Fixed");
}
#[test]
fn measured_margin_grows_the_right_margin_to_protect_the_rightmost_x_label_from_clipping() {
use uzor_export::{render_to_png, ExportSpec};
let points = vec![ScatterPoint::new(0.0, 1.0), ScatterPoint::new(1.0, 5.0), ScatterPoint::new(2.0, 3.0), ScatterPoint::new(3.0, 8.0)];
let theme = FigureTheme::dark();
let spec = ExportSpec { width_px: 300, height_px: 200, dpr: 1.0, background: None };
let rect = Rect::new(0.0, 0.0, 300.0, 200.0);
let fixed = ScatterFigure::new(points.clone()).with_margin_policy(MarginPolicy::Fixed);
let measured = ScatterFigure::new(points).with_margin_policy(MarginPolicy::Measured);
let fixed_png = render_to_png(&spec, |ctx| fixed.render(ctx, rect, &theme)).expect("fixed render");
let measured_png = render_to_png(&spec, |ctx| measured.render(ctx, rect, &theme)).expect("measured render");
assert_ne!(
fixed_png, measured_png,
"Measured must widen the right margin to protect the rightmost X label from the clipping Fixed's own tiny MARGIN_RIGHT allows"
);
}
#[test]
fn adaptive_tick_policy_renders_without_panicking() {
use uzor_export::{render_to_png, ExportSpec};
let points: Vec<ScatterPoint> = (0..40).map(|i| ScatterPoint::new(i as f64, ((i * 13) % 29) as f64)).collect();
let figure = ScatterFigure::new(points)
.with_x_tick_policy(TickCountPolicy::Adaptive { min: 2, max: 25 })
.with_y_tick_policy(TickCountPolicy::Adaptive { min: 2, max: 25 });
let theme = FigureTheme::dark();
let spec = ExportSpec { width_px: 500, height_px: 300, dpr: 1.0, background: None };
let result = render_to_png(&spec, |ctx| figure.render(ctx, Rect::new(0.0, 0.0, 500.0, 300.0), &theme));
assert!(result.is_ok());
}
}