Skip to main content

chartml_core/
element.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5/// The output of any ChartRenderer. Framework adapters walk this tree
6/// and produce framework-specific DOM/view output.
7#[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        /// Corner radius on the x axis. When `None`, no `rx` attribute is
30        /// emitted (preserving byte-identical output for un-themed charts).
31        /// Wired from `Theme::bar_corner_radius` in Phase 5 — bars emit
32        /// `Some(v)` when `bar_corner_radius` is `Uniform(v)` with `v > 0.0`,
33        /// else `None`. Top-only rounding is emitted as a `Path` instead of
34        /// a `Rect` (see `BarCornerRadius::Top`).
35        #[serde(default, skip_serializing_if = "Option::is_none")]
36        rx: Option<f64>,
37        /// Corner radius on the y axis. See `rx`.
38        #[serde(default, skip_serializing_if = "Option::is_none")]
39        ry: Option<f64>,
40        class: String,
41        data: Option<ElementData>,
42        /// Fill-box percentage origin for entrance animations (e.g. 50, 100
43        /// for bottom-center). Emitted with `transform-box: fill-box` so the
44        /// browser resolves against the element's own bounding box. `None`
45        /// for non-bar Rects and stacked bar segments (which use group-level
46        /// clip-path animation instead).
47        #[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        /// See [`ChartElement::Rect::animation_origin`]. Same fill-box
63        /// percentage semantics. Populated for top-rounded bars emitted as
64        /// `Path`. `None` for non-bar Paths and stacked bar segments.
65        #[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    /// Non-SVG container (e.g., metric card uses div-based layout)
106    Div {
107        class: String,
108        style: HashMap<String, String>,
109        children: Vec<ChartElement>,
110    },
111    /// Raw text node (for metric values, labels in div-based charts)
112    Span {
113        class: String,
114        style: HashMap<String, String>,
115        content: String,
116    },
117}
118
119/// Data attached to interactive elements for tooltips.
120#[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    /// Format as SVG viewBox attribute string: "x y width height"
162    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    /// Format as SVG transform attribute string.
175    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
229/// Count elements in the tree matching a predicate.
230pub 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
248// =============================================================================
249// TextStyle — role-based typography resolution from a Theme.
250// =============================================================================
251//
252// Theme typography fields are wired into `ChartElement::Text` via `TextStyle`.
253// Each text emission site picks a `TextRole` (axis-label / tick-value /
254// legend-label), builds a `TextStyle` from the active theme, and plugs the
255// resulting `Option<String>` fields into the `Text` literal.
256//
257// ## Backward-compatibility sentinels
258//
259// Phase 4 must preserve byte-identical SVG output for the `Theme::default()`
260// case. The pre-existing emission uses hardcoded values that differ from
261// `Theme::default()`'s typography fields (for example, the SVG serializer
262// stamps `font-family="Inter, Liberation Sans, Arial, sans-serif"` on every
263// `<text>` element regardless of what the `Text` element carries). The
264// "legacy sentinel" for each attribute is the value that would otherwise be
265// emitted today. `TextStyle` returns `None` for any attribute whose theme
266// value equals its legacy sentinel, ensuring the attribute is omitted (for
267// `font-family` / `letter-spacing` / `text-transform` / `font-weight`) or
268// restated unchanged (for `font-size`).
269
270/// Legacy hardcoded font-size string for axis labels and tick labels.
271///
272/// Companion constants for the other legacy typography values are not
273/// exported because they have no code consumers: the legacy font-family
274/// (`"Inter, Liberation Sans, Arial, sans-serif"`, owned by
275/// `chartml-render/src/svg.rs`) and legacy font-style (`"normal"`) are
276/// handled purely by the `Theme::default()` sentinel comparison in
277/// `TextStyle::for_role`, not by explicit constants.
278pub const LEGACY_LABEL_FONT_SIZE: &str = "12px";
279
280/// Legacy hardcoded font-size string for legend labels. Note this differs
281/// from `LEGACY_LABEL_FONT_SIZE` — the legend has historically used a
282/// slightly smaller size (11px) while all other chrome uses 12px.
283pub const LEGACY_LEGEND_FONT_SIZE: &str = "11px";
284
285/// Legacy font-weight for all chrome text. `None` because no current
286/// emission site sets `font-weight` at all — the attribute is absent from
287/// every pre-Phase-4 baseline SVG.
288pub const LEGACY_FONT_WEIGHT: u16 = 400;
289
290/// Role of a text element, used to select the right bundle of typography
291/// fields from the active `Theme`.
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293pub enum TextRole {
294    /// Axis titles and category/axis tick labels. Reads `label_*` fields.
295    AxisLabel,
296    /// Numeric tick labels. Reads `numeric_*` for family/size; inherits
297    /// `label_font_weight` / `label_letter_spacing` / `label_text_transform`.
298    TickValue,
299    /// Legend labels. Reads `legend_*` fields.
300    LegendLabel,
301}
302
303/// Resolved typography for a single `ChartElement::Text` literal. All fields
304/// are already prepared as `Option<String>` — plug them directly into the
305/// `Text` enum variant.
306#[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    /// Build a `TextStyle` for the given role from a `Theme`.
317    ///
318    /// Returns `Some(...)` only for attributes whose theme value diverges
319    /// from its legacy sentinel (see the module-level constants). This keeps
320    /// byte-identical output whenever `Theme::default()` is in effect.
321    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        // Axis-label, tick-value, and legend-label all inherit
355        // `label_letter_spacing` / `label_text_transform` (no legend-specific
356        // overrides per Phase 4 mapping).
357        let letter_spacing = theme.label_letter_spacing;
358        let text_transform = &theme.label_text_transform;
359
360        // Emit `font-family` only when the user has overridden the theme
361        // default. When the theme's family equals the default theme's family,
362        // omit the attribute so the SVG serializer's legacy hardcoded
363        // `Inter, Liberation Sans, Arial, sans-serif` path is preserved and
364        // baseline output stays byte-identical.
365        let font_family = if family == default_family {
366            None
367        } else {
368            Some(family.clone())
369        };
370
371        // Font size: `font-size` is always emitted today. To preserve
372        // byte-identical output for `Theme::default()`, when the theme's
373        // size equals the default (i.e. the user hasn't overridden it),
374        // emit the legacy hardcoded string — which differs from the default
375        // for the legend role (11px vs. 12.0 default).
376        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        // Exact-equals on f32 is safe here: the sentinel is the literal
389        // `0.0_f32` produced by `Theme::default()`, never the result of
390        // arithmetic. Same reasoning as the `size_px == default_size_px`
391        // sentinel above.
392        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
414// =============================================================================
415// Dot halo helper (Phase 8) — outer ring behind dot markers.
416// =============================================================================
417//
418// Every dot marker in ChartML (scatter points, line endpoint markers, combo
419// dot markers) is a `ChartElement::Circle`. When `theme.dot_halo_color` is
420// `Some`, a halo is emitted BEFORE the dot so it renders underneath. The
421// halo is a stroke-based ring at the same radius as the dot, which means the
422// stroke extends half-outside the dot — producing a crisp visible outline.
423//
424// The helper returns `None` when `theme.dot_halo_color` is `None`, which
425// keeps byte-identical emission for the default theme (Phase 0 contract).
426//
427// The halo is emitted as a `ChartElement::Path` (not `Circle`) because
428// `Circle` has no `stroke_width` field — `Path` supports `stroke_width`,
429// `stroke`, and `fill` directly and renders the same visual result. The
430// emitted class is `dot-halo`, which Phase 9 CSS can target regardless of
431// underlying SVG tag.
432
433/// Emit a halo element to render BEFORE a dot marker, or `None` when
434/// `theme.dot_halo_color` is `None`. Uses `theme.dot_halo_width` as the
435/// stroke width. The halo radius always matches the dot's own radius so the
436/// ring hugs the dot — bubble charts pass the per-point radius here, not a
437/// static theme default.
438pub 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    // SVG circle-as-path: move to (cx-r, cy), arc 180° to (cx+r, cy), arc
446    // 180° back. Two half-arcs form a full circle.
447    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
468/// Format a pixel value, preferring an integer rendering when the value has
469/// no fractional part. Mirrors the pre-Phase-4 emission of `"12px"`
470/// (not `"12.0px"`), which every baseline snapshot asserts.
471fn 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        // Re-serialize to confirm structural equality
673        let json2 = serde_json::to_string(&deserialized).expect("re-serialize");
674        assert_eq!(json, json2);
675
676        // Verify the tag format is correct
677        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}