use uzor::render::{CircleBatch, RenderContext, TextAlign, TextBaseline};
use uzor::types::Rect;
use crate::coord::PlotArea;
use crate::guide::text_protect::plate_under_text;
use crate::scale::Scale;
use crate::theme::FigureTheme;
const REFERENCE_DASH: [f64; 2] = [5.0, 3.0];
const REFERENCE_LABEL_GAP: f64 = 4.0;
const BAND_ALPHA: f64 = 0.14;
const CALLOUT_PAD: f64 = 6.0;
const CALLOUT_OFFSET: f64 = 10.0;
const CALLOUT_DOT_RADIUS: f64 = 3.0;
const CALLOUT_CORNER_RADIUS: f64 = 3.0;
const CALLOUT_ALPHA: f64 = 0.94;
#[derive(Debug, Clone)]
pub enum Annotation {
HLine { value: f64, color: Option<String>, label: Option<String> },
VLine { value: f64, color: Option<String>, label: Option<String> },
HBand { low: f64, high: f64, color: Option<String>, label: Option<String> },
Callout { x: f64, y: f64, text: String },
}
fn dashed_hline(ctx: &mut dyn RenderContext, area: &PlotArea, yscale: &dyn Scale, theme: &FigureTheme, value: f64, color: &Option<String>, label: &Option<String>) {
let y = area.y(yscale, value);
let stroke = color.as_deref().unwrap_or(&theme.label_color);
ctx.set_stroke_color(stroke);
ctx.set_stroke_width(1.0);
ctx.set_line_dash(&REFERENCE_DASH);
ctx.begin_path();
ctx.move_to(area.rect.x, y);
ctx.line_to(area.rect.right(), y);
ctx.stroke();
ctx.set_line_dash(&[]);
if let Some(text) = label {
ctx.set_font(&theme.label_font);
ctx.set_fill_color(stroke);
ctx.set_text_align(TextAlign::Right);
ctx.set_text_baseline(TextBaseline::Bottom);
ctx.fill_text(text, area.rect.right() - REFERENCE_LABEL_GAP, y - REFERENCE_LABEL_GAP);
}
}
fn dashed_vline(ctx: &mut dyn RenderContext, area: &PlotArea, xscale: &dyn Scale, theme: &FigureTheme, value: f64, color: &Option<String>, label: &Option<String>) {
let x = area.x(xscale, value);
let stroke = color.as_deref().unwrap_or(&theme.label_color);
ctx.set_stroke_color(stroke);
ctx.set_stroke_width(1.0);
ctx.set_line_dash(&REFERENCE_DASH);
ctx.begin_path();
ctx.move_to(x, area.rect.y);
ctx.line_to(x, area.rect.bottom());
ctx.stroke();
ctx.set_line_dash(&[]);
if let Some(text) = label {
ctx.set_font(&theme.label_font);
ctx.set_fill_color(stroke);
ctx.set_text_align(TextAlign::Left);
ctx.set_text_baseline(TextBaseline::Top);
ctx.fill_text(text, x + REFERENCE_LABEL_GAP, area.rect.y + REFERENCE_LABEL_GAP);
}
}
fn shaded_hband_fill(ctx: &mut dyn RenderContext, area: &PlotArea, yscale: &dyn Scale, theme: &FigureTheme, low: f64, high: f64, color: &Option<String>) {
let y0 = area.y(yscale, low);
let y1 = area.y(yscale, high);
let (top, height) = (y0.min(y1), (y1 - y0).abs());
let fill = color.as_deref().unwrap_or(&theme.label_color);
ctx.set_fill_color(fill);
ctx.set_global_alpha(BAND_ALPHA);
ctx.fill_rect(area.rect.x, top, area.rect.width, height);
ctx.set_global_alpha(1.0);
}
fn shaded_hband_label(ctx: &mut dyn RenderContext, area: &PlotArea, yscale: &dyn Scale, theme: &FigureTheme, low: f64, high: f64, color: &Option<String>, label: &Option<String>) {
let Some(text) = label else { return };
let y0 = area.y(yscale, low);
let y1 = area.y(yscale, high);
let top = y0.min(y1);
let fill = color.as_deref().unwrap_or(&theme.label_color);
let x = area.rect.x + REFERENCE_LABEL_GAP;
let y = top + REFERENCE_LABEL_GAP;
ctx.set_font(&theme.label_font);
plate_under_text(ctx, theme, text, x, y, TextAlign::Left, TextBaseline::Top);
ctx.set_fill_color(fill);
ctx.set_text_align(TextAlign::Left);
ctx.set_text_baseline(TextBaseline::Top);
ctx.fill_text(text, x, y);
}
fn draw_callout(ctx: &mut dyn RenderContext, theme: &FigureTheme, anchor_px: (f64, f64), text: &str, bounds: Rect) {
if text.is_empty() {
return;
}
let (anchor_x, anchor_y) = anchor_px;
ctx.draw_circle_batch(&[CircleBatch { cx: anchor_x, cy: anchor_y, r: CALLOUT_DOT_RADIUS }], &theme.label_color);
ctx.set_font(&theme.label_font);
let text_w = ctx.measure_text(text);
let text_h = ctx.text_bounds("Ag", &theme.label_font).h.max(12.0);
let box_w = CALLOUT_PAD * 2.0 + text_w;
let box_h = CALLOUT_PAD * 2.0 + text_h;
let mut box_x = anchor_x + CALLOUT_OFFSET;
if box_x + box_w > bounds.right() {
box_x = (anchor_x - CALLOUT_OFFSET - box_w).max(bounds.x);
}
let mut box_y = anchor_y - box_h - CALLOUT_OFFSET;
if box_y < bounds.y {
box_y = (anchor_y + CALLOUT_OFFSET).min((bounds.bottom() - box_h).max(bounds.y));
}
if box_y + box_h > bounds.bottom() {
box_y = (bounds.bottom() - box_h).max(bounds.y);
}
ctx.set_stroke_color(&theme.label_color);
ctx.set_stroke_width(1.0);
ctx.set_line_dash(&[]);
ctx.begin_path();
ctx.move_to(anchor_x, anchor_y);
ctx.line_to((box_x + box_w / 2.0).clamp(box_x, box_x + box_w), (box_y + box_h / 2.0).clamp(box_y, box_y + box_h));
ctx.stroke();
ctx.set_fill_color(&theme.background);
ctx.set_global_alpha(CALLOUT_ALPHA);
ctx.fill_rounded_rect(box_x, box_y, box_w, box_h, CALLOUT_CORNER_RADIUS);
ctx.set_global_alpha(1.0);
ctx.set_stroke_color(&theme.axis_color);
ctx.stroke_rounded_rect(box_x, box_y, box_w, box_h, CALLOUT_CORNER_RADIUS);
ctx.set_font(&theme.label_font);
ctx.set_fill_color(&theme.label_color);
ctx.set_text_align(TextAlign::Left);
ctx.set_text_baseline(TextBaseline::Middle);
ctx.fill_text(text, box_x + CALLOUT_PAD, box_y + box_h / 2.0);
}
pub fn draw_annotation_underlays(ctx: &mut dyn RenderContext, area: &PlotArea, yscale: &dyn Scale, theme: &FigureTheme, annotations: &[Annotation]) {
for annotation in annotations {
if let Annotation::HBand { low, high, color, .. } = annotation {
shaded_hband_fill(ctx, area, yscale, theme, *low, *high, color);
}
}
}
pub fn draw_annotation_overlays(ctx: &mut dyn RenderContext, area: &PlotArea, xscale: &dyn Scale, yscale: &dyn Scale, theme: &FigureTheme, annotations: &[Annotation]) {
for annotation in annotations {
match annotation {
Annotation::HLine { value, color, label } => dashed_hline(ctx, area, yscale, theme, *value, color, label),
Annotation::VLine { value, color, label } => dashed_vline(ctx, area, xscale, theme, *value, color, label),
Annotation::HBand { low, high, color, label } => shaded_hband_label(ctx, area, yscale, theme, *low, *high, color, label),
Annotation::Callout { x, y, text } => {
let anchor_px = (area.x(xscale, *x), area.y(yscale, *y));
draw_callout(ctx, theme, anchor_px, text, area.rect);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scale::LinearScale;
use uzor_export::{render_to_png, ExportSpec};
fn area() -> PlotArea {
PlotArea::new(Rect::new(20.0, 20.0, 200.0, 150.0))
}
#[test]
fn draw_annotation_passes_render_every_variant_without_panicking() {
let theme = FigureTheme::dark();
let xscale = LinearScale::new(0.0, 100.0);
let yscale = LinearScale::new(0.0, 50.0);
let annotations = vec![
Annotation::HLine { value: 25.0, color: None, label: Some("target".to_owned()) },
Annotation::VLine { value: 60.0, color: Some("#ff0000".to_owned()), label: None },
Annotation::HBand { low: 10.0, high: 20.0, color: None, label: Some("range".to_owned()) },
Annotation::Callout { x: 80.0, y: 40.0, text: "notable point".to_owned() },
];
let spec = ExportSpec { width_px: 240, height_px: 190, dpr: 1.0, background: None };
let result = render_to_png(&spec, |ctx| {
draw_annotation_underlays(ctx, &area(), &yscale, &theme, &annotations);
draw_annotation_overlays(ctx, &area(), &xscale, &yscale, &theme, &annotations);
});
assert!(result.is_ok());
}
#[test]
fn draw_annotation_passes_empty_slice_is_a_no_op() {
let theme = FigureTheme::dark();
let xscale = LinearScale::new(0.0, 100.0);
let yscale = LinearScale::new(0.0, 50.0);
let spec = ExportSpec { width_px: 240, height_px: 190, dpr: 1.0, background: None };
let result = render_to_png(&spec, |ctx| {
draw_annotation_underlays(ctx, &area(), &yscale, &theme, &[]);
draw_annotation_overlays(ctx, &area(), &xscale, &yscale, &theme, &[]);
});
assert!(result.is_ok());
}
#[test]
fn hband_fill_paints_before_a_mark_and_its_label_paints_after() {
let theme = FigureTheme::dark();
let yscale = LinearScale::new(0.0, 50.0);
let annotations = vec![Annotation::HBand { low: 10.0, high: 40.0, color: None, label: Some("band".to_owned()) }];
let spec = ExportSpec { width_px: 240, height_px: 190, dpr: 1.0, background: None };
let result = render_to_png(&spec, |ctx| {
draw_annotation_underlays(ctx, &area(), &yscale, &theme, &annotations);
ctx.set_fill_color("#ffffff");
ctx.fill_rect(area().rect.x, area().rect.y, area().rect.width, area().rect.height);
draw_annotation_overlays(ctx, &area(), &LinearScale::new(0.0, 100.0), &yscale, &theme, &annotations);
});
assert!(result.is_ok());
}
#[test]
fn callout_with_empty_text_draws_nothing_but_still_does_not_panic() {
let theme = FigureTheme::dark();
let spec = ExportSpec { width_px: 240, height_px: 190, dpr: 1.0, background: None };
let result = render_to_png(&spec, |ctx| {
draw_callout(ctx, &theme, (100.0, 100.0), "", area().rect);
});
assert!(result.is_ok());
}
#[test]
fn callout_near_the_right_edge_flips_the_box_to_the_left() {
let theme = FigureTheme::dark();
let bounds = area().rect;
let anchor_px = (bounds.right() - 2.0, bounds.y + 5.0);
let spec = ExportSpec { width_px: 240, height_px: 190, dpr: 1.0, background: None };
let result = render_to_png(&spec, |ctx| {
draw_callout(ctx, &theme, anchor_px, "a fairly long callout label", bounds);
});
assert!(result.is_ok());
}
}