tawasuyu

tawasuyu/brahman

Rust 332TOML 127JSON 17Markdown 154710 Definitions

⎇ 2b8e990cf9df64441ba021f2e9fa80b01ae9544d crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs Rust · 557 lines · 18.4 KB History Who touched what raw

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

1//! `LapalomaChartElement` — el `Element` GPUI que envuelve el
2//! pipeline cartesian.
3//!
4//! ## Picture cache pan-blit
5//!
6//! En GPUI cada frame se construye un Element nuevo (el árbol se
7//! recrea), así que el cache no puede vivir en el Element. El
8//! caller crea un `ChartCacheHandle` (Arc<Mutex<ChartCache>>) una
9//! vez y se lo pasa a cada frame.
10//!
11//! Algoritmo (sección 4.4 del ARCHITECTURE.md adaptada a GPUI):
12//! - Hash estructural = plot rect + span (no x_min/y_min) + por
13//!   serie: revision + len + stroke.
14//! - Si hash igual al cached: **pan puro** → emitimos las coords
15//!   cacheadas con un offset `(dx_px, dy_px)` calculado del
16//!   diff `viewport.x_min - cached.x_min`. Saltea LTTB +
17//!   projection.
18//! - Si hash distinto: full rebuild. Re-corre LTTB + project,
19//!   pisa el cache, actualiza el snapshot del viewport.
20//!
21//! Sin cache, el Element funciona igual: cada frame rebuild
22//! completo. Útil para tests/smoke.
23
24use std::collections::hash_map::DefaultHasher;
25use std::hash::{Hash, Hasher};
26use std::panic;
27use std::sync::{Arc, Mutex};
28
29use gpui::{
30    App, Bounds, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, LayoutId,
31    Pixels, Style, Window,
32};
33
34use lapaloma_core::buffer::DataBuffer;
35use lapaloma_core::scale::nice_step;
36use lapaloma_render::{Canvas, Color, Point, Rect, StrokeStyle, WindowCanvas};
37
38use crate::axis::{decimate_labels, format_tick, ticks_nice, AxisStyle};
39use crate::coord_system::CoordinateSystem;
40use crate::series::LineSeries;
41use crate::viewport::ChartViewport;
42
43const TARGET_TICKS_X: usize = 8;
44const TARGET_TICKS_Y: usize = 6;
45const MONO_GLYPH_RATIO: f32 = 0.55;
46
47/// Cache de coords proyectadas para reuso entre frames. Es lo
48/// que habilita el pan-blit: el caller lo crea una vez y lo
49/// pasa por handle.
50#[derive(Default, Debug)]
51pub struct ChartCache {
52    /// Coords proyectadas por serie. `projected.len()` debe coincidir
53    /// con la cantidad de series del Element.
54    projected: Vec<Vec<f32>>,
55    /// Hash de la geometría + identidades de data. Si cambia,
56    /// invalidamos.
57    structural_hash: u64,
58    /// `viewport.x_min` con el que se proyectaron las coords.
59    cached_x_min: f64,
60    cached_y_min: f64,
61    /// Estadística informativa: cuántos pan-blits desde el último
62    /// rebuild. Útil para debugging y para mostrar en demos.
63    pan_blits: u64,
64    /// Estadística informativa: cuántos rebuilds totales.
65    rebuilds: u64,
66    has_valid_cache: bool,
67}
68
69impl ChartCache {
70    pub fn new() -> Self {
71        Self::default()
72    }
73    pub fn pan_blits(&self) -> u64 {
74        self.pan_blits
75    }
76    pub fn rebuilds(&self) -> u64 {
77        self.rebuilds
78    }
79    pub fn invalidate(&mut self) {
80        *self = Self::default();
81    }
82}
83
84pub type ChartCacheHandle = Arc<Mutex<ChartCache>>;
85
86/// Atajo para crear un cache compartido. El caller lo guarda en
87/// su `Render` host y le pasa el clone al Element en cada frame.
88pub fn chart_cache() -> ChartCacheHandle {
89    Arc::new(Mutex::new(ChartCache::new()))
90}
91
92#[derive(Clone)]
93pub struct ChartSeriesItem {
94    pub data: DataBuffer,
95    pub stroke: StrokeStyle,
96    pub name: Option<String>,
97}
98
99impl ChartSeriesItem {
100    pub fn new(data: DataBuffer, stroke: StrokeStyle) -> Self {
101        Self { data, stroke, name: None }
102    }
103    pub fn named(data: DataBuffer, stroke: StrokeStyle, name: impl Into<String>) -> Self {
104        Self { data, stroke, name: Some(name.into()) }
105    }
106}
107
108pub struct LapalomaChartElement {
109    pub series: Vec<ChartSeriesItem>,
110    pub viewport: ChartViewport,
111    pub background: Option<Color>,
112    pub axis_color: Color,
113    pub axis_style: AxisStyle,
114    pub margin_bottom: f32,
115    pub margin_left: f32,
116    pub margin_top: f32,
117    pub margin_right: f32,
118    /// Cache opcional compartido con el `Render` host. Si está
119    /// presente, habilita pan-blit.
120    pub cache: Option<ChartCacheHandle>,
121    scratch: Vec<f32>,
122}
123
124impl LapalomaChartElement {
125    pub fn new(viewport: ChartViewport) -> Self {
126        Self {
127            series: Vec::new(),
128            viewport,
129            background: None,
130            axis_color: Color::rgba(0.6, 0.6, 0.65, 0.8),
131            axis_style: AxisStyle::default(),
132            margin_bottom: 24.0,
133            margin_left: 32.0,
134            margin_top: 8.0,
135            margin_right: 8.0,
136            cache: None,
137            scratch: Vec::new(),
138        }
139    }
140
141    pub fn add_series(mut self, data: DataBuffer, stroke: StrokeStyle) -> Self {
142        self.series.push(ChartSeriesItem::new(data, stroke));
143        self
144    }
145
146    pub fn add_series_named(
147        mut self,
148        data: DataBuffer,
149        stroke: StrokeStyle,
150        name: impl Into<String>,
151    ) -> Self {
152        self.series.push(ChartSeriesItem::named(data, stroke, name));
153        self
154    }
155
156    pub fn background(mut self, color: Color) -> Self {
157        self.background = Some(color);
158        self
159    }
160
161    pub fn axis_color(mut self, color: Color) -> Self {
162        self.axis_color = color;
163        self
164    }
165
166    pub fn margins(mut self, top: f32, right: f32, bottom: f32, left: f32) -> Self {
167        self.margin_top = top;
168        self.margin_right = right;
169        self.margin_bottom = bottom;
170        self.margin_left = left;
171        self
172    }
173
174    /// Enchufa un cache compartido. Sin esto, cada frame es rebuild
175    /// completo (correcto pero sin la optimización pan-blit).
176    pub fn with_cache(mut self, cache: ChartCacheHandle) -> Self {
177        self.cache = Some(cache);
178        self
179    }
180
181    fn plot_rect(&self, bounds: Rect) -> Rect {
182        Rect::new(
183            bounds.x + self.margin_left,
184            bounds.y + self.margin_top,
185            (bounds.w - self.margin_left - self.margin_right).max(1.0),
186            (bounds.h - self.margin_top - self.margin_bottom).max(1.0),
187        )
188    }
189
190    fn paint_axes(&self, canvas: &mut dyn Canvas, cs: &CoordinateSystem) {
191        let plot = cs.plot;
192        let style = self.axis_style;
193        let axis_stroke = StrokeStyle::new(style.axis_line_width_px, self.axis_color);
194        let tick_stroke = StrokeStyle::new(style.tick_width_px, self.axis_color);
195        let tlen = style.tick_length_px;
196
197        canvas.stroke_line(
198            Point::new(plot.x, plot.bottom()),
199            Point::new(plot.right(), plot.bottom()),
200            axis_stroke,
201        );
202        canvas.stroke_line(
203            Point::new(plot.x, plot.y),
204            Point::new(plot.x, plot.bottom()),
205            axis_stroke,
206        );
207
208        let x_ticks = ticks_nice(self.viewport.x_min, self.viewport.x_max, TARGET_TICKS_X);
209        let x_step = nice_step(self.viewport.x_min, self.viewport.x_max, TARGET_TICKS_X);
210        let mut x_pos: Vec<f32> = Vec::with_capacity(x_ticks.len());
211        let mut x_lbl: Vec<String> = Vec::with_capacity(x_ticks.len());
212        let mut x_widths: Vec<f32> = Vec::with_capacity(x_ticks.len());
213        for v in &x_ticks {
214            let pixel = cs.data_to_pixel(*v, self.viewport.y_min).x;
215            if pixel < plot.x - 0.5 || pixel > plot.right() + 0.5 {
216                continue;
217            }
218            canvas.stroke_line(
219                Point::new(pixel, plot.bottom()),
220                Point::new(pixel, plot.bottom() + tlen),
221                tick_stroke,
222            );
223            let lbl = format_tick(*v, x_step);
224            let w = lbl.len() as f32 * style.label_size_px * MONO_GLYPH_RATIO;
225            x_pos.push(pixel);
226            x_widths.push(w);
227            x_lbl.push(lbl);
228        }
229        let keep_x = decimate_labels(&x_pos, &x_widths, style.label_min_spacing_px);
230        for i in keep_x {
231            let half = x_widths[i] * 0.5;
232            let origin_x = x_pos[i] - half;
233            let origin_y = plot.bottom() + tlen + style.label_offset_px;
234            canvas.draw_text(
235                Point::new(origin_x, origin_y),
236                &x_lbl[i],
237                self.axis_color,
238                style.label_size_px,
239            );
240        }
241
242        let y_ticks = ticks_nice(self.viewport.y_min, self.viewport.y_max, TARGET_TICKS_Y);
243        let y_step = nice_step(self.viewport.y_min, self.viewport.y_max, TARGET_TICKS_Y);
244        let y_label_pitch = style.label_size_px + style.label_min_spacing_px;
245        let mut prev_py: Option<f32> = None;
246
247        for v in &y_ticks {
248            let py = cs.data_to_pixel(self.viewport.x_min, *v).y;
249            if py < plot.y - 0.5 || py > plot.bottom() + 0.5 {
250                continue;
251            }
252            canvas.stroke_line(
253                Point::new(plot.x - tlen, py),
254                Point::new(plot.x, py),
255                tick_stroke,
256            );
257            let label_ok = match prev_py {
258                None => true,
259                Some(p) => (py - p).abs() >= y_label_pitch,
260            };
261            if !label_ok {
262                continue;
263            }
264            let lbl = format_tick(*v, y_step);
265            let w = lbl.len() as f32 * style.label_size_px * MONO_GLYPH_RATIO;
266            let origin_x = plot.x - tlen - style.label_offset_px - w;
267            let origin_y = py - style.label_size_px * 0.5;
268            canvas.draw_text(
269                Point::new(origin_x, origin_y),
270                &lbl,
271                self.axis_color,
272                style.label_size_px,
273            );
274            prev_py = Some(py);
275        }
276    }
277
278    /// Rebuild full: LTTB + projection por serie. Pinta directo desde
279    /// el cache si está enchufado (para no copiar dos veces).
280    fn rebuild_and_paint(&mut self, cs: &CoordinateSystem, canvas: &mut dyn Canvas) {
281        if let Some(handle) = self.cache.clone() {
282            let mut cache = handle.lock().unwrap();
283            cache.projected.clear();
284            cache.projected.resize_with(self.series.len(), Vec::new);
285            for (i, item) in self.series.iter().enumerate() {
286                let series = LineSeries::new(&item.data, item.stroke);
287                series.compute_projected(cs, &mut cache.projected[i]);
288                if cache.projected[i].len() >= 4 {
289                    canvas.stroke_polyline(&cache.projected[i], item.stroke);
290                }
291            }
292            cache.structural_hash = structural_hash(
293                cs.plot,
294                self.viewport.x_span(),
295                self.viewport.y_span(),
296                &self.series,
297            );
298            cache.cached_x_min = self.viewport.x_min;
299            cache.cached_y_min = self.viewport.y_min;
300            cache.has_valid_cache = true;
301            cache.pan_blits = 0;
302            cache.rebuilds = cache.rebuilds.wrapping_add(1);
303        } else {
304            // Sin cache: usamos el scratch local.
305            for item in &self.series {
306                let series = LineSeries::new(&item.data, item.stroke);
307                series.compute_projected(cs, &mut self.scratch);
308                if self.scratch.len() >= 4 {
309                    canvas.stroke_polyline(&self.scratch, item.stroke);
310                }
311            }
312        }
313    }
314
315    /// Emite las coords cacheadas con un offset en pixel space.
316    /// Se usa cuando detectamos pan puro (mismo hash estructural).
317    fn pan_blit_paint(&mut self, plot: Rect, canvas: &mut dyn Canvas) {
318        let Some(handle) = self.cache.clone() else {
319            return;
320        };
321        let mut cache = handle.lock().unwrap();
322        let dx_px = ((cache.cached_x_min - self.viewport.x_min) * plot.w as f64
323            / self.viewport.x_span()) as f32;
324        let dy_px = ((self.viewport.y_min - cache.cached_y_min) * plot.h as f64
325            / self.viewport.y_span()) as f32;
326
327        for (i, item) in self.series.iter().enumerate() {
328            let cached = &cache.projected[i];
329            if cached.len() < 4 {
330                continue;
331            }
332            self.scratch.clear();
333            self.scratch.reserve(cached.len());
334            let mut k = 0;
335            while k + 1 < cached.len() {
336                self.scratch.push(cached[k] + dx_px);
337                self.scratch.push(cached[k + 1] + dy_px);
338                k += 2;
339            }
340            canvas.stroke_polyline(&self.scratch, item.stroke);
341        }
342        cache.pan_blits = cache.pan_blits.wrapping_add(1);
343    }
344}
345
346impl IntoElement for LapalomaChartElement {
347    type Element = Self;
348    fn into_element(self) -> Self::Element {
349        self
350    }
351}
352
353impl Element for LapalomaChartElement {
354    type RequestLayoutState = ();
355    type PrepaintState = ();
356
357    fn id(&self) -> Option<ElementId> {
358        None
359    }
360    fn source_location(&self) -> Option<&'static panic::Location<'static>> {
361        None
362    }
363
364    fn request_layout(
365        &mut self,
366        _id: Option<&GlobalElementId>,
367        _inspector_id: Option<&InspectorElementId>,
368        window: &mut Window,
369        cx: &mut App,
370    ) -> (LayoutId, Self::RequestLayoutState) {
371        let mut style = Style::default();
372        style.size.width = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0));
373        style.size.height = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0));
374        let id = window.request_layout(style, [], cx);
375        (id, ())
376    }
377
378    fn prepaint(
379        &mut self,
380        _id: Option<&GlobalElementId>,
381        _inspector_id: Option<&InspectorElementId>,
382        _bounds: Bounds<Pixels>,
383        _request_layout: &mut Self::RequestLayoutState,
384        _window: &mut Window,
385        _cx: &mut App,
386    ) -> Self::PrepaintState {
387    }
388
389    fn paint(
390        &mut self,
391        _id: Option<&GlobalElementId>,
392        _inspector_id: Option<&InspectorElementId>,
393        bounds: Bounds<Pixels>,
394        _request_layout: &mut Self::RequestLayoutState,
395        _prepaint: &mut Self::PrepaintState,
396        window: &mut Window,
397        _cx: &mut App,
398    ) {
399        let ox: f32 = bounds.origin.x.into();
400        let oy: f32 = bounds.origin.y.into();
401        let w: f32 = bounds.size.width.into();
402        let h: f32 = bounds.size.height.into();
403        let outer = Rect::new(ox, oy, w, h);
404        let plot = self.plot_rect(outer);
405
406        let cs = CoordinateSystem::new(self.viewport, plot);
407        let mut canvas = WindowCanvas::new(window);
408
409        if let Some(bg) = self.background {
410            canvas.fill_rect(outer, bg);
411        }
412
413        self.paint_axes(&mut canvas, &cs);
414
415        // Decide rebuild vs pan-blit.
416        let current_hash = structural_hash(
417            plot,
418            self.viewport.x_span(),
419            self.viewport.y_span(),
420            &self.series,
421        );
422        let pan_only = self
423            .cache
424            .as_ref()
425            .map(|h| {
426                let c = h.lock().unwrap();
427                c.has_valid_cache
428                    && c.structural_hash == current_hash
429                    && c.projected.len() == self.series.len()
430            })
431            .unwrap_or(false);
432
433        if pan_only {
434            self.pan_blit_paint(plot, &mut canvas);
435        } else {
436            self.rebuild_and_paint(&cs, &mut canvas);
437        }
438    }
439}
440
441/// Hash de la geometría + identidades de data. Lo que NO va acá:
442/// `viewport.x_min` y `y_min` (el pan los mueve sin invalidar).
443fn structural_hash(
444    plot: Rect,
445    x_span: f64,
446    y_span: f64,
447    series: &[ChartSeriesItem],
448) -> u64 {
449    let mut h = DefaultHasher::new();
450    plot.x.to_bits().hash(&mut h);
451    plot.y.to_bits().hash(&mut h);
452    plot.w.to_bits().hash(&mut h);
453    plot.h.to_bits().hash(&mut h);
454    x_span.to_bits().hash(&mut h);
455    y_span.to_bits().hash(&mut h);
456    (series.len() as u64).hash(&mut h);
457    for s in series {
458        s.data.revision().hash(&mut h);
459        (s.data.len() as u64).hash(&mut h);
460        s.stroke.width.to_bits().hash(&mut h);
461        s.stroke.color.r.to_bits().hash(&mut h);
462        s.stroke.color.g.to_bits().hash(&mut h);
463        s.stroke.color.b.to_bits().hash(&mut h);
464        s.stroke.color.a.to_bits().hash(&mut h);
465    }
466    h.finish()
467}
468
469pub fn lapaloma_chart(
470    data: DataBuffer,
471    viewport: ChartViewport,
472    stroke: StrokeStyle,
473) -> LapalomaChartElement {
474    LapalomaChartElement::new(viewport).add_series(data, stroke)
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    #[test]
482    fn structural_hash_estable_para_mismo_estado() {
483        let mut data = DataBuffer::with_capacity(10);
484        for i in 0..10 {
485            data.push(i as f32, (i as f32).sin());
486        }
487        let series = vec![ChartSeriesItem::new(
488            data,
489            StrokeStyle::new(2.0, Color::rgb(1.0, 0.0, 0.0)),
490        )];
491        let plot = Rect::new(0.0, 0.0, 100.0, 100.0);
492        let a = structural_hash(plot, 10.0, 2.0, &series);
493        let b = structural_hash(plot, 10.0, 2.0, &series);
494        assert_eq!(a, b);
495    }
496
497    #[test]
498    fn structural_hash_pan_no_cambia() {
499        // Mismo span pero distinto x_min/y_min — hash igual.
500        let mut data = DataBuffer::with_capacity(10);
501        for i in 0..10 {
502            data.push(i as f32, (i as f32).sin());
503        }
504        let series = vec![ChartSeriesItem::new(
505            data,
506            StrokeStyle::new(2.0, Color::rgb(1.0, 0.0, 0.0)),
507        )];
508        let plot = Rect::new(0.0, 0.0, 100.0, 100.0);
509        let a = structural_hash(plot, 10.0, 2.0, &series);
510        // Pan implícito: x_span/y_span no cambiaron → hash igual.
511        let b = structural_hash(plot, 10.0, 2.0, &series);
512        assert_eq!(a, b);
513    }
514
515    #[test]
516    fn structural_hash_zoom_invalida() {
517        let series = vec![ChartSeriesItem::new(
518            DataBuffer::new(),
519            StrokeStyle::new(2.0, Color::WHITE),
520        )];
521        let plot = Rect::new(0.0, 0.0, 100.0, 100.0);
522        let a = structural_hash(plot, 10.0, 2.0, &series);
523        let b = structural_hash(plot, 5.0, 2.0, &series); // zoom in X
524        assert_ne!(a, b);
525    }
526
527    #[test]
528    fn structural_hash_data_revision_invalida() {
529        let mut data = DataBuffer::with_capacity(2);
530        data.push(0.0, 0.0);
531        let series0 = vec![ChartSeriesItem::new(
532            data.clone(),
533            StrokeStyle::new(2.0, Color::WHITE),
534        )];
535        let plot = Rect::new(0.0, 0.0, 100.0, 100.0);
536        let a = structural_hash(plot, 1.0, 1.0, &series0);
537
538        data.push(1.0, 1.0); // bump revision
539        let series1 = vec![ChartSeriesItem::new(
540            data,
541            StrokeStyle::new(2.0, Color::WHITE),
542        )];
543        let b = structural_hash(plot, 1.0, 1.0, &series1);
544        assert_ne!(a, b);
545    }
546
547    #[test]
548    fn structural_hash_plot_rect_invalida() {
549        let series = vec![ChartSeriesItem::new(
550            DataBuffer::new(),
551            StrokeStyle::new(2.0, Color::WHITE),
552        )];
553        let a = structural_hash(Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, 1.0, &series);
554        let b = structural_hash(Rect::new(0.0, 0.0, 200.0, 100.0), 1.0, 1.0, &series);
555        assert_ne!(a, b);
556    }
557}