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 use super::*;
482
483 #[test]
484 fn viewbox_display() {
485 let vb = ViewBox::new(0.0, 0.0, 800.0, 400.0);
486 assert_eq!(vb.to_string(), "0 0 800 400");
487 }
488
489 #[test]
490 fn transform_translate_display() {
491 let t = Transform::Translate(10.0, 20.0);
492 assert_eq!(t.to_string(), "translate(10,20)");
493 }
494
495 #[test]
496 fn transform_rotate_display() {
497 let t = Transform::Rotate(45.0, 100.0, 200.0);
498 assert_eq!(t.to_string(), "rotate(45,100,200)");
499 }
500
501 #[test]
502 fn transform_multiple_display() {
503 let t = Transform::Multiple(vec![
504 Transform::Translate(10.0, 20.0),
505 Transform::Rotate(45.0, 0.0, 0.0),
506 ]);
507 assert_eq!(t.to_string(), "translate(10,20) rotate(45,0,0)");
508 }
509
510 #[test]
511 fn text_anchor_display() {
512 assert_eq!(TextAnchor::Start.to_string(), "start");
513 assert_eq!(TextAnchor::Middle.to_string(), "middle");
514 assert_eq!(TextAnchor::End.to_string(), "end");
515 }
516
517 #[test]
518 fn element_data_builder() {
519 let data = ElementData::new("Jan", "1234")
520 .with_series("Revenue");
521 assert_eq!(data.label, "Jan");
522 assert_eq!(data.value, "1234");
523 assert_eq!(data.series, Some("Revenue".to_string()));
524 }
525
526 #[test]
527 fn count_rects_in_tree() {
528 let tree = ChartElement::Svg {
529 viewbox: ViewBox::new(0.0, 0.0, 800.0, 400.0),
530 width: Some(800.0),
531 height: Some(400.0),
532 class: "chart".to_string(),
533 children: vec![
534 ChartElement::Group {
535 class: "bars".to_string(),
536 transform: None,
537 children: vec![
538 ChartElement::Rect {
539 x: 0.0, y: 0.0, width: 50.0, height: 100.0,
540 fill: "red".to_string(), stroke: None,
541 rx: None, ry: None,
542 class: "bar".to_string(), data: None,
543 animation_origin: None,
544 },
545 ChartElement::Rect {
546 x: 60.0, y: 0.0, width: 50.0, height: 150.0,
547 fill: "blue".to_string(), stroke: None,
548 rx: None, ry: None,
549 class: "bar".to_string(), data: None,
550 animation_origin: None,
551 },
552 ],
553 },
554 ChartElement::Text {
555 x: 400.0, y: 20.0, content: "Title".to_string(),
556 anchor: TextAnchor::Middle, dominant_baseline: None,
557 transform: None, font_family: None, font_size: None, font_weight: None,
558 letter_spacing: None, text_transform: None, fill: None,
559 class: "title".to_string(),
560 data: None,
561 },
562 ],
563 };
564 let rect_count = count_elements(&tree, &|e| matches!(e, ChartElement::Rect { .. }));
565 assert_eq!(rect_count, 2);
566 }
567
568 #[test]
569 fn dimensions_builder() {
570 let dims = Dimensions::new(400.0).with_width(800.0);
571 assert_eq!(dims.height, 400.0);
572 assert_eq!(dims.width, Some(800.0));
573 }
574
575 #[test]
576 fn serde_round_trip_chart_element_tree() {
577 let tree = ChartElement::Svg {
578 viewbox: ViewBox::new(0.0, 0.0, 800.0, 400.0),
579 width: Some(800.0),
580 height: Some(400.0),
581 class: "chart".to_string(),
582 children: vec![
583 ChartElement::Group {
584 class: "bars".to_string(),
585 transform: Some(Transform::Translate(50.0, 10.0)),
586 children: vec![
587 ChartElement::Rect {
588 x: 0.0,
589 y: 0.0,
590 width: 50.0,
591 height: 100.0,
592 fill: "red".to_string(),
593 stroke: None,
594 rx: None,
595 ry: None,
596 class: "bar".to_string(),
597 data: Some(
598 ElementData::new("Jan", "1234").with_series("Revenue"),
599 ),
600 animation_origin: None,
601 },
602 ChartElement::Path {
603 d: "M0,0 L10,10".to_string(),
604 fill: None,
605 stroke: Some("blue".to_string()),
606 stroke_width: Some(2.0),
607 stroke_dasharray: Some("4,2".to_string()),
608 opacity: Some(0.8),
609 class: "line".to_string(),
610 data: None,
611 animation_origin: None,
612 },
613 ],
614 },
615 ChartElement::Line {
616 x1: 0.0,
617 y1: 0.0,
618 x2: 100.0,
619 y2: 100.0,
620 stroke: "black".to_string(),
621 stroke_width: Some(1.0),
622 stroke_dasharray: None,
623 class: "axis".to_string(),
624 },
625 ChartElement::Text {
626 x: 400.0,
627 y: 20.0,
628 content: "Title".to_string(),
629 anchor: TextAnchor::Middle,
630 dominant_baseline: Some("central".to_string()),
631 transform: Some(Transform::Rotate(45.0, 400.0, 20.0)),
632 font_family: None,
633 font_size: Some("14px".to_string()),
634 font_weight: Some("bold".to_string()),
635 letter_spacing: None,
636 text_transform: None,
637 fill: Some("black".to_string()),
638 class: "title".to_string(),
639 data: None,
640 },
641 ChartElement::Circle {
642 cx: 50.0,
643 cy: 50.0,
644 r: 5.0,
645 fill: "green".to_string(),
646 stroke: None,
647 class: "dot".to_string(),
648 data: None,
649 },
650 ChartElement::Div {
651 class: "metric-card".to_string(),
652 style: HashMap::from([
653 ("display".to_string(), "flex".to_string()),
654 ]),
655 children: vec![ChartElement::Span {
656 class: "value".to_string(),
657 style: HashMap::from([
658 ("font-size".to_string(), "24px".to_string()),
659 ]),
660 content: "$1,234".to_string(),
661 }],
662 },
663 ],
664 };
665
666 let json = serde_json::to_string(&tree).expect("serialize");
667 let deserialized: ChartElement =
668 serde_json::from_str(&json).expect("deserialize");
669
670 let json2 = serde_json::to_string(&deserialized).expect("re-serialize");
672 assert_eq!(json, json2);
673
674 let value: serde_json::Value =
676 serde_json::from_str(&json).expect("parse as Value");
677 assert_eq!(value["type"], "svg");
678 assert_eq!(value["children"][0]["type"], "group");
679 assert_eq!(value["children"][0]["children"][1]["type"], "path");
680 assert_eq!(
681 value["children"][0]["children"][1]["strokeWidth"],
682 serde_json::json!(2.0)
683 );
684 assert_eq!(
685 value["children"][2]["dominantBaseline"],
686 serde_json::json!("central")
687 );
688 }
689}