1use chrono::{DateTime, Utc};
8use scirs2_core::ndarray::{Array1, Array2};
9use std::collections::HashMap;
10use std::path::Path;
11
12use serde::{Deserialize, Serialize};
13
14use super::animation::{AnimationFrame, StreamingFrame};
15use super::interactive::{CameraState, ClusterStats, ViewMode};
16use super::{ScatterPlot2D, ScatterPlot3D, VisualizationConfig};
17use crate::error::{ClusteringError, Result};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21pub enum ExportFormat {
22 PNG,
24 SVG,
26 PDF,
28 GIF,
30 MP4,
32 WebM,
34 HTML,
36 JSON,
38 CSV,
40 PlotlyJSON,
42 ThreeJS,
44 GLTF,
46 Unity3D,
48 Blender,
50 RGGplot,
52 Matplotlib,
54 D3JS,
56 WebGL,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct ExportConfig {
63 pub format: ExportFormat,
65 pub dimensions: (u32, u32),
67 pub dpi: u32,
69 pub quality: u8,
71 pub fps: f32,
73 pub duration: f32,
75 pub include_metadata: bool,
77 pub compression: u8,
79 pub background_color: String,
81 pub interactive: bool,
83 pub custom_styling: Option<String>,
85 pub animation_controls: bool,
87 pub stereoscopic: bool,
89}
90
91impl Default for ExportConfig {
92 fn default() -> Self {
93 Self {
94 format: ExportFormat::PNG,
95 dimensions: (1920, 1080),
96 dpi: 300,
97 quality: 90,
98 fps: 30.0,
99 duration: 10.0,
100 include_metadata: true,
101 compression: 6,
102 background_color: "#FFFFFF".to_string(),
103 interactive: false,
104 custom_styling: None,
105 animation_controls: true,
106 stereoscopic: false,
107 }
108 }
109}
110
111#[allow(dead_code)]
123pub fn export_scatter_2d_to_file<P: AsRef<Path>>(
124 plot: &ScatterPlot2D,
125 output_path: P,
126 config: &ExportConfig,
127) -> Result<()> {
128 let path = output_path.as_ref();
129
130 match config.format {
131 ExportFormat::JSON => export_scatter_2d_to_json(plot, path, config),
132 ExportFormat::HTML => export_scatter_2d_to_html(plot, path, config),
133 ExportFormat::CSV => export_scatter_2d_to_csv(plot, path, config),
134 ExportFormat::PlotlyJSON => export_scatter_2d_to_plotly(plot, path, config),
135 ExportFormat::D3JS => export_scatter_2d_to_d3(plot, path, config),
136 ExportFormat::SVG => export_scatter_2d_to_svg(plot, path, config),
137 ExportFormat::PNG => export_scatter_2d_to_png(plot, path, config),
138 _ => Err(ClusteringError::ComputationError(format!(
139 "Unsupported export format {:?} for 2D scatter plot",
140 config.format
141 ))),
142 }
143}
144
145#[allow(dead_code)]
157pub fn export_scatter_3d_to_file<P: AsRef<Path>>(
158 plot: &ScatterPlot3D,
159 output_path: P,
160 config: &ExportConfig,
161) -> Result<()> {
162 let path = output_path.as_ref();
163
164 match config.format {
165 ExportFormat::JSON => export_scatter_3d_to_json(plot, path, config),
166 ExportFormat::HTML => export_scatter_3d_to_html(plot, path, config),
167 ExportFormat::ThreeJS => export_scatter_3d_to_threejs(plot, path, config),
168 ExportFormat::GLTF => export_scatter_3d_to_gltf(plot, path, config),
169 ExportFormat::WebGL => export_scatter_3d_to_webgl(plot, path, config),
170 ExportFormat::Unity3D => export_scatter_3d_to_unity(plot, path, config),
171 ExportFormat::Blender => export_scatter_3d_to_blender(plot, path, config),
172 _ => Err(ClusteringError::ComputationError(format!(
173 "Unsupported export format {:?} for 3D scatter plot",
174 config.format
175 ))),
176 }
177}
178
179#[allow(dead_code)]
191pub fn export_animation_to_file<P: AsRef<Path>>(
192 frames: &[AnimationFrame],
193 output_path: P,
194 config: &ExportConfig,
195) -> Result<()> {
196 let path = output_path.as_ref();
197
198 match config.format {
199 ExportFormat::GIF => export_animation_to_gif(frames, path, config),
200 ExportFormat::MP4 => export_animation_to_mp4(frames, path, config),
201 ExportFormat::WebM => export_animation_to_webm(frames, path, config),
202 ExportFormat::HTML => export_animation_to_html(frames, path, config),
203 ExportFormat::JSON => export_animation_to_json(frames, path, config),
204 _ => Err(ClusteringError::ComputationError(format!(
205 "Unsupported export format {:?} for animation",
206 config.format
207 ))),
208 }
209}
210
211#[allow(dead_code)]
213#[allow(unused_variables)]
214pub fn export_scatter_2d_to_json<P: AsRef<Path>>(
215 plot: &ScatterPlot2D,
216 output_path: P,
217 config: &ExportConfig,
218) -> Result<()> {
219 #[cfg(feature = "serde")]
220 {
221 let export_data = Scatter2DExport {
222 format_version: "1.0".to_string(),
223 export_config: config.clone(),
224 plot_data: plot.clone(),
225 metadata: create_metadata(),
226 };
227
228 let json_string = serde_json::to_string_pretty(&export_data).map_err(|e| {
229 ClusteringError::ComputationError(format!("JSON serialization failed: {}", e))
230 })?;
231
232 std::fs::write(output_path, json_string)
233 .map_err(|e| ClusteringError::ComputationError(format!("File write failed: {}", e)))?;
234
235 return Ok(());
236 }
237
238 #[cfg(not(feature = "serde"))]
239 {
240 Err(ClusteringError::ComputationError(
241 "JSON export requires 'serde' feature".to_string(),
242 ))
243 }
244}
245
246#[allow(dead_code)]
248#[allow(unused_variables)]
249pub fn export_scatter_3d_to_json<P: AsRef<Path>>(
250 plot: &ScatterPlot3D,
251 output_path: P,
252 config: &ExportConfig,
253) -> Result<()> {
254 #[cfg(feature = "serde")]
255 {
256 let export_data = Scatter3DExport {
257 format_version: "1.0".to_string(),
258 export_config: config.clone(),
259 plot_data: plot.clone(),
260 metadata: create_metadata(),
261 };
262
263 let json_string = serde_json::to_string_pretty(&export_data).map_err(|e| {
264 ClusteringError::ComputationError(format!("JSON serialization failed: {}", e))
265 })?;
266
267 std::fs::write(output_path, json_string)
268 .map_err(|e| ClusteringError::ComputationError(format!("File write failed: {}", e)))?;
269
270 return Ok(());
271 }
272
273 #[cfg(not(feature = "serde"))]
274 {
275 Err(ClusteringError::ComputationError(
276 "JSON export requires 'serde' feature".to_string(),
277 ))
278 }
279}
280
281#[allow(dead_code)]
283pub fn export_scatter_2d_to_html<P: AsRef<Path>>(
284 plot: &ScatterPlot2D,
285 output_path: P,
286 config: &ExportConfig,
287) -> Result<()> {
288 let html_content = generate_scatter_2d_html(plot, config)?;
289
290 std::fs::write(output_path, html_content)
291 .map_err(|e| ClusteringError::ComputationError(format!("File write failed: {}", e)))?;
292
293 Ok(())
294}
295
296#[allow(dead_code)]
298pub fn export_scatter_3d_to_html<P: AsRef<Path>>(
299 plot: &ScatterPlot3D,
300 output_path: P,
301 config: &ExportConfig,
302) -> Result<()> {
303 let html_content = generate_scatter_3d_html(plot, config)?;
304
305 std::fs::write(output_path, html_content)
306 .map_err(|e| ClusteringError::ComputationError(format!("File write failed: {}", e)))?;
307
308 Ok(())
309}
310
311#[allow(dead_code)]
313pub fn save_visualization_to_file<P: AsRef<Path>>(
314 plot_2d: Option<&ScatterPlot2D>,
315 plot_3d: Option<&ScatterPlot3D>,
316 animation_frames: Option<&[AnimationFrame]>,
317 output_path: P,
318 mut config: ExportConfig,
319) -> Result<()> {
320 let path = output_path.as_ref();
321
322 if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
324 config.format = match extension.to_lowercase().as_str() {
325 "png" => ExportFormat::PNG,
326 "svg" => ExportFormat::SVG,
327 "pdf" => ExportFormat::PDF,
328 "gif" => ExportFormat::GIF,
329 "mp4" => ExportFormat::MP4,
330 "webm" => ExportFormat::WebM,
331 "html" => ExportFormat::HTML,
332 "json" => ExportFormat::JSON,
333 "csv" => ExportFormat::CSV,
334 "gltf" | "glb" => ExportFormat::GLTF,
335 _ => config.format, };
337 }
338
339 if let Some(_frames) = animation_frames {
341 export_animation_to_file(_frames, path, &config)
342 } else if let Some(plot_3d) = plot_3d {
343 export_scatter_3d_to_file(plot_3d, path, &config)
344 } else if let Some(plot_2d) = plot_2d {
345 export_scatter_2d_to_file(plot_2d, path, &config)
346 } else {
347 Err(ClusteringError::InvalidInput(
348 "No visualization data provided for export".to_string(),
349 ))
350 }
351}
352
353#[allow(dead_code)]
355fn generate_scatter_2d_html(plot: &ScatterPlot2D, config: &ExportConfig) -> Result<String> {
356 let plot_data = serde_json::json!({
358 "type": "scatter2d",
359 "data": "plot_data_placeholder" });
361 let plot_data_json = serde_json::to_string(&plot_data).map_err(|e| {
362 ClusteringError::ComputationError(format!("JSON serialization failed: {}", e))
363 })?;
364
365 const HTML_TEMPLATE: &str = "<!DOCTYPE html>
366<html lang=\"en\">
367<head>
368 <meta charset=\"UTF-8\">
369 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">
370 <title>Clustering Visualization</title>
371 <script src=\"https://d3js.org/d3.v7.min.js\"></script>
372 <style>
373 body {{ font-family: Arial, sans-serif; margin: 0; padding: 20px; background: {background}; }}
374 .container {{ max-width: 1200px; margin: 0 auto; }}
375 .visualization {{ border: 1px solid #ccc; border-radius: 8px; }}
376 .controls {{ margin: 20px 0; }}
377 .legend {{ margin-top: 20px; }}
378 .legend-item {{ display: inline-block; margin-right: 20px; }}
379 .legend-color {{ width: 20px; height: 20px; display: inline-block; margin-right: 5px; vertical-align: middle; }}
380 {custom_css}
381 </style>
382</head>
383<body>
384 <div class=\"container\">
385 <h1>Clustering Visualization</h1>
386 <div id=\"_plot\" class=\"visualization\"></div>
387 <div class=\"legend\" id=\"legend\"></div>
388 <div class=\"controls\">
389 <label>Point Size: <input type=\"range\" id=\"point-size\" min=\"1\" max=\"20\" value=\"{point_size}\"></label>
390 <label>Opacity: <input type=\"range\" id=\"opacity\" min=\"0\" max=\"100\" value=\"{opacity}\"></label>
391 </div>
392 </div>
393
394 <script>
395 const plotData = {plot_data};
396 const config = {{
397 width: {width},
398 height: {height},
399 interactive: {interactive}
400 }};
401
402 function createVisualization() {{
403 const svg = d3.select(\"HASH_PLOT\")
404 .append(\"svg\")
405 .attr(\"width\", config.width)
406 .attr(\"height\", config.height);
407
408 const margin = {{top: 20, right: 30, bottom: 40, left: 40}};
409 const width = config.width - margin.left - margin.right;
410 const height = config.height - margin.top - margin.bottom;
411
412 const g = svg.append(\"g\")
413 .attr(\"transform\", \"translate(\" + margin.left + \",\" + margin.top + \")\");
414
415 const xScale = d3.scaleLinear()
416 .domain(d3.extent(plotData.points.flat().filter((_, i) => i % 2 === 0)))
417 .range([0, width]);
418
419 const yScale = d3.scaleLinear()
420 .domain(d3.extent(plotData.points.flat().filter((_, i) => i % 2 === 1)))
421 .range([height, 0]);
422
423 g.append(\"g\")
424 .attr(\"transform\", \"translate(0,\" + height + \")\")
425 .call(d3.axisBottom(xScale));
426
427 g.append(\"g\")
428 .call(d3.axisLeft(yScale));
429
430 const points = [];
431 for (let i = 0; i < plotData.points.length; i++) {{
432 points.push({{
433 x: plotData.points[i][0],
434 y: plotData.points[i][1],
435 label: plotData.labels[i],
436 color: plotData.colors[i],
437 size: plotData.sizes[i]
438 }});
439 }}
440
441 g.selectAll(\"DOT_POINT\")
442 .data(points)
443 .enter().append(\"circle\")
444 .attr(\"class\", \"point\")
445 .attr(\"cx\", d => xScale(d.x))
446 .attr(\"cy\", d => yScale(d.y))
447 .attr(\"r\", d => d.size)
448 .attr(\"fill\", d => d.color)
449 .attr(\"opacity\", {opacity});
450
451 const legend = d3.select(\"HASH_LEGEND\");
452 plotData.legend.forEach(item => {{
453 const legendItem = legend.append(\"div\")
454 .attr(\"class\", \"legend-item\");
455
456 legendItem.append(\"div\")
457 .attr(\"class\", \"legend-color\")
458 .style(\"background-color\", item.color);
459
460 legendItem.append(\"span\")
461 .text(item.label + \" (\" + item.count + \" points)\");
462 }});
463 }}
464
465 createVisualization();
466
467 if (config.interactive) {{
468 d3.select(\"HASH_POINT_SIZE\").on(\"input\", function() {{
469 const size = +this.value;
470 d3.selectAll(\"DOT_POINT\").attr(\"r\", size);
471 }});
472
473 d3.select(\"HASH_OPACITY\").on(\"input\", function() {{
474 const opacity = +this.value / 100;
475 d3.selectAll(\"DOT_POINT\").attr(\"opacity\", opacity);
476 }});
477 }}
478 </script>
479</body>
480</html>";
481
482 let html_template = HTML_TEMPLATE
483 .replace("HASH_PLOT", "#_plot")
484 .replace("DOT_POINT", ".point")
485 .replace("HASH_LEGEND", "#legend")
486 .replace("HASH_POINT_SIZE", "#point-size")
487 .replace("HASH_OPACITY", "#opacity");
488
489 let html_content = html_template
490 .replace("{background}", &config.background_color)
491 .replace("{plot_data}", &plot_data_json)
492 .replace("{width}", &config.dimensions.0.to_string())
493 .replace("{height}", &config.dimensions.1.to_string())
494 .replace(
495 "{point_size}",
496 &plot.sizes.first().unwrap_or(&5.0).to_string(),
497 )
498 .replace("{opacity}", &(config.quality as f32 / 100.0).to_string())
499 .replace("{interactive}", &config.interactive.to_string())
500 .replace(
501 "{custom_css}",
502 config.custom_styling.as_deref().unwrap_or(""),
503 );
504
505 Ok(html_content)
506}
507
508#[allow(dead_code)]
510fn generate_scatter_3d_html(plot: &ScatterPlot3D, config: &ExportConfig) -> Result<String> {
511 let plot_data = serde_json::json!({
513 "type": "scatter3d",
514 "data": "plot_data_placeholder" });
516 let plot_data_json = serde_json::to_string(&plot_data).map_err(|e| {
517 ClusteringError::ComputationError(format!("JSON serialization failed: {}", e))
518 })?;
519
520 let html_template = r#"<!DOCTYPE html>
521<html lang="en">
522<head>
523 <meta charset="UTF-8">
524 <meta name="viewport" content="width=device-width, initial-scale=1.0">
525 <title>3D Clustering Visualization</title>
526 <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
527 <script src="https://cdnjs.cloudflare.com/ajax/libs/dat-gui/0.7.7/dat.gui.min.js"></script>
528 <style>
529 body {{ font-family: Arial, sans-serif; margin: 0; padding: 0; overflow: hidden; background: {background}; }}
530 #container {{ width: 100vw; height: 100vh; }}
531 #info {{ position: absolute; top: 10px; left: 10px; color: white; z-index: 100; }}
532 .controls {{ position: absolute; top: 10px; right: 10px; z-index: 100; }}
533 {custom_css}
534 </style>
535</head>
536<body>
537 <div id="container"></div>
538 <div id="info">
539 <h2>3D Clustering Visualization</h2>
540 <p>Use mouse to rotate, scroll to zoom</p>
541 </div>
542
543 <script>
544 const plotData = {plot_data};
545
546 let scene, camera, renderer, controls;
547 let pointsGroup;
548
549 function init() {{
550 // Scene
551 scene = new THREE.Scene();
552 scene.background = new THREE.Color('{background}');
553
554 // Camera
555 camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
556 camera.position.set(10, 10, 10);
557
558 // Renderer
559 renderer = new THREE.WebGLRenderer({{ antialias: true }});
560 renderer.setSize(window.innerWidth, window.innerHeight);
561 document.getElementById('container').appendChild(renderer.domElement);
562
563 // Controls (basic orbit controls implementation)
564 setupControls();
565
566 // Add coordinate axes
567 const axesHelper = new THREE.AxesHelper(5);
568 scene.add(axesHelper);
569
570 // Add grid
571 const gridHelper = new THREE.GridHelper(20, 20);
572 scene.add(gridHelper);
573
574 // Create points
575 createPoints();
576
577 // Add lighting
578 const ambientLight = new THREE.AmbientLight(0x404040, 0.6);
579 scene.add(ambientLight);
580
581 const directionalLight = new THREE.DirectionalLight(0xffffff, 0.4);
582 directionalLight.position.set(10, 10, 5);
583 scene.add(directionalLight);
584
585 // Animation loop
586 animate();
587 }}
588
589 function createPoints() {{
590 pointsGroup = new THREE.Group();
591
592 const geometry = new THREE.SphereGeometry(0.1, 8, 6);
593
594 for (let i = 0; i < plotData.points.length; i++) {{
595 const material = new THREE.MeshLambertMaterial({{
596 color: plotData.colors[i],
597 transparent: true,
598 opacity: {opacity}
599 }});
600
601 const point = new THREE.Mesh(geometry, material);
602 point.position.set(
603 plotData.points[i][0],
604 plotData.points[i][1],
605 plotData.points[i][2]
606 );
607
608 point.scale.setScalar(plotData.sizes[i] * 0.1);
609 pointsGroup.add(point);
610 }}
611
612 scene.add(pointsGroup);
613
614 // Add centroids if available
615 if (plotData.centroids) {{
616 const centroidGeometry = new THREE.SphereGeometry(0.2, 16, 12);
617 for (let i = 0; i < plotData.centroids.length; i++) {{
618 const material = new THREE.MeshLambertMaterial({{
619 color: 0xff0000,
620 transparent: true,
621 opacity: 0.8
622 }});
623
624 const centroid = new THREE.Mesh(centroidGeometry, material);
625 centroid.position.set(
626 plotData.centroids[i][0],
627 plotData.centroids[i][1],
628 plotData.centroids[i][2]
629 );
630
631 scene.add(centroid);
632 }}
633 }}
634 }}
635
636 function setupControls() {{
637 let mouseDown = false;
638 let mouseX = 0, mouseY = 0;
639
640 renderer.domElement.addEventListener('mousedown', (event) => {{
641 mouseDown = true;
642 mouseX = event.clientX;
643 mouseY = event.clientY;
644 }});
645
646 renderer.domElement.addEventListener('mouseup', () => {{
647 mouseDown = false;
648 }});
649
650 renderer.domElement.addEventListener('mousemove', (event) => {{
651 if (!mouseDown) return;
652
653 const deltaX = event.clientX - mouseX;
654 const deltaY = event.clientY - mouseY;
655
656 // Rotate camera around the scene
657 const spherical = new THREE.Spherical();
658 spherical.setFromVector3(camera.position);
659 spherical.theta -= deltaX * 0.01;
660 spherical.phi += deltaY * 0.01;
661 spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi));
662
663 camera.position.setFromSpherical(spherical);
664 camera.lookAt(0, 0, 0);
665
666 mouseX = event.clientX;
667 mouseY = event.clientY;
668 }});
669
670 renderer.domElement.addEventListener('wheel', (event) => {{
671 const scale = event.deltaY > 0 ? 1.1 : 0.9;
672 camera.position.multiplyScalar(scale);
673 }});
674 }}
675
676 function animate() {{
677 requestAnimationFrame(animate);
678 renderer.render(scene, camera);
679 }}
680
681 function onWindowResize() {{
682 camera.aspect = window.innerWidth / window.innerHeight;
683 camera.updateProjectionMatrix();
684 renderer.setSize(window.innerWidth, window.innerHeight);
685 }}
686
687 window.addEventListener('resize', onWindowResize);
688
689 init();
690 </script>
691</body>
692</html>"#;
693
694 let html_content = html_template
695 .replace("{background}", &config.background_color)
696 .replace("{plot_data}", &plot_data_json)
697 .replace("{opacity}", &(config.quality as f32 / 100.0).to_string())
698 .replace(
699 "{custom_css}",
700 config.custom_styling.as_deref().unwrap_or(""),
701 );
702
703 Ok(html_content)
704}
705
706#[allow(dead_code)]
708fn export_scatter_2d_to_csv<P: AsRef<Path>>(
709 plot: &ScatterPlot2D,
710 output_path: P,
711 _config: &ExportConfig,
712) -> Result<()> {
713 let mut csv_content = String::from("x,y,cluster,color\n");
714
715 for i in 0..plot.points.nrows() {
716 csv_content.push_str(&format!(
717 "{},{},{},{}\n",
718 plot.points[[i, 0]],
719 plot.points[[i, 1]],
720 plot.labels[i],
721 plot.colors[i]
722 ));
723 }
724
725 std::fs::write(output_path, csv_content)
726 .map_err(|e| ClusteringError::ComputationError(format!("File write failed: {}", e)))?;
727
728 Ok(())
729}
730
731#[allow(dead_code)]
732fn export_scatter_2d_to_plotly<P: AsRef<Path>>(
733 plot: &ScatterPlot2D,
734 path: P,
735 _config: &ExportConfig,
736) -> Result<()> {
737 let n = plot.points.nrows();
739 let mut traces: std::collections::HashMap<i32, (Vec<f64>, Vec<f64>, String)> =
740 std::collections::HashMap::new();
741
742 for i in 0..n {
743 let label = plot.labels[i];
744 let entry = traces.entry(label).or_insert_with(|| {
745 let color = plot
746 .colors
747 .get(i)
748 .cloned()
749 .unwrap_or_else(|| "#888888".to_string());
750 (Vec::new(), Vec::new(), color)
751 });
752 entry.0.push(plot.points[[i, 0]]);
753 entry.1.push(plot.points[[i, 1]]);
754 }
755
756 let mut trace_array = Vec::new();
757 let mut labels_sorted: Vec<i32> = traces.keys().copied().collect();
758 labels_sorted.sort();
759 for label in labels_sorted {
760 let (xs, ys, color) = &traces[&label];
761 trace_array.push(serde_json::json!({
762 "type": "scatter",
763 "mode": "markers",
764 "name": format!("Cluster {}", label),
765 "x": xs,
766 "y": ys,
767 "marker": {
768 "color": color,
769 "size": 8
770 }
771 }));
772 }
773
774 let layout = serde_json::json!({
775 "title": "Clustering Visualization",
776 "xaxis": { "title": "X" },
777 "yaxis": { "title": "Y" }
778 });
779
780 let plotly_doc = serde_json::json!({
781 "data": trace_array,
782 "layout": layout
783 });
784
785 let json_string = serde_json::to_string_pretty(&plotly_doc).map_err(|e| {
786 ClusteringError::ComputationError(format!("Plotly JSON serialization failed: {}", e))
787 })?;
788
789 std::fs::write(path, json_string)
790 .map_err(|e| ClusteringError::ComputationError(format!("File write failed: {}", e)))?;
791
792 Ok(())
793}
794
795#[allow(dead_code)]
796fn export_scatter_2d_to_d3<P: AsRef<Path>>(
797 plot: &ScatterPlot2D,
798 path: P,
799 config: &ExportConfig,
800) -> Result<()> {
801 let n = plot.points.nrows();
803 let mut points_json = String::from("[");
804 for i in 0..n {
805 if i > 0 {
806 points_json.push(',');
807 }
808 let color = plot.colors.get(i).map(|s| s.as_str()).unwrap_or("#888888");
809 let size = plot.sizes.get(i).copied().unwrap_or(5.0);
810 points_json.push_str(&format!(
811 "{{\"x\":{},\"y\":{},\"label\":{},\"color\":\"{}\",\"r\":{}}}",
812 plot.points[[i, 0]],
813 plot.points[[i, 1]],
814 plot.labels[i],
815 color,
816 size
817 ));
818 }
819 points_json.push(']');
820
821 let (width, height) = config.dimensions;
822 let background = &config.background_color;
823
824 let mut html = String::new();
826 html.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
827 html.push_str("<meta charset=\"UTF-8\">\n");
828 html.push_str("<title>D3.js Clustering Visualization</title>\n");
829 html.push_str("<script src=\"https://d3js.org/d3.v7.min.js\"></script>\n");
830 html.push_str(&format!(
831 "<style>body{{margin:0;background:{};}}svg{{display:block;}}</style>\n",
832 background
833 ));
834 html.push_str("</head>\n<body>\n");
835 html.push_str(&format!(
836 "<svg id=\"chart\" width=\"{}\" height=\"{}\"></svg>\n",
837 width, height
838 ));
839 html.push_str("<script>\n");
840 html.push_str(&format!("const data = {};\n", points_json));
841 html.push_str("const svg = d3.select(\"#chart\");\n");
842 html.push_str("const margin = {top:20,right:20,bottom:40,left:40};\n");
843 html.push_str(&format!(
844 "const w = {} - margin.left - margin.right;\n",
845 width
846 ));
847 html.push_str(&format!(
848 "const h = {} - margin.top - margin.bottom;\n",
849 height
850 ));
851 html.push_str("const g = svg.append(\"g\").attr(\"transform\",\"translate(\"+margin.left+\",\"+margin.top+\")\");\n");
852 html.push_str("const xExt = d3.extent(data, d => d.x);\n");
853 html.push_str("const yExt = d3.extent(data, d => d.y);\n");
854 html.push_str("const xSc = d3.scaleLinear().domain(xExt).range([0,w]);\n");
855 html.push_str("const ySc = d3.scaleLinear().domain(yExt).range([h,0]);\n");
856 html.push_str(
857 "g.append(\"g\").attr(\"transform\",\"translate(0,\"+h+\")\").call(d3.axisBottom(xSc));\n",
858 );
859 html.push_str("g.append(\"g\").call(d3.axisLeft(ySc));\n");
860 html.push_str("g.selectAll(\"circle\").data(data).enter().append(\"circle\")\n");
861 html.push_str(" .attr(\"cx\", d => xSc(d.x))\n");
862 html.push_str(" .attr(\"cy\", d => ySc(d.y))\n");
863 html.push_str(" .attr(\"r\", d => d.r)\n");
864 html.push_str(" .attr(\"fill\", d => d.color)\n");
865 html.push_str(" .attr(\"opacity\", 0.8);\n");
866 html.push_str("</script>\n</body>\n</html>\n");
867
868 std::fs::write(path, html)
869 .map_err(|e| ClusteringError::ComputationError(format!("File write failed: {}", e)))?;
870
871 Ok(())
872}
873
874#[allow(dead_code)]
875fn export_scatter_2d_to_svg<P: AsRef<Path>>(
876 plot: &ScatterPlot2D,
877 path: P,
878 config: &ExportConfig,
879) -> Result<()> {
880 let (width, height) = config.dimensions;
881 let margin = 60u32;
882 let plot_w = width.saturating_sub(2 * margin) as f64;
883 let plot_h = height.saturating_sub(2 * margin) as f64;
884
885 let (min_x, max_x, min_y, max_y) = plot.bounds;
886 let range_x = (max_x - min_x).max(1e-10);
887 let range_y = (max_y - min_y).max(1e-10);
888
889 let scale_x = |v: f64| -> f64 { (v - min_x) / range_x * plot_w + margin as f64 };
890 let scale_y =
891 |v: f64| -> f64 { height as f64 - margin as f64 - (v - min_y) / range_y * plot_h };
892
893 let mut svg = format!(
894 r#"<?xml version="1.0" encoding="UTF-8"?>
895<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}">
896<rect width="{width}" height="{height}" fill="{bg}"/>
897"#,
898 width = width,
899 height = height,
900 bg = config.background_color
901 );
902
903 svg.push_str(&format!(
905 r#"<line x1="{m}" y1="{m}" x2="{m}" y2="{bot}" stroke="black" stroke-width="1"/>
906<line x1="{m}" y1="{bot}" x2="{right}" y2="{bot}" stroke="black" stroke-width="1"/>
907"#,
908 m = margin,
909 bot = height - margin,
910 right = width - margin
911 ));
912
913 let n = plot.points.nrows();
915 for i in 0..n {
916 let cx = scale_x(plot.points[[i, 0]]);
917 let cy = scale_y(plot.points[[i, 1]]);
918 let r = plot.sizes.get(i).copied().unwrap_or(5.0);
919 let color = plot.colors.get(i).map(|s| s.as_str()).unwrap_or("#888888");
920 svg.push_str(&format!(
921 r#"<circle cx="{cx:.2}" cy="{cy:.2}" r="{r:.1}" fill="{color}" opacity="0.8"/>
922"#,
923 cx = cx,
924 cy = cy,
925 r = r,
926 color = color
927 ));
928 }
929
930 svg.push_str("</svg>\n");
931
932 std::fs::write(path, svg)
933 .map_err(|e| ClusteringError::ComputationError(format!("File write failed: {}", e)))?;
934
935 Ok(())
936}
937
938#[allow(dead_code)]
939fn export_scatter_2d_to_png<P: AsRef<Path>>(
940 _plot: &ScatterPlot2D,
941 _path: P,
942 _config: &ExportConfig,
943) -> Result<()> {
944 Err(ClusteringError::ComputationError(
945 "PNG export requires image rendering library".to_string(),
946 ))
947}
948
949#[allow(dead_code)]
950fn export_scatter_3d_to_threejs<P: AsRef<Path>>(
951 plot: &ScatterPlot3D,
952 path: P,
953 config: &ExportConfig,
954) -> Result<()> {
955 let n = plot.points.nrows();
957 let mut positions: Vec<f64> = Vec::with_capacity(n * 3);
958 let mut colors_rgb: Vec<f64> = Vec::with_capacity(n * 3);
959
960 for i in 0..n {
961 positions.push(plot.points[[i, 0]]);
962 positions.push(plot.points[[i, 1]]);
963 positions.push(plot.points[[i, 2]]);
964
965 let hex = plot
967 .colors
968 .get(i)
969 .map(|s| s.trim_start_matches('#'))
970 .unwrap_or("888888");
971 let (r, g, b) = parse_hex_color(hex);
972 colors_rgb.push(r);
973 colors_rgb.push(g);
974 colors_rgb.push(b);
975 }
976
977 let threejs_json = serde_json::json!({
978 "metadata": {
979 "version": 4.5,
980 "type": "BufferGeometry",
981 "generator": "scirs2-cluster"
982 },
983 "uuid": "scirs2-points",
984 "type": "BufferGeometry",
985 "data": {
986 "attributes": {
987 "position": {
988 "itemSize": 3,
989 "type": "Float32Array",
990 "array": positions,
991 "normalized": false
992 },
993 "color": {
994 "itemSize": 3,
995 "type": "Float32Array",
996 "array": colors_rgb,
997 "normalized": false
998 }
999 }
1000 },
1001 "pointCount": n,
1002 "config": {
1003 "background": config.background_color,
1004 "opacity": config.quality as f64 / 100.0
1005 }
1006 });
1007
1008 let json_string = serde_json::to_string_pretty(&threejs_json).map_err(|e| {
1009 ClusteringError::ComputationError(format!("Three.js JSON serialization failed: {}", e))
1010 })?;
1011
1012 std::fs::write(path, json_string)
1013 .map_err(|e| ClusteringError::ComputationError(format!("File write failed: {}", e)))?;
1014
1015 Ok(())
1016}
1017
1018#[allow(dead_code)]
1019fn export_scatter_3d_to_gltf<P: AsRef<Path>>(
1020 plot: &ScatterPlot3D,
1021 path: P,
1022 _config: &ExportConfig,
1023) -> Result<()> {
1024 let n = plot.points.nrows();
1026
1027 let mut pos_bytes: Vec<u8> = Vec::with_capacity(n * 3 * 4);
1029 let mut min_pos = [f32::MAX; 3];
1030 let mut max_pos = [f32::MIN; 3];
1031
1032 for i in 0..n {
1033 for c in 0..3 {
1034 let v = plot.points[[i, c]] as f32;
1035 pos_bytes.extend_from_slice(&v.to_le_bytes());
1036 if v < min_pos[c] {
1037 min_pos[c] = v;
1038 }
1039 if v > max_pos[c] {
1040 max_pos[c] = v;
1041 }
1042 }
1043 }
1044
1045 let mut col_bytes: Vec<u8> = Vec::with_capacity(n * 3 * 4);
1047 for i in 0..n {
1048 let hex = plot
1049 .colors
1050 .get(i)
1051 .map(|s| s.trim_start_matches('#'))
1052 .unwrap_or("888888");
1053 let (r, g, b) = parse_hex_color(hex);
1054 col_bytes.extend_from_slice(&(r as f32).to_le_bytes());
1055 col_bytes.extend_from_slice(&(g as f32).to_le_bytes());
1056 col_bytes.extend_from_slice(&(b as f32).to_le_bytes());
1057 }
1058
1059 let pos_byte_len = pos_bytes.len();
1060 let col_byte_len = col_bytes.len();
1061 let mut combined = pos_bytes;
1062 combined.extend(col_bytes);
1063
1064 let mut b64 = String::new();
1065 {
1067 const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1068 let data = &combined;
1069 let mut i = 0;
1070 while i + 2 < data.len() {
1071 let v = (data[i] as usize) << 16 | (data[i + 1] as usize) << 8 | (data[i + 2] as usize);
1072 b64.push(TABLE[(v >> 18) & 63] as char);
1073 b64.push(TABLE[(v >> 12) & 63] as char);
1074 b64.push(TABLE[(v >> 6) & 63] as char);
1075 b64.push(TABLE[v & 63] as char);
1076 i += 3;
1077 }
1078 let rem = data.len() - i;
1079 if rem == 1 {
1080 let v = (data[i] as usize) << 16;
1081 b64.push(TABLE[(v >> 18) & 63] as char);
1082 b64.push(TABLE[(v >> 12) & 63] as char);
1083 b64.push_str("==");
1084 } else if rem == 2 {
1085 let v = (data[i] as usize) << 16 | (data[i + 1] as usize) << 8;
1086 b64.push(TABLE[(v >> 18) & 63] as char);
1087 b64.push(TABLE[(v >> 12) & 63] as char);
1088 b64.push(TABLE[(v >> 6) & 63] as char);
1089 b64.push('=');
1090 }
1091 }
1092
1093 let data_uri = format!("data:application/octet-stream;base64,{}", b64);
1094 let total_byte_len = combined.len();
1095
1096 let gltf = serde_json::json!({
1097 "asset": { "version": "2.0", "generator": "scirs2-cluster" },
1098 "scene": 0,
1099 "scenes": [{ "nodes": [0] }],
1100 "nodes": [{ "mesh": 0 }],
1101 "meshes": [{
1102 "name": "ClusterPoints",
1103 "primitives": [{
1104 "attributes": { "POSITION": 0, "COLOR_0": 1 },
1105 "mode": 0
1106 }]
1107 }],
1108 "accessors": [
1109 {
1110 "bufferView": 0,
1111 "componentType": 5126,
1112 "count": n,
1113 "type": "VEC3",
1114 "min": [min_pos[0], min_pos[1], min_pos[2]],
1115 "max": [max_pos[0], max_pos[1], max_pos[2]]
1116 },
1117 {
1118 "bufferView": 1,
1119 "componentType": 5126,
1120 "count": n,
1121 "type": "VEC3"
1122 }
1123 ],
1124 "bufferViews": [
1125 { "buffer": 0, "byteOffset": 0, "byteLength": pos_byte_len },
1126 { "buffer": 0, "byteOffset": pos_byte_len, "byteLength": col_byte_len }
1127 ],
1128 "buffers": [{ "uri": data_uri, "byteLength": total_byte_len }]
1129 });
1130
1131 let json_string = serde_json::to_string_pretty(&gltf).map_err(|e| {
1132 ClusteringError::ComputationError(format!("GLTF JSON serialization failed: {}", e))
1133 })?;
1134
1135 std::fs::write(path, json_string)
1136 .map_err(|e| ClusteringError::ComputationError(format!("File write failed: {}", e)))?;
1137
1138 Ok(())
1139}
1140
1141#[allow(dead_code)]
1142fn export_scatter_3d_to_webgl<P: AsRef<Path>>(
1143 plot: &ScatterPlot3D,
1144 path: P,
1145 config: &ExportConfig,
1146) -> Result<()> {
1147 let n = plot.points.nrows();
1149
1150 let mut pos_vals = Vec::with_capacity(n * 3);
1152 let mut col_vals = Vec::with_capacity(n * 3);
1153
1154 for i in 0..n {
1155 pos_vals.push(plot.points[[i, 0]]);
1156 pos_vals.push(plot.points[[i, 1]]);
1157 pos_vals.push(plot.points[[i, 2]]);
1158
1159 let hex = plot
1160 .colors
1161 .get(i)
1162 .map(|s| s.trim_start_matches('#'))
1163 .unwrap_or("888888");
1164 let (r, g, b) = parse_hex_color(hex);
1165 col_vals.push(r);
1166 col_vals.push(g);
1167 col_vals.push(b);
1168 }
1169
1170 let pos_json = serde_json::to_string(&pos_vals).unwrap_or_else(|_| "[]".to_string());
1171 let col_json = serde_json::to_string(&col_vals).unwrap_or_else(|_| "[]".to_string());
1172
1173 let (width, height) = config.dimensions;
1174 let bg = &config.background_color;
1175
1176 let bg_hex = bg.trim_start_matches('#');
1178 let (bg_r, bg_g, bg_b) = parse_hex_color(bg_hex);
1179
1180 let mut html = String::new();
1182 html.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
1183 html.push_str("<meta charset=\"UTF-8\">\n");
1184 html.push_str("<title>WebGL Clustering Visualization</title>\n");
1185 html.push_str(&format!(
1186 "<style>body{{margin:0;background:{};}}canvas{{display:block;}}</style>\n",
1187 bg
1188 ));
1189 html.push_str("</head>\n<body>\n");
1190 html.push_str(&format!(
1191 "<canvas id=\"gl\" width=\"{}\" height=\"{}\"></canvas>\n",
1192 width, height
1193 ));
1194 html.push_str("<script>\n");
1195 html.push_str(&format!("const posData = {};\n", pos_json));
1196 html.push_str(&format!("const colData = {};\n", col_json));
1197 html.push_str("const canvas = document.getElementById(\"gl\");\n");
1198 html.push_str(
1199 "const gl = canvas.getContext(\"webgl\") || canvas.getContext(\"experimental-webgl\");\n",
1200 );
1201 html.push_str("if (!gl) { alert(\"WebGL not supported\"); throw new Error(\"no webgl\"); }\n");
1202 html.push_str(&format!(
1203 "gl.clearColor({:.4}, {:.4}, {:.4}, 1.0);\n",
1204 bg_r, bg_g, bg_b
1205 ));
1206 html.push_str("gl.enable(gl.DEPTH_TEST);\n");
1207 html.push_str("const vsrc = \"attribute vec3 aPos; attribute vec3 aCol; varying vec3 vCol; uniform mat4 uMVP; void main() { gl_Position = uMVP * vec4(aPos, 1.0); gl_PointSize = 6.0; vCol = aCol; }\";\n");
1209 html.push_str("const fsrc = \"precision mediump float; varying vec3 vCol; void main() { float d = length(gl_PointCoord - vec2(0.5)); if (d > 0.5) discard; gl_FragColor = vec4(vCol, 0.85); }\";\n");
1210 html.push_str("function compileShader(type, src) { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return s; }\n");
1211 html.push_str("const prog = gl.createProgram();\n");
1212 html.push_str("gl.attachShader(prog, compileShader(gl.VERTEX_SHADER, vsrc));\n");
1213 html.push_str("gl.attachShader(prog, compileShader(gl.FRAGMENT_SHADER, fsrc));\n");
1214 html.push_str("gl.linkProgram(prog);\ngl.useProgram(prog);\n");
1215 html.push_str("function makeBuffer(data, attr, size) { const buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW); const loc = gl.getAttribLocation(prog, attr); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, size, gl.FLOAT, false, 0, 0); }\n");
1216 html.push_str("makeBuffer(posData, \"aPos\", 3);\nmakeBuffer(colData, \"aCol\", 3);\n");
1217 html.push_str("function mat4ortho(l,r,b,t,n,f) { return [2/(r-l),0,0,0, 0,2/(t-b),0,0, 0,0,-2/(f-n),0, -(r+l)/(r-l),-(t+b)/(t-b),-(f+n)/(f-n),1]; }\n");
1218 html.push_str("const mvpLoc = gl.getUniformLocation(prog, \"uMVP\");\n");
1219 html.push_str("const mvp = mat4ortho(-10,10,-10,10,-10,10);\n");
1220 html.push_str("gl.uniformMatrix4fv(mvpLoc, false, mvp);\n");
1221 html.push_str("gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);\n");
1222 html.push_str(&format!("gl.drawArrays(gl.POINTS, 0, {});\n", n));
1223 html.push_str("</script>\n</body>\n</html>\n");
1224
1225 std::fs::write(path, html)
1226 .map_err(|e| ClusteringError::ComputationError(format!("File write failed: {}", e)))?;
1227
1228 Ok(())
1229}
1230
1231#[allow(dead_code)]
1232fn export_scatter_3d_to_unity<P: AsRef<Path>>(
1233 plot: &ScatterPlot3D,
1234 path: P,
1235 _config: &ExportConfig,
1236) -> Result<()> {
1237 let n = plot.points.nrows();
1240 let mut points_json_arr = Vec::with_capacity(n);
1241 for i in 0..n {
1242 let hex = plot
1243 .colors
1244 .get(i)
1245 .map(|s| s.trim_start_matches('#'))
1246 .unwrap_or("888888");
1247 let (r, g, b) = parse_hex_color(hex);
1248 points_json_arr.push(serde_json::json!({
1249 "x": plot.points[[i, 0]],
1250 "y": plot.points[[i, 1]],
1251 "z": plot.points[[i, 2]],
1252 "cluster": plot.labels[i],
1253 "color": { "r": r, "g": g, "b": b, "a": 1.0 },
1254 "size": plot.sizes.get(i).copied().unwrap_or(1.0)
1255 }));
1256 }
1257
1258 let doc = serde_json::json!({
1259 "format": "scirs2-unity3d-v1",
1260 "pointCount": n,
1261 "points": points_json_arr
1262 });
1263
1264 let json_string = serde_json::to_string_pretty(&doc).map_err(|e| {
1265 ClusteringError::ComputationError(format!("Unity JSON serialization failed: {}", e))
1266 })?;
1267
1268 std::fs::write(path, json_string)
1269 .map_err(|e| ClusteringError::ComputationError(format!("File write failed: {}", e)))?;
1270
1271 Ok(())
1272}
1273
1274#[allow(dead_code)]
1275fn export_scatter_3d_to_blender<P: AsRef<Path>>(
1276 plot: &ScatterPlot3D,
1277 path: P,
1278 _config: &ExportConfig,
1279) -> Result<()> {
1280 let n = plot.points.nrows();
1282 let mut script = String::from(
1283 "import bpy\nimport mathutils\n\n# Generated by scirs2-cluster\n# Run via: blender --background --python this_file.py\n\n",
1284 );
1285
1286 script.push_str("def create_cluster_points():\n");
1287 script.push_str(" bpy.ops.object.select_all(action='DESELECT')\n");
1288 script.push_str(" mesh_data = []\n");
1289
1290 for i in 0..n {
1291 let hex = plot
1292 .colors
1293 .get(i)
1294 .map(|s| s.trim_start_matches('#'))
1295 .unwrap_or("888888");
1296 let (r, g, b) = parse_hex_color(hex);
1297 let size = plot.sizes.get(i).copied().unwrap_or(1.0) * 0.05;
1298 script.push_str(&format!(
1299 " mesh_data.append(({:.6}, {:.6}, {:.6}, {:.4}, {:.4}, {:.4}, {:.4}))\n",
1300 plot.points[[i, 0]],
1301 plot.points[[i, 1]],
1302 plot.points[[i, 2]],
1303 r,
1304 g,
1305 b,
1306 size
1307 ));
1308 }
1309
1310 script.push_str(
1311 r#"
1312 for (x, y, z, r, g, b, s) in mesh_data:
1313 bpy.ops.mesh.primitive_uv_sphere_add(radius=s, location=(x, y, z))
1314 obj = bpy.context.active_object
1315 mat = bpy.data.materials.new(name=f"cluster_mat_{len(bpy.data.materials)}")
1316 mat.use_nodes = True
1317 bsdf = mat.node_tree.nodes.get("Principled BSDF")
1318 if bsdf:
1319 bsdf.inputs['Base Color'].default_value = (r, g, b, 1.0)
1320 obj.data.materials.append(mat)
1321
1322create_cluster_points()
1323print("Done: created cluster points")
1324"#,
1325 );
1326
1327 std::fs::write(path, script)
1328 .map_err(|e| ClusteringError::ComputationError(format!("File write failed: {}", e)))?;
1329
1330 Ok(())
1331}
1332
1333#[allow(dead_code)]
1334fn export_animation_to_gif<P: AsRef<Path>>(
1335 _frames: &[AnimationFrame],
1336 _output_path: P,
1337 _config: &ExportConfig,
1338) -> Result<()> {
1339 Err(ClusteringError::ComputationError(
1340 "GIF export requires animation library".to_string(),
1341 ))
1342}
1343
1344#[allow(dead_code)]
1345fn export_animation_to_mp4<P: AsRef<Path>>(
1346 _frames: &[AnimationFrame],
1347 _output_path: P,
1348 _config: &ExportConfig,
1349) -> Result<()> {
1350 Err(ClusteringError::ComputationError(
1351 "MP4 export requires video encoding library".to_string(),
1352 ))
1353}
1354
1355#[allow(dead_code)]
1356fn export_animation_to_webm<P: AsRef<Path>>(
1357 _frames: &[AnimationFrame],
1358 _output_path: P,
1359 _config: &ExportConfig,
1360) -> Result<()> {
1361 Err(ClusteringError::ComputationError(
1362 "WebM export requires video encoding library".to_string(),
1363 ))
1364}
1365
1366#[allow(dead_code)]
1367fn export_animation_to_html<P: AsRef<Path>>(
1368 frames: &[AnimationFrame],
1369 output_path: P,
1370 config: &ExportConfig,
1371) -> Result<()> {
1372 if frames.is_empty() {
1373 return Err(ClusteringError::InvalidInput(
1374 "No animation frames provided".to_string(),
1375 ));
1376 }
1377
1378 let frames_json = serde_json::to_string(frames).map_err(|e| {
1380 ClusteringError::ComputationError(format!("Frame JSON serialization failed: {}", e))
1381 })?;
1382
1383 let (width, height) = config.dimensions;
1384 let fps = config.fps.max(1.0);
1385 let bg = &config.background_color;
1386
1387 let mut html = String::new();
1389 html.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
1390 html.push_str("<meta charset=\"UTF-8\">\n");
1391 html.push_str("<title>Clustering Animation</title>\n");
1392 html.push_str("<script src=\"https://d3js.org/d3.v7.min.js\"></script>\n");
1393 html.push_str("<style>\n");
1394 html.push_str(&format!(
1395 "body{{margin:0;background:{};font-family:Arial,sans-serif;}}\n",
1396 bg
1397 ));
1398 html.push_str("svg{display:block;margin:auto;}\n");
1399 html.push_str("#controls{text-align:center;padding:10px;}\n");
1400 html.push_str("</style>\n</head>\n<body>\n");
1401 html.push_str("<div id=\"controls\">\n");
1402 html.push_str(" <button id=\"play\">Play</button>\n");
1403 html.push_str(" <button id=\"pause\">Pause</button>\n");
1404 html.push_str(" <button id=\"reset\">Reset</button>\n");
1405 html.push_str(" <span id=\"frame-info\"> Frame: 0 / 0 </span>\n");
1406 html.push_str("</div>\n");
1407 html.push_str(&format!(
1408 "<svg id=\"chart\" width=\"{}\" height=\"{}\"></svg>\n",
1409 width, height
1410 ));
1411 html.push_str("<script>\n");
1412 html.push_str(&format!("const frames = {};\n", frames_json));
1413 html.push_str(&format!("const FPS = {:.1};\n", fps));
1414 html.push_str("const svg = d3.select(\"#chart\");\n");
1415 html.push_str("const margin = {top:20,right:20,bottom:40,left:40};\n");
1416 html.push_str(&format!(
1417 "const w = {} - margin.left - margin.right;\n",
1418 width
1419 ));
1420 html.push_str(&format!(
1421 "const h = {} - margin.top - margin.bottom;\n",
1422 height
1423 ));
1424 html.push_str("const g = svg.append(\"g\").attr(\"transform\",\"translate(\"+margin.left+\",\"+margin.top+\")\");\n");
1425 html.push_str("let currentFrame = 0;\n");
1426 html.push_str("let interval = null;\n");
1427 html.push_str("function allPoints() {\n");
1428 html.push_str(" let pts = [];\n");
1429 html.push_str(" for (const f of frames) {\n");
1430 html.push_str(" for (let i=0;i<f.points.length;i++) pts.push(f.points[i]);\n");
1431 html.push_str(" }\n return pts;\n}\n");
1432 html.push_str("const allPts = allPoints();\n");
1433 html.push_str("const xExt = d3.extent(allPts, d => d[0]);\n");
1434 html.push_str("const yExt = d3.extent(allPts, d => d[1]);\n");
1435 html.push_str("const xSc = d3.scaleLinear().domain(xExt).range([0,w]);\n");
1436 html.push_str("const ySc = d3.scaleLinear().domain(yExt).range([h,0]);\n");
1437 html.push_str(
1438 "g.append(\"g\").attr(\"transform\",\"translate(0,\"+h+\")\").call(d3.axisBottom(xSc));\n",
1439 );
1440 html.push_str("g.append(\"g\").call(d3.axisLeft(ySc));\n");
1441 let palette_str = [
1443 "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f",
1444 "#bcbd22", "#17becf",
1445 ]
1446 .iter()
1447 .map(|c| format!("\"{}\"", c))
1448 .collect::<Vec<_>>()
1449 .join(",");
1450 html.push_str(&format!("const palette = [{}];\n", palette_str));
1451 html.push_str("function colorForLabel(label) {\n");
1452 html.push_str(" return palette[Math.abs(label) % palette.length];\n}\n");
1453 html.push_str("function renderFrame(idx) {\n");
1454 html.push_str(" if (idx >= frames.length) return;\n");
1455 html.push_str(" const f = frames[idx];\n");
1456 html.push_str(" const pts = f.points.map((p,i) => ({x:p[0],y:p[1],label:f.labels[i]}));\n");
1457 html.push_str(" const circles = g.selectAll(\"circle.point\").data(pts, (_,i) => i);\n");
1458 html.push_str(" circles.enter().append(\"circle\").attr(\"class\",\"point\")\n");
1459 html.push_str(" .merge(circles)\n");
1460 html.push_str(" .attr(\"cx\", d => xSc(d.x))\n");
1461 html.push_str(" .attr(\"cy\", d => ySc(d.y))\n");
1462 html.push_str(" .attr(\"r\", 5)\n");
1463 html.push_str(" .attr(\"fill\", d => colorForLabel(d.label))\n");
1464 html.push_str(" .attr(\"opacity\", 0.8);\n");
1465 html.push_str(" circles.exit().remove();\n");
1466 html.push_str(" document.getElementById(\"frame-info\").textContent =\n");
1467 html.push_str(" \" Frame: \" + (idx+1) + \" / \" + frames.length;\n}\n");
1468 html.push_str("renderFrame(0);\n");
1469 html.push_str("document.getElementById(\"play\").addEventListener(\"click\", () => {\n");
1470 html.push_str(" if (interval) return;\n");
1471 html.push_str(" interval = setInterval(() => {\n");
1472 html.push_str(" currentFrame = (currentFrame + 1) % frames.length;\n");
1473 html.push_str(" renderFrame(currentFrame);\n");
1474 html.push_str(&format!(" }}, 1000 / FPS);\n}}){}\n", ";"));
1475 html.push_str("document.getElementById(\"pause\").addEventListener(\"click\", () => {\n");
1476 html.push_str(" clearInterval(interval);\n interval = null;\n});\n");
1477 html.push_str("document.getElementById(\"reset\").addEventListener(\"click\", () => {\n");
1478 html.push_str(" clearInterval(interval);\n interval = null;\n");
1479 html.push_str(" currentFrame = 0;\n renderFrame(0);\n});\n");
1480 html.push_str("</script>\n</body>\n</html>\n");
1481
1482 std::fs::write(output_path, html)
1483 .map_err(|e| ClusteringError::ComputationError(format!("File write failed: {}", e)))?;
1484
1485 Ok(())
1486}
1487
1488#[allow(dead_code)]
1489#[allow(unused_variables)]
1490fn export_animation_to_json<P: AsRef<Path>>(
1491 frames: &[AnimationFrame],
1492 output_path: P,
1493 _config: &ExportConfig,
1494) -> Result<()> {
1495 #[cfg(feature = "serde")]
1496 {
1497 let json_string = serde_json::to_string_pretty(frames).map_err(|e| {
1498 ClusteringError::ComputationError(format!("JSON serialization failed: {}", e))
1499 })?;
1500
1501 std::fs::write(output_path, json_string)
1502 .map_err(|e| ClusteringError::ComputationError(format!("File write failed: {}", e)))?;
1503
1504 return Ok(());
1505 }
1506
1507 #[cfg(not(feature = "serde"))]
1508 {
1509 Err(ClusteringError::ComputationError(
1510 "JSON export requires 'serde' feature".to_string(),
1511 ))
1512 }
1513}
1514
1515#[allow(dead_code)]
1517fn create_metadata() -> ExportMetadata {
1518 ExportMetadata {
1519 created_at: chrono::Utc::now().to_rfc3339(),
1520 software: "scirs2-cluster".to_string(),
1521 version: env!("CARGO_PKG_VERSION").to_string(),
1522 format_version: "1.0".to_string(),
1523 }
1524}
1525
1526#[derive(Debug, Clone, Serialize, Deserialize)]
1528struct ExportMetadata {
1529 created_at: String,
1530 software: String,
1531 version: String,
1532 format_version: String,
1533}
1534
1535#[derive(Debug, Clone, Serialize, Deserialize)]
1537struct Scatter2DExport {
1538 format_version: String,
1539 export_config: ExportConfig,
1540 plot_data: ScatterPlot2D,
1541 metadata: ExportMetadata,
1542}
1543
1544#[derive(Debug, Clone, Serialize, Deserialize)]
1546struct Scatter3DExport {
1547 format_version: String,
1548 export_config: ExportConfig,
1549 plot_data: ScatterPlot3D,
1550 metadata: ExportMetadata,
1551}
1552
1553#[cfg(test)]
1554mod tests {
1555 use super::*;
1556 use scirs2_core::ndarray::Array2;
1557
1558 #[test]
1559 fn test_export_config_defaults() {
1560 let config = ExportConfig::default();
1561 assert_eq!(config.format, ExportFormat::PNG);
1562 assert_eq!(config.dimensions, (1920, 1080));
1563 assert_eq!(config.dpi, 300);
1564 }
1565
1566 #[test]
1567 fn test_scatter_2d_csv_export() {
1568 let plot = ScatterPlot2D {
1569 points: Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0])
1570 .expect("Operation failed"),
1571 labels: Array1::from_vec(vec![0, 1]),
1572 centroids: None,
1573 colors: vec!["#FF0000".to_string(), "#00FF00".to_string()],
1574 sizes: vec![5.0, 5.0],
1575 point_labels: None,
1576 bounds: (0.0, 4.0, 0.0, 4.0),
1577 legend: Vec::new(),
1578 };
1579
1580 let config = ExportConfig {
1581 format: ExportFormat::CSV,
1582 ..Default::default()
1583 };
1584
1585 let temp_file = tempfile::NamedTempFile::new().expect("Operation failed");
1586 export_scatter_2d_to_csv(&plot, temp_file.path(), &config).expect("Operation failed");
1587
1588 let content = std::fs::read_to_string(temp_file.path()).expect("Operation failed");
1589 assert!(content.contains("x,y,cluster,color"));
1590 assert!(content.contains("1,2,0,#FF0000"));
1591 }
1592
1593 fn make_plot_2d() -> ScatterPlot2D {
1594 ScatterPlot2D {
1595 points: Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 3.0, 1.0, 5.0, 4.0])
1596 .expect("shape"),
1597 labels: Array1::from_vec(vec![0, 0, 1]),
1598 centroids: None,
1599 colors: vec![
1600 "#FF0000".to_string(),
1601 "#FF0000".to_string(),
1602 "#0000FF".to_string(),
1603 ],
1604 sizes: vec![4.0, 4.0, 6.0],
1605 point_labels: None,
1606 bounds: (1.0, 5.0, 1.0, 4.0),
1607 legend: Vec::new(),
1608 }
1609 }
1610
1611 fn make_plot_3d() -> ScatterPlot3D {
1612 use scirs2_core::ndarray::Array1;
1613 ScatterPlot3D {
1614 points: Array2::from_shape_vec(
1615 (3, 3),
1616 vec![1.0, 2.0, 3.0, 4.0, 1.0, 2.0, 3.0, 4.0, 1.0],
1617 )
1618 .expect("shape"),
1619 labels: Array1::from_vec(vec![0, 0, 1]),
1620 centroids: None,
1621 colors: vec![
1622 "#FF0000".to_string(),
1623 "#FF0000".to_string(),
1624 "#00FF00".to_string(),
1625 ],
1626 sizes: vec![4.0, 4.0, 6.0],
1627 point_labels: None,
1628 bounds: (1.0, 4.0, 1.0, 4.0, 1.0, 3.0),
1629 legend: Vec::new(),
1630 }
1631 }
1632
1633 #[test]
1634 fn test_plotly_export_writes_valid_json() {
1635 let plot = make_plot_2d();
1636 let config = ExportConfig::default();
1637 let tmp = std::env::temp_dir().join("test_plotly_export.json");
1638 export_scatter_2d_to_plotly(&plot, &tmp, &config).expect("plotly export");
1639 let content = std::fs::read_to_string(&tmp).expect("read");
1640 let v: serde_json::Value = serde_json::from_str(&content).expect("valid json");
1641 assert!(v["data"].is_array());
1642 assert!(v["layout"].is_object());
1643 let _ = std::fs::remove_file(&tmp);
1644 }
1645
1646 #[test]
1647 fn test_d3_export_writes_html() {
1648 let plot = make_plot_2d();
1649 let config = ExportConfig::default();
1650 let tmp = std::env::temp_dir().join("test_d3_export.html");
1651 export_scatter_2d_to_d3(&plot, &tmp, &config).expect("d3 export");
1652 let content = std::fs::read_to_string(&tmp).expect("read");
1653 assert!(content.contains("<!DOCTYPE html>"));
1654 assert!(content.contains("d3.v7.min.js"));
1655 let _ = std::fs::remove_file(&tmp);
1656 }
1657
1658 #[test]
1659 fn test_svg_export_contains_circles() {
1660 let plot = make_plot_2d();
1661 let config = ExportConfig::default();
1662 let tmp = std::env::temp_dir().join("test_svg_export.svg");
1663 export_scatter_2d_to_svg(&plot, &tmp, &config).expect("svg export");
1664 let content = std::fs::read_to_string(&tmp).expect("read");
1665 assert!(content.contains("<svg"));
1666 assert!(content.contains("<circle"));
1667 assert!(content.contains("</svg>"));
1668 let _ = std::fs::remove_file(&tmp);
1669 }
1670
1671 #[test]
1672 fn test_threejs_export_writes_valid_json() {
1673 let plot = make_plot_3d();
1674 let config = ExportConfig::default();
1675 let tmp = std::env::temp_dir().join("test_threejs_export.json");
1676 export_scatter_3d_to_threejs(&plot, &tmp, &config).expect("threejs export");
1677 let content = std::fs::read_to_string(&tmp).expect("read");
1678 let v: serde_json::Value = serde_json::from_str(&content).expect("valid json");
1679 assert_eq!(v["type"], "BufferGeometry");
1680 assert!(v["data"]["attributes"]["position"].is_object());
1681 let _ = std::fs::remove_file(&tmp);
1682 }
1683
1684 #[test]
1685 fn test_gltf_export_writes_valid_gltf2() {
1686 let plot = make_plot_3d();
1687 let config = ExportConfig::default();
1688 let tmp = std::env::temp_dir().join("test_gltf_export.gltf");
1689 export_scatter_3d_to_gltf(&plot, &tmp, &config).expect("gltf export");
1690 let content = std::fs::read_to_string(&tmp).expect("read");
1691 let v: serde_json::Value = serde_json::from_str(&content).expect("valid json");
1692 assert_eq!(v["asset"]["version"], "2.0");
1693 assert!(v["buffers"].is_array());
1694 assert!(v["meshes"].is_array());
1695 let _ = std::fs::remove_file(&tmp);
1696 }
1697
1698 #[test]
1699 fn test_webgl_export_writes_html_with_shaders() {
1700 let plot = make_plot_3d();
1701 let config = ExportConfig::default();
1702 let tmp = std::env::temp_dir().join("test_webgl_export.html");
1703 export_scatter_3d_to_webgl(&plot, &tmp, &config).expect("webgl export");
1704 let content = std::fs::read_to_string(&tmp).expect("read");
1705 assert!(content.contains("<!DOCTYPE html>"));
1706 assert!(content.contains("aPos"));
1707 assert!(content.contains("gl.drawArrays"));
1708 let _ = std::fs::remove_file(&tmp);
1709 }
1710
1711 #[test]
1712 fn test_unity_export_writes_valid_json() {
1713 let plot = make_plot_3d();
1714 let config = ExportConfig::default();
1715 let tmp = std::env::temp_dir().join("test_unity_export.json");
1716 export_scatter_3d_to_unity(&plot, &tmp, &config).expect("unity export");
1717 let content = std::fs::read_to_string(&tmp).expect("read");
1718 let v: serde_json::Value = serde_json::from_str(&content).expect("valid json");
1719 assert_eq!(v["format"], "scirs2-unity3d-v1");
1720 assert_eq!(v["pointCount"], 3);
1721 assert!(v["points"].is_array());
1722 let _ = std::fs::remove_file(&tmp);
1723 }
1724
1725 #[test]
1726 fn test_blender_export_writes_python_script() {
1727 let plot = make_plot_3d();
1728 let config = ExportConfig::default();
1729 let tmp = std::env::temp_dir().join("test_blender_export.py");
1730 export_scatter_3d_to_blender(&plot, &tmp, &config).expect("blender export");
1731 let content = std::fs::read_to_string(&tmp).expect("read");
1732 assert!(content.contains("import bpy"));
1733 assert!(content.contains("create_cluster_points"));
1734 let _ = std::fs::remove_file(&tmp);
1735 }
1736
1737 #[test]
1738 fn test_animation_html_export_writes_interactive_html() {
1739 use crate::visualization::animation::AnimationFrame;
1740 use scirs2_core::ndarray::Array1;
1741 let frame = AnimationFrame {
1742 frame_number: 0,
1743 iteration: 0,
1744 timestamp: 0.0,
1745 points: Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0]).expect("shape"),
1746 labels: Array1::from_vec(vec![0, 1]),
1747 centroids: None,
1748 previous_centroids: None,
1749 convergence_info: None,
1750 annotations: Vec::new(),
1751 };
1752 let config = ExportConfig::default();
1753 let tmp = std::env::temp_dir().join("test_anim_export.html");
1754 export_animation_to_html(&[frame], &tmp, &config).expect("animation html export");
1755 let content = std::fs::read_to_string(&tmp).expect("read");
1756 assert!(content.contains("<!DOCTYPE html>"));
1757 assert!(content.contains("renderFrame"));
1758 assert!(content.contains("play"));
1759 let _ = std::fs::remove_file(&tmp);
1760 }
1761
1762 #[test]
1763 fn test_parse_hex_color_white() {
1764 let (r, g, b) = parse_hex_color("FFFFFF");
1765 assert!((r - 1.0).abs() < 1e-6);
1766 assert!((g - 1.0).abs() < 1e-6);
1767 assert!((b - 1.0).abs() < 1e-6);
1768 }
1769
1770 #[test]
1771 fn test_parse_hex_color_shorthand() {
1772 let (r, g, b) = parse_hex_color("F00");
1773 assert!((r - 1.0).abs() < 1e-6);
1774 assert!(g < 0.01);
1775 assert!(b < 0.01);
1776 }
1777
1778 #[test]
1779 fn test_animation_html_empty_frames_error() {
1780 let config = ExportConfig::default();
1781 let tmp = std::env::temp_dir().join("test_anim_empty.html");
1782 let result = export_animation_to_html(&[], &tmp, &config);
1783 assert!(result.is_err());
1784 }
1785}
1786
1787fn parse_hex_color(hex: &str) -> (f64, f64, f64) {
1790 let h = if hex.len() == 3 {
1791 format!(
1793 "{}{}{}{}{}{}",
1794 &hex[0..1],
1795 &hex[0..1],
1796 &hex[1..2],
1797 &hex[1..2],
1798 &hex[2..3],
1799 &hex[2..3]
1800 )
1801 } else {
1802 hex.to_string()
1803 };
1804
1805 if h.len() != 6 {
1806 return (0.533, 0.533, 0.533);
1807 }
1808
1809 let r = u8::from_str_radix(&h[0..2], 16).unwrap_or(136) as f64 / 255.0;
1810 let g = u8::from_str_radix(&h[2..4], 16).unwrap_or(136) as f64 / 255.0;
1811 let b = u8::from_str_radix(&h[4..6], 16).unwrap_or(136) as f64 / 255.0;
1812 (r, g, b)
1813}
1814
1815use chrono;