| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"/> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"/> |
| <title>MutVar β Protein Mutation Impact</title> |
| <link rel="preconnect" href="https://fonts.googleapis.com"/> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/> |
| <link href="https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,wght@0,300;0,400;0,600;1,400&family=DM+Sans:wght@300;400;500&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"/> |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/molstar@4/build/viewer/molstar.css"/> |
| <style> |
| |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
| html { font-size: 14px; -webkit-font-smoothing: antialiased; } |
| |
| :root { |
| |
| --font-body: 'Source Serif 4', Georgia, serif; |
| --font-ui: 'DM Sans', system-ui, sans-serif; |
| --font-mono: 'JetBrains Mono', 'Fira Code', monospace; |
| |
| --font-serif: var(--font-body); |
| --font-sans: var(--font-ui); |
| |
| --bg: #fafaf8; |
| --bg-primary: #fafaf8; |
| --bg-secondary: #f4f3ef; |
| --bg-card: #ffffff; |
| --bg-surface: #f3f2ee; |
| --bg-inset: #eceae5; |
| --border: #e0dfd8; |
| --border-strong:#c8c7bf; |
| --border-light: #e8e5df; |
| --text: #1a1a18; |
| --text-primary: #1a1a18; |
| --text-secondary:#5a5a52; |
| --text-2: #4a4845; |
| --text-muted: #9a9a90; |
| --text-3: #7a776f; |
| --red: #c0392b; |
| --red-bg: #fdf0ee; |
| --red-border: #f0c5c0; |
| --green: #1e8449; |
| --green-bg: #edf6f1; |
| --green-border: #b4dcc7; |
| --amber: #d68910; |
| --amber-bg: #fdf3e8; |
| --amber-border: #f0d5a8; |
| --mech-stability: #922b21; |
| --mech-interface: #1a5276; |
| --mech-pocket: #4a1a6b; |
| --mech-unassigned: #717d7e; |
| --accent: #2d5a8e; |
| --accent-light: #e8f0f8; |
| --gauge-bg: #e8e5df; |
| --shadow-sm: 0 1px 3px rgba(0,0,0,.07); |
| } |
| body { font-family: var(--font-sans); background: var(--bg); color: var(--text); min-height: 100vh; } |
| |
| |
| #view-search { display: none; } |
| #view-results { display: none; } |
| body[data-view="search"] #view-search { display: block; } |
| body[data-view="results"] #view-results { display: flex; flex-direction: column; height: calc(100vh - 52px); overflow: hidden; } |
| |
| |
| .site-header { border-bottom: 1px solid var(--border); background: var(--bg-card); position: sticky; top: 0; z-index: 100; } |
| .header-inner { max-width: 1100px; margin: 0 auto; padding: 0 2rem; height: 52px; display: flex; align-items: center; justify-content: space-between; } |
| .header-left { display: flex; align-items: center; gap: 1rem; } |
| .site-logo { font-family: var(--font-ui); font-size: .9rem; font-weight: 500; color: var(--text-primary); text-decoration: none; display: flex; align-items: center; gap: .4rem; letter-spacing: .01em; } |
| .logo-mark { color: var(--accent); font-size: .6rem; } |
| .logo-name { color: var(--text-secondary); } |
| .logo-divider { color: var(--text-muted); } |
| .logo-tool { color: var(--text-primary); font-weight: 500; } |
| .site-nav { display: flex; gap: 1.8rem; align-items: center; } |
| .nav-link { font-family: var(--font-ui); font-size: .82rem; color: var(--text-secondary); text-decoration: none; letter-spacing: .02em; transition: color .15s; } |
| .nav-link:hover { color: var(--accent); } |
| .nav-link.active { color: var(--text-primary); font-weight: 500; } |
| .nav-dropdown { position: relative; } |
| .nav-dropdown-trigger { cursor: pointer; user-select: none; } |
| .nav-dropdown-menu { display: none; position: absolute; top: calc(100% + 6px); left: 50%; transform: translateX(-50%); background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,.08); min-width: 220px; padding: .4rem; z-index: 200; } |
| .nav-dropdown:hover .nav-dropdown-menu { display: block; } |
| .nav-dropdown-menu::before { content: ''; position: absolute; top: -6px; left: 0; right: 0; height: 6px; } |
| .nav-dropdown-item { display: flex; justify-content: space-between; align-items: center; padding: .55rem .8rem; border-radius: 5px; text-decoration: none; transition: background .1s; } |
| .nav-dropdown-item:not(.disabled):hover { background: var(--bg-secondary); } |
| .nav-dropdown-item.disabled { opacity: .45; cursor: not-allowed; pointer-events: none; } |
| .dropdown-item-name { font-family: var(--font-ui); font-size: .83rem; color: var(--text-primary); font-weight: 500; } |
| .dropdown-item-desc { font-family: var(--font-ui); font-size: .75rem; color: var(--text-muted); } |
| .dropdown-item-badge { font-family: var(--font-ui); font-size: .65rem; color: var(--text-muted); background: var(--bg-secondary); border: 1px solid var(--border); padding: .1rem .35rem; border-radius: 3px; } |
| .nav-dropdown-divider { height: 1px; background: var(--border); margin: .3rem .5rem; } |
| .header-back { |
| font-size: .78rem; font-family: var(--font-ui); |
| color: var(--text-secondary); background: none; border: 1px solid var(--border); |
| cursor: pointer; padding: .3rem .8rem; border-radius: 4px; display: none; align-items: center; gap: 4px; |
| transition: background .15s; |
| } |
| .header-back:hover { background: var(--bg-surface); color: var(--text-primary); } |
| body[data-view="results"] .header-back { display: flex; } |
| |
| |
| .landscape-banner { margin: .8rem 0 0; border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 0 6px 6px 0; background: var(--accent-light); padding: .9rem 1.2rem; } |
| .landscape-banner-inner { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } |
| .landscape-banner-text { display: flex; align-items: center; gap: .8rem; } |
| .landscape-icon { font-size: 1.2rem; color: var(--accent); flex-shrink: 0; } |
| .landscape-banner-text strong { display: block; font-family: var(--font-ui); font-size: .88rem; font-weight: 500; color: var(--text-primary); } |
| .landscape-banner-text span { font-family: var(--font-ui); font-size: .8rem; color: var(--text-secondary); } |
| .landscape-btn { font-family: var(--font-ui); font-size: .83rem; font-weight: 500; color: var(--accent); border: 1px solid var(--accent); padding: .45rem 1rem; border-radius: 4px; text-decoration: none; white-space: nowrap; transition: background .15s; flex-shrink: 0; } |
| .landscape-btn:hover { background: var(--accent); color: #fff; } |
| |
| |
| .site-footer { border-top: 1px solid var(--border); margin-top: 4rem; padding: 2rem; } |
| .footer-inner { max-width: 1100px; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; } |
| .footer-left, .footer-right { font-family: var(--font-ui); font-size: .78rem; color: var(--text-muted); } |
| .footer-right { display: flex; gap: 1.5rem; } |
| .footer-right a { color: var(--text-muted); text-decoration: none; } |
| .footer-right a:hover { color: var(--accent); } |
| |
| |
| #view-search { max-width: 640px; margin: 0 auto; padding: 64px 24px 48px; } |
| .search-hero h1 { font-family: var(--font-serif); font-size: 30px; font-weight: 600; line-height: 1.25; margin-bottom: 8px; } |
| .search-hero p { font-size: 14px; color: var(--text-3); line-height: 1.6; margin-bottom: 32px; } |
| .search-hero p a { color: var(--accent); text-decoration: none; } |
| .search-hero p a:hover { text-decoration: underline; } |
| .search-form-label { display: block; font-size: 12px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: var(--text-2); margin-bottom: 8px; } |
| #query-input { |
| width: 100%; min-height: 120px; padding: 12px 14px; |
| font-family: var(--font-mono); font-size: 13px; line-height: 1.7; |
| background: white; border: 1px solid var(--border); border-radius: 6px; |
| resize: vertical; color: var(--text); outline: none; |
| transition: border-color .15s, box-shadow .15s; |
| } |
| #query-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37,99,235,.1); } |
| #query-input::placeholder { color: var(--text-3); } |
| .search-format-hint { margin-top: 8px; font-size: 12px; color: var(--text-3); line-height: 1.6; } |
| .search-format-hint code { font-family: var(--font-mono); background: var(--bg-inset); padding: 1px 5px; border-radius: 3px; color: var(--text-2); } |
| .search-examples { margin-top: 16px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } |
| .search-examples-label { font-size: 11px; color: var(--text-3); font-weight: 600; text-transform: uppercase; letter-spacing: .05em; margin-right: 2px; } |
| .example-chip { font-family: var(--font-mono); font-size: 11.5px; padding: 4px 10px; background: var(--bg-surface); border: 1px solid var(--border); border-radius: 100px; cursor: pointer; color: var(--text-2); transition: all .15s; } |
| .example-chip:hover { background: var(--text); border-color: var(--text); color: white; } |
| .search-actions { margin-top: 20px; display: flex; align-items: center; gap: 12px; } |
| #btn-analyze { padding: 9px 20px; background: var(--text); color: white; border: none; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; transition: opacity .15s; } |
| #btn-analyze:hover { opacity: .85; } |
| #btn-analyze:disabled { opacity: .4; cursor: not-allowed; } |
| #tsv-status { font-size: 12px; font-family: var(--font-mono); color: var(--text-3); } |
| #search-errors { margin-top: 16px; display: none; } |
| .error-item { font-size: 12.5px; font-family: var(--font-mono); color: var(--red); padding: 6px 10px; background: var(--red-bg); border: 1px solid var(--red-border); border-radius: 4px; margin-bottom: 4px; } |
| |
| |
| #results-body { display: grid; grid-template-columns: 1fr 400px; flex: 1; overflow: hidden; } |
| #panel-left { display: flex; flex-direction: column; overflow: hidden; border-right: 1px solid var(--border); } |
| #panel-right { display: flex; flex-direction: column; overflow-y: auto; background: var(--bg); } |
| |
| |
| #position-chart { |
| flex-shrink: 0; |
| display: none; |
| background: var(--bg-surface); |
| border-bottom: 1px solid var(--border); |
| cursor: crosshair; |
| position: relative; |
| overflow: hidden; |
| } |
| #position-chart canvas { display: block; } |
| #position-chart .chart-title { |
| position: absolute; top: 5px; right: 8px; |
| font-family: var(--font-mono); font-size: 9px; |
| color: var(--text-3); pointer-events: none; |
| } |
| #position-filter-badge { |
| display: none; |
| padding: 3px 12px; |
| background: #eff6ff; |
| border-bottom: 1px solid #bfdbfe; |
| font-size: 11px; font-family: var(--font-mono); |
| color: var(--accent); |
| flex-shrink: 0; |
| } |
| #position-filter-badge button { |
| background: none; border: none; color: var(--accent); |
| cursor: pointer; font-size: 11px; font-family: inherit; |
| margin-left: 6px; text-decoration: underline; padding: 0; |
| } |
| |
| |
| #table-toolbar { |
| padding: 8px 12px; |
| border-bottom: 1px solid var(--border); |
| display: flex; align-items: center; gap: 8px; flex-wrap: wrap; |
| background: var(--bg); flex-shrink: 0; |
| } |
| .filter-chips { display: flex; gap: 4px; flex-wrap: wrap; } |
| .chip { |
| font-size: 11px; font-weight: 500; padding: 3px 9px; |
| border-radius: 100px; border: 1px solid var(--border); |
| background: var(--bg); color: var(--text-2); cursor: pointer; |
| transition: all .12s; white-space: nowrap; |
| } |
| .chip:hover { background: var(--bg-surface); } |
| .chip.active { background: var(--text); color: white; border-color: var(--text); } |
| .toolbar-right { margin-left: auto; display: flex; align-items: center; gap: 6px; } |
| #mutation-filter { |
| font-family: var(--font-mono); font-size: 11px; |
| padding: 3px 8px; border: 1px solid var(--border); |
| border-radius: 4px; background: var(--bg); color: var(--text); |
| outline: none; width: 130px; transition: border-color .15s, box-shadow .15s; |
| } |
| #mutation-filter:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(37,99,235,.08); } |
| #mutation-filter::placeholder { color: var(--text-3); } |
| #results-count { font-family: var(--font-mono); font-size: 11px; color: var(--text-3); white-space: nowrap; } |
| #btn-share, #btn-export { |
| font-size: 11.5px; font-weight: 500; padding: 3px 9px; |
| background: var(--bg-surface); border: 1px solid var(--border); |
| border-radius: 4px; cursor: pointer; color: var(--text-2); |
| transition: all .12s; white-space: nowrap; |
| } |
| #btn-share:hover, #btn-export:hover { background: var(--bg-inset); color: var(--text); } |
| #share-done { display: none; font-size: 10px; font-family: var(--font-mono); color: var(--green); } |
| |
| |
| #results-header { padding: 10px 16px 0; flex-shrink: 0; } |
| .results-title { font-family: var(--font-serif); font-size: 17px; font-weight: 600; color: var(--text); margin-bottom: 2px; } |
| .results-subtitle { font-size: 11px; color: var(--text-3); font-family: var(--font-mono); } |
| #table-scroll { overflow: auto; flex: 1; outline: none; } |
| table { width: 100%; border-collapse: collapse; font-size: 12.5px; } |
| thead th { |
| font-size: 10.5px; font-weight: 600; letter-spacing: .06em; |
| text-transform: uppercase; color: var(--text-3); background: var(--bg-inset); |
| padding: 7px 8px; border-bottom: 1px solid var(--border); |
| position: sticky; top: 0; cursor: pointer; user-select: none; |
| white-space: nowrap; z-index: 10; |
| } |
| thead th:hover { color: var(--text-2); } |
| thead th.sort-asc::after { content: ' β'; } |
| thead th.sort-desc::after { content: ' β'; } |
| thead th::after { opacity: 0.3; } |
| tbody tr { cursor: pointer; height: 34px; border-bottom: 1px solid var(--border-light); } |
| tbody tr:hover td { background: var(--bg-surface); } |
| tbody tr.selected td { background: #eff6ff; border-bottom-color: #bfdbfe; } |
| tbody tr.selected td:first-child { border-left: 2px solid var(--accent); } |
| tbody tr.vs-spacer td { pointer-events: none; border: none; padding: 0; } |
| td { padding: 4px 8px; vertical-align: middle; height: 34px; } |
| .cell-id { font-family: var(--font-mono); font-size: 12px; font-weight: 500; color: var(--text); white-space: nowrap; } |
| .cell-id .protein-id { color: var(--text-3); } |
| |
| |
| .gauge-wrap { display: flex; align-items: center; gap: 6px; min-width: 100px; } |
| .gauge-track { position: relative; flex: 1; height: 5px; background: var(--gauge-bg); border-radius: 3px; overflow: visible; } |
| .gauge-fill { position: absolute; left: 0; top: 0; height: 100%; border-radius: 3px; } |
| .gauge-tick { position: absolute; top: -3px; width: 1px; height: 11px; background: var(--text-3); opacity: 0.5; } |
| .gauge-center { position: absolute; top: -3px; width: 1.5px; height: 11px; background: var(--text-2); opacity: 0.5; transform: translateX(-50%); } |
| .gauge-val { font-family: var(--font-mono); font-size: 10.5px; color: var(--text-3); min-width: 36px; text-align: right; white-space: nowrap; } |
| |
| |
| .mech-badge { display: inline-flex; align-items: center; gap: 4px; font-family: var(--font-mono); font-size: 10.5px; padding: 2px 7px; border-radius: 3px; white-space: nowrap; } |
| .mech-stability { background: #f7ede0; color: var(--mech-stability); border: 1px solid #e8c9a0; } |
| .mech-interface { background: #e0eef7; color: var(--mech-interface); border: 1px solid #a0c9e8; } |
| .mech-pocket { background: #ede0f7; color: var(--mech-pocket); border: 1px solid #c9a0e8; } |
| .mech-unassigned { background: var(--bg-surface); color: var(--mech-unassigned); border: 1px solid var(--border); } |
| .mech-badge.predicted { opacity: 0.85; border-style: dashed; } |
| .mech-badge.predicted .pred-marker { font-size: 0.65em; margin-left: 1px; opacity: 0.7; } |
| .consensus-badge { font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; padding: 2px 8px; border-radius: 100px; white-space: nowrap; } |
| .consensus-high { background: var(--red-bg); color: var(--red); border: 1px solid var(--red-border); } |
| .consensus-medium { background: var(--amber-bg); color: var(--amber); border: 1px solid var(--amber-border); } |
| .consensus-low { background: var(--green-bg); color: var(--green); border: 1px solid var(--green-border); } |
| |
| |
| #protein-info { padding: 14px 16px; border-bottom: 1px solid var(--border); background: var(--bg); flex-shrink: 0; } |
| .pinfo-header { display: flex; align-items: baseline; gap: 8px; margin-bottom: 5px; flex-wrap: wrap; } |
| .pinfo-id { font-family: var(--font-mono); font-size: 13px; font-weight: 500; } |
| .pinfo-name { font-family: var(--font-serif); font-size: 13px; color: var(--text-2); flex: 1; min-width: 0; } |
| .pinfo-link { font-size: 11px; color: var(--accent); text-decoration: none; white-space: nowrap; margin-left: auto; } |
| .pinfo-link:hover { text-decoration: underline; } |
| .pinfo-meta { display: flex; gap: 14px; font-size: 11.5px; color: var(--text-3); margin-bottom: 6px; flex-wrap: wrap; } |
| .pinfo-meta b { color: var(--text-2); font-weight: 500; } |
| .pinfo-function { font-size: 11.5px; color: var(--text-3); line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 8px; } |
| .pinfo-loading { font-size: 11.5px; color: var(--text-3); font-family: var(--font-mono); } |
| |
| .stats-bar { display: flex; height: 6px; border-radius: 3px; overflow: hidden; margin-bottom: 4px; background: var(--gauge-bg); } |
| .stats-seg { height: 100%; transition: width .3s; } |
| .stats-legend { display: flex; gap: 10px; font-size: 10px; color: var(--text-3); font-family: var(--font-mono); flex-wrap: wrap; } |
| .stats-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 3px; vertical-align: middle; } |
| .mech-chips { margin-top: 6px; display: flex; gap: 4px; flex-wrap: wrap; } |
| .mech-chip-mini { font-size: 10px; font-family: var(--font-mono); padding: 1px 6px; border-radius: 3px; color: var(--text-3); background: var(--bg-inset); } |
| |
| |
| #scatter-section { border-bottom: 1px solid var(--border); flex-shrink: 0; display: none; } |
| #scatter-canvas { display: block; width: 100%; height: 130px; cursor: crosshair; } |
| |
| |
| #viewer-section { flex-shrink: 0; position: relative; } |
| #mol-container { width: 100%; height: 320px; background: #111; position: relative; } |
| #viewer-controls { position: absolute; top: 8px; right: 8px; display: flex; flex-direction: column; gap: 4px; z-index: 20; } |
| |
| #mol-container .msp-viewport-controls { display: none !important; } |
| .viewer-btn { |
| width: 28px; height: 28px; background: rgba(0,0,0,.45); |
| backdrop-filter: blur(4px); border: 1px solid rgba(255,255,255,.15); |
| border-radius: 4px; color: rgba(255,255,255,.8); font-size: 13px; |
| cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background .12s; |
| } |
| .viewer-btn:hover { background: rgba(0,0,0,.7); color: white; } |
| #viewer-legend { position: absolute; bottom: 8px; left: 8px; background: rgba(0,0,0,.55); backdrop-filter: blur(4px); border: 1px solid rgba(255,255,255,.1); border-radius: 4px; padding: 6px 8px; z-index: 20; } |
| .legend-item { display: flex; align-items: center; gap: 5px; font-size: 10px; color: rgba(255,255,255,.75); margin-bottom: 3px; } |
| .legend-item:last-child { margin-bottom: 0; } |
| .legend-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } |
| #viewer-status { position: absolute; bottom: 8px; right: 8px; font-family: var(--font-mono); font-size: 10px; color: rgba(255,255,255,.5); z-index: 20; pointer-events: none; } |
| #viewer-loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(17,17,17,.85); z-index: 30; pointer-events: none; opacity: 0; transition: opacity .2s; } |
| #viewer-loading.visible { opacity: 1; } |
| .spinner { width: 24px; height: 24px; border: 2px solid rgba(255,255,255,.2); border-top-color: rgba(255,255,255,.7); border-radius: 50%; animation: spin .7s linear infinite; } |
| @keyframes spin { to { transform: rotate(360deg); } } |
| |
| |
| #detail-panel { padding: 14px 16px; border-top: 1px solid var(--border); flex: 1; background: var(--bg); } |
| #detail-panel.empty { display: flex; align-items: center; justify-content: center; } |
| .detail-empty-msg { font-size: 12px; color: var(--text-3); text-align: center; font-family: var(--font-mono); } |
| .detail-header { margin-bottom: 10px; } |
| .detail-variant { font-family: var(--font-mono); font-size: 14px; font-weight: 500; display: block; margin-bottom: 2px; } |
| .detail-description { font-family: var(--font-serif); font-style: italic; font-size: 13px; color: var(--text-2); } |
| .detail-scores { margin-bottom: 12px; } |
| .score-row { display: grid; grid-template-columns: 86px 1fr auto; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px solid var(--border-light); } |
| .score-row:last-child { border-bottom: none; } |
| .score-name { font-size: 10.5px; font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: .04em; } |
| .score-verdict { font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; white-space: nowrap; } |
| .score-verdict.pathogenic { color: var(--red); } |
| .score-verdict.benign { color: var(--green); } |
| .score-verdict.ambiguous { color: var(--amber); } |
| .detail-consensus { display: flex; align-items: flex-start; gap: 10px; padding: 9px 11px; border-radius: 5px; margin-bottom: 11px; } |
| .detail-consensus.high { background: var(--red-bg); border: 1px solid var(--red-border); } |
| .detail-consensus.medium { background: var(--amber-bg); border: 1px solid var(--amber-border); } |
| .detail-consensus.low { background: var(--green-bg); border: 1px solid var(--green-border); } |
| .consensus-icon { font-size: 18px; line-height: 1; flex-shrink: 0; } |
| .consensus-title { font-family: var(--font-mono); font-size: 12px; font-weight: 500; margin-bottom: 2px; } |
| .consensus-title.high { color: var(--red); } |
| .consensus-title.medium { color: var(--amber); } |
| .consensus-title.low { color: var(--green); } |
| .consensus-detail { font-size: 11.5px; color: var(--text-2); line-height: 1.5; } |
| .detail-links { display: flex; gap: 8px; flex-wrap: wrap; } |
| .detail-link { font-size: 11.5px; color: var(--accent); text-decoration: none; padding: 4px 10px; border: 1px solid #bfdbfe; background: #eff6ff; border-radius: 4px; transition: all .12s; } |
| .detail-link:hover { background: #dbeafe; } |
| |
| |
| #clinvar-panel { |
| border-top: 1px solid var(--border); |
| padding: 10px 14px; |
| font-size: 12px; |
| } |
| .cv-loading { font-family: var(--font-mono); font-size: 11.5px; color: var(--text-3); } |
| .cv-not-found { color: var(--text-3); font-family: var(--font-mono); font-size: 11.5px; } |
| .cv-found { display: flex; flex-direction: column; gap: 5px; } |
| .cv-badge { |
| display: inline-block; font-size: 11px; font-weight: 600; font-family: var(--font-mono); |
| padding: 2px 8px; border-radius: 100px; color: white; |
| } |
| .cv-title { font-size: 11.5px; color: var(--text-2); line-height: 1.4; } |
| .cv-review { font-size: 10.5px; color: var(--text-3); font-family: var(--font-mono); } |
| .cv-link { font-size: 11.5px; color: var(--accent); text-decoration: none; } |
| .cv-link:hover { text-decoration: underline; } |
| .cv-alert { |
| margin-top: 6px; padding: 6px 9px; |
| background: #fef3cd; border: 1px solid #fde68a; border-radius: 4px; |
| font-size: 11px; color: #92400e; line-height: 1.5; |
| } |
| .cv-section-title { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text-3); margin-bottom: 5px; } |
| </style> |
| </head> |
| <body data-view="search"> |
|
|
| |
| <header class="site-header"> |
| <div class="header-inner"> |
| <div class="header-left"> |
| <a href="landing.html" class="site-logo" id="logo-link"> |
| <span class="logo-mark">β</span> |
| <span class="logo-name">Beltrao Lab</span> |
| <span class="logo-divider">/</span> |
| <span class="logo-tool">MutVar</span> |
| </a> |
| <button class="header-back" id="btn-back">β New search</button> |
| </div> |
| <nav class="site-nav"> |
| <div class="nav-dropdown"> |
| <span class="nav-link nav-dropdown-trigger">Tools βΎ</span> |
| <div class="nav-dropdown-menu"> |
| <a href="index.html" class="nav-dropdown-item"> |
| <span class="dropdown-item-name">MutVar</span> |
| <span class="dropdown-item-desc">Missense variant impact</span> |
| </a> |
| <a href="vcf-upload.html" class="nav-dropdown-item"> |
| <span class="dropdown-item-name">VCF Upload</span> |
| <span class="dropdown-item-desc">Batch variant analysis</span> |
| </a> |
| <div class="nav-dropdown-divider"></div> |
| <a href="kinaseatlas.html" class="nav-dropdown-item"> |
| <span class="dropdown-item-name">KinaseAtlas</span> |
| <span class="dropdown-item-badge">Planned</span> |
| </a> |
| </div> |
| </div> |
| <a href="landing.html#about" class="nav-link">About</a> |
| <a href="https://www.biorxiv.org/content/10.1101/2024.05.29.596373v1" class="nav-link" target="_blank">Preprint β</a> |
| </nav> |
| </div> |
| </header> |
|
|
| |
| <div id="view-search"> |
| <div class="search-hero"> |
| <h1>Protein Mutation<br/>Impact Explorer</h1> |
| <p>Query predicted pathogenicity for missense variants across three human proteins. |
| Scores from <a href="https://alphafold.ebi.ac.uk/" target="_blank">AlphaMissense</a>, |
| ESM1b, and structural ΞΞG are integrated into a mechanistic assessment.</p> |
| </div> |
|
|
| <label class="search-form-label" for="query-input">Mutations β one per line</label> |
| <textarea id="query-input" placeholder="P60709 M1A P68431 A1V Q71U36"></textarea> |
|
|
| <p class="search-format-hint"> |
| Format: <code>PROTEIN_ID MUTATION</code> Β· |
| accepts <code>A126C</code> or <code>Ala126Cys</code> Β· |
| protein-only returns all variants Β· |
| all ~20,000 human proteins are searchable |
| </p> |
| <div class="search-examples"> |
| <span class="search-examples-label">Try:</span> |
| <button class="example-chip" data-query="P60709 M1A" title="Ξ²-actin (ACTB)">P60709 M1A</button> |
| <button class="example-chip" data-query="P68431 A1V" title="Histone H3.1">P68431 A1V</button> |
| <button class="example-chip" data-query="Q71U36 M1A" title="Ξ±-tubulin">Q71U36 M1A</button> |
| <button class="example-chip" data-query="P60709">All P60709</button> |
| <button class="example-chip" data-query="Q7Z4H8 A126C">Q7Z4H8 A126C</button> |
| </div> |
| <div class="search-actions"> |
| <button id="btn-analyze" disabled>Search</button> |
| <span id="tsv-status">Loading datasetβ¦</span> |
| </div> |
| <div id="search-errors"></div> |
| </div> |
|
|
| <footer class="site-footer" id="search-footer"> |
| <div class="footer-inner"> |
| <div class="footer-left">Beltrao Lab Β· Institute of Molecular Systems Biology Β· ETH Zurich</div> |
| <div class="footer-right"> |
| <a href="https://imsb.ethz.ch/research/beltrao.html" target="_blank">Lab website β</a> |
| <a href="https://www.biorxiv.org/content/10.1101/2024.05.29.596373v1" target="_blank">Preprint β</a> |
| <a href="https://www.ebi.ac.uk/ProtVar/" target="_blank">ProtVar β</a> |
| </div> |
| </div> |
| </footer> |
|
|
| |
| <div id="view-results"> |
| <div id="results-body"> |
|
|
| |
| <div id="panel-left"> |
| <div id="results-header"> |
| <div class="results-title" id="results-title">Results</div> |
| <div class="results-subtitle" id="results-subtitle"></div> |
| </div> |
|
|
| |
| <div id="position-chart"> |
| <span class="chart-title">AlphaMissense landscape β click position to filter</span> |
| </div> |
| |
| <div id="position-filter-badge"> |
| Filtered to position <strong id="pf-pos-label"></strong> |
| <button id="btn-clear-posfilter">clear Γ</button> |
| </div> |
|
|
| <div id="table-toolbar"> |
| <div class="filter-chips" id="filter-chips"> |
| <button class="chip active" data-mech="all">All</button> |
| <button class="chip" data-mech="Stability">β² Stability</button> |
| <button class="chip" data-mech="Interface">⬑ Interface</button> |
| <button class="chip" data-mech="Pockets">β Pockets</button> |
| <button class="chip" data-mech="Unassigned">Β· Unassigned</button> |
| </div> |
| <div class="toolbar-right"> |
| <input id="mutation-filter" type="text" placeholder="Filter: A126β¦" autocomplete="off"/> |
| <span id="results-count"></span> |
| <button id="btn-share">⬑ Share</button> |
| <span id="share-done">Copied!</span> |
| <button id="btn-export">β CSV</button> |
| </div> |
| </div> |
|
|
| <div id="table-scroll" tabindex="0"> |
| <table id="results-table"> |
| <thead> |
| <tr> |
| <th data-col="mutCode">Variant</th> |
| <th data-col="amScore">AlphaMissense</th> |
| <th data-col="esmLLR">ESM1b LLR</th> |
| <th data-col="predDDG">ΞΞG</th> |
| <th data-col="mechLabel">Mechanism</th> |
| <th data-col="consensusScore">Consensus</th> |
| </tr> |
| </thead> |
| <tbody id="results-tbody"></tbody> |
| </table> |
| </div> |
| </div> |
|
|
| |
| <div id="panel-right"> |
| <div id="protein-info"><p class="pinfo-loading">Fetching protein informationβ¦</p></div> |
| <div id="scatter-section"> |
| <canvas id="scatter-canvas"></canvas> |
| </div> |
| <div id="viewer-section"> |
| <div id="mol-container"></div> |
| <div id="viewer-controls"> |
| <button class="viewer-btn" id="btn-reset-view" title="Reset view">β</button> |
| <button class="viewer-btn" id="btn-toggle-spin" title="Toggle spin">β»</button> |
| <button class="viewer-btn" id="btn-toggle-surface" title="Toggle surface">β</button> |
| </div> |
| <div id="viewer-legend"> |
| <div class="legend-item"><div class="legend-dot" style="background:#e3002c"></div>Mutation site</div> |
| <div class="legend-item" id="legend-pocket" style="display:none"><div class="legend-dot" style="background:#00bcd4"></div>Pocket</div> |
| <div class="legend-item" id="legend-interface" style="display:none"><div class="legend-dot" style="background:#ffc107"></div>Interface</div> |
| </div> |
| <div id="viewer-status"></div> |
| <div id="viewer-loading"><div class="spinner"></div></div> |
| </div> |
| <div id="detail-panel" class="empty"> |
| <p class="detail-empty-msg">β Select a variant to explore</p> |
| </div> |
| <div id="clinvar-panel" style="display:none"></div> |
| </div> |
| </div> |
| </div> |
|
|
| <script src="https://cdn.jsdelivr.net/npm/molstar@4/build/viewer/molstar.js"></script> |
| <script> |
| 'use strict'; |
| |
| |
| |
| |
| |
| const API_BASE = (location.hostname === 'localhost' || location.hostname === '127.0.0.1') |
| ? 'http://localhost:8000' |
| : ''; |
| |
| let API_MODE = false; |
| |
| |
| |
| |
| const App = { |
| byVariant: new Map(), |
| byProtein: new Map(), |
| currentRows: [], |
| filteredRows: [], |
| sortCol: 'amScore', |
| sortDir: 'desc', |
| filterMech: 'all', |
| filterText: '', |
| filterPosition: null, |
| selectedRow: null, |
| molViewer: null, |
| molLoadedProtein: null, |
| molSpinning: false, |
| uniprotCache: new Map(), |
| landscapeData: null, |
| }; |
| |
| |
| |
| |
| const AA1 = { A:'Alanine',C:'Cysteine',D:'Aspartate',E:'Glutamate',F:'Phenylalanine',G:'Glycine',H:'Histidine',I:'Isoleucine',K:'Lysine',L:'Leucine',M:'Methionine',N:'Asparagine',P:'Proline',Q:'Glutamine',R:'Arginine',S:'Serine',T:'Threonine',V:'Valine',W:'Tryptophan',Y:'Tyrosine' }; |
| const AA3 = { ALA:'A',ARG:'R',ASN:'N',ASP:'D',CYS:'C',GLN:'Q',GLU:'E',GLY:'G',HIS:'H',ILE:'I',LEU:'L',LYS:'K',MET:'M',PHE:'F',PRO:'P',SER:'S',THR:'T',TRP:'W',TYR:'Y',VAL:'V' }; |
| |
| |
| |
| |
| async function loadTSV() { |
| const resp = await fetch('data/missense_human_subset.tsv'); |
| if (!resp.ok) throw new Error(`TSV ${resp.status}`); |
| const text = await resp.text(); |
| const lines = text.split('\n'); |
| const hdrs = lines[0].split('\t'); |
| const C = {}; |
| hdrs.forEach((h,i) => { C[h.trim()] = i; }); |
| |
| for (let i = 1; i < lines.length; i++) { |
| const l = lines[i]; if (!l.trim()) continue; |
| const f = l.split('\t'); |
| const vid = f[C['variant_id']]; if (!vid) continue; |
| const slash = vid.indexOf('/'); |
| const pid = vid.slice(0, slash); |
| const mut = vid.slice(slash + 1); |
| const amScore = parseFloat(f[C['am_pathogenicity']]) || 0; |
| const esmLLR = parseFloat(f[C['ESM1b_LLR']]) || 0; |
| const predDDG = parseFloat(f[C['pred_ddg']]) || 0; |
| const amLabel = f[C['am_label']] === 'True'; |
| const esmPath = f[C['ESM1b_is_pathogenic']] === 'pathogenic'; |
| const ddgLbl = f[C['pred_ddg_label']] === 'True'; |
| const pathCount = (amLabel?1:0) + (esmPath?1:0) + (ddgLbl?1:0); |
| const row = { |
| variantId: vid, proteinId: pid, mutCode: mut, amScore, |
| amClass: f[C['am_class']], amLabel, esmLLR, esmPathogenic: esmPath, |
| predDDG, ddgLabel: ddgLbl, |
| interfacePdockq: f[C['interface_pdockq']] ? parseFloat(f[C['interface_pdockq']]) : null, |
| interfaceLabel: f[C['interface_label']] === 'True', |
| pocketLabel: f[C['pocket_label']] === 'True', |
| mechLabel: f[C['mechanistic_label']] || 'Unassigned', |
| mlMechLabel: f[C['ml_mechLabel']] || null, |
| mlConfidence: f[C['ml_confidence']] ? parseFloat(f[C['ml_confidence']]) : null, |
| pathCount, consensus: pathCount >= 2 ? 'High Risk' : pathCount === 1 ? 'Medium' : 'Low Risk', |
| }; |
| App.byVariant.set(vid, row); |
| if (!App.byProtein.has(pid)) App.byProtein.set(pid, []); |
| App.byProtein.get(pid).push(row); |
| } |
| } |
| |
| |
| |
| |
| function parseLine(raw) { |
| const line = raw.trim(); if (!line) return null; |
| |
| |
| const slashIdx = line.indexOf('/'); |
| if (slashIdx > 0 && slashIdx < line.length - 1) { |
| const pid = line.slice(0, slashIdx).toUpperCase(); |
| const mutPart = line.slice(slashIdx + 1).trim().toUpperCase(); |
| const ms = mutPart.match(/^([A-Z])(\d+)([A-Z])$/); |
| if (ms) return { pid, mut: `${ms[1]}${ms[2]}${ms[3]}` }; |
| } |
| |
| const parts = line.split(/\s+/); |
| const pid = parts[0].toUpperCase(); |
| if (parts.length === 1) return { pid, mut: null }; |
| let m = parts[1].toUpperCase().match(/^([A-Z])(\d+)([A-Z])$/); |
| if (m) return { pid, mut: `${m[1]}${m[2]}${m[3]}` }; |
| m = parts[1].match(/^([A-Za-z]{3})(\d+)([A-Za-z]{3})$/); |
| if (m) { |
| const from = AA3[m[1].toUpperCase()], to = AA3[m[3].toUpperCase()]; |
| if (from && to) return { pid, mut: `${from}${m[2]}${to}` }; |
| } |
| return { error: `Invalid format: "${raw}" β expected e.g. Q7Z4H8 A126C or Q7Z4H8/A126C` }; |
| } |
| |
| function executeSearch(rawInput) { |
| const rows = [], errors = [], seen = new Set(); |
| for (const line of rawInput.split('\n')) { |
| if (!line.trim()) continue; |
| const parsed = parseLine(line); |
| if (!parsed) continue; |
| if (parsed.error) { errors.push(parsed.error); continue; } |
| if (parsed.mut === null) { |
| const protein = App.byProtein.get(parsed.pid); |
| if (!protein) errors.push(`Protein "${parsed.pid}" not found in dataset.`); |
| else for (const r of protein) { if (!seen.has(r.variantId)) { seen.add(r.variantId); rows.push(r); } } |
| } else { |
| const key = `${parsed.pid}/${parsed.mut}`; |
| const row = App.byVariant.get(key); |
| if (!row) { |
| let hint = ''; |
| const mutM = parsed.mut.match(/^([A-Z])(\d+)([A-Z])$/); |
| if (mutM) { |
| const protein = App.byProtein.get(parsed.pid); |
| if (protein) { |
| const atPos = protein.filter(r => r.mutCode.match(/^[A-Z](\d+)/)?.[1] === mutM[2]); |
| if (atPos.length > 0) hint = ` Position ${mutM[2]} reference is "${atPos[0].mutCode[0]}", not "${mutM[1]}". Try: ${parsed.pid} ${atPos[0].mutCode[0]}${mutM[2]}${mutM[3]}`; |
| else hint = ` Position ${mutM[2]} not in dataset for ${parsed.pid}.`; |
| } |
| } |
| errors.push(`Variant "${key}" not found.${hint}`); |
| } else if (!seen.has(row.variantId)) { seen.add(row.variantId); rows.push(row); } |
| } |
| } |
| return { rows, errors }; |
| } |
| |
| |
| |
| |
| function amColor(s) { return s > 0.564 ? 'var(--red)' : s < 0.340 ? 'var(--green)' : 'var(--amber)'; } |
| |
| function amGauge(score) { |
| const col = amColor(score); |
| return `<div class="gauge-wrap"><div class="gauge-track"> |
| <div class="gauge-fill" style="width:${(score*100).toFixed(1)}%;background:${col}"></div> |
| <div class="gauge-tick" style="left:34%"></div> |
| <div class="gauge-tick" style="left:56.4%"></div> |
| </div><span class="gauge-val">${score.toFixed(3)}</span></div>`; |
| } |
| |
| function esmGauge(llr) { |
| if (llr === null || llr === undefined) return `<span class="gauge-val" style="color:var(--text-muted)">β</span>`; |
| const CPct = 25/35*100, c = Math.max(-25,Math.min(10,llr)), p = (c+25)/35*100; |
| const [l, w, col] = llr < 0 ? [p, CPct-p, 'var(--red)'] : [CPct, p-CPct, 'var(--green)']; |
| const sign = llr >= 0 ? '+' : ''; |
| return `<div class="gauge-wrap"><div class="gauge-track"> |
| <div class="gauge-fill" style="left:${l.toFixed(1)}%;width:${Math.max(0,w).toFixed(1)}%;background:${col}"></div> |
| <div class="gauge-center" style="left:${CPct.toFixed(1)}%"></div> |
| </div><span class="gauge-val" style="color:${col}">${sign}${llr.toFixed(2)}</span></div>`; |
| } |
| |
| function ddgGauge(ddg) { |
| if (ddg === null || ddg === undefined) return `<span class="gauge-val" style="color:var(--text-muted)">β</span>`; |
| const CPct = 5/15*100, c = Math.max(-5,Math.min(10,ddg)), p = (c+5)/15*100; |
| const [l, w, col] = ddg < 0 ? [p, CPct-p, 'var(--green)'] : [CPct, p-CPct, 'var(--red)']; |
| const sign = ddg >= 0 ? '+' : ''; |
| return `<div class="gauge-wrap"><div class="gauge-track"> |
| <div class="gauge-fill" style="left:${l.toFixed(1)}%;width:${Math.max(0,w).toFixed(1)}%;background:${col}"></div> |
| <div class="gauge-center" style="left:${CPct.toFixed(1)}%"></div> |
| </div><span class="gauge-val" style="color:${col}">${sign}${ddg.toFixed(3)}</span></div>`; |
| } |
| |
| const MECH = { Stability:{sym:'β²',cls:'mech-stability'}, Interface:{sym:'⬑',cls:'mech-interface'}, Pockets:{sym:'β',cls:'mech-pocket'}, Unassigned:{sym:'Β·',cls:'mech-unassigned'} }; |
| function mechBadge(label, predicted=false) { |
| const m = MECH[label]||MECH.Unassigned; |
| if (predicted) return `<span class="mech-badge ${m.cls} predicted" title="ML prediction β approximated (${label})">${m.sym} ${label}<span class="pred-marker">~</span></span>`; |
| return `<span class="mech-badge ${m.cls}">${m.sym} ${label}</span>`; |
| } |
| |
| function mechDisplay(row) { |
| if (row.mechLabel !== 'Unassigned') return mechBadge(row.mechLabel); |
| if (row.mlMechLabel) return mechBadge(row.mlMechLabel, true); |
| return mechBadge('Unassigned'); |
| } |
| function consensusBadge(row) { const cls = row.pathCount>=2?'high':row.pathCount===1?'medium':'low'; return `<span class="consensus-badge consensus-${cls}">${row.consensus}</span>`; } |
| |
| async function fetchMechPrediction(row) { |
| if (!API_MODE || row.mechLabel !== 'Unassigned') return null; |
| if (row._mechPred !== undefined) return row._mechPred; |
| try { |
| const pos = parseInt(row.mutCode.match(/\d+/)?.[0] || '0'); |
| const aa_from = row.mutCode[0]; |
| const aa_to = row.mutCode[row.mutCode.length - 1]; |
| const params = new URLSearchParams({ |
| am_pathogenicity: row.amScore ?? 0, |
| esm1b_llr: row.esmLLR ?? 0, |
| pred_ddg: row.predDDG ?? 0, |
| position: pos, |
| aa_from, aa_to, |
| }); |
| const res = await fetch(`${API_BASE}/api/predict-mechanism?${params}`, |
| { signal: AbortSignal.timeout(45000) }); |
| if (!res.ok) { row._mechPred = null; return null; } |
| row._mechPred = await res.json(); |
| } catch { row._mechPred = null; } |
| return row._mechPred; |
| } |
| |
| |
| |
| |
| let chartPositions = []; |
| |
| function renderPositionChart(rows, selectedPos, landscapeData) { |
| const el = document.getElementById('position-chart'); |
| |
| |
| |
| const posMap = new Map(); |
| if (landscapeData && landscapeData.length > 0) { |
| for (const d of landscapeData) { |
| if (d.position == null || d.score == null) continue; |
| const e = posMap.get(d.position); |
| if (!e || d.score > e.maxAM) posMap.set(d.position, { maxAM: d.score }); |
| } |
| } else { |
| for (const r of rows) { |
| const m = r.mutCode.match(/^[A-Z](\d+)[A-Z]$/); |
| if (!m) continue; |
| const pos = parseInt(m[1]); |
| const e = posMap.get(pos); |
| if (!e || r.amScore > e.maxAM) posMap.set(pos, { maxAM: r.amScore, variantId: r.variantId }); |
| } |
| } |
| |
| if (posMap.size < 5) { el.style.display = 'none'; return; } |
| el.style.display = 'block'; |
| |
| const positions = [...posMap.entries()].sort((a,b) => a[0]-b[0]); |
| const minPos = positions[0][0], maxPos = positions[positions.length-1][0]; |
| |
| |
| let canvas = el.querySelector('canvas'); |
| if (!canvas) { |
| canvas = document.createElement('canvas'); |
| el.insertBefore(canvas, el.querySelector('.chart-title')); |
| canvas.addEventListener('click', onChartClick); |
| canvas.addEventListener('mousemove', onChartHover); |
| canvas.addEventListener('mouseleave', () => { canvas._tooltip && (canvas._tooltip.style.display = 'none'); }); |
| } |
| |
| const dpr = window.devicePixelRatio || 1; |
| const W = el.clientWidth || 600; |
| const H = 72; |
| el.style.height = H + 'px'; |
| canvas.width = W * dpr; canvas.height = H * dpr; |
| canvas.style.width = W + 'px'; canvas.style.height = H + 'px'; |
| |
| const ctx = canvas.getContext('2d'); |
| ctx.scale(dpr, dpr); |
| |
| const ML = 30, MR = 8, MT = 8, MB = 18; |
| const CW = W - ML - MR, CH = H - MT - MB; |
| const posRange = Math.max(1, maxPos - minPos); |
| |
| function xS(pos) { return ML + (pos - minPos) / posRange * CW; } |
| function yS(v) { return MT + CH - v * CH; } |
| |
| |
| ctx.fillStyle = '#f3f2ee'; ctx.fillRect(0, 0, W, H); |
| |
| |
| ctx.setLineDash([3,3]); |
| ctx.lineWidth = 0.8; |
| ctx.strokeStyle = 'rgba(192,57,43,0.35)'; |
| ctx.beginPath(); ctx.moveTo(ML, yS(0.564)); ctx.lineTo(W-MR, yS(0.564)); ctx.stroke(); |
| ctx.strokeStyle = 'rgba(166,92,0,0.3)'; |
| ctx.beginPath(); ctx.moveTo(ML, yS(0.340)); ctx.lineTo(W-MR, yS(0.340)); ctx.stroke(); |
| ctx.setLineDash([]); |
| |
| |
| ctx.strokeStyle = '#d8d5ce'; ctx.lineWidth = 0.5; |
| ctx.beginPath(); ctx.moveTo(ML, yS(0)); ctx.lineTo(W-MR, yS(0)); ctx.stroke(); |
| |
| |
| chartPositions = []; |
| for (const [pos, data] of positions) { |
| const x = xS(pos), y = yS(data.maxAM), baseY = yS(0); |
| const col = data.maxAM > 0.564 ? '#c0392b' : data.maxAM < 0.340 ? '#1f6b40' : '#a65c00'; |
| const isSel = pos === selectedPos; |
| |
| |
| ctx.strokeStyle = col + (isSel ? 'ff' : '70'); |
| ctx.lineWidth = isSel ? 1.5 : 0.8; |
| ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, baseY); ctx.stroke(); |
| |
| |
| const r = isSel ? 3.5 : 2; |
| ctx.fillStyle = col + (isSel ? 'ff' : 'bb'); |
| ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI*2); ctx.fill(); |
| if (isSel) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 1; ctx.stroke(); } |
| |
| chartPositions.push({ pos, x, y, maxAM: data.maxAM }); |
| } |
| |
| |
| ctx.font = `9px "JetBrains Mono", monospace`; |
| ctx.textAlign = 'right'; ctx.fillStyle = '#7a776f'; |
| ctx.fillText('1.0', ML-3, yS(1)+3); |
| ctx.fillStyle = 'rgba(192,57,43,0.8)'; |
| ctx.fillText('0.56', ML-3, yS(0.564)+3); |
| ctx.fillStyle = 'rgba(166,92,0,0.8)'; |
| ctx.fillText('0.34', ML-3, yS(0.340)+3); |
| ctx.fillStyle = '#7a776f'; ctx.textAlign = 'left'; |
| ctx.fillText(minPos, ML, H-4); |
| ctx.textAlign = 'right'; |
| ctx.fillText(maxPos, W-MR, H-4); |
| } |
| |
| function findNearestChartPosition(mouseX) { |
| let nearest = null, minDist = Infinity; |
| for (const p of chartPositions) { |
| const d = Math.abs(p.x - mouseX); |
| if (d < minDist) { minDist = d; nearest = p; } |
| } |
| return minDist < 12 ? nearest : null; |
| } |
| |
| function onChartClick(e) { |
| const rect = this.getBoundingClientRect(); |
| const nearest = findNearestChartPosition(e.clientX - rect.left); |
| if (!nearest) return; |
| App.filterPosition = App.filterPosition === nearest.pos ? null : nearest.pos; |
| updatePositionFilterUI(); |
| renderTable(App.currentRows); |
| renderPositionChart(App.filteredRows.length ? App.filteredRows : App.currentRows, App.filterPosition, App.landscapeData); |
| } |
| |
| function onChartHover(e) { |
| const canvas = this; |
| const rect = canvas.getBoundingClientRect(); |
| const nearest = findNearestChartPosition(e.clientX - rect.left); |
| canvas.style.cursor = nearest ? 'pointer' : 'crosshair'; |
| |
| |
| if (nearest) { |
| const clsLabel = nearest.maxAM > 0.564 ? 'Pathogenic' : nearest.maxAM < 0.340 ? 'Benign' : 'Ambiguous'; |
| canvas.title = `Position ${nearest.pos} β max AM: ${nearest.maxAM.toFixed(3)} (${clsLabel})`; |
| } else { |
| canvas.title = ''; |
| } |
| } |
| |
| function updatePositionFilterUI() { |
| const badge = document.getElementById('position-filter-badge'); |
| if (App.filterPosition !== null) { |
| badge.style.display = 'flex'; |
| document.getElementById('pf-pos-label').textContent = App.filterPosition; |
| } else { |
| badge.style.display = 'none'; |
| } |
| } |
| |
| |
| |
| |
| const VS_H = 34; |
| const VS_BUF = 30; |
| |
| let vsRows = []; |
| |
| function vsInit() { |
| const scroll = document.getElementById('table-scroll'); |
| scroll.addEventListener('scroll', () => vsRender(scroll.scrollTop), { passive: true }); |
| } |
| |
| function vsUpdate(rows) { |
| vsRows = rows; |
| vsRender(document.getElementById('table-scroll').scrollTop); |
| document.getElementById('results-count').textContent = `${rows.length.toLocaleString()} variants`; |
| updateSortUI(); |
| } |
| |
| function vsRender(scrollTop) { |
| const scroll = document.getElementById('table-scroll'); |
| const containerH = scroll.clientHeight || 500; |
| const total = vsRows.length; |
| |
| if (!total) { document.getElementById('results-tbody').replaceChildren(); return; } |
| |
| const firstVis = Math.floor(scrollTop / VS_H); |
| const lastVis = Math.ceil((scrollTop + containerH) / VS_H); |
| const first = Math.max(0, firstVis - VS_BUF); |
| const last = Math.min(total - 1, lastVis + VS_BUF); |
| const sel = App.selectedRow?.variantId; |
| |
| const tbody = document.getElementById('results-tbody'); |
| const frag = document.createDocumentFragment(); |
| |
| if (first > 0) { |
| const tr = document.createElement('tr'); |
| tr.className = 'vs-spacer'; |
| tr.innerHTML = `<td colspan="6" style="height:${first*VS_H}px"></td>`; |
| frag.appendChild(tr); |
| } |
| |
| for (let i = first; i <= last; i++) { |
| const row = vsRows[i]; |
| const tr = document.createElement('tr'); |
| tr.dataset.vid = row.variantId; |
| if (row.variantId === sel) tr.classList.add('selected'); |
| tr.innerHTML = ` |
| <td class="cell-id"><span class="protein-id">${row.proteinId} /</span> ${row.mutCode}</td> |
| <td>${amGauge(row.amScore)}</td> |
| <td>${esmGauge(row.esmLLR)}</td> |
| <td>${ddgGauge(row.predDDG)}</td> |
| <td>${mechDisplay(row)}</td> |
| <td>${consensusBadge(row)}</td>`; |
| tr.addEventListener('click', () => selectRow(row)); |
| frag.appendChild(tr); |
| } |
| |
| if (last < total - 1) { |
| const tr = document.createElement('tr'); |
| tr.className = 'vs-spacer'; |
| tr.innerHTML = `<td colspan="6" style="height:${(total-1-last)*VS_H}px"></td>`; |
| frag.appendChild(tr); |
| } |
| |
| tbody.replaceChildren(frag); |
| } |
| |
| |
| |
| |
| function applyFilterAndSort(rows) { |
| let out = rows; |
| if (App.filterMech !== 'all') out = out.filter(r => r.mechLabel === App.filterMech); |
| if (App.filterPosition !== null) out = out.filter(r => { |
| const m = r.mutCode.match(/^[A-Z](\d+)/); |
| return m && parseInt(m[1]) === App.filterPosition; |
| }); |
| if (App.filterText) { |
| const q = App.filterText.toUpperCase(); |
| out = out.filter(r => r.mutCode.includes(q) || r.variantId.includes(q)); |
| } |
| const col = App.sortCol, dir = App.sortDir === 'asc' ? 1 : -1; |
| out = [...out].sort((a,b) => { |
| const av = a[col], bv = b[col]; |
| if (col === 'mechLabel') { |
| |
| const tA = av !== 'Unassigned' ? 0 : (a.mlMechLabel ? 1 : 2); |
| const tB = bv !== 'Unassigned' ? 0 : (b.mlMechLabel ? 1 : 2); |
| if (tA !== tB) return tA - tB; |
| const lA = tA === 0 ? av : (a.mlMechLabel || ''); |
| const lB = tB === 0 ? bv : (b.mlMechLabel || ''); |
| return dir * lA.localeCompare(lB); |
| } |
| return typeof av === 'string' ? dir * av.localeCompare(bv) : dir * (av - bv); |
| }); |
| App.filteredRows = out; |
| return out; |
| } |
| |
| function renderTable(rows) { |
| const visible = applyFilterAndSort(rows); |
| vsUpdate(visible); |
| } |
| |
| function updateSortUI() { |
| document.querySelectorAll('#results-table th').forEach(th => { |
| th.classList.remove('sort-asc', 'sort-desc'); |
| if (th.dataset.col === App.sortCol) th.classList.add(App.sortDir === 'asc' ? 'sort-asc' : 'sort-desc'); |
| }); |
| } |
| |
| |
| |
| |
| function selectRow(row) { |
| const old = document.querySelector('#results-tbody tr.selected'); |
| if (old) old.classList.remove('selected'); |
| App.selectedRow = row; |
| const tr = document.querySelector(`#results-tbody tr[data-vid="${row.variantId}"]`); |
| if (tr) { tr.classList.add('selected'); tr.scrollIntoView({ block: 'nearest' }); } |
| renderDetail(row); |
| drawScatter(App.currentRows, row); |
| loadAndHighlight(row.proteinId, row.mutCode); |
| document.getElementById('legend-pocket').style.display = row.pocketLabel ? 'flex' : 'none'; |
| document.getElementById('legend-interface').style.display = row.interfaceLabel ? 'flex' : 'none'; |
| } |
| |
| function mutDescription(mutCode) { |
| const m = mutCode.match(/^([A-Z])(\d+)([A-Z])$/); |
| if (!m) return mutCode; |
| return `${AA1[m[1]]||m[1]} β ${AA1[m[3]]||m[3]} at position ${m[2]}`; |
| } |
| |
| function renderDetail(row) { |
| const panel = document.getElementById('detail-panel'); |
| panel.classList.remove('empty'); |
| const amVerdict = row.amLabel ? 'pathogenic' : (row.amClass === 'ambiguous' ? 'ambiguous' : 'benign'); |
| const amText = row.amLabel ? 'Pathogenic' : (row.amClass === 'ambiguous' ? 'Ambiguous' : 'Benign'); |
| const esmVerdict = row.esmLLR === null ? '' : (row.esmPathogenic ? 'pathogenic' : 'benign'); |
| const esmText = row.esmLLR === null ? 'β' : (row.esmPathogenic ? 'Pathogenic' : 'Neutral'); |
| const ddgVerdict = row.predDDG === null ? '' : (row.ddgLabel ? 'pathogenic' : 'benign'); |
| const ddgText = row.predDDG === null ? 'β' : (row.ddgLabel ? 'Destabilizing' : 'Stabilizing'); |
| const consIcons = { 'High Risk':'β¬', 'Medium':'βͺ', 'Low Risk':'β‘' }; |
| const consCls = row.pathCount >= 2 ? 'high' : row.pathCount === 1 ? 'medium' : 'low'; |
| const consMsg = row.pathCount >= 2 ? `${row.pathCount} of 3 classifiers predict pathogenic effect.` : row.pathCount === 1 ? `1 of 3 classifiers predicts pathogenic effect. Evidence is inconclusive.` : `No classifier predicts pathogenic effect.`; |
| const pdockqText = row.interfacePdockq !== null ? ` (pDockQ ${row.interfacePdockq.toFixed(3)})` : ''; |
| |
| panel.innerHTML = ` |
| <div class="detail-header"> |
| <span class="detail-variant">${row.proteinId} / ${row.mutCode}</span> |
| <span class="detail-description">${mutDescription(row.mutCode)}</span> |
| </div> |
| <div class="detail-scores"> |
| <div class="score-row"><span class="score-name">AlphaMissense</span>${amGauge(row.amScore)}<span class="score-verdict ${amVerdict}">${amText}</span></div> |
| <div class="score-row"><span class="score-name">ESM1b LLR</span>${esmGauge(row.esmLLR)}<span class="score-verdict ${esmVerdict}">${esmText}</span></div> |
| <div class="score-row"><span class="score-name">ΞΞG</span>${ddgGauge(row.predDDG)}<span class="score-verdict ${ddgVerdict}">${ddgText}</span></div> |
| </div> |
| <div class="detail-consensus ${consCls}"> |
| <span class="consensus-icon">${consIcons[row.consensus]}</span> |
| <div> |
| <div class="consensus-title ${consCls}">${row.consensus}</div> |
| <div class="consensus-detail">${consMsg}<br/> |
| Mechanism: <span id="detail-mech-badge">${mechDisplay(row)}${row.mechLabel === 'Unassigned' && row.mlConfidence != null ? `<span style="font-size:10px;color:var(--text-muted);margin-left:4px;">${(row.mlConfidence*100).toFixed(0)}%</span>` : ''}</span> |
| ${row.interfaceLabel ? ` Β· Interface${pdockqText}` : ''} |
| ${row.pocketLabel ? ' Β· In predicted pocket' : ''} |
| </div> |
| </div> |
| </div> |
| <div class="detail-links"> |
| <a class="detail-link" href="https://www.uniprot.org/uniprotkb/${row.proteinId}" target="_blank" rel="noopener">UniProt: ${row.proteinId} β</a> |
| <a class="detail-link" href="https://www.ebi.ac.uk/ProtVar/query?search=${row.proteinId}" target="_blank" rel="noopener">ProtVar β</a> |
| <a class="detail-link" href="landscape.html?protein=${row.proteinId}" target="_blank">Landscape β</a> |
| </div>`; |
| |
| |
| const cvPanel = document.getElementById('clinvar-panel'); |
| if (API_MODE) { |
| cvPanel.style.display = 'block'; |
| loadClinVar(row.proteinId, row.mutCode, row.amClass); |
| } else { |
| cvPanel.style.display = 'none'; |
| } |
| |
| |
| } |
| |
| |
| |
| |
| function computeStats(rows) { |
| const s = { path: 0, amb: 0, ben: 0, mech: {}, total: rows.length }; |
| for (const r of rows) { |
| if (r.amClass === 'pathogenic') s.path++; |
| else if (r.amClass === 'ambiguous') s.amb++; |
| else s.ben++; |
| s.mech[r.mechLabel] = (s.mech[r.mechLabel] || 0) + 1; |
| } |
| return s; |
| } |
| |
| function statsHTML(stats) { |
| const n = stats.total; |
| if (!n) return ''; |
| const pP = (stats.path/n*100).toFixed(0), pA = (stats.amb/n*100).toFixed(0), pB = (stats.ben/n*100).toFixed(0); |
| const mechOrder = ['Stability','Interface','Pockets','Unassigned']; |
| const mechChips = mechOrder.filter(k => stats.mech[k]).map(k => { |
| const m = MECH[k] || MECH.Unassigned; |
| return `<span class="mech-chip-mini">${m.sym} ${stats.mech[k].toLocaleString()}</span>`; |
| }).join(''); |
| return `<div class="pinfo-stats"> |
| <div class="stats-bar"> |
| <div class="stats-seg" style="width:${pP}%;background:var(--red)"></div> |
| <div class="stats-seg" style="width:${pA}%;background:var(--amber)"></div> |
| <div class="stats-seg" style="width:${pB}%;background:var(--green)"></div> |
| </div> |
| <div class="stats-legend"> |
| <span><span class="stats-dot" style="background:var(--red)"></span>${pP}% pathogenic</span> |
| <span><span class="stats-dot" style="background:var(--amber)"></span>${pA}% ambiguous</span> |
| <span><span class="stats-dot" style="background:var(--green)"></span>${pB}% benign</span> |
| </div> |
| <div class="mech-chips">${mechChips}</div> |
| </div>`; |
| } |
| |
| |
| |
| |
| async function fetchUniProt(pid) { |
| if (App.uniprotCache.has(pid)) { renderProteinInfo(pid, App.uniprotCache.get(pid)); return; } |
| try { |
| const resp = await fetch(`https://rest.uniprot.org/uniprotkb/${pid}.json`); |
| if (!resp.ok) throw new Error(resp.status); |
| const data = await resp.json(); |
| const info = { |
| name: data.proteinDescription?.recommendedName?.fullName?.value || data.proteinDescription?.submissionNames?.[0]?.fullName?.value || pid, |
| gene: data.genes?.[0]?.geneName?.value || 'β', |
| length: data.sequence?.length || 'β', |
| fn: data.comments?.find(c => c.commentType === 'FUNCTION')?.texts?.[0]?.value?.slice(0, 200) || null, |
| url: `https://www.uniprot.org/uniprotkb/${pid}`, |
| }; |
| App.uniprotCache.set(pid, info); |
| renderProteinInfo(pid, info); |
| } catch(e) { |
| renderProteinInfo(pid, { name: pid, gene:'β', length:'β', fn:null, url:`https://www.uniprot.org/uniprotkb/${pid}` }); |
| } |
| } |
| |
| function renderProteinInfo(pid, info) { |
| const allRows = App.byProtein.get(pid) || []; |
| const stats = computeStats(allRows); |
| document.getElementById('protein-info').innerHTML = ` |
| <div class="pinfo-header"> |
| <span class="pinfo-id">${pid}</span> |
| <span class="pinfo-name">${escHtml(info.name)}</span> |
| <a class="pinfo-link" href="${info.url}" target="_blank" rel="noopener">UniProt β</a> |
| </div> |
| <div class="pinfo-meta"> |
| <span>Gene: <b>${escHtml(info.gene)}</b></span> |
| <span>Length: <b>${info.length} aa</b></span> |
| <span>${allRows.length.toLocaleString()} variants in dataset</span> |
| </div> |
| ${info.fn ? `<div class="pinfo-function">${escHtml(info.fn)}β¦</div>` : ''} |
| ${statsHTML(stats)}`; |
| } |
| |
| function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); } |
| |
| |
| |
| |
| let scatterPoints = []; |
| |
| function drawScatter(rows, selRow) { |
| const canvas = document.getElementById('scatter-canvas'); |
| const section = document.getElementById('scatter-section'); |
| if (!canvas || !rows || !rows.length) { if (section) section.style.display = 'none'; return; } |
| section.style.display = ''; |
| |
| const dpr = window.devicePixelRatio || 1; |
| const W = canvas.offsetWidth || 300, H = canvas.offsetHeight || 130; |
| canvas.width = W * dpr; canvas.height = H * dpr; |
| const ctx = canvas.getContext('2d'); |
| ctx.scale(dpr, dpr); |
| |
| const ML = 34, MR = 8, MT = 16, MB = 22; |
| const pw = W - ML - MR, ph = H - MT - MB; |
| |
| |
| let yMin = Infinity, yMax = -Infinity; |
| for (const r of rows) { if (r.esmLLR === null) continue; if (r.esmLLR < yMin) yMin = r.esmLLR; if (r.esmLLR > yMax) yMax = r.esmLLR; } |
| if (!isFinite(yMin)) { yMin = -10; yMax = 5; } |
| const yPad = (yMax - yMin) * 0.1 || 1; |
| yMin -= yPad; yMax += yPad; |
| |
| const toX = am => ML + am * pw; |
| const toY = esm => MT + (1 - (esm - yMin) / (yMax - yMin)) * ph; |
| |
| |
| ctx.fillStyle = '#fafaf8'; ctx.fillRect(0, 0, W, H); |
| |
| |
| ctx.strokeStyle = '#d8d5ce'; ctx.lineWidth = 1; |
| ctx.beginPath(); ctx.moveTo(ML, MT); ctx.lineTo(ML, MT + ph); ctx.lineTo(ML + pw, MT + ph); ctx.stroke(); |
| |
| |
| const y0 = toY(0); |
| if (y0 > MT && y0 < MT + ph) { |
| ctx.strokeStyle = '#c0bcb4'; ctx.lineWidth = 0.5; ctx.setLineDash([3, 3]); |
| ctx.beginPath(); ctx.moveTo(ML, y0); ctx.lineTo(ML + pw, y0); ctx.stroke(); |
| ctx.setLineDash([]); |
| } |
| |
| |
| for (const [thresh, color] of [[0.340, '#1f6b40'], [0.564, '#c0392b']]) { |
| const tx = toX(thresh); |
| ctx.strokeStyle = color; ctx.lineWidth = 0.8; ctx.globalAlpha = 0.35; |
| ctx.setLineDash([3, 3]); |
| ctx.beginPath(); ctx.moveTo(tx, MT); ctx.lineTo(tx, MT + ph); ctx.stroke(); |
| ctx.setLineDash([]); ctx.globalAlpha = 1; |
| } |
| |
| |
| ctx.fillStyle = '#7a776f'; ctx.font = '9px JetBrains Mono, monospace'; |
| ctx.textAlign = 'center'; |
| ctx.fillText('AlphaMissense', ML + pw / 2, H - 4); |
| ctx.textAlign = 'left'; ctx.fillText('0', ML, H - 10); |
| ctx.textAlign = 'right'; ctx.fillText('1', ML + pw, H - 10); |
| ctx.save(); ctx.translate(10, MT + ph / 2); ctx.rotate(-Math.PI / 2); |
| ctx.textAlign = 'center'; ctx.fillText('ESM1b LLR', 0, 0); ctx.restore(); |
| |
| |
| ctx.fillStyle = '#4a4845'; ctx.font = '9px JetBrains Mono, monospace'; |
| ctx.textAlign = 'left'; |
| ctx.fillText(`Score correlation Β· n=${rows.length.toLocaleString()}`, ML, 11); |
| |
| |
| const MAX = 4000; |
| const step = rows.length > MAX ? Math.ceil(rows.length / MAX) : 1; |
| scatterPoints = []; |
| for (let i = 0; i < rows.length; i += step) { |
| const r = rows[i]; |
| if (r.esmLLR === null) continue; |
| const x = toX(r.amScore), y = toY(r.esmLLR); |
| const isSel = selRow && r.variantId === selRow.variantId; |
| const color = r.pathCount >= 2 ? '#c0392b' : r.pathCount === 1 ? '#a65c00' : '#1f6b40'; |
| ctx.globalAlpha = isSel ? 1 : 0.45; |
| ctx.fillStyle = color; |
| ctx.beginPath(); ctx.arc(x, y, isSel ? 4.5 : 2, 0, Math.PI * 2); ctx.fill(); |
| if (isSel) { |
| ctx.globalAlpha = 1; ctx.strokeStyle = 'white'; ctx.lineWidth = 1.5; |
| ctx.stroke(); |
| } |
| scatterPoints.push({ x, y, row: r }); |
| } |
| ctx.globalAlpha = 1; |
| } |
| |
| |
| |
| |
| async function initMolstar() { |
| if (typeof molstar === 'undefined') return; |
| try { |
| App.molViewer = await molstar.Viewer.create('mol-container', { |
| layoutIsExpanded: false, layoutShowControls: false, |
| layoutShowRemoteState: false, layoutShowSequence: false, |
| layoutShowLog: false, layoutShowLeftPanel: false, |
| viewportShowExpand: false, viewportShowSelectionMode: false, |
| viewportShowAnimation: false, |
| }); |
| } catch(e) { console.error('Molstar init failed:', e); } |
| } |
| |
| function setViewerLoading(on) { document.getElementById('viewer-loading').classList.toggle('visible', on); } |
| function setViewerStatus(msg) { document.getElementById('viewer-status').textContent = msg; } |
| |
| async function loadAndHighlight(proteinId, mutCode) { |
| if (!App.molViewer) return; |
| const plugin = App.molViewer.plugin || App.molViewer; |
| setViewerLoading(true); setViewerStatus(''); |
| try { |
| if (App.molLoadedProtein !== proteinId) { |
| await plugin.clear(); |
| |
| let structureUrl = `structures/AF-${proteinId}-F1-model_v6.pdb`; |
| try { |
| const probe = await fetch(structureUrl, { method: 'HEAD' }); |
| if (!probe.ok) { |
| const afApi = await fetch(`https://alphafold.ebi.ac.uk/api/prediction/${proteinId}`); |
| if (afApi.ok) { |
| const afData = await afApi.json(); |
| structureUrl = afData[0]?.pdbUrl || structureUrl; |
| } |
| } |
| } catch(_) {} |
| await App.molViewer.loadStructureFromUrl(structureUrl, 'pdb', false); |
| App.molLoadedProtein = proteinId; |
| await delay(150); |
| } |
| if (mutCode) { |
| const m = mutCode.match(/^([A-Z])(\d+)([A-Z])$/i); |
| if (m) await highlightResidue(plugin, parseInt(m[2])); |
| } |
| setViewerStatus(mutCode ? `pos ${mutCode.match(/\d+/)?.[0]}` : ''); |
| } catch(e) { |
| console.warn('Viewer update error:', e); |
| setViewerStatus(''); |
| } finally { setViewerLoading(false); } |
| } |
| |
| async function highlightResidue(plugin, resno) { |
| if (tryStateTransforms(plugin, resno)) return; |
| await trySelectionFocus(plugin, resno); |
| } |
| |
| function tryStateTransforms(plugin, resno) { |
| try { |
| const ST = molstar.StateTransforms; |
| if (!ST?.Model?.StructureComponentFromExpression) return false; |
| const structures = plugin.managers.structure.hierarchy.current.structures; |
| if (!structures.length) return false; |
| const cellRef = structures[0].cell.transform.ref; |
| const expression = { '@version':2, expression: { name:'structure-query.generator.atom-groups', params: { 'residue-test': { name:'core.rel.eq', params: [{ name:'structure-query.atom.auth-seq-id' }, { name:'core.type.int', params:[resno] }] } } } }; |
| const REF = 'mut-hl'; |
| if (plugin.state.data.cells.has(REF)) plugin.build().delete(REF).commit(); |
| plugin.build().to(cellRef) |
| .apply(ST.Model.StructureComponentFromExpression, { label:`Mut@${resno}`, expression }, { ref: REF }) |
| .apply(ST.Representation.StructureRepresentation3D, { |
| type: { name:'ball-and-stick', params:{ sizeFactor:2.5 } }, |
| colorTheme: { name:'uniform', params:{ value:0xE3002C } }, |
| sizeTheme: { name:'uniform', params:{ value:1 } }, |
| }).commit(); |
| setTimeout(() => { |
| try { |
| const sphere = getSphereForResidue(plugin, resno); |
| if (sphere) plugin.managers.camera.focusSphere?.(sphere, { durationMs:500 }); |
| } catch(e) {} |
| }, 300); |
| return true; |
| } catch(e) { return false; } |
| } |
| |
| async function trySelectionFocus(plugin, resno) { |
| try { |
| const structures = plugin.managers.structure.hierarchy.current.structures; |
| if (!structures.length) return; |
| const structure = structures[0].cell.obj?.data; |
| if (!structure) return; |
| const loci = buildResidueLoci(structure, resno); |
| if (!loci) return; |
| plugin.managers.structure.selection.fromLoci('set', loci); |
| plugin.managers.camera.focusLoci(loci, { extraRadius:8, durationMs:500 }); |
| } catch(e) {} |
| } |
| |
| function buildResidueLoci(structure, resno) { |
| const els = []; |
| for (const unit of structure.units) { |
| const { elements } = unit; |
| const ri = unit.residueIndex; |
| const hier = unit.model.atomicHierarchy; |
| if (!hier?.residues?.auth_seq_id) continue; |
| const seqId = hier.residues.auth_seq_id; |
| const indices = []; |
| for (let j = 0; j < elements.length; j++) { |
| if (seqId.value(ri[elements[j]]) === resno) indices.push(j); |
| } |
| if (indices.length) els.push({ unit, indices: new Int32Array(indices.sort((a,b)=>a-b)) }); |
| } |
| if (!els.length) return null; |
| return { kind:'element-loci', structure, elements: els }; |
| } |
| |
| function getSphereForResidue(plugin, resno) { |
| try { |
| const structure = plugin.managers.structure.hierarchy.current.structures[0]?.cell.obj?.data; |
| if (!structure) return null; |
| const loci = buildResidueLoci(structure, resno); |
| if (!loci) return null; |
| let cx=0,cy=0,cz=0,n=0; |
| for (const { unit, indices } of loci.elements) { |
| const { elements } = unit, { x,y,z } = unit.conformation; |
| for (const idx of indices) { const eI = elements[idx]; cx+=x(eI);cy+=y(eI);cz+=z(eI);n++; } |
| } |
| if (!n) return null; |
| return { center:[cx/n,cy/n,cz/n], radius:6 }; |
| } catch(e) { return null; } |
| } |
| |
| function setupViewerControls() { |
| document.getElementById('btn-reset-view').onclick = () => { |
| try { App.molViewer?.plugin.managers.camera.reset(); } catch(e) {} |
| }; |
| document.getElementById('btn-toggle-spin').onclick = function() { |
| App.molSpinning = !App.molSpinning; |
| this.style.background = App.molSpinning ? 'rgba(255,255,255,.2)' : ''; |
| try { |
| const plugin = App.molViewer.plugin; |
| const cur = plugin.canvas3d.props.trackball; |
| plugin.canvas3d.setProps({ trackball: { ...cur, animate: App.molSpinning ? {name:'spin',params:{speed:1}} : {name:'off',params:{}} } }); |
| } catch(e) {} |
| }; |
| document.getElementById('btn-toggle-surface').onclick = function() { |
| if (App.selectedRow) { |
| App.molLoadedProtein = null; |
| loadAndHighlight(App.selectedRow.proteinId, App.selectedRow.mutCode); |
| } |
| }; |
| } |
| |
| |
| |
| |
| function encodeURL(query) { |
| |
| return '#q=' + query.trim().split('\n').map(l => l.trim().replace(/\s+/,'+')).join(','); |
| } |
| function decodeURL(hash) { |
| if (!hash.startsWith('#q=')) return null; |
| return hash.slice(3).split(',').map(l => l.replace(/\+/,' ')).join('\n'); |
| } |
| |
| function shareResults(rawQuery) { |
| const url = location.href.split('#')[0] + encodeURL(rawQuery); |
| navigator.clipboard?.writeText(url).then(() => { |
| const el = document.getElementById('share-done'); |
| el.style.display = 'inline'; |
| setTimeout(() => { el.style.display = 'none'; }, 1800); |
| }); |
| } |
| |
| |
| |
| |
| let lastQuery = ''; |
| |
| function isProteinWideQuery(query) { |
| const lines = query.trim().split('\n').map(l => l.trim()).filter(Boolean); |
| if (lines.length !== 1) return null; |
| const parsed = parseLine(lines[0]); |
| return (parsed && !parsed.error && parsed.mut === null) ? parsed.pid : null; |
| } |
| |
| function showResults(rows, query) { |
| lastQuery = query; |
| const proteins = [...new Set(rows.map(r => r.proteinId))]; |
| document.getElementById('results-title').textContent = |
| proteins.length === 1 ? `${proteins[0]} β ${rows.length.toLocaleString()} variants` |
| : `${rows.length.toLocaleString()} variants across ${proteins.length} proteins`; |
| document.getElementById('results-subtitle').textContent = `Query: ${query.trim().split('\n').join(', ')}`; |
| |
| |
| |
| const existingBanner = document.getElementById('landscape-banner'); |
| if (existingBanner) existingBanner.remove(); |
| const singlePid = isProteinWideQuery(query); |
| if (singlePid) { |
| const banner = document.createElement('div'); |
| banner.id = 'landscape-banner'; |
| banner.className = 'landscape-banner'; |
| banner.innerHTML = `<div class="landscape-banner-inner"> |
| <div class="landscape-banner-text"> |
| <span class="landscape-icon">β</span> |
| <div> |
| <strong>Mutational Landscape</strong> |
| <span>View all positions Γ amino acid substitutions as a heatmap</span> |
| </div> |
| </div> |
| <a href="landscape.html?protein=${singlePid}&score=am_pathogenicity" class="landscape-btn" target="_blank"> |
| Open landscape β |
| </a> |
| </div>`; |
| document.getElementById('results-header').after(banner); |
| } |
| |
| App.currentRows = rows; |
| App.selectedRow = null; |
| App.filterMech = 'all'; |
| App.filterText = ''; |
| App.filterPosition = null; |
| App.landscapeData = null; |
| document.querySelectorAll('.chip').forEach(c => c.classList.toggle('active', c.dataset.mech === 'all')); |
| document.getElementById('mutation-filter').value = ''; |
| updatePositionFilterUI(); |
| |
| document.body.dataset.view = 'results'; |
| renderTable(rows); |
| |
| |
| renderPositionChart(rows, null, null); |
| drawScatter(rows, null); |
| |
| |
| |
| if (singlePid) { |
| fetch(`${API_BASE}/api/protein/${singlePid}/landscape?score=am_pathogenicity`, |
| { signal: AbortSignal.timeout(120000) }) |
| .then(r => r.ok ? r.json() : null) |
| .then(json => { |
| if (json && json.data && json.data.length > 0) { |
| App.landscapeData = json.data; |
| renderPositionChart( |
| App.filteredRows.length ? App.filteredRows : App.currentRows, |
| App.filterPosition, |
| App.landscapeData |
| ); |
| } |
| }) |
| .catch(() => {}); |
| } |
| |
| |
| |
| |
| |
| if (rows.length > 0) { |
| requestAnimationFrame(() => { |
| vsRender(0); |
| selectRow(rows[0]); |
| }); |
| proteins.forEach(pid => fetchUniProt(pid)); |
| } |
| |
| |
| history.replaceState(null, '', encodeURL(query)); |
| } |
| |
| |
| |
| |
| |
| function apiResultToRow(r) { |
| const pathCount = (r.am_label ? 1 : 0) + (r.esm1b_is_pathogenic ? 1 : 0) + (r.pred_ddg_label ? 1 : 0); |
| return { |
| variantId: r.variant_id, |
| proteinId: r.protein_id, |
| mutCode: r.mutation_code, |
| amScore: r.am_pathogenicity ?? 0, |
| amClass: r.am_class, |
| amLabel: !!r.am_label, |
| esmLLR: r.esm1b_llr ?? null, |
| esmPathogenic: !!r.esm1b_is_pathogenic, |
| predDDG: r.pred_ddg ?? null, |
| ddgLabel: !!r.pred_ddg_label, |
| interfacePdockq: r.interface_pdockq ?? null, |
| interfaceLabel: !!r.interface_label, |
| pocketLabel: !!r.pocket_label, |
| mechLabel: r.mechanistic_label || 'Unassigned', |
| mlMechLabel: r.ml_mechLabel || null, |
| mlConfidence: r.ml_confidence ?? null, |
| pathCount, |
| consensus: pathCount >= 2 ? 'High Risk' : pathCount === 1 ? 'Medium' : 'Low Risk', |
| consensusScore: r.consensus_score ?? pathCount, |
| }; |
| } |
| |
| async function searchViaAPI(rawInput) { |
| const rows = [], errors = []; |
| const lines = rawInput.split('\n').map(l => l.trim()).filter(Boolean); |
| for (const line of lines) { |
| const parsed = parseLine(line); |
| if (!parsed) continue; |
| if (parsed.error) { errors.push(parsed.error); continue; } |
| try { |
| const url = parsed.mut |
| ? `${API_BASE}/api/search?protein_id=${parsed.pid}&mutation=${parsed.mut}&limit=1` |
| : `${API_BASE}/api/search?protein_id=${parsed.pid}&limit=2000`; |
| const resp = await fetch(url); |
| if (!resp.ok) { |
| errors.push(`Protein "${parsed.pid}" not found or API error.`); |
| continue; |
| } |
| const data = await resp.json(); |
| if (data.total === 0) { |
| errors.push(`${parsed.mut ? `Variant "${parsed.pid}/${parsed.mut}"` : `Protein "${parsed.pid}"`} not found in dataset.`); |
| continue; |
| } |
| const mapped = data.results.map(apiResultToRow); |
| |
| for (const r of mapped) { |
| App.byVariant.set(r.variantId, r); |
| if (!App.byProtein.has(r.proteinId)) App.byProtein.set(r.proteinId, []); |
| if (!App.byProtein.get(r.proteinId).find(x => x.variantId === r.variantId)) |
| App.byProtein.get(r.proteinId).push(r); |
| } |
| rows.push(...mapped); |
| } catch(e) { |
| errors.push(`Search failed for "${line}": ${e.message}`); |
| } |
| } |
| return { rows, errors }; |
| } |
| |
| async function handleAnalyze() { |
| const raw = document.getElementById('query-input').value; |
| if (!raw.trim()) return; |
| document.getElementById('btn-analyze').disabled = true; |
| try { |
| let rows, errors; |
| if (API_MODE) { |
| ({ rows, errors } = await searchViaAPI(raw)); |
| } else { |
| ({ rows, errors } = executeSearch(raw)); |
| } |
| renderErrors(errors); |
| if (rows.length === 0 && !errors.length) renderErrors(['No results found. Check the protein ID and mutation format.']); |
| if (rows.length > 0) showResults(rows, raw); |
| } finally { |
| document.getElementById('btn-analyze').disabled = false; |
| } |
| } |
| |
| function renderErrors(errors) { |
| const el = document.getElementById('search-errors'); |
| if (!errors.length) { el.style.display = 'none'; return; } |
| el.style.display = 'block'; |
| el.innerHTML = errors.map(e => `<div class="error-item">${escHtml(e)}</div>`).join(''); |
| } |
| |
| |
| |
| |
| function exportCSV() { |
| const rows = App.filteredRows.length ? App.filteredRows : App.currentRows; |
| const hdr = ['variant_id','protein_id','mutation','am_pathogenicity','am_class','ESM1b_LLR','pred_ddg','mechanistic_label','interface_label','pocket_label','consensus']; |
| const lines = [hdr.join(',')]; |
| for (const r of rows) { |
| lines.push([r.variantId,r.proteinId,r.mutCode,r.amScore.toFixed(4),r.amClass,r.esmLLR!=null?r.esmLLR.toFixed(3):'',r.predDDG!=null?r.predDDG.toFixed(3):'',r.mechLabel,r.interfaceLabel,r.pocketLabel,`"${r.consensus}"`].join(',')); |
| } |
| const a = document.createElement('a'); |
| a.href = URL.createObjectURL(new Blob([lines.join('\r\n')], { type:'text/csv;charset=utf-8;' })); |
| a.download = `mutvar_${Date.now()}.csv`; |
| a.click(); URL.revokeObjectURL(a.href); |
| } |
| |
| |
| |
| |
| const CV_COLORS = { |
| 'Pathogenic': '#dc2626', |
| 'Likely pathogenic': '#ef4444', |
| 'Uncertain significance': '#f59e0b', |
| 'Likely benign': '#22c55e', |
| 'Benign': '#16a34a', |
| }; |
| |
| async function loadClinVar(proteinId, mutCode, ourAmClass) { |
| const panel = document.getElementById('clinvar-panel'); |
| panel.innerHTML = '<div class="cv-section-title">ClinVar</div><span class="cv-loading">Checking ClinVarβ¦</span>'; |
| |
| try { |
| const resp = await fetch(`${API_BASE}/api/clinvar/${proteinId}/${mutCode}`); |
| const data = await resp.json(); |
| |
| if (!data.found) { |
| panel.innerHTML = ` |
| <div class="cv-section-title">ClinVar</div> |
| <div class="cv-not-found">Not found in ClinVar${data.reason ? ` (${data.reason})` : ''}</div>`; |
| return; |
| } |
| |
| const color = CV_COLORS[data.classification] || '#6b7280'; |
| const cvClass = data.classification.toLowerCase(); |
| const disagreement = ( |
| (ourAmClass === 'pathogenic' && (cvClass.includes('benign') && !cvClass.includes('likely pathogenic'))) || |
| (ourAmClass === 'benign' && cvClass.includes('pathogenic')) |
| ); |
| |
| panel.innerHTML = ` |
| <div class="cv-section-title">ClinVar</div> |
| <div class="cv-found"> |
| <div> |
| <span class="cv-badge" style="background:${color}">${escHtml(data.classification)}</span> |
| </div> |
| ${data.review_status ? `<div class="cv-review">${escHtml(data.review_status)}</div>` : ''} |
| ${data.title ? `<div class="cv-title">${escHtml(data.title)}</div>` : ''} |
| <a class="cv-link" href="${escHtml(data.clinvar_url)}" target="_blank" rel="noopener">View on ClinVar β</a> |
| ${disagreement ? ` |
| <div class="cv-alert"> |
| <strong>Prediction disagreement</strong> β AlphaMissense: <b>${ourAmClass}</b> vs ClinVar: <b>${escHtml(data.classification)}</b>. |
| This variant warrants manual review. |
| </div>` : ''} |
| </div>`; |
| } catch(e) { |
| panel.innerHTML = '<div class="cv-section-title">ClinVar</div><span class="cv-not-found">Lookup unavailable</span>'; |
| } |
| } |
| |
| |
| |
| |
| function delay(ms) { return new Promise(r => setTimeout(r, ms)); } |
| |
| |
| |
| |
| document.addEventListener('DOMContentLoaded', async () => { |
| |
| |
| document.querySelectorAll('.example-chip').forEach(chip => { |
| chip.onclick = () => { |
| document.getElementById('query-input').value = chip.dataset.query; |
| document.getElementById('search-errors').style.display = 'none'; |
| }; |
| }); |
| |
| |
| document.getElementById('btn-analyze').onclick = () => handleAnalyze(); |
| document.getElementById('query-input').addEventListener('keydown', e => { |
| if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleAnalyze(); |
| }); |
| |
| |
| document.getElementById('btn-back').onclick = () => { document.body.dataset.view = 'search'; }; |
| |
| |
| document.querySelectorAll('#results-table th[data-col]').forEach(th => { |
| th.onclick = () => { |
| const col = th.dataset.col; |
| App.sortDir = App.sortCol === col ? (App.sortDir === 'asc' ? 'desc' : 'asc') : (col === 'mutCode' || col === 'mechLabel' ? 'asc' : 'desc'); |
| App.sortCol = col; |
| renderTable(App.currentRows); |
| }; |
| }); |
| |
| |
| document.getElementById('filter-chips').onclick = e => { |
| const chip = e.target.closest('.chip'); if (!chip) return; |
| App.filterMech = chip.dataset.mech; |
| document.querySelectorAll('.chip').forEach(c => c.classList.remove('active')); |
| chip.classList.add('active'); |
| renderTable(App.currentRows); |
| renderPositionChart(App.filteredRows.length ? App.filteredRows : App.currentRows, App.filterPosition, App.landscapeData); |
| }; |
| |
| |
| let filterTimer; |
| document.getElementById('mutation-filter').oninput = function() { |
| clearTimeout(filterTimer); |
| filterTimer = setTimeout(() => { |
| App.filterText = this.value.trim(); |
| renderTable(App.currentRows); |
| }, 120); |
| }; |
| |
| |
| document.getElementById('btn-clear-posfilter').onclick = () => { |
| App.filterPosition = null; |
| updatePositionFilterUI(); |
| renderTable(App.currentRows); |
| renderPositionChart(App.currentRows, null, App.landscapeData); |
| }; |
| |
| |
| document.getElementById('btn-export').onclick = exportCSV; |
| document.getElementById('btn-share').onclick = () => { if (lastQuery) shareResults(lastQuery); }; |
| |
| |
| document.getElementById('table-scroll').addEventListener('keydown', e => { |
| if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; |
| e.preventDefault(); |
| const rows = App.filteredRows; |
| if (!rows.length) return; |
| const currIdx = App.selectedRow ? rows.findIndex(r => r.variantId === App.selectedRow.variantId) : -1; |
| const nextIdx = e.key === 'ArrowDown' ? Math.min(rows.length-1, currIdx+1) : Math.max(0, currIdx-1); |
| if (nextIdx !== currIdx) selectRow(rows[nextIdx]); |
| }); |
| |
| |
| window.addEventListener('resize', () => { |
| if (document.body.dataset.view === 'results' && App.currentRows.length) { |
| renderPositionChart(App.filteredRows.length ? App.filteredRows : App.currentRows, App.filterPosition, App.landscapeData); |
| drawScatter(App.currentRows, App.selectedRow); |
| } |
| }); |
| |
| |
| document.getElementById('scatter-canvas').addEventListener('click', e => { |
| const rect = e.target.getBoundingClientRect(); |
| const mx = e.clientX - rect.left, my = e.clientY - rect.top; |
| let nearest = null, minDist = 14; |
| for (const p of scatterPoints) { |
| const d = Math.hypot(p.x - mx, p.y - my); |
| if (d < minDist) { minDist = d; nearest = p; } |
| } |
| if (nearest) selectRow(nearest.row); |
| }); |
| |
| |
| setupViewerControls(); |
| vsInit(); |
| |
| |
| const statusEl = document.getElementById('tsv-status'); |
| statusEl.textContent = 'Connectingβ¦'; |
| |
| let dataReady = false; |
| |
| |
| try { |
| const infoResp = await fetch(`${API_BASE}/api/info`, { signal: AbortSignal.timeout(60000) }); |
| if (infoResp.ok) { |
| const info = await infoResp.json(); |
| API_MODE = true; |
| statusEl.textContent = `${info.total_variants.toLocaleString()} variants Β· ${info.total_proteins.toLocaleString()} proteins`; |
| document.getElementById('btn-analyze').disabled = false; |
| dataReady = true; |
| } |
| } catch(_) {} |
| |
| |
| if (!dataReady) { |
| try { |
| await loadTSV(); |
| statusEl.textContent = `${App.byVariant.size.toLocaleString()} variants ready`; |
| document.getElementById('btn-analyze').disabled = false; |
| dataReady = true; |
| } catch(e) { |
| statusEl.textContent = 'Dataset load failed'; |
| console.error(e); |
| } |
| } |
| |
| |
| const fromURL = decodeURL(location.hash); |
| if (fromURL && dataReady) { |
| document.getElementById('query-input').value = fromURL; |
| await handleAnalyze(); |
| } |
| |
| |
| if (typeof molstar !== 'undefined') initMolstar(); |
| else window.addEventListener('load', () => { if (typeof molstar !== 'undefined') initMolstar(); }); |
| }); |
| </script> |
| </body> |
| </html> |
|
|