plot-arena / index.html
ritianzhaoxx's picture
wire collector URL
68dcc48 verified
Raw
History Blame Contribute Delete
18.8 kB
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FigMirror 风格盲评</title>
<style>
:root{
--bg:#eef1f6; --panel:#fff; --ink:#182031; --dim:#6b7688; --line:#e3e7ef; --letterbox:#eef2f7;
--a:#2563eb; --a-soft:#e8f0fe; --b:#7c3aed; --b-soft:#f1eafe; --good:#0fa356; --warn:#e0902f;
--shadow:0 1px 2px rgba(20,30,50,.06),0 6px 20px rgba(20,30,50,.07); --hdr:48px;
}
*{box-sizing:border-box}
html,body{height:100%;margin:0;overflow:hidden}
body{background:var(--bg);color:var(--ink);
font:14px/1.5 system-ui,-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
button{font:inherit;color:inherit;cursor:pointer}
.app{height:100vh;height:100dvh;display:grid;
grid-template-rows:var(--hdr) clamp(92px,20vh,210px) minmax(0,1fr) auto;overflow:hidden}
/* header */
.topbar{display:flex;align-items:center;gap:14px;padding:0 16px;background:var(--panel);border-bottom:1px solid var(--line)}
.brand{font-weight:750;font-size:15px;white-space:nowrap}
.brand span{color:var(--a)}
.rater{font-size:12px;font-weight:700;color:#fff;background:var(--a);border-radius:20px;padding:2px 10px;white-space:nowrap}
.prog{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:4px;max-width:560px;margin:0 auto}
.prog .t{font-size:12px;color:var(--dim);text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.prog .t b{color:var(--ink)}
.prog .bar{height:6px;border-radius:20px;background:#e2e7f0;overflow:hidden}
.prog .bar>i{display:block;height:100%;width:0;background:linear-gradient(90deg,var(--a),#7c85f5);transition:width .25s}
.sync{font-size:11.5px;white-space:nowrap;color:var(--dim)}
.sync .ok{color:var(--good);font-weight:700}
.sync .wait{color:var(--warn);font-weight:700}
.hbtn{border:1px solid var(--line);background:#fff;border-radius:8px;padding:6px 10px;font-size:12px}
.hbtn:hover{background:#f7fafe;border-color:#c7cfdc}
/* reference */
.refband{position:relative;background:var(--panel);border-bottom:1px solid var(--line)}
.refband img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;padding:8px 8px 6px;cursor:zoom-in}
.reftag{position:absolute;top:7px;left:14px;z-index:2;font-size:11px;font-weight:700;letter-spacing:.1em;
color:var(--a);background:var(--a-soft);border-radius:20px;padding:2px 9px;pointer-events:none}
.refhint{position:absolute;top:7px;right:14px;z-index:2;font-size:11px;color:var(--dim);pointer-events:none}
/* candidates */
.stage{min-height:0;min-width:0;display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);
grid-template-rows:minmax(0,1fr);gap:12px;padding:10px 12px}
.cand{position:relative;min-width:0;min-height:0;overflow:hidden;background:var(--letterbox);
border:2px solid var(--line);border-radius:13px;cursor:zoom-in}
.cand img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;padding:8px;display:block}
.cand .badge{position:absolute;top:9px;left:9px;z-index:2;width:28px;height:28px;border-radius:8px;
display:grid;place-items:center;font-weight:800;font-size:14px;color:#fff;box-shadow:0 2px 6px rgba(20,30,50,.2)}
#cardA .badge{background:var(--a)} #cardB .badge{background:var(--b)}
.cand .chosen{position:absolute;top:9px;right:9px;z-index:2;display:none;font-size:11px;font-weight:700;color:#fff;
background:var(--good);border-radius:20px;padding:3px 9px}
#cardA.win{border-color:var(--a);box-shadow:0 0 0 3px var(--a-soft)}
#cardB.win{border-color:var(--b);box-shadow:0 0 0 3px var(--b-soft)}
.cand.win .chosen{display:block}
/* vote bar */
.votebar{display:flex;align-items:center;justify-content:center;gap:10px;padding:9px 12px;
background:var(--panel);border-top:1px solid var(--line)}
.nav{flex:0 0 auto;border:1px solid var(--line);background:#fff;border-radius:10px;padding:10px 14px;
font-size:13px;font-weight:600;color:var(--dim)}
.nav:hover:not(:disabled){background:#f7fafe;border-color:#c7cfdc;color:var(--ink)}
.nav:disabled{opacity:.4;cursor:default}
.choice{border:2px solid;border-radius:12px;padding:11px 14px;font-size:14.5px;font-weight:800;
display:inline-flex;align-items:center;gap:8px;white-space:nowrap}
.choice kbd{font:700 10.5px/1 ui-monospace,Menlo,monospace;padding:2px 5px;border-radius:5px;background:rgba(20,30,50,.07);color:var(--dim)}
.cA{color:var(--a);border-color:var(--a);background:#fff;flex:0 1 240px} .cA:hover{background:var(--a-soft)}
.cA.on{background:var(--a);color:#fff} .cA.on kbd{background:rgba(255,255,255,.25);color:#fff}
.cB{color:var(--b);border-color:var(--b);background:#fff;flex:0 1 240px} .cB:hover{background:var(--b-soft)}
.cB.on{background:var(--b);color:#fff} .cB.on kbd{background:rgba(255,255,255,.25);color:#fff}
.cMid{color:var(--dim);border-color:var(--line);background:#fff;font-weight:700;font-size:13px}
.cMid:hover{background:#f4f6fa;border-color:#c7cfdc} .cMid.on{background:#3a4557;color:#fff;border-color:#3a4557}
.cMid.on kbd{background:rgba(255,255,255,.22);color:#fff}
/* overlays */
.overlay{position:fixed;inset:0;z-index:60;background:rgba(238,241,246,.97);display:grid;place-items:center;padding:22px}
.panel{background:#fff;border:1px solid var(--line);border-radius:18px;box-shadow:var(--shadow);max-width:520px;padding:30px 34px;text-align:center}
.panel h1{font-size:22px;margin:0 0 6px} .panel h1 span{color:var(--a)}
.panel p{color:#3f475a;margin:8px 0;font-size:14.5px}
.go{margin-top:16px;border:0;border-radius:12px;background:var(--a);color:#fff;font-weight:750;font-size:15px;padding:12px 30px}
.go:hover{background:#1e57d6}
.rules{text-align:left;background:#f6f9fc;border:1px solid var(--line);border-radius:11px;padding:12px 16px;margin:14px 0 0}
.rules li{margin:4px 0;color:#3f475a;font-size:13.5px}
.raters{display:flex;gap:10px;justify-content:center;margin-top:16px;flex-wrap:wrap}
.raters a{border:1px solid var(--line);border-radius:10px;padding:10px 16px;font-weight:700;color:var(--a);text-decoration:none}
.raters a:hover{background:var(--a-soft)}
.hidden{display:none!important}
/* lightbox */
.lb{position:fixed;inset:0;z-index:80;background:#0b0f16;display:none;flex-direction:column}
.lb.on{display:flex}
.lbtop{display:flex;align-items:center;gap:10px;padding:10px 14px;color:#dfe6f2}
.lbtop .t{font-size:12.5px;color:#aeb8ca} .lbtop .sp{flex:1}
.lbbtn{border:1px solid #3a4459;background:#1b2334;color:#dfe6f2;border-radius:8px;height:34px;padding:0 12px;font-size:13px}
.lbbtn:hover{background:#242e44}
.lbstage{flex:1;min-height:0;overflow:hidden;position:relative;cursor:grab}
.lbstage.drag{cursor:grabbing}
.lbstage img{position:absolute;left:50%;top:50%;max-width:94vw;max-height:82vh;object-fit:contain;
transform:translate(-50%,-50%);transform-origin:center;user-select:none;-webkit-user-drag:none}
@media(max-width:820px){.stage{grid-template-columns:1fr;grid-template-rows:1fr 1fr}.votebar{flex-wrap:wrap}}
</style>
</head>
<body>
<!-- landing (no valid rater) -->
<div class="overlay" id="landing">
<div class="panel">
<h1><span>FigMirror</span> 风格盲评</h1>
<p>请打开分配给你的链接(带 <code>?rater=编号</code>)。或直接选你的编号:</p>
<div class="raters" id="raterLinks"></div>
<p style="margin-top:14px;font-size:12.5px;color:#8a93a3">编号是我发给每个人的,别人和你的题目不完全一样。</p>
</div>
</div>
<!-- start -->
<div class="overlay hidden" id="start">
<div class="panel">
<h1><span>FigMirror</span> 风格盲评 · <span id="startRater"></span></h1>
<p>顶上是<b>参考图</b>,下面 A / B 两张。选<b>哪张更好地复刻了参考图的风格</b>——配色、字体、间距、布局。</p>
<ul class="rules">
<li>只看<b>风格像不像参考图</b>,别管数字对不对,也别管单看谁更好看。</li>
<li>实在难分就点<b>「差不多」</b>;这一对看不了就<b>「跳过」</b></li>
<li><b>点任意图片放大</b>看细节。快捷键:<b>1</b>=A,<b>2</b>=B,<b>3</b>=差不多,<b>4</b>=跳过,<b>←/→</b>翻页。</li>
<li>进度自动保存,可随时关掉、下次接着标。</li>
</ul>
<button class="go" id="btnStart">开始标注</button>
</div>
</div>
<!-- done -->
<div class="overlay hidden" id="done">
<div class="panel">
<h1>你的题标完了 🎉</h1>
<p id="doneMsg"></p>
<button class="go" id="btnReview">回去检查</button>
<div style="margin-top:10px"><button class="hbtn" id="btnExport2">导出我的结果(备份)</button></div>
</div>
</div>
<div class="app">
<div class="topbar">
<div class="brand"><span>FigMirror</span> 盲评</div>
<div class="rater" id="raterTag"></div>
<div class="prog">
<div class="t"><b id="pIdx">1</b> / <b id="pTot">0</b> · 已评 <b id="pDone">0</b></div>
<div class="bar"><i id="barFill"></i></div>
</div>
<div class="sync" id="syncUI"></div>
<button class="hbtn" id="btnExport">导出</button>
</div>
<div class="refband">
<div class="reftag">参考图 REFERENCE</div>
<div class="refhint">点图放大 🔍</div>
<img id="refImg" alt="reference">
</div>
<div class="stage">
<div class="cand" id="cardA"><div class="badge">A</div><div class="chosen">✓ 已选</div><img id="imgA" alt="A"></div>
<div class="cand" id="cardB"><div class="badge">B</div><div class="chosen">✓ 已选</div><img id="imgB" alt="B"></div>
</div>
<div class="votebar">
<button class="nav" id="btnPrev">← 上一张</button>
<button class="choice cA" id="btnA">A 更好 <kbd>1</kbd></button>
<button class="choice cMid" id="btnTie">差不多 <kbd>3</kbd></button>
<button class="choice cMid" id="btnSkip">跳过 <kbd>4</kbd></button>
<button class="choice cB" id="btnB">B 更好 <kbd>2</kbd></button>
<button class="nav" id="btnNext">下一张 →</button>
</div>
</div>
<!-- lightbox -->
<div class="lb" id="lb">
<div class="lbtop"><span class="t" id="lbTitle"></span><span class="sp"></span>
<button class="lbbtn" id="lbOut"></button><button class="lbbtn" id="lbFit">适应</button>
<button class="lbbtn" id="lbIn"></button><button class="lbbtn" id="lbClose">✕ 关闭</button>
</div>
<div class="lbstage" id="lbStage"><img id="lbImg" alt="zoom"></div>
</div>
<script src="manifest.js"></script>
<script>
const $ = id => document.getElementById(id);
const A = window.ARENA || {pairs:[],assignment:{}};
const byId = {}; A.pairs.forEach(p => byId[p.id] = p);
// Central collector endpoint (Google Apps Script Web App URL). Empty => local-only
// (votes still saved in the browser + exportable); set to enable auto-collection.
const COLLECT_URL = "https://script.google.com/macros/s/AKfycbzDYgGaW43lpzWaFMlBXfWvnz7KmL_XRUFKdFPtA020u00QCb9dESHVnnHodrlh07mNYg/exec";
// ---- rater resolution ----
const params = new URLSearchParams(location.search);
let rater = params.get('rater');
const N_RATERS = A.n_raters || 5;
const validRater = (rater && /^[1-9][0-9]*$/.test(rater) && +rater>=1 && +rater<=N_RATERS) ? +rater : null;
const zoom = {
scale:1,tx:0,ty:0,drag:false,sx:0,sy:0,
open(src,title){ $('lbImg').src=src; $('lbTitle').textContent=title+' · 滚轮缩放 · 拖动平移'; this.fit(); $('lb').classList.add('on'); },
close(){ $('lb').classList.remove('on'); },
isOpen(){ return $('lb').classList.contains('on'); },
apply(){ $('lbImg').style.transform=`translate(calc(-50% + ${this.tx}px),calc(-50% + ${this.ty}px)) scale(${this.scale})`; },
fit(){ this.scale=1;this.tx=0;this.ty=0;this.apply(); },
by(f){ this.scale=Math.min(9,Math.max(1,this.scale*f)); if(this.scale===1){this.tx=0;this.ty=0;} this.apply(); },
init(){
const s=$('lbStage');
s.addEventListener('wheel',e=>{e.preventDefault();const r=s.getBoundingClientRect();
const cx=e.clientX-(r.left+r.width/2),cy=e.clientY-(r.top+r.height/2);
const f=e.deltaY<0?1.18:1/1.18,ns=Math.min(9,Math.max(1,this.scale*f)),k=ns/this.scale;
this.tx=cx-k*(cx-this.tx);this.ty=cy-k*(cy-this.ty);this.scale=ns;
if(ns===1){this.tx=0;this.ty=0;}this.apply();},{passive:false});
s.addEventListener('mousedown',e=>{this.drag=true;this.sx=e.clientX-this.tx;this.sy=e.clientY-this.ty;s.classList.add('drag');});
window.addEventListener('mousemove',e=>{if(this.drag){this.tx=e.clientX-this.sx;this.ty=e.clientY-this.sy;this.apply();}});
window.addEventListener('mouseup',()=>{this.drag=false;s.classList.remove('drag');});
s.addEventListener('click',e=>{if(e.target===s)this.close();});
$('lbIn').onclick=()=>this.by(1.3); $('lbOut').onclick=()=>this.by(1/1.3);
$('lbFit').onclick=()=>this.fit(); $('lbClose').onclick=()=>this.close();
}
};
const app = {
rater:validRater, pairs:[], idx:0, votes:{}, KEY:'',
cur(){ return this.pairs[this.idx]; },
boot(){
zoom.init();
if(!this.rater){
const box=$('raterLinks');
for(let i=1;i<=N_RATERS;i++){ const a=document.createElement('a'); a.href='?rater='+i; a.textContent='编号 '+i; box.appendChild(a); }
return; // stay on landing
}
$('landing').classList.add('hidden');
this.KEY='arena_votes_r'+this.rater;
try{ this.votes=JSON.parse(localStorage.getItem(this.KEY))||{}; }catch(e){ this.votes={}; }
const ids=(A.assignment&&A.assignment[this.rater])||A.pairs.map(p=>p.id);
this.pairs=ids.map(id=>byId[id]).filter(Boolean);
$('pTot').textContent=this.pairs.length;
$('raterTag').textContent='编号 '+this.rater;
$('startRater').textContent='编号 '+this.rater;
// resume at first unjudged
const fu=this.pairs.findIndex(p=>!this.votes[p.id]);
this.idx=fu===-1?0:fu;
$('start').classList.remove('hidden');
$('btnStart').onclick=()=>{ $('start').classList.add('hidden'); this.render(); };
$('btnReview').onclick=()=>{ $('done').classList.add('hidden'); this.idx=0; this.render(); };
$('btnA').onclick=()=>this.vote('A'); $('btnB').onclick=()=>this.vote('B');
$('btnTie').onclick=()=>this.vote('tie'); $('btnSkip').onclick=()=>this.vote('skip');
$('btnPrev').onclick=()=>this.go(-1); $('btnNext').onclick=()=>this.go(1);
$('btnExport').onclick=()=>this.export(); $('btnExport2').onclick=()=>this.export();
$('cardA').onclick=()=>zoom.open(this.cur().A.img,'A'); $('cardB').onclick=()=>zoom.open(this.cur().B.img,'B');
$('refImg').onclick=()=>zoom.open(this.cur().ref,'参考图');
document.addEventListener('keydown',e=>{
if(zoom.isOpen()){ if(e.key==='Escape')zoom.close(); else if(e.key==='+'||e.key==='=')zoom.by(1.3);
else if(e.key==='-')zoom.by(1/1.3); else if(e.key==='0')zoom.fit(); return; }
if(!$('start').classList.contains('hidden')||!$('done').classList.contains('hidden')) return;
if(e.key==='1')this.vote('A'); else if(e.key==='2')this.vote('B');
else if(e.key==='3')this.vote('tie'); else if(e.key==='4')this.vote('skip');
else if(e.key==='ArrowLeft')this.go(-1); else if(e.key==='ArrowRight')this.go(1);
});
this.render(); this.syncLoop();
setInterval(()=>this.syncLoop(), 5000);
},
render(){
const p=this.cur(); if(!p)return;
$('refImg').src=p.ref; $('imgA').src=p.A.img; $('imgB').src=p.B.img;
$('pIdx').textContent=this.idx+1;
const done=Object.keys(this.votes).length;
$('pDone').textContent=done;
$('barFill').style.width=(done/this.pairs.length*100)+'%';
$('btnPrev').disabled=this.idx===0; $('btnNext').disabled=this.idx===this.pairs.length-1;
const c=this.votes[p.id]?this.votes[p.id].choice:null;
$('cardA').classList.toggle('win',c==='A');
$('cardB').classList.toggle('win',c==='B');
$('btnA').classList.toggle('on',c==='A');
$('btnB').classList.toggle('on',c==='B');
$('btnTie').classList.toggle('on',c==='tie');
$('btnSkip').classList.toggle('on',c==='skip');
this.syncUI();
},
uuid(){ return 'v'+Date.now().toString(36)+Math.floor(performance.now()*1000).toString(36); },
vote(choice){
const p=this.cur(); const fresh=!this.votes[p.id];
const rec={ rater:this.rater, pair_id:p.id, figure_id:p.figure_id,
A_method:p.A.method, B_method:p.B.method, choice,
winner_method: choice==='A'?p.A.method : choice==='B'?p.B.method : null,
uuid:(this.votes[p.id]&&this.votes[p.id].uuid)||this.uuid(),
ts:new Date().toISOString(), synced:false };
this.votes[p.id]=rec; this.save(); this.render(); this.post(rec);
if(fresh) setTimeout(()=>this.advance(),260);
},
advance(){
const nxt=this.pairs.findIndex((p,i)=>i>this.idx && !this.votes[p.id]);
if(nxt!==-1){ this.idx=nxt; this.render(); }
else if(this.idx<this.pairs.length-1){ this.idx++; this.render(); }
else if(Object.keys(this.votes).length>=this.pairs.length){ this.finish(); }
},
go(d){ const n=this.idx+d; if(n>=0&&n<this.pairs.length){ this.idx=n; this.render(); } },
finish(){ $('doneMsg').textContent=`你(编号 ${this.rater})一共标了 ${Object.keys(this.votes).length} 对,已自动保存并上传。辛苦!`; $('done').classList.remove('hidden'); },
save(){ localStorage.setItem(this.KEY, JSON.stringify(this.votes)); },
async post(rec){
if(!COLLECT_URL) return; // local-only until a collector URL is set
try{
const {synced, ...payload}=rec;
// no-cors: Apps Script gives an opaque cross-origin response we can't read; a
// resolved fetch means the request was delivered. The script dedups by
// rater+pair_id, so a retry never double-counts.
await fetch(COLLECT_URL,{method:'POST',mode:'no-cors',body:JSON.stringify(payload)});
rec.synced=true; this.save(); this.syncUI();
}catch(e){ /* real network failure -> stays unsynced, retried by syncLoop */ }
},
syncLoop(){ if(!COLLECT_URL){ this.syncUI(); return; } for(const id in this.votes){ if(!this.votes[id].synced) this.post(this.votes[id]); } this.syncUI(); },
syncUI(){
if(!COLLECT_URL){ $('syncUI').innerHTML='<span class="ok">✓ 本地已保存</span>'; return; }
const pend=Object.values(this.votes).filter(v=>!v.synced).length;
$('syncUI').innerHTML = pend===0 ? '<span class="ok">✓ 已全部保存</span>' : `<span class="wait">⟳ 待上传 ${pend}</span>`;
},
export(){
const rows=Object.values(this.votes).map(({synced,...r})=>r);
const blob=new Blob([JSON.stringify({rater:this.rater,n:rows.length,votes:rows},null,2)],{type:'application/json'});
const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=`arena_rater${this.rater}.json`; a.click();
},
};
app.boot();
window.app=app; window.zoom=zoom;
</script>
</body>
</html>