CoolFace
Apppublic

figmirror/plot-arena

sourceHugging Faceupdated 1mo agoView on Hugging Face
0likes
index.html333 linesDownload Raw Back to root
1<!doctype html>2<html lang="zh-CN">3<head>4<meta charset="utf-8">5<meta name="viewport" content="width=device-width, initial-scale=1">6<title>FigMirror 风格盲评</title>7<style>8  :root{9    --bg:#eef1f6; --panel:#fff; --ink:#182031; --dim:#6b7688; --line:#e3e7ef; --letterbox:#eef2f7;10    --a:#2563eb; --a-soft:#e8f0fe; --b:#7c3aed; --b-soft:#f1eafe; --good:#0fa356; --warn:#e0902f;11    --shadow:0 1px 2px rgba(20,30,50,.06),0 6px 20px rgba(20,30,50,.07); --hdr:48px;12  }13  *{box-sizing:border-box}14  html,body{height:100%;margin:0;overflow:hidden}15  body{background:var(--bg);color:var(--ink);16    font:14px/1.5 system-ui,-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}17  button{font:inherit;color:inherit;cursor:pointer}18  .app{height:100vh;height:100dvh;display:grid;19    grid-template-rows:var(--hdr) clamp(92px,20vh,210px) minmax(0,1fr) auto;overflow:hidden}20 21  /* header */22  .topbar{display:flex;align-items:center;gap:14px;padding:0 16px;background:var(--panel);border-bottom:1px solid var(--line)}23  .brand{font-weight:750;font-size:15px;white-space:nowrap}24  .brand span{color:var(--a)}25  .rater{font-size:12px;font-weight:700;color:#fff;background:var(--a);border-radius:20px;padding:2px 10px;white-space:nowrap}26  .prog{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:4px;max-width:560px;margin:0 auto}27  .prog .t{font-size:12px;color:var(--dim);text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}28  .prog .t b{color:var(--ink)}29  .prog .bar{height:6px;border-radius:20px;background:#e2e7f0;overflow:hidden}30  .prog .bar>i{display:block;height:100%;width:0;background:linear-gradient(90deg,var(--a),#7c85f5);transition:width .25s}31  .sync{font-size:11.5px;white-space:nowrap;color:var(--dim)}32  .sync .ok{color:var(--good);font-weight:700}33  .sync .wait{color:var(--warn);font-weight:700}34  .hbtn{border:1px solid var(--line);background:#fff;border-radius:8px;padding:6px 10px;font-size:12px}35  .hbtn:hover{background:#f7fafe;border-color:#c7cfdc}36 37  /* reference */38  .refband{position:relative;background:var(--panel);border-bottom:1px solid var(--line)}39  .refband img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;padding:8px 8px 6px;cursor:zoom-in}40  .reftag{position:absolute;top:7px;left:14px;z-index:2;font-size:11px;font-weight:700;letter-spacing:.1em;41    color:var(--a);background:var(--a-soft);border-radius:20px;padding:2px 9px;pointer-events:none}42  .refhint{position:absolute;top:7px;right:14px;z-index:2;font-size:11px;color:var(--dim);pointer-events:none}43 44  /* candidates */45  .stage{min-height:0;min-width:0;display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);46    grid-template-rows:minmax(0,1fr);gap:12px;padding:10px 12px}47  .cand{position:relative;min-width:0;min-height:0;overflow:hidden;background:var(--letterbox);48    border:2px solid var(--line);border-radius:13px;cursor:zoom-in}49  .cand img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;padding:8px;display:block}50  .cand .badge{position:absolute;top:9px;left:9px;z-index:2;width:28px;height:28px;border-radius:8px;51    display:grid;place-items:center;font-weight:800;font-size:14px;color:#fff;box-shadow:0 2px 6px rgba(20,30,50,.2)}52  #cardA .badge{background:var(--a)} #cardB .badge{background:var(--b)}53  .cand .chosen{position:absolute;top:9px;right:9px;z-index:2;display:none;font-size:11px;font-weight:700;color:#fff;54    background:var(--good);border-radius:20px;padding:3px 9px}55  #cardA.win{border-color:var(--a);box-shadow:0 0 0 3px var(--a-soft)}56  #cardB.win{border-color:var(--b);box-shadow:0 0 0 3px var(--b-soft)}57  .cand.win .chosen{display:block}58 59  /* vote bar */60  .votebar{display:flex;align-items:center;justify-content:center;gap:10px;padding:9px 12px;61    background:var(--panel);border-top:1px solid var(--line)}62  .nav{flex:0 0 auto;border:1px solid var(--line);background:#fff;border-radius:10px;padding:10px 14px;63    font-size:13px;font-weight:600;color:var(--dim)}64  .nav:hover:not(:disabled){background:#f7fafe;border-color:#c7cfdc;color:var(--ink)}65  .nav:disabled{opacity:.4;cursor:default}66  .choice{border:2px solid;border-radius:12px;padding:11px 14px;font-size:14.5px;font-weight:800;67    display:inline-flex;align-items:center;gap:8px;white-space:nowrap}68  .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)}69  .cA{color:var(--a);border-color:var(--a);background:#fff;flex:0 1 240px} .cA:hover{background:var(--a-soft)}70  .cA.on{background:var(--a);color:#fff} .cA.on kbd{background:rgba(255,255,255,.25);color:#fff}71  .cB{color:var(--b);border-color:var(--b);background:#fff;flex:0 1 240px} .cB:hover{background:var(--b-soft)}72  .cB.on{background:var(--b);color:#fff} .cB.on kbd{background:rgba(255,255,255,.25);color:#fff}73  .cMid{color:var(--dim);border-color:var(--line);background:#fff;font-weight:700;font-size:13px}74  .cMid:hover{background:#f4f6fa;border-color:#c7cfdc} .cMid.on{background:#3a4557;color:#fff;border-color:#3a4557}75  .cMid.on kbd{background:rgba(255,255,255,.22);color:#fff}76 77  /* overlays */78  .overlay{position:fixed;inset:0;z-index:60;background:rgba(238,241,246,.97);display:grid;place-items:center;padding:22px}79  .panel{background:#fff;border:1px solid var(--line);border-radius:18px;box-shadow:var(--shadow);max-width:520px;padding:30px 34px;text-align:center}80  .panel h1{font-size:22px;margin:0 0 6px} .panel h1 span{color:var(--a)}81  .panel p{color:#3f475a;margin:8px 0;font-size:14.5px}82  .go{margin-top:16px;border:0;border-radius:12px;background:var(--a);color:#fff;font-weight:750;font-size:15px;padding:12px 30px}83  .go:hover{background:#1e57d6}84  .rules{text-align:left;background:#f6f9fc;border:1px solid var(--line);border-radius:11px;padding:12px 16px;margin:14px 0 0}85  .rules li{margin:4px 0;color:#3f475a;font-size:13.5px}86  .raters{display:flex;gap:10px;justify-content:center;margin-top:16px;flex-wrap:wrap}87  .raters a{border:1px solid var(--line);border-radius:10px;padding:10px 16px;font-weight:700;color:var(--a);text-decoration:none}88  .raters a:hover{background:var(--a-soft)}89  .hidden{display:none!important}90 91  /* lightbox */92  .lb{position:fixed;inset:0;z-index:80;background:#0b0f16;display:none;flex-direction:column}93  .lb.on{display:flex}94  .lbtop{display:flex;align-items:center;gap:10px;padding:10px 14px;color:#dfe6f2}95  .lbtop .t{font-size:12.5px;color:#aeb8ca} .lbtop .sp{flex:1}96  .lbbtn{border:1px solid #3a4459;background:#1b2334;color:#dfe6f2;border-radius:8px;height:34px;padding:0 12px;font-size:13px}97  .lbbtn:hover{background:#242e44}98  .lbstage{flex:1;min-height:0;overflow:hidden;position:relative;cursor:grab}99  .lbstage.drag{cursor:grabbing}100  .lbstage img{position:absolute;left:50%;top:50%;max-width:94vw;max-height:82vh;object-fit:contain;101    transform:translate(-50%,-50%);transform-origin:center;user-select:none;-webkit-user-drag:none}102  @media(max-width:820px){.stage{grid-template-columns:1fr;grid-template-rows:1fr 1fr}.votebar{flex-wrap:wrap}}103</style>104</head>105<body>106 107<!-- landing (no valid rater) -->108<div class="overlay" id="landing">109  <div class="panel">110    <h1><span>FigMirror</span> 风格盲评</h1>111    <p>请打开分配给你的链接(带 <code>?rater=编号</code>)。或直接选你的编号:</p>112    <div class="raters" id="raterLinks"></div>113    <p style="margin-top:14px;font-size:12.5px;color:#8a93a3">编号是我发给每个人的,别人和你的题目不完全一样。</p>114  </div>115</div>116 117<!-- start -->118<div class="overlay hidden" id="start">119  <div class="panel">120    <h1><span>FigMirror</span> 风格盲评 · <span id="startRater"></span></h1>121    <p>顶上是<b>参考图</b>,下面 A / B 两张。选<b>哪张更好地复刻了参考图的风格</b>——配色、字体、间距、布局。</p>122    <ul class="rules">123      <li>只看<b>风格像不像参考图</b>,别管数字对不对,也别管单看谁更好看。</li>124      <li>实在难分就点<b>「差不多」</b>;这一对看不了就<b>「跳过」</b>。</li>125      <li><b>点任意图片放大</b>看细节。快捷键:<b>1</b>=A,<b>2</b>=B,<b>3</b>=差不多,<b>4</b>=跳过,<b>←/→</b>翻页。</li>126      <li>进度自动保存,可随时关掉、下次接着标。</li>127    </ul>128    <button class="go" id="btnStart">开始标注</button>129  </div>130</div>131 132<!-- done -->133<div class="overlay hidden" id="done">134  <div class="panel">135    <h1>你的题标完了 🎉</h1>136    <p id="doneMsg"></p>137    <button class="go" id="btnReview">回去检查</button>138    <div style="margin-top:10px"><button class="hbtn" id="btnExport2">导出我的结果(备份)</button></div>139  </div>140</div>141 142<div class="app">143  <div class="topbar">144    <div class="brand"><span>FigMirror</span> 盲评</div>145    <div class="rater" id="raterTag">—</div>146    <div class="prog">147      <div class="t">第 <b id="pIdx">1</b> / <b id="pTot">0</b> · 已评 <b id="pDone">0</b></div>148      <div class="bar"><i id="barFill"></i></div>149    </div>150    <div class="sync" id="syncUI">…</div>151    <button class="hbtn" id="btnExport">导出</button>152  </div>153 154  <div class="refband">155    <div class="reftag">参考图 REFERENCE</div>156    <div class="refhint">点图放大 🔍</div>157    <img id="refImg" alt="reference">158  </div>159 160  <div class="stage">161    <div class="cand" id="cardA"><div class="badge">A</div><div class="chosen">✓ 已选</div><img id="imgA" alt="A"></div>162    <div class="cand" id="cardB"><div class="badge">B</div><div class="chosen">✓ 已选</div><img id="imgB" alt="B"></div>163  </div>164 165  <div class="votebar">166    <button class="nav" id="btnPrev">← 上一张</button>167    <button class="choice cA" id="btnA">A 更好 <kbd>1</kbd></button>168    <button class="choice cMid" id="btnTie">差不多 <kbd>3</kbd></button>169    <button class="choice cMid" id="btnSkip">跳过 <kbd>4</kbd></button>170    <button class="choice cB" id="btnB">B 更好 <kbd>2</kbd></button>171    <button class="nav" id="btnNext">下一张 →</button>172  </div>173</div>174 175<!-- lightbox -->176<div class="lb" id="lb">177  <div class="lbtop"><span class="t" id="lbTitle"></span><span class="sp"></span>178    <button class="lbbtn" id="lbOut">−</button><button class="lbbtn" id="lbFit">适应</button>179    <button class="lbbtn" id="lbIn">+</button><button class="lbbtn" id="lbClose">✕ 关闭</button>180  </div>181  <div class="lbstage" id="lbStage"><img id="lbImg" alt="zoom"></div>182</div>183 184<script src="manifest.js"></script>185<script>186const $ = id => document.getElementById(id);187const A = window.ARENA || {pairs:[],assignment:{}};188const byId = {}; A.pairs.forEach(p => byId[p.id] = p);189// Central collector endpoint (Google Apps Script Web App URL). Empty => local-only190// (votes still saved in the browser + exportable); set to enable auto-collection.191const COLLECT_URL = "https://script.google.com/macros/s/AKfycbzDYgGaW43lpzWaFMlBXfWvnz7KmL_XRUFKdFPtA020u00QCb9dESHVnnHodrlh07mNYg/exec";192 193// ---- rater resolution ----194const params = new URLSearchParams(location.search);195let rater = params.get('rater');196const N_RATERS = A.n_raters || 5;197const validRater = (rater && /^[1-9][0-9]*$/.test(rater) && +rater>=1 && +rater<=N_RATERS) ? +rater : null;198 199const zoom = {200  scale:1,tx:0,ty:0,drag:false,sx:0,sy:0,201  open(src,title){ $('lbImg').src=src; $('lbTitle').textContent=title+' · 滚轮缩放 · 拖动平移'; this.fit(); $('lb').classList.add('on'); },202  close(){ $('lb').classList.remove('on'); },203  isOpen(){ return $('lb').classList.contains('on'); },204  apply(){ $('lbImg').style.transform=`translate(calc(-50% + ${this.tx}px),calc(-50% + ${this.ty}px)) scale(${this.scale})`; },205  fit(){ this.scale=1;this.tx=0;this.ty=0;this.apply(); },206  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(); },207  init(){208    const s=$('lbStage');209    s.addEventListener('wheel',e=>{e.preventDefault();const r=s.getBoundingClientRect();210      const cx=e.clientX-(r.left+r.width/2),cy=e.clientY-(r.top+r.height/2);211      const f=e.deltaY<0?1.18:1/1.18,ns=Math.min(9,Math.max(1,this.scale*f)),k=ns/this.scale;212      this.tx=cx-k*(cx-this.tx);this.ty=cy-k*(cy-this.ty);this.scale=ns;213      if(ns===1){this.tx=0;this.ty=0;}this.apply();},{passive:false});214    s.addEventListener('mousedown',e=>{this.drag=true;this.sx=e.clientX-this.tx;this.sy=e.clientY-this.ty;s.classList.add('drag');});215    window.addEventListener('mousemove',e=>{if(this.drag){this.tx=e.clientX-this.sx;this.ty=e.clientY-this.sy;this.apply();}});216    window.addEventListener('mouseup',()=>{this.drag=false;s.classList.remove('drag');});217    s.addEventListener('click',e=>{if(e.target===s)this.close();});218    $('lbIn').onclick=()=>this.by(1.3); $('lbOut').onclick=()=>this.by(1/1.3);219    $('lbFit').onclick=()=>this.fit(); $('lbClose').onclick=()=>this.close();220  }221};222 223const app = {224  rater:validRater, pairs:[], idx:0, votes:{}, KEY:'',225  cur(){ return this.pairs[this.idx]; },226 227  boot(){228    zoom.init();229    if(!this.rater){230      const box=$('raterLinks');231      for(let i=1;i<=N_RATERS;i++){ const a=document.createElement('a'); a.href='?rater='+i; a.textContent='编号 '+i; box.appendChild(a); }232      return; // stay on landing233    }234    $('landing').classList.add('hidden');235    this.KEY='arena_votes_r'+this.rater;236    try{ this.votes=JSON.parse(localStorage.getItem(this.KEY))||{}; }catch(e){ this.votes={}; }237    const ids=(A.assignment&&A.assignment[this.rater])||A.pairs.map(p=>p.id);238    this.pairs=ids.map(id=>byId[id]).filter(Boolean);239    $('pTot').textContent=this.pairs.length;240    $('raterTag').textContent='编号 '+this.rater;241    $('startRater').textContent='编号 '+this.rater;242    // resume at first unjudged243    const fu=this.pairs.findIndex(p=>!this.votes[p.id]);244    this.idx=fu===-1?0:fu;245    $('start').classList.remove('hidden');246    $('btnStart').onclick=()=>{ $('start').classList.add('hidden'); this.render(); };247    $('btnReview').onclick=()=>{ $('done').classList.add('hidden'); this.idx=0; this.render(); };248    $('btnA').onclick=()=>this.vote('A'); $('btnB').onclick=()=>this.vote('B');249    $('btnTie').onclick=()=>this.vote('tie'); $('btnSkip').onclick=()=>this.vote('skip');250    $('btnPrev').onclick=()=>this.go(-1); $('btnNext').onclick=()=>this.go(1);251    $('btnExport').onclick=()=>this.export(); $('btnExport2').onclick=()=>this.export();252    $('cardA').onclick=()=>zoom.open(this.cur().A.img,'A'); $('cardB').onclick=()=>zoom.open(this.cur().B.img,'B');253    $('refImg').onclick=()=>zoom.open(this.cur().ref,'参考图');254    document.addEventListener('keydown',e=>{255      if(zoom.isOpen()){ if(e.key==='Escape')zoom.close(); else if(e.key==='+'||e.key==='=')zoom.by(1.3);256        else if(e.key==='-')zoom.by(1/1.3); else if(e.key==='0')zoom.fit(); return; }257      if(!$('start').classList.contains('hidden')||!$('done').classList.contains('hidden')) return;258      if(e.key==='1')this.vote('A'); else if(e.key==='2')this.vote('B');259      else if(e.key==='3')this.vote('tie'); else if(e.key==='4')this.vote('skip');260      else if(e.key==='ArrowLeft')this.go(-1); else if(e.key==='ArrowRight')this.go(1);261    });262    this.render(); this.syncLoop();263    setInterval(()=>this.syncLoop(), 5000);264  },265 266  render(){267    const p=this.cur(); if(!p)return;268    $('refImg').src=p.ref; $('imgA').src=p.A.img; $('imgB').src=p.B.img;269    $('pIdx').textContent=this.idx+1;270    const done=Object.keys(this.votes).length;271    $('pDone').textContent=done;272    $('barFill').style.width=(done/this.pairs.length*100)+'%';273    $('btnPrev').disabled=this.idx===0; $('btnNext').disabled=this.idx===this.pairs.length-1;274    const c=this.votes[p.id]?this.votes[p.id].choice:null;275    $('cardA').classList.toggle('win',c==='A');276    $('cardB').classList.toggle('win',c==='B');277    $('btnA').classList.toggle('on',c==='A');278    $('btnB').classList.toggle('on',c==='B');279    $('btnTie').classList.toggle('on',c==='tie');280    $('btnSkip').classList.toggle('on',c==='skip');281    this.syncUI();282  },283 284  uuid(){ return 'v'+Date.now().toString(36)+Math.floor(performance.now()*1000).toString(36); },285  vote(choice){286    const p=this.cur(); const fresh=!this.votes[p.id];287    const rec={ rater:this.rater, pair_id:p.id, figure_id:p.figure_id,288      A_method:p.A.method, B_method:p.B.method, choice,289      winner_method: choice==='A'?p.A.method : choice==='B'?p.B.method : null,290      uuid:(this.votes[p.id]&&this.votes[p.id].uuid)||this.uuid(),291      ts:new Date().toISOString(), synced:false };292    this.votes[p.id]=rec; this.save(); this.render(); this.post(rec);293    if(fresh) setTimeout(()=>this.advance(),260);294  },295  advance(){296    const nxt=this.pairs.findIndex((p,i)=>i>this.idx && !this.votes[p.id]);297    if(nxt!==-1){ this.idx=nxt; this.render(); }298    else if(this.idx<this.pairs.length-1){ this.idx++; this.render(); }299    else if(Object.keys(this.votes).length>=this.pairs.length){ this.finish(); }300  },301  go(d){ const n=this.idx+d; if(n>=0&&n<this.pairs.length){ this.idx=n; this.render(); } },302  finish(){ $('doneMsg').textContent=`你(编号 ${this.rater})一共标了 ${Object.keys(this.votes).length} 对,已自动保存并上传。辛苦!`; $('done').classList.remove('hidden'); },303 304  save(){ localStorage.setItem(this.KEY, JSON.stringify(this.votes)); },305  async post(rec){306    if(!COLLECT_URL) return;                 // local-only until a collector URL is set307    try{308      const {synced, ...payload}=rec;309      // no-cors: Apps Script gives an opaque cross-origin response we can't read; a310      // resolved fetch means the request was delivered. The script dedups by311      // rater+pair_id, so a retry never double-counts.312      await fetch(COLLECT_URL,{method:'POST',mode:'no-cors',body:JSON.stringify(payload)});313      rec.synced=true; this.save(); this.syncUI();314    }catch(e){ /* real network failure -> stays unsynced, retried by syncLoop */ }315  },316  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(); },317  syncUI(){318    if(!COLLECT_URL){ $('syncUI').innerHTML='<span class="ok">✓ 本地已保存</span>'; return; }319    const pend=Object.values(this.votes).filter(v=>!v.synced).length;320    $('syncUI').innerHTML = pend===0 ? '<span class="ok">✓ 已全部保存</span>' : `<span class="wait">⟳ 待上传 ${pend}</span>`;321  },322  export(){323    const rows=Object.values(this.votes).map(({synced,...r})=>r);324    const blob=new Blob([JSON.stringify({rater:this.rater,n:rows.length,votes:rows},null,2)],{type:'application/json'});325    const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=`arena_rater${this.rater}.json`; a.click();326  },327};328app.boot();329window.app=app; window.zoom=zoom;330</script>331</body>332</html>333