use crate::components::layout::card_registry::get_card_transform_signal;
use crate::components::layout::draggable_card::CardId;
use crate::components::svg::axis::{Axis, AxisOrientation};
use crate::components::svg::box_violin_tooltip::{BoxGroupTooltipData, BoxViolinTooltip};
use crate::components::svg::grid::Grid;
use crate::components::svg::legend::{estimate_legend_width, Legend, LegendItem, LegendPosition};
use crate::hooks::use_container_size;
use leptos::prelude::*;
use lodviz_core::algorithms::statistics::{box_plot_stats, gaussian_kde, BoxPlotStats};
use lodviz_core::core::scale::{BandScale, LinearScale, Scale};
use lodviz_core::core::theme::{ChartConfig, ChartTheme};
#[derive(Clone, Debug)]
pub struct BoxGroup {
pub label: String,
pub data: Vec<f64>,
}
#[derive(Clone, Debug)]
struct GroupLayout {
center: f64,
band_width: f64,
stats: BoxPlotStats,
}
fn compute_groups(
groups: &[BoxGroup],
x_band: &BandScale,
y_scale: &LinearScale,
) -> Vec<(GroupLayout, Vec<(f64, f64)>)> {
groups
.iter()
.enumerate()
.filter_map(|(i, g)| {
if g.data.is_empty() {
return None;
}
let mut data_copy = g.data.clone();
let stats = box_plot_stats(&mut data_copy)?;
let center = x_band.map_index_center(i);
let bw = x_band.band_width();
let layout = GroupLayout {
center,
band_width: bw,
stats: stats.clone(),
};
let outlier_pxs: Vec<(f64, f64)> = stats
.outliers
.iter()
.map(|&v| (center, y_scale.map(v)))
.collect();
Some((layout, outlier_pxs))
})
.collect()
}
#[component]
pub fn BoxPlot(
data: Signal<Vec<BoxGroup>>,
#[prop(default = Signal::derive(|| ChartConfig::default()), into)]
config: Signal<ChartConfig>,
#[prop(optional)]
width: Option<u32>,
#[prop(optional)]
height: Option<u32>,
#[prop(optional, into)]
y_label: Option<String>,
) -> impl IntoView {
let ctx_theme = use_context::<Signal<ChartTheme>>();
let theme = Memo::new(move |_| {
config
.get()
.theme
.unwrap_or_else(|| ctx_theme.map(|s| s.get()).unwrap_or_default())
});
let (container_width, container_height, container_ref) = use_container_size();
let card_transform = use_context::<CardId>().map(|id| get_card_transform_signal(id.0.clone()));
let chart_width = Memo::new(move |_| {
let measured = container_width.get();
if measured > 0.0 {
return measured as u32;
}
config
.get()
.width
.or(width)
.or_else(|| {
card_transform.and_then(|sig| sig.get().map(|ct| (ct.width - 32.0).max(0.0) as u32))
})
.unwrap_or(800)
});
let chart_height = Memo::new(move |_| {
let measured = container_height.get();
if measured > 0.0 {
return measured as u32;
}
config
.get()
.height
.or(height)
.or_else(|| {
card_transform
.and_then(|sig| sig.get().map(|ct| (ct.height - 40.0).max(100.0) as u32))
})
.unwrap_or(400)
});
let legend_items = Signal::derive(move || {
let groups = data.get();
let th = theme.get();
groups
.iter()
.enumerate()
.map(|(i, g)| LegendItem {
name: g.label.clone(),
color: th.palette[i % th.palette.len()].clone(),
visible: true,
})
.collect::<Vec<_>>()
});
let legend_outside = Memo::new(move |_| config.get().legend_outside.unwrap_or(false));
let margin = Memo::new(move |_| {
let mut m = config.get().margin.unwrap_or_default();
if legend_outside.get() {
m.right += estimate_legend_width(&legend_items.get()) + 16.0;
}
m
});
let inner_width =
Memo::new(move |_| chart_width.get() as f64 - margin.get().left - margin.get().right);
let inner_height =
Memo::new(move |_| chart_height.get() as f64 - margin.get().top - margin.get().bottom);
let final_title = Memo::new(move |_| config.get().title.clone());
let a11y_title_id = format!("chart-title-{}", uuid::Uuid::new_v4().as_simple());
let a11y_desc_id = format!("chart-desc-{}", uuid::Uuid::new_v4().as_simple());
let a11y_labelledby = format!("{} {}", a11y_title_id, a11y_desc_id);
let y_scale = Memo::new(move |_| {
let groups = data.get();
let mut all_vals: Vec<f64> = groups.iter().flat_map(|g| g.data.iter().copied()).collect();
if all_vals.is_empty() {
all_vals = vec![0.0, 1.0];
}
all_vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let y_min = all_vals[0];
let y_max = all_vals[all_vals.len() - 1];
let pad = (y_max - y_min) * 0.1;
LinearScale::new((y_min - pad, y_max + pad), (inner_height.get(), 0.0))
});
let y_tick_count = Memo::new(move |_| (inner_height.get() / 50.0).max(2.0) as usize);
let y_label_clone = y_label.clone();
let x_band_memo = Memo::new(move |_| {
let groups = data.get();
BandScale::new(
groups.iter().map(|g| g.label.clone()).collect(),
(0.0, inner_width.get()),
0.3,
)
});
let show_legend = Memo::new(move |_| {
config
.get()
.show_legend
.unwrap_or_else(|| legend_items.get().len() > 1)
});
let groups_tooltip: Memo<Vec<BoxGroupTooltipData>> = Memo::new(move |_| {
let groups = data.get();
let x_band = x_band_memo.get();
let th = theme.get();
groups
.iter()
.enumerate()
.filter_map(|(i, g)| {
if g.data.is_empty() {
return None;
}
let mut data_copy = g.data.clone();
let stats = box_plot_stats(&mut data_copy)?;
let center = x_band.map_index_center(i);
let bw = x_band.band_width();
let color = th.palette[i % th.palette.len()].clone();
Some(BoxGroupTooltipData {
label: g.label.clone(),
center,
band_width: bw,
stats,
n: g.data.len(),
color,
})
})
.collect()
});
view! {
<div
class="box-plot"
style=move || {
format!(
"width: 100%; height: 100%; display: flex; flex-direction: column; background-color: {};",
theme.get().background_color,
)
}
>
{move || {
final_title
.get()
.map(|t| {
let th = theme.get();
view! {
<h3 style=format!(
"text-align: center; margin: 0; padding-top: {}px; padding-bottom: {}px; font-size: {}px; font-family: {}; color: {}; font-weight: {};",
th.title_padding_top,
th.title_padding_bottom,
th.title_font_size,
th.font_family,
th.text_color,
th.title_font_weight,
)>{t}</h3>
}
})
}}
<div node_ref=container_ref style="flex: 1; position: relative; min-height: 0;">
<svg
role="img"
aria-labelledby=a11y_labelledby
viewBox=move || format!("0 0 {} {}", chart_width.get(), chart_height.get())
style="width: 100%; height: 100%; display: block;"
>
<title id=a11y_title_id>
{move || final_title.get().unwrap_or_else(|| "Box plot chart".to_string())}
</title>
<desc id=a11y_desc_id>
"Box plot showing distribution of data values across groups, with median, quartiles, and outliers."
</desc>
<g transform=move || {
format!("translate({}, {})", margin.get().left, margin.get().top)
}>
{move || {
let ys = y_scale.get();
let dummy_xs = LinearScale::new(
(0.0, inner_width.get()),
(0.0, inner_width.get()),
);
view! {
<Grid
x_scale=dummy_xs
y_scale=ys
tick_count=y_tick_count.get()
width=inner_width.get()
height=inner_height.get()
style=theme.get().grid.clone()
/>
}
}} {move || {
let groups = data.get();
let ys = y_scale.get();
let th = theme.get();
let iw = inner_width.get();
let x_band = BandScale::new(
groups.iter().map(|g| g.label.clone()).collect(),
(0.0, iw),
0.3,
);
let computed = compute_groups(&groups, &x_band, &ys);
computed
.iter()
.enumerate()
.map(|(i, (gl, outlier_pxs))| {
let color = th.palette[i % th.palette.len()].clone();
let s = &gl.stats;
let cx = gl.center;
let bw = gl.band_width;
let half_bw = bw / 2.0;
let quarter_bw = bw / 4.0;
let y_q1 = ys.map(s.q1);
let y_q3 = ys.map(s.q3);
let y_med = ys.map(s.median);
let y_lw = ys.map(s.lower_whisker);
let y_uw = ys.map(s.upper_whisker);
let box_x = cx - half_bw;
let box_y = y_q3.min(y_q1);
let box_h = (y_q1 - y_q3).abs();
let outlier_views: Vec<_> = outlier_pxs
.iter()
.map(|&(ox, oy)| {
view! {
<circle
cx=format!("{ox:.2}")
cy=format!("{oy:.2}")
r=3
fill="none"
stroke=color.clone()
stroke-width=1.5
/>
}
})
.collect();
view! {
<g>
<line
x1=format!("{cx:.2}")
y1=format!("{y_q1:.2}")
x2=format!("{cx:.2}")
y2=format!("{y_lw:.2}")
stroke=color.clone()
stroke-width=1.5
stroke-dasharray="4,2"
/>
<line
x1=format!("{cx:.2}")
y1=format!("{y_q3:.2}")
x2=format!("{cx:.2}")
y2=format!("{y_uw:.2}")
stroke=color.clone()
stroke-width=1.5
stroke-dasharray="4,2"
/>
<line
x1=format!("{:.2}", cx - quarter_bw)
y1=format!("{y_lw:.2}")
x2=format!("{:.2}", cx + quarter_bw)
y2=format!("{y_lw:.2}")
stroke=color.clone()
stroke-width=2
/>
<line
x1=format!("{:.2}", cx - quarter_bw)
y1=format!("{y_uw:.2}")
x2=format!("{:.2}", cx + quarter_bw)
y2=format!("{y_uw:.2}")
stroke=color.clone()
stroke-width=2
/>
<rect
x=format!("{box_x:.2}")
y=format!("{box_y:.2}")
width=format!("{bw:.2}")
height=format!("{box_h:.2}")
fill=th.background_color.clone()
stroke=color.clone()
stroke-width=2
/>
<line
x1=format!("{:.2}", cx - half_bw)
y1=format!("{y_med:.2}")
x2=format!("{:.2}", cx + half_bw)
y2=format!("{y_med:.2}")
stroke=color.clone()
stroke-width=3
/>
{outlier_views}
</g>
}
})
.collect_view()
}} {move || {
let groups = data.get();
let th = theme.get();
let x_band = BandScale::new(
groups.iter().map(|g| g.label.clone()).collect(),
(0.0, inner_width.get()),
0.3,
);
groups
.iter()
.enumerate()
.map(|(i, g)| {
let cx = x_band.map_index_center(i);
let ty = inner_height.get() + 18.0;
view! {
<text
x=format!("{cx:.2}")
y=format!("{ty:.2}")
text-anchor="middle"
font-size=th.axis_font_size
fill=th.axis_color.clone()
>
{g.label.clone()}
</text>
}
})
.collect_view()
}} {move || {
view! {
<Axis
orientation=AxisOrientation::Left
scale=y_scale.get()
tick_count=y_tick_count.get()
_dimension=inner_height.get()
stroke=theme.get().axis_color
font_size=theme.get().axis_font_size
label=y_label_clone.clone()
/>
}
}} <BoxViolinTooltip
groups=groups_tooltip
band_scale=x_band_memo
inner_width=inner_width
inner_height=inner_height
margin=margin
tooltip_bg=Signal::derive(move || theme.get().tooltip_bg.clone())
tooltip_text=Signal::derive(move || theme.get().tooltip_text.clone())
/> {move || {
show_legend
.get()
.then(|| {
let text_color = theme.get().text_color;
let position = if legend_outside.get() {
LegendPosition::ExternalRight
} else {
LegendPosition::TopRight
};
view! {
<Legend
items=legend_items
position=position
inner_width=inner_width
inner_height=inner_height
text_color=text_color
/>
}
})
}}
</g>
</svg>
</div>
</div>
}
}
#[component]
pub fn ViolinChart(
data: Signal<Vec<BoxGroup>>,
#[prop(default = Signal::derive(|| ChartConfig::default()), into)]
config: Signal<ChartConfig>,
#[prop(optional)]
width: Option<u32>,
#[prop(optional)]
height: Option<u32>,
#[prop(optional, into)]
y_label: Option<String>,
) -> impl IntoView {
let ctx_theme = use_context::<Signal<ChartTheme>>();
let theme = Memo::new(move |_| {
config
.get()
.theme
.unwrap_or_else(|| ctx_theme.map(|s| s.get()).unwrap_or_default())
});
let (container_width, container_height, container_ref) = use_container_size();
let chart_width = Memo::new(move |_| {
let measured = container_width.get();
if measured > 0.0 {
return measured as u32;
}
config.get().width.or(width).unwrap_or(800)
});
let chart_height = Memo::new(move |_| {
let measured = container_height.get();
if measured > 0.0 {
return measured as u32;
}
config.get().height.or(height).unwrap_or(400)
});
let legend_items = Signal::derive(move || {
let groups = data.get();
let th = theme.get();
groups
.iter()
.enumerate()
.map(|(i, g)| LegendItem {
name: g.label.clone(),
color: th.palette[i % th.palette.len()].clone(),
visible: true,
})
.collect::<Vec<_>>()
});
let legend_outside = Memo::new(move |_| config.get().legend_outside.unwrap_or(false));
let margin = Memo::new(move |_| {
let mut m = config.get().margin.unwrap_or_default();
if legend_outside.get() {
m.right += estimate_legend_width(&legend_items.get()) + 16.0;
}
m
});
let inner_width =
Memo::new(move |_| chart_width.get() as f64 - margin.get().left - margin.get().right);
let inner_height =
Memo::new(move |_| chart_height.get() as f64 - margin.get().top - margin.get().bottom);
let final_title = Memo::new(move |_| config.get().title.clone());
let a11y_title_id_v = format!("chart-title-{}", uuid::Uuid::new_v4().as_simple());
let a11y_desc_id_v = format!("chart-desc-{}", uuid::Uuid::new_v4().as_simple());
let a11y_labelledby_v = format!("{} {}", a11y_title_id_v, a11y_desc_id_v);
let violin_kdes = Signal::derive(move || {
data.get()
.iter()
.map(|g| {
if g.data.len() < 2 {
return None;
}
let kde = gaussian_kde(&g.data, 80)?;
let max_density = kde.ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
if max_density <= 0.0 {
None
} else {
Some((kde, max_density))
}
})
.collect::<Vec<_>>()
});
let y_scale = Memo::new(move |_| {
let kdes = violin_kdes.get();
let mut min_val = f64::INFINITY;
let mut max_val = f64::NEG_INFINITY;
let mut has_data = false;
for (kde, _) in kdes.into_iter().flatten() {
if let Some(min) = kde.xs.first() {
min_val = min_val.min(*min);
}
if let Some(max) = kde.xs.last() {
max_val = max_val.max(*max);
}
has_data = true;
}
if !has_data {
min_val = 0.0;
max_val = 1.0;
}
let pad = (max_val - min_val) * 0.05;
LinearScale::new((min_val - pad, max_val + pad), (inner_height.get(), 0.0))
});
let y_tick_count = Memo::new(move |_| (inner_height.get() / 50.0).max(2.0) as usize);
let y_label_clone = y_label.clone();
let x_band_memo_violin = Memo::new(move |_| {
let groups = data.get();
BandScale::new(
groups.iter().map(|g| g.label.clone()).collect(),
(0.0, inner_width.get()),
0.25,
)
});
let groups_tooltip_violin: Memo<Vec<BoxGroupTooltipData>> = Memo::new(move |_| {
let groups = data.get();
let x_band = x_band_memo_violin.get();
let th = theme.get();
groups
.iter()
.enumerate()
.filter_map(|(i, g)| {
if g.data.is_empty() {
return None;
}
let mut data_copy = g.data.clone();
let stats = box_plot_stats(&mut data_copy)?;
let center = x_band.map_index_center(i);
let bw = x_band.band_width();
let color = th.palette[i % th.palette.len()].clone();
Some(BoxGroupTooltipData {
label: g.label.clone(),
center,
band_width: bw,
stats,
n: g.data.len(),
color,
})
})
.collect()
});
let show_legend = Memo::new(move |_| {
config
.get()
.show_legend
.unwrap_or_else(|| legend_items.get().len() > 1)
});
view! {
<div
class="violin-chart"
style=move || {
format!(
"width: 100%; height: 100%; display: flex; flex-direction: column; background-color: {};",
theme.get().background_color,
)
}
>
{move || {
final_title
.get()
.map(|t| {
let th = theme.get();
view! {
<h3 style=format!(
"text-align: center; margin: 0; padding-top: {}px; padding-bottom: {}px; font-size: {}px; font-family: {}; color: {}; font-weight: {};",
th.title_padding_top,
th.title_padding_bottom,
th.title_font_size,
th.font_family,
th.text_color,
th.title_font_weight,
)>{t}</h3>
}
})
}}
<div node_ref=container_ref style="flex: 1; position: relative; min-height: 0;">
<svg
role="img"
aria-labelledby=a11y_labelledby_v
viewBox=move || format!("0 0 {} {}", chart_width.get(), chart_height.get())
style="width: 100%; height: 100%; display: block;"
>
<title id=a11y_title_id_v>
{move || final_title.get().unwrap_or_else(|| "Violin chart".to_string())}
</title>
<desc id=a11y_desc_id_v>
"Violin plot showing the probability density distribution of data values across groups."
</desc>
<g transform=move || {
format!("translate({}, {})", margin.get().left, margin.get().top)
}>
{move || {
let ys = y_scale.get();
let dummy_xs = LinearScale::new(
(0.0, inner_width.get()),
(0.0, inner_width.get()),
);
view! {
<Grid
x_scale=dummy_xs
y_scale=ys
tick_count=y_tick_count.get()
width=inner_width.get()
height=inner_height.get()
style=theme.get().grid.clone()
/>
}
}} {move || {
let groups = data.get();
let kdes = violin_kdes.get();
let ys = y_scale.get();
let th = theme.get();
let iw = inner_width.get();
let x_band = BandScale::new(
groups.iter().map(|g| g.label.clone()).collect(),
(0.0, iw),
0.25,
);
groups
.iter()
.zip(kdes.iter())
.enumerate()
.filter_map(|(i, (g, kde_opt))| {
let (kde, max_density) = kde_opt.as_ref()?;
let color = th.palette[i % th.palette.len()].clone();
let cx = x_band.map_index_center(i);
let bw = x_band.band_width();
let max_half_width = bw / 2.0 * 0.9;
let n = kde.xs.len();
let mut right: Vec<(f64, f64)> = Vec::with_capacity(n);
let mut left: Vec<(f64, f64)> = Vec::with_capacity(n);
for j in 0..n {
let y_px = ys.map(kde.xs[j]);
let w = (kde.ys[j] / max_density) * max_half_width;
right.push((cx + w, y_px));
left.push((cx - w, y_px));
}
let mut path = String::new();
for (k, &(px, py)) in right.iter().enumerate() {
if k == 0 {
path.push_str(&format!("M {px:.2} {py:.2}"));
} else {
path.push_str(&format!(" L {px:.2} {py:.2}"));
}
}
for &(px, py) in left.iter().rev() {
path.push_str(&format!(" L {px:.2} {py:.2}"));
}
path.push_str(" Z");
let mut data_copy = g.data.clone();
let stats = box_plot_stats(&mut data_copy)?;
let y_q1 = ys.map(stats.q1);
let y_q3 = ys.map(stats.q3);
let y_med = ys.map(stats.median);
let box_half = bw * 0.12;
Some(
view! {
<g>
<path
d=path
fill=format!("{}55", color)
stroke=color.clone()
stroke-width=1.5
/>
<rect
x=format!("{:.2}", cx - box_half)
y=format!("{:.2}", y_q3.min(y_q1))
width=format!("{:.2}", box_half * 2.0)
height=format!("{:.2}", (y_q1 - y_q3).abs())
fill=th.background_color.clone()
stroke=color.clone()
stroke-width=2
/>
<circle
cx=format!("{cx:.2}")
cy=format!("{y_med:.2}")
r=4
fill=color.clone()
/>
</g>
},
)
})
.collect_view()
}} {move || {
let groups = data.get();
let th = theme.get();
let x_band = BandScale::new(
groups.iter().map(|g| g.label.clone()).collect(),
(0.0, inner_width.get()),
0.25,
);
groups
.iter()
.enumerate()
.map(|(i, g)| {
let cx = x_band.map_index_center(i);
let ty = inner_height.get() + 18.0;
view! {
<text
x=format!("{cx:.2}")
y=format!("{ty:.2}")
text-anchor="middle"
font-size=th.axis_font_size
fill=th.axis_color.clone()
>
{g.label.clone()}
</text>
}
})
.collect_view()
}} {move || {
view! {
<Axis
orientation=AxisOrientation::Left
scale=y_scale.get()
tick_count=y_tick_count.get()
_dimension=inner_height.get()
stroke=theme.get().axis_color
font_size=theme.get().axis_font_size
label=y_label_clone.clone()
/>
}
}} <BoxViolinTooltip
groups=groups_tooltip_violin
band_scale=x_band_memo_violin
inner_width=inner_width
inner_height=inner_height
margin=margin
tooltip_bg=Signal::derive(move || theme.get().tooltip_bg.clone())
tooltip_text=Signal::derive(move || theme.get().tooltip_text.clone())
/> {move || {
show_legend
.get()
.then(|| {
let text_color = theme.get().text_color;
let position = if legend_outside.get() {
LegendPosition::ExternalRight
} else {
LegendPosition::TopRight
};
view! {
<Legend
items=legend_items
position=position
inner_width=inner_width
inner_height=inner_height
text_color=text_color
/>
}
})
}}
</g>
</svg>
</div>
</div>
}
}