Spaces:
Running
Running
| <!-- | |
| Request: Build a WebXR gallery app that loads protein-ligand complex PDB files | |
| from Tc-43's HuggingFace datasets and displays them as interactive 3D structures | |
| viewable in an Oculus Quest browser with toggleable representations. | |
| Datasets: CRBN_Binders, HIF2_Inhibitor_synthetic, Novel_NLRP3_Inhibitor_Designs, | |
| ACE2-B0AT1, WRNHelicase_Inhibitor, CyclinA_RXL_PPI_BLOCKER, | |
| KRAS_CyclophilinA_MolecularGlue, novel_myosin_modulator_designs | |
| Usage: Open this file in a browser (or serve via local HTTP server). | |
| On Meta Quest: open in Oculus Browser and tap "Enter VR". | |
| --> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Tc-43 Molecular VR Gallery</title> | |
| <!-- 3Dmol.js for protein-ligand 3D rendering --> | |
| <script src="https://cdn.jsdelivr.net/npm/3dmol@2.0.4/build/3Dmol-min.js"></script> | |
| <!-- A-Frame for WebXR / Oculus support --> | |
| <script src="https://cdn.jsdelivr.net/npm/aframe@1.5.0/dist/aframe.min.js"></script> | |
| <!-- RDKit.js MinimalLib — 2D SMILES depiction --> | |
| <script src="https://unpkg.com/@rdkit/rdkit@2024.3.5-1.0.0/dist/RDKit_minimal.js"></script> | |
| <style> | |
| /* ═══════════════════════════════════════════════ | |
| Exact RCSB / Mol* LIGHT skin | |
| Canvas: #FFFFFF (rcsb-molstar DefaultViewerProps backgroundColor: white) | |
| UI base: #EEECE7 ($default-background = invert(#111318)) | |
| Sidebar: #D4E1EE (.msp-layout-left compiled) | |
| Toolbar: #ECF2F8 (.msp-control-row compiled) | |
| Button: #F5F8FB (.msp-btn compiled) | |
| Border: #B5CBE2 (compiled border-color) | |
| Text: #333333 (primary font) | |
| Accent: #325880 (link/hover blue) | |
| Source: rcsb-molstar.css + molstar light.scss | |
| ═══════════════════════════════════════════════ */ | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| background: #EEECE7; /* $default-background light */ | |
| color: #333333; /* primary font color */ | |
| font-family: 'Segoe UI', Arial, sans-serif; | |
| min-height: 100vh; | |
| } | |
| /* ── Header ── */ | |
| #header { | |
| background: #ECF2F8; /* toolbar/control-row bg */ | |
| border-bottom: 1px solid #B5CBE2; | |
| padding: 8px 16px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| } | |
| #header h1 { | |
| font-size: 1.05rem; | |
| font-weight: 700; | |
| color: #325880; /* RCSB accent blue */ | |
| letter-spacing: 0.01em; | |
| } | |
| #header p { font-size: 0.73rem; color: #595959; margin-top: 1px; } | |
| #vr-btn { | |
| background: #325880; | |
| color: #fff; | |
| border: none; | |
| border-radius: 3px; | |
| padding: 6px 14px; | |
| font-size: 0.82rem; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: background 0.15s; | |
| } | |
| #vr-btn:hover { background: #1e3d5c; } | |
| /* ── Layout ── */ | |
| #app { | |
| display: grid; | |
| grid-template-columns: 260px 1fr; | |
| height: calc(100vh - 50px); | |
| } | |
| /* ── Sidebar ── */ | |
| #sidebar { | |
| background: #D4E1EE; /* .msp-layout-left compiled */ | |
| border-right: 1px solid #B5CBE2; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| } | |
| #dataset-tabs { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 3px; | |
| padding: 7px; | |
| background: #ECF2F8; | |
| border-bottom: 1px solid #B5CBE2; | |
| } | |
| .ds-tab { | |
| background: #F5F8FB; /* button bg */ | |
| border: 1px solid #B5CBE2; | |
| color: #333333; | |
| border-radius: 3px; | |
| padding: 3px 8px; | |
| font-size: 0.68rem; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.12s; | |
| white-space: nowrap; | |
| } | |
| .ds-tab:hover { background: #E3EBF4; border-color: #325880; color: #325880; } | |
| .ds-tab.active { background: #325880; color: #fff; border-color: #325880; } | |
| #search-box { | |
| padding: 6px 7px; | |
| border-bottom: 1px solid #B5CBE2; | |
| background: #D4E1EE; | |
| } | |
| #search-input { | |
| width: 100%; | |
| background: #F5F8FB; | |
| border: 1px solid #B5CBE2; | |
| border-radius: 3px; | |
| color: #333333; | |
| padding: 5px 9px; | |
| font-size: 0.77rem; | |
| outline: none; | |
| } | |
| #search-input::placeholder { color: #878787; } | |
| #search-input:focus { border-color: #325880; box-shadow: 0 0 0 2px rgba(50,88,128,0.15); } | |
| #file-list { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 4px; | |
| } | |
| #file-list::-webkit-scrollbar { width: 5px; } | |
| #file-list::-webkit-scrollbar-track { background: #D4E1EE; } | |
| #file-list::-webkit-scrollbar-thumb { background: #B5CBE2; border-radius: 3px; } | |
| .file-item { | |
| padding: 5px 8px; | |
| border-radius: 3px; | |
| cursor: pointer; | |
| font-size: 0.71rem; | |
| color: #595959; | |
| transition: all 0.1s; | |
| border: 1px solid transparent; | |
| margin-bottom: 1px; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .file-item:hover { background: #E3EBF4; color: #325880; } | |
| .file-item.active { | |
| background: #c8daea; | |
| color: #325880; | |
| border-color: #325880; | |
| font-weight: 600; | |
| } | |
| .file-item .file-idx { | |
| color: #878787; | |
| margin-right: 5px; | |
| font-size: 0.64rem; | |
| } | |
| #sidebar-footer { | |
| padding: 5px 10px; | |
| border-top: 1px solid #B5CBE2; | |
| font-size: 0.67rem; | |
| color: #878787; | |
| text-align: center; | |
| background: #ECF2F8; | |
| } | |
| /* ── RCSB / Local input box ── */ | |
| #rcsb-input-box { | |
| padding: 6px 7px; | |
| border-bottom: 1px solid #B5CBE2; | |
| background: #D4E1EE; | |
| } | |
| #rcsb-pdb-input { | |
| background: #F5F8FB; | |
| border: 1px solid #B5CBE2; | |
| border-radius: 3px; | |
| color: #333333; | |
| padding: 5px 9px; | |
| font-size: 0.77rem; | |
| outline: none; | |
| text-transform: uppercase; | |
| width: 100%; | |
| box-sizing: border-box; | |
| } | |
| #rcsb-pdb-input::placeholder { color: #878787; text-transform: none; } | |
| #rcsb-pdb-input:focus { | |
| border-color: #325880; | |
| box-shadow: 0 0 0 2px rgba(50,88,128,0.15); | |
| } | |
| #rcsb-fetch-btn, #local-file-btn { | |
| background: #F5F8FB; | |
| border: 1px solid #B5CBE2; | |
| color: #325880; | |
| border-radius: 3px; | |
| padding: 4px 10px; | |
| font-size: 0.73rem; | |
| cursor: pointer; | |
| transition: all 0.12s; | |
| font-weight: 600; | |
| white-space: nowrap; | |
| } | |
| #rcsb-fetch-btn:hover, #local-file-btn:hover { | |
| background: #E3EBF4; border-color: #325880; | |
| } | |
| #rcsb-fetch-status { | |
| font-size: 0.63rem; color: #878787; | |
| margin-top: 3px; min-height: 1em; | |
| line-height: 1.3; | |
| } | |
| /* ── Drag-and-drop overlay ── */ | |
| #drop-overlay { | |
| position: absolute; | |
| top: 0; left: 0; right: 0; bottom: 0; | |
| background: rgba(50, 88, 128, 0.12); | |
| border: 3px dashed #325880; | |
| border-radius: 3px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 15; | |
| pointer-events: none; | |
| } | |
| #drop-overlay.hidden { display: none; } | |
| .drop-message { | |
| background: rgba(245, 248, 251, 0.95); | |
| border: 1px solid #B5CBE2; | |
| border-radius: 3px; | |
| padding: 16px 32px; | |
| font-size: 1rem; | |
| color: #325880; | |
| font-weight: 700; | |
| box-shadow: 0 2px 8px rgba(50,88,128,0.15); | |
| } | |
| /* ── Viewer ── */ | |
| #viewer-panel { | |
| display: flex; | |
| flex-direction: column; | |
| background: #ffffff; | |
| position: relative; | |
| } | |
| #viewer-toolbar { | |
| display: flex; | |
| align-items: center; | |
| gap: 5px; | |
| padding: 5px 10px; | |
| border-bottom: 1px solid #B5CBE2; | |
| background: #ECF2F8; | |
| flex-wrap: wrap; | |
| } | |
| #mol-title { | |
| flex: 1; | |
| font-size: 0.79rem; | |
| color: #325880; | |
| font-weight: 700; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| min-width: 80px; | |
| } | |
| .repr-btn { | |
| background: #F5F8FB; | |
| border: 1px solid #B5CBE2; | |
| color: #333333; | |
| border-radius: 3px; | |
| padding: 4px 10px; | |
| font-size: 0.73rem; | |
| cursor: pointer; | |
| transition: all 0.12s; | |
| font-weight: 500; | |
| } | |
| .repr-btn:hover { background: #E3EBF4; border-color: #325880; color: #325880; } | |
| .repr-btn.active { background: #325880; color: #fff; border-color: #325880; } | |
| .nav-btn { | |
| background: #F5F8FB; | |
| border: 1px solid #B5CBE2; | |
| color: #333333; | |
| border-radius: 3px; | |
| padding: 4px 9px; | |
| font-size: 0.73rem; | |
| cursor: pointer; | |
| transition: all 0.12s; | |
| } | |
| .nav-btn:hover { background: #E3EBF4; color: #325880; } | |
| .nav-btn:disabled { opacity: 0.4; cursor: default; } | |
| #viewer-container { | |
| flex: 1; | |
| position: relative; | |
| min-height: 0; | |
| } | |
| #mol-viewer { | |
| width: 100%; | |
| height: 100%; | |
| position: absolute; | |
| top: 0; left: 0; right: 0; bottom: 0; | |
| background: #ffffff; | |
| } | |
| #loading-overlay { | |
| position: absolute; | |
| top: 0; left: 0; right: 0; bottom: 0; | |
| background: rgba(255, 255, 255, 0.88); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 10; | |
| } | |
| #loading-overlay.hidden { display: none; } | |
| .spinner { | |
| width: 40px; height: 40px; | |
| border: 3px solid #B5CBE2; | |
| border-top-color: #325880; | |
| border-radius: 50%; | |
| animation: spin 0.85s linear infinite; | |
| margin-bottom: 12px; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| #loading-text { color: #325880; font-size: 0.88rem; font-weight: 600; } | |
| #loading-sub { color: #878787; font-size: 0.72rem; margin-top: 4px; } | |
| #empty-state { | |
| position: absolute; | |
| top: 50%; left: 50%; | |
| transform: translate(-50%, -50%); | |
| text-align: center; | |
| pointer-events: none; | |
| } | |
| #empty-state .icon { font-size: 3.5rem; margin-bottom: 10px; opacity: 0.25; } | |
| #empty-state p { font-size: 0.87rem; color: #B5CBE2; } | |
| #error-banner { | |
| position: absolute; | |
| bottom: 14px; left: 50%; | |
| transform: translateX(-50%); | |
| background: #fff0f0; | |
| border: 1px solid #DE0A28; | |
| color: #DE0A28; | |
| border-radius: 3px; | |
| padding: 8px 18px; | |
| font-size: 0.79rem; | |
| z-index: 20; | |
| max-width: 80%; | |
| text-align: center; | |
| } | |
| #error-banner.hidden { display: none; } | |
| /* ── Info panel ── */ | |
| #info-panel { | |
| position: absolute; | |
| top: 10px; right: 10px; | |
| background: rgba(245, 248, 251, 0.97); | |
| border: 1px solid #B5CBE2; | |
| border-radius: 3px; | |
| padding: 7px 10px; | |
| font-size: 0.68rem; | |
| color: #595959; | |
| z-index: 5; | |
| min-width: 170px; | |
| max-width: 195px; | |
| max-height: 62vh; | |
| overflow-y: auto; | |
| box-shadow: 0 2px 8px rgba(50,88,128,0.15); | |
| display: none; | |
| } | |
| #info-panel.visible { display: block; } | |
| /* ── 2D Ligand section (inside info-panel) ── */ | |
| #ligand-2d-section { | |
| margin-top: 6px; | |
| padding-top: 6px; | |
| border-top: 1px solid #B5CBE2; | |
| } | |
| #ligand-2d-header { | |
| font-size: 0.65rem; | |
| font-weight: 600; | |
| color: #595959; | |
| margin-bottom: 4px; | |
| letter-spacing: 0.02em; | |
| } | |
| #ligand-2d-svg-wrap { | |
| background: #ffffff; | |
| border: 1px solid #dde8f2; | |
| border-radius: 3px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| min-height: 90px; | |
| overflow: hidden; | |
| } | |
| #ligand-2d-svg-wrap svg { display: block; max-width: 100%; height: auto; } | |
| #ligand-2d-msg { font-size: 0.68rem; color: #B5CBE2; } | |
| #ligand-smiles-section { | |
| margin-top: 4px; | |
| padding: 3px 5px; | |
| background: #F5F8FB; | |
| border: 1px solid #B5CBE2; | |
| border-radius: 2px; | |
| } | |
| #ligand-smiles-lbl { | |
| font-size: 0.57rem; | |
| color: #878787; | |
| font-weight: 600; | |
| letter-spacing: 0.04em; | |
| margin-bottom: 2px; | |
| text-transform: uppercase; | |
| } | |
| #ligand-smiles-val { | |
| font-family: 'Courier New', monospace; | |
| font-size: 0.56rem; | |
| color: #325880; | |
| word-break: break-all; | |
| line-height: 1.40; | |
| max-height: 40px; | |
| overflow-y: auto; | |
| user-select: all; | |
| cursor: text; | |
| } | |
| #info-panel .info-row { display: flex; justify-content: space-between; gap: 10px; margin-bottom: 3px; } | |
| #info-panel .info-label { color: #878787; } | |
| #info-panel .info-val { color: #325880; font-weight: 600; } | |
| /* ── Interaction panel (bottom-right corner; elec panel shares this corner at z:6) ── */ | |
| #interaction-panel { | |
| position: absolute; | |
| bottom: 10px; right: 10px; | |
| background: rgba(245, 248, 251, 0.97); | |
| border: 1px solid #B5CBE2; | |
| border-radius: 3px; | |
| padding: 10px 13px; | |
| font-size: 0.71rem; | |
| color: #595959; | |
| z-index: 5; | |
| min-width: 220px; | |
| max-width: 280px; | |
| max-height: 55vh; | |
| overflow-y: auto; | |
| box-shadow: 0 2px 8px rgba(50,88,128,0.15); | |
| display: none; | |
| } | |
| #interaction-panel.visible { display: block; } | |
| #interaction-title { | |
| font-size: 0.78rem; | |
| font-weight: 700; | |
| color: #325880; | |
| margin-bottom: 7px; | |
| } | |
| #interaction-legend { margin-bottom: 7px; border-bottom: 1px solid #B5CBE2; padding-bottom: 7px; } | |
| .leg-row { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; color: #595959; } | |
| .leg-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; border: 1px solid rgba(0,0,0,0.12); } | |
| #interaction-list { max-height: 240px; overflow-y: auto; } | |
| .iact-item { | |
| display: flex; align-items: flex-start; gap: 6px; | |
| padding: 3px 0; border-bottom: 1px solid #ECF2F8; | |
| font-size: 0.69rem; | |
| } | |
| .iact-item:last-child { border-bottom: none; } | |
| .iact-color { width: 8px; height: 8px; border-radius: 50%; margin-top: 2px; flex-shrink: 0; } | |
| .iact-text { color: #333333; line-height: 1.35; } | |
| .iact-dist { color: #878787; font-size: 0.64rem; } | |
| #interaction-count { | |
| margin-top: 6px; padding-top: 5px; border-top: 1px solid #B5CBE2; | |
| font-size: 0.67rem; color: #878787; text-align: right; | |
| } | |
| /* ── Affinity panel (bottom-left corner; strain panel shares this corner at z:6) ── */ | |
| #affinity-panel { | |
| position: absolute; | |
| bottom: 10px; left: 10px; | |
| background: rgba(245, 248, 251, 0.97); | |
| border: 1px solid #B5CBE2; | |
| border-radius: 3px; | |
| padding: 10px 13px; | |
| font-size: 0.71rem; | |
| color: #595959; | |
| z-index: 5; | |
| min-width: 215px; | |
| max-width: 265px; | |
| max-height: 65vh; | |
| overflow-y: auto; | |
| box-shadow: 0 2px 8px rgba(50,88,128,0.15); | |
| display: none; | |
| } | |
| #affinity-panel.visible { display: block; } | |
| #affinity-title { | |
| font-size: 0.78rem; font-weight: 700; color: #325880; margin-bottom: 7px; | |
| } | |
| .aff-section-hdr { | |
| font-size: 0.70rem; font-weight: 600; color: #325880; | |
| border-bottom: 1px solid #B5CBE2; padding-bottom: 3px; margin-bottom: 5px; | |
| } | |
| #aff-score-box { | |
| background: #EBF4FC; border-radius: 3px; padding: 6px 8px; | |
| margin-bottom: 7px; text-align: center; | |
| } | |
| #aff-dg { font-size: 1.05rem; font-weight: 700; color: #1a5276; } | |
| #aff-pki { font-size: 0.72rem; color: #4a5a8a; margin-top: 2px; } | |
| .aff-row { | |
| display: flex; justify-content: space-between; align-items: center; | |
| padding: 2px 0; border-bottom: 1px solid #ECF2F8; font-size: 0.67rem; | |
| } | |
| .aff-row:last-child { border-bottom: none; } | |
| .aff-contrib { color: #1a5276; font-weight: 600; } | |
| #aff-chembl-section { margin-top: 8px; } | |
| .aff-chembl-item { | |
| padding: 4px 0; border-bottom: 1px solid #ECF2F8; font-size: 0.66rem; | |
| line-height: 1.45; | |
| } | |
| .aff-chembl-item:last-child { border-bottom: none; } | |
| .aff-chembl-id { color: #325880; font-weight: 600; } | |
| .aff-chembl-val { color: #c0392b; font-weight: 600; } | |
| #aff-note { | |
| margin-top: 7px; padding-top: 5px; border-top: 1px solid #B5CBE2; | |
| font-size: 0.60rem; color: #aaaaaa; line-height: 1.4; | |
| } | |
| #aff-chembl-spinner { font-size: 0.65rem; color: #878787; margin-left: 4px; } | |
| /* ── PubChem similar compound section inside affinity panel ── */ | |
| #aff-pubchem-section { margin-top: 8px; } | |
| #aff-pubchem-spinner { font-size: 0.65rem; color: #878787; margin-left: 4px; } | |
| #aff-pubchem-results { min-height: 18px; } | |
| .pubchem-card { | |
| padding: 5px 0; font-size: 0.66rem; line-height: 1.45; | |
| } | |
| .pubchem-card-top { | |
| display: flex; align-items: flex-start; gap: 8px; | |
| } | |
| .pubchem-card-img { | |
| width: 100px; height: 100px; border: 1px solid #dde4ec; | |
| border-radius: 3px; background: #fff; flex-shrink: 0; | |
| } | |
| .pubchem-card-img img { width: 100%; height: 100%; object-fit: contain; } | |
| .pubchem-card-info { flex: 1; min-width: 0; } | |
| .pubchem-cid { color: #325880; font-weight: 600; } | |
| .pubchem-cid a { color: #325880; text-decoration: none; } | |
| .pubchem-cid a:hover { text-decoration: underline; } | |
| .pubchem-name { color: #444; font-size: 0.63rem; word-break: break-word; margin-top: 1px; } | |
| .pubchem-prop { color: #555; font-size: 0.61rem; margin-top: 2px; } | |
| .pubchem-sim-badge { | |
| display: inline-block; background: #d5f0d5; color: #1a6e1a; | |
| font-size: 0.58rem; font-weight: 600; padding: 1px 5px; | |
| border-radius: 2px; margin-left: 4px; | |
| } | |
| /* ── Strain energy panel (bottom-left corner, stacked above affinity panel) ── */ | |
| /* z-index: 6 keeps strain on top of affinity (z:5) when both panels are open */ | |
| #strain-panel { | |
| position: absolute; | |
| bottom: 10px; left: 10px; | |
| background: rgba(245, 248, 251, 0.97); | |
| border: 1px solid #B5CBE2; | |
| border-radius: 3px; | |
| padding: 10px 13px; | |
| font-size: 0.71rem; | |
| color: #595959; | |
| z-index: 6; | |
| min-width: 200px; | |
| max-width: 250px; | |
| box-shadow: 0 2px 8px rgba(50,88,128,0.15); | |
| display: none; | |
| } | |
| #strain-panel.visible { display: block; } | |
| .strain-title { | |
| font-size: 0.78rem; font-weight: 700; color: #325880; margin-bottom: 7px; | |
| } | |
| #strain-score-box { | |
| background: #EBF4FC; border-radius: 3px; padding: 6px 8px; | |
| margin-bottom: 7px; text-align: center; | |
| } | |
| #strain-total { font-size: 1.05rem; font-weight: 700; } | |
| .strain-row { | |
| display: flex; justify-content: space-between; align-items: center; | |
| padding: 2px 0; border-bottom: 1px solid #ECF2F8; font-size: 0.67rem; | |
| } | |
| .strain-row:last-child { border-bottom: none; } | |
| .strain-val { color: #1a5276; font-weight: 600; } | |
| #strain-interp { | |
| margin-top: 6px; font-size: 0.65rem; color: #555; | |
| font-style: italic; line-height: 1.35; | |
| } | |
| .strain-note { | |
| margin-top: 6px; padding-top: 5px; border-top: 1px solid #B5CBE2; | |
| font-size: 0.60rem; color: #aaaaaa; line-height: 1.4; | |
| } | |
| /* ── Electrostatic Repulsion Panel ── */ | |
| /* Bottom-right corner, stacked above interaction panel (z:5). | |
| z-index: 6 ensures elec appears on top of interaction when both are open. */ | |
| #elec-panel { | |
| position: absolute; | |
| bottom: 10px; right: 10px; | |
| background: rgba(245, 248, 251, 0.97); | |
| border: 1px solid #B5CBE2; | |
| border-radius: 3px; | |
| padding: 10px 13px; | |
| font-size: 0.71rem; | |
| color: #595959; | |
| z-index: 6; | |
| min-width: 215px; | |
| max-width: 275px; | |
| box-shadow: 0 2px 8px rgba(50,88,128,0.15); | |
| display: none; | |
| } | |
| #elec-panel.visible { display: block; } | |
| .elec-title { | |
| font-size: 0.78rem; font-weight: 700; color: #325880; margin-bottom: 7px; | |
| } | |
| #elec-score-box { | |
| background: #EBF4FC; border-radius: 3px; padding: 6px 8px; | |
| margin-bottom: 7px; text-align: center; | |
| } | |
| #elec-total { font-size: 1.05rem; font-weight: 700; } | |
| /* Each repulsive pair row: label on the left, energy on the right */ | |
| .elec-row { | |
| display: flex; justify-content: space-between; align-items: center; | |
| padding: 2px 0; border-bottom: 1px solid #ECF2F8; font-size: 0.63rem; | |
| } | |
| .elec-row:last-child { border-bottom: none; } | |
| .elec-val { color: #1a5276; font-weight: 600; font-size: 0.65rem; white-space: nowrap; } | |
| #elec-interp { | |
| margin-top: 6px; font-size: 0.65rem; color: #555; | |
| font-style: italic; line-height: 1.35; | |
| } | |
| #elec-pairs { margin-top: 5px; } | |
| /* ── 3D Conformer Panel ── */ | |
| #conformer-panel { | |
| position: absolute; | |
| top: 10px; right: 10px; | |
| background: rgba(245, 248, 251, 0.97); | |
| border: 1px solid #B5CBE2; | |
| border-radius: 3px; | |
| padding: 8px 10px 10px; | |
| font-size: 0.71rem; | |
| color: #595959; | |
| z-index: 7; /* sits above #info-panel (z-index:5) */ | |
| width: 260px; | |
| box-shadow: 0 2px 8px rgba(50,88,128,0.15); | |
| display: none; | |
| } | |
| #conformer-panel.visible { display: block; } | |
| .conf-title { | |
| font-size: 0.78rem; font-weight: 700; color: #325880; | |
| display: flex; justify-content: space-between; align-items: center; | |
| margin-bottom: 7px; | |
| } | |
| .conf-close { | |
| cursor: pointer; color: #aaa; font-size: 0.85rem; | |
| line-height: 1; padding: 0 2px; | |
| } | |
| .conf-close:hover { color: #325880; } | |
| #conformer-3d-div { | |
| width: 240px; height: 240px; | |
| background: #ffffff; | |
| border: 1px solid #dde6f0; | |
| border-radius: 2px; | |
| position: relative; /* required by 3Dmol.js */ | |
| overflow: hidden; | |
| } | |
| #conformer-status { | |
| margin-top: 5px; | |
| font-size: 0.63rem; color: #878787; | |
| font-style: italic; line-height: 1.4; | |
| min-height: 1.4em; | |
| } | |
| /* ── OpenMM Minimize Panel (top-left, z:8 above target-panel) ── */ | |
| #minimize-panel { | |
| position: absolute; | |
| top: 10px; left: 10px; | |
| background: rgba(245, 248, 251, 0.97); | |
| border: 1px solid #B5CBE2; | |
| border-radius: 3px; | |
| padding: 10px 13px; | |
| font-size: 0.71rem; | |
| color: #595959; | |
| z-index: 8; | |
| min-width: 230px; | |
| max-width: 280px; | |
| box-shadow: 0 2px 8px rgba(50,88,128,0.15); | |
| display: none; | |
| } | |
| #minimize-panel.visible { display: block; } | |
| .minimize-title { | |
| font-size: 0.78rem; font-weight: 700; color: #325880; | |
| display: flex; justify-content: space-between; align-items: center; | |
| margin-bottom: 7px; | |
| } | |
| .minimize-close { | |
| cursor: pointer; color: #aaa; font-size: 0.85rem; | |
| line-height: 1; padding: 0 2px; | |
| } | |
| .minimize-close:hover { color: #325880; } | |
| #minimize-score-box { | |
| background: #EBF4FC; border-radius: 3px; padding: 6px 8px; | |
| margin-bottom: 7px; text-align: center; | |
| } | |
| #minimize-energy { font-size: 1.05rem; font-weight: 700; } | |
| #minimize-status { | |
| margin-top: 5px; | |
| font-size: 0.63rem; color: #878787; | |
| font-style: italic; line-height: 1.4; | |
| min-height: 1.4em; | |
| } | |
| #minimize-run-btn { | |
| background: #325880; color: #fff; border: none; border-radius: 3px; | |
| padding: 5px 12px; font-size: 0.72rem; font-weight: 600; | |
| cursor: pointer; width: 100%; margin-bottom: 6px; | |
| transition: background 0.15s; | |
| } | |
| #minimize-run-btn:hover { background: #1e3d5c; } | |
| #minimize-run-btn:disabled { background: #aaa; cursor: not-allowed; } | |
| .view-btn { | |
| flex: 1; border: 1px solid #B5CBE2; border-radius: 3px; | |
| padding: 4px 2px; font-size: 0.60rem; font-weight: 600; | |
| cursor: pointer; transition: background 0.15s, color 0.15s; | |
| background: #F5F8FB; color: #7a8fa3; | |
| } | |
| .view-btn:hover:not(:disabled):not(.active) { background: #E3EBF4; } | |
| .view-btn:disabled { color: #bbb; border-color: #ddd; cursor: not-allowed; background: #f0f0f0; } | |
| .view-btn.active#view-btn-minimized { background: #2980b9; color: #fff; border-color: #2980b9; } | |
| .view-btn.active#view-btn-original { background: #27ae60; color: #fff; border-color: #27ae60; } | |
| .view-btn.active#view-btn-overlay { background: #c0392b; color: #fff; border-color: #c0392b; } | |
| /* ── Hover tooltip ── */ | |
| #mol-tooltip { | |
| position: fixed; | |
| background: rgba(245, 248, 251, 0.97); | |
| border: 1px solid #B5CBE2; | |
| border-radius: 4px; | |
| padding: 5px 10px; | |
| font-size: 0.73rem; | |
| color: #325880; | |
| pointer-events: none; | |
| z-index: 200; | |
| display: none; | |
| box-shadow: 0 2px 8px rgba(50, 88, 128, 0.18); | |
| white-space: nowrap; | |
| } | |
| #mol-tooltip b { font-weight: 700; } | |
| #mol-tooltip .tip-sub { color: #878787; font-size: 0.67rem; margin-left: 4px; } | |
| /* ── Click-to-select residue badge ── */ | |
| #selection-badge { | |
| position: absolute; | |
| bottom: 52px; left: 50%; transform: translateX(-50%); | |
| background: rgba(0, 131, 176, 0.93); | |
| color: #fff; | |
| border-radius: 4px; | |
| padding: 5px 12px 5px 11px; | |
| font-size: 0.72rem; | |
| display: none; | |
| z-index: 10; | |
| white-space: nowrap; | |
| box-shadow: 0 2px 10px rgba(0,80,130,0.28); | |
| pointer-events: auto; | |
| user-select: none; | |
| gap: 9px; | |
| align-items: center; | |
| } | |
| #selection-badge.visible { display: flex; } | |
| #sel-badge-text b { font-weight: 700; } | |
| #sel-badge-text .sel-dist { font-size: 0.68rem; opacity: 0.88; } | |
| #sel-badge-close { | |
| cursor: pointer; opacity: 0.75; font-size: 0.82rem; | |
| line-height: 1; padding: 1px 3px; border-radius: 2px; | |
| } | |
| #sel-badge-close:hover { opacity: 1.0; background: rgba(255,255,255,0.22); } | |
| /* ── Target significance panel ── */ | |
| #target-panel { | |
| position: absolute; | |
| top: 10px; left: 10px; | |
| background: rgba(245, 248, 251, 0.97); | |
| border: 1px solid #B5CBE2; | |
| border-radius: 3px; | |
| padding: 10px 13px; | |
| font-size: 0.71rem; | |
| color: #595959; | |
| z-index: 5; | |
| min-width: 265px; | |
| max-width: 330px; | |
| max-height: 74vh; | |
| overflow-y: auto; | |
| box-shadow: 0 2px 8px rgba(50,88,128,0.15); | |
| display: none; | |
| } | |
| #target-panel::-webkit-scrollbar { width: 4px; } | |
| #target-panel::-webkit-scrollbar-track { background: #ECF2F8; } | |
| #target-panel::-webkit-scrollbar-thumb { background: #B5CBE2; border-radius: 2px; } | |
| #target-panel.visible { display: block; } | |
| #tp-title { | |
| font-size: 0.82rem; | |
| font-weight: 700; | |
| color: #325880; | |
| margin-bottom: 1px; | |
| line-height: 1.3; | |
| } | |
| #tp-gene { | |
| font-family: 'Courier New', monospace; | |
| font-size: 0.64rem; | |
| color: #878787; | |
| margin-bottom: 6px; | |
| } | |
| .tp-badge { | |
| display: inline-block; | |
| background: #dde8f4; | |
| color: #325880; | |
| border-radius: 2px; | |
| padding: 2px 7px; | |
| font-size: 0.62rem; | |
| font-weight: 700; | |
| margin-bottom: 9px; | |
| letter-spacing: 0.025em; | |
| } | |
| .tp-section { | |
| margin-bottom: 7px; | |
| padding-bottom: 7px; | |
| border-bottom: 1px solid #ECF2F8; | |
| } | |
| .tp-section:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; } | |
| .tp-label { | |
| font-size: 0.60rem; | |
| font-weight: 700; | |
| color: #878787; | |
| text-transform: uppercase; | |
| letter-spacing: 0.055em; | |
| margin-bottom: 3px; | |
| } | |
| .tp-text { font-size: 0.70rem; color: #333333; line-height: 1.48; } | |
| .tp-mono { | |
| font-family: 'Courier New', monospace; | |
| font-size: 0.62rem; | |
| color: #325880; | |
| line-height: 1.55; | |
| word-break: break-word; | |
| } | |
| .tp-pdb { | |
| font-family: 'Courier New', monospace; | |
| font-size: 0.62rem; | |
| color: #595959; | |
| line-height: 1.45; | |
| } | |
| /* ── Responsive / VR ── */ | |
| @media (max-width: 700px) { | |
| #app { grid-template-columns: 1fr; grid-template-rows: 220px 1fr; } | |
| #sidebar { border-right: none; border-bottom: 1px solid #1e2a4a; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- ═══════════════════════════════════════════════════════════ | |
| HEADER | |
| ═══════════════════════════════════════════════════════════ --> | |
| <div id="header"> | |
| <div> | |
| <h1>🧬 Tc-43 Molecular VR Gallery</h1> | |
| <p>Protein–ligand complex viewer · WebXR · 3Dmol.js</p> | |
| </div> | |
| <button id="vr-btn" onclick="enterVR()">🥽 Enter VR</button> | |
| </div> | |
| <!-- ═══════════════════════════════════════════════════════════ | |
| MAIN APP | |
| ═══════════════════════════════════════════════════════════ --> | |
| <div id="app"> | |
| <!-- ── Sidebar ── --> | |
| <div id="sidebar"> | |
| <div id="dataset-tabs"></div> | |
| <div id="search-box"> | |
| <input id="search-input" type="text" placeholder="Search files…" oninput="filterFiles()" /> | |
| </div> | |
| <div id="rcsb-input-box"> | |
| <div style="display:flex;gap:4px;"> | |
| <input id="rcsb-pdb-input" type="text" placeholder="PDB ID (e.g. 3UE4)" maxlength="4" /> | |
| <button id="rcsb-fetch-btn" onclick="fetchRCSBPDB()">Fetch</button> | |
| </div> | |
| <div style="display:flex;gap:4px;margin-top:4px;"> | |
| <button id="local-file-btn" onclick="document.getElementById('local-file-input').click()" style="flex:1">📂 Open PDB File</button> | |
| <input id="local-file-input" type="file" accept=".pdb,.ent" style="display:none" onchange="handleLocalFile(event)" /> | |
| </div> | |
| <div id="rcsb-fetch-status"></div> | |
| </div> | |
| <div id="file-list"></div> | |
| <div id="sidebar-footer" id="count-label">Loading…</div> | |
| </div> | |
| <!-- ── Viewer ── --> | |
| <div id="viewer-panel"> | |
| <div id="viewer-toolbar"> | |
| <span id="mol-title">Select a structure from the list</span> | |
| <button class="nav-btn" id="btn-prev" onclick="navigate(-1)" disabled>◀ Prev</button> | |
| <button class="nav-btn" id="btn-next" onclick="navigate(1)" disabled>Next ▶</button> | |
| <div style="width:1px;height:20px;background:#2a3a6e;margin:0 4px;"></div> | |
| <button class="repr-btn active" id="btn-bs" onclick="setRepr('ball-stick')">Ball & Stick</button> | |
| <button class="repr-btn" id="btn-surface" onclick="setRepr('surface')">Surface</button> | |
| <button class="repr-btn" id="btn-ribbon" onclick="setRepr('ribbon')">Ribbon</button> | |
| <button class="repr-btn" id="btn-wire" onclick="setRepr('wireframe')">Wire</button> | |
| <div style="width:1px;height:20px;background:#2a3a6e;margin:0 4px;"></div> | |
| <button class="repr-btn" onclick="zoomLigand()">🎯 Ligand</button> | |
| <button class="repr-btn" id="btn-interactions" onclick="toggleInteractions()">⚡ Interactions</button> | |
| <button class="repr-btn" id="btn-affinity" onclick="toggleAffinityPanel()">💊 Affinity</button> | |
| <button class="repr-btn" id="btn-strain" onclick="toggleStrainPanel()">⚗️ Strain</button> | |
| <button class="repr-btn" id="btn-elec" onclick="toggleElecPanel()">⊖ Elec</button> | |
| <button class="repr-btn" id="btn-conformer" onclick="toggleConformerPanel()">🔄 Conformer</button> | |
| <button class="repr-btn" id="btn-target" onclick="toggleTargetPanel()">🧬 Target</button> | |
| <button class="repr-btn" id="btn-minimize" onclick="toggleMinimizePanel()">⚙ Minimize</button> | |
| <button class="repr-btn" onclick="toggleInfo()">ℹ Info</button> | |
| <button class="repr-btn" onclick="resetView()">⟳ Reset</button> | |
| </div> | |
| <div id="viewer-container"> | |
| <div id="mol-viewer"></div> | |
| <!-- Drag-and-drop overlay for local PDB files --> | |
| <div id="drop-overlay" class="hidden"> | |
| <div class="drop-message">Drop .pdb file here</div> | |
| </div> | |
| <div id="empty-state"> | |
| <div class="icon">🔬</div> | |
| <p>Select a dataset tab and click a structure to view it</p> | |
| </div> | |
| <div id="loading-overlay" class="hidden"> | |
| <div class="spinner"></div> | |
| <div id="loading-text">Loading structure…</div> | |
| <div id="loading-sub"></div> | |
| </div> | |
| <div id="error-banner" class="hidden"></div> | |
| <div id="info-panel"> | |
| <div class="info-row"><span class="info-label">Dataset</span><span class="info-val" id="ip-dataset">—</span></div> | |
| <div class="info-row"><span class="info-label">File</span><span class="info-val" id="ip-file">—</span></div> | |
| <div class="info-row"><span class="info-label">Index</span><span class="info-val" id="ip-idx">—</span></div> | |
| <div class="info-row"><span class="info-label">Repr</span><span class="info-val" id="ip-repr">—</span></div> | |
| <!-- ── 2D Ligand ── --> | |
| <div id="ligand-2d-section"> | |
| <div id="ligand-2d-header">2D Ligand</div> | |
| <div id="ligand-2d-svg-wrap"> | |
| <span id="ligand-2d-msg">—</span> | |
| </div> | |
| <div id="ligand-smiles-section"> | |
| <div id="ligand-smiles-lbl">SMILES</div> | |
| <div id="ligand-smiles-val">—</div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ── Target significance panel (top-left) ── --> | |
| <div id="target-panel"> | |
| <div id="tp-title">—</div> | |
| <div id="tp-gene">—</div> | |
| <div id="tp-class" class="tp-badge">—</div> | |
| <div class="tp-section"> | |
| <div class="tp-label">Therapeutic Area</div> | |
| <div id="tp-area" class="tp-text">—</div> | |
| </div> | |
| <div class="tp-section"> | |
| <div class="tp-label">Disease Context</div> | |
| <div id="tp-diseases" class="tp-text">—</div> | |
| </div> | |
| <div class="tp-section"> | |
| <div class="tp-label">Mechanism of Action</div> | |
| <div id="tp-mechanism" class="tp-text">—</div> | |
| </div> | |
| <div class="tp-section"> | |
| <div class="tp-label">Drug Discovery Significance</div> | |
| <div id="tp-significance" class="tp-text">—</div> | |
| </div> | |
| <div class="tp-section"> | |
| <div class="tp-label">Notable Compounds</div> | |
| <div id="tp-compounds" class="tp-mono">—</div> | |
| </div> | |
| <div class="tp-section"> | |
| <div class="tp-label">Key PDB Structures</div> | |
| <div id="tp-pdb" class="tp-pdb">—</div> | |
| </div> | |
| </div> | |
| <!-- ── Ligand Strain Energy Panel ── --> | |
| <div id="strain-panel"> | |
| <div class="strain-title">⚗️ Torsional Strain</div> | |
| <div id="strain-score-box"> | |
| <div id="strain-total">—</div> | |
| <div id="strain-meta" style="font-size:0.65rem;color:#878787;margin-top:2px">—</div> | |
| </div> | |
| <div id="strain-interp"></div> | |
| <div class="strain-note"> | |
| CSD potentials (CCDC A2 poster): amide 12, ester 8,<br> | |
| aryl-ether 10/8(anti)/4(⊥), aryl-NH 8/6(anti)/3(⊥),<br> | |
| aryl-S 7/7(anti)/4(⊥), aryl-SO2 5/4(⊥),<br> | |
| biaryl 3(45°)/4(75°,bis-ortho), bipyridyl 5(anti),<br> | |
| aryl-2-pyridyl 3(22°), aryl-NR2 3, aryl-CO 5,<br> | |
| benzyl-CH2 2(⊥), benzyl-CHR 2(0°), alkyl 1.5 kcal/mol<br> | |
| + 1,4 steric + 1,5 ortho-sub steric (mono-ortho bonds only). | |
| </div> | |
| </div> | |
| <!-- ══════════════════════════════════════════════════════════════════ | |
| Electrostatic Repulsion Panel | |
| ────────────────────────────── | |
| Computes the Coulomb repulsion energy between the ligand and the | |
| protein binding-site atoms using rule-based partial charges and | |
| the same Coulomb formula as electron_repulsion.py: | |
| E = k·q1·q2 / (ε·r) [kcal/mol, k=332.06, ε=4, cutoff 5 Å] | |
| Only same-sign charge pairs (both negative O/N/F/Cl) are flagged. | |
| Note: AutoDock Vina has NO electrostatic term, so this panel fills | |
| that gap for post-docking electrostatic screening. | |
| ═══════════════════════════════════════════════════════════════════ --> | |
| <div id="elec-panel"> | |
| <div class="elec-title">⊖ Electrostatic Repulsion</div> | |
| <!-- pH and force-field controls for pdb2pqr (server mode only). | |
| When elec_server.py is running, changing these will re-run pdb2pqr | |
| at the new pH / FF and refresh the panel + 3D lines. --> | |
| <div id="elec-controls" style="display:flex;align-items:center;gap:6px;margin-bottom:5px;font-size:0.65rem;color:#555"> | |
| <label title="pH for pdb2pqr protonation via PROPKA (server mode)">pH | |
| <input id="elec-ph-input" type="number" min="0" max="14" step="0.5" value="7.0" | |
| style="width:38px;font-size:0.65rem;padding:1px 3px;border:1px solid #ccc;border-radius:3px" | |
| onchange="onElecSettingChange()"> | |
| </label> | |
| <label title="Protein force-field for pdb2pqr charges (server mode)">FF | |
| <select id="elec-ff-select" | |
| style="font-size:0.65rem;padding:1px 2px;border:1px solid #ccc;border-radius:3px" | |
| onchange="onElecSettingChange()"> | |
| <option value="AMBER" selected>AMBER</option> | |
| <option value="CHARMM">CHARMM</option> | |
| </select> | |
| </label> | |
| <!-- Source indicator: "pdb2pqr · AMBER · pH 7.0" or "rule-based · server offline" --> | |
| <span id="elec-source" style="margin-left:auto;color:#878787;font-style:italic;font-size:0.60rem">—</span> | |
| </div> | |
| <!-- Total repulsion energy (colour-coded green / amber / red) --> | |
| <div id="elec-score-box"> | |
| <div id="elec-total">—</div> | |
| <div id="elec-meta" style="font-size:0.65rem;color:#878787;margin-top:2px">—</div> | |
| </div> | |
| <!-- Qualitative verdict line --> | |
| <div id="elec-interp"></div> | |
| <!-- Top-3 worst repulsive pairs mini-table, filled by updateElecPanel() --> | |
| <div id="elec-pairs"></div> | |
| <div class="strain-note"> | |
| Coulomb scan: ligand × binding-site atoms. ε=4 (protein interior), | |
| cutoff 5 Å. Protein charges via <strong>pdb2pqr+PROPKA</strong> when server running | |
| (start: <code style="font-size:0.58rem">python3 elec_server.py</code>); falls back to | |
| rule-based estimates offline.<br> | |
| Ligand charges: rule-based (backbone/Asp/Glu O ≈ −0.57–0.80, | |
| amine/amide N ≈ −0.34–0.42, O/N/F/Cl/S by bonding context).<br> | |
| Positive-charge residues (Lys, Arg) show as attractive — not flagged.<br> | |
| <em>Note: AutoDock Vina has no Coulomb term — this panel fills that gap.</em> | |
| </div> | |
| </div> | |
| <!-- ── 3D Conformer Panel ── --> | |
| <div id="conformer-panel"> | |
| <div class="conf-title"> | |
| 🔄 3D Conformer | |
| <span class="conf-close" onclick="toggleConformerPanel()" title="Close">✕</span> | |
| </div> | |
| <div id="conformer-3d-div"></div> | |
| <div id="conformer-status">—</div> | |
| <div class="strain-note" style="margin-top:5px"> | |
| RDKit ETKDGv3 + MMFF94s conformer via local server.<br> | |
| Start first: <code style="font-size:0.58rem">python3 conformer_server.py</code><br> | |
| Falls back to bound PDB conformation if server is off. | |
| </div> | |
| </div> | |
| <!-- ── OpenMM Energy Minimisation Panel ── --> | |
| <div id="minimize-panel"> | |
| <div class="minimize-title"> | |
| ⚙ Energy Minimize | |
| <span class="minimize-close" onclick="toggleMinimizePanel()" title="Close">✕</span> | |
| </div> | |
| <button id="minimize-run-btn" onclick="runMinimization()">Run OpenMM Minimization</button> | |
| <div id="minimize-score-box"> | |
| <div id="minimize-energy">—</div> | |
| <div id="minimize-meta" style="font-size:0.65rem;color:#878787;margin-top:2px">—</div> | |
| </div> | |
| <div id="minimize-rmsd" style="font-size:0.72rem;text-align:center;margin-bottom:5px;color:#555"></div> | |
| <div id="minimize-status">—</div> | |
| <div id="minimize-view-btns" style="display:flex; gap:4px; margin-top:4px;"> | |
| <button id="view-btn-minimized" class="view-btn" onclick="setViewMode('minimized')" disabled>Minimized</button> | |
| <button id="view-btn-original" class="view-btn" onclick="setViewMode('original')" disabled>Original</button> | |
| <button id="view-btn-overlay" class="view-btn" onclick="setViewMode('overlay')" disabled>Overlay</button> | |
| </div> | |
| <div class="strain-note" style="margin-top:5px"> | |
| OpenMM AMBER14 + GAFF-2.11 energy minimisation via local server.<br> | |
| Start first: <code style="font-size:0.58rem">python3 minimize_server.py</code><br> | |
| Tolerance: 10 kJ/mol/nm, max 500 iterations. | |
| </div> | |
| </div> | |
| <!-- ── Binding Affinity Panel ── --> | |
| <div id="affinity-panel"> | |
| <div id="affinity-title">💊 Binding Affinity</div> | |
| <div id="aff-empirical-section"> | |
| <div class="aff-section-hdr">Empirical Score</div> | |
| <div id="aff-score-box"> | |
| <div id="aff-dg">—</div> | |
| <div id="aff-pki">—</div> | |
| </div> | |
| <div id="aff-breakdown"></div> | |
| </div> | |
| <div id="aff-chembl-section"> | |
| <div class="aff-section-hdr">ChEMBL Experimental Data | |
| <span id="aff-chembl-spinner"></span> | |
| </div> | |
| <div id="aff-chembl-results"> | |
| <span style="color:#aaa;font-size:0.67rem">—</span> | |
| </div> | |
| </div> | |
| <div id="aff-pubchem-section"> | |
| <div class="aff-section-hdr">PubChem Similar Compound | |
| <span id="aff-pubchem-spinner"></span> | |
| </div> | |
| <div id="aff-pubchem-results"> | |
| <span style="color:#aaa;font-size:0.67rem">—</span> | |
| </div> | |
| </div> | |
| <div id="aff-note"> | |
| Empirical score from geometry (H-bonds, π–π, hydrophobic, salt bridges).<br> | |
| For ML predictions run locally: <em>Boltz-1</em> (MIT, open-source) or | |
| <em>DiffDock-Score</em>. | |
| </div> | |
| </div> | |
| <div id="interaction-panel"> | |
| <div id="interaction-title">⚡ Interactions</div> | |
| <div id="interaction-legend"> | |
| <div class="leg-row"><span class="leg-dot" style="background:#2471a3"></span> H-Bond (N/O) <em style="font-size:0.62rem;color:#878787">always on</em></div> | |
| <div class="leg-row"><span class="leg-dot" style="background:#e67e22"></span> H-Bond (weak/S)</div> | |
| <div class="leg-row"><span class="leg-dot" style="background:#8e44ad"></span> π–π Stacking</div> | |
| <div class="leg-row"><span class="leg-dot" style="background:#c0392b"></span> Hydrophobic</div> | |
| <div class="leg-row"><span class="leg-dot" style="background:#27ae60"></span> Salt Bridge</div> | |
| </div> | |
| <div id="interaction-list"></div> | |
| <div id="interaction-count"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Hover tooltip (positioned by JS) --> | |
| <div id="mol-tooltip"></div> | |
| <!-- Click-to-select residue badge (bottom-centre of viewer) --> | |
| <div id="selection-badge"> | |
| <span id="sel-badge-text">—</span> | |
| <span id="sel-badge-close" title="Clear selection (Esc)" onclick="clearSelection()">✕</span> | |
| </div> | |
| <!-- ═══════════════════════════════════════════════════════════ | |
| JAVASCRIPT | |
| ═══════════════════════════════════════════════════════════ --> | |
| <script> | |
| // ── Dataset definitions ───────────────────────────────────── | |
| const HF_BASE = 'https://huggingface.co/datasets/Tc-43'; | |
| const HF_RESOLVE = (dataset, file) => | |
| `${HF_BASE}/${dataset}/resolve/main/${encodeURIComponent(file)}?download=true`; | |
| const DATASETS = { | |
| 'CRBN_Binders': { | |
| label: 'CRBN', | |
| color: '#4a8aee', | |
| files: [ | |
| 's1012307_cmpx.pdb','s1017764_cmpx.pdb','s10212_cmpx.pdb','s1027407_cmpx.pdb', | |
| 's10490_cmpx.pdb','s1083141_cmpx.pdb','s1094543_cmpx.pdb','s1119362_cmpx.pdb', | |
| 's114350_cmpx.pdb','s1151439_cmpx.pdb','s1152768_cmpx.pdb','s1159154_cmpx.pdb', | |
| 's116046_cmpx.pdb','s1190271_cmpx.pdb','s1197347_cmpx.pdb','s1211948_cmpx.pdb', | |
| 's1235511_cmpx.pdb','s1264539_cmpx.pdb','s127256_cmpx.pdb','s1282746_cmpx.pdb', | |
| 's1300063_cmpx.pdb','s1315327_cmpx.pdb','s133276_cmpx.pdb','s1333057_cmpx.pdb', | |
| 's1338668_cmpx.pdb','s1355408_cmpx.pdb','s1381130_cmpx.pdb','s139171_cmpx.pdb', | |
| 's139172_cmpx.pdb','s139173_cmpx.pdb','s139174_cmpx.pdb','s139182_cmpx.pdb', | |
| 's140717_cmpx.pdb','s1413313_cmpx.pdb','s1436513_cmpx.pdb','s1443416_cmpx.pdb', | |
| 's1489411_cmpx.pdb','s149207_cmpx.pdb','s1502089_cmpx.pdb','s152173_cmpx.pdb', | |
| 's1534821_cmpx.pdb','s1535376_cmpx.pdb','s1535377_cmpx.pdb','s1535378_cmpx.pdb', | |
| 's1535385_cmpx.pdb','s1538874_cmpx.pdb','s1538876_cmpx.pdb','s1538883_cmpx.pdb', | |
| ] | |
| }, | |
| 'HIF2_Inhibitor_synthetic': { | |
| label: 'HIF2', | |
| color: '#ee8a4a', | |
| files: [ | |
| 's1011099_cmpx.pdb','s1150728_cmpx.pdb','s1158496_cmpx.pdb','s1231989_cmpx.pdb', | |
| 's1235504_cmpx.pdb','s1238967_cmpx.pdb','s1339788_cmpx.pdb','s1341510_cmpx.pdb', | |
| 's1346177_cmpx.pdb','s1346479_cmpx.pdb','s1377092_cmpx.pdb','s1384862_cmpx.pdb', | |
| 's144101_cmpx.pdb','s1452546_cmpx.pdb','s1545008_cmpx.pdb','s1586945_cmpx.pdb', | |
| 's167802_cmpx.pdb','s1682792_cmpx.pdb','s1691667_cmpx.pdb','s1824094_cmpx.pdb', | |
| 's1841647_cmpx.pdb','s1842312_cmpx.pdb','s190051_cmpx.pdb','s194912_cmpx.pdb', | |
| 's1952954_cmpx.pdb','s2074392_cmpx.pdb','s2091813_cmpx.pdb','s2132546_cmpx.pdb', | |
| 's2141636_cmpx.pdb','s2152355_cmpx.pdb','s2166755_cmpx.pdb','s220190_cmpx.pdb', | |
| 's2233696_cmpx.pdb','s2234183_cmpx.pdb','s2246951_cmpx.pdb','s2257583_cmpx.pdb', | |
| 's245275_cmpx.pdb','s2457778_cmpx.pdb','s248721_cmpx.pdb','s2520873_cmpx.pdb', | |
| 's2551626_cmpx.pdb','s2551753_cmpx.pdb','s2552937_cmpx.pdb','s2554691_cmpx.pdb', | |
| 's263652_cmpx.pdb','s268433_cmpx.pdb','s27237_cmpx.pdb','s317316_cmpx.pdb', | |
| 's329381_cmpx.pdb', | |
| ] | |
| }, | |
| 'Novel_NLRP3_Inhibitor_Designs': { | |
| label: 'NLRP3', | |
| color: '#4aee8a', | |
| files: [ | |
| '250815081648-609d00d3_cmpx.pdb','250815081847-54c1654a_cmpx.pdb', | |
| '250815081929-eb7821bb_cmpx.pdb','250815081956-ad54d76f_cmpx.pdb', | |
| '250815082043-d7837624_cmpx.pdb','250815082044-b09184c2_cmpx.pdb', | |
| '250815082101-71f594d3_cmpx.pdb','250815204808-bf980454-4_cmpx.pdb', | |
| '250815204808-bf980454-8_cmpx.pdb','250820221800-fb073dd4_cmpx.pdb', | |
| '250824150836-487d84cf-1_cmpx.pdb','250824150836-487d84cf-2_cmpx.pdb', | |
| '250824150836-487d84cf-3_cmpx.pdb','250824150836-487d84cf-4_cmpx.pdb', | |
| '250824150836-487d84cf-5_cmpx.pdb','250824150836-487d84cf-6_cmpx.pdb', | |
| '250824150836-487d84cf-7_cmpx.pdb','250824150836-487d84cf-8_cmpx.pdb', | |
| '250824150836-487d84cf-9_cmpx.pdb','250824153715-f58cdc3f-1_cmpx.pdb', | |
| '250824153715-f58cdc3f-2_cmpx.pdb','250824153715-f58cdc3f-3_cmpx.pdb', | |
| '250824153715-f58cdc3f-4_cmpx.pdb','250824153715-f58cdc3f-5_cmpx.pdb', | |
| '250824153715-f58cdc3f-6_cmpx.pdb','250824153715-f58cdc3f-7_cmpx.pdb', | |
| ] | |
| }, | |
| 'WRNHelicase_Inhibitor': { | |
| label: 'WRN', | |
| color: '#ee4a8a', | |
| files: [ | |
| '250731032303-0543ecf5_cmpx.pdb','250731034137-3f8f6ea7_cmpx.pdb', | |
| '250731044200-0e9564d6_cmpx.pdb','250731050930-77d77d1c_cmpx.pdb', | |
| '250731075623-f589674e_cmpx.pdb','250802195610-134f6f94_cmpx.pdb', | |
| '250802202252-4708c0e5_cmpx.pdb','250802203001-6ad8eb58_cmpx.pdb', | |
| '250802203314-561d0aba_cmpx.pdb','250802203702-90f52b7d_cmpx.pdb', | |
| '250802205218-f7eddf56_cmpx.pdb','250802211736-e18eb6cf_cmpx.pdb', | |
| '250802213535-65345fb2_cmpx.pdb','250802213841-29877543_cmpx.pdb', | |
| '250802213906-cf619765_cmpx.pdb','250802221408-7d871794_cmpx.pdb', | |
| '250802222301-91422e91_cmpx.pdb','250805135152-20913fcb_cmpx.pdb', | |
| '250805153010-9de5a334_cmpx.pdb','250805174645-683ce49c_cmpx.pdb', | |
| '250805181311-1e9c5c83_cmpx.pdb','250805211943-a41f4e91_cmpx.pdb', | |
| '250805213701-676c89f9_cmpx.pdb','250805235924-d43fae8f_cmpx.pdb', | |
| '250807033856-89b06e5a_cmpx.pdb','250807041522-025eaae4_cmpx.pdb', | |
| '250807041818-47ebe241_cmpx.pdb','250807052406-53cd47cf_cmpx.pdb', | |
| '250807072424-6c50af55_cmpx.pdb','250807073023-fd2aa143_cmpx.pdb', | |
| '250807080017-a87fe001_cmpx.pdb','250807105306-6ab63370_cmpx.pdb', | |
| '250807110735-2d5530e3_cmpx.pdb','250807112047-ffb8958c_cmpx.pdb', | |
| ] | |
| }, | |
| 'CyclinA_RXL_PPI_BLOCKER': { | |
| label: 'CyclinA', | |
| color: '#aa4aee', | |
| files: [ | |
| '250830123831-5722c95a_cmpx.pdb','250830231036-1107e911_cmpx.pdb', | |
| '250830233609-a62985d4_cmpx.pdb','250831003157-7c355cc3_cmpx.pdb', | |
| '250831004611-8c997049_cmpx.pdb','250831010740-c2130e4e_cmpx.pdb', | |
| '250831011435-ad3aae6f_cmpx.pdb','250831030036-e385fd0b_cmpx.pdb', | |
| '250831031750-375860b9_cmpx.pdb','250831032431-2b95ccce_cmpx.pdb', | |
| '250831040103-386729bb_cmpx.pdb','250831042343-90420829_cmpx.pdb', | |
| '250831052032-694d9ee4_cmpx.pdb','250831060628-8b982d3a_cmpx.pdb', | |
| '250831060930-17e5455f_cmpx.pdb','250831062008-a8ef6def_cmpx.pdb', | |
| '250831065951-1d79d68e_cmpx.pdb','250831083134-f5eada73_cmpx.pdb', | |
| '250831083835-cd50ba0e_cmpx.pdb','250831084207-accba1ab_cmpx.pdb', | |
| '250831090547-0c1ab757_cmpx.pdb','250831235814-c136ecfe_cmpx.pdb', | |
| '250905095923-2775f481_cmpx.pdb','250905100453-1e06dc6e_cmpx.pdb', | |
| '250905100532-1877029c_cmpx.pdb','250905100639-1fb71b79_cmpx.pdb', | |
| '250905100744-ad8ef2b6_cmpx.pdb','250905101551-9f4264b7_cmpx.pdb', | |
| '250905101633-f13c074c_cmpx.pdb','250905101637-a71bd7ba_cmpx.pdb', | |
| '250905101829-94e9aa8d_cmpx.pdb','250905102007-6eaeed3d_cmpx.pdb', | |
| '250905102119-c829dd95_cmpx.pdb','250905103937-31e6e847_cmpx.pdb', | |
| '250905103946-6ce3125e_cmpx.pdb','250905150504-64333ca7_cmpx.pdb', | |
| '250905152709-086d6645_cmpx.pdb','250906013247-e6b3a5a2_cmpx.pdb', | |
| '250907091357-6a746f04_cmpx.pdb','250907091520-4a4e0c03_cmpx.pdb', | |
| '250907091624-0be7ed9e_cmpx.pdb','250907091721-084f5b02_cmpx.pdb', | |
| '250907091721-ffd52cb6_cmpx.pdb','250907091723-126ec7c4_cmpx.pdb', | |
| '250907091726-9bbc43f5_cmpx.pdb','250907091743-2b4932a7_cmpx.pdb', | |
| '250907091744-3774fdcb_cmpx.pdb','250907091746-4394851c_cmpx.pdb', | |
| '250907092422-5692b5f4_cmpx.pdb', | |
| ] | |
| }, | |
| 'KRAS_CyclophilinA_MolecularGlue': { | |
| label: 'KRAS', | |
| color: '#eedd4a', | |
| files: [ | |
| '250827081705-e8e3d616_cmpx.pdb','250827081932-1f0c204c_cmpx.pdb', | |
| '250827082836-3d791c43_cmpx.pdb','250827082950-350459f5_cmpx.pdb', | |
| '250827083121-5238efa3_cmpx.pdb','250827085638-38ec3339_cmpx.pdb', | |
| '250827091756-51a37874_cmpx.pdb','250827093042-6b02034b_cmpx.pdb', | |
| '250827095734-832cc7f6_cmpx.pdb','250827095756-ab1df039_cmpx.pdb', | |
| '250827095855-b8c8e511_cmpx.pdb','250827095908-e4c49ba2_cmpx.pdb', | |
| '250827095911-46d3e556_cmpx.pdb','250827095919-c8e74089_cmpx.pdb', | |
| '250827095920-1ef53d72_cmpx.pdb','250827095920-8a22b9a0_cmpx.pdb', | |
| '250827095930-978fcfc2_cmpx.pdb','250827095944-1d4a91c2_cmpx.pdb', | |
| '250827095948-9f9bb8d5_cmpx.pdb','250827100024-a60627e9_cmpx.pdb', | |
| '250827100043-26d3a301_cmpx.pdb','250827100100-6ecf8ad7_cmpx.pdb', | |
| '250827100109-01822d3a_cmpx.pdb','250827100122-b2bf00d3_cmpx.pdb', | |
| '250827100139-c2a4c97b_cmpx.pdb','250827100148-ebcb9b61_cmpx.pdb', | |
| '250827100230-b30f6cc6_cmpx.pdb','250827100246-a86fae76_cmpx.pdb', | |
| '250827100339-33a3faa9_cmpx.pdb','250827100344-33f1acb2_cmpx.pdb', | |
| '250827100350-1867a4a6_cmpx.pdb','250827100354-7d60af45_cmpx.pdb', | |
| '250827100408-52aea8e6_cmpx.pdb','250827100431-07a07d2f_cmpx.pdb', | |
| '250827100459-359d868d_cmpx.pdb','250827100501-d6de5cba_cmpx.pdb', | |
| '250827100520-83d3d794_cmpx.pdb','250827100535-a0dacc0e_cmpx.pdb', | |
| '250827100537-c016ae67_cmpx.pdb','250827100602-c5ee861e_cmpx.pdb', | |
| ] | |
| }, | |
| 'novel_myosin_modulator_designs': { | |
| label: 'Myosin', | |
| color: '#4aeeee', | |
| files: [ | |
| '250913164752-a534bc11_cmpx.pdb','250913164932-1c18025a_cmpx.pdb', | |
| '250913164956-7d094df7_cmpx.pdb','250919201321-e7268568_cmpx.pdb', | |
| '250919201516-feec91e6_cmpx.pdb','250919205955-838e9a09_cmpx.pdb', | |
| '250919221545-18cc41b5_cmpx.pdb','250928224046-bcc1c865_cmpx.pdb', | |
| '250928224125-898a1fdc_cmpx.pdb','250928224343-c7286c0b_cmpx.pdb', | |
| '250928224442-1c2245cc_cmpx.pdb','250928224839-419737b0_cmpx.pdb', | |
| '250928224932-01ae098b_cmpx.pdb','250928225132-4c9c4ccb_cmpx.pdb', | |
| '250928225431-3b5a0299_cmpx.pdb','250928225623-ff31541f_cmpx.pdb', | |
| '250928225734-994b5b15_cmpx.pdb','250928225854-24d8c4f7_cmpx.pdb', | |
| '250928230452-4d4d88ac_cmpx.pdb','250928230555-3edc8be0_cmpx.pdb', | |
| '250928230719-172cff8e_cmpx.pdb','250928230738-414a35bc_cmpx.pdb', | |
| ] | |
| }, | |
| 'ACE2-B0AT1': { | |
| label: 'ACE2', | |
| color: '#ff6b6b', | |
| files: [ | |
| '251004170759-61b4b936_cmpx.pdb','251004170908-969ea277_cmpx.pdb', | |
| '251004170935-2daa29b5_cmpx.pdb','251004171154-5c81ad04_cmpx.pdb', | |
| '251004171333-26f517e7_cmpx.pdb','251004171643-e4abc734_cmpx.pdb', | |
| '251004171833-23361efa_cmpx.pdb','251004171906-cb574ad2_cmpx.pdb', | |
| '251005101653-7c1a6615-1_cmpx.pdb','251005101942-a785e829-3_cmpx.pdb', | |
| '251005101942-a785e829-4_cmpx.pdb','251005110544-d2cfe151-8_cmpx.pdb', | |
| '251005110544-d2cfe151-9_cmpx.pdb','251005231232-fdce00d3_cmpx.pdb', | |
| '251005233304-5090146c_cmpx.pdb','251005233336-3e16df38_cmpx.pdb', | |
| '251006074016-2ed3ca2c-6_cmpx.pdb','251006074016-2ed3ca2c-7_cmpx.pdb', | |
| '251006074157-a63eed91-6_cmpx.pdb','251006074157-a63eed91-8_cmpx.pdb', | |
| '251006093926-7c1925fc_cmpx.pdb','251006094658-44786eae_cmpx.pdb', | |
| '251006124517-adcf7902-2_cmpx.pdb','251006124651-3e2235ed-4_cmpx.pdb', | |
| '251006125631-6747bdf7-7_cmpx.pdb','251006130829-b834f54c-2_cmpx.pdb', | |
| '251007103747-821201e1-7_cmpx.pdb','251007105506-cb750747-7_cmpx.pdb', | |
| '251007105725-268502cb-1_cmpx.pdb','251007110144-404ab235-4_cmpx.pdb', | |
| '251007112337-eba71903-9_cmpx.pdb','251007113000-6a768c0f-9_cmpx.pdb', | |
| '251007113715-7e5d7288-4_cmpx.pdb','251007114157-43348f27-7_cmpx.pdb', | |
| '251007120011-79a2f4f2-7_cmpx.pdb','251007121856-8cb5ca2d-7_cmpx.pdb', | |
| '251007122957-9ec6ba1e-5_cmpx.pdb','251007123504-5ba03364-7_cmpx.pdb', | |
| ] | |
| }, | |
| }; | |
| // ── Virtual dataset for user-loaded structures (RCSB fetch + local upload) ── | |
| const RCSB_DATASET_KEY = '__RCSB_LOCAL__'; | |
| DATASETS[RCSB_DATASET_KEY] = { | |
| label: 'RCSB / Local', | |
| color: '#00796B', | |
| files: [], // display labels, populated dynamically | |
| isVirtual: true, | |
| entries: [], // parallel array: {pdbId, pdbText, label, source:'rcsb'|'local'} | |
| }; | |
| // Common crystallographic/buffer additives to exclude from ligand detection. | |
| // These HETATM residues are not drug-like and should not be selected as the primary ligand. | |
| const CRYST_ARTIFACTS = new Set([ | |
| 'HOH','WAT','SOL','TIP', // water | |
| 'GOL','EDO','PEG','PGE','1PE','P6G','PG4','PE4', // glycols / PEG | |
| 'SO4','PO4','NO3','CIT','ACT','FMT','TRS','EPE','MES', // buffers | |
| 'DMS','DMF','ACE','BME','DTT', // solvents / reducing agents | |
| 'BOG','LDA','SDS','OLC', // detergents | |
| 'SCN','AZI','MPD','IPA','EOH','MOH', // cryoprotectants | |
| 'UNX','UNL', // unknown placeholders | |
| 'NA','CL','MG','ZN','CA','FE','MN','K','ION','NI','CU','CO', // metal ions | |
| ]); | |
| // ── Medicinal chemistry significance data ──────────────────── | |
| const TARGET_INFO = { | |
| 'CRBN_Binders': { | |
| fullName: 'Cereblon (CRBN)', | |
| gene: 'CRBN · UniProt Q96SW2', | |
| cls: 'E3 Ubiquitin Ligase Adaptor', | |
| area: 'Oncology · Immunology · CNS', | |
| diseases: 'Multiple myeloma, lenalidomide-sensitive hematologic malignancies, ' + | |
| 'neurodevelopmental disorders (CRBN germline variants).', | |
| mechanism: 'CRBN is the substrate receptor of the CRL4ᶜʳᵇⁿ E3 ligase complex. ' + | |
| 'Molecular glues and PROTACs co-opt CRBN to recruit neo-substrates ' + | |
| '(IKZF1/3, CK1α, GSPT1) for proteasomal degradation. IMiD drugs ' + | |
| '(thalidomide, lenalidomide, pomalidomide) bind the C-terminal thalidomide-' + | |
| 'binding domain (TBD) and reshape its surface to engage new substrate proteins.', | |
| significance: 'CRBN is the founding E3 ligase exploited in targeted protein degradation (TPD). ' + | |
| 'It underpins the entire IMiD/CELMoD class and is the most widely used E3 ' + | |
| 'ligase for PROTAC design. Understanding its binding pocket drives next-generation ' + | |
| 'molecular glue discovery and bifunctional degrader campaigns.', | |
| compounds: 'Thalidomide, Lenalidomide (Revlimid®), Pomalidomide (Pomalyst®), ' + | |
| 'Avadomide, Iberdomide (CC-92480), Mezigdomide (CC-92480 successor), ' + | |
| 'ARV-110 (Bavdegalutamide — PROTAC)', | |
| pdb: '4CI3 (CRBN–thalidomide) · 4TZ4 (CRBN–lenalidomide) · 6H0F · 7BQX', | |
| }, | |
| 'HIF2_Inhibitor_synthetic': { | |
| fullName: 'Hypoxia-Inducible Factor 2α (HIF-2α / EPAS1)', | |
| gene: 'EPAS1 · UniProt Q99814', | |
| cls: 'bHLH-PAS Transcription Factor', | |
| area: 'Oncology — Renal Cell Carcinoma · VHL Disease', | |
| diseases: 'Clear-cell renal cell carcinoma (ccRCC), VHL syndrome, ' + | |
| 'polycythemia, hemangioblastoma, paraganglioma.', | |
| mechanism: 'HIF-2α heterodimerises with HIF-1β (ARNT) to activate hypoxia-response genes ' + | |
| '(VEGF, EPO, cyclin D1, etc.). A cryptic hydrophobic cavity in the PAS-B ' + | |
| 'domain is allosterically coupled to the HIF-2α/ARNT interface — small molecules ' + | |
| 'binding this pocket disrupt dimerization and downstream transcription. ' + | |
| 'VHL loss (>90% of ccRCC) causes constitutive HIF-2α stabilisation and ' + | |
| 'oncogenic signalling.', | |
| significance: 'HIF-2α was considered undruggable as a transcription factor until PT2977 ' + | |
| '(belzutifan) exploited its cryptic allosteric PAS-B pocket — a landmark in ' + | |
| 'TF drug discovery. It validated allosteric PPI disruption at a transcription ' + | |
| 'factor surface and paved the way for broader bHLH-PAS family targeting.', | |
| compounds: 'Belzutifan (PT2977, Welireg® — FDA approved 2021), PT2385, PT2399', | |
| pdb: '6E3S · 6D0C (HIF-2α PAS-B + inhibitor) · 5TBM · 4GS9 (HIF-2α/ARNT PAS-B dimer)', | |
| }, | |
| 'Novel_NLRP3_Inhibitor_Designs': { | |
| fullName: 'NLRP3 Inflammasome (NOD-, LRR- and Pyrin domain-containing 3)', | |
| gene: 'NLRP3 · UniProt Q8WXI7', | |
| cls: 'NLR Innate Immune Sensor / NACHT ATPase', | |
| area: 'Inflammation · Autoimmunity · Neurodegeneration · Cardiometabolic', | |
| diseases: 'Gout, atherosclerosis, type 2 diabetes, NASH/MAFLD, Alzheimer\'s disease, ' + | |
| 'Parkinson\'s disease, cryopyrin-associated periodic syndromes (CAPS), ' + | |
| 'heart failure.', | |
| mechanism: 'NLRP3 senses danger signals (uric acid crystals, ATP, cholesterol) and ' + | |
| 'oligomerises to form the inflammasome platform, activating caspase-1 to ' + | |
| 'process pro-IL-1β and pro-IL-18 and triggering pyroptotic cell death. ' + | |
| 'The NACHT ATPase domain contains a druggable sulfonamide-binding pocket ' + | |
| 'where inhibitors such as MCC950 lock NLRP3 in an inactive conformation, ' + | |
| 'blocking oligomerisation and inflammasome assembly.', | |
| significance: 'NLRP3 sits at the centre of sterile inflammation driving multiple chronic ' + | |
| 'diseases. Its NACHT domain offers a highly selective allosteric binding site. ' + | |
| 'No approved NLRP3 inhibitor exists yet, but the class represents a major ' + | |
| 'therapeutic opportunity across metabolic, cardiovascular, and ' + | |
| 'neurodegenerative indications. Inzomelid (IZD334) and NXC736 are in Phase II.', | |
| compounds: 'MCC950 (CP-456773, tool compound), Tranilast, OLT1177 (Dapansutrile), ' + | |
| 'Inzomelid (IZD334, Novartis — Phase II), NXC736 (Nodthera — Phase II), ' + | |
| 'NT-0796 (Ventus Therapeutics), CMPD-10', | |
| pdb: '7LYD (NLRP3 NACHT–MCC950) · 7EKO · 8C08 · 6NPY (NLRP3 full-length)', | |
| }, | |
| 'WRNHelicase_Inhibitor': { | |
| fullName: 'Werner Syndrome Helicase (WRN)', | |
| gene: 'WRN · UniProt Q14191', | |
| cls: 'RecQ-Family DNA Helicase / 3′→5′ Exonuclease', | |
| area: 'Oncology — Synthetic Lethality in MSI-H Tumors', | |
| diseases: 'Microsatellite instability-high (MSI-H) cancers: colorectal (~15%), ' + | |
| 'endometrial, gastric, ovarian; Werner syndrome (germline WRN loss).', | |
| mechanism: 'WRN resolves unusual DNA secondary structures (G-quadruplexes, Holliday ' + | |
| 'junctions) at fragile expanded microsatellite loci. MSI-H tumors — which ' + | |
| 'lack DNA mismatch repair (MMR) — accumulate toxic TA-dinucleotide repeats ' + | |
| 'that require WRN helicase for replication fork restart. Small molecules ' + | |
| 'inhibiting WRN\'s ATPase/helicase domain selectively kill MSI-H cancer cells ' + | |
| 'while sparing MMR-proficient normal cells (synthetic lethality).', | |
| significance: 'WRN represents the first robust synthetic-lethal dependency specific to a ' + | |
| 'defined molecular biomarker (MMR status), applicable to ~15% of all ' + | |
| 'colorectal cancers — a large, underserved patient population. Multiple ' + | |
| 'programmes have entered Phase I/II (HRO761, VVD-133214), validating the ' + | |
| 'ATPase domain as an oncology drug target.', | |
| compounds: 'HRO761 (Novartis — Phase I/II), VVD-133214 (Vividion/Roche — Phase I), ' + | |
| 'ZW-111 (ZENAS BioPharma)', | |
| pdb: '3AAF (WRN helicase domain) · 6YHR · 3AAD (WRN exonuclease)', | |
| }, | |
| 'CyclinA_RXL_PPI_BLOCKER': { | |
| fullName: 'Cyclin A2 / CDK2 Complex', | |
| gene: 'CCNA2 / CDK2 · UniProt P20248 / P24941', | |
| cls: 'Cyclin-Dependent Kinase Complex (Cell Cycle Regulator)', | |
| area: 'Oncology — Cell Cycle · DNA Replication', | |
| diseases: 'Solid tumors with Cyclin A overexpression; breast, ovarian, and ' + | |
| 'other CDK2-driven cancers.', | |
| mechanism: 'Cyclin A recruits substrates bearing RxL (Cy) hydrophobic motifs via a ' + | |
| 'conserved hydrophobic patch on its cyclin box — distinct from the CDK2 ' + | |
| 'ATP-binding site. PPI inhibitors that block substrate docking at this ' + | |
| 'recruitment site prevent phosphorylation of key S-phase substrates (E2F1, ' + | |
| 'p27, CDC25A) without directly competing with ATP, enabling selectivity over ' + | |
| 'other CDKs. Stapled peptides and small molecules mimic the RxL motif to ' + | |
| 'competitively displace endogenous substrates.', | |
| significance: 'Targeting Cyclin A\'s substrate-recruitment surface provides an alternative ' + | |
| 'to ATP-competitive CDK inhibitors, offering improved isoform selectivity and ' + | |
| 'the potential to overcome resistance mechanisms. Demonstrates feasibility of ' + | |
| 'PPI inhibition at a shallow protein surface using fragment-derived and ' + | |
| 'stapled-peptide approaches — a paradigm for cyclin/CDK targeting.', | |
| compounds: 'ATSP-7041 analogs (stapled RxL peptides), fragment hits (NMR-based), ' + | |
| 'peptidomimetic RxL blockers (academic tool compounds)', | |
| pdb: '1OKV · 3FQL · 4II5 (Cyclin A–substrate RxL peptide co-crystal)', | |
| }, | |
| 'KRAS_CyclophilinA_MolecularGlue': { | |
| fullName: 'KRAS GTPase · Cyclophilin A (CypA) — Molecular Glue Complex', | |
| gene: 'KRAS / PPIA · UniProt P01116 / P62937', | |
| cls: 'Oncogenic GTPase · Peptidyl-Prolyl Isomerase (PPIase)', | |
| area: 'Oncology — RAS-Driven Cancers', | |
| diseases: 'KRAS-mutant pancreatic cancer (~90%), colorectal cancer (~40%), ' + | |
| 'lung adenocarcinoma (~30%); among the most lethal solid tumors.', | |
| mechanism: 'Molecular glues induce or stabilise a neo-protein complex between KRAS ' + | |
| 'and CyclophilinA (CypA), sequestering KRAS from its effectors (RAF, PI3K, ' + | |
| 'RalGDS) and suppressing oncogenic signalling. CypA\'s PPIase activity and ' + | |
| 'KRAS\'s hypervariable region are exploited at the glue interface. This ' + | |
| 'mechanism is distinct from covalent KRAS^G12C inhibitors and may address ' + | |
| 'KRAS mutants beyond G12C (e.g. G12D, G12V).', | |
| significance: 'KRAS was the paradigmatic "undruggable" oncogene for ~40 years. ' + | |
| 'Covalent G12C inhibitors (sotorasib, adagrasib) broke this barrier, but ' + | |
| 'most KRAS mutations remain unaddressed. Molecular glues that redirect CypA — ' + | |
| 'an abundant cellular chaperone — into an anti-KRAS agent represent a ' + | |
| 'creative neo-substrate strategy with potential for pan-KRAS applicability ' + | |
| 'and PK advantages over direct binders.', | |
| compounds: 'Sotorasib (AMG-510, Lumakras® — KRAS^G12C, FDA 2021), ' + | |
| 'Adagrasib (MRTX-849, Krazati® — KRAS^G12C, FDA 2022), ' + | |
| 'RMC-6236 (pan-RAS-GTP, Revolution Medicines — Phase II), ' + | |
| 'Compound series from this dataset (CypA molecular glue mechanism)', | |
| pdb: '6OIM (KRAS^G12C–AMG-510) · 4DSO (CypA apo) · 5VQ2 (KRAS^G12C GDP)', | |
| }, | |
| 'novel_myosin_modulator_designs': { | |
| fullName: 'Cardiac / Skeletal Myosin II Heavy Chain', | |
| gene: 'MYH7 (cardiac β-myosin) / MYH2 (skeletal IIa) · UniProt P12883 / Q9UKX2', | |
| cls: 'Class II Myosin Motor ATPase', | |
| area: 'Cardiovascular · Neuromuscular Diseases', | |
| diseases: 'Hypertrophic cardiomyopathy (HCM), dilated cardiomyopathy (DCM), ' + | |
| 'heart failure with preserved ejection fraction (HFpEF), ' + | |
| 'spinal muscular atrophy (SMA), sarcopenia.', | |
| mechanism: 'Myosin converts ATP hydrolysis into mechanical force driving muscle ' + | |
| 'contraction. Small molecules bind allosteric pockets in the motor domain: ' + | |
| 'activators (e.g. omecamtiv mecarbil) extend the working stroke and increase ' + | |
| 'force production without raising intracellular Ca²⁺; inhibitors ' + | |
| '(e.g. mavacamten) stabilise the super-relaxed (SRX) off-state, reducing ' + | |
| 'basal ATPase activity and hypercontractility in HCM. The actin-binding ' + | |
| 'cleft and converter domain are key druggable allosteric sites.', | |
| significance: 'Myosin modulators enable direct tuning of cardiac output without affecting ' + | |
| 'calcium handling — a mechanism orthogonal to all existing heart failure ' + | |
| 'therapies. Mavacamten\'s 2022 FDA approval validated the target and opened ' + | |
| 'an entirely new therapeutic class for HCM. Skeletal myosin activators are ' + | |
| 'being explored for SMA and HFpEF, representing a paradigm shift in ' + | |
| 'musculoskeletal pharmacology.', | |
| compounds: 'Mavacamten (MYK-461, Camzyos® — HCM, FDA 2022), ' + | |
| 'Omecamtiv mecarbil (cardiac activator, Phase III), ' + | |
| 'Aficamten (CK-3773274, Cytokinetics — Phase III HCM), ' + | |
| 'Reldesemtiv (CK-2127107 — skeletal activator, SMA/HFpEF)', | |
| pdb: '5N6A (β-myosin motor domain) · 7Y5G (mavacamten-bound β-myosin) · 2MYS', | |
| }, | |
| 'ACE2-B0AT1': { | |
| fullName: 'ACE2 – B0AT1 Complex (SARS-CoV-2 Entry Receptor)', | |
| gene: 'ACE2 / SLC6A19 · UniProt Q9BYF1 / Q695T7', | |
| cls: 'Carboxypeptidase / Neutral Amino Acid Transporter Heterodimer', | |
| area: 'Antiviral (COVID-19) · Cardiovascular · Renal · Metabolic', | |
| diseases: 'SARS-CoV-2 infection (COVID-19), pulmonary arterial hypertension, ' + | |
| 'heart failure, acute kidney injury, inflammatory bowel disease, Hartnup disease.', | |
| mechanism: 'ACE2 is the host receptor for SARS-CoV-2 Spike RBD — the first step of ' + | |
| 'viral entry. It forms a homodimeric complex with the neutral amino acid ' + | |
| 'transporter B0AT1 (SLC6A19) in intestinal/renal epithelium, influencing ' + | |
| 'Spike binding stoichiometry. ACE2 also functions as a carboxypeptidase ' + | |
| 'cleaving angiotensin II (vasoconstrictive) to Ang-(1-7) (vasodilatory, ' + | |
| 'cardioprotective). Blocking the ACE2–Spike interface is a key antiviral ' + | |
| 'strategy; ACE2 activation reduces hypertension and cardiac remodelling.', | |
| significance: 'The cryo-EM structure of the ACE2–B0AT1 dimer (PDB 6M17) was pivotal for ' + | |
| 'understanding SARS-CoV-2 tropism and designing entry inhibitors. ACE2 sits ' + | |
| 'at the intersection of virology, cardiovascular pharmacology, and renal ' + | |
| 'physiology — one of the most multifaceted drug targets in modern medicine. ' + | |
| 'Its dual role as a viral receptor and cardioprotective enzyme complicates ' + | |
| 'therapeutic strategies and highlights context-dependent drug design.', | |
| compounds: 'APN01 (recombinant soluble ACE2, Phase II antiviral), ' + | |
| 'SSAA09E2 (ACE2–Spike interface blocker), DX600 (ACE2 enzyme inhibitor), ' + | |
| 'Diminazene aceturate (ACE2 activator, experimental), ' + | |
| 'Telmisartan / ACE inhibitors (indirect ACE2 regulation)', | |
| pdb: '6M17 (ACE2–B0AT1 cryo-EM dimer) · 6M0J (ACE2–Spike RBD) · ' + | |
| '1R42 (ACE2 apo) · 7KMS (Spike-ACE2 omicron)', | |
| }, | |
| }; | |
| // ── Target panel state & functions ─────────────────────────── | |
| let targetPanelVisible = false; | |
| /** | |
| * @brief Toggle the target information side-panel visibility. | |
| * | |
| * Flips the `targetPanelVisible` state, updates the toggle button's | |
| * active class, and shows or hides the target panel. When the panel | |
| * becomes visible and a dataset is currently selected, the panel | |
| * contents are refreshed via {@link updateTargetPanel}. | |
| * | |
| * @returns {void} | |
| */ | |
| function toggleTargetPanel() { | |
| targetPanelVisible = !targetPanelVisible; | |
| document.getElementById('btn-target').classList.toggle('active', targetPanelVisible); | |
| document.getElementById('target-panel').classList.toggle('visible', targetPanelVisible); | |
| if (targetPanelVisible && currentDataset) updateTargetPanel(currentDataset); | |
| } | |
| /** | |
| * @brief Populate the target information panel with data for a given dataset. | |
| * | |
| * Looks up the dataset key in the {@link TARGET_INFO} dictionary and fills | |
| * the DOM elements for full name, gene, class, therapeutic area, diseases, | |
| * mechanism, significance, known compounds, and related PDB entries. | |
| * If no entry exists for the key, the function returns immediately. | |
| * | |
| * @param {string} datasetKey - Key into the TARGET_INFO lookup object | |
| * (e.g. 'EGFR', 'ACE2'). | |
| * @returns {void} | |
| */ | |
| function updateTargetPanel(datasetKey) { | |
| const info = TARGET_INFO[datasetKey]; | |
| if (!info) return; | |
| document.getElementById('tp-title').textContent = info.fullName; | |
| document.getElementById('tp-gene').textContent = info.gene; | |
| document.getElementById('tp-class').textContent = info.cls; | |
| document.getElementById('tp-area').textContent = info.area; | |
| document.getElementById('tp-diseases').textContent = info.diseases; | |
| document.getElementById('tp-mechanism').textContent = info.mechanism; | |
| document.getElementById('tp-significance').textContent = info.significance; | |
| document.getElementById('tp-compounds').textContent = info.compounds; | |
| document.getElementById('tp-pdb').textContent = info.pdb; | |
| } | |
| // ── State ──────────────────────────────────────────────────── | |
| let viewer = null; | |
| let currentDataset = null; | |
| let currentFileIdx = -1; | |
| let currentRepr = 'ball-stick'; | |
| let filteredFiles = []; | |
| let infoVisible = false; | |
| /** | |
| * @brief Initialise the 3Dmol.js molecular viewer. | |
| * | |
| * Creates a new 3Dmol viewer instance inside the `#mol-viewer` DOM element | |
| * with a white background and antialiasing enabled. The resulting viewer | |
| * object is stored in the module-level {@link viewer} variable. | |
| * | |
| * @returns {void} | |
| */ | |
| function initViewer() { | |
| const el = document.getElementById('mol-viewer'); | |
| viewer = $3Dmol.createViewer(el, { | |
| backgroundColor: '#ffffff', | |
| antialias: true, | |
| }); | |
| } | |
| /** | |
| * @brief Build the dataset tab buttons in the sidebar. | |
| * | |
| * Iterates over all entries in the global {@link DATASETS} object and | |
| * creates a `<button>` element for each one inside the `#dataset-tabs` | |
| * container. Virtual datasets (e.g. user-imported structures) are | |
| * prefixed with a download icon. Each button's border colour is | |
| * derived from the dataset's configured colour, and clicking a tab | |
| * calls {@link selectDataset} with the corresponding key. | |
| * | |
| * @returns {void} | |
| */ | |
| function buildTabs() { | |
| const container = document.getElementById('dataset-tabs'); | |
| container.innerHTML = ''; | |
| Object.entries(DATASETS).forEach(([key, ds]) => { | |
| const btn = document.createElement('button'); | |
| btn.className = 'ds-tab'; | |
| btn.textContent = ds.isVirtual ? '📥 ' + ds.label : ds.label; | |
| btn.style.borderColor = ds.color + '55'; | |
| btn.onclick = () => selectDataset(key); | |
| btn.dataset.key = key; | |
| container.appendChild(btn); | |
| }); | |
| } | |
| /** | |
| * @brief Select and activate a dataset by its key. | |
| * | |
| * Sets `currentDataset` to the given key, resets `currentFileIdx`, | |
| * toggles the `.active` CSS class on the corresponding tab button, | |
| * clears the search input, and re-renders the file list via | |
| * {@link renderFileList}. If the target panel is currently visible, | |
| * it is also refreshed with the new dataset's information. | |
| * | |
| * @param {string} key - Dataset key corresponding to a property in | |
| * the global {@link DATASETS} object. | |
| * @returns {void} | |
| */ | |
| function selectDataset(key) { | |
| currentDataset = key; | |
| currentFileIdx = -1; | |
| // Update tab styles | |
| document.querySelectorAll('.ds-tab').forEach(t => { | |
| t.classList.toggle('active', t.dataset.key === key); | |
| }); | |
| document.getElementById('search-input').value = ''; | |
| renderFileList(); | |
| // Refresh target panel if it's open | |
| if (targetPanelVisible) updateTargetPanel(key); | |
| } | |
| /** | |
| * @brief Render the filtered file list in the sidebar. | |
| * | |
| * Reads the current search query from the `#search-input` element, filters | |
| * the active dataset's file array by case-insensitive substring match, and | |
| * populates the `#file-list` container with clickable `.file-item` elements. | |
| * Virtual-dataset entries display a source icon (RCSB or local). | |
| * Updates the `#sidebar-footer` text with a count of displayed vs. total | |
| * structures. Sets the module-level {@link filteredFiles} array. | |
| * | |
| * @returns {void} | |
| */ | |
| function renderFileList() { | |
| const query = document.getElementById('search-input').value.toLowerCase(); | |
| const files = currentDataset ? DATASETS[currentDataset].files : []; | |
| filteredFiles = files.filter(f => f.toLowerCase().includes(query)); | |
| const container = document.getElementById('file-list'); | |
| container.innerHTML = ''; | |
| const ds = currentDataset ? DATASETS[currentDataset] : null; | |
| filteredFiles.forEach((file, i) => { | |
| const realIdx = ds ? ds.files.indexOf(file) : i; | |
| const item = document.createElement('div'); | |
| item.className = 'file-item' + (realIdx === currentFileIdx ? ' active' : ''); | |
| if (ds && ds.isVirtual) { | |
| // Virtual dataset: show source icon (🏛️ RCSB, 📂 local) | |
| const entry = ds.entries[realIdx]; | |
| const icon = entry && entry.source === 'rcsb' ? '🏛️' : '📂'; | |
| item.innerHTML = `<span class="file-idx">${String(i + 1).padStart(3, '0')}</span>${icon} ${file}`; | |
| } else { | |
| item.innerHTML = `<span class="file-idx">${String(i + 1).padStart(3, '0')}</span>${file.replace('_cmpx.pdb', '')}`; | |
| } | |
| item.title = file; | |
| item.onclick = () => loadFile(currentDataset, realIdx); | |
| container.appendChild(item); | |
| }); | |
| const footer = document.getElementById('sidebar-footer'); | |
| footer.textContent = currentDataset | |
| ? `${filteredFiles.length} of ${DATASETS[currentDataset].files.length} structures` | |
| : 'Select a dataset above'; | |
| } | |
| /** | |
| * @brief Re-filter and re-render the file list. | |
| * | |
| * Convenience wrapper that delegates directly to {@link renderFileList}. | |
| * Intended as an event handler for the search input field. | |
| * | |
| * @returns {void} | |
| */ | |
| function filterFiles() { renderFileList(); } | |
| /** | |
| * @brief Load a PDB structure by dataset key and file index. | |
| * | |
| * Handles two loading paths: | |
| * 1. **Virtual datasets** (RCSB / Local) -- PDB text is already stored | |
| * in memory inside `ds.entries[fileIdx].pdbText`. | |
| * 2. **HuggingFace datasets** -- PDB is fetched from the remote | |
| * HuggingFace repository URL constructed by {@link HF_RESOLVE}. | |
| * | |
| * In both cases the file list is re-rendered to reflect the active | |
| * selection, a loading indicator is shown, and the fetched/stored PDB | |
| * text is handed to {@link loadPDBIntoViewer}. Errors are surfaced | |
| * via {@link showError}. | |
| * | |
| * @async | |
| * @param {string} dataset - Key into the global {@link DATASETS} object. | |
| * @param {number} fileIdx - Zero-based index into the dataset's `files` | |
| * (or `entries`) array. | |
| * @returns {Promise<void>} | |
| */ | |
| async function loadFile(dataset, fileIdx) { | |
| const ds = DATASETS[dataset]; | |
| if (!ds) return; | |
| // ── Virtual dataset (RCSB / Local): PDB text is already in memory ── | |
| if (ds.isVirtual) { | |
| if (fileIdx < 0 || fileIdx >= ds.entries.length) return; | |
| currentDataset = dataset; | |
| currentFileIdx = fileIdx; | |
| renderFileList(); | |
| const entry = ds.entries[fileIdx]; | |
| showLoading(true, entry.label); | |
| hideError(); | |
| try { | |
| loadPDBIntoViewer(entry.pdbText, entry.label, dataset, fileIdx); | |
| } catch (err) { | |
| showError(`Failed to load: ${entry.label}\n${err.message}`); | |
| console.error(err); | |
| } finally { | |
| showLoading(false); | |
| } | |
| return; | |
| } | |
| // ── HuggingFace dataset: fetch PDB from remote ── | |
| if (fileIdx < 0 || fileIdx >= ds.files.length) return; | |
| currentDataset = dataset; | |
| currentFileIdx = fileIdx; | |
| renderFileList(); | |
| const file = ds.files[fileIdx]; | |
| const url = HF_RESOLVE(dataset, file); | |
| showLoading(true, file); | |
| hideError(); | |
| try { | |
| const response = await fetch(url); | |
| if (!response.ok) throw new Error(`HTTP ${response.status}`); | |
| const pdbData = await response.text(); | |
| loadPDBIntoViewer(pdbData, file.replace('_cmpx.pdb', ''), dataset, fileIdx); | |
| } catch (err) { | |
| showError(`Failed to load: ${file}\n${err.message}`); | |
| console.error(err); | |
| } finally { | |
| showLoading(false); | |
| } | |
| } | |
| /** | |
| * @brief Load raw PDB text into the 3Dmol viewer and refresh all UI panels. | |
| * | |
| * Shared core loading routine used by both remote-fetch and in-memory paths. | |
| * Resets all analysis panels (affinity, strain, electrostatics, minimisation), | |
| * clears the viewer, adds the new model, applies representation, and updates | |
| * every dependent panel and diagram. | |
| * | |
| * @param {string} pdbData - Complete PDB-format text to display. | |
| * @param {string} label - Human-readable label for the title bar. | |
| * @param {string} dataset - Dataset key for metadata lookup. | |
| * @param {number} fileIdx - Index of the file within the dataset. | |
| * @returns {void} | |
| */ | |
| function loadPDBIntoViewer(pdbData, label, dataset, fileIdx) { | |
| currentPDBData = pdbData; | |
| // Reset affinity + strain panels BEFORE applyRepr() populates lastInteractions | |
| lastInteractions = []; | |
| document.getElementById('aff-dg').textContent = '—'; | |
| document.getElementById('aff-pki').textContent = '—'; | |
| document.getElementById('aff-breakdown').innerHTML = ''; | |
| document.getElementById('aff-chembl-results').innerHTML = | |
| '<span style="color:#aaa;font-size:0.67rem">—</span>'; | |
| document.getElementById('strain-total').textContent = '—'; | |
| document.getElementById('strain-total').style.color = ''; | |
| document.getElementById('strain-meta').textContent = '—'; | |
| document.getElementById('strain-interp').textContent = ''; | |
| viewer.clear(); | |
| viewer.addModel(pdbData, 'pdb'); | |
| strainHighlightShapes = []; // ← viewer.clear() wiped old shapes; reset refs BEFORE update | |
| strainHighlightLabel = null; | |
| elecShapes = []; // ← viewer.clear() also wiped elec lines; reset refs | |
| elecLabels = []; | |
| lastConformerSmiles = ''; // ← invalidate cache so conformer refetches for new ligand | |
| // Reset minimisation state for new structure | |
| minimizedPDBData = null; | |
| originalPDBData = null; | |
| showingMinimized = false; | |
| viewMode = null; | |
| document.getElementById('minimize-energy').textContent = '—'; | |
| document.getElementById('minimize-energy').style.color = ''; | |
| document.getElementById('minimize-meta').textContent = '—'; | |
| document.getElementById('minimize-rmsd').textContent = ''; | |
| document.getElementById('minimize-status').textContent = '—'; | |
| document.querySelectorAll('.view-btn').forEach(b => { b.disabled = true; b.classList.remove('active'); }); | |
| applyRepr(currentRepr); // ← calls autoDrawHBondsAndPiPi → sets lastInteractions correctly | |
| updateStrainPanel(); // ← compute + highlight strain; refs stored in strainHighlightShapes | |
| updateElecPanel(); // ← refresh electrostatic repulsion panel for new ligand/structure | |
| viewer.zoomTo(); | |
| viewer.render(); | |
| document.getElementById('mol-title').textContent = | |
| `${DATASETS[dataset].label} · ${label}`; | |
| updateInfo(dataset, DATASETS[dataset].isVirtual ? label : DATASETS[dataset].files[fileIdx], fileIdx); | |
| updateNavButtons(); | |
| document.getElementById('empty-state').style.display = 'none'; | |
| // Auto-show info panel (includes 2D ligand section) and render 2D | |
| if (!infoVisible) { | |
| infoVisible = true; | |
| document.getElementById('info-panel').classList.add('visible'); | |
| } | |
| update2DLigand(); | |
| updateConformerPanel(); // ← refresh 3D conformer if panel is open | |
| } | |
| // ═══════════════════════════════════════════════════════════════ | |
| // RCSB FETCH, LOCAL FILE UPLOAD, SMILES AUTO-INJECTION | |
| // ═══════════════════════════════════════════════════════════════ | |
| /** | |
| * @brief Detect the primary drug-like ligand residue name from HETATM records. | |
| * | |
| * Scans all HETATM lines in the PDB text, counts atoms per residue name, | |
| * and returns the 3-letter code with the highest atom count. Standard | |
| * amino acids (PROTEIN_RESN) and crystallographic artifacts (CRYST_ARTIFACTS) | |
| * are excluded from consideration. | |
| * | |
| * @param {string} pdbText - Raw PDB-format text to scan. | |
| * @returns {string|null} Three-letter residue code of the primary ligand, | |
| * or null if no drug-like HETATM residues are found. | |
| */ | |
| function detectPrimaryLigand(pdbText) { | |
| const resnCounts = {}; | |
| for (const line of pdbText.split('\n')) { | |
| if (!line.startsWith('HETATM')) continue; | |
| const resn = line.substring(17, 20).trim(); | |
| if (!resn || PROTEIN_RESN.has(resn) || CRYST_ARTIFACTS.has(resn)) continue; | |
| resnCounts[resn] = (resnCounts[resn] || 0) + 1; | |
| } | |
| let best = null, bestCount = 0; | |
| for (const [resn, count] of Object.entries(resnCounts)) { | |
| if (count > bestCount) { bestCount = count; best = resn; } | |
| } | |
| return best; | |
| } | |
| /** | |
| * @brief Fetch canonical SMILES from the RCSB Chemical Component Dictionary. | |
| * | |
| * Queries the RCSB REST API for the given ligand residue name (e.g. 'DB8') | |
| * and extracts the canonical SMILES string. Tries the primary location | |
| * (`rcsb_chem_comp_descriptor.smiles`) first, then falls back to the | |
| * `pdbx_chem_comp_descriptor` array looking for SMILES_CANONICAL or SMILES. | |
| * | |
| * @async | |
| * @param {string} ligResn - Three-letter chemical component identifier | |
| * (e.g. 'DB8' for Bosutinib). | |
| * @returns {Promise<string|null>} Canonical SMILES string, or null if the | |
| * component is not found or has no SMILES. | |
| */ | |
| async function fetchSMILESFromRCSB(ligResn) { | |
| try { | |
| const url = `https://data.rcsb.org/rest/v1/core/chemcomp/${ligResn}`; | |
| const resp = await fetch(url); | |
| if (!resp.ok) return null; | |
| const data = await resp.json(); | |
| // Primary location: rcsb_chem_comp_descriptor.smiles | |
| const rcsb = data.rcsb_chem_comp_descriptor; | |
| if (rcsb && rcsb.smiles) return rcsb.smiles; | |
| // Fallback: pdbx_chem_comp_descriptor array | |
| const pdbx = data.pdbx_chem_comp_descriptor || []; | |
| for (const d of pdbx) { | |
| if (d.type === 'SMILES_CANONICAL' && d.descriptor) return d.descriptor; | |
| } | |
| for (const d of pdbx) { | |
| if (d.type === 'SMILES' && d.descriptor) return d.descriptor; | |
| } | |
| return null; | |
| } catch (e) { console.warn('RCSB chemcomp error:', e); return null; } | |
| } | |
| /** | |
| * @brief Inject a REMARK SMILES line into PDB text. | |
| * | |
| * Inserts a `REMARK SMILES <smiles>` record immediately before the first | |
| * ATOM, HETATM, or MODEL line in the PDB text. This allows downstream | |
| * routines (e.g. 2D ligand rendering) to retrieve the ligand's SMILES | |
| * without a separate network request. | |
| * | |
| * @param {string} pdbText - Original PDB-format text. | |
| * @param {string} smiles - SMILES string to embed. | |
| * @returns {string} Modified PDB text with the injected REMARK line. | |
| */ | |
| function injectSmilesRemark(pdbText, smiles) { | |
| const lines = pdbText.split('\n'); | |
| let insertIdx = 0; | |
| for (let i = 0; i < lines.length; i++) { | |
| if (lines[i].startsWith('ATOM') || lines[i].startsWith('HETATM') || lines[i].startsWith('MODEL')) { | |
| insertIdx = i; | |
| break; | |
| } | |
| insertIdx = i + 1; | |
| } | |
| lines.splice(insertIdx, 0, `REMARK SMILES ${smiles}`); | |
| return lines.join('\n'); | |
| } | |
| /** | |
| * @brief Extract a single monomer-ligand pair from a multi-chain PDB. | |
| * | |
| * RCSB crystal structures often contain two or more copies of the asymmetric | |
| * unit (e.g. 3UE4 has chains A and B, each with its own Bosutinib). Keeping | |
| * all copies doubles every calculation (interactions, electrostatics, strain, | |
| * affinity). | |
| * | |
| * Strategy: keep only the first protein chain (alphabetically, usually 'A') | |
| * and any HETATM records on that same chain or with a blank chain field. | |
| * All non-coordinate metadata lines (REMARK, HEADER, TITLE, CONECT, TER, | |
| * END, etc.) are preserved unconditionally. | |
| * | |
| * If the PDB contains zero or one chain, the text is returned unchanged. | |
| * | |
| * @param {string} pdbText - Raw PDB-format text, potentially multi-chain. | |
| * @returns {string} PDB text filtered to a single chain. | |
| */ | |
| function extractSingleChain(pdbText) { | |
| const lines = pdbText.split('\n'); | |
| // Step 1: identify all protein chains (from ATOM records) | |
| const chainSet = new Set(); | |
| for (const line of lines) { | |
| if (line.startsWith('ATOM')) { | |
| const ch = line.substring(21, 22).trim(); | |
| if (ch) chainSet.add(ch); | |
| } | |
| } | |
| // Only 0–1 chains → nothing to filter | |
| if (chainSet.size <= 1) return pdbText; | |
| // Step 2: pick the first chain alphabetically (almost always 'A') | |
| const sortedChains = [...chainSet].sort(); | |
| const keepChain = sortedChains[0]; | |
| // Step 3: filter lines | |
| const kept = []; | |
| for (const line of lines) { | |
| const rec = line.substring(0, 6).trimEnd(); | |
| if (rec === 'ATOM' || rec === 'HETATM') { | |
| const ch = line.substring(21, 22).trim(); | |
| // Keep if on the chosen chain, or blank chain (some HETATM have no chain) | |
| if (ch === keepChain || ch === '') { | |
| kept.push(line); | |
| } | |
| } else { | |
| // Preserve all non-coordinate lines (REMARK, HEADER, TER, END, CONECT, etc.) | |
| kept.push(line); | |
| } | |
| } | |
| console.log(`[extractSingleChain] Kept chain ${keepChain} of ${sortedChains.length} chains (${sortedChains.join(', ')})`); | |
| return kept.join('\n'); | |
| } | |
| /** | |
| * @brief Auto-detect the primary ligand and inject its SMILES into PDB text. | |
| * | |
| * If the PDB text already contains a REMARK SMILES line, the text is | |
| * returned unchanged. Otherwise the primary ligand is detected via | |
| * detectPrimaryLigand, its SMILES fetched from the RCSB Chemical Component | |
| * Dictionary, and injected as a REMARK record. | |
| * | |
| * @async | |
| * @param {string} pdbText - Raw PDB-format text. | |
| * @returns {Promise<string>} PDB text, potentially augmented with a | |
| * REMARK SMILES line. | |
| */ | |
| async function injectRCSBSmiles(pdbText) { | |
| if (getSMILESFromPDB(pdbText)) return pdbText; | |
| const ligResn = detectPrimaryLigand(pdbText); | |
| if (!ligResn) return pdbText; | |
| const smiles = await fetchSMILESFromRCSB(ligResn); | |
| if (!smiles) return pdbText; | |
| return injectSmilesRemark(pdbText, smiles); | |
| } | |
| /** | |
| * @brief Add a structure to the virtual RCSB/Local dataset and load it. | |
| * | |
| * Appends a new entry (or updates an existing one with the same label) in | |
| * the virtual dataset identified by RCSB_DATASET_KEY. After inserting, | |
| * the dataset is selected and the new structure is loaded into the viewer | |
| * via selectDataset and loadFile. | |
| * | |
| * @param {string} label - Display label for the structure (e.g. PDB ID | |
| * or filename without extension). | |
| * @param {string} pdbText - Complete PDB-format text of the structure. | |
| * @param {string} source - Origin identifier: 'rcsb' for RCSB downloads | |
| * or 'local' for user-uploaded files. | |
| * @returns {void} | |
| */ | |
| function addToRCSBDataset(label, pdbText, source) { | |
| const ds = DATASETS[RCSB_DATASET_KEY]; | |
| // Avoid duplicates: update existing entry | |
| const existingIdx = ds.files.indexOf(label); | |
| if (existingIdx >= 0) { | |
| ds.entries[existingIdx] = { pdbId: label, pdbText, label, source }; | |
| selectDataset(RCSB_DATASET_KEY); | |
| loadFile(RCSB_DATASET_KEY, existingIdx); | |
| return; | |
| } | |
| ds.files.push(label); | |
| ds.entries.push({ pdbId: label, pdbText, label, source }); | |
| selectDataset(RCSB_DATASET_KEY); | |
| loadFile(RCSB_DATASET_KEY, ds.files.length - 1); | |
| } | |
| /** | |
| * @brief Fetch a PDB structure from RCSB by its 4-character PDB ID. | |
| * | |
| * Reads the PDB ID from the #rcsb-pdb-input field, validates it, downloads | |
| * the PDB from files.rcsb.org, processes it through extractSingleChain and | |
| * injectRCSBSmiles, then adds it to the virtual dataset. Progress and errors | |
| * are displayed in the #rcsb-fetch-status element. | |
| * | |
| * @async | |
| * @returns {Promise<void>} | |
| */ | |
| async function fetchRCSBPDB() { | |
| const input = document.getElementById('rcsb-pdb-input'); | |
| const status = document.getElementById('rcsb-fetch-status'); | |
| const pdbId = input.value.trim().toUpperCase(); | |
| if (!pdbId || pdbId.length !== 4) { | |
| status.textContent = 'Enter a 4-character PDB ID'; | |
| status.style.color = '#c0392b'; | |
| return; | |
| } | |
| status.textContent = `Fetching ${pdbId} from RCSB…`; | |
| status.style.color = '#878787'; | |
| try { | |
| const pdbUrl = `https://files.rcsb.org/download/${pdbId}.pdb`; | |
| const resp = await fetch(pdbUrl); | |
| if (!resp.ok) throw new Error(`PDB ${pdbId} not found (HTTP ${resp.status})`); | |
| let pdbText = await resp.text(); | |
| // Extract single monomer:ligand pair (RCSB structures often have 2+ copies) | |
| status.textContent = 'Extracting monomer…'; | |
| pdbText = extractSingleChain(pdbText); | |
| // Auto-inject SMILES for the primary ligand | |
| status.textContent = 'Detecting ligand SMILES…'; | |
| pdbText = await injectRCSBSmiles(pdbText); | |
| addToRCSBDataset(pdbId, pdbText, 'rcsb'); | |
| status.textContent = `✓ Loaded ${pdbId}`; | |
| status.style.color = '#27ae60'; | |
| input.value = ''; | |
| } catch (err) { | |
| status.textContent = err.message; | |
| status.style.color = '#c0392b'; | |
| console.error('RCSB fetch error:', err); | |
| } | |
| } | |
| // Handle local PDB file selection via file picker. | |
| function handleLocalFile(event) { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| const status = document.getElementById('rcsb-fetch-status'); | |
| status.textContent = `Reading ${file.name}…`; | |
| status.style.color = '#878787'; | |
| const reader = new FileReader(); | |
| reader.onload = async function(e) { | |
| let pdbText = e.target.result; | |
| const label = file.name.replace(/\.pdb$/i, '').replace(/\.ent$/i, ''); | |
| // Extract single monomer:ligand pair if multi-chain | |
| pdbText = extractSingleChain(pdbText); | |
| // Auto-inject SMILES if not present | |
| if (!getSMILESFromPDB(pdbText)) { | |
| status.textContent = 'Detecting ligand SMILES…'; | |
| pdbText = await injectRCSBSmiles(pdbText); | |
| } | |
| addToRCSBDataset(label, pdbText, 'local'); | |
| status.textContent = `✓ Loaded ${label}`; | |
| status.style.color = '#27ae60'; | |
| }; | |
| reader.readAsText(file); | |
| event.target.value = ''; // reset so same file can be re-selected | |
| } | |
| // Drag-and-drop initialisation for #viewer-container. | |
| function initDragDrop() { | |
| const container = document.getElementById('viewer-container'); | |
| const overlay = document.getElementById('drop-overlay'); | |
| if (!container || !overlay) return; | |
| container.addEventListener('dragenter', (e) => { | |
| e.preventDefault(); | |
| overlay.classList.remove('hidden'); | |
| }); | |
| container.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| e.dataTransfer.dropEffect = 'copy'; | |
| }); | |
| container.addEventListener('dragleave', (e) => { | |
| if (!container.contains(e.relatedTarget)) { | |
| overlay.classList.add('hidden'); | |
| } | |
| }); | |
| container.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| overlay.classList.add('hidden'); | |
| const files = [...e.dataTransfer.files].filter(f => | |
| f.name.endsWith('.pdb') || f.name.endsWith('.ent') | |
| ); | |
| if (!files.length) { showError('Please drop a .pdb file'); return; } | |
| const file = files[0]; | |
| const status = document.getElementById('rcsb-fetch-status'); | |
| status.textContent = `Reading ${file.name}…`; | |
| status.style.color = '#878787'; | |
| const reader = new FileReader(); | |
| reader.onload = async function(ev) { | |
| let pdbText = ev.target.result; | |
| const label = file.name.replace(/\.pdb$/i, '').replace(/\.ent$/i, ''); | |
| pdbText = extractSingleChain(pdbText); | |
| if (!getSMILESFromPDB(pdbText)) { | |
| status.textContent = 'Detecting ligand SMILES…'; | |
| pdbText = await injectRCSBSmiles(pdbText); | |
| } | |
| addToRCSBDataset(label, pdbText, 'local'); | |
| status.textContent = `✓ Loaded ${label}`; | |
| status.style.color = '#27ae60'; | |
| }; | |
| reader.readAsText(file); | |
| }); | |
| } | |
| // ── Standard amino acid residue names (to exclude from ligand selection) ── | |
| const PROTEIN_RESN = new Set([ | |
| 'ALA','ARG','ASN','ASP','CYS','GLN','GLU','GLY','HIS','ILE', | |
| 'LEU','LYS','MET','PHE','PRO','SER','THR','TRP','TYR','VAL', | |
| 'HID','HIE','HIP','CYX','ACE','NME','HOH','WAT','SOL','TIP', | |
| 'NA','CL','MG','ZN','CA','FE','MN','K','ION' | |
| ]); | |
| // ── Detect ligand residue name from the loaded model ───────── | |
| function getLigandSel() { | |
| const _SKIP = new Set(['HOH','WAT','SOL','TIP']); | |
| // Try hetflag first (HETATM records) | |
| const hetAtoms = viewer.selectedAtoms({ hetflag: true }); | |
| const ligAtoms = hetAtoms.filter(a => !_SKIP.has(a.resn)); | |
| if (ligAtoms.length > 0) { | |
| const resNames = [...new Set(ligAtoms.map(a => a.resn))]; | |
| return { resn: resNames }; | |
| } | |
| // Fallback: any residue not in the protein set | |
| const allAtoms = viewer.selectedAtoms({}); | |
| const nonProtein = allAtoms.filter(a => !PROTEIN_RESN.has(a.resn) && !_SKIP.has(a.resn)); | |
| if (nonProtein.length > 0) { | |
| const resNames = [...new Set(nonProtein.map(a => a.resn))]; | |
| return { resn: resNames }; | |
| } | |
| return null; | |
| } | |
| /** | |
| * @brief Model-specific variant of getLigandSel() for overlay mode. | |
| * | |
| * Returns a 3Dmol.js selection spec scoped to a single model index, | |
| * preventing doubled atom selection when two models are loaded. | |
| * | |
| * @param {number} modelIdx Model index (0 = minimized, 1 = original). | |
| * @returns {Object|null} Selection spec like {model: 0, resn: ['LIG']} or null. | |
| */ | |
| function getLigandSel_model(modelIdx) { | |
| const _SKIP = new Set(['HOH','WAT','SOL','TIP']); | |
| // Try hetflag first (HETATM records) | |
| const hetAtoms = viewer.selectedAtoms({ model: modelIdx, hetflag: true }); | |
| const ligAtoms = hetAtoms.filter(a => !_SKIP.has(a.resn)); | |
| if (ligAtoms.length > 0) { | |
| const resNames = [...new Set(ligAtoms.map(a => a.resn))]; | |
| return { model: modelIdx, resn: resNames }; | |
| } | |
| // Fallback: any residue not in the protein set | |
| const allAtoms = viewer.selectedAtoms({ model: modelIdx }); | |
| const nonProtein = allAtoms.filter(a => !PROTEIN_RESN.has(a.resn) && !_SKIP.has(a.resn)); | |
| if (nonProtein.length > 0) { | |
| const resNames = [...new Set(nonProtein.map(a => a.resn))]; | |
| return { model: modelIdx, resn: resNames }; | |
| } | |
| return null; | |
| } | |
| // ── Get binding-site residue numbers within cutoff Å of ligand ─ | |
| function getBindingSiteResidues(cutoff = 5.0) { | |
| const ligSel = getLigandSel(); | |
| if (!ligSel) return null; | |
| const ligAtoms = viewer.selectedAtoms(ligSel); | |
| // Exclude ligand residue names from protein atom list. | |
| // In KRAS-style PDBs the ligand (UNL) is stored as ATOM records (no HETATM), | |
| // so hetflag:false alone would include ligand atoms and cause self-detection. | |
| const ligResns = new Set(ligAtoms.map(a => a.resn)); | |
| const protAtoms = viewer.selectedAtoms({ hetflag: false }).filter(a => !ligResns.has(a.resn)); | |
| if (!ligAtoms.length || !protAtoms.length) return null; | |
| const nearResidues = new Map(); // key: "chain:resi" → {resi, chain, resn} | |
| for (const pa of protAtoms) { | |
| for (const la of ligAtoms) { | |
| const d = dist3(pa, la); | |
| if (d <= cutoff) { | |
| nearResidues.set(`${pa.chain}:${pa.resi}`, { resi: pa.resi, chain: pa.chain, resn: pa.resn }); | |
| break; | |
| } | |
| } | |
| } | |
| return nearResidues.size > 0 ? [...nearResidues.values()] : null; | |
| } | |
| // ── Exact Mol* color constants ─────────────────────────────── | |
| // Source: molstar/src/mol-theme/color/element-symbol.ts | |
| const CPK = { | |
| C: '#909090', // carbon — all carbons same grey in element-symbol theme | |
| N: '#3050F8', // nitrogen | |
| O: '#FF0D0D', // oxygen | |
| S: '#FFFF30', // sulfur | |
| P: '#FF8000', // phosphorus | |
| F: '#90E050', // fluorine | |
| Cl: '#1FF01F', // chlorine | |
| Br: '#A62929', // bromine | |
| I: '#940094', // iodine | |
| H: '#FFFFFF', // hydrogen | |
| Fe: '#E06633', // iron | |
| Zn: '#7D80B0', // zinc | |
| Ca: '#3DFF00', // calcium | |
| Mg: '#8AFF00', // magnesium | |
| Na: '#AB5CF2', // sodium | |
| K: '#8F40D4', // potassium | |
| }; | |
| // Mol* 'many-distinct' palette — chain-id color theme | |
| // Source: molstar/src/mol-util/color/lists.ts (Dark2 + Set1 + Set2 combined) | |
| const MOLSTAR_CHAINS = [ | |
| // Dark2 (8) | |
| '#1b9e77', // A — teal green ← chain A protein ribbon appears green in RCSB | |
| '#d95f02', // B — burnt orange | |
| '#7570b3', // C — muted purple | |
| '#e7298a', // D — hot pink | |
| '#66a61e', // E — olive green | |
| '#e6ab02', // F — amber | |
| '#a6761d', // G — brown | |
| '#666666', // H — grey | |
| // Set1 (9) | |
| '#e41a1c', // I — red | |
| '#377eb8', // J — steel blue | |
| '#4daf4a', // K — grass green | |
| '#984ea3', // L — purple | |
| '#ff7f00', // M — orange | |
| '#ffff33', // N — yellow | |
| '#a65628', // O — sienna | |
| '#f781bf', // P — pink | |
| '#999999', // Q — light grey | |
| // Set2 (8) | |
| '#66c2a5', // R — sea green | |
| '#fc8d62', // S — salmon | |
| '#8da0cb', // T — periwinkle | |
| '#e78ac3', // U — orchid | |
| '#a6d854', // V — yellow-green | |
| '#ffd92f', // W — gold | |
| '#e5c494', // X — tan | |
| '#b3b3b3', // Y — silver | |
| ]; | |
| // Get exact Mol* chain colour | |
| function chainColor(chainId) { | |
| const idx = (chainId || 'A').charCodeAt(0) - 65; // A=0, B=1 … | |
| return MOLSTAR_CHAINS[Math.abs(idx) % MOLSTAR_CHAINS.length]; | |
| } | |
| // Build element-colour map — carbon overrideable, rest exact Mol* CPK | |
| function elemMap(carbonColor) { | |
| return { | |
| C: carbonColor || CPK.C, | |
| N: CPK.N, O: CPK.O, S: CPK.S, P: CPK.P, | |
| F: CPK.F, Cl: CPK.Cl, Br: CPK.Br, I: CPK.I, H: CPK.H, | |
| Fe: CPK.Fe, Zn: CPK.Zn, Ca: CPK.Ca, Mg: CPK.Mg, | |
| }; | |
| } | |
| // ── Apply representation ────────────────────────────────────── | |
| // Always shows the full protein ribbon for all chains. | |
| // Binding-site residues (≤5 Å from ligand) additionally get ball+stick on top. | |
| // Each binding-site residue is selected by chain+resi+resn to prevent | |
| // cross-chain collisions and avoid accidentally matching ligand atoms | |
| // that share the same resi number (common in KRAS-style PDBs where | |
| // ligand UNL is stored as ATOM records at resi=1, same as protein MET 1). | |
| function applyRepr(repr) { | |
| if (!viewer) return; | |
| viewer.removeAllSurfaces(); | |
| viewer.removeAllLabels(); // ← also destroys _selectedLabel; we'll restore it below | |
| viewer.setStyle({}, {}); // hide everything first | |
| // removeAllLabels() wipes the selection label — remember the atom so we can re-add it | |
| const _restoredSelectedAtom = _selectedAtom; | |
| _selectedLabel = null; // already gone from the scene | |
| const ligSel = getLigandSel(); | |
| const ligAtoms = ligSel ? viewer.selectedAtoms(ligSel) : []; | |
| const ligResns = new Set(ligAtoms.map(a => a.resn)); | |
| // Build protein-only chain list (exclude ligand resn) | |
| const allAtoms = viewer.selectedAtoms({ hetflag: false }); | |
| const chains = [...new Set(allAtoms.filter(a => !ligResns.has(a.resn)).map(a => a.chain))]; | |
| const ligChainCol = chains.length > 0 ? chainColor(chains[0]) : chainColor('A'); | |
| const cartoonThickness = 0.3; | |
| // ── 1. Full protein ribbon for every chain (always shown) ──── | |
| chains.forEach(ch => { | |
| if (repr === 'ribbon') { | |
| viewer.setStyle({ chain: ch, hetflag: false }, { | |
| ribbon: { color: chainColor(ch), opacity: 0.90, | |
| thickness: 0.45, arrows: true } | |
| }); | |
| } else if (repr === 'wireframe') { | |
| viewer.setStyle({ chain: ch, hetflag: false }, { | |
| line: { linewidth: 0.9, color: chainColor(ch), opacity: 0.65 } | |
| }); | |
| } else { | |
| // cartoon for both 'cartoon' and 'surface' modes | |
| viewer.setStyle({ chain: ch, hetflag: false }, { | |
| cartoon: { color: chainColor(ch), opacity: 0.90, | |
| thickness: cartoonThickness, arrows: true } | |
| }); | |
| } | |
| }); | |
| // ── 2. Binding-site residues: overlay ball+stick (≤5 Å from ligand) ── | |
| const bsResidues = getBindingSiteResidues(5.0); | |
| if (bsResidues && bsResidues.length > 0) { | |
| bsResidues.forEach(r => { | |
| const sel = { resi: r.resi, chain: r.chain, resn: r.resn, hetflag: false }; | |
| viewer.addStyle(sel, { | |
| stick: { radius: 0.12, opacity: 0.60, colorscheme: { prop: 'elem', map: elemMap(CPK.C) } }, | |
| sphere: { scale: 0.10, opacity: 0.60, colorscheme: { prop: 'elem', map: elemMap(CPK.C) } }, | |
| }); | |
| }); | |
| // Surface mode: SES over binding-site atoms only | |
| if (repr === 'surface') { | |
| viewer.addSurface($3Dmol.SurfaceType.SES, { | |
| opacity: 0.45, | |
| colorscheme: { prop: 'elem', map: elemMap('#d0d0d0') }, | |
| }, { resi: bsResidues.map(r => r.resi), chain: chains }); | |
| } | |
| } | |
| // ── 3. Ligand: RCSB-scale ball+stick + glowing outline ─────── | |
| // RCSB ligand atoms: prominent spheres (~35% VDW), bond cylinders ~0.15 Å radius | |
| if (ligSel) { | |
| viewer.setStyle(ligSel, { | |
| stick: { radius: 0.12, colorscheme: { prop: 'elem', map: elemMap(ligChainCol) } }, | |
| sphere: { scale: 0.10, colorscheme: { prop: 'elem', map: elemMap(ligChainCol) } }, | |
| }); | |
| // ── Ligand outline: SES border glow ─────────────────────── | |
| viewer.addSurface($3Dmol.SurfaceType.SES, { | |
| opacity: 0.52, | |
| color: '#FF6200', | |
| }, ligSel); | |
| // Layer 2 — halo kept close to atom size so it doesn't inflate visual size | |
| viewer.addStyle(ligSel, { | |
| sphere: { scale: 0.13, color: '#FF6200', opacity: 0.18 }, | |
| }); | |
| } | |
| // Re-register hover + click after every style rebuild | |
| registerHoverable(); | |
| // Restore the selection label if a residue was selected before the repr change | |
| if (_restoredSelectedAtom) { | |
| const a = _restoredSelectedAtom; | |
| const isLig = !PROTEIN_RESN.has(a.resn) && !WATER_RESN.has(a.resn); | |
| _selectedLabel = viewer.addLabel( | |
| isLig ? `${a.resn} [ligand]` : `${a.resn} ${a.resi} (${a.chain})`, { | |
| position: { x: a.x, y: a.y, z: a.z }, | |
| fontColor: '#003D5C', | |
| fontSize: 12, | |
| fontOpacity: 1.0, | |
| backgroundOpacity: 0.93, | |
| backgroundColor: '#E0F7FA', | |
| borderColor: '#00ACC1', | |
| borderThickness: 1.5, | |
| padding: 3, | |
| inFront: true, | |
| } | |
| ); | |
| } | |
| if (interactionsVisible) { | |
| computeAndDrawInteractions(); | |
| } else { | |
| autoDrawHBondsAndPiPi(); | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════ | |
| // INTERACTION DETECTION & VISUALIZATION | |
| // ═══════════════════════════════════════════════════════════════ | |
| let interactionsVisible = false; | |
| let interactionShapes = []; // track added cylinders/shapes for removal | |
| let interactionLabels = []; // track distance labels (cleared alongside shapes) | |
| let elecShapes = []; // track dashed-red cylinders for electrostatic repulsion lines | |
| let elecLabels = []; // track Å distance labels on repulsion lines | |
| // Donor/acceptor atom element sets | |
| const HBOND_DONORS = new Set(['N','O','S']); | |
| const HBOND_ACCEPTORS = new Set(['N','O','F','S']); | |
| const AROMATIC_RESN = new Set(['PHE','TYR','TRP','HIS','HID','HIE','HIP']); | |
| const HYDROPHOBIC_RESN = new Set(['ALA','VAL','ILE','LEU','MET','PHE','TRP','PRO','TYR']); | |
| const POS_CHARGED_RESN = new Set(['LYS','ARG','HIS','HID','HIE','HIP']); | |
| const NEG_CHARGED_RESN = new Set(['ASP','GLU']); | |
| // Aromatic ring centre atoms per residue | |
| const AROM_RING_ATOMS = { | |
| PHE: ['CG','CD1','CD2','CE1','CE2','CZ'], | |
| TYR: ['CG','CD1','CD2','CE1','CE2','CZ'], | |
| TRP: ['CG','CD1','CD2','CE2','CE3','CZ2','CZ3','CH2','NE1'], | |
| HIS: ['CG','ND1','CD2','CE1','NE2'], | |
| HID: ['CG','ND1','CD2','CE1','NE2'], | |
| HIE: ['CG','ND1','CD2','CE1','NE2'], | |
| HIP: ['CG','ND1','CD2','CE1','NE2'], | |
| }; | |
| function dist3(a, b) { | |
| return Math.sqrt((a.x-b.x)**2 + (a.y-b.y)**2 + (a.z-b.z)**2); | |
| } | |
| function centroid(atoms) { | |
| const n = atoms.length; | |
| return { | |
| x: atoms.reduce((s,a)=>s+a.x,0)/n, | |
| y: atoms.reduce((s,a)=>s+a.y,0)/n, | |
| z: atoms.reduce((s,a)=>s+a.z,0)/n, | |
| }; | |
| } | |
| // Estimate the implicit H position on a donor atom D. | |
| // Looks for heavy atoms covalently bonded to D (within 1.8 Å) in the same molecule, | |
| // then places H 1.0 Å from D in the direction away from those bonded neighbors. | |
| // Returns {x,y,z} or null if no bonded neighbors found. | |
| function estimateHPos(donor, moleculeAtoms) { | |
| const bonded = moleculeAtoms.filter(a => { | |
| if (a === donor) return false; | |
| return dist3(a, donor) <= 1.8; | |
| }); | |
| if (bonded.length === 0) return null; | |
| const cx = bonded.reduce((s,a) => s+a.x, 0) / bonded.length; | |
| const cy = bonded.reduce((s,a) => s+a.y, 0) / bonded.length; | |
| const cz = bonded.reduce((s,a) => s+a.z, 0) / bonded.length; | |
| const dx = donor.x - cx, dy = donor.y - cy, dz = donor.z - cz; | |
| const len = Math.sqrt(dx*dx + dy*dy + dz*dz); | |
| if (len < 0.001) return null; | |
| return { x: donor.x + (dx/len)*1.0, y: donor.y + (dy/len)*1.0, z: donor.z + (dz/len)*1.0 }; | |
| } | |
| // Compute D-H···A angle in degrees (the angle at H, between vectors H→D and H→A). | |
| // For an ideal linear H-bond this is 180°; strong H-bonds require > 150°. | |
| function angleDHA(D, H, A) { | |
| const v1 = { x: D.x-H.x, y: D.y-H.y, z: D.z-H.z }; | |
| const v2 = { x: A.x-H.x, y: A.y-H.y, z: A.z-H.z }; | |
| const dot = v1.x*v2.x + v1.y*v2.y + v1.z*v2.z; | |
| const len = Math.sqrt((v1.x**2+v1.y**2+v1.z**2) * (v2.x**2+v2.y**2+v2.z**2)); | |
| if (len < 0.001) return 0; | |
| return Math.acos(Math.max(-1, Math.min(1, dot/len))) * 180 / Math.PI; | |
| } | |
| // Find individual aromatic ring centroids in the ligand by bond-proximity clustering. | |
| // Atoms within 1.65 Å are considered bonded; atoms with ≥2 such neighbours are | |
| // in-ring; connected components of ≥5 in-ring atoms are candidate aromatic rings. | |
| // Returns an array of {x,y,z} centroid objects (one per ring system found). | |
| function findLigandRingCentroids(ligAtoms) { | |
| const cnAtoms = ligAtoms.filter(a => a.elem === 'C' || a.elem === 'N'); | |
| if (cnAtoms.length < 5) return []; | |
| const n = cnAtoms.length; | |
| const adj = Array.from({length: n}, () => []); | |
| for (let i = 0; i < n; i++) { | |
| for (let j = i + 1; j < n; j++) { | |
| if (dist3(cnAtoms[i], cnAtoms[j]) <= 1.65) { | |
| adj[i].push(j); | |
| adj[j].push(i); | |
| } | |
| } | |
| } | |
| // Atoms with ≥2 bonded neighbours are candidates for being in a ring | |
| const inRing = cnAtoms.map((_, i) => adj[i].length >= 2); | |
| // BFS over ring atoms to find connected components | |
| const visited = new Array(n).fill(false); | |
| const ringCentroids = []; | |
| for (let start = 0; start < n; start++) { | |
| if (visited[start] || !inRing[start]) continue; | |
| const comp = []; | |
| const queue = [start]; | |
| while (queue.length) { | |
| const cur = queue.shift(); | |
| if (visited[cur]) continue; | |
| visited[cur] = true; | |
| if (inRing[cur]) { | |
| comp.push(cnAtoms[cur]); | |
| adj[cur].forEach(nb => { if (!visited[nb] && inRing[nb]) queue.push(nb); }); | |
| } | |
| } | |
| if (comp.length >= 5) ringCentroids.push(centroid(comp)); | |
| } | |
| return ringCentroids; | |
| } | |
| // Detect all interactions between ligand atoms and protein atoms | |
| function detectInteractions(ligAtoms, protAtoms) { | |
| const results = []; | |
| // ── 1. Hydrogen bonds — good stability only ─────────────── | |
| // Criteria: D···A distance < 3.3 Å AND D-H···A angle > 150° | |
| // H position is estimated from bonded-neighbor geometry (PDB lacks explicit H). | |
| for (const la of ligAtoms) { | |
| if (!HBOND_DONORS.has(la.elem) && !HBOND_ACCEPTORS.has(la.elem)) continue; | |
| for (const pa of protAtoms) { | |
| if (!HBOND_DONORS.has(pa.elem) && !HBOND_ACCEPTORS.has(pa.elem)) continue; | |
| const d = dist3(la, pa); | |
| if (d >= 3.3 || d < 2.4) continue; // distance filter < 3.3 Å | |
| // Angle filter > 150°: try ligand atom as donor first, then protein atom | |
| let passAngle = false; | |
| const Hlig = estimateHPos(la, ligAtoms); // H on ligand donor | |
| if (Hlig && angleDHA(la, Hlig, pa) > 150) passAngle = true; | |
| if (!passAngle) { | |
| const Hprot = estimateHPos(pa, protAtoms); // H on protein donor | |
| if (Hprot && angleDHA(pa, Hprot, la) > 150) passAngle = true; | |
| } | |
| // If we couldn't estimate H at all, fall back to distance-only (< 2.8 Å = very strong) | |
| if (!passAngle && d >= 2.8) continue; | |
| results.push({ | |
| type: 'hbond', | |
| label: `H-Bond: LIG(${la.elem}${la.resi}) ↔ ${pa.resn}${pa.resi}(${pa.atom})`, | |
| dist: d, | |
| color: '#2471a3', | |
| from: { x: la.x, y: la.y, z: la.z }, | |
| to: { x: pa.x, y: pa.y, z: pa.z }, | |
| dashed: true, | |
| }); | |
| } | |
| } | |
| // ── 2. π–π stacking ────────────────────────────────────── | |
| // Use proper ring-centroid detection (bond-proximity clustering) so lines are | |
| // drawn between real ring systems, not an artificial whole-ligand centroid. | |
| const aromaticProtRes = protAtoms.filter(a => AROMATIC_RESN.has(a.resn)); | |
| const protResGroups = {}; | |
| for (const a of aromaticProtRes) { | |
| const key = `${a.resn}${a.resi}${a.chain}`; | |
| if (!protResGroups[key]) protResGroups[key] = []; | |
| protResGroups[key].push(a); | |
| } | |
| const ligRingCentroids = findLigandRingCentroids(ligAtoms); | |
| if (ligRingCentroids.length > 0) { | |
| for (const [key, resAtoms] of Object.entries(protResGroups)) { | |
| const ringAtomNames = AROM_RING_ATOMS[resAtoms[0].resn] || []; | |
| const ringAtoms = resAtoms.filter(a => ringAtomNames.includes(a.atom)); | |
| if (ringAtoms.length < 4) continue; | |
| const rc = centroid(ringAtoms); | |
| // Find the closest ligand ring centroid to this protein ring | |
| let minDist = Infinity, closestLrc = null; | |
| for (const lrc of ligRingCentroids) { | |
| const d = dist3(lrc, rc); | |
| if (d < minDist) { minDist = d; closestLrc = lrc; } | |
| } | |
| if (minDist <= 5.5) { | |
| results.push({ | |
| type: 'pipi', | |
| label: `π–π Stack: LIG ↔ ${resAtoms[0].resn}${resAtoms[0].resi}`, | |
| dist: minDist, | |
| color: '#8e44ad', | |
| from: closestLrc, | |
| to: rc, | |
| dashed: false, | |
| }); | |
| } | |
| } | |
| } | |
| // ── 3. Hydrophobic contacts ─────────────────────────────── | |
| // Ligand C atoms within 4.0 Å of hydrophobic residue C atoms | |
| const ligC = ligAtoms.filter(a => a.elem === 'C'); | |
| const protHydroC = protAtoms.filter(a => a.elem === 'C' && HYDROPHOBIC_RESN.has(a.resn)); | |
| const hydrophobicPairs = new Set(); | |
| for (const la of ligC) { | |
| for (const pa of protHydroC) { | |
| const d = dist3(la, pa); | |
| if (d <= 4.0) { | |
| const key = `${pa.resn}${pa.resi}${pa.chain}`; | |
| if (!hydrophobicPairs.has(key)) { | |
| hydrophobicPairs.add(key); | |
| results.push({ | |
| type: 'hydrophobic', | |
| label: `Hydrophobic: LIG ↔ ${pa.resn}${pa.resi}`, | |
| dist: d, | |
| color: '#c0392b', | |
| from: { x: la.x, y: la.y, z: la.z }, | |
| to: { x: pa.x, y: pa.y, z: pa.z }, | |
| dashed: false, | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| // ── 4. Salt bridges ─────────────────────────────────────── | |
| // Charged ligand atoms (N+/O-) within 4.0 Å of oppositely charged residue | |
| const ligCharged = ligAtoms.filter(a => a.elem === 'N' || a.elem === 'O'); | |
| const protPos = protAtoms.filter(a => POS_CHARGED_RESN.has(a.resn) && (a.elem==='N'||a.elem==='O')); | |
| const protNeg = protAtoms.filter(a => NEG_CHARGED_RESN.has(a.resn) && (a.elem==='O')); | |
| const saltPairs = new Set(); | |
| for (const la of ligCharged) { | |
| const candidates = la.elem === 'O' ? protPos : protNeg; | |
| for (const pa of candidates) { | |
| const d = dist3(la, pa); | |
| if (d <= 4.0) { | |
| const key = `${pa.resn}${pa.resi}${pa.chain}`; | |
| if (!saltPairs.has(key)) { | |
| saltPairs.add(key); | |
| results.push({ | |
| type: 'saltbridge', | |
| label: `Salt Bridge: LIG(${la.elem}) ↔ ${pa.resn}${pa.resi}`, | |
| dist: d, | |
| color: '#27ae60', | |
| from: { x: la.x, y: la.y, z: la.z }, | |
| to: { x: pa.x, y: pa.y, z: pa.z }, | |
| dashed: false, | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| // Deduplicate and sort by distance | |
| return results.sort((a, b) => a.dist - b.dist); | |
| } | |
| // Draw interactions as cylinders/dashed lines in 3Dmol viewer | |
| function drawInteractions(interactions) { | |
| // Remove old shapes | |
| clearInteractionShapes(); | |
| for (const ix of interactions) { | |
| // Safety filter: skip any line whose from→to distance exceeds 5.5 Å. | |
| // Real molecular interactions (H-bonds ≤3.5 Å, π–π ≤5.5 Å, hydrophobic ≤4.0 Å, | |
| // salt bridges ≤4.0 Å) are all within this bound; anything longer is a false positive. | |
| const lineLen = dist3(ix.from, ix.to); | |
| if (lineLen > 5.5) continue; | |
| if (ix.dashed) { | |
| // Dashed line: draw as series of short cylinders with gaps | |
| const dx = ix.to.x - ix.from.x; | |
| const dy = ix.to.y - ix.from.y; | |
| const dz = ix.to.z - ix.from.z; | |
| const totalLen = Math.sqrt(dx*dx + dy*dy + dz*dz); | |
| const steps = Math.max(4, Math.floor(totalLen / 0.4)); | |
| for (let i = 0; i < steps; i++) { | |
| if (i % 2 === 0) continue; // skip alternating segments = dashed | |
| const t0 = i / steps, t1 = (i + 1) / steps; | |
| const start = { | |
| x: ix.from.x + dx * t0, | |
| y: ix.from.y + dy * t0, | |
| z: ix.from.z + dz * t0, | |
| }; | |
| const end = { | |
| x: ix.from.x + dx * t1, | |
| y: ix.from.y + dy * t1, | |
| z: ix.from.z + dz * t1, | |
| }; | |
| interactionShapes.push(viewer.addCylinder({ | |
| start, end, | |
| radius: 0.06, | |
| color: ix.color, | |
| fromCap: 1, toCap: 1, | |
| opacity: 0.85, | |
| })); | |
| } | |
| } else { | |
| // Solid thin cylinder | |
| interactionShapes.push(viewer.addCylinder({ | |
| start: ix.from, | |
| end: ix.to, | |
| radius: 0.05, | |
| color: ix.color, | |
| fromCap: 1, toCap: 1, | |
| opacity: 0.7, | |
| })); | |
| } | |
| // Small sphere at protein end to mark the residue | |
| interactionShapes.push(viewer.addSphere({ | |
| center: ix.to, | |
| radius: 0.18, | |
| color: ix.color, | |
| opacity: 0.9, | |
| })); | |
| // Distance label at the midpoint of the interaction line | |
| const mid = { | |
| x: (ix.from.x + ix.to.x) / 2, | |
| y: (ix.from.y + ix.to.y) / 2, | |
| z: (ix.from.z + ix.to.z) / 2, | |
| }; | |
| interactionLabels.push(viewer.addLabel(`${ix.dist.toFixed(1)} Å`, { | |
| position: mid, | |
| fontColor: ix.color, | |
| fontSize: 10, | |
| fontOpacity: 1.0, | |
| backgroundOpacity: 0.80, | |
| backgroundColor: '#F5F8FB', | |
| borderColor: ix.color, | |
| borderThickness: 0.8, | |
| padding: 2, | |
| inFront: true, | |
| showBackground: true, | |
| })); | |
| } | |
| viewer.render(); | |
| } | |
| /** | |
| * @brief Remove all tracked interaction shapes and distance labels from the viewer. | |
| * | |
| * Iterates over the interactionShapes and interactionLabels arrays, individually | |
| * removing each cylinder, sphere, and label from the 3Dmol viewer. Unlike | |
| * removeAllShapes(), this only removes interaction-specific visuals and preserves | |
| * other shapes such as the hover sphere and click-selection sphere. | |
| * | |
| * @returns {void} | |
| */ | |
| function clearInteractionShapes() { | |
| // Remove only the tracked interaction shapes — NOT removeAllShapes() which | |
| // would also destroy the hover sphere and the click-selection sphere. | |
| for (const s of interactionShapes) { | |
| try { viewer.removeShape(s); } catch (_) {} | |
| } | |
| interactionShapes = []; | |
| // Remove the distance labels that sit at each interaction midpoint | |
| for (const l of interactionLabels) { | |
| try { viewer.removeLabel(l); } catch (_) {} | |
| } | |
| interactionLabels = []; | |
| } | |
| /** | |
| * @brief Remove all electrostatic-repulsion dashed lines and their distance labels. | |
| * | |
| * Iterates over the tracked elecShapes and elecLabels arrays, removing each | |
| * shape and label from the 3Dmol viewer. Called when the electrostatic panel is | |
| * hidden, a new structure is loaded, or no charged pairs remain. | |
| * | |
| * @returns {void} | |
| */ | |
| function clearElecShapes() { | |
| for (const s of elecShapes) { | |
| try { viewer.removeShape(s); } catch (_) {} | |
| } | |
| elecShapes = []; | |
| for (const l of elecLabels) { | |
| try { viewer.removeLabel(l); } catch (_) {} | |
| } | |
| elecLabels = []; | |
| } | |
| /** | |
| * @brief Update the HTML interaction list panel with detected interactions. | |
| * | |
| * Populates the interaction-list DOM element with color-coded entries for each | |
| * detected interaction, and displays a summary count broken down by type | |
| * (H-Bond, pi-pi, Hydrophobic, Salt Bridge) in the interaction-count element. | |
| * Shows a placeholder message if no interactions were detected. | |
| * | |
| * @param {Array<Object>} interactions - Array of interaction objects, each with | |
| * properties: type, label, dist, and color. | |
| * @returns {void} | |
| */ | |
| function updateInteractionPanel(interactions) { | |
| const list = document.getElementById('interaction-list'); | |
| const count = document.getElementById('interaction-count'); | |
| list.innerHTML = ''; | |
| if (interactions.length === 0) { | |
| list.innerHTML = '<div style="color:#4a5a8a;font-size:0.72rem;padding:4px 0">No interactions detected within cutoff distances.</div>'; | |
| count.textContent = ''; | |
| return; | |
| } | |
| const typeCounts = {}; | |
| for (const ix of interactions) { | |
| typeCounts[ix.type] = (typeCounts[ix.type] || 0) + 1; | |
| const item = document.createElement('div'); | |
| item.className = 'iact-item'; | |
| item.innerHTML = ` | |
| <div class="iact-color" style="background:${ix.color}"></div> | |
| <div class="iact-text">${ix.label} <span class="iact-dist">${ix.dist.toFixed(2)} Å</span></div> | |
| `; | |
| list.appendChild(item); | |
| } | |
| const summary = Object.entries(typeCounts).map(([t, n]) => { | |
| const labels = { hbond:'H-Bond', hbond_weak:'H-Bond(weak)', pipi:'π–π', hydrophobic:'Hydrophob.', saltbridge:'Salt Bridge' }; | |
| return `${labels[t]||t}: ${n}`; | |
| }).join(' · '); | |
| count.textContent = `Total: ${interactions.length} | ${summary}`; | |
| } | |
| /** | |
| * @brief Toggle the interaction visualization panel on or off. | |
| * | |
| * Flips the interactionsVisible flag, updates the button and panel CSS classes, | |
| * and either computes/draws all interactions (when opening) or restores the | |
| * default always-on H-bond visuals (when closing). | |
| * | |
| * @returns {void} | |
| */ | |
| function toggleInteractions() { | |
| interactionsVisible = !interactionsVisible; | |
| document.getElementById('btn-interactions').classList.toggle('active', interactionsVisible); | |
| document.getElementById('interaction-panel').classList.toggle('visible', interactionsVisible); | |
| if (viewMode === 'overlay') { | |
| // In overlay mode, delegate to applyOverlayStyle which handles | |
| // model-scoped interaction drawing to prevent doubled detection. | |
| applyOverlayStyle(); | |
| viewer.render(); | |
| } else if (interactionsVisible) { | |
| computeAndDrawInteractions(); | |
| } else { | |
| // Closing the panel: restore the always-on H-bond + π-π visuals | |
| autoDrawHBondsAndPiPi(); | |
| } | |
| } | |
| /** | |
| * @brief Compute and visualize all molecular interactions for the current model. | |
| * | |
| * Retrieves the ligand and protein atoms from the loaded 3Dmol viewer, filters | |
| * protein atoms to those within 12 Angstroms of the ligand centroid for performance, | |
| * then runs full interaction detection (H-bonds, pi-pi, hydrophobic, salt bridges). | |
| * Draws the detected interactions as 3D shapes and updates both the interaction | |
| * list panel and the binding affinity panel. | |
| * | |
| * @returns {void} | |
| */ | |
| function computeAndDrawInteractions() { | |
| if (!viewer) return; | |
| const ligSel = getLigandSel(); | |
| if (!ligSel) { | |
| document.getElementById('interaction-list').innerHTML = | |
| '<div style="color:#aa4444;font-size:0.72rem">No ligand detected.</div>'; | |
| return; | |
| } | |
| const ligAtoms = viewer.selectedAtoms(ligSel); | |
| // Exclude ligand residue names from protein list (UNL stored as ATOM in KRAS PDBs) | |
| const ligResns0 = new Set(ligAtoms.map(a => a.resn)); | |
| const protAtoms = viewer.selectedAtoms({ hetflag: false }).filter(a => !ligResns0.has(a.resn)); | |
| // Only consider protein atoms within 12 Å of ligand centroid for speed | |
| const ligCentroid = centroid(ligAtoms); | |
| const nearProtAtoms = protAtoms.filter(pa => dist3(pa, ligCentroid) <= 12.0); | |
| const interactions = detectInteractions(ligAtoms, nearProtAtoms); | |
| lastInteractions = interactions; | |
| drawInteractions(interactions); | |
| updateInteractionPanel(interactions); | |
| updateAffinityPanel(); | |
| } | |
| /** | |
| * @brief Automatically draw strong hydrogen bonds that are always visible after load. | |
| * | |
| * Detects all interactions between ligand and nearby protein atoms, then renders | |
| * only the strong H-bond subset (N/O donors/acceptors) as dashed lines. This | |
| * provides a baseline visual without cluttering the view. Pi-pi stacking lines | |
| * are intentionally excluded here to prevent false-positive long lines from | |
| * centroid mismatches; those are shown only via the Interactions panel. | |
| * Also triggers an update of the affinity panel with the full interaction set. | |
| * | |
| * @returns {void} | |
| */ | |
| function autoDrawHBondsAndPiPi() { | |
| if (!viewer) return; | |
| const ligSel = getLigandSel(); | |
| if (!ligSel) { viewer.render(); return; } | |
| const ligAtoms = viewer.selectedAtoms(ligSel); | |
| // Exclude ligand residue names so UNL atoms (stored as ATOM) don't appear as protein | |
| const ligResns1 = new Set(ligAtoms.map(a => a.resn)); | |
| const protAtoms = viewer.selectedAtoms({ hetflag: false }).filter(a => !ligResns1.has(a.resn)); | |
| if (!ligAtoms.length || !protAtoms.length) { viewer.render(); return; } | |
| const ligCen = centroid(ligAtoms); | |
| const nearProt = protAtoms.filter(pa => dist3(pa, ligCen) <= 12.0); | |
| const all = detectInteractions(ligAtoms, nearProt); | |
| lastInteractions = all; | |
| // Only strong H-bonds (N/O donors/acceptors, excludes weak S-based ones) | |
| drawInteractions(all.filter(ix => ix.type === 'hbond')); | |
| updateAffinityPanel(); | |
| } | |
| // ═══════════════════════════════════════════════════════════════ | |
| // BINDING AFFINITY PANEL | |
| // ═══════════════════════════════════════════════════════════════ | |
| let lastInteractions = []; // stored after each detectInteractions() call | |
| let affinityVisible = false; | |
| /** | |
| * @brief Compute an empirical binding free energy estimate from detected interactions. | |
| * | |
| * Uses simplified coefficients inspired by X-Score / AutoDock literature to sum | |
| * per-interaction-type contributions into an overall deltaG (kcal/mol). Converts | |
| * deltaG to pKi via the thermodynamic relation pKi = -deltaG / (2.303 * RT) where | |
| * RT = 0.593 kcal/mol at 298 K, and derives Ki in molar units. | |
| * | |
| * @param {Array<Object>} interactions - Array of interaction objects as returned by | |
| * detectInteractions(), each having a `type` field (e.g. 'hbond', 'pipi', | |
| * 'hydrophobic', 'saltbridge'). | |
| * @returns {Object} An object containing: | |
| * - {number} dG - Estimated binding free energy in kcal/mol. | |
| * - {number} pKi - Negative log of the inhibition constant. | |
| * - {number} Ki - Inhibition constant in molar. | |
| * - {Object} counts - Per-type interaction counts (hbonds, pipi, hydro, salt). | |
| * - {Object} contribs - Per-type energy contributions and the baseline constant. | |
| */ | |
| function computeEmpiricalAffinity(interactions) { | |
| const hbonds = interactions.filter(ix => ix.type === 'hbond'); | |
| const pipi = interactions.filter(ix => ix.type === 'pipi'); | |
| const hydro = interactions.filter(ix => ix.type === 'hydrophobic'); | |
| const salt = interactions.filter(ix => ix.type === 'saltbridge'); | |
| const hCont = hbonds.length * -1.5; // strong H-bond (d<3.3, angle>150) | |
| const pCont = pipi.length * -1.2; // π–π stacking | |
| const hdCont = hydro.length * -0.3; // hydrophobic contact | |
| const sCont = salt.length * -2.5; // salt bridge / ion pair | |
| const base = -2.5; // non-specific baseline | |
| const dG = base + hCont + pCont + hdCont + sCont; | |
| const pKi = -dG / (2.303 * 0.593); | |
| const Ki = Math.pow(10, -pKi); // molar | |
| return { dG, pKi, Ki, | |
| counts: { hbonds: hbonds.length, pipi: pipi.length, hydro: hydro.length, salt: salt.length }, | |
| contribs: { hCont, pCont, hdCont, sCont, base } }; | |
| } | |
| /** | |
| * @brief Query ChEMBL for similar compounds via Tanimoto similarity search. | |
| * | |
| * Performs a similarity search (>=75% Tanimoto) against the ChEMBL database | |
| * using the provided SMILES string, returning up to 3 matching molecules. | |
| * For each hit, fetches associated bioactivity data (IC50, Ki, Kd, EC50). | |
| * | |
| * @param {string} smiles - SMILES string of the query molecule. | |
| * @returns {Promise<Array<{id: string, pref: string, sim: string, activities: Array}>|null>} | |
| * Array of up to 3 hit objects with ChEMBL ID, preferred name, similarity | |
| * percentage, and activity data; or null on failure or if SMILES is too short. | |
| */ | |
| async function lookupChEMBL(smiles) { | |
| if (!smiles || smiles.length < 5) return null; | |
| try { | |
| const enc = encodeURIComponent(smiles); | |
| const simResp = await fetch( | |
| `https://www.ebi.ac.uk/chembl/api/data/similarity/${enc}/75?format=json&limit=3`); | |
| if (!simResp.ok) return null; | |
| const simData = await simResp.json(); | |
| const mols = (simData.molecules || []).slice(0, 3); | |
| if (!mols.length) return null; | |
| const results = []; | |
| for (const mol of mols) { | |
| const id = mol.molecule_chembl_id; | |
| const pref = mol.pref_name || '—'; | |
| const sim = mol.similarity != null ? `${(mol.similarity * 100).toFixed(0)}%` : '—'; | |
| let activities = []; | |
| try { | |
| const actResp = await fetch( | |
| `https://www.ebi.ac.uk/chembl/api/data/activity?molecule_chembl_id=${id}` + | |
| `&standard_type__in=IC50,Ki,Kd,EC50&format=json&limit=3&order_by=standard_value`); | |
| if (actResp.ok) { | |
| const actData = await actResp.json(); | |
| activities = (actData.activities || []).filter(a => a.standard_value && a.standard_units); | |
| } | |
| } catch (_) {} | |
| results.push({ id, pref, sim, activities }); | |
| } | |
| return results; | |
| } catch (e) { console.warn('ChEMBL error:', e); return null; } | |
| } | |
| /** | |
| * @brief Query PubChem for similar compounds via 2D fingerprint similarity. | |
| * | |
| * Uses the PUG REST fastsimilarity_2d endpoint to find compounds with >=70% | |
| * Tanimoto similarity to the query SMILES. Handles PubChem's asynchronous | |
| * ListKey polling (up to ~20 seconds). Fetches molecular properties for the | |
| * top hit and skips self-matches by comparing canonical SMILES. | |
| * | |
| * @param {string} smiles - SMILES string of the query molecule. | |
| * @returns {Promise<{cid: number, name: string, formula: string, mw: string, smiles: string, imgUrl: string}|null>} | |
| * Object with PubChem CID, IUPAC name, molecular formula, molecular weight, | |
| * canonical SMILES, and a 2D structure image URL; or null on failure. | |
| */ | |
| async function lookupPubChem(smiles) { | |
| if (!smiles || smiles.length < 5) return null; | |
| try { | |
| const enc = encodeURIComponent(smiles); | |
| // Step 1: similarity search — returns list of CIDs sorted by similarity (descending) | |
| const simUrl = `https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/fastsimilarity_2d/smiles/${enc}/cids/JSON?Threshold=70&MaxRecords=5`; | |
| let simResp = await fetch(simUrl); | |
| // PubChem may return 202 with a ListKey for async processing | |
| if (simResp.status === 202) { | |
| const waitData = await simResp.json(); | |
| const listKey = waitData?.Waiting?.ListKey; | |
| if (!listKey) return null; | |
| // Poll up to 10 times (~20 s) for results | |
| for (let i = 0; i < 10; i++) { | |
| await new Promise(r => setTimeout(r, 2000)); | |
| simResp = await fetch( | |
| `https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/listkey/${listKey}/cids/JSON`); | |
| if (simResp.ok) break; | |
| if (simResp.status !== 202) return null; | |
| } | |
| } | |
| if (!simResp.ok) return null; | |
| const simData = await simResp.json(); | |
| const cids = simData?.IdentifierList?.CID; | |
| if (!cids || !cids.length) return null; | |
| // Step 2: fetch properties for top hits (skip exact match by comparing canonical SMILES) | |
| const topCids = cids.slice(0, 5).join(','); | |
| const propUrl = `https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/${topCids}/property/IUPACName,MolecularFormula,MolecularWeight,CanonicalSMILES/JSON`; | |
| const propResp = await fetch(propUrl); | |
| if (!propResp.ok) return null; | |
| const propData = await propResp.json(); | |
| const props = propData?.PropertyTable?.Properties; | |
| if (!props || !props.length) return null; | |
| // Pick the first compound whose canonical SMILES differs from input (skip self-match) | |
| const inputCanon = smiles.trim(); | |
| let best = null; | |
| for (const p of props) { | |
| if (p.CanonicalSMILES === inputCanon) continue; | |
| best = p; | |
| break; | |
| } | |
| if (!best) best = props[0]; // fallback: show top hit even if same SMILES | |
| return { | |
| cid: best.CID, | |
| name: best.IUPACName || '—', | |
| formula: best.MolecularFormula || '—', | |
| mw: best.MolecularWeight ? parseFloat(best.MolecularWeight).toFixed(1) : '—', | |
| smiles: best.CanonicalSMILES || '—', | |
| imgUrl: `https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/${best.CID}/PNG?image_size=small`, | |
| }; | |
| } catch (e) { console.warn('PubChem error:', e); return null; } | |
| } | |
| /** | |
| * @brief Refresh the affinity panel with empirical binding estimates and database lookups. | |
| * | |
| * Computes an empirical binding affinity (delta-G, Ki, pKi) from the current | |
| * protein-ligand interactions, renders a breakdown table in the UI, then | |
| * asynchronously queries ChEMBL and PubChem for similar known compounds. | |
| * Results are displayed in the affinity side panel with 2D structure depictions. | |
| * Does nothing if the affinity panel is not currently visible. | |
| * | |
| * @returns {Promise<void>} | |
| */ | |
| async function updateAffinityPanel() { | |
| if (!affinityVisible) return; | |
| // ── Empirical score ────────────────────────────────────────── | |
| const aff = computeEmpiricalAffinity(lastInteractions); | |
| document.getElementById('aff-dg').textContent = | |
| `ΔG ≈ ${aff.dG.toFixed(1)} kcal/mol`; | |
| const Ki = aff.Ki; | |
| let kiStr; | |
| if (Ki >= 1e-3) kiStr = `Ki ≈ ${(Ki*1e3).toFixed(1)} mM`; | |
| else if (Ki >= 1e-6) kiStr = `Ki ≈ ${(Ki*1e6).toFixed(1)} μM`; | |
| else if (Ki >= 1e-9) kiStr = `Ki ≈ ${(Ki*1e9).toFixed(1)} nM`; | |
| else kiStr = `Ki ≈ ${(Ki*1e12).toFixed(1)} pM`; | |
| document.getElementById('aff-pki').textContent = | |
| `${kiStr} (pKi ${aff.pKi.toFixed(1)})`; | |
| const { counts: c, contribs: k } = aff; | |
| const rows = [ | |
| ['H-bond', c.hbonds, k.hCont ], | |
| ['π–π stacking', c.pipi, k.pCont ], | |
| ['Hydrophobic', c.hydro, k.hdCont], | |
| ['Salt bridge', c.salt, k.sCont ], | |
| ['Base', '', k.base ], | |
| ]; | |
| document.getElementById('aff-breakdown').innerHTML = rows.map(([lbl, n, contrib]) => | |
| `<div class="aff-row"> | |
| <span>${lbl}${n !== '' ? ` ×${n}` : ''}</span> | |
| <span class="aff-contrib">${contrib >= 0 ? '+' : ''}${contrib.toFixed(1)} kcal/mol</span> | |
| </div>` | |
| ).join(''); | |
| // ── ChEMBL lookup ───────────────────────────────────────────── | |
| const smilesEl = document.getElementById('ligand-smiles-val'); | |
| const smiles = smilesEl ? smilesEl.textContent.trim() : ''; | |
| const chemblDiv = document.getElementById('aff-chembl-results'); | |
| const spinner = document.getElementById('aff-chembl-spinner'); | |
| if (smiles && smiles !== '—') { | |
| spinner.textContent = '⏳'; | |
| chemblDiv.innerHTML = '<span style="color:#aaa;font-size:0.67rem">Searching ChEMBL…</span>'; | |
| const hits = await lookupChEMBL(smiles); | |
| spinner.textContent = ''; | |
| if (!hits || !hits.length) { | |
| chemblDiv.innerHTML = | |
| '<span style="color:#aaa;font-size:0.67rem">No match ≥75% — likely a novel compound</span>'; | |
| } else { | |
| chemblDiv.innerHTML = hits.map(h => { | |
| const actHtml = h.activities.length | |
| ? h.activities.slice(0, 2).map(a => | |
| `<span class="aff-chembl-val">${a.standard_type} ${parseFloat(a.standard_value).toFixed(1)} ${a.standard_units}</span>` | |
| ).join(' · ') | |
| : '<span style="color:#aaa">no activity data</span>'; | |
| return `<div class="aff-chembl-item"> | |
| <span class="aff-chembl-id">${h.id}</span> | |
| <span style="color:#555"> · ${h.pref} · ${h.sim} sim</span><br>${actHtml} | |
| </div>`; | |
| }).join(''); | |
| } | |
| } else { | |
| chemblDiv.innerHTML = '<span style="color:#aaa;font-size:0.67rem">No SMILES available</span>'; | |
| } | |
| // ── PubChem lookup ─────────────────────────────────────────── | |
| const pubchemDiv = document.getElementById('aff-pubchem-results'); | |
| const pubchemSpin = document.getElementById('aff-pubchem-spinner'); | |
| if (smiles && smiles !== '—') { | |
| pubchemSpin.textContent = '⏳'; | |
| pubchemDiv.innerHTML = '<span style="color:#aaa;font-size:0.67rem">Searching PubChem…</span>'; | |
| const hit = await lookupPubChem(smiles); | |
| pubchemSpin.textContent = ''; | |
| if (!hit) { | |
| pubchemDiv.innerHTML = | |
| '<span style="color:#aaa;font-size:0.67rem">No similar compound found</span>'; | |
| } else { | |
| // Render 2D depiction via RDKit if available, otherwise fall back to PubChem PNG | |
| let imgHtml = `<img src="${hit.imgUrl}" alt="2D structure">`; | |
| if (RDKitModule && hit.smiles && hit.smiles !== '—') { | |
| let mol = null; | |
| try { mol = RDKitModule.get_mol(hit.smiles); } catch(_) {} | |
| if (mol && mol.is_valid()) { | |
| let svg = ''; | |
| try { svg = mol.get_svg(100, 100); } catch(_) {} | |
| mol.delete(); | |
| if (svg) { | |
| svg = svg.replace(/<rect([^>]+)fill="#(?:FFFFFF|ffffff|FFF|fff)"([^>]*)>/g, | |
| '<rect$1fill="transparent"$2>'); | |
| imgHtml = svg; | |
| } | |
| } else if (mol) { mol.delete(); } | |
| } | |
| const nameShort = hit.name.length > 60 ? hit.name.slice(0, 57) + '…' : hit.name; | |
| pubchemDiv.innerHTML = ` | |
| <div class="pubchem-card"> | |
| <div class="pubchem-card-top"> | |
| <div class="pubchem-card-img">${imgHtml}</div> | |
| <div class="pubchem-card-info"> | |
| <div class="pubchem-cid"> | |
| <a href="https://pubchem.ncbi.nlm.nih.gov/compound/${hit.cid}" target="_blank">CID ${hit.cid}</a> | |
| </div> | |
| <div class="pubchem-name" title="${hit.name}">${nameShort}</div> | |
| <div class="pubchem-prop">${hit.formula} · ${hit.mw} g/mol</div> | |
| <div class="pubchem-prop" style="margin-top:3px;word-break:break-all;color:#777;font-size:0.56rem">${hit.smiles}</div> | |
| </div> | |
| </div> | |
| </div>`; | |
| } | |
| } else { | |
| pubchemDiv.innerHTML = '<span style="color:#aaa;font-size:0.67rem">No SMILES available</span>'; | |
| } | |
| } | |
| function toggleAffinityPanel() { | |
| affinityVisible = !affinityVisible; | |
| document.getElementById('affinity-panel').classList.toggle('visible', affinityVisible); | |
| document.getElementById('btn-affinity').classList.toggle('active', affinityVisible); | |
| if (affinityVisible) updateAffinityPanel(); | |
| } | |
| // ═══════════════════════════════════════════════════════════════ | |
| // LIGAND STRAIN ENERGY | |
| // Simplified molecular-mechanics estimate from bound PDB geometry. | |
| // E_strain = E_bond_stretch + E_angle_bend + E_torsion | |
| // Parameters: UFF/MMFF94-like (heavy atoms only; H implicit). | |
| // ═══════════════════════════════════════════════════════════════ | |
| let strainVisible = false; | |
| // ── Vector math helpers ────────────────────────────────────── | |
| function vec3(A, B) { return { x:B.x-A.x, y:B.y-A.y, z:B.z-A.z }; } | |
| function cross3(a, b) { return { x:a.y*b.z-a.z*b.y, y:a.z*b.x-a.x*b.z, z:a.x*b.y-a.y*b.x }; } | |
| function dot3v(a, b) { return a.x*b.x + a.y*b.y + a.z*b.z; } | |
| function len3(v) { return Math.sqrt(v.x*v.x + v.y*v.y + v.z*v.z); } | |
| function norm3(v) { const l = len3(v) || 1; return { x:v.x/l, y:v.y/l, z:v.z/l }; } | |
| // Angle at B in A–B–C (radians) | |
| function angleABC(A, B, C) { | |
| const u = vec3(B, A), w = vec3(B, C); | |
| const d = dot3v(u, w) / ((len3(u) * len3(w)) || 1); | |
| return Math.acos(Math.max(-1, Math.min(1, d))); | |
| } | |
| // Signed dihedral A–B–C–D (radians, Praxitelou atan2 method) | |
| function dihedralAngle(A, B, C, D) { | |
| const b1 = vec3(A, B), b2 = vec3(B, C), b3 = vec3(C, D); | |
| const n1 = cross3(b1, b2); | |
| const n2 = cross3(b2, b3); | |
| const m = cross3(n1, norm3(b2)); | |
| return Math.atan2(dot3v(m, n2), dot3v(n1, n2)); | |
| } | |
| // ── Parameters ─────────────────────────────────────────────── | |
| const STRAIN_COV_RAD = { | |
| C:0.77, N:0.75, O:0.73, S:1.02, F:0.71, Cl:0.99, Br:1.14, I:1.33, P:1.06, B:0.87 | |
| }; | |
| // Ideal single-bond lengths (Å); key = sorted element pair | |
| const IDEAL_SINGLE_LEN = { | |
| CC:1.540, CN:1.469, CO:1.411, CS:1.812, CF:1.353, | |
| CCl:1.739, CBr:1.944, CI:2.141, CP:1.833, CB:1.570, | |
| NN:1.450, NO:1.400, NS:1.672, OO:1.480, OS:1.657, | |
| SS:2.038, PP:2.210, PS:1.960, NP:1.685, OP:1.640, | |
| }; | |
| const K_BOND = 300; // kcal mol⁻¹ Å⁻² (bond-stretch spring) | |
| const K_ANGLE = 70; // kcal mol⁻¹ rad⁻² (angle-bending spring) | |
| // Torsion barriers: CSD-derived, classified per bond by classifyTorsionCSD(). | |
| // Source: CSD-based Conformation cheat sheet (parts 1–6) by @GOuvry (CCDC). | |
| // ── Steric terms: 1,4 LJ + 1,5 ortho steric ───────────────────────────── | |
| // 1,4 (SCALE 0.5): standard MMFF94 LJ for terminal A and D of the dihedral A–B–C–D. | |
| // 1,5 (SCALE 1.0): for aryl–heteroatom bonds the ring ortho-C atoms AND their | |
| // non-ring substituents (Cl, F, Me, CF3 …) also have steric interactions with | |
| // the D atoms. These 1,5 pairs use full vdW weight (MMFF94 standard). | |
| // This captures e.g. an ortho-Cl clashing with an O-methyl in the docked pose — | |
| // the dominant ortho steric penalty — which a simple V-reduction cannot model. | |
| const VDW_R_STRAIN = { // van der Waals radii (Å) | |
| C:1.70, N:1.55, O:1.52, S:1.80, | |
| F:1.47, Cl:1.75, Br:1.85, I:1.98, P:1.80, | |
| }; | |
| const VDW_EPS_14 = 0.15; // kcal mol⁻¹ well depth (simplified, same for all pairs) | |
| const SCALE_14 = 0.5; // MMFF94 standard 1,4 scaling factor | |
| function lj14(atomA, atomD) { | |
| const r = dist3(atomA, atomD); | |
| const rMin = (VDW_R_STRAIN[atomA.elem] || 1.70) + | |
| (VDW_R_STRAIN[atomD.elem] || 1.70); | |
| const ratio = rMin / Math.max(r, 0.5); // clamp to avoid singularity | |
| return SCALE_14 * VDW_EPS_14 * (Math.pow(ratio, 12) - 2 * Math.pow(ratio, 6)); | |
| } | |
| // 1,5 LJ: same formula but SCALE_15 = 1.0 (no halving — MMFF94 standard). | |
| function lj15(atomA, atomD) { | |
| const r = dist3(atomA, atomD); | |
| const rMin = (VDW_R_STRAIN[atomA.elem] || 1.70) + | |
| (VDW_R_STRAIN[atomD.elem] || 1.70); | |
| const ratio = rMin / Math.max(r, 0.5); | |
| return VDW_EPS_14 * (Math.pow(ratio, 12) - 2 * Math.pow(ratio, 6)); // SCALE_15 = 1.0 | |
| } | |
| // ── CSD-based torsion classifier ───────────────────────────── | |
| // Returns { V (kcal/mol), n (periodicity), phase (rad), label } | |
| // Unified energy formula: E = V/2 × (1 − cos(n·φ − phase)) | |
| // phase=0, n=2 → minima at 0°/180° (amide, ester, aryl-ether, aryl-NH, benzyl-CHR) | |
| // phase=π/4, n=2 → minimum at 22.5° (aryl-2-pyridyl — C-H···N planar pull) | |
| // phase=π/2, n=2 → minimum at 45° (biaryl — CSD twisted H/H preference) | |
| // phase=5π/6,n=2 → minimum at 75° (biaryl(bis-ortho) — steric forces near-perp.) | |
| // phase=π, n=1 → minimum at 180° only (mono-ortho aryl-ether/NH/S and bipyridyl anti) | |
| // phase=π, n=2 → minima at ±90° (bis-ortho/OCF3/sulfonamide/sulfone/benzyl-CH2) | |
| // phase=π, n=3 → minima at 60°/180°/300° (sp3–sp3 staggered) | |
| // phase=π, n=4 → minima at ±45°/±135° (aryl-NR2 mono-ortho) | |
| // | |
| // CSD evidence (CCDC CSD-Based Conformations Guide A2 poster, all pages): | |
| // Amide C(=O)–N : 3330 ex → sharp 0°/180° → V=12 n=2 phase=0 | |
| // Ester C(=O)–O : 2500 ex → sharp Z at 0° → V= 8 n=2 phase=0 | |
| // Aryl-ether Ar–O–R : 77714 ex → δ-spike at 0° → V=10 n=2 phase=0 | |
| // (1 ortho) : 89178 ex → sharp anti at 180° → V= 8 n=1 phase=π | |
| // (2 ortho) : 28084 ex → peak ~90° (perp) → V= 4 n=2 phase=π | |
| // Ar–O–CF₃ : 536 ex → peak ~90° (perp) → V= 4 n=2 phase=π | |
| // Phenol ester ArO–CO : 3227 ex → peak ~70° → V= 4 n=2 phase=2π/3 | |
| // Aryl-NH Ar–NH–R : 28327 ex → δ-spike at 0° → V= 8 n=2 phase=0 | |
| // (1 ortho) : 18904 ex → sharp anti at 180° → V= 6 n=1 phase=π | |
| // (2 ortho) : 3825 ex → peak ~90° (perp) → V= 3 n=2 phase=π | |
| // Ar–NH–SO₂R : 824 ex → peak ~45° → V= 3 n=2 phase=π/2 | |
| // Aryl-NR2 Ar–NR₂ : 17964 ex → broad, 0° preferred → V= 3 n=2 phase=0 | |
| // (1 ortho) : 3597 ex → broad ±45°/±135° → V= 3 n=4 phase=π | |
| // (2 ortho) : 943 ex → peak ~90° (perp) → V= 3 n=2 phase=π | |
| // Ar–S–R thioether : 5929 ex → δ-spike at 0° → V= 7 n=2 phase=0 | |
| // (1 ortho) : 2118 ex → sharp anti at 180° → V= 7 n=1 phase=π | |
| // (2 ortho) : 635 ex → peak ~90° (perp) → V= 4 n=2 phase=π | |
| // Ar–SO₂–R sulfone : 32517 ex → peak ~90° (perp, elec.) → V= 5 n=2 phase=π | |
| // (1 ortho) : 2435 ex → peak ~65-70° → V= 4 n=2 phase=π | |
| // Ar–S(O)–R sulfoxide : 1873 ex → peak ~90° (perp) → V= 5 n=2 phase=π | |
| // Aryl–carbonyl : 4200 ex → peak ~35° (soft, min at 0°)→ V= 5 n=2 phase=0 | |
| // (2 ortho) : 706 ex → peak ~85° → V= 4 n=2 phase=π | |
| // Biaryl Ar–Ar : 24289 ex → peak ~40-45° (H/H twist) → V= 3 n=2 phase=π/2 | |
| // Biaryl Ar–Ar : 24289 ex → peak ~40-45° (H/H twist) → V= 3 n=2 phase=π/2 | |
| // bis-ortho : 12092 ex → peak ~70-75° (steric) → V= 4 n=2 phase=5π/6 | |
| // Bipyridyl 2-Py–2-Py : 3846 ex → sharp anti at 180° → V= 5 n=1 phase=π | |
| // Aryl-2-pyridyl : 2138 ex → peak ~20-30° (C-H···N) → V= 3 n=2 phase=π/4 | |
| // Benzyl-CH2 Ar–CH₂–R : 36720 ex → peak ~90° (perp) → V= 2 n=2 phase=π | |
| // bis-ortho : 5709 ex → sharp 90° → V= 3 n=2 phase=π | |
| // Benzyl-CHR Ar–CHR₂ : 22659 ex → peak 0° (H in ring plane) → V= 2 n=2 phase=0 | |
| // Benzyl-CR2 Ar–CR₃ : 90296 ex → peak ~65-90° → V=1.5 n=2 phase=π | |
| // Alkyl sp3–sp3 : 3-fold, anti preferred → V=1.5 n=3 phase=π | |
| function classifyTorsionCSD(atoms, i, j, adj) { | |
| // ── How this function works ─────────────────────────────────────────────── | |
| // Given a single bond i–j, it returns a torsion potential descriptor: | |
| // { V : barrier height (kcal/mol) | |
| // n : periodicity (fold) of the cosine | |
| // phase : phase offset (rad) that places the *minimum* at φ_min = phase/n | |
| // label : human-readable fragment name for the UI strain panel } | |
| // | |
| // Energy formula applied by the caller: | |
| // E_torsion = V/2 × (1 − cos(n·φ − phase)) | |
| // where φ is the observed A–B–C–D dihedral angle. | |
| // The expression equals 0 at the energy minimum (low-strain conformation) | |
| // and equals V at the energy maximum (highest-strain conformation). | |
| // | |
| // Decision tree (tested in priority order; first match wins): | |
| // 1. C–N → amide / thioamide / aryl-NH / aryl-NR2 (by N substitution & ortho count) | |
| // 2. C–O → ester / aryl-ether / OCF3 / phenol-ester (by ortho & tail group) | |
| // 2.5 C–S → aryl-SO2 / aryl-S (by oxygen count on S, then ortho count) | |
| // 3. C–C → aryl-carbonyl / biaryl family / benzylic / sp3–sp3 / conjugated | |
| // 4–6. Other sp2–sp2, sp3–sp3, misc fallback | |
| // ───────────────────────────────────────────────────────────────────────── | |
| const ei = atoms[i].elem, ej = atoms[j].elem; | |
| // Detect carbonyl C: has an O neighbour with d < 1.30 Å (C=O ≈ 1.22 Å; C–O ≈ 1.43 Å) | |
| const hasCarbonyl = k => adj[k].some(m => atoms[m].elem === 'O' && | |
| dist3(atoms[k], atoms[m]) < 1.30); | |
| // Detect thiocarbonyl C: has an S neighbour with d < 1.65 Å (C=S ≈ 1.56 Å; C–S ≈ 1.81 Å) | |
| const hasThiocarbonyl = k => adj[k].some(m => atoms[m].elem === 'S' && | |
| dist3(atoms[k], atoms[m]) < 1.65); | |
| // Aromatic ring C: has any neighbour (≠ skip) with bond d < 1.45 Å (ring bond ≈ 1.39 Å). | |
| // Distinguishes true aromatic C from sp3 C that happens to have 3 heavy substituents. | |
| const isAromatic = (k, skip) => adj[k].some(m => m !== skip && | |
| dist3(atoms[k], atoms[m]) < 1.45); | |
| // Count ortho-substituted ring carbons adjacent to ci (excluding the heteroatom bond). | |
| // An ortho-C is "substituted" when adj.length ≥ 3 (2 ring bonds + ≥1 heavy substituent). | |
| // In heavy-atom model, an unsubstituted ring C has only 2 ring-C neighbours (no H). | |
| // Returns 0 (none), 1 (mono-ortho), or ≥2 (bis-ortho). | |
| const countOrthoSubs = (ci, hetIdx) => { | |
| const orthoCarbons = adj[ci].filter(m => m !== hetIdx && | |
| atoms[m].elem === 'C' && | |
| dist3(atoms[ci], atoms[m]) < 1.45); | |
| return orthoCarbons.filter(oc => adj[oc].length >= 3).length; | |
| }; | |
| // ── 1. Amide / thioamide / aryl-amine: C–N bonds ─────────────────────── | |
| // CSD amide: 3300 examples, very sharp planarity (0° and 180°) | |
| if ((ei === 'C' && ej === 'N') || (ei === 'N' && ej === 'C')) { | |
| const ci = ei === 'C' ? i : j; | |
| const nIdx = ei === 'N' ? i : j; | |
| if (hasCarbonyl(ci)) return { V: 12.0, n: 2, phase: 0, label: 'amide' }; | |
| if (hasThiocarbonyl(ci)) return { V: 8.0, n: 2, phase: 0, label: 'thioamide' }; | |
| if (isAromatic(ci, nIdx)) { | |
| const nOrtho = countOrthoSubs(ci, nIdx); | |
| const nHeavy = adj[nIdx].length; // heavy-atom neighbours of N (H atoms absent in PDB) | |
| // In a PDB / heavy-atom-only model, H atoms are stripped, so: | |
| // Ar–NH–R (secondary amine) → N has 2 heavy neighbours (aryl-C + one alkyl R) → nHeavy = 2 | |
| // Ar–NR₂ (tertiary amine) → N has 3 heavy neighbours (aryl-C + two R groups) → nHeavy = 3 | |
| // This lets us distinguish the two substitution patterns without needing explicit H atoms. | |
| // Detect sulfonamide-type N: bonded to S (Ar–N–SO₂R has very different preference) | |
| const hasSonN = adj[nIdx].some(m => m !== ci && atoms[m].elem === 'S'); | |
| if (nHeavy <= 2) { | |
| // ── Secondary aryl-amine (Ar–NH–R) ────────────────────────────────── | |
| if (hasSonN) { | |
| // Ar–NH–SO₂R (N-phenyl sulfonamide, CSD 824 / 1123 examples): | |
| // no ortho: peak ~45° → n=2, phase=π/2 (min at ±45°) | |
| // ≥1 ortho: peak ~90° (perp) → n=2, phase=π (min at ±90°) | |
| if (nOrtho >= 1) return { V: 3.0, n: 2, phase: Math.PI, label: 'aryl-NH-SO2(ortho)' }; | |
| return { V: 3.0, n: 2, phase: Math.PI / 2, label: 'aryl-NH-SO2' }; | |
| } | |
| // Regular Ar–NH–alkyl (CSD: 28327 no-ortho; 18904 mono-ortho; 3825 bis-ortho): | |
| // no ortho: peak 0° (coplanar) → V=8, n=2, phase=0 | |
| // 1 ortho: peak 180° (anti) → V=6, n=1, phase=π (single anti min) | |
| // 2 ortho: peak ~90° (perpendicular) → V=3, n=2, phase=π (min at ±90°) | |
| if (nOrtho >= 2) return { V: 3.0, n: 2, phase: Math.PI, label: 'aryl-NH(bis-ortho)' }; | |
| if (nOrtho === 1) return { V: 6.0, n: 1, phase: Math.PI, label: 'aryl-NH(ortho)' }; | |
| return { V: 8.0, n: 2, phase: 0, label: 'aryl-NH' }; | |
| } else { | |
| // ── Tertiary aryl-amine (Ar–NR₂) ──────────────────────────────────── | |
| if (hasSonN) { | |
| // Ar–N(R)–SO₂R (CSD 324 examples): peak ~85-90° → n=2, phase=π | |
| return { V: 3.0, n: 2, phase: Math.PI, label: 'aryl-NR2-SO2' }; | |
| } | |
| // Regular Ar–NR₂ (CSD: 17964 no-ortho; 3597 mono-ortho; 943 bis-ortho): | |
| // no ortho: peak 0° (coplanar) → V=3, n=2, phase=0 | |
| // 1 ortho: broad ±45°/±135° (4-fold) → V=3, n=4, phase=π | |
| // 2 ortho: peak ~90° (perp) → V=3, n=2, phase=π | |
| if (nOrtho >= 2) return { V: 3.0, n: 2, phase: Math.PI, label: 'aryl-NR2(bis-ortho)' }; | |
| if (nOrtho === 1) return { V: 3.0, n: 4, phase: Math.PI, label: 'aryl-NR2(ortho)' }; | |
| return { V: 3.0, n: 2, phase: 0, label: 'aryl-NR2' }; | |
| } | |
| } | |
| } | |
| // ── 2. Ester / aryl-ether / phenol-ester / OCF₃: C–O bonds ───────────── | |
| // CSD ester: 2500 examples, sharp Z at 0° | |
| if ((ei === 'C' && ej === 'O') || (ei === 'O' && ej === 'C')) { | |
| const ci = ei === 'C' ? i : j; | |
| const oIdx = ei === 'O' ? i : j; | |
| if (hasCarbonyl(ci)) return { V: 8.0, n: 2, phase: 0, label: 'ester' }; | |
| if (isAromatic(ci, oIdx)) { | |
| // Ar–O–CF₃ (trifluoromethoxy): O bound to a CF₃ group (C with ≥3 F neighbours) | |
| // CSD: 536 examples, peak ~90° (perp) — very different from alkyl aryl ethers | |
| const isOCF3 = adj[oIdx].some(m => m !== ci && | |
| atoms[m].elem === 'C' && | |
| adj[m].filter(x => atoms[x].elem === 'F').length >= 3); | |
| if (isOCF3) return { V: 4.0, n: 2, phase: Math.PI, label: 'aryl-OCF3' }; | |
| // Phenol ester (Ar–O–C=O): O also bound to a carbonyl C | |
| // CSD: 3227 examples, peak ~70° — conjugation partially broken by ester C=O | |
| const isPhenolicEster = adj[oIdx].some(m => m !== ci && hasCarbonyl(m)); | |
| if (isPhenolicEster) return { V: 4.0, n: 2, phase: 2 * Math.PI / 3, label: 'phenol-ester' }; | |
| // Regular aryl-ether Ar–O–alkyl (CSD: 77714 no-ortho; 89178 mono; 28084 bis): | |
| // no ortho: CSD δ-spike at 0° (coplanar syn) → V=10, n=2, phase=0 | |
| // 1 ortho: CSD sharp anti peak at 180° → V=8, n=1, phase=π | |
| // 2 ortho: CSD peak ~90° (perpendicular) → V=4, n=2, phase=π | |
| const nOrtho = countOrthoSubs(ci, oIdx); | |
| if (nOrtho >= 2) return { V: 4.0, n: 2, phase: Math.PI, label: 'aryl-ether(bis-ortho)' }; | |
| if (nOrtho === 1) return { V: 8.0, n: 1, phase: Math.PI, label: 'aryl-ether(ortho)' }; | |
| return { V: 10.0, n: 2, phase: 0, label: 'aryl-ether' }; | |
| } | |
| } | |
| // ── 2.5. Aryl–sulfur (C–S bonds: thioethers, sulfoxides, sulfones) ────── | |
| // Thioether Ar–S–R: 5929/2118/635 ex → δ-spike 0° / anti 180° / perp 90° | |
| // Sulfone Ar–SO₂–R: 32517/2435 ex → perp ~90°/~65° (electronic, not steric) | |
| // Sulfoxide Ar–S(O)–R: 1873 ex → perp ~90° | |
| // Key: count oxygens on S → 0=thioether, ≥1=sulfone/sulfoxide (both prefer ⊥) | |
| if ((ei === 'C' && ej === 'S') || (ei === 'S' && ej === 'C')) { | |
| const ci = ei === 'C' ? i : j; | |
| const sIdx = ei === 'S' ? i : j; | |
| if (isAromatic(ci, sIdx)) { | |
| const nOrtho = countOrthoSubs(ci, sIdx); | |
| // Count oxygens on S (excluding the aryl C): 0=thioether, 1=sulfoxide, ≥2=sulfone | |
| const sOxygens = adj[sIdx].filter(m => m !== ci && atoms[m].elem === 'O').length; | |
| if (sOxygens >= 1) { | |
| // Aryl-sulfone/sulfoxide: perpendicular preference is ELECTRONIC, not steric. | |
| // The S=O σ* acceptor orbital aligns best with the aryl π system at 90°. | |
| // This is opposite to thioether Ar–S–R which prefers coplanar (0°/180°) because | |
| // the S lone pair can donate into the π system when coplanar. | |
| // CSD peak ~90° for no-ortho (32517+1873 ex), ~65-70° for mono-ortho (2435 ex). | |
| if (nOrtho >= 2) return { V: 4.0, n: 2, phase: Math.PI, label: 'aryl-SO2(bis-ortho)' }; | |
| if (nOrtho === 1) return { V: 4.0, n: 2, phase: Math.PI, label: 'aryl-SO2(ortho)' }; | |
| return { V: 5.0, n: 2, phase: Math.PI, label: 'aryl-SO2' }; | |
| } | |
| // Thioether Ar–S–R: CSD δ-spike at 0° / anti 180° / perp 90° | |
| if (nOrtho >= 2) return { V: 4.0, n: 2, phase: Math.PI, label: 'aryl-S(bis-ortho)' }; | |
| if (nOrtho === 1) return { V: 7.0, n: 1, phase: Math.PI, label: 'aryl-S(ortho)' }; | |
| return { V: 7.0, n: 2, phase: 0, label: 'aryl-S' }; | |
| } | |
| } | |
| // ── 3. C–C bonds: aromatic vs sp3 confirmed by short ring bonds ────── | |
| if (ei === 'C' && ej === 'C') { | |
| const ni = adj[i].length, nj = adj[j].length; | |
| const iArom = isAromatic(i, j); // ring C has neighbours at ~1.39 Å | |
| const jArom = isAromatic(j, i); | |
| // Aryl–carbonyl (CSD: ~4200 no/mono-ortho peak ~35° soft; 706 bis-ortho peak ~85°) | |
| if ((ni === 3 && iArom && hasCarbonyl(j)) || | |
| (nj === 3 && jArom && hasCarbonyl(i))) { | |
| const arylIdx = (ni === 3 && iArom) ? i : j; | |
| const carbIdx = (ni === 3 && iArom) ? j : i; | |
| const nOrtho = countOrthoSubs(arylIdx, carbIdx); | |
| // bis-ortho: CSD peak ~85° → n=2, phase=π (min at ±90°) | |
| if (nOrtho >= 2) return { V: 4.0, n: 2, phase: Math.PI, label: 'aryl-carbonyl(bis-ortho)' }; | |
| // no/mono-ortho: soft potential, conjugation min at 0° — CSD peak ~35° = thermal broadening | |
| return { V: 5.0, n: 2, phase: 0, label: 'aryl-carbonyl' }; | |
| } | |
| // Biaryl family (ni=3, nj=3, both aromatic C) — sub-cases by substitution / heteroatom | |
| if (ni === 3 && nj === 3 && iArom && jArom) { | |
| // (a) Bis-ortho-substituted biaryl: CSD 12092 ex, peak ~70-75° (steric → near-perp.) | |
| // phase=5π/6 → min at 75°; checked first — steric effect dominates | |
| const nOrthoBI = countOrthoSubs(i, j); | |
| const nOrthoBJ = countOrthoSubs(j, i); | |
| if (nOrthoBI >= 2 || nOrthoBJ >= 2) | |
| return { V: 4.0, n: 2, phase: Math.PI * 5 / 6, label: 'biaryl(bis-ortho)' }; | |
| // (b) 2-Pyridyl detection: is either biaryl bond-carbon at the 2-position of a pyridine? | |
| // | |
| // A ring-C at the 2-position of pyridine has the ring nitrogen N1 as a direct | |
| // ring-neighbour (C2–N1 bond distance ≈ 1.34 Å < 1.45 Å threshold). | |
| // The 3-, 4-, 5-, 6-positions of pyridine do NOT have N as a direct ring-neighbour | |
| // (the nearest N is two bonds away, d ≈ 2.4 Å). | |
| // | |
| // Why does 2-pyridyl matter? An intramolecular C-H···N hydrogen bond pulls the | |
| // biaryl toward planar (0°), while H/H steric pushes it away, giving a CSD peak | |
| // at ~20-30° — shallower twist than standard biphenyl's 40-45°. | |
| const adj2PyI = adj[i].some(m => m !== j && atoms[m].elem === 'N' && | |
| dist3(atoms[i], atoms[m]) < 1.45); | |
| const adj2PyJ = adj[j].some(m => m !== i && atoms[m].elem === 'N' && | |
| dist3(atoms[j], atoms[m]) < 1.45); | |
| // 2,2'-Bipyridyl (both rings are 2-pyridyl): CSD 3846 ex, VERY sharp anti 180° spike | |
| // N···N repulsion makes syn highly unfavourable → n=1, phase=π (single anti minimum) | |
| if (adj2PyI && adj2PyJ) | |
| return { V: 5.0, n: 1, phase: Math.PI, label: 'bipyridyl' }; | |
| // Single 2-pyridyl: CSD 2138 ex, peak ~20-30° (C-H···N attraction pulls toward planar) | |
| // phase=π/4 → min at 22.5° — between planar (0°) and standard biaryl (45°) | |
| if (adj2PyI || adj2PyJ) | |
| return { V: 3.0, n: 2, phase: Math.PI / 4, label: 'aryl-2-pyridyl' }; | |
| // (c) Standard biphenyl: CSD 24289 ex, peak ~40-45° (H/H steric push away from 0°) | |
| return { V: 3.0, n: 2, phase: Math.PI / 2, label: 'biaryl' }; | |
| } | |
| // Both sp2-like but not 3-connected ring C (vinyl-vinyl, enamine, etc.) | |
| if (iArom && jArom) { | |
| return { V: 5.0, n: 2, phase: 0, label: 'conjugated' }; | |
| } | |
| // For non-aromatic C–C: use short bonds to other neighbours to detect sp2 character. | |
| // sp2 conjugated vinyl bonds: ~1.34–1.46 Å; sp3 C–C bonds: ~1.52–1.54 Å. | |
| const hasSp2Char = (k, skip) => adj[k].some(m => m !== skip && | |
| dist3(atoms[k], atoms[m]) < 1.48); | |
| if (!iArom && !jArom) { | |
| // Conjugated sp2–sp2 (vinyl-vinyl, enone): sp2 shown by short neighbour bonds | |
| if (hasSp2Char(i, j) || hasSp2Char(j, i)) { | |
| return { V: 5.0, n: 2, phase: 0, label: 'conjugated' }; | |
| } | |
| // Pure sp3–sp3: all neighbour bonds are long (~1.54 Å) | |
| return { V: 1.5, n: 3, phase: Math.PI, label: 'sp3–sp3' }; | |
| } | |
| // sp2–sp3: Ar–(sp3 C) benzylic bonds — behaviour depends on degree of the sp3 carbon | |
| // CSD evidence (poster pages 34-46): | |
| // Ar–CH2–R (deg=2, primary sp3): peak 90° → n=2, phase=π, V=2.0 (36720+11226+5709 ex) | |
| // Ar–CHR2 (deg=3, secondary sp3): peak 0° → n=2, phase=0, V=2.0 (22659+151196 ex) | |
| // Ar–CR3 (deg≥4, tertiary sp3): peak ~90°→ n=2, phase=π, V=1.5 (90296 ex) | |
| // Cycloalkyls w/ H at attachment (deg=3): peak 0° ✓; without H (deg≥4): peak 90° ✓ | |
| // Benzyl–OH (Ar–CH2–OH, deg=2): peak 90° ✓ (16440 ex) | |
| const arBIdx = iArom ? i : j; // aromatic ring carbon (B side of Ar–Csp3 bond) | |
| const spBIdx = iArom ? j : i; // sp3 benzylic carbon (whose degree we classify) | |
| const degB = adj[spBIdx].length; // heavy-atom degree of sp3 carbon | |
| // Because PDB coordinates contain no explicit H atoms, the heavy-atom degree tells us | |
| // how many H atoms the sp3 carbon carries: | |
| // degB = 2 → Ar–CH₂–R (primary, one R group) — 2 H implicit | |
| // degB = 3 → Ar–CHR–R' (secondary, two R groups) — 1 H implicit | |
| // degB ≥ 4 → Ar–CR₂–R' (tertiary, three R groups)— 0 H implicit | |
| // This H-count determines the torsional preference (H eclipsing the ring plane = min). | |
| const nOrthoB = countOrthoSubs(arBIdx, spBIdx); | |
| if (degB <= 2) { | |
| // Primary benzylic CH2 (includes CH2-OH, CH2-halide): perpendicular preference | |
| if (nOrthoB >= 2) return { V: 3.0, n: 2, phase: Math.PI, label: 'benzyl(bis-ortho)' }; | |
| return { V: 2.0, n: 2, phase: Math.PI, label: 'benzyl-CH2' }; | |
| } | |
| if (degB === 3) { | |
| // Secondary benzylic CHR2 (phenyl-CHR2, cyclopentyl, cyclohexyl, cyclopropyl w/ H): | |
| // H eclipses ring plane (0°) → phase=0 (minima at 0°/180°) | |
| return { V: 2.0, n: 2, phase: 0, label: 'benzyl-CHR' }; | |
| } | |
| // Tertiary sp3 (deg≥4, no H): steric push to perpendicular (~65-90°) | |
| return { V: 1.5, n: 2, phase: Math.PI, label: 'benzyl-CR2' }; | |
| } | |
| // ── 4. Other sp2–sp2 (N=N, C=N–X, heterocycle linkages) ──────────── | |
| if (adj[i].length <= 3 && adj[j].length <= 3) { | |
| return { V: 3.0, n: 2, phase: 0, label: 'sp2–sp2' }; | |
| } | |
| // ── 5. sp3–sp3: alkyl chain/ring ──────────────────────────────────── | |
| // CSD part 6/6: anti (180°) preferred, gauche (±60°) secondary | |
| if (adj[i].length >= 3 && adj[j].length >= 3) { | |
| return { V: 1.5, n: 3, phase: Math.PI, label: 'sp3–sp3' }; | |
| } | |
| // ── 6. Fallback: non-C heteroatom sp2–sp3 (e.g. allylic N, vinyl-S, etc.) ── | |
| // Ar–C benzylic bonds are fully handled in section 3 above. | |
| // This catches rare mixed bonds not covered above → nearly flat default. | |
| return { V: 0.3, n: 6, phase: 0, label: 'sp2–sp3' }; | |
| } | |
| // ── Bond topology (heavy atoms only) ───────────────────────── | |
| function inferLigBonds(atoms) { | |
| const bonds = [], adj = atoms.map(() => []); | |
| for (let i = 0; i < atoms.length; i++) { | |
| for (let j = i+1; j < atoms.length; j++) { | |
| const d = dist3(atoms[i], atoms[j]); | |
| const maxD = ((STRAIN_COV_RAD[atoms[i].elem]||0.90) + | |
| (STRAIN_COV_RAD[atoms[j].elem]||0.90)) * 1.25; | |
| if (d > 0.5 && d <= maxD) { | |
| bonds.push({ i, j, d }); | |
| adj[i].push(j); adj[j].push(i); | |
| } | |
| } | |
| } | |
| return { bonds, adj }; | |
| } | |
| // ── Ring bond detection (DFS: is there an alternate path?) ─── | |
| function detectRingBondsSet(nAtoms, bonds, adj) { | |
| const ring = new Set(); | |
| for (const { i, j } of bonds) { | |
| const visited = new Uint8Array(nAtoms); | |
| visited[j] = 1; | |
| const stack = adj[j].filter(x => x !== i); | |
| let found = false; | |
| while (stack.length && !found) { | |
| const node = stack.pop(); | |
| if (node === i) { found = true; break; } | |
| if (visited[node]) continue; | |
| visited[node] = 1; | |
| for (const nb of adj[node]) if (!visited[nb]) stack.push(nb); | |
| } | |
| if (found) ring.add(`${Math.min(i,j)},${Math.max(i,j)}`); | |
| } | |
| return ring; | |
| } | |
| // ── Main calculation ───────────────────────────────────────── | |
| function computeStrainEnergy(ligAtoms) { | |
| if (!ligAtoms || ligAtoms.length < 3) return null; | |
| const { bonds, adj } = inferLigBonds(ligAtoms); | |
| const ringSet = detectRingBondsSet(ligAtoms.length, bonds, adj); | |
| // Hybridization: sp3 if ≥4 heavy neighbors, else sp2 | |
| const hyb = adj.map(nb => nb.length >= 4 ? 'sp3' : 'sp2'); | |
| // 1. Bond-stretch energy | |
| let eBond = 0; | |
| for (const { i, j, d } of bonds) { | |
| const key = [ligAtoms[i].elem, ligAtoms[j].elem].sort().join(''); | |
| const r0s = IDEAL_SINGLE_LEN[key]; if (!r0s) continue; | |
| // If observed distance >0.08 Å shorter than single → double bond → r0 ≈ 0.86×r0s | |
| const r0 = (d < r0s - 0.08) ? r0s * 0.86 : r0s; | |
| eBond += 0.5 * K_BOND * (d - r0) ** 2; | |
| } | |
| // 2. Angle-bending energy | |
| let eAngle = 0; | |
| for (let b = 0; b < ligAtoms.length; b++) { | |
| const nb = adj[b]; if (nb.length < 2) continue; | |
| const theta0 = hyb[b] === 'sp3' ? 109.5 * Math.PI/180 : 120.0 * Math.PI/180; | |
| for (let p = 0; p < nb.length; p++) { | |
| for (let q = p+1; q < nb.length; q++) { | |
| const theta = angleABC(ligAtoms[nb[p]], ligAtoms[b], ligAtoms[nb[q]]); | |
| eAngle += 0.5 * K_ANGLE * (theta - theta0) ** 2; | |
| } | |
| } | |
| } | |
| // 3. Torsional energy + 1,4 LJ steric term (rotatable non-ring bonds) | |
| // For each A–B–C–D dihedral: E = E_cosine(φ) + E_LJ(A,D) | |
| // The LJ term captures how substituent size raises the gauche/eclipsed barrier. | |
| let eTorsion = 0; | |
| const bondDetails = []; // per-bond data for 3D strain highlight | |
| for (const { i, j, d } of bonds) { | |
| // Skip ring bonds: the dihedral of a ring bond (e.g. a benzene C–C or cyclohexane C–C) | |
| // is NOT an independent rotatable degree of freedom — it is rigidly fixed by the ring | |
| // geometry (all other ring bonds must bend/stretch simultaneously if it changes). | |
| // Ring strain is captured by the bond-stretch (eBond) and angle-bending (eAngle) terms. | |
| if (ringSet.has(`${Math.min(i,j)},${Math.max(i,j)}`)) continue; | |
| // Skip terminal atoms (degree < 2): a true rotatable dihedral A–B–C–D requires B and C | |
| // each to have at least one other neighbour to define A and D. Degree-1 atoms are | |
| // chain ends (e.g. a halide, a carbonyl O) and cannot be the central bond of a torsion. | |
| if (adj[i].length < 2 || adj[j].length < 2) continue; | |
| const key = [ligAtoms[i].elem, ligAtoms[j].elem].sort().join(''); | |
| const r0s = IDEAL_SINGLE_LEN[key]; | |
| // Skip double bonds: a bond whose observed length is >0.08 Å shorter than the ideal | |
| // single-bond length is treated as a double bond (e.g. C=O ≈ 1.22 Å vs C–O ≈ 1.43 Å). | |
| // Double bonds have very high rotation barriers (~60 kcal/mol) and are effectively rigid; | |
| // their out-of-plane strain is already captured by the bond-stretch and angle terms. | |
| if (r0s && d < r0s - 0.08) continue; | |
| // CSD-derived torsion potential (replaces generic sp2/sp3 switch) | |
| // Classify FIRST so biaryl-family bonds can use ring-C as terminal atoms | |
| const tc = classifyTorsionCSD(ligAtoms, i, j, adj); | |
| // For biaryl-family bonds, prefer ring-C as terminal atoms A and D. | |
| // Without this, pyridine N1 (lower index) is picked instead of ring-C C3, | |
| // giving dihedral A–Cph–Cpy–N1 ≈ 0° even when the ring twist is e.g. 119°. | |
| const isBiarylFam = ['biaryl','bipyridyl','aryl-2-pyridyl','biaryl(bis-ortho)'] | |
| .includes(tc.label); | |
| const pickTerm = (center, excl) => { | |
| if (isBiarylFam) { | |
| const rc = adj[center].find(x => x !== excl && ligAtoms[x].elem === 'C' && | |
| dist3(ligAtoms[center], ligAtoms[x]) < 1.45); | |
| if (rc !== undefined) return rc; | |
| } | |
| return adj[center].find(x => x !== excl); | |
| }; | |
| const a = pickTerm(i, j); // terminal atom A (on the i side of the central bond) | |
| const e = pickTerm(j, i); // terminal atom D (on the j side of the central bond) | |
| if (a === undefined || e === undefined) continue; | |
| // φ = dihedral angle of the four-atom sequence A–B–C–D where: | |
| // A = atom a (terminal on the i side — chosen by pickTerm) | |
| // B = atom i (left central atom, e.g. aryl-C for an aryl–heteroatom bond) | |
| // C = atom j (right central atom, e.g. O, N, S, or second ring-C) | |
| // D = atom e (terminal on the j side — chosen by pickTerm) | |
| // φ = 0° when A and D are syn (same side, eclipsed); φ = 180° when anti (opposite). | |
| const phi = dihedralAngle(ligAtoms[a], ligAtoms[i], ligAtoms[j], ligAtoms[e]); | |
| // ── Unified cosine torsion formula ───────────────────────────────────── | |
| // E = V/2 × (1 − cos(n·φ − phase)) | |
| // • V = barrier height: energy difference between max and min (kcal/mol) | |
| // • n = fold: number of maxima per 360° rotation | |
| // • phase = shifts the minimum to φ_min = phase/n (e.g. phase=π, n=2 → min at 90°) | |
| // • Result is 0 at the energy minimum and V at the maximum. | |
| // phase=0, n=2 → minima at 0°/180° (amide, ester, aryl-ether, aryl-NH, benzyl-CHR) | |
| // phase=π/4, n=2 → minimum at 22.5° (aryl-2-pyridyl — C-H···N pull toward planar) | |
| // phase=π/2, n=2 → minimum at 45° (biaryl — CSD 24k structures, twisted prefer.) | |
| // phase=5π/6, n=2 → minimum at 75° (biaryl(bis-ortho) — steric near-perpendicular) | |
| // phase=π, n=1 → minimum at 180° only (mono-ortho aryl-ether/NH/S; 2,2'-bipyridyl) | |
| // phase=π, n=2 → minima at ±90° (bis-ortho/OCF3/sulfonamide/sulfone/benzyl-CH2) | |
| // phase=π, n=3 → minima at 60°/180°/300° (sp3–sp3 staggered) | |
| // phase=π, n=4 → minima at ±45°/±135° (aryl-NR2 mono-ortho) | |
| // eCos: the pure cosine torsion energy for this bond at the observed dihedral φ. | |
| // High when φ is far from φ_min (high-strain conformation); zero at φ_min. | |
| const eCos = 0.5 * tc.V * (1 - Math.cos(tc.n * phi - tc.phase)); | |
| // eLJ: 1,4 Lennard-Jones interaction between the two terminal atoms A (=a) and D (=e). | |
| // Captures how the size of those substituents raises the eclipsing barrier beyond | |
| // what the cosine term alone encodes. MMFF94 SCALE_14 = 0.5 (half weight vs full vdW). | |
| // Positive = steric clash (repulsive); negative = dispersion (attractive, small). | |
| const eLJ = lj14(ligAtoms[a], ligAtoms[e]); | |
| // ── Ortho steric: extra 1,4 + 1,5 pairs for aryl–heteroatom bonds ────── | |
| // The a↔e pair above covers only ONE ortho position. For aryl bonds we also | |
| // need: (a) the OTHER ortho-C ↔ each D atom (another 1,4 pair), and | |
| // (b) non-ring substituents on every ortho-C ↔ each D atom (1,5 pairs, | |
| // full weight). This is where an ortho-Cl vs O-methyl clash lives. | |
| let eOrtho = 0; | |
| // Skip eOrtho for bis-ortho bonds: their cosine term was calibrated from CSD data | |
| // which already encodes the perpendicular steric preference — adding 1,4/1,5 again | |
| // would double-count the steric contribution and over-penalise those geometries. | |
| if ((tc.label.startsWith('aryl-') || tc.label === 'biaryl') && | |
| !tc.label.includes('bis-ortho')) { | |
| // Both ortho-C positions on the aryl-C i (ring bonds identified by d < 1.45 Å) | |
| const orthoCs = adj[i].filter(x => x !== j && | |
| dist3(ligAtoms[i], ligAtoms[x]) < 1.45); | |
| // All D atoms on the heteroatom j (may be >1 for N-disubstituted) | |
| const dAtoms = adj[j].filter(x => x !== i); | |
| for (const oc of orthoCs) { | |
| for (const d of dAtoms) { | |
| if (oc === a && d === e) continue; // already counted in eLJ | |
| if (adj[oc].includes(d)) continue; // directly bonded — skip | |
| eOrtho += lj14(ligAtoms[oc], ligAtoms[d]); // 1,4 pair (SCALE 0.5) | |
| } | |
| // Non-ring substituents on this ortho-C (bond ≥ 1.45 Å = not aromatic/ring) | |
| const ocRingNbrs = new Set( | |
| adj[oc].filter(x => dist3(ligAtoms[oc], ligAtoms[x]) < 1.45) | |
| ); | |
| const orthoSubs = adj[oc].filter(x => !ocRingNbrs.has(x)); | |
| for (const os of orthoSubs) { | |
| for (const d of dAtoms) { | |
| if (adj[os].includes(d)) continue; // directly bonded — skip | |
| eOrtho += lj15(ligAtoms[os], ligAtoms[d]); // 1,5 pair (SCALE 1.0) | |
| } | |
| } | |
| } | |
| } | |
| eTorsion += eCos + eLJ + eOrtho; | |
| // Store per-bond data for the strain-highlight overlay | |
| bondDetails.push({ | |
| i, j, | |
| xi: ligAtoms[i].x, yi: ligAtoms[i].y, zi: ligAtoms[i].z, | |
| xj: ligAtoms[j].x, yj: ligAtoms[j].y, zj: ligAtoms[j].z, | |
| type: tc.label, | |
| phiDeg: phi * 180 / Math.PI, | |
| eTor: eCos, | |
| eLJ: eLJ + eOrtho, // combined: standard 1,4 + ortho 1,4/1,5 | |
| eTotal: eCos + eLJ + eOrtho, | |
| }); | |
| } | |
| return { | |
| eTotal: eBond + eAngle + eTorsion, | |
| eBond, eAngle, eTorsion, | |
| nAtoms: ligAtoms.length, nBonds: bonds.length, | |
| bondDetails, // ← per-bond details for highlightMaxStrainBond() | |
| }; | |
| } | |
| // ── Panel UI ───────────────────────────────────────────────── | |
| function updateStrainPanel() { | |
| if (!strainVisible || !viewer) return; | |
| const ligSel = getLigandSel(); | |
| if (!ligSel) { | |
| document.getElementById('strain-total').textContent = 'No ligand'; | |
| return; | |
| } | |
| const ligAtoms = viewer.selectedAtoms(ligSel); | |
| const r = computeStrainEnergy(ligAtoms); | |
| if (!r) { | |
| document.getElementById('strain-total').textContent = 'Too few atoms'; | |
| return; | |
| } | |
| // Color-code torsional strain: green < 3, amber 3–8, red > 8 kcal/mol | |
| const col = r.eTorsion < 3 ? '#1a7a1a' : r.eTorsion < 8 ? '#8a6a00' : '#aa2222'; | |
| document.getElementById('strain-total').textContent = `${r.eTorsion.toFixed(1)} kcal/mol`; | |
| document.getElementById('strain-total').style.color = col; | |
| document.getElementById('strain-meta').textContent = `${r.nAtoms} atoms · ${r.nBonds} bonds`; | |
| document.getElementById('strain-interp').textContent = | |
| r.eTorsion < 2 ? 'Low torsional strain — favorable bound geometry' : | |
| r.eTorsion < 5 ? 'Moderate torsional strain — typical for drug-like ligands' : | |
| r.eTorsion < 10 ? 'High torsional strain — notable conformational cost' : | |
| 'Very high torsional strain — binding imposes heavy penalty'; | |
| highlightMaxStrainBond(r.bondDetails); | |
| } | |
| // ── Strain bond highlight (highest-energy rotatable bond) ───── | |
| let strainHighlightShapes = []; | |
| let strainHighlightLabel = null; | |
| function clearStrainHighlight() { | |
| strainHighlightShapes.forEach(s => { try { viewer.removeShape(s); } catch(_){} }); | |
| strainHighlightShapes = []; | |
| if (strainHighlightLabel) { | |
| try { viewer.removeLabel(strainHighlightLabel); } catch(_) {} | |
| strainHighlightLabel = null; | |
| } | |
| } | |
| // Draw a coloured highlight cylinder + glow spheres + label on the | |
| // rotatable bond with the highest torsional strain. | |
| function highlightMaxStrainBond(bondDetails) { | |
| clearStrainHighlight(); | |
| if (!viewer || !bondDetails || bondDetails.length === 0) return; | |
| // Rank by torsional energy (pure conformational cost, before LJ) | |
| const worst = bondDetails.reduce((b, c) => c.eTor > b.eTor ? c : b); | |
| if (worst.eTor < 0.05) return; // nothing strained — skip overlay | |
| // Colour by severity: green → amber → red | |
| const col = worst.eTor < 2 ? '#27ae60' | |
| : worst.eTor < 5 ? '#e67e22' | |
| : '#e74c3c'; | |
| // Thick translucent cylinder over the central bond i–j | |
| strainHighlightShapes.push(viewer.addCylinder({ | |
| start: { x: worst.xi, y: worst.yi, z: worst.zi }, | |
| end: { x: worst.xj, y: worst.yj, z: worst.zj }, | |
| radius: 0.28, | |
| fromCap: 2, | |
| toCap: 2, | |
| color: col, | |
| opacity: 0.50, | |
| })); | |
| // Glow sphere at each end of the bond | |
| for (const [x, y, z] of [ | |
| [worst.xi, worst.yi, worst.zi], | |
| [worst.xj, worst.yj, worst.zj], | |
| ]) { | |
| strainHighlightShapes.push( | |
| viewer.addSphere({ center: { x, y, z }, radius: 0.33, color: col, opacity: 0.42 }) | |
| ); | |
| } | |
| // ── Leader line + label offset perpendicularly from the bond ────────── | |
| // Bond midpoint | |
| const mx = (worst.xi + worst.xj) / 2; | |
| const my = (worst.yi + worst.yj) / 2; | |
| const mz = (worst.zi + worst.zj) / 2; | |
| // Unit vector along the bond | |
| const bx = worst.xj - worst.xi; | |
| const by = worst.yj - worst.yi; | |
| const bz = worst.zj - worst.zi; | |
| const blen = Math.sqrt(bx*bx + by*by + bz*bz) || 1; | |
| // Cross bond with world-Y (0,1,0) to get a perpendicular direction. | |
| // If the bond is nearly vertical (|by/blen| ≥ 0.85) use world-Z instead. | |
| let px, py, pz; | |
| if (Math.abs(by / blen) < 0.85) { | |
| // bond × Y → (-bz, 0, bx) | |
| px = -bz; py = 0; pz = bx; | |
| } else { | |
| // bond × Z → (by, -bx, 0) | |
| px = by; py = -bx; pz = 0; | |
| } | |
| const plen = Math.sqrt(px*px + py*py + pz*pz) || 1; | |
| // Label anchor: 4.0 Å out from the bond midpoint along the perpendicular | |
| const OFFSET = 4.0; | |
| const lx = mx + (px / plen) * OFFSET; | |
| const ly = my + (py / plen) * OFFSET; | |
| const lz = mz + (pz / plen) * OFFSET; | |
| // Dashed leader line: midpoint → label anchor | |
| // 6 segments total → 3 visible dashes; thicker radius + full opacity for visibility | |
| const DASH_SEGS = 6; | |
| for (let k = 0; k < DASH_SEGS; k++) { | |
| if (k % 2 === 0) continue; // skip even segments → dashed appearance | |
| const t0 = k / DASH_SEGS, t1 = (k + 1) / DASH_SEGS; | |
| strainHighlightShapes.push(viewer.addCylinder({ | |
| start: { x: mx + (lx-mx)*t0, y: my + (ly-my)*t0, z: mz + (lz-mz)*t0 }, | |
| end: { x: mx + (lx-mx)*t1, y: my + (ly-my)*t1, z: mz + (lz-mz)*t1 }, | |
| radius: 0.09, fromCap: 1, toCap: 1, color: col, opacity: 0.95, | |
| })); | |
| } | |
| // Anchor dot at the bond midpoint (root of the leader) | |
| strainHighlightShapes.push( | |
| viewer.addSphere({ center: { x: mx, y: my, z: mz }, radius: 0.18, color: col, opacity: 1.0 }) | |
| ); | |
| // Label at the far end of the leader | |
| strainHighlightLabel = viewer.addLabel( | |
| `${worst.eTor.toFixed(1)} kcal/mol · ${worst.type} · φ=${worst.phiDeg.toFixed(0)}°`, | |
| { | |
| position: { x: lx, y: ly, z: lz }, | |
| fontSize: 13, | |
| fontColor: col, | |
| backgroundColor: 'white', | |
| backgroundOpacity: 0.92, | |
| borderThickness: 1.2, | |
| borderColor: col, | |
| alignment: 'center', | |
| } | |
| ); | |
| viewer.render(); // ← flush all added shapes/label to canvas | |
| } | |
| function toggleStrainPanel() { | |
| strainVisible = !strainVisible; | |
| document.getElementById('strain-panel').classList.toggle('visible', strainVisible); | |
| document.getElementById('btn-strain').classList.toggle('active', strainVisible); | |
| if (strainVisible) { | |
| updateStrainPanel(); | |
| } else { | |
| clearStrainHighlight(); | |
| viewer.render(); | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════ | |
| // ELECTROSTATIC REPULSION PANEL | |
| // | |
| // Mirrors the algorithm in electron_repulsion.py but runs directly in the | |
| // browser using atom positions and rule-based partial charges. | |
| // | |
| // ── Why this panel is needed ──────────────────────────────────────────────── | |
| // AutoDock Vina uses NO explicit Coulomb term — its scoring function is | |
| // purely steric (gauss1 / gauss2 / repulsion) + hydrophobic + H-bond + nrot. | |
| // Two negatively charged atoms (ligand O near Asp OD1) at 3.5 Å score zero | |
| // in Vina, but the true Coulomb repulsion is: | |
| // E = 332.06 × (−0.76)(−0.80) / (4 × 3.5) ≈ +18 kcal/mol | |
| // This panel makes that invisible penalty visible. | |
| // | |
| // ── Algorithm ─────────────────────────────────────────────────────────────── | |
| // For every (ligand atom, binding-site protein atom) pair within 5 Å: | |
| // 1. Assign partial charges using rule-based tables (no explicit PDB charges). | |
| // 2. Skip if either charge is zero (neutral C, H, P, metals). | |
| // 3. Skip if charge product ≤ 0 (opposite-sign pair = attraction, not repulsion). | |
| // 4. Apply exclusion rules for chemically misleading pairs (see below). | |
| // 5. Compute E = COULOMB_K × q1 × q2 / (ELEC_DIELECTRIC × r). | |
| // 6. Classify: mild (0–1 kcal/mol), moderate (1–5), severe (>5). | |
| // Display total energy + top-5 worst pairs in the panel + draw dashed red | |
| // lines in the 3D viewer for every repulsive pair. | |
| // | |
| // ── Exclusion rules (false-positive suppression) ──────────────────────────── | |
| // The following atom pairs are skipped even when q1×q2 > 0, because their | |
| // apparent repulsion is a force-field artefact rather than a real clash: | |
| // | |
| // 1. S – O (either direction): | |
| // Sulfur lone-pair geometry produces a formal negative charge, but S is a | |
| // weaker repulsor toward O than O–O. Remove to avoid false alerts on | |
| // Met/Cys contacts. | |
| // | |
| // 2. Ligand O – Tyrosine O (any O in TYR): | |
| // TYR–OH is a classic H-bond donor. O···O near TYR is usually a productive | |
| // interaction; flagging it as repulsion is a false positive. | |
| // | |
| // 3. Ligand hydroxyl O (–OH, single-bond terminal O) – Protein backbone C=O: | |
| // Backbone carbonyl O is the canonical H-bond acceptor for –OH donors. | |
| // Named 'O' in PDB records (all sidechain oxygens have distinct names). | |
| // | |
| // To add a new exclusion: insert another `if (...) continue;` block in the | |
| // inner loop of computeElecRepulsion(), following the pattern of the three | |
| // existing blocks (lines ~3289–3309 below). | |
| // | |
| // ── Partial charge sources ─────────────────────────────────────────────────── | |
| // Protein: AMBER ff14SB residue/atom-name table → PROT_CHARGE_MAP | |
| // Reference: Maier et al. J. Chem. Theory Comput. 2015, 11, 3696. | |
| // Ligand: Element + bonding-context rules derived from MMFF94 / GAFF values. | |
| // Reference: Jakalian et al. J. Comput. Chem. 2002, 23, 1623 (AM1-BCC). | |
| // ═══════════════════════════════════════════════════════════════ | |
| // Tracks whether the ⊖ Elec panel is currently open. | |
| // Also gates computeElecRepulsion() — the function returns early when false, | |
| // so no Coulomb scan runs while the panel is hidden (performance guard). | |
| let elecVisible = false; | |
| // ── Physical constants ─────────────────────────────────────────────────────── | |
| // These match the values used in electron_repulsion.py for consistency. | |
| const COULOMB_K = 332.0636; | |
| // Unit conversion factor: 1 e²/Å → kcal/mol. | |
| // Derivation: 1 e²/(4πε₀ × 1 Å) = 14.4 eV = 14.4 × 23.06 kcal/mol ≈ 332 kcal·Å/mol/e². | |
| const ELEC_DIELECTRIC = 4.0; | |
| // Effective dielectric constant for a protein binding site. | |
| // Literature consensus (Warshel & Russell 1984; Sitkoff et al. 1994): | |
| // ε ≈ 2–4 for buried, hydrophobic pockets | |
| // ε ≈ 10–20 for solvent-exposed surfaces | |
| // ε = 4 is the standard compromise for binding-site Coulomb calculations. | |
| // To screen a more solvent-exposed site: raise to 10–20. | |
| // To model a very hydrophobic, buried pocket: lower to 2. | |
| const ELEC_CUTOFF = 5.0; | |
| // Maximum interatomic distance (Å) considered in the scan. | |
| // At 5 Å with ε=4 and two −0.57 charges: E = 332 × 0.325 / (4 × 5) ≈ 5.4 kcal/mol — | |
| // large enough to matter. Pairs beyond 5 Å fall below ~1 kcal/mol for typical charges | |
| // and would only inflate the count without identifying real problem contacts. | |
| // To restore longer-range screening: raise to 8 Å (original value). | |
| const ELEC_MIN_PRODUCT = 0.01; | |
| // Minimum |q1 × q2| for a pair to be scored. | |
| // Purpose: discard near-neutral pairs (e.g. very weakly charged S–S contacts) | |
| // whose maximum possible Coulomb contribution at ELEC_CUTOFF is: | |
| // E_max = 332 × 0.01 / (4 × 5) ≈ 0.17 kcal/mol — below the mild threshold. | |
| // Raising this threshold speeds up the scan; lowering it catches more subtle clashes. | |
| // ── Protein partial-charge table (AMBER ff14SB values) ───────────────────── | |
| // | |
| // Key format: | |
| // 'RESNAME_ATOMNAME' → residue-specific lookup (e.g. 'ASP_OD1') | |
| // '_ATOMNAME' → backbone wildcard lookup (e.g. '_O' matches O in any residue) | |
| // | |
| // Only NEGATIVE partial charges are listed because this is a repulsion-only screen. | |
| // Positively charged residues (Lys NZ, Arg NH1/NH2/NE, His HIP) are intentionally | |
| // omitted: their q > 0 produces q1×q2 < 0 when paired with a negative ligand atom, | |
| // so the same-sign filter discards them automatically (they attract, not repel). | |
| // | |
| // Reference values: Maier et al. J. Chem. Theory Comput. 2015, 11, 3696 (ff14SB). | |
| // | |
| // ── How to extend this table ──────────────────────────────────────────────── | |
| // To add a new sidechain atom: | |
| // 'RESNAME_ATOMNAME': charge e.g. 'SER_OG': -0.66 (already present) | |
| // To add a backbone atom that applies to all residues: | |
| // '_ATOMNAME': charge e.g. '_O': -0.57 (already present) | |
| // Atom names must match PDB column 13–16 exactly (e.g. 'OD1', not 'Od1'). | |
| const PROT_CHARGE_MAP = { | |
| // ── Backbone (any residue) ───────────────────────────────────────────── | |
| '_O' : -0.57, // backbone carbonyl O (C=O amide oxygen, present in every residue) | |
| '_OXT' : -0.57, // C-terminal carboxylate O (second oxygen of –COO⁻ at chain end) | |
| '_N' : -0.42, // backbone amide N (sp2, lone pair partially delocalised into C=O) | |
| // ── Asp (negatively charged at pH 7, pKa ≈ 3.7) ────────────────────── | |
| 'ASP_OD1': -0.80, 'ASP_OD2': -0.80, | |
| // Both carboxylate oxygens share the formal −1 charge via resonance. | |
| // At −0.80 each they are among the strongest repulsors in the protein. | |
| // ── Glu (negatively charged at pH 7, pKa ≈ 4.1) ────────────────────── | |
| 'GLU_OE1': -0.80, 'GLU_OE2': -0.80, // same reasoning as Asp above | |
| // ── Asn / Gln (neutral carboxamide, not charged) ────────────────────── | |
| 'ASN_OD1': -0.57, // carbonyl O of Asn side chain amide | |
| 'GLN_OE1': -0.57, // carbonyl O of Gln side chain amide | |
| // ── Ser / Thr / Tyr hydroxyl O ──────────────────────────────────────── | |
| 'SER_OG' : -0.66, // serine –OH oxygen (sp3, lone pairs more concentrated than ether) | |
| 'THR_OG1': -0.66, // threonine –OH oxygen | |
| 'TYR_OH' : -0.64, // tyrosine phenol oxygen (slightly less negative due to phenol resonance) | |
| // NOTE: TYR–O contacts with ligand O are excluded downstream (exclusion rule #2). | |
| // ── His (neutral, δ-tautomer assumed — ND1 protonated, NE2 free) ────── | |
| 'HIS_ND1': -0.36, // protonated N — slightly negative despite H because of sp2 delocalisation | |
| 'HIS_NE2': -0.36, // unprotonated N — lone pair available, slightly negative | |
| // To model HIE (ε-tautomer) or HIP (doubly protonated, positively charged): | |
| // HIE: swap charges — 'HIS_ND1': 0, 'HIS_NE2': -0.36 | |
| // HIP: both N are positive → remove both entries (they will return 0.0) | |
| // ── Cys thiol / Met thioether (weakly negative S) ───────────────────── | |
| 'CYS_SG' : -0.31, // thiol sulfur (lone pairs, but weaker than O) | |
| 'MET_SD' : -0.27, // thioether sulfur (lone pairs; even weaker than Cys SG) | |
| // NOTE: S–O pairs are excluded downstream (exclusion rule #1). | |
| // ── Trp indole N (slightly negative due to lone pair on ring N) ─────── | |
| 'TRP_NE1': -0.34, | |
| // ── Pro backbone O (identical to generic backbone O) ────────────────── | |
| 'PRO_O' : -0.57, // included explicitly in case the '_O' wildcard misses proline | |
| }; | |
| /** | |
| * @brief Look up the AMBER ff14SB partial charge for a protein atom. | |
| * | |
| * Uses a three-level priority chain against the PROT_CHARGE_MAP table: | |
| * Level 1 — residue-specific key 'RESNAME_ATOMNAME' (e.g. 'ASP_OD1' = -0.80). | |
| * Level 2 — backbone wildcard key '_ATOMNAME' (e.g. '_O' = -0.57). | |
| * Level 3 — neutral fallback returning 0.0 for unlisted atoms (C, CA, CB, H, etc.). | |
| * | |
| * Only negative charges are stored because this is a repulsion-only screen; | |
| * positively charged atoms (Lys NZ, Arg NH1, etc.) are intentionally omitted | |
| * and default to 0.0 so the same-sign filter skips them automatically. | |
| * | |
| * @param {Object} atom - A 3Dmol.js atom object with .resn (residue name, | |
| * e.g. 'ASP') and .atom (PDB atom name, e.g. 'OD1'). | |
| * @returns {number} Partial charge in electron units (e). Negative for | |
| * electronegative heteroatoms, 0.0 for neutral/unlisted atoms. | |
| */ | |
| function proteinCharge(atom) { | |
| const resKey = `${atom.resn}_${atom.atom}`; // e.g. 'ASP_OD1', 'SER_OG' | |
| const backKey = `_${atom.atom}`; // e.g. '_O', '_N', '_OXT' | |
| if (PROT_CHARGE_MAP[resKey] !== undefined) return PROT_CHARGE_MAP[resKey]; | |
| if (PROT_CHARGE_MAP[backKey] !== undefined) return PROT_CHARGE_MAP[backKey]; | |
| return 0.0; // C, Cα, H, metals, unlisted atoms → treat as neutral | |
| } | |
| // ── Ligand partial-charge assignment ─────────────────────────────────────── | |
| // | |
| // PDB HETATM records carry no explicit partial charges, so we derive them from | |
| // element type and bonding context using rules calibrated against MMFF94 / GAFF | |
| // force-field values (Jakalian et al. J. Comput. Chem. 2002, 23, 1623). | |
| // | |
| // Parameters: | |
| // atom — 3Dmol.js atom object (.elem, .x, .y, .z) | |
| // atomIdx — index of this atom in the ligAtoms array | |
| // ligAtoms — full array of ligand atoms (used for bond-length measurement) | |
| // adj — heavy-atom adjacency list from inferLigBonds() | |
| // adj[atomIdx] = [index of neighbor 1, index of neighbor 2, ...] | |
| // NOTE: only heavy-atom bonds are listed; H atoms are implicit in PDB. | |
| // | |
| // Returns a partial charge in electron units (e). | |
| // Returns 0.0 for neutral/unlisted elements so they are skipped by the scan. | |
| // | |
| // ── How to extend for a new element ──────────────────────────────────────── | |
| // Add a new case to the switch below, e.g.: | |
| // case 'I': return -0.03; // iodine (weakly negative; σ-hole not modelled) | |
| // Good references: GAFF2 (Wang et al. 2004) or AM1-BCC (Jakalian et al. 2002). | |
| function ligandCharge(atom, atomIdx, ligAtoms, adj) { | |
| const elem = atom.elem; | |
| const nbrs = adj[atomIdx] || []; // indices of heavy-atom neighbors in ligAtoms[] | |
| const deg = nbrs.length; // heavy-atom degree (H count is not available in PDB) | |
| switch (elem) { | |
| case 'O': { | |
| // Distinguish carbonyl O from ether / hydroxyl O using bond length: | |
| // C=O (carbonyl) ≈ 1.22 Å → threshold 1.30 Å catches all C=O bonds | |
| // C–O (ether/alcohol) ≈ 1.43 Å → clearly above threshold | |
| // | |
| // Carbonyl O (−0.57): the C=O π bond shifts electron density onto O, | |
| // making it more negative than a single-bond O. | |
| // Terminal O (deg ≤ 1, −0.57): oxide / isolated terminal OH treated conservatively. | |
| // Ether / bridging O (−0.40): C–O–C lone pairs are less concentrated than C=O. | |
| // | |
| // NOTE: ligand hydroxyl O (deg=1, d>1.30 Å) is also classified as −0.57 here, | |
| // but backbone carbonyl pairings are suppressed by exclusion rule #3 downstream. | |
| const isCarbonyl = nbrs.some(m => { | |
| const nb = ligAtoms[m]; | |
| if (nb.elem !== 'C') return false; | |
| const dx = atom.x-nb.x, dy = atom.y-nb.y, dz = atom.z-nb.z; | |
| return Math.sqrt(dx*dx + dy*dy + dz*dz) < 1.30; // C=O bond length cutoff | |
| }); | |
| if (isCarbonyl || deg <= 1) return -0.57; // carbonyl O or terminal O | |
| return -0.40; // bridging ether O (C–O–C) | |
| } | |
| case 'N': { | |
| // Nitrogen charge is mapped from its heavy-atom degree (= bond count to heavy atoms). | |
| // H atoms are not present in PDB, so we cannot directly count N–H bonds. | |
| // | |
| // deg ≥ 4 → quaternary ammonium N⁺ (fully substituted, carries a formal + charge) | |
| // Return 0.0 so the same-sign filter skips it — it is POSITIVE and | |
| // would attract negative protein atoms, not repel them. | |
| // | |
| // deg ≤ 1 → terminal or nitrile N (–C≡N, isocyanate) | |
| // Lone pair fully localised → strongest negative charge. | |
| // | |
| // deg = 2 → sp2 ring N (pyridine, imidazole, imine –N=C–) | |
| // Lone pair partially delocalised into the π system → moderate charge. | |
| // | |
| // deg = 3 → sp3 amine (–NHR, –NR₂) or amide N | |
| // Typical tertiary amine partial charge. | |
| // | |
| // ⚠ Limitation: protonation state is NOT modelled. A protonated amine –NH₃⁺ | |
| // behaves like quaternary N (deg=3 but positively charged) yet is | |
| // indistinguishable from a neutral amine in PDB records (no H atoms). | |
| // Future upgrade: integrate a pKa prediction tool (e.g. MoKa / Epik) to | |
| // set protonation state before charge assignment. | |
| if (deg >= 4) return 0.0; // quaternary N⁺ → positive → not repulsive | |
| if (deg <= 1) return -0.57; // terminal / nitrile N | |
| if (deg === 2) return -0.34; // sp2 ring N (pyridine-like) | |
| return -0.40; // sp3 amine (deg = 3) | |
| } | |
| // ── Halogens ────────────────────────────────────────────────────────────── | |
| // All halogens carry a negative partial charge from their lone pairs. | |
| // Charge magnitude decreases with atomic number (F is most electronegative). | |
| // | |
| // Known limitation: halogens also have a σ-hole — a region of positive | |
| // electrostatic potential along the C–X bond axis opposite the substituent. | |
| // This makes Cl and Br halogen-bond DONORS toward O/N acceptors (C–X···O angle | |
| // close to 180°). The σ-hole is not modelled here; we assign only the | |
| // overall partial charge. If your ligand has a Cl/Br pointing its σ-hole at | |
| // a protein O/N, the repulsion line shown here is a false positive. | |
| // Future upgrade: add an angle check (C–X···O > 150°) to detect and exclude σ-hole donors. | |
| case 'F': return -0.25; // fluorine — most electronegative halogen | |
| case 'CL': return -0.12; // chlorine | |
| case 'BR': return -0.06; // bromine — weakest halogen partial charge | |
| // ── Sulfur ──────────────────────────────────────────────────────────────── | |
| // Thioether (C–S–C) and thiol (C–SH) both carry small negative charges | |
| // from their lone pairs. | |
| // NOTE: S–O pairs are suppressed by exclusion rule #1 in computeElecRepulsion() | |
| // because S–O repulsion is frequently a force-field artefact near Met/Cys. | |
| case 'S': return -0.15; | |
| // ── All other elements (neutral for this screen) ─────────────────────── | |
| // Carbon: polarised within individual bonds but no net partial charge. | |
| // Phosphorus, boron, metals, Se, Si, etc.: not commonly drug-like; treated neutral. | |
| // To add support for a new element, add a case above this default. | |
| default: return 0.0; | |
| } | |
| } | |
| // ── Main Coulomb repulsion computation ───────────────────────────────────── | |
| // | |
| // Scans every (ligand atom, binding-site protein atom) pair, assigns rule-based | |
| // partial charges, applies exclusion filters, and computes the Coulomb energy | |
| // for same-sign (repulsive) pairs. | |
| // | |
| // Returns null if no ligand is loaded or protein atoms are unavailable. | |
| // Returns an object with: | |
| // totalEnergy — sum of all repulsive pair energies (kcal/mol) | |
| // pairs[] — array of repulsive pair objects sorted by energy (worst first): | |
| // { la : ligand atom object (3Dmol.js, has .elem .x .y .z) | |
| // pa : protein atom object (.resn .atom .elem .x .y .z) | |
| // dist : interatomic distance (Å) | |
| // energy : Coulomb energy (kcal/mol, always > 0) | |
| // severity : 'mild' | 'moderate' | 'severe' | |
| // qL : ligand atom partial charge (e) | |
| // qP : protein atom partial charge (e) } | |
| // nLigAtoms — number of ligand heavy atoms (for panel metadata display) | |
| // nProtNearby — number of protein atoms in the centroid pre-filter sphere | |
| function computeElecRepulsion() { | |
| if (!viewer) return null; | |
| const ligSel = getLigandSel(); | |
| if (!ligSel) return null; | |
| const ligAtoms = viewer.selectedAtoms(ligSel); | |
| if (!ligAtoms || ligAtoms.length < 2) return null; | |
| // inferLigBonds() builds the heavy-atom adjacency list and bond-order estimates | |
| // from PDB geometry (same helper used by the torsional strain panel). | |
| // adj[i] = array of neighbor indices in ligAtoms[]. | |
| const { adj } = inferLigBonds(ligAtoms); | |
| // Build a set of ligand residue names so we can exclude them from the protein | |
| // selection. Critical for KRAS-style PDBs where the ligand is stored as ATOM | |
| // records (not HETATM), meaning it would otherwise be included in 'allProt'. | |
| const ligResns = new Set(ligAtoms.map(a => a.resn)); | |
| const allProt = viewer.selectedAtoms({ hetflag: false }) | |
| .filter(a => !ligResns.has(a.resn)); | |
| if (!allProt || allProt.length === 0) return null; | |
| // ── Centroid pre-filter ────────────────────────────────────────────────── | |
| // Computing the exact distance for every (ligand, protein) pair is O(N_lig × N_prot). | |
| // For a 300-residue protein (~2400 heavy atoms) and a 30-atom ligand, that is | |
| // ~72 000 distance evaluations. We first discard protein atoms whose distance | |
| // to the ligand CENTROID exceeds (ELEC_CUTOFF + 5) Å. This pre-pass is O(N_prot) | |
| // and reduces the inner loop to only the binding-site neighbourhood. | |
| // The +5 Å margin ensures we never miss a pair where the ligand atom is far from | |
| // the centroid but the protein atom is close (e.g. elongated ligands). | |
| const cx = ligAtoms.reduce((s,a) => s+a.x, 0) / ligAtoms.length; | |
| const cy = ligAtoms.reduce((s,a) => s+a.y, 0) / ligAtoms.length; | |
| const cz = ligAtoms.reduce((s,a) => s+a.z, 0) / ligAtoms.length; | |
| const nearby = allProt.filter(a => { | |
| const dx=a.x-cx, dy=a.y-cy, dz=a.z-cz; | |
| return Math.sqrt(dx*dx+dy*dy+dz*dz) < ELEC_CUTOFF + 5; | |
| }); | |
| let totalEnergy = 0.0; | |
| const pairs = []; | |
| for (let li = 0; li < ligAtoms.length; li++) { | |
| const la = ligAtoms[li]; | |
| const qL = ligandCharge(la, li, ligAtoms, adj); | |
| if (qL === 0.0) continue; // neutral C, H, P, metal → skip all pairings for this atom | |
| // ── Ligand hydroxyl O flag (computed once per ligand atom) ───────────── | |
| // An oxygen is a hydroxyl –OH if it has exactly ONE heavy-atom neighbor AND | |
| // that bond is a SINGLE bond (d > 1.30 Å). | |
| // Carbonyl O: deg=1, d≤1.30 Å → NOT hydroxyl (also classified as −0.57 above) | |
| // Ether O: deg=2 → NOT hydroxyl (bridging) | |
| // Hydroxyl O: deg=1, d>1.30 Å → YES | |
| // This flag is used below to apply exclusion rule #3 (hydroxyl O vs backbone C=O). | |
| const isLigHydroxylO = la.elem.toUpperCase() === 'O' | |
| && adj[li] && adj[li].length === 1 | |
| && dist3(la, ligAtoms[adj[li][0]]) > 1.30; | |
| for (const pa of nearby) { | |
| const qP = proteinCharge(pa); | |
| if (qP === 0.0) continue; // neutral protein atom (C, Cα, H, etc.) → skip | |
| // ── Same-sign filter ───────────────────────────────────────────────── | |
| // Coulomb repulsion only occurs between like charges (q1×q2 > 0). | |
| // Opposite-sign pairs (e.g. positively charged Lys NZ near negative ligand O) | |
| // are attractive — they improve binding, not worsen it. Skip them here. | |
| const product = qL * qP; | |
| if (product <= 0) continue; | |
| // ── Near-neutral filter ────────────────────────────────────────────── | |
| // If |q1×q2| is below ELEC_MIN_PRODUCT (0.01), the maximum possible Coulomb | |
| // energy at ELEC_CUTOFF is < 0.17 kcal/mol — sub-threshold noise. Skip. | |
| if (Math.abs(product) < ELEC_MIN_PRODUCT) continue; | |
| // ── Distance filter ────────────────────────────────────────────────── | |
| const dx = la.x-pa.x, dy = la.y-pa.y, dz = la.z-pa.z; | |
| const dist = Math.sqrt(dx*dx + dy*dy + dz*dz); | |
| if (dist > ELEC_CUTOFF) continue; // beyond the 5 Å hard cutoff | |
| // ── Element derivation for exclusion filters ───────────────────────── | |
| // 3Dmol.js sets atom.elem for standard ATOM records but may leave it blank | |
| // for some HETATM entries. Fallback: first character of the PDB atom name | |
| // (e.g. 'OD1' → 'O', 'NZ' → 'N'). The second fallback '' prevents crashes. | |
| const elemL = la.elem.toUpperCase(); | |
| const elemP = (pa.elem || pa.atom[0] || '').toUpperCase(); | |
| // ── Exclusion rule #1: S – O ───────────────────────────────────────── | |
| // Sulfur's lone-pair geometry produces a formal negative charge via the | |
| // rule-based table, but S does not repel O the same way O–O does. | |
| // This exclusion prevents false alerts on Met SD and Cys SG contacts. | |
| // Both directions checked (ligand-S/protein-O and ligand-O/protein-S). | |
| if ((elemL === 'S' && elemP === 'O') || (elemL === 'O' && elemP === 'S')) continue; | |
| // ── Exclusion rule #2: Ligand O – Tyrosine O ───────────────────────── | |
| // TYR–OH is a classic H-bond donor. When it sits near a ligand O at short | |
| // distance, it almost always signals a productive H-bond, not a repulsion. | |
| // Covers both TYR_OH and the backbone O of TYR (both have pa.resn === 'TYR'). | |
| if (elemL === 'O' && elemP === 'O' && pa.resn === 'TYR') continue; | |
| // ── Exclusion rule #3: Ligand hydroxyl O – Protein backbone C=O ────── | |
| // Backbone carbonyl O is the canonical H-bond acceptor for –OH donors. | |
| // PDB convention: backbone O is named exactly 'O'; all sidechain oxygens | |
| // carry distinct names (OD1, OD2, OE1, OE2, OG, OG1, OH, OXT …). | |
| // pa.atom === 'O' therefore precisely identifies backbone carbonyl O only. | |
| if (isLigHydroxylO && pa.atom === 'O') continue; | |
| // ── Coulomb energy: E = k × q1 × q2 / (ε × r) ────────────────────── | |
| // With q1, q2 same sign → product > 0 → E > 0 (repulsive). | |
| // Math.max(dist, 0.1) prevents division by zero if two atoms are at the | |
| // same coordinates (should not occur in valid PDB data, but defensive). | |
| const energy = (COULOMB_K * product) / (ELEC_DIELECTRIC * Math.max(dist, 0.1)); | |
| if (energy <= 0) continue; // safety guard (shouldn't happen for same-sign pairs) | |
| // ── Severity classification ────────────────────────────────────────── | |
| // Thresholds calibrated against typical H-bond energies (−1 to −5 kcal/mol): | |
| // severe (>5 kcal/mol): outweighs a typical H-bond; pose likely problematic | |
| // moderate (1–5 kcal/mol): comparable to 1 H-bond; worth investigating | |
| // mild (<1 kcal/mol): small contribution; background noise level | |
| const severity = energy > 5.0 ? 'severe' | |
| : energy > 1.0 ? 'moderate' | |
| : 'mild'; | |
| pairs.push({ la, pa, dist, energy, severity, qL, qP }); | |
| totalEnergy += energy; | |
| } | |
| } | |
| // Sort worst-first so the panel and top-5 table always show the most critical pairs. | |
| pairs.sort((a, b) => b.energy - a.energy); | |
| return { totalEnergy, pairs, nLigAtoms: ligAtoms.length, nProtNearby: nearby.length }; | |
| } | |
| // ── Draw dashed red lines for repulsive atom pairs ─────────────────────────── | |
| // | |
| // Called by updateElecPanel() after computeElecRepulsion() returns results. | |
| // Renders ALL repulsive pairs (not just the top-5 shown in the panel) so the | |
| // user can see every clash spatially while reading the panel summary. | |
| // | |
| // Per pair, five 3Dmol.js objects are created: | |
| // 1. Dashed red cylinder — from ligand atom → protein atom. | |
| // Dashing uses the same alternating-segment technique as H-bond lines. | |
| // Radius 0.07 Å (slightly thicker than H-bond 0.06 Å) for visual distinction. | |
| // 2. Small red sphere — at the protein atom endpoint to mark the contact point. | |
| // 3. Distance label — Å value at the midpoint of the line, pale-red background | |
| // (H-bond labels use white background — different colour aids identification). | |
| // 4. Two outward arrows — one at the ligand atom, one at the protein atom, each | |
| // pointing AWAY from the other atom (i.e. in the direction of repulsive force). | |
| // Cone-tipped (addArrow), 0.55 Å length, severity-graded colour. | |
| // 5. Two charge labels — "q=X.XX" placed 0.85 Å outward from each atom (just | |
| // beyond the arrowhead), showing the partial charge on each heteroatom. | |
| // | |
| // Colour grades by severity: | |
| // Severe → #cc0000 bright red (> 5 kcal/mol) | |
| // Moderate → #cc4400 orange-red (1–5 kcal/mol) | |
| // Mild → #cc7700 amber (0–1 kcal/mol) | |
| // | |
| // All shapes and labels are stored in elecShapes[] / elecLabels[] and removed | |
| // by clearElecShapes() — this avoids accidentally destroying hover spheres, | |
| // selection spheres, or H-bond cylinders (which removeAllShapes() would do). | |
| // | |
| // ── How to change the visual style ────────────────────────────────────────── | |
| // Line thickness: change `radius` in the addCylinder call below | |
| // Dash density: change the 0.4 Å segment length in the `steps` formula | |
| // Sphere size: change `radius` in the addSphere call below | |
| // Label font size: change `fontSize` in the addLabel call below | |
| // Severity colours: change the three hex values in the `lineColor` assignment | |
| function drawElecLines(pairs) { | |
| clearElecShapes(); // remove any previous repulsion lines before redrawing | |
| if (!viewer || !pairs || pairs.length === 0) { viewer.render(); return; } | |
| for (const p of pairs) { | |
| const from = { x: p.la.x, y: p.la.y, z: p.la.z }; // ligand atom xyz | |
| const to = { x: p.pa.x, y: p.pa.y, z: p.pa.z }; // protein atom xyz | |
| // Direction vector from ligand atom to protein atom, and its unit vector. | |
| // ux/uy/uz is used both for the dashed line segments and for the outward | |
| // charge-direction arrows added below. | |
| const dx = to.x - from.x; | |
| const dy = to.y - from.y; | |
| const dz = to.z - from.z; | |
| const totalLen = Math.sqrt(dx*dx + dy*dy + dz*dz); | |
| const ux = dx / totalLen; // unit vector components (ligand → protein direction) | |
| const uy = dy / totalLen; | |
| const uz = dz / totalLen; | |
| // Severity-graded colour: worst clashes (severe) are brightest red. | |
| // To add a 4th severity tier, extend this ternary and the classification | |
| // thresholds in computeElecRepulsion(). | |
| const lineColor = p.severity === 'severe' ? '#cc0000' // bright red | |
| : p.severity === 'moderate' ? '#cc4400' // orange-red | |
| : '#cc7700'; // amber | |
| // ── Dashed cylinder ────────────────────────────────────────────────────── | |
| // The line is divided into segments of ~0.4 Å each (minimum 4 segments). | |
| // ODD-indexed segments are drawn; EVEN-indexed segments are gaps. | |
| // This produces a dash pattern visually similar to H-bond lines. | |
| // | |
| // To change dash density: | |
| // Increase 0.4 → longer gaps between dashes (sparser pattern) | |
| // Decrease 0.4 → shorter gaps (denser, more solid-looking) | |
| const steps = Math.max(4, Math.floor(totalLen / 0.4)); | |
| for (let i = 0; i < steps; i++) { | |
| if (i % 2 === 0) continue; // even indices = gap segments (not drawn) | |
| const t0 = i / steps; | |
| const t1 = (i + 1) / steps; | |
| const start = { x: from.x + dx*t0, y: from.y + dy*t0, z: from.z + dz*t0 }; | |
| const end = { x: from.x + dx*t1, y: from.y + dy*t1, z: from.z + dz*t1 }; | |
| elecShapes.push(viewer.addCylinder({ | |
| start, end, | |
| radius: 0.07, // 0.07 Å — slightly thicker than H-bond lines (0.06 Å) | |
| color: lineColor, | |
| fromCap: 1, toCap: 1, // hemisphere caps on both ends of each dash | |
| opacity: 0.90, | |
| })); | |
| } | |
| // ── Endpoint sphere at the protein atom ────────────────────────────────── | |
| // Marks the protein-side contact atom so it remains visible even when the | |
| // protein representation (ribbon/cartoon) obscures the backbone. | |
| elecShapes.push(viewer.addSphere({ | |
| center: to, | |
| radius: 0.20, // slightly larger than the cylinder for visibility | |
| color: lineColor, | |
| opacity: 0.90, | |
| })); | |
| // ── Distance label at the midpoint ─────────────────────────────────────── | |
| // Shows the interatomic distance in Å, matching the style of H-bond labels. | |
| // Pale red background (#FFF5F5) distinguishes these labels from H-bond labels | |
| // (which use a white background) when both types are visible simultaneously. | |
| const mid = { | |
| x: (from.x + to.x) / 2, | |
| y: (from.y + to.y) / 2, | |
| z: (from.z + to.z) / 2, | |
| }; | |
| elecLabels.push(viewer.addLabel(`${p.dist.toFixed(1)} Å`, { | |
| position: mid, | |
| fontColor: lineColor, | |
| fontSize: 10, | |
| fontOpacity: 1.0, | |
| backgroundOpacity: 0.82, | |
| backgroundColor: '#FFF5F5', // pale red — distinct from H-bond label background (white) | |
| borderColor: lineColor, | |
| borderThickness: 0.8, | |
| padding: 2, | |
| inFront: true, // always rendered in front of protein surfaces | |
| showBackground: true, | |
| })); | |
| // ── Partial-charge directionality indicators ────────────────────────────── | |
| // | |
| // Each repulsive heteroatom gets: | |
| // (A) A short outward-pointing ARROW (0.55 Å, cone-tipped) showing the | |
| // direction of the repulsive electrostatic force on that atom. | |
| // • Ligand atom arrow: points AWAY from protein (−u direction). | |
| // • Protein atom arrow: points AWAY from ligand (+u direction). | |
| // (B) A small charge label "q=X.XX" placed just beyond the arrowhead | |
| // (0.85 Å from the atom), showing the partial charge magnitude. | |
| // | |
| // Together these let the user read, for each red line: | |
| // • Which atom is more negative (larger |q| = larger δ−) | |
| // • Which direction the electrostatic force pushes each atom | |
| // | |
| // viewer.addArrow() parameters: | |
| // start / end — shaft start and cone tip positions | |
| // radius — shaft cylinder radius (Å) | |
| // radiusRatio — cone base radius = radiusRatio × shaft radius | |
| // mid — fraction of total length where the cone head begins (0–1) | |
| // color — same severity-graded colour as the dashed line | |
| // (A1) Outward arrow at the LIGAND atom — force direction on ligand | |
| elecShapes.push(viewer.addArrow({ | |
| start: from, | |
| end: { x: from.x - ux*0.55, y: from.y - uy*0.55, z: from.z - uz*0.55 }, | |
| radius: 0.055, // shaft radius (Å) | |
| radiusRatio: 2.2, // cone base = 2.2 × shaft ≈ 0.12 Å — visible but not bulky | |
| mid: 0.58, // cone starts 58% along the arrow, leaving a clear shaft | |
| color: lineColor, | |
| opacity: 0.95, | |
| })); | |
| // (A2) Outward arrow at the PROTEIN atom — force direction on protein | |
| elecShapes.push(viewer.addArrow({ | |
| start: to, | |
| end: { x: to.x + ux*0.55, y: to.y + uy*0.55, z: to.z + uz*0.55 }, | |
| radius: 0.055, | |
| radiusRatio: 2.2, | |
| mid: 0.58, | |
| color: lineColor, | |
| opacity: 0.95, | |
| })); | |
| // (B1) Charge label at the LIGAND atom — partial charge on the ligand heteroatom. | |
| // Positioned 0.85 Å outward (beyond the arrowhead) so it doesn't overlap the arrow. | |
| elecLabels.push(viewer.addLabel(`q=${p.qL.toFixed(2)}`, { | |
| position: { x: from.x - ux*0.85, y: from.y - uy*0.85, z: from.z - uz*0.85 }, | |
| fontColor: lineColor, | |
| fontSize: 9, // slightly smaller than the Å label to reduce clutter | |
| fontOpacity: 1.0, | |
| backgroundOpacity: 0.78, | |
| backgroundColor: '#FFF5F5', | |
| borderColor: lineColor, | |
| borderThickness: 0.6, | |
| padding: 1, | |
| inFront: true, | |
| showBackground: true, | |
| })); | |
| // (B2) Charge label at the PROTEIN atom — partial charge on the protein heteroatom. | |
| elecLabels.push(viewer.addLabel(`q=${p.qP.toFixed(2)}`, { | |
| position: { x: to.x + ux*0.85, y: to.y + uy*0.85, z: to.z + uz*0.85 }, | |
| fontColor: lineColor, | |
| fontSize: 9, | |
| fontOpacity: 1.0, | |
| backgroundOpacity: 0.78, | |
| backgroundColor: '#FFF5F5', | |
| borderColor: lineColor, | |
| borderThickness: 0.6, | |
| padding: 1, | |
| inFront: true, | |
| showBackground: true, | |
| })); | |
| } | |
| viewer.render(); // commit all new shapes and labels to the WebGL canvas | |
| } | |
| // ── Panel display update ───────────────────────────────────────────────────── | |
| // Called when: | |
| // (a) The user clicks ⊖ Elec to open the panel (via toggleElecPanel). | |
| // (b) A new structure is loaded (structure-loading path calls updateElecPanel | |
| // automatically after updateStrainPanel). | |
| // (c) The user changes pH or FF in the controls (via onElecSettingChange). | |
| // | |
| // Data source (server-first with rule-based fallback): | |
| // PRIMARY — fetchElecFromServer(): POST current PDB to elec_server.py (port 8084), | |
| // which runs pdb2pqr to assign physics-based protein partial charges at | |
| // the requested pH using PROPKA, then does the Coulomb pairwise scan. | |
| // The #elec-source indicator shows "pdb2pqr · FF · pH X.X". | |
| // FALLBACK — computeElecRepulsion(): browser-side rule-based charges (no server | |
| // needed). Shown when server is unreachable or times out. | |
| // The #elec-source indicator shows "rule-based · server offline". | |
| // | |
| // Flow: | |
| // 1. Guard: return early if panel is hidden or no viewer exists. | |
| // 2. Show "Calculating…" spinner text in #elec-total. | |
| // 3. Try fetchElecFromServer(currentPDBData, currentElecPh, currentElecFf). | |
| // On success → use server result. | |
| // On error → fall back to computeElecRepulsion(); show offline notice. | |
| // 4. Colour-code and display the total energy in #elec-total. | |
| // 5. Fill #elec-meta with pair counts per severity tier. | |
| // 6. Set #elec-interp to a qualitative verdict string. | |
| // 7. Build the top-3 pairs mini-table in #elec-pairs. | |
| // 8. Call drawElecLines(r.pairs.slice(0,3)) to render red dashed lines. | |
| // | |
| // ── Verdict thresholds (kcal/mol) ─────────────────────────────────────────── | |
| // < 2 LOW — background noise; electrostatically acceptable | |
| // 2–5 MODERATE — one moderate clash or several mild ones; worth reviewing | |
| // 5–10 HIGH — likely unfavorable; Vina score is optimistic | |
| // > 10 VERY HIGH — consider re-docking with electrostatics-aware scoring | |
| // (Glide XP, AutoDock4 with Gasteiger charges, etc.) | |
| // | |
| // ── How to change the thresholds ──────────────────────────────────────────── | |
| // Two places use the same numerical cutoffs: | |
| // 1. `col` assignment (controls #elec-total text colour) | |
| // 2. `elec-interp` textContent assignment (controls verdict string) | |
| // Update both in tandem to keep panel colour and text consistent. | |
| async function updateElecPanel() { | |
| if (!elecVisible || !viewer) return; // panel hidden or viewer not initialised → no-op | |
| const ligSel = getLigandSel(); | |
| if (!ligSel) { | |
| // No ligand loaded yet (user opened the panel before selecting a structure) | |
| document.getElementById('elec-total').textContent = 'No ligand selected'; | |
| document.getElementById('elec-source').textContent = '—'; | |
| return; | |
| } | |
| // ── Show loading indicator while server call is in flight ───────────────── | |
| document.getElementById('elec-total').textContent = 'Calculating…'; | |
| document.getElementById('elec-total').style.color = '#555'; | |
| document.getElementById('elec-source').textContent = '…'; | |
| // ── Try server first; fall back to rule-based if unavailable ───────────── | |
| let r = null; | |
| let sourceText = ''; | |
| if (currentPDBData) { | |
| try { | |
| r = await fetchElecFromServer(currentPDBData, currentElecPh, currentElecFf); | |
| // Server succeeded — update source indicator with pdb2pqr details | |
| sourceText = r.source || `pdb2pqr · ${currentElecFf} · pH ${currentElecPh.toFixed(1)}`; | |
| } catch (err) { | |
| // Server unreachable / timed out / error — fall through to rule-based | |
| // (common when elec_server.py is not running) | |
| r = null; | |
| } | |
| } | |
| if (!r) { | |
| // Browser-side fallback: rule-based partial charges (no server needed) | |
| r = computeElecRepulsion(); | |
| sourceText = 'rule-based · server offline'; | |
| } | |
| // Update source indicator in the panel header controls row | |
| document.getElementById('elec-source').textContent = sourceText; | |
| if (!r) { | |
| // computeElecRepulsion also returned null — no protein atoms in selection | |
| document.getElementById('elec-total').textContent = 'No protein atoms'; | |
| return; | |
| } | |
| // ── Total energy — colour-coded by verdict tier ─────────────────────────── | |
| // green (#1a7a1a) → LOW (< 2 kcal/mol) | |
| // amber (#8a6a00) → MODERATE (2–10 kcal/mol) | |
| // red (#aa2222) → HIGH (> 10 kcal/mol) | |
| // To change colour breakpoints: update the two numerical thresholds below. | |
| const col = r.totalEnergy < 2 ? '#1a7a1a' // green — LOW | |
| : r.totalEnergy < 10 ? '#8a6a00' // amber — MODERATE / HIGH | |
| : '#aa2222'; // red — VERY HIGH | |
| document.getElementById('elec-total').textContent = `${r.totalEnergy.toFixed(1)} kcal/mol`; | |
| document.getElementById('elec-total').style.color = col; | |
| // ── Pair count summary line ─────────────────────────────────────────────── | |
| // "N repulsive pairs · X severe · Y mod · Z mild" | |
| // Useful for quickly spotting whether the total is driven by one large clash | |
| // (1 severe) or many small ones (many mild). | |
| const nSevere = r.pairs.filter(p => p.severity === 'severe').length; | |
| const nModerate = r.pairs.filter(p => p.severity === 'moderate').length; | |
| const nMild = r.pairs.filter(p => p.severity === 'mild').length; | |
| document.getElementById('elec-meta').textContent = | |
| `${r.pairs.length} repulsive pairs · ${nSevere} severe · ${nModerate} mod · ${nMild} mild`; | |
| // ── Qualitative verdict string ──────────────────────────────────────────── | |
| // A one-line interpretation shown in italic below the total. | |
| // To change text: update the strings below (keep breakpoints consistent with `col`). | |
| document.getElementById('elec-interp').textContent = | |
| r.totalEnergy < 2 ? 'Low repulsion — electrostatically favorable pose' : | |
| r.totalEnergy < 5 ? 'Moderate — review charged-group placement' : | |
| r.totalEnergy < 10 ? 'High repulsion — likely electrostatically unfavorable' : | |
| 'Very high repulsion — Vina score may be misleading'; | |
| // ── Top-3 worst pairs mini-table ────────────────────────────────────────── | |
| // Shows at most 3 pairs to keep the panel compact. | |
| // To show more pairs: change `.slice(0, 3)` to `.slice(0, N)`. | |
| // Each row format: [S/M/L] ELEM(lig) ↔ ATOMNAME/RESNAME+RESI energy | |
| // The HTML title attribute provides a hover tooltip with full detail: | |
| // "ELEM(lig) q=−0.57 ↔ OD1/ASP105 q=−0.80, d=3.4 Å" | |
| const pairsDiv = document.getElementById('elec-pairs'); | |
| if (r.pairs.length === 0) { | |
| // No repulsive pairs at all — pose is electrostatically clean | |
| pairsDiv.innerHTML = '<div style="font-size:0.65rem;color:#1a7a1a;margin-top:4px">No repulsive pairs found.</div>'; | |
| // Still call drawElecLines to clear any previous lines (passes empty array) | |
| drawElecLines(r.pairs); | |
| return; | |
| } | |
| const top = r.pairs.slice(0, 3); // worst 3 pairs (sorted by energy desc) | |
| pairsDiv.innerHTML = top.map(p => { | |
| // Severity badge colour (different from lineColor in drawElecLines — panel uses softer tones) | |
| const sc = p.severity === 'severe' ? '#aa2222' // red | |
| : p.severity === 'moderate' ? '#8a6a00' // amber | |
| : '#1a7a1a'; // green | |
| const ligLabel = `${p.la.elem}(lig)`; // e.g. "O(lig)" | |
| const protLabel = `${p.pa.atom}/${p.pa.resn}${p.pa.resi}`; // e.g. "OD1/ASP105" | |
| // Full detail shown on mouse hover (title attribute → browser tooltip) | |
| const tooltip = | |
| `${ligLabel} q=${p.qL.toFixed(2)} ↔ ${protLabel} q=${p.qP.toFixed(2)}, d=${p.dist.toFixed(1)} Å`; | |
| return `<div class="elec-row" title="${tooltip}"> | |
| <span> | |
| <span style="color:${sc};font-weight:700">[${p.severity[0].toUpperCase()}]</span> | |
| ${ligLabel} ↔ ${protLabel} | |
| </span> | |
| <span class="elec-val">${p.energy.toFixed(2)}</span> | |
| </div>`; | |
| }).join(''); | |
| // Draw dashed red lines in the 3D viewer for the top-3 worst pairs only, | |
| // matching the panel table so the 3D view and panel are always in sync. | |
| drawElecLines(r.pairs.slice(0, 3)); | |
| } | |
| // ── Panel visibility toggle ────────────────────────────────────────────────── | |
| // Called by the ⊖ Elec toolbar button (onclick="toggleElecPanel()"). | |
| // | |
| // Opening the panel (elecVisible becomes true): | |
| // • Adds the 'visible' CSS class to #elec-panel → display: block | |
| // • Adds the 'active' CSS class to #btn-elec → highlighted button | |
| // • Calls updateElecPanel() which runs the full Coulomb scan and draws red lines. | |
| // | |
| // Closing the panel (elecVisible becomes false): | |
| // • Removes 'visible' from #elec-panel → display: none | |
| // • Removes 'active' from #btn-elec → button returns to normal style | |
| // • Calls clearElecShapes() to remove dashed red lines and distance labels | |
| // from the 3D viewer immediately (no stale lines left on screen). | |
| // • viewer.render() forces a WebGL redraw so the lines disappear without | |
| // waiting for the next user interaction. | |
| // | |
| // Note: the panel is also updated automatically on structure load | |
| // (structure-loading path calls updateElecPanel() after updateStrainPanel()). | |
| // In that case elecVisible is checked at the top of updateElecPanel(); if the | |
| // panel is closed the function returns early and no computation runs. | |
| function toggleElecPanel() { | |
| elecVisible = !elecVisible; | |
| document.getElementById('elec-panel').classList.toggle('visible', elecVisible); | |
| document.getElementById('btn-elec').classList.toggle('active', elecVisible); | |
| if (elecVisible) { | |
| updateElecPanel(); // compute Coulomb scan + fill panel + draw red lines | |
| } else { | |
| clearElecShapes(); // remove dashed red lines and Å labels from the viewer | |
| viewer && viewer.render(); // force immediate redraw so lines disappear at once | |
| } | |
| } | |
| // ── pH / FF setting change handler ─────────────────────────────────────────── | |
| // Fired by onchange="onElecSettingChange()" on the #elec-ph-input and | |
| // #elec-ff-select controls in the elec panel. | |
| // | |
| // Reads the current widget values, updates the module-level state variables | |
| // (currentElecPh, currentElecFf), then triggers a fresh updateElecPanel() so | |
| // the new pH/FF take effect immediately (server-side pdb2pqr re-runs). | |
| // | |
| // If the server is not running, the fallback rule-based calculation is | |
| // unaffected by pH/FF — the controls are still available for when the | |
| // server is started later. | |
| function onElecSettingChange() { | |
| const phEl = document.getElementById('elec-ph-input'); | |
| const ffEl = document.getElementById('elec-ff-select'); | |
| const newPh = parseFloat(phEl.value); | |
| if (!isNaN(newPh) && newPh >= 0 && newPh <= 14) { | |
| currentElecPh = newPh; | |
| } else { | |
| phEl.value = currentElecPh; // reset to last valid value | |
| } | |
| currentElecFf = ffEl.value; // 'AMBER' or 'CHARMM' | |
| if (elecVisible) updateElecPanel(); | |
| } | |
| // ── pdb2pqr electrostatic server fetch ─────────────────────────────────────── | |
| // Posts the raw PDB text to elec_server.py (port 8084), which runs: | |
| // Step 1 — separate protein ATOM / ligand HETATM, remove waters/ions | |
| // Step 2 — pdb2pqr --ff=FF --with-ph=PH --titration-state-method=propka | |
| // Step 3 — pairwise Coulomb scan using PQR charges (protein) + rule-based (ligand) | |
| // | |
| // Returns a normalised result object matching computeElecRepulsion() output: | |
| // { | |
| // pairs: [ {la, pa, dist, energy, severity, qL, qP}, … ], | |
| // totalEnergy: Number, | |
| // source: String e.g. "pdb2pqr · AMBER · pH 7.0" | |
| // } | |
| // where la / pa have the same field names used by drawElecLines() and the | |
| // panel table builder so they work interchangeably. | |
| // | |
| // Throws on network error or non-OK HTTP response so the caller can catch and | |
| // fall back to the browser-side computeElecRepulsion(). | |
| // | |
| // Timeout: 30 seconds — pdb2pqr can take 10–20 s on large proteins. | |
| async function fetchElecFromServer(pdbText, ph, ff) { | |
| const resp = await fetch(ELEC_SERVER + '/elec', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ pdb: pdbText, ph: ph, ff: ff }), | |
| signal: AbortSignal.timeout(30000) // 30 s hard timeout | |
| }); | |
| if (!resp.ok) { | |
| const msg = await resp.text().catch(() => ''); | |
| throw new Error(msg || `HTTP ${resp.status}`); | |
| } | |
| const data = await resp.json(); | |
| if (!data.pairs) throw new Error('Malformed server response: missing pairs'); | |
| // ── Normalise server pair format → browser pair format ─────────────────── | |
| // Server returns: | |
| // { ligAtom: {name, resname, x, y, z, charge}, | |
| // protAtom: {name, resname, resseq, x, y, z, charge}, | |
| // dist, energy, severity } | |
| // Browser (drawElecLines / panel table) expects: | |
| // { la: {elem, atom, resn, x, y, z}, | |
| // pa: {atom, resn, resi, x, y, z}, | |
| // dist, energy, severity, qL, qP } | |
| const normPairs = data.pairs.map(p => ({ | |
| la: { | |
| // Derive element from the first non-digit character of the atom name | |
| // (PDB/PQR convention: atom name starts with element symbol for HETATM). | |
| elem: (p.ligAtom.name.replace(/[0-9]/g, '')[0] || 'C').toUpperCase(), | |
| atom: p.ligAtom.name, | |
| resn: p.ligAtom.resname, | |
| x: p.ligAtom.x, y: p.ligAtom.y, z: p.ligAtom.z | |
| }, | |
| pa: { | |
| atom: p.protAtom.name, | |
| resn: p.protAtom.resname, | |
| resi: p.protAtom.resseq, | |
| x: p.protAtom.x, y: p.protAtom.y, z: p.protAtom.z | |
| }, | |
| dist: p.dist, | |
| energy: p.energy, | |
| severity: p.severity, | |
| qL: p.ligAtom.charge, | |
| qP: p.protAtom.charge | |
| })); | |
| return { | |
| pairs: normPairs, | |
| totalEnergy: data.totalEnergy, | |
| source: data.source // e.g. "pdb2pqr · AMBER · pH 7.0" | |
| }; | |
| } | |
| // ═══════════════════════════════════════════════════════════════ | |
| // 3D CONFORMER PANEL (PubChem REST API → 3Dmol.js mini-viewer) | |
| // Fetches the lowest-energy MMFF94 conformer from PubChem for the | |
| // current ligand's SMILES string, then renders it in a small embedded | |
| // $3Dmol viewer. Falls back to the bound PDB conformation if not found. | |
| // ═══════════════════════════════════════════════════════════════ | |
| let conformerPanelVisible = false; | |
| let conformerViewer = null; // $3Dmol viewer inside the panel | |
| let lastConformerSmiles = ''; // cache: skip re-fetch for same SMILES | |
| function toggleConformerPanel() { | |
| conformerPanelVisible = !conformerPanelVisible; | |
| document.getElementById('conformer-panel').classList.toggle('visible', conformerPanelVisible); | |
| document.getElementById('btn-conformer').classList.toggle('active', conformerPanelVisible); | |
| if (conformerPanelVisible) { | |
| const smiles = document.getElementById('ligand-smiles-val').textContent.trim(); | |
| if (smiles && smiles !== '—' && smiles !== lastConformerSmiles) { | |
| fetchAndShow3DConformer(smiles); | |
| } | |
| } | |
| } | |
| // Called from loadFile() so that a new ligand auto-updates an open panel. | |
| function updateConformerPanel() { | |
| if (!conformerPanelVisible) return; | |
| const smiles = document.getElementById('ligand-smiles-val').textContent.trim(); | |
| if (smiles && smiles !== '—' && smiles !== lastConformerSmiles) { | |
| fetchAndShow3DConformer(smiles); | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════ | |
| // OPENMM ENERGY MINIMISATION | |
| // ═══════════════════════════════════════════════════════════════ | |
| function toggleMinimizePanel() { | |
| minimizeVisible = !minimizeVisible; | |
| document.getElementById('minimize-panel').classList.toggle('visible', minimizeVisible); | |
| document.getElementById('btn-minimize').classList.toggle('active', minimizeVisible); | |
| } | |
| async function runMinimization() { | |
| if (minimizeRunning) return; | |
| if (!currentPDBData) { | |
| document.getElementById('minimize-status').textContent = 'No structure loaded'; | |
| return; | |
| } | |
| const smiles = getSMILESFromPDB(currentPDBData); | |
| if (!smiles) { | |
| document.getElementById('minimize-status').textContent = 'No SMILES found in PDB'; | |
| return; | |
| } | |
| // UI: disable button, show progress | |
| minimizeRunning = true; | |
| const runBtn = document.getElementById('minimize-run-btn'); | |
| runBtn.disabled = true; | |
| runBtn.textContent = 'Minimizing…'; | |
| document.getElementById('minimize-energy').textContent = 'Running…'; | |
| document.getElementById('minimize-energy').style.color = '#555'; | |
| document.getElementById('minimize-meta').textContent = 'OpenMM AMBER14 + GAFF-2.11'; | |
| document.getElementById('minimize-rmsd').textContent = ''; | |
| document.getElementById('minimize-status').textContent = 'Sending to OpenMM server…'; | |
| document.querySelectorAll('.view-btn').forEach(b => b.disabled = true); | |
| try { | |
| const resp = await fetch(MINIMIZE_SERVER + '/minimize', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ pdb: currentPDBData, smiles: smiles }), | |
| signal: AbortSignal.timeout(120000), // 120 s generous timeout | |
| }); | |
| if (!resp.ok) { | |
| const msg = await resp.text().catch(() => ''); | |
| throw new Error(msg || `HTTP ${resp.status}`); | |
| } | |
| const data = await resp.json(); | |
| if (data.error) throw new Error(data.error); | |
| if (!data.pdb) throw new Error('No PDB in response'); | |
| // Store original and minimised | |
| originalPDBData = currentPDBData; | |
| minimizedPDBData = data.pdb; | |
| // Display energy results | |
| const dE = data.finalEnergy - data.initialEnergy; | |
| const col = dE < -100 ? '#27ae60' : dE < 0 ? '#2980b9' : '#c0392b'; | |
| document.getElementById('minimize-energy').textContent = | |
| `${data.finalEnergy.toFixed(1)} kJ/mol`; | |
| document.getElementById('minimize-energy').style.color = col; | |
| document.getElementById('minimize-meta').textContent = | |
| `Initial: ${data.initialEnergy.toFixed(1)} kJ/mol | ΔE: ${dE.toFixed(1)} kJ/mol`; | |
| document.getElementById('minimize-rmsd').textContent = | |
| `RMSD: ${data.rmsd.toFixed(3)} Å (≤${data.steps} steps)`; | |
| document.getElementById('minimize-status').textContent = | |
| 'Minimisation complete. Structure loaded.'; | |
| // Load minimized PDB into viewer (single model, proven working) | |
| loadMinimizedPDB(data.pdb); | |
| showingMinimized = true; | |
| viewMode = 'minimized'; | |
| // Enable view buttons — starts showing minimized pose | |
| document.querySelectorAll('.view-btn').forEach(b => b.disabled = false); | |
| updateViewButtons(); | |
| // Zoom to ligand binding site so the pose is clearly visible | |
| const ligSel = getLigandSel(); | |
| if (ligSel) { | |
| viewer.zoomTo(ligSel); | |
| viewer.render(); | |
| } | |
| } catch (err) { | |
| document.getElementById('minimize-energy').textContent = 'Error'; | |
| document.getElementById('minimize-energy').style.color = '#c0392b'; | |
| document.getElementById('minimize-status').textContent = | |
| err.name === 'TimeoutError' | |
| ? 'Timeout (>120 s). Server may be busy or not running.' | |
| : `Error: ${err.message}`; | |
| console.error('Minimisation error:', err); | |
| } finally { | |
| minimizeRunning = false; | |
| runBtn.disabled = false; | |
| runBtn.textContent = 'Run OpenMM Minimization'; | |
| } | |
| } | |
| /** | |
| * @brief Load a minimised PDB into the main viewer, replacing the current model. | |
| * @param {string} pdbText PDB-format text from OpenMM minimisation server. | |
| * | |
| * Clears the viewer, adds the PDB as a single model, resets overlay state, | |
| * applies the current representation, and refreshes all analysis panels. | |
| */ | |
| function loadMinimizedPDB(pdbText) { | |
| currentPDBData = pdbText; | |
| viewer.clear(); | |
| viewer.addModel(pdbText, 'pdb'); | |
| strainHighlightShapes = []; | |
| strainHighlightLabel = null; | |
| elecShapes = []; | |
| elecLabels = []; | |
| lastConformerSmiles = ''; | |
| viewMode = null; | |
| applyRepr(currentRepr); | |
| updateStrainPanel(); | |
| updateElecPanel(); | |
| viewer.zoomTo(); | |
| viewer.render(); | |
| // Refresh 2D ligand depiction and conformer panel | |
| update2DLigand(); | |
| updateConformerPanel(); | |
| } | |
| /** | |
| * @brief Apply model-aware styling for overlay mode (two models loaded). | |
| * | |
| * Unlike applyRepr(), this function is designed specifically for two-model overlay: | |
| * - Model 0 (minimized): protein cartoon/ribbon + ligand ball+stick (chain-coloured) | |
| * - Model 1 (original): protein hidden (structural duplicate), ligand light-blue 70% | |
| * | |
| * Key differences from applyRepr(): | |
| * - Uses {model: N} selectors throughout to prevent doubled atom matching | |
| * - Skips SES ligand surface (avoids crash when surface spans two divergent poses) | |
| * - Skips binding-site residue detection (avoids O(4N²) on doubled atoms) | |
| */ | |
| function applyOverlayStyle() { | |
| viewer.removeAllSurfaces(); | |
| viewer.removeAllLabels(); | |
| // Remember selected atom for label restoration | |
| const _restoredSelectedAtom = _selectedAtom; | |
| _selectedLabel = null; | |
| // Hide everything first | |
| viewer.setStyle({model: 0}, {}); | |
| viewer.setStyle({model: 1}, {}); | |
| // ── Model 0 (minimized): protein backbone ────────────────────── | |
| const allM0Protein = viewer.selectedAtoms({model: 0, hetflag: false}); | |
| const ligSel0 = getLigandSel_model(0); | |
| const ligResns0 = ligSel0 | |
| ? new Set(viewer.selectedAtoms(ligSel0).map(a => a.resn)) | |
| : new Set(); | |
| const chains = [...new Set( | |
| allM0Protein.filter(a => !ligResns0.has(a.resn)).map(a => a.chain) | |
| )]; | |
| const cartoonThickness = 0.3; | |
| chains.forEach(ch => { | |
| const chainSel = {model: 0, chain: ch, hetflag: false}; | |
| if (currentRepr === 'ribbon') { | |
| viewer.setStyle(chainSel, { | |
| ribbon: { color: chainColor(ch), opacity: 0.90, | |
| thickness: 0.45, arrows: true } | |
| }); | |
| } else if (currentRepr === 'wireframe') { | |
| viewer.setStyle(chainSel, { | |
| line: { linewidth: 0.9, color: chainColor(ch), opacity: 0.65 } | |
| }); | |
| } else { | |
| viewer.setStyle(chainSel, { | |
| cartoon: { color: chainColor(ch), opacity: 0.90, | |
| thickness: cartoonThickness, arrows: true } | |
| }); | |
| } | |
| }); | |
| // ── Model 0 (minimized): ligand ball+stick (chain-coloured) ──── | |
| const ligChainCol = chains.length > 0 ? chainColor(chains[0]) : chainColor('A'); | |
| if (ligSel0) { | |
| viewer.setStyle(ligSel0, { | |
| stick: { radius: 0.12, colorscheme: { prop: 'elem', map: elemMap(ligChainCol) } }, | |
| sphere: { scale: 0.10, colorscheme: { prop: 'elem', map: elemMap(ligChainCol) } }, | |
| }); | |
| // Halo glow (same as applyRepr, but NO SES surface) | |
| viewer.addStyle(ligSel0, { | |
| sphere: { scale: 0.13, color: '#FF6200', opacity: 0.18 }, | |
| }); | |
| } | |
| // ── Model 1 (original): protein hidden, ligand light-blue ───── | |
| const ligSel1 = getLigandSel_model(1); | |
| if (ligSel1) { | |
| viewer.setStyle(ligSel1, { | |
| stick: { radius: 0.12, colorscheme: { prop: 'elem', map: elemMap('#80A0FF') }, opacity: 0.70 }, | |
| sphere: { scale: 0.10, colorscheme: { prop: 'elem', map: elemMap('#80A0FF') }, opacity: 0.70 }, | |
| }); | |
| } | |
| console.log('[Overlay] Styled model 0 (minimized) + model 1 (original ligand)'); | |
| // Re-register hover + click after style rebuild | |
| registerHoverable(); | |
| // Restore selection label if one existed | |
| if (_restoredSelectedAtom) { | |
| const a = _restoredSelectedAtom; | |
| const isLig = !PROTEIN_RESN.has(a.resn) && !WATER_RESN.has(a.resn); | |
| _selectedLabel = viewer.addLabel( | |
| isLig ? `${a.resn} [ligand]` : `${a.resn} ${a.resi} (${a.chain})`, { | |
| position: { x: a.x, y: a.y, z: a.z }, | |
| fontColor: '#003D5C', | |
| fontSize: 12, | |
| fontOpacity: 1.0, | |
| backgroundOpacity: 0.93, | |
| backgroundColor: '#E0F7FA', | |
| borderColor: '#00ACC1', | |
| borderThickness: 1.5, | |
| padding: 3, | |
| inFront: true, | |
| } | |
| ); | |
| } | |
| // Interactions: scope to model 0 only to prevent doubled detection | |
| if (interactionsVisible) { | |
| clearInteractionShapes(); | |
| const lig0 = ligSel0 ? viewer.selectedAtoms(ligSel0) : []; | |
| const ligR = new Set(lig0.map(a => a.resn)); | |
| const prot0 = viewer.selectedAtoms({model: 0, hetflag: false}).filter(a => !ligR.has(a.resn)); | |
| if (lig0.length && prot0.length) { | |
| const c = centroid(lig0); | |
| const near = prot0.filter(pa => dist3(pa, c) <= 12.0); | |
| const ix = detectInteractions(lig0, near); | |
| lastInteractions = ix; | |
| drawInteractions(ix); | |
| updateInteractionPanel(ix); | |
| updateAffinityPanel(); | |
| } | |
| } else { | |
| clearInteractionShapes(); | |
| const lig0 = ligSel0 ? viewer.selectedAtoms(ligSel0) : []; | |
| const ligR = new Set(lig0.map(a => a.resn)); | |
| const prot0 = viewer.selectedAtoms({model: 0, hetflag: false}).filter(a => !ligR.has(a.resn)); | |
| if (lig0.length && prot0.length) { | |
| const c = centroid(lig0); | |
| const near = prot0.filter(pa => dist3(pa, c) <= 12.0); | |
| const all = detectInteractions(lig0, near); | |
| lastInteractions = all; | |
| drawInteractions(all.filter(ix => ix.type === 'hbond')); | |
| updateAffinityPanel(); | |
| } | |
| } | |
| } | |
| /** | |
| * @brief Switch the viewer to a specific pose view mode. | |
| * | |
| * Three dedicated buttons for comparing original and minimised ligand poses: | |
| * - **Minimized** (blue): single model showing the OpenMM-minimised structure. | |
| * - **Original** (green): single model showing the pre-minimisation structure. | |
| * - **Overlay** (red): both PDBs loaded as separate 3Dmol.js models. | |
| * Protein atoms overlap (frozen during minimisation). The minimised ligand | |
| * keeps chain-coloured styling; the original ligand is shown in light-blue | |
| * at 70% opacity. Uses model index (not residue name) to distinguish poses. | |
| * | |
| * @param {string} mode One of 'minimized' | 'original' | 'overlay'. | |
| */ | |
| function setViewMode(mode) { | |
| if (!originalPDBData || !minimizedPDBData) return; | |
| if (mode === viewMode) return; // already in this mode — no-op | |
| const prevMode = viewMode; | |
| try { | |
| if (mode === 'minimized') { | |
| showingMinimized = true; | |
| viewer.clear(); | |
| viewer.addModel(minimizedPDBData, 'pdb'); | |
| currentPDBData = minimizedPDBData; | |
| applyRepr(currentRepr); | |
| } else if (mode === 'original') { | |
| showingMinimized = false; | |
| viewer.clear(); | |
| viewer.addModel(originalPDBData, 'pdb'); | |
| currentPDBData = originalPDBData; | |
| applyRepr(currentRepr); | |
| } else if (mode === 'overlay') { | |
| showingMinimized = true; | |
| viewer.clear(); | |
| viewer.addModel(minimizedPDBData, 'pdb'); // model 0 — minimized | |
| viewer.addModel(originalPDBData, 'pdb'); // model 1 — original | |
| currentPDBData = minimizedPDBData; | |
| // Use overlay-specific styling (model-aware, no SES surface) | |
| applyOverlayStyle(); | |
| } | |
| // Commit state only after successful rendering | |
| viewMode = mode; | |
| updateViewButtons(); | |
| // Zoom to ligand binding site (use model 0 in overlay to avoid doubled bbox) | |
| const ligSel = (mode === 'overlay') ? getLigandSel_model(0) : getLigandSel(); | |
| if (ligSel) viewer.zoomTo(ligSel); | |
| viewer.render(); | |
| } catch (err) { | |
| console.error('[setViewMode] Error switching to', mode, ':', err); | |
| viewMode = prevMode; | |
| updateViewButtons(); | |
| // Attempt recovery: reload the last good single-model state | |
| try { | |
| viewer.clear(); | |
| viewer.addModel(currentPDBData || minimizedPDBData, 'pdb'); | |
| applyRepr(currentRepr); | |
| viewer.zoomTo(); | |
| viewer.render(); | |
| } catch (e2) { | |
| console.error('[setViewMode] Recovery failed:', e2); | |
| } | |
| } | |
| } | |
| /** | |
| * @brief Sync the visual active state of the three view-mode buttons with viewMode. | |
| */ | |
| function updateViewButtons() { | |
| ['minimized', 'original', 'overlay'].forEach(m => { | |
| const btn = document.getElementById('view-btn-' + m); | |
| if (btn) btn.classList.toggle('active', viewMode === m); | |
| }); | |
| } | |
| // RDKit conformer server port (matches conformer_server.py PORT = 5051) | |
| const CONFORMER_SERVER = 'http://localhost:5051'; | |
| // ── Electrostatic repulsion server ─────────────────────────────────────────── | |
| // pdb2pqr pipeline server port (matches elec_server.py PORT = 8084). | |
| // When running, provides physics-based protein partial charges via pdb2pqr + | |
| // PROPKA protonation at the requested pH. The browser falls back to | |
| // computeElecRepulsion() (rule-based charges) if the server is unreachable. | |
| const ELEC_SERVER = 'http://localhost:8084'; | |
| // ── OpenMM minimisation server ──────────────────────────────────────────── | |
| // Runs AMBER14 + GAFF-2.11 energy minimisation on the full protein–ligand complex. | |
| // Start with: python3 minimize_server.py | |
| const MINIMIZE_SERVER = 'http://localhost:8085'; | |
| // State: minimisation panel | |
| let minimizeVisible = false; | |
| let minimizedPDBData = null; // PDB text after minimisation | |
| let originalPDBData = null; // PDB text before minimisation (for toggle-back) | |
| let showingMinimized = false; // true when displaying minimised structure | |
| let viewMode = null; // 'minimized' | 'original' | 'overlay' | null | |
| let minimizeRunning = false; // prevents double-click | |
| // State: pH and force-field currently selected in the elec panel controls. | |
| // These are kept in sync with the #elec-ph-input and #elec-ff-select widgets. | |
| // Updated by onElecSettingChange() when the user edits the controls. | |
| let currentElecPh = 7.0; // pH for pdb2pqr protonation (default physiological) | |
| let currentElecFf = 'AMBER'; // Protein force field for pdb2pqr charges | |
| /** | |
| * @brief Fetch a 3D conformer for a ligand and display it in the conformer viewer. | |
| * | |
| * Attempts to retrieve an ETKDGv3 + MMFF94s conformer from the local RDKit | |
| * conformer server (conformer_server.py). If the server is unreachable or | |
| * returns an error, falls back to displaying the bound ligand conformation | |
| * extracted from the currently loaded PDB data. | |
| * | |
| * @param {string} smiles - SMILES string of the ligand to generate a conformer for. | |
| * @returns {Promise<void>} Resolves when the conformer is rendered or an error message is shown. | |
| */ | |
| async function fetchAndShow3DConformer(smiles) { | |
| const statusEl = document.getElementById('conformer-status'); | |
| statusEl.textContent = 'Generating conformer (RDKit)…'; | |
| lastConformerSmiles = smiles; | |
| try { | |
| // ── Primary: local RDKit conformer server (conformer_server.py) ────── | |
| // ETKDGv3 embedding + MMFF94s minimisation, no external network needed. | |
| // Run: python3 conformer_server.py | |
| const url = CONFORMER_SERVER + '/conformer?smiles=' + encodeURIComponent(smiles); | |
| const resp = await fetch(url, { signal: AbortSignal.timeout(10000) }); | |
| if (!resp.ok) { | |
| const msg = await resp.text().catch(() => ''); | |
| throw new Error(msg || `HTTP ${resp.status}`); | |
| } | |
| const sdfText = await resp.text(); | |
| if (!sdfText || sdfText.trim().length < 20) throw new Error('Empty SDF response'); | |
| renderConformer(sdfText, 'sdf', 'RDKit ETKDGv3 + MMFF94s conformer'); | |
| } catch (err) { | |
| // ── Fallback: bound ligand from PDB ────────────────────────────────── | |
| if (currentPDBData) { | |
| const ligLines = currentPDBData.split('\n') | |
| .filter(l => l.startsWith('HETATM') && | |
| !['HOH','WAT','SOL'].includes(l.slice(17,20).trim())) | |
| .join('\n'); | |
| if (ligLines.trim().length > 0) { | |
| renderConformer(ligLines, 'pdb', | |
| 'Bound conformation (start conformer_server.py for RDKit 3D)'); | |
| return; | |
| } | |
| } | |
| statusEl.textContent = `Server not running — run: python3 conformer_server.py`; | |
| } | |
| } | |
| /** | |
| * @brief Render a molecular conformer in the 3D conformer sub-viewer. | |
| * | |
| * Creates the 3Dmol viewer instance on first call, then clears and loads | |
| * the provided molecular data as a ball-and-stick model with Jmol coloring. | |
| * | |
| * @param {string} data - Molecular structure data (SDF block or PDB HETATM lines). | |
| * @param {string} fmt - Format identifier for 3Dmol ('sdf' or 'pdb'). | |
| * @param {string} sourceLabel - Human-readable provenance label shown in the status text. | |
| */ | |
| function renderConformer(data, fmt, sourceLabel) { | |
| const divEl = document.getElementById('conformer-3d-div'); | |
| const statusEl = document.getElementById('conformer-status'); | |
| // Create the viewer once (div must be visible / have dimensions at this point) | |
| if (!conformerViewer) { | |
| conformerViewer = $3Dmol.createViewer(divEl, { | |
| backgroundColor: 'white', | |
| antialias: true, | |
| }); | |
| } | |
| conformerViewer.clear(); | |
| conformerViewer.addModel(data, fmt); | |
| conformerViewer.setStyle({}, { | |
| stick: { radius: 0.12, colorscheme: 'Jmol' }, | |
| sphere: { scale: 0.10, colorscheme: 'Jmol' }, | |
| }); | |
| conformerViewer.zoomTo(); | |
| conformerViewer.render(); | |
| statusEl.textContent = sourceLabel; | |
| } | |
| // ═══════════════════════════════════════════════════════════════ | |
| // 2D LIGAND PANEL (RDKit.js MinimalLib) | |
| // ═══════════════════════════════════════════════════════════════ | |
| let RDKitModule = null; // set by initRDKit() | |
| let currentPDBData = null; // raw PDB text of the currently loaded file | |
| /** | |
| * @brief Initialise the RDKit WebAssembly module for 2D structure depiction. | |
| * | |
| * Loads the RDKit MinimalLib WASM module asynchronously. Once loaded, if a | |
| * molecular structure is already displayed, triggers an immediate 2D ligand | |
| * rendering via update2DLigand(). | |
| */ | |
| function initRDKit() { | |
| if (typeof initRDKitModule !== 'function') { | |
| console.warn('RDKit script not available'); return; | |
| } | |
| initRDKitModule().then(rdkit => { | |
| RDKitModule = rdkit; | |
| // If a structure is already showing, render it immediately | |
| if (currentFileIdx >= 0) update2DLigand(); | |
| }).catch(err => console.warn('RDKit init failed:', err)); | |
| } | |
| /** | |
| * @brief Parse ligand atoms and bonds from HETATM/CONECT records in PDB text. | |
| * | |
| * Extracts all HETATM atoms matching the specified residue names and builds | |
| * connectivity from CONECT records. Bond orders are inferred from directional | |
| * CONECT counts (double bonds appear twice per direction). | |
| * | |
| * @param {string} pdbText - Raw PDB file text containing HETATM and CONECT records. | |
| * @param {string[]} resNames - Array of three-letter residue names to extract (e.g. ['LIG']). | |
| * @returns {?{atoms: Array<{x: number, y: number, z: number, elem: string}>, bonds: Array<[number, number, number]>}} | |
| * Object with 1-based indexed atoms and bonds ([atomIdx1, atomIdx2, bondOrder]), | |
| * or null if no matching HETATM atoms are found. | |
| */ | |
| function parseLigandFromPDB(pdbText, resNames) { | |
| const resSet = new Set(resNames); | |
| const lines = pdbText.split('\n'); | |
| // ── Collect HETATM atoms for the ligand ── | |
| const serialToData = {}; // serial → {x,y,z,elem} | |
| const orderedSerials = []; // preserves atom order | |
| for (const line of lines) { | |
| if (!line.startsWith('HETATM')) continue; | |
| const resn = line.substring(17, 20).trim(); | |
| if (!resSet.has(resn)) continue; | |
| const serial = parseInt(line.substring(6, 11)); | |
| const x = parseFloat(line.substring(30, 38)); | |
| const y = parseFloat(line.substring(38, 46)); | |
| const z = parseFloat(line.substring(46, 54)); | |
| // Element: columns 77-78 preferred, fall back to atom-name column | |
| let elem = line.length >= 78 ? line.substring(76, 78).trim() : ''; | |
| if (!elem) { | |
| const aname = line.substring(12, 16).trim(); | |
| elem = aname.replace(/[0-9]/g, '').replace(/[^A-Za-z]/g, '').substring(0, 2); | |
| } | |
| if (!elem) elem = 'C'; | |
| // Capitalise properly (e.g. "CL" → "Cl") | |
| elem = elem.length > 1 | |
| ? elem[0].toUpperCase() + elem[1].toLowerCase() | |
| : elem.toUpperCase(); | |
| orderedSerials.push(serial); | |
| serialToData[serial] = { x, y, z, elem }; | |
| } | |
| if (!orderedSerials.length) return null; | |
| // Build 1-based serial→idx mapping for the mol block | |
| const serialToIdx = {}; | |
| orderedSerials.forEach((s, i) => { serialToIdx[s] = i + 1; }); | |
| // ── Collect CONECT bonds for ligand atoms ── | |
| // PDB CONECT: each bond is listed once per direction (A→B and B→A separately). | |
| // Double bonds: the partner serial is repeated on the CONECT record. | |
| // We count per-direction occurrences, then divide by 2 for the canonical bond order. | |
| const bondCounts = {}; // "minIdx_maxIdx" → total directional count | |
| for (const line of lines) { | |
| if (!line.startsWith('CONECT')) continue; | |
| const s1 = parseInt(line.substring(6, 11)); | |
| const idx1 = serialToIdx[s1]; | |
| if (!idx1) continue; | |
| // Columns 12-16, 17-21, 22-26, 27-31 each hold a bonded serial (fixed-width 5) | |
| for (let col = 11; col < line.length; col += 5) { | |
| const tok = line.substring(col, col + 5).trim(); | |
| if (!tok) continue; | |
| const idx2 = serialToIdx[parseInt(tok)]; | |
| if (!idx2) continue; | |
| const key = Math.min(idx1, idx2) + '_' + Math.max(idx1, idx2); | |
| bondCounts[key] = (bondCounts[key] || 0) + 1; | |
| } | |
| } | |
| // Convert counts → bond order (clamp 1–3) | |
| const bonds = Object.entries(bondCounts).map(([key, cnt]) => { | |
| const [a1, a2] = key.split('_').map(Number); | |
| const order = Math.min(3, Math.max(1, Math.round(cnt / 2))); | |
| return [a1, a2, order]; | |
| }); | |
| const atoms = orderedSerials.map(s => serialToData[s]); | |
| return { atoms, bonds }; | |
| } | |
| // Build a V2000 MOL block from parsed atom/bond arrays | |
| function buildMolV2000(atoms, bonds) { | |
| const na = atoms.length, nb = bonds.length; | |
| const rows = [ | |
| '', | |
| ' 3Dmol 0D', | |
| '', | |
| `${String(na).padStart(3)}${String(nb).padStart(3)} 0 0 0 0 0 0 0 0999 V2000`, | |
| ]; | |
| for (const { x, y, z, elem } of atoms) { | |
| rows.push( | |
| `${x.toFixed(4).padStart(10)}${y.toFixed(4).padStart(10)}${z.toFixed(4).padStart(10)} ` + | |
| `${elem.padEnd(3)} 0 0 0 0 0 0 0 0 0 0 0 0` | |
| ); | |
| } | |
| for (const [a1, a2, ord] of bonds) { | |
| rows.push(`${String(a1).padStart(3)}${String(a2).padStart(3)}${String(ord).padStart(3)} 0 0 0 0`); | |
| } | |
| rows.push('M END'); | |
| return rows.join('\n'); | |
| } | |
| // Extract the SMILES string from "REMARK SMILES <smiles>" in a PDB file. | |
| // Skips "REMARK SMILES IDX ..." continuation lines. | |
| function getSMILESFromPDB(pdbText) { | |
| for (const line of pdbText.split('\n')) { | |
| if (!line.startsWith('REMARK SMILES')) continue; | |
| const rest = line.substring(14).trim(); // skip "REMARK SMILES " | |
| if (!rest || rest.startsWith('IDX')) continue; | |
| return rest; | |
| } | |
| return null; | |
| } | |
| // Render the 2D structure + SMILES for the currently loaded ligand. | |
| // The SMILES is read directly from the "REMARK SMILES" line in the PDB file | |
| // (written by AutoDock Vina / OpenBabel), so no atom/bond parsing is needed. | |
| // SMILES is shown immediately; the 2D SVG is rendered once RDKit is ready. | |
| function update2DLigand() { | |
| const svgWrap = document.getElementById('ligand-2d-svg-wrap'); | |
| const smilesEl = document.getElementById('ligand-smiles-val'); | |
| function setMsg(txt, isErr) { | |
| svgWrap.innerHTML = | |
| `<span id="ligand-2d-msg" style="font-size:0.71rem;color:${isErr ? '#c0392b' : '#B5CBE2'}">${txt}</span>`; | |
| } | |
| // Guard: need a loaded structure and raw PDB text | |
| if (!viewer || currentFileIdx < 0) { setMsg('—'); smilesEl.textContent = '—'; return; } | |
| if (!currentPDBData) { setMsg('No PDB data'); smilesEl.textContent = '—'; return; } | |
| // ── Step 1: Show SMILES string immediately (no RDKit needed) ─ | |
| const smiles = getSMILESFromPDB(currentPDBData); | |
| if (!smiles) { | |
| setMsg('No SMILES in file', true); | |
| smilesEl.textContent = '—'; | |
| return; | |
| } | |
| smilesEl.textContent = smiles; // always shown, even before RDKit loads | |
| // ── Step 2: Render 2D SVG via RDKit (requires WASM) ────────── | |
| if (!RDKitModule) { | |
| setMsg('2D image loading…'); // SMILES above is already visible | |
| return; | |
| } | |
| let mol2d = null; | |
| try { mol2d = RDKitModule.get_mol(smiles); } catch(_) {} | |
| if (!mol2d || !mol2d.is_valid()) { | |
| if (mol2d) mol2d.delete(); | |
| setMsg('Cannot parse SMILES', true); | |
| return; | |
| } | |
| let svg = ''; | |
| try { svg = mol2d.get_svg(160, 110); } catch(_) {} | |
| mol2d.delete(); | |
| if (svg) { | |
| // Transparent background so panel bg shows through | |
| svg = svg.replace(/<rect([^>]+)fill="#(?:FFFFFF|ffffff|FFF|fff)"([^>]*)>/g, | |
| '<rect$1fill="transparent"$2>'); | |
| svgWrap.innerHTML = svg; | |
| } else { | |
| setMsg('SVG error', true); | |
| } | |
| } | |
| // ── Zoom to ligand + binding site ──────────────────────────── | |
| function zoomLigand() { | |
| if (!viewer) return; | |
| const ligSel = getLigandSel(); | |
| const bsResidues = getBindingSiteResidues(5.0); | |
| if (bsResidues && ligSel) { | |
| // Zoom to binding site: ligand residues + nearby protein residues | |
| const bsResi = bsResidues.map(r => r.resi); | |
| const ligAtoms = viewer.selectedAtoms(ligSel); | |
| const ligResi = [...new Set(ligAtoms.map(a => a.resi))]; | |
| viewer.zoomTo({ resi: [...bsResi, ...ligResi] }, 800); | |
| } else if (ligSel) { | |
| viewer.zoomTo(ligSel, 800); | |
| } else { | |
| viewer.zoomTo(); | |
| } | |
| viewer.render(); | |
| } | |
| // ── Set representation ──────────────────────────────────────── | |
| function setRepr(repr) { | |
| currentRepr = repr; | |
| document.querySelectorAll('.repr-btn').forEach(b => b.classList.remove('active')); | |
| const ids = { 'ball-stick': 'btn-bs', 'surface': 'btn-surface', 'ribbon': 'btn-ribbon', 'wireframe': 'btn-wire' }; | |
| if (ids[repr]) document.getElementById(ids[repr]).classList.add('active'); | |
| document.getElementById('ip-repr').textContent = repr; | |
| if (viewer && currentFileIdx >= 0) { | |
| if (viewMode === 'overlay') { | |
| applyOverlayStyle(); | |
| viewer.render(); | |
| } else { | |
| applyRepr(repr); | |
| } | |
| } | |
| } | |
| // ── Navigate prev/next ──────────────────────────────────────── | |
| function navigate(delta) { | |
| if (!currentDataset) return; | |
| const files = DATASETS[currentDataset].files; | |
| const newIdx = currentFileIdx + delta; | |
| if (newIdx >= 0 && newIdx < files.length) loadFile(currentDataset, newIdx); | |
| } | |
| function updateNavButtons() { | |
| if (!currentDataset) return; | |
| const files = DATASETS[currentDataset].files; | |
| document.getElementById('btn-prev').disabled = currentFileIdx <= 0; | |
| document.getElementById('btn-next').disabled = currentFileIdx >= files.length - 1; | |
| } | |
| // ── Reset view ──────────────────────────────────────────────── | |
| function resetView() { | |
| if (viewer) { viewer.zoomTo(); viewer.render(); } | |
| } | |
| // ── Info panel ──────────────────────────────────────────────── | |
| function updateInfo(dataset, file, idx) { | |
| document.getElementById('ip-dataset').textContent = DATASETS[dataset].label; | |
| document.getElementById('ip-file').textContent = file.replace('_cmpx.pdb', ''); | |
| document.getElementById('ip-idx').textContent = `${idx + 1} / ${DATASETS[dataset].files.length}`; | |
| document.getElementById('ip-repr').textContent = currentRepr; | |
| } | |
| function toggleInfo() { | |
| infoVisible = !infoVisible; | |
| document.getElementById('info-panel').classList.toggle('visible', infoVisible); | |
| } | |
| // ── Loading overlay ─────────────────────────────────────────── | |
| function showLoading(show, filename) { | |
| const el = document.getElementById('loading-overlay'); | |
| el.classList.toggle('hidden', !show); | |
| if (show && filename) { | |
| document.getElementById('loading-text').textContent = 'Loading structure…'; | |
| document.getElementById('loading-sub').textContent = filename; | |
| } | |
| } | |
| // ── Error banner ────────────────────────────────────────────── | |
| function showError(msg) { | |
| const el = document.getElementById('error-banner'); | |
| el.textContent = msg; | |
| el.classList.remove('hidden'); | |
| setTimeout(() => el.classList.add('hidden'), 6000); | |
| } | |
| function hideError() { | |
| document.getElementById('error-banner').classList.add('hidden'); | |
| } | |
| // ── WebXR / Oculus VR ───────────────────────────────────────── | |
| function checkVRSupport() { | |
| // Button is always enabled — VR or fullscreen is attempted on click. | |
| // Just update the tooltip to indicate what's available. | |
| const btn = document.getElementById('vr-btn'); | |
| if (!navigator.xr) { | |
| btn.title = 'Fullscreen mode (serve over HTTPS for WebXR on Quest)'; | |
| return; | |
| } | |
| navigator.xr.isSessionSupported('immersive-vr').then(supported => { | |
| btn.title = supported | |
| ? 'Enter immersive VR (WebXR headset detected)' | |
| : 'Fullscreen mode (no VR headset — serve over HTTPS for Quest WebXR)'; | |
| }).catch(() => {}); | |
| } | |
| async function enterVR() { | |
| const btn = document.getElementById('vr-btn'); | |
| const canvas = document.querySelector('#mol-viewer canvas'); | |
| if (!canvas) { showError('Load a molecule first before entering VR.'); return; } | |
| // ── Attempt WebXR (Oculus Browser / PC with headset) ───────── | |
| if (navigator.xr) { | |
| const supported = await navigator.xr.isSessionSupported('immersive-vr').catch(() => false); | |
| if (supported) { | |
| try { | |
| const gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); | |
| if (!gl) throw new Error('No WebGL context'); | |
| // Make the existing 3Dmol GL context XR-compatible | |
| await gl.makeXRCompatible(); | |
| const session = await navigator.xr.requestSession('immersive-vr', { | |
| requiredFeatures: ['local'], | |
| optionalFeatures: ['local-floor', 'bounded-floor', 'hand-tracking'], | |
| }); | |
| const layer = new XRWebGLLayer(session, gl); | |
| session.updateRenderState({ baseLayer: layer }); | |
| // Prefer local-floor; fall back to local if floor tracking unavailable | |
| const refSpace = await session.requestReferenceSpace('local-floor') | |
| .catch(() => session.requestReferenceSpace('local')); | |
| btn.textContent = '🥽 Exit VR'; | |
| session.addEventListener('end', () => { | |
| btn.textContent = '🥽 Enter VR'; | |
| viewer.render(); | |
| }); | |
| const onXRFrame = (time, frame) => { | |
| session.requestAnimationFrame(onXRFrame); | |
| const pose = frame.getViewerPose(refSpace); | |
| if (pose) { | |
| gl.bindFramebuffer(gl.FRAMEBUFFER, layer.framebuffer); | |
| viewer.render(); | |
| } | |
| }; | |
| session.requestAnimationFrame(onXRFrame); | |
| return; // WebXR session started — done | |
| } catch (err) { | |
| console.warn('WebXR session failed, falling back to fullscreen:', err.message); | |
| } | |
| } | |
| } | |
| // ── Fallback: fullscreen the viewer panel ──────────────────── | |
| const panel = document.getElementById('viewer-panel'); | |
| const requestFS = panel.requestFullscreen | |
| || panel.webkitRequestFullscreen | |
| || panel.mozRequestFullScreen | |
| || panel.msRequestFullscreen; | |
| if (requestFS) { | |
| requestFS.call(panel).then(() => { | |
| btn.textContent = '🥽 Exit FS'; | |
| document.addEventListener('fullscreenchange', function onFSChange() { | |
| if (!document.fullscreenElement) { | |
| btn.textContent = '🥽 Enter VR'; | |
| document.removeEventListener('fullscreenchange', onFSChange); | |
| } | |
| }, { once: false }); | |
| }).catch(err => showError('Fullscreen failed: ' + err.message)); | |
| } else { | |
| showError('VR not available. On Meta Quest: serve this file over HTTPS for WebXR.'); | |
| } | |
| } | |
| // ── Hover highlight & tooltip ──────────────────────────────── | |
| const WATER_RESN = new Set(['HOH','WAT','SOL','TIP']); | |
| let _hoverShape = null; // kept so we removeShape() individually, not removeAllShapes() | |
| let _hoverLabel = null; // 3Dmol label shown only while cursor is on a residue | |
| // Named module-level callbacks so registerHoverable() can re-pass them after every | |
| // viewer.clear() call (clear() wipes atom hover-flags, so setHoverable must re-run). | |
| function _onHoverIn(atom, v, event) { | |
| if (WATER_RESN.has(atom.resn)) return; | |
| const tip = document.getElementById('mol-tooltip'); | |
| const isLig = atom.hetflag && !WATER_RESN.has(atom.resn); | |
| // Cursor tooltip (follows the mouse) | |
| tip.innerHTML = isLig | |
| ? `<b>${atom.resn}</b> <span class="tip-sub">${atom.elem} · chain ${atom.chain}</span>` | |
| : `<b>${atom.resn} ${atom.resi}</b> <span class="tip-sub">${atom.atom} · chain ${atom.chain}</span>`; | |
| const x = Math.min(event.clientX + 14, window.innerWidth - 170); | |
| const y = Math.max(event.clientY - 32, 6); | |
| tip.style.left = x + 'px'; | |
| tip.style.top = y + 'px'; | |
| tip.style.display = 'block'; | |
| // Amber glow sphere — removed via removeShape() so cylinders are untouched | |
| if (_hoverShape) { v.removeShape(_hoverShape); _hoverShape = null; } | |
| _hoverShape = v.addSphere({ | |
| center: { x: atom.x, y: atom.y, z: atom.z }, | |
| radius: isLig ? 0.55 : 0.70, | |
| color: '#FFB300', | |
| opacity: 0.42, | |
| }); | |
| // 3D residue label anchored in the scene — only for protein residues | |
| if (_hoverLabel) { v.removeLabel(_hoverLabel); _hoverLabel = null; } | |
| if (!isLig) { | |
| _hoverLabel = v.addLabel(`${atom.resn} ${atom.resi}`, { | |
| position: { x: atom.x, y: atom.y, z: atom.z }, | |
| fontColor: '#325880', | |
| fontSize: 11, | |
| fontOpacity: 1.0, | |
| backgroundOpacity: 0.85, | |
| backgroundColor: '#F5F8FB', | |
| borderColor: '#B5CBE2', | |
| borderThickness: 1, | |
| padding: 2, | |
| inFront: true, | |
| }); | |
| } | |
| v.render(); | |
| } | |
| function _onHoverOut(atom, v) { | |
| document.getElementById('mol-tooltip').style.display = 'none'; | |
| if (_hoverShape) { v.removeShape(_hoverShape); _hoverShape = null; } | |
| if (_hoverLabel) { v.removeLabel(_hoverLabel); _hoverLabel = null; } | |
| v.render(); | |
| } | |
| // ── Click-to-select residue ─────────────────────────────────── | |
| let _selectedAtom = null; // anchor atom of the currently selected residue | |
| let _selectedShape = null; // persistent cyan highlight sphere | |
| let _selectedLabel = null; // persistent 3D label anchored in the scene | |
| function _onAtomClick(atom, v) { | |
| if (WATER_RESN.has(atom.resn)) return; | |
| // Toggle off if clicking the same residue again | |
| if (_selectedAtom && | |
| _selectedAtom.chain === atom.chain && | |
| _selectedAtom.resi === atom.resi && | |
| _selectedAtom.resn === atom.resn) { | |
| clearSelection(); | |
| return; | |
| } | |
| // Remove previous selection visuals before adding new ones | |
| if (_selectedShape) { v.removeShape(_selectedShape); _selectedShape = null; } | |
| if (_selectedLabel) { v.removeLabel(_selectedLabel); _selectedLabel = null; } | |
| _selectedAtom = atom; | |
| const isLig = !PROTEIN_RESN.has(atom.resn) && !WATER_RESN.has(atom.resn); | |
| // Distance from this atom to the nearest ligand atom | |
| let distToLig = null; | |
| if (!isLig) { | |
| const ligSel = getLigandSel(); | |
| if (ligSel) { | |
| const ligAtoms = viewer.selectedAtoms(ligSel); | |
| let minD = Infinity; | |
| for (const la of ligAtoms) { | |
| const d = dist3(atom, la); | |
| if (d < minD) minD = d; | |
| } | |
| if (minD < Infinity) distToLig = minD; | |
| } | |
| } | |
| // ── Persistent cyan sphere ── | |
| _selectedShape = v.addSphere({ | |
| center: { x: atom.x, y: atom.y, z: atom.z }, | |
| radius: isLig ? 0.65 : 0.90, | |
| color: '#00BCD4', | |
| opacity: 0.55, | |
| }); | |
| // ── Persistent 3D label anchored in the scene ── | |
| const sceneLabel = isLig | |
| ? `${atom.resn} [ligand]` | |
| : `${atom.resn} ${atom.resi} (${atom.chain})`; | |
| _selectedLabel = v.addLabel(sceneLabel, { | |
| position: { x: atom.x, y: atom.y, z: atom.z }, | |
| fontColor: '#003D5C', | |
| fontSize: 12, | |
| fontOpacity: 1.0, | |
| backgroundOpacity: 0.93, | |
| backgroundColor: '#E0F7FA', | |
| borderColor: '#00ACC1', | |
| borderThickness: 1.5, | |
| padding: 3, | |
| inFront: true, | |
| }); | |
| // ── Bottom-centre DOM badge ── | |
| const badge = document.getElementById('selection-badge'); | |
| const badgeTxt = document.getElementById('sel-badge-text'); | |
| if (isLig) { | |
| badgeTxt.innerHTML = | |
| `<b>${atom.resn}</b> · ligand · ${atom.elem}`; | |
| } else { | |
| const distStr = distToLig !== null | |
| ? ` · <span class="sel-dist">${distToLig.toFixed(1)} Å to lig</span>` | |
| : ''; | |
| badgeTxt.innerHTML = | |
| `<b>${atom.resn} ${atom.resi}</b> · chain <b>${atom.chain}</b>${distStr} · ${atom.atom}`; | |
| } | |
| badge.classList.add('visible'); | |
| v.render(); | |
| } | |
| // Clear the current residue selection (sphere, label, badge). | |
| // Safe to call even when nothing is selected. | |
| function clearSelection() { | |
| const v = viewer; | |
| if (_selectedShape && v) { v.removeShape(_selectedShape); _selectedShape = null; } | |
| if (_selectedLabel && v) { v.removeLabel(_selectedLabel); _selectedLabel = null; } | |
| _selectedAtom = null; | |
| document.getElementById('selection-badge').classList.remove('visible'); | |
| if (v) v.render(); | |
| } | |
| // Call once at startup for the mousemove listener (DOM setup only — no atoms yet) | |
| function initHovering() { | |
| const tip = document.getElementById('mol-tooltip'); | |
| document.getElementById('mol-viewer').addEventListener('mousemove', e => { | |
| if (tip.style.display !== 'none') { | |
| tip.style.left = Math.min(e.clientX + 14, window.innerWidth - tip.offsetWidth - 6) + 'px'; | |
| tip.style.top = Math.max(e.clientY - 32, 6) + 'px'; | |
| } | |
| }); | |
| } | |
| // Call after every viewer.clear() + addModel() — i.e. from applyRepr() — | |
| // because clear() resets per-atom hover/click flags. | |
| function registerHoverable() { | |
| if (!viewer) return; | |
| viewer.setHoverable({}, true, _onHoverIn, _onHoverOut); | |
| viewer.setClickable({}, true, _onAtomClick); | |
| } | |
| // ── Keyboard shortcuts ──────────────────────────────────────── | |
| document.addEventListener('keydown', e => { | |
| if (e.key === 'ArrowRight') navigate(1); | |
| if (e.key === 'ArrowLeft') navigate(-1); | |
| if (e.key === '1') setRepr('ball-stick'); | |
| if (e.key === '2') setRepr('surface'); | |
| if (e.key === '3') setRepr('ribbon'); | |
| if (e.key === '4') setRepr('wireframe'); | |
| if (e.key === 'i' || e.key === 'I') toggleInfo(); | |
| if (e.key === 't' || e.key === 'T') toggleTargetPanel(); | |
| if (e.key === 'r' || e.key === 'R') resetView(); | |
| if (e.key === 'Escape') clearSelection(); | |
| }); | |
| // ── Startup ─────────────────────────────────────────────────── | |
| window.addEventListener('load', () => { | |
| initViewer(); | |
| initHovering(); | |
| initRDKit(); // load RDKit WASM for 2D depiction | |
| initDragDrop(); // enable drag-and-drop PDB upload on viewer | |
| buildTabs(); | |
| renderFileList(); | |
| checkVRSupport(); | |
| // Auto-select first dataset | |
| selectDataset('CRBN_Binders'); | |
| // Enter key triggers RCSB PDB fetch | |
| const rcsbInput = document.getElementById('rcsb-pdb-input'); | |
| if (rcsbInput) { | |
| rcsbInput.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') fetchRCSBPDB(); | |
| }); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |