tawasuyu

tawasuyu/brahman

Rust 521TOML 202Markdown 57JSON 197505 Definitions

feat(lapaloma-cartesian): viewport + coord_system + LineSeries con LTTB

- ChartViewport: pan/zoom anchor-preserving en coords de dominio.
- CoordinateSystem: proyección dominio→pixel + project_buffer zero-alloc.
- trait Series + LineSeries que emite una sola stroke_polyline por frame
  (valida P3 del ARCHITECTURE.md). LTTB se dispara cuando data.len()
  excede 3× el ancho del plot.
- hit_test sobre coords sorted-by-X con binary search + threshold 8px.
- 14 tests cubren pan, zoom, projection, downsample y hit-test.

Element GPUI queda para la siguiente fase (requiere pionear paint custom
sobre PaintContext — el monorepo no tiene precedente todavía).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

791ca18d8148cbe2a6eaf7e92eafab53478c3473 · by sergio · (5 months ago) · parent 88051d220 · browse the tree at this commit

Semantic delta · 51

crates/modules/ui_engine/widgets/lapaloma-cartesian/src/coord_system.rs

crates/modules/ui_engine/widgets/lapaloma-cartesian/src/series.rs

crates/modules/ui_engine/widgets/lapaloma-cartesian/src/viewport.rs

4 files · +548 −3

diff --git a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/coord_system.rs b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/coord_system.rs
new file mode 100644
index 0000000..8beefb8
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/coord_system.rs
@@ -0,0 +1,133 @@
+//! `CoordinateSystem` — proyección dominio ↔ pixel.
+//!
+//! Compone `ChartViewport` (qué se ve) + `plot_rect` (dónde, en
+//! píxeles) en una transformación afín. La invocación es
+//! pointwise; no toca los buffers de datos.
+//!
+//! Convención Y: +Y de pantalla apunta abajo; +Y de datos arriba.
+//! La proyección invierte Y para que un valor alto quede arriba.
+
+use crate::viewport::ChartViewport;
+use lapaloma_render::{Point, Rect};
+
+#[derive(Debug, Clone, Copy)]
+pub struct CoordinateSystem {
+    pub viewport: ChartViewport,
+    pub plot: Rect,
+}
+
+impl CoordinateSystem {
+    pub fn new(viewport: ChartViewport, plot: Rect) -> Self {
+        Self { viewport, plot }
+    }
+
+    /// `(value_x, value_y)` → `(pixel_x, pixel_y)`.
+    pub fn data_to_pixel(&self, x: f64, y: f64) -> Point {
+        let nx = (x - self.viewport.x_min) / self.viewport.x_span();
+        let ny = (y - self.viewport.y_min) / self.viewport.y_span();
+        let px = self.plot.x + nx as f32 * self.plot.w;
+        // +Y de datos = arriba → restar de bottom.
+        let py = self.plot.bottom() - ny as f32 * self.plot.h;
+        Point::new(px, py)
+    }
+
+    /// `(pixel_x, pixel_y)` → `(value_x, value_y)`.
+    /// Usado para hit-test y tooltip-on-hover.
+    pub fn pixel_to_data(&self, p: Point) -> (f64, f64) {
+        let nx = ((p.x - self.plot.x) / self.plot.w) as f64;
+        let ny = ((self.plot.bottom() - p.y) / self.plot.h) as f64;
+        let x = self.viewport.x_min + nx * self.viewport.x_span();
+        let y = self.viewport.y_min + ny * self.viewport.y_span();
+        (x, y)
+    }
+
+    /// Proyecta un buffer entero de coords interleaved
+    /// `[x, y, x, y, …]` (en dominio) a `[px, py, px, py, …]`
+    /// (en píxeles), escribiendo a `out` sin allocar.
+    ///
+    /// El caller debe hacer `out.clear()` previo si quiere reuso
+    /// del buffer; este método sólo extiende.
+    pub fn project_buffer(&self, data: &[f32], out: &mut Vec<f32>) {
+        debug_assert!(data.len() % 2 == 0);
+        // Factorizamos para evitar la división por iteración.
+        let sx = self.plot.w / self.viewport.x_span() as f32;
+        let sy = self.plot.h / self.viewport.y_span() as f32;
+        let tx = self.plot.x - self.viewport.x_min as f32 * sx;
+        let ty = self.plot.bottom() + self.viewport.y_min as f32 * sy;
+
+        out.reserve(data.len());
+        let mut i = 0;
+        while i < data.len() {
+            let px = data[i] * sx + tx;
+            let py = ty - data[i + 1] * sy;
+            out.push(px);
+            out.push(py);
+            i += 2;
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn fixture() -> CoordinateSystem {
+        // Viewport [0..10, 0..10] sobre plot de 100×100 en (0, 0).
+        CoordinateSystem::new(
+            ChartViewport::new(0.0, 10.0, 0.0, 10.0),
+            Rect::new(0.0, 0.0, 100.0, 100.0),
+        )
+    }
+
+    #[test]
+    fn data_to_pixel_origen() {
+        let cs = fixture();
+        // (0,0) data → (0, 100) pixel (bottom-left del plot).
+        let p = cs.data_to_pixel(0.0, 0.0);
+        assert!((p.x - 0.0).abs() < 1e-6);
+        assert!((p.y - 100.0).abs() < 1e-6);
+    }
+
+    #[test]
+    fn data_to_pixel_centro() {
+        let cs = fixture();
+        // (5, 5) → (50, 50)
+        let p = cs.data_to_pixel(5.0, 5.0);
+        assert!((p.x - 50.0).abs() < 1e-6);
+        assert!((p.y - 50.0).abs() < 1e-6);
+    }
+
+    #[test]
+    fn data_to_pixel_top_right() {
+        let cs = fixture();
+        // (10, 10) → (100, 0)
+        let p = cs.data_to_pixel(10.0, 10.0);
+        assert!((p.x - 100.0).abs() < 1e-6);
+        assert!((p.y - 0.0).abs() < 1e-6);
+    }
+
+    #[test]
+    fn pixel_to_data_roundtrip() {
+        let cs = fixture();
+        for (x, y) in [(2.5_f64, 7.5), (0.0, 0.0), (10.0, 10.0), (3.14, 1.59)] {
+            let p = cs.data_to_pixel(x, y);
+            let (x2, y2) = cs.pixel_to_data(p);
+            assert!((x - x2).abs() < 1e-4, "x roundtrip: {} vs {}", x, x2);
+            assert!((y - y2).abs() < 1e-4, "y roundtrip: {} vs {}", y, y2);
+        }
+    }
+
+    #[test]
+    fn project_buffer_consistente_con_pointwise() {
+        let cs = fixture();
+        let data: Vec<f32> = vec![0.0, 0.0, 5.0, 5.0, 10.0, 10.0];
+        let mut out = Vec::new();
+        cs.project_buffer(&data, &mut out);
+        assert_eq!(out.len(), data.len());
+        for i in 0..3 {
+            let expected = cs.data_to_pixel(data[i * 2] as f64, data[i * 2 + 1] as f64);
+            assert!((out[i * 2] - expected.x).abs() < 1e-4);
+            assert!((out[i * 2 + 1] - expected.y).abs() < 1e-4);
+        }
+    }
+}
diff --git a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs
index 0f2baa2..c8ce08a 100644
--- a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs
+++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs
@@ -23,9 +23,15 @@
 #![forbid(unsafe_code)]
 #![allow(dead_code)]
 
-pub mod viewport {}
-pub mod coord_system {}
-pub mod series {}
+pub mod viewport;
+pub mod coord_system;
+pub mod series;
+
+// Pendientes — siguen como placeholders hasta su fase.
 pub mod axis {}
 pub mod picture_cache {}
 pub mod element {}
+
+pub use viewport::ChartViewport;
+pub use coord_system::CoordinateSystem;
+pub use series::{LineSeries, PaintCtx, RenderMode, Series};
diff --git a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/series.rs b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/series.rs
new file mode 100644
index 0000000..72d2b2e
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/series.rs
@@ -0,0 +1,276 @@
+//! `Series` — trait que abstrae cualquier dataset visualizable
+//! sobre coordenadas cartesianas, + impl [`LineSeries`].
+//!
+//! La firma es agnóstica de `gpui`: el painter dibuja contra
+//! `lapaloma_render::Canvas`. El Element GPUI envuelve esto y
+//! pasa un adaptador del Canvas trait sobre el PaintContext nativo.
+
+use lapaloma_core::buffer::DataBuffer;
+use lapaloma_core::lttb;
+use lapaloma_render::{Canvas, StrokeStyle};
+
+use crate::coord_system::CoordinateSystem;
+
+/// Hint para la serie sobre el nivel de detalle. A alta densidad
+/// (muchos más puntos que pixeles) el painter saltea decoraciones
+/// y aplica decimación; a baja densidad pinta marcadores y todo.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RenderMode {
+    HighDensity,
+    UiRich,
+}
+
+/// Contexto que la serie recibe en cada `paint`.
+///
+/// Lleva el coord system + el modo + un buffer scratch que el
+/// caller mantiene entre frames para evitar allocations.
+pub struct PaintCtx<'a> {
+    pub cs: CoordinateSystem,
+    pub mode: RenderMode,
+    /// Buffer scratch reusable (compartido entre series del mismo
+    /// chart). El caller hace `clear()` antes de cada serie.
+    pub scratch: &'a mut Vec<f32>,
+}
+
+pub trait Series {
+    fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut dyn Canvas);
+
+    /// Devuelve `Some(point_index)` del sample más cercano al
+    /// pixel pasado, si está dentro del threshold de hit (default
+    /// 8 px). El default impl asume que el data buffer expuesto
+    /// por la serie está sorted por X (caso de [`LineSeries`]).
+    fn hit_test(&self, _pixel: lapaloma_render::Point, _cs: &CoordinateSystem) -> Option<usize> {
+        None
+    }
+}
+
+/// Serie de polilínea simple. Decimación LTTB cuando
+/// `data.len() > 3 × plot_width_px`.
+pub struct LineSeries<'a> {
+    pub data: &'a DataBuffer,
+    pub stroke: StrokeStyle,
+    /// Si `None`, se usa heurística `target = plot_width × 3`.
+    pub lttb_target: Option<usize>,
+}
+
+impl<'a> LineSeries<'a> {
+    pub fn new(data: &'a DataBuffer, stroke: StrokeStyle) -> Self {
+        Self { data, stroke, lttb_target: None }
+    }
+
+    fn effective_target(&self, plot_w: f32) -> usize {
+        self.lttb_target.unwrap_or_else(|| (plot_w as usize).saturating_mul(3))
+    }
+}
+
+impl<'a> Series for LineSeries<'a> {
+    fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut dyn Canvas) {
+        if self.data.len() < 2 {
+            return;
+        }
+
+        let target = self.effective_target(ctx.cs.plot.w);
+
+        ctx.scratch.clear();
+
+        if self.data.len() > target {
+            // Decimar primero (en coords de dominio), proyectar después.
+            let mut idx = Vec::with_capacity(target);
+            lttb::lttb_indices(self.data.coords(), target, &mut idx);
+            let mut decimated: Vec<f32> = Vec::with_capacity(idx.len() * 2);
+            for i in idx {
+                decimated.push(self.data.coords()[i * 2]);
+                decimated.push(self.data.coords()[i * 2 + 1]);
+            }
+            ctx.cs.project_buffer(&decimated, ctx.scratch);
+        } else {
+            ctx.cs.project_buffer(self.data.coords(), ctx.scratch);
+        }
+
+        canvas.stroke_polyline(ctx.scratch, self.stroke);
+    }
+
+    fn hit_test(&self, pixel: lapaloma_render::Point, cs: &CoordinateSystem) -> Option<usize> {
+        let (target_x, _) = cs.pixel_to_data(pixel);
+        let idx = lapaloma_core::spatial::SpatialIndex::new(self.data.coords())
+            .nearest(target_x as f32)?;
+        // Threshold de 8px sobre la distancia en pixeles real,
+        // no sólo la X — evita match cuando el punto está lejos
+        // verticalmente.
+        let (dx, dy) = self.data.xy(idx);
+        let p = cs.data_to_pixel(dx as f64, dy as f64);
+        let dist2 = (p.x - pixel.x).powi(2) + (p.y - pixel.y).powi(2);
+        if dist2 <= 64.0 { Some(idx) } else { None }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::viewport::ChartViewport;
+    use lapaloma_render::{Color, Point, Rect, RenderCmd, RenderPlan};
+
+    /// Canvas mock que captura comandos en un `RenderPlan`.
+    struct Capture {
+        plan: RenderPlan,
+    }
+    impl Capture {
+        fn new() -> Self {
+            Self { plan: RenderPlan::new() }
+        }
+    }
+    impl Canvas for Capture {
+        fn push_clip(&mut self, rect: Rect) {
+            self.plan.push(RenderCmd::PushClip(rect));
+        }
+        fn pop_clip(&mut self) {
+            self.plan.push(RenderCmd::PopClip);
+        }
+        fn fill_rect(&mut self, rect: Rect, color: Color) {
+            self.plan.push(RenderCmd::FillRect { rect, color });
+        }
+        fn stroke_rect(&mut self, rect: Rect, stroke: StrokeStyle) {
+            self.plan.push(RenderCmd::StrokeRect { rect, stroke });
+        }
+        fn stroke_line(&mut self, a: Point, b: Point, stroke: StrokeStyle) {
+            self.plan.push(RenderCmd::StrokeLine { a, b, stroke });
+        }
+        fn stroke_polyline(&mut self, coords: &[f32], stroke: StrokeStyle) {
+            self.plan.push(RenderCmd::StrokePolyline {
+                coords: coords.to_vec(),
+                stroke,
+            });
+        }
+        fn fill_triangle_strip(&mut self, coords: &[f32], colors: &[Color]) {
+            self.plan.push(RenderCmd::FillTriangleStrip {
+                coords: coords.to_vec(),
+                colors: colors.to_vec(),
+            });
+        }
+        fn draw_text(&mut self, p: Point, text: &str, color: Color, size_px: f32) {
+            self.plan.push(RenderCmd::DrawText {
+                p,
+                text: text.into(),
+                color,
+                size_px,
+            });
+        }
+    }
+
+    fn line_series_pinta_polyline() -> (Capture, usize) {
+        let mut buf = DataBuffer::with_capacity(5);
+        for i in 0..5 {
+            buf.push(i as f32, (i as f32).powi(2));
+        }
+        let series = LineSeries::new(&buf, StrokeStyle::new(2.0, Color::WHITE));
+        let cs = CoordinateSystem::new(
+            ChartViewport::new(0.0, 4.0, 0.0, 16.0),
+            Rect::new(0.0, 0.0, 100.0, 100.0),
+        );
+        let mut scratch = Vec::new();
+        let mut ctx = PaintCtx {
+            cs,
+            mode: RenderMode::UiRich,
+            scratch: &mut scratch,
+        };
+        let mut cap = Capture::new();
+        series.paint(&mut ctx, &mut cap);
+
+        let n = cap.plan.cmds.len();
+        (cap, n)
+    }
+
+    #[test]
+    fn line_series_emite_un_solo_drawcall() {
+        let (cap, n) = line_series_pinta_polyline();
+        assert_eq!(n, 1, "una sola draw call (P3 del ARCHITECTURE.md)");
+        match &cap.plan.cmds[0] {
+            RenderCmd::StrokePolyline { coords, .. } => {
+                assert_eq!(coords.len(), 10, "5 puntos × 2 = 10 floats");
+            }
+            other => panic!("se esperaba StrokePolyline, se vio {:?}", other),
+        }
+    }
+
+    #[test]
+    fn lttb_se_dispara_con_alta_densidad() {
+        // 10k puntos sobre plot de 50px → target = 150 → debe decimar
+        let mut buf = DataBuffer::with_capacity(10_000);
+        for i in 0..10_000 {
+            buf.push(i as f32, (i as f32 * 0.01).sin());
+        }
+        let series = LineSeries::new(&buf, StrokeStyle::new(1.0, Color::WHITE));
+        let cs = CoordinateSystem::new(
+            ChartViewport::new(0.0, 9999.0, -1.0, 1.0),
+            Rect::new(0.0, 0.0, 50.0, 100.0),
+        );
+        let mut scratch = Vec::new();
+        let mut ctx = PaintCtx {
+            cs,
+            mode: RenderMode::HighDensity,
+            scratch: &mut scratch,
+        };
+        let mut cap = Capture::new();
+        series.paint(&mut ctx, &mut cap);
+
+        match &cap.plan.cmds[0] {
+            RenderCmd::StrokePolyline { coords, .. } => {
+                // Debe haber muchos menos que 10k puntos (target ≈ 150).
+                assert!(coords.len() / 2 <= 160);
+                assert!(coords.len() / 2 >= 100);
+            }
+            _ => panic!(),
+        }
+    }
+
+    #[test]
+    fn line_series_vacio_no_emite() {
+        let buf = DataBuffer::new();
+        let series = LineSeries::new(&buf, StrokeStyle::new(1.0, Color::WHITE));
+        let cs = CoordinateSystem::new(
+            ChartViewport::new(0.0, 1.0, 0.0, 1.0),
+            Rect::new(0.0, 0.0, 100.0, 100.0),
+        );
+        let mut scratch = Vec::new();
+        let mut ctx = PaintCtx {
+            cs,
+            mode: RenderMode::UiRich,
+            scratch: &mut scratch,
+        };
+        let mut cap = Capture::new();
+        series.paint(&mut ctx, &mut cap);
+        assert_eq!(cap.plan.cmds.len(), 0);
+    }
+
+    #[test]
+    fn hit_test_acepta_punto_cercano() {
+        let mut buf = DataBuffer::with_capacity(5);
+        for i in 0..5 {
+            buf.push(i as f32, (i as f32).powi(2));
+        }
+        let series = LineSeries::new(&buf, StrokeStyle::new(1.0, Color::WHITE));
+        let cs = CoordinateSystem::new(
+            ChartViewport::new(0.0, 4.0, 0.0, 16.0),
+            Rect::new(0.0, 0.0, 100.0, 100.0),
+        );
+        // Punto (2,4) en data → ¿qué pixel? (2/4)·100=50, (1-4/16)·100=75
+        let target = lapaloma_render::Point::new(50.0, 75.0);
+        let hit = series.hit_test(target, &cs);
+        assert_eq!(hit, Some(2));
+    }
+
+    #[test]
+    fn hit_test_rechaza_punto_lejano() {
+        let mut buf = DataBuffer::with_capacity(2);
+        buf.push(0.0, 0.0);
+        buf.push(1.0, 1.0);
+        let series = LineSeries::new(&buf, StrokeStyle::new(1.0, Color::WHITE));
+        let cs = CoordinateSystem::new(
+            ChartViewport::new(0.0, 1.0, 0.0, 1.0),
+            Rect::new(0.0, 0.0, 100.0, 100.0),
+        );
+        // Pixel muy lejos de la línea.
+        let far = lapaloma_render::Point::new(50.0, 99.0);
+        assert!(series.hit_test(far, &cs).is_none());
+    }
+}
diff --git a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/viewport.rs b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/viewport.rs
new file mode 100644
index 0000000..a30451f
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/viewport.rs
@@ -0,0 +1,130 @@
+//! `ChartViewport` — ventana visible en el dominio de datos.
+//!
+//! El viewport NO conoce pixeles. Sólo describe qué rango de
+//! valores X/Y es visible. La proyección a píxeles la hace
+//! [`crate::coord_system::CoordinateSystem`] cuando le pasás
+//! el `plot_rect`.
+//!
+//! Pan y zoom mutan el viewport, no los datos. Esto preserva el
+//! P2 zero-alloc: los buffers de DataBuffer / RingBuffer se quedan
+//! quietos; sólo cambian cuatro `f64` en el viewport.
+
+use lapaloma_render::Rect;
+
+/// Rango visible en coordenadas de dominio. `f64` porque ejes
+/// temporales con epoch ms se desbordan en `f32`.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct ChartViewport {
+    pub x_min: f64,
+    pub x_max: f64,
+    pub y_min: f64,
+    pub y_max: f64,
+}
+
+impl ChartViewport {
+    pub fn new(x_min: f64, x_max: f64, y_min: f64, y_max: f64) -> Self {
+        debug_assert!(x_max > x_min && y_max > y_min);
+        Self { x_min, x_max, y_min, y_max }
+    }
+
+    pub fn x_span(&self) -> f64 {
+        self.x_max - self.x_min
+    }
+    pub fn y_span(&self) -> f64 {
+        self.y_max - self.y_min
+    }
+
+    /// Pan en unidades de **dominio**. Suma dx y dy a ambos
+    /// extremos del rango respectivo.
+    pub fn pan(&mut self, dx: f64, dy: f64) {
+        self.x_min += dx;
+        self.x_max += dx;
+        self.y_min += dy;
+        self.y_max += dy;
+    }
+
+    /// Pan en **píxeles** dado el `plot_rect`. Convierte dx_px →
+    /// unidades de dominio usando el span actual / ancho del plot.
+    ///
+    /// Convención de signos: `dx_px > 0` significa "el mouse se
+    /// movió a la derecha", que arrastra el viewport a la
+    /// **izquierda** (los datos parecen ir hacia la derecha).
+    pub fn pan_pixels(&mut self, dx_px: f32, dy_px: f32, plot: Rect) {
+        let dx = -(dx_px as f64) * self.x_span() / plot.w as f64;
+        // En la convención canvas (+Y hacia abajo) pero queremos
+        // que arrastrar para arriba muestre valores más altos,
+        // así que también invertimos Y.
+        let dy = (dy_px as f64) * self.y_span() / plot.h as f64;
+        self.pan(dx, dy);
+    }
+
+    /// 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
+    /// del mouse dentro del plot_rect, normalizada).
+    ///
+    /// `factor > 1` aleja (zoom out), `< 1` acerca (zoom in).
+    pub fn zoom_at(&mut self, factor_x: f64, factor_y: f64, anchor_norm: (f64, f64)) {
+        let (ax, ay) = anchor_norm;
+        let anchor_x = self.x_min + ax * self.x_span();
+        let anchor_y = self.y_min + ay * self.y_span();
+        let new_xspan = self.x_span() * factor_x;
+        let new_yspan = self.y_span() * factor_y;
+        self.x_min = anchor_x - ax * new_xspan;
+        self.x_max = self.x_min + new_xspan;
+        self.y_min = anchor_y - ay * new_yspan;
+        self.y_max = self.y_min + new_yspan;
+    }
+
+    /// Zoom uniforme con el mismo factor en X e Y.
+    pub fn zoom_uniform(&mut self, factor: f64, anchor_norm: (f64, f64)) {
+        self.zoom_at(factor, factor, anchor_norm);
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn pan_no_cambia_span() {
+        let mut v = ChartViewport::new(0.0, 10.0, -1.0, 1.0);
+        v.pan(2.0, 0.5);
+        assert!((v.x_min - 2.0).abs() < 1e-9);
+        assert!((v.x_max - 12.0).abs() < 1e-9);
+        assert!((v.x_span() - 10.0).abs() < 1e-9);
+    }
+
+    #[test]
+    fn zoom_in_preserva_anchor() {
+        // Zoom in 2× con anchor en el centro: el valor que estaba
+        // en el centro sigue en el centro.
+        let mut v = ChartViewport::new(0.0, 10.0, 0.0, 10.0);
+        v.zoom_uniform(0.5, (0.5, 0.5));
+        let new_center_x = v.x_min + v.x_span() * 0.5;
+        let new_center_y = v.y_min + v.y_span() * 0.5;
+        assert!((new_center_x - 5.0).abs() < 1e-9);
+        assert!((new_center_y - 5.0).abs() < 1e-9);
+        assert!((v.x_span() - 5.0).abs() < 1e-9);
+    }
+
+    #[test]
+    fn zoom_anchor_esquina() {
+        // Anchor en (0,0): la esquina inferior-izquierda no se mueve.
+        let mut v = ChartViewport::new(0.0, 10.0, 0.0, 10.0);
+        v.zoom_uniform(0.5, (0.0, 0.0));
+        assert!((v.x_min - 0.0).abs() < 1e-9);
+        assert!((v.y_min - 0.0).abs() < 1e-9);
+        assert!((v.x_span() - 5.0).abs() < 1e-9);
+    }
+
+    #[test]
+    fn pan_pixels_invertido() {
+        // Plot de 100px ancho, span de dominio 10. Arrastrar 50px
+        // a la derecha = pan dominio -5.
+        let mut v = ChartViewport::new(0.0, 10.0, 0.0, 10.0);
+        v.pan_pixels(50.0, 0.0, Rect::new(0.0, 0.0, 100.0, 100.0));
+        assert!((v.x_min - (-5.0)).abs() < 1e-9);
+        assert!((v.x_max - 5.0).abs() < 1e-9);
+    }
+}