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            // Mirror the post-rotation truncation from generate_x_axis:
323            // visible labels are capped so their rotated horizontal projection
324            // fits the available space.  The effective label width after truncation
325            // determines the actual vertical descent used for the margin.
326            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(); // ~0.707 for 45 deg
331            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            // Effective width: cap each label at the overlap-free width
340            // derived from the per-label spacing. This scales naturally with
341            // chart width and label count — no special-case boost needed.
342            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            // Rotated labels are placed at y_position + 10, so total space
349            // needed below the axis line is 10 + vertical_descent + padding.
350            // The base bottom margin (40px) already covers some of that.
351            // Match the JS labelUtils.js padding of 15px.
352            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        // Strategy 3: Truncated -- if truncated labels would fit
359        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        // Strategy 4: Sampled -- show a subset
364        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
370/// Approximate width of a single character in pixels at default font size (~12px).
371fn 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
381/// Approximate text width in pixels using a character-width table.
382/// Calibrated for ~12px font. For other sizes, use `approximate_text_width_at`.
383pub fn approximate_text_width(text: &str) -> f64 {
384    text.chars().map(char_width).sum()
385}
386
387/// Approximate text width scaled for a specific font size.
388pub fn approximate_text_width_at(text: &str, font_size_px: f64) -> f64 {
389    approximate_text_width(text) * (font_size_px / 12.0)
390}
391
392/// Format a numeric tick value with SI suffixes for large magnitudes.
393/// Returns compact labels like "1.5M", "200K", "3B" based on the tick step.
394pub 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        // No SI suffix — use standard formatting
403        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    // Use integer form if value is whole, otherwise one decimal
412    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        // Should not panic or produce absurd output
451        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
460/// After rotation, check if labels still overlap and compute skip factor.
461///
462/// Two-pronged approach:
463/// 1. **Physical overlap**: When rotated labels overlap, the renderer truncates
464///    them. Only skip when truncation would make labels too short to read
465///    (below `min_readable_width`).
466/// 2. **Readability thinning**: When there are many rotated labels (> 14) that
467///    fill most of their allotted horizontal space, thin for visual clarity
468///    even though there is no physical overlap.
469pub 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    // Use actual average label width for the overlap check (post-rotation
483    // horizontal projection) rather than a fixed minimum.
484    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    // Check 1: Physical overlap after rotation.
489    // When the rotated projection exceeds the per-label slot, the renderer
490    // applies post-rotation truncation. Only skip if truncation would make
491    // labels unreadably short (< min_readable_width unrotated).
492    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; // ~4 chars + ellipsis
496        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        // Truncation keeps labels readable — fall through to readability
502        // thinning check (many truncated labels are still cluttered).
503    }
504
505    // Check 2: Readability thinning.
506    // Many rotated labels (> 14) look cluttered regardless of overlap state.
507    // Thin by 2 to improve visual clarity.
508    if label_count > 14 {
509        return Some(2);
510    }
511
512    None
513}
514
515/// Select strategic indices for sampled label display.
516/// Always includes first and last; evenly distributes the rest.
517pub 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    // Deduplicate while preserving order
538    indices.dedup();
539    indices
540}
541
542/// Truncate a label to fit within max_width, adding ellipsis.
543///
544/// Uses the legacy calibration. Call `truncate_label_with_metrics` when the
545/// theme has overridden font size, letter spacing, or text transform.
546pub fn truncate_label(label: &str, max_width: f64) -> String {
547    truncate_label_with_metrics(label, max_width, &TextMetrics::default())
548}
549
550/// Truncate a label to fit within `max_width`, adding ellipsis, measuring
551/// glyphs under the provided `metrics`.
552pub 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    // Progressively shrink the label's char prefix until its measured width
565    // (with the transform, size, and letter-spacing applied) fits.
566    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        // Use <= 12 labels that don't fit horizontally to get Rotated
593        // (> 12 labels now prefer Sampled over Rotated)
594        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        // 20 labels that don't fit horizontally should be Rotated (<=40 labels)
605        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        // 18 monthly labels should be Rotated (<=40 labels)
616        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    // ---- TextMetrics / measure_text tests ----
676
677    #[test]
678    fn measure_text_default_matches_legacy() {
679        // The byte-identity contract: default metrics must reproduce the
680        // legacy approximate_text_width exactly for every input.
681        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        // Not legacy-default (font_size != 12.0), so full path runs.
695        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        // Even for all-uppercase input, applying the Uppercase transform
702        // activates the width-correction boost (1.10×), so the measurement
703        // exceeds the raw char-table sum.
704        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}