⎇ 66d18ab47cd9ae7d14a3131aa703cac00de19260
crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(lapaloma-cartesian): multi-series en LapalomaChartElement
1//! `LapalomaChartElement` — el `Element` GPUI que envuelve el
2//! pipeline cartesian.
3//!
4//! Mantiene un `Vec<ChartSeriesItem>` con `DataBuffer + StrokeStyle`
5//! por serie. En `paint()` arma el `WindowCanvas` adapter, pinta el
6//! background, los ejes con sus labels, y delega a una `LineSeries`
7//! por cada item. Cada serie emite un solo `paint_path` — N series
8//! = N draw calls totales (no N × por punto).
9//!
10//! Todas las series comparten viewport. Para Y axis dual (necesario
11//! cuando series tienen rangos muy distintos) la API se va a
12//! extender con `y_axis_id` por serie y un viewport segundario.
13
14use std::panic;
15
16use gpui::{
17 App, Bounds, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, LayoutId,
18 Pixels, Style, Window,
19};
20
21use lapaloma_core::buffer::DataBuffer;
22use lapaloma_core::scale::nice_step;
23use lapaloma_render::{Canvas, Color, Point, Rect, StrokeStyle, WindowCanvas};
24
25use crate::axis::{decimate_labels, format_tick, ticks_nice, AxisStyle};
26use crate::coord_system::CoordinateSystem;
27use crate::series::{LineSeries, PaintCtx, RenderMode, Series};
28use crate::viewport::ChartViewport;
29
30const TARGET_TICKS_X: usize = 8;
31const TARGET_TICKS_Y: usize = 6;
32
33/// Aproximación del ancho de glifo monoespaciado en función del
34/// font size. Suficiente para alinear / decimar labels.
35const MONO_GLYPH_RATIO: f32 = 0.55;
36
37/// Una serie del chart: data + estilo + nombre opcional (para
38/// futura leyenda visual).
39#[derive(Clone)]
40pub struct ChartSeriesItem {
41 pub data: DataBuffer,
42 pub stroke: StrokeStyle,
43 pub name: Option<String>,
44}
45
46impl ChartSeriesItem {
47 pub fn new(data: DataBuffer, stroke: StrokeStyle) -> Self {
48 Self { data, stroke, name: None }
49 }
50
51 pub fn named(data: DataBuffer, stroke: StrokeStyle, name: impl Into<String>) -> Self {
52 Self { data, stroke, name: Some(name.into()) }
53 }
54}
55
56pub struct LapalomaChartElement {
57 pub series: Vec<ChartSeriesItem>,
58 pub viewport: ChartViewport,
59 pub background: Option<Color>,
60 pub axis_color: Color,
61 pub axis_style: AxisStyle,
62 pub margin_bottom: f32,
63 pub margin_left: f32,
64 pub margin_top: f32,
65 pub margin_right: f32,
66 /// Scratch reusable entre series y entre frames.
67 scratch: Vec<f32>,
68}
69
70impl LapalomaChartElement {
71 /// Construye un chart vacío con un viewport. Las series se
72 /// agregan con `add_series` / `add_series_named`.
73 pub fn new(viewport: ChartViewport) -> Self {
74 Self {
75 series: Vec::new(),
76 viewport,
77 background: None,
78 axis_color: Color::rgba(0.6, 0.6, 0.65, 0.8),
79 axis_style: AxisStyle::default(),
80 margin_bottom: 24.0,
81 margin_left: 32.0,
82 margin_top: 8.0,
83 margin_right: 8.0,
84 scratch: Vec::new(),
85 }
86 }
87
88 pub fn add_series(mut self, data: DataBuffer, stroke: StrokeStyle) -> Self {
89 self.series.push(ChartSeriesItem::new(data, stroke));
90 self
91 }
92
93 pub fn add_series_named(
94 mut self,
95 data: DataBuffer,
96 stroke: StrokeStyle,
97 name: impl Into<String>,
98 ) -> Self {
99 self.series.push(ChartSeriesItem::named(data, stroke, name));
100 self
101 }
102
103 pub fn background(mut self, color: Color) -> Self {
104 self.background = Some(color);
105 self
106 }
107
108 pub fn axis_color(mut self, color: Color) -> Self {
109 self.axis_color = color;
110 self
111 }
112
113 pub fn margins(mut self, top: f32, right: f32, bottom: f32, left: f32) -> Self {
114 self.margin_top = top;
115 self.margin_right = right;
116 self.margin_bottom = bottom;
117 self.margin_left = left;
118 self
119 }
120
121 fn plot_rect(&self, bounds: Rect) -> Rect {
122 Rect::new(
123 bounds.x + self.margin_left,
124 bounds.y + self.margin_top,
125 (bounds.w - self.margin_left - self.margin_right).max(1.0),
126 (bounds.h - self.margin_top - self.margin_bottom).max(1.0),
127 )
128 }
129
130 fn paint_axes(&self, canvas: &mut dyn Canvas, cs: &CoordinateSystem) {
131 let plot = cs.plot;
132 let style = self.axis_style;
133 let axis_stroke = StrokeStyle::new(style.axis_line_width_px, self.axis_color);
134 let tick_stroke = StrokeStyle::new(style.tick_width_px, self.axis_color);
135 let tlen = style.tick_length_px;
136
137 canvas.stroke_line(
138 Point::new(plot.x, plot.bottom()),
139 Point::new(plot.right(), plot.bottom()),
140 axis_stroke,
141 );
142 canvas.stroke_line(
143 Point::new(plot.x, plot.y),
144 Point::new(plot.x, plot.bottom()),
145 axis_stroke,
146 );
147
148 // X axis ticks + labels.
149 let x_ticks = ticks_nice(self.viewport.x_min, self.viewport.x_max, TARGET_TICKS_X);
150 let x_step = nice_step(self.viewport.x_min, self.viewport.x_max, TARGET_TICKS_X);
151 let mut x_pos: Vec<f32> = Vec::with_capacity(x_ticks.len());
152 let mut x_lbl: Vec<String> = Vec::with_capacity(x_ticks.len());
153 let mut x_widths: Vec<f32> = Vec::with_capacity(x_ticks.len());
154 for v in &x_ticks {
155 let pixel = cs.data_to_pixel(*v, self.viewport.y_min).x;
156 if pixel < plot.x - 0.5 || pixel > plot.right() + 0.5 {
157 continue;
158 }
159 canvas.stroke_line(
160 Point::new(pixel, plot.bottom()),
161 Point::new(pixel, plot.bottom() + tlen),
162 tick_stroke,
163 );
164 let lbl = format_tick(*v, x_step);
165 let w = lbl.len() as f32 * style.label_size_px * MONO_GLYPH_RATIO;
166 x_pos.push(pixel);
167 x_widths.push(w);
168 x_lbl.push(lbl);
169 }
170 let keep_x = decimate_labels(&x_pos, &x_widths, style.label_min_spacing_px);
171 for i in keep_x {
172 let half = x_widths[i] * 0.5;
173 let origin_x = x_pos[i] - half;
174 let origin_y = plot.bottom() + tlen + style.label_offset_px;
175 canvas.draw_text(
176 Point::new(origin_x, origin_y),
177 &x_lbl[i],
178 self.axis_color,
179 style.label_size_px,
180 );
181 }
182
183 // Y axis ticks + labels con decimación vertical.
184 let y_ticks = ticks_nice(self.viewport.y_min, self.viewport.y_max, TARGET_TICKS_Y);
185 let y_step = nice_step(self.viewport.y_min, self.viewport.y_max, TARGET_TICKS_Y);
186 let y_label_pitch = style.label_size_px + style.label_min_spacing_px;
187 let mut prev_py: Option<f32> = None;
188
189 for v in &y_ticks {
190 let py = cs.data_to_pixel(self.viewport.x_min, *v).y;
191 if py < plot.y - 0.5 || py > plot.bottom() + 0.5 {
192 continue;
193 }
194 canvas.stroke_line(
195 Point::new(plot.x - tlen, py),
196 Point::new(plot.x, py),
197 tick_stroke,
198 );
199 let label_ok = match prev_py {
200 None => true,
201 Some(p) => (py - p).abs() >= y_label_pitch,
202 };
203 if !label_ok {
204 continue;
205 }
206 let lbl = format_tick(*v, y_step);
207 let w = lbl.len() as f32 * style.label_size_px * MONO_GLYPH_RATIO;
208 let origin_x = plot.x - tlen - style.label_offset_px - w;
209 let origin_y = py - style.label_size_px * 0.5;
210 canvas.draw_text(
211 Point::new(origin_x, origin_y),
212 &lbl,
213 self.axis_color,
214 style.label_size_px,
215 );
216 prev_py = Some(py);
217 }
218 }
219}
220
221impl IntoElement for LapalomaChartElement {
222 type Element = Self;
223 fn into_element(self) -> Self::Element {
224 self
225 }
226}
227
228impl Element for LapalomaChartElement {
229 type RequestLayoutState = ();
230 type PrepaintState = ();
231
232 fn id(&self) -> Option<ElementId> {
233 None
234 }
235
236 fn source_location(&self) -> Option<&'static panic::Location<'static>> {
237 None
238 }
239
240 fn request_layout(
241 &mut self,
242 _id: Option<&GlobalElementId>,
243 _inspector_id: Option<&InspectorElementId>,
244 window: &mut Window,
245 cx: &mut App,
246 ) -> (LayoutId, Self::RequestLayoutState) {
247 let mut style = Style::default();
248 style.size.width = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0));
249 style.size.height = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0));
250 let id = window.request_layout(style, [], cx);
251 (id, ())
252 }
253
254 fn prepaint(
255 &mut self,
256 _id: Option<&GlobalElementId>,
257 _inspector_id: Option<&InspectorElementId>,
258 _bounds: Bounds<Pixels>,
259 _request_layout: &mut Self::RequestLayoutState,
260 _window: &mut Window,
261 _cx: &mut App,
262 ) -> Self::PrepaintState {
263 }
264
265 fn paint(
266 &mut self,
267 _id: Option<&GlobalElementId>,
268 _inspector_id: Option<&InspectorElementId>,
269 bounds: Bounds<Pixels>,
270 _request_layout: &mut Self::RequestLayoutState,
271 _prepaint: &mut Self::PrepaintState,
272 window: &mut Window,
273 _cx: &mut App,
274 ) {
275 let ox: f32 = bounds.origin.x.into();
276 let oy: f32 = bounds.origin.y.into();
277 let w: f32 = bounds.size.width.into();
278 let h: f32 = bounds.size.height.into();
279 let outer = Rect::new(ox, oy, w, h);
280 let plot = self.plot_rect(outer);
281
282 let cs = CoordinateSystem::new(self.viewport, plot);
283 let mut canvas = WindowCanvas::new(window);
284
285 if let Some(bg) = self.background {
286 canvas.fill_rect(outer, bg);
287 }
288
289 self.paint_axes(&mut canvas, &cs);
290
291 // Una pasada de paint por serie. Re-usamos el mismo scratch
292 // entre series — cada `LineSeries::paint` hace `clear()`.
293 for item in &self.series {
294 let series = LineSeries::new(&item.data, item.stroke);
295 let mut ctx = PaintCtx {
296 cs,
297 mode: RenderMode::UiRich,
298 scratch: &mut self.scratch,
299 };
300 series.paint(&mut ctx, &mut canvas);
301 }
302 }
303}
304
305/// Helper de una sola línea. Para multi-series usar
306/// `LapalomaChartElement::new(viewport).add_series(...).add_series(...)`.
307pub fn lapaloma_chart(
308 data: DataBuffer,
309 viewport: ChartViewport,
310 stroke: StrokeStyle,
311) -> LapalomaChartElement {
312 LapalomaChartElement::new(viewport).add_series(data, stroke)
313}