From 0e001a73dd2af0de95957d2471e17a347031ff75 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Jan 2026 01:33:21 +0000 Subject: [PATCH 1/5] feat: add multi-file/multi-geometry XYZ and CIF support (v1.5.0) This major feature release adds support for: Multi-geometry XYZ files: - parseMultiXYZ() parses XYZ files with multiple structures - isMultiFrameXYZ() detects multi-frame files automatically - validateMultiXYZ() validates all frames with detailed error reporting - parseXYZAuto() smart parser that handles both single and multi-frame CIF file support: - New cifParser.js using crystcif-parse library - Automatic fractional to Cartesian coordinate conversion - Multi-block CIF file support - Metadata extraction (cell params, space group) Multiple file upload: - Upload multiple files at once (.xyz and .cif) - Structure selector with navigation buttons - Display structure count and format info Enhanced reporting: - PDF report includes structure name and file format - CSV export includes Structure column when applicable - Updated version references to v1.5.0 Tests: - 52 file parser tests all passing - Tests for isMultiFrameXYZ, countXYZFrames, validateMultiXYZ - Tests for parseMultiXYZ and parseXYZAuto --- package-lock.json | 87 +++++++ package.json | 3 +- src/App.js | 34 ++- src/components/FileUploadSection.jsx | 178 +++++++++++++- src/hooks/useFileUpload.js | 304 +++++++++++++++++++++--- src/services/reportGenerator.js | 34 ++- src/utils/cifParser.js | 302 ++++++++++++++++++++++++ src/utils/fileParser.js | 341 +++++++++++++++++++++++++++ src/utils/fileParser.test.js | 191 ++++++++++++++- 9 files changed, 1423 insertions(+), 51 deletions(-) create mode 100644 src/utils/cifParser.js diff --git a/package-lock.json b/package-lock.json index 4b6bcbe..2073860 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "q-shape", "version": "1.4.0", "dependencies": { + "crystcif-parse": "^0.2.9", "munkres-js": "^1.2.2", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -5661,6 +5662,19 @@ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "license": "MIT" }, + "node_modules/complex.js": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.3.tgz", + "integrity": "sha512-UrQVSUur14tNX6tiP4y8T4w4FeJAX3bi2cIv0pu/DTLFNxoq7z2Yh83Vfzztj6Px3X/lubqQ9IrPp7Bpn6p4MQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -5849,6 +5863,19 @@ "node": ">=8" } }, + "node_modules/crystcif-parse": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/crystcif-parse/-/crystcif-parse-0.2.9.tgz", + "integrity": "sha512-h2pirCEB2yZLjOVL02uULwmI41leiJlQLUgO5HWYh2r+wt1aKMJ8IGPr74U9H5tNyyc3FF8utYj+Yfh60QrRfQ==", + "license": "MIT", + "dependencies": { + "mathjs": "^7.6.0", + "mendeleev": "^1.2.2" + }, + "bin": { + "validate-cif": "bin/validate.js" + } + }, "node_modules/css-blank-pseudo": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-3.0.3.tgz", @@ -6975,6 +7002,12 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/escape-latex": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz", + "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -9844,6 +9877,12 @@ "node": ">=10" } }, + "node_modules/javascript-natural-sort": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", + "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==", + "license": "MIT" + }, "node_modules/jest": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", @@ -11188,6 +11227,28 @@ "node": ">= 0.4" } }, + "node_modules/mathjs": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-7.6.0.tgz", + "integrity": "sha512-abywR28hUpKF4at5jE8Ys+Kigk40eKMT5mcBLD0/dtsqjfOLbtzd3WjlRqIopNo7oQ6FME51qph6lb8h/bhpUg==", + "license": "Apache-2.0", + "dependencies": { + "complex.js": "^2.0.11", + "decimal.js": "^10.2.1", + "escape-latex": "^1.2.0", + "fraction.js": "^4.0.12", + "javascript-natural-sort": "^0.7.1", + "seed-random": "^2.2.0", + "tiny-emitter": "^2.1.0", + "typed-function": "^2.0.0" + }, + "bin": { + "mathjs": "bin/cli.js" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/mdn-data": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz", @@ -11215,6 +11276,12 @@ "node": ">= 4.0.0" } }, + "node_modules/mendeleev": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/mendeleev/-/mendeleev-1.2.2.tgz", + "integrity": "sha512-F/mZqsr2dt94kddCxj66sAbfEXGDKbICut86ONTeuN9st4u0R2Zc39FrCWYWYuVdzmQlE9y4RFFgcuJ6bg6n+A==", + "license": "ISC" + }, "node_modules/merge-descriptors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", @@ -14517,6 +14584,12 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/seed-random": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/seed-random/-/seed-random-2.2.0.tgz", + "integrity": "sha512-34EQV6AAHQGhoc0tn/96a9Fsi6v2xdqe/dMUwljGRaFOzR3EgRmECvD0O8vi8X+/uQ50LGHfkNu/Eue5TPKZkQ==", + "license": "MIT" + }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -16040,6 +16113,12 @@ "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", "license": "MIT" }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -16318,6 +16397,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typed-function": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-2.1.0.tgz", + "integrity": "sha512-bctQIOqx2iVbWGDGPWwIm18QScpu2XRmkC19D8rQGFsjKSgteq/o1hTZvIG/wuDq8fanpBDrLkLq+aEN/6y5XQ==", + "engines": { + "node": ">= 10" + } + }, "node_modules/typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", diff --git a/package.json b/package.json index a82796c..4533b71 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "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, "dependencies": { + "crystcif-parse": "^0.2.9", "munkres-js": "^1.2.2", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/src/App.js b/src/App.js index 743b907..81ab655 100644 --- a/src/App.js +++ b/src/App.js @@ -39,8 +39,18 @@ export default function CoordinationGeometryAnalyzer() { const canvasRef = useRef(null); const fileInputRef = useRef(null); - // File Upload Hook - const { atoms, fileName, error, uploadMetadata, handleFileUpload } = useFileUpload(); + // File Upload Hook (v1.5.0 - supports multiple files/structures) + const { + atoms, + fileName, + error, + uploadMetadata, + handleFileUpload, + structures, + selectedStructureIndex, + currentStructure, + selectStructure + } = useFileUpload(); // Stable callback for radius changes const handleRadiusChange = useCallback((radius, isAuto) => { @@ -239,7 +249,9 @@ export default function CoordinationGeometryAnalyzer() { fileName, analysisMode: analysisParams.mode, intensiveMetadata, - imgData + imgData, + structureName: currentStructure?.name || null, + fileFormat: currentStructure?.format || 'xyz' }); } catch (err) { console.error("Report generation failed:", err); @@ -253,12 +265,16 @@ export default function CoordinationGeometryAnalyzer() { if (!geometryResults || geometryResults.length === 0) return; try { - generateCSVReport({ geometryResults, fileName }); + generateCSVReport({ + geometryResults, + fileName, + structureName: currentStructure?.name || null + }); } catch (err) { console.error("CSV generation failed:", err); setWarnings(prev => [...prev, `CSV export failed: ${err.message}`]); } - }, [geometryResults, fileName]); + }, [geometryResults, fileName, currentStructure]); return (
@@ -276,10 +292,10 @@ export default function CoordinationGeometryAnalyzer() { marginTop: '0.5rem', fontFamily: 'monospace' }}> - Version 1.4.0 | Built: November 25, 2025 + Version 1.5.0 | Built: January 2026

- 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: Castro Silva Junior, H. (2025). Q-Shape - Quantitative Shape Analyzer (v1.5.0). Zenodo. https://doi.org/10.5281/zenodo.17717110

@@ -318,6 +334,10 @@ export default function CoordinationGeometryAnalyzer() { {atoms.length > 0 && ( diff --git a/src/components/FileUploadSection.jsx b/src/components/FileUploadSection.jsx index 73abfeb..380ec9e 100644 --- a/src/components/FileUploadSection.jsx +++ b/src/components/FileUploadSection.jsx @@ -1,26 +1,196 @@ /** * File Upload Section Component * - * Handles XYZ file upload interface + * Handles XYZ and CIF file upload interface. + * Supports multiple file upload and multi-frame XYZ files. + * Includes structure selector when multiple structures are loaded. */ import React from 'react'; -export default function FileUploadSection({ fileInputRef, onFileUpload }) { +export default function FileUploadSection({ + fileInputRef, + onFileUpload, + structures = [], + selectedStructureIndex = 0, + onSelectStructure, + uploadMetadata +}) { + const hasMultipleStructures = structures.length > 1; + return (
+ + Supports .xyz (single/multi-frame) and .cif files +
+ + {/* Structure count badge */} + {uploadMetadata && uploadMetadata.structureCount > 1 && ( +
+ + ๐Ÿ“Š {uploadMetadata.structureCount} structures loaded + + {uploadMetadata.fileCount > 1 && ( + + from {uploadMetadata.fileCount} files + + )} + {uploadMetadata.formats && uploadMetadata.formats.length > 1 && ( + + ({uploadMetadata.formats.map(f => f.toUpperCase()).join(', ')}) + + )} +
+ )} + + {/* Structure selector dropdown */} + {hasMultipleStructures && ( +
+ + + + {/* Quick navigation buttons for many structures */} + {structures.length > 5 && ( +
+ + + + {selectedStructureIndex + 1} / {structures.length} + + + +
+ )} +
+ )} + + {/* Current structure info */} + {structures.length > 0 && structures[selectedStructureIndex] && ( +
+ Current: {structures[selectedStructureIndex].name} + {structures[selectedStructureIndex].source && structures[selectedStructureIndex].source !== structures[selectedStructureIndex].name && ( + + (from {structures[selectedStructureIndex].source}) + + )} +
+ )}
); } diff --git a/src/hooks/useFileUpload.js b/src/hooks/useFileUpload.js index 385e3d7..d53e388 100644 --- a/src/hooks/useFileUpload.js +++ b/src/hooks/useFileUpload.js @@ -2,43 +2,284 @@ * useFileUpload Hook * * Manages file upload, validation, and parsing for molecular structure files. - * Handles XYZ file format with comprehensive validation and error handling. + * Supports XYZ (single and multi-frame) and CIF file formats. + * Handles multiple file uploads simultaneously. * * @returns {Object} File upload state and handlers - * @returns {Array} atoms - Parsed molecular structure - * @returns {String} fileName - Name of uploaded file (without .xyz extension) + * @returns {Array} structures - Array of parsed Structure objects + * @returns {Array} atoms - Atoms from currently selected structure (backwards compatible) + * @returns {number} selectedStructureIndex - Index of currently selected structure + * @returns {String} fileName - Name(s) of uploaded file(s) * @returns {String|null} error - Error message if upload/parsing failed * @returns {Array} warnings - Array of warning messages + * @returns {Object} uploadMetadata - Metadata about the upload * @returns {Function} handleFileUpload - File upload event handler + * @returns {Function} selectStructure - Select a structure by index * @returns {Function} resetFileState - Reset all file-related state * * @example - * const { atoms, fileName, error, warnings, handleFileUpload } = useFileUpload(); + * const { structures, atoms, handleFileUpload, selectStructure } = useFileUpload(); * - * // In JSX: - * + * // In JSX - supports multiple files and both formats: + * */ -import { useState, useCallback } from 'react'; -import { parseXYZ, validateXYZ } from '../utils/fileParser'; +import { useState, useCallback, useMemo } from 'react'; +import { + parseXYZ, + validateXYZ, + parseXYZAuto, + isMultiFrameXYZ, + validateMultiXYZ +} from '../utils/fileParser'; +import { parseCIF, validateCIF, isCIFContent } from '../utils/cifParser'; import { detectMetalCenter } from '../services/coordination/metalDetector'; import { detectOptimalRadius } from '../services/coordination/radiusDetector'; +/** + * Determines file format from filename extension + * @param {string} filename - Filename with extension + * @returns {'xyz'|'cif'|'unknown'} File format + */ +function getFileFormat(filename) { + const ext = filename.toLowerCase().split('.').pop(); + if (ext === 'xyz') return 'xyz'; + if (ext === 'cif') return 'cif'; + return 'unknown'; +} + export function useFileUpload() { - const [atoms, setAtoms] = useState([]); + // Multi-structure state + const [structures, setStructures] = useState([]); + const [selectedStructureIndex, setSelectedStructureIndex] = useState(0); + + // Legacy state (maintained for backwards compatibility) const [fileName, setFileName] = useState(""); const [error, setError] = useState(null); const [warnings, setWarnings] = useState([]); const [uploadMetadata, setUploadMetadata] = useState(null); + // Computed: atoms from currently selected structure (backwards compatible) + const atoms = useMemo(() => { + if (structures.length === 0) return []; + const selected = structures[selectedStructureIndex]; + return selected?.atoms || []; + }, [structures, selectedStructureIndex]); + + // Computed: current structure info + const currentStructure = useMemo(() => { + if (structures.length === 0) return null; + return structures[selectedStructureIndex] || null; + }, [structures, selectedStructureIndex]); + + /** + * Process a single file content + */ + const processFileContent = useCallback((content, filename) => { + const format = getFileFormat(filename); + const sourceName = filename.replace(/\.(xyz|cif)$/i, ""); + const fileWarnings = []; + + // Auto-detect format if unknown + let detectedFormat = format; + if (format === 'unknown') { + if (isCIFContent(content)) { + detectedFormat = 'cif'; + } else { + detectedFormat = 'xyz'; // Default to XYZ + } + fileWarnings.push(`Unknown file extension, detected as ${detectedFormat.toUpperCase()}`); + } + + if (detectedFormat === 'cif') { + // Validate and parse CIF + const validation = validateCIF(content); + if (!validation.valid) { + throw new Error(`${filename}: ${validation.error}`); + } + if (validation.warnings) { + fileWarnings.push(...validation.warnings.map(w => `${filename}: ${w}`)); + } + + const cifStructures = parseCIF(content, sourceName); + return { structures: cifStructures, warnings: fileWarnings }; + + } else { + // XYZ format - check for multi-frame + if (isMultiFrameXYZ(content)) { + const validation = validateMultiXYZ(content); + if (!validation.valid) { + throw new Error(`${filename}: ${validation.error || validation.errors?.[0]?.error}`); + } + if (validation.warnings) { + fileWarnings.push(...validation.warnings.map(w => `${filename}: ${w}`)); + } + + const xyzStructures = parseXYZAuto(content, sourceName); + return { structures: xyzStructures, warnings: fileWarnings }; + + } else { + // Single-frame XYZ + const validation = validateXYZ(content); + if (!validation.valid) { + throw new Error(`${filename}: ${validation.error}`); + } + if (validation.warnings) { + fileWarnings.push(...validation.warnings.map(w => `${filename}: ${w}`)); + } + + const xyzStructures = parseXYZAuto(content, sourceName); + return { structures: xyzStructures, warnings: fileWarnings }; + } + } + }, []); + + /** + * Handle file upload - supports single or multiple files + */ const handleFileUpload = useCallback((e) => { + const files = e.target.files; + if (!files || files.length === 0) return; + + // Reset state + setError(null); + setWarnings([]); + setStructures([]); + setSelectedStructureIndex(0); + + // Build filename string + const fileNames = Array.from(files).map(f => f.name.replace(/\.(xyz|cif)$/i, "")); + setFileName(fileNames.join(', ')); + + // Process all files + const allStructures = []; + const allWarnings = []; + const readPromises = []; + + Array.from(files).forEach((file) => { + const promise = new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (ev) => { + try { + const content = String(ev.target?.result || ""); + const result = processFileContent(content, file.name); + resolve(result); + } catch (err) { + reject(err); + } + }; + + reader.onerror = () => { + reject(new Error(`Failed to read ${file.name}`)); + }; + + reader.readAsText(file); + }); + + readPromises.push(promise); + }); + + // Wait for all files to be processed + Promise.all(readPromises) + .then((results) => { + results.forEach(result => { + allStructures.push(...result.structures); + allWarnings.push(...result.warnings); + }); + + if (allStructures.length === 0) { + throw new Error("No valid structures found in uploaded files"); + } + + setStructures(allStructures); + setWarnings(allWarnings); + setSelectedStructureIndex(0); + + // Auto-detect metal for first structure + const firstStructure = allStructures[0]; + const metalIdx = detectMetalCenter(firstStructure.atoms); + + let optimalRadius = 3.0; + if (metalIdx != null && firstStructure.atoms[metalIdx]) { + optimalRadius = detectOptimalRadius(firstStructure.atoms[metalIdx], firstStructure.atoms); + } + + setUploadMetadata({ + detectedMetalIndex: metalIdx, + suggestedRadius: optimalRadius, + atomCount: firstStructure.atoms.length, + structureCount: allStructures.length, + uploadTime: Date.now(), + fileCount: files.length, + formats: [...new Set(allStructures.map(s => s.format))] + }); + + }) + .catch((err) => { + console.error("File upload error:", err); + setError(err.message); + setStructures([]); + setUploadMetadata(null); + }); + + }, [processFileContent]); + + /** + * Select a structure by index + */ + const selectStructure = useCallback((index) => { + if (index < 0 || index >= structures.length) { + console.warn(`Invalid structure index: ${index}`); + return; + } + + setSelectedStructureIndex(index); + + // Update metadata for the newly selected structure + const selected = structures[index]; + if (selected) { + const metalIdx = detectMetalCenter(selected.atoms); + let optimalRadius = 3.0; + if (metalIdx != null && selected.atoms[metalIdx]) { + optimalRadius = detectOptimalRadius(selected.atoms[metalIdx], selected.atoms); + } + + setUploadMetadata(prev => ({ + ...prev, + detectedMetalIndex: metalIdx, + suggestedRadius: optimalRadius, + atomCount: selected.atoms.length, + currentStructureName: selected.name, + currentStructureFormat: selected.format + })); + } + }, [structures]); + + /** + * Reset all file-related state + */ + const resetFileState = useCallback(() => { + setStructures([]); + setSelectedStructureIndex(0); + setFileName(""); + setError(null); + setWarnings([]); + setUploadMetadata(null); + }, []); + + /** + * Legacy single-file upload handler (for backwards compatibility) + * Use handleFileUpload for new code + */ + const handleSingleFileUpload = useCallback((e) => { const file = e.target.files?.[0]; if (!file) return; // Reset state setError(null); setWarnings([]); - setFileName(file.name.replace(/\.xyz$/i, "")); + setFileName(file.name.replace(/\.(xyz|cif)$/i, "")); const reader = new FileReader(); @@ -46,7 +287,7 @@ export function useFileUpload() { try { const content = String(ev.target?.result || ""); - // Validate XYZ file + // Use legacy XYZ-only path for backwards compatibility const validation = validateXYZ(content); if (!validation.valid) { @@ -57,31 +298,36 @@ export function useFileUpload() { setWarnings(validation.warnings); } - // Parse atoms const parsedAtoms = parseXYZ(content); - setAtoms(parsedAtoms); - // Auto-detect metal center - const metalIdx = detectMetalCenter(parsedAtoms); + // Wrap in structure format + setStructures([{ + name: file.name.replace(/\.xyz$/i, ""), + atoms: parsedAtoms, + format: 'xyz', + frameIndex: 0, + source: file.name + }]); + setSelectedStructureIndex(0); - // Calculate optimal radius if metal found - let optimalRadius = 3.0; // default + const metalIdx = detectMetalCenter(parsedAtoms); + let optimalRadius = 3.0; if (metalIdx != null && parsedAtoms[metalIdx]) { optimalRadius = detectOptimalRadius(parsedAtoms[metalIdx], parsedAtoms); } - // Store metadata for parent component setUploadMetadata({ detectedMetalIndex: metalIdx, suggestedRadius: optimalRadius, atomCount: parsedAtoms.length, + structureCount: 1, uploadTime: Date.now() }); } catch (err) { console.error("File upload error:", err); setError(err.message); - setAtoms([]); + setStructures([]); setUploadMetadata(null); } }; @@ -89,28 +335,30 @@ 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); }, []); - const resetFileState = useCallback(() => { - setAtoms([]); - setFileName(""); - setError(null); - setWarnings([]); - setUploadMetadata(null); - }, []); - return { + // Multi-structure API + structures, + selectedStructureIndex, + currentStructure, + selectStructure, + + // Backwards compatible API atoms, fileName, error, warnings, uploadMetadata, + + // Handlers handleFileUpload, + handleSingleFileUpload, // Legacy handler resetFileState }; } diff --git a/src/services/reportGenerator.js b/src/services/reportGenerator.js index e9afcb6..189c689 100644 --- a/src/services/reportGenerator.js +++ b/src/services/reportGenerator.js @@ -41,6 +41,8 @@ function escapeHtml(str) { * @param {string} params.analysisMode - 'default' or 'intensive' * @param {Object} params.intensiveMetadata - Intensive analysis metadata * @param {string} params.imgData - Base64 encoded 3D visualization image + * @param {string} params.structureName - Name of the structure (for multi-structure files) + * @param {string} params.fileFormat - File format ('xyz' or 'cif') * @returns {void} Opens report in new window */ export function generatePDFReport({ @@ -56,7 +58,9 @@ export function generatePDFReport({ fileName, analysisMode, intensiveMetadata, - imgData + imgData, + structureName = null, + fileFormat = 'xyz' }) { if (!atoms.length || selectedMetal == null || !bestGeometry) { throw new Error('Missing required data for report generation'); @@ -426,11 +430,12 @@ footer strong {

๐Ÿ”ฌ Q-Shape (Quantitative Shape Analyzer)

Coordination Geometry Analysis Report

-

File: ${escapeHtml(fileName)}.xyz

+

File: ${escapeHtml(fileName)}.${fileFormat}

+ ${structureName ? `

Structure: ${escapeHtml(structureName)}

` : ''}

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. + Cite this: Castro Silva Junior, H. (2025). Q-Shape - Quantitative Shape Analyzer (v1.5.0). Zenodo. https://doi.org/10.5281/zenodo.17717110

@@ -644,10 +649,10 @@ footer strong {