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