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