HAWK-ONE / field.py
ageraustine's picture
init
ab93d31 verified
Raw History Blame Contribute Delete
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">&#9995; Move</button><button id="t_route">&#8599; Route</button>
<button id="t_motion">&#10551; Motion</button><button id="t_block">&#8869; 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">&#9654; Play</button><button id="b_reset">&#8634; Reset play</button>
<span class="sep"></span>
<button id="b_view">&#128269; 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">&#10074;&#10074; Pause</button><button id="r_restart">&#8634; 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> &middot; ball on <b>${ydl()}</b>, ${C.spotName} &middot; ball &rarr; sidelines: <b>${toTop.toFixed(1)}</b> yd (left) / <b>${toBot.toFixed(1)}</b> yd (right) &middot; 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'}) &middot; ${depth} &middot; ${side.toFixed(1)} yd from sideline &middot; ${hash}`+(p.route?` &middot; 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?'&#128269; Full field':'&#128269; 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> &nbsp;${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='&#9654; Play';} setFrame(fi);} requestAnimationFrame(loop);}
playBtn.onclick=()=>{ if(!playing&&fi>=F.length-1) fi=0; playing=!playing; playBtn.innerHTML=playing?'&#10074;&#10074; Pause':'&#9654; Play'; };
document.getElementById('r_restart').onclick=()=>{fi=0;playing=true;playBtn.innerHTML='&#10074;&#10074; 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='&#9654; 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>')