1use chartml_core::data::DataTable;
2use chartml_core::element::{ChartElement, ElementData, TextAnchor, Transform, ViewBox};
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};
11
12use chartml_core::layout::legend::{calculate_legend_layout, LegendConfig};
13
14use crate::helpers::{GridConfig, 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
16struct SingleSeriesBarParams<'a> {
17 category_field: &'a str,
18 value_field: &'a str,
19 categories: &'a [String],
20 inner_width: f64,
21 inner_height: f64,
22 is_horizontal: bool,
23 y_fmt_ref: Option<&'a str>,
24 domain_min: f64,
25 domain_max: f64,
26}
27
28struct MultiSeriesBarParams<'a> {
29 category_field: &'a str,
30 value_field: &'a str,
31 color_field: &'a str,
32 categories: &'a [String],
33 inner_width: f64,
34 inner_height: f64,
35 is_stacked: bool,
36 is_normalized: bool,
37 is_horizontal: bool,
38 y_fmt_ref: Option<&'a str>,
39 domain_min: f64,
40 domain_max: f64,
41}
42
43pub fn render_bar(data: &DataTable, config: &ChartConfig) -> Result<ChartElement, ChartError> {
44 use chartml_core::spec::{FieldRef, FieldRefItem, FieldSpec};
45
46
47 let multi_fields: Vec<FieldSpec> = match &config.visualize.rows {
49 Some(FieldRef::Multiple(items)) => items.iter().map(|item| match item {
50 FieldRefItem::Detailed(spec) => spec.as_ref().clone(),
51 FieldRefItem::Simple(name) => FieldSpec {
52 field: name.clone(), mark: None, axis: None, label: None,
53 color: None, format: None, data_labels: None,
54 line_style: None, upper: None, lower: None, opacity: None,
55 },
56 }).collect(),
57 _ => vec![],
58 };
59
60 if !multi_fields.is_empty() {
61 return render_combo(data, config, &multi_fields);
62 }
63
64 let category_field = get_field_name(&config.visualize.columns)?;
65 let value_field = get_field_name(&config.visualize.rows)?;
66
67 let color_field = get_color_field(config);
68
69 let (categories, display_labels): (Vec<String>, Option<Vec<String>>) = if color_field.is_none() {
75 let all_vals = data.all_values(&category_field);
76 if all_vals.is_empty() {
77 return Err(ChartError::DataError("No category values found".into()));
78 }
79 let has_duplicates = {
81 let mut seen = std::collections::HashSet::new();
82 all_vals.iter().any(|v| !seen.insert(v.as_str()))
83 };
84 if has_duplicates {
85 let band_keys: Vec<String> = all_vals.iter().enumerate()
86 .map(|(i, v)| format!("{}\x00{}", v, i))
87 .collect();
88 (band_keys, Some(all_vals))
89 } else {
90 (all_vals, None)
91 }
92 } else {
93 let unique = data.unique_values(&category_field);
94 if unique.is_empty() {
95 return Err(ChartError::DataError("No category values found".into()));
96 }
97 (unique, None)
98 };
99
100 let is_horizontal = matches!(config.visualize.orientation, Some(Orientation::Horizontal));
101 let is_normalized = matches!(config.visualize.mode, Some(ChartMode::Normalized));
102 let is_stacked = matches!(config.visualize.mode, Some(ChartMode::Stacked)) || is_normalized;
103 let _is_grouped = matches!(config.visualize.mode, Some(ChartMode::Grouped));
104
105 let x_format = get_x_format(config);
107 let y_fmt = get_y_format(config);
108 let y_fmt_ref = y_fmt.as_deref();
109 let (axis_min, axis_max) = get_y_axis_bounds(config);
110
111 let labels_for_strategy = display_labels.as_deref().unwrap_or(&categories);
113 let x_extra_margin = if !is_horizontal {
114 let estimated_width = config.width - 80.0;
115 let x_strategy = LabelStrategy::determine(labels_for_strategy, estimated_width, &LabelStrategyConfig::default());
116 match &x_strategy {
117 LabelStrategy::Rotated { margin, .. } => *margin,
118 _ => 0.0,
119 }
120 } else {
121 0.0
122 };
123
124 let (prelim_data_min, prelim_data_max): (f64, f64) = if let Some(ref color_f) = color_field {
127 if is_stacked {
128 let groups = data.group_by(color_f);
129 let series_names = data.unique_values(color_f);
130 let stacked_vals: Vec<f64> = categories.iter().map(|cat| {
131 series_names.iter().map(|s| {
132 groups.get(s).and_then(|series_data| {
133 (0..series_data.num_rows()).find_map(|i| {
134 if series_data.get_string(i, &category_field).as_deref() == Some(cat.as_str()) {
135 series_data.get_f64(i, &value_field)
136 } else {
137 None
138 }
139 })
140 }).unwrap_or(0.0)
141 }).sum::<f64>()
142 }).collect();
143 let mn = stacked_vals.iter().cloned().fold(f64::INFINITY, f64::min);
144 let mx = stacked_vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
145 (mn, mx)
146 } else {
147 let vals: Vec<f64> = (0..data.num_rows()).filter_map(|i| data.get_f64(i, &value_field)).collect();
148 let mn = vals.iter().cloned().fold(f64::INFINITY, f64::min);
149 let mx = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
150 (mn, mx)
151 }
152 } else {
153 let vals: Vec<f64> = (0..data.num_rows()).filter_map(|i| data.get_f64(i, &value_field)).collect();
154 let mn = vals.iter().cloned().fold(f64::INFINITY, f64::min);
155 let mx = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
156 (mn, mx)
157 };
158 let prelim_data_max = if prelim_data_max <= 0.0 { 1.0 } else { prelim_data_max };
159 let prelim_data_min = if prelim_data_min >= 0.0 { 0.0 } else { prelim_data_min };
161
162 let prelim_domain_max = if is_normalized {
163 1.0
164 } else {
165 let raw_max = axis_max.unwrap_or(prelim_data_max);
166 if axis_max.is_none() { nice_domain(axis_min.unwrap_or(prelim_data_min), raw_max, 5).1 } else { raw_max }
167 };
168 let prelim_domain_min = if is_normalized { 0.0 } else { axis_min.unwrap_or(prelim_data_min) };
169
170 let y_tick_labels_for_margin: Vec<String> = if !is_horizontal {
173 let prelim_fmt = if is_normalized { Some(".0%") } else { y_fmt_ref };
174 vec![
175 format_value(prelim_domain_max, prelim_fmt),
176 format_value(prelim_domain_min, prelim_fmt),
177 ]
178 } else {
179 let display = display_labels.as_deref().unwrap_or(&categories);
180 display.to_vec()
181 };
182
183 let has_x_axis_label = config.visualize.axes.as_ref()
185 .and_then(|a| a.x.as_ref())
186 .and_then(|a| a.label.as_ref())
187 .is_some();
188 let margin_config = MarginConfig {
189 has_title: config.title.is_some(),
190 has_legend: color_field.is_some(),
191 has_x_axis_label,
192 x_label_strategy_margin: x_extra_margin,
193 y_tick_labels: y_tick_labels_for_margin,
194 ..Default::default()
195 };
196 let margins = calculate_margins(&margin_config);
197
198 let inner_width = margins.inner_width(config.width);
199 let inner_height = margins.inner_height(config.height);
200
201 let mut children = Vec::new();
202
203 let grid = GridConfig::from_config(config);
207
208 let _tick_count = adaptive_tick_count(inner_height);
209
210 let raw_data_max = prelim_data_max;
212
213 let (domain_min, domain_max) = if is_normalized {
215 (0.0, 1.0)
216 } else {
217 let raw_domain_min = axis_min.unwrap_or(prelim_data_min);
218 let raw_domain_max = axis_max.unwrap_or(raw_data_max);
219 if axis_min.is_none() && axis_max.is_none() {
222 nice_domain(raw_domain_min, raw_domain_max, 5)
224 } else {
225 (raw_domain_min, raw_domain_max)
226 }
227 };
228 let effective_y_fmt: Option<String> = if is_normalized {
231 Some(".0%".to_string())
232 } else {
233 y_fmt.clone()
234 };
235 let effective_y_fmt_ref = effective_y_fmt.as_deref();
236
237 let (_, bar_elements) = if let Some(ref color_f) = color_field {
238 render_multi_series_bars(
239 data,
240 config,
241 &MultiSeriesBarParams {
242 category_field: &category_field,
243 value_field: &value_field,
244 color_field: color_f,
245 categories: &categories,
246 inner_width,
247 inner_height,
248 is_stacked,
249 is_normalized,
250 is_horizontal,
251 y_fmt_ref,
252 domain_min,
253 domain_max,
254 },
255 )?
256 } else {
257 render_single_series_bars(
258 data,
259 config,
260 &SingleSeriesBarParams {
261 category_field: &category_field,
262 value_field: &value_field,
263 categories: &categories,
264 inner_width,
265 inner_height,
266 is_horizontal,
267 y_fmt_ref,
268 domain_min,
269 domain_max,
270 },
271 )?
272 };
273
274 let axis_elements = if is_horizontal {
276 let x_axis = generate_y_axis_with_display(&categories, display_labels.as_deref(), (0.0, inner_height), 0.0, None);
278 let y_axis = generate_x_axis_numeric((domain_min, domain_max), (0.0, inner_width), margins.top + inner_height, effective_y_fmt_ref, 5, Some(inner_height), &grid);
279 let mut axes = Vec::new();
280 axes.extend(x_axis.into_iter().map(|e| offset_element(e, margins.left, margins.top)));
281 axes.extend(y_axis.into_iter().map(|e| offset_element(e, margins.left, 0.0)));
282 axes
283 } else {
284 let bottom_axis_label = config.visualize.axes.as_ref()
285 .and_then(|a| a.x.as_ref())
286 .and_then(|a| a.label.as_deref());
287 let x_axis_result = generate_x_axis_with_display(&crate::helpers::XAxisParams {
288 labels: &categories,
289 display_label_overrides: display_labels.as_deref(),
290 range: (0.0, inner_width),
291 y_position: margins.top + inner_height,
292 available_width: inner_width,
293 x_format: x_format.as_deref(),
294 chart_height: Some(inner_height),
295 grid: &grid,
296 axis_label: bottom_axis_label,
297 });
298 let left_axis_label = config.visualize.axes.as_ref()
299 .and_then(|a| a.left.as_ref())
300 .and_then(|a| a.label.as_deref());
301 let y_axis = generate_y_axis_numeric(&crate::helpers::YAxisNumericParams {
302 domain: (domain_min, domain_max),
303 range: (inner_height, 0.0),
304 x_position: margins.left,
305 fmt: effective_y_fmt_ref,
306 tick_count: adaptive_tick_count(inner_height),
307 chart_width: Some(inner_width),
308 grid: &grid,
309 axis_label: left_axis_label,
310 });
311 let mut axes = Vec::new();
312 axes.extend(x_axis_result.elements.into_iter().map(|e| offset_element(e, margins.left, 0.0)));
313 axes.extend(y_axis.into_iter().map(|e| offset_element(e, 0.0, margins.top)));
314 axes
315 };
316
317 children.push(ChartElement::Group {
318 class: "axes".to_string(),
319 transform: None,
320 children: axis_elements,
321 });
322
323 children.push(ChartElement::Group {
324 class: "bars".to_string(),
325 transform: Some(Transform::Translate(margins.left, margins.top)),
326 children: bar_elements,
327 });
328
329 if !is_horizontal {
331 if let Some(annotations) = config.visualize.annotations.as_deref() {
332 if !annotations.is_empty() {
333 use chartml_core::scales::ScaleLinear;
334 let ann_scale = ScaleLinear::new((domain_min, domain_max), (inner_height, 0.0));
335 let ann_cats = display_labels.as_deref().unwrap_or(&categories);
336 let ann_elements = generate_annotations(
337 annotations,
338 &ann_scale,
339 0.0,
340 inner_width,
341 inner_height,
342 Some(ann_cats),
343 );
344 if !ann_elements.is_empty() {
345 children.push(ChartElement::Group {
346 class: "annotations".to_string(),
347 transform: Some(Transform::Translate(margins.left, margins.top)),
348 children: ann_elements,
349 });
350 }
351 }
352 }
353 }
354
355 if let Some(ref color_f) = color_field {
357 let series_names = data.unique_values(color_f);
358 let legend_config = LegendConfig::default();
359 let legend_layout = calculate_legend_layout(&series_names, &config.colors, config.width, &legend_config);
360 let legend_y = config.height - legend_layout.total_height - 8.0;
361 let legend_elements = generate_legend(&series_names, &config.colors, config.width, legend_y);
362 children.push(ChartElement::Group {
363 class: "legend".to_string(),
364 transform: None,
365 children: legend_elements,
366 });
367 }
368
369 Ok(ChartElement::Svg {
370 viewbox: ViewBox::new(0.0, 0.0, config.width, config.height),
371 width: Some(config.width),
372 height: Some(config.height),
373 class: "chartml-bar".to_string(),
374 children,
375 })
376}
377
378fn render_single_series_bars(
379 data: &DataTable,
380 config: &ChartConfig,
381 params: &SingleSeriesBarParams,
382) -> Result<(f64, Vec<ChartElement>), ChartError> {
383 let category_field = params.category_field;
384 let value_field = params.value_field;
385 let categories = params.categories;
386 let inner_width = params.inner_width;
387 let inner_height = params.inner_height;
388 let is_horizontal = params.is_horizontal;
389 let y_fmt_ref = params.y_fmt_ref;
390 let domain_min = params.domain_min;
391 let domain_max = params.domain_max;
392 let values: Vec<f64> = (0..data.num_rows())
394 .filter_map(|i| data.get_f64(i, value_field))
395 .collect();
396 let value_max = values.iter().cloned().fold(0.0_f64, f64::max);
397 let value_max = if value_max <= 0.0 { 1.0 } else { value_max };
398 let effective_max = domain_max;
400
401 let mut elements = Vec::new();
402 let fill_color = config.colors.first()
405 .cloned()
406 .unwrap_or_else(|| "#2E7D9A".to_string());
407
408 if is_horizontal {
409 let band = ScaleBand::new(categories.to_vec(), (0.0, inner_height))
410 .padding(crate::helpers::adaptive_bar_padding(categories.len()));
411 let linear = ScaleLinear::new((domain_min, effective_max), (0.0, inner_width));
412 let bar_render_height = band.bandwidth().min(40.0);
414 let y_inset = (band.bandwidth() - bar_render_height) / 2.0;
415
416 for i in 0..data.num_rows() {
417 let cat = match data.get_string(i, category_field) {
418 Some(c) => c,
419 None => continue,
420 };
421 let val = data.get_f64(i, value_field).unwrap_or(0.0);
422 let band_key = categories.get(i).map(|k| k.as_str()).unwrap_or(&cat);
424 let y = match band.map(band_key) {
425 Some(y) => y,
426 None => continue,
427 };
428 let bar_width = linear.map(val);
429
430 elements.push(ChartElement::Rect {
431 x: 0.0,
432 y: y + y_inset,
433 width: bar_width,
434 height: bar_render_height,
435 fill: fill_color.clone(),
436 stroke: None,
437 class: "bar".to_string(),
438 data: Some(ElementData::new(&cat, format_value(val, y_fmt_ref))),
439 });
440 }
441 } else {
442 let band = ScaleBand::new(categories.to_vec(), (0.0, inner_width))
443 .padding(crate::helpers::adaptive_bar_padding(categories.len()));
444 let linear = ScaleLinear::new((domain_min, effective_max), (inner_height, 0.0));
445 let max_bar_width = inner_width * 0.2;
447 let bar_render_width = band.bandwidth().min(max_bar_width);
448 let x_inset = (band.bandwidth() - bar_render_width) / 2.0;
449
450 for i in 0..data.num_rows() {
451 let cat = match data.get_string(i, category_field) {
452 Some(c) => c,
453 None => continue,
454 };
455 let val = data.get_f64(i, value_field).unwrap_or(0.0);
456 let band_key = categories.get(i).map(|k| k.as_str()).unwrap_or(&cat);
458 let x = match band.map(band_key) {
459 Some(x) => x,
460 None => continue,
461 };
462 let bar_val_y = linear.map(val);
463 let bar_zero_y = linear.map(0.0);
464 let bar_height = (bar_zero_y - bar_val_y).abs();
465 let rect_y = bar_val_y.min(bar_zero_y);
468
469 elements.push(ChartElement::Rect {
470 x: x + x_inset,
471 y: rect_y,
472 width: bar_render_width,
473 height: bar_height,
474 fill: fill_color.clone(),
475 stroke: None,
476 class: "bar".to_string(),
477 data: Some(ElementData::new(&cat, format_value(val, y_fmt_ref))),
478 });
479
480 if let Some(dl) = get_data_labels_config(config) {
482 if dl.show == Some(true) {
483 let label_fmt = dl.format.as_deref().or(y_fmt_ref);
484 let label_y = match dl.position.as_deref() {
485 Some("center") => rect_y + bar_height / 2.0,
486 Some("bottom") => rect_y + bar_height - 5.0,
487 _ => if val >= 0.0 { rect_y - 5.0 } else { rect_y + bar_height + 12.0 }, };
489 elements.push(ChartElement::Text {
490 x: x + band.bandwidth() / 2.0,
491 y: label_y,
492 content: format_value(val, label_fmt),
493 anchor: TextAnchor::Middle,
494 dominant_baseline: None,
495 transform: None,
496 font_size: Some(dl.font_size.map(|s| format!("{}px", s)).unwrap_or_else(|| "11px".to_string())),
497 font_weight: None,
498 fill: Some(dl.color.clone().unwrap_or_else(|| "#333".to_string())),
499 class: "data-label".to_string(),
500 data: None,
501 });
502 }
503 }
504 }
505 }
506
507 Ok((value_max, elements))
508}
509
510fn render_multi_series_bars(
511 data: &DataTable,
512 config: &ChartConfig,
513 params: &MultiSeriesBarParams,
514) -> Result<(f64, Vec<ChartElement>), ChartError> {
515 let category_field = params.category_field;
516 let value_field = params.value_field;
517 let color_field = params.color_field;
518 let categories = params.categories;
519 let inner_width = params.inner_width;
520 let inner_height = params.inner_height;
521 let is_stacked = params.is_stacked;
522 let is_normalized = params.is_normalized;
523 let is_horizontal = params.is_horizontal;
524 let y_fmt_ref = params.y_fmt_ref;
525 let domain_min = params.domain_min;
526 let domain_max = params.domain_max;
527 use chartml_core::layout::stack::{StackLayout, StackOffset};
528
529 let series_names = data.unique_values(color_field);
530 let groups = data.group_by(color_field);
531
532 let mut elements = Vec::new();
533
534 if is_stacked {
535 let mut values_matrix: Vec<Vec<f64>> = Vec::new();
537 for series in &series_names {
538 let mut series_vals = Vec::new();
539 let series_data = groups.get(series);
540 for cat in categories {
541 let val = series_data
542 .map(|sd| {
543 (0..sd.num_rows())
544 .find_map(|i| {
545 if sd.get_string(i, category_field).as_deref() == Some(cat.as_str()) {
546 sd.get_f64(i, value_field)
547 } else {
548 None
549 }
550 })
551 .unwrap_or(0.0)
552 })
553 .unwrap_or(0.0);
554 series_vals.push(val);
555 }
556 values_matrix.push(series_vals);
557 }
558
559 let stack = if is_normalized {
560 StackLayout::new().offset(StackOffset::Normalize)
561 } else {
562 StackLayout::new()
563 };
564 let stacked_points = stack.layout(categories, &series_names, &values_matrix);
565
566 let (effective_min, effective_max) = if is_normalized {
568 (0.0, 1.0)
569 } else {
570 let value_max = stacked_points
571 .iter()
572 .map(|p| p.y1)
573 .fold(0.0_f64, f64::max);
574 let value_max = if value_max <= 0.0 { 1.0 } else { value_max };
575 (domain_min, if domain_max < f64::MAX { domain_max } else { value_max })
576 };
577
578 if is_horizontal {
579 let band = ScaleBand::new(categories.to_vec(), (0.0, inner_height))
581 .padding(crate::helpers::adaptive_bar_padding(categories.len()));
582 let linear = ScaleLinear::new((effective_min, effective_max), (0.0, inner_width));
583 let bar_render_height = band.bandwidth().min(40.0);
584 let y_inset = (band.bandwidth() - bar_render_height) / 2.0;
585
586 for point in &stacked_points {
587 let y = match band.map(&point.key) {
588 Some(y) => y,
589 None => continue,
590 };
591 let x_left = linear.map(point.y0);
592 let x_right = linear.map(point.y1);
593 let bar_width = (x_right - x_left).abs();
594
595 let series_idx = series_names.iter().position(|s| s == &point.series).unwrap_or(0);
596 let fill = config
597 .colors
598 .get(series_idx)
599 .cloned()
600 .unwrap_or_else(|| "#2E7D9A".to_string());
601
602 elements.push(ChartElement::Rect {
603 x: x_left.min(x_right),
604 y: y + y_inset,
605 width: bar_width,
606 height: bar_render_height,
607 fill,
608 stroke: None,
609 class: "bar".to_string(),
610 data: Some(
611 ElementData::new(&point.key, format_value(point.value, y_fmt_ref))
612 .with_series(&point.series),
613 ),
614 });
615 }
616 } else {
617 let band = ScaleBand::new(categories.to_vec(), (0.0, inner_width))
619 .padding(crate::helpers::adaptive_bar_padding(categories.len()));
620 let linear = ScaleLinear::new((effective_min, effective_max), (inner_height, 0.0));
621 let max_bar_width = inner_width * 0.2;
623 let bar_render_width = band.bandwidth().min(max_bar_width);
624 let x_inset = (band.bandwidth() - bar_render_width) / 2.0;
625
626 for point in &stacked_points {
627 let x = match band.map(&point.key) {
628 Some(x) => x,
629 None => continue,
630 };
631 let y_top = linear.map(point.y1);
632 let y_bottom = linear.map(point.y0);
633 let bar_height = (y_bottom - y_top).abs();
634
635 let series_idx = series_names.iter().position(|s| s == &point.series).unwrap_or(0);
636 let fill = config
637 .colors
638 .get(series_idx)
639 .cloned()
640 .unwrap_or_else(|| "#2E7D9A".to_string());
641
642 elements.push(ChartElement::Rect {
643 x: x + x_inset,
644 y: y_top,
645 width: bar_render_width,
646 height: bar_height,
647 fill,
648 stroke: None,
649 class: "bar".to_string(),
650 data: Some(
651 ElementData::new(&point.key, format_value(point.value, y_fmt_ref))
652 .with_series(&point.series),
653 ),
654 });
655 }
656 }
657
658 Ok((effective_max, elements))
659 } else {
660 let value_max = (0..data.num_rows())
663 .filter_map(|i| data.get_f64(i, value_field))
664 .fold(0.0_f64, f64::max);
665 let value_max = if value_max <= 0.0 { 1.0 } else { value_max };
666 let effective_max = if domain_max < f64::MAX { domain_max } else { value_max };
667
668 let num_series = series_names.len().max(1);
669
670 if is_horizontal {
671 let band = ScaleBand::new(categories.to_vec(), (0.0, inner_height))
673 .padding(0.05);
674 let linear = ScaleLinear::new((domain_min, effective_max), (0.0, inner_width));
675 let sub_band_height = band.bandwidth() / num_series as f64;
676
677 for i in 0..data.num_rows() {
678 let cat = match data.get_string(i, category_field) {
679 Some(c) => c,
680 None => continue,
681 };
682 let series = match data.get_string(i, color_field) {
683 Some(s) => s,
684 None => continue,
685 };
686 let val = data.get_f64(i, value_field).unwrap_or(0.0);
687
688 let y_base = match band.map(&cat) {
689 Some(y) => y,
690 None => continue,
691 };
692 let series_idx = series_names.iter().position(|s| s == &series).unwrap_or(0);
693 let y = y_base + series_idx as f64 * sub_band_height;
694
695 let bar_left = linear.map(0.0);
696 let bar_right = linear.map(val);
697 let bar_width = (bar_right - bar_left).abs();
698
699 let fill = config
700 .colors
701 .get(series_idx)
702 .cloned()
703 .unwrap_or_else(|| "#2E7D9A".to_string());
704
705 elements.push(ChartElement::Rect {
706 x: bar_left.min(bar_right),
707 y,
708 width: bar_width,
709 height: sub_band_height,
710 fill,
711 stroke: None,
712 class: "bar".to_string(),
713 data: Some(
714 ElementData::new(&cat, format_value(val, y_fmt_ref)).with_series(&series),
715 ),
716 });
717 }
718 } else {
719 let band = ScaleBand::new(categories.to_vec(), (0.0, inner_width))
721 .padding(0.05);
722 let linear = ScaleLinear::new((domain_min, effective_max), (inner_height, 0.0));
723 let sub_band_width = band.bandwidth() / num_series as f64;
724
725 for i in 0..data.num_rows() {
726 let cat = match data.get_string(i, category_field) {
727 Some(c) => c,
728 None => continue,
729 };
730 let series = match data.get_string(i, color_field) {
731 Some(s) => s,
732 None => continue,
733 };
734 let val = data.get_f64(i, value_field).unwrap_or(0.0);
735
736 let x_base = match band.map(&cat) {
737 Some(x) => x,
738 None => continue,
739 };
740 let series_idx = series_names.iter().position(|s| s == &series).unwrap_or(0);
741 let x = x_base + series_idx as f64 * sub_band_width;
742
743 let bar_top = linear.map(val);
744 let bar_bottom = linear.map(0.0);
745 let bar_height = (bar_bottom - bar_top).abs();
746
747 let fill = config
748 .colors
749 .get(series_idx)
750 .cloned()
751 .unwrap_or_else(|| "#2E7D9A".to_string());
752
753 elements.push(ChartElement::Rect {
754 x,
755 y: bar_top,
756 width: sub_band_width,
757 height: bar_height,
758 fill,
759 stroke: None,
760 class: "bar".to_string(),
761 data: Some(
762 ElementData::new(&cat, format_value(val, y_fmt_ref)).with_series(&series),
763 ),
764 });
765 }
766 }
767
768 Ok((value_max, elements))
769 }
770}
771
772fn render_combo(
774 data: &DataTable,
775 config: &ChartConfig,
776 fields: &[chartml_core::spec::FieldSpec],
777) -> Result<ChartElement, ChartError> {
778 use chartml_core::shapes::LineGenerator;
779 use chartml_core::layout::stack::StackLayout;
780
781 let category_field = get_field_name(&config.visualize.columns)?;
782 let categories = data.unique_values(&category_field);
783 if categories.is_empty() {
784 return Err(ChartError::DataError("No category values found".into()));
785 }
786
787 let y_fmt = get_y_format(config);
788 let y_fmt_ref = y_fmt.as_deref();
789 let grid = GridConfig::from_config(config);
790 let x_format = get_x_format(config);
791
792 let color_field = get_color_field(config);
794 let is_stacked = matches!(config.visualize.mode, Some(ChartMode::Stacked));
795
796 let has_right = fields.iter().any(|f| f.axis.as_deref() == Some("right"));
798 let right_fmt = config.visualize.axes.as_ref()
799 .and_then(|a| a.right.as_ref())
800 .and_then(|a| a.format.as_deref());
801
802 let right_tick_labels: Vec<String> = if has_right {
804 let right_max = fields.iter()
806 .filter(|f| f.axis.as_deref() == Some("right"))
807 .flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
808 .fold(0.0_f64, f64::max);
809 let right_domain_max = config.visualize.axes.as_ref()
810 .and_then(|a| a.right.as_ref())
811 .and_then(|a| a.max)
812 .unwrap_or(if right_max <= 0.0 { 1.0 } else { right_max });
813 let tmp_scale = ScaleLinear::new((0.0, right_domain_max), (0.0, 100.0));
814 tmp_scale.ticks(5).iter().map(|v| format_value(*v, right_fmt)).collect()
815 } else {
816 vec![]
817 };
818
819 let has_x_axis_label = config.visualize.axes.as_ref()
820 .and_then(|a| a.x.as_ref())
821 .and_then(|a| a.label.as_ref())
822 .is_some();
823 let margin_config = MarginConfig {
824 has_title: config.title.is_some(),
825 has_legend: fields.len() > 1 || color_field.is_some(),
826 has_y_axis_label: false,
829 has_x_axis_label,
830 has_right_axis: has_right,
831 right_tick_labels,
832 ..Default::default()
833 };
834 let margins = calculate_margins(&margin_config);
835 let inner_width = margins.inner_width(config.width);
836 let inner_height = margins.inner_height(config.height);
837
838 let band = ScaleBand::new(categories.clone(), (0.0, inner_width))
839 .padding(crate::helpers::adaptive_bar_padding(categories.len()));
840 let bandwidth = band.bandwidth();
841
842 let left_fields: Vec<&chartml_core::spec::FieldSpec> = fields.iter()
844 .filter(|f| f.axis.as_deref() != Some("right"))
845 .collect();
846 let right_fields: Vec<&chartml_core::spec::FieldSpec> = fields.iter()
847 .filter(|f| f.axis.as_deref() == Some("right"))
848 .collect();
849
850 let left_max = if let (true, Some(color_f)) = (is_stacked, color_field.as_ref()) {
853 let color_series = data.unique_values(color_f);
854 let mut max_stack = 0.0_f64;
855 for f in &left_fields {
856 for cat in &categories {
857 let mut stack_total = 0.0_f64;
858 for series in &color_series {
859 let val = (0..data.num_rows())
860 .find(|&i| {
861 data.get_string(i, &category_field).as_deref() == Some(cat.as_str())
862 && data.get_string(i, color_f).as_deref() == Some(series.as_str())
863 })
864 .and_then(|i| data.get_f64(i, &f.field))
865 .unwrap_or(0.0);
866 stack_total += val;
867 }
868 max_stack = max_stack.max(stack_total);
869 }
870 }
871 max_stack
872 } else {
873 left_fields.iter()
874 .flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
875 .fold(0.0_f64, f64::max)
876 };
877 let left_data_min = left_fields.iter()
879 .flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
880 .fold(0.0_f64, f64::min);
881 let left_data_min = if left_data_min >= 0.0 { 0.0 } else { left_data_min };
883 let axes_left = config.visualize.axes.as_ref().and_then(|a| a.left.as_ref());
884 let left_explicit_min = axes_left.and_then(|a| a.min);
885 let left_explicit_max = axes_left.and_then(|a| a.max);
886 let raw_left_domain_min = left_explicit_min.unwrap_or(left_data_min);
887 let raw_left_domain_max = left_explicit_max.unwrap_or(if left_max <= 0.0 { 1.0 } else { left_max });
888 let (left_domain_min, left_domain_max) = if left_explicit_min.is_none() && left_explicit_max.is_none() {
889 nice_domain(raw_left_domain_min, raw_left_domain_max, 5)
891 } else {
892 (raw_left_domain_min, raw_left_domain_max)
893 };
894 let left_scale = ScaleLinear::new((left_domain_min, left_domain_max), (inner_height, 0.0));
895
896 let right_scale = if !right_fields.is_empty() {
898 let right_max = right_fields.iter()
899 .flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
900 .fold(0.0_f64, f64::max);
901 let right_data_min = right_fields.iter()
902 .flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
903 .fold(0.0_f64, f64::min);
904 let axes_right = config.visualize.axes.as_ref().and_then(|a| a.right.as_ref());
905 let right_explicit_min = axes_right.and_then(|a| a.min);
906 let right_explicit_max = axes_right.and_then(|a| a.max);
907 let raw_right_domain_min = right_explicit_min.unwrap_or(if right_data_min < 0.0 { right_data_min } else { 0.0 });
908 let raw_right_domain_max = right_explicit_max.unwrap_or(if right_max <= 0.0 { 1.0 } else { right_max });
909 let (right_domain_min, right_domain_max) = if right_explicit_min.is_none() && right_explicit_max.is_none() {
910 nice_domain(raw_right_domain_min, raw_right_domain_max, 5)
912 } else {
913 (raw_right_domain_min, raw_right_domain_max)
914 };
915 Some(ScaleLinear::new((right_domain_min, right_domain_max), (inner_height, 0.0)))
916 } else {
917 None
918 };
919
920 let mut children = Vec::new();
921
922 let bottom_axis_label = config.visualize.axes.as_ref()
926 .and_then(|a| a.x.as_ref())
927 .and_then(|a| a.label.as_deref());
928 let x_axis_result = generate_x_axis(&crate::helpers::XAxisParams {
929 labels: &categories,
930 display_label_overrides: None,
931 range: (0.0, inner_width),
932 y_position: margins.top + inner_height,
933 available_width: inner_width,
934 x_format: x_format.as_deref(),
935 chart_height: Some(inner_height),
936 grid: &grid,
937 axis_label: bottom_axis_label,
938 });
939 let left_axis_label = axes_left.and_then(|a| a.label.as_deref());
940 let y_axis_left = generate_y_axis_numeric(&crate::helpers::YAxisNumericParams {
941 domain: (left_domain_min, left_domain_max),
942 range: (inner_height, 0.0),
943 x_position: margins.left,
944 fmt: y_fmt_ref,
945 tick_count: adaptive_tick_count(inner_height),
946 chart_width: Some(inner_width),
947 grid: &grid,
948 axis_label: left_axis_label,
949 });
950
951 let mut axis_elements = Vec::new();
952 axis_elements.extend(x_axis_result.elements.into_iter().map(|e| offset_element(e, margins.left, 0.0)));
953 axis_elements.extend(y_axis_left.into_iter().map(|e| offset_element(e, 0.0, margins.top)));
954
955 if let Some(ref rs) = right_scale {
957 let right_fmt = config.visualize.axes.as_ref()
958 .and_then(|a| a.right.as_ref())
959 .and_then(|a| a.format.as_deref());
960 let right_axis = generate_y_axis_numeric_right(
963 rs.domain(), (inner_height, 0.0), margins.left + inner_width,
964 right_fmt, adaptive_tick_count(inner_height),
965 None,
966 );
967 axis_elements.extend(right_axis.into_iter().map(|e| offset_element(e, 0.0, margins.top)));
968 }
969
970 if let Some(label) = config.visualize.axes.as_ref().and_then(|a| a.right.as_ref()).and_then(|a| a.label.clone()) {
973 let rx = config.width - 12.0;
974 axis_elements.push(ChartElement::Text {
975 x: rx,
976 y: margins.top + inner_height / 2.0,
977 content: label,
978 anchor: TextAnchor::Middle,
979 dominant_baseline: None,
980 transform: Some(Transform::Rotate(90.0, rx, margins.top + inner_height / 2.0)),
981 font_size: Some("12px".to_string()),
982 font_weight: None,
983 fill: Some("#666".to_string()),
984 class: "axis-label".to_string(),
985 data: None,
986 });
987 }
988
989 children.push(ChartElement::Group {
990 class: "axes".to_string(), transform: None, children: axis_elements,
991 });
992
993 let mut mark_elements = Vec::new();
995 let line_gen = LineGenerator::new().curve(chartml_core::shapes::CurveType::MonotoneX);
996
997 let num_bar_fields = fields.iter()
999 .filter(|f| f.mark.as_deref().unwrap_or("bar") == "bar")
1000 .count()
1001 .max(1);
1002 let max_bar_width = inner_width * 0.2;
1004 let effective_bandwidth = bandwidth.min(max_bar_width);
1005 let combo_x_inset = (bandwidth - effective_bandwidth) / 2.0;
1006 let sub_bar_padding = effective_bandwidth * 0.05;
1007 let sub_bar_width = (effective_bandwidth - sub_bar_padding * (num_bar_fields as f64 - 1.0).max(0.0)) / num_bar_fields as f64;
1008 let mut bar_field_idx = 0_usize;
1009 let mut series_names = Vec::new();
1010 let mut series_colors = Vec::new();
1011 let mut series_marks = Vec::new();
1012
1013 let stacked_bar_rendered = if let (true, Some(color_f)) = (is_stacked, color_field.as_ref()) {
1015 let color_series = data.unique_values(color_f);
1016
1017 for field_spec in fields.iter() {
1019 let mark = field_spec.mark.as_deref().unwrap_or("bar");
1020 if mark != "bar" { continue; }
1021
1022 let field_name = &field_spec.field;
1023 let is_right = field_spec.axis.as_deref() == Some("right");
1024 let scale = if is_right { right_scale.as_ref().unwrap_or(&left_scale) } else { &left_scale };
1025 let fmt_ref = if is_right {
1026 config.visualize.axes.as_ref().and_then(|a| a.right.as_ref()).and_then(|a| a.format.as_deref())
1027 } else {
1028 y_fmt_ref
1029 };
1030
1031 let mut values_matrix: Vec<Vec<f64>> = Vec::new();
1033 for series in &color_series {
1034 let mut series_vals = Vec::new();
1035 for cat in &categories {
1036 let val = (0..data.num_rows())
1037 .find(|&i| {
1038 data.get_string(i, &category_field).as_deref() == Some(cat.as_str())
1039 && data.get_string(i, color_f).as_deref() == Some(series.as_str())
1040 })
1041 .and_then(|i| data.get_f64(i, field_name))
1042 .unwrap_or(0.0);
1043 series_vals.push(val);
1044 }
1045 values_matrix.push(series_vals);
1046 }
1047
1048 let stack = StackLayout::new();
1049 let stacked_points = stack.layout(&categories, &color_series, &values_matrix);
1050
1051 let bar_render_width = bandwidth.min(max_bar_width);
1052 let x_inset = (bandwidth - bar_render_width) / 2.0;
1053
1054 for point in &stacked_points {
1055 let x = match band.map(&point.key) { Some(x) => x, None => continue };
1056 let y_top = scale.map(point.y1);
1057 let y_bottom = scale.map(point.y0);
1058 let bar_height = (y_bottom - y_top).abs();
1059
1060 let series_idx = color_series.iter().position(|s| s == &point.series).unwrap_or(0);
1061 let fill = config.colors.get(series_idx).cloned().unwrap_or_else(|| "#2E7D9A".to_string());
1062
1063 mark_elements.push(ChartElement::Rect {
1064 x: x + x_inset + margins.left, y: y_top + margins.top,
1065 width: bar_render_width, height: bar_height,
1066 fill, stroke: None,
1067 class: "bar".to_string(),
1068 data: Some(ElementData::new(&point.key, format_value(point.value, fmt_ref)).with_series(&point.series)),
1069 });
1070 }
1071 }
1072
1073 for (si, series_name) in color_series.iter().enumerate() {
1075 let color = config.colors.get(si).cloned().unwrap_or_else(|| "#2E7D9A".to_string());
1076 series_names.push(series_name.clone());
1077 series_colors.push(color);
1078 series_marks.push("bar".to_string());
1079 }
1080
1081 true
1082 } else {
1083 false
1084 };
1085
1086 for (field_idx, field_spec) in fields.iter().enumerate() {
1087 let field_name = &field_spec.field;
1088 let is_right = field_spec.axis.as_deref() == Some("right");
1089 let scale = if is_right { right_scale.as_ref().unwrap_or(&left_scale) } else { &left_scale };
1090 let mark = field_spec.mark.as_deref().unwrap_or("bar");
1091 let color = field_spec.color.clone()
1092 .unwrap_or_else(|| config.colors.get(field_idx).cloned().unwrap_or_else(|| "#2E7D9A".to_string()));
1093 let label = field_spec.label.clone().unwrap_or_else(|| field_name.clone());
1094 let fmt_ref = if is_right {
1095 config.visualize.axes.as_ref().and_then(|a| a.right.as_ref()).and_then(|a| a.format.as_deref())
1096 } else {
1097 y_fmt_ref
1098 };
1099
1100 match mark {
1101 "bar" if stacked_bar_rendered => {
1102 }
1104 "bar" => {
1105 let this_bar_idx = bar_field_idx;
1106 bar_field_idx += 1;
1107
1108 for row_i in 0..data.num_rows() {
1109 let cat = match data.get_string(row_i, &category_field) { Some(c) => c, None => continue };
1110 let val = data.get_f64(row_i, field_name).unwrap_or(0.0);
1111 let x = match band.map(&cat) { Some(x) => x, None => continue };
1112 let bar_x = x + combo_x_inset + this_bar_idx as f64 * (sub_bar_width + sub_bar_padding);
1113 let bar_val_y = scale.map(val);
1114 let bar_zero_y = scale.map(0.0);
1115 let bar_height = (bar_zero_y - bar_val_y).abs();
1116 let rect_y = bar_val_y.min(bar_zero_y);
1117
1118 mark_elements.push(ChartElement::Rect {
1119 x: bar_x + margins.left, y: rect_y + margins.top,
1120 width: sub_bar_width, height: bar_height,
1121 fill: color.clone(), stroke: None,
1122 class: "bar".to_string(),
1123 data: Some(ElementData::new(&cat, format_value(val, fmt_ref)).with_series(&label)),
1124 });
1125
1126 if let Some(ref dl) = field_spec.data_labels {
1128 if dl.show == Some(true) {
1129 let dl_fmt = dl.format.as_deref().or(fmt_ref);
1130 mark_elements.push(ChartElement::Text {
1131 x: bar_x + sub_bar_width / 2.0 + margins.left,
1132 y: rect_y + margins.top - 5.0,
1133 content: format_value(val, dl_fmt),
1134 anchor: TextAnchor::Middle, dominant_baseline: None,
1135 transform: None,
1136 font_size: Some(dl.font_size.map(|s| format!("{}px", s)).unwrap_or_else(|| "11px".to_string())),
1137 font_weight: None,
1138 fill: Some(dl.color.clone().unwrap_or_else(|| "#333".to_string())),
1139 class: "data-label".to_string(), data: None,
1140 });
1141 }
1142 }
1143 }
1144 }
1145 _ => {
1146 let mut points = Vec::new();
1147 let mut point_data = Vec::new();
1148 for cat in &categories {
1149 let row_i = match (0..data.num_rows()).find(|&i| data.get_string(i, &category_field).as_deref() == Some(cat.as_str())) {
1150 Some(i) => i, None => continue,
1151 };
1152 let val = match data.get_f64(row_i, field_name) { Some(v) => v, None => continue };
1153 let x = match band.map(cat) { Some(x) => x + bandwidth / 2.0, None => continue };
1154 let y = scale.map(val);
1155 points.push((x + margins.left, y + margins.top));
1156 point_data.push((cat.clone(), val));
1157 }
1158
1159 if !points.is_empty() {
1160 let path_d = line_gen.generate(&points);
1161 mark_elements.push(ChartElement::Path {
1162 d: path_d, fill: None, stroke: Some(color.clone()),
1163 stroke_width: Some(2.0), stroke_dasharray: None,
1164 opacity: None,
1165 class: "line".to_string(),
1166 data: Some(ElementData::new(&label, "").with_series(&label)),
1167 });
1168
1169 for (i, &(px, py)) in points.iter().enumerate() {
1171 let (ref cat, val) = point_data[i];
1172 mark_elements.push(ChartElement::Circle {
1173 cx: px, cy: py, r: 5.0,
1174 fill: color.clone(), stroke: Some("#fff".to_string()),
1175 class: "chartml-line-dot".to_string(),
1176 data: Some(ElementData::new(cat, format_value(val, fmt_ref)).with_series(&label)),
1177 });
1178 }
1179
1180 if let Some(ref dl) = field_spec.data_labels {
1182 if dl.show == Some(true) {
1183 let dl_fmt = dl.format.as_deref().or(fmt_ref);
1184 for (i, &(px, py)) in points.iter().enumerate() {
1185 let (_, val) = &point_data[i];
1186 mark_elements.push(ChartElement::Text {
1187 x: px, y: py - 10.0,
1188 content: format_value(*val, dl_fmt),
1189 anchor: TextAnchor::Middle, dominant_baseline: None,
1190 transform: None,
1191 font_size: Some(dl.font_size.map(|s| format!("{}px", s)).unwrap_or_else(|| "11px".to_string())),
1192 font_weight: None,
1193 fill: Some(dl.color.clone().unwrap_or_else(|| color.clone())),
1194 class: "data-label".to_string(), data: None,
1195 });
1196 }
1197 }
1198 }
1199 }
1200 }
1201 }
1202
1203 if !(stacked_bar_rendered && mark == "bar") {
1206 series_names.push(label);
1207 series_colors.push(color);
1208 series_marks.push(mark.to_string());
1209 }
1210 }
1211
1212 children.push(ChartElement::Group {
1213 class: "marks".to_string(), transform: None, children: mark_elements,
1214 });
1215
1216 if let Some(annotations) = config.visualize.annotations.as_deref() {
1218 if !annotations.is_empty() {
1219 let ann_elements = generate_annotations(
1220 annotations,
1221 &left_scale,
1222 0.0,
1223 inner_width,
1224 inner_height,
1225 Some(&categories),
1226 );
1227 if !ann_elements.is_empty() {
1228 children.push(ChartElement::Group {
1229 class: "annotations".to_string(),
1230 transform: Some(Transform::Translate(margins.left, margins.top)),
1231 children: ann_elements,
1232 });
1233 }
1234 }
1235 }
1236
1237 if series_names.len() > 1 {
1239 let mut legend_elements = Vec::new();
1240 let total_w: f64 = series_names.iter().map(|name| {
1241 let tw = chartml_core::layout::labels::approximate_text_width(name);
1242 12.0 + 6.0 + tw + 16.0
1243 }).sum();
1244 let mut x_offset = (config.width - total_w).max(0.0) / 2.0;
1245
1246 for (i, name) in series_names.iter().enumerate() {
1247 let color = &series_colors[i];
1248 let mark = series_marks[i].as_str();
1249 let y = config.height - 10.0;
1250
1251 match mark {
1252 "line" => {
1253 legend_elements.push(ChartElement::Line {
1254 x1: x_offset, y1: y + 6.0, x2: x_offset + 12.0, y2: y + 6.0,
1255 stroke: color.clone(), stroke_width: Some(2.5),
1256 stroke_dasharray: None, class: "legend-symbol legend-line".to_string(),
1257 });
1258 }
1259 _ => {
1260 legend_elements.push(ChartElement::Rect {
1261 x: x_offset, y, width: 12.0, height: 12.0,
1262 fill: color.clone(), stroke: None,
1263 class: "legend-symbol".to_string(), data: None,
1264 });
1265 }
1266 }
1267
1268 legend_elements.push(ChartElement::Text {
1269 x: x_offset + 18.0, y: y + 10.0, content: name.clone(),
1270 anchor: TextAnchor::Start, dominant_baseline: None,
1271 transform: None, font_size: Some("11px".to_string()),
1272 font_weight: None,
1273 fill: Some("#333".to_string()), class: "legend-label".to_string(), data: None,
1274 });
1275
1276 let tw = chartml_core::layout::labels::approximate_text_width(name);
1277 x_offset += 12.0 + 6.0 + tw + 16.0;
1278 }
1279
1280 children.push(ChartElement::Group {
1281 class: "legend".to_string(), transform: None, children: legend_elements,
1282 });
1283 }
1284
1285 Ok(ChartElement::Svg {
1286 viewbox: ViewBox::new(0.0, 0.0, config.width, config.height),
1287 width: Some(config.width),
1288 height: Some(config.height),
1289 class: "chartml-bar chartml-combo".to_string(),
1290 children,
1291 })
1292}
1293