use std::collections::BTreeMap;
use zenith_core::{ChartNode, Diagnostic, FontStyle, ResolvedToken};
use zenith_layout::{ShapeRequest, TextDirection, TextLayoutEngine};
use crate::ir::{Color, SceneCommand};
use super::super::NodeCtx;
use super::super::RenderCtx;
use super::super::paint::resolve_property_color;
use super::super::text::run_to_scene_glyphs;
use super::super::util::{
AxisTarget, missing_geometry_diag, resolve_anchored_axis, resolve_geometry_px,
unsupported_unit_diag,
};
use super::axis::{AxisColors, emit_axis_lines, emit_gridlines_and_labels};
use super::bar::{BarMode, CatLabels, emit_bars, emit_category_labels, stacked_max};
use super::frame::{PlotArea, plot_area};
use super::hbar::emit_hbar;
use super::legend::{
LegendAlign, LegendArea, LegendConfig, LegendLayout, LegendPosition, emit_legend,
legend_reserve,
};
use super::line::{emit_area_fill, emit_line_series, line_points};
use super::palette::series_color;
use super::pie::{emit_pie, resolve_slice_color};
use super::scale::{LinearScale, data_range, nice_ticks};
const DEFAULT_AXIS_COLOR: Color = Color::srgb(120, 120, 120, 255);
const DEFAULT_GRID_COLOR: Color = Color::srgb(225, 225, 225, 255);
const DEFAULT_LABEL_COLOR: Color = Color::srgb(90, 90, 90, 255);
pub(super) const DEFAULT_TITLE_COLOR: Color = Color::srgb(40, 40, 40, 255);
pub(in crate::compile) fn compile_chart(
chart: &ChartNode,
cx: NodeCtx,
commands: &mut Vec<SceneCommand>,
diagnostics: &mut Vec<Diagnostic>,
ctx: RenderCtx,
) -> f64 {
if chart.visible == Some(false) {
return 0.0;
}
match chart.kind.as_str() {
"bar" | "line" | "area" | "sparkline" | "pie" | "donut" => {}
_ => return 0.0,
}
let (Some(w_dim), Some(h_dim)) = (&chart.w, &chart.h) else {
diagnostics.push(missing_geometry_diag("chart", &chart.id, chart.source_span));
return 0.0;
};
let Some(w) = resolve_geometry_px(Some(w_dim), cx.resolved) else {
diagnostics.push(unsupported_unit_diag(
"chart",
&chart.id,
"w",
chart.source_span,
));
return 0.0;
};
let Some(h) = resolve_geometry_px(Some(h_dim), cx.resolved) else {
diagnostics.push(unsupported_unit_diag(
"chart",
&chart.id,
"h",
chart.source_span,
));
return 0.0;
};
let anchor_xy = cx.anchors.get(&chart.id).copied();
let Some(x_raw) = resolve_anchored_axis(
AxisTarget {
kind: "chart",
node_id: &chart.id,
axis: "x",
},
chart.x.as_ref(),
cx.resolved,
anchor_xy.map(|(ax, _)| ax),
chart.source_span,
diagnostics,
) else {
return 0.0;
};
let Some(y_raw) = resolve_anchored_axis(
AxisTarget {
kind: "chart",
node_id: &chart.id,
axis: "y",
},
chart.y.as_ref(),
cx.resolved,
anchor_xy.map(|(_, ay)| ay),
chart.source_span,
diagnostics,
) else {
return 0.0;
};
let x = x_raw + ctx.dx;
let y = y_raw + ctx.dy;
if chart.kind.as_str() == "sparkline" {
return emit_sparkline(chart, (x, y, w, h), cx, commands, diagnostics);
}
if matches!(chart.kind.as_str(), "pie" | "donut") {
let is_donut = chart.kind.as_str() == "donut";
let legend_on = chart.legend == Some(true);
let legend_config = LegendConfig {
position: LegendPosition::from_opt(chart.legend_position.as_deref()),
layout: LegendLayout::from_opt(chart.legend_layout.as_deref()),
align: LegendAlign::from_opt(chart.legend_align.as_deref()),
};
let n = chart.series.first().map(|s| s.values.len()).unwrap_or(0);
let (w_res, h_res) = if legend_on {
let mut reserve_diags: Vec<Diagnostic> = Vec::new();
let entries = pie_legend_entries(chart, n, cx.resolved, &mut reserve_diags);
let (wr, hr) = legend_reserve(&entries, legend_config, w, cx);
let wr_capped = if legend_config.position.is_side() {
wr.min(w * 0.4)
} else {
wr
};
let hr_capped = if !legend_config.position.is_side() {
hr.min(h * 0.5)
} else {
hr
};
(wr_capped, hr_capped)
} else {
(0.0, 0.0)
};
let (bbox_x, bbox_y, bbox_w, bbox_h, la_x, la_y, la_w, la_h) =
position_layout(x, y, w, h, w_res, h_res, legend_config.position);
emit_pie(
chart,
(bbox_x, bbox_y, bbox_w, bbox_h),
is_donut,
cx,
commands,
diagnostics,
);
if legend_on && (w_res > 0.0 || h_res > 0.0) {
let entries = pie_legend_entries(chart, n, cx.resolved, diagnostics);
emit_legend(
&entries,
LegendArea {
x: la_x,
y: la_y,
w: la_w,
h: la_h,
},
legend_config,
cx,
commands,
diagnostics,
);
}
return 0.0;
}
if chart.axis_style.as_deref() == Some("hidden") {
return 0.0;
}
let legend_on = chart.legend == Some(true);
let legend_config = LegendConfig {
position: LegendPosition::from_opt(chart.legend_position.as_deref()),
layout: LegendLayout::from_opt(chart.legend_layout.as_deref()),
align: LegendAlign::from_opt(chart.legend_align.as_deref()),
};
let series_entries: Vec<(String, Color)> = if legend_on {
chart
.series
.iter()
.enumerate()
.map(|(s, sr)| {
let label = sr
.label
.clone()
.unwrap_or_else(|| format!("Series {}", s + 1));
let color = series_color(sr, s, cx.resolved, diagnostics, &chart.id);
(label, color)
})
.collect()
} else {
Vec::new()
};
let (w_res, h_res) = if legend_on {
let (wr, hr) = legend_reserve(&series_entries, legend_config, w, cx);
let wr_capped = if legend_config.position.is_side() {
wr.min(w * 0.4)
} else {
wr
};
let hr_capped = if !legend_config.position.is_side() {
hr.min(h * 0.5)
} else {
hr
};
(wr_capped, hr_capped)
} else {
(0.0, 0.0)
};
let (bbox_x, bbox_y, bbox_w, bbox_h, la_x, la_y, la_w, la_h) =
position_layout(x, y, w, h, w_res, h_res, legend_config.position);
let has_title = chart.title.is_some();
let has_caption = chart.caption.is_some();
let plot = plot_area(bbox_x, bbox_y, bbox_w, bbox_h, has_title, has_caption);
let axis_color = chart
.stroke
.as_ref()
.and_then(|p| resolve_property_color(p, cx.resolved, diagnostics, &chart.id))
.unwrap_or(DEFAULT_AXIS_COLOR);
let colors = AxisColors {
axis: axis_color,
grid: DEFAULT_GRID_COLOR,
label: DEFAULT_LABEL_COLOR,
};
let (mut data_lo, mut data_hi) =
data_range(&chart.series, chart.axis_min, chart.axis_max).unwrap_or((0.0, 1.0));
if chart.kind.as_str() == "bar" && chart.axis_min.is_none() {
data_lo = data_lo.min(0.0);
}
if chart.kind.as_str() == "bar"
&& BarMode::from_opt(chart.bar_mode.as_deref()) == BarMode::Stacked
&& chart.axis_max.is_none()
{
data_hi = data_hi.max(stacked_max(chart));
}
let y_scale = LinearScale {
data_min: data_lo,
data_max: data_hi,
pixel_min: plot.y + plot.h, pixel_max: plot.y, };
let y_ticks = nice_ticks(&y_scale, 5);
match chart.kind.as_str() {
"bar" => {
if chart.orientation.as_deref() == Some("horizontal") {
emit_hbar(
chart,
(bbox_x, bbox_y, bbox_w, bbox_h),
colors,
cx,
commands,
diagnostics,
);
} else {
let n_categories = chart
.series
.iter()
.map(|s| s.values.len())
.max()
.unwrap_or(0);
emit_gridlines_and_labels(
&plot,
&y_ticks,
colors,
&chart.id,
cx,
commands,
diagnostics,
);
emit_bars(chart, &plot, &y_scale, cx, commands, diagnostics);
emit_category_labels(
&chart.categories,
n_categories,
CatLabels {
plot: &plot,
color: colors.label,
slot_center: true,
},
cx,
commands,
diagnostics,
);
emit_axis_lines(&plot, colors.axis, commands);
}
}
"line" | "area" => {
let is_area = chart.kind.as_str() == "area";
let slot_center = chart.point_placement.as_deref() == Some("center");
let n_categories = chart
.series
.iter()
.map(|s| s.values.len())
.max()
.unwrap_or(0);
emit_gridlines_and_labels(
&plot,
&y_ticks,
colors,
&chart.id,
cx,
commands,
diagnostics,
);
let mut series_geom: Vec<(Vec<(f64, f64)>, Color)> =
Vec::with_capacity(chart.series.len());
for (idx, series) in chart.series.iter().enumerate() {
let c = series_color(series, idx, cx.resolved, diagnostics, &chart.id);
let pts = line_points(&series.values, &plot, &y_scale, slot_center);
series_geom.push((pts, c));
}
if is_area {
for (pts, c) in &series_geom {
let area_color = Color::srgb(c.r, c.g, c.b, 64);
emit_area_fill(pts, &plot, area_color, commands);
}
}
for (pts, c) in &series_geom {
emit_line_series(pts, *c, 2.0, commands);
}
emit_axis_lines(&plot, colors.axis, commands);
if !chart.categories.is_empty() {
emit_category_labels(
&chart.categories,
n_categories,
CatLabels {
plot: &plot,
color: colors.label,
slot_center,
},
cx,
commands,
diagnostics,
);
}
}
_ => {}
}
if legend_on && (w_res > 0.0 || h_res > 0.0) {
emit_legend(
&series_entries,
LegendArea {
x: la_x,
y: la_y,
w: la_w,
h: la_h,
},
legend_config,
cx,
commands,
diagnostics,
);
}
if let Some(title) = &chart.title {
emit_title(
title,
(x, y),
DEFAULT_TITLE_COLOR,
&chart.id,
cx,
commands,
diagnostics,
);
}
0.0
}
fn emit_sparkline(
chart: &ChartNode,
bbox: (f64, f64, f64, f64),
cx: NodeCtx,
commands: &mut Vec<SceneCommand>,
diagnostics: &mut Vec<Diagnostic>,
) -> f64 {
const INSET: f64 = 4.0;
let (x, y, w, h) = bbox;
let spark_plot = PlotArea {
x: x + INSET,
y: y + INSET,
w: (w - 2.0 * INSET).max(0.0),
h: (h - 2.0 * INSET).max(0.0),
};
let (data_lo, data_hi) =
data_range(&chart.series, chart.axis_min, chart.axis_max).unwrap_or((0.0, 1.0));
let y_scale = LinearScale {
data_min: data_lo,
data_max: data_hi,
pixel_min: spark_plot.y + spark_plot.h,
pixel_max: spark_plot.y,
};
for (idx, series) in chart.series.iter().enumerate() {
let color = series_color(series, idx, cx.resolved, diagnostics, &chart.id);
let pts = line_points(&series.values, &spark_plot, &y_scale, false);
emit_line_series(&pts, color, 1.5, commands);
}
0.0
}
pub(super) fn emit_title(
title: &str,
origin: (f64, f64),
color: Color,
chart_id: &str,
cx: NodeCtx,
commands: &mut Vec<SceneCommand>,
diagnostics: &mut Vec<Diagnostic>,
) {
let (chart_x, chart_y) = origin;
let families = [String::from("Noto Sans")];
let req = ShapeRequest {
text: title,
families: &families,
weight: 600,
style: FontStyle::Normal,
font_size: 13.0,
direction: TextDirection::Ltr,
};
match cx.engine.shape_with_fallback(&req, cx.fonts) {
Err(e) => {
diagnostics.push(Diagnostic::advisory(
"scene.text_unshaped",
format!(
"chart '{}' title could not be shaped: {}",
chart_id, e.message
),
None,
Some(chart_id.to_owned()),
));
}
Ok(result) => {
let ascent: f64 = result.runs.first().map(|r| r.ascent as f64).unwrap_or(10.0);
let baseline_y = chart_y + ascent + 2.0;
let mut label_x = chart_x + 4.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,
font_size: run.font_size,
color,
stroke_color: None,
stroke_width: None,
link: None,
selectable: true,
glyphs,
});
label_x += advance;
}
}
}
}
fn position_layout(
x: f64,
y: f64,
w: f64,
h: f64,
w_res: f64,
h_res: f64,
position: LegendPosition,
) -> (f64, f64, f64, f64, f64, f64, f64, f64) {
match position {
LegendPosition::Right => (x, y, w - w_res, h, x + w - w_res, y, w_res, h),
LegendPosition::Left => (x + w_res, y, w - w_res, h, x, y, w_res, h),
LegendPosition::Top => (x, y + h_res, w, h - h_res, x, y, w, h_res),
LegendPosition::Bottom => (x, y, w, h - h_res, x, y + h - h_res, w, h_res),
}
}
pub(super) fn pie_legend_entries(
chart: &ChartNode,
n: usize,
resolved: &BTreeMap<String, ResolvedToken>,
diagnostics: &mut Vec<Diagnostic>,
) -> Vec<(String, Color)> {
(0..n)
.map(|i| {
let label = chart
.categories
.get(i)
.cloned()
.unwrap_or_else(|| (i + 1).to_string());
let color = resolve_slice_color(chart, i, resolved, diagnostics);
(label, color)
})
.collect()
}