Skip to main content

scirs2_cluster/visualization/
export.rs

1//! Export capabilities for clustering visualizations
2//!
3//! This module provides comprehensive export functionality for clustering visualizations,
4//! including static images, animated GIFs, videos, interactive HTML files, VR formats,
5//! and various data formats for integration with other visualization tools.
6
7use 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/// Export format options
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21pub enum ExportFormat {
22    /// Static PNG image
23    PNG,
24    /// Static SVG vector graphics
25    SVG,
26    /// PDF document
27    PDF,
28    /// Animated GIF
29    GIF,
30    /// MP4 video
31    MP4,
32    /// WebM video
33    WebM,
34    /// Interactive HTML with JavaScript
35    HTML,
36    /// JSON data format
37    JSON,
38    /// CSV data format
39    CSV,
40    /// Plotly JSON format
41    PlotlyJSON,
42    /// Three.js compatible JSON
43    ThreeJS,
44    /// VR/AR compatible GLTF format
45    GLTF,
46    /// Unity 3D compatible format
47    Unity3D,
48    /// Blender compatible format
49    Blender,
50    /// R ggplot2 compatible format
51    RGGplot,
52    /// Python matplotlib compatible format
53    Matplotlib,
54    /// D3.js compatible format
55    D3JS,
56    /// WebGL compatible format
57    WebGL,
58}
59
60/// Export configuration
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct ExportConfig {
63    /// Output format
64    pub format: ExportFormat,
65    /// Output dimensions (width, height) in pixels
66    pub dimensions: (u32, u32),
67    /// DPI for raster formats
68    pub dpi: u32,
69    /// Quality setting (0-100 for lossy formats)
70    pub quality: u8,
71    /// Frame rate for video/animation exports
72    pub fps: f32,
73    /// Duration for animations in seconds
74    pub duration: f32,
75    /// Include metadata in export
76    pub include_metadata: bool,
77    /// Compression level (0-9 for applicable formats)
78    pub compression: u8,
79    /// Background color (hex format)
80    pub background_color: String,
81    /// Whether to include interactive controls
82    pub interactive: bool,
83    /// Custom CSS/styling for web exports
84    pub custom_styling: Option<String>,
85    /// Include animation controls for interactive exports
86    pub animation_controls: bool,
87    /// Export stereoscopic view for VR
88    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/// Export a 2D scatter plot to file
112///
113/// # Arguments
114///
115/// * `plot` - 2D scatter plot data
116/// * `output_path` - Output file path
117/// * `config` - Export configuration
118///
119/// # Returns
120///
121/// * `Result<()>` - Success or error
122#[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/// Export a 3D scatter plot to file
146///
147/// # Arguments
148///
149/// * `plot` - 3D scatter plot data
150/// * `output_path` - Output file path
151/// * `config` - Export configuration
152///
153/// # Returns
154///
155/// * `Result<()>` - Success or error
156#[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/// Export animation frames to video or animated format
180///
181/// # Arguments
182///
183/// * `frames` - Animation frames
184/// * `output_path` - Output file path
185/// * `config` - Export configuration
186///
187/// # Returns
188///
189/// * `Result<()>` - Success or error
190#[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/// Export 2D scatter plot to JSON format
212#[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/// Export 3D scatter plot to JSON format
247#[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/// Export 2D scatter plot to HTML format with interactive visualization
282#[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/// Export 3D scatter plot to HTML format with WebGL visualization
297#[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/// Save visualization to file with automatic format detection
312#[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    // Auto-detect format from file extension if not specified
323    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, // Keep original format if not recognized
336        };
337    }
338
339    // Export based on available data
340    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/// Generate HTML content for 2D scatter plot
354#[allow(dead_code)]
355fn generate_scatter_2d_html(plot: &ScatterPlot2D, config: &ExportConfig) -> Result<String> {
356    // Create a serializable version of plot data
357    let plot_data = serde_json::json!({
358        "type": "scatter2d",
359        "data": "plot_data_placeholder" // Would extract actual data from plot
360    });
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/// Generate HTML content for 3D scatter plot with Three.js
509#[allow(dead_code)]
510fn generate_scatter_3d_html(plot: &ScatterPlot3D, config: &ExportConfig) -> Result<String> {
511    // Create a serializable version of plot data
512    let plot_data = serde_json::json!({
513        "type": "scatter3d",
514        "data": "plot_data_placeholder" // Would extract actual data from plot
515    });
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// Placeholder implementations for other export formats
707#[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    // Build Plotly JSON: one trace per cluster with scatter mode="markers"
738    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    // Build an HTML file with embedded D3.js and inline data
802    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    // Build HTML via concatenation to avoid format!() conflicts with JS tokens
825    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    // Axis lines
904    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    // Points
914    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    // Emit a Three.js-compatible BufferGeometry JSON descriptor
956    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        // Parse hex color to r,g,b in [0,1]
966        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    // Build a minimal valid glTF 2.0 JSON with POINTS primitive and base64-encoded buffer
1025    let n = plot.points.nrows();
1026
1027    // Pack positions as f32 LE bytes
1028    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    // Pack color as f32 LE bytes (r,g,b per vertex)
1046    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    // Simple base64 encoding without external crate
1066    {
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    // Emit a self-contained HTML with inline WebGL vertex/fragment shaders
1148    let n = plot.points.nrows();
1149
1150    // Build flat JS arrays
1151    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    // Parse background hex to float triplet for WebGL clearColor
1177    let bg_hex = bg.trim_start_matches('#');
1178    let (bg_r, bg_g, bg_b) = parse_hex_color(bg_hex);
1179
1180    // Build HTML by concatenation to avoid format!() conflicts with JS template literals
1181    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    // Vertex shader as a regular string (no template literals needed)
1208    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    // Emit a Unity-importable JSON descriptor with positions and colors
1238    // Unity scripts can load this via JsonUtility or Newtonsoft.Json
1239    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    // Emit a Blender-importable Python script that creates a mesh of instanced spheres
1281    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    // Serialize all frames to JSON for embedding in the HTML animation loop
1379    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    // Build HTML by concatenation to avoid format!() conflicts with JS hex tokens
1388    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    // Color palette as a const - use string concatenation to avoid Rust 2021 prefix issues
1442    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/// Create metadata for exports
1516#[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/// Export metadata
1527#[derive(Debug, Clone, Serialize, Deserialize)]
1528struct ExportMetadata {
1529    created_at: String,
1530    software: String,
1531    version: String,
1532    format_version: String,
1533}
1534
1535/// 2D scatter plot export wrapper
1536#[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/// 3D scatter plot export wrapper
1545#[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
1787/// Parse a hex color string (without `#` prefix) into (r,g,b) floats in [0.0, 1.0].
1788/// Falls back to `(0.533, 0.533, 0.533)` on malformed input.
1789fn parse_hex_color(hex: &str) -> (f64, f64, f64) {
1790    let h = if hex.len() == 3 {
1791        // Expand shorthand #RGB → #RRGGBB
1792        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
1815// Conditionally include chrono for metadata timestamps
1816
1817use chrono;