diff --git a/app/Http/Controllers/StudyController.php b/app/Http/Controllers/StudyController.php index 2a77b697..338b7a92 100644 --- a/app/Http/Controllers/StudyController.php +++ b/app/Http/Controllers/StudyController.php @@ -198,7 +198,7 @@ public function renderTabView($tab, $study, $team, $project, $license, $studyFSO break; case 'Datasets': return Inertia::render('Study/Datasets', [ - 'study' => $study->load('users', 'owner', 'studyInvitations', 'datasets'), + 'study' => $study->load('users', 'owner', 'studyInvitations', 'datasets', 'sample.molecules'), 'team' => $team ? $team->load('users', 'owner') : null, 'project' => $project ? $project->load('users', 'owner') : null, 'members' => $study->allUsers(), diff --git a/app/Http/Resources/StudyResource.php b/app/Http/Resources/StudyResource.php index ef8d20f3..cf2d227d 100644 --- a/app/Http/Resources/StudyResource.php +++ b/app/Http/Resources/StudyResource.php @@ -56,6 +56,7 @@ public function toArray($request): array 'external_id' => $this->external_id, 'external_url' => $this->external_url, 'processing_logs' => $this->processing_logs, + 'hifsa_data' => $this->when(! $this->lite, fn () => $this->hifsa_data), 'stats' => [ 'likes' => $this->likesCount(), 'views' => (int) $this->views, diff --git a/app/Support/Draft/HifsaPdfResolver.php b/app/Support/Draft/HifsaPdfResolver.php index d9cf6994..069a2545 100644 --- a/app/Support/Draft/HifsaPdfResolver.php +++ b/app/Support/Draft/HifsaPdfResolver.php @@ -107,7 +107,9 @@ public function resolveExportZip(Study $study): ?FileSystemObject * chemical_shifts: list>, * couplings: list>, * lineshapes: list>, - * qmgi: list> + * qmgi: list>, + * structures?: array, + * atom_maps?: array> * }|null */ public function readCsvData(FileSystemObject $zipFile): ?array @@ -203,6 +205,8 @@ public function readCsvData(FileSystemObject $zipFile): ?array } $this->enrichFromRefCsvs($zip, $parsed); + $parsed['structures'] = $this->extractSpinSystemStructures($zip); + $parsed['atom_maps'] = $this->extractAtomMaps($zip); return $parsed; } finally { @@ -655,7 +659,7 @@ private function mapChemicalShiftRow(array $row): array 'nucleus' => $this->toFloat($row['Nucleus'] ?? null), 'spincount' => $this->toFloat($row['Spincount'] ?? null), 'nucleicount' => $this->toFloat($row['Nucleicount'] ?? null), - 'shift' => $this->toFloat($row['Shift'] ?? null), + 'shift' => $this->toDisplayableShiftPpm($row['Shift'] ?? null), 'response' => $this->toFloat($row['Response'] ?? null), 'line_shape' => $this->nullableString($row['Line shape'] ?? null), 'lrms' => $this->toNullableFiniteFloat($row['LRMS'] ?? null), @@ -687,7 +691,7 @@ private function mapCouplingRow(array $header, array $cells): array 'name' => $this->nullableString($this->cellByHeader($header, $cells, 'Name')), 'shift_from' => $this->nullableString($cells[$shiftIndexes[0] ?? -1] ?? null), 'shift_to' => $this->nullableString($cells[$shiftIndexes[1] ?? -1] ?? null), - 'coupling' => $this->toFloat($this->cellByHeader($header, $cells, 'Coupling')), + 'coupling' => $this->toDisplayableCouplingHz($this->cellByHeader($header, $cells, 'Coupling')), ]; } @@ -822,6 +826,34 @@ private function toFloat(mixed $value): ?float return (float) $value; } + /** + * Cosmic Truth unset chemical shifts use huge sentinels (e.g. -1e12). + */ + private function toDisplayableShiftPpm(mixed $value): ?float + { + $float = $this->toFloat($value); + + if ($float === null || ! is_finite($float) || abs($float) >= 1000) { + return null; + } + + return $float; + } + + /** + * Reject non-physical / sentinel coupling constants. + */ + private function toDisplayableCouplingHz(mixed $value): ?float + { + $float = $this->toFloat($value); + + if ($float === null || ! is_finite($float) || abs($float) >= 1e6) { + return null; + } + + return $float; + } + /** * Like toFloat, but also rejects Cosmic Truth ND sentinels (-1, ±Infinity). */ @@ -845,9 +877,9 @@ private function toNullableFiniteFloat(mixed $value): ?float } /** - * True when hifsa_data already has scores and the section arrays introduced - * for the detail tables. Score-only payloads from earlier parses are treated - * as incomplete so they get upgraded from the export zip. + * True when hifsa_data already has scores, section arrays, and non-empty + * CT structures + atom maps. Empty maps/structures are incomplete so a + * later export with OUTPUT.json / spinsystems.sdf can upgrade the study. */ private function hasStructuredHifsaData(mixed $data): bool { @@ -855,15 +887,151 @@ private function hasStructuredHifsaData(mixed $data): bool return false; } - foreach (['spinsystems', 'chemical_shifts', 'couplings', 'lineshapes', 'qmgi'] as $key) { + foreach (['spinsystems', 'chemical_shifts', 'couplings', 'lineshapes', 'qmgi', 'structures', 'atom_maps'] as $key) { if (! array_key_exists($key, $data) || ! is_array($data[$key])) { return false; } } + if ($data['structures'] === [] || $data['atom_maps'] === []) { + return false; + } + return true; } + /** + * Extract Cosmic Truth `spinsystems.sdf` (true 3D conformers) keyed by + * spin-system name / SDF title line. + * + * @return array + */ + private function extractSpinSystemStructures(ZipArchive $zip): array + { + $sdfName = null; + + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + + if (! is_string($name)) { + continue; + } + + if (strcasecmp(basename($name), 'spinsystems.sdf') === 0) { + $sdfName = $name; + break; + } + } + + if ($sdfName === null) { + return []; + } + + $contents = $zip->getFromName($sdfName); + + if ($contents === false || trim($contents) === '') { + return []; + } + + $structures = []; + + foreach (preg_split('/\$\$\$\$\s*/', $contents) ?: [] as $block) { + $block = trim($block); + + if ($block === '') { + continue; + } + + $lines = preg_split('/\r\n|\r|\n/', $block) ?: []; + $title = trim((string) ($lines[0] ?? '')); + + if ($title === '') { + continue; + } + + if (! str_contains($block, 'M END') && ! str_contains($block, 'M END')) { + continue; + } + + $structures[$title] = $block."\n".'$$$$'."\n"; + } + + return $structures; + } + + /** + * Build Cosmic Truth atom-name → 1-based SDF index maps from OUTPUT.json. + * + * CT labels like C34 / H4 are NOT SDF serials. Each atom entry has `n` + * (label) and `o` (0-based order in the mol/SDF); use o+1 as the SDF index. + * + * @return array> + */ + private function extractAtomMaps(ZipArchive $zip): array + { + $maps = []; + + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + + if (! is_string($name)) { + continue; + } + + if (! preg_match('/_OUTPUT\.json$/i', $name)) { + continue; + } + + $contents = $zip->getFromName($name); + + if ($contents === false || trim($contents) === '') { + continue; + } + + $json = json_decode($contents, true); + + if (! is_array($json)) { + continue; + } + + $spinSystem = trim((string) ($json['n'] ?? '')); + $atoms = $json['a'] ?? null; + + if ($spinSystem === '' || ! is_array($atoms)) { + continue; + } + + $map = []; + + foreach ($atoms as $atom) { + if (! is_array($atom)) { + continue; + } + + $label = trim((string) ($atom['n'] ?? '')); + $order = $atom['o'] ?? null; + + if ($label === '' || ! is_numeric($order)) { + continue; + } + + $index = ((int) $order) + 1; + + if ($index < 1) { + continue; + } + + $map[$label] = $index; + } + + if ($map !== []) { + $maps[$spinSystem] = $map; + } + } + + return $maps; + } + /** * Parse a Cosmic Truth CSV line. Multi-atom group names are exported as * `""C10,C11""` (doubled quotes around a comma-containing name without a diff --git a/app/Support/Hifsa/HifsaAtomLabels.php b/app/Support/Hifsa/HifsaAtomLabels.php new file mode 100644 index 00000000..cc0f75ec --- /dev/null +++ b/app/Support/Hifsa/HifsaAtomLabels.php @@ -0,0 +1,179 @@ + + */ + public static function parseGroup(?string $name): array + { + if ($name === null || trim($name) === '') { + return []; + } + + $parts = preg_split('/\s*,\s*/', trim($name)) ?: []; + $atoms = []; + + foreach ($parts as $part) { + $parsed = self::parseAtom($part); + + if ($parsed !== null) { + $atoms[] = $parsed; + } + } + + return $atoms; + } + + /** + * Parse a single Cosmic Truth atom label such as H14, C10, or H14a. + * + * @return array{element: string, serial: int, suffix: ?string, raw: string}|null + */ + public static function parseAtom(?string $label): ?array + { + if ($label === null) { + return null; + } + + $raw = trim($label); + + if ($raw === '') { + return null; + } + + if (! preg_match('/^([A-Za-z]{1,2})(\d+)([A-Za-z]?)$/', $raw, $matches)) { + return null; + } + + return [ + 'element' => strtoupper($matches[1]), + 'serial' => (int) $matches[2], + 'suffix' => $matches[3] !== '' ? strtolower($matches[3]) : null, + 'raw' => $raw, + ]; + } + + /** + * Pair from/to Cosmic Truth groups for coupling arrows. + * Unequal lengths zip only min(count) pairs (no clamping onto the last atom). + * + * @return list + */ + public static function pairCoupling(?string $from, ?string $to): array + { + $fromAtoms = self::parseGroup($from); + $toAtoms = self::parseGroup($to); + + if ($fromAtoms === [] || $toAtoms === []) { + return []; + } + + // Geminal / same-group couplings like H28,H29 → H28,H29 should connect + // the two partners, not zip identical endpoints onto themselves. + $identicalGroups = + count($fromAtoms) === count($toAtoms) + && count($fromAtoms) >= 2; + + if ($identicalGroups) { + foreach ($fromAtoms as $index => $atom) { + if ( + ($atom['raw'] ?? null) !== ($toAtoms[$index]['raw'] ?? null) + || ($atom['serial'] ?? null) !== ($toAtoms[$index]['serial'] ?? null) + || ($atom['element'] ?? null) !== ($toAtoms[$index]['element'] ?? null) + ) { + $identicalGroups = false; + break; + } + } + } + + if ($identicalGroups) { + $pairs = []; + + for ($i = 0; $i < count($fromAtoms) - 1; $i++) { + $pairs[] = [ + 'from' => $fromAtoms[$i], + 'to' => $fromAtoms[$i + 1], + ]; + } + + return $pairs; + } + + $count = min(count($fromAtoms), count($toAtoms)); + $pairs = []; + + for ($i = 0; $i < $count; $i++) { + $pairs[] = [ + 'from' => $fromAtoms[$i], + 'to' => $toAtoms[$i], + ]; + } + + return $pairs; + } + + /** + * Pick a study molecule only when InChIKey matches the spin system. + * Never fall back to "first SDF" (wrong enantiomer / compound risk). + * + * @param iterable> $molecules + * @param array|null $spinSystem + * @return array|object|null + */ + public static function resolveMolecule(iterable $molecules, ?array $spinSystem = null): mixed + { + $list = []; + + foreach ($molecules as $molecule) { + $list[] = $molecule; + } + + if ($list === []) { + return null; + } + + $inchiKey = is_array($spinSystem) + ? ($spinSystem['inchi_key'] ?? null) + : null; + + if (! is_string($inchiKey) || $inchiKey === '') { + return null; + } + + foreach ($list as $molecule) { + $candidate = self::moleculeValue($molecule, 'inchi_key') + ?? self::moleculeValue($molecule, 'standard_inchi_key'); + + if (is_string($candidate) && strcasecmp($candidate, $inchiKey) === 0) { + $sdf = self::moleculeValue($molecule, 'sdf'); + + if (is_string($sdf) && trim($sdf) !== '') { + return $molecule; + } + } + } + + return null; + } + + /** + * @param object|array $molecule + */ + private static function moleculeValue(object|array $molecule, string $key): mixed + { + if (is_array($molecule)) { + return $molecule[$key] ?? null; + } + + return $molecule->{$key} ?? null; + } +} diff --git a/app/Support/Hifsa/HifsaNmriumFileFilter.php b/app/Support/Hifsa/HifsaNmriumFileFilter.php new file mode 100644 index 00000000..4dfc7a2f --- /dev/null +++ b/app/Support/Hifsa/HifsaNmriumFileFilter.php @@ -0,0 +1,81 @@ + + */ + public const EXCLUDE = [ + 'EXTRA/', + 'hifsa/', + 'HiFSA/', + 'HIFSA/', + ]; + + /** + * True when the study (or study-like array/object) has HiFSA payload or PDF. + */ + public static function studyHasHifsa(mixed $study): bool + { + if ($study === null) { + return false; + } + + $hifsaData = self::value($study, 'hifsa_data'); + $hifsaPdfUrl = self::value($study, 'hifsa_pdf_url'); + + if (is_array($hifsaData) && $hifsaData !== []) { + return true; + } + + return is_string($hifsaPdfUrl) && trim($hifsaPdfUrl) !== ''; + } + + /** + * Wrapper fileFilter for HiFSA samples, or null when filtering is not needed. + * + * Only `exclude` is set — never pass an empty `include` array. + * + * @return array{exclude: list}|null + */ + public static function forStudy(mixed $study): ?array + { + if (! self::studyHasHifsa($study)) { + return null; + } + + return [ + 'exclude' => self::EXCLUDE, + ]; + } + + private static function value(mixed $study, string $key): mixed + { + if (is_array($study)) { + return $study[$key] ?? null; + } + + if (is_object($study)) { + return $study->{$key} ?? null; + } + + return null; + } +} diff --git a/package-lock.json b/package-lock.json index 2841520d..d142a4ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@vue-hero-icons/outline": "^1.7.2", "@vuepic/vue-datepicker": "^3.3.1", "@vueuse/core": "^8.9.4", + "3dmol": "^2.5.5", "axios-retry": "^3.2.5", "crypto-js": "^4.2.0", "dompurify": "^3.0.5", @@ -2312,6 +2313,22 @@ "license": "Apache-2.0", "peer": true }, + "node_modules/3dmol": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/3dmol/-/3dmol-2.5.5.tgz", + "integrity": "sha512-kqNHouGqq3YfW58174tdERvm0XYTmP0tavQKOqIw1ouc2OJ7epkXEFrtEkVXV0clBZT2Ze2xHRC/qxX0u0qCdw==", + "license": "BSD-3-Clause", + "dependencies": { + "iobuffer": "^5.0.0", + "netcdfjs": "^3.0.0", + "pako": "^2.1.0", + "upng-js": "^2.1.0" + }, + "engines": { + "node": ">=16.16.0", + "npm": ">=8.11" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -4309,6 +4326,12 @@ "dev": true, "license": "ISC" }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -5122,6 +5145,15 @@ "license": "MIT", "peer": true }, + "node_modules/netcdfjs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/netcdfjs/-/netcdfjs-3.0.0.tgz", + "integrity": "sha512-LOvT8KkC308qtpUkcBPiCMBtii7ZQCN6LxcVheWgyUeZ6DQWcpSRFV9dcVXLj/2eHZ/bre9tV5HTH4Sf93vrFw==", + "license": "MIT", + "dependencies": { + "iobuffer": "^5.3.2" + } + }, "node_modules/node-releases": { "version": "2.0.54", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", @@ -5284,6 +5316,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6782,6 +6830,21 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/upng-js": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/upng-js/-/upng-js-2.1.0.tgz", + "integrity": "sha512-d3xzZzpMP64YkjP5pr8gNyvBt7dLk/uGI67EctzDuVp4lCZyVMo0aJO6l/VDlgbInJYDY6cnClLoBp29eKWI6g==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.5" + } + }, + "node_modules/upng-js/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index 1e67c7ab..4f4a093d 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@vue-hero-icons/outline": "^1.7.2", "@vuepic/vue-datepicker": "^3.3.1", "@vueuse/core": "^8.9.4", + "3dmol": "^2.5.5", "axios-retry": "^3.2.5", "crypto-js": "^4.2.0", "dompurify": "^3.0.5", diff --git a/resources/js/Pages/Public/Project/Study.vue b/resources/js/Pages/Public/Project/Study.vue index b9c47ba0..4aa46d1b 100644 --- a/resources/js/Pages/Public/Project/Study.vue +++ b/resources/js/Pages/Public/Project/Study.vue @@ -158,6 +158,12 @@ :study="study.data" /> + +
+ +
+
@@ -311,6 +321,7 @@ import { ShareIcon, ClipboardDocumentIcon } from "@heroicons/vue/24/solid"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue"; import SpectraViewer from "@/Shared/SpectraViewer.vue"; import SpectraEditor from "@/Shared/SpectraEditor.vue"; +import HifsaPanel from "@/Shared/HifsaPanel.vue"; export default { components: { @@ -324,6 +335,7 @@ export default { MenuItems, SpectraEditor, SpectraViewer, + HifsaPanel, }, props: [ "study", diff --git a/resources/js/Pages/Upload.vue b/resources/js/Pages/Upload.vue index 4f9cb123..cada2c53 100644 --- a/resources/js/Pages/Upload.vue +++ b/resources/js/Pages/Upload.vue @@ -1753,84 +1753,31 @@
-
- -
- - -
-
+ :hifsa-data=" + selectedStudy.hifsa_data + " + :hifsa-pdf-url=" + selectedStudy.hifsa_pdf_url + " + :molecules=" + selectedStudy + .sample + ?.molecules || + [] + " + :expanded=" + hifsaExpanded + " + id-prefix="upload-hifsa" + @update:expanded=" + hifsaExpanded = + $event + " + /> { this.validation = response.data.report; + const studies = this.validation?.project?.studies; this.validationStatus = true; - this.validation.project.studies.forEach((study) => { - if (study.status == false) { - this.validationStatus = false; - } - }); + if (!Array.isArray(studies) || studies.length === 0) { + this.validationStatus = false; + } else { + studies.forEach((study) => { + if (study.status == false) { + this.validationStatus = false; + } + }); + } }); }, /** @@ -4879,7 +4830,9 @@ export default { updateAutoImportList() { this.studiesToImport = []; this.studies.forEach((study) => { - if (!study.has_nmrium) { + // HiFSA samples must load via NMRium URL+fileFilter; NMRKit has + // no fileFilter and would persist Cosmic Truth EXTRA/ artifacts. + if (!study.has_nmrium && !studyHasHifsa(study)) { this.studiesToImport.push({ projectSlug: this.project.slug, study: study, diff --git a/resources/js/Shared/Hifsa/HifsaMoleculeViewer.vue b/resources/js/Shared/Hifsa/HifsaMoleculeViewer.vue new file mode 100644 index 00000000..69cf5efa --- /dev/null +++ b/resources/js/Shared/Hifsa/HifsaMoleculeViewer.vue @@ -0,0 +1,845 @@ + + + diff --git a/resources/js/Shared/HifsaPanel.vue b/resources/js/Shared/HifsaPanel.vue new file mode 100644 index 00000000..7736851b --- /dev/null +++ b/resources/js/Shared/HifsaPanel.vue @@ -0,0 +1,115 @@ + + + diff --git a/resources/js/Shared/HifsaScoresPanel.vue b/resources/js/Shared/HifsaScoresPanel.vue index e2ceb435..e321359d 100644 --- a/resources/js/Shared/HifsaScoresPanel.vue +++ b/resources/js/Shared/HifsaScoresPanel.vue @@ -211,7 +211,7 @@
{{ group.name }}
-
+ +
+
+ +
+
+ + + + + + + + + + + +
+ {{ column.label }} +
+ {{ + displayCell( + row[column.key], + column + ) + }} +
+
+
+ +