Skip to main content

pineal_bars/
paint.rs

1//! Painters de barras agnósticos: categorías → `fill_rect` contra un
2//! `Canvas`. Una unidad geométrica común ([`Axis`]) unifica orientación
3//! vertical/horizontal y los tres modos (simple, agrupado, apilado).
4
5use pineal_render::{Canvas, Color, Rect};
6
7/// Una barra: su valor (puede ser negativo) y su color.
8#[derive(Debug, Clone, Copy)]
9pub struct Bar {
10    pub value: f64,
11    pub color: Color,
12}
13
14impl Bar {
15    pub fn new(value: f64, color: Color) -> Self {
16        Self { value, color }
17    }
18}
19
20/// Hacia dónde crecen las barras.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Orientation {
23    /// Columnas: el eje de valor es vertical, crecen hacia arriba.
24    Vertical,
25    /// Barras: el eje de valor es horizontal, crecen hacia la derecha.
26    Horizontal,
27}
28
29/// Estilo del gráfico de barras.
30#[derive(Debug, Clone, Copy)]
31pub struct BarStyle {
32    pub orientation: Orientation,
33    /// Fracción del slot de cada categoría que va a separación, en
34    /// `[0, 1)`. `0.0` = barras pegadas; `0.2` = 20 % de aire.
35    pub gap_ratio: f32,
36    /// Valor del cero. Las barras nacen acá; valores por debajo crecen
37    /// en sentido contrario. Normalmente `0.0`.
38    pub baseline: f64,
39    /// Override del rango de valor. `None` = automático a partir de los
40    /// datos (incluyendo siempre el baseline).
41    pub range: Option<(f64, f64)>,
42}
43
44impl Default for BarStyle {
45    fn default() -> Self {
46        Self {
47            orientation: Orientation::Vertical,
48            gap_ratio: 0.18,
49            baseline: 0.0,
50            range: None,
51        }
52    }
53}
54
55impl BarStyle {
56    pub fn vertical() -> Self {
57        Self::default()
58    }
59    pub fn horizontal() -> Self {
60        Self {
61            orientation: Orientation::Horizontal,
62            ..Self::default()
63        }
64    }
65    pub fn with_gap(mut self, gap_ratio: f32) -> Self {
66        self.gap_ratio = gap_ratio.clamp(0.0, 0.95);
67        self
68    }
69    pub fn with_baseline(mut self, baseline: f64) -> Self {
70        self.baseline = baseline;
71        self
72    }
73    pub fn with_range(mut self, lo: f64, hi: f64) -> Self {
74        self.range = Some((lo, hi));
75        self
76    }
77}
78
79/// Geometría compartida: mapea valor → pixel sobre el eje de valor y
80/// arma el rect de una barra que ocupa el segmento `[cat_lo, cat_hi]`
81/// del eje de categoría y va de `v_from` a `v_to` en el eje de valor.
82struct Axis {
83    area: Rect,
84    vmin: f64,
85    vmax: f64,
86    orientation: Orientation,
87}
88
89impl Axis {
90    fn new(area: Rect, vmin: f64, vmax: f64, orientation: Orientation) -> Self {
91        // Evita división por cero cuando todos los valores coinciden.
92        let (vmin, vmax) = if (vmax - vmin).abs() < f64::EPSILON {
93            (vmin - 0.5, vmax + 0.5)
94        } else {
95            (vmin, vmax)
96        };
97        Self { area, vmin, vmax, orientation }
98    }
99
100    /// Extensión del eje de categoría (donde se reparten los slots).
101    fn cat_span(&self) -> (f32, f32) {
102        match self.orientation {
103            Orientation::Vertical => (self.area.x, self.area.x + self.area.w),
104            Orientation::Horizontal => (self.area.y, self.area.y + self.area.h),
105        }
106    }
107
108    /// Pixel del eje de valor para `v`. En vertical, +valor = arriba
109    /// (y menor); en horizontal, +valor = derecha (x mayor).
110    fn value_px(&self, v: f64) -> f32 {
111        let t = ((v - self.vmin) / (self.vmax - self.vmin)) as f32;
112        match self.orientation {
113            Orientation::Vertical => self.area.y + self.area.h * (1.0 - t),
114            Orientation::Horizontal => self.area.x + self.area.w * t,
115        }
116    }
117
118    /// Rect de una barra: ocupa `[cat_lo, cat_hi]` en categoría y el
119    /// tramo de valor `[v_from, v_to]` (orden indistinto).
120    fn bar_rect(&self, cat_lo: f32, cat_hi: f32, v_from: f64, v_to: f64) -> Rect {
121        let p0 = self.value_px(v_from);
122        let p1 = self.value_px(v_to);
123        let (lo, hi) = (p0.min(p1), p0.max(p1));
124        match self.orientation {
125            Orientation::Vertical => Rect::new(cat_lo, lo, cat_hi - cat_lo, hi - lo),
126            Orientation::Horizontal => Rect::new(lo, cat_lo, hi - lo, cat_hi - cat_lo),
127        }
128    }
129}
130
131/// Reparte `n` slots iguales sobre `[span0, span1]` y devuelve el
132/// sub-rango `[lo, hi]` del slot `i` ya descontado el gap.
133fn slot(span0: f32, span1: f32, n: usize, i: usize, gap_ratio: f32) -> (f32, f32) {
134    let total = span1 - span0;
135    let w = total / n as f32;
136    let pad = w * gap_ratio * 0.5;
137    let lo = span0 + w * i as f32 + pad;
138    let hi = span0 + w * (i + 1) as f32 - pad;
139    (lo, hi)
140}
141
142fn auto_range(values: impl Iterator<Item = f64>, baseline: f64) -> (f64, f64) {
143    let mut lo = baseline;
144    let mut hi = baseline;
145    for v in values {
146        if v < lo {
147            lo = v;
148        }
149        if v > hi {
150            hi = v;
151        }
152    }
153    (lo, hi)
154}
155
156/// Dibuja una serie de barras dentro de `area`. Una `fill_rect` por
157/// barra; valores negativos crecen al otro lado del baseline.
158pub fn paint_bars(bars: &[Bar], area: Rect, style: &BarStyle, canvas: &mut dyn Canvas) {
159    if bars.is_empty() {
160        return;
161    }
162    let (vmin, vmax) = style
163        .range
164        .unwrap_or_else(|| auto_range(bars.iter().map(|b| b.value), style.baseline));
165    let axis = Axis::new(area, vmin, vmax, style.orientation);
166    let (s0, s1) = axis.cat_span();
167    for (i, bar) in bars.iter().enumerate() {
168        let (lo, hi) = slot(s0, s1, bars.len(), i, style.gap_ratio);
169        let r = axis.bar_rect(lo, hi, style.baseline, bar.value);
170        if r.w > 0.0 && r.h > 0.0 {
171            canvas.fill_rect(r, bar.color);
172        }
173    }
174}
175
176/// Dibuja varias series agrupadas (clustered): cada categoría es un
177/// slot que se subdivide entre las `series.len()` series. `series[k]`
178/// debe tener un valor por categoría; series más cortas se rellenan
179/// hasta la categoría que tengan.
180pub fn paint_grouped(series: &[&[Bar]], area: Rect, style: &BarStyle, canvas: &mut dyn Canvas) {
181    let n_series = series.len();
182    if n_series == 0 {
183        return;
184    }
185    let n_cats = series.iter().map(|s| s.len()).max().unwrap_or(0);
186    if n_cats == 0 {
187        return;
188    }
189    let all = series.iter().flat_map(|s| s.iter().map(|b| b.value));
190    let (vmin, vmax) = style.range.unwrap_or_else(|| auto_range(all, style.baseline));
191    let axis = Axis::new(area, vmin, vmax, style.orientation);
192    let (s0, s1) = axis.cat_span();
193    for cat in 0..n_cats {
194        // Slot de la categoría (sin gap: el gap se aplica adentro, entre
195        // las barras del cluster).
196        let (clo, chi) = slot(s0, s1, n_cats, cat, 0.0);
197        for (k, serie) in series.iter().enumerate() {
198            let Some(bar) = serie.get(cat) else { continue };
199            let (lo, hi) = slot(clo, chi, n_series, k, style.gap_ratio);
200            let r = axis.bar_rect(lo, hi, style.baseline, bar.value);
201            if r.w > 0.0 && r.h > 0.0 {
202                canvas.fill_rect(r, bar.color);
203            }
204        }
205    }
206}
207
208/// Dibuja barras apiladas: `stacks[c]` son los segmentos de la
209/// categoría `c`, acumulados desde el baseline. Pensado para segmentos
210/// del mismo signo (lo habitual en stacked bars).
211pub fn paint_stacked(stacks: &[&[Bar]], area: Rect, style: &BarStyle, canvas: &mut dyn Canvas) {
212    if stacks.is_empty() {
213        return;
214    }
215    // Rango: del baseline al mínimo/máximo acumulado de cada pila.
216    let mut lo = style.baseline;
217    let mut hi = style.baseline;
218    for stack in stacks {
219        let mut acc = style.baseline;
220        for seg in stack.iter() {
221            acc += seg.value;
222            lo = lo.min(acc);
223            hi = hi.max(acc);
224        }
225    }
226    let (vmin, vmax) = style.range.unwrap_or((lo, hi));
227    let axis = Axis::new(area, vmin, vmax, style.orientation);
228    let (s0, s1) = axis.cat_span();
229    for (c, stack) in stacks.iter().enumerate() {
230        let (clo, chi) = slot(s0, s1, stacks.len(), c, style.gap_ratio);
231        let mut acc = style.baseline;
232        for seg in stack.iter() {
233            let from = acc;
234            acc += seg.value;
235            let r = axis.bar_rect(clo, chi, from, acc);
236            if r.w > 0.0 && r.h > 0.0 {
237                canvas.fill_rect(r, seg.color);
238            }
239        }
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use pineal_render::{PlanRecorder, RenderCmd};
247
248    fn fill_rects(rec: PlanRecorder) -> Vec<Rect> {
249        rec.into_plan()
250            .cmds
251            .into_iter()
252            .filter_map(|c| match c {
253                RenderCmd::FillRect { rect, .. } => Some(rect),
254                _ => None,
255            })
256            .collect()
257    }
258
259    #[test]
260    fn one_rect_per_bar() {
261        let bars = [
262            Bar::new(3.0, Color::WHITE),
263            Bar::new(5.0, Color::BLACK),
264            Bar::new(1.0, Color::from_hex(0x00ff00)),
265        ];
266        let mut rec = PlanRecorder::new();
267        paint_bars(&bars, Rect::new(0.0, 0.0, 300.0, 200.0), &BarStyle::vertical(), &mut rec);
268        assert_eq!(fill_rects(rec).len(), 3);
269    }
270
271    #[test]
272    fn taller_value_taller_bar() {
273        let bars = [Bar::new(1.0, Color::WHITE), Bar::new(4.0, Color::WHITE)];
274        let mut rec = PlanRecorder::new();
275        paint_bars(&bars, Rect::new(0.0, 0.0, 200.0, 100.0), &BarStyle::vertical(), &mut rec);
276        let rects = fill_rects(rec);
277        assert!(rects[1].h > rects[0].h, "la barra de mayor valor debe ser más alta");
278    }
279
280    #[test]
281    fn negative_grows_below_baseline() {
282        // Con baseline 0 y rango simétrico, un valor negativo debe quedar
283        // por debajo (y mayor) que uno positivo.
284        let bars = [Bar::new(2.0, Color::WHITE), Bar::new(-2.0, Color::WHITE)];
285        let style = BarStyle::vertical().with_range(-3.0, 3.0);
286        let mut rec = PlanRecorder::new();
287        paint_bars(&bars, Rect::new(0.0, 0.0, 200.0, 100.0), &style, &mut rec);
288        let rects = fill_rects(rec);
289        // baseline (v=0) está en el medio (y=50). El positivo arranca
290        // arriba del baseline; el negativo abajo.
291        assert!(rects[0].y < 50.0, "positivo arriba del baseline");
292        assert!(rects[1].y >= 50.0 - f32::EPSILON, "negativo en/abajo del baseline");
293    }
294
295    #[test]
296    fn horizontal_swaps_axes() {
297        let bars = [Bar::new(1.0, Color::WHITE), Bar::new(4.0, Color::WHITE)];
298        let mut rec = PlanRecorder::new();
299        paint_bars(&bars, Rect::new(0.0, 0.0, 200.0, 100.0), &BarStyle::horizontal(), &mut rec);
300        let rects = fill_rects(rec);
301        // En horizontal el largo es el ancho (w), no la altura.
302        assert!(rects[1].w > rects[0].w, "mayor valor = barra más larga (w)");
303    }
304
305    #[test]
306    fn grouped_emits_all_bars() {
307        let a = [Bar::new(1.0, Color::WHITE), Bar::new(2.0, Color::WHITE)];
308        let b = [Bar::new(3.0, Color::BLACK), Bar::new(4.0, Color::BLACK)];
309        let series: [&[Bar]; 2] = [&a, &b];
310        let mut rec = PlanRecorder::new();
311        paint_grouped(&series, Rect::new(0.0, 0.0, 400.0, 200.0), &BarStyle::vertical(), &mut rec);
312        assert_eq!(fill_rects(rec).len(), 4);
313    }
314
315    #[test]
316    fn stacked_segments_dont_overlap() {
317        let s0 = [Bar::new(2.0, Color::WHITE), Bar::new(3.0, Color::BLACK)];
318        let stacks: [&[Bar]; 1] = [&s0];
319        let mut rec = PlanRecorder::new();
320        paint_stacked(&stacks, Rect::new(0.0, 0.0, 100.0, 100.0), &BarStyle::vertical(), &mut rec);
321        let rects = fill_rects(rec);
322        assert_eq!(rects.len(), 2);
323        // Apilados verticalmente: el primer segmento (baseline→2) queda
324        // debajo del segundo (2→5). Sin solape ⇒ el de abajo empieza
325        // donde termina el de arriba (con tolerancia de borde).
326        let bottom0 = rects[0].y + rects[0].h;
327        let bottom1 = rects[1].y + rects[1].h;
328        assert!(bottom1 <= bottom0 + 0.01 && rects[1].y < rects[0].y);
329    }
330}