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