From fd25369715461a25f4241e03f6e77b7c8fba3b9e Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 13 May 2026 02:43:01 +0000 Subject: [PATCH] feat(lapaloma-cartesian): ejes con ticks y labels decimadas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../widgets/lapaloma-cartesian/src/axis.rs | 201 ++++++++++++++++++ .../widgets/lapaloma-cartesian/src/element.rs | 188 +++++++++++++--- .../widgets/lapaloma-cartesian/src/lib.rs | 2 +- .../lapaloma-render/src/gpui_backend.rs | 43 +++- 4 files changed, 397 insertions(+), 37 deletions(-) create mode 100644 crates/modules/ui_engine/widgets/lapaloma-cartesian/src/axis.rs 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 { + 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 { + 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, - /// 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, } @@ -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 = Vec::with_capacity(x_ticks.len()); + let mut x_lbl: Vec = Vec::with_capacity(x_ticks.len()); + let mut x_widths: Vec = 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 = 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); + } + } } }