use crate::compile::stat_boxplot::BoxPlotSummary;
use crate::compile::stat_transform::ResolvedLayer;
use crate::error::ChartError;
use crate::grammar::layer::MarkType;
use crate::new_theme::NewTheme;
use esoc_scene::bounds::{BoundingBox, DataBounds};
use esoc_scene::mark::{
ArcMark, AreaMark, BatchAttr, Interpolation, LineMark, Mark, MarkBatch, RuleMark, TextAnchor,
TextMark,
};
use esoc_scene::node::{Node, NodeId};
use esoc_scene::scale::Scale;
use esoc_scene::style::{FillStyle, FontStyle, MarkerShape, StrokeStyle};
use esoc_scene::SceneGraph;
#[allow(clippy::too_many_arguments)]
pub fn generate_layer_marks(
scene: &mut SceneGraph,
plot_id: NodeId,
layer: &ResolvedLayer,
data_bounds: &DataBounds,
plot_w: f32,
plot_h: f32,
theme: &NewTheme,
total_layers: usize,
) -> Result<(), ChartError> {
generate_layer_marks_inner(
scene,
plot_id,
layer,
data_bounds,
plot_w,
plot_h,
theme,
false,
total_layers,
)
}
#[allow(clippy::too_many_arguments)]
pub fn generate_layer_marks_flipped(
scene: &mut SceneGraph,
plot_id: NodeId,
layer: &ResolvedLayer,
data_bounds: &DataBounds,
plot_w: f32,
plot_h: f32,
theme: &NewTheme,
is_flipped: bool,
total_layers: usize,
) -> Result<(), ChartError> {
generate_layer_marks_inner(
scene,
plot_id,
layer,
data_bounds,
plot_w,
plot_h,
theme,
is_flipped,
total_layers,
)
}
#[allow(clippy::too_many_arguments)]
fn generate_layer_marks_inner(
scene: &mut SceneGraph,
plot_id: NodeId,
layer: &ResolvedLayer,
data_bounds: &DataBounds,
plot_w: f32,
plot_h: f32,
theme: &NewTheme,
is_flipped: bool,
total_layers: usize,
) -> Result<(), ChartError> {
let x_scale = Scale::Linear {
domain: (data_bounds.x_min, data_bounds.x_max),
range: (0.0, plot_w),
};
let y_scale = Scale::Linear {
domain: (data_bounds.y_min, data_bounds.y_max),
range: (plot_h, 0.0),
};
let series_color = theme.palette.get(layer.layer_idx);
if let Some(summaries) = &layer.boxplot {
return generate_boxplot(scene, plot_id, summaries, &x_scale, &y_scale, plot_w, theme);
}
match layer.mark {
MarkType::Point => {
generate_points(
scene,
plot_id,
layer,
&x_scale,
&y_scale,
series_color,
theme,
)?;
}
MarkType::Line => {
generate_line(
scene,
plot_id,
layer,
&x_scale,
&y_scale,
series_color,
theme,
total_layers,
)?;
}
MarkType::Bar => {
generate_bars(
scene,
plot_id,
layer,
&x_scale,
&y_scale,
series_color,
plot_w,
plot_h,
theme,
is_flipped,
)?;
}
MarkType::Area => {
generate_area(
scene,
plot_id,
layer,
&x_scale,
&y_scale,
series_color,
theme,
)?;
}
MarkType::Arc => {
generate_arcs(scene, plot_id, layer, plot_w, plot_h, theme)?;
}
MarkType::Heatmap => {
generate_heatmap(scene, plot_id, layer, plot_w, plot_h, theme)?;
}
MarkType::Treemap => {
generate_treemap(scene, plot_id, layer, plot_w, plot_h, theme)?;
}
MarkType::Text => {
return Err(ChartError::InvalidParameter(
"Text marks are not yet implemented".into(),
));
}
MarkType::Rule => {
return Err(ChartError::InvalidParameter(
"Rule marks are not yet implemented".into(),
));
}
}
Ok(())
}
fn generate_points(
scene: &mut SceneGraph,
plot_id: NodeId,
layer: &ResolvedLayer,
x_scale: &Scale,
y_scale: &Scale,
color: esoc_color::Color,
theme: &NewTheme,
) -> Result<(), ChartError> {
if layer.x_data.is_empty() || layer.y_data.is_empty() {
return Ok(());
}
let n = layer.x_data.len();
let diameter = {
let base = (theme.point_size / std::f32::consts::PI).sqrt() * 2.0;
if n > 200 {
base * (200.0 / n as f32).sqrt()
} else {
base
}
};
let opacity = if n > 50 {
(50.0 / (n as f32).sqrt()).clamp(0.1, 1.0)
} else {
1.0
};
if let Some(cats) = &layer.categories {
let unique_cats: Vec<String> = {
let mut seen = Vec::new();
for c in cats {
if !seen.contains(c) {
seen.push(c.clone());
}
}
seen
};
let positions: Vec<[f32; 2]> = layer
.x_data
.iter()
.zip(layer.y_data.iter())
.map(|(&x, &y)| [x_scale.map(x), y_scale.map(y)])
.collect();
let fills: Vec<FillStyle> = cats
.iter()
.map(|c| {
let idx = unique_cats.iter().position(|u| u == c).unwrap_or(0);
FillStyle::Solid(theme.palette.get(idx).with_alpha(opacity))
})
.collect();
let batch = MarkBatch::points(
positions,
BatchAttr::Uniform(diameter),
BatchAttr::Varying(fills),
MarkerShape::Circle,
BatchAttr::Uniform(StrokeStyle {
width: 0.0,
..Default::default()
}),
)
.map_err(|e| ChartError::InvalidData {
layer: layer.layer_idx,
detail: e,
})?;
let node = Node::with_batch(batch).z_order(3);
scene.insert_child(plot_id, node);
} else {
let positions: Vec<[f32; 2]> = layer
.x_data
.iter()
.zip(layer.y_data.iter())
.map(|(&x, &y)| [x_scale.map(x), y_scale.map(y)])
.collect();
let batch = MarkBatch::points(
positions,
BatchAttr::Uniform(diameter),
BatchAttr::Uniform(FillStyle::Solid(color.with_alpha(opacity))),
MarkerShape::Circle,
BatchAttr::Uniform(StrokeStyle {
width: 0.0,
..Default::default()
}),
)
.map_err(|e| ChartError::InvalidData {
layer: layer.layer_idx,
detail: e,
})?;
let node = Node::with_batch(batch).z_order(3);
scene.insert_child(plot_id, node);
}
Ok(())
}
fn generate_line(
scene: &mut SceneGraph,
plot_id: NodeId,
layer: &ResolvedLayer,
x_scale: &Scale,
y_scale: &Scale,
color: esoc_color::Color,
theme: &NewTheme,
total_layers: usize,
) -> Result<(), ChartError> {
if layer.x_data.is_empty() || layer.y_data.is_empty() {
return Ok(());
}
let line_w = if total_layers > 10 {
theme.line_width * 0.5
} else if total_layers > 5 {
theme.line_width * 0.75
} else {
theme.line_width
};
let points: Vec<[f32; 2]> = layer
.x_data
.iter()
.zip(layer.y_data.iter())
.map(|(&x, &y)| [x_scale.map(x), y_scale.map(y)])
.collect();
let mark = Mark::Line(LineMark {
points,
stroke: StrokeStyle::solid(color, line_w),
interpolation: Interpolation::Linear,
});
let node = Node::with_mark(mark).z_order(2);
scene.insert_child(plot_id, node);
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn generate_bars(
scene: &mut SceneGraph,
plot_id: NodeId,
layer: &ResolvedLayer,
x_scale: &Scale,
y_scale: &Scale,
color: esoc_color::Color,
plot_w: f32,
_plot_h: f32,
theme: &NewTheme,
is_flipped: bool,
) -> Result<(), ChartError> {
let n = layer.x_data.len().min(layer.y_data.len());
if n == 0 {
return Ok(());
}
let mut rects = Vec::with_capacity(n);
if is_flipped {
let gap_factor = 0.8;
let bar_height = if n > 1 {
let y0 = y_scale.map(layer.y_data[0]);
let y1 = y_scale.map(layer.y_data[1]);
(y1 - y0).abs() * gap_factor
} else {
20.0
};
for i in 0..n {
let cy = y_scale.map(layer.y_data[i]);
let x_val = x_scale.map(layer.x_data[i]);
let x_base = x_scale.map(0.0);
let left = x_val.min(x_base);
let w = (x_val - x_base).abs();
let top = cy - bar_height * 0.5;
rects.push(BoundingBox::new(left, top, w, bar_height));
}
} else {
let bar_width = {
let raw = if let Some(dw) = layer.dodge_width {
let x0_px = x_scale.map(0.0);
let x1_px = x_scale.map(dw);
(x1_px - x0_px).abs()
} else {
let gap_factor = if layer.is_binned { 0.98 } else { 0.8 };
if n > 1 {
let x0 = x_scale.map(layer.x_data[0]);
let x1 = x_scale.map(layer.x_data[1]);
(x1 - x0).abs() * gap_factor
} else {
plot_w * 0.67
}
};
raw.max(2.0) };
for i in 0..n {
let cx = x_scale.map(layer.x_data[i]);
let y = y_scale.map(layer.y_data[i]);
let x = cx - bar_width * 0.5;
let base_y = if let Some(baseline) = &layer.y_baseline {
y_scale.map(baseline.get(i).copied().unwrap_or(0.0))
} else {
y_scale.map(0.0)
};
let h = (base_y - y).abs();
let top = y.min(base_y);
rects.push(BoundingBox::new(x, top, bar_width, h));
}
}
let fills = if layer.label.is_some() || layer.dodge_width.is_some() {
BatchAttr::Uniform(FillStyle::Solid(color))
} else if let Some(cats) = &layer.categories {
let unique_cats: Vec<String> = {
let mut seen = Vec::new();
for c in cats {
if !seen.contains(c) {
seen.push(c.clone());
}
}
seen
};
BatchAttr::Varying(
cats.iter()
.take(n)
.map(|c| {
let idx = unique_cats.iter().position(|u| u == c).unwrap_or(0);
FillStyle::Solid(theme.palette.get(idx))
})
.collect(),
)
} else {
BatchAttr::Uniform(FillStyle::Solid(color))
};
let stroke = if layer.is_binned {
StrokeStyle::solid(theme.background, 0.5)
} else {
StrokeStyle {
width: 0.0,
..Default::default()
}
};
let batch =
MarkBatch::rects(rects.clone(), fills, BatchAttr::Uniform(stroke), 0.0).map_err(|e| {
ChartError::InvalidData {
layer: layer.layer_idx,
detail: e,
}
})?;
let node = Node::with_batch(batch).z_order(2);
scene.insert_child(plot_id, node);
if let Some(errors) = &layer.error_bars {
let whisker_stroke = StrokeStyle::solid(theme.foreground, 1.5);
let mut segments = Vec::new();
for (i, &err) in errors.iter().enumerate().take(n) {
if err <= 0.0 {
continue;
}
let y_val = layer.y_data[i];
if is_flipped {
let cy = y_scale.map(layer.y_data[i]);
let x_lo = x_scale.map(y_val - err);
let x_hi = x_scale.map(y_val + err);
let bar_h = rects[i].h;
let cap_half = bar_h * 0.3;
segments.push(([x_lo, cy], [x_hi, cy]));
segments.push(([x_lo, cy - cap_half], [x_lo, cy + cap_half]));
segments.push(([x_hi, cy - cap_half], [x_hi, cy + cap_half]));
} else {
let cx = x_scale.map(layer.x_data[i]);
let y_lo = y_scale.map(y_val - err);
let y_hi = y_scale.map(y_val + err);
let bar_w = rects[i].w;
let cap_half = bar_w * 0.3;
segments.push(([cx, y_lo], [cx, y_hi]));
segments.push(([cx - cap_half, y_lo], [cx + cap_half, y_lo]));
segments.push(([cx - cap_half, y_hi], [cx + cap_half, y_hi]));
}
}
if !segments.is_empty() {
let whiskers = Node::with_mark(Mark::Rule(RuleMark {
segments,
stroke: whisker_stroke,
}))
.z_order(3);
scene.insert_child(plot_id, whiskers);
}
}
Ok(())
}
fn generate_area(
scene: &mut SceneGraph,
plot_id: NodeId,
layer: &ResolvedLayer,
x_scale: &Scale,
y_scale: &Scale,
color: esoc_color::Color,
theme: &NewTheme,
) -> Result<(), ChartError> {
if layer.x_data.is_empty() || layer.y_data.is_empty() {
return Ok(());
}
if let Some(cats) = &layer.categories {
let unique_cats: Vec<String> = {
let mut seen = Vec::new();
for c in cats {
if !seen.contains(c) {
seen.push(c.clone());
}
}
seen
};
for (cat_idx, cat) in unique_cats.iter().enumerate() {
let cat_color = theme.palette.get(cat_idx);
let indices: Vec<usize> = cats
.iter()
.enumerate()
.filter(|(_, c)| *c == cat)
.map(|(i, _)| i)
.collect();
let upper: Vec<[f32; 2]> = indices
.iter()
.map(|&i| [x_scale.map(layer.x_data[i]), y_scale.map(layer.y_data[i])])
.collect();
let lower: Vec<[f32; 2]> = if let Some(baseline) = &layer.y_baseline {
indices
.iter()
.map(|&i| [x_scale.map(layer.x_data[i]), y_scale.map(baseline[i])])
.collect()
} else {
indices
.iter()
.map(|&i| [x_scale.map(layer.x_data[i]), y_scale.map(0.0)])
.collect()
};
if upper.len() != lower.len() || upper.is_empty() {
continue;
}
let alpha = if layer.y_baseline.is_some() { 0.3 } else { 0.4 };
let mark = Mark::Area(AreaMark {
upper: upper.clone(),
lower,
fill: FillStyle::Solid(cat_color.with_alpha(alpha)),
stroke: StrokeStyle {
width: 0.0,
..Default::default()
},
});
scene.insert_child(plot_id, Node::with_mark(mark).z_order(1));
let line_mark = Mark::Line(LineMark {
points: upper,
stroke: StrokeStyle::solid(cat_color, theme.line_width),
interpolation: Interpolation::Linear,
});
scene.insert_child(plot_id, Node::with_mark(line_mark).z_order(2));
}
return Ok(());
}
let upper: Vec<[f32; 2]> = layer
.x_data
.iter()
.zip(layer.y_data.iter())
.map(|(&x, &y)| [x_scale.map(x), y_scale.map(y)])
.collect();
let lower: Vec<[f32; 2]> = if let Some(baseline) = &layer.y_baseline {
layer
.x_data
.iter()
.zip(baseline.iter())
.map(|(&x, &y)| [x_scale.map(x), y_scale.map(y)])
.collect()
} else {
layer
.x_data
.iter()
.map(|&x| [x_scale.map(x), y_scale.map(0.0)])
.collect()
};
if upper.len() != lower.len() {
return Err(ChartError::InvalidData {
layer: layer.layer_idx,
detail: format!(
"area upper ({}) and lower ({}) paths have different lengths",
upper.len(),
lower.len()
),
});
}
let mark = Mark::Area(AreaMark {
upper: upper.clone(),
lower,
fill: FillStyle::Solid(color.with_alpha(if layer.y_baseline.is_some() {
0.3
} else {
0.5
})),
stroke: StrokeStyle {
width: 0.0,
..Default::default()
},
});
let node = Node::with_mark(mark).z_order(1);
scene.insert_child(plot_id, node);
let line_mark = Mark::Line(LineMark {
points: upper,
stroke: StrokeStyle::solid(color, theme.line_width),
interpolation: Interpolation::Linear,
});
let line_node = Node::with_mark(line_mark).z_order(2);
scene.insert_child(plot_id, line_node);
Ok(())
}
fn generate_arcs(
scene: &mut SceneGraph,
plot_id: NodeId,
layer: &ResolvedLayer,
plot_w: f32,
plot_h: f32,
theme: &NewTheme,
) -> Result<(), ChartError> {
if layer.y_data.is_empty() {
return Ok(());
}
if let Some(pos) = layer.y_data.iter().position(|&v| v < 0.0) {
return Err(ChartError::InvalidData {
layer: layer.layer_idx,
detail: format!(
"pie chart values must be non-negative, but value[{pos}] = {}",
layer.y_data[pos]
),
});
}
let total: f64 = layer.y_data.iter().sum();
if total <= 0.0 {
return Ok(());
}
let center = [plot_w * 0.5, plot_h * 0.5];
let outer_radius = plot_w.min(plot_h) * 0.4;
let inner_radius = outer_radius * layer.inner_radius_fraction;
let start_initial = -std::f32::consts::FRAC_PI_2; let mut start_angle = start_initial;
let n = layer.y_data.len();
for (i, &value) in layer.y_data.iter().enumerate() {
let fraction = value / total;
let sweep = (fraction * std::f64::consts::TAU) as f32;
let end_angle = if i == n - 1 {
start_initial + std::f32::consts::TAU
} else {
start_angle + sweep
};
let color = theme.palette.get(i);
let arc = Node::with_mark(Mark::Arc(ArcMark {
center,
inner_radius,
outer_radius,
start_angle,
end_angle,
fill: FillStyle::Solid(color),
stroke: StrokeStyle::solid(theme.background, 1.5),
}))
.z_order(2);
scene.insert_child(plot_id, arc);
start_angle = end_angle;
}
Ok(())
}
fn generate_boxplot(
scene: &mut SceneGraph,
plot_id: NodeId,
summaries: &[BoxPlotSummary],
x_scale: &Scale,
y_scale: &Scale,
plot_w: f32,
theme: &NewTheme,
) -> Result<(), ChartError> {
let n = summaries.len();
if n == 0 {
return Ok(());
}
let box_width = (plot_w / n as f32) * 0.6;
for (i, s) in summaries.iter().enumerate() {
let cx = x_scale.map(i as f64);
let half_w = box_width * 0.5;
let y_q1 = y_scale.map(s.q1);
let y_q3 = y_scale.map(s.q3);
let y_med = y_scale.map(s.median);
let y_lo = y_scale.map(s.whisker_lo);
let y_hi = y_scale.map(s.whisker_hi);
let box_top = y_q3.min(y_q1);
let box_h = (y_q1 - y_q3).abs();
let color = theme.palette.get(0);
let box_rect = Node::with_mark(Mark::Rect(esoc_scene::mark::RectMark {
bounds: BoundingBox::new(cx - half_w, box_top, box_width, box_h),
fill: FillStyle::Solid(color.with_alpha(0.6)),
stroke: StrokeStyle::solid(color, 1.5),
corner_radius: 0.0,
}))
.z_order(2);
scene.insert_child(plot_id, box_rect);
let rule_stroke = StrokeStyle::solid(theme.foreground, 1.5);
let whisker_stroke = StrokeStyle::solid(theme.foreground, 1.0);
let median_rule = Node::with_mark(Mark::Rule(RuleMark {
segments: vec![([cx - half_w, y_med], [cx + half_w, y_med])],
stroke: rule_stroke,
}))
.z_order(3);
scene.insert_child(plot_id, median_rule);
let whisker_cap = half_w * 0.5;
let whiskers = Node::with_mark(Mark::Rule(RuleMark {
segments: vec![
([cx, y_q1.max(y_q3)], [cx, y_lo]),
([cx - whisker_cap, y_lo], [cx + whisker_cap, y_lo]),
([cx, y_q1.min(y_q3)], [cx, y_hi]),
([cx - whisker_cap, y_hi], [cx + whisker_cap, y_hi]),
],
stroke: whisker_stroke,
}))
.z_order(2);
scene.insert_child(plot_id, whiskers);
if !s.outliers.is_empty() {
let positions: Vec<[f32; 2]> =
s.outliers.iter().map(|&o| [cx, y_scale.map(o)]).collect();
let batch = MarkBatch::points(
positions,
BatchAttr::Uniform((theme.point_size / std::f32::consts::PI).sqrt() * 2.0 * 0.6),
BatchAttr::Uniform(FillStyle::Solid(color.with_alpha(0.7))),
MarkerShape::Circle,
BatchAttr::Uniform(StrokeStyle {
width: 0.0,
..Default::default()
}),
)
.map_err(|e| ChartError::InvalidData {
layer: 0,
detail: e,
})?;
let node = Node::with_batch(batch).z_order(3);
scene.insert_child(plot_id, node);
}
}
Ok(())
}
fn generate_heatmap(
scene: &mut SceneGraph,
plot_id: NodeId,
layer: &ResolvedLayer,
plot_w: f32,
plot_h: f32,
theme: &NewTheme,
) -> Result<(), ChartError> {
let data = match &layer.heatmap_data {
Some(d) if !d.is_empty() => d,
_ => return Ok(()),
};
let rows = data.len();
let cols = data.first().map_or(0, |r| r.len());
if cols == 0 {
return Ok(());
}
let mut v_min = f64::INFINITY;
let mut v_max = f64::NEG_INFINITY;
for row in data {
for &v in row {
if v < v_min {
v_min = v;
}
if v > v_max {
v_max = v;
}
}
}
let v_range = if (v_max - v_min).abs() < 1e-12 {
1.0
} else {
v_max - v_min
};
let color_scale = theme
.color_scale
.clone()
.unwrap_or_else(esoc_color::ColorScale::viridis);
let cell_w = plot_w / cols as f32;
let cell_h = plot_h / rows as f32;
let mut rects = Vec::with_capacity(rows * cols);
let mut fills = Vec::with_capacity(rows * cols);
for (r, row) in data.iter().enumerate() {
for (c, &val) in row.iter().enumerate() {
let t = ((val - v_min) / v_range) as f32;
let color = color_scale.map(t);
let x = c as f32 * cell_w;
let y = r as f32 * cell_h; rects.push(BoundingBox::new(x, y, cell_w, cell_h));
fills.push(FillStyle::Solid(color));
}
}
let batch = MarkBatch::rects(
rects,
BatchAttr::Varying(fills),
BatchAttr::Uniform(StrokeStyle::solid(theme.background, 1.0)),
0.0,
)
.map_err(|e| ChartError::InvalidData {
layer: layer.layer_idx,
detail: e,
})?;
let node = Node::with_batch(batch).z_order(2);
scene.insert_child(plot_id, node);
if layer.annotate_cells {
let font_size = (cell_h * 0.35).min(cell_w * 0.35).max(8.0);
let cell_decimals = if v_max.abs().max(v_min.abs()) < 0.1 {
3
} else if v_max.abs().max(v_min.abs()) < 10.0 {
2
} else if v_max.abs().max(v_min.abs()) < 100.0 {
1
} else {
0
};
for (r, row) in data.iter().enumerate() {
for (c, &val) in row.iter().enumerate() {
let t = ((val - v_min) / v_range) as f32;
let cell_color = color_scale.map(t);
let text_color = esoc_color::contrast::text_color_on(cell_color);
let cx = (c as f32 + 0.5) * cell_w;
let cy = (r as f32 + 0.5) * cell_h;
let text = if (val - val.round()).abs() < 1e-9 {
format!("{}", val as i64)
} else {
format!("{val:.*}", cell_decimals)
};
let text_node = Node::with_mark(Mark::Text(TextMark {
position: [cx, cy],
text,
font: FontStyle {
family: theme.font_family.clone(),
size: font_size,
weight: 400,
italic: false,
},
fill: FillStyle::Solid(text_color),
angle: 0.0,
anchor: TextAnchor::Middle,
}))
.z_order(3);
scene.insert_child(plot_id, text_node);
}
}
}
Ok(())
}
const TREEMAP_FONT: &str =
"Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif";
fn generate_treemap(
scene: &mut SceneGraph,
plot_id: NodeId,
layer: &ResolvedLayer,
plot_w: f32,
plot_h: f32,
theme: &NewTheme,
) -> Result<(), ChartError> {
if layer.y_data.is_empty() {
return Ok(());
}
let gap = 2.0_f32;
let container = BoundingBox::new(0.0, 0.0, plot_w, plot_h);
let cells = crate::compile::layout_treemap::squarified_layout(&layer.y_data, container);
if cells.is_empty() {
return Ok(());
}
for cell in &cells {
let color = theme.palette.get(cell.index);
let inset = BoundingBox::new(
cell.bounds.x + gap * 0.5,
cell.bounds.y + gap * 0.5,
(cell.bounds.w - gap).max(0.0),
(cell.bounds.h - gap).max(0.0),
);
let rect = Node::with_mark(Mark::Rect(esoc_scene::mark::RectMark {
bounds: inset,
fill: FillStyle::Solid(color),
stroke: StrokeStyle {
width: 0.0,
..Default::default()
},
corner_radius: 4.0,
}))
.z_order(2);
scene.insert_child(plot_id, rect);
}
let pad = 8.0_f32;
let categories = layer.categories.as_deref();
let total: f64 = layer.y_data.iter().sum();
for cell in &cells {
let w = cell.bounds.w - gap;
let h = cell.bounds.h - gap;
if w < 32.0 || h < 22.0 {
continue;
}
let label = categories
.and_then(|cats| cats.get(cell.index))
.cloned()
.unwrap_or_default();
if label.is_empty() {
continue;
}
let bg = theme.palette.get(cell.index);
let text_color = esoc_color::contrast::text_color_on(bg);
let text_color_muted = text_color.with_alpha(0.7);
let label_size = (w * 0.09)
.min(h * 0.22)
.clamp(9.0, theme.base_font_size * 1.4);
let value_size = (label_size * 0.78).max(8.0);
let x = cell.bounds.x + gap * 0.5 + pad;
let y_label = cell.bounds.y + gap * 0.5 + pad + label_size;
let label_node = Node::with_mark(Mark::Text(TextMark {
position: [x, y_label],
text: label,
font: FontStyle {
family: TREEMAP_FONT.into(),
size: label_size,
weight: 600,
italic: false,
},
fill: FillStyle::Solid(text_color),
angle: 0.0,
anchor: TextAnchor::Start,
}))
.z_order(3);
scene.insert_child(plot_id, label_node);
if h > label_size + value_size + pad * 2.0 + 4.0 {
let value = layer.y_data.get(cell.index).copied().unwrap_or(0.0);
let pct = if total > 0.0 {
value / total * 100.0
} else {
0.0
};
#[allow(clippy::float_cmp)] let value_text = if value == value.round() {
format!("{} ({:.1}%)", value as i64, pct)
} else {
format!("{value:.1} ({pct:.1}%)")
};
let y_value = y_label + value_size + 3.0;
let value_node = Node::with_mark(Mark::Text(TextMark {
position: [x, y_value],
text: value_text,
font: FontStyle {
family: TREEMAP_FONT.into(),
size: value_size,
weight: 400,
italic: false,
},
fill: FillStyle::Solid(text_color_muted),
angle: 0.0,
anchor: TextAnchor::Start,
}))
.z_order(3);
scene.insert_child(plot_id, value_node);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compile::stat_transform::ResolvedLayer;
use crate::grammar::position::Position;
use esoc_scene::bounds::DataBounds;
fn make_resolved(mark: MarkType, x: Vec<f64>, y: Vec<f64>, idx: usize) -> ResolvedLayer {
ResolvedLayer {
mark,
x_data: x,
y_data: y,
categories: None,
y_baseline: None,
boxplot: None,
inner_radius_fraction: 0.0,
position: Position::default(),
is_binned: false,
facet_values: None,
layer_idx: idx,
heatmap_data: None,
row_labels: None,
col_labels: None,
annotate_cells: false,
label: None,
dodge_width: None,
error_bars: None,
}
}
fn count_non_container(scene: &SceneGraph) -> usize {
scene
.iter()
.filter(|(_, n)| !matches!(n.content, esoc_scene::node::NodeContent::Container))
.count()
}
#[test]
fn point_batch_generated() {
let mut scene = SceneGraph::with_root();
let root = scene.root().unwrap();
let plot_id = scene.insert_child(root, Node::container());
let layer = make_resolved(MarkType::Point, vec![0.0, 1.0, 2.0], vec![3.0, 4.0, 5.0], 0);
let bounds = DataBounds::new(0.0, 2.0, 0.0, 5.0);
let theme = NewTheme::default();
generate_layer_marks(
&mut scene, plot_id, &layer, &bounds, 400.0, 300.0, &theme, 1,
)
.unwrap();
assert!(count_non_container(&scene) >= 1);
}
#[test]
fn bar_rects_generated() {
let mut scene = SceneGraph::with_root();
let root = scene.root().unwrap();
let plot_id = scene.insert_child(root, Node::container());
let layer = make_resolved(
MarkType::Bar,
vec![0.0, 1.0, 2.0],
vec![10.0, 20.0, 30.0],
0,
);
let bounds = DataBounds::new(0.0, 2.0, 0.0, 30.0);
let theme = NewTheme::default();
generate_layer_marks(
&mut scene, plot_id, &layer, &bounds, 400.0, 300.0, &theme, 1,
)
.unwrap();
assert!(count_non_container(&scene) >= 1);
}
#[test]
fn line_mark_generated() {
let mut scene = SceneGraph::with_root();
let root = scene.root().unwrap();
let plot_id = scene.insert_child(root, Node::container());
let layer = make_resolved(MarkType::Line, vec![0.0, 1.0, 2.0], vec![1.0, 2.0, 3.0], 0);
let bounds = DataBounds::new(0.0, 2.0, 0.0, 3.0);
let theme = NewTheme::default();
generate_layer_marks(
&mut scene, plot_id, &layer, &bounds, 400.0, 300.0, &theme, 1,
)
.unwrap();
assert!(count_non_container(&scene) >= 1);
}
#[test]
fn arc_marks_generated() {
let mut scene = SceneGraph::with_root();
let root = scene.root().unwrap();
let plot_id = scene.insert_child(root, Node::container());
let mut layer = make_resolved(MarkType::Arc, vec![], vec![30.0, 50.0, 20.0], 0);
layer.inner_radius_fraction = 0.0;
let bounds = DataBounds::new(0.0, 1.0, 0.0, 1.0);
let theme = NewTheme::default();
generate_layer_marks(
&mut scene, plot_id, &layer, &bounds, 400.0, 300.0, &theme, 1,
)
.unwrap();
assert_eq!(count_non_container(&scene), 3);
}
#[test]
fn heatmap_rects_generated() {
let mut scene = SceneGraph::with_root();
let root = scene.root().unwrap();
let plot_id = scene.insert_child(root, Node::container());
let mut layer = make_resolved(MarkType::Heatmap, vec![], vec![], 0);
layer.heatmap_data = Some(vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]]);
let bounds = DataBounds::new(-0.5, 2.5, -0.5, 1.5);
let theme = NewTheme::default();
generate_layer_marks(
&mut scene, plot_id, &layer, &bounds, 300.0, 200.0, &theme, 1,
)
.unwrap();
assert!(count_non_container(&scene) >= 1);
}
#[test]
fn heatmap_with_annotations() {
let mut scene = SceneGraph::with_root();
let root = scene.root().unwrap();
let plot_id = scene.insert_child(root, Node::container());
let mut layer = make_resolved(MarkType::Heatmap, vec![], vec![], 0);
layer.heatmap_data = Some(vec![vec![1.0, 2.0], vec![3.0, 4.0]]);
layer.annotate_cells = true;
let bounds = DataBounds::new(-0.5, 1.5, -0.5, 1.5);
let theme = NewTheme::default();
generate_layer_marks(
&mut scene, plot_id, &layer, &bounds, 200.0, 200.0, &theme, 1,
)
.unwrap();
assert!(count_non_container(&scene) >= 5);
}
#[test]
fn treemap_marks_generated() {
let mut scene = SceneGraph::with_root();
let root = scene.root().unwrap();
let plot_id = scene.insert_child(root, Node::container());
let mut layer = make_resolved(MarkType::Treemap, vec![], vec![30.0, 20.0, 10.0], 0);
layer.categories = Some(vec!["A".into(), "B".into(), "C".into()]);
let bounds = DataBounds::new(0.0, 1.0, 0.0, 1.0);
let theme = NewTheme::default();
generate_layer_marks(
&mut scene, plot_id, &layer, &bounds, 400.0, 300.0, &theme, 1,
)
.unwrap();
assert!(count_non_container(&scene) >= 1);
}
#[test]
fn treemap_empty_data() {
let mut scene = SceneGraph::with_root();
let root = scene.root().unwrap();
let plot_id = scene.insert_child(root, Node::container());
let layer = make_resolved(MarkType::Treemap, vec![], vec![], 0);
let bounds = DataBounds::new(0.0, 1.0, 0.0, 1.0);
let theme = NewTheme::default();
generate_layer_marks(
&mut scene, plot_id, &layer, &bounds, 400.0, 300.0, &theme, 1,
)
.unwrap();
assert_eq!(count_non_container(&scene), 0);
}
#[test]
fn area_mark_generated() {
let mut scene = SceneGraph::with_root();
let root = scene.root().unwrap();
let plot_id = scene.insert_child(root, Node::container());
let layer = make_resolved(MarkType::Area, vec![0.0, 1.0, 2.0], vec![1.0, 3.0, 2.0], 0);
let bounds = DataBounds::new(0.0, 2.0, 0.0, 3.0);
let theme = NewTheme::default();
generate_layer_marks(
&mut scene, plot_id, &layer, &bounds, 400.0, 300.0, &theme, 1,
)
.unwrap();
assert!(count_non_container(&scene) >= 1);
}
}