1use crate::theme::{TextTransform, Theme};
2
3#[derive(Debug, Clone, PartialEq)]
15pub struct TextMetrics {
16 pub font_size_px: f64,
18 pub letter_spacing_px: f64,
20 pub text_transform: TextTransform,
22 pub monospace: bool,
26}
27
28impl Default for TextMetrics {
29 fn default() -> Self {
34 Self {
35 font_size_px: 12.0,
36 letter_spacing_px: 0.0,
37 text_transform: TextTransform::None,
38 monospace: false,
39 }
40 }
41}
42
43impl TextMetrics {
44 #[inline]
48 pub fn is_legacy_default(&self) -> bool {
49 self.font_size_px == 12.0
52 && self.letter_spacing_px == 0.0
53 && !self.monospace
54 && matches!(self.text_transform, TextTransform::None)
55 }
56
57 pub fn from_theme_tick_value(theme: &Theme) -> Self {
64 let default = Theme::default();
65 let size = theme.numeric_font_size as f64;
66 let letter_spacing = theme.label_letter_spacing as f64;
67 let transform = theme.label_text_transform.clone();
68 let family_changed = theme.numeric_font_family != default.numeric_font_family;
69 let monospace = family_changed && family_is_monospace(&theme.numeric_font_family);
70 if (size - default.numeric_font_size as f64).abs() < f64::EPSILON
71 && letter_spacing == 0.0
72 && matches!(transform, TextTransform::None)
73 && !family_changed
74 {
75 return Self::default();
76 }
77 Self {
78 font_size_px: size,
79 letter_spacing_px: letter_spacing,
80 text_transform: transform,
81 monospace,
82 }
83 }
84
85 pub fn from_theme_axis_label(theme: &Theme) -> Self {
87 let default = Theme::default();
88 let size = theme.label_font_size as f64;
89 let letter_spacing = theme.label_letter_spacing as f64;
90 let transform = theme.label_text_transform.clone();
91 let family_changed = theme.label_font_family != default.label_font_family;
92 let monospace = family_changed && family_is_monospace(&theme.label_font_family);
93 if (size - default.label_font_size as f64).abs() < f64::EPSILON
94 && letter_spacing == 0.0
95 && matches!(transform, TextTransform::None)
96 && !family_changed
97 {
98 return Self::default();
99 }
100 Self {
101 font_size_px: size,
102 letter_spacing_px: letter_spacing,
103 text_transform: transform,
104 monospace,
105 }
106 }
107
108 pub fn from_theme_legend(theme: &Theme) -> Self {
110 let default = Theme::default();
111 let size = theme.legend_font_size as f64;
112 let letter_spacing = theme.label_letter_spacing as f64;
113 let transform = theme.label_text_transform.clone();
114 let family_changed = theme.legend_font_family != default.legend_font_family;
115 let monospace = family_changed && family_is_monospace(&theme.legend_font_family);
116 if (size - default.legend_font_size as f64).abs() < f64::EPSILON
117 && letter_spacing == 0.0
118 && matches!(transform, TextTransform::None)
119 && !family_changed
120 {
121 return Self::default();
122 }
123 Self {
124 font_size_px: size,
125 letter_spacing_px: letter_spacing,
126 text_transform: transform,
127 monospace,
128 }
129 }
130
131 pub fn from_theme_title(theme: &Theme) -> Self {
133 let default = Theme::default();
134 let size = theme.title_font_size as f64;
135 let family_changed = theme.title_font_family != default.title_font_family;
136 let monospace = family_changed && family_is_monospace(&theme.title_font_family);
137 if (size - default.title_font_size as f64).abs() < f64::EPSILON && !family_changed {
138 return Self::default();
139 }
140 Self {
141 font_size_px: size,
142 letter_spacing_px: 0.0,
143 text_transform: TextTransform::None,
144 monospace,
145 }
146 }
147}
148
149fn family_is_monospace(family: &str) -> bool {
156 let s = family.to_ascii_lowercase();
157 s.contains("monospace")
158 || s.contains(" mono")
159 || s.contains(",mono")
160 || s.ends_with(" mono")
161 || s.starts_with("mono")
162 || s.contains("ui-monospace")
163 || s.contains("menlo")
164 || s.contains("consolas")
165 || s.contains("courier")
166 || s.contains("sf mono")
167 || s.contains("jetbrains mono")
168 || s.contains("fira code")
169 || s.contains("fira mono")
170 || s.contains("source code pro")
171}
172
173fn apply_transform<'a>(text: &'a str, transform: &TextTransform) -> std::borrow::Cow<'a, str> {
176 match transform {
177 TextTransform::None => std::borrow::Cow::Borrowed(text),
178 TextTransform::Uppercase => std::borrow::Cow::Owned(text.to_uppercase()),
179 TextTransform::Lowercase => std::borrow::Cow::Owned(text.to_lowercase()),
180 }
181}
182
183pub fn measure_text(text: &str, metrics: &TextMetrics) -> f64 {
201 if metrics.is_legacy_default() {
202 return approximate_text_width(text);
203 }
204
205 let transformed = apply_transform(text, &metrics.text_transform);
206
207 let base = if metrics.monospace {
209 transformed.chars().count() as f64 * 7.7
214 } else {
215 transformed.chars().map(char_width).sum::<f64>()
216 };
217
218 let size_ratio = metrics.font_size_px / 12.0;
220 let mut width = base * size_ratio;
221
222 if matches!(metrics.text_transform, TextTransform::Uppercase) {
227 width *= 1.10;
228 }
229
230 let char_count = transformed.chars().count() as f64;
233 if metrics.letter_spacing_px != 0.0 && char_count > 0.0 {
234 width += char_count * metrics.letter_spacing_px;
235 }
236
237 width
238}
239
240#[derive(Debug, Clone, PartialEq)]
242pub enum LabelStrategy {
243 Horizontal,
245 Rotated { margin: f64, skip_factor: Option<usize> },
248 Truncated { max_width: f64 },
250 Sampled { indices: Vec<usize> },
252}
253
254pub struct LabelStrategyConfig {
256 pub min_label_spacing: f64, pub max_label_width: f64, pub max_rotation_margin: f64, pub rotation_angle_deg: f64, pub text_metrics: TextMetrics,
264}
265
266impl Default for LabelStrategyConfig {
267 fn default() -> Self {
268 Self {
269 min_label_spacing: 4.0,
270 max_label_width: 120.0,
271 max_rotation_margin: 150.0,
272 rotation_angle_deg: 45.0,
273 text_metrics: TextMetrics::default(),
274 }
275 }
276}
277
278impl LabelStrategy {
279 pub fn determine(
293 labels: &[String],
294 available_width: f64,
295 config: &LabelStrategyConfig,
296 ) -> Self {
297 let label_count = labels.len();
298 if label_count == 0 {
299 return LabelStrategy::Horizontal;
300 }
301
302 let available_per_label = available_width / label_count as f64;
303
304 let widths: Vec<f64> = labels.iter().map(|l| measure_text(l, &config.text_metrics)).collect();
307 let avg_width = widths.iter().sum::<f64>() / widths.len() as f64;
308 let max_width = widths.iter().cloned().fold(0.0_f64, f64::max);
309
310 if avg_width + config.min_label_spacing <= available_per_label {
312 return LabelStrategy::Horizontal;
313 }
314
315 if label_count <= 40 {
319 let angle_rad = config.rotation_angle_deg.to_radians();
320 let skip_factor = compute_skip_factor(labels, available_width, config.rotation_angle_deg, &config.text_metrics);
321
322 let visible_max_width = match skip_factor {
326 Some(f) if f > 1 => widths.iter().enumerate()
327 .filter(|(i, _)| i % f == 0)
328 .map(|(_, w)| *w)
329 .fold(0.0_f64, f64::max),
330 _ => max_width,
331 };
332 let required_vertical = visible_max_width * angle_rad.sin();
333 let total_needed = 10.0 + required_vertical + 15.0;
338 let base_bottom = 40.0;
339 let margin = (total_needed - base_bottom).max(0.0).ceil().min(config.max_rotation_margin);
340 return LabelStrategy::Rotated { margin, skip_factor };
341 }
342
343 if config.max_label_width + config.min_label_spacing <= available_per_label && label_count <= 50 {
345 return LabelStrategy::Truncated { max_width: config.max_label_width };
346 }
347
348 let target_count = ((available_width / 120.0).floor() as usize).max(5);
350 let indices = strategic_indices(label_count, target_count);
351 LabelStrategy::Sampled { indices }
352 }
353}
354
355fn char_width(ch: char) -> f64 {
357 match ch {
358 'M' | 'W' | 'm' | 'w' => 9.0,
359 'i' | 'l' | 'j' | '!' | '|' | '.' | ',' | ':' | ';' | '\'' => 4.0,
360 'f' | 'r' | 't' => 5.0,
361 ' ' => 4.0,
362 _ => 7.0,
363 }
364}
365
366pub fn approximate_text_width(text: &str) -> f64 {
369 text.chars().map(char_width).sum()
370}
371
372pub fn approximate_text_width_at(text: &str, font_size_px: f64) -> f64 {
374 approximate_text_width(text) * (font_size_px / 12.0)
375}
376
377pub fn format_tick_value_si(value: f64, tick_step: f64) -> String {
380 let (scaled, suffix) = if tick_step >= 1_000_000_000.0 {
381 (value / 1_000_000_000.0, "B")
382 } else if tick_step >= 1_000_000.0 {
383 (value / 1_000_000.0, "M")
384 } else if tick_step >= 1_000.0 {
385 (value / 1_000.0, "K")
386 } else {
387 let precision = if tick_step.abs() < 1e-15 {
389 0usize
390 } else {
391 ((-tick_step.abs().log10().floor()) as i64).max(0) as usize
392 };
393 return format!("{:.prec$}", value, prec = precision);
394 };
395
396 if (scaled - scaled.round()).abs() < 1e-9 {
398 format!("{}{}", scaled.round() as i64, suffix)
399 } else {
400 format!("{:.1}{}", scaled, suffix)
401 }
402}
403
404#[cfg(test)]
405mod si_tests {
406 #![allow(clippy::unwrap_used)]
407 use super::format_tick_value_si;
408
409 #[test]
410 fn si_millions() {
411 assert_eq!(format_tick_value_si(1_000_000.0, 1_000_000.0), "1M");
412 assert_eq!(format_tick_value_si(7_200_000.0, 1_000_000.0), "7.2M");
413 assert_eq!(format_tick_value_si(0.0, 1_000_000.0), "0M");
414 }
415
416 #[test]
417 fn si_thousands() {
418 assert_eq!(format_tick_value_si(1_000.0, 1_000.0), "1K");
419 assert_eq!(format_tick_value_si(200_000.0, 100_000.0), "200K");
420 assert_eq!(format_tick_value_si(1_500.0, 1_000.0), "1.5K");
421 }
422
423 #[test]
424 fn si_billions() {
425 assert_eq!(format_tick_value_si(2_000_000_000.0, 1_000_000_000.0), "2B");
426 }
427
428 #[test]
429 fn no_si_small_values() {
430 assert_eq!(format_tick_value_si(42.0, 10.0), "42");
431 assert_eq!(format_tick_value_si(3.5, 0.5), "3.5");
432 }
433
434 #[test]
435 fn zero_tick_step() {
436 assert_eq!(format_tick_value_si(5.0, 0.0), "5");
438 }
439
440 #[test]
441 fn negative_values() {
442 assert_eq!(format_tick_value_si(-2_000_000.0, 1_000_000.0), "-2M");
443 }
444}
445
446pub fn compute_skip_factor(
456 labels: &[String],
457 available_width: f64,
458 rotation_angle_deg: f64,
459 metrics: &TextMetrics,
460) -> Option<usize> {
461 if labels.len() <= 8 {
462 return None;
463 }
464 let label_count = labels.len();
465 let available_per_label = available_width / label_count as f64;
466 let cos_angle = rotation_angle_deg.to_radians().cos();
467
468 let widths: Vec<f64> = labels.iter().map(|l| measure_text(l, metrics)).collect();
471 let avg_width = widths.iter().sum::<f64>() / widths.len() as f64;
472 let avg_rotated = avg_width * cos_angle;
473
474 let min_gap = 2.0;
479 if avg_rotated + min_gap > available_per_label {
480 let max_unrotated = (available_per_label - min_gap).max(0.0) / cos_angle;
481 let min_readable_width = 30.0; if max_unrotated < min_readable_width {
483 let needed_per = min_readable_width * cos_angle + min_gap;
484 let skip = (needed_per / available_per_label).ceil() as usize;
485 return Some(skip.max(2));
486 }
487 }
490
491 if label_count > 14 {
495 return Some(2);
496 }
497
498 None
499}
500
501pub fn strategic_indices(total: usize, target: usize) -> Vec<usize> {
504 if total == 0 {
505 return vec![];
506 }
507 if target >= total {
508 return (0..total).collect();
509 }
510 if target <= 1 {
511 return if total == 1 { vec![0] } else { vec![0, total - 1] };
512 }
513 if target == 2 {
514 return vec![0, total - 1];
515 }
516
517 let mut indices = Vec::with_capacity(target);
518 let step = (total - 1) as f64 / (target - 1) as f64;
519 for i in 0..target {
520 let idx = (i as f64 * step).round() as usize;
521 indices.push(idx.min(total - 1));
522 }
523 indices.dedup();
525 indices
526}
527
528pub fn truncate_label(label: &str, max_width: f64) -> String {
533 truncate_label_with_metrics(label, max_width, &TextMetrics::default())
534}
535
536pub fn truncate_label_with_metrics(label: &str, max_width: f64, metrics: &TextMetrics) -> String {
539 let full_width = measure_text(label, metrics);
540 if full_width <= max_width {
541 return label.to_string();
542 }
543
544 let ellipsis_width = measure_text("\u{2026}", metrics);
545 let target_width = max_width - ellipsis_width;
546 if target_width <= 0.0 {
547 return "\u{2026}".to_string();
548 }
549
550 let chars: Vec<(usize, char)> = label.char_indices().collect();
553 let mut end_chars = chars.len();
554 while end_chars > 0 {
555 let end_byte = chars[end_chars - 1].0 + chars[end_chars - 1].1.len_utf8();
556 let slice = &label[..end_byte];
557 if measure_text(slice, metrics) <= target_width {
558 return format!("{}\u{2026}", slice);
559 }
560 end_chars -= 1;
561 }
562 "\u{2026}".to_string()
563}
564
565#[cfg(test)]
566mod tests {
567 #![allow(clippy::unwrap_used)]
568 use super::*;
569
570 #[test]
571 fn strategy_horizontal_when_fits() {
572 let labels: Vec<String> = vec!["A".into(), "B".into(), "C".into()];
573 let strategy = LabelStrategy::determine(&labels, 800.0, &LabelStrategyConfig::default());
574 assert_eq!(strategy, LabelStrategy::Horizontal);
575 }
576
577 #[test]
578 fn strategy_rotated_when_moderate() {
579 let labels: Vec<String> = (0..10)
582 .map(|i| format!("Category {}", i))
583 .collect();
584 let strategy = LabelStrategy::determine(&labels, 200.0, &LabelStrategyConfig::default());
585 assert!(matches!(strategy, LabelStrategy::Rotated { .. }),
586 "Expected Rotated, got {:?}", strategy);
587 }
588
589 #[test]
590 fn strategy_rotated_when_dense_axis() {
591 let labels: Vec<String> = (0..20)
593 .map(|i| format!("Category {}", i))
594 .collect();
595 let strategy = LabelStrategy::determine(&labels, 200.0, &LabelStrategyConfig::default());
596 assert!(matches!(strategy, LabelStrategy::Rotated { .. }),
597 "Expected Rotated, got {:?}", strategy);
598 }
599
600 #[test]
601 fn strategy_rotated_for_monthly_labels() {
602 let labels: Vec<String> = (0..18)
604 .map(|i| format!("Jan {:02}", i + 1))
605 .collect();
606 let strategy = LabelStrategy::determine(&labels, 560.0, &LabelStrategyConfig::default());
607 assert!(matches!(strategy, LabelStrategy::Rotated { .. }),
608 "Expected Rotated, got {:?}", strategy);
609 }
610
611 #[test]
612 fn strategy_sampled_when_many() {
613 let labels: Vec<String> = (0..100)
614 .map(|i| format!("Long Category Name {}", i))
615 .collect();
616 let strategy = LabelStrategy::determine(&labels, 400.0, &LabelStrategyConfig::default());
617 assert!(matches!(strategy, LabelStrategy::Sampled { .. }),
618 "Expected Sampled, got {:?}", strategy);
619 }
620
621 #[test]
622 fn rotated_margin_covers_full_label_descent() {
623 let labels: Vec<String> = vec![
627 "UNKNOWN".into(), "GOOGLE".into(), "DIRECT".into(), "LINKEDIN".into(),
628 "PRODUCTHUNT".into(), "TWITTER".into(), "OTHER".into(),
629 ];
630 let config = LabelStrategyConfig::default();
631 let strategy = LabelStrategy::determine(&labels, 300.0, &config);
632 let LabelStrategy::Rotated { margin, skip_factor } = strategy else {
633 panic!("Expected Rotated, got {:?}", strategy);
634 };
635 let visible_max = labels.iter().enumerate()
636 .filter(|(i, _)| skip_factor.is_none_or(|f| i % f == 0))
637 .map(|(_, l)| measure_text(l, &config.text_metrics))
638 .fold(0.0_f64, f64::max);
639 let descent = visible_max * config.rotation_angle_deg.to_radians().sin();
640 let needed = (10.0 + descent + 15.0 - 40.0).max(0.0);
643 assert!(margin + 0.5 >= needed,
644 "Rotated margin {margin} does not cover full label descent (needs {needed})");
645 }
646
647 #[test]
648 fn strategy_empty_labels() {
649 let labels: Vec<String> = vec![];
650 let strategy = LabelStrategy::determine(&labels, 800.0, &LabelStrategyConfig::default());
651 assert_eq!(strategy, LabelStrategy::Horizontal);
652 }
653
654 #[test]
655 fn strategic_indices_basic() {
656 let indices = strategic_indices(10, 5);
657 assert!(indices.contains(&0), "Should include first index");
658 assert!(indices.contains(&9), "Should include last index");
659 assert!(indices.len() <= 5, "Should have at most 5 indices");
660 }
661
662 #[test]
663 fn strategic_indices_all() {
664 let indices = strategic_indices(5, 10);
665 assert_eq!(indices, vec![0, 1, 2, 3, 4]);
666 }
667
668 #[test]
669 fn truncate_short_label() {
670 let result = truncate_label("Hi", 100.0);
671 assert_eq!(result, "Hi");
672 }
673
674 #[test]
675 fn truncate_long_label() {
676 let result = truncate_label("This is a very long label that should be truncated", 50.0);
677 assert!(result.ends_with('\u{2026}'), "Should end with ellipsis, got '{}'", result);
678 assert!(result.len() < "This is a very long label that should be truncated".len(),
679 "Should be shorter than original");
680 }
681
682 #[test]
683 fn approximate_text_width_basic() {
684 let width = approximate_text_width("Hello");
685 assert!(width > 0.0, "Width should be non-zero for non-empty string");
686 }
687
688 #[test]
691 fn measure_text_default_matches_legacy() {
692 let samples = ["", "A", "Hello, world!", "1,234,567", "Category 42", "\u{2026}"];
695 for s in samples {
696 let legacy = approximate_text_width(s);
697 let measured = measure_text(s, &TextMetrics::default());
698 assert!((legacy - measured).abs() < f64::EPSILON,
699 "measure_text default must equal approximate_text_width for {s:?} (legacy={legacy}, measured={measured})");
700 }
701 }
702
703 #[test]
704 fn measure_text_font_size_scales_linearly() {
705 let base = measure_text("Hello", &TextMetrics::default());
706 let m = TextMetrics { font_size_px: 24.0, ..TextMetrics::default() };
707 let big = measure_text("Hello", &m);
709 assert!((big - base * 2.0).abs() < 1e-9);
710 }
711
712 #[test]
713 fn measure_text_uppercase_is_wider() {
714 let text = "HELLO";
718 let none = measure_text(text, &TextMetrics::default());
719 let m = TextMetrics {
720 text_transform: crate::theme::TextTransform::Uppercase,
721 ..TextMetrics::default()
722 };
723 let upper = measure_text(text, &m);
724 assert!(upper > none,
725 "uppercase measurement must exceed default (upper={upper}, none={none})");
726 }
727
728 #[test]
729 fn measure_text_letter_spacing_adds_space() {
730 let m = TextMetrics { letter_spacing_px: 2.0, ..TextMetrics::default() };
731 let base = approximate_text_width("Hello");
732 let spaced = measure_text("Hello", &m);
733 let expected = base + 5.0 * 2.0;
734 assert!((spaced - expected).abs() < 1e-9,
735 "letter_spacing should add char_count * spacing (expected={expected}, got={spaced})");
736 }
737
738 #[test]
739 fn measure_text_monospace_is_wider_than_sans() {
740 let sans = measure_text("1,234,567", &TextMetrics::default());
741 let m = TextMetrics { monospace: true, font_size_px: 12.0, ..TextMetrics::default() };
742 let mono = measure_text("1,234,567", &m);
743 assert!(mono > sans,
744 "monospace should measure wider than the sans calibration (sans={sans}, mono={mono})");
745 }
746
747 #[test]
748 fn theme_tick_metrics_default_is_legacy() {
749 use crate::theme::Theme;
750 let t = Theme::default();
751 let m = TextMetrics::from_theme_tick_value(&t);
752 assert!(m.is_legacy_default(),
753 "Theme::default() must produce legacy-default tick metrics for byte-identity");
754 }
755
756 #[test]
757 fn theme_axis_label_metrics_default_is_legacy() {
758 use crate::theme::Theme;
759 assert!(TextMetrics::from_theme_axis_label(&Theme::default()).is_legacy_default());
760 }
761
762 #[test]
763 fn theme_legend_metrics_default_is_legacy() {
764 use crate::theme::Theme;
765 assert!(TextMetrics::from_theme_legend(&Theme::default()).is_legacy_default());
766 }
767
768 #[test]
769 fn theme_title_metrics_default_is_legacy() {
770 use crate::theme::Theme;
771 assert!(TextMetrics::from_theme_title(&Theme::default()).is_legacy_default());
772 }
773
774 #[test]
775 fn theme_tick_metrics_picks_up_override() {
776 use crate::theme::{Theme, TextTransform};
777 let t = Theme {
778 numeric_font_size: 11.0,
779 numeric_font_family: "Geist Mono, monospace".into(),
780 label_letter_spacing: 1.2,
781 label_text_transform: TextTransform::Uppercase,
782 ..Theme::default()
783 };
784 let m = TextMetrics::from_theme_tick_value(&t);
785 assert!(!m.is_legacy_default());
786 assert!((m.font_size_px - 11.0).abs() < 1e-6);
787 assert!((m.letter_spacing_px - 1.2).abs() < 1e-6);
788 assert!(m.monospace);
789 assert!(matches!(m.text_transform, TextTransform::Uppercase));
790 }
791
792 #[test]
793 fn family_is_monospace_recognises_common_stacks() {
794 assert!(family_is_monospace("Geist Mono, monospace"));
795 assert!(family_is_monospace("'JetBrains Mono', monospace"));
796 assert!(family_is_monospace("ui-monospace, Menlo, monospace"));
797 assert!(family_is_monospace("Consolas, Courier New, monospace"));
798 assert!(!family_is_monospace("system-ui, sans-serif"));
799 assert!(!family_is_monospace("Inter, Liberation Sans, Arial, sans-serif"));
800 }
801}