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_count = match skip_factor {
327 Some(f) if f > 1 => (0..label_count).filter(|i| i % f == 0).count(),
328 _ => label_count,
329 };
330 let cos_a = angle_rad.cos(); let available_per_visible = if visible_count > 0 {
332 available_width / visible_count as f64
333 } else {
334 available_width
335 };
336 let spacing = 6.0;
337 let overlap_width = (available_per_visible - spacing) / cos_a;
338
339 let effective_width = if overlap_width > 0.0 {
343 max_width.min(overlap_width)
344 } else {
345 max_width
346 };
347 let required_vertical = effective_width * angle_rad.sin();
348 let total_needed = 10.0 + required_vertical + 15.0;
353 let base_bottom = 40.0;
354 let margin = (total_needed - base_bottom).max(0.0).ceil().min(config.max_rotation_margin);
355 return LabelStrategy::Rotated { margin, skip_factor };
356 }
357
358 if config.max_label_width + config.min_label_spacing <= available_per_label && label_count <= 50 {
360 return LabelStrategy::Truncated { max_width: config.max_label_width };
361 }
362
363 let target_count = ((available_width / 120.0).floor() as usize).max(5);
365 let indices = strategic_indices(label_count, target_count);
366 LabelStrategy::Sampled { indices }
367 }
368}
369
370fn char_width(ch: char) -> f64 {
372 match ch {
373 'M' | 'W' | 'm' | 'w' => 9.0,
374 'i' | 'l' | 'j' | '!' | '|' | '.' | ',' | ':' | ';' | '\'' => 4.0,
375 'f' | 'r' | 't' => 5.0,
376 ' ' => 4.0,
377 _ => 7.0,
378 }
379}
380
381pub fn approximate_text_width(text: &str) -> f64 {
384 text.chars().map(char_width).sum()
385}
386
387pub fn approximate_text_width_at(text: &str, font_size_px: f64) -> f64 {
389 approximate_text_width(text) * (font_size_px / 12.0)
390}
391
392pub fn format_tick_value_si(value: f64, tick_step: f64) -> String {
395 let (scaled, suffix) = if tick_step >= 1_000_000_000.0 {
396 (value / 1_000_000_000.0, "B")
397 } else if tick_step >= 1_000_000.0 {
398 (value / 1_000_000.0, "M")
399 } else if tick_step >= 1_000.0 {
400 (value / 1_000.0, "K")
401 } else {
402 let precision = if tick_step.abs() < 1e-15 {
404 0usize
405 } else {
406 ((-tick_step.abs().log10().floor()) as i64).max(0) as usize
407 };
408 return format!("{:.prec$}", value, prec = precision);
409 };
410
411 if (scaled - scaled.round()).abs() < 1e-9 {
413 format!("{}{}", scaled.round() as i64, suffix)
414 } else {
415 format!("{:.1}{}", scaled, suffix)
416 }
417}
418
419#[cfg(test)]
420mod si_tests {
421 use super::format_tick_value_si;
422
423 #[test]
424 fn si_millions() {
425 assert_eq!(format_tick_value_si(1_000_000.0, 1_000_000.0), "1M");
426 assert_eq!(format_tick_value_si(7_200_000.0, 1_000_000.0), "7.2M");
427 assert_eq!(format_tick_value_si(0.0, 1_000_000.0), "0M");
428 }
429
430 #[test]
431 fn si_thousands() {
432 assert_eq!(format_tick_value_si(1_000.0, 1_000.0), "1K");
433 assert_eq!(format_tick_value_si(200_000.0, 100_000.0), "200K");
434 assert_eq!(format_tick_value_si(1_500.0, 1_000.0), "1.5K");
435 }
436
437 #[test]
438 fn si_billions() {
439 assert_eq!(format_tick_value_si(2_000_000_000.0, 1_000_000_000.0), "2B");
440 }
441
442 #[test]
443 fn no_si_small_values() {
444 assert_eq!(format_tick_value_si(42.0, 10.0), "42");
445 assert_eq!(format_tick_value_si(3.5, 0.5), "3.5");
446 }
447
448 #[test]
449 fn zero_tick_step() {
450 assert_eq!(format_tick_value_si(5.0, 0.0), "5");
452 }
453
454 #[test]
455 fn negative_values() {
456 assert_eq!(format_tick_value_si(-2_000_000.0, 1_000_000.0), "-2M");
457 }
458}
459
460pub fn compute_skip_factor(
470 labels: &[String],
471 available_width: f64,
472 rotation_angle_deg: f64,
473 metrics: &TextMetrics,
474) -> Option<usize> {
475 if labels.len() <= 8 {
476 return None;
477 }
478 let label_count = labels.len();
479 let available_per_label = available_width / label_count as f64;
480 let cos_angle = rotation_angle_deg.to_radians().cos();
481
482 let widths: Vec<f64> = labels.iter().map(|l| measure_text(l, metrics)).collect();
485 let avg_width = widths.iter().sum::<f64>() / widths.len() as f64;
486 let avg_rotated = avg_width * cos_angle;
487
488 let min_gap = 2.0;
493 if avg_rotated + min_gap > available_per_label {
494 let max_unrotated = (available_per_label - min_gap).max(0.0) / cos_angle;
495 let min_readable_width = 30.0; if max_unrotated < min_readable_width {
497 let needed_per = min_readable_width * cos_angle + min_gap;
498 let skip = (needed_per / available_per_label).ceil() as usize;
499 return Some(skip.max(2));
500 }
501 }
504
505 if label_count > 14 {
509 return Some(2);
510 }
511
512 None
513}
514
515pub fn strategic_indices(total: usize, target: usize) -> Vec<usize> {
518 if total == 0 {
519 return vec![];
520 }
521 if target >= total {
522 return (0..total).collect();
523 }
524 if target <= 1 {
525 return if total == 1 { vec![0] } else { vec![0, total - 1] };
526 }
527 if target == 2 {
528 return vec![0, total - 1];
529 }
530
531 let mut indices = Vec::with_capacity(target);
532 let step = (total - 1) as f64 / (target - 1) as f64;
533 for i in 0..target {
534 let idx = (i as f64 * step).round() as usize;
535 indices.push(idx.min(total - 1));
536 }
537 indices.dedup();
539 indices
540}
541
542pub fn truncate_label(label: &str, max_width: f64) -> String {
547 truncate_label_with_metrics(label, max_width, &TextMetrics::default())
548}
549
550pub fn truncate_label_with_metrics(label: &str, max_width: f64, metrics: &TextMetrics) -> String {
553 let full_width = measure_text(label, metrics);
554 if full_width <= max_width {
555 return label.to_string();
556 }
557
558 let ellipsis_width = measure_text("\u{2026}", metrics);
559 let target_width = max_width - ellipsis_width;
560 if target_width <= 0.0 {
561 return "\u{2026}".to_string();
562 }
563
564 let chars: Vec<(usize, char)> = label.char_indices().collect();
567 let mut end_chars = chars.len();
568 while end_chars > 0 {
569 let end_byte = chars[end_chars - 1].0 + chars[end_chars - 1].1.len_utf8();
570 let slice = &label[..end_byte];
571 if measure_text(slice, metrics) <= target_width {
572 return format!("{}\u{2026}", slice);
573 }
574 end_chars -= 1;
575 }
576 "\u{2026}".to_string()
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582
583 #[test]
584 fn strategy_horizontal_when_fits() {
585 let labels: Vec<String> = vec!["A".into(), "B".into(), "C".into()];
586 let strategy = LabelStrategy::determine(&labels, 800.0, &LabelStrategyConfig::default());
587 assert_eq!(strategy, LabelStrategy::Horizontal);
588 }
589
590 #[test]
591 fn strategy_rotated_when_moderate() {
592 let labels: Vec<String> = (0..10)
595 .map(|i| format!("Category {}", i))
596 .collect();
597 let strategy = LabelStrategy::determine(&labels, 200.0, &LabelStrategyConfig::default());
598 assert!(matches!(strategy, LabelStrategy::Rotated { .. }),
599 "Expected Rotated, got {:?}", strategy);
600 }
601
602 #[test]
603 fn strategy_rotated_when_dense_axis() {
604 let labels: Vec<String> = (0..20)
606 .map(|i| format!("Category {}", i))
607 .collect();
608 let strategy = LabelStrategy::determine(&labels, 200.0, &LabelStrategyConfig::default());
609 assert!(matches!(strategy, LabelStrategy::Rotated { .. }),
610 "Expected Rotated, got {:?}", strategy);
611 }
612
613 #[test]
614 fn strategy_rotated_for_monthly_labels() {
615 let labels: Vec<String> = (0..18)
617 .map(|i| format!("Jan {:02}", i + 1))
618 .collect();
619 let strategy = LabelStrategy::determine(&labels, 560.0, &LabelStrategyConfig::default());
620 assert!(matches!(strategy, LabelStrategy::Rotated { .. }),
621 "Expected Rotated, got {:?}", strategy);
622 }
623
624 #[test]
625 fn strategy_sampled_when_many() {
626 let labels: Vec<String> = (0..100)
627 .map(|i| format!("Long Category Name {}", i))
628 .collect();
629 let strategy = LabelStrategy::determine(&labels, 400.0, &LabelStrategyConfig::default());
630 assert!(matches!(strategy, LabelStrategy::Sampled { .. }),
631 "Expected Sampled, got {:?}", strategy);
632 }
633
634 #[test]
635 fn strategy_empty_labels() {
636 let labels: Vec<String> = vec![];
637 let strategy = LabelStrategy::determine(&labels, 800.0, &LabelStrategyConfig::default());
638 assert_eq!(strategy, LabelStrategy::Horizontal);
639 }
640
641 #[test]
642 fn strategic_indices_basic() {
643 let indices = strategic_indices(10, 5);
644 assert!(indices.contains(&0), "Should include first index");
645 assert!(indices.contains(&9), "Should include last index");
646 assert!(indices.len() <= 5, "Should have at most 5 indices");
647 }
648
649 #[test]
650 fn strategic_indices_all() {
651 let indices = strategic_indices(5, 10);
652 assert_eq!(indices, vec![0, 1, 2, 3, 4]);
653 }
654
655 #[test]
656 fn truncate_short_label() {
657 let result = truncate_label("Hi", 100.0);
658 assert_eq!(result, "Hi");
659 }
660
661 #[test]
662 fn truncate_long_label() {
663 let result = truncate_label("This is a very long label that should be truncated", 50.0);
664 assert!(result.ends_with('\u{2026}'), "Should end with ellipsis, got '{}'", result);
665 assert!(result.len() < "This is a very long label that should be truncated".len(),
666 "Should be shorter than original");
667 }
668
669 #[test]
670 fn approximate_text_width_basic() {
671 let width = approximate_text_width("Hello");
672 assert!(width > 0.0, "Width should be non-zero for non-empty string");
673 }
674
675 #[test]
678 fn measure_text_default_matches_legacy() {
679 let samples = ["", "A", "Hello, world!", "1,234,567", "Category 42", "\u{2026}"];
682 for s in samples {
683 let legacy = approximate_text_width(s);
684 let measured = measure_text(s, &TextMetrics::default());
685 assert!((legacy - measured).abs() < f64::EPSILON,
686 "measure_text default must equal approximate_text_width for {s:?} (legacy={legacy}, measured={measured})");
687 }
688 }
689
690 #[test]
691 fn measure_text_font_size_scales_linearly() {
692 let base = measure_text("Hello", &TextMetrics::default());
693 let m = TextMetrics { font_size_px: 24.0, ..TextMetrics::default() };
694 let big = measure_text("Hello", &m);
696 assert!((big - base * 2.0).abs() < 1e-9);
697 }
698
699 #[test]
700 fn measure_text_uppercase_is_wider() {
701 let text = "HELLO";
705 let none = measure_text(text, &TextMetrics::default());
706 let m = TextMetrics {
707 text_transform: crate::theme::TextTransform::Uppercase,
708 ..TextMetrics::default()
709 };
710 let upper = measure_text(text, &m);
711 assert!(upper > none,
712 "uppercase measurement must exceed default (upper={upper}, none={none})");
713 }
714
715 #[test]
716 fn measure_text_letter_spacing_adds_space() {
717 let m = TextMetrics { letter_spacing_px: 2.0, ..TextMetrics::default() };
718 let base = approximate_text_width("Hello");
719 let spaced = measure_text("Hello", &m);
720 let expected = base + 5.0 * 2.0;
721 assert!((spaced - expected).abs() < 1e-9,
722 "letter_spacing should add char_count * spacing (expected={expected}, got={spaced})");
723 }
724
725 #[test]
726 fn measure_text_monospace_is_wider_than_sans() {
727 let sans = measure_text("1,234,567", &TextMetrics::default());
728 let m = TextMetrics { monospace: true, font_size_px: 12.0, ..TextMetrics::default() };
729 let mono = measure_text("1,234,567", &m);
730 assert!(mono > sans,
731 "monospace should measure wider than the sans calibration (sans={sans}, mono={mono})");
732 }
733
734 #[test]
735 fn theme_tick_metrics_default_is_legacy() {
736 use crate::theme::Theme;
737 let t = Theme::default();
738 let m = TextMetrics::from_theme_tick_value(&t);
739 assert!(m.is_legacy_default(),
740 "Theme::default() must produce legacy-default tick metrics for byte-identity");
741 }
742
743 #[test]
744 fn theme_axis_label_metrics_default_is_legacy() {
745 use crate::theme::Theme;
746 assert!(TextMetrics::from_theme_axis_label(&Theme::default()).is_legacy_default());
747 }
748
749 #[test]
750 fn theme_legend_metrics_default_is_legacy() {
751 use crate::theme::Theme;
752 assert!(TextMetrics::from_theme_legend(&Theme::default()).is_legacy_default());
753 }
754
755 #[test]
756 fn theme_title_metrics_default_is_legacy() {
757 use crate::theme::Theme;
758 assert!(TextMetrics::from_theme_title(&Theme::default()).is_legacy_default());
759 }
760
761 #[test]
762 fn theme_tick_metrics_picks_up_override() {
763 use crate::theme::{Theme, TextTransform};
764 let t = Theme {
765 numeric_font_size: 11.0,
766 numeric_font_family: "Geist Mono, monospace".into(),
767 label_letter_spacing: 1.2,
768 label_text_transform: TextTransform::Uppercase,
769 ..Theme::default()
770 };
771 let m = TextMetrics::from_theme_tick_value(&t);
772 assert!(!m.is_legacy_default());
773 assert!((m.font_size_px - 11.0).abs() < 1e-6);
774 assert!((m.letter_spacing_px - 1.2).abs() < 1e-6);
775 assert!(m.monospace);
776 assert!(matches!(m.text_transform, TextTransform::Uppercase));
777 }
778
779 #[test]
780 fn family_is_monospace_recognises_common_stacks() {
781 assert!(family_is_monospace("Geist Mono, monospace"));
782 assert!(family_is_monospace("'JetBrains Mono', monospace"));
783 assert!(family_is_monospace("ui-monospace, Menlo, monospace"));
784 assert!(family_is_monospace("Consolas, Courier New, monospace"));
785 assert!(!family_is_monospace("system-ui, sans-serif"));
786 assert!(!family_is_monospace("Inter, Liberation Sans, Arial, sans-serif"));
787 }
788}