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 · 2026-05-13T02:24:30Z (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
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);
+ }
+}