use uzor::render::RenderContext;
use crate::coord::PlotArea;
use crate::scale::time::TickMarkWeight;
use crate::scale::Scale;
use crate::theme::FigureTheme;
#[derive(Debug, Clone, PartialEq)]
pub struct GridTickWeightStyle {
pub major_stroke_width: f64,
pub medium_stroke_width: f64,
pub minor_stroke_width: f64,
}
impl Default for GridTickWeightStyle {
fn default() -> Self {
Self { major_stroke_width: 1.5, medium_stroke_width: 1.2, minor_stroke_width: 1.0 }
}
}
impl GridTickWeightStyle {
pub fn flat() -> Self {
Self { major_stroke_width: 1.0, medium_stroke_width: 1.0, minor_stroke_width: 1.0 }
}
}
fn resolve_stroke_width(weight: Option<TickMarkWeight>, style: &GridTickWeightStyle) -> f64 {
match weight {
Some(w) if w.is_major() => style.major_stroke_width,
Some(w) if w.is_medium() => style.medium_stroke_width,
_ => style.minor_stroke_width,
}
}
pub fn draw_x_grid(ctx: &mut dyn RenderContext, area: &PlotArea, scale: &dyn Scale, theme: &FigureTheme, target_ticks: usize) {
draw_x_grid_impl(ctx, area, scale, theme, target_ticks, None);
}
pub fn draw_x_grid_weighted(ctx: &mut dyn RenderContext, area: &PlotArea, scale: &dyn Scale, theme: &FigureTheme, target_ticks: usize, style: &GridTickWeightStyle) {
draw_x_grid_impl(ctx, area, scale, theme, target_ticks, Some(style));
}
fn draw_x_grid_impl(ctx: &mut dyn RenderContext, area: &PlotArea, scale: &dyn Scale, theme: &FigureTheme, target_ticks: usize, weight_style: Option<&GridTickWeightStyle>) {
let ticks = scale.ticks(target_ticks);
if ticks.is_empty() {
return;
}
ctx.set_stroke_color(&theme.grid_color);
ctx.set_stroke_width(1.0);
ctx.set_line_dash(&[]);
for tick in &ticks {
let x = area.x(scale, tick.value);
if let Some(style) = weight_style {
ctx.set_stroke_width(resolve_stroke_width(scale.tick_weight(tick.value), style));
}
ctx.begin_path();
ctx.move_to(x, area.rect.y);
ctx.line_to(x, area.rect.bottom());
ctx.stroke();
}
}
pub fn draw_y_grid(ctx: &mut dyn RenderContext, area: &PlotArea, scale: &dyn Scale, theme: &FigureTheme, target_ticks: usize) {
draw_y_grid_impl(ctx, area, scale, theme, target_ticks, None);
}
pub fn draw_y_grid_weighted(ctx: &mut dyn RenderContext, area: &PlotArea, scale: &dyn Scale, theme: &FigureTheme, target_ticks: usize, style: &GridTickWeightStyle) {
draw_y_grid_impl(ctx, area, scale, theme, target_ticks, Some(style));
}
fn draw_y_grid_impl(ctx: &mut dyn RenderContext, area: &PlotArea, scale: &dyn Scale, theme: &FigureTheme, target_ticks: usize, weight_style: Option<&GridTickWeightStyle>) {
let ticks = scale.ticks(target_ticks);
if ticks.is_empty() {
return;
}
ctx.set_stroke_color(&theme.grid_color);
ctx.set_stroke_width(1.0);
ctx.set_line_dash(&[]);
for tick in &ticks {
let y = area.y(scale, tick.value);
if let Some(style) = weight_style {
ctx.set_stroke_width(resolve_stroke_width(scale.tick_weight(tick.value), style));
}
ctx.begin_path();
ctx.move_to(area.rect.x, y);
ctx.line_to(area.rect.right(), y);
ctx.stroke();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scale::{LinearScale, TimeScale};
use crate::theme::FigureTheme;
use uzor::types::Rect;
use uzor_export::{render_to_png, ExportSpec};
fn area() -> PlotArea {
PlotArea::new(Rect::new(40.0, 10.0, 300.0, 150.0))
}
#[test]
fn resolve_stroke_width_distinguishes_every_weight_tier() {
let style = GridTickWeightStyle::default();
let major = resolve_stroke_width(Some(TickMarkWeight::Year), &style);
let medium = resolve_stroke_width(Some(TickMarkWeight::Day), &style);
let minor = resolve_stroke_width(Some(TickMarkWeight::Minute1), &style);
let none = resolve_stroke_width(None, &style);
assert!(major > medium && medium > minor);
assert_eq!(minor, none, "no weight (every non-TimeScale scale) resolves the SAME as an explicit minor tick");
}
#[test]
fn flat_style_matches_minor_for_every_weight_tier() {
let flat = GridTickWeightStyle::flat();
assert_eq!(resolve_stroke_width(Some(TickMarkWeight::Year), &flat), resolve_stroke_width(None, &flat));
assert_eq!(resolve_stroke_width(None, &flat), 1.0);
}
#[test]
fn weighted_grid_over_a_non_time_scale_renders_byte_identical_to_the_unweighted_grid() {
let theme = FigureTheme::dark();
let scale = LinearScale::new(0.0, 1_000.0);
let spec = ExportSpec { width_px: 400, height_px: 200, dpr: 1.0, background: None };
let unweighted = render_to_png(&spec, |ctx| draw_x_grid(ctx, &area(), &scale, &theme, 6)).expect("unweighted x-grid render");
let weighted = render_to_png(&spec, |ctx| draw_x_grid_weighted(ctx, &area(), &scale, &theme, 6, &GridTickWeightStyle::default()))
.expect("weighted x-grid render over a non-TimeScale scale");
assert_eq!(unweighted, weighted, "a non-TimeScale x-grid must render identically through the weighted entry point");
}
#[test]
fn a_month_spanning_time_axis_resolves_distinguishable_major_and_minor_gridline_widths() {
let jan1_2024 = 1_704_067_200.0; const DAY_SECS: f64 = 86_400.0;
let scale = TimeScale::new(jan1_2024, jan1_2024 + 62.0 * DAY_SECS);
let ticks = scale.ticks(6);
assert!(ticks.len() >= 2, "fixture must produce a real multi-tick set");
let style = GridTickWeightStyle::default();
let mut widths: Vec<f64> = ticks.iter().map(|t| resolve_stroke_width(scale.tick_weight(t.value), &style)).collect();
widths.sort_by(|a, b| a.partial_cmp(b).unwrap());
widths.dedup();
assert!(widths.len() >= 2, "a month-spanning time axis must resolve at least 2 distinct gridline widths");
let theme = FigureTheme::dark();
let spec = ExportSpec { width_px: 600, height_px: 250, dpr: 1.0, background: None };
let result = render_to_png(&spec, |ctx| draw_x_grid_weighted(ctx, &area(), &scale, &theme, 6, &style));
assert!(result.is_ok());
}
}