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>
This commit is contained in:
sergio
2026-05-13 02:55:15 +00:00
parent 138307c2ba
commit 66d18ab47c
2 changed files with 144 additions and 102 deletions
+74 -58
View File
@@ -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()