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 mut category_series: std::collections::HashMap<String, Vec<String>> =
1022 std::collections::HashMap::new();
1023 for i in 0..data.num_rows() {
1024 let cat = match data.get_string(i, category_field) {
1025 Some(c) => c,
1026 None => continue,
1027 };
1028 let series = match data.get_string(i, color_field) {
1029 Some(s) => s,
1030 None => continue,
1031 };
1032 let present = category_series.entry(cat).or_default();
1033 if !present.contains(&series) {
1034 present.push(series);
1035 }
1036 }
1037
1038 if is_horizontal {
1039 let band = ScaleBand::new(categories.to_vec(), (0.0, inner_height))
1041 .padding(0.05);
1042 let linear = ScaleLinear::new((domain_min, effective_max), (0.0, inner_width));
1043
1044 for i in 0..data.num_rows() {
1045 let cat = match data.get_string(i, category_field) {
1046 Some(c) => c,
1047 None => continue,
1048 };
1049 let series = match data.get_string(i, color_field) {
1050 Some(s) => s,
1051 None => continue,
1052 };
1053 let val = data.get_f64(i, value_field).unwrap_or(0.0);
1054
1055 let y_base = match band.map(&cat) {
1056 Some(y) => y,
1057 None => continue,
1058 };
1059
1060 let local_series = category_series.get(&cat);
1063 let local_count = local_series.map_or(1, |v| v.len()).max(1);
1064 let sub_band_height = band.bandwidth() / local_count as f64;
1065
1066 let bar_render_height = sub_band_height.min(40.0);
1069 let y_inset = (sub_band_height - bar_render_height) / 2.0;
1070
1071 let local_idx = local_series
1072 .and_then(|v| v.iter().position(|s| s == &series))
1073 .unwrap_or(0);
1074 let y = y_base + local_idx as f64 * sub_band_height + y_inset;
1075
1076 let series_idx = series_names.iter().position(|s| s == &series).unwrap_or(0);
1077
1078 let bar_left = linear.map(0.0);
1079 let bar_right = linear.map(val);
1080 let bar_width = (bar_right - bar_left).abs();
1081
1082 let fill = config
1083 .colors
1084 .get(series_idx)
1085 .cloned()
1086 .unwrap_or_else(|| "#2E7D9A".to_string());
1087
1088 elements.push(build_bar_element(
1089 BarRectSpec {
1090 x: bar_left.min(bar_right),
1091 y,
1092 width: bar_width,
1093 height: bar_render_height,
1094 is_horizontal: true,
1095 is_negative: val < 0.0,
1096 fill,
1097 class: "bar bar-rect".to_string(),
1098 data: Some(
1099 ElementData::new(&cat, format_value(val, y_fmt_ref))
1100 .with_series(&series),
1101 ),
1102 },
1103 &config.theme,
1104 ));
1105 }
1106 } else {
1107 let band = ScaleBand::new(categories.to_vec(), (0.0, inner_width))
1109 .padding(0.05);
1110 let linear = ScaleLinear::new((domain_min, effective_max), (inner_height, 0.0));
1111
1112 let max_bar_width = inner_width * 0.2;
1114
1115 for i in 0..data.num_rows() {
1116 let cat = match data.get_string(i, category_field) {
1117 Some(c) => c,
1118 None => continue,
1119 };
1120 let series = match data.get_string(i, color_field) {
1121 Some(s) => s,
1122 None => continue,
1123 };
1124 let val = data.get_f64(i, value_field).unwrap_or(0.0);
1125
1126 let x_base = match band.map(&cat) {
1127 Some(x) => x,
1128 None => continue,
1129 };
1130
1131 let local_series = category_series.get(&cat);
1134 let local_count = local_series.map_or(1, |v| v.len()).max(1);
1135 let sub_band_width = band.bandwidth() / local_count as f64;
1136
1137 let bar_render_width = sub_band_width.min(max_bar_width);
1139 let x_inset = (sub_band_width - bar_render_width) / 2.0;
1140
1141 let local_idx = local_series
1142 .and_then(|v| v.iter().position(|s| s == &series))
1143 .unwrap_or(0);
1144 let x = x_base + local_idx as f64 * sub_band_width + x_inset;
1145
1146 let series_idx = series_names.iter().position(|s| s == &series).unwrap_or(0);
1147
1148 let bar_top = linear.map(val);
1149 let bar_bottom = linear.map(0.0);
1150 let bar_height = (bar_bottom - bar_top).abs();
1151
1152 let fill = config
1153 .colors
1154 .get(series_idx)
1155 .cloned()
1156 .unwrap_or_else(|| "#2E7D9A".to_string());
1157
1158 elements.push(build_bar_element(
1159 BarRectSpec {
1160 x,
1161 y: bar_top,
1162 width: bar_render_width,
1163 height: bar_height,
1164 is_horizontal: false,
1165 is_negative: val < 0.0,
1166 fill,
1167 class: "bar bar-rect".to_string(),
1168 data: Some(
1169 ElementData::new(&cat, format_value(val, y_fmt_ref))
1170 .with_series(&series),
1171 ),
1172 },
1173 &config.theme,
1174 ));
1175 }
1176 }
1177
1178 Ok((value_max, elements))
1179 }
1180}
1181
1182fn render_combo(
1184 data: &DataTable,
1185 config: &ChartConfig,
1186 fields: &[chartml_core::spec::FieldSpec],
1187) -> Result<ChartElement, ChartError> {
1188 use chartml_core::shapes::LineGenerator;
1189 use chartml_core::layout::stack::StackLayout;
1190
1191 let category_field = get_field_name(&config.visualize.columns)?;
1192 let categories = data.unique_values(&category_field);
1193 if categories.is_empty() {
1194 return Err(ChartError::DataError("No category values found".into()));
1195 }
1196
1197 let y_fmt = get_y_format(config);
1198 let y_fmt_ref = y_fmt.as_deref();
1199 let grid = GridConfig::from_config(config);
1200 let x_format = get_x_format(config);
1201
1202 let color_field = get_color_field(config);
1204 let is_stacked = matches!(config.visualize.mode, Some(ChartMode::Stacked));
1205
1206 let has_right = fields.iter().any(|f| f.axis.as_deref() == Some("right"));
1208 let right_fmt = config.visualize.axes.as_ref()
1209 .and_then(|a| a.right.as_ref())
1210 .and_then(|a| a.format.as_deref());
1211
1212 let right_tick_labels: Vec<String> = if has_right {
1214 let right_max = fields.iter()
1216 .filter(|f| f.axis.as_deref() == Some("right"))
1217 .filter_map(|f| f.field.as_deref())
1218 .flat_map(|name| (0..data.num_rows()).filter_map(move |i| data.get_f64(i, name)))
1219 .fold(0.0_f64, f64::max);
1220 let right_domain_max = config.visualize.axes.as_ref()
1221 .and_then(|a| a.right.as_ref())
1222 .and_then(|a| a.max)
1223 .unwrap_or(if right_max <= 0.0 { 1.0 } else { right_max });
1224 let tmp_scale = ScaleLinear::new((0.0, right_domain_max), (0.0, 100.0));
1225 tmp_scale.ticks(5).iter().map(|v| format_value(*v, right_fmt)).collect()
1226 } else {
1227 vec![]
1228 };
1229
1230 let has_x_axis_label = config.visualize.axes.as_ref()
1231 .and_then(|a| a.x.as_ref())
1232 .and_then(|a| a.label.as_ref())
1233 .is_some();
1234 let combo_legend_labels: Vec<String> = fields.iter()
1238 .filter(|f| f.mark.as_deref() != Some("range"))
1239 .map(|f| {
1240 f.label
1241 .clone()
1242 .unwrap_or_else(|| f.field.clone().unwrap_or_default())
1243 })
1244 .collect();
1245 let combo_legend_height = if combo_legend_labels.len() > 1 || color_field.is_some() {
1246 let legend_config = LegendConfig {
1247 text_metrics: TextMetrics::from_theme_legend(&config.theme),
1248 ..LegendConfig::default()
1249 };
1250 calculate_legend_layout(&combo_legend_labels, &config.colors, config.width, &legend_config).total_height
1251 } else {
1252 0.0
1253 };
1254 let margin_config = MarginConfig {
1255 has_title: config.title.is_some(),
1256 legend_height: combo_legend_height,
1257 has_y_axis_label: false,
1260 has_x_axis_label,
1261 has_right_axis: has_right,
1262 right_tick_labels,
1263 tick_value_metrics: TextMetrics::from_theme_tick_value(&config.theme),
1264 axis_label_metrics: TextMetrics::from_theme_axis_label(&config.theme),
1265 ..Default::default()
1266 };
1267 let margins = calculate_margins(&margin_config);
1268 let inner_width = margins.inner_width(config.width);
1269 let inner_height = margins.inner_height(config.height);
1270
1271 let band = ScaleBand::new(categories.clone(), (0.0, inner_width))
1272 .padding(crate::helpers::adaptive_bar_padding(categories.len()));
1273 let bandwidth = band.bandwidth();
1274
1275 let left_fields: Vec<&chartml_core::spec::FieldSpec> = fields.iter()
1277 .filter(|f| f.axis.as_deref() != Some("right"))
1278 .collect();
1279 let right_fields: Vec<&chartml_core::spec::FieldSpec> = fields.iter()
1280 .filter(|f| f.axis.as_deref() == Some("right"))
1281 .collect();
1282
1283 let left_max = if let (true, Some(color_f)) = (is_stacked, color_field.as_ref()) {
1286 let color_series = data.unique_values(color_f);
1287 let mut max_stack = 0.0_f64;
1288 for f in &left_fields {
1289 let Some(field_name) = f.field.as_deref() else { continue };
1290 for cat in &categories {
1291 let mut stack_total = 0.0_f64;
1292 for series in &color_series {
1293 let val = (0..data.num_rows())
1294 .find(|&i| {
1295 data.get_string(i, &category_field).as_deref() == Some(cat.as_str())
1296 && data.get_string(i, color_f).as_deref() == Some(series.as_str())
1297 })
1298 .and_then(|i| data.get_f64(i, field_name))
1299 .unwrap_or(0.0);
1300 stack_total += val;
1301 }
1302 max_stack = max_stack.max(stack_total);
1303 }
1304 }
1305 max_stack
1306 } else {
1307 left_fields.iter()
1308 .filter_map(|f| f.field.as_deref())
1309 .flat_map(|name| (0..data.num_rows()).filter_map(move |i| data.get_f64(i, name)))
1310 .fold(0.0_f64, f64::max)
1311 };
1312 let left_data_min = left_fields.iter()
1314 .filter_map(|f| f.field.as_deref())
1315 .flat_map(|name| (0..data.num_rows()).filter_map(move |i| data.get_f64(i, name)))
1316 .fold(0.0_f64, f64::min);
1317 let left_data_min = if left_data_min >= 0.0 { 0.0 } else { left_data_min };
1319 let axes_left = config.visualize.axes.as_ref().and_then(|a| a.left.as_ref());
1320 let left_explicit_min = axes_left.and_then(|a| a.min);
1321 let left_explicit_max = axes_left.and_then(|a| a.max);
1322 let raw_left_domain_min = left_explicit_min.unwrap_or(left_data_min);
1323 let raw_left_domain_max = left_explicit_max.unwrap_or(if left_max <= 0.0 { 1.0 } else { left_max });
1324 let (left_domain_min, left_domain_max) = if left_explicit_min.is_none() && left_explicit_max.is_none() {
1325 nice_domain(raw_left_domain_min, raw_left_domain_max, 5)
1327 } else {
1328 (raw_left_domain_min, raw_left_domain_max)
1329 };
1330 let left_scale = ScaleLinear::new((left_domain_min, left_domain_max), (inner_height, 0.0));
1331
1332 let right_scale = if !right_fields.is_empty() {
1334 let right_max = right_fields.iter()
1335 .filter_map(|f| f.field.as_deref())
1336 .flat_map(|name| (0..data.num_rows()).filter_map(move |i| data.get_f64(i, name)))
1337 .fold(0.0_f64, f64::max);
1338 let right_data_min = right_fields.iter()
1339 .filter_map(|f| f.field.as_deref())
1340 .flat_map(|name| (0..data.num_rows()).filter_map(move |i| data.get_f64(i, name)))
1341 .fold(0.0_f64, f64::min);
1342 let axes_right = config.visualize.axes.as_ref().and_then(|a| a.right.as_ref());
1343 let right_explicit_min = axes_right.and_then(|a| a.min);
1344 let right_explicit_max = axes_right.and_then(|a| a.max);
1345 let raw_right_domain_min = right_explicit_min.unwrap_or(if right_data_min < 0.0 { right_data_min } else { 0.0 });
1346 let raw_right_domain_max = right_explicit_max.unwrap_or(if right_max <= 0.0 { 1.0 } else { right_max });
1347 let (right_domain_min, right_domain_max) = if right_explicit_min.is_none() && right_explicit_max.is_none() {
1348 nice_domain(raw_right_domain_min, raw_right_domain_max, 5)
1350 } else {
1351 (raw_right_domain_min, raw_right_domain_max)
1352 };
1353 Some(ScaleLinear::new((right_domain_min, right_domain_max), (inner_height, 0.0)))
1354 } else {
1355 None
1356 };
1357
1358 let mut children = Vec::new();
1359
1360 let bottom_axis_label = config.visualize.axes.as_ref()
1364 .and_then(|a| a.x.as_ref())
1365 .and_then(|a| a.label.as_deref());
1366 let x_axis_result = generate_x_axis(&crate::helpers::XAxisParams {
1367 labels: &categories,
1368 display_label_overrides: None,
1369 range: (0.0, inner_width),
1370 y_position: margins.top + inner_height,
1371 available_width: inner_width,
1372 x_format: x_format.as_deref(),
1373 chart_height: Some(inner_height),
1374 grid: &grid,
1375 axis_label: bottom_axis_label,
1376 theme: &config.theme,
1377 });
1378 let left_axis_label = axes_left.and_then(|a| a.label.as_deref());
1379 let y_axis_left = generate_y_axis_numeric(&crate::helpers::YAxisNumericParams {
1380 domain: (left_domain_min, left_domain_max),
1381 range: (inner_height, 0.0),
1382 x_position: margins.left,
1383 fmt: y_fmt_ref,
1384 tick_count: adaptive_tick_count(inner_height),
1385 chart_width: Some(inner_width),
1386 grid: &grid,
1387 axis_label: left_axis_label,
1388 theme: &config.theme,
1389 });
1390
1391 let mut axis_elements = Vec::new();
1392 axis_elements.extend(x_axis_result.elements.into_iter().map(|e| offset_element(e, margins.left, 0.0)));
1393 axis_elements.extend(y_axis_left.into_iter().map(|e| offset_element(e, 0.0, margins.top)));
1394 if let Some(zl) = emit_zero_line_if_crosses(
1399 &config.theme,
1400 (left_domain_min, left_domain_max),
1401 inner_width,
1402 inner_height,
1403 false,
1404 ) {
1405 axis_elements.push(offset_element(zl, margins.left, margins.top));
1406 }
1407
1408 if let Some(ref rs) = right_scale {
1410 let right_fmt = config.visualize.axes.as_ref()
1411 .and_then(|a| a.right.as_ref())
1412 .and_then(|a| a.format.as_deref());
1413 let right_axis = generate_y_axis_numeric_right(
1416 rs.domain(), (inner_height, 0.0), margins.left + inner_width,
1417 right_fmt, adaptive_tick_count(inner_height),
1418 None, &config.theme,
1419 );
1420 axis_elements.extend(right_axis.into_iter().map(|e| offset_element(e, 0.0, margins.top)));
1421 }
1422
1423 if let Some(label) = config.visualize.axes.as_ref().and_then(|a| a.right.as_ref()).and_then(|a| a.label.clone()) {
1426 let rx = config.width - 12.0;
1427 let ts = TextStyle::for_role(&config.theme, TextRole::AxisLabel);
1428 axis_elements.push(ChartElement::Text {
1429 x: rx,
1430 y: margins.top + inner_height / 2.0,
1431 content: label,
1432 anchor: TextAnchor::Middle,
1433 dominant_baseline: None,
1434 transform: Some(Transform::Rotate(90.0, rx, margins.top + inner_height / 2.0)),
1435 font_family: ts.font_family,
1436 font_size: ts.font_size,
1437 font_weight: ts.font_weight,
1438 letter_spacing: ts.letter_spacing,
1439 text_transform: ts.text_transform,
1440 fill: Some(config.theme.text_secondary.clone()),
1441 class: "axis-label".to_string(),
1442 data: None,
1443 });
1444 }
1445
1446 children.push(ChartElement::Group {
1447 class: "axes".to_string(), transform: None, children: axis_elements,
1448 });
1449
1450 let mut mark_elements = Vec::new();
1452 let line_gen = LineGenerator::new().curve(chartml_core::shapes::CurveType::MonotoneX);
1453
1454 let num_bar_fields = fields.iter()
1456 .filter(|f| f.mark.as_deref().unwrap_or("bar") == "bar")
1457 .count()
1458 .max(1);
1459 let max_bar_width = inner_width * 0.2;
1461 let effective_bandwidth = bandwidth.min(max_bar_width);
1462 let combo_x_inset = (bandwidth - effective_bandwidth) / 2.0;
1463 let sub_bar_padding = effective_bandwidth * 0.05;
1464 let sub_bar_width = (effective_bandwidth - sub_bar_padding * (num_bar_fields as f64 - 1.0).max(0.0)) / num_bar_fields as f64;
1465 let mut bar_field_idx = 0_usize;
1466 let mut series_names = Vec::new();
1467 let mut series_colors = Vec::new();
1468 let mut series_marks = Vec::new();
1469
1470 let stacked_bar_rendered = if let (true, Some(color_f)) = (is_stacked, color_field.as_ref()) {
1472 let color_series = data.unique_values(color_f);
1473
1474 for field_spec in fields.iter() {
1476 let mark = field_spec.mark.as_deref().unwrap_or("bar");
1477 if mark != "bar" { continue; }
1478
1479 let field_name = field_spec.field.as_deref().unwrap_or("");
1480 let is_right = field_spec.axis.as_deref() == Some("right");
1481 let scale = if is_right { right_scale.as_ref().unwrap_or(&left_scale) } else { &left_scale };
1482 let fmt_ref = if is_right {
1483 config.visualize.axes.as_ref().and_then(|a| a.right.as_ref()).and_then(|a| a.format.as_deref())
1484 } else {
1485 y_fmt_ref
1486 };
1487
1488 let mut values_matrix: Vec<Vec<f64>> = Vec::new();
1490 for series in &color_series {
1491 let mut series_vals = Vec::new();
1492 for cat in &categories {
1493 let val = (0..data.num_rows())
1494 .find(|&i| {
1495 data.get_string(i, &category_field).as_deref() == Some(cat.as_str())
1496 && data.get_string(i, color_f).as_deref() == Some(series.as_str())
1497 })
1498 .and_then(|i| data.get_f64(i, field_name))
1499 .unwrap_or(0.0);
1500 series_vals.push(val);
1501 }
1502 values_matrix.push(series_vals);
1503 }
1504
1505 let stack = StackLayout::new();
1506 let stacked_points = stack.layout(&categories, &color_series, &values_matrix);
1507
1508 let bar_render_width = bandwidth.min(max_bar_width);
1509 let x_inset = (bandwidth - bar_render_width) / 2.0;
1510
1511 for point in &stacked_points {
1512 let x = match band.map(&point.key) { Some(x) => x, None => continue };
1513 let y_top = scale.map(point.y1);
1514 let y_bottom = scale.map(point.y0);
1515 let bar_height = (y_bottom - y_top).abs();
1516
1517 let series_idx = color_series.iter().position(|s| s == &point.series).unwrap_or(0);
1518 let fill = config.colors.get(series_idx).cloned().unwrap_or_else(|| "#2E7D9A".to_string());
1519
1520 mark_elements.push(build_bar_element(
1521 BarRectSpec {
1522 x: x + x_inset + margins.left,
1523 y: y_top + margins.top,
1524 width: bar_render_width,
1525 height: bar_height,
1526 is_horizontal: false,
1527 is_negative: point.value < 0.0,
1528 fill,
1529 class: "bar bar-rect".to_string(),
1530 data: Some(
1531 ElementData::new(&point.key, format_value(point.value, fmt_ref))
1532 .with_series(&point.series),
1533 ),
1534 },
1535 &config.theme,
1536 ));
1537 }
1538 }
1539
1540 for (si, series_name) in color_series.iter().enumerate() {
1542 let color = config.colors.get(si).cloned().unwrap_or_else(|| "#2E7D9A".to_string());
1543 series_names.push(series_name.clone());
1544 series_colors.push(color);
1545 series_marks.push("bar".to_string());
1546 }
1547
1548 true
1549 } else {
1550 false
1551 };
1552
1553 for (field_idx, field_spec) in fields.iter().enumerate() {
1554 if field_spec.mark.as_deref() == Some("range") {
1560 continue;
1561 }
1562 let field_name = field_spec.field.as_deref().unwrap_or("");
1563 let is_right = field_spec.axis.as_deref() == Some("right");
1564 let scale = if is_right { right_scale.as_ref().unwrap_or(&left_scale) } else { &left_scale };
1565 let mark = field_spec.mark.as_deref().unwrap_or("bar");
1566 let color = field_spec.color.clone()
1567 .unwrap_or_else(|| config.colors.get(field_idx).cloned().unwrap_or_else(|| "#2E7D9A".to_string()));
1568 let label = field_spec.label.clone().unwrap_or_else(|| field_name.to_string());
1569 let fmt_ref = if is_right {
1570 config.visualize.axes.as_ref().and_then(|a| a.right.as_ref()).and_then(|a| a.format.as_deref())
1571 } else {
1572 y_fmt_ref
1573 };
1574
1575 match mark {
1576 "bar" if stacked_bar_rendered => {
1577 }
1579 "bar" => {
1580 let this_bar_idx = bar_field_idx;
1581 bar_field_idx += 1;
1582
1583 for row_i in 0..data.num_rows() {
1584 let cat = match data.get_string(row_i, &category_field) { Some(c) => c, None => continue };
1585 let val = data.get_f64(row_i, field_name).unwrap_or(0.0);
1586 let x = match band.map(&cat) { Some(x) => x, None => continue };
1587 let bar_x = x + combo_x_inset + this_bar_idx as f64 * (sub_bar_width + sub_bar_padding);
1588 let bar_val_y = scale.map(val);
1589 let bar_zero_y = scale.map(0.0);
1590 let bar_height = (bar_zero_y - bar_val_y).abs();
1591 let rect_y = bar_val_y.min(bar_zero_y);
1592
1593 mark_elements.push(build_bar_element(
1594 BarRectSpec {
1595 x: bar_x + margins.left,
1596 y: rect_y + margins.top,
1597 width: sub_bar_width,
1598 height: bar_height,
1599 is_horizontal: false,
1600 is_negative: val < 0.0,
1601 fill: color.clone(),
1602 class: "bar bar-rect".to_string(),
1603 data: Some(
1604 ElementData::new(&cat, format_value(val, fmt_ref))
1605 .with_series(&label),
1606 ),
1607 },
1608 &config.theme,
1609 ));
1610
1611 if let Some(ref dl) = field_spec.data_labels {
1613 if dl.show == Some(true) {
1614 let dl_fmt = dl.format.as_deref().or(fmt_ref);
1615 mark_elements.push(ChartElement::Text {
1616 x: bar_x + sub_bar_width / 2.0 + margins.left,
1617 y: rect_y + margins.top - 5.0,
1618 content: format_value(val, dl_fmt),
1619 anchor: TextAnchor::Middle, dominant_baseline: None,
1620 transform: None,
1621 font_family: None,
1622 font_size: Some(dl.font_size.map(|s| format!("{}px", s)).unwrap_or_else(|| "12px".to_string())),
1623 font_weight: None,
1624 letter_spacing: None,
1625 text_transform: None,
1626 fill: Some(dl.color.clone().unwrap_or_else(|| config.theme.text_secondary.clone())),
1627 class: "data-label".to_string(), data: None,
1628 });
1629 }
1630 }
1631 }
1632 }
1633 _ => {
1634 let mut points = Vec::new();
1635 let mut point_data = Vec::new();
1636 for cat in &categories {
1637 let row_i = match (0..data.num_rows()).find(|&i| data.get_string(i, &category_field).as_deref() == Some(cat.as_str())) {
1638 Some(i) => i, None => continue,
1639 };
1640 let val = match data.get_f64(row_i, field_name) { Some(v) => v, None => continue };
1641 let x = match band.map(cat) { Some(x) => x + bandwidth / 2.0, None => continue };
1642 let y = scale.map(val);
1643 points.push((x + margins.left, y + margins.top));
1644 point_data.push((cat.clone(), val));
1645 }
1646
1647 if !points.is_empty() {
1648 let path_d = line_gen.generate(&points);
1649 mark_elements.push(ChartElement::Path {
1650 d: path_d, fill: None, stroke: Some(color.clone()),
1651 stroke_width: Some(config.theme.series_line_weight as f64), stroke_dasharray: None,
1652 opacity: None,
1653 class: "chartml-line-path series-line".to_string(),
1654 data: Some(ElementData::new(&label, "").with_series(&label)),
1655 animation_origin: None,
1656 });
1657
1658 let dot_r = config.theme.dot_radius as f64;
1660 for (i, &(px, py)) in points.iter().enumerate() {
1661 let (ref cat, val) = point_data[i];
1662 if let Some(halo) = emit_dot_halo_if_enabled(&config.theme, px, py, dot_r) {
1663 mark_elements.push(halo);
1664 }
1665 mark_elements.push(ChartElement::Circle {
1666 cx: px, cy: py, r: dot_r,
1667 fill: color.clone(), stroke: Some(config.theme.bg.clone()),
1668 class: "chartml-line-dot dot-marker".to_string(),
1669 data: Some(ElementData::new(cat, format_value(val, fmt_ref)).with_series(&label)),
1670 });
1671 }
1672
1673 if let Some(ref dl) = field_spec.data_labels {
1675 if dl.show == Some(true) {
1676 let dl_fmt = dl.format.as_deref().or(fmt_ref);
1677 for (i, &(px, py)) in points.iter().enumerate() {
1678 let (_, val) = &point_data[i];
1679 mark_elements.push(ChartElement::Text {
1680 x: px, y: py - 10.0,
1681 content: format_value(*val, dl_fmt),
1682 anchor: TextAnchor::Middle, dominant_baseline: None,
1683 transform: None,
1684 font_family: None,
1685 font_size: Some(dl.font_size.map(|s| format!("{}px", s)).unwrap_or_else(|| "12px".to_string())),
1686 font_weight: None,
1687 letter_spacing: None,
1688 text_transform: None,
1689 fill: Some(dl.color.clone().unwrap_or_else(|| color.clone())),
1690 class: "data-label".to_string(), data: None,
1691 });
1692 }
1693 }
1694 }
1695 }
1696 }
1697 }
1698
1699 if !(stacked_bar_rendered && mark == "bar") {
1702 series_names.push(label);
1703 series_colors.push(color);
1704 series_marks.push(mark.to_string());
1705 }
1706 }
1707
1708 children.push(ChartElement::Group {
1709 class: "marks".to_string(), transform: None, children: mark_elements,
1710 });
1711
1712 if let Some(annotations) = config.visualize.annotations.as_deref() {
1714 if !annotations.is_empty() {
1715 let ann_elements = generate_annotations(
1716 annotations,
1717 &left_scale,
1718 0.0,
1719 inner_width,
1720 inner_height,
1721 Some(&categories),
1722 &config.theme,
1723 );
1724 if !ann_elements.is_empty() {
1725 children.push(ChartElement::Group {
1726 class: "annotations".to_string(),
1727 transform: Some(Transform::Translate(margins.left, margins.top)),
1728 children: ann_elements,
1729 });
1730 }
1731 }
1732 }
1733
1734 if series_names.len() > 1 {
1736 let combo_legend_metrics = TextMetrics::from_theme_legend(&config.theme);
1737 let mut legend_elements = Vec::new();
1738 let total_w: f64 = series_names.iter().map(|name| {
1739 let tw = measure_text(name, &combo_legend_metrics);
1740 12.0 + 6.0 + tw + 16.0
1741 }).sum();
1742 let mut x_offset = (config.width - total_w).max(0.0) / 2.0;
1743 let legend_y = config.height - combo_legend_height - 8.0;
1744
1745 for (i, name) in series_names.iter().enumerate() {
1746 let color = &series_colors[i];
1747 let mark = series_marks[i].as_str();
1748 let y = legend_y;
1749
1750 match mark {
1751 "line" => {
1752 legend_elements.push(ChartElement::Line {
1753 x1: x_offset, y1: y + 6.0, x2: x_offset + 12.0, y2: y + 6.0,
1754 stroke: color.clone(), stroke_width: Some(2.5),
1755 stroke_dasharray: None, class: "legend-symbol legend-line".to_string(),
1756 });
1757 }
1758 _ => {
1759 legend_elements.push(ChartElement::Rect {
1760 x: x_offset, y, width: 12.0, height: 12.0,
1761 fill: color.clone(), stroke: None,
1762 rx: None, ry: None,
1763 class: "legend-symbol".to_string(), data: None,
1764 animation_origin: None,
1765 });
1766 }
1767 }
1768
1769 let ts = TextStyle::for_role(&config.theme, TextRole::LegendLabel);
1770 legend_elements.push(ChartElement::Text {
1771 x: x_offset + 18.0, y: y + 10.0, content: name.clone(),
1772 anchor: TextAnchor::Start, dominant_baseline: None,
1773 transform: None,
1774 font_family: ts.font_family,
1775 font_size: ts.font_size,
1776 font_weight: ts.font_weight,
1777 letter_spacing: ts.letter_spacing,
1778 text_transform: ts.text_transform,
1779 fill: Some(config.theme.text_secondary.clone()), class: "legend-label".to_string(), data: None,
1780 });
1781
1782 let tw = measure_text(name, &combo_legend_metrics);
1783 x_offset += 12.0 + 6.0 + tw + 16.0;
1784 }
1785
1786 children.push(ChartElement::Group {
1787 class: "legend".to_string(), transform: None, children: legend_elements,
1788 });
1789 }
1790
1791 Ok(ChartElement::Svg {
1792 viewbox: ViewBox::new(0.0, 0.0, config.width, config.height),
1793 width: Some(config.width),
1794 height: Some(config.height),
1795 class: "chartml-bar chartml-combo".to_string(),
1796 children,
1797 })
1798}
1799