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 opacity: Option<f64>,
60 class: String,
61 data: Option<ElementData>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
67 animation_origin: Option<(f64, f64)>,
68 },
69 Circle {
70 cx: f64,
71 cy: f64,
72 r: f64,
73 fill: String,
74 stroke: Option<String>,
75 class: String,
76 data: Option<ElementData>,
77 },
78 #[serde(rename_all = "camelCase")]
79 Line {
80 x1: f64,
81 y1: f64,
82 x2: f64,
83 y2: f64,
84 stroke: String,
85 stroke_width: Option<f64>,
86 stroke_dasharray: Option<String>,
87 class: String,
88 },
89 #[serde(rename_all = "camelCase")]
90 Text {
91 x: f64,
92 y: f64,
93 content: String,
94 anchor: TextAnchor,
95 dominant_baseline: Option<String>,
96 transform: Option<Transform>,
97 font_family: Option<String>,
98 font_size: Option<String>,
99 font_weight: Option<String>,
100 letter_spacing: Option<String>,
101 text_transform: Option<String>,
102 fill: Option<String>,
103 class: String,
104 data: Option<ElementData>,
105 },
106 Div {
108 class: String,
109 style: HashMap<String, String>,
110 children: Vec<ChartElement>,
111 },
112 Span {
114 class: String,
115 style: HashMap<String, String>,
116 content: String,
117 },
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct ElementData {
123 pub label: String,
124 pub value: String,
125 pub series: Option<String>,
126 pub raw: HashMap<String, serde_json::Value>,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct ViewBox {
131 pub x: f64,
132 pub y: f64,
133 pub width: f64,
134 pub height: f64,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub enum Transform {
139 Translate(f64, f64),
140 Rotate(f64, f64, f64),
141 Multiple(Vec<Transform>),
142}
143
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
145pub enum TextAnchor {
146 Start,
147 Middle,
148 End,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct Dimensions {
153 pub width: Option<f64>,
154 pub height: f64,
155}
156
157impl ViewBox {
158 pub fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
159 Self { x, y, width, height }
160 }
161
162 pub fn to_svg_string(&self) -> String {
164 format!("{} {} {} {}", self.x, self.y, self.width, self.height)
165 }
166}
167
168impl std::fmt::Display for ViewBox {
169 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170 write!(f, "{} {} {} {}", self.x, self.y, self.width, self.height)
171 }
172}
173
174impl Transform {
175 pub fn to_svg_string(&self) -> String {
177 match self {
178 Transform::Translate(x, y) => format!("translate({},{})", x, y),
179 Transform::Rotate(angle, cx, cy) => format!("rotate({},{},{})", angle, cx, cy),
180 Transform::Multiple(transforms) => {
181 transforms.iter().map(|t| t.to_svg_string()).collect::<Vec<_>>().join(" ")
182 }
183 }
184 }
185}
186
187impl std::fmt::Display for Transform {
188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189 write!(f, "{}", self.to_svg_string())
190 }
191}
192
193impl std::fmt::Display for TextAnchor {
194 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195 match self {
196 TextAnchor::Start => write!(f, "start"),
197 TextAnchor::Middle => write!(f, "middle"),
198 TextAnchor::End => write!(f, "end"),
199 }
200 }
201}
202
203impl ElementData {
204 pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
205 Self {
206 label: label.into(),
207 value: value.into(),
208 series: None,
209 raw: HashMap::new(),
210 }
211 }
212
213 pub fn with_series(mut self, series: impl Into<String>) -> Self {
214 self.series = Some(series.into());
215 self
216 }
217}
218
219impl Dimensions {
220 pub fn new(height: f64) -> Self {
221 Self { width: None, height }
222 }
223
224 pub fn with_width(mut self, width: f64) -> Self {
225 self.width = Some(width);
226 self
227 }
228}
229
230pub fn count_elements<F>(element: &ChartElement, predicate: &F) -> usize
232where
233 F: Fn(&ChartElement) -> bool,
234{
235 let mut count = if predicate(element) { 1 } else { 0 };
236 match element {
237 ChartElement::Svg { children, .. }
238 | ChartElement::Group { children, .. }
239 | ChartElement::Div { children, .. } => {
240 for child in children {
241 count += count_elements(child, predicate);
242 }
243 }
244 _ => {}
245 }
246 count
247}
248
249pub const LEGACY_LABEL_FONT_SIZE: &str = "12px";
280
281pub const LEGACY_LEGEND_FONT_SIZE: &str = "11px";
285
286pub const LEGACY_FONT_WEIGHT: u16 = 400;
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub enum TextRole {
295 AxisLabel,
297 TickValue,
300 LegendLabel,
302}
303
304#[derive(Debug, Clone)]
308pub struct TextStyle {
309 pub font_family: Option<String>,
310 pub font_size: Option<String>,
311 pub font_weight: Option<String>,
312 pub letter_spacing: Option<String>,
313 pub text_transform: Option<String>,
314}
315
316impl TextStyle {
317 pub fn for_role(theme: &crate::theme::Theme, role: TextRole) -> Self {
323 use crate::theme::{TextTransform, Theme};
324
325 let default_theme = Theme::default();
326
327 let (family, default_family, size_px, default_size_px, legacy_size) = match role {
328 TextRole::AxisLabel => (
329 &theme.label_font_family,
330 &default_theme.label_font_family,
331 theme.label_font_size,
332 default_theme.label_font_size,
333 LEGACY_LABEL_FONT_SIZE,
334 ),
335 TextRole::TickValue => (
336 &theme.numeric_font_family,
337 &default_theme.numeric_font_family,
338 theme.numeric_font_size,
339 default_theme.numeric_font_size,
340 LEGACY_LABEL_FONT_SIZE,
341 ),
342 TextRole::LegendLabel => (
343 &theme.legend_font_family,
344 &default_theme.legend_font_family,
345 theme.legend_font_size,
346 default_theme.legend_font_size,
347 LEGACY_LEGEND_FONT_SIZE,
348 ),
349 };
350 let weight = match role {
351 TextRole::AxisLabel | TextRole::TickValue => theme.label_font_weight,
352 TextRole::LegendLabel => theme.legend_font_weight,
353 };
354
355 let letter_spacing = theme.label_letter_spacing;
359 let text_transform = &theme.label_text_transform;
360
361 let font_family = if family == default_family {
367 None
368 } else {
369 Some(family.clone())
370 };
371
372 let font_size = if (size_px - default_size_px).abs() < f32::EPSILON {
378 Some(legacy_size.to_string())
379 } else {
380 Some(format!("{}px", format_px(size_px)))
381 };
382
383 let font_weight = if weight == LEGACY_FONT_WEIGHT {
384 None
385 } else {
386 Some(weight.to_string())
387 };
388
389 let letter_spacing = if letter_spacing == 0.0 {
394 None
395 } else {
396 Some(format_px(letter_spacing))
397 };
398
399 let text_transform = match text_transform {
400 TextTransform::None => None,
401 TextTransform::Uppercase => Some("uppercase".to_string()),
402 TextTransform::Lowercase => Some("lowercase".to_string()),
403 };
404
405 Self {
406 font_family,
407 font_size,
408 font_weight,
409 letter_spacing,
410 text_transform,
411 }
412 }
413}
414
415pub fn emit_dot_halo_if_enabled(
440 theme: &crate::theme::Theme,
441 cx: f64,
442 cy: f64,
443 r: f64,
444) -> Option<ChartElement> {
445 let color = theme.dot_halo_color.as_ref()?;
446 let d = format!(
449 "M {cx},{cy} m -{r},0 a {r},{r} 0 1,0 {d2},0 a {r},{r} 0 1,0 -{d2},0",
450 cx = cx,
451 cy = cy,
452 r = r,
453 d2 = 2.0 * r,
454 );
455 Some(ChartElement::Path {
456 d,
457 fill: None,
458 stroke: Some(color.clone()),
459 stroke_width: Some(theme.dot_halo_width as f64),
460 stroke_dasharray: 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 opacity: Some(0.8),
610 class: "line".to_string(),
611 data: None,
612 animation_origin: None,
613 },
614 ],
615 },
616 ChartElement::Line {
617 x1: 0.0,
618 y1: 0.0,
619 x2: 100.0,
620 y2: 100.0,
621 stroke: "black".to_string(),
622 stroke_width: Some(1.0),
623 stroke_dasharray: None,
624 class: "axis".to_string(),
625 },
626 ChartElement::Text {
627 x: 400.0,
628 y: 20.0,
629 content: "Title".to_string(),
630 anchor: TextAnchor::Middle,
631 dominant_baseline: Some("central".to_string()),
632 transform: Some(Transform::Rotate(45.0, 400.0, 20.0)),
633 font_family: None,
634 font_size: Some("14px".to_string()),
635 font_weight: Some("bold".to_string()),
636 letter_spacing: None,
637 text_transform: None,
638 fill: Some("black".to_string()),
639 class: "title".to_string(),
640 data: None,
641 },
642 ChartElement::Circle {
643 cx: 50.0,
644 cy: 50.0,
645 r: 5.0,
646 fill: "green".to_string(),
647 stroke: None,
648 class: "dot".to_string(),
649 data: None,
650 },
651 ChartElement::Div {
652 class: "metric-card".to_string(),
653 style: HashMap::from([
654 ("display".to_string(), "flex".to_string()),
655 ]),
656 children: vec![ChartElement::Span {
657 class: "value".to_string(),
658 style: HashMap::from([
659 ("font-size".to_string(), "24px".to_string()),
660 ]),
661 content: "$1,234".to_string(),
662 }],
663 },
664 ],
665 };
666
667 let json = serde_json::to_string(&tree).expect("serialize");
668 let deserialized: ChartElement =
669 serde_json::from_str(&json).expect("deserialize");
670
671 let json2 = serde_json::to_string(&deserialized).expect("re-serialize");
673 assert_eq!(json, json2);
674
675 let value: serde_json::Value =
677 serde_json::from_str(&json).expect("parse as Value");
678 assert_eq!(value["type"], "svg");
679 assert_eq!(value["children"][0]["type"], "group");
680 assert_eq!(value["children"][0]["children"][1]["type"], "path");
681 assert_eq!(
682 value["children"][0]["children"][1]["strokeWidth"],
683 serde_json::json!(2.0)
684 );
685 assert_eq!(
686 value["children"][2]["dominantBaseline"],
687 serde_json::json!("central")
688 );
689 }
690}