| <!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> |
| |
| <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); |
| } |
| |
| |
| @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; |
| } |
| |
| @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; |
| } |
| |
| |
| @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-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 & 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"> |
| |
| <div class="sidebar"> |
| |
| <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> |
|
|
| |
| <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> |
| |
| <input type="hidden" id="resolution" value="20000"> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <div id="complex-dna-inputs"> |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <div class="content-area"> |
| <div class="section"> |
| <h2>Mass Spectrum</h2> |
|
|
| |
| <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> |
|
|
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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, '&') |
| .replace(/</g, '<') |
| .replace(/>/g, '>') |
| .replace(/"/g, '"') |
| .replace(/'/g, '''); |
| } |
| |
| // 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, '"')}" |
| 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> |
| |
| |
| <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> |
| |
| |
| <div id="input-params" style="display: block;"> |
| <div style="font-size: 0.9em; color: #666; margin-bottom: 10px;"> |
| Specify composition parameters: |
| </div> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <div id="param-adduct-rows" style="margin-bottom: 8px;"> |
| |
| </div> |
| |
| |
| <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> |
| |
| |
| <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, '"')}" |
| 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()">×</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 & 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> |
|
|
| |
| |
| <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);"> |
| |
| <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;">×</button> |
| </div> |
|
|
| |
| <div style="padding: 30px; max-height: 70vh; overflow-y: auto;"> |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <div> |
| <h3 style="color: #9b59b6; font-size: 1.1em;">Custom Adducts</h3> |
| <div id="custom-adducts-list" style="max-height: 250px; overflow-y: auto;"> |
| |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <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);"> |
| |
| <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;">×</button> |
| </div> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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);"> |
| |
| <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;">×</button> |
| </div> |
|
|
| |
| <div style="padding: 25px; min-height: 300px; max-height: 60vh; overflow-y: auto;"> |
| |
| <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–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>−</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> |
|
|
| |
| <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> |
|
|
| |
| <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'→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'→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> |
|
|
| |
| <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 (< 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> |
|
|
| |
| <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> — 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> — 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> — 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> — 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> — Difference between theoretical and experimental centroid (m/z). Lower is better; < 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> — Mean of cosine similarity and Pearson correlation between experimental and theoretical isotope envelopes (0–1). Reported as a <em>confidence indicator</em>, not the ranking metric. Tiers: <span style="color: #27ae60; font-weight: 600;">▲ > 0.8</span> high, <span style="color: #f39c12; font-weight: 600;">○ 0.5–0.8</span> moderate, <span style="color: #e74c3c; font-weight: 600;">▽ < 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> |
|
|
| |
| <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> |
|
|
| |
| <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>−</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> |
|
|
| |
| <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 – 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 – 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 – all data is discarded after analysis</li> |
| </ul> |
| </div> |
| </div> |
|
|
| |
| <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;"> |
| ← 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 → |
| </button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <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);"> |
| |
| <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;">×</button> |
| </div> |
| |
| <div id="compositionNotFoundContent" style="padding: 20px;"> |
| |
| </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);"> |
| |
| <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;">×</button> |
| </div> |
|
|
| |
| <div style="padding: 20px; max-height: 55vh; overflow-y: auto;"> |
| |
| <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 | <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> |
|
|
| |
| <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> |
|
|
| |
| <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 & 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> |
|
|
| |
| <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> |
|
|
| |
| <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 © 2026 Copp Lab, UC Irvine.<br> |
| Free for academic and non-commercial use. Attribution required.<br> |
| Derivative work based on PythoMS (MIT, © 2018 Yunker & Yeung, U. Victoria). |
| </p> |
| </div> |
|
|
| |
| <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> |
|
|