use super::common::{OUTER_PAD, TITLE_FONT};
use crate::ir::{ChartKind, ChartSpec, Color};
use crate::num::fmt_num;
use crate::scene::{Anchor, Prim, Scene};
use crate::text::TextMeasurer;
use std::f64::consts::PI;
const CENTER_VALUE_FONT_SCALE: f64 = 1.6;
const CENTER_VALUE_RADIUS_RATIO: f64 = 0.45;
const SEMI_CUTOUT_RATIO: f64 = 0.5;
const NEEDLE_LEN_RATIO: f64 = 0.8;
const NEEDLE_HALF_WIDTH_RATIO: f64 = 0.032;
const NEEDLE_PIVOT_RATIO: f64 = 0.04;
const LABEL_PAD: f64 = 5.0;
pub fn build(spec: &ChartSpec, m: &TextMeasurer) -> Scene {
let ink = spec.theme.text_color;
let mut items: Vec<Prim> = Vec::new();
let title_band = if spec.title.is_some() {
super::common::TITLE_BAND
} else {
0.0
};
if let Some(title) = &spec.title {
items.push(Prim::Text {
x: spec.width / 2.0,
y: OUTER_PAD + TITLE_FONT,
size: TITLE_FONT,
anchor: Anchor::Middle,
fill: ink,
content: title.clone(),
rotate_deg: None,
});
}
match &spec.kind {
ChartKind::RadialGauge {
min,
max,
track,
inner_ratio,
rounded,
display_text,
center_font_size,
} => build_radial(
&mut items,
spec,
title_band,
*min,
*max,
*track,
*inner_ratio,
*rounded,
*display_text,
*center_font_size,
),
ChartKind::Gauge {
value,
min,
needle,
label,
label_color,
label_bg,
} => build_semi(
&mut items,
spec,
m,
title_band,
*value,
*min,
*needle,
*label,
*label_color,
*label_bg,
),
_ => {}
}
Scene {
width: spec.width,
height: spec.height,
items,
}
}
fn area_geom(spec: &ChartSpec, title_band: f64) -> (f64, f64, f64) {
let area_top = OUTER_PAD + title_band;
let area_bottom = spec.height - OUTER_PAD;
let area_left = OUTER_PAD;
let area_right = spec.width - OUTER_PAD;
let cx = (area_left + area_right) / 2.0;
let cy = (area_top + area_bottom) / 2.0;
let r = ((area_right - area_left).min(area_bottom - area_top) / 2.0 * 0.9).max(0.0);
(cx, cy, r)
}
#[allow(clippy::too_many_arguments)]
fn build_radial(
items: &mut Vec<Prim>,
spec: &ChartSpec,
title_band: f64,
min: f64,
max: f64,
track: Color,
inner_ratio: f64,
rounded: bool,
display_text: bool,
center_font_size: Option<f64>,
) {
let (cx, cy, r_outer) = area_geom(spec, title_band);
let r_inner = (r_outer * inner_ratio).clamp(0.0, r_outer);
if r_outer <= 0.0 {
return;
}
let fill = spec.series.first().map(|s| s.fill_at(0)).unwrap_or(track);
let value = spec
.series
.first()
.and_then(|s| s.values.first().copied())
.unwrap_or(min);
let frac = if (max - min).abs() > f64::EPSILON {
((value - min) / (max - min)).clamp(0.0, 1.0)
} else {
0.0
};
let start = -PI / 2.0; let mid = start + PI;
items.push(Prim::Path {
d: ring_segment_path(cx, cy, r_outer, r_inner, start, mid),
fill: Some(track),
stroke: None,
stroke_width: 0.0,
});
items.push(Prim::Path {
d: ring_segment_path(cx, cy, r_outer, r_inner, mid, start + 2.0 * PI),
fill: Some(track),
stroke: None,
stroke_width: 0.0,
});
if frac > 0.0 {
let end = start + frac * 2.0 * PI;
if frac >= 1.0 - 1e-9 {
let amid = start + PI;
push_value_arc(items, cx, cy, r_outer, r_inner, start, amid, fill, false);
push_value_arc(
items,
cx,
cy,
r_outer,
r_inner,
amid,
start + 2.0 * PI,
fill,
false,
);
} else {
push_value_arc(items, cx, cy, r_outer, r_inner, start, end, fill, rounded);
}
}
if display_text {
let size = center_font_size.unwrap_or_else(|| {
(r_inner * CENTER_VALUE_RADIUS_RATIO)
.max(spec.theme.font_size * CENTER_VALUE_FONT_SCALE)
});
items.push(Prim::Text {
x: cx,
y: cy + size * super::common::TEXT_BASELINE_RATIO,
size,
anchor: Anchor::Middle,
fill: spec.theme.text_color,
content: fmt_num(value.round()),
rotate_deg: None,
});
}
}
#[allow(clippy::too_many_arguments)]
fn push_value_arc(
items: &mut Vec<Prim>,
cx: f64,
cy: f64,
r_outer: f64,
r_inner: f64,
a0: f64,
a1: f64,
fill: Color,
rounded: bool,
) {
items.push(Prim::Path {
d: ring_segment_path(cx, cy, r_outer, r_inner, a0, a1),
fill: Some(fill),
stroke: None,
stroke_width: 0.0,
});
if rounded {
let cap_r = (r_outer - r_inner) / 2.0;
let mid_r = (r_outer + r_inner) / 2.0;
for a in [a0, a1] {
items.push(Prim::Circle {
cx: cx + mid_r * a.cos(),
cy: cy + mid_r * a.sin(),
r: cap_r.max(0.0),
fill,
stroke: fill,
stroke_width: 0.0,
});
}
}
}
#[allow(clippy::too_many_arguments)]
fn build_semi(
items: &mut Vec<Prim>,
spec: &ChartSpec,
m: &TextMeasurer,
title_band: f64,
value: f64,
min: f64,
needle: Color,
label: bool,
label_color: Color,
label_bg: Color,
) {
let font = spec.theme.font_size;
let label_band = if label {
font + LABEL_PAD * 2.0 + 4.0
} else {
0.0
};
let area_top = OUTER_PAD + title_band;
let area_bottom = spec.height - OUTER_PAD;
let area_left = OUTER_PAD;
let area_right = spec.width - OUTER_PAD;
let cx = (area_left + area_right) / 2.0;
let cy = area_bottom - label_band;
let r_outer = (((area_right - area_left) / 2.0).min(cy - area_top) * 0.9).max(0.0);
let r_inner = r_outer * SEMI_CUTOUT_RATIO; if r_outer <= 0.0 {
return;
}
let series = spec.series.first();
let thresholds: &[f64] = series.map(|s| s.values.as_slice()).unwrap_or(&[]);
if thresholds.is_empty() {
return;
}
let max = thresholds
.iter()
.rev()
.find(|v| v.is_finite())
.copied()
.unwrap_or(min + 1.0);
let range = if (max - min).abs() > f64::EPSILON {
max - min
} else {
1.0
};
let angle = |frac: f64| PI + frac.clamp(0.0, 1.0) * PI;
let mut lo = min;
for (i, &thr) in thresholds.iter().enumerate() {
if !thr.is_finite() {
continue;
}
let hi = thr;
let a0 = angle((lo - min) / range);
let a1 = angle((hi - min) / range);
if a1 > a0 {
let fill = series.map(|s| s.fill_at(i)).unwrap_or(needle);
items.push(Prim::Path {
d: ring_segment_path(cx, cy, r_outer, r_inner, a0, a1),
fill: Some(fill),
stroke: None,
stroke_width: 0.0,
});
}
lo = hi;
}
let value = if value.is_finite() { value } else { min };
let va = angle((value - min) / range);
let needle_len = r_outer * NEEDLE_LEN_RATIO;
let tip = (cx + needle_len * va.cos(), cy + needle_len * va.sin());
let half_w = (r_outer * NEEDLE_HALF_WIDTH_RATIO).max(1.5); let perp = va + PI / 2.0;
let base1 = (cx + half_w * perp.cos(), cy + half_w * perp.sin());
let base2 = (cx - half_w * perp.cos(), cy - half_w * perp.sin());
items.push(Prim::Path {
d: format!(
"M {} {} L {} {} L {} {} Z",
fmt_num(tip.0),
fmt_num(tip.1),
fmt_num(base1.0),
fmt_num(base1.1),
fmt_num(base2.0),
fmt_num(base2.1),
),
fill: Some(needle),
stroke: None,
stroke_width: 0.0,
});
items.push(Prim::Circle {
cx,
cy,
r: (r_outer * NEEDLE_PIVOT_RATIO).max(2.0), fill: needle,
stroke: needle,
stroke_width: 0.0,
});
if label {
let text = fmt_num(value.round());
let text_w = m.width(&text, font as f32) as f64;
let max_box_w = (area_right - area_left).max(0.0);
let box_w = (text_w + LABEL_PAD * 2.0).min(max_box_w);
let box_h = font + LABEL_PAD * 2.0;
let box_x = (cx - box_w / 2.0).clamp(area_left, (area_right - box_w).max(area_left));
let box_y = cy + (label_band - box_h) / 2.0; items.push(Prim::Path {
d: crate::layout::progress::rounded_rect_path(box_x, box_y, box_w, box_h, 5.0),
fill: Some(label_bg),
stroke: None,
stroke_width: 0.0,
});
items.push(Prim::Text {
x: box_x + box_w / 2.0,
y: box_y + box_h / 2.0 + font * super::common::TEXT_BASELINE_RATIO,
size: font,
anchor: Anchor::Middle,
fill: label_color,
content: text,
rotate_deg: None,
});
}
}
fn ring_segment_path(cx: f64, cy: f64, r_outer: f64, r_inner: f64, a0: f64, a1: f64) -> String {
let laf = if (a1 - a0) > PI { 1 } else { 0 };
let o0 = (cx + r_outer * a0.cos(), cy + r_outer * a0.sin());
let o1 = (cx + r_outer * a1.cos(), cy + r_outer * a1.sin());
let i0 = (cx + r_inner * a0.cos(), cy + r_inner * a0.sin());
let i1 = (cx + r_inner * a1.cos(), cy + r_inner * a1.sin());
format!(
"M {} {} A {} {} 0 {} 1 {} {} L {} {} A {} {} 0 {} 0 {} {} Z",
fmt_num(o0.0),
fmt_num(o0.1),
fmt_num(r_outer),
fmt_num(r_outer),
laf,
fmt_num(o1.0),
fmt_num(o1.1),
fmt_num(i1.0),
fmt_num(i1.1),
fmt_num(r_inner),
fmt_num(r_inner),
laf,
fmt_num(i0.0),
fmt_num(i0.1),
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::PI;
#[test]
fn ring_segment_path_is_closed_and_clean() {
let d = ring_segment_path(100.0, 100.0, 80.0, 40.0, -PI / 2.0, 0.0);
assert!(d.starts_with('M'), "must start with moveto: {d}");
assert!(d.ends_with('Z'), "must close: {d}");
assert!(!d.contains("NaN") && !d.contains("inf"), "{d}");
}
#[test]
fn ring_segment_path_uses_standalone_command_tokens() {
let d = ring_segment_path(100.0, 100.0, 80.0, 40.0, -PI / 2.0, PI / 2.0);
let tokens: Vec<&str> = d.split_ascii_whitespace().collect();
assert!(tokens.contains(&"M"), "{d}");
assert_eq!(tokens.iter().filter(|t| **t == "A").count(), 2, "{d}");
assert_eq!(tokens.iter().filter(|t| **t == "Z").count(), 1, "{d}");
}
#[test]
fn ring_segment_path_deterministic() {
let a = ring_segment_path(1.0, 2.0, 50.0, 25.0, 0.0, 1.0);
let b = ring_segment_path(1.0, 2.0, 50.0, 25.0, 0.0, 1.0);
assert_eq!(a, b);
}
}