1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
8#[serde(tag = "type", rename_all = "camelCase")]
9pub enum ChartElement {
10 Svg {
11 viewbox: ViewBox,
12 width: Option<f64>,
13 height: Option<f64>,
14 class: String,
15 children: Vec<ChartElement>,
16 },
17 Group {
18 class: String,
19 transform: Option<Transform>,
20 children: Vec<ChartElement>,
21 },
22 Rect {
23 x: f64,
24 y: f64,
25 width: f64,
26 height: f64,
27 fill: String,
28 stroke: Option<String>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
36 rx: Option<f64>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 ry: Option<f64>,
40 class: String,
41 data: Option<ElementData>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
50 animation_origin: Option<(f64, f64)>,
51 },
52 #[serde(rename_all = "camelCase")]
53 Path {
54 d: String,
55 fill: Option<String>,
56 stroke: Option<String>,
57 stroke_width: Option<f64>,
58 stroke_dasharray: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 stroke_dashoffset: Option<String>,
61 opacity: Option<f64>,
62 class: String,
63 data: Option<ElementData>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
69 animation_origin: Option<(f64, f64)>,
70 },
71 Circle {
72 cx: f64,
73 cy: f64,
74 r: f64,
75 fill: String,
76 stroke: Option<String>,
77 class: String,
78 data: Option<ElementData>,
79 },
80 #[serde(rename_all = "camelCase")]
81 Line {
82 x1: f64,
83 y1: f64,
84 x2: f64,
85 y2: f64,
86 stroke: String,
87 stroke_width: Option<f64>,
88 stroke_dasharray: Option<String>,
89 class: String,
90 },
91 #[serde(rename_all = "camelCase")]
92 Text {
93 x: f64,
94 y: f64,
95 content: String,
96 anchor: TextAnchor,
97 dominant_baseline: Option<String>,
98 transform: Option<Transform>,
99 font_family: Option<String>,
100 font_size: Option<String>,
101 font_weight: Option<String>,
102 letter_spacing: Option<String>,
103 text_transform: Option<String>,
104 fill: Option<String>,
105 class: String,
106 data: Option<ElementData>,
107 },
108 Div {
110 class: String,
111 style: HashMap<String, String>,
112 children: Vec<ChartElement>,
113 },
114 Span {
116 class: String,
117 style: HashMap<String, String>,
118 content: String,
119 },
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct ElementData {
125 pub label: String,
126 pub value: String,
127 pub series: Option<String>,
128 pub raw: HashMap<String, serde_json::Value>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct ViewBox {
133 pub x: f64,
134 pub y: f64,
135 pub width: f64,
136 pub height: f64,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub enum Transform {
141 Translate(f64, f64),
142 Rotate(f64, f64, f64),
143 Multiple(Vec<Transform>),
144}
145
146#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
147pub enum TextAnchor {
148 Start,
149 Middle,
150 End,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct Dimensions {
155 pub width: Option<f64>,
156 pub height: f64,
157}
158
159impl ViewBox {
160 pub fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
161 Self { x, y, width, height }
162 }
163
164 pub fn to_svg_string(&self) -> String {
166 format!("{} {} {} {}", self.x, self.y, self.width, self.height)
167 }
168}
169
170impl std::fmt::Display for ViewBox {
171 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172 write!(f, "{} {} {} {}", self.x, self.y, self.width, self.height)
173 }
174}
175
176impl Transform {
177 pub fn to_svg_string(&self) -> String {
179 match self {
180 Transform::Translate(x, y) => format!("translate({},{})", x, y),
181 Transform::Rotate(angle, cx, cy) => format!("rotate({},{},{})", angle, cx, cy),
182 Transform::Multiple(transforms) => {
183 transforms.iter().map(|t| t.to_svg_string()).collect::<Vec<_>>().join(" ")
184 }
185 }
186 }
187}
188
189impl std::fmt::Display for Transform {
190 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191 write!(f, "{}", self.to_svg_string())
192 }
193}
194
195impl std::fmt::Display for TextAnchor {
196 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197 match self {
198 TextAnchor::Start => write!(f, "start"),
199 TextAnchor::Middle => write!(f, "middle"),
200 TextAnchor::End => write!(f, "end"),
201 }
202 }
203}
204
205impl ElementData {
206 pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
207 Self {
208 label: label.into(),
209 value: value.into(),
210 series: None,
211 raw: HashMap::new(),
212 }
213 }
214
215 pub fn with_series(mut self, series: impl Into<String>) -> Self {
216 self.series = Some(series.into());
217 self
218 }
219}
220
221impl Dimensions {
222 pub fn new(height: f64) -> Self {
223 Self { width: None, height }
224 }
225
226 pub fn with_width(mut self, width: f64) -> Self {
227 self.width = Some(width);
228 self
229 }
230}
231
232pub fn count_elements<F>(element: &ChartElement, predicate: &F) -> usize
234where
235 F: Fn(&ChartElement) -> bool,
236{
237 let mut count = if predicate(element) { 1 } else { 0 };
238 match element {
239 ChartElement::Svg { children, .. }
240 | ChartElement::Group { children, .. }
241 | ChartElement::Div { children, .. } => {
242 for child in children {
243 count += count_elements(child, predicate);
244 }
245 }
246 _ => {}
247 }
248 count
249}
250
251pub const LEGACY_LABEL_FONT_SIZE: &str = "12px";
282
283pub const LEGACY_LEGEND_FONT_SIZE: &str = "11px";
287
288pub const LEGACY_FONT_WEIGHT: u16 = 400;
292
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296pub enum TextRole {
297 AxisLabel,
299 TickValue,
302 LegendLabel,
304}
305
306#[derive(Debug, Clone)]
310pub struct TextStyle {
311 pub font_family: Option<String>,
312 pub font_size: Option<String>,
313 pub font_weight: Option<String>,
314 pub letter_spacing: Option<String>,
315 pub text_transform: Option<String>,
316}
317
318impl TextStyle {
319 pub fn for_role(theme: &crate::theme::Theme, role: TextRole) -> Self {
325 use crate::theme::{TextTransform, Theme};
326
327 let default_theme = Theme::default();
328
329 let (family, default_family, size_px, default_size_px, legacy_size) = match role {
330 TextRole::AxisLabel => (
331 &theme.label_font_family,
332 &default_theme.label_font_family,
333 theme.label_font_size,
334 default_theme.label_font_size,
335 LEGACY_LABEL_FONT_SIZE,
336 ),
337 TextRole::TickValue => (
338 &theme.numeric_font_family,
339 &default_theme.numeric_font_family,
340 theme.numeric_font_size,
341 default_theme.numeric_font_size,
342 LEGACY_LABEL_FONT_SIZE,
343 ),
344 TextRole::LegendLabel => (
345 &theme.legend_font_family,
346 &default_theme.legend_font_family,
347 theme.legend_font_size,
348 default_theme.legend_font_size,
349 LEGACY_LEGEND_FONT_SIZE,
350 ),
351 };
352 let weight = match role {
353 TextRole::AxisLabel | TextRole::TickValue => theme.label_font_weight,
354 TextRole::LegendLabel => theme.legend_font_weight,
355 };
356
357 let letter_spacing = theme.label_letter_spacing;
361 let text_transform = &theme.label_text_transform;
362
363 let font_family = if family == default_family {
369 None
370 } else {
371 Some(family.clone())
372 };
373
374 let font_size = if (size_px - default_size_px).abs() < f32::EPSILON {
380 Some(legacy_size.to_string())
381 } else {
382 Some(format!("{}px", format_px(size_px)))
383 };
384
385 let font_weight = if weight == LEGACY_FONT_WEIGHT {
386 None
387 } else {
388 Some(weight.to_string())
389 };
390
391 let letter_spacing = if letter_spacing == 0.0 {
396 None
397 } else {
398 Some(format_px(letter_spacing))
399 };
400
401 let text_transform = match text_transform {
402 TextTransform::None => None,
403 TextTransform::Uppercase => Some("uppercase".to_string()),
404 TextTransform::Lowercase => Some("lowercase".to_string()),
405 };
406
407 Self {
408 font_family,
409 font_size,
410 font_weight,
411 letter_spacing,
412 text_transform,
413 }
414 }
415}
416
417pub fn emit_dot_halo_if_enabled(
442 theme: &crate::theme::Theme,
443 cx: f64,
444 cy: f64,
445 r: f64,
446) -> Option<ChartElement> {
447 let color = theme.dot_halo_color.as_ref()?;
448 let d = format!(
451 "M {cx},{cy} m -{r},0 a {r},{r} 0 1,0 {d2},0 a {r},{r} 0 1,0 -{d2},0",
452 cx = cx,
453 cy = cy,
454 r = r,
455 d2 = 2.0 * r,
456 );
457 Some(ChartElement::Path {
458 d,
459 fill: None,
460 stroke: Some(color.clone()),
461 stroke_width: Some(theme.dot_halo_width as f64),
462 stroke_dasharray: None,
463 stroke_dashoffset: None,
464 opacity: None,
465 class: "dot-halo".to_string(),
466 data: None,
467 animation_origin: None,
468 })
469}
470
471fn format_px(v: f32) -> String {
475 if v.fract() == 0.0 {
476 format!("{}", v as i64)
477 } else {
478 format!("{}", v)
479 }
480}
481
482#[cfg(test)]
483mod tests {
484 #![allow(clippy::unwrap_used)]
485 use super::*;
486
487 #[test]
488 fn viewbox_display() {
489 let vb = ViewBox::new(0.0, 0.0, 800.0, 400.0);
490 assert_eq!(vb.to_string(), "0 0 800 400");
491 }
492
493 #[test]
494 fn transform_translate_display() {
495 let t = Transform::Translate(10.0, 20.0);
496 assert_eq!(t.to_string(), "translate(10,20)");
497 }
498
499 #[test]
500 fn transform_rotate_display() {
501 let t = Transform::Rotate(45.0, 100.0, 200.0);
502 assert_eq!(t.to_string(), "rotate(45,100,200)");
503 }
504
505 #[test]
506 fn transform_multiple_display() {
507 let t = Transform::Multiple(vec![
508 Transform::Translate(10.0, 20.0),
509 Transform::Rotate(45.0, 0.0, 0.0),
510 ]);
511 assert_eq!(t.to_string(), "translate(10,20) rotate(45,0,0)");
512 }
513
514 #[test]
515 fn text_anchor_display() {
516 assert_eq!(TextAnchor::Start.to_string(), "start");
517 assert_eq!(TextAnchor::Middle.to_string(), "middle");
518 assert_eq!(TextAnchor::End.to_string(), "end");
519 }
520
521 #[test]
522 fn element_data_builder() {
523 let data = ElementData::new("Jan", "1234")
524 .with_series("Revenue");
525 assert_eq!(data.label, "Jan");
526 assert_eq!(data.value, "1234");
527 assert_eq!(data.series, Some("Revenue".to_string()));
528 }
529
530 #[test]
531 fn count_rects_in_tree() {
532 let tree = ChartElement::Svg {
533 viewbox: ViewBox::new(0.0, 0.0, 800.0, 400.0),
534 width: Some(800.0),
535 height: Some(400.0),
536 class: "chart".to_string(),
537 children: vec![
538 ChartElement::Group {
539 class: "bars".to_string(),
540 transform: None,
541 children: vec![
542 ChartElement::Rect {
543 x: 0.0, y: 0.0, width: 50.0, height: 100.0,
544 fill: "red".to_string(), stroke: None,
545 rx: None, ry: None,
546 class: "bar".to_string(), data: None,
547 animation_origin: None,
548 },
549 ChartElement::Rect {
550 x: 60.0, y: 0.0, width: 50.0, height: 150.0,
551 fill: "blue".to_string(), stroke: None,
552 rx: None, ry: None,
553 class: "bar".to_string(), data: None,
554 animation_origin: None,
555 },
556 ],
557 },
558 ChartElement::Text {
559 x: 400.0, y: 20.0, content: "Title".to_string(),
560 anchor: TextAnchor::Middle, dominant_baseline: None,
561 transform: None, font_family: None, font_size: None, font_weight: None,
562 letter_spacing: None, text_transform: None, fill: None,
563 class: "title".to_string(),
564 data: None,
565 },
566 ],
567 };
568 let rect_count = count_elements(&tree, &|e| matches!(e, ChartElement::Rect { .. }));
569 assert_eq!(rect_count, 2);
570 }
571
572 #[test]
573 fn dimensions_builder() {
574 let dims = Dimensions::new(400.0).with_width(800.0);
575 assert_eq!(dims.height, 400.0);
576 assert_eq!(dims.width, Some(800.0));
577 }
578
579 #[test]
580 fn serde_round_trip_chart_element_tree() {
581 let tree = ChartElement::Svg {
582 viewbox: ViewBox::new(0.0, 0.0, 800.0, 400.0),
583 width: Some(800.0),
584 height: Some(400.0),
585 class: "chart".to_string(),
586 children: vec![
587 ChartElement::Group {
588 class: "bars".to_string(),
589 transform: Some(Transform::Translate(50.0, 10.0)),
590 children: vec![
591 ChartElement::Rect {
592 x: 0.0,
593 y: 0.0,
594 width: 50.0,
595 height: 100.0,
596 fill: "red".to_string(),
597 stroke: None,
598 rx: None,
599 ry: None,
600 class: "bar".to_string(),
601 data: Some(
602 ElementData::new("Jan", "1234").with_series("Revenue"),
603 ),
604 animation_origin: None,
605 },
606 ChartElement::Path {
607 d: "M0,0 L10,10".to_string(),
608 fill: None,
609 stroke: Some("blue".to_string()),
610 stroke_width: Some(2.0),
611 stroke_dasharray: Some("4,2".to_string()),
612 stroke_dashoffset: None,
613 opacity: Some(0.8),
614 class: "line".to_string(),
615 data: None,
616 animation_origin: None,
617 },
618 ],
619 },
620 ChartElement::Line {
621 x1: 0.0,
622 y1: 0.0,
623 x2: 100.0,
624 y2: 100.0,
625 stroke: "black".to_string(),
626 stroke_width: Some(1.0),
627 stroke_dasharray: None,
628 class: "axis".to_string(),
629 },
630 ChartElement::Text {
631 x: 400.0,
632 y: 20.0,
633 content: "Title".to_string(),
634 anchor: TextAnchor::Middle,
635 dominant_baseline: Some("central".to_string()),
636 transform: Some(Transform::Rotate(45.0, 400.0, 20.0)),
637 font_family: None,
638 font_size: Some("14px".to_string()),
639 font_weight: Some("bold".to_string()),
640 letter_spacing: None,
641 text_transform: None,
642 fill: Some("black".to_string()),
643 class: "title".to_string(),
644 data: None,
645 },
646 ChartElement::Circle {
647 cx: 50.0,
648 cy: 50.0,
649 r: 5.0,
650 fill: "green".to_string(),
651 stroke: None,
652 class: "dot".to_string(),
653 data: None,
654 },
655 ChartElement::Div {
656 class: "metric-card".to_string(),
657 style: HashMap::from([
658 ("display".to_string(), "flex".to_string()),
659 ]),
660 children: vec![ChartElement::Span {
661 class: "value".to_string(),
662 style: HashMap::from([
663 ("font-size".to_string(), "24px".to_string()),
664 ]),
665 content: "$1,234".to_string(),
666 }],
667 },
668 ],
669 };
670
671 let json = serde_json::to_string(&tree).expect("serialize");
672 let deserialized: ChartElement =
673 serde_json::from_str(&json).expect("deserialize");
674
675 let json2 = serde_json::to_string(&deserialized).expect("re-serialize");
677 assert_eq!(json, json2);
678
679 let value: serde_json::Value =
681 serde_json::from_str(&json).expect("parse as Value");
682 assert_eq!(value["type"], "svg");
683 assert_eq!(value["children"][0]["type"], "group");
684 assert_eq!(value["children"][0]["children"][1]["type"], "path");
685 assert_eq!(
686 value["children"][0]["children"][1]["strokeWidth"],
687 serde_json::json!(2.0)
688 );
689 assert_eq!(
690 value["children"][2]["dominantBaseline"],
691 serde_json::json!("central")
692 );
693 }
694}