NucleoSpec / templates /index.html
viviandlin
Sync from GitHub: Refactor analyzer into mixin modules for maintainability
98c5587
Raw
History Blame Contribute Delete
346 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NucleoSpec - Nucleic Acid-Silver Complex & Cluster Analyzer</title>
<script src="https://cdn.plot.ly/plotly-2.26.0.min.js"></script>
<!-- JSME Molecule Editor - using official GitHub Pages distribution -->
<script src="https://jsme-editor.github.io/dist/jsme/jsme.nocache.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 50%, #7e22ce 100%);
background-attachment: fixed;
min-height: 100vh;
padding: 20px;
position: relative;
}
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image:
radial-gradient(circle at 20% 50%, rgba(120, 119, 198, 0.3) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(138, 43, 226, 0.2) 0%, transparent 50%),
radial-gradient(circle at 40% 20%, rgba(59, 130, 246, 0.2) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
.container {
position: relative;
z-index: 1;
}
.container {
max-width: 1400px;
min-width: 320px;
margin: 0 auto;
background: white;
border-radius: 15px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
overflow-x: auto;
overflow-y: visible;
}
header {
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 50%, #7e22ce 100%);
color: white;
padding: 30px;
text-align: center;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}
h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
.subtitle {
font-size: 1.1em;
opacity: 0.9;
}
.main-content {
display: grid;
grid-template-columns: minmax(300px, 350px) 1fr;
gap: 0;
min-height: calc(100vh - 200px);
}
.sidebar {
background: #f8f9fa;
padding: 20px;
border-right: 1px solid #e0e0e0;
overflow-y: auto;
max-height: calc(100vh - 150px);
}
.content-area {
padding: 20px;
overflow-y: auto;
max-height: calc(100vh - 150px);
}
/* Responsive: stack on smaller screens */
@media (max-width: 900px) {
.main-content {
grid-template-columns: 1fr;
}
.sidebar {
max-height: none;
border-right: none;
border-bottom: 1px solid #e0e0e0;
}
.content-area {
max-height: none;
}
h1 {
font-size: 1.8em;
}
.subtitle {
font-size: 0.95em;
}
}
@media (max-width: 600px) {
body {
padding: 10px;
}
header {
padding: 20px 15px;
}
h1 {
font-size: 1.5em;
}
.section {
padding: 15px;
}
.btn {
padding: 10px 16px;
font-size: 0.9em;
}
#spectrum-plot,
#isotope-plot {
height: 350px;
}
}
.section {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
overflow-wrap: break-word;
word-wrap: break-word;
}
.section h2 {
color: #667eea;
margin-bottom: 15px;
font-size: 1.3em;
}
.upload-area {
border: 3px dashed #667eea;
border-radius: 8px;
padding: 30px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background: #f8f9ff;
}
.upload-area:hover {
border-color: #764ba2;
background: #f0f2ff;
}
.upload-area.dragover {
border-color: #764ba2;
background: #e8ebff;
}
.btn {
background: linear-gradient(135deg, #2a5298 0%, #7e22ce 100%);
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
cursor: pointer;
font-size: 1em;
transition: all 0.3s;
margin: 5px;
box-shadow: 0 2px 8px rgba(126, 34, 206, 0.3);
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(126, 34, 206, 0.5);
background: linear-gradient(135deg, #3b6bb8 0%, #9333ea 100%);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: #6c757d;
}
input[type="file"] {
display: none;
}
input[type="number"],
input[type="text"],
select {
width: 100%;
padding: 10px;
margin: 8px 0;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 1em;
}
input[type="number"]:focus,
input[type="text"]:focus,
select:focus {
outline: none;
border-color: #667eea;
}
label {
display: block;
margin-top: 15px;
font-weight: 600;
color: #333;
}
#spectrum-plot,
#isotope-plot {
width: 100%;
height: 500px;
margin-top: 20px;
}
.peak-list {
max-height: 400px;
overflow-y: auto;
}
.peak-item {
padding: 15px;
border: 1px solid #e0e0e0;
border-radius: 6px;
margin-bottom: 10px;
cursor: pointer;
transition: all 0.3s;
}
.peak-item:hover {
border-color: #667eea;
background: #f8f9ff;
transform: translateX(5px);
}
.peak-item.selected {
border-color: #667eea;
background: #e8ebff;
}
.peak-mz {
font-size: 1.2em;
font-weight: bold;
color: #667eea;
}
.peak-info {
margin-top: 8px;
color: #666;
font-size: 0.9em;
}
.composition-item {
background: #f8f9fa;
padding: 12px;
border-radius: 6px;
margin-bottom: 8px;
border-left: 4px solid #667eea;
overflow-wrap: break-word;
word-wrap: break-word;
}
.composition-formula {
font-weight: bold;
color: #333;
margin-bottom: 5px;
overflow-wrap: break-word;
word-wrap: break-word;
}
.composition-details {
font-size: 0.9em;
color: #666;
}
.error {
background: #f8d7da;
color: #721c24;
padding: 15px;
border-radius: 6px;
margin-bottom: 15px;
border-left: 4px solid #dc3545;
font-weight: 500;
}
.success {
background: #d4edda;
color: #155724;
padding: 15px;
border-radius: 6px;
margin-bottom: 15px;
border-left: 4px solid #28a745;
font-weight: 500;
}
.info {
background: #d1ecf1;
color: #0c5460;
padding: 15px;
border-radius: 6px;
margin-bottom: 15px;
border-left: 4px solid #17a2b8;
font-weight: 500;
}
#message-area {
min-height: 0;
transition: all 0.3s ease;
}
#message-area:not(:empty) {
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255,255,255,.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin-top: 15px;
}
.stat-card {
background: linear-gradient(135deg, #2a5298 0%, #7e22ce 100%);
color: white;
padding: 15px;
border-radius: 8px;
text-align: center;
box-shadow: 0 3px 12px rgba(126, 34, 206, 0.3);
}
.stat-value {
font-size: 2em;
font-weight: bold;
}
.stat-label {
font-size: 0.9em;
opacity: 0.9;
margin-top: 5px;
}
.overlay-controls {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
/* Onboarding: pulsing glow for Load Sample button */
@keyframes pulseGlow {
0%, 100% { box-shadow: 0 0 5px rgba(39, 174, 96, 0.4); }
50% { box-shadow: 0 0 20px rgba(39, 174, 96, 0.8), 0 0 40px rgba(39, 174, 96, 0.3); }
}
.btn-pulse {
animation: pulseGlow 2s ease-in-out infinite;
}
/* Onboarding: click-peak prompt banner */
@keyframes slideDown {
from { transform: translateY(-100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.click-peak-prompt {
animation: slideDown 0.4s ease-out;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 12px 20px;
border-radius: 8px;
margin-bottom: 10px;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.95em;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.click-peak-prompt .dismiss-btn {
background: rgba(255,255,255,0.2);
border: none;
color: white;
cursor: pointer;
border-radius: 50%;
width: 24px;
height: 24px;
font-size: 14px;
line-height: 24px;
text-align: center;
flex-shrink: 0;
margin-left: 12px;
}
/* Onboarding: first-visit spotlight overlay */
.onboarding-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.onboarding-card {
background: white;
border-radius: 16px;
padding: 40px;
max-width: 480px;
text-align: center;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
animation: scaleIn 0.3s ease-out;
}
@keyframes scaleIn {
from { transform: scale(0.9); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>NucleoSpec <span style="font-size: 0.5em; font-weight: normal;">Nucleic Acid-Silver Complex & Cluster Analyzer</span></h1>
<p class="subtitle">Advanced analysis for DNA-stabilized silver nanoclusters with composition finding and identification</p>
<div style="margin-top: 10px; display: flex; gap: 20px; justify-content: center;">
<a href="#" onclick="showHelpModal(); return false;"
style="color: rgba(255,255,255,0.8); text-decoration: none; font-size: 0.85em; border-bottom: 1px dotted rgba(255,255,255,0.5); padding-bottom: 2px; transition: all 0.3s;">
Help & Tutorial
</a>
<a href="#" onclick="showAboutModal(); return false;"
style="color: rgba(255,255,255,0.8); text-decoration: none; font-size: 0.85em; border-bottom: 1px dotted rgba(255,255,255,0.5); padding-bottom: 2px; transition: all 0.3s;">
About & Licenses
</a>
</div>
<div style="margin-top: 12px; display: flex; gap: 20px; justify-content: center; flex-wrap: wrap;">
<span style="color: rgba(255,255,255,0.9); font-size: 0.8em; background: rgba(255,255,255,0.15); padding: 4px 12px; border-radius: 15px;">
Free and open to all users • No login required
</span>
<span style="color: rgba(255,255,255,0.9); font-size: 0.8em; background: rgba(255,255,255,0.15); padding: 4px 12px; border-radius: 15px;">
Your data stays private • Not stored on server
</span>
</div>
<div style="margin-top: 8px;">
<span style="color: rgba(255,255,255,0.75); font-size: 0.75em;">
Please cite: Lin &amp; Copp, <em>ChemRxiv</em> 2026.
<a href="https://doi.org/10.26434/chemrxiv.15004738/v1" target="_blank" style="color: rgba(255,255,255,0.9); text-decoration: underline;">DOI</a>
</span>
</div>
</header>
<div class="main-content">
<!-- Sidebar -->
<div class="sidebar">
<!-- How to use - at the top -->
<div style="padding: 10px; background: #fff9e6; border-radius: 4px; border-left: 4px solid #f1c40f; margin-bottom: 15px;">
<strong style="color: #856404;">Quick start:</strong>
<ol style="margin: 5px 0; padding-left: 20px; font-size: 0.9em;">
<li>Upload spectrum file</li>
<li>Choose mode</li>
<li>Click any peak to analyze compositions</li>
<li>Check boxes to overlay theoretical patterns</li>
<li>Use "Manage Adducts" and "Draw Structure" if there is a need</li>
</ol>
</div>
<div class="section">
<h2>Upload Data</h2>
<div class="upload-area" id="uploadArea">
<p>Drop TXT file here or click to browse</p>
<input type="file" id="fileInput" accept=".txt,.csv">
<button class="btn" onclick="document.getElementById('fileInput').click()">
Select File
</button>
<button class="btn btn-pulse" id="loadSampleBtn" onclick="loadSampleData()"
style="background: #27ae60; margin-top: 10px;">
Load Sample
</button>
<a href="/download_sample" download="sample_spectrum.txt"
class="btn" style="background: #3498db; margin-top: 8px; display: block; text-align: center; text-decoration: none;">
Download Sample Format
</a>
</div>
<!-- Reset Button -->
<div id="reset-section" style="display: none; margin-top: 15px;">
<button class="btn" onclick="resetAnalysis()"
style="background: #e74c3c; width: 100%;">
Reset Analysis
</button>
<small style="color: #666; display: block; margin-top: 5px; text-align: center;">
Clear all data and start fresh
</small>
</div>
</div>
<div class="section">
<h2>Settings</h2>
<!-- Hidden resolution input - auto-detected from spectrum -->
<input type="hidden" id="resolution" value="20000">
<!-- Analysis Mode Selector -->
<div style="margin-bottom: 15px; padding: 12px; background: #f5f5f5; border-radius: 8px;">
<label style="font-weight: bold; display: block; margin-bottom: 10px; color: #333;">Analysis Mode:</label>
<div style="display: flex; flex-direction: column; gap: 8px;">
<label style="display: flex; align-items: center; gap: 10px; padding: 10px; background: white; border-radius: 6px; cursor: pointer; border: 2px solid transparent; transition: all 0.2s;" class="mode-option" data-mode="dna">
<input type="radio" name="analysis-mode" value="dna" checked onchange="switchAnalysisMode('dna')" style="width: 18px; height: 18px;">
<div>
<strong style="color: #4CAF50;">DNA-Ag<sub>N</sub></strong>
<div style="font-size: 0.8em; color: #666;">Single-stranded DNA + Silver nanoclusters</div>
</div>
</label>
<label style="display: flex; align-items: center; gap: 10px; padding: 10px; background: white; border-radius: 6px; cursor: pointer; border: 2px solid transparent; transition: all 0.2s;" class="mode-option" data-mode="complex">
<input type="radio" name="analysis-mode" value="complex" onchange="switchAnalysisMode('complex')" style="width: 18px; height: 18px;">
<div>
<strong style="color: #2196F3;">Ag(I)-DNA/XNA Complex</strong>
<div style="font-size: 0.8em; color: #666;">Double-stranded DNA/XNA + Silver atoms</div>
</div>
</label>
<label style="display: flex; align-items: center; gap: 10px; padding: 10px; background: white; border-radius: 6px; cursor: pointer; border: 2px solid transparent; transition: all 0.2s;" class="mode-option" data-mode="xna">
<input type="radio" name="analysis-mode" value="xna" onchange="switchAnalysisMode('xna')" style="width: 18px; height: 18px;">
<div>
<strong style="color: #ff9800;">Custom XNA</strong>
<div style="font-size: 0.8em; color: #666;">Custom nucleotide formula</div>
</div>
</label>
</div>
</div>
<!-- ========== DNA-AgN MODE ========== -->
<div id="mode-dna-section" class="mode-section">
<div style="padding: 12px; background: #e8f5e9; border-left: 3px solid #4CAF50; border-radius: 4px;">
<div style="font-weight: bold; margin-bottom: 8px; color: #2e7d32;">DNA-Ag<sub>N</sub> Mode</div>
<label for="dna-sequence" style="font-weight: bold; display: block; margin-bottom: 4px;">DNA Sequence:</label>
<input type="text" id="dna-sequence" placeholder="e.g., CACCTAGCGA" oninput="updateDNAMass()" style="width: 100%; padding: 10px; border: 1px solid #4CAF50; border-radius: 6px; font-size: 1em;">
<small id="dna-seq-hint" style="color: #666; display: block; margin-top: 5px;">Single-stranded DNA sequence for silver nanocluster analysis</small>
<button id="apply-dna-btn" onclick="applyDNASettings()" style="width: 100%; padding: 10px; margin-top: 10px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 1em;">
✓ Apply DNA Settings
</button>
<div id="dna-applied-status" style="display: none; padding: 10px; background: #d4edda; border: 1px solid #c3e6cb; border-radius: 4px; margin-top: 10px; color: #155724;">
<strong>✓ DNA Settings Applied:</strong>
<div id="dna-applied-details" style="margin-top: 5px; font-size: 0.9em;"></div>
</div>
</div>
</div>
<!-- ========== COMPLEX MODE ========== -->
<div id="mode-complex-section" class="mode-section" style="display: none;">
<div style="padding: 12px; background: #e3f2fd; border-left: 3px solid #2196F3; border-radius: 4px;">
<div style="font-weight: bold; margin-bottom: 6px; color: #1565c0;">Ag(I)-DNA/XNA Complex Mode</div>
<!-- Use XNA checkbox -->
<label style="font-size: 0.85em; display: flex; align-items: center; cursor: pointer; margin-bottom: 10px;">
<input type="checkbox" id="complex-xna-toggle" onchange="toggleComplexXNA()" style="margin-right: 4px;">
<span style="color: #ff9800;">Use XNA</span>
</label>
<!-- DNA Strands (shown by default) -->
<div id="complex-dna-inputs">
<!-- Strand 1 -->
<div style="margin-bottom: 12px; padding: 10px; background: #fff; border: 1px solid #90caf9; border-radius: 4px;">
<label style="font-weight: bold; display: block; margin-bottom: 8px;">Strand 1 (5' → 3'):</label>
<input type="text" id="complex-sequence-1" placeholder="e.g., CACCTAGCGA" oninput="updateComplexMass()" style="width: 100%; padding: 10px; border: 1px solid #2196F3; border-radius: 6px; font-size: 1em; box-sizing: border-box;">
</div>
<!-- Strand 2 -->
<div style="margin-bottom: 12px; padding: 10px; background: #fff; border: 1px solid #90caf9; border-radius: 4px;">
<label style="font-weight: bold; display: block; margin-bottom: 8px;">Strand 2 (5' → 3'):</label>
<input type="text" id="dna-sequence-2" placeholder="e.g., TCGCTAGGTG" oninput="updateComplexMass()" style="width: 100%; padding: 10px; border: 1px solid #2196F3; border-radius: 6px; font-size: 1em; box-sizing: border-box;">
</div>
</div>
<!-- XNA options (hidden by default) -->
<div id="complex-xna-options" style="display: none; margin-bottom: 12px; padding: 10px; background: #fff9e6; border: 1px solid #ffcc80; border-radius: 4px;">
<div style="margin-bottom: 6px;">
<label style="font-size: 0.85em; display: block; margin-bottom: 2px;">XNA Name:</label>
<input type="text" id="complex-xna-name" placeholder="e.g., TNA, PNA, LNA" style="width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px; font-size: 0.9em; box-sizing: border-box;">
</div>
<div style="margin-bottom: 6px;">
<label style="font-size: 0.85em; display: block; margin-bottom: 2px;">Strand 1 Formula: <span style="color: red;">*</span></label>
<input type="text" id="complex-xna-formula-1" placeholder="e.g., C100H120N40O60P10" oninput="updateComplexMass()" style="width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px; font-size: 0.9em; box-sizing: border-box;">
</div>
<div>
<label style="font-size: 0.85em; display: block; margin-bottom: 2px;">Strand 2 Formula: <span style="color: red;">*</span></label>
<input type="text" id="complex-xna-formula-2" placeholder="e.g., C100H120N40O60P10" oninput="updateComplexMass()" style="width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px; font-size: 0.9em; box-sizing: border-box;">
</div>
</div>
<button id="apply-complex-btn" onclick="applyComplexSettings()" style="width: 100%; padding: 10px; margin-top: 10px; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 1em;">
✓ Apply Complex Settings
</button>
<div id="complex-applied-status" style="display: none; padding: 10px; background: #d4edda; border: 1px solid #c3e6cb; border-radius: 4px; margin-top: 10px; color: #155724;">
<strong>✓ Complex Settings Applied:</strong>
<div id="complex-applied-details" style="margin-top: 5px; font-size: 0.9em;"></div>
</div>
</div>
</div>
<!-- ========== XNA MODE ========== -->
<div id="mode-xna-section" class="mode-section" style="display: none;">
<div style="padding: 12px; background: #fff9e6; border-left: 3px solid #ff9800; border-radius: 4px;">
<div style="font-weight: bold; margin-bottom: 10px; color: #e65100;">Custom XNA Mode</div>
<div style="margin-bottom: 10px;">
<label style="font-weight: bold; display: block; margin-bottom: 4px;">Name: <span style="color: red;">*</span></label>
<input type="text" id="xna-name" placeholder="e.g., TNA, PNA, LNA" style="width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px;">
</div>
<div style="margin-bottom: 10px;">
<label style="font-weight: bold; display: block; margin-bottom: 4px;">Chemical Formula (per nucleotide): <span style="color: red;">*</span></label>
<input type="text" id="xna-formula" placeholder="e.g., C10H13N5O6P8" oninput="updateXNAMass()" style="width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px;">
<small style="color: #666; display: block; margin-top: 3px;">Formula for ONE nucleotide (mass calculated automatically)</small>
</div>
<!-- XNA Mass Display -->
<div id="xna-mass-display" style="display: none; margin-top: 10px; margin-bottom: 10px; padding: 10px; background: #fff3e0; border-left: 3px solid #ff9800; border-radius: 4px;">
<div style="font-weight: bold; color: #f57c00; margin-bottom: 5px;">Calculated XNA Mass:</div>
<div id="xna-mass-value" style="font-family: monospace; color: #333;"></div>
</div>
<button id="apply-xna-btn" style="width: 100%; padding: 10px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 1em;">
✓ Apply XNA Settings
</button>
<small style="color: #666; display: block; margin-top: 5px; text-align: center;">
Click to confirm and use these XNA settings for analysis
</small>
<div id="xna-applied-status" style="display: none; padding: 10px; background: #d4edda; border: 1px solid #c3e6cb; border-radius: 4px; margin-top: 10px; color: #155724;">
<strong>✓ XNA Settings Applied:</strong>
<div id="xna-applied-details" style="margin-top: 5px; font-size: 0.9em;"></div>
</div>
</div>
</div>
<div id="dna-mass-display" style="margin-top: 8px; padding: 8px; background: #f0f8ff; border-radius: 4px; display: none;">
<strong style="color: #1976d2;">DNA Mass:</strong> <span id="dna-mass-value" style="font-family: monospace;"></span>
</div>
<!-- Adduct Manager Button (always visible) -->
<div id="adduct-manager-section">
<button class="btn" onclick="openAdductManager()" style="margin-top: 15px; width: 100%; background: #9b59b6;">
⚛️ Manage Adducts
</button>
<small style="color: #666; display: block; margin-top: 5px; text-align: center;">
Add custom adducts for analysis
</small>
</div>
<!-- Structure Drawing Button (JSME) -->
<button class="btn" onclick="openStructureDrawer()" style="margin-top: 10px; width: 100%; background: #e67e22;">
✏️ Draw Structure
</button>
<small style="color: #666; display: block; margin-top: 5px; text-align: center;">
Draw molecule to get formula (JSME)
</small>
</div>
</div>
<!-- Main Content -->
<div class="content-area">
<div class="section">
<h2>Mass Spectrum</h2>
<!-- Peak Detection Info -->
<div style="padding: 10px; background: #e3f2fd; border-left: 4px solid #2196f3; border-radius: 4px; margin-bottom: 15px; font-size: 0.9em;">
<strong style="color: #1976d2;">Tip:</strong> Click directly on any peak in the spectrum to analyze it, even if it's not automatically detected. Small peaks below 1% intensity may not show markers but can still be analyzed by clicking.
</div>
<!-- Charge State Legend -->
<div id="charge-legend" style="display: none; padding: 10px; background: #f8f9fa; border-radius: 6px; margin-bottom: 15px; border-left: 4px solid #667eea;">
<div style="font-weight: bold; margin-bottom: 8px; color: #667eea;">Charge State (z) Legend:</div>
<div style="display: flex; flex-wrap: wrap; gap: 15px;">
<div style="display: flex; align-items: center; gap: 5px;">
<div style="width: 16px; height: 16px; background: #e74c3c; border-radius: 3px; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2);"></div>
<span style="font-size: 0.9em;">z = 1</span>
</div>
<div style="display: flex; align-items: center; gap: 5px;">
<div style="width: 16px; height: 16px; background: #3498db; border-radius: 3px; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2);"></div>
<span style="font-size: 0.9em;">z = 2</span>
</div>
<div style="display: flex; align-items: center; gap: 5px;">
<div style="width: 16px; height: 16px; background: #2ecc71; border-radius: 3px; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2);"></div>
<span style="font-size: 0.9em;">z = 3</span>
</div>
<div style="display: flex; align-items: center; gap: 5px;">
<div style="width: 16px; height: 16px; background: #f39c12; border-radius: 3px; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2);"></div>
<span style="font-size: 0.9em;">z = 4</span>
</div>
<div style="display: flex; align-items: center; gap: 5px;">
<div style="width: 16px; height: 16px; background: #9b59b6; border-radius: 3px; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2);"></div>
<span style="font-size: 0.9em;">z = 5</span>
</div>
<div style="display: flex; align-items: center; gap: 5px;">
<div style="width: 16px; height: 16px; background: #f8a5c2; border-radius: 3px; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2);"></div>
<span style="font-size: 0.9em;">z = 6</span>
</div>
<div style="display: flex; align-items: center; gap: 5px;">
<div style="width: 16px; height: 16px; background: #95a5a6; border-radius: 3px; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2);"></div>
<span style="font-size: 0.9em;">z ≥ 7</span>
</div>
</div>
</div>
<div id="spectrum-plot"></div>
<!-- Website Messages (Always Visible) -->
<div id="message-area-container" style="margin-top: 20px;">
<div style="background: #f8f9fa; border-radius: 8px; padding: 15px; border: 2px solid #e9ecef; box-shadow: 0 2px 8px rgba(0,0,0,0.08);">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<span style="font-size: 1.2em;">💬</span>
<h3 style="color: #495057; font-size: 1em; margin: 0; font-weight: 600;">Website Messages</h3>
</div>
<div id="message-area" style="min-height: 50px; display: flex; align-items: center; justify-content: center; color: #999; font-style: italic;">
No messages
</div>
</div>
</div>
<!-- Small peak zoom display -->
<div id="peak-zoom-section" style="display: none; margin-top: 20px;">
<h3 style="color: #667eea; font-size: 1.1em; margin-bottom: 10px;">Clicked Peak Region</h3>
<div id="peak-zoom-plot" style="width: 100%; height: 300px; border: 2px solid #667eea; border-radius: 8px;"></div>
</div>
</div>
<div class="section" id="peaks-section" style="display: none;">
<h2>Detected Peaks</h2>
<div class="stats" id="stats-area"></div>
<div class="peak-list" id="peak-list"></div>
</div>
<div class="section" id="composition-section" style="display: none;">
<h2>Composition Analysis</h2>
<div style="padding: 10px 12px; margin-bottom: 15px; background: #f8f0f2; border-left: 3px solid #c9a0a9; border-radius: 4px; font-size: 0.85em; color: #666;">
<strong>Important:</strong> Adduct peaks following cluster peaks may require manual validation.
The tool assigns compositions based on best pattern match, but cannot verify
if the calculated N₀ is chemically feasible for your system.
</div>
<div id="composition-details" style="max-height: 500px; overflow-y: auto;"></div>
</div>
</div>
</div>
</div>
<script>
let currentSpectrum = null;
let currentPeaks = []; // Initialize as empty array
let selectedPeak = null;
let theoryOverlays = {}; // Track active theory overlays by composition ID
let currentClickedPeakMz = null; // Track the current clicked peak m/z for zoom
let deletedCompositions = {}; // Track deleted compositions: { peakMz: Set([compIds]) }
let peakAnalysisCache = {}; // Cache peak analysis results to avoid re-analyzing
let autoDetectedResolution = 20000; // Auto-detected resolution (default 20000)
// Security: HTML escape function to prevent XSS (fast string-based version)
function escapeHtml(text) {
if (text === null || text === undefined) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Parse a chemical formula string into an atoms dictionary
// e.g., "C10H15N5O6P" -> {C: 10, H: 15, N: 5, O: 6, P: 1}
function parseFormulaToAtoms(formula) {
const atoms = {};
// Match element symbols followed by optional numbers
const regex = /([A-Z][a-z]?)(\d*)/g;
let match;
while ((match = regex.exec(formula)) !== null) {
const element = match[1];
const count = match[2] ? parseInt(match[2]) : 1;
if (element) {
atoms[element] = (atoms[element] || 0) + count;
}
}
return atoms;
}
// Combine two chemical formulas (for complex mode)
// e.g., "C100H120N40O60P10" + "C100H120N40O60P10" -> "C200H240N80O120P20"
function combineFormulas(formula1, formula2) {
const atoms1 = parseFormulaToAtoms(formula1);
const atoms2 = parseFormulaToAtoms(formula2);
// Combine atoms from both formulas
const combined = {...atoms1};
for (const element in atoms2) {
combined[element] = (combined[element] || 0) + atoms2[element];
}
// Format back to string (Hill notation: C first, then H, then alphabetical)
let result = '';
if (combined['C']) {
result += 'C' + combined['C'];
delete combined['C'];
}
if (combined['H']) {
result += 'H' + combined['H'];
delete combined['H'];
}
const sortedElements = Object.keys(combined).sort();
for (const element of sortedElements) {
if (combined[element] > 0) {
result += element + combined[element];
}
}
return result;
}
// Format chemical formula with subscripts (e.g., "C10H15N5O6P" -> "C₁₀H₁₅N₅O₆P")
// BUT: Don't subscript numbers after + or - (adducts like +1Ag, -2Cl)
function formatFormula(formula) {
if (!formula) return '';
const subscriptDigits = {
'0': '₀', '1': '₁', '2': '₂', '3': '₃', '4': '₄',
'5': '₅', '6': '₆', '7': '₇', '8': '₈', '9': '₉'
};
// Split formula into parts: base formula and adducts (parts with +/-)
// Example: "C99H122N45O56P9+1Ag-2Cl" -> ["C99H122N45O56P9", "+1Ag", "-2Cl"]
const parts = formula.split(/([+-]\d+[A-Z][a-z]*)/g);
return parts.map(part => {
if (part.startsWith('+') || part.startsWith('-')) {
// This is an adduct - don't subscript the numbers
return part;
} else {
// This is base formula - subscript all numbers
return part.replace(/(\d+)/g, (match) => {
return match.split('').map(d => subscriptDigits[d]).join('');
});
}
}).join('');
}
// Copy plain text formula to clipboard (no subscripts)
function copyFormula(formula, event) {
event.stopPropagation();
if (!formula) return;
// Try modern clipboard API first, with fallback for older browsers/non-HTTPS
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(formula).then(() => {
showMessage('Formula copied: ' + formula, 'success');
}).catch(err => {
// Fallback if clipboard API fails
fallbackCopyToClipboard(formula);
});
} else {
// Fallback for non-secure contexts or older browsers
fallbackCopyToClipboard(formula);
}
}
// Fallback copy function using textarea
function fallbackCopyToClipboard(text) {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
textArea.style.top = '0';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
showMessage('Formula copied: ' + text, 'success');
} else {
showMessage('Failed to copy formula', 'error');
}
} catch (err) {
showMessage('Failed to copy formula', 'error');
console.error('Fallback copy failed:', err);
}
document.body.removeChild(textArea);
}
// Global variables to store applied settings for each mode
let appliedXNASettings = null;
let appliedDNASettings = null;
let appliedComplexSettings = null;
// Global variable to track current analysis mode: 'dna', 'complex', or 'xna'
let currentAnalysisMode = 'dna';
// Switch between analysis modes (DNA-AgN, Complex, XNA)
function switchAnalysisMode(mode) {
currentAnalysisMode = mode;
// Hide all mode sections
document.getElementById('mode-dna-section').style.display = 'none';
document.getElementById('mode-complex-section').style.display = 'none';
document.getElementById('mode-xna-section').style.display = 'none';
// Show selected mode section
document.getElementById(`mode-${mode}-section`).style.display = 'block';
// Update visual feedback for mode options
document.querySelectorAll('.mode-option').forEach(option => {
option.style.border = '2px solid transparent';
});
const selectedOption = document.querySelector(`.mode-option[data-mode="${mode}"]`);
if (selectedOption) {
const borderColors = { 'dna': '#4CAF50', 'complex': '#2196F3', 'xna': '#ff9800' };
selectedOption.style.border = `2px solid ${borderColors[mode]}`;
}
// Clear applied settings when switching modes
if (mode !== 'xna') {
appliedXNASettings = null;
const xnaStatus = document.getElementById('xna-applied-status');
if (xnaStatus) xnaStatus.style.display = 'none';
}
if (mode !== 'dna') {
appliedDNASettings = null;
const dnaStatus = document.getElementById('dna-applied-status');
if (dnaStatus) dnaStatus.style.display = 'none';
}
if (mode !== 'complex') {
appliedComplexSettings = null;
const complexStatus = document.getElementById('complex-applied-status');
if (complexStatus) complexStatus.style.display = 'none';
}
// Hide DNA mass display when switching modes
const massDisplay = document.getElementById('dna-mass-display');
if (massDisplay) massDisplay.style.display = 'none';
// Adduct Manager is always visible (all modes can use custom adducts)
// Update strands label based on mode (Complex uses "Complexes (nd)", others use "Strands (ns)")
const strandsLabel = document.getElementById('manual-strands-label');
if (strandsLabel) {
strandsLabel.textContent = mode === 'complex' ? 'Duplexes (nd):' : 'Strands (ns):';
}
// Update param mode hint for Complex mode
const paramHint = document.getElementById('param-mode-hint');
if (paramHint) {
if (mode === 'complex') {
paramHint.innerHTML = 'Complex mode: N₀ = 0 always, Q<sub>cl</sub> = nAg';
} else {
paramHint.innerHTML = 'N₀ will be calculated as: N₀ = nAg - Q<sub>cl</sub>';
}
}
// For Complex mode, hide Qcl input (since Qcl = nAg always)
const qclContainer = document.getElementById('manual-qcl-container');
if (qclContainer) {
qclContainer.style.display = mode === 'complex' ? 'none' : 'block';
}
// Update search mode hint for Complex mode
const searchHint = document.getElementById('search-mode-hint');
if (searchHint) {
if (mode === 'complex') {
searchHint.textContent = 'Complex mode: N₀ = 0 always, Qcl = nAg (will search with/without adducts)';
} else {
searchHint.textContent = 'System will search across all N₀ values (Qcl = 0 to nAg) to find best match';
}
}
// Show mode-specific message
const modeMessages = {
'dna': 'DNA-AgN mode: Analyzing single-stranded DNA with silver nanoclusters (N₀/Qcl framework)',
'complex': 'Complex mode: Analyzing double-stranded DNA with silver (N₀ = 0, Qcl = nAg)',
'xna': 'XNA mode: Analyzing custom nucleic acid with user-defined formula'
};
showMessage(`✓ ${modeMessages[mode]}`, 'success');
console.log(`✓ Switched to ${mode} mode`);
}
// Apply XNA settings
function applyXNASettings() {
const name = document.getElementById('xna-name').value.trim();
const formula = document.getElementById('xna-formula').value.trim();
// Validate name (required)
if (!name) {
showMessage('⚠️ Please enter a Name before applying!', 'error');
return;
}
// Validate formula (required)
if (!formula) {
showMessage('⚠️ Please enter a Chemical Formula before applying!', 'error');
return;
}
// Calculate mass for display (client-side)
const atomicMasses = {
'H': 1.007825, 'C': 12.0, 'N': 14.003074, 'O': 15.994915,
'P': 30.973762, 'S': 31.972071, 'F': 18.998403,
'Cl': 34.968853, 'Br': 78.918338, 'I': 126.904473
};
let calculatedMass = 0;
try {
const elementRegex = /([A-Z][a-z]?)(\d*)/g;
let match;
while ((match = elementRegex.exec(formula)) !== null) {
const element = match[1];
const count = match[2] ? parseInt(match[2]) : 1;
if (atomicMasses[element]) {
calculatedMass += atomicMasses[element] * count;
}
}
} catch (e) {
calculatedMass = 0;
}
// Store the applied settings (mass will be calculated from formula by backend)
appliedXNASettings = {
name: name || 'XNA',
formula: formula
};
// Show applied status
const statusDiv = document.getElementById('xna-applied-status');
const detailsDiv = document.getElementById('xna-applied-details');
statusDiv.style.display = 'block';
const massDisplay = calculatedMass > 0 ? `${calculatedMass.toFixed(4)} Da` : 'Calculated from formula';
detailsDiv.innerHTML = `
<div><strong>Name:</strong> ${escapeHtml(appliedXNASettings.name)}</div>
<div><strong>Formula:</strong> ${escapeHtml(appliedXNASettings.formula)}</div>
<div><strong>Mass (per nucleotide):</strong> ${massDisplay}</div>
`;
showMessage(`✓ XNA settings applied! Ready to analyze with ${escapeHtml(appliedXNASettings.name)} (${massDisplay})`, 'success');
}
// Apply DNA-AgN settings
function applyDNASettings() {
const sequence = document.getElementById('dna-sequence').value.trim().toUpperCase();
// Validate sequence
if (!sequence) {
showMessage('⚠️ Please enter a DNA sequence before applying!', 'error');
return;
}
// Validate only ATCG
const validBases = /^[ATCG]+$/;
if (!validBases.test(sequence)) {
showMessage('⚠️ Invalid sequence! Only A, T, C, G bases are allowed.', 'error');
return;
}
// Store the applied settings
appliedDNASettings = {
sequence: sequence,
length: sequence.length
};
// Show applied status with mass from backend
const statusDiv = document.getElementById('dna-applied-status');
const detailsDiv = document.getElementById('dna-applied-details');
statusDiv.style.display = 'block';
detailsDiv.innerHTML = `
<div><strong>Sequence:</strong> ${escapeHtml(sequence)}</div>
<div><strong>Length:</strong> ${sequence.length} bases</div>
<div><strong>Mass:</strong> <span id="dna-applied-mass">Calculating...</span></div>
`;
// Fetch mass from backend
fetch('/calculate_dna_mass', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dna_sequence: sequence })
})
.then(response => response.json())
.then(data => {
const massSpan = document.getElementById('dna-applied-mass');
if (data.error) {
massSpan.innerHTML = `<span style="color: #e74c3c;">Error: ${escapeHtml(data.error)}</span>`;
} else {
massSpan.innerHTML = `${data.mass.toFixed(4)} Da`;
showMessage(`✓ DNA settings applied! Mass: ${data.mass.toFixed(4)} Da`, 'success');
}
})
.catch(error => {
document.getElementById('dna-applied-mass').innerHTML = `<span style="color: #e74c3c;">Error</span>`;
});
showMessage(`✓ DNA settings applied! Ready to analyze with ${sequence.length}-mer DNA sequence.`, 'success');
}
// Apply Complex settings
function applyComplexSettings() {
const sequence1 = document.getElementById('complex-sequence-1').value.trim().toUpperCase();
const sequence2 = document.getElementById('dna-sequence-2').value.trim().toUpperCase();
const metal = 'Ag';
// Check if XNA mode is enabled
const useXNA = document.getElementById('complex-xna-toggle').checked;
// Get XNA data if enabled (now with two formulas)
const xnaFormula1 = useXNA ? document.getElementById('complex-xna-formula-1').value.trim() : '';
const xnaFormula2 = useXNA ? document.getElementById('complex-xna-formula-2').value.trim() : '';
const xnaName = useXNA ? (document.getElementById('complex-xna-name').value.trim() || 'XNA') : '';
// Validate based on mode
if (useXNA) {
// XNA mode: require both formulas
if (!xnaFormula1) {
showMessage('⚠️ Please enter Strand 1 XNA formula!', 'error');
return;
}
if (!xnaFormula2) {
showMessage('⚠️ Please enter Strand 2 XNA formula!', 'error');
return;
}
} else {
// DNA mode: require sequences
const validBases = /^[ATCG]+$/;
if (!sequence1) {
showMessage('⚠️ Please enter Strand 1 sequence!', 'error');
return;
}
if (!validBases.test(sequence1)) {
showMessage('⚠️ Invalid Strand 1 sequence! Only A, T, C, G bases are allowed.', 'error');
return;
}
if (!sequence2) {
showMessage('⚠️ Please enter Strand 2 sequence!', 'error');
return;
}
if (!validBases.test(sequence2)) {
showMessage('⚠️ Invalid Strand 2 sequence! Only A, T, C, G bases are allowed.', 'error');
return;
}
}
// Store the applied settings
// For XNA complex: combine both strand formulas into one
const combinedXnaFormula = useXNA ? combineFormulas(xnaFormula1, xnaFormula2) : '';
// Check if both strands are the same (for single strand search optimization)
const sameStrands = useXNA ? (xnaFormula1 === xnaFormula2) : (sequence1 === sequence2);
appliedComplexSettings = {
sequence1: sequence1,
sequence2: sameStrands ? '' : sequence2, // Empty if same as seq1
metal: metal,
sameStrands: sameStrands, // Flag to indicate identical strands
xna: useXNA ? {
formula1: xnaFormula1,
formula2: sameStrands ? '' : xnaFormula2, // Empty if same as formula1
combinedFormula: combinedXnaFormula,
name: xnaName,
sameStrands: sameStrands
} : null
};
// Show applied status with mass
const statusDiv = document.getElementById('complex-applied-status');
const detailsDiv = document.getElementById('complex-applied-details');
// Helper function to calculate mass from formula
function calcMassFromFormula(formula) {
const atomicMasses = {
'H': 1.007825, 'C': 12.0, 'N': 14.003074, 'O': 15.994915,
'P': 30.973762, 'S': 31.972071, 'F': 18.998403,
'Cl': 34.968853, 'Br': 78.918338, 'I': 126.904473
};
let mass = 0;
try {
const elementRegex = /([A-Z][a-z]?)(\d*)/g;
let match;
while ((match = elementRegex.exec(formula)) !== null) {
const element = match[1];
const count = match[2] ? parseInt(match[2]) : 1;
if (atomicMasses[element]) {
mass += atomicMasses[element] * count;
}
}
} catch (e) {
mass = 0;
}
return mass;
}
statusDiv.style.display = 'block';
if (useXNA) {
// Calculate mass for each strand and combined
const mass1 = calcMassFromFormula(xnaFormula1);
const mass2 = calcMassFromFormula(xnaFormula2);
const totalMass = mass1 + mass2;
const mass1Display = mass1 > 0 ? `${mass1.toFixed(4)} Da` : 'N/A';
const mass2Display = mass2 > 0 ? `${mass2.toFixed(4)} Da` : 'N/A';
const totalDisplay = totalMass > 0 ? `${totalMass.toFixed(4)} Da` : 'N/A';
detailsDiv.innerHTML = `
<div><strong>Type:</strong> ${escapeHtml(xnaName)}-Complex</div>
<div><strong>Strand 1:</strong> ${escapeHtml(xnaFormula1)} → ${mass1Display}</div>
<div><strong>Strand 2:</strong> ${escapeHtml(xnaFormula2)} → ${mass2Display}</div>
<div><strong>Total Mass:</strong> ${totalDisplay}</div>
`;
showMessage(`✓ Complex settings applied! Total mass: ${totalDisplay}.`, 'success');
} else {
detailsDiv.innerHTML = `
<div><strong>Strand 1:</strong> ${escapeHtml(sequence1)} (${sequence1.length} bp) → <span id="complex-mass1">...</span></div>
<div><strong>Strand 2:</strong> ${escapeHtml(sequence2)} (${sequence2.length} bp) → <span id="complex-mass2">...</span></div>
<div><strong>Total Mass:</strong> <span id="complex-applied-mass">Calculating...</span></div>
`;
// Fetch both strand masses and calculate total
Promise.all([
fetch('/calculate_dna_mass', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dna_sequence: sequence1 })
}).then(r => r.json()),
fetch('/calculate_dna_mass', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dna_sequence: sequence2 })
}).then(r => r.json())
])
.then(([data1, data2]) => {
const mass1Span = document.getElementById('complex-mass1');
const mass2Span = document.getElementById('complex-mass2');
const totalSpan = document.getElementById('complex-applied-mass');
let mass1 = 0, mass2 = 0;
if (data1.error) {
mass1Span.innerHTML = `<span style="color: #e74c3c;">Error</span>`;
} else {
mass1 = data1.mass;
mass1Span.innerHTML = `${mass1.toFixed(4)} Da`;
}
if (data2.error) {
mass2Span.innerHTML = `<span style="color: #e74c3c;">Error</span>`;
} else {
mass2 = data2.mass;
mass2Span.innerHTML = `${mass2.toFixed(4)} Da`;
}
const totalMass = mass1 + mass2;
if (totalMass > 0) {
totalSpan.innerHTML = `${totalMass.toFixed(4)} Da`;
showMessage(`✓ Complex settings applied! Total mass: ${totalMass.toFixed(4)} Da.`, 'success');
} else {
totalSpan.innerHTML = `<span style="color: #e74c3c;">Error</span>`;
}
})
.catch(error => {
document.getElementById('complex-applied-mass').innerHTML = `<span style="color: #e74c3c;">Error</span>`;
});
showMessage(`✓ Complex settings applied!`, 'success');
}
}
// Toggle XNA mode for complex
function toggleComplexXNA() {
const toggle = document.getElementById('complex-xna-toggle');
const dnaInputs = document.getElementById('complex-dna-inputs');
const xnaOptions = document.getElementById('complex-xna-options');
if (toggle.checked) {
// XNA mode: show XNA options, hide DNA inputs
dnaInputs.style.display = 'none';
xnaOptions.style.display = 'block';
// Clear DNA sequences
document.getElementById('complex-sequence-1').value = '';
document.getElementById('dna-sequence-2').value = '';
} else {
// DNA mode: hide XNA options, show DNA inputs
dnaInputs.style.display = 'block';
xnaOptions.style.display = 'none';
}
updateComplexMass();
}
// Complex mode now uses automatic hybrid charge detection on the backend
// No user selection needed - the server handles it smartly
// Add event listener for Apply XNA Settings button
document.getElementById('apply-xna-btn').addEventListener('click', applyXNASettings);
// Delete a composition from the display
function deleteComposition(peakMz, compId, event) {
event.stopPropagation();
// Track deleted composition
if (!deletedCompositions[peakMz]) {
deletedCompositions[peakMz] = new Set();
}
deletedCompositions[peakMz].add(compId);
// Remove from theory overlays if it's active
if (theoryOverlays[compId]) {
delete theoryOverlays[compId];
// Update zoom plot if visible
if (currentClickedPeakMz !== null) {
// Get exp_x0 from current peak data
const peakData = currentPeaks.find(p => Math.abs((p.peak_mz || p.mz) - currentClickedPeakMz) < 0.01);
const expX0 = peakData ? peakData.exp_x0 : null;
showPeakZoom(currentClickedPeakMz, 5, expX0);
}
}
// Remove from DOM
const compElement = document.getElementById(`comp_container_${compId}`);
if (compElement) {
compElement.remove();
}
showMessage('Composition deleted', 'info');
}
// Format N0/X0 with subscript zero (e.g., "N0" -> "N₀", "X0" -> "X₀")
function formatSubscript(text) {
if (!text) return '';
return text.replace(/N0/g, 'N₀').replace(/X0/g, 'X₀');
}
// File upload handlers
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFile(files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFile(e.target.files[0]);
}
});
function handleFile(file) {
const formData = new FormData();
formData.append('file', file);
showMessage('Loading spectrum...', 'info');
fetch('/upload', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.error) {
showMessage('Error: ' + data.error, 'error');
} else {
currentSpectrum = data.spectrum;
currentPeaks = []; // No automatic peak detection
// Store auto-detected peaks with charge states (NEW!)
if (data.auto_detected_peaks) {
window.autoDetectedPeaksWithCharge = data.auto_detected_peaks;
console.log(`Auto-detected ${data.auto_detected_peaks.length} peaks with charge states`);
} else {
window.autoDetectedPeaksWithCharge = [];
}
// Store auto-detected resolution
if (data.resolution) {
autoDetectedResolution = data.resolution;
console.log('Auto-detected resolution:', autoDetectedResolution);
// Update the hidden input field
document.getElementById('resolution').value = autoDetectedResolution;
}
// Plot spectrum IMMEDIATELY (will now show z labels)
plotSpectrum();
updateStats(data);
const peakCount = window.autoDetectedPeaksWithCharge ? window.autoDetectedPeaksWithCharge.length : 0;
showMessage(`Spectrum loaded! ${data.num_points} data points, ${peakCount} isotope envelopes detected with z values (Resolution: ${autoDetectedResolution}). Click any peak to analyze compositions.`, 'success');
stopPulse();
showClickPeakPrompt();
// Show reset button
document.getElementById('reset-section').style.display = 'block';
// Hide peaks section initially
document.getElementById('peaks-section').style.display = 'none';
document.getElementById('composition-section').style.display = 'none';
// Clear file input so same file can be re-selected with different mode
document.getElementById('fileInput').value = '';
}
})
.catch(error => {
showMessage('Error uploading file: ' + error, 'error');
// Clear file input on error too
document.getElementById('fileInput').value = '';
});
}
// Load sample data for demo/testing
async function loadSampleData() {
showMessage('Loading sample data...', 'info');
try {
// Fetch sample file from server
const response = await fetch('/load_sample');
const data = await response.json();
if (data.error) {
showMessage('Error: ' + data.error, 'error');
return;
}
// Store spectrum data
currentSpectrum = data.spectrum;
currentPeaks = [];
// Store auto-detected peaks with charge states
if (data.auto_detected_peaks) {
window.autoDetectedPeaksWithCharge = data.auto_detected_peaks;
console.log(`Auto-detected ${data.auto_detected_peaks.length} peaks with charge states`);
} else {
window.autoDetectedPeaksWithCharge = [];
}
// Store auto-detected resolution
if (data.resolution) {
autoDetectedResolution = data.resolution;
document.getElementById('resolution').value = autoDetectedResolution;
}
// Set DNA mode and fill in sequence
document.querySelector('input[name="analysis-mode"][value="dna"]').checked = true;
switchAnalysisMode('dna');
document.getElementById('dna-sequence').value = 'ATCCCCTGTC';
// Auto-apply DNA settings
applyDNASettings();
// Plot spectrum
plotSpectrum();
updateStats(data);
const peakCount = window.autoDetectedPeaksWithCharge ? window.autoDetectedPeaksWithCharge.length : 0;
showMessage(`📊 Sample loaded! ${peakCount} isotope envelopes detected. Click any peak in the spectrum to analyze its composition.`, 'success');
stopPulse();
showClickPeakPrompt();
// Show reset button
document.getElementById('reset-section').style.display = 'block';
// Hide peaks section initially
document.getElementById('peaks-section').style.display = 'none';
document.getElementById('composition-section').style.display = 'none';
} catch (error) {
showMessage('Error loading sample: ' + error, 'error');
}
}
async function resetAnalysis() {
if (!confirm('Are you sure you want to reset? This will clear all analyzed peaks, compositions, custom adducts, and user inputs.')) {
return;
}
dismissClickPrompt();
hasClickedPeak = false;
// Clear all data
currentSpectrum = null;
currentPeaks = []; // Empty array instead of null
selectedPeak = null;
window.autoDetectedPeaksWithCharge = []; // Clear auto-detected peaks
// Clear ALL history
deletedCompositions = {}; // Clear deleted compositions memory
peakAnalysisCache = {}; // Clear cached peak analyses
theoryOverlays = {}; // Clear active theory overlays
currentClickedPeakMz = null; // Clear clicked peak tracking
// Clear applied XNA settings
appliedXNASettings = null;
const xnaAppliedStatus = document.getElementById('xna-applied-status');
if (xnaAppliedStatus) xnaAppliedStatus.style.display = 'none';
// Clear custom adducts (reset to default built-in adducts only)
await clearAllCustomAdducts();
// Clear file input
const fileInput = document.getElementById('fileInput');
if (fileInput) fileInput.value = '';
// ============ CLEAR ALL USER INPUT FIELDS ============
// Clear DNA sequence input
const dnaSequence = document.getElementById('dna-sequence');
if (dnaSequence) dnaSequence.value = '';
// Reset to DNA-AgN mode
currentAnalysisMode = 'dna';
const dnaRadio = document.querySelector('input[name="analysis-mode"][value="dna"]');
if (dnaRadio) dnaRadio.checked = true;
// Show only DNA mode section
document.getElementById('mode-dna-section').style.display = 'block';
document.getElementById('mode-complex-section').style.display = 'none';
document.getElementById('mode-xna-section').style.display = 'none';
// Reset mode visual feedback
document.querySelectorAll('.mode-option').forEach(option => {
option.style.border = '2px solid transparent';
});
const dnaOption = document.querySelector('.mode-option[data-mode="dna"]');
if (dnaOption) dnaOption.style.border = '2px solid #4CAF50';
// Clear Complex mode inputs
const complexSeq1 = document.getElementById('complex-sequence-1');
if (complexSeq1) complexSeq1.value = '';
const dnaSequence2 = document.getElementById('dna-sequence-2');
if (dnaSequence2) dnaSequence2.value = '';
// Reset complex XNA toggles and inputs
const strand1XNAToggle = document.getElementById('complex-strand1-xna-toggle');
if (strand1XNAToggle) strand1XNAToggle.checked = false;
const strand2XNAToggle = document.getElementById('complex-strand2-xna-toggle');
if (strand2XNAToggle) strand2XNAToggle.checked = false;
const strand1XNAOptions = document.getElementById('complex-strand1-xna-options');
if (strand1XNAOptions) strand1XNAOptions.style.display = 'none';
const strand2XNAOptions = document.getElementById('complex-strand2-xna-options');
if (strand2XNAOptions) strand2XNAOptions.style.display = 'none';
const strand1XNAName = document.getElementById('complex-strand1-xna-name');
if (strand1XNAName) strand1XNAName.value = '';
const strand2XNAName = document.getElementById('complex-strand2-xna-name');
if (strand2XNAName) strand2XNAName.value = '';
const strand1XNAFormula = document.getElementById('complex-strand1-xna-formula');
if (strand1XNAFormula) strand1XNAFormula.value = '';
const strand2XNAFormula = document.getElementById('complex-strand2-xna-formula');
if (strand2XNAFormula) strand2XNAFormula.value = '';
// Clear applied settings
appliedDNASettings = null;
appliedComplexSettings = null;
// appliedXNASettings is already cleared earlier in the function
// Hide applied status messages
const dnaAppliedStatus = document.getElementById('dna-applied-status');
if (dnaAppliedStatus) dnaAppliedStatus.style.display = 'none';
const complexAppliedStatus = document.getElementById('complex-applied-status');
if (complexAppliedStatus) complexAppliedStatus.style.display = 'none';
// Clear DNA mass calculator display
const massDisplay = document.getElementById('dna-mass-display');
if (massDisplay) {
massDisplay.style.display = 'none';
}
const massValue = document.getElementById('dna-mass-value');
if (massValue) {
massValue.innerHTML = '';
}
// Reset XNA inputs
const xnaName = document.getElementById('xna-name');
if (xnaName) xnaName.value = '';
const xnaFormula = document.getElementById('xna-formula');
if (xnaFormula) xnaFormula.value = '';
// Hide XNA mass display
const xnaMassDisplay = document.getElementById('xna-mass-display');
if (xnaMassDisplay) xnaMassDisplay.style.display = 'none';
// Clear manual composition inputs
const manualStrands = document.getElementById('manual-strands');
if (manualStrands) manualStrands.value = '1';
const manualNag = document.getElementById('manual-nag');
if (manualNag) manualNag.value = '8';
const manualQcl = document.getElementById('manual-qcl');
if (manualQcl) manualQcl.value = '7';
const manualFormula = document.getElementById('manual-formula');
if (manualFormula) manualFormula.value = '';
// Reset DNA-only checkbox
const dnaOnlyCheckbox = document.getElementById('dna-only-checkbox');
if (dnaOnlyCheckbox) dnaOnlyCheckbox.checked = false;
const manualNagContainer = document.getElementById('manual-nag-container');
if (manualNagContainer) manualNagContainer.style.display = 'block';
const manualQclContainer = document.getElementById('manual-qcl-container');
if (manualQclContainer) manualQclContainer.style.display = 'block';
// Reset custom Z checkbox
const useCustomZCheckbox = document.getElementById('use-custom-z-checkbox');
if (useCustomZCheckbox) {
useCustomZCheckbox.checked = false;
}
// Reset show-all-strands checkbox
const showAllStrandsCheckbox = document.getElementById('show-all-strands-checkbox');
if (showAllStrandsCheckbox) {
showAllStrandsCheckbox.checked = false;
}
// Clear parameter mode adduct rows
const paramAdductRowsContainer = document.getElementById('param-adduct-rows');
if (paramAdductRowsContainer) {
paramAdductRowsContainer.innerHTML = '';
}
// Clear adduct manager inputs
const adductName = document.getElementById('adduct-name');
if (adductName) adductName.value = '';
const adductFormula = document.getElementById('adduct-formula');
if (adductFormula) adductFormula.value = '';
const adductCharge = document.getElementById('adduct-charge');
if (adductCharge) adductCharge.value = '1';
// Reload adduct cache for parameter mode
await loadAdductsForParamMode();
// ============ END USER INPUT CLEARING ============
// Hide all sections
const peaksSection = document.getElementById('peaks-section');
if (peaksSection) peaksSection.style.display = 'none';
const compositionSection = document.getElementById('composition-section');
if (compositionSection) compositionSection.style.display = 'none';
const chargeLegend = document.getElementById('charge-legend');
if (chargeLegend) chargeLegend.style.display = 'none';
const resetSection = document.getElementById('reset-section');
if (resetSection) resetSection.style.display = 'none';
// Clear plots - use try-catch to handle cases where plot isn't initialized
try {
Plotly.purge('spectrum-plot');
} catch (e) {
// Plot may not be initialized yet, that's okay
}
// Clear the spectrum plot div and show placeholder
const spectrumPlot = document.getElementById('spectrum-plot');
if (spectrumPlot) {
spectrumPlot.innerHTML = `
<div style="
height: 400px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border: 2px dashed #dee2e6;
border-radius: 12px;
color: #6c757d;
">
<div style="font-size: 3em; margin-bottom: 15px;">📊</div>
<div style="font-size: 1.2em; font-weight: 600; margin-bottom: 8px;">No Spectrum Loaded</div>
<div style="font-size: 0.95em; margin-bottom: 20px;">Upload a spectrum file or try an example to get started</div>
<button onclick="loadSampleData()" style="background: #27ae60; color: white; border: none; padding: 10px 24px; border-radius: 6px; font-size: 1em; cursor: pointer; font-weight: 600; box-shadow: 0 2px 8px rgba(39,174,96,0.3);">
Load Example Spectrum
</button>
</div>
`;
}
// Hide and clear peak zoom section
const peakZoomSection = document.getElementById('peak-zoom-section');
if (peakZoomSection) peakZoomSection.style.display = 'none';
try {
Plotly.purge('peak-zoom-plot');
} catch (e) {
// Plot may not be initialized
}
const peakZoomPlot = document.getElementById('peak-zoom-plot');
if (peakZoomPlot) peakZoomPlot.innerHTML = '';
// Reset auto-detected resolution to default
autoDetectedResolution = 20000;
document.getElementById('resolution').value = 20000;
// Clear stats
const statsArea = document.getElementById('stats-area');
if (statsArea) statsArea.innerHTML = '';
// Reset message to default
const messageArea = document.getElementById('message-area');
if (messageArea) {
messageArea.style.display = 'flex';
messageArea.style.justifyContent = 'center';
messageArea.style.fontStyle = 'italic';
messageArea.innerHTML = 'No messages';
}
// Clear composition details
const compositionResults = document.getElementById('composition-results');
if (compositionResults) compositionResults.innerHTML = '';
// Clear composition-details div (main composition display area)
const compositionDetails = document.getElementById('composition-details');
if (compositionDetails) compositionDetails.innerHTML = '';
// Clear peak-list div
const peakList = document.getElementById('peak-list');
if (peakList) peakList.innerHTML = '';
showMessage('✓ Complete reset! All data cleared. Upload a spectrum file or click "Load Sample" to start.', 'success');
}
function plotSpectrum() {
console.log('plotSpectrum called, currentSpectrum:', currentSpectrum ? 'exists' : 'null');
if (!currentSpectrum) {
console.error('Cannot plot: currentSpectrum is null');
return;
}
console.log('Spectrum data points:', currentSpectrum.mz ? currentSpectrum.mz.length : 'no mz data');
const trace = {
x: currentSpectrum.mz,
y: currentSpectrum.intensity,
type: 'scatter',
mode: 'lines',
name: 'Experimental Spectrum',
line: { color: '#667eea', width: 2 }
};
const data = [trace];
// Add auto-detected peak markers with z labels (NEW! - Senko method)
const annotations = [];
if (window.autoDetectedPeaksWithCharge && window.autoDetectedPeaksWithCharge.length > 0) {
// Color-code by charge state
const chargeColors = {
1: '#e74c3c', 2: '#3498db', 3: '#2ecc71',
4: '#f39c12', 5: '#9b59b6', 6: '#f8a5c2',
7: '#e67e22', 8: '#16a085', 9: '#8e44ad', 10: '#c0392b'
};
window.autoDetectedPeaksWithCharge.forEach(peak => {
if (peak.charge !== null) {
const color = chargeColors[peak.charge] || '#95a5a6';
// Add marker for the peak
const peakTrace = {
x: [peak.mz],
y: [peak.intensity],
type: 'scatter',
mode: 'markers',
name: `z=${peak.charge}`,
marker: {
size: 8,
color: color,
symbol: 'circle',
line: { color: '#fff', width: 1 }
},
showlegend: false,
hovertemplate: `m/z: ${peak.mz.toFixed(2)}<br>z: ${peak.charge}<br>Confidence: ${(peak.confidence * 100).toFixed(1)}%<extra></extra>`
};
data.push(peakTrace);
// Add text annotation with z label
annotations.push({
x: peak.mz,
y: peak.intensity,
text: `z=${peak.charge}`,
showarrow: false,
yshift: 15,
font: {
size: 10,
color: color,
family: 'Arial, sans-serif'
},
bgcolor: 'rgba(255, 255, 255, 0.8)',
borderpad: 2
});
}
});
}
// Also add clicked peak markers if any (user has analyzed specific peaks)
if (currentPeaks && currentPeaks.length > 0) {
// Color-code by charge state
const chargeColors = {
1: '#e74c3c', 2: '#3498db', 3: '#2ecc71',
4: '#f39c12', 5: '#9b59b6', 6: '#f8a5c2'
};
currentPeaks.forEach(peak => {
const color = chargeColors[peak.charge] || '#95a5a6';
const peakTrace = {
x: [peak.peak_mz],
y: [peak.intensity],
type: 'scatter',
mode: 'markers',
name: `z=${peak.charge} (analyzed)`,
marker: {
size: 12,
color: color,
symbol: 'diamond',
line: { color: '#fff', width: 2 }
},
showlegend: false
};
data.push(peakTrace);
});
}
const layout = {
title: 'Mass Spectrum - Click any peak to analyze',
xaxis: { title: 'm/z' },
yaxis: { title: 'Intensity' },
showlegend: true,
hovermode: 'closest',
barmode: 'overlay',
annotations: annotations // Add z labels as annotations
};
const config = {
responsive: true
};
Plotly.newPlot('spectrum-plot', data, layout, config);
// Show legend if peaks are present
if (currentPeaks && currentPeaks.length > 0) {
document.getElementById('charge-legend').style.display = 'block';
} else {
document.getElementById('charge-legend').style.display = 'none';
}
// Add click event listener
const plotDiv = document.getElementById('spectrum-plot');
plotDiv.on('plotly_click', function(data) {
const clickedMz = data.points[0].x;
analyzeClickedRegion(clickedMz);
});
}
function analyzeClickedRegion(clickedMz) {
dismissClickPrompt();
// Use the mode-based approach
const isXNAMode = currentAnalysisMode === 'xna';
const isComplexMode = currentAnalysisMode === 'complex';
const isDNAMode = currentAnalysisMode === 'dna';
// Validate settings are applied for each mode
if (isDNAMode) {
if (!appliedDNASettings) {
showMessage('⚠️ DNA-AgN mode: Please click "Apply DNA Settings" first!', 'error');
return;
}
} else if (isComplexMode) {
if (!appliedComplexSettings) {
showMessage('⚠️ Complex mode: Please click "Apply Complex Settings" first!', 'error');
return;
}
} else if (isXNAMode) {
if (!appliedXNASettings) {
showMessage('⚠️ XNA mode: Please click "Apply XNA Settings" first!', 'error');
return;
}
}
// Get DNA sequence based on applied settings
let dnaSeq = '';
if (isDNAMode && appliedDNASettings) {
dnaSeq = appliedDNASettings.sequence;
} else if (isComplexMode && appliedComplexSettings) {
// In complex XNA mode, dnaSeq can be empty - backend will use XNA formula
dnaSeq = appliedComplexSettings.xna ? '' : appliedComplexSettings.sequence1;
} else if (isXNAMode) {
// XNA mode doesn't use sequence - backend uses formula from appliedXNASettings
dnaSeq = '';
}
console.log('DEBUG: Current analysis mode:', currentAnalysisMode);
console.log('DEBUG: Sequence value:', dnaSeq);
// Check cache first - if this peak was already analyzed, just show cached results
const cacheKey = clickedMz.toFixed(4);
if (peakAnalysisCache[cacheKey]) {
const cachedData = peakAnalysisCache[cacheKey];
showMessage(`Using cached analysis for m/z ${cachedData.peak_mz.toFixed(4)}`, 'info');
// Show peak zoom display centered on clicked m/z
showPeakZoom(cachedData.peak_mz, 5, cachedData.exp_x0);
// CLEAR ALL THEORY OVERLAYS before displaying cached results
// When re-clicking a peak, old theoretical patterns are invalid
theoryOverlays = {};
console.log('Cleared all theory overlays for cached peak display');
// Display composition results (will filter out deleted ones)
displayClickedPeakResults(cachedData);
return;
}
// Show progress message with timer
const startTime = Date.now();
let progressTimer = null;
function updateProgress() {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
showMessage(`Analyzing peak at m/z ${clickedMz.toFixed(2)}... (${elapsed}s)`, 'info');
}
updateProgress();
progressTimer = setInterval(updateProgress, 100); // Update every 100ms
const resolution = parseInt(document.getElementById('resolution').value);
// Calculate centroid from the peak shape (same as marker calculation)
const windowSize = 5;
const minMz = clickedMz - windowSize;
const maxMz = clickedMz + windowSize;
let totalIntensity = 0;
let weightedSum = 0;
for (let i = 0; i < currentSpectrum.mz.length; i++) {
if (currentSpectrum.mz[i] >= minMz && currentSpectrum.mz[i] <= maxMz) {
totalIntensity += currentSpectrum.intensity[i];
weightedSum += currentSpectrum.mz[i] * currentSpectrum.intensity[i];
}
}
const detectedCentroid = totalIntensity > 0 ? weightedSum / totalIntensity : clickedMz;
// Get custom XNA data if in XNA mode or Complex mode
let customXNA = null;
if (isXNAMode && appliedXNASettings) {
// Use the applied XNA settings
customXNA = appliedXNASettings;
console.log('✓ Using applied XNA settings:', customXNA);
} else if (isComplexMode && appliedComplexSettings && appliedComplexSettings.xna) {
// Complex XNA mode: send combined formula AND individual strand formulas
// Backend will search for both single strands and complex
customXNA = {
name: appliedComplexSettings.xna.name,
formula: appliedComplexSettings.xna.combinedFormula, // Complex formula
strand1_formula: appliedComplexSettings.xna.formula1, // Single strand 1
strand2_formula: appliedComplexSettings.xna.formula2, // Single strand 2 (empty if same as strand1)
is_complex: true,
same_strands: appliedComplexSettings.xna.sameStrands || false
};
console.log('✓ Complex XNA mode: combined formula', appliedComplexSettings.xna.formula1, '+', (appliedComplexSettings.xna.formula2 || appliedComplexSettings.xna.formula1), '->', appliedComplexSettings.xna.combinedFormula);
} else if (isComplexMode && appliedComplexSettings) {
// DNA-only Complex mode: no XNA formula, but still need to flag as complex
customXNA = {
name: 'Complex',
is_complex: true,
same_strands: false
};
console.log('✓ Complex DNA mode (no XNA formula)');
} else if (isXNAMode) {
console.log('⚠ XNA mode but no settings applied');
} else {
console.log('✓ Using standard DNA mass calculation');
}
// Look up auto-detected charge for this peak (if available)
// All modes (DNA-AgN, Complex, XNA) use the SAME charge detection method
let detectedCharge = null;
let chargeConfidence = null;
let chargeMethod = null;
// Use auto-detected peaks from file upload for ALL modes
if (window.autoDetectedPeaksWithCharge && window.autoDetectedPeaksWithCharge.length > 0) {
// Find the closest auto-detected peak within ±0.5 m/z
let closestPeak = null;
let minDistance = 0.5;
for (const peak of window.autoDetectedPeaksWithCharge) {
const distance = Math.abs(peak.mz - clickedMz);
if (distance < minDistance && peak.charge !== null) {
minDistance = distance;
closestPeak = peak;
}
}
if (closestPeak) {
detectedCharge = closestPeak.charge;
chargeConfidence = closestPeak.confidence;
chargeMethod = closestPeak.method;
const modeStr = isComplexMode ? 'Complex' : (isXNAMode ? 'XNA' : 'DNA-AgN');
console.log(`✓ [${modeStr}] Using auto-detected charge z=${detectedCharge} from upload (distance: ${minDistance.toFixed(4)} m/z, confidence: ${(chargeConfidence*100).toFixed(1)}%, method: ${chargeMethod})`);
} else {
console.log('⚠ No auto-detected peak found within 0.5 m/z - backend will detect charge');
}
} else {
console.log('⚠ No auto-detected peaks available - backend will detect charge');
}
// Collect complex mode data from applied settings
let complexData = null;
if (isComplexMode && appliedComplexSettings) {
complexData = {
enabled: true,
dna_sequence_2: appliedComplexSettings.sequence2,
use_silver: true,
extra_atom: '',
extra_atom_count: 0,
xna: appliedComplexSettings.xna
};
console.log('✓ Complex mode enabled, xna:', appliedComplexSettings.xna);
}
fetch('/analyze_region', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clicked_mz: clickedMz,
detected_centroid: detectedCentroid,
window_size: windowSize,
spectrum: currentSpectrum,
dna_sequence: dnaSeq,
resolution: resolution,
custom_xna: customXNA,
detected_charge: detectedCharge,
charge_confidence: chargeConfidence,
charge_method: chargeMethod,
complex_mode: complexData,
custom_adducts: getCustomAdducts(),
})
})
.then(response => {
if (!response.ok) {
return response.text().then(text => {
throw new Error(`Server returned ${response.status}: ${text.substring(0, 200)}`);
});
}
// Debug: log response before parsing
return response.text().then(text => {
console.log('Raw response (first 500 chars):', text.substring(0, 500));
console.log('Response length:', text.length);
try {
return JSON.parse(text);
} catch (e) {
console.error('JSON parse error:', e);
console.error('Full response:', text);
throw new Error('Invalid JSON response: ' + e.message);
}
});
})
.then(data => {
// Clear progress timer
if (progressTimer) {
clearInterval(progressTimer);
progressTimer = null;
}
// Check if charge detection failed and user input is required
if (data.charge_required) {
// Determine mode for the prompt message
let modeHint = 'DNA-AgN clusters (typical: 3-6)';
if (complexData && complexData.enabled) {
modeHint = 'Complex DNA/XNA (typical: 5-10)';
} else if (customXNA && customXNA.total_mass) {
modeHint = 'XNA complexes (typical: 3-8)';
}
const userCharge = prompt(
`Charge detection failed for peak at m/z ${data.peak_mz.toFixed(4)}.\n\n` +
`Please enter the charge state (z) manually:\n` +
`${modeHint}`,
'7'
);
if (userCharge && !isNaN(parseInt(userCharge))) {
const z = parseInt(userCharge);
if (z >= 1 && z <= 15) {
// Re-analyze with user-specified charge using reanalyze_peak endpoint
// Get DNA sequence based on mode
let dnaSeq = '';
if (isComplexMode && appliedComplexSettings) {
// Complex mode: use applied settings, empty for XNA complex
dnaSeq = appliedComplexSettings.xna ? '' : appliedComplexSettings.sequence1;
} else if (isXNAMode) {
// XNA mode: no sequence needed
dnaSeq = '';
} else {
// DNA mode: get from input field
const dnaSeqEl = document.getElementById('dna-sequence');
dnaSeq = dnaSeqEl ? dnaSeqEl.value.trim().toUpperCase() : '';
}
// Set up progress timer
let reanalyzeProgressTimer = null;
const reanalyzeStartTime = Date.now();
const updateReanalyzeProgress = () => {
const elapsed = ((Date.now() - reanalyzeStartTime) / 1000).toFixed(1);
showMessage(`Re-analyzing peak at m/z ${data.peak_mz.toFixed(2)} with z=${z}... (${elapsed}s)`, 'info');
};
updateReanalyzeProgress();
reanalyzeProgressTimer = setInterval(updateReanalyzeProgress, 100);
// Debug: log what we're sending
console.log('[reanalyze] Sending request with:', {
peak_mz: data.peak_mz,
charge: z,
custom_xna: customXNA,
complex_mode: complexData
});
fetch('/reanalyze_peak', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
peak_mz: data.peak_mz,
charge: z,
intensity: 0,
spectrum: currentSpectrum,
dna_sequence: dnaSeq,
resolution: parseInt(document.getElementById('resolution').value) || 20000,
custom_xna: customXNA,
complex_mode: complexData,
custom_adducts: getCustomAdducts(),
})
})
.then(resp => resp.json())
.then(reanalyzedData => {
console.log('[reanalyze] Response:', reanalyzedData);
// Clear progress timer
if (reanalyzeProgressTimer) {
clearInterval(reanalyzeProgressTimer);
reanalyzeProgressTimer = null;
}
if (reanalyzedData.error) {
showMessage('Error: ' + reanalyzedData.error, 'error');
} else {
const elapsed = ((Date.now() - reanalyzeStartTime) / 1000).toFixed(1);
peakAnalysisCache[reanalyzedData.peak_mz.toFixed(4)] = reanalyzedData;
currentPeaks.push(reanalyzedData);
plotSpectrum();
showPeakZoom(reanalyzedData.peak_mz, 5, reanalyzedData.exp_x0);
theoryOverlays = {};
displayClickedPeakResults(reanalyzedData);
showMessage(`✅ Analysis complete in ${elapsed}s! Peak at m/z ${reanalyzedData.peak_mz.toFixed(4)} (z=${z} user-specified)`, 'success');
}
})
.catch(err => {
// Clear progress timer
if (reanalyzeProgressTimer) {
clearInterval(reanalyzeProgressTimer);
reanalyzeProgressTimer = null;
}
showMessage('Error re-analyzing: ' + err.message, 'error');
});
return;
} else {
showMessage('Invalid charge value. Please use a value between 1 and 15.', 'error');
return;
}
} else {
showMessage('Analysis cancelled - charge input required.', 'warning');
return;
}
}
if (data.error) {
showMessage('Error: ' + data.error, 'error');
} else {
// Cache the analysis result
peakAnalysisCache[data.peak_mz.toFixed(4)] = data;
// Add to analyzed peaks list
currentPeaks.push(data);
// Update plot with new marker
plotSpectrum();
// Show peak zoom display centered on clicked m/z
showPeakZoom(data.peak_mz, 5, data.exp_x0);
// CLEAR ALL THEORY OVERLAYS before displaying new results
// When re-analyzing a peak, old theoretical patterns are invalid
theoryOverlays = {};
console.log('Cleared all theory overlays for new peak analysis');
// Display composition results
displayClickedPeakResults(data);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
let chargeInfo;
if (data.charge_confidence !== null && data.charge_confidence !== undefined) {
const confidencePercent = (data.charge_confidence * 100).toFixed(1);
const methodNames = {
'spacing': 'isotope spacing',
'senko_fallback': 'Senko method',
'user_input_required': 'user input',
'user_specified': 'user specified',
'ptft': 'PTFT'
};
const methodName = methodNames[data.charge_method] || data.charge_method;
chargeInfo = `(z=${data.charge} by ${methodName}, ${confidencePercent}% confidence)`;
} else {
chargeInfo = data.charge_method === 'detected' ?
`(charge z=${data.charge} detected)` :
`(charge z=${data.charge} estimated)`;
}
showMessage(`✅ Analysis complete in ${elapsed}s! Peak at m/z ${data.peak_mz.toFixed(4)} ${chargeInfo}`, 'success');
}
})
.catch(error => {
// Clear progress timer
if (progressTimer) {
clearInterval(progressTimer);
progressTimer = null;
}
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.error('Error analyzing region:', error);
showMessage(`❌ Error after ${elapsed}s: ` + error.message, 'error');
});
}
function displayClickedPeakResults(peakData) {
// Set selectedPeak so button functions can use it
selectedPeak = peakData;
// Show composition section
document.getElementById('composition-section').style.display = 'block';
const compositionDiv = document.getElementById('composition-details');
compositionDiv.innerHTML = '';
// Debug: log peakData to check if exp_x0 is present
console.log('Peak Data:', peakData);
console.log('exp_x0:', peakData.exp_x0);
console.log('exp_sigma:', peakData.exp_sigma);
// Add header with editable charge state
const headerDiv = document.createElement('div');
headerDiv.style.cssText = 'padding: 15px; background: #f8f9fa; border-radius: 4px; margin-bottom: 15px;';
// Check if manual fit is active
const isManualFit = peakData.manual_fit_range && peakData.manual_fit_range.length === 2;
const valueColor = isManualFit ? '#9c27b0' : '#666'; // Purple for manual fit, default gray otherwise
const manualFitLabel = isManualFit ? ' <span style="color: #9c27b0; font-weight: bold;">(Manual Fit)</span>' : '';
let x0Display = '';
if (peakData.exp_x0 !== null && peakData.exp_x0 !== undefined) {
x0Display = `<strong>Experimental X₀ (Gaussian centroid):</strong> <span style="color: ${valueColor}; font-weight: bold;">${peakData.exp_x0.toFixed(4)} m/z</span>${manualFitLabel}<br>`;
}
let sigmaDisplay = '';
if (peakData.exp_sigma !== null && peakData.exp_sigma !== undefined) {
sigmaDisplay = `<strong>Experimental σ (fitting error):</strong> <span style="color: ${valueColor}; font-weight: bold;">${peakData.exp_sigma.toFixed(4)} m/z</span>${manualFitLabel}<br>`;
}
// Manual fit range indicator
let manualFitDisplay = '';
if (peakData.manual_fit_range && peakData.manual_fit_range.length === 2) {
manualFitDisplay = `<div style="margin-top: 5px; padding: 6px 10px; background: #d4edda; border-left: 3px solid #28a745; border-radius: 3px; font-size: 0.85em;">
<strong>Manual Fit Active:</strong> Range [${peakData.manual_fit_range[0].toFixed(2)}, ${peakData.manual_fit_range[1].toFixed(2)}] m/z
</div>`;
}
// Symmetry display
let symmetryDisplay = '';
if (peakData.symmetry) {
const sym = peakData.symmetry;
const scoreColor = sym.symmetry_score > 0.85 ? '#27ae60' :
sym.symmetry_score > 0.7 ? '#f39c12' : '#e74c3c';
const scorePercent = (sym.symmetry_score * 100).toFixed(1);
symmetryDisplay = `
<div style="margin-top: 8px; padding: 8px; background: #fff; border-left: 3px solid ${scoreColor}; border-radius: 3px;">
<strong>Peak Symmetry:</strong> <span style="color: ${scoreColor};">${scorePercent}%</span>
${sym.is_symmetric ? ' ✓' : ' ⚠'}<br>
<small style="color: #666;">${sym.note}</small><br>
<small style="color: #666;">Skewness: ${sym.skewness.toFixed(3)}</small>
</div>
`;
}
// Charge display with confidence
// Method names: 'spacing' (isotope spacing), 'senko_fallback', 'default'
const methodDisplayNames = {
'spacing': 'isotope spacing',
'senko_fallback': 'Senko method',
'spacing_override': 'isotope spacing',
'default': 'estimated',
'ptft': 'PTFT',
'detected': 'detected',
'estimated': 'estimated'
};
const displayMethod = methodDisplayNames[peakData.charge_method] || peakData.charge_method;
let chargeDisplay = `<strong>Charge state (z):</strong> ${peakData.charge} (${displayMethod})`;
if (peakData.charge_confidence !== null && peakData.charge_confidence !== undefined) {
const confidencePercent = (peakData.charge_confidence * 100).toFixed(1);
const confidenceColor = peakData.charge_confidence >= 0.7 ? '#27ae60' :
peakData.charge_confidence >= 0.3 ? '#f39c12' : '#e74c3c';
chargeDisplay += ` <span style="color: ${confidenceColor}; font-weight: bold;">[${confidencePercent}% confidence]</span>`;
if (peakData.charge_confidence < 0.3) {
chargeDisplay += ` <span style="color: #e74c3c;">⚠️</span>`;
}
}
// Safe intensity value with fallback
const safeIntensity = (peakData.intensity !== null && peakData.intensity !== undefined) ? peakData.intensity : 0;
const intensityDisplay = safeIntensity > 0 ? safeIntensity.toExponential(2) : 'N/A';
headerDiv.innerHTML = `
<h3 style="margin: 0 0 10px 0;">Peak at m/z ${peakData.peak_mz.toFixed(4)}</h3>
<div style="font-size: 0.9em; color: #666; margin-bottom: 10px;">
${chargeDisplay}<br>
<strong>Intensity:</strong> ${intensityDisplay}<br>
${x0Display}
${sigmaDisplay}
</div>
${manualFitDisplay}
${symmetryDisplay}
<div style="padding: 10px; background: #fff3cd; border-left: 3px solid #ffc107; border-radius: 4px; margin-top: 10px;">
<div style="font-size: 0.9em; margin-bottom: 8px;">
<strong>Change charge state if detection is wrong:</strong>
</div>
<div style="display: flex; gap: 10px; align-items: center;">
<input type="number"
id="clicked-peak-z-input"
value="${peakData.charge}"
min="1"
max="10"
style="width: 70px; padding: 6px; border: 2px solid #ffc107; border-radius: 4px; font-size: 1em;">
<button onclick="reanalyzeClickedPeak(${peakData.peak_mz}, ${safeIntensity})"
style="padding: 6px 16px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9em; font-weight: bold;">
Re-analyze with new z
</button>
</div>
</div>
`;
compositionDiv.appendChild(headerDiv);
// Manual Gaussian Fit Range section - positioned above composition analysis
const manualFitDiv = document.createElement('div');
manualFitDiv.style.cssText = 'padding: 10px; background: #e8f5e9; border-left: 3px solid #4caf50; border-radius: 4px; margin-top: 15px; margin-bottom: 15px;';
manualFitDiv.innerHTML = `
<div style="font-size: 0.9em; margin-bottom: 8px;">
<strong>📊 Manual Gaussian Fit Range:</strong>
</div>
<div style="font-size: 0.85em; color: #666; margin-bottom: 8px;">
Manually define the m/z range for Gaussian fitting (useful for excluding noise or overlapping peaks)
</div>
<div style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
<label style="display: flex; align-items: center; gap: 5px;">
<span style="font-size: 0.9em; font-weight: bold;">Start m/z:</span>
<input type="number"
id="manual-fit-start-mz"
${peakData.manual_fit_range && peakData.manual_fit_range.length === 2
? `value="${peakData.manual_fit_range[0].toFixed(2)}"`
: `placeholder="${(peakData.peak_mz - 3).toFixed(2)}"`}
step="0.01"
style="width: 90px; padding: 6px; border: 2px solid #4caf50; border-radius: 4px; font-size: 0.9em;">
</label>
<label style="display: flex; align-items: center; gap: 5px;">
<span style="font-size: 0.9em; font-weight: bold;">End m/z:</span>
<input type="number"
id="manual-fit-end-mz"
${peakData.manual_fit_range && peakData.manual_fit_range.length === 2
? `value="${peakData.manual_fit_range[1].toFixed(2)}"`
: `placeholder="${(peakData.peak_mz + 3).toFixed(2)}"`}
step="0.01"
style="width: 90px; padding: 6px; border: 2px solid #4caf50; border-radius: 4px; font-size: 0.9em;">
</label>
<button onclick="recalculateWithManualFit(${peakData.peak_mz}, ${peakData.charge}, ${safeIntensity})"
style="padding: 6px 16px; background: #4caf50; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9em; font-weight: bold;">
Recalculate Fit
</button>
<button onclick="resetManualFit(${peakData.peak_mz}, ${peakData.charge}, ${safeIntensity})"
style="padding: 6px 12px; background: #6c757d; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9em;">
Reset
</button>
</div>
`;
compositionDiv.appendChild(manualFitDiv);
// Track if we have compositions to display
// Also check if best X₀ error is within acceptable threshold (1.0 m/z)
// BUT: Skip threshold check if any composition was manually added
const X0_ERROR_THRESHOLD = 0.5; // m/z - if best error > this, treat as no good compositions
let hasCompositions = peakData.compositions && peakData.compositions.length > 0;
let bestX0ErrorTooHigh = false;
// Get currentExpX0 outside the if block so it's available everywhere
const currentExpX0 = peakData.exp_x0;
const hasValidExpX0 = currentExpX0 !== null && currentExpX0 !== undefined;
// Check if any composition was manually added or is DNA/XNA Only (no silver)
const hasManualComposition = hasCompositions && peakData.compositions.some(comp => comp.manual === true);
const hasDNAOnlyComposition = hasCompositions && peakData.compositions.some(comp => comp.num_silver === 0);
if (hasCompositions && !hasManualComposition && !hasDNAOnlyComposition) {
// Only apply X₀ threshold to auto-detected compositions with silver
// Calculate best (lowest) X₀ error using dynamic calculation
let bestX0Error = Infinity;
for (const comp of peakData.compositions) {
// Prefer comp.exp_x0 (from backend for custom searches), fallback to peakData.exp_x0
const compExpX0 = (comp.exp_x0 !== null && comp.exp_x0 !== undefined) ? comp.exp_x0 : currentExpX0;
const hasValidCompExpX0 = compExpX0 !== null && compExpX0 !== undefined;
if (hasValidCompExpX0 && comp.theo_x0 !== null && comp.theo_x0 !== undefined) {
const dynamicX0Error = Math.abs(comp.theo_x0 - compExpX0);
if (dynamicX0Error < bestX0Error) {
bestX0Error = dynamicX0Error;
}
}
}
// If best X₀ error is too high, suppress composition cards
if (bestX0Error > X0_ERROR_THRESHOLD) {
bestX0ErrorTooHigh = true;
hasCompositions = false; // Suppress cards, show warning block instead
console.log(`Best X₀ error (${bestX0Error.toFixed(4)} m/z) exceeds threshold (${X0_ERROR_THRESHOLD} m/z) - suppressing composition cards`);
}
} else if (hasManualComposition) {
console.log('Manual composition detected - skipping X₀ error threshold check');
} else if (hasDNAOnlyComposition) {
console.log('DNA/XNA Only composition detected (no silver) - skipping X₀ error threshold check');
}
if (!hasCompositions) {
// Show "no compositions found" message with estimates
const noCompDiv = document.createElement('div');
noCompDiv.style.cssText = 'padding: 15px; background: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px; margin-bottom: 15px;';
// Build estimates HTML
// When X₀ error is too high, show info from the found compositions
// When no compositions found, show the composition_estimates
let estimatesHTML = '';
if (bestX0ErrorTooHigh && peakData.compositions && peakData.compositions.length > 0) {
// X₀ error too high - show info from the actual compositions found
const uniqueConfigs = new Map();
for (const comp of peakData.compositions) {
const key = `${comp.num_strands}_${comp.num_silver}`;
if (!uniqueConfigs.has(key)) {
// Calculate dynamic X₀ error - prefer comp.exp_x0
let x0Err = 999;
const compExpX0 = (comp.exp_x0 !== null && comp.exp_x0 !== undefined) ? comp.exp_x0 : currentExpX0;
if (compExpX0 !== null && compExpX0 !== undefined && comp.theo_x0 !== null && comp.theo_x0 !== undefined) {
x0Err = Math.abs(comp.theo_x0 - compExpX0);
}
uniqueConfigs.set(key, {
ns: comp.num_strands,
nAg: comp.num_silver,
n0: comp.n0,
qcl: comp.qcl,
adduct: comp.adduct,
adduct_charge: comp.adduct_charge,
x0_error: x0Err
});
}
}
// Sort by X₀ error and show top configurations
const sortedConfigs = Array.from(uniqueConfigs.values())
.sort((a, b) => a.x0_error - b.x0_error)
.slice(0, 5);
let configRows = sortedConfigs.map(cfg => {
const statusIcon = cfg.x0_error > 2 ? '❌' : '⚠️';
const statusClass = cfg.x0_error > 2 ? 'color: #dc3545;' : 'color: #fd7e14;';
// Use displayed_qcl when available (for compositions with adducts)
const displayQcl = cfg.displayed_qcl !== undefined ? cfg.displayed_qcl : cfg.qcl;
const nsDisplay = currentAnalysisMode === 'complex' ? cfg.ns / 2 : cfg.ns;
const nsLabel = currentAnalysisMode === 'complex' ? 'nd' : 'ns';
// Show adduct if present
const adductDisplay = cfg.adduct ? ` +${cfg.adduct}` : '';
return `<div style="margin: 4px 0; font-family: monospace; ${statusClass}">
<span style="display: inline-block; width: 55px;">${nsLabel}=${nsDisplay}:</span>
nAg=${cfg.nAg}, N₀=${cfg.n0}, Qcl=${displayQcl}${adductDisplay} (ΔX₀: ${cfg.x0_error.toFixed(3)} m/z) ${statusIcon}
</div>`;
}).join('');
estimatesHTML = `
<div style="margin-top: 12px; padding: 12px; background: rgba(255,255,255,0.7); border-radius: 4px;">
<div style="font-weight: bold; margin-bottom: 8px; color: #333;">
📊 Best candidates found (all have high ΔX₀):
</div>
<div style="font-size: 0.85em; color: #666; margin-bottom: 8px;">
Try adjusting your calibration or using Custom Parameters below
</div>
${configRows}
</div>
`;
} else if (peakData.composition_estimates && peakData.composition_estimates.length > 0) {
let bestEstimate = null;
let estimateRows = peakData.composition_estimates.map(est => {
let statusIcon = '';
let statusClass = '';
let valueDisplay = '';
if (est.status === 'invalid') {
statusIcon = '❌';
statusClass = 'color: #dc3545;';
valueDisplay = `nAg < 0`;
} else if (est.status === 'high') {
statusIcon = '⚠️';
statusClass = 'color: #fd7e14;';
valueDisplay = `nAg ≈ ${est.nAg}`;
} else if (est.status === 'possible') {
statusIcon = '⚠️';
statusClass = 'color: #856404;';
valueDisplay = `nAg ≈ ${est.nAg}`;
if (!bestEstimate) bestEstimate = est;
} else {
statusIcon = '✓';
statusClass = 'color: #28a745;';
valueDisplay = `nAg ≈ ${est.nAg}`;
if (!bestEstimate) bestEstimate = est;
}
const warningText = est.warning ? ` <span style="font-size: 0.85em; opacity: 0.8;">(${est.warning})</span>` : '';
const estNsDisplay = currentAnalysisMode === 'complex' ? est.ns / 2 : est.ns;
const estNsLabel = currentAnalysisMode === 'complex' ? 'nd' : 'ns';
return `<div style="margin: 4px 0; font-family: monospace; ${statusClass}">
<span style="display: inline-block; width: 55px;">${estNsLabel}=${estNsDisplay}:</span>
${valueDisplay} ${statusIcon}${warningText}
</div>`;
}).join('');
// Build suggestion for best estimate (only suggest ns and nAg, not Qcl)
let suggestionHTML = '';
if (bestEstimate) {
const bestNsDisplay = currentAnalysisMode === 'complex' ? bestEstimate.ns / 2 : bestEstimate.ns;
const bestNsLabel = currentAnalysisMode === 'complex' ? 'nd' : 'ns';
suggestionHTML = `
<div style="margin-top: 12px; padding: 10px; background: #e8f5e9; border-radius: 4px; border-left: 3px solid #4caf50;">
<strong style="color: #2e7d32;">Try:</strong>
<span style="font-family: monospace;">${bestNsLabel}=${bestNsDisplay}, nAg=${bestEstimate.nAg}</span>
<span style="font-size: 0.9em; color: #666;"> using Custom Parameters below</span>
</div>
`;
}
estimatesHTML = `
<div style="margin-top: 12px; padding: 12px; background: rgba(255,255,255,0.7); border-radius: 4px;">
<div style="font-weight: bold; margin-bottom: 8px; color: #333;">
📊 Estimated nAg based on m/z ${peakData.peak_mz.toFixed(2)} (z=${peakData.charge}):
</div>
<div style="font-size: 0.85em; color: #666; margin-bottom: 8px;">
(Qcl and N₀ require isotope pattern analysis)
</div>
${estimateRows}
${suggestionHTML}
</div>
`;
}
// Show different message depending on whether X₀ error was too high or no compositions found
const titleText = bestX0ErrorTooHigh
? 'No good composition match found'
: 'No automatic compositions found';
const descText = bestX0ErrorTooHigh
? `All candidate compositions have ΔX₀ > <strong>${X0_ERROR_THRESHOLD} m/z</strong> (poor centroid match)`
: 'No matching compositions within automatic search range: <strong>ns = 1-3, nAg = 0-30</strong>';
noCompDiv.innerHTML = `
<div style="display: flex; align-items: flex-start; gap: 10px;">
<span style="font-size: 1.5em;">⚠️</span>
<div style="flex: 1;">
<strong style="color: #856404;">${titleText}</strong>
<div style="font-size: 0.9em; color: #856404; margin-top: 4px;">
${descText}
</div>
${estimatesHTML}
</div>
</div>
`;
compositionDiv.appendChild(noCompDiv);
}
// Only show instruction box and composition list if we have compositions
if (hasCompositions) {
// Show X₀ error warning banner if threshold exceeded but compositions still displayed
if (bestX0ErrorTooHigh) {
const x0WarningDiv = document.createElement('div');
x0WarningDiv.style.cssText = 'padding: 12px; background: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px; margin-bottom: 15px;';
x0WarningDiv.innerHTML = `
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 1.3em;">⚠️</span>
<strong style="color: #856404;">High ΔX₀</strong>
</div>
`;
compositionDiv.appendChild(x0WarningDiv);
}
// Add instruction box for checkboxes with manual fit notification
const instructionDiv = document.createElement('div');
const manualFitNotice = isManualFit ?
`<div style="font-size: 0.9em; color: #9c27b0; margin-top: 4px; font-weight: bold;">
Compositions re-ranked using manual fit X₀ = ${peakData.exp_x0.toFixed(4)} m/z
</div>` : '';
instructionDiv.style.cssText = 'padding: 12px; background: linear-gradient(135deg, #e8f5e9 0%, #e3f2fd 100%); border-left: 4px solid #4caf50; border-radius: 6px; margin-bottom: 15px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);';
instructionDiv.innerHTML = `
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 1.5em;"></span>
<div style="flex: 1;">
<strong style="color: #2e7d32; font-size: 1.05em;">Check boxes to overlay theoretical spectra</strong>
<div style="font-size: 0.9em; color: #555; margin-top: 4px;">
Toggle checkboxes to compare theoretical isotope patterns with experimental data on both main and zoomed plots
</div>
${manualFitNotice}
</div>
</div>
`;
compositionDiv.appendChild(instructionDiv);
// Get deleted compositions for this peak
const peakMzKey = peakData.peak_mz.toFixed(4);
const deletedSet = deletedCompositions[peakMzKey] || new Set();
// Check if best result has odd N₀ (calibration warning)
// Find composition with LOWEST X₀ error (best match), excluding deleted ones
// Use DYNAMIC X₀ error calculation: |theo_x0 - current_exp_x0|
let bestComp = null;
let lowestX0Error = Infinity;
const peakExpX0 = peakData.exp_x0;
for (let i = 0; i < peakData.compositions.length; i++) {
const comp = peakData.compositions[i];
const clickedCompId = `clicked_comp_${i}_${comp.num_strands}_${comp.num_silver}_${comp.z}_${comp.qcl}`;
// Skip deleted compositions
if (deletedSet.has(clickedCompId)) {
continue;
}
// Calculate dynamic X₀ error - prefer comp.exp_x0 (from backend for custom searches)
let dynamicX0Error = Infinity;
const compExpX0 = (comp.exp_x0 !== null && comp.exp_x0 !== undefined) ? comp.exp_x0 : peakExpX0;
if (compExpX0 !== null && compExpX0 !== undefined &&
comp.theo_x0 !== null && comp.theo_x0 !== undefined) {
dynamicX0Error = Math.abs(comp.theo_x0 - compExpX0);
}
// Find composition with lowest dynamic X₀ error
if (dynamicX0Error < lowestX0Error) {
lowestX0Error = dynamicX0Error;
bestComp = comp;
}
}
// Show complex mode indicator if this is a complex analysis
if (peakData.is_complex) {
const complexIndicator = document.createElement('div');
complexIndicator.style.cssText = 'padding: 12px; background: #e3f2fd; border-left: 4px solid #2196F3; border-radius: 6px; margin-bottom: 15px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);';
complexIndicator.innerHTML = `
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 1.5em;">⚛️</span>
<div style="flex: 1;">
<strong style="color: #1565c0; font-size: 1.05em;">Ag(I)-DNA/XNA Complex Mode</strong>
<div style="font-size: 0.9em; color: #555; margin-top: 4px;">
Analyzing double-stranded DNA. N₀ and Q<sub>cl</sub> are not applicable in complex mode.
</div>
</div>
</div>
`;
compositionDiv.appendChild(complexIndicator);
}
// Show calibration warning if best result has ODD N₀ (should be EVEN)
// Skip this warning for complex mode (N₀ doesn't apply)
if (bestComp && !peakData.is_complex && bestComp.n0 !== null && bestComp.n0 % 2 === 1) {
const calibrationWarning = document.createElement('div');
calibrationWarning.style.cssText = 'padding: 12px; background: #fff3cd; border-left: 4px solid #ff9800; border-radius: 6px; margin-bottom: 15px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);';
calibrationWarning.innerHTML = `
<div style="display: flex; align-items: flex-start; gap: 10px;">
<span style="font-size: 1.5em;">⚠️</span>
<div style="flex: 1;">
<strong style="color: #e65100; font-size: 1.05em;">Calibration Warning</strong>
<div style="font-size: 0.9em; color: #555; margin-top: 6px; line-height: 1.5;">
The best-matching composition (lowest ΔX₀: ${lowestX0Error.toFixed(4)} m/z) has an <strong>odd N₀ value (${bestComp.n0})</strong>.<br>
DNA-stabilized silver nanoclusters typically exhibit <strong>even N₀ values</strong> (2, 4, 6, 8, 10...) for stable superatomic configurations.<br>
→ This may indicate a <strong>calibration issue</strong> with your mass spectrum data.
</div>
</div>
</div>
`;
compositionDiv.appendChild(calibrationWarning);
}
// FILTER compositions to show only best N₀±1 (Qcl±1) after dynamic X₀ error calculation
// This ensures the correct N₀ range is shown even if backend selected a slightly different best
let filteredCompositions = peakData.compositions;
if (bestComp && bestComp.qcl !== null && bestComp.qcl !== undefined) {
const bestQcl = bestComp.qcl;
filteredCompositions = peakData.compositions.filter(comp => {
// Always keep manually-added and DNA/XNA-only compositions
if (comp.manual || comp.num_silver === 0) return true;
// Apply Qcl±1 filter to auto-detected compositions with silver
if (comp.qcl !== null && comp.qcl !== undefined) {
return Math.abs(comp.qcl - bestQcl) <= 1;
}
return true;
});
console.log(`Filtered to Qcl±1 of best (Qcl=${bestQcl}, N₀=${bestComp.n0}): ${filteredCompositions.length} compositions`);
}
// Display compositions (filter out deleted ones)
filteredCompositions.forEach((comp, index) => {
const clickedCompId = `clicked_comp_${index}_${comp.num_strands}_${comp.num_silver}_${comp.z}_${comp.qcl}`;
// Skip if this composition was deleted
if (deletedSet.has(clickedCompId)) {
return;
}
// Debug log to see what's in the composition
console.log(`Composition #${index + 1}:`, {
formula: comp.formula,
neutral_formula: comp.neutral_formula,
ion_formula: comp.ion_formula,
z: comp.z,
theo_x0: comp.theo_x0,
theo_sigma: comp.theo_sigma
});
const compDiv = document.createElement('div');
compDiv.className = 'composition-item';
compDiv.id = `comp_container_${clickedCompId}`; // Add ID for deletion
// Use neutral_formula for search (IPMolecule will handle protonation based on charge)
const searchFormula = comp.neutral_formula || comp.formula || 'MISSING';
// Check if this is DNA-only or XNA-only (no silver)
const isDNAOnly = comp.num_silver === 0;
const dnaOnlyBadge = isDNAOnly ?
`<span style="display: inline-block; margin-left: 8px; padding: 2px 8px; background: #e3f2fd; color: #1976d2; font-size: 0.75em; font-weight: bold; border-radius: 3px;">${comp.type}</span>` :
'';
// Check if this is the best composition (smallest X₀ error)
const isBestFit = bestComp && comp === bestComp;
compDiv.innerHTML = `
<div style="display: flex; align-items: flex-start; gap: 10px;">
<label style="display: flex; align-items: center; cursor: pointer; margin: 0; padding-top: 5px;">
<input type="checkbox" id="${clickedCompId}"
onchange='toggleTheoryOverlay("${clickedCompId}", ${JSON.stringify(comp.theo_mz)}, ${JSON.stringify(comp.theo_intensity)}, "${formatFormula(comp.formula).replace(/'/g, "\\'")}", ${comp.num_strands !== null ? comp.num_strands : 'null'}, ${comp.num_silver}, ${comp.z}, ${comp.qcl !== null ? comp.qcl : 'null'})'
style="width: 18px; height: 18px; margin: 0; cursor: pointer;">
</label>
<div style="flex: 1;">
<div class="composition-rank">#${index + 1}${isBestFit ? ' <span style="background: linear-gradient(135deg, #e91e63, #f06292); color: white; padding: 2px 8px; border-radius: 10px; font-size: 0.75em; margin-left: 8px; font-weight: bold; box-shadow: 0 2px 4px rgba(0,0,0,0.2);">⭐ Best Fit!</span>' : ''}</div>
${comp.formula !== comp.ion_formula ? `<div class="composition-formula">${formatFormula(comp.formula)}${dnaOnlyBadge}</div>` : ''}
${comp.ion_formula ? `<div style="font-size: ${comp.formula === comp.ion_formula ? '1em' : '0.85em'}; color: ${comp.formula === comp.ion_formula ? '#333' : '#666'}; margin-top: ${comp.formula === comp.ion_formula ? '0' : '3px'}; ${comp.formula === comp.ion_formula ? 'font-weight: 500;' : ''}">
<strong>Ion Formula:</strong> ${formatFormula(comp.ion_formula)}
<button onclick="copyFormula(this.dataset.formula, event)"
data-formula="${comp.ion_formula.replace(/"/g, '&quot;')}"
style="margin-left: 5px; padding: 2px 8px; background: #667eea; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 0.85em;"
title="Copy plain text formula">Copy</button>
${comp.formula !== comp.ion_formula ? `<br><span style="font-size: 0.9em; color: #999; font-style: italic;">(Neutral - (Q<sub>cl</sub> + z)H)</span>` : ''}
</div>` : ''}
<div class="composition-info">
${comp.num_strands !== null ? `<strong>${currentAnalysisMode === 'complex' ? 'Duplexes' : 'Strands'}:</strong> ${currentAnalysisMode === 'complex' ? comp.num_strands / 2 : comp.num_strands} |` : ''}
<strong>nAg:</strong> ${comp.num_silver} |
${comp.n0 !== null ? `<strong>N₀:</strong> ${comp.n0} |` : ''}
${comp.qcl !== null ? `<strong>Q<sub>cl</sub>:</strong> ${comp.displayed_qcl !== undefined ? comp.displayed_qcl : comp.qcl} |` : ''}
<strong>z:</strong> ${comp.z}
${comp.adduct ? ` | <strong>Adduct:</strong> ${comp.adduct}` : ''}
${comp.is_multimer ? ` | <span style="background: #e8f5e9; color: #2e7d32; padding: 1px 6px; border-radius: 3px; font-weight: bold; font-size: 0.85em;">${comp.multimer_label}</span>` : ''}
</div>
<div class="composition-info">
<strong>Expected m/z:</strong> ${comp.expected_mz.toFixed(4)} |
<strong>ΔX₀:</strong> ${(() => {
// Calculate ΔX₀ dynamically
// Prefer comp.exp_x0 (from backend for custom searches), fallback to peakData.exp_x0
const currentExpX0 = (comp.exp_x0 !== null && comp.exp_x0 !== undefined) ? comp.exp_x0 : peakData.exp_x0;
const theoX0 = comp.theo_x0;
if (currentExpX0 !== null && currentExpX0 !== undefined &&
theoX0 !== null && theoX0 !== undefined) {
const dynamicX0Error = Math.abs(theoX0 - currentExpX0);
return `<span style="color: ${isManualFit ? '#9c27b0' : 'inherit'}; font-weight: ${isManualFit ? 'bold' : 'normal'};">${dynamicX0Error.toFixed(4)} m/z</span>`;
} else {
return '<span style="color: #dc3545;">Failed</span>';
}
})()}
${(() => {
const ps = comp.pattern_similarity !== undefined && comp.pattern_similarity !== null
? comp.pattern_similarity
: comp.pattern_score;
if (ps === undefined || ps === null) return '';
let tierSym, tierColor;
if (ps > 0.8) { tierSym = '▲'; tierColor = '#27ae60'; }
else if (ps >= 0.5){ tierSym = '○'; tierColor = '#f39c12'; }
else { tierSym = '▽'; tierColor = '#e74c3c'; }
return ` | <strong>Pattern similarity:</strong> <span style="color: ${tierColor}; font-weight: 600;" title="Confidence indicator (▲ >0.8, ○ 0.5–0.8, ▽ <0.5). Best-fit ranking is by ΔX₀.">${ps.toFixed(3)} ${tierSym}</span>`;
})()}
</div>
<div class="composition-info" style="font-size: 0.9em;">
${comp.theo_x0 !== null && comp.theo_x0 !== undefined ?
`<strong>Theoretical X₀:</strong> ${comp.theo_x0.toFixed(4)} m/z` :
'<strong>Theoretical X₀:</strong> N/A'}
${comp.theo_sigma !== null && comp.theo_sigma !== undefined ?
` | <strong>X₀ uncertainty:</strong> ±${comp.theo_sigma.toFixed(6)} m/z` :
''}
</div>
${comp.n0 !== null && comp.qcl !== null ? `<div class="composition-details" style="font-size: 0.85em; color: #888;">
${comp.adduct && comp.adduct_charge ?
`Relation: N₀ + Q<sub>cl</sub> - adduct_charge = nAg → ${comp.n0} + ${comp.displayed_qcl !== undefined ? comp.displayed_qcl : comp.qcl} - (${comp.adduct_charge}) = ${comp.num_silver} ✓` :
`Relation: N₀ + Q<sub>cl</sub> = nAg → ${comp.n0} + ${comp.qcl} = ${comp.num_silver} ✓`}
</div>` : ''}
<div style="display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; align-items: center;">
<button onclick='downloadTheorySpectrum(event, ${comp.num_strands}, ${comp.num_silver}, ${comp.z}, ${comp.qcl}, ${JSON.stringify(comp.theo_mz)}, ${JSON.stringify(comp.theo_intensity)})'
style="padding: 6px 12px; background: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9em;">
Download
</button>
</div>
</div>
<button onclick='deleteComposition(${peakData.peak_mz}, "${clickedCompId}", event)'
style="padding: 2px 6px; background: #dc3545; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 1.1em; line-height: 1; min-width: 24px; height: 24px; margin-left: 10px; align-self: flex-start; margin-top: 5px;"
title="Delete this composition"></button>
</div>
`;
compositionDiv.appendChild(compDiv);
});
// Old calibration warning code removed - now using client-side logic above (line 1217-1247)
} // End of hasCompositions block
// Add manual composition input section (ALWAYS shown, even when no compositions found)
const manualInputDiv = document.createElement('div');
manualInputDiv.style.cssText = 'padding: 15px; background: #e3f2fd; border-left: 3px solid #2196f3; border-radius: 4px; margin-top: 20px;';
manualInputDiv.innerHTML = `
<h4 style="margin: 0 0 10px 0; color: #1976d2;">Add Custom Composition</h4>
<!-- Toggle Tabs -->
<div style="display: flex; gap: 5px; margin-bottom: 15px; border-bottom: 2px solid #ddd;">
<button id="tab-params" onclick="switchCompositionInputTab('params')"
style="padding: 8px 16px; background: #2196f3; color: white; border: none; border-radius: 4px 4px 0 0; cursor: pointer; font-weight: bold;">
By Parameters
</button>
<button id="tab-formula" onclick="switchCompositionInputTab('formula')"
style="padding: 8px 16px; background: #ddd; color: #666; border: none; border-radius: 4px 4px 0 0; cursor: pointer; font-weight: bold;">
By Formula
</button>
</div>
<!-- Parameters Input (nf, nAg, Qcl) -->
<div id="input-params" style="display: block;">
<div style="font-size: 0.9em; color: #666; margin-bottom: 10px;">
Specify composition parameters:
</div>
<!-- DNA-only checkbox -->
<div style="margin-bottom: 10px;">
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" id="dna-only-checkbox" onchange="toggleDNAOnly()"
style="margin-right: 8px; width: 18px; height: 18px; cursor: pointer;">
<span style="font-weight: bold;">DNA/XNA Only</span>
</label>
</div>
<!-- Mode Selection: Exact vs Search -->
<div style="margin-bottom: 15px; padding: 10px; background: #f5f5f5; border-radius: 4px;">
<label style="font-size: 0.85em; font-weight: bold; display: block; margin-bottom: 8px;">Composition Mode:</label>
<div style="display: flex; flex-direction: column; gap: 8px;">
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="radio" name="param-mode" value="exact" checked onchange="toggleParamMode()"
style="margin-right: 8px; width: 16px; height: 16px; cursor: pointer;">
<div>
<strong style="font-size: 0.9em;">Exact Composition</strong>
<div style="font-size: 0.75em; color: #666; margin-top: 2px;">${currentAnalysisMode === 'complex' ? 'Specify Duplexes and nAg (N₀ = 0, Qcl = nAg)' : 'Specify Strands, nAg, and Qcl (calculates single N₀)'}</div>
</div>
</label>
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="radio" name="param-mode" value="search" onchange="toggleParamMode()"
style="margin-right: 8px; width: 16px; height: 16px; cursor: pointer;">
<div>
<strong style="font-size: 0.9em;">Search with Adduct</strong>
<div style="font-size: 0.75em; color: #666; margin-top: 2px;">${currentAnalysisMode === 'complex' ? 'Specify Duplexes, nAg, and Adduct (N₀ = 0, Qcl = nAg + adduct)' : 'Specify Strands, nAg, and Adduct (searches for best N₀/Qcl)'}</div>
</div>
</label>
</div>
</div>
<!-- Complex mode: Strand Type Selector -->
<div id="complex-strand-type-selector" style="display: ${currentAnalysisMode === 'complex' ? 'block' : 'none'}; margin-bottom: 15px; padding: 10px; background: #fff3e0; border-radius: 4px; border-left: 3px solid #ff9800;">
<label style="font-size: 0.85em; font-weight: bold; display: block; margin-bottom: 8px;">Strand Type:</label>
<div style="display: flex; gap: 15px; flex-wrap: wrap;">
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="radio" name="complex-strand-type" value="complex" checked
style="margin-right: 6px; cursor: pointer;">
<span style="font-size: 0.9em;"><strong>Complex</strong> (both strands)</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="radio" name="complex-strand-type" value="strand1"
style="margin-right: 6px; cursor: pointer;">
<span style="font-size: 0.9em;"><strong>Strand 1</strong> only</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="radio" name="complex-strand-type" value="strand2"
style="margin-right: 6px; cursor: pointer;">
<span style="font-size: 0.9em;"><strong>Strand 2</strong> only</span>
</label>
</div>
<small style="color: #666; font-size: 0.75em; display: block; margin-top: 5px;">
Select which strand configuration to analyze (N₀ = 0 for all complex mode compositions)
</small>
</div>
<!-- Common inputs: Strands/Complexes and nAg -->
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-bottom: 10px;">
<div>
<label id="manual-strands-label" style="display: block; font-size: 0.85em; margin-bottom: 3px; font-weight: bold;">${currentAnalysisMode === 'complex' ? 'Count:' : 'Strands (ns):'}</label>
<input type="number" id="manual-strands" value="1" min="1" max="10"
style="width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px;">
<small id="manual-strands-hint" style="color: #666; font-size: 0.7em; display: ${currentAnalysisMode === 'complex' ? 'block' : 'none'};">Number of selected strand type</small>
</div>
<div id="manual-nag-container">
<label style="display: block; font-size: 0.85em; margin-bottom: 3px; font-weight: bold;">Total Silver Atoms (nAg):</label>
<input type="number" id="manual-nag" value="8" min="0" max="50"
style="width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px;">
</div>
</div>
<!-- Exact mode: Qcl input (hidden for Complex mode since Qcl = nAg always) -->
<div id="exact-mode-inputs" style="margin-bottom: 10px;">
<div id="manual-qcl-container" style="display: ${currentAnalysisMode === 'complex' ? 'none' : 'block'};">
<label style="display: block; font-size: 0.85em; margin-bottom: 3px; font-weight: bold;">Core Charge (Qcl):</label>
<input type="number" id="manual-qcl" value="7" min="0" max="50"
style="width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px;">
</div>
</div>
<!-- Search mode: Adduct input -->
<div id="search-mode-inputs" style="display: none; margin-bottom: 10px;">
<label style="display: block; font-size: 0.85em; margin-bottom: 5px; font-weight: bold;">Adducts:</label>
<!-- Adduct input rows container -->
<div id="param-adduct-rows" style="margin-bottom: 8px;">
<!-- Adduct rows will be added here dynamically -->
</div>
<!-- Add adduct button -->
<button type="button" onclick="addParamAdductRow()" style="padding: 5px 10px; background: #4CAF50; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 0.8em; margin-bottom: 5px;">
+ Add Adduct
</button>
<small id="search-mode-hint" style="color: #666; font-size: 0.75em; display: block;">
${currentAnalysisMode === 'complex' ? 'Complex mode: N₀ = 0 always, Qcl = nAg (will search with/without adducts)' : 'System will search across all N₀ values (Qcl = 0 to nAg) to find best match'}
</small>
</div>
<div style="display: flex; gap: 10px; align-items: center;">
<button onclick="addManualComposition(${peakData.peak_mz}, ${peakData.charge}, ${safeIntensity})"
style="padding: 8px 16px; background: #2196f3; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">
Add & Analyze
</button>
<span id="param-mode-hint" style="font-size: 0.85em; color: #666;">${currentAnalysisMode === 'complex' ? 'Complex mode: N₀ = 0 always, Q<sub>cl</sub> = nAg' : 'N₀ will be calculated as: N₀ = nAg - Q<sub>cl</sub>'}</span>
</div>
</div>
<!-- Formula Input (Ion Formula - Simplified) -->
<div id="input-formula" style="display: none;">
<div style="font-size: 0.9em; color: #666; margin-bottom: 10px;">
<strong>Enter the ion formula directly</strong> (what you observe in MS after ionization):
</div>
<div style="margin-bottom: 10px;">
<label style="display: block; font-size: 0.85em; margin-bottom: 3px; font-weight: bold;">Ion Formula:</label>
<input type="text" id="manual-formula" placeholder="e.g., C196H245N86O112P18Ag12"
style="width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px;">
<small style="color: #666; font-size: 0.8em;">
Enter the observed ion composition after ionization<br>
<strong>Example:</strong> C₁₉₆H₂₅₀N₈₆O₁₁₂P₁₈Ag₁₂ or C₉₇H₁₁₉N₃₅O₆₀P₉Ag₂<br>
<span style="color: #ff9800;">⚠️ N₀ and Qcl will not be calculated for manual formulas</span>
</small>
</div>
<div style="margin-bottom: 10px; padding: 8px; background: #fff3cd; border-left: 3px solid #ffc107; border-radius: 4px;">
<label style="display: flex; align-items: center; cursor: pointer; margin-bottom: 5px;">
<input type="checkbox" id="use-custom-z-checkbox" onchange="toggleCustomZ()"
style="margin-right: 8px; width: 18px; height: 18px; cursor: pointer;">
<span style="font-weight: bold; font-size: 0.9em;">Use different charge state (z) for this composition</span>
</label>
<div id="custom-z-input-container" style="display: none; margin-top: 8px;">
<label style="display: block; font-size: 0.85em; margin-bottom: 3px; font-weight: bold;">Custom z:</label>
<input type="number" id="manual-custom-z" value="${peakData.charge}" min="1" max="10"
style="width: 100px; padding: 6px; border: 1px solid #ffc107; border-radius: 4px;">
<small style="display: block; margin-top: 3px; color: #856404; font-size: 0.8em;">
Detected z = ${peakData.charge}. Enter different z if your ion formula was calculated with a different charge state.
</small>
</div>
</div>
<div style="display: flex; gap: 10px; align-items: center;">
<button onclick="addManualCompositionByFormula(${peakData.peak_mz}, ${peakData.charge}, ${safeIntensity})"
style="padding: 8px 16px; background: #2196f3; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">
Add & Analyze
</button>
<span style="font-size: 0.85em; color: #666;">Ion formula will be analyzed directly</span>
</div>
</div>
`;
compositionDiv.appendChild(manualInputDiv);
// Update the peak zoom plot with the current exp_x0 (which may be from manual fit)
showPeakZoom(peakData.peak_mz, 5, peakData.exp_x0);
}
function displayPeaks() {
if (!currentPeaks || currentPeaks.length === 0) return;
document.getElementById('peaks-section').style.display = 'block';
const peakList = document.getElementById('peak-list');
peakList.innerHTML = '';
currentPeaks.forEach((peak, index) => {
const peakDiv = document.createElement('div');
peakDiv.className = 'peak-item';
peakDiv.onclick = () => selectPeak(index);
const topComposition = peak.compositions[0];
peakDiv.innerHTML = `
<div class="peak-mz">m/z ${(peak.peak_mz || peak.mz).toFixed(4)}</div>
<div class="peak-info" style="display: flex; align-items: center; gap: 10px;">
<span>z (charge): ${peak.charge || 'Unknown'}</span>
<input type="number"
id="peak-z-${index}"
value="${peak.charge || ''}"
min="1" max="10"
placeholder="z"
onclick="event.stopPropagation()"
onchange="updatePeakCharge(${index}, this.value)"
style="width: 50px; padding: 2px 5px; border: 1px solid #ddd; border-radius: 3px;">
<span>| Intensity: ${peak.intensity.toFixed(2)}</span>
</div>
${topComposition ? `
<div class="peak-info">
Top: ${formatFormula(topComposition.formula)}
${topComposition.num_silver === 0 ? `<span style="color: #1976d2; font-size: 0.85em; font-weight: bold;">[${topComposition.type}]</span>` : `[N₀=${topComposition.n0}, Q<sub>cl</sub>=${topComposition.displayed_qcl !== undefined ? topComposition.displayed_qcl : topComposition.qcl}]`}
</div>
` : ''}
`;
peakList.appendChild(peakDiv);
});
}
function selectPeak(index) {
selectedPeak = currentPeaks[index];
// Update UI
document.querySelectorAll('.peak-item').forEach((item, i) => {
if (i === index) {
item.classList.add('selected');
} else {
item.classList.remove('selected');
}
});
// Display composition details
displayCompositions(selectedPeak);
}
function displayCompositions(peak, showAllStrands = false) {
console.log('displayCompositions called with peak:', peak);
console.log('has_other_strands:', peak.has_other_strands);
console.log('all_compositions length:', peak.all_compositions ? peak.all_compositions.length : 'undefined');
document.getElementById('composition-section').style.display = 'block';
const compositionDiv = document.getElementById('composition-details');
// Safety check for peak.compositions
if (!peak || !peak.compositions) {
compositionDiv.innerHTML = `
<h3>Peak at m/z ${peak && (peak.peak_mz || peak.mz) ? (peak.peak_mz || peak.mz).toFixed(4) : 'Unknown'}</h3>
<p style="color: red;">Error: No composition data available for this peak.</p>
`;
console.error('Peak missing compositions:', peak);
return;
}
// Checkbox for showing other strand numbers
let strandsCheckbox = '';
console.log('Checking if we should show checkbox. has_other_strands =', peak.has_other_strands);
if (peak.has_other_strands === true) {
const warningText = '⚠️ Other strand numbers also have good matches';
const explanationText = 'Check to view compositions for all strand numbers';
strandsCheckbox = `
<div style="padding: 10px; margin: 10px 0; background: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px;">
<label style="display: flex; align-items: center; cursor: pointer; font-size: 0.95em;">
<input type="checkbox" id="show-all-strands-checkbox"
${showAllStrands ? 'checked' : ''}
onchange="toggleAllStrands()"
style="margin-right: 8px; width: 18px; height: 18px; cursor: pointer;">
<span><strong>${warningText}</strong></span>
</label>
<p style="font-size: 0.85em; color: #856404; margin-top: 5px; margin-left: 26px;">
${explanationText}
</p>
</div>
`;
}
compositionDiv.innerHTML = `
<h3>Peak at m/z ${(peak.peak_mz || peak.mz).toFixed(4)}</h3>
<p><strong>z (charge state from isotope spacing):</strong> ${peak.charge || 'Unknown'}</p>
<p><strong>Intensity:</strong> ${peak.intensity ? peak.intensity.toFixed(2) : 'Unknown'}</p>
${strandsCheckbox}
<h4>Possible Compositions:</h4>
<p style="font-size: 0.9em; color: #666;">Ranked by X₀ centroid difference (smallest error = best match)</p>
<div style="padding: 12px; background: linear-gradient(135deg, #e8f5e9 0%, #e3f2fd 100%); border-left: 4px solid #4caf50; border-radius: 6px; margin: 10px 0 15px 0; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 1.5em;"></span>
<div style="flex: 1;">
<strong style="color: #2e7d32; font-size: 1.05em;">Check boxes to overlay theory spectra</strong>
<div style="font-size: 0.9em; color: #555; margin-top: 4px;">
Toggle checkboxes to compare theoretical isotope patterns with experimental data
</div>
</div>
</div>
</div>
`;
// Decide which compositions to show
const compositionsToShow = showAllStrands && peak.all_compositions && peak.all_compositions.length > 0
? peak.all_compositions
: peak.compositions;
if (!compositionsToShow || compositionsToShow.length === 0) {
compositionDiv.innerHTML += '<p>No matching compositions found.</p>';
return;
}
compositionsToShow.forEach(comp => {
// Debug log to see what's in the composition
console.log('Selected peak composition:', {
formula: comp.formula,
neutral_formula: comp.neutral_formula,
ion_formula: comp.ion_formula,
z: comp.z
});
const compDiv = document.createElement('div');
compDiv.className = 'composition-item';
// Use neutral_formula for search (IPMolecule will handle protonation based on charge)
const searchFormula = comp.neutral_formula || comp.formula || 'MISSING';
// Check if this is DNA-only or XNA-only (no silver)
const isDNAOnly = comp.num_silver === 0;
const dnaOnlyBadge = isDNAOnly ?
`<span style="display: inline-block; margin-left: 8px; padding: 2px 8px; background: #e3f2fd; color: #1976d2; font-size: 0.75em; font-weight: bold; border-radius: 3px;">${comp.type}</span>` :
'';
const compId = `comp_${i}_${comp.num_strands}_${comp.num_silver}_${comp.z}_${comp.qcl}`;
compDiv.innerHTML = `
<div style="display: flex; align-items: flex-start; gap: 10px;">
<label style="display: flex; align-items: center; cursor: pointer; margin: 0;">
<input type="checkbox" id="${compId}"
onchange='toggleTheoryOverlay("${compId}", ${JSON.stringify(comp.theo_mz)}, ${JSON.stringify(comp.theo_intensity)}, "${formatFormula(comp.formula).replace(/'/g, "\\'")}", ${comp.num_strands}, ${comp.num_silver}, ${comp.z}, ${comp.qcl})'
style="width: 18px; height: 18px; margin: 0; cursor: pointer;">
</label>
<div style="flex: 1;">
${comp.formula !== comp.ion_formula ? `<div class="composition-formula">${formatFormula(comp.formula)}${dnaOnlyBadge}</div>` : ''}
${comp.ion_formula ? `<div style="font-size: ${comp.formula === comp.ion_formula ? '1em' : '0.85em'}; color: ${comp.formula === comp.ion_formula ? '#333' : '#666'}; margin-top: ${comp.formula === comp.ion_formula ? '0' : '3px'}; ${comp.formula === comp.ion_formula ? 'font-weight: 500;' : ''}">
<strong>Ion Formula:</strong> ${formatFormula(comp.ion_formula)}
<button onclick="copyFormula(this.dataset.formula, event)"
data-formula="${comp.ion_formula.replace(/"/g, '&quot;')}"
style="margin-left: 5px; padding: 2px 8px; background: #667eea; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 0.85em;"
title="Copy plain text formula">Copy</button>
${comp.formula !== comp.ion_formula ? `<br><span style="font-size: 0.9em; color: #999; font-style: italic;">(Neutral - (Q<sub>cl</sub> + z)H)</span>` : ''}
</div>` : ''}
<div class="composition-details">
${comp.num_strands !== null ? `<strong>${currentAnalysisMode === 'complex' ? 'Duplexes' : 'Strands'}:</strong> ${currentAnalysisMode === 'complex' ? comp.num_strands / 2 : comp.num_strands} |` : ''}
<strong>nAg:</strong> ${comp.num_silver} |
${comp.n0 !== null ? `<strong>N₀ (valence e⁻):</strong> ${comp.n0} |` : ''}
${comp.qcl !== null ? `<strong>Q<sub>cl</sub>:</strong> ${comp.displayed_qcl !== undefined ? comp.displayed_qcl : comp.qcl} |` : ''}
<strong>z:</strong> ${comp.z}+
${comp.adduct ? ` | <strong>Adduct:</strong> ${comp.adduct}` : ''}
${comp.is_multimer ? ` | <span style="background: #e8f5e9; color: #2e7d32; padding: 1px 6px; border-radius: 3px; font-weight: bold; font-size: 0.85em;">${comp.multimer_label}</span>` : ''}
</div>
<div class="composition-details">
<strong>Expected m/z:</strong> ${comp.expected_mz.toFixed(4)} |
<strong>ΔX₀:</strong> ${(() => {
// Calculate ΔX₀ dynamically - prefer comp.exp_x0 (from backend)
const effectiveExpX0 = (comp.exp_x0 !== null && comp.exp_x0 !== undefined) ? comp.exp_x0 : peak.exp_x0;
const theoX0 = comp.theo_x0;
if (effectiveExpX0 !== null && effectiveExpX0 !== undefined &&
theoX0 !== null && theoX0 !== undefined) {
const dynamicX0Error = Math.abs(theoX0 - effectiveExpX0);
return dynamicX0Error.toFixed(4) + ' m/z';
} else {
return '<span style="color: #dc3545;">N/A</span>';
}
})()}
</div>
${comp.n0 !== null && comp.qcl !== null ? `<div class="composition-details" style="font-size: 0.85em; color: #888;">
${comp.adduct && comp.adduct_charge ?
`Relation: N₀ + Q<sub>cl</sub> - adduct_charge = nAg → ${comp.n0} + ${comp.displayed_qcl !== undefined ? comp.displayed_qcl : comp.qcl} - (${comp.adduct_charge}) = ${comp.num_silver} ✓` :
`Relation: N₀ + Q<sub>cl</sub> = nAg → ${comp.n0} + ${comp.qcl} = ${comp.num_silver} ✓`}
</div>` : ''}
<div style="display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; align-items: center;">
<button onclick='downloadTheorySpectrum(event, ${comp.num_strands}, ${comp.num_silver}, ${comp.z}, ${comp.qcl}, ${JSON.stringify(comp.theo_mz)}, ${JSON.stringify(comp.theo_intensity)})'
style="padding: 6px 12px; background: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9em;">
Download
</button>
</div>
</div>
<button onclick='deleteComposition(${(peak.peak_mz || peak.mz)}, "${compId}", event)'
style="padding: 2px 6px; background: #dc3545; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 1.1em; line-height: 1; min-width: 24px; height: 24px; margin-left: 10px; align-self: flex-start; margin-top: 5px;"
title="Delete this composition"></button>
</div>
`;
compositionDiv.appendChild(compDiv);
});
}
function toggleAllStrands() {
const checkbox = document.getElementById('show-all-strands-checkbox');
if (selectedPeak) {
displayCompositions(selectedPeak, checkbox.checked);
}
}
function searchThisComposition(formula, charge, qcl) {
console.log('Original formula:', formula, 'charge:', charge, 'qcl:', qcl);
// Sanitize formula - remove any "mz" suffix that might have been accidentally appended
formula = formula.trim();
if (formula.endsWith('mz')) {
console.log('Found and removing "mz" suffix');
formula = formula.slice(0, -2);
}
console.log('Sanitized formula:', formula);
// Pre-fill the search form with this composition
document.getElementById('search-formula').value = formula;
document.getElementById('search-charge').value = charge;
// Store qcl for the search
window.currentSearchQcl = qcl || 0;
// Execute the search
searchComposition();
}
function searchComposition() {
let formula = document.getElementById('search-formula').value;
const charge = parseInt(document.getElementById('search-charge').value);
const resolution = parseInt(document.getElementById('resolution').value);
const qcl = window.currentSearchQcl || 0;
console.log('=== SEARCH COMPOSITION START ===');
console.log('Raw formula from input:', formula);
console.log('Charge:', charge);
console.log('Qcl:', qcl);
console.log('Resolution:', resolution);
if (!formula) {
showMessage('Please enter a chemical formula', 'error');
return;
}
// Sanitize formula - remove any "mz" or "m/z" text that might have been copied
formula = formula.trim();
console.log('After trim:', formula);
// Remove "mz" at the end
if (formula.endsWith('mz')) {
console.log('Detected mz suffix, removing...');
formula = formula.slice(0, -2).trim();
}
// Remove "m/z" anywhere in the formula
const beforeReplace = formula;
formula = formula.replace(/m\/z/gi, '').trim();
if (beforeReplace !== formula) {
console.log('Removed m/z text. Before:', beforeReplace, 'After:', formula);
}
console.log('Final cleaned formula for search:', formula);
if (!currentSpectrum || !currentPeaks) {
showMessage('Please upload a spectrum first', 'error');
return;
}
showMessage('Searching for composition...', 'info');
// Collect XNA data if in XNA mode or Complex XNA mode
let customXNA = null;
const isXNAMode = currentAnalysisMode === 'xna';
const isComplexMode = currentAnalysisMode === 'complex';
if (isXNAMode && appliedXNASettings) {
// Use the applied XNA settings (includes molecular_weight!)
customXNA = appliedXNASettings;
} else if (isComplexMode && appliedComplexSettings && appliedComplexSettings.xna) {
// Complex XNA mode: send combined formula AND individual strand formulas
customXNA = {
name: appliedComplexSettings.xna.name,
formula: appliedComplexSettings.xna.combinedFormula,
strand1_formula: appliedComplexSettings.xna.formula1,
strand2_formula: appliedComplexSettings.xna.formula2,
is_complex: true,
same_strands: appliedComplexSettings.xna.sameStrands || false
};
} else if (isComplexMode && appliedComplexSettings) {
// DNA-only Complex mode: no XNA formula, but still flag as complex for N0=0
customXNA = { name: 'Complex', is_complex: true, same_strands: false };
} else if (isXNAMode) {
showMessage('⚠️ XNA mode: Please click "Apply XNA Settings" first!', 'error');
return;
}
fetch('/find_composition', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
formula,
charge,
qcl,
resolution,
spectrum: currentSpectrum,
peaks: currentPeaks,
custom_xna: customXNA,
custom_adducts: getCustomAdducts(),
})
})
.then(response => {
console.log('Server response status:', response.status);
return response.json();
})
.then(data => {
console.log('Server response data:', data);
if (data.error) {
console.error('ERROR FROM SERVER:', data.error);
showMessage('Error: ' + data.error, 'error');
} else {
if (data.found === true) {
showMessage(`Composition found! ΔX₀: ${data.x0_error.toFixed(4)} m/z`, 'success');
} else {
// Show detailed popup when composition not found
showCompositionNotFoundPopup(data);
}
}
})
.catch(error => {
console.error('FETCH ERROR:', error);
showMessage('Network error: ' + error.message, 'error');
})
.catch(error => {
showMessage('Error searching: ' + error, 'error');
});
}
function updateStats(data) {
const statsArea = document.getElementById('stats-area');
// Handle both old format (with num_peaks) and new format (with num_points)
const numPoints = data.num_points || data.spectrum.mz.length;
const mzRange = data.mz_range || [Math.min(...data.spectrum.mz), Math.max(...data.spectrum.mz)];
statsArea.innerHTML = `
<div class="stat-card">
<div class="stat-value">${numPoints}</div>
<div class="stat-label">Data Points</div>
</div>
<div class="stat-card">
<div class="stat-value">${mzRange[0].toFixed(1)}</div>
<div class="stat-label">Min m/z</div>
</div>
<div class="stat-card">
<div class="stat-value">${mzRange[1].toFixed(1)}</div>
<div class="stat-label">Max m/z</div>
</div>
<div class="stat-card">
<div class="stat-value">${currentPeaks.length}</div>
<div class="stat-label">Analyzed Peaks</div>
</div>
`;
}
function showMessage(message, type) {
const messageArea = document.getElementById('message-area');
const className = type === 'error' ? 'error' : (type === 'success' ? 'success' : 'info');
// Replace content with actual message
messageArea.style.display = 'block';
messageArea.style.justifyContent = 'flex-start';
messageArea.style.fontStyle = 'normal';
// Sanitize message to prevent XSS (allow only safe characters for status messages)
const safeMessage = escapeHtml(message);
messageArea.innerHTML = `<div class="${className}">${safeMessage}</div>`;
if (type !== 'info') {
setTimeout(() => {
// Restore "No messages" default
messageArea.style.display = 'flex';
messageArea.style.justifyContent = 'center';
messageArea.style.fontStyle = 'italic';
messageArea.innerHTML = 'No messages';
}, 5000);
}
}
function showCompositionNotFoundPopup(data) {
// Show detailed popup when composition search fails due to mass error
console.log('showCompositionNotFoundPopup called with:', data);
const modal = document.getElementById('compositionNotFoundModal');
const content = document.getElementById('compositionNotFoundContent');
if (!modal || !content) {
console.error('Modal elements not found!');
showMessage(data.message || 'Composition not found in spectrum', 'error');
return;
}
// Safe number formatting helper
const formatNum = (val, decimals) => {
if (val === undefined || val === null) return 'N/A';
return Number(val).toFixed(decimals);
};
let html = `
<div style="margin-bottom: 15px;">
<div style="background: #fff3cd; border: 1px solid #ffc107; border-radius: 6px; padding: 12px; margin-bottom: 15px;">
<strong style="color: #856404;">Composition Not Found</strong>
</div>
</div>
<table style="width: 100%; border-collapse: collapse; font-size: 0.9em;">
<tr>
<td style="padding: 8px; border-bottom: 1px solid #eee; color: #666;">Theoretical m/z:</td>
<td style="padding: 8px; border-bottom: 1px solid #eee; font-weight: bold;">${formatNum(data.theoretical_mz, 4)}</td>
</tr>
<tr>
<td style="padding: 8px; border-bottom: 1px solid #eee; color: #666;">Charge (z):</td>
<td style="padding: 8px; border-bottom: 1px solid #eee; font-weight: bold;">${data.charge || 'N/A'}</td>
</tr>
`;
if (data.nearest_peak_mz !== undefined) {
html += `
<tr>
<td style="padding: 8px; border-bottom: 1px solid #eee; color: #666;">Nearest Peak:</td>
<td style="padding: 8px; border-bottom: 1px solid #eee; font-weight: bold;">${formatNum(data.nearest_peak_mz, 4)} m/z</td>
</tr>
<tr>
<td style="padding: 8px; border-bottom: 1px solid #eee; color: #666;">Mass Error:</td>
<td style="padding: 8px; border-bottom: 1px solid #eee; font-weight: bold; color: #dc3545;">
${formatNum(data.mass_error_mz, 2)} m/z (${formatNum(data.mass_error_ppm, 0)} ppm)
</td>
</tr>
<tr>
<td style="padding: 8px; border-bottom: 1px solid #eee; color: #666;">Tolerance:</td>
<td style="padding: 8px; border-bottom: 1px solid #eee;">${formatNum(data.threshold_mz, 1)} m/z</td>
</tr>
`;
}
html += `</table>`;
if (data.reason === 'mass_error_exceeds_threshold' && data.mass_error_mz !== undefined) {
html += `
<div style="background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 6px; padding: 12px; margin-top: 15px;">
<p style="margin: 0; color: #721c24; font-size: 0.85em;">
<strong>Why not found:</strong> The mass error (${formatNum(data.mass_error_mz, 2)} m/z) exceeds the ${formatNum(data.threshold_mz, 1)} m/z tolerance threshold.
</p>
</div>
<div style="background: #e7f3ff; border: 1px solid #b8daff; border-radius: 6px; padding: 12px; margin-top: 10px;">
<p style="margin: 0; color: #004085; font-size: 0.85em;">
<strong>Suggestions:</strong><br>
- Verify the molecular formula is correct<br>
- Check if the charge state (z) is correct<br>
- Consider if adducts are needed
</p>
</div>
`;
} else if (data.reason === 'no_peaks_in_range') {
html += `
<div style="background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 6px; padding: 12px; margin-top: 15px;">
<p style="margin: 0; color: #721c24; font-size: 0.85em;">
<strong>Why not found:</strong> No peaks were detected in the spectrum.
</p>
</div>
`;
} else if (data.message) {
// Fallback: show the message from server
html += `
<div style="background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 6px; padding: 12px; margin-top: 15px;">
<p style="margin: 0; color: #721c24; font-size: 0.85em;">
${data.message}
</p>
</div>
`;
}
content.innerHTML = html;
modal.style.display = 'block';
console.log('Modal should now be visible');
}
function closeCompositionNotFoundModal() {
const modal = document.getElementById('compositionNotFoundModal');
modal.style.display = 'none';
}
function switchCompositionInputTab(tab) {
const tabParams = document.getElementById('tab-params');
const tabFormula = document.getElementById('tab-formula');
const inputParams = document.getElementById('input-params');
const inputFormula = document.getElementById('input-formula');
if (tab === 'params') {
tabParams.style.background = '#2196f3';
tabParams.style.color = 'white';
tabFormula.style.background = '#ddd';
tabFormula.style.color = '#666';
inputParams.style.display = 'block';
inputFormula.style.display = 'none';
} else {
tabFormula.style.background = '#2196f3';
tabFormula.style.color = 'white';
tabParams.style.background = '#ddd';
tabParams.style.color = '#666';
inputFormula.style.display = 'block';
inputParams.style.display = 'none';
}
}
// NOTE: toggleXNAInput and toggleComplexMode are obsolete - now using switchAnalysisMode()
// Update DNA mass for DNA-AgN mode (single strand)
function updateDNAMass() {
const dnaSequence = document.getElementById('dna-sequence').value.trim().toUpperCase();
const massDisplay = document.getElementById('dna-mass-display');
const massValue = document.getElementById('dna-mass-value');
// Hide display if sequence is empty
if (!dnaSequence) {
massDisplay.style.display = 'none';
return;
}
// Validate sequence (only ATCG allowed)
const validBases = /^[ATCG]+$/;
if (!validBases.test(dnaSequence)) {
massDisplay.style.display = 'block';
massValue.innerHTML = '<span style="color: #e74c3c;">Invalid sequence (only A, T, C, G allowed)</span>';
return;
}
// Single-strand mode: Call backend to calculate mass
fetch('/calculate_dna_mass', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dna_sequence: dnaSequence })
})
.then(response => response.json())
.then(data => {
if (data.error) {
massDisplay.style.display = 'block';
massValue.innerHTML = `<span style="color: #e74c3c;">Error: ${escapeHtml(data.error)}</span>`;
} else {
massDisplay.style.display = 'block';
massValue.innerHTML = `
<div style="margin-top: 5px;">
<div style="font-size: 1.1em; margin-bottom: 5px;">
<strong>${data.mass.toFixed(4)} Da</strong>
</div>
<div style="font-size: 0.9em; color: #555;">
<strong>Length:</strong> ${data.length} bases
(A: ${data.base_count.A}, T: ${data.base_count.T}, C: ${data.base_count.C}, G: ${data.base_count.G})
</div>
<div style="font-size: 0.9em; color: #555; margin-top: 3px;">
<strong>Formula:</strong> ${escapeHtml(formatFormula(data.formula))}
</div>
</div>
`;
}
})
.catch(error => {
massDisplay.style.display = 'block';
massValue.innerHTML = `<span style="color: #e74c3c;">Error: ${escapeHtml(String(error))}</span>`;
});
}
// Update Complex mass for Ag(I)-DNA/XNA Complex mode (double strand)
function updateComplexMass() {
const sequence1 = document.getElementById('complex-sequence-1').value.trim().toUpperCase();
const sequence2 = document.getElementById('dna-sequence-2').value.trim().toUpperCase();
const massDisplay = document.getElementById('dna-mass-display');
const massValue = document.getElementById('dna-mass-value');
// Check if XNA is enabled for each strand
const strand1XNA = document.getElementById('complex-strand1-xna-toggle').checked;
const strand2XNA = document.getElementById('complex-strand2-xna-toggle').checked;
const strand1XNAFormula = strand1XNA ? document.getElementById('complex-strand1-xna-formula').value.trim() : '';
const strand2XNAFormula = strand2XNA ? document.getElementById('complex-strand2-xna-formula').value.trim() : '';
// Hide display if first sequence is empty
if (!sequence1) {
massDisplay.style.display = 'none';
return;
}
// Validate sequences - XNA strands only need length, DNA strands need valid bases
const validBases = /^[ATCG]+$/;
if (!strand1XNA && !validBases.test(sequence1)) {
massDisplay.style.display = 'block';
massValue.innerHTML = '<span style="color: #e74c3c;">Invalid strand 1 sequence (only A, T, C, G allowed for DNA)</span>';
return;
}
if (sequence2 && !strand2XNA && !validBases.test(sequence2)) {
massDisplay.style.display = 'block';
massValue.innerHTML = '<span style="color: #e74c3c;">Invalid strand 2 sequence (only A, T, C, G allowed for DNA)</span>';
return;
}
// For XNA strands, validate formula is provided
if (strand1XNA && !strand1XNAFormula) {
massDisplay.style.display = 'block';
massValue.innerHTML = '<span style="color: #ff9800;">Please enter XNA formula for strand 1</span>';
return;
}
if (strand2XNA && sequence2 && !strand2XNAFormula) {
massDisplay.style.display = 'block';
massValue.innerHTML = '<span style="color: #ff9800;">Please enter XNA formula for strand 2</span>';
return;
}
// Call backend to calculate complex mass with XNA support
fetch('/calculate_complex_mass', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
dna_sequence_1: sequence1,
dna_sequence_2: sequence2 || '',
extra_atom: '',
extra_atom_count: 0,
strand1_xna: strand1XNA ? { formula: strand1XNAFormula, length: sequence1.length } : null,
strand2_xna: strand2XNA ? { formula: strand2XNAFormula, length: sequence2.length } : null
})
})
.then(response => response.json())
.then(data => {
if (data.error) {
massDisplay.style.display = 'block';
massValue.innerHTML = `<span style="color: #e74c3c;">Error: ${escapeHtml(data.error)}</span>`;
} else {
massDisplay.style.display = 'block';
// Build strand info based on DNA or XNA
let strand1Info, strand2Info;
if (strand1XNA) {
strand1Info = `<div style="font-size: 0.9em; color: #555; margin-bottom: 3px;">
<strong>Strand 1:</strong> ${sequence1.length} nt <span style="color: #ff9800;">(XNA: ${escapeHtml(strand1XNAFormula)})</span>
</div>`;
} else {
strand1Info = `<div style="font-size: 0.9em; color: #555; margin-bottom: 3px;">
<strong>Strand 1:</strong> ${data.length1} bases
${data.base_count1 ? `(A: ${data.base_count1.A}, T: ${data.base_count1.T}, C: ${data.base_count1.C}, G: ${data.base_count1.G})` : ''}
</div>`;
}
if (sequence2) {
if (strand2XNA) {
strand2Info = `<div style="font-size: 0.9em; color: #555;">
<strong>Strand 2:</strong> ${sequence2.length} nt <span style="color: #ff9800;">(XNA: ${strand2XNAFormula})</span>
</div>`;
} else {
strand2Info = `<div style="font-size: 0.9em; color: #555;">
<strong>Strand 2:</strong> ${data.length2} bases
${data.base_count2 ? `(A: ${data.base_count2.A}, T: ${data.base_count2.T}, C: ${data.base_count2.C}, G: ${data.base_count2.G})` : ''}
</div>`;
}
} else {
strand2Info = '<div style="font-size: 0.9em; color: #888; font-style: italic;">Enter strand 2 for complete complex mass</div>';
}
// Determine mode label
const modeLabel = (strand1XNA || strand2XNA) ? 'Ag(I)-DNA/XNA Complex Mode' : 'Ag(I)-DNA Complex Mode';
const modeColor = (strand1XNA || strand2XNA) ? '#fff9e6' : '#e3f2fd';
const modeBorderColor = (strand1XNA || strand2XNA) ? '#ff9800' : '#1565c0';
massValue.innerHTML = `
<div style="margin-top: 5px;">
<div style="background: ${modeColor}; padding: 5px 8px; border-radius: 4px; margin-bottom: 8px; color: ${modeBorderColor}; font-weight: bold;">
${modeLabel}
</div>
<div style="font-size: 1.1em; margin-bottom: 5px;">
<strong>Total Mass: ${data.total_mass.toFixed(4)} Da</strong>
</div>
${strand1Info}
${strand2Info}
<div style="font-size: 0.9em; color: #555; margin-top: 5px;">
<strong>Formula:</strong> ${formatFormula(data.formula)}
</div>
<div style="font-size: 0.85em; color: #888; margin-top: 3px;">
Total: ${data.total_length} nt (${data.length1} + ${data.length2 || 0})
</div>
</div>
`;
}
})
.catch(error => {
massDisplay.style.display = 'block';
massValue.innerHTML = `<span style="color: #e74c3c;">Error: ${escapeHtml(String(error))}</span>`;
});
}
function updateXNAMass() {
const xnaFormula = document.getElementById('xna-formula').value.trim();
const massDisplay = document.getElementById('xna-mass-display');
const massValue = document.getElementById('xna-mass-value');
// Hide display if formula is empty
if (!xnaFormula) {
massDisplay.style.display = 'none';
return;
}
// Atomic masses (monoisotopic)
const atomicMasses = {
'H': 1.007825,
'C': 12.0,
'N': 14.003074,
'O': 15.994915,
'P': 30.973762,
'S': 31.972071,
'F': 18.998403,
'Cl': 34.968853,
'Br': 78.918338,
'I': 126.904473
};
try {
// Parse formula like "C10H13N5O6P8"
const elementRegex = /([A-Z][a-z]?)(\d*)/g;
let match;
let totalMass = 0;
const composition = {};
while ((match = elementRegex.exec(xnaFormula)) !== null) {
const element = match[1];
const count = match[2] ? parseInt(match[2]) : 1;
if (!atomicMasses[element]) {
throw new Error(`Unknown element: ${element}`);
}
composition[element] = (composition[element] || 0) + count;
totalMass += atomicMasses[element] * count;
}
// Check if we actually parsed something
if (Object.keys(composition).length === 0) {
massDisplay.style.display = 'block';
massValue.innerHTML = '<span style="color: #e74c3c;">Invalid formula format</span>';
return;
}
// Display the result
massDisplay.style.display = 'block';
const compositionStr = Object.entries(composition)
.map(([elem, count]) => `${elem}: ${count}`)
.join(', ');
massValue.innerHTML = `
<div style="margin-top: 5px;">
<div style="font-size: 1.1em; margin-bottom: 5px;">
<strong>${totalMass.toFixed(4)} Da</strong>
</div>
<div style="font-size: 0.9em; color: #555;">
<strong>Composition:</strong> ${compositionStr}
</div>
<div style="font-size: 0.85em; color: #666; margin-top: 3px;">
Formula: ${escapeHtml(xnaFormula)}
</div>
</div>
`;
} catch (error) {
massDisplay.style.display = 'block';
massValue.innerHTML = `<span style="color: #e74c3c;">Error: ${escapeHtml(error.message)}</span>`;
}
}
function toggleCustomZ() {
const checkbox = document.getElementById('use-custom-z-checkbox');
const container = document.getElementById('custom-z-input-container');
if (checkbox.checked) {
container.style.display = 'block';
} else {
container.style.display = 'none';
}
}
function addManualCompositionByFormula(peakMz, charge, intensity) {
const formula = document.getElementById('manual-formula').value.trim();
const isXNAMode = currentAnalysisMode === 'xna';
const isComplexMode = currentAnalysisMode === 'complex';
// Get DNA sequence based on mode
let dnaSeq = '';
if (currentAnalysisMode === 'dna') {
dnaSeq = document.getElementById('dna-sequence').value.trim();
} else if (isComplexMode) {
dnaSeq = document.getElementById('complex-sequence-1').value.trim();
} else if (isXNAMode) {
// XNA mode doesn't use sequence - backend uses formula from appliedXNASettings
dnaSeq = '';
}
// Collect XNA data if in XNA mode or Complex XNA mode
let customXNA = null;
if (isXNAMode && appliedXNASettings) {
// Use the applied XNA settings
customXNA = appliedXNASettings;
} else if (isComplexMode && appliedComplexSettings && appliedComplexSettings.xna) {
// Complex XNA mode: send combined formula AND individual strand formulas
customXNA = {
name: appliedComplexSettings.xna.name,
formula: appliedComplexSettings.xna.combinedFormula,
strand1_formula: appliedComplexSettings.xna.formula1,
strand2_formula: appliedComplexSettings.xna.formula2,
is_complex: true,
same_strands: appliedComplexSettings.xna.sameStrands || false
};
} else if (isComplexMode && appliedComplexSettings) {
// DNA-only Complex mode: no XNA formula, but still flag as complex for N0=0
customXNA = { name: 'Complex', is_complex: true, same_strands: false };
} else if (isXNAMode) {
showMessage('⚠️ XNA mode: Please click "Apply XNA Settings" first!', 'error');
return;
}
// Check if user wants to use custom z
const useCustomZ = document.getElementById('use-custom-z-checkbox').checked;
const finalCharge = useCustomZ ? parseInt(document.getElementById('manual-custom-z').value) : charge;
// Validation - for XNA mode or XNA Complex mode with applied settings, sequence is not strictly needed
const isXNAComplexMode = isComplexMode && appliedComplexSettings && appliedComplexSettings.xna;
if (!isXNAMode && !isXNAComplexMode && !dnaSeq) {
showMessage('Please enter DNA sequence first!', 'error');
return;
}
if (!formula) {
showMessage('Please enter an ion formula', 'error');
return;
}
if (useCustomZ && (isNaN(finalCharge) || finalCharge < 1 || finalCharge > 10)) {
showMessage('Invalid custom charge state (must be 1-10)', 'error');
return;
}
const chargeMsg = useCustomZ ? ` with custom z=${finalCharge}` : '';
showMessage(`Adding composition: ${formula}${chargeMsg}...`, 'info');
// Pass manual fit data if available from selectedPeak
const requestData = {
peak_mz: peakMz,
charge: finalCharge,
intensity: intensity,
formula: formula,
dna_sequence: dnaSeq,
resolution: parseInt(document.getElementById('resolution').value),
spectrum: currentSpectrum,
custom_xna: customXNA,
custom_adducts: getCustomAdducts(),
};
// If manual fit was applied, include the manual fit exp_x0 and range
if (selectedPeak && selectedPeak.exp_x0 !== null && selectedPeak.exp_x0 !== undefined) {
requestData.exp_x0 = selectedPeak.exp_x0;
console.log(`[addManualCompositionByFormula] Including exp_x0 = ${selectedPeak.exp_x0.toFixed(4)} from current peak`);
}
if (selectedPeak && selectedPeak.manual_fit_range && selectedPeak.manual_fit_range.length === 2) {
requestData.manual_fit_range = selectedPeak.manual_fit_range;
console.log(`[addManualCompositionByFormula] Including manual_fit_range = [${selectedPeak.manual_fit_range[0].toFixed(4)}, ${selectedPeak.manual_fit_range[1].toFixed(4)}]`);
}
fetch('/add_manual_composition_by_formula', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestData)
})
.then(response => response.json())
.then(data => {
if (data.error) {
showMessage('Error: ' + data.error, 'error');
} else {
const comp = data.composition;
const MZ_TOLERANCE = 5.0;
// Check mass error only if expected_mz is available
if (comp.expected_mz !== undefined && comp.expected_mz !== null) {
const massErrorMz = Math.abs(comp.expected_mz - peakMz);
const massErrorPpm = Math.abs((comp.expected_mz - peakMz) / peakMz * 1e6);
// Check if mass error exceeds threshold
if (massErrorMz > MZ_TOLERANCE) {
// Show warning popup for large mass error
showCompositionNotFoundPopup({
found: false,
theoretical_mz: comp.expected_mz,
charge: finalCharge,
nearest_peak_mz: peakMz,
mass_error_mz: massErrorMz,
mass_error_ppm: massErrorPpm,
threshold_mz: MZ_TOLERANCE,
reason: 'mass_error_exceeds_threshold',
message: `Theoretical m/z ${comp.expected_mz.toFixed(4)} is ${massErrorMz.toFixed(2)} m/z (${massErrorPpm.toFixed(0)} ppm) away from clicked peak (${peakMz.toFixed(4)}). Exceeds ${MZ_TOLERANCE.toFixed(1)} m/z tolerance.`
});
return; // Don't add composition when error is too large
}
}
// Find the peak in currentPeaks
let peakIndex = currentPeaks.findIndex(p => Math.abs((p.peak_mz || p.mz) - peakMz) < 0.01);
if (peakIndex >= 0) {
// Ensure compositions array exists (may be undefined if no auto compositions found)
if (!currentPeaks[peakIndex].compositions) {
currentPeaks[peakIndex].compositions = [];
}
// Add the new composition to existing peak
currentPeaks[peakIndex].compositions.push(data.composition);
// Re-display the results
displayClickedPeakResults(currentPeaks[peakIndex]);
} else {
// Peak not found in currentPeaks - use selectedPeak if available
if (selectedPeak) {
if (!selectedPeak.compositions) {
selectedPeak.compositions = [];
}
selectedPeak.compositions.push(data.composition);
displayClickedPeakResults(selectedPeak);
}
}
// Use displayed_qcl when available (for compositions with adducts)
const displayQcl = data.composition.displayed_qcl !== undefined ? data.composition.displayed_qcl : data.composition.qcl;
const qclMsg = displayQcl !== null ? `, Qcl=${displayQcl}` : '';
const n0SuccessMsg = data.composition.n0 !== null ? `, N0=${data.composition.n0}` : '';
const adductMsg = data.composition.adduct ? ` +${data.composition.adduct}` : '';
showMessage(`Composition added! Formula=${formula}${n0SuccessMsg}${qclMsg}${adductMsg}`, 'success');
}
})
.catch(error => {
console.error('Error adding composition by formula:', error);
showMessage('Error adding composition: ' + error, 'error');
});
}
function toggleParamMode() {
const mode = document.querySelector('input[name="param-mode"]:checked').value;
const exactInputs = document.getElementById('exact-mode-inputs');
const searchInputs = document.getElementById('search-mode-inputs');
const hint = document.getElementById('param-mode-hint');
if (mode === 'exact') {
exactInputs.style.display = 'block';
searchInputs.style.display = 'none';
hint.innerHTML = 'N₀ will be calculated as: N₀ = nAg - Q<sub>cl</sub>';
} else {
exactInputs.style.display = 'none';
searchInputs.style.display = 'block';
hint.innerHTML = 'System will search all N₀ values (Qcl = 0 to nAg) for best match';
}
}
// Parameter mode adduct row counter
let paramAdductRowCount = 0;
function addParamAdductRow() {
const container = document.getElementById('param-adduct-rows');
const rowId = `param-adduct-row-${paramAdductRowCount++}`;
const row = document.createElement('div');
row.id = rowId;
row.style.cssText = 'display: flex; gap: 8px; margin-bottom: 5px; align-items: center;';
row.innerHTML = `
<select class="param-adduct-select" style="flex: 1; padding: 5px; border: 1px solid #ddd; border-radius: 3px; font-size: 0.85em;">
<option value="">Select Adduct</option>
</select>
<button type="button" onclick="removeParamAdductRow('${rowId}')"
style="padding: 5px 10px; background: #f44336; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 0.8em;">
</button>
`;
container.appendChild(row);
// Populate the select with adducts
const select = row.querySelector('.param-adduct-select');
populateAdductSelect(select);
}
function removeParamAdductRow(rowId) {
const row = document.getElementById(rowId);
if (row) {
row.remove();
}
}
function populateAdductSelect(selectElement) {
// Clear existing options except the first "Select Adduct" option
while (selectElement.options.length > 1) {
selectElement.remove(1);
}
// Use the cached adduct data
if (window.cachedAdducts && window.cachedAdducts.length > 0) {
window.cachedAdducts.forEach(adduct => {
const option = document.createElement('option');
option.value = adduct.name;
option.textContent = `${adduct.name} (${adduct.mass > 0 ? '+' : ''}${adduct.mass.toFixed(4)} Da, z=${adduct.charge > 0 ? '+' : ''}${adduct.charge})`;
selectElement.appendChild(option);
});
}
}
function refreshAllAdductSelects() {
// Refresh all existing parameter adduct dropdowns
const selects = document.querySelectorAll('.param-adduct-select');
selects.forEach(select => {
const currentValue = select.value; // Preserve current selection
populateAdductSelect(select);
if (currentValue) {
select.value = currentValue; // Restore selection if still valid
}
});
}
function getParamAdducts() {
// Collect all adduct inputs
const rows = document.querySelectorAll('#param-adduct-rows > div');
const adducts = [];
console.log('getParamAdducts: Found', rows.length, 'adduct rows');
rows.forEach((row, idx) => {
const select = row.querySelector('.param-adduct-select');
console.log(` Row ${idx}: select found=${!!select}, value="${select ? select.value : 'N/A'}"`);
if (select && select.value) {
const entry = { name: select.value, count: 1 };
// For custom adducts (this browser's localStorage), send mass/charge
// inline so the server doesn't need to remember them.
const custom = getCustomAdductByName(select.value);
if (custom) {
entry.mass = custom.mass;
entry.charge = custom.charge;
if (custom.formula) entry.formula = custom.formula;
}
adducts.push(entry);
}
});
console.log('getParamAdducts: Returning', adducts);
return adducts;
}
function toggleDNAOnly() {
const isDNAOnly = document.getElementById('dna-only-checkbox').checked;
const nagContainer = document.getElementById('manual-nag-container');
const qclContainer = document.getElementById('manual-qcl-container');
const nagInput = document.getElementById('manual-nag');
const qclInput = document.getElementById('manual-qcl');
if (isDNAOnly) {
// Hide nAg and Qcl inputs, set to 0
nagContainer.style.display = 'none';
qclContainer.style.display = 'none';
nagInput.value = 0;
qclInput.value = 0;
} else {
// Show nAg and Qcl inputs, reset to defaults
nagContainer.style.display = 'block';
qclContainer.style.display = 'block';
nagInput.value = 8;
qclInput.value = 7;
}
}
function addManualComposition(peakMz, charge, intensity) {
const inputValue = parseInt(document.getElementById('manual-strands').value);
const isDNAOnly = document.getElementById('dna-only-checkbox').checked;
const isXNAMode = currentAnalysisMode === 'xna';
const isComplexMode = currentAnalysisMode === 'complex';
// Get strand type selection for complex mode
let complexStrandType = 'complex'; // Default to full complex
if (isComplexMode) {
const strandTypeRadio = document.querySelector('input[name="complex-strand-type"]:checked');
if (strandTypeRadio) {
complexStrandType = strandTypeRadio.value; // 'complex', 'strand1', or 'strand2'
}
}
// Calculate strands based on mode and strand type
// In Complex mode with 'complex' selected: inputValue = number of complexes (1 complex = 2 strands)
// In Complex mode with 'strand1' or 'strand2': inputValue = number of single strands
let strands;
let displayUnit;
if (isComplexMode) {
if (complexStrandType === 'complex') {
strands = inputValue * 2; // 1 complex = 2 strands
displayUnit = 'complexes';
} else {
strands = inputValue; // Single strand count
displayUnit = complexStrandType === 'strand1' ? 'strand1' : 'strand2';
}
} else {
strands = inputValue;
displayUnit = 'strands';
}
// Get DNA sequence based on mode
let dnaSeq = '';
let dnaSeq2 = ''; // Second sequence for DNA complex mode
if (currentAnalysisMode === 'dna') {
dnaSeq = document.getElementById('dna-sequence').value.trim();
} else if (isComplexMode) {
dnaSeq = document.getElementById('complex-sequence-1').value.trim();
// Get second sequence for DNA complex (may be same as first if sameStrands)
const seq2Input = document.getElementById('dna-sequence-2');
dnaSeq2 = seq2Input ? seq2Input.value.trim() : dnaSeq;
if (!dnaSeq2) dnaSeq2 = dnaSeq; // Fallback to seq1 if seq2 is empty
} else if (isXNAMode) {
// XNA mode doesn't use sequence - backend uses formula from appliedXNASettings
dnaSeq = '';
}
// Collect XNA data if in XNA mode or Complex XNA mode
let customXNA = null;
if (isXNAMode && appliedXNASettings) {
// Use the applied XNA settings
customXNA = appliedXNASettings;
} else if (isComplexMode && appliedComplexSettings && appliedComplexSettings.xna) {
// Complex XNA mode: send combined formula AND individual strand formulas
customXNA = {
name: appliedComplexSettings.xna.name,
formula: appliedComplexSettings.xna.combinedFormula,
strand1_formula: appliedComplexSettings.xna.formula1,
strand2_formula: appliedComplexSettings.xna.formula2,
is_complex: true,
same_strands: appliedComplexSettings.xna.sameStrands || false
};
} else if (isComplexMode && appliedComplexSettings) {
// DNA-only Complex mode: send both sequences for strand1/strand2 selection
customXNA = {
name: 'Complex',
is_complex: true,
same_strands: appliedComplexSettings.sameStrands || false,
dna_sequence1: dnaSeq,
dna_sequence2: dnaSeq2
};
} else if (isComplexMode && !appliedComplexSettings) {
showMessage('⚠️ Complex mode: Please click "Apply Complex Settings" first!', 'error');
return;
} else if (isXNAMode) {
showMessage('⚠️ XNA mode: Please click "Apply XNA Settings" first!', 'error');
return;
}
// Validation - for XNA mode or XNA Complex mode with applied settings, sequence is not strictly needed
const isXNAComplexMode = isComplexMode && appliedComplexSettings && appliedComplexSettings.xna;
if (!isXNAMode && !isXNAComplexMode && !dnaSeq) {
showMessage('Please enter DNA sequence first!', 'error');
return;
}
if (isNaN(inputValue) || inputValue < 1 || inputValue > 10) {
showMessage(`Invalid number of ${displayUnit} (must be 1-10)`, 'error');
return;
}
// Check which mode is selected
const mode = document.querySelector('input[name="param-mode"]:checked').value;
if (mode === 'exact') {
// EXACT MODE: User specifies Qcl, calculates single N₀
// For Complex mode: N₀ = 0 always, so Qcl = nAg
const nag = isDNAOnly ? 0 : parseInt(document.getElementById('manual-nag').value);
// For Complex mode, Qcl = nAg (N₀ = 0 always)
const qcl = isDNAOnly ? 0 : (isComplexMode ? nag : parseInt(document.getElementById('manual-qcl').value));
// Skip silver/N0 validation if DNA-only mode
if (!isDNAOnly) {
if (isNaN(nag) || nag < 0 || nag > 50) {
showMessage('Invalid number of silver atoms (must be 0-50)', 'error');
return;
}
// Skip Qcl validation for Complex mode (Qcl is auto-set to nAg)
if (!isComplexMode) {
if (isNaN(qcl) || qcl < 0 || qcl > nag) {
showMessage('Invalid Qcl (must be 0 to nAg)', 'error');
return;
}
const n0 = nag - qcl;
if (n0 < 0) {
showMessage('Invalid composition: N0 must be >= 0 (N0 = nAg - Qcl)', 'error');
return;
}
}
}
const n0 = nag - qcl; // For Complex: n0 = nag - nag = 0
// Build display string based on mode
let displayStr;
if (isComplexMode) {
if (complexStrandType === 'complex') {
displayStr = `nd=${inputValue}`;
} else {
displayStr = `${complexStrandType}×${inputValue}`;
}
} else {
displayStr = `ns=${strands}`;
}
showMessage(`Adding composition: ${displayStr}, nAg=${nag}, Qcl=${qcl}, N0=${n0}...`, 'info');
// Pass manual fit data if available from selectedPeak
const requestData = {
peak_mz: peakMz,
charge: charge,
intensity: intensity,
num_strands: strands,
num_silver: nag,
qcl: qcl,
dna_sequence: dnaSeq,
resolution: parseInt(document.getElementById('resolution').value),
spectrum: currentSpectrum,
custom_xna: customXNA,
strand_type: isComplexMode ? complexStrandType : null, // 'strand1', 'strand2', or 'complex'
custom_adducts: getCustomAdducts(),
};
// If manual fit was applied, include the manual fit exp_x0 and range
if (selectedPeak && selectedPeak.exp_x0 !== null && selectedPeak.exp_x0 !== undefined) {
requestData.exp_x0 = selectedPeak.exp_x0;
console.log(`[addManualComposition] Including exp_x0 = ${selectedPeak.exp_x0.toFixed(4)} from current peak`);
}
if (selectedPeak && selectedPeak.manual_fit_range && selectedPeak.manual_fit_range.length === 2) {
requestData.manual_fit_range = selectedPeak.manual_fit_range;
console.log(`[addManualComposition] Including manual_fit_range = [${selectedPeak.manual_fit_range[0].toFixed(4)}, ${selectedPeak.manual_fit_range[1].toFixed(4)}]`);
}
fetch('/add_manual_composition', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestData)
})
.then(response => response.json())
.then(data => {
console.log('[addManualComposition] Response:', data);
if (data.error) {
showMessage('Error: ' + data.error, 'error');
} else {
const comp = data.composition;
const MZ_TOLERANCE = 5.0;
console.log('[addManualComposition] comp.expected_mz =', comp.expected_mz, ', peakMz =', peakMz);
// Check mass error only if expected_mz is available
if (comp.expected_mz !== undefined && comp.expected_mz !== null) {
const massErrorMz = Math.abs(comp.expected_mz - peakMz);
const massErrorPpm = Math.abs((comp.expected_mz - peakMz) / peakMz * 1e6);
console.log('[addManualComposition] massErrorMz =', massErrorMz, ', MZ_TOLERANCE =', MZ_TOLERANCE);
// For manual formula entry, show warning but still proceed
if (massErrorMz > MZ_TOLERANCE) {
console.log('[addManualComposition] Mass error exceeds threshold, showing warning but proceeding');
showMessage(`Note: Theoretical m/z ${comp.expected_mz.toFixed(4)} is ${massErrorMz.toFixed(2)} m/z from clicked peak (${peakMz.toFixed(4)}). Overlay will be shown at the theoretical position.`, 'warning');
}
} else {
console.log('[addManualComposition] expected_mz not available, skipping mass error check');
}
// Find the peak in currentPeaks
let peakIndex = currentPeaks.findIndex(p => Math.abs((p.peak_mz || p.mz) - peakMz) < 0.01);
if (peakIndex >= 0) {
// Ensure compositions array exists (may be undefined if no auto compositions found)
if (!currentPeaks[peakIndex].compositions) {
currentPeaks[peakIndex].compositions = [];
}
// Add the new composition to existing peak
currentPeaks[peakIndex].compositions.push(data.composition);
// Re-display the results
displayClickedPeakResults(currentPeaks[peakIndex]);
} else {
// Peak not found in currentPeaks - use selectedPeak if available
if (selectedPeak) {
if (!selectedPeak.compositions) {
selectedPeak.compositions = [];
}
selectedPeak.compositions.push(data.composition);
displayClickedPeakResults(selectedPeak);
}
}
const expectedMzMsg = comp.expected_mz ? `Expected m/z=${comp.expected_mz.toFixed(4)}, ` : '';
const x0ErrorMsg = comp.x0_error !== undefined ? `ΔX₀=${comp.x0_error.toFixed(4)}` : '';
showMessage(`Composition added! N0=${n0}, ${expectedMzMsg}${x0ErrorMsg}`, 'success');
}
})
.catch(error => {
console.error('Error adding composition:', error);
showMessage('Error adding composition: ' + error, 'error');
});
} else {
// SEARCH MODE: User specifies Adducts, searches all N₀ for best match
const nag = parseInt(document.getElementById('manual-nag').value);
const adducts = getParamAdducts(); // Get array of {name, count}
// Validation
if (isNaN(nag) || nag < 0 || nag > 50) {
showMessage('Invalid number of silver atoms (must be 0-50)', 'error');
return;
}
// Build adduct display string
let adductDisplay = 'None';
if (adducts.length > 0) {
adductDisplay = adducts.map(a => a.count > 1 ? `${a.count}×${a.name}` : a.name).join(' + ');
}
// Build display string for search mode
let searchDisplayStr;
if (isComplexMode) {
if (complexStrandType === 'complex') {
searchDisplayStr = `nd=${inputValue} (${strands} strands)`;
} else {
searchDisplayStr = `${complexStrandType}×${inputValue}`;
}
} else {
searchDisplayStr = `ns=${strands}`;
}
showMessage(`Searching for best N₀: ${searchDisplayStr}, nAg=${nag}, Adducts=${adductDisplay}...`, 'info');
// Prepare request for search mode
const requestData = {
peak_mz: peakMz,
charge: charge,
intensity: intensity,
num_strands: strands,
num_silver: nag,
adducts: adducts, // Send array of adducts with counts
dna_sequence: dnaSeq,
resolution: parseInt(document.getElementById('resolution').value),
spectrum: currentSpectrum,
custom_xna: customXNA,
search_mode: true, // Flag to indicate search mode
strand_type: isComplexMode ? complexStrandType : null, // 'strand1', 'strand2', or 'complex' for complex mode
custom_adducts: getCustomAdducts(),
};
console.log('Search with Adduct - sending request:', JSON.stringify({
peak_mz: peakMz, charge: charge, num_strands: strands, num_silver: nag,
adducts: adducts
}, null, 2));
// If manual fit was applied, include the manual fit exp_x0 and range
if (selectedPeak && selectedPeak.exp_x0 !== null && selectedPeak.exp_x0 !== undefined) {
requestData.exp_x0 = selectedPeak.exp_x0;
}
if (selectedPeak && selectedPeak.manual_fit_range && selectedPeak.manual_fit_range.length === 2) {
requestData.manual_fit_range = selectedPeak.manual_fit_range;
}
fetch('/add_manual_composition_search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestData)
})
.then(response => response.json())
.then(data => {
console.log('Search with Adduct - received response:', data);
if (data.compositions && data.compositions.length > 0) {
console.log('First composition adduct field:', data.compositions[0].adduct);
}
if (data.error) {
showMessage('Error: ' + data.error, 'error');
} else if (!data.compositions || data.compositions.length === 0) {
// No compositions found - show popup
const errorNsLabel = isComplexMode ? 'nd' : 'ns';
const errorNsValue = isComplexMode ? inputValue : strands;
showCompositionNotFoundPopup({
found: false,
theoretical_mz: null,
charge: charge,
nearest_peak_mz: peakMz,
reason: 'no_peaks_in_range',
message: `No valid compositions found for ${errorNsLabel}=${errorNsValue}, nAg=${nag} with specified adducts.`
});
} else {
// Check if best composition has large mass error
const bestComp = data.compositions[0];
const MZ_TOLERANCE = 5.0;
// Check mass error only if expected_mz is available
if (bestComp.expected_mz !== undefined && bestComp.expected_mz !== null) {
const massErrorMz = Math.abs(bestComp.expected_mz - peakMz);
const massErrorPpm = Math.abs((bestComp.expected_mz - peakMz) / peakMz * 1e6);
if (massErrorMz > MZ_TOLERANCE) {
// Show warning popup for large mass error
showCompositionNotFoundPopup({
found: false,
theoretical_mz: bestComp.expected_mz,
charge: charge,
nearest_peak_mz: peakMz,
mass_error_mz: massErrorMz,
mass_error_ppm: massErrorPpm,
threshold_mz: MZ_TOLERANCE,
reason: 'mass_error_exceeds_threshold',
message: `Best match theoretical m/z ${bestComp.expected_mz.toFixed(4)} is ${massErrorMz.toFixed(2)} m/z (${massErrorPpm.toFixed(0)} ppm) away from clicked peak (${peakMz.toFixed(4)}). Exceeds ${MZ_TOLERANCE.toFixed(1)} m/z tolerance.`
});
return; // Don't add compositions when error is too large
}
}
// Find the peak in currentPeaks
let peakIndex = currentPeaks.findIndex(p => Math.abs((p.peak_mz || p.mz) - peakMz) < 0.01);
if (peakIndex >= 0) {
// REPLACE existing compositions with new search results
// (don't mix old non-adduct compositions with new adduct ones)
currentPeaks[peakIndex].compositions = data.compositions;
// Re-display the results
displayClickedPeakResults(currentPeaks[peakIndex]);
} else {
// Peak not found in currentPeaks - use selectedPeak if available
if (selectedPeak) {
// REPLACE existing compositions with new search results
selectedPeak.compositions = data.compositions;
displayClickedPeakResults(selectedPeak);
}
}
// Use displayed_qcl when available (for compositions with adducts)
const displayQcl = bestComp.displayed_qcl !== undefined ? bestComp.displayed_qcl : bestComp.qcl;
const adductMsg = bestComp.adduct ? ` +${bestComp.adduct}` : '';
showMessage(`Found ${data.compositions.length} compositions! Best: N0=${bestComp.n0}, Qcl=${displayQcl}${adductMsg}, ΔX₀=${bestComp.x0_error.toFixed(4)}`, 'success');
}
})
.catch(error => {
console.error('Error searching compositions:', error);
showMessage('Error searching compositions: ' + error, 'error');
});
}
}
function downloadTheorySpectrum(event, nf, nAg, z, qcl, theoMz, theoIntensity) {
event.stopPropagation(); // Prevent triggering parent click events
if (!theoMz || !theoIntensity || theoMz.length === 0) {
showMessage('No theoretical spectrum data available', 'error');
return;
}
// Detect apexes (same as displayed in plots)
const apexes = detectApexes(theoMz, theoIntensity, 0);
if (apexes.length === 0) {
showMessage('No apex points found in theoretical spectrum', 'error');
return;
}
// Create CSV content (no header, just values) - only apex points
// Format: m/z with 5 decimals, intensity with 3 decimals, separated by ", "
let csv = '';
for (let i = 0; i < apexes.length; i++) {
const mzFormatted = apexes[i].mz.toFixed(5);
const intensityFormatted = apexes[i].intensity.toFixed(3);
csv += `${mzFormatted}, ${intensityFormatted}\n`;
}
// Create filename: qcl7.txt (where 7 is the qcl value)
const filename = `qcl${qcl}.txt`;
// Download the file
const blob = new Blob([csv], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showMessage('Theory spectrum downloaded: ' + filename, 'success');
}
function toggleTheoryOverlay(compId, theoMz, theoIntensity, formula, nf, nAg, z, qcl) {
const checkbox = document.getElementById(compId);
if (checkbox.checked) {
// Extract composition number from compId (e.g., "clicked_comp_0_..." -> #1)
const match = compId.match(/clicked_comp_(\d+)_/);
const compNumber = match ? parseInt(match[1]) + 1 : 1;
// Add theory overlay to the plot
theoryOverlays[compId] = {
mz: theoMz,
intensity: theoIntensity,
formula: formula,
nf: nf,
nAg: nAg,
z: z,
qcl: qcl,
number: compNumber // Store composition number for legend
};
} else {
// Remove theory overlay
delete theoryOverlays[compId];
}
// Only update the peak zoom plot (not the main plot)
if (currentClickedPeakMz !== null) {
// Find the exp_x0 for this peak
const peakData = currentPeaks.find(p => Math.abs((p.peak_mz || p.mz) - currentClickedPeakMz) < 0.01);
const expX0 = peakData ? peakData.exp_x0 : null;
showPeakZoom(currentClickedPeakMz, 5, expX0);
}
}
// Helper function to detect ALL apexes (local maxima) in spectrum
function detectApexes(mzArray, intensityArray, minHeight = 0) {
const apexes = [];
for (let i = 1; i < intensityArray.length - 1; i++) {
// Check if this point is a local maximum (higher than both neighbors)
if (intensityArray[i] > intensityArray[i-1] &&
intensityArray[i] > intensityArray[i+1] &&
intensityArray[i] > minHeight) {
apexes.push({ mz: mzArray[i], intensity: intensityArray[i] });
}
}
return apexes;
}
let isUpdatingPlot = false; // Prevent recursion
function updatePlotWithOverlays() {
if (!currentSpectrum) return;
// Prevent recursion
if (isUpdatingPlot) {
console.log('Already updating plot, skipping...');
return;
}
isUpdatingPlot = true;
const plotDiv = document.getElementById('spectrum-plot');
// Main plot shows only experimental spectrum (unchanged by checkboxes)
const trace = {
x: currentSpectrum.mz,
y: currentSpectrum.intensity,
type: 'scatter',
mode: 'lines',
name: 'Experimental Spectrum',
line: { color: '#667eea', width: 2 }
};
const data = [trace];
// Add analyzed peak markers if any
if (currentPeaks && currentPeaks.length > 0) {
const chargeColors = {
1: '#e74c3c', 2: '#3498db', 3: '#2ecc71',
4: '#f39c12', 5: '#9b59b6', 6: '#f8a5c2'
};
currentPeaks.forEach(peak => {
const color = chargeColors[peak.charge] || '#95a5a6';
const peakTrace = {
x: [peak.peak_mz],
y: [peak.intensity],
type: 'scatter',
mode: 'markers',
name: `z=${peak.charge}`,
marker: {
size: 12,
color: color,
symbol: 'diamond',
line: { color: '#fff', width: 2 }
},
showlegend: false
};
data.push(peakTrace);
});
}
const layout = {
title: 'Mass Spectrum - Click any peak to analyze',
xaxis: { title: 'm/z' },
yaxis: { title: 'Intensity' },
showlegend: true,
hovermode: 'closest'
};
const config = {
responsive: true
};
Plotly.react(plotDiv, data, layout, config);
// Re-attach click handler
plotDiv.on('plotly_click', function(data) {
const clickedMz = data.points[0].x;
analyzeClickedRegion(clickedMz);
});
// Reset recursion flag after plot is updated
setTimeout(() => { isUpdatingPlot = false; }, 100);
}
function showPeakZoom(peakMz, windowSize = 5, expX0 = null) {
if (!currentSpectrum) return;
currentClickedPeakMz = peakMz;
// Expand window to include any theory overlays that fall outside default range
let minMz = peakMz - windowSize;
let maxMz = peakMz + windowSize;
for (const overlay of Object.values(theoryOverlays)) {
if (overlay.mz && overlay.mz.length > 0) {
const overlayMin = Math.min(...overlay.mz);
const overlayMax = Math.max(...overlay.mz);
if (overlayMin < minMz) minMz = overlayMin - 1;
if (overlayMax > maxMz) maxMz = overlayMax + 1;
}
}
const indices = [];
for (let i = 0; i < currentSpectrum.mz.length; i++) {
if (currentSpectrum.mz[i] >= minMz && currentSpectrum.mz[i] <= maxMz) {
indices.push(i);
}
}
if (indices.length === 0) return;
const zoomMz = indices.map(i => currentSpectrum.mz[i]);
const zoomIntensity = indices.map(i => currentSpectrum.intensity[i]);
const traces = [];
// Experimental trace on primary y-axis - show all original data
traces.push({
x: zoomMz,
y: zoomIntensity,
type: 'scatter',
mode: 'lines',
name: 'Experimental',
line: { color: '#667eea', width: 2 },
fill: 'tozeroy',
fillcolor: 'rgba(102, 126, 234, 0.2)',
yaxis: 'y'
});
// Add experimental Gaussian curve (black line) if available
// BUT only if exp_x0 is close to the clicked peak (within 1 m/z)
// If exp_x0 is too far, the Gaussian fit is wrong and shouldn't be displayed
const peakData = currentPeaks.find(p => Math.abs((p.peak_mz || p.mz) - peakMz) < 0.01);
console.log(`[showPeakZoom] Peak data found:`, peakData ? `Yes (exp_gaussian_mz length: ${peakData.exp_gaussian_mz?.length || 0})` : 'No');
// Check if exp_x0 is valid and close to clicked peak
const EXP_X0_THRESHOLD = 1.0; // m/z - exp_x0 must be within this distance of clicked peak
const expX0IsValid = expX0 !== null && expX0 !== undefined && Math.abs(expX0 - peakMz) <= EXP_X0_THRESHOLD;
if (!expX0IsValid && expX0 !== null && expX0 !== undefined) {
console.log(`[showPeakZoom] exp_x0 (${expX0.toFixed(4)}) is too far from clicked peak (${peakMz.toFixed(4)}) - not showing Gaussian trace`);
}
if (expX0IsValid && peakData && peakData.exp_gaussian_mz && peakData.exp_gaussian_intensity &&
peakData.exp_gaussian_mz.length > 0) {
// Filter Gaussian curve to same window
const gaussianInWindow = [];
for (let i = 0; i < peakData.exp_gaussian_mz.length; i++) {
if (peakData.exp_gaussian_mz[i] >= minMz && peakData.exp_gaussian_mz[i] <= maxMz) {
gaussianInWindow.push({
mz: peakData.exp_gaussian_mz[i],
intensity: peakData.exp_gaussian_intensity[i]
});
}
}
if (gaussianInWindow.length > 0) {
// Use dashed line for automatic fit, solid line for manual fit
const isManualFit = peakData.manual_fit_range && peakData.manual_fit_range.length === 2;
const lineStyle = isManualFit ? 'solid' : 'dash';
const lineName = isManualFit ? 'Exp. Gaussian (Manual)' : 'Exp. Gaussian (Auto)';
// Normalize Gaussian curve to match experimental peak height
const gaussianMaxIntensity = Math.max(...gaussianInWindow.map(p => p.intensity));
const expMaxIntensity = Math.max(...zoomIntensity);
const scaleFactor = expMaxIntensity / gaussianMaxIntensity;
console.log(`[showPeakZoom] Displaying ${lineName}: ${gaussianInWindow.length} points, scale factor = ${scaleFactor.toFixed(2)}`);
const gaussianTrace = {
x: gaussianInWindow.map(p => p.mz),
y: gaussianInWindow.map(p => p.intensity * scaleFactor),
type: 'scatter',
mode: 'lines',
name: lineName,
line: { color: '#000000', width: 2, dash: lineStyle },
yaxis: 'y'
};
// Debug: Log first and last few points
console.log(`[showPeakZoom] Gaussian trace x range: [${gaussianTrace.x[0]}, ${gaussianTrace.x[gaussianTrace.x.length-1]}]`);
console.log(`[showPeakZoom] Gaussian trace y range: [${Math.min(...gaussianTrace.y)}, ${Math.max(...gaussianTrace.y)}]`);
console.log(`[showPeakZoom] Gaussian trace line style: ${lineStyle}`);
traces.push(gaussianTrace);
}
}
// Only show X₀ marker if exp_x0 is valid (within threshold of clicked peak)
if (expX0IsValid) {
// Use backend-calculated exp_x0 for marker
const centroidMz = expX0;
console.log(`[showPeakZoom] Using backend exp_x0 = ${expX0} for marker at peak ${peakMz}`);
// Place marker above the apex (maximum intensity) at centroid position
const maxIntensity = Math.max(...zoomIntensity);
// Check if manual fit is active to determine marker color
const isManualFitActive = peakData && peakData.manual_fit_range && peakData.manual_fit_range.length === 2;
const markerColor = isManualFitActive ? '#9c27b0' : '#dc3545'; // Purple for manual fit, red for auto
const markerMz = centroidMz;
const markerIntensity = maxIntensity;
const markerName = isManualFitActive ? 'Peak Center X₀ (Manual)' : 'Peak Center X₀ (Auto)';
console.log(`[showPeakZoom] Marker at X₀ = ${markerMz.toFixed(4)}, color: ${markerColor}, manual fit: ${isManualFitActive}`);
traces.push({
x: [markerMz],
y: [markerIntensity],
type: 'scatter',
mode: 'markers',
name: markerName,
marker: { color: markerColor, size: 10, symbol: 'diamond' },
yaxis: 'y'
});
} else {
console.log(`[showPeakZoom] exp_x0 invalid - not showing X₀ marker for peak ${peakMz}`);
}
// Add theory overlays on secondary y-axis (same window)
const colors = ['#dc3545', '#28a745', '#ffc107', '#17a2b8', '#6610f2', '#fd7e14'];
let colorIndex = 0;
for (const [compId, overlay] of Object.entries(theoryOverlays)) {
// Filter theory data to same window
const theoryInWindow = [];
for (let i = 0; i < overlay.mz.length; i++) {
if (overlay.mz[i] >= minMz && overlay.mz[i] <= maxMz) {
theoryInWindow.push({
mz: overlay.mz[i],
intensity: overlay.intensity[i]
});
}
}
if (theoryInWindow.length > 0) {
const theoryMzWindow = theoryInWindow.map(p => p.mz);
const theoryIntWindow = theoryInWindow.map(p => p.intensity);
const theoryApexes = detectApexes(theoryMzWindow, theoryIntWindow, 0);
traces.push({
x: theoryApexes.map(p => p.mz),
y: theoryApexes.map(p => p.intensity),
type: 'scatter',
mode: 'lines',
name: `#${overlay.number}`,
line: {
color: colors[colorIndex % colors.length],
width: 2
},
yaxis: 'y2'
});
colorIndex++;
}
}
const layout = {
title: `Peak Region: m/z ${minMz.toFixed(1)} – ${maxMz.toFixed(1)}`,
xaxis: { title: 'm/z' },
yaxis: {
title: '', // No title
side: 'left',
rangemode: 'tozero',
showticklabels: false // Hide tick values
},
yaxis2: {
title: '', // No title
side: 'right',
overlaying: 'y',
showgrid: false,
rangemode: 'tozero',
showticklabels: false // Hide tick values
},
showlegend: true,
margin: { t: 40, b: 40, l: 30, r: 30 } // Reduced margins since no labels
};
const zoomPlotDiv = document.getElementById('peak-zoom-plot');
Plotly.newPlot(zoomPlotDiv, traces, layout, { responsive: true });
// Show the zoom section
document.getElementById('peak-zoom-section').style.display = 'block';
}
function reanalyzeClickedPeak(peakMz, intensity) {
const z = parseInt(document.getElementById('clicked-peak-z-input').value);
if (isNaN(z) || z < 1 || z > 10) {
showMessage('Invalid charge state. Please enter a value between 1 and 10.', 'error');
return;
}
const isXNAMode = currentAnalysisMode === 'xna';
const isComplexMode = currentAnalysisMode === 'complex';
const isDNAMode = currentAnalysisMode === 'dna';
// Check if settings have been applied
if (isDNAMode && !appliedDNASettings) {
showMessage('⚠️ DNA mode: Please click "Apply DNA Settings" first!', 'error');
return;
}
if (isComplexMode && !appliedComplexSettings) {
showMessage('⚠️ Complex mode: Please click "Apply Complex Settings" first!', 'error');
return;
}
if (isXNAMode && !appliedXNASettings) {
showMessage('⚠️ XNA mode: Please click "Apply XNA Settings" first!', 'error');
return;
}
// Get DNA sequence from applied settings
let dnaSeq = '';
if (isDNAMode && appliedDNASettings) {
dnaSeq = appliedDNASettings.sequence;
} else if (isComplexMode && appliedComplexSettings) {
// In complex XNA mode, dnaSeq can be empty - backend will use XNA formula
dnaSeq = appliedComplexSettings.xna ? '' : appliedComplexSettings.sequence1;
} else if (isXNAMode) {
// XNA mode doesn't use sequence - backend uses formula from appliedXNASettings
dnaSeq = '';
}
// Collect XNA data if in XNA mode or Complex XNA mode
let customXNA = null;
if (isXNAMode && appliedXNASettings) {
// Use the applied XNA settings
customXNA = appliedXNASettings;
} else if (isComplexMode && appliedComplexSettings && appliedComplexSettings.xna) {
// Complex XNA mode: send combined formula AND individual strand formulas
customXNA = {
name: appliedComplexSettings.xna.name,
formula: appliedComplexSettings.xna.combinedFormula,
strand1_formula: appliedComplexSettings.xna.formula1,
strand2_formula: appliedComplexSettings.xna.formula2,
is_complex: true,
same_strands: appliedComplexSettings.xna.sameStrands || false
};
} else if (isComplexMode && appliedComplexSettings) {
// DNA-only Complex mode: no XNA formula, but still flag as complex for N0=0
customXNA = { name: 'Complex', is_complex: true, same_strands: false };
} else if (isXNAMode) {
showMessage('⚠️ XNA mode: Please click "Apply XNA Settings" first!', 'error');
return;
}
// Collect complex mode data from applied settings
let complexData = null;
if (isComplexMode && appliedComplexSettings) {
complexData = {
enabled: true,
dna_sequence_2: appliedComplexSettings.sequence2,
use_silver: true,
extra_atom: '',
extra_atom_count: 0,
xna: appliedComplexSettings.xna
};
}
// Show progress message with timer
const startTime = Date.now();
let progressTimer = null;
function updateProgress() {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
showMessage(`Re-analyzing peak with z=${z}... (${elapsed}s)`, 'info');
}
updateProgress();
progressTimer = setInterval(updateProgress, 100); // Update every 100ms
fetch('/reanalyze_peak', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
peak_mz: peakMz,
charge: z,
intensity: intensity,
dna_sequence: dnaSeq,
resolution: parseInt(document.getElementById('resolution').value),
spectrum: currentSpectrum,
custom_xna: customXNA,
complex_mode: complexData,
custom_adducts: getCustomAdducts(),
})
})
.then(response => response.json())
.then(data => {
// Clear progress timer
if (progressTimer) {
clearInterval(progressTimer);
progressTimer = null;
}
if (data.error) {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
showMessage(`❌ Error after ${elapsed}s: ` + data.error, 'error');
} else {
// CLEAR ALL THEORY OVERLAYS before re-analyzing with new z
// When charge changes, old compositions are invalid
theoryOverlays = {};
console.log('Cleared all theory overlays for re-analysis with new z');
// Find if this peak already exists in currentPeaks
let peakIndex = currentPeaks.findIndex(p => Math.abs((p.peak_mz || p.mz) - peakMz) < 0.01);
if (peakIndex >= 0) {
// Update existing peak
currentPeaks[peakIndex].compositions = data.compositions;
currentPeaks[peakIndex].charge = z;
currentPeaks[peakIndex].charge_method = 'manual';
currentPeaks[peakIndex].exp_x0 = data.exp_x0;
currentPeaks[peakIndex].exp_sigma = data.exp_sigma;
currentPeaks[peakIndex].symmetry = data.symmetry;
currentPeaks[peakIndex].exp_gaussian_mz = data.exp_gaussian_mz;
currentPeaks[peakIndex].exp_gaussian_intensity = data.exp_gaussian_intensity;
} else {
// Add as new peak
currentPeaks.push({
peak_mz: peakMz,
intensity: intensity,
charge: z,
charge_method: 'manual',
compositions: data.compositions,
exp_x0: data.exp_x0,
exp_sigma: data.exp_sigma,
symmetry: data.symmetry,
exp_gaussian_mz: data.exp_gaussian_mz,
exp_gaussian_intensity: data.exp_gaussian_intensity
});
peakIndex = currentPeaks.length - 1;
}
// Sync auto-detected peak label so z=? annotation updates
if (window.autoDetectedPeaksWithCharge) {
const match = window.autoDetectedPeaksWithCharge.find(p => Math.abs(p.mz - peakMz) < 1.0);
if (match) match.charge = z;
}
// Update plot with new marker
plotSpectrum();
// Display updated results
displayClickedPeakResults(currentPeaks[peakIndex]);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
showMessage(`✅ Peak re-analyzed in ${elapsed}s with z=${z} - Check updated compositions below!`, 'success');
}
})
.catch(error => {
// Clear progress timer
if (progressTimer) {
clearInterval(progressTimer);
progressTimer = null;
}
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
showMessage(`❌ Error after ${elapsed}s: ` + error, 'error');
});
}
function updatePeakCharge(peakIndex, newCharge) {
const z = parseInt(newCharge);
if (isNaN(z) || z < 1 || z > 10) {
showMessage('Invalid charge state. Please enter a value between 1 and 10.', 'error');
return;
}
// Update the peak charge in the current data
currentPeaks[peakIndex].charge = z;
showMessage('Re-analyzing peak with z=' + z + '...', 'info');
// Re-analyze this specific peak with the new charge
const peak = currentPeaks[peakIndex];
// Collect XNA data if in XNA mode or Complex XNA mode
let customXNA = null;
const isXNAMode = currentAnalysisMode === 'xna';
const isComplexMode = currentAnalysisMode === 'complex';
if (isXNAMode && appliedXNASettings) {
// Use the applied XNA settings (includes molecular_weight!)
customXNA = appliedXNASettings;
} else if (isComplexMode && appliedComplexSettings && appliedComplexSettings.xna) {
// Complex XNA mode: send combined formula AND individual strand formulas
customXNA = {
name: appliedComplexSettings.xna.name,
formula: appliedComplexSettings.xna.combinedFormula,
strand1_formula: appliedComplexSettings.xna.formula1,
strand2_formula: appliedComplexSettings.xna.formula2,
is_complex: true,
same_strands: appliedComplexSettings.xna.sameStrands || false
};
} else if (isComplexMode && appliedComplexSettings) {
// DNA-only Complex mode: no XNA formula, but still flag as complex for N0=0
customXNA = { name: 'Complex', is_complex: true, same_strands: false };
} else if (isXNAMode) {
showMessage('⚠️ XNA mode: Please click "Apply XNA Settings" first!', 'error');
return;
}
// Build complex mode data if in complex mode
let complexModeData = null;
if (isComplexMode && appliedComplexSettings) {
complexModeData = {
enabled: true,
xna: appliedComplexSettings.xna
};
}
// Get DNA sequence based on mode
let dnaSeq = '';
if (isComplexMode && appliedComplexSettings) {
// Complex mode: use applied settings, empty for XNA complex
dnaSeq = appliedComplexSettings.xna ? '' : appliedComplexSettings.sequence1;
} else if (isXNAMode) {
// XNA mode: no sequence needed
dnaSeq = '';
} else {
// DNA mode: get from input field
dnaSeq = document.getElementById('dna-sequence').value;
}
fetch('/reanalyze_peak', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
peak_mz: peak.peak_mz || peak.mz,
charge: z,
intensity: peak.intensity,
dna_sequence: dnaSeq,
resolution: parseInt(document.getElementById('resolution').value),
spectrum: currentSpectrum,
custom_xna: customXNA,
complex_mode: complexModeData,
custom_adducts: getCustomAdducts(),
})
})
.then(response => response.json())
.then(data => {
if (data.error) {
showMessage('Error: ' + data.error, 'error');
} else {
// CLEAR ALL THEORY OVERLAYS before updating with new charge
// When charge changes, old compositions are invalid
theoryOverlays = {};
console.log('Cleared all theory overlays for charge update');
// Update the peak with new compositions
currentPeaks[peakIndex].compositions = data.compositions;
currentPeaks[peakIndex].charge = z;
currentPeaks[peakIndex].charge_method = 'manual';
currentPeaks[peakIndex].exp_x0 = data.exp_x0;
currentPeaks[peakIndex].exp_sigma = data.exp_sigma;
currentPeaks[peakIndex].symmetry = data.symmetry;
currentPeaks[peakIndex].exp_gaussian_mz = data.exp_gaussian_mz;
currentPeaks[peakIndex].exp_gaussian_intensity = data.exp_gaussian_intensity;
// Sync auto-detected peak label so z=? annotation updates
if (window.autoDetectedPeaksWithCharge) {
const match = window.autoDetectedPeaksWithCharge.find(p => Math.abs(p.mz - (peak.peak_mz || peak.mz)) < 1.0);
if (match) match.charge = z;
}
// Refresh the plot and peak list display
plotSpectrum();
displayPeaks();
// If this peak was selected, update the detail view
if (selectedPeak === peak) {
selectPeak(peakIndex);
}
showMessage('Peak re-analyzed with z=' + z, 'success');
}
})
.catch(error => {
showMessage('Error re-analyzing peak: ' + error, 'error');
});
}
function recalculateWithManualFit(peakMz, charge, intensity) {
// Get custom m/z range from inputs
const startMz = parseFloat(document.getElementById('manual-fit-start-mz').value);
const endMz = parseFloat(document.getElementById('manual-fit-end-mz').value);
// Validate inputs
if (isNaN(startMz) || isNaN(endMz)) {
showMessage('Please enter valid start and end m/z values', 'error');
return;
}
if (startMz >= endMz) {
showMessage('Start m/z must be less than end m/z', 'error');
return;
}
if (startMz < peakMz - 10 || endMz > peakMz + 10) {
showMessage('m/z range too far from peak center. Please use values within ±10 m/z of peak center', 'error');
return;
}
// Collect XNA data if in XNA mode or Complex XNA mode
let customXNA = null;
const isXNAMode = currentAnalysisMode === 'xna';
const isComplexMode = currentAnalysisMode === 'complex';
// Get DNA sequence based on mode
let dnaSeq = '';
if (isComplexMode && appliedComplexSettings) {
// Complex mode: use applied settings, empty for XNA complex
dnaSeq = appliedComplexSettings.xna ? '' : appliedComplexSettings.sequence1;
} else if (isXNAMode) {
// XNA mode: no sequence needed
dnaSeq = '';
} else {
// DNA mode: get from input field
dnaSeq = document.getElementById('dna-sequence').value;
}
if (isXNAMode && appliedXNASettings) {
// Use the applied XNA settings (includes molecular_weight!)
customXNA = appliedXNASettings;
} else if (isComplexMode && appliedComplexSettings && appliedComplexSettings.xna) {
// Complex XNA mode: send combined formula AND individual strand formulas
customXNA = {
name: appliedComplexSettings.xna.name,
formula: appliedComplexSettings.xna.combinedFormula,
strand1_formula: appliedComplexSettings.xna.formula1,
strand2_formula: appliedComplexSettings.xna.formula2,
is_complex: true,
same_strands: appliedComplexSettings.xna.sameStrands || false
};
} else if (isComplexMode && appliedComplexSettings) {
// DNA-only Complex mode: no XNA formula, but still flag as complex for N0=0
customXNA = { name: 'Complex', is_complex: true, same_strands: false };
} else if (isXNAMode) {
showMessage('⚠️ XNA mode: Please click "Apply XNA Settings" first!', 'error');
return;
}
// Show progress message with timer
const startTime = Date.now();
let progressTimer = null;
function updateProgress() {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
showMessage(`Recalculating with manual fit range [${startMz.toFixed(2)}, ${endMz.toFixed(2)}]... (${elapsed}s)`, 'info');
}
updateProgress();
progressTimer = setInterval(updateProgress, 100); // Update every 100ms
// Build complex mode data if in complex mode
let complexModeData = null;
if (isComplexMode && appliedComplexSettings) {
complexModeData = {
enabled: true,
xna: appliedComplexSettings.xna
};
}
fetch('/recalculate_peak_with_manual_fit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
peak_mz: peakMz,
charge: charge,
intensity: intensity,
start_mz: startMz,
end_mz: endMz,
dna_sequence: dnaSeq,
resolution: parseInt(document.getElementById('resolution').value),
spectrum: currentSpectrum,
custom_xna: customXNA,
complex_mode: complexModeData,
custom_adducts: getCustomAdducts(),
})
})
.then(response => response.json())
.then(data => {
// Clear progress timer
if (progressTimer) {
clearInterval(progressTimer);
progressTimer = null;
}
if (data.error) {
showMessage('Error: ' + data.error, 'error');
} else {
// CLEAR ALL THEORY OVERLAYS before recalculating
// When manual fit is applied, old compositions are invalid
theoryOverlays = {};
console.log('Cleared all theory overlays for manual fit recalculation');
// Find and update the peak in currentPeaks
let peakIndex = currentPeaks.findIndex(p => Math.abs((p.peak_mz || p.mz) - peakMz) < 0.01);
if (peakIndex >= 0) {
// Update existing peak with new fit results
currentPeaks[peakIndex].compositions = data.compositions;
currentPeaks[peakIndex].exp_x0 = data.exp_x0;
currentPeaks[peakIndex].exp_sigma = data.exp_sigma;
currentPeaks[peakIndex].symmetry = data.symmetry;
currentPeaks[peakIndex].manual_fit_range = [startMz, endMz];
// Store experimental Gaussian curve for plotting (black line)
currentPeaks[peakIndex].exp_gaussian_mz = data.exp_gaussian_mz;
currentPeaks[peakIndex].exp_gaussian_intensity = data.exp_gaussian_intensity;
}
// Re-display the peak results (with fresh, unchecked checkboxes)
if (peakIndex >= 0) {
displayClickedPeakResults(currentPeaks[peakIndex]);
}
// Update plot (will not show any theory overlays since we cleared them)
plotSpectrum();
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
showMessage(`✅ Peak recalculated in ${elapsed}s with manual fit range [${startMz.toFixed(2)}, ${endMz.toFixed(2)}]`, 'success');
}
})
.catch(error => {
// Clear progress timer
if (progressTimer) {
clearInterval(progressTimer);
progressTimer = null;
}
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
showMessage(`❌ Error after ${elapsed}s: ` + error, 'error');
});
}
function resetManualFit(peakMz, charge, intensity) {
// Clear the manual fit inputs
document.getElementById('manual-fit-start-mz').value = '';
document.getElementById('manual-fit-end-mz').value = '';
// Collect XNA data if in XNA mode or Complex XNA mode
let customXNA = null;
const isXNAMode = currentAnalysisMode === 'xna';
const isComplexMode = currentAnalysisMode === 'complex';
// Get DNA sequence based on mode
let dnaSeq = '';
if (isComplexMode && appliedComplexSettings) {
// Complex mode: use applied settings, empty for XNA complex
dnaSeq = appliedComplexSettings.xna ? '' : appliedComplexSettings.sequence1;
} else if (isXNAMode) {
// XNA mode: no sequence needed
dnaSeq = '';
} else {
// DNA mode: get from input field
dnaSeq = document.getElementById('dna-sequence').value;
}
if (isXNAMode && appliedXNASettings) {
// Use the applied XNA settings (includes molecular_weight!)
customXNA = appliedXNASettings;
} else if (isComplexMode && appliedComplexSettings && appliedComplexSettings.xna) {
// Complex XNA mode: send combined formula AND individual strand formulas
customXNA = {
name: appliedComplexSettings.xna.name,
formula: appliedComplexSettings.xna.combinedFormula,
strand1_formula: appliedComplexSettings.xna.formula1,
strand2_formula: appliedComplexSettings.xna.formula2,
is_complex: true,
same_strands: appliedComplexSettings.xna.sameStrands || false
};
} else if (isComplexMode && appliedComplexSettings) {
// DNA-only Complex mode: no XNA formula, but still flag as complex for N0=0
customXNA = { name: 'Complex', is_complex: true, same_strands: false };
} else if (isXNAMode) {
showMessage('⚠️ XNA mode: Please click "Apply XNA Settings" first!', 'error');
return;
}
showMessage('Resetting to automatic fit...', 'info');
// Build complex mode data if in complex mode
let complexModeData = null;
if (isComplexMode && appliedComplexSettings) {
complexModeData = {
enabled: true,
xna: appliedComplexSettings.xna
};
}
// Re-analyze with default settings (no manual range)
fetch('/reanalyze_peak', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
peak_mz: peakMz,
charge: charge,
intensity: intensity,
dna_sequence: dnaSeq,
resolution: parseInt(document.getElementById('resolution').value),
spectrum: currentSpectrum,
custom_xna: customXNA,
complex_mode: complexModeData,
custom_adducts: getCustomAdducts(),
})
})
.then(response => response.json())
.then(data => {
if (data.error) {
showMessage('Error: ' + data.error, 'error');
} else {
// CLEAR ALL THEORY OVERLAYS when resetting manual fit
// Compositions change when switching back to automatic fit
theoryOverlays = {};
console.log('Cleared all theory overlays for manual fit reset');
// Find and update the peak
let peakIndex = currentPeaks.findIndex(p => Math.abs((p.peak_mz || p.mz) - peakMz) < 0.01);
if (peakIndex >= 0) {
// Update with automatic fit results
currentPeaks[peakIndex].compositions = data.compositions;
currentPeaks[peakIndex].exp_x0 = data.exp_x0;
currentPeaks[peakIndex].exp_sigma = data.exp_sigma;
delete currentPeaks[peakIndex].manual_fit_range;
}
// Re-display the peak results (with fresh, unchecked checkboxes)
if (peakIndex >= 0) {
displayClickedPeakResults(currentPeaks[peakIndex]);
}
// Update plot (will not show any theory overlays)
plotSpectrum();
showMessage('✓ Reset to automatic fit', 'success');
}
})
.catch(error => {
showMessage('Error resetting peak: ' + error, 'error');
});
}
// Adduct Manager Functions
function openAdductManager() {
const modal = document.getElementById('adductModal');
modal.style.display = 'block';
loadCustomAdducts();
}
function closeAdductManager() {
const modal = document.getElementById('adductModal');
modal.style.display = 'none';
}
// Structure Drawing (JSME) Functions
let jsmeApplet = null;
let jsmeLibraryReady = false;
let jsmeModalOpen = false;
let jsmeInitAttempts = 0;
// This function is called automatically by JSME when it's ready
function jsmeOnLoad() {
console.log('JSME library loaded and ready');
jsmeLibraryReady = true;
// If modal is already open, create the applet now
if (jsmeModalOpen && !jsmeApplet) {
createJSMEApplet();
}
}
function createJSMEApplet() {
if (jsmeApplet) return; // Already created
try {
console.log('Creating JSME applet...');
if (typeof JSApplet === 'undefined' || typeof JSApplet.JSME === 'undefined') {
throw new Error('JSApplet not available');
}
jsmeApplet = new JSApplet.JSME("jsme_container", "800px", "450px", {
"options": "query,hydrogens"
});
console.log('JSME applet created successfully');
} catch (e) {
console.error('Error creating JSME applet:', e);
showJSMEFallback();
}
}
function showJSMEFallback() {
document.getElementById('jsme_container').innerHTML =
'<div style="padding: 40px; text-align: center; color: #666; background: #f9f9f9; border-radius: 4px;">' +
'<p style="font-size: 1.1em; margin-bottom: 10px;">⚠️ Could not load JSME editor.</p>' +
'<p>The JSME library failed to load. Please try refreshing the page.</p>' +
'<p style="margin-top: 15px; font-size: 0.9em; color: #888;">If the problem persists, check your internet connection.</p>' +
'</div>';
}
function openStructureDrawer() {
const modal = document.getElementById('structureDrawerModal');
modal.style.display = 'block';
jsmeModalOpen = true;
// Clear previous formula
document.getElementById('jsme-formula-output').value = '';
// Initialize JSME if not already done
if (!jsmeApplet) {
jsmeInitAttempts = 0; // Reset attempts
// Check if library is ready
if (typeof JSApplet !== 'undefined' && typeof JSApplet.JSME !== 'undefined') {
jsmeLibraryReady = true;
createJSMEApplet();
} else {
// Show loading message only if JSME not already creating content
const container = document.getElementById('jsme_container');
if (!container.querySelector('iframe')) {
container.innerHTML =
'<div style="padding: 40px; text-align: center; color: #666;">' +
'<p>Loading JSME editor...</p></div>';
}
// Wait for library to load
const checkInterval = setInterval(() => {
if (typeof JSApplet !== 'undefined' && typeof JSApplet.JSME !== 'undefined') {
clearInterval(checkInterval);
jsmeLibraryReady = true;
if (!jsmeApplet) {
createJSMEApplet();
}
} else if (jsmeInitAttempts >= 10) {
clearInterval(checkInterval);
if (!jsmeApplet) {
showJSMEFallback();
}
}
jsmeInitAttempts++;
}, 500);
}
}
}
function closeStructureDrawer() {
const modal = document.getElementById('structureDrawerModal');
modal.style.display = 'none';
jsmeModalOpen = false;
}
function getFormulaFromJSME() {
if (!jsmeApplet) {
alert('JSME editor not loaded. Please try refreshing the page.');
return;
}
try {
// Use JSME's built-in formula method if available (fastest)
// Otherwise parse the MOL file
let formula = null;
// Try to get MOL file and parse it (this is fast)
const molfile = jsmeApplet.molFile();
if (molfile && molfile.trim() !== '') {
// Check if molecule is empty (counts line shows 0 atoms)
const lines = molfile.split('\n');
if (lines.length > 3) {
const countsLine = lines[3].trim().split(/\s+/);
const atomCount = parseInt(countsLine[0]) || 0;
if (atomCount === 0) {
alert('Please draw a molecule first');
return;
}
formula = parseMolFileToFormula(molfile);
}
}
if (formula) {
document.getElementById('jsme-formula-output').value = formula;
showMessage(`Formula: ${formula}`, 'success');
} else {
alert('Please draw a molecule first');
}
} catch (error) {
console.error('Error getting formula from JSME:', error);
alert('Error reading molecule. Please try again.');
}
}
// Parse MOL file to extract molecular formula (fast, client-side)
function parseMolFileToFormula(molfile) {
try {
const lines = molfile.split('\n');
// Line 4 contains atom and bond counts: " 5 4 0 0 0 0 0 0 0 0 0 V2000"
const countsLine = lines[3].trim().split(/\s+/);
const atomCount = parseInt(countsLine[0]);
if (atomCount === 0) return null;
// Count atoms by element
const atoms = {};
for (let i = 4; i < 4 + atomCount; i++) {
const parts = lines[i].trim().split(/\s+/);
const element = parts[3]; // Element symbol is 4th field
atoms[element] = (atoms[element] || 0) + 1;
}
// Format formula in Hill notation (C first, then H, then alphabetical)
return formatAtomsToFormula(atoms);
} catch (e) {
console.error('Error parsing MOL file:', e);
return null;
}
}
// Format atoms dictionary to Hill notation formula (for JSME MOL file parsing)
function formatAtomsToFormula(atoms) {
let formula = '';
// Hill notation: C first, then H, then alphabetical
if (atoms['C']) {
formula += 'C' + (atoms['C'] > 1 ? atoms['C'] : '');
delete atoms['C'];
}
if (atoms['H']) {
formula += 'H' + (atoms['H'] > 1 ? atoms['H'] : '');
delete atoms['H'];
}
// Remaining elements alphabetically
const sortedElements = Object.keys(atoms).sort();
for (const element of sortedElements) {
if (atoms[element] > 0) {
formula += element + (atoms[element] > 1 ? atoms[element] : '');
}
}
return formula;
}
function clearJSME() {
if (jsmeApplet) {
jsmeApplet.reset();
}
document.getElementById('jsme-formula-output').value = '';
}
function copyFormulaToClipboard() {
const formula = document.getElementById('jsme-formula-output').value.trim();
if (!formula) {
alert('Please extract a formula first by drawing a molecule or entering SMILES');
return;
}
// Use modern clipboard API if available and in secure context
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(formula).then(() => {
showMessage('Formula copied to clipboard: ' + formula, 'success');
}).catch(err => {
// Fallback if clipboard API fails
fallbackCopyToClipboard(formula);
});
} else {
// Fallback for non-secure contexts or older browsers
fallbackCopyToClipboard(formula);
}
}
// Custom adducts live in this browser's localStorage so different users
// on the HuggingFace Space never share or overwrite each other's lists.
const CUSTOM_ADDUCTS_KEY = 'nucleospec.customAdducts';
function getCustomAdducts() {
try {
const raw = localStorage.getItem(CUSTOM_ADDUCTS_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
console.warn('Failed to parse custom adducts from localStorage', e);
return [];
}
}
function saveCustomAdducts(list) {
localStorage.setItem(CUSTOM_ADDUCTS_KEY, JSON.stringify(list));
}
function getCustomAdductByName(name) {
return getCustomAdducts().find(a => a.name === name) || null;
}
async function loadCustomAdducts() {
try {
const customAdducts = getCustomAdducts();
const listDiv = document.getElementById('custom-adducts-list');
if (customAdducts.length === 0) {
listDiv.innerHTML = '<p style="color: #999; text-align: center; padding: 20px;">No custom adducts yet. Add one above!</p>';
return;
}
let html = '';
const sortedAdducts = [...customAdducts].sort((a, b) => {
if (a.prioritized && !b.prioritized) return -1;
if (!a.prioritized && b.prioritized) return 1;
return 0;
});
sortedAdducts.forEach(adduct => {
const chargeSign = adduct.charge > 0 ? '+' : (adduct.charge === 0 ? '' : '');
const chargeDisplay = adduct.charge === 0 ? '0 (neutral)' : `${chargeSign}${adduct.charge}`;
const isPrioritized = adduct.prioritized || false;
// Show formula if available, otherwise just mass
const formulaInfo = adduct.formula
? `Formula: ${adduct.formula} → ${adduct.mass.toFixed(4)} Da`
: `Mass: ${adduct.mass.toFixed(4)} Da`;
// Check if this is a conjugate (prioritized + charge 0)
const isConjugate = isPrioritized && adduct.charge === 0;
// Different styling for prioritized adducts/conjugates
const borderColor = isPrioritized ? '#ff9800' : '#ddd';
const bgColor = isPrioritized ? '#fff8e1' : 'white';
const priorityBadge = isConjugate
? '<span style="background: #e65100; color: white; font-size: 0.7em; padding: 2px 6px; border-radius: 3px; margin-left: 8px;">CONJUGATE</span>'
: (isPrioritized ? '<span style="background: #ff9800; color: white; font-size: 0.7em; padding: 2px 6px; border-radius: 3px; margin-left: 8px;">PRIORITY</span>' : '');
const priorityBtnText = isPrioritized ? 'Unprioritize' : 'Prioritize';
const priorityBtnColor = isPrioritized ? '#ff9800' : '#2196f3';
// Conjugates don't have multiples
const generatesText = isConjugate
? `Conjugate: ${adduct.name} (attached to DNA base)`
: `Generates: ${adduct.name}, 2${adduct.name}`;
html += `
<div style="background: ${bgColor}; border: 2px solid ${borderColor}; border-radius: 6px; padding: 15px; margin-bottom: 10px;">
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<div style="flex: 1;">
<div style="display: flex; align-items: center;">
<strong style="color: #9b59b6; font-size: 1.1em;">${adduct.name}</strong>
${priorityBadge}
</div>
<div style="color: #666; font-size: 0.85em; margin-top: 5px;">
${formulaInfo} | Charge: ${chargeDisplay}
</div>
<div style="color: #999; font-size: 0.8em; margin-top: 3px;">
${generatesText}
</div>
</div>
<div style="display: flex; gap: 8px; flex-shrink: 0;">
<button onclick="toggleAdductPriority('${adduct.name}')" style="background: ${priorityBtnColor}; color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; font-size: 0.85em;">
${priorityBtnText}
</button>
<button onclick="removeCustomAdduct('${adduct.name}', ${isConjugate})" style="background: #e74c3c; color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; font-size: 0.85em;">
Remove
</button>
</div>
</div>
</div>
`;
});
listDiv.innerHTML = html;
} catch (error) {
console.error('Error loading custom adducts:', error);
showMessage('Failed to load custom adducts', 'error');
}
}
async function addCustomAdduct() {
const name = document.getElementById('adduct-name').value.trim();
const formulaOrMass = document.getElementById('adduct-formula').value.trim();
const charge = parseInt(document.getElementById('adduct-charge').value);
const prioritized = document.getElementById('adduct-prioritize').checked;
if (!name) {
showMessage('Please enter an adduct name', 'error');
return;
}
if (!formulaOrMass) {
showMessage('Please enter a chemical formula or mass', 'error');
return;
}
const existing = getCustomAdducts();
if (existing.some(a => a.name === name)) {
showMessage(`Adduct '${name}' already exists`, 'error');
return;
}
try {
const response = await fetch('/parse_adduct_formula', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
name: name,
formula: formulaOrMass,
charge: charge,
})
});
const data = await response.json();
if (!data.success) {
showMessage(data.error || 'Failed to parse adduct', 'error');
return;
}
const adduct = {
name: data.name,
mass: data.mass,
charge: data.charge,
prioritized: prioritized,
};
if (data.formula) adduct.formula = data.formula;
existing.push(adduct);
saveCustomAdducts(existing);
showMessage(`Added ${name} (${data.mass.toFixed(4)} Da)`, 'success');
document.getElementById('adduct-name').value = '';
document.getElementById('adduct-formula').value = '';
document.getElementById('adduct-charge').value = '1';
document.getElementById('adduct-prioritize').checked = false;
loadCustomAdducts();
loadAdductsForParamMode();
} catch (error) {
console.error('Error adding custom adduct:', error);
showMessage('Failed to add custom adduct', 'error');
}
}
async function toggleAdductPriority(name) {
const list = getCustomAdducts();
const adduct = list.find(a => a.name === name);
if (!adduct) {
showMessage(`Adduct '${name}' not found`, 'error');
return;
}
adduct.prioritized = !adduct.prioritized;
saveCustomAdducts(list);
showMessage(adduct.prioritized ? `${name} prioritized` : `${name} unprioritized`, 'success');
loadCustomAdducts();
}
async function removeCustomAdduct(name, isConjugate = false) {
const confirmMsg = isConjugate
? `Remove conjugate "${name}"?`
: `Remove adduct "${name}" and its multiples (${name}, 2${name})?`;
if (!confirm(confirmMsg)) {
return;
}
const list = getCustomAdducts();
const next = list.filter(a => a.name !== name);
if (next.length === list.length) {
showMessage(`Adduct '${name}' not found`, 'error');
return;
}
saveCustomAdducts(next);
showMessage(`Removed ${name}`, 'success');
loadCustomAdducts();
loadAdductsForParamMode();
}
async function clearAllCustomAdducts() {
saveCustomAdducts([]);
console.log('Custom adducts cleared (localStorage)');
loadAdductsForParamMode();
}
// Show About Modal
function showAboutModal() {
const modal = document.getElementById('aboutModal');
modal.style.display = 'block';
}
function closeAboutModal() {
const modal = document.getElementById('aboutModal');
modal.style.display = 'none';
}
// Help Modal Functions
let currentHelpCard = 1;
const totalHelpCards = 8;
function showHelpModal() {
const modal = document.getElementById('helpModal');
modal.style.display = 'block';
currentHelpCard = 1;
updateHelpCard();
}
function closeHelpModal() {
const modal = document.getElementById('helpModal');
modal.style.display = 'none';
}
function helpNextCard() {
if (currentHelpCard < totalHelpCards) {
currentHelpCard++;
updateHelpCard();
}
}
function helpPrevCard() {
if (currentHelpCard > 1) {
currentHelpCard--;
updateHelpCard();
}
}
function updateHelpCard() {
// Hide all cards
for (let i = 1; i <= totalHelpCards; i++) {
const card = document.getElementById('help-card-' + i);
if (card) card.style.display = 'none';
}
// Show current card
const currentCard = document.getElementById('help-card-' + currentHelpCard);
if (currentCard) currentCard.style.display = 'block';
// Update indicator
document.getElementById('help-card-indicator').textContent = currentHelpCard + ' / ' + totalHelpCards;
// Update button states
const prevBtn = document.getElementById('help-prev-btn');
const nextBtn = document.getElementById('help-next-btn');
prevBtn.style.opacity = currentHelpCard === 1 ? '0.5' : '1';
prevBtn.style.cursor = currentHelpCard === 1 ? 'default' : 'pointer';
nextBtn.style.opacity = currentHelpCard === totalHelpCards ? '0.5' : '1';
nextBtn.style.cursor = currentHelpCard === totalHelpCards ? 'default' : 'pointer';
}
// Close modal when clicking outside
window.onclick = function(event) {
const aboutModal = document.getElementById('aboutModal');
const adductModal = document.getElementById('adductModal');
const structureDrawerModal = document.getElementById('structureDrawerModal');
const helpModal = document.getElementById('helpModal');
const compositionNotFoundModal = document.getElementById('compositionNotFoundModal');
if (event.target === aboutModal) {
closeAboutModal();
}
if (event.target === adductModal) {
closeAdductManager();
}
if (event.target === structureDrawerModal) {
closeStructureDrawer();
}
if (event.target === helpModal) {
closeHelpModal();
}
if (event.target === compositionNotFoundModal) {
closeCompositionNotFoundModal();
}
}
// Load adducts from server and cache for parameter mode
function loadAdductsForParamMode() {
fetch('/get_all_adducts')
.then(response => response.json())
.then(data => {
const base = (data && data.adducts) ? data.adducts : [];
// Merge client-side custom adducts (with 1x and 2x multiples)
// so each browser sees its own list without server-side state.
const customMerged = [];
for (const c of getCustomAdducts()) {
const isConjugate = c.prioritized && c.charge === 0;
if (isConjugate) {
customMerged.push({ name: c.name, mass: c.mass, charge: c.charge });
} else {
customMerged.push({ name: c.name, mass: c.mass, charge: c.charge });
customMerged.push({ name: `2${c.name}`, mass: c.mass * 2, charge: c.charge * 2 });
}
}
const seen = new Set(base.map(a => a.name));
const merged = base.concat(customMerged.filter(a => !seen.has(a.name)));
merged.sort((a, b) => a.name.localeCompare(b.name));
window.cachedAdducts = merged;
console.log(`Loaded ${merged.length} adducts (${base.length} base + ${customMerged.length} custom-derived)`);
refreshAllAdductSelects();
})
.catch(error => {
console.error('Error loading adducts for parameter mode:', error);
});
}
// Initialize page on load
// Onboarding state
let hasClickedPeak = false;
function stopPulse() {
const btn = document.getElementById('loadSampleBtn');
if (btn) btn.classList.remove('btn-pulse');
}
function showClickPeakPrompt() {
if (hasClickedPeak) return;
const plotContainer = document.getElementById('spectrum-plot').parentElement;
if (document.getElementById('click-peak-prompt')) return;
const prompt = document.createElement('div');
prompt.id = 'click-peak-prompt';
prompt.className = 'click-peak-prompt';
prompt.innerHTML = `
<span><strong>Next step:</strong> Click any peak in the spectrum to analyze its composition</span>
<button class="dismiss-btn" onclick="dismissClickPrompt()">&times;</button>
`;
plotContainer.insertBefore(prompt, document.getElementById('spectrum-plot'));
}
function dismissClickPrompt() {
const prompt = document.getElementById('click-peak-prompt');
if (prompt) prompt.remove();
hasClickedPeak = true;
}
function showFirstVisitOverlay() {
const overlay = document.createElement('div');
overlay.className = 'onboarding-overlay';
overlay.id = 'onboarding-overlay';
overlay.innerHTML = `
<div class="onboarding-card">
<div style="font-size: 2.5em; margin-bottom: 15px;">🔬</div>
<h2 style="color: #1e3c72; margin-bottom: 10px; font-size: 1.4em;">Welcome to NucleoSpec</h2>
<p style="color: #555; line-height: 1.6; margin-bottom: 25px;">
Analyze nucleic acid–silver nanocluster compositions from ESI-MS data in 4 steps:
</p>
<div style="text-align: left; margin: 0 auto 25px; max-width: 340px;">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;">
<span style="background: #667eea; color: white; border-radius: 50%; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; font-weight: bold; flex-shrink: 0;">1</span>
<span style="color: #333;">Upload a spectrum file</span>
</div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;">
<span style="background: #667eea; color: white; border-radius: 50%; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; font-weight: bold; flex-shrink: 0;">2</span>
<span style="color: #333;">Choose mode, enter sequence, and apply</span>
</div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;">
<span style="background: #667eea; color: white; border-radius: 50%; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; font-weight: bold; flex-shrink: 0;">3</span>
<span style="color: #333;">Click any peak in the spectrum</span>
</div>
<div style="display: flex; align-items: center; gap: 12px;">
<span style="background: #667eea; color: white; border-radius: 50%; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; font-weight: bold; flex-shrink: 0;">4</span>
<span style="color: #333;">View matched compositions</span>
</div>
</div>
<p style="color: #888; font-size: 0.85em; margin-bottom: 18px;">
For a detailed walkthrough, click <strong>Help &amp; Tutorial</strong> in the top menu.
</p>
<div style="display: flex; gap: 12px; justify-content: center;">
<button onclick="dismissOverlayAndLoadSample()" style="background: #27ae60; color: white; border: none; padding: 12px 24px; border-radius: 8px; font-size: 1em; cursor: pointer; font-weight: 600;">
Try with Example Data
</button>
<button onclick="dismissOverlay()" style="background: #667eea; color: white; border: none; padding: 12px 24px; border-radius: 8px; font-size: 1em; cursor: pointer;">
I'll upload my own
</button>
</div>
</div>
`;
document.body.appendChild(overlay);
overlay.addEventListener('click', function(e) {
if (e.target === overlay) dismissOverlay();
});
}
function dismissOverlay() {
const overlay = document.getElementById('onboarding-overlay');
if (overlay) overlay.remove();
}
function dismissOverlayAndLoadSample() {
dismissOverlay();
loadSampleData();
}
window.addEventListener('DOMContentLoaded', function() {
console.log('Page loaded, initializing...');
loadAdductsForParamMode();
const spectrumPlot = document.getElementById('spectrum-plot');
if (spectrumPlot && !spectrumPlot.innerHTML.trim()) {
spectrumPlot.innerHTML = `
<div style="
height: 400px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border: 2px dashed #dee2e6;
border-radius: 12px;
color: #6c757d;
">
<div style="font-size: 3em; margin-bottom: 15px;">📊</div>
<div style="font-size: 1.2em; font-weight: 600; margin-bottom: 8px;">No Spectrum Loaded</div>
<div style="font-size: 0.95em; margin-bottom: 20px;">Upload a spectrum file or try an example to get started</div>
<button onclick="loadSampleData()" style="background: #27ae60; color: white; border: none; padding: 10px 24px; border-radius: 6px; font-size: 1em; cursor: pointer; font-weight: 600; box-shadow: 0 2px 8px rgba(39,174,96,0.3);">
Load Example Spectrum
</button>
</div>
`;
}
showFirstVisitOverlay();
});
</script>
<!-- About Modal -->
<!-- Adduct Manager Modal -->
<div id="adductModal" style="display: none; position: fixed; z-index: 10000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.6); overflow: auto;">
<div style="background: white; margin: 5% auto; padding: 0; max-width: 600px; border-radius: 10px; box-shadow: 0 10px 40px rgba(0,0,0,0.3);">
<!-- Modal Header -->
<div style="background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); color: white; padding: 20px; border-radius: 10px 10px 0 0; position: relative;">
<h2 style="margin: 0; font-size: 1.5em;">⚛️ Custom Adduct Manager</h2>
<button onclick="closeAdductManager()" style="position: absolute; right: 20px; top: 20px; background: rgba(255,255,255,0.2); border: none; color: white; font-size: 24px; cursor: pointer; border-radius: 50%; width: 35px; height: 35px; line-height: 35px; text-align: center; transition: all 0.3s;">&times;</button>
</div>
<!-- Modal Content -->
<div style="padding: 30px; max-height: 70vh; overflow-y: auto;">
<!-- Built-in Adducts Info -->
<div style="background: #e8f5e9; border-left: 4px solid #4caf50; padding: 15px; margin-bottom: 20px; border-radius: 4px;">
<h4 style="margin: 0 0 10px 0; color: #2e7d32; font-size: 0.95em;">Built-in Adducts (Always Available)</h4>
<p style="margin: 0; color: #555; font-size: 0.85em; line-height: 1.5;">
<strong>NH<sub>4</sub><sup>+</sup>, Na<sup>+</sup>, Cl<sup>-</sup></strong> and their multiples are already included in all analyses.
</p>
<p style="margin: 5px 0 0 0; color: #666; font-size: 0.8em; font-style: italic;">
No need to add these - use the form below only for additional adducts.
</p>
</div>
<p style="margin-top: 0; color: #666; font-size: 0.9em;">
Add custom adducts below. Multiples (1x, 2x) will be automatically generated for each custom adduct you add.
</p>
<!-- Add Adduct Form -->
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; margin-bottom: 20px;">
<h3 style="margin-top: 0; color: #9b59b6; font-size: 1.1em;">Add New Adduct</h3>
<label style="display: block; margin-bottom: 5px; font-weight: bold; font-size: 0.9em;">Name:</label>
<input type="text" id="adduct-name" placeholder="e.g., Acetate, Phosphate" style="width: 100%; padding: 8px; margin-bottom: 15px; border: 1px solid #ddd; border-radius: 4px;">
<label style="display: block; margin-bottom: 5px; font-weight: bold; font-size: 0.9em;">Chemical Formula (or Mass):</label>
<input type="text" id="adduct-formula" placeholder="e.g., C2H3O2 or HPO4 or 59.01" style="width: 100%; padding: 8px; margin-bottom: 5px; border: 1px solid #ddd; border-radius: 4px;">
<small style="color: #666; display: block; margin-bottom: 15px; font-size: 0.85em;">Enter chemical formula (C2H3O2) or mass in Da (59.01)</small>
<label style="display: block; margin-bottom: 5px; font-weight: bold; font-size: 0.9em;">Charge:</label>
<select id="adduct-charge" style="width: 100%; padding: 8px; margin-bottom: 15px; border: 1px solid #ddd; border-radius: 4px;">
<option value="-5">-5</option>
<option value="-4">-4</option>
<option value="-3">-3</option>
<option value="-2">-2</option>
<option value="-1">-1</option>
<option value="0">0 (neutral)</option>
<option value="1" selected>+1</option>
<option value="2">+2</option>
<option value="3">+3</option>
<option value="4">+4</option>
<option value="5">+5</option>
</select>
<!-- Prioritize Option -->
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 15px; padding: 10px; background: #fff3e0; border-radius: 4px; border: 1px solid #ffcc80;">
<input type="checkbox" id="adduct-prioritize" style="width: 18px; height: 18px; cursor: pointer;">
<label for="adduct-prioritize" style="font-weight: bold; font-size: 0.9em; color: #e65100; cursor: pointer;">Prioritize (conjugating compound)</label>
</div>
<small style="color: #666; display: block; margin-bottom: 15px; font-size: 0.8em; line-height: 1.4;">
Check this if the adduct is a conjugating compound that may be the main product. Prioritized adducts will be highlighted and searched first in analysis.
</small>
<button onclick="addCustomAdduct()" style="background: #9b59b6; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; width: 100%; font-weight: bold;">
Add Adduct
</button>
</div>
<!-- Custom Adducts List -->
<div>
<h3 style="color: #9b59b6; font-size: 1.1em;">Custom Adducts</h3>
<div id="custom-adducts-list" style="max-height: 250px; overflow-y: auto;">
<!-- Will be populated by JavaScript -->
</div>
</div>
</div>
</div>
</div>
<!-- Structure Drawing Modal (JSME) -->
<div id="structureDrawerModal" style="display: none; position: fixed; z-index: 10000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.6); overflow: auto;">
<div style="background: white; margin: 2% auto; padding: 0; width: 95%; max-width: 900px; border-radius: 10px; box-shadow: 0 10px 40px rgba(0,0,0,0.3);">
<!-- Modal Header -->
<div style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: white; padding: 15px 20px; border-radius: 10px 10px 0 0; position: relative;">
<h2 style="margin: 0; font-size: 1.3em;">✏️ Structure Drawing (JSME)</h2>
<p style="margin: 5px 0 0 0; font-size: 0.85em; opacity: 0.9;">Draw your molecule and click "Get Formula" to extract the chemical formula</p>
<button onclick="closeStructureDrawer()" style="position: absolute; right: 20px; top: 15px; background: rgba(255,255,255,0.2); border: none; color: white; font-size: 24px; cursor: pointer; border-radius: 50%; width: 35px; height: 35px; line-height: 35px; text-align: center; transition: all 0.3s;">&times;</button>
</div>
<!-- JSME Editor Container -->
<div style="padding: 20px; display: flex; flex-direction: column; align-items: center;">
<div id="jsme_container" style="width: 100%; max-width: 800px; height: 450px; border: 1px solid #ddd; border-radius: 4px; background: #f9f9f9;"></div>
<!-- Quick Tips -->
<div style="margin-top: 10px; padding: 10px; background: #e3f2fd; border-radius: 4px; font-size: 0.85em; color: #1565c0; width: 100%; max-width: 800px;">
<strong>Tips:</strong> Click atoms to add (C, N, O, P, S, etc.) | Use bonds menu for different bond types | Right-click to delete
</div>
</div>
<!-- Modal Footer with actions -->
<div style="padding: 15px 20px; background: #f8f9fa; border-top: 1px solid #ddd; border-radius: 0 0 10px 10px;">
<div style="display: flex; gap: 15px; align-items: center; flex-wrap: wrap;">
<div style="flex: 1; min-width: 200px;">
<label style="font-weight: bold; font-size: 0.9em; color: #333;">Extracted Formula:</label>
<input type="text" id="jsme-formula-output" readonly placeholder="Draw a molecule and click 'Get Formula'"
style="width: 100%; padding: 8px; margin-top: 5px; border: 1px solid #ddd; border-radius: 4px; background: #fff; font-family: monospace; font-size: 1em;">
</div>
<div style="display: flex; gap: 10px; align-items: flex-end;">
<button onclick="getFormulaFromJSME()" style="background: #3498db; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; font-weight: bold;">
Get Formula
</button>
<button onclick="copyFormulaToClipboard()" style="background: #27ae60; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; font-weight: bold;">
Copy Formula
</button>
<button onclick="clearJSME()" style="background: #f39c12; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer;">
Clear
</button>
<button onclick="closeStructureDrawer()" style="background: #95a5a6; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer;">
Close
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Help & Tutorial Modal -->
<div id="helpModal" style="display: none; position: fixed; z-index: 10000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.6); overflow: auto;">
<div style="background: white; margin: 5% auto; padding: 0; max-width: 550px; border-radius: 10px; box-shadow: 0 10px 40px rgba(0,0,0,0.3);">
<!-- Modal Header -->
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 15px 20px; border-radius: 10px 10px 0 0; position: relative;">
<h2 style="margin: 0; font-size: 1.3em;">Help & Tutorial</h2>
<button onclick="closeHelpModal()" style="position: absolute; right: 15px; top: 12px; background: rgba(255,255,255,0.2); border: none; color: white; font-size: 20px; cursor: pointer; border-radius: 50%; width: 30px; height: 30px; line-height: 30px; text-align: center;">&times;</button>
</div>
<!-- Flashcard Content -->
<div style="padding: 25px; min-height: 300px; max-height: 60vh; overflow-y: auto;">
<!-- Card 1: What is NucleoSpec? -->
<div class="help-card" id="help-card-1" style="display: block;">
<h3 style="color: #667eea; margin: 0 0 15px 0; font-size: 1.2em;">What is NucleoSpec?</h3>
<p style="margin: 0 0 12px 0; color: #444; line-height: 1.7;">
NucleoSpec is a web-based tool for assigning compositions to <strong>negative-mode ESI mass spectra</strong> of nucleic acid&ndash;metal complexes.
</p>
<p style="margin: 0 0 12px 0; color: #444; line-height: 1.7;">
Given a spectrum and a nucleic acid sequence (or formula), NucleoSpec will:
</p>
<ul style="margin: 0; padding-left: 20px; line-height: 1.8; color: #444;">
<li>Auto-detect charge states from isotope spacing</li>
<li>Search for matching compositions (strand count, metal count, N<sub>0</sub>, Q<sub>cl</sub>)</li>
<li>Generate theoretical isotope patterns for visual overlay comparison</li>
<li>Handle adducts (Na<sup>+</sup>, NH<sub>4</sub><sup>+</sup>, Cl<sup>&minus;</sup>, custom) automatically</li>
</ul>
<p style="margin: 12px 0 0 0; color: #888; font-size: 0.85em;">
Supports DNA-Ag<sub>N</sub>, complex DNA/XNA, and custom XNA (TNA, PNA, LNA, etc.) modes.
</p>
</div>
<!-- Card 2: Upload Data -->
<div class="help-card" id="help-card-2" style="display: none;">
<h3 style="color: #667eea; margin: 0 0 15px 0; font-size: 1.2em;">Upload Data</h3>
<ul style="margin: 0; padding-left: 20px; line-height: 1.8; color: #444;">
<li>This tool is designed for <strong>negative mode ESI-MS data</strong></li>
<li>Supported formats: <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">.txt</code> or <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">.csv</code></li>
<li>Two columns: m/z (mass-to-charge) and intensity</li>
<li>Tab or space separated</li>
<li>Example:
<pre style="background: #f8f9fa; padding: 10px; border-radius: 4px; margin: 8px 0; font-size: 0.9em; overflow-x: auto;">500.123 1000
500.624 850
501.125 600</pre>
</li>
<li>Drag & drop file or click "Select File"</li>
<li>Resolution is auto-detected from your spectrum</li>
</ul>
</div>
<!-- Card 3: Choose Analysis Mode -->
<div class="help-card" id="help-card-3" style="display: none;">
<h3 style="color: #667eea; margin: 0 0 15px 0; font-size: 1.2em;">Choose Analysis Mode</h3>
<p style="margin: 0 0 15px 0; color: #444;">Three modes available:</p>
<div style="background: #e8f5e9; padding: 12px; border-left: 3px solid #4CAF50; border-radius: 4px; margin-bottom: 12px;">
<strong style="color: #2e7d32;">DNA-Ag<sub>N</sub> Mode</strong>
<ul style="margin: 8px 0 0 0; padding-left: 20px; line-height: 1.6; color: #444; font-size: 0.95em;">
<li>For single-stranded DNA with silver nanoclusters</li>
<li>Enter DNA sequence 5'&rarr;3' (e.g., CACCTAGCGA)</li>
<li>Standard bases only: A, T, G, C</li>
<li>Click "Apply DNA Settings" to confirm</li>
</ul>
</div>
<div style="background: #e3f2fd; padding: 12px; border-left: 3px solid #2196F3; border-radius: 4px; margin-bottom: 12px;">
<strong style="color: #1565c0;">Ag(I)-DNA/XNA Complex Mode</strong>
<ul style="margin: 8px 0 0 0; padding-left: 20px; line-height: 1.6; color: #444; font-size: 0.95em;">
<li>For double-stranded DNA or DNA/XNA hybrids</li>
<li>Enter both strands 5'&rarr;3'</li>
<li>Select metal to search (Ag, Na, K, Mg, etc.)</li>
<li>Optional: Use custom XNA formulas for non-DNA strands</li>
<li>Click "Apply Complex Settings" to confirm</li>
</ul>
</div>
<div style="background: #fff9e6; padding: 12px; border-left: 3px solid #ff9800; border-radius: 4px;">
<strong style="color: #e65100;">Custom XNA Mode</strong>
<ul style="margin: 8px 0 0 0; padding-left: 20px; line-height: 1.6; color: #444; font-size: 0.95em;">
<li>For non-standard nucleotides (TNA, PNA, LNA, etc.)</li>
<li>Enter XNA name and chemical formula per nucleotide</li>
<li>Click "Apply XNA Settings" to confirm</li>
</ul>
</div>
</div>
<!-- Card 4: Analyze Peaks -->
<div class="help-card" id="help-card-4" style="display: none;">
<h3 style="color: #667eea; margin: 0 0 15px 0; font-size: 1.2em;">Analyze Peaks</h3>
<ul style="margin: 0; padding-left: 20px; line-height: 1.8; color: #444;">
<li>Click any peak in the spectrum to analyze it</li>
<li>Charge state (z) is auto-detected based on isotope spacing</li>
<li>Color-coded markers indicate charge state (see legend on spectrum)</li>
<li>Small peaks (&lt; 1% intensity) may not show markers but can still be clicked</li>
<li><strong>Zoom in to double-check the charge state assignment</strong></li>
<li><strong>If the Gaussian fit looks poor, use manual fitting to adjust</strong></li>
<li>Composition matches appear in the "Composition Analysis" section below</li>
</ul>
</div>
<!-- Card 5: Understanding Results -->
<div class="help-card" id="help-card-5" style="display: none;">
<h3 style="color: #667eea; margin: 0 0 15px 0; font-size: 1.2em;">Understanding Results</h3>
<p style="margin: 0 0 10px 0; color: #444; line-height: 1.6;">Each composition result shows these key parameters:</p>
<div style="font-size: 0.93em; color: #444; line-height: 1.7;">
<div style="background: #f8f9fa; padding: 8px 12px; border-radius: 4px; margin-bottom: 8px;">
<strong>z</strong> &mdash; Charge state of the ion (determined from isotope spacing = 1/z)
</div>
<div style="background: #f8f9fa; padding: 8px 12px; border-radius: 4px; margin-bottom: 8px;">
<strong>nAg</strong> &mdash; Total number of silver atoms (n<sub>Ag</sub> = N<sub>0</sub> + Q<sub>cl</sub>)
</div>
<div style="background: #f8f9fa; padding: 8px 12px; border-radius: 4px; margin-bottom: 8px;">
<strong>N<sub>0</sub></strong> &mdash; Number of valence (neutral) electrons on the silver cluster. Stable clusters typically have <strong>even</strong> N<sub>0</sub> (2, 4, 6, ...)
</div>
<div style="background: #f8f9fa; padding: 8px 12px; border-radius: 4px; margin-bottom: 8px;">
<strong>Q<sub>cl</sub></strong> &mdash; Charge of the inorganic core. Satisfies: N<sub>0</sub> + Q<sub>cl</sub> = n<sub>Ag</sub>
</div>
<div style="background: #f8f9fa; padding: 8px 12px; border-radius: 4px; margin-bottom: 8px;">
<strong>ΔX<sub>0</sub></strong> &mdash; Difference between theoretical and experimental centroid (m/z). Lower is better; &lt; 0.1 m/z is a strong match. <em>Primary criterion for Best Fit selection.</em>
</div>
<div style="background: #f8f9fa; padding: 8px 12px; border-radius: 4px; margin-bottom: 8px;">
<strong>Pattern similarity</strong> &mdash; Mean of cosine similarity and Pearson correlation between experimental and theoretical isotope envelopes (0&ndash;1). Reported as a <em>confidence indicator</em>, not the ranking metric. Tiers: <span style="color: #27ae60; font-weight: 600;">&gt; 0.8</span> high, <span style="color: #f39c12; font-weight: 600;">○ 0.5&ndash;0.8</span> moderate, <span style="color: #e74c3c; font-weight: 600;">&lt; 0.5</span> low
</div>
<div style="background: #e8f5e9; padding: 8px 12px; border-radius: 4px;">
The composition marked <strong>Best Fit</strong> has the lowest ΔX<sub>0</sub> (best centroid match). Pattern similarity and the isotope-pattern overlay serve as visual confirmation of the assignment
</div>
</div>
</div>
<!-- Card 6: Overlay & Compare -->
<div class="help-card" id="help-card-6" style="display: none;">
<h3 style="color: #667eea; margin: 0 0 15px 0; font-size: 1.2em;">Overlay & Compare</h3>
<ul style="margin: 0; padding-left: 20px; line-height: 1.8; color: #444;">
<li>Check the box next to any composition to overlay its theoretical isotope pattern</li>
<li>The overlay appears in the zoom plot on a secondary y-axis</li>
<li>A <strong>good match</strong>: theoretical peaks align with experimental peaks in m/z position</li>
<li>A <strong>poor match</strong>: peaks are shifted or the envelope shape doesn't match</li>
<li>Multiple overlays can be shown at once to compare candidates</li>
<li>If the best fit overlay looks offset, try the neighboring N<sub>0</sub> values</li>
</ul>
</div>
<!-- Card 7: Advanced Features -->
<div class="help-card" id="help-card-7" style="display: none;">
<h3 style="color: #667eea; margin: 0 0 15px 0; font-size: 1.2em;">Advanced Features</h3>
<div style="background: #e8f5e9; padding: 12px; border-left: 3px solid #4CAF50; border-radius: 4px; margin-bottom: 12px;">
<strong style="color: #2e7d32;">Re-analyze & Manual Fit</strong>
<ul style="margin: 8px 0 0 0; padding-left: 20px; line-height: 1.6; color: #444; font-size: 0.95em;">
<li><strong>Re-analyze with new z:</strong> Override auto-detected charge state</li>
<li><strong>Manual Gaussian fit:</strong> Click two points on the peak to define the fitting range</li>
<li><strong>Custom search:</strong> Manually specify strand count, nAg, z, and adduct</li>
</ul>
</div>
<div style="background: #f3e5f5; padding: 12px; border-left: 3px solid #9b59b6; border-radius: 4px; margin-bottom: 12px;">
<strong style="color: #7b1fa2;">Custom Adducts</strong>
<ul style="margin: 8px 0 0 0; padding-left: 20px; line-height: 1.6; color: #444; font-size: 0.95em;">
<li>Click "Manage Adducts" to add custom adducts</li>
<li>Built-in adducts: NH<sub>4</sub><sup>+</sup>, Na<sup>+</sup>, Cl<sup>&minus;</sup> (always included)</li>
<li>Enter name, formula (or mass), and charge</li>
<li>Multiples (1x, 2x) are auto-generated</li>
<li>Check "Prioritize" for conjugating compounds</li>
</ul>
</div>
<div style="background: #fff3e0; padding: 12px; border-left: 3px solid #e67e22; border-radius: 4px;">
<strong style="color: #e65100;">Draw Structure (JSME)</strong>
<ul style="margin: 8px 0 0 0; padding-left: 20px; line-height: 1.6; color: #444; font-size: 0.95em;">
<li>Click "Draw Structure" to open molecule editor</li>
<li>Draw molecule to get its chemical formula</li>
<li>Useful for conjugates or modifications</li>
</ul>
</div>
</div>
<!-- Card 8: Tips & Troubleshooting -->
<div class="help-card" id="help-card-8" style="display: none;">
<h3 style="color: #667eea; margin: 0 0 15px 0; font-size: 1.2em;">Tips & Troubleshooting</h3>
<ul style="margin: 0; padding-left: 20px; line-height: 1.8; color: #444;">
<li><strong>No peaks detected?</strong> Check file format &ndash; ensure two columns (m/z, intensity)</li>
<li><strong>Poor matches?</strong> Try manual Gaussian fitting or custom composition search</li>
<li><strong>Wrong charge state?</strong> Zoom in to verify isotope spacing, then re-analyze with correct z</li>
<li><strong>Overlay looks offset?</strong> The neighboring N<sub>0</sub> value may be a better match &ndash; check by overlaying it</li>
<li><strong>Odd N<sub>0</sub> warning?</strong> Stable DNA-Ag<sub>N</sub> clusters typically have even N<sub>0</sub>; odd values may indicate a calibration issue</li>
<li><strong>Start fresh?</strong> Click "Reset Analysis" to clear all data</li>
<li><strong>Data privacy:</strong> Uploaded spectra are processed on the server but are not stored &ndash; all data is discarded after analysis</li>
</ul>
</div>
</div>
<!-- Modal Footer with Navigation -->
<div style="background: #f8f9fa; padding: 15px 20px; border-radius: 0 0 10px 10px; display: flex; justify-content: space-between; align-items: center;">
<button id="help-prev-btn" onclick="helpPrevCard()" style="background: #e0e0e0; color: #333; border: none; padding: 8px 16px; border-radius: 5px; cursor: pointer; font-weight: bold;">
&larr; Previous
</button>
<span id="help-card-indicator" style="color: #666; font-size: 0.9em;">1 / 8</span>
<button id="help-next-btn" onclick="helpNextCard()" style="background: #667eea; color: white; border: none; padding: 8px 16px; border-radius: 5px; cursor: pointer; font-weight: bold;">
Next &rarr;
</button>
</div>
</div>
</div>
<!-- Composition Not Found Modal -->
<div id="compositionNotFoundModal" style="display: none; position: fixed; z-index: 10001; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.6); overflow: auto;">
<div style="background: white; margin: 10% auto; padding: 0; max-width: 450px; border-radius: 10px; box-shadow: 0 10px 40px rgba(0,0,0,0.3);">
<!-- Modal Header -->
<div style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color: white; padding: 15px 20px; border-radius: 10px 10px 0 0; position: relative;">
<h2 style="margin: 0; font-size: 1.2em;">Search Result</h2>
<button onclick="closeCompositionNotFoundModal()" style="position: absolute; right: 15px; top: 12px; background: rgba(255,255,255,0.2); border: none; color: white; font-size: 20px; cursor: pointer; border-radius: 50%; width: 30px; height: 30px; line-height: 30px; text-align: center;">&times;</button>
</div>
<!-- Modal Content -->
<div id="compositionNotFoundContent" style="padding: 20px;">
<!-- Content populated by JavaScript -->
</div>
</div>
</div>
<div id="aboutModal" style="display: none; position: fixed; z-index: 10000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.6); overflow: auto;">
<div style="background: white; margin: 5% auto; padding: 0; max-width: 550px; border-radius: 10px; box-shadow: 0 10px 40px rgba(0,0,0,0.3);">
<!-- Modal Header -->
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 15px 20px; border-radius: 10px 10px 0 0; position: relative;">
<h2 style="margin: 0; font-size: 1.3em;">About NucleoSpec</h2>
<button onclick="closeAboutModal()" style="position: absolute; right: 15px; top: 12px; background: rgba(255,255,255,0.2); border: none; color: white; font-size: 20px; cursor: pointer; border-radius: 50%; width: 30px; height: 30px; line-height: 30px; text-align: center;">&times;</button>
</div>
<!-- Modal Content -->
<div style="padding: 20px; max-height: 55vh; overflow-y: auto;">
<!-- Developer Info -->
<div style="background: #e8f4fd; padding: 12px; border-left: 4px solid #667eea; border-radius: 4px; margin-bottom: 15px;">
<p style="margin: 0; font-size: 0.85em; line-height: 1.5; color: #333;">
<strong>Developer:</strong> I-Hsin (Vivian) Lin<br>
<strong>Lab:</strong> Copp Lab, UC Irvine &nbsp;|&nbsp; <a href="mailto:ihl1@uci.edu" style="color: #667eea;">ihl1@uci.edu</a><br>
<strong>Source:</strong> <a href="https://github.com/copplab/NucleoSpec" target="_blank" style="color: #667eea;">github.com/copplab/NucleoSpec</a>
</p>
</div>
<!-- Citation -->
<h4 style="color: #667eea; margin: 0 0 10px 0; font-size: 0.95em;">Citation</h4>
<div style="font-size: 0.8em; line-height: 1.5; color: #555; margin-bottom: 15px;">
<p style="margin: 0;">
If you use NucleoSpec in a publication, please cite:<br>
Lin, I.-H.; Copp, S. M. A Tutorial on Automated Mass Spectral Analysis using NucleoSpec for Compositional Assignment of Nucleic Acid–Silver Complexes and Nanoclusters. <em>ChemRxiv</em> 2026.
<a href="https://doi.org/10.26434/chemrxiv.15004738/v1" target="_blank" style="color: #667eea;">DOI</a>
</p>
</div>
<!-- References -->
<h4 style="color: #667eea; margin: 0 0 10px 0; font-size: 0.95em;">References</h4>
<div style="font-size: 0.8em; line-height: 1.5; color: #555; margin-bottom: 15px;">
<p style="margin: 0 0 6px 0;">
<strong>PythoMS:</strong> Yunker et al., <em>J. Chem. Inf. Model.</em> 2019, 59, 1295-1300.
<a href="https://doi.org/10.1021/acs.jcim.9b00055" target="_blank" style="color: #667eea;">DOI</a>
</p>
<p style="margin: 0 0 6px 0;">
<strong>Charge Detection:</strong> Senko et al., <em>J. Am. Soc. Mass Spectrom.</em> 1995, 6, 229-233.
<a href="https://doi.org/10.1016/1044-0305(95)00017-8" target="_blank" style="color: #667eea;">DOI</a>
</p>
<p style="margin: 0 0 6px 0;">
<strong>IsoSpecPy:</strong> Lacki et al., <em>Anal. Chem.</em> 2017, 89, 3272-3277.
<a href="https://doi.org/10.1021/acs.analchem.6b01459" target="_blank" style="color: #667eea;">DOI</a>
</p>
<p style="margin: 0;">
<strong>JSME:</strong> Bienfait &amp; Ertl, <em>J. Cheminform.</em> 2013, 5, 24.
<a href="https://doi.org/10.1186/1758-2946-5-24" target="_blank" style="color: #667eea;">DOI</a>
</p>
</div>
<!-- Built With -->
<h4 style="color: #667eea; margin: 0 0 10px 0; font-size: 0.95em;">Built With</h4>
<p style="font-size: 0.8em; line-height: 1.6; color: #555; margin: 0 0 15px 0;">
PythoMS, Flask, NumPy, SciPy, Plotly.js, JSME, IsoSpecPy
</p>
<!-- License -->
<h4 style="color: #667eea; margin: 0 0 10px 0; font-size: 0.95em;">License</h4>
<p style="font-size: 0.8em; line-height: 1.5; color: #555; margin: 0;">
CC BY-NC 4.0 &copy; 2026 Copp Lab, UC Irvine.<br>
Free for academic and non-commercial use. Attribution required.<br>
Derivative work based on PythoMS (MIT, &copy; 2018 Yunker &amp; Yeung, U. Victoria).
</p>
</div>
<!-- Modal Footer -->
<div style="background: #f8f9fa; padding: 12px 20px; border-radius: 0 0 10px 10px; text-align: right;">
<button onclick="closeAboutModal()" style="background: #667eea; color: white; border: none; padding: 8px 16px; border-radius: 5px; cursor: pointer; font-weight: bold;">
Close
</button>
</div>
</div>
</div>
</body>
</html>