Spaces:
Running on Zero
Running on Zero
Download field.py from ageraustine/HAWK-ONE: direct link, hf CLI and curl.
- Browser
- Download file 23.3 kB
-
https://huggingface.co/spaces/ageraustine/HAWK-ONE/resolve/main/field.py
- Command line
-
hf download hf://spaces/ageraustine/HAWK-ONE/field.py
-
curl -L -o field.py https://huggingface.co/spaces/ageraustine/HAWK-ONE/resolve/main/field.py
23.3 kB
| """Interactive All-22 field: a self-contained HTML/SVG/JS document served in an iframe. | |
| Python decides *what* (rule profile, formation, ball spot); the field renders and edits it. | |
| Edits (dragged players, drawn lines) persist in localStorage keyed by formation pair, so | |
| switching NFL <-> NCAA keeps the coach's work and only the geometry changes. | |
| """ | |
| from __future__ import annotations | |
| import html | |
| import json | |
| from formations import DEFENSE, OFFENSE | |
| from rules import PROFILES, RuleProfile, other | |
| TEMPLATE = r"""<!doctype html><html><head><meta charset="utf-8"><style> | |
| *{box-sizing:border-box} | |
| html,body{margin:0;height:100%;background:#0b1220;font-family:Inter,system-ui,Segoe UI,Roboto,sans-serif;color:#e5e7eb} | |
| body{display:flex;flex-direction:column} | |
| #bar{display:flex;flex-wrap:wrap;gap:6px;align-items:center;padding:8px 10px;background:#111a2e;border-bottom:1px solid #22304d} | |
| button{background:#1c2a47;color:#e5e7eb;border:1px solid #2f4470;border-radius:8px;padding:6px 10px;font-size:12.5px;cursor:pointer} | |
| button:hover{background:#26375c}button.on{background:#2563eb;border-color:#60a5fa;color:#fff} | |
| select,input[type=range]{background:#1c2a47;color:#e5e7eb;border:1px solid #2f4470;border-radius:8px;padding:5px} | |
| .sep{width:1px;height:22px;background:#2f4470;margin:0 4px} | |
| #meta{padding:6px 12px;font-size:12.5px;background:#0f172a;border-bottom:1px solid #22304d;color:#cbd5e1} | |
| #meta b{color:#fff}.pill{display:inline-block;padding:1px 8px;border-radius:999px;background:#2563eb;color:#fff;font-weight:700;margin-right:6px} | |
| #wrap{flex:1;min-height:0}svg{width:100%;height:100%;display:block;touch-action:none;user-select:none} | |
| #sel{padding:7px 12px;font-size:12.5px;background:#0f172a;border-top:1px solid #22304d;min-height:34px;color:#cbd5e1} | |
| .pt{font:800 .82px Inter,system-ui,sans-serif;fill:#fff;text-anchor:middle;pointer-events:none} | |
| .pn{font:700 .68px Inter,system-ui,sans-serif;fill:#e2e8f0;text-anchor:middle;pointer-events:none} | |
| .pl{cursor:grab}.draw .pl{cursor:crosshair} | |
| </style></head><body> | |
| <div id="bar"> | |
| <button id="t_move" class="on">✋ Move</button><button id="t_route">↗ Route</button> | |
| <button id="t_motion">⤷ Motion</button><button id="t_block">⊥ Block</button> | |
| <span class="sep"></span> | |
| <button id="b_clrsel">Clear line</button><button id="b_clrall">Clear all lines</button> | |
| <span class="sep"></span> | |
| <button id="b_play">▶ Play</button><button id="b_reset">↺ Reset play</button> | |
| <span class="sep"></span> | |
| <button id="b_view">🔍 Full field</button><button id="b_formation">Reset formation</button><button id="b_export">Export JSON</button> | |
| </div> | |
| <div id="rbar" style="display:none;gap:8px;align-items:center;padding:8px 10px;background:#111a2e;border-bottom:1px solid #22304d"> | |
| <button id="r_play">❚❚ Pause</button><button id="r_restart">↺ Restart</button> | |
| <input id="scrub" type="range" min="0" max="100" value="0" style="flex:1;min-width:140px"> | |
| <select id="r_speed"><option value="0.25">0.25x</option><option value="0.5">0.5x</option><option value="1" selected>1x</option><option value="2">2x</option></select> | |
| <button id="r_follow" class="on">Follow ball</button><span id="r_time" style="min-width:46px;text-align:right;font-variant-numeric:tabular-nums"></span> | |
| </div> | |
| <div id="cap" style="display:none;padding:6px 12px;font-size:13px;background:#0b1630;border-bottom:1px solid #22304d;min-height:62px;line-height:1.5"></div> | |
| <div id="meta"></div> | |
| <div id="wrap"><svg id="field" xmlns="http://www.w3.org/2000/svg"></svg></div> | |
| <div id="sel">Drag any player. Pick Route / Motion / Block, then drag from a player to draw his line.</div> | |
| <script> | |
| (function(){ | |
| const C = __CFG__; | |
| const W = 160/3, LEN = 120, R = 0.95; | |
| const HH = C.hashHalf, GH = C.ghostHashHalf; | |
| const ballX = 10 + C.yardline; | |
| const ballY = (C.ballY!=null) ? C.ballY : W/2 + (C.spot==='L' ? -HH : C.spot==='R' ? HH : 0); | |
| const svg = document.getElementById('field'); | |
| const KEY = 'hawk1:' + C.offName + '|' + C.defName; | |
| function marker(id,col){return `<marker id="${id}" viewBox="0 0 10 10" refX="7" refY="5" markerWidth="4.2" markerHeight="4.2" orient="auto"><path d="M0 0L10 5L0 10z" fill="${col}"/></marker>`;} | |
| svg.innerHTML = `<defs> | |
| <linearGradient id="turf" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2f7d3a"/><stop offset=".5" stop-color="#3b9147"/><stop offset="1" stop-color="#2f7d3a"/></linearGradient> | |
| <radialGradient id="gO" cx="35%" cy="30%" r="75%"><stop offset="0" stop-color="#bfdbfe"/><stop offset=".5" stop-color="#2563eb"/><stop offset="1" stop-color="#172554"/></radialGradient> | |
| <radialGradient id="gD" cx="35%" cy="30%" r="75%"><stop offset="0" stop-color="#fecaca"/><stop offset=".5" stop-color="#dc2626"/><stop offset="1" stop-color="#450a0a"/></radialGradient> | |
| <filter id="sh" x="-50%" y="-50%" width="200%" height="200%"><feDropShadow dx=".1" dy=".18" stdDeviation=".18" flood-color="#000" flood-opacity=".55"/></filter> | |
| ${marker('aW','#ffffff')}${marker('aM','#fb923c')}${marker('aD','#fda4af')} | |
| </defs><g id="gField"></g><g id="gOver"></g><g id="gRoutes"></g><g id="gPlayers"></g>`; | |
| const gField=document.getElementById('gField'), gOver=document.getElementById('gOver'), | |
| gRoutes=document.getElementById('gRoutes'), gPlayers=document.getElementById('gPlayers'); | |
| // ---------------------------------------------------------------- field | |
| function drawField(){ | |
| let h = `<rect x="-2" y="-2" width="124" height="${W+4}" fill="#0b1220"/><rect x="0" y="0" width="120" height="${W}" fill="url(#turf)"/>`; | |
| for(let i=0;i<20;i+=2) h+=`<rect x="${10+i*5}" y="0" width="5" height="${W}" fill="#000" opacity=".06"/>`; | |
| h+=`<rect x="0" y="0" width="10" height="${W}" fill="#12356b"/><rect x="110" y="0" width="10" height="${W}" fill="#5b1226"/>`; | |
| h+=`<text transform="translate(5.6 ${W/2}) rotate(-90)" text-anchor="middle" font-size="3.6" font-weight="800" fill="#fff" opacity=".85" letter-spacing=".4">HAWK ONE</text>`; | |
| h+=`<text transform="translate(114.4 ${W/2}) rotate(90)" text-anchor="middle" font-size="3.6" font-weight="800" fill="#fff" opacity=".85" letter-spacing=".4">${C.levelShort}</text>`; | |
| for(let x=10;x<=110;x+=5){const major=(x%10===0); | |
| h+=`<line x1="${x}" y1="0" x2="${x}" y2="${W}" stroke="#fff" stroke-width="${x===10||x===110?.32:major?.2:.11}" opacity=".85"/>`;} | |
| for(let x=20;x<=100;x+=10){const v=x<=60?x-10:110-x; | |
| h+=`<text transform="translate(${x} 10.4) rotate(180)" text-anchor="middle" font-size="2.3" font-weight="700" fill="#fff" opacity=".8">${v}</text>`; | |
| h+=`<text transform="translate(${x} ${W-10.4})" text-anchor="middle" font-size="2.3" font-weight="700" fill="#fff" opacity=".8">${v}</text>`;} | |
| for(let x=11;x<110;x++){ | |
| for(const hy of [W/2-HH, W/2+HH]) h+=`<line x1="${x}" y1="${hy-.34}" x2="${x}" y2="${hy+.34}" stroke="#fff" stroke-width=".11"/>`; | |
| h+=`<line x1="${x}" y1="0" x2="${x}" y2=".7" stroke="#fff" stroke-width=".11"/><line x1="${x}" y1="${W}" x2="${x}" y2="${W-.7}" stroke="#fff" stroke-width=".11"/>`;} | |
| for(const hy of [W/2-HH, W/2+HH]) h+=`<line x1="10" y1="${hy}" x2="110" y2="${hy}" stroke="#fff" stroke-width=".05" opacity=".25"/>`; | |
| gField.innerHTML=h; | |
| } | |
| function drawOverlay(){ | |
| let h=''; | |
| if(C.showGhost){ | |
| for(const hy of [W/2-GH, W/2+GH]) h+=`<line x1="10" y1="${hy}" x2="110" y2="${hy}" stroke="#fde047" stroke-width=".12" stroke-dasharray=".8 .6" opacity=".75"/>`; | |
| h+=`<text x="${ballX+27}" y="${W/2-GH-.4}" font-size=".95" fill="#fde047" opacity=".95">${C.ghostName} hash (ghost)</text>`;} | |
| // dimension: hash to hash | |
| const dx=ballX-14.5; | |
| h+=`<g stroke="#fff" stroke-width=".12" opacity=".9"><line x1="${dx}" y1="${W/2-HH}" x2="${dx}" y2="${W/2+HH}"/><line x1="${dx-.6}" y1="${W/2-HH}" x2="${dx+.6}" y2="${W/2-HH}"/><line x1="${dx-.6}" y1="${W/2+HH}" x2="${dx+.6}" y2="${W/2+HH}"/></g>`; | |
| h+=`<text x="${dx-.9}" y="${W/2+.3}" text-anchor="end" font-size=".95" fill="#fff" font-weight="700">hash-to-hash ${C.hashYd.toFixed(2)} yd</text>`; | |
| // LOS, line to gain, ineligible line | |
| h+=`<line x1="${ballX}" y1="0" x2="${ballX}" y2="${W}" stroke="#38bdf8" stroke-width=".22" opacity=".9"/>`; | |
| h+=`<line x1="${ballX+10}" y1="0" x2="${ballX+10}" y2="${W}" stroke="#facc15" stroke-width=".2" opacity=".9"/>`; | |
| if(C.showInel){ | |
| const ix=ballX+C.inel; | |
| h+=`<line x1="${ix}" y1="0" x2="${ix}" y2="${W}" stroke="#fb923c" stroke-width=".14" stroke-dasharray=".6 .5"/>`; | |
| h+=`<text x="${ix+.4}" y="2.3" font-size=".95" fill="#fdba74" font-weight="700">OL downfield limit: ${C.inel} yd (${C.levelShort})</text>`;} | |
| if(!C.replay) h+=`<ellipse cx="${ballX}" cy="${ballY}" rx=".55" ry=".34" fill="#92400e" stroke="#fff" stroke-width=".07"/><line x1="${ballX-.25}" y1="${ballY}" x2="${ballX+.25}" y2="${ballY}" stroke="#fff" stroke-width=".05"/>`; | |
| gOver.innerHTML=h; | |
| } | |
| // ---------------------------------------------------------------- state | |
| let players = C.players.map(p=>({...p, route:null})); | |
| let selected=null, tool='move', drag=null, drawing=null, anim=null, raf=null, zoom=true; | |
| try{const s=JSON.parse(localStorage.getItem(KEY)||'null'); if(s) for(const r of s){const p=players.find(q=>q.id===r.id); if(p){p.ox=r.ox;p.oy=r.oy;p.route=r.route||null;}}}catch(e){} | |
| function save(){try{localStorage.setItem(KEY,JSON.stringify(players.map(p=>({id:p.id,ox:p.ox,oy:p.oy,route:p.route}))));}catch(e){}} | |
| function pos(p){const x=ballX+p.ox; const y=p.anchor==='B'?ballY+p.oy:p.anchor==='T'?p.oy:W-p.oy; return [x,y];} | |
| function setAbs(p,x,y){p.ox=x-ballX; p.oy=p.anchor==='B'?y-ballY:p.anchor==='T'?y:W-y;} | |
| function cur(p){return (anim&&anim[p.id])||pos(p);} | |
| // ---------------------------------------------------------------- geometry helpers | |
| function pd(p,a,b){const dx=b[0]-a[0],dy=b[1]-a[1],L=Math.hypot(dx,dy); if(!L) return Math.hypot(p[0]-a[0],p[1]-a[1]); return Math.abs(dy*p[0]-dx*p[1]+b[0]*a[1]-b[1]*a[0])/L;} | |
| function rdp(pts,eps){if(pts.length<3) return pts; let dm=0,ix=0; const a=pts[0],b=pts[pts.length-1]; | |
| for(let i=1;i<pts.length-1;i++){const d=pd(pts[i],a,b); if(d>dm){dm=d;ix=i;}} | |
| if(dm>eps){const l=rdp(pts.slice(0,ix+1),eps),r=rdp(pts.slice(ix),eps); return l.slice(0,-1).concat(r);} return [a,b];} | |
| function smooth(pts){if(pts.length<2) return ''; let d=`M${pts[0][0]} ${pts[0][1]}`; | |
| if(pts.length===2) return d+`L${pts[1][0]} ${pts[1][1]}`; | |
| for(let i=1;i<pts.length-1;i++){const mx=(pts[i][0]+pts[i+1][0])/2,my=(pts[i][1]+pts[i+1][1])/2; d+=`Q${pts[i][0]} ${pts[i][1]} ${mx} ${my}`;} | |
| const l=pts[pts.length-1]; return d+`L${l[0]} ${l[1]}`;} | |
| function toSvg(e){const pt=svg.createSVGPoint(); pt.x=e.clientX; pt.y=e.clientY; const q=pt.matrixTransform(svg.getScreenCTM().inverse()); return [q.x,q.y];} | |
| // ---------------------------------------------------------------- render | |
| function renderRoutes(){ | |
| let h=''; | |
| for(const p of players){ if(!p.route||p.route.pts.length<2) continue; | |
| const [x0,y0]=pos(p); const abs=p.route.pts.map(r=>[x0+r[0],y0+r[1]]); const st=p.route.style; | |
| const col=st==='motion'?'#fb923c':st==='block'?(p.side==='O'?'#93c5fd':'#fda4af'):(p.side==='O'?'#ffffff':'#fda4af'); | |
| const mk=st==='motion'?'aM':p.side==='O'?'aW':'aD'; | |
| const dash=st==='motion'?'stroke-dasharray=".8 .5"':''; | |
| h+=`<path d="${smooth(abs)}" fill="none" stroke="${col}" stroke-width=".24" stroke-linecap="round" ${dash} ${st==='block'?'':`marker-end="url(#${mk})"`} opacity=".95"/>`; | |
| if(st==='block'){const a=abs[abs.length-2],b=abs[abs.length-1]; const dx=b[0]-a[0],dy=b[1]-a[1],L=Math.hypot(dx,dy)||1; const nx=-dy/L*.9,ny=dx/L*.9; | |
| h+=`<line x1="${b[0]-nx}" y1="${b[1]-ny}" x2="${b[0]+nx}" y2="${b[1]+ny}" stroke="${col}" stroke-width=".3" stroke-linecap="round"/>`;} | |
| } | |
| gRoutes.innerHTML=h; | |
| } | |
| function renderPlayers(){ | |
| let h=''; | |
| for(const p of players){ const [x,y]=cur(p); const c=p.side==='O'?'gO':'gD'; | |
| h+=`<g class="pl" data-id="${p.id}" transform="translate(${x} ${y})">`+ | |
| (selected===p.id?`<circle r="${R+.42}" fill="none" stroke="#fde047" stroke-width=".18"/>`:'')+ | |
| `<circle r="${R}" fill="url(#${c})" stroke="#fff" stroke-width=".14" filter="url(#sh)"/>`+ | |
| `<text class="pt" y="0" dy=".29">${p.pos}</text>`+ | |
| `<g transform="translate(0 ${R+.78})"><rect x="-1" y="-.58" width="2" height="1.05" rx=".5" fill="rgba(2,6,23,.78)"/><text class="pn" dy=".22">${p.num}</text></g></g>`; | |
| } | |
| gPlayers.innerHTML=h; | |
| } | |
| function ydl(){const y=C.yardline; return y<=50?`own ${y}`:`opp ${100-y}`;} | |
| function meta(){ | |
| const toTop=ballY, toBot=W-ballY; | |
| document.getElementById('meta').innerHTML=`<span class="pill">${C.levelLabel}</span>hash-to-hash <b>${C.hashYd.toFixed(2)} yd</b> · ball on <b>${ydl()}</b>, ${C.spotName} · ball → sidelines: <b>${toTop.toFixed(1)}</b> yd (left) / <b>${toBot.toFixed(1)}</b> yd (right) · OL downfield limit <b>${C.inel} yd</b>`; | |
| } | |
| function readout(){ | |
| const el=document.getElementById('sel'); const p=players.find(q=>q.id===selected); | |
| if(!p){el.textContent='Drag any player. Pick Route / Motion / Block, then drag from a player to draw his line.'; return;} | |
| const [x,y]=pos(p); const dx=x-ballX; const side=Math.min(y,W-y); const off=Math.abs(y-W/2); | |
| const depth=Math.abs(dx)<1.3?'on the LOS':`${Math.abs(dx).toFixed(1)} yd ${dx<0?'behind':'beyond'} the LOS`; | |
| const hash=off<=HH?'inside the hashes':`${(off-HH).toFixed(1)} yd outside the hash`; | |
| el.innerHTML=`<b>${p.pos} #${p.num}</b> (${p.side==='O'?'offense':'defense'}) · ${depth} · ${side.toFixed(1)} yd from sideline · ${hash}`+(p.route?` · line: ${p.route.style}`:''); | |
| } | |
| function renderAll(){renderRoutes();renderPlayers();readout();} | |
| // ---------------------------------------------------------------- interaction | |
| svg.addEventListener('pointerdown',e=>{ | |
| if(C.replay) return; | |
| const g=e.target.closest('.pl'); if(!g){selected=null;renderAll();return;} | |
| if(anim) stopAnim(); | |
| const p=players.find(q=>q.id===g.dataset.id); selected=p.id; const [mx,my]=toSvg(e); svg.setPointerCapture(e.pointerId); | |
| if(tool==='move'){const [x,y]=pos(p); drag={p,gx:mx-x,gy:my-y};} | |
| else{drawing={p,pts:[[0,0]],style:tool}; const [x,y]=pos(p); drawing.o=[x,y];} | |
| renderAll(); | |
| }); | |
| svg.addEventListener('pointermove',e=>{ | |
| if(!drag&&!drawing) return; const [mx,my]=toSvg(e); | |
| if(drag){const x=Math.min(LEN-.5,Math.max(.5,mx-drag.gx)), y=Math.min(W-.5,Math.max(.5,my-drag.gy)); setAbs(drag.p,x,y); renderAll();} | |
| else{const r=[mx-drawing.o[0],my-drawing.o[1]], l=drawing.pts[drawing.pts.length-1]; | |
| if(Math.hypot(r[0]-l[0],r[1]-l[1])>.45){drawing.pts.push(r); drawing.p.route={style:drawing.style,pts:drawing.pts}; renderRoutes();}} | |
| }); | |
| function endPtr(){ | |
| if(drawing){const pts=rdp(drawing.pts,.35); drawing.p.route=pts.length>1?{style:drawing.style,pts}:null; drawing=null; renderAll();} | |
| if(drag||true){drag=null; save();} | |
| } | |
| svg.addEventListener('pointerup',endPtr); svg.addEventListener('pointercancel',endPtr); | |
| window.addEventListener('keydown',e=>{ | |
| if(e.key==='Escape'){selected=null;renderAll();} | |
| if((e.key==='Delete'||e.key==='Backspace')&&selected){const p=players.find(q=>q.id===selected); if(p){p.route=null;save();renderAll();}} | |
| }); | |
| function setTool(t){tool=t; for(const k of ['move','route','motion','block']) document.getElementById('t_'+k).classList.toggle('on',k===t); svg.classList.toggle('draw',t!=='move');} | |
| for(const k of ['move','route','motion','block']) document.getElementById('t_'+k).onclick=()=>setTool(k); | |
| document.getElementById('b_clrsel').onclick=()=>{const p=players.find(q=>q.id===selected); if(p){p.route=null;save();renderAll();}}; | |
| document.getElementById('b_clrall').onclick=()=>{players.forEach(p=>p.route=null);save();renderAll();}; | |
| document.getElementById('b_formation').onclick=()=>{try{localStorage.removeItem(KEY);}catch(e){} players=C.players.map(p=>({...p,route:null})); selected=null; stopAnim(); renderAll();}; | |
| // ---------------------------------------------------------------- playback (rules-free preview) | |
| function stopAnim(){if(raf) cancelAnimationFrame(raf); raf=null; anim=null; renderPlayers();} | |
| function play(){ | |
| stopAnim(); const tracks=[]; | |
| for(const p of players){ if(!p.route||p.route.pts.length<2) continue; const [x0,y0]=pos(p); | |
| const pts=p.route.pts.map(r=>[x0+r[0],y0+r[1]]); const cum=[0]; for(let i=1;i<pts.length;i++) cum.push(cum[i-1]+Math.hypot(pts[i][0]-pts[i-1][0],pts[i][1]-pts[i-1][1])); | |
| tracks.push({p,pts,cum,sp:p.route.style==='motion'?5.5:p.route.style==='block'?3:8.5});} | |
| if(!tracks.length) return; const t0=performance.now(); anim={}; | |
| (function step(now){const t=(now-t0)/1000; let alive=false; | |
| for(const k of tracks){const d=Math.min(k.cum[k.cum.length-1],k.sp*t); if(d<k.cum[k.cum.length-1]) alive=true; | |
| let i=1; while(i<k.cum.length-1&&k.cum[i]<d) i++; const f=(d-k.cum[i-1])/((k.cum[i]-k.cum[i-1])||1); | |
| anim[k.p.id]=[k.pts[i-1][0]+(k.pts[i][0]-k.pts[i-1][0])*f, k.pts[i-1][1]+(k.pts[i][1]-k.pts[i-1][1])*f];} | |
| renderPlayers(); if(alive) raf=requestAnimationFrame(step); else raf=null;})(t0); | |
| } | |
| document.getElementById('b_play').onclick=play; document.getElementById('b_reset').onclick=stopAnim; | |
| // ---------------------------------------------------------------- view + export | |
| function setView(){ | |
| if(zoom){const w=72,x0=Math.max(-2,Math.min(122-w,ballX-27)); svg.setAttribute('viewBox',`${x0} -2 ${w} ${W+4}`);} | |
| else svg.setAttribute('viewBox',`-2 -2 124 ${W+4}`); | |
| document.getElementById('b_view').innerHTML=zoom?'🔍 Full field':'🔍 Zoom to play'; | |
| } | |
| document.getElementById('b_view').onclick=()=>{zoom=!zoom;setView();}; | |
| document.getElementById('b_export').onclick=()=>{ | |
| const out={level:C.levelShort,hash_width_ft:C.hashFt,ball:{yard_line:C.yardline,hash:C.spotName,x:ballX,y:+ballY.toFixed(2)}, | |
| offense:C.offName,defense:C.defName,players:players.map(p=>{const [x,y]=pos(p);return {id:p.id,side:p.side,pos:p.pos,num:p.num,x:+x.toFixed(2),y:+y.toFixed(2), | |
| line:p.route?{style:p.route.style,points:p.route.pts.map(r=>[+(x+r[0]).toFixed(2),+(y+r[1]).toFixed(2)])}:null};})}; | |
| const a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([JSON.stringify(out,null,2)],{type:'application/json'})); a.download='hawk_one_play.json'; a.click(); | |
| }; | |
| drawField(); drawOverlay(); setView(); meta(); renderAll(); | |
| // ---------------------------------------------------------------- replay of a simulated play | |
| if(C.replay){ | |
| const RP=C.replay, F=RP.frames, ids=RP.ids, n=ids.length; | |
| document.getElementById('bar').style.display='none'; | |
| document.getElementById('rbar').style.display='flex'; | |
| const capEl=document.getElementById('cap'); capEl.style.display='block'; | |
| const scrub=document.getElementById('scrub'); scrub.max=F.length-1; scrub.step='any'; | |
| svg.insertAdjacentHTML('beforeend','<g id="gBall"></g>'); const gBall=document.getElementById('gBall'); | |
| const playBtn=document.getElementById('r_play'), followBtn=document.getElementById('r_follow'); | |
| let fi=0, playing=true, speed=1, last=performance.now(), follow=true, camX=ballX; | |
| const colour={play:'#e5e7eb',result:'#fde68a',rule:'#fb923c',penalty:'#f87171',pressure:'#93c5fd',decision:'#a7f3d0',info:'#cbd5e1'}; | |
| function setFrame(f){ | |
| fi=Math.max(0,Math.min(F.length-1,f)); const i=Math.floor(fi), j=Math.min(F.length-1,i+1), u=fi-i, A=F[i], B=F[j]; | |
| anim={}; | |
| for(let k=0;k<n;k++) anim[ids[k]]=[A.p[2*k]+(B.p[2*k]-A.p[2*k])*u, A.p[2*k+1]+(B.p[2*k+1]-A.p[2*k+1])*u]; | |
| const bx_=A.b[0]+(B.b[0]-A.b[0])*u, by_=A.b[1]+(B.b[1]-A.b[1])*u, bh=A.b[2]+(B.b[2]-A.b[2])*u; | |
| renderPlayers(); | |
| let h=''; | |
| for(let k=0;k<n;k++){ let d='M'+F[0].p[2*k]+' '+F[0].p[2*k+1]; for(let q=1;q<=i;q++) d+='L'+F[q].p[2*k]+' '+F[q].p[2*k+1]; d+='L'+anim[ids[k]][0]+' '+anim[ids[k]][1]; | |
| h+=`<path d="${d}" fill="none" stroke="${players[k].side==='O'?'#bfdbfe':'#fecaca'}" stroke-opacity=".38" stroke-width=".14" stroke-linecap="round" stroke-linejoin="round"/>`;} | |
| gRoutes.innerHTML=h; | |
| const sc=1+0.9*bh; | |
| gBall.innerHTML=(bh>0.04?`<ellipse cx="${bx_}" cy="${by_+0.15}" rx=".5" ry=".28" fill="#000" opacity=".28"/>`:'')+ | |
| `<ellipse cx="${bx_}" cy="${by_-bh*1.7}" rx="${.55*sc}" ry="${.34*sc}" fill="#92400e" stroke="#fff" stroke-width=".09"/>`+ | |
| `<line x1="${bx_-.22*sc}" y1="${by_-bh*1.7}" x2="${bx_+.22*sc}" y2="${by_-bh*1.7}" stroke="#fff" stroke-width=".05"/>`; | |
| const shown=RP.events.filter(e=>e.t<=A.t+0.001).slice(-3); | |
| capEl.innerHTML=shown.map((e,k)=>`<div style="color:${colour[e.kind]||'#e5e7eb'};opacity:${k===shown.length-1?1:.6}"><b>${e.t.toFixed(1)}s</b> ${e.text}</div>`).join('')||'<span style="opacity:.6">Snap...</span>'; | |
| document.getElementById('r_time').textContent=A.t.toFixed(1)+'s'; | |
| scrub.value=fi; | |
| if(follow){camX+=(bx_-camX)*0.12; const w=72, x0=Math.max(-2,Math.min(122-w,camX-30)); svg.setAttribute('viewBox',`${x0} -2 ${w} ${W+4}`);} | |
| } | |
| function loop(now){const dt=Math.min(0.1,(now-last)/1000); last=now; | |
| if(playing){fi+=dt*10*speed; if(fi>=F.length-1){fi=F.length-1; playing=false; playBtn.innerHTML='▶ Play';} setFrame(fi);} requestAnimationFrame(loop);} | |
| playBtn.onclick=()=>{ if(!playing&&fi>=F.length-1) fi=0; playing=!playing; playBtn.innerHTML=playing?'❚❚ Pause':'▶ Play'; }; | |
| document.getElementById('r_restart').onclick=()=>{fi=0;playing=true;playBtn.innerHTML='❚❚ Pause';}; | |
| document.getElementById('r_speed').onchange=e=>{speed=parseFloat(e.target.value);}; | |
| followBtn.onclick=()=>{follow=!follow; followBtn.classList.toggle('on',follow); if(!follow){zoom=false;setView();}}; | |
| scrub.oninput=()=>{playing=false; playBtn.innerHTML='▶ Play'; setFrame(parseFloat(scrub.value));}; | |
| document.getElementById('sel').innerHTML='<b>Play result:</b> '+RP.summary; | |
| setFrame(0); requestAnimationFrame(t=>{last=t; loop(t);}); | |
| } | |
| })(); | |
| </script></body></html>""" | |
| def build_field_html(level_key: str, off_name: str, def_name: str, spot: str, | |
| yardline: int, show_ghost: bool, show_inel: bool, height: int = 720, replay=None) -> str: | |
| p: RuleProfile = PROFILES[level_key] | |
| o = other(p) | |
| if replay: | |
| players = [dict(m, ox=0.0, oy=0.0, anchor="B") for m in replay["meta"]] | |
| yardline = int(round(replay["bx"] - 10)) | |
| else: | |
| players = ([dict(pl, side="O") for pl in OFFENSE[off_name]] + | |
| [dict(pl, side="D") for pl in DEFENSE[def_name]]) | |
| cfg = dict( | |
| levelLabel=p.label, levelShort=p.key, hashHalf=p.hash_half_yd, hashYd=p.hash_width_yd, | |
| hashFt=p.hash_width_ft, ghostHashHalf=o.hash_half_yd, ghostName=o.key, | |
| showGhost=show_ghost, showInel=show_inel, inel=p.ineligible_downfield_yd, | |
| yardline=int(yardline), spot={"Left hash": "L", "Middle": "M", "Right hash": "R"}[spot], | |
| spotName=spot.lower(), offName=off_name, defName=def_name, players=players, | |
| ) | |
| if replay: | |
| cfg["ballY"] = replay["by"] | |
| cfg["spotName"] = "snap spot" | |
| cfg["replay"] = dict(ids=replay["ids"], frames=replay["frames"], events=replay["events"], | |
| summary=replay["summary"]) | |
| doc = TEMPLATE.replace("__CFG__", json.dumps(cfg)) | |
| return (f'<iframe srcdoc="{html.escape(doc, quote=True)}" ' | |
| f'sandbox="allow-scripts allow-same-origin allow-downloads" ' | |
| f'style="width:100%;height:{height}px;border:1px solid #22304d;border-radius:12px;background:#0b1220"></iframe>') | |