1use 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#[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#[derive(Clone, Debug, PartialEq)]
40pub enum ChartScale {
41 Band(BandScale),
43 Linear(LinearScale),
45}
46
47impl ChartScale {
48 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 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 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 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#[derive(Clone, Debug, Default, PartialEq)]
83pub struct ChartScales {
84 pub x: HashMap<String, ChartScale>,
86 pub y: HashMap<String, ChartScale>,
88}
89
90#[derive(Clone, Debug, Default, PartialEq)]
92pub struct ChartAxisTicks {
93 pub x: HashMap<String, Vec<f64>>,
95 pub y: HashMap<String, Vec<f64>>,
97}
98
99#[derive(Clone, Debug)]
101pub struct ChartContext {
102 pub series: Vec<SeriesDef>,
104 pub x_axes: Vec<AxisDef>,
106 pub y_axes: Vec<AxisDef>,
108 pub projected: Option<ProjectedChartData>,
110 pub pie: Option<ProjectedPieData>,
112 pub scatter: Option<ProjectedScatterData>,
114 pub chart_kind: ChartKind,
116 pub highlight_scope: Option<HighlightScope>,
118 pub drawing_area: DrawingArea,
120 pub grid: Option<GridConfig>,
122 pub palette: OrbitalChartPalette,
124 pub motion: ChartMotion,
126 pub skip_animation: bool,
128 pub width: f64,
130 pub height: f64,
132 pub margin: PlotInset,
134 pub y_domain: (f64, f64),
136 pub is_empty: bool,
138 pub loading: bool,
140 pub projection_error: Option<String>,
142 pub orientation: ChartOrientation,
144 pub on_item_click: Option<Callback<(ChartItemId,), ()>>,
146 pub on_axis_click: Option<Callback<(AxisClickData,), ()>>,
148 pub features: ChartFeatures,
150 pub keyboard_navigation: bool,
152 pub charts_theme: OrbitalChartsTheme,
154 pub zoom_windows: Vec<ZoomWindow>,
156 pub zoom_full_category_counts: HashMap<String, usize>,
158}
159
160#[derive(Clone, Debug)]
162pub struct ChartState {
163 pub context: ChartContext,
165 pub scales: ChartScales,
167 pub y_ticks: HashMap<String, Vec<f64>>,
169 pub x_ticks: HashMap<String, Vec<f64>>,
171 pub axis_domains: HashMap<String, (f64, f64)>,
173}
174
175pub 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#[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
726pub fn use_chart_context() -> ChartContext {
728 expect_context::<ChartContext>()
729}
730
731pub fn use_drawing_area() -> DrawingArea {
733 use_chart_context().drawing_area
734}
735
736pub 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
748pub 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
760pub 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
770pub 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
780fn 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
862pub 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}