use std::collections::{HashMap, HashSet};
use crate::GeometryPathCommand;
use super::model::ChartSpace;
pub const CHART_AXIS_COLOR: &str = "#666666";
pub const CHART_GRID_COLOR: &str = "#D9D9D9";
pub const CHART_TEXT_COLOR: &str = "#222222";
pub const CHART_BACKGROUND_COLOR: &str = "#FFFFFF";
const DEFAULT_MARKER_PT: f64 = 7.0;
const EMU_PER_PIXEL: f64 = 9525.0;
const MAX_LINE_PX: f64 = 16.0;
const DEFAULT_SERIES_LINE_PX: f64 = 2.0;
pub const CHART_SERIES_COLORS: [&str; 8] = [
"#4472C4", "#ED7D31", "#A5A5A5", "#FFC000", "#5B9BD5", "#70AD47", "#264478", "#9E480E",
];
pub const CHART_FONT_FAMILY: &str = "Calibri, sans-serif";
pub const CHART_LABEL_SIZE_PX: f64 = 10.0;
pub const CHART_TITLE_SIZE_PX: f64 = 13.0;
pub fn chart_label_font() -> PlotFont {
PlotFont {
weight: 400,
size_px: CHART_LABEL_SIZE_PX,
family: CHART_FONT_FAMILY.to_owned(),
italic: false,
letter_spacing_px: 0.0,
}
}
pub fn chart_title_font() -> PlotFont {
PlotFont {
weight: 600,
size_px: CHART_TITLE_SIZE_PX,
family: CHART_FONT_FAMILY.to_owned(),
italic: false,
letter_spacing_px: 0.0,
}
}
pub const MAX_PLOT_OPS: usize = 100_000;
pub const MAX_PLOT_DATA_SCAN: usize = 200_000;
pub const MAX_PLOT_GROUPS: usize = 64;
pub const MAX_PLOT_SERIES: usize = 1_024;
pub const MAX_PLOT_AXIS_TICKS: usize = 64;
pub const MAX_PLOT_SURFACE_CELLS: usize = 16_384;
pub const MAX_PLOT_DATA_LABELS: usize = 4_096;
pub const MAX_PLOT_POLYGON_POINTS: usize = 8_192;
pub const MAX_PLOT_COORD: f64 = 1e9;
const MAX_LABEL_CHARS: usize = 120;
const MAX_LEGEND_ENTRIES: usize = 8;
const AXIS_HEADER: f64 = 18.0;
const LEGEND_KEY_EM: f64 = 0.53;
const LEGEND_KEY_GAP_EM: f64 = 0.32;
const LEGEND_PITCH_EM: f64 = 1.53;
const LEGEND_BASELINE_EM: f64 = 0.24;
const LEGEND_PLOT_GAP_EM: f64 = 1.0;
const LEGEND_EDGE: f64 = 13.0;
const LEGEND_ROW_EM: f64 = 1.4;
const LEGEND_ENTRY_GAP_EM: f64 = 0.65;
const MIN_PLOT: f64 = 24.0;
const LEGEND_TEXT_SLACK: f64 = LEGEND_EDGE;
const BAND_GAP: f64 = 12.0;
const TITLE_LINE_EM: f64 = 1.22;
const TITLE_ASCENT_EM: f64 = 0.95;
#[derive(Clone, Debug, PartialEq)]
pub struct PlotFont {
pub weight: u16,
pub size_px: f64,
pub family: String,
pub italic: bool,
pub letter_spacing_px: f64,
}
impl PlotFont {
pub fn css(&self) -> String {
let style = if self.italic { "italic " } else { "" };
format!("{style}{} {}px {}", self.weight, self.size_px, self.family)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PlotTextStyle<'a> {
pub font: Option<&'a str>,
pub size_pt: Option<f64>,
pub bold: Option<bool>,
pub italic: Option<bool>,
pub color: Option<&'a str>,
pub spacing_pt: Option<f64>,
}
impl<'a> PlotTextStyle<'a> {
fn over(self, base: PlotTextStyle<'a>) -> Self {
Self {
font: self.font.or(base.font),
size_pt: self.size_pt.or(base.size_pt),
bold: self.bold.or(base.bold),
italic: self.italic.or(base.italic),
color: self.color.or(base.color),
spacing_pt: self.spacing_pt.or(base.spacing_pt),
}
}
fn resolve(self, size_px: f64, weight: u16) -> ResolvedText {
ResolvedText {
font: PlotFont {
weight: match self.bold {
Some(true) => 700,
Some(false) => 400,
None => weight,
},
size_px: self
.size_pt
.filter(|size| size.is_finite() && *size > 0.0)
.map(|size| (size * 4.0 / 3.0).clamp(1.0, 400.0))
.unwrap_or(size_px),
family: self
.font
.filter(|font| !font.is_empty())
.map(str::to_owned)
.unwrap_or_else(|| CHART_FONT_FAMILY.to_owned()),
italic: self.italic.unwrap_or(false),
letter_spacing_px: self
.spacing_pt
.filter(|spacing| spacing.is_finite())
.map(|spacing| (spacing * 4.0 / 3.0).clamp(-400.0, 400.0))
.unwrap_or(0.0),
},
color: self
.color
.filter(|color| !color.is_empty())
.map(hex)
.unwrap_or_else(|| CHART_TEXT_COLOR.to_owned()),
}
}
}
#[derive(Clone, Debug, PartialEq)]
struct ResolvedText {
font: PlotFont,
color: String,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PlotChartText<'a> {
pub chart: PlotTextStyle<'a>,
pub title: PlotTextStyle<'a>,
pub legend: PlotTextStyle<'a>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PlotRect {
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PlotStroke {
pub color: String,
pub width: f64,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PlotTextAlign {
#[default]
Start,
Center,
}
#[derive(Clone, Debug, PartialEq)]
pub enum PlotOp {
Rect {
x: f64,
y: f64,
w: f64,
h: f64,
fill: String,
},
Text {
text: String,
x: f64,
baseline_y: f64,
width: f64,
font: PlotFont,
color: String,
align: PlotTextAlign,
rotation_deg: f64,
},
Line {
x1: f64,
y1: f64,
x2: f64,
y2: f64,
color: String,
width: f64,
},
Path {
x: f64,
y: f64,
w: f64,
h: f64,
commands: Vec<GeometryPathCommand>,
fill: String,
stroke: Option<PlotStroke>,
},
}
pub trait PlotSink {
fn accepts_more(&mut self) -> bool {
true
}
fn measure_text(&mut self, _text: &str, _font: &PlotFont) -> Option<f64> {
None
}
fn push_op(&mut self, op: PlotOp) -> bool;
fn turns_text(&self) -> bool {
false
}
}
impl PlotSink for Vec<PlotOp> {
fn push_op(&mut self, op: PlotOp) -> bool {
self.push(op);
true
}
fn turns_text(&self) -> bool {
true
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PlotChart<'a> {
pub chart_type: &'a str,
pub title: Option<&'a str>,
pub legend: Option<PlotLegend<'a>>,
pub value_axis: Option<PlotAxisRange>,
pub axis_titles: PlotAxisTitles<'a>,
pub series: Vec<PlotSeries<'a>>,
pub plot_groups: Vec<PlotGroup<'a>>,
pub text: PlotChartText<'a>,
pub axes: Vec<PlotAxis<'a>>,
pub fill: Option<PlotFill<'a>>,
pub plot_layout: Option<PlotRect>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum PlotFill<'a> {
None,
Solid(&'a str),
Pattern {
foreground: Option<&'a str>,
background: Option<&'a str>,
},
Gradient(&'a [String]),
Unsupported,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PlotLine<'a> {
pub none: bool,
pub color: Option<&'a str>,
pub width_emu: Option<f64>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PlotAxisTitles<'a> {
pub category: Option<&'a str>,
pub value: Option<&'a str>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PlotLegend<'a> {
pub position: Option<&'a str>,
pub visible: Option<bool>,
pub overlay: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PlotAxisRange {
pub min: Option<f64>,
pub max: Option<f64>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PlotAxisKind {
#[default]
Category,
Value,
Date,
Series,
}
impl PlotAxisKind {
pub fn from_name(name: &str) -> Self {
match name {
"value" => Self::Value,
"date" => Self::Date,
"series" => Self::Series,
_ => Self::Category,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PlotAxis<'a> {
pub id: Option<&'a str>,
pub kind: PlotAxisKind,
pub range: PlotAxisRange,
pub log_base: Option<f64>,
pub reversed: bool,
pub major_unit: Option<f64>,
pub minor_unit: Option<f64>,
pub major_tick_mark: Option<&'a str>,
pub minor_tick_mark: Option<&'a str>,
pub major_gridlines: bool,
pub minor_gridlines: bool,
pub major_gridline: Option<PlotLine<'a>>,
pub minor_gridline: Option<PlotLine<'a>>,
pub cross_between: Option<&'a str>,
pub number_format: Option<&'a str>,
pub position: Option<&'a str>,
pub title: Option<&'a str>,
pub hidden: bool,
pub text: PlotTextStyle<'a>,
pub line: Option<PlotLine<'a>>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PlotGroup<'a> {
pub chart_type: Option<&'a str>,
pub grouping: Option<&'a str>,
pub series: Vec<PlotSeries<'a>>,
pub overlap: Option<f64>,
pub gap_width: Option<f64>,
pub hole_size: Option<f64>,
pub first_slice_angle: Option<f64>,
pub vary_colors: bool,
pub scatter_style: Option<&'a str>,
pub radar_style: Option<&'a str>,
pub bubble_scale: Option<f64>,
pub size_represents: Option<&'a str>,
pub wireframe: Option<bool>,
pub hi_low_lines: bool,
pub up_down_bars: bool,
pub markers: Option<bool>,
pub axis_ids: Vec<&'a str>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PlotSeries<'a> {
pub name: Option<&'a str>,
pub categories: &'a [String],
pub values: &'a [f64],
pub color: Option<&'a str>,
pub points: Vec<PlotPoint<'a>>,
pub grouping: Option<&'a str>,
pub marker: Option<PlotMarker>,
pub x_values: &'a [f64],
pub bubble_sizes: &'a [f64],
pub smooth: bool,
pub labels: Option<PlotDataLabels<'a>>,
pub line: Option<PlotLine<'a>>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PlotPoint<'a> {
pub index: Option<usize>,
pub value: Option<f64>,
pub color: Option<&'a str>,
pub marker: Option<PlotMarker>,
pub label: Option<&'a str>,
pub label_runs: Option<&'a [super::model::ChartLabelRun]>,
pub explosion: Option<f64>,
pub labels: Option<PlotDataLabels<'a>>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PlotDataLabels<'a> {
pub show_value: bool,
pub show_category_name: bool,
pub show_series_name: bool,
pub show_percent: bool,
pub show_legend_key: bool,
pub show_bubble_size: bool,
pub separator: Option<&'a str>,
pub position: Option<&'a str>,
pub number_format: Option<&'a str>,
pub text: PlotTextStyle<'a>,
}
impl PlotDataLabels<'_> {
pub fn shows_anything(&self) -> bool {
self.show_value
|| self.show_category_name
|| self.show_series_name
|| self.show_percent
|| self.show_bubble_size
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PlotMarker {
pub size: Option<f64>,
pub symbol: Option<PlotMarkerSymbol>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PlotMarkerSymbol {
Auto,
None,
Circle,
Dash,
Diamond,
Dot,
Plus,
Square,
Star,
Triangle,
X,
}
impl PlotMarkerSymbol {
pub fn from_name(name: &str) -> Option<Self> {
Some(match name {
"auto" => Self::Auto,
"none" => Self::None,
"circle" => Self::Circle,
"dash" => Self::Dash,
"diamond" => Self::Diamond,
"dot" => Self::Dot,
"plus" => Self::Plus,
"square" => Self::Square,
"star" => Self::Star,
"triangle" => Self::Triangle,
"x" => Self::X,
_ => return None,
})
}
fn resolved(self, index: usize) -> Self {
const CYCLE: [PlotMarkerSymbol; 8] = [
PlotMarkerSymbol::Diamond,
PlotMarkerSymbol::Square,
PlotMarkerSymbol::Triangle,
PlotMarkerSymbol::X,
PlotMarkerSymbol::Star,
PlotMarkerSymbol::Dot,
PlotMarkerSymbol::Plus,
PlotMarkerSymbol::Dash,
];
match self {
Self::Auto => CYCLE[index % CYCLE.len()],
symbol => symbol,
}
}
}
impl<'a> From<&'a ChartSpace> for PlotChart<'a> {
fn from(space: &'a ChartSpace) -> Self {
Self {
chart_type: &space.chart_type,
title: space.title.as_deref(),
legend: Some(space.legend.as_ref().map_or(
PlotLegend {
position: None,
visible: Some(false),
overlay: false,
},
|legend| PlotLegend {
position: legend.position.as_deref(),
visible: Some(legend.visible),
overlay: legend.overlay,
},
)),
value_axis: space
.axes
.as_ref()
.and_then(|axes| axes.value.as_ref())
.map(|axis| PlotAxisRange {
min: axis.min,
max: axis.max,
}),
axis_titles: PlotAxisTitles {
category: space
.axes
.as_ref()
.and_then(|axes| axes.category.as_ref())
.and_then(|axis| axis.title.as_deref()),
value: space
.axes
.as_ref()
.and_then(|axes| axes.value.as_ref())
.and_then(|axis| axis.title.as_deref()),
},
series: space
.series
.iter()
.map(|series| plot_series_from_model(series, None))
.collect(),
plot_groups: space
.plot_groups
.iter()
.map(|group| PlotGroup {
chart_type: group.chart_type.as_deref(),
grouping: group.grouping.as_deref(),
series: group
.series
.iter()
.map(|series| plot_series_from_model(series, group.data_labels.as_ref()))
.collect(),
overlap: group.overlap,
gap_width: group.gap_width,
hole_size: group.hole_size,
first_slice_angle: group.first_slice_angle,
vary_colors: group.vary_colors,
scatter_style: group.scatter_style.as_deref(),
radar_style: group.radar_style.as_deref(),
bubble_scale: group.bubble_scale,
size_represents: group.size_represents.as_deref(),
wireframe: group.wireframe,
hi_low_lines: group.hi_low_lines,
up_down_bars: group.up_down_bars,
markers: group.marker,
axis_ids: group.axis_ids.iter().map(String::as_str).collect(),
})
.collect(),
axes: space
.axis_list
.iter()
.flatten()
.map(plot_axis_from_model)
.collect(),
text: PlotChartText {
chart: plot_text_from_model(space.text.as_ref()),
title: plot_text_from_model(space.title_text.as_ref()),
legend: plot_text_from_model(
space
.legend
.as_ref()
.and_then(|legend| legend.text.as_ref()),
),
},
fill: space.fill.as_ref().map(plot_fill_from_model),
plot_layout: space.plot_layout.map(|layout| PlotRect {
x: layout.x,
y: layout.y,
w: layout.w,
h: layout.h,
}),
}
}
}
fn plot_fill_from_model(fill: &super::model::ChartFill) -> PlotFill<'_> {
match fill {
super::model::ChartFill::None => PlotFill::None,
super::model::ChartFill::Solid { color } => PlotFill::Solid(color),
super::model::ChartFill::Pattern {
foreground,
background,
} => PlotFill::Pattern {
foreground: foreground.as_deref(),
background: background.as_deref(),
},
super::model::ChartFill::Gradient { colors } => PlotFill::Gradient(colors),
super::model::ChartFill::Unsupported => PlotFill::Unsupported,
}
}
fn plot_text_from_model(text: Option<&super::model::ChartTextProperties>) -> PlotTextStyle<'_> {
text.map(|text| PlotTextStyle {
font: text.font.as_deref(),
size_pt: text.size_pt,
bold: text.bold,
italic: text.italic,
color: text.color.as_deref(),
spacing_pt: text.spacing_pt,
})
.unwrap_or_default()
}
fn plot_axis_from_model(axis: &super::model::ChartAxis) -> PlotAxis<'_> {
PlotAxis {
id: axis.id.as_deref(),
kind: PlotAxisKind::from_name(&axis.axis_type),
range: PlotAxisRange {
min: axis.min,
max: axis.max,
},
log_base: axis.logarithmic_base,
reversed: axis.reversed,
major_unit: axis.major_unit,
minor_unit: axis.minor_unit,
major_tick_mark: axis.major_tick_mark.as_deref(),
minor_tick_mark: axis.minor_tick_mark.as_deref(),
major_gridlines: axis.major_gridlines,
minor_gridlines: axis.minor_gridlines,
major_gridline: axis.major_gridline_line.as_ref().map(plot_line_from_model),
minor_gridline: axis.minor_gridline_line.as_ref().map(plot_line_from_model),
cross_between: axis.cross_between.as_deref(),
number_format: axis.number_format.as_deref(),
position: axis.position.as_deref(),
title: axis.title.as_deref(),
hidden: axis.hidden,
text: plot_text_from_model(axis.text.as_ref()),
line: axis.line.as_ref().map(plot_line_from_model),
}
}
fn plot_line_from_model(line: &super::model::ChartLine) -> PlotLine<'_> {
PlotLine {
none: line.none,
color: line.color.as_deref(),
width_emu: line.width_emu,
}
}
fn plot_series_from_model<'a>(
series: &'a super::model::ChartSeries,
group_labels: Option<&'a super::model::ChartDataLabels>,
) -> PlotSeries<'a> {
let mut labels = plot_labels_from_model(None, series.data_labels.as_ref(), None, group_labels);
let cached = series.value_format.as_deref();
if let Some(spec) = labels.as_mut()
&& spec.number_format.is_none()
{
spec.number_format = cached;
}
let mut points: Vec<PlotPoint<'a>> = series
.points
.iter()
.flatten()
.filter_map(|point| {
Some(PlotPoint {
index: match point.index {
Some(index) => Some(point_index(index)?),
None => None,
},
color: Some(&point.color),
explosion: point.explosion,
labels,
..PlotPoint::default()
})
})
.collect();
merge_point_labels(&mut points, group_labels, series.data_labels.as_ref());
for point in &mut points {
if let Some(spec) = point.labels.as_mut()
&& spec.number_format.is_none()
{
spec.number_format = cached;
}
}
PlotSeries {
name: series.name.as_deref(),
categories: &series.categories,
values: &series.values,
color: Some(&series.color),
points,
grouping: series.grouping.as_deref(),
marker: series.marker.as_ref().map(plot_marker_from_model),
x_values: series.x_values.as_deref().unwrap_or_default(),
bubble_sizes: series.bubble_sizes.as_deref().unwrap_or_default(),
smooth: series.smooth.unwrap_or(false),
labels,
line: series.line.as_ref().map(|line| PlotLine {
none: line.none,
color: line.color.as_deref(),
width_emu: line.width_emu,
}),
}
}
fn plot_labels_from_model<'a>(
series_point: Option<&'a super::model::ChartDataLabels>,
series: Option<&'a super::model::ChartDataLabels>,
group_point: Option<&'a super::model::ChartDataLabels>,
group: Option<&'a super::model::ChartDataLabels>,
) -> Option<PlotDataLabels<'a>> {
let levels = [series_point, series, group_point, group];
levels.iter().copied().flatten().next()?;
let flag = |read: fn(&super::model::ChartDataLabels) -> Option<bool>| {
levels
.iter()
.copied()
.find_map(|labels| labels.and_then(read))
};
if flag(|labels| labels.delete) == Some(true) {
return None;
}
let switch =
|read: fn(&super::model::ChartDataLabels) -> Option<bool>| flag(read).unwrap_or(false);
let text = |read: fn(&super::model::ChartDataLabels) -> Option<&str>| {
levels
.iter()
.copied()
.find_map(|labels| labels.and_then(read))
};
let mut style = PlotTextStyle::default();
for labels in levels.iter().rev().flatten() {
style = plot_text_from_model(labels.text.as_ref()).over(style);
}
Some(PlotDataLabels {
show_value: switch(|labels| labels.show_value),
show_category_name: switch(|labels| labels.show_category_name),
show_series_name: switch(|labels| labels.show_series_name),
show_percent: switch(|labels| labels.show_percent),
show_legend_key: switch(|labels| labels.show_legend_key),
show_bubble_size: switch(|labels| labels.show_bubble_size),
separator: text(|labels| labels.separator.as_deref()),
position: text(|labels| labels.position.as_deref()),
number_format: text(|labels| labels.number_format.as_deref()),
text: style,
})
}
fn merge_point_labels<'a>(
points: &mut Vec<PlotPoint<'a>>,
group: Option<&'a super::model::ChartDataLabels>,
series: Option<&'a super::model::ChartDataLabels>,
) {
let group_overrides = group
.and_then(|labels| labels.points.as_deref())
.unwrap_or_default();
let series_overrides = series
.and_then(|labels| labels.points.as_deref())
.unwrap_or_default();
let mut group_points = HashMap::new();
for over in group_overrides.iter().take(MAX_PLOT_DATA_LABELS) {
if let Some(index) = over.index.and_then(point_index) {
group_points.entry(index).or_insert(over);
}
}
let mut series_points = HashSet::new();
let wildcard = points.iter().position(|point| point.index.is_none());
for over in series_overrides.iter().take(MAX_PLOT_DATA_LABELS) {
let Some(index) = over.index.and_then(point_index) else {
continue;
};
series_points.insert(index);
merge_point_label(
points,
wildcard,
index,
group_points.get(&index).copied(),
Some(over),
group,
series,
);
}
let mut merged_group_points = HashSet::new();
for over in group_overrides.iter().take(MAX_PLOT_DATA_LABELS) {
let Some(index) = over.index.and_then(point_index) else {
continue;
};
if !series_points.contains(&index) && merged_group_points.insert(index) {
merge_point_label(points, wildcard, index, Some(over), None, group, series);
}
}
}
#[allow(clippy::too_many_arguments)]
fn merge_point_label<'a>(
points: &mut Vec<PlotPoint<'a>>,
wildcard: Option<usize>,
index: usize,
group_point: Option<&'a super::model::ChartPointLabel>,
series_point: Option<&'a super::model::ChartPointLabel>,
group: Option<&'a super::model::ChartDataLabels>,
series: Option<&'a super::model::ChartDataLabels>,
) {
let resolved = plot_labels_from_model(
series_point.map(|point| &point.labels),
series,
group_point.map(|point| &point.labels),
group,
);
let label = resolved.and_then(|_| {
series_point
.and_then(|point| point.text.as_deref())
.or_else(|| group_point.and_then(|point| point.text.as_deref()))
});
let label_runs = resolved.and_then(|_| {
series_point
.and_then(|point| point.runs.as_deref())
.or_else(|| group_point.and_then(|point| point.runs.as_deref()))
});
let labels = Some(resolved.unwrap_or_default());
if let Some(slot) = points
.iter()
.position(|point| point.index == Some(index))
.filter(|slot| wildcard.is_none_or(|wildcard| *slot <= wildcard))
{
points[slot].label = label;
points[slot].label_runs = label_runs;
points[slot].labels = labels;
return;
}
let mut point = PlotPoint {
index: Some(index),
label,
label_runs,
labels,
..PlotPoint::default()
};
match wildcard {
Some(wildcard) => {
point.color = points[wildcard].color;
point.marker = points[wildcard].marker;
points.insert(wildcard, point);
}
None => points.push(point),
}
}
fn plot_marker_from_model(marker: &super::model::ChartMarker) -> PlotMarker {
PlotMarker {
size: marker.size,
symbol: marker
.symbol
.as_deref()
.and_then(PlotMarkerSymbol::from_name),
}
}
fn point_index(index: f64) -> Option<usize> {
(index.is_finite() && index >= 0.0 && index.fract() == 0.0 && index <= f64::from(u32::MAX))
.then_some(index as usize)
}
fn finite(value: f64) -> f64 {
if value.is_finite() {
value.clamp(-MAX_PLOT_COORD, MAX_PLOT_COORD)
} else {
0.0
}
}
pub fn plot_chart_into<S: PlotSink + ?Sized>(chart: &PlotChart<'_>, rect: PlotRect, sink: &mut S) {
let (x, y, width, height) = (
finite(rect.x),
finite(rect.y),
finite(rect.w),
finite(rect.h),
);
let ops = &mut Emitter {
sink,
remaining: MAX_PLOT_OPS,
};
let scan = &mut ScanBudget::new();
let chart_text = chart.text.chart;
let label_style = &chart_text.resolve(CHART_LABEL_SIZE_PX, 400);
let legend_style = &chart
.text
.legend
.over(chart_text)
.resolve(CHART_LABEL_SIZE_PX, 400);
match chart.fill {
Some(PlotFill::None) => {}
fill => {
let ground = fill.and_then(plot_fill_color);
push_rect(
ops,
x,
y,
width,
height,
ground.as_deref().unwrap_or(CHART_BACKGROUND_COLOR),
);
}
}
let title_h = if let Some(title) = chart.title.filter(|s| !s.is_empty()) {
let mut style = chart
.text
.title
.over(chart_text)
.resolve(CHART_TITLE_SIZE_PX, 600);
let fits = (height / 2.0 - CHART_PAD - BAND_GAP) / TITLE_LINE_EM;
style.font.size_px = style.font.size_px.min(fits.max(1.0));
let size = style.font.size_px;
push_text_aligned(
ops,
title,
x + CHART_PAD,
y + CHART_PAD + TITLE_ASCENT_EM * size,
(width - 2.0 * CHART_PAD).max(0.0),
&style,
PlotTextAlign::Center,
);
CHART_PAD + TITLE_LINE_EM * size + BAND_GAP
} else {
CHART_PAD
};
let views: Vec<Vec<SeriesView<'_>>> = if chart.plot_groups.is_empty() {
vec![series_views(&chart.series, scan)]
} else {
chart
.plot_groups
.iter()
.take(MAX_PLOT_GROUPS)
.map(|group| series_views(&group.series, scan))
.collect()
};
let families = plot_families(chart, &views, label_style);
let bands = families
.iter()
.copied()
.filter(|family| has_axes(family.chart_type) && !family.secondary)
.map(|family| axis_bands(ops, Some(family), width))
.reduce(|a, b| AxisBands {
left: a.left.max(b.left),
top: a.top.max(b.top),
right: a.right.max(b.right),
bottom: a.bottom.max(b.bottom),
overhang: a.overhang.max(b.overhang),
})
.unwrap_or_else(|| axis_bands(ops, None, width));
let axis_header =
if has_transposed_category_title(chart) || secondary_value_axis(chart, true).is_some() {
AXIS_HEADER
} else {
0.0
};
let legend_reserves = chart.legend.as_ref().is_none_or(|legend| !legend.overlay);
let plot_floor = legend_reserves.then(|| {
if families.iter().any(|family| has_axes(family.chart_type)) {
MIN_PLOT + axis_header + bands.top + bands.bottom
} else {
MIN_PLOT + CHART_PAD
}
});
let legend_position = chart
.legend
.as_ref()
.and_then(|legend| legend.position)
.unwrap_or("right");
let legend = legend_band(
chart,
legend_position,
PlotRect {
x,
y,
w: width,
h: height,
},
title_h,
plot_floor,
legend_style,
ops,
);
let legend_gap = legend_style.font.size_px * LEGEND_PLOT_GAP_EM;
let (reserve_left, reserve_right) = match &legend {
Some(band) if !band.horizontal && legend_reserves && !band.rows.is_empty() => {
if legend_position == "left" {
(band.x + band.w + legend_gap - x, 0.0)
} else {
(0.0, x + width - band.x + legend_gap)
}
}
_ => (0.0, 0.0),
};
let legend_h = match &legend {
Some(band) if band.horizontal && legend_reserves => band.h,
_ => 0.0,
};
let secondary_w = if secondary_value_axis(chart, false).is_some() {
38.0
} else {
0.0
};
let band_top = if legend_position == "top" && legend_h > 0.0 {
legend_h + BAND_GAP
} else {
0.0
};
let right_margin = if reserve_right > 0.0 {
bands.overhang
} else {
CHART_PAD + bands.right
};
let top = title_h;
let region_x = x + reserve_left.min(width);
let region_w = (width - reserve_left - reserve_right).max(0.0);
let gutter = bands.left;
let plot_x = x + reserve_left + gutter;
let region_y = y + top + band_top;
let region_bottom = if legend_h <= 0.0 {
y + height
} else if legend_position == "bottom" {
y + height - CHART_PAD - legend_h - BAND_GAP
} else {
y + height - CHART_PAD
};
let region_h = (region_bottom - region_y).max(0.0);
let plot = match chart.plot_layout {
Some(manual) => PlotArea {
x: x + manual.x * width,
y: y + manual.y * height,
w: (manual.w * width).max(24.0),
h: (manual.h * height).max(24.0),
gutter,
},
None => {
let plot_y = region_y + axis_header + bands.top;
let bottom = if legend_position == "bottom" && legend_h > 0.0 {
legend_h + BAND_GAP
} else {
0.0
};
let plot_y = plot_y.min(y + height);
PlotArea {
x: plot_x,
y: plot_y,
w: (region_w - gutter - right_margin - secondary_w).max(MIN_PLOT),
h: (y + height - bottom - bands.bottom - plot_y).max(0.0),
gutter,
}
}
};
for family in families {
if ops.exhausted() {
break;
}
emit_family(ops, family, plot, region_x, region_y, region_w, region_h);
}
if let Some(band) = legend {
emit_legend(ops, chart, band, legend_style);
}
}
fn plot_families<'a>(
chart: &'a PlotChart<'a>,
views: &'a [Vec<SeriesView<'a>>],
label: &'a ResolvedText,
) -> Vec<PlotFamily<'a>> {
let chart_text = chart.text.chart;
if chart.plot_groups.is_empty() {
return views
.first()
.map(|series| PlotFamily {
chart_type: chart.chart_type,
series,
value_axis: chart.value_axis,
axis_titles: chart.axis_titles,
group: None,
chart_text,
label,
axis: None,
x_axis: None,
category_axis: chart
.axes
.iter()
.find(|axis| axis.kind != PlotAxisKind::Value),
secondary: false,
})
.into_iter()
.collect();
}
let primary = primary_value_axis(chart);
chart
.plot_groups
.iter()
.zip(views)
.map(|(group, series)| {
let (x_axis, axis) = group_value_axes(chart, group);
let category_axis = group_category_axis(chart, group);
let legacy_axes = chart.axes.is_empty() && group.axis_ids.is_empty();
PlotFamily {
chart_type: group.chart_type.unwrap_or(chart.chart_type),
series,
value_axis: legacy_axes.then_some(chart.value_axis).flatten(),
axis_titles: if legacy_axes {
chart.axis_titles
} else {
PlotAxisTitles {
category: category_axis.and_then(|axis| axis.title),
value: axis.and_then(|axis| axis.title),
}
},
group: Some(group),
chart_text,
label,
axis,
x_axis,
category_axis,
secondary: match (axis, primary) {
(Some(axis), Some(primary)) => axis.id != primary.id,
_ => false,
},
}
})
.collect()
}
fn has_axes(chart_type: &str) -> bool {
!matches!(chart_type, "pie" | "doughnut" | "ofPie" | "radar")
}
const CHART_PAD: f64 = 9.5;
const PLOT_INSET: f64 = 4.6;
const TICK_LEN: f64 = 7.0;
const LABEL_GAP_EM: f64 = 0.6;
const AXIS_TITLE_EM: f64 = 1.4;
const LABEL_LINE_EM: f64 = 1.2;
fn label_gap(style: &ResolvedText) -> f64 {
TICK_LEN + LABEL_GAP_EM * style.font.size_px
}
fn baseline_below(edge: f64, style: &ResolvedText) -> f64 {
edge + label_gap(style) + 0.75 * style.font.size_px
}
fn baseline_centred(middle: f64, style: &ResolvedText) -> f64 {
middle + 0.25 * style.font.size_px
}
struct AxisBands {
left: f64,
top: f64,
right: f64,
bottom: f64,
overhang: f64,
}
fn axis_bands<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
family: Option<PlotFamily<'_>>,
width: f64,
) -> AxisBands {
let empty = AxisBands {
left: CHART_PAD + PLOT_INSET,
top: PLOT_INSET,
right: PLOT_INSET,
bottom: CHART_PAD + PLOT_INSET,
overhang: 0.0,
};
let Some(family) = family else {
return empty;
};
let value_style = family.scoped(family.axis.map(|axis| axis.text).unwrap_or_default());
let values: Vec<f64> = if family.axis.is_some_and(|axis| axis.hidden) {
Vec::new()
} else {
let scale = value_scale(family);
let format = family.axis.and_then(|axis| axis.number_format);
axis_ticks(scale, family.axis.and_then(|axis| axis.major_unit))
.into_iter()
.map(|value| text_width(&scale.format(value, format), &value_style, ops))
.collect()
};
let category_style = family.category_text();
let count = category_count(family.series).min(MAX_PLOT_DATA_SCAN);
let categories: Vec<String> = if family.category_axis.is_some_and(|axis| axis.hidden) {
Vec::new()
} else {
(0..count)
.map(|index| category_label(family.series, index))
.collect()
};
let widest = |ops: &mut Emitter<'_, S>| {
categories
.iter()
.flat_map(|label| label.split('\n'))
.map(|line| text_width(line.trim(), &category_style, ops))
.fold(0.0, f64::max)
};
let lines = categories
.iter()
.map(|label| label.split('\n').count())
.max()
.unwrap_or(0);
let hung = |style: &ResolvedText, lines: usize| {
CHART_PAD
+ label_gap(style)
+ style.font.size_px * (1.0 + LABEL_LINE_EM * lines.saturating_sub(1) as f64)
};
let widest_value = values.iter().copied().fold(0.0, f64::max);
let titled = |title: Option<&str>| {
if title.is_some_and(|title| !title.is_empty()) {
AXIS_TITLE_EM * value_style.font.size_px
} else {
0.0
}
};
let (value_title, category_title) = (
titled(family.axis_titles.value),
titled(family.axis_titles.category),
);
if family.transposed() {
let overhang = values.last().map_or(0.0, |width| width / 2.0);
AxisBands {
left: category_title
+ if categories.is_empty() {
empty.left
} else {
CHART_PAD + widest(ops) + label_gap(&category_style)
},
top: PLOT_INSET,
right: PLOT_INSET + overhang,
bottom: value_title
+ if values.is_empty() {
empty.bottom
} else {
hung(&value_style, 1)
},
overhang,
}
} else {
let x_overhang = if matches!(family.chart_type, "scatter" | "bubble")
&& !family.x_axis.is_some_and(|axis| axis.hidden)
{
let scale = scatter_x_scale(
family,
PlotArea {
x: 0.0,
y: 0.0,
w: width,
h: 0.0,
gutter: 0.0,
},
);
let format = family.x_axis.and_then(|axis| axis.number_format);
axis_ticks(scale, family.x_axis.and_then(|axis| axis.major_unit))
.last()
.map_or(0.0, |value| {
text_width(&scale.format(*value, format), family.label(), ops) / 2.0
})
} else {
0.0
};
AxisBands {
left: value_title
+ if values.is_empty() {
empty.left
} else {
CHART_PAD + widest_value + label_gap(&value_style)
},
top: if values.is_empty() {
PLOT_INSET
} else {
PLOT_INSET.max(0.5 * value_style.font.size_px - 0.7)
},
right: PLOT_INSET + x_overhang,
bottom: category_title
+ if categories.is_empty() {
empty.bottom
} else {
hung(&category_style, lines)
},
overhang: x_overhang,
}
}
}
pub fn plot_chart(chart: &PlotChart<'_>, rect: PlotRect) -> Vec<PlotOp> {
let mut ops = Vec::new();
plot_chart_into(chart, rect, &mut ops);
ops
}
pub fn chart_aria_label(chart: &PlotChart<'_>) -> String {
let kind = if chart.plot_groups.len() > 1 {
"combo chart"
} else {
let family = chart
.plot_groups
.first()
.and_then(|group| group.chart_type)
.unwrap_or(chart.chart_type);
match family {
"bar" => "bar chart",
"line" => "line chart",
"pie" | "ofPie" => "pie chart",
"doughnut" => "doughnut chart",
"area" => "area chart",
"scatter" => "scatter chart",
"bubble" => "bubble chart",
"radar" => "radar chart",
"stock" => "stock chart",
"surface" => "surface chart",
_ => "column chart",
}
};
let title = chart
.title
.filter(|s| !s.is_empty())
.unwrap_or("Untitled chart");
let series_count = if chart.series.is_empty() {
chart
.plot_groups
.iter()
.map(|group| group.series.len())
.sum()
} else {
chart.series.len()
};
let category_count = if chart.series.is_empty() {
chart
.plot_groups
.iter()
.flat_map(|group| group.series.iter())
.map(series_length)
.max()
.unwrap_or(0)
} else {
chart.series.iter().map(series_length).max().unwrap_or(0)
};
format!("{title}, {kind}, {series_count} series, {category_count} categories")
}
struct Emitter<'s, S: PlotSink + ?Sized> {
sink: &'s mut S,
remaining: usize,
}
impl<S: PlotSink + ?Sized> Emitter<'_, S> {
fn push(&mut self, op: PlotOp) {
if self.exhausted() {
return;
}
if self.sink.push_op(op) {
self.remaining -= 1;
} else {
self.remaining = 0;
}
}
fn exhausted(&mut self) -> bool {
self.remaining == 0 || !self.sink.accepts_more()
}
}
#[derive(Clone, Copy)]
struct PlotFamily<'a> {
chart_type: &'a str,
series: &'a [SeriesView<'a>],
value_axis: Option<PlotAxisRange>,
axis_titles: PlotAxisTitles<'a>,
group: Option<&'a PlotGroup<'a>>,
chart_text: PlotTextStyle<'a>,
label: &'a ResolvedText,
axis: Option<&'a PlotAxis<'a>>,
x_axis: Option<&'a PlotAxis<'a>>,
category_axis: Option<&'a PlotAxis<'a>>,
secondary: bool,
}
impl<'a> PlotFamily<'a> {
fn label(&self) -> &ResolvedText {
self.label
}
fn scoped(&self, scope: PlotTextStyle<'a>) -> ResolvedText {
scope
.over(self.chart_text)
.resolve(CHART_LABEL_SIZE_PX, 400)
}
fn category_text(&self) -> ResolvedText {
self.scoped(self.category_axis.map(|axis| axis.text).unwrap_or_default())
}
fn grouping(&self) -> &'a str {
self.group
.and_then(|group| group.grouping)
.or_else(|| {
self.series
.first()
.and_then(|series| series.series.grouping)
})
.unwrap_or("clustered")
}
fn stacking(&self) -> Stacking {
match self.grouping() {
"stacked" => Stacking::Stacked,
"percentStacked" => Stacking::Percent,
_ => Stacking::None,
}
}
fn transposed(&self) -> bool {
self.chart_type == "bar"
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Stacking {
None,
Stacked,
Percent,
}
fn unique_axis_by_id<'a>(chart: &'a PlotChart<'a>, id: &str) -> Option<&'a PlotAxis<'a>> {
let mut matches = chart.axes.iter().filter(|axis| axis.id == Some(id));
let axis = matches.next()?;
matches.next().is_none().then_some(axis)
}
fn referenced_axis<'a>(
chart: &'a PlotChart<'a>,
id: &str,
kind: PlotAxisKind,
) -> Option<&'a PlotAxis<'a>> {
unique_axis_by_id(chart, id).filter(|axis| axis.kind == kind)
}
fn group_value_axes<'a>(
chart: &'a PlotChart<'a>,
group: &PlotGroup<'a>,
) -> (Option<&'a PlotAxis<'a>>, Option<&'a PlotAxis<'a>>) {
if matches!(
group.chart_type.unwrap_or(chart.chart_type),
"scatter" | "bubble"
) {
let x_id = group.axis_ids.first().copied();
let y_id = group.axis_ids.get(1).copied();
if x_id.is_some() && x_id == y_id {
return (None, None);
}
return (
x_id.and_then(|id| referenced_axis(chart, id, PlotAxisKind::Value)),
y_id.and_then(|id| referenced_axis(chart, id, PlotAxisKind::Value)),
);
}
let mut named = group
.axis_ids
.iter()
.filter_map(|id| referenced_axis(chart, id, PlotAxisKind::Value));
match (named.next(), named.next()) {
(Some(axis), None) => (None, Some(axis)),
_ => (None, None),
}
}
fn primary_value_axis<'a>(chart: &'a PlotChart<'a>) -> Option<&'a PlotAxis<'a>> {
chart
.plot_groups
.first()
.and_then(|group| group_value_axes(chart, group).1)
}
fn group_category_axis<'a>(
chart: &'a PlotChart<'a>,
group: &PlotGroup<'a>,
) -> Option<&'a PlotAxis<'a>> {
let mut named = group
.axis_ids
.iter()
.filter_map(|id| unique_axis_by_id(chart, id))
.filter(|axis| matches!(axis.kind, PlotAxisKind::Category | PlotAxisKind::Date));
match (named.next(), named.next()) {
(Some(axis), None) => Some(axis),
_ => None,
}
}
fn has_transposed_category_title(chart: &PlotChart<'_>) -> bool {
let has_title = |title: Option<&str>| title.is_some_and(|title| !title.is_empty());
if chart.plot_groups.is_empty() {
return chart.chart_type == "bar" && has_title(chart.axis_titles.category);
}
chart.plot_groups.iter().take(MAX_PLOT_GROUPS).any(|group| {
group.chart_type.unwrap_or(chart.chart_type) == "bar"
&& has_title(if chart.axes.is_empty() && group.axis_ids.is_empty() {
chart.axis_titles.category
} else {
group_category_axis(chart, group).and_then(|axis| axis.title)
})
})
}
fn secondary_value_axis<'a>(
chart: &'a PlotChart<'a>,
transposed: bool,
) -> Option<&'a PlotAxis<'a>> {
let primary = primary_value_axis(chart)?;
chart.plot_groups.iter().find_map(|group| {
if (group.chart_type.unwrap_or(chart.chart_type) == "bar") != transposed {
return None;
}
let axis = group_value_axes(chart, group).1?;
(axis.id != primary.id).then_some(axis)
})
}
struct SeriesView<'a> {
series: &'a PlotSeries<'a>,
wildcard: Option<usize>,
indexed: Vec<(usize, usize)>,
}
struct ScanBudget {
series: usize,
points: usize,
}
impl ScanBudget {
fn new() -> Self {
Self {
series: MAX_PLOT_SERIES,
points: MAX_PLOT_DATA_SCAN,
}
}
fn take_series(&mut self, requested: usize) -> usize {
let allowed = requested.min(self.series);
self.series -= allowed;
allowed
}
fn take_points(&mut self, requested: usize) -> usize {
let allowed = requested.min(self.points);
self.points -= allowed;
allowed
}
}
fn series_views<'a>(series: &'a [PlotSeries<'a>], budget: &mut ScanBudget) -> Vec<SeriesView<'a>> {
let allowed = budget.take_series(series.len());
series
.iter()
.take(allowed)
.map(|series| SeriesView::new(series, budget))
.collect()
}
impl<'a> SeriesView<'a> {
fn new(series: &'a PlotSeries<'a>, budget: &mut ScanBudget) -> Self {
let mut wildcard = None;
let scanned = budget.take_points(series.points.len());
let mut indexed = Vec::with_capacity(scanned);
for (position, point) in series.points.iter().take(scanned).enumerate() {
match point.index {
Some(index) => indexed.push((index, position)),
None if wildcard.is_none() => wildcard = Some(position),
None => {}
}
}
indexed.sort_unstable();
indexed.dedup_by_key(|(index, _)| *index);
Self {
series,
wildcard,
indexed,
}
}
fn point(&self, index: usize) -> Option<&PlotPoint<'a>> {
let exact = self
.indexed
.binary_search_by_key(&index, |(key, _)| *key)
.ok()
.map(|slot| self.indexed[slot].1);
let position = match (self.wildcard, exact) {
(Some(wildcard), Some(exact)) => Some(wildcard.min(exact)),
(wildcard, exact) => wildcard.or(exact),
};
position.map(|position| &self.series.points[position])
}
fn value(&self, index: usize) -> f64 {
self.data_value(index).unwrap_or(0.0)
}
fn data_value(&self, index: usize) -> Option<f64> {
self.point(index)
.and_then(|point| point.value)
.or_else(|| self.series.values.get(index).copied())
.filter(|value| value.is_finite())
}
fn point_color(&self, point_index: usize, series_index: usize) -> String {
self.point(point_index)
.and_then(|point| point.color)
.map(hex)
.unwrap_or_else(|| series_color(Some(self.series), series_index))
}
fn marker_size(&self, index: usize) -> f64 {
self.point(index)
.and_then(|point| point.marker.as_ref())
.or(self.series.marker.as_ref())
.and_then(|marker| marker.size)
.unwrap_or(DEFAULT_MARKER_PT)
.clamp(2.0, 72.0)
* 4.0
/ 3.0
}
fn marker_symbol(&self, index: usize, series_index: usize) -> Option<PlotMarkerSymbol> {
let symbol = self
.point(index)
.and_then(|point| point.marker.as_ref())
.or(self.series.marker.as_ref())
.and_then(|marker| marker.symbol)
.unwrap_or(PlotMarkerSymbol::Auto)
.resolved(series_index);
(symbol != PlotMarkerSymbol::None).then_some(symbol)
}
fn x_value(&self, index: usize) -> Option<f64> {
self.series
.x_values
.get(index)
.copied()
.filter(|value| value.is_finite())
}
fn xy_value(&self, index: usize) -> Option<(f64, f64)> {
Some((self.x_value(index)?, self.data_value(index)?))
}
fn bubble_size(&self, index: usize) -> f64 {
self.series
.bubble_sizes
.get(index)
.copied()
.filter(|value| value.is_finite() && *value > 0.0)
.unwrap_or(0.0)
}
fn explosion(&self, index: usize) -> f64 {
self.point(index)
.and_then(|point| point.explosion)
.filter(|value| value.is_finite())
.unwrap_or(0.0)
.clamp(0.0, 400.0)
}
fn length(&self) -> usize {
series_length(self.series)
}
}
#[derive(Clone, Copy)]
struct PlotArea {
x: f64,
y: f64,
w: f64,
h: f64,
gutter: f64,
}
fn emit_family<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
family: PlotFamily<'_>,
plot: PlotArea,
x: f64,
y: f64,
width: f64,
height: f64,
) {
match family.chart_type {
"pie" | "doughnut" | "ofPie" => emit_pie(ops, family, x, y, width, height),
"line" => emit_line(ops, family, plot),
"area" => emit_area(ops, family, plot),
"scatter" => emit_scatter(ops, family, plot),
"bubble" => emit_bubble(ops, family, plot),
"radar" => emit_radar(ops, family, plot, x, y, width, height),
"stock" => emit_stock(ops, family, plot),
"surface" => emit_surface(ops, family, plot),
_ => emit_bar(ops, family, plot),
}
}
fn plot_fill_color(fill: PlotFill<'_>) -> Option<String> {
match fill {
PlotFill::None => None,
PlotFill::Solid(color) => Some(color.to_owned()),
PlotFill::Pattern {
foreground,
background,
} => match (foreground, background) {
(Some(foreground), Some(background)) => {
Some(blend_hex(foreground, background).unwrap_or_else(|| foreground.to_owned()))
}
(foreground, background) => foreground.or(background).map(str::to_owned),
},
PlotFill::Gradient(colors) => mean_hex(colors),
PlotFill::Unsupported => None,
}
}
fn mean_hex(colors: &[String]) -> Option<String> {
let parsed = colors
.iter()
.map(|color| parse_rgba(color))
.collect::<Option<Vec<[u8; 4]>>>()
.filter(|parsed| !parsed.is_empty())?;
let count = parsed.len() as u64;
let weight: u64 = parsed.iter().map(|rgba| u64::from(rgba[3])).sum();
let channel = |index: usize| {
parsed
.iter()
.map(|rgba| u64::from(rgba[index]) * u64::from(rgba[3]))
.sum::<u64>()
.checked_div(weight)
.unwrap_or_else(|| {
parsed
.iter()
.map(|rgba| u64::from(rgba[index]))
.sum::<u64>()
/ count
})
};
let rgb = format!("#{:02X}{:02X}{:02X}", channel(0), channel(1), channel(2));
Some(match weight / count {
255 => rgb,
alpha => format!("{rgb}{alpha:02X}"),
})
}
fn parse_rgba(color: &str) -> Option<[u8; 4]> {
let hex = color.strip_prefix('#').unwrap_or(color);
let alpha = match hex.len() {
6 => 255,
8 => u8::from_str_radix(hex.get(6..8)?, 16).ok()?,
_ => return None,
};
let [red, green, blue] = parse_hex(hex.get(..6)?)?;
Some([red, green, blue, alpha])
}
fn blend_hex(first: &str, second: &str) -> Option<String> {
let (first, second) = (parse_hex(first)?, parse_hex(second)?);
let mix = |index: usize| (u16::from(first[index]) + u16::from(second[index])) / 2;
Some(format!("#{:02X}{:02X}{:02X}", mix(0), mix(1), mix(2)))
}
fn parse_hex(color: &str) -> Option<[u8; 3]> {
let hex = color.strip_prefix('#').unwrap_or(color);
if hex.len() != 6 || !hex.is_ascii() {
return None;
}
let channel = |at: usize| u8::from_str_radix(&hex[at..at + 2], 16).ok();
Some([channel(0)?, channel(2)?, channel(4)?])
}
fn push_rect<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
x: f64,
y: f64,
w: f64,
h: f64,
fill: &str,
) {
if w <= 0.0 || h <= 0.0 || ops.exhausted() {
return;
}
ops.push(PlotOp::Rect {
x,
y,
w,
h,
fill: fill.to_owned(),
});
}
fn push_text<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
text: &str,
x: f64,
baseline_y: f64,
width: f64,
style: &ResolvedText,
) {
push_text_aligned(ops, text, x, baseline_y, width, style, PlotTextAlign::Start);
}
fn push_text_aligned<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
text: &str,
x: f64,
baseline_y: f64,
width: f64,
style: &ResolvedText,
align: PlotTextAlign,
) {
push_text_turned(ops, text, x, baseline_y, width, style, align, 0.0);
}
#[allow(clippy::too_many_arguments)]
fn push_text_turned<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
text: &str,
x: f64,
baseline_y: f64,
width: f64,
style: &ResolvedText,
align: PlotTextAlign,
rotation_deg: f64,
) {
if text.is_empty() || width <= 0.0 || ops.exhausted() {
return;
}
ops.push(PlotOp::Text {
text: text.chars().take(MAX_LABEL_CHARS).collect(),
x,
baseline_y,
width,
font: style.font.clone(),
color: style.color.clone(),
align,
rotation_deg,
});
}
fn push_line<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
x1: f64,
y1: f64,
x2: f64,
y2: f64,
color: &str,
width: f64,
) {
if ops.exhausted() {
return;
}
ops.push(PlotOp::Line {
x1,
y1,
x2,
y2,
color: color.to_owned(),
width,
});
}
fn has_legend(chart: &PlotChart<'_>) -> bool {
chart
.legend
.as_ref()
.and_then(|legend| legend.visible)
.unwrap_or(true)
}
struct LegendBand {
x: f64,
y: f64,
w: f64,
h: f64,
horizontal: bool,
rows: Vec<LegendRow>,
}
#[derive(Default)]
struct LegendRow {
entries: Vec<LegendEntry>,
width: f64,
height: f64,
}
struct LegendEntry {
lines: Vec<String>,
color: String,
width: f64,
}
fn legend_band<S: PlotSink + ?Sized>(
chart: &PlotChart<'_>,
position: &str,
rect: PlotRect,
title_h: f64,
plot_floor: Option<f64>,
style: &ResolvedText,
ops: &mut Emitter<'_, S>,
) -> Option<LegendBand> {
if !has_legend(chart) {
return None;
}
let PlotRect {
x,
y,
w: width,
h: height,
} = rect;
let horizontal = matches!(position, "top" | "bottom");
if !horizontal {
return Some(side_legend_band(chart, position, rect, title_h, style, ops));
}
let w = (width - 2.0 * CHART_PAD).max(0.0);
let mut rows = legend_rows(chart, w, style, ops);
let room = match plot_floor {
Some(floor) => height - title_h - BAND_GAP - floor,
None => height - title_h - CHART_PAD,
}
.max(0.0);
let mut used = 0.0;
rows.retain(|row| {
used += row.height;
used <= room
});
let h = rows.iter().map(|row| row.height).sum::<f64>();
Some(LegendBand {
x: x + CHART_PAD,
y: if position == "bottom" {
y + height - CHART_PAD - h
} else {
y + title_h
},
w,
h,
horizontal,
rows,
})
}
fn reverse_stacked_rows(chart: &PlotChart<'_>, rows: &mut [LegendRow]) {
let mut start = 0;
for group in &chart.plot_groups {
let end = (start + group.series.len()).min(rows.len());
if matches!(
group.chart_type.unwrap_or(chart.chart_type),
"column" | "area"
) && matches!(group.grouping, Some("stacked" | "percentStacked"))
{
rows[start..end].reverse();
}
start = end;
}
}
fn side_legend_band<S: PlotSink + ?Sized>(
chart: &PlotChart<'_>,
position: &str,
rect: PlotRect,
title_h: f64,
style: &ResolvedText,
ops: &mut Emitter<'_, S>,
) -> LegendBand {
let size = style.font.size_px;
let lead = size * (LEGEND_KEY_EM + LEGEND_KEY_GAP_EM);
let limit = (rect.w / 3.0).max(lead + size);
let mut rows = Vec::new();
for (label, color) in legend_entries(chart, &mut ScanBudget::new()) {
let lines = wrap_legend_label(&label, limit - lead, style, ops);
let width = lead
+ lines
.iter()
.map(|line| legend_text_width(line, style, ops, 0.0))
.fold(0.0, f64::max);
let height = size * LEGEND_PITCH_EM + size * 1.22 * lines.len().saturating_sub(1) as f64;
rows.push(LegendRow {
entries: vec![LegendEntry {
lines,
color,
width,
}],
width,
height,
});
}
reverse_stacked_rows(chart, &mut rows);
let top = if chart.title.is_some_and(|title| !title.is_empty()) {
rect.y + title_h
} else {
rect.y
};
let first = if position == "topRight" {
top + LEGEND_EDGE
} else {
top
};
let room = (rect.y + rect.h - first).max(0.0);
let mut used = 0.0;
rows.retain(|row| {
used += row.height;
used <= room
});
let w = rows
.iter()
.map(|row| row.width)
.fold(0.0, f64::max)
.min(limit);
let h = rows.iter().map(|row| row.height).sum::<f64>();
LegendBand {
x: if position == "left" {
rect.x + LEGEND_EDGE
} else {
rect.x + rect.w - LEGEND_EDGE - w
},
y: if position == "topRight" {
first
} else {
((top + rect.y + rect.h) / 2.0 - h / 2.0).max(top)
},
w,
h,
horizontal: false,
rows,
}
}
fn legend_rows<S: PlotSink + ?Sized>(
chart: &PlotChart<'_>,
width: f64,
style: &ResolvedText,
ops: &mut Emitter<'_, S>,
) -> Vec<LegendRow> {
let size = style.font.size_px;
let lead = size * (LEGEND_KEY_EM + LEGEND_KEY_GAP_EM);
let gap = size * LEGEND_ENTRY_GAP_EM;
if width <= lead {
return Vec::new();
}
let mut rows = vec![LegendRow::default()];
for (label, color) in legend_entries(chart, &mut ScanBudget::new()) {
let lines = wrap_legend_label(&label, width - lead, style, ops);
let entry_width = lead
+ lines
.iter()
.map(|line| legend_text_width(line, style, ops, LEGEND_TEXT_SLACK))
.fold(0.0, f64::max);
let row = rows.last().unwrap();
if !row.entries.is_empty() && row.width + gap + entry_width > width {
rows.push(LegendRow::default());
}
let row = rows.last_mut().unwrap();
if !row.entries.is_empty() {
row.width += gap;
}
row.width += entry_width;
row.height = row
.height
.max(size * (LEGEND_ROW_EM + TITLE_LINE_EM * (lines.len() - 1) as f64));
row.entries.push(LegendEntry {
lines,
color,
width: entry_width,
});
}
rows
}
fn legend_text_width<S: PlotSink + ?Sized>(
label: &str,
style: &ResolvedText,
ops: &mut Emitter<'_, S>,
slack: f64,
) -> f64 {
ops.sink
.measure_text(label, &style.font)
.filter(|width| width.is_finite() && *width >= 0.0)
.unwrap_or_else(|| legend_fallback_width(label, &style.font) + slack)
}
fn text_width<S: PlotSink + ?Sized>(
label: &str,
style: &ResolvedText,
ops: &mut Emitter<'_, S>,
) -> f64 {
ops.sink
.measure_text(label, &style.font)
.filter(|width| width.is_finite() && *width >= 0.0)
.unwrap_or_else(|| fallback_label_width(label, &style.font))
}
pub fn fallback_label_width(label: &str, font: &PlotFont) -> f64 {
estimated_width(label, font, |_| 0.5)
}
fn legend_fallback_width(label: &str, font: &PlotFont) -> f64 {
estimated_width(label, font, legend_advance)
}
fn estimated_width(label: &str, font: &PlotFont, advance: fn(char) -> f64) -> f64 {
let count = label.chars().count() as f64;
let ems: f64 = label.chars().map(advance).sum();
(ems * font.size_px + (count - 1.0).max(0.0) * font.letter_spacing_px).max(0.0)
}
const FALLBACK_ADVANCES: [u16; 95] = [
226, 326, 401, 498, 507, 715, 682, 220, 303, 303, 498, 498, 250, 306, 252, 386, 507, 507, 507,
507, 507, 507, 507, 507, 507, 507, 268, 268, 498, 498, 498, 464, 894, 578, 544, 533, 615, 488,
460, 631, 623, 252, 319, 520, 420, 855, 646, 662, 516, 673, 543, 460, 488, 642, 568, 890, 519,
488, 468, 306, 386, 306, 498, 498, 291, 479, 526, 423, 526, 498, 305, 470, 526, 230, 240, 454,
230, 799, 526, 528, 526, 526, 348, 391, 335, 526, 452, 715, 433, 452, 395, 314, 460, 314, 498,
];
fn legend_advance(character: char) -> f64 {
match character as u32 {
code @ 0x20..=0x7E => {
(f64::from(FALLBACK_ADVANCES[(code - 0x20) as usize]) / 1000.0).max(0.5)
}
0x1100..=0x115F
| 0x2E80..=0xA4CF
| 0xAC00..=0xD7A3
| 0xF900..=0xFAFF
| 0xFE30..=0xFE4F
| 0xFF00..=0xFF60
| 0xFFE0..=0xFFE6
| 0x20000..=0x3FFFD => 1.0,
_ => 0.5,
}
}
fn wrap_legend_label<S: PlotSink + ?Sized>(
label: &str,
width: f64,
style: &ResolvedText,
ops: &mut Emitter<'_, S>,
) -> Vec<String> {
let mut remaining: String = label.chars().take(MAX_LABEL_CHARS).collect();
let mut lines = Vec::new();
while !remaining.is_empty() && text_width(&remaining, style, ops) > width {
let mut end = 0;
for (index, ch) in remaining.char_indices() {
let next = index + ch.len_utf8();
if end > 0 && text_width(&remaining[..next], style, ops) > width {
break;
}
end = next;
}
let split = remaining[..end]
.rfind(char::is_whitespace)
.map(|index| index + remaining[index..].chars().next().unwrap().len_utf8())
.unwrap_or(end);
lines.push(remaining[..split].to_owned());
remaining = remaining[split..].to_owned();
}
if !remaining.is_empty() || lines.is_empty() {
lines.push(remaining);
}
lines
}
fn hex(color: &str) -> String {
if color.starts_with('#') {
color.to_owned()
} else {
format!("#{color}")
}
}
fn series_color(series: Option<&PlotSeries<'_>>, index: usize) -> String {
series
.and_then(|series| series.color)
.filter(|color| !color.is_empty())
.map(hex)
.unwrap_or_else(|| CHART_SERIES_COLORS[index % CHART_SERIES_COLORS.len()].to_owned())
}
fn series_length(series: &PlotSeries<'_>) -> usize {
series
.categories
.len()
.max(series.values.len())
.max(series.x_values.len())
.max(series.bubble_sizes.len())
}
fn category_count(series: &[SeriesView<'_>]) -> usize {
series.iter().map(SeriesView::length).max().unwrap_or(0)
}
fn category_label(series: &[SeriesView<'_>], index: usize) -> String {
series
.iter()
.find_map(|series| series.series.categories.get(index).cloned())
.unwrap_or_else(|| (index + 1).to_string())
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct ValueScale {
min: f64,
max: f64,
unit: f64,
log_base: Option<f64>,
reversed: bool,
percent: bool,
}
impl ValueScale {
fn fraction(&self, value: f64) -> f64 {
let raw = match self.log_base {
Some(base) if base > 1.0 && self.min > 0.0 && self.max > self.min => {
let log = |value: f64| value.max(f64::MIN_POSITIVE).log(base);
(log(value.max(self.min)) - log(self.min)) / (log(self.max) - log(self.min))
}
_ => (value - self.min) / (self.max - self.min),
};
if raw.is_finite() { raw } else { 0.0 }
}
fn ratio(&self, value: f64) -> f64 {
let fraction = self.fraction(value).clamp(0.0, 1.0);
if self.reversed {
1.0 - fraction
} else {
fraction
}
}
fn y(&self, plot: PlotArea, value: f64) -> f64 {
plot.y + (1.0 - self.ratio(value)) * plot.h
}
fn x(&self, plot: PlotArea, value: f64) -> f64 {
plot.x + self.ratio(value) * plot.w
}
fn tick(&self, plot: PlotArea, transposed: bool, value: f64) -> f64 {
if transposed {
self.x(plot, value)
} else {
self.y(plot, value)
}
}
fn baseline(&self) -> f64 {
0.0_f64.clamp(self.min, self.max)
}
fn format(&self, value: f64, code: Option<&str>) -> String {
if let Some(formatted) = code.and_then(|code| format_with_code(value, code)) {
return formatted;
}
if self.percent {
return format_percent(value);
}
format_number(value)
}
}
fn stacked_totals(family: PlotFamily<'_>) -> (f64, f64) {
let (mut low, mut high) = (0.0_f64, 0.0_f64);
let categories = category_count(family.series).min(MAX_PLOT_DATA_SCAN);
for index in 0..categories {
let (mut negative, mut positive) = (0.0_f64, 0.0_f64);
for series in family.series {
let value = series.value(index);
if !value.is_finite() {
continue;
}
if value < 0.0 {
negative += value;
} else {
positive += value;
}
}
low = low.min(negative);
high = high.max(positive);
}
(low, high)
}
fn percent_range(family: PlotFamily<'_>) -> (f64, f64) {
let (mut low, mut high) = (0.0_f64, 0.0_f64);
let categories = category_count(family.series).min(MAX_PLOT_DATA_SCAN);
for index in 0..categories {
let (mut negative, mut total) = (0.0_f64, 0.0_f64);
for series in family.series {
let value = series.value(index);
if !value.is_finite() {
continue;
}
total += value.abs();
if value < 0.0 {
negative += value;
}
}
if total > 0.0 {
low = low.min(negative / total);
high = high.max(1.0 + negative / total);
}
}
(low, if high > 0.0 { high } else { 1.0 })
}
fn plain_range(family: PlotFamily<'_>) -> (f64, f64) {
let mut min = 0.0;
let mut max = 0.0;
let mut remaining = MAX_PLOT_DATA_SCAN;
for series in family.series {
let samples = series
.series
.values
.len()
.max(series.series.points.len())
.min(remaining);
remaining -= samples;
for index in 0..samples {
let value = series.value(index);
if value.is_finite() {
min = f64::min(min, value);
max = f64::max(max, value);
}
}
}
(min, max)
}
fn nice_unit(rough: f64) -> f64 {
if rough <= 0.0 || !rough.is_finite() {
return 1.0;
}
let decade = 10.0_f64.powf(rough.log10().floor());
let scaled = rough / decade * (1.0 - 1e-9);
let nice = if scaled <= 1.0 {
1.0
} else if scaled <= 2.0 {
2.0
} else if scaled <= 5.0 {
5.0
} else {
10.0
};
let unit = nice * decade;
if unit > 0.0 && unit.is_finite() {
unit
} else {
1.0
}
}
fn excel_unit(range: f64) -> f64 {
if !range.is_finite() || range <= 0.0 {
return 1.0;
}
let major = 10.0_f64.powf(range.log10().round() - 1.0);
let steps = range / major;
if steps >= 20.0 {
major * 5.0
} else if steps >= 10.0 {
major * 2.0
} else {
major
}
}
fn target_intervals(extent: f64) -> f64 {
const LABEL_PITCH_PX: f64 = 20.0;
let target = extent / LABEL_PITCH_PX;
if target.is_finite() {
target.clamp(2.0, 12.0)
} else {
4.0
}
}
fn round_to_unit(value: f64, unit: f64, up: bool) -> f64 {
let steps = value / unit;
if !steps.is_finite() {
return value;
}
let rounded = if up {
(steps - 1e-9).ceil()
} else {
(steps + 1e-9).floor()
} * unit;
if !rounded.is_finite() {
return value;
}
if rounded == 0.0 { 0.0 } else { rounded }
}
#[cfg(test)]
fn value_range(family: PlotFamily<'_>) -> (f64, f64) {
let scale = value_scale(family);
(scale.min, scale.max)
}
fn value_scale(family: PlotFamily<'_>) -> ValueScale {
let stacking = family.stacking();
let (mut min, mut max) = match stacking {
Stacking::Percent => percent_range(family),
Stacking::Stacked => stacked_totals(family),
Stacking::None => plain_range(family),
};
let bounds = family
.axis
.map(|axis| axis.range)
.or(family.value_axis)
.unwrap_or_default();
let pinned_min = bounds.min.filter(|value| value.is_finite());
let pinned_max = bounds.max.filter(|value| value.is_finite());
if let Some(value) = pinned_min {
min = value;
}
if let Some(value) = pinned_max {
max = value;
}
if max <= min {
max = min + 1.0;
}
if !(max - min).is_finite() || max <= min {
(min, max) = (0.0, 1.0);
}
let log_base = family
.axis
.and_then(|axis| axis.log_base)
.filter(|base| *base > 1.0 && base.is_finite() && min > 0.0);
let padding = (max - min) * 0.05;
if log_base.is_none() {
if pinned_max.is_none() && max > 0.0 {
max += padding;
}
if pinned_min.is_none() && min < 0.0 {
min -= padding;
}
}
let unit = family
.axis
.and_then(|axis| axis.major_unit)
.filter(|unit| unit.is_finite() && *unit > 0.0)
.unwrap_or_else(|| excel_unit(max - min));
if log_base.is_none() {
if pinned_min.is_none() {
min = round_to_unit(min, unit, false);
}
if pinned_max.is_none() {
max = round_to_unit(max, unit, true);
}
}
ValueScale {
min,
max,
unit,
log_base,
reversed: family.axis.is_some_and(|axis| axis.reversed),
percent: stacking == Stacking::Percent,
}
}
fn axis_ticks(scale: ValueScale, unit: Option<f64>) -> Vec<f64> {
let span = scale.max - scale.min;
if let Some(base) = scale.log_base {
let first = scale.min.log(base).ceil();
let last = scale.max.log(base).floor();
if last >= first && (last - first) < MAX_PLOT_AXIS_TICKS as f64 {
let ticks: Vec<f64> = (0..=(last - first) as usize)
.map(|step| base.powf(first + step as f64))
.filter(|value| value.is_finite())
.collect();
if ticks.len() >= 2 {
return ticks;
}
}
}
let unit = unit
.filter(|unit| unit.is_finite() && *unit > 0.0)
.unwrap_or(scale.unit);
if unit.is_finite() && unit > 0.0 {
let steps = (span / unit).floor();
if steps >= 1.0 && steps < MAX_PLOT_AXIS_TICKS as f64 {
let mut ticks = Vec::new();
let mut index = 0;
while ticks.len() < MAX_PLOT_AXIS_TICKS {
let value = scale.min + unit * index as f64;
if !value.is_finite() || value > scale.max + unit * 1e-9 {
break;
}
ticks.push(value);
index += 1;
}
if ticks.len() >= 2 {
return ticks;
}
}
}
(0..=4)
.map(|step| match step {
4 => scale.max,
step => scale.min + span * step as f64 / 4.0,
})
.collect()
}
fn draws_no_line(line: Option<PlotLine<'_>>) -> bool {
line.is_some_and(|line| line.none)
}
fn tick_extents(mark: Option<&str>) -> Option<(f64, f64)> {
match mark? {
"in" => Some((0.0, TICK_LEN)),
"out" => Some((TICK_LEN, 0.0)),
"cross" => Some((TICK_LEN / 2.0, TICK_LEN / 2.0)),
_ => None,
}
}
fn axis_stroke<'a>(axis: Option<&'a PlotAxis<'a>>) -> Option<(&'a str, f64)> {
match axis.and_then(|axis| axis.line) {
Some(line) if line.none => None,
Some(line) => Some((
line.color.unwrap_or(CHART_AXIS_COLOR),
axis_line_width(line.width_emu),
)),
None => Some((CHART_AXIS_COLOR, 1.0)),
}
}
fn line_width(width_emu: Option<f64>, default_px: f64) -> f64 {
match width_emu {
Some(emu) if emu.is_finite() => (emu / EMU_PER_PIXEL).clamp(1.0, MAX_LINE_PX),
_ => default_px,
}
}
fn axis_line_width(width_emu: Option<f64>) -> f64 {
line_width(width_emu, 1.0)
}
fn series_line_width(series: &PlotSeries<'_>) -> Option<f64> {
match series.line {
Some(line) if line.none => None,
line => Some(line_width(
line.and_then(|line| line.width_emu),
DEFAULT_SERIES_LINE_PX,
)),
}
}
fn emit_axes<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
family: PlotFamily<'_>,
plot: PlotArea,
) {
let transposed = family.transposed();
let scale = value_scale(family);
let axis = family.axis;
let hidden = axis.is_some_and(|axis| axis.hidden);
let major_grid =
axis.is_none_or(|axis| axis.major_gridlines && !draws_no_line(axis.major_gridline));
let minor_grid =
axis.is_some_and(|axis| axis.minor_gridlines && !draws_no_line(axis.minor_gridline));
let number_format = axis.and_then(|axis| axis.number_format);
let tick_style = &family.scoped(axis.map(|axis| axis.text).unwrap_or_default());
let (edge, outward) = match (transposed, family.secondary) {
(true, false) => (plot.y + plot.h, 1.0),
(true, true) => (plot.y, -1.0),
(false, false) => (plot.x, -1.0),
(false, true) => (plot.x + plot.w, 1.0),
};
if let Some(minor_unit) = axis.and_then(|axis| axis.minor_unit).filter(|_| minor_grid) {
for value in axis_ticks(scale, Some(minor_unit)) {
push_value_gridline(
ops,
plot,
transposed,
scale.tick(plot, transposed, value),
0.25,
);
}
}
let mut widest: f64 = 0.0;
for value in axis_ticks(scale, axis.and_then(|axis| axis.major_unit))
.into_iter()
.rev()
{
if ops.exhausted() {
return;
}
let at = scale.tick(plot, transposed, value);
if major_grid {
push_value_gridline(ops, plot, transposed, at, 0.5);
}
if hidden {
continue;
}
let text = scale.format(value, number_format);
let width = text_width(&text, tick_style, ops);
widest = widest.max(width);
if transposed {
let baseline = if family.secondary {
plot.y - label_gap(tick_style) - 0.25 * tick_style.font.size_px
} else {
baseline_below(plot.y + plot.h, tick_style)
};
push_text_aligned(
ops,
&text,
at - width,
baseline,
width * 2.0,
tick_style,
PlotTextAlign::Center,
);
} else {
let label_x = if family.secondary {
plot.x + plot.w + label_gap(tick_style)
} else {
plot.x - label_gap(tick_style) - width
};
push_text(
ops,
&text,
label_x,
baseline_centred(at, tick_style),
width,
tick_style,
);
}
if let Some((outer, inner)) = tick_extents(axis.and_then(|axis| axis.major_tick_mark)) {
let (near, far) = (edge + outward * outer, edge - outward * inner);
if transposed {
push_line(ops, at, near, at, far, CHART_AXIS_COLOR, 1.0);
} else {
push_line(ops, near, at, far, at, CHART_AXIS_COLOR, 1.0);
}
}
}
if family.secondary {
if let Some((color, width)) = axis_stroke(axis) {
if transposed {
push_line(ops, plot.x, plot.y, plot.x + plot.w, plot.y, color, width);
} else {
push_line(
ops,
plot.x + plot.w,
plot.y,
plot.x + plot.w,
plot.y + plot.h,
color,
width,
);
}
}
return;
}
let (left, bottom) = if transposed {
(axis_stroke(family.category_axis), axis_stroke(axis))
} else {
(axis_stroke(axis), axis_stroke(family.category_axis))
};
if let Some((color, width)) = left {
push_line(ops, plot.x, plot.y, plot.x, plot.y + plot.h, color, width);
}
if let Some((color, width)) = bottom {
push_line(
ops,
plot.x,
plot.y + plot.h,
plot.x + plot.w,
plot.y + plot.h,
color,
width,
);
}
let category_style = family.category_text();
let below = below_plot(
plot,
if transposed {
tick_style
} else {
&category_style
},
tick_style,
);
let (value_title, category_title) = if transposed {
(below, left_of_plot(plot))
} else {
(left_of_plot(plot), below)
};
if let Some(title) = family.axis_titles.value.filter(|title| !title.is_empty()) {
if transposed || !ops.sink.turns_text() {
push_text(
ops,
title,
value_title.0,
value_title.1,
value_title.2,
tick_style,
);
} else {
push_text_turned(
ops,
title,
plot.x
- label_gap(tick_style)
- widest
- AXIS_TITLE_EM / 2.0 * tick_style.font.size_px
- plot.h / 2.0,
plot.y + plot.h / 2.0 + tick_style.font.size_px * 0.34,
plot.h,
tick_style,
PlotTextAlign::Center,
-90.0,
);
}
}
if let Some(title) = family
.axis_titles
.category
.filter(|title| !title.is_empty())
{
push_text(
ops,
title,
category_title.0,
category_title.1,
category_title.2,
tick_style,
);
}
}
fn left_of_plot(plot: PlotArea) -> (f64, f64, f64) {
(
plot.x - plot.gutter + 4.0,
plot.y - 5.0,
plot.w + plot.gutter - 4.0,
)
}
fn below_plot(plot: PlotArea, labels: &ResolvedText, title: &ResolvedText) -> (f64, f64, f64) {
(
plot.x,
plot.y + plot.h + label_gap(labels) + labels.font.size_px + title.font.size_px,
plot.w,
)
}
fn push_value_gridline<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
plot: PlotArea,
transposed: bool,
at: f64,
width: f64,
) {
if transposed {
push_line(
ops,
at,
plot.y,
at,
plot.y + plot.h,
CHART_GRID_COLOR,
width,
);
} else {
push_line(
ops,
plot.x,
at,
plot.x + plot.w,
at,
CHART_GRID_COLOR,
width,
);
}
}
fn point_label(
family: PlotFamily<'_>,
series: &SeriesView<'_>,
index: usize,
percent_total: f64,
) -> Option<String> {
let point = series.point(index);
series.data_value(index)?;
let spec_for_runs = point_label_spec(series, index);
if let Some(runs) = point.and_then(|point| point.label_runs) {
let number_format = spec_for_runs.and_then(|spec| spec.number_format);
return Some(
runs.iter()
.map(|run| match run {
super::model::ChartLabelRun::Text(text) => text.clone(),
super::model::ChartLabelRun::Field(field) => {
label_field(field, family, series, index, percent_total, number_format)
}
})
.collect(),
);
}
if let Some(text) = point.and_then(|point| point.label) {
return Some(text.to_owned());
}
let spec = spec_for_runs?;
if !spec.shows_anything() {
return None;
}
let separator = spec.separator.unwrap_or(", ");
let mut parts: Vec<String> = Vec::with_capacity(4);
if spec.show_series_name
&& let Some(name) = series.series.name
{
parts.push(name.to_owned());
}
if spec.show_category_name {
parts.push(category_label(family.series, index));
}
let value = series.value(index);
if spec.show_value {
parts.push(
spec.number_format
.and_then(|code| format_with_code(value, code))
.unwrap_or_else(|| format_number(value)),
);
}
if spec.show_percent {
parts.push(if percent_total > 0.0 {
format_percent(value / percent_total)
} else {
format_percent(0.0)
});
}
if spec.show_bubble_size {
parts.push(format_number(series.bubble_size(index)));
}
(!parts.is_empty()).then(|| parts.join(separator))
}
fn label_field(
field: &str,
family: PlotFamily<'_>,
series: &SeriesView<'_>,
index: usize,
percent_total: f64,
number_format: Option<&str>,
) -> String {
let formatted = |value: f64| {
number_format
.and_then(|code| format_with_code(value, code))
.unwrap_or_else(|| format_number(value))
};
match field {
"SERIESNAME" => series.series.name.unwrap_or_default().to_owned(),
"CATEGORYNAME" => category_label(family.series, index),
"VALUE" => formatted(series.value(index)),
"PERCENTAGE" => {
let share = if percent_total > 0.0 {
series.value(index) / percent_total
} else {
0.0
};
number_format
.and_then(|code| format_with_code(share, code))
.unwrap_or_else(|| format_percent(share))
}
_ => String::new(),
}
}
fn point_label_spec<'a>(series: &SeriesView<'a>, index: usize) -> Option<PlotDataLabels<'a>> {
series
.point(index)
.and_then(|point| point.labels)
.or(series.series.labels)
}
fn category_total(family: PlotFamily<'_>, index: usize) -> f64 {
family
.series
.iter()
.map(|series| series.value(index).abs())
.filter(|value| value.is_finite())
.sum()
}
fn push_legend_key<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
series: &SeriesView<'_>,
series_index: usize,
index: usize,
x: f64,
baseline_y: f64,
) {
let shows = point_label_spec(series, index).is_some_and(|labels| labels.show_legend_key);
if shows {
push_rect(
ops,
x - 10.0,
baseline_y - 7.0,
7.0,
7.0,
&series.point_color(index, series_index),
);
}
}
#[allow(clippy::too_many_arguments)]
fn push_point_label<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
family: PlotFamily<'_>,
series: &SeriesView<'_>,
series_index: usize,
index: usize,
x: f64,
baseline_y: f64,
width: f64,
percent_total: f64,
align: PlotTextAlign,
) {
let Some(text) = point_label(family, series, index, percent_total) else {
return;
};
let scope = point_label_spec(series, index)
.map(|labels| labels.text)
.unwrap_or_default();
push_legend_key(ops, series, series_index, index, x, baseline_y);
let style = family.scoped(scope);
let lines: Vec<&str> = text.split('\n').collect();
let step = style.font.size_px * 1.2;
let top = baseline_y - step * (lines.len() as f64 - 1.0) / 2.0;
for (line, text) in lines.iter().enumerate() {
push_text_aligned(
ops,
text.trim(),
x,
top + step * line as f64,
width,
&style,
align,
);
}
}
const MARKER_LABEL_WIDTH: f64 = 48.0;
#[allow(clippy::too_many_arguments)]
fn push_marker_label<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
family: PlotFamily<'_>,
series: &SeriesView<'_>,
series_index: usize,
index: usize,
(x, y): (f64, f64),
size: f64,
percent_total: f64,
) {
let spec = point_label_spec(series, index);
let font_px = family
.scoped(spec.map(|labels| labels.text).unwrap_or_default())
.font
.size_px;
let reach = size / 2.0 + 3.0;
let middle = y + font_px * 0.35;
let centred = x - MARKER_LABEL_WIDTH / 2.0;
let (left, baseline, align) = match spec.and_then(|labels| labels.position) {
Some("t") => (centred, y - reach - font_px * 0.25, PlotTextAlign::Center),
Some("b") => (centred, y + reach + font_px * 0.75, PlotTextAlign::Center),
Some("ctr") => (centred, middle, PlotTextAlign::Center),
Some("l") => (
x - reach - MARKER_LABEL_WIDTH,
middle,
PlotTextAlign::Center,
),
_ => (x + reach, middle, PlotTextAlign::Start),
};
push_point_label(
ops,
family,
series,
series_index,
index,
left,
baseline,
MARKER_LABEL_WIDTH,
percent_total,
align,
);
}
fn bar_label_anchor(position: Option<&str>) -> (f64, f64) {
match position {
Some("ctr") => (0.5, 0.0),
Some("inEnd") => (1.0, -3.0),
Some("inBase") => (0.0, 3.0),
_ => (1.0, 3.0),
}
}
fn pie_label_reach(position: Option<&str>) -> f64 {
match position {
Some("ctr") => 0.5,
Some("inEnd") => 0.8,
Some("outEnd") => 1.15,
_ => 0.62,
}
}
#[derive(Clone, Copy)]
struct BarBands {
slot: f64,
bar: f64,
lead: f64,
step: f64,
}
fn bar_bands(family: PlotFamily<'_>, categories: usize, extent: f64) -> BarBands {
let stacking = family.stacking();
let lanes = if stacking == Stacking::None {
family.series.len().max(1)
} else {
1
};
let gap = family
.group
.and_then(|group| group.gap_width)
.filter(|gap| gap.is_finite())
.unwrap_or(150.0)
.clamp(0.0, 500.0)
/ 100.0;
let overlap = family
.group
.and_then(|group| group.overlap)
.filter(|overlap| overlap.is_finite())
.unwrap_or(if stacking == Stacking::None {
0.0
} else {
100.0
})
.clamp(-100.0, 100.0)
/ 100.0;
let slot = extent / categories.max(1) as f64;
let span = lanes as f64 - (lanes as f64 - 1.0) * overlap;
let bar = (slot / (span + gap).max(0.1)).clamp(0.5, extent.max(0.5));
BarBands {
slot,
bar,
lead: (slot - bar * span).max(0.0) / 2.0,
step: bar * (1.0 - overlap),
}
}
fn category_position(family: PlotFamily<'_>, index: usize, count: usize) -> usize {
let reversed = family.category_axis.is_some_and(|axis| axis.reversed);
if reversed != family.transposed() {
count.saturating_sub(1).saturating_sub(index)
} else {
index
}
}
fn stacked_spans(family: PlotFamily<'_>, category: usize, spans: &mut Vec<(f64, f64)>) {
spans.clear();
let stacking = family.stacking();
let total: f64 = if stacking == Stacking::Percent {
family
.series
.iter()
.map(|series| series.value(category).abs())
.filter(|value| value.is_finite())
.sum()
} else {
0.0
};
let (mut positive, mut negative) = (0.0_f64, 0.0_f64);
for series in family.series {
let mut value = series.value(category);
if !value.is_finite() {
value = 0.0;
}
if stacking == Stacking::Percent {
value = if total > 0.0 { value / total } else { 0.0 };
}
match stacking {
Stacking::None => spans.push((0.0, value)),
_ if value < 0.0 => {
spans.push((negative, negative + value));
negative += value;
}
_ => {
spans.push((positive, positive + value));
positive += value;
}
}
}
}
fn emit_bar<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
family: PlotFamily<'_>,
plot: PlotArea,
) {
let cat_count = category_count(family.series);
if cat_count == 0 || family.series.is_empty() {
return;
}
let horizontal = family.transposed();
emit_axes(ops, family, plot);
let scale = value_scale(family);
let bands = bar_bands(family, cat_count, if horizontal { plot.h } else { plot.w });
let category_style = &family.category_text();
let spans = &mut Vec::with_capacity(family.series.len());
for cat_idx in 0..cat_count {
if ops.exhausted() {
return;
}
let slot = bands.slot * category_position(family, cat_idx, cat_count) as f64;
let label = category_label(family.series, cat_idx);
let step = category_style.font.size_px * LABEL_LINE_EM;
let lift = step * (label.split('\n').count() - 1) as f64 / 2.0;
for (line, text) in label.split('\n').enumerate() {
if horizontal {
let text = text.trim();
let width = text_width(text, category_style, ops);
push_text(
ops,
text,
plot.x - label_gap(category_style) - width,
baseline_centred(plot.y + slot + bands.slot / 2.0, category_style) - lift
+ step * line as f64,
width,
category_style,
);
} else {
push_text_aligned(
ops,
text.trim(),
plot.x + slot + 2.0,
baseline_below(plot.y + plot.h, category_style) + step * line as f64,
bands.slot - 4.0,
category_style,
PlotTextAlign::Center,
);
}
}
stacked_spans(family, cat_idx, spans);
let total = category_total(family, cat_idx);
for (ser_idx, series) in family.series.iter().enumerate() {
let (start, end) = spans.get(ser_idx).copied().unwrap_or((0.0, 0.0));
let lane = if family.stacking() == Stacking::None {
ser_idx as f64
} else {
0.0
};
let offset = slot + bands.lead + bands.step * lane;
let color = series.point_color(cat_idx, ser_idx);
if horizontal {
let (x0, x1) = (scale.x(plot, start), scale.x(plot, end));
let y = plot.y + offset;
push_rect(ops, x0.min(x1), y, (x1 - x0).abs(), bands.bar, &color);
let (fraction, offset) = bar_label_anchor(
point_label_spec(series, cat_idx).and_then(|labels| labels.position),
);
push_point_label(
ops,
family,
series,
ser_idx,
cat_idx,
x0 + (x1 - x0) * fraction + offset,
y + bands.bar,
48.0,
total,
PlotTextAlign::Start,
);
} else {
let (y0, y1) = (scale.y(plot, start), scale.y(plot, end));
let x = plot.x + offset;
push_rect(
ops,
x,
y0.min(y1),
bands.bar,
(y0 - y1).abs().max(1.0),
&color,
);
let (fraction, offset) = bar_label_anchor(
point_label_spec(series, cat_idx).and_then(|labels| labels.position),
);
let label_w = bands.bar.max(32.0);
push_point_label(
ops,
family,
series,
ser_idx,
cat_idx,
x + (bands.bar - label_w) / 2.0,
y0 + (y1 - y0) * fraction - offset,
label_w,
total,
PlotTextAlign::Center,
);
}
}
}
}
fn line_x(family: PlotFamily<'_>, plot: PlotArea, index: usize, count: usize) -> f64 {
let position = category_position(family, index, count) as f64;
if family.axis.and_then(|axis| axis.cross_between) == Some("midCat") {
let denom = count.saturating_sub(1).max(1) as f64;
plot.x + plot.w * position / denom
} else {
plot.x + plot.w * (position + 0.5) / count.max(1) as f64
}
}
fn emit_category_labels<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
family: PlotFamily<'_>,
plot: PlotArea,
count: usize,
) {
let style = &family.category_text();
let slot = (plot.w / count.max(1) as f64).max(32.0);
let step = style.font.size_px * LABEL_LINE_EM;
for index in 0..count {
if ops.exhausted() {
return;
}
let label = category_label(family.series, index);
let x = line_x(family, plot, index, count) - slot / 2.0;
for (line, text) in label.split('\n').enumerate() {
push_text_aligned(
ops,
text.trim(),
x,
baseline_below(plot.y + plot.h, style) + step * line as f64,
slot,
style,
PlotTextAlign::Center,
);
}
}
}
fn emit_line<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
family: PlotFamily<'_>,
plot: PlotArea,
) {
let cat_count = category_count(family.series);
if cat_count == 0 || family.series.is_empty() {
return;
}
emit_axes(ops, family, plot);
let scale = value_scale(family);
let stacking = family.stacking();
emit_category_labels(ops, family, plot, cat_count);
let spans = &mut Vec::with_capacity(family.series.len());
for (ser_idx, series) in family.series.iter().enumerate() {
let color = series_color(Some(series.series), ser_idx);
let width = series_line_width(series.series);
let markers = family.group.and_then(|group| group.markers) != Some(false);
let mut prev: Option<(f64, f64)> = None;
for i in 0..cat_count {
if ops.exhausted() {
return;
}
let value = if stacking == Stacking::None {
series.value(i)
} else {
stacked_spans(family, i, spans);
spans.get(ser_idx).map_or(0.0, |(_, end)| *end)
};
let x = line_x(family, plot, i, cat_count);
let y = scale.y(plot, value);
if let (Some(width), Some((prev_x, prev_y))) = (width, prev) {
push_line(ops, prev_x, prev_y, x, y, &color, width);
}
if markers {
push_marker(
ops,
series.marker_symbol(i, ser_idx),
x,
y,
series.marker_size(i),
&series.point_color(i, ser_idx),
);
}
let visible = markers && series.marker_symbol(i, ser_idx).is_some();
push_marker_label(
ops,
family,
series,
ser_idx,
i,
(x, y),
if visible { series.marker_size(i) } else { 0.0 },
category_total(family, i),
);
prev = Some((x, y));
}
}
}
fn emit_area<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
family: PlotFamily<'_>,
plot: PlotArea,
) {
let cat_count = category_count(family.series);
if cat_count == 0 || family.series.is_empty() {
return;
}
emit_axes(ops, family, plot);
let scale = value_scale(family);
let stacking = family.stacking();
emit_category_labels(ops, family, plot, cat_count);
let vertices = cat_count.min(MAX_PLOT_POLYGON_POINTS);
let spans = &mut Vec::with_capacity(family.series.len());
for (ser_idx, series) in family.series.iter().enumerate() {
if ops.exhausted() {
return;
}
let mut upper = Vec::with_capacity(vertices);
let mut lower = Vec::with_capacity(vertices);
for i in 0..vertices {
let x = line_x(family, plot, i, cat_count);
let (base, top) = if stacking == Stacking::None {
(scale.baseline(), series.value(i))
} else {
stacked_spans(family, i, spans);
spans.get(ser_idx).copied().unwrap_or((0.0, 0.0))
};
upper.push((x, scale.y(plot, top)));
lower.push((x, scale.y(plot, base)));
}
let mut commands = Vec::with_capacity(vertices * 2 + 2);
for (index, (x, y)) in upper.iter().enumerate() {
commands.push(if index == 0 {
GeometryPathCommand::Move { x: *x, y: *y }
} else {
GeometryPathCommand::Line { x: *x, y: *y }
});
}
for (x, y) in lower.iter().rev() {
commands.push(GeometryPathCommand::Line { x: *x, y: *y });
}
commands.push(GeometryPathCommand::Close);
let color = series_color(Some(series.series), ser_idx);
push_path(ops, plot, commands, &color, None);
for window in upper.windows(2) {
if ops.exhausted() {
return;
}
push_line(
ops,
window[0].0,
window[0].1,
window[1].0,
window[1].1,
&color,
1.5,
);
}
for (i, (x, y)) in upper.iter().enumerate() {
push_point_label(
ops,
family,
series,
ser_idx,
i,
*x,
*y - 3.0,
48.0,
category_total(family, i),
PlotTextAlign::Start,
);
}
}
}
fn scatter_x_scale(family: PlotFamily<'_>, plot: PlotArea) -> ValueScale {
let (mut min, mut max) = (f64::INFINITY, f64::NEG_INFINITY);
let mut seen = false;
let mut remaining = MAX_PLOT_DATA_SCAN;
for series in family.series {
let samples = series.series.x_values.len().min(remaining);
remaining -= samples;
for index in 0..samples {
if let Some((value, _)) = series.xy_value(index) {
seen = true;
min = min.min(value);
max = max.max(value);
}
}
}
if !seen {
min = 0.0;
max = 1.0;
}
let axis = family.x_axis;
if let Some(value) = axis
.and_then(|axis| axis.range.min)
.filter(|value| value.is_finite())
{
min = value;
}
if let Some(value) = axis
.and_then(|axis| axis.range.max)
.filter(|value| value.is_finite())
{
max = value;
}
if !(max - min).is_finite() || max <= min {
(min, max) = (min.min(0.0), min.min(0.0) + 1.0);
}
let log_base = axis
.and_then(|axis| axis.log_base)
.filter(|base| *base > 1.0 && base.is_finite() && min > 0.0);
let unit = axis
.and_then(|axis| axis.major_unit)
.filter(|unit| unit.is_finite() && *unit > 0.0)
.unwrap_or_else(|| nice_unit((max - min) / target_intervals(plot.w)));
if log_base.is_none() {
if axis.and_then(|axis| axis.range.min).is_none() {
min = round_to_unit(min, unit, false);
}
if axis.and_then(|axis| axis.range.max).is_none() {
max = round_to_unit(max, unit, true);
}
}
ValueScale {
min,
max,
unit,
log_base,
reversed: axis.is_some_and(|axis| axis.reversed),
percent: false,
}
}
fn emit_scatter_x_labels<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
family: PlotFamily<'_>,
plot: PlotArea,
scale: ValueScale,
) {
if !family.series.iter().any(|series| {
(0..series.series.x_values.len().min(MAX_PLOT_DATA_SCAN))
.any(|index| series.xy_value(index).is_some())
}) {
return;
}
let format = family.x_axis.and_then(|axis| axis.number_format);
for value in axis_ticks(scale, family.x_axis.and_then(|axis| axis.major_unit)) {
if ops.exhausted() {
return;
}
let text = scale.format(value, format);
let width = text_width(&text, family.label(), ops);
push_text_aligned(
ops,
&text,
scale.x(plot, value) - width,
baseline_below(plot.y + plot.h, family.label()),
width * 2.0,
family.label(),
PlotTextAlign::Center,
);
}
}
fn scatter_parts(style: Option<&str>) -> (bool, bool) {
match style {
Some("line" | "smooth") => (true, false),
Some("marker") => (false, true),
Some("none") => (false, false),
_ => (true, true),
}
}
fn emit_scatter<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
family: PlotFamily<'_>,
plot: PlotArea,
) {
let count = category_count(family.series);
if count == 0 || family.series.is_empty() {
return;
}
emit_axes(ops, family, plot);
let y_scale = value_scale(family);
let x_scale = scatter_x_scale(family, plot);
emit_scatter_x_labels(ops, family, plot, x_scale);
let (lines, markers) = scatter_parts(family.group.and_then(|group| group.scatter_style));
for (ser_idx, series) in family.series.iter().enumerate() {
let color = series_color(Some(series.series), ser_idx);
let width = series_line_width(series.series);
let mut prev: Option<(f64, f64)> = None;
for i in 0..series.length().min(MAX_PLOT_DATA_SCAN) {
if ops.exhausted() {
return;
}
let Some((x_value, y_value)) = series.xy_value(i) else {
prev = None;
continue;
};
let x = x_scale.x(plot, x_value);
let y = y_scale.y(plot, y_value);
if lines && let (Some(width), Some((prev_x, prev_y))) = (width, prev) {
push_line(ops, prev_x, prev_y, x, y, &color, width);
}
if markers {
push_marker(
ops,
series.marker_symbol(i, ser_idx),
x,
y,
series.marker_size(i),
&series.point_color(i, ser_idx),
);
}
let visible = markers && series.marker_symbol(i, ser_idx).is_some();
push_marker_label(
ops,
family,
series,
ser_idx,
i,
(x, y),
if visible { series.marker_size(i) } else { 0.0 },
category_total(family, i),
);
prev = Some((x, y));
}
}
}
fn emit_bubble<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
family: PlotFamily<'_>,
plot: PlotArea,
) {
let count = category_count(family.series);
if count == 0 || family.series.is_empty() {
return;
}
emit_axes(ops, family, plot);
let y_scale = value_scale(family);
let x_scale = scatter_x_scale(family, plot);
emit_scatter_x_labels(ops, family, plot, x_scale);
let group = family.group;
let scale_percent = group
.and_then(|group| group.bubble_scale)
.filter(|scale| scale.is_finite())
.unwrap_or(100.0)
.clamp(0.0, 300.0)
/ 100.0;
let by_area = group.and_then(|group| group.size_represents) != Some("w");
let largest = family
.series
.iter()
.flat_map(|series| {
(0..series.length().min(MAX_PLOT_DATA_SCAN))
.filter_map(move |index| series.xy_value(index).map(|_| series.bubble_size(index)))
})
.fold(0.0_f64, f64::max);
let max_radius = plot.w.min(plot.h) * 0.125 * scale_percent;
for (ser_idx, series) in family.series.iter().enumerate() {
for i in 0..series.length().min(MAX_PLOT_DATA_SCAN) {
if ops.exhausted() {
return;
}
let Some((x_value, y_value)) = series.xy_value(i) else {
continue;
};
let size = series.bubble_size(i);
if size <= 0.0 || largest <= 0.0 {
continue;
}
let ratio = (size / largest).clamp(0.0, 1.0);
let radius = (max_radius * if by_area { ratio.sqrt() } else { ratio }).max(1.0);
let x = x_scale.x(plot, x_value);
let y = y_scale.y(plot, y_value);
ops.push(PlotOp::Path {
x: x - radius,
y: y - radius,
w: radius * 2.0,
h: radius * 2.0,
commands: circle_path(x, y, radius),
fill: series.point_color(i, ser_idx),
stroke: Some(PlotStroke {
color: CHART_BACKGROUND_COLOR.to_owned(),
width: 1.0,
}),
});
push_point_label(
ops,
family,
series,
ser_idx,
i,
x,
y,
48.0,
category_total(family, i),
PlotTextAlign::Start,
);
}
}
}
fn emit_radar<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
family: PlotFamily<'_>,
plot: PlotArea,
x: f64,
y: f64,
width: f64,
height: f64,
) {
let cat_count = category_count(family.series);
if cat_count == 0 || family.series.is_empty() {
return;
}
let scale = value_scale(family);
let spokes = cat_count.min(MAX_PLOT_POLYGON_POINTS);
let radius = (width.min(height) * 0.34).max(6.0_f64.min(width.min(height) / 2.0).max(0.0));
let (cx, cy) = (x + width * 0.38, y + height * 0.5);
let angle = |index: usize| {
-std::f64::consts::FRAC_PI_2
+ std::f64::consts::TAU * category_position(family, index, spokes) as f64
/ spokes as f64
};
let at = |index: usize, value: f64| {
let reach = radius * scale.ratio(value);
let angle = angle(index);
(cx + reach * angle.cos(), cy + reach * angle.sin())
};
let rings = !family
.axis
.is_some_and(|axis| draws_no_line(axis.major_gridline));
for value in axis_ticks(scale, family.axis.and_then(|axis| axis.major_unit)) {
if !rings || ops.exhausted() || scale.ratio(value) <= 0.0 {
continue;
}
let ring: Vec<(f64, f64)> = (0..spokes).map(|index| at(index, value)).collect();
for (from, to) in ring_edges(&ring) {
push_line(ops, from.0, from.1, to.0, to.1, CHART_GRID_COLOR, 0.5);
}
}
for index in 0..spokes {
if ops.exhausted() {
return;
}
let outer = at(index, scale.max);
push_line(ops, cx, cy, outer.0, outer.1, CHART_AXIS_COLOR, 0.5);
push_text(
ops,
&category_label(family.series, index),
cx + (outer.0 - cx) * 1.1 - 16.0,
cy + (outer.1 - cy) * 1.1,
32.0,
family.label(),
);
}
let style = family.group.and_then(|group| group.radar_style);
let filled = style == Some("filled");
for (ser_idx, series) in family.series.iter().enumerate() {
if ops.exhausted() {
return;
}
let color = series_color(Some(series.series), ser_idx);
let ring: Vec<(f64, f64)> = (0..spokes)
.map(|index| at(index, series.value(index)))
.collect();
if filled {
let mut commands = Vec::with_capacity(spokes + 1);
for (index, (x, y)) in ring.iter().enumerate() {
commands.push(if index == 0 {
GeometryPathCommand::Move { x: *x, y: *y }
} else {
GeometryPathCommand::Line { x: *x, y: *y }
});
}
commands.push(GeometryPathCommand::Close);
push_path(ops, plot, commands, &color, None);
}
if !filled && let Some(width) = series_line_width(series.series) {
for (from, to) in ring_edges(&ring) {
if ops.exhausted() {
return;
}
push_line(ops, from.0, from.1, to.0, to.1, &color, width);
}
}
for (index, (x, y)) in ring.iter().enumerate() {
if ops.exhausted() {
return;
}
if style != Some("standard") && !filled {
push_marker(
ops,
series.marker_symbol(index, ser_idx),
*x,
*y,
series.marker_size(index),
&series.point_color(index, ser_idx),
);
}
push_point_label(
ops,
family,
series,
ser_idx,
index,
*x,
*y - 4.0,
48.0,
category_total(family, index),
PlotTextAlign::Start,
);
}
}
}
fn ring_edges(ring: &[(f64, f64)]) -> impl Iterator<Item = ((f64, f64), (f64, f64))> + '_ {
let closes = ring.len() > 2;
ring.iter()
.enumerate()
.filter(move |(index, _)| closes || *index + 1 < ring.len())
.map(move |(index, point)| (*point, ring[(index + 1) % ring.len()]))
}
fn stock_roles(count: usize) -> Option<(Option<usize>, usize, usize, usize)> {
match count {
3 => Some((None, 0, 1, 2)),
4 => Some((Some(0), 1, 2, 3)),
n if n > 4 => Some((Some(n - 4), n - 3, n - 2, n - 1)),
_ => None,
}
}
fn emit_stock<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
family: PlotFamily<'_>,
plot: PlotArea,
) {
let cat_count = category_count(family.series);
let Some((open, high, low, close)) = stock_roles(family.series.len()) else {
emit_line(ops, family, plot);
return;
};
if cat_count == 0 {
return;
}
emit_axes(ops, family, plot);
let scale = value_scale(family);
let bands = bar_bands(family, cat_count, plot.w);
let hi_lo = family.group.is_none_or(|group| group.hi_low_lines) || open.is_none();
let up_down = open.is_some() && family.group.is_none_or(|group| group.up_down_bars);
for cat_idx in 0..cat_count {
if ops.exhausted() {
return;
}
let slot = bands.slot * category_position(family, cat_idx, cat_count) as f64;
let center = plot.x + slot + bands.slot / 2.0;
push_text(
ops,
&category_label(family.series, cat_idx),
plot.x + slot + 2.0,
baseline_below(plot.y + plot.h, family.label()),
bands.slot - 4.0,
family.label(),
);
let value = |index: usize| family.series[index].value(cat_idx);
let (high_y, low_y) = (scale.y(plot, value(high)), scale.y(plot, value(low)));
if hi_lo {
push_line(ops, center, high_y, center, low_y, CHART_AXIS_COLOR, 1.0);
}
let tick = (bands.slot * 0.25).clamp(1.0, 12.0);
match open {
Some(open) if up_down => {
let (open_y, close_y) = (scale.y(plot, value(open)), scale.y(plot, value(close)));
let rising = value(close) >= value(open);
push_rect(
ops,
center - tick,
open_y.min(close_y),
tick * 2.0,
(open_y - close_y).abs().max(1.0),
if rising {
CHART_BACKGROUND_COLOR
} else {
CHART_AXIS_COLOR
},
);
if rising {
push_line(
ops,
center - tick,
open_y.min(close_y),
center - tick,
open_y.max(close_y),
CHART_AXIS_COLOR,
1.0,
);
}
}
Some(open) => {
let open_y = scale.y(plot, value(open));
push_line(
ops,
center - tick,
open_y,
center,
open_y,
CHART_AXIS_COLOR,
1.0,
);
let close_y = scale.y(plot, value(close));
push_line(
ops,
center,
close_y,
center + tick,
close_y,
CHART_AXIS_COLOR,
1.0,
);
}
None => {
let close_y = scale.y(plot, value(close));
push_line(
ops,
center,
close_y,
center + tick,
close_y,
CHART_AXIS_COLOR,
1.0,
);
}
}
}
}
fn contour_color(ratio: f64) -> &'static str {
const RAMP: [&str; 8] = [
"#264478", "#4472C4", "#5B9BD5", "#70AD47", "#A9D18E", "#FFC000", "#ED7D31", "#9E480E",
];
let slot = (ratio.clamp(0.0, 1.0) * (RAMP.len() - 1) as f64).round() as usize;
RAMP[slot.min(RAMP.len() - 1)]
}
fn emit_surface<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
family: PlotFamily<'_>,
plot: PlotArea,
) {
let cat_count = category_count(family.series);
let rows = family.series.len();
if cat_count == 0 || rows == 0 {
return;
}
push_line(
ops,
plot.x,
plot.y,
plot.x,
plot.y + plot.h,
CHART_AXIS_COLOR,
1.0,
);
push_line(
ops,
plot.x,
plot.y + plot.h,
plot.x + plot.w,
plot.y + plot.h,
CHART_AXIS_COLOR,
1.0,
);
let scale = value_scale(family);
let columns = cat_count.min(MAX_PLOT_SURFACE_CELLS / rows.max(1));
let cell_w = plot.w / columns.max(1) as f64;
let cell_h = plot.h / rows as f64;
let wireframe = family.group.and_then(|group| group.wireframe) == Some(true);
for (row, series) in family.series.iter().enumerate() {
if ops.exhausted() {
return;
}
let y = plot.y + plot.h - cell_h * (row + 1) as f64;
for column in 0..columns {
let x = plot.x + cell_w * category_position(family, column, columns) as f64;
if wireframe {
push_line(ops, x, y, x + cell_w, y, CHART_AXIS_COLOR, 0.5);
push_line(ops, x, y, x, y + cell_h, CHART_AXIS_COLOR, 0.5);
} else {
let color = contour_color(scale.fraction(series.value(column)));
push_rect(ops, x, y, cell_w, cell_h, color);
}
}
push_text(
ops,
series.series.name.unwrap_or_default(),
plot.x - 38.0,
y + cell_h * 0.6,
36.0,
family.label(),
);
}
for column in 0..columns {
if ops.exhausted() {
return;
}
push_text(
ops,
&category_label(family.series, column),
plot.x + cell_w * category_position(family, column, columns) as f64 + 2.0,
baseline_below(plot.y + plot.h, family.label()),
cell_w - 4.0,
family.label(),
);
}
}
fn push_path<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
plot: PlotArea,
commands: Vec<GeometryPathCommand>,
fill: &str,
stroke: Option<PlotStroke>,
) {
if commands.is_empty() {
return;
}
ops.push(PlotOp::Path {
x: plot.x,
y: plot.y,
w: plot.w,
h: plot.h,
commands,
fill: fill.to_owned(),
stroke,
});
}
fn circle_path(cx: f64, cy: f64, r: f64) -> Vec<GeometryPathCommand> {
polygon_path(cx, cy, r, 24, 0.0)
}
fn polygon_path(cx: f64, cy: f64, r: f64, sides: usize, rotation: f64) -> Vec<GeometryPathCommand> {
let mut commands = Vec::with_capacity(sides + 1);
for index in 0..sides {
let angle = rotation + std::f64::consts::TAU * index as f64 / sides as f64;
let (x, y) = (cx + r * angle.cos(), cy + r * angle.sin());
commands.push(if index == 0 {
GeometryPathCommand::Move { x, y }
} else {
GeometryPathCommand::Line { x, y }
});
}
commands.push(GeometryPathCommand::Close);
commands
}
fn marker_path(symbol: PlotMarkerSymbol, cx: f64, cy: f64, size: f64) -> Vec<GeometryPathCommand> {
let half = size / 2.0;
let bar = |width: f64, height: f64| {
vec![
GeometryPathCommand::Move {
x: cx - width,
y: cy - height,
},
GeometryPathCommand::Line {
x: cx + width,
y: cy - height,
},
GeometryPathCommand::Line {
x: cx + width,
y: cy + height,
},
GeometryPathCommand::Line {
x: cx - width,
y: cy + height,
},
GeometryPathCommand::Close,
]
};
match symbol {
PlotMarkerSymbol::Circle | PlotMarkerSymbol::Auto | PlotMarkerSymbol::None => {
circle_path(cx, cy, half)
}
PlotMarkerSymbol::Dot => circle_path(cx, cy, half * 0.5),
PlotMarkerSymbol::Diamond => polygon_path(cx, cy, half, 4, -std::f64::consts::FRAC_PI_2),
PlotMarkerSymbol::Triangle => polygon_path(cx, cy, half, 3, -std::f64::consts::FRAC_PI_2),
PlotMarkerSymbol::Square => polygon_path(cx, cy, half, 4, std::f64::consts::FRAC_PI_4),
PlotMarkerSymbol::Star => star_path(cx, cy, half),
PlotMarkerSymbol::Plus => cross_path(cx, cy, half, 0.0),
PlotMarkerSymbol::X => cross_path(cx, cy, half, std::f64::consts::FRAC_PI_4),
PlotMarkerSymbol::Dash => bar(half, half * 0.25),
}
}
fn star_path(cx: f64, cy: f64, r: f64) -> Vec<GeometryPathCommand> {
let mut commands = Vec::with_capacity(11);
for index in 0..10 {
let reach = if index % 2 == 0 { r } else { r * 0.42 };
let angle = -std::f64::consts::FRAC_PI_2 + std::f64::consts::PI * index as f64 / 5.0;
let (x, y) = (cx + reach * angle.cos(), cy + reach * angle.sin());
commands.push(if index == 0 {
GeometryPathCommand::Move { x, y }
} else {
GeometryPathCommand::Line { x, y }
});
}
commands.push(GeometryPathCommand::Close);
commands
}
fn cross_path(cx: f64, cy: f64, r: f64, rotation: f64) -> Vec<GeometryPathCommand> {
const ARM: f64 = 0.3;
let corners = [
(-ARM, -1.0),
(ARM, -1.0),
(ARM, -ARM),
(1.0, -ARM),
(1.0, ARM),
(ARM, ARM),
(ARM, 1.0),
(-ARM, 1.0),
(-ARM, ARM),
(-1.0, ARM),
(-1.0, -ARM),
(-ARM, -ARM),
];
let (sin, cos) = rotation.sin_cos();
let mut commands = Vec::with_capacity(corners.len() + 1);
for (index, (dx, dy)) in corners.into_iter().enumerate() {
let (x, y) = (
cx + r * (dx * cos - dy * sin),
cy + r * (dx * sin + dy * cos),
);
commands.push(if index == 0 {
GeometryPathCommand::Move { x, y }
} else {
GeometryPathCommand::Line { x, y }
});
}
commands.push(GeometryPathCommand::Close);
commands
}
fn push_marker<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
symbol: Option<PlotMarkerSymbol>,
x: f64,
y: f64,
size: f64,
color: &str,
) {
let Some(symbol) = symbol else {
return;
};
if symbol == PlotMarkerSymbol::Square {
push_rect(ops, x - size / 2.0, y - size / 2.0, size, size, color);
return;
}
ops.push(PlotOp::Path {
x: x - size / 2.0,
y: y - size / 2.0,
w: size,
h: size,
commands: marker_path(symbol, x, y, size),
fill: color.to_owned(),
stroke: None,
});
}
fn emit_pie<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
family: PlotFamily<'_>,
x: f64,
y: f64,
width: f64,
height: f64,
) {
let Some(series) = family.series.first() else {
return;
};
let scanned = series
.series
.values
.len()
.max(series.series.points.len())
.min(MAX_PLOT_DATA_SCAN);
let available = ops.remaining;
let total: f64 = (0..scanned)
.map(|index| (index, series.value(index)))
.filter(|(_, value)| *value > 0.0 && value.is_finite())
.take(available)
.map(|(_, value)| value)
.sum();
if total <= 0.0 {
return;
}
let r = (width.min(height) * 0.45).max(10.0_f64.min(width.min(height) / 2.0).max(0.0));
let cx = x + width / 2.0;
let cy = y + height / 2.0;
let group = family.group;
let inner_r = if family.chart_type == "doughnut" {
r * group
.and_then(|group| group.hole_size)
.filter(|size| size.is_finite())
.unwrap_or(50.0)
.clamp(1.0, 90.0)
/ 100.0
} else {
0.0
};
let start = -std::f64::consts::FRAC_PI_2
+ group
.and_then(|group| group.first_slice_angle)
.filter(|angle| angle.is_finite())
.unwrap_or(0.0)
.rem_euclid(360.0)
.to_radians();
let mut angle = start;
for (index, value) in (0..scanned)
.map(|index| (index, series.value(index)))
.filter(|(_, value)| *value > 0.0 && value.is_finite())
.take(available)
{
let sweep = (value / total) * std::f64::consts::TAU;
let middle = angle + sweep / 2.0;
let offset = r * series.explosion(index) / 100.0;
let (ox, oy) = (cx + offset * middle.cos(), cy + offset * middle.sin());
let color = series.point_color(index, index);
ops.push(PlotOp::Path {
x: ox - r,
y: oy - r,
w: r * 2.0,
h: r * 2.0,
commands: pie_wedge_path(ox, oy, r, inner_r, angle, angle + sweep),
fill: color,
stroke: Some(PlotStroke {
color: CHART_BACKGROUND_COLOR.to_owned(),
width: 1.0,
}),
});
let reach =
r * pie_label_reach(point_label_spec(series, index).and_then(|labels| labels.position));
push_point_label(
ops,
family,
series,
index,
index,
ox + reach * middle.cos(),
oy + reach * middle.sin(),
48.0,
total,
PlotTextAlign::Start,
);
angle += sweep;
}
}
fn pie_wedge_path(
cx: f64,
cy: f64,
r: f64,
inner_r: f64,
start: f64,
end: f64,
) -> Vec<GeometryPathCommand> {
let steps = (((end - start).abs() / std::f64::consts::TAU) * 48.0)
.ceil()
.max(2.0) as usize;
let mut path = Vec::new();
if inner_r > 0.0 {
path.push(GeometryPathCommand::Move {
x: cx + r * start.cos(),
y: cy + r * start.sin(),
});
} else {
path.push(GeometryPathCommand::Move { x: cx, y: cy });
path.push(GeometryPathCommand::Line {
x: cx + r * start.cos(),
y: cy + r * start.sin(),
});
}
for i in 1..=steps {
let a = start + (end - start) * i as f64 / steps as f64;
path.push(GeometryPathCommand::Line {
x: cx + r * a.cos(),
y: cy + r * a.sin(),
});
}
if inner_r > 0.0 {
path.push(GeometryPathCommand::Line {
x: cx + inner_r * end.cos(),
y: cy + inner_r * end.sin(),
});
for i in (0..steps).rev() {
let a = start + (end - start) * i as f64 / steps as f64;
path.push(GeometryPathCommand::Line {
x: cx + inner_r * a.cos(),
y: cy + inner_r * a.sin(),
});
}
}
path.push(GeometryPathCommand::Close);
path
}
#[allow(clippy::too_many_arguments)]
fn emit_legend<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
chart: &PlotChart<'_>,
band: LegendBand,
style: &ResolvedText,
) {
if !has_legend(chart) || band.w <= 0.0 {
return;
}
if band.horizontal {
emit_legend_rows(ops, band, style);
return;
}
let size = style.font.size_px;
let key = size * LEGEND_KEY_EM;
let lead = size * (LEGEND_KEY_EM + LEGEND_KEY_GAP_EM);
let mut top = band.y;
for row in &band.rows {
let Some(entry) = row.entries.first() else {
continue;
};
let middle = top + size * LEGEND_PITCH_EM / 2.0;
push_rect(ops, band.x, middle - key / 2.0, key, key, &entry.color);
for (index, line) in entry.lines.iter().enumerate() {
push_text(
ops,
line,
band.x + lead,
middle + size * (LEGEND_BASELINE_EM + 1.22 * index as f64),
(band.w - lead + LEGEND_TEXT_SLACK).max(1.0),
style,
);
}
top += row.height;
}
}
fn legend_entries(chart: &PlotChart<'_>, budget: &mut ScanBudget) -> Vec<(String, String)> {
let series: Vec<&PlotSeries<'_>> = if chart.plot_groups.is_empty() {
chart.series.iter().take(MAX_LEGEND_ENTRIES).collect()
} else {
chart
.plot_groups
.iter()
.flat_map(|group| group.series.iter())
.take(MAX_LEGEND_ENTRIES)
.collect()
};
let varied_bar = series.len() == 1
&& chart.plot_groups.iter().any(|group| {
group.vary_colors
&& matches!(
group.chart_type.unwrap_or(chart.chart_type),
"bar" | "column"
)
});
let pie_legend = varied_bar
|| matches!(chart.chart_type, "pie" | "doughnut" | "ofPie")
|| chart.plot_groups.iter().any(|group| {
matches!(
group.chart_type,
Some("pie") | Some("doughnut") | Some("ofPie")
)
});
if pie_legend {
series
.as_slice()
.first()
.map(|series| SeriesView::new(series, budget))
.map(|series| {
(0..series.length().min(MAX_LEGEND_ENTRIES))
.map(|i| {
(
series
.series
.categories
.get(i)
.cloned()
.unwrap_or_else(|| (i + 1).to_string()),
series.point_color(i, i),
)
})
.collect()
})
.unwrap_or_default()
} else {
series
.iter()
.enumerate()
.map(|(i, series)| {
(
series
.name
.map(str::to_owned)
.unwrap_or_else(|| format!("Series {}", i + 1)),
series_color(Some(series), i),
)
})
.collect()
}
}
fn emit_legend_rows<S: PlotSink + ?Sized>(
ops: &mut Emitter<'_, S>,
band: LegendBand,
style: &ResolvedText,
) {
let size = style.font.size_px;
let key = size * LEGEND_KEY_EM;
let lead = size * (LEGEND_KEY_EM + LEGEND_KEY_GAP_EM);
let gap = size * LEGEND_ENTRY_GAP_EM;
let line = size * TITLE_LINE_EM;
let mut y = band.y;
for row in &band.rows {
let mut x = band.x + ((band.w - row.width) / 2.0).max(0.0);
let middle = y + row.height / 2.0;
for entry in &row.entries {
push_rect(ops, x, middle - key / 2.0, key, key, &entry.color);
let first = middle - line * (entry.lines.len() - 1) as f64 / 2.0;
for (index, text) in entry.lines.iter().enumerate() {
push_text(
ops,
text,
x + lead,
first + size * LEGEND_BASELINE_EM + line * index as f64,
(entry.width - lead + LEGEND_TEXT_SLACK).max(1.0),
style,
);
}
x += entry.width + gap;
}
y += row.height;
}
}
pub fn format_number(value: f64) -> String {
if value.abs() >= 100.0 || value.fract().abs() < 0.01 {
format!("{value:.0}")
} else {
format!("{value:.1}")
}
}
pub fn format_percent(value: f64) -> String {
let scaled = value * 100.0;
let snapped = if (scaled - scaled.round()).abs() < 1e-6 {
scaled.round()
} else {
scaled
};
format_number(snapped) + "%"
}
pub fn format_with_code(value: f64, code: &str) -> Option<String> {
if !value.is_finite() || code.is_empty() {
return None;
}
let sections = format_sections(code);
let (section, signed) = match (value, sections.as_slice()) {
(value, [_, negative, ..]) if value < 0.0 => (*negative, false),
(0.0, [_, _, zero, ..]) => (*zero, false),
_ => (sections[0], true),
};
if section.eq_ignore_ascii_case("general") {
return Some(format_number(value));
}
let section = &strip_modifiers(section)?;
if section.contains(['y', 'd', 'h', 's', 'E', 'e']) || section.contains("m/") {
return None;
}
if !section.contains(['0', '#', '?']) {
let (leading, trailing) = literals(section);
return Some(leading + &trailing);
}
let pattern = placeholders(section);
let (whole_pattern, fraction_pattern) = match pattern.split_once('.') {
Some((whole, fraction)) => (whole, Some(fraction)),
None => (pattern.as_str(), None),
};
let digits = fraction_pattern.map_or(0, |fraction| fraction.len().min(9));
let percent = section.contains('%');
let scaled = if percent { value * 100.0 } else { value };
let factor = 10_f64.powi(digits as i32);
let rounded = (scaled.abs() * factor).round() / factor;
let mut body = placeholder_digits(rounded, whole_pattern, fraction_pattern, digits);
if section.contains(',') {
body = group_thousands(&body);
}
let (leading, trailing) = literals(section);
let mut out = String::new();
if signed && scaled < 0.0 && !body.trim_start_matches(['0', '.', ',']).is_empty() {
out.push('-');
}
out.push_str(&leading);
out.push_str(&body);
out.push_str(&trailing);
if percent {
out.push('%');
}
Some(out)
}
fn format_sections(code: &str) -> Vec<&str> {
let mut sections = Vec::new();
let mut start = 0;
let mut quoted = false;
let mut literal = false;
for (index, character) in code.char_indices() {
if literal {
literal = false;
continue;
}
match character {
'"' => quoted = !quoted,
'\\' | '_' | '*' if !quoted => literal = true,
';' if !quoted => {
sections.push(&code[start..index]);
start = index + 1;
}
_ => {}
}
}
sections.push(&code[start..]);
sections
}
fn strip_modifiers(section: &str) -> Option<String> {
const COLORS: [&str; 8] = [
"black", "blue", "cyan", "green", "magenta", "red", "white", "yellow",
];
let mut out = String::with_capacity(section.len());
let mut rest = section;
while let Some(open) = rest.find('[') {
let close = rest[open..].find(']')? + open;
let inside = &rest[open + 1..close];
out.push_str(&rest[..open]);
rest = &rest[close + 1..];
if let Some(currency) = inside.strip_prefix('$') {
let symbol = currency
.split_once('-')
.map_or(currency, |(symbol, _)| symbol);
for character in symbol.chars() {
out.push('\\');
out.push(character);
}
continue;
}
let known = COLORS.iter().any(|name| inside.eq_ignore_ascii_case(name))
|| inside.to_ascii_lowercase().starts_with("color");
if !known {
return None;
}
}
out.push_str(rest);
Some(out)
}
fn literals(code: &str) -> (String, String) {
let placeholder = |c: char| matches!(c, '0' | '#' | '.' | ',' | '%' | '?');
let mut leading = String::new();
let mut trailing = String::new();
let mut past_digits = false;
let mut chars = code.chars();
while let Some(character) = chars.next() {
let target = if past_digits {
&mut trailing
} else {
&mut leading
};
match character {
'"' => target.extend(chars.by_ref().take_while(|c| *c != '"')),
'\\' => target.extend(chars.next()),
'_' | '*' => {
chars.next();
}
character if placeholder(character) => past_digits = true,
character => target.push(character),
}
}
(leading, trailing)
}
fn placeholders(code: &str) -> String {
let mut pattern = String::new();
let mut chars = code.chars();
while let Some(character) = chars.next() {
match character {
'"' => chars.by_ref().take_while(|c| *c != '"').for_each(drop),
'\\' | '_' | '*' => {
chars.next();
}
'0' | '#' | '?' => pattern.push(character),
'.' if !pattern.contains('.') => pattern.push('.'),
_ => {}
}
}
pattern
}
fn placeholder_digits(
rounded: f64,
whole_pattern: &str,
fraction_pattern: Option<&str>,
digits: usize,
) -> String {
let fixed = format!("{rounded:.digits$}");
let (whole, fraction) = fixed.split_once('.').unwrap_or((fixed.as_str(), ""));
let least = whole_pattern.matches('0').count();
let mut out = if whole == "0" && least == 0 {
String::new()
} else {
format!("{whole:0>least$}")
};
if let Some(places) = fraction_pattern {
let places = places.as_bytes();
let mut kept = fraction.as_bytes().to_vec();
let mut end = kept.len();
while end > 0 && kept[end - 1] == b'0' && places.get(end - 1).is_some_and(|p| *p != b'0') {
if places[end - 1] == b'?' {
kept[end - 1] = b' ';
} else {
kept.remove(end - 1);
}
end -= 1;
}
out.push('.');
out.extend(kept.into_iter().map(char::from));
}
out
}
fn group_thousands(body: &str) -> String {
let (whole, rest) = match body.split_once('.') {
Some((whole, rest)) => (whole, Some(rest)),
None => (body, None),
};
let mut grouped =
String::with_capacity(whole.len() + whole.len() / 3 + rest.map_or(0, str::len) + 1);
for (index, digit) in whole.chars().enumerate() {
if index > 0 && (whole.len() - index) % 3 == 0 {
grouped.push(',');
}
grouped.push(digit);
}
if let Some(rest) = rest {
grouped.push('.');
grouped.push_str(rest);
}
grouped
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chart::{
ChartDataLabels, ChartPlotGroup, ChartPointLabel, ChartSeries, ChartTextProperties,
};
const DEFAULT_MARKER_PX: f64 = DEFAULT_MARKER_PT * 4.0 / 3.0;
#[test]
fn a_gradient_with_many_opaque_stops_keeps_its_colour() {
let stops = vec!["#FFFFFF".to_owned(); 70_000];
assert_eq!(mean_hex(&stops).as_deref(), Some("#FFFFFF"));
}
#[test]
fn a_gradient_with_a_stop_that_does_not_parse_has_no_mean() {
let stops = ["#00000000".to_owned(), "invalid".to_owned()];
assert_eq!(mean_hex(&stops), None);
assert_eq!(mean_hex(&[]), None);
}
#[test]
fn an_unmeasured_legend_label_is_never_negative_and_counts_gaps_between() {
let font = |spacing: f64| PlotFont {
weight: 400,
size_px: 12.0,
family: "Arial".to_owned(),
italic: false,
letter_spacing_px: spacing,
};
assert!(fallback_label_width("Series", &font(-40.0)) >= 0.0);
let loose = fallback_label_width("Series", &font(2.0));
let plain = fallback_label_width("Series", &font(0.0));
assert!((loose - plain - 10.0).abs() < 1e-9, "{loose} vs {plain}");
}
struct Source {
categories: Vec<String>,
values: Vec<f64>,
}
fn source(values: &[f64]) -> Source {
Source {
categories: vec!["Q1".to_owned(), "Q2".to_owned()],
values: values.to_vec(),
}
}
fn series<'a>(name: &'a str, source: &'a Source) -> PlotSeries<'a> {
PlotSeries {
name: Some(name),
categories: &source.categories,
values: &source.values,
..PlotSeries::default()
}
}
fn labelled_space(
chart_type: &str,
group_labels: Option<ChartDataLabels>,
series_labels: Option<ChartDataLabels>,
) -> ChartSpace {
ChartSpace {
chart_type: chart_type.to_owned(),
plot_groups: vec![ChartPlotGroup {
chart_type: Some(chart_type.to_owned()),
data_labels: group_labels,
series: vec![ChartSeries {
categories: vec!["Q1".to_owned(), "Q2".to_owned()],
values: vec![3.0, 1.0],
color: "#4472C4".to_owned(),
data_labels: series_labels,
..ChartSeries::default()
}],
..ChartPlotGroup::default()
}],
..ChartSpace::default()
}
}
fn model_series(name: &str, values: &[f64]) -> ChartSeries {
ChartSeries {
name: Some(name.to_owned()),
categories: values
.iter()
.enumerate()
.map(|(index, _)| format!("Q{}", index + 1))
.collect(),
values: values.to_vec(),
color: "#4472C4".to_owned(),
..ChartSeries::default()
}
}
fn model_space(
chart_type: &str,
group_labels: Option<ChartDataLabels>,
series: Vec<ChartSeries>,
) -> ChartSpace {
ChartSpace {
chart_type: chart_type.to_owned(),
plot_groups: vec![ChartPlotGroup {
chart_type: Some(chart_type.to_owned()),
data_labels: group_labels,
series,
..ChartPlotGroup::default()
}],
..ChartSpace::default()
}
}
fn rect() -> PlotRect {
PlotRect {
x: 0.0,
y: 0.0,
w: 300.0,
h: 200.0,
}
}
fn family<'a>(
chart: &'a PlotChart<'a>,
series: &'a [SeriesView<'a>],
label: &'a ResolvedText,
) -> PlotFamily<'a> {
PlotFamily {
chart_type: chart.chart_type,
series,
value_axis: chart.value_axis,
axis_titles: chart.axis_titles,
group: chart.plot_groups.first(),
chart_text: chart.text.chart,
label,
axis: None,
x_axis: None,
category_axis: None,
secondary: false,
}
}
#[test]
fn fonts_render_the_css_shorthand() {
assert_eq!(chart_label_font().css(), "400 10px Calibri, sans-serif");
assert_eq!(chart_title_font().css(), "600 13px Calibri, sans-serif");
assert_eq!(
PlotTextStyle {
font: Some("Georgia"),
size_pt: Some(18.0),
bold: Some(true),
italic: Some(true),
color: Some("FF0000"),
spacing_pt: None,
}
.resolve(CHART_LABEL_SIZE_PX, 400)
.font
.css(),
"italic 700 24px Georgia"
);
}
#[test]
fn column_chart_emits_background_title_axes_bars_and_legend() {
let north = source(&[10.0, 20.0]);
let chart = PlotChart {
chart_type: "column",
title: Some("Revenue"),
series: vec![series("North", &north)],
..PlotChart::default()
};
let ops = plot_chart(&chart, rect());
assert!(matches!(&ops[0], PlotOp::Rect { fill, .. } if fill == CHART_BACKGROUND_COLOR));
assert!(matches!(&ops[1], PlotOp::Text { text, font, .. }
if text == "Revenue" && *font == chart_title_font()));
assert_eq!(
ops.iter()
.filter(|op| matches!(op, PlotOp::Line { .. }))
.count(),
8
);
assert!(
ops.iter()
.any(|op| matches!(op, PlotOp::Text { text, .. } if text == "North"))
);
assert_eq!(
chart_aria_label(&chart),
"Revenue, column chart, 1 series, 2 categories"
);
}
#[test]
fn pie_chart_emits_one_closed_wedge_per_positive_value() {
let share = source(&[3.0, 0.0, 1.0]);
let chart = PlotChart {
chart_type: "pie",
series: vec![series("Share", &share)],
..PlotChart::default()
};
let ops = plot_chart(&chart, rect());
let paths: Vec<&PlotOp> = ops
.iter()
.filter(|op| matches!(op, PlotOp::Path { .. }))
.collect();
assert_eq!(paths.len(), 2);
assert!(
paths
.iter()
.all(|op| matches!(op, PlotOp::Path { commands, .. }
if matches!(commands.last(), Some(GeometryPathCommand::Close))))
);
}
#[test]
fn axis_titles_draw_beside_the_axes_they_name() {
let north = source(&[10.0, 20.0]);
for chart_type in ["column", "bar", "line"] {
let chart = PlotChart {
chart_type,
axis_titles: PlotAxisTitles {
category: Some("Quarter"),
value: Some("Millions"),
},
series: vec![series("North", &north)],
..PlotChart::default()
};
let ops = plot_chart(&chart, rect());
for title in ["Quarter", "Millions"] {
assert!(
ops.iter()
.any(|op| matches!(op, PlotOp::Text { text, .. } if text == title)),
"{chart_type} drops {title}"
);
}
}
let pie = PlotChart {
chart_type: "pie",
axis_titles: PlotAxisTitles {
category: Some("Quarter"),
value: Some("Millions"),
},
series: vec![series("North", &north)],
..PlotChart::default()
};
assert!(
!plot_chart(&pie, rect())
.iter()
.any(|op| matches!(op, PlotOp::Text { text, .. } if text == "Millions"))
);
}
#[test]
fn a_value_axis_title_turns_only_for_a_host_that_can_draw_it_turned() {
struct Flat(Vec<PlotOp>);
impl PlotSink for Flat {
fn push_op(&mut self, op: PlotOp) -> bool {
self.0.push(op);
true
}
}
let north = source(&[10.0, 20.0]);
let chart = PlotChart {
chart_type: "column",
axis_titles: PlotAxisTitles {
category: None,
value: Some("Millions"),
},
series: vec![series("North", &north)],
..PlotChart::default()
};
let title = |ops: &[PlotOp]| {
ops.iter()
.find_map(|op| match op {
PlotOp::Text {
text,
rotation_deg,
width,
..
} if text == "Millions" => Some((*rotation_deg, *width)),
_ => None,
})
.expect("the value-axis title is drawn")
};
let turned = title(&plot_chart(&chart, rect()));
assert_eq!(turned.0, -90.0);
let mut flat = Flat(Vec::new());
plot_chart_into(&chart, rect(), &mut flat);
let (rotation, width) = title(&flat.0);
assert_eq!(rotation, 0.0);
assert_ne!(width, turned.1, "a flat title keeps its own box");
}
#[test]
fn an_of_pie_group_draws_wedges_and_a_per_slice_legend() {
let share = source(&[3.0, 1.0]);
let chart = PlotChart {
chart_type: "pie",
plot_groups: vec![PlotGroup {
chart_type: Some("ofPie"),
series: vec![series("Share", &share)],
..PlotGroup::default()
}],
..PlotChart::default()
};
let ops = plot_chart(&chart, rect());
assert_eq!(
ops.iter()
.filter(|op| matches!(op, PlotOp::Path { .. }))
.count(),
2
);
assert!(
ops.iter()
.any(|op| matches!(op, PlotOp::Text { text, .. } if text == "Q1"))
);
}
#[test]
fn axis_bounds_override_the_data_range() {
let north = source(&[1.0]);
let chart = PlotChart {
chart_type: "column",
value_axis: Some(PlotAxisRange {
min: Some(-10.0),
max: Some(10.0),
}),
series: vec![series("North", &north)],
..PlotChart::default()
};
let views = series_views(&chart.series, &mut ScanBudget::new());
let label = PlotTextStyle::default().resolve(CHART_LABEL_SIZE_PX, 400);
assert_eq!(value_range(family(&chart, &views, &label)), (-10.0, 10.0));
}
#[test]
fn point_marker_without_a_size_falls_back_to_the_default() {
let north = source(&[1.0, 2.0]);
let mut series = series("North", &north);
series.marker = Some(PlotMarker {
size: Some(9.0),
symbol: None,
});
series.points = vec![PlotPoint {
index: Some(0),
marker: Some(PlotMarker {
size: None,
symbol: None,
}),
..PlotPoint::default()
}];
let view = SeriesView::new(&series, &mut ScanBudget::new());
assert_eq!(view.marker_size(0), DEFAULT_MARKER_PX);
assert_eq!(view.marker_size(1), 12.0);
for (points, pixels) in [(0.5, 2.0 * 4.0 / 3.0), (400.0, 96.0)] {
series.marker = Some(PlotMarker {
size: Some(points),
symbol: None,
});
let view = SeriesView::new(&series, &mut ScanBudget::new());
assert_eq!(view.marker_size(1), pixels, "{points} pt clamps to 2..72");
}
}
#[test]
fn a_line_without_markers_keeps_its_labels_beside_the_point() {
let data = source(&[1.0, 3.7]);
let label_x = |markers: Option<bool>, size: f64| {
let mut labelled = series("North", &data);
labelled.marker = Some(PlotMarker {
size: Some(size),
symbol: None,
});
labelled.labels = Some(PlotDataLabels {
show_value: true,
..PlotDataLabels::default()
});
let mut line = group("line", vec![labelled]);
line.markers = markers;
texts_at(&plot_chart(&grouped("line", line), rect()))
.into_iter()
.find(|(text, ..)| text == "3.7")
.map(|(_, x, ..)| x)
.expect("a label")
};
assert_eq!(label_x(Some(false), 72.0), label_x(Some(false), 2.0));
assert!(label_x(None, 72.0) > label_x(None, 2.0));
}
#[test]
fn point_labels_draw_on_every_chart_family() {
let data = source(&[3.0, 1.0]);
for chart_type in ["column", "bar", "line", "pie", "doughnut"] {
let mut labelled = series("North", &data);
labelled.points = vec![
PlotPoint {
index: Some(0),
label: Some("first"),
..PlotPoint::default()
},
PlotPoint {
index: Some(1),
label: Some("second"),
..PlotPoint::default()
},
];
let chart = PlotChart {
chart_type,
series: vec![labelled],
..PlotChart::default()
};
let ops = plot_chart(&chart, rect());
for label in ["first", "second"] {
assert!(
ops.iter()
.any(|op| matches!(op, PlotOp::Text { text, .. } if text == label)),
"{chart_type} drops {label}"
);
}
}
}
#[test]
fn combo_plot_groups_drive_the_label_and_the_legend() {
let revenue = source(&[5.0, 9.0]);
let trend = source(&[4.0, 8.0]);
let chart = PlotChart {
chart_type: "column",
plot_groups: vec![
PlotGroup {
chart_type: Some("column"),
series: vec![series("Revenue", &revenue)],
..PlotGroup::default()
},
PlotGroup {
chart_type: Some("line"),
series: vec![series("Trend", &trend)],
..PlotGroup::default()
},
],
..PlotChart::default()
};
assert_eq!(
chart_aria_label(&chart),
"Untitled chart, combo chart, 2 series, 2 categories"
);
let ops = plot_chart(&chart, rect());
assert!(
ops.iter()
.any(|op| matches!(op, PlotOp::Text { text, .. } if text == "Revenue"))
);
assert!(
ops.iter()
.any(|op| matches!(op, PlotOp::Text { text, .. } if text == "Trend"))
);
}
#[test]
fn the_op_budget_bounds_a_chart_with_far_more_data_than_it_can_draw() {
let values: Vec<f64> = (0..200_000).map(|i| i as f64).collect();
let wide = Source {
categories: Vec::new(),
values,
};
let chart = PlotChart {
chart_type: "line",
series: vec![series("Wide", &wide)],
..PlotChart::default()
};
let ops = plot_chart(&chart, rect());
assert_eq!(ops.len(), MAX_PLOT_OPS);
}
#[test]
fn the_label_budget_is_chart_wide_rather_than_per_series() {
let values = vec![1.0; MAX_PLOT_OPS / 2 + 1];
let wide = Source {
categories: Vec::new(),
values,
};
let labelled = |name, label| {
let mut labelled = series(name, &wide);
labelled.points = vec![PlotPoint {
label: Some(label),
..PlotPoint::default()
}];
labelled
};
let chart = grouped(
"line",
PlotGroup {
chart_type: Some("line"),
series: vec![
labelled("First", "first-label"),
labelled("Second", "second-label"),
],
markers: Some(false),
..PlotGroup::default()
},
);
let ops = plot_chart(&chart, rect());
assert_eq!(ops.len(), MAX_PLOT_OPS);
let labels = texts(&ops);
assert!(labels.contains(&"first-label".to_owned()));
assert!(!labels.contains(&"second-label".to_owned()));
}
#[test]
fn the_index_budget_is_chart_wide_rather_than_per_series() {
let data = source(&[1.0, 2.0]);
let mut indexed = series("Indexed", &data);
indexed.points = vec![PlotPoint {
index: Some(1),
color: Some("#010203"),
..PlotPoint::default()
}];
let both = [indexed.clone(), indexed];
let views = series_views(&both, &mut ScanBudget::new());
assert_eq!(views[1].indexed.len(), 1);
let mut spent = ScanBudget::new();
assert_eq!(spent.take_points(MAX_PLOT_DATA_SCAN), MAX_PLOT_DATA_SCAN);
assert_eq!(spent.take_points(1), 0);
assert!(series_views(&both, &mut spent)[1].indexed.is_empty());
let mut capped = ScanBudget::new();
assert_eq!(capped.take_series(MAX_PLOT_SERIES + 1), MAX_PLOT_SERIES);
assert!(series_views(&both, &mut capped).is_empty());
}
#[test]
fn the_series_and_group_caps_bound_a_directly_constructed_chart() {
let data = source(&[1.0, 2.0]);
let drawn = PlotSeries {
color: Some("#111111"),
..series("Drawn", &data)
};
let beyond = PlotSeries {
color: Some("#ABCDEF"),
..series("Beyond", &data)
};
fn group<'a>(series: Vec<PlotSeries<'a>>) -> PlotGroup<'a> {
PlotGroup {
chart_type: Some("column"),
series,
..PlotGroup::default()
}
}
let mut wide = vec![drawn.clone(); MAX_PLOT_SERIES];
wide.push(beyond.clone());
let mut many = vec![group(vec![drawn]); MAX_PLOT_GROUPS];
many.push(group(vec![beyond]));
for plot_groups in [vec![group(wide)], many] {
let chart = PlotChart {
chart_type: "column",
plot_groups,
..PlotChart::default()
};
let ops = plot_chart(&chart, rect());
assert!(ops.len() < MAX_PLOT_OPS);
assert!(
ops.iter()
.all(|op| !matches!(op, PlotOp::Rect { fill, .. } if fill == "#ABCDEF"))
);
}
}
#[test]
fn a_later_plot_group_adds_nothing_once_the_op_budget_is_spent() {
let values: Vec<f64> = (0..200_000).map(|i| i as f64).collect();
let wide = Source {
categories: Vec::new(),
values,
};
let tail = source(&[1.0, 2.0]);
let chart = PlotChart {
chart_type: "line",
plot_groups: vec![
PlotGroup {
chart_type: Some("line"),
series: vec![series("Wide", &wide)],
..PlotGroup::default()
},
PlotGroup {
chart_type: Some("column"),
series: vec![PlotSeries {
color: Some("#ABCDEF"),
..series("Tail", &tail)
}],
..PlotGroup::default()
},
],
..PlotChart::default()
};
let ops = plot_chart(&chart, rect());
assert_eq!(ops.len(), MAX_PLOT_OPS);
assert!(
ops.iter()
.all(|op| !matches!(op, PlotOp::Rect { fill, .. } if fill == "#ABCDEF"))
);
}
#[test]
fn the_point_index_agrees_with_a_linear_first_match_scan() {
let data = source(&[1.0, 2.0, 3.0, 4.0]);
let cases: [Vec<Option<usize>>; 7] = [
vec![],
vec![Some(0), Some(1), Some(2)],
vec![Some(2), Some(0)],
vec![None, Some(0)],
vec![Some(1), None, Some(0)],
vec![Some(3), Some(3), None, None],
vec![Some(9), Some(1), Some(9)],
];
for indexes in cases {
let mut series = series("S", &data);
series.points = indexes
.iter()
.enumerate()
.map(|(position, index)| PlotPoint {
index: *index,
value: Some(position as f64),
..PlotPoint::default()
})
.collect();
let view = SeriesView::new(&series, &mut ScanBudget::new());
for query in 0..12 {
let expected = series
.points
.iter()
.find(|point| point.index.unwrap_or(query) == query);
assert_eq!(view.point(query), expected, "{indexes:?} at {query}");
}
}
}
#[test]
fn a_point_per_category_resolves_without_rescanning_the_point_vector() {
let values: Vec<f64> = (0..20_000).map(|i| (i % 13) as f64).collect();
let dense = Source {
categories: Vec::new(),
values,
};
let mut series = series("Dense", &dense);
series.points = (0..20_000)
.map(|index| PlotPoint {
index: Some(index),
color: Some("#010203"),
..PlotPoint::default()
})
.collect();
let chart = PlotChart {
chart_type: "line",
series: vec![series],
..PlotChart::default()
};
let ops = plot_chart(&chart, rect());
assert!(ops.len() <= MAX_PLOT_OPS);
assert!(
ops.iter()
.any(|op| matches!(op, PlotOp::Rect { fill, .. } | PlotOp::Path { fill, .. } if fill == "#010203"))
);
}
#[test]
fn out_of_schema_point_indexes_are_dropped_rather_than_coerced() {
let space = ChartSpace {
chart_type: "pie".to_owned(),
plot_groups: vec![crate::chart::ChartPlotGroup {
chart_type: Some("pie".to_owned()),
series: vec![crate::chart::ChartSeries {
values: vec![1.0, 2.0],
color: "#4472C4".to_owned(),
points: Some(
[-1.0, 1.5, 1e30, f64::NAN, 2.0]
.into_iter()
.map(|index| crate::chart::ChartPoint {
index: Some(index),
explosion: None,
color: "#010203".to_owned(),
})
.collect(),
),
..crate::chart::ChartSeries::default()
}],
..crate::chart::ChartPlotGroup::default()
}],
..ChartSpace::default()
};
let chart = PlotChart::from(&space);
let points = &chart.plot_groups[0].series[0].points;
assert_eq!(points.len(), 1);
assert_eq!(points[0].index, Some(2));
}
#[test]
fn degenerate_rects_and_extreme_ranges_stay_finite() {
let extremes = Source {
categories: Vec::new(),
values: vec![f64::MAX, -f64::MAX, 5.0],
};
let rects = [
PlotRect {
x: f64::NAN,
y: 0.0,
w: 300.0,
h: 200.0,
},
PlotRect {
x: 0.0,
y: 0.0,
w: f64::INFINITY,
h: 200.0,
},
PlotRect {
x: 0.0,
y: 0.0,
w: 1e308,
h: 1e308,
},
PlotRect::default(),
];
for chart_type in ["column", "bar", "line", "pie"] {
let chart = PlotChart {
chart_type,
series: vec![series("Extreme", &extremes)],
..PlotChart::default()
};
for rect in rects {
for op in plot_chart(&chart, rect) {
assert!(op_is_finite(&op), "{chart_type} {rect:?} {op:?}");
}
}
}
}
fn op_is_finite(op: &PlotOp) -> bool {
let numbers: Vec<f64> = match op {
PlotOp::Rect { x, y, w, h, .. } => vec![*x, *y, *w, *h],
PlotOp::Text {
x,
baseline_y,
width,
..
} => vec![*x, *baseline_y, *width],
PlotOp::Line { x1, y1, x2, y2, .. } => vec![*x1, *y1, *x2, *y2],
PlotOp::Path {
x,
y,
w,
h,
commands,
..
} => {
let mut numbers = vec![*x, *y, *w, *h];
for command in commands {
match command {
GeometryPathCommand::Move { x, y } | GeometryPathCommand::Line { x, y } => {
numbers.extend([*x, *y])
}
GeometryPathCommand::Quad { cpx, cpy, x, y } => {
numbers.extend([*cpx, *cpy, *x, *y]);
}
GeometryPathCommand::Cubic {
cp1x,
cp1y,
cp2x,
cp2y,
x,
y,
} => numbers.extend([*cp1x, *cp1y, *cp2x, *cp2y, *x, *y]),
GeometryPathCommand::Close => {}
}
}
numbers
}
};
numbers.iter().all(|number| number.is_finite())
}
fn rects(ops: &[PlotOp]) -> Vec<(f64, f64, f64, f64)> {
ops.iter()
.filter_map(|op| match op {
PlotOp::Rect { x, y, w, h, .. } => Some((*x, *y, *w, *h)),
_ => None,
})
.collect()
}
fn marks(ops: &[PlotOp]) -> Vec<(f64, f64, f64, f64)> {
ops.iter()
.filter_map(|op| match op {
PlotOp::Rect { x, y, w, h, .. } | PlotOp::Path { x, y, w, h, .. } => {
Some((*x, *y, *w, *h))
}
_ => None,
})
.collect()
}
const SWATCH: f64 = CHART_LABEL_SIZE_PX * LEGEND_KEY_EM;
fn is_swatch(w: f64, h: f64) -> bool {
(w - SWATCH).abs() < 0.01 && (h - SWATCH).abs() < 0.01
}
fn bars(ops: &[PlotOp]) -> Vec<(f64, f64, f64, f64)> {
rects(ops)
.into_iter()
.skip(1)
.filter(|(_, _, w, h)| !is_swatch(*w, *h))
.collect()
}
fn paths(ops: &[PlotOp]) -> usize {
ops.iter()
.filter(|op| matches!(op, PlotOp::Path { .. }))
.count()
}
fn texts(ops: &[PlotOp]) -> Vec<String> {
ops.iter()
.filter_map(|op| match op {
PlotOp::Text { text, .. } => Some(text.clone()),
_ => None,
})
.collect()
}
fn texts_at(ops: &[PlotOp]) -> Vec<(String, f64, f64, f64)> {
ops.iter()
.filter_map(|op| match op {
PlotOp::Text {
text,
x,
baseline_y,
width,
..
} => Some((text.clone(), *x, *baseline_y, *width)),
_ => None,
})
.collect()
}
fn text_at(ops: &[PlotOp], want: &str) -> (String, f64, f64, f64) {
texts_at(ops)
.into_iter()
.find(|(text, ..)| text == want)
.unwrap_or_else(|| panic!("no {want} was drawn"))
}
fn tick_labels(ops: &[PlotOp]) -> Vec<(String, f64, f64, f64)> {
texts_at(ops)
.into_iter()
.filter(|(text, ..)| text.parse::<f64>().is_ok())
.collect()
}
fn group<'a>(chart_type: &'a str, series: Vec<PlotSeries<'a>>) -> PlotGroup<'a> {
PlotGroup {
chart_type: Some(chart_type),
series,
..PlotGroup::default()
}
}
fn grouped<'a>(chart_type: &'a str, group: PlotGroup<'a>) -> PlotChart<'a> {
PlotChart {
chart_type,
plot_groups: vec![group],
..PlotChart::default()
}
}
#[test]
fn every_family_draws_with_its_own_renderer() {
let data = source(&[3.0, 1.0]);
for chart_type in ["area", "radar"] {
let chart = grouped(chart_type, group(chart_type, vec![series("North", &data)]));
let ops = plot_chart(&chart, rect());
let columns = bars(&ops)
.into_iter()
.filter(|(_, _, w, h)| *w > DEFAULT_MARKER_PX && *h > DEFAULT_MARKER_PX)
.count();
assert_ne!(columns, 2, "{chart_type} still draws columns");
}
let area = grouped("area", group("area", vec![series("North", &data)]));
assert_eq!(
paths(&plot_chart(&area, rect())),
1,
"area fills one region"
);
let surface = grouped("surface", group("surface", vec![series("North", &data)]));
let bands: Vec<String> = plot_chart(&surface, rect())
.iter()
.filter_map(|op| match op {
PlotOp::Rect { fill, .. } if fill != CHART_BACKGROUND_COLOR => Some(fill.clone()),
_ => None,
})
.collect();
assert!(
bands.len() > 1
&& bands
.iter()
.all(|fill| CHART_SERIES_COLORS.contains(&fill.as_str())),
"a contour band takes its colour from the value ramp, got {bands:?}"
);
}
#[test]
fn scatter_places_points_at_their_x_values() {
let categorical = source(&[10.0, 20.0]);
let x = [0.0, 30.0];
let mut xy = series("XY", &categorical);
xy.x_values = &x;
let chart = grouped("scatter", group("scatter", vec![xy]));
let ops = plot_chart(&chart, rect());
let markers: Vec<(f64, f64, f64, f64)> = marks(&ops)
.into_iter()
.filter(|(_, _, w, h)| {
(*w - DEFAULT_MARKER_PX).abs() < 0.01 && (*h - DEFAULT_MARKER_PX).abs() < 0.01
})
.collect();
assert_eq!(markers.len(), 2);
assert!(markers[0].0 < markers[1].0);
assert!(
texts(&ops).contains(&"30".to_owned()),
"the x axis labels its own values"
);
}
#[test]
fn scatter_and_bubble_never_substitute_indexes_for_missing_x_values() {
let data = source(&[10.0, 20.0]);
let x = [3.0];
let sizes = [2.0, 4.0];
let mut scatter_series = series("XY", &data);
scatter_series.x_values = &x;
let scatter = plot_chart(
&grouped("scatter", group("scatter", vec![scatter_series])),
rect(),
);
assert_eq!(
marks(&scatter)
.iter()
.filter(|(_, _, w, h)| (*w - DEFAULT_MARKER_PX).abs() < 0.01
&& (*h - DEFAULT_MARKER_PX).abs() < 0.01)
.count(),
1
);
assert!(
scatter
.iter()
.all(|op| !matches!(op, PlotOp::Line { width, .. } if *width == 2.0))
);
let mut bubble_series = series("Bubbles", &data);
bubble_series.bubble_sizes = &sizes;
let bubble = plot_chart(
&grouped("bubble", group("bubble", vec![bubble_series])),
rect(),
);
assert!(bubble.iter().all(|op| !matches!(op, PlotOp::Path { .. })));
assert!(!texts(&bubble).contains(&"Q1".to_owned()));
assert!(!texts(&bubble).contains(&"Q2".to_owned()));
}
#[test]
fn scatter_and_bubble_skip_points_without_y_values() {
let data = Source {
categories: Vec::new(),
values: vec![10.0],
};
let x = [1.0, 2.0];
let sizes = [2.0, 4.0];
let mut scatter_series = series("XY", &data);
scatter_series.x_values = &x;
let scatter = plot_chart(
&grouped("scatter", group("scatter", vec![scatter_series])),
rect(),
);
assert_eq!(
marks(&scatter)
.iter()
.filter(|(_, _, w, h)| (*w - DEFAULT_MARKER_PX).abs() < 0.01
&& (*h - DEFAULT_MARKER_PX).abs() < 0.01)
.count(),
1
);
let mut bubble_series = series("Bubbles", &data);
bubble_series.x_values = &x;
bubble_series.bubble_sizes = &sizes;
let bubble = plot_chart(
&grouped("bubble", group("bubble", vec![bubble_series])),
rect(),
);
assert_eq!(paths(&bubble), 1);
}
#[test]
fn scatter_axis_references_are_independent_of_definition_order() {
let data = Source {
categories: Vec::new(),
values: vec![80.0],
};
let x = [2.0];
let mut xy = series("XY", &data);
xy.x_values = &x;
let mut scatter = group("scatter", vec![xy]);
scatter.axis_ids = vec!["x", "y"];
let x_axis = value_axis("x", 0.0, 10.0);
let y_axis = value_axis("y", 0.0, 100.0);
let chart = |axes| PlotChart {
chart_type: "scatter",
plot_groups: vec![scatter.clone()],
axes,
..PlotChart::default()
};
assert_eq!(
plot_chart(&chart(vec![x_axis, y_axis]), rect()),
plot_chart(&chart(vec![y_axis, x_axis]), rect())
);
}
#[test]
fn unresolved_axis_references_never_select_another_definition() {
let x_axis = value_axis("x", 0.0, 10.0);
let y_axis = value_axis("y", 0.0, 100.0);
let chart = PlotChart {
chart_type: "scatter",
axes: vec![x_axis, y_axis],
..PlotChart::default()
};
let resolve = |chart_type, axis_ids| {
let group = PlotGroup {
chart_type: Some(chart_type),
axis_ids,
..PlotGroup::default()
};
let (x, y) = group_value_axes(&chart, &group);
(x.and_then(|axis| axis.id), y.and_then(|axis| axis.id))
};
for chart_type in ["scatter", "bubble"] {
assert_eq!(resolve(chart_type, Vec::new()), (None, None));
assert_eq!(resolve(chart_type, vec!["missing", "y"]), (None, Some("y")));
assert_eq!(resolve(chart_type, vec!["x"]), (Some("x"), None));
assert_eq!(resolve(chart_type, vec!["x", "missing"]), (Some("x"), None));
assert_eq!(resolve(chart_type, vec!["x", "x"]), (None, None));
}
let duplicated = PlotChart {
chart_type: "scatter",
axes: vec![x_axis, x_axis, y_axis],
..PlotChart::default()
};
let group = PlotGroup {
chart_type: Some("scatter"),
axis_ids: vec!["x", "y"],
..PlotGroup::default()
};
let (x, y) = group_value_axes(&duplicated, &group);
assert!(x.is_none());
assert_eq!(y.and_then(|axis| axis.id), Some("y"));
}
#[test]
fn categorical_families_require_unique_referenced_axes() {
let category = PlotAxis {
id: Some("category"),
kind: PlotAxisKind::Category,
..PlotAxis::default()
};
let value = value_axis("value", 0.0, 10.0);
let chart = PlotChart {
chart_type: "line",
axes: vec![value, category],
..PlotChart::default()
};
for chart_type in ["bar", "column", "line", "area", "radar", "surface", "stock"] {
let valid = PlotGroup {
chart_type: Some(chart_type),
axis_ids: vec!["category", "value"],
..PlotGroup::default()
};
assert_eq!(
group_value_axes(&chart, &valid).1.and_then(|axis| axis.id),
Some("value")
);
assert_eq!(
group_category_axis(&chart, &valid).and_then(|axis| axis.id),
Some("category")
);
for axis_ids in [Vec::new(), vec!["missing"]] {
let unresolved = PlotGroup {
chart_type: Some(chart_type),
axis_ids,
..PlotGroup::default()
};
assert!(group_value_axes(&chart, &unresolved).1.is_none());
assert!(group_category_axis(&chart, &unresolved).is_none());
}
}
let duplicated = PlotChart {
chart_type: "line",
axes: vec![value, value, category, category],
..PlotChart::default()
};
let group = PlotGroup {
chart_type: Some("line"),
axis_ids: vec!["category", "value"],
..PlotGroup::default()
};
assert!(group_value_axes(&duplicated, &group).1.is_none());
assert!(group_category_axis(&duplicated, &group).is_none());
}
#[test]
fn a_surface_category_axis_is_distinct_from_its_series_axis() {
let category = PlotAxis {
id: Some("category"),
kind: PlotAxisKind::Category,
..PlotAxis::default()
};
let series = PlotAxis {
id: Some("series"),
kind: PlotAxisKind::Series,
..PlotAxis::default()
};
let chart = PlotChart {
chart_type: "surface",
axes: vec![series, value_axis("value", 0.0, 10.0), category],
..PlotChart::default()
};
let group = PlotGroup {
chart_type: Some("surface"),
axis_ids: vec!["category", "value", "series"],
..PlotGroup::default()
};
assert_eq!(
group_category_axis(&chart, &group).and_then(|axis| axis.id),
Some("category")
);
}
#[test]
fn a_scatter_style_without_lines_draws_only_markers() {
let data = source(&[10.0, 20.0]);
let x = [1.0, 2.0];
let mut xy = series("XY", &data);
xy.x_values = &x;
let mut markers_only = group("scatter", vec![xy.clone()]);
markers_only.scatter_style = Some("marker");
let mut lines_only = group("scatter", vec![xy]);
lines_only.scatter_style = Some("line");
let series_lines = |chart: &PlotChart<'_>| {
plot_chart(chart, rect())
.iter()
.filter(|op| matches!(op, PlotOp::Line { width, .. } if *width == 2.0))
.count()
};
assert_eq!(series_lines(&grouped("scatter", markers_only)), 0);
assert_eq!(series_lines(&grouped("scatter", lines_only)), 1);
}
#[test]
fn bubbles_scale_by_area_or_by_width() {
let data = source(&[10.0, 10.0]);
let x = [1.0, 2.0];
let sizes = [1.0, 4.0];
let bubble = |represents: &'static str| {
let mut series = series("Bubbles", &data);
series.x_values = &x;
series.bubble_sizes = &sizes;
let mut group = group("bubble", vec![series]);
group.size_represents = Some(represents);
group
};
let radii = |chart: &PlotChart<'_>| {
plot_chart(chart, rect())
.iter()
.filter_map(|op| match op {
PlotOp::Path { w, .. } => Some(*w),
_ => None,
})
.collect::<Vec<_>>()
};
let area = radii(&grouped("bubble", bubble("area")));
let width = radii(&grouped("bubble", bubble("w")));
assert_eq!(area.len(), 2);
assert!((area[0] / area[1] - 0.5).abs() < 0.01, "{area:?}");
assert!((width[0] / width[1] - 0.25).abs() < 0.01, "{width:?}");
}
#[test]
fn a_bubble_without_coordinates_does_not_scale_visible_bubbles() {
let data = source(&[10.0, 10.0]);
let x = [1.0];
let visible_size = [2.0];
let hidden_size = [2.0, 400.0];
let render = |sizes: &[f64]| {
let mut bubbles = series("Bubbles", &data);
bubbles.x_values = &x;
bubbles.bubble_sizes = sizes;
plot_chart(&grouped("bubble", group("bubble", vec![bubbles])), rect())
};
assert_eq!(render(&visible_size), render(&hidden_size));
}
#[test]
fn a_stock_family_draws_hi_lo_and_up_down_bars() {
let sources: Vec<Source> = [
vec![10.0, 12.0],
vec![20.0, 22.0],
vec![5.0, 6.0],
vec![18.0, 8.0],
]
.into_iter()
.map(|values| Source {
categories: vec!["D1".to_owned(), "D2".to_owned()],
values,
})
.collect();
let names = ["Open", "High", "Low", "Close"];
let ohlc: Vec<PlotSeries<'_>> = names
.iter()
.zip(&sources)
.map(|(name, source)| series(name, source))
.collect();
let mut ohlc_group = group("stock", ohlc.clone());
ohlc_group.hi_low_lines = true;
ohlc_group.up_down_bars = true;
let ops = plot_chart(&grouped("stock", ohlc_group), rect());
let verticals = ops
.iter()
.filter(|op| {
matches!(op, PlotOp::Line { x1, x2, y1, y2, .. }
if (x1 - x2).abs() < 0.01 && (y1 - y2).abs() > 1.0 && (y1 - y2).abs() < 150.0)
})
.count();
assert_eq!(verticals, 3, "two hi-lo lines and one rising open edge");
let up_down: Vec<&str> = bars(&ops)
.iter()
.zip(
ops.iter()
.filter_map(|op| match op {
PlotOp::Rect { fill, .. } => Some(fill.as_str()),
_ => None,
})
.skip(1),
)
.map(|(_, fill)| fill)
.collect();
assert!(
up_down.contains(&CHART_AXIS_COLOR),
"a falling bar fills dark"
);
assert!(
up_down.contains(&CHART_BACKGROUND_COLOR),
"a rising bar fills light"
);
let hlc = grouped("stock", group("stock", ohlc[1..].to_vec()));
assert!(
plot_chart(&hlc, rect())
.iter()
.any(|op| matches!(op, PlotOp::Line { .. })),
"three series still plot high-low-close"
);
}
#[test]
fn stacked_bars_pile_onto_one_another() {
let north = source(&[10.0, 20.0]);
let south = source(&[5.0, 5.0]);
let mut stacked = group(
"column",
vec![series("North", &north), series("South", &south)],
);
stacked.grouping = Some("stacked");
let ops = plot_chart(&grouped("column", stacked), rect());
let bars = bars(&ops);
assert_eq!(bars.len(), 4);
assert_eq!(bars[0].0, bars[1].0, "a stack shares one slot");
assert!(bars[1].1 < bars[0].1, "the second series sits on the first");
assert!(
texts(&ops).contains(&"25".to_owned()),
"the axis reaches the stack total"
);
}
#[test]
fn percent_stacked_normalizes_every_category() {
let north = source(&[1.0, 30.0]);
let south = source(&[3.0, 10.0]);
let mut percent = group(
"column",
vec![series("North", &north), series("South", &south)],
);
percent.grouping = Some("percentStacked");
let ops = plot_chart(&grouped("column", percent), rect());
let bars = bars(&ops);
assert_eq!(bars.len(), 4);
let height = |slot: usize| bars[slot].3;
assert!((height(0) + height(1) - (height(2) + height(3))).abs() < 0.01);
assert!(texts(&ops).contains(&"100%".to_owned()));
}
#[test]
fn gap_width_and_overlap_size_and_place_the_bars() {
let north = source(&[10.0, 20.0]);
let south = source(&[5.0, 5.0]);
let sized = |gap: f64, overlap: f64| {
let mut group = group(
"column",
vec![series("North", &north), series("South", &south)],
);
group.gap_width = Some(gap);
group.overlap = Some(overlap);
bars(&plot_chart(&grouped("column", group), rect()))
};
let wide = sized(0.0, 0.0);
let narrow = sized(300.0, 0.0);
assert!(wide[0].2 > narrow[0].2, "a wider gap leaves thinner bars");
let apart = sized(150.0, 0.0);
let over = sized(150.0, 100.0);
assert!((apart[1].0 - apart[0].0 - apart[0].2).abs() < 0.01);
assert!(
(over[1].0 - over[0].0).abs() < 0.01,
"full overlap shares a slot"
);
}
#[test]
fn marker_symbols_draw_their_own_outlines() {
let data = source(&[1.0, 2.0]);
for symbol in [
PlotMarkerSymbol::Circle,
PlotMarkerSymbol::Diamond,
PlotMarkerSymbol::Triangle,
PlotMarkerSymbol::Star,
PlotMarkerSymbol::Plus,
PlotMarkerSymbol::Dash,
PlotMarkerSymbol::Dot,
PlotMarkerSymbol::X,
PlotMarkerSymbol::Auto,
] {
let mut marked = series("North", &data);
marked.marker = Some(PlotMarker {
size: Some(8.0),
symbol: Some(symbol),
});
let chart = grouped("line", group("line", vec![marked]));
let ops = plot_chart(&chart, rect());
assert_eq!(paths(&ops), 2, "{symbol:?} draws one outline per point");
}
let mut square = series("North", &data);
square.marker = Some(PlotMarker {
size: Some(8.0),
symbol: Some(PlotMarkerSymbol::Square),
});
assert_eq!(
paths(&plot_chart(
&grouped("line", group("line", vec![square])),
rect()
)),
0,
"a square stays a rectangle op"
);
let mut none = series("North", &data);
none.marker = Some(PlotMarker {
size: Some(8.0),
symbol: Some(PlotMarkerSymbol::None),
});
let bare = plot_chart(&grouped("line", group("line", vec![none])), rect());
assert!(
bars(&bare).is_empty(),
"a none symbol draws nothing: {:?}",
bars(&bare)
);
}
#[test]
fn a_line_group_marker_switch_removes_every_marker() {
let data = source(&[1.0, 2.0]);
let mut off = group("line", vec![series("North", &data)]);
off.markers = Some(false);
let ops = plot_chart(&grouped("line", off), rect());
assert!(
!marks(&ops)
.iter()
.any(|(_, _, w, h)| (*w - DEFAULT_MARKER_PX).abs() < 0.01
&& (*h - DEFAULT_MARKER_PX).abs() < 0.01)
);
}
#[test]
fn a_doughnut_honours_its_hole_size() {
let data = source(&[3.0, 1.0]);
let inner = |hole: Option<f64>| {
let mut group = group("doughnut", vec![series("Share", &data)]);
group.hole_size = hole;
let ops = plot_chart(&grouped("doughnut", group), rect());
let PlotOp::Path {
commands,
x,
y,
w,
h,
..
} = ops
.iter()
.find(|op| matches!(op, PlotOp::Path { .. }))
.expect("a wedge")
else {
unreachable!()
};
let (cx, cy) = (x + w / 2.0, y + h / 2.0);
commands
.iter()
.filter_map(|command| match command {
GeometryPathCommand::Move { x, y } | GeometryPathCommand::Line { x, y } => {
Some(((x - cx).powi(2) + (y - cy).powi(2)).sqrt())
}
_ => None,
})
.fold(f64::INFINITY, f64::min)
/ (w / 2.0)
};
assert!(inner(Some(20.0)) < inner(None));
assert!(inner(None) < inner(Some(80.0)));
}
#[test]
fn a_first_slice_angle_rotates_the_pie_and_explosion_offsets_a_slice() {
let data = source(&[1.0, 1.0]);
let start = |angle: Option<f64>| {
let mut group = group("pie", vec![series("Share", &data)]);
group.first_slice_angle = angle;
let ops = plot_chart(&grouped("pie", group), rect());
match ops.iter().find(|op| matches!(op, PlotOp::Path { .. })) {
Some(PlotOp::Path { commands, .. }) => match commands[1] {
GeometryPathCommand::Line { x, y } => (x, y),
_ => unreachable!(),
},
_ => unreachable!(),
}
};
let (upright_x, upright_y) = start(None);
let (turned_x, turned_y) = start(Some(90.0));
assert!((upright_x - turned_y).abs() > 1.0 || (upright_y - turned_x).abs() > 1.0);
let mut exploded = series("Share", &data);
exploded.points = vec![PlotPoint {
index: Some(0),
explosion: Some(50.0),
..PlotPoint::default()
}];
let ops = plot_chart(&grouped("pie", group("pie", vec![exploded])), rect());
let centers: Vec<f64> = ops
.iter()
.filter_map(|op| match op {
PlotOp::Path { x, w, .. } => Some(x + w / 2.0),
_ => None,
})
.collect();
assert!(
(centers[0] - centers[1]).abs() > 1.0,
"the exploded slice moves off centre"
);
}
#[test]
fn vary_colors_cycles_a_pie_through_the_palette() {
let data = source(&[3.0, 1.0]);
let mut varied = group("pie", vec![series("Share", &data)]);
varied.vary_colors = true;
let fills: Vec<String> = plot_chart(&grouped("pie", varied), rect())
.iter()
.filter_map(|op| match op {
PlotOp::Path { fill, .. } => Some(fill.clone()),
_ => None,
})
.collect();
assert_eq!(fills, [CHART_SERIES_COLORS[0], CHART_SERIES_COLORS[1]]);
}
fn combo_with_axes<'a>(
first: PlotGroup<'a>,
second: PlotGroup<'a>,
axes: Vec<PlotAxis<'a>>,
) -> PlotChart<'a> {
PlotChart {
chart_type: "column",
plot_groups: vec![first, second],
axes,
..PlotChart::default()
}
}
fn value_axis<'a>(id: &'a str, min: f64, max: f64) -> PlotAxis<'a> {
PlotAxis {
id: Some(id),
kind: PlotAxisKind::Value,
range: PlotAxisRange {
min: Some(min),
max: Some(max),
},
major_gridlines: true,
..PlotAxis::default()
}
}
#[test]
fn a_secondary_value_axis_scales_only_the_group_that_names_it() {
let small = source(&[10.0, 10.0]);
let large = source(&[10.0, 10.0]);
let mut primary = group("column", vec![series("Units", &small)]);
primary.axis_ids = vec!["1", "2"];
let mut secondary = group("line", vec![series("Rate", &large)]);
secondary.axis_ids = vec!["1", "3"];
let chart = combo_with_axes(
primary,
secondary,
vec![value_axis("2", 0.0, 20.0), value_axis("3", 0.0, 100.0)],
);
let ops = plot_chart(&chart, rect());
assert!(
texts(&ops).contains(&"20".to_owned()) && texts(&ops).contains(&"100".to_owned()),
"both axes label their own range"
);
let bar = bars(&ops)
.into_iter()
.find(|(_, _, w, h)| *w > 8.0 && *h > 8.0)
.expect("a column");
let marker = marks(&ops)
.into_iter()
.find(|(_, _, w, h)| {
(*w - DEFAULT_MARKER_PX).abs() < 0.01 && (*h - DEFAULT_MARKER_PX).abs() < 0.01
})
.expect("a line marker");
assert!(
marker.1 > bar.1,
"the same value sits lower against the wider axis"
);
}
#[test]
fn secondary_axis_groups_ignore_all_four_definition_positions() {
let primary_data = source(&[10.0, 10.0]);
let secondary_data = source(&[60.0, 60.0]);
let mut primary = group("column", vec![series("Units", &primary_data)]);
primary.axis_ids = vec!["cat-primary", "val-primary"];
let mut secondary = group("line", vec![series("Rate", &secondary_data)]);
secondary.axis_ids = vec!["cat-secondary", "val-secondary"];
let category = |id| PlotAxis {
id: Some(id),
kind: PlotAxisKind::Category,
..PlotAxis::default()
};
let primary_category = category("cat-primary");
let secondary_category = category("cat-secondary");
let primary_value = value_axis("val-primary", 0.0, 20.0);
let secondary_value = value_axis("val-secondary", 0.0, 100.0);
let chart = |axes| combo_with_axes(primary.clone(), secondary.clone(), axes);
assert_eq!(
plot_chart(
&chart(vec![
primary_category,
primary_value,
secondary_category,
secondary_value,
]),
rect(),
),
plot_chart(
&chart(vec![
secondary_value,
primary_category,
primary_value,
secondary_category,
]),
rect(),
)
);
}
#[test]
fn a_line_point_sits_mid_band_unless_the_axis_crosses_at_the_category() {
let markers = |cross_between| {
let data = source(&[1.0, 2.0, 3.0]);
let mut trend = series("Trend", &data);
trend.marker = Some(PlotMarker {
size: Some(8.0),
symbol: Some(PlotMarkerSymbol::Diamond),
});
let mut group = group("line", vec![trend]);
group.axis_ids = vec!["1"];
let mut axis = value_axis("1", 0.0, 4.0);
axis.cross_between = cross_between;
let chart = PlotChart {
chart_type: "line",
plot_groups: vec![group],
axes: vec![axis],
..PlotChart::default()
};
plot_chart(&chart, rect())
.into_iter()
.filter_map(|op| match op {
PlotOp::Path { x, w, h, .. } if (w - h).abs() < 0.01 => Some(x + w / 2.0),
_ => None,
})
.collect::<Vec<f64>>()
};
let on_ticks = markers(Some("midCat"));
let mid_band = markers(None);
assert_eq!(mid_band, markers(Some("between")));
let (tick_step, band) = (on_ticks[1] - on_ticks[0], mid_band[1] - mid_band[0]);
assert!(
(band / tick_step - 2.0 / 3.0).abs() < 1e-6,
"{on_ticks:?} {mid_band:?}"
);
assert!((mid_band[0] - on_ticks[0] - band / 2.0).abs() < 1e-6);
}
#[test]
fn a_log_axis_places_values_by_their_logarithm() {
let data = source(&[1.0, 100.0]);
let mut group = group("line", vec![series("Growth", &data)]);
group.axis_ids = vec!["1"];
let mut axis = value_axis("1", 1.0, 1000.0);
axis.log_base = Some(10.0);
let chart = PlotChart {
chart_type: "line",
plot_groups: vec![group],
axes: vec![axis],
..PlotChart::default()
};
let ops = plot_chart(&chart, rect());
let markers: Vec<(f64, f64, f64, f64)> = marks(&ops)
.into_iter()
.filter(|(_, _, w, h)| {
(*w - DEFAULT_MARKER_PX).abs() < 0.01 && (*h - DEFAULT_MARKER_PX).abs() < 0.01
})
.collect();
let plot_h = 200.0 - 10.0 - 34.0;
let travelled = markers[0].1 - markers[1].1;
assert!(
(travelled / plot_h - 2.0 / 3.0).abs() < 0.02,
"two decades of three cover two thirds of the axis: {travelled}"
);
}
#[test]
fn a_reversed_axis_flips_the_value_direction_and_major_unit_places_the_ticks() {
let data = source(&[0.0, 10.0]);
let mut group = group("column", vec![series("North", &data)]);
group.axis_ids = vec!["1"];
let mut axis = value_axis("1", 0.0, 10.0);
axis.reversed = true;
axis.major_unit = Some(2.0);
let chart = PlotChart {
chart_type: "column",
plot_groups: vec![group],
axes: vec![axis],
..PlotChart::default()
};
let ops = plot_chart(&chart, rect());
let labels = texts(&ops);
for tick in ["0", "2", "4", "6", "8", "10"] {
assert!(labels.contains(&tick.to_owned()), "{tick} is missing");
}
let tall = bars(&ops);
assert_eq!(tall.len(), 2);
assert!(
tall[1].1 < 15.0 && tall[1].3 > 150.0,
"a reversed axis grows the bar downward from the top: {tall:?}"
);
}
#[test]
fn a_pinned_minimum_starts_the_major_unit_walk() {
let data = source(&[268.0, 273.0]);
let mut group = group("line", vec![series("Score", &data)]);
group.axis_ids = vec!["1"];
let mut axis = value_axis("1", 245.0, 285.0);
axis.major_unit = Some(10.0);
let chart = PlotChart {
chart_type: "line",
plot_groups: vec![group],
axes: vec![axis],
..PlotChart::default()
};
let ops = plot_chart(&chart, rect());
let labels = texts(&ops);
for tick in ["245", "255", "265", "275", "285"] {
assert!(
labels.contains(&tick.to_owned()),
"{tick} is missing: {labels:?}"
);
}
assert!(!labels.contains(&"250".to_owned()), "{labels:?}");
let mut grid: Vec<f64> = ops
.iter()
.filter_map(|op| match op {
PlotOp::Line { y1, y2, color, .. }
if color == CHART_GRID_COLOR && (y1 - y2).abs() < 0.01 =>
{
Some(*y1)
}
_ => None,
})
.collect();
grid.sort_by(f64::total_cmp);
let mut marks: Vec<f64> = texts_at(&ops)
.into_iter()
.filter(|(text, ..)| text.parse::<f64>().is_ok_and(|value| value >= 245.0))
.map(|(_, _, baseline, _)| baseline - 0.25 * CHART_LABEL_SIZE_PX)
.collect();
marks.sort_by(f64::total_cmp);
assert_eq!(grid.len(), 5, "{grid:?}");
assert_eq!(marks.len(), grid.len(), "{marks:?}");
assert!(
grid.iter()
.zip(&marks)
.all(|(line, mark)| (line - mark).abs() < 1e-9),
"every gridline stands on its own label: {grid:?} {marks:?}"
);
}
#[test]
fn nice_unit_rounds_up_to_one_two_or_five_times_a_power_of_ten() {
let close = |got: f64, want: f64| (got - want).abs() <= want.abs() * 1e-9;
for (rough, want) in [
(1.0, 1.0),
(1.01, 2.0),
(2.0, 2.0),
(2.1, 5.0),
(5.0, 5.0),
(5.1, 10.0),
(10.0, 10.0),
(1.757, 2.0),
(3.205, 5.0),
(6.25, 10.0),
(1234.0, 2000.0),
(0.5, 0.5),
(0.51, 1.0),
(0.128, 0.2),
(0.05, 0.05),
] {
let got = nice_unit(rough);
assert!(close(got, want), "nice_unit({rough}) is {got}, want {want}");
}
for rough in [0.0, -1.0, f64::NAN, f64::INFINITY] {
assert_eq!(nice_unit(rough), 1.0, "nice_unit({rough}) falls back to 1");
}
}
#[test]
fn an_auto_value_axis_counts_in_round_steps_and_leaves_headroom() {
let column = |values: &[f64]| Source {
categories: ["Alpha", "Beta", "Gamma", "Delta"]
.iter()
.map(|name| (*name).to_owned())
.collect(),
values: values.to_vec(),
};
let (north, south, east) = (
column(&[2.9, 3.0, 2.8, 4.1]),
column(&[2.9, 3.0, 2.8, 4.1]),
column(&[2.9, 2.9, 2.7, 4.1]),
);
let mut stacked = group(
"bar",
vec![
series("North", &north),
series("South", &south),
series("East", &east),
],
);
stacked.grouping = Some("stacked");
let wide = PlotRect {
x: 0.0,
y: 0.0,
w: 1124.0,
h: 482.0,
};
let ops = plot_chart(&grouped("bar", stacked), wide);
let mut ticks: Vec<f64> = tick_labels(&ops)
.iter()
.map(|(text, ..)| text.parse::<f64>().expect("numeric"))
.collect();
ticks.sort_by(f64::total_cmp);
assert_eq!(
ticks,
vec![0.0, 2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0],
"an auto axis over 0..12.3 counts in twos to 14"
);
for (text, ..) in tick_labels(&ops) {
assert!(!text.contains('.'), "{text} is not a round step");
}
let mut grid: Vec<f64> = ops
.iter()
.filter_map(|op| match op {
PlotOp::Line { x1, x2, color, .. }
if color == CHART_GRID_COLOR && (x1 - x2).abs() < 0.01 =>
{
Some(*x1)
}
_ => None,
})
.collect();
grid.sort_by(f64::total_cmp);
assert_eq!(grid.len(), 8);
let far = bars(&ops)
.iter()
.map(|(x, _, w, _)| x + w)
.fold(f64::MIN, f64::max);
assert!(
grid[6] < far && far < grid[7],
"the 12.3 stack stops between the 12 and 14 gridlines rather than \
against the plot edge: {far} in {grid:?}"
);
}
#[test]
fn a_pinned_axis_keeps_its_bounds_and_still_counts_in_round_steps() {
let data = source(&[10.0, 20.0]);
let mut group = group("column", vec![series("North", &data)]);
group.axis_ids = vec!["1"];
let chart = PlotChart {
chart_type: "column",
plot_groups: vec![group],
axes: vec![value_axis("1", 0.0, 25.0)],
..PlotChart::default()
};
let mut ticks: Vec<f64> = tick_labels(&plot_chart(&chart, rect()))
.iter()
.map(|(text, ..)| text.parse::<f64>().expect("numeric"))
.collect();
ticks.sort_by(f64::total_cmp);
assert_eq!(ticks, vec![0.0, 5.0, 10.0, 15.0, 20.0, 25.0]);
}
#[test]
fn an_explicit_major_unit_beats_the_computed_one() {
let north = source(&[8.0, 12.3]);
let mut group = group("column", vec![series("North", &north)]);
group.axis_ids = vec!["1"];
let chart = PlotChart {
chart_type: "column",
plot_groups: vec![group],
axes: vec![PlotAxis {
id: Some("1"),
kind: PlotAxisKind::Value,
major_gridlines: true,
major_unit: Some(3.0),
..PlotAxis::default()
}],
..PlotChart::default()
};
let mut ticks: Vec<f64> = tick_labels(&plot_chart(&chart, rect()))
.iter()
.map(|(text, ..)| text.parse::<f64>().expect("numeric"))
.collect();
ticks.sort_by(f64::total_cmp);
assert_eq!(
ticks,
vec![0.0, 3.0, 6.0, 9.0, 12.0, 15.0],
"the author's unit picks the ticks and the auto max rounds to it"
);
}
#[test]
fn gridlines_follow_the_axis_that_declares_them() {
let data = source(&[1.0, 2.0]);
let grid = |on: bool| {
let mut group = group("column", vec![series("North", &data)]);
group.axis_ids = vec!["1"];
let mut axis = value_axis("1", 0.0, 4.0);
axis.major_gridlines = on;
let chart = PlotChart {
chart_type: "column",
plot_groups: vec![group],
axes: vec![axis],
..PlotChart::default()
};
plot_chart(&chart, rect())
.iter()
.filter(|op| matches!(op, PlotOp::Line { color, .. } if color == CHART_GRID_COLOR))
.count()
};
assert_eq!(grid(true), 5);
assert_eq!(grid(false), 0);
}
#[test]
fn a_gridline_whose_sp_pr_draws_no_line_is_hidden() {
let data = source(&[1.0, 2.0, 3.0]);
let grid = |chart_type: &'static str, line: Option<PlotLine<'static>>| {
let mut group = group(chart_type, vec![series("North", &data)]);
group.axis_ids = vec!["1"];
let mut axis = value_axis("1", 0.0, 4.0);
axis.major_gridline = line;
let chart = PlotChart {
chart_type,
plot_groups: vec![group],
axes: vec![axis],
..PlotChart::default()
};
plot_chart(&chart, rect())
.iter()
.filter(|op| matches!(op, PlotOp::Line { color, .. } if color == CHART_GRID_COLOR))
.count()
};
let line = |none| {
Some(PlotLine {
none,
color: None,
width_emu: None,
})
};
for chart_type in ["column", "radar"] {
assert!(grid(chart_type, None) > 0, "{chart_type}");
assert_eq!(
grid(chart_type, line(false)),
grid(chart_type, None),
"{chart_type}"
);
assert_eq!(grid(chart_type, line(true)), 0, "{chart_type}");
}
}
#[test]
fn a_hidden_axis_keeps_its_gridlines_but_drops_its_labels() {
let data = source(&[1.0, 2.0]);
let mut group = group("column", vec![series("North", &data)]);
group.axis_ids = vec!["1"];
let mut axis = value_axis("1", 0.0, 4.0);
axis.hidden = true;
let chart = PlotChart {
chart_type: "column",
plot_groups: vec![group],
axes: vec![axis],
..PlotChart::default()
};
let ops = plot_chart(&chart, rect());
assert!(!texts(&ops).contains(&"4".to_owned()));
assert_eq!(
ops.iter()
.filter(|op| matches!(op, PlotOp::Line { color, .. } if color == CHART_GRID_COLOR))
.count(),
5
);
}
#[test]
fn tick_marks_draw_only_when_the_axis_names_them() {
let data = source(&[1.0, 2.0]);
let ticks = |mark: Option<&'static str>| {
let mut group = group("column", vec![series("North", &data)]);
group.axis_ids = vec!["1"];
let mut axis = value_axis("1", 0.0, 4.0);
axis.major_tick_mark = mark;
let chart = PlotChart {
chart_type: "column",
plot_groups: vec![group],
axes: vec![axis],
..PlotChart::default()
};
plot_chart(&chart, rect())
.iter()
.filter(|op| {
matches!(op, PlotOp::Line { x1, x2, color, .. }
if color == CHART_AXIS_COLOR && (x1 - x2).abs() < 8.0 && (x1 - x2).abs() > 0.0)
})
.count()
};
assert_eq!(ticks(None), 0);
assert_eq!(ticks(Some("none")), 0);
assert_eq!(ticks(Some("out")), 5);
assert_eq!(ticks(Some("cross")), 5);
}
#[test]
fn the_chart_space_fill_replaces_the_default_white_ground() {
let ground = |fill| {
let chart = PlotChart {
chart_type: "column",
fill,
..PlotChart::default()
};
plot_chart(&chart, rect())
.into_iter()
.find_map(|op| match op {
PlotOp::Rect { w, h, fill, .. } if w == 300.0 && h == 200.0 => Some(fill),
_ => None,
})
};
assert_eq!(ground(None).as_deref(), Some(CHART_BACKGROUND_COLOR));
assert_eq!(
ground(Some(PlotFill::Solid("#123456"))).as_deref(),
Some("#123456")
);
assert_eq!(
ground(Some(PlotFill::Pattern {
foreground: Some("#01C4BF"),
background: Some("#01BABC"),
}))
.as_deref(),
Some("#01BFBD")
);
assert_eq!(ground(Some(PlotFill::None)), None);
}
#[test]
fn a_no_fill_chart_space_paints_nothing_behind_the_series() {
let data = source(&[1.0, 2.0]);
let chart = PlotChart {
chart_type: "column",
plot_groups: vec![group("column", vec![series("North", &data)])],
fill: Some(PlotFill::None),
..PlotChart::default()
};
assert!(!plot_chart(&chart, rect()).iter().any(|op| matches!(
op,
PlotOp::Rect { w, h, .. } if *w == 300.0 && *h == 200.0
)));
}
fn rules(ops: &[PlotOp]) -> Vec<(f64, f64, f64, f64, String, f64)> {
ops.iter()
.filter_map(|op| match op {
PlotOp::Line {
x1,
y1,
x2,
y2,
color,
width,
} if color != CHART_GRID_COLOR => Some((*x1, *y1, *x2, *y2, color.clone(), *width)),
_ => None,
})
.collect()
}
#[test]
fn an_axis_line_follows_its_own_sp_pr_rather_than_the_chart_default() {
let data = source(&[1.0, 2.0]);
let edges = |value_line, category_line| {
let mut group = group("column", vec![series("North", &data)]);
group.axis_ids = vec!["1", "2"];
let mut value = value_axis("1", 0.0, 4.0);
value.line = value_line;
let category = PlotAxis {
id: Some("2"),
kind: PlotAxisKind::Category,
line: category_line,
..PlotAxis::default()
};
let chart = PlotChart {
chart_type: "column",
plot_groups: vec![group],
axes: vec![value, category],
..PlotChart::default()
};
rules(&plot_chart(&chart, rect()))
};
let default = edges(None, None);
assert_eq!(default.len(), 2);
assert!(
default
.iter()
.all(|(.., color, width)| color == CHART_AXIS_COLOR && *width == 1.0)
);
let suppressed = edges(
Some(PlotLine {
none: true,
..PlotLine::default()
}),
Some(PlotLine {
color: Some("#BFBFBF"),
width_emu: Some(9525.0),
..PlotLine::default()
}),
);
assert_eq!(suppressed.len(), 1);
let (x1, y1, x2, _, color, width) = suppressed[0].clone();
assert!(y1 > 0.0 && x2 > x1, "the surviving rule is the bottom one");
assert_eq!((color.as_str(), width), ("#BFBFBF", 1.0));
}
#[test]
fn an_axis_line_width_scales_with_its_emu_and_stays_at_least_a_hairline() {
assert_eq!(axis_line_width(None), 1.0);
assert_eq!(axis_line_width(Some(9525.0)), 1.0);
assert_eq!(axis_line_width(Some(3175.0)), 1.0);
assert_eq!(axis_line_width(Some(38100.0)), 4.0);
assert_eq!(axis_line_width(Some(f64::INFINITY)), 1.0);
assert_eq!(axis_line_width(Some(1e30)), MAX_LINE_PX);
}
#[test]
fn a_series_line_takes_its_width_from_its_own_sp_pr() {
let data = source(&[1.0, 2.0]);
let widths = |line| {
let mut north = series("North", &data);
north.line = line;
plot_chart(&grouped("line", group("line", vec![north])), rect())
.iter()
.filter_map(|op| match op {
PlotOp::Line { color, width, .. } if color == CHART_SERIES_COLORS[0] => {
Some(*width)
}
_ => None,
})
.collect::<Vec<_>>()
};
assert_eq!(widths(None), [DEFAULT_SERIES_LINE_PX]);
assert_eq!(
widths(Some(PlotLine {
width_emu: Some(41275.0),
..PlotLine::default()
})),
[41275.0 / EMU_PER_PIXEL]
);
assert!(
widths(Some(PlotLine {
none: true,
width_emu: Some(41275.0),
..PlotLine::default()
}))
.is_empty()
);
}
#[test]
fn a_series_line_width_stays_between_a_hairline_and_the_cap() {
let width = |line| {
series_line_width(&PlotSeries {
line,
..PlotSeries::default()
})
};
assert_eq!(width(None), Some(DEFAULT_SERIES_LINE_PX));
assert_eq!(
width(Some(PlotLine::default())),
Some(DEFAULT_SERIES_LINE_PX)
);
assert_eq!(
width(Some(PlotLine {
width_emu: Some(3175.0),
..PlotLine::default()
})),
Some(1.0)
);
assert_eq!(
width(Some(PlotLine {
width_emu: Some(1e30),
..PlotLine::default()
})),
Some(MAX_LINE_PX)
);
assert_eq!(
width(Some(PlotLine {
width_emu: Some(f64::NAN),
..PlotLine::default()
})),
Some(DEFAULT_SERIES_LINE_PX)
);
}
#[test]
fn a_reversed_category_axis_draws_the_categories_from_the_far_end() {
let data = source(&[1.0, 9.0]);
let mut group = group("column", vec![series("North", &data)]);
group.axis_ids = vec!["1"];
let chart = PlotChart {
chart_type: "column",
plot_groups: vec![group],
axes: vec![PlotAxis {
id: Some("1"),
kind: PlotAxisKind::Category,
reversed: true,
..PlotAxis::default()
}],
..PlotChart::default()
};
let bars = bars(&plot_chart(&chart, rect()));
assert_eq!(bars.len(), 2);
assert!(
bars[0].0 > bars[1].0,
"the first category draws on the right"
);
}
#[test]
fn a_pie_region_stays_inside_the_frame_and_clear_of_its_legend() {
let data = source(&[3.0, 1.0]);
for position in ["top", "bottom"] {
let mut chart = grouped("pie", group("pie", vec![series("North", &data)]));
chart.title = Some("Share");
chart.legend = Some(PlotLegend {
overlay: false,
position: Some(position),
visible: Some(true),
});
let frame = PlotRect {
x: 0.0,
y: 0.0,
w: 260.0,
h: 180.0,
};
let ops = plot_chart(&chart, frame);
let key = swatches(&ops)[0].1;
for op in &ops {
if let PlotOp::Path { y, h, .. } = op {
assert!(*y >= frame.y && y + h <= frame.y + frame.h, "{position}");
if position == "bottom" {
let band_top =
key - (LEGEND_ROW_EM - LEGEND_KEY_EM) * CHART_LABEL_SIZE_PX / 2.0;
assert!(
y + h + BAND_GAP <= band_top + 1e-9,
"the pie keeps a full gap from its legend: {} vs {band_top}",
y + h
);
} else {
assert!(*y >= key + SWATCH, "the pie runs into its legend");
}
}
}
}
}
#[test]
fn a_huge_title_leaves_the_plot_inside_the_frame() {
let data = source(&[10.0, 20.0]);
let frame = PlotRect {
x: 0.0,
y: 0.0,
w: 300.0,
h: 140.0,
};
for chart_type in ["column", "pie"] {
let mut chart = grouped(chart_type, group(chart_type, vec![series("North", &data)]));
chart.title = Some("Revenue");
chart.text.title.size_pt = Some(72.0);
chart.legend = Some(PlotLegend {
overlay: false,
position: None,
visible: Some(false),
});
for op in plot_chart(&chart, frame) {
match op {
PlotOp::Rect { y, h, .. } | PlotOp::Path { y, h, .. } => {
assert!(y + h <= frame.y + frame.h + 1e-9, "{chart_type}: {y} + {h}")
}
PlotOp::Line { y1, y2, .. } => {
assert!(y1.max(y2) <= frame.y + frame.h + 1e-9, "{chart_type}")
}
_ => {}
}
}
}
}
#[test]
fn a_row_legend_keeps_only_the_rows_the_plot_and_its_labels_leave_room_for() {
let data = source(&[10.0, 20.0]);
let names = ["North America", "South America", "Europe", "Asia Pacific"];
let mut chart = legend_chart(Some("bottom"), &names, &data);
chart.title = None;
chart.text.legend.size_pt = Some(14.0);
let frame = PlotRect {
x: 0.0,
y: 0.0,
w: 180.0,
h: 140.0,
};
let ops = plot_chart(&chart, frame);
let drawn = texts(&ops);
let kept: Vec<&str> = names
.into_iter()
.filter(|name| drawn.iter().any(|text| text == name))
.collect();
assert_eq!(kept, ["North America", "South America"]);
let ticks = tick_labels(&ops);
let span = ticks.iter().map(|tick| tick.2).fold(f64::MIN, f64::max)
- ticks.iter().map(|tick| tick.2).fold(f64::MAX, f64::min);
assert!(span >= MIN_PLOT - 1e-9, "the plot keeps {span}px");
let legend_px = 14.0 * 4.0 / 3.0;
let legend_top = text_at(&ops, "North America").2 - TITLE_ASCENT_EM * legend_px;
let category = text_at(&ops, "Q1");
assert!(
category.2 + 0.25 * CHART_LABEL_SIZE_PX < legend_top,
"{category:?} runs into the legend at {legend_top}"
);
}
#[test]
fn axis_labels_keep_half_an_em_and_legend_labels_calibri_widths() {
let font = chart_label_font();
assert_eq!(
fallback_label_width("July", &font),
4.0 * 0.5 * font.size_px
);
assert_eq!(
fallback_label_width("日本", &font),
2.0 * 0.5 * font.size_px
);
assert_eq!(legend_fallback_width("日本", &font), 2.0 * font.size_px);
for label in ["July", "illicit", "WW", "日本"] {
assert!(legend_fallback_width(label, &font) >= fallback_label_width(label, &font));
}
assert!(legend_fallback_width("WW", &font) > 2.0 * 0.85 * font.size_px);
}
#[test]
fn an_estimated_row_legend_leaves_each_label_at_least_the_room_it_had() {
let data = source(&[10.0, 20.0]);
let names = ["EASTERN", "WESTERN"];
let ops = plot_chart(
&legend_chart(Some("bottom"), &names, &data),
PlotRect {
x: 0.0,
y: 0.0,
w: 300.0,
h: 180.0,
},
);
let size = chart_label_font().size_px;
let key = size * LEGEND_KEY_EM;
let swatches: Vec<f64> = rects(&ops)
.into_iter()
.filter(|(_, _, w, h)| (w - key).abs() < 0.01 && (h - key).abs() < 0.01)
.map(|(x, ..)| x)
.collect();
let first = text_at(&ops, "EASTERN").1;
assert_eq!(swatches.len(), 2, "{swatches:?}");
assert!(
swatches[1] - first >= 7.0 * 0.5 * size + 14.0,
"{swatches:?} vs text at {first}"
);
}
#[test]
fn a_long_category_label_leaves_the_bars_clear_of_a_side_legend() {
let data = Source {
categories: vec!["Worldwide Customer Management".to_owned()],
values: vec![10.0],
};
let mut chart = legend_chart(Some("right"), &["North"], &data);
chart.chart_type = "bar";
chart.title = None;
let ops = plot_chart(
&chart,
PlotRect {
x: 0.0,
y: 0.0,
w: 250.0,
h: 180.0,
},
);
let key = chart_label_font().size_px * LEGEND_KEY_EM;
let (swatches, bars): (Vec<_>, Vec<_>) = rects(&ops)
.into_iter()
.skip(1)
.partition(|(_, _, w, h)| (w - key).abs() < 0.01 && (h - key).abs() < 0.01);
assert_eq!(swatches.len(), 1, "{swatches:?}");
for bar in &bars {
assert!(
bar.0 + bar.2 <= swatches[0].0,
"{bar:?} reaches {swatches:?}"
);
}
let widest = bars.iter().map(|bar| bar.2).fold(0.0, f64::max);
assert!(widest > MIN_PLOT / 2.0, "the bar keeps only {widest}px");
}
#[test]
fn a_short_frame_shrinks_the_plot_before_its_labels_leave_it() {
let data = source(&[10.0, 20.0]);
let mut chart = legend_chart(None, &["North"], &data);
chart.legend = None;
let frame = PlotRect {
x: 0.0,
y: 0.0,
w: 300.0,
h: 80.0,
};
let ops = plot_chart(&chart, frame);
let label = text_at(&ops, "Q1");
assert!(
label.2 <= frame.y + frame.h - CHART_PAD,
"{label:?} leaves the frame"
);
}
#[test]
fn an_overlay_row_legend_only_has_to_fit_the_frame() {
let data = source(&[10.0, 20.0]);
let names = ["A"];
let mut chart = legend_chart(Some("bottom"), &names, &data);
chart.title = None;
chart.legend = Some(PlotLegend {
overlay: true,
position: Some("bottom"),
visible: Some(true),
});
chart.text.legend.size_pt = Some(30.0);
let frame = PlotRect {
x: 0.0,
y: 0.0,
w: 260.0,
h: 80.0,
};
let ops = plot_chart(&chart, frame);
let key = 40.0 * LEGEND_KEY_EM;
let keys: Vec<f64> = rects(&ops)
.into_iter()
.filter(|(_, _, w, h)| (w - key).abs() < 0.01 && (h - key).abs() < 0.01)
.map(|(_, y, _, _)| y)
.collect();
assert_eq!(keys.len(), 1, "{keys:?}");
assert!(keys[0] >= frame.y && keys[0] + key <= frame.y + frame.h);
}
#[test]
fn a_legend_label_keeps_room_past_its_estimated_width() {
let data = source(&[10.0, 20.0]);
let names = ["WW"];
for position in ["bottom", "right"] {
let ops = plot_chart(&legend_chart(Some(position), &names, &data), rect());
let label = text_at(&ops, "WW");
assert!(
label.3
>= legend_fallback_width("WW", &chart_label_font()) + LEGEND_TEXT_SLACK - 1e-9,
"{position}: {label:?}"
);
}
}
#[test]
fn a_title_takes_a_band_sized_by_its_font() {
let data = source(&[10.0, 20.0]);
let mut chart = grouped("column", group("column", vec![series("North", &data)]));
chart.title = Some("Regional Sales");
chart.text.title.size_pt = Some(20.0);
chart.legend = Some(PlotLegend {
overlay: false,
position: None,
visible: Some(false),
});
let ops = plot_chart(&chart, rect());
let size = 20.0 * 4.0 / 3.0;
let title = text_at(&ops, "Regional Sales");
assert!((title.2 - (CHART_PAD + TITLE_ASCENT_EM * size)).abs() < 1e-9);
let top_tick = tick_labels(&ops)
.into_iter()
.map(|tick| tick.2)
.fold(f64::MAX, f64::min);
let band = CHART_PAD + TITLE_LINE_EM * size + BAND_GAP;
assert!(
top_tick - 0.75 * CHART_LABEL_SIZE_PX >= band - 1.0,
"the top tick {top_tick} rises into the title band {band}"
);
}
#[test]
fn a_combo_chart_makes_room_for_its_widest_labels() {
let data = Source {
categories: vec![
"A rather long category name".to_owned(),
"Another long one".to_owned(),
],
values: vec![10.0, 20.0],
};
let chart = PlotChart {
chart_type: "column",
plot_groups: vec![
group("column", vec![series("North", &data)]),
group("bar", vec![series("South", &data)]),
],
legend: Some(PlotLegend {
overlay: false,
position: None,
visible: Some(false),
}),
..PlotChart::default()
};
let frame = PlotRect {
x: 0.0,
y: 0.0,
w: 400.0,
h: 300.0,
};
let ops = plot_chart(&chart, frame);
for name in ["A rather long category name", "Another long one"] {
assert!(
texts_at(&ops)
.iter()
.filter(|(text, ..)| text == name)
.all(|(_, x, ..)| *x >= frame.x),
"{name} starts outside the frame"
);
}
}
#[test]
fn a_scatter_keeps_its_last_x_label_inside_the_frame() {
let data = source(&[1.0, 2.0]);
let xs = [0.0, 123_456.0];
let mut points = series("North", &data);
points.x_values = &xs;
let chart = PlotChart {
chart_type: "scatter",
plot_groups: vec![group("scatter", vec![points])],
legend: Some(PlotLegend {
overlay: false,
position: None,
visible: Some(false),
}),
..PlotChart::default()
};
let frame = rect();
for (text, x, _, width) in tick_labels(&plot_chart(&chart, frame)) {
let ink_right = x + width * 0.75;
assert!(ink_right <= frame.x + frame.w, "{text} ends at {ink_right}");
}
}
#[test]
fn axis_labels_keep_a_font_sized_gap_from_their_axis() {
let data = source(&[10.0, 20.0]);
let mut chart = grouped("column", group("column", vec![series("North", &data)]));
chart.legend = Some(PlotLegend {
overlay: false,
position: None,
visible: Some(false),
});
chart.text.chart.size_pt = Some(18.0);
let frame = PlotRect {
x: 0.0,
y: 0.0,
w: 600.0,
h: 400.0,
};
let ops = plot_chart(&chart, frame);
let size = 24.0;
let gap = TICK_LEN + LABEL_GAP_EM * size;
let axis_x = ops
.iter()
.filter_map(|op| match op {
PlotOp::Line { x1, x2, width, .. } if *width >= 1.0 && x1 == x2 => Some(*x1),
_ => None,
})
.fold(f64::MAX, f64::min);
let ticks = tick_labels(&ops);
let widest = ticks.iter().map(|tick| tick.3).fold(0.0, f64::max);
for tick in &ticks {
assert!((tick.1 + tick.3 - (axis_x - gap)).abs() < 1e-9, "{tick:?}");
}
assert!(
(axis_x - (CHART_PAD + widest + gap)).abs() < 1e-9,
"{axis_x}"
);
let axis_y = ops
.iter()
.filter_map(|op| match op {
PlotOp::Line { y1, y2, width, .. } if *width >= 1.0 && y1 == y2 => Some(*y1),
_ => None,
})
.fold(f64::MIN, f64::max);
let q1 = text_at(&ops, "Q1");
assert!((q1.2 - (axis_y + gap + 0.75 * size)).abs() < 1e-9, "{q1:?}");
assert!((frame.h - axis_y - (CHART_PAD + gap + size)).abs() < 1e-9);
}
#[test]
fn a_horizontal_bar_chart_draws_its_value_axis_under_the_plot() {
let north = source(&[10.0, 20.0]);
let plotted = |chart_type| {
plot_chart(
&PlotChart {
chart_type,
axis_titles: PlotAxisTitles {
category: Some("Quarter"),
value: Some("Millions"),
},
series: vec![series("North", &north)],
..PlotChart::default()
},
rect(),
)
};
let bar = plotted("bar");
let ticks = tick_labels(&bar);
assert!(ticks.len() > 1, "the value axis places ticks: {ticks:?}");
assert!(
ticks.iter().all(|tick| (tick.2 - ticks[0].2).abs() < 0.01),
"every value tick shares one row under the plot: {ticks:?}"
);
assert!(
ticks.iter().any(|tick| (tick.1 - ticks[0].1).abs() > 0.01),
"and they spread along it: {ticks:?}"
);
let (q1, q2) = (text_at(&bar, "Q1"), text_at(&bar, "Q2"));
assert!(
(q1.1 - q2.1).abs() < 0.01 && (q1.2 - q2.2).abs() > 0.01,
"the category names stack down the left gutter: {q1:?} {q2:?}"
);
assert!(
ticks
.iter()
.all(|tick| tick.2 > q1.2.max(q2.2) && (tick.1 - q1.1).abs() > 0.01),
"the value ticks vacate the category gutter for the row under the plot: \
{ticks:?} {q1:?} {q2:?}"
);
assert!(
q1.1 >= rect().x && q1.3 >= fallback_label_width("Q1", &chart_label_font()),
"the gutter is wide enough for a whole category name: {q1:?}"
);
assert!(
text_at(&bar, "Millions").2 > ticks[0].2,
"the value title sits below its ticks"
);
assert!(
text_at(&bar, "Quarter").2 < q1.2,
"the category title sits above its names"
);
let column = plotted("column");
let ticks = tick_labels(&column);
assert!(
ticks
.iter()
.all(|tick| (tick.1 + tick.3 - ticks[0].1 - ticks[0].3).abs() < 0.01),
"a column chart right-aligns its value ticks in one left-hand column: {ticks:?}"
);
let (q1, q2) = (text_at(&column, "Q1"), text_at(&column, "Q2"));
assert!(
(q1.2 - q2.2).abs() < 0.01 && (q1.1 - q2.1).abs() > 0.01,
"and its category names along the bottom: {q1:?} {q2:?}"
);
}
#[test]
fn a_transposed_value_axis_turns_its_gridlines_and_tick_marks_upright() {
let data = source(&[1.0, 2.0]);
let lines = |chart_type: &'static str| {
let mut group = group(chart_type, vec![series("North", &data)]);
group.axis_ids = vec!["1"];
let mut axis = value_axis("1", 0.0, 4.0);
axis.major_tick_mark = Some("out");
let chart = PlotChart {
chart_type,
plot_groups: vec![group],
axes: vec![axis],
..PlotChart::default()
};
let ops = plot_chart(&chart, rect());
let upright = |color: &str| {
ops.iter()
.filter(|op| {
matches!(op, PlotOp::Line { x1, x2, color: c, .. }
if c == color && (x1 - x2).abs() < 0.01)
})
.count()
};
(upright(CHART_GRID_COLOR), upright(CHART_AXIS_COLOR))
};
assert_eq!(
lines("bar"),
(5, 6),
"a bar chart draws vertical gridlines, five tick marks and the left frame line"
);
assert_eq!(
lines("column"),
(0, 1),
"a column chart draws horizontal gridlines and only the left frame line"
);
}
#[test]
fn a_horizontal_bar_chart_plots_its_first_category_at_the_bottom() {
let data = source(&[1.0, 9.0]);
let ordered = |reversed: bool| {
let mut group = group("bar", vec![series("North", &data)]);
group.axis_ids = vec!["1"];
let chart = PlotChart {
chart_type: "bar",
plot_groups: vec![group],
axes: vec![PlotAxis {
id: Some("1"),
kind: PlotAxisKind::Category,
reversed,
..PlotAxis::default()
}],
..PlotChart::default()
};
bars(&plot_chart(&chart, rect()))
};
let upright = ordered(false);
assert_eq!(upright.len(), 2);
assert!(
upright[0].1 > upright[1].1,
"the first category draws below the second: {upright:?}"
);
assert!(
upright[0].2 < upright[1].2,
"and it is the shorter bar: {upright:?}"
);
let reversed = ordered(true);
assert_eq!(reversed.len(), 2);
assert!(
reversed[0].1 < reversed[1].1,
"a maxMin category axis puts the first category back on top: {reversed:?}"
);
}
#[test]
fn horizontal_category_titles_have_their_own_header_row() {
let data = source(&[1.0, 2.0]);
for title in [None, Some("Revenue")] {
let chart = PlotChart {
chart_type: "bar",
title,
axis_titles: PlotAxisTitles {
category: Some("Quarter"),
value: Some("Millions"),
},
series: vec![series("North", &data)],
..PlotChart::default()
};
let ops = plot_chart(&chart, rect());
let category = text_at(&ops, "Quarter");
let title_bottom = title.map_or(0.0, |title| text_at(&ops, title).2 + 3.0);
assert!(category.2 - CHART_LABEL_SIZE_PX >= title_bottom);
assert_eq!(text_at(&ops, "Millions").2, 186.5);
}
}
#[test]
fn secondary_horizontal_axes_reserve_space_above_the_plot() {
let data = source(&[1.0, 2.0]);
for title in [None, Some("Revenue")] {
let mut primary = group("bar", vec![series("North", &data)]);
primary.axis_ids = vec!["1"];
let mut secondary = group("bar", vec![series("South", &data)]);
secondary.axis_ids = vec!["2"];
let mut chart = combo_with_axes(
primary,
secondary,
vec![value_axis("1", 0.0, 4.0), value_axis("2", 0.0, 8.0)],
);
chart.title = title;
let ops = plot_chart(&chart, rect());
let upper_tick = text_at(&ops, "8");
let title_bottom = title.map_or(0.0, |title| text_at(&ops, title).2 + 3.0);
assert!(upper_tick.2 - CHART_LABEL_SIZE_PX >= title_bottom);
let centre = upper_tick.1 + upper_tick.3 / 2.0;
assert!((centre - 239.0).abs() < 1e-9, "{centre}");
assert_eq!(text_at(&ops, "4").2, 188.0);
}
}
#[test]
fn number_format_codes_change_what_the_axis_reads() {
assert_eq!(format_with_code(0.256, "0.0%").as_deref(), Some("25.6%"));
assert_eq!(format_with_code(1234.5, "#,##0").as_deref(), Some("1,235"));
assert_eq!(
format_with_code(1234.5, "\"$\"#,##0.00").as_deref(),
Some("$1,234.50")
);
assert_eq!(
format_with_code(-12.0, "0.0;(0.0)").as_deref(),
Some("(12.0)")
);
assert_eq!(format_with_code(-12.0, "0.0").as_deref(), Some("-12.0"));
let accounting = "\"$\"#,##0_);[Red]\\(\"$\"#,##0\\)";
assert_eq!(
format_with_code(18766.0, accounting).as_deref(),
Some("$18,766")
);
assert_eq!(
format_with_code(-18766.0, accounting).as_deref(),
Some("($18,766)")
);
assert_eq!(format_with_code(7.0, "General").as_deref(), Some("7"));
assert_eq!(format_with_code(0.0, "0;-0;\"-\"").as_deref(), Some("-"));
assert_eq!(format_with_code(0.0, "0;-0;").as_deref(), Some(""));
assert_eq!(format_with_code(4.0, "0;-0;").as_deref(), Some("4"));
assert_eq!(
format_with_code(1234.5, "[$$-409]#,##0.00").as_deref(),
Some("$1,234.50")
);
assert_eq!(
format_with_code(1234.5, "[$\u{20ac}-407]#,##0.00").as_deref(),
Some("\u{20ac}1,234.50")
);
assert_eq!(format_with_code(3.0, "[$-409]0").as_deref(), Some("3"));
assert_eq!(format_with_code(2.5, "0.0\";\"").as_deref(), Some("2.5;"));
assert_eq!(
format_with_code(-2.5, "0.0\\;;(0.0)").as_deref(),
Some("(2.5)")
);
assert_eq!(format_with_code(7.0, "0 \"kg\"").as_deref(), Some("7 kg"));
assert_eq!(format_with_code(7.0, "yyyy-mm-dd"), None);
let ledger = "_(\"$\"* #,##0_);_(\"$\"* \\(#,##0\\);_(\"$\"* \"-\"??_);_(@_)";
assert_eq!(format_with_code(800.0, ledger).as_deref(), Some("$800"));
assert_eq!(format_with_code(0.0, ledger).as_deref(), Some("$-"));
for (value, code, want) in [
(0.0, "#.##", "."),
(1.5, "#.##", "1.5"),
(0.5, "#.##", ".5"),
(1.0, "0.0#", "1.0"),
(1.25, "0.0#", "1.25"),
(0.0, "#,##0.00", "0.00"),
(1234.5, "#,##0.##", "1,234.5"),
(7.0, "\"No.\" 0", "No. 7"),
] {
assert_eq!(
format_with_code(value, code).as_deref(),
Some(want),
"{value} {code}"
);
}
assert_eq!(format_with_code(1.25, "?.??").as_deref(), Some("1.25"));
assert_eq!(format_with_code(0.5, "0.0?").as_deref(), Some("0.5 "));
assert_eq!(format_with_code(-40.0, ledger).as_deref(), Some("$(40)"));
assert_eq!(format_with_code(f64::NAN, "0.0"), None);
assert_eq!(format_percent(0.5), "50%");
}
#[test]
fn an_axis_number_format_reaches_the_tick_labels() {
let data = source(&[0.2, 0.8]);
let mut group = group("column", vec![series("Share", &data)]);
group.axis_ids = vec!["1"];
let mut axis = value_axis("1", 0.0, 1.0);
axis.number_format = Some("0%");
let chart = PlotChart {
chart_type: "column",
plot_groups: vec![group],
axes: vec![axis],
..PlotChart::default()
};
let labels = texts(&plot_chart(&chart, rect()));
assert!(labels.contains(&"80%".to_owned()), "{labels:?}");
assert!(labels.contains(&"100%".to_owned()), "{labels:?}");
}
#[test]
fn data_label_switches_compose_the_text_they_ask_for() {
let data = source(&[3.0, 1.0]);
let compose = |spec: PlotDataLabels<'static>, chart_type: &'static str| {
let mut labelled = series("North", &data);
labelled.labels = Some(spec);
texts(&plot_chart(
&grouped(chart_type, group(chart_type, vec![labelled])),
rect(),
))
};
let value = PlotDataLabels {
show_value: true,
..PlotDataLabels::default()
};
assert!(compose(value, "column").contains(&"3".to_owned()));
assert!(
compose(
PlotDataLabels {
show_category_name: true,
show_series_name: true,
separator: Some(" / "),
..value
},
"column",
)
.contains(&"North / Q1 / 3".to_owned())
);
assert!(
compose(
PlotDataLabels {
show_percent: true,
show_value: false,
..PlotDataLabels::default()
},
"pie",
)
.contains(&"75%".to_owned()),
"a wedge takes its share of the series total"
);
assert!(
compose(
PlotDataLabels {
number_format: Some("0.00"),
..value
},
"column",
)
.contains(&"3.00".to_owned())
);
let count = |texts: Vec<String>| texts.iter().filter(|text| *text == "3").count();
assert!(
count(compose(PlotDataLabels::default(), "column")) < count(compose(value, "column")),
"a dLbls that shows nothing draws nothing"
);
}
#[test]
fn a_legend_key_draws_a_swatch_beside_its_label() {
let data = source(&[3.0, 1.0]);
let keyed = |show: bool| {
let mut labelled = series("North", &data);
labelled.color = Some("#123456");
labelled.labels = Some(PlotDataLabels {
show_value: true,
show_legend_key: show,
..PlotDataLabels::default()
});
plot_chart(&grouped("column", group("column", vec![labelled])), rect())
.iter()
.filter(
|op| matches!(op, PlotOp::Rect { fill, w, .. } if fill == "#123456" && *w == 7.0),
)
.count()
};
assert_eq!(keyed(true), 2);
assert_eq!(keyed(false), 0);
}
fn swatches(ops: &[PlotOp]) -> Vec<(f64, f64)> {
rects(ops)
.into_iter()
.filter(|(_, _, w, h)| is_swatch(*w, *h))
.map(|(x, y, _, _)| (x, y))
.collect()
}
fn legend_chart<'a>(
position: Option<&'a str>,
names: &'a [&'a str],
data: &'a Source,
) -> PlotChart<'a> {
PlotChart {
chart_type: "column",
title: Some("Revenue"),
series: names.iter().map(|name| series(name, data)).collect(),
legend: Some(PlotLegend {
overlay: false,
position,
visible: Some(true),
}),
..PlotChart::default()
}
}
#[test]
fn a_bottom_legend_flows_in_a_row_below_the_plot() {
let data = source(&[10.0, 20.0]);
let names = ["North", "South", "East"];
let ops = plot_chart(&legend_chart(Some("bottom"), &names, &data), rect());
let swatches = swatches(&ops);
assert_eq!(swatches.len(), 3);
assert!(
swatches.windows(2).all(|pair| pair[0].1 == pair[1].1),
"a row legend shares one y: {swatches:?}"
);
assert!(
swatches.windows(2).all(|pair| pair[0].0 < pair[1].0),
"a row legend advances in x: {swatches:?}"
);
let plot_bottom = bars(&ops)
.into_iter()
.map(|(_, y, _, h)| y + h)
.fold(f64::MIN, f64::max);
assert!(
swatches[0].1 > plot_bottom,
"the legend row sits over the bars: {} vs {plot_bottom}",
swatches[0].1
);
let span = swatches[2].0 - swatches[0].0;
let centre = swatches[0].0 + span / 2.0;
assert!(
(centre - rect().w / 2.0).abs() < rect().w / 8.0,
"the row is not near the frame centre: {centre}"
);
}
#[test]
fn a_side_legend_keeps_its_column() {
let data = source(&[10.0, 20.0]);
let names = ["North", "South", "East"];
for position in [None, Some("right"), Some("left")] {
let ops = plot_chart(&legend_chart(position, &names, &data), rect());
let swatches = swatches(&ops);
assert_eq!(swatches.len(), 3, "{position:?}");
assert!(
swatches.windows(2).all(|pair| pair[0].0 == pair[1].0),
"{position:?} legend shares one x: {swatches:?}"
);
assert!(
swatches.windows(2).all(|pair| pair[0].1 < pair[1].1),
"{position:?} legend advances in y: {swatches:?}"
);
}
}
#[test]
fn a_side_legend_spaces_its_rows_by_its_font_and_centres_them() {
let data = source(&[10.0, 20.0]);
let names = ["Hardware", "Software", "Services", "Other"];
let mut chart = legend_chart(Some("right"), &names, &data);
chart.title = None;
chart.text.legend.size_pt = Some(18.0);
let frame = PlotRect {
x: 0.0,
y: 0.0,
w: 880.0,
h: 560.0,
};
let ops = plot_chart(&chart, frame);
let key = 24.0 * LEGEND_KEY_EM;
let keys: Vec<(f64, f64)> = rects(&ops)
.into_iter()
.filter(|(_, _, w, h)| (w - key).abs() < 0.01 && (h - key).abs() < 0.01)
.map(|(x, y, _, _)| (x, y))
.collect();
assert_eq!(keys.len(), 4);
for pair in keys.windows(2) {
assert!((pair[1].1 - pair[0].1 - 24.0 * LEGEND_PITCH_EM).abs() < 0.01);
}
let middle = (keys[0].1 + keys[3].1 + key) / 2.0;
assert!((middle - frame.h / 2.0).abs() < 0.01, "{middle}");
let right = ops
.iter()
.filter_map(|op| match op {
PlotOp::Text { text, x, width, .. } if names.contains(&text.as_str()) => {
Some(x + width)
}
_ => None,
})
.fold(f64::MIN, f64::max);
assert!((right - frame.w).abs() < 0.01, "{right}");
let plot_right = ops
.iter()
.filter_map(|op| match op {
PlotOp::Line { x2, width, .. } if *width >= 1.0 => Some(*x2),
_ => None,
})
.fold(f64::MIN, f64::max);
assert!(
(plot_right - (keys[0].0 - 24.0)).abs() < 0.01,
"{plot_right}"
);
}
#[test]
fn a_stacked_column_legend_reads_top_down_like_its_stack() {
let data = source(&[10.0, 20.0]);
let order = |grouping| {
let mut columns = group(
"column",
vec![series("North", &data), series("South", &data)],
);
columns.grouping = grouping;
let mut chart = grouped("column", columns);
chart.legend = Some(PlotLegend {
overlay: false,
position: Some("right"),
visible: Some(true),
});
let ops = plot_chart(&chart, rect());
let mut names: Vec<(f64, String)> = ops
.iter()
.filter_map(|op| match op {
PlotOp::Text {
text, baseline_y, ..
} if text == "North" || text == "South" => Some((*baseline_y, text.clone())),
_ => None,
})
.collect();
names.sort_by(|a, b| a.0.total_cmp(&b.0));
names.into_iter().map(|(_, name)| name).collect::<Vec<_>>()
};
assert_eq!(order(None), ["North", "South"]);
assert_eq!(order(Some("stacked")), ["South", "North"]);
}
#[test]
fn a_side_legend_leaves_out_rows_the_chart_cannot_hold() {
let data = source(&[10.0, 20.0]);
let names = ["A", "B", "C", "D", "E", "F", "G", "H"];
let mut chart = legend_chart(Some("right"), &names, &data);
chart.title = None;
chart.text.legend.size_pt = Some(18.0);
let frame = PlotRect {
x: 0.0,
y: 0.0,
w: 400.0,
h: 180.0,
};
let key = 24.0 * LEGEND_KEY_EM;
let keys: Vec<(f64, f64)> = rects(&plot_chart(&chart, frame))
.into_iter()
.filter(|(_, _, w, h)| (w - key).abs() < 0.01 && (h - key).abs() < 0.01)
.map(|(x, y, _, _)| (x, y))
.collect();
assert!(!keys.is_empty() && keys.len() < names.len(), "{keys:?}");
assert!(
keys.iter()
.all(|(_, y)| *y >= frame.y && y + key <= frame.y + frame.h)
);
}
#[test]
fn a_pie_beside_a_legend_wider_than_its_chart_stays_in_the_frame() {
let data = source(&[3.0, 1.0]);
let mut chart = grouped("pie", group("pie", vec![series("Share", &data)]));
chart.legend = Some(PlotLegend {
overlay: false,
position: Some("right"),
visible: Some(true),
});
let frame = PlotRect {
x: 50.0,
y: 0.0,
w: 12.0,
h: 200.0,
};
for op in plot_chart(&chart, frame) {
if let PlotOp::Path { x, w, .. } = op {
assert!(x + w / 2.0 >= frame.x, "the pie centre left its frame: {x}");
}
}
}
#[test]
fn a_combo_legend_reverses_only_its_stacked_group() {
let data = source(&[10.0, 20.0]);
let mut columns = group(
"column",
vec![series("North", &data), series("South", &data)],
);
columns.grouping = Some("stacked");
let line = group("line", vec![series("East", &data), series("West", &data)]);
let chart = PlotChart {
chart_type: "column",
plot_groups: vec![columns, line],
legend: Some(PlotLegend {
overlay: false,
position: Some("right"),
visible: Some(true),
}),
..PlotChart::default()
};
let mut names: Vec<(f64, String)> = plot_chart(&chart, rect())
.into_iter()
.filter_map(|op| match op {
PlotOp::Text {
text, baseline_y, ..
} if ["North", "South", "East", "West"].contains(&text.as_str()) => {
Some((baseline_y, text))
}
_ => None,
})
.collect();
names.sort_by(|a, b| a.0.total_cmp(&b.0));
let order: Vec<String> = names.into_iter().map(|(_, name)| name).collect();
assert_eq!(order, ["South", "North", "East", "West"]);
}
#[test]
fn a_capped_combo_legend_reverses_its_stacked_group_safely() {
let data = source(&[10.0, 20.0]);
let names: Vec<String> = (0..9).map(|index| format!("S{index}")).collect();
let lines = group(
"line",
names.iter().map(|name| series(name, &data)).collect(),
);
let mut columns = group(
"column",
vec![series("North", &data), series("South", &data)],
);
columns.grouping = Some("stacked");
let chart = PlotChart {
chart_type: "line",
plot_groups: vec![lines, columns],
legend: Some(PlotLegend {
overlay: false,
position: Some("right"),
visible: Some(true),
}),
..PlotChart::default()
};
assert_eq!(
swatches(&plot_chart(&chart, rect())).len(),
MAX_LEGEND_ENTRIES
);
}
#[test]
fn a_top_right_legend_keeps_its_rows_inside_a_short_chart() {
let data = source(&[10.0, 20.0]);
let names = ["A", "B", "C", "D", "E", "F"];
let mut chart = legend_chart(Some("topRight"), &names, &data);
chart.title = None;
let frame = PlotRect {
x: 0.0,
y: 0.0,
w: 300.0,
h: 80.0,
};
let keys = swatches(&plot_chart(&chart, frame));
assert!(!keys.is_empty() && keys.len() < names.len(), "{keys:?}");
for (_, y) in keys {
assert!(y + CHART_LABEL_SIZE_PX * LEGEND_KEY_EM <= frame.y + frame.h);
}
}
#[test]
fn a_side_legend_with_no_room_leaves_the_plot_its_width() {
let data = source(&[10.0, 20.0]);
let names = ["North", "South"];
let frame = PlotRect {
x: 0.0,
y: 0.0,
w: 300.0,
h: 12.0,
};
let widest = |position| {
let mut chart = legend_chart(position, &names, &data);
chart.title = None;
plot_chart(&chart, frame)
.iter()
.filter_map(|op| match op {
PlotOp::Line { x1, x2, width, .. } if *width >= 1.0 => Some(x2 - x1),
_ => None,
})
.fold(f64::MIN, f64::max)
};
assert_eq!(widest(Some("right")), widest(Some("bottom")));
}
#[test]
fn a_top_right_legend_starts_at_the_top() {
let data = source(&[10.0, 20.0]);
let names = ["North", "South", "East"];
let mut chart = legend_chart(Some("topRight"), &names, &data);
chart.title = None;
let top = swatches(&plot_chart(&chart, rect()))[0].1;
let middle = swatches(&plot_chart(
&legend_chart(Some("right"), &names, &data),
rect(),
))[0]
.1;
assert!(top < middle, "{top} vs {middle}");
assert!(top < rect().y + LEGEND_EDGE + LEGEND_PITCH_EM * CHART_LABEL_SIZE_PX);
}
#[test]
fn a_row_legend_hands_its_width_back_to_the_plot() {
let data = source(&[10.0, 20.0]);
let names = ["North", "South", "East"];
let widest = |position| {
let ops = plot_chart(&legend_chart(position, &names, &data), rect());
ops.iter()
.filter_map(|op| match op {
PlotOp::Line { x1, x2, width, .. } if *width >= 1.0 => Some(x2 - x1),
_ => None,
})
.fold(f64::MIN, f64::max)
};
let key_x = swatches(&plot_chart(
&legend_chart(Some("right"), &names, &data),
rect(),
))[0]
.0;
let column = rect().x + rect().w
- CHART_PAD
- PLOT_INSET
- (key_x - CHART_LABEL_SIZE_PX * LEGEND_PLOT_GAP_EM);
assert!(
(widest(Some("bottom")) - widest(Some("right")) - column).abs() < 0.01,
"a bottom legend must return the column's width: {} vs {}",
widest(Some("bottom")),
widest(Some("right"))
);
}
#[test]
fn an_overlaid_legend_takes_no_band_from_the_plot() {
let data = source(&[10.0, 20.0]);
let names = ["North", "South", "East"];
let widest = |overlay| {
let mut chart = legend_chart(Some("right"), &names, &data);
chart.legend.as_mut().expect("legend").overlay = overlay;
plot_chart(&chart, rect())
.iter()
.filter_map(|op| match op {
PlotOp::Line { x1, x2, width, .. } if *width >= 1.0 => Some(x2 - x1),
_ => None,
})
.fold(f64::MIN, f64::max)
};
let key_x = swatches(&plot_chart(
&legend_chart(Some("right"), &names, &data),
rect(),
))[0]
.0;
assert!(
(widest(true)
- widest(false)
- (rect().x + rect().w
- CHART_PAD
- PLOT_INSET
- (key_x - CHART_LABEL_SIZE_PX * LEGEND_PLOT_GAP_EM)))
.abs()
< 0.01,
"an overlaid legend must leave the plot its width: {} vs {}",
widest(true),
widest(false)
);
}
#[test]
fn a_band_with_no_room_still_finishes_its_wrap() {
let mut ops = Vec::new();
let mut emitter = Emitter {
sink: &mut ops,
remaining: MAX_PLOT_OPS,
};
let style = PlotTextStyle::default().resolve(CHART_LABEL_SIZE_PX, 400);
let lines = wrap_legend_label("North", -1.0, &style, &mut emitter);
assert_eq!(lines.concat(), "North");
}
#[test]
fn a_category_name_with_breaks_keeps_every_line_inside_the_frame() {
let data = source(&[10.0, 20.0]);
let names = ["North"];
let categories = ["One\nTwo\nThree".to_owned(), "Four".to_owned()];
let mut chart = legend_chart(Some("bottom"), &names, &data);
chart.series[0].categories = &categories;
let ops = plot_chart(&chart, rect());
let bottom = rect().y + rect().h;
let three = ops
.iter()
.find_map(|op| match op {
PlotOp::Text {
text, baseline_y, ..
} if text == "Three" => Some(*baseline_y),
_ => None,
})
.expect("the third line is drawn");
assert!(
three < bottom,
"the third line sits below the frame: {three} vs {bottom}"
);
}
#[test]
fn a_wrapped_legend_keeps_every_entry_inside_the_frame() {
let data = source(&[10.0, 20.0]);
let names = [
"Northern region",
"Southern region",
"Eastern region",
"Western region",
"Central region",
"Coastal region",
"Highland region",
"Lowland region",
];
let ops = plot_chart(&legend_chart(Some("bottom"), &names, &data), rect());
let swatches = swatches(&ops);
assert_eq!(swatches.len(), MAX_LEGEND_ENTRIES);
assert!(
swatches.windows(2).any(|pair| pair[0].1 < pair[1].1),
"long entries wrap onto another row: {swatches:?}"
);
for (x, y) in swatches {
assert!(x >= rect().x && x + SWATCH <= rect().x + rect().w);
assert!(y >= rect().y && y + SWATCH <= rect().y + rect().h);
}
}
#[test]
fn top_and_bottom_bands_resize_pie_and_radar_families() {
let data = source(&[10.0, 20.0, 30.0]);
let names = ["North"];
for kind in ["pie", "radar"] {
let bounds = |position| {
let mut chart = legend_chart(position, &names, &data);
chart.chart_type = kind;
if position.is_none() {
chart.legend.as_mut().unwrap().visible = Some(false);
}
if kind == "radar" {
chart.plot_groups = vec![PlotGroup {
chart_type: Some("radar"),
series: chart.series.clone(),
radar_style: Some("filled"),
..PlotGroup::default()
}];
}
plot_chart(&chart, rect())
.into_iter()
.find_map(|op| match op {
PlotOp::Path { commands, .. } => {
let ys: Vec<_> = commands
.iter()
.filter_map(|command| match command {
GeometryPathCommand::Move { y, .. }
| GeometryPathCommand::Line { y, .. } => Some(*y),
_ => None,
})
.collect();
let min = ys.iter().copied().fold(f64::INFINITY, f64::min);
let max = ys.iter().copied().fold(f64::NEG_INFINITY, f64::max);
Some((min, max - min))
}
_ => None,
})
.unwrap()
};
let original = bounds(None);
let top = bounds(Some("top"));
let bottom = bounds(Some("bottom"));
assert!(top.1 < original.1, "{kind}: {top:?} vs {original:?}");
assert!((top.1 - bottom.1).abs() < 1e-9);
assert!(
(top.0 - bottom.0 - (LEGEND_ROW_EM * CHART_LABEL_SIZE_PX + BAND_GAP)).abs() < 1e-9
);
}
}
#[test]
fn the_title_and_the_category_names_are_the_centred_text() {
let data = source(&[10.0, 20.0]);
let names = ["North"];
let ops = plot_chart(&legend_chart(Some("bottom"), &names, &data), rect());
let centred: Vec<&str> = ops
.iter()
.filter_map(|op| match op {
PlotOp::Text {
text,
align: PlotTextAlign::Center,
..
} => Some(text.as_str()),
_ => None,
})
.collect();
assert_eq!(centred, ["Revenue", "Q1", "Q2"]);
let title = ops
.iter()
.find_map(|op| match op {
PlotOp::Text { text, x, width, .. } if text == "Revenue" => Some((*x, *width)),
_ => None,
})
.expect("the title is emitted");
assert_eq!(title.0 + title.1 / 2.0, rect().x + rect().w / 2.0);
}
#[test]
fn a_narrow_column_centres_its_label_on_the_bar() {
let mut space = labelled_space(
"column",
None,
Some(ChartDataLabels {
show_value: Some(true),
position: Some("ctr".to_owned()),
..ChartDataLabels::default()
}),
);
let series = &mut space.plot_groups[0].series[0];
series.values = (1..=40).map(f64::from).collect();
series.categories = (1..=40).map(|index| format!("C{index}")).collect();
let chart = PlotChart::from(&space);
let ops = plot_chart(&chart, rect());
let bar = bars(&ops)[0];
assert!(bar.2 < 32.0, "the bar is narrower than a label: {}", bar.2);
let (x, width) = ops
.iter()
.find_map(|op| match op {
PlotOp::Text { text, x, width, .. } if text == "1" => Some((*x, *width)),
_ => None,
})
.expect("the first label is drawn");
assert!(((x + width / 2.0) - (bar.0 + bar.2 / 2.0)).abs() < 1e-6);
}
#[test]
fn a_point_text_inherits_series_switches() {
let space = labelled_space(
"column",
None,
Some(ChartDataLabels {
show_value: Some(true),
show_legend_key: Some(true),
points: Some(vec![ChartPointLabel {
runs: None,
index: Some(1.0),
text: Some("pinned".to_owned()),
labels: ChartDataLabels::default(),
}]),
..ChartDataLabels::default()
}),
);
let chart = PlotChart::from(&space);
let point = &chart.plot_groups[0].series[0].points[0];
let inherited = point.labels.expect("point labels");
assert!(inherited.show_value);
assert!(inherited.show_legend_key);
let labels = texts(&plot_chart(&chart, rect()));
assert!(labels.contains(&"pinned".to_owned()));
assert!(labels.contains(&"3".to_owned()));
}
#[test]
fn a_point_override_keeps_unset_series_switches() {
let space = labelled_space(
"column",
None,
Some(ChartDataLabels {
show_value: Some(true),
show_category_name: Some(true),
points: Some(vec![ChartPointLabel {
runs: None,
index: Some(1.0),
text: None,
labels: ChartDataLabels {
show_value: Some(false),
..ChartDataLabels::default()
},
}]),
..ChartDataLabels::default()
}),
);
let chart = PlotChart::from(&space);
let labels = chart.plot_groups[0].series[0].points[0]
.labels
.expect("point labels");
assert!(!labels.show_value);
assert!(labels.show_category_name);
}
#[test]
fn a_point_delete_suppresses_its_inherited_series_label() {
let space = labelled_space(
"pie",
None,
Some(ChartDataLabels {
show_value: Some(true),
points: Some(vec![ChartPointLabel {
runs: None,
index: Some(1.0),
text: None,
labels: ChartDataLabels {
delete: Some(true),
..ChartDataLabels::default()
},
}]),
..ChartDataLabels::default()
}),
);
let labels = texts(&plot_chart(&PlotChart::from(&space), rect()));
assert!(labels.contains(&"3".to_owned()));
assert!(!labels.contains(&"1".to_owned()));
}
#[test]
fn chart_scope_tracking_reaches_every_text_op_and_a_scope_overrides_it() {
let mut space = model_space("column", None, vec![model_series("North", &[3.0, 1.0])]);
space.title = Some("Revenue".to_owned());
space.text = Some(ChartTextProperties {
spacing_pt: Some(1.5),
..ChartTextProperties::default()
});
space.title_text = Some(ChartTextProperties {
spacing_pt: Some(-0.75),
..ChartTextProperties::default()
});
let ops = plot_chart(&PlotChart::from(&space), rect());
let spacing = |label: &str| {
ops.iter().find_map(|op| match op {
PlotOp::Text { text, font, .. } if text == label => Some(font.letter_spacing_px),
_ => None,
})
};
assert_eq!(spacing("Revenue"), Some(-1.0));
assert_eq!(spacing("Q1"), Some(2.0));
assert_eq!(chart_label_font().letter_spacing_px, 0.0);
}
#[test]
fn a_series_delete_suppresses_group_labels() {
let group = ChartDataLabels {
show_value: Some(true),
..ChartDataLabels::default()
};
let series = ChartDataLabels {
delete: Some(true),
..ChartDataLabels::default()
};
assert!(plot_labels_from_model(None, Some(&series), None, Some(&group)).is_none());
let space = labelled_space("pie", Some(group), Some(series));
let labels = texts(&plot_chart(&PlotChart::from(&space), rect()));
assert!(!labels.contains(&"3".to_owned()));
assert!(!labels.contains(&"1".to_owned()));
}
#[test]
fn group_point_fields_resolve_through_the_four_level_cascade() {
let group = ChartDataLabels {
show_percent: Some(true),
show_bubble_size: Some(true),
position: Some("outEnd".to_owned()),
text: Some(ChartTextProperties {
font: Some("Group".to_owned()),
color: Some("112233".to_owned()),
..ChartTextProperties::default()
}),
..ChartDataLabels::default()
};
let group_point = ChartDataLabels {
show_category_name: Some(true),
show_legend_key: Some(true),
separator: Some(" | ".to_owned()),
text: Some(ChartTextProperties {
italic: Some(true),
..ChartTextProperties::default()
}),
..ChartDataLabels::default()
};
let series = ChartDataLabels {
show_value: Some(false),
position: Some("inEnd".to_owned()),
text: Some(ChartTextProperties {
size_pt: Some(12.0),
bold: Some(false),
..ChartTextProperties::default()
}),
..ChartDataLabels::default()
};
let series_point = ChartDataLabels {
show_series_name: Some(true),
number_format: Some("0.0".to_owned()),
text: Some(ChartTextProperties {
color: Some("AABBCC".to_owned()),
..ChartTextProperties::default()
}),
..ChartDataLabels::default()
};
let labels = plot_labels_from_model(
Some(&series_point),
Some(&series),
Some(&group_point),
Some(&group),
)
.expect("labels resolve");
assert!(!labels.show_value);
assert!(labels.show_category_name);
assert!(labels.show_series_name);
assert!(labels.show_percent);
assert!(labels.show_legend_key);
assert!(labels.show_bubble_size);
assert_eq!(labels.separator, Some(" | "));
assert_eq!(labels.position, Some("inEnd"));
assert_eq!(labels.number_format, Some("0.0"));
assert_eq!(labels.text.font, Some("Group"));
assert_eq!(labels.text.size_pt, Some(12.0));
assert_eq!(labels.text.bold, Some(false));
assert_eq!(labels.text.italic, Some(true));
assert_eq!(labels.text.color, Some("AABBCC"));
}
#[test]
fn series_delete_overrides_group_label_defaults() {
let inherited = model_series("Inherited", &[41.0, 42.0]);
let mut deleted = model_series("Deleted", &[51.0]);
deleted.data_labels = Some(ChartDataLabels {
delete: Some(true),
..ChartDataLabels::default()
});
let space = model_space(
"line",
Some(ChartDataLabels {
show_value: Some(true),
points: Some(vec![ChartPointLabel {
runs: None,
index: Some(1.0),
text: None,
labels: ChartDataLabels {
delete: Some(true),
..ChartDataLabels::default()
},
}]),
..ChartDataLabels::default()
}),
vec![inherited, deleted],
);
let chart = PlotChart::from(&space);
let views = series_views(&chart.plot_groups[0].series, &mut ScanBudget::new());
let label = PlotTextStyle::default().resolve(CHART_LABEL_SIZE_PX, 400);
let family = family(&chart, &views, &label);
assert_eq!(
point_label(family, &views[0], 0, 0.0).as_deref(),
Some("41")
);
assert!(point_label(family, &views[0], 1, 0.0).is_none());
assert!(point_label(family, &views[1], 0, 0.0).is_none());
}
#[test]
fn delete_is_inherited_until_a_lower_scope_overrides_it() {
let mut inherited = model_series("Inherited", &[11.0, 12.0]);
inherited.data_labels = Some(ChartDataLabels {
show_category_name: Some(false),
..ChartDataLabels::default()
});
let mut point_restored = model_series("Point", &[21.0, 22.0]);
point_restored.data_labels = Some(ChartDataLabels {
delete: Some(true),
points: Some(vec![ChartPointLabel {
runs: None,
index: Some(1.0),
text: None,
labels: ChartDataLabels {
delete: Some(false),
..ChartDataLabels::default()
},
}]),
..ChartDataLabels::default()
});
let mut series_restored = model_series("Series", &[31.0, 32.0]);
series_restored.data_labels = Some(ChartDataLabels {
delete: Some(false),
..ChartDataLabels::default()
});
let space = model_space(
"line",
Some(ChartDataLabels {
delete: Some(true),
show_value: Some(true),
..ChartDataLabels::default()
}),
vec![inherited, point_restored, series_restored],
);
let chart = PlotChart::from(&space);
let views = series_views(&chart.plot_groups[0].series, &mut ScanBudget::new());
let label = PlotTextStyle::default().resolve(CHART_LABEL_SIZE_PX, 400);
let family = family(&chart, &views, &label);
assert!(point_label(family, &views[0], 0, 0.0).is_none());
assert!(point_label(family, &views[0], 1, 0.0).is_none());
assert!(point_label(family, &views[1], 0, 0.0).is_none());
assert_eq!(
point_label(family, &views[1], 1, 0.0).as_deref(),
Some("22")
);
assert_eq!(
point_label(family, &views[2], 0, 0.0).as_deref(),
Some("31")
);
assert_eq!(
point_label(family, &views[2], 1, 0.0).as_deref(),
Some("32")
);
}
#[test]
fn series_and_point_label_overrides_do_not_leak() {
let mut shown = model_series("Shown", &[11.0, 12.0]);
shown.data_labels = Some(ChartDataLabels {
show_value: Some(true),
points: Some(vec![ChartPointLabel {
runs: None,
index: Some(1.0),
text: None,
labels: ChartDataLabels {
delete: Some(true),
..ChartDataLabels::default()
},
}]),
..ChartDataLabels::default()
});
let inherited = model_series("Inherited", &[21.0, 22.0]);
let mut deleted = model_series("Deleted", &[31.0, 32.0]);
deleted.data_labels = Some(ChartDataLabels {
delete: Some(true),
..ChartDataLabels::default()
});
let space = model_space("line", None, vec![shown, inherited, deleted]);
let chart = PlotChart::from(&space);
let views = series_views(&chart.plot_groups[0].series, &mut ScanBudget::new());
let label = PlotTextStyle::default().resolve(CHART_LABEL_SIZE_PX, 400);
let family = family(&chart, &views, &label);
assert_eq!(
point_label(family, &views[0], 0, 0.0).as_deref(),
Some("11")
);
assert!(point_label(family, &views[0], 1, 0.0).is_none());
for view in &views[1..] {
assert!(point_label(family, view, 0, 0.0).is_none());
assert!(point_label(family, view, 1, 0.0).is_none());
}
}
#[test]
fn a_label_position_moves_the_text_within_its_bar() {
let data = source(&[10.0, 20.0]);
let placed = |position: &'static str| {
let mut labelled = series("North", &data);
labelled.labels = Some(PlotDataLabels {
show_value: true,
position: Some(position),
..PlotDataLabels::default()
});
plot_chart(&grouped("column", group("column", vec![labelled])), rect())
.into_iter()
.filter_map(|op| match op {
PlotOp::Text {
text, baseline_y, ..
} if text == "20" => Some(baseline_y),
_ => None,
})
.next_back()
.expect("a label")
};
assert!(placed("outEnd") < placed("inEnd"));
assert!(placed("inEnd") < placed("ctr"));
assert!(placed("ctr") < placed("inBase"));
}
#[test]
fn a_line_label_stands_on_the_side_its_position_names() {
let data = source(&[10.0, 20.0]);
let placed = |position: Option<&'static str>| {
let mut labelled = series("North", &data);
labelled.marker = Some(PlotMarker {
size: Some(8.0),
symbol: Some(PlotMarkerSymbol::Diamond),
});
labelled.labels = Some(PlotDataLabels {
show_value: true,
position,
..PlotDataLabels::default()
});
let ops = plot_chart(&grouped("line", group("line", vec![labelled])), rect());
let marker = ops
.iter()
.filter_map(|op| match op {
PlotOp::Path { x, y, w, h, .. } if (w - h).abs() < 0.01 => {
Some((x + w / 2.0, y + h / 2.0))
}
_ => None,
})
.nth(1)
.expect("the second point's marker");
let (_, x, baseline, width) = texts_at(&ops)
.into_iter()
.rfind(|(text, ..)| text == "20")
.expect("a label");
(marker, x, baseline, width)
};
let ((mx, my), x, baseline, _) = placed(None);
assert!(
x > mx && (baseline - my).abs() < 8.0,
"right of the point by default"
);
let ((mx, my), x, baseline, width) = placed(Some("t"));
assert!(
(x + width / 2.0 - mx).abs() < 0.01 && baseline < my,
"centred above"
);
let ((mx, my), x, baseline, width) = placed(Some("b"));
assert!(
(x + width / 2.0 - mx).abs() < 0.01 && baseline > my,
"centred below"
);
}
#[test]
fn chart_text_properties_reach_every_scope_and_inherit() {
let data = source(&[1.0, 2.0]);
let mut labelled = series("North", &data);
labelled.labels = Some(PlotDataLabels {
show_value: true,
number_format: Some("0.0"),
text: PlotTextStyle {
size_pt: Some(6.0),
..PlotTextStyle::default()
},
..PlotDataLabels::default()
});
let axis = PlotAxis {
id: Some("1"),
kind: PlotAxisKind::Value,
range: PlotAxisRange {
min: Some(0.0),
max: Some(4.0),
},
major_gridlines: true,
text: PlotTextStyle {
italic: Some(true),
..PlotTextStyle::default()
},
..PlotAxis::default()
};
let mut group = group("column", vec![labelled]);
group.axis_ids = vec!["1"];
let chart = PlotChart {
chart_type: "column",
title: Some("Revenue"),
plot_groups: vec![group],
axes: vec![axis],
text: PlotChartText {
chart: PlotTextStyle {
font: Some("Georgia"),
color: Some("#112233"),
..PlotTextStyle::default()
},
title: PlotTextStyle {
size_pt: Some(30.0),
..PlotTextStyle::default()
},
legend: PlotTextStyle {
bold: Some(true),
..PlotTextStyle::default()
},
},
..PlotChart::default()
};
let ops = plot_chart(&chart, rect());
let font = |needle: &str| {
ops.iter()
.find_map(|op| match op {
PlotOp::Text {
text, font, color, ..
} if text == needle => Some((font.css(), color.clone())),
_ => None,
})
.unwrap_or_else(|| panic!("{needle} is missing"))
};
assert_eq!(
font("Revenue"),
("600 40px Georgia".to_owned(), "#112233".to_owned())
);
assert_eq!(font("4").0, "italic 400 10px Georgia");
assert_eq!(font("North").0, "700 10px Georgia");
assert_eq!(font("2.0").0, "400 8px Georgia");
}
#[test]
fn every_family_stays_finite_and_bounded_on_hostile_input() {
let extremes = Source {
categories: Vec::new(),
values: vec![f64::MAX, -f64::MAX, 5.0, f64::NAN],
};
let wide: Vec<f64> = (0..50_000).map(|value| value as f64).collect();
let dense = Source {
categories: Vec::new(),
values: wide.clone(),
};
for chart_type in [
"area", "scatter", "bubble", "radar", "stock", "surface", "doughnut", "line", "column",
"bar",
] {
for data in [&extremes, &dense] {
let mut plotted = series("Hostile", data);
plotted.x_values = &wide;
plotted.bubble_sizes = &wide;
let chart = grouped(chart_type, group(chart_type, vec![plotted]));
let ops = plot_chart(&chart, rect());
assert!(ops.len() <= MAX_PLOT_OPS, "{chart_type}");
for op in &ops {
assert!(op_is_finite(op), "{chart_type} {op:?}");
}
}
}
}
#[test]
fn the_sink_sees_the_same_ops_the_vector_wrapper_collects() {
let north = source(&[10.0, 20.0]);
let chart = PlotChart {
chart_type: "column",
title: Some("Revenue"),
series: vec![series("North", &north)],
..PlotChart::default()
};
struct Counter(usize);
impl PlotSink for Counter {
fn push_op(&mut self, _: PlotOp) -> bool {
self.0 += 1;
true
}
}
let mut counter = Counter(0);
plot_chart_into(&chart, rect(), &mut counter);
assert_eq!(counter.0, plot_chart(&chart, rect()).len());
}
}