use crate::aes::Aesthetic;
use crate::data::Value;
use crate::guide::config::GuideLegend;
use crate::render::backend::{
DrawBackend, LineStyle, Linetype, PointStyle, RectStyle, TextAnchor, TextStyle,
};
use crate::render::{Rect, RenderError};
use crate::scale::ScaleSet;
use crate::theme::{LegendPosition, Theme};
const LEGEND_AESTHETICS: &[Aesthetic] = &[
Aesthetic::Color,
Aesthetic::Fill,
Aesthetic::Shape,
Aesthetic::Linetype,
Aesthetic::Size,
Aesthetic::Alpha,
Aesthetic::Linewidth,
];
pub(crate) fn is_legend_aesthetic(a: &Aesthetic) -> bool {
LEGEND_AESTHETICS.contains(a)
}
pub(crate) fn legend_scales_for(
scales: &ScaleSet,
suppressed: &std::collections::HashSet<Aesthetic>,
) -> Vec<Aesthetic> {
let mut legend_scales: Vec<Aesthetic> = Vec::new();
for aes in LEGEND_AESTHETICS {
if suppressed.contains(aes) {
continue;
}
if let Some(scale) = scales.get(aes) {
if !scale.breaks().is_empty() {
legend_scales.push(aes.clone());
}
}
}
legend_scales
}
pub(crate) fn group_mergeable_legends(
scales: &ScaleSet,
legend_scales: &[Aesthetic],
guide: &GuideLegend,
) -> Vec<Vec<Aesthetic>> {
struct Key {
title: String,
discrete: bool,
breaks: Vec<(f64, String)>,
}
let mut groups: Vec<(Key, Vec<Aesthetic>)> = Vec::new();
for aes in legend_scales {
let Some(scale) = scales.get(aes) else {
continue;
};
let breaks = scale.breaks();
let title = guide
.title
.clone()
.unwrap_or_else(|| scale.name().to_string());
let discrete = scale.is_discrete();
match groups
.iter_mut()
.find(|(k, _)| k.title == title && k.discrete == discrete && k.breaks == breaks)
{
Some((_, members)) => members.push(aes.clone()),
None => groups.push((
Key {
title,
discrete,
breaks,
},
vec![aes.clone()],
)),
}
}
groups.into_iter().map(|(_, members)| members).collect()
}
#[deprecated(
since = "0.23.0",
note = "unused internally since 0.23 (superseded by the crate-internal draw_legend_planned, \
sipemu/ggplot-rs#155); no direct public replacement — legends are drawn automatically \
by GGPlot's render methods. Will be removed in a later minor release."
)]
pub fn draw_legend(
scales: &ScaleSet,
theme: &Theme,
plot_area: &Rect,
backend: &mut dyn DrawBackend,
guide: &GuideLegend,
suppressed: &std::collections::HashSet<Aesthetic>,
) -> Result<(), RenderError> {
if matches!(theme.legend_position, LegendPosition::None) {
return Ok(());
}
let mut legend_scales: Vec<&Aesthetic> = Vec::new();
for aes in LEGEND_AESTHETICS {
if suppressed.contains(aes) {
continue;
}
if let Some(scale) = scales.get(aes) {
if !scale.breaks().is_empty() {
if *aes == Aesthetic::Fill && legend_scales.contains(&&Aesthetic::Color) {
continue;
}
legend_scales.push(aes);
}
}
}
if legend_scales.is_empty() {
return Ok(());
}
let (legend_x, legend_y, mut is_horizontal) =
legend_position(theme, plot_area, 0, (0.0, 0.0), plot_area);
if let Some(dir) = theme.legend_direction {
is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
}
let mut offset_y = legend_y;
let mut offset_x = legend_x;
for aes in &legend_scales {
let scale = scales.get(aes).unwrap();
if scale.is_discrete() {
if is_horizontal {
let width = draw_discrete_legend_at(
scales,
&[((*aes).clone(), scale)],
theme,
offset_x,
offset_y,
backend,
guide,
true,
)?;
offset_x += width + theme.legend_spacing * 2.0;
} else {
let height = draw_discrete_legend_at(
scales,
&[((*aes).clone(), scale)],
theme,
offset_x,
offset_y,
backend,
guide,
false,
)?;
offset_y += height + theme.legend_spacing * 2.0;
}
} else {
if matches!(aes, Aesthetic::Color | Aesthetic::Fill) {
let height = draw_continuous_legend_at(
scale,
theme,
offset_x,
offset_y,
backend,
guide,
is_horizontal,
)?;
if is_horizontal {
let (bar_len, _) = continuous_bar_dims(theme, true);
offset_x += bar_len + theme.legend_spacing * 2.0;
} else {
offset_y += height + theme.legend_spacing * 2.0;
}
} else {
let height = draw_discrete_legend_at(
scales,
&[((*aes).clone(), scale)],
theme,
offset_x,
offset_y,
backend,
guide,
is_horizontal,
)?;
if is_horizontal {
offset_x += theme.legend_key_width
+ theme.legend_text.size * 6.0
+ theme.legend_spacing * 2.0;
} else {
offset_y += height + theme.legend_spacing * 2.0;
}
}
}
}
Ok(())
}
fn legend_rows_beyond_first(
is_horizontal: bool,
legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
) -> usize {
if is_horizontal {
legend_plans
.values()
.map(|p| p.rows.saturating_sub(1))
.max()
.unwrap_or(0)
} else {
legend_plans
.values()
.map(|p| p.rows.saturating_sub(1))
.sum()
}
}
pub(crate) fn legend_band_height(theme: &Theme, legend_extra_rows: usize) -> f64 {
theme.legend_margin.top
+ (1.0 + legend_extra_rows as f64) * (theme.legend_key_height + theme.legend_spacing)
+ theme.legend_margin.bottom
}
pub(crate) fn top_legend_lift(
theme: &Theme,
scales: &ScaleSet,
suppressed: &std::collections::HashSet<Aesthetic>,
legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
) -> f64 {
if !matches!(theme.legend_position, LegendPosition::Top) {
return 0.0;
}
if legend_scales_for(scales, suppressed).is_empty() {
return 0.0;
}
let is_horizontal = match theme.legend_direction {
Some(dir) => matches!(dir, crate::theme::LegendDirection::Horizontal),
None => true, };
legend_band_height(theme, legend_rows_beyond_first(is_horizontal, legend_plans))
}
fn estimate_legend_box(
scales: &ScaleSet,
legend_scales: &[Aesthetic],
theme: &Theme,
guide: &GuideLegend,
legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
) -> (f64, f64) {
let mut total_h = 0.0_f64;
let mut max_w = 0.0_f64;
for aes in legend_scales {
let Some(scale) = scales.get(aes) else {
continue;
};
let breaks = scale.breaks();
if breaks.is_empty() {
continue;
}
let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
let title_offset = if !title.is_empty() {
theme.legend_title.size + 4.0
} else {
0.0
};
let diameter = size_key_diameter(aes, scales, scale, &breaks);
let key_width = theme.legend_key_width.max(diameter);
let key_height = theme.legend_key_height.max(key_width);
let single_col_width = key_width + theme.legend_spacing + theme.legend_text.size * 6.0;
let is_continuous_bar =
matches!(aes, Aesthetic::Color | Aesthetic::Fill) && !scale.is_discrete();
let (w, h) = if is_continuous_bar {
(single_col_width, title_offset + key_height * 8.0)
} else if let Some(plan) = legend_plans.get(aes) {
let w = if plan.col_pitch > 0.0 {
plan.cols as f64 * plan.col_pitch
} else {
single_col_width
};
(w, title_offset + plan.rows as f64 * key_height)
} else {
(
single_col_width,
title_offset + breaks.len() as f64 * key_height,
)
};
total_h += h;
max_w = max_w.max(w);
}
(max_w, total_h)
}
fn clamp_span(pos: f64, size: f64, lo: f64, hi: f64) -> f64 {
if size >= hi - lo {
lo
} else {
pos.clamp(lo, hi - size)
}
}
pub(crate) fn legend_position(
theme: &Theme,
plot_area: &Rect,
legend_extra_rows: usize,
legend_size_hint: (f64, f64),
canvas: &Rect,
) -> (f64, f64, bool) {
match theme.legend_position {
LegendPosition::Right => (
plot_area.x + plot_area.width + theme.legend_margin.left,
plot_area.y + theme.legend_margin.top,
false,
),
LegendPosition::Left => (
theme.legend_margin.left,
plot_area.y + theme.legend_margin.top,
false,
),
LegendPosition::Top => (
plot_area.x + theme.legend_margin.left,
plot_area.y - legend_band_height(theme, legend_extra_rows) + theme.legend_margin.top,
true,
),
LegendPosition::Bottom => (
plot_area.x + theme.legend_margin.left,
plot_area.y + plot_area.height + theme.legend_margin.top + 30.0,
true,
),
LegendPosition::None => (0.0, 0.0, false),
LegendPosition::Inside(fx, fy) => {
let (legend_w, legend_h) = legend_size_hint;
let raw_x = plot_area.x + fx * (plot_area.width - legend_w);
let raw_y = plot_area.y + (1.0 - fy) * (plot_area.height - legend_h);
let x = clamp_span(raw_x, legend_w, canvas.x, canvas.x + canvas.width);
let y = clamp_span(raw_y, legend_h, canvas.y, canvas.y + canvas.height);
(x, y, false)
}
}
}
pub(crate) fn size_key_diameter(
aes: &Aesthetic,
scales: &ScaleSet,
scale: &dyn crate::scale::Scale,
breaks: &[(f64, String)],
) -> f64 {
if *aes != Aesthetic::Size {
return 0.0;
}
let max_r = breaks
.iter()
.map(|(pos, label)| {
scale
.map_fraction_to_size(*pos)
.or_else(|| scales.map_size(&Value::Str(label.clone())))
.unwrap_or(3.0)
})
.fold(0.0_f64, f64::max);
max_r * 2.0
}
#[allow(clippy::too_many_arguments)]
fn draw_discrete_legend_at(
scales: &ScaleSet,
group: &[(Aesthetic, &dyn crate::scale::Scale)],
theme: &Theme,
legend_x: f64,
legend_y: f64,
backend: &mut dyn DrawBackend,
guide: &GuideLegend,
is_horizontal: bool,
) -> Result<f64, RenderError> {
let (_, scale) = &group[0];
let mut breaks = scale.breaks();
if breaks.is_empty() {
return Ok(0.0);
}
if guide.reverse {
breaks.reverse();
}
let diameter = group
.iter()
.map(|(a, s)| size_key_diameter(a, scales, *s, &breaks))
.fold(0.0_f64, f64::max);
let swatch_size = theme.legend_key_width.max(diameter);
let item_height = theme.legend_key_height.max(swatch_size);
let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
let legend_family = if theme.legend_title.family.is_empty() {
None
} else {
Some(theme.legend_title.family.clone())
};
let title_offset = if !title.is_empty() {
backend.draw_text(
title,
(legend_x, legend_y),
&TextStyle {
color: theme.legend_title.color,
size: theme.legend_title.size,
anchor: TextAnchor::Start,
angle: 0.0,
family: legend_family,
face: theme.legend_title.face,
bg_colour: None,
bg_r: 0.0,
},
)?;
theme.legend_title.size + 4.0
} else {
0.0
};
let items_y = legend_y + title_offset;
if theme.legend_background.visible {
let total_height = breaks.len() as f64 * item_height;
let total_width = swatch_size + theme.legend_spacing + theme.legend_text.size * 6.0;
if let Some(fill) = theme.legend_background.fill {
backend.draw_rect(
(legend_x - 2.0, items_y - 2.0),
(legend_x + total_width + 2.0, items_y + total_height + 2.0),
&RectStyle {
fill: Some(fill),
stroke: theme.legend_background.color,
stroke_width: theme.legend_background.width,
alpha: 1.0,
clip: false,
corner_radius: 0.0,
},
)?;
}
}
let mut cur_x = legend_x;
for (i, (pos, label)) in breaks.iter().enumerate() {
let x = if is_horizontal { cur_x } else { legend_x };
let y = if is_horizontal {
items_y
} else {
items_y + i as f64 * item_height
};
let center_x = x + swatch_size / 2.0;
let center_y = y + swatch_size / 2.0;
if theme.legend_key.visible {
if let Some(fill) = theme.legend_key.fill {
backend.draw_rect(
(x, y),
(x + swatch_size, y + swatch_size),
&RectStyle {
fill: Some(fill),
stroke: theme.legend_key.color,
stroke_width: theme.legend_key.width,
alpha: 1.0,
clip: false,
corner_radius: 0.0,
},
)?;
}
}
for (member_aes, member_scale) in group {
draw_legend_swatch(
backend,
scales,
member_aes,
*member_scale,
theme,
label,
*pos,
x,
y,
center_x,
center_y,
swatch_size,
)?;
}
let label_family = if theme.legend_text.family.is_empty() {
None
} else {
Some(theme.legend_text.family.clone())
};
backend.draw_text(
label,
(x + swatch_size + theme.legend_spacing, center_y),
&TextStyle {
color: theme.legend_text.color,
size: theme.legend_text.size,
anchor: TextAnchor::Start,
angle: 0.0,
family: label_family,
face: theme.legend_text.face,
bg_colour: None,
bg_r: 0.0,
},
)?;
if is_horizontal {
cur_x = x
+ swatch_size
+ theme.legend_spacing
+ (label.chars().count() as f64) * theme.legend_text.size * 0.62
+ theme.legend_spacing * 3.0;
}
}
if is_horizontal {
Ok(cur_x - legend_x) } else {
Ok(title_offset + breaks.len() as f64 * item_height)
}
}
fn continuous_bar_dims(theme: &Theme, is_horizontal: bool) -> (f64, f64) {
if is_horizontal {
(theme.legend_key_height * 8.0, theme.legend_key_width)
} else {
(theme.legend_key_width, theme.legend_key_height * 8.0)
}
}
pub(crate) fn continuous_horizontal_bar_height(theme: &Theme, has_title: bool) -> f64 {
let title_offset = if has_title {
theme.legend_title.size + 4.0
} else {
0.0
};
let (_, bar_thickness) = continuous_bar_dims(theme, true);
let tick_len = 3.0;
let label_line = theme.legend_text.size + 4.0;
title_offset + bar_thickness + tick_len + theme.legend_spacing + label_line
}
fn draw_continuous_legend_at(
scale: &dyn crate::scale::Scale,
theme: &Theme,
legend_x: f64,
legend_y: f64,
backend: &mut dyn DrawBackend,
guide: &GuideLegend,
is_horizontal: bool,
) -> Result<f64, RenderError> {
let breaks = scale.breaks();
if breaks.is_empty() {
return Ok(0.0);
}
let (bar_width, bar_height) = continuous_bar_dims(theme, is_horizontal);
let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
let cont_family = if theme.legend_title.family.is_empty() {
None
} else {
Some(theme.legend_title.family.clone())
};
let title_offset = if !title.is_empty() {
backend.draw_text(
title,
(legend_x, legend_y),
&TextStyle {
color: theme.legend_title.color,
size: theme.legend_title.size,
anchor: TextAnchor::Start,
angle: 0.0,
family: cont_family,
face: theme.legend_title.face,
bg_colour: None,
bg_r: 0.0,
},
)?;
theme.legend_title.size + 4.0
} else {
0.0
};
let bar_top = legend_y + title_offset;
if theme.legend_background.visible {
let total_width = if is_horizontal {
bar_width
} else {
bar_width + theme.legend_spacing + theme.legend_text.size * 6.0
};
let total_height = if is_horizontal {
bar_height + theme.legend_spacing + theme.legend_text.size * 2.0
} else {
bar_height
};
if let Some(fill) = theme.legend_background.fill {
backend.draw_rect(
(legend_x - 2.0, bar_top - 2.0),
(legend_x + total_width + 2.0, bar_top + total_height + 2.0),
&RectStyle {
fill: Some(fill),
stroke: theme.legend_background.color,
stroke_width: theme.legend_background.width,
alpha: 1.0,
clip: false,
corner_radius: 0.0,
},
)?;
}
}
let (data_min, data_max) = scale.domain().unwrap_or((0.0, 1.0));
let n_slices = 50;
if is_horizontal {
let slice_width = bar_width / n_slices as f64;
for i in 0..n_slices {
let t = i as f64 / n_slices as f64;
let data_val = data_min + t * (data_max - data_min);
let color = scale
.map_to_color(&Value::Float(data_val))
.unwrap_or((127, 127, 127));
let sx = legend_x + i as f64 * slice_width;
backend.draw_rect(
(sx, bar_top),
(sx + slice_width + 0.5, bar_top + bar_height),
&RectStyle {
fill: Some(color),
stroke: None,
stroke_width: 0.0,
alpha: 1.0,
clip: false,
corner_radius: 0.0,
},
)?;
}
} else {
let slice_height = bar_height / n_slices as f64;
for i in 0..n_slices {
let t = 1.0 - i as f64 / n_slices as f64;
let data_val = data_min + t * (data_max - data_min);
let color = scale
.map_to_color(&Value::Float(data_val))
.unwrap_or((127, 127, 127));
let sy = bar_top + i as f64 * slice_height;
backend.draw_rect(
(legend_x, sy),
(legend_x + bar_width, sy + slice_height + 0.5),
&RectStyle {
fill: Some(color),
stroke: None,
stroke_width: 0.0,
alpha: 1.0,
clip: false,
corner_radius: 0.0,
},
)?;
}
}
let border = theme.legend_bar_border_line();
let border_style = LineStyle {
color: border.color,
width: border.width,
alpha: 1.0,
linetype: border.linetype,
..Default::default()
};
if border.visible {
backend.draw_line(
&[
(legend_x, bar_top),
(legend_x + bar_width, bar_top),
(legend_x + bar_width, bar_top + bar_height),
(legend_x, bar_top + bar_height),
(legend_x, bar_top),
],
&border_style,
)?;
}
let tick_len = 3.0;
let tick_family = if theme.legend_text.family.is_empty() {
None
} else {
Some(theme.legend_text.family.clone())
};
for (pos, label) in &breaks {
if is_horizontal {
let tick_x = legend_x + bar_width * pos;
backend.draw_line(
&[
(tick_x, bar_top + bar_height),
(tick_x, bar_top + bar_height + tick_len),
],
&border_style,
)?;
backend.draw_text(
label,
(
tick_x,
bar_top + bar_height + tick_len + theme.legend_text.size,
),
&TextStyle {
color: theme.legend_text.color,
size: theme.legend_text.size,
anchor: TextAnchor::Middle,
angle: 0.0,
family: tick_family.clone(),
face: theme.legend_text.face,
bg_colour: None,
bg_r: 0.0,
},
)?;
} else {
let tick_y = bar_top + bar_height * (1.0 - pos);
backend.draw_line(
&[
(legend_x + bar_width, tick_y),
(legend_x + bar_width + tick_len, tick_y),
],
&border_style,
)?;
backend.draw_text(
label,
(
legend_x + bar_width + tick_len + theme.legend_spacing,
tick_y,
),
&TextStyle {
color: theme.legend_text.color,
size: theme.legend_text.size,
anchor: TextAnchor::Start,
angle: 0.0,
family: tick_family.clone(),
face: theme.legend_text.face,
bg_colour: None,
bg_r: 0.0,
},
)?;
}
}
if is_horizontal {
Ok(continuous_horizontal_bar_height(theme, !title.is_empty()))
} else {
Ok(title_offset + bar_height)
}
}
#[allow(clippy::too_many_arguments)]
fn draw_legend_swatch(
backend: &mut dyn DrawBackend,
scales: &ScaleSet,
aes: &Aesthetic,
scale: &dyn crate::scale::Scale,
theme: &Theme,
label: &str,
pos: f64,
x: f64,
y: f64,
center_x: f64,
center_y: f64,
swatch_size: f64,
) -> Result<(), RenderError> {
let value = Value::Str(label.to_string());
match aes {
Aesthetic::Color | Aesthetic::Fill => {
let color = scales.map_color(aes, &value).unwrap_or((127, 127, 127));
let style = RectStyle {
fill: Some(color),
stroke: None,
stroke_width: 0.0,
alpha: 1.0,
clip: false,
corner_radius: 0.0,
};
if scale.is_na_break(label) {
if let Some(p) = scale.na_fill_pattern() {
return backend.draw_rect_pattern(
(x, y),
(x + swatch_size, y + swatch_size),
&p,
&style,
);
}
}
backend.draw_rect((x, y), (x + swatch_size, y + swatch_size), &style)?;
}
Aesthetic::Shape => {
let shape = scales
.map_shape(&value)
.unwrap_or(crate::render::backend::PointShape::Circle);
backend.draw_shape(
(center_x, center_y),
swatch_size / 3.0,
&PointStyle {
color: theme.geom.ink.unwrap_or(theme.legend_text.color),
alpha: 1.0,
filled: true,
shape,
},
)?;
}
Aesthetic::Linetype => {
let lt = scales.map_linetype(&value).unwrap_or(Linetype::Solid);
backend.draw_line(
&[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
&LineStyle {
color: theme.geom.ink.unwrap_or(theme.legend_text.color),
width: 1.5,
alpha: 1.0,
linetype: lt,
..Default::default()
},
)?;
}
Aesthetic::Size => {
let size = scale
.map_fraction_to_size(pos)
.or_else(|| scales.map_size(&value))
.unwrap_or(3.0);
backend.draw_shape(
(center_x, center_y),
size.min(swatch_size / 2.0),
&PointStyle {
color: theme.geom.ink.unwrap_or(theme.legend_text.color),
alpha: 1.0,
filled: true,
shape: crate::render::backend::PointShape::Circle,
},
)?;
}
Aesthetic::Alpha => {
let alpha = scale
.map_fraction_to_alpha(pos)
.or_else(|| scales.map_alpha(&value))
.unwrap_or(1.0);
backend.draw_rect(
(x, y),
(x + swatch_size, y + swatch_size),
&RectStyle {
fill: Some(theme.geom.ink.unwrap_or(theme.legend_text.color)),
stroke: None,
stroke_width: 0.0,
alpha,
clip: false,
corner_radius: 0.0,
},
)?;
}
Aesthetic::Linewidth => {
let width = scale.map_fraction_to_size(pos).unwrap_or(1.0);
backend.draw_line(
&[(x + 2.0, center_y), (x + swatch_size - 2.0, center_y)],
&LineStyle {
color: theme.geom.ink.unwrap_or(theme.legend_text.color),
width: width.min(swatch_size),
alpha: 1.0,
linetype: Linetype::Solid,
..Default::default()
},
)?;
}
_ => {}
}
Ok(())
}
#[derive(Clone, Debug)]
pub(crate) struct LegendEntry {
pub break_idx: usize,
pub row: usize,
pub x_offset: f64,
pub label_override: Option<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct LegendOverflow {
pub dropped: usize,
pub row: usize,
pub x_offset: f64,
pub text: String,
}
#[derive(Clone, Debug)]
pub(crate) struct LegendPlan {
pub rows: usize,
pub entries: Vec<LegendEntry>,
pub overflow: Option<LegendOverflow>,
pub cols: usize,
pub col_pitch: f64,
}
fn legend_pitch(label: &str, theme: &Theme) -> f64 {
let swatch = theme.legend_key_width;
let spacing = theme.legend_spacing;
let text_size = theme.legend_text.size;
let legacy =
swatch + spacing + (label.chars().count() as f64) * text_size * 0.62 + spacing * 3.0;
let measured = swatch + spacing + crate::render::text::text_width(label, text_size) + 8.0;
legacy.max(measured)
}
pub(crate) fn plan_legend(
breaks: &[(f64, String)],
guide: &GuideLegend,
theme: &Theme,
avail_width: f64,
avail_height: f64,
is_horizontal: bool,
) -> Option<LegendPlan> {
let n = breaks.len();
if n == 0 {
return None;
}
if guide.nrow.is_some() || guide.ncol.is_some() {
let (rows, cols) = match (guide.nrow, guide.ncol) {
(Some(nrow), Some(ncol)) => (nrow, ncol),
(Some(nrow), None) => (nrow.max(1), n.div_ceil(nrow.max(1))),
(None, Some(ncol)) => (n.div_ceil(ncol.max(1)), ncol.max(1)),
(None, None) => unreachable!("checked above"),
};
let rows = rows.max(1);
let cols = cols.max(1);
let col_pitch = breaks
.iter()
.map(|(_, l)| legend_pitch(l, theme))
.fold(0.0_f64, f64::max);
let entries = (0..n)
.map(|i| {
let (row, col) = if guide.byrow {
(i / cols, i % cols)
} else {
(i % rows, i / rows)
};
LegendEntry {
break_idx: i,
row,
x_offset: col as f64 * col_pitch,
label_override: None,
}
})
.collect();
return Some(LegendPlan {
rows,
entries,
overflow: None,
cols,
col_pitch,
});
}
if !is_horizontal {
return None;
}
let pitches: Vec<f64> = breaks.iter().map(|(_, l)| legend_pitch(l, theme)).collect();
let total_width: f64 = pitches.iter().sum();
if total_width <= avail_width.max(0.0) {
return None; }
let item_height = theme.legend_key_height.max(1.0);
let row_limit = guide
.max_rows
.unwrap_or_else(|| 1 + (avail_height / 3.0 / item_height).floor().max(0.0) as usize)
.max(1);
let mut entries: Vec<LegendEntry> = Vec::with_capacity(n);
let mut row = 0usize;
let mut cur_x = 0.0_f64;
for (i, &p) in pitches.iter().enumerate() {
if cur_x > 0.0 && cur_x + p > avail_width {
if row + 1 >= row_limit {
break; }
row += 1;
cur_x = 0.0;
}
entries.push(LegendEntry {
break_idx: i,
row,
x_offset: cur_x,
label_override: None,
});
cur_x += p;
}
if entries.len() >= n {
return Some(LegendPlan {
rows: row + 1,
entries,
overflow: None,
cols: 1,
col_pitch: 0.0,
});
}
let mut dropped = n - entries.len();
loop {
let text = format!("+{dropped} more");
let more_pitch = legend_pitch(&text, theme);
let row_count = entries.iter().filter(|e| e.row == row).count();
let last_x = entries
.iter()
.filter(|e| e.row == row)
.map(|e| e.x_offset + pitches[e.break_idx])
.fold(0.0_f64, f64::max);
if last_x + more_pitch <= avail_width || row_count == 0 {
return Some(LegendPlan {
rows: row + 1,
entries,
overflow: Some(LegendOverflow {
dropped,
row,
x_offset: last_x,
text,
}),
cols: 1,
col_pitch: 0.0,
});
}
entries.pop();
dropped += 1;
}
}
fn discrete_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
legend_scales
.iter()
.filter(|aes| scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false))
.count()
.max(1)
}
fn continuous_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
legend_scales
.iter()
.filter(|aes| !scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false))
.count()
}
fn continuous_guide_width(theme: &Theme) -> f64 {
let (bar_len, _) = continuous_bar_dims(theme, true);
bar_len + theme.legend_spacing * 2.0
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn plan_all_legends(
scales: &ScaleSet,
guide: &GuideLegend,
theme: &Theme,
suppressed: &std::collections::HashSet<Aesthetic>,
avail_width: f64,
avail_height: f64,
is_horizontal: bool,
warnings: &mut Vec<String>,
) -> std::collections::HashMap<Aesthetic, LegendPlan> {
let legend_scales = legend_scales_for(scales, suppressed);
let n_discrete = discrete_guide_count(scales, &legend_scales);
let per_guide_width = if is_horizontal {
let n_continuous = continuous_guide_count(scales, &legend_scales);
let continuous_width = n_continuous as f64 * continuous_guide_width(theme);
((avail_width - continuous_width) / n_discrete as f64).max(0.0)
} else {
avail_width
};
let mut plans = std::collections::HashMap::new();
for aes in &legend_scales {
let Some(scale) = scales.get(aes) else {
continue;
};
if !scale.is_discrete() {
continue;
}
let breaks = scale.breaks();
let validated_guide = guide.validated(breaks.len(), warnings);
if let Some(plan) = plan_legend(
&breaks,
&validated_guide,
theme,
per_guide_width,
avail_height,
is_horizontal,
) {
plans.insert(aes.clone(), plan);
}
}
plans
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn draw_legend_planned(
scales: &ScaleSet,
theme: &Theme,
plot_area: &Rect,
legend_avail_width: f64,
backend: &mut dyn DrawBackend,
guide: &GuideLegend,
suppressed: &std::collections::HashSet<Aesthetic>,
legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
) -> Result<(), RenderError> {
if matches!(theme.legend_position, LegendPosition::None) {
return Ok(());
}
let legend_scales = legend_scales_for(scales, suppressed);
if legend_scales.is_empty() {
return Ok(());
}
let groups = group_mergeable_legends(scales, &legend_scales, guide);
let representatives: Vec<Aesthetic> = groups.iter().map(|g| g[0].clone()).collect();
let is_horizontal_hint = match theme.legend_direction {
Some(dir) => matches!(dir, crate::theme::LegendDirection::Horizontal),
None => matches!(
theme.legend_position,
LegendPosition::Top | LegendPosition::Bottom
),
};
let legend_extra_rows = legend_rows_beyond_first(is_horizontal_hint, legend_plans);
let legend_size_hint =
estimate_legend_box(scales, &representatives, theme, guide, legend_plans);
let canvas = backend.total_area();
let (legend_x, legend_y, mut is_horizontal) = legend_position(
theme,
plot_area,
legend_extra_rows,
legend_size_hint,
&canvas,
);
if let Some(dir) = theme.legend_direction {
is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
}
let n_discrete = discrete_guide_count(scales, &representatives);
let n_continuous = continuous_guide_count(scales, &representatives);
let continuous_width = n_continuous as f64 * continuous_guide_width(theme);
let per_guide_width = ((legend_avail_width - continuous_width) / n_discrete as f64).max(0.0);
let mut offset_y = legend_y;
let mut offset_x = legend_x;
for group in &groups {
let representative = &group[0];
let rep_scale = scales.get(representative).unwrap();
let scale_group: Vec<(Aesthetic, &dyn crate::scale::Scale)> = group
.iter()
.map(|a| (a.clone(), scales.get(a).unwrap()))
.collect();
if !rep_scale.is_discrete() {
if matches!(representative, Aesthetic::Color | Aesthetic::Fill) {
let height = draw_continuous_legend_at(
rep_scale,
theme,
offset_x,
offset_y,
backend,
guide,
is_horizontal,
)?;
if is_horizontal {
let (bar_len, _) = continuous_bar_dims(theme, true);
offset_x += bar_len + theme.legend_spacing * 2.0;
} else {
offset_y += height + theme.legend_spacing * 2.0;
}
} else {
let height = draw_discrete_legend_at(
scales,
&scale_group,
theme,
offset_x,
offset_y,
backend,
guide,
is_horizontal,
)?;
if is_horizontal {
offset_x += theme.legend_key_width
+ theme.legend_text.size * 6.0
+ theme.legend_spacing * 2.0;
} else {
offset_y += height + theme.legend_spacing * 2.0;
}
}
continue;
}
match legend_plans.get(representative) {
None => {
if is_horizontal {
let width = draw_discrete_legend_at(
scales,
&scale_group,
theme,
offset_x,
offset_y,
backend,
guide,
true,
)?;
offset_x += width + theme.legend_spacing * 2.0;
} else {
let height = draw_discrete_legend_at(
scales,
&scale_group,
theme,
offset_x,
offset_y,
backend,
guide,
false,
)?;
offset_y += height + theme.legend_spacing * 2.0;
}
}
Some(plan) => {
let height = draw_discrete_legend_grid(
scales,
&scale_group,
theme,
offset_x,
offset_y,
backend,
guide,
plan,
)?;
if is_horizontal {
offset_x += per_guide_width;
} else {
offset_y += height + theme.legend_spacing * 2.0;
}
}
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn draw_discrete_legend_grid(
scales: &ScaleSet,
group: &[(Aesthetic, &dyn crate::scale::Scale)],
theme: &Theme,
legend_x: f64,
legend_y: f64,
backend: &mut dyn DrawBackend,
guide: &GuideLegend,
plan: &LegendPlan,
) -> Result<f64, RenderError> {
let (_, scale) = &group[0];
let mut breaks = scale.breaks();
if breaks.is_empty() {
return Ok(0.0);
}
if guide.reverse {
breaks.reverse();
}
let diameter = group
.iter()
.map(|(a, s)| size_key_diameter(a, scales, *s, &breaks))
.fold(0.0_f64, f64::max);
let swatch_size = theme.legend_key_width.max(diameter);
let item_height = theme.legend_key_height.max(swatch_size);
let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
let legend_family = if theme.legend_title.family.is_empty() {
None
} else {
Some(theme.legend_title.family.clone())
};
let title_offset = if !title.is_empty() {
backend.draw_text(
title,
(legend_x, legend_y),
&TextStyle {
color: theme.legend_title.color,
size: theme.legend_title.size,
anchor: TextAnchor::Start,
angle: 0.0,
family: legend_family,
face: theme.legend_title.face,
bg_colour: None,
bg_r: 0.0,
},
)?;
theme.legend_title.size + 4.0
} else {
0.0
};
let items_y = legend_y + title_offset;
for entry in &plan.entries {
let (pos, label) = &breaks[entry.break_idx];
let x = legend_x + entry.x_offset;
let y = items_y + entry.row as f64 * item_height;
let center_x = x + swatch_size / 2.0;
let center_y = y + swatch_size / 2.0;
if theme.legend_key.visible {
if let Some(fill) = theme.legend_key.fill {
backend.draw_rect(
(x, y),
(x + swatch_size, y + swatch_size),
&RectStyle {
fill: Some(fill),
stroke: theme.legend_key.color,
stroke_width: theme.legend_key.width,
alpha: 1.0,
clip: false,
corner_radius: 0.0,
},
)?;
}
}
for (member_aes, member_scale) in group {
draw_legend_swatch(
backend,
scales,
member_aes,
*member_scale,
theme,
label,
*pos,
x,
y,
center_x,
center_y,
swatch_size,
)?;
}
let label_family = if theme.legend_text.family.is_empty() {
None
} else {
Some(theme.legend_text.family.clone())
};
let display = entry.label_override.as_deref().unwrap_or(label.as_str());
backend.draw_text_with_title(
display,
(x + swatch_size + theme.legend_spacing, center_y),
&TextStyle {
color: theme.legend_text.color,
size: theme.legend_text.size,
anchor: TextAnchor::Start,
angle: 0.0,
family: label_family,
face: theme.legend_text.face,
bg_colour: None,
bg_r: 0.0,
},
entry.label_override.as_deref().map(|_| label.as_str()),
)?;
}
if let Some(ov) = &plan.overflow {
let x = legend_x + ov.x_offset;
let y = items_y + ov.row as f64 * item_height + swatch_size / 2.0;
let label_family = if theme.legend_text.family.is_empty() {
None
} else {
Some(theme.legend_text.family.clone())
};
backend.draw_text(
&ov.text,
(x, y),
&TextStyle {
color: theme.legend_text.color,
size: theme.legend_text.size,
anchor: TextAnchor::Start,
angle: 0.0,
family: label_family,
face: theme.legend_text.face,
bg_colour: None,
bg_r: 0.0,
},
)?;
}
Ok(title_offset + plan.rows as f64 * item_height)
}
#[cfg(test)]
mod group_mergeable_legends_tests {
use super::*;
use crate::scale::color::RGBAColor;
use crate::scale::manual::ScaleManual;
#[test]
fn an_aesthetic_with_no_attached_scale_is_skipped_not_grouped() {
let mut scales = ScaleSet::new();
scales.add(Box::new(ScaleManual::new(
Aesthetic::Color,
vec![("a", RGBAColor::new(200, 0, 0))],
)));
let legend_scales = vec![Aesthetic::Color, Aesthetic::Fill];
let guide = GuideLegend::default();
let groups = group_mergeable_legends(&scales, &legend_scales, &guide);
assert_eq!(groups, vec![vec![Aesthetic::Color]]);
}
}
#[cfg(test)]
mod continuous_bar_dims_tests {
use super::*;
#[test]
fn dims_swap_between_vertical_and_horizontal() {
let theme = Theme::default();
let (w, h) = continuous_bar_dims(&theme, false);
assert_eq!(w, theme.legend_key_width);
assert_eq!(h, theme.legend_key_height * 8.0);
let (w, h) = continuous_bar_dims(&theme, true);
assert_eq!(w, theme.legend_key_height * 8.0);
assert_eq!(h, theme.legend_key_width);
}
#[test]
fn horizontal_bar_height_adds_a_title_line_only_when_there_is_a_title() {
let theme = Theme::default();
let without_title = continuous_horizontal_bar_height(&theme, false);
let with_title = continuous_horizontal_bar_height(&theme, true);
assert!(with_title > without_title);
assert!(((with_title - without_title) - (theme.legend_title.size + 4.0)).abs() < 1e-9);
let (_, thickness) = continuous_bar_dims(&theme, true);
let tick_len = 3.0;
let label_line = theme.legend_text.size + 4.0;
assert!(
(without_title - (thickness + tick_len + theme.legend_spacing + label_line)).abs()
< 1e-9
);
}
}