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