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;
15pub mod svg;
16pub mod pipeline;
17pub mod resolver;
18
19pub use error::ChartError;
20pub use spec::{parse, ChartMLSpec, Component};
21pub use element::ChartElement;
22pub use plugin::{ChartConfig, ChartRenderer, DataSource, TransformMiddleware, DatasourceResolver};
23pub use registry::ChartMLRegistry;
24pub use theme::Theme;
25pub use pipeline::{FetchedChart, PreparedChart, FetchMetadata, PreparedMetadata, RenderOptions};
26pub use resolver::{
27    CacheBackend, CacheBackendRef, CacheConfig, CacheError, CachedEntry, CacheHitEvent,
28    CacheMissEvent, CacheTier, CancellationToken, DataSourceProvider, ErrorEvent, FetchBatchResult,
29    FetchError, FetchRequest, FetchResult, HooksRef, HttpProvider, InlineProvider, MemoryBackend,
30    MissReason, NullHooks, Phase, ProgressEvent, ResolveOutcome, Resolver, ResolverHooks,
31    ResolverRef, SharedRef,
32};
33
34use std::collections::HashMap;
35use std::sync::Arc;
36// `web_time::SystemTime` is a wasm32-compatible drop-in for `std::time::SystemTime`.
37use web_time::SystemTime;
38
39use arrow::array::RecordBatch;
40use arrow::datatypes::SchemaRef;
41use indexmap::IndexMap;
42
43use crate::data::{Row, DataTable};
44use crate::spec::{ChartSpec, DataRef, InlineData};
45
46/// Main ChartML instance. Orchestrates parsing, data fetching, and rendering.
47/// Maintains source and parameter registries that persist across render calls,
48/// matching the JS ChartML class behavior.
49pub struct ChartML {
50    registry: ChartMLRegistry,
51    /// Named source data, registered via register_component() or
52    /// automatically collected from multi-document YAML specs. Pre-registered
53    /// sources are the chartml 5.0 "fast path": `data: name` references and
54    /// `data: { name: ... }` map entries that match a registered name skip
55    /// the resolver entirely and use the registered table directly.
56    sources: HashMap<String, DataTable>,
57    /// Parameter default values, collected from type: params components.
58    param_values: params::ParamValues,
59    /// Default color palette — used when the spec doesn't specify `style.colors`.
60    /// Mirrors the JS ChartML `setDefaultPalette()` API.
61    default_palette: Option<Vec<String>>,
62    /// Theme colors for chart chrome (axes, grid, text).
63    /// Defaults to light mode. Set via `set_theme()` to match your app's appearance.
64    theme: theme::Theme,
65    /// Provider + cache + dedup orchestrator. Held behind `ResolverRef`
66    /// (`Arc` on native, `Rc` on WASM) so consumers can grab a handle for
67    /// the `invalidate*` API while ChartML keeps using it. Pre-registered
68    /// with built-in `inline` + `http` providers — the `datasource` slot is
69    /// intentionally empty so consumers must opt in.
70    resolver: resolver::ResolverRef,
71    /// Optional tenant / workspace namespace. When set, every `FetchRequest`
72    /// the resolver dispatches carries this string and the cache key includes
73    /// it — preventing cross-tenant cache collisions on shared deployments.
74    namespace: Option<String>,
75}
76
77impl ChartML {
78    /// Create a new empty ChartML instance with the built-in `inline` and
79    /// `http` providers pre-registered. The `datasource` provider slot is
80    /// intentionally empty — consumers using `data: { datasource: ... }`
81    /// shapes must register their own provider via `register_provider("datasource", ...)`.
82    pub fn new() -> Self {
83        let resolver = resolver::ResolverRef::new(resolver::Resolver::new());
84        // Pre-register the two built-in providers. Consumers can override
85        // either by re-registering under the same kind key.
86        resolver.register_provider("inline", Arc::new(resolver::InlineProvider::new()));
87        resolver.register_provider("http", Arc::new(resolver::HttpProvider::new()));
88        Self {
89            registry: ChartMLRegistry::new(),
90            sources: HashMap::new(),
91            param_values: params::ParamValues::new(),
92            default_palette: None,
93            theme: theme::Theme::default(),
94            resolver,
95            namespace: None,
96        }
97    }
98
99    /// Create with default built-in plugins.
100    /// (No built-in renderers — those come from chartml-chart-* crates)
101    pub fn with_defaults() -> Self {
102        Self::new()
103    }
104
105    // --- Registration methods (delegate to registry) ---
106
107    pub fn register_renderer(&mut self, chart_type: &str, renderer: impl ChartRenderer + 'static) {
108        self.registry.register_renderer(chart_type, renderer);
109    }
110
111    pub fn register_data_source(&mut self, name: &str, source: impl DataSource + 'static) {
112        self.registry.register_data_source(name, source);
113    }
114
115    pub fn register_transform(&mut self, middleware: impl TransformMiddleware + 'static) {
116        self.registry.register_transform(middleware);
117    }
118
119    pub fn set_datasource_resolver(&mut self, resolver: impl DatasourceResolver + 'static) {
120        self.registry.set_datasource_resolver(resolver);
121    }
122
123    /// Set the default color palette for charts that don't specify `style.colors`.
124    /// Matches the JS ChartML `setDefaultPalette()` API.
125    pub fn set_default_palette(&mut self, colors: Vec<String>) {
126        self.default_palette = Some(colors);
127    }
128
129    /// Set the theme for chart chrome colors (axes, grid, text, background).
130    /// Use `Theme::default()` for light mode, `Theme::dark()` for dark mode,
131    /// or construct a custom `Theme` to match your application's appearance.
132    pub fn set_theme(&mut self, theme: theme::Theme) {
133        self.theme = theme;
134    }
135
136    /// Get a reference to the current theme. Consumers (e.g. chartml-leptos)
137    /// use this to thread typography into HTML chrome rendered outside the SVG.
138    pub fn theme(&self) -> &theme::Theme {
139        &self.theme
140    }
141
142    // --- Component registration (matches JS chartml.registerComponent()) ---
143
144    /// Register a non-chart component (source, style, config, params) from a YAML string.
145    /// Sources are stored in the instance and available to all subsequent render calls.
146    /// This matches the JS `chartml.registerComponent(spec)` API.
147    pub fn register_component(&mut self, yaml: &str) -> Result<(), ChartError> {
148        let parsed = spec::parse(yaml)?;
149        match parsed {
150            ChartMLSpec::Single(component) => self.register_single_component(*component),
151            ChartMLSpec::Array(components) => {
152                for component in components {
153                    self.register_single_component(component)?;
154                }
155                Ok(())
156            }
157        }
158    }
159
160    fn register_single_component(&mut self, component: spec::Component) -> Result<(), ChartError> {
161        match component {
162            spec::Component::Source(source_spec) => {
163                if let Some(ref rows) = source_spec.rows {
164                    let json_rows = self.convert_json_rows(rows)?;
165                    let data = DataTable::from_rows(&json_rows)?;
166                    self.sources.insert(source_spec.name.clone(), data);
167                }
168                Ok(())
169            }
170            spec::Component::Params(params_spec) => {
171                let defaults = params::collect_param_defaults(&[&params_spec]);
172                self.param_values.extend(defaults);
173                Ok(())
174            }
175            spec::Component::Style(_) | spec::Component::Config(_) => {
176                // Style/config registration — stored for future use
177                Ok(())
178            }
179            spec::Component::Chart(..) => {
180                Err(ChartError::InvalidSpec(
181                    "Cannot register chart components. Use render_from_yaml() instead.".into()
182                ))
183            }
184        }
185    }
186
187    /// Register a named source directly from a DataTable.
188    pub fn register_source(&mut self, name: &str, data: DataTable) {
189        self.sources.insert(name.to_string(), data);
190    }
191
192    // --- Provider / cache / namespace wiring (chartml 5.0 phase 3) ---
193
194    /// Register a `DataSourceProvider` under a dispatch key.
195    ///
196    /// Built-in kinds:
197    /// - `"inline"` — handles `data: { rows: [...] }`. Pre-registered;
198    ///   overridable.
199    /// - `"http"` — handles `data: { url: "..." }`. Pre-registered;
200    ///   overridable.
201    /// - `"datasource"` — handles `data: { datasource: "slug", query: "..." }`.
202    ///   NOT pre-registered. Consumers whose YAML uses the `datasource:`
203    ///   shape MUST register their own provider under this key (or under an
204    ///   explicit `provider: "..."` slug the spec also names).
205    ///
206    /// Re-registration replaces the provider for that kind; no merging.
207    pub fn register_provider(
208        &mut self,
209        kind: &str,
210        provider: impl resolver::DataSourceProvider + 'static,
211    ) {
212        self.resolver.register_provider(kind, Arc::new(provider));
213    }
214
215    /// Replace the tier-1 cache backend (default: `MemoryBackend`). The new
216    /// backend starts empty — entries in the old backend are not migrated.
217    /// Safe to call after `resolver()` handles have been handed out — the
218    /// swap is atomic on the shared resolver.
219    pub fn set_cache(&mut self, backend: impl resolver::CacheBackend + 'static) {
220        self.resolver
221            .set_primary_cache(resolver::SharedRef::new(backend));
222    }
223
224    /// Builder-style variant of `set_cache`. Takes `self` by value so it can
225    /// chain off `ChartML::new()` in a single expression.
226    pub fn with_cache(mut self, backend: impl resolver::CacheBackend + 'static) -> Self {
227        self.set_cache(backend);
228        self
229    }
230
231    /// Set the tier-2 (persistent) cache to a lazy factory. The factory is
232    /// invoked on the first `fetch()` call; until then the slot stays
233    /// deferred. Delegates to `Resolver::set_persistent_cache_factory`.
234    ///
235    /// This exists because on WASM `IndexedDbBackend::new()` is async — it
236    /// can't be ready at `ChartML` construction time.
237    #[cfg(not(target_arch = "wasm32"))]
238    pub fn set_persistent_cache_factory<F, Fut>(&mut self, factory: F)
239    where
240        F: FnOnce() -> Fut + Send + 'static,
241        Fut: std::future::Future<Output = Option<resolver::CacheBackendRef>> + Send + 'static,
242    {
243        self.resolver.set_persistent_cache_factory(factory);
244    }
245
246    /// Set the tier-2 (persistent) cache to a lazy factory (WASM variant).
247    /// See the native variant above for full docs.
248    #[cfg(target_arch = "wasm32")]
249    pub fn set_persistent_cache_factory<F, Fut>(&mut self, factory: F)
250    where
251        F: FnOnce() -> Fut + 'static,
252        Fut: std::future::Future<Output = Option<resolver::CacheBackendRef>> + 'static,
253    {
254        self.resolver.set_persistent_cache_factory(factory);
255    }
256
257    /// Enable IndexedDB-backed persistent caching. Convenience wrapper
258    /// that delegates to `Resolver::enable_indexeddb_cache`.
259    #[cfg(all(target_arch = "wasm32", feature = "wasm-indexeddb"))]
260    pub fn enable_indexeddb_cache(&self, db_name: &str, namespace: &str) {
261        self.resolver.enable_indexeddb_cache(db_name, namespace);
262    }
263
264    /// Set the tenant / workspace namespace threaded into every resolver
265    /// cache key. Multi-tenant deployments MUST set this so two tenants
266    /// sharing a slug name cannot collide in the cache.
267    pub fn set_namespace(&mut self, slug: impl Into<String>) {
268        self.namespace = Some(slug.into());
269    }
270
271    /// Builder-style variant of `set_namespace`.
272    pub fn with_namespace(mut self, slug: impl Into<String>) -> Self {
273        self.set_namespace(slug);
274        self
275    }
276
277    /// Get a clone of the `ResolverRef` handle (`Arc<Resolver>` on native,
278    /// `Rc<Resolver>` on WASM) so callers can drive the bulk `invalidate*`
279    /// API (or inspect registered provider kinds).
280    pub fn resolver(&self) -> resolver::ResolverRef {
281        self.resolver.clone()
282    }
283
284    /// Register a [`resolver::ResolverHooks`] impl. Replaces any previously
285    /// registered hooks. Pass `NullHooks` (or call `clear_hooks` on the
286    /// resolver handle) to disable observability.
287    ///
288    /// Hook callbacks are fire-and-forget on the current async runtime
289    /// (`tokio::spawn` on native, `wasm_bindgen_futures::spawn_local` on
290    /// WASM) so a slow telemetry sink can't stall the resolver. See
291    /// [`resolver::ResolverHooks`] for the safety contract (panic-free,
292    /// no resolver re-entry, no shared locks).
293    pub fn set_hooks(&self, hooks: impl resolver::ResolverHooks + 'static) {
294        #[cfg(not(target_arch = "wasm32"))]
295        let r: resolver::HooksRef = std::sync::Arc::new(hooks);
296        #[cfg(target_arch = "wasm32")]
297        let r: resolver::HooksRef = std::rc::Rc::new(hooks);
298        self.resolver.set_hooks(r);
299    }
300
301    /// Await graceful shutdown on every registered provider AND cache
302    /// backend. Called at SSR request end, browser tab close, or explicit
303    /// host-app lifecycle boundaries. Safe to call multiple times — every
304    /// provider's default `shutdown` is a no-op.
305    pub async fn shutdown(&self) {
306        self.resolver.shutdown().await;
307    }
308
309    // --- Rendering ---
310
311    /// Parse a YAML string and render the chart component(s).
312    /// Returns the ChartElement tree.
313    /// Uses default dimensions (800x400) unless the spec overrides them.
314    pub fn render_from_yaml(&self, yaml: &str) -> Result<ChartElement, ChartError> {
315        self.render_from_yaml_with_size(yaml, None, None)
316    }
317
318    /// Parse a YAML string and render with an explicit container size.
319    /// `container_width` overrides the default width (used when the spec doesn't specify one).
320    /// `container_height` overrides the default height.
321    pub fn render_from_yaml_with_size(
322        &self,
323        yaml: &str,
324        container_width: Option<f64>,
325        container_height: Option<f64>,
326    ) -> Result<ChartElement, ChartError> {
327        self.render_from_yaml_with_params(yaml, container_width, container_height, None)
328    }
329
330    /// Render with explicit param value overrides.
331    /// `param_overrides` are current interactive values that take priority over defaults.
332    pub fn render_from_yaml_with_params(
333        &self,
334        yaml: &str,
335        container_width: Option<f64>,
336        container_height: Option<f64>,
337        param_overrides: Option<&params::ParamValues>,
338    ) -> Result<ChartElement, ChartError> {
339        // Step 1: Collect ALL param values — defaults + overrides.
340        // Priority: overrides > persistent defaults > inline defaults
341        let mut all_params = self.param_values.clone();
342
343        // Extract inline (chart-level) param defaults from the raw YAML
344        let inline_defaults = params::extract_inline_param_defaults(yaml);
345        all_params.extend(inline_defaults);
346
347        // Apply overrides (interactive values from UI controls)
348        if let Some(overrides) = param_overrides {
349            all_params.extend(overrides.iter().map(|(k, v)| (k.clone(), v.clone())));
350        }
351
352        // Resolve parameter references in the YAML string
353        let resolved_yaml = if !all_params.is_empty() {
354            params::resolve_param_references(yaml, &all_params)
355        } else {
356            yaml.to_string()
357        };
358
359        let parsed = spec::parse(&resolved_yaml)?;
360
361        // Step 2: Collect document-local params and re-resolve if needed.
362        let mut local_params = self.param_values.clone();
363        let mut has_local_params = false;
364        if let ChartMLSpec::Array(ref components) = parsed {
365            for component in components {
366                if let Component::Params(params_spec) = component {
367                    let defaults = params::collect_param_defaults(&[params_spec]);
368                    local_params.extend(defaults);
369                    has_local_params = true;
370                }
371            }
372        }
373
374        // If we found local params, re-resolve and re-parse
375        let parsed = if has_local_params && local_params.len() > self.param_values.len() {
376            let re_resolved = params::resolve_param_references(yaml, &local_params);
377            spec::parse(&re_resolved)?
378        } else {
379            parsed
380        };
381
382        // Step 3: Collect sources (persistent + document-local).
383        let mut sources: HashMap<String, DataTable> = self.sources.clone();
384
385        if let ChartMLSpec::Array(ref components) = parsed {
386            for component in components {
387                if let Component::Source(source_spec) = component {
388                    if let Some(ref rows) = source_spec.rows {
389                        let json_rows = self.convert_json_rows(rows)?;
390                        let data = DataTable::from_rows(&json_rows)?;
391                        sources.insert(source_spec.name.clone(), data);
392                    }
393                }
394            }
395        }
396
397        // Collect all chart components
398        let chart_specs: Vec<&ChartSpec> = match &parsed {
399            ChartMLSpec::Single(component) => match component.as_ref() {
400                Component::Chart(chart) => vec![chart.as_ref()],
401                _ => vec![],
402            },
403            ChartMLSpec::Array(components) => {
404                components.iter()
405                    .filter_map(|c| match c {
406                        Component::Chart(chart) => Some(chart.as_ref()),
407                        _ => None,
408                    })
409                    .collect()
410            }
411        };
412
413        // If no charts, check for params components to render as UI controls
414        if chart_specs.is_empty() {
415            let params_specs: Vec<&spec::ParamsSpec> = match &parsed {
416                ChartMLSpec::Single(component) => match component.as_ref() {
417                    Component::Params(p) => vec![p],
418                    _ => vec![],
419                },
420                ChartMLSpec::Array(components) => {
421                    components.iter()
422                        .filter_map(|c| match c {
423                            Component::Params(p) => Some(p),
424                            _ => None,
425                        })
426                        .collect()
427                }
428            };
429
430            if !params_specs.is_empty() {
431                return Ok(self.render_params_ui(&params_specs));
432            }
433
434            return Err(ChartError::InvalidSpec("No chart or params component found".into()));
435        }
436
437        if chart_specs.len() == 1 {
438            self.render_chart_internal(chart_specs[0], container_width, container_height, &sources)
439        } else {
440            // Multiple charts — render each and wrap in a grid container
441            let mut children = Vec::new();
442            for spec in chart_specs {
443                match self.render_chart_internal(spec, container_width, container_height, &sources) {
444                    Ok(element) => children.push(element),
445                    Err(e) => {
446                        // Continue rendering other charts even if one fails
447                        children.push(ChartElement::Div {
448                            class: "chartml-error".to_string(),
449                            style: HashMap::new(),
450                            children: vec![ChartElement::Span {
451                                class: "".to_string(),
452                                style: HashMap::new(),
453                                content: format!("Chart error: {}", e),
454                            }],
455                        });
456                    }
457                }
458            }
459            Ok(ChartElement::Div {
460                class: "chartml-multi-chart".to_string(),
461                style: HashMap::from([
462                    ("display".to_string(), "grid".to_string()),
463                    ("grid-template-columns".to_string(), format!("repeat({}, 1fr)", children.len().min(4))),
464                    ("gap".to_string(), "16px".to_string()),
465                ]),
466                children,
467            })
468        }
469    }
470
471    /// Render a parsed ChartSpec into a ChartElement tree.
472    pub fn render_chart(&self, chart_spec: &ChartSpec) -> Result<ChartElement, ChartError> {
473        self.render_chart_with_size(chart_spec, None, None)
474    }
475
476    /// Render a parsed ChartSpec with explicit container dimensions.
477    /// Spec-level width/height take priority; container size is the fallback.
478    pub fn render_chart_with_size(
479        &self,
480        chart_spec: &ChartSpec,
481        container_width: Option<f64>,
482        container_height: Option<f64>,
483    ) -> Result<ChartElement, ChartError> {
484        let sources = HashMap::new();
485        self.render_chart_internal(chart_spec, container_width, container_height, &sources)
486    }
487
488    /// Internal render method that accepts named sources for resolution.
489    ///
490    /// On native targets, when a `TransformMiddleware` is registered the sync
491    /// path dispatches through it via `pollster::block_on`, so multi-source
492    /// `NamedMap` + SQL joins work identically to the async path. On WASM the
493    /// async middleware can't be polled synchronously — multi-source maps and
494    /// `sql` / `forecast` transforms surface a clear error pointing the caller
495    /// to `render_from_yaml_with_params_async`.
496    fn render_chart_internal(
497        &self,
498        chart_spec: &ChartSpec,
499        container_width: Option<f64>,
500        container_height: Option<f64>,
501        sources: &HashMap<String, DataTable>,
502    ) -> Result<ChartElement, ChartError> {
503        // Resolve every declared source into an ordered map. Inline / Named /
504        // single-entry NamedMap collapse to a 1-entry map; multi-entry NamedMap
505        // produces one entry per declared source.
506        let chart_sources = self.resolve_chart_data(chart_spec, sources)?;
507
508        // Apply transforms — preferring the registered middleware so the sync
509        // and async paths share semantics (DataFusion SQL, multi-source joins,
510        // etc.). Falls back to the built-in aggregate-only sync transform when
511        // no middleware is registered AND the spec only uses an aggregate.
512        let data = self.run_sync_transform_pipeline(chart_spec, &chart_sources)?;
513
514        let (element, _, _) =
515            self.build_and_render(chart_spec, &data, container_width, container_height)?;
516        Ok(element)
517    }
518
519    /// Run the transform stage on the sync render path, sharing dispatch logic
520    /// with the async path. When a `TransformMiddleware` is registered, the
521    /// async `transform` call is driven to completion with `pollster::block_on`
522    /// on native targets. WASM has no synchronous executor available, so the
523    /// sync path keeps the legacy aggregate-only fallback there and surfaces a
524    /// clear error if the spec needs middleware features (sql / forecast /
525    /// multi-source joins).
526    fn run_sync_transform_pipeline(
527        &self,
528        chart_spec: &ChartSpec,
529        chart_sources: &IndexMap<String, DataTable>,
530    ) -> Result<DataTable, ChartError> {
531        let Some(transform_spec) = chart_spec.transform.as_ref() else {
532            return single_source_or_err_no_transform(chart_sources);
533        };
534
535        if let Some(_middleware) = self.registry.get_transform() {
536            // Native: drive the async middleware to completion synchronously so
537            // multi-source NamedMap + SQL joins work on both sync and async
538            // entry points. WASM has no sync executor — surface a clear error
539            // so callers move to the async API instead of hanging.
540            #[cfg(not(target_arch = "wasm32"))]
541            {
542                let context = plugin::TransformContext::default();
543                let result = pollster::block_on(
544                    _middleware.transform(chart_sources, transform_spec, &context),
545                )?;
546                return Ok(result.data);
547            }
548            #[cfg(target_arch = "wasm32")]
549            {
550                return Err(ChartError::InvalidSpec(
551                    "Sync render cannot drive the registered TransformMiddleware on WASM. Call `render_from_yaml_with_params_async` instead.".into(),
552                ));
553            }
554        }
555
556        // No middleware registered — fall back to the built-in aggregate
557        // transform. Multi-source maps and sql / forecast transforms require
558        // middleware; surface a clear error for those.
559        if transform_spec.sql.is_some() || transform_spec.forecast.is_some() {
560            return Err(ChartError::InvalidSpec(format!(
561                "Spec uses `{}` transform but no TransformMiddleware is registered. Call `register_transform(DataFusionTransform)` (or another middleware) before rendering.",
562                describe_transform(transform_spec),
563            )));
564        }
565        let single = single_source_or_err(chart_sources, transform_spec)?;
566        let rows = single.to_rows();
567        let transformed_rows = transform::apply_transforms(rows, transform_spec)?;
568        DataTable::from_rows(&transformed_rows)
569    }
570
571    /// Resolve a single named-map entry: look up `name` in pre-registered
572    /// sources first; if the entry carried inline `rows`, materialize those.
573    /// Returns an error if neither path produces a table.
574    fn materialize_named_entry(
575        &self,
576        name: &str,
577        inline: &InlineData,
578        sources: &HashMap<String, DataTable>,
579    ) -> Result<DataTable, ChartError> {
580        if let Some(table) = sources.get(name) {
581            return Ok(table.clone());
582        }
583        if let Some(rows) = &inline.rows {
584            let json_rows = self.convert_json_rows(rows)?;
585            return DataTable::from_rows(&json_rows);
586        }
587        Err(ChartError::DataError(format!(
588            "Named data source '{}' is not pre-registered (call `register_source(\"{}\", ...)` before rendering) and the spec did not provide inline `rows`.",
589            name, name,
590        )))
591    }
592
593    /// Convert JSON value rows into typed Row objects.
594    fn convert_json_rows(&self, rows: &[serde_json::Value]) -> Result<Vec<Row>, ChartError> {
595        let mut result = Vec::with_capacity(rows.len());
596        for value in rows {
597            match value {
598                serde_json::Value::Object(map) => {
599                    let row: Row = map.iter()
600                        .map(|(k, v)| (k.clone(), v.clone()))
601                        .collect();
602                    result.push(row);
603                }
604                _ => return Err(ChartError::DataError(
605                    "Data rows must be objects".into()
606                )),
607            }
608        }
609        Ok(result)
610    }
611
612    /// Render params components as UI controls (Div/Span elements).
613    /// Matches the JS paramsUI.js visual output with proper CSS classes.
614    fn render_params_ui(&self, params_specs: &[&spec::ParamsSpec]) -> ChartElement {
615        let mut param_groups = Vec::new();
616
617        for params_spec in params_specs {
618            for param in &params_spec.params {
619                let control = self.render_param_control(param);
620                param_groups.push(ChartElement::Div {
621                    class: "chartml-param-group".to_string(),
622                    style: HashMap::new(),
623                    children: vec![control],
624                });
625            }
626        }
627
628        ChartElement::Div {
629            class: "chartml-params".to_string(),
630            style: HashMap::from([
631                ("display".to_string(), "flex".to_string()),
632                ("flex-wrap".to_string(), "wrap".to_string()),
633                ("gap".to_string(), "12px".to_string()),
634                ("padding".to_string(), "12px 0".to_string()),
635            ]),
636            children: param_groups,
637        }
638    }
639
640    /// Render a single parameter control based on its type.
641    fn render_param_control(&self, param: &spec::ParamDef) -> ChartElement {
642        let label = ChartElement::Span {
643            class: "chartml-param-label".to_string(),
644            style: HashMap::from([
645                ("font-size".to_string(), "12px".to_string()),
646                ("font-weight".to_string(), "600".to_string()),
647                ("color".to_string(), "#555".to_string()),
648                ("display".to_string(), "block".to_string()),
649                ("margin-bottom".to_string(), "4px".to_string()),
650            ]),
651            content: param.label.clone(),
652        };
653
654        let control = match param.param_type.as_str() {
655            "multiselect" => {
656                let _options_text = param.options.as_ref()
657                    .map(|opts| opts.join(", "))
658                    .unwrap_or_default();
659                let default_text = param.default.as_ref()
660                    .map(|d| match d {
661                        serde_json::Value::Array(arr) => arr.iter()
662                            .filter_map(|v| v.as_str())
663                            .collect::<Vec<_>>()
664                            .join(", "),
665                        _ => d.to_string(),
666                    })
667                    .unwrap_or_default();
668                ChartElement::Div {
669                    class: "chartml-param-control chartml-param-multiselect".to_string(),
670                    style: HashMap::from([
671                        ("background".to_string(), "#f5f5f5".to_string()),
672                        ("border".to_string(), "1px solid #ddd".to_string()),
673                        ("border-radius".to_string(), "4px".to_string()),
674                        ("padding".to_string(), "6px 10px".to_string()),
675                        ("font-size".to_string(), "13px".to_string()),
676                        ("color".to_string(), self.theme.text.clone()),
677                        ("min-width".to_string(), "140px".to_string()),
678                    ]),
679                    children: vec![ChartElement::Span {
680                        class: "".to_string(),
681                        style: HashMap::new(),
682                        content: default_text,
683                    }],
684                }
685            }
686            "select" => {
687                let default_text = param.default.as_ref()
688                    .and_then(|d| d.as_str())
689                    .unwrap_or("")
690                    .to_string();
691                ChartElement::Div {
692                    class: "chartml-param-control chartml-param-select".to_string(),
693                    style: HashMap::from([
694                        ("background".to_string(), "#f5f5f5".to_string()),
695                        ("border".to_string(), "1px solid #ddd".to_string()),
696                        ("border-radius".to_string(), "4px".to_string()),
697                        ("padding".to_string(), "6px 10px".to_string()),
698                        ("font-size".to_string(), "13px".to_string()),
699                        ("color".to_string(), self.theme.text.clone()),
700                        ("min-width".to_string(), "120px".to_string()),
701                    ]),
702                    children: vec![ChartElement::Span {
703                        class: "".to_string(),
704                        style: HashMap::new(),
705                        content: format!("{} ▾", default_text),
706                    }],
707                }
708            }
709            "daterange" => {
710                let default_text = param.default.as_ref()
711                    .map(|d| {
712                        let start = d.get("start").and_then(|v| v.as_str()).unwrap_or("");
713                        let end = d.get("end").and_then(|v| v.as_str()).unwrap_or("");
714                        format!("{} → {}", start, end)
715                    })
716                    .unwrap_or_default();
717                ChartElement::Div {
718                    class: "chartml-param-control chartml-param-daterange".to_string(),
719                    style: HashMap::from([
720                        ("background".to_string(), "#f5f5f5".to_string()),
721                        ("border".to_string(), "1px solid #ddd".to_string()),
722                        ("border-radius".to_string(), "4px".to_string()),
723                        ("padding".to_string(), "6px 10px".to_string()),
724                        ("font-size".to_string(), "13px".to_string()),
725                        ("color".to_string(), self.theme.text.clone()),
726                    ]),
727                    children: vec![ChartElement::Span {
728                        class: "".to_string(),
729                        style: HashMap::new(),
730                        content: default_text,
731                    }],
732                }
733            }
734            "number" => {
735                let default_text = param.default.as_ref()
736                    .map(|d| d.to_string())
737                    .unwrap_or_default();
738                ChartElement::Div {
739                    class: "chartml-param-control chartml-param-number".to_string(),
740                    style: HashMap::from([
741                        ("background".to_string(), "#f5f5f5".to_string()),
742                        ("border".to_string(), "1px solid #ddd".to_string()),
743                        ("border-radius".to_string(), "4px".to_string()),
744                        ("padding".to_string(), "6px 10px".to_string()),
745                        ("font-size".to_string(), "13px".to_string()),
746                        ("color".to_string(), self.theme.text.clone()),
747                        ("min-width".to_string(), "80px".to_string()),
748                    ]),
749                    children: vec![ChartElement::Span {
750                        class: "".to_string(),
751                        style: HashMap::new(),
752                        content: default_text,
753                    }],
754                }
755            }
756            _ => {
757                let default_text = param.default.as_ref()
758                    .map(|d| d.to_string())
759                    .unwrap_or_default();
760                ChartElement::Div {
761                    class: "chartml-param-control chartml-param-text".to_string(),
762                    style: HashMap::from([
763                        ("background".to_string(), "#f5f5f5".to_string()),
764                        ("border".to_string(), "1px solid #ddd".to_string()),
765                        ("border-radius".to_string(), "4px".to_string()),
766                        ("padding".to_string(), "6px 10px".to_string()),
767                        ("font-size".to_string(), "13px".to_string()),
768                        ("color".to_string(), self.theme.text.clone()),
769                    ]),
770                    children: vec![ChartElement::Span {
771                        class: "".to_string(),
772                        style: HashMap::new(),
773                        content: param.placeholder.clone().unwrap_or(default_text),
774                    }],
775                }
776            }
777        };
778
779        ChartElement::Div {
780            class: "chartml-param-item".to_string(),
781            style: HashMap::from([
782                ("display".to_string(), "flex".to_string()),
783                ("flex-direction".to_string(), "column".to_string()),
784            ]),
785            children: vec![label, control],
786        }
787    }
788
789    // --- Three-stage pipeline (chartml 5.0 phase 2) ---
790
791    /// Stage 1 of the chartml 5.0 pipeline: parse YAML, resolve params,
792    /// and produce a `FetchedChart` whose `sources` map contains every
793    /// named source the chart needs.
794    ///
795    /// Phase 3 dispatch order, per source:
796    /// 1. `DataRef::Named(n)` → look up in pre-registered `self.sources`.
797    ///    No provider call (this is the chartml-5 fast path: callers that
798    ///    already own the data and registered it via `register_source` skip
799    ///    the resolver entirely).
800    /// 2. `DataRef::NamedMap` entry whose key matches a pre-registered
801    ///    source → use the registered table. Resolver bypassed for that
802    ///    entry. Other entries route through the resolver in parallel via
803    ///    `try_join_all`.
804    /// 3. `DataRef::Inline(flat)` without transform → single resolver call,
805    ///    wrapped in a 1-entry map keyed `"source"`.
806    /// 4. `DataRef::Inline(flat)` with transform → normalized to
807    ///    `NamedMap { "source": flat }` first, then taken through the
808    ///    NamedMap path so transforms see a uniform `IndexMap` shape.
809    ///
810    /// `FetchMetadata.cache_hits` / `cache_misses` / `per_source` are
811    /// populated from each resolver call's `ResolveOutcome`.
812    pub async fn fetch(
813        &self,
814        yaml: &str,
815        opts: &RenderOptions,
816    ) -> Result<FetchedChart, ChartError> {
817        let (chart_spec, mut sources) =
818            self.parse_and_collect_sources(yaml, opts.params_ref())?;
819
820        // Apply the unnamed-with-transform normalization: any flat `data:`
821        // shape with a `transform:` block is rewritten internally to a
822        // 1-entry `NamedMap { "source": flat }` so the downstream code path
823        // is uniform. Don't mutate `chart_spec` — local rewrite only.
824        let normalized_data = normalize_data_ref(&chart_spec.data, chart_spec.transform.is_some());
825
826        let mut cache_hits: Vec<String> = Vec::new();
827        let mut cache_misses: Vec<String> = Vec::new();
828        let mut per_source: HashMap<String, HashMap<String, serde_json::Value>> = HashMap::new();
829        let mut batch_map: IndexMap<String, (SchemaRef, Vec<RecordBatch>)> = IndexMap::new();
830        let mut any_batch_source = false;
831
832        let chart_sources: IndexMap<String, DataTable> = match &normalized_data {
833            DataRef::Named(name) => {
834                let table = sources.remove(name).ok_or_else(|| {
835                    ChartError::DataError(format!("Named data source '{name}' not found"))
836                })?;
837                let batch = table.record_batch().clone();
838                let schema = batch.schema();
839                batch_map.insert(name.clone(), (schema, vec![batch]));
840                any_batch_source = true;
841                let mut map = IndexMap::new();
842                map.insert(name.clone(), table);
843                map
844            }
845            DataRef::Inline(inline) => {
846                let request = self.build_fetch_request(None, inline)?;
847                let key = resolver::Resolver::key_for(inline, self.namespace.as_deref());
848                let outcome = self
849                    .resolver
850                    .fetch(key, request)
851                    .await
852                    .map_err(|e| context_fetch_error(e, "source"))?;
853                classify_outcome(
854                    "source",
855                    &outcome,
856                    &mut cache_hits,
857                    &mut cache_misses,
858                );
859                if let Some(batch_result) = outcome.batches {
860                    batch_map.insert(
861                        "source".to_string(),
862                        (batch_result.schema, batch_result.batches),
863                    );
864                    any_batch_source = true;
865                }
866                if !outcome.result.metadata.is_empty() {
867                    per_source.insert("source".to_string(), outcome.result.metadata);
868                }
869                let mut map = IndexMap::new();
870                map.insert("source".to_string(), outcome.result.data);
871                map
872            }
873            DataRef::NamedMap(map) => {
874                let mut prefetched: IndexMap<String, DataTable> = IndexMap::new();
875                let mut to_dispatch: Vec<(String, InlineData)> = Vec::new();
876                for (name, inline) in map {
877                    if let Some(table) = sources.remove(name) {
878                        prefetched.insert(name.clone(), table);
879                    } else {
880                        to_dispatch.push((name.clone(), inline.clone()));
881                    }
882                }
883
884                let resolver = self.resolver.clone();
885                let namespace = self.namespace.clone();
886                let dispatch_futures = to_dispatch.into_iter().map(|(name, inline)| {
887                    let resolver = resolver.clone();
888                    let namespace = namespace.clone();
889                    async move {
890                        let request = build_fetch_request_static(
891                            Some(name.clone()),
892                            &inline,
893                            namespace.as_deref(),
894                        )?;
895                        let key = resolver::Resolver::key_for(&inline, namespace.as_deref());
896                        let outcome = resolver
897                            .fetch(key, request)
898                            .await
899                            .map_err(|e| context_fetch_error(e, &name))?;
900                        Ok::<(String, resolver::ResolveOutcome), ChartError>((name, outcome))
901                    }
902                });
903
904                let dispatched: Vec<(String, resolver::ResolveOutcome)> =
905                    futures::future::try_join_all(dispatch_futures).await?;
906
907                let mut dispatched_by_name: HashMap<String, resolver::ResolveOutcome> =
908                    dispatched.into_iter().collect();
909                let mut out: IndexMap<String, DataTable> = IndexMap::new();
910                for name in map.keys() {
911                    if let Some(table) = prefetched.shift_remove(name) {
912                        let batch = table.record_batch().clone();
913                        let schema = batch.schema();
914                        batch_map.insert(name.clone(), (schema, vec![batch]));
915                        any_batch_source = true;
916                        out.insert(name.clone(), table);
917                    } else if let Some(outcome) = dispatched_by_name.remove(name) {
918                        classify_outcome(
919                            name,
920                            &outcome,
921                            &mut cache_hits,
922                            &mut cache_misses,
923                        );
924                        if let Some(batch_result) = outcome.batches {
925                            batch_map.insert(
926                                name.clone(),
927                                (batch_result.schema, batch_result.batches),
928                            );
929                            any_batch_source = true;
930                        }
931                        if !outcome.result.metadata.is_empty() {
932                            per_source.insert(name.clone(), outcome.result.metadata);
933                        }
934                        out.insert(name.clone(), outcome.result.data);
935                    } else {
936                        return Err(ChartError::DataError(format!(
937                            "Internal invariant violation: source '{name}' was neither pre-registered nor dispatched"
938                        )));
939                    }
940                }
941                out
942            }
943        };
944
945        Ok(FetchedChart {
946            spec: chart_spec,
947            sources: chart_sources,
948            batch_sources: if any_batch_source { Some(batch_map) } else { None },
949            metadata: FetchMetadata {
950                refreshed_at: SystemTime::now(),
951                cache_hits,
952                cache_misses,
953                per_source,
954            },
955        })
956    }
957
958    /// Build a `FetchRequest` capturing the resolved spec, parsed cache
959    /// config, and current namespace. Headers default to empty — host apps
960    /// that need request-level headers thread them through their custom
961    /// provider implementation rather than `ChartML` itself (chartml-core
962    /// has no notion of "current request" outside the resolver).
963    ///
964    /// Returns `Err` when `spec.cache.ttl` is malformed; callers propagate
965    /// the error rather than silently fall back to `DEFAULT_TTL`.
966    fn build_fetch_request(
967        &self,
968        source_name: Option<String>,
969        spec: &InlineData,
970    ) -> Result<resolver::FetchRequest, ChartError> {
971        build_fetch_request_static(source_name, spec, self.namespace.as_deref())
972    }
973
974    /// Stage 2: collapse the fetched sources into a single `DataTable` ready
975    /// for the renderer. Runs the registered `TransformMiddleware` when a
976    /// `transform:` block is present, falls back to the built-in
977    /// aggregate-only transform when no middleware is registered, or
978    /// passes the lone source through unchanged when no transform is
979    /// declared.
980    ///
981    /// Validation rules (error text begins with the React/JS-matching wording,
982    /// then appends extra source-count context for debuggability):
983    /// - 0 sources → internal invariant violation (`fetch` always produces ≥1 entry).
984    /// - 1 source, no transform → passthrough.
985    /// - >1 sources, no transform → error beginning with `"Named data sources require a transform block when multiple sources are defined"` followed by `(got N sources: …)` detail.
986    /// - Otherwise → middleware (or built-in fallback for aggregate-only).
987    pub async fn transform(
988        &self,
989        fetched: FetchedChart,
990        _opts: &RenderOptions,
991    ) -> Result<PreparedChart, ChartError> {
992        // `_opts` is reserved — phase 3 will thread params through TransformContext.
993        let FetchedChart { spec, sources, batch_sources, metadata: _ } = fetched;
994
995        // Snapshot hooks once so the lock is never held across `await`.
996        let hooks = self.resolver.hooks_snapshot();
997        resolver::emit_progress(
998            &hooks,
999            resolver::Phase::Transform,
1000            &None,
1001            None,
1002            None,
1003            "Transforming chart".to_string(),
1004        );
1005
1006        if sources.is_empty() {
1007            // Internal invariant: phase 2 fetch always produces ≥1 entry.
1008            let err = ChartError::InvalidSpec(
1009                "Internal invariant violation: ChartML::fetch produced zero sources. \
1010                 Every spec must resolve to at least one named source before transform.".into(),
1011            );
1012            resolver::emit_error(
1013                &hooks,
1014                resolver::Phase::Transform,
1015                &None,
1016                err.to_string(),
1017            );
1018            return Err(err);
1019        }
1020
1021        let sources_used: Vec<String> = sources.keys().cloned().collect();
1022
1023        let result: Result<(DataTable, bool), ChartError> = match spec.transform.as_ref() {
1024            None => {
1025                // No transform → passthrough requires exactly one source;
1026                // multi-source maps without a transform have no defined
1027                // merge semantics. Error text begins with the React-matching
1028                // wording, then appends source-count context for debuggability.
1029                single_source_or_err_no_transform(&sources).map(|single| (single, false))
1030            }
1031            Some(transform_spec) => {
1032                if let Some(middleware) = self.registry.get_transform() {
1033                    let context = plugin::TransformContext::default();
1034                    if let Some(ref bs) = batch_sources {
1035                        middleware
1036                            .transform_batches(bs, transform_spec, &context)
1037                            .await
1038                            .map(|r| (r.data, true))
1039                    } else {
1040                        middleware
1041                            .transform(&sources, transform_spec, &context)
1042                            .await
1043                            .map(|r| (r.data, true))
1044                    }
1045                } else {
1046                    // No middleware — built-in fallback handles aggregate-only on a single table.
1047                    single_source_or_err(&sources, transform_spec).and_then(|single_ref| {
1048                        let rows = single_ref.to_rows();
1049                        let transformed_rows =
1050                            transform::apply_transforms(rows, transform_spec)?;
1051                        Ok((DataTable::from_rows(&transformed_rows)?, true))
1052                    })
1053                }
1054            }
1055        };
1056
1057        let (data, transform_applied) = match result {
1058            Ok(t) => t,
1059            Err(err) => {
1060                resolver::emit_error(
1061                    &hooks,
1062                    resolver::Phase::Transform,
1063                    &None,
1064                    err.to_string(),
1065                );
1066                return Err(err);
1067            }
1068        };
1069
1070        Ok(PreparedChart {
1071            spec,
1072            data,
1073            metadata: PreparedMetadata {
1074                refreshed_at: SystemTime::now(),
1075                transform_applied,
1076                sources_used,
1077            },
1078        })
1079    }
1080
1081    /// Stage 3: render an already-prepared chart to an SVG string. Sync and
1082    /// pure — no I/O, no async — so consumers can resize-render from the
1083    /// same `PreparedChart` repeatedly without re-fetching or re-transforming.
1084    pub fn render_prepared_to_svg(
1085        &self,
1086        prepared: &PreparedChart,
1087        opts: &RenderOptions,
1088    ) -> Result<String, ChartError> {
1089        let (element, svg_width, svg_height) = self.build_and_render(
1090            &prepared.spec,
1091            &prepared.data,
1092            opts.width,
1093            opts.height,
1094        )?;
1095        Ok(svg::element_to_svg(&element, svg_width, svg_height))
1096    }
1097
1098    /// Convenience: run the full async pipeline (fetch + transform +
1099    /// render_prepared_to_svg) in one call. Equivalent to chaining the
1100    /// three stages explicitly; use the explicit form when you need to
1101    /// cache the intermediate `FetchedChart` / `PreparedChart`.
1102    pub async fn render_to_svg_async(
1103        &self,
1104        yaml: &str,
1105        opts: &RenderOptions,
1106    ) -> Result<String, ChartError> {
1107        let fetched = self.fetch(yaml, opts).await?;
1108        let prepared = self.transform(fetched, opts).await?;
1109        self.render_prepared_to_svg(&prepared, opts)
1110    }
1111
1112    // --- Async rendering (for use with TransformMiddleware, e.g. DataFusion) ---
1113
1114    /// Async render with full parameter support — mirrors `render_from_yaml_with_params`
1115    /// but uses the registered TransformMiddleware for ALL transforms (sql, aggregate, forecast).
1116    /// Falls back to built-in sync transform only if no middleware is registered.
1117    ///
1118    /// Back-compat shim over the chartml 5.0 three-stage pipeline. Returns
1119    /// `ChartElement` (not `String`) so existing internal callers
1120    /// (`chartml-leptos`, `chartml-render`, npm wrappers) keep compiling
1121    /// unchanged. Will be deprecated in phase 7 once every caller has
1122    /// migrated to `render_to_svg_async`.
1123    pub async fn render_from_yaml_with_params_async(
1124        &self,
1125        yaml: &str,
1126        container_width: Option<f64>,
1127        container_height: Option<f64>,
1128        param_overrides: Option<&params::ParamValues>,
1129    ) -> Result<ChartElement, ChartError> {
1130        let opts = RenderOptions {
1131            width: container_width,
1132            height: container_height,
1133            params: param_overrides.cloned(),
1134        };
1135        let fetched = self.fetch(yaml, &opts).await?;
1136        let prepared = self.transform(fetched, &opts).await?;
1137        let (element, _, _) = self.build_and_render(
1138            &prepared.spec,
1139            &prepared.data,
1140            opts.width,
1141            opts.height,
1142        )?;
1143        Ok(element)
1144    }
1145
1146    /// Shared step 1+2 for `fetch` and the legacy async path: resolve params
1147    /// (including local `params:` blocks), parse the YAML, and collect every
1148    /// inline-source component into a working `HashMap`. Returns the FIRST
1149    /// chart spec found (matching the legacy single-chart contract; multi-
1150    /// chart specs continue to flow through the sync `render_from_yaml`
1151    /// path which already handles them).
1152    fn parse_and_collect_sources(
1153        &self,
1154        yaml: &str,
1155        param_overrides: Option<&params::ParamValues>,
1156    ) -> Result<(ChartSpec, HashMap<String, DataTable>), ChartError> {
1157        // Param resolution mirrors the sync path: defaults < inline defaults < overrides.
1158        let mut all_params = self.param_values.clone();
1159        let inline_defaults = params::extract_inline_param_defaults(yaml);
1160        all_params.extend(inline_defaults);
1161        if let Some(overrides) = param_overrides {
1162            all_params.extend(overrides.iter().map(|(k, v)| (k.clone(), v.clone())));
1163        }
1164        let resolved_yaml = if !all_params.is_empty() {
1165            params::resolve_param_references(yaml, &all_params)
1166        } else {
1167            yaml.to_string()
1168        };
1169
1170        let parsed = spec::parse(&resolved_yaml)?;
1171
1172        // Collect persistent + document-local inline sources.
1173        let mut sources: HashMap<String, DataTable> = self.sources.clone();
1174        if let ChartMLSpec::Array(ref components) = parsed {
1175            for component in components {
1176                if let Component::Source(source_spec) = component {
1177                    if let Some(ref rows) = source_spec.rows {
1178                        let json_rows = self.convert_json_rows(rows)?;
1179                        let data = DataTable::from_rows(&json_rows)?;
1180                        sources.insert(source_spec.name.clone(), data);
1181                    }
1182                }
1183            }
1184        }
1185
1186        // Extract the chart spec — first chart wins, matching the legacy
1187        // single-chart contract of `render_from_yaml_with_params_async`.
1188        // Cloning is cheap (ChartSpec is mostly small fields + a few Vec/Option).
1189        let chart_spec: ChartSpec = match &parsed {
1190            ChartMLSpec::Single(component) => match component.as_ref() {
1191                Component::Chart(chart) => chart.as_ref().clone(),
1192                _ => return Err(ChartError::InvalidSpec("No chart component found".into())),
1193            },
1194            ChartMLSpec::Array(components) => components
1195                .iter()
1196                .find_map(|c| match c {
1197                    Component::Chart(chart) => Some(chart.as_ref().clone()),
1198                    _ => None,
1199                })
1200                .ok_or_else(|| ChartError::InvalidSpec("No chart component found".into()))?,
1201        };
1202
1203        Ok((chart_spec, sources))
1204    }
1205
1206    /// Async render with external data — for integration tests and programmatic use.
1207    /// Data is used as fallback when spec has empty inline rows.
1208    pub async fn render_from_yaml_with_data_async(
1209        &self,
1210        yaml: &str,
1211        data: DataTable,
1212    ) -> Result<ChartElement, ChartError> {
1213        // Register data as "source", then delegate to full async render
1214        let parsed = spec::parse(yaml)?;
1215        let chart_spec: &ChartSpec = match &parsed {
1216            ChartMLSpec::Single(component) => match component.as_ref() {
1217                Component::Chart(chart) => chart.as_ref(),
1218                _ => return Err(ChartError::InvalidSpec("No chart component found".into())),
1219            },
1220            ChartMLSpec::Array(components) => {
1221                components.iter()
1222                    .find_map(|c| match c { Component::Chart(chart) => Some(chart.as_ref()), _ => None })
1223                    .ok_or_else(|| ChartError::InvalidSpec("No chart component found".into()))?
1224            }
1225        };
1226
1227        // Build the named-source map. Single-source shapes (Inline / Named)
1228        // produce a 1-entry map; NamedMap produces one entry per declared
1229        // source. Pre-registered sources fill in entries that don't carry
1230        // inline rows.
1231        let chart_sources: IndexMap<String, DataTable> = match &chart_spec.data {
1232            DataRef::Inline(inline) => {
1233                // `unwrap_or_default()` collapses "no `rows:` key" and "rows: []" to
1234                // the same empty `Vec<Row>` — the `is_empty()` check below then
1235                // falls through to the caller-supplied `data`, which is the
1236                // explicit contract of `render_from_yaml_with_data_async`.
1237                let inline_rows = inline.rows.as_ref()
1238                    .map(|r| self.convert_json_rows(r))
1239                    .transpose()?
1240                    .unwrap_or_default();
1241                let inline_table = DataTable::from_rows(&inline_rows)?;
1242                let chosen = if inline_table.is_empty() && !data.is_empty() {
1243                    data
1244                } else {
1245                    inline_table
1246                };
1247                let mut map = IndexMap::new();
1248                map.insert("source".to_string(), chosen);
1249                map
1250            }
1251            DataRef::Named(name) => {
1252                let table = self.sources.get(name).cloned().ok_or_else(|| {
1253                    ChartError::DataError(format!("Source '{}' not found", name))
1254                })?;
1255                let mut map = IndexMap::new();
1256                map.insert(name.clone(), table);
1257                map
1258            }
1259            DataRef::NamedMap(map) => {
1260                let mut out = IndexMap::new();
1261                for (name, inline) in map {
1262                    let table = self.materialize_named_entry(name, inline, &self.sources)?;
1263                    out.insert(name.clone(), table);
1264                }
1265                out
1266            }
1267        };
1268
1269        let transformed_data = if let Some(ref transform_spec) = chart_spec.transform {
1270            if let Some(middleware) = self.registry.get_transform() {
1271                let context = plugin::TransformContext::default();
1272                let result = middleware.transform(&chart_sources, transform_spec, &context).await?;
1273                result.data
1274            } else if transform_spec.sql.is_some() || transform_spec.forecast.is_some() {
1275                return Err(ChartError::InvalidSpec(
1276                    "Spec uses sql or forecast transforms but no TransformMiddleware is registered".into()
1277                ));
1278            } else {
1279                // Sync fallback: DataTable → Vec<Row> → apply_transforms → DataTable.
1280                // The sync path only handles a single table — multi-source maps
1281                // require a registered TransformMiddleware to join.
1282                let single = single_source_or_err(&chart_sources, transform_spec)?;
1283                let rows = single.to_rows();
1284                let transformed_rows = transform::apply_transforms(rows, transform_spec)?;
1285                DataTable::from_rows(&transformed_rows)?
1286            }
1287        } else {
1288            single_source_or_err_no_transform(&chart_sources)?
1289        };
1290
1291        let (element, _, _) =
1292            self.build_and_render(chart_spec, &transformed_data, None, None)?;
1293        Ok(element)
1294    }
1295
1296    /// Resolve a chart spec's `data:` reference into a map of named source
1297    /// tables. The map is `IndexMap`-typed so insertion order from the YAML is
1298    /// preserved when the spec uses a multi-source `data:` map.
1299    ///
1300    /// - `DataRef::Inline(flat)` → 1-entry map keyed `"source"` (the canonical
1301    ///   default name; transform middleware aliases this so legacy SQL keeps
1302    ///   working).
1303    /// - `DataRef::Named(name)` → 1-entry map keyed `name`, looked up in
1304    ///   pre-registered sources.
1305    /// - `DataRef::NamedMap(map)` → one entry per declared source. Each entry
1306    ///   is resolved via pre-registered sources first, falling back to inline
1307    ///   `rows` carried directly on the entry. All entries must resolve to a
1308    ///   table; missing sources produce a clear error message.
1309    fn resolve_chart_data(
1310        &self,
1311        chart_spec: &ChartSpec,
1312        sources: &HashMap<String, DataTable>,
1313    ) -> Result<IndexMap<String, DataTable>, ChartError> {
1314        let mut out = IndexMap::new();
1315        match &chart_spec.data {
1316            DataRef::Inline(inline) => {
1317                let json_rows = inline
1318                    .rows
1319                    .as_ref()
1320                    .map(|r| self.convert_json_rows(r))
1321                    .transpose()?
1322                    .unwrap_or_default();
1323                let table = DataTable::from_rows(&json_rows)?;
1324                out.insert("source".to_string(), table);
1325            }
1326            DataRef::Named(name) => {
1327                let table = sources.get(name).cloned().ok_or_else(|| {
1328                    ChartError::DataError(format!("Named data source '{}' not found", name))
1329                })?;
1330                out.insert(name.clone(), table);
1331            }
1332            DataRef::NamedMap(map) => {
1333                for (name, inline) in map {
1334                    let table = self.materialize_named_entry(name, inline, sources)?;
1335                    out.insert(name.clone(), table);
1336                }
1337            }
1338        }
1339        Ok(out)
1340    }
1341
1342    /// Build chart config and render — shared by sync and async paths.
1343    ///
1344    /// Returns `(element, width, height)` so callers that need the resolved
1345    /// SVG envelope (e.g. `render_prepared_to_svg`) can use the *same*
1346    /// dimensions that were baked into the layout. This avoids a dual
1347    /// source-of-truth — the renderer's `default_dimensions()` is consulted
1348    /// exactly once, here.
1349    fn build_and_render(
1350        &self,
1351        chart_spec: &ChartSpec,
1352        data: &DataTable,
1353        container_width: Option<f64>,
1354        container_height: Option<f64>,
1355    ) -> Result<(ChartElement, f64, f64), ChartError> {
1356        let chart_type = &chart_spec.visualize.chart_type;
1357        let renderer = self.registry.get_renderer(chart_type)
1358            .ok_or_else(|| ChartError::UnknownChartType(chart_type.clone()))?;
1359
1360        let default_height = renderer.default_dimensions(&chart_spec.visualize)
1361            .map(|d| d.height)
1362            .unwrap_or(400.0);
1363
1364        let height = chart_spec.visualize.style.as_ref()
1365            .and_then(|s| s.height)
1366            .unwrap_or(container_height.unwrap_or(default_height));
1367
1368        let width = chart_spec.visualize.style.as_ref()
1369            .and_then(|s| s.width)
1370            .unwrap_or(container_width.unwrap_or(800.0));
1371
1372        let colors = chart_spec.visualize.style.as_ref()
1373            .and_then(|s| s.colors.clone())
1374            .or_else(|| self.default_palette.clone())
1375            .unwrap_or_else(|| {
1376                color::get_chart_colors(12, color::palettes::get_palette("autumn_forest"))
1377            });
1378
1379        let config = plugin::ChartConfig {
1380            visualize: chart_spec.visualize.clone(),
1381            title: chart_spec.title.clone(),
1382            width,
1383            height,
1384            colors,
1385            theme: self.theme.clone(),
1386        };
1387
1388        let element = renderer.render(data, &config)?;
1389        Ok((element, width, height))
1390    }
1391
1392    /// Get a reference to the internal registry.
1393    pub fn registry(&self) -> &ChartMLRegistry {
1394        &self.registry
1395    }
1396
1397    /// Get a mutable reference to the internal registry.
1398    pub fn registry_mut(&mut self) -> &mut ChartMLRegistry {
1399        &mut self.registry
1400    }
1401}
1402
1403impl Default for ChartML {
1404    fn default() -> Self {
1405        Self::new()
1406    }
1407}
1408
1409/// Apply the design-doc's "normalize unnamed+transform → `{source: <original>}`"
1410/// rewrite at the gate. Only the `Inline + has_transform` case rewrites; every
1411/// other shape is passed through unchanged. Returns a borrowed-or-owned ref
1412/// without `Cow` because the rewrite path needs to construct a new
1413/// `IndexMap` anyway, so a clone is appropriate.
1414fn normalize_data_ref(data: &DataRef, has_transform: bool) -> DataRef {
1415    match (data, has_transform) {
1416        (DataRef::Inline(inline), true) => {
1417            let mut map = IndexMap::new();
1418            map.insert("source".to_string(), inline.clone());
1419            DataRef::NamedMap(map)
1420        }
1421        _ => data.clone(),
1422    }
1423}
1424
1425/// Free-function variant of `ChartML::build_fetch_request` so async closures
1426/// can construct requests without borrowing `&self` across `.await` points.
1427fn build_fetch_request_static(
1428    source_name: Option<String>,
1429    spec: &InlineData,
1430    namespace: Option<&str>,
1431) -> Result<resolver::FetchRequest, ChartError> {
1432    Ok(resolver::FetchRequest {
1433        source_name,
1434        spec: spec.clone(),
1435        cache: resolver::CacheConfig::from_spec(spec.cache.as_ref())?,
1436        headers: HashMap::new(),
1437        namespace: namespace.map(String::from),
1438        cancel_token: None,
1439    })
1440}
1441
1442fn classify_outcome(
1443    name: &str,
1444    outcome: &resolver::ResolveOutcome,
1445    cache_hits: &mut Vec<String>,
1446    cache_misses: &mut Vec<String>,
1447) {
1448    if outcome.cache_hit {
1449        cache_hits.push(name.to_string());
1450    } else {
1451        cache_misses.push(name.to_string());
1452    }
1453}
1454
1455/// Wrap a `FetchError` with the failing source name so the user-facing
1456/// `ChartError` identifies which source (out of N in a map) actually failed.
1457/// `try_join_all` only surfaces the FIRST error, so the per-source name in
1458/// the message is the only thing that distinguishes "visitors failed" from
1459/// "sessions failed" without hooks (phase 3c lands per-source ErrorEvents).
1460fn context_fetch_error(err: resolver::FetchError, source_name: &str) -> ChartError {
1461    let base: ChartError = err.into();
1462    ChartError::DataError(format!("source '{source_name}' fetch failed: {base}"))
1463}
1464
1465/// Helper: when no `TransformMiddleware` is registered, the sync fallback can
1466/// only operate on a single source table. Multi-source maps with a transform
1467/// require the user to register a middleware (e.g. `DataFusionTransform`) that
1468/// can join the sources.
1469fn single_source_or_err<'a>(
1470    sources: &'a IndexMap<String, DataTable>,
1471    transform_spec: &spec::TransformSpec,
1472) -> Result<&'a DataTable, ChartError> {
1473    if sources.len() == 1 {
1474        return Ok(sources
1475            .values()
1476            .next()
1477            .expect("sources has 1 entry"));
1478    }
1479    Err(ChartError::InvalidSpec(format!(
1480        "Multi-source `data:` map (got {} sources: {}) with transform `{}` requires a registered TransformMiddleware to join the sources. Call `register_transform(DataFusionTransform)` (or another middleware) before rendering.",
1481        sources.len(),
1482        sources.keys().cloned().collect::<Vec<_>>().join(", "),
1483        describe_transform(transform_spec),
1484    )))
1485}
1486
1487/// Helper: when no transform is declared, the renderer needs exactly one
1488/// source table. Multi-source maps without a transform have no defined merge
1489/// semantics — surface a clear error so the user adds a transform block.
1490fn single_source_or_err_no_transform(
1491    sources: &IndexMap<String, DataTable>,
1492) -> Result<DataTable, ChartError> {
1493    if sources.len() == 1 {
1494        return Ok(sources
1495            .values()
1496            .next()
1497            .expect("sources has 1 entry")
1498            .clone());
1499    }
1500    Err(ChartError::InvalidSpec(format!(
1501        "Named data sources require a transform block when multiple sources are defined (got {} sources: {}).",
1502        sources.len(),
1503        sources.keys().cloned().collect::<Vec<_>>().join(", "),
1504    )))
1505}
1506
1507fn describe_transform(spec: &spec::TransformSpec) -> &'static str {
1508    if spec.sql.is_some() {
1509        "sql"
1510    } else if spec.aggregate.is_some() {
1511        "aggregate"
1512    } else if spec.forecast.is_some() {
1513        "forecast"
1514    } else {
1515        "transform"
1516    }
1517}
1518
1519#[cfg(test)]
1520mod tests {
1521    #![allow(clippy::unwrap_used)]
1522    use super::*;
1523    use crate::element::ViewBox;
1524
1525    struct MockRenderer;
1526
1527    impl ChartRenderer for MockRenderer {
1528        fn render(&self, _data: &DataTable, _config: &ChartConfig) -> Result<ChartElement, ChartError> {
1529            Ok(ChartElement::Svg {
1530                viewbox: ViewBox::new(0.0, 0.0, 800.0, 400.0),
1531                width: Some(800.0),
1532                height: Some(400.0),
1533                class: "mock".to_string(),
1534                children: vec![],
1535            })
1536        }
1537    }
1538
1539    #[test]
1540    fn chartml_render_from_yaml_with_mock() {
1541        let mut chartml = ChartML::new();
1542        chartml.register_renderer("bar", MockRenderer);
1543
1544        let yaml = r#"
1545type: chart
1546version: 1
1547title: Test
1548data:
1549  provider: inline
1550  rows:
1551    - { x: "A", y: 10 }
1552    - { x: "B", y: 20 }
1553visualize:
1554  type: bar
1555  columns: x
1556  rows: y
1557"#;
1558
1559        let result = chartml.render_from_yaml(yaml);
1560        assert!(result.is_ok(), "render failed: {:?}", result.err());
1561    }
1562
1563    #[test]
1564    fn chartml_unknown_chart_type() {
1565        let chartml = ChartML::new();
1566        let yaml = r#"
1567type: chart
1568version: 1
1569data:
1570  provider: inline
1571  rows: []
1572visualize:
1573  type: unknown_type
1574  columns: x
1575  rows: y
1576"#;
1577        let result = chartml.render_from_yaml(yaml);
1578        assert!(result.is_err());
1579    }
1580
1581    #[test]
1582    fn chartml_named_source_resolution() {
1583        let mut chartml = ChartML::new();
1584        chartml.register_renderer("bar", MockRenderer);
1585
1586        let yaml = r#"---
1587type: source
1588version: 1
1589name: q1_sales
1590provider: inline
1591rows:
1592  - { month: "Jan", revenue: 100 }
1593  - { month: "Feb", revenue: 200 }
1594---
1595type: chart
1596version: 1
1597title: Revenue by Month
1598data: q1_sales
1599visualize:
1600  type: bar
1601  columns: month
1602  rows: revenue
1603"#;
1604
1605        let result = chartml.render_from_yaml(yaml);
1606        assert!(result.is_ok(), "named source render failed: {:?}", result.err());
1607    }
1608
1609    #[test]
1610    fn chartml_named_source_not_found() {
1611        let mut chartml = ChartML::new();
1612        chartml.register_renderer("bar", MockRenderer);
1613
1614        let yaml = r#"
1615type: chart
1616version: 1
1617data: nonexistent_source
1618visualize:
1619  type: bar
1620  columns: x
1621  rows: y
1622"#;
1623
1624        let result = chartml.render_from_yaml(yaml);
1625        assert!(result.is_err());
1626        let err = result.unwrap_err().to_string();
1627        assert!(err.contains("not found"), "Expected 'not found' error, got: {}", err);
1628    }
1629
1630    #[test]
1631    fn chartml_multi_chart_rendering() {
1632        let mut chartml = ChartML::new();
1633        chartml.register_renderer("bar", MockRenderer);
1634
1635        let yaml = r#"
1636- type: chart
1637  version: 1
1638  title: Chart A
1639  data:
1640    provider: inline
1641    rows:
1642      - { x: "A", y: 10 }
1643  visualize:
1644    type: bar
1645    columns: x
1646    rows: y
1647- type: chart
1648  version: 1
1649  title: Chart B
1650  data:
1651    provider: inline
1652    rows:
1653      - { x: "B", y: 20 }
1654  visualize:
1655    type: bar
1656    columns: x
1657    rows: y
1658"#;
1659
1660        let result = chartml.render_from_yaml(yaml);
1661        assert!(result.is_ok(), "multi-chart render failed: {:?}", result.err());
1662        match result.unwrap() {
1663            ChartElement::Div { class, children, .. } => {
1664                assert_eq!(class, "chartml-multi-chart");
1665                assert_eq!(children.len(), 2);
1666            }
1667            other => panic!("Expected Div wrapper, got {:?}", other),
1668        }
1669    }
1670
1671    #[test]
1672    fn chartml_named_source_with_transform() {
1673        let mut chartml = ChartML::new();
1674        chartml.register_renderer("bar", MockRenderer);
1675
1676        let yaml = r#"---
1677type: source
1678version: 1
1679name: raw_sales
1680provider: inline
1681rows:
1682  - { region: "North", revenue: 100 }
1683  - { region: "North", revenue: 200 }
1684  - { region: "South", revenue: 150 }
1685---
1686type: chart
1687version: 1
1688title: Revenue by Region
1689data: raw_sales
1690transform:
1691  aggregate:
1692    dimensions:
1693      - region
1694    measures:
1695      - column: revenue
1696        aggregation: sum
1697        name: total_revenue
1698    sort:
1699      - field: total_revenue
1700        direction: desc
1701visualize:
1702  type: bar
1703  columns: region
1704  rows: total_revenue
1705"#;
1706
1707        let result = chartml.render_from_yaml(yaml);
1708        assert!(result.is_ok(), "transform pipeline render failed: {:?}", result.err());
1709    }
1710
1711    #[test]
1712    fn chartml_multi_chart_with_shared_source() {
1713        let mut chartml = ChartML::new();
1714        chartml.register_renderer("bar", MockRenderer);
1715        chartml.register_renderer("metric", MockRenderer);
1716
1717        let yaml = r#"---
1718type: source
1719version: 1
1720name: kpis
1721provider: inline
1722rows:
1723  - { totalRevenue: 1500000, previousRevenue: 1200000 }
1724---
1725- type: chart
1726  version: 1
1727  title: Revenue
1728  data: kpis
1729  visualize:
1730    type: metric
1731    value: totalRevenue
1732- type: chart
1733  version: 1
1734  title: Prev Revenue
1735  data: kpis
1736  visualize:
1737    type: metric
1738    value: previousRevenue
1739"#;
1740
1741        let result = chartml.render_from_yaml(yaml);
1742        assert!(result.is_ok(), "multi-chart shared source failed: {:?}", result.err());
1743    }
1744}