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