⎇ 2b8e990cf9df64441ba021f2e9fa80b01ae9544d
crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs
History
Who touched what
raw
feat(lapaloma-cartesian): picture cache pan-blit
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}