Spaces:
Sleeping
Sleeping
Persistent client-side 3Dmol viewer: focus/grey/hide + in-place highlights (orientation preserved)
Browse files
app.py
CHANGED
|
@@ -17,10 +17,11 @@ CANDIDATES, not patent-ready antibodies — wet-lab validation still required.
|
|
| 17 |
"""
|
| 18 |
|
| 19 |
import io
|
|
|
|
|
|
|
| 20 |
import os
|
| 21 |
import time
|
| 22 |
import gradio as gr
|
| 23 |
-
import py3Dmol
|
| 24 |
from ImmuneBuilder import ABodyBuilder2
|
| 25 |
|
| 26 |
try:
|
|
@@ -358,13 +359,6 @@ def format_flags(flags, h3_len, ok):
|
|
| 358 |
|
| 359 |
|
| 360 |
# -------------------------------------------------------------------- viewer
|
| 361 |
-
# Which chains the viewer draws, for the "Show chains" selector.
|
| 362 |
-
CHAIN_VIEW = {
|
| 363 |
-
"Both chains": ("H", "L"),
|
| 364 |
-
"Heavy only (VH)": ("H",),
|
| 365 |
-
"Light only (VL)": ("L",),
|
| 366 |
-
}
|
| 367 |
-
|
| 368 |
# Per-residue colors for the clickable sequence tracks (CDRs reuse the 3D colors).
|
| 369 |
TRACK_COLORS = {
|
| 370 |
"H1": CDR_COLORS["H"]["1"], "H2": CDR_COLORS["H"]["2"], "H3": CDR_COLORS["H"]["3"],
|
|
@@ -384,77 +378,145 @@ def build_track(chain_label: str, residues):
|
|
| 384 |
return tokens, idxmap
|
| 385 |
|
| 386 |
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
|
| 423 |
|
| 424 |
# ---------------------------------------------------------------------- fold
|
| 425 |
-
def fold(heavy: str, light: str
|
| 426 |
-
"""Fold VH+VL, scan liabilities,
|
| 427 |
heavy, light = clean(heavy), clean(light)
|
| 428 |
reset_btn = gr.update(value="▶ Spin")
|
| 429 |
-
empty_manual = {"H": [], "L": []}
|
| 430 |
if not heavy or not light:
|
| 431 |
-
return ("
|
| 432 |
-
|
| 433 |
-
[], [], {"H": [], "L": []}, empty_manual)
|
| 434 |
try:
|
| 435 |
t0 = time.time()
|
| 436 |
antibody = PREDICTOR.predict({"H": heavy, "L": light})
|
| 437 |
dt = time.time() - t0
|
| 438 |
antibody.save("myabs_fold.pdb")
|
| 439 |
except Exception as e: # OpenMM refinement / numbering can occasionally fail
|
| 440 |
-
return (f"
|
| 441 |
-
|
| 442 |
-
[], [], {"H": [], "L": []}, empty_manual)
|
| 443 |
|
| 444 |
pdb = open("myabs_fold.pdb").read()
|
| 445 |
flags, highlights, h3_len, ok = developability(heavy, light, pdb)
|
| 446 |
-
chains = CHAIN_VIEW.get(chain_choice, ("H", "L"))
|
| 447 |
-
viewer = render_structure(pdb, spin=False, highlights=highlights, chains=chains)
|
| 448 |
status = (f"Folded in {dt:.1f} s · VH {len(heavy)} aa / VL {len(light)} aa · "
|
| 449 |
f"CDRs highlighted (H: yellow/orange/red, L: cyan/blue).")
|
| 450 |
flags_md = format_flags(flags, h3_len, ok)
|
| 451 |
|
| 452 |
-
# Clickable sequence tracks (index->imgt), and reset manual highlights.
|
| 453 |
h_tokens, h_idx = build_track("H", number_chain(heavy) or [])
|
| 454 |
l_tokens, l_idx = build_track("L", number_chain(light) or [])
|
| 455 |
idxmap = {"H": h_idx, "L": l_idx}
|
| 456 |
-
|
| 457 |
-
|
|
|
|
| 458 |
|
| 459 |
|
| 460 |
def load_library(name: str):
|
|
@@ -470,58 +532,13 @@ def load_library(name: str):
|
|
| 470 |
return entry["H"], entry["L"], prov
|
| 471 |
|
| 472 |
|
| 473 |
-
def
|
| 474 |
-
"""
|
| 475 |
-
|
| 476 |
-
label = "⏸ Stop" if spin else "▶ Spin"
|
| 477 |
-
if not pdb:
|
| 478 |
-
return spin, gr.update(value=label), gr.update()
|
| 479 |
-
chains = CHAIN_VIEW.get(chain_choice, ("H", "L"))
|
| 480 |
-
return spin, gr.update(value=label), render_structure(pdb, spin, highlights, chains, manual)
|
| 481 |
|
| 482 |
|
| 483 |
-
def
|
| 484 |
-
""
|
| 485 |
-
if not pdb:
|
| 486 |
-
return gr.update()
|
| 487 |
-
chains = CHAIN_VIEW.get(chain_choice, ("H", "L"))
|
| 488 |
-
return render_structure(pdb, spin, highlights, chains, manual)
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
def _rerender(pdb, highlights, spin, chain_choice, manual):
|
| 492 |
-
chains = CHAIN_VIEW.get(chain_choice, ("H", "L"))
|
| 493 |
-
return render_structure(pdb, spin, highlights, chains, manual)
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
def click_residue(chain_label, evt, idxmap, manual, pdb, highlights, spin, chain_choice):
|
| 497 |
-
"""Toggle the clicked residue's highlight and re-render (no re-fold)."""
|
| 498 |
-
manual = {"H": list(manual.get("H", [])), "L": list(manual.get("L", []))}
|
| 499 |
-
idx = getattr(evt, "index", None)
|
| 500 |
-
if isinstance(idx, (list, tuple)):
|
| 501 |
-
idx = idx[0] if idx else None
|
| 502 |
-
imap = idxmap.get(chain_label, []) if idxmap else []
|
| 503 |
-
if not pdb or idx is None or not isinstance(idx, int) or idx < 0 or idx >= len(imap):
|
| 504 |
-
return manual, gr.update()
|
| 505 |
-
imgt = imap[idx]
|
| 506 |
-
cur = manual[chain_label]
|
| 507 |
-
cur.remove(imgt) if imgt in cur else cur.append(imgt)
|
| 508 |
-
return manual, _rerender(pdb, highlights, spin, chain_choice, manual)
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
def click_heavy(idxmap, manual, pdb, highlights, spin, chain_choice, evt: gr.SelectData):
|
| 512 |
-
return click_residue("H", evt, idxmap, manual, pdb, highlights, spin, chain_choice)
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
def click_light(idxmap, manual, pdb, highlights, spin, chain_choice, evt: gr.SelectData):
|
| 516 |
-
return click_residue("L", evt, idxmap, manual, pdb, highlights, spin, chain_choice)
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
def clear_highlights(pdb, highlights, spin, chain_choice):
|
| 520 |
-
"""Drop all user-clicked residue highlights and re-render."""
|
| 521 |
-
manual = {"H": [], "L": []}
|
| 522 |
-
if not pdb:
|
| 523 |
-
return manual, gr.update()
|
| 524 |
-
return manual, _rerender(pdb, highlights, spin, chain_choice, manual)
|
| 525 |
|
| 526 |
|
| 527 |
with gr.Blocks(title="MyAbs") as demo: # theme moved to launch() in Gradio 6
|
|
@@ -546,11 +563,17 @@ with gr.Blocks(title="MyAbs") as demo: # theme moved to launch() in Gradio 6
|
|
| 546 |
status = gr.Markdown()
|
| 547 |
pdb_file = gr.File(label="Download structure (.pdb)")
|
| 548 |
with gr.Column(scale=3):
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 552 |
)
|
| 553 |
-
viewer = gr.HTML()
|
| 554 |
gr.HTML(LEGEND_HTML)
|
| 555 |
gr.Markdown(
|
| 556 |
"**Rotate it yourself** — _Touchscreen:_ one-finger drag = rotate · "
|
|
@@ -570,41 +593,35 @@ with gr.Blocks(title="MyAbs") as demo: # theme moved to launch() in Gradio 6
|
|
| 570 |
gr.Markdown("---")
|
| 571 |
flags_md = gr.Markdown()
|
| 572 |
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
manual_state = gr.State({"H": [], "L": []}) # user-clicked residues, per chain
|
| 578 |
|
| 579 |
-
fold_outputs = [
|
| 580 |
-
|
| 581 |
|
| 582 |
-
go.click(fold, inputs=[h_in, l_in
|
| 583 |
|
| 584 |
# Pick a therapeutic -> load its sequences + provenance -> auto-fold.
|
| 585 |
lib_dd.change(load_library, inputs=lib_dd, outputs=[h_in, l_in, provenance]).then(
|
| 586 |
-
fold, inputs=[h_in, l_in
|
| 587 |
)
|
| 588 |
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
)
|
|
|
|
|
|
|
|
|
|
| 593 |
|
| 594 |
-
#
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
outputs=viewer,
|
| 598 |
-
)
|
| 599 |
|
| 600 |
-
#
|
| 601 |
-
|
| 602 |
-
h_track.select(click_heavy, inputs=click_inputs, outputs=[manual_state, viewer])
|
| 603 |
-
l_track.select(click_light, inputs=click_inputs, outputs=[manual_state, viewer])
|
| 604 |
-
clear_btn.click(
|
| 605 |
-
clear_highlights, inputs=[pdb_state, hi_state, spin_state, chain_dd],
|
| 606 |
-
outputs=[manual_state, viewer],
|
| 607 |
-
)
|
| 608 |
|
| 609 |
if __name__ == "__main__":
|
| 610 |
# Local dev defaults to 127.0.0.1:7900. On an HF Docker Space the Dockerfile
|
|
|
|
| 17 |
"""
|
| 18 |
|
| 19 |
import io
|
| 20 |
+
import itertools
|
| 21 |
+
import json
|
| 22 |
import os
|
| 23 |
import time
|
| 24 |
import gradio as gr
|
|
|
|
| 25 |
from ImmuneBuilder import ABodyBuilder2
|
| 26 |
|
| 27 |
try:
|
|
|
|
| 359 |
|
| 360 |
|
| 361 |
# -------------------------------------------------------------------- viewer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
# Per-residue colors for the clickable sequence tracks (CDRs reuse the 3D colors).
|
| 363 |
TRACK_COLORS = {
|
| 364 |
"H1": CDR_COLORS["H"]["1"], "H2": CDR_COLORS["H"]["2"], "H3": CDR_COLORS["H"]["3"],
|
|
|
|
| 378 |
return tokens, idxmap
|
| 379 |
|
| 380 |
|
| 381 |
+
FOCUS_CHOICES = ["Both chains", "Heavy only (VH)", "Light only (VL)"]
|
| 382 |
+
MODE_CHOICES = ["Grey out others", "Hide others"]
|
| 383 |
+
|
| 384 |
+
_NONCE = itertools.count(1)
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
def build_payload(pdb: str, highlights) -> str:
|
| 388 |
+
"""JSON handed to the client-side viewer: the structure + liability positions.
|
| 389 |
+
The nonce guarantees the value changes each fold so the .change bridge fires."""
|
| 390 |
+
highlights = highlights or {}
|
| 391 |
+
return json.dumps({
|
| 392 |
+
"pdb": pdb,
|
| 393 |
+
"highlights": {"H": highlights.get("H", []), "L": highlights.get("L", [])},
|
| 394 |
+
"n": next(_NONCE),
|
| 395 |
+
})
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
def toggle_payload(chain_label: str, evt, idxmap) -> str:
|
| 399 |
+
"""JSON telling the client to toggle a lime stick on one clicked residue."""
|
| 400 |
+
idx = getattr(evt, "index", None)
|
| 401 |
+
if isinstance(idx, (list, tuple)):
|
| 402 |
+
idx = idx[0] if idx else None
|
| 403 |
+
imap = (idxmap or {}).get(chain_label, [])
|
| 404 |
+
imgt = imap[idx] if isinstance(idx, int) and 0 <= idx < len(imap) else None
|
| 405 |
+
return json.dumps({"chain": chain_label, "imgt": imgt, "n": next(_NONCE)})
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
# Client-side 3Dmol controller. One persistent viewer; every interaction updates
|
| 409 |
+
# it IN PLACE (no re-render, no zoomTo) so the camera / orientation is preserved.
|
| 410 |
+
# Loaded once via demo.load(js=...); it also injects 3Dmol.js from the CDN.
|
| 411 |
+
_CDR_JS = json.dumps(CDR_COLORS)
|
| 412 |
+
_RANGES_JS = json.dumps({k: list(v) for k, v in CDR_RANGES.items()})
|
| 413 |
+
CONTROLLER_JS = """
|
| 414 |
+
() => {
|
| 415 |
+
if (window.__myabsReady) return;
|
| 416 |
+
window.__myabsReady = true;
|
| 417 |
+
const CDR = __CDR__;
|
| 418 |
+
const RANGES = __RANGES__;
|
| 419 |
+
const rlist = (lo,hi) => { let a=[]; for(let i=lo;i<=hi;i++) a.push(i); return a; };
|
| 420 |
+
const S = window.myabsState = {viewer:null, highlights:{H:[],L:[]}, picks:{H:[],L:[]},
|
| 421 |
+
focus:["H","L"], mode:"grey", spinning:false};
|
| 422 |
+
|
| 423 |
+
window.myabsApply = () => {
|
| 424 |
+
const v = S.viewer; if(!v) return;
|
| 425 |
+
v.setStyle({}, {});
|
| 426 |
+
["H","L"].forEach(ch => {
|
| 427 |
+
if (S.focus.includes(ch)) {
|
| 428 |
+
v.addStyle({chain:ch}, {cartoon:{color:"#cfd8dc"}});
|
| 429 |
+
for (const c in RANGES){ const r=RANGES[c];
|
| 430 |
+
v.addStyle({chain:ch, resi:rlist(r[0],r[1])}, {cartoon:{color:CDR[ch][c]}}); }
|
| 431 |
+
const hl=S.highlights[ch]||[]; if(hl.length) v.addStyle({chain:ch, resi:hl},{stick:{color:"magenta",radius:0.3}});
|
| 432 |
+
const pk=S.picks[ch]||[]; if(pk.length) v.addStyle({chain:ch, resi:pk},{stick:{color:"lime",radius:0.3}});
|
| 433 |
+
} else if (S.mode === "grey") {
|
| 434 |
+
v.addStyle({chain:ch}, {cartoon:{color:"#c7ccd1", opacity:0.35}});
|
| 435 |
+
}
|
| 436 |
+
});
|
| 437 |
+
v.render();
|
| 438 |
+
};
|
| 439 |
+
|
| 440 |
+
window.myabsInit = () => {
|
| 441 |
+
if (S.viewer) return true;
|
| 442 |
+
const el = document.getElementById("myabs-viewer");
|
| 443 |
+
if (!el || !window.$3Dmol) return false;
|
| 444 |
+
S.viewer = $3Dmol.createViewer(el, {backgroundColor:"white"});
|
| 445 |
+
return true;
|
| 446 |
+
};
|
| 447 |
+
|
| 448 |
+
window.myabsLoad = (payload) => {
|
| 449 |
+
if (!payload) return;
|
| 450 |
+
let p; try { p = JSON.parse(payload); } catch(e){ return; }
|
| 451 |
+
if (!p.pdb) return;
|
| 452 |
+
if (!window.myabsInit()) { setTimeout(()=>window.myabsLoad(payload), 200); return; }
|
| 453 |
+
const v = S.viewer;
|
| 454 |
+
S.highlights = p.highlights || {H:[],L:[]}; S.picks = {H:[],L:[]}; S.spinning = false;
|
| 455 |
+
v.removeAllModels(); v.addModel(p.pdb, "pdb");
|
| 456 |
+
window.myabsApply();
|
| 457 |
+
v.zoomTo(); v.render(); // new molecule: recentering here is expected
|
| 458 |
+
};
|
| 459 |
+
|
| 460 |
+
window.myabsSetFocus = (choice) => {
|
| 461 |
+
const M = {"Both chains":["H","L"], "Heavy only (VH)":["H"], "Light only (VL)":["L"]};
|
| 462 |
+
S.focus = M[choice] || ["H","L"]; window.myabsApply(); // no zoomTo -> view kept
|
| 463 |
+
};
|
| 464 |
+
window.myabsSetMode = (m) => {
|
| 465 |
+
S.mode = (m && m.indexOf("Hide")>=0) ? "hide" : "grey"; window.myabsApply();
|
| 466 |
+
};
|
| 467 |
+
window.myabsToggleResidue = (payload) => {
|
| 468 |
+
let p; try { p = JSON.parse(payload); } catch(e){ return; }
|
| 469 |
+
if (p.imgt == null || !p.chain) return;
|
| 470 |
+
const arr = S.picks[p.chain] || (S.picks[p.chain]=[]);
|
| 471 |
+
const i = arr.indexOf(p.imgt); if (i>=0) arr.splice(i,1); else arr.push(p.imgt);
|
| 472 |
+
window.myabsApply();
|
| 473 |
+
};
|
| 474 |
+
window.myabsClear = () => { S.picks = {H:[],L:[]}; window.myabsApply(); };
|
| 475 |
+
window.myabsToggleSpin = () => {
|
| 476 |
+
const v = S.viewer; if (!v) return "▶ Spin";
|
| 477 |
+
S.spinning = !S.spinning; v.spin(S.spinning ? "y" : false);
|
| 478 |
+
return S.spinning ? "⏸ Stop" : "▶ Spin";
|
| 479 |
+
};
|
| 480 |
+
|
| 481 |
+
if (!window.$3Dmol) {
|
| 482 |
+
const s = document.createElement("script");
|
| 483 |
+
s.src = "https://3Dmol.org/build/3Dmol-min.js";
|
| 484 |
+
s.onload = () => window.myabsInit();
|
| 485 |
+
document.head.appendChild(s);
|
| 486 |
+
} else { window.myabsInit(); }
|
| 487 |
+
}
|
| 488 |
+
""".replace("__CDR__", _CDR_JS).replace("__RANGES__", _RANGES_JS)
|
| 489 |
|
| 490 |
|
| 491 |
# ---------------------------------------------------------------------- fold
|
| 492 |
+
def fold(heavy: str, light: str):
|
| 493 |
+
"""Fold VH+VL, scan liabilities, and hand the structure to the client viewer."""
|
| 494 |
heavy, light = clean(heavy), clean(light)
|
| 495 |
reset_btn = gr.update(value="▶ Spin")
|
|
|
|
| 496 |
if not heavy or not light:
|
| 497 |
+
return ("Enter both a heavy and a light chain.", "", None, "",
|
| 498 |
+
[], [], {"H": [], "L": []}, reset_btn)
|
|
|
|
| 499 |
try:
|
| 500 |
t0 = time.time()
|
| 501 |
antibody = PREDICTOR.predict({"H": heavy, "L": light})
|
| 502 |
dt = time.time() - t0
|
| 503 |
antibody.save("myabs_fold.pdb")
|
| 504 |
except Exception as e: # OpenMM refinement / numbering can occasionally fail
|
| 505 |
+
return (f"Fold failed: {e}", "", None, "",
|
| 506 |
+
[], [], {"H": [], "L": []}, reset_btn)
|
|
|
|
| 507 |
|
| 508 |
pdb = open("myabs_fold.pdb").read()
|
| 509 |
flags, highlights, h3_len, ok = developability(heavy, light, pdb)
|
|
|
|
|
|
|
| 510 |
status = (f"Folded in {dt:.1f} s · VH {len(heavy)} aa / VL {len(light)} aa · "
|
| 511 |
f"CDRs highlighted (H: yellow/orange/red, L: cyan/blue).")
|
| 512 |
flags_md = format_flags(flags, h3_len, ok)
|
| 513 |
|
|
|
|
| 514 |
h_tokens, h_idx = build_track("H", number_chain(heavy) or [])
|
| 515 |
l_tokens, l_idx = build_track("L", number_chain(light) or [])
|
| 516 |
idxmap = {"H": h_idx, "L": l_idx}
|
| 517 |
+
payload = build_payload(pdb, highlights) # -> client-side viewer via .change bridge
|
| 518 |
+
return (status, flags_md, "myabs_fold.pdb", payload,
|
| 519 |
+
h_tokens, l_tokens, idxmap, reset_btn)
|
| 520 |
|
| 521 |
|
| 522 |
def load_library(name: str):
|
|
|
|
| 532 |
return entry["H"], entry["L"], prov
|
| 533 |
|
| 534 |
|
| 535 |
+
def click_heavy(idxmap, evt: gr.SelectData):
|
| 536 |
+
"""Map a heavy-chain track click to its IMGT position for the client to toggle."""
|
| 537 |
+
return toggle_payload("H", evt, idxmap)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 538 |
|
| 539 |
|
| 540 |
+
def click_light(idxmap, evt: gr.SelectData):
|
| 541 |
+
return toggle_payload("L", evt, idxmap)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
|
| 543 |
|
| 544 |
with gr.Blocks(title="MyAbs") as demo: # theme moved to launch() in Gradio 6
|
|
|
|
| 563 |
status = gr.Markdown()
|
| 564 |
pdb_file = gr.File(label="Download structure (.pdb)")
|
| 565 |
with gr.Column(scale=3):
|
| 566 |
+
with gr.Row():
|
| 567 |
+
focus_dd = gr.Radio(choices=FOCUS_CHOICES, value="Both chains",
|
| 568 |
+
label="Focus chain")
|
| 569 |
+
mode_dd = gr.Radio(choices=MODE_CHOICES, value="Grey out others",
|
| 570 |
+
label="The non-focused chain is…")
|
| 571 |
+
# Persistent 3Dmol viewer container. Updated in place by the client-side
|
| 572 |
+
# controller (CONTROLLER_JS) so interactions never reset the camera.
|
| 573 |
+
viewer = gr.HTML(
|
| 574 |
+
'<div id="myabs-viewer" style="width:100%;height:580px;position:relative;'
|
| 575 |
+
'border:1px solid #eee;border-radius:8px"></div>'
|
| 576 |
)
|
|
|
|
| 577 |
gr.HTML(LEGEND_HTML)
|
| 578 |
gr.Markdown(
|
| 579 |
"**Rotate it yourself** — _Touchscreen:_ one-finger drag = rotate · "
|
|
|
|
| 593 |
gr.Markdown("---")
|
| 594 |
flags_md = gr.Markdown()
|
| 595 |
|
| 596 |
+
idxmap_state = gr.State({"H": [], "L": []}) # track index -> imgt, per chain
|
| 597 |
+
# Hidden bridges: server writes JSON here, a .change(js=...) hands it to the viewer.
|
| 598 |
+
load_box = gr.Textbox(visible=False) # fold -> myabsLoad
|
| 599 |
+
toggle_box = gr.Textbox(visible=False) # residue click -> myabsToggleResidue
|
|
|
|
| 600 |
|
| 601 |
+
fold_outputs = [status, flags_md, pdb_file, load_box, h_track, l_track,
|
| 602 |
+
idxmap_state, spin_btn]
|
| 603 |
|
| 604 |
+
go.click(fold, inputs=[h_in, l_in], outputs=fold_outputs)
|
| 605 |
|
| 606 |
# Pick a therapeutic -> load its sequences + provenance -> auto-fold.
|
| 607 |
lib_dd.change(load_library, inputs=lib_dd, outputs=[h_in, l_in, provenance]).then(
|
| 608 |
+
fold, inputs=[h_in, l_in], outputs=fold_outputs
|
| 609 |
)
|
| 610 |
|
| 611 |
+
# --- client-side viewer controls (no server round-trip, camera preserved) ---
|
| 612 |
+
load_box.change(None, inputs=[load_box], js="(p) => window.myabsLoad(p)")
|
| 613 |
+
toggle_box.change(None, inputs=[toggle_box], js="(p) => window.myabsToggleResidue(p)")
|
| 614 |
+
focus_dd.change(None, inputs=[focus_dd], js="(c) => window.myabsSetFocus(c)")
|
| 615 |
+
mode_dd.change(None, inputs=[mode_dd], js="(m) => window.myabsSetMode(m)")
|
| 616 |
+
spin_btn.click(None, outputs=[spin_btn], js="() => window.myabsToggleSpin()")
|
| 617 |
+
clear_btn.click(None, js="() => window.myabsClear()")
|
| 618 |
|
| 619 |
+
# Residue click: server maps track index -> IMGT, client applies the lime stick.
|
| 620 |
+
h_track.select(click_heavy, inputs=[idxmap_state], outputs=[toggle_box])
|
| 621 |
+
l_track.select(click_light, inputs=[idxmap_state], outputs=[toggle_box])
|
|
|
|
|
|
|
| 622 |
|
| 623 |
+
# Load 3Dmol.js + define the controller once, on page load.
|
| 624 |
+
demo.load(None, js=CONTROLLER_JS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 625 |
|
| 626 |
if __name__ == "__main__":
|
| 627 |
# Local dev defaults to 127.0.0.1:7900. On an HF Docker Space the Dockerfile
|