Skip to content
//! 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\""));
    }
}