tawasuyu

tawasuyu/brahman

Rust 332TOML 127JSON 17Markdown 154710 Sutichaykuna

⎇ fd25369715461a25f4241e03f6e77b7c8fba3b9e crates/modules/ui_engine/widgets/lapaloma-cartesian/src/axis.rs Rust · 201 siq'ikuna · 6.2 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(lapaloma-cartesian): ejes con ticks y labels decimadas fd2536971 · sergio ·

1//! Generación y decimación de ticks para ejes cartesianos.
2//!
3//! Toda esta lógica es agnóstica de backend: produce listas de
4//! valores (ticks en dominio + posiciones en pixel + strings de
5//! label). El `Element` GPUI los itera para emitir línea base,
6//! segmentos de tick y `draw_text` de cada label.
7//!
8//! Pipeline canónico:
9//! 1. [`ticks_nice`] — Wilkinson nice numbers en el rango del eje.
10//! 2. Proyección dominio → pixel via [`crate::CoordinateSystem`].
11//! 3. [`decimate_labels`] — descarta labels que se solaparían con
12//!    el anterior dado un `min_spacing_px`. Los **ticks** sí
13//!    siempre se dibujan (delgados, no estorban); sólo el texto
14//!    se decima (sección 4.7 del ARCHITECTURE.md).
15//!
16//! `format_tick` es heurístico: si `step >= 1`, sin decimales; si
17//! no, tantos decimales como hagan falta para distinguir ticks
18//! adyacentes. Para escalas temporales el caller pasa su propio
19//! formato (epoch ms → "HH:MM:SS"), `format_tick` no entiende
20//! semántica.
21
22use lapaloma_core::scale::nice_step;
23
24/// Lado del plot donde vive el eje.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum AxisSide {
27    Bottom,
28    Left,
29    Top,
30    Right,
31}
32
33impl AxisSide {
34    pub fn is_horizontal(self) -> bool {
35        matches!(self, AxisSide::Bottom | AxisSide::Top)
36    }
37}
38
39/// Genera ticks "lindos" para un rango y cantidad objetivo.
40///
41/// El step es Wilkinson nice (`{1, 2, 5} × 10^k`); los ticks
42/// resultantes son múltiplos del step alineados a 0.
43/// Garantiza inclusión de bordes que caigan exactamente en
44/// múltiplos; ticks fuera del rango se descartan.
45pub fn ticks_nice(min: f64, max: f64, target_ticks: usize) -> Vec<f64> {
46    debug_assert!(max > min && target_ticks > 0);
47    let step = nice_step(min, max, target_ticks);
48    let mut t = (min / step).ceil() * step;
49    let mut out = Vec::with_capacity(target_ticks + 2);
50    // Tolerancia para incluir el borde derecho cuando cae justo
51    // por epsilon arriba del max.
52    let epsilon = step * 1e-9;
53    while t <= max + epsilon {
54        out.push(t);
55        t += step;
56    }
57    out
58}
59
60/// Filtra una lista de `(pixel_pos, label)` para que los labels
61/// no se solapen. Devuelve los **índices** que sobreviven (los
62/// del input). Asume input ordenado por `pixel_pos`.
63///
64/// `min_spacing_px` es la distancia mínima entre el borde
65/// derecho de un label aprobado y el borde izquierdo del
66/// siguiente. Si no tenés el ancho del label, pasá un valor
67/// conservador (≈ 48 px del Flutter doc).
68pub fn decimate_labels(
69    positions_px: &[f32],
70    label_widths_px: &[f32],
71    min_spacing_px: f32,
72) -> Vec<usize> {
73    debug_assert_eq!(positions_px.len(), label_widths_px.len());
74    if positions_px.is_empty() {
75        return Vec::new();
76    }
77
78    let mut out = Vec::with_capacity(positions_px.len());
79    // Primero (más a la izquierda) siempre va.
80    out.push(0);
81    let mut last_right = positions_px[0] + label_widths_px[0] * 0.5;
82
83    for i in 1..positions_px.len() {
84        let half_w = label_widths_px[i] * 0.5;
85        let my_left = positions_px[i] - half_w;
86        if my_left - last_right >= min_spacing_px {
87            out.push(i);
88            last_right = positions_px[i] + half_w;
89        }
90    }
91
92    out
93}
94
95/// Formateo numérico básico con decimales dependientes del step.
96///
97/// - `step >= 1` → sin decimales: "1", "20", "300".
98/// - `0 < step < 1` → decimales suficientes para distinguir step
99///   de step + step (típicamente `-floor(log10(step))`).
100/// - Valores absolutos muy chicos quedan en "0".
101pub fn format_tick(value: f64, step: f64) -> String {
102    if step >= 1.0 {
103        format!("{}", value.round() as i64)
104    } else if step <= 0.0 {
105        format!("{}", value)
106    } else {
107        let decimals = (-step.log10().floor()) as i32;
108        let decimals = decimals.clamp(1, 9) as usize;
109        format!("{:.*}", decimals, value)
110    }
111}
112
113/// Estilo visual del eje. Lo consume el Element en `paint()`.
114#[derive(Debug, Clone, Copy)]
115pub struct AxisStyle {
116    pub tick_length_px: f32,
117    pub tick_width_px: f32,
118    pub axis_line_width_px: f32,
119    pub label_size_px: f32,
120    pub label_offset_px: f32,
121    /// Min spacing entre labels después de decimar.
122    pub label_min_spacing_px: f32,
123}
124
125impl Default for AxisStyle {
126    fn default() -> Self {
127        Self {
128            tick_length_px: 4.0,
129            tick_width_px: 1.0,
130            axis_line_width_px: 1.0,
131            label_size_px: 10.0,
132            label_offset_px: 4.0,
133            label_min_spacing_px: 8.0,
134        }
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn ticks_nice_genera_alineados_a_step() {
144        let t = ticks_nice(0.0, 10.0, 5);
145        assert_eq!(t, vec![0.0, 2.0, 4.0, 6.0, 8.0, 10.0]);
146    }
147
148    #[test]
149    fn ticks_nice_clipea_fuera_de_rango() {
150        let t = ticks_nice(0.3, 9.8, 5);
151        // step = 2; ticks dentro [0.3, 9.8] son 2,4,6,8.
152        assert_eq!(t, vec![2.0, 4.0, 6.0, 8.0]);
153    }
154
155    #[test]
156    fn ticks_nice_rango_fraccional() {
157        let t = ticks_nice(0.0, 1.0, 5);
158        // step = 0.2 → 0, 0.2, 0.4, 0.6, 0.8, 1.0
159        assert_eq!(t.len(), 6);
160        for (i, v) in t.iter().enumerate() {
161            assert!((v - (i as f64 * 0.2)).abs() < 1e-9);
162        }
163    }
164
165    #[test]
166    fn decimate_preserva_primero() {
167        let pos = vec![0.0, 5.0, 10.0, 100.0];
168        let w = vec![20.0; 4];
169        // min_spacing 10 px. 0 va; 5 está a 5-10=-5 del borde der → no
170        // entra; 10 está a 10-10=0 → no entra; 100 sí.
171        let keep = decimate_labels(&pos, &w, 10.0);
172        assert_eq!(keep, vec![0, 3]);
173    }
174
175    #[test]
176    fn decimate_vacio() {
177        let keep = decimate_labels(&[], &[], 10.0);
178        assert!(keep.is_empty());
179    }
180
181    #[test]
182    fn decimate_pasa_todo_cuando_hay_lugar() {
183        let pos = vec![0.0, 50.0, 100.0];
184        let w = vec![10.0, 10.0, 10.0];
185        let keep = decimate_labels(&pos, &w, 5.0);
186        assert_eq!(keep, vec![0, 1, 2]);
187    }
188
189    #[test]
190    fn format_tick_integer() {
191        assert_eq!(format_tick(42.0, 1.0), "42");
192        assert_eq!(format_tick(0.0, 5.0), "0");
193        assert_eq!(format_tick(1000.0, 100.0), "1000");
194    }
195
196    #[test]
197    fn format_tick_fraccional() {
198        assert_eq!(format_tick(0.5, 0.1), "0.5");
199        assert_eq!(format_tick(0.05, 0.01), "0.05");
200    }
201}