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: 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 let cat = data.get_string(i, &category_field).unwrap_or_default();
274 let val = data.get_f64(i, &field_spec.field).unwrap_or(0.0);
275 let label = field_spec.label.clone().unwrap_or_else(|| field_spec.field.clone());
276 let mut row = std::collections::HashMap::new();
277 row.insert(category_field.clone(), serde_json::json!(cat));
278 row.insert("_value".to_string(), serde_json::json!(val));
279 row.insert("_series".to_string(), serde_json::json!(label));
280 long_rows.push(row);
281 }
282 }
283 let long_data = DataTable::from_rows(&long_rows)
284 .map_err(|e| ChartError::DataError(format!("Failed to reshape data: {}", e)))?;
285
286 let mut viz = config.visualize.clone();
288 viz.rows = Some(FieldRef::Simple("_value".to_string()));
289 viz.marks = Some(chartml_core::spec::MarksSpec {
290 color: Some(chartml_core::spec::MarkEncoding::Simple("_series".to_string())),
291 size: None, shape: None, text: None,
292 });
293 viz.mode = Some(ChartMode::Grouped);
294 let mut colors = Vec::new();
296 for (i, f) in multi_fields.iter().enumerate() {
297 colors.push(f.color.clone().unwrap_or_else(|| {
298 config.colors.get(i).cloned().unwrap_or_else(|| "#2E7D9A".to_string())
299 }));
300 }
301 let long_config = ChartConfig {
302 visualize: viz,
303 title: config.title.clone(),
304 width: config.width,
305 height: config.height,
306 colors,
307 theme: config.theme.clone(),
308 };
309 return render_bar(&long_data, &long_config);
310 }
311
312 return render_combo(data, config, &multi_fields);
313 }
314
315 let category_field = get_field_name(&config.visualize.columns)?;
316 let value_field = get_field_name(&config.visualize.rows)?;
317
318 let color_field = get_color_field(config);
319
320 let (categories, display_labels): (Vec<String>, Option<Vec<String>>) = if color_field.is_none() {
326 let all_vals = data.all_values(&category_field);
327 if all_vals.is_empty() {
328 return Err(ChartError::DataError("No category values found".into()));
329 }
330 let has_duplicates = {
332 let mut seen = std::collections::HashSet::new();
333 all_vals.iter().any(|v| !seen.insert(v.as_str()))
334 };
335 if has_duplicates {
336 let band_keys: Vec<String> = all_vals.iter().enumerate()
337 .map(|(i, v)| format!("{}\x00{}", v, i))
338 .collect();
339 (band_keys, Some(all_vals))
340 } else {
341 (all_vals, None)
342 }
343 } else {
344 let unique = data.unique_values(&category_field);
345 if unique.is_empty() {
346 return Err(ChartError::DataError("No category values found".into()));
347 }
348 (unique, None)
349 };
350
351 let is_horizontal = matches!(config.visualize.orientation, Some(Orientation::Horizontal));
352 let is_normalized = matches!(config.visualize.mode, Some(ChartMode::Normalized));
353 let is_stacked = matches!(config.visualize.mode, Some(ChartMode::Stacked)) || is_normalized;
354 let _is_grouped = matches!(config.visualize.mode, Some(ChartMode::Grouped));
355
356 let x_format = get_x_format(config);
358 let y_fmt = get_y_format(config);
359 let y_fmt_ref = y_fmt.as_deref();
360 let (axis_min, axis_max) = get_y_axis_bounds(config);
361
362 let raw_for_strategy = display_labels.as_deref().unwrap_or(&categories);
365 let formatted_for_strategy = crate::helpers::format_display_labels(raw_for_strategy, x_format.as_deref());
366 let x_extra_margin = if !is_horizontal {
367 let estimated_width = config.width - 80.0;
368 let label_strategy_config = LabelStrategyConfig {
369 text_metrics: TextMetrics::from_theme_axis_label(&config.theme),
370 ..LabelStrategyConfig::default()
371 };
372 let x_strategy = LabelStrategy::determine(&formatted_for_strategy, estimated_width, &label_strategy_config);
373 match &x_strategy {
374 LabelStrategy::Rotated { margin, .. } => *margin,
375 _ => 0.0,
376 }
377 } else {
378 0.0
379 };
380
381 let (prelim_data_min, prelim_data_max): (f64, f64) = if let Some(ref color_f) = color_field {
384 if is_stacked {
385 let groups = data.group_by(color_f);
386 let series_names = data.unique_values(color_f);
387 let stacked_vals: Vec<f64> = categories.iter().map(|cat| {
388 series_names.iter().map(|s| {
389 groups.get(s).and_then(|series_data| {
390 (0..series_data.num_rows()).find_map(|i| {
391 if series_data.get_string(i, &category_field).as_deref() == Some(cat.as_str()) {
392 series_data.get_f64(i, &value_field)
393 } else {
394 None
395 }
396 })
397 }).unwrap_or(0.0)
398 }).sum::<f64>()
399 }).collect();
400 let mn = stacked_vals.iter().cloned().fold(f64::INFINITY, f64::min);
401 let mx = stacked_vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
402 (mn, mx)
403 } else {
404 let vals: Vec<f64> = (0..data.num_rows()).filter_map(|i| data.get_f64(i, &value_field)).collect();
405 let mn = vals.iter().cloned().fold(f64::INFINITY, f64::min);
406 let mx = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
407 (mn, mx)
408 }
409 } else {
410 let vals: Vec<f64> = (0..data.num_rows()).filter_map(|i| data.get_f64(i, &value_field)).collect();
411 let mn = vals.iter().cloned().fold(f64::INFINITY, f64::min);
412 let mx = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
413 (mn, mx)
414 };
415 let prelim_data_max = if prelim_data_max <= 0.0 { 1.0 } else { prelim_data_max };
416 let prelim_data_min = if prelim_data_min >= 0.0 { 0.0 } else { prelim_data_min };
418
419 let prelim_domain_max = if is_normalized {
420 1.0
421 } else {
422 let raw_max = axis_max.unwrap_or(prelim_data_max);
423 if axis_max.is_none() { nice_domain(axis_min.unwrap_or(prelim_data_min), raw_max, 5).1 } else { raw_max }
424 };
425 let prelim_domain_min = if is_normalized { 0.0 } else { axis_min.unwrap_or(prelim_data_min) };
426
427 let y_tick_labels_for_margin: Vec<String> = if !is_horizontal {
430 let prelim_fmt = if is_normalized { Some(".0%") } else { y_fmt_ref };
431 vec![
432 format_value(prelim_domain_max, prelim_fmt),
433 format_value(prelim_domain_min, prelim_fmt),
434 ]
435 } else {
436 let display = display_labels.as_deref().unwrap_or(&categories);
437 display.to_vec()
438 };
439
440 let legend_height = if let Some(ref color_f) = color_field {
442 let series_names = data.unique_values(color_f);
443 let legend_config = LegendConfig {
444 text_metrics: TextMetrics::from_theme_legend(&config.theme),
445 ..LegendConfig::default()
446 };
447 calculate_legend_layout(&series_names, &config.colors, config.width, &legend_config).total_height
448 } else {
449 0.0
450 };
451
452 let has_x_axis_label = config.visualize.axes.as_ref()
454 .and_then(|a| a.x.as_ref())
455 .and_then(|a| a.label.as_ref())
456 .is_some();
457 let has_y_axis_label = config.visualize.axes.as_ref()
458 .and_then(|a| a.left.as_ref())
459 .and_then(|a| a.label.as_ref())
460 .is_some();
461 let margin_config = MarginConfig {
462 has_title: config.title.is_some(),
463 legend_height,
464 has_x_axis_label,
465 has_y_axis_label,
466 x_label_strategy_margin: x_extra_margin,
467 y_tick_labels: y_tick_labels_for_margin,
468 tick_value_metrics: if is_horizontal {
471 TextMetrics::from_theme_axis_label(&config.theme)
472 } else {
473 TextMetrics::from_theme_tick_value(&config.theme)
474 },
475 axis_label_metrics: TextMetrics::from_theme_axis_label(&config.theme),
476 ..Default::default()
477 };
478 let margins = calculate_margins(&margin_config);
479
480 let inner_width = margins.inner_width(config.width);
481 let inner_height = margins.inner_height(config.height);
482
483 let mut children = Vec::new();
484
485 let grid = GridConfig::from_config(config);
489
490 let _tick_count = adaptive_tick_count(inner_height);
491
492 let raw_data_max = prelim_data_max;
494
495 let (domain_min, domain_max) = if is_normalized {
497 (0.0, 1.0)
498 } else {
499 let raw_domain_min = axis_min.unwrap_or(prelim_data_min);
500 let raw_domain_max = axis_max.unwrap_or(raw_data_max);
501 if axis_min.is_none() && axis_max.is_none() {
504 nice_domain(raw_domain_min, raw_domain_max, 5)
506 } else {
507 (raw_domain_min, raw_domain_max)
508 }
509 };
510 let effective_y_fmt: Option<String> = if is_normalized {
513 Some(".0%".to_string())
514 } else {
515 y_fmt.clone()
516 };
517 let effective_y_fmt_ref = effective_y_fmt.as_deref();
518
519 let (_, bar_elements) = if let Some(ref color_f) = color_field {
520 render_multi_series_bars(
521 data,
522 config,
523 &MultiSeriesBarParams {
524 category_field: &category_field,
525 value_field: &value_field,
526 color_field: color_f,
527 categories: &categories,
528 inner_width,
529 inner_height,
530 is_stacked,
531 is_normalized,
532 is_horizontal,
533 y_fmt_ref,
534 domain_min,
535 domain_max,
536 },
537 )?
538 } else {
539 render_single_series_bars(
540 data,
541 config,
542 &SingleSeriesBarParams {
543 category_field: &category_field,
544 value_field: &value_field,
545 categories: &categories,
546 inner_width,
547 inner_height,
548 is_horizontal,
549 y_fmt_ref,
550 domain_min,
551 domain_max,
552 },
553 )?
554 };
555
556 let axis_elements = if is_horizontal {
558 let x_axis = generate_y_axis_with_display(&categories, display_labels.as_deref(), (0.0, inner_height), 0.0, None, &config.theme);
560 let y_axis = generate_x_axis_numeric(&crate::helpers::XAxisNumericParams {
561 domain: (domain_min, domain_max),
562 range: (0.0, inner_width),
563 y_position: margins.top + inner_height,
564 fmt: effective_y_fmt_ref,
565 tick_count: 5,
566 chart_height: Some(inner_height),
567 grid: &grid,
568 theme: &config.theme,
569 });
570 let mut axes = Vec::new();
571 axes.extend(x_axis.into_iter().map(|e| offset_element(e, margins.left, margins.top)));
572 axes.extend(y_axis.into_iter().map(|e| offset_element(e, margins.left, 0.0)));
573 if let Some(zl) = emit_zero_line_if_crosses(
578 &config.theme,
579 (domain_min, domain_max),
580 inner_width,
581 inner_height,
582 true,
583 ) {
584 axes.push(offset_element(zl, margins.left, margins.top));
585 }
586 axes
587 } else {
588 let bottom_axis_label = config.visualize.axes.as_ref()
589 .and_then(|a| a.x.as_ref())
590 .and_then(|a| a.label.as_deref());
591 let x_axis_result = generate_x_axis_with_display(&crate::helpers::XAxisParams {
592 labels: &categories,
593 display_label_overrides: display_labels.as_deref(),
594 range: (0.0, inner_width),
595 y_position: margins.top + inner_height,
596 available_width: inner_width,
597 x_format: x_format.as_deref(),
598 chart_height: Some(inner_height),
599 grid: &grid,
600 axis_label: bottom_axis_label,
601 theme: &config.theme,
602 });
603 let left_axis_label = config.visualize.axes.as_ref()
604 .and_then(|a| a.left.as_ref())
605 .and_then(|a| a.label.as_deref());
606 let y_axis = generate_y_axis_numeric(&crate::helpers::YAxisNumericParams {
607 domain: (domain_min, domain_max),
608 range: (inner_height, 0.0),
609 x_position: margins.left,
610 fmt: effective_y_fmt_ref,
611 tick_count: adaptive_tick_count(inner_height),
612 chart_width: Some(inner_width),
613 grid: &grid,
614 axis_label: left_axis_label,
615 theme: &config.theme,
616 });
617 let mut axes = Vec::new();
618 axes.extend(x_axis_result.elements.into_iter().map(|e| offset_element(e, margins.left, 0.0)));
619 axes.extend(y_axis.into_iter().map(|e| offset_element(e, 0.0, margins.top)));
620 if let Some(zl) = emit_zero_line_if_crosses(
624 &config.theme,
625 (domain_min, domain_max),
626 inner_width,
627 inner_height,
628 is_horizontal,
629 ) {
630 axes.push(offset_element(zl, margins.left, margins.top));
631 }
632 axes
633 };
634
635 children.push(ChartElement::Group {
636 class: "axes".to_string(),
637 transform: None,
638 children: axis_elements,
639 });
640
641 children.push(ChartElement::Group {
642 class: "bars".to_string(),
643 transform: Some(Transform::Translate(margins.left, margins.top)),
644 children: bar_elements,
645 });
646
647 if !is_horizontal {
649 if let Some(annotations) = config.visualize.annotations.as_deref() {
650 if !annotations.is_empty() {
651 use chartml_core::scales::ScaleLinear;
652 let ann_scale = ScaleLinear::new((domain_min, domain_max), (inner_height, 0.0));
653 let ann_cats = display_labels.as_deref().unwrap_or(&categories);
654 let ann_elements = generate_annotations(
655 annotations,
656 &ann_scale,
657 0.0,
658 inner_width,
659 inner_height,
660 Some(ann_cats),
661 &config.theme,
662 );
663 if !ann_elements.is_empty() {
664 children.push(ChartElement::Group {
665 class: "annotations".to_string(),
666 transform: Some(Transform::Translate(margins.left, margins.top)),
667 children: ann_elements,
668 });
669 }
670 }
671 }
672 }
673
674 if let Some(ref color_f) = color_field {
676 let series_names = data.unique_values(color_f);
677 let legend_config = LegendConfig {
678 text_metrics: TextMetrics::from_theme_legend(&config.theme),
679 ..LegendConfig::default()
680 };
681 let legend_layout = calculate_legend_layout(&series_names, &config.colors, config.width, &legend_config);
682 let legend_y = config.height - legend_layout.total_height - 8.0;
683 let legend_elements = generate_legend(&series_names, &config.colors, config.width, legend_y, &config.theme);
684 children.push(ChartElement::Group {
685 class: "legend".to_string(),
686 transform: None,
687 children: legend_elements,
688 });
689 }
690
691 let svg_class = if is_horizontal { "chartml-bar chartml-horizontal" } else { "chartml-bar" };
692 Ok(ChartElement::Svg {
693 viewbox: ViewBox::new(0.0, 0.0, config.width, config.height),
694 width: Some(config.width),
695 height: Some(config.height),
696 class: svg_class.to_string(),
697 children,
698 })
699}
700
701fn render_single_series_bars(
702 data: &DataTable,
703 config: &ChartConfig,
704 params: &SingleSeriesBarParams,
705) -> Result<(f64, Vec<ChartElement>), ChartError> {
706 let category_field = params.category_field;
707 let value_field = params.value_field;
708 let categories = params.categories;
709 let inner_width = params.inner_width;
710 let inner_height = params.inner_height;
711 let is_horizontal = params.is_horizontal;
712 let y_fmt_ref = params.y_fmt_ref;
713 let domain_min = params.domain_min;
714 let domain_max = params.domain_max;
715 let values: Vec<f64> = (0..data.num_rows())
717 .filter_map(|i| data.get_f64(i, value_field))
718 .collect();
719 let value_max = values.iter().cloned().fold(0.0_f64, f64::max);
720 let value_max = if value_max <= 0.0 { 1.0 } else { value_max };
721 let effective_max = domain_max;
723
724 let mut elements = Vec::new();
725 let fill_color = config.colors.first()
728 .cloned()
729 .unwrap_or_else(|| "#2E7D9A".to_string());
730
731 if is_horizontal {
732 let band = ScaleBand::new(categories.to_vec(), (0.0, inner_height))
733 .padding(crate::helpers::adaptive_bar_padding(categories.len()));
734 let linear = ScaleLinear::new((domain_min, effective_max), (0.0, inner_width));
735 let bar_render_height = band.bandwidth().min(40.0);
737 let y_inset = (band.bandwidth() - bar_render_height) / 2.0;
738
739 for i in 0..data.num_rows() {
740 let cat = match data.get_string(i, category_field) {
741 Some(c) => c,
742 None => continue,
743 };
744 let val = data.get_f64(i, value_field).unwrap_or(0.0);
745 let band_key = categories.get(i).map(|k| k.as_str()).unwrap_or(&cat);
747 let y = match band.map(band_key) {
748 Some(y) => y,
749 None => continue,
750 };
751 let bar_width = linear.map(val);
752
753 elements.push(build_bar_element(
754 BarRectSpec {
755 x: 0.0,
756 y: y + y_inset,
757 width: bar_width,
758 height: bar_render_height,
759 is_horizontal: true,
760 is_negative: val < 0.0,
761 fill: fill_color.clone(),
762 class: "bar bar-rect".to_string(),
763 data: Some(ElementData::new(&cat, format_value(val, y_fmt_ref))),
764 },
765 &config.theme,
766 ));
767 }
768 } else {
769 let band = ScaleBand::new(categories.to_vec(), (0.0, inner_width))
770 .padding(crate::helpers::adaptive_bar_padding(categories.len()));
771 let linear = ScaleLinear::new((domain_min, effective_max), (inner_height, 0.0));
772 let max_bar_width = inner_width * 0.2;
774 let bar_render_width = band.bandwidth().min(max_bar_width);
775 let x_inset = (band.bandwidth() - bar_render_width) / 2.0;
776
777 for i in 0..data.num_rows() {
778 let cat = match data.get_string(i, category_field) {
779 Some(c) => c,
780 None => continue,
781 };
782 let val = data.get_f64(i, value_field).unwrap_or(0.0);
783 let band_key = categories.get(i).map(|k| k.as_str()).unwrap_or(&cat);
785 let x = match band.map(band_key) {
786 Some(x) => x,
787 None => continue,
788 };
789 let bar_val_y = linear.map(val);
790 let bar_zero_y = linear.map(0.0);
791 let bar_height = (bar_zero_y - bar_val_y).abs();
792 let rect_y = bar_val_y.min(bar_zero_y);
795
796 elements.push(build_bar_element(
797 BarRectSpec {
798 x: x + x_inset,
799 y: rect_y,
800 width: bar_render_width,
801 height: bar_height,
802 is_horizontal: false,
803 is_negative: val < 0.0,
804 fill: fill_color.clone(),
805 class: "bar bar-rect".to_string(),
806 data: Some(ElementData::new(&cat, format_value(val, y_fmt_ref))),
807 },
808 &config.theme,
809 ));
810
811 if let Some(dl) = get_data_labels_config(config) {
813 if dl.show == Some(true) {
814 let label_fmt = dl.format.as_deref().or(y_fmt_ref);
815 let label_y = match dl.position.as_deref() {
816 Some("center") => rect_y + bar_height / 2.0,
817 Some("bottom") => rect_y + bar_height - 5.0,
818 _ => if val >= 0.0 { rect_y - 5.0 } else { rect_y + bar_height + 12.0 }, };
820 elements.push(ChartElement::Text {
821 x: x + band.bandwidth() / 2.0,
822 y: label_y,
823 content: format_value(val, label_fmt),
824 anchor: TextAnchor::Middle,
825 dominant_baseline: None,
826 transform: None,
827 font_family: None,
828 font_size: Some(dl.font_size.map(|s| format!("{}px", s)).unwrap_or_else(|| "12px".to_string())),
829 font_weight: None,
830 letter_spacing: None,
831 text_transform: None,
832 fill: Some(dl.color.clone().unwrap_or_else(|| config.theme.text_secondary.clone())),
833 class: "data-label".to_string(),
834 data: None,
835 });
836 }
837 }
838 }
839 }
840
841 Ok((value_max, elements))
842}
843
844fn render_multi_series_bars(
845 data: &DataTable,
846 config: &ChartConfig,
847 params: &MultiSeriesBarParams,
848) -> Result<(f64, Vec<ChartElement>), ChartError> {
849 let category_field = params.category_field;
850 let value_field = params.value_field;
851 let color_field = params.color_field;
852 let categories = params.categories;
853 let inner_width = params.inner_width;
854 let inner_height = params.inner_height;
855 let is_stacked = params.is_stacked;
856 let is_normalized = params.is_normalized;
857 let is_horizontal = params.is_horizontal;
858 let y_fmt_ref = params.y_fmt_ref;
859 let domain_min = params.domain_min;
860 let domain_max = params.domain_max;
861 use chartml_core::layout::stack::{StackLayout, StackOffset};
862
863 let series_names = data.unique_values(color_field);
864 let groups = data.group_by(color_field);
865
866 let mut elements = Vec::new();
867
868 if is_stacked {
869 let mut values_matrix: Vec<Vec<f64>> = Vec::new();
871 for series in &series_names {
872 let mut series_vals = Vec::new();
873 let series_data = groups.get(series);
874 for cat in categories {
875 let val = series_data
876 .map(|sd| {
877 (0..sd.num_rows())
878 .find_map(|i| {
879 if sd.get_string(i, category_field).as_deref() == Some(cat.as_str()) {
880 sd.get_f64(i, value_field)
881 } else {
882 None
883 }
884 })
885 .unwrap_or(0.0)
886 })
887 .unwrap_or(0.0);
888 series_vals.push(val);
889 }
890 values_matrix.push(series_vals);
891 }
892
893 let stack = if is_normalized {
894 StackLayout::new().offset(StackOffset::Normalize)
895 } else {
896 StackLayout::new()
897 };
898 let stacked_points = stack.layout(categories, &series_names, &values_matrix);
899
900 let (effective_min, effective_max) = if is_normalized {
902 (0.0, 1.0)
903 } else {
904 let value_max = stacked_points
905 .iter()
906 .map(|p| p.y1)
907 .fold(0.0_f64, f64::max);
908 let value_max = if value_max <= 0.0 { 1.0 } else { value_max };
909 (domain_min, if domain_max < f64::MAX { domain_max } else { value_max })
910 };
911
912 if is_horizontal {
913 let band = ScaleBand::new(categories.to_vec(), (0.0, inner_height))
915 .padding(crate::helpers::adaptive_bar_padding(categories.len()));
916 let linear = ScaleLinear::new((effective_min, effective_max), (0.0, inner_width));
917 let bar_render_height = band.bandwidth().min(40.0);
918 let y_inset = (band.bandwidth() - bar_render_height) / 2.0;
919
920 for point in &stacked_points {
921 let y = match band.map(&point.key) {
922 Some(y) => y,
923 None => continue,
924 };
925 let x_left = linear.map(point.y0);
926 let x_right = linear.map(point.y1);
927 let bar_width = (x_right - x_left).abs();
928
929 let series_idx = series_names.iter().position(|s| s == &point.series).unwrap_or(0);
930 let fill = config
931 .colors
932 .get(series_idx)
933 .cloned()
934 .unwrap_or_else(|| "#2E7D9A".to_string());
935
936 elements.push(build_bar_element(
937 BarRectSpec {
938 x: x_left.min(x_right),
939 y: y + y_inset,
940 width: bar_width,
941 height: bar_render_height,
942 is_horizontal: true,
943 is_negative: point.value < 0.0,
944 fill,
945 class: "bar bar-rect".to_string(),
946 data: Some(
947 ElementData::new(&point.key, format_value(point.value, y_fmt_ref))
948 .with_series(&point.series),
949 ),
950 },
951 &config.theme,
952 ));
953 }
954 } else {
955 let band = ScaleBand::new(categories.to_vec(), (0.0, inner_width))
957 .padding(crate::helpers::adaptive_bar_padding(categories.len()));
958 let linear = ScaleLinear::new((effective_min, effective_max), (inner_height, 0.0));
959 let max_bar_width = inner_width * 0.2;
961 let bar_render_width = band.bandwidth().min(max_bar_width);
962 let x_inset = (band.bandwidth() - bar_render_width) / 2.0;
963
964 for point in &stacked_points {
965 let x = match band.map(&point.key) {
966 Some(x) => x,
967 None => continue,
968 };
969 let y_top = linear.map(point.y1);
970 let y_bottom = linear.map(point.y0);
971 let bar_height = (y_bottom - y_top).abs();
972
973 let series_idx = series_names.iter().position(|s| s == &point.series).unwrap_or(0);
974 let fill = config
975 .colors
976 .get(series_idx)
977 .cloned()
978 .unwrap_or_else(|| "#2E7D9A".to_string());
979
980 elements.push(build_bar_element(
981 BarRectSpec {
982 x: x + x_inset,
983 y: y_top,
984 width: bar_render_width,
985 height: bar_height,
986 is_horizontal: false,
987 is_negative: point.value < 0.0,
988 fill,
989 class: "bar bar-rect".to_string(),
990 data: Some(
991 ElementData::new(&point.key, format_value(point.value, y_fmt_ref))
992 .with_series(&point.series),
993 ),
994 },
995 &config.theme,
996 ));
997 }
998 }
999
1000 Ok((effective_max, elements))
1001 } else {
1002 let value_max = (0..data.num_rows())
1005 .filter_map(|i| data.get_f64(i, value_field))
1006 .fold(0.0_f64, f64::max);
1007 let value_max = if value_max <= 0.0 { 1.0 } else { value_max };
1008 let effective_max = if domain_max < f64::MAX { domain_max } else { value_max };
1009
1010 let num_series = series_names.len().max(1);
1011
1012 if is_horizontal {
1013 let band = ScaleBand::new(categories.to_vec(), (0.0, inner_height))
1015 .padding(0.05);
1016 let linear = ScaleLinear::new((domain_min, effective_max), (0.0, inner_width));
1017 let sub_band_height = band.bandwidth() / num_series as f64;
1018
1019 for i in 0..data.num_rows() {
1020 let cat = match data.get_string(i, category_field) {
1021 Some(c) => c,
1022 None => continue,
1023 };
1024 let series = match data.get_string(i, color_field) {
1025 Some(s) => s,
1026 None => continue,
1027 };
1028 let val = data.get_f64(i, value_field).unwrap_or(0.0);
1029
1030 let y_base = match band.map(&cat) {
1031 Some(y) => y,
1032 None => continue,
1033 };
1034 let series_idx = series_names.iter().position(|s| s == &series).unwrap_or(0);
1035 let y = y_base + series_idx as f64 * sub_band_height;
1036
1037 let bar_left = linear.map(0.0);
1038 let bar_right = linear.map(val);
1039 let bar_width = (bar_right - bar_left).abs();
1040
1041 let fill = config
1042 .colors
1043 .get(series_idx)
1044 .cloned()
1045 .unwrap_or_else(|| "#2E7D9A".to_string());
1046
1047 elements.push(build_bar_element(
1048 BarRectSpec {
1049 x: bar_left.min(bar_right),
1050 y,
1051 width: bar_width,
1052 height: sub_band_height,
1053 is_horizontal: true,
1054 is_negative: val < 0.0,
1055 fill,
1056 class: "bar bar-rect".to_string(),
1057 data: Some(
1058 ElementData::new(&cat, format_value(val, y_fmt_ref))
1059 .with_series(&series),
1060 ),
1061 },
1062 &config.theme,
1063 ));
1064 }
1065 } else {
1066 let band = ScaleBand::new(categories.to_vec(), (0.0, inner_width))
1068 .padding(0.05);
1069 let linear = ScaleLinear::new((domain_min, effective_max), (inner_height, 0.0));
1070 let sub_band_width = band.bandwidth() / num_series as f64;
1071
1072 for i in 0..data.num_rows() {
1073 let cat = match data.get_string(i, category_field) {
1074 Some(c) => c,
1075 None => continue,
1076 };
1077 let series = match data.get_string(i, color_field) {
1078 Some(s) => s,
1079 None => continue,
1080 };
1081 let val = data.get_f64(i, value_field).unwrap_or(0.0);
1082
1083 let x_base = match band.map(&cat) {
1084 Some(x) => x,
1085 None => continue,
1086 };
1087 let series_idx = series_names.iter().position(|s| s == &series).unwrap_or(0);
1088 let x = x_base + series_idx as f64 * sub_band_width;
1089
1090 let bar_top = linear.map(val);
1091 let bar_bottom = linear.map(0.0);
1092 let bar_height = (bar_bottom - bar_top).abs();
1093
1094 let fill = config
1095 .colors
1096 .get(series_idx)
1097 .cloned()
1098 .unwrap_or_else(|| "#2E7D9A".to_string());
1099
1100 elements.push(build_bar_element(
1101 BarRectSpec {
1102 x,
1103 y: bar_top,
1104 width: sub_band_width,
1105 height: bar_height,
1106 is_horizontal: false,
1107 is_negative: val < 0.0,
1108 fill,
1109 class: "bar bar-rect".to_string(),
1110 data: Some(
1111 ElementData::new(&cat, format_value(val, y_fmt_ref))
1112 .with_series(&series),
1113 ),
1114 },
1115 &config.theme,
1116 ));
1117 }
1118 }
1119
1120 Ok((value_max, elements))
1121 }
1122}
1123
1124fn render_combo(
1126 data: &DataTable,
1127 config: &ChartConfig,
1128 fields: &[chartml_core::spec::FieldSpec],
1129) -> Result<ChartElement, ChartError> {
1130 use chartml_core::shapes::LineGenerator;
1131 use chartml_core::layout::stack::StackLayout;
1132
1133 let category_field = get_field_name(&config.visualize.columns)?;
1134 let categories = data.unique_values(&category_field);
1135 if categories.is_empty() {
1136 return Err(ChartError::DataError("No category values found".into()));
1137 }
1138
1139 let y_fmt = get_y_format(config);
1140 let y_fmt_ref = y_fmt.as_deref();
1141 let grid = GridConfig::from_config(config);
1142 let x_format = get_x_format(config);
1143
1144 let color_field = get_color_field(config);
1146 let is_stacked = matches!(config.visualize.mode, Some(ChartMode::Stacked));
1147
1148 let has_right = fields.iter().any(|f| f.axis.as_deref() == Some("right"));
1150 let right_fmt = config.visualize.axes.as_ref()
1151 .and_then(|a| a.right.as_ref())
1152 .and_then(|a| a.format.as_deref());
1153
1154 let right_tick_labels: Vec<String> = if has_right {
1156 let right_max = fields.iter()
1158 .filter(|f| f.axis.as_deref() == Some("right"))
1159 .flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
1160 .fold(0.0_f64, f64::max);
1161 let right_domain_max = config.visualize.axes.as_ref()
1162 .and_then(|a| a.right.as_ref())
1163 .and_then(|a| a.max)
1164 .unwrap_or(if right_max <= 0.0 { 1.0 } else { right_max });
1165 let tmp_scale = ScaleLinear::new((0.0, right_domain_max), (0.0, 100.0));
1166 tmp_scale.ticks(5).iter().map(|v| format_value(*v, right_fmt)).collect()
1167 } else {
1168 vec![]
1169 };
1170
1171 let has_x_axis_label = config.visualize.axes.as_ref()
1172 .and_then(|a| a.x.as_ref())
1173 .and_then(|a| a.label.as_ref())
1174 .is_some();
1175 let combo_legend_labels: Vec<String> = fields.iter()
1177 .map(|f| f.label.clone().unwrap_or_else(|| f.field.clone()))
1178 .collect();
1179 let combo_legend_height = if combo_legend_labels.len() > 1 || color_field.is_some() {
1180 let legend_config = LegendConfig {
1181 text_metrics: TextMetrics::from_theme_legend(&config.theme),
1182 ..LegendConfig::default()
1183 };
1184 calculate_legend_layout(&combo_legend_labels, &config.colors, config.width, &legend_config).total_height
1185 } else {
1186 0.0
1187 };
1188 let margin_config = MarginConfig {
1189 has_title: config.title.is_some(),
1190 legend_height: combo_legend_height,
1191 has_y_axis_label: false,
1194 has_x_axis_label,
1195 has_right_axis: has_right,
1196 right_tick_labels,
1197 tick_value_metrics: TextMetrics::from_theme_tick_value(&config.theme),
1198 axis_label_metrics: TextMetrics::from_theme_axis_label(&config.theme),
1199 ..Default::default()
1200 };
1201 let margins = calculate_margins(&margin_config);
1202 let inner_width = margins.inner_width(config.width);
1203 let inner_height = margins.inner_height(config.height);
1204
1205 let band = ScaleBand::new(categories.clone(), (0.0, inner_width))
1206 .padding(crate::helpers::adaptive_bar_padding(categories.len()));
1207 let bandwidth = band.bandwidth();
1208
1209 let left_fields: Vec<&chartml_core::spec::FieldSpec> = fields.iter()
1211 .filter(|f| f.axis.as_deref() != Some("right"))
1212 .collect();
1213 let right_fields: Vec<&chartml_core::spec::FieldSpec> = fields.iter()
1214 .filter(|f| f.axis.as_deref() == Some("right"))
1215 .collect();
1216
1217 let left_max = if let (true, Some(color_f)) = (is_stacked, color_field.as_ref()) {
1220 let color_series = data.unique_values(color_f);
1221 let mut max_stack = 0.0_f64;
1222 for f in &left_fields {
1223 for cat in &categories {
1224 let mut stack_total = 0.0_f64;
1225 for series in &color_series {
1226 let val = (0..data.num_rows())
1227 .find(|&i| {
1228 data.get_string(i, &category_field).as_deref() == Some(cat.as_str())
1229 && data.get_string(i, color_f).as_deref() == Some(series.as_str())
1230 })
1231 .and_then(|i| data.get_f64(i, &f.field))
1232 .unwrap_or(0.0);
1233 stack_total += val;
1234 }
1235 max_stack = max_stack.max(stack_total);
1236 }
1237 }
1238 max_stack
1239 } else {
1240 left_fields.iter()
1241 .flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
1242 .fold(0.0_f64, f64::max)
1243 };
1244 let left_data_min = left_fields.iter()
1246 .flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
1247 .fold(0.0_f64, f64::min);
1248 let left_data_min = if left_data_min >= 0.0 { 0.0 } else { left_data_min };
1250 let axes_left = config.visualize.axes.as_ref().and_then(|a| a.left.as_ref());
1251 let left_explicit_min = axes_left.and_then(|a| a.min);
1252 let left_explicit_max = axes_left.and_then(|a| a.max);
1253 let raw_left_domain_min = left_explicit_min.unwrap_or(left_data_min);
1254 let raw_left_domain_max = left_explicit_max.unwrap_or(if left_max <= 0.0 { 1.0 } else { left_max });
1255 let (left_domain_min, left_domain_max) = if left_explicit_min.is_none() && left_explicit_max.is_none() {
1256 nice_domain(raw_left_domain_min, raw_left_domain_max, 5)
1258 } else {
1259 (raw_left_domain_min, raw_left_domain_max)
1260 };
1261 let left_scale = ScaleLinear::new((left_domain_min, left_domain_max), (inner_height, 0.0));
1262
1263 let right_scale = if !right_fields.is_empty() {
1265 let right_max = right_fields.iter()
1266 .flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
1267 .fold(0.0_f64, f64::max);
1268 let right_data_min = right_fields.iter()
1269 .flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
1270 .fold(0.0_f64, f64::min);
1271 let axes_right = config.visualize.axes.as_ref().and_then(|a| a.right.as_ref());
1272 let right_explicit_min = axes_right.and_then(|a| a.min);
1273 let right_explicit_max = axes_right.and_then(|a| a.max);
1274 let raw_right_domain_min = right_explicit_min.unwrap_or(if right_data_min < 0.0 { right_data_min } else { 0.0 });
1275 let raw_right_domain_max = right_explicit_max.unwrap_or(if right_max <= 0.0 { 1.0 } else { right_max });
1276 let (right_domain_min, right_domain_max) = if right_explicit_min.is_none() && right_explicit_max.is_none() {
1277 nice_domain(raw_right_domain_min, raw_right_domain_max, 5)
1279 } else {
1280 (raw_right_domain_min, raw_right_domain_max)
1281 };
1282 Some(ScaleLinear::new((right_domain_min, right_domain_max), (inner_height, 0.0)))
1283 } else {
1284 None
1285 };
1286
1287 let mut children = Vec::new();
1288
1289 let bottom_axis_label = config.visualize.axes.as_ref()
1293 .and_then(|a| a.x.as_ref())
1294 .and_then(|a| a.label.as_deref());
1295 let x_axis_result = generate_x_axis(&crate::helpers::XAxisParams {
1296 labels: &categories,
1297 display_label_overrides: None,
1298 range: (0.0, inner_width),
1299 y_position: margins.top + inner_height,
1300 available_width: inner_width,
1301 x_format: x_format.as_deref(),
1302 chart_height: Some(inner_height),
1303 grid: &grid,
1304 axis_label: bottom_axis_label,
1305 theme: &config.theme,
1306 });
1307 let left_axis_label = axes_left.and_then(|a| a.label.as_deref());
1308 let y_axis_left = generate_y_axis_numeric(&crate::helpers::YAxisNumericParams {
1309 domain: (left_domain_min, left_domain_max),
1310 range: (inner_height, 0.0),
1311 x_position: margins.left,
1312 fmt: y_fmt_ref,
1313 tick_count: adaptive_tick_count(inner_height),
1314 chart_width: Some(inner_width),
1315 grid: &grid,
1316 axis_label: left_axis_label,
1317 theme: &config.theme,
1318 });
1319
1320 let mut axis_elements = Vec::new();
1321 axis_elements.extend(x_axis_result.elements.into_iter().map(|e| offset_element(e, margins.left, 0.0)));
1322 axis_elements.extend(y_axis_left.into_iter().map(|e| offset_element(e, 0.0, margins.top)));
1323 if let Some(zl) = emit_zero_line_if_crosses(
1328 &config.theme,
1329 (left_domain_min, left_domain_max),
1330 inner_width,
1331 inner_height,
1332 false,
1333 ) {
1334 axis_elements.push(offset_element(zl, margins.left, margins.top));
1335 }
1336
1337 if let Some(ref rs) = right_scale {
1339 let right_fmt = config.visualize.axes.as_ref()
1340 .and_then(|a| a.right.as_ref())
1341 .and_then(|a| a.format.as_deref());
1342 let right_axis = generate_y_axis_numeric_right(
1345 rs.domain(), (inner_height, 0.0), margins.left + inner_width,
1346 right_fmt, adaptive_tick_count(inner_height),
1347 None, &config.theme,
1348 );
1349 axis_elements.extend(right_axis.into_iter().map(|e| offset_element(e, 0.0, margins.top)));
1350 }
1351
1352 if let Some(label) = config.visualize.axes.as_ref().and_then(|a| a.right.as_ref()).and_then(|a| a.label.clone()) {
1355 let rx = config.width - 12.0;
1356 let ts = TextStyle::for_role(&config.theme, TextRole::AxisLabel);
1357 axis_elements.push(ChartElement::Text {
1358 x: rx,
1359 y: margins.top + inner_height / 2.0,
1360 content: label,
1361 anchor: TextAnchor::Middle,
1362 dominant_baseline: None,
1363 transform: Some(Transform::Rotate(90.0, rx, margins.top + inner_height / 2.0)),
1364 font_family: ts.font_family,
1365 font_size: ts.font_size,
1366 font_weight: ts.font_weight,
1367 letter_spacing: ts.letter_spacing,
1368 text_transform: ts.text_transform,
1369 fill: Some(config.theme.text_secondary.clone()),
1370 class: "axis-label".to_string(),
1371 data: None,
1372 });
1373 }
1374
1375 children.push(ChartElement::Group {
1376 class: "axes".to_string(), transform: None, children: axis_elements,
1377 });
1378
1379 let mut mark_elements = Vec::new();
1381 let line_gen = LineGenerator::new().curve(chartml_core::shapes::CurveType::MonotoneX);
1382
1383 let num_bar_fields = fields.iter()
1385 .filter(|f| f.mark.as_deref().unwrap_or("bar") == "bar")
1386 .count()
1387 .max(1);
1388 let max_bar_width = inner_width * 0.2;
1390 let effective_bandwidth = bandwidth.min(max_bar_width);
1391 let combo_x_inset = (bandwidth - effective_bandwidth) / 2.0;
1392 let sub_bar_padding = effective_bandwidth * 0.05;
1393 let sub_bar_width = (effective_bandwidth - sub_bar_padding * (num_bar_fields as f64 - 1.0).max(0.0)) / num_bar_fields as f64;
1394 let mut bar_field_idx = 0_usize;
1395 let mut series_names = Vec::new();
1396 let mut series_colors = Vec::new();
1397 let mut series_marks = Vec::new();
1398
1399 let stacked_bar_rendered = if let (true, Some(color_f)) = (is_stacked, color_field.as_ref()) {
1401 let color_series = data.unique_values(color_f);
1402
1403 for field_spec in fields.iter() {
1405 let mark = field_spec.mark.as_deref().unwrap_or("bar");
1406 if mark != "bar" { continue; }
1407
1408 let field_name = &field_spec.field;
1409 let is_right = field_spec.axis.as_deref() == Some("right");
1410 let scale = if is_right { right_scale.as_ref().unwrap_or(&left_scale) } else { &left_scale };
1411 let fmt_ref = if is_right {
1412 config.visualize.axes.as_ref().and_then(|a| a.right.as_ref()).and_then(|a| a.format.as_deref())
1413 } else {
1414 y_fmt_ref
1415 };
1416
1417 let mut values_matrix: Vec<Vec<f64>> = Vec::new();
1419 for series in &color_series {
1420 let mut series_vals = Vec::new();
1421 for cat in &categories {
1422 let val = (0..data.num_rows())
1423 .find(|&i| {
1424 data.get_string(i, &category_field).as_deref() == Some(cat.as_str())
1425 && data.get_string(i, color_f).as_deref() == Some(series.as_str())
1426 })
1427 .and_then(|i| data.get_f64(i, field_name))
1428 .unwrap_or(0.0);
1429 series_vals.push(val);
1430 }
1431 values_matrix.push(series_vals);
1432 }
1433
1434 let stack = StackLayout::new();
1435 let stacked_points = stack.layout(&categories, &color_series, &values_matrix);
1436
1437 let bar_render_width = bandwidth.min(max_bar_width);
1438 let x_inset = (bandwidth - bar_render_width) / 2.0;
1439
1440 for point in &stacked_points {
1441 let x = match band.map(&point.key) { Some(x) => x, None => continue };
1442 let y_top = scale.map(point.y1);
1443 let y_bottom = scale.map(point.y0);
1444 let bar_height = (y_bottom - y_top).abs();
1445
1446 let series_idx = color_series.iter().position(|s| s == &point.series).unwrap_or(0);
1447 let fill = config.colors.get(series_idx).cloned().unwrap_or_else(|| "#2E7D9A".to_string());
1448
1449 mark_elements.push(build_bar_element(
1450 BarRectSpec {
1451 x: x + x_inset + margins.left,
1452 y: y_top + margins.top,
1453 width: bar_render_width,
1454 height: bar_height,
1455 is_horizontal: false,
1456 is_negative: point.value < 0.0,
1457 fill,
1458 class: "bar bar-rect".to_string(),
1459 data: Some(
1460 ElementData::new(&point.key, format_value(point.value, fmt_ref))
1461 .with_series(&point.series),
1462 ),
1463 },
1464 &config.theme,
1465 ));
1466 }
1467 }
1468
1469 for (si, series_name) in color_series.iter().enumerate() {
1471 let color = config.colors.get(si).cloned().unwrap_or_else(|| "#2E7D9A".to_string());
1472 series_names.push(series_name.clone());
1473 series_colors.push(color);
1474 series_marks.push("bar".to_string());
1475 }
1476
1477 true
1478 } else {
1479 false
1480 };
1481
1482 for (field_idx, field_spec) in fields.iter().enumerate() {
1483 let field_name = &field_spec.field;
1484 let is_right = field_spec.axis.as_deref() == Some("right");
1485 let scale = if is_right { right_scale.as_ref().unwrap_or(&left_scale) } else { &left_scale };
1486 let mark = field_spec.mark.as_deref().unwrap_or("bar");
1487 let color = field_spec.color.clone()
1488 .unwrap_or_else(|| config.colors.get(field_idx).cloned().unwrap_or_else(|| "#2E7D9A".to_string()));
1489 let label = field_spec.label.clone().unwrap_or_else(|| field_name.clone());
1490 let fmt_ref = if is_right {
1491 config.visualize.axes.as_ref().and_then(|a| a.right.as_ref()).and_then(|a| a.format.as_deref())
1492 } else {
1493 y_fmt_ref
1494 };
1495
1496 match mark {
1497 "bar" if stacked_bar_rendered => {
1498 }
1500 "bar" => {
1501 let this_bar_idx = bar_field_idx;
1502 bar_field_idx += 1;
1503
1504 for row_i in 0..data.num_rows() {
1505 let cat = match data.get_string(row_i, &category_field) { Some(c) => c, None => continue };
1506 let val = data.get_f64(row_i, field_name).unwrap_or(0.0);
1507 let x = match band.map(&cat) { Some(x) => x, None => continue };
1508 let bar_x = x + combo_x_inset + this_bar_idx as f64 * (sub_bar_width + sub_bar_padding);
1509 let bar_val_y = scale.map(val);
1510 let bar_zero_y = scale.map(0.0);
1511 let bar_height = (bar_zero_y - bar_val_y).abs();
1512 let rect_y = bar_val_y.min(bar_zero_y);
1513
1514 mark_elements.push(build_bar_element(
1515 BarRectSpec {
1516 x: bar_x + margins.left,
1517 y: rect_y + margins.top,
1518 width: sub_bar_width,
1519 height: bar_height,
1520 is_horizontal: false,
1521 is_negative: val < 0.0,
1522 fill: color.clone(),
1523 class: "bar bar-rect".to_string(),
1524 data: Some(
1525 ElementData::new(&cat, format_value(val, fmt_ref))
1526 .with_series(&label),
1527 ),
1528 },
1529 &config.theme,
1530 ));
1531
1532 if let Some(ref dl) = field_spec.data_labels {
1534 if dl.show == Some(true) {
1535 let dl_fmt = dl.format.as_deref().or(fmt_ref);
1536 mark_elements.push(ChartElement::Text {
1537 x: bar_x + sub_bar_width / 2.0 + margins.left,
1538 y: rect_y + margins.top - 5.0,
1539 content: format_value(val, dl_fmt),
1540 anchor: TextAnchor::Middle, dominant_baseline: None,
1541 transform: None,
1542 font_family: None,
1543 font_size: Some(dl.font_size.map(|s| format!("{}px", s)).unwrap_or_else(|| "12px".to_string())),
1544 font_weight: None,
1545 letter_spacing: None,
1546 text_transform: None,
1547 fill: Some(dl.color.clone().unwrap_or_else(|| config.theme.text_secondary.clone())),
1548 class: "data-label".to_string(), data: None,
1549 });
1550 }
1551 }
1552 }
1553 }
1554 _ => {
1555 let mut points = Vec::new();
1556 let mut point_data = Vec::new();
1557 for cat in &categories {
1558 let row_i = match (0..data.num_rows()).find(|&i| data.get_string(i, &category_field).as_deref() == Some(cat.as_str())) {
1559 Some(i) => i, None => continue,
1560 };
1561 let val = match data.get_f64(row_i, field_name) { Some(v) => v, None => continue };
1562 let x = match band.map(cat) { Some(x) => x + bandwidth / 2.0, None => continue };
1563 let y = scale.map(val);
1564 points.push((x + margins.left, y + margins.top));
1565 point_data.push((cat.clone(), val));
1566 }
1567
1568 if !points.is_empty() {
1569 let path_d = line_gen.generate(&points);
1570 mark_elements.push(ChartElement::Path {
1571 d: path_d, fill: None, stroke: Some(color.clone()),
1572 stroke_width: Some(config.theme.series_line_weight as f64), stroke_dasharray: None,
1573 opacity: None,
1574 class: "chartml-line-path series-line".to_string(),
1575 data: Some(ElementData::new(&label, "").with_series(&label)),
1576 animation_origin: None,
1577 });
1578
1579 let dot_r = config.theme.dot_radius as f64;
1581 for (i, &(px, py)) in points.iter().enumerate() {
1582 let (ref cat, val) = point_data[i];
1583 if let Some(halo) = emit_dot_halo_if_enabled(&config.theme, px, py, dot_r) {
1584 mark_elements.push(halo);
1585 }
1586 mark_elements.push(ChartElement::Circle {
1587 cx: px, cy: py, r: dot_r,
1588 fill: color.clone(), stroke: Some(config.theme.bg.clone()),
1589 class: "chartml-line-dot dot-marker".to_string(),
1590 data: Some(ElementData::new(cat, format_value(val, fmt_ref)).with_series(&label)),
1591 });
1592 }
1593
1594 if let Some(ref dl) = field_spec.data_labels {
1596 if dl.show == Some(true) {
1597 let dl_fmt = dl.format.as_deref().or(fmt_ref);
1598 for (i, &(px, py)) in points.iter().enumerate() {
1599 let (_, val) = &point_data[i];
1600 mark_elements.push(ChartElement::Text {
1601 x: px, y: py - 10.0,
1602 content: format_value(*val, dl_fmt),
1603 anchor: TextAnchor::Middle, dominant_baseline: None,
1604 transform: None,
1605 font_family: None,
1606 font_size: Some(dl.font_size.map(|s| format!("{}px", s)).unwrap_or_else(|| "12px".to_string())),
1607 font_weight: None,
1608 letter_spacing: None,
1609 text_transform: None,
1610 fill: Some(dl.color.clone().unwrap_or_else(|| color.clone())),
1611 class: "data-label".to_string(), data: None,
1612 });
1613 }
1614 }
1615 }
1616 }
1617 }
1618 }
1619
1620 if !(stacked_bar_rendered && mark == "bar") {
1623 series_names.push(label);
1624 series_colors.push(color);
1625 series_marks.push(mark.to_string());
1626 }
1627 }
1628
1629 children.push(ChartElement::Group {
1630 class: "marks".to_string(), transform: None, children: mark_elements,
1631 });
1632
1633 if let Some(annotations) = config.visualize.annotations.as_deref() {
1635 if !annotations.is_empty() {
1636 let ann_elements = generate_annotations(
1637 annotations,
1638 &left_scale,
1639 0.0,
1640 inner_width,
1641 inner_height,
1642 Some(&categories),
1643 &config.theme,
1644 );
1645 if !ann_elements.is_empty() {
1646 children.push(ChartElement::Group {
1647 class: "annotations".to_string(),
1648 transform: Some(Transform::Translate(margins.left, margins.top)),
1649 children: ann_elements,
1650 });
1651 }
1652 }
1653 }
1654
1655 if series_names.len() > 1 {
1657 let combo_legend_metrics = TextMetrics::from_theme_legend(&config.theme);
1658 let mut legend_elements = Vec::new();
1659 let total_w: f64 = series_names.iter().map(|name| {
1660 let tw = measure_text(name, &combo_legend_metrics);
1661 12.0 + 6.0 + tw + 16.0
1662 }).sum();
1663 let mut x_offset = (config.width - total_w).max(0.0) / 2.0;
1664 let legend_y = config.height - combo_legend_height - 8.0;
1665
1666 for (i, name) in series_names.iter().enumerate() {
1667 let color = &series_colors[i];
1668 let mark = series_marks[i].as_str();
1669 let y = legend_y;
1670
1671 match mark {
1672 "line" => {
1673 legend_elements.push(ChartElement::Line {
1674 x1: x_offset, y1: y + 6.0, x2: x_offset + 12.0, y2: y + 6.0,
1675 stroke: color.clone(), stroke_width: Some(2.5),
1676 stroke_dasharray: None, class: "legend-symbol legend-line".to_string(),
1677 });
1678 }
1679 _ => {
1680 legend_elements.push(ChartElement::Rect {
1681 x: x_offset, y, width: 12.0, height: 12.0,
1682 fill: color.clone(), stroke: None,
1683 rx: None, ry: None,
1684 class: "legend-symbol".to_string(), data: None,
1685 animation_origin: None,
1686 });
1687 }
1688 }
1689
1690 let ts = TextStyle::for_role(&config.theme, TextRole::LegendLabel);
1691 legend_elements.push(ChartElement::Text {
1692 x: x_offset + 18.0, y: y + 10.0, content: name.clone(),
1693 anchor: TextAnchor::Start, dominant_baseline: None,
1694 transform: None,
1695 font_family: ts.font_family,
1696 font_size: ts.font_size,
1697 font_weight: ts.font_weight,
1698 letter_spacing: ts.letter_spacing,
1699 text_transform: ts.text_transform,
1700 fill: Some(config.theme.text_secondary.clone()), class: "legend-label".to_string(), data: None,
1701 });
1702
1703 let tw = measure_text(name, &combo_legend_metrics);
1704 x_offset += 12.0 + 6.0 + tw + 16.0;
1705 }
1706
1707 children.push(ChartElement::Group {
1708 class: "legend".to_string(), transform: None, children: legend_elements,
1709 });
1710 }
1711
1712 Ok(ChartElement::Svg {
1713 viewbox: ViewBox::new(0.0, 0.0, config.width, config.height),
1714 width: Some(config.width),
1715 height: Some(config.height),
1716 class: "chartml-bar chartml-combo".to_string(),
1717 children,
1718 })
1719}
1720