use super::{LABEL_LINE, LABEL_SIZE, count, fit_text, title::two_lines};
use crate::{
error::ChartWarning,
metrics::TextMetrics,
scene::{Element, Text, TextAnchor},
spec::{AxisScale, ChartSpec, Locale, NumberStyle, ValueAxisSpec, ValueFormat},
};
pub(crate) fn add_bottom_category_title(
spec: &ChartSpec,
left: f64,
plot_width: f64,
chart_height: f64,
elements: &mut Vec<Element>,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) {
if let Some(title) = &spec.category_axis.title {
let title = fit_text(
title,
plot_width,
LABEL_SIZE,
metrics,
warnings,
"/categoryAxis/title",
);
elements.push(Element::Text(Text {
x: left + plot_width / 2.0,
y: chart_height - 14.0,
class: "chartlet-axis-title",
anchor: TextAnchor::Middle,
content: title,
}));
}
}
fn label_lines(
label: &str,
max_width: f64,
metrics: &impl TextMetrics,
warnings: &mut Vec<ChartWarning>,
path: &str,
) -> Vec<String> {
match two_lines(label, max_width, LABEL_SIZE, metrics) {
Some((first, rest)) => vec![
first.to_owned(),
fit_text(rest, max_width, LABEL_SIZE, metrics, warnings, path),
],
None => vec![fit_text(
label, max_width, LABEL_SIZE, metrics, warnings, path,
)],
}
}
const DENSE_CATEGORIES: usize = 16;
pub(crate) fn label_step(categories: &[String], band: f64, metrics: &impl TextMetrics) -> usize {
if categories.len() <= DENSE_CATEGORIES {
return 1;
}
let fits = |label: &str, room: f64| {
metrics.width(label, LABEL_SIZE) <= room
|| two_lines(label, room, LABEL_SIZE, metrics)
.is_some_and(|(_, rest)| metrics.width(rest, LABEL_SIZE) <= room)
};
(1..categories.len())
.find(|step| {
categories
.iter()
.all(|label| fits(label, band * count(*step) - 8.0))
})
.unwrap_or(categories.len())
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn push_stepped_category_label(
elements: &mut Vec<Element>,
label: &str,
at: (f64, f64),
(index, step, band): (usize, usize, f64),
metrics: &impl TextMetrics,
warnings: &mut Vec<ChartWarning>,
path: &str,
) {
if index.is_multiple_of(step) {
let room = (band * count(step) - 8.0).max(20.0);
push_category_label(elements, label, at, room, metrics, warnings, path);
}
}
pub(crate) fn warn_if_labels_thinned(
spec: &ChartSpec,
step: usize,
warnings: &mut Vec<ChartWarning>,
) {
if step > 1 {
let path = if spec.data.is_empty() {
"/categories"
} else {
"/data"
};
warnings.push(ChartWarning::new(
"labels_thinned",
path,
format!(
"one category in {step} is labelled, so that the labels fit; the data table lists every category"
),
));
}
}
pub(crate) fn push_category_label(
elements: &mut Vec<Element>,
label: &str,
(x, y): (f64, f64),
max_width: f64,
metrics: &impl TextMetrics,
warnings: &mut Vec<ChartWarning>,
path: &str,
) {
let lines = label_lines(label, max_width, metrics, warnings, path);
for (index, content) in lines.into_iter().enumerate() {
elements.push(Element::Text(Text {
x,
y: y + count(index) * LABEL_LINE,
class: "chartlet-label",
anchor: TextAnchor::Middle,
content,
}));
}
}
pub(crate) fn push_side_label(
elements: &mut Vec<Element>,
label: &str,
(x, center): (f64, f64),
(max_width, band): (f64, f64),
metrics: &impl TextMetrics,
warnings: &mut Vec<ChartWarning>,
path: &str,
) {
let lines = if band >= 2.0 * LABEL_LINE + 4.0 {
label_lines(label, max_width, metrics, warnings, path)
} else {
vec![fit_text(
label, max_width, LABEL_SIZE, metrics, warnings, path,
)]
};
let first = center + 4.0 - count(lines.len() - 1) * LABEL_LINE / 2.0;
for (index, content) in lines.into_iter().enumerate() {
elements.push(Element::Text(Text {
x,
y: first + count(index) * LABEL_LINE,
class: "chartlet-label",
anchor: TextAnchor::End,
content,
}));
}
}
pub(crate) fn format_value(value: f64, style: impl Into<NumberStyle>) -> String {
let style = style.into();
let value = match style.format {
ValueFormat::Number => value,
ValueFormat::Percent => value * 100.0,
};
let digits = match style.decimals {
Some(decimals) => format!("{:.*}", usize::from(decimals), tidy(value)),
None => format!("{}", tidy(value)),
};
let digits = if digits.starts_with('-') && digits.trim_start_matches(['-', '0', '.']).is_empty()
{
digits[1..].to_owned()
} else {
digits
};
let digits = digits.replace('-', "\u{2212}");
let digits = if style.thousands {
separate_thousands(&digits)
} else {
digits
};
let digits = match style.locale {
Locale::En => digits,
Locale::De => digits
.chars()
.map(|c| match c {
'.' => ',',
',' => '.',
c => c,
})
.collect(),
};
let suffix = match (style.format, style.locale) {
(ValueFormat::Number, _) => "",
(ValueFormat::Percent, Locale::En) => "%",
(ValueFormat::Percent, Locale::De) => "\u{202f}%",
};
format!("{digits}{suffix}")
}
fn separate_thousands(digits: &str) -> String {
let start = digits.find(|c: char| c.is_ascii_digit()).unwrap_or(0);
let end = digits[start..]
.find(|c: char| !c.is_ascii_digit())
.map_or(digits.len(), |offset| start + offset);
let integer = &digits[start..end];
let mut grouped = String::with_capacity(digits.len() + integer.len() / 3);
grouped.push_str(&digits[..start]);
for (index, digit) in integer.chars().enumerate() {
if index > 0 && (integer.len() - index).is_multiple_of(3) {
grouped.push(',');
}
grouped.push(digit);
}
grouped.push_str(&digits[end..]);
grouped
}
pub(crate) fn format_tick(value: f64, step: f64, start: f64, style: NumberStyle) -> String {
let scaled = |number: f64| match style.format {
ValueFormat::Number => number,
ValueFormat::Percent => number * 100.0,
};
let decimals_of = |number: f64| {
format!("{}", tidy(scaled(number)))
.split_once('.')
.map_or(0, |(_, fraction)| fraction.len())
};
let decimals = decimals_of(step).max(decimals_of(start));
format_value(
value,
NumberStyle {
decimals: Some(u8::try_from(decimals).unwrap_or(u8::MAX)),
..style
},
)
}
fn tidy(value: f64) -> f64 {
let rounded: f64 = format!("{value:.11e}")
.parse()
.expect("a formatted float parses back");
if rounded == 0.0 { 0.0 } else { rounded }
}
fn nice_step(range: f64) -> f64 {
let raw_step = range / 5.0;
let magnitude = 10.0_f64.powf(raw_step.log10().floor());
let fraction = raw_step / magnitude;
let nice_fraction = if fraction <= 1.0 {
1.0
} else if fraction <= 2.0 {
2.0
} else if fraction <= 5.0 {
5.0
} else {
10.0
};
nice_fraction * magnitude
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct NumericScale {
min: f64,
max: f64,
pub step: f64,
log: bool,
reversed: bool,
}
impl NumericScale {
pub(crate) fn for_axis(
values: impl Iterator<Item = f64>,
include_zero: bool,
axis: &ValueAxisSpec,
) -> Self {
let scale = match (axis.scale, axis.exact, axis.min, axis.max) {
(AxisScale::Log, ..) => Self::logarithmic(values, axis.bounds()),
(AxisScale::Linear, true, Some(min), Some(max)) => Self {
min,
max,
step: axis.step.unwrap_or_else(|| nice_step(max - min)),
log: false,
reversed: false,
},
(AxisScale::Linear, ..) => {
let scale = Self::from_values(values, include_zero, axis.bounds());
match axis.step {
Some(step) => Self {
min: tidy(tidy(scale.min / step).floor() * step),
max: tidy(tidy(scale.max / step).ceil() * step),
step,
..scale
},
None => scale,
}
}
};
Self {
reversed: axis.reverse,
..scale
}
}
fn logarithmic(
values: impl Iterator<Item = f64>,
(declared_min, declared_max): (Option<f64>, Option<f64>),
) -> Self {
let (mut min, mut max) = values.fold((f64::INFINITY, 0.0_f64), |(min, max), value| {
(min.min(value), max.max(value))
});
min = declared_min.map_or(min, |bound| min.min(bound));
max = declared_max.map_or(max, |bound| max.max(bound));
let low = tidy(10.0_f64.powf(min.log10().floor()));
let decade = 10.0_f64.powf(max.log10().floor());
let high = [1.0, 2.0, 5.0, 10.0]
.into_iter()
.map(|multiple| tidy(decade * multiple))
.find(|end| *end >= max * (1.0 - 1e-9))
.expect("ten times the decade of a value is above it");
Self {
min: low,
max: if high <= low { tidy(low * 10.0) } else { high },
step: 1.0,
log: true,
reversed: false,
}
}
pub(crate) const fn is_log(self) -> bool {
self.log
}
pub(crate) const fn base(self) -> f64 {
if self.log { self.min } else { 0.0 }
}
pub(crate) fn is_zero(self, value: f64) -> bool {
!self.log && value.abs() < self.step / 100.0
}
pub(crate) fn tick_label(self, value: f64, style: NumberStyle) -> String {
if !self.log {
return format_tick(value, self.step, self.first_tick(), style);
}
let mut decimals = 0_u8;
let mut scaled = value;
while scaled < 1.0 - 1e-9 && decimals < 6 {
scaled *= 10.0;
decimals += 1;
}
format_value(
value,
NumberStyle {
decimals: Some(decimals),
..style
},
)
}
pub(crate) fn from_values(
mut values: impl Iterator<Item = f64>,
include_zero: bool,
(declared_min, declared_max): (Option<f64>, Option<f64>),
) -> Self {
let first = values.next().expect("validated charts contain values");
let mut min = if include_zero { first.min(0.0) } else { first };
let mut max = if include_zero { first.max(0.0) } else { first };
for value in values {
min = min.min(value);
max = max.max(value);
}
min = declared_min.map_or(min, |bound| min.min(bound));
max = declared_max.map_or(max, |bound| max.max(bound));
if (max - min).abs() < f64::EPSILON {
if max.abs() < f64::EPSILON {
min = -1.0;
max = 1.0;
} else {
let padding = max.abs() * 0.2;
min -= padding;
max += padding;
}
} else if !include_zero {
let padding = (max - min) * 0.05;
if declared_min.is_none() {
min = if min >= 0.0 {
(min - padding).max(0.0)
} else {
min - padding
};
}
if declared_max.is_none() {
max = if max <= 0.0 {
(max + padding).min(0.0)
} else {
max + padding
};
}
}
let step = nice_step(max - min);
Self {
min: tidy(tidy(min / step).floor() * step),
max: tidy(tidy(max / step).ceil() * step),
step,
log: false,
reversed: false,
}
}
pub(crate) fn map(self, value: f64, output_min: f64, output_max: f64) -> f64 {
let ratio = if self.log {
(value.log10() - self.min.log10()) / (self.max.log10() - self.min.log10())
} else {
(value - self.min) / (self.max - self.min)
};
let ratio = if self.reversed { 1.0 - ratio } else { ratio };
output_min + ratio * (output_max - output_min)
}
pub(crate) const fn ends(self) -> (f64, f64) {
(self.min, self.max)
}
fn first_tick(self) -> f64 {
tidy((self.min / self.step - 1e-9).ceil() * self.step)
}
pub(crate) fn ticks(self) -> Box<dyn Iterator<Item = f64>> {
if self.log {
let multiples: &[f64] = if self.max / self.min <= 100.0 * (1.0 + 1e-9) {
&[1.0, 2.0, 5.0]
} else {
&[1.0]
};
let top = self.max * (1.0 + 1e-9);
let ticks: Vec<f64> =
std::iter::successors(Some(self.min), |power| Some(tidy(power * 10.0)))
.take_while(|power| *power <= top)
.flat_map(|power| multiples.iter().map(move |multiple| tidy(power * multiple)))
.filter(|tick| *tick <= top)
.collect();
return Box::new(ticks.into_iter());
}
let first = self.first_tick();
Box::new(
std::iter::successors(Some(0.0_f64), |index| Some(index + 1.0))
.map(move |index| tidy(first + index * self.step))
.take_while(move |tick| *tick <= self.max + self.step * 1e-6)
.take(201),
)
}
}
#[cfg(test)]
mod tests {
use super::{NumericScale, format_tick, format_value};
use crate::spec::{Locale, NumberStyle, ValueFormat};
#[test]
fn scale_includes_zero_and_uses_nice_ticks() {
let scale = NumericScale::from_values([12.0, 18.0, 15.0].into_iter(), true, (None, None));
assert!(scale.min.abs() < f64::EPSILON);
assert!((scale.max - 20.0).abs() < f64::EPSILON);
assert_eq!(
scale.ticks().collect::<Vec<_>>(),
vec![0.0, 5.0, 10.0, 15.0, 20.0]
);
}
#[test]
fn a_logarithmic_axis_ends_at_the_next_one_two_or_five_not_at_the_next_decade() {
use crate::spec::ValueAxisSpec;
let axis = ValueAxisSpec {
scale: crate::spec::AxisScale::Log,
..ValueAxisSpec::default()
};
for (largest, end) in [
(13.75, 20.0),
(1.5, 2.0),
(4.0, 5.0),
(7.0, 10.0),
(20.0, 20.0),
(100.0, 100.0),
(101.0, 200.0),
] {
let scale = NumericScale::for_axis([0.14, largest].into_iter(), false, &axis);
assert!((scale.max - end).abs() < 1e-9, "{largest}: {}", scale.max);
assert!((scale.min - 0.1).abs() < 1e-12);
}
}
#[test]
fn a_margin_never_crosses_zero() {
let scale = NumericScale::from_values([0.0, 40.0, 100.0].into_iter(), false, (None, None));
assert!(scale.min.abs() < f64::EPSILON);
let scale = NumericScale::from_values([-100.0, -3.0].into_iter(), false, (None, None));
assert!(scale.max.abs() < f64::EPSILON);
let scale = NumericScale::from_values([-10.0, 90.0].into_iter(), false, (None, None));
assert!(scale.min < -10.0 && scale.max > 90.0);
}
#[test]
fn a_declared_range_widens_the_scale_without_a_margin() {
let scale =
NumericScale::from_values([12.0, 48.0].into_iter(), false, (Some(10.0), Some(60.0)));
assert!((scale.min - 10.0).abs() < f64::EPSILON);
assert!((scale.max - 60.0).abs() < f64::EPSILON);
let scale = NumericScale::from_values([12.0, 48.0].into_iter(), true, (None, Some(100.0)));
assert_eq!(
scale.ticks().collect::<Vec<_>>(),
vec![0.0, 20.0, 40.0, 60.0, 80.0, 100.0]
);
}
#[test]
fn formats_percent_without_duplicate_suffix() {
assert_eq!(format_value(0.125, ValueFormat::Percent), "12.5%");
assert_eq!(format_value(1.0, ValueFormat::Percent), "100%");
}
#[test]
fn removes_floating_point_noise_from_labels() {
assert_eq!(format_value(0.07, ValueFormat::Percent), "7%");
assert_eq!(format_value(0.1 + 0.2, ValueFormat::Number), "0.3");
assert_eq!(format_value(-0.0, ValueFormat::Number), "0");
}
#[test]
fn writes_german_numbers_with_comma_and_true_minus() {
let de = NumberStyle {
locale: Locale::De,
..NumberStyle::default()
};
assert_eq!(format_value(-1.25, de), "\u{2212}1,25");
assert_eq!(format_value(-1.25, ValueFormat::Number), "\u{2212}1.25");
let fixed = NumberStyle {
decimals: Some(2),
..de
};
assert_eq!(format_value(1.547, fixed), "1,55");
assert_eq!(format_value(-0.001, fixed), "0,00");
let percent = NumberStyle {
format: ValueFormat::Percent,
..de
};
assert_eq!(format_value(0.125, percent), "12,5\u{202f}%");
}
#[test]
fn separates_thousands_only_when_asked() {
let en = NumberStyle {
thousands: true,
..NumberStyle::default()
};
assert_eq!(format_value(1_234_567.5, en), "1,234,567.5");
assert_eq!(format_value(-12_500.0, en), "\u{2212}12,500");
assert_eq!(format_value(999.0, en), "999");
assert_eq!(format_value(2024.0, NumberStyle::default()), "2024");
let de = NumberStyle {
locale: Locale::De,
..en
};
assert_eq!(format_value(12_500.25, de), "12.500,25");
let percent = NumberStyle {
format: ValueFormat::Percent,
..de
};
assert_eq!(format_value(12.5, percent), "1.250\u{202f}%");
}
#[test]
fn ticks_share_the_decimals_of_their_step() {
let style = NumberStyle::default();
assert_eq!(format_tick(1.0, 0.5, 0.0, style), "1.0");
assert_eq!(format_tick(-0.5, 0.5, 0.0, style), "\u{2212}0.5");
assert_eq!(format_tick(20.0, 5.0, 0.0, style), "20");
let percent = NumberStyle::from(ValueFormat::Percent);
assert_eq!(format_tick(0.1, 0.05, 0.0, percent), "10%");
assert_eq!(format_tick(0.1, 0.025, 0.0, percent), "10.0%");
}
#[test]
fn ticks_do_not_accumulate_rounding_errors() {
let scale = NumericScale::from_values([0.12, -0.04, 0.15].into_iter(), true, (None, None));
assert_eq!(
scale.ticks().collect::<Vec<_>>(),
vec![-0.05, 0.0, 0.05, 0.1, 0.15]
);
let scale = NumericScale::from_values([0.07, -0.005, 0.3].into_iter(), true, (None, None));
assert_eq!(
scale
.ticks()
.map(|tick| format_value(tick, ValueFormat::Percent))
.collect::<Vec<_>>(),
vec!["−10%", "0%", "10%", "20%", "30%"]
);
}
}