tawasuyu

tawasuyu/brahman

Rust 332TOML 127JSON 17Markdown 154710 Sutichaykuna

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

- axis.rs: ticks_nice (Wilkinson sobre lapaloma_core::scale::nice_step),
  decimate_labels con min_spacing_px, format_tick con decimales según
  step, AxisStyle config. 8 tests.
- gpui_backend::draw_text: shape_line via window.text_system() + iterate
  glyphs con paint_glyph. Sin dep en App context (sólo &mut Window).
- LapalomaChartElement.paint_axes: línea base + tick marks + labels
  centrados (X) / right-aligned (Y) con decimación. Margins por defecto
  reservan 32px izq + 24px abajo.

45 tests verdes en lapaloma-{core,cartesian,render}.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

fd25369715461a25f4241e03f6e77b7c8fba3b9e · ruwaq sergio · (5 killa ñawpaq) · tayta 97c09bc96 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 28

crates/modules/ui_engine/widgets/lapaloma-cartesian/src/axis.rs

crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs

crates/modules/ui_engine/widgets/lapaloma-render/src/gpui_backend.rs

4 qillqakuna · +397 −37

diff --git a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/axis.rs b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/axis.rs
new file mode 100644
index 0000000..13a53a5
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/axis.rs
@@ -0,0 +1,201 @@
+//! Generación y decimación de ticks para ejes cartesianos.
+//!
+//! Toda esta lógica es agnóstica de backend: produce listas de
+//! valores (ticks en dominio + posiciones en pixel + strings de
+//! label). El `Element` GPUI los itera para emitir línea base,
+//! segmentos de tick y `draw_text` de cada label.
+//!
+//! Pipeline canónico:
+//! 1. [`ticks_nice`] — Wilkinson nice numbers en el rango del eje.
+//! 2. Proyección dominio → pixel via [`crate::CoordinateSystem`].
+//! 3. [`decimate_labels`] — descarta labels que se solaparían con
+//!    el anterior dado un `min_spacing_px`. Los **ticks** sí
+//!    siempre se dibujan (delgados, no estorban); sólo el texto
+//!    se decima (sección 4.7 del ARCHITECTURE.md).
+//!
+//! `format_tick` es heurístico: si `step >= 1`, sin decimales; si
+//! no, tantos decimales como hagan falta para distinguir ticks
+//! adyacentes. Para escalas temporales el caller pasa su propio
+//! formato (epoch ms → "HH:MM:SS"), `format_tick` no entiende
+//! semántica.
+
+use lapaloma_core::scale::nice_step;
+
+/// Lado del plot donde vive el eje.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum AxisSide {
+    Bottom,
+    Left,
+    Top,
+    Right,
+}
+
+impl AxisSide {
+    pub fn is_horizontal(self) -> bool {
+        matches!(self, AxisSide::Bottom | AxisSide::Top)
+    }
+}
+
+/// Genera ticks "lindos" para un rango y cantidad objetivo.
+///
+/// El step es Wilkinson nice (`{1, 2, 5} × 10^k`); los ticks
+/// resultantes son múltiplos del step alineados a 0.
+/// Garantiza inclusión de bordes que caigan exactamente en
+/// múltiplos; ticks fuera del rango se descartan.
+pub fn ticks_nice(min: f64, max: f64, target_ticks: usize) -> Vec<f64> {
+    debug_assert!(max > min && target_ticks > 0);
+    let step = nice_step(min, max, target_ticks);
+    let mut t = (min / step).ceil() * step;
+    let mut out = Vec::with_capacity(target_ticks + 2);
+    // Tolerancia para incluir el borde derecho cuando cae justo
+    // por epsilon arriba del max.
+    let epsilon = step * 1e-9;
+    while t <= max + epsilon {
+        out.push(t);
+        t += step;
+    }
+    out
+}
+
+/// Filtra una lista de `(pixel_pos, label)` para que los labels
+/// no se solapen. Devuelve los **índices** que sobreviven (los
+/// del input). Asume input ordenado por `pixel_pos`.
+///
+/// `min_spacing_px` es la distancia mínima entre el borde
+/// derecho de un label aprobado y el borde izquierdo del
+/// siguiente. Si no tenés el ancho del label, pasá un valor
+/// conservador (≈ 48 px del Flutter doc).
+pub fn decimate_labels(
+    positions_px: &[f32],
+    label_widths_px: &[f32],
+    min_spacing_px: f32,
+) -> Vec<usize> {
+    debug_assert_eq!(positions_px.len(), label_widths_px.len());
+    if positions_px.is_empty() {
+        return Vec::new();
+    }
+
+    let mut out = Vec::with_capacity(positions_px.len());
+    // Primero (más a la izquierda) siempre va.
+    out.push(0);
+    let mut last_right = positions_px[0] + label_widths_px[0] * 0.5;
+
+    for i in 1..positions_px.len() {
+        let half_w = label_widths_px[i] * 0.5;
+        let my_left = positions_px[i] - half_w;
+        if my_left - last_right >= min_spacing_px {
+            out.push(i);
+            last_right = positions_px[i] + half_w;
+        }
+    }
+
+    out
+}
+
+/// Formateo numérico básico con decimales dependientes del step.
+///
+/// - `step >= 1` → sin decimales: "1", "20", "300".
+/// - `0 < step < 1` → decimales suficientes para distinguir step
+///   de step + step (típicamente `-floor(log10(step))`).
+/// - Valores absolutos muy chicos quedan en "0".
+pub fn format_tick(value: f64, step: f64) -> String {
+    if step >= 1.0 {
+        format!("{}", value.round() as i64)
+    } else if step <= 0.0 {
+        format!("{}", value)
+    } else {
+        let decimals = (-step.log10().floor()) as i32;
+        let decimals = decimals.clamp(1, 9) as usize;
+        format!("{:.*}", decimals, value)
+    }
+}
+
+/// Estilo visual del eje. Lo consume el Element en `paint()`.
+#[derive(Debug, Clone, Copy)]
+pub struct AxisStyle {
+    pub tick_length_px: f32,
+    pub tick_width_px: f32,
+    pub axis_line_width_px: f32,
+    pub label_size_px: f32,
+    pub label_offset_px: f32,
+    /// Min spacing entre labels después de decimar.
+    pub label_min_spacing_px: f32,
+}
+
+impl Default for AxisStyle {
+    fn default() -> Self {
+        Self {
+            tick_length_px: 4.0,
+            tick_width_px: 1.0,
+            axis_line_width_px: 1.0,
+            label_size_px: 10.0,
+            label_offset_px: 4.0,
+            label_min_spacing_px: 8.0,
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn ticks_nice_genera_alineados_a_step() {
+        let t = ticks_nice(0.0, 10.0, 5);
+        assert_eq!(t, vec![0.0, 2.0, 4.0, 6.0, 8.0, 10.0]);
+    }
+
+    #[test]
+    fn ticks_nice_clipea_fuera_de_rango() {
+        let t = ticks_nice(0.3, 9.8, 5);
+        // step = 2; ticks dentro [0.3, 9.8] son 2,4,6,8.
+        assert_eq!(t, vec![2.0, 4.0, 6.0, 8.0]);
+    }
+
+    #[test]
+    fn ticks_nice_rango_fraccional() {
+        let t = ticks_nice(0.0, 1.0, 5);
+        // step = 0.2 → 0, 0.2, 0.4, 0.6, 0.8, 1.0
+        assert_eq!(t.len(), 6);
+        for (i, v) in t.iter().enumerate() {
+            assert!((v - (i as f64 * 0.2)).abs() < 1e-9);
+        }
+    }
+
+    #[test]
+    fn decimate_preserva_primero() {
+        let pos = vec![0.0, 5.0, 10.0, 100.0];
+        let w = vec![20.0; 4];
+        // min_spacing 10 px. 0 va; 5 está a 5-10=-5 del borde der → no
+        // entra; 10 está a 10-10=0 → no entra; 100 sí.
+        let keep = decimate_labels(&pos, &w, 10.0);
+        assert_eq!(keep, vec![0, 3]);
+    }
+
+    #[test]
+    fn decimate_vacio() {
+        let keep = decimate_labels(&[], &[], 10.0);
+        assert!(keep.is_empty());
+    }
+
+    #[test]
+    fn decimate_pasa_todo_cuando_hay_lugar() {
+        let pos = vec![0.0, 50.0, 100.0];
+        let w = vec![10.0, 10.0, 10.0];
+        let keep = decimate_labels(&pos, &w, 5.0);
+        assert_eq!(keep, vec![0, 1, 2]);
+    }
+
+    #[test]
+    fn format_tick_integer() {
+        assert_eq!(format_tick(42.0, 1.0), "42");
+        assert_eq!(format_tick(0.0, 5.0), "0");
+        assert_eq!(format_tick(1000.0, 100.0), "1000");
+    }
+
+    #[test]
+    fn format_tick_fraccional() {
+        assert_eq!(format_tick(0.5, 0.1), "0.5");
+        assert_eq!(format_tick(0.05, 0.01), "0.05");
+    }
+}
diff --git a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs
index 213735e..e53c2f7 100644
--- a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs
+++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs
@@ -3,11 +3,9 @@
 //!
 //! Owns un `DataBuffer` y un `ChartViewport`. En `paint()` arma el
 //! `WindowCanvas` adapter de `lapaloma-render` y delega a una
-//! [`LineSeries`]. El resultado: una sola `stroke_polyline` =
-//! una sola `paint_path` de GPUI = un solo draw call.
-//!
-//! Sin event handlers todavía — pan/zoom interactivos van en una
-//! fase posterior cuando enganchemos los gesture handlers de GPUI.
+//! [`LineSeries`]. Dibuja además los ejes (línea base + ticks).
+//! Los labels llegan cuando `draw_text` esté implementado en el
+//! WindowCanvas.
 
 use std::panic;
 
@@ -17,26 +15,41 @@ use gpui::{
 };
 
 use lapaloma_core::buffer::DataBuffer;
-use lapaloma_render::{Canvas, Color, Rect, StrokeStyle, WindowCanvas};
+use lapaloma_render::{Canvas, Color, Point, Rect, StrokeStyle, WindowCanvas};
+
+use lapaloma_core::scale::nice_step;
 
+use crate::axis::{decimate_labels, format_tick, ticks_nice, AxisStyle};
 use crate::coord_system::CoordinateSystem;
 use crate::series::{LineSeries, PaintCtx, RenderMode, Series};
 use crate::viewport::ChartViewport;
 
+/// Aproximación del ancho de glifo monoespaciado en función del
+/// font size. Suficiente para alinear/decimar labels — no hace
+/// falta exactitud de subpixel acá.
+const MONO_GLYPH_RATIO: f32 = 0.55;
+
+/// Cuántos ticks objetivo por eje. Wilkinson nice numbers ajusta
+/// al múltiplo más cercano del step ideal.
+const TARGET_TICKS_X: usize = 8;
+const TARGET_TICKS_Y: usize = 6;
+
 /// Chart cartesiano de una sola serie. Para múltiples series va a
-/// venir un `LapalomaChart` que componga varios `Series` boxed —
-/// por ahora arrancamos con el caso mono-serie.
+/// venir un `LapalomaChart` que componga varios `Series` boxed.
 pub struct LapalomaChartElement {
     pub data: DataBuffer,
     pub viewport: ChartViewport,
     pub stroke: StrokeStyle,
-    /// Color de fondo del plot. `None` = transparente, hereda
-    /// el container.
     pub background: Option<Color>,
-    /// Padding interior del plot (deja espacio para futuros ejes).
-    pub padding: f32,
-    /// Scratch buffer reusable entre frames. Sin Arc/Mutex porque
-    /// el Element se mueve al árbol y no se comparte.
+    pub axis_color: Color,
+    pub axis_style: AxisStyle,
+    /// Margen para X axis (espacio reservado abajo).
+    pub margin_bottom: f32,
+    /// Margen para Y axis (espacio reservado a izquierda).
+    pub margin_left: f32,
+    pub margin_top: f32,
+    pub margin_right: f32,
+    /// Scratch buffer reusable entre frames.
     scratch: Vec<f32>,
 }
 
@@ -47,7 +60,12 @@ impl LapalomaChartElement {
             viewport,
             stroke,
             background: None,
-            padding: 8.0,
+            axis_color: Color::rgba(0.6, 0.6, 0.65, 0.8),
+            axis_style: AxisStyle::default(),
+            margin_bottom: 24.0,
+            margin_left: 32.0,
+            margin_top: 8.0,
+            margin_right: 8.0,
             scratch: Vec::new(),
         }
     }
@@ -57,10 +75,125 @@ impl LapalomaChartElement {
         self
     }
 
-    pub fn padding(mut self, px: f32) -> Self {
-        self.padding = px;
+    pub fn axis_color(mut self, color: Color) -> Self {
+        self.axis_color = color;
+        self
+    }
+
+    pub fn margins(mut self, top: f32, right: f32, bottom: f32, left: f32) -> Self {
+        self.margin_top = top;
+        self.margin_right = right;
+        self.margin_bottom = bottom;
+        self.margin_left = left;
         self
     }
+
+    /// Calcula el rect del área de plot (datos), descontando los
+    /// márgenes reservados para ejes.
+    fn plot_rect(&self, bounds: Rect) -> Rect {
+        Rect::new(
+            bounds.x + self.margin_left,
+            bounds.y + self.margin_top,
+            (bounds.w - self.margin_left - self.margin_right).max(1.0),
+            (bounds.h - self.margin_top - self.margin_bottom).max(1.0),
+        )
+    }
+
+    /// Dibuja línea base + ticks + labels de ambos ejes.
+    fn paint_axes(&self, canvas: &mut dyn Canvas, cs: &CoordinateSystem) {
+        let plot = cs.plot;
+        let style = self.axis_style;
+        let axis_stroke = StrokeStyle::new(style.axis_line_width_px, self.axis_color);
+        let tick_stroke = StrokeStyle::new(style.tick_width_px, self.axis_color);
+        let tlen = style.tick_length_px;
+
+        // Líneas base.
+        canvas.stroke_line(
+            Point::new(plot.x, plot.bottom()),
+            Point::new(plot.right(), plot.bottom()),
+            axis_stroke,
+        );
+        canvas.stroke_line(
+            Point::new(plot.x, plot.y),
+            Point::new(plot.x, plot.bottom()),
+            axis_stroke,
+        );
+
+        // === X axis ===
+        let x_ticks = ticks_nice(self.viewport.x_min, self.viewport.x_max, TARGET_TICKS_X);
+        let x_step = nice_step(self.viewport.x_min, self.viewport.x_max, TARGET_TICKS_X);
+
+        let mut x_pos: Vec<f32> = Vec::with_capacity(x_ticks.len());
+        let mut x_lbl: Vec<String> = Vec::with_capacity(x_ticks.len());
+        let mut x_widths: Vec<f32> = Vec::with_capacity(x_ticks.len());
+        for v in &x_ticks {
+            let pixel = cs.data_to_pixel(*v, self.viewport.y_min).x;
+            if pixel < plot.x - 0.5 || pixel > plot.right() + 0.5 {
+                continue;
+            }
+            canvas.stroke_line(
+                Point::new(pixel, plot.bottom()),
+                Point::new(pixel, plot.bottom() + tlen),
+                tick_stroke,
+            );
+            let lbl = format_tick(*v, x_step);
+            let w = lbl.len() as f32 * style.label_size_px * MONO_GLYPH_RATIO;
+            x_pos.push(pixel);
+            x_widths.push(w);
+            x_lbl.push(lbl);
+        }
+        let keep_x = decimate_labels(&x_pos, &x_widths, style.label_min_spacing_px);
+        for i in keep_x {
+            let half = x_widths[i] * 0.5;
+            let origin_x = x_pos[i] - half;
+            let origin_y = plot.bottom() + tlen + style.label_offset_px;
+            canvas.draw_text(
+                Point::new(origin_x, origin_y),
+                &x_lbl[i],
+                self.axis_color,
+                style.label_size_px,
+            );
+        }
+
+        // === Y axis ===
+        let y_ticks = ticks_nice(self.viewport.y_min, self.viewport.y_max, TARGET_TICKS_Y);
+        let y_step = nice_step(self.viewport.y_min, self.viewport.y_max, TARGET_TICKS_Y);
+        let y_label_pitch = style.label_size_px + style.label_min_spacing_px;
+        let mut prev_py: Option<f32> = None;
+
+        for v in &y_ticks {
+            let py = cs.data_to_pixel(self.viewport.x_min, *v).y;
+            if py < plot.y - 0.5 || py > plot.bottom() + 0.5 {
+                continue;
+            }
+            canvas.stroke_line(
+                Point::new(plot.x - tlen, py),
+                Point::new(plot.x, py),
+                tick_stroke,
+            );
+
+            // Decimación vertical: si el tick anterior con label está
+            // muy cerca, saltamos sólo el label (el tick se queda).
+            let label_ok = match prev_py {
+                None => true,
+                Some(p) => (py - p).abs() >= y_label_pitch,
+            };
+            if !label_ok {
+                continue;
+            }
+            let lbl = format_tick(*v, y_step);
+            let w = lbl.len() as f32 * style.label_size_px * MONO_GLYPH_RATIO;
+            let origin_x = plot.x - tlen - style.label_offset_px - w;
+            let origin_y = py - style.label_size_px * 0.5;
+            canvas.draw_text(
+                Point::new(origin_x, origin_y),
+                &lbl,
+                self.axis_color,
+                style.label_size_px,
+            );
+            prev_py = Some(py);
+        }
+    }
 }
 
 impl IntoElement for LapalomaChartElement {
@@ -89,9 +222,6 @@ impl Element for LapalomaChartElement {
         window: &mut Window,
         cx: &mut App,
     ) -> (LayoutId, Self::RequestLayoutState) {
-        // Layout default: ocupa lo que su parent le dé via
-        // size_full(). El usuario arma el sizing afuera con
-        // div().w_full().h(px(N)) o equivalente.
         let mut style = Style::default();
         style.size.width = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0));
         style.size.height = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0));
@@ -124,20 +254,18 @@ impl Element for LapalomaChartElement {
         let oy: f32 = bounds.origin.y.into();
         let w: f32 = bounds.size.width.into();
         let h: f32 = bounds.size.height.into();
-        let plot = Rect::new(
-            ox + self.padding,
-            oy + self.padding,
-            (w - self.padding * 2.0).max(1.0),
-            (h - self.padding * 2.0).max(1.0),
-        );
+        let outer = Rect::new(ox, oy, w, h);
+        let plot = self.plot_rect(outer);
 
         let cs = CoordinateSystem::new(self.viewport, plot);
         let mut canvas = WindowCanvas::new(window);
 
         if let Some(bg) = self.background {
-            canvas.fill_rect(Rect::new(ox, oy, w, h), bg);
+            canvas.fill_rect(outer, bg);
         }
 
+        self.paint_axes(&mut canvas, &cs);
+
         let series = LineSeries::new(&self.data, self.stroke);
         self.scratch.clear();
         let mut ctx = PaintCtx {
@@ -150,12 +278,6 @@ impl Element for LapalomaChartElement {
 }
 
 /// Helper builder-style para uso ergonómico desde `Render::render`.
-///
-/// ```ignore
-/// div().w_full().h(px(300.)).child(
-///     lapaloma_chart(buf, viewport, stroke).background(rgb(0xff000000))
-/// )
-/// ```
 pub fn lapaloma_chart(
     data: DataBuffer,
     viewport: ChartViewport,
diff --git a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs
index d461d12..6e8acc8 100644
--- a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs
+++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs
@@ -26,12 +26,12 @@
 pub mod viewport;
 pub mod coord_system;
 pub mod series;
+pub mod axis;
 
 #[cfg(feature = "gpui")]
 pub mod element;
 
 // Pendientes — siguen como placeholders hasta su fase.
-pub mod axis {}
 pub mod picture_cache {}
 
 pub use viewport::ChartViewport;
diff --git a/crates/modules/ui_engine/widgets/lapaloma-render/src/gpui_backend.rs b/crates/modules/ui_engine/widgets/lapaloma-render/src/gpui_backend.rs
index 7683a4b..699a38b 100644
--- a/crates/modules/ui_engine/widgets/lapaloma-render/src/gpui_backend.rs
+++ b/crates/modules/ui_engine/widgets/lapaloma-render/src/gpui_backend.rs
@@ -16,7 +16,10 @@
 //!   `WindowTextSystem` en una fase próxima).
 
 use crate::{Canvas, Color, Point, Rect, StrokeStyle};
-use gpui::{fill, hsla, point as gpui_point, px, size as gpui_size, Bounds, Hsla, PathBuilder, Window};
+use gpui::{
+    fill, font, hsla, point as gpui_point, px, size as gpui_size, Bounds, Hsla, PathBuilder,
+    SharedString, TextRun, Window,
+};
 
 /// Adapter que pinta sobre un `&mut Window` de GPUI.
 ///
@@ -120,8 +123,42 @@ impl<'a> Canvas for WindowCanvas<'a> {
         // vertex buffer wgpu.
     }
 
-    fn draw_text(&mut self, _p: Point, _text: &str, _color: Color, _size_px: f32) {
-        // TODO: integrar con WindowTextSystem para axis labels.
+    fn draw_text(&mut self, p: Point, text: &str, color: Color, size_px: f32) {
+        if text.is_empty() {
+            return;
+        }
+        let hsla = color_to_hsla(color);
+        let font_size = px(size_px);
+        let text_str: SharedString = text.to_string().into();
+        let runs = [TextRun {
+            len: text.len(),
+            font: font("Monospace"),
+            color: hsla,
+            background_color: None,
+            underline: None,
+            strikethrough: None,
+        }];
+
+        let shaped = self
+            .window
+            .text_system()
+            .shape_line(text_str, font_size, &runs, None);
+
+        // Iteramos glyphs vía `paint_glyph` para evitar la
+        // dependencia con `&mut App` que pide `ShapedLine::paint`.
+        // Eso encaja con el contrato actual del Canvas trait que
+        // sólo expone `&mut Window`.
+        let origin_x = px(p.x);
+        let origin_y = px(p.y);
+        for run in shaped.runs.iter() {
+            for glyph in run.glyphs.iter() {
+                let gx = origin_x + glyph.position.x;
+                let gy = origin_y + glyph.position.y;
+                let _ = self
+                    .window
+                    .paint_glyph(gpui_point(gx, gy), run.font_id, glyph.id, font_size, hsla);
+            }
+        }
     }
 }