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:
sergio
2026-05-13 02:24:30 +00:00
parent 88051d220a
commit 791ca18d81
4 changed files with 548 additions and 3 deletions
@@ -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);
}
}