#![allow(
clippy::cast_precision_loss,
reason = "chart rendering: rows/columns displayed to user; any values approaching 2^53 would saturate to Infinity in the chart anyway"
)]
use crate::error::{ErrorCode, McpError};
use plotters::prelude::*;
use plotters::style::colors;
use serde_json::Value;
use std::collections::BTreeMap;
type SeriesPoints = Vec<(f64, f64, String)>;
type SeriesMap = BTreeMap<String, SeriesPoints>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChartType {
Bar,
Line,
Scatter,
Histogram,
}
impl ChartType {
pub fn parse(s: &str) -> Result<Self, McpError> {
match s.to_lowercase().as_str() {
"bar" => Ok(ChartType::Bar),
"line" => Ok(ChartType::Line),
"scatter" => Ok(ChartType::Scatter),
"histogram" | "hist" => Ok(ChartType::Histogram),
other => Err(McpError::new(
ErrorCode::SchemaMismatch,
format!(
"Unknown chart type '{other}'. Expected one of: bar, line, scatter, histogram"
),
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChartFormat {
Png,
Svg,
}
impl ChartFormat {
pub fn parse(s: &str) -> Result<Self, McpError> {
match s.to_lowercase().as_str() {
"png" => Ok(ChartFormat::Png),
"svg" => Ok(ChartFormat::Svg),
other => Err(McpError::new(
ErrorCode::UnsupportedFormat,
format!("Unknown chart format '{other}'. Expected 'png' or 'svg'"),
)),
}
}
#[must_use]
pub fn mime_type(&self) -> &'static str {
match self {
ChartFormat::Png => "image/png",
ChartFormat::Svg => "image/svg+xml",
}
}
#[must_use]
pub fn extension(&self) -> &'static str {
match self {
ChartFormat::Png => "png",
ChartFormat::Svg => "svg",
}
}
}
pub fn resolve_chart_format(
explicit_format: Option<&str>,
output_path: Option<&str>,
) -> Result<ChartFormat, McpError> {
let ext_from_path = output_path.and_then(extract_extension);
match (explicit_format, ext_from_path.as_deref()) {
(Some(f), Some(ext)) => {
let from_format = ChartFormat::parse(f)?;
let from_ext = format_from_extension(ext)?;
if from_format != from_ext {
return Err(McpError::new(
ErrorCode::InvalidArgument,
format!(
"chart: format=\"{f}\" conflicts with output_path extension \".{ext}\" — \
remove one or make them agree"
),
));
}
Ok(from_format)
}
(Some(f), None) => ChartFormat::parse(f),
(None, Some(ext)) => format_from_extension(ext),
(None, None) => Ok(ChartFormat::Png),
}
}
fn extract_extension(path: &str) -> Option<String> {
std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
}
fn format_from_extension(ext: &str) -> Result<ChartFormat, McpError> {
match ext {
"png" => Ok(ChartFormat::Png),
"svg" => Ok(ChartFormat::Svg),
other => Err(McpError::new(
ErrorCode::InvalidArgument,
format!(
"chart: unsupported output_path extension \".{other}\" (use .png or .svg, \
or omit output_path to auto-generate one)"
),
)),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChartDisposition {
WriteOnly { path: std::path::PathBuf },
InlineOnly,
WriteAndInline { path: std::path::PathBuf },
}
impl ChartDisposition {
#[must_use]
pub fn path(&self) -> Option<&std::path::Path> {
match self {
ChartDisposition::WriteOnly { path } | ChartDisposition::WriteAndInline { path } => {
Some(path)
}
ChartDisposition::InlineOnly => None,
}
}
#[must_use]
pub fn wants_inline(&self) -> bool {
matches!(
self,
ChartDisposition::InlineOnly | ChartDisposition::WriteAndInline { .. }
)
}
}
#[must_use]
pub fn resolve_chart_disposition(
inline: bool,
output_path: Option<&str>,
format: ChartFormat,
) -> ChartDisposition {
match (inline, output_path) {
(true, None) => ChartDisposition::InlineOnly,
(true, Some(p)) => ChartDisposition::WriteAndInline {
path: std::path::PathBuf::from(p),
},
(false, Some(p)) => ChartDisposition::WriteOnly {
path: std::path::PathBuf::from(p),
},
(false, None) => ChartDisposition::WriteOnly {
path: auto_generated_chart_path(format),
},
}
}
pub fn auto_generated_chart_path(format: ChartFormat) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
std::env::temp_dir().join("hyperdb-charts").join(format!(
"chart-{nanos}-{pid}-{counter}.{ext}",
ext = format.extension()
))
}
pub fn write_chart_to_disk(
path: &std::path::Path,
bytes: &[u8],
overwrite: bool,
) -> Result<u64, McpError> {
if path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(McpError::new(
ErrorCode::InvalidArgument,
format!(
"Chart output path '{}' may not contain '..' components",
path.display()
),
));
}
if !overwrite && path.exists() {
return Err(McpError::new(
ErrorCode::PermissionDenied,
format!(
"Refusing to overwrite existing chart: {} (pass overwrite=true to replace it)",
path.display()
),
));
}
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|e| {
McpError::new(
ErrorCode::InternalError,
format!(
"Failed to create parent directory for chart '{}': {e}",
path.display()
),
)
})?;
}
}
std::fs::write(path, bytes).map_err(|e| {
McpError::new(
ErrorCode::InternalError,
format!("Failed to write chart to '{}': {e}", path.display()),
)
})?;
Ok(bytes.len() as u64)
}
#[derive(Debug, Clone)]
pub struct ChartOptions {
pub chart_type: ChartType,
pub x_column: Option<String>,
pub y_column: Option<String>,
pub series_column: Option<String>,
pub title: Option<String>,
pub format: ChartFormat,
pub width: u32,
pub height: u32,
pub bins: u32,
pub x_as_category: Option<bool>,
pub x_range: Option<[f64; 2]>,
pub y_range: Option<[f64; 2]>,
pub color_map: std::collections::HashMap<String, RGBColor>,
pub label_points: bool,
}
impl Default for ChartOptions {
fn default() -> Self {
Self {
chart_type: ChartType::Bar,
x_column: None,
y_column: None,
series_column: None,
title: None,
format: ChartFormat::Png,
width: 800,
height: 480,
bins: 20,
x_as_category: None,
x_range: None,
y_range: None,
color_map: std::collections::HashMap::new(),
label_points: false,
}
}
}
#[derive(Debug)]
pub struct ChartResult {
pub bytes: Vec<u8>,
pub mime_type: &'static str,
pub rows_plotted: usize,
}
pub fn render_chart(rows: &[Value], opts: &ChartOptions) -> Result<ChartResult, McpError> {
const MAX_CHART_ROWS: usize = 50_000;
if rows.is_empty() {
return Err(McpError::new(
ErrorCode::EmptyData,
"No rows returned from SQL query — nothing to chart",
));
}
if rows.len() > MAX_CHART_ROWS {
return Err(McpError::new(
ErrorCode::InvalidArgument,
format!(
"Chart data has {} rows, exceeding the {MAX_CHART_ROWS}-row limit. \
Add a LIMIT clause or aggregate your data to reduce row count.",
rows.len()
),
)
.with_suggestion(format!(
"Add `LIMIT {MAX_CHART_ROWS}` to your query, or use GROUP BY to aggregate."
)));
}
match opts.format {
ChartFormat::Png => render_png(rows, opts),
ChartFormat::Svg => render_svg(rows, opts),
}
}
fn render_png(rows: &[Value], opts: &ChartOptions) -> Result<ChartResult, McpError> {
let tmp = tempfile::Builder::new()
.suffix(".png")
.tempfile()
.map_err(|e| {
McpError::new(
ErrorCode::InternalError,
format!("Cannot create temp PNG file: {e}"),
)
})?;
let path = tmp.path().to_path_buf();
let rows_plotted = {
let backend = BitMapBackend::new(&path, (opts.width, opts.height));
draw_on_backend(backend, rows, opts)?
};
let bytes = std::fs::read(&path).map_err(|e| {
McpError::new(
ErrorCode::InternalError,
format!("Cannot read rendered PNG: {e}"),
)
})?;
drop(tmp);
Ok(ChartResult {
bytes,
mime_type: ChartFormat::Png.mime_type(),
rows_plotted,
})
}
fn render_svg(rows: &[Value], opts: &ChartOptions) -> Result<ChartResult, McpError> {
let mut svg_string = String::new();
let rows_plotted = {
let backend = SVGBackend::with_string(&mut svg_string, (opts.width, opts.height));
draw_on_backend(backend, rows, opts)?
};
Ok(ChartResult {
bytes: svg_string.into_bytes(),
mime_type: ChartFormat::Svg.mime_type(),
rows_plotted,
})
}
fn draw_on_backend<DB: DrawingBackend>(
backend: DB,
rows: &[Value],
opts: &ChartOptions,
) -> Result<usize, McpError>
where
<DB as DrawingBackend>::ErrorType: 'static,
{
let root = backend.into_drawing_area();
root.fill(&WHITE).map_err(draw_err)?;
match opts.chart_type {
ChartType::Bar => draw_bar(&root, rows, opts),
ChartType::Line => draw_line(&root, rows, opts),
ChartType::Scatter => draw_scatter(&root, rows, opts),
ChartType::Histogram => draw_histogram(&root, rows, opts),
}
}
#[expect(
clippy::needless_pass_by_value,
reason = "call-site ergonomics: function consumes logically-owned parameters, refactoring signatures is not worth per-site churn"
)]
fn draw_err<E: std::error::Error + Send + Sync + 'static>(e: DrawingAreaErrorKind<E>) -> McpError {
McpError::new(
ErrorCode::InternalError,
format!("Chart rendering error: {e}"),
)
}
#[expect(
clippy::ref_option,
reason = "matches callers that already hold `&Option<T>`; avoiding a `.as_ref()` dance at every call site"
)]
fn require_column<'a>(col: &'a Option<String>, role: &str) -> Result<&'a str, McpError> {
col.as_deref().ok_or_else(|| {
McpError::new(
ErrorCode::SchemaMismatch,
format!("The '{role}' column name is required for this chart type"),
)
})
}
fn as_number(v: &Value) -> Option<f64> {
match v {
Value::Number(n) => n.as_f64(),
Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
_ => None,
}
}
fn as_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Null => String::new(),
other => other.to_string(),
}
}
fn collect_categories(groups: &SeriesMap) -> Vec<(f64, String)> {
let mut seen: BTreeMap<u64, String> = BTreeMap::new();
for pts in groups.values() {
for (x, _y, label) in pts {
seen.entry(x.to_bits()).or_insert_with(|| label.clone());
}
}
let mut entries: Vec<_> = seen.into_iter().collect();
entries.sort_by(|a, b| {
f64::from_bits(a.0)
.partial_cmp(&f64::from_bits(b.0))
.unwrap_or(std::cmp::Ordering::Equal)
});
entries
.into_iter()
.map(|(bits, label)| (f64::from_bits(bits), label))
.collect()
}
fn group_series(
rows: &[Value],
x_col: &str,
y_col: &str,
series_col: Option<&str>,
x_as_category: bool,
) -> Result<SeriesMap, McpError> {
let mut groups: SeriesMap = BTreeMap::new();
let mut category_index: BTreeMap<String, f64> = BTreeMap::new();
for row in rows {
let Some(obj) = row.as_object() else { continue };
let y_val = obj.get(y_col).and_then(as_number).ok_or_else(|| {
McpError::new(
ErrorCode::SchemaMismatch,
format!("Column '{y_col}' is missing or not numeric in at least one row"),
)
})?;
let x_raw = obj.get(x_col).cloned().unwrap_or(Value::Null);
let x_label = as_string(&x_raw);
let x_val = if x_as_category {
let next = category_index.len() as f64;
*category_index.entry(x_label.clone()).or_insert(next)
} else {
as_number(&x_raw).ok_or_else(|| {
McpError::new(
ErrorCode::SchemaMismatch,
format!("Column '{x_col}' is missing or not numeric in at least one row"),
)
})?
};
let series_key = match series_col {
Some(s) => obj.get(s).map(as_string).unwrap_or_default(),
None => String::new(),
};
groups
.entry(series_key)
.or_default()
.push((x_val, y_val, x_label));
}
if groups.values().all(std::vec::Vec::is_empty) {
return Err(McpError::new(
ErrorCode::EmptyData,
"No valid data points after filtering",
));
}
Ok(groups)
}
fn series_color(idx: usize) -> RGBColor {
const PALETTE: [RGBColor; 8] = [
RGBColor(31, 119, 180), RGBColor(255, 127, 14), RGBColor(44, 160, 44), RGBColor(214, 39, 40), RGBColor(148, 103, 189), RGBColor(140, 86, 75), RGBColor(227, 119, 194), RGBColor(127, 127, 127), ];
PALETTE[idx % PALETTE.len()]
}
fn series_color_for(series_name: &str, idx: usize, opts: &ChartOptions) -> RGBColor {
opts.color_map
.get(series_name)
.copied()
.unwrap_or_else(|| series_color(idx))
}
#[must_use]
pub fn parse_hex_color(s: &str) -> Option<RGBColor> {
let s = s.strip_prefix('#').unwrap_or(s);
if s.len() != 6 {
return None;
}
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
Some(RGBColor(r, g, b))
}
fn draw_bar<DB: DrawingBackend>(
root: &DrawingArea<DB, plotters::coord::Shift>,
rows: &[Value],
opts: &ChartOptions,
) -> Result<usize, McpError>
where
<DB as DrawingBackend>::ErrorType: 'static,
{
let x_col = require_column(&opts.x_column, "x")?;
let y_col = require_column(&opts.y_column, "y")?;
let x_as_category = opts.x_as_category.unwrap_or(true);
let groups = group_series(
rows,
x_col,
y_col,
opts.series_column.as_deref(),
x_as_category,
)?;
let categories = collect_categories(&groups);
let x_min = -0.5_f64;
let x_max = categories.len() as f64 - 0.5;
let y_min = groups
.values()
.flat_map(|pts| pts.iter().map(|(_, y, _)| *y))
.fold(f64::INFINITY, f64::min)
.min(0.0);
let y_max = groups
.values()
.flat_map(|pts| pts.iter().map(|(_, y, _)| *y))
.fold(f64::NEG_INFINITY, f64::max)
.max(0.0);
let y_pad = (y_max - y_min).abs() * 0.1 + 1.0;
let title = opts
.title
.clone()
.unwrap_or_else(|| format!("{y_col} by {x_col}"));
let mut chart = ChartBuilder::on(root)
.caption(&title, ("sans-serif", 22))
.margin(10)
.x_label_area_size(60)
.y_label_area_size(70)
.build_cartesian_2d(x_min..x_max, (y_min - y_pad)..(y_max + y_pad))
.map_err(draw_err)?;
let labels: Vec<String> = categories.iter().map(|(_, l)| l.clone()).collect();
chart
.configure_mesh()
.x_labels(categories.len().min(20))
.x_label_formatter(&|v| {
#[expect(
clippy::cast_possible_truncation,
reason = "axis tick value originated as an integer index into `labels`; the subsequent `usize::try_from` + length check make out-of-range ticks render as the empty-string branch"
)]
let idx = v.round() as isize;
usize::try_from(idx)
.ok()
.and_then(|i| labels.get(i).cloned())
.unwrap_or_default()
})
.y_desc(y_col)
.x_desc(x_col)
.draw()
.map_err(draw_err)?;
let num_series = groups.len().max(1);
let total_width = 0.8_f64;
let bar_width = total_width / num_series as f64;
let mut total_plotted = 0usize;
for (idx, (series_key, pts)) in groups.iter().enumerate() {
let color = series_color_for(series_key, idx, opts);
let offset = -total_width / 2.0 + bar_width * (idx as f64 + 0.5);
let name = if series_key.is_empty() {
y_col.to_string()
} else {
series_key.clone()
};
chart
.draw_series(pts.iter().map(|(x, y, _)| {
let left = x + offset - bar_width / 2.0;
let right = x + offset + bar_width / 2.0;
Rectangle::new([(left, 0.0), (right, *y)], color.filled())
}))
.map_err(draw_err)?
.label(name)
.legend(move |(x, y)| Rectangle::new([(x, y - 5), (x + 12, y + 5)], color.filled()));
total_plotted += pts.len();
}
chart
.configure_series_labels()
.background_style(colors::WHITE.mix(0.9))
.border_style(colors::BLACK)
.draw()
.map_err(draw_err)?;
root.present().map_err(draw_err)?;
Ok(total_plotted)
}
fn draw_line<DB: DrawingBackend>(
root: &DrawingArea<DB, plotters::coord::Shift>,
rows: &[Value],
opts: &ChartOptions,
) -> Result<usize, McpError>
where
<DB as DrawingBackend>::ErrorType: 'static,
{
line_or_scatter(root, rows, opts, true)
}
fn draw_scatter<DB: DrawingBackend>(
root: &DrawingArea<DB, plotters::coord::Shift>,
rows: &[Value],
opts: &ChartOptions,
) -> Result<usize, McpError>
where
<DB as DrawingBackend>::ErrorType: 'static,
{
line_or_scatter(root, rows, opts, false)
}
#[expect(
clippy::similar_names,
reason = "paired bindings (request/response, reader/writer, etc.) are more readable with symmetric names than artificially distinct ones"
)]
fn line_or_scatter<DB: DrawingBackend>(
root: &DrawingArea<DB, plotters::coord::Shift>,
rows: &[Value],
opts: &ChartOptions,
connect_points: bool,
) -> Result<usize, McpError>
where
<DB as DrawingBackend>::ErrorType: 'static,
{
let x_col = require_column(&opts.x_column, "x")?;
let y_col = require_column(&opts.y_column, "y")?;
let x_as_category = opts.x_as_category.unwrap_or(false);
let groups = group_series(
rows,
x_col,
y_col,
opts.series_column.as_deref(),
x_as_category,
)?;
let auto = bounds(&groups);
let (rx_min, rx_max, ry_min, ry_max) = apply_ranges(auto, opts);
let default_title = if connect_points {
"Line chart"
} else {
"Scatter plot"
};
let title = opts.title.clone().unwrap_or_else(|| default_title.into());
let mut chart = ChartBuilder::on(root)
.caption(&title, ("sans-serif", 22))
.margin(10)
.x_label_area_size(if x_as_category { 60 } else { 50 })
.y_label_area_size(70)
.build_cartesian_2d(rx_min..rx_max, ry_min..ry_max)
.map_err(draw_err)?;
if x_as_category {
let categories = collect_categories(&groups);
let labels: Vec<String> = categories.iter().map(|(_, l)| l.clone()).collect();
chart
.configure_mesh()
.x_desc(x_col)
.y_desc(y_col)
.x_labels(categories.len().min(20))
.x_label_formatter(&|v| {
#[expect(
clippy::cast_possible_truncation,
reason = "axis tick value originated as an integer index into `labels`; the subsequent `usize::try_from` + length check make out-of-range ticks render as the empty-string branch"
)]
let idx = v.round() as isize;
usize::try_from(idx)
.ok()
.and_then(|i| labels.get(i).cloned())
.unwrap_or_default()
})
.draw()
.map_err(draw_err)?;
} else {
chart
.configure_mesh()
.x_desc(x_col)
.y_desc(y_col)
.draw()
.map_err(draw_err)?;
}
let mut total_plotted = 0usize;
for (idx, (series_key, pts)) in groups.iter().enumerate() {
let color = series_color_for(series_key, idx, opts);
let name = if series_key.is_empty() {
y_col.to_string()
} else {
series_key.clone()
};
let mut sorted = pts.clone();
if connect_points {
sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
}
if opts.label_points {
if connect_points {
chart
.draw_series(LineSeries::new(
sorted.iter().map(|(x, y, _)| (*x, *y)),
color.stroke_width(2),
))
.map_err(draw_err)?;
} else {
chart
.draw_series(
sorted
.iter()
.map(|(x, y, _)| Circle::new((*x, *y), 4, color.filled())),
)
.map_err(draw_err)?;
}
let x_flip_threshold = rx_min + (rx_max - rx_min) * 0.75;
let y_flip_threshold = ry_min + (ry_max - ry_min) * 0.15;
let label_style = ("sans-serif", 11).into_font().color(&BLACK);
chart
.draw_series(sorted.iter().map(|(x, y, _)| {
let label = name.clone();
let char_px = i32::try_from(label.chars().count())
.unwrap_or(i32::MAX)
.saturating_mul(7);
let x_off = if *x >= x_flip_threshold {
-(char_px + 6)
} else {
6
};
let y_off = if *y <= y_flip_threshold { -20 } else { -12 };
EmptyElement::at((*x, *y))
+ Text::new(label, (x_off, y_off), label_style.clone())
}))
.map_err(draw_err)?;
} else {
if connect_points {
chart
.draw_series(LineSeries::new(
sorted.iter().map(|(x, y, _)| (*x, *y)),
color.stroke_width(2),
))
.map_err(draw_err)?
.label(name)
.legend(move |(x, y)| {
PathElement::new(vec![(x, y), (x + 16, y)], color.stroke_width(2))
});
} else {
chart
.draw_series(
sorted
.iter()
.map(|(x, y, _)| Circle::new((*x, *y), 4, color.filled())),
)
.map_err(draw_err)?
.label(name)
.legend(move |(x, y)| Circle::new((x + 8, y), 4, color.filled()));
}
}
total_plotted += pts.len();
}
if !opts.label_points {
chart
.configure_series_labels()
.background_style(colors::WHITE.mix(0.9))
.border_style(colors::BLACK)
.draw()
.map_err(draw_err)?;
}
root.present().map_err(draw_err)?;
Ok(total_plotted)
}
fn bounds(groups: &SeriesMap) -> (f64, f64, f64, f64) {
let (mut x_min, mut x_max) = (f64::INFINITY, f64::NEG_INFINITY);
let (mut y_min, mut y_max) = (f64::INFINITY, f64::NEG_INFINITY);
for pts in groups.values() {
for (x, y, _) in pts {
if *x < x_min {
x_min = *x;
}
if *x > x_max {
x_max = *x;
}
if *y < y_min {
y_min = *y;
}
if *y > y_max {
y_max = *y;
}
}
}
if !x_min.is_finite() {
x_min = 0.0;
}
if !x_max.is_finite() {
x_max = 1.0;
}
if !y_min.is_finite() {
y_min = 0.0;
}
if !y_max.is_finite() {
y_max = 1.0;
}
if (x_max - x_min).abs() < 1e-12 {
x_max = x_min + 1.0;
}
if (y_max - y_min).abs() < 1e-12 {
y_max = y_min + 1.0;
}
(x_min, x_max, y_min, y_max)
}
#[expect(
clippy::similar_names,
reason = "paired bindings (request/response, reader/writer, etc.) are more readable with symmetric names than artificially distinct ones"
)]
fn apply_ranges(auto: (f64, f64, f64, f64), opts: &ChartOptions) -> (f64, f64, f64, f64) {
let (x_min, x_max, y_min, y_max) = auto;
let x_pad = (x_max - x_min).abs() * 0.05 + 1e-9;
let y_pad = (y_max - y_min).abs() * 0.05 + 1e-9;
let (final_x_min, final_x_max) = match opts.x_range {
Some([lo, hi]) => (lo, hi),
None => (x_min - x_pad, x_max + x_pad),
};
let (final_y_min, final_y_max) = match opts.y_range {
Some([lo, hi]) => (lo, hi),
None => (y_min - y_pad, y_max + y_pad),
};
(final_x_min, final_x_max, final_y_min, final_y_max)
}
fn draw_histogram<DB: DrawingBackend>(
root: &DrawingArea<DB, plotters::coord::Shift>,
rows: &[Value],
opts: &ChartOptions,
) -> Result<usize, McpError>
where
<DB as DrawingBackend>::ErrorType: 'static,
{
let col = opts
.x_column
.as_deref()
.or(opts.y_column.as_deref())
.ok_or_else(|| {
McpError::new(
ErrorCode::SchemaMismatch,
"Histogram requires an 'x' or 'y' column name",
)
})?;
let values: Vec<f64> = rows
.iter()
.filter_map(|r| r.as_object().and_then(|o| o.get(col)).and_then(as_number))
.collect();
if values.is_empty() {
return Err(McpError::new(
ErrorCode::SchemaMismatch,
format!("Column '{col}' has no numeric values to histogram"),
));
}
let bin_count = opts.bins.max(1) as usize;
let min = values.iter().copied().fold(f64::INFINITY, f64::min);
let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let span = if (max - min).abs() < 1e-12 {
1.0
} else {
max - min
};
let bin_width = span / bin_count as f64;
let mut bins = vec![0u64; bin_count];
for v in &values {
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "bin index is clamped into `[0, bin_count)` on the surrounding lines, so the narrowing f64→isize→usize is a reinterpret of an already-bounded small integer"
)]
let idx = (((*v - min) / bin_width).floor() as isize).max(0) as usize;
let idx = idx.min(bin_count - 1);
bins[idx] += 1;
}
let y_max = *bins.iter().max().unwrap_or(&1) as f64;
let title = opts
.title
.clone()
.unwrap_or_else(|| format!("Distribution of {col}"));
let mut chart = ChartBuilder::on(root)
.caption(&title, ("sans-serif", 22))
.margin(10)
.x_label_area_size(50)
.y_label_area_size(60)
.build_cartesian_2d(min..(max + bin_width * 0.01), 0.0..(y_max * 1.1 + 1.0))
.map_err(draw_err)?;
chart
.configure_mesh()
.x_desc(col)
.y_desc("count")
.draw()
.map_err(draw_err)?;
let color = series_color(0);
chart
.draw_series(bins.iter().enumerate().map(|(i, count)| {
let left = min + bin_width * i as f64;
let right = left + bin_width;
Rectangle::new([(left, 0.0), (right, *count as f64)], color.filled())
}))
.map_err(draw_err)?;
root.present().map_err(draw_err)?;
Ok(values.len())
}