use zenith_core::{ChartNode, Diagnostic, FontStyle};
use zenith_layout::{ShapeRequest, TextDirection, TextLayoutEngine};
use crate::ir::{Color, Paint, SceneCommand};
use super::super::NodeCtx;
use super::super::paint::resolve_property_color;
use super::super::text::run_to_scene_glyphs;
use super::axis::format_tick_label;
use super::frame::PlotArea;
use super::palette::series_color;
use super::scale::LinearScale;
const CATEGORY_PAD_FRAC: f64 = 0.20;
const BAR_GAP_FRAC: f64 = 0.15;
pub(super) const VALUE_LABEL_COLOR: Color = Color::srgb(60, 60, 60, 255);
#[derive(Clone, Copy, PartialEq, Debug)]
pub(super) enum BarMode {
Grouped,
Stacked,
}
impl BarMode {
pub(super) fn from_opt(s: Option<&str>) -> BarMode {
match s {
Some("stacked") => BarMode::Stacked,
_ => BarMode::Grouped,
}
}
}
#[derive(Clone, Copy)]
pub(super) struct BarRect {
pub(super) x: f64,
pub(super) y: f64,
pub(super) w: f64,
pub(super) h: f64,
}
pub(super) fn bar_rects(
plot: &PlotArea,
y_scale: &LinearScale,
series_values: &[&[f64]],
mode: BarMode,
) -> Vec<Vec<BarRect>> {
let n_categories = series_values.iter().map(|s| s.len()).max().unwrap_or(0);
if n_categories == 0 || plot.w <= 0.0 {
return Vec::new();
}
let n_series = series_values.len();
if n_series == 0 {
return Vec::new();
}
let baseline_px = y_scale.map(0.0).round();
let slot_w = plot.w / n_categories as f64;
let usable_w = slot_w * (1.0 - CATEGORY_PAD_FRAC);
let left_pad = (slot_w - usable_w) / 2.0;
match mode {
BarMode::Grouped => {
let bar_w = usable_w / (n_series as f64 * (1.0 + BAR_GAP_FRAC) - BAR_GAP_FRAC).max(1.0);
if bar_w <= 0.0 {
return Vec::new();
}
let step = bar_w * (1.0 + BAR_GAP_FRAC);
series_values
.iter()
.enumerate()
.map(|(s, sv)| {
(0..n_categories)
.map(|c| match sv.get(c) {
None => BarRect {
x: 0.0,
y: 0.0,
w: 0.0,
h: 0.0,
},
Some(&value) => {
let bar_x = plot.x + c as f64 * slot_w + left_pad + s as f64 * step;
let top = y_scale.map(value).round();
let h = (baseline_px - top).abs();
let y = top.min(baseline_px);
BarRect {
x: bar_x,
y,
w: bar_w,
h,
}
}
})
.collect()
})
.collect()
}
BarMode::Stacked => {
let bar_w = usable_w;
let mut cumulative = vec![0.0f64; n_categories];
series_values
.iter()
.map(|sv| {
(0..n_categories)
.map(|c| match sv.get(c) {
None => BarRect {
x: 0.0,
y: 0.0,
w: 0.0,
h: 0.0,
},
Some(&value) => {
let bar_x = plot.x + c as f64 * slot_w + left_pad;
let lower = cumulative.get(c).copied().unwrap_or(0.0);
let upper = lower + value;
if let Some(slot) = cumulative.get_mut(c) {
*slot = upper;
}
let top = y_scale.map(upper).round();
let bottom = y_scale.map(lower).round();
let y = top.min(bottom);
let h = (bottom - top).abs();
BarRect {
x: bar_x,
y,
w: bar_w,
h,
}
}
})
.collect()
})
.collect()
}
}
}
pub(super) fn stacked_max(chart: &ChartNode) -> f64 {
let n_categories = chart
.series
.iter()
.map(|s| s.values.len())
.max()
.unwrap_or(0);
let mut max = 0.0_f64;
for c in 0..n_categories {
let sum: f64 = chart.series.iter().filter_map(|s| s.values.get(c)).sum();
if sum > max {
max = sum;
}
}
max
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub(super) enum ValueLabelMode {
Off,
Top,
Center,
}
impl ValueLabelMode {
pub(super) fn resolve(value_labels: Option<&str>, is_stacked: bool) -> ValueLabelMode {
match value_labels {
Some("none") => ValueLabelMode::Off,
Some("top") => ValueLabelMode::Top,
Some("center") => ValueLabelMode::Center,
_ => {
if is_stacked {
ValueLabelMode::Center
} else {
ValueLabelMode::Top
}
}
}
}
}
pub(super) const ON_FILL_LABEL_COLOR: Color = Color::srgb(255, 255, 255, 255);
pub(super) fn emit_bars(
chart: &ChartNode,
plot: &PlotArea,
y_scale: &LinearScale,
cx: NodeCtx,
commands: &mut Vec<SceneCommand>,
diagnostics: &mut Vec<Diagnostic>,
) {
let series_values: Vec<&[f64]> = chart.series.iter().map(|s| s.values.as_slice()).collect();
let mode = BarMode::from_opt(chart.bar_mode.as_deref());
let rects = bar_rects(plot, y_scale, &series_values, mode);
if rects.is_empty() {
return;
}
let label_mode =
ValueLabelMode::resolve(chart.value_labels.as_deref(), mode == BarMode::Stacked);
let explicit_label_color = chart
.value_color
.as_ref()
.and_then(|p| resolve_property_color(p, cx.resolved, diagnostics, &chart.id));
let value_label_families = [String::from("Noto Sans")];
for s in 0..chart.series.len() {
let color = match chart.series.get(s) {
Some(series) => series_color(series, s, cx.resolved, diagnostics, &chart.id),
None => continue,
};
let paint = Paint::solid(color);
let label_explicit = chart
.series
.get(s)
.and_then(|sr| sr.label_color.as_ref())
.and_then(|p| resolve_property_color(p, cx.resolved, diagnostics, &chart.id))
.or(explicit_label_color);
if let Some(series_rects) = rects.get(s) {
for (c, rect) in series_rects.iter().enumerate() {
if rect.w <= 0.0 || rect.h < 0.5 {
continue;
}
commands.push(SceneCommand::FillRect {
x: rect.x,
y: rect.y,
w: rect.w,
h: rect.h,
paint: paint.clone(),
});
if label_mode == ValueLabelMode::Off {
continue;
}
let value = match chart.series.get(s).and_then(|sr| sr.values.get(c)) {
Some(v) => *v,
None => continue,
};
emit_value_label(
value,
*rect,
LabelCtx {
plot,
families: &value_label_families,
chart_id: &chart.id,
placement: label_mode,
explicit: label_explicit,
},
cx,
commands,
diagnostics,
);
}
}
}
}
#[derive(Clone, Copy)]
struct LabelCtx<'a> {
plot: &'a PlotArea,
families: &'a [String],
chart_id: &'a str,
placement: ValueLabelMode,
explicit: Option<Color>,
}
fn emit_value_label(
value: f64,
rect: BarRect,
lc: LabelCtx,
cx: NodeCtx,
commands: &mut Vec<SceneCommand>,
diagnostics: &mut Vec<Diagnostic>,
) {
let plot = lc.plot;
if lc.placement == ValueLabelMode::Center && rect.h < 12.0 {
return;
}
let label = format_tick_label(value);
let req = ShapeRequest {
text: &label,
families: lc.families,
weight: 400,
style: FontStyle::Normal,
font_size: 9.0,
direction: TextDirection::Ltr,
};
match cx.engine.shape_with_fallback(&req, cx.fonts) {
Err(e) => {
diagnostics.push(Diagnostic::advisory(
"scene.text_unshaped",
format!(
"chart '{}' bar value label '{}' could not be shaped: {}",
lc.chart_id, label, e.message
),
None,
Some(lc.chart_id.to_owned()),
));
}
Ok(result) => {
let total_advance: f64 = result.runs.iter().map(|r| r.advance_width as f64).sum();
let ascent: f64 = result.runs.first().map(|r| r.ascent as f64).unwrap_or(7.0);
let (baseline_y, on_fill) = match lc.placement {
ValueLabelMode::Center => (rect.y + rect.h / 2.0 + ascent * 0.35, true),
ValueLabelMode::Top | ValueLabelMode::Off => {
if rect.y - 3.0 - ascent >= plot.y {
(rect.y - 3.0, false)
} else {
(rect.y + 12.0, true)
}
}
};
let color = lc.explicit.unwrap_or(if on_fill {
ON_FILL_LABEL_COLOR
} else {
VALUE_LABEL_COLOR
});
let mut label_x = rect.x + rect.w / 2.0 - total_advance / 2.0;
for run in result.runs {
let advance = run.advance_width as f64;
let glyphs = run_to_scene_glyphs(&run);
commands.push(SceneCommand::DrawGlyphRun {
x: label_x,
y: baseline_y,
font_id: run.font_id.clone(),
font_size: run.font_size,
color,
stroke_color: None,
stroke_width: None,
link: None,
selectable: true,
glyphs,
});
label_x += advance;
}
}
}
}
#[derive(Clone, Copy)]
pub(super) struct CatLabels<'a> {
pub plot: &'a PlotArea,
pub color: Color,
pub slot_center: bool,
}
pub(super) fn emit_category_labels(
categories: &[String],
n_categories: usize,
layout: CatLabels,
cx: NodeCtx,
commands: &mut Vec<SceneCommand>,
diagnostics: &mut Vec<Diagnostic>,
) {
let plot = layout.plot;
let label_color = layout.color;
if n_categories == 0 || plot.w <= 0.0 {
return;
}
let baseline_y = plot.y + plot.h + 14.0;
let families = [String::from("Noto Sans")];
for c in 0..n_categories {
let label: String = categories
.get(c)
.cloned()
.unwrap_or_else(|| (c + 1).to_string());
if label.is_empty() {
continue;
}
let req = ShapeRequest {
text: &label,
families: &families,
weight: 400,
style: FontStyle::Normal,
font_size: 9.0,
direction: TextDirection::Ltr,
};
match cx.engine.shape_with_fallback(&req, cx.fonts) {
Err(e) => {
diagnostics.push(Diagnostic::advisory(
"scene.text_unshaped",
format!(
"chart category label '{}' could not be shaped: {}",
label, e.message
),
None,
None,
));
}
Ok(result) => {
let total_advance: f64 = result.runs.iter().map(|r| r.advance_width as f64).sum();
let center_x = if layout.slot_center {
plot.x + (c as f64 + 0.5) * (plot.w / n_categories as f64)
} else if n_categories <= 1 {
plot.x + plot.w / 2.0
} else {
plot.x + c as f64 * (plot.w / (n_categories - 1) as f64)
};
let mut label_x = center_x - total_advance / 2.0;
for run in result.runs {
let advance = run.advance_width as f64;
let glyphs = run_to_scene_glyphs(&run);
commands.push(SceneCommand::DrawGlyphRun {
x: label_x,
y: baseline_y,
font_id: run.font_id.clone(),
font_size: run.font_size,
color: label_color,
stroke_color: None,
stroke_width: None,
link: None,
selectable: true,
glyphs,
});
label_x += advance;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_plot() -> PlotArea {
PlotArea {
x: 44.0,
y: 10.0,
w: 300.0,
h: 200.0,
}
}
fn test_scale() -> LinearScale {
LinearScale {
data_min: 0.0,
data_max: 100.0,
pixel_min: 210.0, pixel_max: 10.0, }
}
#[test]
fn bar_mode_from_opt_stacked() {
assert_eq!(BarMode::from_opt(Some("stacked")), BarMode::Stacked);
}
#[test]
fn bar_mode_from_opt_grouped_variants() {
assert_eq!(BarMode::from_opt(None), BarMode::Grouped);
assert_eq!(BarMode::from_opt(Some("grouped")), BarMode::Grouped);
assert_eq!(BarMode::from_opt(Some("x")), BarMode::Grouped);
}
#[test]
fn value_label_mode_resolve() {
use ValueLabelMode::*;
assert_eq!(ValueLabelMode::resolve(Some("none"), false), Off);
assert_eq!(ValueLabelMode::resolve(Some("top"), true), Top);
assert_eq!(ValueLabelMode::resolve(Some("center"), false), Center);
assert_eq!(ValueLabelMode::resolve(Some("auto"), true), Center);
assert_eq!(ValueLabelMode::resolve(Some("auto"), false), Top);
assert_eq!(ValueLabelMode::resolve(None, true), Center);
assert_eq!(ValueLabelMode::resolve(None, false), Top);
assert_eq!(ValueLabelMode::resolve(Some("???"), true), Center);
}
#[test]
fn bar_rects_empty_series_returns_empty() {
let plot = test_plot();
let scale = test_scale();
let result = bar_rects(&plot, &scale, &[], BarMode::Grouped);
assert!(result.is_empty());
}
#[test]
fn bar_rects_zero_categories_returns_empty() {
let plot = test_plot();
let scale = test_scale();
let empty: &[f64] = &[];
let result = bar_rects(&plot, &scale, &[empty], BarMode::Grouped);
assert!(result.is_empty());
}
#[test]
fn bar_rects_single_series_grouped_geometry() {
let plot = test_plot();
let scale = test_scale();
let values: &[f64] = &[25.0, 50.0, 75.0];
let rects = bar_rects(&plot, &scale, &[values], BarMode::Grouped);
assert_eq!(rects.len(), 1, "one series");
assert_eq!(rects[0].len(), 3, "three categories");
let eps = 0.5;
for r in &rects[0] {
assert!(r.x >= plot.x - eps, "bar left of plot.x");
assert!(
r.x + r.w <= plot.x + plot.w + eps,
"bar right of plot right"
);
let baseline = scale.map(0.0);
assert!(
(r.y + r.h - baseline).abs() < eps,
"bar bottom not at baseline"
);
}
let r0 = rects[0][0]; let r1 = rects[0][1]; let r2 = rects[0][2]; assert!(r0.h < r1.h, "25 bar shorter than 50 bar");
assert!(r1.h < r2.h, "50 bar shorter than 75 bar");
assert!(
r2.y < r0.y,
"75 bar top is higher (smaller y) than 25 bar top"
);
}
#[test]
fn bar_rects_grouped_two_series_no_overlap() {
let plot = test_plot();
let scale = test_scale();
let s0: &[f64] = &[30.0, 60.0, 90.0];
let s1: &[f64] = &[10.0, 20.0, 30.0];
let rects = bar_rects(&plot, &scale, &[s0, s1], BarMode::Grouped);
assert_eq!(rects.len(), 2);
assert_eq!(rects[0].len(), 3);
assert_eq!(rects[1].len(), 3);
for (c, (r0, r1)) in rects[0].iter().zip(rects[1].iter()).enumerate() {
assert!(
r0.x < r1.x,
"series 0 bar not left of series 1 bar at category {}",
c
);
assert!(
r0.x + r0.w <= r1.x + 0.5,
"bars overlap at category {}: s0 right={} s1 left={}",
c,
r0.x + r0.w,
r1.x
);
}
}
#[test]
fn bar_rects_stacked_same_x_stacked_heights() {
let plot = test_plot();
let scale = test_scale();
let s0: &[f64] = &[20.0, 40.0];
let s1: &[f64] = &[30.0, 10.0];
let rects = bar_rects(&plot, &scale, &[s0, s1], BarMode::Stacked);
assert_eq!(rects.len(), 2);
assert_eq!(rects[0].len(), 2);
assert_eq!(rects[1].len(), 2);
let eps = 0.5;
for c in 0..2 {
let r0 = rects[0][c];
let r1 = rects[1][c];
assert!(
(r0.x - r1.x).abs() < eps,
"stacked bars differ in x at cat {}",
c
);
assert!(
(r0.w - r1.w).abs() < eps,
"stacked bars differ in w at cat {}",
c
);
assert!(r1.y < r0.y, "series 1 not above series 0 at cat {}", c);
let combined_value = s0[c] + s1[c];
let expected_h = (scale.map(0.0) - scale.map(combined_value)).abs();
let actual_h = r0.h + r1.h;
assert!(
(actual_h - expected_h).abs() < eps,
"stacked heights don't sum at cat {}: got {} expected {}",
c,
actual_h,
expected_h
);
}
}
}