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:
@@ -1,8 +1,17 @@
|
||||
//! `lapaloma-demo` — demo visual mínimo de Lapaloma sobre yahweh.
|
||||
//!
|
||||
//! Levanta una ventana de 900×560 con un único chart cartesiano
|
||||
//! pre-llenado con `sin(x · 0.04)` sobre 1024 muestras. Sirve
|
||||
//! como smoke test de la cadena completa:
|
||||
//! Levanta una ventana 900×560 con un único chart cartesiano
|
||||
//! pre-llenado con `sin(x · 0.04)` sobre 1024 muestras. Pan + zoom
|
||||
//! 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
|
||||
//! DataBuffer (lapaloma-core)
|
||||
@@ -12,13 +21,14 @@
|
||||
//! LineSeries (lapaloma-cartesian)
|
||||
//! ↓ canvas.stroke_polyline (una sola draw call)
|
||||
//! WindowCanvas (lapaloma-render::gpui_backend)
|
||||
//! ↓ paint_path
|
||||
//! ↓ paint_path + paint_glyph
|
||||
//! 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_core::buffer::DataBuffer;
|
||||
@@ -29,24 +39,116 @@ 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", (900., 560.), Demo::new);
|
||||
launch_app("Lapaloma — sin(x) demo (drag = pan, wheel = zoom)", (900., 560.), Demo::new);
|
||||
}
|
||||
|
||||
struct Demo {
|
||||
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 {
|
||||
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);
|
||||
for i in 0..N_SAMPLES {
|
||||
let x = i as f32;
|
||||
let y = (x * FREQ).sin();
|
||||
data.push(x, y);
|
||||
data.push(x, (x * FREQ).sin());
|
||||
}
|
||||
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 {
|
||||
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 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
|
||||
// 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);
|
||||
let drag_active = self.drag.is_some();
|
||||
|
||||
div()
|
||||
.id("lapaloma-demo-root")
|
||||
.size_full()
|
||||
.bg(theme.bg_app.clone())
|
||||
.p(px(16.))
|
||||
@@ -84,10 +181,23 @@ impl Render for Demo {
|
||||
.text_color(theme.fg_muted)
|
||||
.text_size(px(12.))
|
||||
.child(format!(
|
||||
"{} muestras de sin(x · {}). LineSeries · LTTB-on-density · 1 draw call.",
|
||||
N_SAMPLES, FREQ
|
||||
"{} muestras de sin(x · {}). drag = pan · wheel = zoom · double-click = reset · {}",
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// `anchor_norm` es la posición del ancla **normalizada al
|
||||
/// 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_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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user