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 let refreshed_at = if cache_misses.is_empty() && !cache_hits.is_empty() {
946 per_source
947 .values()
948 .filter_map(|meta| meta.get("fetched_at_ms"))
949 .filter_map(|v| v.as_f64())
950 .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
951 .and_then(|ms| {
952 SystemTime::UNIX_EPOCH
953 .checked_add(std::time::Duration::from_millis(ms as u64))
954 })
955 .unwrap_or_else(SystemTime::now)
956 } else {
957 SystemTime::now()
958 };
959
960 Ok(FetchedChart {
961 spec: chart_spec,
962 sources: chart_sources,
963 batch_sources: if any_batch_source { Some(batch_map) } else { None },
964 metadata: FetchMetadata {
965 refreshed_at,
966 cache_hits,
967 cache_misses,
968 per_source,
969 },
970 })
971 }
972
973 fn build_fetch_request(
982 &self,
983 source_name: Option<String>,
984 spec: &InlineData,
985 ) -> Result<resolver::FetchRequest, ChartError> {
986 build_fetch_request_static(source_name, spec, self.namespace.as_deref())
987 }
988
989 pub async fn transform(
1003 &self,
1004 fetched: FetchedChart,
1005 _opts: &RenderOptions,
1006 ) -> Result<PreparedChart, ChartError> {
1007 let FetchedChart { spec, sources, batch_sources, metadata: fetch_meta } = fetched;
1009
1010 let hooks = self.resolver.hooks_snapshot();
1012 resolver::emit_progress(
1013 &hooks,
1014 resolver::Phase::Transform,
1015 &None,
1016 None,
1017 None,
1018 "Transforming chart".to_string(),
1019 );
1020
1021 if sources.is_empty() {
1022 let err = ChartError::InvalidSpec(
1024 "Internal invariant violation: ChartML::fetch produced zero sources. \
1025 Every spec must resolve to at least one named source before transform.".into(),
1026 );
1027 resolver::emit_error(
1028 &hooks,
1029 resolver::Phase::Transform,
1030 &None,
1031 err.to_string(),
1032 );
1033 return Err(err);
1034 }
1035
1036 let sources_used: Vec<String> = sources.keys().cloned().collect();
1037
1038 let result: Result<(DataTable, bool), ChartError> = match spec.transform.as_ref() {
1039 None => {
1040 single_source_or_err_no_transform(&sources).map(|single| (single, false))
1045 }
1046 Some(transform_spec) => {
1047 if let Some(middleware) = self.registry.get_transform() {
1048 let context = plugin::TransformContext::default();
1049 if let Some(ref bs) = batch_sources {
1050 middleware
1051 .transform_batches(bs, transform_spec, &context)
1052 .await
1053 .map(|r| (r.data, true))
1054 } else {
1055 middleware
1056 .transform(&sources, transform_spec, &context)
1057 .await
1058 .map(|r| (r.data, true))
1059 }
1060 } else {
1061 single_source_or_err(&sources, transform_spec).and_then(|single_ref| {
1063 let rows = single_ref.to_rows();
1064 let transformed_rows =
1065 transform::apply_transforms(rows, transform_spec)?;
1066 Ok((DataTable::from_rows(&transformed_rows)?, true))
1067 })
1068 }
1069 }
1070 };
1071
1072 let (data, transform_applied) = match result {
1073 Ok(t) => t,
1074 Err(err) => {
1075 resolver::emit_error(
1076 &hooks,
1077 resolver::Phase::Transform,
1078 &None,
1079 err.to_string(),
1080 );
1081 return Err(err);
1082 }
1083 };
1084
1085 Ok(PreparedChart {
1086 spec,
1087 data,
1088 metadata: PreparedMetadata {
1089 refreshed_at: fetch_meta.refreshed_at,
1090 transform_applied,
1091 sources_used,
1092 },
1093 })
1094 }
1095
1096 pub fn render_prepared_to_svg(
1100 &self,
1101 prepared: &PreparedChart,
1102 opts: &RenderOptions,
1103 ) -> Result<String, ChartError> {
1104 let (element, svg_width, svg_height) = self.build_and_render(
1105 &prepared.spec,
1106 &prepared.data,
1107 opts.width,
1108 opts.height,
1109 )?;
1110 Ok(svg::element_to_svg(&element, svg_width, svg_height))
1111 }
1112
1113 pub async fn render_to_svg_async(
1118 &self,
1119 yaml: &str,
1120 opts: &RenderOptions,
1121 ) -> Result<String, ChartError> {
1122 let fetched = self.fetch(yaml, opts).await?;
1123 let prepared = self.transform(fetched, opts).await?;
1124 self.render_prepared_to_svg(&prepared, opts)
1125 }
1126
1127 pub async fn render_from_yaml_with_params_async(
1139 &self,
1140 yaml: &str,
1141 container_width: Option<f64>,
1142 container_height: Option<f64>,
1143 param_overrides: Option<¶ms::ParamValues>,
1144 ) -> Result<ChartElement, ChartError> {
1145 let opts = RenderOptions {
1146 width: container_width,
1147 height: container_height,
1148 params: param_overrides.cloned(),
1149 };
1150 let fetched = self.fetch(yaml, &opts).await?;
1151 let prepared = self.transform(fetched, &opts).await?;
1152 let (element, _, _) = self.build_and_render(
1153 &prepared.spec,
1154 &prepared.data,
1155 opts.width,
1156 opts.height,
1157 )?;
1158 Ok(element)
1159 }
1160
1161 fn parse_and_collect_sources(
1168 &self,
1169 yaml: &str,
1170 param_overrides: Option<¶ms::ParamValues>,
1171 ) -> Result<(ChartSpec, HashMap<String, DataTable>), ChartError> {
1172 let mut all_params = self.param_values.clone();
1174 let inline_defaults = params::extract_inline_param_defaults(yaml);
1175 all_params.extend(inline_defaults);
1176 if let Some(overrides) = param_overrides {
1177 all_params.extend(overrides.iter().map(|(k, v)| (k.clone(), v.clone())));
1178 }
1179 let resolved_yaml = if !all_params.is_empty() {
1180 params::resolve_param_references(yaml, &all_params)
1181 } else {
1182 yaml.to_string()
1183 };
1184
1185 let parsed = spec::parse(&resolved_yaml)?;
1186
1187 let mut sources: HashMap<String, DataTable> = self.sources.clone();
1189 if let ChartMLSpec::Array(ref components) = parsed {
1190 for component in components {
1191 if let Component::Source(source_spec) = component {
1192 if let Some(ref rows) = source_spec.rows {
1193 let json_rows = self.convert_json_rows(rows)?;
1194 let data = DataTable::from_rows(&json_rows)?;
1195 sources.insert(source_spec.name.clone(), data);
1196 }
1197 }
1198 }
1199 }
1200
1201 let chart_spec: ChartSpec = match &parsed {
1205 ChartMLSpec::Single(component) => match component.as_ref() {
1206 Component::Chart(chart) => chart.as_ref().clone(),
1207 _ => return Err(ChartError::InvalidSpec("No chart component found".into())),
1208 },
1209 ChartMLSpec::Array(components) => components
1210 .iter()
1211 .find_map(|c| match c {
1212 Component::Chart(chart) => Some(chart.as_ref().clone()),
1213 _ => None,
1214 })
1215 .ok_or_else(|| ChartError::InvalidSpec("No chart component found".into()))?,
1216 };
1217
1218 Ok((chart_spec, sources))
1219 }
1220
1221 pub async fn render_from_yaml_with_data_async(
1224 &self,
1225 yaml: &str,
1226 data: DataTable,
1227 ) -> Result<ChartElement, ChartError> {
1228 let parsed = spec::parse(yaml)?;
1230 let chart_spec: &ChartSpec = match &parsed {
1231 ChartMLSpec::Single(component) => match component.as_ref() {
1232 Component::Chart(chart) => chart.as_ref(),
1233 _ => return Err(ChartError::InvalidSpec("No chart component found".into())),
1234 },
1235 ChartMLSpec::Array(components) => {
1236 components.iter()
1237 .find_map(|c| match c { Component::Chart(chart) => Some(chart.as_ref()), _ => None })
1238 .ok_or_else(|| ChartError::InvalidSpec("No chart component found".into()))?
1239 }
1240 };
1241
1242 let chart_sources: IndexMap<String, DataTable> = match &chart_spec.data {
1247 DataRef::Inline(inline) => {
1248 let inline_rows = inline.rows.as_ref()
1253 .map(|r| self.convert_json_rows(r))
1254 .transpose()?
1255 .unwrap_or_default();
1256 let inline_table = DataTable::from_rows(&inline_rows)?;
1257 let chosen = if inline_table.is_empty() && !data.is_empty() {
1258 data
1259 } else {
1260 inline_table
1261 };
1262 let mut map = IndexMap::new();
1263 map.insert("source".to_string(), chosen);
1264 map
1265 }
1266 DataRef::Named(name) => {
1267 let table = self.sources.get(name).cloned().ok_or_else(|| {
1268 ChartError::DataError(format!("Source '{}' not found", name))
1269 })?;
1270 let mut map = IndexMap::new();
1271 map.insert(name.clone(), table);
1272 map
1273 }
1274 DataRef::NamedMap(map) => {
1275 let mut out = IndexMap::new();
1276 for (name, inline) in map {
1277 let table = self.materialize_named_entry(name, inline, &self.sources)?;
1278 out.insert(name.clone(), table);
1279 }
1280 out
1281 }
1282 };
1283
1284 let transformed_data = if let Some(ref transform_spec) = chart_spec.transform {
1285 if let Some(middleware) = self.registry.get_transform() {
1286 let context = plugin::TransformContext::default();
1287 let result = middleware.transform(&chart_sources, transform_spec, &context).await?;
1288 result.data
1289 } else if transform_spec.sql.is_some() || transform_spec.forecast.is_some() {
1290 return Err(ChartError::InvalidSpec(
1291 "Spec uses sql or forecast transforms but no TransformMiddleware is registered".into()
1292 ));
1293 } else {
1294 let single = single_source_or_err(&chart_sources, transform_spec)?;
1298 let rows = single.to_rows();
1299 let transformed_rows = transform::apply_transforms(rows, transform_spec)?;
1300 DataTable::from_rows(&transformed_rows)?
1301 }
1302 } else {
1303 single_source_or_err_no_transform(&chart_sources)?
1304 };
1305
1306 let (element, _, _) =
1307 self.build_and_render(chart_spec, &transformed_data, None, None)?;
1308 Ok(element)
1309 }
1310
1311 fn resolve_chart_data(
1325 &self,
1326 chart_spec: &ChartSpec,
1327 sources: &HashMap<String, DataTable>,
1328 ) -> Result<IndexMap<String, DataTable>, ChartError> {
1329 let mut out = IndexMap::new();
1330 match &chart_spec.data {
1331 DataRef::Inline(inline) => {
1332 let json_rows = inline
1333 .rows
1334 .as_ref()
1335 .map(|r| self.convert_json_rows(r))
1336 .transpose()?
1337 .unwrap_or_default();
1338 let table = DataTable::from_rows(&json_rows)?;
1339 out.insert("source".to_string(), table);
1340 }
1341 DataRef::Named(name) => {
1342 let table = sources.get(name).cloned().ok_or_else(|| {
1343 ChartError::DataError(format!("Named data source '{}' not found", name))
1344 })?;
1345 out.insert(name.clone(), table);
1346 }
1347 DataRef::NamedMap(map) => {
1348 for (name, inline) in map {
1349 let table = self.materialize_named_entry(name, inline, sources)?;
1350 out.insert(name.clone(), table);
1351 }
1352 }
1353 }
1354 Ok(out)
1355 }
1356
1357 fn build_and_render(
1365 &self,
1366 chart_spec: &ChartSpec,
1367 data: &DataTable,
1368 container_width: Option<f64>,
1369 container_height: Option<f64>,
1370 ) -> Result<(ChartElement, f64, f64), ChartError> {
1371 let chart_type = &chart_spec.visualize.chart_type;
1372 let renderer = self.registry.get_renderer(chart_type)
1373 .ok_or_else(|| ChartError::UnknownChartType(chart_type.clone()))?;
1374
1375 let default_height = renderer.default_dimensions(&chart_spec.visualize)
1376 .map(|d| d.height)
1377 .unwrap_or(400.0);
1378
1379 let height = chart_spec.visualize.style.as_ref()
1380 .and_then(|s| s.height)
1381 .unwrap_or(container_height.unwrap_or(default_height));
1382
1383 let width = chart_spec.visualize.style.as_ref()
1384 .and_then(|s| s.width)
1385 .unwrap_or(container_width.unwrap_or(800.0));
1386
1387 let colors = chart_spec.visualize.style.as_ref()
1388 .and_then(|s| s.colors.clone())
1389 .or_else(|| self.default_palette.clone())
1390 .unwrap_or_else(|| {
1391 color::get_chart_colors(12, color::palettes::get_palette("autumn_forest"))
1392 });
1393
1394 let config = plugin::ChartConfig {
1395 visualize: chart_spec.visualize.clone(),
1396 title: chart_spec.title.clone(),
1397 width,
1398 height,
1399 colors,
1400 theme: self.theme.clone(),
1401 };
1402
1403 let element = renderer.render(data, &config)?;
1404 Ok((element, width, height))
1405 }
1406
1407 pub fn registry(&self) -> &ChartMLRegistry {
1409 &self.registry
1410 }
1411
1412 pub fn registry_mut(&mut self) -> &mut ChartMLRegistry {
1414 &mut self.registry
1415 }
1416}
1417
1418impl Default for ChartML {
1419 fn default() -> Self {
1420 Self::new()
1421 }
1422}
1423
1424fn normalize_data_ref(data: &DataRef, has_transform: bool) -> DataRef {
1430 match (data, has_transform) {
1431 (DataRef::Inline(inline), true) => {
1432 let mut map = IndexMap::new();
1433 map.insert("source".to_string(), inline.clone());
1434 DataRef::NamedMap(map)
1435 }
1436 _ => data.clone(),
1437 }
1438}
1439
1440fn build_fetch_request_static(
1443 source_name: Option<String>,
1444 spec: &InlineData,
1445 namespace: Option<&str>,
1446) -> Result<resolver::FetchRequest, ChartError> {
1447 Ok(resolver::FetchRequest {
1448 source_name,
1449 spec: spec.clone(),
1450 cache: resolver::CacheConfig::from_spec(spec.cache.as_ref())?,
1451 headers: HashMap::new(),
1452 namespace: namespace.map(String::from),
1453 cancel_token: None,
1454 })
1455}
1456
1457fn classify_outcome(
1458 name: &str,
1459 outcome: &resolver::ResolveOutcome,
1460 cache_hits: &mut Vec<String>,
1461 cache_misses: &mut Vec<String>,
1462) {
1463 if outcome.cache_hit {
1464 cache_hits.push(name.to_string());
1465 } else {
1466 cache_misses.push(name.to_string());
1467 }
1468}
1469
1470fn context_fetch_error(err: resolver::FetchError, source_name: &str) -> ChartError {
1476 let base: ChartError = err.into();
1477 ChartError::DataError(format!("source '{source_name}' fetch failed: {base}"))
1478}
1479
1480fn single_source_or_err<'a>(
1485 sources: &'a IndexMap<String, DataTable>,
1486 transform_spec: &spec::TransformSpec,
1487) -> Result<&'a DataTable, ChartError> {
1488 if sources.len() == 1 {
1489 return Ok(sources
1490 .values()
1491 .next()
1492 .expect("sources has 1 entry"));
1493 }
1494 Err(ChartError::InvalidSpec(format!(
1495 "Multi-source `data:` map (got {} sources: {}) with transform `{}` requires a registered TransformMiddleware to join the sources. Call `register_transform(DataFusionTransform)` (or another middleware) before rendering.",
1496 sources.len(),
1497 sources.keys().cloned().collect::<Vec<_>>().join(", "),
1498 describe_transform(transform_spec),
1499 )))
1500}
1501
1502fn single_source_or_err_no_transform(
1506 sources: &IndexMap<String, DataTable>,
1507) -> Result<DataTable, ChartError> {
1508 if sources.len() == 1 {
1509 return Ok(sources
1510 .values()
1511 .next()
1512 .expect("sources has 1 entry")
1513 .clone());
1514 }
1515 Err(ChartError::InvalidSpec(format!(
1516 "Named data sources require a transform block when multiple sources are defined (got {} sources: {}).",
1517 sources.len(),
1518 sources.keys().cloned().collect::<Vec<_>>().join(", "),
1519 )))
1520}
1521
1522fn describe_transform(spec: &spec::TransformSpec) -> &'static str {
1523 if spec.sql.is_some() {
1524 "sql"
1525 } else if spec.aggregate.is_some() {
1526 "aggregate"
1527 } else if spec.forecast.is_some() {
1528 "forecast"
1529 } else {
1530 "transform"
1531 }
1532}
1533
1534#[cfg(test)]
1535mod tests {
1536 #![allow(clippy::unwrap_used)]
1537 use super::*;
1538 use crate::element::ViewBox;
1539
1540 struct MockRenderer;
1541
1542 impl ChartRenderer for MockRenderer {
1543 fn render(&self, _data: &DataTable, _config: &ChartConfig) -> Result<ChartElement, ChartError> {
1544 Ok(ChartElement::Svg {
1545 viewbox: ViewBox::new(0.0, 0.0, 800.0, 400.0),
1546 width: Some(800.0),
1547 height: Some(400.0),
1548 class: "mock".to_string(),
1549 children: vec![],
1550 })
1551 }
1552 }
1553
1554 #[test]
1555 fn chartml_render_from_yaml_with_mock() {
1556 let mut chartml = ChartML::new();
1557 chartml.register_renderer("bar", MockRenderer);
1558
1559 let yaml = r#"
1560type: chart
1561version: 1
1562title: Test
1563data:
1564 provider: inline
1565 rows:
1566 - { x: "A", y: 10 }
1567 - { x: "B", y: 20 }
1568visualize:
1569 type: bar
1570 columns: x
1571 rows: y
1572"#;
1573
1574 let result = chartml.render_from_yaml(yaml);
1575 assert!(result.is_ok(), "render failed: {:?}", result.err());
1576 }
1577
1578 #[test]
1579 fn chartml_unknown_chart_type() {
1580 let chartml = ChartML::new();
1581 let yaml = r#"
1582type: chart
1583version: 1
1584data:
1585 provider: inline
1586 rows: []
1587visualize:
1588 type: unknown_type
1589 columns: x
1590 rows: y
1591"#;
1592 let result = chartml.render_from_yaml(yaml);
1593 assert!(result.is_err());
1594 }
1595
1596 #[test]
1597 fn chartml_named_source_resolution() {
1598 let mut chartml = ChartML::new();
1599 chartml.register_renderer("bar", MockRenderer);
1600
1601 let yaml = r#"---
1602type: source
1603version: 1
1604name: q1_sales
1605provider: inline
1606rows:
1607 - { month: "Jan", revenue: 100 }
1608 - { month: "Feb", revenue: 200 }
1609---
1610type: chart
1611version: 1
1612title: Revenue by Month
1613data: q1_sales
1614visualize:
1615 type: bar
1616 columns: month
1617 rows: revenue
1618"#;
1619
1620 let result = chartml.render_from_yaml(yaml);
1621 assert!(result.is_ok(), "named source render failed: {:?}", result.err());
1622 }
1623
1624 #[test]
1625 fn chartml_named_source_not_found() {
1626 let mut chartml = ChartML::new();
1627 chartml.register_renderer("bar", MockRenderer);
1628
1629 let yaml = r#"
1630type: chart
1631version: 1
1632data: nonexistent_source
1633visualize:
1634 type: bar
1635 columns: x
1636 rows: y
1637"#;
1638
1639 let result = chartml.render_from_yaml(yaml);
1640 assert!(result.is_err());
1641 let err = result.unwrap_err().to_string();
1642 assert!(err.contains("not found"), "Expected 'not found' error, got: {}", err);
1643 }
1644
1645 #[test]
1646 fn chartml_multi_chart_rendering() {
1647 let mut chartml = ChartML::new();
1648 chartml.register_renderer("bar", MockRenderer);
1649
1650 let yaml = r#"
1651- type: chart
1652 version: 1
1653 title: Chart A
1654 data:
1655 provider: inline
1656 rows:
1657 - { x: "A", y: 10 }
1658 visualize:
1659 type: bar
1660 columns: x
1661 rows: y
1662- type: chart
1663 version: 1
1664 title: Chart B
1665 data:
1666 provider: inline
1667 rows:
1668 - { x: "B", y: 20 }
1669 visualize:
1670 type: bar
1671 columns: x
1672 rows: y
1673"#;
1674
1675 let result = chartml.render_from_yaml(yaml);
1676 assert!(result.is_ok(), "multi-chart render failed: {:?}", result.err());
1677 match result.unwrap() {
1678 ChartElement::Div { class, children, .. } => {
1679 assert_eq!(class, "chartml-multi-chart");
1680 assert_eq!(children.len(), 2);
1681 }
1682 other => panic!("Expected Div wrapper, got {:?}", other),
1683 }
1684 }
1685
1686 #[test]
1687 fn chartml_named_source_with_transform() {
1688 let mut chartml = ChartML::new();
1689 chartml.register_renderer("bar", MockRenderer);
1690
1691 let yaml = r#"---
1692type: source
1693version: 1
1694name: raw_sales
1695provider: inline
1696rows:
1697 - { region: "North", revenue: 100 }
1698 - { region: "North", revenue: 200 }
1699 - { region: "South", revenue: 150 }
1700---
1701type: chart
1702version: 1
1703title: Revenue by Region
1704data: raw_sales
1705transform:
1706 aggregate:
1707 dimensions:
1708 - region
1709 measures:
1710 - column: revenue
1711 aggregation: sum
1712 name: total_revenue
1713 sort:
1714 - field: total_revenue
1715 direction: desc
1716visualize:
1717 type: bar
1718 columns: region
1719 rows: total_revenue
1720"#;
1721
1722 let result = chartml.render_from_yaml(yaml);
1723 assert!(result.is_ok(), "transform pipeline render failed: {:?}", result.err());
1724 }
1725
1726 #[test]
1727 fn chartml_multi_chart_with_shared_source() {
1728 let mut chartml = ChartML::new();
1729 chartml.register_renderer("bar", MockRenderer);
1730 chartml.register_renderer("metric", MockRenderer);
1731
1732 let yaml = r#"---
1733type: source
1734version: 1
1735name: kpis
1736provider: inline
1737rows:
1738 - { totalRevenue: 1500000, previousRevenue: 1200000 }
1739---
1740- type: chart
1741 version: 1
1742 title: Revenue
1743 data: kpis
1744 visualize:
1745 type: metric
1746 value: totalRevenue
1747- type: chart
1748 version: 1
1749 title: Prev Revenue
1750 data: kpis
1751 visualize:
1752 type: metric
1753 value: previousRevenue
1754"#;
1755
1756 let result = chartml.render_from_yaml(yaml);
1757 assert!(result.is_ok(), "multi-chart shared source failed: {:?}", result.err());
1758 }
1759}