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 DataRef::NamedMap(_) => {
407 Err(ChartError::InvalidSpec(
408 "Multi-source `data:` map requires each source to be pre-fetched and registered by name, then `data:` rewritten to that name. The core renderer cannot fetch sources directly.".into()
409 ))
410 }
411 }
412 }
413
414 fn convert_json_rows(&self, rows: &[serde_json::Value]) -> Result<Vec<Row>, ChartError> {
416 let mut result = Vec::with_capacity(rows.len());
417 for value in rows {
418 match value {
419 serde_json::Value::Object(map) => {
420 let row: Row = map.iter()
421 .map(|(k, v)| (k.clone(), v.clone()))
422 .collect();
423 result.push(row);
424 }
425 _ => return Err(ChartError::DataError(
426 "Data rows must be objects".into()
427 )),
428 }
429 }
430 Ok(result)
431 }
432
433 fn render_params_ui(&self, params_specs: &[&spec::ParamsSpec]) -> ChartElement {
436 let mut param_groups = Vec::new();
437
438 for params_spec in params_specs {
439 for param in ¶ms_spec.params {
440 let control = self.render_param_control(param);
441 param_groups.push(ChartElement::Div {
442 class: "chartml-param-group".to_string(),
443 style: HashMap::new(),
444 children: vec![control],
445 });
446 }
447 }
448
449 ChartElement::Div {
450 class: "chartml-params".to_string(),
451 style: HashMap::from([
452 ("display".to_string(), "flex".to_string()),
453 ("flex-wrap".to_string(), "wrap".to_string()),
454 ("gap".to_string(), "12px".to_string()),
455 ("padding".to_string(), "12px 0".to_string()),
456 ]),
457 children: param_groups,
458 }
459 }
460
461 fn render_param_control(&self, param: &spec::ParamDef) -> ChartElement {
463 let label = ChartElement::Span {
464 class: "chartml-param-label".to_string(),
465 style: HashMap::from([
466 ("font-size".to_string(), "12px".to_string()),
467 ("font-weight".to_string(), "600".to_string()),
468 ("color".to_string(), "#555".to_string()),
469 ("display".to_string(), "block".to_string()),
470 ("margin-bottom".to_string(), "4px".to_string()),
471 ]),
472 content: param.label.clone(),
473 };
474
475 let control = match param.param_type.as_str() {
476 "multiselect" => {
477 let _options_text = param.options.as_ref()
478 .map(|opts| opts.join(", "))
479 .unwrap_or_default();
480 let default_text = param.default.as_ref()
481 .map(|d| match d {
482 serde_json::Value::Array(arr) => arr.iter()
483 .filter_map(|v| v.as_str())
484 .collect::<Vec<_>>()
485 .join(", "),
486 _ => d.to_string(),
487 })
488 .unwrap_or_default();
489 ChartElement::Div {
490 class: "chartml-param-control chartml-param-multiselect".to_string(),
491 style: HashMap::from([
492 ("background".to_string(), "#f5f5f5".to_string()),
493 ("border".to_string(), "1px solid #ddd".to_string()),
494 ("border-radius".to_string(), "4px".to_string()),
495 ("padding".to_string(), "6px 10px".to_string()),
496 ("font-size".to_string(), "13px".to_string()),
497 ("color".to_string(), self.theme.text.clone()),
498 ("min-width".to_string(), "140px".to_string()),
499 ]),
500 children: vec![ChartElement::Span {
501 class: "".to_string(),
502 style: HashMap::new(),
503 content: default_text,
504 }],
505 }
506 }
507 "select" => {
508 let default_text = param.default.as_ref()
509 .and_then(|d| d.as_str())
510 .unwrap_or("")
511 .to_string();
512 ChartElement::Div {
513 class: "chartml-param-control chartml-param-select".to_string(),
514 style: HashMap::from([
515 ("background".to_string(), "#f5f5f5".to_string()),
516 ("border".to_string(), "1px solid #ddd".to_string()),
517 ("border-radius".to_string(), "4px".to_string()),
518 ("padding".to_string(), "6px 10px".to_string()),
519 ("font-size".to_string(), "13px".to_string()),
520 ("color".to_string(), self.theme.text.clone()),
521 ("min-width".to_string(), "120px".to_string()),
522 ]),
523 children: vec![ChartElement::Span {
524 class: "".to_string(),
525 style: HashMap::new(),
526 content: format!("{} ▾", default_text),
527 }],
528 }
529 }
530 "daterange" => {
531 let default_text = param.default.as_ref()
532 .map(|d| {
533 let start = d.get("start").and_then(|v| v.as_str()).unwrap_or("");
534 let end = d.get("end").and_then(|v| v.as_str()).unwrap_or("");
535 format!("{} → {}", start, end)
536 })
537 .unwrap_or_default();
538 ChartElement::Div {
539 class: "chartml-param-control chartml-param-daterange".to_string(),
540 style: HashMap::from([
541 ("background".to_string(), "#f5f5f5".to_string()),
542 ("border".to_string(), "1px solid #ddd".to_string()),
543 ("border-radius".to_string(), "4px".to_string()),
544 ("padding".to_string(), "6px 10px".to_string()),
545 ("font-size".to_string(), "13px".to_string()),
546 ("color".to_string(), self.theme.text.clone()),
547 ]),
548 children: vec![ChartElement::Span {
549 class: "".to_string(),
550 style: HashMap::new(),
551 content: default_text,
552 }],
553 }
554 }
555 "number" => {
556 let default_text = param.default.as_ref()
557 .map(|d| d.to_string())
558 .unwrap_or_default();
559 ChartElement::Div {
560 class: "chartml-param-control chartml-param-number".to_string(),
561 style: HashMap::from([
562 ("background".to_string(), "#f5f5f5".to_string()),
563 ("border".to_string(), "1px solid #ddd".to_string()),
564 ("border-radius".to_string(), "4px".to_string()),
565 ("padding".to_string(), "6px 10px".to_string()),
566 ("font-size".to_string(), "13px".to_string()),
567 ("color".to_string(), self.theme.text.clone()),
568 ("min-width".to_string(), "80px".to_string()),
569 ]),
570 children: vec![ChartElement::Span {
571 class: "".to_string(),
572 style: HashMap::new(),
573 content: default_text,
574 }],
575 }
576 }
577 _ => {
578 let default_text = param.default.as_ref()
579 .map(|d| d.to_string())
580 .unwrap_or_default();
581 ChartElement::Div {
582 class: "chartml-param-control chartml-param-text".to_string(),
583 style: HashMap::from([
584 ("background".to_string(), "#f5f5f5".to_string()),
585 ("border".to_string(), "1px solid #ddd".to_string()),
586 ("border-radius".to_string(), "4px".to_string()),
587 ("padding".to_string(), "6px 10px".to_string()),
588 ("font-size".to_string(), "13px".to_string()),
589 ("color".to_string(), self.theme.text.clone()),
590 ]),
591 children: vec![ChartElement::Span {
592 class: "".to_string(),
593 style: HashMap::new(),
594 content: param.placeholder.clone().unwrap_or(default_text),
595 }],
596 }
597 }
598 };
599
600 ChartElement::Div {
601 class: "chartml-param-item".to_string(),
602 style: HashMap::from([
603 ("display".to_string(), "flex".to_string()),
604 ("flex-direction".to_string(), "column".to_string()),
605 ]),
606 children: vec![label, control],
607 }
608 }
609
610 pub async fn render_from_yaml_with_params_async(
616 &self,
617 yaml: &str,
618 container_width: Option<f64>,
619 container_height: Option<f64>,
620 param_overrides: Option<¶ms::ParamValues>,
621 ) -> Result<ChartElement, ChartError> {
622 let mut all_params = self.param_values.clone();
624 let inline_defaults = params::extract_inline_param_defaults(yaml);
625 all_params.extend(inline_defaults);
626 if let Some(overrides) = param_overrides {
627 all_params.extend(overrides.iter().map(|(k, v)| (k.clone(), v.clone())));
628 }
629 let resolved_yaml = if !all_params.is_empty() {
630 params::resolve_param_references(yaml, &all_params)
631 } else {
632 yaml.to_string()
633 };
634
635 let parsed = spec::parse(&resolved_yaml)?;
636
637 let mut sources: HashMap<String, DataTable> = self.sources.clone();
639 if let ChartMLSpec::Array(ref components) = parsed {
640 for component in components {
641 if let Component::Source(source_spec) = component {
642 if let Some(ref rows) = source_spec.rows {
643 let json_rows = self.convert_json_rows(rows)?;
644 let data = DataTable::from_rows(&json_rows)?;
645 sources.insert(source_spec.name.clone(), data);
646 }
647 }
648 }
649 }
650
651 let chart_spec: &ChartSpec = match &parsed {
653 ChartMLSpec::Single(component) => match component.as_ref() {
654 Component::Chart(chart) => chart.as_ref(),
655 _ => return Err(ChartError::InvalidSpec("No chart component found".into())),
656 },
657 ChartMLSpec::Array(components) => {
658 components.iter()
659 .find_map(|c| match c {
660 Component::Chart(chart) => Some(chart.as_ref()),
661 _ => None,
662 })
663 .ok_or_else(|| ChartError::InvalidSpec("No chart component found".into()))?
664 }
665 };
666
667 let chart_data = self.resolve_chart_data(chart_spec, &sources)?;
669
670 let transformed_data = if let Some(ref transform_spec) = chart_spec.transform {
672 if let Some(middleware) = self.registry.get_transform() {
673 let context = plugin::TransformContext::default();
674 let result = middleware.transform(chart_data, transform_spec, &context).await?;
675 result.data
676 } else {
677 let rows = chart_data.to_rows();
680 let transformed_rows = transform::apply_transforms(rows, transform_spec)?;
681 DataTable::from_rows(&transformed_rows)?
682 }
683 } else {
684 chart_data
685 };
686
687 self.build_and_render(chart_spec, &transformed_data, container_width, container_height)
689 }
690
691 pub async fn render_from_yaml_with_data_async(
694 &self,
695 yaml: &str,
696 data: DataTable,
697 ) -> Result<ChartElement, ChartError> {
698 let parsed = spec::parse(yaml)?;
700 let chart_spec: &ChartSpec = match &parsed {
701 ChartMLSpec::Single(component) => match component.as_ref() {
702 Component::Chart(chart) => chart.as_ref(),
703 _ => return Err(ChartError::InvalidSpec("No chart component found".into())),
704 },
705 ChartMLSpec::Array(components) => {
706 components.iter()
707 .find_map(|c| match c { Component::Chart(chart) => Some(chart.as_ref()), _ => None })
708 .ok_or_else(|| ChartError::InvalidSpec("No chart component found".into()))?
709 }
710 };
711
712 let chart_data = match &chart_spec.data {
713 DataRef::Inline(inline) => {
714 let inline_rows = inline.rows.as_ref()
715 .map(|r| self.convert_json_rows(r))
716 .transpose()?
717 .unwrap_or_default();
718 let inline_table = DataTable::from_rows(&inline_rows)?;
719 if inline_table.is_empty() && !data.is_empty() { data } else { inline_table }
720 }
721 DataRef::Named(name) => {
722 self.sources.get(name).cloned()
723 .ok_or_else(|| ChartError::DataError(format!("Source '{}' not found", name)))?
724 }
725 DataRef::NamedMap(_) => {
726 return Err(ChartError::InvalidSpec(
727 "Multi-source `data:` map requires each source to be pre-fetched and registered by name, then `data:` rewritten to that name.".into()
728 ));
729 }
730 };
731
732 let transformed_data = if let Some(ref transform_spec) = chart_spec.transform {
733 if let Some(middleware) = self.registry.get_transform() {
734 let context = plugin::TransformContext::default();
735 let result = middleware.transform(chart_data, transform_spec, &context).await?;
736 result.data
737 } else if transform_spec.sql.is_some() || transform_spec.forecast.is_some() {
738 return Err(ChartError::InvalidSpec(
739 "Spec uses sql or forecast transforms but no TransformMiddleware is registered".into()
740 ));
741 } else {
742 let rows = chart_data.to_rows();
744 let transformed_rows = transform::apply_transforms(rows, transform_spec)?;
745 DataTable::from_rows(&transformed_rows)?
746 }
747 } else {
748 chart_data
749 };
750
751 self.build_and_render(chart_spec, &transformed_data, None, None)
752 }
753
754 fn resolve_chart_data(&self, chart_spec: &ChartSpec, sources: &HashMap<String, DataTable>) -> Result<DataTable, ChartError> {
756 match &chart_spec.data {
757 DataRef::Inline(inline) => {
758 let json_rows = inline.rows.as_ref()
759 .map(|r| self.convert_json_rows(r))
760 .transpose()?
761 .unwrap_or_default();
762 DataTable::from_rows(&json_rows)
763 }
764 DataRef::Named(name) => {
765 sources.get(name)
766 .cloned()
767 .ok_or_else(|| ChartError::DataError(
768 format!("Named data source '{}' not found", name)
769 ))
770 }
771 DataRef::NamedMap(_) => {
772 Err(ChartError::InvalidSpec(
773 "Multi-source `data:` map requires each source to be pre-fetched and registered by name, then `data:` rewritten to that name.".into()
774 ))
775 }
776 }
777 }
778
779 fn build_and_render(
781 &self,
782 chart_spec: &ChartSpec,
783 data: &DataTable,
784 container_width: Option<f64>,
785 container_height: Option<f64>,
786 ) -> Result<ChartElement, ChartError> {
787 let chart_type = &chart_spec.visualize.chart_type;
788 let renderer = self.registry.get_renderer(chart_type)
789 .ok_or_else(|| ChartError::UnknownChartType(chart_type.clone()))?;
790
791 let default_height = renderer.default_dimensions(&chart_spec.visualize)
792 .map(|d| d.height)
793 .unwrap_or(400.0);
794
795 let height = chart_spec.visualize.style.as_ref()
796 .and_then(|s| s.height)
797 .unwrap_or(container_height.unwrap_or(default_height));
798
799 let width = chart_spec.visualize.style.as_ref()
800 .and_then(|s| s.width)
801 .unwrap_or(container_width.unwrap_or(800.0));
802
803 let colors = chart_spec.visualize.style.as_ref()
804 .and_then(|s| s.colors.clone())
805 .or_else(|| self.default_palette.clone())
806 .unwrap_or_else(|| {
807 color::get_chart_colors(12, color::palettes::get_palette("autumn_forest"))
808 });
809
810 let config = plugin::ChartConfig {
811 visualize: chart_spec.visualize.clone(),
812 title: chart_spec.title.clone(),
813 width,
814 height,
815 colors,
816 theme: self.theme.clone(),
817 };
818
819 renderer.render(data, &config)
820 }
821
822 pub fn registry(&self) -> &ChartMLRegistry {
824 &self.registry
825 }
826
827 pub fn registry_mut(&mut self) -> &mut ChartMLRegistry {
829 &mut self.registry
830 }
831}
832
833impl Default for ChartML {
834 fn default() -> Self {
835 Self::new()
836 }
837}
838
839#[cfg(test)]
840mod tests {
841 use super::*;
842 use crate::element::ViewBox;
843
844 struct MockRenderer;
845
846 impl ChartRenderer for MockRenderer {
847 fn render(&self, _data: &DataTable, _config: &ChartConfig) -> Result<ChartElement, ChartError> {
848 Ok(ChartElement::Svg {
849 viewbox: ViewBox::new(0.0, 0.0, 800.0, 400.0),
850 width: Some(800.0),
851 height: Some(400.0),
852 class: "mock".to_string(),
853 children: vec![],
854 })
855 }
856 }
857
858 #[test]
859 fn chartml_render_from_yaml_with_mock() {
860 let mut chartml = ChartML::new();
861 chartml.register_renderer("bar", MockRenderer);
862
863 let yaml = r#"
864type: chart
865version: 1
866title: Test
867data:
868 provider: inline
869 rows:
870 - { x: "A", y: 10 }
871 - { x: "B", y: 20 }
872visualize:
873 type: bar
874 columns: x
875 rows: y
876"#;
877
878 let result = chartml.render_from_yaml(yaml);
879 assert!(result.is_ok(), "render failed: {:?}", result.err());
880 }
881
882 #[test]
883 fn chartml_unknown_chart_type() {
884 let chartml = ChartML::new();
885 let yaml = r#"
886type: chart
887version: 1
888data:
889 provider: inline
890 rows: []
891visualize:
892 type: unknown_type
893 columns: x
894 rows: y
895"#;
896 let result = chartml.render_from_yaml(yaml);
897 assert!(result.is_err());
898 }
899
900 #[test]
901 fn chartml_named_source_resolution() {
902 let mut chartml = ChartML::new();
903 chartml.register_renderer("bar", MockRenderer);
904
905 let yaml = r#"---
906type: source
907version: 1
908name: q1_sales
909provider: inline
910rows:
911 - { month: "Jan", revenue: 100 }
912 - { month: "Feb", revenue: 200 }
913---
914type: chart
915version: 1
916title: Revenue by Month
917data: q1_sales
918visualize:
919 type: bar
920 columns: month
921 rows: revenue
922"#;
923
924 let result = chartml.render_from_yaml(yaml);
925 assert!(result.is_ok(), "named source render failed: {:?}", result.err());
926 }
927
928 #[test]
929 fn chartml_named_source_not_found() {
930 let mut chartml = ChartML::new();
931 chartml.register_renderer("bar", MockRenderer);
932
933 let yaml = r#"
934type: chart
935version: 1
936data: nonexistent_source
937visualize:
938 type: bar
939 columns: x
940 rows: y
941"#;
942
943 let result = chartml.render_from_yaml(yaml);
944 assert!(result.is_err());
945 let err = result.unwrap_err().to_string();
946 assert!(err.contains("not found"), "Expected 'not found' error, got: {}", err);
947 }
948
949 #[test]
950 fn chartml_multi_chart_rendering() {
951 let mut chartml = ChartML::new();
952 chartml.register_renderer("bar", MockRenderer);
953
954 let yaml = r#"
955- type: chart
956 version: 1
957 title: Chart A
958 data:
959 provider: inline
960 rows:
961 - { x: "A", y: 10 }
962 visualize:
963 type: bar
964 columns: x
965 rows: y
966- type: chart
967 version: 1
968 title: Chart B
969 data:
970 provider: inline
971 rows:
972 - { x: "B", y: 20 }
973 visualize:
974 type: bar
975 columns: x
976 rows: y
977"#;
978
979 let result = chartml.render_from_yaml(yaml);
980 assert!(result.is_ok(), "multi-chart render failed: {:?}", result.err());
981 match result.unwrap() {
982 ChartElement::Div { class, children, .. } => {
983 assert_eq!(class, "chartml-multi-chart");
984 assert_eq!(children.len(), 2);
985 }
986 other => panic!("Expected Div wrapper, got {:?}", other),
987 }
988 }
989
990 #[test]
991 fn chartml_named_source_with_transform() {
992 let mut chartml = ChartML::new();
993 chartml.register_renderer("bar", MockRenderer);
994
995 let yaml = r#"---
996type: source
997version: 1
998name: raw_sales
999provider: inline
1000rows:
1001 - { region: "North", revenue: 100 }
1002 - { region: "North", revenue: 200 }
1003 - { region: "South", revenue: 150 }
1004---
1005type: chart
1006version: 1
1007title: Revenue by Region
1008data: raw_sales
1009transform:
1010 aggregate:
1011 dimensions:
1012 - region
1013 measures:
1014 - column: revenue
1015 aggregation: sum
1016 name: total_revenue
1017 sort:
1018 - field: total_revenue
1019 direction: desc
1020visualize:
1021 type: bar
1022 columns: region
1023 rows: total_revenue
1024"#;
1025
1026 let result = chartml.render_from_yaml(yaml);
1027 assert!(result.is_ok(), "transform pipeline render failed: {:?}", result.err());
1028 }
1029
1030 #[test]
1031 fn chartml_multi_chart_with_shared_source() {
1032 let mut chartml = ChartML::new();
1033 chartml.register_renderer("bar", MockRenderer);
1034 chartml.register_renderer("metric", MockRenderer);
1035
1036 let yaml = r#"---
1037type: source
1038version: 1
1039name: kpis
1040provider: inline
1041rows:
1042 - { totalRevenue: 1500000, previousRevenue: 1200000 }
1043---
1044- type: chart
1045 version: 1
1046 title: Revenue
1047 data: kpis
1048 visualize:
1049 type: metric
1050 value: totalRevenue
1051- type: chart
1052 version: 1
1053 title: Prev Revenue
1054 data: kpis
1055 visualize:
1056 type: metric
1057 value: previousRevenue
1058"#;
1059
1060 let result = chartml.render_from_yaml(yaml);
1061 assert!(result.is_ok(), "multi-chart shared source failed: {:?}", result.err());
1062 }
1063}