use crate::{
core::{
ComputedMargins, CoordinateTransform, LayoutRect, Legend, LegendItem, LegendItemType,
LegendPosition, LegendSpacingPixels, LegendStyle, PlottingError, RenderScale, Result,
SpacingConfig, TextPosition, TickFormatter, find_best_position,
plot::{Image, TextEngineMode, TickDirection, TickSides},
pt_to_px,
},
render::{
Color, FontConfig, FontFamily, LineStyle, MarkerStyle, TextRenderer, Theme,
typst_text::{self, TypstBackendKind, TypstTextAnchor},
},
};
use std::borrow::Cow;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use tiny_skia::*;
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
struct ClipMaskKey {
x_bits: u32,
y_bits: u32,
width_bits: u32,
height_bits: u32,
}
impl ClipMaskKey {
fn new((x, y, width, height): (f32, f32, f32, f32)) -> Self {
Self {
x_bits: x.to_bits(),
y_bits: y.to_bits(),
width_bits: width.to_bits(),
height_bits: height.to_bits(),
}
}
}
pub struct SkiaRenderer {
width: u32,
height: u32,
pixmap: Pixmap,
paint: Paint<'static>,
theme: Theme,
text_renderer: TextRenderer,
font_config: FontConfig,
render_scale: RenderScale,
text_engine_mode: TextEngineMode,
clip_mask_cache: HashMap<ClipMaskKey, Arc<Mask>>,
}
impl SkiaRenderer {
pub fn new(width: u32, height: u32, theme: Theme) -> Result<Self> {
Self::with_font_family(width, height, theme, FontFamily::SansSerif)
}
pub fn with_font_family(
width: u32,
height: u32,
theme: Theme,
font_family: FontFamily,
) -> Result<Self> {
let mut pixmap = Pixmap::new(width, height).ok_or(PlottingError::OutOfMemory)?;
let bg_color = theme.background.to_tiny_skia_color();
pixmap.fill(bg_color);
let paint = Paint::default();
let text_renderer = TextRenderer::new();
let font_config = FontConfig::new(font_family, 12.0);
Ok(Self {
width,
height,
pixmap,
paint,
theme,
text_renderer,
font_config,
render_scale: RenderScale::from_canvas_size(width, height, crate::core::REFERENCE_DPI),
text_engine_mode: TextEngineMode::Plain,
clip_mask_cache: HashMap::new(),
})
}
pub fn set_render_scale(&mut self, render_scale: RenderScale) {
self.render_scale = render_scale;
}
pub fn render_scale(&self) -> RenderScale {
self.render_scale
}
pub fn set_dpi_scale(&mut self, dpi_scale: f32) {
self.set_render_scale(RenderScale::from_reference_scale(dpi_scale));
}
pub fn dpi_scale(&self) -> f32 {
self.render_scale.reference_scale()
}
fn points_to_pixels(&self, points: f32) -> f32 {
self.render_scale.points_to_pixels(points)
}
fn logical_pixels_to_pixels(&self, logical_pixels: f32) -> f32 {
self.render_scale.logical_pixels_to_pixels(logical_pixels)
}
fn scaled_dash_pattern(&self, style: &LineStyle) -> Option<Vec<f32>> {
style.to_dash_array().map(|pattern| {
pattern
.into_iter()
.map(|segment| self.logical_pixels_to_pixels(segment))
.collect()
})
}
pub fn set_text_engine_mode(&mut self, mode: TextEngineMode) {
self.text_engine_mode = mode;
}
pub fn text_engine_mode(&self) -> TextEngineMode {
self.text_engine_mode
}
fn typst_size_pt(&self, size_px: f32) -> f32 {
size_px.max(0.1)
}
fn draw_typst_raster(&mut self, rendered: &typst_text::TypstRasterOutput, x: f32, y: f32) {
let logical_w = rendered.width.max(1e-6);
let logical_h = rendered.height.max(1e-6);
let pixel_w = rendered.pixmap.width().max(1) as f32;
let pixel_h = rendered.pixmap.height().max(1) as f32;
let scale_x = (pixel_w / logical_w).max(1e-6);
let scale_y = (pixel_h / logical_h).max(1e-6);
if (scale_x - 1.0).abs() <= 0.02 && (scale_y - 1.0).abs() <= 0.02 {
self.pixmap.draw_pixmap(
x.round() as i32,
y.round() as i32,
rendered.pixmap.as_ref(),
&PixmapPaint::default(),
Transform::identity(),
None,
);
return;
}
let transform = Transform::from_scale(1.0 / scale_x, 1.0 / scale_y).post_translate(x, y);
let paint = PixmapPaint {
quality: FilterQuality::Bilinear,
..PixmapPaint::default()
};
self.pixmap
.draw_pixmap(0, 0, rendered.pixmap.as_ref(), &paint, transform, None);
}
pub fn clear(&mut self) {
let bg_color = self.theme.background.to_tiny_skia_color();
self.pixmap.fill(bg_color);
}
pub fn draw_line(
&mut self,
x1: f32,
y1: f32,
x2: f32,
y2: f32,
color: Color,
width: f32,
style: LineStyle,
) -> Result<()> {
self.draw_line_with_mask(x1, y1, x2, y2, color, width, style, None)
}
pub fn draw_line_clipped(
&mut self,
x1: f32,
y1: f32,
x2: f32,
y2: f32,
color: Color,
width: f32,
style: LineStyle,
clip_rect: (f32, f32, f32, f32),
) -> Result<()> {
let mask = self.get_clip_mask(clip_rect)?;
self.draw_line_with_mask(x1, y1, x2, y2, color, width, style, Some(mask.as_ref()))
}
fn draw_line_with_mask(
&mut self,
x1: f32,
y1: f32,
x2: f32,
y2: f32,
color: Color,
width: f32,
style: LineStyle,
mask: Option<&Mask>,
) -> Result<()> {
let mut paint = Paint::default();
paint.set_color(color.to_tiny_skia_color());
paint.anti_alias = true;
paint.set_color_rgba8(color.r, color.g, color.b, color.a);
let mut stroke = Stroke {
width: width.max(0.1),
..Stroke::default()
};
if let Some(dash_pattern) = self.scaled_dash_pattern(&style) {
stroke.dash = StrokeDash::new(dash_pattern, 0.0);
}
let mut path = PathBuilder::new();
path.move_to(x1, y1);
path.line_to(x2, y2);
let path = path.finish().ok_or(PlottingError::RenderError(
"Failed to create line path".to_string(),
))?;
self.stroke_path_masked(&path, &paint, &stroke, Transform::identity(), mask)?;
Ok(())
}
pub fn draw_polyline(
&mut self,
points: &[(f32, f32)],
color: Color,
width: f32,
style: LineStyle,
) -> Result<()> {
self.draw_polyline_with_mask(points, color, width, style, None)
}
fn draw_polyline_with_mask(
&mut self,
points: &[(f32, f32)],
color: Color,
width: f32,
style: LineStyle,
mask: Option<&Mask>,
) -> Result<()> {
if points.len() < 2 {
return Ok(());
}
let mut paint = Paint::default();
paint.set_color(color.to_tiny_skia_color());
paint.anti_alias = true;
let mut stroke = Stroke {
width: width.max(0.1),
line_cap: LineCap::Round,
line_join: LineJoin::Round,
..Stroke::default()
};
if let Some(dash_pattern) = self.scaled_dash_pattern(&style) {
stroke.dash = StrokeDash::new(dash_pattern, 0.0);
}
let mut path = PathBuilder::new();
path.move_to(points[0].0, points[0].1);
for &(x, y) in &points[1..] {
path.line_to(x, y);
}
let path = path.finish().ok_or(PlottingError::RenderError(
"Failed to create polyline path".to_string(),
))?;
self.stroke_path_masked(&path, &paint, &stroke, Transform::identity(), mask)?;
Ok(())
}
pub fn draw_polyline_clipped(
&mut self,
points: &[(f32, f32)],
color: Color,
width: f32,
style: LineStyle,
clip_rect: (f32, f32, f32, f32), ) -> Result<()> {
let mask = self.get_clip_mask(clip_rect)?;
self.draw_polyline_with_mask(points, color, width, style, Some(mask.as_ref()))
}
fn get_clip_mask(&mut self, clip_rect: (f32, f32, f32, f32)) -> Result<Arc<Mask>> {
let key = ClipMaskKey::new(clip_rect);
if let Some(mask) = self.clip_mask_cache.get(&key) {
return Ok(Arc::clone(mask));
}
let mask = Arc::new(self.create_clip_mask(clip_rect)?);
self.clip_mask_cache.insert(key, Arc::clone(&mask));
Ok(mask)
}
fn create_clip_mask(&self, clip_rect: (f32, f32, f32, f32)) -> Result<Mask> {
let mut mask = Mask::new(self.width, self.height).ok_or(PlottingError::RenderError(
"Failed to create clip mask".to_string(),
))?;
let clip_path = {
let mut pb = PathBuilder::new();
let (x, y, w, h) = clip_rect;
pb.move_to(x, y);
pb.line_to(x + w, y);
pb.line_to(x + w, y + h);
pb.line_to(x, y + h);
pb.close();
pb.finish().ok_or(PlottingError::RenderError(
"Failed to create clip path".to_string(),
))?
};
mask.fill_path(&clip_path, FillRule::Winding, true, Transform::identity());
Ok(mask)
}
fn fill_path_masked(
&mut self,
path: &tiny_skia::Path,
paint: &Paint,
fill_rule: FillRule,
transform: Transform,
mask: Option<&Mask>,
) -> Result<()> {
self.pixmap
.fill_path(path, paint, fill_rule, transform, mask);
Ok(())
}
fn stroke_path_masked(
&mut self,
path: &tiny_skia::Path,
paint: &Paint,
stroke: &Stroke,
transform: Transform,
mask: Option<&Mask>,
) -> Result<()> {
self.pixmap
.stroke_path(path, paint, stroke, transform, mask);
Ok(())
}
pub fn draw_circle(
&mut self,
x: f32,
y: f32,
radius: f32,
color: Color,
filled: bool,
) -> Result<()> {
self.draw_circle_with_mask(x, y, radius, color, filled, None)
}
fn draw_circle_with_mask(
&mut self,
x: f32,
y: f32,
radius: f32,
color: Color,
filled: bool,
mask: Option<&Mask>,
) -> Result<()> {
let mut paint = Paint::default();
paint.set_color(color.to_tiny_skia_color());
paint.anti_alias = true;
let mut path = PathBuilder::new();
path.push_circle(x, y, radius);
let path = path.finish().ok_or(PlottingError::RenderError(
"Failed to create circle path".to_string(),
))?;
if filled {
self.fill_path_masked(
&path,
&paint,
FillRule::Winding,
Transform::identity(),
mask,
)?;
} else {
let stroke = Stroke::default();
self.stroke_path_masked(&path, &paint, &stroke, Transform::identity(), mask)?;
}
Ok(())
}
pub fn draw_rectangle(
&mut self,
x: f32,
y: f32,
width: f32,
height: f32,
color: Color,
filled: bool,
) -> Result<()> {
self.draw_rectangle_with_mask(x, y, width, height, color, filled, None)
}
pub fn draw_rectangle_clipped(
&mut self,
x: f32,
y: f32,
width: f32,
height: f32,
color: Color,
filled: bool,
clip_rect: (f32, f32, f32, f32),
) -> Result<()> {
let mask = self.get_clip_mask(clip_rect)?;
self.draw_rectangle_with_mask(x, y, width, height, color, filled, Some(mask.as_ref()))
}
fn draw_rectangle_with_mask(
&mut self,
x: f32,
y: f32,
width: f32,
height: f32,
color: Color,
filled: bool,
mask: Option<&Mask>,
) -> Result<()> {
let rect = Rect::from_xywh(x, y, width, height).ok_or(PlottingError::RenderError(
"Invalid rectangle dimensions".to_string(),
))?;
let mut path = PathBuilder::new();
path.push_rect(rect);
let path = path.finish().ok_or(PlottingError::RenderError(
"Failed to create rectangle path".to_string(),
))?;
if filled {
let mut fill_paint = Paint::default();
let (r, g, b, a) = color.to_rgba_f32();
let professional_alpha = (a * 0.85).min(1.0); let fill_color = tiny_skia::Color::from_rgba(r, g, b, professional_alpha).ok_or(
PlottingError::RenderError("Invalid color for rectangle fill".to_string()),
)?;
fill_paint.set_color(fill_color);
fill_paint.anti_alias = true;
self.fill_path_masked(
&path,
&fill_paint,
FillRule::Winding,
Transform::identity(),
mask,
)?;
let mut border_paint = Paint::default();
let border_r = (r * 0.8).max(0.0);
let border_g = (g * 0.8).max(0.0);
let border_b = (b * 0.8).max(0.0);
let border_color = tiny_skia::Color::from_rgba(border_r, border_g, border_b, a).ok_or(
PlottingError::RenderError("Invalid border color".to_string()),
)?;
border_paint.set_color(border_color);
border_paint.anti_alias = true;
let stroke = Stroke {
width: 1.0,
line_cap: LineCap::Square,
line_join: LineJoin::Miter,
..Stroke::default()
};
self.stroke_path_masked(&path, &border_paint, &stroke, Transform::identity(), mask)?;
} else {
let mut paint = Paint::default();
paint.set_color(color.to_tiny_skia_color());
paint.anti_alias = true;
let stroke = Stroke::default();
self.stroke_path_masked(&path, &paint, &stroke, Transform::identity(), mask)?;
}
Ok(())
}
pub fn draw_solid_rectangle(
&mut self,
x: f32,
y: f32,
width: f32,
height: f32,
color: Color,
) -> Result<()> {
let rect = Rect::from_xywh(x, y, width, height).ok_or(PlottingError::RenderError(
"Invalid rectangle dimensions".to_string(),
))?;
let mut path = PathBuilder::new();
path.push_rect(rect);
let path = path.finish().ok_or(PlottingError::RenderError(
"Failed to create rectangle path".to_string(),
))?;
let mut fill_paint = Paint::default();
fill_paint.set_color(color.to_tiny_skia_color());
fill_paint.anti_alias = false;
self.pixmap.fill_path(
&path,
&fill_paint,
FillRule::Winding,
Transform::identity(),
None,
);
Ok(())
}
pub fn draw_rounded_rectangle(
&mut self,
x: f32,
y: f32,
width: f32,
height: f32,
corner_radius: f32,
color: Color,
filled: bool,
) -> Result<()> {
let max_radius = (width.min(height) / 2.0).max(0.0);
let radius = corner_radius.min(max_radius);
if radius < 0.1 {
return self.draw_rectangle(x, y, width, height, color, filled);
}
let mut pb = PathBuilder::new();
pb.move_to(x + radius, y);
pb.line_to(x + width - radius, y);
pb.quad_to(x + width, y, x + width, y + radius);
pb.line_to(x + width, y + height - radius);
pb.quad_to(x + width, y + height, x + width - radius, y + height);
pb.line_to(x + radius, y + height);
pb.quad_to(x, y + height, x, y + height - radius);
pb.line_to(x, y + radius);
pb.quad_to(x, y, x + radius, y);
pb.close();
let path = pb.finish().ok_or(PlottingError::RenderError(
"Failed to create rounded rectangle path".to_string(),
))?;
if filled {
let mut fill_paint = Paint::default();
let (r, g, b, a) = color.to_rgba_f32();
let fill_color = tiny_skia::Color::from_rgba(r, g, b, a).ok_or(
PlottingError::RenderError("Invalid color for rounded rectangle fill".to_string()),
)?;
fill_paint.set_color(fill_color);
fill_paint.anti_alias = true;
self.pixmap.fill_path(
&path,
&fill_paint,
FillRule::Winding,
Transform::identity(),
None,
);
} else {
let mut paint = Paint::default();
paint.set_color(color.to_tiny_skia_color());
paint.anti_alias = true;
let stroke = Stroke::default();
self.pixmap
.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
}
Ok(())
}
pub fn draw_filled_polygon(&mut self, vertices: &[(f32, f32)], color: Color) -> Result<()> {
if vertices.len() < 3 {
return Ok(()); }
let mut pb = PathBuilder::new();
pb.move_to(vertices[0].0, vertices[0].1);
for &(x, y) in &vertices[1..] {
pb.line_to(x, y);
}
pb.close();
let path = pb.finish().ok_or(PlottingError::RenderError(
"Failed to create polygon path".to_string(),
))?;
let mut paint = Paint::default();
let (r, g, b, a) = color.to_rgba_f32();
let fill_color = tiny_skia::Color::from_rgba(r, g, b, a).ok_or(
PlottingError::RenderError("Invalid polygon color".to_string()),
)?;
paint.set_color(fill_color);
paint.anti_alias = true;
self.pixmap.fill_path(
&path,
&paint,
FillRule::Winding,
Transform::identity(),
None,
);
Ok(())
}
pub fn draw_filled_polygon_clipped(
&mut self,
vertices: &[(f32, f32)],
color: Color,
clip_rect: (f32, f32, f32, f32), ) -> Result<()> {
if vertices.len() < 3 {
return Ok(()); }
let mut mask = Mask::new(self.width, self.height).ok_or(PlottingError::RenderError(
"Failed to create clip mask".to_string(),
))?;
let clip_path = {
let mut pb = PathBuilder::new();
let (x, y, w, h) = clip_rect;
pb.move_to(x, y);
pb.line_to(x + w, y);
pb.line_to(x + w, y + h);
pb.line_to(x, y + h);
pb.close();
pb.finish().ok_or(PlottingError::RenderError(
"Failed to create clip path".to_string(),
))?
};
mask.fill_path(&clip_path, FillRule::Winding, true, Transform::identity());
let mut pb = PathBuilder::new();
pb.move_to(vertices[0].0, vertices[0].1);
for &(x, y) in &vertices[1..] {
pb.line_to(x, y);
}
pb.close();
let path = pb.finish().ok_or(PlottingError::RenderError(
"Failed to create polygon path".to_string(),
))?;
let mut paint = Paint::default();
let (r, g, b, a) = color.to_rgba_f32();
let fill_color = tiny_skia::Color::from_rgba(r, g, b, a).ok_or(
PlottingError::RenderError("Invalid polygon color".to_string()),
)?;
paint.set_color(fill_color);
paint.anti_alias = true;
self.fill_path_masked(
&path,
&paint,
FillRule::Winding,
Transform::identity(),
Some(&mask),
)?;
Ok(())
}
pub fn draw_polygon_outline(
&mut self,
vertices: &[(f32, f32)],
color: Color,
width: f32,
) -> Result<()> {
if vertices.len() < 3 {
return Ok(()); }
let mut pb = PathBuilder::new();
pb.move_to(vertices[0].0, vertices[0].1);
for &(x, y) in &vertices[1..] {
pb.line_to(x, y);
}
pb.close();
let path = pb.finish().ok_or(PlottingError::RenderError(
"Failed to create polygon outline path".to_string(),
))?;
let mut paint = Paint::default();
paint.set_color(color.to_tiny_skia_color());
paint.anti_alias = true;
let stroke = Stroke {
width,
..Stroke::default()
};
self.pixmap
.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
Ok(())
}
pub fn draw_marker(
&mut self,
x: f32,
y: f32,
size: f32,
style: MarkerStyle,
color: Color,
) -> Result<()> {
self.draw_marker_with_mask(x, y, size, style, color, None)
}
pub fn draw_marker_clipped(
&mut self,
x: f32,
y: f32,
size: f32,
style: MarkerStyle,
color: Color,
clip_rect: (f32, f32, f32, f32),
) -> Result<()> {
let mask = self.get_clip_mask(clip_rect)?;
self.draw_marker_with_mask(x, y, size, style, color, Some(mask.as_ref()))
}
fn draw_marker_with_mask(
&mut self,
x: f32,
y: f32,
size: f32,
style: MarkerStyle,
color: Color,
mask: Option<&Mask>,
) -> Result<()> {
let radius = size * 0.5;
match style {
MarkerStyle::Circle | MarkerStyle::CircleOpen => {
self.draw_circle_with_mask(x, y, radius, color, style.is_filled(), mask)?;
}
MarkerStyle::Square | MarkerStyle::SquareOpen => {
let half_size = radius;
self.draw_rectangle_with_mask(
x - half_size,
y - half_size,
size,
size,
color,
style.is_filled(),
mask,
)?;
}
MarkerStyle::Triangle | MarkerStyle::TriangleOpen | MarkerStyle::TriangleDown => {
let mut paint = Paint::default();
paint.set_color(color.to_tiny_skia_color());
paint.anti_alias = true;
let mut path = PathBuilder::new();
if style == MarkerStyle::TriangleDown {
path.move_to(x, y + radius);
path.line_to(x - radius * 0.866, y - radius * 0.5);
path.line_to(x + radius * 0.866, y - radius * 0.5);
} else {
path.move_to(x, y - radius);
path.line_to(x - radius * 0.866, y + radius * 0.5); path.line_to(x + radius * 0.866, y + radius * 0.5);
}
path.close();
let path = path.finish().ok_or(PlottingError::RenderError(
"Failed to create triangle path".to_string(),
))?;
if style.is_filled() {
self.fill_path_masked(
&path,
&paint,
FillRule::Winding,
Transform::identity(),
mask,
)?;
} else {
let stroke = Stroke {
width: (size * 0.15).max(1.0),
..Stroke::default()
};
self.stroke_path_masked(&path, &paint, &stroke, Transform::identity(), mask)?;
}
}
MarkerStyle::Diamond | MarkerStyle::DiamondOpen => {
let mut paint = Paint::default();
paint.set_color(color.to_tiny_skia_color());
paint.anti_alias = true;
let mut path = PathBuilder::new();
path.move_to(x, y - radius);
path.line_to(x + radius, y);
path.line_to(x, y + radius);
path.line_to(x - radius, y);
path.close();
let path = path.finish().ok_or(PlottingError::RenderError(
"Failed to create diamond path".to_string(),
))?;
if style.is_filled() {
self.fill_path_masked(
&path,
&paint,
FillRule::Winding,
Transform::identity(),
mask,
)?;
} else {
let stroke = Stroke {
width: (size * 0.15).max(1.0),
..Stroke::default()
};
self.stroke_path_masked(&path, &paint, &stroke, Transform::identity(), mask)?;
}
}
MarkerStyle::Plus => {
let marker_line_width = (size * 0.25).max(1.0);
self.draw_line_with_mask(
x - radius,
y,
x + radius,
y,
color,
marker_line_width,
LineStyle::Solid,
mask,
)?;
self.draw_line_with_mask(
x,
y - radius,
x,
y + radius,
color,
marker_line_width,
LineStyle::Solid,
mask,
)?;
}
MarkerStyle::Cross => {
let marker_line_width = (size * 0.25).max(1.0);
let offset = radius * 0.707; self.draw_line_with_mask(
x - offset,
y - offset,
x + offset,
y + offset,
color,
marker_line_width,
LineStyle::Solid,
mask,
)?;
self.draw_line_with_mask(
x - offset,
y + offset,
x + offset,
y - offset,
color,
marker_line_width,
LineStyle::Solid,
mask,
)?;
}
MarkerStyle::Star => {
let marker_line_width = (size * 0.22).max(1.0);
self.draw_line_with_mask(
x - radius,
y,
x + radius,
y,
color,
marker_line_width,
LineStyle::Solid,
mask,
)?;
self.draw_line_with_mask(
x,
y - radius,
x,
y + radius,
color,
marker_line_width,
LineStyle::Solid,
mask,
)?;
let offset = radius * 0.707;
self.draw_line_with_mask(
x - offset,
y - offset,
x + offset,
y + offset,
color,
marker_line_width,
LineStyle::Solid,
mask,
)?;
self.draw_line_with_mask(
x - offset,
y + offset,
x + offset,
y - offset,
color,
marker_line_width,
LineStyle::Solid,
mask,
)?;
}
}
Ok(())
}
pub fn draw_grid(
&mut self,
x_ticks: &[f32],
y_ticks: &[f32],
plot_area: Rect,
color: Color,
style: LineStyle,
line_width: f32,
) -> Result<()> {
for &x in x_ticks {
if x >= plot_area.left() && x <= plot_area.right() {
self.draw_line(
x,
plot_area.top(),
x,
plot_area.bottom(),
color,
line_width,
style.clone(),
)?;
}
}
for &y in y_ticks {
if y >= plot_area.top() && y <= plot_area.bottom() {
self.draw_line(
plot_area.left(),
y,
plot_area.right(),
y,
color,
line_width,
style.clone(),
)?;
}
}
Ok(())
}
fn vertical_tick_span(
spine_y: f32,
tick_size: f32,
tick_direction: &TickDirection,
top: bool,
) -> (f32, f32) {
match tick_direction {
TickDirection::Inside => {
if top {
(spine_y, spine_y + tick_size)
} else {
(spine_y, spine_y - tick_size)
}
}
TickDirection::Outside => {
if top {
(spine_y, spine_y - tick_size)
} else {
(spine_y, spine_y + tick_size)
}
}
TickDirection::InOut => (spine_y - tick_size / 2.0, spine_y + tick_size / 2.0),
}
}
fn horizontal_tick_span(
spine_x: f32,
tick_size: f32,
tick_direction: &TickDirection,
right: bool,
) -> (f32, f32) {
match tick_direction {
TickDirection::Inside => {
if right {
(spine_x, spine_x - tick_size)
} else {
(spine_x, spine_x + tick_size)
}
}
TickDirection::Outside => {
if right {
(spine_x, spine_x + tick_size)
} else {
(spine_x, spine_x - tick_size)
}
}
TickDirection::InOut => (spine_x - tick_size / 2.0, spine_x + tick_size / 2.0),
}
}
fn x_label_center(plot_area: &LayoutRect, x_value: f64, x_min: f64, x_max: f64) -> f32 {
let x_range = x_max - x_min;
if x_range.abs() < f64::EPSILON {
plot_area.center_x()
} else {
plot_area.left + ((x_value - x_min) as f32 / x_range as f32) * plot_area.width()
}
}
fn y_label_center(plot_area: &LayoutRect, y_value: f64, y_min: f64, y_max: f64) -> f32 {
let y_range = y_max - y_min;
if y_range.abs() < f64::EPSILON {
plot_area.center_y()
} else {
plot_area.bottom - ((y_value - y_min) as f32 / y_range as f32) * plot_area.height()
}
}
pub fn draw_axes(
&mut self,
plot_area: Rect,
x_ticks: &[f32],
y_ticks: &[f32],
tick_direction: &TickDirection,
tick_sides: &TickSides,
color: Color,
) -> Result<()> {
let axis_width = self.logical_pixels_to_pixels(1.5);
let tick_size = self.logical_pixels_to_pixels(5.0);
let tick_width = self.logical_pixels_to_pixels(1.0);
self.draw_line(
plot_area.left(),
plot_area.bottom(),
plot_area.right(),
plot_area.bottom(),
color,
axis_width,
LineStyle::Solid,
)?;
self.draw_line(
plot_area.left(),
plot_area.top(),
plot_area.left(),
plot_area.bottom(),
color,
axis_width,
LineStyle::Solid,
)?;
self.draw_line(
plot_area.left(),
plot_area.top(),
plot_area.right(),
plot_area.top(),
color,
axis_width,
LineStyle::Solid,
)?;
self.draw_line(
plot_area.right(),
plot_area.top(),
plot_area.right(),
plot_area.bottom(),
color,
axis_width,
LineStyle::Solid,
)?;
for &x in x_ticks {
if x >= plot_area.left() && x <= plot_area.right() {
if tick_sides.bottom {
let (tick_start, tick_end) = Self::vertical_tick_span(
plot_area.bottom(),
tick_size,
tick_direction,
false,
);
self.draw_line(
x,
tick_start,
x,
tick_end,
color,
tick_width,
LineStyle::Solid,
)?;
}
if tick_sides.top {
let (tick_start, tick_end) =
Self::vertical_tick_span(plot_area.top(), tick_size, tick_direction, true);
self.draw_line(
x,
tick_start,
x,
tick_end,
color,
tick_width,
LineStyle::Solid,
)?;
}
}
}
for &y in y_ticks {
if y >= plot_area.top() && y <= plot_area.bottom() {
if tick_sides.left {
let (tick_start, tick_end) = Self::horizontal_tick_span(
plot_area.left(),
tick_size,
tick_direction,
false,
);
self.draw_line(
tick_start,
y,
tick_end,
y,
color,
tick_width,
LineStyle::Solid,
)?;
}
if tick_sides.right {
let (tick_start, tick_end) = Self::horizontal_tick_span(
plot_area.right(),
tick_size,
tick_direction,
true,
);
self.draw_line(
tick_start,
y,
tick_end,
y,
color,
tick_width,
LineStyle::Solid,
)?;
}
}
}
Ok(())
}
pub fn draw_axes_with_config(
&mut self,
plot_area: Rect,
x_major_ticks: &[f32],
y_major_ticks: &[f32],
x_minor_ticks: &[f32],
y_minor_ticks: &[f32],
tick_direction: &TickDirection,
tick_sides: &TickSides,
color: Color,
dpi_scale: f32,
) -> Result<()> {
let render_scale = RenderScale::from_reference_scale(dpi_scale);
let axis_width = render_scale.logical_pixels_to_pixels(1.5);
let major_tick_size = render_scale.logical_pixels_to_pixels(8.0);
let minor_tick_size = render_scale.logical_pixels_to_pixels(4.0);
let major_tick_width = render_scale.logical_pixels_to_pixels(1.5);
let minor_tick_width = render_scale.logical_pixels_to_pixels(1.0);
self.draw_line(
plot_area.left(),
plot_area.bottom(),
plot_area.right(),
plot_area.bottom(),
color,
axis_width,
LineStyle::Solid,
)?;
self.draw_line(
plot_area.left(),
plot_area.top(),
plot_area.left(),
plot_area.bottom(),
color,
axis_width,
LineStyle::Solid,
)?;
self.draw_line(
plot_area.left(),
plot_area.top(),
plot_area.right(),
plot_area.top(),
color,
axis_width,
LineStyle::Solid,
)?;
self.draw_line(
plot_area.right(),
plot_area.top(),
plot_area.right(),
plot_area.bottom(),
color,
axis_width,
LineStyle::Solid,
)?;
for &x in x_major_ticks {
if x >= plot_area.left() && x <= plot_area.right() {
if tick_sides.bottom {
let (tick_start, tick_end) = Self::vertical_tick_span(
plot_area.bottom(),
major_tick_size,
tick_direction,
false,
);
self.draw_line(
x,
tick_start,
x,
tick_end,
color,
major_tick_width,
LineStyle::Solid,
)?;
}
if tick_sides.top {
let (tick_start, tick_end) = Self::vertical_tick_span(
plot_area.top(),
major_tick_size,
tick_direction,
true,
);
self.draw_line(
x,
tick_start,
x,
tick_end,
color,
major_tick_width,
LineStyle::Solid,
)?;
}
}
}
for &x in x_minor_ticks {
if x >= plot_area.left() && x <= plot_area.right() {
if tick_sides.bottom {
let (tick_start, tick_end) = Self::vertical_tick_span(
plot_area.bottom(),
minor_tick_size,
tick_direction,
false,
);
self.draw_line(
x,
tick_start,
x,
tick_end,
color,
minor_tick_width,
LineStyle::Solid,
)?;
}
if tick_sides.top {
let (tick_start, tick_end) = Self::vertical_tick_span(
plot_area.top(),
minor_tick_size,
tick_direction,
true,
);
self.draw_line(
x,
tick_start,
x,
tick_end,
color,
minor_tick_width,
LineStyle::Solid,
)?;
}
}
}
for &y in y_major_ticks {
if y >= plot_area.top() && y <= plot_area.bottom() {
if tick_sides.left {
let (tick_start, tick_end) = Self::horizontal_tick_span(
plot_area.left(),
major_tick_size,
tick_direction,
false,
);
self.draw_line(
tick_start,
y,
tick_end,
y,
color,
major_tick_width,
LineStyle::Solid,
)?;
}
if tick_sides.right {
let (tick_start, tick_end) = Self::horizontal_tick_span(
plot_area.right(),
major_tick_size,
tick_direction,
true,
);
self.draw_line(
tick_start,
y,
tick_end,
y,
color,
major_tick_width,
LineStyle::Solid,
)?;
}
}
}
for &y in y_minor_ticks {
if y >= plot_area.top() && y <= plot_area.bottom() {
if tick_sides.left {
let (tick_start, tick_end) = Self::horizontal_tick_span(
plot_area.left(),
minor_tick_size,
tick_direction,
false,
);
self.draw_line(
tick_start,
y,
tick_end,
y,
color,
minor_tick_width,
LineStyle::Solid,
)?;
}
if tick_sides.right {
let (tick_start, tick_end) = Self::horizontal_tick_span(
plot_area.right(),
minor_tick_size,
tick_direction,
true,
);
self.draw_line(
tick_start,
y,
tick_end,
y,
color,
minor_tick_width,
LineStyle::Solid,
)?;
}
}
}
Ok(())
}
pub fn draw_datashader_image(
&mut self,
image: &crate::data::DataShaderImage,
plot_area: Rect,
) -> Result<()> {
let mut datashader_pixmap = Pixmap::new(image.width as u32, image.height as u32)
.ok_or(PlottingError::OutOfMemory)?;
if image.pixels.len() != (image.width * image.height * 4) {
return Err(PlottingError::RenderError(
"Invalid DataShader image pixel data".to_string(),
));
}
let pixmap_data = datashader_pixmap.data_mut();
for (i, chunk) in image.pixels.chunks_exact(4).enumerate() {
let r = chunk[0];
let g = chunk[1];
let b = chunk[2];
let a = chunk[3];
let alpha_f = a as f32 / 255.0;
let premult_r = (r as f32 * alpha_f) as u8;
let premult_g = (g as f32 * alpha_f) as u8;
let premult_b = (b as f32 * alpha_f) as u8;
pixmap_data[i * 4] = premult_b;
pixmap_data[i * 4 + 1] = premult_g;
pixmap_data[i * 4 + 2] = premult_r;
pixmap_data[i * 4 + 3] = a;
}
let src_rect = Rect::from_xywh(0.0, 0.0, image.width as f32, image.height as f32).ok_or(
PlottingError::RenderError("Invalid source rect".to_string()),
)?;
let transform = Transform::from_scale(
plot_area.width() / image.width as f32,
plot_area.height() / image.height as f32,
)
.post_translate(plot_area.x(), plot_area.y());
self.pixmap.draw_pixmap(
plot_area.x() as i32,
plot_area.y() as i32,
datashader_pixmap.as_ref(),
&PixmapPaint::default(),
Transform::identity(),
None,
);
Ok(())
}
pub fn draw_text(&mut self, text: &str, x: f32, y: f32, size: f32, color: Color) -> Result<()> {
match self.text_engine_mode {
TextEngineMode::Plain => {
let config = FontConfig::new(self.font_config.family.clone(), size);
self.text_renderer
.render_text(&mut self.pixmap, text, x, y, &config, color)
}
#[cfg(feature = "typst-math")]
TextEngineMode::Typst => {
let size_pt = self.typst_size_pt(size);
let rendered =
typst_text::render_raster(text, size_pt, color, 0.0, "Skia text rendering")?;
let (draw_x, draw_y) = typst_text::anchored_top_left(
x,
y,
rendered.width,
rendered.height,
TypstTextAnchor::TopLeft,
);
self.draw_typst_raster(&rendered, draw_x, draw_y);
Ok(())
}
}
}
pub fn draw_text_rotated(
&mut self,
text: &str,
x: f32,
y: f32,
size: f32,
color: Color,
) -> Result<()> {
match self.text_engine_mode {
TextEngineMode::Plain => {
let config = FontConfig::new(self.font_config.family.clone(), size);
self.text_renderer
.render_text_rotated(&mut self.pixmap, text, x, y, &config, color)
}
#[cfg(feature = "typst-math")]
TextEngineMode::Typst => {
let size_pt = self.typst_size_pt(size);
let rendered = typst_text::render_raster(
text,
size_pt,
color,
-90.0,
"Skia rotated text rendering",
)?;
let (draw_x, draw_y) = typst_text::anchored_top_left(
x,
y,
rendered.width,
rendered.height,
TypstTextAnchor::Center,
);
self.draw_typst_raster(&rendered, draw_x, draw_y);
Ok(())
}
}
}
pub fn draw_text_centered(
&mut self,
text: &str,
center_x: f32,
y: f32,
size: f32,
color: Color,
) -> Result<()> {
match self.text_engine_mode {
TextEngineMode::Plain => {
let config = FontConfig::new(self.font_config.family.clone(), size);
self.text_renderer.render_text_centered(
&mut self.pixmap,
text,
center_x,
y,
&config,
color,
)
}
#[cfg(feature = "typst-math")]
TextEngineMode::Typst => {
let size_pt = self.typst_size_pt(size);
let rendered = typst_text::render_raster(
text,
size_pt,
color,
0.0,
"Skia centered text rendering",
)?;
let (draw_x, draw_y) = typst_text::anchored_top_left(
center_x,
y,
rendered.width,
rendered.height,
TypstTextAnchor::TopCenter,
);
self.draw_typst_raster(&rendered, draw_x, draw_y);
Ok(())
}
}
}
pub fn measure_text(&self, text: &str, size: f32) -> Result<(f32, f32)> {
match self.text_engine_mode {
TextEngineMode::Plain => {
let config = FontConfig::new(self.font_config.family.clone(), size);
self.text_renderer.measure_text(text, &config)
}
#[cfg(feature = "typst-math")]
TextEngineMode::Typst => {
let size_pt = self.typst_size_pt(size);
typst_text::measure_text(
text,
size_pt,
self.theme.foreground,
0.0,
TypstBackendKind::Raster,
"Skia text measurement",
)
}
}
}
fn generated_label<'a>(&self, text: &'a str) -> Cow<'a, str> {
#[cfg(feature = "typst-math")]
if self.text_engine_mode.uses_typst() {
return Cow::Owned(typst_text::literal_text_snippet(text));
}
Cow::Borrowed(text)
}
pub fn draw_axis_labels(
&mut self,
plot_area: Rect,
x_min: f64,
x_max: f64,
y_min: f64,
y_max: f64,
x_label: &str,
y_label: &str,
color: Color,
label_size: f32,
dpi: f32,
spacing: &SpacingConfig,
) -> Result<()> {
let tick_size = label_size * 0.7; let render_scale = RenderScale::new(dpi);
let tick_pad_px = pt_to_px(spacing.tick_pad, dpi);
let label_pad_px = pt_to_px(spacing.label_pad, dpi);
let char_width_estimate = render_scale.logical_pixels_to_pixels(4.0);
let x_ticks = generate_ticks(x_min, x_max, 5);
let y_ticks = generate_ticks(y_min, y_max, 5);
let x_labels = format_tick_labels(&x_ticks);
let y_labels = format_tick_labels(&y_ticks);
for (tick_value, label_text) in x_ticks.iter().zip(x_labels.iter()) {
let x_pixel = plot_area.left()
+ (*tick_value - x_min) as f32 / (x_max - x_min) as f32 * plot_area.width();
let text_width_estimate = label_text.len() as f32 * char_width_estimate / 2.0;
let label_x = (x_pixel - text_width_estimate)
.max(0.0)
.min(self.width() as f32 - text_width_estimate * 2.0);
let label_y = (plot_area.bottom() + tick_pad_px + tick_size)
.min(self.height() as f32 - tick_size - 5.0);
let label_snippet = self.generated_label(label_text);
self.draw_text(&label_snippet, label_x, label_y, tick_size, color)?;
}
for (tick_value, label_text) in y_ticks.iter().zip(y_labels.iter()) {
let y_pixel = plot_area.bottom()
- (*tick_value - y_min) as f32 / (y_max - y_min) as f32 * plot_area.height();
let text_width_estimate = label_text.len() as f32 * char_width_estimate;
let label_x = (plot_area.left() - text_width_estimate - tick_pad_px).max(5.0);
let label_snippet = self.generated_label(label_text);
self.draw_text(
&label_snippet,
label_x,
y_pixel - tick_size / 3.0,
tick_size,
color,
)?;
}
let x_label_x =
plot_area.left() + plot_area.width() / 2.0 - x_label.len() as f32 * char_width_estimate;
let x_label_y = plot_area.bottom() + tick_pad_px + tick_size + label_pad_px + label_size;
self.draw_text(x_label, x_label_x, x_label_y, label_size, color)?;
let estimated_tick_width = 4.0 * char_width_estimate;
let y_label_x = plot_area.left() - tick_pad_px - estimated_tick_width - label_pad_px;
let y_label_y = plot_area.top() + plot_area.height() / 2.0;
self.draw_text_rotated(y_label, y_label_x, y_label_y, label_size, color)?;
self.draw_plot_border(plot_area, color, render_scale.reference_scale())?;
Ok(())
}
pub fn draw_axis_labels_legacy(
&mut self,
plot_area: Rect,
x_min: f64,
x_max: f64,
y_min: f64,
y_max: f64,
x_label: &str,
y_label: &str,
color: Color,
label_size: f32,
dpi_scale: f32,
) -> Result<()> {
let tick_size = label_size * 0.7;
let render_scale = RenderScale::from_reference_scale(dpi_scale);
let tick_offset_y = render_scale.logical_pixels_to_pixels(20.0);
let x_label_offset = render_scale.logical_pixels_to_pixels(50.0);
let y_label_offset = render_scale.logical_pixels_to_pixels(25.0);
let char_width_estimate = render_scale.logical_pixels_to_pixels(4.0);
let x_ticks = generate_ticks(x_min, x_max, 5);
let y_ticks = generate_ticks(y_min, y_max, 5);
let x_labels = format_tick_labels(&x_ticks);
let y_labels = format_tick_labels(&y_ticks);
for (tick_value, label_text) in x_ticks.iter().zip(x_labels.iter()) {
let x_pixel = plot_area.left()
+ (*tick_value - x_min) as f32 / (x_max - x_min) as f32 * plot_area.width();
let text_width_estimate = label_text.len() as f32 * char_width_estimate / 2.0;
let label_x = (x_pixel - text_width_estimate)
.max(0.0)
.min(self.width() as f32 - text_width_estimate * 2.0);
let label_y =
(plot_area.bottom() + tick_offset_y).min(self.height() as f32 - tick_size - 5.0);
let label_snippet = self.generated_label(label_text);
self.draw_text(&label_snippet, label_x, label_y, tick_size, color)?;
}
for (tick_value, label_text) in y_ticks.iter().zip(y_labels.iter()) {
let y_pixel = plot_area.bottom()
- (*tick_value - y_min) as f32 / (y_max - y_min) as f32 * plot_area.height();
let text_width_estimate = label_text.len() as f32 * char_width_estimate;
let label_x = (plot_area.left()
- text_width_estimate
- render_scale.logical_pixels_to_pixels(15.0))
.max(5.0);
let label_snippet = self.generated_label(label_text);
self.draw_text(
&label_snippet,
label_x,
y_pixel - tick_size / 3.0,
tick_size,
color,
)?;
}
let x_label_x =
plot_area.left() + plot_area.width() / 2.0 - x_label.len() as f32 * char_width_estimate;
let x_label_y = plot_area.bottom() + x_label_offset;
self.draw_text(x_label, x_label_x, x_label_y, label_size, color)?;
let y_label_x = plot_area.left() - y_label_offset;
let y_label_y = plot_area.top() + plot_area.height() / 2.0;
self.draw_text_rotated(y_label, y_label_x, y_label_y, label_size, color)?;
self.draw_plot_border(plot_area, color, dpi_scale)?;
Ok(())
}
pub fn draw_axis_labels_with_ticks(
&mut self,
plot_area: Rect,
x_min: f64,
x_max: f64,
y_min: f64,
y_max: f64,
x_major_ticks: &[f64],
y_major_ticks: &[f64],
x_label: &str,
y_label: &str,
color: Color,
label_size: f32,
dpi_scale: f32,
) -> Result<()> {
let tick_size = label_size * 0.7; let render_scale = RenderScale::from_reference_scale(dpi_scale);
let tick_offset_y = render_scale.logical_pixels_to_pixels(25.0);
let x_label_offset = render_scale.logical_pixels_to_pixels(55.0);
let y_label_offset = render_scale.logical_pixels_to_pixels(50.0);
let y_tick_offset = render_scale.logical_pixels_to_pixels(15.0);
let char_width_estimate = render_scale.logical_pixels_to_pixels(4.0);
let x_labels = format_tick_labels(x_major_ticks);
let y_labels = format_tick_labels(y_major_ticks);
for (tick_value, label_text) in x_major_ticks.iter().zip(x_labels.iter()) {
let x_pixel = plot_area.left()
+ (*tick_value - x_min) as f32 / (x_max - x_min) as f32 * plot_area.width();
let text_width_estimate = label_text.len() as f32 * char_width_estimate / 2.0;
let label_x = (x_pixel - text_width_estimate)
.max(0.0)
.min(self.width() as f32 - text_width_estimate * 2.0);
let label_y =
(plot_area.bottom() + tick_offset_y).min(self.height() as f32 - tick_size - 5.0); let label_snippet = self.generated_label(label_text);
self.draw_text(&label_snippet, label_x, label_y, tick_size, color)?;
}
for (tick_value, label_text) in y_major_ticks.iter().zip(y_labels.iter()) {
let y_pixel = plot_area.bottom()
- (*tick_value - y_min) as f32 / (y_max - y_min) as f32 * plot_area.height();
let text_width_estimate = label_text.len() as f32 * char_width_estimate;
let label_x = (plot_area.left() - text_width_estimate - y_tick_offset).max(5.0); let label_snippet = self.generated_label(label_text);
self.draw_text(
&label_snippet,
label_x,
y_pixel + tick_size * 0.3,
tick_size,
color,
)?;
}
let x_label_x =
plot_area.left() + plot_area.width() / 2.0 - x_label.len() as f32 * char_width_estimate;
let x_label_y = plot_area.bottom() + x_label_offset;
self.draw_text(x_label, x_label_x, x_label_y, label_size, color)?;
let estimated_text_width = y_label.len() as f32 * label_size * 0.8;
let improved_y_label_offset = (estimated_text_width * 0.6).max(y_label_offset);
let y_label_x = plot_area.left() - improved_y_label_offset;
let y_label_y = plot_area.top() + plot_area.height() / 2.0;
self.draw_text_rotated(y_label, y_label_x, y_label_y, label_size, color)?;
self.draw_plot_border(plot_area, color, dpi_scale)?;
Ok(())
}
pub fn draw_axis_labels_with_categories(
&mut self,
plot_area: Rect,
categories: &[String],
y_min: f64,
y_max: f64,
y_major_ticks: &[f64],
x_label: &str,
y_label: &str,
color: Color,
label_size: f32,
dpi_scale: f32,
) -> Result<()> {
let tick_size = label_size * 0.7;
let render_scale = RenderScale::from_reference_scale(dpi_scale);
let tick_offset_y = render_scale.logical_pixels_to_pixels(25.0);
let x_label_offset = render_scale.logical_pixels_to_pixels(55.0);
let y_label_offset = render_scale.logical_pixels_to_pixels(50.0);
let y_tick_offset = render_scale.logical_pixels_to_pixels(15.0);
let char_width_estimate = render_scale.logical_pixels_to_pixels(4.0);
let n_categories = categories.len();
if n_categories > 0 {
let x_min = -0.5_f64;
let x_max = n_categories as f64 - 0.5;
let x_range = x_max - x_min;
for (i, category) in categories.iter().enumerate() {
let x_data = i as f64;
let x_center =
plot_area.left() + ((x_data - x_min) / x_range) as f32 * plot_area.width();
let text_width_estimate = category.len() as f32 * char_width_estimate / 2.0;
let label_x = (x_center - text_width_estimate)
.max(0.0)
.min(self.width() as f32 - text_width_estimate * 2.0);
let label_y = (plot_area.bottom() + tick_offset_y)
.min(self.height() as f32 - tick_size - 5.0);
self.draw_text(category, label_x, label_y, tick_size, color)?;
}
}
let y_labels = format_tick_labels(y_major_ticks);
for (tick_value, label_text) in y_major_ticks.iter().zip(y_labels.iter()) {
let y_pixel = plot_area.bottom()
- (*tick_value - y_min) as f32 / (y_max - y_min) as f32 * plot_area.height();
let text_width_estimate = label_text.len() as f32 * char_width_estimate;
let label_x = (plot_area.left() - text_width_estimate - y_tick_offset).max(5.0);
let label_snippet = self.generated_label(label_text);
self.draw_text(
&label_snippet,
label_x,
y_pixel + tick_size * 0.3,
tick_size,
color,
)?;
}
let x_label_x =
plot_area.left() + plot_area.width() / 2.0 - x_label.len() as f32 * char_width_estimate;
let x_label_y = plot_area.bottom() + x_label_offset;
self.draw_text(x_label, x_label_x, x_label_y, label_size, color)?;
let estimated_text_width = y_label.len() as f32 * label_size * 0.8;
let improved_y_label_offset = (estimated_text_width * 0.6).max(y_label_offset);
let y_label_x = plot_area.left() - improved_y_label_offset;
let y_label_y = plot_area.top() + plot_area.height() / 2.0;
self.draw_text_rotated(y_label, y_label_x, y_label_y, label_size, color)?;
self.draw_plot_border(plot_area, color, dpi_scale)?;
Ok(())
}
pub fn draw_plot_border(
&mut self,
plot_area: Rect,
color: Color,
dpi_scale: f32,
) -> Result<()> {
let border_width =
RenderScale::from_reference_scale(dpi_scale).logical_pixels_to_pixels(1.5);
let mut paint = tiny_skia::Paint::default();
paint.set_color_rgba8(color.r, color.g, color.b, color.a);
paint.anti_alias = true;
let stroke = tiny_skia::Stroke {
width: border_width,
..tiny_skia::Stroke::default()
};
let path = tiny_skia::PathBuilder::from_rect(plot_area);
self.pixmap.stroke_path(
&path,
&paint,
&stroke,
tiny_skia::Transform::identity(),
None,
);
Ok(())
}
pub fn draw_title(
&mut self,
title: &str,
_plot_area: Rect,
color: Color,
title_size: f32,
dpi: f32,
_spacing: &SpacingConfig,
) -> Result<()> {
let canvas_center_x = self.width() as f32 / 2.0;
let top_padding = RenderScale::new(dpi).logical_pixels_to_pixels(8.0);
let title_y = top_padding + title_size;
self.draw_text_centered(title, canvas_center_x, title_y, title_size, color)
}
pub fn draw_title_at(&mut self, pos: &TextPosition, text: &str, color: Color) -> Result<()> {
self.draw_text_centered(text, pos.x, pos.y, pos.size, color)
}
pub fn draw_xlabel_at(&mut self, pos: &TextPosition, text: &str, color: Color) -> Result<()> {
self.draw_text_centered(text, pos.x, pos.y, pos.size, color)
}
pub fn draw_ylabel_at(&mut self, pos: &TextPosition, text: &str, color: Color) -> Result<()> {
self.draw_text_rotated(text, pos.x, pos.y, pos.size, color)
}
pub fn draw_axis_labels_at(
&mut self,
plot_area: &LayoutRect,
x_min: f64,
x_max: f64,
y_min: f64,
y_max: f64,
x_ticks: &[f64],
y_ticks: &[f64],
xtick_baseline_y: f32,
ytick_right_x: f32,
tick_size: f32,
color: Color,
dpi: f32,
show_tick_labels: bool,
draw_border: bool,
) -> Result<()> {
let render_scale = RenderScale::new(dpi);
let skia_plot_area = Rect::from_ltrb(
plot_area.left,
plot_area.top,
plot_area.right,
plot_area.bottom,
)
.ok_or(PlottingError::InvalidData {
message: "Invalid plot area dimensions".to_string(),
position: None,
})?;
let x_labels = format_tick_labels(x_ticks);
let y_labels = format_tick_labels(y_ticks);
if show_tick_labels {
for (tick_value, label_text) in x_ticks.iter().zip(x_labels.iter()) {
let x_pixel = Self::x_label_center(plot_area, *tick_value, x_min, x_max);
let label_snippet = self.generated_label(label_text);
let (text_width, _) = self.measure_text(&label_snippet, tick_size)?;
let label_x = (x_pixel - text_width / 2.0)
.max(0.0)
.min(self.width() as f32 - text_width);
self.draw_text(&label_snippet, label_x, xtick_baseline_y, tick_size, color)?;
}
for (tick_value, label_text) in y_ticks.iter().zip(y_labels.iter()) {
let y_pixel = Self::y_label_center(plot_area, *tick_value, y_min, y_max);
let label_snippet = self.generated_label(label_text);
let (text_width, text_height) = self.measure_text(&label_snippet, tick_size)?;
let gap = tick_size * 0.5;
let min_x = tick_size * 0.5;
let label_x = (ytick_right_x - text_width - gap).max(min_x);
let centered_y = y_pixel - text_height / 2.0;
self.draw_text(&label_snippet, label_x, centered_y, tick_size, color)?;
}
}
if draw_border {
self.draw_plot_border(skia_plot_area, color, render_scale.reference_scale())?;
}
Ok(())
}
pub fn draw_axis_labels_at_categorical(
&mut self,
plot_area: &LayoutRect,
categories: &[String],
x_min: f64,
x_max: f64,
y_min: f64,
y_max: f64,
y_ticks: &[f64],
xtick_baseline_y: f32,
ytick_right_x: f32,
tick_size: f32,
color: Color,
dpi: f32,
show_tick_labels: bool,
draw_border: bool,
) -> Result<()> {
let render_scale = RenderScale::new(dpi);
let skia_plot_area = Rect::from_ltrb(
plot_area.left,
plot_area.top,
plot_area.right,
plot_area.bottom,
)
.ok_or(PlottingError::InvalidData {
message: "Invalid plot area dimensions".to_string(),
position: None,
})?;
if show_tick_labels {
let n_categories = categories.len();
if n_categories > 0 {
for (i, category) in categories.iter().enumerate() {
let x_center = Self::x_label_center(plot_area, i as f64, x_min, x_max);
let label_snippet = self.generated_label(category);
let (text_width, _) = self.measure_text(&label_snippet, tick_size)?;
let label_x = (x_center - text_width / 2.0)
.max(0.0)
.min(self.width() as f32 - text_width);
self.draw_text(&label_snippet, label_x, xtick_baseline_y, tick_size, color)?;
}
}
let y_labels = format_tick_labels(y_ticks);
for (tick_value, label_text) in y_ticks.iter().zip(y_labels.iter()) {
let y_pixel = Self::y_label_center(plot_area, *tick_value, y_min, y_max);
let label_snippet = self.generated_label(label_text);
let (text_width, text_height) = self.measure_text(&label_snippet, tick_size)?;
let gap = tick_size * 0.5;
let min_x = tick_size * 0.5;
let label_x = (ytick_right_x - text_width - gap).max(min_x);
let centered_y = y_pixel - text_height / 2.0;
self.draw_text(&label_snippet, label_x, centered_y, tick_size, color)?;
}
}
if draw_border {
self.draw_plot_border(skia_plot_area, color, render_scale.reference_scale())?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn draw_axis_labels_at_categorical_violin(
&mut self,
plot_area: &LayoutRect,
categories: &[String],
x_positions: &[f64],
x_min: f64,
x_max: f64,
y_min: f64,
y_max: f64,
y_ticks: &[f64],
xtick_baseline_y: f32,
ytick_right_x: f32,
tick_size: f32,
color: Color,
dpi: f32,
show_tick_labels: bool,
draw_border: bool,
) -> Result<()> {
let render_scale = RenderScale::new(dpi);
let skia_plot_area = Rect::from_ltrb(
plot_area.left,
plot_area.top,
plot_area.right,
plot_area.bottom,
)
.ok_or(PlottingError::InvalidData {
message: "Invalid plot area dimensions".to_string(),
position: None,
})?;
if show_tick_labels {
for (category, &x_pos) in categories.iter().zip(x_positions.iter()) {
let x_center = Self::x_label_center(plot_area, x_pos, x_min, x_max);
let label_snippet = self.generated_label(category);
let (text_width, _) = self.measure_text(&label_snippet, tick_size)?;
let label_x = (x_center - text_width / 2.0)
.max(0.0)
.min(self.width() as f32 - text_width);
self.draw_text(&label_snippet, label_x, xtick_baseline_y, tick_size, color)?;
}
let y_labels = format_tick_labels(y_ticks);
for (tick_value, label_text) in y_ticks.iter().zip(y_labels.iter()) {
let y_pixel = Self::y_label_center(plot_area, *tick_value, y_min, y_max);
let label_snippet = self.generated_label(label_text);
let (text_width, text_height) = self.measure_text(&label_snippet, tick_size)?;
let gap = tick_size * 0.5;
let min_x = tick_size * 0.5;
let label_x = (ytick_right_x - text_width - gap).max(min_x);
let centered_y = y_pixel - text_height / 2.0;
self.draw_text(&label_snippet, label_x, centered_y, tick_size, color)?;
}
}
if draw_border {
self.draw_plot_border(skia_plot_area, color, render_scale.reference_scale())?;
}
Ok(())
}
pub fn draw_title_legacy(
&mut self,
title: &str,
plot_area: Rect,
color: Color,
title_size: f32,
dpi_scale: f32,
) -> Result<()> {
let title_offset =
RenderScale::from_reference_scale(dpi_scale).logical_pixels_to_pixels(30.0);
let canvas_center_x = self.width() as f32 / 2.0;
let title_y = (plot_area.top() - title_offset).max(title_size + 5.0);
self.draw_text_centered(title, canvas_center_x, title_y, title_size, color)
}
pub fn draw_legend(&mut self, legend_items: &[(String, Color)], plot_area: Rect) -> Result<()> {
if legend_items.is_empty() {
return Ok(());
}
let legend_size = 12.0;
let legend_spacing = 20.0;
let legend_x = plot_area.right() - 150.0;
let mut legend_y = plot_area.top() + 30.0;
let legend_bg = Rect::from_xywh(
legend_x - 10.0,
legend_y - 15.0,
140.0,
legend_items.len() as f32 * legend_spacing + 10.0,
)
.ok_or(PlottingError::InvalidData {
message: "Invalid legend dimensions".to_string(),
position: None,
})?;
self.draw_rectangle(
legend_bg.left(),
legend_bg.top(),
legend_bg.width(),
legend_bg.height(),
Color::new_rgba(255, 255, 255, 200),
true,
)?;
for (label, color) in legend_items {
let color_rect = Rect::from_xywh(legend_x, legend_y - 8.0, 12.0, 12.0).ok_or(
PlottingError::InvalidData {
message: "Invalid legend item dimensions".to_string(),
position: None,
},
)?;
self.draw_rectangle(
color_rect.left(),
color_rect.top(),
color_rect.width(),
color_rect.height(),
*color,
true,
)?;
self.draw_text(
label,
legend_x + 20.0,
legend_y,
legend_size,
Color::new_rgba(0, 0, 0, 255),
)?;
legend_y += legend_spacing;
}
Ok(())
}
pub fn draw_legend_positioned(
&mut self,
legend_items: &[(String, Color)],
plot_area: Rect,
position: crate::core::Position,
) -> Result<()> {
if legend_items.is_empty() {
return Ok(());
}
let legend_size = 12.0;
let legend_spacing = 20.0;
let legend_width = 140.0;
let legend_height = legend_items.len() as f32 * legend_spacing + 10.0;
let center_x = plot_area.left() + plot_area.width() / 2.0;
let center_y = plot_area.top() + plot_area.height() / 2.0;
let (legend_x, legend_y) = match position {
crate::core::Position::Best | crate::core::Position::TopRight => (
plot_area.right() - legend_width - 10.0,
plot_area.top() + 10.0,
),
crate::core::Position::TopLeft => (plot_area.left() + 10.0, plot_area.top() + 10.0),
crate::core::Position::TopCenter => {
(center_x - legend_width / 2.0, plot_area.top() + 10.0)
}
crate::core::Position::CenterLeft => {
(plot_area.left() + 10.0, center_y - legend_height / 2.0)
}
crate::core::Position::Center => (
center_x - legend_width / 2.0,
center_y - legend_height / 2.0,
),
crate::core::Position::CenterRight => (
plot_area.right() - legend_width - 10.0,
center_y - legend_height / 2.0,
),
crate::core::Position::BottomLeft => (
plot_area.left() + 10.0,
plot_area.bottom() - legend_height - 10.0,
),
crate::core::Position::BottomCenter => (
center_x - legend_width / 2.0,
plot_area.bottom() - legend_height - 10.0,
),
crate::core::Position::BottomRight => (
plot_area.right() - legend_width - 10.0,
plot_area.bottom() - legend_height - 10.0,
),
crate::core::Position::Custom { x, y } => (x, y),
};
let legend_bg =
Rect::from_xywh(legend_x - 10.0, legend_y - 5.0, legend_width, legend_height).ok_or(
PlottingError::InvalidData {
message: "Invalid legend dimensions".to_string(),
position: None,
},
)?;
self.draw_rectangle(
legend_bg.left(),
legend_bg.top(),
legend_bg.width(),
legend_bg.height(),
Color::new_rgba(255, 255, 255, 200),
true,
)?;
let mut item_y = legend_y + 10.0;
for (label, color) in legend_items {
let color_rect = Rect::from_xywh(legend_x, item_y - 8.0, 12.0, 12.0).ok_or(
PlottingError::InvalidData {
message: "Invalid legend item dimensions".to_string(),
position: None,
},
)?;
self.draw_rectangle(
color_rect.left(),
color_rect.top(),
color_rect.width(),
color_rect.height(),
*color,
true,
)?;
self.draw_text(
label,
legend_x + 20.0,
item_y,
legend_size,
Color::new_rgba(0, 0, 0, 255),
)?;
item_y += legend_spacing;
}
Ok(())
}
fn draw_legend_line_handle(
&mut self,
x: f32,
y: f32,
length: f32,
color: Color,
style: &LineStyle,
width: f32,
) -> Result<()> {
self.draw_line(x, y, x + length, y, color, width, style.clone())
}
fn draw_legend_scatter_handle(
&mut self,
x: f32,
y: f32,
length: f32,
color: Color,
marker: &MarkerStyle,
size: f32,
) -> Result<()> {
let center_x = x + length / 2.0;
self.draw_marker(center_x, y, size, *marker, color)
}
fn draw_legend_bar_handle(
&mut self,
x: f32,
y: f32,
length: f32,
height: f32,
color: Color,
) -> Result<()> {
let rect_y = y - height / 2.0;
self.draw_rectangle(x, rect_y, length, height, color, true)
}
fn draw_legend_line_marker_handle(
&mut self,
x: f32,
y: f32,
length: f32,
color: Color,
line_style: &LineStyle,
line_width: f32,
marker: &MarkerStyle,
marker_size: f32,
) -> Result<()> {
self.draw_legend_line_handle(x, y, length, color, line_style, line_width)?;
self.draw_legend_scatter_handle(x, y, length, color, marker, marker_size)
}
fn draw_legend_handle(
&mut self,
item: &LegendItem,
x: f32,
y: f32,
spacing: &LegendSpacingPixels,
) -> Result<()> {
let handle_length = spacing.handle_length;
let handle_height = spacing.handle_height;
match &item.item_type {
LegendItemType::Line { style, width } => {
let scaled_width = self.points_to_pixels(*width);
self.draw_legend_line_handle(x, y, handle_length, item.color, style, scaled_width)?;
}
LegendItemType::Scatter { marker, size } => {
let scaled_size = self.points_to_pixels(*size);
self.draw_legend_scatter_handle(
x,
y,
handle_length,
item.color,
marker,
scaled_size,
)?;
}
LegendItemType::LineMarker {
line_style,
line_width,
marker,
marker_size,
} => {
let scaled_line_width = self.points_to_pixels(*line_width);
let scaled_marker_size = self.points_to_pixels(*marker_size);
self.draw_legend_line_marker_handle(
x,
y,
handle_length,
item.color,
line_style,
scaled_line_width,
marker,
scaled_marker_size,
)?;
}
LegendItemType::Bar | LegendItemType::Histogram => {
self.draw_legend_bar_handle(x, y, handle_length, handle_height, item.color)?;
}
LegendItemType::Area { edge_color } => {
self.draw_legend_bar_handle(x, y, handle_length, handle_height, item.color)?;
if let Some(edge) = edge_color {
let rect_y = y - handle_height / 2.0;
let scaled_edge_width = self.logical_pixels_to_pixels(1.0);
self.draw_rectangle_outline(
x,
rect_y,
handle_length,
handle_height,
*edge,
scaled_edge_width,
)?;
}
}
LegendItemType::ErrorBar => {
let center_x = x + handle_length / 2.0;
let error_height = handle_height * 0.8;
let half_error = error_height / 2.0;
let cap_width = handle_height * 0.5;
let half_cap = cap_width / 2.0;
let error_line_width = self.logical_pixels_to_pixels(1.5);
self.draw_line(
center_x,
y - half_error,
center_x,
y + half_error,
item.color,
error_line_width,
LineStyle::Solid,
)?;
self.draw_line(
center_x - half_cap,
y - half_error,
center_x + half_cap,
y - half_error,
item.color,
error_line_width,
LineStyle::Solid,
)?;
self.draw_line(
center_x - half_cap,
y + half_error,
center_x + half_cap,
y + half_error,
item.color,
error_line_width,
LineStyle::Solid,
)?;
let marker_size = handle_height * 0.4;
self.draw_marker(center_x, y, marker_size, MarkerStyle::Circle, item.color)?;
}
}
if item.has_error_bars && !matches!(item.item_type, LegendItemType::ErrorBar) {
let center_x = x + handle_length / 2.0;
let error_height = handle_height * 0.7; let half_error = error_height / 2.0;
let cap_width = handle_height * 0.4;
let half_cap = cap_width / 2.0;
let overlay_line_width = self.logical_pixels_to_pixels(1.0);
self.draw_line(
center_x,
y - half_error,
center_x,
y + half_error,
item.color,
overlay_line_width,
LineStyle::Solid,
)?;
self.draw_line(
center_x - half_cap,
y - half_error,
center_x + half_cap,
y - half_error,
item.color,
overlay_line_width,
LineStyle::Solid,
)?;
self.draw_line(
center_x - half_cap,
y + half_error,
center_x + half_cap,
y + half_error,
item.color,
overlay_line_width,
LineStyle::Solid,
)?;
}
Ok(())
}
fn draw_rectangle_outline(
&mut self,
x: f32,
y: f32,
width: f32,
height: f32,
color: Color,
line_width: f32,
) -> Result<()> {
let x2 = x + width;
let y2 = y + height;
self.draw_line(x, y, x2, y, color, line_width, LineStyle::Solid)?;
self.draw_line(x2, y, x2, y2, color, line_width, LineStyle::Solid)?;
self.draw_line(x2, y2, x, y2, color, line_width, LineStyle::Solid)?;
self.draw_line(x, y2, x, y, color, line_width, LineStyle::Solid)
}
fn draw_rounded_rectangle_outline(
&mut self,
x: f32,
y: f32,
width: f32,
height: f32,
corner_radius: f32,
color: Color,
line_width: f32,
) -> Result<()> {
let max_radius = (width.min(height) / 2.0).max(0.0);
let radius = corner_radius.min(max_radius);
if radius < 0.1 {
return self.draw_rectangle_outline(x, y, width, height, color, line_width);
}
let mut pb = PathBuilder::new();
pb.move_to(x + radius, y);
pb.line_to(x + width - radius, y);
pb.quad_to(x + width, y, x + width, y + radius);
pb.line_to(x + width, y + height - radius);
pb.quad_to(x + width, y + height, x + width - radius, y + height);
pb.line_to(x + radius, y + height);
pb.quad_to(x, y + height, x, y + height - radius);
pb.line_to(x, y + radius);
pb.quad_to(x, y, x + radius, y);
pb.close();
let path = pb.finish().ok_or(PlottingError::RenderError(
"Failed to create rounded rectangle outline path".to_string(),
))?;
let mut paint = Paint::default();
paint.set_color(color.to_tiny_skia_color());
paint.anti_alias = true;
let stroke = Stroke {
width: line_width,
line_cap: LineCap::Round,
line_join: LineJoin::Round,
..Stroke::default()
};
self.pixmap
.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
Ok(())
}
fn draw_legend_frame(
&mut self,
x: f32,
y: f32,
width: f32,
height: f32,
style: &LegendStyle,
) -> Result<()> {
if !style.visible {
return Ok(());
}
let radius = style.effective_corner_radius();
if style.shadow {
let (shadow_dx, shadow_dy) = style.shadow_offset;
if radius > 0.0 {
self.draw_rounded_rectangle(
x + shadow_dx,
y + shadow_dy,
width,
height,
radius,
style.shadow_color,
true,
)?;
} else {
self.draw_rectangle(
x + shadow_dx,
y + shadow_dy,
width,
height,
style.shadow_color,
true,
)?;
}
}
let face_color = style.effective_face_color();
if radius > 0.0 {
self.draw_rounded_rectangle(x, y, width, height, radius, face_color, true)?;
} else {
self.draw_rectangle(x, y, width, height, face_color, true)?;
}
if let Some(edge_color) = style.edge_color {
if radius > 0.0 {
self.draw_rounded_rectangle_outline(
x,
y,
width,
height,
radius,
edge_color,
style.border_width,
)?;
} else {
self.draw_rectangle_outline(x, y, width, height, edge_color, style.border_width)?;
}
}
Ok(())
}
fn calculate_legend_dimensions(
&self,
items: &[LegendItem],
legend: &Legend,
char_width: f32,
) -> (f32, f32) {
legend.calculate_size(items, char_width)
}
pub fn draw_legend_full(
&mut self,
items: &[LegendItem],
legend: &Legend,
plot_area: Rect,
data_bboxes: Option<&[(f32, f32, f32, f32)]>,
) -> Result<()> {
if items.is_empty() || !legend.enabled {
return Ok(());
}
let spacing = legend.spacing.to_pixels(legend.font_size);
let char_width = legend.font_size * 0.6;
let (legend_width, legend_height) =
self.calculate_legend_dimensions(items, legend, char_width);
let plot_bounds = (
plot_area.left(),
plot_area.top(),
plot_area.right(),
plot_area.bottom(),
);
let position = if matches!(legend.position, LegendPosition::Best) {
let bboxes = data_bboxes.unwrap_or(&[]);
if bboxes.iter().map(|b| 1).sum::<usize>() > 100000 {
LegendPosition::UpperRight
} else {
find_best_position(
(legend_width, legend_height),
plot_bounds,
bboxes,
&legend.spacing,
legend.font_size,
)
}
} else {
legend.position
};
let resolved_legend = Legend {
position,
..legend.clone()
};
let (legend_x, legend_y) =
resolved_legend.calculate_position((legend_width, legend_height), plot_bounds);
self.draw_legend_frame(
legend_x,
legend_y,
legend_width,
legend_height,
&legend.style,
)?;
let item_x = legend_x + spacing.border_pad;
let mut item_y = legend_y + spacing.border_pad + legend.font_size / 2.0;
if let Some(ref title) = legend.title {
let title_x = legend_x + legend_width / 2.0;
self.draw_text_centered(title, title_x, item_y, legend.font_size, legend.text_color)?;
item_y += legend.font_size + spacing.label_spacing;
}
let items_per_col = items.len().div_ceil(legend.columns);
let max_label_len = items.iter().map(|item| item.label.len()).max().unwrap_or(0);
let label_width = max_label_len as f32 * char_width;
let col_width = spacing.handle_length + spacing.handle_text_pad + label_width;
for col in 0..legend.columns {
let col_x = item_x + col as f32 * (col_width + spacing.column_spacing);
let mut row_y = item_y;
for row in 0..items_per_col {
let idx = col * items_per_col + row;
if idx >= items.len() {
break;
}
let item = &items[idx];
self.draw_legend_handle(item, col_x, row_y, &spacing)?;
let text_x = col_x + spacing.handle_length + spacing.handle_text_pad;
let centered_y = row_y - legend.font_size * 0.65;
self.draw_text(
&item.label,
text_x,
centered_y,
legend.font_size,
legend.text_color,
)?;
row_y += legend.font_size + spacing.label_spacing;
}
}
Ok(())
}
pub fn draw_colorbar(
&mut self,
colormap: &crate::render::ColorMap,
vmin: f64,
vmax: f64,
x: f32,
y: f32,
width: f32,
height: f32,
label: Option<&str>,
foreground_color: Color,
tick_font_size: f32,
label_font_size: Option<f32>,
) -> Result<()> {
let label_font_size = label_font_size.unwrap_or(tick_font_size * 1.1);
let num_segments = (height as usize).max(50);
let segment_height = height / num_segments as f32;
for i in 0..num_segments {
let normalized = 1.0 - (i as f64 / (num_segments - 1).max(1) as f64);
let color = colormap.sample(normalized);
let segment_y = y + i as f32 * segment_height;
self.draw_solid_rectangle(x, segment_y, width, segment_height + 0.5, color)?;
}
let stroke_width = 1.0;
self.draw_rectangle(x, y, width, height, foreground_color, false)?;
let ticks = generate_ticks(vmin, vmax, 6);
let tick_width = width * 0.3;
let text_offset = width + tick_font_size * 0.5;
for &value in &ticks {
let t = (value - vmin) / (vmax - vmin);
let tick_y = y + height * (1.0 - t as f32);
self.draw_line(
x + width,
tick_y,
x + width + tick_width,
tick_y,
foreground_color,
stroke_width,
LineStyle::Solid,
)?;
let label_text = format_tick_label(value);
self.draw_text(
&label_text,
x + text_offset,
tick_y + tick_font_size * 0.3,
tick_font_size,
foreground_color,
)?;
}
if let Some(label) = label {
let label_x = x + width + tick_font_size * 4.0;
let label_y = y + height / 2.0;
self.draw_text_rotated(label, label_x, label_y, label_font_size, foreground_color)?;
}
Ok(())
}
pub fn into_image(self) -> Image {
Image {
width: self.width,
height: self.height,
pixels: self.pixmap.data().to_vec(),
}
}
pub fn save_png<P: AsRef<Path>>(&self, path: P) -> Result<()> {
let image = Image {
width: self.width,
height: self.height,
pixels: self.pixmap.clone().take_demultiplied(),
};
crate::export::write_rgba_png_atomic(path, &image)
}
pub fn export_svg<P: AsRef<Path>>(&self, path: P, width: u32, height: u32) -> Result<()> {
let svg_content = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<svg width="{}" height="{}" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="{}"/>
<text x="50%" y="50%" text-anchor="middle" font-family="Arial" font-size="16">
Ruviz Plot ({} x {})
</text>
</svg>"#,
width, height, self.theme.background, width, height
);
crate::export::write_bytes_atomic(path, svg_content.as_bytes())
}
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
pub fn draw_subplot(
&mut self,
subplot_image: crate::core::plot::Image,
x: u32,
y: u32,
) -> Result<()> {
let subplot_pixmap = tiny_skia::Pixmap::from_vec(
subplot_image.pixels,
tiny_skia::IntSize::from_wh(subplot_image.width, subplot_image.height).ok_or_else(
|| PlottingError::InvalidInput("Invalid subplot dimensions".to_string()),
)?,
)
.ok_or_else(|| PlottingError::RenderError("Failed to create subplot pixmap".to_string()))?;
self.pixmap.draw_pixmap(
x as i32,
y as i32,
subplot_pixmap.as_ref(),
&tiny_skia::PixmapPaint::default(),
tiny_skia::Transform::identity(),
None,
);
Ok(())
}
pub fn draw_annotations(
&mut self,
annotations: &[crate::core::Annotation],
plot_area: Rect,
x_min: f64,
x_max: f64,
y_min: f64,
y_max: f64,
dpi: f32,
) -> Result<()> {
for annotation in annotations {
self.draw_annotation(annotation, plot_area, x_min, x_max, y_min, y_max, dpi)?;
}
Ok(())
}
fn draw_annotation(
&mut self,
annotation: &crate::core::Annotation,
plot_area: Rect,
x_min: f64,
x_max: f64,
y_min: f64,
y_max: f64,
dpi: f32,
) -> Result<()> {
use crate::core::Annotation;
match annotation {
Annotation::Text { x, y, text, style } => self.draw_annotation_text(
*x, *y, text, style, plot_area, x_min, x_max, y_min, y_max, dpi,
),
Annotation::Arrow {
x1,
y1,
x2,
y2,
style,
} => self.draw_annotation_arrow(
*x1, *y1, *x2, *y2, style, plot_area, x_min, x_max, y_min, y_max, dpi,
),
Annotation::HLine {
y,
style,
color,
width,
} => {
self.draw_annotation_hline(*y, style, *color, *width, plot_area, y_min, y_max, dpi)
}
Annotation::VLine {
x,
style,
color,
width,
} => {
self.draw_annotation_vline(*x, style, *color, *width, plot_area, x_min, x_max, dpi)
}
Annotation::Rectangle {
x,
y,
width,
height,
style,
} => self.draw_annotation_rect(
*x, *y, *width, *height, style, plot_area, x_min, x_max, y_min, y_max,
),
Annotation::FillBetween {
x,
y1,
y2,
style,
where_positive,
} => self.draw_annotation_fill_between(
x,
y1,
y2,
style,
*where_positive,
plot_area,
x_min,
x_max,
y_min,
y_max,
),
Annotation::HSpan {
x_min: xmin,
x_max: xmax,
style,
} => self
.draw_annotation_hspan(*xmin, *xmax, style, plot_area, x_min, x_max, y_min, y_max),
Annotation::VSpan {
y_min: ymin,
y_max: ymax,
style,
} => self
.draw_annotation_vspan(*ymin, *ymax, style, plot_area, x_min, x_max, y_min, y_max),
}
}
fn draw_annotation_text(
&mut self,
x: f64,
y: f64,
text: &str,
style: &crate::core::TextStyle,
plot_area: Rect,
x_min: f64,
x_max: f64,
y_min: f64,
y_max: f64,
dpi: f32,
) -> Result<()> {
let (px, py) = map_data_to_pixels(x, y, x_min, x_max, y_min, y_max, plot_area);
let font_size_px = pt_to_px(style.font_size, dpi);
self.draw_text(text, px, py, font_size_px, style.color)
}
fn draw_annotation_arrow(
&mut self,
x1: f64,
y1: f64,
x2: f64,
y2: f64,
style: &crate::core::ArrowStyle,
plot_area: Rect,
x_min: f64,
x_max: f64,
y_min: f64,
y_max: f64,
dpi: f32,
) -> Result<()> {
let (px1, py1) = map_data_to_pixels(x1, y1, x_min, x_max, y_min, y_max, plot_area);
let (px2, py2) = map_data_to_pixels(x2, y2, x_min, x_max, y_min, y_max, plot_area);
let line_width_px = pt_to_px(style.line_width, dpi);
self.draw_line(
px1,
py1,
px2,
py2,
style.color,
line_width_px,
style.line_style.clone(),
)?;
if !matches!(style.head_style, crate::core::ArrowHead::None) {
let head_length_px = pt_to_px(style.head_length, dpi);
let head_width_px = pt_to_px(style.head_width, dpi);
self.draw_arrow_head(
px2,
py2,
px1,
py1,
head_length_px,
head_width_px,
style.color,
)?;
}
if !matches!(style.tail_style, crate::core::ArrowHead::None) {
let head_length_px = pt_to_px(style.head_length, dpi);
let head_width_px = pt_to_px(style.head_width, dpi);
self.draw_arrow_head(
px1,
py1,
px2,
py2,
head_length_px,
head_width_px,
style.color,
)?;
}
Ok(())
}
fn draw_arrow_head(
&mut self,
tip_x: f32,
tip_y: f32,
from_x: f32,
from_y: f32,
length: f32,
width: f32,
color: Color,
) -> Result<()> {
let dx = tip_x - from_x;
let dy = tip_y - from_y;
let len = (dx * dx + dy * dy).sqrt();
if len < 0.001 {
return Ok(());
}
let ux = dx / len;
let uy = dy / len;
let px = -uy;
let py = ux;
let base_x = tip_x - ux * length;
let base_y = tip_y - uy * length;
let left_x = base_x + px * width / 2.0;
let left_y = base_y + py * width / 2.0;
let right_x = base_x - px * width / 2.0;
let right_y = base_y - py * width / 2.0;
let mut path = PathBuilder::new();
path.move_to(tip_x, tip_y);
path.line_to(left_x, left_y);
path.line_to(right_x, right_y);
path.close();
let path = path.finish().ok_or(PlottingError::RenderError(
"Failed to create arrow head path".to_string(),
))?;
let mut paint = Paint::default();
paint.set_color(color.to_tiny_skia_color());
paint.anti_alias = true;
self.pixmap.fill_path(
&path,
&paint,
FillRule::Winding,
Transform::identity(),
None,
);
Ok(())
}
fn draw_annotation_hline(
&mut self,
y: f64,
style: &LineStyle,
color: Color,
width: f32,
plot_area: Rect,
y_min: f64,
y_max: f64,
dpi: f32,
) -> Result<()> {
let frac = (y - y_min) / (y_max - y_min);
let py = plot_area.bottom() - frac as f32 * plot_area.height();
let line_width_px = pt_to_px(width, dpi);
self.draw_line(
plot_area.left(),
py,
plot_area.right(),
py,
color,
line_width_px,
style.clone(),
)
}
fn draw_annotation_vline(
&mut self,
x: f64,
style: &LineStyle,
color: Color,
width: f32,
plot_area: Rect,
x_min: f64,
x_max: f64,
dpi: f32,
) -> Result<()> {
let frac = (x - x_min) / (x_max - x_min);
let px = plot_area.left() + frac as f32 * plot_area.width();
let line_width_px = pt_to_px(width, dpi);
self.draw_line(
px,
plot_area.top(),
px,
plot_area.bottom(),
color,
line_width_px,
style.clone(),
)
}
fn draw_annotation_rect(
&mut self,
x: f64,
y: f64,
width: f64,
height: f64,
style: &crate::core::ShapeStyle,
plot_area: Rect,
x_min: f64,
x_max: f64,
y_min: f64,
y_max: f64,
) -> Result<()> {
let (px1, py1) = map_data_to_pixels(x, y + height, x_min, x_max, y_min, y_max, plot_area);
let (px2, py2) = map_data_to_pixels(x + width, y, x_min, x_max, y_min, y_max, plot_area);
let rect_width = (px2 - px1).abs();
let rect_height = (py2 - py1).abs();
let rect_x = px1.min(px2);
let rect_y = py1.min(py2);
if let Some(rect) = Rect::from_xywh(rect_x, rect_y, rect_width, rect_height) {
if let Some(fill_color) = &style.fill_color {
let mut paint = Paint::default();
let color_with_alpha = fill_color.with_alpha(style.fill_alpha);
paint.set_color(color_with_alpha.to_tiny_skia_color());
paint.anti_alias = true;
self.pixmap
.fill_rect(rect, &paint, Transform::identity(), None);
}
if let Some(edge_color) = &style.edge_color {
let mut paint = Paint::default();
paint.set_color(edge_color.to_tiny_skia_color());
paint.anti_alias = true;
let mut stroke = Stroke {
width: style.edge_width.max(0.1),
..Stroke::default()
};
if let Some(dash_pattern) = self.scaled_dash_pattern(&style.edge_style) {
stroke.dash = StrokeDash::new(dash_pattern, 0.0);
}
let mut path = PathBuilder::new();
path.push_rect(rect);
if let Some(path) = path.finish() {
self.pixmap
.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
}
}
}
Ok(())
}
fn draw_annotation_fill_between(
&mut self,
x: &[f64],
y1: &[f64],
y2: &[f64],
style: &crate::core::FillStyle,
where_positive: bool,
plot_area: Rect,
x_min: f64,
x_max: f64,
y_min: f64,
y_max: f64,
) -> Result<()> {
if x.len() < 2 || x.len() != y1.len() || x.len() != y2.len() {
return Ok(()); }
let mut path = PathBuilder::new();
let (start_x, start_y) =
map_data_to_pixels(x[0], y1[0], x_min, x_max, y_min, y_max, plot_area);
path.move_to(start_x, start_y);
for i in 1..x.len() {
if !where_positive || y1[i] >= y2[i] {
let (px, py) =
map_data_to_pixels(x[i], y1[i], x_min, x_max, y_min, y_max, plot_area);
path.line_to(px, py);
} else {
let (px, py) =
map_data_to_pixels(x[i], y2[i], x_min, x_max, y_min, y_max, plot_area);
path.line_to(px, py);
}
}
for i in (0..x.len()).rev() {
if !where_positive || y1[i] >= y2[i] {
let (px, py) =
map_data_to_pixels(x[i], y2[i], x_min, x_max, y_min, y_max, plot_area);
path.line_to(px, py);
}
}
path.close();
if let Some(path) = path.finish() {
let color_with_alpha = style.color.with_alpha(style.alpha);
let mut paint = Paint::default();
paint.set_color(color_with_alpha.to_tiny_skia_color());
paint.anti_alias = true;
self.pixmap.fill_path(
&path,
&paint,
FillRule::Winding,
Transform::identity(),
None,
);
if let Some(edge_color) = &style.edge_color {
let mut edge_paint = Paint::default();
edge_paint.set_color(edge_color.to_tiny_skia_color());
edge_paint.anti_alias = true;
let stroke = Stroke {
width: style.edge_width.max(0.1),
..Stroke::default()
};
self.pixmap
.stroke_path(&path, &edge_paint, &stroke, Transform::identity(), None);
}
}
Ok(())
}
fn draw_annotation_hspan(
&mut self,
span_x_min: f64,
span_x_max: f64,
style: &crate::core::ShapeStyle,
plot_area: Rect,
x_min: f64,
x_max: f64,
_y_min: f64,
_y_max: f64,
) -> Result<()> {
let frac_min = ((span_x_min - x_min) / (x_max - x_min)) as f32;
let frac_max = ((span_x_max - x_min) / (x_max - x_min)) as f32;
let px_min = plot_area.left() + frac_min * plot_area.width();
let px_max = plot_area.left() + frac_max * plot_area.width();
let left = px_min.max(plot_area.left()).min(plot_area.right());
let right = px_max.max(plot_area.left()).min(plot_area.right());
if let Some(rect) = Rect::from_xywh(left, plot_area.top(), right - left, plot_area.height())
{
if let Some(fill_color) = &style.fill_color {
let mut paint = Paint::default();
let color_with_alpha = fill_color.with_alpha(style.fill_alpha);
paint.set_color(color_with_alpha.to_tiny_skia_color());
paint.anti_alias = true;
self.pixmap
.fill_rect(rect, &paint, Transform::identity(), None);
}
}
Ok(())
}
fn draw_annotation_vspan(
&mut self,
span_y_min: f64,
span_y_max: f64,
style: &crate::core::ShapeStyle,
plot_area: Rect,
_x_min: f64,
_x_max: f64,
y_min: f64,
y_max: f64,
) -> Result<()> {
let frac_min = ((span_y_min - y_min) / (y_max - y_min)) as f32;
let frac_max = ((span_y_max - y_min) / (y_max - y_min)) as f32;
let py_max = plot_area.bottom() - frac_min * plot_area.height(); let py_min = plot_area.bottom() - frac_max * plot_area.height();
let top = py_min.max(plot_area.top()).min(plot_area.bottom());
let bottom = py_max.max(plot_area.top()).min(plot_area.bottom());
if let Some(rect) = Rect::from_xywh(plot_area.left(), top, plot_area.width(), bottom - top)
{
if let Some(fill_color) = &style.fill_color {
let mut paint = Paint::default();
let color_with_alpha = fill_color.with_alpha(style.fill_alpha);
paint.set_color(color_with_alpha.to_tiny_skia_color());
paint.anti_alias = true;
self.pixmap
.fill_rect(rect, &paint, Transform::identity(), None);
}
}
Ok(())
}
}
pub fn calculate_plot_area(canvas_width: u32, canvas_height: u32, margin_fraction: f32) -> Rect {
let margin_x = (canvas_width as f32) * margin_fraction;
let margin_y = (canvas_height as f32) * margin_fraction;
Rect::from_xywh(
margin_x,
margin_y,
(canvas_width as f32) - 2.0 * margin_x,
(canvas_height as f32) - 2.0 * margin_y,
)
.unwrap_or_else(|| {
Rect::from_xywh(
10.0,
10.0,
(canvas_width as f32) - 20.0,
(canvas_height as f32) - 20.0,
)
.unwrap()
})
}
pub fn calculate_plot_area_dpi(canvas_width: u32, canvas_height: u32, dpi_scale: f32) -> Rect {
let render_scale = RenderScale::from_reference_scale(dpi_scale);
let base_margin_left = 100.0; let base_margin_right = 40.0; let base_margin_top = 80.0; let base_margin_bottom = 60.0;
let margin_left = render_scale.logical_pixels_to_pixels(base_margin_left);
let margin_right = render_scale.logical_pixels_to_pixels(base_margin_right);
let margin_top = render_scale.logical_pixels_to_pixels(base_margin_top);
let margin_bottom = render_scale.logical_pixels_to_pixels(base_margin_bottom);
let plot_width = (canvas_width as f32) - margin_left - margin_right;
let plot_height = (canvas_height as f32) - margin_top - margin_bottom;
if plot_width > 100.0 && plot_height > 100.0 {
let plot_x = margin_left;
let plot_y = margin_top;
Rect::from_xywh(plot_x, plot_y, plot_width, plot_height).unwrap_or_else(|| {
Rect::from_xywh(
40.0,
40.0,
(canvas_width as f32) - 80.0,
(canvas_height as f32) - 80.0,
)
.unwrap()
})
} else {
let fallback_margin = (canvas_width.min(canvas_height) as f32) * 0.1;
Rect::from_xywh(
fallback_margin,
fallback_margin,
(canvas_width as f32) - 2.0 * fallback_margin,
(canvas_height as f32) - 2.0 * fallback_margin,
)
.unwrap()
}
}
pub fn calculate_plot_area_config(
canvas_width: u32,
canvas_height: u32,
margins: &ComputedMargins,
dpi: f32,
) -> Rect {
let margin_left = margins.left_px(dpi);
let margin_right = margins.right_px(dpi);
let margin_top = margins.top_px(dpi);
let margin_bottom = margins.bottom_px(dpi);
let plot_width = (canvas_width as f32) - margin_left - margin_right;
let plot_height = (canvas_height as f32) - margin_top - margin_bottom;
if plot_width > 50.0 && plot_height > 50.0 {
let plot_x = margin_left;
let plot_y = margin_top;
Rect::from_xywh(plot_x, plot_y, plot_width, plot_height).unwrap_or_else(|| {
Rect::from_xywh(
40.0,
40.0,
(canvas_width as f32) - 80.0,
(canvas_height as f32) - 80.0,
)
.unwrap()
})
} else {
let fallback_margin = (canvas_width.min(canvas_height) as f32) * 0.1;
Rect::from_xywh(
fallback_margin,
fallback_margin,
(canvas_width as f32) - 2.0 * fallback_margin,
(canvas_height as f32) - 2.0 * fallback_margin,
)
.unwrap()
}
}
pub fn map_data_to_pixels(
data_x: f64,
data_y: f64,
data_x_min: f64,
data_x_max: f64,
data_y_min: f64,
data_y_max: f64,
plot_area: Rect,
) -> (f32, f32) {
let transform = CoordinateTransform::from_plot_area(
plot_area.left(),
plot_area.top(),
plot_area.width(),
plot_area.height(),
data_x_min,
data_x_max,
data_y_min,
data_y_max,
);
transform.data_to_screen(data_x, data_y)
}
pub fn map_data_to_pixels_scaled(
data_x: f64,
data_y: f64,
data_x_min: f64,
data_x_max: f64,
data_y_min: f64,
data_y_max: f64,
plot_area: Rect,
x_scale: &crate::axes::AxisScale,
y_scale: &crate::axes::AxisScale,
) -> (f32, f32) {
use crate::axes::Scale;
let x_scale_obj = x_scale.create_scale(data_x_min, data_x_max);
let y_scale_obj = y_scale.create_scale(data_y_min, data_y_max);
let normalized_x = x_scale_obj.transform(data_x);
let normalized_y = y_scale_obj.transform(data_y);
let transform = CoordinateTransform::from_plot_area(
plot_area.left(),
plot_area.top(),
plot_area.width(),
plot_area.height(),
0.0, 1.0, 0.0, 1.0, );
transform.data_to_screen(normalized_x, normalized_y)
}
pub fn generate_ticks(min: f64, max: f64, target_count: usize) -> Vec<f64> {
if min >= max || target_count == 0 {
return vec![min, max];
}
let max_ticks = target_count.clamp(3, 10);
generate_scientific_ticks(min, max, max_ticks)
}
fn generate_scientific_ticks(min: f64, max: f64, max_ticks: usize) -> Vec<f64> {
let range = max - min;
if range <= 0.0 {
return vec![min];
}
let rough_step = range / (max_ticks - 1) as f64;
if rough_step <= f64::EPSILON {
return vec![min, max];
}
let magnitude = 10.0_f64.powf(rough_step.log10().floor());
let normalized_step = rough_step / magnitude;
let nice_step = if normalized_step <= 1.0 {
1.0
} else if normalized_step <= 2.0 {
2.0
} else if normalized_step <= 5.0 {
5.0
} else {
10.0
};
let step = nice_step * magnitude;
let start = (min / step).floor() * step;
let end = (max / step).ceil() * step;
let mut ticks = Vec::new();
let mut tick = start;
let epsilon = step * 1e-10;
while tick <= end + epsilon {
if tick >= min - epsilon && tick <= max + epsilon {
let clean_tick = clean_tick_value(tick, step);
ticks.push(clean_tick);
}
tick += step;
if ticks.len() > max_ticks * 2 {
break;
}
}
if ticks.len() < 3 {
let range = max - min;
let fallback_step = range / 2.0;
let clean_min = clean_tick_value(min, fallback_step);
let clean_max = clean_tick_value(max, fallback_step);
let clean_middle = clean_tick_value((min + max) / 2.0, fallback_step);
return vec![clean_min, clean_middle, clean_max];
}
if ticks.len() > max_ticks {
ticks.truncate(max_ticks);
}
ticks
}
fn clean_tick_value(value: f64, step: f64) -> f64 {
let decimals = if step >= 1.0 {
0
} else {
(-step.log10().floor()) as i32 + 1
};
let mult = 10.0_f64.powi(decimals);
(value * mult).round() / mult
}
pub fn generate_minor_ticks(major_ticks: &[f64], minor_count: usize) -> Vec<f64> {
if major_ticks.len() < 2 || minor_count == 0 {
return Vec::new();
}
let mut minor_ticks = Vec::new();
for i in 0..major_ticks.len() - 1 {
let start = major_ticks[i];
let end = major_ticks[i + 1];
let step = (end - start) / (minor_count + 1) as f64;
for j in 1..=minor_count {
let minor_tick = start + step * j as f64;
minor_ticks.push(minor_tick);
}
}
minor_ticks
}
pub fn format_tick_label(value: f64) -> String {
static FORMATTER: std::sync::LazyLock<TickFormatter> =
std::sync::LazyLock::new(TickFormatter::default);
FORMATTER.format_tick(value)
}
pub fn format_tick_labels(values: &[f64]) -> Vec<String> {
static FORMATTER: std::sync::LazyLock<TickFormatter> =
std::sync::LazyLock::new(TickFormatter::default);
FORMATTER.format_ticks(values)
}
#[test]
fn test_renderer_dimensions() {
let theme = Theme::default();
let renderer = SkiaRenderer::new(800, 600, theme).unwrap();
assert_eq!(renderer.width(), 800);
assert_eq!(renderer.height(), 600);
}
#[test]
fn test_draw_subplot() {
use crate::core::plot::Image;
let theme = Theme::default();
let mut main_renderer = SkiaRenderer::new(800, 600, theme.clone()).unwrap();
let subplot_renderer = SkiaRenderer::new(200, 150, theme).unwrap();
let subplot_image = subplot_renderer.into_image();
let result = main_renderer.draw_subplot(subplot_image, 10, 20);
assert!(result.is_ok());
}
#[test]
fn test_draw_subplot_bounds_checking() {
use crate::core::plot::Image;
let theme = Theme::default();
let mut main_renderer = SkiaRenderer::new(400, 300, theme.clone()).unwrap();
let subplot_renderer = SkiaRenderer::new(200, 150, theme).unwrap();
let subplot_image = subplot_renderer.into_image();
assert!(
main_renderer
.draw_subplot(subplot_image.clone(), 0, 0)
.is_ok()
);
assert!(
main_renderer
.draw_subplot(subplot_image.clone(), 100, 50)
.is_ok()
);
assert!(main_renderer.draw_subplot(subplot_image, 200, 150).is_ok());
}
#[test]
fn test_to_image_conversion() {
let theme = Theme::default();
let renderer = SkiaRenderer::new(400, 300, theme).unwrap();
let image = renderer.into_image();
assert_eq!(image.width, 400);
assert_eq!(image.height, 300);
assert_eq!(image.pixels.len(), 400 * 300 * 4); }
#[cfg(test)]
mod tests {
use super::*;
fn pixel_is_dark(image: &Image, x: u32, y: u32) -> bool {
let idx = ((y * image.width + x) * 4) as usize;
image.pixels[idx..idx + 3]
.iter()
.all(|channel| *channel < 220)
}
fn count_red_pixels_outside_rect(image: &Image, rect: Rect) -> usize {
let left = rect.left().floor() as i32;
let right = rect.right().ceil() as i32;
let top = rect.top().floor() as i32;
let bottom = rect.bottom().ceil() as i32;
let mut count = 0usize;
for y in 0..image.height as i32 {
for x in 0..image.width as i32 {
if x >= left && x < right && y >= top && y < bottom {
continue;
}
let idx = ((y as u32 * image.width + x as u32) * 4) as usize;
let pixel = &image.pixels[idx..idx + 4];
if pixel[3] > 0 && pixel[0] > 160 && pixel[1] < 80 && pixel[2] < 80 {
count += 1;
}
}
}
count
}
#[test]
fn test_renderer_creation() {
let theme = Theme::default();
let renderer = SkiaRenderer::new(800, 600, theme);
assert!(renderer.is_ok());
let renderer = renderer.unwrap();
assert_eq!(renderer.width, 800);
assert_eq!(renderer.height, 600);
}
#[test]
fn test_set_dpi_scale_sanitizes_invalid_values() {
let theme = Theme::default();
let mut renderer = SkiaRenderer::new(100, 100, theme).unwrap();
renderer.set_dpi_scale(2.5);
assert!((renderer.dpi_scale() - 2.5).abs() < f32::EPSILON);
renderer.set_dpi_scale(0.0);
assert!((renderer.dpi_scale() - 1.0).abs() < f32::EPSILON);
renderer.set_dpi_scale(-3.0);
assert!((renderer.dpi_scale() - 1.0).abs() < f32::EPSILON);
renderer.set_dpi_scale(f32::NAN);
assert!((renderer.dpi_scale() - 1.0).abs() < f32::EPSILON);
renderer.set_dpi_scale(f32::INFINITY);
assert!((renderer.dpi_scale() - 1.0).abs() < f32::EPSILON);
}
#[test]
fn test_plot_area_calculation() {
let area = calculate_plot_area(800, 600, 0.1);
assert_eq!(area.left(), 80.0);
assert_eq!(area.top(), 60.0);
assert_eq!(area.width(), 640.0);
assert_eq!(area.height(), 480.0);
}
#[test]
fn test_data_to_pixel_mapping() {
let plot_area = Rect::from_xywh(100.0, 100.0, 600.0, 400.0).unwrap();
let (px, py) = map_data_to_pixels(
1.5, 2.5, 1.0, 2.0, 2.0, 3.0, plot_area,
);
assert_eq!(px, 400.0); assert_eq!(py, 300.0); }
#[test]
fn test_tick_generation() {
let ticks = generate_ticks(0.0, 10.0, 5);
assert!(!ticks.is_empty());
assert!(ticks[0] >= 0.0);
assert!(ticks.last().unwrap() <= &10.0);
let ticks = generate_ticks(5.0, 5.0, 3);
assert_eq!(ticks, vec![5.0, 5.0]);
}
#[test]
fn test_draw_axes_with_config_draws_top_and_right_ticks() {
let theme = Theme::default();
let mut renderer = SkiaRenderer::new(120, 100, theme).unwrap();
let plot_area = Rect::from_xywh(20.0, 20.0, 80.0, 60.0).unwrap();
renderer
.draw_axes_with_config(
plot_area,
&[60.0],
&[50.0],
&[],
&[],
&TickDirection::Inside,
&TickSides::all(),
Color::BLACK,
1.0,
)
.unwrap();
let image = renderer.into_image();
assert!(pixel_is_dark(&image, 60, 20));
assert!(pixel_is_dark(&image, 100, 50));
assert!(pixel_is_dark(&image, 60, 24));
assert!(pixel_is_dark(&image, 96, 50));
}
#[test]
fn test_draw_axes_with_config_respects_bottom_left_ticks() {
let theme = Theme::default();
let mut renderer = SkiaRenderer::new(120, 100, theme).unwrap();
let plot_area = Rect::from_xywh(20.0, 20.0, 80.0, 60.0).unwrap();
renderer
.draw_axes_with_config(
plot_area,
&[60.0],
&[50.0],
&[],
&[],
&TickDirection::Inside,
&TickSides::bottom_left(),
Color::BLACK,
1.0,
)
.unwrap();
let image = renderer.into_image();
assert!(pixel_is_dark(&image, 60, 20));
assert!(pixel_is_dark(&image, 100, 50));
assert!(!pixel_is_dark(&image, 60, 24));
assert!(!pixel_is_dark(&image, 96, 50));
assert!(pixel_is_dark(&image, 60, 76));
assert!(pixel_is_dark(&image, 24, 50));
}
#[test]
fn test_draw_axis_labels_at_handles_collapsed_ranges() {
let theme = Theme::default();
let mut renderer = SkiaRenderer::new(120, 100, theme).unwrap();
let plot_area = LayoutRect {
left: 20.0,
top: 20.0,
right: 100.0,
bottom: 80.0,
};
renderer
.draw_axis_labels_at(
&plot_area,
1.0,
1.0,
2.0,
2.0,
&[1.0],
&[2.0],
88.0,
18.0,
10.0,
Color::BLACK,
100.0,
true,
false,
)
.expect("collapsed ranges should use centered label placement");
}
#[test]
fn test_draw_polyline_clipped_keeps_pixels_inside_clip_rect() {
let theme = Theme::default();
let mut renderer = SkiaRenderer::new(120, 120, theme).unwrap();
let clip_rect = Rect::from_xywh(20.0, 20.0, 80.0, 80.0).unwrap();
renderer
.draw_polyline_clipped(
&[(20.0, 20.0), (100.0, 100.0)],
Color::new(220, 20, 20),
18.0,
LineStyle::Solid,
(
clip_rect.x(),
clip_rect.y(),
clip_rect.width(),
clip_rect.height(),
),
)
.unwrap();
let image = renderer.into_image();
assert_eq!(count_red_pixels_outside_rect(&image, clip_rect), 0);
}
#[cfg(feature = "typst-math")]
#[test]
fn test_typst_raster_uses_native_1x_scale() {
let theme = Theme::default();
let mut renderer = SkiaRenderer::new(400, 300, theme).unwrap();
renderer.set_dpi_scale(1.0);
renderer.set_text_engine_mode(TextEngineMode::Typst);
let rendered_native = typst_text::render_raster(
"scale-check",
12.0,
Color::BLACK,
0.0,
"typst native scale test",
)
.unwrap();
let rendered_second = typst_text::render_raster(
"scale-check",
12.0,
Color::BLACK,
0.0,
"typst native scale test",
)
.unwrap();
assert_eq!(
rendered_native.pixmap.width(),
rendered_second.pixmap.width()
);
assert_eq!(
rendered_native.pixmap.height(),
rendered_second.pixmap.height()
);
assert!(
(rendered_native.pixmap.width() as f32 - rendered_native.width).abs() <= 1.0,
"native raster width should align with logical width: pixel={} logical={}",
rendered_native.pixmap.width(),
rendered_native.width
);
assert!(
(rendered_native.pixmap.height() as f32 - rendered_native.height).abs() <= 1.0,
"native raster height should align with logical height: pixel={} logical={}",
rendered_native.pixmap.height(),
rendered_native.height
);
}
}