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