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;
36use 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
46pub struct ChartML {
50 registry: ChartMLRegistry,
51 sources: HashMap<String, DataTable>,
57 param_values: params::ParamValues,
59 default_palette: Option<Vec<String>>,
62 theme: theme::Theme,
65 resolver: resolver::ResolverRef,
71 namespace: Option<String>,
75}
76
77impl ChartML {
78 pub fn new() -> Self {
83 let resolver = resolver::ResolverRef::new(resolver::Resolver::new());
84 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 pub fn with_defaults() -> Self {
102 Self::new()
103 }
104
105 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 pub fn set_default_palette(&mut self, colors: Vec<String>) {
126 self.default_palette = Some(colors);
127 }
128
129 pub fn set_theme(&mut self, theme: theme::Theme) {
133 self.theme = theme;
134 }
135
136 pub fn theme(&self) -> &theme::Theme {
139 &self.theme
140 }
141
142 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(&[¶ms_spec]);
172 self.param_values.extend(defaults);
173 Ok(())
174 }
175 spec::Component::Style(_) | spec::Component::Config(_) => {
176 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 pub fn register_source(&mut self, name: &str, data: DataTable) {
189 self.sources.insert(name.to_string(), data);
190 }
191
192 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 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 pub fn with_cache(mut self, backend: impl resolver::CacheBackend + 'static) -> Self {
227 self.set_cache(backend);
228 self
229 }
230
231 #[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 #[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 #[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 pub fn set_namespace(&mut self, slug: impl Into<String>) {
268 self.namespace = Some(slug.into());
269 }
270
271 pub fn with_namespace(mut self, slug: impl Into<String>) -> Self {
273 self.set_namespace(slug);
274 self
275 }
276
277 pub fn resolver(&self) -> resolver::ResolverRef {
281 self.resolver.clone()
282 }
283
284 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 pub async fn shutdown(&self) {
306 self.resolver.shutdown().await;
307 }
308
309 pub fn render_from_yaml(&self, yaml: &str) -> Result<ChartElement, ChartError> {
315 self.render_from_yaml_with_size(yaml, None, None)
316 }
317
318 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 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<¶ms::ParamValues>,
338 ) -> Result<ChartElement, ChartError> {
339 let mut all_params = self.param_values.clone();
342
343 let inline_defaults = params::extract_inline_param_defaults(yaml);
345 all_params.extend(inline_defaults);
346
347 if let Some(overrides) = param_overrides {
349 all_params.extend(overrides.iter().map(|(k, v)| (k.clone(), v.clone())));
350 }
351
352 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 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 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 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 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 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(¶ms_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 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 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 pub fn render_chart(&self, chart_spec: &ChartSpec) -> Result<ChartElement, ChartError> {
473 self.render_chart_with_size(chart_spec, None, None)
474 }
475
476 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 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 let chart_sources = self.resolve_chart_data(chart_spec, sources)?;
507
508 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 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 #[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 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 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 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 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 ¶ms_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 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 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 let normalized_data = normalize_data_ref(&chart_spec.data, chart_spec.transform.is_some());
825
826 let mut cache_hits: Vec<String> = Vec::new();
827 let mut cache_misses: Vec<String> = Vec::new();
828 let mut per_source: HashMap<String, HashMap<String, serde_json::Value>> = HashMap::new();
829 let mut batch_map: IndexMap<String, (SchemaRef, Vec<RecordBatch>)> = IndexMap::new();
830 let mut any_batch_source = false;
831
832 let chart_sources: IndexMap<String, DataTable> = match &normalized_data {
833 DataRef::Named(name) => {
834 let table = sources.remove(name).ok_or_else(|| {
835 ChartError::DataError(format!("Named data source '{name}' not found"))
836 })?;
837 let batch = table.record_batch().clone();
838 let schema = batch.schema();
839 batch_map.insert(name.clone(), (schema, vec![batch]));
840 any_batch_source = true;
841 let mut map = IndexMap::new();
842 map.insert(name.clone(), table);
843 map
844 }
845 DataRef::Inline(inline) => {
846 let request = self.build_fetch_request(None, inline)?;
847 let key = resolver::Resolver::key_for(inline, self.namespace.as_deref());
848 let outcome = self
849 .resolver
850 .fetch(key, request)
851 .await
852 .map_err(|e| context_fetch_error(e, "source"))?;
853 classify_outcome(
854 "source",
855 &outcome,
856 &mut cache_hits,
857 &mut cache_misses,
858 );
859 if let Some(batch_result) = outcome.batches {
860 batch_map.insert(
861 "source".to_string(),
862 (batch_result.schema, batch_result.batches),
863 );
864 any_batch_source = true;
865 }
866 if !outcome.result.metadata.is_empty() {
867 per_source.insert("source".to_string(), outcome.result.metadata);
868 }
869 let mut map = IndexMap::new();
870 map.insert("source".to_string(), outcome.result.data);
871 map
872 }
873 DataRef::NamedMap(map) => {
874 let mut prefetched: IndexMap<String, DataTable> = IndexMap::new();
875 let mut to_dispatch: Vec<(String, InlineData)> = Vec::new();
876 for (name, inline) in map {
877 if let Some(table) = sources.remove(name) {
878 prefetched.insert(name.clone(), table);
879 } else {
880 to_dispatch.push((name.clone(), inline.clone()));
881 }
882 }
883
884 let resolver = self.resolver.clone();
885 let namespace = self.namespace.clone();
886 let dispatch_futures = to_dispatch.into_iter().map(|(name, inline)| {
887 let resolver = resolver.clone();
888 let namespace = namespace.clone();
889 async move {
890 let request = build_fetch_request_static(
891 Some(name.clone()),
892 &inline,
893 namespace.as_deref(),
894 )?;
895 let key = resolver::Resolver::key_for(&inline, namespace.as_deref());
896 let outcome = resolver
897 .fetch(key, request)
898 .await
899 .map_err(|e| context_fetch_error(e, &name))?;
900 Ok::<(String, resolver::ResolveOutcome), ChartError>((name, outcome))
901 }
902 });
903
904 let dispatched: Vec<(String, resolver::ResolveOutcome)> =
905 futures::future::try_join_all(dispatch_futures).await?;
906
907 let mut dispatched_by_name: HashMap<String, resolver::ResolveOutcome> =
908 dispatched.into_iter().collect();
909 let mut out: IndexMap<String, DataTable> = IndexMap::new();
910 for name in map.keys() {
911 if let Some(table) = prefetched.shift_remove(name) {
912 let batch = table.record_batch().clone();
913 let schema = batch.schema();
914 batch_map.insert(name.clone(), (schema, vec![batch]));
915 any_batch_source = true;
916 out.insert(name.clone(), table);
917 } else if let Some(outcome) = dispatched_by_name.remove(name) {
918 classify_outcome(
919 name,
920 &outcome,
921 &mut cache_hits,
922 &mut cache_misses,
923 );
924 if let Some(batch_result) = outcome.batches {
925 batch_map.insert(
926 name.clone(),
927 (batch_result.schema, batch_result.batches),
928 );
929 any_batch_source = true;
930 }
931 if !outcome.result.metadata.is_empty() {
932 per_source.insert(name.clone(), outcome.result.metadata);
933 }
934 out.insert(name.clone(), outcome.result.data);
935 } else {
936 return Err(ChartError::DataError(format!(
937 "Internal invariant violation: source '{name}' was neither pre-registered nor dispatched"
938 )));
939 }
940 }
941 out
942 }
943 };
944
945 Ok(FetchedChart {
946 spec: chart_spec,
947 sources: chart_sources,
948 batch_sources: if any_batch_source { Some(batch_map) } else { None },
949 metadata: FetchMetadata {
950 refreshed_at: SystemTime::now(),
951 cache_hits,
952 cache_misses,
953 per_source,
954 },
955 })
956 }
957
958 fn build_fetch_request(
967 &self,
968 source_name: Option<String>,
969 spec: &InlineData,
970 ) -> Result<resolver::FetchRequest, ChartError> {
971 build_fetch_request_static(source_name, spec, self.namespace.as_deref())
972 }
973
974 pub async fn transform(
988 &self,
989 fetched: FetchedChart,
990 _opts: &RenderOptions,
991 ) -> Result<PreparedChart, ChartError> {
992 let FetchedChart { spec, sources, batch_sources, metadata: _ } = fetched;
994
995 let hooks = self.resolver.hooks_snapshot();
997 resolver::emit_progress(
998 &hooks,
999 resolver::Phase::Transform,
1000 &None,
1001 None,
1002 None,
1003 "Transforming chart".to_string(),
1004 );
1005
1006 if sources.is_empty() {
1007 let err = ChartError::InvalidSpec(
1009 "Internal invariant violation: ChartML::fetch produced zero sources. \
1010 Every spec must resolve to at least one named source before transform.".into(),
1011 );
1012 resolver::emit_error(
1013 &hooks,
1014 resolver::Phase::Transform,
1015 &None,
1016 err.to_string(),
1017 );
1018 return Err(err);
1019 }
1020
1021 let sources_used: Vec<String> = sources.keys().cloned().collect();
1022
1023 let result: Result<(DataTable, bool), ChartError> = match spec.transform.as_ref() {
1024 None => {
1025 single_source_or_err_no_transform(&sources).map(|single| (single, false))
1030 }
1031 Some(transform_spec) => {
1032 if let Some(middleware) = self.registry.get_transform() {
1033 let context = plugin::TransformContext::default();
1034 if let Some(ref bs) = batch_sources {
1035 middleware
1036 .transform_batches(bs, transform_spec, &context)
1037 .await
1038 .map(|r| (r.data, true))
1039 } else {
1040 middleware
1041 .transform(&sources, transform_spec, &context)
1042 .await
1043 .map(|r| (r.data, true))
1044 }
1045 } else {
1046 single_source_or_err(&sources, transform_spec).and_then(|single_ref| {
1048 let rows = single_ref.to_rows();
1049 let transformed_rows =
1050 transform::apply_transforms(rows, transform_spec)?;
1051 Ok((DataTable::from_rows(&transformed_rows)?, true))
1052 })
1053 }
1054 }
1055 };
1056
1057 let (data, transform_applied) = match result {
1058 Ok(t) => t,
1059 Err(err) => {
1060 resolver::emit_error(
1061 &hooks,
1062 resolver::Phase::Transform,
1063 &None,
1064 err.to_string(),
1065 );
1066 return Err(err);
1067 }
1068 };
1069
1070 Ok(PreparedChart {
1071 spec,
1072 data,
1073 metadata: PreparedMetadata {
1074 refreshed_at: SystemTime::now(),
1075 transform_applied,
1076 sources_used,
1077 },
1078 })
1079 }
1080
1081 pub fn render_prepared_to_svg(
1085 &self,
1086 prepared: &PreparedChart,
1087 opts: &RenderOptions,
1088 ) -> Result<String, ChartError> {
1089 let (element, svg_width, svg_height) = self.build_and_render(
1090 &prepared.spec,
1091 &prepared.data,
1092 opts.width,
1093 opts.height,
1094 )?;
1095 Ok(svg::element_to_svg(&element, svg_width, svg_height))
1096 }
1097
1098 pub async fn render_to_svg_async(
1103 &self,
1104 yaml: &str,
1105 opts: &RenderOptions,
1106 ) -> Result<String, ChartError> {
1107 let fetched = self.fetch(yaml, opts).await?;
1108 let prepared = self.transform(fetched, opts).await?;
1109 self.render_prepared_to_svg(&prepared, opts)
1110 }
1111
1112 pub async fn render_from_yaml_with_params_async(
1124 &self,
1125 yaml: &str,
1126 container_width: Option<f64>,
1127 container_height: Option<f64>,
1128 param_overrides: Option<¶ms::ParamValues>,
1129 ) -> Result<ChartElement, ChartError> {
1130 let opts = RenderOptions {
1131 width: container_width,
1132 height: container_height,
1133 params: param_overrides.cloned(),
1134 };
1135 let fetched = self.fetch(yaml, &opts).await?;
1136 let prepared = self.transform(fetched, &opts).await?;
1137 let (element, _, _) = self.build_and_render(
1138 &prepared.spec,
1139 &prepared.data,
1140 opts.width,
1141 opts.height,
1142 )?;
1143 Ok(element)
1144 }
1145
1146 fn parse_and_collect_sources(
1153 &self,
1154 yaml: &str,
1155 param_overrides: Option<¶ms::ParamValues>,
1156 ) -> Result<(ChartSpec, HashMap<String, DataTable>), ChartError> {
1157 let mut all_params = self.param_values.clone();
1159 let inline_defaults = params::extract_inline_param_defaults(yaml);
1160 all_params.extend(inline_defaults);
1161 if let Some(overrides) = param_overrides {
1162 all_params.extend(overrides.iter().map(|(k, v)| (k.clone(), v.clone())));
1163 }
1164 let resolved_yaml = if !all_params.is_empty() {
1165 params::resolve_param_references(yaml, &all_params)
1166 } else {
1167 yaml.to_string()
1168 };
1169
1170 let parsed = spec::parse(&resolved_yaml)?;
1171
1172 let mut sources: HashMap<String, DataTable> = self.sources.clone();
1174 if let ChartMLSpec::Array(ref components) = parsed {
1175 for component in components {
1176 if let Component::Source(source_spec) = component {
1177 if let Some(ref rows) = source_spec.rows {
1178 let json_rows = self.convert_json_rows(rows)?;
1179 let data = DataTable::from_rows(&json_rows)?;
1180 sources.insert(source_spec.name.clone(), data);
1181 }
1182 }
1183 }
1184 }
1185
1186 let chart_spec: ChartSpec = match &parsed {
1190 ChartMLSpec::Single(component) => match component.as_ref() {
1191 Component::Chart(chart) => chart.as_ref().clone(),
1192 _ => return Err(ChartError::InvalidSpec("No chart component found".into())),
1193 },
1194 ChartMLSpec::Array(components) => components
1195 .iter()
1196 .find_map(|c| match c {
1197 Component::Chart(chart) => Some(chart.as_ref().clone()),
1198 _ => None,
1199 })
1200 .ok_or_else(|| ChartError::InvalidSpec("No chart component found".into()))?,
1201 };
1202
1203 Ok((chart_spec, sources))
1204 }
1205
1206 pub async fn render_from_yaml_with_data_async(
1209 &self,
1210 yaml: &str,
1211 data: DataTable,
1212 ) -> Result<ChartElement, ChartError> {
1213 let parsed = spec::parse(yaml)?;
1215 let chart_spec: &ChartSpec = match &parsed {
1216 ChartMLSpec::Single(component) => match component.as_ref() {
1217 Component::Chart(chart) => chart.as_ref(),
1218 _ => return Err(ChartError::InvalidSpec("No chart component found".into())),
1219 },
1220 ChartMLSpec::Array(components) => {
1221 components.iter()
1222 .find_map(|c| match c { Component::Chart(chart) => Some(chart.as_ref()), _ => None })
1223 .ok_or_else(|| ChartError::InvalidSpec("No chart component found".into()))?
1224 }
1225 };
1226
1227 let chart_sources: IndexMap<String, DataTable> = match &chart_spec.data {
1232 DataRef::Inline(inline) => {
1233 let inline_rows = inline.rows.as_ref()
1238 .map(|r| self.convert_json_rows(r))
1239 .transpose()?
1240 .unwrap_or_default();
1241 let inline_table = DataTable::from_rows(&inline_rows)?;
1242 let chosen = if inline_table.is_empty() && !data.is_empty() {
1243 data
1244 } else {
1245 inline_table
1246 };
1247 let mut map = IndexMap::new();
1248 map.insert("source".to_string(), chosen);
1249 map
1250 }
1251 DataRef::Named(name) => {
1252 let table = self.sources.get(name).cloned().ok_or_else(|| {
1253 ChartError::DataError(format!("Source '{}' not found", name))
1254 })?;
1255 let mut map = IndexMap::new();
1256 map.insert(name.clone(), table);
1257 map
1258 }
1259 DataRef::NamedMap(map) => {
1260 let mut out = IndexMap::new();
1261 for (name, inline) in map {
1262 let table = self.materialize_named_entry(name, inline, &self.sources)?;
1263 out.insert(name.clone(), table);
1264 }
1265 out
1266 }
1267 };
1268
1269 let transformed_data = if let Some(ref transform_spec) = chart_spec.transform {
1270 if let Some(middleware) = self.registry.get_transform() {
1271 let context = plugin::TransformContext::default();
1272 let result = middleware.transform(&chart_sources, transform_spec, &context).await?;
1273 result.data
1274 } else if transform_spec.sql.is_some() || transform_spec.forecast.is_some() {
1275 return Err(ChartError::InvalidSpec(
1276 "Spec uses sql or forecast transforms but no TransformMiddleware is registered".into()
1277 ));
1278 } else {
1279 let single = single_source_or_err(&chart_sources, transform_spec)?;
1283 let rows = single.to_rows();
1284 let transformed_rows = transform::apply_transforms(rows, transform_spec)?;
1285 DataTable::from_rows(&transformed_rows)?
1286 }
1287 } else {
1288 single_source_or_err_no_transform(&chart_sources)?
1289 };
1290
1291 let (element, _, _) =
1292 self.build_and_render(chart_spec, &transformed_data, None, None)?;
1293 Ok(element)
1294 }
1295
1296 fn resolve_chart_data(
1310 &self,
1311 chart_spec: &ChartSpec,
1312 sources: &HashMap<String, DataTable>,
1313 ) -> Result<IndexMap<String, DataTable>, ChartError> {
1314 let mut out = IndexMap::new();
1315 match &chart_spec.data {
1316 DataRef::Inline(inline) => {
1317 let json_rows = inline
1318 .rows
1319 .as_ref()
1320 .map(|r| self.convert_json_rows(r))
1321 .transpose()?
1322 .unwrap_or_default();
1323 let table = DataTable::from_rows(&json_rows)?;
1324 out.insert("source".to_string(), table);
1325 }
1326 DataRef::Named(name) => {
1327 let table = sources.get(name).cloned().ok_or_else(|| {
1328 ChartError::DataError(format!("Named data source '{}' not found", name))
1329 })?;
1330 out.insert(name.clone(), table);
1331 }
1332 DataRef::NamedMap(map) => {
1333 for (name, inline) in map {
1334 let table = self.materialize_named_entry(name, inline, sources)?;
1335 out.insert(name.clone(), table);
1336 }
1337 }
1338 }
1339 Ok(out)
1340 }
1341
1342 fn build_and_render(
1350 &self,
1351 chart_spec: &ChartSpec,
1352 data: &DataTable,
1353 container_width: Option<f64>,
1354 container_height: Option<f64>,
1355 ) -> Result<(ChartElement, f64, f64), ChartError> {
1356 let chart_type = &chart_spec.visualize.chart_type;
1357 let renderer = self.registry.get_renderer(chart_type)
1358 .ok_or_else(|| ChartError::UnknownChartType(chart_type.clone()))?;
1359
1360 let default_height = renderer.default_dimensions(&chart_spec.visualize)
1361 .map(|d| d.height)
1362 .unwrap_or(400.0);
1363
1364 let height = chart_spec.visualize.style.as_ref()
1365 .and_then(|s| s.height)
1366 .unwrap_or(container_height.unwrap_or(default_height));
1367
1368 let width = chart_spec.visualize.style.as_ref()
1369 .and_then(|s| s.width)
1370 .unwrap_or(container_width.unwrap_or(800.0));
1371
1372 let colors = chart_spec.visualize.style.as_ref()
1373 .and_then(|s| s.colors.clone())
1374 .or_else(|| self.default_palette.clone())
1375 .unwrap_or_else(|| {
1376 color::get_chart_colors(12, color::palettes::get_palette("autumn_forest"))
1377 });
1378
1379 let config = plugin::ChartConfig {
1380 visualize: chart_spec.visualize.clone(),
1381 title: chart_spec.title.clone(),
1382 width,
1383 height,
1384 colors,
1385 theme: self.theme.clone(),
1386 };
1387
1388 let element = renderer.render(data, &config)?;
1389 Ok((element, width, height))
1390 }
1391
1392 pub fn registry(&self) -> &ChartMLRegistry {
1394 &self.registry
1395 }
1396
1397 pub fn registry_mut(&mut self) -> &mut ChartMLRegistry {
1399 &mut self.registry
1400 }
1401}
1402
1403impl Default for ChartML {
1404 fn default() -> Self {
1405 Self::new()
1406 }
1407}
1408
1409fn normalize_data_ref(data: &DataRef, has_transform: bool) -> DataRef {
1415 match (data, has_transform) {
1416 (DataRef::Inline(inline), true) => {
1417 let mut map = IndexMap::new();
1418 map.insert("source".to_string(), inline.clone());
1419 DataRef::NamedMap(map)
1420 }
1421 _ => data.clone(),
1422 }
1423}
1424
1425fn build_fetch_request_static(
1428 source_name: Option<String>,
1429 spec: &InlineData,
1430 namespace: Option<&str>,
1431) -> Result<resolver::FetchRequest, ChartError> {
1432 Ok(resolver::FetchRequest {
1433 source_name,
1434 spec: spec.clone(),
1435 cache: resolver::CacheConfig::from_spec(spec.cache.as_ref())?,
1436 headers: HashMap::new(),
1437 namespace: namespace.map(String::from),
1438 cancel_token: None,
1439 })
1440}
1441
1442fn classify_outcome(
1443 name: &str,
1444 outcome: &resolver::ResolveOutcome,
1445 cache_hits: &mut Vec<String>,
1446 cache_misses: &mut Vec<String>,
1447) {
1448 if outcome.cache_hit {
1449 cache_hits.push(name.to_string());
1450 } else {
1451 cache_misses.push(name.to_string());
1452 }
1453}
1454
1455fn context_fetch_error(err: resolver::FetchError, source_name: &str) -> ChartError {
1461 let base: ChartError = err.into();
1462 ChartError::DataError(format!("source '{source_name}' fetch failed: {base}"))
1463}
1464
1465fn single_source_or_err<'a>(
1470 sources: &'a IndexMap<String, DataTable>,
1471 transform_spec: &spec::TransformSpec,
1472) -> Result<&'a DataTable, ChartError> {
1473 if sources.len() == 1 {
1474 return Ok(sources
1475 .values()
1476 .next()
1477 .expect("sources has 1 entry"));
1478 }
1479 Err(ChartError::InvalidSpec(format!(
1480 "Multi-source `data:` map (got {} sources: {}) with transform `{}` requires a registered TransformMiddleware to join the sources. Call `register_transform(DataFusionTransform)` (or another middleware) before rendering.",
1481 sources.len(),
1482 sources.keys().cloned().collect::<Vec<_>>().join(", "),
1483 describe_transform(transform_spec),
1484 )))
1485}
1486
1487fn single_source_or_err_no_transform(
1491 sources: &IndexMap<String, DataTable>,
1492) -> Result<DataTable, ChartError> {
1493 if sources.len() == 1 {
1494 return Ok(sources
1495 .values()
1496 .next()
1497 .expect("sources has 1 entry")
1498 .clone());
1499 }
1500 Err(ChartError::InvalidSpec(format!(
1501 "Named data sources require a transform block when multiple sources are defined (got {} sources: {}).",
1502 sources.len(),
1503 sources.keys().cloned().collect::<Vec<_>>().join(", "),
1504 )))
1505}
1506
1507fn describe_transform(spec: &spec::TransformSpec) -> &'static str {
1508 if spec.sql.is_some() {
1509 "sql"
1510 } else if spec.aggregate.is_some() {
1511 "aggregate"
1512 } else if spec.forecast.is_some() {
1513 "forecast"
1514 } else {
1515 "transform"
1516 }
1517}
1518
1519#[cfg(test)]
1520mod tests {
1521 #![allow(clippy::unwrap_used)]
1522 use super::*;
1523 use crate::element::ViewBox;
1524
1525 struct MockRenderer;
1526
1527 impl ChartRenderer for MockRenderer {
1528 fn render(&self, _data: &DataTable, _config: &ChartConfig) -> Result<ChartElement, ChartError> {
1529 Ok(ChartElement::Svg {
1530 viewbox: ViewBox::new(0.0, 0.0, 800.0, 400.0),
1531 width: Some(800.0),
1532 height: Some(400.0),
1533 class: "mock".to_string(),
1534 children: vec![],
1535 })
1536 }
1537 }
1538
1539 #[test]
1540 fn chartml_render_from_yaml_with_mock() {
1541 let mut chartml = ChartML::new();
1542 chartml.register_renderer("bar", MockRenderer);
1543
1544 let yaml = r#"
1545type: chart
1546version: 1
1547title: Test
1548data:
1549 provider: inline
1550 rows:
1551 - { x: "A", y: 10 }
1552 - { x: "B", y: 20 }
1553visualize:
1554 type: bar
1555 columns: x
1556 rows: y
1557"#;
1558
1559 let result = chartml.render_from_yaml(yaml);
1560 assert!(result.is_ok(), "render failed: {:?}", result.err());
1561 }
1562
1563 #[test]
1564 fn chartml_unknown_chart_type() {
1565 let chartml = ChartML::new();
1566 let yaml = r#"
1567type: chart
1568version: 1
1569data:
1570 provider: inline
1571 rows: []
1572visualize:
1573 type: unknown_type
1574 columns: x
1575 rows: y
1576"#;
1577 let result = chartml.render_from_yaml(yaml);
1578 assert!(result.is_err());
1579 }
1580
1581 #[test]
1582 fn chartml_named_source_resolution() {
1583 let mut chartml = ChartML::new();
1584 chartml.register_renderer("bar", MockRenderer);
1585
1586 let yaml = r#"---
1587type: source
1588version: 1
1589name: q1_sales
1590provider: inline
1591rows:
1592 - { month: "Jan", revenue: 100 }
1593 - { month: "Feb", revenue: 200 }
1594---
1595type: chart
1596version: 1
1597title: Revenue by Month
1598data: q1_sales
1599visualize:
1600 type: bar
1601 columns: month
1602 rows: revenue
1603"#;
1604
1605 let result = chartml.render_from_yaml(yaml);
1606 assert!(result.is_ok(), "named source render failed: {:?}", result.err());
1607 }
1608
1609 #[test]
1610 fn chartml_named_source_not_found() {
1611 let mut chartml = ChartML::new();
1612 chartml.register_renderer("bar", MockRenderer);
1613
1614 let yaml = r#"
1615type: chart
1616version: 1
1617data: nonexistent_source
1618visualize:
1619 type: bar
1620 columns: x
1621 rows: y
1622"#;
1623
1624 let result = chartml.render_from_yaml(yaml);
1625 assert!(result.is_err());
1626 let err = result.unwrap_err().to_string();
1627 assert!(err.contains("not found"), "Expected 'not found' error, got: {}", err);
1628 }
1629
1630 #[test]
1631 fn chartml_multi_chart_rendering() {
1632 let mut chartml = ChartML::new();
1633 chartml.register_renderer("bar", MockRenderer);
1634
1635 let yaml = r#"
1636- type: chart
1637 version: 1
1638 title: Chart A
1639 data:
1640 provider: inline
1641 rows:
1642 - { x: "A", y: 10 }
1643 visualize:
1644 type: bar
1645 columns: x
1646 rows: y
1647- type: chart
1648 version: 1
1649 title: Chart B
1650 data:
1651 provider: inline
1652 rows:
1653 - { x: "B", y: 20 }
1654 visualize:
1655 type: bar
1656 columns: x
1657 rows: y
1658"#;
1659
1660 let result = chartml.render_from_yaml(yaml);
1661 assert!(result.is_ok(), "multi-chart render failed: {:?}", result.err());
1662 match result.unwrap() {
1663 ChartElement::Div { class, children, .. } => {
1664 assert_eq!(class, "chartml-multi-chart");
1665 assert_eq!(children.len(), 2);
1666 }
1667 other => panic!("Expected Div wrapper, got {:?}", other),
1668 }
1669 }
1670
1671 #[test]
1672 fn chartml_named_source_with_transform() {
1673 let mut chartml = ChartML::new();
1674 chartml.register_renderer("bar", MockRenderer);
1675
1676 let yaml = r#"---
1677type: source
1678version: 1
1679name: raw_sales
1680provider: inline
1681rows:
1682 - { region: "North", revenue: 100 }
1683 - { region: "North", revenue: 200 }
1684 - { region: "South", revenue: 150 }
1685---
1686type: chart
1687version: 1
1688title: Revenue by Region
1689data: raw_sales
1690transform:
1691 aggregate:
1692 dimensions:
1693 - region
1694 measures:
1695 - column: revenue
1696 aggregation: sum
1697 name: total_revenue
1698 sort:
1699 - field: total_revenue
1700 direction: desc
1701visualize:
1702 type: bar
1703 columns: region
1704 rows: total_revenue
1705"#;
1706
1707 let result = chartml.render_from_yaml(yaml);
1708 assert!(result.is_ok(), "transform pipeline render failed: {:?}", result.err());
1709 }
1710
1711 #[test]
1712 fn chartml_multi_chart_with_shared_source() {
1713 let mut chartml = ChartML::new();
1714 chartml.register_renderer("bar", MockRenderer);
1715 chartml.register_renderer("metric", MockRenderer);
1716
1717 let yaml = r#"---
1718type: source
1719version: 1
1720name: kpis
1721provider: inline
1722rows:
1723 - { totalRevenue: 1500000, previousRevenue: 1200000 }
1724---
1725- type: chart
1726 version: 1
1727 title: Revenue
1728 data: kpis
1729 visualize:
1730 type: metric
1731 value: totalRevenue
1732- type: chart
1733 version: 1
1734 title: Prev Revenue
1735 data: kpis
1736 visualize:
1737 type: metric
1738 value: previousRevenue
1739"#;
1740
1741 let result = chartml.render_from_yaml(yaml);
1742 assert!(result.is_ok(), "multi-chart shared source failed: {:?}", result.err());
1743 }
1744}