1use crate::{ExtractLabels, MetricTimeseries};
4use chrono::{DateTime, FixedOffset, TimeZone, Utc};
5use plotters::prelude::*;
6use std::{
7 borrow::Borrow,
8 error::Error,
9 fmt::{Debug, Display, Write},
10 ops::Range,
11 path::Path,
12};
13
14#[non_exhaustive]
16pub struct PlotStyle {
17 drawing_area: (u32, u32),
19 background: RGBAColor,
21 grid: RGBAColor,
23 axis: RGBAColor,
25 text_color: RGBAColor,
27 text_font: String,
29 text_size: u32,
31 caption_size: u32,
33 data_colors: Vec<RGBAColor>,
35 threshold_color: RGBAColor,
37 skip_labels: Vec<String>,
39 utc_offset: FixedOffset,
41 title: Option<String>,
43 line_width: u32,
45}
46
47impl Default for PlotStyle {
48 fn default() -> Self {
49 Self {
50 drawing_area: (1920, 1200),
51 background: WHITE.into(),
52 grid: RGBAColor(100, 100, 100, 0.5),
53 axis: BLACK.into(),
54 text_color: BLACK.into(),
55 text_font: "sans-serif".into(),
56 text_size: 18,
57 caption_size: 36,
58 data_colors: [
59 GREEN,
60 BLUE,
61 full_palette::ORANGE,
62 YELLOW,
63 MAGENTA,
64 full_palette::TEAL,
65 full_palette::PURPLE,
66 ]
67 .iter()
68 .cloned()
69 .map(Into::into)
70 .collect(),
71 threshold_color: RED.mix(0.2),
72 skip_labels: vec!["job".into(), "instance".into()],
73 utc_offset: FixedOffset::east_opt(0).unwrap(),
74 title: None,
75 line_width: 1,
76 }
77 }
78}
79
80impl PlotStyle {
81 pub fn with_drawing_area(mut self, drawing_area: impl Into<(u32, u32)>) -> Self {
83 self.drawing_area = drawing_area.into();
84 self
85 }
86
87 pub fn with_title(mut self, title: impl Into<String>) -> Self {
90 self.title = Some(title.into());
91 self
92 }
93
94 pub fn with_utc_offset(mut self, offset: FixedOffset) -> Self {
97 self.utc_offset = offset;
98 self
99 }
100
101 pub fn with_line_width(mut self, width: u32) -> Self {
103 self.line_width = width;
104 self
105 }
106
107 pub fn with_skip_labels(mut self, labels: Vec<String>) -> Self {
109 self.skip_labels = labels;
110 self
111 }
112
113 pub fn with_background(mut self, color: impl Into<RGBAColor>) -> Self {
115 self.background = color.into();
116 self
117 }
118
119 pub fn with_grid_color(mut self, color: impl Into<RGBAColor>) -> Self {
121 self.grid = color.into();
122 self
123 }
124
125 pub fn with_axis_color(mut self, color: impl Into<RGBAColor>) -> Self {
127 self.axis = color.into();
128 self
129 }
130
131 pub fn with_text_color(mut self, color: impl Into<RGBAColor>) -> Self {
133 self.text_color = color.into();
134 self
135 }
136
137 pub fn with_text_font(mut self, font: impl Into<String>) -> Self {
139 self.text_font = font.into();
140 self
141 }
142
143 pub fn with_text_size(mut self, size: u32) -> Self {
145 self.text_size = size;
146 self
147 }
148
149 pub fn with_caption_size(mut self, size: u32) -> Self {
151 self.caption_size = size;
152 self
153 }
154
155 pub fn with_data_colors(mut self, colors: Vec<RGBAColor>) -> Self {
158 self.data_colors = colors;
159 self
160 }
161
162 pub fn with_threshold_color(mut self, color: impl Into<RGBAColor>) -> Self {
165 self.threshold_color = color.into();
166 self
167 }
168}
169
170pub enum PlotThreshold {
172 GreaterThan(f64),
174 LessThan(f64),
176}
177
178impl PlotStyle {
179 pub fn dark_mode(mut self) -> Self {
181 self.background = BLACK.into();
182 self.grid = RGBAColor(100, 100, 100, 0.5);
183 self.axis = WHITE.into();
184 self.text_color = WHITE.into();
185 self
186 }
187
188 pub fn plot_timeseries<KV, K, V>(
191 &self,
192 path: impl AsRef<Path>,
193 mts: &[MetricTimeseries<KV>],
194 plot_threshold: Option<PlotThreshold>,
195 ) -> Result<(), Box<dyn Error + Send + Sync>>
196 where
197 KV: Clone + Debug,
198 K: Display,
199 V: Display,
200 for<'a> &'a KV: IntoIterator<Item = (&'a K, &'a V)>,
201 {
202 let ExtractLabels {
204 name,
205 common_labels,
206 specific_labels,
207 } = ExtractLabels::new(mts.iter().map(|mts| &mts.metric), &self.skip_labels);
208 let PreparedPlot {
209 x_range,
210 y_range,
211 ts,
212 } = PreparedPlot::prepare(mts)?;
213
214 let mut caption = if let Some(title) = &self.title {
218 title.clone()
219 } else {
220 let mut c = name;
221 if !common_labels.is_empty() {
222 write!(&mut c, " {common_labels:?}")?;
223 }
224 c
225 };
226
227 let start_date_naive = x_range.start.with_timezone(&self.utc_offset).date_naive();
231 let date_format_str =
232 if start_date_naive == x_range.end.with_timezone(&self.utc_offset).date_naive() {
233 write!(&mut caption, " {start_date_naive}")?;
234 "%H:%M:%S"
235 } else {
236 "%m/%d %H:%M:%S"
237 };
238
239 write!(&mut caption, " UTC{}", self.utc_offset)?;
241
242 let root_area = BitMapBackend::new(&path, self.drawing_area).into_drawing_area();
244 root_area.fill(&self.background)?;
245
246 let mut ctx = ChartBuilder::on(&root_area)
247 .set_label_area_size(LabelAreaPosition::Left, 100)
248 .set_label_area_size(LabelAreaPosition::Bottom, 40)
249 .caption(
250 caption,
251 (self.text_font.as_str(), self.caption_size, &self.text_color)
252 .into_text_style(&root_area),
253 )
254 .build_cartesian_2d(x_range.clone(), y_range.clone())?;
255
256 let text_style =
257 (self.text_font.as_str(), self.text_size, &self.text_color).into_text_style(&root_area);
258
259 ctx.configure_mesh()
260 .light_line_style(self.grid) .axis_style(self.axis) .bold_line_style(self.axis) .label_style(text_style.clone())
264 .x_label_formatter(&|x| {
265 x.with_timezone(&self.utc_offset)
266 .format(date_format_str)
267 .to_string()
268 })
269 .draw()?;
270
271 if let Some(threshold) = plot_threshold {
272 let (limit, baseline) = match threshold {
273 PlotThreshold::GreaterThan(limit) => (limit, y_range.end),
274 PlotThreshold::LessThan(limit) => (limit, y_range.start),
275 };
276 ctx.draw_series(AreaSeries::new(
277 [(x_range.start, limit), (x_range.end, limit)],
278 baseline,
279 self.threshold_color,
280 ))?;
281 }
282
283 let show_legend = specific_labels.len() > 1;
285
286 for (idx, (mut metric, vals)) in specific_labels.into_iter().zip(ts.into_iter()).enumerate()
287 {
288 let color = &self.data_colors[idx % self.data_colors.len()];
289 let style = ShapeStyle::from(color).stroke_width(self.line_width);
290
291 let name = metric.remove("__name__").unwrap_or_default();
292 let label = format!("{name} {metric:?}");
293
294 let series = ctx.draw_series(LineSeries::new(vals, style))?;
295 if show_legend {
296 series
297 .label(label)
298 .legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], style));
299 }
300 }
301
302 if show_legend {
303 ctx.configure_series_labels()
304 .position(SeriesLabelPosition::LowerLeft)
305 .border_style(self.axis)
306 .background_style(self.background.mix(0.8))
307 .label_font(text_style)
308 .draw()?;
309 }
310
311 root_area.present()?;
314
315 Ok(())
316 }
317}
318
319struct PreparedPlot {
320 x_range: Range<DateTime<Utc>>,
321 y_range: Range<f64>,
322 ts: Vec<Vec<(DateTime<Utc>, f64)>>,
323}
324
325impl PreparedPlot {
326 fn prepare<KV>(data: &[impl Borrow<MetricTimeseries<KV>>]) -> Result<Self, &'static str>
327 where
328 KV: Clone + Debug,
329 {
330 let mut x_range = None;
331 let mut y_range = None;
332
333 let ts: Vec<Vec<(_, f64)>> = data
334 .iter()
335 .map(|mts| {
336 let mts = mts.borrow();
337
338 mts.values
339 .iter()
340 .filter_map(|(k, v)| {
341 let x = f64_to_datetime(k)?;
342 let y = *v.as_ref();
343 extend_range(&mut x_range, &x);
344 extend_range(&mut y_range, &y);
345
346 Some((x, y))
347 })
348 .collect::<Vec<_>>()
349 })
350 .collect();
351
352 let x_range = x_range.ok_or("No data")?;
353 let y_range = y_range.ok_or("No data")?;
354
355 Ok(Self {
356 x_range,
357 y_range,
358 ts,
359 })
360 }
361}
362
363fn f64_to_datetime(t: &f64) -> Option<DateTime<Utc>> {
364 let seconds = t.trunc() as i64;
365 let nanoseconds = (t.fract() * 1_000_000_000.0) as u32;
366
367 Utc.timestamp_opt(seconds, nanoseconds).single()
368}
369
370fn extend_range<T: PartialOrd + Clone>(range: &mut Option<Range<T>>, val: &T) {
371 if let Some(range) = range.as_mut() {
372 if range.start > *val {
373 range.start = val.clone();
374 } else if range.end < *val {
375 range.end = val.clone();
376 }
377 } else {
378 *range = Some(val.clone()..val.clone())
379 }
380}