mod colorbar;
mod layout;
mod measure;
mod render_keys;
mod spec;
pub use render_keys::DEFAULT_KEY_TEXT;
pub use spec::{
collapse_legends, AestheticSource, BinSpacing, ColorbarSpec, EndpointMarkerKey, Legend,
LegendBody, LegendId, LegendKey, LegendKeySpec, ResolvedKey, StackBody,
};
use colorbar::{render_binned_stack_body, render_colorbar_body};
use layout::translate_rect;
use measure::{BodyMeasure, LegendMeasure, LegendStackMeasure};
use render_keys::render_key;
use spec::resolve_key;
use crate::brush::Brush;
use crate::geometry::Shape as _;
use crate::geometry::{Affine, Point, Rect};
use crate::layout::Measure;
use crate::pick::PickId;
use crate::plot::chrome::linear_axis::{draw_axis_label, pt_to_px, AxisLabelAt};
use crate::plot::scale::ScaleRegistry;
use crate::plot::theme::HAlign;
use crate::plot::theme::{
DEFAULT_LEGEND_GAP_PT as PANEL_LEGEND_GAP_PT, DEFAULT_LEGEND_SPACING_PT as LEGEND_GAP_PT,
};
use crate::scales::breaks::DEFAULT_BREAK_COUNT;
use crate::scales::chrome::{Anchor, LegendSide};
use crate::scales::value::Value;
use crate::scene::SceneBuilder;
use crate::shape::ShapeRegistry;
use crate::text::{draw_text, TextRun, TextStyle};
pub(super) fn cardinal_side(side: LegendSide) -> LegendSide {
match side {
LegendSide::Left => LegendSide::Left,
LegendSide::Right => LegendSide::Right,
LegendSide::Top => LegendSide::Top,
LegendSide::Bottom => LegendSide::Bottom,
LegendSide::InPanel { .. } => LegendSide::Right,
}
}
fn legend_gap_px(theme: &crate::plot::theme::Theme, dpi: f64) -> f64 {
pt_to_px(theme.legend_gap.resolve(PANEL_LEGEND_GAP_PT), dpi)
}
fn inset_for_panel_gap(slot_rect: Rect, side: LegendSide, gap_px: f64) -> Rect {
match side {
LegendSide::Right => Rect::new(
slot_rect.x0 + gap_px,
slot_rect.y0,
slot_rect.x1,
slot_rect.y1,
),
LegendSide::Left => Rect::new(
slot_rect.x0,
slot_rect.y0,
slot_rect.x1 - gap_px,
slot_rect.y1,
),
LegendSide::Bottom => Rect::new(
slot_rect.x0,
slot_rect.y0 + gap_px,
slot_rect.x1,
slot_rect.y1,
),
LegendSide::Top => Rect::new(
slot_rect.x0,
slot_rect.y0,
slot_rect.x1,
slot_rect.y1 - gap_px,
),
LegendSide::InPanel { .. } => slot_rect,
}
}
pub fn resolve_anchor(panel: Rect, anchor: Anchor, inset_px: f64, size: (f64, f64)) -> Rect {
let (w, h) = size;
let (x0, y0) = match anchor {
Anchor::TopLeft => (panel.x0 + inset_px, panel.y0 + inset_px),
Anchor::TopCenter => (
panel.x0 + (panel.x1 - panel.x0 - w) * 0.5,
panel.y0 + inset_px,
),
Anchor::TopRight => (panel.x1 - w - inset_px, panel.y0 + inset_px),
Anchor::CenterLeft => (
panel.x0 + inset_px,
panel.y0 + (panel.y1 - panel.y0 - h) * 0.5,
),
Anchor::Center => (
panel.x0 + (panel.x1 - panel.x0 - w) * 0.5,
panel.y0 + (panel.y1 - panel.y0 - h) * 0.5,
),
Anchor::CenterRight => (
panel.x1 - w - inset_px,
panel.y0 + (panel.y1 - panel.y0 - h) * 0.5,
),
Anchor::BottomLeft => (panel.x0 + inset_px, panel.y1 - h - inset_px),
Anchor::BottomCenter => (
panel.x0 + (panel.x1 - panel.x0 - w) * 0.5,
panel.y1 - h - inset_px,
),
Anchor::BottomRight => (panel.x1 - w - inset_px, panel.y1 - h - inset_px),
};
Rect::new(x0, y0, x0 + w, y0 + h)
}
pub fn legend_stack_natural_size(
legends: &[&Legend],
registry: &ScaleRegistry,
shapes: &ShapeRegistry,
dpi: f64,
theme: &crate::plot::theme::Theme,
) -> (f64, f64) {
let gap_px = legend_gap_px(theme, dpi);
let measures: Vec<LegendMeasure> = legends
.iter()
.map(|l| {
LegendMeasure::new(
l,
registry,
shapes,
dpi,
theme.legend_for(l.theme_variant.as_deref()),
&theme.geom,
gap_px,
&theme.locale,
crate::plot::chrome::root_text_pt(theme),
)
})
.filter(|m| !m.is_empty())
.collect();
if measures.is_empty() {
return (0.0, 0.0);
}
let gap_px = pt_to_px(theme.legend_spacing.resolve(LEGEND_GAP_PT), dpi);
let primary = measures
.iter()
.map(|m| m.primary_dim_px(dpi))
.fold(0.0_f64, f64::max);
let cross: f64 = measures.iter().map(|m| m.cross_dim_px(dpi)).sum::<f64>()
+ gap_px * (measures.len() as f64 - 1.0).max(0.0);
(primary, cross)
}
pub fn legend_measure(
legend: &Legend,
registry: &ScaleRegistry,
shapes: &ShapeRegistry,
dpi: f64,
theme: &crate::plot::theme::Theme,
) -> Box<dyn Measure> {
Box::new(LegendMeasure::new(
legend,
registry,
shapes,
dpi,
theme.legend_for(legend.theme_variant.as_deref()),
&theme.geom,
legend_gap_px(theme, dpi),
&theme.locale,
crate::plot::chrome::root_text_pt(theme),
))
}
pub fn legend_stack_measure(
legends: &[&Legend],
side: LegendSide,
registry: &ScaleRegistry,
shapes: &ShapeRegistry,
dpi: f64,
theme: &crate::plot::theme::Theme,
) -> Box<dyn Measure> {
let inter_gap_px = pt_to_px(theme.legend_spacing.resolve(LEGEND_GAP_PT), dpi);
let panel_gap_px = legend_gap_px(theme, dpi);
let children: Vec<LegendMeasure> = legends
.iter()
.map(|l| {
LegendMeasure::new(
l,
registry,
shapes,
dpi,
theme.legend_for(l.theme_variant.as_deref()),
&theme.geom,
panel_gap_px,
&theme.locale,
crate::plot::chrome::root_text_pt(theme),
)
})
.collect();
Box::new(LegendStackMeasure {
side: cardinal_side(side),
children,
gap_px: inter_gap_px,
})
}
#[allow(clippy::too_many_arguments)]
pub fn render_legend_stack(
legends: &[&Legend],
side: LegendSide,
slot_rect: Rect,
registry: &ScaleRegistry,
shapes: &ShapeRegistry,
scene: &mut dyn SceneBuilder,
dpi: f64,
theme: &crate::plot::theme::Theme,
) {
let inter_gap_px = pt_to_px(theme.legend_spacing.resolve(LEGEND_GAP_PT), dpi);
let panel_gap_px = legend_gap_px(theme, dpi);
let measures: Vec<(usize, LegendMeasure)> = legends
.iter()
.enumerate()
.map(|(i, l)| {
(
i,
LegendMeasure::new(
l,
registry,
shapes,
dpi,
theme.legend_for(l.theme_variant.as_deref()),
&theme.geom,
panel_gap_px,
&theme.locale,
crate::plot::chrome::root_text_pt(theme),
),
)
})
.filter(|(_, m)| !m.is_empty())
.collect();
if measures.is_empty() {
return;
}
let stack_axis_is_y = matches!(side, LegendSide::Right | LegendSide::Left);
let mut cursor = if stack_axis_is_y {
slot_rect.y0
} else {
slot_rect.x0
};
for (orig_idx, measure) in &measures {
let cross = measure.cross_dim_px(dpi);
let sub_rect = if stack_axis_is_y {
Rect::new(slot_rect.x0, cursor, slot_rect.x1, cursor + cross)
} else {
Rect::new(cursor, slot_rect.y0, cursor + cross, slot_rect.y1)
};
render_legend_with_measure(
legends[*orig_idx],
measure,
registry,
shapes,
sub_rect,
scene,
dpi,
theme,
);
cursor += cross + inter_gap_px;
}
}
#[allow(clippy::too_many_arguments)]
pub fn render_legend(
legend: &Legend,
registry: &ScaleRegistry,
shapes: &ShapeRegistry,
slot_rect: Rect,
scene: &mut dyn SceneBuilder,
dpi: f64,
theme: &crate::plot::theme::Theme,
) {
let lt = theme.legend_for(legend.theme_variant.as_deref());
let measure = LegendMeasure::new(
legend,
registry,
shapes,
dpi,
lt,
&theme.geom,
legend_gap_px(theme, dpi),
&theme.locale,
crate::plot::chrome::root_text_pt(theme),
);
render_legend_with_measure(
legend, &measure, registry, shapes, slot_rect, scene, dpi, theme,
);
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn render_legend_with_measure(
legend: &Legend,
measure: &LegendMeasure,
registry: &ScaleRegistry,
shapes: &ShapeRegistry,
slot_rect: Rect,
scene: &mut dyn SceneBuilder,
dpi: f64,
theme: &crate::plot::theme::Theme,
) {
let lt = theme.legend_for(legend.theme_variant.as_deref());
let gap_px = legend_gap_px(theme, dpi);
if measure.is_empty() {
return;
}
let draw_rect = inset_for_panel_gap(slot_rect, legend.side, gap_px);
paint_legend_background(scene, lt, &theme.palette, draw_rect, dpi);
match &legend.body {
LegendBody::Stack(stack) if stack.binned => render_binned_stack_body(
legend,
&stack.keys,
measure,
registry,
shapes,
draw_rect,
scene,
dpi,
lt,
&theme.palette,
&theme.geom,
&theme.locale,
crate::plot::chrome::root_text_pt(theme),
),
LegendBody::Stack(stack) => render_stack_body(
legend,
&stack.keys,
measure,
registry,
shapes,
draw_rect,
scene,
dpi,
lt,
&theme.palette,
&theme.geom,
&theme.locale,
crate::plot::chrome::root_text_pt(theme),
),
LegendBody::Colorbar(spec) => render_colorbar_body(
legend,
spec,
measure,
registry,
draw_rect,
scene,
dpi,
lt,
&theme.palette,
&theme.geom,
&theme.locale,
crate::plot::chrome::root_text_pt(theme),
),
}
}
pub(super) struct LegendTextPaint {
pub(super) style: TextStyle,
pub(super) brush: Brush,
pub(super) outline: Option<crate::plot::chrome::text::TextOutline>,
}
pub(super) struct LegendTextStyles {
pub(super) title: Option<LegendTextPaint>,
pub(super) label: Option<LegendTextPaint>,
}
pub(super) fn legend_text_elements(
lt: &crate::plot::theme::LegendTheme,
) -> (
Option<crate::plot::theme::TextElement>,
Option<crate::plot::theme::TextElement>,
) {
use crate::plot::theme::{axis_concrete_defaults, text_concrete_defaults, Element};
let text_defaults = text_concrete_defaults();
let axis_defaults = axis_concrete_defaults();
let axis_title = axis_defaults
.title
.as_set()
.expect("axis_concrete_defaults sets title")
.cascade(&text_defaults);
let title = match <.title {
Element::Set(child) => Some(child.cascade(&axis_title)),
Element::Blank => None,
Element::Inherit => Some(axis_title),
};
let label = lt.axis.resolved().text.map(|el| el.cascade(&text_defaults));
(title, label)
}
pub(super) fn legend_text_styles(
lt: &crate::plot::theme::LegendTheme,
palette: &crate::plot::theme::Palette,
dpi: f64,
root_pt: f64,
) -> LegendTextStyles {
let paint = |merged: crate::plot::theme::TextElement| {
let color = merged
.color
.as_ref()
.expect("text color default")
.resolve(palette);
LegendTextPaint {
style: crate::plot::chrome::text::text_style_from(&merged, root_pt),
brush: Brush::Solid(color),
outline: crate::plot::chrome::text::text_outline_from(&merged, palette, dpi),
}
};
let (title, label) = legend_text_elements(lt);
LegendTextStyles {
title: title.map(paint),
label: label.map(paint),
}
}
pub(super) fn paint_rect_frame(
scene: &mut dyn SceneBuilder,
frame: &crate::plot::theme::RectElement,
palette: &crate::plot::theme::Palette,
rect: Rect,
dpi: f64,
paint_fill: bool,
paint_stroke: bool,
) {
use crate::plot::theme::rect_concrete_defaults;
let defaults = rect_concrete_defaults();
let path = path_for_rect_element(rect, frame, &defaults, dpi);
if paint_fill {
if let Some(fill) = frame.fill.clone() {
let brush = Brush::Solid(fill.resolve(palette));
scene.fill(
crate::path::FillRule::NonZero,
Affine::IDENTITY,
&brush,
None,
&path,
PickId::Skip,
);
}
}
if paint_stroke {
let lw = frame
.linewidth_pt
.or(defaults.linewidth_pt)
.expect("rect linewidth default");
if lw.resolve(1.0) > 0.0 {
let stroke = crate::plot::chrome::linear_axis::stroke_from_rect_border(frame, dpi);
let color = frame
.color
.clone()
.or(defaults.color)
.expect("rect color default");
let brush = Brush::Solid(color.resolve(palette));
scene.stroke(&stroke, Affine::IDENTITY, &brush, None, &path, PickId::Skip);
}
}
}
fn path_for_rect_element(
rect: Rect,
el: &crate::plot::theme::RectElement,
defaults: &crate::plot::theme::RectElement,
dpi: f64,
) -> crate::path::Path {
let radius_pt = el
.corner_radius
.or(defaults.corner_radius)
.map(|l| l.resolve(0.0))
.unwrap_or(0.0);
let radius_px = (radius_pt * dpi / 72.0).max(0.0);
if radius_px > 0.0 {
crate::primitives::rounded_rect(rect, radius_px)
} else {
rect.to_path(0.0)
}
}
fn paint_legend_background(
scene: &mut dyn SceneBuilder,
lt: &crate::plot::theme::LegendTheme,
palette: &crate::plot::theme::Palette,
slot_rect: Rect,
dpi: f64,
) {
let Some(bg) = lt.background.as_set() else {
return;
};
paint_rect_frame(scene, bg, palette, slot_rect, dpi, true, true);
}
#[allow(clippy::too_many_arguments)]
fn render_stack_body(
legend: &Legend,
keys: &[LegendKeySpec],
measure: &LegendMeasure,
registry: &ScaleRegistry,
shapes: &ShapeRegistry,
slot_rect: Rect,
scene: &mut dyn SceneBuilder,
dpi: f64,
lt: &crate::plot::theme::LegendTheme,
palette: &crate::plot::theme::Palette,
geom: &crate::plot::theme::GeomTheme,
locale: &crate::scales::Locale,
root_pt: f64,
) {
let side = cardinal_side(legend.side);
let domain = match registry.get(&legend.domain_scale) {
Some(s) => s,
None => return,
};
let layout = match &measure.body {
BodyMeasure::Stack { layout, .. } => layout,
_ => return,
};
let padding = measure.padding_px;
let title_gap = if legend.title.is_some() && measure.title_h_px > 0.0 {
measure.row_gap_px
} else {
0.0
};
let styles = legend_text_styles(lt, palette, dpi, root_pt);
let entries = domain.breaks(DEFAULT_BREAK_COUNT);
let entries: Vec<&Value> = entries
.iter()
.filter(|v| !matches!(v, Value::Null))
.collect();
let block_w = layout.entries_w_px.max(measure.title_w_px);
let block_h = measure.title_h_px + title_gap + layout.entries_h_px;
let title_x = match side {
LegendSide::Left => slot_rect.x1 - padding - block_w,
_ => slot_rect.x0 + padding,
};
let title_y = match side {
LegendSide::Top => slot_rect.y1 - padding - block_h,
_ => slot_rect.y0 + padding,
};
let entries_x = title_x;
let entries_y = title_y + measure.title_h_px + title_gap;
if let (Some(title), Some(paint)) = (&legend.title, &styles.title) {
let run = TextRun::new(title, &paint.style, dpi);
let _ = run.set_max_width(f32::INFINITY, HAlign::Start);
crate::plot::chrome::text::draw_text_outline_pass(
scene,
paint.outline.as_ref(),
&run,
title_x,
title_y,
Affine::IDENTITY,
);
draw_text(
scene,
&run,
title_x,
title_y,
&paint.brush,
Affine::IDENTITY,
PickId::Skip,
);
}
let key_frame = lt.key.frame.as_set();
for (idx, v) in entries.iter().enumerate() {
if idx >= layout.entries.len() {
break;
}
let (swatch_local, label_local) = &layout.entries[idx];
let swatch_rect = translate_rect(*swatch_local, entries_x, entries_y);
let label_rect = translate_rect(*label_local, entries_x, entries_y);
if let Some(frame_el) = key_frame {
paint_rect_frame(scene, frame_el, palette, swatch_rect, dpi, true, false);
}
for key in keys {
let resolved = resolve_key(key, registry, v);
render_key(
key.kind,
&resolved,
swatch_rect,
shapes,
scene,
dpi,
geom,
palette,
);
}
if let Some(frame_el) = key_frame {
paint_rect_frame(scene, frame_el, palette, swatch_rect, dpi, false, true);
}
if let Some(paint) = &styles.label {
let label = domain.format(v, locale);
let anchor = Point::new(label_rect.x0, (label_rect.y0 + label_rect.y1) * 0.5);
draw_axis_label(
scene,
&label,
&paint.style,
&paint.brush,
paint.outline.as_ref(),
AxisLabelAt {
anchor,
direction: (1.0, 0.0),
},
dpi,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color::rgb;
use crate::plot::scale;
use crate::plot::theme::Theme;
use crate::scene::recording::{Op, RecordingScene};
use std::sync::Arc;
fn dpi_96() -> f64 {
96.0
}
fn default_theme() -> Theme {
let mut t = Theme::default();
t.legend.key.frame = crate::plot::theme::Element::Blank;
t
}
fn shape_reg() -> ShapeRegistry {
ShapeRegistry::with_builtins()
}
fn build_registry() -> ScaleRegistry {
let mut reg = ScaleRegistry::new();
reg.insert(
"category_color",
scale::discrete([
Value::String(Arc::from("A")),
Value::String(Arc::from("B")),
Value::String(Arc::from("C")),
])
.range_colors([
rgb(1.0, 0.0, 0.0),
rgb(0.0, 1.0, 0.0),
rgb(0.0, 0.0, 1.0),
]),
);
reg.insert(
"category_size",
scale::discrete([
Value::String(Arc::from("A")),
Value::String(Arc::from("B")),
Value::String(Arc::from("C")),
])
.range_numbers([4.0, 8.0, 12.0]),
);
reg
}
#[test]
fn fixed_stroke_is_applied_alongside_scaled_fill() {
let legend = Legend::new("category_color").key(
LegendKeySpec::point()
.scaled("fill", "category_color")
.fixed("stroke", Value::Color(rgb(0.0, 0.0, 0.0))),
);
let reg = build_registry();
let mut scene = RecordingScene::default();
let shapes = ShapeRegistry::with_builtins();
render_legend(
&legend,
®,
&shapes,
Rect::new(0.0, 0.0, 200.0, 200.0),
&mut scene,
dpi_96(),
&default_theme(),
);
let fills = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Fill { .. }))
.count();
let strokes = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.count();
assert_eq!(fills, 3);
assert_eq!(strokes, 3);
}
#[test]
fn linewidth_scaled_line_keys_do_not_overlap_their_neighbours() {
let mut reg = build_registry();
reg.insert(
"category_linewidth",
scale::discrete([
Value::String(Arc::from("A")),
Value::String(Arc::from("B")),
Value::String(Arc::from("C")),
])
.range_numbers([25.0, 30.0, 35.0]),
);
let legend = Legend::new("category_color")
.key(LegendKeySpec::line().scaled("linewidth", "category_linewidth"));
let mut scene = RecordingScene::default();
let shapes = ShapeRegistry::with_builtins();
render_legend(
&legend,
®,
&shapes,
Rect::new(0.0, 0.0, 300.0, 300.0),
&mut scene,
dpi_96(),
&default_theme(),
);
let mut bands: Vec<(f64, f64)> = scene
.ops
.iter()
.filter_map(|op| match op {
Op::Stroke { stroke, path, .. } => {
let b = path.bounding_box();
Some((b.y0 - stroke.width * 0.5, b.y1 + stroke.width * 0.5))
}
_ => None,
})
.collect();
assert_eq!(bands.len(), 3, "one line key per break");
bands.sort_by(|a, b| a.0.total_cmp(&b.0));
for pair in bands.windows(2) {
assert!(
pair[0].1 <= pair[1].0 + 1e-9,
"key bands overlap: {:?} into {:?}",
pair[0],
pair[1]
);
}
}
#[test]
fn line_plus_point_keys_emit_both() {
let legend = Legend::new("category_color")
.key(LegendKeySpec::line().scaled("stroke", "category_color"))
.key(LegendKeySpec::point().scaled("fill", "category_color"));
let reg = build_registry();
let mut scene = RecordingScene::default();
let shapes = ShapeRegistry::with_builtins();
render_legend(
&legend,
®,
&shapes,
Rect::new(0.0, 0.0, 200.0, 200.0),
&mut scene,
dpi_96(),
&default_theme(),
);
let strokes = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.count();
let fills = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Fill { .. }))
.count();
assert_eq!(strokes, 3, "one line stroke per row");
assert_eq!(fills, 3, "one point fill per row");
}
#[test]
fn resolve_anchor_top_right_six_pt() {
let panel = Rect::new(0.0, 0.0, 100.0, 100.0);
let rect = resolve_anchor(panel, Anchor::TopRight, 6.0, (20.0, 10.0));
assert!((rect.x1 - 94.0).abs() < 1e-12);
assert!((rect.y0 - 6.0).abs() < 1e-12);
assert!((rect.x0 - 74.0).abs() < 1e-12);
assert!((rect.y1 - 16.0).abs() < 1e-12);
}
#[test]
fn resolve_anchor_centre_centres_on_panel() {
let panel = Rect::new(0.0, 0.0, 100.0, 100.0);
let rect = resolve_anchor(panel, Anchor::Center, 6.0, (20.0, 10.0));
let cx = (rect.x0 + rect.x1) * 0.5;
let cy = (rect.y0 + rect.y1) * 0.5;
assert!((cx - 50.0).abs() < 1e-12);
assert!((cy - 50.0).abs() < 1e-12);
}
#[test]
fn resolve_anchor_bottom_left() {
let panel = Rect::new(10.0, 20.0, 110.0, 120.0);
let rect = resolve_anchor(panel, Anchor::BottomLeft, 4.0, (30.0, 12.0));
assert!((rect.x0 - 14.0).abs() < 1e-12);
assert!((rect.y1 - 116.0).abs() < 1e-12);
}
#[test]
fn in_panel_legend_natural_size_is_nonzero_for_populated_stack() {
let legend = Legend::new("category_color")
.side(LegendSide::InPanel {
anchor: Anchor::TopRight,
inset_pt: 6.0,
})
.key(LegendKeySpec::point().scaled("fill", "category_color"));
let reg = build_registry();
let (w, h) =
legend_stack_natural_size(&[&legend], ®, &shape_reg(), dpi_96(), &default_theme());
assert!(w > 0.0);
assert!(h > 0.0);
}
#[test]
fn in_panel_legend_natural_size_zero_for_empty_stack() {
let legend = Legend::new("category_color").side(LegendSide::InPanel {
anchor: Anchor::TopRight,
inset_pt: 6.0,
});
let reg = build_registry();
let (w, h) =
legend_stack_natural_size(&[&legend], ®, &shape_reg(), dpi_96(), &default_theme());
assert_eq!(w, 0.0);
assert_eq!(h, 0.0);
}
}