tawasuyu

tawasuyu/brahman

Rust 572TOML 217Markdown 68JSON 198326 Sutichaykuna

⎇ d1ce4c89706fc7c9456d7b28e4b9f98d85a5921a crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs Rust · 479 siq'ikuna · 15.2 KB Ñawpa willay Pi imata llamkarqan ch'uya

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