use super::*;
use crate::core::types::Point2f;
use crate::plots::{PlotArea, heatmap::HeatmapData};
use crate::render::{Color, LineStyle, MarkerStyle, skia::SkiaRenderer};
use std::sync::Arc;
pub(super) type ClipRect = (f32, f32, f32, f32);
#[derive(Debug, Clone)]
pub(super) struct PolylineBatch {
points: Arc<[Point2f]>,
color: Color,
line_width: f32,
line_style: LineStyle,
clip_rect: ClipRect,
}
impl PolylineBatch {
pub(super) fn new(
points: Arc<[Point2f]>,
color: Color,
line_width: f32,
line_style: LineStyle,
clip_rect: ClipRect,
) -> Self {
Self {
points,
color,
line_width,
line_style,
clip_rect,
}
}
fn execute(&self, renderer: &mut SkiaRenderer) -> Result<()> {
renderer.draw_polyline_points_clipped(
self.points.as_ref(),
self.color,
self.line_width,
self.line_style.clone(),
self.clip_rect,
)
}
}
#[derive(Debug, Clone)]
pub(super) struct MarkerBatch {
points: Arc<[Point2f]>,
size: f32,
style: MarkerStyle,
color: Color,
edge: Option<(Color, f32)>,
clip_rect: ClipRect,
}
impl MarkerBatch {
pub(super) fn new(
points: Arc<[Point2f]>,
size: f32,
style: MarkerStyle,
color: Color,
edge: Option<(Color, f32)>,
clip_rect: ClipRect,
) -> Self {
Self {
points,
size,
style,
color,
edge,
clip_rect,
}
}
fn execute(&self, renderer: &mut SkiaRenderer) -> Result<()> {
renderer.draw_markers_styled_clipped(
self.points.as_ref(),
self.size,
self.style,
self.color,
self.edge,
self.clip_rect,
)
}
}
#[derive(Debug, Clone)]
pub(super) struct RectGridBatch {
x_edges: Arc<[i32]>,
y_edges: Arc<[i32]>,
colors: Arc<[Option<Color>]>,
n_rows: usize,
n_cols: usize,
cell_borders: bool,
}
impl RectGridBatch {
pub(super) fn from_heatmap_data(
data: &HeatmapData,
area: PlotArea,
alpha: f32,
) -> Option<Self> {
if !data.can_use_pixel_aligned_grid_fast_path(alpha) {
return None;
}
let (x_edges, y_edges) = data.pixel_aligned_screen_edges(&area);
let colors = data
.values
.iter()
.flat_map(|row| row.iter())
.map(|&value| {
(!data.should_mask_value(value)).then(|| data.get_color(value).with_alpha(alpha))
})
.collect::<Vec<_>>();
Some(Self {
x_edges: x_edges.into(),
y_edges: y_edges.into(),
colors: colors.into(),
n_rows: data.n_rows,
n_cols: data.n_cols,
cell_borders: data.config.cell_borders,
})
}
fn execute(&self, renderer: &mut SkiaRenderer) -> Result<()> {
for row in 0..self.n_rows {
let top = self.y_edges[row].min(self.y_edges[row + 1]);
let bottom = self.y_edges[row].max(self.y_edges[row + 1]);
if bottom <= top {
continue;
}
for col in 0..self.n_cols {
let Some(cell_color) = self.colors[row * self.n_cols + col] else {
continue;
};
let left = self.x_edges[col].min(self.x_edges[col + 1]);
let right = self.x_edges[col].max(self.x_edges[col + 1]);
if right <= left {
continue;
}
let x = left as f32;
let y = top as f32;
let width = (right - left) as f32;
let height = (bottom - top) as f32;
renderer.draw_pixel_aligned_solid_rectangle(x, y, width, height, cell_color)?;
if self.cell_borders {
renderer.draw_pixel_aligned_rectangle_outline(
x,
y,
width,
height,
cell_color.darken(0.2),
)?;
}
}
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub(super) enum StaticRasterBatch {
Polyline(PolylineBatch),
Markers(MarkerBatch),
RectGrid(RectGridBatch),
}
impl StaticRasterBatch {
fn execute(&self, renderer: &mut SkiaRenderer) -> Result<()> {
match self {
Self::Polyline(batch) => batch.execute(renderer),
Self::Markers(batch) => batch.execute(renderer),
Self::RectGrid(batch) => batch.execute(renderer),
}
}
}
#[derive(Debug, Clone, Default)]
pub(super) struct SeriesRasterPlan {
batches: Vec<StaticRasterBatch>,
used_exact_line_canonicalization: bool,
used_raster_line_reduction: bool,
}
impl SeriesRasterPlan {
pub(super) fn push_polyline(
&mut self,
points: Arc<[Point2f]>,
color: Color,
line_width: f32,
line_style: LineStyle,
clip_rect: ClipRect,
) {
self.batches
.push(StaticRasterBatch::Polyline(PolylineBatch::new(
points, color, line_width, line_style, clip_rect,
)));
}
pub(super) fn push_markers(
&mut self,
points: Arc<[Point2f]>,
size: f32,
style: MarkerStyle,
color: Color,
edge: Option<(Color, f32)>,
clip_rect: ClipRect,
) {
self.batches
.push(StaticRasterBatch::Markers(MarkerBatch::new(
points, size, style, color, edge, clip_rect,
)));
}
pub(super) fn push_rect_grid(&mut self, batch: RectGridBatch) {
self.batches.push(StaticRasterBatch::RectGrid(batch));
}
pub(super) fn note_exact_line_canonicalization(&mut self) {
self.used_exact_line_canonicalization = true;
}
pub(super) fn note_raster_line_reduction(&mut self) {
self.used_raster_line_reduction = true;
}
pub(super) fn execute(&self, renderer: &mut SkiaRenderer) -> Result<()> {
if self.used_exact_line_canonicalization {
renderer.note_exact_line_canonicalization();
}
if self.used_raster_line_reduction {
renderer.note_raster_line_reduction();
}
for batch in &self.batches {
batch.execute(renderer)?;
}
Ok(())
}
}
pub(super) fn clip_rect_from_plot_area(plot_area: tiny_skia::Rect) -> ClipRect {
(
plot_area.x(),
plot_area.y(),
plot_area.width(),
plot_area.height(),
)
}
pub(super) fn sample_is_representable(
x: f64,
y: f64,
x_scale: &crate::axes::AxisScale,
y_scale: &crate::axes::AxisScale,
) -> bool {
x_scale.is_valid_value(x) && y_scale.is_valid_value(y)
}
pub(super) fn representable_sample_runs(
x_data: &[f64],
y_data: &[f64],
x_scale: &crate::axes::AxisScale,
y_scale: &crate::axes::AxisScale,
) -> Vec<std::ops::Range<usize>> {
let len = x_data.len().min(y_data.len());
let mut runs = Vec::new();
let mut start = None;
for index in 0..len {
if sample_is_representable(x_data[index], y_data[index], x_scale, y_scale) {
start.get_or_insert(index);
} else if let Some(run_start) = start.take() {
runs.push(run_start..index);
}
}
if let Some(run_start) = start {
runs.push(run_start..len);
}
runs
}
fn all_samples_representable(
x_data: &[f64],
y_data: &[f64],
x_scale: &crate::axes::AxisScale,
y_scale: &crate::axes::AxisScale,
) -> bool {
x_data
.iter()
.zip(y_data.iter())
.all(|(&x, &y)| sample_is_representable(x, y, x_scale, y_scale))
}
pub(super) fn project_xy_points(
x_data: &[f64],
y_data: &[f64],
x_min: f64,
x_max: f64,
y_min: f64,
y_max: f64,
plot_area: tiny_skia::Rect,
x_scale: &crate::axes::AxisScale,
y_scale: &crate::axes::AxisScale,
) -> Arc<[Point2f]> {
let projected = project_xy_points_unchecked(
x_data, y_data, x_min, x_max, y_min, y_max, plot_area, x_scale, y_scale,
);
if all_samples_representable(x_data, y_data, x_scale, y_scale) {
return projected;
}
x_data
.iter()
.zip(y_data.iter())
.zip(projected.iter())
.filter(|&((&x, &y), _)| sample_is_representable(x, y, x_scale, y_scale))
.map(|(_, point)| *point)
.collect::<Vec<_>>()
.into()
}
pub(super) fn project_xy_subpaths(
x_data: &[f64],
y_data: &[f64],
x_min: f64,
x_max: f64,
y_min: f64,
y_max: f64,
plot_area: tiny_skia::Rect,
x_scale: &crate::axes::AxisScale,
y_scale: &crate::axes::AxisScale,
) -> Vec<Arc<[Point2f]>> {
let projected = project_xy_points_unchecked(
x_data, y_data, x_min, x_max, y_min, y_max, plot_area, x_scale, y_scale,
);
if all_samples_representable(x_data, y_data, x_scale, y_scale) {
return if projected.is_empty() {
Vec::new()
} else {
vec![projected]
};
}
representable_sample_runs(x_data, y_data, x_scale, y_scale)
.into_iter()
.map(|run| Arc::from(&projected[run]))
.collect()
}
fn project_xy_points_unchecked(
x_data: &[f64],
y_data: &[f64],
x_min: f64,
x_max: f64,
y_min: f64,
y_max: f64,
plot_area: tiny_skia::Rect,
x_scale: &crate::axes::AxisScale,
y_scale: &crate::axes::AxisScale,
) -> Arc<[Point2f]> {
if matches!(x_scale, crate::axes::AxisScale::Linear)
&& matches!(y_scale, crate::axes::AxisScale::Linear)
{
return project_linear_xy_points(x_data, y_data, x_min, x_max, y_min, y_max, plot_area);
}
project_scaled_xy_points(
x_data, y_data, x_min, x_max, y_min, y_max, plot_area, x_scale, y_scale,
)
}
fn project_linear_xy_points(
x_data: &[f64],
y_data: &[f64],
x_min: f64,
x_max: f64,
y_min: f64,
y_max: f64,
plot_area: tiny_skia::Rect,
) -> Arc<[Point2f]> {
let x_range = x_max - x_min;
let y_range = y_max - y_min;
let x_is_degenerate = crate::axes::scale::linear_range_is_degenerate(x_range);
let y_is_degenerate = crate::axes::scale::linear_range_is_degenerate(y_range);
let left = plot_area.left();
let bottom = plot_area.bottom();
let width = plot_area.width();
let height = plot_area.height();
x_data
.iter()
.zip(y_data.iter())
.map(|(&x, &y)| {
let normalized_x = if x_is_degenerate {
0.5
} else {
crate::axes::scale::linear_normalized_position_with_range(x, x_min, x_max, x_range)
};
let normalized_y = if y_is_degenerate {
0.5
} else {
crate::axes::scale::linear_normalized_position_with_range(y, y_min, y_max, y_range)
};
Point2f::new(
left + normalized_x as f32 * width,
bottom - normalized_y as f32 * height,
)
})
.collect::<Vec<_>>()
.into()
}
fn project_scaled_xy_points(
x_data: &[f64],
y_data: &[f64],
x_min: f64,
x_max: f64,
y_min: f64,
y_max: f64,
plot_area: tiny_skia::Rect,
x_scale: &crate::axes::AxisScale,
y_scale: &crate::axes::AxisScale,
) -> Arc<[Point2f]> {
let transform = crate::core::CoordinateTransform::from_plot_area(
plot_area.left(),
plot_area.top(),
plot_area.width(),
plot_area.height(),
x_min,
x_max,
y_min,
y_max,
);
x_data
.iter()
.zip(y_data.iter())
.map(|(&x, &y)| {
let (px, py) = transform.data_to_screen_scaled(x, y, x_scale, y_scale);
Point2f::new(px, py)
})
.collect::<Vec<_>>()
.into()
}
pub(super) fn plot_area_from_rect(
plot_area: tiny_skia::Rect,
x_min: f64,
x_max: f64,
y_min: f64,
y_max: f64,
x_scale: &crate::axes::AxisScale,
y_scale: &crate::axes::AxisScale,
) -> PlotArea {
PlotArea::new(
plot_area.x(),
plot_area.y(),
plot_area.width(),
plot_area.height(),
x_min,
x_max,
y_min,
y_max,
)
.with_scales(*x_scale, *y_scale)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::axes::AxisScale;
#[test]
fn test_linear_projection_fast_path_matches_scaled_mapper() {
let plot_area = tiny_skia::Rect::from_xywh(73.5, 41.25, 503.75, 318.5);
assert!(plot_area.is_some(), "test rectangle should be valid");
let Some(plot_area) = plot_area else {
return;
};
let x_data = [-2.0, -0.5, 0.0, 1.25, 4.0, 7.5];
let y_data = [8.0, 1.5, -2.0, 0.25, 3.0, 5.5];
let points = project_xy_points(
&x_data,
&y_data,
-2.0,
7.5,
-2.0,
8.0,
plot_area,
&AxisScale::Linear,
&AxisScale::Linear,
);
let expected = x_data
.iter()
.zip(y_data.iter())
.map(|(&x, &y)| {
let (px, py) = crate::render::skia::map_data_to_pixels_scaled(
x,
y,
-2.0,
7.5,
-2.0,
8.0,
plot_area,
&AxisScale::Linear,
&AxisScale::Linear,
);
Point2f::new(px, py)
})
.collect::<Vec<_>>();
assert_eq!(points.as_ref(), expected.as_slice());
}
#[test]
fn test_linear_projection_fast_path_matches_degenerate_axis_mapper() {
let plot_area = tiny_skia::Rect::from_xywh(12.0, 18.0, 320.0, 240.0);
assert!(plot_area.is_some(), "test rectangle should be valid");
let Some(plot_area) = plot_area else {
return;
};
let x_data = [1.0, 1.0, 1.0];
let y_data = [-1.0, 0.0, 1.0];
let points = project_xy_points(
&x_data,
&y_data,
1.0,
1.0,
-1.0,
1.0,
plot_area,
&AxisScale::Linear,
&AxisScale::Linear,
);
let expected = x_data
.iter()
.zip(y_data.iter())
.map(|(&x, &y)| {
let (px, py) = crate::render::skia::map_data_to_pixels_scaled(
x,
y,
1.0,
1.0,
-1.0,
1.0,
plot_area,
&AxisScale::Linear,
&AxisScale::Linear,
);
Point2f::new(px, py)
})
.collect::<Vec<_>>();
assert_eq!(points.as_ref(), expected.as_slice());
}
#[test]
fn test_scaled_projection_uses_core_transform_with_reversed_ranges() {
let plot_area = tiny_skia::Rect::from_xywh(20.0, 30.0, 600.0, 400.0);
assert!(plot_area.is_some(), "test rectangle should be valid");
let Some(plot_area) = plot_area else {
return;
};
let x_data = [100.0, 10.0, 1.0];
let y_data = [100.0, 0.0, -100.0];
let points = project_xy_points(
&x_data,
&y_data,
100.0,
1.0,
100.0,
-100.0,
plot_area,
&AxisScale::Log,
&AxisScale::symlog(1.0),
);
let expected = [
Point2f::new(20.0, 430.0),
Point2f::new(320.0, 230.0),
Point2f::new(620.0, 30.0),
];
assert_eq!(points.as_ref(), expected.as_slice());
}
#[test]
fn test_linear_projection_fast_path_uses_shared_epsilon_and_extreme_range_rules() {
let plot_area = tiny_skia::Rect::from_xywh(10.0, 20.0, 200.0, 100.0);
assert!(plot_area.is_some(), "test rectangle should be valid");
let Some(plot_area) = plot_area else {
return;
};
let x_data = [0.0, f64::EPSILON / 2.0, f64::EPSILON];
let y_data = [-f64::MAX, 0.0, f64::MAX];
let points = project_xy_points(
&x_data,
&y_data,
0.0,
f64::EPSILON,
-f64::MAX,
f64::MAX,
plot_area,
&AxisScale::Linear,
&AxisScale::Linear,
);
let expected = [
Point2f::new(10.0, 120.0),
Point2f::new(110.0, 70.0),
Point2f::new(210.0, 20.0),
];
assert_eq!(points.as_ref(), expected.as_slice());
}
#[test]
fn test_log_axis_gaps_split_the_polyline_instead_of_joining_across() {
let plot_area = tiny_skia::Rect::from_xywh(0.0, 0.0, 100.0, 100.0);
let Some(plot_area) = plot_area else {
unreachable!("test rectangle should be valid");
};
let x_data = [1.0, 2.0, 3.0, 4.0, 5.0];
let y_data = [1.0, 0.0, 10.0, -5.0, 100.0];
let subpaths = project_xy_subpaths(
&x_data,
&y_data,
1.0,
5.0,
1.0,
100.0,
plot_area,
&AxisScale::Linear,
&AxisScale::Log,
);
assert_eq!(
subpaths.len(),
3,
"each rejected sample must break the line"
);
assert_eq!(subpaths[0].len(), 1);
assert_eq!(subpaths[1].len(), 1);
assert_eq!(subpaths[2].len(), 1);
for subpath in &subpaths {
for point in subpath.iter() {
assert!(
point.x.is_finite() && point.y.is_finite(),
"no sub-path may contain a NaN pixel"
);
}
}
}
#[test]
fn test_representable_runs_cover_leading_trailing_and_interior_gaps() {
let log = AxisScale::Log;
let linear = AxisScale::Linear;
assert_eq!(
representable_sample_runs(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0], &linear, &log),
vec![0..3],
"an all-valid series is one unbroken run"
);
assert_eq!(
representable_sample_runs(&[1.0, 2.0, 3.0], &[0.0, 2.0, 3.0], &linear, &log),
vec![1..3],
"a leading gap must not produce an empty run"
);
assert_eq!(
representable_sample_runs(&[1.0, 2.0, 3.0], &[1.0, 2.0, 0.0], &linear, &log),
vec![0..2],
"a trailing gap must not produce an empty run"
);
assert_eq!(
representable_sample_runs(&[1.0, 2.0, 3.0, 4.0], &[1.0, 0.0, 0.0, 4.0], &linear, &log),
vec![0..1, 3..4],
"adjacent gaps collapse into one break"
);
assert!(
representable_sample_runs(&[1.0, 2.0], &[0.0, -1.0], &linear, &log).is_empty(),
"a wholly unrepresentable series draws nothing"
);
}
#[test]
fn test_non_finite_samples_break_a_linear_polyline() {
assert_eq!(
representable_sample_runs(
&[1.0, 2.0, 3.0],
&[1.0, f64::NAN, 3.0],
&AxisScale::Linear,
&AxisScale::Linear,
),
vec![0..1, 2..3]
);
}
#[test]
fn test_marker_projection_drops_unrepresentable_samples() {
let plot_area = tiny_skia::Rect::from_xywh(0.0, 0.0, 100.0, 100.0);
let Some(plot_area) = plot_area else {
unreachable!("test rectangle should be valid");
};
let x_data = [1.0, 2.0, 3.0];
let y_data = [1.0, 0.0, 100.0];
let points = project_xy_points(
&x_data,
&y_data,
1.0,
3.0,
1.0,
100.0,
plot_area,
&AxisScale::Linear,
&AxisScale::Log,
);
assert_eq!(points.len(), 2, "the log-invalid sample must be dropped");
assert!(points.iter().all(|p| p.x.is_finite() && p.y.is_finite()));
}
}