use crate::theme::{TextTransform, Theme};
#[derive(Debug, Clone, PartialEq)]
pub struct TextMetrics {
pub font_size_px: f64,
pub letter_spacing_px: f64,
pub text_transform: TextTransform,
pub monospace: bool,
}
impl Default for TextMetrics {
fn default() -> Self {
Self {
font_size_px: 12.0,
letter_spacing_px: 0.0,
text_transform: TextTransform::None,
monospace: false,
}
}
}
impl TextMetrics {
#[inline]
pub fn is_legacy_default(&self) -> bool {
self.font_size_px == 12.0
&& self.letter_spacing_px == 0.0
&& !self.monospace
&& matches!(self.text_transform, TextTransform::None)
}
pub fn from_theme_tick_value(theme: &Theme) -> Self {
let default = Theme::default();
let size = theme.numeric_font_size as f64;
let letter_spacing = theme.label_letter_spacing as f64;
let transform = theme.label_text_transform.clone();
let family_changed = theme.numeric_font_family != default.numeric_font_family;
let monospace = family_changed && family_is_monospace(&theme.numeric_font_family);
if (size - default.numeric_font_size as f64).abs() < f64::EPSILON
&& letter_spacing == 0.0
&& matches!(transform, TextTransform::None)
&& !family_changed
{
return Self::default();
}
Self {
font_size_px: size,
letter_spacing_px: letter_spacing,
text_transform: transform,
monospace,
}
}
pub fn from_theme_axis_label(theme: &Theme) -> Self {
let default = Theme::default();
let size = theme.label_font_size as f64;
let letter_spacing = theme.label_letter_spacing as f64;
let transform = theme.label_text_transform.clone();
let family_changed = theme.label_font_family != default.label_font_family;
let monospace = family_changed && family_is_monospace(&theme.label_font_family);
if (size - default.label_font_size as f64).abs() < f64::EPSILON
&& letter_spacing == 0.0
&& matches!(transform, TextTransform::None)
&& !family_changed
{
return Self::default();
}
Self {
font_size_px: size,
letter_spacing_px: letter_spacing,
text_transform: transform,
monospace,
}
}
pub fn from_theme_legend(theme: &Theme) -> Self {
let default = Theme::default();
let size = theme.legend_font_size as f64;
let letter_spacing = theme.label_letter_spacing as f64;
let transform = theme.label_text_transform.clone();
let family_changed = theme.legend_font_family != default.legend_font_family;
let monospace = family_changed && family_is_monospace(&theme.legend_font_family);
if (size - default.legend_font_size as f64).abs() < f64::EPSILON
&& letter_spacing == 0.0
&& matches!(transform, TextTransform::None)
&& !family_changed
{
return Self::default();
}
Self {
font_size_px: size,
letter_spacing_px: letter_spacing,
text_transform: transform,
monospace,
}
}
pub fn from_theme_title(theme: &Theme) -> Self {
let default = Theme::default();
let size = theme.title_font_size as f64;
let family_changed = theme.title_font_family != default.title_font_family;
let monospace = family_changed && family_is_monospace(&theme.title_font_family);
if (size - default.title_font_size as f64).abs() < f64::EPSILON && !family_changed {
return Self::default();
}
Self {
font_size_px: size,
letter_spacing_px: 0.0,
text_transform: TextTransform::None,
monospace,
}
}
}
fn family_is_monospace(family: &str) -> bool {
let s = family.to_ascii_lowercase();
s.contains("monospace")
|| s.contains(" mono")
|| s.contains(",mono")
|| s.ends_with(" mono")
|| s.starts_with("mono")
|| s.contains("ui-monospace")
|| s.contains("menlo")
|| s.contains("consolas")
|| s.contains("courier")
|| s.contains("sf mono")
|| s.contains("jetbrains mono")
|| s.contains("fira code")
|| s.contains("fira mono")
|| s.contains("source code pro")
}
fn apply_transform<'a>(text: &'a str, transform: &TextTransform) -> std::borrow::Cow<'a, str> {
match transform {
TextTransform::None => std::borrow::Cow::Borrowed(text),
TextTransform::Uppercase => std::borrow::Cow::Owned(text.to_uppercase()),
TextTransform::Lowercase => std::borrow::Cow::Owned(text.to_lowercase()),
}
}
pub fn measure_text(text: &str, metrics: &TextMetrics) -> f64 {
if metrics.is_legacy_default() {
return approximate_text_width(text);
}
let transformed = apply_transform(text, &metrics.text_transform);
let base = if metrics.monospace {
transformed.chars().count() as f64 * 7.7
} else {
transformed.chars().map(char_width).sum::<f64>()
};
let size_ratio = metrics.font_size_px / 12.0;
let mut width = base * size_ratio;
if matches!(metrics.text_transform, TextTransform::Uppercase) {
width *= 1.10;
}
let char_count = transformed.chars().count() as f64;
if metrics.letter_spacing_px != 0.0 && char_count > 0.0 {
width += char_count * metrics.letter_spacing_px;
}
width
}
#[derive(Debug, Clone, PartialEq)]
pub enum LabelStrategy {
Horizontal,
Rotated { margin: f64, skip_factor: Option<usize> },
Truncated { max_width: f64 },
Sampled { indices: Vec<usize> },
}
pub struct LabelStrategyConfig {
pub min_label_spacing: f64, pub max_label_width: f64, pub max_rotation_margin: f64, pub rotation_angle_deg: f64, pub text_metrics: TextMetrics,
}
impl Default for LabelStrategyConfig {
fn default() -> Self {
Self {
min_label_spacing: 4.0,
max_label_width: 120.0,
max_rotation_margin: 150.0,
rotation_angle_deg: 45.0,
text_metrics: TextMetrics::default(),
}
}
}
impl LabelStrategy {
pub fn determine(
labels: &[String],
available_width: f64,
config: &LabelStrategyConfig,
) -> Self {
let label_count = labels.len();
if label_count == 0 {
return LabelStrategy::Horizontal;
}
let available_per_label = available_width / label_count as f64;
let widths: Vec<f64> = labels.iter().map(|l| measure_text(l, &config.text_metrics)).collect();
let avg_width = widths.iter().sum::<f64>() / widths.len() as f64;
let max_width = widths.iter().cloned().fold(0.0_f64, f64::max);
if avg_width + config.min_label_spacing <= available_per_label {
return LabelStrategy::Horizontal;
}
if label_count <= 40 {
let angle_rad = config.rotation_angle_deg.to_radians();
let skip_factor = compute_skip_factor(labels, available_width, config.rotation_angle_deg, &config.text_metrics);
let visible_count = match skip_factor {
Some(f) if f > 1 => (0..label_count).filter(|i| i % f == 0).count(),
_ => label_count,
};
let cos_a = angle_rad.cos(); let available_per_visible = if visible_count > 0 {
available_width / visible_count as f64
} else {
available_width
};
let spacing = 6.0;
let overlap_width = (available_per_visible - spacing) / cos_a;
let effective_width = if overlap_width > 0.0 {
max_width.min(overlap_width)
} else {
max_width
};
let required_vertical = effective_width * angle_rad.sin();
let total_needed = 10.0 + required_vertical + 15.0;
let base_bottom = 40.0;
let margin = (total_needed - base_bottom).max(0.0).ceil().min(config.max_rotation_margin);
return LabelStrategy::Rotated { margin, skip_factor };
}
if config.max_label_width + config.min_label_spacing <= available_per_label && label_count <= 50 {
return LabelStrategy::Truncated { max_width: config.max_label_width };
}
let target_count = ((available_width / 120.0).floor() as usize).max(5);
let indices = strategic_indices(label_count, target_count);
LabelStrategy::Sampled { indices }
}
}
fn char_width(ch: char) -> f64 {
match ch {
'M' | 'W' | 'm' | 'w' => 9.0,
'i' | 'l' | 'j' | '!' | '|' | '.' | ',' | ':' | ';' | '\'' => 4.0,
'f' | 'r' | 't' => 5.0,
' ' => 4.0,
_ => 7.0,
}
}
pub fn approximate_text_width(text: &str) -> f64 {
text.chars().map(char_width).sum()
}
pub fn approximate_text_width_at(text: &str, font_size_px: f64) -> f64 {
approximate_text_width(text) * (font_size_px / 12.0)
}
pub fn format_tick_value_si(value: f64, tick_step: f64) -> String {
let (scaled, suffix) = if tick_step >= 1_000_000_000.0 {
(value / 1_000_000_000.0, "B")
} else if tick_step >= 1_000_000.0 {
(value / 1_000_000.0, "M")
} else if tick_step >= 1_000.0 {
(value / 1_000.0, "K")
} else {
let precision = if tick_step.abs() < 1e-15 {
0usize
} else {
((-tick_step.abs().log10().floor()) as i64).max(0) as usize
};
return format!("{:.prec$}", value, prec = precision);
};
if (scaled - scaled.round()).abs() < 1e-9 {
format!("{}{}", scaled.round() as i64, suffix)
} else {
format!("{:.1}{}", scaled, suffix)
}
}
#[cfg(test)]
mod si_tests {
use super::format_tick_value_si;
#[test]
fn si_millions() {
assert_eq!(format_tick_value_si(1_000_000.0, 1_000_000.0), "1M");
assert_eq!(format_tick_value_si(7_200_000.0, 1_000_000.0), "7.2M");
assert_eq!(format_tick_value_si(0.0, 1_000_000.0), "0M");
}
#[test]
fn si_thousands() {
assert_eq!(format_tick_value_si(1_000.0, 1_000.0), "1K");
assert_eq!(format_tick_value_si(200_000.0, 100_000.0), "200K");
assert_eq!(format_tick_value_si(1_500.0, 1_000.0), "1.5K");
}
#[test]
fn si_billions() {
assert_eq!(format_tick_value_si(2_000_000_000.0, 1_000_000_000.0), "2B");
}
#[test]
fn no_si_small_values() {
assert_eq!(format_tick_value_si(42.0, 10.0), "42");
assert_eq!(format_tick_value_si(3.5, 0.5), "3.5");
}
#[test]
fn zero_tick_step() {
assert_eq!(format_tick_value_si(5.0, 0.0), "5");
}
#[test]
fn negative_values() {
assert_eq!(format_tick_value_si(-2_000_000.0, 1_000_000.0), "-2M");
}
}
pub fn compute_skip_factor(
labels: &[String],
available_width: f64,
rotation_angle_deg: f64,
metrics: &TextMetrics,
) -> Option<usize> {
if labels.len() <= 8 {
return None;
}
let label_count = labels.len();
let available_per_label = available_width / label_count as f64;
let cos_angle = rotation_angle_deg.to_radians().cos();
let widths: Vec<f64> = labels.iter().map(|l| measure_text(l, metrics)).collect();
let avg_width = widths.iter().sum::<f64>() / widths.len() as f64;
let avg_rotated = avg_width * cos_angle;
let min_gap = 2.0;
if avg_rotated + min_gap > available_per_label {
let max_unrotated = (available_per_label - min_gap).max(0.0) / cos_angle;
let min_readable_width = 30.0; if max_unrotated < min_readable_width {
let needed_per = min_readable_width * cos_angle + min_gap;
let skip = (needed_per / available_per_label).ceil() as usize;
return Some(skip.max(2));
}
}
if label_count > 14 {
return Some(2);
}
None
}
pub fn strategic_indices(total: usize, target: usize) -> Vec<usize> {
if total == 0 {
return vec![];
}
if target >= total {
return (0..total).collect();
}
if target <= 1 {
return if total == 1 { vec![0] } else { vec![0, total - 1] };
}
if target == 2 {
return vec![0, total - 1];
}
let mut indices = Vec::with_capacity(target);
let step = (total - 1) as f64 / (target - 1) as f64;
for i in 0..target {
let idx = (i as f64 * step).round() as usize;
indices.push(idx.min(total - 1));
}
indices.dedup();
indices
}
pub fn truncate_label(label: &str, max_width: f64) -> String {
truncate_label_with_metrics(label, max_width, &TextMetrics::default())
}
pub fn truncate_label_with_metrics(label: &str, max_width: f64, metrics: &TextMetrics) -> String {
let full_width = measure_text(label, metrics);
if full_width <= max_width {
return label.to_string();
}
let ellipsis_width = measure_text("\u{2026}", metrics);
let target_width = max_width - ellipsis_width;
if target_width <= 0.0 {
return "\u{2026}".to_string();
}
let chars: Vec<(usize, char)> = label.char_indices().collect();
let mut end_chars = chars.len();
while end_chars > 0 {
let end_byte = chars[end_chars - 1].0 + chars[end_chars - 1].1.len_utf8();
let slice = &label[..end_byte];
if measure_text(slice, metrics) <= target_width {
return format!("{}\u{2026}", slice);
}
end_chars -= 1;
}
"\u{2026}".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strategy_horizontal_when_fits() {
let labels: Vec<String> = vec!["A".into(), "B".into(), "C".into()];
let strategy = LabelStrategy::determine(&labels, 800.0, &LabelStrategyConfig::default());
assert_eq!(strategy, LabelStrategy::Horizontal);
}
#[test]
fn strategy_rotated_when_moderate() {
let labels: Vec<String> = (0..10)
.map(|i| format!("Category {}", i))
.collect();
let strategy = LabelStrategy::determine(&labels, 200.0, &LabelStrategyConfig::default());
assert!(matches!(strategy, LabelStrategy::Rotated { .. }),
"Expected Rotated, got {:?}", strategy);
}
#[test]
fn strategy_rotated_when_dense_axis() {
let labels: Vec<String> = (0..20)
.map(|i| format!("Category {}", i))
.collect();
let strategy = LabelStrategy::determine(&labels, 200.0, &LabelStrategyConfig::default());
assert!(matches!(strategy, LabelStrategy::Rotated { .. }),
"Expected Rotated, got {:?}", strategy);
}
#[test]
fn strategy_rotated_for_monthly_labels() {
let labels: Vec<String> = (0..18)
.map(|i| format!("Jan {:02}", i + 1))
.collect();
let strategy = LabelStrategy::determine(&labels, 560.0, &LabelStrategyConfig::default());
assert!(matches!(strategy, LabelStrategy::Rotated { .. }),
"Expected Rotated, got {:?}", strategy);
}
#[test]
fn strategy_sampled_when_many() {
let labels: Vec<String> = (0..100)
.map(|i| format!("Long Category Name {}", i))
.collect();
let strategy = LabelStrategy::determine(&labels, 400.0, &LabelStrategyConfig::default());
assert!(matches!(strategy, LabelStrategy::Sampled { .. }),
"Expected Sampled, got {:?}", strategy);
}
#[test]
fn strategy_empty_labels() {
let labels: Vec<String> = vec![];
let strategy = LabelStrategy::determine(&labels, 800.0, &LabelStrategyConfig::default());
assert_eq!(strategy, LabelStrategy::Horizontal);
}
#[test]
fn strategic_indices_basic() {
let indices = strategic_indices(10, 5);
assert!(indices.contains(&0), "Should include first index");
assert!(indices.contains(&9), "Should include last index");
assert!(indices.len() <= 5, "Should have at most 5 indices");
}
#[test]
fn strategic_indices_all() {
let indices = strategic_indices(5, 10);
assert_eq!(indices, vec![0, 1, 2, 3, 4]);
}
#[test]
fn truncate_short_label() {
let result = truncate_label("Hi", 100.0);
assert_eq!(result, "Hi");
}
#[test]
fn truncate_long_label() {
let result = truncate_label("This is a very long label that should be truncated", 50.0);
assert!(result.ends_with('\u{2026}'), "Should end with ellipsis, got '{}'", result);
assert!(result.len() < "This is a very long label that should be truncated".len(),
"Should be shorter than original");
}
#[test]
fn approximate_text_width_basic() {
let width = approximate_text_width("Hello");
assert!(width > 0.0, "Width should be non-zero for non-empty string");
}
#[test]
fn measure_text_default_matches_legacy() {
let samples = ["", "A", "Hello, world!", "1,234,567", "Category 42", "\u{2026}"];
for s in samples {
let legacy = approximate_text_width(s);
let measured = measure_text(s, &TextMetrics::default());
assert!((legacy - measured).abs() < f64::EPSILON,
"measure_text default must equal approximate_text_width for {s:?} (legacy={legacy}, measured={measured})");
}
}
#[test]
fn measure_text_font_size_scales_linearly() {
let base = measure_text("Hello", &TextMetrics::default());
let m = TextMetrics { font_size_px: 24.0, ..TextMetrics::default() };
let big = measure_text("Hello", &m);
assert!((big - base * 2.0).abs() < 1e-9);
}
#[test]
fn measure_text_uppercase_is_wider() {
let text = "HELLO";
let none = measure_text(text, &TextMetrics::default());
let m = TextMetrics {
text_transform: crate::theme::TextTransform::Uppercase,
..TextMetrics::default()
};
let upper = measure_text(text, &m);
assert!(upper > none,
"uppercase measurement must exceed default (upper={upper}, none={none})");
}
#[test]
fn measure_text_letter_spacing_adds_space() {
let m = TextMetrics { letter_spacing_px: 2.0, ..TextMetrics::default() };
let base = approximate_text_width("Hello");
let spaced = measure_text("Hello", &m);
let expected = base + 5.0 * 2.0;
assert!((spaced - expected).abs() < 1e-9,
"letter_spacing should add char_count * spacing (expected={expected}, got={spaced})");
}
#[test]
fn measure_text_monospace_is_wider_than_sans() {
let sans = measure_text("1,234,567", &TextMetrics::default());
let m = TextMetrics { monospace: true, font_size_px: 12.0, ..TextMetrics::default() };
let mono = measure_text("1,234,567", &m);
assert!(mono > sans,
"monospace should measure wider than the sans calibration (sans={sans}, mono={mono})");
}
#[test]
fn theme_tick_metrics_default_is_legacy() {
use crate::theme::Theme;
let t = Theme::default();
let m = TextMetrics::from_theme_tick_value(&t);
assert!(m.is_legacy_default(),
"Theme::default() must produce legacy-default tick metrics for byte-identity");
}
#[test]
fn theme_axis_label_metrics_default_is_legacy() {
use crate::theme::Theme;
assert!(TextMetrics::from_theme_axis_label(&Theme::default()).is_legacy_default());
}
#[test]
fn theme_legend_metrics_default_is_legacy() {
use crate::theme::Theme;
assert!(TextMetrics::from_theme_legend(&Theme::default()).is_legacy_default());
}
#[test]
fn theme_title_metrics_default_is_legacy() {
use crate::theme::Theme;
assert!(TextMetrics::from_theme_title(&Theme::default()).is_legacy_default());
}
#[test]
fn theme_tick_metrics_picks_up_override() {
use crate::theme::{Theme, TextTransform};
let t = Theme {
numeric_font_size: 11.0,
numeric_font_family: "Geist Mono, monospace".into(),
label_letter_spacing: 1.2,
label_text_transform: TextTransform::Uppercase,
..Theme::default()
};
let m = TextMetrics::from_theme_tick_value(&t);
assert!(!m.is_legacy_default());
assert!((m.font_size_px - 11.0).abs() < 1e-6);
assert!((m.letter_spacing_px - 1.2).abs() < 1e-6);
assert!(m.monospace);
assert!(matches!(m.text_transform, TextTransform::Uppercase));
}
#[test]
fn family_is_monospace_recognises_common_stacks() {
assert!(family_is_monospace("Geist Mono, monospace"));
assert!(family_is_monospace("'JetBrains Mono', monospace"));
assert!(family_is_monospace("ui-monospace, Menlo, monospace"));
assert!(family_is_monospace("Consolas, Courier New, monospace"));
assert!(!family_is_monospace("system-ui, sans-serif"));
assert!(!family_is_monospace("Inter, Liberation Sans, Arial, sans-serif"));
}
}