use super::common;
use crate::ir::{ChartSpec, Color, LegendPos};
use crate::num::fmt_num;
use crate::scale::nice_ticks;
use crate::scene::{Anchor, Prim, Scene};
use crate::text::TextMeasurer;
use std::f64::consts::PI;
use std::fmt::Write;
const MARKER_R: f64 = 3.0;
const RADIUS_RATIO: f64 = 0.8;
const LABEL_OFFSET: f64 = 12.0;
pub fn build(spec: &ChartSpec, m: &TextMeasurer) -> Scene {
let mut items: Vec<Prim> = Vec::new();
let ink = spec.theme.text_color;
let label_font = spec.theme.font_size;
let has_legend = matches!(
spec.legend,
LegendPos::Top | LegendPos::Bottom | LegendPos::Left | LegendPos::Right
) && spec.series.iter().any(|s| !s.name.is_empty());
let title_band = if spec.title.is_some() {
common::TITLE_BAND
} else {
0.0
};
if let Some(title) = &spec.title {
items.push(Prim::Text {
x: spec.width / 2.0,
y: common::OUTER_PAD + common::TITLE_FONT,
size: common::TITLE_FONT,
anchor: Anchor::Middle,
fill: ink,
content: title.clone(),
});
}
let legend_top = if has_legend && spec.legend == LegendPos::Top {
common::LEGEND_BAND
} else {
0.0
};
let legend_bottom = if has_legend && spec.legend == LegendPos::Bottom {
common::LEGEND_BAND
} else {
0.0
};
let series_names: Vec<String> = spec.series.iter().map(|s| s.name.clone()).collect();
let legend_left = if has_legend && spec.legend == LegendPos::Left {
common::legend_band_width_vertical(m, &series_names, label_font)
} else {
0.0
};
let legend_right = if has_legend && spec.legend == LegendPos::Right {
common::legend_band_width_vertical(m, &series_names, label_font)
} else {
0.0
};
if has_legend && matches!(spec.legend, LegendPos::Top | LegendPos::Bottom) {
let mut total = 0.0_f64;
let n = spec.series.len();
for (k, ser) in spec.series.iter().enumerate() {
total += common::legend_entry_width(m, &ser.name, label_font);
if k == n - 1 {
total -= 16.0;
}
}
let start_x = (spec.width - total) / 2.0;
let legend_cy = if spec.legend == LegendPos::Top {
common::OUTER_PAD + title_band + common::LEGEND_BAND / 2.0
} else {
spec.height - common::OUTER_PAD - common::LEGEND_BAND / 2.0
};
let mut cursor = start_x;
for ser in &spec.series {
items.push(Prim::Rect {
x: cursor,
y: legend_cy - 6.0,
w: 12.0,
h: 12.0,
fill: ser.fill_at(0),
});
items.push(Prim::Text {
x: cursor + 16.0,
y: legend_cy + label_font * common::TEXT_BASELINE_RATIO,
size: label_font,
anchor: Anchor::Start,
fill: ink,
content: ser.name.clone(),
});
cursor += common::legend_entry_width(m, &ser.name, label_font);
}
}
if has_legend && matches!(spec.legend, LegendPos::Left | LegendPos::Right) {
let entries: Vec<(String, Color)> = spec
.series
.iter()
.map(|s| (s.name.clone(), s.fill_at(0)))
.collect();
let band_w = if spec.legend == LegendPos::Left {
legend_left
} else {
legend_right
};
let band_x = if spec.legend == LegendPos::Left {
common::OUTER_PAD
} else {
spec.width - common::OUTER_PAD - band_w
};
let area_top = common::OUTER_PAD + title_band + legend_top;
let area_bottom = spec.height - common::OUTER_PAD - legend_bottom;
common::draw_vertical_legend(
&mut items,
&entries,
band_x,
area_top,
area_bottom,
ink,
label_font,
);
}
let area_top = common::OUTER_PAD + title_band + legend_top;
let area_bottom = spec.height - common::OUTER_PAD - legend_bottom;
let area_left = common::OUTER_PAD + legend_left;
let area_right = spec.width - common::OUTER_PAD - legend_right;
let cx = (area_left + area_right) / 2.0;
let cy = (area_top + area_bottom) / 2.0;
let radius =
((area_right - area_left).min(area_bottom - area_top) / 2.0 * RADIUS_RATIO).max(0.0);
let n = spec.categories.len();
if n == 0 || radius <= 0.0 {
return Scene {
width: spec.width,
height: spec.height,
items,
};
}
let angle = |i: usize| -PI / 2.0 + i as f64 * (2.0 * PI / n as f64);
let mut max_val = 0.0_f64;
for ser in &spec.series {
for &v in &ser.values {
if v.is_finite() && v >= 0.0 && v > max_val {
max_val = v;
}
}
}
let nice = nice_ticks(0.0, max_val, 10);
let rr = |v: f64| -> f64 {
if nice.max > 0.0 {
(v / nice.max) * radius
} else {
0.0
}
};
for &t in &nice.ticks {
if t <= 0.0 {
continue;
}
let r = rr(t);
let mut d = String::new();
for i in 0..n {
let a = angle(i);
let x = cx + r * a.cos();
let y = cy + r * a.sin();
let cmd = if i == 0 { 'M' } else { 'L' };
write!(d, "{} {} {} ", cmd, fmt_num(x), fmt_num(y)).unwrap();
}
d.push('Z');
items.push(Prim::Path {
d,
fill: None,
stroke: Some(spec.theme.grid_color),
stroke_width: 1.0,
});
}
for i in 0..n {
let a = angle(i);
items.push(Prim::Line {
x1: cx,
y1: cy,
x2: cx + radius * a.cos(),
y2: cy + radius * a.sin(),
stroke: spec.theme.grid_color,
stroke_width: 1.0,
});
}
for ser in &spec.series {
let verts: Vec<(f64, f64)> = (0..n)
.map(|i| {
let v = ser.values.get(i).copied().unwrap_or(0.0);
let v = if v.is_finite() { v } else { 0.0 };
let a = angle(i);
let r = rr(v);
(cx + r * a.cos(), cy + r * a.sin())
})
.collect();
let mut d = String::new();
for (i, (x, y)) in verts.iter().enumerate() {
let cmd = if i == 0 { 'M' } else { 'L' };
write!(d, "{} {} {} ", cmd, fmt_num(*x), fmt_num(*y)).unwrap();
}
d.push('Z');
items.push(Prim::Path {
d,
fill: Some(ser.fill_at(0)),
stroke: Some(ser.stroke_at(0)),
stroke_width: ser.stroke_width,
});
for (x, y) in &verts {
items.push(Prim::Circle {
cx: *x,
cy: *y,
r: MARKER_R,
fill: ser.stroke_at(0),
stroke: ser.stroke_at(0),
stroke_width: 0.0,
});
}
}
let label_r = radius + LABEL_OFFSET;
for (i, cat) in spec.categories.iter().enumerate() {
if cat.is_empty() {
continue;
}
let a = angle(i);
let lx = cx + label_r * a.cos();
let ly = cy + label_r * a.sin();
let c = a.cos();
let anchor = if c > 0.1 {
Anchor::Start
} else if c < -0.1 {
Anchor::End
} else {
Anchor::Middle
};
items.push(Prim::Text {
x: lx,
y: ly + label_font * common::TEXT_BASELINE_RATIO,
size: label_font,
anchor,
fill: ink,
content: cat.clone(),
});
}
Scene {
width: spec.width,
height: spec.height,
items,
}
}