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 @@

-[](https://github.com/HenriqueCSJ/q-shape/releases/tag/v1.4.0)
+[](https://github.com/HenriqueCSJ/q-shape/releases/tag/v1.5.0)
[](https://doi.org/10.5281/zenodo.17717110)
[](https://choosealicense.com/licenses/mit/)
[](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 */}
-
- π― Metal Center
-
+
+
+ π― Metal Center
+
+ {batchMode && selectedMetal !== null && onApplyMetalToAll && (
+ onApplyMetalToAll(selectedMetal)}
+ style={{
+ padding: '0.25rem 0.5rem',
+ fontSize: '0.75rem',
+ background: '#8b5cf6',
+ color: 'white',
+ border: 'none',
+ borderRadius: '4px',
+ cursor: 'pointer',
+ fontWeight: 600
+ }}
+ title="Apply this metal selection to all structures in batch"
+ >
+ Apply to All
+
+ )}
+
onMetalChange(Number(e.target.value))}
@@ -52,15 +76,35 @@ export default function AnalysisControls({
π Coordination Radius: {coordRadius.toFixed(2)} Γ
-
- onAutoRadiusChange(e.target.checked)}
- style={{ cursor: 'pointer' }}
- />
- Auto
-
+
+ {batchMode && onApplyRadiusToAll && (
+ onApplyRadiusToAll(coordRadius)}
+ style={{
+ padding: '0.25rem 0.5rem',
+ fontSize: '0.75rem',
+ background: '#8b5cf6',
+ color: 'white',
+ border: 'none',
+ borderRadius: '4px',
+ cursor: 'pointer',
+ fontWeight: 600
+ }}
+ title="Apply this radius to all structures in batch"
+ >
+ Apply to All
+
+ )}
+
+ onAutoRadiusChange(e.target.checked)}
+ style={{ cursor: 'pointer' }}
+ />
+ Auto
+
+
{/* 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 */}
+
+
+ Select Structure to View:
+
+ onSelectStructure(parseInt(e.target.value, 10))}
+ style={{
+ width: '100%',
+ padding: '0.75rem',
+ borderRadius: '8px',
+ border: '2px solid #e2e8f0',
+ fontSize: '0.95rem',
+ background: 'white',
+ cursor: 'pointer'
+ }}
+ >
+ {structures.map((structure, index) => {
+ const result = batchResults?.get(index);
+ const hasResult = !!result;
+ return (
+
+ {structure.id}
+ {hasResult && result.bestGeometry
+ ? ` β ${result.bestGeometry.name} (CShM: ${result.bestGeometry.shapeMeasure.toFixed(3)})`
+ : ' β Not analyzed'
+ }
+
+ );
+ })}
+
+
+
+ );
+}
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
+
+
+
+
+
+ #
+ Structure ID
+ Metal
+ CN
+ Best Geometry
+ CShM
+ Quality
+
+
+
+ {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';
+ }
+ }}
+ >
+
+ {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({
bestGeometry && !isLoading && (e.currentTarget.style.transform = 'translateY(-2px)')}
+ onMouseOver={(e) => canGenerateReport && (e.currentTarget.style.transform = 'translateY(-2px)')}
onMouseOut={(e) => e.currentTarget.style.transform = 'translateY(0)'}
>
π Generate Report
@@ -175,27 +212,56 @@ export default function CoordinationSummary({
0 && !isLoading
+ background: canGenerateCSV
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
: '#cbd5e1',
color: 'white',
border: 'none',
borderRadius: '10px',
fontWeight: 700,
- cursor: geometryResults && geometryResults.length > 0 && !isLoading ? 'pointer' : 'not-allowed',
- boxShadow: geometryResults && geometryResults.length > 0 && !isLoading ? '0 4px 6px rgba(16, 185, 129, 0.4)' : 'none',
+ cursor: canGenerateCSV ? 'pointer' : 'not-allowed',
+ boxShadow: canGenerateCSV ? '0 4px 6px rgba(16, 185, 129, 0.4)' : 'none',
transition: 'all 0.2s',
fontSize: '1rem',
- minWidth: '200px'
+ minWidth: '180px'
}}
- onMouseOver={(e) => geometryResults && geometryResults.length > 0 && !isLoading && (e.currentTarget.style.transform = 'translateY(-2px)')}
+ onMouseOver={(e) => canGenerateCSV && (e.currentTarget.style.transform = 'translateY(-2px)')}
onMouseOut={(e) => e.currentTarget.style.transform = 'translateY(0)'}
>
π Download CSV
+
+ {/* Analyze All Structures button - only in batch mode */}
+ {batchMode && (
+ e.currentTarget.style.transform = 'translateY(-2px)'}
+ onMouseOut={(e) => e.currentTarget.style.transform = 'translateY(0)'}
+ >
+ {isBatchRunning ? 'βΉοΈ Cancel' : 'π Analyze All Structures'}
+
+ )}
{/* 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 (
- π Load Molecular Structure (.xyz)
+ π Load Molecular Structure (.xyz, .cif)
+
+ 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 */}
+
+
+
+ Metal Center:
+
+ {batchMode && (
+ handleApplyToAll('metal')}
+ style={{
+ fontSize: '0.75rem',
+ padding: '0.25rem 0.5rem',
+ background: '#f1f5f9',
+ border: '1px solid #e2e8f0',
+ borderRadius: '4px',
+ cursor: 'pointer',
+ color: '#64748b'
+ }}
+ >
+ Apply to all
+
+ )}
+
+
{
+ const value = e.target.value;
+ onMetalChange?.(value === '' ? null : parseInt(value, 10));
+ }}
+ style={{
+ width: '100%',
+ padding: '0.75rem',
+ borderRadius: '8px',
+ border: '2px solid #e2e8f0',
+ fontSize: '0.95rem',
+ background: 'white'
+ }}
+ >
+ Select metal center...
+
+ {metalAtoms.map(({ atom, index }) => (
+
+ {atom.element} (#{index + 1}) β {ATOMIC_DATA[atom.element]?.name || atom.element}
+
+ ))}
+
+ {showAdvanced && (
+
+ {allAtoms
+ .filter(({ atom }) => !ALL_METALS.has(atom.element))
+ .map(({ atom, index }) => (
+
+ {atom.element} (#{index + 1})
+
+ ))}
+
+ )}
+
+
+
+
+ setShowAdvanced(e.target.checked)}
+ />
+ Show non-metal atoms (advanced)
+
+
+
+ {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 */}
+
+
+
+ Coordination Radius (Γ
):
+
+ {batchMode && (
+ handleApplyToAll('radius')}
+ style={{
+ fontSize: '0.75rem',
+ padding: '0.25rem 0.5rem',
+ background: '#f1f5f9',
+ border: '1px solid #e2e8f0',
+ borderRadius: '4px',
+ cursor: 'pointer',
+ color: '#64748b'
+ }}
+ >
+ Apply to all
+
+ )}
+
+
+ {
+ 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'
+ }}
+ />
+ onRadiusChange?.(Math.max(1, (currentRadius || 3) - 0.1))}
+ style={{
+ padding: '0.75rem 1rem',
+ borderRadius: '8px',
+ border: '2px solid #e2e8f0',
+ background: 'white',
+ cursor: 'pointer',
+ fontSize: '1rem',
+ fontWeight: 600
+ }}
+ >
+ β
+
+ onRadiusChange?.(Math.min(10, (currentRadius || 3) + 0.1))}
+ style={{
+ padding: '0.75rem 1rem',
+ borderRadius: '8px',
+ border: '2px solid #e2e8f0',
+ background: 'white',
+ cursor: 'pointer',
+ fontSize: '1rem',
+ fontWeight: 600
+ }}
+ >
+ +
+
+
+
+ {currentCN !== undefined && (
+
+ Current coordination number: {currentCN}
+
+ )}
+
+
+ {/* Find Radius for CN */}
+
+
+ Find Radius for Target 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'
+ }}
+ />
+ 12}
+ style={{
+ padding: '0.75rem 1.25rem',
+ borderRadius: '8px',
+ border: 'none',
+ background: 'linear-gradient(135deg, #4f46e5 0%, #4338ca 100%)',
+ color: 'white',
+ fontWeight: 600,
+ cursor: 'pointer',
+ opacity: (!targetCN || parseInt(targetCN, 10) < 2 || parseInt(targetCN, 10) > 12) ? 0.5 : 1
+ }}
+ >
+ Find
+
+
+
+ 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 {
- Report generated by Q-Shape (Quantitative Shape Analyzer) v1.4.0
+ Report generated by ${APP_FULL_NAME} v${APP_VERSION}
Comprehensive analysis with ${totalAvailableGeometries} reference geometries β’ Optimized Kabsch alignment with Jacobi SVD β’ Enhanced Hungarian algorithm
- Castro Silva Junior, H. (2025). Q-Shape - Quantitative Shape Analyzer (v1.4.0). Zenodo.
- https://doi.org/10.5281/zenodo.17717110
+ ${getCitationString()}
+ ${CITATION.url}
Based on Continuous Shape Measures methodology: Pinsky & Avnir (1998), Alvarez et al. (2002)
@@ -727,3 +729,563 @@ export function generateCSVReport({ geometryResults, fileName }) {
link.click();
document.body.removeChild(link);
}
+
+/**
+ * Generate Batch PDF Report - v1.5.0
+ *
+ * Creates a comprehensive PDF report for multiple structures with:
+ * - Batch summary table
+ * - Per-structure detail sections with full geometry lists
+ *
+ * @param {Object} params - Report parameters
+ * @param {Array} params.structures - Array of Structure objects
+ * @param {Map} params.batchResults - Map of structureIndex -> results
+ * @param {string} params.fileName - Base filename
+ * @param {string} params.fileFormat - File format (xyz/cif)
+ */
+export function generateBatchPDFReport({ structures, batchResults, fileName, fileFormat }) {
+ if (!structures || structures.length === 0) {
+ throw new Error('No structures available for batch report');
+ }
+
+ if (!batchResults || batchResults.size === 0) {
+ throw new Error('No batch results available for report');
+ }
+
+ const date = new Date().toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
+ const analyzedCount = batchResults.size;
+
+ // Build summary table rows
+ const summaryRows = [];
+ structures.forEach((structure, index) => {
+ const result = batchResults.get(index);
+ if (result && result.bestGeometry) {
+ const interpretation = interpretShapeMeasure(result.bestGeometry.shapeMeasure);
+ summaryRows.push(`
+
+ ${index + 1}
+ ${escapeHtml(structure.id)}
+ ${structure.atoms[result.metalIndex]?.element || 'N/A'}
+ ${result.coordinationNumber || 'N/A'}
+ ${result.bestGeometry.name}
+ ${result.bestGeometry.shapeMeasure.toFixed(4)}
+ ${interpretation.confidence}%
+
+ `);
+ }
+ });
+
+ // Build per-structure detail sections with full metrics
+ const detailSections = [];
+ structures.forEach((structure, index) => {
+ const result = batchResults.get(index);
+ if (result && result.geometryResults) {
+ // Get coordAtoms from the result (stored during batch analysis)
+ const coordAtoms = result.coordAtoms || [];
+
+ // Calculate metrics
+ const additionalMetrics = calculateAdditionalMetrics(coordAtoms);
+ const qualityMetrics = result.bestGeometry
+ ? calculateQualityMetrics(coordAtoms, result.bestGeometry, result.bestGeometry.shapeMeasure)
+ : null;
+
+ const bestInterp = result.bestGeometry
+ ? interpretShapeMeasure(result.bestGeometry.shapeMeasure)
+ : null;
+
+ const geomRows = result.geometryResults.map((r, i) => {
+ const interp = interpretShapeMeasure(r.shapeMeasure);
+ return `
+
+ ${i + 1}
+ ${r.name}
+ ${POINT_GROUPS[r.name] || 'β'}
+ ${r.shapeMeasure.toFixed(4)}
+ ${interp.text}
+ ${interp.confidence}%
+
+ `;
+ }).join('');
+
+ // Get ligand elements
+ const ligandElements = coordAtoms.length > 0
+ ? coordAtoms.map(c => c.atom?.element || '?').join(', ')
+ : 'N/A';
+
+ // Calculate total available geometries
+ const totalAvailableGeometries = Object.values(REFERENCE_GEOMETRIES).reduce(
+ (sum, geoms) => sum + Object.keys(geoms).length, 0
+ );
+ const cnGeometries = coordAtoms.length > 0
+ ? Object.keys(REFERENCE_GEOMETRIES[coordAtoms.length] || {}).length
+ : 0;
+
+ detailSections.push(`
+
+
+ π Structure: ${escapeHtml(structure.id)}
+
+
+
+
+
Q-Shape analyzed this structure against ${totalAvailableGeometries} reference geometries .
+
For CN=${coordAtoms.length}, ${cnGeometries} reference geometries were evaluated using Kabsch alignment and Hungarian algorithm.
+
+
+
+
+
π Analysis Summary
+
+
+ Metal Center
+ ${structure.atoms[result.metalIndex]?.element || 'N/A'} (#${(result.metalIndex || 0) + 1})
+
+
+ Coordination Number
+ ${result.coordinationNumber || coordAtoms.length || 'N/A'}
+
+
+ Coordination Radius
+ ${result.radius?.toFixed(3) || 'N/A'} Γ
+
+
+ Best Match Geometry
+ ${result.bestGeometry?.name || 'N/A'}
+
+
+ Point Group
+ ${POINT_GROUPS[result.bestGeometry?.name] || 'β'}
+
+
+ CShM Value
+ ${result.bestGeometry?.shapeMeasure?.toFixed(4) || 'N/A'}
+
+
+ Interpretation
+ ${bestInterp?.text || 'N/A'}
+
+
+ Ligands
+ ${ligandElements}
+
+
+
+
+ ${qualityMetrics ? `
+
+
+
π― Quality Metrics
+
+
+ Overall Quality Score
+ ${qualityMetrics.overallQualityScore.toFixed(1)}/100
+
+
+ Angular Distortion Index
+ ${qualityMetrics.angularDistortionIndex.toFixed(3)}Β°
+
+
+ Bond Length Uniformity
+ ${qualityMetrics.bondLengthUniformityIndex.toFixed(1)}%
+
+
+ Shape Deviation Parameter
+ ${qualityMetrics.shapeDeviationParameter.toFixed(4)}
+
+
+ RMSD
+ ${qualityMetrics.rmsd.toFixed(4)} Γ
+
+
+
+ ` : ''}
+
+ ${additionalMetrics && additionalMetrics.meanBondLength > 0 ? `
+
+
+
π Bond Statistics
+
+
+ Mean Bond Length
+ ${additionalMetrics.meanBondLength.toFixed(4)} Γ
+
+
+ Std Dev Bond Length
+ ${additionalMetrics.stdDevBondLength.toFixed(4)} Γ
+
+
+ Bond Length Range
+ ${additionalMetrics.minBondLength.toFixed(3)} - ${additionalMetrics.maxBondLength.toFixed(3)} Γ
+
+ ${additionalMetrics.angleStats && additionalMetrics.angleStats.count > 0 ? `
+
+ Mean L-M-L Angle
+ ${additionalMetrics.angleStats.mean.toFixed(2)}Β° Β± ${additionalMetrics.angleStats.stdDev.toFixed(2)}Β°
+
+
+ Angle Range
+ ${additionalMetrics.angleStats.min.toFixed(1)}Β° - ${additionalMetrics.angleStats.max.toFixed(1)}Β°
+
+
+ Number of L-M-L Angles
+ ${additionalMetrics.angleStats.count}
+
+ ` : ''}
+
+
+ ` : ''}
+
+ ${result.ligandGroups && (result.ligandGroups.ringCount > 0 || result.ligandGroups.hasSandwichStructure) ? `
+
+
+
π¬ Ligand Groups Analysis
+
${result.ligandGroups.summary}
+ ${result.ligandGroups.rings && result.ligandGroups.rings.length > 0 ? `
+
+
Detected Rings:
+
+ ${result.ligandGroups.rings.map((ring, i) => `
+
+ Ring ${i + 1}: ${ring.hapticity || 'Unknown'} (${ring.size} atoms${ring.distanceToMetal ? ', ' + ring.distanceToMetal.toFixed(3) + ' Γ
from metal' : ''})
+
+ `).join('')}
+
+
+ ` : ''}
+ ${result.ligandGroups.hasSandwichStructure ? `
+
+
π₯ͺ Sandwich Structure Detected
+
+ ` : ''}
+
+ ` : ''}
+
+ ${coordAtoms.length > 0 ? `
+
+
π Coordinating Atoms
+
+
+
+ #
+ Element
+ Distance (Γ
)
+ Coordinates (x, y, z)
+
+
+
+ ${coordAtoms.map((c, i) => `
+
+ ${i + 1}
+ ${c.atom?.element || '?'}
+ ${c.distance?.toFixed(4) || 'N/A'}
+ ${c.atom?.x?.toFixed(4) || '?'}, ${c.atom?.y?.toFixed(4) || '?'}, ${c.atom?.z?.toFixed(4) || '?'}
+
+ `).join('')}
+
+
+ ` : ''}
+
+
+
π All Geometry Comparisons
+
+
+
+ #
+ Geometry
+ Point Group
+ CShM
+ Interpretation
+ Confidence
+
+
+
+ ${geomRows}
+
+
+
+ `);
+ }
+ });
+
+ const html = `
+
+
+
+
+Q-Shape Batch Report: ${escapeHtml(fileName)}
+
+
+
+
+ π Download as PDF
+
+
+
+ π¬ Q-Shape Batch Analysis Report
+ Coordination Geometry Analysis - Multi-Structure Report
+ File: ${escapeHtml(fileName)}.${fileFormat || 'xyz'}
+ Generated: ${date}
+ Structures Analyzed: ${analyzedCount} of ${structures.length}
+ Analysis Mode: Intensive (High Precision) with Kabsch Alignment
+
+ Cite this: ${getCitationString()}
+ ${CITATION.url}
+
+
+
+
+ π Batch Summary
+
+
+
+ #
+ Structure ID
+ Metal
+ CN
+ Best Geometry
+ CShM
+ Confidence
+
+
+
+ ${summaryRows.join('')}
+
+
+
+ π Detailed Results by Structure
+ ${detailSections.join('')}
+
+
+
+ Report generated by ${APP_FULL_NAME} v${APP_VERSION}
+ Comprehensive analysis with optimized Kabsch alignment with Jacobi SVD β’ Enhanced Hungarian algorithm
+
+ ${getCitationString()}
+ ${CITATION.url}
+
+
+ Based on Continuous Shape Measures methodology: Pinsky & Avnir (1998), Alvarez et al. (2002)
+
+
+
+
+
+
Universidade Federal Rural do Rio de Janeiro (UFRRJ)
+
Departamento de QuΓmica Fundamental
+
Prof. Dr. Henrique C. S. Junior
+
+
+
+
+`;
+
+ const reportWindow = window.open("", "_blank");
+ if (reportWindow) {
+ reportWindow.document.write(html);
+ reportWindow.document.close();
+ } else {
+ throw new Error("Popup blocked. Please allow popups for this site.");
+ }
+}
+
+/**
+ * Generate Wide Summary CSV - v1.5.0
+ *
+ * Creates a CSV with one row per structure (best match + key metrics)
+ *
+ * @param {Object} params
+ * @param {Array} params.structures
+ * @param {Map} params.batchResults
+ * @param {string} params.fileName
+ */
+export function generateWideSummaryCSV({ structures, batchResults, fileName }) {
+ if (!structures || !batchResults || batchResults.size === 0) {
+ throw new Error('No batch results available for CSV export');
+ }
+
+ const headers = [
+ 'Structure_ID',
+ 'Metal_Element',
+ 'Coordination_Number',
+ 'Radius_Γ
',
+ 'Best_Geometry',
+ 'Point_Group',
+ 'CShM',
+ 'Interpretation',
+ 'Confidence_%',
+ 'Analysis_Mode'
+ ];
+
+ const rows = [];
+ structures.forEach((structure, index) => {
+ const result = batchResults.get(index);
+ if (result && result.bestGeometry) {
+ const interpretation = interpretShapeMeasure(result.bestGeometry.shapeMeasure);
+ rows.push([
+ `"${structure.id}"`,
+ structure.atoms[result.metalIndex]?.element || '',
+ result.coordinationNumber || '',
+ result.radius?.toFixed(3) || '',
+ `"${result.bestGeometry.name}"`,
+ POINT_GROUPS[result.bestGeometry.name] || '',
+ result.bestGeometry.shapeMeasure.toFixed(4),
+ `"${interpretation.text}"`,
+ interpretation.confidence,
+ result.analysisMode || 'default'
+ ]);
+ }
+ });
+
+ const csvContent = [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
+ downloadCSV(csvContent, `${fileName}_batch_summary.csv`);
+}
+
+/**
+ * Generate Long Detailed CSV - v1.5.0
+ *
+ * Creates a CSV with one row per (structure, geometry) pair - all results for all geometries
+ *
+ * @param {Object} params
+ * @param {Array} params.structures
+ * @param {Map} params.batchResults
+ * @param {string} params.fileName
+ */
+export function generateLongDetailedCSV({ structures, batchResults, fileName }) {
+ if (!structures || !batchResults || batchResults.size === 0) {
+ throw new Error('No batch results available for CSV export');
+ }
+
+ const headers = [
+ 'Structure_ID',
+ 'Metal_Element',
+ 'Coordination_Number',
+ 'Geometry_Rank',
+ 'Geometry_Name',
+ 'Point_Group',
+ 'CShM',
+ 'Interpretation',
+ 'Confidence_%',
+ 'Is_Best_Match'
+ ];
+
+ const rows = [];
+ structures.forEach((structure, index) => {
+ const result = batchResults.get(index);
+ if (result && result.geometryResults) {
+ result.geometryResults.forEach((geom, geomIndex) => {
+ const interpretation = interpretShapeMeasure(geom.shapeMeasure);
+ rows.push([
+ `"${structure.id}"`,
+ structure.atoms[result.metalIndex]?.element || '',
+ result.coordinationNumber || '',
+ geomIndex + 1,
+ `"${geom.name}"`,
+ POINT_GROUPS[geom.name] || '',
+ geom.shapeMeasure.toFixed(4),
+ `"${interpretation.text}"`,
+ interpretation.confidence,
+ geomIndex === 0 ? 'Yes' : 'No'
+ ]);
+ });
+ }
+ });
+
+ const csvContent = [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
+ downloadCSV(csvContent, `${fileName}_all_geometries.csv`);
+}
+
+/**
+ * Helper: Download CSV content
+ */
+function downloadCSV(content, filename) {
+ const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
+ const link = document.createElement('a');
+ link.href = URL.createObjectURL(blob);
+ link.download = filename.replace(/[<>:"/\\|?*]/g, '_');
+ link.style.visibility = 'hidden';
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+}
+
+/**
+ * Helper: Get batch report CSS styles
+ */
+function getBatchReportStyles() {
+ return `
+@media print {
+ body { margin: 0; padding: 20px; background: white !important; }
+ .no-print { display: none; }
+ @page { size: A4; margin: 15mm; }
+}
+* { box-sizing: border-box; }
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ line-height: 1.6;
+ color: #1e293b;
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 2rem;
+ background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
+}
+header {
+ background: white;
+ padding: 2rem;
+ border-radius: 12px;
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
+ margin-bottom: 2rem;
+}
+h1 { margin: 0; color: #312e81; font-size: 2rem; }
+h2 { color: #312e81; border-bottom: 2px solid #e2e8f0; padding-bottom: 0.5rem; }
+h3 { color: #1e293b; }
+.summary-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 1rem;
+ background: #f8fafc;
+ padding: 1rem;
+ border-radius: 8px;
+}
+.summary-item {
+ padding: 0.75rem;
+ background: white;
+ border-radius: 6px;
+ border-left: 3px solid #4f46e5;
+}
+.summary-item strong {
+ display: block;
+ font-size: 0.75em;
+ color: #64748b;
+ text-transform: uppercase;
+}
+table {
+ width: 100%;
+ border-collapse: collapse;
+ background: white;
+ border-radius: 8px;
+ overflow: hidden;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.05);
+}
+th, td { padding: 0.75rem; text-align: left; border-bottom: 1px solid #e2e8f0; }
+th { background: linear-gradient(135deg, #4f46e5 0%, #4338ca 100%); color: white; font-size: 0.85em; }
+.best-result { background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%) !important; font-weight: 600; }
+.download-btn {
+ background: linear-gradient(135deg, #4f46e5 0%, #4338ca 100%);
+ color: white;
+ border: none;
+ padding: 1rem 2rem;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+}
+footer {
+ margin-top: 3rem;
+ padding-top: 2rem;
+ border-top: 2px solid #e2e8f0;
+ text-align: center;
+ color: #64748b;
+}
+ `;
+}
diff --git a/src/services/shapeAnalysis/workerPool.js b/src/services/shapeAnalysis/workerPool.js
index 1d61357..b2f36e8 100644
--- a/src/services/shapeAnalysis/workerPool.js
+++ b/src/services/shapeAnalysis/workerPool.js
@@ -3,7 +3,7 @@
*
* Manages a pool of Web Workers for parallel CShM calculations.
*
- * TODO: Currently unused in v1.4.0 (reverted to sequential execution).
+ * TODO: Currently unused in v1.5.0 (reverted to sequential execution).
* Keep for future re-integration of parallel processing.
* Distributes work across workers, aggregates progress, handles errors.
*
diff --git a/src/types/structureTypes.js b/src/types/structureTypes.js
new file mode 100644
index 0000000..5463fbf
--- /dev/null
+++ b/src/types/structureTypes.js
@@ -0,0 +1,224 @@
+/**
+ * Unified Structure Data Model for Q-Shape v1.5.0
+ *
+ * This file defines the canonical in-memory model for molecular structures.
+ * All parsers must return data conforming to these structures.
+ *
+ * Design Principles:
+ * - Single source of truth: structures[] is the only representation
+ * - Single mode: structures.length === 1
+ * - Batch mode: structures.length > 1
+ * - No competing representations (no separate 'frames', 'models', 'selectedStructure')
+ */
+
+/**
+ * @typedef {Object} Atom
+ * @property {string} element - Element symbol (normalized: first letter uppercase, rest lowercase)
+ * @property {number} x - X coordinate in Γ
ngstrΓΆms (Cartesian)
+ * @property {number} y - Y coordinate in Γ
ngstrΓΆms (Cartesian)
+ * @property {number} z - Z coordinate in Γ
ngstrΓΆms (Cartesian)
+ */
+
+/**
+ * @typedef {Object} UnitCell
+ * @property {number} a - Cell parameter a (Γ
)
+ * @property {number} b - Cell parameter b (Γ
)
+ * @property {number} c - Cell parameter c (Γ
)
+ * @property {number} alpha - Cell angle alpha (degrees)
+ * @property {number} beta - Cell angle beta (degrees)
+ * @property {number} gamma - Cell angle gamma (degrees)
+ */
+
+/**
+ * @typedef {Object} StructureMetadata
+ * @property {UnitCell} [unitCell] - Unit cell parameters (CIF only)
+ * @property {string} [spaceGroup] - Space group symbol (CIF only)
+ * @property {string} [comment] - Comment line (XYZ) or CIF metadata
+ * @property {string} [parseProvenance] - How this structure was parsed
+ * @property {Array} [warnings] - Structure-specific warnings
+ */
+
+/**
+ * @typedef {Object} Structure
+ * @property {string} id - Unique identifier for UI display (e.g., "LMMPa", "CIF:block1", "file:frame-001")
+ * @property {string} source - Source file name or CIF block name
+ * @property {Array} atoms - Array of atoms with Cartesian coordinates
+ * @property {StructureMetadata} [metadata] - Optional metadata
+ */
+
+/**
+ * @typedef {Object} ParseResult
+ * @property {Array} structures - Array of parsed structures
+ * @property {Array} warnings - Global warnings from parsing
+ * @property {'xyz'|'cif'|'unknown'} format - Detected file format
+ * @property {number} frameCount - Number of structures/frames parsed
+ * @property {boolean} valid - Whether parsing was successful
+ * @property {string} [error] - Error message if valid === false
+ */
+
+/**
+ * @typedef {Object} StructureOverride
+ * @property {number} [metalIndex] - Override metal center index
+ * @property {number} [radius] - Override coordination radius
+ * @property {number} [targetCN] - Target coordination number
+ */
+
+/**
+ * @typedef {Object} BatchAnalysisResult
+ * @property {string} structureId - ID of the structure this result belongs to
+ * @property {Array} geometryResults - All geometry analysis results (sorted by CShM)
+ * @property {Object} bestGeometry - Best matching geometry
+ * @property {Object} additionalMetrics - Bond statistics
+ * @property {Object} qualityMetrics - Quality scores
+ * @property {Object} [intensiveMetadata] - Intensive analysis metadata if applicable
+ * @property {number} metalIndex - Metal center used for analysis
+ * @property {number} radius - Coordination radius used
+ * @property {number} coordinationNumber - Number of coordinating atoms
+ * @property {string} analysisMode - 'default' or 'intensive'
+ */
+
+/**
+ * Creates an empty parse result with error
+ * @param {string} error - Error message
+ * @returns {ParseResult}
+ */
+export function createErrorResult(error) {
+ return {
+ structures: [],
+ warnings: [],
+ format: 'unknown',
+ frameCount: 0,
+ valid: false,
+ error
+ };
+}
+
+/**
+ * Creates a successful parse result
+ * @param {Array} structures - Parsed structures
+ * @param {'xyz'|'cif'} format - File format
+ * @param {Array} [warnings=[]] - Parse warnings
+ * @returns {ParseResult}
+ */
+export function createSuccessResult(structures, format, warnings = []) {
+ return {
+ structures,
+ warnings,
+ format,
+ frameCount: structures.length,
+ valid: true
+ };
+}
+
+/**
+ * Creates a Structure object
+ * @param {string} id - Unique identifier
+ * @param {string} source - Source file/block name
+ * @param {Array} atoms - Atom array
+ * @param {StructureMetadata} [metadata={}] - Optional metadata
+ * @returns {Structure}
+ */
+export function createStructure(id, source, atoms, metadata = {}) {
+ return {
+ id,
+ source,
+ atoms,
+ metadata
+ };
+}
+
+/**
+ * Generates a stable structure ID
+ * @param {string} filename - Source filename
+ * @param {number} frameIndex - Frame index (0-based)
+ * @param {string} [comment] - Optional comment line to extract ID from
+ * @returns {string}
+ */
+export function generateStructureId(filename, frameIndex, comment = null) {
+ // Try to extract identifier from comment line
+ if (comment) {
+ const trimmed = comment.trim();
+ // Use comment as ID if it's short and looks like an identifier
+ if (trimmed.length > 0 && trimmed.length <= 50 && !trimmed.includes('\n')) {
+ // Remove common prefixes and clean up
+ const cleaned = trimmed
+ .replace(/^(frame|structure|model|#)\s*/i, '')
+ .trim();
+ if (cleaned.length > 0) {
+ return cleaned;
+ }
+ }
+ }
+
+ // Fallback: filename + frame index
+ const baseName = filename.replace(/\.(xyz|cif)$/i, '');
+ return frameIndex === 0 ? baseName : `${baseName}:frame-${String(frameIndex + 1).padStart(3, '0')}`;
+}
+
+/**
+ * Determines if batch mode should be active
+ * @param {Array} structures - Array of structures
+ * @returns {boolean} - True if batch mode should be enabled
+ */
+export function isBatchMode(structures) {
+ return Array.isArray(structures) && structures.length > 1;
+}
+
+/**
+ * Validates a Structure object
+ * @param {Structure} structure - Structure to validate
+ * @returns {{valid: boolean, errors: string[]}}
+ */
+export function validateStructure(structure) {
+ const errors = [];
+
+ if (!structure) {
+ return { valid: false, errors: ['Structure is null or undefined'] };
+ }
+
+ if (!structure.id || typeof structure.id !== 'string') {
+ errors.push('Structure must have a string id');
+ }
+
+ if (!structure.source || typeof structure.source !== 'string') {
+ errors.push('Structure must have a string source');
+ }
+
+ if (!Array.isArray(structure.atoms)) {
+ errors.push('Structure must have an atoms array');
+ } else if (structure.atoms.length === 0) {
+ errors.push('Structure must have at least one atom');
+ } else {
+ // Validate first few atoms
+ for (let i = 0; i < Math.min(3, structure.atoms.length); i++) {
+ const atom = structure.atoms[i];
+ if (!atom.element || typeof atom.element !== 'string') {
+ errors.push(`Atom ${i} must have a string element`);
+ }
+ if (!Number.isFinite(atom.x) || !Number.isFinite(atom.y) || !Number.isFinite(atom.z)) {
+ errors.push(`Atom ${i} must have finite x, y, z coordinates`);
+ }
+ }
+ }
+
+ return {
+ valid: errors.length === 0,
+ errors
+ };
+}
+
+// Export constants for configuration
+export const PARSE_CONFIG = {
+ // XYZ parsing
+ MAX_COORD_MAGNITUDE: 1000, // Warn if coordinates exceed this (possible unit mismatch)
+ LARGE_STRUCTURE_WARNING: 500, // Warn if atom count exceeds this
+
+ // Multi-frame parsing
+ STRICT_MODE: false, // If true, fail on first malformed frame; if false, stop at last valid frame
+
+ // CIF parsing
+ EXPAND_SYMMETRY: false, // If true, expand asymmetric unit to full cell (default: conservative)
+
+ // ID generation
+ MAX_ID_LENGTH: 50 // Maximum length for generated structure IDs
+};
diff --git a/src/utils/parseInput.js b/src/utils/parseInput.js
new file mode 100644
index 0000000..22eedb9
--- /dev/null
+++ b/src/utils/parseInput.js
@@ -0,0 +1,737 @@
+/**
+ * Unified Input Parser for Q-Shape v1.5.0
+ *
+ * Parses XYZ (single/multi-frame) and CIF files into a unified Structure format.
+ * This is the SINGLE entry point for all file parsing.
+ *
+ * Contract:
+ * - Returns a ParseResult with structures[], warnings[], format, frameCount, valid
+ * - Never throws exceptions - errors are captured in the result
+ * - Correctly routes to XYZ or CIF parser based on file content/extension
+ */
+
+import { ATOMIC_DATA } from '../constants/atomicData.js';
+import {
+ createErrorResult,
+ createSuccessResult,
+ createStructure,
+ generateStructureId,
+ PARSE_CONFIG
+} from '../types/structureTypes.js';
+
+/**
+ * Main entry point for file parsing
+ *
+ * @param {string} content - Raw file content
+ * @param {string} filename - Original filename (used for format detection and IDs)
+ * @returns {ParseResult} - Unified parse result
+ */
+export function parseInput(content, filename) {
+ if (!content || typeof content !== 'string') {
+ return createErrorResult('No file content provided');
+ }
+
+ if (!filename || typeof filename !== 'string') {
+ return createErrorResult('No filename provided');
+ }
+
+ // Detect format based on extension and content
+ const format = detectFormat(content, filename);
+
+ if (format === 'cif') {
+ return parseCIF(content, filename);
+ } else if (format === 'xyz') {
+ return parseXYZMultiFrame(content, filename);
+ } else {
+ return createErrorResult(`Unknown file format. Expected .xyz or .cif file.`);
+ }
+}
+
+/**
+ * Detect file format from content and filename
+ *
+ * @param {string} content - File content
+ * @param {string} filename - Filename
+ * @returns {'xyz'|'cif'|'unknown'}
+ */
+export function detectFormat(content, filename) {
+ const ext = filename.toLowerCase().split('.').pop();
+
+ // Extension-based detection first
+ if (ext === 'cif') {
+ return 'cif';
+ }
+ if (ext === 'xyz') {
+ return 'xyz';
+ }
+
+ // Content-based detection as fallback
+ const trimmed = content.trim();
+
+ // CIF files start with data_ or have characteristic CIF patterns
+ if (trimmed.startsWith('data_') ||
+ trimmed.includes('_cell_length_a') ||
+ trimmed.includes('_atom_site_')) {
+ return 'cif';
+ }
+
+ // XYZ files start with a number (atom count)
+ const firstLine = trimmed.split('\n')[0].trim();
+ if (/^\d+$/.test(firstLine)) {
+ return 'xyz';
+ }
+
+ return 'unknown';
+}
+
+/**
+ * Parse multi-frame XYZ content
+ *
+ * XYZ Format:
+ * - Line 1: Atom count (positive integer)
+ * - Line 2: Comment (can be anything, used for structure ID)
+ * - Lines 3+: Element X Y Z [optional extra columns]
+ * - Repeat for multiple frames
+ *
+ * @param {string} content - Raw XYZ content
+ * @param {string} filename - Source filename
+ * @returns {ParseResult}
+ */
+export function parseXYZMultiFrame(content, filename) {
+ const structures = [];
+ const warnings = [];
+
+ try {
+ // Normalize line endings and split
+ const lines = content.replace(/\r\n?/g, '\n').split('\n');
+ let lineIndex = 0;
+ let frameIndex = 0;
+
+ while (lineIndex < lines.length) {
+ // Skip empty lines between frames
+ while (lineIndex < lines.length && lines[lineIndex].trim() === '') {
+ lineIndex++;
+ }
+
+ if (lineIndex >= lines.length) {
+ break; // End of file
+ }
+
+ // Parse atom count
+ const countLine = lines[lineIndex].trim();
+ const atomCount = parseInt(countLine, 10);
+
+ if (!Number.isFinite(atomCount) || atomCount <= 0) {
+ if (structures.length === 0) {
+ // First frame must be valid
+ return createErrorResult(
+ `Invalid atom count in XYZ header at line ${lineIndex + 1}: "${countLine}". ` +
+ `Expected a positive integer.`
+ );
+ } else {
+ // Subsequent frames: stop at last valid frame (tolerant mode)
+ if (!PARSE_CONFIG.STRICT_MODE) {
+ warnings.push(
+ `Stopped parsing at line ${lineIndex + 1}: invalid frame header. ` +
+ `${structures.length} frame(s) successfully parsed.`
+ );
+ break;
+ } else {
+ return createErrorResult(
+ `Invalid atom count at line ${lineIndex + 1} (frame ${frameIndex + 1})`
+ );
+ }
+ }
+ }
+
+ lineIndex++;
+
+ // Parse comment line
+ if (lineIndex >= lines.length) {
+ if (structures.length === 0) {
+ return createErrorResult('XYZ file missing comment line');
+ }
+ break;
+ }
+ const comment = lines[lineIndex].trim();
+ lineIndex++;
+
+ // Check if we have enough lines for atoms
+ if (lineIndex + atomCount > lines.length) {
+ if (structures.length === 0) {
+ return createErrorResult(
+ `XYZ file claims ${atomCount} atoms but only has ${lines.length - lineIndex} data lines remaining`
+ );
+ } else {
+ warnings.push(
+ `Frame ${frameIndex + 1} incomplete: expected ${atomCount} atoms, ` +
+ `but only ${lines.length - lineIndex} lines remaining. Frame skipped.`
+ );
+ break;
+ }
+ }
+
+ // Parse atoms
+ const atoms = [];
+ const frameWarnings = [];
+ let hasLargeCoords = false;
+
+ for (let i = 0; i < atomCount; i++) {
+ const atomLine = lines[lineIndex + i].trim();
+ const parts = atomLine.split(/\s+/);
+
+ if (parts.length < 4) {
+ frameWarnings.push(
+ `Line ${lineIndex + i + 1}: Invalid format "${atomLine}" - expected "Element X Y Z"`
+ );
+ continue;
+ }
+
+ const [elementRaw, xStr, yStr, zStr] = parts;
+ const element = normalizeElement(elementRaw);
+ const x = parseFloat(xStr);
+ const y = parseFloat(yStr);
+ const z = parseFloat(zStr);
+
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
+ frameWarnings.push(
+ `Line ${lineIndex + i + 1}: Non-numeric coordinates for ${elementRaw}`
+ );
+ continue;
+ }
+
+ // Check for unknown elements
+ if (!ATOMIC_DATA[element]) {
+ frameWarnings.push(
+ `Line ${lineIndex + i + 1}: Unknown element "${elementRaw}" (will use defaults)`
+ );
+ }
+
+ // Check for large coordinates (unit mismatch warning)
+ if (!hasLargeCoords &&
+ (Math.abs(x) > PARSE_CONFIG.MAX_COORD_MAGNITUDE ||
+ Math.abs(y) > PARSE_CONFIG.MAX_COORD_MAGNITUDE ||
+ Math.abs(z) > PARSE_CONFIG.MAX_COORD_MAGNITUDE)) {
+ hasLargeCoords = true;
+ }
+
+ atoms.push({ element, x, y, z });
+ }
+
+ lineIndex += atomCount;
+
+ // Validate we got atoms
+ if (atoms.length === 0) {
+ if (structures.length === 0) {
+ return createErrorResult('No valid atoms found in first frame');
+ }
+ frameWarnings.push(`Frame ${frameIndex + 1} has no valid atoms - skipped`);
+ frameIndex++;
+ continue;
+ }
+
+ // Large structure warning (only once per file)
+ if (atoms.length > PARSE_CONFIG.LARGE_STRUCTURE_WARNING && structures.length === 0) {
+ warnings.push(
+ `Large structure detected (${atoms.length} atoms) - analysis may be slow`
+ );
+ }
+
+ // Large coordinates warning
+ if (hasLargeCoords) {
+ frameWarnings.push(
+ `Frame ${frameIndex + 1}: Very large coordinates detected (may indicate unit mismatch)`
+ );
+ }
+
+ // Generate structure ID
+ const id = generateStructureId(filename, frameIndex, comment);
+
+ // Create structure
+ const structure = createStructure(id, filename, atoms, {
+ comment,
+ parseProvenance: 'xyz',
+ warnings: frameWarnings.length > 0 ? frameWarnings : undefined
+ });
+
+ structures.push(structure);
+
+ // Add frame warnings to global warnings
+ if (frameWarnings.length > 0) {
+ warnings.push(...frameWarnings);
+ }
+
+ frameIndex++;
+ }
+
+ if (structures.length === 0) {
+ return createErrorResult('No valid structures found in XYZ file');
+ }
+
+ return createSuccessResult(structures, 'xyz', warnings);
+
+ } catch (error) {
+ return createErrorResult(`XYZ parsing failed: ${error.message}`);
+ }
+}
+
+/**
+ * Parse CIF content
+ *
+ * NOTE: This is a placeholder implementation. Full CIF parsing requires gemmi-wasm.
+ * For now, we provide a basic parser that handles simple CIF files.
+ *
+ * @param {string} content - Raw CIF content
+ * @param {string} filename - Source filename
+ * @returns {ParseResult}
+ */
+export function parseCIF(content, filename) {
+ const structures = [];
+ const warnings = [];
+
+ try {
+ // Split into data blocks
+ const blocks = splitCIFBlocks(content);
+
+ if (blocks.length === 0) {
+ return createErrorResult('No valid data blocks found in CIF file');
+ }
+
+ for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
+ const block = blocks[blockIndex];
+ const blockResult = parseCIFBlock(block, filename, blockIndex);
+
+ if (blockResult.valid) {
+ structures.push(blockResult.structure);
+ if (blockResult.warnings.length > 0) {
+ warnings.push(...blockResult.warnings);
+ }
+ } else {
+ warnings.push(`Block ${blockIndex + 1} (${block.name}): ${blockResult.error}`);
+ }
+ }
+
+ if (structures.length === 0) {
+ return createErrorResult(
+ 'No valid structures found in CIF file. ' +
+ 'Ensure the file contains valid atom coordinates. ' +
+ warnings.join('; ')
+ );
+ }
+
+ return createSuccessResult(structures, 'cif', warnings);
+
+ } catch (error) {
+ return createErrorResult(`CIF parsing failed: ${error.message}`);
+ }
+}
+
+/**
+ * Split CIF content into data blocks
+ *
+ * @param {string} content - Raw CIF content
+ * @returns {Array<{name: string, content: string}>}
+ */
+function splitCIFBlocks(content) {
+ const blocks = [];
+ const lines = content.split('\n');
+ let currentBlock = null;
+ let currentLines = [];
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+
+ if (trimmed.toLowerCase().startsWith('data_')) {
+ // Save previous block
+ if (currentBlock !== null) {
+ blocks.push({
+ name: currentBlock,
+ content: currentLines.join('\n')
+ });
+ }
+ // Start new block
+ currentBlock = trimmed.substring(5).trim() || `block_${blocks.length + 1}`;
+ currentLines = [];
+ } else if (currentBlock !== null) {
+ currentLines.push(line);
+ }
+ }
+
+ // Save last block
+ if (currentBlock !== null) {
+ blocks.push({
+ name: currentBlock,
+ content: currentLines.join('\n')
+ });
+ }
+
+ return blocks;
+}
+
+/**
+ * Parse a single CIF data block
+ *
+ * @param {{name: string, content: string}} block - CIF block
+ * @param {string} filename - Source filename
+ * @param {number} blockIndex - Block index
+ * @returns {{valid: boolean, structure?: Structure, warnings: string[], error?: string}}
+ */
+function parseCIFBlock(block, filename, blockIndex) {
+ const warnings = [];
+
+ try {
+ const lines = block.content.split('\n');
+
+ // Extract unit cell parameters
+ const unitCell = extractUnitCell(lines);
+ if (!unitCell) {
+ warnings.push('No unit cell parameters found - assuming orthogonal cell');
+ }
+
+ // Try to extract atom coordinates
+ let atoms = [];
+
+ // Try _atom_site_fract_* first (fractional coordinates)
+ const fractAtoms = extractFractionalAtoms(lines);
+ if (fractAtoms.length > 0) {
+ if (unitCell) {
+ atoms = convertFractionalToCartesian(fractAtoms, unitCell);
+ } else {
+ // Without unit cell, fractional coords would be cramped
+ warnings.push('Fractional coordinates without unit cell - coordinates may be incorrect');
+ // Use as-is (will be cramped, but at least parseable)
+ atoms = fractAtoms.map(a => ({
+ element: a.element,
+ x: a.x,
+ y: a.y,
+ z: a.z
+ }));
+ }
+ }
+
+ // If no fractional atoms, try _atom_site_Cartn_* (Cartesian coordinates)
+ if (atoms.length === 0) {
+ atoms = extractCartesianAtoms(lines);
+ }
+
+ if (atoms.length === 0) {
+ return {
+ valid: false,
+ warnings,
+ error: 'No atom coordinates found in block'
+ };
+ }
+
+ // Generate structure ID
+ const id = blockIndex === 0 && block.name
+ ? block.name
+ : `${filename.replace(/\.cif$/i, '')}:${block.name || `block-${blockIndex + 1}`}`;
+
+ // Extract space group if available
+ const spaceGroup = extractValue(lines, '_symmetry_space_group_name_H-M') ||
+ extractValue(lines, '_space_group_name_H-M_alt');
+
+ const structure = createStructure(id, filename, atoms, {
+ unitCell,
+ spaceGroup: spaceGroup || undefined,
+ parseProvenance: 'cif-basic',
+ warnings: warnings.length > 0 ? warnings : undefined
+ });
+
+ return {
+ valid: true,
+ structure,
+ warnings
+ };
+
+ } catch (error) {
+ return {
+ valid: false,
+ warnings,
+ error: error.message
+ };
+ }
+}
+
+/**
+ * Extract unit cell parameters from CIF lines
+ *
+ * @param {string[]} lines - CIF content lines
+ * @returns {UnitCell|null}
+ */
+function extractUnitCell(lines) {
+ const a = parseFloat(extractValue(lines, '_cell_length_a'));
+ const b = parseFloat(extractValue(lines, '_cell_length_b'));
+ const c = parseFloat(extractValue(lines, '_cell_length_c'));
+ const alpha = parseFloat(extractValue(lines, '_cell_angle_alpha'));
+ const beta = parseFloat(extractValue(lines, '_cell_angle_beta'));
+ const gamma = parseFloat(extractValue(lines, '_cell_angle_gamma'));
+
+ if (Number.isFinite(a) && Number.isFinite(b) && Number.isFinite(c)) {
+ return {
+ a, b, c,
+ alpha: Number.isFinite(alpha) ? alpha : 90,
+ beta: Number.isFinite(beta) ? beta : 90,
+ gamma: Number.isFinite(gamma) ? gamma : 90
+ };
+ }
+
+ return null;
+}
+
+/**
+ * Extract a simple CIF value
+ *
+ * @param {string[]} lines - CIF lines
+ * @param {string} key - CIF key to find
+ * @returns {string|null}
+ */
+function extractValue(lines, key) {
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (trimmed.toLowerCase().startsWith(key.toLowerCase())) {
+ const parts = trimmed.split(/\s+/);
+ if (parts.length >= 2) {
+ // Remove uncertainty notation like 1.234(5)
+ return parts[1].replace(/\([^)]*\)/g, '');
+ }
+ }
+ }
+ return null;
+}
+
+/**
+ * Extract fractional coordinates from CIF loop
+ *
+ * @param {string[]} lines - CIF lines
+ * @returns {Array<{element: string, x: number, y: number, z: number}>}
+ */
+function extractFractionalAtoms(lines) {
+ return extractAtomLoop(lines, [
+ '_atom_site_fract_x',
+ '_atom_site_fract_y',
+ '_atom_site_fract_z'
+ ]);
+}
+
+/**
+ * Extract Cartesian coordinates from CIF loop
+ *
+ * @param {string[]} lines - CIF lines
+ * @returns {Array<{element: string, x: number, y: number, z: number}>}
+ */
+function extractCartesianAtoms(lines) {
+ return extractAtomLoop(lines, [
+ '_atom_site_Cartn_x',
+ '_atom_site_Cartn_y',
+ '_atom_site_Cartn_z'
+ ]);
+}
+
+/**
+ * Extract atoms from a CIF loop with specified coordinate columns
+ *
+ * @param {string[]} lines - CIF lines
+ * @param {string[]} coordKeys - Keys for x, y, z coordinates
+ * @returns {Array<{element: string, x: number, y: number, z: number}>}
+ */
+function extractAtomLoop(lines, coordKeys) {
+ const atoms = [];
+ let inLoop = false;
+ let inAtomSiteLoop = false;
+ const columns = [];
+ let elementCol = -1;
+ let symbolCol = -1;
+ let xCol = -1;
+ let yCol = -1;
+ let zCol = -1;
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i].trim();
+
+ if (line.toLowerCase() === 'loop_') {
+ inLoop = true;
+ inAtomSiteLoop = false;
+ columns.length = 0;
+ elementCol = -1;
+ symbolCol = -1;
+ xCol = yCol = zCol = -1;
+ continue;
+ }
+
+ if (inLoop && line.startsWith('_')) {
+ columns.push(line.toLowerCase());
+
+ if (line.toLowerCase().includes('_atom_site_')) {
+ inAtomSiteLoop = true;
+ }
+
+ // Track column indices
+ const colIndex = columns.length - 1;
+ const lowerLine = line.toLowerCase();
+
+ if (lowerLine === '_atom_site_type_symbol') {
+ symbolCol = colIndex;
+ } else if (lowerLine === '_atom_site_label') {
+ elementCol = colIndex;
+ } else if (lowerLine === coordKeys[0].toLowerCase()) {
+ xCol = colIndex;
+ } else if (lowerLine === coordKeys[1].toLowerCase()) {
+ yCol = colIndex;
+ } else if (lowerLine === coordKeys[2].toLowerCase()) {
+ zCol = colIndex;
+ }
+
+ continue;
+ }
+
+ if (inLoop && inAtomSiteLoop && !line.startsWith('_') && line !== '' && !line.startsWith('#')) {
+ // Check if we have coordinate columns
+ if (xCol === -1 || yCol === -1 || zCol === -1) {
+ continue;
+ }
+
+ // Parse data line
+ const parts = parseLoopLine(line);
+ if (parts.length >= columns.length) {
+ // Get element from type_symbol or extract from label
+ let element = '';
+ if (symbolCol >= 0 && parts[symbolCol]) {
+ element = normalizeElement(parts[symbolCol]);
+ } else if (elementCol >= 0 && parts[elementCol]) {
+ // Extract element from label like "Fe1", "N2", etc.
+ const match = parts[elementCol].match(/^([A-Za-z]+)/);
+ if (match) {
+ element = normalizeElement(match[1]);
+ }
+ }
+
+ if (!element) {
+ continue;
+ }
+
+ // Parse coordinates (remove uncertainty notation)
+ const x = parseFloat(parts[xCol].replace(/\([^)]*\)/g, ''));
+ const y = parseFloat(parts[yCol].replace(/\([^)]*\)/g, ''));
+ const z = parseFloat(parts[zCol].replace(/\([^)]*\)/g, ''));
+
+ if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) {
+ atoms.push({ element, x, y, z });
+ }
+ }
+ }
+
+ // End of loop if we hit another loop_ or data_ or end of relevant data
+ if (inAtomSiteLoop && (line.toLowerCase() === 'loop_' || line.toLowerCase().startsWith('data_'))) {
+ break;
+ }
+ }
+
+ return atoms;
+}
+
+/**
+ * Parse a CIF loop data line, handling quoted strings
+ *
+ * @param {string} line - Data line
+ * @returns {string[]} - Parsed values
+ */
+function parseLoopLine(line) {
+ const parts = [];
+ let current = '';
+ let inQuote = false;
+ let quoteChar = '';
+
+ for (let i = 0; i < line.length; i++) {
+ const char = line[i];
+
+ if (!inQuote && (char === '"' || char === "'")) {
+ inQuote = true;
+ quoteChar = char;
+ } else if (inQuote && char === quoteChar) {
+ inQuote = false;
+ parts.push(current);
+ current = '';
+ } else if (!inQuote && /\s/.test(char)) {
+ if (current.length > 0) {
+ parts.push(current);
+ current = '';
+ }
+ } else {
+ current += char;
+ }
+ }
+
+ if (current.length > 0) {
+ parts.push(current);
+ }
+
+ return parts;
+}
+
+/**
+ * Convert fractional to Cartesian coordinates
+ *
+ * Uses the standard crystallographic transformation matrix.
+ *
+ * @param {Array<{element: string, x: number, y: number, z: number}>} fractAtoms - Fractional coords
+ * @param {UnitCell} cell - Unit cell parameters
+ * @returns {Array<{element: string, x: number, y: number, z: number}>}
+ */
+function convertFractionalToCartesian(fractAtoms, cell) {
+ const { a, b, c, alpha, beta, gamma } = cell;
+
+ // Convert angles to radians
+ const alphaRad = (alpha * Math.PI) / 180;
+ const betaRad = (beta * Math.PI) / 180;
+ const gammaRad = (gamma * Math.PI) / 180;
+
+ // Calculate transformation matrix components
+ const cosAlpha = Math.cos(alphaRad);
+ const cosBeta = Math.cos(betaRad);
+ const cosGamma = Math.cos(gammaRad);
+ const sinGamma = Math.sin(gammaRad);
+
+ // Volume factor
+ const v = Math.sqrt(
+ 1 - cosAlpha * cosAlpha - cosBeta * cosBeta - cosGamma * cosGamma +
+ 2 * cosAlpha * cosBeta * cosGamma
+ );
+
+ // Transformation matrix (fractional to Cartesian)
+ // Standard crystallographic convention
+ const m11 = a;
+ const m12 = b * cosGamma;
+ const m13 = c * cosBeta;
+ const m21 = 0;
+ const m22 = b * sinGamma;
+ const m23 = c * (cosAlpha - cosBeta * cosGamma) / sinGamma;
+ const m31 = 0;
+ const m32 = 0;
+ const m33 = c * v / sinGamma;
+
+ return fractAtoms.map(atom => ({
+ element: atom.element,
+ x: m11 * atom.x + m12 * atom.y + m13 * atom.z,
+ y: m21 * atom.x + m22 * atom.y + m23 * atom.z,
+ z: m31 * atom.x + m32 * atom.y + m33 * atom.z
+ }));
+}
+
+/**
+ * Normalize element symbol
+ *
+ * @param {string} element - Raw element string
+ * @returns {string} - Normalized element (e.g., "FE" -> "Fe")
+ */
+function normalizeElement(element) {
+ if (!element || typeof element !== 'string') {
+ return '';
+ }
+ // Remove any charge notation like Fe2+, N3-
+ const cleaned = element.replace(/[0-9+-]+$/, '');
+ return cleaned.charAt(0).toUpperCase() + cleaned.slice(1).toLowerCase();
+}
+
+// Re-export legacy functions for backwards compatibility
+export { parseXYZMultiFrame as parseXYZ };
diff --git a/src/utils/parseInput.test.js b/src/utils/parseInput.test.js
new file mode 100644
index 0000000..0bd7456
--- /dev/null
+++ b/src/utils/parseInput.test.js
@@ -0,0 +1,305 @@
+/**
+ * parseInput Tests - v1.5.0
+ *
+ * Tests for the unified parser that handles XYZ (single/multi-frame) and CIF files.
+ */
+
+import { parseInput, detectFormat, parseXYZMultiFrame, parseCIF } from './parseInput';
+
+describe('parseInput - Format Detection', () => {
+ it('should detect XYZ format from extension', () => {
+ expect(detectFormat('3\nTest\nFe 0 0 0', 'test.xyz')).toBe('xyz');
+ });
+
+ it('should detect CIF format from extension', () => {
+ expect(detectFormat('data_test\n_cell_length_a 10', 'test.cif')).toBe('cif');
+ });
+
+ it('should detect XYZ format from content when extension unknown', () => {
+ const content = '3\nTest molecule\nFe 0 0 0\nN 2 0 0\nN 0 2 0';
+ expect(detectFormat(content, 'test.dat')).toBe('xyz');
+ });
+
+ it('should detect CIF format from content when extension unknown', () => {
+ const content = 'data_test\n_cell_length_a 10.5\n_atom_site_label Fe1';
+ expect(detectFormat(content, 'test.dat')).toBe('cif');
+ });
+
+ it('should return unknown for unrecognized format', () => {
+ expect(detectFormat('random text', 'test.txt')).toBe('unknown');
+ });
+});
+
+describe('parseInput - Single XYZ', () => {
+ it('should parse a valid single-structure XYZ file', () => {
+ const content = `3
+Water molecule
+O 0.000 0.000 0.000
+H 0.757 0.586 0.000
+H -0.757 0.586 0.000`;
+
+ const result = parseInput(content, 'water.xyz');
+
+ expect(result.valid).toBe(true);
+ expect(result.format).toBe('xyz');
+ expect(result.frameCount).toBe(1);
+ expect(result.structures.length).toBe(1);
+ expect(result.structures[0].atoms.length).toBe(3);
+ expect(result.structures[0].atoms[0].element).toBe('O');
+ });
+
+ it('should use comment as structure ID when appropriate', () => {
+ const content = `3
+LMMPa
+Fe 0 0 0
+N 2 0 0
+N 0 2 0`;
+
+ const result = parseInput(content, 'complex.xyz');
+ expect(result.structures[0].id).toBe('LMMPa');
+ });
+
+ it('should generate ID from filename when comment is empty', () => {
+ const content = `3
+
+Fe 0 0 0
+N 2 0 0
+N 0 2 0`;
+
+ const result = parseInput(content, 'mycomplex.xyz');
+ expect(result.structures[0].id).toBe('mycomplex');
+ });
+
+ it('should fail on invalid atom count', () => {
+ const content = `not_a_number
+Test
+Fe 0 0 0`;
+
+ const result = parseInput(content, 'test.xyz');
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain('Invalid atom count');
+ });
+
+ it('should fail when atom count exceeds data lines', () => {
+ const content = `10
+Test
+Fe 0 0 0
+N 2 0 0`;
+
+ const result = parseInput(content, 'test.xyz');
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain('claims 10 atoms');
+ });
+});
+
+describe('parseInput - Multi-frame XYZ', () => {
+ it('should parse multiple frames', () => {
+ const content = `2
+Frame 1
+Fe 0 0 0
+N 2 0 0
+2
+Frame 2
+Fe 0 0 1
+N 2 0 1`;
+
+ const result = parseInput(content, 'trajectory.xyz');
+
+ expect(result.valid).toBe(true);
+ expect(result.frameCount).toBe(2);
+ expect(result.structures.length).toBe(2);
+ // IDs are extracted from comment line - "Frame 1" becomes "1" after removing "frame" prefix
+ expect(result.structures[0].id).toBe('1');
+ expect(result.structures[1].id).toBe('2');
+ });
+
+ it('should handle empty lines between frames', () => {
+ const content = `2
+Frame 1
+Fe 0 0 0
+N 2 0 0
+
+2
+Frame 2
+Fe 1 0 0
+N 3 0 0
+
+`;
+
+ const result = parseInput(content, 'trajectory.xyz');
+ expect(result.valid).toBe(true);
+ expect(result.frameCount).toBe(2);
+ });
+
+ it('should stop at malformed frame in tolerant mode', () => {
+ const content = `2
+Good Frame
+Fe 0 0 0
+N 2 0 0
+bad_header
+broken frame`;
+
+ const result = parseInput(content, 'trajectory.xyz');
+ expect(result.valid).toBe(true);
+ expect(result.frameCount).toBe(1);
+ expect(result.warnings.length).toBeGreaterThan(0);
+ });
+
+ it('should generate sequential IDs when comments are generic', () => {
+ const content = `2
+
+Fe 0 0 0
+N 2 0 0
+2
+
+Fe 1 0 0
+N 3 0 0`;
+
+ const result = parseInput(content, 'traj.xyz');
+ expect(result.valid).toBe(true);
+ expect(result.structures[0].id).toBe('traj');
+ expect(result.structures[1].id).toBe('traj:frame-002');
+ });
+});
+
+describe('parseInput - CIF Basic Parsing', () => {
+ it('should parse a simple CIF file with Cartesian coordinates', () => {
+ const content = `data_test
+_cell_length_a 10.0
+_cell_length_b 10.0
+_cell_length_c 10.0
+_cell_angle_alpha 90
+_cell_angle_beta 90
+_cell_angle_gamma 90
+
+loop_
+_atom_site_label
+_atom_site_type_symbol
+_atom_site_Cartn_x
+_atom_site_Cartn_y
+_atom_site_Cartn_z
+Fe1 Fe 0.0 0.0 0.0
+N1 N 2.0 0.0 0.0
+N2 N 0.0 2.0 0.0`;
+
+ const result = parseInput(content, 'test.cif');
+
+ expect(result.valid).toBe(true);
+ expect(result.format).toBe('cif');
+ expect(result.structures.length).toBe(1);
+ expect(result.structures[0].id).toBe('test');
+ expect(result.structures[0].atoms.length).toBe(3);
+ });
+
+ it('should parse CIF with fractional coordinates and unit cell', () => {
+ const content = `data_NaCl
+_cell_length_a 5.64
+_cell_length_b 5.64
+_cell_length_c 5.64
+_cell_angle_alpha 90
+_cell_angle_beta 90
+_cell_angle_gamma 90
+
+loop_
+_atom_site_label
+_atom_site_type_symbol
+_atom_site_fract_x
+_atom_site_fract_y
+_atom_site_fract_z
+Na1 Na 0.0 0.0 0.0
+Cl1 Cl 0.5 0.5 0.5`;
+
+ const result = parseInput(content, 'nacl.cif');
+
+ expect(result.valid).toBe(true);
+ expect(result.structures.length).toBe(1);
+ expect(result.structures[0].atoms.length).toBe(2);
+ // Check that fractional coords were converted to Cartesian
+ expect(result.structures[0].atoms[0].x).toBeCloseTo(0, 2);
+ expect(result.structures[0].atoms[1].x).toBeCloseTo(2.82, 1);
+ });
+
+ it('should handle multiple data blocks', () => {
+ const content = `data_block1
+_cell_length_a 10.0
+_cell_length_b 10.0
+_cell_length_c 10.0
+loop_
+_atom_site_label
+_atom_site_type_symbol
+_atom_site_Cartn_x
+_atom_site_Cartn_y
+_atom_site_Cartn_z
+Fe1 Fe 0.0 0.0 0.0
+
+data_block2
+_cell_length_a 10.0
+_cell_length_b 10.0
+_cell_length_c 10.0
+loop_
+_atom_site_label
+_atom_site_type_symbol
+_atom_site_Cartn_x
+_atom_site_Cartn_y
+_atom_site_Cartn_z
+Cu1 Cu 1.0 1.0 1.0`;
+
+ const result = parseInput(content, 'multi.cif');
+
+ expect(result.valid).toBe(true);
+ expect(result.frameCount).toBe(2);
+ // First block uses block name directly, subsequent blocks include filename prefix
+ expect(result.structures[0].id).toBe('block1');
+ expect(result.structures[1].id).toBe('multi:block2');
+ });
+
+ it('should fail gracefully with no atom coordinates', () => {
+ const content = `data_empty
+_cell_length_a 10.0`;
+
+ const result = parseInput(content, 'empty.cif');
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain('No valid structures');
+ });
+});
+
+describe('parseInput - Edge Cases', () => {
+ it('should handle empty content', () => {
+ const result = parseInput('', 'test.xyz');
+ expect(result.valid).toBe(false);
+ });
+
+ it('should handle null content', () => {
+ const result = parseInput(null, 'test.xyz');
+ expect(result.valid).toBe(false);
+ });
+
+ it('should handle unknown file format', () => {
+ const result = parseInput('random data', 'test.unknown');
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain('Unknown file format');
+ });
+
+ it('should normalize element symbols', () => {
+ const content = `3
+Test
+FE 0 0 0
+n 2 0 0
+CL 0 2 0`;
+
+ const result = parseInput(content, 'test.xyz');
+ expect(result.structures[0].atoms[0].element).toBe('Fe');
+ expect(result.structures[0].atoms[1].element).toBe('N');
+ expect(result.structures[0].atoms[2].element).toBe('Cl');
+ });
+
+ it('should warn about large coordinates', () => {
+ const content = `1
+Test
+Fe 10000 0 0`;
+
+ const result = parseInput(content, 'test.xyz');
+ expect(result.valid).toBe(true);
+ expect(result.warnings.some(w => w.includes('large coordinates'))).toBe(true);
+ });
+});
diff --git a/src/workers/cshm.worker.js b/src/workers/cshm.worker.js
index cd8a512..280e86d 100644
--- a/src/workers/cshm.worker.js
+++ b/src/workers/cshm.worker.js
@@ -1,7 +1,7 @@
/**
* Handles intensive CShM optimization with progress reporting.
*
- * TODO: This worker is currently disconnected from the main application flow (v1.4.0).
+ * TODO: This worker is currently disconnected from the main application flow (v1.5.0).
* It was reverted due to memory issues but has been optimized.
* Re-integrate in future versions for parallel processing.
*