use zenith_core::{Diagnostic, FontStyle};
use zenith_layout::{ShapeRequest, TextDirection, TextLayoutEngine};
use crate::ir::{Color, Paint, SceneCommand};
use super::super::NodeCtx;
use super::super::text::run_to_scene_glyphs;
const PAD_L: f64 = 10.0;
const PAD_R: f64 = 10.0;
const SWATCH: f64 = 11.0;
const GAP: f64 = 6.0;
const LINE_H: f64 = 18.0;
const FONT: f32 = 10.0;
const LEGEND_TEXT_COLOR: Color = Color::srgb(60, 60, 60, 255);
const ENTRY_GAP: f64 = 16.0;
const PAD_V: f64 = 8.0;
#[derive(Clone, Copy)]
pub(super) struct LegendArea {
pub(super) x: f64,
pub(super) y: f64,
pub(super) w: f64,
pub(super) h: f64,
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub(super) enum LegendPosition {
Left,
Right,
Top,
Bottom,
}
impl LegendPosition {
pub(super) fn from_opt(s: Option<&str>) -> Self {
match s {
Some("left") => Self::Left,
Some("top") => Self::Top,
Some("bottom") => Self::Bottom,
_ => Self::Right,
}
}
pub(super) fn is_side(self) -> bool {
matches!(self, Self::Left | Self::Right)
}
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub(super) enum LegendLayout {
List,
Wrapped,
}
impl LegendLayout {
pub(super) fn from_opt(s: Option<&str>) -> Self {
match s {
Some("list") => Self::List,
_ => Self::Wrapped,
}
}
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub(super) enum LegendAlign {
Start,
Center,
End,
}
impl LegendAlign {
pub(super) fn from_opt(s: Option<&str>) -> Self {
match s {
Some("left") => Self::Start,
Some("right") => Self::End,
_ => Self::Center,
}
}
}
#[derive(Clone, Copy)]
pub(super) struct LegendConfig {
pub(super) position: LegendPosition,
pub(super) layout: LegendLayout,
pub(super) align: LegendAlign,
}
pub(super) fn legend_width_from_advance(max_advance: f64) -> f64 {
PAD_L + SWATCH + GAP + max_advance + PAD_R
}
fn entry_advances(entries: &[(String, Color)], cx: NodeCtx<'_>) -> Vec<f64> {
let families = [String::from("Noto Sans")];
entries
.iter()
.map(|(label, _)| {
let req = ShapeRequest {
text: label,
families: &families,
weight: 400,
style: FontStyle::Normal,
font_size: FONT,
direction: TextDirection::Ltr,
};
match cx.engine.shape_with_fallback(&req, cx.fonts) {
Ok(result) => result.runs.iter().map(|r| r.advance_width as f64).sum(),
Err(_) => 0.0,
}
})
.collect()
}
fn entry_content_w(advance: f64) -> f64 {
SWATCH + GAP + advance
}
pub(super) fn legend_reserve(
entries: &[(String, Color)],
config: LegendConfig,
avail_w: f64,
cx: NodeCtx<'_>,
) -> (f64, f64) {
if entries.is_empty() {
return (0.0, 0.0);
}
if config.position.is_side() {
let advances = entry_advances(entries, cx);
let max_advance = advances.into_iter().fold(0.0_f64, f64::max);
return (legend_width_from_advance(max_advance), 0.0);
}
let height = match config.layout {
LegendLayout::List => entries.len() as f64 * LINE_H + 2.0 * PAD_V,
LegendLayout::Wrapped => {
let advances = entry_advances(entries, cx);
let rows = wrapped_row_count(&advances, avail_w);
rows as f64 * LINE_H + 2.0 * PAD_V
}
};
(0.0, height)
}
fn wrapped_row_count(advances: &[f64], avail_w: f64) -> usize {
let row_avail = (avail_w - 2.0 * PAD_L).max(1.0);
let mut rows: usize = 1;
let mut cur: f64 = 0.0;
for &adv in advances {
let cw = entry_content_w(adv);
if cur > 0.0 && cur + ENTRY_GAP + cw > row_avail {
rows += 1;
cur = cw;
} else if cur > 0.0 {
cur += ENTRY_GAP + cw;
} else {
cur = cw;
}
}
rows.max(1)
}
fn wrapped_rows(advances: &[f64], avail_w: f64) -> Vec<Vec<usize>> {
let row_avail = (avail_w - 2.0 * PAD_L).max(1.0);
let mut rows: Vec<Vec<usize>> = Vec::new();
let mut cur_row: Vec<usize> = Vec::new();
let mut cur: f64 = 0.0;
for (i, &adv) in advances.iter().enumerate() {
let cw = entry_content_w(adv);
if cur > 0.0 && cur + ENTRY_GAP + cw > row_avail {
rows.push(cur_row);
cur_row = vec![i];
cur = cw;
} else if cur > 0.0 {
cur += ENTRY_GAP + cw;
cur_row.push(i);
} else {
cur = cw;
cur_row.push(i);
}
}
if !cur_row.is_empty() {
rows.push(cur_row);
}
if rows.is_empty() {
rows.push(Vec::new());
}
rows
}
#[derive(Clone, Copy)]
struct DrawCtx<'a> {
families: &'a [String],
cx: NodeCtx<'a>,
}
fn draw_entry(
swatch_x: f64,
line_top: f64,
label: &str,
color: Color,
dctx: DrawCtx<'_>,
commands: &mut Vec<SceneCommand>,
diagnostics: &mut Vec<Diagnostic>,
) {
let cx = dctx.cx;
let swatch_y = line_top + (LINE_H - SWATCH) / 2.0;
commands.push(SceneCommand::FillRect {
x: swatch_x,
y: swatch_y,
w: SWATCH,
h: SWATCH,
paint: Paint::solid(color),
});
let req = ShapeRequest {
text: label,
families: dctx.families,
weight: 400,
style: FontStyle::Normal,
font_size: FONT,
direction: TextDirection::Ltr,
};
match cx.engine.shape_with_fallback(&req, cx.fonts) {
Err(e) => {
diagnostics.push(Diagnostic::advisory(
"scene.text_unshaped",
format!(
"chart legend label '{}' could not be shaped: {}",
label, e.message
),
None,
None,
));
}
Ok(result) => {
let ascent: f64 = result.runs.first().map(|r| r.ascent as f64).unwrap_or(8.0);
let baseline_y = line_top + LINE_H / 2.0 + ascent * 0.35;
let mut text_x = swatch_x + SWATCH + GAP;
for run in result.runs {
let advance = run.advance_width as f64;
let glyphs = run_to_scene_glyphs(&run);
commands.push(SceneCommand::DrawGlyphRun {
x: text_x,
y: baseline_y,
font_id: run.font_id,
font_size: run.font_size,
color: LEGEND_TEXT_COLOR,
stroke_color: None,
stroke_width: None,
link: None,
selectable: true,
glyphs,
});
text_x += advance;
}
}
}
}
fn align_x(align: LegendAlign, block_w: f64, left_edge: f64, right_edge: f64) -> f64 {
let x = match align {
LegendAlign::Start => left_edge,
LegendAlign::Center => left_edge + (right_edge - left_edge - block_w) / 2.0,
LegendAlign::End => right_edge - block_w,
};
x.max(left_edge)
}
pub(super) fn emit_legend(
entries: &[(String, Color)],
area: LegendArea,
config: LegendConfig,
cx: NodeCtx<'_>,
commands: &mut Vec<SceneCommand>,
diagnostics: &mut Vec<Diagnostic>,
) {
if area.w <= 0.0 || area.h <= 0.0 || entries.is_empty() {
return;
}
let area_bottom = area.y + area.h;
let left_edge = area.x + PAD_L;
let right_edge = area.x + area.w - PAD_R;
if config.position.is_side() {
emit_legend_side(entries, area, area_bottom, cx, commands, diagnostics);
} else {
emit_legend_band(
entries,
area,
config,
BandGeom {
area_bottom,
left_edge,
right_edge,
},
cx,
commands,
diagnostics,
);
}
}
fn emit_legend_side(
entries: &[(String, Color)],
area: LegendArea,
area_bottom: f64,
cx: NodeCtx<'_>,
commands: &mut Vec<SceneCommand>,
diagnostics: &mut Vec<Diagnostic>,
) {
let n = entries.len() as f64;
let total_h = n * LINE_H;
let start_y = (area.y + (area.h - total_h) / 2.0).max(area.y);
let swatch_x = area.x + PAD_L;
let families = [String::from("Noto Sans")];
let dctx = DrawCtx {
families: &families,
cx,
};
for (i, (label, color)) in entries.iter().enumerate() {
let line_top = start_y + i as f64 * LINE_H;
if line_top >= area_bottom {
break;
}
draw_entry(
swatch_x,
line_top,
label,
*color,
dctx,
commands,
diagnostics,
);
}
}
struct BandGeom {
area_bottom: f64,
left_edge: f64,
right_edge: f64,
}
fn emit_legend_band(
entries: &[(String, Color)],
area: LegendArea,
config: LegendConfig,
geom: BandGeom,
cx: NodeCtx<'_>,
commands: &mut Vec<SceneCommand>,
diagnostics: &mut Vec<Diagnostic>,
) {
let BandGeom {
area_bottom,
left_edge,
right_edge,
} = geom;
let advances = entry_advances(entries, cx);
let families = [String::from("Noto Sans")];
let dctx = DrawCtx {
families: &families,
cx,
};
match config.layout {
LegendLayout::List => {
let block_w = advances
.iter()
.map(|&a| entry_content_w(a))
.fold(0.0_f64, f64::max);
let total_h = entries.len() as f64 * LINE_H;
let start_y = (area.y + (area.h - total_h) / 2.0).max(area.y);
let block_x = align_x(config.align, block_w, left_edge, right_edge);
for (i, (label, color)) in entries.iter().enumerate() {
let line_top = start_y + i as f64 * LINE_H;
if line_top >= area_bottom {
break;
}
draw_entry(
block_x,
line_top,
label,
*color,
dctx,
commands,
diagnostics,
);
}
}
LegendLayout::Wrapped => {
let rows = wrapped_rows(&advances, area.w);
let total_rows_h = rows.len() as f64 * LINE_H;
let start_y = (area.y + (area.h - total_rows_h) / 2.0).max(area.y);
for (row_idx, row_indices) in rows.iter().enumerate() {
let line_top = start_y + row_idx as f64 * LINE_H;
if line_top >= area_bottom {
break;
}
let row_w: f64 = row_indices.iter().enumerate().fold(0.0, |acc, (j, &ei)| {
let cw = entry_content_w(advances.get(ei).copied().unwrap_or(0.0));
if j == 0 {
acc + cw
} else {
acc + ENTRY_GAP + cw
}
});
let row_x0 = align_x(config.align, row_w, left_edge, right_edge);
let mut x = row_x0;
for (j, &ei) in row_indices.iter().enumerate() {
if j > 0 {
x += ENTRY_GAP;
}
let (label, color) = match entries.get(ei) {
Some(e) => e,
None => continue,
};
draw_entry(x, line_top, label, *color, dctx, commands, diagnostics);
x += entry_content_w(advances.get(ei).copied().unwrap_or(0.0));
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn width_from_advance_zero() {
let expected = PAD_L + SWATCH + GAP + PAD_R;
let got = legend_width_from_advance(0.0);
assert!(
(got - expected).abs() < 1e-9,
"expected {expected}, got {got}"
);
}
#[test]
fn width_from_advance_nonzero() {
let advance = 42.5;
let expected = PAD_L + SWATCH + GAP + advance + PAD_R;
let got = legend_width_from_advance(advance);
assert!(
(got - expected).abs() < 1e-9,
"expected {expected}, got {got}"
);
}
#[test]
fn position_from_opt_known() {
assert_eq!(LegendPosition::from_opt(Some("left")), LegendPosition::Left);
assert_eq!(
LegendPosition::from_opt(Some("right")),
LegendPosition::Right
);
assert_eq!(LegendPosition::from_opt(Some("top")), LegendPosition::Top);
assert_eq!(
LegendPosition::from_opt(Some("bottom")),
LegendPosition::Bottom
);
}
#[test]
fn position_from_opt_default() {
assert_eq!(LegendPosition::from_opt(None), LegendPosition::Right);
assert_eq!(
LegendPosition::from_opt(Some("unknown")),
LegendPosition::Right
);
assert_eq!(LegendPosition::from_opt(Some("")), LegendPosition::Right);
}
#[test]
fn position_is_side() {
assert!(LegendPosition::Left.is_side());
assert!(LegendPosition::Right.is_side());
assert!(!LegendPosition::Top.is_side());
assert!(!LegendPosition::Bottom.is_side());
}
#[test]
fn layout_from_opt_known() {
assert_eq!(LegendLayout::from_opt(Some("list")), LegendLayout::List);
assert_eq!(
LegendLayout::from_opt(Some("wrapped")),
LegendLayout::Wrapped
);
}
#[test]
fn layout_from_opt_default() {
assert_eq!(LegendLayout::from_opt(None), LegendLayout::Wrapped);
assert_eq!(
LegendLayout::from_opt(Some("unknown")),
LegendLayout::Wrapped
);
}
#[test]
fn align_from_opt_known() {
assert_eq!(LegendAlign::from_opt(Some("left")), LegendAlign::Start);
assert_eq!(LegendAlign::from_opt(Some("right")), LegendAlign::End);
assert_eq!(LegendAlign::from_opt(Some("center")), LegendAlign::Center);
}
#[test]
fn align_from_opt_default() {
assert_eq!(LegendAlign::from_opt(None), LegendAlign::Center);
assert_eq!(LegendAlign::from_opt(Some("unknown")), LegendAlign::Center);
}
#[test]
fn align_x_start() {
let x = align_x(LegendAlign::Start, 50.0, 10.0, 200.0);
assert!((x - 10.0).abs() < 1e-9, "start: expected 10, got {x}");
}
#[test]
fn align_x_center() {
let x = align_x(LegendAlign::Center, 40.0, 10.0, 110.0);
assert!((x - 40.0).abs() < 1e-9, "center: expected 40, got {x}");
}
#[test]
fn align_x_end() {
let x = align_x(LegendAlign::End, 40.0, 10.0, 110.0);
assert!((x - 70.0).abs() < 1e-9, "end: expected 70, got {x}");
}
#[test]
fn align_x_clamps_to_left_edge() {
let x = align_x(LegendAlign::End, 300.0, 10.0, 110.0);
assert!((x - 10.0).abs() < 1e-9, "clamp: expected 10, got {x}");
}
#[test]
fn wrapped_row_count_single_row() {
let advances = vec![20.0, 20.0, 20.0];
let rows = wrapped_row_count(&advances, 200.0);
assert_eq!(rows, 1, "expected 1 row, got {rows}");
}
#[test]
fn wrapped_row_count_wraps() {
let advances = vec![200.0, 200.0];
let rows = wrapped_row_count(&advances, 100.0);
assert_eq!(rows, 2, "expected 2 rows, got {rows}");
}
#[test]
fn wrapped_row_count_empty() {
let rows = wrapped_row_count(&[], 200.0);
assert_eq!(rows, 1, "empty advances: expected min-1 row, got {rows}");
}
}