⎇ fd25369715461a25f4241e03f6e77b7c8fba3b9e
crates/modules/ui_engine/widgets/lapaloma-cartesian/src/axis.rs
Historia
Quién tocó qué
crudo
feat(lapaloma-cartesian): ejes con ticks y labels decimadas
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}