#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""蛋白结构与性能工作台 v2：内嵌 3Dmol 交互查看器（可缩放/点击残基）+ 结构域着色"""
import io
import json
import os
import sys

sys.path.insert(0, '/Users/liuxinyuan/Desktop/Hermes输出-工作类/脚本')
import brand_kit as BK  # noqa: E402

W = '/Users/liuxinyuan/Desktop/Hermes输出-工作类'
PD = W + '/数据/蛋白结构'
WEB = PD + '/web'
OUT = W + '/销售分析/蛋白结构与性能工作台.html'
AB = json.load(io.open(PD + '/_pmid_abstracts.json', encoding='utf-8'))
THREEDMOL = io.open(WEB + '/3Dmol-min.js', encoding='utf-8').read()

PROTEINS = [
    {
        'key': 'Taq', 'acc': 'P19821', 'name': 'Taq DNA 聚合酶', 'org': 'Thermus aquaticus',
        'use': 'PCR / qPCR / 探针法', 'pdb': '1TAQ', 'pdbweb': 'Taq.pdb', 'kind': '实验结构',
        'cov': (10, 832), 'pred': None, 'rmsd': None, 'plddt': None,
        'domains': [('5′→3′ 外切酶域', 1, 290, '#E08A1E', '水解探针与损伤碱基，决定 TaqMan 类探针法可用性'),
                    ('聚合酶域 · 手指', 291, 420, '#1852B6', '结合模板与 dNTP，决定底物识别'),
                    ('聚合酶域 · 手掌', 421, 610, '#2E7DD1', '催化中心，dNTP 掺入与磷酸二酯键形成'),
                    ('聚合酶域 · 拇指', 611, 832, '#4FA3E3', '结合双链 DNA，决定持续合成能力')],
        'assoc': [
            ('5′→3′ 外切酶活性可水解探针，是 TaqMan 类实时荧光 PCR 的酶学基础', '5′→3′ 外切酶域', '10510675', '直接'),
            ('F667Y 突变使 Taq 能掺入 ddNTP，成为 Sanger 测序用酶（AmpliTaq FS）', '聚合酶域 · 手掌', '8891223', '直接'),
        ],
    },
    {
        'key': 'Tth', 'acc': 'P52028', 'name': 'Tth DNA 聚合酶', 'org': 'Thermus thermophilus',
        'use': 'RT-PCR / 单管扩增', 'pdb': 'AlphaFold', 'pdbweb': 'Tth.pdb', 'kind': '预测模型',
        'cov': (296, 832), 'pred': (1, 295), 'rmsd': 1.86, 'plddt': 91.56,
        'domains': [('5′→3′ 外切酶域（预测）', 1, 290, '#E08A1E', '切探针/去损伤；该区段无实验结构'),
                    ('聚合酶域 · 手指', 291, 420, '#1852B6', '结合模板与 dNTP'),
                    ('聚合酶域 · 手掌', 421, 610, '#2E7DD1', '催化中心'),
                    ('聚合酶域 · 拇指', 611, 834, '#4FA3E3', '持续合成')],
        'assoc': [
            ('Mn²⁺ 条件下 Tth 聚合酶具有高效逆转录活性，可在一个管内完成逆转录 + 扩增', '聚合酶域', '1714296', '直接'),
        ],
    },
    {
        'key': 'MMLV', 'acc': 'P03355', 'name': 'M-MLV 逆转录酶', 'org': 'Moloney murine leukemia virus',
        'use': 'cDNA 合成 / RT-qPCR', 'pdb': 'AlphaFold', 'pdbweb': 'MMLV.pdb', 'kind': '预测模型',
        'cov': (24, 487), 'pred': (488, 671), 'rmsd': 1.45, 'plddt': 87.65, 'offset': 660,
        'domains': [('RT 域 · fingers/palm/thumb', 741, 1100, '#1852B6', '逆转录催化：以 RNA 为模板合成 cDNA'),
                    ('连接区（预测）', 1101, 1173, '#9AA7B8', '连接 RT 与 RNase H，影响构象协调'),
                    ('RNase H 域（预测）', 1174, 1320, '#E08A1E', '降解 RNA–DNA 杂交链中的 RNA，影响 cDNA 长度')],
        'assoc': [
            ('M-MLV RT 由 fingers / palm / thumb / connection 四个亚域加一个 RNase H 域组成', 'RT 域', '25959458', '直接'),
            ('RNase H 域突变会改变 DNA 合成与链转移行为', 'RNase H 域', '7539510', '直接'),
            ('RNase H 域不同突变对 RNase H 活性的影响存在差异', 'RNase H 域', '8576137', '直接'),
        ],
    },
    {
        'key': 'UNG', 'acc': 'P12295', 'name': '尿嘧啶-DNA 糖苷酶（UNG）', 'org': 'Escherichia coli K-12',
        'use': '防污染（dUTP/UNG 体系）', 'pdb': '3UF7', 'pdbweb': 'UNG.pdb', 'kind': '实验结构',
        'cov': (3, 225), 'pred': None, 'rmsd': None, 'plddt': None,
        'domains': [('UDG 催化域', 1, 229, '#0F9D8F', '切除 DNA 中的尿嘧啶，防遗留污染')],
        'sites': [(64, 'D', '质子受体（催化）')],
        'assoc': [
            ('UNG 用于去除 PCR 遗留污染（carryover contamination）', 'UDG 催化域', '33469645', '直接'),
            ('热不稳定 UNG 设计可降低对后续扩增的干扰、减少假阴性', 'UDG 催化域', '35183522', '直接'),
            ('大西洋鳕 UNG 为冷适应酶，最适反应温度低于人源 UNG', 'UDG 催化域', '11126771', '直接'),
            ('冷适应 UNG 的热展开研究支持其“低温高活、升温失活”的特性', 'UDG 催化域', '21959147', '直接'),
        ],
    },
]
SITES = {
    'Taq': [[667, 'F', 'F667Y：决定 ddNTP 掺入，Sanger 测序用酶（PMID 8891223）']],
    'UNG': [[64, 'D', 'D64：催化质子受体，切除尿嘧啶（UniProt 活性位点）']],
    'Tth': [],
    'MMLV': [],
}
_LEN = {}


def _len(p):
    if p['acc'] not in _LEN:
        _LEN[p['acc']] = json.load(io.open('%s/%s.json' % (PD, p['acc']), encoding='utf-8'))['length']
    return _LEN[p['acc']]


def domain_svg(prot, width=1180):
    L = 1738 if prot['key'] == 'MMLV' else (832 if prot['key'] in ('Taq', 'Tth') else 229)
    if prot['key'] == 'MMLV':
        L = 671  # AF 模型本地编号
    s = ['<svg viewBox="0 0 %d 112" class="domtrack">' % width]
    for name, a, b, col, _fn in prot['domains']:
        if prot['key'] == 'MMLV':
            a, b = a - prot['offset'], b - prot['offset']
        x = (a - 1) / L * width
        w = (b - a + 1) / L * width
        pred = '（预测）' in name
        s.append('<rect x="%.1f" y="22" width="%.1f" height="30" rx="3" fill="%s" opacity="%s"></rect>' % (x, w, col, 0.42 if pred else 1))
        if pred:
            s.append('<rect x="%.1f" y="22" width="%.1f" height="30" rx="3" fill="url(#hatch)" opacity="0.55"></rect>' % (x, w))
        narrow = w < 130
        ly = 68 if not narrow else 86
        s.append('<text x="%.1f" y="%d" font-size="%s" text-anchor="middle" fill="#43505f">%s</text>' % (x + w / 2, ly, '11' if narrow else '13', name.replace('（预测）', '')))
        s.append('<text x="%.1f" y="%d" font-size="11" text-anchor="middle" fill="#8a94a3">%d–%d</text>' % (x + w / 2, ly + 14, a, b))
    s.append('<defs><pattern id="hatch" width="6" height="6" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">'
             '<line x1="0" y1="0" x2="0" y2="6" stroke="#fff" stroke-width="2"/></pattern></defs></svg>')
    return ''.join(s)


def seq_html(prot):
    seq = json.load(io.open('%s/%s.json' % (PD, prot['acc']), encoding='utf-8'))['sequence']
    start, end = (700, 1340) if prot['key'] == 'MMLV' else (1, len(seq))
    seq = seq[start - 1:end]
    out = []
    for i, ch in enumerate(seq, start=start):
        col = ''
        for name, a, b, c, _fn in prot['domains']:
            if a <= i <= b:
                col = c
                break
        cls = ' class="sq" style="background:%s22;border-bottom:2px solid %s"' % (col, col) if col else ' class="sq"'
        mark = ''
        for site in prot.get('sites', []):
            if site[0] == i:
                mark = ' style="background:#C0392B;color:#fff;font-weight:800"'
                cls = ''
        out.append('<span%s%s>%s</span>' % (cls, mark, ch))
    return '<div class="seq">%s</div>' % ''.join(out)


panels = []
for idx, p in enumerate(PROTEINS):
    rows = []
    for claim, dom, pmid, strength in p['assoc']:
        t = AB.get(pmid, {})
        rows.append('<tr><td>%s</td><td>%s</td><td class="n"><a href="https://pubmed.ncbi.nlm.nih.gov/%s/" target="_blank">%s</a></td>'
                    '<td>%s</td><td class="pj">%s</td></tr>' % (claim, dom, pmid, pmid, strength, (t.get('title', '') or '')[:80]))
    legend = ''.join('<span><i style="background:%s"></i>%s（%d–%d）</span>' % (c, n.replace('（预测）', ''), a, b)
                     for n, a, b, c, _f in p['domains'])
    dombtns = ''.join('<button data-k="%s" data-a="dom" data-i="%d">%s</button>' % (
        p['key'], i, n.replace('（预测）', '').replace('聚合酶域 · ', '')) for i, (n, a, b, c, fn) in enumerate(p['domains'])) + \
        '<button data-k="%s" data-a="domall">全部结构</button>' % p['key']
    legendbox = '''
<div class="lgroup"><b>图形代表什么</b>
  <span class="li"><svg width="26" height="12"><path d="M2,6 Q6,0 10,6 T18,6 T24,6" stroke="#1852B6" stroke-width="2" fill="none"/></svg>螺旋 = α 螺旋</span>
  <span class="li"><svg width="26" height="12"><path d="M2,6 L18,6 M14,2 L18,6 L14,10" stroke="#1852B6" stroke-width="2" fill="none"/></svg>箭头 = β 折叠</span>
  <span class="li"><svg width="26" height="12"><path d="M2,6 C8,2 12,10 24,6" stroke="#9AA7B8" stroke-width="2" fill="none"/></svg>细线 = 无规卷曲</span>
</div>
<div class="lgroup"><b>颜色 = 结构域（含功能）</b>
  %s
</div>
<div class="lgroup"><b>标记与操作</b>
  <span class="li"><i style="background:#C0392B"></i>红色棒状 = 选中残基 / 催化位点</span>
  <span class="li"><i class="hatchbox"></i>斜纹 = AlphaFold 预测区</span>
  <span class="li">左键拖动 = 旋转 ｜ 滚轮 = 缩放</span>
  <span class="li">平移模式拖动 或 右键拖动 = 平移视野</span>
  <span class="li">点击残基 = 显示名称与编号</span>
  <span class="li">勾选「域聚焦显示侧链」= 该域叠加棒状侧链</span>
</div>''' % ''.join(
        '<span class="li"><i style="background:%s"></i><b>%s</b>（%d–%d）：%s</span>' % (c, n.replace('（预测）', ''), a, b, fn)
        for n, a, b, c, fn in p['domains'])
    badge = '<span class="badge %s">%s</span>' % ('exp' if p['kind'] == '实验结构' else 'pred', p['kind'])
    facts = ''.join('<tr><td style="color:#6b7684;width:38%%">%s</td><td>%s</td></tr>' % kv for kv in [
        ('UniProt', p['acc']), ('长度', '%d aa' % _len(p)), ('结构来源', p['pdb']),
        ('实验解析', '%d–%d' % p['cov']),
        ('预测补全', ('%s（pLDDT %s）' % ('是' if p['pred'] else '无', p['plddt'])) if p['pred'] else '无'),
        ('对齐 RMSD', ('%.2f Å' % p['rmsd']) if p['rmsd'] else '—'), ('主要用途', p['use']),
    ])
    site_note = ''
    if p.get('sites'):
        site_note = '<div class="fn">催化位点：%s</div>' % '；'.join('%s%d（%s）' % (s[1], s[0], s[2]) for s in p['sites'])
    panels.append("""
<div class="panel%(on)s" id="p-%(key)s">
  <div class="card"><h2>%(name)s <span>%(acc)s · %(org)s · %(len)d aa</span> %(badge)s</h2>
    <div class="grid2">
      <div>
        <div class="viewer" id="v-%(key)s"><div class="panlayer" id="pan-%(key)s"></div>
          <button class="fsbtn" data-k="%(key)s" data-a="fs">⛶ 全屏</button>
          <div class="vcap" id="cap-%(key)s"></div></div>
        <div class="ctrl">
          <button data-k="%(key)s" data-a="fit">全览</button>
          <button data-k="%(key)s" data-a="in">放大 +</button>
          <button data-k="%(key)s" data-a="out">缩小 −</button>
          <button data-k="%(key)s" data-a="stick">侧链/原子</button>
          <button data-k="%(key)s" data-a="spin">自转</button>
          <button data-k="%(key)s" data-a="pan">平移模式</button>
          <button data-k="%(key)s" data-a="left">←</button>
          <button data-k="%(key)s" data-a="right">→</button>
          <button data-k="%(key)s" data-a="up">↑</button>
          <button data-k="%(key)s" data-a="down">↓</button>
          <button data-k="%(key)s" data-a="reset">还原默认视角</button>
          <input class="resi" placeholder="残基号" inputmode="numeric" style="width:78px;padding:6px 8px;border:1px solid #cfd8e6;border-radius:6px;font-size:12px">
          <button data-k="%(key)s" data-a="go">定位残基</button>
        </div>
        <div class="dombtns">%(dombtns)s
          <label class="chk"><input type="checkbox" class="domstick" data-k="%(key)s"> 域聚焦显示侧链</label>
          %(keysite)s
        </div>
        <div class="fn">拖动旋转 ｜ 滚轮缩放（可到单残基）｜ <b>点击残基</b>显示名称与编号</div>
      </div>
      <div><h3 style="margin-top:0">关键事实</h3>
        <table class="facts"><tbody>%(facts)s</tbody></table>
        %(site_note)s
        <h3>图例（图形 / 颜色 / 操作）</h3>%(legendbox)s
      </div>
    </div>
    <h3>结构域轨道</h3>%(domsvg)s
    <div class="fn">斜纹＝预测区（AlphaFold），实色＝实验解析区。三维视图与轨道同色系。</div>
  </div>
  <div class="card"><h2>序列 <span>按结构域着色；深红为催化位点</span></h2>%(seq)s</div>
  <div class="card"><h2>结构域 ↔ 性能关联 <span>每条附 PMID</span></h2>
    <table><thead><tr><th style="width:38%%">关联论断</th><th style="width:16%%">结构域</th><th style="width:10%%" class="n">PMID</th><th style="width:9%%">证据强度</th><th>文献标题</th></tr></thead>
    <tbody>%(rows)s</tbody></table>
    <div class="fn">“直接”＝文献结论直接支持该论断；未读原文或间接推断的条目不收录。</div>
  </div>
</div>""" % {
        'on': ' on' if idx == 0 else '', 'key': p['key'], 'name': p['name'], 'acc': p['acc'], 'org': p['org'],
        'len': _len(p), 'badge': badge, 'facts': facts, 'site_note': site_note, 'legend': legend,
        'legendbox': legendbox, 'dombtns': dombtns,
        'keysite': ('<label class="chk"><input type="checkbox" class="onlykey" data-k="%s"> 只显示关键残基</label>'
                    '<button data-k="%s" data-a="key">关键残基</button>' % (p['key'], p['key'])) if SITES.get(p['key']) else '',
        'domsvg': domain_svg(p), 'seq': seq_html(p), 'rows': '\n'.join(rows),
    })

DOMAINS_JS = []
for p in PROTEINS:
    ds = []
    for n, a, b, c, fn in p['domains']:
        if p['key'] == 'MMLV':
            a, b = a - p['offset'], b - p['offset']
        ds.append([a, b, c, n.replace('（预测）', ''), fn])
    DOMAINS_JS.append('"%s":[%s]' % (p['key'], ','.join('[%d,%d,"%s",%s,%s]' % (
        x[0], x[1], x[2], json.dumps(x[3], ensure_ascii=False), json.dumps(x[4], ensure_ascii=False)) for x in ds)))

PDB_BLOCKS = ''.join('<script type="text/plain" id="pdb-%s">%s</script>' % (
    p['key'], io.open(WEB + '/' + p['pdbweb'], encoding='utf-8', errors='ignore').read()) for p in PROTEINS)

CSS = """
*{box-sizing:border-box}body{margin:0;background:#f4f6fa;font-family:-apple-system,"PingFang SC",sans-serif;color:#1b2430;font-size:13px;line-height:1.6}
.wrap{max-width:1240px;margin:0 auto;padding:0 18px 40px}
h1{font-size:21px;margin:14px 0 4px}.meta{color:#5f6b7a;font-size:12.2px;margin-bottom:12px}
.pbar{display:flex;gap:6px;flex-wrap:wrap;margin:10px 0 14px}
.pbtn{padding:8px 16px;border:1px solid #cfd8e6;background:#fff;border-radius:8px;cursor:pointer;font-weight:600;font-size:13px}
.pbtn.on{background:#1852B6;color:#fff;border-color:#1852B6}
.panel{display:none}.panel.on{display:block}
.card{background:#fff;border:1px solid #e3e8f1;border-radius:10px;padding:14px 16px;margin-bottom:14px}
.card h2{font-size:15px;margin:0 0 10px;padding-left:9px;border-left:3px solid #1F4E79}
.card h2 span{font-size:10.8px;color:#8a94a3;font-weight:600;margin-left:6px}
.badge{display:inline-block;font-size:10px;font-weight:800;padding:1px 7px;border-radius:8px;margin-left:6px}
.badge.exp{background:#e8f6ee;color:#1c7c54}.badge.pred{background:#fff4e0;color:#8a5a12}
h3{font-size:13px;color:#1F4E79;margin:14px 0 6px}
.grid2{display:grid;grid-template-columns:1.15fr 1fr;gap:16px}
.viewer{height:520px;border:1px solid #e3e8f1;border-radius:8px;background:#fff;position:relative}
.ctrl{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
.ctrl button{padding:6px 11px;border:1px solid #cfd8e6;background:#fff;border-radius:6px;cursor:pointer;font-size:12px}
.ctrl button:hover{border-color:#1852B6;color:#1852B6}
.legend{display:flex;gap:12px;flex-wrap:wrap;font-size:11.3px;color:#43505f}
.lgroup{border-top:1px solid #eef1f6;padding:7px 0 5px}
.lgroup b{display:block;font-size:11.6px;color:#1F4E79;margin-bottom:4px}
.lgroup .li{display:inline-flex;align-items:center;gap:5px;font-size:11.4px;color:#43505f;margin:2px 12px 2px 0}
.lgroup .li i{width:11px;height:11px;border-radius:2px;display:inline-block}
.hatchbox{background:repeating-linear-gradient(45deg,#c9d3e0,#c9d3e0 2px,#fff 2px,#fff 5px);border:1px solid #b7c3d3}
.panlayer{position:absolute;inset:0;display:none;cursor:grab;z-index:5}
.fsbtn{position:absolute;top:8px;right:8px;z-index:6;background:rgba(31,78,121,.92);color:#fff;border:none;border-radius:6px;padding:5px 10px;font-size:11.5px;cursor:pointer}
.fsbtn:hover{background:#1852B6}
.vcap{position:absolute;left:10px;right:10px;bottom:10px;z-index:6;background:rgba(31,78,121,.92);color:#fff;padding:7px 10px;border-radius:7px;font-size:11.8px;line-height:1.5;display:none}
.vcap b{color:#FFD479}
.viewer:fullscreen{height:100vh;width:100vw;background:#fff;border-radius:0}
.viewer:fullscreen .vcap{display:block;font-size:14px;padding:10px 14px}
.dombtns{display:flex;gap:6px;flex-wrap:wrap;margin-top:7px}
.dombtns button{padding:5px 10px;border:1px solid #D3E2F8;background:#F4F8FE;color:#1852B6;border-radius:14px;cursor:pointer;font-size:11.8px}
.dombtns button:hover{background:#1852B6;color:#fff;border-color:#1852B6}
.chk{display:inline-flex;align-items:center;gap:5px;font-size:11.8px;color:#43505f;margin-left:8px;cursor:pointer;user-select:none}
.chk input{accent-color:#1852B6}
.panlayer.on{display:block}
.panlayer.grabbing{cursor:grabbing}
.legend span{display:inline-flex;align-items:center;gap:5px}
.legend i{width:12px;height:12px;border-radius:2px;display:inline-block}
svg.domtrack{width:100%;height:auto;display:block;margin:4px 0 2px}
.seq{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11.2px;line-height:1.85;word-break:break-all;background:#fbfcfe;border:1px solid #eef1f6;border-radius:6px;padding:10px;max-height:260px;overflow:auto}
.sq{padding:1px 0}
table{width:100%;border-collapse:collapse;font-size:11.8px}
th{background:#1F4E79;color:#fff;padding:7px;text-align:left}th.n{text-align:right}
td{padding:6px 7px;border-bottom:1px solid #eef1f6;vertical-align:top}
td.n{text-align:right;white-space:nowrap}td.pj{color:#6b7684;font-size:11.2px}
a{color:#1852B6;text-decoration:none}a:hover{text-decoration:underline}
.fn{color:#6b7684;font-size:11.3px;margin-top:7px}
.facts{font-size:11.8px}.facts td{padding:4px 6px;border-bottom:1px solid #f1f4f9}
.box{background:#fffaf0;border:1px solid #f3e2bf;border-radius:8px;padding:10px 12px}
@media(max-width:900px){.grid2{grid-template-columns:1fr}.bior-tagwrap{display:none!important}.viewer{height:360px}.seq{font-size:10.2px}}
"""

NAV = ''.join('<button class="pbtn%s" data-p="%s">%s</button>' % (' on' if i == 0 else '', p['key'], p['name'].split('（')[0])
              for i, p in enumerate(PROTEINS))

html = """<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>蛋白结构与性能关联工作台</title><style>__CSS__</style>
<script>__3DMOL__</script></head><body><div class="wrap">
<h1>蛋白结构与性能关联工作台</h1>
<div class="meta">Taq / Tth / M-MLV RT / UNG（E. coli）。三维视图可旋转、滚轮缩放、点击残基；颜色与结构域轨道一致。数据：UniProt / PDB / AlphaFold DB / PubMed。截至 2026-10-17。</div>
<div class="pbar" id="pbar">__NAV__</div>
__PANELS__
<div class="card"><h2>口径与来源</h2>
<div class="grid2">
<div><h3 style="margin-top:0">数据源</h3><ul>
<li>UniProt：P19821（Taq）、P52028（Tth）、P03355（M-MLV Gag-Pol）、P12295（E. coli UNG）</li>
<li>实验结构：1TAQ（Taq）、8XK9（Tth）、4MH8（M-MLV）、3UF7（UNG）</li>
<li>三维视图：Taq/UNG 用实验结构；Tth/MMLV 用 AlphaFold 模型（实验结构覆盖不全）</li>
<li>文献：PubMed，逐条列 PMID</li>
</ul></div>
<div class="box"><h3 style="margin-top:0">必须知道的 3 个限制</h3><ul>
<li><b>预测模型不是实验结构</b>：Tth/MMLV 的三维视图来自 AlphaFold，只能示意。</li>
<li><b>Tth 外切域无实验结构</b>：8XK9 只解析到 296 位。</li>
<li><b>结构域边界为文献近似</b>：手指/手掌/拇指边界不同文献略有差异。</li>
</ul></div>
</div></div>
__FOOTER__
</div>
__PDBS__
<script>
var DOMAINS={__DOMAINS__};
var SITES=__SITES__;
var k0=1.0;
var V={}, INIT={}, STICK={}, SPIN={}, SAVED={}, PAN={}, LAST={};
function colorFor(k,resi){
  var ds=DOMAINS[k]||[];
  for(var i=0;i<ds.length;i++){ if(resi>=ds[i][0]&&resi<=ds[i][1]) return ds[i][2]; }
  return '#B8C2D0';
}
function initV(k){
  if(V[k]) return V[k];
  var el=document.getElementById('v-'+k);
  if(!el) return null;
  var v=$3Dmol.createViewer(el,{backgroundColor:'white',antialias:true});
  v.addModel(document.getElementById('pdb-'+k).textContent,'pdb');
  v.setStyle({},{cartoon:{colorfunc:function(a){return colorFor(k,a.resi);}}});
  v.zoomTo(); v.zoom(1.25); v.render();
  v.setHoverable({},true,function(a){
    if(!a) return;
    el.title=a.resn+' '+a.resi;
  });
  v.setClickable({},true,function(a){
    if(!a) return;
    v.removeAllLabels();
    v.addLabel(a.resn+' '+a.resi,{position:a,backgroundColor:'#1F4E79',fontColor:'white',
      fontSize:13,backgroundOpacity:0.88,inFront:true});
    v.render();
  });
  V[k]=v; INIT[k]=true;
  SAVED[k]=v.getView();
  var layer=document.getElementById('pan-'+k);
  if(layer && !layer.dataset.bound){
    layer.dataset.bound='1';
    var drag=null;
    layer.addEventListener('mousedown',function(e){ drag={x:e.clientX,y:e.clientY}; layer.classList.add('grabbing'); e.preventDefault(); });
    window.addEventListener('mousemove',function(e){
      if(!drag) return;
      var dx=e.clientX-drag.x, dy=e.clientY-drag.y;
      drag={x:e.clientX,y:e.clientY};
      v.translateScene(dx*k0, -dy*k0);
      v.render();
    });
    window.addEventListener('mouseup',function(){ if(drag){ drag=null; layer.classList.remove('grabbing'); } });
  }
  return v;
}
document.getElementById('pbar').addEventListener('click',function(e){
  var b=e.target.closest('.pbtn'); if(!b) return;
  document.querySelectorAll('.pbtn').forEach(function(x){x.classList.toggle('on',x===b);});
  document.querySelectorAll('.panel').forEach(function(p){p.classList.toggle('on',p.id==='p-'+b.dataset.p);});
  setTimeout(function(){ initV(b.dataset.p); },30);
});
document.addEventListener('click',function(e){
  var b=e.target.closest('button[data-k]'); if(!b||!b.dataset.a) return;
  var k=b.dataset.k, a=b.dataset.a, v=initV(k); if(!v) return;
  if(a==='fit'){ v.zoomTo(); v.render(); }
  if(a==='in'){ v.zoom(1.3); v.render(); }
  if(a==='out'){ v.zoom(0.77); v.render(); }
  if(a==='stick'){
    STICK[k]=!STICK[k];
    if(STICK[k]) v.setStyle({},{cartoon:{colorfunc:function(x){return colorFor(k,x.resi);}},stick:{radius:0.12}});
    else v.setStyle({},{cartoon:{colorfunc:function(x){return colorFor(k,x.resi);}}});
    v.render();
  }
  if(a==='spin'){ SPIN[k]=!SPIN[k]; v.spin(SPIN[k]?'y':false); }
  if(a==='pan'){
    PAN[k]=!PAN[k];
    var ly=document.getElementById('pan-'+k);
    if(ly) ly.classList.toggle('on',PAN[k]);
    b.textContent=PAN[k]?'退出平移':'平移模式';
    b.style.background=PAN[k]?'#1852B6':'#fff';
    b.style.color=PAN[k]?'#fff':'#1b2430';
  }
  if(a==='left'){ v.translateScene(24,0); v.render(); }
  if(a==='right'){ v.translateScene(-24,0); v.render(); }
  if(a==='up'){ v.translateScene(0,-24); v.render(); }
  if(a==='down'){ v.translateScene(0,24); v.render(); }
  if(a==='reset'){
    if(SAVED[k]){ v.setView(SAVED[k]); }
    v.setStyle({},{cartoon:{colorfunc:function(x){return colorFor(k,x.resi);}}});
    v.removeAllLabels();
    var cap=document.getElementById('cap-'+k); if(cap) cap.style.display='none';
    LAST[k]=null;
    v.render();
  }
  if(a==='fs'){
    var el=document.getElementById('v-'+k);
    if(!document.fullscreenElement){
      if(el.requestFullscreen) el.requestFullscreen();
    } else { document.exitFullscreen(); }
  }
  if(a==='dom'){
    var i=+b.dataset.i, d=DOMAINS[k][i];
    v.setStyle({},{cartoon:{colorfunc:function(x){
      return (x.resi>=d[0]&&x.resi<=d[1])?'#D93025':'#DCE2EA';
    }}});
    v.removeAllLabels();
    LAST[k]={t:'dom',i:i};
    var cb=document.querySelector('.domstick[data-k="'+k+'"]');
    var ok=document.querySelector('.onlykey[data-k="'+k+'"]');
    if(ok && ok.checked && (SITES[k]||[]).length){
      (SITES[k]||[]).forEach(function(site){
        if(site[0]>=d[0] && site[0]<=d[1]){
          v.addStyle({resi:site[0]},{stick:{radius:0.30,color:'#C0392B'}});
          v.addLabel(site[1]+site[0],{position:v.selectedAtoms({resi:site[0]})[0]||{x:0,y:0,z:0},
            backgroundColor:'#C0392B',fontColor:'white',fontSize:12,inFront:true});
        }
      });
    } else if(cb && cb.checked){
      v.addStyle({resi:d[0]+'-'+d[1]},{stick:{radius:0.09,color:'#D93025'}});
    }
    v.zoomTo(); v.zoom(1.25);
    var sel=v.selectedAtoms({}).filter(function(x){return x.resi>=d[0]&&x.resi<=d[1];});
    if(sel.length){
      v.addLabel(d[3],{position:sel[Math.floor(sel.length/2)],backgroundColor:'#C0392B',fontColor:'white',fontSize:13,inFront:true});
    }
    v.render();
    var cap=document.getElementById('cap-'+k);
    if(cap){ cap.style.display='block'; cap.innerHTML='<b>'+d[3]+'（'+d[0]+'–'+d[1]+'）</b>：'+d[4]; }
  }
  if(a==='key'){
    var ss=SITES[k]||[]; if(!ss.length) return;
    v.setStyle({},{cartoon:{colorfunc:function(x){return colorFor(k,x.resi);}}});
    v.removeAllLabels();
    var first=null;
    ss.forEach(function(site){
      v.addStyle({resi:site[0]},{stick:{radius:0.32,color:'#C0392B'}});
      var at=v.selectedAtoms({resi:site[0]});
      if(at.length){ if(!first) first=at[0];
        v.addLabel(site[1]+site[0],{position:at[0],backgroundColor:'#C0392B',fontColor:'white',fontSize:13,inFront:true}); }
    });
    if(first){ v.center({x:first.x,y:first.y,z:first.z}); v.zoom(2.2); }
    v.render();
    var cap=document.getElementById('cap-'+k);
    if(cap){ cap.style.display='block'; cap.innerHTML='<b>关键残基</b>：'+ss.map(function(x){return x[2];}).join('；'); }
  }
  if(a==='domall'){
    v.setStyle({},{cartoon:{colorfunc:function(x){return colorFor(k,x.resi);}}});
    v.removeAllLabels(); v.zoomTo(); v.zoom(1.25); v.render(); LAST[k]=null;
    var cap=document.getElementById('cap-'+k); if(cap) cap.style.display='none';
  }
  if(a==='go'){
    var inp=b.parentNode.querySelector('.resi');
    var n=parseInt((inp&&inp.value||'').trim(),10);
    if(!n||n<1){ if(inp) inp.focus(); return; }
    var ats=v.selectedAtoms({resi:n});
    if(!ats.length){ v.removeAllLabels(); v.addLabel('未找到残基 '+n,{position:{x:0,y:0,z:0},backgroundColor:'#C0392B',fontColor:'white',fontSize:12}); v.render(); return; }
    v.removeAllLabels();
    v.addStyle({resi:n},{stick:{radius:0.30},cartoon:{color:'#C0392B'}});
    v.addLabel(ats[0].resn+' '+n,{position:ats[0],backgroundColor:'#C0392B',fontColor:'white',fontSize:13,backgroundOpacity:0.9,inFront:true});
    v.zoomTo({resi:n}); v.zoom(0.55); v.render();
  }
});
initV('Taq');
window.__V=V;
document.addEventListener('fullscreenchange',function(){
  var fs=!!document.fullscreenElement;
  document.querySelectorAll('.fsbtn').forEach(function(b){ b.textContent = fs?'✕ 退出全屏':'⛶ 全屏'; });
  setTimeout(function(){ Object.keys(V).forEach(function(k){
    try{
      var v=V[k]; v.resize();
      var L=LAST[k];
      if(L && L.t==='dom'){
        var d=DOMAINS[k][L.i];
        v.setStyle({},{cartoon:{colorfunc:function(x){
          return (x.resi>=d[0]&&x.resi<=d[1])?'#D93025':'#DCE2EA';
        }}});
        var cb2=document.querySelector('.domstick[data-k="'+k+'"]');
        if(cb2 && cb2.checked){ v.addStyle({resi:d[0]+'-'+d[1]},{stick:{radius:0.09,color:'#D93025'}}); }
        v.zoomTo(); v.zoom(1.3);
      } else { v.zoomTo(); v.zoom(1.25); }
      v.render();
    }catch(e){}
  }); },140);
});
</script></body></html>"""

html = (html.replace('__CSS__', CSS).replace('__3DMOL__', THREEDMOL).replace('__NAV__', NAV)
        .replace('__PANELS__', '\n'.join(panels)).replace('__PDBS__', PDB_BLOCKS)
        .replace('__DOMAINS__', ','.join(DOMAINS_JS))
        .replace('__SITES__', json.dumps(SITES, ensure_ascii=False)))
html = BK.inject(html, theme='dark', tag='蛋白结构与性能 · 4 酶',
                 footer_extra='内部资料 · 请勿外发 ｜ UniProt / PDB / AlphaFold DB / PubMed')
html = html.replace('__FOOTER__', '')
io.open(OUT, 'w', encoding='utf-8').write(html)
print('✅ %.1f MB' % (len(html.encode()) / 1024 / 1024))
