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>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user