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;
15
16pub use error::ChartError;
17pub use spec::{parse, ChartMLSpec, Component};
18pub use element::ChartElement;
19pub use plugin::{ChartConfig, ChartRenderer, DataSource, TransformMiddleware, DatasourceResolver};
20pub use registry::ChartMLRegistry;
21pub use theme::Theme;
22
23use std::collections::HashMap;
24use crate::data::{Row, DataTable};
25use crate::spec::{ChartSpec, DataRef};
26
27pub struct ChartML {
31 registry: ChartMLRegistry,
32 sources: HashMap<String, DataTable>,
35 param_values: params::ParamValues,
37 default_palette: Option<Vec<String>>,
40 theme: theme::Theme,
43}
44
45impl ChartML {
46 pub fn new() -> Self {
48 Self {
49 registry: ChartMLRegistry::new(),
50 sources: HashMap::new(),
51 param_values: params::ParamValues::new(),
52 default_palette: None,
53 theme: theme::Theme::default(),
54 }
55 }
56
57 pub fn with_defaults() -> Self {
60 Self::new()
61 }
62
63 pub fn register_renderer(&mut self, chart_type: &str, renderer: impl ChartRenderer + 'static) {
66 self.registry.register_renderer(chart_type, renderer);
67 }
68
69 pub fn register_data_source(&mut self, name: &str, source: impl DataSource + 'static) {
70 self.registry.register_data_source(name, source);
71 }
72
73 pub fn register_transform(&mut self, middleware: impl TransformMiddleware + 'static) {
74 self.registry.register_transform(middleware);
75 }
76
77 pub fn set_datasource_resolver(&mut self, resolver: impl DatasourceResolver + 'static) {
78 self.registry.set_datasource_resolver(resolver);
79 }
80
81 pub fn set_default_palette(&mut self, colors: Vec<String>) {
84 self.default_palette = Some(colors);
85 }
86
87 pub fn set_theme(&mut self, theme: theme::Theme) {
91 self.theme = theme;
92 }
93
94 pub fn theme(&self) -> &theme::Theme {
97 &self.theme
98 }
99
100 pub fn register_component(&mut self, yaml: &str) -> Result<(), ChartError> {
106 let parsed = spec::parse(yaml)?;
107 match parsed {
108 ChartMLSpec::Single(component) => self.register_single_component(*component),
109 ChartMLSpec::Array(components) => {
110 for component in components {
111 self.register_single_component(component)?;
112 }
113 Ok(())
114 }
115 }
116 }
117
118 fn register_single_component(&mut self, component: spec::Component) -> Result<(), ChartError> {
119 match component {
120 spec::Component::Source(source_spec) => {
121 if let Some(ref rows) = source_spec.rows {
122 let json_rows = self.convert_json_rows(rows)?;
123 let data = DataTable::from_rows(&json_rows)?;
124 self.sources.insert(source_spec.name.clone(), data);
125 }
126 Ok(())
127 }
128 spec::Component::Params(params_spec) => {
129 let defaults = params::collect_param_defaults(&[¶ms_spec]);
130 self.param_values.extend(defaults);
131 Ok(())
132 }
133 spec::Component::Style(_) | spec::Component::Config(_) => {
134 Ok(())
136 }
137 spec::Component::Chart(..) => {
138 Err(ChartError::InvalidSpec(
139 "Cannot register chart components. Use render_from_yaml() instead.".into()
140 ))
141 }
142 }
143 }
144
145 pub fn register_source(&mut self, name: &str, data: DataTable) {
147 self.sources.insert(name.to_string(), data);
148 }
149
150 pub fn render_from_yaml(&self, yaml: &str) -> Result<ChartElement, ChartError> {
156 self.render_from_yaml_with_size(yaml, None, None)
157 }
158
159 pub fn render_from_yaml_with_size(
163 &self,
164 yaml: &str,
165 container_width: Option<f64>,
166 container_height: Option<f64>,
167 ) -> Result<ChartElement, ChartError> {
168 self.render_from_yaml_with_params(yaml, container_width, container_height, None)
169 }
170
171 pub fn render_from_yaml_with_params(
174 &self,
175 yaml: &str,
176 container_width: Option<f64>,
177 container_height: Option<f64>,
178 param_overrides: Option<¶ms::ParamValues>,
179 ) -> Result<ChartElement, ChartError> {
180 let mut all_params = self.param_values.clone();
183
184 let inline_defaults = params::extract_inline_param_defaults(yaml);
186 all_params.extend(inline_defaults);
187
188 if let Some(overrides) = param_overrides {
190 all_params.extend(overrides.iter().map(|(k, v)| (k.clone(), v.clone())));
191 }
192
193 let resolved_yaml = if !all_params.is_empty() {
195 params::resolve_param_references(yaml, &all_params)
196 } else {
197 yaml.to_string()
198 };
199
200 let parsed = spec::parse(&resolved_yaml)?;
201
202 let mut local_params = self.param_values.clone();
204 let mut has_local_params = false;
205 if let ChartMLSpec::Array(ref components) = parsed {
206 for component in components {
207 if let Component::Params(params_spec) = component {
208 let defaults = params::collect_param_defaults(&[params_spec]);
209 local_params.extend(defaults);
210 has_local_params = true;
211 }
212 }
213 }
214
215 let parsed = if has_local_params && local_params.len() > self.param_values.len() {
217 let re_resolved = params::resolve_param_references(yaml, &local_params);
218 spec::parse(&re_resolved)?
219 } else {
220 parsed
221 };
222
223 let mut sources: HashMap<String, DataTable> = self.sources.clone();
225
226 if let ChartMLSpec::Array(ref components) = parsed {
227 for component in components {
228 if let Component::Source(source_spec) = component {
229 if let Some(ref rows) = source_spec.rows {
230 let json_rows = self.convert_json_rows(rows)?;
231 let data = DataTable::from_rows(&json_rows)?;
232 sources.insert(source_spec.name.clone(), data);
233 }
234 }
235 }
236 }
237
238 let chart_specs: Vec<&ChartSpec> = match &parsed {
240 ChartMLSpec::Single(component) => match component.as_ref() {
241 Component::Chart(chart) => vec![chart.as_ref()],
242 _ => vec![],
243 },
244 ChartMLSpec::Array(components) => {
245 components.iter()
246 .filter_map(|c| match c {
247 Component::Chart(chart) => Some(chart.as_ref()),
248 _ => None,
249 })
250 .collect()
251 }
252 };
253
254 if chart_specs.is_empty() {
256 let params_specs: Vec<&spec::ParamsSpec> = match &parsed {
257 ChartMLSpec::Single(component) => match component.as_ref() {
258 Component::Params(p) => vec![p],
259 _ => vec![],
260 },
261 ChartMLSpec::Array(components) => {
262 components.iter()
263 .filter_map(|c| match c {
264 Component::Params(p) => Some(p),
265 _ => None,
266 })
267 .collect()
268 }
269 };
270
271 if !params_specs.is_empty() {
272 return Ok(self.render_params_ui(¶ms_specs));
273 }
274
275 return Err(ChartError::InvalidSpec("No chart or params component found".into()));
276 }
277
278 if chart_specs.len() == 1 {
279 self.render_chart_internal(chart_specs[0], container_width, container_height, &sources)
280 } else {
281 let mut children = Vec::new();
283 for spec in chart_specs {
284 match self.render_chart_internal(spec, container_width, container_height, &sources) {
285 Ok(element) => children.push(element),
286 Err(e) => {
287 children.push(ChartElement::Div {
289 class: "chartml-error".to_string(),
290 style: HashMap::new(),
291 children: vec![ChartElement::Span {
292 class: "".to_string(),
293 style: HashMap::new(),
294 content: format!("Chart error: {}", e),
295 }],
296 });
297 }
298 }
299 }
300 Ok(ChartElement::Div {
301 class: "chartml-multi-chart".to_string(),
302 style: HashMap::from([
303 ("display".to_string(), "grid".to_string()),
304 ("grid-template-columns".to_string(), format!("repeat({}, 1fr)", children.len().min(4))),
305 ("gap".to_string(), "16px".to_string()),
306 ]),
307 children,
308 })
309 }
310 }
311
312 pub fn render_chart(&self, chart_spec: &ChartSpec) -> Result<ChartElement, ChartError> {
314 self.render_chart_with_size(chart_spec, None, None)
315 }
316
317 pub fn render_chart_with_size(
320 &self,
321 chart_spec: &ChartSpec,
322 container_width: Option<f64>,
323 container_height: Option<f64>,
324 ) -> Result<ChartElement, ChartError> {
325 let sources = HashMap::new();
326 self.render_chart_internal(chart_spec, container_width, container_height, &sources)
327 }
328
329 fn render_chart_internal(
331 &self,
332 chart_spec: &ChartSpec,
333 container_width: Option<f64>,
334 container_height: Option<f64>,
335 sources: &HashMap<String, DataTable>,
336 ) -> Result<ChartElement, ChartError> {
337 let chart_type = &chart_spec.visualize.chart_type;
338
339 let renderer = self.registry.get_renderer(chart_type)
341 .ok_or_else(|| ChartError::UnknownChartType(chart_type.clone()))?;
342
343 let mut data = self.extract_data(chart_spec, sources)?;
345
346 if let Some(ref transform_spec) = chart_spec.transform {
348 let rows = data.to_rows();
349 let transformed_rows = transform::apply_transforms(rows, transform_spec)?;
350 data = DataTable::from_rows(&transformed_rows)?;
351 }
352
353 let default_height = renderer.default_dimensions(&chart_spec.visualize)
355 .map(|d| d.height)
356 .unwrap_or(400.0);
357
358 let height = chart_spec.visualize.style
359 .as_ref()
360 .and_then(|s| s.height)
361 .or(container_height)
362 .unwrap_or(default_height);
363
364 let width = chart_spec.visualize.style
365 .as_ref()
366 .and_then(|s| s.width)
367 .or(container_width)
368 .unwrap_or(800.0);
369
370 let colors = chart_spec.visualize.style
371 .as_ref()
372 .and_then(|s| s.colors.clone())
373 .or_else(|| self.default_palette.clone())
374 .unwrap_or_else(|| {
375 color::get_chart_colors(12, color::palettes::get_palette("autumn_forest"))
376 });
377
378 let config = ChartConfig {
379 visualize: chart_spec.visualize.clone(),
380 title: chart_spec.title.clone(),
381 width,
382 height,
383 colors,
384 theme: self.theme.clone(),
385 };
386
387 renderer.render(&data, &config)
388 }
389
390 fn extract_data(&self, chart_spec: &ChartSpec, sources: &HashMap<String, DataTable>) -> Result<DataTable, ChartError> {
392 match &chart_spec.data {
393 DataRef::Inline(inline) => {
394 let rows = inline.rows.as_ref()
395 .ok_or_else(|| ChartError::DataError("Inline data source has no rows".into()))?;
396 let json_rows = self.convert_json_rows(rows)?;
397 DataTable::from_rows(&json_rows)
398 }
399 DataRef::Named(name) => {
400 sources.get(name)
401 .cloned()
402 .ok_or_else(|| ChartError::DataError(
403 format!("Named data source '{}' not found", name)
404 ))
405 }
406 }
407 }
408
409 fn convert_json_rows(&self, rows: &[serde_json::Value]) -> Result<Vec<Row>, ChartError> {
411 let mut result = Vec::with_capacity(rows.len());
412 for value in rows {
413 match value {
414 serde_json::Value::Object(map) => {
415 let row: Row = map.iter()
416 .map(|(k, v)| (k.clone(), v.clone()))
417 .collect();
418 result.push(row);
419 }
420 _ => return Err(ChartError::DataError(
421 "Data rows must be objects".into()
422 )),
423 }
424 }
425 Ok(result)
426 }
427
428 fn render_params_ui(&self, params_specs: &[&spec::ParamsSpec]) -> ChartElement {
431 let mut param_groups = Vec::new();
432
433 for params_spec in params_specs {
434 for param in ¶ms_spec.params {
435 let control = self.render_param_control(param);
436 param_groups.push(ChartElement::Div {
437 class: "chartml-param-group".to_string(),
438 style: HashMap::new(),
439 children: vec![control],
440 });
441 }
442 }
443
444 ChartElement::Div {
445 class: "chartml-params".to_string(),
446 style: HashMap::from([
447 ("display".to_string(), "flex".to_string()),
448 ("flex-wrap".to_string(), "wrap".to_string()),
449 ("gap".to_string(), "12px".to_string()),
450 ("padding".to_string(), "12px 0".to_string()),
451 ]),
452 children: param_groups,
453 }
454 }
455
456 fn render_param_control(&self, param: &spec::ParamDef) -> ChartElement {
458 let label = ChartElement::Span {
459 class: "chartml-param-label".to_string(),
460 style: HashMap::from([
461 ("font-size".to_string(), "12px".to_string()),
462 ("font-weight".to_string(), "600".to_string()),
463 ("color".to_string(), "#555".to_string()),
464 ("display".to_string(), "block".to_string()),
465 ("margin-bottom".to_string(), "4px".to_string()),
466 ]),
467 content: param.label.clone(),
468 };
469
470 let control = match param.param_type.as_str() {
471 "multiselect" => {
472 let _options_text = param.options.as_ref()
473 .map(|opts| opts.join(", "))
474 .unwrap_or_default();
475 let default_text = param.default.as_ref()
476 .map(|d| match d {
477 serde_json::Value::Array(arr) => arr.iter()
478 .filter_map(|v| v.as_str())
479 .collect::<Vec<_>>()
480 .join(", "),
481 _ => d.to_string(),
482 })
483 .unwrap_or_default();
484 ChartElement::Div {
485 class: "chartml-param-control chartml-param-multiselect".to_string(),
486 style: HashMap::from([
487 ("background".to_string(), "#f5f5f5".to_string()),
488 ("border".to_string(), "1px solid #ddd".to_string()),
489 ("border-radius".to_string(), "4px".to_string()),
490 ("padding".to_string(), "6px 10px".to_string()),
491 ("font-size".to_string(), "13px".to_string()),
492 ("color".to_string(), self.theme.text.clone()),
493 ("min-width".to_string(), "140px".to_string()),
494 ]),
495 children: vec![ChartElement::Span {
496 class: "".to_string(),
497 style: HashMap::new(),
498 content: default_text,
499 }],
500 }
501 }
502 "select" => {
503 let default_text = param.default.as_ref()
504 .and_then(|d| d.as_str())
505 .unwrap_or("")
506 .to_string();
507 ChartElement::Div {
508 class: "chartml-param-control chartml-param-select".to_string(),
509 style: HashMap::from([
510 ("background".to_string(), "#f5f5f5".to_string()),
511 ("border".to_string(), "1px solid #ddd".to_string()),
512 ("border-radius".to_string(), "4px".to_string()),
513 ("padding".to_string(), "6px 10px".to_string()),
514 ("font-size".to_string(), "13px".to_string()),
515 ("color".to_string(), self.theme.text.clone()),
516 ("min-width".to_string(), "120px".to_string()),
517 ]),
518 children: vec![ChartElement::Span {
519 class: "".to_string(),
520 style: HashMap::new(),
521 content: format!("{} ▾", default_text),
522 }],
523 }
524 }
525 "daterange" => {
526 let default_text = param.default.as_ref()
527 .map(|d| {
528 let start = d.get("start").and_then(|v| v.as_str()).unwrap_or("");
529 let end = d.get("end").and_then(|v| v.as_str()).unwrap_or("");
530 format!("{} → {}", start, end)
531 })
532 .unwrap_or_default();
533 ChartElement::Div {
534 class: "chartml-param-control chartml-param-daterange".to_string(),
535 style: HashMap::from([
536 ("background".to_string(), "#f5f5f5".to_string()),
537 ("border".to_string(), "1px solid #ddd".to_string()),
538 ("border-radius".to_string(), "4px".to_string()),
539 ("padding".to_string(), "6px 10px".to_string()),
540 ("font-size".to_string(), "13px".to_string()),
541 ("color".to_string(), self.theme.text.clone()),
542 ]),
543 children: vec![ChartElement::Span {
544 class: "".to_string(),
545 style: HashMap::new(),
546 content: default_text,
547 }],
548 }
549 }
550 "number" => {
551 let default_text = param.default.as_ref()
552 .map(|d| d.to_string())
553 .unwrap_or_default();
554 ChartElement::Div {
555 class: "chartml-param-control chartml-param-number".to_string(),
556 style: HashMap::from([
557 ("background".to_string(), "#f5f5f5".to_string()),
558 ("border".to_string(), "1px solid #ddd".to_string()),
559 ("border-radius".to_string(), "4px".to_string()),
560 ("padding".to_string(), "6px 10px".to_string()),
561 ("font-size".to_string(), "13px".to_string()),
562 ("color".to_string(), self.theme.text.clone()),
563 ("min-width".to_string(), "80px".to_string()),
564 ]),
565 children: vec![ChartElement::Span {
566 class: "".to_string(),
567 style: HashMap::new(),
568 content: default_text,
569 }],
570 }
571 }
572 _ => {
573 let default_text = param.default.as_ref()
574 .map(|d| d.to_string())
575 .unwrap_or_default();
576 ChartElement::Div {
577 class: "chartml-param-control chartml-param-text".to_string(),
578 style: HashMap::from([
579 ("background".to_string(), "#f5f5f5".to_string()),
580 ("border".to_string(), "1px solid #ddd".to_string()),
581 ("border-radius".to_string(), "4px".to_string()),
582 ("padding".to_string(), "6px 10px".to_string()),
583 ("font-size".to_string(), "13px".to_string()),
584 ("color".to_string(), self.theme.text.clone()),
585 ]),
586 children: vec![ChartElement::Span {
587 class: "".to_string(),
588 style: HashMap::new(),
589 content: param.placeholder.clone().unwrap_or(default_text),
590 }],
591 }
592 }
593 };
594
595 ChartElement::Div {
596 class: "chartml-param-item".to_string(),
597 style: HashMap::from([
598 ("display".to_string(), "flex".to_string()),
599 ("flex-direction".to_string(), "column".to_string()),
600 ]),
601 children: vec![label, control],
602 }
603 }
604
605 pub async fn render_from_yaml_with_params_async(
611 &self,
612 yaml: &str,
613 container_width: Option<f64>,
614 container_height: Option<f64>,
615 param_overrides: Option<¶ms::ParamValues>,
616 ) -> Result<ChartElement, ChartError> {
617 let mut all_params = self.param_values.clone();
619 let inline_defaults = params::extract_inline_param_defaults(yaml);
620 all_params.extend(inline_defaults);
621 if let Some(overrides) = param_overrides {
622 all_params.extend(overrides.iter().map(|(k, v)| (k.clone(), v.clone())));
623 }
624 let resolved_yaml = if !all_params.is_empty() {
625 params::resolve_param_references(yaml, &all_params)
626 } else {
627 yaml.to_string()
628 };
629
630 let parsed = spec::parse(&resolved_yaml)?;
631
632 let mut sources: HashMap<String, DataTable> = self.sources.clone();
634 if let ChartMLSpec::Array(ref components) = parsed {
635 for component in components {
636 if let Component::Source(source_spec) = component {
637 if let Some(ref rows) = source_spec.rows {
638 let json_rows = self.convert_json_rows(rows)?;
639 let data = DataTable::from_rows(&json_rows)?;
640 sources.insert(source_spec.name.clone(), data);
641 }
642 }
643 }
644 }
645
646 let chart_spec: &ChartSpec = match &parsed {
648 ChartMLSpec::Single(component) => match component.as_ref() {
649 Component::Chart(chart) => chart.as_ref(),
650 _ => return Err(ChartError::InvalidSpec("No chart component found".into())),
651 },
652 ChartMLSpec::Array(components) => {
653 components.iter()
654 .find_map(|c| match c {
655 Component::Chart(chart) => Some(chart.as_ref()),
656 _ => None,
657 })
658 .ok_or_else(|| ChartError::InvalidSpec("No chart component found".into()))?
659 }
660 };
661
662 let chart_data = self.resolve_chart_data(chart_spec, &sources)?;
664
665 let transformed_data = if let Some(ref transform_spec) = chart_spec.transform {
667 if let Some(middleware) = self.registry.get_transform() {
668 let context = plugin::TransformContext::default();
669 let result = middleware.transform(chart_data, transform_spec, &context).await?;
670 result.data
671 } else {
672 let rows = chart_data.to_rows();
675 let transformed_rows = transform::apply_transforms(rows, transform_spec)?;
676 DataTable::from_rows(&transformed_rows)?
677 }
678 } else {
679 chart_data
680 };
681
682 self.build_and_render(chart_spec, &transformed_data, container_width, container_height)
684 }
685
686 pub async fn render_from_yaml_with_data_async(
689 &self,
690 yaml: &str,
691 data: DataTable,
692 ) -> Result<ChartElement, ChartError> {
693 let parsed = spec::parse(yaml)?;
695 let chart_spec: &ChartSpec = match &parsed {
696 ChartMLSpec::Single(component) => match component.as_ref() {
697 Component::Chart(chart) => chart.as_ref(),
698 _ => return Err(ChartError::InvalidSpec("No chart component found".into())),
699 },
700 ChartMLSpec::Array(components) => {
701 components.iter()
702 .find_map(|c| match c { Component::Chart(chart) => Some(chart.as_ref()), _ => None })
703 .ok_or_else(|| ChartError::InvalidSpec("No chart component found".into()))?
704 }
705 };
706
707 let chart_data = match &chart_spec.data {
708 DataRef::Inline(inline) => {
709 let inline_rows = inline.rows.as_ref()
710 .map(|r| self.convert_json_rows(r))
711 .transpose()?
712 .unwrap_or_default();
713 let inline_table = DataTable::from_rows(&inline_rows)?;
714 if inline_table.is_empty() && !data.is_empty() { data } else { inline_table }
715 }
716 DataRef::Named(name) => {
717 self.sources.get(name).cloned()
718 .ok_or_else(|| ChartError::DataError(format!("Source '{}' not found", name)))?
719 }
720 };
721
722 let transformed_data = if let Some(ref transform_spec) = chart_spec.transform {
723 if let Some(middleware) = self.registry.get_transform() {
724 let context = plugin::TransformContext::default();
725 let result = middleware.transform(chart_data, transform_spec, &context).await?;
726 result.data
727 } else if transform_spec.sql.is_some() || transform_spec.forecast.is_some() {
728 return Err(ChartError::InvalidSpec(
729 "Spec uses sql or forecast transforms but no TransformMiddleware is registered".into()
730 ));
731 } else {
732 let rows = chart_data.to_rows();
734 let transformed_rows = transform::apply_transforms(rows, transform_spec)?;
735 DataTable::from_rows(&transformed_rows)?
736 }
737 } else {
738 chart_data
739 };
740
741 self.build_and_render(chart_spec, &transformed_data, None, None)
742 }
743
744 fn resolve_chart_data(&self, chart_spec: &ChartSpec, sources: &HashMap<String, DataTable>) -> Result<DataTable, ChartError> {
746 match &chart_spec.data {
747 DataRef::Inline(inline) => {
748 let json_rows = inline.rows.as_ref()
749 .map(|r| self.convert_json_rows(r))
750 .transpose()?
751 .unwrap_or_default();
752 DataTable::from_rows(&json_rows)
753 }
754 DataRef::Named(name) => {
755 sources.get(name)
756 .cloned()
757 .ok_or_else(|| ChartError::DataError(
758 format!("Named data source '{}' not found", name)
759 ))
760 }
761 }
762 }
763
764 fn build_and_render(
766 &self,
767 chart_spec: &ChartSpec,
768 data: &DataTable,
769 container_width: Option<f64>,
770 container_height: Option<f64>,
771 ) -> Result<ChartElement, ChartError> {
772 let chart_type = &chart_spec.visualize.chart_type;
773 let renderer = self.registry.get_renderer(chart_type)
774 .ok_or_else(|| ChartError::UnknownChartType(chart_type.clone()))?;
775
776 let default_height = renderer.default_dimensions(&chart_spec.visualize)
777 .map(|d| d.height)
778 .unwrap_or(400.0);
779
780 let height = chart_spec.visualize.style.as_ref()
781 .and_then(|s| s.height)
782 .unwrap_or(container_height.unwrap_or(default_height));
783
784 let width = chart_spec.visualize.style.as_ref()
785 .and_then(|s| s.width)
786 .unwrap_or(container_width.unwrap_or(800.0));
787
788 let colors = chart_spec.visualize.style.as_ref()
789 .and_then(|s| s.colors.clone())
790 .or_else(|| self.default_palette.clone())
791 .unwrap_or_else(|| {
792 color::get_chart_colors(12, color::palettes::get_palette("autumn_forest"))
793 });
794
795 let config = plugin::ChartConfig {
796 visualize: chart_spec.visualize.clone(),
797 title: chart_spec.title.clone(),
798 width,
799 height,
800 colors,
801 theme: self.theme.clone(),
802 };
803
804 renderer.render(data, &config)
805 }
806
807 pub fn registry(&self) -> &ChartMLRegistry {
809 &self.registry
810 }
811
812 pub fn registry_mut(&mut self) -> &mut ChartMLRegistry {
814 &mut self.registry
815 }
816}
817
818impl Default for ChartML {
819 fn default() -> Self {
820 Self::new()
821 }
822}
823
824#[cfg(test)]
825mod tests {
826 use super::*;
827 use crate::element::ViewBox;
828
829 struct MockRenderer;
830
831 impl ChartRenderer for MockRenderer {
832 fn render(&self, _data: &DataTable, _config: &ChartConfig) -> Result<ChartElement, ChartError> {
833 Ok(ChartElement::Svg {
834 viewbox: ViewBox::new(0.0, 0.0, 800.0, 400.0),
835 width: Some(800.0),
836 height: Some(400.0),
837 class: "mock".to_string(),
838 children: vec![],
839 })
840 }
841 }
842
843 #[test]
844 fn chartml_render_from_yaml_with_mock() {
845 let mut chartml = ChartML::new();
846 chartml.register_renderer("bar", MockRenderer);
847
848 let yaml = r#"
849type: chart
850version: 1
851title: Test
852data:
853 provider: inline
854 rows:
855 - { x: "A", y: 10 }
856 - { x: "B", y: 20 }
857visualize:
858 type: bar
859 columns: x
860 rows: y
861"#;
862
863 let result = chartml.render_from_yaml(yaml);
864 assert!(result.is_ok(), "render failed: {:?}", result.err());
865 }
866
867 #[test]
868 fn chartml_unknown_chart_type() {
869 let chartml = ChartML::new();
870 let yaml = r#"
871type: chart
872version: 1
873data:
874 provider: inline
875 rows: []
876visualize:
877 type: unknown_type
878 columns: x
879 rows: y
880"#;
881 let result = chartml.render_from_yaml(yaml);
882 assert!(result.is_err());
883 }
884
885 #[test]
886 fn chartml_named_source_resolution() {
887 let mut chartml = ChartML::new();
888 chartml.register_renderer("bar", MockRenderer);
889
890 let yaml = r#"---
891type: source
892version: 1
893name: q1_sales
894provider: inline
895rows:
896 - { month: "Jan", revenue: 100 }
897 - { month: "Feb", revenue: 200 }
898---
899type: chart
900version: 1
901title: Revenue by Month
902data: q1_sales
903visualize:
904 type: bar
905 columns: month
906 rows: revenue
907"#;
908
909 let result = chartml.render_from_yaml(yaml);
910 assert!(result.is_ok(), "named source render failed: {:?}", result.err());
911 }
912
913 #[test]
914 fn chartml_named_source_not_found() {
915 let mut chartml = ChartML::new();
916 chartml.register_renderer("bar", MockRenderer);
917
918 let yaml = r#"
919type: chart
920version: 1
921data: nonexistent_source
922visualize:
923 type: bar
924 columns: x
925 rows: y
926"#;
927
928 let result = chartml.render_from_yaml(yaml);
929 assert!(result.is_err());
930 let err = result.unwrap_err().to_string();
931 assert!(err.contains("not found"), "Expected 'not found' error, got: {}", err);
932 }
933
934 #[test]
935 fn chartml_multi_chart_rendering() {
936 let mut chartml = ChartML::new();
937 chartml.register_renderer("bar", MockRenderer);
938
939 let yaml = r#"
940- type: chart
941 version: 1
942 title: Chart A
943 data:
944 provider: inline
945 rows:
946 - { x: "A", y: 10 }
947 visualize:
948 type: bar
949 columns: x
950 rows: y
951- type: chart
952 version: 1
953 title: Chart B
954 data:
955 provider: inline
956 rows:
957 - { x: "B", y: 20 }
958 visualize:
959 type: bar
960 columns: x
961 rows: y
962"#;
963
964 let result = chartml.render_from_yaml(yaml);
965 assert!(result.is_ok(), "multi-chart render failed: {:?}", result.err());
966 match result.unwrap() {
967 ChartElement::Div { class, children, .. } => {
968 assert_eq!(class, "chartml-multi-chart");
969 assert_eq!(children.len(), 2);
970 }
971 other => panic!("Expected Div wrapper, got {:?}", other),
972 }
973 }
974
975 #[test]
976 fn chartml_named_source_with_transform() {
977 let mut chartml = ChartML::new();
978 chartml.register_renderer("bar", MockRenderer);
979
980 let yaml = r#"---
981type: source
982version: 1
983name: raw_sales
984provider: inline
985rows:
986 - { region: "North", revenue: 100 }
987 - { region: "North", revenue: 200 }
988 - { region: "South", revenue: 150 }
989---
990type: chart
991version: 1
992title: Revenue by Region
993data: raw_sales
994transform:
995 aggregate:
996 dimensions:
997 - region
998 measures:
999 - column: revenue
1000 aggregation: sum
1001 name: total_revenue
1002 sort:
1003 - field: total_revenue
1004 direction: desc
1005visualize:
1006 type: bar
1007 columns: region
1008 rows: total_revenue
1009"#;
1010
1011 let result = chartml.render_from_yaml(yaml);
1012 assert!(result.is_ok(), "transform pipeline render failed: {:?}", result.err());
1013 }
1014
1015 #[test]
1016 fn chartml_multi_chart_with_shared_source() {
1017 let mut chartml = ChartML::new();
1018 chartml.register_renderer("bar", MockRenderer);
1019 chartml.register_renderer("metric", MockRenderer);
1020
1021 let yaml = r#"---
1022type: source
1023version: 1
1024name: kpis
1025provider: inline
1026rows:
1027 - { totalRevenue: 1500000, previousRevenue: 1200000 }
1028---
1029- type: chart
1030 version: 1
1031 title: Revenue
1032 data: kpis
1033 visualize:
1034 type: metric
1035 value: totalRevenue
1036- type: chart
1037 version: 1
1038 title: Prev Revenue
1039 data: kpis
1040 visualize:
1041 type: metric
1042 value: previousRevenue
1043"#;
1044
1045 let result = chartml.render_from_yaml(yaml);
1046 assert!(result.is_ok(), "multi-chart shared source failed: {:?}", result.err());
1047 }
1048}