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 #![allow(clippy::unwrap_used)]
422 use super::format_tick_value_si;
423
424 #[test]
425 fn si_millions() {
426 assert_eq!(format_tick_value_si(1_000_000.0, 1_000_000.0), "1M");
427 assert_eq!(format_tick_value_si(7_200_000.0, 1_000_000.0), "7.2M");
428 assert_eq!(format_tick_value_si(0.0, 1_000_000.0), "0M");
429 }
430
431 #[test]
432 fn si_thousands() {
433 assert_eq!(format_tick_value_si(1_000.0, 1_000.0), "1K");
434 assert_eq!(format_tick_value_si(200_000.0, 100_000.0), "200K");
435 assert_eq!(format_tick_value_si(1_500.0, 1_000.0), "1.5K");
436 }
437
438 #[test]
439 fn si_billions() {
440 assert_eq!(format_tick_value_si(2_000_000_000.0, 1_000_000_000.0), "2B");
441 }
442
443 #[test]
444 fn no_si_small_values() {
445 assert_eq!(format_tick_value_si(42.0, 10.0), "42");
446 assert_eq!(format_tick_value_si(3.5, 0.5), "3.5");
447 }
448
449 #[test]
450 fn zero_tick_step() {
451 assert_eq!(format_tick_value_si(5.0, 0.0), "5");
453 }
454
455 #[test]
456 fn negative_values() {
457 assert_eq!(format_tick_value_si(-2_000_000.0, 1_000_000.0), "-2M");
458 }
459}
460
461pub fn compute_skip_factor(
471 labels: &[String],
472 available_width: f64,
473 rotation_angle_deg: f64,
474 metrics: &TextMetrics,
475) -> Option<usize> {
476 if labels.len() <= 8 {
477 return None;
478 }
479 let label_count = labels.len();
480 let available_per_label = available_width / label_count as f64;
481 let cos_angle = rotation_angle_deg.to_radians().cos();
482
483 let widths: Vec<f64> = labels.iter().map(|l| measure_text(l, metrics)).collect();
486 let avg_width = widths.iter().sum::<f64>() / widths.len() as f64;
487 let avg_rotated = avg_width * cos_angle;
488
489 let min_gap = 2.0;
494 if avg_rotated + min_gap > available_per_label {
495 let max_unrotated = (available_per_label - min_gap).max(0.0) / cos_angle;
496 let min_readable_width = 30.0; if max_unrotated < min_readable_width {
498 let needed_per = min_readable_width * cos_angle + min_gap;
499 let skip = (needed_per / available_per_label).ceil() as usize;
500 return Some(skip.max(2));
501 }
502 }
505
506 if label_count > 14 {
510 return Some(2);
511 }
512
513 None
514}
515
516pub fn strategic_indices(total: usize, target: usize) -> Vec<usize> {
519 if total == 0 {
520 return vec![];
521 }
522 if target >= total {
523 return (0..total).collect();
524 }
525 if target <= 1 {
526 return if total == 1 { vec![0] } else { vec![0, total - 1] };
527 }
528 if target == 2 {
529 return vec![0, total - 1];
530 }
531
532 let mut indices = Vec::with_capacity(target);
533 let step = (total - 1) as f64 / (target - 1) as f64;
534 for i in 0..target {
535 let idx = (i as f64 * step).round() as usize;
536 indices.push(idx.min(total - 1));
537 }
538 indices.dedup();
540 indices
541}
542
543pub fn truncate_label(label: &str, max_width: f64) -> String {
548 truncate_label_with_metrics(label, max_width, &TextMetrics::default())
549}
550
551pub fn truncate_label_with_metrics(label: &str, max_width: f64, metrics: &TextMetrics) -> String {
554 let full_width = measure_text(label, metrics);
555 if full_width <= max_width {
556 return label.to_string();
557 }
558
559 let ellipsis_width = measure_text("\u{2026}", metrics);
560 let target_width = max_width - ellipsis_width;
561 if target_width <= 0.0 {
562 return "\u{2026}".to_string();
563 }
564
565 let chars: Vec<(usize, char)> = label.char_indices().collect();
568 let mut end_chars = chars.len();
569 while end_chars > 0 {
570 let end_byte = chars[end_chars - 1].0 + chars[end_chars - 1].1.len_utf8();
571 let slice = &label[..end_byte];
572 if measure_text(slice, metrics) <= target_width {
573 return format!("{}\u{2026}", slice);
574 }
575 end_chars -= 1;
576 }
577 "\u{2026}".to_string()
578}
579
580#[cfg(test)]
581mod tests {
582 #![allow(clippy::unwrap_used)]
583 use super::*;
584
585 #[test]
586 fn strategy_horizontal_when_fits() {
587 let labels: Vec<String> = vec!["A".into(), "B".into(), "C".into()];
588 let strategy = LabelStrategy::determine(&labels, 800.0, &LabelStrategyConfig::default());
589 assert_eq!(strategy, LabelStrategy::Horizontal);
590 }
591
592 #[test]
593 fn strategy_rotated_when_moderate() {
594 let labels: Vec<String> = (0..10)
597 .map(|i| format!("Category {}", i))
598 .collect();
599 let strategy = LabelStrategy::determine(&labels, 200.0, &LabelStrategyConfig::default());
600 assert!(matches!(strategy, LabelStrategy::Rotated { .. }),
601 "Expected Rotated, got {:?}", strategy);
602 }
603
604 #[test]
605 fn strategy_rotated_when_dense_axis() {
606 let labels: Vec<String> = (0..20)
608 .map(|i| format!("Category {}", i))
609 .collect();
610 let strategy = LabelStrategy::determine(&labels, 200.0, &LabelStrategyConfig::default());
611 assert!(matches!(strategy, LabelStrategy::Rotated { .. }),
612 "Expected Rotated, got {:?}", strategy);
613 }
614
615 #[test]
616 fn strategy_rotated_for_monthly_labels() {
617 let labels: Vec<String> = (0..18)
619 .map(|i| format!("Jan {:02}", i + 1))
620 .collect();
621 let strategy = LabelStrategy::determine(&labels, 560.0, &LabelStrategyConfig::default());
622 assert!(matches!(strategy, LabelStrategy::Rotated { .. }),
623 "Expected Rotated, got {:?}", strategy);
624 }
625
626 #[test]
627 fn strategy_sampled_when_many() {
628 let labels: Vec<String> = (0..100)
629 .map(|i| format!("Long Category Name {}", i))
630 .collect();
631 let strategy = LabelStrategy::determine(&labels, 400.0, &LabelStrategyConfig::default());
632 assert!(matches!(strategy, LabelStrategy::Sampled { .. }),
633 "Expected Sampled, got {:?}", strategy);
634 }
635
636 #[test]
637 fn strategy_empty_labels() {
638 let labels: Vec<String> = vec![];
639 let strategy = LabelStrategy::determine(&labels, 800.0, &LabelStrategyConfig::default());
640 assert_eq!(strategy, LabelStrategy::Horizontal);
641 }
642
643 #[test]
644 fn strategic_indices_basic() {
645 let indices = strategic_indices(10, 5);
646 assert!(indices.contains(&0), "Should include first index");
647 assert!(indices.contains(&9), "Should include last index");
648 assert!(indices.len() <= 5, "Should have at most 5 indices");
649 }
650
651 #[test]
652 fn strategic_indices_all() {
653 let indices = strategic_indices(5, 10);
654 assert_eq!(indices, vec![0, 1, 2, 3, 4]);
655 }
656
657 #[test]
658 fn truncate_short_label() {
659 let result = truncate_label("Hi", 100.0);
660 assert_eq!(result, "Hi");
661 }
662
663 #[test]
664 fn truncate_long_label() {
665 let result = truncate_label("This is a very long label that should be truncated", 50.0);
666 assert!(result.ends_with('\u{2026}'), "Should end with ellipsis, got '{}'", result);
667 assert!(result.len() < "This is a very long label that should be truncated".len(),
668 "Should be shorter than original");
669 }
670
671 #[test]
672 fn approximate_text_width_basic() {
673 let width = approximate_text_width("Hello");
674 assert!(width > 0.0, "Width should be non-zero for non-empty string");
675 }
676
677 #[test]
680 fn measure_text_default_matches_legacy() {
681 let samples = ["", "A", "Hello, world!", "1,234,567", "Category 42", "\u{2026}"];
684 for s in samples {
685 let legacy = approximate_text_width(s);
686 let measured = measure_text(s, &TextMetrics::default());
687 assert!((legacy - measured).abs() < f64::EPSILON,
688 "measure_text default must equal approximate_text_width for {s:?} (legacy={legacy}, measured={measured})");
689 }
690 }
691
692 #[test]
693 fn measure_text_font_size_scales_linearly() {
694 let base = measure_text("Hello", &TextMetrics::default());
695 let m = TextMetrics { font_size_px: 24.0, ..TextMetrics::default() };
696 let big = measure_text("Hello", &m);
698 assert!((big - base * 2.0).abs() < 1e-9);
699 }
700
701 #[test]
702 fn measure_text_uppercase_is_wider() {
703 let text = "HELLO";
707 let none = measure_text(text, &TextMetrics::default());
708 let m = TextMetrics {
709 text_transform: crate::theme::TextTransform::Uppercase,
710 ..TextMetrics::default()
711 };
712 let upper = measure_text(text, &m);
713 assert!(upper > none,
714 "uppercase measurement must exceed default (upper={upper}, none={none})");
715 }
716
717 #[test]
718 fn measure_text_letter_spacing_adds_space() {
719 let m = TextMetrics { letter_spacing_px: 2.0, ..TextMetrics::default() };
720 let base = approximate_text_width("Hello");
721 let spaced = measure_text("Hello", &m);
722 let expected = base + 5.0 * 2.0;
723 assert!((spaced - expected).abs() < 1e-9,
724 "letter_spacing should add char_count * spacing (expected={expected}, got={spaced})");
725 }
726
727 #[test]
728 fn measure_text_monospace_is_wider_than_sans() {
729 let sans = measure_text("1,234,567", &TextMetrics::default());
730 let m = TextMetrics { monospace: true, font_size_px: 12.0, ..TextMetrics::default() };
731 let mono = measure_text("1,234,567", &m);
732 assert!(mono > sans,
733 "monospace should measure wider than the sans calibration (sans={sans}, mono={mono})");
734 }
735
736 #[test]
737 fn theme_tick_metrics_default_is_legacy() {
738 use crate::theme::Theme;
739 let t = Theme::default();
740 let m = TextMetrics::from_theme_tick_value(&t);
741 assert!(m.is_legacy_default(),
742 "Theme::default() must produce legacy-default tick metrics for byte-identity");
743 }
744
745 #[test]
746 fn theme_axis_label_metrics_default_is_legacy() {
747 use crate::theme::Theme;
748 assert!(TextMetrics::from_theme_axis_label(&Theme::default()).is_legacy_default());
749 }
750
751 #[test]
752 fn theme_legend_metrics_default_is_legacy() {
753 use crate::theme::Theme;
754 assert!(TextMetrics::from_theme_legend(&Theme::default()).is_legacy_default());
755 }
756
757 #[test]
758 fn theme_title_metrics_default_is_legacy() {
759 use crate::theme::Theme;
760 assert!(TextMetrics::from_theme_title(&Theme::default()).is_legacy_default());
761 }
762
763 #[test]
764 fn theme_tick_metrics_picks_up_override() {
765 use crate::theme::{Theme, TextTransform};
766 let t = Theme {
767 numeric_font_size: 11.0,
768 numeric_font_family: "Geist Mono, monospace".into(),
769 label_letter_spacing: 1.2,
770 label_text_transform: TextTransform::Uppercase,
771 ..Theme::default()
772 };
773 let m = TextMetrics::from_theme_tick_value(&t);
774 assert!(!m.is_legacy_default());
775 assert!((m.font_size_px - 11.0).abs() < 1e-6);
776 assert!((m.letter_spacing_px - 1.2).abs() < 1e-6);
777 assert!(m.monospace);
778 assert!(matches!(m.text_transform, TextTransform::Uppercase));
779 }
780
781 #[test]
782 fn family_is_monospace_recognises_common_stacks() {
783 assert!(family_is_monospace("Geist Mono, monospace"));
784 assert!(family_is_monospace("'JetBrains Mono', monospace"));
785 assert!(family_is_monospace("ui-monospace, Menlo, monospace"));
786 assert!(family_is_monospace("Consolas, Courier New, monospace"));
787 assert!(!family_is_monospace("system-ui, sans-serif"));
788 assert!(!family_is_monospace("Inter, Liberation Sans, Arial, sans-serif"));
789 }
790}