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    #![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        // Should not panic or produce absurd output
452        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
461/// After rotation, check if labels still overlap and compute skip factor.
462///
463/// Two-pronged approach:
464/// 1. **Physical overlap**: When rotated labels overlap, the renderer truncates
465///    them. Only skip when truncation would make labels too short to read
466///    (below `min_readable_width`).
467/// 2. **Readability thinning**: When there are many rotated labels (> 14) that
468///    fill most of their allotted horizontal space, thin for visual clarity
469///    even though there is no physical overlap.
470pub 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    // Use actual average label width for the overlap check (post-rotation
484    // horizontal projection) rather than a fixed minimum.
485    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    // Check 1: Physical overlap after rotation.
490    // When the rotated projection exceeds the per-label slot, the renderer
491    // applies post-rotation truncation. Only skip if truncation would make
492    // labels unreadably short (< min_readable_width unrotated).
493    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; // ~4 chars + ellipsis
497        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        // Truncation keeps labels readable — fall through to readability
503        // thinning check (many truncated labels are still cluttered).
504    }
505
506    // Check 2: Readability thinning.
507    // Many rotated labels (> 14) look cluttered regardless of overlap state.
508    // Thin by 2 to improve visual clarity.
509    if label_count > 14 {
510        return Some(2);
511    }
512
513    None
514}
515
516/// Select strategic indices for sampled label display.
517/// Always includes first and last; evenly distributes the rest.
518pub 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    // Deduplicate while preserving order
539    indices.dedup();
540    indices
541}
542
543/// Truncate a label to fit within max_width, adding ellipsis.
544///
545/// Uses the legacy calibration. Call `truncate_label_with_metrics` when the
546/// theme has overridden font size, letter spacing, or text transform.
547pub fn truncate_label(label: &str, max_width: f64) -> String {
548    truncate_label_with_metrics(label, max_width, &TextMetrics::default())
549}
550
551/// Truncate a label to fit within `max_width`, adding ellipsis, measuring
552/// glyphs under the provided `metrics`.
553pub 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    // Progressively shrink the label's char prefix until its measured width
566    // (with the transform, size, and letter-spacing applied) fits.
567    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        // Use <= 12 labels that don't fit horizontally to get Rotated
595        // (> 12 labels now prefer Sampled over Rotated)
596        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        // 20 labels that don't fit horizontally should be Rotated (<=40 labels)
607        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        // 18 monthly labels should be Rotated (<=40 labels)
618        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    // ---- TextMetrics / measure_text tests ----
678
679    #[test]
680    fn measure_text_default_matches_legacy() {
681        // The byte-identity contract: default metrics must reproduce the
682        // legacy approximate_text_width exactly for every input.
683        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        // Not legacy-default (font_size != 12.0), so full path runs.
697        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        // Even for all-uppercase input, applying the Uppercase transform
704        // activates the width-correction boost (1.10×), so the measurement
705        // exceeds the raw char-table sum.
706        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}