use crate::axes::AxisScale;
use crate::core::error::Result;
use crate::core::transform::CoordinateTransform;
use crate::core::units::RenderScale;
use crate::render::{Color, LineStyle, MarkerStyle, SkiaRenderer, Theme};
#[derive(Debug, Clone, Copy)]
pub struct PlotArea {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
pub x_min: f64,
pub x_max: f64,
pub y_min: f64,
pub y_max: f64,
pub x_scale: AxisScale,
pub y_scale: AxisScale,
}
impl PlotArea {
pub fn new(
x: f32,
y: f32,
width: f32,
height: f32,
x_min: f64,
x_max: f64,
y_min: f64,
y_max: f64,
) -> Self {
Self {
x,
y,
width,
height,
x_min,
x_max,
y_min,
y_max,
x_scale: AxisScale::Linear,
y_scale: AxisScale::Linear,
}
}
#[inline]
#[must_use]
pub fn with_scales(mut self, x_scale: AxisScale, y_scale: AxisScale) -> Self {
self.x_scale = x_scale;
self.y_scale = y_scale;
self
}
#[inline]
pub fn to_transform(self) -> CoordinateTransform {
CoordinateTransform::from_plot_area(
self.x,
self.y,
self.width,
self.height,
self.x_min,
self.x_max,
self.y_min,
self.y_max,
)
}
#[inline]
pub fn data_to_screen(&self, data_x: f64, data_y: f64) -> (f32, f32) {
self.to_transform()
.data_to_screen_scaled(data_x, data_y, &self.x_scale, &self.y_scale)
}
#[inline]
pub fn try_data_to_screen(&self, data_x: f64, data_y: f64) -> Option<(f32, f32)> {
self.to_transform()
.try_data_to_screen_scaled(data_x, data_y, &self.x_scale, &self.y_scale)
}
#[inline]
pub fn edge_data_to_screen(&self, data_x: f64, data_y: f64) -> (f32, f32) {
fn pinned(scale: &AxisScale, value: f64, min: f64, max: f64) -> f64 {
if scale.is_valid_value(value) {
value
} else {
min.min(max)
}
}
self.data_to_screen(
pinned(&self.x_scale, data_x, self.x_min, self.x_max),
pinned(&self.y_scale, data_y, self.y_min, self.y_max),
)
}
pub fn project_points<I>(&self, points: I) -> Vec<(f32, f32)>
where
I: IntoIterator<Item = (f64, f64)>,
{
points
.into_iter()
.filter_map(|(x, y)| self.try_data_to_screen(x, y))
.collect()
}
pub fn project_subpaths<I>(&self, points: I) -> Vec<Vec<(f32, f32)>>
where
I: IntoIterator<Item = (f64, f64)>,
{
let mut runs: Vec<Vec<(f32, f32)>> = Vec::new();
let mut current: Vec<(f32, f32)> = Vec::new();
for (x, y) in points {
match self.try_data_to_screen(x, y) {
Some(point) => current.push(point),
None => {
if !current.is_empty() {
runs.push(std::mem::take(&mut current));
}
}
}
}
if !current.is_empty() {
runs.push(current);
}
runs
}
#[inline]
pub fn fill_baseline_y(&self) -> f32 {
if self.y_scale.is_valid_value(0.0) {
self.to_transform()
.data_to_screen_scaled(self.x_min, 0.0, &AxisScale::Linear, &self.y_scale)
.1
} else {
self.y + self.height
}
}
#[inline]
pub fn screen_to_data(&self, screen_x: f32, screen_y: f32) -> (f64, f64) {
self.to_transform()
.screen_to_data_scaled(screen_x, screen_y, &self.x_scale, &self.y_scale)
}
#[inline]
pub fn contains_data(&self, data_x: f64, data_y: f64) -> bool {
self.to_transform().contains_data(data_x, data_y)
}
pub fn center(&self) -> (f32, f32) {
self.to_transform().screen_center()
}
pub fn data_center(&self) -> (f64, f64) {
self.to_transform().data_center()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AxisScaleSupport {
Scaled,
Independent,
Unsupported(&'static str),
}
impl AxisScaleSupport {
pub const ORDINAL: Self = Self::Unsupported(
"its categories sit at ordinal positions, which carry no quantitative spacing",
);
pub fn accepts(&self, scale: &AxisScale) -> bool {
match self {
Self::Scaled | Self::Independent => true,
Self::Unsupported(_) => matches!(scale, AxisScale::Linear),
}
}
pub fn rejection_reason(&self) -> Option<&'static str> {
match self {
Self::Scaled | Self::Independent => None,
Self::Unsupported(reason) => Some(reason),
}
}
}
pub trait PlotConfig: Default + Clone {}
pub trait PlotCompute {
type Input<'a>;
type Config: PlotConfig;
type Output: PlotData;
fn compute(input: Self::Input<'_>, config: &Self::Config) -> Result<Self::Output>;
}
pub trait PlotData {
fn data_bounds(&self) -> ((f64, f64), (f64, f64));
fn is_empty(&self) -> bool;
}
pub trait PlotRender: PlotData {
fn render(
&self,
renderer: &mut SkiaRenderer,
area: &PlotArea,
theme: &Theme,
color: Color,
) -> Result<()>;
fn render_styled(
&self,
renderer: &mut SkiaRenderer,
area: &PlotArea,
theme: &Theme,
color: Color,
_alpha: f32,
_line_width: Option<f32>,
) -> Result<()> {
self.render(renderer, area, theme, color)
}
fn render_styled_with_grid(
&self,
renderer: &mut SkiaRenderer,
area: &PlotArea,
theme: &Theme,
color: Color,
alpha: f32,
line_width: Option<f32>,
_grid_style: Option<&crate::core::GridStyle>,
) -> Result<()> {
self.render_styled(renderer, area, theme, color, alpha, line_width)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum PlotPrimitive {
Line {
from: (f32, f32),
to: (f32, f32),
color: Color,
width_px: f32,
style: LineStyle,
},
Polygon {
points: Vec<(f32, f32)>,
fill: Option<Color>,
edge: Option<(Color, f32)>,
},
Marker {
at: (f32, f32),
size_px: f32,
style: MarkerStyle,
color: Color,
},
}
#[derive(Debug, Clone, Copy)]
pub struct ComputedStyle {
pub scale: RenderScale,
pub color: Color,
pub alpha: f32,
pub line_width: Option<f32>,
}
impl ComputedStyle {
pub fn opaque(scale: RenderScale, color: Color) -> Self {
Self {
scale,
color,
alpha: 1.0,
line_width: None,
}
}
pub fn tinted(&self, base: Color) -> Color {
base.with_alpha((f32::from(base.a) / 255.0) * self.alpha.clamp(0.0, 1.0))
}
pub fn stroke_px(&self, fallback_points: f32) -> f32 {
self.scale
.points_to_pixels(self.line_width.unwrap_or(fallback_points))
}
}
pub fn draw_primitives(renderer: &mut SkiaRenderer, primitives: &[PlotPrimitive]) -> Result<()> {
for primitive in primitives {
match primitive {
PlotPrimitive::Line {
from,
to,
color,
width_px,
style,
} => {
renderer.draw_line(from.0, from.1, to.0, to.1, *color, *width_px, style.clone())?;
}
PlotPrimitive::Polygon { points, fill, edge } => {
if let Some(fill) = fill {
renderer.draw_filled_polygon(points, *fill)?;
}
if let Some((color, width_px)) = edge {
renderer.draw_polygon_outline(points, *color, *width_px)?;
}
}
PlotPrimitive::Marker {
at,
size_px,
style,
color,
} => {
renderer.draw_marker(at.0, at.1, *size_px, *style, *color)?;
}
}
}
Ok(())
}
pub fn draw_primitives_svg(svg: &mut crate::export::SvgRenderer, primitives: &[PlotPrimitive]) {
for primitive in primitives {
match primitive {
PlotPrimitive::Line {
from,
to,
color,
width_px,
style,
} => {
svg.draw_line(from.0, from.1, to.0, to.1, *color, *width_px, style.clone());
}
PlotPrimitive::Polygon { points, fill, edge } => {
if let Some(fill) = fill {
svg.draw_filled_polygon(points, *fill);
}
if let Some((color, width_px)) = edge {
svg.draw_polygon_outline(points, *color, *width_px);
}
}
PlotPrimitive::Marker {
at,
size_px,
style,
color,
} => {
svg.draw_marker(at.0, at.1, *size_px, *style, *color);
}
}
}
}
pub trait ComputedSeries: PlotRender + std::fmt::Debug + Send + Sync {
fn kind(&self) -> &'static str;
fn primitives(&self, area: &PlotArea, style: &ComputedStyle) -> Vec<PlotPrimitive>;
fn axis_scale_support(&self) -> (AxisScaleSupport, AxisScaleSupport) {
(AxisScaleSupport::Scaled, AxisScaleSupport::Scaled)
}
fn point_count(&self) -> usize;
fn category_slots(&self) -> Vec<(String, f64)> {
Vec::new()
}
fn legend_key(&self) -> LegendKey {
LegendKey::Line
}
fn colorbar(
&self,
_theme: &crate::render::Theme,
) -> Option<crate::render::colorbar::ColorbarRequest> {
None
}
fn pins_zero_baseline(&self) -> bool {
false
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum LegendKey {
#[default]
Line,
Marker,
Patch,
None,
}
pub trait StyledShape {
fn fill_color(&self) -> Color;
fn edge_color(&self) -> Option<Color>;
fn edge_width(&self) -> f32;
fn alpha(&self) -> f32;
fn resolved_edge_color(&self) -> Color {
self.edge_color()
.unwrap_or_else(|| self.fill_color().darken(0.3))
}
fn fill_color_with_alpha(&self) -> Color {
self.fill_color().with_alpha(self.alpha())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plot_area_matches_the_scaled_pixel_mapper() {
let rect = tiny_skia::Rect::from_xywh(24.0, 12.0, 480.0, 360.0).unwrap();
let area = PlotArea::new(24.0, 12.0, 480.0, 360.0, 1.0, 1000.0, 0.5, 200.0)
.with_scales(AxisScale::Log, AxisScale::Log);
for (x, y) in [(1.0, 0.5), (10.0, 7.0), (250.0, 199.0), (1000.0, 200.0)] {
let (ax, ay) = area.data_to_screen(x, y);
let (mx, my) = crate::render::skia::map_data_to_pixels_scaled(
x,
y,
1.0,
1000.0,
0.5,
200.0,
rect,
&AxisScale::Log,
&AxisScale::Log,
);
assert!(
(ax - mx).abs() < 1e-4,
"x differs at ({x}, {y}): {ax} vs {mx}"
);
assert!(
(ay - my).abs() < 1e-4,
"y differs at ({x}, {y}): {ay} vs {my}"
);
}
}
#[test]
fn plot_area_defaults_to_a_linear_projection() {
let area = PlotArea::new(0.0, 0.0, 100.0, 100.0, 0.0, 10.0, 0.0, 10.0);
assert_eq!(area.x_scale, AxisScale::Linear);
assert_eq!(area.y_scale, AxisScale::Linear);
let (x, _) = area.data_to_screen(5.0, 5.0);
assert!((x - 50.0).abs() < 1e-4);
}
#[test]
fn plot_area_rejects_samples_a_log_axis_cannot_place() {
let area = PlotArea::new(0.0, 0.0, 100.0, 100.0, 1.0, 100.0, 1.0, 100.0)
.with_scales(AxisScale::Log, AxisScale::Log);
assert!(area.try_data_to_screen(10.0, 10.0).is_some());
assert!(area.try_data_to_screen(0.0, 10.0).is_none());
assert!(area.try_data_to_screen(10.0, -1.0).is_none());
assert!(area.try_data_to_screen(f64::NAN, 10.0).is_none());
}
#[test]
fn plot_area_splits_a_run_at_every_unplaceable_point() {
let area = PlotArea::new(0.0, 0.0, 100.0, 100.0, 1.0, 100.0, 1.0, 100.0)
.with_scales(AxisScale::Log, AxisScale::Linear);
let runs = area.project_subpaths([
(1.0, 1.0),
(2.0, 2.0),
(0.0, 3.0),
(4.0, 4.0),
(-1.0, 5.0),
(6.0, 6.0),
(7.0, 7.0),
]);
assert_eq!(runs.len(), 3);
assert_eq!(runs[0].len(), 2);
assert_eq!(runs[1].len(), 1);
assert_eq!(runs[2].len(), 2);
let points = area.project_points([(1.0, 1.0), (0.0, 3.0), (4.0, 4.0)]);
assert_eq!(points.len(), 2);
}
#[test]
fn plot_area_fill_baseline_falls_back_to_the_axis_floor_on_a_log_axis() {
let linear = PlotArea::new(0.0, 10.0, 100.0, 200.0, 0.0, 10.0, 0.0, 100.0);
assert!(
(linear.fill_baseline_y() - 210.0).abs() < 1e-4,
"zero is on the axis"
);
let log = PlotArea::new(0.0, 10.0, 100.0, 200.0, 0.0, 10.0, 1.0, 100.0)
.with_scales(AxisScale::Linear, AxisScale::Log);
assert!(
(log.fill_baseline_y() - 210.0).abs() < 1e-4,
"a log axis has no zero, so the fill bottoms out on its floor"
);
}
#[test]
fn plot_area_pins_an_unplaceable_edge_to_the_axis_floor() {
let area = PlotArea::new(20.0, 0.0, 100.0, 100.0, 1.0, 100.0, 1.0, 100.0)
.with_scales(AxisScale::Log, AxisScale::Linear);
let (x, _) = area.edge_data_to_screen(0.0, 50.0);
assert!((x - 20.0).abs() < 1e-4, "expected the axis floor, got {x}");
assert!(area.data_to_screen(0.0, 50.0).0.is_nan());
}
#[test]
fn test_plot_area_creation() {
let area = PlotArea::new(100.0, 50.0, 600.0, 400.0, 0.0, 10.0, 0.0, 100.0);
assert_eq!(area.x, 100.0);
assert_eq!(area.y, 50.0);
assert_eq!(area.width, 600.0);
assert_eq!(area.height, 400.0);
assert_eq!(area.x_min, 0.0);
assert_eq!(area.x_max, 10.0);
assert_eq!(area.y_min, 0.0);
assert_eq!(area.y_max, 100.0);
}
#[test]
fn test_plot_area_data_to_screen() {
let area = PlotArea::new(100.0, 50.0, 600.0, 400.0, 0.0, 10.0, 0.0, 100.0);
let (sx, sy) = area.data_to_screen(0.0, 0.0);
assert!((sx - 100.0).abs() < 0.01);
assert!((sy - 450.0).abs() < 0.01);
let (sx, sy) = area.data_to_screen(10.0, 100.0);
assert!((sx - 700.0).abs() < 0.01); assert!((sy - 50.0).abs() < 0.01);
let (sx, sy) = area.data_to_screen(5.0, 50.0);
assert!((sx - 400.0).abs() < 0.01); assert!((sy - 250.0).abs() < 0.01); }
#[test]
fn test_plot_area_screen_to_data() {
let area = PlotArea::new(100.0, 50.0, 600.0, 400.0, 0.0, 10.0, 0.0, 100.0);
let (data_x, data_y) = (5.0, 50.0);
let (sx, sy) = area.data_to_screen(data_x, data_y);
let (rx, ry) = area.screen_to_data(sx, sy);
assert!((rx - data_x).abs() < 0.01);
assert!((ry - data_y).abs() < 0.01);
}
#[test]
fn test_plot_area_contains_data() {
let area = PlotArea::new(100.0, 50.0, 600.0, 400.0, 0.0, 10.0, 0.0, 100.0);
assert!(area.contains_data(5.0, 50.0)); assert!(area.contains_data(0.0, 0.0)); assert!(area.contains_data(10.0, 100.0)); assert!(!area.contains_data(-1.0, 50.0)); assert!(!area.contains_data(5.0, 150.0)); }
#[test]
fn test_plot_area_center() {
let area = PlotArea::new(100.0, 50.0, 600.0, 400.0, 0.0, 10.0, 0.0, 100.0);
let (cx, cy) = area.center();
assert!((cx - 400.0).abs() < 0.01);
assert!((cy - 250.0).abs() < 0.01);
let (dx, dy) = area.data_center();
assert!((dx - 5.0).abs() < 0.01);
assert!((dy - 50.0).abs() < 0.01);
}
#[test]
fn test_plot_area_zero_range() {
let area = PlotArea::new(100.0, 50.0, 600.0, 400.0, 5.0, 5.0, 50.0, 50.0);
let (sx, sy) = area.data_to_screen(5.0, 50.0);
assert!((sx - 400.0).abs() < 0.01); assert!((sy - 250.0).abs() < 0.01); }
struct TestShape {
fill: Color,
edge: Option<Color>,
edge_width: f32,
alpha: f32,
}
impl StyledShape for TestShape {
fn fill_color(&self) -> Color {
self.fill
}
fn edge_color(&self) -> Option<Color> {
self.edge
}
fn edge_width(&self) -> f32 {
self.edge_width
}
fn alpha(&self) -> f32 {
self.alpha
}
}
#[test]
fn test_styled_shape_explicit_edge() {
let shape = TestShape {
fill: Color::BLUE,
edge: Some(Color::RED),
edge_width: 1.5,
alpha: 0.8,
};
assert_eq!(shape.fill_color(), Color::BLUE);
assert_eq!(shape.edge_color(), Some(Color::RED));
assert_eq!(shape.resolved_edge_color(), Color::RED);
assert_eq!(shape.edge_width(), 1.5);
assert_eq!(shape.alpha(), 0.8);
}
#[test]
fn test_styled_shape_auto_edge() {
let shape = TestShape {
fill: Color::from_rgb(100, 150, 200),
edge: None,
edge_width: 0.8,
alpha: 1.0,
};
let edge = shape.resolved_edge_color();
assert_eq!(edge.r, 70); assert_eq!(edge.g, 105); assert_eq!(edge.b, 140); }
#[test]
fn test_axis_scale_support_only_refuses_non_linear_scales() {
let unsupported = AxisScaleSupport::Unsupported("positions bars by category index");
assert!(unsupported.accepts(&AxisScale::Linear));
assert!(!unsupported.accepts(&AxisScale::Log));
assert!(!unsupported.accepts(&AxisScale::SymLog { linthresh: 1.0 }));
for support in [AxisScaleSupport::Scaled, AxisScaleSupport::Independent] {
assert!(support.accepts(&AxisScale::Linear));
assert!(support.accepts(&AxisScale::Log));
assert!(support.accepts(&AxisScale::SymLog { linthresh: 1.0 }));
}
}
#[test]
fn test_axis_scale_support_carries_a_reason_only_when_it_refuses() {
assert_eq!(
AxisScaleSupport::Unsupported("because").rejection_reason(),
Some("because")
);
assert_eq!(AxisScaleSupport::Scaled.rejection_reason(), None);
assert_eq!(AxisScaleSupport::Independent.rejection_reason(), None);
}
#[test]
fn test_styled_shape_fill_with_alpha() {
let shape = TestShape {
fill: Color::from_rgb(100, 150, 200),
edge: None,
edge_width: 0.8,
alpha: 0.5,
};
let fill_with_alpha = shape.fill_color_with_alpha();
assert_eq!(fill_with_alpha.r, 100);
assert_eq!(fill_with_alpha.g, 150);
assert_eq!(fill_with_alpha.b, 200);
assert_eq!(fill_with_alpha.a, 127); }
}