use leptos::callback::Callback;
use leptos::prelude::*;
use orbital_data::{ChartFieldBinding, Dataset};
use orbital_macros::component_doc;
use crate::context::ChartKind;
use crate::shared::{ChartContainer, ResponsiveChartContainer, SparklinePlot};
use crate::{
AxisDef, AxisPosition, ChartItemId, ChartMotion, CurveType, FadeMode, HighlightMode,
HighlightScope, PlotInset, ScaleType, SeriesDef, SparklinePlotType, TooltipConfig,
};
pub fn sparkline_series_from_data(data: &[f64]) -> (Vec<SeriesDef>, Vec<AxisDef>, Vec<AxisDef>) {
let categories: Vec<String> = (0..data.len()).map(|i| i.to_string()).collect();
let series = vec![SeriesDef {
id: "sparkline".into(),
data: Some(data.to_vec()),
..Default::default()
}];
let (min, max) = data
.iter()
.copied()
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| {
(lo.min(v), hi.max(v))
});
let pad = if min.is_finite() && max.is_finite() {
((max - min).abs() * 0.12).max(0.5)
} else {
1.0
};
let y_min = if min.is_finite() { min - pad } else { 0.0 };
let y_max = if max.is_finite() { max + pad } else { 1.0 };
let x_axis = vec![AxisDef {
id: "x".into(),
scale_type: ScaleType::Point,
data: Some(categories),
position: AxisPosition::Bottom,
category_gap_ratio: Some(0.0),
..Default::default()
}];
let y_axis = vec![AxisDef {
id: "y".into(),
scale_type: ScaleType::Linear,
position: AxisPosition::Left,
min: Some(y_min),
max: Some(y_max),
domain_limit: Some(crate::DomainLimit::Strict),
..Default::default()
}];
(series, x_axis, y_axis)
}
#[component_doc(
category = "Charts",
preview_slug = "sparkline",
preview_label = "Sparkline",
preview_icon = icondata::AiStockOutlined,
)]
#[component]
pub fn Sparkline(
#[prop(optional)]
data: Option<Vec<f64>>,
#[prop(optional)]
dataset: Option<Dataset>,
#[prop(optional, into)]
y_field: Option<String>,
#[prop(default = SparklinePlotType::Line)]
plot_type: SparklinePlotType,
#[prop(default = false)]
area: bool,
#[prop(default = CurveType::Linear)]
curve: CurveType,
#[prop(optional)]
x_axis: Option<AxisDef>,
#[prop(optional)]
y_axis: Option<AxisDef>,
#[prop(optional)]
width: Option<f64>,
#[prop(optional)]
height: Option<f64>,
#[prop(optional)]
margin: Option<PlotInset>,
#[prop(optional)]
skip_animation: Option<bool>,
#[prop(optional)]
motion: Option<ChartMotion>,
#[prop(optional, into)]
color: Option<String>,
#[prop(optional, into)]
class: MaybeProp<String>,
#[prop(default = false)]
show_tooltip: bool,
#[prop(default = false)]
show_highlight: bool,
#[prop(default = None)]
highlighted_item: Option<RwSignal<Option<ChartItemId>>>,
#[prop(default = None)]
on_highlight_change: Option<Callback<(Option<ChartItemId>,), ()>>,
) -> impl IntoView {
let binding = y_field.map(|f| ChartFieldBinding {
y_fields: vec![f],
..Default::default()
});
let (series, x_axes, y_axes) = if let Some(values) = data {
let (s, mut x, mut y) = sparkline_series_from_data(&values);
if let Some(user_x) = x_axis {
x[0] = user_x;
}
if let Some(user_y) = y_axis {
if user_y.min.is_some() {
y[0].min = user_y.min;
}
if user_y.max.is_some() {
y[0].max = user_y.max;
}
if user_y.domain_limit.is_some() {
y[0].domain_limit = user_y.domain_limit;
}
}
(Some(s), Some(x), Some(y))
} else {
(None, x_axis.map(|a| vec![a]), y_axis.map(|a| vec![a]))
};
let inset = margin.unwrap_or_else(|| PlotInset::uniform(5.0));
let h = height.unwrap_or(28.0);
let tooltip = show_tooltip.then(TooltipConfig::item);
let highlight_scope = match (show_highlight, show_tooltip) {
(true, _) => Some(HighlightScope {
highlight: HighlightMode::Item,
fade: FadeMode::Global,
}),
(false, true) => Some(HighlightScope {
highlight: HighlightMode::Item,
fade: FadeMode::None,
}),
_ => None,
};
if width.is_some() {
let w = width.unwrap();
view! {
<ChartContainer
class=class
dataset=dataset
binding=binding
series=series
x_axis=x_axes
y_axis=y_axes
width=Some(w)
height=Some(h)
margin=Some(inset)
skip_animation=skip_animation
motion=motion
chart_kind=ChartKind::Sparkline
tooltip=tooltip
highlight_scope=highlight_scope
highlighted_item=highlighted_item
on_highlight_change=on_highlight_change
>
<SparklinePlot plot_type=plot_type area=area curve=curve color=color />
</ChartContainer>
}
.into_any()
} else {
view! {
<ResponsiveChartContainer
class=class
dataset=dataset
binding=binding
series=series
x_axis=x_axes
y_axis=y_axes
height=Some(h)
margin=Some(inset)
skip_animation=skip_animation
motion=motion
chart_kind=ChartKind::Sparkline
tooltip=tooltip
highlight_scope=highlight_scope
highlighted_item=highlighted_item
on_highlight_change=on_highlight_change
>
<SparklinePlot plot_type=plot_type area=area curve=curve color=color />
</ResponsiveChartContainer>
}
.into_any()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::ChartScale;
use crate::engine::{build_line_path, BandScale, LinearScale, PlotPoint};
use crate::CurveType;
#[test]
fn sample_data_line_path_is_non_empty_and_in_plot_bounds() {
let data = vec![3.0, 5.0, 2.0, 8.0, 6.0, 9.0, 7.0, 11.0];
let (_, x_axes, y_axes) = sparkline_series_from_data(&data);
let plot_w = 470.0;
let plot_h = 74.0;
let x_scale = ChartScale::Band(BandScale::new(
x_axes[0].data.clone().unwrap_or_default(),
(0.0, plot_w),
0.0,
));
let y_axis = &y_axes[0];
let y_min = y_axis.min.unwrap();
let y_max = y_axis.max.unwrap();
let y_scale = ChartScale::Linear(LinearScale::new((y_min, y_max), (plot_h, 0.0)));
let points: Vec<PlotPoint> = data
.iter()
.enumerate()
.map(|(i, v)| {
let x = match &x_scale {
ChartScale::Band(b) => b.scale(&i.to_string()).unwrap_or(0.0),
_ => 0.0,
};
let y = match &y_scale {
ChartScale::Linear(l) => Some(l.scale(*v)),
_ => None,
};
PlotPoint { x, y }
})
.collect();
let path = build_line_path(&points, CurveType::Linear, false);
assert!(!path.d.is_empty(), "expected line path, got empty d");
for (_, y) in path.markers {
assert!(
y >= 0.0 && y <= plot_h,
"y={y} outside plot height {plot_h}"
);
}
}
}