From 66d18ab47cd9ae7d14a3131aa703cac00de19260 Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 13 May 2026 02:55:15 +0000 Subject: [PATCH] feat(lapaloma-cartesian): multi-series en LapalomaChartElement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Element ahora mantiene Vec 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 --- crates/apps/lapaloma-demo/src/main.rs | 132 ++++++++++-------- .../widgets/lapaloma-cartesian/src/element.rs | 114 +++++++++------ 2 files changed, 144 insertions(+), 102 deletions(-) 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, } -/// 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, @@ -63,14 +48,20 @@ struct DragAnchor { impl Demo { fn new(_cx: &mut Context) -> 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) { - // 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) -> 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` 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; -/// 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. -pub struct LapalomaChartElement { +/// Aproximación del ancho de glifo monoespaciado en función del +/// font size. Suficiente para alinear / decimar labels. +const MONO_GLYPH_RATIO: f32 = 0.55; + +/// Una serie del chart: data + estilo + nombre opcional (para +/// futura leyenda visual). +#[derive(Clone)] +pub struct ChartSeriesItem { pub data: DataBuffer, - pub viewport: ChartViewport, pub stroke: StrokeStyle, + pub name: Option, +} + +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) -> Self { + Self { data, stroke, name: Some(name.into()) } + } +} + +pub struct LapalomaChartElement { + pub series: Vec, + pub viewport: ChartViewport, pub background: Option, 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, } 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, + ) -> 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 = 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()); @@ -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) }