use std::collections::HashMap;
use std::sync::Arc;
use crate::composition::{spacer, Composition, CompositionLayout, Element, Patch, Span};
use crate::geometry::Size;
use crate::layout::{Inset, Track};
use crate::scene::SceneBuilder;
use crate::shape::ShapeRegistry;
use super::plot::Plot;
use super::scale::{Scale, ScaleRegistry};
use super::theme::Theme;
use super::scale::AxisSide;
const ROOT_COMPOSITION_ID: &str = "__hephaestus_root__";
#[derive(Debug, Clone)]
struct CompositionTemplate {
id: Option<String>,
rows: usize,
cols: usize,
widths: Vec<Track>,
heights: Vec<Track>,
aspect: Option<(f64, f64)>,
margin: Inset,
padding: Inset,
placements: Vec<PlacementTemplate>,
}
#[derive(Debug, Clone)]
struct PlacementTemplate {
row: u16,
col: u16,
span: Span,
element: ElementTemplate,
}
#[derive(Debug, Clone)]
enum ElementTemplate {
NamedPatch(String),
Spacer,
Composition(Box<CompositionTemplate>),
}
impl CompositionTemplate {
fn capture(c: &Composition) -> Self {
Self {
id: c.composition_id().map(str::to_string),
rows: c.rows(),
cols: c.cols(),
widths: c.widths_slice().to_vec(),
heights: c.heights_slice().to_vec(),
aspect: c.aspect_ratio(),
margin: c.margin_inset().clone(),
padding: c.padding_inset().clone(),
placements: c
.placements()
.map(|(row, col, span, element)| PlacementTemplate {
row,
col,
span,
element: ElementTemplate::capture(element),
})
.collect(),
}
}
fn rebuild(&self, ctx: &RebuildCtx<'_>) -> Composition {
let mut c = Composition::empty(self.rows, self.cols);
if let Some(id) = &self.id {
c = c.id(id.clone());
}
if !self.widths.is_empty() {
c = c.widths(self.widths.clone());
}
if !self.heights.is_empty() {
c = c.heights(self.heights.clone());
}
if let Some((w, h)) = self.aspect {
c = c.aspect(w, h);
}
c = c.margin(self.margin.clone()).padding(self.padding.clone());
for p in &self.placements {
let element = p.element.rebuild(ctx);
c = c.place(p.row, p.col, p.span, element);
}
if let Some(chrome) = self.id.as_deref().and_then(|id| ctx.chrome.get(id)) {
c = chrome.wire(c, ctx);
}
c
}
}
struct RebuildCtx<'a> {
plots: &'a HashMap<String, Vec<Plot>>,
registry: &'a ScaleRegistry,
dpi: f64,
theme: &'a Theme,
chrome: &'a HashMap<String, CompositionChrome>,
shapes: &'a ShapeRegistry,
}
impl ElementTemplate {
fn capture(e: &Element) -> Self {
match e {
Element::Patch(p) => match p.patch_id() {
Some(id) => ElementTemplate::NamedPatch(id.to_string()),
None => ElementTemplate::Spacer,
},
Element::Composition(c) => {
ElementTemplate::Composition(Box::new(CompositionTemplate::capture(c)))
}
}
}
fn rebuild(&self, ctx: &RebuildCtx<'_>) -> Element {
match self {
ElementTemplate::NamedPatch(id) => {
let patch = wire_into_patch(id, ctx.plots, ctx.registry, ctx.dpi, ctx.theme);
Element::Patch(patch)
}
ElementTemplate::Spacer => Element::Patch(spacer()),
ElementTemplate::Composition(inner) => Element::Composition(inner.rebuild(ctx)),
}
}
}
fn wire_into_patch(
id: &str,
plots: &HashMap<String, Vec<Plot>>,
registry: &ScaleRegistry,
dpi: f64,
comp_theme: &Theme,
) -> Patch {
let plot_list = plots.get(id);
{
if let Some(list) = plot_list {
if !list.is_empty() {
return wire_merged_patch(id, list, registry, dpi, comp_theme);
}
}
}
let _ = registry;
let _ = dpi;
let p = apply_plot_chrome(Patch::new(id), comp_theme);
match plot_list.and_then(|list| list.first()) {
Some(pl) => pl.wire_panel(p),
None => pl_wire_panel_fallback(p),
}
}
fn apply_plot_chrome(patch: Patch, theme: &Theme) -> Patch {
use crate::composition::Slot;
use crate::layout::{Cell, Extent, Inset};
let root_pt = crate::plot::chrome::root_text_pt(theme);
let margin_inset = {
let (mt, mr, mb, ml) = theme.plot_margin.resolve(root_pt);
(mt != 0.0 || mr != 0.0 || mb != 0.0 || ml != 0.0).then(|| {
Inset::default()
.top(Extent::pt(mt))
.right(Extent::pt(mr))
.bottom(Extent::pt(mb))
.left(Extent::pt(ml))
})
};
let padding_inset = {
let (pt, pr, pb, pl) = theme.plot_padding.resolve(root_pt);
(pt != 0.0 || pr != 0.0 || pb != 0.0 || pl != 0.0).then(|| {
Inset::default()
.top(Extent::pt(pt))
.right(Extent::pt(pr))
.bottom(Extent::pt(pb))
.left(Extent::pt(pl))
})
};
let mut patch = patch;
if let Some(inset) = margin_inset {
patch = patch.margin(inset);
}
if let Some(inset) = padding_inset {
patch = patch.padding(inset);
}
if theme.plot_background.as_set().is_some() {
patch = patch.slot(Slot::Background, Cell::empty());
}
patch
}
fn wire_merged_patch(
id: &str,
plots: &[Plot],
registry: &ScaleRegistry,
dpi: f64,
comp_theme: &Theme,
) -> Patch {
use crate::composition::PatchPlacement;
use crate::layout::{Cell, MaxMergeMeasure, Placement};
type RegionEntry = (String, Placement, Vec<Box<dyn crate::layout::Measure>>);
let mut by_region: Vec<RegionEntry> = Vec::new();
for plot in plots {
let effective_theme = match plot.theme_override_ref() {
Some(part) => comp_theme.merge(part),
None => comp_theme.clone(),
};
let per_plot = plot.wire(Patch::new(id), registry, dpi, &effective_theme);
for PatchPlacement {
placement,
region,
cell,
} in per_plot.into_placements()
{
if let Some((_, _, measures)) = by_region.iter_mut().find(|(r, _, _)| *r == region) {
measures.push(cell.into_measure());
} else {
by_region.push((region, placement, vec![cell.into_measure()]));
}
}
}
let mut patch = apply_plot_chrome(Patch::new(id), comp_theme);
let aspects: Vec<(f64, f64)> = plots
.iter()
.filter_map(|p| p.desired_panel_aspect(registry))
.collect();
if let Some(agreed) = unify_aspect(&aspects) {
patch = patch.aspect(agreed.0, agreed.1);
}
for (region, placement, mut measures) in by_region {
let cell = if measures.len() == 1 {
Cell::measured_boxed(measures.pop().unwrap())
} else {
Cell::measured(MaxMergeMeasure::new(measures))
};
patch = patch.place_at(
region,
placement.row,
placement.col,
crate::composition::Span::rc(placement.row_span, placement.col_span),
cell,
);
}
patch
}
fn unify_aspect(aspects: &[(f64, f64)]) -> Option<(f64, f64)> {
let first = *aspects.first()?;
let ratio0 = first.0 / first.1;
const EPS: f64 = 1e-3;
for &(w, h) in &aspects[1..] {
let r = w / h;
if (r - ratio0).abs() > EPS {
return None;
}
}
Some(first)
}
fn pl_wire_panel_fallback(p: Patch) -> Patch {
use crate::composition::Slot;
use crate::layout::Cell;
p.slot(Slot::Panel, Cell::empty())
}
const AXIS_TITLE_SIDES: [AxisSide; 4] = [
AxisSide::Top,
AxisSide::Right,
AxisSide::Bottom,
AxisSide::Left,
];
fn reject_in_panel_legend(
legend: &crate::plot::chrome::legend::Legend,
) -> Result<(), crate::plot::plot::PlotError> {
use crate::scales::chrome::LegendSide;
if matches!(legend.side, LegendSide::InPanel { .. }) {
return Err(crate::plot::plot::PlotError::InPanelLegendUnsupported);
}
Ok(())
}
#[derive(Default)]
struct CompositionChrome {
title: Option<String>,
subtitle: Option<String>,
caption: Option<String>,
axis_titles: [Option<String>; 4],
legends: Vec<crate::plot::chrome::legend::Legend>,
next_legend_id: u32,
}
impl CompositionChrome {
fn wire(&self, mut c: Composition, ctx: &RebuildCtx<'_>) -> Composition {
use super::plot::{
cartesian_axis_title_slot, legends_grouped_by_side, title_band_placement,
};
use crate::composition::Slot;
use crate::layout::Cell;
use crate::plot::chrome::text::{axis_title_cell, effective_text, text_cell_for_element};
let theme = ctx.theme;
let root_pt = crate::plot::chrome::root_text_pt(theme);
for (slot, text_opt, theme_slot) in [
(Slot::Title, self.title.as_ref(), &theme.plot_title),
(Slot::Subtitle, self.subtitle.as_ref(), &theme.plot_subtitle),
(Slot::Caption, self.caption.as_ref(), &theme.plot_caption),
] {
if let (Some(t), Some(el)) = (text_opt, effective_text(theme_slot, &theme.text)) {
let (r, col, rs, cs) = title_band_placement(slot, theme.plot_text_align_to);
c = c.place_at(
slot.name(),
r,
col,
Span::rc(rs, cs),
text_cell_for_element(t, &el, root_pt, ctx.dpi, theme),
);
}
}
for side in AXIS_TITLE_SIDES {
if let Some(title) = self.axis_title_at(side) {
c = c.slot(
cartesian_axis_title_slot(side),
axis_title_cell(title, side, theme, ctx.dpi),
);
}
}
let collapsed = crate::plot::chrome::legend::collapse_legends(
&self.legends,
ctx.registry,
&theme.locale,
);
for (side, slot, group) in legends_grouped_by_side(&collapsed) {
if group.is_empty() {
continue;
}
c = c.slot(
slot,
Cell::measured_boxed(crate::plot::chrome::legend::legend_stack_measure(
&group,
side,
ctx.registry,
ctx.shapes,
ctx.dpi,
theme,
)),
);
}
c
}
fn axis_title_at(&self, side: AxisSide) -> Option<&str> {
self.axis_titles[super::plot::axis_side_index(side)].as_deref()
}
#[allow(clippy::too_many_arguments)]
fn draw_into(
&self,
comp_id: &str,
scene: &mut dyn SceneBuilder,
layout: &CompositionLayout,
registry: &ScaleRegistry,
shapes: &ShapeRegistry,
dpi: f64,
theme: &Theme,
) {
use super::plot::{cartesian_axis_title_slot, legends_grouped_by_side};
use crate::brush::Brush;
use crate::composition::Slot;
use crate::plot::chrome::text::{
draw_axis_title, draw_text_element_in_rect, effective_text,
};
use crate::plot::theme::text_concrete_defaults;
use crate::text::TextRun;
let root_pt = crate::plot::chrome::root_text_pt(theme);
for (slot, text_opt, theme_slot) in [
(Slot::Title, self.title.as_ref(), &theme.plot_title),
(Slot::Subtitle, self.subtitle.as_ref(), &theme.plot_subtitle),
(Slot::Caption, self.caption.as_ref(), &theme.plot_caption),
] {
let (Some(text), Some(rect), Some(el)) = (
text_opt,
layout.get(comp_id, slot),
effective_text(theme_slot, &theme.text),
) else {
continue;
};
draw_text_element_in_rect(
scene,
text,
&el,
rect,
&theme.palette,
root_pt,
dpi,
crate::pick::PickId::Skip,
Some(&theme.rich_text),
);
}
let text_defaults = text_concrete_defaults();
for side in AXIS_TITLE_SIDES {
let Some(title) = self.axis_title_at(side) else {
continue;
};
let (ch, side_idx) = crate::plot::chrome::axis::axis_side_to_channel_side(side);
let Some(el) = theme.resolved_axis(ch, side_idx).title else {
continue;
};
let Some(rect) = layout.get(comp_id, cartesian_axis_title_slot(side)) else {
continue;
};
let color = el
.color
.clone()
.or_else(|| text_defaults.color.clone())
.expect("text_concrete_defaults sets color");
let angle = el
.angle
.or(text_defaults.angle)
.expect("text_concrete_defaults sets angle");
let style = crate::plot::chrome::text::text_style_from(&el, root_pt);
if matches!(el.markdown, Some(true)) {
crate::plot::chrome::text::draw_axis_title_markdown(
scene,
title,
&style,
color.resolve(&theme.palette),
&theme.palette,
&theme.rich_text,
dpi,
rect,
side,
angle,
);
continue;
}
let run = TextRun::new(title, &style, dpi);
let outline = crate::plot::chrome::text::text_outline_from(&el, &theme.palette, dpi);
draw_axis_title(
scene,
&run,
rect,
side,
&Brush::Solid(color.resolve(&theme.palette)),
outline.as_ref(),
angle,
);
}
let collapsed =
crate::plot::chrome::legend::collapse_legends(&self.legends, registry, &theme.locale);
for (side, slot, group) in legends_grouped_by_side(&collapsed) {
if group.is_empty() {
continue;
}
if let Some(rect) = layout.get(comp_id, slot) {
crate::plot::chrome::legend::render_legend_stack(
&group, side, rect, registry, shapes, scene, dpi, theme,
);
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationIssue {
MissingScale {
plot_id: String,
channel: String,
scale_name: String,
},
OutputTypeMismatch {
plot_id: String,
channel: String,
scale_name: String,
expected: super::geom::ExpectedOutput,
found: &'static str,
},
}
pub struct PlotComposition {
template: CompositionTemplate,
scales: ScaleRegistry,
theme: Arc<Theme>,
plots: HashMap<String, Vec<Plot>>,
plot_order: Vec<String>,
chrome: HashMap<String, CompositionChrome>,
chrome_order: Vec<String>,
root_id: String,
shapes: ShapeRegistry,
plot_dirty: HashMap<String, bool>,
scale_dirty: HashMap<String, bool>,
layout_dirty: bool,
last_layout: Option<CompositionLayout>,
last_size: Option<Size>,
last_dpi: Option<f64>,
}
impl PlotComposition {
pub fn new(composition: &Composition) -> Self {
let mut template = CompositionTemplate::capture(composition);
let root_id = template
.id
.get_or_insert_with(|| ROOT_COMPOSITION_ID.to_string())
.clone();
Self {
template,
scales: ScaleRegistry::new(),
theme: Arc::new(Theme::default()),
plots: HashMap::new(),
plot_order: Vec::new(),
chrome: HashMap::new(),
chrome_order: Vec::new(),
root_id,
shapes: ShapeRegistry::with_builtins(),
plot_dirty: HashMap::new(),
scale_dirty: HashMap::new(),
layout_dirty: true,
last_layout: None,
last_size: None,
last_dpi: None,
}
}
pub fn theme(mut self, theme: Theme) -> Self {
self.set_theme(theme);
self
}
pub fn set_theme(&mut self, theme: Theme) {
self.theme = Arc::new(theme);
self.layout_dirty = true;
}
pub fn update_theme(&mut self, f: impl FnOnce(&mut Theme)) {
let theme = Arc::make_mut(&mut self.theme);
f(theme);
self.layout_dirty = true;
}
pub fn theme_ref(&self) -> &Theme {
&self.theme
}
fn effective_theme_for(&self, plot: &Plot) -> Arc<Theme> {
match plot.theme_override_ref() {
Some(part) => Arc::new(self.theme.merge(part)),
None => Arc::clone(&self.theme),
}
}
fn plots_in_order(&self) -> impl Iterator<Item = &Plot> {
self.plot_order
.iter()
.filter_map(|id| self.plots.get(id))
.flat_map(|list| list.iter())
}
fn root_chrome_mut(&mut self) -> &mut CompositionChrome {
self.layout_dirty = true;
if !self.chrome.contains_key(&self.root_id) {
self.chrome_order.push(self.root_id.clone());
}
self.chrome.entry(self.root_id.clone()).or_default()
}
pub fn title(mut self, s: impl Into<String>) -> Self {
self.set_title(s);
self
}
pub fn subtitle(mut self, s: impl Into<String>) -> Self {
self.root_chrome_mut().subtitle = Some(s.into());
self
}
pub fn caption(mut self, s: impl Into<String>) -> Self {
self.root_chrome_mut().caption = Some(s.into());
self
}
pub fn set_title(&mut self, s: impl Into<String>) {
self.root_chrome_mut().title = Some(s.into());
}
pub fn clear_title(&mut self) {
self.root_chrome_mut().title = None;
}
pub fn axis_title(mut self, side: AxisSide, text: impl Into<String>) -> Self {
self.set_axis_title(side, Some(text.into()));
self
}
pub fn set_axis_title(&mut self, side: AxisSide, text: Option<String>) {
let idx = super::plot::axis_side_index(side);
self.root_chrome_mut().axis_titles[idx] = text;
}
pub fn axis_title_at(&self, side: AxisSide) -> Option<&str> {
self.chrome
.get(&self.root_id)
.and_then(|c| c.axis_title_at(side))
}
pub fn add_legend(
&mut self,
legend: crate::plot::chrome::legend::Legend,
) -> crate::plot::chrome::legend::LegendId {
self.push_legend(legend)
}
pub fn add_legend_separate(
&mut self,
mut legend: crate::plot::chrome::legend::Legend,
) -> crate::plot::chrome::legend::LegendId {
legend.merge = false;
self.push_legend(legend)
}
fn push_legend(
&mut self,
legend: crate::plot::chrome::legend::Legend,
) -> crate::plot::chrome::legend::LegendId {
match self.try_push_legend(legend) {
Ok(id) => id,
Err(e) => panic!("{e}"),
}
}
pub fn try_add_legend(
&mut self,
legend: crate::plot::chrome::legend::Legend,
) -> Result<crate::plot::chrome::legend::LegendId, crate::plot::plot::PlotError> {
self.try_push_legend(legend)
}
fn try_push_legend(
&mut self,
legend: crate::plot::chrome::legend::Legend,
) -> Result<crate::plot::chrome::legend::LegendId, crate::plot::plot::PlotError> {
reject_in_panel_legend(&legend)?;
let chrome = self.root_chrome_mut();
let id = crate::plot::chrome::legend::LegendId::new(chrome.next_legend_id);
chrome.next_legend_id += 1;
chrome.legends.push(legend);
Ok(id)
}
pub fn legends(&self) -> &[crate::plot::chrome::legend::Legend] {
self.chrome
.get(&self.root_id)
.map(|c| c.legends.as_slice())
.unwrap_or(&[])
}
pub fn clear_legends(&mut self) {
self.root_chrome_mut().legends.clear();
}
pub fn shape_registry(mut self, r: ShapeRegistry) -> Self {
self.shapes = r;
self
}
pub fn add_scale(mut self, name: impl Into<String>, scale: Scale) -> Self {
self.insert_scale(name, scale);
self
}
pub fn insert_scale(&mut self, name: impl Into<String>, scale: Scale) {
let name = name.into();
self.scale_dirty.insert(name.clone(), true);
self.flag_plots_referencing(&name);
self.scales.insert(name, scale);
self.layout_dirty = true;
}
pub fn remove_scale(&mut self, name: &str) -> Option<Scale> {
let removed = self.scales.remove(name);
if removed.is_some() {
self.scale_dirty.insert(name.to_string(), true);
self.flag_plots_referencing(name);
self.layout_dirty = true;
}
removed
}
pub fn scale(&self, name: &str) -> Option<&Scale> {
self.scales.get(name)
}
pub fn scales(&self) -> &ScaleRegistry {
&self.scales
}
pub fn update_scale(&mut self, name: &str, f: impl FnOnce(&mut Scale)) {
let dirty_was_set;
if let Some(s) = self.scales.get_mut(name) {
f(s);
self.scale_dirty.insert(name.to_string(), true);
dirty_was_set = true;
} else {
dirty_was_set = false;
}
if dirty_was_set {
self.flag_plots_referencing(name);
self.layout_dirty = true;
}
}
pub fn with_plot(mut self, plot: Plot) -> Self {
self.attach_plot(plot);
self
}
pub fn attach_plot(&mut self, plot: Plot) {
let id = plot.patch_id().to_string();
self.plot_dirty.insert(id.clone(), true);
if !self.plots.contains_key(&id) {
self.plot_order.push(id.clone());
}
self.plots.entry(id).or_default().push(plot);
self.layout_dirty = true;
}
pub fn detach_plot(&mut self, patch_id: &str) -> Vec<Plot> {
let removed = self.plots.remove(patch_id).unwrap_or_default();
self.plot_order.retain(|id| id != patch_id);
if !removed.is_empty() {
self.plot_dirty.insert(patch_id.to_string(), true);
self.layout_dirty = true;
}
removed
}
pub fn plot(&self, patch_id: &str) -> Option<&Plot> {
self.plots.get(patch_id).and_then(|v| v.first())
}
pub fn plots_in(&self, patch_id: &str) -> &[Plot] {
self.plots
.get(patch_id)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
pub fn plots(&self) -> impl Iterator<Item = (&str, &Plot)> + '_ {
self.plot_order.iter().flat_map(|id| {
self.plots
.get(id)
.into_iter()
.flat_map(move |list| list.iter().map(move |p| (id.as_str(), p)))
})
}
pub fn update_plot(&mut self, patch_id: &str, f: impl FnOnce(&mut Plot)) {
if let Some(p) = self.plots.get_mut(patch_id).and_then(|v| v.first_mut()) {
f(p);
self.plot_dirty.insert(patch_id.to_string(), true);
self.layout_dirty = true;
}
}
pub fn update_plot_at(&mut self, patch_id: &str, index: usize, f: impl FnOnce(&mut Plot)) {
if let Some(p) = self.plots.get_mut(patch_id).and_then(|v| v.get_mut(index)) {
f(p);
self.plot_dirty.insert(patch_id.to_string(), true);
self.layout_dirty = true;
}
}
pub fn invalidate_layout(&mut self) {
self.layout_dirty = true;
self.last_layout = None;
}
pub fn render(&mut self, scene: &mut dyn SceneBuilder, size: Size, dpi: f64) {
let size_or_dpi_changed = match (self.last_size, self.last_dpi) {
(Some(s), Some(d)) => s.width != size.width || s.height != size.height || d != dpi,
_ => true,
};
if size_or_dpi_changed {
self.layout_dirty = true;
}
if self.layout_dirty || self.last_layout.is_none() {
let comp = self.template.rebuild(&RebuildCtx {
plots: &self.plots,
registry: &self.scales,
dpi,
theme: &self.theme,
chrome: &self.chrome,
shapes: &self.shapes,
});
self.last_layout = Some(comp.solve(size, dpi));
self.last_size = Some(size);
self.last_dpi = Some(dpi);
self.layout_dirty = false;
}
let layout = self
.last_layout
.as_ref()
.expect("layout must be cached by this point");
let plot_themes: Vec<Arc<Theme>> = self
.plots_in_order()
.map(|plot| self.effective_theme_for(plot))
.collect();
for (plot, effective) in self.plots_in_order().zip(&plot_themes) {
plot.draw_patch_background_into(scene, layout, effective, dpi);
}
for (plot, effective) in self.plots_in_order().zip(&plot_themes) {
plot.draw_panel_chrome_into(scene, layout, &self.scales, dpi, effective);
}
let mut theme_idx = 0usize;
for id in &self.plot_order {
let Some(list) = self.plots.get_mut(id) else {
continue;
};
for plot in list.iter_mut() {
let effective = &plot_themes[theme_idx];
theme_idx += 1;
plot.draw_geoms_into(scene, layout, &self.scales, dpi, effective);
}
}
for (plot, effective) in self.plots_in_order().zip(&plot_themes) {
plot.draw_chrome_into(scene, layout, &self.scales, dpi, effective);
}
for (comp_id, chrome) in self
.chrome_order
.iter()
.filter_map(|id| self.chrome.get(id).map(|c| (id, c)))
{
chrome.draw_into(
comp_id,
scene,
layout,
&self.scales,
&self.shapes,
dpi,
&self.theme,
);
}
self.plot_dirty.clear();
self.scale_dirty.clear();
}
pub fn validate(&self) -> Vec<ValidationIssue> {
let mut out = Vec::new();
for (plot_id, plot) in self.plots() {
for (channel, scale_name) in plot.bindings() {
let scale = self.scales.get(scale_name);
if scale.is_none() {
out.push(ValidationIssue::MissingScale {
plot_id: plot_id.to_string(),
channel: channel.to_string(),
scale_name: scale_name.to_string(),
});
continue;
}
let expected = expected_output_for_channel(channel);
if let (Some(expected), Some(s)) = (expected, scale) {
if let Some(found) = output_type_name(s) {
if !matches_expected(expected, found) {
out.push(ValidationIssue::OutputTypeMismatch {
plot_id: plot_id.to_string(),
channel: channel.to_string(),
scale_name: scale_name.to_string(),
expected,
found,
});
}
}
}
}
}
out
}
fn flag_plots_referencing(&mut self, scale_name: &str) {
for (pid, list) in &self.plots {
let referenced = list
.iter()
.any(|plot| plot.bindings().any(|(_, sn)| sn == scale_name));
if referenced {
self.plot_dirty.insert(pid.clone(), true);
}
}
}
}
fn expected_output_for_channel(channel: &str) -> Option<super::geom::ExpectedOutput> {
use super::geom::ExpectedOutput::*;
Some(match channel {
"x" | "y" | "size" | "linewidth" | "fill_opacity" | "stroke_opacity" | "x_offset"
| "y_offset" | "x_band" | "y_band" | "dash_offset" => Numbers,
"fill" | "stroke" => Colors,
"shape" | "cap" | "join" => Strings,
"linetype" => Linetypes,
_ => return None,
})
}
fn output_type_name(scale: &Scale) -> Option<&'static str> {
use super::scale::OutputRange;
Some(match scale.output_range()? {
OutputRange::Numbers(_) => "Numbers",
OutputRange::Colors(_) => "Colors",
OutputRange::Strings(_) => "Strings",
OutputRange::Linetypes(_) => "Linetypes",
})
}
fn matches_expected(expected: super::geom::ExpectedOutput, found: &'static str) -> bool {
use super::geom::ExpectedOutput::*;
match expected {
Numbers => found == "Numbers",
Colors => found == "Colors",
Strings => found == "Strings",
Linetypes => found == "Linetypes",
Any => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::composition::{beside, Patch as CompPatch};
use crate::plot::geom::PointGeom;
use crate::plot::scale;
fn comp_two() -> Composition {
beside(CompPatch::new("a"), CompPatch::new("b"))
}
#[test]
fn new_captures_template() {
let view = PlotComposition::new(&comp_two());
assert_eq!(view.template.cols, 2);
assert_eq!(view.template.rows, 1);
assert_eq!(view.template.placements.len(), 2);
}
#[test]
fn add_scale_flips_layout_dirty() {
let mut view = PlotComposition::new(&comp_two());
view.layout_dirty = false; view.insert_scale("x", scale::continuous(0.0..=1.0));
assert!(view.layout_dirty);
assert!(view.scale_dirty.get("x").copied().unwrap_or(false));
}
#[test]
fn update_scale_flips_plot_dirty_for_referencing_plots() {
let mut view = PlotComposition::new(&comp_two())
.add_scale("time", scale::continuous(0.0..=100.0))
.with_plot(crate::plot::Plot::new(&comp_two(), "a").bind("x", "time"))
.with_plot(crate::plot::Plot::new(&comp_two(), "b").bind("x", "time"));
view.layout_dirty = false;
view.plot_dirty.clear();
view.scale_dirty.clear();
view.update_scale("time", |s| s.set_domain_continuous(0.0, 50.0));
assert!(view.layout_dirty);
assert!(view.scale_dirty.get("time").copied().unwrap_or(false));
assert!(view.plot_dirty.get("a").copied().unwrap_or(false));
assert!(view.plot_dirty.get("b").copied().unwrap_or(false));
}
#[test]
fn update_plot_flips_dirty() {
let mut view =
PlotComposition::new(&comp_two()).with_plot(crate::plot::Plot::new(&comp_two(), "a"));
view.layout_dirty = false;
view.plot_dirty.clear();
view.update_plot("a", |p| p.set_title("hello"));
assert!(view.layout_dirty);
assert!(view.plot_dirty.get("a").copied().unwrap_or(false));
}
#[test]
fn render_resolves_cached_layout() {
let mut view =
PlotComposition::new(&comp_two()).with_plot(crate::plot::Plot::new(&comp_two(), "a"));
let mut scene = crate::scene::recording::RecordingScene::default();
view.render(&mut scene, Size::new(400.0, 300.0), 96.0);
assert!(!view.layout_dirty);
assert!(view.last_layout.is_some());
let first_ops = scene.ops.len();
view.render(&mut scene, Size::new(400.0, 300.0), 96.0);
let _ = first_ops;
assert!(!view.layout_dirty);
}
#[test]
fn render_with_size_change_re_solves() {
use crate::composition::Slot;
let mut view =
PlotComposition::new(&comp_two()).with_plot(crate::plot::Plot::new(&comp_two(), "a"));
let mut scene = crate::scene::recording::RecordingScene::default();
view.render(&mut scene, Size::new(400.0, 300.0), 96.0);
let r1 = view
.last_layout
.as_ref()
.unwrap()
.get("a", Slot::Panel)
.expect("panel rect 1");
view.render(&mut scene, Size::new(800.0, 300.0), 96.0);
let r2 = view
.last_layout
.as_ref()
.unwrap()
.get("a", Slot::Panel)
.expect("panel rect 2");
assert!(r1.x1 < r2.x1, "panel should be wider after size change");
}
#[test]
fn render_passes_user_pick_ids_through() {
let g = PointGeom::builder()
.set("x", vec![0.0_f64, 1.0, 2.0])
.set("y", vec![0.0_f64, 1.0, 2.0])
.set("fill", crate::color::Color::new([1.0, 0.0, 0.0, 1.0]))
.set("pick_id", vec![100_i64, 200, 300])
.build();
let mut plot = crate::plot::Plot::new(&comp_two(), "a");
let _gid = plot.add_geom(g);
let mut view = PlotComposition::new(&comp_two()).with_plot(plot);
let mut scene = crate::scene::recording::RecordingScene::default();
view.render(&mut scene, Size::new(400.0, 300.0), 96.0);
let ids: Vec<u32> = scene
.ops
.iter()
.filter_map(|op| match op {
crate::scene::recording::Op::Fill {
pick_id: crate::pick::PickId::Id(n),
..
} => Some(*n),
_ => None,
})
.collect();
assert_eq!(ids, vec![100, 200, 300]);
}
#[test]
fn unattached_patch_renders_silently() {
let mut view =
PlotComposition::new(&comp_two()).with_plot(crate::plot::Plot::new(&comp_two(), "a"));
let mut scene = crate::scene::recording::RecordingScene::default();
view.render(&mut scene, Size::new(400.0, 300.0), 96.0);
}
fn view_two_plots() -> PlotComposition {
PlotComposition::new(&comp_two())
.with_plot(crate::plot::Plot::new(&comp_two(), "a"))
.with_plot(crate::plot::Plot::new(&comp_two(), "b"))
}
#[test]
fn capture_preserves_composition_geometry() {
use crate::layout::{Extent, Inset};
let comp = comp_two()
.id("outer")
.aspect(1.0, 1.0)
.margin(Inset::default().left(Extent::pt(7.0)));
let view = PlotComposition::new(&comp);
assert_eq!(view.template.id.as_deref(), Some("outer"));
assert_eq!(view.template.aspect, Some((1.0, 1.0)));
assert!(view.template.margin.left.is_some());
assert_eq!(view.root_id, "outer");
}
#[test]
fn unnamed_composition_gets_a_root_id() {
let view = PlotComposition::new(&comp_two());
assert_eq!(view.root_id, ROOT_COMPOSITION_ID);
}
#[test]
fn composition_title_spans_every_facet() {
let mut view = view_two_plots().title("Fuel economy");
let mut scene = crate::scene::recording::RecordingScene::default();
view.render(&mut scene, Size::new(600.0, 400.0), 96.0);
let layout = view.last_layout.as_ref().unwrap();
let title = layout
.get(ROOT_COMPOSITION_ID, crate::composition::Slot::Title)
.expect("composition title rect");
let a = layout.get("a", crate::composition::Slot::Panel).unwrap();
let b = layout.get("b", crate::composition::Slot::Panel).unwrap();
assert!(title.y1 > title.y0, "title row has height");
assert!(
title.x0 <= a.x0 && title.x1 >= b.x1,
"title {title:?} should span both facet panels {a:?} / {b:?}"
);
assert!(
a.y0 >= title.y1,
"both panels sit below the title band, got panel y0 {} vs title y1 {}",
a.y0,
title.y1
);
}
#[test]
fn composition_title_is_drawn() {
let mut bare = view_two_plots();
let mut scene_bare = crate::scene::recording::RecordingScene::default();
bare.render(&mut scene_bare, Size::new(600.0, 400.0), 96.0);
let mut titled = view_two_plots().title("Fuel economy");
let mut scene_titled = crate::scene::recording::RecordingScene::default();
titled.render(&mut scene_titled, Size::new(600.0, 400.0), 96.0);
let glyphs = |s: &crate::scene::recording::RecordingScene| {
s.ops
.iter()
.filter(|op| matches!(op, crate::scene::recording::Op::DrawGlyphs(_)))
.count()
};
assert!(
glyphs(&scene_titled) > glyphs(&scene_bare),
"the composition title should emit glyphs"
);
}
#[test]
fn composition_chrome_uses_the_caller_id() {
let mut view = PlotComposition::new(&comp_two().id("outer"))
.with_plot(crate::plot::Plot::new(&comp_two(), "a"))
.title("Shared");
let mut scene = crate::scene::recording::RecordingScene::default();
view.render(&mut scene, Size::new(600.0, 400.0), 96.0);
let layout = view.last_layout.as_ref().unwrap();
assert!(layout
.get("outer", crate::composition::Slot::Title)
.is_some());
assert!(layout
.get(ROOT_COMPOSITION_ID, crate::composition::Slot::Title)
.is_none());
}
#[test]
fn composition_axis_title_spans_every_facet() {
let mut view = view_two_plots().axis_title(AxisSide::Bottom, "Displacement (l)");
let mut scene = crate::scene::recording::RecordingScene::default();
view.render(&mut scene, Size::new(600.0, 400.0), 96.0);
let layout = view.last_layout.as_ref().unwrap();
let rect = layout
.get(
ROOT_COMPOSITION_ID,
crate::composition::Slot::AxisBottomTitle,
)
.expect("composition axis title rect");
let a = layout.get("a", crate::composition::Slot::Panel).unwrap();
let b = layout.get("b", crate::composition::Slot::Panel).unwrap();
assert!(rect.y1 > rect.y0, "axis title row has height");
assert!(rect.x0 <= a.x0 && rect.x1 >= b.x1, "spans both panels");
assert_eq!(
view.axis_title_at(AxisSide::Bottom),
Some("Displacement (l)")
);
}
#[test]
fn composition_legend_reserves_the_ring_once() {
use crate::plot::chrome::legend::{Legend, LegendKeySpec};
use crate::scales::chrome::LegendSide;
let scale = scale::discrete(vec![
crate::scales::value::Value::from("a"),
crate::scales::value::Value::from("b"),
])
.range_colors([
crate::color::Color::new([1.0, 0.0, 0.0, 1.0]),
crate::color::Color::new([0.0, 0.0, 1.0, 1.0]),
]);
let mut view = view_two_plots().add_scale("cat", scale);
view.add_legend(
Legend::new("cat")
.side(LegendSide::Right)
.title("Category")
.key(LegendKeySpec::point().scaled("fill", "cat")),
);
let mut scene = crate::scene::recording::RecordingScene::default();
view.render(&mut scene, Size::new(600.0, 400.0), 96.0);
let layout = view.last_layout.as_ref().unwrap();
let legend = layout
.get(ROOT_COMPOSITION_ID, crate::composition::Slot::LegendRight)
.expect("composition legend rect");
let b = layout.get("b", crate::composition::Slot::Panel).unwrap();
assert!(legend.x1 > legend.x0, "legend column has width");
assert!(
legend.x0 >= b.x1 - 0.5,
"the composition legend sits outside the rightmost panel, got legend x0 {} vs panel x1 {}",
legend.x0,
b.x1
);
assert!(layout
.get("a", crate::composition::Slot::LegendRight)
.is_none());
assert_eq!(view.legends().len(), 1);
}
#[test]
fn composition_collapses_compatible_keys_at_render_time() {
use crate::plot::chrome::legend::{collapse_legends, Legend, LegendBody, LegendKeySpec};
use crate::scales::chrome::LegendSide;
let mut view = view_two_plots();
let one = view.add_legend(
Legend::new("cat")
.side(LegendSide::Right)
.title("Category")
.key(LegendKeySpec::line().scaled("stroke", "cat")),
);
let two = view.add_legend(
Legend::new("cat")
.side(LegendSide::Right)
.title("Category")
.key(LegendKeySpec::point().scaled("fill", "cat")),
);
assert_ne!(one, two, "each attach gets its own id");
assert_eq!(view.legends().len(), 2, "storage keeps both as attached");
let collapsed = collapse_legends(view.legends(), view.scales(), &Theme::default().locale);
assert_eq!(collapsed.len(), 1, "compatible legends render as one");
let LegendBody::Stack(stack) = &collapsed[0].body else {
panic!("stack body");
};
assert_eq!(stack.keys.len(), 2);
}
#[test]
fn composition_collapses_scales_trained_alike_after_attach() {
use crate::plot::chrome::legend::{collapse_legends, Legend, LegendKeySpec};
use crate::plot::scale;
use crate::scales::chrome::LegendSide;
use crate::scales::value::Value;
let cats: Vec<Value> = ["A", "B"]
.iter()
.map(|s| Value::String(std::sync::Arc::from(*s)))
.collect();
let mut view = view_two_plots()
.add_scale("cat_color", scale::discrete(cats.clone()))
.add_scale("cat_shape", scale::discrete(cats));
view.add_legend(
Legend::new("cat_color")
.side(LegendSide::Right)
.title("Category")
.key(LegendKeySpec::rect().scaled("fill", "cat_color")),
);
view.add_legend(
Legend::new("cat_shape")
.side(LegendSide::Right)
.title("Category")
.key(LegendKeySpec::point().scaled("shape", "cat_shape")),
);
let locale = Theme::default().locale;
assert_eq!(
collapse_legends(view.legends(), view.scales(), &locale).len(),
1,
"distinct scales over the same domain share one legend"
);
view.update_scale("cat_shape", |s| {
*s = scale::discrete([Value::String(std::sync::Arc::from("A"))]);
});
assert_eq!(
collapse_legends(view.legends(), view.scales(), &locale).len(),
2,
"collapse re-evaluates against the current scale state"
);
}
#[test]
fn composition_rejects_in_panel_legends() {
use crate::plot::chrome::legend::Legend;
use crate::plot::plot::PlotError;
use crate::scales::chrome::{Anchor, LegendSide};
let in_panel = || {
Legend::new("cat").side(LegendSide::InPanel {
anchor: Anchor::TopRight,
inset_pt: 6.0,
})
};
let mut view = view_two_plots();
assert_eq!(
view.try_add_legend(in_panel()),
Err(PlotError::InPanelLegendUnsupported)
);
assert!(
view.legends().is_empty(),
"a rejected legend must not be stored"
);
let mut view = view_two_plots();
let panicked =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| view.add_legend(in_panel())));
assert!(panicked.is_err(), "the convenience form still panics");
}
#[test]
fn clearing_composition_chrome_releases_its_band() {
let mut view = view_two_plots().title("Fuel economy");
let mut scene = crate::scene::recording::RecordingScene::default();
view.render(&mut scene, Size::new(600.0, 400.0), 96.0);
let titled_panel = view
.last_layout
.as_ref()
.unwrap()
.get("a", crate::composition::Slot::Panel)
.unwrap();
view.clear_title();
view.render(&mut scene, Size::new(600.0, 400.0), 96.0);
let bare_panel = view
.last_layout
.as_ref()
.unwrap()
.get("a", crate::composition::Slot::Panel)
.unwrap();
assert!(
bare_panel.y0 < titled_panel.y0,
"clearing the title should give the row back to the panel"
);
}
#[test]
fn composition_aspect_survives_alongside_chrome() {
let shape = || {
Composition::empty(1, 1)
.place(1, 1, Span::cell(), CompPatch::new("a"))
.aspect(1.0, 1.0)
};
let mut view = PlotComposition::new(&shape())
.with_plot(crate::plot::Plot::new(&shape(), "a"))
.title("Wide canvas");
let mut scene = crate::scene::recording::RecordingScene::default();
view.render(&mut scene, Size::new(900.0, 400.0), 96.0);
let layout = view.last_layout.as_ref().unwrap();
let panel = layout.get("a", crate::composition::Slot::Panel).unwrap();
let (pw, ph) = (panel.x1 - panel.x0, panel.y1 - panel.y0);
assert!(
(pw - ph).abs() < 2.0,
"panel should stay square under a 900x400 canvas, got {pw} x {ph}"
);
assert!(layout
.get(ROOT_COMPOSITION_ID, crate::composition::Slot::Title)
.is_some());
}
#[test]
fn validate_flags_missing_scale() {
let view = PlotComposition::new(&comp_two())
.with_plot(crate::plot::Plot::new(&comp_two(), "a").bind("x", "nope"));
let issues = view.validate();
assert_eq!(issues.len(), 1);
match &issues[0] {
ValidationIssue::MissingScale {
plot_id,
channel,
scale_name,
} => {
assert_eq!(plot_id, "a");
assert_eq!(channel, "x");
assert_eq!(scale_name, "nope");
}
other => panic!("unexpected issue: {other:?}"),
}
}
#[test]
fn validate_flags_output_type_mismatch() {
let numeric = scale::continuous(0.0..=10.0).range_numbers([0.0, 1.0]);
let view = PlotComposition::new(&comp_two())
.add_scale("size_scale", numeric)
.with_plot(crate::plot::Plot::new(&comp_two(), "a").bind("fill", "size_scale"));
let issues = view.validate();
assert!(
issues.iter().any(|i| matches!(
i,
ValidationIssue::OutputTypeMismatch { channel, found, .. }
if channel == "fill" && *found == "Numbers"
)),
"expected OutputTypeMismatch; got {issues:?}"
);
}
#[test]
fn validate_flags_linetype_bound_to_non_linetype_scale() {
let s = scale::ordinal(["a", "b"]).range_strings([
std::sync::Arc::from("solid"),
std::sync::Arc::from("dashed"),
]);
let view = PlotComposition::new(&comp_two())
.add_scale("dash_scale", s)
.with_plot(crate::plot::Plot::new(&comp_two(), "a").bind("linetype", "dash_scale"));
let issues = view.validate();
assert!(
issues.iter().any(|i| matches!(
i,
ValidationIssue::OutputTypeMismatch { channel, found, .. }
if channel == "linetype" && *found == "Strings"
)),
"expected OutputTypeMismatch for linetype; got {issues:?}"
);
}
#[test]
fn validate_empty_when_everything_resolves() {
let view = PlotComposition::new(&comp_two())
.add_scale("time", scale::continuous(0.0..=100.0))
.add_scale(
"cat",
scale::ordinal(["a", "b"]).range_colors([
crate::color::Color::new([1.0, 0.0, 0.0, 1.0]),
crate::color::Color::new([0.0, 1.0, 0.0, 1.0]),
]),
)
.with_plot(
crate::plot::Plot::new(&comp_two(), "a")
.bind("x", "time")
.bind("fill", "cat"),
);
assert_eq!(view.validate(), vec![]);
}
}