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        let refreshed_at = if cache_misses.is_empty() && !cache_hits.is_empty() {
946            per_source
947                .values()
948                .filter_map(|meta| meta.get("fetched_at_ms"))
949                .filter_map(|v| v.as_f64())
950                .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
951                .and_then(|ms| {
952                    SystemTime::UNIX_EPOCH
953                        .checked_add(std::time::Duration::from_millis(ms as u64))
954                })
955                .unwrap_or_else(SystemTime::now)
956        } else {
957            SystemTime::now()
958        };
959
960        Ok(FetchedChart {
961            spec: chart_spec,
962            sources: chart_sources,
963            batch_sources: if any_batch_source { Some(batch_map) } else { None },
964            metadata: FetchMetadata {
965                refreshed_at,
966                cache_hits,
967                cache_misses,
968                per_source,
969            },
970        })
971    }
972
973    /// Build a `FetchRequest` capturing the resolved spec, parsed cache
974    /// config, and current namespace. Headers default to empty — host apps
975    /// that need request-level headers thread them through their custom
976    /// provider implementation rather than `ChartML` itself (chartml-core
977    /// has no notion of "current request" outside the resolver).
978    ///
979    /// Returns `Err` when `spec.cache.ttl` is malformed; callers propagate
980    /// the error rather than silently fall back to `DEFAULT_TTL`.
981    fn build_fetch_request(
982        &self,
983        source_name: Option<String>,
984        spec: &InlineData,
985    ) -> Result<resolver::FetchRequest, ChartError> {
986        build_fetch_request_static(source_name, spec, self.namespace.as_deref())
987    }
988
989    /// Stage 2: collapse the fetched sources into a single `DataTable` ready
990    /// for the renderer. Runs the registered `TransformMiddleware` when a
991    /// `transform:` block is present, falls back to the built-in
992    /// aggregate-only transform when no middleware is registered, or
993    /// passes the lone source through unchanged when no transform is
994    /// declared.
995    ///
996    /// Validation rules (error text begins with the React/JS-matching wording,
997    /// then appends extra source-count context for debuggability):
998    /// - 0 sources → internal invariant violation (`fetch` always produces ≥1 entry).
999    /// - 1 source, no transform → passthrough.
1000    /// - >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.
1001    /// - Otherwise → middleware (or built-in fallback for aggregate-only).
1002    pub async fn transform(
1003        &self,
1004        fetched: FetchedChart,
1005        _opts: &RenderOptions,
1006    ) -> Result<PreparedChart, ChartError> {
1007        // `_opts` is reserved — phase 3 will thread params through TransformContext.
1008        let FetchedChart { spec, sources, batch_sources, metadata: fetch_meta } = fetched;
1009
1010        // Snapshot hooks once so the lock is never held across `await`.
1011        let hooks = self.resolver.hooks_snapshot();
1012        resolver::emit_progress(
1013            &hooks,
1014            resolver::Phase::Transform,
1015            &None,
1016            None,
1017            None,
1018            "Transforming chart".to_string(),
1019        );
1020
1021        if sources.is_empty() {
1022            // Internal invariant: phase 2 fetch always produces ≥1 entry.
1023            let err = ChartError::InvalidSpec(
1024                "Internal invariant violation: ChartML::fetch produced zero sources. \
1025                 Every spec must resolve to at least one named source before transform.".into(),
1026            );
1027            resolver::emit_error(
1028                &hooks,
1029                resolver::Phase::Transform,
1030                &None,
1031                err.to_string(),
1032            );
1033            return Err(err);
1034        }
1035
1036        let sources_used: Vec<String> = sources.keys().cloned().collect();
1037
1038        let result: Result<(DataTable, bool), ChartError> = match spec.transform.as_ref() {
1039            None => {
1040                // No transform → passthrough requires exactly one source;
1041                // multi-source maps without a transform have no defined
1042                // merge semantics. Error text begins with the React-matching
1043                // wording, then appends source-count context for debuggability.
1044                single_source_or_err_no_transform(&sources).map(|single| (single, false))
1045            }
1046            Some(transform_spec) => {
1047                if let Some(middleware) = self.registry.get_transform() {
1048                    let context = plugin::TransformContext::default();
1049                    if let Some(ref bs) = batch_sources {
1050                        middleware
1051                            .transform_batches(bs, transform_spec, &context)
1052                            .await
1053                            .map(|r| (r.data, true))
1054                    } else {
1055                        middleware
1056                            .transform(&sources, transform_spec, &context)
1057                            .await
1058                            .map(|r| (r.data, true))
1059                    }
1060                } else {
1061                    // No middleware — built-in fallback handles aggregate-only on a single table.
1062                    single_source_or_err(&sources, transform_spec).and_then(|single_ref| {
1063                        let rows = single_ref.to_rows();
1064                        let transformed_rows =
1065                            transform::apply_transforms(rows, transform_spec)?;
1066                        Ok((DataTable::from_rows(&transformed_rows)?, true))
1067                    })
1068                }
1069            }
1070        };
1071
1072        let (data, transform_applied) = match result {
1073            Ok(t) => t,
1074            Err(err) => {
1075                resolver::emit_error(
1076                    &hooks,
1077                    resolver::Phase::Transform,
1078                    &None,
1079                    err.to_string(),
1080                );
1081                return Err(err);
1082            }
1083        };
1084
1085        Ok(PreparedChart {
1086            spec,
1087            data,
1088            metadata: PreparedMetadata {
1089                refreshed_at: fetch_meta.refreshed_at,
1090                transform_applied,
1091                sources_used,
1092            },
1093        })
1094    }
1095
1096    /// Stage 3: render an already-prepared chart to an SVG string. Sync and
1097    /// pure — no I/O, no async — so consumers can resize-render from the
1098    /// same `PreparedChart` repeatedly without re-fetching or re-transforming.
1099    pub fn render_prepared_to_svg(
1100        &self,
1101        prepared: &PreparedChart,
1102        opts: &RenderOptions,
1103    ) -> Result<String, ChartError> {
1104        let (element, svg_width, svg_height) = self.build_and_render(
1105            &prepared.spec,
1106            &prepared.data,
1107            opts.width,
1108            opts.height,
1109        )?;
1110        Ok(svg::element_to_svg(&element, svg_width, svg_height))
1111    }
1112
1113    /// Convenience: run the full async pipeline (fetch + transform +
1114    /// render_prepared_to_svg) in one call. Equivalent to chaining the
1115    /// three stages explicitly; use the explicit form when you need to
1116    /// cache the intermediate `FetchedChart` / `PreparedChart`.
1117    pub async fn render_to_svg_async(
1118        &self,
1119        yaml: &str,
1120        opts: &RenderOptions,
1121    ) -> Result<String, ChartError> {
1122        let fetched = self.fetch(yaml, opts).await?;
1123        let prepared = self.transform(fetched, opts).await?;
1124        self.render_prepared_to_svg(&prepared, opts)
1125    }
1126
1127    // --- Async rendering (for use with TransformMiddleware, e.g. DataFusion) ---
1128
1129    /// Async render with full parameter support — mirrors `render_from_yaml_with_params`
1130    /// but uses the registered TransformMiddleware for ALL transforms (sql, aggregate, forecast).
1131    /// Falls back to built-in sync transform only if no middleware is registered.
1132    ///
1133    /// Back-compat shim over the chartml 5.0 three-stage pipeline. Returns
1134    /// `ChartElement` (not `String`) so existing internal callers
1135    /// (`chartml-leptos`, `chartml-render`, npm wrappers) keep compiling
1136    /// unchanged. Will be deprecated in phase 7 once every caller has
1137    /// migrated to `render_to_svg_async`.
1138    pub async fn render_from_yaml_with_params_async(
1139        &self,
1140        yaml: &str,
1141        container_width: Option<f64>,
1142        container_height: Option<f64>,
1143        param_overrides: Option<&params::ParamValues>,
1144    ) -> Result<ChartElement, ChartError> {
1145        let opts = RenderOptions {
1146            width: container_width,
1147            height: container_height,
1148            params: param_overrides.cloned(),
1149        };
1150        let fetched = self.fetch(yaml, &opts).await?;
1151        let prepared = self.transform(fetched, &opts).await?;
1152        let (element, _, _) = self.build_and_render(
1153            &prepared.spec,
1154            &prepared.data,
1155            opts.width,
1156            opts.height,
1157        )?;
1158        Ok(element)
1159    }
1160
1161    /// Shared step 1+2 for `fetch` and the legacy async path: resolve params
1162    /// (including local `params:` blocks), parse the YAML, and collect every
1163    /// inline-source component into a working `HashMap`. Returns the FIRST
1164    /// chart spec found (matching the legacy single-chart contract; multi-
1165    /// chart specs continue to flow through the sync `render_from_yaml`
1166    /// path which already handles them).
1167    fn parse_and_collect_sources(
1168        &self,
1169        yaml: &str,
1170        param_overrides: Option<&params::ParamValues>,
1171    ) -> Result<(ChartSpec, HashMap<String, DataTable>), ChartError> {
1172        // Param resolution mirrors the sync path: defaults < inline defaults < overrides.
1173        let mut all_params = self.param_values.clone();
1174        let inline_defaults = params::extract_inline_param_defaults(yaml);
1175        all_params.extend(inline_defaults);
1176        if let Some(overrides) = param_overrides {
1177            all_params.extend(overrides.iter().map(|(k, v)| (k.clone(), v.clone())));
1178        }
1179        let resolved_yaml = if !all_params.is_empty() {
1180            params::resolve_param_references(yaml, &all_params)
1181        } else {
1182            yaml.to_string()
1183        };
1184
1185        let parsed = spec::parse(&resolved_yaml)?;
1186
1187        // Collect persistent + document-local inline sources.
1188        let mut sources: HashMap<String, DataTable> = self.sources.clone();
1189        if let ChartMLSpec::Array(ref components) = parsed {
1190            for component in components {
1191                if let Component::Source(source_spec) = component {
1192                    if let Some(ref rows) = source_spec.rows {
1193                        let json_rows = self.convert_json_rows(rows)?;
1194                        let data = DataTable::from_rows(&json_rows)?;
1195                        sources.insert(source_spec.name.clone(), data);
1196                    }
1197                }
1198            }
1199        }
1200
1201        // Extract the chart spec — first chart wins, matching the legacy
1202        // single-chart contract of `render_from_yaml_with_params_async`.
1203        // Cloning is cheap (ChartSpec is mostly small fields + a few Vec/Option).
1204        let chart_spec: ChartSpec = match &parsed {
1205            ChartMLSpec::Single(component) => match component.as_ref() {
1206                Component::Chart(chart) => chart.as_ref().clone(),
1207                _ => return Err(ChartError::InvalidSpec("No chart component found".into())),
1208            },
1209            ChartMLSpec::Array(components) => components
1210                .iter()
1211                .find_map(|c| match c {
1212                    Component::Chart(chart) => Some(chart.as_ref().clone()),
1213                    _ => None,
1214                })
1215                .ok_or_else(|| ChartError::InvalidSpec("No chart component found".into()))?,
1216        };
1217
1218        Ok((chart_spec, sources))
1219    }
1220
1221    /// Async render with external data — for integration tests and programmatic use.
1222    /// Data is used as fallback when spec has empty inline rows.
1223    pub async fn render_from_yaml_with_data_async(
1224        &self,
1225        yaml: &str,
1226        data: DataTable,
1227    ) -> Result<ChartElement, ChartError> {
1228        // Register data as "source", then delegate to full async render
1229        let parsed = spec::parse(yaml)?;
1230        let chart_spec: &ChartSpec = match &parsed {
1231            ChartMLSpec::Single(component) => match component.as_ref() {
1232                Component::Chart(chart) => chart.as_ref(),
1233                _ => return Err(ChartError::InvalidSpec("No chart component found".into())),
1234            },
1235            ChartMLSpec::Array(components) => {
1236                components.iter()
1237                    .find_map(|c| match c { Component::Chart(chart) => Some(chart.as_ref()), _ => None })
1238                    .ok_or_else(|| ChartError::InvalidSpec("No chart component found".into()))?
1239            }
1240        };
1241
1242        // Build the named-source map. Single-source shapes (Inline / Named)
1243        // produce a 1-entry map; NamedMap produces one entry per declared
1244        // source. Pre-registered sources fill in entries that don't carry
1245        // inline rows.
1246        let chart_sources: IndexMap<String, DataTable> = match &chart_spec.data {
1247            DataRef::Inline(inline) => {
1248                // `unwrap_or_default()` collapses "no `rows:` key" and "rows: []" to
1249                // the same empty `Vec<Row>` — the `is_empty()` check below then
1250                // falls through to the caller-supplied `data`, which is the
1251                // explicit contract of `render_from_yaml_with_data_async`.
1252                let inline_rows = inline.rows.as_ref()
1253                    .map(|r| self.convert_json_rows(r))
1254                    .transpose()?
1255                    .unwrap_or_default();
1256                let inline_table = DataTable::from_rows(&inline_rows)?;
1257                let chosen = if inline_table.is_empty() && !data.is_empty() {
1258                    data
1259                } else {
1260                    inline_table
1261                };
1262                let mut map = IndexMap::new();
1263                map.insert("source".to_string(), chosen);
1264                map
1265            }
1266            DataRef::Named(name) => {
1267                let table = self.sources.get(name).cloned().ok_or_else(|| {
1268                    ChartError::DataError(format!("Source '{}' not found", name))
1269                })?;
1270                let mut map = IndexMap::new();
1271                map.insert(name.clone(), table);
1272                map
1273            }
1274            DataRef::NamedMap(map) => {
1275                let mut out = IndexMap::new();
1276                for (name, inline) in map {
1277                    let table = self.materialize_named_entry(name, inline, &self.sources)?;
1278                    out.insert(name.clone(), table);
1279                }
1280                out
1281            }
1282        };
1283
1284        let transformed_data = if let Some(ref transform_spec) = chart_spec.transform {
1285            if let Some(middleware) = self.registry.get_transform() {
1286                let context = plugin::TransformContext::default();
1287                let result = middleware.transform(&chart_sources, transform_spec, &context).await?;
1288                result.data
1289            } else if transform_spec.sql.is_some() || transform_spec.forecast.is_some() {
1290                return Err(ChartError::InvalidSpec(
1291                    "Spec uses sql or forecast transforms but no TransformMiddleware is registered".into()
1292                ));
1293            } else {
1294                // Sync fallback: DataTable → Vec<Row> → apply_transforms → DataTable.
1295                // The sync path only handles a single table — multi-source maps
1296                // require a registered TransformMiddleware to join.
1297                let single = single_source_or_err(&chart_sources, transform_spec)?;
1298                let rows = single.to_rows();
1299                let transformed_rows = transform::apply_transforms(rows, transform_spec)?;
1300                DataTable::from_rows(&transformed_rows)?
1301            }
1302        } else {
1303            single_source_or_err_no_transform(&chart_sources)?
1304        };
1305
1306        let (element, _, _) =
1307            self.build_and_render(chart_spec, &transformed_data, None, None)?;
1308        Ok(element)
1309    }
1310
1311    /// Resolve a chart spec's `data:` reference into a map of named source
1312    /// tables. The map is `IndexMap`-typed so insertion order from the YAML is
1313    /// preserved when the spec uses a multi-source `data:` map.
1314    ///
1315    /// - `DataRef::Inline(flat)` → 1-entry map keyed `"source"` (the canonical
1316    ///   default name; transform middleware aliases this so legacy SQL keeps
1317    ///   working).
1318    /// - `DataRef::Named(name)` → 1-entry map keyed `name`, looked up in
1319    ///   pre-registered sources.
1320    /// - `DataRef::NamedMap(map)` → one entry per declared source. Each entry
1321    ///   is resolved via pre-registered sources first, falling back to inline
1322    ///   `rows` carried directly on the entry. All entries must resolve to a
1323    ///   table; missing sources produce a clear error message.
1324    fn resolve_chart_data(
1325        &self,
1326        chart_spec: &ChartSpec,
1327        sources: &HashMap<String, DataTable>,
1328    ) -> Result<IndexMap<String, DataTable>, ChartError> {
1329        let mut out = IndexMap::new();
1330        match &chart_spec.data {
1331            DataRef::Inline(inline) => {
1332                let json_rows = inline
1333                    .rows
1334                    .as_ref()
1335                    .map(|r| self.convert_json_rows(r))
1336                    .transpose()?
1337                    .unwrap_or_default();
1338                let table = DataTable::from_rows(&json_rows)?;
1339                out.insert("source".to_string(), table);
1340            }
1341            DataRef::Named(name) => {
1342                let table = sources.get(name).cloned().ok_or_else(|| {
1343                    ChartError::DataError(format!("Named data source '{}' not found", name))
1344                })?;
1345                out.insert(name.clone(), table);
1346            }
1347            DataRef::NamedMap(map) => {
1348                for (name, inline) in map {
1349                    let table = self.materialize_named_entry(name, inline, sources)?;
1350                    out.insert(name.clone(), table);
1351                }
1352            }
1353        }
1354        Ok(out)
1355    }
1356
1357    /// Build chart config and render — shared by sync and async paths.
1358    ///
1359    /// Returns `(element, width, height)` so callers that need the resolved
1360    /// SVG envelope (e.g. `render_prepared_to_svg`) can use the *same*
1361    /// dimensions that were baked into the layout. This avoids a dual
1362    /// source-of-truth — the renderer's `default_dimensions()` is consulted
1363    /// exactly once, here.
1364    fn build_and_render(
1365        &self,
1366        chart_spec: &ChartSpec,
1367        data: &DataTable,
1368        container_width: Option<f64>,
1369        container_height: Option<f64>,
1370    ) -> Result<(ChartElement, f64, f64), ChartError> {
1371        let chart_type = &chart_spec.visualize.chart_type;
1372        let renderer = self.registry.get_renderer(chart_type)
1373            .ok_or_else(|| ChartError::UnknownChartType(chart_type.clone()))?;
1374
1375        let default_height = renderer.default_dimensions(&chart_spec.visualize)
1376            .map(|d| d.height)
1377            .unwrap_or(400.0);
1378
1379        let height = chart_spec.visualize.style.as_ref()
1380            .and_then(|s| s.height)
1381            .unwrap_or(container_height.unwrap_or(default_height));
1382
1383        let width = chart_spec.visualize.style.as_ref()
1384            .and_then(|s| s.width)
1385            .unwrap_or(container_width.unwrap_or(800.0));
1386
1387        let colors = chart_spec.visualize.style.as_ref()
1388            .and_then(|s| s.colors.clone())
1389            .or_else(|| self.default_palette.clone())
1390            .unwrap_or_else(|| {
1391                color::get_chart_colors(12, color::palettes::get_palette("autumn_forest"))
1392            });
1393
1394        let config = plugin::ChartConfig {
1395            visualize: chart_spec.visualize.clone(),
1396            title: chart_spec.title.clone(),
1397            width,
1398            height,
1399            colors,
1400            theme: self.theme.clone(),
1401        };
1402
1403        let element = renderer.render(data, &config)?;
1404        Ok((element, width, height))
1405    }
1406
1407    /// Get a reference to the internal registry.
1408    pub fn registry(&self) -> &ChartMLRegistry {
1409        &self.registry
1410    }
1411
1412    /// Get a mutable reference to the internal registry.
1413    pub fn registry_mut(&mut self) -> &mut ChartMLRegistry {
1414        &mut self.registry
1415    }
1416}
1417
1418impl Default for ChartML {
1419    fn default() -> Self {
1420        Self::new()
1421    }
1422}
1423
1424/// Apply the design-doc's "normalize unnamed+transform → `{source: <original>}`"
1425/// rewrite at the gate. Only the `Inline + has_transform` case rewrites; every
1426/// other shape is passed through unchanged. Returns a borrowed-or-owned ref
1427/// without `Cow` because the rewrite path needs to construct a new
1428/// `IndexMap` anyway, so a clone is appropriate.
1429fn normalize_data_ref(data: &DataRef, has_transform: bool) -> DataRef {
1430    match (data, has_transform) {
1431        (DataRef::Inline(inline), true) => {
1432            let mut map = IndexMap::new();
1433            map.insert("source".to_string(), inline.clone());
1434            DataRef::NamedMap(map)
1435        }
1436        _ => data.clone(),
1437    }
1438}
1439
1440/// Free-function variant of `ChartML::build_fetch_request` so async closures
1441/// can construct requests without borrowing `&self` across `.await` points.
1442fn build_fetch_request_static(
1443    source_name: Option<String>,
1444    spec: &InlineData,
1445    namespace: Option<&str>,
1446) -> Result<resolver::FetchRequest, ChartError> {
1447    Ok(resolver::FetchRequest {
1448        source_name,
1449        spec: spec.clone(),
1450        cache: resolver::CacheConfig::from_spec(spec.cache.as_ref())?,
1451        headers: HashMap::new(),
1452        namespace: namespace.map(String::from),
1453        cancel_token: None,
1454    })
1455}
1456
1457fn classify_outcome(
1458    name: &str,
1459    outcome: &resolver::ResolveOutcome,
1460    cache_hits: &mut Vec<String>,
1461    cache_misses: &mut Vec<String>,
1462) {
1463    if outcome.cache_hit {
1464        cache_hits.push(name.to_string());
1465    } else {
1466        cache_misses.push(name.to_string());
1467    }
1468}
1469
1470/// Wrap a `FetchError` with the failing source name so the user-facing
1471/// `ChartError` identifies which source (out of N in a map) actually failed.
1472/// `try_join_all` only surfaces the FIRST error, so the per-source name in
1473/// the message is the only thing that distinguishes "visitors failed" from
1474/// "sessions failed" without hooks (phase 3c lands per-source ErrorEvents).
1475fn context_fetch_error(err: resolver::FetchError, source_name: &str) -> ChartError {
1476    let base: ChartError = err.into();
1477    ChartError::DataError(format!("source '{source_name}' fetch failed: {base}"))
1478}
1479
1480/// Helper: when no `TransformMiddleware` is registered, the sync fallback can
1481/// only operate on a single source table. Multi-source maps with a transform
1482/// require the user to register a middleware (e.g. `DataFusionTransform`) that
1483/// can join the sources.
1484fn single_source_or_err<'a>(
1485    sources: &'a IndexMap<String, DataTable>,
1486    transform_spec: &spec::TransformSpec,
1487) -> Result<&'a DataTable, ChartError> {
1488    if sources.len() == 1 {
1489        return Ok(sources
1490            .values()
1491            .next()
1492            .expect("sources has 1 entry"));
1493    }
1494    Err(ChartError::InvalidSpec(format!(
1495        "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.",
1496        sources.len(),
1497        sources.keys().cloned().collect::<Vec<_>>().join(", "),
1498        describe_transform(transform_spec),
1499    )))
1500}
1501
1502/// Helper: when no transform is declared, the renderer needs exactly one
1503/// source table. Multi-source maps without a transform have no defined merge
1504/// semantics — surface a clear error so the user adds a transform block.
1505fn single_source_or_err_no_transform(
1506    sources: &IndexMap<String, DataTable>,
1507) -> Result<DataTable, ChartError> {
1508    if sources.len() == 1 {
1509        return Ok(sources
1510            .values()
1511            .next()
1512            .expect("sources has 1 entry")
1513            .clone());
1514    }
1515    Err(ChartError::InvalidSpec(format!(
1516        "Named data sources require a transform block when multiple sources are defined (got {} sources: {}).",
1517        sources.len(),
1518        sources.keys().cloned().collect::<Vec<_>>().join(", "),
1519    )))
1520}
1521
1522fn describe_transform(spec: &spec::TransformSpec) -> &'static str {
1523    if spec.sql.is_some() {
1524        "sql"
1525    } else if spec.aggregate.is_some() {
1526        "aggregate"
1527    } else if spec.forecast.is_some() {
1528        "forecast"
1529    } else {
1530        "transform"
1531    }
1532}
1533
1534#[cfg(test)]
1535mod tests {
1536    #![allow(clippy::unwrap_used)]
1537    use super::*;
1538    use crate::element::ViewBox;
1539
1540    struct MockRenderer;
1541
1542    impl ChartRenderer for MockRenderer {
1543        fn render(&self, _data: &DataTable, _config: &ChartConfig) -> Result<ChartElement, ChartError> {
1544            Ok(ChartElement::Svg {
1545                viewbox: ViewBox::new(0.0, 0.0, 800.0, 400.0),
1546                width: Some(800.0),
1547                height: Some(400.0),
1548                class: "mock".to_string(),
1549                children: vec![],
1550            })
1551        }
1552    }
1553
1554    #[test]
1555    fn chartml_render_from_yaml_with_mock() {
1556        let mut chartml = ChartML::new();
1557        chartml.register_renderer("bar", MockRenderer);
1558
1559        let yaml = r#"
1560type: chart
1561version: 1
1562title: Test
1563data:
1564  provider: inline
1565  rows:
1566    - { x: "A", y: 10 }
1567    - { x: "B", y: 20 }
1568visualize:
1569  type: bar
1570  columns: x
1571  rows: y
1572"#;
1573
1574        let result = chartml.render_from_yaml(yaml);
1575        assert!(result.is_ok(), "render failed: {:?}", result.err());
1576    }
1577
1578    #[test]
1579    fn chartml_unknown_chart_type() {
1580        let chartml = ChartML::new();
1581        let yaml = r#"
1582type: chart
1583version: 1
1584data:
1585  provider: inline
1586  rows: []
1587visualize:
1588  type: unknown_type
1589  columns: x
1590  rows: y
1591"#;
1592        let result = chartml.render_from_yaml(yaml);
1593        assert!(result.is_err());
1594    }
1595
1596    #[test]
1597    fn chartml_named_source_resolution() {
1598        let mut chartml = ChartML::new();
1599        chartml.register_renderer("bar", MockRenderer);
1600
1601        let yaml = r#"---
1602type: source
1603version: 1
1604name: q1_sales
1605provider: inline
1606rows:
1607  - { month: "Jan", revenue: 100 }
1608  - { month: "Feb", revenue: 200 }
1609---
1610type: chart
1611version: 1
1612title: Revenue by Month
1613data: q1_sales
1614visualize:
1615  type: bar
1616  columns: month
1617  rows: revenue
1618"#;
1619
1620        let result = chartml.render_from_yaml(yaml);
1621        assert!(result.is_ok(), "named source render failed: {:?}", result.err());
1622    }
1623
1624    #[test]
1625    fn chartml_named_source_not_found() {
1626        let mut chartml = ChartML::new();
1627        chartml.register_renderer("bar", MockRenderer);
1628
1629        let yaml = r#"
1630type: chart
1631version: 1
1632data: nonexistent_source
1633visualize:
1634  type: bar
1635  columns: x
1636  rows: y
1637"#;
1638
1639        let result = chartml.render_from_yaml(yaml);
1640        assert!(result.is_err());
1641        let err = result.unwrap_err().to_string();
1642        assert!(err.contains("not found"), "Expected 'not found' error, got: {}", err);
1643    }
1644
1645    #[test]
1646    fn chartml_multi_chart_rendering() {
1647        let mut chartml = ChartML::new();
1648        chartml.register_renderer("bar", MockRenderer);
1649
1650        let yaml = r#"
1651- type: chart
1652  version: 1
1653  title: Chart A
1654  data:
1655    provider: inline
1656    rows:
1657      - { x: "A", y: 10 }
1658  visualize:
1659    type: bar
1660    columns: x
1661    rows: y
1662- type: chart
1663  version: 1
1664  title: Chart B
1665  data:
1666    provider: inline
1667    rows:
1668      - { x: "B", y: 20 }
1669  visualize:
1670    type: bar
1671    columns: x
1672    rows: y
1673"#;
1674
1675        let result = chartml.render_from_yaml(yaml);
1676        assert!(result.is_ok(), "multi-chart render failed: {:?}", result.err());
1677        match result.unwrap() {
1678            ChartElement::Div { class, children, .. } => {
1679                assert_eq!(class, "chartml-multi-chart");
1680                assert_eq!(children.len(), 2);
1681            }
1682            other => panic!("Expected Div wrapper, got {:?}", other),
1683        }
1684    }
1685
1686    #[test]
1687    fn chartml_named_source_with_transform() {
1688        let mut chartml = ChartML::new();
1689        chartml.register_renderer("bar", MockRenderer);
1690
1691        let yaml = r#"---
1692type: source
1693version: 1
1694name: raw_sales
1695provider: inline
1696rows:
1697  - { region: "North", revenue: 100 }
1698  - { region: "North", revenue: 200 }
1699  - { region: "South", revenue: 150 }
1700---
1701type: chart
1702version: 1
1703title: Revenue by Region
1704data: raw_sales
1705transform:
1706  aggregate:
1707    dimensions:
1708      - region
1709    measures:
1710      - column: revenue
1711        aggregation: sum
1712        name: total_revenue
1713    sort:
1714      - field: total_revenue
1715        direction: desc
1716visualize:
1717  type: bar
1718  columns: region
1719  rows: total_revenue
1720"#;
1721
1722        let result = chartml.render_from_yaml(yaml);
1723        assert!(result.is_ok(), "transform pipeline render failed: {:?}", result.err());
1724    }
1725
1726    #[test]
1727    fn chartml_multi_chart_with_shared_source() {
1728        let mut chartml = ChartML::new();
1729        chartml.register_renderer("bar", MockRenderer);
1730        chartml.register_renderer("metric", MockRenderer);
1731
1732        let yaml = r#"---
1733type: source
1734version: 1
1735name: kpis
1736provider: inline
1737rows:
1738  - { totalRevenue: 1500000, previousRevenue: 1200000 }
1739---
1740- type: chart
1741  version: 1
1742  title: Revenue
1743  data: kpis
1744  visualize:
1745    type: metric
1746    value: totalRevenue
1747- type: chart
1748  version: 1
1749  title: Prev Revenue
1750  data: kpis
1751  visualize:
1752    type: metric
1753    value: previousRevenue
1754"#;
1755
1756        let result = chartml.render_from_yaml(yaml);
1757        assert!(result.is_ok(), "multi-chart shared source failed: {:?}", result.err());
1758    }
1759}