1#![allow(
36 clippy::cast_precision_loss,
37 reason = "chart rendering: rows/columns displayed to user; any values approaching 2^53 would saturate to Infinity in the chart anyway"
38)]
39
40use crate::error::{ErrorCode, McpError};
41use plotters::prelude::*;
42use plotters::style::colors;
43use serde_json::Value;
44use std::collections::BTreeMap;
45
46type SeriesPoints = Vec<(f64, f64, String)>;
55
56type SeriesMap = BTreeMap<String, SeriesPoints>;
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum ChartType {
64 Bar,
65 Line,
66 Scatter,
67 Histogram,
68}
69
70impl ChartType {
71 pub fn parse(s: &str) -> Result<Self, McpError> {
78 match s.to_lowercase().as_str() {
79 "bar" => Ok(ChartType::Bar),
80 "line" => Ok(ChartType::Line),
81 "scatter" => Ok(ChartType::Scatter),
82 "histogram" | "hist" => Ok(ChartType::Histogram),
83 other => Err(McpError::new(
84 ErrorCode::SchemaMismatch,
85 format!(
86 "Unknown chart type '{other}'. Expected one of: bar, line, scatter, histogram"
87 ),
88 )),
89 }
90 }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum ChartFormat {
96 Png,
97 Svg,
98}
99
100impl ChartFormat {
101 pub fn parse(s: &str) -> Result<Self, McpError> {
108 match s.to_lowercase().as_str() {
109 "png" => Ok(ChartFormat::Png),
110 "svg" => Ok(ChartFormat::Svg),
111 other => Err(McpError::new(
112 ErrorCode::UnsupportedFormat,
113 format!("Unknown chart format '{other}'. Expected 'png' or 'svg'"),
114 )),
115 }
116 }
117
118 #[must_use]
119 pub fn mime_type(&self) -> &'static str {
120 match self {
121 ChartFormat::Png => "image/png",
122 ChartFormat::Svg => "image/svg+xml",
123 }
124 }
125
126 #[must_use]
129 pub fn extension(&self) -> &'static str {
130 match self {
131 ChartFormat::Png => "png",
132 ChartFormat::Svg => "svg",
133 }
134 }
135}
136
137pub fn resolve_chart_format(
159 explicit_format: Option<&str>,
160 output_path: Option<&str>,
161) -> Result<ChartFormat, McpError> {
162 let ext_from_path = output_path.and_then(extract_extension);
163
164 match (explicit_format, ext_from_path.as_deref()) {
165 (Some(f), Some(ext)) => {
166 let from_format = ChartFormat::parse(f)?;
167 let from_ext = format_from_extension(ext)?;
168 if from_format != from_ext {
169 return Err(McpError::new(
170 ErrorCode::InvalidArgument,
171 format!(
172 "chart: format=\"{f}\" conflicts with output_path extension \".{ext}\" — \
173 remove one or make them agree"
174 ),
175 ));
176 }
177 Ok(from_format)
178 }
179 (Some(f), None) => ChartFormat::parse(f),
180 (None, Some(ext)) => format_from_extension(ext),
181 (None, None) => Ok(ChartFormat::Png),
182 }
183}
184
185fn extract_extension(path: &str) -> Option<String> {
188 std::path::Path::new(path)
189 .extension()
190 .and_then(|e| e.to_str())
191 .map(str::to_ascii_lowercase)
192}
193
194fn format_from_extension(ext: &str) -> Result<ChartFormat, McpError> {
197 match ext {
198 "png" => Ok(ChartFormat::Png),
199 "svg" => Ok(ChartFormat::Svg),
200 other => Err(McpError::new(
201 ErrorCode::InvalidArgument,
202 format!(
203 "chart: unsupported output_path extension \".{other}\" (use .png or .svg, \
204 or omit output_path to auto-generate one)"
205 ),
206 )),
207 }
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
215pub enum ChartDisposition {
216 WriteOnly { path: std::path::PathBuf },
219 InlineOnly,
221 WriteAndInline { path: std::path::PathBuf },
223}
224
225impl ChartDisposition {
226 #[must_use]
228 pub fn path(&self) -> Option<&std::path::Path> {
229 match self {
230 ChartDisposition::WriteOnly { path } | ChartDisposition::WriteAndInline { path } => {
231 Some(path)
232 }
233 ChartDisposition::InlineOnly => None,
234 }
235 }
236
237 #[must_use]
239 pub fn wants_inline(&self) -> bool {
240 matches!(
241 self,
242 ChartDisposition::InlineOnly | ChartDisposition::WriteAndInline { .. }
243 )
244 }
245}
246
247#[must_use]
262pub fn resolve_chart_disposition(
263 inline: bool,
264 output_path: Option<&str>,
265 format: ChartFormat,
266) -> ChartDisposition {
267 match (inline, output_path) {
268 (true, None) => ChartDisposition::InlineOnly,
269 (true, Some(p)) => ChartDisposition::WriteAndInline {
270 path: std::path::PathBuf::from(p),
271 },
272 (false, Some(p)) => ChartDisposition::WriteOnly {
273 path: std::path::PathBuf::from(p),
274 },
275 (false, None) => ChartDisposition::WriteOnly {
276 path: auto_generated_chart_path(format),
277 },
278 }
279}
280
281pub fn auto_generated_chart_path(format: ChartFormat) -> std::path::PathBuf {
289 use std::sync::atomic::{AtomicU64, Ordering};
290 static COUNTER: AtomicU64 = AtomicU64::new(0);
291
292 let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
293 let pid = std::process::id();
294 let nanos = std::time::SystemTime::now()
295 .duration_since(std::time::UNIX_EPOCH)
296 .map_or(0, |d| d.as_nanos());
297
298 std::env::temp_dir().join("hyperdb-charts").join(format!(
299 "chart-{nanos}-{pid}-{counter}.{ext}",
300 ext = format.extension()
301 ))
302}
303
304pub fn write_chart_to_disk(
322 path: &std::path::Path,
323 bytes: &[u8],
324 overwrite: bool,
325) -> Result<u64, McpError> {
326 if path
330 .components()
331 .any(|c| matches!(c, std::path::Component::ParentDir))
332 {
333 return Err(McpError::new(
334 ErrorCode::InvalidArgument,
335 format!(
336 "Chart output path '{}' may not contain '..' components",
337 path.display()
338 ),
339 ));
340 }
341
342 if !overwrite && path.exists() {
343 return Err(McpError::new(
344 ErrorCode::PermissionDenied,
345 format!(
346 "Refusing to overwrite existing chart: {} (pass overwrite=true to replace it)",
347 path.display()
348 ),
349 ));
350 }
351
352 if let Some(parent) = path.parent() {
353 if !parent.as_os_str().is_empty() {
354 std::fs::create_dir_all(parent).map_err(|e| {
355 McpError::new(
356 ErrorCode::InternalError,
357 format!(
358 "Failed to create parent directory for chart '{}': {e}",
359 path.display()
360 ),
361 )
362 })?;
363 }
364 }
365
366 std::fs::write(path, bytes).map_err(|e| {
367 McpError::new(
368 ErrorCode::InternalError,
369 format!("Failed to write chart to '{}': {e}", path.display()),
370 )
371 })?;
372
373 Ok(bytes.len() as u64)
374}
375
376#[derive(Debug, Clone)]
378pub struct ChartOptions {
379 pub chart_type: ChartType,
380 pub x_column: Option<String>,
381 pub y_column: Option<String>,
382 pub series_column: Option<String>,
383 pub title: Option<String>,
384 pub format: ChartFormat,
385 pub width: u32,
386 pub height: u32,
387 pub bins: u32,
388 pub x_as_category: Option<bool>,
404 pub x_range: Option<[f64; 2]>,
409 pub y_range: Option<[f64; 2]>,
411 pub color_map: std::collections::HashMap<String, RGBColor>,
416 pub label_points: bool,
425}
426
427impl Default for ChartOptions {
428 fn default() -> Self {
429 Self {
430 chart_type: ChartType::Bar,
431 x_column: None,
432 y_column: None,
433 series_column: None,
434 title: None,
435 format: ChartFormat::Png,
436 width: 800,
437 height: 480,
438 bins: 20,
439 x_as_category: None,
440 x_range: None,
441 y_range: None,
442 color_map: std::collections::HashMap::new(),
443 label_points: false,
444 }
445 }
446}
447
448#[derive(Debug)]
450pub struct ChartResult {
451 pub bytes: Vec<u8>,
452 pub mime_type: &'static str,
453 pub rows_plotted: usize,
454}
455
456pub fn render_chart(rows: &[Value], opts: &ChartOptions) -> Result<ChartResult, McpError> {
474 const MAX_CHART_ROWS: usize = 50_000;
475 if rows.is_empty() {
476 return Err(McpError::new(
477 ErrorCode::EmptyData,
478 "No rows returned from SQL query — nothing to chart",
479 ));
480 }
481 if rows.len() > MAX_CHART_ROWS {
482 return Err(McpError::new(
483 ErrorCode::InvalidArgument,
484 format!(
485 "Chart data has {} rows, exceeding the {MAX_CHART_ROWS}-row limit. \
486 Add a LIMIT clause or aggregate your data to reduce row count.",
487 rows.len()
488 ),
489 )
490 .with_suggestion(format!(
491 "Add `LIMIT {MAX_CHART_ROWS}` to your query, or use GROUP BY to aggregate."
492 )));
493 }
494
495 match opts.format {
496 ChartFormat::Png => render_png(rows, opts),
497 ChartFormat::Svg => render_svg(rows, opts),
498 }
499}
500
501fn render_png(rows: &[Value], opts: &ChartOptions) -> Result<ChartResult, McpError> {
502 let tmp = tempfile::Builder::new()
503 .suffix(".png")
504 .tempfile()
505 .map_err(|e| {
506 McpError::new(
507 ErrorCode::InternalError,
508 format!("Cannot create temp PNG file: {e}"),
509 )
510 })?;
511 let path = tmp.path().to_path_buf();
512 let rows_plotted = {
513 let backend = BitMapBackend::new(&path, (opts.width, opts.height));
514 draw_on_backend(backend, rows, opts)?
515 };
516 let bytes = std::fs::read(&path).map_err(|e| {
517 McpError::new(
518 ErrorCode::InternalError,
519 format!("Cannot read rendered PNG: {e}"),
520 )
521 })?;
522 drop(tmp);
523 Ok(ChartResult {
524 bytes,
525 mime_type: ChartFormat::Png.mime_type(),
526 rows_plotted,
527 })
528}
529
530fn render_svg(rows: &[Value], opts: &ChartOptions) -> Result<ChartResult, McpError> {
531 let mut svg_string = String::new();
532 let rows_plotted = {
533 let backend = SVGBackend::with_string(&mut svg_string, (opts.width, opts.height));
534 draw_on_backend(backend, rows, opts)?
535 };
536 Ok(ChartResult {
537 bytes: svg_string.into_bytes(),
538 mime_type: ChartFormat::Svg.mime_type(),
539 rows_plotted,
540 })
541}
542
543fn draw_on_backend<DB: DrawingBackend>(
545 backend: DB,
546 rows: &[Value],
547 opts: &ChartOptions,
548) -> Result<usize, McpError>
549where
550 <DB as DrawingBackend>::ErrorType: 'static,
551{
552 let root = backend.into_drawing_area();
553 root.fill(&WHITE).map_err(draw_err)?;
554
555 match opts.chart_type {
556 ChartType::Bar => draw_bar(&root, rows, opts),
557 ChartType::Line => draw_line(&root, rows, opts),
558 ChartType::Scatter => draw_scatter(&root, rows, opts),
559 ChartType::Histogram => draw_histogram(&root, rows, opts),
560 }
561}
562
563#[expect(
564 clippy::needless_pass_by_value,
565 reason = "call-site ergonomics: function consumes logically-owned parameters, refactoring signatures is not worth per-site churn"
566)]
567fn draw_err<E: std::error::Error + Send + Sync + 'static>(e: DrawingAreaErrorKind<E>) -> McpError {
568 McpError::new(
569 ErrorCode::InternalError,
570 format!("Chart rendering error: {e}"),
571 )
572}
573
574#[expect(
575 clippy::ref_option,
576 reason = "matches callers that already hold `&Option<T>`; avoiding a `.as_ref()` dance at every call site"
577)]
578fn require_column<'a>(col: &'a Option<String>, role: &str) -> Result<&'a str, McpError> {
579 col.as_deref().ok_or_else(|| {
580 McpError::new(
581 ErrorCode::SchemaMismatch,
582 format!("The '{role}' column name is required for this chart type"),
583 )
584 })
585}
586
587fn as_number(v: &Value) -> Option<f64> {
588 match v {
589 Value::Number(n) => n.as_f64(),
590 Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
591 _ => None,
592 }
593}
594
595fn as_string(v: &Value) -> String {
596 match v {
597 Value::String(s) => s.clone(),
598 Value::Null => String::new(),
599 other => other.to_string(),
600 }
601}
602
603fn collect_categories(groups: &SeriesMap) -> Vec<(f64, String)> {
613 let mut seen: BTreeMap<u64, String> = BTreeMap::new();
617 for pts in groups.values() {
618 for (x, _y, label) in pts {
619 seen.entry(x.to_bits()).or_insert_with(|| label.clone());
620 }
621 }
622 let mut entries: Vec<_> = seen.into_iter().collect();
623 entries.sort_by(|a, b| {
624 f64::from_bits(a.0)
625 .partial_cmp(&f64::from_bits(b.0))
626 .unwrap_or(std::cmp::Ordering::Equal)
627 });
628 entries
629 .into_iter()
630 .map(|(bits, label)| (f64::from_bits(bits), label))
631 .collect()
632}
633
634fn group_series(
637 rows: &[Value],
638 x_col: &str,
639 y_col: &str,
640 series_col: Option<&str>,
641 x_as_category: bool,
642) -> Result<SeriesMap, McpError> {
643 let mut groups: SeriesMap = BTreeMap::new();
644 let mut category_index: BTreeMap<String, f64> = BTreeMap::new();
645
646 for row in rows {
647 let Some(obj) = row.as_object() else { continue };
648
649 let y_val = obj.get(y_col).and_then(as_number).ok_or_else(|| {
650 McpError::new(
651 ErrorCode::SchemaMismatch,
652 format!("Column '{y_col}' is missing or not numeric in at least one row"),
653 )
654 })?;
655
656 let x_raw = obj.get(x_col).cloned().unwrap_or(Value::Null);
657 let x_label = as_string(&x_raw);
658 let x_val = if x_as_category {
659 let next = category_index.len() as f64;
660 *category_index.entry(x_label.clone()).or_insert(next)
661 } else {
662 as_number(&x_raw).ok_or_else(|| {
663 McpError::new(
664 ErrorCode::SchemaMismatch,
665 format!("Column '{x_col}' is missing or not numeric in at least one row"),
666 )
667 })?
668 };
669
670 let series_key = match series_col {
671 Some(s) => obj.get(s).map(as_string).unwrap_or_default(),
672 None => String::new(),
673 };
674
675 groups
676 .entry(series_key)
677 .or_default()
678 .push((x_val, y_val, x_label));
679 }
680
681 if groups.values().all(std::vec::Vec::is_empty) {
682 return Err(McpError::new(
683 ErrorCode::EmptyData,
684 "No valid data points after filtering",
685 ));
686 }
687
688 Ok(groups)
689}
690
691fn series_color(idx: usize) -> RGBColor {
693 const PALETTE: [RGBColor; 8] = [
695 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), ];
704 PALETTE[idx % PALETTE.len()]
705}
706
707fn series_color_for(series_name: &str, idx: usize, opts: &ChartOptions) -> RGBColor {
710 opts.color_map
711 .get(series_name)
712 .copied()
713 .unwrap_or_else(|| series_color(idx))
714}
715
716#[must_use]
720pub fn parse_hex_color(s: &str) -> Option<RGBColor> {
721 let s = s.strip_prefix('#').unwrap_or(s);
722 if s.len() != 6 {
723 return None;
724 }
725 let r = u8::from_str_radix(&s[0..2], 16).ok()?;
726 let g = u8::from_str_radix(&s[2..4], 16).ok()?;
727 let b = u8::from_str_radix(&s[4..6], 16).ok()?;
728 Some(RGBColor(r, g, b))
729}
730
731fn draw_bar<DB: DrawingBackend>(
732 root: &DrawingArea<DB, plotters::coord::Shift>,
733 rows: &[Value],
734 opts: &ChartOptions,
735) -> Result<usize, McpError>
736where
737 <DB as DrawingBackend>::ErrorType: 'static,
738{
739 let x_col = require_column(&opts.x_column, "x")?;
740 let y_col = require_column(&opts.y_column, "y")?;
741
742 let x_as_category = opts.x_as_category.unwrap_or(true);
745 let groups = group_series(
746 rows,
747 x_col,
748 y_col,
749 opts.series_column.as_deref(),
750 x_as_category,
751 )?;
752
753 let categories = collect_categories(&groups);
754
755 let x_min = -0.5_f64;
756 let x_max = categories.len() as f64 - 0.5;
757
758 let y_min = groups
759 .values()
760 .flat_map(|pts| pts.iter().map(|(_, y, _)| *y))
761 .fold(f64::INFINITY, f64::min)
762 .min(0.0);
763 let y_max = groups
764 .values()
765 .flat_map(|pts| pts.iter().map(|(_, y, _)| *y))
766 .fold(f64::NEG_INFINITY, f64::max)
767 .max(0.0);
768 let y_pad = (y_max - y_min).abs() * 0.1 + 1.0;
769
770 let title = opts
771 .title
772 .clone()
773 .unwrap_or_else(|| format!("{y_col} by {x_col}"));
774
775 let mut chart = ChartBuilder::on(root)
776 .caption(&title, ("sans-serif", 22))
777 .margin(10)
778 .x_label_area_size(60)
779 .y_label_area_size(70)
780 .build_cartesian_2d(x_min..x_max, (y_min - y_pad)..(y_max + y_pad))
781 .map_err(draw_err)?;
782
783 let labels: Vec<String> = categories.iter().map(|(_, l)| l.clone()).collect();
784 chart
785 .configure_mesh()
786 .x_labels(categories.len().min(20))
787 .x_label_formatter(&|v| {
788 #[expect(
794 clippy::cast_possible_truncation,
795 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"
796 )]
797 let idx = v.round() as isize;
798 usize::try_from(idx)
799 .ok()
800 .and_then(|i| labels.get(i).cloned())
801 .unwrap_or_default()
802 })
803 .y_desc(y_col)
804 .x_desc(x_col)
805 .draw()
806 .map_err(draw_err)?;
807
808 let num_series = groups.len().max(1);
809 let total_width = 0.8_f64;
810 let bar_width = total_width / num_series as f64;
811 let mut total_plotted = 0usize;
812 for (idx, (series_key, pts)) in groups.iter().enumerate() {
813 let color = series_color_for(series_key, idx, opts);
814 let offset = -total_width / 2.0 + bar_width * (idx as f64 + 0.5);
815 let name = if series_key.is_empty() {
816 y_col.to_string()
817 } else {
818 series_key.clone()
819 };
820 chart
821 .draw_series(pts.iter().map(|(x, y, _)| {
822 let left = x + offset - bar_width / 2.0;
823 let right = x + offset + bar_width / 2.0;
824 Rectangle::new([(left, 0.0), (right, *y)], color.filled())
825 }))
826 .map_err(draw_err)?
827 .label(name)
828 .legend(move |(x, y)| Rectangle::new([(x, y - 5), (x + 12, y + 5)], color.filled()));
829 total_plotted += pts.len();
830 }
831
832 chart
833 .configure_series_labels()
834 .background_style(colors::WHITE.mix(0.9))
835 .border_style(colors::BLACK)
836 .draw()
837 .map_err(draw_err)?;
838
839 root.present().map_err(draw_err)?;
840 Ok(total_plotted)
841}
842
843fn draw_line<DB: DrawingBackend>(
844 root: &DrawingArea<DB, plotters::coord::Shift>,
845 rows: &[Value],
846 opts: &ChartOptions,
847) -> Result<usize, McpError>
848where
849 <DB as DrawingBackend>::ErrorType: 'static,
850{
851 line_or_scatter(root, rows, opts, true)
852}
853
854fn draw_scatter<DB: DrawingBackend>(
855 root: &DrawingArea<DB, plotters::coord::Shift>,
856 rows: &[Value],
857 opts: &ChartOptions,
858) -> Result<usize, McpError>
859where
860 <DB as DrawingBackend>::ErrorType: 'static,
861{
862 line_or_scatter(root, rows, opts, false)
863}
864
865#[expect(
866 clippy::similar_names,
867 reason = "paired bindings (request/response, reader/writer, etc.) are more readable with symmetric names than artificially distinct ones"
868)]
869fn line_or_scatter<DB: DrawingBackend>(
872 root: &DrawingArea<DB, plotters::coord::Shift>,
873 rows: &[Value],
874 opts: &ChartOptions,
875 connect_points: bool,
876) -> Result<usize, McpError>
877where
878 <DB as DrawingBackend>::ErrorType: 'static,
879{
880 let x_col = require_column(&opts.x_column, "x")?;
881 let y_col = require_column(&opts.y_column, "y")?;
882 let x_as_category = opts.x_as_category.unwrap_or(false);
885 let groups = group_series(
886 rows,
887 x_col,
888 y_col,
889 opts.series_column.as_deref(),
890 x_as_category,
891 )?;
892
893 let auto = bounds(&groups);
894 let (rx_min, rx_max, ry_min, ry_max) = apply_ranges(auto, opts);
895
896 let default_title = if connect_points {
897 "Line chart"
898 } else {
899 "Scatter plot"
900 };
901 let title = opts.title.clone().unwrap_or_else(|| default_title.into());
902
903 let mut chart = ChartBuilder::on(root)
904 .caption(&title, ("sans-serif", 22))
905 .margin(10)
906 .x_label_area_size(if x_as_category { 60 } else { 50 })
907 .y_label_area_size(70)
908 .build_cartesian_2d(rx_min..rx_max, ry_min..ry_max)
909 .map_err(draw_err)?;
910
911 if x_as_category {
917 let categories = collect_categories(&groups);
918 let labels: Vec<String> = categories.iter().map(|(_, l)| l.clone()).collect();
919 chart
920 .configure_mesh()
921 .x_desc(x_col)
922 .y_desc(y_col)
923 .x_labels(categories.len().min(20))
924 .x_label_formatter(&|v| {
925 #[expect(
926 clippy::cast_possible_truncation,
927 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"
928 )]
929 let idx = v.round() as isize;
930 usize::try_from(idx)
931 .ok()
932 .and_then(|i| labels.get(i).cloned())
933 .unwrap_or_default()
934 })
935 .draw()
936 .map_err(draw_err)?;
937 } else {
938 chart
939 .configure_mesh()
940 .x_desc(x_col)
941 .y_desc(y_col)
942 .draw()
943 .map_err(draw_err)?;
944 }
945
946 let mut total_plotted = 0usize;
947 for (idx, (series_key, pts)) in groups.iter().enumerate() {
948 let color = series_color_for(series_key, idx, opts);
949 let name = if series_key.is_empty() {
950 y_col.to_string()
951 } else {
952 series_key.clone()
953 };
954 let mut sorted = pts.clone();
955 if connect_points {
956 sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
957 }
958
959 if opts.label_points {
960 if connect_points {
963 chart
964 .draw_series(LineSeries::new(
965 sorted.iter().map(|(x, y, _)| (*x, *y)),
966 color.stroke_width(2),
967 ))
968 .map_err(draw_err)?;
969 } else {
970 chart
971 .draw_series(
972 sorted
973 .iter()
974 .map(|(x, y, _)| Circle::new((*x, *y), 4, color.filled())),
975 )
976 .map_err(draw_err)?;
977 }
978 let x_flip_threshold = rx_min + (rx_max - rx_min) * 0.75;
983 let y_flip_threshold = ry_min + (ry_max - ry_min) * 0.15;
984 let label_style = ("sans-serif", 11).into_font().color(&BLACK);
985 chart
986 .draw_series(sorted.iter().map(|(x, y, _)| {
987 let label = name.clone();
988 let char_px = i32::try_from(label.chars().count())
996 .unwrap_or(i32::MAX)
997 .saturating_mul(7);
998 let x_off = if *x >= x_flip_threshold {
999 -(char_px + 6)
1000 } else {
1001 6
1002 };
1003 let y_off = if *y <= y_flip_threshold { -20 } else { -12 };
1004 EmptyElement::at((*x, *y))
1005 + Text::new(label, (x_off, y_off), label_style.clone())
1006 }))
1007 .map_err(draw_err)?;
1008 } else {
1009 if connect_points {
1011 chart
1012 .draw_series(LineSeries::new(
1013 sorted.iter().map(|(x, y, _)| (*x, *y)),
1014 color.stroke_width(2),
1015 ))
1016 .map_err(draw_err)?
1017 .label(name)
1018 .legend(move |(x, y)| {
1019 PathElement::new(vec![(x, y), (x + 16, y)], color.stroke_width(2))
1020 });
1021 } else {
1022 chart
1023 .draw_series(
1024 sorted
1025 .iter()
1026 .map(|(x, y, _)| Circle::new((*x, *y), 4, color.filled())),
1027 )
1028 .map_err(draw_err)?
1029 .label(name)
1030 .legend(move |(x, y)| Circle::new((x + 8, y), 4, color.filled()));
1031 }
1032 }
1033 total_plotted += pts.len();
1034 }
1035
1036 if !opts.label_points {
1039 chart
1040 .configure_series_labels()
1041 .background_style(colors::WHITE.mix(0.9))
1042 .border_style(colors::BLACK)
1043 .draw()
1044 .map_err(draw_err)?;
1045 }
1046
1047 root.present().map_err(draw_err)?;
1048 Ok(total_plotted)
1049}
1050
1051fn bounds(groups: &SeriesMap) -> (f64, f64, f64, f64) {
1052 let (mut x_min, mut x_max) = (f64::INFINITY, f64::NEG_INFINITY);
1053 let (mut y_min, mut y_max) = (f64::INFINITY, f64::NEG_INFINITY);
1054 for pts in groups.values() {
1055 for (x, y, _) in pts {
1056 if *x < x_min {
1057 x_min = *x;
1058 }
1059 if *x > x_max {
1060 x_max = *x;
1061 }
1062 if *y < y_min {
1063 y_min = *y;
1064 }
1065 if *y > y_max {
1066 y_max = *y;
1067 }
1068 }
1069 }
1070 if !x_min.is_finite() {
1071 x_min = 0.0;
1072 }
1073 if !x_max.is_finite() {
1074 x_max = 1.0;
1075 }
1076 if !y_min.is_finite() {
1077 y_min = 0.0;
1078 }
1079 if !y_max.is_finite() {
1080 y_max = 1.0;
1081 }
1082 if (x_max - x_min).abs() < 1e-12 {
1083 x_max = x_min + 1.0;
1084 }
1085 if (y_max - y_min).abs() < 1e-12 {
1086 y_max = y_min + 1.0;
1087 }
1088 (x_min, x_max, y_min, y_max)
1089}
1090
1091#[expect(
1092 clippy::similar_names,
1093 reason = "paired bindings (request/response, reader/writer, etc.) are more readable with symmetric names than artificially distinct ones"
1094)]
1095fn apply_ranges(auto: (f64, f64, f64, f64), opts: &ChartOptions) -> (f64, f64, f64, f64) {
1102 let (x_min, x_max, y_min, y_max) = auto;
1103 let x_pad = (x_max - x_min).abs() * 0.05 + 1e-9;
1104 let y_pad = (y_max - y_min).abs() * 0.05 + 1e-9;
1105 let (final_x_min, final_x_max) = match opts.x_range {
1106 Some([lo, hi]) => (lo, hi),
1107 None => (x_min - x_pad, x_max + x_pad),
1108 };
1109 let (final_y_min, final_y_max) = match opts.y_range {
1110 Some([lo, hi]) => (lo, hi),
1111 None => (y_min - y_pad, y_max + y_pad),
1112 };
1113 (final_x_min, final_x_max, final_y_min, final_y_max)
1114}
1115
1116fn draw_histogram<DB: DrawingBackend>(
1117 root: &DrawingArea<DB, plotters::coord::Shift>,
1118 rows: &[Value],
1119 opts: &ChartOptions,
1120) -> Result<usize, McpError>
1121where
1122 <DB as DrawingBackend>::ErrorType: 'static,
1123{
1124 let col = opts
1126 .x_column
1127 .as_deref()
1128 .or(opts.y_column.as_deref())
1129 .ok_or_else(|| {
1130 McpError::new(
1131 ErrorCode::SchemaMismatch,
1132 "Histogram requires an 'x' or 'y' column name",
1133 )
1134 })?;
1135
1136 let values: Vec<f64> = rows
1137 .iter()
1138 .filter_map(|r| r.as_object().and_then(|o| o.get(col)).and_then(as_number))
1139 .collect();
1140 if values.is_empty() {
1141 return Err(McpError::new(
1142 ErrorCode::SchemaMismatch,
1143 format!("Column '{col}' has no numeric values to histogram"),
1144 ));
1145 }
1146
1147 let bin_count = opts.bins.max(1) as usize;
1148 let min = values.iter().copied().fold(f64::INFINITY, f64::min);
1149 let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1150 let span = if (max - min).abs() < 1e-12 {
1151 1.0
1152 } else {
1153 max - min
1154 };
1155 let bin_width = span / bin_count as f64;
1156
1157 let mut bins = vec![0u64; bin_count];
1158 for v in &values {
1159 #[expect(
1165 clippy::cast_possible_truncation,
1166 clippy::cast_sign_loss,
1167 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"
1168 )]
1169 let idx = (((*v - min) / bin_width).floor() as isize).max(0) as usize;
1170 let idx = idx.min(bin_count - 1);
1171 bins[idx] += 1;
1172 }
1173
1174 let y_max = *bins.iter().max().unwrap_or(&1) as f64;
1175 let title = opts
1176 .title
1177 .clone()
1178 .unwrap_or_else(|| format!("Distribution of {col}"));
1179
1180 let mut chart = ChartBuilder::on(root)
1181 .caption(&title, ("sans-serif", 22))
1182 .margin(10)
1183 .x_label_area_size(50)
1184 .y_label_area_size(60)
1185 .build_cartesian_2d(min..(max + bin_width * 0.01), 0.0..(y_max * 1.1 + 1.0))
1186 .map_err(draw_err)?;
1187
1188 chart
1189 .configure_mesh()
1190 .x_desc(col)
1191 .y_desc("count")
1192 .draw()
1193 .map_err(draw_err)?;
1194
1195 let color = series_color(0);
1196 chart
1197 .draw_series(bins.iter().enumerate().map(|(i, count)| {
1198 let left = min + bin_width * i as f64;
1199 let right = left + bin_width;
1200 Rectangle::new([(left, 0.0), (right, *count as f64)], color.filled())
1201 }))
1202 .map_err(draw_err)?;
1203
1204 root.present().map_err(draw_err)?;
1205 Ok(values.len())
1206}