use crate::aes::Aesthetic;
use crate::data::Value;
use crate::guide::config::{ColourbarDisplay, GuideColourbar, 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};
pub(crate) 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), 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, &guide.colourbar_for(aes));
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 {
let m = theme.legend_margin_outer();
m.top
+ (1.0 + legend_extra_rows as f64) * (theme.legend_key_height + theme.legend_spacing)
+ m.bottom
}
pub(crate) fn top_legend_lift(
theme: &Theme,
scales: &ScaleSet,
suppressed: &std::collections::HashSet<Aesthetic>,
legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
guide: &GuideLegend,
) -> f64 {
if !matches!(theme.legend_position, LegendPosition::Top) {
return if extra_legend_sides(scales, suppressed, guide, &theme.legend_position)[2] {
legend_band_height(theme, 0)
} else {
0.0
};
}
let legend_scales = legend_scales_for(scales, suppressed);
if legend_scales.is_empty() {
return 0.0;
}
let is_horizontal = match theme.legend_direction {
Some(dir) => matches!(dir, crate::theme::LegendDirection::Horizontal),
None => true, };
let base = legend_band_height(theme, legend_rows_beyond_first(is_horizontal, legend_plans));
let colorbar_extra = if is_horizontal {
legend_scales
.iter()
.find_map(|aes| {
if !matches!(aes, Aesthetic::Color | Aesthetic::Fill) {
return None;
}
let scale = scales.get(aes)?;
if scale.is_discrete() {
return None;
}
let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
let bar_height = continuous_horizontal_bar_height(
theme,
!title.is_empty(),
&guide.colourbar_for(aes),
);
Some((bar_height - theme.legend_key_height).max(0.0))
})
.unwrap_or(0.0)
} else {
0.0
};
base + colorbar_extra
}
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 {
if let Some((w, h)) = estimate_guide_size(scales, aes, theme, guide, legend_plans) {
total_h += h;
max_w = max_w.max(w);
}
}
(max_w, total_h)
}
fn horizontal_guide_height(
scales: &ScaleSet,
aes: &Aesthetic,
theme: &Theme,
guide: &GuideLegend,
legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
) -> f64 {
let Some(scale) = scales.get(aes) else {
return 0.0;
};
let title = guide.title.as_deref().unwrap_or_else(|| scale.name());
if !scale.is_discrete() && is_colorbar_aesthetic(aes) {
return continuous_horizontal_bar_height(
theme,
!title.is_empty(),
&guide.colourbar_for(aes),
);
}
let breaks = scale.breaks();
let title_offset = if title.is_empty() {
0.0
} else {
theme.legend_title.size + 4.0
};
let key = theme.legend_key_height.max(
theme
.legend_key_width
.max(size_key_diameter(aes, scales, scale, &breaks)),
);
let rows = legend_plans.get(aes).map_or(1, |p| p.rows.max(1)) as f64;
let gap = theme.legend_key_gap_y();
let bins_labels = if guide.bins_for(aes, scale).is_some() {
2.0 + theme.legend_text.size
} else {
0.0
};
title_offset + rows * (key + gap) - gap + bins_labels
}
fn estimate_guide_size(
scales: &ScaleSet,
aes: &Aesthetic,
theme: &Theme,
guide: &GuideLegend,
legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
) -> Option<(f64, f64)> {
{
let scale = scales.get(aes)?;
let breaks = scale.breaks();
if breaks.is_empty() {
return None;
}
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 label_w = breaks
.iter()
.map(|(_, l)| crate::render::text::text_width(l, theme.legend_text.size))
.fold(0.0_f64, f64::max);
let title_w = crate::render::text::text_width(title, theme.legend_title.size);
let single_col_width = (key_width + theme.legend_spacing + label_w).max(title_w);
let is_continuous_bar =
matches!(aes, Aesthetic::Color | Aesthetic::Fill) && !scale.is_discrete();
let (w, h) = if is_continuous_bar {
let bar = guide.colourbar_for(aes);
let bar_w = bar.bar_width.unwrap_or(key_width);
let bar_h = bar.bar_height.unwrap_or(key_height * 8.0);
(
(bar_w + theme.legend_spacing + label_w).max(title_w),
title_offset + bar_h + theme.legend_text.size / 2.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
};
let gap = theme.legend_key_gap_y();
(
w,
title_offset + plan.rows as f64 * (key_height + gap) - gap,
)
} else {
let gap = theme.legend_key_gap_y();
(
single_col_width,
title_offset + breaks.len() as f64 * (key_height + gap) - gap,
)
};
Some((w, 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),
title_lift: f64,
canvas: &Rect,
) -> (f64, f64, bool) {
let (ref_x, ref_y) = if theme.legend_location_plot {
(
canvas.x + theme.plot_margin.left,
canvas.y + theme.plot_margin.top,
)
} else {
(plot_area.x, plot_area.y)
};
let om = theme.legend_margin_outer();
match theme.legend_position {
LegendPosition::Right => (
plot_area.x + plot_area.width + om.left,
ref_y + om.top,
false,
),
LegendPosition::Left => (om.left, ref_y + om.top, false),
LegendPosition::Top => (
ref_x + om.left,
plot_area.y - legend_band_height(theme, legend_extra_rows) + om.top,
true,
),
LegendPosition::Bottom => (
ref_x + om.left,
plot_area.y
+ plot_area.height
+ om.top
+ crate::render::layout::PlotLayout::x_axis_band(theme, None).min(30.0),
true,
),
LegendPosition::None => (0.0, 0.0, false),
LegendPosition::Inside(fx, fy) => {
let (legend_w, legend_h) = legend_size_hint;
let m = &theme.legend_margin;
let lift = title_lift;
let box_h = legend_h + lift;
let raw_x = plot_area.x + m.left + fx * (plot_area.width - m.left - m.right - legend_w);
let raw_top =
plot_area.y + m.top + (1.0 - fy) * (plot_area.height - m.top - m.bottom - box_h);
let x = clamp_span(raw_x, legend_w, canvas.x, canvas.x + canvas.width);
let top = clamp_span(raw_top, box_h, canvas.y, canvas.y + canvas.height);
(x, top + lift, false)
}
}
}
fn inside_title_lift(
scales: &ScaleSet,
representatives: &[Aesthetic],
theme: &Theme,
guide: &GuideLegend,
) -> f64 {
let first = representatives
.iter()
.filter_map(|aes| scales.get(aes))
.find(|scale| !scale.breaks().is_empty());
match first {
Some(scale)
if theme.legend_title.visible
&& !guide
.title
.as_deref()
.unwrap_or_else(|| scale.name())
.is_empty() =>
{
theme.legend_title.size / 2.0
}
_ => 0.0,
}
}
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.is_reversed(&group[0].0) {
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.set_element_class(Some("gg-legend gg-legend-title"));
crate::render::rich::draw(
backend,
theme.rich_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;
let gap_y = theme.legend_key_gap_y();
let keys_height = breaks.len() as f64 * item_height + (breaks.len() - 1) as f64 * gap_y;
if theme.legend_background.visible {
let (total_width, total_height) = if is_horizontal {
let row: f64 = breaks
.iter()
.map(|(_, label)| {
swatch_size
+ theme.legend_spacing
+ (label.chars().count() as f64) * theme.legend_text.size * 0.62
+ theme
.legend_key_spacing_x
.unwrap_or(theme.legend_spacing * 3.0)
})
.sum();
(row, item_height)
} else {
(
swatch_size + theme.legend_spacing + theme.legend_text.size * 6.0,
keys_height,
)
};
if let Some(fill) = theme.legend_background.fill {
backend.set_element_class(Some("gg-legend gg-legend-background"));
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,
},
)?;
}
}
if let Some(bins) = guide.bins_for(&group[0].0, *scale) {
if let Some(mut edges) = scale
.bin_edge_labels()
.filter(|e| e.len() == breaks.len() + 1)
{
if guide.is_reversed(&group[0].0) {
edges.reverse();
}
let extent = draw_bins_keys(
scales,
group,
theme,
guide,
&bins,
&breaks,
&edges,
(legend_x, items_y),
swatch_size,
item_height,
backend,
is_horizontal,
)?;
return Ok(if is_horizontal {
extent
} else {
title_offset + extent
});
}
}
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 + gap_y)
};
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.set_element_class(Some("gg-legend gg-legend-key"));
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,
},
)?;
}
}
backend.set_element_class(Some("gg-legend gg-legend-key"));
draw_group_keys(
backend,
scales,
group,
theme,
guide,
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.set_element_class(Some("gg-legend gg-legend-text"));
crate::render::rich::draw(
backend,
theme.rich_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_key_spacing_x
.unwrap_or(theme.legend_spacing * 3.0);
}
}
if is_horizontal {
Ok(cur_x - legend_x) } else {
Ok(title_offset + keys_height)
}
}
#[allow(clippy::too_many_arguments)]
fn draw_bins_keys(
scales: &ScaleSet,
group: &[(Aesthetic, &dyn crate::scale::Scale)],
theme: &Theme,
guide: &GuideLegend,
bins: &crate::guide::config::GuideBins,
breaks: &[(f64, String)],
edges: &[String],
(x0, y0): (f64, f64),
swatch_size: f64,
item_height: f64,
backend: &mut dyn DrawBackend,
is_horizontal: bool,
) -> Result<f64, RenderError> {
let n = breaks.len();
let text_size = theme.legend_text.size;
let pitch = if is_horizontal {
let widest = edges
.iter()
.map(|l| l.chars().count() as f64 * text_size * 0.62)
.fold(0.0_f64, f64::max);
swatch_size.max(widest + theme.legend_spacing)
} else {
item_height
};
for (i, (pos, label)) in breaks.iter().enumerate() {
let (cell_x, cell_y) = if is_horizontal {
(x0 + i as f64 * pitch, y0)
} else {
(x0, y0 + i as f64 * pitch)
};
let (cell_w, cell_h) = if is_horizontal {
(pitch, swatch_size)
} else {
(swatch_size, pitch)
};
if theme.legend_key.visible {
if let Some(fill) = theme.legend_key.fill {
backend.set_element_class(Some("gg-legend gg-legend-key"));
backend.draw_rect(
(cell_x, cell_y),
(cell_x + cell_w, cell_y + cell_h),
&RectStyle {
fill: Some(fill),
stroke: theme.legend_key.color,
stroke_width: theme.legend_key.width,
alpha: 1.0,
clip: false,
corner_radius: 0.0,
},
)?;
}
}
let x = cell_x + (cell_w - swatch_size) / 2.0;
let y = cell_y + (cell_h - swatch_size) / 2.0;
backend.set_element_class(Some("gg-legend gg-legend-key"));
draw_group_keys(
backend,
scales,
group,
theme,
guide,
label,
*pos,
(x, y, x + swatch_size / 2.0, y + swatch_size / 2.0),
swatch_size,
)?;
}
let length = n as f64 * pitch;
let tick = (swatch_size * 0.2).max(2.0);
let line_style = LineStyle {
color: theme.legend_text.color,
width: 0.5,
alpha: 1.0,
linetype: Linetype::Solid,
..Default::default()
};
if bins.axis {
let axis = if is_horizontal {
let ay = y0 + swatch_size;
[(x0, ay), (x0 + length, ay)]
} else {
let ax = x0 + swatch_size;
[(ax, y0), (ax, y0 + length)]
};
backend.set_element_class(Some("gg-legend gg-legend-tick"));
backend.draw_line(&axis, &line_style)?;
}
let family = if theme.legend_text.family.is_empty() {
None
} else {
Some(theme.legend_text.family.clone())
};
for (j, label) in edges.iter().enumerate() {
let at = j as f64 * pitch;
if bins.axis {
let t = if is_horizontal {
let (x, ay) = (x0 + at, y0 + swatch_size);
[(x, ay), (x, ay - tick)]
} else {
let (ax, y) = (x0 + swatch_size, y0 + at);
[(ax, y), (ax - tick, y)]
};
backend.set_element_class(Some("gg-legend gg-legend-tick"));
backend.draw_line(&t, &line_style)?;
}
if (j == 0 || j == n) && !bins.show_limits {
continue;
}
let (pos, anchor) = if is_horizontal {
(
(x0 + at, y0 + swatch_size + 2.0 + text_size * 0.6),
TextAnchor::Middle,
)
} else {
(
(x0 + swatch_size + theme.legend_spacing, y0 + at),
TextAnchor::Start,
)
};
backend.set_element_class(Some("gg-legend gg-legend-text"));
crate::render::rich::draw(
backend,
theme.rich_text,
label,
pos,
&TextStyle {
color: theme.legend_text.color,
size: text_size,
anchor,
angle: 0.0,
family: family.clone(),
face: theme.legend_text.face,
bg_colour: None,
bg_r: 0.0,
},
)?;
}
Ok(length)
}
pub(crate) fn continuous_bar_dims(
theme: &Theme,
is_horizontal: bool,
bar: &GuideColourbar,
) -> (f64, f64) {
let (w, h) = if is_horizontal {
(theme.legend_key_height * 8.0, theme.legend_key_width)
} else {
(theme.legend_key_width, theme.legend_key_height * 8.0)
};
(
bar.bar_width
.filter(|v| v.is_finite() && *v > 0.0)
.unwrap_or(w),
bar.bar_height
.filter(|v| v.is_finite() && *v > 0.0)
.unwrap_or(h),
)
}
pub(crate) fn continuous_horizontal_bar_height(
theme: &Theme,
has_title: bool,
bar: &GuideColourbar,
) -> f64 {
let title_offset = if has_title {
theme.legend_title.size + 4.0
} else {
0.0
};
let (_, bar_thickness) = continuous_bar_dims(theme, true, bar);
let tick_len = theme.legend_ticks_length;
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 = guide.colourbar_for(&scale.aesthetic());
let (bar_width, bar_height) = continuous_bar_dims(theme, is_horizontal, &bar);
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.set_element_class(Some("gg-legend gg-legend-title"));
crate::render::rich::draw(
backend,
theme.rich_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.set_element_class(Some("gg-legend gg-legend-background"));
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 at = |t: f64| data_min + t * (data_max - data_min);
let mut step_labels: Option<Vec<(f64, String)>> = None;
if !bar.steps && bar.display == ColourbarDisplay::Gradient {
let n_slices = bar.nbin.max(1);
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 color = scale
.map_to_color(&Value::Float(at(t)))
.unwrap_or((127, 127, 127));
let sx = legend_x + i as f64 * slice_width;
backend.set_element_class(Some("gg-legend gg-legend-key gg-colourbar"));
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 color = scale
.map_to_color(&Value::Float(at(t)))
.unwrap_or((127, 127, 127));
let sy = bar_top + i as f64 * slice_height;
backend.set_element_class(Some("gg-legend gg-legend-key gg-colourbar"));
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,
},
)?;
}
}
} else {
let blocks: Vec<(f64, f64, f64)> = if bar.steps {
let mut edges: Vec<(f64, Option<String>)> = vec![(0.0, None)];
for (pos, label) in &breaks {
if *pos > 1e-9 && *pos < 1.0 - 1e-9 {
edges.push((*pos, Some(label.clone())));
}
}
edges.push((1.0, None));
let n = edges.len() - 1;
let place = |i: usize| {
if bar.even_steps {
i as f64 / n as f64
} else {
edges[i].0
}
};
let mut labels: Vec<(f64, String)> = Vec::new();
for (i, (_, l)) in edges.iter().enumerate() {
match l {
Some(l) => labels.push((place(i), l.clone())),
None if bar.show_limits => {
let v = if i == 0 { data_min } else { data_max };
labels.push((place(i), crate::scale::util::format_number_opt(v, None)));
}
None => {}
}
}
step_labels = Some(labels);
(0..n)
.map(|i| {
(
place(i),
place(i + 1),
at((edges[i].0 + edges[i + 1].0) / 2.0),
)
})
.collect()
} else {
let n = bar.nbin.max(1);
(0..n)
.map(|i| {
let (t0, t1) = (i as f64 / n as f64, (i + 1) as f64 / n as f64);
(t0, t1, at((t0 + t1) / 2.0))
})
.collect()
};
for &(t0, t1, value) in &blocks {
let color = scale
.map_to_color(&Value::Float(value))
.unwrap_or((127, 127, 127));
let (p0, p1) = if is_horizontal {
(
(legend_x + t0 * bar_width, bar_top),
(legend_x + t1 * bar_width, bar_top + bar_height),
)
} else {
(
(legend_x, bar_top + (1.0 - t1) * bar_height),
(legend_x + bar_width, bar_top + (1.0 - t0) * bar_height),
)
};
backend.set_element_class(Some("gg-legend gg-legend-key gg-colourbar"));
backend.draw_rect(
p0,
p1,
&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 bar.frame.unwrap_or(border.visible) {
backend.set_element_class(Some("gg-legend gg-colourbar-frame"));
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 = theme.legend_ticks_length;
let tick_family = if theme.legend_text.family.is_empty() {
None
} else {
Some(theme.legend_text.family.clone())
};
let labels = step_labels.unwrap_or(breaks);
for (pos, label) in &labels {
if is_horizontal {
let tick_x = legend_x + bar_width * pos;
if bar.ticks {
backend.set_element_class(Some("gg-legend gg-legend-tick"));
backend.draw_line(
&[
(tick_x, bar_top + bar_height),
(tick_x, bar_top + bar_height + tick_len),
],
&border_style,
)?;
}
backend.set_element_class(Some("gg-legend gg-legend-text"));
crate::render::rich::draw(
backend,
theme.rich_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);
if bar.ticks {
backend.set_element_class(Some("gg-legend gg-legend-tick"));
backend.draw_line(
&[
(legend_x + bar_width, tick_y),
(legend_x + bar_width + tick_len, tick_y),
],
&border_style,
)?;
}
backend.set_element_class(Some("gg-legend gg-legend-text"));
crate::render::rich::draw(
backend,
theme.rich_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(),
&bar,
))
} 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(())
}
#[allow(clippy::too_many_arguments)]
fn draw_group_keys(
backend: &mut dyn DrawBackend,
scales: &ScaleSet,
group: &[(Aesthetic, &dyn crate::scale::Scale)],
theme: &Theme,
guide: &GuideLegend,
label: &str,
pos: f64,
(x, y, center_x, center_y): (f64, f64, f64, f64),
swatch_size: f64,
) -> Result<(), RenderError> {
use crate::guide::key::{draw_key, KeyProps};
let in_group = |a: &Aesthetic| group.iter().any(|(g, _)| g == a);
let layers: Vec<_> = guide
.layer_keys
.iter()
.filter(|k| k.aesthetics.iter().any(in_group))
.collect();
let legacy = |backend: &mut dyn DrawBackend, only: Option<&[Aesthetic]>| {
for (member_aes, member_scale) in group {
if only.is_some_and(|o| !o.contains(member_aes)) {
continue;
}
draw_legend_swatch(
backend,
scales,
member_aes,
*member_scale,
theme,
label,
pos,
x,
y,
center_x,
center_y,
swatch_size,
)?;
}
Ok::<(), RenderError>(())
};
if layers.iter().all(|k| k.glyph.is_none()) {
return legacy(backend, None);
}
let value = Value::Str(label.to_string());
let ink = theme.geom.ink.unwrap_or(theme.legend_text.color);
for k in layers {
let Some(glyph) = k.glyph else {
legacy(backend, Some(&k.aesthetics))?;
continue;
};
let mut props = KeyProps::default();
for (aes, scale) in group {
if !k.aesthetics.contains(aes) {
continue;
}
match aes {
Aesthetic::Color => props.color = scales.map_color(aes, &value),
Aesthetic::Fill => props.fill = scales.map_color(aes, &value),
Aesthetic::Shape => props.shape = scales.map_shape(&value),
Aesthetic::Linetype => props.linetype = scales.map_linetype(&value),
Aesthetic::Size => {
props.size = scale
.map_fraction_to_size(pos)
.or_else(|| scales.map_size(&value))
}
Aesthetic::Alpha => {
props.alpha = scale
.map_fraction_to_alpha(pos)
.or_else(|| scales.map_alpha(&value))
}
Aesthetic::Linewidth => props.linewidth = scale.map_fraction_to_size(pos),
_ => {}
}
}
for (aes, _) in group {
if let Some((_, o)) = guide.overrides.iter().find(|(a, _)| a == aes) {
props.color = o.color.or(props.color);
props.fill = o.fill.or(props.fill);
props.shape = o.shape.or(props.shape);
props.linetype = o.linetype.clone().or(props.linetype);
props.size = o.size.or(props.size);
props.alpha = o.alpha.or(props.alpha);
props.linewidth = o.linewidth.or(props.linewidth);
}
}
draw_key(backend, glyph, &props, ink, (x, y, swatch_size))?;
}
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 gap = theme.legend_key_spacing_x;
let legacy = swatch
+ spacing
+ (label.chars().count() as f64) * text_size * 0.62
+ gap.unwrap_or(spacing * 3.0);
let measured =
swatch + spacing + crate::render::text::text_width(label, text_size) + gap.unwrap_or(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;
}
}
pub(crate) fn is_colorbar_aesthetic(aes: &Aesthetic) -> bool {
matches!(aes, Aesthetic::Color | Aesthetic::Fill)
}
fn wrap_guide_count(scales: &ScaleSet, legend_scales: &[Aesthetic]) -> usize {
legend_scales
.iter()
.filter(|aes| {
let is_discrete = scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false);
is_discrete || !is_colorbar_aesthetic(aes)
})
.count()
.max(1)
}
fn colorbar_guide_width(theme: &Theme, bar: &GuideColourbar) -> f64 {
let (bar_len, _) = continuous_bar_dims(theme, true, bar);
bar_len + theme.legend_spacing * 2.0
}
fn colorbar_guides_width(
scales: &ScaleSet,
legend_scales: &[Aesthetic],
theme: &Theme,
guide: &GuideLegend,
) -> f64 {
legend_scales
.iter()
.filter(|aes| {
is_colorbar_aesthetic(aes) && !scales.get(aes).map(|s| s.is_discrete()).unwrap_or(false)
})
.map(|aes| colorbar_guide_width(theme, &guide.colourbar_for(aes)))
.sum()
}
#[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_wrap = wrap_guide_count(scales, &legend_scales);
let per_guide_width = if is_horizontal {
let colorbar_width = colorbar_guides_width(scales, &legend_scales, theme, guide);
((avail_width - colorbar_width) / n_wrap 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() && is_colorbar_aesthetic(aes) {
continue;
}
if guide.bins_for(aes, scale).is_some() {
continue;
}
let breaks = scale.breaks();
let validated_guide = guide.validated(breaks.len(), warnings);
let layout_breaks = scale.breaks_for_layout();
if let Some(plan) = plan_legend(
&layout_breaks,
&validated_guide,
theme,
per_guide_width,
avail_height,
is_horizontal,
) {
plans.insert(aes.clone(), plan);
}
}
plans
}
pub(crate) fn inside_legend_box(
scales: &ScaleSet,
theme: &Theme,
plot_area: &Rect,
canvas: &Rect,
guide: &GuideLegend,
suppressed: &std::collections::HashSet<Aesthetic>,
legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
) -> Option<Rect> {
if !matches!(theme.legend_position, LegendPosition::Inside(..)) {
return None;
}
let legend_scales = legend_scales_for(scales, suppressed);
if legend_scales.is_empty() {
return None;
}
let groups = group_mergeable_legends(scales, &legend_scales, guide);
let representatives: Vec<Aesthetic> = groups.iter().map(|g| g[0].clone()).collect();
let legend_size_hint =
estimate_legend_box(scales, &representatives, theme, guide, legend_plans);
let lift = inside_title_lift(scales, &representatives, theme, guide);
let (legend_x, legend_y, _) =
legend_position(theme, plot_area, 0, legend_size_hint, lift, canvas);
Some(Rect {
x: legend_x,
y: legend_y - lift,
width: legend_size_hint.0,
height: legend_size_hint.1 + lift,
})
}
pub(crate) fn extra_legend_sides(
scales: &ScaleSet,
suppressed: &std::collections::HashSet<Aesthetic>,
guide: &GuideLegend,
main: &LegendPosition,
) -> [bool; 4] {
let mut sides = [false; 4];
if guide.positions.is_empty() || matches!(main, LegendPosition::None) {
return sides;
}
for aes in legend_scales_for(scales, suppressed) {
match guide.position_for(&aes, main) {
p if p == main => {}
LegendPosition::Right => sides[0] = true,
LegendPosition::Left => sides[1] = true,
LegendPosition::Top => sides[2] = true,
LegendPosition::Bottom => sides[3] = true,
LegendPosition::None | LegendPosition::Inside(..) => {}
}
}
sides
}
#[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> {
let main = &theme.legend_position;
let drawn = legend_scales_for(scales, suppressed);
let mut others: Vec<LegendPosition> = Vec::new();
if !matches!(main, LegendPosition::None) {
for aes in &drawn {
let p = guide.position_for(aes, main);
if p != main && !matches!(p, LegendPosition::None) && !others.contains(p) {
others.push(p.clone());
}
}
}
if others.is_empty() {
return draw_legend_side(
scales,
theme,
plot_area,
legend_avail_width,
backend,
guide,
suppressed,
legend_plans,
);
}
let without = |at: &LegendPosition| -> std::collections::HashSet<Aesthetic> {
let mut s = suppressed.clone();
s.extend(
drawn
.iter()
.filter(|a| guide.position_for(a, main) != at)
.cloned(),
);
s
};
draw_legend_side(
scales,
theme,
plot_area,
legend_avail_width,
backend,
guide,
&without(main),
legend_plans,
)?;
let moved_guide = GuideLegend {
custom: Vec::new(),
..guide.clone()
};
for at in &others {
let side_theme = Theme {
legend_position: at.clone(),
..theme.clone()
};
draw_legend_side(
scales,
&side_theme,
plot_area,
legend_avail_width,
backend,
&moved_guide,
&without(at),
legend_plans,
)?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn draw_legend_side(
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() && guide.custom.is_empty() {
return Ok(());
}
backend.set_clipping(false);
let drawn = draw_legend_planned_inner(
scales,
theme,
plot_area,
legend_avail_width,
backend,
guide,
legend_plans,
legend_scales,
);
backend.set_clipping(true);
drawn
}
#[allow(clippy::too_many_arguments)]
fn draw_legend_planned_inner(
scales: &ScaleSet,
theme: &Theme,
plot_area: &Rect,
legend_avail_width: f64,
backend: &mut dyn DrawBackend,
guide: &GuideLegend,
legend_plans: &std::collections::HashMap<Aesthetic, LegendPlan>,
legend_scales: Vec<Aesthetic>,
) -> Result<(), RenderError> {
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,
inside_title_lift(scales, &representatives, theme, guide),
&canvas,
);
if let Some(dir) = theme.legend_direction {
is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
}
let n_wrap = wrap_guide_count(scales, &representatives);
let colorbar_width = colorbar_guides_width(scales, &representatives, theme, guide);
let per_guide_width = ((legend_avail_width - colorbar_width) / n_wrap as f64).max(0.0);
let mut offset_y = legend_y;
let mut offset_x = legend_x;
let just = theme.legend_box_just.clamp(0.0, 1.0);
let cross: Vec<f64> = if just > 0.0 {
representatives
.iter()
.map(|aes| {
if is_horizontal {
horizontal_guide_height(scales, aes, theme, guide, legend_plans)
} else {
estimate_guide_size(scales, aes, theme, guide, legend_plans)
.map_or(0.0, |(w, _)| w)
}
})
.collect()
} else {
vec![0.0; representatives.len()]
};
let cross_max = cross.iter().copied().fold(0.0_f64, f64::max);
for (gi, group) in groups.iter().enumerate() {
let shift = (cross_max - cross[gi]) * just;
let (draw_x, draw_y) = if is_horizontal {
(offset_x, offset_y + shift)
} else {
(offset_x + shift, offset_y)
};
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() && is_colorbar_aesthetic(representative) {
let height = draw_continuous_legend_at(
rep_scale,
theme,
draw_x,
draw_y,
backend,
guide,
is_horizontal,
)?;
if is_horizontal {
let (bar_len, _) =
continuous_bar_dims(theme, true, &guide.colourbar_for(representative));
offset_x += bar_len + 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,
draw_x,
draw_y,
backend,
guide,
true,
)?;
offset_x += width + theme.legend_spacing * 2.0;
} else {
let height = draw_discrete_legend_at(
scales,
&scale_group,
theme,
draw_x,
draw_y,
backend,
guide,
false,
)?;
offset_y += height + theme.legend_spacing * 2.0;
}
}
Some(plan) => {
let height = draw_discrete_legend_grid(
scales,
&scale_group,
theme,
draw_x,
draw_y,
backend,
guide,
plan,
)?;
if is_horizontal {
offset_x += per_guide_width;
} else {
offset_y += height + theme.legend_spacing * 2.0;
}
}
}
}
let family = (!theme.legend_title.family.is_empty()).then(|| theme.legend_title.family.clone());
for custom in &guide.custom {
let title_offset = custom.title_offset(theme);
if let Some(title) = custom.title.as_deref().filter(|t| !t.is_empty()) {
backend.set_element_class(Some("gg-legend gg-legend-title"));
crate::render::rich::draw(
backend,
theme.rich_text,
title,
(offset_x, offset_y),
&TextStyle {
color: theme.legend_title.color,
size: theme.legend_title.size,
anchor: TextAnchor::Start,
angle: 0.0,
family: family.clone(),
face: theme.legend_title.face,
bg_colour: None,
bg_r: 0.0,
},
)?;
}
let rect = Rect {
x: offset_x,
y: offset_y + title_offset,
width: custom.width,
height: custom.height,
};
backend.set_element_class(Some("gg-legend gg-legend-custom"));
(custom.draw)(backend, &rect)?;
if is_horizontal {
offset_x += custom.width + theme.legend_spacing * 2.0;
} else {
offset_y += title_offset + custom.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.is_reversed(&group[0].0) {
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 gap_y = theme.legend_key_gap_y();
let row_pitch = item_height + gap_y;
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.set_element_class(Some("gg-legend gg-legend-title"));
crate::render::rich::draw(
backend,
theme.rich_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 * row_pitch;
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.set_element_class(Some("gg-legend gg-legend-key"));
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,
},
)?;
}
}
backend.set_element_class(Some("gg-legend gg-legend-key"));
draw_group_keys(
backend,
scales,
group,
theme,
guide,
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.set_element_class(Some("gg-legend gg-legend-text"));
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 * row_pitch + swatch_size / 2.0;
let label_family = if theme.legend_text.family.is_empty() {
None
} else {
Some(theme.legend_text.family.clone())
};
backend.set_element_class(Some("gg-legend gg-legend-text"));
crate::render::rich::draw(
backend,
theme.rich_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 * row_pitch - gap_y)
}
#[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, &GuideColourbar::default());
assert_eq!(w, theme.legend_key_width);
assert_eq!(h, theme.legend_key_height * 8.0);
let (w, h) = continuous_bar_dims(&theme, true, &GuideColourbar::default());
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, &GuideColourbar::default());
let with_title = continuous_horizontal_bar_height(&theme, true, &GuideColourbar::default());
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, &GuideColourbar::default());
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
);
}
}
#[cfg(test)]
mod inside_legend_box_tests {
use super::*;
use crate::scale::color::RGBAColor;
use crate::scale::manual::ScaleManual;
fn scales_with_a_legend() -> ScaleSet {
let mut scales = ScaleSet::new();
scales.add(Box::new(ScaleManual::new(
Aesthetic::Color,
vec![
("a", RGBAColor::new(200, 0, 0)),
("b", RGBAColor::new(0, 0, 200)),
],
)));
scales
}
fn areas() -> (Rect, Rect) {
let plot_area = Rect {
x: 10.0,
y: 10.0,
width: 200.0,
height: 150.0,
};
let canvas = Rect {
x: 0.0,
y: 0.0,
width: 220.0,
height: 170.0,
};
(plot_area, canvas)
}
#[test]
fn non_inside_positions_claim_nothing() {
let scales = scales_with_a_legend();
let (plot_area, canvas) = areas();
let guide = GuideLegend::default();
let suppressed = std::collections::HashSet::new();
let legend_plans = std::collections::HashMap::new();
for pos in [
LegendPosition::Right,
LegendPosition::Left,
LegendPosition::Top,
LegendPosition::Bottom,
LegendPosition::None,
] {
let theme = Theme {
legend_position: pos,
..Theme::default()
};
assert!(inside_legend_box(
&scales,
&theme,
&plot_area,
&canvas,
&guide,
&suppressed,
&legend_plans
)
.is_none());
}
}
#[test]
fn no_legend_scales_claims_nothing_even_when_inside() {
let scales = ScaleSet::new(); let (plot_area, canvas) = areas();
let theme = Theme {
legend_position: LegendPosition::Inside(0.85, 0.85),
..Theme::default()
};
let guide = GuideLegend::default();
let suppressed = std::collections::HashSet::new();
let legend_plans = std::collections::HashMap::new();
assert!(inside_legend_box(
&scales,
&theme,
&plot_area,
&canvas,
&guide,
&suppressed,
&legend_plans
)
.is_none());
}
#[test]
fn inside_position_with_a_legend_claims_a_box_within_the_panel() {
let scales = scales_with_a_legend();
let (plot_area, canvas) = areas();
let theme = Theme {
legend_position: LegendPosition::Inside(0.85, 0.85),
..Theme::default()
};
let guide = GuideLegend::default();
let suppressed = std::collections::HashSet::new();
let legend_plans = std::collections::HashMap::new();
let rect = inside_legend_box(
&scales,
&theme,
&plot_area,
&canvas,
&guide,
&suppressed,
&legend_plans,
)
.expect("an Inside legend with a mapped scale claims a box");
assert!(rect.width > 0.0 && rect.height > 0.0);
assert!(rect.x >= canvas.x - 1e-6 && rect.x + rect.width <= canvas.x + canvas.width + 1e-6);
assert!(
rect.y >= canvas.y - 1e-6 && rect.y + rect.height <= canvas.y + canvas.height + 1e-6
);
}
#[test]
fn matches_the_position_draw_legend_planned_itself_uses() {
let scales = scales_with_a_legend();
let (plot_area, canvas) = areas();
let theme = Theme {
legend_position: LegendPosition::Inside(0.85, 0.85),
..Theme::default()
};
let guide = GuideLegend::default();
let suppressed = std::collections::HashSet::new();
let legend_plans = std::collections::HashMap::new();
let rect = inside_legend_box(
&scales,
&theme,
&plot_area,
&canvas,
&guide,
&suppressed,
&legend_plans,
)
.unwrap();
let legend_scales = legend_scales_for(&scales, &suppressed);
let groups = group_mergeable_legends(&scales, &legend_scales, &guide);
let representatives: Vec<Aesthetic> = groups.iter().map(|g| g[0].clone()).collect();
let size_hint =
estimate_legend_box(&scales, &representatives, &theme, &guide, &legend_plans);
let lift = inside_title_lift(&scales, &representatives, &theme, &guide);
let (x, y, _) = legend_position(&theme, &plot_area, 0, size_hint, lift, &canvas);
assert_eq!(
(rect.x, rect.y, rect.width, rect.height),
(x, y - lift, size_hint.0, size_hint.1 + lift)
);
}
}