Skip to main content

chartml_chart_cartesian/
area.rs

1use chartml_core::data::DataTable;
2use chartml_core::element::{ChartElement, ElementData, Transform, ViewBox};
3use chartml_core::error::ChartError;
4use chartml_core::layout::margins::{calculate_margins, MarginConfig};
5use chartml_core::layout::stack::{StackLayout, StackOffset};
6use chartml_core::plugin::ChartConfig;
7use chartml_core::scales::{ScaleBand, ScaleLinear};
8use chartml_core::shapes::AreaGenerator;
9
10use chartml_core::layout::labels::{LabelStrategy, LabelStrategyConfig};
11
12use chartml_core::layout::legend::{calculate_legend_layout, LegendConfig};
13
14use crate::helpers::{GridConfig, generate_annotations, generate_x_axis, generate_y_axis_numeric, generate_legend, get_color_field, get_field_name, get_x_format, get_y_format, offset_element};
15
16pub fn render_area(data: &DataTable, config: &ChartConfig) -> Result<ChartElement, ChartError> {
17    let category_field = get_field_name(&config.visualize.columns)?;
18    let value_field = get_field_name(&config.visualize.rows)?;
19
20    let categories = data.unique_values(&category_field);
21    if categories.is_empty() {
22        return Err(ChartError::DataError("No category values found".into()));
23    }
24
25    let color_field = get_color_field(config);
26    let is_stacked = matches!(config.visualize.mode, Some(chartml_core::spec::ChartMode::Stacked));
27    let is_normalized = matches!(config.visualize.mode, Some(chartml_core::spec::ChartMode::Normalized));
28    let y_fmt = get_y_format(config);
29    let y_fmt_ref = y_fmt.as_deref();
30    let grid = GridConfig::from_config(config);
31    let left_axis_label = config.visualize.axes.as_ref()
32        .and_then(|a| a.left.as_ref())
33        .and_then(|a| a.label.as_deref());
34
35    // Step 1: Compute label strategy for margin estimation
36    let estimated_width = config.width - 80.0;
37    let x_format = get_x_format(config);
38    let x_strategy = LabelStrategy::determine(&categories, estimated_width, &LabelStrategyConfig::default());
39    let x_extra_margin = match &x_strategy {
40        LabelStrategy::Rotated { margin, .. } => *margin,
41        _ => 0.0,
42    };
43
44    // Step 1b: Pre-compute domain for left margin estimation (matches JS two-pass approach).
45    let prelim_values: Vec<f64> = (0..data.num_rows()).filter_map(|i| data.get_f64(i, &value_field)).collect();
46    let prelim_min = prelim_values.iter().cloned().fold(f64::INFINITY, f64::min);
47    let prelim_max = prelim_values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
48    let prelim_max = if prelim_max <= 0.0 { 1.0 } else { prelim_max };
49    // When data has negative values, use the actual minimum; otherwise anchor at 0
50    let prelim_domain_min = if prelim_min < 0.0 { prelim_min } else { 0.0 };
51    let (prelim_nice_min, prelim_nice_max) = crate::helpers::nice_domain(prelim_domain_min, prelim_max, 5);
52    let area_prelim_fmt = if is_normalized { Some(".0%") } else { y_fmt_ref };
53    let area_prelim_labels = vec![
54        crate::helpers::format_value(prelim_nice_min, area_prelim_fmt),
55        crate::helpers::format_value(prelim_nice_max, area_prelim_fmt),
56    ];
57
58    // Step 2: Calculate margins including rotation
59    let has_x_axis_label = config.visualize.axes.as_ref()
60        .and_then(|a| a.x.as_ref())
61        .and_then(|a| a.label.as_ref())
62        .is_some();
63    let margin_config = MarginConfig {
64        has_title: config.title.is_some(),
65        has_legend: color_field.is_some(),
66        has_x_axis_label,
67        x_label_strategy_margin: x_extra_margin,
68        y_tick_labels: area_prelim_labels,
69        ..Default::default()
70    };
71    let margins = calculate_margins(&margin_config);
72
73    let inner_width = margins.inner_width(config.width);
74    let inner_height = margins.inner_height(config.height);
75
76    let band = ScaleBand::new(categories.clone(), (0.0, inner_width));
77    let bandwidth = band.bandwidth();
78
79    let mut children = Vec::new();
80
81    // Title is rendered as HTML outside the SVG — not added here.
82
83    let area_gen = AreaGenerator::new().curve(chartml_core::shapes::CurveType::MonotoneX);
84    let mut area_elements = Vec::new();
85
86    // Track Y domain for annotations (set inside each branch below)
87    let y_domain_min: f64;
88    let y_domain_max: f64;
89
90    if let Some(ref color_f) = color_field {
91        let series_names = data.unique_values(color_f);
92        let groups = data.group_by(color_f);
93
94        if (is_stacked || is_normalized) && series_names.len() > 1 {
95            // Build values matrix for stacking
96            let mut values_matrix: Vec<Vec<f64>> = Vec::new();
97            for series in &series_names {
98                let series_data = groups.get(series);
99                let mut series_vals = Vec::new();
100                for cat in &categories {
101                    let val = series_data
102                        .map(|sd| {
103                            (0..sd.num_rows())
104                                .find_map(|i| {
105                                    if sd.get_string(i, &category_field).as_deref() == Some(cat.as_str()) {
106                                        sd.get_f64(i, &value_field)
107                                    } else {
108                                        None
109                                    }
110                                })
111                                .unwrap_or(0.0)
112                        })
113                        .unwrap_or(0.0);
114                    series_vals.push(val);
115                }
116                values_matrix.push(series_vals);
117            }
118
119            let stack = if is_normalized {
120                StackLayout::new().offset(StackOffset::Normalize)
121            } else {
122                StackLayout::new()
123            };
124            let stacked_points = stack.layout(&categories, &series_names, &values_matrix);
125
126            // For normalized mode, the stack values are 0-1; Y-axis domain is 0-1
127            // with ".0%" format (NumberFormatter multiplies by 100 and appends %).
128            // For regular stacked mode, use the raw stacked max with nice rounding.
129            let (value_min, value_max, y_axis_fmt): (f64, f64, Option<&str>) = if is_normalized {
130                (0.0, 1.0, Some(".0%"))
131            } else {
132                let raw_value_max = stacked_points
133                    .iter()
134                    .map(|p| p.y1)
135                    .fold(0.0_f64, f64::max);
136                let raw_value_max = if raw_value_max <= 0.0 { 1.0 } else { raw_value_max };
137                // Match JS: yLeft.nice() uses default count=10 for domain rounding.
138                let (_, nice_max) = crate::helpers::nice_domain(0.0, raw_value_max, 5);
139                (0.0, nice_max, y_fmt_ref)
140            };
141            let linear = ScaleLinear::new((value_min, value_max), (inner_height, 0.0));
142            y_domain_min = value_min;
143            y_domain_max = value_max;
144
145            // Group stacked points by series
146            for (series_idx, series_name) in series_names.iter().enumerate() {
147                let mut series_points: Vec<(f64, f64, f64)> = Vec::new();
148                let mut dot_data: Vec<(String, f64, f64)> = Vec::new(); // (category, y1_value, y1_pixel)
149
150                for cat in &categories {
151                    let point = match stacked_points.iter().find(|p| {
152                        p.key == *cat && p.series == *series_name
153                    }) {
154                        Some(p) => p,
155                        None => continue,
156                    };
157                    let x = match band.map(cat) {
158                        Some(x) => x + bandwidth / 2.0,
159                        None => continue,
160                    };
161                    let y0 = linear.map(point.y0);
162                    let y1 = linear.map(point.y1);
163                    series_points.push((x, y0, y1));
164                    // The individual series value is y1 - y0 (unstacked)
165                    let series_val = point.y1 - point.y0;
166                    dot_data.push((cat.clone(), series_val, y1));
167                }
168
169                if series_points.is_empty() {
170                    continue;
171                }
172
173                let path_d = area_gen.generate(&series_points);
174                let color = config
175                    .colors
176                    .get(series_idx)
177                    .cloned()
178                    .unwrap_or_else(|| "#2E7D9A".to_string());
179
180                area_elements.push(ChartElement::Path {
181                    d: path_d,
182                    fill: Some(color.clone()),
183                    stroke: None,
184                    stroke_width: None,
185                    stroke_dasharray: None,
186                    opacity: Some(0.6),
187                    class: "area".to_string(),
188                    data: Some(ElementData::new(series_name, "").with_series(series_name)),
189                });
190
191                // Stroke line along the top edge of the area
192                let line_d = area_gen.generate_line(&series_points);
193                area_elements.push(ChartElement::Path {
194                    d: line_d,
195                    fill: None,
196                    stroke: Some(color.clone()),
197                    stroke_width: Some(2.0),
198                    stroke_dasharray: None,
199                    opacity: None,
200                    class: "line".to_string(),
201                    data: Some(ElementData::new(series_name, "").with_series(series_name)),
202                });
203
204                // Area charts do not show dots by default (matches JS reference behaviour)
205            }
206
207            // Axes
208            let bottom_axis_label = config.visualize.axes.as_ref()
209                .and_then(|a| a.x.as_ref())
210                .and_then(|a| a.label.as_deref());
211            let x_axis_result =
212                generate_x_axis(&crate::helpers::XAxisParams {
213                    labels: &categories,
214                    display_label_overrides: None,
215                    range: (0.0, inner_width),
216                    y_position: margins.top + inner_height,
217                    available_width: inner_width,
218                    x_format: x_format.as_deref(),
219                    chart_height: Some(inner_height),
220                    grid: &grid,
221                    axis_label: bottom_axis_label,
222                });
223            let y_axis_elements =
224                generate_y_axis_numeric(&crate::helpers::YAxisNumericParams {
225                    domain: (value_min, value_max),
226                    range: (inner_height, 0.0),
227                    x_position: margins.left,
228                    fmt: y_axis_fmt,
229                    tick_count: 5,
230                    chart_width: Some(inner_width),
231                    grid: &grid,
232                    axis_label: left_axis_label,
233                });
234
235            children.push(ChartElement::Group {
236                class: "axes".to_string(),
237                transform: None,
238                children: {
239                    let mut axes = Vec::new();
240                    axes.extend(
241                        x_axis_result.elements
242                            .into_iter()
243                            .map(|e| offset_element(e, margins.left, 0.0)),
244                    );
245                    axes.extend(
246                        y_axis_elements
247                            .into_iter()
248                            .map(|e| offset_element(e, 0.0, margins.top)),
249                    );
250                    axes
251                },
252            });
253        } else {
254            // Multiple series, non-stacked: each area from baseline
255            let values: Vec<f64> = (0..data.num_rows())
256                .filter_map(|i| data.get_f64(i, &value_field))
257                .collect();
258            let raw_value_min = values.iter().cloned().fold(f64::INFINITY, f64::min);
259            let raw_value_max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
260            let raw_value_max = if raw_value_max <= 0.0 { 1.0 } else { raw_value_max };
261            // When data has negative values, use the actual minimum; otherwise anchor at 0
262            let domain_min = if raw_value_min < 0.0 { raw_value_min } else { 0.0 };
263            let (value_min, value_max) = crate::helpers::nice_domain(domain_min, raw_value_max, 5);
264            let linear = ScaleLinear::new((value_min, value_max), (inner_height, 0.0));
265            y_domain_min = value_min;
266            y_domain_max = value_max;
267            let baseline = linear.map(0.0);
268
269            for (series_idx, series_name) in series_names.iter().enumerate() {
270                let series_data = match groups.get(series_name) {
271                    Some(d) => d,
272                    None => continue,
273                };
274
275                let mut points: Vec<(f64, f64, f64)> = Vec::new();
276                let mut dot_data: Vec<(String, f64)> = Vec::new();
277
278                for cat in &categories {
279                    let row_i = match (0..series_data.num_rows()).find(|&i| {
280                        series_data.get_string(i, &category_field).as_deref() == Some(cat.as_str())
281                    }) {
282                        Some(i) => i,
283                        None => continue,
284                    };
285                    let val = match series_data.get_f64(row_i, &value_field) {
286                        Some(v) => v,
287                        None => continue,
288                    };
289                    let x = match band.map(cat) {
290                        Some(x) => x + bandwidth / 2.0,
291                        None => continue,
292                    };
293                    let y = linear.map(val);
294                    points.push((x, baseline, y));
295                    dot_data.push((cat.clone(), val));
296                }
297
298                if points.is_empty() {
299                    continue;
300                }
301
302                let path_d = area_gen.generate(&points);
303                let color = config
304                    .colors
305                    .get(series_idx)
306                    .cloned()
307                    .unwrap_or_else(|| "#2E7D9A".to_string());
308
309                area_elements.push(ChartElement::Path {
310                    d: path_d,
311                    fill: Some(color.clone()),
312                    stroke: None,
313                    stroke_width: None,
314                    stroke_dasharray: None,
315                    opacity: Some(0.6),
316                    class: "area".to_string(),
317                    data: Some(ElementData::new(series_name, "").with_series(series_name)),
318                });
319
320                // Stroke line along the top edge of the area
321                let line_d = area_gen.generate_line(&points);
322                area_elements.push(ChartElement::Path {
323                    d: line_d,
324                    fill: None,
325                    stroke: Some(color.clone()),
326                    stroke_width: Some(2.0),
327                    stroke_dasharray: None,
328                    opacity: None,
329                    class: "line".to_string(),
330                    data: Some(ElementData::new(series_name, "").with_series(series_name)),
331                });
332
333            }
334
335            // Axes
336            let bottom_axis_label = config.visualize.axes.as_ref()
337                .and_then(|a| a.x.as_ref())
338                .and_then(|a| a.label.as_deref());
339            let x_axis_result =
340                generate_x_axis(&crate::helpers::XAxisParams {
341                    labels: &categories,
342                    display_label_overrides: None,
343                    range: (0.0, inner_width),
344                    y_position: margins.top + inner_height,
345                    available_width: inner_width,
346                    x_format: x_format.as_deref(),
347                    chart_height: Some(inner_height),
348                    grid: &grid,
349                    axis_label: bottom_axis_label,
350                });
351            let y_axis_elements =
352                generate_y_axis_numeric(&crate::helpers::YAxisNumericParams {
353                    domain: (value_min, value_max),
354                    range: (inner_height, 0.0),
355                    x_position: margins.left,
356                    fmt: None,
357                    tick_count: 5,
358                    chart_width: Some(inner_width),
359                    grid: &grid,
360                    axis_label: left_axis_label,
361                });
362
363            children.push(ChartElement::Group {
364                class: "axes".to_string(),
365                transform: None,
366                children: {
367                    let mut axes = Vec::new();
368                    axes.extend(
369                        x_axis_result.elements
370                            .into_iter()
371                            .map(|e| offset_element(e, margins.left, 0.0)),
372                    );
373                    axes.extend(
374                        y_axis_elements
375                            .into_iter()
376                            .map(|e| offset_element(e, 0.0, margins.top)),
377                    );
378                    axes
379                },
380            });
381        }
382
383        // Legend
384        let series_names_for_legend = data.unique_values(color_f);
385        let legend_config = LegendConfig::default();
386        let legend_layout = calculate_legend_layout(&series_names_for_legend, &config.colors, config.width, &legend_config);
387        let legend_y = config.height - legend_layout.total_height - 8.0;
388        let legend_elements = generate_legend(
389            &series_names_for_legend,
390            &config.colors,
391            config.width,
392            legend_y,
393        );
394        children.push(ChartElement::Group {
395            class: "legend".to_string(),
396            transform: None,
397            children: legend_elements,
398        });
399    } else {
400        // Single series area
401        let values: Vec<f64> = (0..data.num_rows())
402            .filter_map(|i| data.get_f64(i, &value_field))
403            .collect();
404        let raw_value_min = values.iter().cloned().fold(f64::INFINITY, f64::min);
405        let raw_value_max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
406        let raw_value_max = if raw_value_max <= 0.0 { 1.0 } else { raw_value_max };
407        // When data has negative values, use the actual minimum; otherwise anchor at 0
408        let domain_min = if raw_value_min < 0.0 { raw_value_min } else { 0.0 };
409        let (value_min, value_max) = crate::helpers::nice_domain(domain_min, raw_value_max, 5);
410        let linear = ScaleLinear::new((value_min, value_max), (inner_height, 0.0));
411        y_domain_min = value_min;
412        y_domain_max = value_max;
413        let baseline = linear.map(0.0);
414
415        let mut points: Vec<(f64, f64, f64)> = Vec::new();
416
417        for cat in &categories {
418            let row_i = match (0..data.num_rows()).find(|&i| {
419                data.get_string(i, &category_field).as_deref() == Some(cat.as_str())
420            }) {
421                Some(i) => i,
422                None => continue,
423            };
424            let val = match data.get_f64(row_i, &value_field) {
425                Some(v) => v,
426                None => continue,
427            };
428            let x = match band.map(cat) {
429                Some(x) => x + bandwidth / 2.0,
430                None => continue,
431            };
432            let y = linear.map(val);
433            points.push((x, baseline, y));
434        }
435
436        if !points.is_empty() {
437            let path_d = area_gen.generate(&points);
438            let color = config
439                .colors
440                .first()
441                .cloned()
442                .unwrap_or_else(|| "#2E7D9A".to_string());
443
444            area_elements.push(ChartElement::Path {
445                d: path_d,
446                fill: Some(color.clone()),
447                stroke: None,
448                stroke_width: None,
449                stroke_dasharray: None,
450                opacity: Some(0.6),
451                class: "area".to_string(),
452                data: None,
453            });
454
455            // Stroke line along the top edge of the area
456            let line_d = area_gen.generate_line(&points);
457            area_elements.push(ChartElement::Path {
458                d: line_d,
459                fill: None,
460                stroke: Some(color.clone()),
461                stroke_width: Some(2.0),
462                stroke_dasharray: None,
463                opacity: None,
464                class: "line".to_string(),
465                data: None,
466            });
467
468            // Area charts do not show dots by default (matches JS reference behaviour)
469        }
470
471        // Axes
472        let bottom_axis_label = config.visualize.axes.as_ref()
473            .and_then(|a| a.x.as_ref())
474            .and_then(|a| a.label.as_deref());
475        let x_axis_result =
476            generate_x_axis(&crate::helpers::XAxisParams {
477                    labels: &categories,
478                    display_label_overrides: None,
479                    range: (0.0, inner_width),
480                    y_position: margins.top + inner_height,
481                    available_width: inner_width,
482                    x_format: x_format.as_deref(),
483                    chart_height: Some(inner_height),
484                    grid: &grid,
485                    axis_label: bottom_axis_label,
486                });
487        let y_axis_elements =
488            generate_y_axis_numeric(&crate::helpers::YAxisNumericParams {
489                domain: (value_min, value_max),
490                range: (inner_height, 0.0),
491                x_position: margins.left,
492                fmt: None,
493                tick_count: 5,
494                chart_width: Some(inner_width),
495                grid: &grid,
496                axis_label: left_axis_label,
497            });
498
499        children.push(ChartElement::Group {
500            class: "axes".to_string(),
501            transform: None,
502            children: {
503                let mut axes = Vec::new();
504                axes.extend(
505                    x_axis_result.elements
506                        .into_iter()
507                        .map(|e| offset_element(e, margins.left, 0.0)),
508                );
509                axes.extend(
510                    y_axis_elements
511                        .into_iter()
512                        .map(|e| offset_element(e, 0.0, margins.top)),
513                );
514                axes
515            },
516        });
517    }
518
519    children.push(ChartElement::Group {
520        class: "areas".to_string(),
521        transform: Some(Transform::Translate(margins.left, margins.top)),
522        children: area_elements,
523    });
524
525    // Annotations — rendered on top of marks, in inner coordinate space
526    if let Some(annotations) = config.visualize.annotations.as_deref() {
527        if !annotations.is_empty() {
528            use chartml_core::scales::ScaleLinear;
529            let ann_scale = ScaleLinear::new((y_domain_min, y_domain_max), (inner_height, 0.0));
530            let ann_elements = generate_annotations(
531                annotations,
532                &ann_scale,
533                0.0,
534                inner_width,
535                inner_height,
536                Some(&categories),
537            );
538            if !ann_elements.is_empty() {
539                children.push(ChartElement::Group {
540                    class: "annotations".to_string(),
541                    transform: Some(Transform::Translate(margins.left, margins.top)),
542                    children: ann_elements,
543                });
544            }
545        }
546    }
547
548    Ok(ChartElement::Svg {
549        viewbox: ViewBox::new(0.0, 0.0, config.width, config.height),
550        width: Some(config.width),
551        height: Some(config.height),
552        class: "chartml-area".to_string(),
553        children,
554    })
555}
556