tawasuyu

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Definitions

feat(lapaloma-cartesian): multi-series en LapalomaChartElement

- Element ahora mantiene Vec<ChartSeriesItem> con DataBuffer +
  StrokeStyle + nombre opcional por serie. Builder add_series y
  add_series_named.
- En paint(), una pasada por cada serie reusando el mismo scratch.
  N series = N paint_path (no N × por punto). Cumple P3 del
  ARCHITECTURE.md por serie.
- `lapaloma_chart(data, vp, stroke)` queda como helper retrocompat
  para el caso una-serie.
- Demo: 3 series simultáneas (sin, cos, mix) con colores nórdicos
  + leyenda textual en el header.

46 tests verdes.

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

66d18ab47cd9ae7d14a3131aa703cac00de19260 · by sergio · (5 months ago) · parent 138307c2b · browse the tree at this commit

Semantic delta · 18

crates/apps/lapaloma-demo/src/main.rs

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

2 files · +143 −101

diff --git a/crates/apps/lapaloma-demo/src/main.rs b/crates/apps/lapaloma-demo/src/main.rs
index 90810d1..56ccdc8 100644
--- a/crates/apps/lapaloma-demo/src/main.rs
+++ b/crates/apps/lapaloma-demo/src/main.rs
@@ -1,60 +1,45 @@
-//! `lapaloma-demo` — demo visual mínimo de Lapaloma sobre yahweh.
+//! `lapaloma-demo` — demo visual de Lapaloma sobre yahweh.
 //!
-//! Levanta una ventana 900×560 con un único chart cartesiano
-//! pre-llenado con `sin(x · 0.04)` sobre 1024 muestras. Pan + zoom
-//! interactivos:
+//! Ventana 900×560 con un chart cartesiano de **3 series**
+//! simultáneas sobre 1024 muestras:
 //!
-//! - **Click + drag**: pan en X/Y normalizado al tamaño de la
-//!   ventana (vía `ChartViewport::pan_fraction`, sin requerir el
-//!   plot rect exacto).
-//! - **Mouse wheel**: zoom exponencial anchor-preserving en la
-//!   posición del cursor (sección 5.3 / 5.4 del ARCHITECTURE.md).
-//! - **Doble-click**: reset al viewport inicial.
+//! - `sin(x · 0.04)` — azul nórdico
+//! - `cos(x · 0.04)` — naranja
+//! - `0.5·sin(x · 0.02) + 0.5·cos(x · 0.08)` — verde
 //!
-//! Cadena viva:
-//!
-//! ```text
-//! DataBuffer        (lapaloma-core)
-//!   ↓ LTTB cuando densidad > 3× ancho del plot
-//! CoordinateSystem  (lapaloma-cartesian)
-//!   ↓ project_buffer dominio → pixel, zero-alloc
-//! LineSeries        (lapaloma-cartesian)
-//!   ↓ canvas.stroke_polyline (una sola draw call)
-//! WindowCanvas      (lapaloma-render::gpui_backend)
-//!   ↓ paint_path + paint_glyph
-//! gpui::Window
-//! ```
+//! Interacción: click+drag = pan, wheel = zoom, doble-click = reset.
 
 use gpui::{
     div, prelude::*, px, ClickEvent, Context, IntoElement, MouseButton, MouseDownEvent,
     MouseMoveEvent, MouseUpEvent, Point, Render, ScrollDelta, ScrollWheelEvent, Window,
 };
 
-use lapaloma_cartesian::{lapaloma_chart, ChartViewport};
+use lapaloma_cartesian::{ChartViewport, LapalomaChartElement};
 use lapaloma_core::buffer::DataBuffer;
 use lapaloma_render::{Color, StrokeStyle};
 use yahweh_launcher::launch_app;
 use yahweh_theme::Theme;
 
 const N_SAMPLES: usize = 1024;
-const FREQ: f32 = 0.04;
-
-/// Sensibilidad exponencial del wheel zoom (sección 5.4 del doc).
 const WHEEL_SENSITIVITY: f64 = 0.0015;
 
 fn main() {
-    launch_app("Lapaloma — sin(x) demo (drag = pan, wheel = zoom)", (900., 560.), Demo::new);
+    launch_app(
+        "Lapaloma — multi-series (drag = pan, wheel = zoom, dbl-click = reset)",
+        (900., 560.),
+        Demo::new,
+    );
 }
 
 struct Demo {
-    data: DataBuffer,
+    series_sin: DataBuffer,
+    series_cos: DataBuffer,
+    series_mix: DataBuffer,
     viewport: ChartViewport,
     initial_viewport: ChartViewport,
     drag: Option<DragAnchor>,
 }
 
-/// Snapshot del estado al inicio del drag. Mantener una copia del
-/// viewport "al click" evita acumular errores frame a frame.
 #[derive(Clone, Copy)]
 struct DragAnchor {
     start_position: Point<gpui::Pixels>,
@@ -63,14 +48,20 @@ struct DragAnchor {
 
 impl Demo {
     fn new(_cx: &mut Context<Self>) -> Self {
-        let mut data = DataBuffer::with_capacity(N_SAMPLES);
+        let mut sin = DataBuffer::with_capacity(N_SAMPLES);
+        let mut cos = DataBuffer::with_capacity(N_SAMPLES);
+        let mut mix = DataBuffer::with_capacity(N_SAMPLES);
         for i in 0..N_SAMPLES {
             let x = i as f32;
-            data.push(x, (x * FREQ).sin());
+            sin.push(x, (x * 0.04).sin());
+            cos.push(x, (x * 0.04).cos());
+            mix.push(x, 0.5 * (x * 0.02).sin() + 0.5 * (x * 0.08).cos());
         }
-        let viewport = ChartViewport::new(0.0, (N_SAMPLES - 1) as f64, -1.1, 1.1);
+        let viewport = ChartViewport::new(0.0, (N_SAMPLES - 1) as f64, -1.3, 1.3);
         Self {
-            data,
+            series_sin: sin,
+            series_cos: cos,
+            series_mix: mix,
             viewport,
             initial_viewport: viewport,
             drag: None,
@@ -118,31 +109,20 @@ impl Demo {
         if w <= 0.0 || h <= 0.0 {
             return;
         }
-        // dy normalizado a píxeles. ScrollDelta puede venir en
-        // líneas (mouse wheel discreto) o píxeles (trackpad).
         let dy_px: f32 = match e.delta {
             ScrollDelta::Pixels(p) => p.y.into(),
-            ScrollDelta::Lines(p) => {
-                let y: f32 = p.y.into();
-                y * 16.0 // line-height aproximada
-            }
+            ScrollDelta::Lines(p) => p.y * 16.0,
         };
         let factor = (-dy_px as f64 * WHEEL_SENSITIVITY).exp();
-
         let sx: f32 = e.position.x.into();
         let sy: f32 = e.position.y.into();
         let ax = (sx / w).clamp(0.0, 1.0) as f64;
-        // Invertimos Y: dominio crece para arriba, pantalla crece para abajo.
         let ay = (1.0 - sy / h).clamp(0.0, 1.0) as f64;
-
         self.viewport.zoom_uniform(factor, (ax, ay));
         cx.notify();
     }
 
     fn on_click(&mut self, e: &ClickEvent, _w: &mut Window, cx: &mut Context<Self>) {
-        // Reset sólo si es doble (o triple) click de mouse. Click
-        // simple no hace nada; el drag se maneja por
-        // mouse_down/move/up. Clicks de teclado no aplican acá.
         if let ClickEvent::Mouse(m) = e {
             if m.up.click_count >= 2 {
                 self.viewport = self.initial_viewport;
@@ -152,13 +132,34 @@ impl Demo {
     }
 }
 
+/// Color helper para usar el mismo hex tanto en `lapaloma_render`
+/// como en el body de texto del header del demo.
+const COLOR_SIN: u32 = 0x88c0d0; // azul nórdico
+const COLOR_COS: u32 = 0xd08770; // naranja
+const COLOR_MIX: u32 = 0xa3be8c; // verde
+
 impl Render for Demo {
     fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let theme = Theme::global(cx).clone();
 
-        let stroke = StrokeStyle::new(2.0, Color::from_hex(0x88c0d0));
         let plot_bg = Color::rgba(0.10, 0.12, 0.16, 1.0);
-        let chart = lapaloma_chart(self.data.clone(), self.viewport, stroke).background(plot_bg);
+        let chart = LapalomaChartElement::new(self.viewport)
+            .background(plot_bg)
+            .add_series_named(
+                self.series_sin.clone(),
+                StrokeStyle::new(2.0, Color::from_hex(COLOR_SIN)),
+                "sin",
+            )
+            .add_series_named(
+                self.series_cos.clone(),
+                StrokeStyle::new(2.0, Color::from_hex(COLOR_COS)),
+                "cos",
+            )
+            .add_series_named(
+                self.series_mix.clone(),
+                StrokeStyle::new(2.0, Color::from_hex(COLOR_MIX)),
+                "mix",
+            );
 
         let drag_active = self.drag.is_some();
 
@@ -174,18 +175,33 @@ impl Render for Demo {
                 div()
                     .text_color(theme.fg_text)
                     .text_size(px(18.))
-                    .child("Lapaloma — demo cartesian"),
+                    .child("Lapaloma — demo cartesian multi-series"),
             )
             .child(
                 div()
-                    .text_color(theme.fg_muted)
-                    .text_size(px(12.))
-                    .child(format!(
-                        "{} muestras de sin(x · {}). drag = pan · wheel = zoom · double-click = reset · {}",
-                        N_SAMPLES,
-                        FREQ,
-                        if drag_active { "dragging" } else { "idle" },
-                    )),
+                    .flex()
+                    .gap(px(14.))
+                    .text_size(px(11.))
+                    .child(
+                        div()
+                            .text_color(gpui::rgb(COLOR_SIN))
+                            .child("■ sin(x · 0.04)"),
+                    )
+                    .child(
+                        div()
+                            .text_color(gpui::rgb(COLOR_COS))
+                            .child("■ cos(x · 0.04)"),
+                    )
+                    .child(
+                        div()
+                            .text_color(gpui::rgb(COLOR_MIX))
+                            .child("■ ½·sin(x · 0.02) + ½·cos(x · 0.08)"),
+                    )
+                    .child(
+                        div()
+                            .text_color(theme.fg_muted)
+                            .child(if drag_active { "· dragging" } else { "" }),
+                    ),
             )
             .child(
                 div()
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 e53c2f7..e389d7d 100644
--- a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs
+++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs
@@ -1,11 +1,15 @@
 //! `LapalomaChartElement` — el `Element` GPUI que envuelve el
 //! pipeline cartesian.
 //!
-//! Owns un `DataBuffer` y un `ChartViewport`. En `paint()` arma el
-//! `WindowCanvas` adapter de `lapaloma-render` y delega a una
-//! [`LineSeries`]. Dibuja además los ejes (línea base + ticks).
-//! Los labels llegan cuando `draw_text` esté implementado en el
-//! WindowCanvas.
+//! Mantiene un `Vec<ChartSeriesItem>` con `DataBuffer + StrokeStyle`
+//! por serie. En `paint()` arma el `WindowCanvas` adapter, pinta el
+//! background, los ejes con sus labels, y delega a una `LineSeries`
+//! por cada item. Cada serie emite un solo `paint_path` — N series
+//! = N draw calls totales (no N × por punto).
+//!
+//! Todas las series comparten viewport. Para Y axis dual (necesario
+//! cuando series tienen rangos muy distintos) la API se va a
+//! extender con `y_axis_id` por serie y un viewport segundario.
 
 use std::panic;
 
@@ -15,50 +19,61 @@ use gpui::{
 };
 
 use lapaloma_core::buffer::DataBuffer;
-use lapaloma_render::{Canvas, Color, Point, Rect, StrokeStyle, WindowCanvas};
-
 use lapaloma_core::scale::nice_step;
+use lapaloma_render::{Canvas, Color, Point, Rect, StrokeStyle, WindowCanvas};
 
 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;
 
+const TARGET_TICKS_X: usize = 8;
+const TARGET_TICKS_Y: usize = 6;
+
 /// 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á.
+/// font size. Suficiente para alinear / decimar labels.
 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;
+/// Una serie del chart: data + estilo + nombre opcional (para
+/// futura leyenda visual).
+#[derive(Clone)]
+pub struct ChartSeriesItem {
+    pub data: DataBuffer,
+    pub stroke: StrokeStyle,
+    pub name: Option<String>,
+}
+
+impl ChartSeriesItem {
+    pub fn new(data: DataBuffer, stroke: StrokeStyle) -> Self {
+        Self { data, stroke, name: None }
+    }
+
+    pub fn named(data: DataBuffer, stroke: StrokeStyle, name: impl Into<String>) -> Self {
+        Self { data, stroke, name: Some(name.into()) }
+    }
+}
 
-/// Chart cartesiano de una sola serie. Para múltiples series va a
-/// venir un `LapalomaChart` que componga varios `Series` boxed.
 pub struct LapalomaChartElement {
-    pub data: DataBuffer,
+    pub series: Vec<ChartSeriesItem>,
     pub viewport: ChartViewport,
-    pub stroke: StrokeStyle,
     pub background: Option<Color>,
     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 reusable entre series y entre frames.
     scratch: Vec<f32>,
 }
 
 impl LapalomaChartElement {
-    pub fn new(data: DataBuffer, viewport: ChartViewport, stroke: StrokeStyle) -> Self {
+    /// Construye un chart vacío con un viewport. Las series se
+    /// agregan con `add_series` / `add_series_named`.
+    pub fn new(viewport: ChartViewport) -> Self {
         Self {
-            data,
+            series: Vec::new(),
             viewport,
-            stroke,
             background: None,
             axis_color: Color::rgba(0.6, 0.6, 0.65, 0.8),
             axis_style: AxisStyle::default(),
@@ -70,6 +85,21 @@ impl LapalomaChartElement {
         }
     }
 
+    pub fn add_series(mut self, data: DataBuffer, stroke: StrokeStyle) -> Self {
+        self.series.push(ChartSeriesItem::new(data, stroke));
+        self
+    }
+
+    pub fn add_series_named(
+        mut self,
+        data: DataBuffer,
+        stroke: StrokeStyle,
+        name: impl Into<String>,
+    ) -> Self {
+        self.series.push(ChartSeriesItem::named(data, stroke, name));
+        self
+    }
+
     pub fn background(mut self, color: Color) -> Self {
         self.background = Some(color);
         self
@@ -88,8 +118,6 @@ impl LapalomaChartElement {
         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,
@@ -99,7 +127,6 @@ impl LapalomaChartElement {
         )
     }
 
-    /// 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;
@@ -107,7 +134,6 @@ impl LapalomaChartElement {
         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()),
@@ -119,10 +145,9 @@ impl LapalomaChartElement {
             axis_stroke,
         );
 
-        // === X axis ===
+        // X axis ticks + labels.
         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());
@@ -155,7 +180,7 @@ impl LapalomaChartElement {
             );
         }
 
-        // === Y axis ===
+        // Y axis ticks + labels con decimación vertical.
         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;
@@ -171,9 +196,6 @@ impl LapalomaChartElement {
                 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,
@@ -266,22 +288,26 @@ impl Element for LapalomaChartElement {
 
         self.paint_axes(&mut canvas, &cs);
 
-        let series = LineSeries::new(&self.data, self.stroke);
-        self.scratch.clear();
-        let mut ctx = PaintCtx {
-            cs,
-            mode: RenderMode::UiRich,
-            scratch: &mut self.scratch,
-        };
-        series.paint(&mut ctx, &mut canvas);
+        // Una pasada de paint por serie. Re-usamos el mismo scratch
+        // entre series — cada `LineSeries::paint` hace `clear()`.
+        for item in &self.series {
+            let series = LineSeries::new(&item.data, item.stroke);
+            let mut ctx = PaintCtx {
+                cs,
+                mode: RenderMode::UiRich,
+                scratch: &mut self.scratch,
+            };
+            series.paint(&mut ctx, &mut canvas);
+        }
     }
 }
 
-/// Helper builder-style para uso ergonómico desde `Render::render`.
+/// Helper de una sola línea. Para multi-series usar
+/// `LapalomaChartElement::new(viewport).add_series(...).add_series(...)`.
 pub fn lapaloma_chart(
     data: DataBuffer,
     viewport: ChartViewport,
     stroke: StrokeStyle,
 ) -> LapalomaChartElement {
-    LapalomaChartElement::new(data, viewport, stroke)
+    LapalomaChartElement::new(viewport).add_series(data, stroke)
 }