From 791ca18d8148cbe2a6eaf7e92eafab53478c3473 Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 13 May 2026 02:24:30 +0000 Subject: [PATCH] feat(lapaloma-cartesian): viewport + coord_system + LineSeries con LTTB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChartViewport: pan/zoom anchor-preserving en coords de dominio. - CoordinateSystem: proyección dominio→pixel + project_buffer zero-alloc. - trait Series + LineSeries que emite una sola stroke_polyline por frame (valida P3 del ARCHITECTURE.md). LTTB se dispara cuando data.len() excede 3× el ancho del plot. - hit_test sobre coords sorted-by-X con binary search + threshold 8px. - 14 tests cubren pan, zoom, projection, downsample y hit-test. Element GPUI queda para la siguiente fase (requiere pionear paint custom sobre PaintContext — el monorepo no tiene precedente todavía). Co-Authored-By: Claude Opus 4.7 --- .../lapaloma-cartesian/src/coord_system.rs | 133 +++++++++ .../widgets/lapaloma-cartesian/src/lib.rs | 12 +- .../widgets/lapaloma-cartesian/src/series.rs | 276 ++++++++++++++++++ .../lapaloma-cartesian/src/viewport.rs | 130 +++++++++ 4 files changed, 548 insertions(+), 3 deletions(-) create mode 100644 crates/modules/ui_engine/widgets/lapaloma-cartesian/src/coord_system.rs create mode 100644 crates/modules/ui_engine/widgets/lapaloma-cartesian/src/series.rs create mode 100644 crates/modules/ui_engine/widgets/lapaloma-cartesian/src/viewport.rs diff --git a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/coord_system.rs b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/coord_system.rs new file mode 100644 index 0000000..8beefb8 --- /dev/null +++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/coord_system.rs @@ -0,0 +1,133 @@ +//! `CoordinateSystem` — proyección dominio ↔ pixel. +//! +//! Compone `ChartViewport` (qué se ve) + `plot_rect` (dónde, en +//! píxeles) en una transformación afín. La invocación es +//! pointwise; no toca los buffers de datos. +//! +//! Convención Y: +Y de pantalla apunta abajo; +Y de datos arriba. +//! La proyección invierte Y para que un valor alto quede arriba. + +use crate::viewport::ChartViewport; +use lapaloma_render::{Point, Rect}; + +#[derive(Debug, Clone, Copy)] +pub struct CoordinateSystem { + pub viewport: ChartViewport, + pub plot: Rect, +} + +impl CoordinateSystem { + pub fn new(viewport: ChartViewport, plot: Rect) -> Self { + Self { viewport, plot } + } + + /// `(value_x, value_y)` → `(pixel_x, pixel_y)`. + pub fn data_to_pixel(&self, x: f64, y: f64) -> Point { + let nx = (x - self.viewport.x_min) / self.viewport.x_span(); + let ny = (y - self.viewport.y_min) / self.viewport.y_span(); + let px = self.plot.x + nx as f32 * self.plot.w; + // +Y de datos = arriba → restar de bottom. + let py = self.plot.bottom() - ny as f32 * self.plot.h; + Point::new(px, py) + } + + /// `(pixel_x, pixel_y)` → `(value_x, value_y)`. + /// Usado para hit-test y tooltip-on-hover. + pub fn pixel_to_data(&self, p: Point) -> (f64, f64) { + let nx = ((p.x - self.plot.x) / self.plot.w) as f64; + let ny = ((self.plot.bottom() - p.y) / self.plot.h) as f64; + let x = self.viewport.x_min + nx * self.viewport.x_span(); + let y = self.viewport.y_min + ny * self.viewport.y_span(); + (x, y) + } + + /// Proyecta un buffer entero de coords interleaved + /// `[x, y, x, y, …]` (en dominio) a `[px, py, px, py, …]` + /// (en píxeles), escribiendo a `out` sin allocar. + /// + /// El caller debe hacer `out.clear()` previo si quiere reuso + /// del buffer; este método sólo extiende. + pub fn project_buffer(&self, data: &[f32], out: &mut Vec) { + debug_assert!(data.len() % 2 == 0); + // Factorizamos para evitar la división por iteración. + let sx = self.plot.w / self.viewport.x_span() as f32; + let sy = self.plot.h / self.viewport.y_span() as f32; + let tx = self.plot.x - self.viewport.x_min as f32 * sx; + let ty = self.plot.bottom() + self.viewport.y_min as f32 * sy; + + out.reserve(data.len()); + let mut i = 0; + while i < data.len() { + let px = data[i] * sx + tx; + let py = ty - data[i + 1] * sy; + out.push(px); + out.push(py); + i += 2; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> CoordinateSystem { + // Viewport [0..10, 0..10] sobre plot de 100×100 en (0, 0). + CoordinateSystem::new( + ChartViewport::new(0.0, 10.0, 0.0, 10.0), + Rect::new(0.0, 0.0, 100.0, 100.0), + ) + } + + #[test] + fn data_to_pixel_origen() { + let cs = fixture(); + // (0,0) data → (0, 100) pixel (bottom-left del plot). + let p = cs.data_to_pixel(0.0, 0.0); + assert!((p.x - 0.0).abs() < 1e-6); + assert!((p.y - 100.0).abs() < 1e-6); + } + + #[test] + fn data_to_pixel_centro() { + let cs = fixture(); + // (5, 5) → (50, 50) + let p = cs.data_to_pixel(5.0, 5.0); + assert!((p.x - 50.0).abs() < 1e-6); + assert!((p.y - 50.0).abs() < 1e-6); + } + + #[test] + fn data_to_pixel_top_right() { + let cs = fixture(); + // (10, 10) → (100, 0) + let p = cs.data_to_pixel(10.0, 10.0); + assert!((p.x - 100.0).abs() < 1e-6); + assert!((p.y - 0.0).abs() < 1e-6); + } + + #[test] + fn pixel_to_data_roundtrip() { + let cs = fixture(); + for (x, y) in [(2.5_f64, 7.5), (0.0, 0.0), (10.0, 10.0), (3.14, 1.59)] { + let p = cs.data_to_pixel(x, y); + let (x2, y2) = cs.pixel_to_data(p); + assert!((x - x2).abs() < 1e-4, "x roundtrip: {} vs {}", x, x2); + assert!((y - y2).abs() < 1e-4, "y roundtrip: {} vs {}", y, y2); + } + } + + #[test] + fn project_buffer_consistente_con_pointwise() { + let cs = fixture(); + let data: Vec = vec![0.0, 0.0, 5.0, 5.0, 10.0, 10.0]; + let mut out = Vec::new(); + cs.project_buffer(&data, &mut out); + assert_eq!(out.len(), data.len()); + for i in 0..3 { + let expected = cs.data_to_pixel(data[i * 2] as f64, data[i * 2 + 1] as f64); + assert!((out[i * 2] - expected.x).abs() < 1e-4); + assert!((out[i * 2 + 1] - expected.y).abs() < 1e-4); + } + } +} 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 0f2baa2..c8ce08a 100644 --- a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs +++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs @@ -23,9 +23,15 @@ #![forbid(unsafe_code)] #![allow(dead_code)] -pub mod viewport {} -pub mod coord_system {} -pub mod series {} +pub mod viewport; +pub mod coord_system; +pub mod series; + +// Pendientes — siguen como placeholders hasta su fase. pub mod axis {} pub mod picture_cache {} pub mod element {} + +pub use viewport::ChartViewport; +pub use coord_system::CoordinateSystem; +pub use series::{LineSeries, PaintCtx, RenderMode, Series}; diff --git a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/series.rs b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/series.rs new file mode 100644 index 0000000..72d2b2e --- /dev/null +++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/series.rs @@ -0,0 +1,276 @@ +//! `Series` — trait que abstrae cualquier dataset visualizable +//! sobre coordenadas cartesianas, + impl [`LineSeries`]. +//! +//! La firma es agnóstica de `gpui`: el painter dibuja contra +//! `lapaloma_render::Canvas`. El Element GPUI envuelve esto y +//! pasa un adaptador del Canvas trait sobre el PaintContext nativo. + +use lapaloma_core::buffer::DataBuffer; +use lapaloma_core::lttb; +use lapaloma_render::{Canvas, StrokeStyle}; + +use crate::coord_system::CoordinateSystem; + +/// Hint para la serie sobre el nivel de detalle. A alta densidad +/// (muchos más puntos que pixeles) el painter saltea decoraciones +/// y aplica decimación; a baja densidad pinta marcadores y todo. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RenderMode { + HighDensity, + UiRich, +} + +/// Contexto que la serie recibe en cada `paint`. +/// +/// Lleva el coord system + el modo + un buffer scratch que el +/// caller mantiene entre frames para evitar allocations. +pub struct PaintCtx<'a> { + pub cs: CoordinateSystem, + pub mode: RenderMode, + /// Buffer scratch reusable (compartido entre series del mismo + /// chart). El caller hace `clear()` antes de cada serie. + pub scratch: &'a mut Vec, +} + +pub trait Series { + fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut dyn Canvas); + + /// Devuelve `Some(point_index)` del sample más cercano al + /// pixel pasado, si está dentro del threshold de hit (default + /// 8 px). El default impl asume que el data buffer expuesto + /// por la serie está sorted por X (caso de [`LineSeries`]). + fn hit_test(&self, _pixel: lapaloma_render::Point, _cs: &CoordinateSystem) -> Option { + None + } +} + +/// Serie de polilínea simple. Decimación LTTB cuando +/// `data.len() > 3 × plot_width_px`. +pub struct LineSeries<'a> { + pub data: &'a DataBuffer, + pub stroke: StrokeStyle, + /// Si `None`, se usa heurística `target = plot_width × 3`. + pub lttb_target: Option, +} + +impl<'a> LineSeries<'a> { + pub fn new(data: &'a DataBuffer, stroke: StrokeStyle) -> Self { + Self { data, stroke, lttb_target: None } + } + + fn effective_target(&self, plot_w: f32) -> usize { + self.lttb_target.unwrap_or_else(|| (plot_w as usize).saturating_mul(3)) + } +} + +impl<'a> Series for LineSeries<'a> { + fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut dyn Canvas) { + if self.data.len() < 2 { + return; + } + + let target = self.effective_target(ctx.cs.plot.w); + + ctx.scratch.clear(); + + if self.data.len() > target { + // Decimar primero (en coords de dominio), proyectar después. + let mut idx = Vec::with_capacity(target); + lttb::lttb_indices(self.data.coords(), target, &mut idx); + let mut decimated: Vec = Vec::with_capacity(idx.len() * 2); + for i in idx { + decimated.push(self.data.coords()[i * 2]); + decimated.push(self.data.coords()[i * 2 + 1]); + } + ctx.cs.project_buffer(&decimated, ctx.scratch); + } else { + ctx.cs.project_buffer(self.data.coords(), ctx.scratch); + } + + canvas.stroke_polyline(ctx.scratch, self.stroke); + } + + fn hit_test(&self, pixel: lapaloma_render::Point, cs: &CoordinateSystem) -> Option { + let (target_x, _) = cs.pixel_to_data(pixel); + let idx = lapaloma_core::spatial::SpatialIndex::new(self.data.coords()) + .nearest(target_x as f32)?; + // Threshold de 8px sobre la distancia en pixeles real, + // no sólo la X — evita match cuando el punto está lejos + // verticalmente. + let (dx, dy) = self.data.xy(idx); + let p = cs.data_to_pixel(dx as f64, dy as f64); + let dist2 = (p.x - pixel.x).powi(2) + (p.y - pixel.y).powi(2); + if dist2 <= 64.0 { Some(idx) } else { None } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::viewport::ChartViewport; + use lapaloma_render::{Color, Point, Rect, RenderCmd, RenderPlan}; + + /// Canvas mock que captura comandos en un `RenderPlan`. + struct Capture { + plan: RenderPlan, + } + impl Capture { + fn new() -> Self { + Self { plan: RenderPlan::new() } + } + } + impl Canvas for Capture { + fn push_clip(&mut self, rect: Rect) { + self.plan.push(RenderCmd::PushClip(rect)); + } + fn pop_clip(&mut self) { + self.plan.push(RenderCmd::PopClip); + } + fn fill_rect(&mut self, rect: Rect, color: Color) { + self.plan.push(RenderCmd::FillRect { rect, color }); + } + fn stroke_rect(&mut self, rect: Rect, stroke: StrokeStyle) { + self.plan.push(RenderCmd::StrokeRect { rect, stroke }); + } + fn stroke_line(&mut self, a: Point, b: Point, stroke: StrokeStyle) { + self.plan.push(RenderCmd::StrokeLine { a, b, stroke }); + } + fn stroke_polyline(&mut self, coords: &[f32], stroke: StrokeStyle) { + self.plan.push(RenderCmd::StrokePolyline { + coords: coords.to_vec(), + stroke, + }); + } + fn fill_triangle_strip(&mut self, coords: &[f32], colors: &[Color]) { + self.plan.push(RenderCmd::FillTriangleStrip { + coords: coords.to_vec(), + colors: colors.to_vec(), + }); + } + fn draw_text(&mut self, p: Point, text: &str, color: Color, size_px: f32) { + self.plan.push(RenderCmd::DrawText { + p, + text: text.into(), + color, + size_px, + }); + } + } + + fn line_series_pinta_polyline() -> (Capture, usize) { + let mut buf = DataBuffer::with_capacity(5); + for i in 0..5 { + buf.push(i as f32, (i as f32).powi(2)); + } + let series = LineSeries::new(&buf, StrokeStyle::new(2.0, Color::WHITE)); + let cs = CoordinateSystem::new( + ChartViewport::new(0.0, 4.0, 0.0, 16.0), + Rect::new(0.0, 0.0, 100.0, 100.0), + ); + let mut scratch = Vec::new(); + let mut ctx = PaintCtx { + cs, + mode: RenderMode::UiRich, + scratch: &mut scratch, + }; + let mut cap = Capture::new(); + series.paint(&mut ctx, &mut cap); + + let n = cap.plan.cmds.len(); + (cap, n) + } + + #[test] + fn line_series_emite_un_solo_drawcall() { + let (cap, n) = line_series_pinta_polyline(); + assert_eq!(n, 1, "una sola draw call (P3 del ARCHITECTURE.md)"); + match &cap.plan.cmds[0] { + RenderCmd::StrokePolyline { coords, .. } => { + assert_eq!(coords.len(), 10, "5 puntos × 2 = 10 floats"); + } + other => panic!("se esperaba StrokePolyline, se vio {:?}", other), + } + } + + #[test] + fn lttb_se_dispara_con_alta_densidad() { + // 10k puntos sobre plot de 50px → target = 150 → debe decimar + let mut buf = DataBuffer::with_capacity(10_000); + for i in 0..10_000 { + buf.push(i as f32, (i as f32 * 0.01).sin()); + } + let series = LineSeries::new(&buf, StrokeStyle::new(1.0, Color::WHITE)); + let cs = CoordinateSystem::new( + ChartViewport::new(0.0, 9999.0, -1.0, 1.0), + Rect::new(0.0, 0.0, 50.0, 100.0), + ); + let mut scratch = Vec::new(); + let mut ctx = PaintCtx { + cs, + mode: RenderMode::HighDensity, + scratch: &mut scratch, + }; + let mut cap = Capture::new(); + series.paint(&mut ctx, &mut cap); + + match &cap.plan.cmds[0] { + RenderCmd::StrokePolyline { coords, .. } => { + // Debe haber muchos menos que 10k puntos (target ≈ 150). + assert!(coords.len() / 2 <= 160); + assert!(coords.len() / 2 >= 100); + } + _ => panic!(), + } + } + + #[test] + fn line_series_vacio_no_emite() { + let buf = DataBuffer::new(); + let series = LineSeries::new(&buf, StrokeStyle::new(1.0, Color::WHITE)); + let cs = CoordinateSystem::new( + ChartViewport::new(0.0, 1.0, 0.0, 1.0), + Rect::new(0.0, 0.0, 100.0, 100.0), + ); + let mut scratch = Vec::new(); + let mut ctx = PaintCtx { + cs, + mode: RenderMode::UiRich, + scratch: &mut scratch, + }; + let mut cap = Capture::new(); + series.paint(&mut ctx, &mut cap); + assert_eq!(cap.plan.cmds.len(), 0); + } + + #[test] + fn hit_test_acepta_punto_cercano() { + let mut buf = DataBuffer::with_capacity(5); + for i in 0..5 { + buf.push(i as f32, (i as f32).powi(2)); + } + let series = LineSeries::new(&buf, StrokeStyle::new(1.0, Color::WHITE)); + let cs = CoordinateSystem::new( + ChartViewport::new(0.0, 4.0, 0.0, 16.0), + Rect::new(0.0, 0.0, 100.0, 100.0), + ); + // Punto (2,4) en data → ¿qué pixel? (2/4)·100=50, (1-4/16)·100=75 + let target = lapaloma_render::Point::new(50.0, 75.0); + let hit = series.hit_test(target, &cs); + assert_eq!(hit, Some(2)); + } + + #[test] + fn hit_test_rechaza_punto_lejano() { + let mut buf = DataBuffer::with_capacity(2); + buf.push(0.0, 0.0); + buf.push(1.0, 1.0); + let series = LineSeries::new(&buf, StrokeStyle::new(1.0, Color::WHITE)); + let cs = CoordinateSystem::new( + ChartViewport::new(0.0, 1.0, 0.0, 1.0), + Rect::new(0.0, 0.0, 100.0, 100.0), + ); + // Pixel muy lejos de la línea. + let far = lapaloma_render::Point::new(50.0, 99.0); + assert!(series.hit_test(far, &cs).is_none()); + } +} diff --git a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/viewport.rs b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/viewport.rs new file mode 100644 index 0000000..a30451f --- /dev/null +++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/viewport.rs @@ -0,0 +1,130 @@ +//! `ChartViewport` — ventana visible en el dominio de datos. +//! +//! El viewport NO conoce pixeles. Sólo describe qué rango de +//! valores X/Y es visible. La proyección a píxeles la hace +//! [`crate::coord_system::CoordinateSystem`] cuando le pasás +//! el `plot_rect`. +//! +//! Pan y zoom mutan el viewport, no los datos. Esto preserva el +//! P2 zero-alloc: los buffers de DataBuffer / RingBuffer se quedan +//! quietos; sólo cambian cuatro `f64` en el viewport. + +use lapaloma_render::Rect; + +/// Rango visible en coordenadas de dominio. `f64` porque ejes +/// temporales con epoch ms se desbordan en `f32`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ChartViewport { + pub x_min: f64, + pub x_max: f64, + pub y_min: f64, + pub y_max: f64, +} + +impl ChartViewport { + pub fn new(x_min: f64, x_max: f64, y_min: f64, y_max: f64) -> Self { + debug_assert!(x_max > x_min && y_max > y_min); + Self { x_min, x_max, y_min, y_max } + } + + pub fn x_span(&self) -> f64 { + self.x_max - self.x_min + } + pub fn y_span(&self) -> f64 { + self.y_max - self.y_min + } + + /// Pan en unidades de **dominio**. Suma dx y dy a ambos + /// extremos del rango respectivo. + pub fn pan(&mut self, dx: f64, dy: f64) { + self.x_min += dx; + self.x_max += dx; + self.y_min += dy; + self.y_max += dy; + } + + /// Pan en **píxeles** dado el `plot_rect`. Convierte dx_px → + /// unidades de dominio usando el span actual / ancho del plot. + /// + /// Convención de signos: `dx_px > 0` significa "el mouse se + /// movió a la derecha", que arrastra el viewport a la + /// **izquierda** (los datos parecen ir hacia la derecha). + pub fn pan_pixels(&mut self, dx_px: f32, dy_px: f32, plot: Rect) { + let dx = -(dx_px as f64) * self.x_span() / plot.w as f64; + // En la convención canvas (+Y hacia abajo) pero queremos + // que arrastrar para arriba muestre valores más altos, + // así que también invertimos Y. + let dy = (dy_px as f64) * self.y_span() / plot.h as f64; + self.pan(dx, dy); + } + + /// Zoom anchor-preserving (sección 5.3 del ARCHITECTURE.md). + /// `anchor_norm` es la posición del ancla **normalizada al + /// viewport** en `[0, 1]` por eje (típicamente: la posición + /// del mouse dentro del plot_rect, normalizada). + /// + /// `factor > 1` aleja (zoom out), `< 1` acerca (zoom in). + pub fn zoom_at(&mut self, factor_x: f64, factor_y: f64, anchor_norm: (f64, f64)) { + let (ax, ay) = anchor_norm; + let anchor_x = self.x_min + ax * self.x_span(); + let anchor_y = self.y_min + ay * self.y_span(); + let new_xspan = self.x_span() * factor_x; + let new_yspan = self.y_span() * factor_y; + self.x_min = anchor_x - ax * new_xspan; + self.x_max = self.x_min + new_xspan; + self.y_min = anchor_y - ay * new_yspan; + self.y_max = self.y_min + new_yspan; + } + + /// Zoom uniforme con el mismo factor en X e Y. + pub fn zoom_uniform(&mut self, factor: f64, anchor_norm: (f64, f64)) { + self.zoom_at(factor, factor, anchor_norm); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pan_no_cambia_span() { + let mut v = ChartViewport::new(0.0, 10.0, -1.0, 1.0); + v.pan(2.0, 0.5); + assert!((v.x_min - 2.0).abs() < 1e-9); + assert!((v.x_max - 12.0).abs() < 1e-9); + assert!((v.x_span() - 10.0).abs() < 1e-9); + } + + #[test] + fn zoom_in_preserva_anchor() { + // Zoom in 2× con anchor en el centro: el valor que estaba + // en el centro sigue en el centro. + let mut v = ChartViewport::new(0.0, 10.0, 0.0, 10.0); + v.zoom_uniform(0.5, (0.5, 0.5)); + let new_center_x = v.x_min + v.x_span() * 0.5; + let new_center_y = v.y_min + v.y_span() * 0.5; + assert!((new_center_x - 5.0).abs() < 1e-9); + assert!((new_center_y - 5.0).abs() < 1e-9); + assert!((v.x_span() - 5.0).abs() < 1e-9); + } + + #[test] + fn zoom_anchor_esquina() { + // Anchor en (0,0): la esquina inferior-izquierda no se mueve. + let mut v = ChartViewport::new(0.0, 10.0, 0.0, 10.0); + v.zoom_uniform(0.5, (0.0, 0.0)); + assert!((v.x_min - 0.0).abs() < 1e-9); + assert!((v.y_min - 0.0).abs() < 1e-9); + assert!((v.x_span() - 5.0).abs() < 1e-9); + } + + #[test] + fn pan_pixels_invertido() { + // Plot de 100px ancho, span de dominio 10. Arrastrar 50px + // a la derecha = pan dominio -5. + let mut v = ChartViewport::new(0.0, 10.0, 0.0, 10.0); + v.pan_pixels(50.0, 0.0, Rect::new(0.0, 0.0, 100.0, 100.0)); + assert!((v.x_min - (-5.0)).abs() < 1e-9); + assert!((v.x_max - 5.0).abs() < 1e-9); + } +}