plotters_layout/
chart.rs

1use std::fmt::Debug;
2
3use plotters::coord::ranged1d::AsRangedCoord;
4use plotters::coord::Shift;
5use plotters::prelude::*;
6use plotters::style::FontError;
7
8const INDEX_TOP: usize = 0;
9const INDEX_BOTTOM: usize = 1;
10const INDEX_LEFT: usize = 2;
11const INDEX_RIGHT: usize = 3;
12
13type DrawingResult<T, DB> = Result<T, DrawingAreaErrorKind<<DB as DrawingBackend>::ErrorType>>;
14
15type ChartContext2d<'a, DB, X, Y> = ChartContext<
16    'a,
17    DB,
18    Cartesian2d<<X as AsRangedCoord>::CoordDescType, <Y as AsRangedCoord>::CoordDescType>,
19>;
20
21/// Specifies layout of chart before creating [`DrawingArea`]
22#[derive(Clone)]
23pub struct ChartLayout<'a> {
24    title_height: u32,
25    title_content: Option<(String, TextStyle<'a>, u32)>,
26    margin: [u32; 4],
27    label_area_size: [u32; 4],
28}
29
30impl<'a> Debug for ChartLayout<'a> {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        f.debug_struct("ChartLayout")
33            .field("title_height", &self.title_height)
34            .field(
35                "title_content",
36                &self.title_content.as_ref().map(|(t, _, _)| t),
37            )
38            .field("margin", &self.margin)
39            .field("label_area_size", &self.label_area_size)
40            .finish()
41    }
42}
43
44fn estimate_text_size(text: &str, font: &FontDesc) -> Result<(u32, u32), FontError> {
45    let text_layout = font.layout_box(text)?;
46    Ok((
47        ((text_layout.1).0 - (text_layout.0).0) as u32,
48        ((text_layout.1).1 - (text_layout.0).1) as u32,
49    ))
50}
51
52impl<'a> ChartLayout<'a> {
53    pub fn new() -> Self {
54        Self {
55            label_area_size: [0; 4],
56            title_height: 0,
57            title_content: None,
58            margin: [0; 4],
59        }
60    }
61
62    pub fn set_all_label_area_size(
63        &mut self,
64        top: u32,
65        bottom: u32,
66        left: u32,
67        right: u32,
68    ) -> &mut Self {
69        self.label_area_size = [top, bottom, left, right];
70        self
71    }
72
73    pub fn x_label_area_size(&mut self, size: u32) -> &mut Self {
74        self.label_area_size[INDEX_BOTTOM] = size;
75        self
76    }
77
78    pub fn y_label_area_size(&mut self, size: u32) -> &mut Self {
79        self.label_area_size[INDEX_LEFT] = size;
80        self
81    }
82
83    pub fn top_x_label_area_size(&mut self, size: u32) -> &mut Self {
84        self.label_area_size[INDEX_TOP] = size;
85        self
86    }
87
88    pub fn right_y_label_area_size(&mut self, size: u32) -> &mut Self {
89        self.label_area_size[INDEX_RIGHT] = size;
90        self
91    }
92
93    pub fn set_all_margin(&mut self, top: u32, bottom: u32, left: u32, right: u32) -> &mut Self {
94        self.margin = [top, bottom, left, right];
95        self
96    }
97
98    pub fn margin(&mut self, size: u32) -> &mut Self {
99        self.margin = [size, size, size, size];
100        self
101    }
102
103    pub fn margin_top(&mut self, size: u32) -> &mut Self {
104        self.margin[INDEX_TOP] = size;
105        self
106    }
107
108    pub fn margin_bottom(&mut self, size: u32) -> &mut Self {
109        self.margin[INDEX_BOTTOM] = size;
110        self
111    }
112
113    pub fn margin_left(&mut self, size: u32) -> &mut Self {
114        self.margin[INDEX_LEFT] = size;
115        self
116    }
117
118    pub fn margin_right(&mut self, size: u32) -> &mut Self {
119        self.margin[INDEX_RIGHT] = size;
120        self
121    }
122
123    // Clears caption text and area information for caption
124    pub fn no_caption(&mut self) -> &mut Self {
125        self.title_height = 0;
126        self.title_content = None;
127        self
128    }
129
130    /// Sets new caption text and calculates caption area above the chart.
131    pub fn caption(
132        &mut self,
133        text: impl Into<String>,
134        font: impl Into<FontDesc<'a>>,
135    ) -> Result<&mut Self, FontError> {
136        let text: String = text.into();
137        let font: FontDesc = font.into();
138        let (_, text_h) = estimate_text_size(&text, &font)?;
139        let style: TextStyle = font.into();
140        let y_padding = (text_h / 2).min(5);
141        self.title_height = y_padding * 2 + text_h;
142        self.title_content = Some((text, style, y_padding));
143        Ok(self)
144    }
145
146    /// Replaces caption test, without updating layout.
147    ///
148    /// It is needed to call [`caption()`](Self::caption) in order to make caption visible.
149    pub fn replace_caption(&mut self, text: impl Into<String>) -> &mut Self {
150        let text: String = text.into();
151        if let Some((_, style, y_padding)) = self.title_content.take() {
152            self.title_content = Some((text, style, y_padding));
153        }
154        self
155    }
156
157    fn additional_sizes(&self) -> (u32, u32) {
158        let [m_top, m_bottom, m_left, m_right] = self.margin;
159        let [l_top, l_bottom, l_left, l_right] = self.label_area_size;
160        let width = m_left + m_right + l_left + l_right;
161        let height = self.title_height + m_top + m_bottom + l_top + l_bottom;
162        (width, height)
163    }
164
165    /// Size of root area whose plotting area will be equal to `plot_size`.
166    ///
167    /// An [`DrawingArea`] with returned size should be given for [`bind()`](Self::bind).
168    pub fn desired_image_size(&self, plot_size: (u32, u32)) -> (u32, u32) {
169        let additional = self.additional_sizes();
170        (plot_size.0 + additional.0, plot_size.1 + additional.1)
171    }
172
173    /// Estimates required root-area height from its width and the aspect ratio of the plotting area.
174    ///
175    /// `aspect_ratio` is the ratio of plotting-area height to its width.
176    pub fn desired_image_height_from_width(&self, image_width: u32, aspect_ratio: f64) -> u32 {
177        let additional = self.additional_sizes();
178        if image_width < additional.0 {
179            additional.1
180        } else {
181            ((image_width - additional.0) as f64 * aspect_ratio) as u32 + additional.1
182        }
183    }
184
185    /// Bind layout information to an actual root area.
186    pub fn bind<'b, DB>(
187        &self,
188        root_area: &'b DrawingArea<DB, Shift>,
189    ) -> DrawingResult<ChartLayoutBuilder<'b, DB>, DB>
190    where
191        'a: 'b,
192        DB: DrawingBackend,
193    {
194        use plotters::style::text_anchor::{HPos, Pos, VPos};
195
196        let title_area_height = self.title_height;
197        let main_area = if title_area_height > 0 {
198            let (title_area, main_area) = root_area.split_vertically(title_area_height);
199            if let Some((text, style, y_padding)) = &self.title_content {
200                let dim = title_area.dim_in_pixel();
201                let x_padding = dim.0 / 2;
202                let style = &style.pos(Pos::new(HPos::Center, VPos::Top));
203                title_area.draw_text(text, style, (x_padding as i32, *y_padding as i32))?;
204                main_area
205            } else {
206                main_area
207            }
208        } else {
209            root_area.clone()
210        };
211        Ok(ChartLayoutBuilder {
212            layout: self.clone(),
213            main_area,
214        })
215    }
216}
217
218impl<'a> Default for ChartLayout<'a> {
219    fn default() -> Self {
220        Self::new()
221    }
222}
223
224pub struct ChartLayoutBuilder<'a, DB: DrawingBackend> {
225    layout: ChartLayout<'a>,
226    main_area: DrawingArea<DB, Shift>,
227}
228
229impl<'a, DB: DrawingBackend> ChartLayoutBuilder<'a, DB> {
230    /// Estimates size of the plotting area in pixels.
231    ///
232    /// Can be used to determine plotting value range to pass to [`build_cartesian_2d`](Self::build_cartesian_2d).
233    pub fn estimate_plot_area_size(&self) -> (u32, u32) {
234        let [m_top, m_bottom, m_left, m_right] = self.layout.margin;
235        let [l_top, l_bottom, l_left, l_right] = self.layout.label_area_size;
236        // main_area does not include caption part
237        let (image_width, image_height) = self.main_area.dim_in_pixel();
238        let plot_width = image_width - (m_left + m_right + l_left + l_right);
239        let plot_height = image_height - (m_top + m_bottom + l_top + l_bottom);
240        (plot_width, plot_height)
241    }
242
243    pub fn build_cartesian_2d<X: AsRangedCoord, Y: AsRangedCoord>(
244        &self,
245        x_spec: X,
246        y_spec: Y,
247    ) -> DrawingResult<ChartContext2d<DB, X, Y>, DB> {
248        let [m_top, m_bottom, m_left, m_right] = self.layout.margin;
249        let [l_top, l_bottom, l_left, l_right] = self.layout.label_area_size;
250
251        let mut builder = ChartBuilder::on(&self.main_area);
252
253        builder
254            .margin_top(m_top)
255            .margin_bottom(m_bottom)
256            .margin_left(m_left)
257            .margin_right(m_right)
258            .set_label_area_size(LabelAreaPosition::Top, l_top)
259            .set_label_area_size(LabelAreaPosition::Bottom, l_bottom)
260            .set_label_area_size(LabelAreaPosition::Left, l_left)
261            .set_label_area_size(LabelAreaPosition::Right, l_right);
262
263        builder.build_cartesian_2d(x_spec, y_spec)
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use std::error::Error;
270    use std::ops::Range;
271
272    use plotters::backend::RGBPixel;
273    use plotters::prelude::*;
274
275    use super::ChartLayout;
276
277    #[test]
278    fn size_estimation() -> Result<(), Box<dyn Error>> {
279        let x_spec = 0.0..2.0;
280        let y_spec = -1.0..2.0;
281        let plot_size = (200, 350);
282        let mut layout = ChartLayout::new();
283
284        for i in 0..0x200 {
285            layout.set_all_margin(
286                if i & 0x1 == 0 { 5 } else { 0 },
287                if i & 0x2 == 0 { 10 } else { 0 },
288                if i & 0x4 == 0 { 12 } else { 0 },
289                if i & 0x8 == 0 { 15 } else { 0 },
290            );
291            layout.set_all_label_area_size(
292                if i & 0x10 == 0 { 20 } else { 0 },
293                if i & 0x20 == 0 { 25 } else { 0 },
294                if i & 0x40 == 0 { 30 } else { 0 },
295                if i & 0x80 == 0 { 32 } else { 0 },
296            );
297            if i & 0x100 == 0 {
298                layout.caption("Test Title", ("sans-serif", 20))?;
299            } else {
300                layout.no_caption();
301            }
302            bmp2d_size_estimation(&layout, plot_size, x_spec.clone(), y_spec.clone())?;
303        }
304
305        Ok(())
306    }
307
308    fn bmp2d_size_estimation(
309        layout: &ChartLayout,
310        plot_size: (u32, u32),
311        x_spec: Range<f64>,
312        y_spec: Range<f64>,
313    ) -> Result<(), Box<dyn Error>> {
314        let image_size = layout.desired_image_size(plot_size);
315
316        let mut buf = vec![0u8; (3 * image_size.0 * image_size.1) as usize];
317        let backend: BitMapBackend<RGBPixel> =
318            BitMapBackend::with_buffer_and_format(&mut buf, image_size)?;
319        let root_area = backend.into_drawing_area();
320
321        let builder = layout.bind(&root_area)?;
322        let estimated_plot_size = builder.estimate_plot_area_size();
323        assert_eq!(
324            plot_size, estimated_plot_size,
325            "wrong estimation; layout = {layout:?}, image_size = {image_size:?}"
326        );
327
328        let chart = builder.build_cartesian_2d(x_spec, y_spec)?;
329        let actual_size = chart.plotting_area().dim_in_pixel();
330
331        assert_eq!(
332            plot_size, actual_size,
333            "wrong actual size, layout = {layout:?}, image_size = {image_size:?}"
334        );
335        Ok(())
336    }
337}