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;
36use web_time::SystemTime;
38use indexmap::IndexMap;
39use crate::data::{Row, DataTable};
40use crate::spec::{ChartSpec, DataRef, InlineData};
41
42pub struct ChartML {
46 registry: ChartMLRegistry,
47 sources: HashMap<String, DataTable>,
53 param_values: params::ParamValues,
55 default_palette: Option<Vec<String>>,
58 theme: theme::Theme,
61 resolver: resolver::ResolverRef,
67 namespace: Option<String>,
71}
72
73impl ChartML {
74 pub fn new() -> Self {
79 let resolver = resolver::ResolverRef::new(resolver::Resolver::new());
80 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 pub fn with_defaults() -> Self {
98 Self::new()
99 }
100
101 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 pub fn set_default_palette(&mut self, colors: Vec<String>) {
122 self.default_palette = Some(colors);
123 }
124
125 pub fn set_theme(&mut self, theme: theme::Theme) {
129 self.theme = theme;
130 }
131
132 pub fn theme(&self) -> &theme::Theme {
135 &self.theme
136 }
137
138 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(&[¶ms_spec]);
168 self.param_values.extend(defaults);
169 Ok(())
170 }
171 spec::Component::Style(_) | spec::Component::Config(_) => {
172 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 pub fn register_source(&mut self, name: &str, data: DataTable) {
185 self.sources.insert(name.to_string(), data);
186 }
187
188 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 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 pub fn with_cache(mut self, backend: impl resolver::CacheBackend + 'static) -> Self {
223 self.set_cache(backend);
224 self
225 }
226
227 pub fn set_namespace(&mut self, slug: impl Into<String>) {
231 self.namespace = Some(slug.into());
232 }
233
234 pub fn with_namespace(mut self, slug: impl Into<String>) -> Self {
236 self.set_namespace(slug);
237 self
238 }
239
240 pub fn resolver(&self) -> resolver::ResolverRef {
244 self.resolver.clone()
245 }
246
247 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 pub async fn shutdown(&self) {
269 self.resolver.shutdown().await;
270 }
271
272 pub fn render_from_yaml(&self, yaml: &str) -> Result<ChartElement, ChartError> {
278 self.render_from_yaml_with_size(yaml, None, None)
279 }
280
281 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 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<¶ms::ParamValues>,
301 ) -> Result<ChartElement, ChartError> {
302 let mut all_params = self.param_values.clone();
305
306 let inline_defaults = params::extract_inline_param_defaults(yaml);
308 all_params.extend(inline_defaults);
309
310 if let Some(overrides) = param_overrides {
312 all_params.extend(overrides.iter().map(|(k, v)| (k.clone(), v.clone())));
313 }
314
315 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 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 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 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 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 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(¶ms_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 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 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 pub fn render_chart(&self, chart_spec: &ChartSpec) -> Result<ChartElement, ChartError> {
436 self.render_chart_with_size(chart_spec, None, None)
437 }
438
439 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 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 let chart_sources = self.resolve_chart_data(chart_spec, sources)?;
470
471 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 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 #[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 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 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 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 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 ¶ms_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 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 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 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 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 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 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 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 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 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 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 pub async fn transform(
931 &self,
932 fetched: FetchedChart,
933 _opts: &RenderOptions,
934 ) -> Result<PreparedChart, ChartError> {
935 let FetchedChart { spec, sources, metadata: _ } = fetched;
937
938 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 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 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 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 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 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 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<¶ms::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 fn parse_and_collect_sources(
1089 &self,
1090 yaml: &str,
1091 param_overrides: Option<¶ms::ParamValues>,
1092 ) -> Result<(ChartSpec, HashMap<String, DataTable>), ChartError> {
1093 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 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 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 pub async fn render_from_yaml_with_data_async(
1145 &self,
1146 yaml: &str,
1147 data: DataTable,
1148 ) -> Result<ChartElement, ChartError> {
1149 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 let chart_sources: IndexMap<String, DataTable> = match &chart_spec.data {
1168 DataRef::Inline(inline) => {
1169 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 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 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 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 pub fn registry(&self) -> &ChartMLRegistry {
1330 &self.registry
1331 }
1332
1333 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
1345fn 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
1361fn 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
1378fn 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
1393fn 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
1403fn 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
1425fn 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}