1use crate::palette::Palette;
4use crate::series::Series;
5use rustlavel_core::Json;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Kind {
9 Line,
10 Bar,
11 HorizontalBar,
14 Doughnut,
15 Pie,
16 Radar,
17 Scatter,
19}
20
21impl Kind {
22 pub fn as_str(self) -> &'static str {
26 match self {
27 Kind::Line => "line",
28 Kind::Bar | Kind::HorizontalBar => "bar",
29 Kind::Doughnut => "doughnut",
30 Kind::Pie => "pie",
31 Kind::Radar => "radar",
32 Kind::Scatter => "scatter",
33 }
34 }
35
36 pub fn colours_each_point(self) -> bool {
38 matches!(self, Kind::Doughnut | Kind::Pie)
39 }
40
41 pub fn has_axes(self) -> bool {
43 matches!(self, Kind::Line | Kind::Bar | Kind::HorizontalBar | Kind::Scatter)
44 }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum Legend {
49 Top,
50 Right,
51 Bottom,
52 Left,
53 Hidden,
54}
55
56impl Legend {
57 fn as_str(self) -> &'static str {
58 match self {
59 Legend::Top => "top",
60 Legend::Right => "right",
61 Legend::Bottom => "bottom",
62 Legend::Left => "left",
63 Legend::Hidden => "top",
64 }
65 }
66}
67
68#[derive(Debug, Clone, Default, PartialEq)]
70pub struct Axis {
71 pub title: Option<String>,
72 pub begin_at_zero: bool,
80 pub min: Option<f64>,
81 pub max: Option<f64>,
82 pub prefix: Option<String>,
84 pub suffix: Option<String>,
86 pub stacked: bool,
87 pub hidden: bool,
88}
89
90impl Axis {
91 pub fn new() -> Axis {
92 Axis { begin_at_zero: true, ..Axis::default() }
93 }
94
95 pub fn title(mut self, title: impl Into<String>) -> Axis {
96 self.title = Some(title.into());
97 self
98 }
99
100 pub fn zoomed(mut self) -> Axis {
103 self.begin_at_zero = false;
104 self
105 }
106
107 pub fn range(mut self, min: f64, max: f64) -> Axis {
108 self.min = Some(min);
109 self.max = Some(max);
110 self.begin_at_zero = false;
111 self
112 }
113
114 pub fn prefix(mut self, prefix: impl Into<String>) -> Axis {
115 self.prefix = Some(prefix.into());
116 self
117 }
118
119 pub fn suffix(mut self, suffix: impl Into<String>) -> Axis {
120 self.suffix = Some(suffix.into());
121 self
122 }
123
124 pub fn stacked(mut self) -> Axis {
125 self.stacked = true;
126 self
127 }
128
129 pub fn hidden(mut self) -> Axis {
130 self.hidden = true;
131 self
132 }
133
134 fn to_json(&self, horizontal_categories: bool) -> Json {
135 let mut fields = vec![
136 ("display", Json::from(!self.hidden)),
137 ("stacked", Json::from(self.stacked)),
138 ];
139 if !horizontal_categories {
140 fields.push(("beginAtZero", Json::from(self.begin_at_zero)));
141 }
142 if let Some(min) = self.min {
143 fields.push(("min", Json::from(min)));
144 }
145 if let Some(max) = self.max {
146 fields.push(("max", Json::from(max)));
147 }
148 if let Some(title) = &self.title {
149 fields.push((
150 "title",
151 Json::object([("display", Json::from(true)), ("text", Json::from(title.as_str()))]),
152 ));
153 }
154 if self.prefix.is_some() || self.suffix.is_some() {
157 fields.push((
158 "rustlavelTicks",
159 Json::object([
160 ("prefix", self.prefix.as_deref().map_or(Json::Null, Json::from)),
161 ("suffix", self.suffix.as_deref().map_or(Json::Null, Json::from)),
162 ]),
163 ));
164 }
165 Json::object(fields)
166 }
167}
168
169#[derive(Debug, Clone, PartialEq)]
171pub struct Chart {
172 pub kind: Kind,
173 pub title: Option<String>,
174 pub labels: Vec<String>,
175 pub series: Vec<Series>,
176 pub palette: Palette,
177 pub legend: Legend,
178 pub x: Axis,
179 pub y: Axis,
180 pub right: Option<Axis>,
182 pub aspect_ratio: Option<f64>,
184 points: Vec<(String, Vec<(f64, f64)>)>,
186}
187
188impl Chart {
189 pub fn new(kind: Kind) -> Chart {
190 Chart {
191 kind,
192 title: None,
193 labels: Vec::new(),
194 series: Vec::new(),
195 palette: Palette::default(),
196 legend: Legend::Top,
197 x: Axis { begin_at_zero: false, ..Axis::default() },
198 y: Axis::new(),
199 right: None,
200 aspect_ratio: None,
201 points: Vec::new(),
202 }
203 }
204
205 pub fn line(title: impl Into<String>) -> Chart {
206 Chart::new(Kind::Line).title(title)
207 }
208
209 pub fn bar(title: impl Into<String>) -> Chart {
210 Chart::new(Kind::Bar).title(title)
211 }
212
213 pub fn horizontal_bar(title: impl Into<String>) -> Chart {
214 Chart::new(Kind::HorizontalBar).title(title)
215 }
216
217 pub fn doughnut(title: impl Into<String>) -> Chart {
218 Chart::new(Kind::Doughnut).title(title)
219 }
220
221 pub fn pie(title: impl Into<String>) -> Chart {
222 Chart::new(Kind::Pie).title(title)
223 }
224
225 pub fn scatter(title: impl Into<String>, label: impl Into<String>, points: impl IntoIterator<Item = (f64, f64)>) -> Chart {
227 let mut chart = Chart::new(Kind::Scatter).title(title);
228 chart.points.push((label.into(), points.into_iter().collect()));
229 chart.x = Axis::new();
230 chart
231 }
232
233 pub fn title(mut self, title: impl Into<String>) -> Chart {
234 self.title = Some(title.into());
235 self
236 }
237
238 pub fn untitled(mut self) -> Chart {
241 self.title = None;
242 self
243 }
244
245 pub fn labels(mut self, labels: impl IntoIterator<Item = impl Into<String>>) -> Chart {
246 self.labels = labels.into_iter().map(Into::into).collect();
247 self
248 }
249
250 pub fn series(mut self, series: Series) -> Chart {
251 self.series.push(series);
252 self
253 }
254
255 pub fn values(self, label: impl Into<String>, values: impl IntoIterator<Item = f64>) -> Chart {
257 self.series(Series::new(label, values))
258 }
259
260 pub fn palette(mut self, palette: Palette) -> Chart {
261 self.palette = palette;
262 self
263 }
264
265 pub fn legend(mut self, legend: Legend) -> Chart {
266 self.legend = legend;
267 self
268 }
269
270 pub fn x(mut self, axis: Axis) -> Chart {
271 self.x = axis;
272 self
273 }
274
275 pub fn y(mut self, axis: Axis) -> Chart {
276 self.y = axis;
277 self
278 }
279
280 pub fn right(mut self, axis: Axis) -> Chart {
282 self.right = Some(axis);
283 self
284 }
285
286 pub fn stacked(mut self) -> Chart {
288 self.x = Axis { stacked: true, ..self.x };
289 self.y = Axis { stacked: true, ..self.y };
290 self
291 }
292
293 pub fn aspect_ratio(mut self, ratio: f64) -> Chart {
294 self.aspect_ratio = Some(ratio);
295 self
296 }
297
298 pub fn problems(&self) -> Vec<String> {
304 let mut problems = Vec::new();
305 if self.series.is_empty() && self.points.is_empty() {
306 problems.push("the chart has no series, so it will draw an empty grid".to_string());
307 }
308 if self.kind != Kind::Scatter {
309 for series in &self.series {
310 if series.len() != self.labels.len() {
311 problems.push(format!(
312 "series `{}` has {} values against {} labels; Chart.js draws the shorter of the two",
313 series.label,
314 series.len(),
315 self.labels.len()
316 ));
317 }
318 }
319 }
320 if self.kind.colours_each_point() && self.series.len() > 1 {
321 problems.push(format!(
322 "a {} shows one set of slices; {} series were given and the rest will be drawn as rings nobody can read",
323 self.kind.as_str(),
324 self.series.len()
325 ));
326 }
327 problems
328 }
329
330 pub fn to_json(&self) -> Json {
332 let datasets: Vec<Json> = if self.kind == Kind::Scatter {
333 self.points
334 .iter()
335 .enumerate()
336 .map(|(index, (label, points))| {
337 Json::object([
338 ("label", Json::from(label.as_str())),
339 (
340 "data",
341 Json::Array(
342 points
343 .iter()
344 .map(|(x, y)| {
345 Json::object([("x", Json::from(*x)), ("y", Json::from(*y))])
346 })
347 .collect(),
348 ),
349 ),
350 ("borderColor", Json::from(self.palette.at(index))),
351 ("backgroundColor", Json::from(self.palette.at(index))),
352 ])
353 })
354 .collect()
355 } else {
356 self.series
357 .iter()
358 .enumerate()
359 .map(|(index, series)| series.to_json(index, &self.palette, self.kind.colours_each_point()))
360 .collect()
361 };
362
363 let mut plugins = vec![(
364 "legend",
365 Json::object([
366 ("display", Json::from(self.legend != Legend::Hidden)),
367 ("position", Json::from(self.legend.as_str())),
368 ]),
369 )];
370 if let Some(title) = &self.title {
371 plugins.push((
372 "title",
373 Json::object([("display", Json::from(true)), ("text", Json::from(title.as_str()))]),
374 ));
375 }
376
377 let mut options = vec![
378 ("responsive", Json::from(true)),
379 ("maintainAspectRatio", Json::from(self.aspect_ratio.is_some())),
380 ("plugins", Json::object(plugins)),
381 ];
382 if let Some(ratio) = self.aspect_ratio {
383 options.push(("aspectRatio", Json::from(ratio)));
384 }
385 if self.kind == Kind::HorizontalBar {
386 options.push(("indexAxis", Json::from("y")));
387 }
388 if self.kind.has_axes() {
389 let flipped = self.kind == Kind::HorizontalBar;
391 let (category, value) = if flipped { (&self.y, &self.x) } else { (&self.x, &self.y) };
392 let mut scales = vec![
393 ("x", if flipped { value.to_json(false) } else { category.to_json(true) }),
394 ("y", if flipped { category.to_json(true) } else { value.to_json(false) }),
395 ];
396 if let Some(right) = &self.right {
397 let mut axis = right.to_json(false);
398 if let Json::Object(fields) = &mut axis {
399 fields.insert("position".to_string(), Json::from("right"));
400 fields.insert(
403 "grid".to_string(),
404 Json::object([("drawOnChartArea", Json::from(false))]),
405 );
406 }
407 scales.push(("right", axis));
408 }
409 options.push(("scales", Json::object(scales)));
410 }
411
412 Json::object([
413 ("type", Json::from(self.kind.as_str())),
414 (
415 "data",
416 Json::object([
417 ("labels", Json::Array(self.labels.iter().map(|l| Json::from(l.as_str())).collect())),
418 ("datasets", Json::Array(datasets)),
419 ]),
420 ),
421 ("options", Json::object(options)),
422 ])
423 }
424}
425
426impl From<Chart> for Json {
427 fn from(chart: Chart) -> Json {
428 chart.to_json()
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 fn revenue() -> Chart {
437 Chart::line("Revenue")
438 .labels(["Jan", "Feb", "Mar"])
439 .series(Series::new("2026", [1.0, 2.0, 3.0]))
440 }
441
442 #[test]
443 fn the_shape_is_what_chart_js_reads() {
444 let json = revenue().to_json();
445 assert_eq!(json.get("type").and_then(Json::as_str), Some("line"));
446 assert_eq!(
447 json.get("data.labels").and_then(Json::as_array).map(|l| l.len()),
448 Some(3)
449 );
450 assert_eq!(
451 json.get("data.datasets").and_then(Json::as_array).map(|d| d.len()),
452 Some(1)
453 );
454 assert_eq!(json.get("options.plugins.title.text").and_then(Json::as_str), Some("Revenue"));
455 }
456
457 #[test]
460 fn the_value_axis_starts_at_zero_unless_asked_otherwise() {
461 let json = revenue().to_json();
462 assert_eq!(json.get("options.scales.y.beginAtZero").and_then(Json::as_bool), Some(true));
463
464 let zoomed = revenue().y(Axis::new().zoomed()).to_json();
465 assert_eq!(zoomed.get("options.scales.y.beginAtZero").and_then(Json::as_bool), Some(false));
466 }
467
468 #[test]
469 fn a_horizontal_bar_is_a_bar_with_the_axes_swapped() {
470 let chart = Chart::horizontal_bar("By channel")
471 .labels(["Virtual account", "QRIS"])
472 .values("count", [10.0, 20.0])
473 .y(Axis::new().title("Channel"))
474 .x(Axis::new().prefix("Rp "));
475 let json = chart.to_json();
476
477 assert_eq!(json.get("type").and_then(Json::as_str), Some("bar"));
478 assert_eq!(json.get("options.indexAxis").and_then(Json::as_str), Some("y"));
479 assert_eq!(json.get("options.scales.y.title.text").and_then(Json::as_str), Some("Channel"));
481 assert_eq!(json.get("options.scales.x.beginAtZero").and_then(Json::as_bool), Some(true));
483 assert_eq!(
484 json.get("options.scales.x.rustlavelTicks.prefix").and_then(Json::as_str),
485 Some("Rp ")
486 );
487 }
488
489 #[test]
490 fn a_second_axis_does_not_draw_a_second_grid() {
491 let json = revenue()
492 .series(Series::new("orders", [1.0, 2.0, 3.0]).right_axis())
493 .right(Axis::new().title("Orders"))
494 .to_json();
495
496 assert_eq!(json.get("options.scales.right.position").and_then(Json::as_str), Some("right"));
497 assert_eq!(
498 json.get("options.scales.right.grid.drawOnChartArea").and_then(Json::as_bool),
499 Some(false)
500 );
501 assert_eq!(
502 json.get("data.datasets").and_then(Json::as_array).unwrap()[1]
503 .get("yAxisID")
504 .and_then(Json::as_str),
505 Some("right")
506 );
507 }
508
509 #[test]
510 fn a_scatter_carries_pairs_rather_than_labels() {
511 let json = Chart::scatter("Latency", "p95", [(1.0, 20.0), (2.0, 24.0)]).to_json();
512 let points = json.get("data.datasets").and_then(Json::as_array).unwrap()[0]
513 .get("data")
514 .and_then(Json::as_array)
515 .unwrap()
516 .to_vec();
517 assert_eq!(points.len(), 2);
518 assert_eq!(points[1].get("x").and_then(Json::as_f64), Some(2.0));
519 assert_eq!(points[1].get("y").and_then(Json::as_f64), Some(24.0));
520 }
521
522 #[test]
523 fn problems_names_the_mismatch_rather_than_drawing_a_short_line() {
524 let chart = Chart::line("x").labels(["Jan", "Feb", "Mar"]).series(Series::new("a", [1.0, 2.0]));
525 let problems = chart.problems();
526 assert_eq!(problems.len(), 1, "{problems:?}");
527 assert!(problems[0].contains("2 values against 3 labels"), "{}", problems[0]);
528
529 assert!(Chart::line("x").problems()[0].contains("no series"));
530 assert!(revenue().problems().is_empty());
531
532 let rings = Chart::doughnut("x")
533 .labels(["a"])
534 .values("one", [1.0])
535 .series(Series::new("two", [1.0]));
536 assert!(rings.problems()[0].contains("one set of slices"), "{:?}", rings.problems());
537 }
538
539 #[test]
540 fn a_hidden_legend_is_hidden_but_still_has_a_valid_position() {
541 let json = revenue().legend(Legend::Hidden).to_json();
542 assert_eq!(json.get("options.plugins.legend.display").and_then(Json::as_bool), Some(false));
543 assert_eq!(json.get("options.plugins.legend.position").and_then(Json::as_str), Some("top"));
544 }
545}