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        }
407    }
408
409    /// Convert JSON value rows into typed Row objects.
410    fn convert_json_rows(&self, rows: &[serde_json::Value]) -> Result<Vec<Row>, ChartError> {
411        let mut result = Vec::with_capacity(rows.len());
412        for value in rows {
413            match value {
414                serde_json::Value::Object(map) => {
415                    let row: Row = map.iter()
416                        .map(|(k, v)| (k.clone(), v.clone()))
417                        .collect();
418                    result.push(row);
419                }
420                _ => return Err(ChartError::DataError(
421                    "Data rows must be objects".into()
422                )),
423            }
424        }
425        Ok(result)
426    }
427
428    /// Render params components as UI controls (Div/Span elements).
429    /// Matches the JS paramsUI.js visual output with proper CSS classes.
430    fn render_params_ui(&self, params_specs: &[&spec::ParamsSpec]) -> ChartElement {
431        let mut param_groups = Vec::new();
432
433        for params_spec in params_specs {
434            for param in &params_spec.params {
435                let control = self.render_param_control(param);
436                param_groups.push(ChartElement::Div {
437                    class: "chartml-param-group".to_string(),
438                    style: HashMap::new(),
439                    children: vec![control],
440                });
441            }
442        }
443
444        ChartElement::Div {
445            class: "chartml-params".to_string(),
446            style: HashMap::from([
447                ("display".to_string(), "flex".to_string()),
448                ("flex-wrap".to_string(), "wrap".to_string()),
449                ("gap".to_string(), "12px".to_string()),
450                ("padding".to_string(), "12px 0".to_string()),
451            ]),
452            children: param_groups,
453        }
454    }
455
456    /// Render a single parameter control based on its type.
457    fn render_param_control(&self, param: &spec::ParamDef) -> ChartElement {
458        let label = ChartElement::Span {
459            class: "chartml-param-label".to_string(),
460            style: HashMap::from([
461                ("font-size".to_string(), "12px".to_string()),
462                ("font-weight".to_string(), "600".to_string()),
463                ("color".to_string(), "#555".to_string()),
464                ("display".to_string(), "block".to_string()),
465                ("margin-bottom".to_string(), "4px".to_string()),
466            ]),
467            content: param.label.clone(),
468        };
469
470        let control = match param.param_type.as_str() {
471            "multiselect" => {
472                let _options_text = param.options.as_ref()
473                    .map(|opts| opts.join(", "))
474                    .unwrap_or_default();
475                let default_text = param.default.as_ref()
476                    .map(|d| match d {
477                        serde_json::Value::Array(arr) => arr.iter()
478                            .filter_map(|v| v.as_str())
479                            .collect::<Vec<_>>()
480                            .join(", "),
481                        _ => d.to_string(),
482                    })
483                    .unwrap_or_default();
484                ChartElement::Div {
485                    class: "chartml-param-control chartml-param-multiselect".to_string(),
486                    style: HashMap::from([
487                        ("background".to_string(), "#f5f5f5".to_string()),
488                        ("border".to_string(), "1px solid #ddd".to_string()),
489                        ("border-radius".to_string(), "4px".to_string()),
490                        ("padding".to_string(), "6px 10px".to_string()),
491                        ("font-size".to_string(), "13px".to_string()),
492                        ("color".to_string(), self.theme.text.clone()),
493                        ("min-width".to_string(), "140px".to_string()),
494                    ]),
495                    children: vec![ChartElement::Span {
496                        class: "".to_string(),
497                        style: HashMap::new(),
498                        content: default_text,
499                    }],
500                }
501            }
502            "select" => {
503                let default_text = param.default.as_ref()
504                    .and_then(|d| d.as_str())
505                    .unwrap_or("")
506                    .to_string();
507                ChartElement::Div {
508                    class: "chartml-param-control chartml-param-select".to_string(),
509                    style: HashMap::from([
510                        ("background".to_string(), "#f5f5f5".to_string()),
511                        ("border".to_string(), "1px solid #ddd".to_string()),
512                        ("border-radius".to_string(), "4px".to_string()),
513                        ("padding".to_string(), "6px 10px".to_string()),
514                        ("font-size".to_string(), "13px".to_string()),
515                        ("color".to_string(), self.theme.text.clone()),
516                        ("min-width".to_string(), "120px".to_string()),
517                    ]),
518                    children: vec![ChartElement::Span {
519                        class: "".to_string(),
520                        style: HashMap::new(),
521                        content: format!("{} ▾", default_text),
522                    }],
523                }
524            }
525            "daterange" => {
526                let default_text = param.default.as_ref()
527                    .map(|d| {
528                        let start = d.get("start").and_then(|v| v.as_str()).unwrap_or("");
529                        let end = d.get("end").and_then(|v| v.as_str()).unwrap_or("");
530                        format!("{} → {}", start, end)
531                    })
532                    .unwrap_or_default();
533                ChartElement::Div {
534                    class: "chartml-param-control chartml-param-daterange".to_string(),
535                    style: HashMap::from([
536                        ("background".to_string(), "#f5f5f5".to_string()),
537                        ("border".to_string(), "1px solid #ddd".to_string()),
538                        ("border-radius".to_string(), "4px".to_string()),
539                        ("padding".to_string(), "6px 10px".to_string()),
540                        ("font-size".to_string(), "13px".to_string()),
541                        ("color".to_string(), self.theme.text.clone()),
542                    ]),
543                    children: vec![ChartElement::Span {
544                        class: "".to_string(),
545                        style: HashMap::new(),
546                        content: default_text,
547                    }],
548                }
549            }
550            "number" => {
551                let default_text = param.default.as_ref()
552                    .map(|d| d.to_string())
553                    .unwrap_or_default();
554                ChartElement::Div {
555                    class: "chartml-param-control chartml-param-number".to_string(),
556                    style: HashMap::from([
557                        ("background".to_string(), "#f5f5f5".to_string()),
558                        ("border".to_string(), "1px solid #ddd".to_string()),
559                        ("border-radius".to_string(), "4px".to_string()),
560                        ("padding".to_string(), "6px 10px".to_string()),
561                        ("font-size".to_string(), "13px".to_string()),
562                        ("color".to_string(), self.theme.text.clone()),
563                        ("min-width".to_string(), "80px".to_string()),
564                    ]),
565                    children: vec![ChartElement::Span {
566                        class: "".to_string(),
567                        style: HashMap::new(),
568                        content: default_text,
569                    }],
570                }
571            }
572            _ => {
573                let default_text = param.default.as_ref()
574                    .map(|d| d.to_string())
575                    .unwrap_or_default();
576                ChartElement::Div {
577                    class: "chartml-param-control chartml-param-text".to_string(),
578                    style: HashMap::from([
579                        ("background".to_string(), "#f5f5f5".to_string()),
580                        ("border".to_string(), "1px solid #ddd".to_string()),
581                        ("border-radius".to_string(), "4px".to_string()),
582                        ("padding".to_string(), "6px 10px".to_string()),
583                        ("font-size".to_string(), "13px".to_string()),
584                        ("color".to_string(), self.theme.text.clone()),
585                    ]),
586                    children: vec![ChartElement::Span {
587                        class: "".to_string(),
588                        style: HashMap::new(),
589                        content: param.placeholder.clone().unwrap_or(default_text),
590                    }],
591                }
592            }
593        };
594
595        ChartElement::Div {
596            class: "chartml-param-item".to_string(),
597            style: HashMap::from([
598                ("display".to_string(), "flex".to_string()),
599                ("flex-direction".to_string(), "column".to_string()),
600            ]),
601            children: vec![label, control],
602        }
603    }
604
605    // --- Async rendering (for use with TransformMiddleware, e.g. DataFusion) ---
606
607    /// Async render with full parameter support — mirrors `render_from_yaml_with_params`
608    /// but uses the registered TransformMiddleware for ALL transforms (sql, aggregate, forecast).
609    /// Falls back to built-in sync transform only if no middleware is registered.
610    pub async fn render_from_yaml_with_params_async(
611        &self,
612        yaml: &str,
613        container_width: Option<f64>,
614        container_height: Option<f64>,
615        param_overrides: Option<&params::ParamValues>,
616    ) -> Result<ChartElement, ChartError> {
617        // Step 1: Resolve params (same as sync path)
618        let mut all_params = self.param_values.clone();
619        let inline_defaults = params::extract_inline_param_defaults(yaml);
620        all_params.extend(inline_defaults);
621        if let Some(overrides) = param_overrides {
622            all_params.extend(overrides.iter().map(|(k, v)| (k.clone(), v.clone())));
623        }
624        let resolved_yaml = if !all_params.is_empty() {
625            params::resolve_param_references(yaml, &all_params)
626        } else {
627            yaml.to_string()
628        };
629
630        let parsed = spec::parse(&resolved_yaml)?;
631
632        // Step 2: Collect sources
633        let mut sources: HashMap<String, DataTable> = self.sources.clone();
634        if let ChartMLSpec::Array(ref components) = parsed {
635            for component in components {
636                if let Component::Source(source_spec) = component {
637                    if let Some(ref rows) = source_spec.rows {
638                        let json_rows = self.convert_json_rows(rows)?;
639                        let data = DataTable::from_rows(&json_rows)?;
640                        sources.insert(source_spec.name.clone(), data);
641                    }
642                }
643            }
644        }
645
646        // Step 3: Extract chart spec
647        let chart_spec: &ChartSpec = match &parsed {
648            ChartMLSpec::Single(component) => match component.as_ref() {
649                Component::Chart(chart) => chart.as_ref(),
650                _ => return Err(ChartError::InvalidSpec("No chart component found".into())),
651            },
652            ChartMLSpec::Array(components) => {
653                components.iter()
654                    .find_map(|c| match c {
655                        Component::Chart(chart) => Some(chart.as_ref()),
656                        _ => None,
657                    })
658                    .ok_or_else(|| ChartError::InvalidSpec("No chart component found".into()))?
659            }
660        };
661
662        // Step 4: Resolve data
663        let chart_data = self.resolve_chart_data(chart_spec, &sources)?;
664
665        // Step 5: Transform — use middleware for ALL transforms when registered
666        let transformed_data = if let Some(ref transform_spec) = chart_spec.transform {
667            if let Some(middleware) = self.registry.get_transform() {
668                let context = plugin::TransformContext::default();
669                let result = middleware.transform(chart_data, transform_spec, &context).await?;
670                result.data
671            } else {
672                // No middleware — fall back to built-in sync transform (aggregate only)
673                // DataTable → Vec<Row> → apply_transforms → DataTable
674                let rows = chart_data.to_rows();
675                let transformed_rows = transform::apply_transforms(rows, transform_spec)?;
676                DataTable::from_rows(&transformed_rows)?
677            }
678        } else {
679            chart_data
680        };
681
682        // Step 6: Render
683        self.build_and_render(chart_spec, &transformed_data, container_width, container_height)
684    }
685
686    /// Async render with external data — for integration tests and programmatic use.
687    /// Data is used as fallback when spec has empty inline rows.
688    pub async fn render_from_yaml_with_data_async(
689        &self,
690        yaml: &str,
691        data: DataTable,
692    ) -> Result<ChartElement, ChartError> {
693        // Register data as "source", then delegate to full async render
694        let parsed = spec::parse(yaml)?;
695        let chart_spec: &ChartSpec = match &parsed {
696            ChartMLSpec::Single(component) => match component.as_ref() {
697                Component::Chart(chart) => chart.as_ref(),
698                _ => return Err(ChartError::InvalidSpec("No chart component found".into())),
699            },
700            ChartMLSpec::Array(components) => {
701                components.iter()
702                    .find_map(|c| match c { Component::Chart(chart) => Some(chart.as_ref()), _ => None })
703                    .ok_or_else(|| ChartError::InvalidSpec("No chart component found".into()))?
704            }
705        };
706
707        let chart_data = match &chart_spec.data {
708            DataRef::Inline(inline) => {
709                let inline_rows = inline.rows.as_ref()
710                    .map(|r| self.convert_json_rows(r))
711                    .transpose()?
712                    .unwrap_or_default();
713                let inline_table = DataTable::from_rows(&inline_rows)?;
714                if inline_table.is_empty() && !data.is_empty() { data } else { inline_table }
715            }
716            DataRef::Named(name) => {
717                self.sources.get(name).cloned()
718                    .ok_or_else(|| ChartError::DataError(format!("Source '{}' not found", name)))?
719            }
720        };
721
722        let transformed_data = if let Some(ref transform_spec) = chart_spec.transform {
723            if let Some(middleware) = self.registry.get_transform() {
724                let context = plugin::TransformContext::default();
725                let result = middleware.transform(chart_data, transform_spec, &context).await?;
726                result.data
727            } else if transform_spec.sql.is_some() || transform_spec.forecast.is_some() {
728                return Err(ChartError::InvalidSpec(
729                    "Spec uses sql or forecast transforms but no TransformMiddleware is registered".into()
730                ));
731            } else {
732                // Sync fallback: DataTable → Vec<Row> → apply_transforms → DataTable
733                let rows = chart_data.to_rows();
734                let transformed_rows = transform::apply_transforms(rows, transform_spec)?;
735                DataTable::from_rows(&transformed_rows)?
736            }
737        } else {
738            chart_data
739        };
740
741        self.build_and_render(chart_spec, &transformed_data, None, None)
742    }
743
744    /// Resolve chart data from inline rows or named sources.
745    fn resolve_chart_data(&self, chart_spec: &ChartSpec, sources: &HashMap<String, DataTable>) -> Result<DataTable, ChartError> {
746        match &chart_spec.data {
747            DataRef::Inline(inline) => {
748                let json_rows = inline.rows.as_ref()
749                    .map(|r| self.convert_json_rows(r))
750                    .transpose()?
751                    .unwrap_or_default();
752                DataTable::from_rows(&json_rows)
753            }
754            DataRef::Named(name) => {
755                sources.get(name)
756                    .cloned()
757                    .ok_or_else(|| ChartError::DataError(
758                        format!("Named data source '{}' not found", name)
759                    ))
760            }
761        }
762    }
763
764    /// Build chart config and render — shared by sync and async paths.
765    fn build_and_render(
766        &self,
767        chart_spec: &ChartSpec,
768        data: &DataTable,
769        container_width: Option<f64>,
770        container_height: Option<f64>,
771    ) -> Result<ChartElement, ChartError> {
772        let chart_type = &chart_spec.visualize.chart_type;
773        let renderer = self.registry.get_renderer(chart_type)
774            .ok_or_else(|| ChartError::UnknownChartType(chart_type.clone()))?;
775
776        let default_height = renderer.default_dimensions(&chart_spec.visualize)
777            .map(|d| d.height)
778            .unwrap_or(400.0);
779
780        let height = chart_spec.visualize.style.as_ref()
781            .and_then(|s| s.height)
782            .unwrap_or(container_height.unwrap_or(default_height));
783
784        let width = chart_spec.visualize.style.as_ref()
785            .and_then(|s| s.width)
786            .unwrap_or(container_width.unwrap_or(800.0));
787
788        let colors = chart_spec.visualize.style.as_ref()
789            .and_then(|s| s.colors.clone())
790            .or_else(|| self.default_palette.clone())
791            .unwrap_or_else(|| {
792                color::get_chart_colors(12, color::palettes::get_palette("autumn_forest"))
793            });
794
795        let config = plugin::ChartConfig {
796            visualize: chart_spec.visualize.clone(),
797            title: chart_spec.title.clone(),
798            width,
799            height,
800            colors,
801            theme: self.theme.clone(),
802        };
803
804        renderer.render(data, &config)
805    }
806
807    /// Get a reference to the internal registry.
808    pub fn registry(&self) -> &ChartMLRegistry {
809        &self.registry
810    }
811
812    /// Get a mutable reference to the internal registry.
813    pub fn registry_mut(&mut self) -> &mut ChartMLRegistry {
814        &mut self.registry
815    }
816}
817
818impl Default for ChartML {
819    fn default() -> Self {
820        Self::new()
821    }
822}
823
824#[cfg(test)]
825mod tests {
826    use super::*;
827    use crate::element::ViewBox;
828
829    struct MockRenderer;
830
831    impl ChartRenderer for MockRenderer {
832        fn render(&self, _data: &DataTable, _config: &ChartConfig) -> Result<ChartElement, ChartError> {
833            Ok(ChartElement::Svg {
834                viewbox: ViewBox::new(0.0, 0.0, 800.0, 400.0),
835                width: Some(800.0),
836                height: Some(400.0),
837                class: "mock".to_string(),
838                children: vec![],
839            })
840        }
841    }
842
843    #[test]
844    fn chartml_render_from_yaml_with_mock() {
845        let mut chartml = ChartML::new();
846        chartml.register_renderer("bar", MockRenderer);
847
848        let yaml = r#"
849type: chart
850version: 1
851title: Test
852data:
853  provider: inline
854  rows:
855    - { x: "A", y: 10 }
856    - { x: "B", y: 20 }
857visualize:
858  type: bar
859  columns: x
860  rows: y
861"#;
862
863        let result = chartml.render_from_yaml(yaml);
864        assert!(result.is_ok(), "render failed: {:?}", result.err());
865    }
866
867    #[test]
868    fn chartml_unknown_chart_type() {
869        let chartml = ChartML::new();
870        let yaml = r#"
871type: chart
872version: 1
873data:
874  provider: inline
875  rows: []
876visualize:
877  type: unknown_type
878  columns: x
879  rows: y
880"#;
881        let result = chartml.render_from_yaml(yaml);
882        assert!(result.is_err());
883    }
884
885    #[test]
886    fn chartml_named_source_resolution() {
887        let mut chartml = ChartML::new();
888        chartml.register_renderer("bar", MockRenderer);
889
890        let yaml = r#"---
891type: source
892version: 1
893name: q1_sales
894provider: inline
895rows:
896  - { month: "Jan", revenue: 100 }
897  - { month: "Feb", revenue: 200 }
898---
899type: chart
900version: 1
901title: Revenue by Month
902data: q1_sales
903visualize:
904  type: bar
905  columns: month
906  rows: revenue
907"#;
908
909        let result = chartml.render_from_yaml(yaml);
910        assert!(result.is_ok(), "named source render failed: {:?}", result.err());
911    }
912
913    #[test]
914    fn chartml_named_source_not_found() {
915        let mut chartml = ChartML::new();
916        chartml.register_renderer("bar", MockRenderer);
917
918        let yaml = r#"
919type: chart
920version: 1
921data: nonexistent_source
922visualize:
923  type: bar
924  columns: x
925  rows: y
926"#;
927
928        let result = chartml.render_from_yaml(yaml);
929        assert!(result.is_err());
930        let err = result.unwrap_err().to_string();
931        assert!(err.contains("not found"), "Expected 'not found' error, got: {}", err);
932    }
933
934    #[test]
935    fn chartml_multi_chart_rendering() {
936        let mut chartml = ChartML::new();
937        chartml.register_renderer("bar", MockRenderer);
938
939        let yaml = r#"
940- type: chart
941  version: 1
942  title: Chart A
943  data:
944    provider: inline
945    rows:
946      - { x: "A", y: 10 }
947  visualize:
948    type: bar
949    columns: x
950    rows: y
951- type: chart
952  version: 1
953  title: Chart B
954  data:
955    provider: inline
956    rows:
957      - { x: "B", y: 20 }
958  visualize:
959    type: bar
960    columns: x
961    rows: y
962"#;
963
964        let result = chartml.render_from_yaml(yaml);
965        assert!(result.is_ok(), "multi-chart render failed: {:?}", result.err());
966        match result.unwrap() {
967            ChartElement::Div { class, children, .. } => {
968                assert_eq!(class, "chartml-multi-chart");
969                assert_eq!(children.len(), 2);
970            }
971            other => panic!("Expected Div wrapper, got {:?}", other),
972        }
973    }
974
975    #[test]
976    fn chartml_named_source_with_transform() {
977        let mut chartml = ChartML::new();
978        chartml.register_renderer("bar", MockRenderer);
979
980        let yaml = r#"---
981type: source
982version: 1
983name: raw_sales
984provider: inline
985rows:
986  - { region: "North", revenue: 100 }
987  - { region: "North", revenue: 200 }
988  - { region: "South", revenue: 150 }
989---
990type: chart
991version: 1
992title: Revenue by Region
993data: raw_sales
994transform:
995  aggregate:
996    dimensions:
997      - region
998    measures:
999      - column: revenue
1000        aggregation: sum
1001        name: total_revenue
1002    sort:
1003      - field: total_revenue
1004        direction: desc
1005visualize:
1006  type: bar
1007  columns: region
1008  rows: total_revenue
1009"#;
1010
1011        let result = chartml.render_from_yaml(yaml);
1012        assert!(result.is_ok(), "transform pipeline render failed: {:?}", result.err());
1013    }
1014
1015    #[test]
1016    fn chartml_multi_chart_with_shared_source() {
1017        let mut chartml = ChartML::new();
1018        chartml.register_renderer("bar", MockRenderer);
1019        chartml.register_renderer("metric", MockRenderer);
1020
1021        let yaml = r#"---
1022type: source
1023version: 1
1024name: kpis
1025provider: inline
1026rows:
1027  - { totalRevenue: 1500000, previousRevenue: 1200000 }
1028---
1029- type: chart
1030  version: 1
1031  title: Revenue
1032  data: kpis
1033  visualize:
1034    type: metric
1035    value: totalRevenue
1036- type: chart
1037  version: 1
1038  title: Prev Revenue
1039  data: kpis
1040  visualize:
1041    type: metric
1042    value: previousRevenue
1043"#;
1044
1045        let result = chartml.render_from_yaml(yaml);
1046        assert!(result.is_ok(), "multi-chart shared source failed: {:?}", result.err());
1047    }
1048}