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