Skip to main content

ggplot_rs/geom/
windbarb.rs

1//! Wind barbs: `geom_windbarb`. The standard meteorological glyph — a shaft
2//! pointing to where the wind blows *from*, with half barbs (5 kt), full
3//! barbs (10 kt) and pennants (50 kt) at its outer end; calm (< 2.5 kt) is a
4//! circle. Speeds are rounded to the nearest 5 kt.
5
6use crate::aes::Aesthetic;
7use crate::coord::Coord;
8use crate::data::{DataFrame, Value};
9use crate::position::identity::PositionIdentity;
10use crate::position::Position;
11use crate::render::backend::{DrawBackend, LineStyle, Linetype, PointStyle, RectStyle};
12use crate::render::RenderError;
13use crate::scale::ScaleSet;
14use crate::stat::identity::StatIdentity;
15use crate::stat::Stat;
16use crate::theme::Theme;
17
18use super::{Geom, GeomParams};
19
20/// Speed unit of the speed column.
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
22pub enum WindUnit {
23    #[default]
24    Knots,
25    MetersPerSecond,
26}
27
28/// Wind barbs. Map `x` (and optionally `y`; without it the barbs sit in a
29/// row across the panel); name the speed and direction columns.
30pub struct GeomWindbarb {
31    /// Speed column.
32    pub speed: String,
33    /// Direction column: degrees the wind blows *from*, clockwise from north.
34    pub direction: String,
35    pub unit: WindUnit,
36    /// Shaft length in pixels.
37    pub length: f64,
38    pub color: (u8, u8, u8),
39    pub line_width: f64,
40}
41
42impl GeomWindbarb {
43    pub fn new(speed: impl Into<String>, direction: impl Into<String>) -> Self {
44        GeomWindbarb {
45            speed: speed.into(),
46            direction: direction.into(),
47            unit: WindUnit::Knots,
48            length: 28.0,
49            color: (40, 40, 40),
50            line_width: 1.3,
51        }
52    }
53
54    /// Speeds are in metres per second (converted to knots for the glyph).
55    pub fn meters_per_second(mut self) -> Self {
56        self.unit = WindUnit::MetersPerSecond;
57        self
58    }
59}
60
61/// `(pennants, full barbs, half barbs)` for a speed in knots (rounded to 5).
62pub(crate) fn barb_parts(knots: f64) -> (usize, usize, usize) {
63    let s = (knots.max(0.0) / 5.0).round() as usize * 5;
64    (s / 50, (s % 50) / 10, (s % 10) / 5)
65}
66
67/// A pixel position.
68type Px = (f64, f64);
69
70/// Line segments and pennant triangles (pixel coordinates) of one barb at
71/// `at`, wind from `dir` degrees, `knots` speed, shaft length `len`.
72pub(crate) fn barb_glyph(
73    at: (f64, f64),
74    dir: f64,
75    knots: f64,
76    len: f64,
77) -> (Vec<[Px; 2]>, Vec<[Px; 3]>) {
78    let rad = dir.to_radians();
79    // Screen y grows downward: north is −y.
80    let u = (rad.sin(), -rad.cos());
81    // Barbs on the clockwise side of the shaft (northern-hemisphere style).
82    let side = (-u.1, u.0);
83    let tip = (at.0 + u.0 * len, at.1 + u.1 * len);
84    let barb = 0.42 * len;
85    let gap = 0.13 * len;
86    let along = |d: f64| (tip.0 - u.0 * d, tip.1 - u.1 * d);
87    // Barbs lean toward the tip.
88    let out = |p: (f64, f64), l: f64| {
89        (
90            p.0 + side.0 * l + u.0 * l * 0.35,
91            p.1 + side.1 * l + u.1 * l * 0.35,
92        )
93    };
94    let (pennants, full, half) = barb_parts(knots);
95    let mut lines = vec![[at, tip]];
96    let mut tris = Vec::new();
97    let mut d = 0.0;
98    for _ in 0..pennants {
99        let (a, b) = (along(d), along(d + gap));
100        tris.push([a, out(a, barb), b]);
101        d += gap * 1.15;
102    }
103    for _ in 0..full {
104        let p = along(d);
105        lines.push([p, out(p, barb)]);
106        d += gap;
107    }
108    for _ in 0..half {
109        // A lone half barb sits one step in from the tip, as on charts.
110        if pennants == 0 && full == 0 {
111            d += gap;
112        }
113        let p = along(d);
114        lines.push([p, out(p, barb / 2.0)]);
115    }
116    (lines, tris)
117}
118
119impl Geom for GeomWindbarb {
120    fn draw(
121        &self,
122        data: &DataFrame,
123        coord: &dyn Coord,
124        scales: &ScaleSet,
125        _theme: &Theme,
126        backend: &mut dyn DrawBackend,
127    ) -> Result<(), RenderError> {
128        let x_col = data
129            .column("x")
130            .ok_or(RenderError::MissingAesthetic("x".into()))?;
131        let y_col = data.column("y");
132        let speed = data
133            .column(&self.speed)
134            .ok_or_else(|| RenderError::MissingAesthetic(self.speed.clone()))?;
135        let dir = data
136            .column(&self.direction)
137            .ok_or_else(|| RenderError::MissingAesthetic(self.direction.clone()))?;
138        let color_col = data.column("color");
139        let area = backend.plot_area();
140        let mx = |v: &Value| scales.get(&Aesthetic::X).map(|s| s.map(v)).unwrap_or(0.5);
141        let my = |v: &Value| scales.get(&Aesthetic::Y).map(|s| s.map(v)).unwrap_or(0.5);
142        let factor = match self.unit {
143            WindUnit::Knots => 1.0,
144            WindUnit::MetersPerSecond => 1.943_844,
145        };
146        for i in 0..data.nrows() {
147            let (Some(s), Some(d)) = (speed[i].as_f64(), dir[i].as_f64()) else {
148                continue;
149            };
150            let ny = y_col.map(|c| my(&c[i])).unwrap_or(0.5);
151            let at = coord.transform((mx(&x_col[i]), ny), &area);
152            if !(at.0.is_finite() && at.1.is_finite() && s.is_finite() && d.is_finite()) {
153                continue;
154            }
155            let color = color_col
156                .and_then(|c| scales.map_color(&Aesthetic::Color, &c[i]))
157                .unwrap_or(self.color);
158            let knots = s * factor;
159            super::set_mark(
160                backend,
161                Some(format!(
162                    "{}: {} from {}°",
163                    super::tip_value(&x_col[i]),
164                    super::tip_value(&speed[i]),
165                    super::tip_value(&dir[i])
166                )),
167                Some(super::tip_value(&x_col[i])),
168                super::series_key(data, i),
169                super::raw_value(&speed[i]),
170            );
171            super::set_row_extras(backend, data, i);
172            if knots < 2.5 {
173                backend.draw_circle(
174                    at,
175                    4.0,
176                    &PointStyle {
177                        color,
178                        alpha: 1.0,
179                        filled: false,
180                        ..Default::default()
181                    },
182                )?;
183                continue;
184            }
185            let (lines, tris) = barb_glyph(at, d, knots, self.length);
186            let style = LineStyle {
187                color,
188                alpha: 1.0,
189                width: self.line_width,
190                linetype: Linetype::Solid,
191                ..Default::default()
192            };
193            for l in &lines {
194                backend.draw_line(l, &style)?;
195            }
196            for t in &tris {
197                backend.draw_polygon(
198                    t,
199                    &RectStyle {
200                        fill: Some(color),
201                        stroke: Some(color),
202                        stroke_width: 0.5,
203                        alpha: 1.0,
204                        clip: true,
205                        corner_radius: 0.0,
206                    },
207                )?;
208            }
209        }
210        super::clear_mark(backend);
211        super::clear_row_extras(backend);
212        Ok(())
213    }
214
215    fn required_aes(&self) -> Vec<Aesthetic> {
216        vec![Aesthetic::X]
217    }
218    fn default_stat(&self) -> Box<dyn Stat> {
219        Box::new(StatIdentity)
220    }
221    fn default_position(&self) -> Box<dyn Position> {
222        Box::new(PositionIdentity)
223    }
224    fn default_params(&self) -> GeomParams {
225        GeomParams::default()
226    }
227    fn name(&self) -> &str {
228        "windbarb"
229    }
230    fn consumes_tooltips(&self) -> bool {
231        true
232    }
233    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
234        if let (true, Some(ink)) = (self.color == (40, 40, 40), el.ink) {
235            self.color = ink;
236        }
237    }
238    /// Without a `y` mapping a hidden y extent keeps the panel's axes.
239    fn setup_data(&self, data: &mut DataFrame, _warnings: &mut Vec<String>) {
240        if !data.has_column("y") {
241            let n = data.nrows();
242            for (c, v) in [("ymin", 0.0), ("ymax", 1.0)] {
243                if !data.has_column(c) {
244                    data.add_column(c.into(), vec![Value::Float(v); n]);
245                }
246            }
247        }
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn barb_counts_follow_the_wmo_convention() {
257        assert_eq!(barb_parts(0.0), (0, 0, 0));
258        assert_eq!(barb_parts(5.0), (0, 0, 1));
259        assert_eq!(barb_parts(15.0), (0, 1, 1));
260        assert_eq!(barb_parts(65.0), (1, 1, 1));
261        assert_eq!(barb_parts(102.0), (2, 0, 0), "rounded to 100");
262        assert_eq!(barb_parts(147.0), (2, 4, 1));
263        assert_eq!(barb_parts(148.0), (3, 0, 0), "rounded to 150");
264    }
265
266    #[test]
267    fn shaft_points_into_the_wind() {
268        // Wind from the north: the shaft runs straight up from the station.
269        let (lines, _) = barb_glyph((0.0, 0.0), 0.0, 10.0, 30.0);
270        let [a, b] = lines[0];
271        assert!((b.0 - a.0).abs() < 1e-9 && (b.1 - a.1 + 30.0).abs() < 1e-9);
272        // From the east: to the right.
273        let (lines, tris) = barb_glyph((0.0, 0.0), 90.0, 50.0, 30.0);
274        assert!((lines[0][1].0 - 30.0).abs() < 1e-9);
275        assert_eq!(tris.len(), 1, "one pennant");
276    }
277}