tawasuyu

tawasuyu/brahman

Rust 326TOML 123JSON 17Markdown 144606 Definiciones

⎇ d3649bfd1a80153652c2a028315369bb4b184024 crates/modules/ui_engine/widgets/lapaloma-cartesian/src/series.rs Rust · 283 líneas · 9.9 KB Historia Quién tocó qué crudo

feat(lapaloma-cartesian): picture cache pan-blit 2b8e990cf · sergio ·

1//! `Series` — trait que abstrae cualquier dataset visualizable
2//! sobre coordenadas cartesianas, + impl [`LineSeries`].
3//!
4//! La firma es agnóstica de `gpui`: el painter dibuja contra
5//! `lapaloma_render::Canvas`. El Element GPUI envuelve esto y
6//! pasa un adaptador del Canvas trait sobre el PaintContext nativo.
7
8use lapaloma_core::buffer::DataBuffer;
9use lapaloma_core::lttb;
10use lapaloma_render::{Canvas, StrokeStyle};
11
12use crate::coord_system::CoordinateSystem;
13
14/// Hint para la serie sobre el nivel de detalle. A alta densidad
15/// (muchos más puntos que pixeles) el painter saltea decoraciones
16/// y aplica decimación; a baja densidad pinta marcadores y todo.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum RenderMode {
19    HighDensity,
20    UiRich,
21}
22
23/// Contexto que la serie recibe en cada `paint`.
24///
25/// Lleva el coord system + el modo + un buffer scratch que el
26/// caller mantiene entre frames para evitar allocations.
27pub struct PaintCtx<'a> {
28    pub cs: CoordinateSystem,
29    pub mode: RenderMode,
30    /// Buffer scratch reusable (compartido entre series del mismo
31    /// chart). El caller hace `clear()` antes de cada serie.
32    pub scratch: &'a mut Vec<f32>,
33}
34
35pub trait Series {
36    fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut dyn Canvas);
37
38    /// Devuelve `Some(point_index)` del sample más cercano al
39    /// pixel pasado, si está dentro del threshold de hit (default
40    /// 8 px). El default impl asume que el data buffer expuesto
41    /// por la serie está sorted por X (caso de [`LineSeries`]).
42    fn hit_test(&self, _pixel: lapaloma_render::Point, _cs: &CoordinateSystem) -> Option<usize> {
43        None
44    }
45}
46
47/// Serie de polilínea simple. Decimación LTTB cuando
48/// `data.len() > 3 × plot_width_px`.
49pub struct LineSeries<'a> {
50    pub data: &'a DataBuffer,
51    pub stroke: StrokeStyle,
52    /// Si `None`, se usa heurística `target = plot_width × 3`.
53    pub lttb_target: Option<usize>,
54}
55
56impl<'a> LineSeries<'a> {
57    pub fn new(data: &'a DataBuffer, stroke: StrokeStyle) -> Self {
58        Self { data, stroke, lttb_target: None }
59    }
60
61    pub fn effective_target(&self, plot_w: f32) -> usize {
62        self.lttb_target.unwrap_or_else(|| (plot_w as usize).saturating_mul(3))
63    }
64
65    /// Materializa las coords proyectadas a pixel space en `out`,
66    /// aplicando LTTB cuando densidad > target. `out` se clearea.
67    ///
68    /// Útil para callers que necesitan cachear el resultado
69    /// (picture cache pan-blit) sin pasar por `paint()`.
70    pub fn compute_projected(&self, cs: &CoordinateSystem, out: &mut Vec<f32>) {
71        out.clear();
72        if self.data.len() < 2 {
73            return;
74        }
75        let target = self.effective_target(cs.plot.w);
76        if self.data.len() > target {
77            let mut idx: Vec<usize> = Vec::with_capacity(target);
78            lttb::lttb_indices(self.data.coords(), target, &mut idx);
79            let mut decimated: Vec<f32> = Vec::with_capacity(idx.len() * 2);
80            for i in idx {
81                decimated.push(self.data.coords()[i * 2]);
82                decimated.push(self.data.coords()[i * 2 + 1]);
83            }
84            cs.project_buffer(&decimated, out);
85        } else {
86            cs.project_buffer(self.data.coords(), out);
87        }
88    }
89}
90
91impl<'a> Series for LineSeries<'a> {
92    fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut dyn Canvas) {
93        self.compute_projected(&ctx.cs, ctx.scratch);
94        if ctx.scratch.len() < 4 {
95            return;
96        }
97        canvas.stroke_polyline(ctx.scratch, self.stroke);
98    }
99
100    fn hit_test(&self, pixel: lapaloma_render::Point, cs: &CoordinateSystem) -> Option<usize> {
101        let (target_x, _) = cs.pixel_to_data(pixel);
102        let idx = lapaloma_core::spatial::SpatialIndex::new(self.data.coords())
103            .nearest(target_x as f32)?;
104        // Threshold de 8px sobre la distancia en pixeles real,
105        // no sólo la X — evita match cuando el punto está lejos
106        // verticalmente.
107        let (dx, dy) = self.data.xy(idx);
108        let p = cs.data_to_pixel(dx as f64, dy as f64);
109        let dist2 = (p.x - pixel.x).powi(2) + (p.y - pixel.y).powi(2);
110        if dist2 <= 64.0 { Some(idx) } else { None }
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::viewport::ChartViewport;
118    use lapaloma_render::{Color, Point, Rect, RenderCmd, RenderPlan};
119
120    /// Canvas mock que captura comandos en un `RenderPlan`.
121    struct Capture {
122        plan: RenderPlan,
123    }
124    impl Capture {
125        fn new() -> Self {
126            Self { plan: RenderPlan::new() }
127        }
128    }
129    impl Canvas for Capture {
130        fn push_clip(&mut self, rect: Rect) {
131            self.plan.push(RenderCmd::PushClip(rect));
132        }
133        fn pop_clip(&mut self) {
134            self.plan.push(RenderCmd::PopClip);
135        }
136        fn fill_rect(&mut self, rect: Rect, color: Color) {
137            self.plan.push(RenderCmd::FillRect { rect, color });
138        }
139        fn stroke_rect(&mut self, rect: Rect, stroke: StrokeStyle) {
140            self.plan.push(RenderCmd::StrokeRect { rect, stroke });
141        }
142        fn stroke_line(&mut self, a: Point, b: Point, stroke: StrokeStyle) {
143            self.plan.push(RenderCmd::StrokeLine { a, b, stroke });
144        }
145        fn stroke_polyline(&mut self, coords: &[f32], stroke: StrokeStyle) {
146            self.plan.push(RenderCmd::StrokePolyline {
147                coords: coords.to_vec(),
148                stroke,
149            });
150        }
151        fn fill_triangle_strip(&mut self, coords: &[f32], colors: &[Color]) {
152            self.plan.push(RenderCmd::FillTriangleStrip {
153                coords: coords.to_vec(),
154                colors: colors.to_vec(),
155            });
156        }
157        fn draw_text(&mut self, p: Point, text: &str, color: Color, size_px: f32) {
158            self.plan.push(RenderCmd::DrawText {
159                p,
160                text: text.into(),
161                color,
162                size_px,
163            });
164        }
165    }
166
167    fn line_series_pinta_polyline() -> (Capture, usize) {
168        let mut buf = DataBuffer::with_capacity(5);
169        for i in 0..5 {
170            buf.push(i as f32, (i as f32).powi(2));
171        }
172        let series = LineSeries::new(&buf, StrokeStyle::new(2.0, Color::WHITE));
173        let cs = CoordinateSystem::new(
174            ChartViewport::new(0.0, 4.0, 0.0, 16.0),
175            Rect::new(0.0, 0.0, 100.0, 100.0),
176        );
177        let mut scratch = Vec::new();
178        let mut ctx = PaintCtx {
179            cs,
180            mode: RenderMode::UiRich,
181            scratch: &mut scratch,
182        };
183        let mut cap = Capture::new();
184        series.paint(&mut ctx, &mut cap);
185
186        let n = cap.plan.cmds.len();
187        (cap, n)
188    }
189
190    #[test]
191    fn line_series_emite_un_solo_drawcall() {
192        let (cap, n) = line_series_pinta_polyline();
193        assert_eq!(n, 1, "una sola draw call (P3 del ARCHITECTURE.md)");
194        match &cap.plan.cmds[0] {
195            RenderCmd::StrokePolyline { coords, .. } => {
196                assert_eq!(coords.len(), 10, "5 puntos × 2 = 10 floats");
197            }
198            other => panic!("se esperaba StrokePolyline, se vio {:?}", other),
199        }
200    }
201
202    #[test]
203    fn lttb_se_dispara_con_alta_densidad() {
204        // 10k puntos sobre plot de 50px → target = 150 → debe decimar
205        let mut buf = DataBuffer::with_capacity(10_000);
206        for i in 0..10_000 {
207            buf.push(i as f32, (i as f32 * 0.01).sin());
208        }
209        let series = LineSeries::new(&buf, StrokeStyle::new(1.0, Color::WHITE));
210        let cs = CoordinateSystem::new(
211            ChartViewport::new(0.0, 9999.0, -1.0, 1.0),
212            Rect::new(0.0, 0.0, 50.0, 100.0),
213        );
214        let mut scratch = Vec::new();
215        let mut ctx = PaintCtx {
216            cs,
217            mode: RenderMode::HighDensity,
218            scratch: &mut scratch,
219        };
220        let mut cap = Capture::new();
221        series.paint(&mut ctx, &mut cap);
222
223        match &cap.plan.cmds[0] {
224            RenderCmd::StrokePolyline { coords, .. } => {
225                // Debe haber muchos menos que 10k puntos (target ≈ 150).
226                assert!(coords.len() / 2 <= 160);
227                assert!(coords.len() / 2 >= 100);
228            }
229            _ => panic!(),
230        }
231    }
232
233    #[test]
234    fn line_series_vacio_no_emite() {
235        let buf = DataBuffer::new();
236        let series = LineSeries::new(&buf, StrokeStyle::new(1.0, Color::WHITE));
237        let cs = CoordinateSystem::new(
238            ChartViewport::new(0.0, 1.0, 0.0, 1.0),
239            Rect::new(0.0, 0.0, 100.0, 100.0),
240        );
241        let mut scratch = Vec::new();
242        let mut ctx = PaintCtx {
243            cs,
244            mode: RenderMode::UiRich,
245            scratch: &mut scratch,
246        };
247        let mut cap = Capture::new();
248        series.paint(&mut ctx, &mut cap);
249        assert_eq!(cap.plan.cmds.len(), 0);
250    }
251
252    #[test]
253    fn hit_test_acepta_punto_cercano() {
254        let mut buf = DataBuffer::with_capacity(5);
255        for i in 0..5 {
256            buf.push(i as f32, (i as f32).powi(2));
257        }
258        let series = LineSeries::new(&buf, StrokeStyle::new(1.0, Color::WHITE));
259        let cs = CoordinateSystem::new(
260            ChartViewport::new(0.0, 4.0, 0.0, 16.0),
261            Rect::new(0.0, 0.0, 100.0, 100.0),
262        );
263        // Punto (2,4) en data → ¿qué pixel? (2/4)·100=50, (1-4/16)·100=75
264        let target = lapaloma_render::Point::new(50.0, 75.0);
265        let hit = series.hit_test(target, &cs);
266        assert_eq!(hit, Some(2));
267    }
268
269    #[test]
270    fn hit_test_rechaza_punto_lejano() {
271        let mut buf = DataBuffer::with_capacity(2);
272        buf.push(0.0, 0.0);
273        buf.push(1.0, 1.0);
274        let series = LineSeries::new(&buf, StrokeStyle::new(1.0, Color::WHITE));
275        let cs = CoordinateSystem::new(
276            ChartViewport::new(0.0, 1.0, 0.0, 1.0),
277            Rect::new(0.0, 0.0, 100.0, 100.0),
278        );
279        // Pixel muy lejos de la línea.
280        let far = lapaloma_render::Point::new(50.0, 99.0);
281        assert!(series.hit_test(far, &cs).is_none());
282    }
283}