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)]
|
#![forbid(unsafe_code)]
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
pub mod viewport {}
|
pub mod viewport;
|
||||||
pub mod coord_system {}
|
pub mod coord_system;
|
||||||
pub mod series {}
|
pub mod series;
|
||||||
|
|
||||||
|
// Pendientes — siguen como placeholders hasta su fase.
|
||||||
pub mod axis {}
|
pub mod axis {}
|
||||||
pub mod picture_cache {}
|
pub mod picture_cache {}
|
||||||
pub mod element {}
|
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