Skip to main content

orbital_charts/context/
chart_context.rs

1//! Chart context provider and consumption hooks.
2
3use std::collections::HashMap;
4
5use leptos::callback::Callback;
6use leptos::prelude::*;
7use orbital_data::{ChartFieldBinding, Dataset};
8use orbital_motion::use_reduced_motion;
9use orbital_theme::use_theme_options;
10
11use crate::engine::{
12    axis_categories, enabled_zoom_axes, filter_projected_by_band_window, nice_domain,
13    project_chart_data, project_pie_data, project_pie_inline, project_scatter_data,
14    project_scatter_series, resolve_linear_axis_domain, resolve_plot_inset,
15    resolve_scatter_domains, resolve_y_domain, slice_categories, window_for_axis, y_axis_ticks,
16    zoom_window_to_linear_domain, BandScale, LinearScale, ProjectedChartData, ProjectedSeries,
17};
18use crate::types::effective_skip_animation;
19use crate::{compute_drawing_area, DrawingArea};
20use crate::{
21    AxisClickData, AxisDef, AxisPosition, ChartFeatures, ChartItemId, ChartMotion,
22    ChartOrientation, ChartType, DomainLimit, GridConfig, HighlightScope, OrbitalChartPalette,
23    OrbitalChartsTheme, PlotInset, ProjectedPieData, ProjectedScatterData, ScaleType, SeriesDef,
24    ZoomConfig, ZoomFilterMode, ZoomWindow,
25};
26
27/// Chart geometry family driving projection and scale construction.
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29pub enum ChartKind {
30    #[default]
31    Cartesian,
32    Pie,
33    Scatter,
34    Sparkline,
35    Heatmap,
36}
37
38/// Scale handle returned by [`use_x_scale`] and [`use_y_scale`].
39#[derive(Clone, Debug, PartialEq)]
40pub enum ChartScale {
41    /// Categorical band scale.
42    Band(BandScale),
43    /// Continuous linear scale.
44    Linear(LinearScale),
45}
46
47impl ChartScale {
48    /// Map a numeric domain value through a linear scale.
49    pub fn scale_f64(&self, value: f64) -> f64 {
50        match self {
51            Self::Band(scale) => scale.scale(&value.to_string()).unwrap_or(0.0),
52            Self::Linear(scale) => scale.scale(value),
53        }
54    }
55
56    /// Map a category to its band center (band scales only).
57    pub fn scale_category(&self, category: &str) -> Option<f64> {
58        match self {
59            Self::Band(scale) => scale.scale(category),
60            Self::Linear(_) => None,
61        }
62    }
63
64    /// Invert a pixel coordinate to a domain value (linear scales only).
65    pub fn invert(&self, pixel: f64) -> Option<f64> {
66        match self {
67            Self::Linear(scale) => Some(scale.invert(pixel)),
68            Self::Band(_) => None,
69        }
70    }
71
72    /// Band width when this is a band scale.
73    pub fn bandwidth(&self) -> Option<f64> {
74        match self {
75            Self::Band(scale) => Some(scale.bandwidth()),
76            Self::Linear(_) => None,
77        }
78    }
79}
80
81/// Built scales keyed by axis id.
82#[derive(Clone, Debug, Default, PartialEq)]
83pub struct ChartScales {
84    /// X-axis scales by axis id.
85    pub x: HashMap<String, ChartScale>,
86    /// Y-axis scales by axis id.
87    pub y: HashMap<String, ChartScale>,
88}
89
90/// Precomputed tick values keyed by axis id (separate x/y maps).
91#[derive(Clone, Debug, Default, PartialEq)]
92pub struct ChartAxisTicks {
93    /// X-axis tick values per axis id.
94    pub x: HashMap<String, Vec<f64>>,
95    /// Y-axis tick values per axis id.
96    pub y: HashMap<String, Vec<f64>>,
97}
98
99/// Full chart context provided to composition children.
100#[derive(Clone, Debug)]
101pub struct ChartContext {
102    /// Series definitions passed to the container.
103    pub series: Vec<SeriesDef>,
104    /// Resolved x-axis definitions.
105    pub x_axes: Vec<AxisDef>,
106    /// Resolved y-axis definitions.
107    pub y_axes: Vec<AxisDef>,
108    /// Projected tabular/inline cartesian data.
109    pub projected: Option<ProjectedChartData>,
110    /// Projected pie slice data.
111    pub pie: Option<ProjectedPieData>,
112    /// Projected scatter point data.
113    pub scatter: Option<ProjectedScatterData>,
114    /// Chart geometry family.
115    pub chart_kind: ChartKind,
116    /// Highlight and fade scope for interaction.
117    pub highlight_scope: Option<HighlightScope>,
118    /// Plot bounds after inset.
119    pub drawing_area: DrawingArea,
120    /// Background grid configuration.
121    pub grid: Option<GridConfig>,
122    /// Resolved palette.
123    pub palette: OrbitalChartPalette,
124    /// Animation configuration.
125    pub motion: ChartMotion,
126    /// Whether animations are skipped.
127    pub skip_animation: bool,
128    /// Chart width in pixels.
129    pub width: f64,
130    /// Chart height in pixels.
131    pub height: f64,
132    /// Plot inset applied to the chart.
133    pub margin: PlotInset,
134    /// Y-axis value domain (raw min/max before nice padding).
135    pub y_domain: (f64, f64),
136    /// Whether the chart has no displayable data.
137    pub is_empty: bool,
138    /// Whether the chart is loading.
139    pub loading: bool,
140    /// Projection error message, if any.
141    pub projection_error: Option<String>,
142    /// Bar/line layout orientation.
143    pub orientation: ChartOrientation,
144    /// Item click callback.
145    pub on_item_click: Option<Callback<(ChartItemId,), ()>>,
146    /// Axis click callback.
147    pub on_axis_click: Option<Callback<(AxisClickData,), ()>>,
148    /// Active capability flags.
149    pub features: ChartFeatures,
150    /// Whether keyboard navigation between marks is enabled.
151    pub keyboard_navigation: bool,
152    /// Chart theme extension (tooltip delay, fade opacity, etc.).
153    pub charts_theme: OrbitalChartsTheme,
154    /// Current zoom windows per axis.
155    pub zoom_windows: Vec<ZoomWindow>,
156    /// Full category counts before zoom slicing (for pointer math).
157    pub zoom_full_category_counts: HashMap<String, usize>,
158}
159
160/// Resolved chart state including built scales.
161#[derive(Clone, Debug)]
162pub struct ChartState {
163    /// Shared chart context.
164    pub context: ChartContext,
165    /// Built axis scales.
166    pub scales: ChartScales,
167    /// Y-axis tick values per axis id.
168    pub y_ticks: HashMap<String, Vec<f64>>,
169    /// X-axis tick values per axis id.
170    pub x_ticks: HashMap<String, Vec<f64>>,
171    /// Per-axis value domains for scatter charts.
172    pub axis_domains: HashMap<String, (f64, f64)>,
173}
174
175/// Build chart state from container props.
176pub fn build_chart_state(
177    dataset: Option<&Dataset>,
178    binding: Option<&ChartFieldBinding>,
179    series: Option<&[SeriesDef]>,
180    x_axis: Option<&[AxisDef]>,
181    y_axis: Option<&[AxisDef]>,
182    width: f64,
183    height: f64,
184    margin: PlotInset,
185    grid: Option<GridConfig>,
186    palette: Option<&OrbitalChartPalette>,
187    skip_animation: Option<bool>,
188    motion: Option<&ChartMotion>,
189    prefers_reduced_motion: bool,
190    loading: bool,
191    orientation: ChartOrientation,
192    chart_kind: ChartKind,
193    highlight_scope: Option<HighlightScope>,
194    on_item_click: Option<Callback<(ChartItemId,), ()>>,
195    on_axis_click: Option<Callback<(AxisClickData,), ()>>,
196    features: ChartFeatures,
197    keyboard_navigation: bool,
198    charts_theme: OrbitalChartsTheme,
199    prefer_line_x_strict: bool,
200    zoom_windows: &[ZoomWindow],
201) -> ChartState {
202    let skip = effective_skip_animation(skip_animation, motion, prefers_reduced_motion);
203    let motion = motion.cloned().unwrap_or_default();
204    let palette = match palette {
205        Some(p) if !p.colors.is_empty() => p.clone(),
206        _ => OrbitalChartPalette::from_theme(None),
207    };
208
209    let (mut projected, pie, scatter, projection_error, is_empty) =
210        resolve_all_projected(chart_kind, dataset, binding, series, x_axis, y_axis);
211
212    let resolved_series =
213        resolve_series_defs(series, projected.as_ref(), pie.as_ref(), scatter.as_ref());
214
215    let (x_axes, y_axes) = resolve_axes(
216        chart_kind,
217        x_axis,
218        y_axis,
219        &resolved_series,
220        prefer_line_x_strict,
221        projected.as_ref(),
222        scatter.as_ref(),
223        orientation,
224    );
225
226    let mut x_axes = x_axes;
227    let mut y_axes = y_axes;
228
229    let full_y_domain = projected
230        .as_ref()
231        .map(|p| resolve_y_domain(p, &resolved_series))
232        .unwrap_or((0.0, 1.0));
233
234    let mut y_domain = full_y_domain;
235
236    let (x_domains, y_domains) = scatter
237        .as_ref()
238        .map(|s| resolve_scatter_domains(s, &x_axes, &y_axes))
239        .unwrap_or_default();
240
241    let mut axis_domains = HashMap::new();
242    axis_domains.extend(x_domains.clone());
243    axis_domains.extend(y_domains.clone());
244
245    if axis_domains.is_empty() {
246        for axis in &y_axes {
247            if matches!(
248                axis.scale_type,
249                ScaleType::Linear | ScaleType::Log | ScaleType::Sqrt
250            ) {
251                axis_domains.insert(axis.id.clone(), resolve_linear_axis_domain(axis, y_domain));
252            }
253        }
254        for axis in &x_axes {
255            if axis.scale_type == ScaleType::Linear {
256                axis_domains.insert(axis.id.clone(), resolve_linear_axis_domain(axis, y_domain));
257            }
258        }
259    }
260
261    let (zoom_full_category_counts, active_zoom_windows) = apply_cartesian_zoom(
262        &mut x_axes,
263        &mut y_axes,
264        &mut projected,
265        &mut axis_domains,
266        &mut y_domain,
267        full_y_domain,
268        &resolved_series,
269        zoom_windows,
270        features,
271        chart_kind,
272    );
273
274    let y_domain = match chart_kind {
275        ChartKind::Scatter => scatter
276            .as_ref()
277            .and_then(|s| s.series.first())
278            .map(|_| (0.0, 1.0))
279            .unwrap_or((0.0, 1.0)),
280        _ => y_domain,
281    };
282
283    let mut y_ticks = HashMap::new();
284    for axis in &y_axes {
285        if axis.scale_type == ScaleType::Linear {
286            let domain = y_domains.get(&axis.id).copied().unwrap_or(y_domain);
287            y_ticks.insert(axis.id.clone(), y_axis_ticks(axis, domain));
288        }
289    }
290
291    let mut x_ticks = HashMap::new();
292    for axis in &x_axes {
293        if axis.scale_type == ScaleType::Linear {
294            let domain = x_domains.get(&axis.id).copied().unwrap_or(y_domain);
295            x_ticks.insert(axis.id.clone(), y_axis_ticks(axis, domain));
296        }
297    }
298
299    let margin = resolve_plot_inset(
300        margin,
301        &x_axes,
302        &y_axes,
303        &x_ticks,
304        &y_ticks,
305        orientation,
306        chart_kind,
307    );
308    let drawing_area = compute_drawing_area(width, height, margin);
309
310    let scales = build_scales(
311        chart_kind,
312        &x_axes,
313        &y_axes,
314        &drawing_area,
315        projected.as_ref(),
316        scatter.as_ref(),
317        y_domain,
318        &axis_domains,
319    );
320
321    let context = ChartContext {
322        series: resolved_series,
323        x_axes,
324        y_axes,
325        projected,
326        pie,
327        scatter,
328        chart_kind,
329        highlight_scope,
330        drawing_area,
331        grid,
332        palette,
333        motion,
334        skip_animation: skip,
335        width,
336        height,
337        margin,
338        y_domain,
339        is_empty,
340        loading,
341        projection_error,
342        orientation,
343        on_item_click,
344        on_axis_click,
345        features,
346        keyboard_navigation,
347        charts_theme,
348        zoom_windows: active_zoom_windows,
349        zoom_full_category_counts,
350    };
351
352    ChartState {
353        context,
354        scales,
355        y_ticks,
356        x_ticks,
357        axis_domains,
358    }
359}
360
361fn resolve_all_projected(
362    chart_kind: ChartKind,
363    dataset: Option<&Dataset>,
364    binding: Option<&ChartFieldBinding>,
365    series: Option<&[SeriesDef]>,
366    x_axis: Option<&[AxisDef]>,
367    y_axis: Option<&[AxisDef]>,
368) -> (
369    Option<ProjectedChartData>,
370    Option<ProjectedPieData>,
371    Option<ProjectedScatterData>,
372    Option<String>,
373    bool,
374) {
375    match chart_kind {
376        ChartKind::Pie => {
377            if let (Some(ds), Some(b)) = (dataset, binding) {
378                return match project_pie_data(ds, b) {
379                    Ok(pie) => {
380                        let empty = pie.slices.is_empty();
381                        (None, Some(pie), None, None, empty)
382                    }
383                    Err(err) => (None, None, None, Some(err.to_string()), true),
384                };
385            }
386            if let Some(series_list) = series {
387                if let Some(first) = series_list.first() {
388                    if let Some(data) = &first.data {
389                        let categories = x_axis
390                            .and_then(|axes| axes.first())
391                            .and_then(|a| a.data.clone())
392                            .unwrap_or_default();
393                        if !categories.is_empty() {
394                            return match project_pie_inline(&categories, data, &first.id) {
395                                Ok(pie) => (None, Some(pie), None, None, false),
396                                Err(err) => (None, None, None, Some(err.to_string()), true),
397                            };
398                        }
399                    }
400                }
401            }
402            let empty = dataset.is_none() && series.is_none();
403            (None, None, None, None, empty)
404        }
405        ChartKind::Scatter => {
406            if let (Some(ds), Some(b)) = (dataset, binding) {
407                return match project_scatter_data(ds, b) {
408                    Ok(scatter) => {
409                        let empty = scatter.series.is_empty()
410                            || scatter.series.iter().all(|s| s.points.is_empty());
411                        (None, None, Some(scatter), None, empty)
412                    }
413                    Err(err) => (None, None, None, Some(err.to_string()), true),
414                };
415            }
416            if let Some(series_list) = series {
417                return match project_scatter_series(series_list) {
418                    Ok(scatter) => {
419                        let empty = scatter.series.iter().all(|s| s.points.is_empty());
420                        (None, None, Some(scatter), None, empty)
421                    }
422                    Err(err) => (None, None, None, Some(err.to_string()), true),
423                };
424            }
425            let empty = dataset.is_none() && series.is_none();
426            (None, None, None, None, empty)
427        }
428        ChartKind::Cartesian | ChartKind::Sparkline => {
429            let (projected, err, empty) =
430                resolve_projected(dataset, binding, series, x_axis, y_axis);
431            (projected, None, None, err, empty)
432        }
433        ChartKind::Heatmap => (None, None, None, None, false),
434    }
435}
436
437fn band_axis_categories(x_axis: Option<&[AxisDef]>, y_axis: Option<&[AxisDef]>) -> Vec<String> {
438    let from_axes = |axes: &[AxisDef]| {
439        axes.iter()
440            .find(|a| matches!(a.scale_type, ScaleType::Band | ScaleType::Point))
441            .and_then(|a| a.data.clone())
442    };
443    x_axis
444        .and_then(from_axes)
445        .or_else(|| y_axis.and_then(from_axes))
446        .unwrap_or_default()
447}
448
449fn resolve_projected(
450    dataset: Option<&Dataset>,
451    binding: Option<&ChartFieldBinding>,
452    series: Option<&[SeriesDef]>,
453    x_axis: Option<&[AxisDef]>,
454    y_axis: Option<&[AxisDef]>,
455) -> (Option<ProjectedChartData>, Option<String>, bool) {
456    if let (Some(ds), Some(b)) = (dataset, binding) {
457        return match project_chart_data(ds, b) {
458            Ok(data) => {
459                let empty = data.series.is_empty() || data.categories.is_empty();
460                (Some(data), None, empty)
461            }
462            Err(err) => (None, Some(err.to_string()), true),
463        };
464    }
465
466    if let Some(series_list) = series {
467        let categories = band_axis_categories(x_axis, y_axis);
468        if !categories.is_empty() && !series_list.is_empty() {
469            let projected_series: Vec<ProjectedSeries> = series_list
470                .iter()
471                .filter_map(|s| {
472                    let data = s.data.clone()?;
473                    Some(ProjectedSeries {
474                        id: s.id.clone(),
475                        label: s.label.clone().unwrap_or_else(|| s.id.clone()),
476                        data,
477                    })
478                })
479                .collect();
480            if !projected_series.is_empty() {
481                let empty = false;
482                return (
483                    Some(ProjectedChartData {
484                        categories,
485                        series: projected_series,
486                    }),
487                    None,
488                    empty,
489                );
490            }
491        }
492    }
493
494    let empty = dataset.is_none() && binding.is_none() && series.is_none();
495    (None, None, empty)
496}
497
498fn resolve_series_defs(
499    series: Option<&[SeriesDef]>,
500    projected: Option<&ProjectedChartData>,
501    pie: Option<&ProjectedPieData>,
502    scatter: Option<&ProjectedScatterData>,
503) -> Vec<SeriesDef> {
504    if let Some(list) = series {
505        if !list.is_empty() {
506            return list.to_vec();
507        }
508    }
509    if let Some(p) = pie {
510        return vec![SeriesDef {
511            id: p.series_id.clone(),
512            label: Some(p.series_id.clone()),
513            chart_type: Some(ChartType::Pie),
514            ..Default::default()
515        }];
516    }
517    if let Some(s) = scatter {
518        return s
519            .series
520            .iter()
521            .map(|ser| SeriesDef {
522                id: ser.series_id.clone(),
523                label: Some(ser.label.clone()),
524                chart_type: Some(ChartType::Scatter),
525                color: ser.color.clone(),
526                marker_size: Some(ser.marker_size),
527                x_axis_id: Some(ser.x_axis_id.clone()),
528                y_axis_id: Some(ser.y_axis_id.clone()),
529                ..Default::default()
530            })
531            .collect();
532    }
533    projected
534        .map(|p| {
535            p.series
536                .iter()
537                .map(|s| SeriesDef {
538                    id: s.id.clone(),
539                    label: Some(s.label.clone()),
540                    ..Default::default()
541                })
542                .collect()
543        })
544        .unwrap_or_default()
545}
546
547fn is_line_cartesian(series: &[SeriesDef], prefer_line_x_strict: bool) -> bool {
548    if series.is_empty() {
549        return prefer_line_x_strict;
550    }
551    series.iter().all(|s| match s.chart_type {
552        None => prefer_line_x_strict,
553        Some(ChartType::Line) | Some(ChartType::Area) => true,
554        _ => false,
555    })
556}
557
558fn resolve_axes(
559    chart_kind: ChartKind,
560    x_axis: Option<&[AxisDef]>,
561    y_axis: Option<&[AxisDef]>,
562    series: &[SeriesDef],
563    prefer_line_x_strict: bool,
564    projected: Option<&ProjectedChartData>,
565    _scatter: Option<&ProjectedScatterData>,
566    orientation: ChartOrientation,
567) -> (Vec<AxisDef>, Vec<AxisDef>) {
568    if chart_kind == ChartKind::Pie {
569        return (Vec::new(), Vec::new());
570    }
571
572    if let (Some(x), Some(y)) = (x_axis.as_ref(), y_axis.as_ref()) {
573        return (x.to_vec(), y.to_vec());
574    }
575
576    if chart_kind == ChartKind::Scatter {
577        let (inferred_x, inferred_y) = (
578            vec![AxisDef {
579                id: "x".into(),
580                scale_type: ScaleType::Linear,
581                position: AxisPosition::Bottom,
582                domain_limit: Some(DomainLimit::Nice),
583                ..Default::default()
584            }],
585            vec![AxisDef {
586                id: "y".into(),
587                scale_type: ScaleType::Linear,
588                position: AxisPosition::Left,
589                domain_limit: Some(DomainLimit::Nice),
590                ..Default::default()
591            }],
592        );
593        return (
594            x_axis.map(|a| a.to_vec()).unwrap_or(inferred_x),
595            y_axis.map(|a| a.to_vec()).unwrap_or(inferred_y),
596        );
597    }
598
599    let categories = projected.map(|p| p.categories.clone()).unwrap_or_default();
600    let line_x_strict = is_line_cartesian(series, prefer_line_x_strict);
601    let x_domain_limit = line_x_strict.then_some(DomainLimit::Strict);
602
603    let (inferred_x, inferred_y) = match orientation {
604        ChartOrientation::Vertical => (
605            vec![AxisDef {
606                id: "x".into(),
607                scale_type: ScaleType::Band,
608                data: Some(categories.clone()),
609                position: AxisPosition::Bottom,
610                domain_limit: x_domain_limit,
611                ..Default::default()
612            }],
613            vec![AxisDef {
614                id: "y".into(),
615                scale_type: ScaleType::Linear,
616                position: AxisPosition::Left,
617                domain_limit: Some(DomainLimit::Nice),
618                ..Default::default()
619            }],
620        ),
621        ChartOrientation::Horizontal => (
622            vec![AxisDef {
623                id: "x".into(),
624                scale_type: ScaleType::Linear,
625                position: AxisPosition::Bottom,
626                domain_limit: Some(if line_x_strict {
627                    DomainLimit::Strict
628                } else {
629                    DomainLimit::Nice
630                }),
631                ..Default::default()
632            }],
633            vec![AxisDef {
634                id: "y".into(),
635                scale_type: ScaleType::Band,
636                data: Some(categories),
637                position: AxisPosition::Left,
638                ..Default::default()
639            }],
640        ),
641    };
642
643    (
644        x_axis.map(|a| a.to_vec()).unwrap_or(inferred_x),
645        y_axis.map(|a| a.to_vec()).unwrap_or(inferred_y),
646    )
647}
648
649fn build_scales(
650    chart_kind: ChartKind,
651    x_axes: &[AxisDef],
652    y_axes: &[AxisDef],
653    drawing_area: &DrawingArea,
654    projected: Option<&ProjectedChartData>,
655    scatter: Option<&ProjectedScatterData>,
656    y_domain: (f64, f64),
657    axis_domains: &HashMap<String, (f64, f64)>,
658) -> ChartScales {
659    let mut x = HashMap::new();
660    for axis in x_axes {
661        let scale = match axis.scale_type {
662            ScaleType::Band | ScaleType::Point => {
663                let categories = axis_categories(axis, projected);
664                let padding = axis.category_gap_ratio.unwrap_or(0.1);
665                ChartScale::Band(BandScale::new(
666                    categories,
667                    (0.0, drawing_area.plot_width),
668                    padding,
669                ))
670            }
671            ScaleType::Linear => {
672                let domain = axis_domains.get(&axis.id).copied().unwrap_or_else(|| {
673                    let limit = axis.domain_limit.unwrap_or(DomainLimit::Nice);
674                    nice_domain(y_domain.0, y_domain.1, limit)
675                });
676                ChartScale::Linear(LinearScale::new(domain, (0.0, drawing_area.plot_width)))
677            }
678            _ => continue,
679        };
680        x.insert(axis.id.clone(), scale);
681    }
682
683    let mut y = HashMap::new();
684    for axis in y_axes {
685        let domain = axis_domains.get(&axis.id).copied().unwrap_or_else(|| {
686            let limit = axis.domain_limit.unwrap_or(DomainLimit::Nice);
687            nice_domain(y_domain.0, y_domain.1, limit)
688        });
689        let scale = match axis.scale_type {
690            ScaleType::Linear | ScaleType::Log | ScaleType::Sqrt => ChartScale::Linear(
691                LinearScale::new((domain.0, domain.1), (drawing_area.plot_height, 0.0)),
692            ),
693            ScaleType::Band | ScaleType::Point => {
694                let categories = axis_categories(axis, projected);
695                ChartScale::Band(BandScale::new(
696                    categories,
697                    (drawing_area.plot_height, 0.0),
698                    axis.category_gap_ratio.unwrap_or(0.1),
699                ))
700            }
701            _ => ChartScale::Linear(LinearScale::new(
702                (domain.0, domain.1),
703                (drawing_area.plot_height, 0.0),
704            )),
705        };
706        y.insert(axis.id.clone(), scale);
707    }
708
709    let _ = (chart_kind, scatter);
710    ChartScales { x, y }
711}
712
713/// Provide chart context and scales to child components.
714#[component]
715pub fn ChartContextProvider(state: ChartState, children: Children) -> impl IntoView {
716    provide_context(state.context.clone());
717    provide_context(state.scales.clone());
718    provide_context(ChartAxisTicks {
719        x: state.x_ticks.clone(),
720        y: state.y_ticks.clone(),
721    });
722    provide_context(state.axis_domains.clone());
723    children()
724}
725
726/// Access the full chart context.
727pub fn use_chart_context() -> ChartContext {
728    expect_context::<ChartContext>()
729}
730
731/// Access the plot drawing area bounds.
732pub fn use_drawing_area() -> DrawingArea {
733    use_chart_context().drawing_area
734}
735
736/// Access an x-axis scale by id (defaults to `"x"`).
737pub fn use_x_scale(axis_id: impl Into<String>) -> ChartScale {
738    let id = axis_id.into();
739    let scales = expect_context::<ChartScales>();
740    scales
741        .x
742        .get(&id)
743        .or_else(|| scales.x.values().next())
744        .cloned()
745        .unwrap_or_else(|| ChartScale::Linear(LinearScale::new((0.0, 1.0), (0.0, 100.0))))
746}
747
748/// Access a y-axis scale by id (defaults to `"y"`).
749pub fn use_y_scale(axis_id: impl Into<String>) -> ChartScale {
750    let id = axis_id.into();
751    let scales = expect_context::<ChartScales>();
752    scales
753        .y
754        .get(&id)
755        .or_else(|| scales.y.values().next())
756        .cloned()
757        .unwrap_or_else(|| ChartScale::Linear(LinearScale::new((0.0, 1.0), (100.0, 0.0))))
758}
759
760/// Y-axis tick values for an axis id.
761pub fn use_y_ticks(axis_id: impl Into<String>) -> Vec<f64> {
762    let id = axis_id.into();
763    expect_context::<ChartAxisTicks>()
764        .y
765        .get(&id)
766        .cloned()
767        .unwrap_or_default()
768}
769
770/// X-axis tick values for an axis id.
771pub fn use_x_ticks(axis_id: impl Into<String>) -> Vec<f64> {
772    let id = axis_id.into();
773    expect_context::<ChartAxisTicks>()
774        .x
775        .get(&id)
776        .cloned()
777        .unwrap_or_default()
778}
779
780/// Apply zoom windows to axes, projected data, and domains.
781fn apply_cartesian_zoom(
782    x_axes: &mut Vec<AxisDef>,
783    y_axes: &mut Vec<AxisDef>,
784    projected: &mut Option<ProjectedChartData>,
785    axis_domains: &mut HashMap<String, (f64, f64)>,
786    y_domain: &mut (f64, f64),
787    full_y_domain: (f64, f64),
788    series_defs: &[SeriesDef],
789    zoom_windows: &[ZoomWindow],
790    features: ChartFeatures,
791    chart_kind: ChartKind,
792) -> (HashMap<String, usize>, Vec<ZoomWindow>) {
793    if !matches!(chart_kind, ChartKind::Cartesian | ChartKind::Sparkline) {
794        return (HashMap::new(), zoom_windows.to_vec());
795    }
796
797    let enabled = enabled_zoom_axes(features, x_axes, y_axes);
798    if enabled.is_empty() {
799        return (HashMap::new(), zoom_windows.to_vec());
800    }
801
802    let mut full_counts = HashMap::new();
803    let mut active_windows = zoom_windows.to_vec();
804
805    let snapshots: Vec<(String, ScaleType, ZoomConfig, Option<Vec<String>>)> = enabled
806        .into_iter()
807        .map(|(axis, config)| (axis.id.clone(), axis.scale_type, config, axis.data.clone()))
808        .collect();
809
810    for (axis_id, scale_type, config, axis_data) in snapshots {
811        let window = window_for_axis(zoom_windows, &axis_id);
812        if window.start <= 0.0 && window.end >= 100.0 {
813            continue;
814        }
815
816        active_windows.retain(|w| w.axis_id != axis_id);
817        active_windows.push(window.clone());
818
819        match scale_type {
820            ScaleType::Band | ScaleType::Point => {
821                let full_cats = axis_data.unwrap_or_else(|| {
822                    let fallback = x_axes
823                        .iter()
824                        .find(|a| a.id == axis_id)
825                        .or_else(|| y_axes.iter().find(|a| a.id == axis_id))
826                        .cloned()
827                        .unwrap_or_default();
828                    axis_categories(&fallback, projected.as_ref())
829                });
830                full_counts.insert(axis_id.clone(), full_cats.len());
831
832                if let Some(data) = projected.as_mut() {
833                    *data = filter_projected_by_band_window(data, &window);
834                    if config.filter_mode() == ZoomFilterMode::Discard {
835                        *y_domain = resolve_y_domain(data, series_defs);
836                    } else {
837                        *y_domain = full_y_domain;
838                    }
839                }
840
841                let sliced = slice_categories(&full_cats, &window);
842                if let Some(axis_mut) = x_axes.iter_mut().find(|a| a.id == axis_id) {
843                    axis_mut.data = Some(sliced.clone());
844                }
845                if let Some(axis_mut) = y_axes.iter_mut().find(|a| a.id == axis_id) {
846                    axis_mut.data = Some(sliced);
847                }
848            }
849            ScaleType::Linear => {
850                if let Some(full) = axis_domains.get(&axis_id).copied() {
851                    let zoomed = zoom_window_to_linear_domain(full, &window);
852                    axis_domains.insert(axis_id.clone(), zoomed);
853                }
854            }
855            _ => {}
856        }
857    }
858
859    (full_counts, active_windows)
860}
861
862/// Build chart state reactively inside a component.
863pub fn use_chart_state(
864    dataset: Option<Dataset>,
865    binding: Option<ChartFieldBinding>,
866    series: Option<Vec<SeriesDef>>,
867    x_axis: Option<Vec<AxisDef>>,
868    y_axis: Option<Vec<AxisDef>>,
869    width: f64,
870    height: f64,
871    margin: PlotInset,
872    grid: Option<GridConfig>,
873    palette: Option<OrbitalChartPalette>,
874    skip_animation: Option<bool>,
875    motion: Option<ChartMotion>,
876    loading: bool,
877    orientation: ChartOrientation,
878    chart_kind: ChartKind,
879    highlight_scope: Option<HighlightScope>,
880    on_item_click: Option<Callback<(ChartItemId,), ()>>,
881    on_axis_click: Option<Callback<(AxisClickData,), ()>>,
882    features: ChartFeatures,
883    keyboard_navigation: bool,
884    charts_theme: OrbitalChartsTheme,
885    prefer_line_x_strict: bool,
886    zoom_windows: RwSignal<Vec<ZoomWindow>>,
887) -> Signal<ChartState> {
888    let prefers_reduced = use_reduced_motion();
889    let theme_options = use_theme_options();
890    Signal::derive(move || {
891        let resolved_palette = palette
892            .clone()
893            .filter(|p| !p.colors.is_empty())
894            .unwrap_or_else(|| OrbitalChartPalette::from_theme(theme_options.get().brand.as_ref()));
895        let windows = zoom_windows.get();
896        build_chart_state(
897            dataset.as_ref(),
898            binding.as_ref(),
899            series.as_deref(),
900            x_axis.as_deref(),
901            y_axis.as_deref(),
902            width,
903            height,
904            margin,
905            grid,
906            Some(&resolved_palette),
907            skip_animation,
908            motion.as_ref(),
909            prefers_reduced.get(),
910            loading,
911            orientation,
912            chart_kind,
913            highlight_scope,
914            on_item_click,
915            on_axis_click,
916            features,
917            keyboard_navigation,
918            charts_theme.clone(),
919            prefer_line_x_strict,
920            &windows,
921        )
922    })
923}
924
925#[cfg(test)]
926mod tests {
927    use super::*;
928    use orbital_data::{ChartFieldBinding, DataRecord, DataSchema, DataValue, Dataset};
929    use std::collections::HashMap;
930
931    fn fixture_dataset() -> Dataset {
932        let schema = DataSchema::from_text_fields([
933            ("quarter".into(), "Quarter".into()),
934            ("revenue".into(), "Revenue".into()),
935        ]);
936        let records = vec![DataRecord::new(
937            "1",
938            HashMap::from([
939                ("quarter".into(), DataValue::Category("Q1".into())),
940                ("revenue".into(), DataValue::Number(100.0)),
941            ]),
942        )];
943        Dataset::from_records(schema, records)
944    }
945
946    #[test]
947    fn build_chart_state_from_dataset() {
948        let dataset = fixture_dataset();
949        let binding = ChartFieldBinding::new("quarter", vec!["revenue".into()]);
950        let state = build_chart_state(
951            Some(&dataset),
952            Some(&binding),
953            None,
954            None,
955            None,
956            400.0,
957            300.0,
958            PlotInset::uniform(40.0),
959            None,
960            None,
961            None,
962            None,
963            false,
964            false,
965            ChartOrientation::Vertical,
966            ChartKind::Cartesian,
967            None,
968            None,
969            None,
970            ChartFeatures::ANIMATION,
971            true,
972            OrbitalChartsTheme::default(),
973            false,
974            &[],
975        );
976        assert!(state.context.projected.is_some());
977        assert!(state.scales.x.contains_key("x"));
978        assert!(state.scales.y.contains_key("y"));
979    }
980
981    #[test]
982    fn inferred_line_x_axis_uses_strict_domain() {
983        let series = vec![SeriesDef {
984            id: "revenue".into(),
985            data: Some(vec![10.0, 20.0, 30.0]),
986            chart_type: Some(ChartType::Line),
987            ..Default::default()
988        }];
989        let state = build_chart_state(
990            None,
991            None,
992            Some(&series),
993            None,
994            None,
995            400.0,
996            300.0,
997            PlotInset::uniform(40.0),
998            None,
999            None,
1000            None,
1001            None,
1002            false,
1003            false,
1004            ChartOrientation::Vertical,
1005            ChartKind::Cartesian,
1006            None,
1007            None,
1008            None,
1009            ChartFeatures::ANIMATION,
1010            true,
1011            OrbitalChartsTheme::default(),
1012            true,
1013            &[],
1014        );
1015        assert_eq!(
1016            state.context.x_axes[0].domain_limit,
1017            Some(DomainLimit::Strict)
1018        );
1019    }
1020
1021    #[test]
1022    fn explicit_x_axis_skips_strict_inference() {
1023        let series = vec![SeriesDef {
1024            id: "revenue".into(),
1025            data: Some(vec![10.0, 20.0, 30.0]),
1026            chart_type: Some(ChartType::Line),
1027            ..Default::default()
1028        }];
1029        let x_axis = vec![AxisDef {
1030            id: "x".into(),
1031            scale_type: ScaleType::Band,
1032            data: Some(vec!["A".into(), "B".into(), "C".into()]),
1033            ..Default::default()
1034        }];
1035        let state = build_chart_state(
1036            None,
1037            None,
1038            Some(&series),
1039            Some(&x_axis),
1040            None,
1041            400.0,
1042            300.0,
1043            PlotInset::uniform(40.0),
1044            None,
1045            None,
1046            None,
1047            None,
1048            false,
1049            false,
1050            ChartOrientation::Vertical,
1051            ChartKind::Cartesian,
1052            None,
1053            None,
1054            None,
1055            ChartFeatures::ANIMATION,
1056            true,
1057            OrbitalChartsTheme::default(),
1058            true,
1059            &[],
1060        );
1061        assert_eq!(state.context.x_axes[0].domain_limit, None);
1062    }
1063
1064    #[test]
1065    fn bar_series_skips_strict_x_domain() {
1066        let series = vec![SeriesDef {
1067            id: "revenue".into(),
1068            data: Some(vec![10.0, 20.0, 30.0]),
1069            chart_type: Some(ChartType::Bar),
1070            ..Default::default()
1071        }];
1072        let state = build_chart_state(
1073            None,
1074            None,
1075            Some(&series),
1076            None,
1077            None,
1078            400.0,
1079            300.0,
1080            PlotInset::uniform(40.0),
1081            None,
1082            None,
1083            None,
1084            None,
1085            false,
1086            false,
1087            ChartOrientation::Vertical,
1088            ChartKind::Cartesian,
1089            None,
1090            None,
1091            None,
1092            ChartFeatures::ANIMATION,
1093            true,
1094            OrbitalChartsTheme::default(),
1095            false,
1096            &[],
1097        );
1098        assert_eq!(state.context.x_axes[0].domain_limit, None);
1099    }
1100
1101    #[test]
1102    fn horizontal_inferred_line_x_axis_uses_strict_domain() {
1103        let series = vec![SeriesDef {
1104            id: "value".into(),
1105            data: Some(vec![2.0, 50.0, 92.0, 45.0]),
1106            chart_type: Some(ChartType::Line),
1107            ..Default::default()
1108        }];
1109        let y_axis = vec![AxisDef {
1110            id: "y".into(),
1111            scale_type: ScaleType::Band,
1112            data: Some(vec!["A".into(), "B".into(), "C".into(), "D".into()]),
1113            ..Default::default()
1114        }];
1115        let state = build_chart_state(
1116            None,
1117            None,
1118            Some(&series),
1119            None,
1120            Some(&y_axis),
1121            400.0,
1122            300.0,
1123            PlotInset::uniform(40.0),
1124            None,
1125            None,
1126            None,
1127            None,
1128            false,
1129            false,
1130            ChartOrientation::Horizontal,
1131            ChartKind::Cartesian,
1132            None,
1133            None,
1134            None,
1135            ChartFeatures::ANIMATION,
1136            true,
1137            OrbitalChartsTheme::default(),
1138            true,
1139            &[],
1140        );
1141        assert_eq!(
1142            state.context.x_axes[0].domain_limit,
1143            Some(DomainLimit::Strict)
1144        );
1145        assert!(!state.axis_domains.get("x").unwrap().0.is_nan());
1146    }
1147
1148    #[test]
1149    fn chart_scale_invert_linear() {
1150        let scale = ChartScale::Linear(LinearScale::new((0.0, 100.0), (200.0, 0.0)));
1151        assert_eq!(scale.scale_f64(50.0), 100.0);
1152        assert_eq!(scale.invert(100.0), Some(50.0));
1153    }
1154}