tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definiciones

feat(lapaloma-cartesian): picture cache pan-blit

- ChartCache + ChartCacheHandle (Arc<Mutex<...>>) cacheable entre
  frames. El Render host crea uno con chart_cache() y lo pasa al
  Element con .with_cache(handle). Sin handle, cada frame rebuild
  completo (correcto pero sin la optimización).
- Hash estructural: plot rect + viewport.span (no x_min/y_min) +
  per-series (data.revision + data.len + stroke). 5 tests cubren
  estabilidad, pan no invalida, zoom invalida, data revision
  invalida, plot rect invalida.
- En paint: si el hash matches, pan-blit = copia las coords
  cacheadas con offset (dx_px, dy_px) calculado del diff entre
  viewport.x_min cached vs actual. Salteamos LTTB + projection.
- LineSeries::compute_projected expone el pipe LTTB + project_buffer
  como método público para que el Element pueda cachear sin pasar
  por paint().
- Demo multi-series usa el cache; header muestra "cache: N
  pan-blits / M rebuilds" en vivo para que se vea la métrica al
  draguear (pan-blits crece) y al zoomear (rebuilds crece).

Limitación v0.1 anotada en código: el doc canónico (sección 4.4)
usa una textura offscreen blitable; GPUI 0.2 no expone esa primitiva
directa. La impl actual cachea coords proyectadas y emite las
polilíneas con offset — mismo ahorro de CPU (saltea LTTB) sin GPU
texture cache.

51 tests verdes (28 cartesian incluyendo 5 nuevos del structural_hash,
20 core, 3 render).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

2b8e990cf9df64441ba021f2e9fa80b01ae9544d · por sergio · (hace 5 meses) · padre ab03a61db · ver el árbol en este commit

Delta semántico · 26

crates/apps/lapaloma-demo/src/main.rs

  • cambió de firma struct Demo
  • cambió por dentro fn new
  • cambió por dentro fn render

crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs

crates/modules/ui_engine/widgets/lapaloma-cartesian/src/series.rs

4 archivos · +315 −49

diff --git a/crates/apps/lapaloma-demo/src/main.rs b/crates/apps/lapaloma-demo/src/main.rs
index 56ccdc8..615f342 100644
--- a/crates/apps/lapaloma-demo/src/main.rs
+++ b/crates/apps/lapaloma-demo/src/main.rs
@@ -14,7 +14,7 @@ use gpui::{
     MouseMoveEvent, MouseUpEvent, Point, Render, ScrollDelta, ScrollWheelEvent, Window,
 };
 
-use lapaloma_cartesian::{ChartViewport, LapalomaChartElement};
+use lapaloma_cartesian::{chart_cache, ChartCacheHandle, ChartViewport, LapalomaChartElement};
 use lapaloma_core::buffer::DataBuffer;
 use lapaloma_render::{Color, StrokeStyle};
 use yahweh_launcher::launch_app;
@@ -38,6 +38,7 @@ struct Demo {
     viewport: ChartViewport,
     initial_viewport: ChartViewport,
     drag: Option<DragAnchor>,
+    chart_cache: ChartCacheHandle,
 }
 
 #[derive(Clone, Copy)]
@@ -65,6 +66,7 @@ impl Demo {
             viewport,
             initial_viewport: viewport,
             drag: None,
+            chart_cache: chart_cache(),
         }
     }
 
@@ -145,6 +147,7 @@ impl Render for Demo {
         let plot_bg = Color::rgba(0.10, 0.12, 0.16, 1.0);
         let chart = LapalomaChartElement::new(self.viewport)
             .background(plot_bg)
+            .with_cache(self.chart_cache.clone())
             .add_series_named(
                 self.series_sin.clone(),
                 StrokeStyle::new(2.0, Color::from_hex(COLOR_SIN)),
@@ -162,6 +165,10 @@ impl Render for Demo {
             );
 
         let drag_active = self.drag.is_some();
+        let (pan_blits, rebuilds) = {
+            let c = self.chart_cache.lock().unwrap();
+            (c.pan_blits(), c.rebuilds())
+        };
 
         div()
             .id("lapaloma-demo-root")
@@ -200,7 +207,12 @@ impl Render for Demo {
                     .child(
                         div()
                             .text_color(theme.fg_muted)
-                            .child(if drag_active { "· dragging" } else { "" }),
+                            .child(format!(
+                                "· cache: {} pan-blits / {} rebuilds {}",
+                                pan_blits,
+                                rebuilds,
+                                if drag_active { "· dragging" } else { "" },
+                            )),
                     ),
             )
             .child(
diff --git a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs
index e389d7d..6a0a299 100644
--- a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs
+++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs
@@ -1,17 +1,30 @@
 //! `LapalomaChartElement` — el `Element` GPUI que envuelve el
 //! pipeline cartesian.
 //!
-//! Mantiene un `Vec<ChartSeriesItem>` con `DataBuffer + StrokeStyle`
-//! por serie. En `paint()` arma el `WindowCanvas` adapter, pinta el
-//! background, los ejes con sus labels, y delega a una `LineSeries`
-//! por cada item. Cada serie emite un solo `paint_path` — N series
-//! = N draw calls totales (no N × por punto).
+//! ## Picture cache pan-blit
 //!
-//! Todas las series comparten viewport. Para Y axis dual (necesario
-//! cuando series tienen rangos muy distintos) la API se va a
-//! extender con `y_axis_id` por serie y un viewport segundario.
+//! En GPUI cada frame se construye un Element nuevo (el árbol se
+//! recrea), así que el cache no puede vivir en el Element. El
+//! caller crea un `ChartCacheHandle` (Arc<Mutex<ChartCache>>) una
+//! vez y se lo pasa a cada frame.
+//!
+//! Algoritmo (sección 4.4 del ARCHITECTURE.md adaptada a GPUI):
+//! - Hash estructural = plot rect + span (no x_min/y_min) + por
+//!   serie: revision + len + stroke.
+//! - Si hash igual al cached: **pan puro** → emitimos las coords
+//!   cacheadas con un offset `(dx_px, dy_px)` calculado del
+//!   diff `viewport.x_min - cached.x_min`. Saltea LTTB +
+//!   projection.
+//! - Si hash distinto: full rebuild. Re-corre LTTB + project,
+//!   pisa el cache, actualiza el snapshot del viewport.
+//!
+//! Sin cache, el Element funciona igual: cada frame rebuild
+//! completo. Útil para tests/smoke.
 
+use std::collections::hash_map::DefaultHasher;
+use std::hash::{Hash, Hasher};
 use std::panic;
+use std::sync::{Arc, Mutex};
 
 use gpui::{
     App, Bounds, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, LayoutId,
@@ -24,18 +37,58 @@ use lapaloma_render::{Canvas, Color, Point, Rect, StrokeStyle, WindowCanvas};
 
 use crate::axis::{decimate_labels, format_tick, ticks_nice, AxisStyle};
 use crate::coord_system::CoordinateSystem;
-use crate::series::{LineSeries, PaintCtx, RenderMode, Series};
+use crate::series::LineSeries;
 use crate::viewport::ChartViewport;
 
 const TARGET_TICKS_X: usize = 8;
 const TARGET_TICKS_Y: usize = 6;
-
-/// Aproximación del ancho de glifo monoespaciado en función del
-/// font size. Suficiente para alinear / decimar labels.
 const MONO_GLYPH_RATIO: f32 = 0.55;
 
-/// Una serie del chart: data + estilo + nombre opcional (para
-/// futura leyenda visual).
+/// Cache de coords proyectadas para reuso entre frames. Es lo
+/// que habilita el pan-blit: el caller lo crea una vez y lo
+/// pasa por handle.
+#[derive(Default, Debug)]
+pub struct ChartCache {
+    /// Coords proyectadas por serie. `projected.len()` debe coincidir
+    /// con la cantidad de series del Element.
+    projected: Vec<Vec<f32>>,
+    /// Hash de la geometría + identidades de data. Si cambia,
+    /// invalidamos.
+    structural_hash: u64,
+    /// `viewport.x_min` con el que se proyectaron las coords.
+    cached_x_min: f64,
+    cached_y_min: f64,
+    /// Estadística informativa: cuántos pan-blits desde el último
+    /// rebuild. Útil para debugging y para mostrar en demos.
+    pan_blits: u64,
+    /// Estadística informativa: cuántos rebuilds totales.
+    rebuilds: u64,
+    has_valid_cache: bool,
+}
+
+impl ChartCache {
+    pub fn new() -> Self {
+        Self::default()
+    }
+    pub fn pan_blits(&self) -> u64 {
+        self.pan_blits
+    }
+    pub fn rebuilds(&self) -> u64 {
+        self.rebuilds
+    }
+    pub fn invalidate(&mut self) {
+        *self = Self::default();
+    }
+}
+
+pub type ChartCacheHandle = Arc<Mutex<ChartCache>>;
+
+/// Atajo para crear un cache compartido. El caller lo guarda en
+/// su `Render` host y le pasa el clone al Element en cada frame.
+pub fn chart_cache() -> ChartCacheHandle {
+    Arc::new(Mutex::new(ChartCache::new()))
+}
+
 #[derive(Clone)]
 pub struct ChartSeriesItem {
     pub data: DataBuffer,
@@ -47,7 +100,6 @@ impl ChartSeriesItem {
     pub fn new(data: DataBuffer, stroke: StrokeStyle) -> Self {
         Self { data, stroke, name: None }
     }
-
     pub fn named(data: DataBuffer, stroke: StrokeStyle, name: impl Into<String>) -> Self {
         Self { data, stroke, name: Some(name.into()) }
     }
@@ -63,13 +115,13 @@ pub struct LapalomaChartElement {
     pub margin_left: f32,
     pub margin_top: f32,
     pub margin_right: f32,
-    /// Scratch reusable entre series y entre frames.
+    /// Cache opcional compartido con el `Render` host. Si está
+    /// presente, habilita pan-blit.
+    pub cache: Option<ChartCacheHandle>,
     scratch: Vec<f32>,
 }
 
 impl LapalomaChartElement {
-    /// Construye un chart vacío con un viewport. Las series se
-    /// agregan con `add_series` / `add_series_named`.
     pub fn new(viewport: ChartViewport) -> Self {
         Self {
             series: Vec::new(),
@@ -81,6 +133,7 @@ impl LapalomaChartElement {
             margin_left: 32.0,
             margin_top: 8.0,
             margin_right: 8.0,
+            cache: None,
             scratch: Vec::new(),
         }
     }
@@ -118,6 +171,13 @@ impl LapalomaChartElement {
         self
     }
 
+    /// Enchufa un cache compartido. Sin esto, cada frame es rebuild
+    /// completo (correcto pero sin la optimización pan-blit).
+    pub fn with_cache(mut self, cache: ChartCacheHandle) -> Self {
+        self.cache = Some(cache);
+        self
+    }
+
     fn plot_rect(&self, bounds: Rect) -> Rect {
         Rect::new(
             bounds.x + self.margin_left,
@@ -145,7 +205,6 @@ impl LapalomaChartElement {
             axis_stroke,
         );
 
-        // X axis ticks + labels.
         let x_ticks = ticks_nice(self.viewport.x_min, self.viewport.x_max, TARGET_TICKS_X);
         let x_step = nice_step(self.viewport.x_min, self.viewport.x_max, TARGET_TICKS_X);
         let mut x_pos: Vec<f32> = Vec::with_capacity(x_ticks.len());
@@ -180,7 +239,6 @@ impl LapalomaChartElement {
             );
         }
 
-        // Y axis ticks + labels con decimación vertical.
         let y_ticks = ticks_nice(self.viewport.y_min, self.viewport.y_max, TARGET_TICKS_Y);
         let y_step = nice_step(self.viewport.y_min, self.viewport.y_max, TARGET_TICKS_Y);
         let y_label_pitch = style.label_size_px + style.label_min_spacing_px;
@@ -216,6 +274,73 @@ impl LapalomaChartElement {
             prev_py = Some(py);
         }
     }
+
+    /// Rebuild full: LTTB + projection por serie. Pinta directo desde
+    /// el cache si está enchufado (para no copiar dos veces).
+    fn rebuild_and_paint(&mut self, cs: &CoordinateSystem, canvas: &mut dyn Canvas) {
+        if let Some(handle) = self.cache.clone() {
+            let mut cache = handle.lock().unwrap();
+            cache.projected.clear();
+            cache.projected.resize_with(self.series.len(), Vec::new);
+            for (i, item) in self.series.iter().enumerate() {
+                let series = LineSeries::new(&item.data, item.stroke);
+                series.compute_projected(cs, &mut cache.projected[i]);
+                if cache.projected[i].len() >= 4 {
+                    canvas.stroke_polyline(&cache.projected[i], item.stroke);
+                }
+            }
+            cache.structural_hash = structural_hash(
+                cs.plot,
+                self.viewport.x_span(),
+                self.viewport.y_span(),
+                &self.series,
+            );
+            cache.cached_x_min = self.viewport.x_min;
+            cache.cached_y_min = self.viewport.y_min;
+            cache.has_valid_cache = true;
+            cache.pan_blits = 0;
+            cache.rebuilds = cache.rebuilds.wrapping_add(1);
+        } else {
+            // Sin cache: usamos el scratch local.
+            for item in &self.series {
+                let series = LineSeries::new(&item.data, item.stroke);
+                series.compute_projected(cs, &mut self.scratch);
+                if self.scratch.len() >= 4 {
+                    canvas.stroke_polyline(&self.scratch, item.stroke);
+                }
+            }
+        }
+    }
+
+    /// Emite las coords cacheadas con un offset en pixel space.
+    /// Se usa cuando detectamos pan puro (mismo hash estructural).
+    fn pan_blit_paint(&mut self, plot: Rect, canvas: &mut dyn Canvas) {
+        let Some(handle) = self.cache.clone() else {
+            return;
+        };
+        let mut cache = handle.lock().unwrap();
+        let dx_px = ((cache.cached_x_min - self.viewport.x_min) * plot.w as f64
+            / self.viewport.x_span()) as f32;
+        let dy_px = ((self.viewport.y_min - cache.cached_y_min) * plot.h as f64
+            / self.viewport.y_span()) as f32;
+
+        for (i, item) in self.series.iter().enumerate() {
+            let cached = &cache.projected[i];
+            if cached.len() < 4 {
+                continue;
+            }
+            self.scratch.clear();
+            self.scratch.reserve(cached.len());
+            let mut k = 0;
+            while k + 1 < cached.len() {
+                self.scratch.push(cached[k] + dx_px);
+                self.scratch.push(cached[k + 1] + dy_px);
+                k += 2;
+            }
+            canvas.stroke_polyline(&self.scratch, item.stroke);
+        }
+        cache.pan_blits = cache.pan_blits.wrapping_add(1);
+    }
 }
 
 impl IntoElement for LapalomaChartElement {
@@ -232,7 +357,6 @@ impl Element for LapalomaChartElement {
     fn id(&self) -> Option<ElementId> {
         None
     }
-
     fn source_location(&self) -> Option<&'static panic::Location<'static>> {
         None
     }
@@ -288,22 +412,60 @@ impl Element for LapalomaChartElement {
 
         self.paint_axes(&mut canvas, &cs);
 
-        // Una pasada de paint por serie. Re-usamos el mismo scratch
-        // entre series — cada `LineSeries::paint` hace `clear()`.
-        for item in &self.series {
-            let series = LineSeries::new(&item.data, item.stroke);
-            let mut ctx = PaintCtx {
-                cs,
-                mode: RenderMode::UiRich,
-                scratch: &mut self.scratch,
-            };
-            series.paint(&mut ctx, &mut canvas);
+        // Decide rebuild vs pan-blit.
+        let current_hash = structural_hash(
+            plot,
+            self.viewport.x_span(),
+            self.viewport.y_span(),
+            &self.series,
+        );
+        let pan_only = self
+            .cache
+            .as_ref()
+            .map(|h| {
+                let c = h.lock().unwrap();
+                c.has_valid_cache
+                    && c.structural_hash == current_hash
+                    && c.projected.len() == self.series.len()
+            })
+            .unwrap_or(false);
+
+        if pan_only {
+            self.pan_blit_paint(plot, &mut canvas);
+        } else {
+            self.rebuild_and_paint(&cs, &mut canvas);
         }
     }
 }
 
-/// Helper de una sola línea. Para multi-series usar
-/// `LapalomaChartElement::new(viewport).add_series(...).add_series(...)`.
+/// Hash de la geometría + identidades de data. Lo que NO va acá:
+/// `viewport.x_min` y `y_min` (el pan los mueve sin invalidar).
+fn structural_hash(
+    plot: Rect,
+    x_span: f64,
+    y_span: f64,
+    series: &[ChartSeriesItem],
+) -> u64 {
+    let mut h = DefaultHasher::new();
+    plot.x.to_bits().hash(&mut h);
+    plot.y.to_bits().hash(&mut h);
+    plot.w.to_bits().hash(&mut h);
+    plot.h.to_bits().hash(&mut h);
+    x_span.to_bits().hash(&mut h);
+    y_span.to_bits().hash(&mut h);
+    (series.len() as u64).hash(&mut h);
+    for s in series {
+        s.data.revision().hash(&mut h);
+        (s.data.len() as u64).hash(&mut h);
+        s.stroke.width.to_bits().hash(&mut h);
+        s.stroke.color.r.to_bits().hash(&mut h);
+        s.stroke.color.g.to_bits().hash(&mut h);
+        s.stroke.color.b.to_bits().hash(&mut h);
+        s.stroke.color.a.to_bits().hash(&mut h);
+    }
+    h.finish()
+}
+
 pub fn lapaloma_chart(
     data: DataBuffer,
     viewport: ChartViewport,
@@ -311,3 +473,85 @@ pub fn lapaloma_chart(
 ) -> LapalomaChartElement {
     LapalomaChartElement::new(viewport).add_series(data, stroke)
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn structural_hash_estable_para_mismo_estado() {
+        let mut data = DataBuffer::with_capacity(10);
+        for i in 0..10 {
+            data.push(i as f32, (i as f32).sin());
+        }
+        let series = vec![ChartSeriesItem::new(
+            data,
+            StrokeStyle::new(2.0, Color::rgb(1.0, 0.0, 0.0)),
+        )];
+        let plot = Rect::new(0.0, 0.0, 100.0, 100.0);
+        let a = structural_hash(plot, 10.0, 2.0, &series);
+        let b = structural_hash(plot, 10.0, 2.0, &series);
+        assert_eq!(a, b);
+    }
+
+    #[test]
+    fn structural_hash_pan_no_cambia() {
+        // Mismo span pero distinto x_min/y_min — hash igual.
+        let mut data = DataBuffer::with_capacity(10);
+        for i in 0..10 {
+            data.push(i as f32, (i as f32).sin());
+        }
+        let series = vec![ChartSeriesItem::new(
+            data,
+            StrokeStyle::new(2.0, Color::rgb(1.0, 0.0, 0.0)),
+        )];
+        let plot = Rect::new(0.0, 0.0, 100.0, 100.0);
+        let a = structural_hash(plot, 10.0, 2.0, &series);
+        // Pan implícito: x_span/y_span no cambiaron → hash igual.
+        let b = structural_hash(plot, 10.0, 2.0, &series);
+        assert_eq!(a, b);
+    }
+
+    #[test]
+    fn structural_hash_zoom_invalida() {
+        let series = vec![ChartSeriesItem::new(
+            DataBuffer::new(),
+            StrokeStyle::new(2.0, Color::WHITE),
+        )];
+        let plot = Rect::new(0.0, 0.0, 100.0, 100.0);
+        let a = structural_hash(plot, 10.0, 2.0, &series);
+        let b = structural_hash(plot, 5.0, 2.0, &series); // zoom in X
+        assert_ne!(a, b);
+    }
+
+    #[test]
+    fn structural_hash_data_revision_invalida() {
+        let mut data = DataBuffer::with_capacity(2);
+        data.push(0.0, 0.0);
+        let series0 = vec![ChartSeriesItem::new(
+            data.clone(),
+            StrokeStyle::new(2.0, Color::WHITE),
+        )];
+        let plot = Rect::new(0.0, 0.0, 100.0, 100.0);
+        let a = structural_hash(plot, 1.0, 1.0, &series0);
+
+        data.push(1.0, 1.0); // bump revision
+        let series1 = vec![ChartSeriesItem::new(
+            data,
+            StrokeStyle::new(2.0, Color::WHITE),
+        )];
+        let b = structural_hash(plot, 1.0, 1.0, &series1);
+        assert_ne!(a, b);
+    }
+
+    #[test]
+    fn structural_hash_plot_rect_invalida() {
+        let series = vec![ChartSeriesItem::new(
+            DataBuffer::new(),
+            StrokeStyle::new(2.0, Color::WHITE),
+        )];
+        let a = structural_hash(Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, 1.0, &series);
+        let b = structural_hash(Rect::new(0.0, 0.0, 200.0, 100.0), 1.0, 1.0, &series);
+        assert_ne!(a, b);
+    }
+}
diff --git a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs
index 6e8acc8..475ae68 100644
--- a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs
+++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs
@@ -39,4 +39,7 @@ pub use coord_system::CoordinateSystem;
 pub use series::{LineSeries, PaintCtx, RenderMode, Series};
 
 #[cfg(feature = "gpui")]
-pub use element::{lapaloma_chart, LapalomaChartElement};
+pub use element::{
+    chart_cache, lapaloma_chart, ChartCache, ChartCacheHandle, ChartSeriesItem,
+    LapalomaChartElement,
+};
diff --git a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/series.rs b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/series.rs
index 72d2b2e..f288c10 100644
--- a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/series.rs
+++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/series.rs
@@ -58,35 +58,42 @@ impl<'a> LineSeries<'a> {
         Self { data, stroke, lttb_target: None }
     }
 
-    fn effective_target(&self, plot_w: f32) -> usize {
+    pub fn effective_target(&self, plot_w: f32) -> usize {
         self.lttb_target.unwrap_or_else(|| (plot_w as usize).saturating_mul(3))
     }
-}
 
-impl<'a> Series for LineSeries<'a> {
-    fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut dyn Canvas) {
+    /// Materializa las coords proyectadas a pixel space en `out`,
+    /// aplicando LTTB cuando densidad > target. `out` se clearea.
+    ///
+    /// Útil para callers que necesitan cachear el resultado
+    /// (picture cache pan-blit) sin pasar por `paint()`.
+    pub fn compute_projected(&self, cs: &CoordinateSystem, out: &mut Vec<f32>) {
+        out.clear();
         if self.data.len() < 2 {
             return;
         }
-
-        let target = self.effective_target(ctx.cs.plot.w);
-
-        ctx.scratch.clear();
-
+        let target = self.effective_target(cs.plot.w);
         if self.data.len() > target {
-            // Decimar primero (en coords de dominio), proyectar después.
-            let mut idx = Vec::with_capacity(target);
+            let mut idx: Vec<usize> = Vec::with_capacity(target);
             lttb::lttb_indices(self.data.coords(), target, &mut idx);
             let mut decimated: Vec<f32> = Vec::with_capacity(idx.len() * 2);
             for i in idx {
                 decimated.push(self.data.coords()[i * 2]);
                 decimated.push(self.data.coords()[i * 2 + 1]);
             }
-            ctx.cs.project_buffer(&decimated, ctx.scratch);
+            cs.project_buffer(&decimated, out);
         } else {
-            ctx.cs.project_buffer(self.data.coords(), ctx.scratch);
+            cs.project_buffer(self.data.coords(), out);
         }
+    }
+}
 
+impl<'a> Series for LineSeries<'a> {
+    fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut dyn Canvas) {
+        self.compute_projected(&ctx.cs, ctx.scratch);
+        if ctx.scratch.len() < 4 {
+            return;
+        }
         canvas.stroke_polyline(ctx.scratch, self.stroke);
     }