| <!doctype html> |
| <html lang="ko"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width,initial-scale=1"> |
| <title>LEADBOARD — 신약 예측 도구 시험대</title> |
| <meta name="description" content="One yardstick, per discipline, for drug prediction tools. Baselines and experimental error published in advance."> |
| |
| |
| <style> |
| :root{ |
| --bg:#fbfcfd;--bg2:#f0f3f7;--card:#fff;--ink:#0b0d12;--ink2:#3a4150; |
| --muted:#6d7585;--line:#e6e9ef;--line2:#d0d6e0; |
| --acc:#1f4fa3;--acc2:#3d7ae0;--soft:#eaf0fb; |
| --warn:#8a5c00;--warnbg:#fff6e2;--warnln:#f0d9a0; |
| --good:#0f6b43;--goodbg:#e6f6ee;--bad:#9c2f24;--badbg:#fdeceb; |
| --m:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; |
| --sh:0 1px 2px rgba(13,18,30,.05),0 2px 6px rgba(13,18,30,.05); |
| --sh2:0 8px 16px -6px rgba(13,18,30,.14),0 20px 36px -12px rgba(13,18,30,.14); |
| } |
| @media (prefers-color-scheme:dark){:root:not([data-theme="light"]){ |
| --bg:#090b0f;--bg2:#101319;--card:#141821;--ink:#f1f4f8;--ink2:#c8cfdb; |
| --muted:#8b94a5;--line:#212734;--line2:#2e3646;--acc:#8cb0f0;--acc2:#6a95e4; |
| --soft:#111a2b;--warn:#e8bd5e;--warnbg:#241d0e;--warnln:#4a3c1a; |
| --good:#4fd396;--goodbg:#0c2418;--bad:#f59285;--badbg:#2b1411; |
| --sh:0 1px 2px rgba(0,0,0,.4);--sh2:0 12px 32px rgba(0,0,0,.55);}} |
| :root[data-theme="dark"]{ |
| --bg:#090b0f;--bg2:#101319;--card:#141821;--ink:#f1f4f8;--ink2:#c8cfdb; |
| --muted:#8b94a5;--line:#212734;--line2:#2e3646;--acc:#8cb0f0;--acc2:#6a95e4; |
| --soft:#111a2b;--warn:#e8bd5e;--warnbg:#241d0e;--warnln:#4a3c1a; |
| --good:#4fd396;--goodbg:#0c2418;--bad:#f59285;--badbg:#2b1411; |
| --sh:0 1px 2px rgba(0,0,0,.4);--sh2:0 12px 32px rgba(0,0,0,.55);} |
| *{box-sizing:border-box} |
| /* 히어로 뒤 그라디언트가 좌우로 음수 inset 을 쓴다. z-index 를 내려도 스크롤 범위에는 |
| 남아 가로 스크롤이 생긴다. clip 은 hidden 과 달리 sticky 헤더를 깨뜨리지 않는다. */ |
| html{overflow-x:clip} |
| body{margin:0;overflow-x:clip;background:var(--bg);color:var(--ink);line-height:1.62;font-size:15px; |
| font-family:-apple-system,BlinkMacSystemFont,"Pretendard","Apple SD Gothic Neo", |
| "Malgun Gothic",Segoe UI,Roboto,sans-serif;-webkit-font-smoothing:antialiased} |
| .wrap{max-width:1060px;margin:0 auto;padding:0 24px} |
| a{color:var(--acc);text-decoration:none}a:hover{text-decoration:underline} |
| .m,code{font-family:var(--m);font-variant-numeric:tabular-nums} |
| .dim{color:var(--muted)} |
|
|
| nav{position:sticky;top:0;z-index:30;background:color-mix(in srgb,var(--bg) 78%,transparent); |
| backdrop-filter:saturate(180%) blur(16px);border-bottom:1px solid var(--line)} |
| nav .in{max-width:1060px;margin:0 auto;padding:12px 24px;display:flex;align-items:center;gap:11px} |
| .lg{font-weight:850;letter-spacing:-.04em;font-size:18px;color:var(--ink)} |
| .lg:hover{text-decoration:none}.lg i{color:var(--acc);font-style:normal} |
| .sp{flex:1} |
| .chip{display:inline-flex;align-items:center;font-size:11px;font-family:var(--m); |
| padding:3px 9px;border-radius:99px;background:var(--soft);color:var(--acc);font-weight:700} |
| .chip.g{background:transparent;border:1px solid var(--line2);color:var(--muted)} |
| .chip.ok{background:var(--goodbg);color:var(--good)} |
| .chip.wr{background:var(--warnbg);color:var(--warn)} |
| .chip.bd{background:var(--badbg);color:var(--bad)} |
| .lang{padding:5px 11px;border-radius:8px;border:1px solid var(--line2);background:var(--card); |
| color:var(--ink2);font-size:12px;font-weight:650;cursor:pointer;font-family:inherit} |
| .lang:hover{border-color:var(--acc);color:var(--acc)} |
| .btn{display:inline-flex;align-items:center;gap:8px;padding:10px 19px;border-radius:11px; |
| background:linear-gradient(180deg,var(--acc2),var(--acc));color:#fff;font-size:14px; |
| font-weight:650;border:0;cursor:pointer;box-shadow:var(--sh);transition:transform .1s,filter .15s} |
| .btn:hover{filter:brightness(1.08);text-decoration:none} |
| .btn:active{transform:translateY(1px)}.btn:disabled{opacity:.45;cursor:default} |
|
|
| /* 자매 시험대 띠 — ODC/OMC 로 건너가는 자리. 사이트가 혼자 서 있지 않다는 표시이기도 하다 */ |
| .sib{border-bottom:1px solid var(--line);background:var(--bg2)} |
| .sib .in{max-width:1060px;margin:0 auto;padding:10px 24px;display:flex;gap:10px; |
| align-items:center;flex-wrap:wrap;font-size:12.5px} |
| .sib .lb{color:var(--muted);letter-spacing:.06em;text-transform:uppercase;font-size:10.5px;font-weight:700} |
| .sib a{display:inline-flex;align-items:center;gap:7px;padding:5px 12px;border-radius:99px; |
| border:1px solid var(--line2);background:var(--card);color:var(--ink2);font-weight:600} |
| .sib a:hover{border-color:var(--acc);color:var(--acc);text-decoration:none} |
| .sib a span{color:var(--muted);font-weight:400} |
|
|
| /* 발견 카드 — 서사가 아니라 수치가 주인공이 되게 한다 */ |
| .finds{display:grid;grid-template-columns:repeat(auto-fit,minmax(288px,1fr));gap:15px} |
| .find{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:21px; |
| box-shadow:var(--sh);border-top:3px solid var(--acc)} |
| .find b{display:block;font-size:16.5px;font-weight:750;letter-spacing:-.02em;margin-bottom:8px;line-height:1.4} |
| .find p{font-size:13.5px;color:var(--muted);line-height:1.62;margin:0} |
|
|
| .prose{font-size:15.5px;color:var(--ink2);line-height:1.78;max-width:44em} |
| .prose p{margin:0 0 16px} |
| .cite{font-size:12.5px;color:var(--muted);font-style:italic;border-left:2px solid var(--line2); |
| padding-left:13px;margin:6px 0 0} |
|
|
| /* 지표 정의 */ |
| .defs{display:grid;grid-template-columns:auto 1fr;gap:0;border:1px solid var(--line); |
| border-radius:16px;overflow:hidden;background:var(--card);box-shadow:var(--sh)} |
| .defs dt{padding:14px 18px;font-family:var(--m);font-size:13px;font-weight:750;color:var(--acc); |
| border-top:1px solid var(--line);white-space:nowrap} |
| .defs dd{padding:14px 18px 14px 0;margin:0;font-size:13.5px;color:var(--ink2); |
| border-top:1px solid var(--line);line-height:1.6} |
| .defs dt:first-of-type,.defs dd:first-of-type{border-top:0} |
|
|
| .hero{padding:78px 0 46px;position:relative} |
| .hero::before{content:"";position:absolute;inset:-70% -30% auto -30%;height:520px;z-index:-1; |
| background:radial-gradient(ellipse 55% 50% at 32% 55%,color-mix(in srgb,var(--acc) 16%,transparent),transparent 70%)} |
| .brand{font-size:clamp(46px,9vw,80px);font-weight:900;letter-spacing:-.05em;margin:0;line-height:.96} |
| .brand i{color:var(--acc);font-style:normal} |
| .subttl{font-size:12.5px;color:var(--muted);margin:9px 0 0;letter-spacing:.11em;text-transform:uppercase} |
| .tag{font-size:clamp(23px,3.9vw,34px);font-weight:750;margin:30px 0 14px; |
| letter-spacing:-.03em;line-height:1.32;max-width:17em} |
| .lede{font-size:16.5px;color:var(--ink2);margin:0;max-width:38em;line-height:1.72} |
| .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(128px,1fr));gap:1px; |
| margin:44px 0 0;background:var(--line);border:1px solid var(--line);border-radius:14px;overflow:hidden} |
| .stat{background:var(--card);padding:17px 19px} |
| .stat b{display:block;font-size:30px;font-weight:850;font-family:var(--m);letter-spacing:-.045em;line-height:1.1} |
| .stat span{font-size:11.5px;color:var(--muted)} |
|
|
| .sec{margin:76px 0 0} |
| .sec h2{font-size:11.5px;font-weight:750;letter-spacing:.17em;text-transform:uppercase; |
| color:var(--acc);margin:0 0 8px} |
| .sec .lead2{font-size:15.5px;color:var(--ink2);margin:0 0 22px;max-width:46em} |
| h3{font-size:18.5px;font-weight:720;margin:30px 0 9px;letter-spacing:-.018em} |
| p{margin:0 0 15px} |
|
|
| .terr{background:var(--card);border:1px solid var(--line);border-radius:16px; |
| padding:22px 24px 18px;box-shadow:var(--sh)} |
| .trow{display:grid;grid-template-columns:104px 1fr 60px;align-items:center;gap:12px;padding:4px 0;font-size:13px} |
| .trow .nm{font-weight:650;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} |
| .trow .nm a{color:var(--ink)} |
| .trk{position:relative;height:19px;background:var(--bg2);border-radius:5px;overflow:hidden} |
| .trk .fill{position:absolute;left:0;top:0;bottom:0;border-radius:5px; |
| background:linear-gradient(90deg,var(--acc2),var(--acc));opacity:.85} |
| .trk .fill.in{background:linear-gradient(90deg,#e0a83c,#c98d1d)} |
| .trk .one{position:absolute;top:-3px;bottom:-3px;width:2px;background:var(--ink);opacity:.42} |
| .trow .v{font-family:var(--m);font-size:12px;color:var(--muted);text-align:right} |
| .trow .v.in{color:var(--warn);font-weight:700} |
| .taxis{display:grid;grid-template-columns:104px 1fr 60px;gap:12px;margin-top:11px; |
| font-size:11px;color:var(--muted);font-family:var(--m)} |
| .taxis .lbl{display:flex;justify-content:space-between;gap:8px} |
|
|
| .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(252px,1fr));gap:15px} |
| .cc{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:20px; |
| display:block;box-shadow:var(--sh);transition:box-shadow .2s,transform .13s,border-color .2s} |
| a.cc:hover{box-shadow:var(--sh2);transform:translateY(-3px);border-color:var(--line2);text-decoration:none} |
| .cc.off{opacity:.5} |
| .cc .ico{font-size:25px;line-height:1} |
| .cc .nm{font-weight:720;font-size:17px;margin:11px 0 4px;color:var(--ink); |
| display:flex;align-items:center;gap:8px;flex-wrap:wrap} |
| .cc .ds{font-size:13px;color:var(--muted);line-height:1.55;min-height:40px} |
| .cc .mt{margin-top:14px;padding-top:12px;border-top:1px solid var(--line); |
| font-family:var(--m);font-size:11.5px;color:var(--muted); |
| display:flex;justify-content:space-between;gap:8px} |
|
|
| .crumb{font-size:13px;color:var(--muted);margin:30px 0 12px} |
| .ttl{font-size:clamp(30px,5vw,42px);font-weight:850;letter-spacing:-.035em;margin:0 0 6px; |
| display:flex;align-items:center;gap:12px;flex-wrap:wrap} |
| .pills{display:flex;gap:7px;flex-wrap:wrap;margin:24px 0 4px} |
| .pilltab{padding:9px 17px;border-radius:11px;font-size:13.5px;font-weight:650; |
| color:var(--muted);border:1px solid var(--line);background:var(--card)} |
| .pilltab:hover{text-decoration:none;color:var(--ink2);border-color:var(--line2)} |
| .pilltab.on{background:var(--ink);color:var(--bg);border-color:var(--ink)} |
|
|
| .tw{overflow-x:auto;border:1px solid var(--line);border-radius:16px;background:var(--card); |
| box-shadow:var(--sh);margin:18px 0} |
| table{width:100%;border-collapse:collapse;font-size:13.5px} |
| th{text-align:right;font-weight:650;color:var(--muted);font-size:10.5px;letter-spacing:.08em; |
| text-transform:uppercase;padding:13px;border-bottom:1px solid var(--line);white-space:nowrap} |
| th:first-child,td:first-child{text-align:center;width:48px} |
| th:nth-child(2),td:nth-child(2),th:nth-child(3),td:nth-child(3){text-align:left} |
| td{padding:13px;border-bottom:1px solid var(--line);text-align:right; |
| font-family:var(--m);font-variant-numeric:tabular-nums;white-space:nowrap} |
| td:nth-child(2){font-family:inherit;font-weight:680} |
| tbody tr:last-child td{border-bottom:0} |
| tbody tr:hover td{background:var(--bg2)} |
| tr.base td{color:var(--muted)} |
| .rk{display:inline-flex;width:25px;height:25px;align-items:center;justify-content:center; |
| border-radius:8px;background:var(--bg2);font-size:12px;font-weight:750} |
| .rk.g1{background:#f0c44a26;color:#9c7710}.rk.g2{background:#aab4c426;color:#77828f} |
| .rk.g3{background:#c9885526;color:#95612f} |
| .gain{color:var(--good);font-weight:700} |
| .loss{color:var(--bad);font-weight:700} |
| .sub2{font-size:11.5px;color:var(--muted);font-family:inherit;font-weight:400;margin-top:3px} |
| .cc .bl{font-size:12.5px;color:var(--acc);font-weight:600;margin:-1px 0 6px} |
| .blurbig{font-size:17px;color:var(--ink2);font-weight:600;margin:2px 0 8px} |
| .mini{display:inline-block;height:6px;border-radius:3px;background:var(--acc);opacity:.5; |
| vertical-align:middle;margin-right:7px} |
|
|
| .fig{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:19px 21px; |
| margin:16px 0;font-family:var(--m);font-size:13px;line-height:1.95;overflow-x:auto;box-shadow:var(--sh)} |
| .fig .n{color:var(--acc);font-weight:750} |
| .note{background:var(--warnbg);border:1px solid var(--warnln);border-radius:13px; |
| padding:15px 18px;font-size:14.5px;margin:18px 0;color:var(--ink2);line-height:1.65} |
| .note.ok{background:var(--goodbg);border-color:color-mix(in srgb,var(--good) 30%,transparent)} |
| .note.n{background:var(--card);border-color:var(--line)} |
| .kv{display:grid;grid-template-columns:190px 1fr;gap:10px 18px;font-size:14px;margin:15px 0} |
| .kv dt{color:var(--muted)}.kv dd{margin:0;font-family:var(--m);font-size:13px;word-break:break-word} |
| label{display:block;font-size:13.5px;font-weight:680;margin:17px 0 6px} |
| .hint{font-size:12px;color:var(--muted);font-weight:400} |
| input[type=text],textarea{width:100%;padding:12px 14px;border:1px solid var(--line2); |
| border-radius:11px;background:var(--card);color:var(--ink);font-family:var(--m);font-size:13px} |
| input:focus,textarea:focus{outline:2px solid color-mix(in srgb,var(--acc) 45%,transparent); |
| outline-offset:-1px;border-color:var(--acc)} |
| .drop{border:2px dashed var(--line2);border-radius:13px;padding:30px;text-align:center; |
| color:var(--muted);font-size:14px;cursor:pointer;transition:.15s} |
| .drop:hover,.drop.hot{border-color:var(--acc);background:var(--soft);color:var(--acc)} |
| .steps{counter-reset:s;list-style:none;padding:0;margin:18px 0} |
| .steps li{counter-increment:s;position:relative;padding:0 0 26px 46px;border-left:2px solid var(--line);margin-left:15px} |
| .steps li:last-child{border-left-color:transparent;padding-bottom:0} |
| .steps li::before{content:counter(s);position:absolute;left:-16px;top:-3px;width:30px;height:30px; |
| border-radius:50%;background:var(--soft);color:var(--acc);font-weight:750;font-size:13px; |
| display:flex;align-items:center;justify-content:center;font-family:var(--m);border:3px solid var(--bg)} |
| .steps b{display:block;font-size:16px;margin-bottom:4px} |
| ol.cl{list-style:none;counter-reset:c;padding:0;margin:18px 0; |
| display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:0} |
| ol.cl li{counter-increment:c;position:relative;padding:16px 16px 16px 52px;border-top:1px solid var(--line)} |
| ol.cl li::before{content:counter(c);position:absolute;left:8px;top:16px;width:30px;height:30px; |
| border-radius:9px;background:var(--soft);color:var(--acc);font-weight:750;font-size:13px; |
| display:flex;align-items:center;justify-content:center;font-family:var(--m)} |
| ol.cl b{display:block;font-size:15px;margin-bottom:3px} |
| ol.cl span{font-size:13px;color:var(--muted);line-height:1.5} |
| .skel{height:150px;border-radius:16px;background:linear-gradient(90deg,var(--bg2),var(--card),var(--bg2)); |
| background-size:200% 100%;animation:sh 1.3s infinite} |
| @keyframes sh{0%{background-position:200% 0}100%{background-position:-200% 0}} |
| footer{margin-top:88px;padding:28px 0 64px;border-top:1px solid var(--line); |
| font-size:13px;color:var(--muted);display:flex;gap:20px;flex-wrap:wrap;align-items:center} |
| @media(max-width:640px){ |
| .kv{grid-template-columns:1fr}.kv dt{margin-top:8px} |
| .trow,.taxis{grid-template-columns:78px 1fr 48px;gap:8px;font-size:12px}} |
| </style> |
| </head> |
| <body> |
|
|
| <nav><div class="in"> |
| <a class="lg" href="#/">LEAD<i>BOARD</i></a> |
| <span class="chip g" id="spec">v1.1</span> |
| <span class="sp"></span> |
| <button class="lang" id="lang"></button> |
| <span id="who" style="font-size:13px;color:var(--muted)"></span> |
| </div></nav> |
|
|
| <div class="sib"><div class="in" id="sibbar"></div></div> |
| <div id="app"><div class="wrap"><div class="skel" style="margin-top:70px"></div></div></div> |
| <footer class="wrap" id="foot"></footer> |
|
|
| <script src="i18n.js"></script> |
| <script> |
| const $=s=>document.querySelector(s), app=$("#app"); |
| const f=(x,d=3)=>(x==null||isNaN(x))?"—":Number(x).toFixed(d); |
| const esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c])); |
| const W=h=>`<div class="wrap">${h}</div>`; |
| /* 제출자는 허깅페이스 계정이다. 이름만 적으면 누구인지 확인할 길이 없다. */ |
| const hfUser=u=>u&&u!=="—" |
| ?`<a href="https://huggingface.co/${encodeURIComponent(u)}" target="_blank" rel="noopener">${esc(u)}</a>` |
| :"—"; |
| let CATS=null, ME={user:null}, BOARDS={}, L=null, t=null; |
| |
| /* 언어 결정 순서: 저장된 선택 → 브라우저 설정 → 영어. |
| 브라우저를 먼저 보면 한 번 고른 선택이 새로고침마다 뒤집힌다. */ |
| function pickLang(){ |
| const saved=localStorage.getItem("lb-lang"); |
| if(saved && window.T[saved]) return saved; |
| return (navigator.language||"").toLowerCase().startsWith("ko") ? "ko" : "en"; |
| } |
| function setLang(l){ |
| L=l; t=window.T[l]; |
| localStorage.setItem("lb-lang", l); |
| document.documentElement.lang=t.htmlLang; |
| document.title=t.title; |
| $("#lang").textContent=t.langBtn; |
| paintChrome(); |
| route(); |
| } |
| function paintChrome(){ |
| $("#who").innerHTML=ME.user?`${esc(ME.user.user)} · <a href="/logout">${t.logout}</a>` |
| :(ME.login_enabled?`<a href="/login?next=${encodeURIComponent(location.hash||"#/")}">${t.login}</a>`:""); |
| $("#sibbar").innerHTML=`<span class="lb">${t.sib}</span> |
| <a href="https://huggingface.co/spaces/FINAL-Bench/open-discovery-challenge"> |
| 🧪 Open Discovery Challenge <span>${t.sibOdc}</span></a> |
| <a href="https://huggingface.co/spaces/FINAL-Bench/open-materials-challenge"> |
| 🔋 Open Materials Challenge <span>${t.sibOmc}</span></a>`; |
| $("#foot").innerHTML=`<span>LEADBOARD · FINAL-Bench / VIDRAFT</span> |
| <span class="dim">${t.srcLabel} <a href="https://www.ebi.ac.uk/chembl/">ChEMBL</a> · |
| <a href="https://creativecommons.org/licenses/by-sa/3.0/">CC BY-SA 3.0</a></span> |
| <span class="sp"></span><a href="/api/health">API</a> |
| <a href="https://huggingface.co/spaces/FINAL-Bench/open-discovery-challenge">ODC</a> |
| <a href="https://huggingface.co/spaces/FINAL-Bench/open-materials-challenge">OMC</a>`; |
| } |
| async function j(u,o){const r=await fetch(u,o);if(!r.ok)throw new Error(await r.text());return r.json();} |
| |
| /* ═══ 측정 한계 지형 ═══ */ |
| function terrain(items,link){ |
| const MAX=3.0, pc=v=>Math.min(100,100*v/MAX); |
| return `<div class="terr">${items.map(b=>{ |
| const inside=b.ratio<1.0; |
| const nm=link?`<a href="#/b/${encodeURIComponent(b.board)}">${esc(b.board)}</a>`:esc(b.board); |
| return `<div class="trow"><div class="nm">${nm}</div> |
| <div class="trk"><div class="fill ${inside?"in":""}" style="width:${pc(b.ratio)}%"></div> |
| <div class="one" style="left:${pc(1)}%"></div></div> |
| <div class="v ${inside?"in":""}">${f(b.ratio,2)}×</div></div>`;}).join("")} |
| <div class="taxis"><span></span><span class="lbl"> |
| <i>0</i><i>← ${t.terrLeft} · 1.0 · ${t.terrRight} →</i><i>${MAX.toFixed(1)}</i> |
| </span><span></span></div></div>`; |
| } |
| /* 분류 전용 부문은 MAE 기준선이 없다. 그런 부문을 0 으로 처리하면 지형도 맨 왼쪽에 |
| 붙어 "측정 한계에 도달했다"로 보인다. 재지 못한 것과 재서 작은 것은 다르므로 비운다. */ |
| const blurb=b=>esc(L==="ko"?(b.blurb||""):(b.blurb_en||b.blurb||"")); |
| const ratioOf=b=>{ |
| const nf=(b.noise_floor||{}).sd_single; |
| const maes=Object.values(b.baselines||{}).map(v=>v.mae).filter(v=>v!=null); |
| if(!nf||!maes.length) return null; |
| return Math.min(...maes)/nf; |
| }; |
| |
| async function boot(){ |
| try{ME=await j("/api/me");}catch(e){} |
| CATS=await j("/api/categories"); |
| CATS.categories.forEach(c=>(c.board_cards||[]).forEach(b=>BOARDS[b.board]=b)); |
| $("#spec").textContent=CATS.spec; |
| $("#lang").onclick=()=>setLang(L==="ko"?"en":"ko"); |
| setLang(pickLang()); |
| } |
| addEventListener("hashchange",route); |
| function route(){ |
| if(!t)return; |
| const p=(location.hash||"#/").slice(2).split("/").filter(Boolean);scrollTo(0,0); |
| if(!p.length)return hub(); |
| if(p[0]==="c")return category(p[1]); |
| if(p[0]==="b")return board(decodeURIComponent(p[1]),p[2]||"rank"); |
| hub(); |
| } |
| |
| /* ═══ 허브 ═══ */ |
| function hub(){ |
| const T0=CATS.totals, bs=Object.values(BOARDS); |
| const items=bs.map(b=>({board:b.board,ratio:ratioOf(b)})) |
| .filter(x=>x.ratio!=null).sort((a,b)=>a.ratio-b.ratio); |
| const inside=items.filter(x=>x.ratio<1.3).length; |
| const lost=bs.filter(b=>b.beats_constant===false).length; |
| app.innerHTML=W(` |
| <div class="hero"> |
| <h1 class="brand">LEAD<i>BOARD</i></h1> |
| <p class="subttl">${t.heroKicker}</p> |
| <p class="tag">${t.heroTag}</p> |
| <p class="lede">${t.heroLede}</p> |
| <div class="stats"> |
| <div class="stat"><b>${T0.open}</b><span>${t.statBoards}</span></div> |
| <div class="stat"><b>${bs.reduce((s,b)=>s+b.n_test,0).toLocaleString()}</b><span>${t.statCompounds}</span></div> |
| <div class="stat"><b>${CATS.categories.filter(c=>c.open).length}</b><span>${t.statFields}</span></div> |
| <div class="stat"><b>8</b><span>${t.statClauses}</span></div> |
| <div class="stat"><b>${T0.planned}</b><span>${t.statPlanned}</span></div> |
| </div> |
| </div> |
| |
| <div class="sec"><h2>${t.secWhy}</h2> |
| <div class="prose"><p>${t.why1}</p><p>${t.why2}</p><p>${t.why3}</p> |
| <p class="cite">${t.whyCite}</p></div></div> |
| |
| <div class="sec"><h2>${t.secFound}</h2> |
| <p class="lead2">${t.secFoundLead}</p> |
| <div class="finds"> |
| <div class="find"><b>${t.found1t}</b><p>${t.found1d}</p></div> |
| <div class="find"><b>${t.found2t}</b><p>${t.found2d}</p></div> |
| <div class="find"><b>${t.found3t}</b><p>${t.found3d}</p></div> |
| </div></div> |
| |
| <div class="sec"><h2>${t.secLeaders}</h2><p class="lead2">${t.secLeadersLead}</p> |
| <div id="leaders"><div class="skel" style="height:200px"></div></div></div> |
| |
| <div class="sec"><h2>${t.secBrowse}</h2><p class="lead2">${t.secBrowseLead}</p> |
| <div class="grid">${CATS.categories.map(cardHTML).join("")}</div></div> |
| |
| <div class="sec"><h2>${t.secEvidence}</h2> |
| <p class="lead2">${t.secEvidenceLead} ${t.insideCount(inside)}</p> |
| ${terrain(items,true)} |
| ${lost?`<div class="note">${t.lostNote(lost)}</div>`:""}</div> |
| |
| <div class="sec"><h2>${t.secHow}</h2> |
| <ol class="steps"> |
| <li><b>${t.how1t}</b><p class="dim" style="font-size:14px">${t.how1d}</p></li> |
| <li><b>${t.how2t}</b><p class="dim" style="font-size:14px">${t.how2d}</p></li> |
| <li><b>${t.how3t}</b><p class="dim" style="font-size:14px">${t.how3d}</p></li> |
| </ol></div> |
| |
| <div class="sec"><h2>${t.secMetric}</h2> |
| <dl class="defs"> |
| <dt>MAE</dt><dd>${t.mMae}</dd> |
| <dt>AUROC</dt><dd>${t.mAuroc}</dd> |
| <dt>PR-AUC</dt><dd>${t.mPrauc}</dd> |
| <dt>${t.noiseH}</dt><dd>${t.mNoise}</dd> |
| <dt>${t.ratioLabel}</dt><dd>${t.mRatio}</dd> |
| <dt>${t.thLeak}</dt><dd>${t.mLeak}</dd> |
| </dl></div> |
| |
| <div class="sec"><h2>${t.secMethod}</h2><p class="lead2">${t.secMethodLead}</p> |
| <ol class="cl">${t.clauses.map(([a,b])=>`<li><b>${a}</b><span>${b}</span></li>`).join("")}</ol></div> |
| |
| <div class="sec"><h2>${t.secOwn}</h2> |
| <div class="prose"><p>${t.own1}</p><p>${t.own2}</p><p>${t.own3}</p></div></div> |
| |
| <div class="sec"><h2>${t.secData}</h2> |
| <div class="note n">${t.dataNote}<br><br> |
| ${t.srcLabel} · <a href="https://www.ebi.ac.uk/chembl/">ChEMBL_37 (EMBL-EBI)</a> · |
| <a href="https://creativecommons.org/licenses/by-sa/3.0/">CC BY-SA 3.0 Unported</a></div></div>`); |
| paintLeaders(); |
| } |
| |
| /* 부문별 1위 표. 비동기로 채운다 - 원장을 부문 수만큼 읽어야 해서 첫 그림을 막으면 안 된다. */ |
| async function paintLeaders(){ |
| const el=$("#leaders"); if(!el)return; |
| let d; try{ d=await j("/api/leaders"); }catch(e){ el.innerHTML=""; return; } |
| const rows=d.rows.map(r=>{ |
| const ld=r.leader; // 전역 L 은 언어다. 가리지 않는다 |
| // 점수는 부문마다 주지표가 다르다. 회귀 부문은 MAE, 분류 전용 부문은 AUROC. |
| const reg=r.baseline_best!=null; |
| const score=ld?(reg?ld.mae:ld.auroc):null; |
| const bas=reg?r.baseline_best:r.baseline_best_auroc; |
| let vs="—"; |
| if(ld&&score!=null&&bas!=null){ |
| const better=reg?score<bas:score>bas; |
| const gap=reg?(bas-score):(score-bas); |
| vs=`<span class="${better?"gain":"loss"}">${better?"▲":"▼"} ${Math.abs(gap).toFixed(3)}</span>`; |
| } |
| return `<tr> |
| <td style="text-align:left"><a href="#/b/${encodeURIComponent(r.board)}">${esc(r.board)}</a> |
| <span class="chip g" style="margin-left:6px">${r.split_grade}/${r.answer_grade}</span> |
| <div class="sub2">${esc(L==="ko"?(r.blurb||""):(r.blurb_en||r.blurb||""))}</div></td> |
| <td style="text-align:left">${ld?esc(ld.method):`<span class="dim">${t.noLeader}</span>`}</td> |
| <td style="text-align:left">${ld?hfUser(ld.user):"—"}</td> |
| <td>${score!=null?f(score):"—"}</td> |
| <td>${vs}</td> |
| <td>${(r.n_test||0).toLocaleString()}</td></tr>`;}).join(""); |
| el.innerHTML=`<div class="tw"><table><thead><tr> |
| <th style="text-align:left;width:auto">${t.thBoard}</th> |
| <th style="text-align:left">${t.thBest}</th><th style="text-align:left">${t.thUser}</th> |
| <th>${t.thScore}</th><th>${t.thVs}</th><th>${t.thN}</th></tr></thead> |
| <tbody>${rows}</tbody></table></div> |
| <p class="dim" style="font-size:13px">${d.held?t.leadersSome(d.held,d.n):t.leadersEmpty(d.n)}</p>`; |
| } |
| |
| function cardHTML(c){ |
| const on=c.open>0, bs=c.board_cards||[]; |
| const tot=bs.reduce((s,b)=>s+b.n_test,0); |
| const inner=`<div class="ico">${c.icon}</div> |
| <div class="nm">${esc(L==="ko"?c.name:c.en)} |
| <span class="chip ${on?"ok":"g"}">${on?t.openN(c.open):t.prep}</span></div> |
| <div class="ds">${esc(L==="ko"?c.desc:(c.desc_en||c.desc))}</div> |
| <div class="mt"><span>${t.cardBoards(c.open,c.planned)}</span> |
| <span>${tot?t.cardCompounds(tot.toLocaleString()):"—"}</span></div>`; |
| return on?`<a class="cc" href="#/c/${c.id}">${inner}</a>`:`<div class="cc off">${inner}</div>`; |
| } |
| |
| /* ═══ 분야 ═══ */ |
| function category(id){ |
| const c=CATS.categories.find(x=>x.id===id); if(!c)return hub(); |
| const bs=(c.board_cards||[]).slice().sort((a,b)=>b.n_test-a.n_test); |
| const items=bs.map(b=>({board:b.board,ratio:ratioOf(b)})) |
| .filter(x=>x.ratio!=null).sort((a,b)=>a.ratio-b.ratio); |
| const nm=L==="ko"?c.name:c.en; |
| app.innerHTML=W(` |
| <div class="crumb"><a href="#/">${t.all}</a> › ${esc(nm)}</div> |
| <h1 class="ttl">${c.icon} ${esc(nm)}</h1> |
| <p class="lede">${esc(L==="ko"?c.desc:(c.desc_en||c.desc))}</p> |
| <div class="stats" style="margin-top:26px"> |
| <div class="stat"><b>${c.open}</b><span>${t.statBoards}</span></div> |
| <div class="stat"><b>${bs.reduce((s,b)=>s+b.n_test,0).toLocaleString()}</b><span>${t.statCompounds}</span></div> |
| <div class="stat"><b>${c.planned}</b><span>${t.statPlanned}</span></div></div> |
| ${bs.length?` |
| <div class="sec"><h2>${t.secBrowse}</h2><div class="grid">${bs.map(boardMini).join("")}</div></div> |
| <div class="sec"><h2>${t.secEvidence}</h2><p class="lead2">${t.secEvidenceLead}</p> |
| ${terrain(items,true)}</div>` |
| :`<div class="note">${t.emptyCat}</div>`}`); |
| } |
| function boardMini(b){ |
| const bl=b.baselines[b.best_baseline]||{}; |
| return `<a class="cc" href="#/b/${encodeURIComponent(b.board)}"> |
| <div class="nm">${esc(b.board)}<span class="chip">${b.split_grade}/${b.answer_grade}</span></div> |
| <div class="bl">${blurb(b)}</div> |
| <div class="ds">${t.cardCompounds(b.n_test.toLocaleString())} · ${t.noiseH} ${f((b.noise_floor||{}).sd_single)}<br> |
| ${esc(b.best_baseline)} ${bl.mae!=null?f(bl.mae):f(bl.auroc)}</div> |
| <div class="mt"><span>${esc(b.source)}</span><span>${ratioOf(b)==null?"—":f(ratioOf(b),2)+"×"}</span></div></a>`; |
| } |
| |
| /* ═══ 부문 ═══ */ |
| async function board(name,tab){ |
| const b=BOARDS[name]||await j("/api/board/"+encodeURIComponent(name)); |
| const cat=CATS.categories.find(c=>(c.boards||[]).includes(name)); |
| const cn=cat?(L==="ko"?cat.name:cat.en):""; |
| const TT=[["rank",t.tabRank],["submit",t.tabSubmit],["guide",t.tabGuide],["info",t.tabInfo]]; |
| app.innerHTML=W(` |
| <div class="crumb"><a href="#/">${t.all}</a> › <a href="#/c/${cat?cat.id:""}">${esc(cn)}</a> › ${esc(b.board)}</div> |
| <h1 class="ttl">${esc(b.board)}<span class="chip">${b.split_grade}/${b.answer_grade}</span></h1> |
| <p class="blurbig">${blurb(b)}</p> |
| <p class="lede">${esc(b.source)} · ${esc(b.type)} · |
| ${t.cardCompounds(b.n_test.toLocaleString())} · ${t.noiseH} <b>${f(b.noise_floor.sd_single)}</b></p> |
| <div class="pills">${TT.map(([k,l])=> |
| `<a class="pilltab ${k===tab?"on":""}" href="#/b/${encodeURIComponent(name)}/${k}">${l}</a>`).join("")}</div> |
| <div id="pane"><div class="skel" style="margin-top:20px"></div></div>`); |
| if(tab==="rank")return paneRank(b); |
| if(tab==="submit")return paneSubmit(b); |
| if(tab==="guide")return paneGuide(b); |
| paneInfo(b); |
| } |
| |
| async function paneRank(b){ |
| const d=await j(`/api/board/${encodeURIComponent(b.board)}/leaderboard`); |
| const nf=d.noise_floor, ent=d.rows.filter(r=>r.kind!=="baseline"); |
| const worst=Math.max(...d.rows.map(r=>r.mae||0))||1; |
| const leak=l=>l==="high"?`<span class="chip bd">${t.leakHigh}</span>` |
| :l==="elevated"?`<span class="chip wr">${t.leakElev}</span>`:`<span class="chip g">—</span>`; |
| const rows=d.rows.map(r=>{ |
| const g=r.rank&&r.rank<=3?` g${r.rank}`:""; |
| const w=Math.max(4,58*(r.mae||0)/worst); |
| return `<tr class="${r.kind!=="entry"?"base":""}"> |
| <td>${r.rank?`<span class="rk${g}">${r.rank}</span>`:"·"}</td> |
| <td>${esc(r.method)}</td><td>${r.kind==="entry"?hfUser(r.user):esc(r.user)}</td> |
| <td><span class="mini" style="width:${w}px"></span>${f(r.mae)}</td> |
| <td>${f(r.auroc)}</td><td>${f(r.prauc)}</td> |
| <td>${r.kind==="baseline"?`<span class="chip g">${t.baseline}</span>`:leak(r.leak)}</td> |
| <td>${r.kind==="baseline"?"—":r.kind==="reference"?`<span class="chip wr">${t.reference}</span>` |
| :(r.verified?`<span class="chip ok">${t.verified}</span>` |
| :`<span class="chip g">${t.submitted}</span>`)}</td></tr>`;}).join(""); |
| $("#pane").innerHTML=` |
| <div class="tw"><table><thead><tr><th>${t.thRank}</th><th>${t.thMethod}</th><th>${t.thUser}</th> |
| <th>MAE</th><th>AUROC</th><th>PR-AUC</th><th>${t.thLeak}</th><th>${t.thGrade}</th></tr></thead> |
| <tbody>${rows}</tbody></table></div> |
| <div class="note">${t.noiseNote(f(nf))}</div> |
| <p class="dim" style="font-size:13px">${ent.length |
| ? t.updated(new Date(d.updated*1000).toLocaleString(L==="ko"?"ko-KR":"en-GB")) |
| : t.noEntries}</p>`; |
| } |
| |
| /* 부문 값이 채워진 참여 가이드. 일반문으로 두면 참가자가 자기 부문에 맞게 고쳐야 하고, |
| 대부분 그 단계에서 떨어진다. */ |
| function promptFor(b){ |
| const nf=(b.noise_floor||{}).sd_single; |
| const bl=Object.entries(b.baselines||{}) |
| .map(([k,v])=>` ${k}: ${v.mae!=null?("MAE "+f(v.mae)):("AUROC "+f(v.auroc))}`).join("\n"); |
| const unit = b.type && b.type.indexOf("logS")>=0 |
| ? "logS = log10(용해도 in mol/L)" |
| : b.threshold==null ? "부문 정의값" : "p 스케일 = -log10(농도 in M)"; |
| return [ |
| `나는 신약 예측 도구 시험대 LEADBOARD 의 "${b.board}" 부문에 참가한다.`, |
| `아래 조건대로 예측 파일을 만드는 Python 스크립트를 작성해 달라.`, |
| ``, |
| `[과제]`, |
| ` 입력 ${b.board.toLowerCase()}_test.json — compound_id 와 SMILES 만 들어 있다 (${b.n_test}개)`, |
| ` 출력 predictions.csv — 두 열: compound_id,prediction`, |
| ` 예측값 ${unit}`, |
| b.target && b.target!=="—" ? ` 대상 ${b.target} · ${b.type}` : ` 대상 ${b.type}`, |
| ``, |
| `[학습 자료]`, |
| b.cut ? ` ChEMBL 에서 ${b.target} 의 ${b.type} 활성을 받아 쓴다.` : ` 공개 자료를 직접 큐레이션해 쓴다.`, |
| b.cut ? ` ★ ${b.cut}년 이후 처음 보고된 화합물은 학습에 넣지 말 것 — 그것이 테스트셋이다.` : ``, |
| ` 같은 화합물이 여러 논문에 있으면 InChIKey 로 접어 중앙값을 쓸 것.`, |
| ``, |
| `[제약]`, |
| ` 방법에 제한은 없다. 지문+트리, 그래프 신경망, 사전학습 모델, 물리 계산 모두 가능.`, |
| ` 정답을 조회해 채우지 말 것. 예측값이 실측과 소수 자리까지 일치하는 밀도를 검사한다.`, |
| ``, |
| `[평가]`, |
| ` 주지표 ${b.metric==="auroc"?"AUROC (높을수록 좋음)":"MAE (낮을수록 좋음)"}`, |
| nf ? ` 이 부문의 실험 재현 오차는 ${f(nf)} 이다. 그보다 작은 차이는 순위로 인정되지 않는다.` : ``, |
| ` 넘어야 할 기준선:`, |
| bl, |
| ``, |
| `스크립트는 자료 수집 → 정제 → 특징 → 학습 → 예측 → CSV 저장까지 한 파일로 작성해 달라.` |
| ].filter(x=>x!==``||true).join("\n"); |
| } |
| |
| function starterFor(b){ |
| return [ |
| `# ${b.board} 제출 뼈대`, |
| `import json, math, requests, numpy as np`, |
| `from rdkit import Chem`, |
| `from rdkit.Chem import rdFingerprintGenerator`, |
| `import lightgbm as lgb`, |
| ``, |
| `# 1) 훈련 자료 (ChEMBL 에서 받는다)`, |
| `API = "https://www.ebi.ac.uk/chembl/api/data/activity.json"`, |
| `rows, off = [], 0`, |
| `while True:`, |
| ` q = {"target_chembl_id": "${b.target}", "standard_type": "${b.type}",`, |
| ` "standard_relation": "=", "limit": 1000, "offset": off}`, |
| ` d = requests.get(API, params=q, timeout=120).json()`, |
| ` rows += d["activities"]; off += 1000`, |
| ` if off >= d["page_meta"]["total_count"]: break`, |
| ``, |
| `# 2) 정제 — InChIKey 로 접고, 기준일 이후는 버린다`, |
| `rec = {}`, |
| `for r in rows:`, |
| ` s, v, u, y = r["canonical_smiles"], r["standard_value"], r["standard_units"], r["document_year"]`, |
| ` if not (s and v and u == "nM" and y): continue`, |
| ` m = Chem.MolFromSmiles(s)`, |
| ` if m is None or float(v) <= 0: continue`, |
| ` k = Chem.MolToInchiKey(m)`, |
| ` rec.setdefault(k, {"y": [], "p": [], "m": m})`, |
| ` rec[k]["y"].append(int(y)); rec[k]["p"].append(9.0 - math.log10(float(v)))`, |
| `train = {k: v for k, v in rec.items() if min(v["y"]) < ${b.cut||2022}} # ★ 테스트셋 제외`, |
| ``, |
| `# 3) 특징 · 학습`, |
| `gen = rdFingerprintGenerator.GetMorganGenerator(radius=2, fpSize=2048)`, |
| `X = np.array([gen.GetFingerprintAsNumPy(v["m"]) for v in train.values()])`, |
| `Y = np.array([float(np.median(v["p"])) for v in train.values()])`, |
| `model = lgb.LGBMRegressor(n_estimators=500, learning_rate=0.05).fit(X, Y)`, |
| ``, |
| `# 4) 예측 · 저장`, |
| `test = json.load(open("${b.board.toLowerCase()}_test.json", encoding="utf-8"))`, |
| `Xt = np.array([gen.GetFingerprintAsNumPy(Chem.MolFromSmiles(c["smiles"]))`, |
| ` for c in test["compounds"]])`, |
| `p = model.predict(Xt)`, |
| `with open("predictions.csv", "w") as f:`, |
| ` f.write("compound_id,prediction\n")`, |
| ` for c, v in zip(test["compounds"], p):`, |
| ` f.write("%s,%.4f\n" % (c["compound_id"], v))` |
| ].join("\n"); |
| } |
| |
| function paneGuide(b){ |
| const pr=promptFor(b), sk=starterFor(b); |
| const bl=Object.entries(b.baselines||{}).sort((x,y)=> |
| (x[1].mae!=null?x[1].mae:-x[1].auroc)-(y[1].mae!=null?y[1].mae:-y[1].auroc)); |
| $("#pane").innerHTML=` |
| <p class="lede" style="margin:6px 0 18px">${t.gdIntro}</p> |
| <h3>${t.gdWhat}</h3><p class="dim" style="font-size:14.5px">${t.gdWhatD}</p> |
| <h3>${t.gdPrompt}</h3><p class="dim" style="font-size:14px">${t.gdPromptD}</p> |
| <div style="position:relative"> |
| <button class="btn" id="cp1" style="position:absolute;right:12px;top:12px;padding:6px 13px;font-size:12px">${t.gdCopy}</button> |
| <pre class="fig" style="white-space:pre-wrap;margin:0">${esc(pr)}</pre></div> |
| <h3>${t.gdStarter}</h3><p class="dim" style="font-size:14px">${t.gdStarterD}</p> |
| <div style="position:relative"> |
| <button class="btn" id="cp2" style="position:absolute;right:12px;top:12px;padding:6px 13px;font-size:12px">${t.gdCopy}</button> |
| <pre class="fig" style="white-space:pre;overflow-x:auto;margin:0;font-size:12px">${esc(sk)}</pre></div> |
| <h3>${t.gdTarget}</h3><p class="dim" style="font-size:14px">${t.gdTargetD}</p> |
| <div class="tw"><table><thead><tr><th style="text-align:left;width:auto">${t.baseH}</th> |
| <th>MAE</th><th>AUROC</th></tr></thead><tbody> |
| ${bl.map(([k,v])=>`<tr><td style="text-align:left">${esc(k)}</td> |
| <td>${v.mae!=null?f(v.mae):"—"}</td><td>${v.auroc!=null?f(v.auroc):"—"}</td></tr>`).join("")} |
| </tbody></table></div> |
| <h3>${t.gdRules}</h3> |
| <ol class="cl" style="grid-template-columns:1fr"> |
| ${[t.gdR1,t.gdR2,t.gdR3,t.gdR4].map(x=>`<li><span>${x}</span></li>`).join("")} |
| </ol>`; |
| const wireCopy=(id,txt)=>{const el=$("#"+id); if(!el)return; |
| el.onclick=async()=>{try{await navigator.clipboard.writeText(txt); |
| el.textContent=t.gdCopied; setTimeout(()=>el.textContent=t.gdCopy,1600);}catch(e){}};}; |
| wireCopy("cp1",pr); wireCopy("cp2",sk); |
| } |
| |
| function paneInfo(b){ |
| const nf=b.noise_floor, bl=b.baselines; |
| $("#pane").innerHTML=` |
| <h3>${t.gradeH}</h3> |
| <dl class="kv"> |
| <dt>${t.splitGrade} <b>${b.split_grade}</b></dt> |
| <dd>${esc(t.gradeNote[b.split_grade]||b.split_grade_note||"")}</dd> |
| <dt>${t.answerGrade} <b>${b.answer_grade}</b></dt> |
| <dd>${esc(t.gradeNote[b.answer_grade]||b.answer_grade_note||"")}</dd></dl> |
| <h3>${t.holdoutH}</h3> |
| <dl class="kv"> |
| <dt>${t.kSource}</dt><dd>${esc(b.source)} · ${esc(b.target)} · ${esc(b.type)}</dd> |
| <dt>${t.kCut}</dt><dd>${b.cut} — ${t.cutNote}</dd> |
| <dt>${t.kTrainTest}</dt><dd>${b.n_train.toLocaleString()} / ${b.n_test.toLocaleString()}</dd> |
| <dt>${t.kOverlap}</dt><dd>${t.overlapNote}</dd> |
| <dt>${t.kThr}</dt><dd>${b.threshold} · ${t.posRate((b.positive_rate*100).toFixed(1))}</dd> |
| <dt>${t.kSha}</dt><dd>${esc(b.dataset_sha)}</dd> |
| <dt>${t.kLicense}</dt><dd>ChEMBL · CC BY-SA 3.0</dd></dl> |
| <h3>${t.noiseH} <span class="dim" style="font-weight:400;font-size:14px">(4)</span></h3> |
| <div class="fig">${t.noiseDesc}<br> |
| ${t.noisePairs(nf.pairs.toLocaleString(),nf.compounds.toLocaleString())}<br> |
| median ${f(nf.median)} · mean ${f(nf.mean)} · p90 ${f(nf.p90)}<br> |
| <b>${t.noiseSd} <span class="n">${f(nf.sd_single)}</span></b></div> |
| <div class="note">${t.nearNote(b.near_pct,b.near_pct>45)}</div> |
| <h3>${t.baseH} <span class="dim" style="font-weight:400;font-size:14px">(3)</span></h3> |
| <div class="tw"><table><thead><tr><th style="width:auto">·</th><th>${t.baseH}</th><th>MAE</th> |
| <th>AUROC</th><th>PR-AUC</th></tr></thead><tbody> |
| ${Object.entries(bl).map(([k,v])=>`<tr><td>·</td><td>${esc(k)}</td><td>${f(v.mae)}</td> |
| <td>${f(v.auroc)}</td><td>${f(v.prauc)}</td></tr>`).join("")} |
| <tr class="base"><td>·</td><td>${t.noiseFloorRow}</td><td>${f(nf.sd_single)}</td><td>—</td><td>—</td></tr> |
| </tbody></table></div> |
| <div class="note ${b.beats_constant?"ok":""}">${b.beats_constant?t.beatsYes:t.beatsNo}</div>`; |
| } |
| |
| function paneSubmit(b){ |
| const url=`/api/board/${encodeURIComponent(b.board)}/testset`; |
| $("#pane").innerHTML=` |
| <ol class="steps"> |
| <li><b>${t.step1}</b> |
| <p class="dim" style="font-size:13.5px">${t.step1d(b.n_test.toLocaleString())}<br>${t.licLine}</p> |
| <a class="btn" href="${url}" download>${t.dl}</a> |
| <span class="hint"> ${esc(b.board.toLowerCase())}_test.json</span></li> |
| <li><b>${t.step2}</b> |
| ${ME.user?` |
| <label>${t.fMethod} <span class="hint">${t.fMethodH}</span></label> |
| <input type="text" id="m" maxlength="60"> |
| <label>${t.fTrain} <span class="hint">${t.fTrainH}</span></label> |
| <input type="text" id="td"> |
| <label>${t.fPred} <span class="hint">${t.fPredH}</span></label> |
| <div class="drop" id="drop">${t.drop}<input type="file" id="file" accept=".json,.csv,.txt" hidden></div> |
| <textarea id="p" rows="6" style="margin-top:11px"></textarea> |
| <p style="margin-top:15px"><button class="btn" id="go">${t.send}</button> |
| <span class="hint" id="msg"></span></p>` |
| :`<div class="note">${t.needLogin} |
| <a href="/login?next=${encodeURIComponent(location.hash)}">${t.login}</a></div>`}</li> |
| <li><b>${t.step3}</b><p class="dim" style="font-size:13.5px">${t.step3d}</p></li> |
| </ol> |
| <div class="note">${t.ladderNote(f(b.noise_floor.sd_single),ME.daily_cap||5)}</div>`; |
| wire(b); |
| } |
| function parsePred(x){ |
| x=x.trim(); if(x.startsWith("{"))return JSON.parse(x); |
| const o={}; |
| x.split(/\r?\n/).forEach((ln,i)=>{const p=ln.split(/[,\t]/);if(p.length<2)return; |
| const k=p[0].trim();if(!k||(i===0&&isNaN(parseFloat(p[1]))))return; |
| const v=parseFloat(p[1]);if(!isNaN(v))o[k]=v;}); |
| if(!Object.keys(o).length)throw new Error("no readable entries"); |
| return o; |
| } |
| function wire(b){ |
| const drop=$("#drop"),file=$("#file"),ta=$("#p"),go=$("#go"); |
| const load=f0=>{const r=new FileReader();r.onload=()=>{ta.value=r.result; |
| drop.textContent=`${f0.name} — ${(f0.size/1024).toFixed(0)} KB`;};r.readAsText(f0);}; |
| if(drop){ |
| drop.onclick=()=>file.click(); |
| file.onchange=e=>e.target.files[0]&&load(e.target.files[0]); |
| ["dragenter","dragover"].forEach(v=>drop.addEventListener(v,e=>{e.preventDefault();drop.classList.add("hot");})); |
| ["dragleave","drop"].forEach(v=>drop.addEventListener(v,e=>{e.preventDefault();drop.classList.remove("hot");})); |
| drop.addEventListener("drop",e=>e.dataTransfer.files[0]&&load(e.dataTransfer.files[0])); |
| } |
| if(!go)return; |
| go.onclick=async()=>{ |
| const msg=$("#msg");let preds; |
| try{preds=parsePred(ta.value);}catch(e){msg.textContent=t.parseFail(e.message);return;} |
| msg.textContent=t.sending(Object.keys(preds).length.toLocaleString());go.disabled=true; |
| try{const r=await j("/api/submit",{method:"POST",headers:{"Content-Type":"application/json"}, |
| body:JSON.stringify({board:b.board,method:($("#m").value||"").trim(), |
| predictions:preds,training_data:($("#td").value||"").trim()})}); |
| msg.textContent=t.accepted(r.submission_id); |
| }catch(e){let x=String(e.message||e);try{x=JSON.parse(x).detail||x;}catch(_){}msg.textContent=x;} |
| go.disabled=false; |
| }; |
| } |
| boot().catch(e=>{app.innerHTML=W(`<div class="note" style="margin-top:50px"> |
| ${(window.T&&window.T.en.loadFail)||"Load failed."} ${esc(e.message||e)}</div>`);}); |
| </script> |
| </body> |
| </html> |
|
|