1use chartml_core::data::DataTable;
2use chartml_core::element::{ChartElement, ElementData, TextAnchor, TextRole, TextStyle, Transform, ViewBox, emit_dot_halo_if_enabled};
3use chartml_core::error::ChartError;
4use chartml_core::layout::margins::{calculate_margins, MarginConfig};
5use chartml_core::plugin::ChartConfig;
6use chartml_core::scales::{ScaleBand, ScaleLinear};
7use chartml_core::layout::adaptive_tick_count;
8use chartml_core::spec::{ChartMode, Orientation};
9
10use chartml_core::layout::labels::{LabelStrategy, LabelStrategyConfig, TextMetrics, measure_text};
11
12use chartml_core::layout::legend::{calculate_legend_layout, LegendConfig};
13
14use crate::helpers::{GridConfig, emit_zero_line_if_crosses, format_value, generate_annotations, generate_x_axis, generate_x_axis_numeric, generate_x_axis_with_display, generate_y_axis_with_display, generate_y_axis_numeric, generate_y_axis_numeric_right, generate_legend, get_color_field, get_data_labels_config, get_field_name, get_x_format, get_y_axis_bounds, get_y_format, nice_domain, offset_element};
15
16pub(crate) struct BarRectSpec {
31 pub x: f64,
32 pub y: f64,
33 pub width: f64,
34 pub height: f64,
35 pub is_horizontal: bool,
36 pub is_negative: bool,
37 pub fill: String,
38 pub class: String,
39 pub data: Option<ElementData>,
40}
41
42pub fn bar_animation_origin(
59 x: f64,
60 y: f64,
61 width: f64,
62 height: f64,
63 is_horizontal: bool,
64 is_negative: bool,
65) -> (f64, f64) {
66 match (is_horizontal, is_negative) {
67 (false, false) => (x + width / 2.0, y + height),
69 (false, true) => (x + width / 2.0, y),
71 (true, false) => (x, y + height / 2.0),
73 (true, true) => (x + width, y + height / 2.0),
75 }
76}
77
78pub(crate) fn build_bar_element(
79 spec: BarRectSpec,
80 theme: &chartml_core::theme::Theme,
81) -> ChartElement {
82 use chartml_core::theme::BarCornerRadius;
83 let BarRectSpec {
84 x, y, width, height, is_horizontal, is_negative, fill, class, data,
85 } = spec;
86 let anim_origin = Some(bar_animation_origin(x, y, width, height, is_horizontal, is_negative));
87
88 let (radius, top_only) = match theme.bar_corner_radius {
91 BarCornerRadius::Uniform(r) => (r as f64, false),
92 BarCornerRadius::Top(r) => (r as f64, true),
93 };
94
95 if radius <= 0.0 {
96 return ChartElement::Rect {
97 x,
98 y,
99 width,
100 height,
101 fill,
102 stroke: None,
103 rx: None,
104 ry: None,
105 class,
106 data,
107 animation_origin: anim_origin,
108 };
109 }
110
111 if !top_only {
112 return ChartElement::Rect {
113 x,
114 y,
115 width,
116 height,
117 fill,
118 stroke: None,
119 rx: Some(radius),
120 ry: Some(radius),
121 class,
122 data,
123 animation_origin: anim_origin,
124 };
125 }
126
127 let max_r = (width.min(height) / 2.0).max(0.0);
131 debug_assert!(
132 radius <= max_r + 1e-9 || width <= 0.0 || height <= 0.0,
133 "bar_corner_radius {} exceeds min(w,h)/2 = {} (w={}, h={})",
134 radius, max_r, width, height
135 );
136 let r = radius.min(max_r);
137
138 if r <= 0.0 {
143 return ChartElement::Rect {
144 x,
145 y,
146 width,
147 height,
148 fill,
149 stroke: None,
150 rx: None,
151 ry: None,
152 class,
153 data,
154 animation_origin: anim_origin,
155 };
156 }
157
158 let x0 = x;
160 let y0 = y;
161 let x1 = x + width;
162 let y1 = y + height;
163
164 let d = match (is_horizontal, is_negative) {
174 (false, false) => format!(
176 "M {x0},{y0r} A {r},{r} 0 0 1 {x0r},{y0} L {x1mr},{y0} A {r},{r} 0 0 1 {x1},{y0r} L {x1},{y1} L {x0},{y1} Z",
177 x0 = x0, y0 = y0, x1 = x1, y1 = y1, r = r,
178 x0r = x0 + r, x1mr = x1 - r, y0r = y0 + r,
179 ),
180 (false, true) => format!(
182 "M {x0},{y0} L {x1},{y0} L {x1},{y1mr} A {r},{r} 0 0 1 {x1mr},{y1} L {x0r},{y1} A {r},{r} 0 0 1 {x0},{y1mr} Z",
183 x0 = x0, y0 = y0, x1 = x1, y1 = y1, r = r,
184 x0r = x0 + r, x1mr = x1 - r, y1mr = y1 - r,
185 ),
186 (true, false) => format!(
188 "M {x0},{y0} L {x1mr},{y0} A {r},{r} 0 0 1 {x1},{y0r} L {x1},{y1mr} A {r},{r} 0 0 1 {x1mr},{y1} L {x0},{y1} Z",
189 x0 = x0, y0 = y0, x1 = x1, y1 = y1, r = r,
190 x1mr = x1 - r, y0r = y0 + r, y1mr = y1 - r,
191 ),
192 (true, true) => format!(
194 "M {x0r},{y0} L {x1},{y0} L {x1},{y1} L {x0r},{y1} A {r},{r} 0 0 1 {x0},{y1mr} L {x0},{y0r} A {r},{r} 0 0 1 {x0r},{y0} Z",
195 x0 = x0, y0 = y0, x1 = x1, y1 = y1, r = r,
196 x0r = x0 + r, y0r = y0 + r, y1mr = y1 - r,
197 ),
198 };
199
200 ChartElement::Path {
201 d,
202 fill: Some(fill),
203 stroke: None,
204 stroke_width: None,
205 stroke_dasharray: None,
206 opacity: None,
207 class,
208 data,
209 animation_origin: anim_origin,
210 }
211}
212
213struct SingleSeriesBarParams<'a> {
214 category_field: &'a str,
215 value_field: &'a str,
216 categories: &'a [String],
217 inner_width: f64,
218 inner_height: f64,
219 is_horizontal: bool,
220 y_fmt_ref: Option<&'a str>,
221 domain_min: f64,
222 domain_max: f64,
223}
224
225struct MultiSeriesBarParams<'a> {
226 category_field: &'a str,
227 value_field: &'a str,
228 color_field: &'a str,
229 categories: &'a [String],
230 inner_width: f64,
231 inner_height: f64,
232 is_stacked: bool,
233 is_normalized: bool,
234 is_horizontal: bool,
235 y_fmt_ref: Option<&'a str>,
236 domain_min: f64,
237 domain_max: f64,
238}
239
240pub fn render_bar(data: &DataTable, config: &ChartConfig) -> Result<ChartElement, ChartError> {
241 use chartml_core::spec::{FieldRef, FieldRefItem, FieldSpec};
242
243
244 let multi_fields: Vec<FieldSpec> = match &config.visualize.rows {
246 Some(FieldRef::Multiple(items)) => items.iter().map(|item| match item {
247 FieldRefItem::Detailed(spec) => spec.as_ref().clone(),
248 FieldRefItem::Simple(name) => FieldSpec {
249 field: Some(name.clone()), mark: None, axis: None, label: None,
250 color: None, format: None, data_labels: None,
251 line_style: None, upper: None, lower: None, opacity: None,
252 },
253 }).collect(),
254 _ => vec![],
255 };
256
257 if !multi_fields.is_empty() {
258 let is_horizontal = matches!(config.visualize.orientation, Some(Orientation::Horizontal));
259 let has_line_fields = multi_fields.iter().any(|f| f.mark.as_deref() == Some("line"));
260 let has_right_axis = multi_fields.iter().any(|f| f.axis.as_deref() == Some("right"));
261
262 if is_horizontal && !has_line_fields && !has_right_axis {
266 let category_field = get_field_name(&config.visualize.columns)?;
270 let mut long_rows: Vec<chartml_core::data::Row> = Vec::new();
271 for i in 0..data.num_rows() {
272 for field_spec in &multi_fields {
273 if field_spec.mark.as_deref() == Some("range") {
278 continue;
279 }
280 let Some(field_name) = field_spec.field.as_deref() else { continue };
281 let cat = data.get_string(i, &category_field).unwrap_or_default();
282 let val = data.get_f64(i, field_name).unwrap_or(0.0);
283 let label = field_spec.label.clone().unwrap_or_else(|| field_name.to_string());
284 let mut row = std::collections::HashMap::new();
285 row.insert(category_field.clone(), serde_json::json!(cat));
286 row.insert("_value".to_string(), serde_json::json!(val));
287 row.insert("_series".to_string(), serde_json::json!(label));
288 long_rows.push(row);
289 }
290 }
291 let long_data = DataTable::from_rows(&long_rows)
292 .map_err(|e| ChartError::DataError(format!("Failed to reshape data: {}", e)))?;
293
294 let mut viz = config.visualize.clone();
296 viz.rows = Some(FieldRef::Simple("_value".to_string()));
297 viz.marks = Some(chartml_core::spec::MarksSpec {
298 color: Some(chartml_core::spec::MarkEncoding::Simple("_series".to_string())),
299 size: None, shape: None, text: None,
300 });
301 viz.mode = Some(ChartMode::Grouped);
302 let mut colors = Vec::new();
304 for (i, f) in multi_fields.iter().enumerate() {
305 colors.push(f.color.clone().unwrap_or_else(|| {
306 config.colors.get(i).cloned().unwrap_or_else(|| "#2E7D9A".to_string())
307 }));
308 }
309 let long_config = ChartConfig {
310 visualize: viz,
311 title: config.title.clone(),
312 width: config.width,
313 height: config.height,
314 colors,
315 theme: config.theme.clone(),
316 };
317 return render_bar(&long_data, &long_config);
318 }
319
320 return render_combo(data, config, &multi_fields);
321 }
322
323 let category_field = get_field_name(&config.visualize.columns)?;
324 let value_field = get_field_name(&config.visualize.rows)?;
325
326 let color_field = get_color_field(config);
327
328 let (categories, display_labels): (Vec<String>, Option<Vec<String>>) = if color_field.is_none() {
334 let all_vals = data.all_values(&category_field);
335 if all_vals.is_empty() {
336 return Err(ChartError::DataError("No category values found".into()));
337 }
338 let has_duplicates = {
340 let mut seen = std::collections::HashSet::new();
341 all_vals.iter().any(|v| !seen.insert(v.as_str()))
342 };
343 if has_duplicates {
344 let band_keys: Vec<String> = all_vals.iter().enumerate()
345 .map(|(i, v)| format!("{}\x00{}", v, i))
346 .collect();
347 (band_keys, Some(all_vals))
348 } else {
349 (all_vals, None)
350 }
351 } else {
352 let unique = data.unique_values(&category_field);
353 if unique.is_empty() {
354 return Err(ChartError::DataError("No category values found".into()));
355 }
356 (unique, None)
357 };
358
359 let is_horizontal = matches!(config.visualize.orientation, Some(Orientation::Horizontal));
360 let is_normalized = matches!(config.visualize.mode, Some(ChartMode::Normalized));
361 let is_stacked = matches!(config.visualize.mode, Some(ChartMode::Stacked)) || is_normalized;
362 let _is_grouped = matches!(config.visualize.mode, Some(ChartMode::Grouped));
363
364 let x_format = get_x_format(config);
366 let y_fmt = get_y_format(config);
367 let y_fmt_ref = y_fmt.as_deref();
368 let (axis_min, axis_max) = get_y_axis_bounds(config);
369
370 let raw_for_strategy = display_labels.as_deref().unwrap_or(&categories);
373 let formatted_for_strategy = crate::helpers::format_display_labels(raw_for_strategy, x_format.as_deref());
374 let x_extra_margin = if !is_horizontal {
375 let estimated_width = config.width - 80.0;
376 let label_strategy_config = LabelStrategyConfig {
377 text_metrics: TextMetrics::from_theme_axis_label(&config.theme),
378 ..LabelStrategyConfig::default()
379 };
380 let x_strategy = LabelStrategy::determine(&formatted_for_strategy, estimated_width, &label_strategy_config);
381 match &x_strategy {
382 LabelStrategy::Rotated { margin, .. } => *margin,
383 _ => 0.0,
384 }
385 } else {
386 0.0
387 };
388
389 let (prelim_data_min, prelim_data_max): (f64, f64) = if let Some(ref color_f) = color_field {
392 if is_stacked {
393 let groups = data.group_by(color_f);
394 let series_names = data.unique_values(color_f);
395 let stacked_vals: Vec<f64> = categories.iter().map(|cat| {
396 series_names.iter().map(|s| {
397 groups.get(s).and_then(|series_data| {
398 (0..series_data.num_rows()).find_map(|i| {
399 if series_data.get_string(i, &category_field).as_deref() == Some(cat.as_str()) {
400 series_data.get_f64(i, &value_field)
401 } else {
402 None
403 }
404 })
405 }).unwrap_or(0.0)
406 }).sum::<f64>()
407 }).collect();
408 let mn = stacked_vals.iter().cloned().fold(f64::INFINITY, f64::min);
409 let mx = stacked_vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
410 (mn, mx)
411 } else {
412 let vals: Vec<f64> = (0..data.num_rows()).filter_map(|i| data.get_f64(i, &value_field)).collect();
413 let mn = vals.iter().cloned().fold(f64::INFINITY, f64::min);
414 let mx = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
415 (mn, mx)
416 }
417 } else {
418 let vals: Vec<f64> = (0..data.num_rows()).filter_map(|i| data.get_f64(i, &value_field)).collect();
419 let mn = vals.iter().cloned().fold(f64::INFINITY, f64::min);
420 let mx = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
421 (mn, mx)
422 };
423 let prelim_data_max = if prelim_data_max <= 0.0 { 1.0 } else { prelim_data_max };
424 let prelim_data_min = if prelim_data_min >= 0.0 { 0.0 } else { prelim_data_min };
426
427 let prelim_domain_max = if is_normalized {
428 1.0
429 } else {
430 let raw_max = axis_max.unwrap_or(prelim_data_max);
431 if axis_max.is_none() { nice_domain(axis_min.unwrap_or(prelim_data_min), raw_max, 5).1 } else { raw_max }
432 };
433 let prelim_domain_min = if is_normalized { 0.0 } else { axis_min.unwrap_or(prelim_data_min) };
434
435 let y_tick_labels_for_margin: Vec<String> = if !is_horizontal {
438 let prelim_fmt = if is_normalized { Some(".0%") } else { y_fmt_ref };
439 vec![
440 format_value(prelim_domain_max, prelim_fmt),
441 format_value(prelim_domain_min, prelim_fmt),
442 ]
443 } else {
444 let display = display_labels.as_deref().unwrap_or(&categories);
445 display.to_vec()
446 };
447
448 let legend_height = if let Some(ref color_f) = color_field {
450 let series_names = data.unique_values(color_f);
451 let legend_config = LegendConfig {
452 text_metrics: TextMetrics::from_theme_legend(&config.theme),
453 ..LegendConfig::default()
454 };
455 calculate_legend_layout(&series_names, &config.colors, config.width, &legend_config).total_height
456 } else {
457 0.0
458 };
459
460 let has_x_axis_label = config.visualize.axes.as_ref()
462 .and_then(|a| a.x.as_ref())
463 .and_then(|a| a.label.as_ref())
464 .is_some();
465 let has_y_axis_label = config.visualize.axes.as_ref()
466 .and_then(|a| a.left.as_ref())
467 .and_then(|a| a.label.as_ref())
468 .is_some();
469 let margin_config = MarginConfig {
470 has_title: config.title.is_some(),
471 legend_height,
472 has_x_axis_label,
473 has_y_axis_label,
474 x_label_strategy_margin: x_extra_margin,
475 y_tick_labels: y_tick_labels_for_margin,
476 tick_value_metrics: if is_horizontal {
479 TextMetrics::from_theme_axis_label(&config.theme)
480 } else {
481 TextMetrics::from_theme_tick_value(&config.theme)
482 },
483 axis_label_metrics: TextMetrics::from_theme_axis_label(&config.theme),
484 ..Default::default()
485 };
486 let margins = calculate_margins(&margin_config);
487
488 let inner_width = margins.inner_width(config.width);
489 let inner_height = margins.inner_height(config.height);
490
491 let mut children = Vec::new();
492
493 let grid = GridConfig::from_config(config);
497
498 let _tick_count = adaptive_tick_count(inner_height);
499
500 let raw_data_max = prelim_data_max;
502
503 let (domain_min, domain_max) = if is_normalized {
505 (0.0, 1.0)
506 } else {
507 let raw_domain_min = axis_min.unwrap_or(prelim_data_min);
508 let raw_domain_max = axis_max.unwrap_or(raw_data_max);
509 if axis_min.is_none() && axis_max.is_none() {
512 nice_domain(raw_domain_min, raw_domain_max, 5)
514 } else {
515 (raw_domain_min, raw_domain_max)
516 }
517 };
518 let effective_y_fmt: Option<String> = if is_normalized {
521 Some(".0%".to_string())
522 } else {
523 y_fmt.clone()
524 };
525 let effective_y_fmt_ref = effective_y_fmt.as_deref();
526
527 let (_, bar_elements) = if let Some(ref color_f) = color_field {
528 render_multi_series_bars(
529 data,
530 config,
531 &MultiSeriesBarParams {
532 category_field: &category_field,
533 value_field: &value_field,
534 color_field: color_f,
535 categories: &categories,
536 inner_width,
537 inner_height,
538 is_stacked,
539 is_normalized,
540 is_horizontal,
541 y_fmt_ref,
542 domain_min,
543 domain_max,
544 },
545 )?
546 } else {
547 render_single_series_bars(
548 data,
549 config,
550 &SingleSeriesBarParams {
551 category_field: &category_field,
552 value_field: &value_field,
553 categories: &categories,
554 inner_width,
555 inner_height,
556 is_horizontal,
557 y_fmt_ref,
558 domain_min,
559 domain_max,
560 },
561 )?
562 };
563
564 let axis_elements = if is_horizontal {
566 let x_axis = generate_y_axis_with_display(&categories, display_labels.as_deref(), (0.0, inner_height), 0.0, None, &config.theme);
568 let y_axis = generate_x_axis_numeric(&crate::helpers::XAxisNumericParams {
569 domain: (domain_min, domain_max),
570 range: (0.0, inner_width),
571 y_position: margins.top + inner_height,
572 fmt: effective_y_fmt_ref,
573 tick_count: 5,
574 chart_height: Some(inner_height),
575 grid: &grid,
576 theme: &config.theme,
577 });
578 let mut axes = Vec::new();
579 axes.extend(x_axis.into_iter().map(|e| offset_element(e, margins.left, margins.top)));
580 axes.extend(y_axis.into_iter().map(|e| offset_element(e, margins.left, 0.0)));
581 if let Some(zl) = emit_zero_line_if_crosses(
586 &config.theme,
587 (domain_min, domain_max),
588 inner_width,
589 inner_height,
590 true,
591 ) {
592 axes.push(offset_element(zl, margins.left, margins.top));
593 }
594 axes
595 } else {
596 let bottom_axis_label = config.visualize.axes.as_ref()
597 .and_then(|a| a.x.as_ref())
598 .and_then(|a| a.label.as_deref());
599 let x_axis_result = generate_x_axis_with_display(&crate::helpers::XAxisParams {
600 labels: &categories,
601 display_label_overrides: display_labels.as_deref(),
602 range: (0.0, inner_width),
603 y_position: margins.top + inner_height,
604 available_width: inner_width,
605 x_format: x_format.as_deref(),
606 chart_height: Some(inner_height),
607 grid: &grid,
608 axis_label: bottom_axis_label,
609 theme: &config.theme,
610 });
611 let left_axis_label = config.visualize.axes.as_ref()
612 .and_then(|a| a.left.as_ref())
613 .and_then(|a| a.label.as_deref());
614 let y_axis = generate_y_axis_numeric(&crate::helpers::YAxisNumericParams {
615 domain: (domain_min, domain_max),
616 range: (inner_height, 0.0),
617 x_position: margins.left,
618 fmt: effective_y_fmt_ref,
619 tick_count: adaptive_tick_count(inner_height),
620 chart_width: Some(inner_width),
621 grid: &grid,
622 axis_label: left_axis_label,
623 theme: &config.theme,
624 });
625 let mut axes = Vec::new();
626 axes.extend(x_axis_result.elements.into_iter().map(|e| offset_element(e, margins.left, 0.0)));
627 axes.extend(y_axis.into_iter().map(|e| offset_element(e, 0.0, margins.top)));
628 if let Some(zl) = emit_zero_line_if_crosses(
632 &config.theme,
633 (domain_min, domain_max),
634 inner_width,
635 inner_height,
636 is_horizontal,
637 ) {
638 axes.push(offset_element(zl, margins.left, margins.top));
639 }
640 axes
641 };
642
643 children.push(ChartElement::Group {
644 class: "axes".to_string(),
645 transform: None,
646 children: axis_elements,
647 });
648
649 children.push(ChartElement::Group {
650 class: "bars".to_string(),
651 transform: Some(Transform::Translate(margins.left, margins.top)),
652 children: bar_elements,
653 });
654
655 if !is_horizontal {
657 if let Some(annotations) = config.visualize.annotations.as_deref() {
658 if !annotations.is_empty() {
659 use chartml_core::scales::ScaleLinear;
660 let ann_scale = ScaleLinear::new((domain_min, domain_max), (inner_height, 0.0));
661 let ann_cats = display_labels.as_deref().unwrap_or(&categories);
662 let ann_elements = generate_annotations(
663 annotations,
664 &ann_scale,
665 0.0,
666 inner_width,
667 inner_height,
668 Some(ann_cats),
669 &config.theme,
670 );
671 if !ann_elements.is_empty() {
672 children.push(ChartElement::Group {
673 class: "annotations".to_string(),
674 transform: Some(Transform::Translate(margins.left, margins.top)),
675 children: ann_elements,
676 });
677 }
678 }
679 }
680 }
681
682 if let Some(ref color_f) = color_field {
684 let series_names = data.unique_values(color_f);
685 let legend_config = LegendConfig {
686 text_metrics: TextMetrics::from_theme_legend(&config.theme),
687 ..LegendConfig::default()
688 };
689 let legend_layout = calculate_legend_layout(&series_names, &config.colors, config.width, &legend_config);
690 let legend_y = config.height - legend_layout.total_height - 8.0;
691 let legend_elements = generate_legend(&series_names, &config.colors, config.width, legend_y, &config.theme);
692 children.push(ChartElement::Group {
693 class: "legend".to_string(),
694 transform: None,
695 children: legend_elements,
696 });
697 }
698
699 let svg_class = if is_horizontal { "chartml-bar chartml-horizontal" } else { "chartml-bar" };
700 Ok(ChartElement::Svg {
701 viewbox: ViewBox::new(0.0, 0.0, config.width, config.height),
702 width: Some(config.width),
703 height: Some(config.height),
704 class: svg_class.to_string(),
705 children,
706 })
707}
708
709fn render_single_series_bars(
710 data: &DataTable,
711 config: &ChartConfig,
712 params: &SingleSeriesBarParams,
713) -> Result<(f64, Vec<ChartElement>), ChartError> {
714 let category_field = params.category_field;
715 let value_field = params.value_field;
716 let categories = params.categories;
717 let inner_width = params.inner_width;
718 let inner_height = params.inner_height;
719 let is_horizontal = params.is_horizontal;
720 let y_fmt_ref = params.y_fmt_ref;
721 let domain_min = params.domain_min;
722 let domain_max = params.domain_max;
723 let values: Vec<f64> = (0..data.num_rows())
725 .filter_map(|i| data.get_f64(i, value_field))
726 .collect();
727 let value_max = values.iter().cloned().fold(0.0_f64, f64::max);
728 let value_max = if value_max <= 0.0 { 1.0 } else { value_max };
729 let effective_max = domain_max;
731
732 let mut elements = Vec::new();
733 let fill_color = config.colors.first()
736 .cloned()
737 .unwrap_or_else(|| "#2E7D9A".to_string());
738
739 if is_horizontal {
740 let band = ScaleBand::new(categories.to_vec(), (0.0, inner_height))
741 .padding(crate::helpers::adaptive_bar_padding(categories.len()));
742 let linear = ScaleLinear::new((domain_min, effective_max), (0.0, inner_width));
743 let bar_render_height = band.bandwidth().min(40.0);
745 let y_inset = (band.bandwidth() - bar_render_height) / 2.0;
746
747 for i in 0..data.num_rows() {
748 let cat = match data.get_string(i, category_field) {
749 Some(c) => c,
750 None => continue,
751 };
752 let val = data.get_f64(i, value_field).unwrap_or(0.0);
753 let band_key = categories.get(i).map(|k| k.as_str()).unwrap_or(&cat);
755 let y = match band.map(band_key) {
756 Some(y) => y,
757 None => continue,
758 };
759 let bar_width = linear.map(val);
760
761 elements.push(build_bar_element(
762 BarRectSpec {
763 x: 0.0,
764 y: y + y_inset,
765 width: bar_width,
766 height: bar_render_height,
767 is_horizontal: true,
768 is_negative: val < 0.0,
769 fill: fill_color.clone(),
770 class: "bar bar-rect".to_string(),
771 data: Some(ElementData::new(&cat, format_value(val, y_fmt_ref))),
772 },
773 &config.theme,
774 ));
775 }
776 } else {
777 let band = ScaleBand::new(categories.to_vec(), (0.0, inner_width))
778 .padding(crate::helpers::adaptive_bar_padding(categories.len()));
779 let linear = ScaleLinear::new((domain_min, effective_max), (inner_height, 0.0));
780 let max_bar_width = inner_width * 0.2;
782 let bar_render_width = band.bandwidth().min(max_bar_width);
783 let x_inset = (band.bandwidth() - bar_render_width) / 2.0;
784
785 for i in 0..data.num_rows() {
786 let cat = match data.get_string(i, category_field) {
787 Some(c) => c,
788 None => continue,
789 };
790 let val = data.get_f64(i, value_field).unwrap_or(0.0);
791 let band_key = categories.get(i).map(|k| k.as_str()).unwrap_or(&cat);
793 let x = match band.map(band_key) {
794 Some(x) => x,
795 None => continue,
796 };
797 let bar_val_y = linear.map(val);
798 let bar_zero_y = linear.map(0.0);
799 let bar_height = (bar_zero_y - bar_val_y).abs();
800 let rect_y = bar_val_y.min(bar_zero_y);
803
804 elements.push(build_bar_element(
805 BarRectSpec {
806 x: x + x_inset,
807 y: rect_y,
808 width: bar_render_width,
809 height: bar_height,
810 is_horizontal: false,
811 is_negative: val < 0.0,
812 fill: fill_color.clone(),
813 class: "bar bar-rect".to_string(),
814 data: Some(ElementData::new(&cat, format_value(val, y_fmt_ref))),
815 },
816 &config.theme,
817 ));
818
819 if let Some(dl) = get_data_labels_config(config) {
821 if dl.show == Some(true) {
822 let label_fmt = dl.format.as_deref().or(y_fmt_ref);
823 let label_y = match dl.position.as_deref() {
824 Some("center") => rect_y + bar_height / 2.0,
825 Some("bottom") => rect_y + bar_height - 5.0,
826 _ => if val >= 0.0 { rect_y - 5.0 } else { rect_y + bar_height + 12.0 }, };
828 elements.push(ChartElement::Text {
829 x: x + band.bandwidth() / 2.0,
830 y: label_y,
831 content: format_value(val, label_fmt),
832 anchor: TextAnchor::Middle,
833 dominant_baseline: None,
834 transform: None,
835 font_family: None,
836 font_size: Some(dl.font_size.map(|s| format!("{}px", s)).unwrap_or_else(|| "12px".to_string())),
837 font_weight: None,
838 letter_spacing: None,
839 text_transform: None,
840 fill: Some(dl.color.clone().unwrap_or_else(|| config.theme.text_secondary.clone())),
841 class: "data-label".to_string(),
842 data: None,
843 });
844 }
845 }
846 }
847 }
848
849 Ok((value_max, elements))
850}
851
852fn render_multi_series_bars(
853 data: &DataTable,
854 config: &ChartConfig,
855 params: &MultiSeriesBarParams,
856) -> Result<(f64, Vec<ChartElement>), ChartError> {
857 let category_field = params.category_field;
858 let value_field = params.value_field;
859 let color_field = params.color_field;
860 let categories = params.categories;
861 let inner_width = params.inner_width;
862 let inner_height = params.inner_height;
863 let is_stacked = params.is_stacked;
864 let is_normalized = params.is_normalized;
865 let is_horizontal = params.is_horizontal;
866 let y_fmt_ref = params.y_fmt_ref;
867 let domain_min = params.domain_min;
868 let domain_max = params.domain_max;
869 use chartml_core::layout::stack::{StackLayout, StackOffset};
870
871 let series_names = data.unique_values(color_field);
872 let groups = data.group_by(color_field);
873
874 let mut elements = Vec::new();
875
876 if is_stacked {
877 let mut values_matrix: Vec<Vec<f64>> = Vec::new();
879 for series in &series_names {
880 let mut series_vals = Vec::new();
881 let series_data = groups.get(series);
882 for cat in categories {
883 let val = series_data
884 .map(|sd| {
885 (0..sd.num_rows())
886 .find_map(|i| {
887 if sd.get_string(i, category_field).as_deref() == Some(cat.as_str()) {
888 sd.get_f64(i, value_field)
889 } else {
890 None
891 }
892 })
893 .unwrap_or(0.0)
894 })
895 .unwrap_or(0.0);
896 series_vals.push(val);
897 }
898 values_matrix.push(series_vals);
899 }
900
901 let stack = if is_normalized {
902 StackLayout::new().offset(StackOffset::Normalize)
903 } else {
904 StackLayout::new()
905 };
906 let stacked_points = stack.layout(categories, &series_names, &values_matrix);
907
908 let (effective_min, effective_max) = if is_normalized {
910 (0.0, 1.0)
911 } else {
912 let value_max = stacked_points
913 .iter()
914 .map(|p| p.y1)
915 .fold(0.0_f64, f64::max);
916 let value_max = if value_max <= 0.0 { 1.0 } else { value_max };
917 (domain_min, if domain_max < f64::MAX { domain_max } else { value_max })
918 };
919
920 if is_horizontal {
921 let band = ScaleBand::new(categories.to_vec(), (0.0, inner_height))
923 .padding(crate::helpers::adaptive_bar_padding(categories.len()));
924 let linear = ScaleLinear::new((effective_min, effective_max), (0.0, inner_width));
925 let bar_render_height = band.bandwidth().min(40.0);
926 let y_inset = (band.bandwidth() - bar_render_height) / 2.0;
927
928 for point in &stacked_points {
929 let y = match band.map(&point.key) {
930 Some(y) => y,
931 None => continue,
932 };
933 let x_left = linear.map(point.y0);
934 let x_right = linear.map(point.y1);
935 let bar_width = (x_right - x_left).abs();
936
937 let series_idx = series_names.iter().position(|s| s == &point.series).unwrap_or(0);
938 let fill = config
939 .colors
940 .get(series_idx)
941 .cloned()
942 .unwrap_or_else(|| "#2E7D9A".to_string());
943
944 elements.push(build_bar_element(
945 BarRectSpec {
946 x: x_left.min(x_right),
947 y: y + y_inset,
948 width: bar_width,
949 height: bar_render_height,
950 is_horizontal: true,
951 is_negative: point.value < 0.0,
952 fill,
953 class: "bar bar-rect".to_string(),
954 data: Some(
955 ElementData::new(&point.key, format_value(point.value, y_fmt_ref))
956 .with_series(&point.series),
957 ),
958 },
959 &config.theme,
960 ));
961 }
962 } else {
963 let band = ScaleBand::new(categories.to_vec(), (0.0, inner_width))
965 .padding(crate::helpers::adaptive_bar_padding(categories.len()));
966 let linear = ScaleLinear::new((effective_min, effective_max), (inner_height, 0.0));
967 let max_bar_width = inner_width * 0.2;
969 let bar_render_width = band.bandwidth().min(max_bar_width);
970 let x_inset = (band.bandwidth() - bar_render_width) / 2.0;
971
972 for point in &stacked_points {
973 let x = match band.map(&point.key) {
974 Some(x) => x,
975 None => continue,
976 };
977 let y_top = linear.map(point.y1);
978 let y_bottom = linear.map(point.y0);
979 let bar_height = (y_bottom - y_top).abs();
980
981 let series_idx = series_names.iter().position(|s| s == &point.series).unwrap_or(0);
982 let fill = config
983 .colors
984 .get(series_idx)
985 .cloned()
986 .unwrap_or_else(|| "#2E7D9A".to_string());
987
988 elements.push(build_bar_element(
989 BarRectSpec {
990 x: x + x_inset,
991 y: y_top,
992 width: bar_render_width,
993 height: bar_height,
994 is_horizontal: false,
995 is_negative: point.value < 0.0,
996 fill,
997 class: "bar bar-rect".to_string(),
998 data: Some(
999 ElementData::new(&point.key, format_value(point.value, y_fmt_ref))
1000 .with_series(&point.series),
1001 ),
1002 },
1003 &config.theme,
1004 ));
1005 }
1006 }
1007
1008 Ok((effective_max, elements))
1009 } else {
1010 let value_max = (0..data.num_rows())
1013 .filter_map(|i| data.get_f64(i, value_field))
1014 .fold(0.0_f64, f64::max);
1015 let value_max = if value_max <= 0.0 { 1.0 } else { value_max };
1016 let effective_max = if domain_max < f64::MAX { domain_max } else { value_max };
1017
1018 let num_series = series_names.len().max(1);
1019
1020 if is_horizontal {
1021 let band = ScaleBand::new(categories.to_vec(), (0.0, inner_height))
1023 .padding(0.05);
1024 let linear = ScaleLinear::new((domain_min, effective_max), (0.0, inner_width));
1025 let sub_band_height = band.bandwidth() / num_series as f64;
1026
1027 for i in 0..data.num_rows() {
1028 let cat = match data.get_string(i, category_field) {
1029 Some(c) => c,
1030 None => continue,
1031 };
1032 let series = match data.get_string(i, color_field) {
1033 Some(s) => s,
1034 None => continue,
1035 };
1036 let val = data.get_f64(i, value_field).unwrap_or(0.0);
1037
1038 let y_base = match band.map(&cat) {
1039 Some(y) => y,
1040 None => continue,
1041 };
1042 let series_idx = series_names.iter().position(|s| s == &series).unwrap_or(0);
1043 let y = y_base + series_idx as f64 * sub_band_height;
1044
1045 let bar_left = linear.map(0.0);
1046 let bar_right = linear.map(val);
1047 let bar_width = (bar_right - bar_left).abs();
1048
1049 let fill = config
1050 .colors
1051 .get(series_idx)
1052 .cloned()
1053 .unwrap_or_else(|| "#2E7D9A".to_string());
1054
1055 elements.push(build_bar_element(
1056 BarRectSpec {
1057 x: bar_left.min(bar_right),
1058 y,
1059 width: bar_width,
1060 height: sub_band_height,
1061 is_horizontal: true,
1062 is_negative: val < 0.0,
1063 fill,
1064 class: "bar bar-rect".to_string(),
1065 data: Some(
1066 ElementData::new(&cat, format_value(val, y_fmt_ref))
1067 .with_series(&series),
1068 ),
1069 },
1070 &config.theme,
1071 ));
1072 }
1073 } else {
1074 let band = ScaleBand::new(categories.to_vec(), (0.0, inner_width))
1076 .padding(0.05);
1077 let linear = ScaleLinear::new((domain_min, effective_max), (inner_height, 0.0));
1078 let sub_band_width = band.bandwidth() / num_series as f64;
1079
1080 for i in 0..data.num_rows() {
1081 let cat = match data.get_string(i, category_field) {
1082 Some(c) => c,
1083 None => continue,
1084 };
1085 let series = match data.get_string(i, color_field) {
1086 Some(s) => s,
1087 None => continue,
1088 };
1089 let val = data.get_f64(i, value_field).unwrap_or(0.0);
1090
1091 let x_base = match band.map(&cat) {
1092 Some(x) => x,
1093 None => continue,
1094 };
1095 let series_idx = series_names.iter().position(|s| s == &series).unwrap_or(0);
1096 let x = x_base + series_idx as f64 * sub_band_width;
1097
1098 let bar_top = linear.map(val);
1099 let bar_bottom = linear.map(0.0);
1100 let bar_height = (bar_bottom - bar_top).abs();
1101
1102 let fill = config
1103 .colors
1104 .get(series_idx)
1105 .cloned()
1106 .unwrap_or_else(|| "#2E7D9A".to_string());
1107
1108 elements.push(build_bar_element(
1109 BarRectSpec {
1110 x,
1111 y: bar_top,
1112 width: sub_band_width,
1113 height: bar_height,
1114 is_horizontal: false,
1115 is_negative: val < 0.0,
1116 fill,
1117 class: "bar bar-rect".to_string(),
1118 data: Some(
1119 ElementData::new(&cat, format_value(val, y_fmt_ref))
1120 .with_series(&series),
1121 ),
1122 },
1123 &config.theme,
1124 ));
1125 }
1126 }
1127
1128 Ok((value_max, elements))
1129 }
1130}
1131
1132fn render_combo(
1134 data: &DataTable,
1135 config: &ChartConfig,
1136 fields: &[chartml_core::spec::FieldSpec],
1137) -> Result<ChartElement, ChartError> {
1138 use chartml_core::shapes::LineGenerator;
1139 use chartml_core::layout::stack::StackLayout;
1140
1141 let category_field = get_field_name(&config.visualize.columns)?;
1142 let categories = data.unique_values(&category_field);
1143 if categories.is_empty() {
1144 return Err(ChartError::DataError("No category values found".into()));
1145 }
1146
1147 let y_fmt = get_y_format(config);
1148 let y_fmt_ref = y_fmt.as_deref();
1149 let grid = GridConfig::from_config(config);
1150 let x_format = get_x_format(config);
1151
1152 let color_field = get_color_field(config);
1154 let is_stacked = matches!(config.visualize.mode, Some(ChartMode::Stacked));
1155
1156 let has_right = fields.iter().any(|f| f.axis.as_deref() == Some("right"));
1158 let right_fmt = config.visualize.axes.as_ref()
1159 .and_then(|a| a.right.as_ref())
1160 .and_then(|a| a.format.as_deref());
1161
1162 let right_tick_labels: Vec<String> = if has_right {
1164 let right_max = fields.iter()
1166 .filter(|f| f.axis.as_deref() == Some("right"))
1167 .filter_map(|f| f.field.as_deref())
1168 .flat_map(|name| (0..data.num_rows()).filter_map(move |i| data.get_f64(i, name)))
1169 .fold(0.0_f64, f64::max);
1170 let right_domain_max = config.visualize.axes.as_ref()
1171 .and_then(|a| a.right.as_ref())
1172 .and_then(|a| a.max)
1173 .unwrap_or(if right_max <= 0.0 { 1.0 } else { right_max });
1174 let tmp_scale = ScaleLinear::new((0.0, right_domain_max), (0.0, 100.0));
1175 tmp_scale.ticks(5).iter().map(|v| format_value(*v, right_fmt)).collect()
1176 } else {
1177 vec![]
1178 };
1179
1180 let has_x_axis_label = config.visualize.axes.as_ref()
1181 .and_then(|a| a.x.as_ref())
1182 .and_then(|a| a.label.as_ref())
1183 .is_some();
1184 let combo_legend_labels: Vec<String> = fields.iter()
1188 .filter(|f| f.mark.as_deref() != Some("range"))
1189 .map(|f| {
1190 f.label
1191 .clone()
1192 .unwrap_or_else(|| f.field.clone().unwrap_or_default())
1193 })
1194 .collect();
1195 let combo_legend_height = if combo_legend_labels.len() > 1 || color_field.is_some() {
1196 let legend_config = LegendConfig {
1197 text_metrics: TextMetrics::from_theme_legend(&config.theme),
1198 ..LegendConfig::default()
1199 };
1200 calculate_legend_layout(&combo_legend_labels, &config.colors, config.width, &legend_config).total_height
1201 } else {
1202 0.0
1203 };
1204 let margin_config = MarginConfig {
1205 has_title: config.title.is_some(),
1206 legend_height: combo_legend_height,
1207 has_y_axis_label: false,
1210 has_x_axis_label,
1211 has_right_axis: has_right,
1212 right_tick_labels,
1213 tick_value_metrics: TextMetrics::from_theme_tick_value(&config.theme),
1214 axis_label_metrics: TextMetrics::from_theme_axis_label(&config.theme),
1215 ..Default::default()
1216 };
1217 let margins = calculate_margins(&margin_config);
1218 let inner_width = margins.inner_width(config.width);
1219 let inner_height = margins.inner_height(config.height);
1220
1221 let band = ScaleBand::new(categories.clone(), (0.0, inner_width))
1222 .padding(crate::helpers::adaptive_bar_padding(categories.len()));
1223 let bandwidth = band.bandwidth();
1224
1225 let left_fields: Vec<&chartml_core::spec::FieldSpec> = fields.iter()
1227 .filter(|f| f.axis.as_deref() != Some("right"))
1228 .collect();
1229 let right_fields: Vec<&chartml_core::spec::FieldSpec> = fields.iter()
1230 .filter(|f| f.axis.as_deref() == Some("right"))
1231 .collect();
1232
1233 let left_max = if let (true, Some(color_f)) = (is_stacked, color_field.as_ref()) {
1236 let color_series = data.unique_values(color_f);
1237 let mut max_stack = 0.0_f64;
1238 for f in &left_fields {
1239 let Some(field_name) = f.field.as_deref() else { continue };
1240 for cat in &categories {
1241 let mut stack_total = 0.0_f64;
1242 for series in &color_series {
1243 let val = (0..data.num_rows())
1244 .find(|&i| {
1245 data.get_string(i, &category_field).as_deref() == Some(cat.as_str())
1246 && data.get_string(i, color_f).as_deref() == Some(series.as_str())
1247 })
1248 .and_then(|i| data.get_f64(i, field_name))
1249 .unwrap_or(0.0);
1250 stack_total += val;
1251 }
1252 max_stack = max_stack.max(stack_total);
1253 }
1254 }
1255 max_stack
1256 } else {
1257 left_fields.iter()
1258 .filter_map(|f| f.field.as_deref())
1259 .flat_map(|name| (0..data.num_rows()).filter_map(move |i| data.get_f64(i, name)))
1260 .fold(0.0_f64, f64::max)
1261 };
1262 let left_data_min = left_fields.iter()
1264 .filter_map(|f| f.field.as_deref())
1265 .flat_map(|name| (0..data.num_rows()).filter_map(move |i| data.get_f64(i, name)))
1266 .fold(0.0_f64, f64::min);
1267 let left_data_min = if left_data_min >= 0.0 { 0.0 } else { left_data_min };
1269 let axes_left = config.visualize.axes.as_ref().and_then(|a| a.left.as_ref());
1270 let left_explicit_min = axes_left.and_then(|a| a.min);
1271 let left_explicit_max = axes_left.and_then(|a| a.max);
1272 let raw_left_domain_min = left_explicit_min.unwrap_or(left_data_min);
1273 let raw_left_domain_max = left_explicit_max.unwrap_or(if left_max <= 0.0 { 1.0 } else { left_max });
1274 let (left_domain_min, left_domain_max) = if left_explicit_min.is_none() && left_explicit_max.is_none() {
1275 nice_domain(raw_left_domain_min, raw_left_domain_max, 5)
1277 } else {
1278 (raw_left_domain_min, raw_left_domain_max)
1279 };
1280 let left_scale = ScaleLinear::new((left_domain_min, left_domain_max), (inner_height, 0.0));
1281
1282 let right_scale = if !right_fields.is_empty() {
1284 let right_max = right_fields.iter()
1285 .filter_map(|f| f.field.as_deref())
1286 .flat_map(|name| (0..data.num_rows()).filter_map(move |i| data.get_f64(i, name)))
1287 .fold(0.0_f64, f64::max);
1288 let right_data_min = right_fields.iter()
1289 .filter_map(|f| f.field.as_deref())
1290 .flat_map(|name| (0..data.num_rows()).filter_map(move |i| data.get_f64(i, name)))
1291 .fold(0.0_f64, f64::min);
1292 let axes_right = config.visualize.axes.as_ref().and_then(|a| a.right.as_ref());
1293 let right_explicit_min = axes_right.and_then(|a| a.min);
1294 let right_explicit_max = axes_right.and_then(|a| a.max);
1295 let raw_right_domain_min = right_explicit_min.unwrap_or(if right_data_min < 0.0 { right_data_min } else { 0.0 });
1296 let raw_right_domain_max = right_explicit_max.unwrap_or(if right_max <= 0.0 { 1.0 } else { right_max });
1297 let (right_domain_min, right_domain_max) = if right_explicit_min.is_none() && right_explicit_max.is_none() {
1298 nice_domain(raw_right_domain_min, raw_right_domain_max, 5)
1300 } else {
1301 (raw_right_domain_min, raw_right_domain_max)
1302 };
1303 Some(ScaleLinear::new((right_domain_min, right_domain_max), (inner_height, 0.0)))
1304 } else {
1305 None
1306 };
1307
1308 let mut children = Vec::new();
1309
1310 let bottom_axis_label = config.visualize.axes.as_ref()
1314 .and_then(|a| a.x.as_ref())
1315 .and_then(|a| a.label.as_deref());
1316 let x_axis_result = generate_x_axis(&crate::helpers::XAxisParams {
1317 labels: &categories,
1318 display_label_overrides: None,
1319 range: (0.0, inner_width),
1320 y_position: margins.top + inner_height,
1321 available_width: inner_width,
1322 x_format: x_format.as_deref(),
1323 chart_height: Some(inner_height),
1324 grid: &grid,
1325 axis_label: bottom_axis_label,
1326 theme: &config.theme,
1327 });
1328 let left_axis_label = axes_left.and_then(|a| a.label.as_deref());
1329 let y_axis_left = generate_y_axis_numeric(&crate::helpers::YAxisNumericParams {
1330 domain: (left_domain_min, left_domain_max),
1331 range: (inner_height, 0.0),
1332 x_position: margins.left,
1333 fmt: y_fmt_ref,
1334 tick_count: adaptive_tick_count(inner_height),
1335 chart_width: Some(inner_width),
1336 grid: &grid,
1337 axis_label: left_axis_label,
1338 theme: &config.theme,
1339 });
1340
1341 let mut axis_elements = Vec::new();
1342 axis_elements.extend(x_axis_result.elements.into_iter().map(|e| offset_element(e, margins.left, 0.0)));
1343 axis_elements.extend(y_axis_left.into_iter().map(|e| offset_element(e, 0.0, margins.top)));
1344 if let Some(zl) = emit_zero_line_if_crosses(
1349 &config.theme,
1350 (left_domain_min, left_domain_max),
1351 inner_width,
1352 inner_height,
1353 false,
1354 ) {
1355 axis_elements.push(offset_element(zl, margins.left, margins.top));
1356 }
1357
1358 if let Some(ref rs) = right_scale {
1360 let right_fmt = config.visualize.axes.as_ref()
1361 .and_then(|a| a.right.as_ref())
1362 .and_then(|a| a.format.as_deref());
1363 let right_axis = generate_y_axis_numeric_right(
1366 rs.domain(), (inner_height, 0.0), margins.left + inner_width,
1367 right_fmt, adaptive_tick_count(inner_height),
1368 None, &config.theme,
1369 );
1370 axis_elements.extend(right_axis.into_iter().map(|e| offset_element(e, 0.0, margins.top)));
1371 }
1372
1373 if let Some(label) = config.visualize.axes.as_ref().and_then(|a| a.right.as_ref()).and_then(|a| a.label.clone()) {
1376 let rx = config.width - 12.0;
1377 let ts = TextStyle::for_role(&config.theme, TextRole::AxisLabel);
1378 axis_elements.push(ChartElement::Text {
1379 x: rx,
1380 y: margins.top + inner_height / 2.0,
1381 content: label,
1382 anchor: TextAnchor::Middle,
1383 dominant_baseline: None,
1384 transform: Some(Transform::Rotate(90.0, rx, margins.top + inner_height / 2.0)),
1385 font_family: ts.font_family,
1386 font_size: ts.font_size,
1387 font_weight: ts.font_weight,
1388 letter_spacing: ts.letter_spacing,
1389 text_transform: ts.text_transform,
1390 fill: Some(config.theme.text_secondary.clone()),
1391 class: "axis-label".to_string(),
1392 data: None,
1393 });
1394 }
1395
1396 children.push(ChartElement::Group {
1397 class: "axes".to_string(), transform: None, children: axis_elements,
1398 });
1399
1400 let mut mark_elements = Vec::new();
1402 let line_gen = LineGenerator::new().curve(chartml_core::shapes::CurveType::MonotoneX);
1403
1404 let num_bar_fields = fields.iter()
1406 .filter(|f| f.mark.as_deref().unwrap_or("bar") == "bar")
1407 .count()
1408 .max(1);
1409 let max_bar_width = inner_width * 0.2;
1411 let effective_bandwidth = bandwidth.min(max_bar_width);
1412 let combo_x_inset = (bandwidth - effective_bandwidth) / 2.0;
1413 let sub_bar_padding = effective_bandwidth * 0.05;
1414 let sub_bar_width = (effective_bandwidth - sub_bar_padding * (num_bar_fields as f64 - 1.0).max(0.0)) / num_bar_fields as f64;
1415 let mut bar_field_idx = 0_usize;
1416 let mut series_names = Vec::new();
1417 let mut series_colors = Vec::new();
1418 let mut series_marks = Vec::new();
1419
1420 let stacked_bar_rendered = if let (true, Some(color_f)) = (is_stacked, color_field.as_ref()) {
1422 let color_series = data.unique_values(color_f);
1423
1424 for field_spec in fields.iter() {
1426 let mark = field_spec.mark.as_deref().unwrap_or("bar");
1427 if mark != "bar" { continue; }
1428
1429 let field_name = field_spec.field.as_deref().unwrap_or("");
1430 let is_right = field_spec.axis.as_deref() == Some("right");
1431 let scale = if is_right { right_scale.as_ref().unwrap_or(&left_scale) } else { &left_scale };
1432 let fmt_ref = if is_right {
1433 config.visualize.axes.as_ref().and_then(|a| a.right.as_ref()).and_then(|a| a.format.as_deref())
1434 } else {
1435 y_fmt_ref
1436 };
1437
1438 let mut values_matrix: Vec<Vec<f64>> = Vec::new();
1440 for series in &color_series {
1441 let mut series_vals = Vec::new();
1442 for cat in &categories {
1443 let val = (0..data.num_rows())
1444 .find(|&i| {
1445 data.get_string(i, &category_field).as_deref() == Some(cat.as_str())
1446 && data.get_string(i, color_f).as_deref() == Some(series.as_str())
1447 })
1448 .and_then(|i| data.get_f64(i, field_name))
1449 .unwrap_or(0.0);
1450 series_vals.push(val);
1451 }
1452 values_matrix.push(series_vals);
1453 }
1454
1455 let stack = StackLayout::new();
1456 let stacked_points = stack.layout(&categories, &color_series, &values_matrix);
1457
1458 let bar_render_width = bandwidth.min(max_bar_width);
1459 let x_inset = (bandwidth - bar_render_width) / 2.0;
1460
1461 for point in &stacked_points {
1462 let x = match band.map(&point.key) { Some(x) => x, None => continue };
1463 let y_top = scale.map(point.y1);
1464 let y_bottom = scale.map(point.y0);
1465 let bar_height = (y_bottom - y_top).abs();
1466
1467 let series_idx = color_series.iter().position(|s| s == &point.series).unwrap_or(0);
1468 let fill = config.colors.get(series_idx).cloned().unwrap_or_else(|| "#2E7D9A".to_string());
1469
1470 mark_elements.push(build_bar_element(
1471 BarRectSpec {
1472 x: x + x_inset + margins.left,
1473 y: y_top + margins.top,
1474 width: bar_render_width,
1475 height: bar_height,
1476 is_horizontal: false,
1477 is_negative: point.value < 0.0,
1478 fill,
1479 class: "bar bar-rect".to_string(),
1480 data: Some(
1481 ElementData::new(&point.key, format_value(point.value, fmt_ref))
1482 .with_series(&point.series),
1483 ),
1484 },
1485 &config.theme,
1486 ));
1487 }
1488 }
1489
1490 for (si, series_name) in color_series.iter().enumerate() {
1492 let color = config.colors.get(si).cloned().unwrap_or_else(|| "#2E7D9A".to_string());
1493 series_names.push(series_name.clone());
1494 series_colors.push(color);
1495 series_marks.push("bar".to_string());
1496 }
1497
1498 true
1499 } else {
1500 false
1501 };
1502
1503 for (field_idx, field_spec) in fields.iter().enumerate() {
1504 if field_spec.mark.as_deref() == Some("range") {
1510 continue;
1511 }
1512 let field_name = field_spec.field.as_deref().unwrap_or("");
1513 let is_right = field_spec.axis.as_deref() == Some("right");
1514 let scale = if is_right { right_scale.as_ref().unwrap_or(&left_scale) } else { &left_scale };
1515 let mark = field_spec.mark.as_deref().unwrap_or("bar");
1516 let color = field_spec.color.clone()
1517 .unwrap_or_else(|| config.colors.get(field_idx).cloned().unwrap_or_else(|| "#2E7D9A".to_string()));
1518 let label = field_spec.label.clone().unwrap_or_else(|| field_name.to_string());
1519 let fmt_ref = if is_right {
1520 config.visualize.axes.as_ref().and_then(|a| a.right.as_ref()).and_then(|a| a.format.as_deref())
1521 } else {
1522 y_fmt_ref
1523 };
1524
1525 match mark {
1526 "bar" if stacked_bar_rendered => {
1527 }
1529 "bar" => {
1530 let this_bar_idx = bar_field_idx;
1531 bar_field_idx += 1;
1532
1533 for row_i in 0..data.num_rows() {
1534 let cat = match data.get_string(row_i, &category_field) { Some(c) => c, None => continue };
1535 let val = data.get_f64(row_i, field_name).unwrap_or(0.0);
1536 let x = match band.map(&cat) { Some(x) => x, None => continue };
1537 let bar_x = x + combo_x_inset + this_bar_idx as f64 * (sub_bar_width + sub_bar_padding);
1538 let bar_val_y = scale.map(val);
1539 let bar_zero_y = scale.map(0.0);
1540 let bar_height = (bar_zero_y - bar_val_y).abs();
1541 let rect_y = bar_val_y.min(bar_zero_y);
1542
1543 mark_elements.push(build_bar_element(
1544 BarRectSpec {
1545 x: bar_x + margins.left,
1546 y: rect_y + margins.top,
1547 width: sub_bar_width,
1548 height: bar_height,
1549 is_horizontal: false,
1550 is_negative: val < 0.0,
1551 fill: color.clone(),
1552 class: "bar bar-rect".to_string(),
1553 data: Some(
1554 ElementData::new(&cat, format_value(val, fmt_ref))
1555 .with_series(&label),
1556 ),
1557 },
1558 &config.theme,
1559 ));
1560
1561 if let Some(ref dl) = field_spec.data_labels {
1563 if dl.show == Some(true) {
1564 let dl_fmt = dl.format.as_deref().or(fmt_ref);
1565 mark_elements.push(ChartElement::Text {
1566 x: bar_x + sub_bar_width / 2.0 + margins.left,
1567 y: rect_y + margins.top - 5.0,
1568 content: format_value(val, dl_fmt),
1569 anchor: TextAnchor::Middle, dominant_baseline: None,
1570 transform: None,
1571 font_family: None,
1572 font_size: Some(dl.font_size.map(|s| format!("{}px", s)).unwrap_or_else(|| "12px".to_string())),
1573 font_weight: None,
1574 letter_spacing: None,
1575 text_transform: None,
1576 fill: Some(dl.color.clone().unwrap_or_else(|| config.theme.text_secondary.clone())),
1577 class: "data-label".to_string(), data: None,
1578 });
1579 }
1580 }
1581 }
1582 }
1583 _ => {
1584 let mut points = Vec::new();
1585 let mut point_data = Vec::new();
1586 for cat in &categories {
1587 let row_i = match (0..data.num_rows()).find(|&i| data.get_string(i, &category_field).as_deref() == Some(cat.as_str())) {
1588 Some(i) => i, None => continue,
1589 };
1590 let val = match data.get_f64(row_i, field_name) { Some(v) => v, None => continue };
1591 let x = match band.map(cat) { Some(x) => x + bandwidth / 2.0, None => continue };
1592 let y = scale.map(val);
1593 points.push((x + margins.left, y + margins.top));
1594 point_data.push((cat.clone(), val));
1595 }
1596
1597 if !points.is_empty() {
1598 let path_d = line_gen.generate(&points);
1599 mark_elements.push(ChartElement::Path {
1600 d: path_d, fill: None, stroke: Some(color.clone()),
1601 stroke_width: Some(config.theme.series_line_weight as f64), stroke_dasharray: None,
1602 opacity: None,
1603 class: "chartml-line-path series-line".to_string(),
1604 data: Some(ElementData::new(&label, "").with_series(&label)),
1605 animation_origin: None,
1606 });
1607
1608 let dot_r = config.theme.dot_radius as f64;
1610 for (i, &(px, py)) in points.iter().enumerate() {
1611 let (ref cat, val) = point_data[i];
1612 if let Some(halo) = emit_dot_halo_if_enabled(&config.theme, px, py, dot_r) {
1613 mark_elements.push(halo);
1614 }
1615 mark_elements.push(ChartElement::Circle {
1616 cx: px, cy: py, r: dot_r,
1617 fill: color.clone(), stroke: Some(config.theme.bg.clone()),
1618 class: "chartml-line-dot dot-marker".to_string(),
1619 data: Some(ElementData::new(cat, format_value(val, fmt_ref)).with_series(&label)),
1620 });
1621 }
1622
1623 if let Some(ref dl) = field_spec.data_labels {
1625 if dl.show == Some(true) {
1626 let dl_fmt = dl.format.as_deref().or(fmt_ref);
1627 for (i, &(px, py)) in points.iter().enumerate() {
1628 let (_, val) = &point_data[i];
1629 mark_elements.push(ChartElement::Text {
1630 x: px, y: py - 10.0,
1631 content: format_value(*val, dl_fmt),
1632 anchor: TextAnchor::Middle, dominant_baseline: None,
1633 transform: None,
1634 font_family: None,
1635 font_size: Some(dl.font_size.map(|s| format!("{}px", s)).unwrap_or_else(|| "12px".to_string())),
1636 font_weight: None,
1637 letter_spacing: None,
1638 text_transform: None,
1639 fill: Some(dl.color.clone().unwrap_or_else(|| color.clone())),
1640 class: "data-label".to_string(), data: None,
1641 });
1642 }
1643 }
1644 }
1645 }
1646 }
1647 }
1648
1649 if !(stacked_bar_rendered && mark == "bar") {
1652 series_names.push(label);
1653 series_colors.push(color);
1654 series_marks.push(mark.to_string());
1655 }
1656 }
1657
1658 children.push(ChartElement::Group {
1659 class: "marks".to_string(), transform: None, children: mark_elements,
1660 });
1661
1662 if let Some(annotations) = config.visualize.annotations.as_deref() {
1664 if !annotations.is_empty() {
1665 let ann_elements = generate_annotations(
1666 annotations,
1667 &left_scale,
1668 0.0,
1669 inner_width,
1670 inner_height,
1671 Some(&categories),
1672 &config.theme,
1673 );
1674 if !ann_elements.is_empty() {
1675 children.push(ChartElement::Group {
1676 class: "annotations".to_string(),
1677 transform: Some(Transform::Translate(margins.left, margins.top)),
1678 children: ann_elements,
1679 });
1680 }
1681 }
1682 }
1683
1684 if series_names.len() > 1 {
1686 let combo_legend_metrics = TextMetrics::from_theme_legend(&config.theme);
1687 let mut legend_elements = Vec::new();
1688 let total_w: f64 = series_names.iter().map(|name| {
1689 let tw = measure_text(name, &combo_legend_metrics);
1690 12.0 + 6.0 + tw + 16.0
1691 }).sum();
1692 let mut x_offset = (config.width - total_w).max(0.0) / 2.0;
1693 let legend_y = config.height - combo_legend_height - 8.0;
1694
1695 for (i, name) in series_names.iter().enumerate() {
1696 let color = &series_colors[i];
1697 let mark = series_marks[i].as_str();
1698 let y = legend_y;
1699
1700 match mark {
1701 "line" => {
1702 legend_elements.push(ChartElement::Line {
1703 x1: x_offset, y1: y + 6.0, x2: x_offset + 12.0, y2: y + 6.0,
1704 stroke: color.clone(), stroke_width: Some(2.5),
1705 stroke_dasharray: None, class: "legend-symbol legend-line".to_string(),
1706 });
1707 }
1708 _ => {
1709 legend_elements.push(ChartElement::Rect {
1710 x: x_offset, y, width: 12.0, height: 12.0,
1711 fill: color.clone(), stroke: None,
1712 rx: None, ry: None,
1713 class: "legend-symbol".to_string(), data: None,
1714 animation_origin: None,
1715 });
1716 }
1717 }
1718
1719 let ts = TextStyle::for_role(&config.theme, TextRole::LegendLabel);
1720 legend_elements.push(ChartElement::Text {
1721 x: x_offset + 18.0, y: y + 10.0, content: name.clone(),
1722 anchor: TextAnchor::Start, dominant_baseline: None,
1723 transform: None,
1724 font_family: ts.font_family,
1725 font_size: ts.font_size,
1726 font_weight: ts.font_weight,
1727 letter_spacing: ts.letter_spacing,
1728 text_transform: ts.text_transform,
1729 fill: Some(config.theme.text_secondary.clone()), class: "legend-label".to_string(), data: None,
1730 });
1731
1732 let tw = measure_text(name, &combo_legend_metrics);
1733 x_offset += 12.0 + 6.0 + tw + 16.0;
1734 }
1735
1736 children.push(ChartElement::Group {
1737 class: "legend".to_string(), transform: None, children: legend_elements,
1738 });
1739 }
1740
1741 Ok(ChartElement::Svg {
1742 viewbox: ViewBox::new(0.0, 0.0, config.width, config.height),
1743 width: Some(config.width),
1744 height: Some(config.height),
1745 class: "chartml-bar chartml-combo".to_string(),
1746 children,
1747 })
1748}
1749