use std::cell::OnceCell;
use std::sync::{Arc, Mutex};
use blinc_core::reactive::{Computed, ReactiveGraph, computed};
use blinc_layout::binding::{IntoReactive, Reactive};
use blinc_layout::canvas::canvas;
use blinc_layout::div::ElementTypeId;
use blinc_layout::element::RenderProps;
use blinc_layout::prelude::*;
use blinc_layout::renderer::{LayoutBoundsCallback, LayoutBoundsStorage};
use blinc_layout::text::Text;
use blinc_layout::text_measure::{TextLayoutOptions, measure_text_with_options};
use blinc_layout::tree::{LayoutNodeId, LayoutTree};
use super::math::compact;
use super::plot::{
BOTTOM, CategoryMarks, Geometry, Notes, Options, Plot, TOP, count, palette_class, palette_ink,
};
use super::{ChartCurve, ChartSeries};
pub struct ChartBuilder {
opts: Options,
series: Vec<ChartSeries>,
data: Option<Reactive<Vec<ChartSeries>>>,
labels: Option<Reactive<Vec<String>>>,
legend: Option<bool>,
width: Option<f32>,
tint: Option<String>,
notes: Option<Notes>,
classes: Vec<String>,
bounds: LayoutBoundsStorage,
built: OnceCell<(Div, Arc<Plot>)>,
}
impl ChartBuilder {
fn new(area: bool, bars: bool) -> Self {
Self {
opts: Options {
area,
bars,
horizontal: false,
height: 200.0,
curve: ChartCurve::Smooth,
dots: false,
stacked: false,
grid: true,
axes: true,
tooltip: true,
format: Arc::new(compact),
min: None,
max: None,
bare: false,
bands: Vec::new(),
dots_by_band: false,
markers: Vec::new(),
category_markers: None,
gap: None,
notes: false,
},
series: Vec::new(),
data: None,
labels: None,
legend: None,
width: None,
tint: None,
notes: None,
classes: Vec::new(),
bounds: Arc::new(Mutex::new(None)),
built: OnceCell::new(),
}
}
pub fn series(mut self, name: impl Into<String>, values: &[f64]) -> Self {
self.series.push(ChartSeries::new(name, values.to_vec()));
self
}
pub fn series_colored(
mut self,
name: impl Into<String>,
values: &[f64],
css: impl Into<String>,
) -> Self {
self.series
.push(ChartSeries::new(name, values.to_vec()).color(css));
self
}
pub fn data(mut self, series: impl IntoReactive<Vec<ChartSeries>>) -> Self {
self.data = Some(series.into_reactive());
self
}
pub fn labels(mut self, labels: impl IntoReactive<Vec<String>>) -> Self {
self.labels = Some(labels.into_reactive());
self
}
pub fn height(mut self, height: f32) -> Self {
self.opts.height = height;
self
}
pub fn width(mut self, width: f32) -> Self {
self.width = Some(width);
self
}
pub fn curve(mut self, curve: ChartCurve) -> Self {
self.opts.curve = curve;
self
}
pub fn dots(mut self) -> Self {
self.opts.dots = true;
self
}
pub fn horizontal(mut self) -> Self {
self.opts.horizontal = true;
self
}
pub fn stacked(mut self) -> Self {
self.opts.stacked = true;
self
}
pub fn no_grid(mut self) -> Self {
self.opts.grid = false;
self
}
pub fn no_axes(mut self) -> Self {
self.opts.axes = false;
self
}
pub fn legend(mut self, shown: bool) -> Self {
self.legend = Some(shown);
self
}
pub fn no_tooltip(mut self) -> Self {
self.opts.tooltip = false;
self
}
pub fn format<F>(mut self, format: F) -> Self
where
F: Fn(f64) -> String + Send + Sync + 'static,
{
self.opts.format = Arc::new(format);
self
}
pub fn min(mut self, min: f64) -> Self {
self.opts.min = Some(min);
self
}
pub fn max(mut self, max: f64) -> Self {
self.opts.max = Some(max);
self
}
pub fn class(mut self, name: impl Into<String>) -> Self {
self.classes.push(name.into());
self
}
pub fn color(mut self, css: impl Into<String>) -> Self {
self.tint = Some(css.into());
self
}
pub fn band(mut self, band: super::ChartBand) -> Self {
self.opts.bands.push(band);
self
}
pub fn marker(mut self, marker: super::ChartMarker) -> Self {
self.opts.markers.push(marker);
self
}
pub fn gap(mut self, gap: f32) -> Self {
self.opts.gap = Some(gap);
self
}
pub(super) fn area(mut self) -> Self {
self.opts.area = true;
self
}
pub(super) fn bare(mut self) -> Self {
(
self.opts.bare,
self.opts.axes,
self.opts.grid,
self.opts.tooltip,
) = (true, false, false, false);
self.legend = Some(false);
self
}
pub(super) fn dots_by_band(mut self) -> Self {
self.opts.dots_by_band = true;
self
}
pub(super) fn category_markers(mut self, markers: CategoryMarks) -> Self {
self.opts.category_markers = Some(markers);
self
}
pub(super) fn notes(mut self, notes: Notes) -> Self {
self.opts.notes = true;
self.notes = Some(notes);
self
}
fn get_or_build(&self) -> &(Div, Arc<Plot>) {
::blinc_layout::build_once::build_once(&self.built, || self.build_chart())
}
fn build_chart(&self) -> (Div, Arc<Plot>) {
let series = list_of(self.data.clone(), self.series.clone());
let series = match self.tint.clone() {
None => series,
Some(tint) => computed(move |_: &ReactiveGraph| {
let mut list = series.try_get().unwrap_or_default();
for s in list.iter_mut().filter(|s| s.color.is_none()) {
s.color = Some(tint.clone());
}
list
}),
};
let labels = {
let (source, series) = (self.labels.clone(), series.clone());
computed(move |g: &ReactiveGraph| match &source {
Some(Reactive::Const(l)) => l.clone(),
Some(Reactive::Bound(state)) => g.get(state.signal()).unwrap_or_default(),
Some(Reactive::Computed(c)) => c.try_get().unwrap_or_default(),
None => (1..=count(&series.try_get().unwrap_or_default()))
.map(|i| i.to_string())
.collect(),
})
};
let plot = Plot::new(series.clone(), labels.clone(), self.opts.clone());
let geometry = {
let plot = Arc::clone(&plot);
computed(move |g: &ReactiveGraph| {
let width = g.get(plot.width).unwrap_or(0.0);
Geometry::of(
&plot.series.try_get().unwrap_or_default(),
&plot.labels.try_get().unwrap_or_default(),
&plot.opts,
width,
)
})
};
let canvas = {
let (draw, wants) = (Arc::clone(&plot), Arc::clone(&plot));
canvas(move |ctx, bounds| draw.draw(ctx, bounds.width))
.absolute()
.left(0.0)
.top(0.0)
.w_full()
.h_full()
.redraw_while(move || wants.wants_frames())
};
let mut area = div()
.class("cn-chart-plot")
.relative()
.w_full()
.h(self.opts.height)
.flex_shrink_0()
.child(canvas);
if self.opts.axes {
area = if self.opts.bars && self.opts.horizontal {
area.child(value_axis_under(&geometry, &self.opts))
.child(category_axis_beside(&geometry, &labels, &self.opts))
} else {
area.child(value_axis(&geometry, &self.opts))
.child(category_axis(&geometry, &labels))
};
}
for band in self.opts.bands.iter().filter(|b| b.label.is_some()) {
area = area.child(band_label(&geometry, band));
}
if let Some(notes) = &self.notes {
area = area.child(super::extras::notes(&geometry, notes, &self.opts));
}
if self.opts.tooltip {
area = with_hover(area, &plot, &geometry, &labels);
}
let mut root = div()
.class("cn-chart")
.flex_col()
.child(area)
.child(legend(&series, self.legend));
root = match self.width {
Some(w) => root.w(w),
None => root.w_full(),
};
let root = self.classes.iter().fold(root, |d, c| d.class(c));
(root, plot)
}
}
fn list_of(
data: Option<Reactive<Vec<ChartSeries>>>,
constant: Vec<ChartSeries>,
) -> Computed<Vec<ChartSeries>> {
match data {
None => computed(move |_: &ReactiveGraph| constant.clone()),
Some(data) => source(data),
}
}
pub(super) fn source<T>(data: Reactive<T>) -> Computed<T>
where
T: Clone + Default + Send + Sync + 'static,
{
match data {
Reactive::Const(v) => computed(move |_: &ReactiveGraph| v.clone()),
Reactive::Bound(state) => {
let signal = state.signal();
computed(move |g: &ReactiveGraph| g.get(signal).unwrap_or_default())
}
Reactive::Computed(c) => c,
}
}
fn band_label(geometry: &Computed<Geometry>, band: &super::ChartBand) -> Div {
let (text, to, place) = (
band.label.clone().unwrap_or_default(),
band.to,
geometry.clone(),
);
div()
.absolute()
.left(0.0)
.top(0.0)
.w_full()
.h_full()
.flex_col()
.pointer_events_none()
.child(div().flex_shrink_0().h(computed(move |_: &ReactiveGraph| {
place.try_get().map_or(0.0, |g| g.at(to) + 2.0)
})))
.child(
div()
.flex_row()
.justify_end()
.pr(2.5)
.child(label("cn-chart-band-label", move |_| text.clone())),
)
}
pub(super) fn label(
class: &str,
text: impl Fn(&ReactiveGraph) -> String + Send + Sync + 'static,
) -> Div {
div()
.class(class)
.child(Text::bound(Reactive::Computed(computed(text))).no_wrap())
}
fn value_axis(geometry: &Computed<Geometry>, opts: &Options) -> Div {
let (ticks, width) = (geometry.clone(), geometry.clone());
let format = Arc::clone(&opts.format);
div()
.class("cn-chart-ticks")
.absolute()
.left(0.0)
.top(TOP - 8.0)
.h(opts.height - TOP - BOTTOM + 16.0)
.w(computed(move |_: &ReactiveGraph| {
width.try_get().map_or(0.0, |g| (g.x0 - 8.0).max(0.0))
}))
.flex_col()
.justify_between()
.pointer_events_none()
.for_each(
computed(move |_: &ReactiveGraph| {
let mut t = ticks.try_get().map(|g| g.ticks).unwrap_or_default();
t.reverse();
t
}),
|t: &f64| t.to_bits(),
move |t: f64| {
let text = format(t);
div()
.h(16.0)
.flex_row()
.items_center()
.justify_end()
.child(label("cn-chart-axis", move |_| text.clone()))
},
)
}
fn category_axis(geometry: &Computed<Geometry>, labels: &Computed<Vec<String>>) -> Div {
let (lead, slots) = (geometry.clone(), labels.clone());
let (geometry, labels) = (geometry.clone(), labels.clone());
let every = computed(move |_: &ReactiveGraph| {
let (Some(g), Some(l)) = (geometry.try_get(), labels.try_get()) else {
return 1usize;
};
let options = TextLayoutOptions::new();
let widest = l
.iter()
.map(|s| measure_text_with_options(s, 12.0, &options).width)
.fold(0.0f32, f32::max);
((widest + 8.0) / g.slot(l.len()).max(1.0)).ceil().max(1.0) as usize
});
let shown = labels_row(slots, every);
div()
.absolute()
.left(0.0)
.top(0.0)
.w_full()
.h_full()
.flex_col()
.justify_end()
.pointer_events_none()
.child(
div()
.w_full()
.h(BOTTOM)
.flex_row()
.items_start()
.child(div().flex_shrink_0().w(computed(move |_: &ReactiveGraph| {
lead.try_get().map_or(0.0, |g| g.x0)
})))
.child(shown)
.child(div().flex_shrink_0().w(8.0)),
)
}
fn labels_row(labels: Computed<Vec<String>>, every: Computed<usize>) -> Div {
let indices = {
let labels = labels.clone();
computed(move |_: &ReactiveGraph| {
(0..labels.try_get().map_or(0, |l| l.len())).collect::<Vec<usize>>()
})
};
div().flex_grow().w(0.0).flex_row().pt(1.5).for_each(
indices,
|i: &usize| *i,
move |i: usize| {
let (text, every) = (labels.clone(), every.clone());
let hidden = computed(move |_: &ReactiveGraph| i % every.try_get().unwrap_or(1) != 0);
div().flex_grow().w(0.0).flex_row().justify_center().child(
label("cn-chart-axis", move |_| {
text.try_get()
.and_then(|l| l.get(i).cloned())
.unwrap_or_default()
})
.attr_when("data-hidden", "", hidden),
)
},
)
}
fn value_axis_under(geometry: &Computed<Geometry>, opts: &Options) -> Div {
let (ticks, lead, span) = (geometry.clone(), geometry.clone(), geometry.clone());
let format = Arc::clone(&opts.format);
let row = div()
.class("cn-chart-ticks")
.flex_shrink_0()
.w(computed(move |_: &ReactiveGraph| {
span.try_get().map_or(0.0, |g| g.x1 - g.x0 + 60.0)
}))
.flex_row()
.justify_between()
.for_each(
computed(move |_: &ReactiveGraph| ticks.try_get().map(|g| g.ticks).unwrap_or_default()),
|t: &f64| t.to_bits(),
move |t: f64| {
let text = format(t);
div()
.w(60.0)
.flex_row()
.justify_center()
.child(label("cn-chart-axis", move |_| text.clone()))
},
);
div()
.absolute()
.left(0.0)
.top(0.0)
.w_full()
.h_full()
.flex_col()
.justify_end()
.pointer_events_none()
.child(
div()
.w_full()
.h(BOTTOM)
.flex_row()
.pt(1.5)
.child(div().flex_shrink_0().w(computed(move |_: &ReactiveGraph| {
lead.try_get().map_or(0.0, |g| g.x0 - 30.0)
})))
.child(row),
)
}
fn category_axis_beside(
geometry: &Computed<Geometry>,
labels: &Computed<Vec<String>>,
opts: &Options,
) -> Div {
let (width, slots) = (geometry.clone(), labels.clone());
let (geometry, labels) = (geometry.clone(), labels.clone());
let every = computed(move |_: &ReactiveGraph| {
let (Some(g), Some(l)) = (geometry.try_get(), labels.try_get()) else {
return 1usize;
};
(16.0 / g.slot(l.len()).max(1.0)).ceil().max(1.0) as usize
});
let indices = {
let slots = slots.clone();
computed(move |_: &ReactiveGraph| {
(0..slots.try_get().map_or(0, |l| l.len())).collect::<Vec<usize>>()
})
};
div()
.absolute()
.left(0.0)
.top(TOP)
.h(opts.height - TOP - BOTTOM)
.w(computed(move |_: &ReactiveGraph| {
width.try_get().map_or(0.0, |g| (g.x0 - 8.0).max(0.0))
}))
.flex_col()
.pointer_events_none()
.for_each(
indices,
|i: &usize| *i,
move |i: usize| {
let (text, every) = (slots.clone(), every.clone());
let hidden =
computed(move |_: &ReactiveGraph| i % every.try_get().unwrap_or(1) != 0);
div()
.flex_grow()
.h(0.0)
.flex_row()
.items_center()
.justify_end()
.child(
label("cn-chart-axis", move |_| {
text.try_get()
.and_then(|l| l.get(i).cloned())
.unwrap_or_default()
})
.attr_when("data-hidden", "", hidden),
)
},
)
}
fn with_hover(
area: Div,
plot: &Arc<Plot>,
geometry: &Computed<Geometry>,
labels: &Computed<Vec<String>>,
) -> Div {
let hover = plot.hover;
let at = {
let series = plot.series.clone();
computed(move |g: &ReactiveGraph| {
let i = g.get(hover).unwrap_or(-1);
let n = count(&series.try_get().unwrap_or_default());
(i >= 0 && (i as usize) < n).then_some(i as usize)
})
};
let hovered = {
let at = at.clone();
computed(move |_: &ReactiveGraph| at.try_get().flatten().is_some())
};
let side = {
let (at, geometry, plot) = (at.clone(), geometry.clone(), Arc::clone(plot));
move |g: &ReactiveGraph| -> (f32, bool, f32, f32) {
let width = g.get(plot.width).unwrap_or(0.0);
let (Some(Some(i)), Some(geo)) = (at.try_get(), geometry.try_get()) else {
return (0.0, false, 0.0, TOP + 4.0);
};
let n = count(&plot.series.try_get().unwrap_or_default());
if geo.horizontal {
(geo.x1, true, width, geo.start(i, n))
} else {
let x = geo.centre(i, n);
(x, x > width * 0.6, width, TOP + 4.0)
}
}
};
let tip = {
let (plot, labels, at) = (Arc::clone(plot), labels.clone(), at.clone());
move || tooltip(&plot, &labels, &at)
};
let (lead, grow, trail, down) = (side.clone(), side.clone(), side.clone(), side);
let holder = div()
.absolute()
.left(0.0)
.top(0.0)
.w_full()
.h_full()
.foreground()
.flex_col()
.pointer_events_none()
.show(hovered, move || {
let (lead, grow, trail, down) =
(lead.clone(), grow.clone(), trail.clone(), down.clone());
div()
.w_full()
.flex_col()
.child(
div()
.flex_shrink_0()
.h(computed(move |g: &ReactiveGraph| down(g).3)),
)
.child(
div()
.w_full()
.flex_row()
.items_start()
.child(
div()
.flex_shrink_0()
.w(computed(move |g: &ReactiveGraph| {
let (x, left, _, _) = lead(g);
if left { 0.0 } else { x + 14.0 }
}))
.flex_grow_value(computed(
move |g: &ReactiveGraph| {
if grow(g).1 { 1.0 } else { 0.0 }
},
)),
)
.child(tip())
.child(div().flex_shrink_0().w(computed(move |g: &ReactiveGraph| {
let (x, left, width, _) = trail(g);
if left { width - x + 14.0 } else { 0.0 }
}))),
)
});
let (move_plot, leave) = (Arc::clone(plot), plot.hover);
area.child(holder)
.on_mouse_move(move |ctx| {
let series = move_plot.series.try_get().unwrap_or_default();
let labels = move_plot.labels.try_get().unwrap_or_default();
let geo = Geometry::of(&series, &labels, &move_plot.opts, ctx.bounds_width);
let along = if geo.horizontal {
ctx.local_y
} else {
ctx.local_x
};
let i = geo
.category_at(along, count(&series))
.map_or(-1, |i| i as i32);
if move_plot.hover.get() != i {
move_plot.hover.set(i);
}
})
.on_hover_leave(move |_| {
if leave.get() != -1 {
leave.set(-1);
}
})
}
fn tooltip(plot: &Arc<Plot>, labels: &Computed<Vec<String>>, at: &Computed<Option<usize>>) -> Div {
let title = {
let (labels, at) = (labels.clone(), at.clone());
move |_: &ReactiveGraph| {
let (Some(Some(i)), Some(l)) = (at.try_get(), labels.try_get()) else {
return String::new();
};
l.get(i).cloned().unwrap_or_default()
}
};
let indices = {
let series = plot.series.clone();
computed(move |_: &ReactiveGraph| {
(0..series.try_get().map_or(0, |s| s.len())).collect::<Vec<usize>>()
})
};
let (series, at, format) = (
plot.series.clone(),
at.clone(),
Arc::clone(&plot.opts.format),
);
div()
.class("cn-chart-tooltip")
.flex_col()
.flex_shrink_0()
.child(label("cn-chart-tooltip-title", title))
.for_each(
indices,
|k: &usize| *k,
move |k: usize| {
let (name, value) = (series.clone(), series.clone());
let (at, format) = (at.clone(), Arc::clone(&format));
div()
.class("cn-chart-tooltip-row")
.flex_row()
.items_center()
.child(swatch(&series.try_get().unwrap_or_default(), k))
.child(label("cn-chart-tooltip-name", move |_| {
name.try_get()
.and_then(|s| s.get(k).map(|s| s.name.clone()))
.unwrap_or_default()
}))
.child(label("cn-chart-tooltip-value", move |_| {
let (Some(Some(i)), Some(s)) = (at.try_get(), value.try_get()) else {
return String::new();
};
s.get(k)
.and_then(|s| s.values.get(i))
.map(|v| format(*v))
.unwrap_or_default()
}))
},
)
}
fn swatch(series: &[ChartSeries], k: usize) -> Div {
mark(series.get(k).and_then(|s| s.color.as_deref()), k)
}
pub(super) fn mark(own: Option<&str>, k: usize) -> Div {
let mark = div()
.class("cn-chart-swatch")
.w(10.0)
.h(10.0)
.flex_shrink_0();
match own {
Some(css) => mark.bg(palette_ink(Some(css), k)),
None => mark.class(palette_class(k)),
}
}
fn legend(series: &Computed<Vec<ChartSeries>>, shown: Option<bool>) -> Div {
let (count, series) = (series.clone(), series.clone());
let visible = computed(move |_: &ReactiveGraph| {
let n = count.try_get().map_or(0, |s| s.len());
shown.unwrap_or(n > 1)
});
div().show(visible, move || {
let (indices, items) = (series.clone(), series.clone());
div()
.class("cn-chart-legend")
.flex_row()
.flex_wrap()
.justify_center()
.for_each(
computed(move |_: &ReactiveGraph| {
(0..indices.try_get().map_or(0, |s| s.len())).collect::<Vec<usize>>()
}),
|k: &usize| *k,
move |k: usize| {
let name = items.clone();
div()
.class("cn-chart-legend-item")
.flex_row()
.items_center()
.child(swatch(&items.try_get().unwrap_or_default(), k))
.child(label("cn-chart-legend-name", move |_| {
name.try_get()
.and_then(|s| s.get(k).map(|s| s.name.clone()))
.unwrap_or_default()
}))
},
)
})
}
impl ElementBuilder for ChartBuilder {
fn build(&self, tree: &mut LayoutTree) -> LayoutNodeId {
self.get_or_build().0.build(tree)
}
fn render_props(&self) -> RenderProps {
self.get_or_build().0.render_props()
}
fn children_builders(&self) -> &[Box<dyn ElementBuilder>] {
self.get_or_build().0.children_builders()
}
fn element_type_id(&self) -> ElementTypeId {
self.get_or_build().0.element_type_id()
}
fn layout_style(&self) -> Option<&taffy::Style> {
ElementBuilder::layout_style(&self.get_or_build().0)
}
fn event_handlers(&self) -> Option<&blinc_layout::event_handler::EventHandlers> {
ElementBuilder::event_handlers(&self.get_or_build().0)
}
fn element_id(&self) -> Option<&str> {
ElementBuilder::element_id(&self.get_or_build().0)
}
fn element_classes(&self) -> &[Arc<str>] {
self.get_or_build().0.element_classes()
}
fn layout_bounds_storage(&self) -> Option<LayoutBoundsStorage> {
Some(Arc::clone(&self.bounds))
}
fn layout_bounds_callback(&self) -> Option<LayoutBoundsCallback> {
let width = self.get_or_build().1.width;
Some(Arc::new(move |bounds| {
if (width.try_get().unwrap_or(0.0) - bounds.width).abs() > 0.5 {
width.set(bounds.width);
}
}))
}
}
pub fn line_chart() -> ChartBuilder {
ChartBuilder::new(false, false)
}
pub fn area_chart() -> ChartBuilder {
ChartBuilder::new(true, false)
}
pub fn bar_chart() -> ChartBuilder {
ChartBuilder::new(false, true)
}