⎇ d3649bfd1a80153652c2a028315369bb4b184024
crates/modules/ui_engine/widgets/lapaloma-cartesian/src/series.rs
Historia
Quién tocó qué
crudo
feat(lapaloma-cartesian): picture cache pan-blit
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}