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