feat(lapaloma-demo): pan + zoom + double-click reset interactivos

- viewport.rs: `pan_fraction(fx, fy)` — pan en fracción del viewport
  independiente del plot_rect. Útil cuando el handler GPUI trabaja
  en coords de window y no conoce el rect interno del chart.
- lapaloma-demo: state machine de drag (DragAnchor con snapshot del
  viewport al click) + handlers on_mouse_down/move/up para pan,
  on_scroll_wheel con sensitivity exponencial 0.0015 para zoom
  anchor-preserving al cursor, on_click con click_count >= 2 para
  reset al viewport inicial. El header muestra estado dragging.
- Maneja ScrollDelta::Pixels (trackpad) y ::Lines (mouse wheel
  tradicional) unificando con line-height 16px.

46 tests verdes en lapaloma-{core,cartesian,render}.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-13 02:52:51 +00:00
parent fd25369715
commit 138307c2ba
2 changed files with 150 additions and 23 deletions
+133 -23
View File
@@ -1,8 +1,17 @@
//! `lapaloma-demo` — demo visual mínimo de Lapaloma sobre yahweh. //! `lapaloma-demo` — demo visual mínimo de Lapaloma sobre yahweh.
//! //!
//! Levanta una ventana de 900×560 con un único chart cartesiano //! Levanta una ventana 900×560 con un único chart cartesiano
//! pre-llenado con `sin(x · 0.04)` sobre 1024 muestras. Sirve //! pre-llenado con `sin(x · 0.04)` sobre 1024 muestras. Pan + zoom
//! como smoke test de la cadena completa: //! interactivos:
//!
//! - **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.
//!
//! Cadena viva:
//! //!
//! ```text //! ```text
//! DataBuffer (lapaloma-core) //! DataBuffer (lapaloma-core)
@@ -12,13 +21,14 @@
//! LineSeries (lapaloma-cartesian) //! LineSeries (lapaloma-cartesian)
//! ↓ canvas.stroke_polyline (una sola draw call) //! ↓ canvas.stroke_polyline (una sola draw call)
//! WindowCanvas (lapaloma-render::gpui_backend) //! WindowCanvas (lapaloma-render::gpui_backend)
//! ↓ paint_path //! ↓ paint_path + paint_glyph
//! gpui::Window //! gpui::Window
//! ``` //! ```
//!
//! Correr con `cargo run -p lapaloma-demo`. Requiere DISPLAY/WAYLAND.
use gpui::{div, prelude::*, px, Context, IntoElement, Render, Window}; 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::{lapaloma_chart, ChartViewport};
use lapaloma_core::buffer::DataBuffer; use lapaloma_core::buffer::DataBuffer;
@@ -29,24 +39,116 @@ use yahweh_theme::Theme;
const N_SAMPLES: usize = 1024; const N_SAMPLES: usize = 1024;
const FREQ: f32 = 0.04; const FREQ: f32 = 0.04;
/// Sensibilidad exponencial del wheel zoom (sección 5.4 del doc).
const WHEEL_SENSITIVITY: f64 = 0.0015;
fn main() { fn main() {
launch_app("Lapaloma — sin(x) demo", (900., 560.), Demo::new); launch_app("Lapaloma — sin(x) demo (drag = pan, wheel = zoom)", (900., 560.), Demo::new);
} }
struct Demo { struct Demo {
data: DataBuffer, data: 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>,
viewport_at_start: ChartViewport,
} }
impl Demo { impl Demo {
fn new(_cx: &mut Context<Self>) -> Self { fn new(_cx: &mut Context<Self>) -> Self {
// Buffer canónico interleaved: x va 0..N-1, y = sin(x · FREQ).
let mut data = DataBuffer::with_capacity(N_SAMPLES); let mut data = DataBuffer::with_capacity(N_SAMPLES);
for i in 0..N_SAMPLES { for i in 0..N_SAMPLES {
let x = i as f32; let x = i as f32;
let y = (x * FREQ).sin(); data.push(x, (x * FREQ).sin());
data.push(x, y); }
let viewport = ChartViewport::new(0.0, (N_SAMPLES - 1) as f64, -1.1, 1.1);
Self {
data,
viewport,
initial_viewport: viewport,
drag: None,
}
}
fn on_mouse_down(&mut self, e: &MouseDownEvent, _w: &mut Window, cx: &mut Context<Self>) {
self.drag = Some(DragAnchor {
start_position: e.position,
viewport_at_start: self.viewport,
});
cx.notify();
}
fn on_mouse_move(&mut self, e: &MouseMoveEvent, window: &mut Window, cx: &mut Context<Self>) {
let Some(anchor) = self.drag else { return };
let win = window.viewport_size();
let w: f32 = win.width.into();
let h: f32 = win.height.into();
if w <= 0.0 || h <= 0.0 {
return;
}
let sx: f32 = e.position.x.into();
let sy: f32 = e.position.y.into();
let ax: f32 = anchor.start_position.x.into();
let ay: f32 = anchor.start_position.y.into();
let dfx = ((sx - ax) / w) as f64;
let dfy = ((sy - ay) / h) as f64;
let mut vp = anchor.viewport_at_start;
vp.pan_fraction(dfx, dfy);
self.viewport = vp;
cx.notify();
}
fn on_mouse_up(&mut self, _e: &MouseUpEvent, _w: &mut Window, cx: &mut Context<Self>) {
if self.drag.take().is_some() {
cx.notify();
}
}
fn on_scroll(&mut self, e: &ScrollWheelEvent, window: &mut Window, cx: &mut Context<Self>) {
let win = window.viewport_size();
let w: f32 = win.width.into();
let h: f32 = win.height.into();
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
}
};
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;
cx.notify();
}
} }
Self { data }
} }
} }
@@ -54,19 +156,14 @@ impl Render for Demo {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::global(cx).clone(); let theme = Theme::global(cx).clone();
// Viewport: ve toda la X, Y con un poco de margen.
let viewport = ChartViewport::new(0.0, (N_SAMPLES - 1) as f64, -1.1, 1.1);
// Estilo: stroke nórdico azul claro sobre fondo del theme.
let stroke = StrokeStyle::new(2.0, Color::from_hex(0x88c0d0)); let stroke = StrokeStyle::new(2.0, Color::from_hex(0x88c0d0));
let plot_bg = Color::rgba(0.10, 0.12, 0.16, 1.0); 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);
// DataBuffer es `Clone`; el Element toma ownership del clone let drag_active = self.drag.is_some();
// por frame. Para datasets enormes el siguiente paso es
// pasar a `Arc<DataBuffer>`; con 1k samples es trivial.
let chart = lapaloma_chart(self.data.clone(), viewport, stroke).background(plot_bg);
div() div()
.id("lapaloma-demo-root")
.size_full() .size_full()
.bg(theme.bg_app.clone()) .bg(theme.bg_app.clone())
.p(px(16.)) .p(px(16.))
@@ -84,10 +181,23 @@ impl Render for Demo {
.text_color(theme.fg_muted) .text_color(theme.fg_muted)
.text_size(px(12.)) .text_size(px(12.))
.child(format!( .child(format!(
"{} muestras de sin(x · {}). LineSeries · LTTB-on-density · 1 draw call.", "{} muestras de sin(x · {}). drag = pan · wheel = zoom · double-click = reset · {}",
N_SAMPLES, FREQ N_SAMPLES,
FREQ,
if drag_active { "dragging" } else { "idle" },
)), )),
) )
.child(div().w_full().flex_grow().child(chart)) .child(
div()
.id("lapaloma-chart-host")
.w_full()
.flex_grow()
.child(chart)
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
.on_mouse_move(cx.listener(Self::on_mouse_move))
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up))
.on_scroll_wheel(cx.listener(Self::on_scroll))
.on_click(cx.listener(Self::on_click)),
)
} }
} }
@@ -58,6 +58,14 @@ impl ChartViewport {
self.pan(dx, dy); self.pan(dx, dy);
} }
/// Pan en **fracción del viewport**. `fx = 0.5` arrastra medio
/// span hacia la izquierda. Útil cuando el caller no conoce el
/// `plot_rect` exacto y trabaja con coords normalizadas
/// (drag dividido por el ancho de la window).
pub fn pan_fraction(&mut self, fx: f64, fy: f64) {
self.pan(-fx * self.x_span(), fy * self.y_span());
}
/// Zoom anchor-preserving (sección 5.3 del ARCHITECTURE.md). /// Zoom anchor-preserving (sección 5.3 del ARCHITECTURE.md).
/// `anchor_norm` es la posición del ancla **normalizada al /// `anchor_norm` es la posición del ancla **normalizada al
/// viewport** en `[0, 1]` por eje (típicamente: la posición /// viewport** en `[0, 1]` por eje (típicamente: la posición
@@ -127,4 +135,13 @@ mod tests {
assert!((v.x_min - (-5.0)).abs() < 1e-9); assert!((v.x_min - (-5.0)).abs() < 1e-9);
assert!((v.x_max - 5.0).abs() < 1e-9); assert!((v.x_max - 5.0).abs() < 1e-9);
} }
#[test]
fn pan_fraction_es_independiente_de_plot() {
let mut v = ChartViewport::new(0.0, 10.0, 0.0, 10.0);
// 50% del span hacia la derecha = viewport se mueve -5 en X.
v.pan_fraction(0.5, 0.0);
assert!((v.x_min - (-5.0)).abs() < 1e-9);
assert!((v.x_max - 5.0).abs() < 1e-9);
}
} }