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