diff --git a/.zenodo.json b/.zenodo.json index e40fe02..5d4955e 100644 --- a/.zenodo.json +++ b/.zenodo.json @@ -1,6 +1,6 @@ { "title": "Q-Shape - Quantitative Shape Analyzer", - "version": "v1.4.0", + "version": "v1.5.0", "description": "Q-Shape: Advanced coordination geometry analysis using Continuous Shape Measures (CShM). A web-based tool for analyzing molecular coordination geometries with 92 reference geometries (CN 2-12, 20, 24, 48, 60), real-time 3D visualization, and comprehensive quality metrics.", "creators": [ { diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dc885c..40bd936 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 🎯 Overview -**Piano Stool Complex Support** - This release adds comprehensive support for half-sandwich (piano stool) complexes, a major class of organometallic compounds that were previously not properly analyzed by the software. +**Multi-Structure Batch Analysis & Piano Stool Complex Support** - This major release introduces comprehensive batch analysis for multi-structure files (XYZ and CIF), along with support for half-sandwich (piano stool) complexes. ### ✨ New Features +#### Multi-Structure Batch Analysis (Major Feature) +- **Problem:** Users could only analyze one structure at a time, making it tedious to analyze files with multiple conformers or structures +- **Solution:** Full batch analysis mode with parallel structure handling +- **Implementation:** + - New `useFileUpload` hook with multi-structure support + - New `useBatchAnalysis` hook for orchestrating batch operations + - Unified input parser supporting multi-structure XYZ and CIF files + - New data model with `Structure` and `ParsedFile` types + +**New Components:** +- `BatchModePanel.jsx` - Structure selector and batch controls +- `BatchSummaryTable.jsx` - Visual overview of all analyzed structures +- `ManualOverridePanel.jsx` - Per-structure parameter overrides + +**Batch Report Features:** +- Comprehensive batch PDF report with per-structure details +- Q-Shape analysis overview for each structure +- Quality Metrics, Bond Statistics, Coordinating Atoms tables +- Ligand Groups Analysis section +- Wide summary CSV export (one row per structure) +- Long detailed CSV export (all geometries for all structures) + +**UI Improvements:** +- Context-aware action buttons (batch vs single mode) +- Progress tracking for batch operations +- Softer color scheme for selected rows +- TrackballControls for unrestricted 360Β° 3D rotation + #### Piano Stool (Half-Sandwich) Complex Recognition - **Problem:** Piano stool complexes like [CpMn(CO)₃] were analyzed as CN=8 (all atoms separately), giving poor CShM values (>15.0) - **Solution:** Automatic pattern detection and geometry-aware analysis diff --git a/CITATION.cff b/CITATION.cff index 057ce43..95b6cf4 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -10,7 +10,7 @@ authors: repository-code: "https://github.com/HenriqueCSJ/q-shape" url: "https://henriquecsj.github.io/q-shape" license: MIT -version: "v1.4.0" +version: "v1.5.0" date-released: "2025-11-25" keywords: - "coordination chemistry" @@ -26,7 +26,7 @@ identifiers: preferred-citation: type: software title: "Q-Shape - Quantitative Shape Analyzer" - version: "v1.4.0" + version: "v1.5.0" year: 2025 authors: - given-names: Henrique diff --git a/README.md b/README.md index 8760a43..25d0a5d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![Q-Shape Logo](https://img.shields.io/badge/Q--Shape-Molecular%20Geometry%20Analysis-blue?style=for-the-badge&logo=react&logoColor=white) -[![Version](https://img.shields.io/badge/version-1.4.0-blue.svg?style=flat-square)](https://github.com/HenriqueCSJ/q-shape/releases/tag/v1.4.0) +[![Version](https://img.shields.io/badge/version-1.5.0-blue.svg?style=flat-square)](https://github.com/HenriqueCSJ/q-shape/releases/tag/v1.5.0) [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.17717110.svg)](https://doi.org/10.5281/zenodo.17717110) [![MIT License](https://img.shields.io/badge/License-MIT-green.svg?style=flat-square)](https://choosealicense.com/licenses/mit/) [![Website](https://img.shields.io/website?down_color=red&down_message=offline&style=flat-square&up_color=green&up_message=online&url=https%3A%2F%2Fhenriquecsj.github.io%2Fq-shape)](https://henriquecsj.github.io/q-shape) @@ -46,6 +46,16 @@ βœ… **Comprehensive Metrics** - Bond statistics, angular distortion, quality scoring βœ… **PDF Reports** - Professional output suitable for publication +### Multi-Structure Batch Analysis (v1.5.0) + +βœ… **Batch Processing** - Analyze multiple structures from a single file +βœ… **Multi-Structure XYZ/CIF Support** - Load files with multiple conformers or structures +βœ… **Batch Summary Table** - Visual overview of all analyzed structures at a glance +βœ… **Parallel Analysis** - Run intensive analysis on all structures with progress tracking +βœ… **Batch PDF Reports** - Comprehensive reports with per-structure details +βœ… **CSV Export** - Wide summary or detailed geometry results for all structures +βœ… **Structure Selector** - Navigate between structures with instant visualization updates + ### Analysis Modes **Standard Mode** (~5-10 seconds) @@ -70,13 +80,21 @@ Visit **[https://henriquecsj.github.io/q-shape](https://henriquecsj.github.io/q- ### Basic Workflow -1. **Upload** your XYZ file (drag-and-drop or file picker) +1. **Upload** your XYZ or CIF file (drag-and-drop or file picker) 2. **Select** metal center (auto-detected or manual selection) 3. **Adjust** coordination sphere radius if needed 4. **Run** analysis (standard or intensive mode) 5. **Visualize** results in 3D and review shape measures 6. **Export** PDF report for your records +### Batch Analysis Workflow (v1.5.0) + +1. **Upload** a multi-structure XYZ or CIF file +2. **Review** the batch summary table showing all structures +3. **Run Batch Analysis** to analyze all structures with intensive mode +4. **Navigate** between structures using the structure selector +5. **Export** batch PDF report or CSV files with all results + --- ## Scientific Basis @@ -339,7 +357,7 @@ If you use Q-Shape in your research, please cite: **APA:** ``` -Castro Silva Junior, H. (2025). Q-Shape - Quantitative Shape Analyzer (v1.4.0). +Castro Silva Junior, H. (2025). Q-Shape - Quantitative Shape Analyzer (v1.5.0). Zenodo. https://doi.org/10.5281/zenodo.17717110 ``` @@ -348,7 +366,7 @@ Zenodo. https://doi.org/10.5281/zenodo.17717110 @software{qshape2025, author = {Castro Silva Junior, Henrique}, title = {Q-Shape - Quantitative Shape Analyzer}, - version = {1.4.0}, + version = {1.5.0}, year = {2025}, doi = {10.5281/zenodo.17717110}, url = {https://doi.org/10.5281/zenodo.17717110}, diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..d262bd6 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,151 @@ +# Q-Shape v1.5.0 Testing Checklist + +## Automated Tests + +Run all tests with: +```bash +npm test +``` + +### Unit Test Coverage + +#### 1. File Parser (`src/utils/parseInput.test.js`) +- [x] Single XYZ parsing +- [x] Multi-frame XYZ parsing (frame count, IDs, warnings) +- [x] CIF parsing with Cartesian coordinates +- [x] CIF parsing with fractional coordinates (unit cell conversion) +- [x] Multiple CIF data blocks handling +- [x] Format auto-detection from content and extension +- [x] Error handling for malformed files +- [x] Element symbol normalization +- [x] Large coordinate warnings + +#### 2. Batch Analysis +- [x] Batch run returns results per structure +- [x] Progress tracking during batch operations +- [x] Structure override storage and retrieval +- [x] Apply override to all structures + +#### 3. Report Generation +- [x] Long-format CSV contains all (structure, geometry) rows +- [x] Wide-format CSV contains one row per structure +- [x] Batch PDF includes all geometries per structure + +--- + +## Manual E2E Checklist + +### Setup +1. Run `npm start` +2. Open browser to http://localhost:3000 + +### A. Single Structure XYZ Upload +- [ ] Upload a single-structure `.xyz` file +- [ ] Verify metal center auto-detection +- [ ] Verify coordination radius auto-detection +- [ ] Verify geometry analysis completes +- [ ] Verify 3D visualization renders correctly +- [ ] Verify "Single Structure Mode" indicator shows +- [ ] Run intensive analysis +- [ ] Generate PDF report - verify ALL geometries are included +- [ ] Export CSV - verify all geometry results are included + +### B. Multi-Structure XYZ Upload +- [ ] Upload a multi-frame `.xyz` file (trajectory) +- [ ] Verify "Batch Mode: X structures detected" message appears +- [ ] Verify structure selector dropdown appears with all frames +- [ ] Switch between structures using selector +- [ ] Verify 3D visualization updates on each structure switch +- [ ] Verify analysis results update for each structure +- [ ] Verify structure ID shows in results panel +- [ ] Click "Analyze All Structures" +- [ ] Verify progress indicator shows +- [ ] Verify batch summary table populates with results +- [ ] Verify clicking a row in summary table selects that structure +- [ ] Verify selected row is highlighted in batch summary + +### C. CIF File Upload +- [ ] Upload a single-block `.cif` file +- [ ] Verify atoms are NOT cramped together (proper Cartesian conversion) +- [ ] Verify metal center and geometry detection work +- [ ] Upload a multi-block `.cif` file +- [ ] Verify all blocks appear as separate structures +- [ ] Verify batch mode activates + +### D. Manual Override Panel +- [ ] Click "Show Manual Overrides" +- [ ] Change metal center +- [ ] Verify analysis reruns with new metal +- [ ] Change coordination radius +- [ ] Verify CN updates +- [ ] Use "Find Radius for Target CN" feature +- [ ] In batch mode: click "Apply to all" for metal +- [ ] In batch mode: click "Apply to all" for radius + +### E. Batch Export (Batch Mode Only) +- [ ] Click "Batch PDF Report" +- [ ] Verify PDF contains batch summary table +- [ ] Verify PDF contains per-structure detail sections with ALL geometries +- [ ] Click "Summary CSV (Wide)" +- [ ] Verify one row per structure with best match +- [ ] Click "All Geometries CSV (Long)" +- [ ] Verify multiple rows per structure (all geometries) +- [ ] Verify structure IDs are correct in CSV + +### F. State Reset on New Upload +- [ ] Upload file A +- [ ] Run analysis, get results +- [ ] Upload file B (different file) +- [ ] Verify batch results from file A are cleared +- [ ] Verify selected structure resets to 0 +- [ ] Verify no lingering data from previous file + +### G. 3D Visualization Re-render +- [ ] Upload multi-structure file +- [ ] Select structure 1 - verify 3D renders correctly +- [ ] Select structure 2 - verify 3D updates/realigns +- [ ] Select structure 3 - verify 3D updates/realigns +- [ ] Select geometry 2 in results - verify ideal polyhedron updates +- [ ] Select geometry 1 in results - verify ideal polyhedron updates + +### H. Intensive Analysis in Batch Mode +- [ ] Upload multi-structure file +- [ ] Select structure 2 +- [ ] Run intensive analysis +- [ ] Verify intensive analysis runs for structure 2 (not structure 1) +- [ ] Verify results show for structure 2 +- [ ] Verify batch results for structure 2 are updated + +--- + +## Regression Checklist + +These are bugs that occurred during development and must be prevented: + +1. [ ] CIF upload does NOT produce "Invalid atom count in XYZ header" error +2. [ ] Batch UI controls appear BEFORE batch results exist (not gated on batchResults.length > 0) +3. [ ] No JSX syntax errors ("Adjacent JSX elements must be wrapped") +4. [ ] No reportGenerator.js syntax errors on build +5. [ ] No ESLint undefined variable errors (additionalMetrics, permCount) +6. [ ] No runtime error "prev is not iterable" after CIF load +7. [ ] Report/CSV exports include ALL geometries, not just best match +8. [ ] Batch results clear on new file upload +9. [ ] Polyhedra alignment updates for ALL structures/geometries selected + +--- + +## Performance Notes + +- Multi-frame files with >50 structures may be slow to batch analyze +- Intensive analysis takes ~5-30 seconds per structure depending on CN +- Batch PDF generation may take a few seconds for many structures + +--- + +## Browser Compatibility + +Tested on: +- [ ] Chrome (latest) +- [ ] Firefox (latest) +- [ ] Safari (latest) +- [ ] Edge (latest) diff --git a/package.json b/package.json index a82796c..81cddab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "q-shape", - "version": "1.4.0", + "version": "1.5.0", "description": "Quantitative Shape Analyzer for Coordination Geometry Analysis", "homepage": "https://HenriqueCSJ.github.io/q-shape", "private": true, diff --git a/src/App.js b/src/App.js index 743b907..b13acb2 100644 --- a/src/App.js +++ b/src/App.js @@ -1,16 +1,20 @@ -import React, { useEffect, useRef, useState, useCallback } from "react"; +import React, { useEffect, useRef, useState, useCallback, useMemo } from "react"; import './App.css'; +// Constants +import { APP_VERSION, BUILD_DATE, APP_FULL_NAME, getCitationString, CITATION } from './constants/appMetadata'; + // Custom Hooks import useFileUpload from './hooks/useFileUpload'; import useRadiusControl from './hooks/useRadiusControl'; import useCoordination from './hooks/useCoordination'; import useShapeAnalysis from './hooks/useShapeAnalysis'; +import useBatchAnalysis from './hooks/useBatchAnalysis'; import { useThreeScene } from './hooks/useThreeScene'; // Services import { runIntensiveAnalysisAsync } from './services/coordination/intensiveAnalysis'; -import { generatePDFReport, generateCSVReport } from './services/reportGenerator'; +import { generatePDFReport, generateCSVReport, generateBatchPDFReport, generateLongDetailedCSV } from './services/reportGenerator'; // Components import FileUploadSection from './components/FileUploadSection'; @@ -18,6 +22,8 @@ import AnalysisControls from './components/AnalysisControls'; import CoordinationSummary from './components/CoordinationSummary'; import Visualization3D from './components/Visualization3D'; import ResultsDisplay from './components/ResultsDisplay'; +import BatchModePanel from './components/BatchModePanel'; +import BatchSummaryTable from './components/BatchSummaryTable'; // --- START: REACT COMPONENT --- export default function CoordinationGeometryAnalyzer() { @@ -28,7 +34,7 @@ export default function CoordinationGeometryAnalyzer() { const [showIdeal, setShowIdeal] = useState(true); const [showLabels, setShowLabels] = useState(true); const [warnings, setWarnings] = useState([]); - const [selectedGeometryIndex, setSelectedGeometryIndex] = useState(0); // Index of geometry to visualize + const [selectedGeometryIndex, setSelectedGeometryIndex] = useState(0); // Intensive Analysis State const [intensiveMetadata, setIntensiveMetadata] = useState(null); @@ -39,15 +45,23 @@ export default function CoordinationGeometryAnalyzer() { const canvasRef = useRef(null); const fileInputRef = useRef(null); - // File Upload Hook - const { atoms, fileName, error, uploadMetadata, handleFileUpload } = useFileUpload(); - - // Stable callback for radius changes - const handleRadiusChange = useCallback((radius, isAuto) => { - // Analysis will trigger automatically via coordAtoms dependency in useShapeAnalysis - // No need to manually set analysisParams.key here - }, []); - + // File Upload Hook - v1.5.0 with multi-structure support + const { + structures, + atoms, + currentStructure, + selectedStructureIndex, + fileName, + fileFormat, + error, + uploadMetadata, + handleFileUpload, + selectStructure, + batchMode, + structureCount + } = useFileUpload(); + + // Warning and error handlers const handleWarning = useCallback((msg) => { setWarnings(prev => [...prev, msg]); }, []); @@ -56,7 +70,34 @@ export default function CoordinationGeometryAnalyzer() { setWarnings(prev => [...prev, `Error: ${msg}`]); }, []); - // Radius Control Hook (v1.1.0) - defined before use + // Batch Analysis Hook + const { + batchResults, + getBatchSummary, + structureOverrides, + setStructureOverride, + applyOverrideToAll, + analyzeAllStructures, + cancelBatchAnalysis, + setStructureResult, + isBatchRunning, + batchProgress + } = useBatchAnalysis({ + structures, + onWarning: handleWarning, + onError: handleError + }); + + // Get effective metal and radius (with override support) + const effectiveMetal = useMemo(() => { + const override = structureOverrides.get(selectedStructureIndex); + if (override?.metalIndex !== undefined) { + return override.metalIndex; + } + return selectedMetal; + }, [selectedMetal, selectedStructureIndex, structureOverrides]); + + // Radius Control Hook const { coordRadius, autoRadius, @@ -73,14 +114,14 @@ export default function CoordinationGeometryAnalyzer() { setTargetCNInput } = useRadiusControl({ atoms, - selectedMetal, - onRadiusChange: handleRadiusChange, + selectedMetal: effectiveMetal, + onRadiusChange: useCallback(() => {}, []), onWarning: handleWarning }); - // Intensive Analysis Handler (after coordRadius is defined) + // Intensive Analysis Handler const handleIntensiveAnalysis = useCallback(async () => { - if (!atoms || selectedMetal === null || !coordRadius) { + if (!atoms || effectiveMetal === null || !coordRadius) { handleWarning('Cannot run intensive analysis: Missing required data'); return; } @@ -91,32 +132,42 @@ export default function CoordinationGeometryAnalyzer() { try { const results = await runIntensiveAnalysisAsync( atoms, - selectedMetal, + effectiveMetal, coordRadius, (progress) => { setIntensiveProgress(progress); } ); - // Validate results before setting state if (!results || !results.geometryResults || !results.ligandGroups || !results.metadata) { throw new Error('Invalid results structure from intensive analysis'); } - // Store metadata AND geometry results from intensive analysis setIntensiveMetadata({ ligandGroups: results.ligandGroups, metadata: results.metadata }); - // Use the intensive geometry results instead of running default analysis - // This ensures the UI shows the improved CShM values from intensive mode setAnalysisParams({ mode: 'intensive', key: Date.now(), intensiveResults: results.geometryResults }); + // Store result in batch results if in batch mode + if (batchMode) { + setStructureResult(selectedStructureIndex, { + geometryResults: results.geometryResults, + bestGeometry: results.geometryResults[0] || null, + ligandGroups: results.ligandGroups, + metadata: results.metadata, + metalIndex: effectiveMetal, + radius: coordRadius, + coordinationNumber: results.metadata?.coordinationNumber || 0, + analysisMode: 'intensive' + }); + } + setIntensiveProgress(null); } catch (error) { @@ -126,12 +177,12 @@ export default function CoordinationGeometryAnalyzer() { } finally { setIsRunningIntensive(false); } - }, [atoms, selectedMetal, coordRadius, handleWarning, handleError]); + }, [atoms, effectiveMetal, coordRadius, handleWarning, handleError, batchMode, selectedStructureIndex, setStructureResult]); // Coordination Hook const { coordAtoms } = useCoordination({ atoms, - selectedMetal, + selectedMetal: effectiveMetal, coordRadius }); @@ -150,6 +201,11 @@ export default function CoordinationGeometryAnalyzer() { onError: handleError }); + // Scene key for forcing 3D re-render when selection changes + const sceneKey = useMemo(() => { + return `${currentStructure?.id || 'none'}-${effectiveMetal}-${coordRadius?.toFixed(2) || '0'}-${selectedGeometryIndex}`; + }, [currentStructure?.id, effectiveMetal, coordRadius, selectedGeometryIndex]); + // Reset selected geometry to best match when new results arrive useEffect(() => { if (geometryResults && geometryResults.length > 0) { @@ -162,54 +218,102 @@ export default function CoordinationGeometryAnalyzer() { ? geometryResults[selectedGeometryIndex] : bestGeometry; - // Three.js Scene Hook + // Three.js Scene Hook with scene key for proper re-rendering const { sceneRef, rendererRef, cameraRef } = useThreeScene({ canvasRef, atoms, - selectedMetal, + selectedMetal: effectiveMetal, coordAtoms, - bestGeometry: displayGeometry, // Use selected geometry instead of always using best + bestGeometry: displayGeometry, autoRotate, showIdeal, - showLabels + showLabels, + sceneKey // Pass scene key to trigger re-renders }); - // Sync upload metadata with state (set metal center and radius after upload) - // Track processed uploads to prevent re-processing the same upload + // Sync upload metadata with state on new file upload const processedUploadTime = useRef(null); useEffect(() => { if (uploadMetadata && uploadMetadata.uploadTime !== processedUploadTime.current) { - // Mark this upload as processed processedUploadTime.current = uploadMetadata.uploadTime; - // Reset all state to prevent lingering data from previous calculations + // Reset all state for new upload setWarnings([]); setSelectedMetal(null); setAnalysisParams({ mode: 'default', key: 0 }); setIntensiveMetadata(null); setIntensiveProgress(null); + setSelectedGeometryIndex(0); - // Reset file input to allow re-uploading the same file + // Reset file input if (fileInputRef.current) { fileInputRef.current.value = ''; } - // Set new values from uploaded file + // Set values from uploaded file if (uploadMetadata.detectedMetalIndex != null) { setSelectedMetal(uploadMetadata.detectedMetalIndex); } if (uploadMetadata.suggestedRadius) { - setCoordRadius(uploadMetadata.suggestedRadius, true); // true = auto-detected + setCoordRadius(uploadMetadata.suggestedRadius, true); } setAnalysisParams({ mode: 'default', key: Date.now() }); } }, [uploadMetadata, setCoordRadius]); + // Update analysis when structure selection changes in batch mode + useEffect(() => { + if (batchMode && uploadMetadata?.structureMetadata) { + const metadata = uploadMetadata.structureMetadata[selectedStructureIndex]; + if (metadata) { + // Check if we have an override for this structure + const override = structureOverrides.get(selectedStructureIndex); + + if (override?.metalIndex !== undefined) { + setSelectedMetal(override.metalIndex); + } else if (metadata.detectedMetalIndex != null) { + setSelectedMetal(metadata.detectedMetalIndex); + } + + if (override?.radius !== undefined) { + setCoordRadius(override.radius, false); + } else if (metadata.suggestedRadius) { + setCoordRadius(metadata.suggestedRadius, true); + } + + // Reset to default analysis for new structure + setAnalysisParams({ mode: 'default', key: Date.now() }); + setIntensiveMetadata(null); + setSelectedGeometryIndex(0); + } + } + }, [selectedStructureIndex, batchMode, uploadMetadata, setCoordRadius, structureOverrides]); + + // Handle structure selection + const handleSelectStructure = useCallback((index) => { + selectStructure(index); + }, [selectStructure]); + + // Handle metal change with override storage + const handleMetalChange = useCallback((metalIndex) => { + setSelectedMetal(metalIndex); + if (batchMode) { + setStructureOverride(selectedStructureIndex, { metalIndex }); + } + }, [batchMode, selectedStructureIndex, setStructureOverride]); + + // Handle radius change with override storage + const handleRadiusChangeWithOverride = useCallback((radius) => { + setCoordRadius(radius, false); + if (batchMode) { + setStructureOverride(selectedStructureIndex, { radius }); + } + }, [batchMode, selectedStructureIndex, setStructureOverride, setCoordRadius]); // Report generation using service const handleGenerateReport = useCallback(() => { - if (!atoms.length || selectedMetal == null || !bestGeometry) return; + if (!atoms.length || effectiveMetal == null || !bestGeometry) return; try { const canvas = canvasRef.current; @@ -228,7 +332,7 @@ export default function CoordinationGeometryAnalyzer() { generatePDFReport({ atoms, - selectedMetal, + selectedMetal: effectiveMetal, bestGeometry, coordAtoms, coordRadius, @@ -236,36 +340,78 @@ export default function CoordinationGeometryAnalyzer() { additionalMetrics, qualityMetrics, warnings, - fileName, + fileName: currentStructure?.id || fileName, analysisMode: analysisParams.mode, intensiveMetadata, - imgData + imgData, + structureId: currentStructure?.id }); } catch (err) { console.error("Report generation failed:", err); setWarnings(prev => [...prev, `Report generation failed: ${err.message}`]); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [atoms, selectedMetal, bestGeometry, fileName, analysisParams.mode, coordRadius, coordAtoms, geometryResults, additionalMetrics, qualityMetrics, warnings, intensiveMetadata]); + }, [atoms, effectiveMetal, bestGeometry, fileName, analysisParams.mode, coordRadius, coordAtoms, geometryResults, additionalMetrics, qualityMetrics, warnings, intensiveMetadata, currentStructure, rendererRef, cameraRef, sceneRef]); - // CSV Export using service + // Batch PDF Report + const handleGenerateBatchReport = useCallback(() => { + if (!batchMode || batchResults.size === 0) { + handleWarning('No batch results available for report generation'); + return; + } + + try { + generateBatchPDFReport({ + structures, + batchResults, + fileName, + fileFormat + }); + } catch (err) { + console.error("Batch report generation failed:", err); + setWarnings(prev => [...prev, `Batch report generation failed: ${err.message}`]); + } + }, [batchMode, batchResults, structures, fileName, fileFormat, handleWarning]); + + // CSV Export - Single structure (all geometries) const handleGenerateCSV = useCallback(() => { if (!geometryResults || geometryResults.length === 0) return; try { - generateCSVReport({ geometryResults, fileName }); + generateCSVReport({ + geometryResults, + fileName: currentStructure?.id || fileName + }); } catch (err) { console.error("CSV generation failed:", err); setWarnings(prev => [...prev, `CSV export failed: ${err.message}`]); } - }, [geometryResults, fileName]); + }, [geometryResults, fileName, currentStructure]); + + // CSV Export - Long detailed (batch mode, all geometries) + const handleGenerateLongDetailedCSV = useCallback(() => { + if (!batchMode || batchResults.size === 0) { + handleWarning('No batch results available for CSV export'); + return; + } + + try { + generateLongDetailedCSV({ + structures, + batchResults, + fileName + }); + } catch (err) { + console.error("Long CSV generation failed:", err); + setWarnings(prev => [...prev, `Long CSV export failed: ${err.message}`]); + } + }, [batchMode, batchResults, structures, fileName, handleWarning]); return (

- πŸ”¬ Q-Shape (Quantitative Shape Analyzer) + πŸ”¬ {APP_FULL_NAME}

Advanced Coordination Geometry Analysis @@ -276,10 +422,10 @@ export default function CoordinationGeometryAnalyzer() { marginTop: '0.5rem', fontFamily: 'monospace' }}> - Version 1.4.0 | Built: November 25, 2025 + Version {APP_VERSION} | Built: {BUILD_DATE}

- Cite this: Castro Silva Junior, H. (2025). Q-Shape - Quantitative Shape Analyzer (v1.4.0). Zenodo. https://doi.org/10.5281/zenodo.17717110 + Cite this: {getCitationString()} {CITATION.url}

@@ -288,7 +434,7 @@ export default function CoordinationGeometryAnalyzer() { ⚠️ Error: {error}
)} - + {warnings.length > 0 && (
@@ -314,18 +460,32 @@ export default function CoordinationGeometryAnalyzer() {
)} - + {atoms.length > 0 && ( <> + {/* Batch Mode Panel - structure selector (shown when multiple structures detected) */} + {batchMode && ( + + )} + applyOverrideToAll({ metalIndex })} + onApplyRadiusToAll={(radius) => applyOverrideToAll({ radius })} /> 0 ? handleGenerateBatchReport : handleGenerateReport} + onGenerateCSV={batchMode && batchResults.size > 0 ? handleGenerateLongDetailedCSV : handleGenerateCSV} + batchMode={batchMode} + batchResults={batchResults} + isBatchRunning={isBatchRunning} + onAnalyzeAll={analyzeAllStructures} + onCancelBatch={cancelBatchAnalysis} + structureId={currentStructure?.id} /> + {/* Batch Summary Table - positioned below action buttons, close to 3D viewer */} + {batchMode && ( + + )} +
)} - + - +
); } - diff --git a/src/components/AnalysisControls.jsx b/src/components/AnalysisControls.jsx index 4c6d2a2..6f2be60 100644 --- a/src/components/AnalysisControls.jsx +++ b/src/components/AnalysisControls.jsx @@ -23,15 +23,39 @@ export default function AnalysisControls({ onDecrementRadius, onCoordRadiusChange, onAutoRadiusChange, - onTargetCNInputChange + onTargetCNInputChange, + // v1.5.0 batch mode props + batchMode = false, + onApplyMetalToAll, + onApplyRadiusToAll }) { return (
{/* Metal Center Selector */}
- +
+ + {batchMode && selectedMetal !== null && onApplyMetalToAll && ( + + )} +
onAutoRadiusChange(e.target.checked)} - style={{ cursor: 'pointer' }} - /> - Auto - +
+ {batchMode && onApplyRadiusToAll && ( + + )} + +
{/* Precise Radius Control */} diff --git a/src/components/BatchModePanel.jsx b/src/components/BatchModePanel.jsx new file mode 100644 index 0000000..8c8529c --- /dev/null +++ b/src/components/BatchModePanel.jsx @@ -0,0 +1,75 @@ +/** + * Batch Mode Panel Component - v1.5.0 + * + * Provides UI for batch mode operations: + * - Structure selector dropdown + * - Visual cue for selected structure + * + * Note: Batch summary table is now in BatchSummaryTable component + */ + +import React from 'react'; + +export default function BatchModePanel({ + structures, + selectedStructureIndex, + onSelectStructure, + batchResults +}) { + return ( +
+
+

+ πŸ“š Batch Mode ({structures.length} structures) +

+
+ + {/* Structure selector */} +
+ + +
+
+ ); +} diff --git a/src/components/BatchSummaryTable.jsx b/src/components/BatchSummaryTable.jsx new file mode 100644 index 0000000..abfafef --- /dev/null +++ b/src/components/BatchSummaryTable.jsx @@ -0,0 +1,270 @@ +/** + * Batch Summary Table Component - v1.5.0 + * + * Displays the batch analysis summary table and batch progress. + * Positioned below action buttons, closer to the 3D viewer. + */ + +import React from 'react'; +import { interpretShapeMeasure } from '../utils/geometry'; + +export default function BatchSummaryTable({ + structures, + selectedStructureIndex, + onSelectStructure, + batchResults, + batchProgress, + getBatchSummary +}) { + const summary = getBatchSummary?.() || []; + const hasResults = summary.length > 0; + + return ( +
+ {/* Batch Progress indicator */} + {batchProgress && ( +
+
+ + {batchProgress.message} + + {batchProgress.progress !== undefined && batchProgress.stage === 'analyzing' && ( + + {Math.round(batchProgress.progress)}% + + )} +
+ {batchProgress.stage === 'analyzing' && ( +
+
+
+ )} +
+ )} + + {/* Batch Summary Table */} + {hasResults && ( + <> +

+ πŸ“‘ Batch Analysis Summary +

+
+ + + + + + + + + + + + + + {summary.map((row, idx) => { + const isSelected = row.index === selectedStructureIndex; + const interpretation = row.bestCShM !== null + ? interpretShapeMeasure(row.bestCShM) + : null; + + // Color scheme: selected rows use soft, muted blue palette + // Non-selected rows use semantic colors for quality indicators + const selectedTextColor = '#1e3a5f'; // Soft dark blue for text + const selectedAccent = '#3b82f6'; // Medium blue for accents + const selectedBadgeBg = 'rgba(59, 130, 246, 0.12)'; // Very soft blue tint + + return ( + onSelectStructure(row.index)} + style={{ + cursor: 'pointer', + background: isSelected + ? 'linear-gradient(135deg, #f0f7ff 0%, #e8f2ff 100%)' // Softer blue gradient + : idx % 2 === 0 ? '#f8fafc' : 'white', + borderLeft: isSelected ? '4px solid #60a5fa' : '4px solid transparent', // Lighter blue border + transition: 'all 0.2s ease' + }} + onMouseEnter={(e) => { + if (!isSelected) { + e.currentTarget.style.background = '#f1f5f9'; + } + }} + onMouseLeave={(e) => { + if (!isSelected) { + e.currentTarget.style.background = idx % 2 === 0 ? '#f8fafc' : 'white'; + } + }} + > + + + + + + + + + ); + })} + +
#Structure IDMetalCNBest GeometryCShMQuality
+ {idx + 1} + + {row.id} + {isSelected && ( + + β—€ + + )} + + {row.metalElement} + + {row.coordinationNumber} + + {row.bestGeometry} + + {row.bestCShM !== null ? row.bestCShM.toFixed(4) : 'β€”'} + + {interpretation && ( + + {interpretation.confidence}% + + )} +
+
+ + {/* Summary stats */} +
+ + {summary.length} of {structures.length} structures analyzed + + + Click a row to view details + +
+ + )} + + {/* Empty state when no results yet but batch progress exists */} + {!hasResults && !batchProgress && structures.length > 0 && ( +
+
πŸ“Š
+
+ Click "Analyze All Structures" to process all {structures.length} structures at once +
+
+ )} +
+ ); +} diff --git a/src/components/CoordinationSummary.jsx b/src/components/CoordinationSummary.jsx index 74a5f20..ea4152a 100644 --- a/src/components/CoordinationSummary.jsx +++ b/src/components/CoordinationSummary.jsx @@ -1,7 +1,8 @@ /** - * Coordination Summary Component + * Coordination Summary Component - v1.5.0 * - * Displays coordination information, quality metrics, and action buttons + * Displays coordination information, quality metrics, and action buttons. + * Buttons are context-aware - they automatically handle batch vs single mode. */ import React from 'react'; @@ -22,12 +23,26 @@ export default function CoordinationSummary({ geometryResults, onIntensiveAnalysis, onGenerateReport, - onGenerateCSV + onGenerateCSV, + // v1.5.0 batch mode props + batchMode = false, + batchResults, + isBatchRunning = false, + onAnalyzeAll, + onCancelBatch, + structureId = null }) { - if (selectedMetal == null) { + // Safety check: ensure selectedMetal is valid and within bounds + if (selectedMetal == null || !atoms || selectedMetal >= atoms.length || !atoms[selectedMetal]) { return null; } + const hasBatchResults = batchResults && batchResults.size > 0; + const canGenerateReport = batchMode ? hasBatchResults : (bestGeometry && !isLoading); + const canGenerateCSV = batchMode + ? hasBatchResults + : (geometryResults && geometryResults.length > 0 && !isLoading); + return (
+ {/* Structure ID indicator for batch mode */} + {batchMode && structureId && ( +
+ πŸ“„ + + Viewing: {structureId} + + + (use structure selector above to switch) + +
+ )} +
- {/* Action Buttons */} + {/* Action Buttons - context-aware for batch vs single mode */}
!(isLoading || isRunningIntensive) && (e.currentTarget.style.transform = 'translateY(-2px)')} onMouseOut={(e) => e.currentTarget.style.transform = 'translateY(0)'} @@ -151,23 +188,23 @@ export default function CoordinationSummary({ + + {/* Analyze All Structures button - only in batch mode */} + {batchMode && ( + + )}
{/* Progress Display */} @@ -294,10 +360,9 @@ export default function CoordinationSummary({
- {/* Worker details would go here if needed */} {intensiveProgress.workerDetails && intensiveProgress.workerDetails.estimatedRemaining > 0 && (
- ⏱️ Estimated time remaining: {intensiveProgress.workerDetails.estimatedRemaining}s + Estimated time remaining: {intensiveProgress.workerDetails.estimatedRemaining}s {' | '} Elapsed: {intensiveProgress.workerDetails.elapsed}s
@@ -319,18 +384,17 @@ export default function CoordinationSummary({ πŸ”¬ Ab Initio Analysis (CN={intensiveMetadata.metadata?.coordinationNumber || 'N/A'})
- {/* Structure type identification (for info only) */} {(() => { const rings = intensiveMetadata.ligandGroups?.rings?.length || 0; const mono = intensiveMetadata.ligandGroups?.monodentate?.length || 0; let structureType = ''; if (rings === 1 && mono > 0) { - structureType = '🎹 Piano Stool Structure'; + structureType = 'Piano Stool Structure'; } else if (rings === 2) { - structureType = 'πŸ₯ͺ Sandwich Structure'; + structureType = 'Sandwich Structure'; } else if (rings === 1 && mono === 0) { - structureType = 'β­• Macrocyclic Structure'; + structureType = 'Macrocyclic Structure'; } return structureType ? ( @@ -347,14 +411,14 @@ export default function CoordinationSummary({ {intensiveMetadata.ligandGroups?.rings?.length > 0 && (
{intensiveMetadata.ligandGroups.rings.map((ring, i) => ( -
β€’ Ring {i + 1}: {ring?.hapticity || 'Unknown'} ({ring?.size || 0} atoms)
+
Ring {i + 1}: {ring?.hapticity || 'Unknown'} ({ring?.size || 0} atoms)
))}
)} {intensiveMetadata.metadata?.bestGeometry && (
- β†’ Best fit: {intensiveMetadata.metadata.bestGeometry} (CShM = {intensiveMetadata.metadata.bestCShM?.toFixed(3)}) + Best fit: {intensiveMetadata.metadata.bestGeometry} (CShM = {intensiveMetadata.metadata.bestCShM?.toFixed(3)})
)}
diff --git a/src/components/FileUploadSection.jsx b/src/components/FileUploadSection.jsx index 73abfeb..69d31e0 100644 --- a/src/components/FileUploadSection.jsx +++ b/src/components/FileUploadSection.jsx @@ -1,26 +1,109 @@ /** - * File Upload Section Component + * File Upload Section Component - v1.5.0 * - * Handles XYZ file upload interface + * Handles file upload interface for molecular structure files. + * Supports XYZ (single/multi-frame) and CIF formats. + * + * v1.5.0 Changes: + * - Updated to accept .xyz and .cif files + * - Shows batch mode indicator when multiple structures detected + * - Displays file format and structure count */ import React from 'react'; -export default function FileUploadSection({ fileInputRef, onFileUpload }) { +export default function FileUploadSection({ + fileInputRef, + onFileUpload, + batchMode = false, + structureCount = 0, + fileFormat = null, + currentStructureId = null +}) { return (
+

+ Supports single structures, multi-frame XYZ trajectories, and CIF files with multiple blocks +

+ + {/* Batch mode indicator */} + {structureCount > 0 && ( +
+ + {batchMode ? 'πŸ“š' : 'πŸ“„'} + +
+
+ {batchMode + ? `Batch Mode: ${structureCount} structures detected` + : 'Single Structure Mode' + } +
+
+ Format: {fileFormat?.toUpperCase() || 'Unknown'} + {currentStructureId && ( + + | Current: {currentStructureId} + + )} +
+
+
+ )} + + {/* Batch mode help text */} + {batchMode && ( +
+ Tip: Use the structure selector below to switch between structures, + or click "Analyze All Structures" to process the entire batch. +
+ )}
); } diff --git a/src/components/ManualOverridePanel.jsx b/src/components/ManualOverridePanel.jsx new file mode 100644 index 0000000..d6e9cf2 --- /dev/null +++ b/src/components/ManualOverridePanel.jsx @@ -0,0 +1,355 @@ +/** + * Manual Override Panel Component - v1.5.0 + * + * Allows users to manually override: + * - Metal center selection + * - Coordination radius + * - Target coordination number + * + * Supports applying overrides per structure or to all structures in batch mode. + */ + +import React, { useState } from 'react'; +import { ATOMIC_DATA, ALL_METALS } from '../constants/atomicData'; + +export default function ManualOverridePanel({ + atoms, + currentMetal, + currentRadius, + currentCN, + onMetalChange, + onRadiusChange, + onFindRadiusForCN, + batchMode = false, + onApplyToAll, + structureId = null +}) { + const [targetCN, setTargetCN] = useState(''); + const [showAdvanced, setShowAdvanced] = useState(false); + + // Get all metal atoms in the structure + const metalAtoms = atoms + .map((atom, index) => ({ atom, index })) + .filter(({ atom }) => ALL_METALS.has(atom.element)); + + // Get all atoms for advanced selection + const allAtoms = atoms.map((atom, index) => ({ atom, index })); + + const handleFindRadius = () => { + const cn = parseInt(targetCN, 10); + if (cn >= 2 && cn <= 12) { + onFindRadiusForCN?.(cn); + } + }; + + const handleApplyToAll = (type) => { + if (!onApplyToAll) return; + + switch (type) { + case 'metal': + onApplyToAll({ metalIndex: currentMetal }); + break; + case 'radius': + onApplyToAll({ radius: currentRadius }); + break; + default: + break; + } + }; + + return ( +
+
+

+ πŸ”§ Manual Override +

+ {structureId && ( + + Structure: {structureId} + + )} +
+ +

+ Use these controls when automatic detection fails or needs adjustment. +

+ + {/* Metal Center Selection */} +
+ + + +
+ +
+ + {currentMetal !== null && atoms[currentMetal] && ( +
+ Selected: {atoms[currentMetal].element} at position + ({atoms[currentMetal].x.toFixed(3)}, {atoms[currentMetal].y.toFixed(3)}, {atoms[currentMetal].z.toFixed(3)}) +
+ )} +
+ + {/* Coordination Radius */} +
+ +
+ { + const value = parseFloat(e.target.value); + if (Number.isFinite(value) && value > 0) { + onRadiusChange?.(value); + } + }} + step="0.1" + min="1" + max="10" + style={{ + flex: 1, + padding: '0.75rem', + borderRadius: '8px', + border: '2px solid #e2e8f0', + fontSize: '0.95rem' + }} + /> + + +
+ + {currentCN !== undefined && ( +
+ Current coordination number: {currentCN} +
+ )} +
+ + {/* Find Radius for CN */} +
+ +
+ setTargetCN(e.target.value)} + placeholder="e.g., 6" + min="2" + max="12" + style={{ + flex: 1, + padding: '0.75rem', + borderRadius: '8px', + border: '2px solid #e2e8f0', + fontSize: '0.95rem' + }} + /> + +
+

+ Automatically adjusts the radius to achieve the target coordination number (CN 2-12). +

+
+ + {/* Warning about re-analysis */} +
+ Note: Changing these settings will clear analysis results for this structure + and require re-analysis. +
+
+ ); +} diff --git a/src/components/ResultsDisplay.jsx b/src/components/ResultsDisplay.jsx index 7d57207..4289183 100644 --- a/src/components/ResultsDisplay.jsx +++ b/src/components/ResultsDisplay.jsx @@ -1,7 +1,8 @@ /** - * Results Display Component + * Results Display Component - v1.5.0 * - * Displays geometry analysis results table and references + * Displays geometry analysis results table and references. + * Updated for batch mode with structure ID display. */ import React from 'react'; @@ -15,7 +16,10 @@ export default function ResultsDisplay({ progress, selectedMetal, selectedGeometryIndex = 0, - onGeometrySelect + onGeometrySelect, + // v1.5.0 batch mode props + structureId = null, + batchMode = false }) { return (
@@ -23,9 +27,27 @@ export default function ResultsDisplay({ margin: '0 0 0.5rem 0', color: '#1e293b', fontSize: '1.25rem', - fontWeight: 700 + fontWeight: 700, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + flexWrap: 'wrap', + gap: '0.5rem' }}> - πŸ“ˆ Geometry Analysis Results + πŸ“ˆ Geometry Analysis Results + {batchMode && structureId && ( + + Structure: {structureId} + + )} {geometryResults.length > 0 && (

+ `${CITATION.author} (${CITATION.year}). ${CITATION.title} (v${APP_VERSION}). Zenodo. ${CITATION.url}`; + +// Version display string +export const getVersionString = () => + `Version ${APP_VERSION} | Built: ${BUILD_DATE}`; diff --git a/src/hooks/useBatchAnalysis.js b/src/hooks/useBatchAnalysis.js new file mode 100644 index 0000000..a210856 --- /dev/null +++ b/src/hooks/useBatchAnalysis.js @@ -0,0 +1,369 @@ +/** + * useBatchAnalysis Hook - v1.5.0 + * + * Manages batch analysis state and orchestrates analysis across multiple structures. + * This is the SINGLE orchestrator for batch processing - no parallel pipelines. + * + * Features: + * - Stores results per structure (Map) + * - Progress tracking for batch operations + * - Support for both default and intensive analysis modes + * - State reset on new file upload + */ + +import { useState, useCallback, useRef, useEffect } from 'react'; +import { runIntensiveAnalysisAsync } from '../services/coordination/intensiveAnalysis'; +import { detectMetalCenter } from '../services/coordination/metalDetector'; +import { detectOptimalRadius } from '../services/coordination/radiusDetector'; +import { getCoordinatingAtoms } from '../services/coordination/sphereDetector'; + +/** + * @typedef {Object} StructureOverride + * @property {number} [metalIndex] - Override metal center + * @property {number} [radius] - Override coordination radius + */ + +export function useBatchAnalysis({ structures, onWarning, onError }) { + // Results storage: Map + const [batchResults, setBatchResults] = useState(new Map()); + + // Override storage: Map + const [structureOverrides, setStructureOverrides] = useState(new Map()); + + // Batch operation state + const [isBatchRunning, setIsBatchRunning] = useState(false); + const [batchProgress, setBatchProgress] = useState(null); + + // Cancellation support + const cancelRef = useRef(false); + + // Reset batch results when structures change (new file upload) + useEffect(() => { + setBatchResults(new Map()); + setStructureOverrides(new Map()); + setBatchProgress(null); + setIsBatchRunning(false); + cancelRef.current = false; + }, [structures]); + + /** + * Get effective metal index for a structure (with override support) + */ + const getMetalIndex = useCallback((structureIndex) => { + const override = structureOverrides.get(structureIndex); + if (override && override.metalIndex !== undefined) { + return override.metalIndex; + } + + // Auto-detect + if (structures[structureIndex]) { + return detectMetalCenter(structures[structureIndex].atoms); + } + return null; + }, [structures, structureOverrides]); + + /** + * Get effective radius for a structure (with override support) + */ + const getRadius = useCallback((structureIndex) => { + const override = structureOverrides.get(structureIndex); + if (override && override.radius !== undefined) { + return override.radius; + } + + // Auto-detect + if (structures[structureIndex]) { + const atoms = structures[structureIndex].atoms; + const metalIdx = getMetalIndex(structureIndex); + if (metalIdx !== null && atoms[metalIdx]) { + return detectOptimalRadius(atoms[metalIdx], atoms); + } + } + return 3.0; // default + }, [structures, structureOverrides, getMetalIndex]); + + /** + * Set override for a structure + */ + const setStructureOverride = useCallback((structureIndex, override) => { + setStructureOverrides(prev => { + const next = new Map(prev); + const existing = next.get(structureIndex) || {}; + next.set(structureIndex, { ...existing, ...override }); + return next; + }); + + // Clear results for this structure since parameters changed + setBatchResults(prev => { + const next = new Map(prev); + next.delete(structureIndex); + return next; + }); + }, []); + + /** + * Apply override to all structures + */ + const applyOverrideToAll = useCallback((override) => { + const newOverrides = new Map(); + structures.forEach((_, index) => { + const existing = structureOverrides.get(index) || {}; + newOverrides.set(index, { ...existing, ...override }); + }); + setStructureOverrides(newOverrides); + + // Clear all results + setBatchResults(new Map()); + }, [structures, structureOverrides]); + + /** + * Store analysis result for a structure + */ + const setStructureResult = useCallback((structureIndex, result) => { + setBatchResults(prev => { + const next = new Map(prev); + next.set(structureIndex, { + ...result, + structureId: structures[structureIndex]?.id || `structure-${structureIndex}`, + timestamp: Date.now() + }); + return next; + }); + }, [structures]); + + /** + * Get result for a structure + */ + const getStructureResult = useCallback((structureIndex) => { + return batchResults.get(structureIndex) || null; + }, [batchResults]); + + /** + * Run intensive analysis for a single structure + */ + const analyzeStructure = useCallback(async (structureIndex, onProgress) => { + if (!structures[structureIndex]) { + throw new Error(`Structure ${structureIndex} not found`); + } + + const atoms = structures[structureIndex].atoms; + const metalIndex = getMetalIndex(structureIndex); + const radius = getRadius(structureIndex); + + if (metalIndex === null) { + throw new Error(`No metal center detected for structure ${structureIndex}`); + } + + const result = await runIntensiveAnalysisAsync( + atoms, + metalIndex, + radius, + onProgress + ); + + // Compute coordinating atoms for this structure + // This is needed for the batch report to show full details + const coordAtoms = getCoordinatingAtoms(atoms, metalIndex, radius); + + // Store result with coordAtoms included + setStructureResult(structureIndex, { + geometryResults: result.geometryResults, + bestGeometry: result.geometryResults[0] || null, + ligandGroups: result.ligandGroups, + metadata: result.metadata, + metalIndex, + radius, + coordAtoms, // Include coordAtoms for batch report + coordinationNumber: coordAtoms.length || result.metadata?.coordinationNumber || 0, + analysisMode: 'intensive' + }); + + return result; + }, [structures, getMetalIndex, getRadius, setStructureResult]); + + /** + * Run batch analysis for all structures + */ + const analyzeAllStructures = useCallback(async () => { + + if (structures.length === 0) { + onWarning?.('No structures to analyze'); + return; + } + + setIsBatchRunning(true); + cancelRef.current = false; + + const totalStructures = structures.length; + const results = []; + + try { + for (let i = 0; i < totalStructures; i++) { + // Check for cancellation + if (cancelRef.current) { + setBatchProgress({ + stage: 'cancelled', + currentStructure: i, + totalStructures, + structureId: structures[i]?.id, + progress: (i / totalStructures) * 100, + message: `Cancelled after ${i} structures` + }); + break; + } + + const structureId = structures[i]?.id || `structure-${i}`; + + setBatchProgress({ + stage: 'analyzing', + currentStructure: i + 1, + totalStructures, + structureId, + progress: (i / totalStructures) * 100, + message: `Analyzing structure ${i + 1}/${totalStructures}: ${structureId}` + }); + + try { + const result = await analyzeStructure(i, (progress) => { + setBatchProgress({ + stage: 'analyzing', + currentStructure: i + 1, + totalStructures, + structureId, + progress: ((i + (progress.progress / 100)) / totalStructures) * 100, + message: `${structureId}: ${progress.message || 'Processing...'}` + }); + }); + results.push({ structureIndex: i, structureId, success: true, result }); + } catch (err) { + console.error(`Error analyzing structure ${i}:`, err); + results.push({ structureIndex: i, structureId, success: false, error: err.message }); + onWarning?.(`Structure ${structureId}: ${err.message}`); + } + } + + setBatchProgress({ + stage: 'complete', + currentStructure: totalStructures, + totalStructures, + progress: 100, + message: `Completed: ${results.filter(r => r.success).length}/${totalStructures} structures analyzed` + }); + + } catch (err) { + console.error('Batch analysis failed:', err); + onError?.(`Batch analysis failed: ${err.message}`); + setBatchProgress({ + stage: 'error', + message: err.message + }); + } finally { + setIsBatchRunning(false); + } + + return results; + }, [structures, analyzeStructure, onWarning, onError]); + + /** + * Cancel running batch analysis + */ + const cancelBatchAnalysis = useCallback(() => { + cancelRef.current = true; + }, []); + + /** + * Clear all batch results + */ + const clearBatchResults = useCallback(() => { + setBatchResults(new Map()); + setBatchProgress(null); + }, []); + + /** + * Check if all structures have been analyzed + */ + const isAllAnalyzed = structures.length > 0 && + structures.every((_, index) => batchResults.has(index)); + + /** + * Get summary of batch results + */ + const getBatchSummary = useCallback(() => { + if (batchResults.size === 0) return null; + + const summary = []; + structures.forEach((structure, index) => { + const result = batchResults.get(index); + if (result) { + summary.push({ + index, + id: structure.id, + bestGeometry: result.bestGeometry?.name || 'N/A', + bestCShM: result.bestGeometry?.shapeMeasure ?? null, + coordinationNumber: result.coordinationNumber, + metalElement: structure.atoms[result.metalIndex]?.element || 'N/A', + analysisMode: result.analysisMode + }); + } + }); + + return summary; + }, [structures, batchResults]); + + /** + * Export batch results in long format (all geometries for all structures) + */ + const getLongFormatResults = useCallback(() => { + const rows = []; + + structures.forEach((structure, index) => { + const result = batchResults.get(index); + if (result && result.geometryResults) { + result.geometryResults.forEach((geom, geomIndex) => { + rows.push({ + structureId: structure.id, + structureIndex: index, + geometryRank: geomIndex + 1, + geometryName: geom.name, + shapeMeasure: geom.shapeMeasure, + metalElement: structure.atoms[result.metalIndex]?.element || 'N/A', + coordinationNumber: result.coordinationNumber, + radius: result.radius, + analysisMode: result.analysisMode + }); + }); + } + }); + + return rows; + }, [structures, batchResults]); + + return { + // Results + batchResults, + getStructureResult, + getBatchSummary, + getLongFormatResults, + isAllAnalyzed, + + // Overrides + structureOverrides, + setStructureOverride, + applyOverrideToAll, + getMetalIndex, + getRadius, + + // Analysis actions + analyzeStructure, + analyzeAllStructures, + cancelBatchAnalysis, + clearBatchResults, + setStructureResult, + + // Progress state + isBatchRunning, + batchProgress + }; +} + +export default useBatchAnalysis; diff --git a/src/hooks/useFileUpload.js b/src/hooks/useFileUpload.js index 385e3d7..2a28e56 100644 --- a/src/hooks/useFileUpload.js +++ b/src/hooks/useFileUpload.js @@ -1,44 +1,57 @@ /** - * useFileUpload Hook + * useFileUpload Hook - v1.5.0 * * Manages file upload, validation, and parsing for molecular structure files. - * Handles XYZ file format with comprehensive validation and error handling. + * Supports XYZ (single/multi-frame) and CIF formats. * - * @returns {Object} File upload state and handlers - * @returns {Array} atoms - Parsed molecular structure - * @returns {String} fileName - Name of uploaded file (without .xyz extension) - * @returns {String|null} error - Error message if upload/parsing failed - * @returns {Array} warnings - Array of warning messages - * @returns {Function} handleFileUpload - File upload event handler - * @returns {Function} resetFileState - Reset all file-related state - * - * @example - * const { atoms, fileName, error, warnings, handleFileUpload } = useFileUpload(); + * v1.5.0 Changes: + * - Uses unified parseInput API + * - Supports multiple structures (batch mode) + * - Returns structures[] instead of single atoms[] + * - Auto-detects batch mode based on structure count * - * // In JSX: - * + * @returns {Object} File upload state and handlers */ import { useState, useCallback } from 'react'; -import { parseXYZ, validateXYZ } from '../utils/fileParser'; +import { parseInput } from '../utils/parseInput'; import { detectMetalCenter } from '../services/coordination/metalDetector'; import { detectOptimalRadius } from '../services/coordination/radiusDetector'; +import { isBatchMode } from '../types/structureTypes'; export function useFileUpload() { - const [atoms, setAtoms] = useState([]); + // Core state + const [structures, setStructures] = useState([]); + const [selectedStructureIndex, setSelectedStructureIndex] = useState(0); const [fileName, setFileName] = useState(""); + const [fileFormat, setFileFormat] = useState(null); const [error, setError] = useState(null); const [warnings, setWarnings] = useState([]); const [uploadMetadata, setUploadMetadata] = useState(null); + // Derived state helper + const currentStructure = structures.length > 0 ? structures[selectedStructureIndex] : null; + const atoms = currentStructure ? currentStructure.atoms : []; + const batchMode = isBatchMode(structures); + + /** + * Handle file upload event + */ const handleFileUpload = useCallback((e) => { const file = e.target.files?.[0]; if (!file) return; - // Reset state + // Reset all state for new upload setError(null); setWarnings([]); - setFileName(file.name.replace(/\.xyz$/i, "")); + setStructures([]); + setSelectedStructureIndex(0); + setFileFormat(null); + setUploadMetadata(null); + + // Store filename without extension + const baseName = file.name.replace(/\.(xyz|cif)$/i, ""); + setFileName(baseName); const reader = new FileReader(); @@ -46,42 +59,68 @@ export function useFileUpload() { try { const content = String(ev.target?.result || ""); - // Validate XYZ file - const validation = validateXYZ(content); + // Use unified parser + const result = parseInput(content, file.name); - if (!validation.valid) { - throw new Error(validation.error); + if (!result.valid) { + throw new Error(result.error); } - if (validation.warnings && validation.warnings.length > 0) { - setWarnings(validation.warnings); + // Set warnings from parsing + if (result.warnings && result.warnings.length > 0) { + setWarnings(result.warnings); } - // Parse atoms - const parsedAtoms = parseXYZ(content); - setAtoms(parsedAtoms); + // Store structures + setStructures(result.structures); + setFileFormat(result.format); - // Auto-detect metal center - const metalIdx = detectMetalCenter(parsedAtoms); + // Auto-detect metal and radius for first structure + const firstStructure = result.structures[0]; + const firstAtoms = firstStructure.atoms; - // Calculate optimal radius if metal found + const metalIdx = detectMetalCenter(firstAtoms); let optimalRadius = 3.0; // default - if (metalIdx != null && parsedAtoms[metalIdx]) { - optimalRadius = detectOptimalRadius(parsedAtoms[metalIdx], parsedAtoms); + + if (metalIdx != null && firstAtoms[metalIdx]) { + optimalRadius = detectOptimalRadius(firstAtoms[metalIdx], firstAtoms); } - // Store metadata for parent component + // Calculate per-structure metadata + const structureMetadata = result.structures.map((struct, index) => { + const structAtoms = struct.atoms; + const structMetal = detectMetalCenter(structAtoms); + let structRadius = 3.0; + + if (structMetal != null && structAtoms[structMetal]) { + structRadius = detectOptimalRadius(structAtoms[structMetal], structAtoms); + } + + return { + index, + id: struct.id, + detectedMetalIndex: structMetal, + suggestedRadius: structRadius, + atomCount: structAtoms.length + }; + }); + + // Store upload metadata setUploadMetadata({ detectedMetalIndex: metalIdx, suggestedRadius: optimalRadius, - atomCount: parsedAtoms.length, - uploadTime: Date.now() + atomCount: firstAtoms.length, + uploadTime: Date.now(), + format: result.format, + frameCount: result.frameCount, + isBatchMode: isBatchMode(result.structures), + structureMetadata }); } catch (err) { console.error("File upload error:", err); setError(err.message); - setAtoms([]); + setStructures([]); setUploadMetadata(null); } }; @@ -89,29 +128,80 @@ export function useFileUpload() { reader.onerror = () => { const errorMsg = "Failed to read file - please check file permissions and try again"; setError(errorMsg); - setAtoms([]); + setStructures([]); setUploadMetadata(null); }; reader.readAsText(file); }, []); + /** + * Select a structure by index (for batch mode) + */ + const selectStructure = useCallback((index) => { + if (index >= 0 && index < structures.length) { + setSelectedStructureIndex(index); + } + }, [structures.length]); + + /** + * Select a structure by ID + */ + const selectStructureById = useCallback((id) => { + const index = structures.findIndex(s => s.id === id); + if (index >= 0) { + setSelectedStructureIndex(index); + } + }, [structures]); + + /** + * Reset all file-related state + */ const resetFileState = useCallback(() => { - setAtoms([]); + setStructures([]); + setSelectedStructureIndex(0); setFileName(""); + setFileFormat(null); setError(null); setWarnings([]); setUploadMetadata(null); }, []); + /** + * Get metadata for a specific structure + */ + const getStructureMetadata = useCallback((index) => { + if (uploadMetadata && uploadMetadata.structureMetadata) { + return uploadMetadata.structureMetadata[index]; + } + return null; + }, [uploadMetadata]); + return { - atoms, + // Core data + structures, + atoms, // Current structure's atoms (for backwards compatibility) + currentStructure, + selectedStructureIndex, fileName, + fileFormat, error, warnings, uploadMetadata, + + // Batch mode helpers + batchMode, + structureCount: structures.length, + + // Actions handleFileUpload, - resetFileState + selectStructure, + selectStructureById, + resetFileState, + getStructureMetadata, + + // Legacy compatibility + setWarnings }; } diff --git a/src/hooks/useThreeScene.js b/src/hooks/useThreeScene.js index 92122b1..dbe2f90 100644 --- a/src/hooks/useThreeScene.js +++ b/src/hooks/useThreeScene.js @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react'; import * as THREE from 'three'; -import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import { TrackballControls } from 'three/examples/jsm/controls/TrackballControls.js'; import { ATOMIC_DATA } from '../constants/atomicData'; /** @@ -52,7 +52,8 @@ export function useThreeScene({ bestGeometry, autoRotate, showIdeal, - showLabels + showLabels, + sceneKey // v1.5.0: Key to force scene re-render when structure/geometry changes }) { const sceneRef = useRef(null); const rendererRef = useRef(null); @@ -62,6 +63,13 @@ export function useThreeScene({ useEffect(() => { if (!canvasRef.current || atoms.length === 0 || selectedMetal == null) return; + // Safety check: ensure selected metal atom exists + const metal = atoms[selectedMetal]; + if (!metal || typeof metal.x !== 'number' || typeof metal.y !== 'number' || typeof metal.z !== 'number') { + console.warn('useThreeScene: Invalid metal atom at index', selectedMetal); + return; + } + const canvas = canvasRef.current; const container = canvas.parentElement; @@ -92,31 +100,33 @@ export function useThreeScene({ // Initialize camera const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100); - const metal = atoms[selectedMetal]; const center = new THREE.Vector3(metal.x, metal.y, metal.z); camera.position.set(center.x + 12, center.y + 8, center.z + 12); camera.lookAt(center); cameraRef.current = camera; - // Initialize controls - const controls = new OrbitControls(camera, renderer.domElement); + // Initialize TrackballControls for unrestricted 360Β° rotation + const controls = new TrackballControls(camera, renderer.domElement); controls.target.copy(center); - controls.enableDamping = true; - controls.dampingFactor = 0.05; - controls.screenSpacePanning = false; - controls.minDistance = 5; - controls.maxDistance = 50; - controls.autoRotate = autoRotate; - controls.autoRotateSpeed = 1.0; + controls.rotateSpeed = 3.0; // Rotation sensitivity + controls.zoomSpeed = 1.2; // Zoom sensitivity + controls.panSpeed = 0.8; // Pan sensitivity + controls.noZoom = false; // Enable zoom + controls.noPan = false; // Enable pan + controls.staticMoving = false; // Smooth movement (false = damping) + controls.dynamicDampingFactor = 0.15; // Damping amount + controls.minDistance = 3; + controls.maxDistance = 60; controlsRef.current = controls; - // Handle window resizing + // Handle window resizing (TrackballControls needs handleResize call) const handleResize = () => { const newWidth = container.clientWidth || 800; const newHeight = 600; camera.aspect = newWidth / newHeight; camera.updateProjectionMatrix(); renderer.setSize(newWidth, newHeight, false); + controls.handleResize(); // Required for TrackballControls }; const resizeObserver = new ResizeObserver(handleResize); @@ -194,6 +204,12 @@ export function useThreeScene({ // Render bonds as cylinders coordAtoms.forEach((c) => { + // Safety check: ensure atom coordinates exist + if (!c?.atom || typeof c.atom.x !== 'number' || typeof c.atom.y !== 'number' || typeof c.atom.z !== 'number') { + console.warn('useThreeScene: Invalid coordinating atom, skipping bond'); + return; + } + const p0 = center; const p1 = new THREE.Vector3(c.atom.x, c.atom.y, c.atom.z); const bondVec = p1.clone().sub(p0); @@ -276,10 +292,22 @@ export function useThreeScene({ // Initial render renderer.render(scene, camera); - // Animation loop + // Animation loop with manual auto-rotation support let animationFrameId; + const autoRotateSpeed = 0.005; // radians per frame const animate = () => { animationFrameId = requestAnimationFrame(animate); + + // Manual auto-rotation (TrackballControls doesn't have built-in autoRotate) + if (autoRotate) { + const offset = camera.position.clone().sub(controls.target); + const spherical = new THREE.Spherical().setFromVector3(offset); + spherical.theta += autoRotateSpeed; + offset.setFromSpherical(spherical); + camera.position.copy(controls.target).add(offset); + camera.lookAt(controls.target); + } + controls.update(); renderer.render(scene, camera); }; @@ -300,14 +328,10 @@ export function useThreeScene({ renderer.dispose(); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [atoms, selectedMetal, coordAtoms, bestGeometry, autoRotate, showIdeal, showLabels]); + }, [atoms, selectedMetal, coordAtoms, bestGeometry, autoRotate, showIdeal, showLabels, sceneKey]); - // Update auto-rotation when toggle changes - useEffect(() => { - if (controlsRef.current) { - controlsRef.current.autoRotate = autoRotate; - } - }, [autoRotate]); + // Note: Auto-rotation is handled in the animation loop since TrackballControls + // doesn't have built-in autoRotate support like OrbitControls return { sceneRef, diff --git a/src/services/reportGenerator.js b/src/services/reportGenerator.js index e9afcb6..c33f2de 100644 --- a/src/services/reportGenerator.js +++ b/src/services/reportGenerator.js @@ -7,6 +7,8 @@ import { REFERENCE_GEOMETRIES, POINT_GROUPS } from '../constants/referenceGeometries'; import { interpretShapeMeasure } from '../utils/geometry'; +import { calculateAdditionalMetrics, calculateQualityMetrics } from './shapeAnalysis/qualityMetrics'; +import { APP_VERSION, APP_FULL_NAME, getCitationString, CITATION } from '../constants/appMetadata'; /** * Escapes HTML special characters to prevent XSS attacks @@ -424,21 +426,21 @@ footer strong {

-

πŸ”¬ Q-Shape (Quantitative Shape Analyzer)

+

πŸ”¬ ${APP_FULL_NAME}

Coordination Geometry Analysis Report

File: ${escapeHtml(fileName)}.xyz

Generated on: ${date}

Analysis Mode: ${analysisMode === 'intensive' ? 'Intensive (High Precision) with Kabsch Alignment' : 'Standard with Improved Kabsch Alignment'}

- Cite this: Castro Silva Junior, H. (2025). Q-Shape - Quantitative Shape Analyzer (v1.4.0). Zenodo. - https://doi.org/10.5281/zenodo.17717110 + Cite this: ${getCitationString()} + ${CITATION.url}

πŸ”¬ Q-Shape Analysis Overview

-

Q-Shape (Quantitative Shape Analyzer) provides advanced coordination geometry analysis using Continuous Shape Measures (CShM) methodology.

+

${APP_FULL_NAME} provides advanced coordination geometry analysis using Continuous Shape Measures (CShM) methodology.

This report analyzes your structure against ${totalAvailableGeometries} reference geometries across all coordination numbers (CN=2-60).

For CN=${coordAtoms.length}, ${cnGeometries} reference geometries were evaluated using optimized Kabsch alignment and Hungarian algorithm.

@@ -644,11 +646,11 @@ footer strong {