Skip to main content

ggplot_rs/geom/
refline.rs

1//! Reference lines: `geom_hline`, `geom_vline`, `geom_abline`.
2//!
3//! Each geom draws one line per row of its layer data when the matching
4//! aesthetic column is present (`yintercept`, `xintercept`, or
5//! `slope`/`intercept` — ggplot2's data-mapped form, which also honours
6//! per-row `color` / `linetype` / `alpha` and facet panels), and otherwise
7//! falls back to the constant stored in the geom. As in ggplot2:
8//!
9//! - `xintercept` / `yintercept` train the x / y position scale (the line is
10//!   always inside the panel); `slope` / `intercept` train nothing;
11//! - reference lines do not inherit the plot-level mapping (`inherit.aes =
12//!   FALSE`);
13//! - `geom_abline` lives in data space (in the scales' transformed space for
14//!   e.g. `scale_x_log10`), clipped to the panel.
15
16use crate::aes::Aesthetic;
17use crate::coord::Coord;
18use crate::data::{DataFrame, Value};
19use crate::position::identity::PositionIdentity;
20use crate::position::Position;
21use crate::render::backend::{DrawBackend, LineStyle, Linetype};
22use crate::render::RenderError;
23use crate::scale::ScaleSet;
24use crate::stat::identity::StatIdentity;
25use crate::stat::Stat;
26use crate::theme::Theme;
27
28use super::support::{clip_polyline_unit, map_position};
29use super::{Geom, GeomParams};
30
31/// Per-row line style: mapped `color`/`linetype`/`alpha`, else the defaults.
32fn row_style(
33    data: &DataFrame,
34    i: usize,
35    scales: &ScaleSet,
36    color: (u8, u8, u8),
37    width: f64,
38    linetype: Linetype,
39    alpha: f64,
40) -> LineStyle {
41    let color = data
42        .column("color")
43        .and_then(|c| c.get(i))
44        .and_then(|v| scales.map_color(&Aesthetic::Color, v))
45        .unwrap_or(color);
46    let linetype = data
47        .column("linetype")
48        .and_then(|c| c.get(i))
49        .and_then(|v| scales.map_linetype(v))
50        .unwrap_or(linetype);
51    let alpha = data
52        .column("alpha")
53        .and_then(|c| c.get(i))
54        .and_then(|v| scales.map_alpha(v).or_else(|| v.as_f64()))
55        .filter(|a| a.is_finite())
56        .unwrap_or(alpha);
57    LineStyle {
58        color,
59        alpha,
60        width,
61        linetype,
62        ..Default::default()
63    }
64}
65
66/// Hover metadata for one reference line: `data-series` (the row's colour /
67/// group level) and `data-value` (the intercept), with a `"<series>: y = v"`
68/// tooltip — replaced by the layer's `tooltip_template` (#165), e.g.
69/// `.tooltip("{xintercept}")`, when one is set (`set_row_extras` only
70/// overrides the tooltip when the internal tooltip column actually
71/// exists, so a layer with no template keeps this default).
72///
73/// B2-4: with `svg_formatted_values` on, the value is formatted through the
74/// matching position scale (`what == "x"` → the x scale, else y) instead of
75/// the plain numeric display — in the tooltip, and (for a vline, which is
76/// the only caller that passes an `axis`) in `data-x` itself: a raw
77/// epoch-seconds number is not materially useful even un-formatted, so this
78/// one replaces rather than just adding `data-fx` alongside it.
79fn mark_line(
80    backend: &mut dyn DrawBackend,
81    scales: &ScaleSet,
82    data: &DataFrame,
83    i: Option<usize>,
84    what: &str,
85    value: &Value,
86    axis: Option<String>,
87) {
88    let series = i.and_then(|i| super::series_key(data, i));
89    let raw = super::raw_value(value);
90    let aes = if what == "x" {
91        Aesthetic::X
92    } else {
93        Aesthetic::Y
94    };
95    let formatted = if backend.wants_formatted_values() {
96        scales.get(&aes).and_then(|s| s.format_value(value))
97    } else {
98        None
99    };
100    let shown = formatted.clone().unwrap_or_else(|| super::tip_value(value));
101    let tip = raw.as_ref().map(|_| match &series {
102        Some(s) => format!("{s}: {what} = {shown}"),
103        None => format!("{what} = {shown}"),
104    });
105    let axis = axis.map(|_| formatted.unwrap_or(shown));
106    super::set_mark(backend, tip, axis, series, raw);
107    super::set_mark_formatted(
108        backend,
109        scales,
110        (what == "x").then_some(value),
111        &Aesthetic::Y,
112        (what == "y").then_some(value),
113    );
114    // Call set_mark (above) first so a `tooltip()` template — rendered
115    // into the same internal column `set_row_extras` reads — wins over
116    // this geom's own default tooltip, same as every other
117    // `consumes_tooltips` geom (point, segment, path, ...).
118    if let Some(i) = i {
119        super::set_row_extras(backend, data, i);
120    }
121}
122
123/// The rows to draw: one per row of `col` (finite values only), or the
124/// constant when the column is absent.
125fn line_rows(data: &DataFrame, col: &str, constant: f64) -> Vec<(Option<usize>, Value)> {
126    match data.column(col) {
127        Some(c) => c
128            .iter()
129            .enumerate()
130            .filter(|(_, v)| v.as_f64().is_some_and(f64::is_finite))
131            .map(|(i, v)| (Some(i), v.clone()))
132            .collect(),
133        None if constant.is_finite() => vec![(None, Value::Float(constant))],
134        None => vec![],
135    }
136}
137
138/// Horizontal reference line spanning the panel width.
139pub struct GeomHline {
140    pub yintercept: f64,
141    pub color: (u8, u8, u8),
142    pub width: f64,
143    pub linetype: Linetype,
144    pub alpha: f64,
145}
146
147impl GeomHline {
148    pub fn new(yintercept: f64) -> Self {
149        GeomHline {
150            yintercept,
151            color: (0, 0, 0),
152            width: 1.0,
153            linetype: Linetype::Dashed,
154            alpha: 1.0,
155        }
156    }
157
158    /// A data-mapped hline: draws one line per row of the `yintercept`
159    /// aesthetic (see [`GGPlot::geom_hline_aes`](crate::plot::GGPlot::geom_hline_aes)).
160    pub fn mapped() -> Self {
161        Self::new(f64::NAN)
162    }
163}
164
165impl Geom for GeomHline {
166    fn draw(
167        &self,
168        data: &DataFrame,
169        coord: &dyn Coord,
170        scales: &ScaleSet,
171        _theme: &Theme,
172        backend: &mut dyn DrawBackend,
173    ) -> Result<(), RenderError> {
174        let plot_area = backend.plot_area();
175        let y_scale = scales.get(&Aesthetic::Y);
176        for (row, value) in line_rows(data, "yintercept", self.yintercept) {
177            let ny = map_position(y_scale, &value).unwrap_or(0.5);
178            if !ny.is_finite() {
179                continue;
180            }
181            let style = match row {
182                Some(i) => row_style(
183                    data,
184                    i,
185                    scales,
186                    self.color,
187                    self.width,
188                    self.linetype.clone(),
189                    self.alpha,
190                ),
191                None => LineStyle {
192                    color: self.color,
193                    alpha: self.alpha,
194                    width: self.width,
195                    linetype: self.linetype.clone(),
196                    ..Default::default()
197                },
198            };
199            mark_line(backend, scales, data, row, "y", &value, None);
200            let pts = sample_segment(coord, &plot_area, (0.0, ny), (1.0, ny));
201            backend.draw_line(&pts, &style)?;
202        }
203        super::clear_mark(backend);
204        super::clear_row_extras(backend);
205        Ok(())
206    }
207
208    fn required_aes(&self) -> Vec<Aesthetic> {
209        vec![]
210    }
211    fn default_stat(&self) -> Box<dyn Stat> {
212        Box::new(StatIdentity)
213    }
214    fn default_position(&self) -> Box<dyn Position> {
215        Box::new(PositionIdentity)
216    }
217    fn default_params(&self) -> GeomParams {
218        GeomParams::default()
219    }
220    fn name(&self) -> &str {
221        "hline"
222    }
223    fn inherit_aes(&self) -> bool {
224        false
225    }
226    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
227        if let (true, Some(ink)) = (self.color == (0, 0, 0), el.ink) {
228            self.color = ink;
229        }
230    }
231    fn consumes_tooltips(&self) -> bool {
232        true
233    }
234}
235
236/// Vertical reference line spanning the panel height.
237pub struct GeomVline {
238    pub xintercept: f64,
239    pub color: (u8, u8, u8),
240    pub width: f64,
241    pub linetype: Linetype,
242    pub alpha: f64,
243}
244
245impl GeomVline {
246    pub fn new(xintercept: f64) -> Self {
247        GeomVline {
248            xintercept,
249            color: (0, 0, 0),
250            width: 1.0,
251            linetype: Linetype::Dashed,
252            alpha: 1.0,
253        }
254    }
255
256    /// A data-mapped vline: one line per row of the `xintercept` aesthetic.
257    pub fn mapped() -> Self {
258        Self::new(f64::NAN)
259    }
260}
261
262impl Geom for GeomVline {
263    fn draw(
264        &self,
265        data: &DataFrame,
266        coord: &dyn Coord,
267        scales: &ScaleSet,
268        _theme: &Theme,
269        backend: &mut dyn DrawBackend,
270    ) -> Result<(), RenderError> {
271        let plot_area = backend.plot_area();
272        let x_scale = scales.get(&Aesthetic::X);
273        for (row, value) in line_rows(data, "xintercept", self.xintercept) {
274            let nx = map_position(x_scale, &value).unwrap_or(0.5);
275            if !nx.is_finite() {
276                continue;
277            }
278            let style = match row {
279                Some(i) => row_style(
280                    data,
281                    i,
282                    scales,
283                    self.color,
284                    self.width,
285                    self.linetype.clone(),
286                    self.alpha,
287                ),
288                None => LineStyle {
289                    color: self.color,
290                    alpha: self.alpha,
291                    width: self.width,
292                    linetype: self.linetype.clone(),
293                    ..Default::default()
294                },
295            };
296            let axis = Some(super::tip_value(&value));
297            mark_line(backend, scales, data, row, "x", &value, axis);
298            let pts = sample_segment(coord, &plot_area, (nx, 0.0), (nx, 1.0));
299            backend.draw_line(&pts, &style)?;
300        }
301        super::clear_mark(backend);
302        super::clear_row_extras(backend);
303        Ok(())
304    }
305
306    fn required_aes(&self) -> Vec<Aesthetic> {
307        vec![]
308    }
309    fn default_stat(&self) -> Box<dyn Stat> {
310        Box::new(StatIdentity)
311    }
312    fn default_position(&self) -> Box<dyn Position> {
313        Box::new(PositionIdentity)
314    }
315    fn default_params(&self) -> GeomParams {
316        GeomParams::default()
317    }
318    fn name(&self) -> &str {
319        "vline"
320    }
321    fn inherit_aes(&self) -> bool {
322        false
323    }
324    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
325        if let (true, Some(ink)) = (self.color == (0, 0, 0), el.ink) {
326            self.color = ink;
327        }
328    }
329    fn consumes_tooltips(&self) -> bool {
330        true
331    }
332}
333
334/// Line `y = intercept + slope · x` in data space, spanning the panel and
335/// clipped to it.
336pub struct GeomAbline {
337    pub slope: f64,
338    pub intercept: f64,
339    pub color: (u8, u8, u8),
340    pub width: f64,
341    pub linetype: Linetype,
342    pub alpha: f64,
343}
344
345impl GeomAbline {
346    pub fn new(slope: f64, intercept: f64) -> Self {
347        GeomAbline {
348            slope,
349            intercept,
350            color: (0, 0, 0),
351            width: 1.0,
352            linetype: Linetype::Dashed,
353            alpha: 1.0,
354        }
355    }
356
357    /// A data-mapped abline: one line per row of the `slope` / `intercept`
358    /// aesthetics (a missing one defaults to slope 1 / intercept 0).
359    pub fn mapped() -> Self {
360        Self::new(1.0, 0.0)
361    }
362}
363
364/// The data-space x range spanned by the panel (normalized 0 → 1).
365fn panel_x_range(scales: &ScaleSet) -> (f64, f64) {
366    match scales.get(&Aesthetic::X) {
367        Some(s) if s.is_discrete() => (0.5, s.breaks().len().max(1) as f64 + 0.5),
368        Some(s) => s.expanded_domain().unwrap_or((0.0, 1.0)),
369        None => (0.0, 1.0),
370    }
371}
372
373/// The normalized (panel) polyline of `y = a + b·x` across the panel,
374/// clipped to the unit square. Empty when the line misses the panel.
375pub(crate) fn abline_runs(scales: &ScaleSet, slope: f64, intercept: f64) -> Vec<Vec<(f64, f64)>> {
376    if !(slope.is_finite() && intercept.is_finite()) {
377        return vec![];
378    }
379    let (x0, x1) = panel_x_range(scales);
380    let y_scale = scales.get(&Aesthetic::Y);
381    let ny = |x: f64| {
382        let y = intercept + slope * x;
383        match y_scale {
384            Some(_) => map_position(y_scale, &Value::Float(y)).unwrap_or(f64::NAN),
385            None => y,
386        }
387    };
388    clip_polyline_unit(&[(0.0, ny(x0)), (1.0, ny(x1))])
389}
390
391/// Transform a normalized segment to pixels — densely sampled under polar /
392/// radar coordinates (where a straight data line is curved), else its two
393/// endpoints.
394fn sample_segment(
395    coord: &dyn Coord,
396    plot_area: &crate::render::Rect,
397    a: (f64, f64),
398    b: (f64, f64),
399) -> Vec<(f64, f64)> {
400    let n = if coord.is_polar() || coord.is_radar() {
401        64
402    } else {
403        1
404    };
405    (0..=n)
406        .map(|k| {
407            let t = k as f64 / n as f64;
408            coord.transform((a.0 + t * (b.0 - a.0), a.1 + t * (b.1 - a.1)), plot_area)
409        })
410        .collect()
411}
412
413impl Geom for GeomAbline {
414    fn draw(
415        &self,
416        data: &DataFrame,
417        coord: &dyn Coord,
418        scales: &ScaleSet,
419        _theme: &Theme,
420        backend: &mut dyn DrawBackend,
421    ) -> Result<(), RenderError> {
422        let plot_area = backend.plot_area();
423        let slope_col = data.column("slope");
424        let icpt_col = data.column("intercept");
425        let rows: Vec<(Option<usize>, f64, f64)> = if slope_col.is_some() || icpt_col.is_some() {
426            (0..data.nrows())
427                .map(|i| {
428                    let get = |c: Option<&[Value]>, d: f64| {
429                        c.map_or(Some(d), |c| c.get(i).and_then(|v| v.as_f64()))
430                    };
431                    (i, get(slope_col, 1.0), get(icpt_col, 0.0))
432                })
433                .filter_map(|(i, s, a)| Some((Some(i), s?, a?)))
434                .collect()
435        } else {
436            vec![(None, self.slope, self.intercept)]
437        };
438        for (row, slope, intercept) in rows {
439            let style = match row {
440                Some(i) => row_style(
441                    data,
442                    i,
443                    scales,
444                    self.color,
445                    self.width,
446                    self.linetype.clone(),
447                    self.alpha,
448                ),
449                None => LineStyle {
450                    color: self.color,
451                    alpha: self.alpha,
452                    width: self.width,
453                    linetype: self.linetype.clone(),
454                    ..Default::default()
455                },
456            };
457            let series = row.and_then(|i| super::series_key(data, i));
458            let label = format!(
459                "y = {} + {}·x",
460                super::tip_value(&Value::Float(intercept)),
461                super::tip_value(&Value::Float(slope))
462            );
463            let tip = Some(match &series {
464                Some(s) => format!("{s}: {label}"),
465                None => label,
466            });
467            super::set_mark(
468                backend,
469                tip,
470                None,
471                series,
472                super::raw_value(&Value::Float(slope)),
473            );
474            // Slope/intercept aren't on either position scale — reset only.
475            super::set_mark_formatted(backend, scales, None, &Aesthetic::Y, None);
476            // #165: a `tooltip()` template wins over the default
477            // "y = a + b·x" label above, same as the other refline geoms.
478            if let Some(i) = row {
479                super::set_row_extras(backend, data, i);
480            }
481            for run in abline_runs(scales, slope, intercept) {
482                let mut pts = Vec::new();
483                for w in run.windows(2) {
484                    let seg = sample_segment(coord, &plot_area, w[0], w[1]);
485                    if !pts.is_empty() {
486                        pts.pop();
487                    }
488                    pts.extend(seg);
489                }
490                backend.draw_line(&pts, &style)?;
491            }
492        }
493        super::clear_mark(backend);
494        super::clear_row_extras(backend);
495        Ok(())
496    }
497
498    fn required_aes(&self) -> Vec<Aesthetic> {
499        vec![]
500    }
501    fn default_stat(&self) -> Box<dyn Stat> {
502        Box::new(StatIdentity)
503    }
504    fn default_position(&self) -> Box<dyn Position> {
505        Box::new(PositionIdentity)
506    }
507    fn default_params(&self) -> GeomParams {
508        GeomParams::default()
509    }
510    fn name(&self) -> &str {
511        "abline"
512    }
513    fn inherit_aes(&self) -> bool {
514        false
515    }
516    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
517        if let (true, Some(ink)) = (self.color == (0, 0, 0), el.ink) {
518            self.color = ink;
519        }
520    }
521    fn consumes_tooltips(&self) -> bool {
522        true
523    }
524}
525
526#[cfg(test)]
527mod fixed_value_tests {
528    use crate::data::Value;
529    use crate::prelude::*;
530
531    /// A plain `geom_hline`/`geom_vline`/`geom_abline` with a fixed value
532    /// (no per-row data mapping) takes the "no row" branch of `row_style`'s
533    /// match — just the geom's own configured colour/width/linetype/alpha,
534    /// not a per-row override.
535    #[test]
536    fn fixed_value_reflines_render_without_a_data_mapping() {
537        let svg = GGPlot::new(vec![
538            ("x".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
539            ("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
540        ])
541        .aes(Aes::new().x("x").y("y"))
542        .geom_point()
543        .geom_hline(0.5)
544        .geom_vline(0.5)
545        .geom_abline(1.0, 0.0)
546        .render_svg_native_with_size(200, 200)
547        .unwrap();
548
549        assert!(!svg.contains("NaN"));
550        // 3 reference lines + the data polyline-less point layer: at least
551        // 3 extra polylines beyond gridlines/axes exist.
552        assert!(svg.matches("<polyline").count() >= 3);
553    }
554}