Skip to main content

chartml_core/
lib.rs

1pub mod error;
2pub mod spec;
3pub mod scales;
4pub mod shapes;
5pub mod layout;
6pub mod format;
7pub mod color;
8pub mod plugin;
9pub mod registry;
10pub mod element;
11pub mod data;
12pub mod transform;
13pub mod params;
14pub mod theme;
15
16pub use error::ChartError;
17pub use spec::{parse, ChartMLSpec, Component};
18pub use element::ChartElement;
19pub use plugin::{ChartConfig, ChartRenderer, DataSource, TransformMiddleware, DatasourceResolver};
20pub use registry::ChartMLRegistry;
21pub use theme::Theme;
22
23use std::collections::HashMap;
24use crate::data::{Row, DataTable};
25use crate::spec::{ChartSpec, DataRef};
26
27/// Main ChartML instance. Orchestrates parsing, data fetching, and rendering.
28/// Maintains source and parameter registries that persist across render calls,
29/// matching the JS ChartML class behavior.
30pub struct ChartML {
31    registry: ChartMLRegistry,
32    /// Named source data, registered via register_component() or
33    /// automatically collected from multi-document YAML specs.
34    sources: HashMap<String, DataTable>,
35    /// Parameter default values, collected from type: params components.
36    param_values: params::ParamValues,
37    /// Default color palette — used when the spec doesn't specify `style.colors`.
38    /// Mirrors the JS ChartML `setDefaultPalette()` API.
39    default_palette: Option<Vec<String>>,
40    /// Theme colors for chart chrome (axes, grid, text).
41    /// Defaults to light mode. Set via `set_theme()` to match your app's appearance.
42    theme: theme::Theme,
43}
44
45impl ChartML {
46    /// Create a new empty ChartML instance.
47    pub fn new() -> Self {
48        Self {
49            registry: ChartMLRegistry::new(),
50            sources: HashMap::new(),
51            param_values: params::ParamValues::new(),
52            default_palette: None,
53            theme: theme::Theme::default(),
54        }
55    }
56
57    /// Create with default built-in plugins.
58    /// (No built-in renderers — those come from chartml-chart-* crates)
59    pub fn with_defaults() -> Self {
60        Self::new()
61    }
62
63    // --- Registration methods (delegate to registry) ---
64
65    pub fn register_renderer(&mut self, chart_type: &str, renderer: impl ChartRenderer + 'static) {
66        self.registry.register_renderer(chart_type, renderer);
67    }
68
69    pub fn register_data_source(&mut self, name: &str, source: impl DataSource + 'static) {
70        self.registry.register_data_source(name, source);
71    }
72
73    pub fn register_transform(&mut self, middleware: impl TransformMiddleware + 'static) {
74        self.registry.register_transform(middleware);
75    }
76
77    pub fn set_datasource_resolver(&mut self, resolver: impl DatasourceResolver + 'static) {
78        self.registry.set_datasource_resolver(resolver);
79    }
80
81    /// Set the default color palette for charts that don't specify `style.colors`.
82    /// Matches the JS ChartML `setDefaultPalette()` API.
83    pub fn set_default_palette(&mut self, colors: Vec<String>) {
84        self.default_palette = Some(colors);
85    }
86
87    /// Set the theme for chart chrome colors (axes, grid, text, background).
88    /// Use `Theme::default()` for light mode, `Theme::dark()` for dark mode,
89    /// or construct a custom `Theme` to match your application's appearance.
90    pub fn set_theme(&mut self, theme: theme::Theme) {
91        self.theme = theme;
92    }
93
94    /// Get a reference to the current theme. Consumers (e.g. chartml-leptos)
95    /// use this to thread typography into HTML chrome rendered outside the SVG.
96    pub fn theme(&self) -> &theme::Theme {
97        &self.theme
98    }
99
100    // --- Component registration (matches JS chartml.registerComponent()) ---
101
102    /// Register a non-chart component (source, style, config, params) from a YAML string.
103    /// Sources are stored in the instance and available to all subsequent render calls.
104    /// This matches the JS `chartml.registerComponent(spec)` API.
105    pub fn register_component(&mut self, yaml: &str) -> Result<(), ChartError> {
106        let parsed = spec::parse(yaml)?;
107        match parsed {
108            ChartMLSpec::Single(component) => self.register_single_component(*component),
109            ChartMLSpec::Array(components) => {
110                for component in components {
111                    self.register_single_component(component)?;
112                }
113                Ok(())
114            }
115        }
116    }
117
118    fn register_single_component(&mut self, component: spec::Component) -> Result<(), ChartError> {
119        match component {
120            spec::Component::Source(source_spec) => {
121                if let Some(ref rows) = source_spec.rows {
122                    let json_rows = self.convert_json_rows(rows)?;
123                    let data = DataTable::from_rows(&json_rows)?;
124                    self.sources.insert(source_spec.name.clone(), data);
125                }
126                Ok(())
127            }
128            spec::Component::Params(params_spec) => {
129                let defaults = params::collect_param_defaults(&[&params_spec]);
130                self.param_values.extend(defaults);
131                Ok(())
132            }
133            spec::Component::Style(_) | spec::Component::Config(_) => {
134                // Style/config registration — stored for future use
135                Ok(())
136            }
137            spec::Component::Chart(..) => {
138                Err(ChartError::InvalidSpec(
139                    "Cannot register chart components. Use render_from_yaml() instead.".into()
140                ))
141            }
142        }
143    }
144
145    /// Register a named source directly from a DataTable.
146    pub fn register_source(&mut self, name: &str, data: DataTable) {
147        self.sources.insert(name.to_string(), data);
148    }
149
150    // --- Rendering ---
151
152    /// Parse a YAML string and render the chart component(s).
153    /// Returns the ChartElement tree.
154    /// Uses default dimensions (800x400) unless the spec overrides them.
155    pub fn render_from_yaml(&self, yaml: &str) -> Result<ChartElement, ChartError> {
156        self.render_from_yaml_with_size(yaml, None, None)
157    }
158
159    /// Parse a YAML string and render with an explicit container size.
160    /// `container_width` overrides the default width (used when the spec doesn't specify one).
161    /// `container_height` overrides the default height.
162    pub fn render_from_yaml_with_size(
163        &self,
164        yaml: &str,
165        container_width: Option<f64>,
166        container_height: Option<f64>,
167    ) -> Result<ChartElement, ChartError> {
168        self.render_from_yaml_with_params(yaml, container_width, container_height, None)
169    }
170
171    /// Render with explicit param value overrides.
172    /// `param_overrides` are current interactive values that take priority over defaults.
173    pub fn render_from_yaml_with_params(
174        &self,
175        yaml: &str,
176        container_width: Option<f64>,
177        container_height: Option<f64>,
178        param_overrides: Option<&params::ParamValues>,
179    ) -> Result<ChartElement, ChartError> {
180        // Step 1: Collect ALL param values — defaults + overrides.
181        // Priority: overrides > persistent defaults > inline defaults
182        let mut all_params = self.param_values.clone();
183
184        // Extract inline (chart-level) param defaults from the raw YAML
185        let inline_defaults = params::extract_inline_param_defaults(yaml);
186        all_params.extend(inline_defaults);
187
188        // Apply overrides (interactive values from UI controls)
189        if let Some(overrides) = param_overrides {
190            all_params.extend(overrides.iter().map(|(k, v)| (k.clone(), v.clone())));
191        }
192
193        // Resolve parameter references in the YAML string
194        let resolved_yaml = if !all_params.is_empty() {
195            params::resolve_param_references(yaml, &all_params)
196        } else {
197            yaml.to_string()
198        };
199
200        let parsed = spec::parse(&resolved_yaml)?;
201
202        // Step 2: Collect document-local params and re-resolve if needed.
203        let mut local_params = self.param_values.clone();
204        let mut has_local_params = false;
205        if let ChartMLSpec::Array(ref components) = parsed {
206            for component in components {
207                if let Component::Params(params_spec) = component {
208                    let defaults = params::collect_param_defaults(&[params_spec]);
209                    local_params.extend(defaults);
210                    has_local_params = true;
211                }
212            }
213        }
214
215        // If we found local params, re-resolve and re-parse
216        let parsed = if has_local_params && local_params.len() > self.param_values.len() {
217            let re_resolved = params::resolve_param_references(yaml, &local_params);
218            spec::parse(&re_resolved)?
219        } else {
220            parsed
221        };
222
223        // Step 3: Collect sources (persistent + document-local).
224        let mut sources: HashMap<String, DataTable> = self.sources.clone();
225
226        if let ChartMLSpec::Array(ref components) = parsed {
227            for component in components {
228                if let Component::Source(source_spec) = component {
229                    if let Some(ref rows) = source_spec.rows {
230                        let json_rows = self.convert_json_rows(rows)?;
231                        let data = DataTable::from_rows(&json_rows)?;
232                        sources.insert(source_spec.name.clone(), data);
233                    }
234                }
235            }
236        }
237
238        // Collect all chart components
239        let chart_specs: Vec<&ChartSpec> = match &parsed {
240            ChartMLSpec::Single(component) => match component.as_ref() {
241                Component::Chart(chart) => vec![chart.as_ref()],
242                _ => vec![],
243            },
244            ChartMLSpec::Array(components) => {
245                components.iter()
246                    .filter_map(|c| match c {
247                        Component::Chart(chart) => Some(chart.as_ref()),
248                        _ => None,
249                    })
250                    .collect()
251            }
252        };
253
254        // If no charts, check for params components to render as UI controls
255        if chart_specs.is_empty() {
256            let params_specs: Vec<&spec::ParamsSpec> = match &parsed {
257                ChartMLSpec::Single(component) => match component.as_ref() {
258                    Component::Params(p) => vec![p],
259                    _ => vec![],
260                },
261                ChartMLSpec::Array(components) => {
262                    components.iter()
263                        .filter_map(|c| match c {
264                            Component::Params(p) => Some(p),
265                            _ => None,
266                        })
267                        .collect()
268                }
269            };
270
271            if !params_specs.is_empty() {
272                return Ok(self.render_params_ui(&params_specs));
273            }
274
275            return Err(ChartError::InvalidSpec("No chart or params component found".into()));
276        }
277
278        if chart_specs.len() == 1 {
279            self.render_chart_internal(chart_specs[0], container_width, container_height, &sources)
280        } else {
281            // Multiple charts — render each and wrap in a grid container
282            let mut children = Vec::new();
283            for spec in chart_specs {
284                match self.render_chart_internal(spec, container_width, container_height, &sources) {
285                    Ok(element) => children.push(element),
286                    Err(e) => {
287                        // Continue rendering other charts even if one fails
288                        children.push(ChartElement::Div {
289                            class: "chartml-error".to_string(),
290                            style: HashMap::new(),
291                            children: vec![ChartElement::Span {
292                                class: "".to_string(),
293                                style: HashMap::new(),
294                                content: format!("Chart error: {}", e),
295                            }],
296                        });
297                    }
298                }
299            }
300            Ok(ChartElement::Div {
301                class: "chartml-multi-chart".to_string(),
302                style: HashMap::from([
303                    ("display".to_string(), "grid".to_string()),
304                    ("grid-template-columns".to_string(), format!("repeat({}, 1fr)", children.len().min(4))),
305                    ("gap".to_string(), "16px".to_string()),
306                ]),
307                children,
308            })
309        }
310    }
311
312    /// Render a parsed ChartSpec into a ChartElement tree.
313    pub fn render_chart(&self, chart_spec: &ChartSpec) -> Result<ChartElement, ChartError> {
314        self.render_chart_with_size(chart_spec, None, None)
315    }
316
317    /// Render a parsed ChartSpec with explicit container dimensions.
318    /// Spec-level width/height take priority; container size is the fallback.
319    pub fn render_chart_with_size(
320        &self,
321        chart_spec: &ChartSpec,
322        container_width: Option<f64>,
323        container_height: Option<f64>,
324    ) -> Result<ChartElement, ChartError> {
325        let sources = HashMap::new();
326        self.render_chart_internal(chart_spec, container_width, container_height, &sources)
327    }
328
329    /// Internal render method that accepts named sources for resolution.
330    fn render_chart_internal(
331        &self,
332        chart_spec: &ChartSpec,
333        container_width: Option<f64>,
334        container_height: Option<f64>,
335        sources: &HashMap<String, DataTable>,
336    ) -> Result<ChartElement, ChartError> {
337        let chart_type = &chart_spec.visualize.chart_type;
338
339        // Look up renderer
340        let renderer = self.registry.get_renderer(chart_type)
341            .ok_or_else(|| ChartError::UnknownChartType(chart_type.clone()))?;
342
343        // Extract data (inline or from named source)
344        let mut data = self.extract_data(chart_spec, sources)?;
345
346        // Apply transforms if specified (sync fallback: DataTable → Vec<Row> → transform → DataTable)
347        if let Some(ref transform_spec) = chart_spec.transform {
348            let rows = data.to_rows();
349            let transformed_rows = transform::apply_transforms(rows, transform_spec)?;
350            data = DataTable::from_rows(&transformed_rows)?;
351        }
352
353        // Build chart config — spec dimensions override container dimensions
354        let default_height = renderer.default_dimensions(&chart_spec.visualize)
355            .map(|d| d.height)
356            .unwrap_or(400.0);
357
358        let height = chart_spec.visualize.style
359            .as_ref()
360            .and_then(|s| s.height)
361            .or(container_height)
362            .unwrap_or(default_height);
363
364        let width = chart_spec.visualize.style
365            .as_ref()
366            .and_then(|s| s.width)
367            .or(container_width)
368            .unwrap_or(800.0);
369
370        let colors = chart_spec.visualize.style
371            .as_ref()
372            .and_then(|s| s.colors.clone())
373            .or_else(|| self.default_palette.clone())
374            .unwrap_or_else(|| {
375                color::get_chart_colors(12, color::palettes::get_palette("autumn_forest"))
376            });
377
378        let config = ChartConfig {
379            visualize: chart_spec.visualize.clone(),
380            title: chart_spec.title.clone(),
381            width,
382            height,
383            colors,
384            theme: self.theme.clone(),
385        };
386
387        renderer.render(&data, &config)
388    }
389
390    /// Extract data from a chart spec, resolving both inline and named sources.
391    fn extract_data(&self, chart_spec: &ChartSpec, sources: &HashMap<String, DataTable>) -> Result<DataTable, ChartError> {
392        match &chart_spec.data {
393            DataRef::Inline(inline) => {
394                let rows = inline.rows.as_ref()
395                    .ok_or_else(|| ChartError::DataError("Inline data source has no rows".into()))?;
396                let json_rows = self.convert_json_rows(rows)?;
397                DataTable::from_rows(&json_rows)
398            }
399            DataRef::Named(name) => {
400                sources.get(name)
401                    .cloned()
402                    .ok_or_else(|| ChartError::DataError(
403                        format!("Named data source '{}' not found", name)
404                    ))
405            }
406            DataRef::NamedMap(_) => {
407                Err(ChartError::InvalidSpec(
408                    "Multi-source `data:` map requires each source to be pre-fetched and registered by name, then `data:` rewritten to that name. The core renderer cannot fetch sources directly.".into()
409                ))
410            }
411        }
412    }
413
414    /// Convert JSON value rows into typed Row objects.
415    fn convert_json_rows(&self, rows: &[serde_json::Value]) -> Result<Vec<Row>, ChartError> {
416        let mut result = Vec::with_capacity(rows.len());
417        for value in rows {
418            match value {
419                serde_json::Value::Object(map) => {
420                    let row: Row = map.iter()
421                        .map(|(k, v)| (k.clone(), v.clone()))
422                        .collect();
423                    result.push(row);
424                }
425                _ => return Err(ChartError::DataError(
426                    "Data rows must be objects".into()
427                )),
428            }
429        }
430        Ok(result)
431    }
432
433    /// Render params components as UI controls (Div/Span elements).
434    /// Matches the JS paramsUI.js visual output with proper CSS classes.
435    fn render_params_ui(&self, params_specs: &[&spec::ParamsSpec]) -> ChartElement {
436        let mut param_groups = Vec::new();
437
438        for params_spec in params_specs {
439            for param in &params_spec.params {
440                let control = self.render_param_control(param);
441                param_groups.push(ChartElement::Div {
442                    class: "chartml-param-group".to_string(),
443                    style: HashMap::new(),
444                    children: vec![control],
445                });
446            }
447        }
448
449        ChartElement::Div {
450            class: "chartml-params".to_string(),
451            style: HashMap::from([
452                ("display".to_string(), "flex".to_string()),
453                ("flex-wrap".to_string(), "wrap".to_string()),
454                ("gap".to_string(), "12px".to_string()),
455                ("padding".to_string(), "12px 0".to_string()),
456            ]),
457            children: param_groups,
458        }
459    }
460
461    /// Render a single parameter control based on its type.
462    fn render_param_control(&self, param: &spec::ParamDef) -> ChartElement {
463        let label = ChartElement::Span {
464            class: "chartml-param-label".to_string(),
465            style: HashMap::from([
466                ("font-size".to_string(), "12px".to_string()),
467                ("font-weight".to_string(), "600".to_string()),
468                ("color".to_string(), "#555".to_string()),
469                ("display".to_string(), "block".to_string()),
470                ("margin-bottom".to_string(), "4px".to_string()),
471            ]),
472            content: param.label.clone(),
473        };
474
475        let control = match param.param_type.as_str() {
476            "multiselect" => {
477                let _options_text = param.options.as_ref()
478                    .map(|opts| opts.join(", "))
479                    .unwrap_or_default();
480                let default_text = param.default.as_ref()
481                    .map(|d| match d {
482                        serde_json::Value::Array(arr) => arr.iter()
483                            .filter_map(|v| v.as_str())
484                            .collect::<Vec<_>>()
485                            .join(", "),
486                        _ => d.to_string(),
487                    })
488                    .unwrap_or_default();
489                ChartElement::Div {
490                    class: "chartml-param-control chartml-param-multiselect".to_string(),
491                    style: HashMap::from([
492                        ("background".to_string(), "#f5f5f5".to_string()),
493                        ("border".to_string(), "1px solid #ddd".to_string()),
494                        ("border-radius".to_string(), "4px".to_string()),
495                        ("padding".to_string(), "6px 10px".to_string()),
496                        ("font-size".to_string(), "13px".to_string()),
497                        ("color".to_string(), self.theme.text.clone()),
498                        ("min-width".to_string(), "140px".to_string()),
499                    ]),
500                    children: vec![ChartElement::Span {
501                        class: "".to_string(),
502                        style: HashMap::new(),
503                        content: default_text,
504                    }],
505                }
506            }
507            "select" => {
508                let default_text = param.default.as_ref()
509                    .and_then(|d| d.as_str())
510                    .unwrap_or("")
511                    .to_string();
512                ChartElement::Div {
513                    class: "chartml-param-control chartml-param-select".to_string(),
514                    style: HashMap::from([
515                        ("background".to_string(), "#f5f5f5".to_string()),
516                        ("border".to_string(), "1px solid #ddd".to_string()),
517                        ("border-radius".to_string(), "4px".to_string()),
518                        ("padding".to_string(), "6px 10px".to_string()),
519                        ("font-size".to_string(), "13px".to_string()),
520                        ("color".to_string(), self.theme.text.clone()),
521                        ("min-width".to_string(), "120px".to_string()),
522                    ]),
523                    children: vec![ChartElement::Span {
524                        class: "".to_string(),
525                        style: HashMap::new(),
526                        content: format!("{} ▾", default_text),
527                    }],
528                }
529            }
530            "daterange" => {
531                let default_text = param.default.as_ref()
532                    .map(|d| {
533                        let start = d.get("start").and_then(|v| v.as_str()).unwrap_or("");
534                        let end = d.get("end").and_then(|v| v.as_str()).unwrap_or("");
535                        format!("{} → {}", start, end)
536                    })
537                    .unwrap_or_default();
538                ChartElement::Div {
539                    class: "chartml-param-control chartml-param-daterange".to_string(),
540                    style: HashMap::from([
541                        ("background".to_string(), "#f5f5f5".to_string()),
542                        ("border".to_string(), "1px solid #ddd".to_string()),
543                        ("border-radius".to_string(), "4px".to_string()),
544                        ("padding".to_string(), "6px 10px".to_string()),
545                        ("font-size".to_string(), "13px".to_string()),
546                        ("color".to_string(), self.theme.text.clone()),
547                    ]),
548                    children: vec![ChartElement::Span {
549                        class: "".to_string(),
550                        style: HashMap::new(),
551                        content: default_text,
552                    }],
553                }
554            }
555            "number" => {
556                let default_text = param.default.as_ref()
557                    .map(|d| d.to_string())
558                    .unwrap_or_default();
559                ChartElement::Div {
560                    class: "chartml-param-control chartml-param-number".to_string(),
561                    style: HashMap::from([
562                        ("background".to_string(), "#f5f5f5".to_string()),
563                        ("border".to_string(), "1px solid #ddd".to_string()),
564                        ("border-radius".to_string(), "4px".to_string()),
565                        ("padding".to_string(), "6px 10px".to_string()),
566                        ("font-size".to_string(), "13px".to_string()),
567                        ("color".to_string(), self.theme.text.clone()),
568                        ("min-width".to_string(), "80px".to_string()),
569                    ]),
570                    children: vec![ChartElement::Span {
571                        class: "".to_string(),
572                        style: HashMap::new(),
573                        content: default_text,
574                    }],
575                }
576            }
577            _ => {
578                let default_text = param.default.as_ref()
579                    .map(|d| d.to_string())
580                    .unwrap_or_default();
581                ChartElement::Div {
582                    class: "chartml-param-control chartml-param-text".to_string(),
583                    style: HashMap::from([
584                        ("background".to_string(), "#f5f5f5".to_string()),
585                        ("border".to_string(), "1px solid #ddd".to_string()),
586                        ("border-radius".to_string(), "4px".to_string()),
587                        ("padding".to_string(), "6px 10px".to_string()),
588                        ("font-size".to_string(), "13px".to_string()),
589                        ("color".to_string(), self.theme.text.clone()),
590                    ]),
591                    children: vec![ChartElement::Span {
592                        class: "".to_string(),
593                        style: HashMap::new(),
594                        content: param.placeholder.clone().unwrap_or(default_text),
595                    }],
596                }
597            }
598        };
599
600        ChartElement::Div {
601            class: "chartml-param-item".to_string(),
602            style: HashMap::from([
603                ("display".to_string(), "flex".to_string()),
604                ("flex-direction".to_string(), "column".to_string()),
605            ]),
606            children: vec![label, control],
607        }
608    }
609
610    // --- Async rendering (for use with TransformMiddleware, e.g. DataFusion) ---
611
612    /// Async render with full parameter support — mirrors `render_from_yaml_with_params`
613    /// but uses the registered TransformMiddleware for ALL transforms (sql, aggregate, forecast).
614    /// Falls back to built-in sync transform only if no middleware is registered.
615    pub async fn render_from_yaml_with_params_async(
616        &self,
617        yaml: &str,
618        container_width: Option<f64>,
619        container_height: Option<f64>,
620        param_overrides: Option<&params::ParamValues>,
621    ) -> Result<ChartElement, ChartError> {
622        // Step 1: Resolve params (same as sync path)
623        let mut all_params = self.param_values.clone();
624        let inline_defaults = params::extract_inline_param_defaults(yaml);
625        all_params.extend(inline_defaults);
626        if let Some(overrides) = param_overrides {
627            all_params.extend(overrides.iter().map(|(k, v)| (k.clone(), v.clone())));
628        }
629        let resolved_yaml = if !all_params.is_empty() {
630            params::resolve_param_references(yaml, &all_params)
631        } else {
632            yaml.to_string()
633        };
634
635        let parsed = spec::parse(&resolved_yaml)?;
636
637        // Step 2: Collect sources
638        let mut sources: HashMap<String, DataTable> = self.sources.clone();
639        if let ChartMLSpec::Array(ref components) = parsed {
640            for component in components {
641                if let Component::Source(source_spec) = component {
642                    if let Some(ref rows) = source_spec.rows {
643                        let json_rows = self.convert_json_rows(rows)?;
644                        let data = DataTable::from_rows(&json_rows)?;
645                        sources.insert(source_spec.name.clone(), data);
646                    }
647                }
648            }
649        }
650
651        // Step 3: Extract chart spec
652        let chart_spec: &ChartSpec = match &parsed {
653            ChartMLSpec::Single(component) => match component.as_ref() {
654                Component::Chart(chart) => chart.as_ref(),
655                _ => return Err(ChartError::InvalidSpec("No chart component found".into())),
656            },
657            ChartMLSpec::Array(components) => {
658                components.iter()
659                    .find_map(|c| match c {
660                        Component::Chart(chart) => Some(chart.as_ref()),
661                        _ => None,
662                    })
663                    .ok_or_else(|| ChartError::InvalidSpec("No chart component found".into()))?
664            }
665        };
666
667        // Step 4: Resolve data
668        let chart_data = self.resolve_chart_data(chart_spec, &sources)?;
669
670        // Step 5: Transform — use middleware for ALL transforms when registered
671        let transformed_data = if let Some(ref transform_spec) = chart_spec.transform {
672            if let Some(middleware) = self.registry.get_transform() {
673                let context = plugin::TransformContext::default();
674                let result = middleware.transform(chart_data, transform_spec, &context).await?;
675                result.data
676            } else {
677                // No middleware — fall back to built-in sync transform (aggregate only)
678                // DataTable → Vec<Row> → apply_transforms → DataTable
679                let rows = chart_data.to_rows();
680                let transformed_rows = transform::apply_transforms(rows, transform_spec)?;
681                DataTable::from_rows(&transformed_rows)?
682            }
683        } else {
684            chart_data
685        };
686
687        // Step 6: Render
688        self.build_and_render(chart_spec, &transformed_data, container_width, container_height)
689    }
690
691    /// Async render with external data — for integration tests and programmatic use.
692    /// Data is used as fallback when spec has empty inline rows.
693    pub async fn render_from_yaml_with_data_async(
694        &self,
695        yaml: &str,
696        data: DataTable,
697    ) -> Result<ChartElement, ChartError> {
698        // Register data as "source", then delegate to full async render
699        let parsed = spec::parse(yaml)?;
700        let chart_spec: &ChartSpec = match &parsed {
701            ChartMLSpec::Single(component) => match component.as_ref() {
702                Component::Chart(chart) => chart.as_ref(),
703                _ => return Err(ChartError::InvalidSpec("No chart component found".into())),
704            },
705            ChartMLSpec::Array(components) => {
706                components.iter()
707                    .find_map(|c| match c { Component::Chart(chart) => Some(chart.as_ref()), _ => None })
708                    .ok_or_else(|| ChartError::InvalidSpec("No chart component found".into()))?
709            }
710        };
711
712        let chart_data = match &chart_spec.data {
713            DataRef::Inline(inline) => {
714                let inline_rows = inline.rows.as_ref()
715                    .map(|r| self.convert_json_rows(r))
716                    .transpose()?
717                    .unwrap_or_default();
718                let inline_table = DataTable::from_rows(&inline_rows)?;
719                if inline_table.is_empty() && !data.is_empty() { data } else { inline_table }
720            }
721            DataRef::Named(name) => {
722                self.sources.get(name).cloned()
723                    .ok_or_else(|| ChartError::DataError(format!("Source '{}' not found", name)))?
724            }
725            DataRef::NamedMap(_) => {
726                return Err(ChartError::InvalidSpec(
727                    "Multi-source `data:` map requires each source to be pre-fetched and registered by name, then `data:` rewritten to that name.".into()
728                ));
729            }
730        };
731
732        let transformed_data = if let Some(ref transform_spec) = chart_spec.transform {
733            if let Some(middleware) = self.registry.get_transform() {
734                let context = plugin::TransformContext::default();
735                let result = middleware.transform(chart_data, transform_spec, &context).await?;
736                result.data
737            } else if transform_spec.sql.is_some() || transform_spec.forecast.is_some() {
738                return Err(ChartError::InvalidSpec(
739                    "Spec uses sql or forecast transforms but no TransformMiddleware is registered".into()
740                ));
741            } else {
742                // Sync fallback: DataTable → Vec<Row> → apply_transforms → DataTable
743                let rows = chart_data.to_rows();
744                let transformed_rows = transform::apply_transforms(rows, transform_spec)?;
745                DataTable::from_rows(&transformed_rows)?
746            }
747        } else {
748            chart_data
749        };
750
751        self.build_and_render(chart_spec, &transformed_data, None, None)
752    }
753
754    /// Resolve chart data from inline rows or named sources.
755    fn resolve_chart_data(&self, chart_spec: &ChartSpec, sources: &HashMap<String, DataTable>) -> Result<DataTable, ChartError> {
756        match &chart_spec.data {
757            DataRef::Inline(inline) => {
758                let json_rows = inline.rows.as_ref()
759                    .map(|r| self.convert_json_rows(r))
760                    .transpose()?
761                    .unwrap_or_default();
762                DataTable::from_rows(&json_rows)
763            }
764            DataRef::Named(name) => {
765                sources.get(name)
766                    .cloned()
767                    .ok_or_else(|| ChartError::DataError(
768                        format!("Named data source '{}' not found", name)
769                    ))
770            }
771            DataRef::NamedMap(_) => {
772                Err(ChartError::InvalidSpec(
773                    "Multi-source `data:` map requires each source to be pre-fetched and registered by name, then `data:` rewritten to that name.".into()
774                ))
775            }
776        }
777    }
778
779    /// Build chart config and render — shared by sync and async paths.
780    fn build_and_render(
781        &self,
782        chart_spec: &ChartSpec,
783        data: &DataTable,
784        container_width: Option<f64>,
785        container_height: Option<f64>,
786    ) -> Result<ChartElement, ChartError> {
787        let chart_type = &chart_spec.visualize.chart_type;
788        let renderer = self.registry.get_renderer(chart_type)
789            .ok_or_else(|| ChartError::UnknownChartType(chart_type.clone()))?;
790
791        let default_height = renderer.default_dimensions(&chart_spec.visualize)
792            .map(|d| d.height)
793            .unwrap_or(400.0);
794
795        let height = chart_spec.visualize.style.as_ref()
796            .and_then(|s| s.height)
797            .unwrap_or(container_height.unwrap_or(default_height));
798
799        let width = chart_spec.visualize.style.as_ref()
800            .and_then(|s| s.width)
801            .unwrap_or(container_width.unwrap_or(800.0));
802
803        let colors = chart_spec.visualize.style.as_ref()
804            .and_then(|s| s.colors.clone())
805            .or_else(|| self.default_palette.clone())
806            .unwrap_or_else(|| {
807                color::get_chart_colors(12, color::palettes::get_palette("autumn_forest"))
808            });
809
810        let config = plugin::ChartConfig {
811            visualize: chart_spec.visualize.clone(),
812            title: chart_spec.title.clone(),
813            width,
814            height,
815            colors,
816            theme: self.theme.clone(),
817        };
818
819        renderer.render(data, &config)
820    }
821
822    /// Get a reference to the internal registry.
823    pub fn registry(&self) -> &ChartMLRegistry {
824        &self.registry
825    }
826
827    /// Get a mutable reference to the internal registry.
828    pub fn registry_mut(&mut self) -> &mut ChartMLRegistry {
829        &mut self.registry
830    }
831}
832
833impl Default for ChartML {
834    fn default() -> Self {
835        Self::new()
836    }
837}
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842    use crate::element::ViewBox;
843
844    struct MockRenderer;
845
846    impl ChartRenderer for MockRenderer {
847        fn render(&self, _data: &DataTable, _config: &ChartConfig) -> Result<ChartElement, ChartError> {
848            Ok(ChartElement::Svg {
849                viewbox: ViewBox::new(0.0, 0.0, 800.0, 400.0),
850                width: Some(800.0),
851                height: Some(400.0),
852                class: "mock".to_string(),
853                children: vec![],
854            })
855        }
856    }
857
858    #[test]
859    fn chartml_render_from_yaml_with_mock() {
860        let mut chartml = ChartML::new();
861        chartml.register_renderer("bar", MockRenderer);
862
863        let yaml = r#"
864type: chart
865version: 1
866title: Test
867data:
868  provider: inline
869  rows:
870    - { x: "A", y: 10 }
871    - { x: "B", y: 20 }
872visualize:
873  type: bar
874  columns: x
875  rows: y
876"#;
877
878        let result = chartml.render_from_yaml(yaml);
879        assert!(result.is_ok(), "render failed: {:?}", result.err());
880    }
881
882    #[test]
883    fn chartml_unknown_chart_type() {
884        let chartml = ChartML::new();
885        let yaml = r#"
886type: chart
887version: 1
888data:
889  provider: inline
890  rows: []
891visualize:
892  type: unknown_type
893  columns: x
894  rows: y
895"#;
896        let result = chartml.render_from_yaml(yaml);
897        assert!(result.is_err());
898    }
899
900    #[test]
901    fn chartml_named_source_resolution() {
902        let mut chartml = ChartML::new();
903        chartml.register_renderer("bar", MockRenderer);
904
905        let yaml = r#"---
906type: source
907version: 1
908name: q1_sales
909provider: inline
910rows:
911  - { month: "Jan", revenue: 100 }
912  - { month: "Feb", revenue: 200 }
913---
914type: chart
915version: 1
916title: Revenue by Month
917data: q1_sales
918visualize:
919  type: bar
920  columns: month
921  rows: revenue
922"#;
923
924        let result = chartml.render_from_yaml(yaml);
925        assert!(result.is_ok(), "named source render failed: {:?}", result.err());
926    }
927
928    #[test]
929    fn chartml_named_source_not_found() {
930        let mut chartml = ChartML::new();
931        chartml.register_renderer("bar", MockRenderer);
932
933        let yaml = r#"
934type: chart
935version: 1
936data: nonexistent_source
937visualize:
938  type: bar
939  columns: x
940  rows: y
941"#;
942
943        let result = chartml.render_from_yaml(yaml);
944        assert!(result.is_err());
945        let err = result.unwrap_err().to_string();
946        assert!(err.contains("not found"), "Expected 'not found' error, got: {}", err);
947    }
948
949    #[test]
950    fn chartml_multi_chart_rendering() {
951        let mut chartml = ChartML::new();
952        chartml.register_renderer("bar", MockRenderer);
953
954        let yaml = r#"
955- type: chart
956  version: 1
957  title: Chart A
958  data:
959    provider: inline
960    rows:
961      - { x: "A", y: 10 }
962  visualize:
963    type: bar
964    columns: x
965    rows: y
966- type: chart
967  version: 1
968  title: Chart B
969  data:
970    provider: inline
971    rows:
972      - { x: "B", y: 20 }
973  visualize:
974    type: bar
975    columns: x
976    rows: y
977"#;
978
979        let result = chartml.render_from_yaml(yaml);
980        assert!(result.is_ok(), "multi-chart render failed: {:?}", result.err());
981        match result.unwrap() {
982            ChartElement::Div { class, children, .. } => {
983                assert_eq!(class, "chartml-multi-chart");
984                assert_eq!(children.len(), 2);
985            }
986            other => panic!("Expected Div wrapper, got {:?}", other),
987        }
988    }
989
990    #[test]
991    fn chartml_named_source_with_transform() {
992        let mut chartml = ChartML::new();
993        chartml.register_renderer("bar", MockRenderer);
994
995        let yaml = r#"---
996type: source
997version: 1
998name: raw_sales
999provider: inline
1000rows:
1001  - { region: "North", revenue: 100 }
1002  - { region: "North", revenue: 200 }
1003  - { region: "South", revenue: 150 }
1004---
1005type: chart
1006version: 1
1007title: Revenue by Region
1008data: raw_sales
1009transform:
1010  aggregate:
1011    dimensions:
1012      - region
1013    measures:
1014      - column: revenue
1015        aggregation: sum
1016        name: total_revenue
1017    sort:
1018      - field: total_revenue
1019        direction: desc
1020visualize:
1021  type: bar
1022  columns: region
1023  rows: total_revenue
1024"#;
1025
1026        let result = chartml.render_from_yaml(yaml);
1027        assert!(result.is_ok(), "transform pipeline render failed: {:?}", result.err());
1028    }
1029
1030    #[test]
1031    fn chartml_multi_chart_with_shared_source() {
1032        let mut chartml = ChartML::new();
1033        chartml.register_renderer("bar", MockRenderer);
1034        chartml.register_renderer("metric", MockRenderer);
1035
1036        let yaml = r#"---
1037type: source
1038version: 1
1039name: kpis
1040provider: inline
1041rows:
1042  - { totalRevenue: 1500000, previousRevenue: 1200000 }
1043---
1044- type: chart
1045  version: 1
1046  title: Revenue
1047  data: kpis
1048  visualize:
1049    type: metric
1050    value: totalRevenue
1051- type: chart
1052  version: 1
1053  title: Prev Revenue
1054  data: kpis
1055  visualize:
1056    type: metric
1057    value: previousRevenue
1058"#;
1059
1060        let result = chartml.render_from_yaml(yaml);
1061        assert!(result.is_ok(), "multi-chart shared source failed: {:?}", result.err());
1062    }
1063}