1use anyhow::Result;
7use parking_lot::RwLock;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::PathBuf;
11use std::sync::Arc;
12
13#[derive(Debug, Clone)]
15pub enum VisualizationData {
16 Array1D(Vec<f64>),
18 Array2D(Vec<Vec<f64>>),
20 Tensor { data: Vec<f64>, shape: Vec<usize> },
22 KeyValue(HashMap<String, String>),
24 TimeSeries {
26 timestamps: Vec<f64>,
27 values: Vec<f64>,
28 labels: Vec<String>,
29 },
30 Json(serde_json::Value),
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36pub enum OutputFormat {
37 Png,
39 Svg,
41 Html,
43 Text,
45 Json,
47 Csv,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct PluginMetadata {
54 pub name: String,
56 pub version: String,
58 pub description: String,
60 pub author: String,
62 pub supported_inputs: Vec<String>,
64 pub supported_outputs: Vec<OutputFormat>,
66 pub tags: Vec<String>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct PluginConfig {
73 pub output_format: OutputFormat,
75 pub output_path: Option<String>,
77 pub width: usize,
79 pub height: usize,
81 pub color_scheme: String,
83 pub custom_params: HashMap<String, serde_json::Value>,
85}
86
87impl Default for PluginConfig {
88 fn default() -> Self {
89 Self {
90 output_format: OutputFormat::Svg,
91 output_path: None,
92 width: 800,
93 height: 600,
94 color_scheme: "viridis".to_string(),
95 custom_params: HashMap::new(),
96 }
97 }
98}
99
100#[derive(Debug)]
102pub struct PluginResult {
103 pub success: bool,
105 pub output_path: Option<String>,
107 pub output_data: Option<Vec<u8>>,
109 pub metadata: HashMap<String, String>,
111 pub error: Option<String>,
113}
114
115pub trait VisualizationPlugin: Send + Sync {
119 fn metadata(&self) -> PluginMetadata;
121
122 fn execute(&self, data: VisualizationData, config: PluginConfig) -> Result<PluginResult>;
131
132 fn validate(&self, data: &VisualizationData) -> bool {
140 let _ = data;
142 true
143 }
144
145 fn config_schema(&self) -> serde_json::Value {
147 serde_json::json!({
148 "type": "object",
149 "properties": {}
150 })
151 }
152}
153
154pub struct PluginManager {
156 plugins: Arc<RwLock<HashMap<String, Box<dyn VisualizationPlugin>>>>,
158 history: Arc<RwLock<Vec<PluginExecution>>>,
160}
161
162#[derive(Debug, Clone)]
164struct PluginExecution {
165 plugin_name: String,
166 timestamp: std::time::SystemTime,
167 success: bool,
168 duration_ms: u128,
169}
170
171impl Default for PluginManager {
172 fn default() -> Self {
173 Self::new()
174 }
175}
176
177impl PluginManager {
178 pub fn new() -> Self {
180 let manager = Self {
181 plugins: Arc::new(RwLock::new(HashMap::new())),
182 history: Arc::new(RwLock::new(Vec::new())),
183 };
184
185 manager.register_builtin_plugins();
187
188 manager
189 }
190
191 fn register_builtin_plugins(&self) {
193 self.register_plugin(Box::new(HistogramPlugin)).ok();
195
196 self.register_plugin(Box::new(HeatmapPlugin)).ok();
198
199 self.register_plugin(Box::new(LinePlotPlugin)).ok();
201
202 self.register_plugin(Box::new(ScatterPlotPlugin)).ok();
204 }
205
206 pub fn register_plugin(&self, plugin: Box<dyn VisualizationPlugin>) -> Result<()> {
211 let name = plugin.metadata().name.clone();
212
213 self.plugins.write().insert(name.clone(), plugin);
214
215 tracing::info!(plugin_name = %name, "Registered visualization plugin");
216
217 Ok(())
218 }
219
220 pub fn unregister_plugin(&self, name: &str) -> Result<()> {
225 self.plugins.write().remove(name);
226
227 tracing::info!(plugin_name = %name, "Unregistered visualization plugin");
228
229 Ok(())
230 }
231
232 pub fn list_plugins(&self) -> Vec<PluginMetadata> {
234 self.plugins.read().values().map(|p| p.metadata()).collect()
235 }
236
237 pub fn execute(
244 &self,
245 plugin_name: &str,
246 data: VisualizationData,
247 config: PluginConfig,
248 ) -> Result<PluginResult> {
249 let start_time = std::time::Instant::now();
250
251 let result = {
252 let plugins = self.plugins.read();
253 let plugin = plugins
254 .get(plugin_name)
255 .ok_or_else(|| anyhow::anyhow!("Plugin not found: {}", plugin_name))?;
256
257 if !plugin.validate(&data) {
259 anyhow::bail!("Invalid data for plugin: {}", plugin_name);
260 }
261
262 plugin.execute(data, config)?
263 };
264
265 let duration = start_time.elapsed().as_millis();
266
267 self.history.write().push(PluginExecution {
269 plugin_name: plugin_name.to_string(),
270 timestamp: std::time::SystemTime::now(),
271 success: result.success,
272 duration_ms: duration,
273 });
274
275 Ok(result)
276 }
277
278 pub fn get_plugin(&self, name: &str) -> Option<PluginMetadata> {
280 self.plugins.read().get(name).map(|p| p.metadata())
281 }
282
283 pub fn get_history(&self) -> Vec<String> {
285 self.history
286 .read()
287 .iter()
288 .map(|e| {
289 format!(
290 "{}: {} ({}ms) - {}",
291 e.timestamp.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(),
292 e.plugin_name,
293 e.duration_ms,
294 if e.success { "success" } else { "failed" }
295 )
296 })
297 .collect()
298 }
299}
300
301mod svg_render {
306 use super::PluginConfig;
312
313 const MARGIN_TOP: f64 = 40.0;
315 const MARGIN_BOTTOM: f64 = 50.0;
316 const MARGIN_LEFT: f64 = 60.0;
317 const MARGIN_RIGHT: f64 = 20.0;
318 const AXIS_TICK_LEN: f64 = 5.0;
319
320 const FONT_ATTR: &str = r#"font-family="sans-serif""#;
322 const AXIS_COLOR: &str = "#555";
323 const BAR_COLOR: &str = "#4878CF";
324
325 fn clamp_f64(v: f64, lo: f64, hi: f64) -> f64 {
327 if v < lo {
328 lo
329 } else if v > hi {
330 hi
331 } else {
332 v
333 }
334 }
335
336 fn map_x(v: f64, data_min: f64, data_max: f64, px_left: f64, px_right: f64) -> f64 {
338 let range = data_max - data_min;
339 if range == 0.0 {
340 return (px_left + px_right) / 2.0;
341 }
342 let t = (v - data_min) / range;
343 clamp_f64(px_left + t * (px_right - px_left), px_left, px_right)
344 }
345
346 fn map_y(v: f64, data_min: f64, data_max: f64, px_top: f64, px_bottom: f64) -> f64 {
348 let range = data_max - data_min;
349 if range == 0.0 {
350 return (px_top + px_bottom) / 2.0;
351 }
352 let t = (v - data_min) / range;
353 clamp_f64(px_bottom - t * (px_bottom - px_top), px_top, px_bottom)
354 }
355
356 fn svg_open(width: usize, height: usize) -> String {
358 format!(
359 "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\">\n\
360 <rect width=\"{w}\" height=\"{h}\" fill=\"white\"/>\n",
361 w = width,
362 h = height,
363 )
364 }
365
366 fn svg_close() -> &'static str {
367 "</svg>"
368 }
369
370 fn svg_title(text: &str, width: usize) -> String {
372 let cx = width / 2;
373 let escaped = escape_xml(text);
374 format!(
375 "<text x=\"{cx}\" y=\"24\" text-anchor=\"middle\" {font} font-size=\"16\" fill=\"#333\">{text}</text>\n",
376 cx = cx,
377 font = FONT_ATTR,
378 text = escaped,
379 )
380 }
381
382 fn svg_axes(
384 px_left: f64,
385 px_right: f64,
386 px_top: f64,
387 px_bottom: f64,
388 x_min: f64,
389 x_max: f64,
390 y_min: f64,
391 y_max: f64,
392 x_label: &str,
393 y_label: &str,
394 _width: usize,
395 height: usize,
396 n_ticks: usize,
397 ) -> String {
398 let mut out = String::new();
399 let c = AXIS_COLOR;
400
401 out.push_str(&format!(
403 "<line x1=\"{x1:.2}\" y1=\"{y1:.2}\" x2=\"{x2:.2}\" y2=\"{y2:.2}\" stroke=\"{c}\" stroke-width=\"1\"/>\n",
404 x1 = px_left, y1 = px_top, x2 = px_left, y2 = px_bottom, c = c,
405 ));
406 out.push_str(&format!(
408 "<line x1=\"{x1:.2}\" y1=\"{y1:.2}\" x2=\"{x2:.2}\" y2=\"{y2:.2}\" stroke=\"{c}\" stroke-width=\"1\"/>\n",
409 x1 = px_left, y1 = px_bottom, x2 = px_right, y2 = px_bottom, c = c,
410 ));
411
412 let x_range = x_max - x_min;
414 for i in 0..=n_ticks {
415 let frac = i as f64 / n_ticks as f64;
416 let val = x_min + frac * x_range;
417 let px = px_left + frac * (px_right - px_left);
418 let ty = px_bottom + AXIS_TICK_LEN + 12.0;
419 let y2 = px_bottom + AXIS_TICK_LEN;
420 out.push_str(&format!(
421 "<line x1=\"{px:.2}\" y1=\"{y1:.2}\" x2=\"{px:.2}\" y2=\"{y2:.2}\" stroke=\"{c}\" stroke-width=\"1\"/>\n\
422 <text x=\"{px:.2}\" y=\"{ty:.2}\" text-anchor=\"middle\" {font} font-size=\"10\" fill=\"{c}\">{val:.2}</text>\n",
423 px = px, y1 = px_bottom, y2 = y2, ty = ty, c = c, font = FONT_ATTR, val = val,
424 ));
425 }
426
427 let y_range = y_max - y_min;
429 for i in 0..=n_ticks {
430 let frac = i as f64 / n_ticks as f64;
431 let val = y_min + frac * y_range;
432 let py = px_bottom - frac * (px_bottom - px_top);
433 let tick_x1 = px_left - AXIS_TICK_LEN;
434 let tx = tick_x1 - 2.0;
435 let py_t = py + 4.0;
436 out.push_str(&format!(
437 "<line x1=\"{tx1:.2}\" y1=\"{py:.2}\" x2=\"{x2:.2}\" y2=\"{py:.2}\" stroke=\"{c}\" stroke-width=\"1\"/>\n\
438 <text x=\"{tx:.2}\" y=\"{pyt:.2}\" text-anchor=\"end\" {font} font-size=\"10\" fill=\"{c}\">{val:.2}</text>\n",
439 tx1 = tick_x1, py = py, x2 = px_left, tx = tx, pyt = py_t,
440 c = c, font = FONT_ATTR, val = val,
441 ));
442 }
443
444 if !x_label.is_empty() {
446 let lx = (px_left + px_right) / 2.0;
447 let ly = height as f64 - 4.0;
448 let label = escape_xml(x_label);
449 out.push_str(&format!(
450 "<text x=\"{lx:.2}\" y=\"{ly:.2}\" text-anchor=\"middle\" {font} font-size=\"12\" fill=\"{c}\">{label}</text>\n",
451 lx = lx, ly = ly, font = FONT_ATTR, c = c, label = label,
452 ));
453 }
454 if !y_label.is_empty() {
455 let ry_x = -((px_top + px_bottom) / 2.0);
456 let label = escape_xml(y_label);
457 out.push_str(&format!(
458 "<text transform=\"rotate(-90)\" x=\"{rx:.2}\" y=\"14\" text-anchor=\"middle\" {font} font-size=\"12\" fill=\"{c}\">{label}</text>\n",
459 rx = ry_x, font = FONT_ATTR, c = c, label = label,
460 ));
461 }
462
463 out
464 }
465
466 fn escape_xml(s: &str) -> String {
468 s.replace('&', "&")
469 .replace('<', "<")
470 .replace('>', ">")
471 .replace('"', """)
472 }
473
474 pub fn histogram(bins: &[(f64, f64, usize)], config: &PluginConfig) -> String {
480 let w = config.width;
481 let h = config.height;
482 let px_left = MARGIN_LEFT;
483 let px_right = w as f64 - MARGIN_RIGHT;
484 let px_top = MARGIN_TOP;
485 let px_bottom = h as f64 - MARGIN_BOTTOM;
486
487 let title = config
488 .custom_params
489 .get("title")
490 .and_then(|v| v.as_str())
491 .unwrap_or("Histogram");
492 let x_label =
493 config.custom_params.get("x_label").and_then(|v| v.as_str()).unwrap_or("Value");
494 let y_label =
495 config.custom_params.get("y_label").and_then(|v| v.as_str()).unwrap_or("Count");
496
497 let max_count = bins.iter().map(|b| b.2).max().unwrap_or(1).max(1);
498 let x_min = bins.first().map(|b| b.0).unwrap_or(0.0);
499 let x_max = bins.last().map(|b| b.1).unwrap_or(1.0);
500
501 let mut out = svg_open(w, h);
502 out.push_str(&svg_title(title, w));
503 out.push_str(&svg_axes(
504 px_left,
505 px_right,
506 px_top,
507 px_bottom,
508 x_min,
509 x_max,
510 0.0,
511 max_count as f64,
512 x_label,
513 y_label,
514 w,
515 h,
516 5,
517 ));
518
519 let fill = BAR_COLOR;
521 for (bin_left, bin_right, count) in bins {
522 let bx1 = map_x(*bin_left, x_min, x_max, px_left, px_right);
523 let bx2 = map_x(*bin_right, x_min, x_max, px_left, px_right);
524 let by_top = map_y(*count as f64, 0.0, max_count as f64, px_top, px_bottom);
525 let bar_h = px_bottom - by_top;
526 let bar_w = (bx2 - bx1).max(1.0);
527 out.push_str(&format!(
528 "<rect x=\"{x:.2}\" y=\"{y:.2}\" width=\"{bw:.2}\" height=\"{bh:.2}\" fill=\"{fill}\" stroke=\"white\" stroke-width=\"1\"/>\n",
529 x = bx1, y = by_top, bw = bar_w, bh = bar_h, fill = fill,
530 ));
531 }
532
533 out.push_str(svg_close());
534 out
535 }
536
537 pub fn heatmap(rows: usize, cols: usize, values: &[f64], config: &PluginConfig) -> String {
539 let w = config.width;
540 let h = config.height;
541 let px_left = MARGIN_LEFT;
542 let px_right = w as f64 - MARGIN_RIGHT;
543 let px_top = MARGIN_TOP;
544 let px_bottom = h as f64 - MARGIN_BOTTOM;
545
546 let title = config.custom_params.get("title").and_then(|v| v.as_str()).unwrap_or("Heatmap");
547
548 let cell_w = if cols > 0 { (px_right - px_left) / cols as f64 } else { 1.0 };
549 let cell_h = if rows > 0 { (px_bottom - px_top) / rows as f64 } else { 1.0 };
550
551 let (v_min, v_max) =
552 values.iter().copied().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| {
553 (lo.min(v), hi.max(v))
554 });
555 let v_range = (v_max - v_min).max(f64::EPSILON);
556
557 let mut out = svg_open(w, h);
558 out.push_str(&svg_title(title, w));
559
560 for row_idx in 0..rows {
562 for col_idx in 0..cols {
563 let idx = row_idx * cols + col_idx;
564 let val = values.get(idx).copied().unwrap_or(0.0);
565 let t = ((val - v_min) / v_range).clamp(0.0, 1.0);
566 let red = (255.0 * (t * t)) as u8;
568 let green = (255.0 * t * (1.0 - t * 0.5)) as u8;
569 let blue = (255.0 * (1.0 - t)) as u8;
570 let cx = px_left + col_idx as f64 * cell_w;
571 let cy = px_top + row_idx as f64 * cell_h;
572 out.push_str(&format!(
573 "<rect x=\"{x:.2}\" y=\"{y:.2}\" width=\"{cw:.2}\" height=\"{ch:.2}\" fill=\"rgb({r},{g},{b})\"/>\n",
574 x = cx, y = cy, cw = cell_w, ch = cell_h,
575 r = red, g = green, b = blue,
576 ));
577 }
578 }
579
580 out.push_str(svg_close());
581 out
582 }
583
584 pub fn line_plot(points: &[(f64, f64)], config: &PluginConfig) -> String {
586 let w = config.width;
587 let h = config.height;
588 let px_left = MARGIN_LEFT;
589 let px_right = w as f64 - MARGIN_RIGHT;
590 let px_top = MARGIN_TOP;
591 let px_bottom = h as f64 - MARGIN_BOTTOM;
592
593 let title = config
594 .custom_params
595 .get("title")
596 .and_then(|v| v.as_str())
597 .unwrap_or("Line Plot");
598 let x_label = config.custom_params.get("x_label").and_then(|v| v.as_str()).unwrap_or("X");
599 let y_label = config.custom_params.get("y_label").and_then(|v| v.as_str()).unwrap_or("Y");
600
601 let (x_min, x_max, y_min, y_max) = data_bounds(points);
602
603 let mut out = svg_open(w, h);
604 out.push_str(&svg_title(title, w));
605 out.push_str(&svg_axes(
606 px_left, px_right, px_top, px_bottom, x_min, x_max, y_min, y_max, x_label, y_label, w,
607 h, 5,
608 ));
609
610 if !points.is_empty() {
611 let pts: Vec<String> = points
613 .iter()
614 .map(|(x, y)| {
615 let px = map_x(*x, x_min, x_max, px_left, px_right);
616 let py = map_y(*y, y_min, y_max, px_top, px_bottom);
617 format!("{:.2},{:.2}", px, py)
618 })
619 .collect();
620 let stroke = BAR_COLOR;
621 out.push_str(&format!(
622 "<polyline points=\"{pts}\" fill=\"none\" stroke=\"{stroke}\" stroke-width=\"2\"/>\n",
623 pts = pts.join(" "),
624 stroke = stroke,
625 ));
626 }
627
628 out.push_str(svg_close());
629 out
630 }
631
632 pub fn scatter(points: &[(f64, f64)], config: &PluginConfig) -> String {
634 let w = config.width;
635 let h = config.height;
636 let px_left = MARGIN_LEFT;
637 let px_right = w as f64 - MARGIN_RIGHT;
638 let px_top = MARGIN_TOP;
639 let px_bottom = h as f64 - MARGIN_BOTTOM;
640
641 let title = config
642 .custom_params
643 .get("title")
644 .and_then(|v| v.as_str())
645 .unwrap_or("Scatter Plot");
646 let x_label = config.custom_params.get("x_label").and_then(|v| v.as_str()).unwrap_or("X");
647 let y_label = config.custom_params.get("y_label").and_then(|v| v.as_str()).unwrap_or("Y");
648
649 let (x_min, x_max, y_min, y_max) = data_bounds(points);
650
651 let mut out = svg_open(w, h);
652 out.push_str(&svg_title(title, w));
653 out.push_str(&svg_axes(
654 px_left, px_right, px_top, px_bottom, x_min, x_max, y_min, y_max, x_label, y_label, w,
655 h, 5,
656 ));
657
658 let fill = BAR_COLOR;
659 for (x, y) in points {
660 let px = map_x(*x, x_min, x_max, px_left, px_right);
661 let py = map_y(*y, y_min, y_max, px_top, px_bottom);
662 out.push_str(&format!(
663 "<circle cx=\"{cx:.2}\" cy=\"{cy:.2}\" r=\"4\" fill=\"{fill}\" fill-opacity=\"0.7\"/>\n",
664 cx = px, cy = py, fill = fill,
665 ));
666 }
667
668 out.push_str(svg_close());
669 out
670 }
671
672 pub fn histogram_json(
678 bins: &[(f64, f64, usize)],
679 min: f64,
680 max: f64,
681 n_values: usize,
682 ) -> String {
683 let bins_arr: Vec<serde_json::Value> = bins
684 .iter()
685 .map(|(lo, hi, cnt)| {
686 serde_json::json!({
687 "bin_left": lo,
688 "bin_right": hi,
689 "count": cnt,
690 })
691 })
692 .collect();
693 serde_json::to_string_pretty(&serde_json::json!({
694 "type": "histogram",
695 "n_values": n_values,
696 "min": min,
697 "max": max,
698 "bins": bins_arr,
699 }))
700 .unwrap_or_else(|_| "{}".to_string())
701 }
702
703 pub fn histogram_csv(bins: &[(f64, f64, usize)]) -> String {
705 let mut out = String::from("bin_left,bin_right,count\n");
706 for (lo, hi, cnt) in bins {
707 out.push_str(&format!("{},{},{}\n", lo, hi, cnt));
708 }
709 out
710 }
711
712 pub fn points_json(kind: &str, points: &[(f64, f64)]) -> String {
714 let pts: Vec<serde_json::Value> =
715 points.iter().map(|(x, y)| serde_json::json!({ "x": x, "y": y })).collect();
716 serde_json::to_string_pretty(&serde_json::json!({
717 "type": kind,
718 "n_points": points.len(),
719 "points": pts,
720 }))
721 .unwrap_or_else(|_| "{}".to_string())
722 }
723
724 pub fn points_csv(points: &[(f64, f64)]) -> String {
726 let mut out = String::from("x,y\n");
727 for (x, y) in points {
728 out.push_str(&format!("{},{}\n", x, y));
729 }
730 out
731 }
732
733 pub fn wrap_html(svg: &str) -> String {
735 format!(
736 "<!DOCTYPE html><html><head><meta charset=\"utf-8\"/></head><body>{svg}</body></html>",
737 svg = svg
738 )
739 }
740
741 fn data_bounds(points: &[(f64, f64)]) -> (f64, f64, f64, f64) {
747 let mut x_min = f64::INFINITY;
748 let mut x_max = f64::NEG_INFINITY;
749 let mut y_min = f64::INFINITY;
750 let mut y_max = f64::NEG_INFINITY;
751 for (x, y) in points {
752 if *x < x_min {
753 x_min = *x;
754 }
755 if *x > x_max {
756 x_max = *x;
757 }
758 if *y < y_min {
759 y_min = *y;
760 }
761 if *y > y_max {
762 y_max = *y;
763 }
764 }
765 let x_pad = if (x_max - x_min).abs() < f64::EPSILON { 1.0 } else { (x_max - x_min) * 0.05 };
767 let y_pad = if (y_max - y_min).abs() < f64::EPSILON { 1.0 } else { (y_max - y_min) * 0.05 };
768 (x_min - x_pad, x_max + x_pad, y_min - y_pad, y_max + y_pad)
769 }
770}
771
772fn write_output_path(path: &str, bytes: &[u8]) -> Result<()> {
777 let p = PathBuf::from(path);
778 std::fs::write(&p, bytes)
779 .map_err(|e| anyhow::anyhow!("Failed to write output to {}: {}", p.display(), e))
780}
781
782struct HistogramPlugin;
788
789impl VisualizationPlugin for HistogramPlugin {
790 fn metadata(&self) -> PluginMetadata {
791 PluginMetadata {
792 name: "histogram".to_string(),
793 version: "1.0.0".to_string(),
794 description: "Generates histogram visualizations".to_string(),
795 author: "TrustformeRS".to_string(),
796 supported_inputs: vec!["Array1D".to_string(), "Tensor".to_string()],
797 supported_outputs: vec![
798 OutputFormat::Svg,
799 OutputFormat::Html,
800 OutputFormat::Text,
801 OutputFormat::Json,
802 OutputFormat::Csv,
803 ],
804 tags: vec!["distribution".to_string(), "statistics".to_string()],
805 }
806 }
807
808 fn config_schema(&self) -> serde_json::Value {
809 serde_json::json!({
810 "width": 800,
811 "height": 600,
812 "title": "Histogram",
813 "x_label": "Value",
814 "y_label": "Count",
815 "bins": 20
816 })
817 }
818
819 fn execute(&self, data: VisualizationData, config: PluginConfig) -> Result<PluginResult> {
820 let values = match data {
821 VisualizationData::Array1D(v) => v,
822 VisualizationData::Tensor { data, .. } => data,
823 _ => anyhow::bail!("Unsupported data type for histogram"),
824 };
825
826 if values.is_empty() {
827 anyhow::bail!("Histogram requires non-empty input data");
828 }
829
830 let n_bins =
832 config.custom_params.get("bins").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
833 let n_bins = n_bins.max(1);
834
835 let min = values.iter().copied().fold(f64::INFINITY, f64::min);
836 let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
837 let bin_width =
838 if (max - min).abs() < f64::EPSILON { 1.0 } else { (max - min) / n_bins as f64 };
839
840 let mut counts = vec![0usize; n_bins];
841 for &value in &values {
842 let bin_idx = ((value - min) / bin_width).floor() as usize;
843 let bin_idx = bin_idx.min(n_bins - 1);
844 counts[bin_idx] += 1;
845 }
846
847 let bins: Vec<(f64, f64, usize)> = counts
848 .iter()
849 .enumerate()
850 .map(|(i, &cnt)| {
851 let lo = min + i as f64 * bin_width;
852 let hi = lo + bin_width;
853 (lo, hi, cnt)
854 })
855 .collect();
856
857 let bytes = match config.output_format {
858 OutputFormat::Png => {
859 anyhow::bail!(
860 "PNG output is not supported (requires binary encoder); use Svg instead"
861 )
862 },
863 OutputFormat::Svg => {
864 let svg = svg_render::histogram(&bins, &config);
865 svg.into_bytes()
866 },
867 OutputFormat::Html => {
868 let svg = svg_render::histogram(&bins, &config);
869 svg_render::wrap_html(&svg).into_bytes()
870 },
871 OutputFormat::Text => {
872 let output_text = format!(
873 "Histogram (bins={}):\nMin={:.4}, Max={:.4}\nBin counts: {:?}",
874 n_bins, min, max, counts
875 );
876 output_text.into_bytes()
877 },
878 OutputFormat::Json => {
879 svg_render::histogram_json(&bins, min, max, values.len()).into_bytes()
880 },
881 OutputFormat::Csv => svg_render::histogram_csv(&bins).into_bytes(),
882 };
883
884 let out_path_str = if let Some(ref path) = config.output_path {
885 write_output_path(path, &bytes)?;
886 Some(path.clone())
887 } else {
888 None
889 };
890
891 Ok(PluginResult {
892 success: true,
893 output_path: out_path_str,
894 output_data: Some(bytes),
895 metadata: {
896 let mut m = HashMap::new();
897 m.insert("bins".to_string(), n_bins.to_string());
898 m.insert("min".to_string(), min.to_string());
899 m.insert("max".to_string(), max.to_string());
900 m.insert("n_values".to_string(), values.len().to_string());
901 m
902 },
903 error: None,
904 })
905 }
906
907 fn validate(&self, data: &VisualizationData) -> bool {
908 matches!(
909 data,
910 VisualizationData::Array1D(_) | VisualizationData::Tensor { .. }
911 )
912 }
913}
914
915struct HeatmapPlugin;
917
918impl VisualizationPlugin for HeatmapPlugin {
919 fn metadata(&self) -> PluginMetadata {
920 PluginMetadata {
921 name: "heatmap".to_string(),
922 version: "1.0.0".to_string(),
923 description: "Generates heatmap visualizations for 2D data".to_string(),
924 author: "TrustformeRS".to_string(),
925 supported_inputs: vec!["Array2D".to_string(), "Tensor".to_string()],
926 supported_outputs: vec![
927 OutputFormat::Svg,
928 OutputFormat::Html,
929 OutputFormat::Json,
930 OutputFormat::Csv,
931 OutputFormat::Text,
932 ],
933 tags: vec!["matrix".to_string(), "2d".to_string()],
934 }
935 }
936
937 fn config_schema(&self) -> serde_json::Value {
938 serde_json::json!({
939 "width": 800,
940 "height": 600,
941 "title": "Heatmap",
942 "x_label": "",
943 "y_label": ""
944 })
945 }
946
947 fn execute(&self, data: VisualizationData, config: PluginConfig) -> Result<PluginResult> {
948 let (rows, cols, flat_values) = match &data {
949 VisualizationData::Array2D(v) => {
950 let r = v.len();
951 let c = v.first().map(|row| row.len()).unwrap_or(0);
952 let flat: Vec<f64> = v.iter().flat_map(|row| row.iter().copied()).collect();
953 (r, c, flat)
954 },
955 VisualizationData::Tensor { shape, data } if shape.len() == 2 => {
956 (shape[0], shape[1], data.clone())
957 },
958 _ => anyhow::bail!("Heatmap requires 2D data"),
959 };
960
961 let bytes = match config.output_format {
962 OutputFormat::Png => {
963 anyhow::bail!(
964 "PNG output is not supported (requires binary encoder); use Svg instead"
965 )
966 },
967 OutputFormat::Svg => {
968 let svg = svg_render::heatmap(rows, cols, &flat_values, &config);
969 svg.into_bytes()
970 },
971 OutputFormat::Html => {
972 let svg = svg_render::heatmap(rows, cols, &flat_values, &config);
973 svg_render::wrap_html(&svg).into_bytes()
974 },
975 OutputFormat::Text => format!("Heatmap {}x{}", rows, cols).into_bytes(),
976 OutputFormat::Json => {
977 let cells: Vec<serde_json::Value> = flat_values
978 .iter()
979 .enumerate()
980 .map(|(i, v)| {
981 serde_json::json!({
982 "row": i / cols.max(1),
983 "col": i % cols.max(1),
984 "value": v,
985 })
986 })
987 .collect();
988 serde_json::to_string_pretty(&serde_json::json!({
989 "type": "heatmap",
990 "rows": rows,
991 "cols": cols,
992 "cells": cells,
993 }))
994 .unwrap_or_else(|_| "{}".to_string())
995 .into_bytes()
996 },
997 OutputFormat::Csv => {
998 let mut out = String::from("row,col,value\n");
999 for (i, v) in flat_values.iter().enumerate() {
1000 let r = i / cols.max(1);
1001 let c = i % cols.max(1);
1002 out.push_str(&format!("{},{},{}\n", r, c, v));
1003 }
1004 out.into_bytes()
1005 },
1006 };
1007
1008 let out_path_str = if let Some(ref path) = config.output_path {
1009 write_output_path(path, &bytes)?;
1010 Some(path.clone())
1011 } else {
1012 None
1013 };
1014
1015 Ok(PluginResult {
1016 success: true,
1017 output_path: out_path_str,
1018 output_data: Some(bytes),
1019 metadata: {
1020 let mut m = HashMap::new();
1021 m.insert("rows".to_string(), rows.to_string());
1022 m.insert("cols".to_string(), cols.to_string());
1023 m
1024 },
1025 error: None,
1026 })
1027 }
1028
1029 fn validate(&self, data: &VisualizationData) -> bool {
1030 match data {
1031 VisualizationData::Array2D(_) => true,
1032 VisualizationData::Tensor { shape, .. } => shape.len() == 2,
1033 _ => false,
1034 }
1035 }
1036}
1037
1038struct LinePlotPlugin;
1040
1041impl VisualizationPlugin for LinePlotPlugin {
1042 fn metadata(&self) -> PluginMetadata {
1043 PluginMetadata {
1044 name: "lineplot".to_string(),
1045 version: "1.0.0".to_string(),
1046 description: "Generates line plots for time series data".to_string(),
1047 author: "TrustformeRS".to_string(),
1048 supported_inputs: vec!["TimeSeries".to_string(), "Array1D".to_string()],
1049 supported_outputs: vec![
1050 OutputFormat::Svg,
1051 OutputFormat::Html,
1052 OutputFormat::Text,
1053 OutputFormat::Json,
1054 OutputFormat::Csv,
1055 ],
1056 tags: vec!["timeseries".to_string(), "trend".to_string()],
1057 }
1058 }
1059
1060 fn config_schema(&self) -> serde_json::Value {
1061 serde_json::json!({
1062 "width": 800,
1063 "height": 600,
1064 "title": "Line Plot",
1065 "x_label": "X",
1066 "y_label": "Y"
1067 })
1068 }
1069
1070 fn execute(&self, data: VisualizationData, config: PluginConfig) -> Result<PluginResult> {
1071 let points: Vec<(f64, f64)> = match &data {
1072 VisualizationData::TimeSeries {
1073 timestamps, values, ..
1074 } => timestamps.iter().zip(values.iter()).map(|(t, v)| (*t, *v)).collect(),
1075 VisualizationData::Array1D(v) => {
1076 v.iter().enumerate().map(|(i, val)| (i as f64, *val)).collect()
1077 },
1078 _ => anyhow::bail!("Line plot requires time series or 1D array data"),
1079 };
1080
1081 let n_points = points.len();
1082
1083 let bytes = match config.output_format {
1084 OutputFormat::Png => {
1085 anyhow::bail!(
1086 "PNG output is not supported (requires binary encoder); use Svg instead"
1087 )
1088 },
1089 OutputFormat::Svg => {
1090 let svg = svg_render::line_plot(&points, &config);
1091 svg.into_bytes()
1092 },
1093 OutputFormat::Html => {
1094 let svg = svg_render::line_plot(&points, &config);
1095 svg_render::wrap_html(&svg).into_bytes()
1096 },
1097 OutputFormat::Text => format!("Line plot with {} points", n_points).into_bytes(),
1098 OutputFormat::Json => svg_render::points_json("lineplot", &points).into_bytes(),
1099 OutputFormat::Csv => svg_render::points_csv(&points).into_bytes(),
1100 };
1101
1102 let out_path_str = if let Some(ref path) = config.output_path {
1103 write_output_path(path, &bytes)?;
1104 Some(path.clone())
1105 } else {
1106 None
1107 };
1108
1109 Ok(PluginResult {
1110 success: true,
1111 output_path: out_path_str,
1112 output_data: Some(bytes),
1113 metadata: {
1114 let mut m = HashMap::new();
1115 m.insert("points".to_string(), n_points.to_string());
1116 m
1117 },
1118 error: None,
1119 })
1120 }
1121
1122 fn validate(&self, data: &VisualizationData) -> bool {
1123 matches!(
1124 data,
1125 VisualizationData::TimeSeries { .. } | VisualizationData::Array1D(_)
1126 )
1127 }
1128}
1129
1130struct ScatterPlotPlugin;
1132
1133impl VisualizationPlugin for ScatterPlotPlugin {
1134 fn metadata(&self) -> PluginMetadata {
1135 PluginMetadata {
1136 name: "scatterplot".to_string(),
1137 version: "1.0.0".to_string(),
1138 description: "Generates scatter plots for 2D point data".to_string(),
1139 author: "TrustformeRS".to_string(),
1140 supported_inputs: vec!["Array2D".to_string()],
1141 supported_outputs: vec![
1142 OutputFormat::Svg,
1143 OutputFormat::Html,
1144 OutputFormat::Text,
1145 OutputFormat::Json,
1146 OutputFormat::Csv,
1147 ],
1148 tags: vec!["correlation".to_string(), "distribution".to_string()],
1149 }
1150 }
1151
1152 fn config_schema(&self) -> serde_json::Value {
1153 serde_json::json!({
1154 "width": 800,
1155 "height": 600,
1156 "title": "Scatter Plot",
1157 "x_label": "X",
1158 "y_label": "Y"
1159 })
1160 }
1161
1162 fn execute(&self, data: VisualizationData, config: PluginConfig) -> Result<PluginResult> {
1163 let points: Vec<(f64, f64)> = match &data {
1164 VisualizationData::Array2D(v) => v
1165 .iter()
1166 .filter_map(
1167 |row| {
1168 if row.len() >= 2 {
1169 Some((row[0], row[1]))
1170 } else {
1171 None
1172 }
1173 },
1174 )
1175 .collect(),
1176 _ => anyhow::bail!("Scatter plot requires 2D array data (each row = [x, y])"),
1177 };
1178
1179 let n_points = points.len();
1180
1181 let bytes = match config.output_format {
1182 OutputFormat::Png => {
1183 anyhow::bail!(
1184 "PNG output is not supported (requires binary encoder); use Svg instead"
1185 )
1186 },
1187 OutputFormat::Svg => {
1188 let svg = svg_render::scatter(&points, &config);
1189 svg.into_bytes()
1190 },
1191 OutputFormat::Html => {
1192 let svg = svg_render::scatter(&points, &config);
1193 svg_render::wrap_html(&svg).into_bytes()
1194 },
1195 OutputFormat::Text => format!("Scatter plot with {} points", n_points).into_bytes(),
1196 OutputFormat::Json => svg_render::points_json("scatterplot", &points).into_bytes(),
1197 OutputFormat::Csv => svg_render::points_csv(&points).into_bytes(),
1198 };
1199
1200 let out_path_str = if let Some(ref path) = config.output_path {
1201 write_output_path(path, &bytes)?;
1202 Some(path.clone())
1203 } else {
1204 None
1205 };
1206
1207 Ok(PluginResult {
1208 success: true,
1209 output_path: out_path_str,
1210 output_data: Some(bytes),
1211 metadata: {
1212 let mut m = HashMap::new();
1213 m.insert("points".to_string(), n_points.to_string());
1214 m
1215 },
1216 error: None,
1217 })
1218 }
1219
1220 fn validate(&self, data: &VisualizationData) -> bool {
1221 matches!(data, VisualizationData::Array2D(_))
1222 }
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227 use super::*;
1228
1229 #[test]
1234 fn test_plugin_manager_creation() {
1235 let manager = PluginManager::new();
1236 let plugins = manager.list_plugins();
1237 assert!(!plugins.is_empty());
1238 }
1239
1240 #[test]
1241 fn test_histogram_plugin() {
1242 let manager = PluginManager::new();
1243 let data = VisualizationData::Array1D(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
1244 let config = PluginConfig::default(); let result = manager.execute("histogram", data, config).expect("operation failed in test");
1247
1248 assert!(result.success);
1249 assert!(result.output_data.is_some());
1250 }
1251
1252 #[test]
1253 fn test_plugin_validation() {
1254 let manager = PluginManager::new();
1255
1256 let data = VisualizationData::Array1D(vec![1.0, 2.0, 3.0]);
1258 let config = PluginConfig::default();
1259 assert!(manager.execute("histogram", data, config.clone()).is_ok());
1260
1261 let data = VisualizationData::Array1D(vec![1.0, 2.0, 3.0]);
1263 assert!(manager.execute("heatmap", data, config).is_err());
1264 }
1265
1266 #[test]
1267 fn test_custom_plugin_registration() {
1268 let manager = PluginManager::new();
1269 let count_before = manager.list_plugins().len();
1270
1271 manager
1273 .register_plugin(Box::new(HistogramPlugin))
1274 .expect("operation failed in test");
1275
1276 let count_after = manager.list_plugins().len();
1277 assert_eq!(count_before, count_after);
1278 }
1279
1280 fn svg_config() -> PluginConfig {
1285 PluginConfig {
1286 output_format: OutputFormat::Svg,
1287 ..PluginConfig::default()
1288 }
1289 }
1290
1291 #[test]
1292 fn test_histogram_svg_contains_rect() {
1293 let plugin = HistogramPlugin;
1294 let data = VisualizationData::Array1D(vec![1.0, 2.0, 2.5, 3.0, 4.0, 5.0, 5.5]);
1295 let result = plugin.execute(data, svg_config()).expect("histogram SVG render failed");
1296 assert!(result.success);
1297 let bytes = result.output_data.expect("no output data");
1298 let svg = String::from_utf8(bytes).expect("invalid UTF-8");
1299 assert!(
1300 svg.contains("<rect"),
1301 "SVG histogram should contain <rect elements; got: {}",
1302 &svg[..svg.len().min(300)]
1303 );
1304 assert!(svg.starts_with("<svg"), "output should start with <svg tag");
1305 }
1306
1307 #[test]
1308 fn test_heatmap_svg_contains_rect_cells() {
1309 let plugin = HeatmapPlugin;
1310 let data = VisualizationData::Array2D(vec![
1311 vec![1.0, 2.0, 3.0],
1312 vec![4.0, 5.0, 6.0],
1313 vec![7.0, 8.0, 9.0],
1314 ]);
1315 let result = plugin.execute(data, svg_config()).expect("heatmap SVG render failed");
1316 assert!(result.success);
1317 let bytes = result.output_data.expect("no output data");
1318 let svg = String::from_utf8(bytes).expect("invalid UTF-8");
1319 assert!(
1320 svg.contains("<rect"),
1321 "SVG heatmap should contain <rect cell elements"
1322 );
1323 assert!(svg.starts_with("<svg"));
1324 }
1325
1326 #[test]
1327 fn test_line_plot_svg_contains_polyline() {
1328 let plugin = LinePlotPlugin;
1329 let data = VisualizationData::TimeSeries {
1330 timestamps: vec![0.0, 1.0, 2.0, 3.0, 4.0],
1331 values: vec![0.1, 0.4, 0.9, 0.3, 0.7],
1332 labels: vec![],
1333 };
1334 let result = plugin.execute(data, svg_config()).expect("line plot SVG render failed");
1335 assert!(result.success);
1336 let bytes = result.output_data.expect("no output data");
1337 let svg = String::from_utf8(bytes).expect("invalid UTF-8");
1338 assert!(
1339 svg.contains("<polyline"),
1340 "SVG line plot should contain <polyline element"
1341 );
1342 assert!(svg.starts_with("<svg"));
1343 }
1344
1345 #[test]
1346 fn test_scatter_svg_contains_circle() {
1347 let plugin = ScatterPlotPlugin;
1348 let data = VisualizationData::Array2D(vec![
1349 vec![1.0, 2.0],
1350 vec![3.0, 4.0],
1351 vec![5.0, 6.0],
1352 vec![7.0, 8.0],
1353 ]);
1354 let result = plugin.execute(data, svg_config()).expect("scatter plot SVG render failed");
1355 assert!(result.success);
1356 let bytes = result.output_data.expect("no output data");
1357 let svg = String::from_utf8(bytes).expect("invalid UTF-8");
1358 assert!(
1359 svg.contains("<circle"),
1360 "SVG scatter plot should contain <circle elements"
1361 );
1362 assert!(svg.starts_with("<svg"));
1363 }
1364
1365 #[test]
1366 fn test_output_path_writes_file() {
1367 let plugin = HistogramPlugin;
1368 let data = VisualizationData::Array1D(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
1369
1370 let tmp_dir = std::env::temp_dir();
1371 let out_file = tmp_dir.join("test_histogram_visp.svg");
1372 let out_path_str = out_file.to_str().expect("temp path is not valid UTF-8").to_string();
1373
1374 let config = PluginConfig {
1375 output_format: OutputFormat::Svg,
1376 output_path: Some(out_path_str.clone()),
1377 ..PluginConfig::default()
1378 };
1379
1380 let result = plugin.execute(data, config).expect("histogram with output_path failed");
1381 assert!(result.success);
1382 assert_eq!(result.output_path.as_deref(), Some(out_path_str.as_str()));
1383
1384 let written = std::fs::read_to_string(&out_file).expect("output file not found on disk");
1385 assert!(
1386 written.contains("<rect"),
1387 "Written SVG file should contain <rect"
1388 );
1389
1390 std::fs::remove_file(&out_file).ok();
1392 }
1393
1394 #[test]
1395 fn test_png_returns_error() {
1396 let plugin = HistogramPlugin;
1397 let data = VisualizationData::Array1D(vec![1.0, 2.0, 3.0]);
1398 let config = PluginConfig {
1399 output_format: OutputFormat::Png,
1400 ..PluginConfig::default()
1401 };
1402 let result = plugin.execute(data, config);
1403 assert!(result.is_err(), "PNG format should return an error, not Ok");
1404 let err = result.unwrap_err();
1405 let msg = err.to_string().to_lowercase();
1406 assert!(
1407 msg.contains("png") || msg.contains("not supported"),
1408 "Error message should mention PNG or not supported; got: {}",
1409 err
1410 );
1411 }
1412
1413 #[test]
1414 fn test_html_wraps_svg() {
1415 let plugin = ScatterPlotPlugin;
1416 let data = VisualizationData::Array2D(vec![vec![0.0, 1.0], vec![2.0, 3.0]]);
1417 let config = PluginConfig {
1418 output_format: OutputFormat::Html,
1419 ..PluginConfig::default()
1420 };
1421 let result = plugin.execute(data, config).expect("HTML render failed");
1422 let bytes = result.output_data.expect("no output data");
1423 let html = String::from_utf8(bytes).expect("invalid UTF-8");
1424 assert!(
1425 html.contains("<!DOCTYPE html>"),
1426 "HTML output should contain DOCTYPE"
1427 );
1428 assert!(html.contains("<svg"), "HTML output should embed SVG");
1429 }
1430
1431 #[test]
1432 fn test_histogram_json_output() {
1433 let plugin = HistogramPlugin;
1434 let data = VisualizationData::Array1D(vec![1.0, 2.0, 3.0, 4.0]);
1435 let config = PluginConfig {
1436 output_format: OutputFormat::Json,
1437 ..PluginConfig::default()
1438 };
1439 let result = plugin.execute(data, config).expect("JSON render failed");
1440 let bytes = result.output_data.expect("no output data");
1441 let json_str = String::from_utf8(bytes).expect("invalid UTF-8");
1442 let parsed: serde_json::Value =
1443 serde_json::from_str(&json_str).expect("output is not valid JSON");
1444 assert_eq!(parsed["type"], "histogram");
1445 assert!(parsed["bins"].is_array());
1446 }
1447
1448 #[test]
1449 fn test_histogram_csv_output() {
1450 let plugin = HistogramPlugin;
1451 let data = VisualizationData::Array1D(vec![1.0, 2.0, 3.0, 4.0]);
1452 let config = PluginConfig {
1453 output_format: OutputFormat::Csv,
1454 ..PluginConfig::default()
1455 };
1456 let result = plugin.execute(data, config).expect("CSV render failed");
1457 let bytes = result.output_data.expect("no output data");
1458 let csv_str = String::from_utf8(bytes).expect("invalid UTF-8");
1459 assert!(
1460 csv_str.starts_with("bin_left,bin_right,count"),
1461 "CSV should start with header"
1462 );
1463 }
1464
1465 #[test]
1466 fn test_config_schema_fields() {
1467 let hist_schema = HistogramPlugin.config_schema();
1468 assert!(hist_schema["width"].is_number());
1469 assert!(hist_schema["height"].is_number());
1470
1471 let heat_schema = HeatmapPlugin.config_schema();
1472 assert!(heat_schema["width"].is_number());
1473
1474 let line_schema = LinePlotPlugin.config_schema();
1475 assert!(line_schema["x_label"].is_string());
1476
1477 let scatter_schema = ScatterPlotPlugin.config_schema();
1478 assert!(scatter_schema["title"].is_string());
1479 }
1480}