leadboard / index.html
SeaWolf-AI's picture
Stop the leader row from shadowing the language variable
e199bee verified
Raw
History Blame Contribute Delete
42.4 kB
<!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.">
<!-- <head> 는 선택이 아니다: 플랫폼이 자기 태그를 문서에 주입하는데, 받을 head 가 없으면
그것들이 doctype 앞에 놓이고 페이지 전체가 쿼크 모드로 떨어진다. -->
<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=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[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">&nbsp;${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>