use crate::core::Result;
#[allow(deprecated)]
use crate::core::position::Position;
use crate::core::units::RenderScale;
use crate::render::{Color, LineStyle, MarkerStyle};
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum LegendPosition {
#[default]
Best,
UpperRight,
UpperLeft,
LowerLeft,
LowerRight,
Right,
CenterLeft,
CenterRight,
LowerCenter,
UpperCenter,
Center,
OutsideRight,
OutsideLeft,
OutsideUpper,
OutsideLower,
Custom {
x: f32,
y: f32,
anchor: LegendAnchor,
},
}
impl LegendPosition {
pub fn is_outside(&self) -> bool {
matches!(
self,
LegendPosition::OutsideRight
| LegendPosition::OutsideLeft
| LegendPosition::OutsideUpper
| LegendPosition::OutsideLower
) || matches!(self, LegendPosition::Custom { x, y, .. } if *x > 1.0 || *y > 1.0 || *x < 0.0 || *y < 0.0)
}
pub fn from_code(code: u8) -> Self {
match code {
0 => LegendPosition::Best,
1 => LegendPosition::UpperRight,
2 => LegendPosition::UpperLeft,
3 => LegendPosition::LowerLeft,
4 => LegendPosition::LowerRight,
5 => LegendPosition::Right,
6 => LegendPosition::CenterLeft,
7 => LegendPosition::CenterRight,
8 => LegendPosition::LowerCenter,
9 => LegendPosition::UpperCenter,
10 => LegendPosition::Center,
_ => LegendPosition::UpperRight,
}
}
#[allow(deprecated)]
pub fn from_position(pos: Position) -> Self {
match pos {
Position::Best => LegendPosition::Best,
Position::TopLeft => LegendPosition::UpperLeft,
Position::TopCenter => LegendPosition::UpperCenter,
Position::TopRight => LegendPosition::UpperRight,
Position::CenterLeft => LegendPosition::CenterLeft,
Position::Center => LegendPosition::Center,
Position::CenterRight => LegendPosition::CenterRight,
Position::BottomLeft => LegendPosition::LowerLeft,
Position::BottomCenter => LegendPosition::LowerCenter,
Position::BottomRight => LegendPosition::LowerRight,
Position::Custom { x, y } => LegendPosition::Custom {
x,
y: 1.0 - y,
anchor: LegendAnchor::NorthWest,
},
}
}
}
#[allow(deprecated)]
impl From<Position> for LegendPosition {
fn from(pos: Position) -> Self {
LegendPosition::from_position(pos)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum LegendAnchor {
#[default]
NorthWest,
North,
NorthEast,
West,
Center,
East,
SouthWest,
South,
SouthEast,
}
impl LegendAnchor {
pub fn offset_multipliers(&self) -> (f32, f32) {
match self {
LegendAnchor::NorthWest => (0.0, 0.0),
LegendAnchor::North => (0.5, 0.0),
LegendAnchor::NorthEast => (1.0, 0.0),
LegendAnchor::West => (0.0, 0.5),
LegendAnchor::Center => (0.5, 0.5),
LegendAnchor::East => (1.0, 0.5),
LegendAnchor::SouthWest => (0.0, 1.0),
LegendAnchor::South => (0.5, 1.0),
LegendAnchor::SouthEast => (1.0, 1.0),
}
}
}
#[derive(Debug, Clone)]
pub struct LegendItem {
pub label: String,
pub color: Color,
pub item_type: LegendItemType,
pub has_error_bars: bool,
}
#[derive(Debug, Clone)]
pub enum LegendItemType {
Line { style: LineStyle, width: f32 },
Scatter {
marker: MarkerStyle,
size: f32,
edge: Option<(Color, f32)>,
},
LineMarker {
line_style: LineStyle,
line_width: f32,
marker: MarkerStyle,
marker_size: f32,
marker_edge: Option<(Color, f32)>,
},
Bar { edge: Option<(Color, f32)> },
Area { edge_color: Option<Color> },
Histogram { edge: Option<(Color, f32)> },
ErrorBar,
}
impl LegendItem {
pub fn line(label: impl Into<String>, color: Color, style: LineStyle, width: f32) -> Self {
Self {
label: label.into(),
color,
item_type: LegendItemType::Line { style, width },
has_error_bars: false,
}
}
pub fn scatter(label: impl Into<String>, color: Color, marker: MarkerStyle, size: f32) -> Self {
Self::scatter_with_edge(label, color, marker, size, None)
}
pub fn scatter_with_edge(
label: impl Into<String>,
color: Color,
marker: MarkerStyle,
size: f32,
edge: Option<(Color, f32)>,
) -> Self {
Self {
label: label.into(),
color,
item_type: LegendItemType::Scatter { marker, size, edge },
has_error_bars: false,
}
}
pub fn line_marker(
label: impl Into<String>,
color: Color,
line_style: LineStyle,
line_width: f32,
marker: MarkerStyle,
marker_size: f32,
) -> Self {
Self::line_marker_with_edge(
label,
color,
line_style,
line_width,
marker,
marker_size,
None,
)
}
#[allow(clippy::too_many_arguments)]
pub fn line_marker_with_edge(
label: impl Into<String>,
color: Color,
line_style: LineStyle,
line_width: f32,
marker: MarkerStyle,
marker_size: f32,
marker_edge: Option<(Color, f32)>,
) -> Self {
Self {
label: label.into(),
color,
item_type: LegendItemType::LineMarker {
line_style,
line_width,
marker,
marker_size,
marker_edge,
},
has_error_bars: false,
}
}
pub fn bar(label: impl Into<String>, color: Color) -> Self {
Self::bar_with_edge(label, color, None)
}
pub fn bar_with_edge(
label: impl Into<String>,
color: Color,
edge: Option<(Color, f32)>,
) -> Self {
Self {
label: label.into(),
color,
item_type: LegendItemType::Bar { edge },
has_error_bars: false,
}
}
pub fn histogram(label: impl Into<String>, color: Color) -> Self {
Self::histogram_with_edge(label, color, None)
}
pub fn histogram_with_edge(
label: impl Into<String>,
color: Color,
edge: Option<(Color, f32)>,
) -> Self {
Self {
label: label.into(),
color,
item_type: LegendItemType::Histogram { edge },
has_error_bars: false,
}
}
pub fn area(label: impl Into<String>, color: Color, edge_color: Option<Color>) -> Self {
Self {
label: label.into(),
color,
item_type: LegendItemType::Area { edge_color },
has_error_bars: false,
}
}
pub fn error_bar(label: impl Into<String>, color: Color) -> Self {
Self {
label: label.into(),
color,
item_type: LegendItemType::ErrorBar,
has_error_bars: true, }
}
pub fn from_tuple(label: String, color: Color) -> Self {
Self {
label,
color,
item_type: LegendItemType::Bar { edge: None },
has_error_bars: false,
}
}
pub fn with_error_bars(mut self, has_error_bars: bool) -> Self {
self.has_error_bars = has_error_bars;
self
}
}
pub(crate) const LEGACY_LEGEND_SWATCH_EDGE_DARK: Color = Color::from_gray(64);
pub(crate) const LEGACY_LEGEND_SWATCH_EDGE_LIGHT: Color = Color::from_gray(224);
pub(crate) const LEGACY_LEGEND_SWATCH_EDGE_WIDTH_PT: f32 = 0.8;
pub(crate) fn legacy_legend_swatch_edge(fill: Color) -> Color {
let alpha = fill.a as f32 / 255.0;
let over_panel = |channel: u8| channel as f32 * alpha + 255.0 * (1.0 - alpha);
let luma = 0.299 * over_panel(fill.r) + 0.587 * over_panel(fill.g) + 0.114 * over_panel(fill.b);
if luma > 128.0 {
LEGACY_LEGEND_SWATCH_EDGE_DARK
} else {
LEGACY_LEGEND_SWATCH_EDGE_LIGHT
}
}
#[derive(Debug, Clone, Copy)]
pub struct LegendSpacing {
pub handle_length: f32,
pub handle_height: f32,
pub handle_text_pad: f32,
pub label_spacing: f32,
pub border_pad: f32,
pub border_axes_pad: f32,
pub column_spacing: f32,
}
impl Default for LegendSpacing {
fn default() -> Self {
Self {
handle_length: 2.0, handle_height: 0.7, handle_text_pad: 1.0, label_spacing: 0.7, border_pad: 0.6, border_axes_pad: 1.0, column_spacing: 2.0, }
}
}
impl LegendSpacing {
pub fn to_pixels(self, font_size: f32) -> LegendSpacingPixels {
LegendSpacingPixels {
handle_length: self.handle_length * font_size,
handle_height: self.handle_height * font_size,
handle_text_pad: self.handle_text_pad * font_size,
label_spacing: self.label_spacing * font_size,
border_pad: self.border_pad * font_size,
border_axes_pad: self.border_axes_pad * font_size,
column_spacing: self.column_spacing * font_size,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LegendSpacingPixels {
pub handle_length: f32,
pub handle_height: f32,
pub handle_text_pad: f32,
pub label_spacing: f32,
pub border_pad: f32,
pub border_axes_pad: f32,
pub column_spacing: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LegendStyle {
pub visible: bool,
pub alpha: f32,
pub face_color: Color,
pub edge_color: Option<Color>,
pub border_width: f32,
pub fancy_box: bool,
pub corner_radius: f32,
pub shadow: bool,
pub shadow_offset: (f32, f32),
pub shadow_color: Color,
}
impl Default for LegendStyle {
fn default() -> Self {
Self {
visible: true,
alpha: 0.8,
face_color: Color::WHITE,
edge_color: Some(Color::from_gray(204)), border_width: 0.8,
fancy_box: true,
corner_radius: 4.0,
shadow: false,
shadow_offset: (2.0, -2.0),
shadow_color: Color::from_rgba(0, 0, 0, 50),
}
}
}
impl LegendStyle {
pub fn new() -> Self {
Self::default()
}
pub fn invisible() -> Self {
Self {
visible: false,
..Default::default()
}
}
pub fn rounded(radius: f32) -> Self {
Self {
fancy_box: true,
corner_radius: radius,
..Default::default()
}
}
pub fn sharp() -> Self {
Self {
fancy_box: false,
corner_radius: 0.0,
..Default::default()
}
}
pub fn visible(mut self, visible: bool) -> Self {
self.visible = visible;
self
}
pub fn alpha(mut self, alpha: f32) -> Self {
self.alpha = alpha.clamp(0.0, 1.0);
self
}
pub fn face_color(mut self, color: Color) -> Self {
self.face_color = color;
self
}
pub fn edge_color(mut self, color: Option<Color>) -> Self {
self.edge_color = color;
self
}
pub fn border_width(mut self, width: f32) -> Self {
self.border_width = width.max(0.0);
self
}
pub fn fancy_box(mut self, enabled: bool) -> Self {
self.fancy_box = enabled;
self
}
pub fn corner_radius(mut self, radius: f32) -> Self {
self.corner_radius = radius.max(0.0);
self
}
pub fn shadow(mut self, enabled: bool) -> Self {
self.shadow = enabled;
self
}
pub fn effective_corner_radius(&self) -> f32 {
if self.fancy_box {
self.corner_radius
} else {
0.0
}
}
pub fn effective_face_color(&self) -> Color {
self.face_color.with_alpha(self.alpha)
}
}
#[deprecated(since = "0.2.0", note = "Use LegendStyle instead")]
pub type LegendFrame = LegendStyle;
#[derive(Debug, Clone)]
pub struct Legend {
pub enabled: bool,
pub position: LegendPosition,
pub spacing: LegendSpacing,
pub style: LegendStyle,
pub font_size: f32,
pub text_color: Color,
pub columns: usize,
pub title: Option<String>,
}
impl Default for Legend {
fn default() -> Self {
Self {
enabled: false,
position: LegendPosition::default(),
spacing: LegendSpacing::default(),
style: LegendStyle::default(),
font_size: 10.0,
text_color: Color::BLACK,
columns: 1,
title: None,
}
}
}
impl Legend {
pub fn new() -> Self {
Self::default()
}
pub fn upper_right() -> Self {
Self {
enabled: true,
position: LegendPosition::UpperRight,
..Default::default()
}
}
pub fn best() -> Self {
Self {
enabled: true,
position: LegendPosition::Best,
..Default::default()
}
}
pub fn outside_right() -> Self {
Self {
enabled: true,
position: LegendPosition::OutsideRight,
..Default::default()
}
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn at(mut self, position: LegendPosition) -> Self {
self.position = position;
self
}
pub fn position(mut self, position: LegendPosition) -> Self {
self.position = position;
self
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn font_size(mut self, size: f32) -> Self {
self.font_size = size;
self
}
pub fn columns(mut self, cols: usize) -> Self {
self.columns = cols.max(1);
self
}
pub fn style(mut self, style: LegendStyle) -> Self {
self.style = style;
self
}
#[deprecated(since = "0.2.0", note = "Use style() instead")]
pub fn frame(mut self, style: LegendStyle) -> Self {
self.style = style;
self
}
pub fn spacing(mut self, spacing: LegendSpacing) -> Self {
self.spacing = spacing;
self
}
pub(crate) fn scaled_for_render(&self, render_scale: RenderScale) -> Self {
let mut scaled = self.clone();
scaled.font_size = render_scale.points_to_pixels(self.font_size);
scaled.style.border_width = render_scale.points_to_pixels(self.style.border_width);
scaled.style.corner_radius = render_scale.points_to_pixels(self.style.corner_radius);
scaled.style.shadow_offset = (
render_scale.points_to_pixels(self.style.shadow_offset.0),
render_scale.points_to_pixels(self.style.shadow_offset.1),
);
scaled
}
pub fn calculate_position(
&self,
legend_size: (f32, f32),
plot_area: (f32, f32, f32, f32),
) -> (f32, f32) {
let (width, height) = legend_size;
let (left, top, right, bottom) = plot_area;
let spacing_px = self.spacing.to_pixels(self.font_size);
let pad = spacing_px.border_axes_pad;
match self.position {
LegendPosition::Best => {
(right - width - pad, top + pad)
}
LegendPosition::UpperRight | LegendPosition::Right => (right - width - pad, top + pad),
LegendPosition::UpperLeft => (left + pad, top + pad),
LegendPosition::LowerLeft => (left + pad, bottom - height - pad),
LegendPosition::LowerRight => (right - width - pad, bottom - height - pad),
LegendPosition::CenterLeft => {
let center_y = (top + bottom) / 2.0;
(left + pad, center_y - height / 2.0)
}
LegendPosition::CenterRight => {
let center_y = (top + bottom) / 2.0;
(right - width - pad, center_y - height / 2.0)
}
LegendPosition::LowerCenter => {
let center_x = (left + right) / 2.0;
(center_x - width / 2.0, bottom - height - pad)
}
LegendPosition::UpperCenter => {
let center_x = (left + right) / 2.0;
(center_x - width / 2.0, top + pad)
}
LegendPosition::Center => {
let center_x = (left + right) / 2.0;
let center_y = (top + bottom) / 2.0;
(center_x - width / 2.0, center_y - height / 2.0)
}
LegendPosition::OutsideRight => (right + pad, top),
LegendPosition::OutsideLeft => (left - width - pad, top),
LegendPosition::OutsideUpper => (right - width, top - height - pad),
LegendPosition::OutsideLower => (right - width, bottom + pad),
LegendPosition::Custom { x, y, anchor } => {
let plot_width = right - left;
let plot_height = bottom - top;
let (x_mult, y_mult) = anchor.offset_multipliers();
let base_x = left + x * plot_width;
let base_y = top + (1.0 - y) * plot_height;
(base_x - x_mult * width, base_y - y_mult * height)
}
}
}
}
pub fn find_best_position(
legend_size: (f32, f32),
plot_area: (f32, f32, f32, f32),
data_bboxes: &[(f32, f32, f32, f32)], spacing: &LegendSpacing,
font_size: f32,
) -> LegendPosition {
let candidates = [
LegendPosition::UpperRight,
LegendPosition::UpperLeft,
LegendPosition::LowerLeft,
LegendPosition::LowerRight,
LegendPosition::CenterRight,
LegendPosition::CenterLeft,
LegendPosition::UpperCenter,
LegendPosition::LowerCenter,
LegendPosition::Center,
];
let legend = Legend {
position: LegendPosition::UpperRight, spacing: *spacing,
font_size,
..Default::default()
};
let mut best_position = LegendPosition::UpperRight;
let mut min_overlap = f32::MAX;
for &candidate in &candidates {
let mut test_legend = legend.clone();
test_legend.position = candidate;
let (x, y) = test_legend.calculate_position(legend_size, plot_area);
let legend_bbox = (x, y, x + legend_size.0, y + legend_size.1);
let overlap = calculate_total_overlap(legend_bbox, data_bboxes);
if overlap < min_overlap {
min_overlap = overlap;
best_position = candidate;
}
}
best_position
}
fn calculate_total_overlap(
legend_bbox: (f32, f32, f32, f32),
data_bboxes: &[(f32, f32, f32, f32)],
) -> f32 {
data_bboxes
.iter()
.map(|data_bbox| calculate_bbox_overlap(legend_bbox, *data_bbox))
.sum()
}
fn calculate_bbox_overlap(bbox1: (f32, f32, f32, f32), bbox2: (f32, f32, f32, f32)) -> f32 {
let (l1, t1, r1, b1) = bbox1;
let (l2, t2, r2, b2) = bbox2;
let x_overlap = (r1.min(r2) - l1.max(l2)).max(0.0);
let y_overlap = (b1.min(b2) - t1.max(t2)).max(0.0);
x_overlap * y_overlap
}
pub const LEGEND_OCCUPANCY_RESOLUTION: usize = 6;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct LegendOccupancy {
cells: Vec<(f32, f32, f32, f32)>,
}
impl LegendOccupancy {
pub fn from_screen_points<I>(plot_area: (f32, f32, f32, f32), points: I) -> Self
where
I: IntoIterator<Item = (f32, f32)>,
{
let (left, top, right, bottom) = plot_area;
let width = right - left;
let height = bottom - top;
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
return Self::default();
}
let resolution = LEGEND_OCCUPANCY_RESOLUTION;
let mut occupied = vec![false; resolution * resolution];
let cell_width = width / resolution as f32;
let cell_height = height / resolution as f32;
for (x, y) in points {
if !x.is_finite() || !y.is_finite() || x < left || x > right || y < top || y > bottom {
continue;
}
let column = (((x - left) / cell_width) as usize).min(resolution - 1);
let row = (((y - top) / cell_height) as usize).min(resolution - 1);
occupied[row * resolution + column] = true;
}
let cells = occupied
.iter()
.enumerate()
.filter(|(_, is_occupied)| **is_occupied)
.map(|(index, _)| {
let column = index % resolution;
let row = index / resolution;
let cell_left = left + column as f32 * cell_width;
let cell_top = top + row as f32 * cell_height;
(
cell_left,
cell_top,
cell_left + cell_width,
cell_top + cell_height,
)
})
.collect();
Self { cells }
}
pub fn boxes(&self) -> &[(f32, f32, f32, f32)] {
&self.cells
}
pub fn is_empty(&self) -> bool {
self.cells.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LegendEntryLayout {
pub item_index: usize,
pub handle_x: f32,
pub handle_center_y: f32,
pub label_x: f32,
pub label_top_y: f32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LegendTitleLayout {
pub center_x: f32,
pub top_y: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LegendLayout {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
pub position: LegendPosition,
pub spacing: LegendSpacingPixels,
pub font_size: f32,
pub title: Option<LegendTitleLayout>,
pub entries: Vec<LegendEntryLayout>,
}
impl LegendLayout {
pub fn size(&self) -> (f32, f32) {
(self.width, self.height)
}
pub fn bounds(&self) -> (f32, f32, f32, f32) {
(self.x, self.y, self.x + self.width, self.y + self.height)
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct LegendPlacement<'a> {
pub reserved: Option<(f32, f32, f32, f32)>,
pub occupancy: Option<&'a LegendOccupancy>,
}
pub fn estimated_label_width(label: &str, font_size: f32) -> f32 {
const AVERAGE_ADVANCE_RATIO: f32 = 0.58;
let columns: f32 = label
.chars()
.map(|character| if is_wide_glyph(character) { 2.0 } else { 1.0 })
.sum();
columns * font_size * AVERAGE_ADVANCE_RATIO
}
fn is_wide_glyph(character: char) -> bool {
matches!(
character,
'\u{1100}'..='\u{115F}'
| '\u{2E80}'..='\u{303E}'
| '\u{3041}'..='\u{33FF}'
| '\u{3400}'..='\u{4DBF}'
| '\u{4E00}'..='\u{9FFF}'
| '\u{A000}'..='\u{A4CF}'
| '\u{AC00}'..='\u{D7A3}'
| '\u{F900}'..='\u{FAFF}'
| '\u{FE30}'..='\u{FE6F}'
| '\u{FF00}'..='\u{FF60}'
| '\u{FFE0}'..='\u{FFE6}'
| '\u{1F300}'..='\u{1F64F}'
| '\u{1F900}'..='\u{1F9FF}'
| '\u{20000}'..='\u{2FFFD}'
)
}
fn legend_content_size(
items: &[LegendItem],
legend: &Legend,
measure: &mut dyn FnMut(&str) -> Result<f32>,
) -> Result<(f32, f32, f32)> {
let spacing = legend.spacing.to_pixels(legend.font_size);
let columns = legend.columns.max(1);
let rows = items.len().div_ceil(columns);
let mut max_label_width = 0.0_f32;
for item in items {
max_label_width = max_label_width.max(measure(&item.label)?);
}
let entry_width = spacing.handle_length + spacing.handle_text_pad + max_label_width;
let content_width =
entry_width * columns as f32 + columns.saturating_sub(1) as f32 * spacing.column_spacing;
let content_height =
rows as f32 * legend.font_size + rows.saturating_sub(1) as f32 * spacing.label_spacing;
let (title_width, title_height) = match legend.title.as_deref() {
Some(title) => (measure(title)?, legend.font_size + spacing.label_spacing),
None => (0.0, 0.0),
};
Ok((
content_width.max(title_width) + spacing.border_pad * 2.0,
content_height + title_height + spacing.border_pad * 2.0,
entry_width,
))
}
pub fn measure_legend_size(
items: &[LegendItem],
legend: &Legend,
mut measure: impl FnMut(&str) -> Result<f32>,
) -> Result<(f32, f32)> {
let (width, height, _) = legend_content_size(items, legend, &mut measure)?;
Ok((width, height))
}
pub fn layout_legend(
items: &[LegendItem],
legend: &Legend,
plot_area: (f32, f32, f32, f32),
placement: LegendPlacement<'_>,
mut measure: impl FnMut(&str) -> Result<f32>,
) -> Result<LegendLayout> {
let spacing = legend.spacing.to_pixels(legend.font_size);
let columns = legend.columns.max(1);
let rows = items.len().div_ceil(columns);
let (natural_width, natural_height, entry_width) =
legend_content_size(items, legend, &mut measure)?;
let natural = (natural_width, natural_height);
let occupied: &[(f32, f32, f32, f32)] = match placement.occupancy {
Some(occupancy) => occupancy.boxes(),
None => &[],
};
let position = match legend.position {
LegendPosition::Best if placement.reserved.is_none() => find_best_position(
natural,
plot_area,
occupied,
&legend.spacing,
legend.font_size,
),
explicit => explicit,
};
let (x, y, width, height) = match placement.reserved {
Some((left, top, right, bottom)) => (left, top, right - left, bottom - top),
None => {
let placed = Legend {
position,
..legend.clone()
};
let (x, y) = placed.calculate_position(natural, plot_area);
(x, y, natural_width, natural_height)
}
};
let column_width = if placement.reserved.is_some() {
let gutters = columns.saturating_sub(1) as f32 * spacing.column_spacing;
(width - spacing.border_pad * 2.0 - gutters) / columns as f32
} else {
entry_width
};
let mut row_center_y = y + spacing.border_pad + legend.font_size / 2.0;
let title = legend.title.is_some().then(|| LegendTitleLayout {
center_x: x + width / 2.0,
top_y: row_center_y,
});
if title.is_some() {
row_center_y += legend.font_size + spacing.label_spacing;
}
let max_center_y = match placement.reserved {
Some((_, _, _, bottom)) => bottom - spacing.border_pad,
None => f32::INFINITY,
};
let mut entries = Vec::with_capacity(items.len());
for column in 0..columns {
let handle_x =
x + spacing.border_pad + column as f32 * (column_width + spacing.column_spacing);
let mut center_y = row_center_y;
for row in 0..rows {
let item_index = column * rows + row;
if item_index >= items.len() || center_y > max_center_y {
break;
}
entries.push(LegendEntryLayout {
item_index,
handle_x,
handle_center_y: center_y,
label_x: handle_x + spacing.handle_length + spacing.handle_text_pad,
label_top_y: center_y - legend.font_size * 0.65,
});
center_y += legend.font_size + spacing.label_spacing;
}
}
Ok(LegendLayout {
x,
y,
width,
height,
position,
spacing,
font_size: legend.font_size,
title,
entries,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_legend_item_creation() {
let line_item = LegendItem::line("sin(x)", Color::BLUE, LineStyle::Solid, 1.5);
assert_eq!(line_item.label, "sin(x)");
assert!(matches!(line_item.item_type, LegendItemType::Line { .. }));
let scatter_item = LegendItem::scatter("data", Color::RED, MarkerStyle::Circle, 6.0);
assert!(matches!(
scatter_item.item_type,
LegendItemType::Scatter { .. }
));
let line_marker = LegendItem::line_marker(
"combined",
Color::GREEN,
LineStyle::Dashed,
1.5,
MarkerStyle::Circle,
6.0,
);
assert!(matches!(
line_marker.item_type,
LegendItemType::LineMarker { .. }
));
}
#[test]
fn test_patch_items_default_to_no_edge() {
assert!(matches!(
LegendItem::bar("bars", Color::BLUE).item_type,
LegendItemType::Bar { edge: None }
));
assert!(matches!(
LegendItem::histogram("hist", Color::BLUE).item_type,
LegendItemType::Histogram { edge: None }
));
assert!(matches!(
LegendItem::from_tuple("legacy".to_string(), Color::BLUE).item_type,
LegendItemType::Bar { edge: None }
));
}
#[test]
fn test_patch_items_carry_edge_colour_and_point_width() {
let edge = (Color::BLACK, 0.8);
let bar = LegendItem::bar_with_edge("bars", Color::BLUE, Some(edge));
match &bar.item_type {
LegendItemType::Bar { edge: Some(e) } => {
assert_eq!(e.0, Color::BLACK);
assert!((e.1 - 0.8).abs() < f32::EPSILON);
}
other => panic!("expected Bar with edge, got {other:?}"),
}
assert_eq!(bar.color, Color::BLUE);
let hist = LegendItem::histogram_with_edge("hist", Color::RED, Some(edge));
match &hist.item_type {
LegendItemType::Histogram { edge: Some(e) } => {
assert_eq!(e.0, Color::BLACK);
assert!((e.1 - 0.8).abs() < f32::EPSILON);
}
other => panic!("expected Histogram with edge, got {other:?}"),
}
assert_eq!(hist.color, Color::RED);
}
#[test]
fn test_spacing_to_pixels() {
let spacing = LegendSpacing::default();
let pixels = spacing.to_pixels(10.0);
assert!((pixels.handle_length - 20.0).abs() < 0.001); assert!((pixels.label_spacing - 7.0).abs() < 0.001); assert!((pixels.border_pad - 6.0).abs() < 0.001); assert!((pixels.handle_text_pad - 10.0).abs() < 0.001); assert!((pixels.border_axes_pad - 10.0).abs() < 0.001); }
#[test]
fn test_legend_position_is_outside() {
assert!(!LegendPosition::UpperRight.is_outside());
assert!(!LegendPosition::Center.is_outside());
assert!(LegendPosition::OutsideRight.is_outside());
assert!(LegendPosition::OutsideUpper.is_outside());
let custom_inside = LegendPosition::Custom {
x: 0.5,
y: 0.5,
anchor: LegendAnchor::Center,
};
assert!(!custom_inside.is_outside());
let custom_outside = LegendPosition::Custom {
x: 1.1,
y: 0.5,
anchor: LegendAnchor::NorthWest,
};
assert!(custom_outside.is_outside());
}
fn six_px_per_char(text: &str) -> Result<f32> {
Ok(text.chars().count() as f32 * 6.0)
}
fn two_line_items() -> Vec<LegendItem> {
vec![
LegendItem::line("sin(x)", Color::BLUE, LineStyle::Solid, 1.5),
LegendItem::line("cos(x)", Color::RED, LineStyle::Dashed, 1.5),
]
}
#[test]
fn test_legend_size_calculation() {
let legend = Legend::new();
let (width, height) =
measure_legend_size(&two_line_items(), &legend, six_px_per_char).expect("measure");
assert!(width > 0.0);
assert!(height > 0.0);
}
#[test]
fn layout_is_sized_from_measured_text_not_bytes() {
let legend = Legend::new();
let ascii = vec![LegendItem::line("abc", Color::BLUE, LineStyle::Solid, 1.5)];
let cjk = vec![LegendItem::line(
"日本語",
Color::BLUE,
LineStyle::Solid,
1.5,
)];
let ascii_size = measure_legend_size(&ascii, &legend, six_px_per_char).expect("ascii");
let cjk_size = measure_legend_size(&cjk, &legend, six_px_per_char).expect("cjk");
assert_eq!(ascii_size, cjk_size);
let bytes = "日本語".len() as f32;
assert!(bytes > "日本語".chars().count() as f32);
}
#[test]
fn reserved_size_matches_drawn_layout() {
let legend = Legend {
enabled: true,
position: LegendPosition::UpperRight,
title: Some("series".to_string()),
..Default::default()
};
let items = two_line_items();
let measured = measure_legend_size(&items, &legend, six_px_per_char).expect("measure");
let layout = layout_legend(
&items,
&legend,
(0.0, 0.0, 400.0, 300.0),
LegendPlacement::default(),
six_px_per_char,
)
.expect("layout");
assert_eq!(measured, layout.size());
let (left, top, right, bottom) = layout.bounds();
for entry in &layout.entries {
assert!(entry.handle_x >= left, "{entry:?}");
assert!(entry.label_x <= right, "{entry:?}");
assert!(entry.handle_center_y >= top, "{entry:?}");
assert!(entry.handle_center_y <= bottom, "{entry:?}");
}
}
#[test]
fn title_widens_the_frame_when_it_is_the_longest_run() {
let items = vec![LegendItem::line("a", Color::BLUE, LineStyle::Solid, 1.5)];
let untitled = Legend::new();
let titled = Legend {
title: Some("a very long legend title".to_string()),
..Legend::new()
};
let (narrow, _) = measure_legend_size(&items, &untitled, six_px_per_char).expect("narrow");
let (wide, _) = measure_legend_size(&items, &titled, six_px_per_char).expect("wide");
assert!(wide > narrow, "narrow = {narrow}, wide = {wide}");
}
#[test]
fn layout_places_every_item_in_column_major_order() {
let legend = Legend {
columns: 2,
..Legend::new()
};
let items: Vec<_> = (0..4)
.map(|index| LegendItem::line(format!("s{index}"), Color::BLUE, LineStyle::Solid, 1.0))
.collect();
let layout = layout_legend(
&items,
&legend,
(0.0, 0.0, 400.0, 300.0),
LegendPlacement::default(),
six_px_per_char,
)
.expect("layout");
assert_eq!(layout.entries.len(), 4);
assert_eq!(
layout
.entries
.iter()
.map(|entry| entry.item_index)
.collect::<Vec<_>>(),
vec![0, 1, 2, 3]
);
assert_eq!(layout.entries[0].handle_x, layout.entries[1].handle_x);
assert!(layout.entries[2].handle_x > layout.entries[0].handle_x);
}
#[test]
fn reserved_rectangle_clips_rows_that_do_not_fit() {
let legend = Legend::new();
let items: Vec<_> = (0..6)
.map(|index| LegendItem::line(format!("s{index}"), Color::BLUE, LineStyle::Solid, 1.0))
.collect();
let layout = layout_legend(
&items,
&legend,
(0.0, 0.0, 400.0, 300.0),
LegendPlacement {
reserved: Some((10.0, 10.0, 120.0, 60.0)),
occupancy: None,
},
six_px_per_char,
)
.expect("layout");
assert_eq!(layout.bounds(), (10.0, 10.0, 120.0, 60.0));
assert!(layout.entries.len() < items.len());
for entry in &layout.entries {
assert!(entry.handle_center_y <= 60.0);
}
}
#[test]
fn occupancy_grid_bins_projected_points() {
let plot_area = (0.0, 0.0, 60.0, 60.0);
let grid = LegendOccupancy::from_screen_points(
plot_area,
[
(1.0, 1.0),
(5.0, 5.0),
(-10.0, 5.0),
(5.0, f32::NAN),
(1000.0, 1000.0),
],
);
assert_eq!(grid.boxes().to_vec(), vec![(0.0, 0.0, 10.0, 10.0)]);
assert!(!grid.is_empty());
assert!(LegendOccupancy::from_screen_points((0.0, 0.0, 0.0, 0.0), [(1.0, 1.0)]).is_empty());
}
#[test]
fn best_position_avoids_the_occupied_corner() {
let plot_area = (0.0, 0.0, 600.0, 400.0);
let legend = Legend {
enabled: true,
position: LegendPosition::Best,
..Default::default()
};
let items = two_line_items();
let points: Vec<(f32, f32)> = (0..30)
.flat_map(|column| {
(0..30).map(move |row| (305.0 + column as f32 * 10.0, 5.0 + row as f32 * 6.5))
})
.collect();
let occupancy = LegendOccupancy::from_screen_points(plot_area, points);
let avoided = layout_legend(
&items,
&legend,
plot_area,
LegendPlacement {
reserved: None,
occupancy: Some(&occupancy),
},
six_px_per_char,
)
.expect("layout");
assert_ne!(avoided.position, LegendPosition::UpperRight);
let blind = layout_legend(
&items,
&legend,
plot_area,
LegendPlacement::default(),
six_px_per_char,
)
.expect("layout");
assert_eq!(blind.position, LegendPosition::UpperRight);
}
#[test]
fn estimated_label_width_counts_wide_glyphs_double() {
let latin = estimated_label_width("abc", 10.0);
let cjk = estimated_label_width("日本語", 10.0);
assert!(
(cjk - latin * 2.0).abs() < 1e-4,
"latin = {latin}, cjk = {cjk}"
);
assert!(cjk > latin);
}
#[test]
fn test_scaled_for_render_scales_point_fields() {
let legend = Legend {
font_size: 12.0,
style: LegendStyle {
border_width: 1.5,
corner_radius: 3.0,
shadow_offset: (2.0, 4.0),
..Default::default()
},
..Default::default()
};
let scaled = legend.scaled_for_render(RenderScale::new(144.0));
assert!((scaled.font_size - 24.0).abs() < 0.001);
assert!((scaled.style.border_width - 3.0).abs() < 0.001);
assert!((scaled.style.corner_radius - 6.0).abs() < 0.001);
assert!((scaled.style.shadow_offset.0 - 4.0).abs() < 0.001);
assert!((scaled.style.shadow_offset.1 - 8.0).abs() < 0.001);
}
#[test]
fn test_legend_position_calculation() {
let legend = Legend::new().at(LegendPosition::UpperRight);
let plot_area = (100.0, 50.0, 500.0, 400.0); let legend_size = (80.0, 60.0);
let (x, y) = legend.calculate_position(legend_size, plot_area);
assert!(x < 500.0);
assert!(x > 400.0);
assert!(y > 50.0);
assert!(y < 100.0);
}
#[test]
fn test_anchor_offsets() {
assert_eq!(LegendAnchor::NorthWest.offset_multipliers(), (0.0, 0.0));
assert_eq!(LegendAnchor::Center.offset_multipliers(), (0.5, 0.5));
assert_eq!(LegendAnchor::SouthEast.offset_multipliers(), (1.0, 1.0));
}
#[test]
fn test_bbox_overlap() {
let overlap1 = calculate_bbox_overlap((0.0, 0.0, 10.0, 10.0), (20.0, 20.0, 30.0, 30.0));
assert_eq!(overlap1, 0.0);
let overlap2 = calculate_bbox_overlap((0.0, 0.0, 10.0, 10.0), (5.0, 5.0, 15.0, 15.0));
assert_eq!(overlap2, 25.0);
let overlap3 = calculate_bbox_overlap((0.0, 0.0, 20.0, 20.0), (5.0, 5.0, 10.0, 10.0));
assert_eq!(overlap3, 25.0); }
#[test]
fn test_find_best_position() {
let legend_size = (80.0, 60.0);
let plot_area = (100.0, 50.0, 500.0, 400.0);
let data_bboxes = vec![(400.0, 50.0, 500.0, 150.0)];
let best = find_best_position(
legend_size,
plot_area,
&data_bboxes,
&LegendSpacing::default(),
10.0,
);
assert_ne!(best, LegendPosition::UpperRight);
}
#[test]
#[allow(deprecated)]
fn custom_top_left_matches_upper_left_pixel() {
let plot_area = (100.0, 50.0, 700.0, 450.0);
let legend_size = (120.0, 80.0);
let spacing = LegendSpacing {
border_axes_pad: 0.0,
..Default::default()
};
let upper_left = Legend {
position: LegendPosition::UpperLeft,
spacing,
..Default::default()
}
.calculate_position(legend_size, plot_area);
let converted = LegendPosition::from(Position::custom(0.0, 0.0));
let custom = Legend {
position: converted,
spacing,
..Default::default()
}
.calculate_position(legend_size, plot_area);
assert_eq!(custom, upper_left);
assert_eq!(custom, (100.0, 50.0));
}
#[test]
#[allow(deprecated)]
fn custom_position_stays_in_the_upper_half() {
let plot_area = (0.0, 0.0, 400.0, 200.0);
let legend = Legend {
position: LegendPosition::from(Position::custom(0.1, 0.05)),
spacing: LegendSpacing {
border_axes_pad: 0.0,
..Default::default()
},
..Default::default()
};
let (x, y) = legend.calculate_position((50.0, 20.0), plot_area);
assert!((x - 40.0).abs() < 1e-4, "x = {x}");
assert!((y - 10.0).abs() < 1e-4, "y = {y}");
}
#[test]
#[allow(deprecated)]
fn from_position_maps_every_named_variant() {
let cases = [
(Position::Best, LegendPosition::Best),
(Position::TopLeft, LegendPosition::UpperLeft),
(Position::TopCenter, LegendPosition::UpperCenter),
(Position::TopRight, LegendPosition::UpperRight),
(Position::CenterLeft, LegendPosition::CenterLeft),
(Position::Center, LegendPosition::Center),
(Position::CenterRight, LegendPosition::CenterRight),
(Position::BottomLeft, LegendPosition::LowerLeft),
(Position::BottomCenter, LegendPosition::LowerCenter),
(Position::BottomRight, LegendPosition::LowerRight),
];
for (old, new) in cases {
assert_eq!(LegendPosition::from(old), new, "mapping {old}");
}
}
}