use crate::brush::Brush;
use crate::color::Color;
use crate::geometry::Shape as _;
use crate::geometry::{Affine, Point, Rect};
use crate::path::{FillRule, Path};
use crate::pick::PickId;
use crate::plot::scale::ScaleRegistry;
use crate::plot::theme::HAlign;
use crate::scales::breaks::DEFAULT_BREAK_COUNT;
use crate::scales::chrome::LegendSide;
use crate::scales::value::Value;
use crate::scene::SceneBuilder;
use crate::shape::ShapeRegistry;
use crate::text::{draw_text, TextRun};
use super::measure::{BodyMeasure, LegendMeasure};
use super::render_keys::{render_key, with_opacity};
use super::spec::{
resolve_key, AestheticSource, BinSpacing, ColorbarSpec, Legend, LegendKeySpec, ResolvedKey,
};
use super::{cardinal_side, legend_text_styles, paint_rect_frame};
fn title_anchor(
side: LegendSide,
slot_rect: Rect,
padding: f64,
block_h: f64,
title_w_px: f64,
anchor_width: f64,
) -> (f64, f64) {
let y = match side {
LegendSide::Top => slot_rect.y1 - block_h + padding,
_ => slot_rect.y0 + padding,
};
let x = match side {
LegendSide::Left => slot_rect.x1 - padding - title_w_px.max(anchor_width),
_ => slot_rect.x0 + padding,
};
(x, y)
}
fn axis_baseline(side: LegendSide, bar_rect: Rect) -> (Point, Point, (f64, f64)) {
match side {
LegendSide::Right => (
Point::new(bar_rect.x1, bar_rect.y1),
Point::new(bar_rect.x1, bar_rect.y0),
(1.0, 0.0),
),
LegendSide::Left => (
Point::new(bar_rect.x0, bar_rect.y1),
Point::new(bar_rect.x0, bar_rect.y0),
(-1.0, 0.0),
),
LegendSide::Top => (
Point::new(bar_rect.x0, bar_rect.y0),
Point::new(bar_rect.x1, bar_rect.y0),
(0.0, -1.0),
),
LegendSide::Bottom => (
Point::new(bar_rect.x0, bar_rect.y1),
Point::new(bar_rect.x1, bar_rect.y1),
(0.0, 1.0),
),
LegendSide::InPanel { .. } => unreachable!("cardinal_side flattens InPanel"),
}
}
pub(super) fn bin_edges(breaks: &[Value]) -> Vec<f64> {
breaks
.iter()
.filter_map(|v| v.as_number().or_else(|| v.as_temporal_f64()))
.filter(|n| n.is_finite())
.collect()
}
pub(super) fn bin_midpoints(edges: &[f64]) -> Vec<Value> {
if edges.len() < 2 {
return Vec::new();
}
let span = edges[edges.len() - 1] - edges[0];
if !span.is_finite() || span.abs() < f64::EPSILON {
return Vec::new();
}
edges
.windows(2)
.map(|w| Value::Number((w[0] + w[1]) * 0.5))
.collect()
}
#[allow(clippy::too_many_arguments)]
pub(super) fn render_binned_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 styles = legend_text_styles(lt, palette, dpi, root_pt);
let domain = match registry.get(&legend.domain_scale) {
Some(s) => s,
None => return,
};
let side = cardinal_side(legend.side);
let row_cells_px: &[(f64, f64)] = match &measure.body {
BodyMeasure::BinnedStack { row_cells_px, .. } => row_cells_px.as_slice(),
_ => return,
};
let breaks = bin_edges(&domain.breaks(DEFAULT_BREAK_COUNT));
let midpoints = bin_midpoints(&breaks);
if midpoints.is_empty() {
return;
}
debug_assert_eq!(
row_cells_px.len(),
midpoints.len(),
"measure and draw must agree on bin count"
);
let (min, max) = (breaks[0], *breaks.last().unwrap());
let span = max - min;
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 block_h = measure.primary_dim_px(dpi);
let n_bins = midpoints.len();
let horizontal = matches!(side, LegendSide::Top | LegendSide::Bottom);
let bar_thickness = if horizontal {
row_cells_px.iter().map(|(_, h)| *h).fold(0.0_f64, f64::max)
} else {
row_cells_px.iter().map(|(w, _)| *w).fold(0.0_f64, f64::max)
};
let bar_len: f64 = if horizontal {
row_cells_px.iter().map(|(w, _)| *w).sum()
} else {
row_cells_px.iter().map(|(_, h)| *h).sum()
};
let (title_x, title_y) = title_anchor(
side,
slot_rect,
padding,
block_h,
measure.title_w_px,
bar_thickness,
);
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 bar_rect = match side {
LegendSide::Right => Rect::new(
slot_rect.x0 + padding,
title_y + measure.title_h_px + title_gap,
slot_rect.x0 + padding + bar_thickness,
title_y + measure.title_h_px + title_gap + bar_len,
),
LegendSide::Left => Rect::new(
slot_rect.x1 - padding - bar_thickness,
title_y + measure.title_h_px + title_gap,
slot_rect.x1 - padding,
title_y + measure.title_h_px + title_gap + bar_len,
),
LegendSide::Top => Rect::new(
slot_rect.x0 + padding,
slot_rect.y1 - padding - bar_thickness,
slot_rect.x0 + padding + bar_len,
slot_rect.y1 - padding,
),
LegendSide::Bottom => Rect::new(
slot_rect.x0 + padding,
title_y + measure.title_h_px + title_gap,
slot_rect.x0 + padding + bar_len,
title_y + measure.title_h_px + title_gap + bar_thickness,
),
LegendSide::InPanel { .. } => unreachable!("cardinal_side flattens InPanel"),
};
let bar_frame = lt.bar.frame.as_set();
if let Some(frame_el) = bar_frame {
paint_rect_frame(scene, frame_el, palette, bar_rect, dpi, true, false);
}
let equal_bins = legend.bin_spacing == BinSpacing::Equal;
for i in 0..n_bins {
let (lo, hi) = (breaks[i], breaks[i + 1]);
let midpoint = &midpoints[i];
let (lo_t, hi_t) = if equal_bins {
(i as f64 / n_bins as f64, (i + 1) as f64 / n_bins as f64)
} else {
((lo - min) / span, (hi - min) / span)
};
let cell = if horizontal {
Rect::new(
bar_rect.x0 + lo_t * (bar_rect.x1 - bar_rect.x0),
bar_rect.y0,
bar_rect.x0 + hi_t * (bar_rect.x1 - bar_rect.x0),
bar_rect.y1,
)
} else {
Rect::new(
bar_rect.x0,
bar_rect.y0 + (1.0 - hi_t) * (bar_rect.y1 - bar_rect.y0),
bar_rect.x1,
bar_rect.y0 + (1.0 - lo_t) * (bar_rect.y1 - bar_rect.y0),
)
};
for key in keys {
let resolved = resolve_key(key, registry, midpoint);
render_key(key.kind, &resolved, cell, shapes, scene, dpi, geom, palette);
}
}
if let Some(frame_el) = bar_frame {
paint_rect_frame(scene, frame_el, palette, bar_rect, dpi, false, true);
}
let (axis_start, axis_end, tick_direction) = axis_baseline(side, bar_rect);
let majors_owned = colorbar_majors(domain, locale);
let majors_owned = if legend.bin_spacing == BinSpacing::Equal {
colorbar_majors_remap_equal(&majors_owned)
} else {
majors_owned
};
let majors = open_end_trim(&majors_owned, legend.open_lower, legend.open_upper);
let style = crate::plot::chrome::linear_axis::AxisChromeStyle::from_resolved(
<.axis.resolved(),
palette,
dpi,
root_pt,
);
crate::plot::chrome::linear_axis::draw_linear_axis_at(
scene,
axis_start,
axis_end,
tick_direction,
majors,
&[],
&style,
dpi,
);
}
#[allow(clippy::too_many_arguments)]
pub(super) fn render_colorbar_body(
legend: &Legend,
spec: &ColorbarSpec,
measure: &LegendMeasure,
registry: &ScaleRegistry,
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 styles = legend_text_styles(lt, palette, dpi, root_pt);
let domain = match registry.get(&legend.domain_scale) {
Some(s) => s,
None => return,
};
let side = cardinal_side(legend.side);
let (bar_thickness_px, samples) = match measure.body {
BodyMeasure::Colorbar {
bar_thickness_px,
samples,
} => (bar_thickness_px, samples),
_ => 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 block_h = measure.primary_dim_px(dpi);
let (title_x, title_y) = title_anchor(
side,
slot_rect,
padding,
block_h,
measure.title_w_px,
bar_thickness_px,
);
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 cross_dim_px = measure.cross_dim_px(dpi);
let bar_rect = match side {
LegendSide::Right => Rect::new(
slot_rect.x0 + padding,
title_y + measure.title_h_px + title_gap,
slot_rect.x0 + padding + bar_thickness_px,
title_y
+ measure.title_h_px
+ title_gap
+ (cross_dim_px - 2.0 * padding - measure.title_h_px - title_gap),
),
LegendSide::Left => Rect::new(
slot_rect.x1 - padding - bar_thickness_px,
title_y + measure.title_h_px + title_gap,
slot_rect.x1 - padding,
title_y
+ measure.title_h_px
+ title_gap
+ (cross_dim_px - 2.0 * padding - measure.title_h_px - title_gap),
),
LegendSide::Top => Rect::new(
slot_rect.x0 + padding,
slot_rect.y1 - padding - bar_thickness_px,
slot_rect.x0 + padding + (cross_dim_px - 2.0 * padding),
slot_rect.y1 - padding,
),
LegendSide::Bottom => Rect::new(
slot_rect.x0 + padding,
title_y + measure.title_h_px + title_gap,
slot_rect.x0 + padding + (cross_dim_px - 2.0 * padding),
title_y + measure.title_h_px + title_gap + bar_thickness_px,
),
LegendSide::InPanel { .. } => unreachable!("cardinal_side flattens InPanel"),
};
use crate::plot::theme::rect_concrete_defaults;
let rect_defaults = rect_concrete_defaults();
let frame = lt.bar.frame.as_set();
let bar_radius_px = frame
.and_then(|f| f.corner_radius.or(rect_defaults.corner_radius))
.map(|l| (l.resolve(0.0) * dpi / 72.0).max(0.0))
.unwrap_or(0.0);
if let Some(frame_el) = frame {
paint_rect_frame(scene, frame_el, palette, bar_rect, dpi, true, false);
}
draw_gradient_bar(
domain,
spec,
legend.bin_spacing,
registry,
&bar_rect,
side,
bar_radius_px,
scene,
palette,
geom,
);
if let Some(frame_el) = frame {
paint_rect_frame(scene, frame_el, palette, bar_rect, dpi, false, true);
}
let _ = samples;
let (axis_start, axis_end, tick_direction) = axis_baseline(side, bar_rect);
let majors_owned = colorbar_majors(domain, locale);
let majors_owned = if legend.bin_spacing == BinSpacing::Equal {
colorbar_majors_remap_equal(&majors_owned)
} else {
majors_owned
};
let majors = open_end_trim(&majors_owned, legend.open_lower, legend.open_upper);
let style = crate::plot::chrome::linear_axis::AxisChromeStyle::from_resolved(
<.axis.resolved(),
palette,
dpi,
root_pt,
);
crate::plot::chrome::linear_axis::draw_linear_axis_at(
scene,
axis_start,
axis_end,
tick_direction,
majors,
&[],
&style,
dpi,
);
}
fn colorbar_majors_remap_equal(majors: &[(f64, String)]) -> Vec<(f64, String)> {
let n = majors.len();
if n <= 1 {
return majors.to_vec();
}
majors
.iter()
.enumerate()
.map(|(i, (_, label))| (i as f64 / (n - 1) as f64, label.clone()))
.collect()
}
fn open_end_trim(majors: &[(f64, String)], open_lower: bool, open_upper: bool) -> &[(f64, String)] {
let start = if open_lower && !majors.is_empty() {
1
} else {
0
};
let end_excl = if open_upper && majors.len() > start {
majors.len() - 1
} else {
majors.len()
};
if start <= end_excl {
&majors[start..end_excl]
} else {
&[]
}
}
fn colorbar_majors(
domain: &crate::plot::scale::Scale,
locale: &crate::scales::Locale,
) -> Vec<(f64, String)> {
let (min, max) = match domain.input_range() {
Some(crate::scales::input::InputRange::Continuous { min, max }) => (*min, *max),
_ => return Vec::new(),
};
let span = max - min;
if !span.is_finite() || span.abs() < f64::EPSILON {
return Vec::new();
}
domain
.breaks(DEFAULT_BREAK_COUNT)
.iter()
.filter(|v| !matches!(v, Value::Null))
.filter_map(|v| {
let n = v.as_number().or_else(|| v.as_temporal_f64())?;
if !n.is_finite() {
return None;
}
let frac = (n - min) / span;
Some((frac, domain.format(v, locale)))
})
.collect()
}
#[allow(clippy::too_many_arguments)]
fn draw_gradient_bar(
domain: &crate::plot::scale::Scale,
spec: &ColorbarSpec,
bin_spacing: BinSpacing,
registry: &ScaleRegistry,
bar: &Rect,
side: LegendSide,
corner_radius_px: f64,
scene: &mut dyn SceneBuilder,
palette: &crate::plot::theme::Palette,
geom: &crate::plot::theme::GeomTheme,
) {
let (min, max) = match domain.input_range() {
Some(crate::scales::input::InputRange::Continuous { min, max }) => (*min, *max),
_ => return,
};
let span = max - min;
if !span.is_finite() || span.abs() < f64::EPSILON {
return;
}
let n = spec.samples.max(2);
let horizontal = matches!(side, LegendSide::Top | LegendSide::Bottom);
let (grad_start, grad_end) = if horizontal {
(
Point::new(bar.x0, bar.y0 + (bar.y1 - bar.y0) * 0.5),
Point::new(bar.x1, bar.y0 + (bar.y1 - bar.y0) * 0.5),
)
} else {
(
Point::new(bar.x0 + (bar.x1 - bar.x0) * 0.5, bar.y1),
Point::new(bar.x0 + (bar.x1 - bar.x0) * 0.5, bar.y0),
)
};
let has_explicit_fill =
spec.bindings.contains_key("fill") || spec.bindings.contains_key("color");
let resolve_stop_colour = |value: Value| -> Color {
let mut resolved = ResolvedKey::default();
for (aesthetic, source) in &spec.bindings {
let v = match source {
AestheticSource::Scaled(name) => match registry.get(name) {
Some(scale) => scale.map(&value),
None => continue,
},
AestheticSource::Fixed(val) => val.clone(),
};
resolved.apply(aesthetic, v);
}
if !has_explicit_fill {
if let Some(c) = domain.map(&value).as_color() {
resolved.fill = Some(c);
}
}
let fallback = || {
geom.rect
.fill
.as_ref()
.map(|c| c.resolve(palette))
.unwrap_or_else(|| palette.ink)
};
with_opacity(
resolved.fill.unwrap_or_else(fallback),
resolved.fill_opacity,
)
};
let stops: Vec<crate::brush::ColorStop> = if spec.stepped {
let mut break_values: Vec<f64> = domain
.breaks(DEFAULT_BREAK_COUNT)
.iter()
.filter_map(|v| {
let n = v.as_number().or_else(|| v.as_temporal_f64())?;
if !n.is_finite() {
return None;
}
Some(n)
})
.filter(|n| *n >= min && *n <= max)
.collect();
if break_values.first().copied().unwrap_or(max) > min {
break_values.insert(0, min);
}
if break_values.last().copied().unwrap_or(min) < max {
break_values.push(max);
}
if break_values.len() < 2 {
return;
}
let n_bins = break_values.len() - 1;
let mut out = Vec::with_capacity(break_values.len() * 2);
for (i, w) in break_values.windows(2).enumerate() {
let (lo, hi) = (w[0], w[1]);
let mid_value = Value::Number((lo + hi) * 0.5);
let (lo_t, hi_t) = match bin_spacing {
BinSpacing::Proportional => ((lo - min) / span, (hi - min) / span),
BinSpacing::Equal => (i as f64 / n_bins as f64, (i + 1) as f64 / n_bins as f64),
};
let color = resolve_stop_colour(mid_value);
out.push(crate::brush::ColorStop {
offset: lo_t as f32,
color: color.into(),
});
out.push(crate::brush::ColorStop {
offset: hi_t as f32,
color: color.into(),
});
}
out
} else {
(0..n)
.map(|i| {
let t = i as f64 / (n - 1) as f64;
let value = Value::Number(min + t * span);
crate::brush::ColorStop {
offset: t as f32,
color: resolve_stop_colour(value).into(),
}
})
.collect()
};
let gradient =
crate::brush::Gradient::new_linear(grad_start, grad_end).with_stops(stops.as_slice());
let path: Path = if corner_radius_px > 0.0 {
crate::primitives::rounded_rect(*bar, corner_radius_px)
} else {
bar.to_path(0.0)
};
scene.fill(
FillRule::NonZero,
Affine::IDENTITY,
&Brush::Gradient(gradient),
None,
&path,
PickId::Skip,
);
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn bin_edges_drops_nulls_and_non_numeric_breaks() {
let breaks = vec![
Value::Null,
Value::Number(0.0),
Value::String(Arc::from("x")),
Value::Number(10.0),
Value::Number(f64::NAN),
];
assert_eq!(bin_edges(&breaks), vec![0.0, 10.0]);
}
#[test]
fn bin_midpoints_yields_one_value_per_bin() {
let mids: Vec<f64> = bin_midpoints(&[0.0, 10.0, 20.0, 30.0])
.iter()
.filter_map(|v| v.as_number())
.collect();
assert_eq!(mids, vec![5.0, 15.0, 25.0]);
assert!(bin_midpoints(&[]).is_empty());
assert!(bin_midpoints(&[5.0]).is_empty());
assert!(bin_midpoints(&[5.0, 5.0]).is_empty());
}
fn sample_majors() -> Vec<(f64, String)> {
vec![
(0.0, "0".into()),
(0.25, "1".into()),
(0.5, "2".into()),
(0.75, "3".into()),
(1.0, "4".into()),
]
}
#[test]
fn open_lower_drops_first_major() {
let m = sample_majors();
let trimmed = open_end_trim(&m, true, false);
assert_eq!(trimmed.len(), 4);
assert_eq!(trimmed[0].1, "1");
assert_eq!(trimmed[3].1, "4");
}
#[test]
fn open_upper_drops_last_major() {
let m = sample_majors();
let trimmed = open_end_trim(&m, false, true);
assert_eq!(trimmed.len(), 4);
assert_eq!(trimmed[0].1, "0");
assert_eq!(trimmed[3].1, "3");
}
#[test]
fn open_both_drops_both_terminals() {
let m = sample_majors();
let trimmed = open_end_trim(&m, true, true);
assert_eq!(trimmed.len(), 3);
assert_eq!(trimmed[0].1, "1");
assert_eq!(trimmed[2].1, "3");
}
#[test]
fn open_neither_returns_full_slice() {
let m = sample_majors();
let trimmed = open_end_trim(&m, false, false);
assert_eq!(trimmed.len(), 5);
}
#[test]
fn open_trim_handles_short_slices() {
let one = vec![(0.5_f64, "mid".to_string())];
assert!(open_end_trim(&one, true, false).is_empty());
let empty: Vec<(f64, String)> = vec![];
assert!(open_end_trim(&empty, true, true).is_empty());
}
#[test]
fn equal_remap_spaces_majors_uniformly() {
let m = vec![
(0.0, "0".into()),
(0.01, "1".into()),
(0.05, "5".into()),
(0.5, "50".into()),
(1.0, "100".into()),
];
let remapped = colorbar_majors_remap_equal(&m);
assert_eq!(remapped.len(), 5);
assert_eq!(remapped[0].1, "0");
assert_eq!(remapped[4].1, "100");
for (i, (frac, _)) in remapped.iter().enumerate() {
let expected = i as f64 / 4.0;
assert!(
(frac - expected).abs() < 1e-12,
"remap[{i}] = {frac}, expected {expected}"
);
}
}
#[test]
fn equal_remap_short_slice_is_passthrough() {
let single = vec![(0.42_f64, "lonely".to_string())];
let remapped = colorbar_majors_remap_equal(&single);
assert_eq!(remapped.len(), 1);
assert!((remapped[0].0 - 0.42).abs() < 1e-12);
}
}