Skip to main content

ggplot_rs/geom/
timeline.rs

1//! Timeline: `geom_timeline`. Events along an x (usually date) axis on a
2//! central baseline; each is a dot with a stem to a boxed label. Labels are
3//! stacked on alternating levels above and below the line so they never
4//! overlap (greedy, at draw time in pixels); a span-end column turns an
5//! event into a span drawn as a band on the baseline.
6
7use crate::aes::Aesthetic;
8use crate::coord::Coord;
9use crate::data::{DataFrame, Value};
10use crate::position::identity::PositionIdentity;
11use crate::position::Position;
12use crate::render::backend::{
13    DrawBackend, FontFace, LineStyle, Linetype, PointStyle, RectStyle, TextAnchor, TextStyle,
14};
15use crate::render::RenderError;
16use crate::scale::ScaleSet;
17use crate::stat::identity::StatIdentity;
18use crate::stat::Stat;
19use crate::theme::Theme;
20
21use super::{Geom, GeomParams};
22
23/// Timeline. Map `x` (event time), `label` and optionally `color`; name a
24/// span-end column in `end` (rows where it is missing are point events —
25/// unlike a mapped `xend`, a missing end doesn't drop the row).
26pub struct GeomTimeline {
27    /// Column holding each event's span end (`None`: point events only).
28    pub end: Option<String>,
29    pub color: (u8, u8, u8),
30    pub line_color: (u8, u8, u8),
31    pub label_size: f64,
32    pub label_color: (u8, u8, u8),
33    /// Label box fill.
34    pub box_fill: (u8, u8, u8),
35    /// Gap kept between neighbouring labels on one level, px.
36    pub spacing: f64,
37}
38
39impl Default for GeomTimeline {
40    fn default() -> Self {
41        GeomTimeline {
42            end: None,
43            color: (31, 119, 180),
44            line_color: (120, 120, 120),
45            label_size: 9.5,
46            label_color: (30, 30, 30),
47            box_fill: (255, 255, 255),
48            spacing: 6.0,
49        }
50    }
51}
52
53impl GeomTimeline {
54    /// Read span ends from `column`.
55    pub fn with_end(mut self, column: impl Into<String>) -> Self {
56        self.end = Some(column.into());
57        self
58    }
59}
60
61/// Level order: 1, −1, 2, −2, … (above, below, further above, …).
62fn level(k: usize) -> i32 {
63    let n = (k / 2 + 1) as i32;
64    if k.is_multiple_of(2) {
65        n
66    } else {
67        -n
68    }
69}
70
71/// Greedy level for a label spanning `[l, r]` given each level's occupied
72/// intervals; `None` when every level up to `max_levels` per side collides.
73pub(crate) fn pick_level(
74    occupied: &mut Vec<(i32, f64, f64)>,
75    l: f64,
76    r: f64,
77    max_levels: usize,
78) -> Option<i32> {
79    (0..2 * max_levels.max(1)).map(level).find(|&lv| {
80        let free = !occupied.iter().any(|&(o, a, b)| o == lv && l < b && a < r);
81        if free {
82            occupied.push((lv, l, r));
83        }
84        free
85    })
86}
87
88impl Geom for GeomTimeline {
89    fn draw(
90        &self,
91        data: &DataFrame,
92        coord: &dyn Coord,
93        scales: &ScaleSet,
94        theme: &Theme,
95        backend: &mut dyn DrawBackend,
96    ) -> Result<(), RenderError> {
97        let x_col = data
98            .column("x")
99            .ok_or(RenderError::MissingAesthetic("x".into()))?;
100        let label = data.column("label");
101        let xend = data.column("xmax");
102        let color_col = data.column("color");
103        let area = backend.plot_area();
104        let mx = |v: &Value| scales.get(&Aesthetic::X).map(|s| s.map(v)).unwrap_or(0.5);
105        let px = |nx: f64| coord.transform((nx, 0.5), &area).0;
106        let base = area.y + area.height / 2.0;
107        backend.draw_line(
108            &[(area.x, base), (area.x + area.width, base)],
109            &LineStyle {
110                color: self.line_color,
111                alpha: 1.0,
112                width: 1.5,
113                linetype: Linetype::Solid,
114                ..Default::default()
115            },
116        )?;
117        let box_h = self.label_size + 8.0;
118        let step = box_h + 6.0;
119        let max_levels = (((area.height / 2.0) - 12.0) / step).floor().max(1.0) as usize;
120        let mut order: Vec<usize> = (0..data.nrows()).collect();
121        order.sort_by(|&a, &b| mx(&x_col[a]).total_cmp(&mx(&x_col[b])));
122        let mut occupied = Vec::new();
123        let family = super::resolve_font_family("", theme);
124        for i in order {
125            let x = px(mx(&x_col[i]));
126            if !x.is_finite() {
127                continue;
128            }
129            let color = color_col
130                .and_then(|c| scales.map_color(&Aesthetic::Color, &c[i]))
131                .unwrap_or(self.color);
132            let text = label.map(|c| super::tip_value(&c[i])).unwrap_or_default();
133            let when = super::tip_value(&x_col[i]);
134            let tip = match xend.map(|c| &c[i]).filter(|v| !v.is_na()) {
135                Some(e) => format!("{when} – {}: {text}", super::tip_value(e)),
136                None => format!("{when}: {text}"),
137            };
138            super::set_mark(
139                backend,
140                Some(tip),
141                Some(when),
142                super::series_key(data, i),
143                None,
144            );
145            super::set_row_extras(backend, data, i);
146            // Span band on the baseline.
147            if let Some(xe) = xend.and_then(|c| (!c[i].is_na()).then(|| px(mx(&c[i])))) {
148                if xe.is_finite() {
149                    backend.draw_rect(
150                        (x.min(xe), base - 4.0),
151                        (x.max(xe), base + 4.0),
152                        &RectStyle {
153                            fill: Some(color),
154                            stroke: None,
155                            stroke_width: 0.0,
156                            alpha: 0.5,
157                            clip: true,
158                            corner_radius: 4.0,
159                        },
160                    )?;
161                }
162            }
163            backend.draw_circle(
164                (x, base),
165                4.5,
166                &PointStyle {
167                    color,
168                    alpha: 1.0,
169                    filled: true,
170                    ..Default::default()
171                },
172            )?;
173            if text.is_empty() {
174                continue;
175            }
176            let w = crate::render::text::text_width(&text, self.label_size) + 10.0;
177            // Keep the box inside the panel horizontally.
178            let cx = x.clamp(area.x + w / 2.0, area.x + area.width - w / 2.0);
179            let Some(lv) = pick_level(
180                &mut occupied,
181                cx - w / 2.0 - self.spacing / 2.0,
182                cx + w / 2.0 + self.spacing / 2.0,
183                max_levels,
184            ) else {
185                continue;
186            };
187            let cy = base - lv as f64 * step;
188            let edge = if lv > 0 {
189                cy + box_h / 2.0
190            } else {
191                cy - box_h / 2.0
192            };
193            super::clear_mark(backend);
194            backend.draw_line(
195                &[(x, base), (x, edge)],
196                &LineStyle {
197                    color,
198                    alpha: 0.8,
199                    width: 1.0,
200                    linetype: Linetype::Solid,
201                    ..Default::default()
202                },
203            )?;
204            backend.draw_rect(
205                (cx - w / 2.0, cy - box_h / 2.0),
206                (cx + w / 2.0, cy + box_h / 2.0),
207                &RectStyle {
208                    fill: Some(self.box_fill),
209                    stroke: Some(color),
210                    stroke_width: 1.0,
211                    alpha: 1.0,
212                    clip: false,
213                    corner_radius: 3.0,
214                },
215            )?;
216            backend.draw_text(
217                &text,
218                (cx, cy),
219                &TextStyle {
220                    color: self.label_color,
221                    size: self.label_size,
222                    anchor: TextAnchor::Middle,
223                    angle: 0.0,
224                    family: family.clone(),
225                    face: FontFace::Plain,
226                    bg_colour: None,
227                    bg_r: 0.0,
228                },
229            )?;
230        }
231        super::clear_mark(backend);
232        super::clear_row_extras(backend);
233        Ok(())
234    }
235
236    fn required_aes(&self) -> Vec<Aesthetic> {
237        vec![Aesthetic::X]
238    }
239
240    /// Span ends become `xmax` (after missing-position rows were dropped, so
241    /// a point event's empty end keeps its row) and train the x scale.
242    fn setup_data(&self, data: &mut DataFrame, _warnings: &mut Vec<String>) {
243        // A (hidden) y scale must exist for the panel's axes to be drawn.
244        let n = data.nrows();
245        for (c, v) in [("ymin", -1.0), ("ymax", 1.0)] {
246            if !data.has_column(c) {
247                data.add_column(c.into(), vec![Value::Float(v); n]);
248            }
249        }
250        if let Some(end) = self.end.as_deref().and_then(|c| data.column(c)) {
251            let end = end.to_vec();
252            if !data.has_column("xmax") {
253                data.add_column("xmax".into(), end);
254            }
255        }
256    }
257    fn default_stat(&self) -> Box<dyn Stat> {
258        Box::new(StatIdentity)
259    }
260    fn default_position(&self) -> Box<dyn Position> {
261        Box::new(PositionIdentity)
262    }
263    fn default_params(&self) -> GeomParams {
264        GeomParams::default()
265    }
266    fn name(&self) -> &str {
267        "timeline"
268    }
269    fn consumes_tooltips(&self) -> bool {
270        true
271    }
272    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
273        if let Some(ink) = el.ink {
274            self.label_color = ink;
275        }
276        if let (true, Some(paper)) = (self.box_fill == (255, 255, 255), el.paper) {
277            self.box_fill = paper;
278        }
279    }
280    fn set_series_color(&mut self, color: (u8, u8, u8)) {
281        self.color = color;
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn levels_alternate_and_avoid_overlap() {
291        assert_eq!((0..4).map(level).collect::<Vec<_>>(), vec![1, -1, 2, -2]);
292        let mut occ = Vec::new();
293        assert_eq!(pick_level(&mut occ, 0.0, 10.0, 3), Some(1));
294        assert_eq!(pick_level(&mut occ, 5.0, 15.0, 3), Some(-1));
295        assert_eq!(pick_level(&mut occ, 8.0, 12.0, 3), Some(2));
296        assert_eq!(pick_level(&mut occ, 20.0, 30.0, 3), Some(1), "free again");
297        assert_eq!(
298            pick_level(&mut occ, 0.0, 30.0, 1),
299            None,
300            "only ±1 allowed, both busy"
301        );
302    }
303}