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.
|
//! `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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user