//! Server-side SVG rendering for chart types.
use plotters::prelude::*;
use plotters::style::text_anchor::{HPos, Pos, VPos};
use super::types::ChartType;
// ─── Canvas + dark theme ─────────────────────────────────────────────────────
const W: u32 = 900;
const H: u32 = 430;
const BG: RGBColor = RGBColor(18, 18, 18);
const GRID_CLR: RGBColor = RGBColor(55, 55, 55);
const LABEL_CLR: RGBColor = RGBColor(165, 165, 165);
const SERIES_CLR: RGBColor = RGBColor(224, 112, 0); // orange
const CAPTION_CLR: RGBColor = RGBColor(215, 215, 215);
// ─── Helpers ─────────────────────────────────────────────────────────────────
/// Compact human number formatting for axis ticks: 1234 → "1.2k", 1.5e6 → "1.5M".
fn fmt_num(v: f64) -> String {
if !v.is_finite() {
return String::new();
}
let abs = v.abs();
if abs >= 1e12 {
format!("{:.1}T", v / 1e12)
} else if abs >= 1e9 {
format!("{:.1}B", v / 1e9)
} else if abs >= 1e6 {
format!("{:.1}M", v / 1e6)
} else if abs >= 1e3 {
format!("{:.1}k", v / 1e3)
} else if abs >= 1.0 || abs == 0.0 {
format!("{v:.0}")
} else {
format!("{v:.2}")
}
}
/// UTF-8 safe truncation by character count.
fn truncate_chars(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
return s.to_string();
}
let mut out: String = s.chars().take(max_chars.saturating_sub(1)).collect();
out.push('…');
out
}
/// Plotters writes `<svg width="900" height="430" ...>`. Convert to a viewBox
/// so the chart scales with its container instead of being a fixed pixel block.
fn make_responsive(svg: String) -> String {
let needle = format!("width=\"{W}\" height=\"{H}\"");
let replacement = format!(
"viewBox=\"0 0 {W} {H}\" preserveAspectRatio=\"xMidYMid meet\" \
style=\"width:100%;height:100%;display:block;\""
);
svg.replacen(&needle, &replacement, 1)
}
/// Centered "error" SVG (used for empty data, parse failures, query errors).
pub fn error_svg(msg: &str) -> String {
let mut svg = String::new();
{
let root = SVGBackend::with_string(&mut svg, (W, H)).into_drawing_area();
let _ = root.fill(&BG);
let style = ("sans-serif", 14u32)
.into_font()
.color(&LABEL_CLR)
.pos(Pos::new(HPos::Center, VPos::Center));
#[allow(clippy::cast_possible_wrap)]
let center = (W as i32 / 2, H as i32 / 2);
let _ = root.draw_text(msg, &style, center);
let _ = root.present();
}
make_responsive(svg)
}
// ─── Chart renderers ─────────────────────────────────────────────────────────
fn render_line(data: &[(String, f64)], x_col: &str, y_col: &str) -> String {
if data.is_empty() {
return error_svg("No data returned");
}
let mut svg = String::new();
{
let root = SVGBackend::with_string(&mut svg, (W, H)).into_drawing_area();
let _ = root.fill(&BG);
let y_vals: Vec<f64> = data.iter().map(|(_, y)| *y).collect();
let y_min = y_vals.iter().copied().fold(f64::INFINITY, f64::min);
let y_max = y_vals.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let y_pad = (y_max - y_min).abs() * 0.1 + 1.0;
let labels: Vec<String> = data.iter().map(|(l, _)| l.clone()).collect();
let n = data.len();
let tick_step = (n / 8).max(1);
let caption = format!("{y_col} over {x_col}");
if let Ok(mut chart) = ChartBuilder::on(&root)
.caption(
&caption,
("sans-serif", 14u32).into_font().color(&CAPTION_CLR),
)
.margin(20u32)
.x_label_area_size(50u32)
.y_label_area_size(60u32)
.build_cartesian_2d(0..n, (y_min - y_pad)..(y_max + y_pad))
{
let _ = chart
.configure_mesh()
.x_labels(8)
.y_labels(6)
.axis_style(GRID_CLR)
.x_label_style(("sans-serif", 9u32).into_font().color(&LABEL_CLR))
.y_label_style(("sans-serif", 9u32).into_font().color(&LABEL_CLR))
.x_label_formatter(&|i| {
if i % tick_step == 0 {
truncate_chars(labels.get(*i).map_or("", String::as_str), 12)
} else {
String::new()
}
})
.y_label_formatter(&|v| fmt_num(*v))
.draw();
let _ = chart.draw_series(LineSeries::new(
data.iter().enumerate().map(|(i, (_, y))| (i, *y)),
&SERIES_CLR,
));
}
let _ = root.present();
}
make_responsive(svg)
}
fn render_bar(data: &[(String, f64)], x_col: &str, y_col: &str) -> String {
if data.is_empty() {
return error_svg("No data returned");
}
let mut svg = String::new();
{
let root = SVGBackend::with_string(&mut svg, (W, H)).into_drawing_area();
let _ = root.fill(&BG);
let y_max = data
.iter()
.map(|(_, y)| *y)
.fold(f64::NEG_INFINITY, f64::max);
let y_pad = y_max.abs() * 0.08 + 1.0;
#[allow(clippy::cast_possible_truncation)]
let n = data.len() as u32;
let labels: Vec<String> = data.iter().map(|(l, _)| l.clone()).collect();
// Show at most 20 x-axis labels regardless of bar count, and skip
// every Nth label so they don't overlap on dense bar plots.
let target_ticks = n.clamp(1, 20);
let tick_step = (n / target_ticks).max(1);
let caption = format!("{y_col} by {x_col}");
if let Ok(mut chart) = ChartBuilder::on(&root)
.caption(
&caption,
("sans-serif", 14u32).into_font().color(&CAPTION_CLR),
)
.margin(20u32)
.x_label_area_size(60u32)
.y_label_area_size(60u32)
.build_cartesian_2d((0u32..n).into_segmented(), 0f64..(y_max + y_pad))
{
let _ = chart
.configure_mesh()
.x_labels(target_ticks as usize)
.y_labels(6)
.axis_style(GRID_CLR)
.x_label_style(("sans-serif", 9u32).into_font().color(&LABEL_CLR))
.y_label_style(("sans-serif", 9u32).into_font().color(&LABEL_CLR))
.x_label_formatter(&|v: &SegmentValue<u32>| {
if let SegmentValue::CenterOf(i) = v {
if i % tick_step != 0 {
return String::new();
}
labels
.get(*i as usize)
.map(|s| truncate_chars(s, 12))
.unwrap_or_default()
} else {
String::new()
}
})
.y_label_formatter(&|v| fmt_num(*v))
.draw();
let _ = chart.draw_series(Histogram::vertical(&chart).style(SERIES_CLR.filled()).data(
data.iter().enumerate().map(|(i, (_, y))| {
#[allow(clippy::cast_possible_truncation)]
(i as u32, *y)
}),
));
}
let _ = root.present();
}
make_responsive(svg)
}
fn render_scatter(data: &[(String, f64)], x_col: &str, y_col: &str) -> String {
if data.is_empty() {
return error_svg("No data returned");
}
let points: Vec<(f64, f64)> = data
.iter()
.filter_map(|(x, y)| Some((x.parse::<f64>().ok()?, *y)))
.collect();
if points.is_empty() {
return error_svg("Could not parse x values as numbers");
}
let mut svg = String::new();
{
let root = SVGBackend::with_string(&mut svg, (W, H)).into_drawing_area();
let _ = root.fill(&BG);
let x_min = points.iter().map(|(x, _)| *x).fold(f64::INFINITY, f64::min);
let x_max = points
.iter()
.map(|(x, _)| *x)
.fold(f64::NEG_INFINITY, f64::max);
let y_min = points.iter().map(|(_, y)| *y).fold(f64::INFINITY, f64::min);
let y_max = points
.iter()
.map(|(_, y)| *y)
.fold(f64::NEG_INFINITY, f64::max);
let x_pad = (x_max - x_min).abs() * 0.05 + 1.0;
let y_pad = (y_max - y_min).abs() * 0.05 + 1.0;
let caption = format!("{x_col} vs {y_col}");
if let Ok(mut chart) = ChartBuilder::on(&root)
.caption(
&caption,
("sans-serif", 14u32).into_font().color(&CAPTION_CLR),
)
.margin(20u32)
.x_label_area_size(40u32)
.y_label_area_size(60u32)
.build_cartesian_2d(
(x_min - x_pad)..(x_max + x_pad),
(y_min - y_pad)..(y_max + y_pad),
)
{
let _ = chart
.configure_mesh()
.x_labels(8)
.y_labels(6)
.axis_style(GRID_CLR)
.x_label_style(("sans-serif", 9u32).into_font().color(&LABEL_CLR))
.y_label_style(("sans-serif", 9u32).into_font().color(&LABEL_CLR))
.x_label_formatter(&|v| fmt_num(*v))
.y_label_formatter(&|v| fmt_num(*v))
.draw();
let _ = chart.draw_series(
points
.iter()
.map(|(x, y)| Circle::new((*x, *y), 3i32, SERIES_CLR.filled())),
);
}
let _ = root.present();
}
make_responsive(svg)
}
fn render_histogram(data: &[(String, f64)], x_col: &str) -> String {
let values: Vec<f64> = data
.iter()
.filter_map(|(x, _)| x.parse::<f64>().ok())
.filter(|v| v.is_finite())
.collect();
if values.is_empty() {
return error_svg("Could not parse values as numbers");
}
let x_min = values.iter().copied().fold(f64::INFINITY, f64::min);
let x_max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let num_bins: usize = 30;
let bin_size = if (x_max - x_min).abs() < f64::EPSILON {
1.0
} else {
(x_max - x_min) / num_bins as f64
};
let mut bins = vec![0u64; num_bins];
for v in &values {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let idx = (((v - x_min) / bin_size) as usize).min(num_bins - 1);
bins[idx] += 1;
}
#[allow(clippy::cast_precision_loss)]
let y_max = *bins.iter().max().unwrap_or(&1) as f64;
let mut svg = String::new();
{
let root = SVGBackend::with_string(&mut svg, (W, H)).into_drawing_area();
let _ = root.fill(&BG);
let caption = format!("Distribution of {x_col}");
#[allow(clippy::cast_possible_truncation)]
let bins_u32 = num_bins as u32;
if let Ok(mut chart) = ChartBuilder::on(&root)
.caption(
&caption,
("sans-serif", 14u32).into_font().color(&CAPTION_CLR),
)
.margin(20u32)
.x_label_area_size(40u32)
.y_label_area_size(60u32)
.build_cartesian_2d((0u32..bins_u32).into_segmented(), 0f64..(y_max * 1.1))
{
let _ = chart
.configure_mesh()
.x_labels(8)
.y_labels(6)
.axis_style(GRID_CLR)
.x_label_style(("sans-serif", 9u32).into_font().color(&LABEL_CLR))
.y_label_style(("sans-serif", 9u32).into_font().color(&LABEL_CLR))
.x_label_formatter(&|v: &SegmentValue<u32>| {
if let SegmentValue::CenterOf(i) = v {
let val = x_min + f64::from(*i) * bin_size;
fmt_num(val)
} else {
String::new()
}
})
.y_label_formatter(&|v| fmt_num(*v))
.draw();
let _ = chart.draw_series(Histogram::vertical(&chart).style(SERIES_CLR.filled()).data(
bins.iter().enumerate().map(|(i, &c)| {
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
(i as u32, c as f64)
}),
));
}
let _ = root.present();
}
make_responsive(svg)
}
pub fn render_svg(
chart_type: &ChartType,
data: &[(String, f64)],
x_col: &str,
y_col: &str,
) -> String {
match chart_type {
ChartType::Line => render_line(data, x_col, y_col),
ChartType::Bar => render_bar(data, x_col, y_col),
ChartType::Scatter => render_scatter(data, x_col, y_col),
ChartType::Histogram => render_histogram(data, x_col),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fmt_num_compacts_large_values() {
assert_eq!(fmt_num(0.0), "0");
assert_eq!(fmt_num(7.0), "7");
assert_eq!(fmt_num(1234.0), "1.2k");
assert_eq!(fmt_num(2_500_000.0), "2.5M");
assert_eq!(fmt_num(1.5e9), "1.5B");
assert_eq!(fmt_num(0.42), "0.42");
}
#[test]
fn truncate_is_utf8_safe() {
// Naive byte slicing on this string would panic.
let s = "日本語テスト文字列";
let out = truncate_chars(s, 4);
assert!(out.ends_with('…'));
assert!(out.chars().count() <= 4);
}
#[test]
fn make_responsive_replaces_pixel_size_with_viewbox() {
let svg = format!("<svg width=\"{W}\" height=\"{H}\" xmlns=\"...\"></svg>");
let out = make_responsive(svg);
assert!(out.contains("viewBox=\"0 0 900 430\""));
assert!(out.contains("width:100%"));
assert!(!out.contains("width=\"900\" height=\"430\""));
}
#[test]
fn error_svg_is_responsive() {
let svg = error_svg("nope");
assert!(svg.contains("viewBox=\"0 0 900 430\""));
}
}