Skip to main content

chartml_core/layout/
labels.rs

1use crate::theme::{TextTransform, Theme};
2
3/// Text-shaping parameters that influence the rendered width of a label.
4///
5/// `TextMetrics` is how layout code feeds theme typography into the width
6/// estimator so margins, tick spacing, label strategy, legend packing, and
7/// truncation all see the same width the SVG renderer will eventually paint.
8///
9/// The default is calibrated to `Theme::default()`: a 12px sans-serif face
10/// with no letter spacing and no text transform. `measure_text(text, default)`
11/// is therefore guaranteed to return exactly the legacy
12/// `approximate_text_width(text)` value — this is the backward-compatibility
13/// contract that keeps the pre-theme-hooks golden snapshots byte-identical.
14#[derive(Debug, Clone, PartialEq)]
15pub struct TextMetrics {
16    /// Rendered font size in pixels.
17    pub font_size_px: f64,
18    /// Extra CSS `letter-spacing` in pixels applied between glyphs.
19    pub letter_spacing_px: f64,
20    /// `text-transform` that will be applied by the renderer.
21    pub text_transform: TextTransform,
22    /// Whether the rendered font is a monospace face. Monospace glyphs are
23    /// noticeably wider than the sans-serif calibration; this flag lets the
24    /// measurement apply a per-character width correction.
25    pub monospace: bool,
26}
27
28impl Default for TextMetrics {
29    /// Default metrics match the `Theme::default()` legacy assumptions
30    /// (12px sans-serif, no letter spacing, no transform). Feeding default
31    /// metrics to `measure_text` produces output byte-identical to the legacy
32    /// `approximate_text_width`.
33    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    /// True when these metrics exactly match the legacy calibration. When
45    /// true, `measure_text` short-circuits to `approximate_text_width` so
46    /// layout output is bit-for-bit identical to the pre-3.1 behavior.
47    #[inline]
48    pub fn is_legacy_default(&self) -> bool {
49        // Exact-equals on f64 is safe here: the sentinel values come from
50        // `Default` literals, never from arithmetic.
51        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    /// Build `TextMetrics` for numeric tick labels from a theme.
58    ///
59    /// Returns the legacy-default metrics (byte-identical to
60    /// `approximate_text_width`) when every relevant field on `theme` matches
61    /// `Theme::default()`. Any divergence flips the layout path to the full
62    /// `measure_text` measurement.
63    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    /// Build `TextMetrics` for axis/category labels from a theme.
86    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    /// Build `TextMetrics` for legend labels from a theme.
109    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    /// Build `TextMetrics` for the chart title from a theme.
132    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
149/// Heuristic check for monospace-family CSS font stacks. Matches any of the
150/// common monospace tokens (`monospace`, `mono`, `code`, or widely-known
151/// monospaced faces like `Geist Mono`, `Menlo`, `Consolas`, …). False
152/// positives are extremely rare because the tokens are specific; false
153/// negatives only affect unrecognised monospaced fonts and fall back to the
154/// sans-serif calibration, which is the legacy behavior.
155fn 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
173/// Apply a `TextTransform` to a text string without allocating when the
174/// transform is `None`.
175fn 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
183/// Measure the rendered width of a text string under the given metrics.
184///
185/// This is the self-correcting text width estimator used by every layout
186/// decision: margins, tick strategy, legend packing, truncation, and label
187/// rotation. It accounts for:
188///
189/// * `font_size_px` — linear scaling from the 12px calibration baseline.
190/// * `text_transform` — transforms the text before measuring so uppercase
191///   labels are measured as uppercase (wider than the original).
192/// * `letter_spacing_px` — CSS letter-spacing adds a constant per glyph.
193/// * `monospace` — monospace faces are measured with a fixed per-character
194///   advance that is wider than the sans-serif default.
195///
196/// Backward compatibility: when `metrics.is_legacy_default()` is true, the
197/// result is identical (to the bit) to `approximate_text_width(text)`. This
198/// is the invariant that keeps the pre-theme-hooks golden snapshots
199/// byte-identical under `Theme::default()`.
200pub 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    // Base width in the 12px calibration.
208    let base = if metrics.monospace {
209        // Monospace average advance ~7.2px at 12px is too narrow — glyphs in
210        // real monospace faces (Geist Mono, Menlo, Consolas, SF Mono) sit
211        // around 7.5–7.8 at 12px. Use 7.7 as the calibration so that e.g.
212        // "1,234,567" measures wider than the sans fallback would claim.
213        transformed.chars().count() as f64 * 7.7
214    } else {
215        transformed.chars().map(char_width).sum::<f64>()
216    };
217
218    // Scale for font size.
219    let size_ratio = metrics.font_size_px / 12.0;
220    let mut width = base * size_ratio;
221
222    // Uppercase letters are meaningfully wider than the average in the
223    // sans-serif calibration table (which is tuned for mixed case). Apply a
224    // 1.10× correction on top of the scaled width when the renderer will
225    // uppercase the glyphs.
226    if matches!(metrics.text_transform, TextTransform::Uppercase) {
227        width *= 1.10;
228    }
229
230    // CSS letter-spacing is additive between glyphs; it also affects the
231    // trailing edge in the conservative direction, so we count all glyphs.
232    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/// The strategy selected for rendering labels.
241#[derive(Debug, Clone, PartialEq)]
242pub enum LabelStrategy {
243    /// Labels displayed horizontally (no transformation needed).
244    Horizontal,
245    /// Labels rotated -45 degrees. Contains the additional bottom margin needed
246    /// and an optional skip factor for label sampling after rotation.
247    Rotated { margin: f64, skip_factor: Option<usize> },
248    /// Labels truncated to max_width with ellipsis.
249    Truncated { max_width: f64 },
250    /// Only a subset of labels shown (evenly sampled).
251    Sampled { indices: Vec<usize> },
252}
253
254/// Configuration for label strategy determination.
255pub struct LabelStrategyConfig {
256    pub min_label_spacing: f64,   // Default: 4.0 px
257    pub max_label_width: f64,     // Default: 120.0 px for truncation
258    pub max_rotation_margin: f64, // Default: 150.0 px
259    pub rotation_angle_deg: f64,  // Default: 45.0 degrees
260    /// Text shaping metrics used to measure the rendered width of each
261    /// candidate label. Defaults to the legacy calibration so callers that
262    /// do not pass theme metrics see byte-identical layout behavior.
263    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    /// Determine the best label strategy based on available space and label measurements.
280    ///
281    /// Algorithm (cascading priority):
282    /// 1. Horizontal: if labels fit without overlap
283    /// 2. Rotated: if <= 40 labels, rotate -45 degrees (post-rotation truncation
284    ///    is applied later in generate_x_axis to guarantee no overlap)
285    /// 3. Truncated: if truncated labels fit and <= 50 labels
286    /// 4. Sampled: show an evenly-distributed subset
287    ///
288    /// Parameters:
289    /// - labels: the label strings
290    /// - available_width: total width available for the axis (chart width)
291    /// - config: strategy configuration
292    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        // Measure label widths using theme-aware metrics. With default
305        // metrics this matches the legacy `approximate_text_width`.
306        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        // Strategy 1: Horizontal -- labels fit without overlap
311        if avg_width + config.min_label_spacing <= available_per_label {
312            return LabelStrategy::Horizontal;
313        }
314
315        // Strategy 2: Rotated -- rotate -45 degrees if not too many labels.
316        // Post-rotation truncation is handled in generate_x_axis to ensure
317        // rotated labels don't collide even when they are long.
318        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            // Rotated labels render at full length (generate_x_axis does not
323            // truncate them), so the vertical descent comes from the widest
324            // label that will actually be visible after skip sampling.
325            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            // Rotated labels are placed at y_position + 10, so total space
334            // needed below the axis line is 10 + vertical_descent + padding.
335            // The base bottom margin (40px) already covers some of that.
336            // Match the JS labelUtils.js padding of 15px.
337            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        // Strategy 3: Truncated -- if truncated labels would fit
344        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        // Strategy 4: Sampled -- show a subset
349        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
355/// Approximate width of a single character in pixels at default font size (~12px).
356fn 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
366/// Approximate text width in pixels using a character-width table.
367/// Calibrated for ~12px font. For other sizes, use `approximate_text_width_at`.
368pub fn approximate_text_width(text: &str) -> f64 {
369    text.chars().map(char_width).sum()
370}
371
372/// Approximate text width scaled for a specific font size.
373pub fn approximate_text_width_at(text: &str, font_size_px: f64) -> f64 {
374    approximate_text_width(text) * (font_size_px / 12.0)
375}
376
377/// Format a numeric tick value with SI suffixes for large magnitudes.
378/// Returns compact labels like "1.5M", "200K", "3B" based on the tick step.
379pub 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        // No SI suffix — use standard formatting
388        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    // Use integer form if value is whole, otherwise one decimal
397    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        // Should not panic or produce absurd output
437        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
446/// After rotation, check if labels still overlap and compute skip factor.
447///
448/// Two-pronged approach:
449/// 1. **Physical overlap**: When rotated labels overlap, the renderer truncates
450///    them. Only skip when truncation would make labels too short to read
451///    (below `min_readable_width`).
452/// 2. **Readability thinning**: When there are many rotated labels (> 14) that
453///    fill most of their allotted horizontal space, thin for visual clarity
454///    even though there is no physical overlap.
455pub 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    // Use actual average label width for the overlap check (post-rotation
469    // horizontal projection) rather than a fixed minimum.
470    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    // Check 1: Physical overlap after rotation.
475    // When the rotated projection exceeds the per-label slot, the renderer
476    // applies post-rotation truncation. Only skip if truncation would make
477    // labels unreadably short (< min_readable_width unrotated).
478    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; // ~4 chars + ellipsis
482        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        // Truncation keeps labels readable — fall through to readability
488        // thinning check (many truncated labels are still cluttered).
489    }
490
491    // Check 2: Readability thinning.
492    // Many rotated labels (> 14) look cluttered regardless of overlap state.
493    // Thin by 2 to improve visual clarity.
494    if label_count > 14 {
495        return Some(2);
496    }
497
498    None
499}
500
501/// Select strategic indices for sampled label display.
502/// Always includes first and last; evenly distributes the rest.
503pub 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    // Deduplicate while preserving order
524    indices.dedup();
525    indices
526}
527
528/// Truncate a label to fit within max_width, adding ellipsis.
529///
530/// Uses the legacy calibration. Call `truncate_label_with_metrics` when the
531/// theme has overridden font size, letter spacing, or text transform.
532pub fn truncate_label(label: &str, max_width: f64) -> String {
533    truncate_label_with_metrics(label, max_width, &TextMetrics::default())
534}
535
536/// Truncate a label to fit within `max_width`, adding ellipsis, measuring
537/// glyphs under the provided `metrics`.
538pub 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    // Progressively shrink the label's char prefix until its measured width
551    // (with the transform, size, and letter-spacing applied) fits.
552    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        // Use <= 12 labels that don't fit horizontally to get Rotated
580        // (> 12 labels now prefer Sampled over Rotated)
581        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        // 20 labels that don't fit horizontally should be Rotated (<=40 labels)
592        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        // 18 monthly labels should be Rotated (<=40 labels)
603        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        // Rotated labels render at full length (no post-rotation truncation
624        // in generate_x_axis), so the reserved margin must cover the descent
625        // of the longest visible label — not a truncation-capped width.
626        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        // Labels are anchored at y_position + 10 with 15px clearance below;
641        // the base bottom margin (40px) absorbs the first 40px of that.
642        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    // ---- TextMetrics / measure_text tests ----
689
690    #[test]
691    fn measure_text_default_matches_legacy() {
692        // The byte-identity contract: default metrics must reproduce the
693        // legacy approximate_text_width exactly for every input.
694        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        // Not legacy-default (font_size != 12.0), so full path runs.
708        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        // Even for all-uppercase input, applying the Uppercase transform
715        // activates the width-correction boost (1.10×), so the measurement
716        // exceeds the raw char-table sum.
717        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}