molecular-viewer / index.html
Tc-43's picture
Upload folder using huggingface_hub
a9d9fb2 verified
<!DOCTYPE html>
<!--
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 &amp; 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(' &nbsp;·&nbsp; ')
: '<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}&thinsp;${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)}&thinsp;Å to lig</span>`
: '';
badgeTxt.innerHTML =
`<b>${atom.resn}&thinsp;${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>