use crate::coord::Coord;
use crate::data::DataFrame;
use crate::position::identity::PositionIdentity;
use crate::position::Position;
use crate::render::backend::{DrawBackend, RectStyle, TextAnchor, TextStyle};
use crate::render::corner_layout::stack_clear;
use crate::render::text::{text_width, wrap_text};
use crate::render::{Rect, RenderError};
use crate::scale::bivariate::bivariate_cell_color;
use crate::scale::ScaleSet;
use crate::stat::identity::StatIdentity;
use crate::stat::Stat;
use crate::theme::Theme;
use super::{Geom, GeomParams};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum LegendCorner {
BottomLeft,
#[default]
BottomRight,
TopLeft,
TopRight,
}
pub struct GeomBivariateLegend {
pub nx: usize,
pub ny: usize,
pub base: (u8, u8, u8),
pub color_x: (u8, u8, u8),
pub color_y: (u8, u8, u8),
pub x_name: String,
pub y_name: String,
pub corner: LegendCorner,
pub cell_size: f64,
pub text_size: f64,
pub text_color: (u8, u8, u8),
}
impl GeomBivariateLegend {
pub fn from_scale(scale: &crate::scale::bivariate::ScaleFillBivariate) -> Self {
GeomBivariateLegend {
nx: scale.nx(),
ny: scale.ny(),
base: scale.base(),
color_x: scale.color_x(),
color_y: scale.color_y(),
x_name: scale.x_name().to_string(),
y_name: scale.y_name().to_string(),
..Self::default()
}
}
pub fn corner(mut self, corner: LegendCorner) -> Self {
self.corner = corner;
self
}
pub fn cell_size(mut self, px: f64) -> Self {
self.cell_size = px.max(1.0);
self
}
pub fn counts(self, counts: Vec<Vec<usize>>) -> GeomBivariateLegendCounts {
GeomBivariateLegendCounts {
legend: self,
counts,
}
}
}
impl Default for GeomBivariateLegend {
fn default() -> Self {
GeomBivariateLegend {
nx: 3,
ny: 3,
base: (255, 255, 255),
color_x: (220, 90, 60),
color_y: (60, 90, 220),
x_name: String::new(),
y_name: String::new(),
corner: LegendCorner::default(),
cell_size: 14.0,
text_size: 8.0,
text_color: (80, 80, 80),
}
}
}
const CAPTION_MAX_LINES: usize = 2;
fn fit_caption(text: &str, budget: f64, base_size: f64) -> (f64, Vec<String>, bool) {
if text.is_empty() {
return (base_size, Vec::new(), false);
}
if budget.is_nan() || budget <= 0.0 {
return (base_size, vec![text.to_string()], false);
}
let full_w = text_width(text, base_size);
if full_w <= budget {
return (base_size, vec![text.to_string()], false);
}
let min_size = (base_size * 0.7).max(5.0).min(base_size);
let shrunk = (base_size * budget / full_w).max(min_size);
if text_width(text, shrunk) <= budget {
return (shrunk, vec![text.to_string()], false);
}
let mut lines = wrap_text(text, budget, min_size);
if lines.len() > CAPTION_MAX_LINES {
lines.truncate(CAPTION_MAX_LINES);
if let Some(last) = lines.last_mut() {
*last = force_ellipsis(last, budget, min_size);
}
return (min_size, lines, true);
}
(min_size, lines, false)
}
fn force_ellipsis(text: &str, max_width: f64, size: f64) -> String {
let chars: Vec<char> = text.chars().collect();
for keep in (0..=chars.len()).rev() {
let candidate: String = chars[..keep].iter().collect::<String>() + "…";
if text_width(&candidate, size) <= max_width {
return candidate;
}
}
"…".to_string()
}
#[derive(Clone)]
struct LegendLayout {
box_x: f64,
box_y: f64,
total_w: f64,
total_h: f64,
grid_x: f64,
grid_y: f64,
grid_w: f64,
grid_h: f64,
x_caption_lines: Vec<String>,
x_caption_size: f64,
x_caption_truncated: bool,
y_caption_lines: Vec<String>,
y_caption_size: f64,
y_caption_truncated: bool,
}
fn caption_slot(size: f64) -> f64 {
size + 4.0
}
fn legend_layout(legend: &GeomBivariateLegend, plot_area: &crate::render::Rect) -> LegendLayout {
let (nx, ny) = (legend.nx.max(1), legend.ny.max(1));
let grid_w = nx as f64 * legend.cell_size;
let grid_h = ny as f64 * legend.cell_size;
let (x_caption_size, x_caption_lines, x_caption_truncated) =
fit_caption(&legend.x_name, grid_w, legend.text_size);
let (y_caption_size, y_caption_lines, y_caption_truncated) =
fit_caption(&legend.y_name, grid_h, legend.text_size);
let x_caption_h = if x_caption_lines.is_empty() {
0.0
} else {
x_caption_lines.len() as f64 * caption_slot(x_caption_size)
};
let y_caption_w = if y_caption_lines.is_empty() {
0.0
} else {
y_caption_lines.len() as f64 * caption_slot(y_caption_size)
};
let pad = 6.0;
let total_w = y_caption_w + grid_w + pad * 2.0;
let total_h = grid_h + x_caption_h + pad * 2.0;
let (box_x, box_y) = match legend.corner {
LegendCorner::BottomLeft => (
plot_area.x + pad,
plot_area.y + plot_area.height - total_h - pad,
),
LegendCorner::BottomRight => (
plot_area.x + plot_area.width - total_w - pad,
plot_area.y + plot_area.height - total_h - pad,
),
LegendCorner::TopLeft => (plot_area.x + pad, plot_area.y + pad),
LegendCorner::TopRight => (
plot_area.x + plot_area.width - total_w - pad,
plot_area.y + pad,
),
};
LegendLayout {
box_x,
box_y,
total_w,
total_h,
grid_x: box_x + y_caption_w,
grid_y: box_y,
grid_w,
grid_h,
x_caption_lines,
x_caption_size,
x_caption_truncated,
y_caption_lines,
y_caption_size,
y_caption_truncated,
}
}
impl GeomBivariateLegend {
fn resolve_layout(
&self,
plot_area: &crate::render::Rect,
backend: &mut dyn DrawBackend,
) -> LegendLayout {
let natural = legend_layout(self, plot_area);
let away_sign = match self.corner {
LegendCorner::BottomLeft | LegendCorner::BottomRight => -1.0,
LegendCorner::TopLeft | LegendCorner::TopRight => 1.0,
};
let natural_box = Rect {
x: natural.box_x,
y: natural.box_y,
width: natural.total_w,
height: natural.total_h,
};
let claims = backend.corner_claims();
let shifted_box = stack_clear(natural_box.clone(), away_sign, &claims, plot_area);
let dy = shifted_box.y - natural_box.y;
backend.claim_corner_box(shifted_box);
LegendLayout {
box_y: natural.box_y + dy,
grid_y: natural.grid_y + dy,
..natural
}
}
fn draw_with_layout(
&self,
theme: &Theme,
backend: &mut dyn DrawBackend,
layout: &LegendLayout,
) -> Result<(), RenderError> {
let (nx, ny) = (self.nx.max(1), self.ny.max(1));
let layout = layout.clone();
if theme.legend_background.visible {
if let Some(fill) = theme.legend_background.fill {
backend.draw_rect(
(layout.box_x, layout.box_y),
(layout.box_x + layout.total_w, layout.box_y + layout.total_h),
&RectStyle {
fill: Some(fill),
stroke: theme.legend_background.color,
stroke_width: theme.legend_background.width,
alpha: 1.0,
clip: false,
corner_radius: 0.0,
},
)?;
}
}
for j in 0..ny {
for i in 0..nx {
let color =
bivariate_cell_color(self.base, self.color_x, self.color_y, nx, ny, i, j);
let cx = layout.grid_x + i as f64 * self.cell_size;
let cy = layout.grid_y + (ny - 1 - j) as f64 * self.cell_size;
backend.draw_rect(
(cx, cy),
(cx + self.cell_size, cy + self.cell_size),
&RectStyle {
fill: Some(color),
stroke: None,
stroke_width: 0.0,
alpha: 1.0,
clip: false,
corner_radius: 0.0,
},
)?;
}
}
let font = super::resolve_font_family("", theme);
let n_x = layout.x_caption_lines.len();
for (i, line) in layout.x_caption_lines.iter().enumerate() {
let slot = caption_slot(layout.x_caption_size);
let ly = layout.grid_y + layout.grid_h + (i as f64 + 0.5) * slot;
let title =
(layout.x_caption_truncated && i + 1 == n_x).then_some(self.x_name.as_str());
backend.draw_text_with_title(
line,
(layout.grid_x + layout.grid_w / 2.0, ly),
&TextStyle {
color: self.text_color,
size: layout.x_caption_size,
anchor: TextAnchor::Middle,
angle: 0.0,
family: font.clone(),
face: crate::render::backend::FontFace::Plain,
bg_colour: None,
bg_r: 0.0,
},
title,
)?;
}
let n_y = layout.y_caption_lines.len();
for (i, line) in layout.y_caption_lines.iter().enumerate() {
let slot = caption_slot(layout.y_caption_size);
let col = n_y - 1 - i; let lx = layout.grid_x - (col as f64 + 0.5) * slot;
let ly = layout.grid_y + layout.grid_h / 2.0;
let title =
(layout.y_caption_truncated && i + 1 == n_y).then_some(self.y_name.as_str());
backend.draw_text_with_title(
line,
(lx, ly),
&TextStyle {
color: self.text_color,
size: layout.y_caption_size,
anchor: TextAnchor::Middle,
angle: -90.0,
family: font.clone(),
face: crate::render::backend::FontFace::Plain,
bg_colour: None,
bg_r: 0.0,
},
title,
)?;
}
Ok(())
}
}
impl Geom for GeomBivariateLegend {
fn draw(
&self,
_data: &DataFrame,
_coord: &dyn Coord,
_scales: &ScaleSet,
theme: &Theme,
backend: &mut dyn DrawBackend,
) -> Result<(), RenderError> {
let plot_area = backend.plot_area();
let layout = self.resolve_layout(&plot_area, backend);
self.draw_with_layout(theme, backend, &layout)
}
fn required_aes(&self) -> Vec<crate::aes::Aesthetic> {
vec![]
}
fn default_stat(&self) -> Box<dyn Stat> {
Box::new(StatIdentity)
}
fn default_position(&self) -> Box<dyn Position> {
Box::new(PositionIdentity)
}
fn default_params(&self) -> GeomParams {
GeomParams::default()
}
fn name(&self) -> &str {
"bivariate_legend"
}
fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
if let (true, Some(ink)) = (self.text_color == (80, 80, 80), el.ink) {
self.text_color = ink;
}
}
}
fn contrast_text_color(fill: (u8, u8, u8)) -> (u8, u8, u8) {
let (r, g, b) = fill;
let luma = 0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64;
if luma > 140.0 {
(30, 30, 30)
} else {
(245, 245, 245)
}
}
#[non_exhaustive]
pub struct GeomBivariateLegendCounts {
pub legend: GeomBivariateLegend,
pub counts: Vec<Vec<usize>>,
}
impl Geom for GeomBivariateLegendCounts {
fn draw(
&self,
_data: &DataFrame,
_coord: &dyn Coord,
_scales: &ScaleSet,
theme: &Theme,
backend: &mut dyn DrawBackend,
) -> Result<(), RenderError> {
let plot_area = backend.plot_area();
let legend = &self.legend;
let layout = legend.resolve_layout(&plot_area, backend);
legend.draw_with_layout(theme, backend, &layout)?;
let (nx, ny) = (legend.nx.max(1), legend.ny.max(1));
let font = super::resolve_font_family("", theme);
let count_size = (legend.text_size - 1.0).max(4.0);
for (j, row) in self.counts.iter().enumerate().take(ny) {
for (i, &count) in row.iter().enumerate().take(nx) {
let fill =
bivariate_cell_color(legend.base, legend.color_x, legend.color_y, nx, ny, i, j);
let cx = layout.grid_x + (i as f64 + 0.5) * legend.cell_size;
let cy =
layout.grid_y + (ny - 1 - j) as f64 * legend.cell_size + legend.cell_size / 2.0;
backend.draw_text(
&count.to_string(),
(cx, cy),
&TextStyle {
color: contrast_text_color(fill),
size: count_size,
anchor: TextAnchor::Middle,
angle: 0.0,
family: font.clone(),
face: crate::render::backend::FontFace::Plain,
bg_colour: None,
bg_r: 0.0,
},
)?;
}
}
Ok(())
}
fn required_aes(&self) -> Vec<crate::aes::Aesthetic> {
vec![]
}
fn default_stat(&self) -> Box<dyn Stat> {
Box::new(StatIdentity)
}
fn default_position(&self) -> Box<dyn Position> {
Box::new(PositionIdentity)
}
fn default_params(&self) -> GeomParams {
GeomParams::default()
}
fn name(&self) -> &str {
"bivariate_legend"
}
fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
self.legend.apply_element_geom(el);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aes::Aesthetic;
use crate::data::Value;
use crate::scale::bivariate::ScaleFillBivariate;
#[test]
fn from_scale_copies_grid_shape_and_colors() {
let s = ScaleFillBivariate::tertile(
Aesthetic::Fill,
(217, 95, 2),
(117, 112, 179),
"Income",
"Education",
);
let g = GeomBivariateLegend::from_scale(&s);
assert_eq!(g.nx, 3);
assert_eq!(g.ny, 3);
assert_eq!(g.color_x, (217, 95, 2));
assert_eq!(g.color_y, (117, 112, 179));
assert_eq!(g.x_name, "Income");
assert_eq!(g.y_name, "Education");
}
#[test]
fn default_corner_is_bottom_right() {
assert_eq!(LegendCorner::default(), LegendCorner::BottomRight);
}
#[test]
fn cell_size_builder_sets_the_field() {
let g = GeomBivariateLegend::default().cell_size(20.0);
assert_eq!(g.cell_size, 20.0);
assert_eq!(
GeomBivariateLegend::default().cell_size(-5.0).cell_size,
1.0
);
}
fn two_region_df() -> DataFrame {
let mut df = DataFrame::new();
df.add_column(
"geometry".into(),
vec![
Value::Str("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))".into()),
Value::Str("POLYGON ((10 0, 20 0, 20 10, 10 10, 10 0))".into()),
],
);
df
}
#[test]
fn empty_captions_draw_the_grid_without_axis_text() {
let legend = GeomBivariateLegend {
x_name: String::new(),
y_name: String::new(),
..GeomBivariateLegend::default()
};
let svg = crate::plot::GGPlot::new(two_region_df())
.aes(crate::aes::Aes::new())
.bivariate_legend(legend)
.render_svg_native()
.expect("render");
assert!(
svg.contains("data-geom=\"bivariate_legend\""),
"grid still draws: {svg}"
);
assert_eq!(svg.matches("<text").count(), 0, "no caption text: {svg}");
}
#[test]
fn legend_background_draws_when_the_theme_enables_it() {
let mut theme = crate::theme::Theme::default();
theme.legend_background.visible = true;
theme.legend_background.fill = Some((40, 40, 44));
let legend = GeomBivariateLegend::from_scale(&ScaleFillBivariate::tertile(
Aesthetic::Fill,
(217, 95, 2),
(117, 112, 179),
"x",
"y",
));
let svg = crate::plot::GGPlot::new(two_region_df())
.aes(crate::aes::Aes::new())
.bivariate_legend(legend)
.theme(theme)
.render_svg_native()
.expect("render");
assert!(svg.contains("#28282C"), "{svg}");
}
#[test]
fn ink_theme_hook_recolours_the_default_text_color() {
let mut g = GeomBivariateLegend::default();
let el = crate::theme::elements::ElementGeom {
ink: Some((10, 20, 30)),
..Default::default()
};
g.apply_element_geom(&el);
assert_eq!(g.text_color, (10, 20, 30));
let mut g2 = GeomBivariateLegend {
text_color: (1, 2, 3),
..GeomBivariateLegend::default()
};
g2.apply_element_geom(&el);
assert_eq!(g2.text_color, (1, 2, 3));
}
#[test]
fn contrast_text_color_picks_light_on_dark_and_dark_on_light() {
assert_eq!(contrast_text_color((0, 0, 0)), (245, 245, 245));
assert_eq!(contrast_text_color((255, 255, 255)), (30, 30, 30));
assert_eq!(contrast_text_color((40, 40, 80)), (245, 245, 245));
}
#[test]
fn counts_builder_moves_the_legend_and_stores_counts_indexed_by_j_then_i() {
let g = GeomBivariateLegend::default().counts(vec![vec![1, 2, 3], vec![4, 5, 6]]);
assert_eq!(g.legend.nx, 3);
assert_eq!(g.counts, vec![vec![1, 2, 3], vec![4, 5, 6]]);
}
#[derive(Default)]
#[allow(clippy::type_complexity)] struct RecordingBackend {
rects: Vec<((f64, f64), (f64, f64), Option<(u8, u8, u8)>)>,
texts: Vec<(String, (f64, f64), f64, (u8, u8, u8))>,
corner_claims: Vec<crate::render::Rect>,
}
impl DrawBackend for RecordingBackend {
fn draw_circle(
&mut self,
_c: (f64, f64),
_r: f64,
_s: &crate::render::backend::PointStyle,
) -> Result<(), RenderError> {
Ok(())
}
fn draw_line(
&mut self,
_p: &[(f64, f64)],
_s: &crate::render::backend::LineStyle,
) -> Result<(), RenderError> {
Ok(())
}
fn draw_rect(
&mut self,
tl: (f64, f64),
br: (f64, f64),
style: &RectStyle,
) -> Result<(), RenderError> {
self.rects.push((tl, br, style.fill));
Ok(())
}
fn draw_text(
&mut self,
text: &str,
pos: (f64, f64),
style: &TextStyle,
) -> Result<(), RenderError> {
self.texts
.push((text.to_string(), pos, style.size, style.color));
Ok(())
}
fn draw_polygon(&mut self, _p: &[(f64, f64)], _s: &RectStyle) -> Result<(), RenderError> {
Ok(())
}
fn plot_area(&self) -> crate::render::Rect {
crate::render::Rect {
x: 0.0,
y: 0.0,
width: 400.0,
height: 300.0,
}
}
fn total_area(&self) -> crate::render::Rect {
self.plot_area()
}
fn corner_claims(&self) -> Vec<crate::render::Rect> {
self.corner_claims.clone()
}
fn claim_corner_box(&mut self, rect: crate::render::Rect) {
self.corner_claims.push(rect);
}
fn clear_corner_claims(&mut self) {
self.corner_claims.clear();
}
}
#[test]
fn counts_are_drawn_centred_in_their_own_cell_with_a_contrasting_color() {
let g = GeomBivariateLegend {
color_x: (217, 95, 2),
color_y: (117, 112, 179),
..GeomBivariateLegend::default()
}
.counts(vec![vec![1, 2, 3], vec![4, 5, 6], vec![7, 8, 9]]);
let mut backend = RecordingBackend::default();
let coord = crate::coord::cartesian::CoordCartesian::default();
let scales = ScaleSet::new();
let mut theme = Theme::default();
theme.legend_background = crate::theme::elements::ElementRect::blank();
g.draw(&DataFrame::new(), &coord, &scales, &theme, &mut backend)
.unwrap();
assert_eq!(backend.rects.len(), 9);
assert_eq!(backend.texts.len(), 9);
for (text, pos, size, color) in &backend.texts {
let n: usize = text.parse().expect("count text is a plain integer");
let j = (n - 1) / 3;
let i = (n - 1) % 3;
let (tl, br, fill) = backend.rects[j * 3 + i];
let cx = (tl.0 + br.0) / 2.0;
let cy = (tl.1 + br.1) / 2.0;
assert!(
(pos.0 - cx).abs() < 1e-6 && (pos.1 - cy).abs() < 1e-6,
"count {n} at {pos:?}, expected cell centre ({cx}, {cy})"
);
assert_eq!(*color, contrast_text_color(fill.unwrap()));
assert_eq!(*size, 7.0);
}
}
#[test]
fn counts_out_of_range_indices_are_skipped_not_panicking() {
let g = GeomBivariateLegend::default().counts(vec![vec![1]]);
let mut backend = RecordingBackend::default();
let coord = crate::coord::cartesian::CoordCartesian::default();
let scales = ScaleSet::new();
g.draw(
&DataFrame::new(),
&coord,
&scales,
&Theme::default(),
&mut backend,
)
.unwrap();
assert_eq!(backend.texts.len(), 1);
assert_eq!(backend.texts[0].0, "1");
}
#[test]
fn no_counts_builder_call_draws_no_count_text() {
let g = GeomBivariateLegend {
color_x: (217, 95, 2),
color_y: (117, 112, 179),
..GeomBivariateLegend::default()
};
let mut backend = RecordingBackend::default();
let coord = crate::coord::cartesian::CoordCartesian::default();
let scales = ScaleSet::new();
let mut theme = Theme::default();
theme.legend_background = crate::theme::elements::ElementRect::blank();
g.draw(&DataFrame::new(), &coord, &scales, &theme, &mut backend)
.unwrap();
assert_eq!(backend.rects.len(), 9);
assert_eq!(backend.texts.len(), 0);
}
#[test]
fn from_scale_stays_count_less() {
let g: GeomBivariateLegend = GeomBivariateLegend::from_scale(&ScaleFillBivariate::tertile(
Aesthetic::Fill,
(217, 95, 2),
(117, 112, 179),
"x",
"y",
));
let _ = g; }
#[test]
fn bivariate_legend_counts_renders_through_the_plot_builder() {
let g = GeomBivariateLegend::from_scale(&ScaleFillBivariate::tertile(
Aesthetic::Fill,
(217, 95, 2),
(117, 112, 179),
"x",
"y",
))
.counts(vec![vec![3, 1, 0], vec![0, 2, 0], vec![0, 0, 5]]);
let svg = crate::plot::GGPlot::new(two_region_df())
.aes(crate::aes::Aes::new())
.bivariate_legend_counts(g)
.render_svg_native()
.expect("render");
assert!(svg.contains("data-geom=\"bivariate_legend\""), "{svg}");
assert!(svg.contains(">3<"), "count 3 missing: {svg}");
assert!(svg.contains(">5<"), "count 5 missing: {svg}");
}
#[test]
fn short_y_caption_leaves_the_box_byte_identical_to_before_273() {
let legend = GeomBivariateLegend {
y_name: "y".to_string(),
..GeomBivariateLegend::default()
};
let plot_area = crate::render::Rect {
x: 0.0,
y: 0.0,
width: 400.0,
height: 400.0,
};
let layout = legend_layout(&legend, &plot_area);
assert_eq!(layout.y_caption_lines, vec!["y".to_string()]);
assert_eq!(layout.y_caption_size, legend.text_size);
assert!(!layout.y_caption_truncated);
assert_eq!(layout.grid_y, layout.box_y);
assert_eq!(layout.total_h, layout.grid_h + 6.0 * 2.0); }
#[test]
fn y_caption_longer_than_the_grid_is_fit_to_the_grid_height_not_the_box() {
let legend = GeomBivariateLegend {
nx: 2,
ny: 2,
cell_size: 10.0, y_name: "Customers per 10,000 inhabitants".to_string(),
x_name: String::new(),
..GeomBivariateLegend::default()
};
let plot_area = crate::render::Rect {
x: 0.0,
y: 0.0,
width: 400.0,
height: 400.0,
};
let layout = legend_layout(&legend, &plot_area);
let caption_len = text_width(&legend.y_name, legend.text_size);
assert!(
caption_len > layout.grid_h,
"test setup: caption ({caption_len}) should exceed the grid height ({})",
layout.grid_h
);
assert!(layout.y_caption_lines.len() <= CAPTION_MAX_LINES);
assert_eq!(
layout.total_h,
layout.grid_h + 6.0 * 2.0,
"the y caption must never grow total_h — only total_w"
);
for line in &layout.y_caption_lines {
let w = text_width(line, layout.y_caption_size);
assert!(
w <= layout.grid_h + 1e-6,
"y caption line {line:?} (w={w}) overflows the grid height ({})",
layout.grid_h
);
}
assert!(layout.y_caption_truncated);
}
#[test]
fn x_caption_longer_than_the_grid_is_fit_to_the_grid_width_not_the_box() {
let legend = GeomBivariateLegend {
nx: 2,
ny: 2,
cell_size: 10.0,
x_name: "Road km to the nearest regional hospital".to_string(),
y_name: String::new(),
..GeomBivariateLegend::default()
};
let plot_area = crate::render::Rect {
x: 0.0,
y: 0.0,
width: 400.0,
height: 400.0,
};
let layout = legend_layout(&legend, &plot_area);
let caption_len = text_width(&legend.x_name, legend.text_size);
assert!(
caption_len > layout.grid_w,
"test setup: caption ({caption_len}) should exceed the grid width ({})",
layout.grid_w
);
assert!(layout.x_caption_lines.len() <= CAPTION_MAX_LINES);
assert_eq!(
layout.total_w,
layout.grid_w + 6.0 * 2.0,
"the x caption must never grow total_w — only total_h"
);
for line in &layout.x_caption_lines {
let w = text_width(line, layout.x_caption_size);
assert!(
w <= layout.grid_w + 1e-6,
"x caption line {line:?} (w={w}) overflows the grid width ({})",
layout.grid_w
);
}
}
#[test]
fn wrapped_y_caption_lines_stack_with_line_0_farthest_from_the_grid() {
let legend = GeomBivariateLegend {
nx: 2,
ny: 2,
cell_size: 10.0,
y_name: "Customers per 10,000 people".to_string(),
x_name: String::new(),
..GeomBivariateLegend::default()
};
let mut backend = RecordingBackend::default();
let coord = crate::coord::cartesian::CoordCartesian::default();
let scales = ScaleSet::new();
legend
.draw(
&DataFrame::new(),
&coord,
&scales,
&Theme::default(),
&mut backend,
)
.unwrap();
let layout = legend_layout(
&legend,
&crate::render::Rect {
x: 0.0,
y: 0.0,
width: 400.0,
height: 400.0,
},
);
assert!(
layout.y_caption_lines.len() >= 2,
"test setup: expected this caption to wrap onto at least 2 lines, got {:?}",
layout.y_caption_lines
);
let x_of = |text: &str| {
backend
.texts
.iter()
.find(|(t, ..)| t == text)
.unwrap_or_else(|| {
panic!("no draw_text call for line {text:?}: {:?}", backend.texts)
})
.1
.0
};
for pair in layout.y_caption_lines.windows(2) {
let (line1, line2) = (&pair[0], &pair[1]);
let (x1, x2) = (x_of(line1), x_of(line2));
assert!(
x1 < x2,
"line {line1:?} (x={x1}) must sit farther from the grid (smaller x) than the \
following line {line2:?} (x={x2})"
);
}
}
fn legend_part_rects(layout: &LegendLayout) -> Vec<crate::render::Rect> {
let mut rects = vec![crate::render::Rect {
x: layout.grid_x,
y: layout.grid_y,
width: layout.grid_w,
height: layout.grid_h,
}];
let n_x = layout.x_caption_lines.len();
for (i, line) in layout.x_caption_lines.iter().enumerate() {
let slot = caption_slot(layout.x_caption_size);
let ly = layout.grid_y + layout.grid_h + (i as f64 + 0.5) * slot;
let w = text_width(line, layout.x_caption_size);
rects.push(crate::render::Rect {
x: layout.grid_x + layout.grid_w / 2.0 - w / 2.0,
y: ly - slot / 2.0,
width: w,
height: slot,
});
let _ = n_x;
}
let n_y = layout.y_caption_lines.len();
for (i, line) in layout.y_caption_lines.iter().enumerate() {
let slot = caption_slot(layout.y_caption_size);
let col = n_y - 1 - i; let lx = layout.grid_x - (col as f64 + 0.5) * slot;
let h = text_width(line, layout.y_caption_size); rects.push(crate::render::Rect {
x: lx - slot / 2.0,
y: layout.grid_y + layout.grid_h / 2.0 - h / 2.0,
width: slot,
height: h,
});
let _ = n_y;
}
rects
}
fn really_intersects(a: &crate::render::Rect, b: &crate::render::Rect) -> bool {
const EPS: f64 = 1e-6;
let deflate = |r: &crate::render::Rect| crate::render::Rect {
x: r.x + EPS,
y: r.y + EPS,
width: (r.width - 2.0 * EPS).max(0.0),
height: (r.height - 2.0 * EPS).max(0.0),
};
crate::render::corner_layout::rects_intersect(&deflate(a), &deflate(b))
}
#[test]
fn legend_is_self_intersection_free_over_caption_lengths_and_grid_sizes() {
let captions = [
"",
"y",
"Income",
"Customers per 10,000 people",
"Road km to the nearest regional hospital or clinic",
];
let plot_area = crate::render::Rect {
x: 0.0,
y: 0.0,
width: 400.0,
height: 400.0,
};
for &(nx, ny, cell_size) in &[
(2usize, 2usize, 8.0),
(3, 3, 20.0),
(4, 2, 14.0),
(2, 5, 6.0),
] {
for &x_name in &captions {
for &y_name in &captions {
let legend = GeomBivariateLegend {
nx,
ny,
cell_size,
x_name: x_name.to_string(),
y_name: y_name.to_string(),
..GeomBivariateLegend::default()
};
let layout = legend_layout(&legend, &plot_area);
let parts = legend_part_rects(&layout);
for (i, a) in parts.iter().enumerate() {
for b in &parts[i + 1..] {
assert!(
!really_intersects(a, b),
"nx={nx} ny={ny} cell_size={cell_size} x_name={x_name:?} \
y_name={y_name:?}: parts intersect: {a:?} vs {b:?}"
);
}
let box_rect = crate::render::Rect {
x: layout.box_x,
y: layout.box_y,
width: layout.total_w,
height: layout.total_h,
};
assert!(
a.x >= box_rect.x - 1e-6
&& a.y >= box_rect.y - 1e-6
&& a.x + a.width <= box_rect.x + box_rect.width + 1e-6
&& a.y + a.height <= box_rect.y + box_rect.height + 1e-6,
"nx={nx} ny={ny} cell_size={cell_size} x_name={x_name:?} \
y_name={y_name:?}: part {a:?} escapes claimed box {box_rect:?}"
);
}
}
}
}
}
#[test]
fn bivariate_legend_stacks_clear_of_an_already_claimed_box_in_its_corner() {
let mut backend = RecordingBackend::default();
let plot_area = backend.plot_area();
let claim = crate::render::Rect {
x: plot_area.width - 80.0,
y: plot_area.height - 60.0,
width: 80.0,
height: 40.0,
};
backend.claim_corner_box(claim.clone());
let legend = GeomBivariateLegend::default(); let coord = crate::coord::cartesian::CoordCartesian::default();
let scales = ScaleSet::new();
legend
.draw(
&DataFrame::new(),
&coord,
&scales,
&Theme::default(),
&mut backend,
)
.unwrap();
assert_eq!(backend.corner_claims.len(), 2);
assert!(!crate::render::corner_layout::rects_intersect(
&backend.corner_claims[0],
&backend.corner_claims[1]
));
}
#[test]
fn counts_overlay_lands_on_the_same_stacked_layout_as_the_swatches() {
let mut backend = RecordingBackend::default();
let plot_area = backend.plot_area();
let claim = crate::render::Rect {
x: plot_area.width - 80.0,
y: plot_area.height - 60.0,
width: 80.0,
height: 40.0,
};
backend.claim_corner_box(claim.clone());
let g = GeomBivariateLegend {
color_x: (217, 95, 2),
color_y: (117, 112, 179),
..GeomBivariateLegend::default()
}
.counts(vec![vec![1, 2, 3], vec![4, 5, 6], vec![7, 8, 9]]);
let coord = crate::coord::cartesian::CoordCartesian::default();
let scales = ScaleSet::new();
let mut theme = Theme::default();
theme.legend_background = crate::theme::elements::ElementRect::blank();
g.draw(&DataFrame::new(), &coord, &scales, &theme, &mut backend)
.unwrap();
assert_eq!(backend.rects.len(), 9);
assert_eq!(backend.texts.len(), 9);
for (text, pos, ..) in &backend.texts {
let n: usize = text.parse().unwrap();
let j = (n - 1) / 3;
let i = (n - 1) % 3;
let (tl, br, _) = backend.rects[j * 3 + i];
let cx = (tl.0 + br.0) / 2.0;
let cy = (tl.1 + br.1) / 2.0;
assert!(
(pos.0 - cx).abs() < 1e-6 && (pos.1 - cy).abs() < 1e-6,
"count {n} at {pos:?}, expected the (stacked) cell centre ({cx}, {cy})"
);
}
assert_eq!(backend.corner_claims.len(), 2);
assert!(!crate::render::corner_layout::rects_intersect(
&backend.corner_claims[0],
&backend.corner_claims[1]
));
}
}