charts_rs/charts/
heatmap_chart.rs

1// Licensed under the Apache License, Version 2.0 (the "License");
2// you may not use this file except in compliance with the License.
3// You may obtain a copy of the License at
4//
5//     http://www.apache.org/licenses/LICENSE-2.0
6//
7// Unless required by applicable law or agreed to in writing, software
8// distributed under the License is distributed on an "AS IS" BASIS,
9// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10// See the License for the specific language governing permissions and
11// limitations under the License.
12
13use super::canvas;
14use super::color::*;
15use super::common::*;
16use super::component::*;
17use super::font::measure_max_text_width_family;
18use super::params::*;
19use super::theme::{get_default_theme_name, get_theme, Theme, DEFAULT_Y_AXIS_WIDTH};
20use super::util::*;
21use super::Canvas;
22use crate::charts::measure_text_width_family;
23use charts_rs_derive::Chart;
24use std::sync::Arc;
25
26#[derive(Clone, Debug, Default)]
27pub struct HeatmapData {
28    pub index: usize,
29    pub value: f32,
30}
31
32impl From<(usize, f32)> for HeatmapData {
33    fn from(value: (usize, f32)) -> Self {
34        HeatmapData {
35            index: value.0,
36            value: value.1,
37        }
38    }
39}
40
41#[derive(Clone, Debug, Default)]
42pub struct HeatmapSeries {
43    pub data: Vec<HeatmapData>,
44    pub min: f32,
45    pub max: f32,
46    pub min_color: Color,
47    pub max_color: Color,
48    pub min_font_color: Color,
49    pub max_font_color: Color,
50}
51
52impl HeatmapSeries {
53    fn get_color(&self, value: f32) -> Color {
54        if value < self.min {
55            return self.min_color;
56        }
57        if value > self.max {
58            return self.max_color;
59        }
60        let percent = (value - self.min) / (self.max - self.min);
61        let get_value = |max: u8, min: u8| {
62            let offset = max.abs_diff(min);
63            let offset = (offset as f32 * percent) as u8;
64            if max > min {
65                min + offset
66            } else {
67                min - offset
68            }
69        };
70        Color {
71            r: get_value(self.max_color.r, self.min_color.r),
72            g: get_value(self.max_color.g, self.min_color.g),
73            b: get_value(self.max_color.b, self.min_color.b),
74            a: get_value(self.max_color.a, self.min_color.a),
75        }
76    }
77}
78
79#[derive(Clone, Debug, Default, Chart)]
80pub struct HeatmapChart {
81    pub width: f32,
82    pub height: f32,
83    pub x: f32,
84    pub y: f32,
85    pub margin: Box,
86    // no use, but for derive chart
87    series_list: Vec<Series>,
88    pub series: HeatmapSeries,
89    pub font_family: String,
90    pub background_color: Color,
91    pub is_light: bool,
92
93    // title
94    pub title_text: String,
95    pub title_font_size: f32,
96    pub title_font_color: Color,
97    pub title_font_weight: Option<String>,
98    pub title_margin: Option<Box>,
99    pub title_align: Align,
100    pub title_height: f32,
101
102    // sub title
103    pub sub_title_text: String,
104    pub sub_title_font_size: f32,
105    pub sub_title_font_color: Color,
106    pub sub_title_font_weight: Option<String>,
107    pub sub_title_margin: Option<Box>,
108    pub sub_title_align: Align,
109    pub sub_title_height: f32,
110
111    // legend
112    pub legend_font_size: f32,
113    pub legend_font_color: Color,
114    pub legend_font_weight: Option<String>,
115    pub legend_align: Align,
116    pub legend_margin: Option<Box>,
117    pub legend_category: LegendCategory,
118    pub legend_show: Option<bool>,
119
120    // x axis
121    pub x_axis_data: Vec<String>,
122    pub x_axis_height: f32,
123    pub x_axis_stroke_color: Color,
124    pub x_axis_font_size: f32,
125    pub x_axis_font_color: Color,
126    pub x_axis_font_weight: Option<String>,
127    pub x_axis_name_gap: f32,
128    pub x_axis_name_rotate: f32,
129    pub x_axis_margin: Option<Box>,
130    pub x_axis_hidden: bool,
131    pub x_boundary_gap: Option<bool>,
132
133    // y axis
134    pub y_axis_hidden: bool,
135    pub y_axis_data: Vec<String>,
136    y_axis_configs: Vec<YAxisConfig>,
137
138    // grid
139    grid_stroke_color: Color,
140    grid_stroke_width: f32,
141
142    // series
143    pub series_stroke_width: f32,
144    pub series_label_font_color: Color,
145    pub series_label_font_size: f32,
146    pub series_label_font_weight: Option<String>,
147    pub series_label_formatter: String,
148    pub series_colors: Vec<Color>,
149    pub series_symbol: Option<Symbol>,
150    pub series_smooth: bool,
151    pub series_fill: bool,
152}
153
154impl HeatmapChart {
155    fn fill_default(&mut self) {
156        if self.y_axis_configs[0].axis_stroke_color.is_zero() {
157            self.y_axis_configs[0].axis_stroke_color = self.x_axis_stroke_color;
158        }
159        self.y_axis_configs[0].axis_name_align = Some(Align::Center);
160        self.y_axis_configs[0].axis_split_number += 1;
161        if self.series.max_color.is_zero() {
162            self.series.max_color = (191, 68, 76).into();
163        }
164        if self.series.min_color.is_zero() {
165            self.series.min_color = (240, 217, 156).into();
166        }
167        if self.series.min_font_color.is_zero() {
168            self.series.min_font_color = (70, 70, 70).into();
169        }
170        if self.series.max_font_color.is_zero() {
171            self.series.max_font_color = (238, 238, 238).into();
172        }
173        if self.series.max == 0.0 {
174            let mut max = 0.0;
175            for item in self.series.data.iter() {
176                if item.value > max {
177                    max = item.value
178                }
179            }
180            self.series.max = max;
181        }
182    }
183    /// Creates a heatmap chart from json.
184    pub fn from_json(data: &str) -> canvas::Result<HeatmapChart> {
185        let mut h = HeatmapChart {
186            ..Default::default()
187        };
188        let value = h.fill_option(data)?;
189        if let Some(y_axis_data) = get_string_slice_from_value(&value, "y_axis_data") {
190            h.y_axis_data = y_axis_data;
191        }
192        if let Some(value) = value.get("series") {
193            if let Some(min) = get_f32_from_value(value, "min") {
194                h.series.min = min;
195            }
196            if let Some(max) = get_f32_from_value(value, "max") {
197                h.series.max = max;
198            }
199            if let Some(min_color) = get_color_from_value(value, "min_color") {
200                h.series.min_color = min_color;
201            }
202            if let Some(max_color) = get_color_from_value(value, "max_color") {
203                h.series.max_color = max_color;
204            }
205            if let Some(min_font_color) = get_color_from_value(value, "min_font_color") {
206                h.series.min_font_color = min_font_color;
207            }
208            if let Some(max_font_color) = get_color_from_value(value, "max_font_color") {
209                h.series.max_font_color = max_font_color;
210            }
211            if let Some(data) = value.get("data") {
212                let mut values = vec![];
213                if let Some(arr) = data.as_array() {
214                    for item in arr.iter() {
215                        if let Some(arr) = item.as_array() {
216                            if arr.len() != 2 {
217                                continue;
218                            }
219                            values.push(HeatmapData {
220                                index: arr[0].as_i64().unwrap_or_default() as usize,
221                                value: arr[1].as_f64().unwrap_or_default() as f32,
222                            });
223                        }
224                    }
225                }
226                h.series.data = values;
227            }
228        }
229        h.fill_default();
230        if let Some(x_axis_hidden) = get_bool_from_value(&value, "x_axis_hidden") {
231            h.x_axis_hidden = x_axis_hidden;
232        }
233        if let Some(y_axis_hidden) = get_bool_from_value(&value, "y_axis_hidden") {
234            h.y_axis_hidden = y_axis_hidden;
235        }
236        Ok(h)
237    }
238    /// Creates a heatmap chart with default theme.
239    pub fn new(
240        series_data: Vec<(usize, f32)>,
241        x_axis_data: Vec<String>,
242        y_axis_data: Vec<String>,
243    ) -> HeatmapChart {
244        HeatmapChart::new_with_theme(
245            series_data,
246            x_axis_data,
247            y_axis_data,
248            &get_default_theme_name(),
249        )
250    }
251    /// Creates a heatmap chart with custom theme.
252    pub fn new_with_theme(
253        series_data: Vec<(usize, f32)>,
254        x_axis_data: Vec<String>,
255        y_axis_data: Vec<String>,
256        theme: &str,
257    ) -> HeatmapChart {
258        let mut h = HeatmapChart {
259            x_axis_data,
260            y_axis_data,
261            ..Default::default()
262        };
263        let mut max = 0.0_f32;
264        let mut data = vec![];
265        for item in series_data.iter() {
266            if item.1 > max {
267                max = item.1;
268            }
269            data.push((*item).into());
270        }
271        h.series.data = data;
272        let theme = get_theme(theme);
273        h.fill_theme(theme);
274        h.fill_default();
275        h
276    }
277    /// Converts heatmap chart to svg.
278    pub fn svg(&self) -> canvas::Result<String> {
279        let mut c = Canvas::new_width_xy(self.width, self.height, self.x, self.y);
280
281        if self.x_axis_data.is_empty() || self.y_axis_data.is_empty() {
282            return Err(canvas::Error::Params {
283                message: "x axis or y axis can not be empty".to_string(),
284            });
285        }
286
287        self.render_background(c.child(Box::default()));
288        let mut x_axis_height = self.x_axis_height;
289        if self.x_axis_hidden {
290            x_axis_height = 0.0;
291        }
292
293        c.margin = self.margin.clone();
294
295        let title_height = self.render_title(c.child(Box::default()));
296
297        let legend_height = self.render_legend(c.child(Box::default()));
298        // get the max height of title and legend
299        let axis_top = if legend_height > title_height {
300            legend_height
301        } else {
302            title_height
303        };
304        let axis_height = c.height() - x_axis_height - axis_top;
305
306        // minus the height of top text area
307        if axis_top > 0.0 {
308            c = c.child(Box {
309                top: axis_top,
310                ..Default::default()
311            });
312        }
313        let mut y_axis_width = 0.0;
314        if !self.y_axis_hidden {
315            let max_text_width_box = measure_max_text_width_family(
316                &self.font_family,
317                self.y_axis_configs[0].axis_font_size,
318                self.y_axis_data.iter().map(|item| item.as_str()).collect(),
319            )?;
320            y_axis_width = max_text_width_box.width() + self.margin.left;
321            // y axis
322            let mut y_axis_data = self.y_axis_data.clone();
323            y_axis_data.reverse();
324            self.render_y_axis(
325                c.child_left_top(Box::default()),
326                y_axis_data,
327                axis_height,
328                y_axis_width,
329                0,
330            );
331        }
332        let axis_width = c.width() - y_axis_width;
333        // x axis
334        if !self.x_axis_hidden {
335            self.render_x_axis(
336                c.child(Box {
337                    top: c.height() - x_axis_height,
338                    left: y_axis_width,
339                    ..Default::default()
340                }),
341                self.x_axis_data.clone(),
342                axis_width,
343            );
344        }
345        let mut data = vec![None; self.x_axis_data.len() * self.y_axis_data.len()];
346        for item in self.series.data.iter() {
347            if item.index < data.len() {
348                data[item.index] = Some(item.value);
349            }
350        }
351
352        let x_unit = (axis_width - 1.0) / self.x_axis_data.len() as f32;
353        let y_unit = (axis_height - 1.0) / self.y_axis_data.len() as f32;
354        let mut c1 = c.child(Box {
355            left: y_axis_width + 1.0,
356            ..Default::default()
357        });
358        let y_axis_count = self.y_axis_data.len();
359        for i in 0..y_axis_count {
360            for j in 0..self.x_axis_data.len() {
361                let index = i * self.y_axis_data.len() + j;
362                let x = x_unit * j as f32;
363                // position of y axis starts from bottom
364                let y = y_unit * (y_axis_count - i - 1) as f32;
365                let mut text = "".to_string();
366                let mut font_color = self.series.min_font_color;
367                let color = if let Some(value) = data[index] {
368                    let percent = (value - self.series.min) / (self.series.max - self.series.min);
369                    if percent >= 0.8 {
370                        font_color = self.series.max_font_color;
371                    }
372
373                    text = format_series_value(value, &self.series_label_formatter);
374                    self.series.get_color(value)
375                } else {
376                    let mut color_index = j;
377                    if i % 2 != 0 {
378                        color_index += 1;
379                    }
380                    let mut color = self.background_color;
381                    let offset = 20;
382                    if color.is_light() {
383                        color.r -= offset;
384                        color.g -= offset;
385                        color.b -= offset;
386                    } else {
387                        color.r += offset;
388                        color.g += offset;
389                        color.b += offset;
390                    }
391                    if color_index % 2 != 0 {
392                        color = color.with_alpha(100);
393                    }
394                    color
395                };
396                c1.rect(Rect {
397                    color: Some(color),
398                    fill: Some(color),
399                    left: x,
400                    top: y,
401                    width: x_unit,
402                    height: y_unit,
403                    ..Default::default()
404                });
405                if !text.is_empty() {
406                    let mut x1 = x + x_unit / 2.0;
407                    let y1 = y + y_unit / 2.0;
408                    if let Ok(b) = measure_text_width_family(
409                        &self.font_family,
410                        self.series_label_font_size,
411                        &text,
412                    ) {
413                        x1 -= b.width() / 2.0;
414                    }
415                    c1.text(Text {
416                        text,
417                        font_family: Some(self.font_family.clone()),
418                        font_color: Some(font_color),
419                        font_size: Some(self.series_label_font_size),
420                        font_weight: self.series_label_font_weight.clone(),
421                        dominant_baseline: Some("central".to_string()),
422                        x: Some(x1),
423                        y: Some(y1),
424                        ..Default::default()
425                    });
426                }
427            }
428        }
429
430        c.svg()
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use crate::THEME_DARK;
437
438    use super::HeatmapChart;
439    use pretty_assertions::assert_eq;
440
441    #[test]
442    fn heatmap_chart_basic() {
443        let x_axis_data = vec![
444            "12a", "1a", "2a", "3a", "4a", "5a", "6a", "7a", "8a", "9a", "10a", "11a", "12p", "1p",
445            "2p", "3p", "4p", "5p", "6p", "7p", "8p", "9p", "10p", "11p",
446        ]
447        .iter()
448        .map(|item| item.to_string())
449        .collect();
450        let y_axis_data = [
451            "Saturday",
452            "Friday",
453            "Thursday",
454            "Wednesday",
455            "Tuesday",
456            "Monday",
457            "Sunday",
458        ]
459        .iter()
460        .map(|item| item.to_string())
461        .collect();
462        let mut heatmap_chart = HeatmapChart::new(
463            vec![
464                (0, 9.0),
465                (1, 3.0),
466                (7, 3.0),
467                (12, 3.0),
468                (24, 12.0),
469                (28, 10.0),
470                (31, 8.0),
471                (50, 4.0),
472                (63, 2.0),
473            ],
474            x_axis_data,
475            y_axis_data,
476        );
477        heatmap_chart.width = 800.0;
478        heatmap_chart.series.max = 10.0;
479
480        assert_eq!(
481            include_str!("../../asset/heatmap_chart/basic.svg"),
482            heatmap_chart.svg().unwrap()
483        );
484    }
485
486    #[test]
487    fn heatmap_chart_dark() {
488        let x_axis_data = vec![
489            "12a", "1a", "2a", "3a", "4a", "5a", "6a", "7a", "8a", "9a", "10a", "11a", "12p", "1p",
490            "2p", "3p", "4p", "5p", "6p", "7p", "8p", "9p", "10p", "11p",
491        ]
492        .iter()
493        .map(|item| item.to_string())
494        .collect();
495        let y_axis_data = [
496            "Saturday",
497            "Friday",
498            "Thursday",
499            "Wednesday",
500            "Tuesday",
501            "Monday",
502            "Sunday",
503        ]
504        .iter()
505        .map(|item| item.to_string())
506        .collect();
507        let mut heatmap_chart = HeatmapChart::new_with_theme(
508            vec![
509                (0, 9.0),
510                (1, 3.0),
511                (7, 3.0),
512                (12, 3.0),
513                (24, 12.0),
514                (28, 10.0),
515                (31, 8.0),
516                (50, 4.0),
517                (63, 2.0),
518            ],
519            x_axis_data,
520            y_axis_data,
521            THEME_DARK,
522        );
523        heatmap_chart.width = 800.0;
524        heatmap_chart.series.max = 10.0;
525
526        assert_eq!(
527            include_str!("../../asset/heatmap_chart/basic_dark.svg"),
528            heatmap_chart.svg().unwrap()
529        );
530    }
531
532    #[test]
533    fn heatmap_chart_no_axis() {
534        let x_axis_data = vec![
535            "12a", "1a", "2a", "3a", "4a", "5a", "6a", "7a", "8a", "9a", "10a", "11a", "12p", "1p",
536            "2p", "3p", "4p", "5p", "6p", "7p", "8p", "9p", "10p", "11p",
537        ]
538        .iter()
539        .map(|item| item.to_string())
540        .collect();
541        let y_axis_data = [
542            "Saturday",
543            "Friday",
544            "Thursday",
545            "Wednesday",
546            "Tuesday",
547            "Monday",
548            "Sunday",
549        ]
550        .iter()
551        .map(|item| item.to_string())
552        .collect();
553        let mut heatmap_chart = HeatmapChart::new(
554            vec![
555                (0, 9.0),
556                (1, 3.0),
557                (7, 3.0),
558                (12, 3.0),
559                (24, 12.0),
560                (28, 10.0),
561                (31, 8.0),
562                (50, 4.0),
563                (63, 2.0),
564            ],
565            x_axis_data,
566            y_axis_data,
567        );
568        heatmap_chart.width = 800.0;
569        heatmap_chart.series.max = 10.0;
570        heatmap_chart.x_axis_hidden = true;
571        heatmap_chart.y_axis_hidden = true;
572
573        assert_eq!(
574            include_str!("../../asset/heatmap_chart/no_axis.svg"),
575            heatmap_chart.svg().unwrap()
576        );
577    }
578}