Basketball Shot Map PRO

'; const w = window.open('', '_blank'); if(!w){ toast('Popup blocked — allow popups to export Summary PDF.'); modal.confirm({title:'Popup Blocked', sub:'Allow popups for this site to use Summary PDF.', okText:'OK', cancelText:'Close'}); return; } w.document.open(); w.document.write(html); w.document.close(); w.focus(); setTimeout(()=>w.print(), 250); toast('Summary PDF ready — use Print > Save as PDF'); } async function editPlayer(playerId){ const p = state.players.find(x=>x.id===playerId); if(!p){ toast('Player not found'); return; } const name = await modal.prompt({ title:'Edit Player', sub:'Update player name', label:'Name', value: p.name, okText:'Save' }); if(name === null) return; const nm = String(name||'').trim(); if(!nm){ toast('Name required'); return; } p.name = nm.slice(0,40); toast('Player updated'); renderAll(); } async function deletePlayer(playerId){ const p = state.players.find(x=>x.id===playerId); if(!p){ toast('Player not found'); return; } const all = allShotsRawNoFilter(); const count = all.filter(s=>s.playerId===playerId).length; if(count > 0){ const choice = await modal.choose({ title:'Delete Player', sub:`"${p.name}" has ${count} shot(s).`, choices:[ {label:'Keep shots as Unassigned', value:'unassign', primary:true}, {label:'Delete shots', value:'deleteShots', danger:true}, {label:'Cancel', value:null} ], cancelText:'Cancel' }); if(choice === null) return; if(choice === 'deleteShots'){ const g = curGame(); for(const per in g.periodShots){ g.periodShots[per] = (g.periodShots[per]||[]).filter(s=>s.playerId!==playerId); } } else if(choice === 'unassign'){ const g = curGame(); for(const per in g.periodShots){ (g.periodShots[per]||[]).forEach(s=>{ if(s.playerId===playerId) s.playerId = UNASSIGNED_ID; }); } } } else { const ok = await modal.confirm({ title:'Delete Player?', sub:`Delete "${p.name}"?`, okText:'Delete', cancelText:'Cancel', danger:true }); if(!ok) return; } state.players = state.players.filter(x=>x.id!==playerId); if(state.playerFilter === playerId) state.playerFilter = 'all'; if(state.lastPlayerId === playerId) state.lastPlayerId = null; toast('Player deleted'); renderAll(); } async function clearUnassignedShots(){ const g = curGame(); const all = allShotsRawNoFilter(); const count = all.filter(s=>!s.playerId || s.playerId===UNASSIGNED_ID).length; if(!count){ toast('No unassigned shots'); return; } const ok = await modal.confirm({ title:'Clear Unassigned?', sub:`Delete ${count} unassigned shot(s)?`, okText:'Delete', cancelText:'Cancel', danger:true }); if(!ok) return; for(const per in g.periodShots){ g.periodShots[per] = (g.periodShots[per]||[]).filter(s=>s.playerId && s.playerId!==UNASSIGNED_ID); } toast('Unassigned cleared'); renderAll(); } function buildPlayerSelectHTML(selectedId){ const opts = []; opts.push(``); state.players.forEach(p=>{ opts.push(``); }); return opts.join(''); } async function openShotEditor(shotId){ const found = findShotById(shotId); if(!found){ toast('Shot not found'); return; } const s = found.shot; const g = curGame(); const perLabel = (g.periodType==='quarters'?'Q':'H') + (s.period || found.period || '?'); const bodyHTML = `
${escapeHtml(perLabel)} • ${escapeHtml(s.zone||'')}
${new Date(s.ts||Date.now()).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}
Player
`; const pName = playerNameById(s.playerId || UNASSIGNED_ID); const resultPromise = modal.actionSheet({ title:'Edit Shot', sub:`${s.made?'MAKE':'MISS'} • ${escapeHtml(pName)}`, bodyHTML, actions:[ {label:'Save', value:'save', primary:true}, ], cancelText:'Close' }); setTimeout(()=>{ const wrap = document.getElementById('sb_body_modal'); if(!wrap) return; const toggleBtn = wrap.querySelector('#sb_shot_toggle'); const delBtn = wrap.querySelector('#sb_shot_delete'); if(toggleBtn && !toggleBtn._sbBound){ toggleBtn._sbBound = true; toggleBtn.addEventListener('click', async ()=>{ const f = findShotById(shotId); if(!f){ toast('Shot not found'); return; } f.shot.made = !f.shot.made; toast('Toggled'); renderAll(); toggleBtn.textContent = f.shot.made ? 'Toggle to MISS' : 'Toggle to MAKE'; toggleBtn.style.background = f.shot.made ? 'rgba(255,77,109,.14)' : 'rgba(53,194,255,.18)'; }); } if(delBtn && !delBtn._sbBound){ delBtn._sbBound = true; delBtn.addEventListener('click', async ()=>{ const ok = await modal.confirm({ title:'Delete Shot?', sub:'Remove permanently?', okText:'Delete', cancelText:'Cancel', danger:true }); if(!ok) return; if(deleteShotById(shotId)){ toast('Shot deleted'); renderAll(); } else { toast('Shot not found'); } }); } }, 50); const action = await resultPromise; if(action !== 'save') return; const wrap = document.getElementById('sb_body_modal'); const sel = wrap ? wrap.querySelector('#sb_shot_player') : null; const newPid = sel ? sel.value : (s.playerId || UNASSIGNED_ID); updateShotById(shotId, { playerId: newPid || UNASSIGNED_ID }); toast('Shot updated'); renderAll(); } function exportToExcel(){ if(typeof XLSX === 'undefined'){ toast('Excel library loading...'); return; } const g = curGame(); const allShots = allShotsRawNoFilter(); const fts = g.freeThrows || []; // Create workbook const wb = XLSX.utils.book_new(); // Sheet 1: All Shots const shotsData = allShots.map(s => { const p = state.players.find(pl => pl.id === s.playerId); return { 'Player': p ? p.name : (s.playerId === UNASSIGNED_ID ? 'Unassigned' : 'Unknown'), 'Period': s.period || 1, 'Made': s.made ? 'Yes' : 'No', 'Points': s.made ? (s.is3 ? 3 : 2) : 0, 'Shot Type': s.is3 ? '3PT' : '2PT', 'Zone': s.zone || 'Unknown', 'Timestamp': s.ts ? new Date(s.ts).toLocaleString() : '' }; }); const wsShots = XLSX.utils.json_to_sheet(shotsData.length ? shotsData : [{ 'Player': 'No shots recorded' }]); XLSX.utils.book_append_sheet(wb, wsShots, 'Shots'); // Sheet 2: Player Stats const playerStats = state.players.map(p => { const pShots = allShots.filter(s => s.playerId === p.id); const pFts = fts.filter(f => f.playerId === p.id); const makes = pShots.filter(s => s.made).length; const total = pShots.length; const makes2 = pShots.filter(s => s.made && !s.is3).length; const total2 = pShots.filter(s => !s.is3).length; const makes3 = pShots.filter(s => s.made && s.is3).length; const total3 = pShots.filter(s => s.is3).length; const ftm = pFts.filter(f => f.made).length; const fta = pFts.length; const pts = (makes2 * 2) + (makes3 * 3) + ftm; return { 'Player': p.name, 'FGM': makes, 'FGA': total, 'FG%': total ? (makes/total*100).toFixed(1) + '%' : '-', '2PM': makes2, '2PA': total2, '2P%': total2 ? (makes2/total2*100).toFixed(1) + '%' : '-', '3PM': makes3, '3PA': total3, '3P%': total3 ? (makes3/total3*100).toFixed(1) + '%' : '-', 'FTM': ftm, 'FTA': fta, 'FT%': fta ? (ftm/fta*100).toFixed(1) + '%' : '-', 'PTS': pts, 'eFG%': total ? ((makes + 0.5*makes3)/total*100).toFixed(1) + '%' : '-', 'TS%': (2*(total + 0.44*fta)) ? (pts/(2*(total + 0.44*fta))*100).toFixed(1) + '%' : '-' }; }); const wsPlayers = XLSX.utils.json_to_sheet(playerStats.length ? playerStats : [{ 'Player': 'No players' }]); XLSX.utils.book_append_sheet(wb, wsPlayers, 'Player Stats'); // Sheet 3: Zone Stats const zones = ['At rim', 'Paint (non-RA)', 'Mid-range', 'Corner 3', 'Above the break 3']; const zoneStats = zones.map(z => { const zShots = allShots.filter(s => s.zone === z); const m = zShots.filter(s => s.made).length; const t = zShots.length; return { 'Zone': z, 'Makes': m, 'Attempts': t, 'FG%': t ? (m/t*100).toFixed(1) + '%' : '-', 'Points': zShots.filter(s=>s.made).reduce((sum,s)=> sum + (s.is3?3:2), 0) }; }); const wsZones = XLSX.utils.json_to_sheet(zoneStats); XLSX.utils.book_append_sheet(wb, wsZones, 'Zone Stats'); // Sheet 4: Free Throws const ftData = fts.map(f => { const p = state.players.find(pl => pl.id === f.playerId); return { 'Player': p ? p.name : 'Unknown', 'Period': f.period || 1, 'Made': f.made ? 'Yes' : 'No', 'Timestamp': f.ts ? new Date(f.ts).toLocaleString() : '' }; }); const wsFT = XLSX.utils.json_to_sheet(ftData.length ? ftData : [{ 'Player': 'No free throws recorded' }]); XLSX.utils.book_append_sheet(wb, wsFT, 'Free Throws'); // Sheet 5: Game Summary const makes = allShots.filter(s=>s.made).length; const total = allShots.length; const makes3 = allShots.filter(s=>s.made && s.is3).length; const total3 = allShots.filter(s=>s.is3).length; const makes2 = allShots.filter(s=>s.made && !s.is3).length; const total2 = allShots.filter(s=>!s.is3).length; const ftm = fts.filter(f=>f.made).length; const fta = fts.length; const totalPts = (makes2*2) + (makes3*3) + ftm; const summaryData = [ { 'Stat': 'Game', 'Value': g.name || 'Game' }, { 'Stat': 'Date', 'Value': g.date || new Date().toLocaleDateString() }, { 'Stat': 'Total Points', 'Value': totalPts }, { 'Stat': 'FGM-FGA', 'Value': `${makes}-${total}` }, { 'Stat': 'FG%', 'Value': total ? (makes/total*100).toFixed(1) + '%' : '-' }, { 'Stat': '2PM-2PA', 'Value': `${makes2}-${total2}` }, { 'Stat': '2P%', 'Value': total2 ? (makes2/total2*100).toFixed(1) + '%' : '-' }, { 'Stat': '3PM-3PA', 'Value': `${makes3}-${total3}` }, { 'Stat': '3P%', 'Value': total3 ? (makes3/total3*100).toFixed(1) + '%' : '-' }, { 'Stat': 'FTM-FTA', 'Value': `${ftm}-${fta}` }, { 'Stat': 'FT%', 'Value': fta ? (ftm/fta*100).toFixed(1) + '%' : '-' }, { 'Stat': 'eFG%', 'Value': total ? ((makes + 0.5*makes3)/total*100).toFixed(1) + '%' : '-' }, { 'Stat': 'TS%', 'Value': (2*(total + 0.44*fta)) ? (totalPts/(2*(total + 0.44*fta))*100).toFixed(1) + '%' : '-' } ]; const wsSummary = XLSX.utils.json_to_sheet(summaryData); XLSX.utils.book_append_sheet(wb, wsSummary, 'Summary'); // Generate filename and download const filename = `${(g.name || 'game').replace(/[^a-z0-9]/gi, '_')}_stats.xlsx`; XLSX.writeFile(wb, filename); toast('Excel exported!'); } // Events el.gameSelect.addEventListener('change', (e)=>{ state.currentGameId = e.target.value; state.viewingPeriod=null; closePicker(); renderAll(); }); el.playerFilter.addEventListener('change', (e)=>{ state.playerFilter = e.target.value; renderAll(); }); el.newGame.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); newGame(); }); el.dupGame.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); duplicateGame(); }); el.deleteGame.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); deleteGame(); }); el.oppScore.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); editOpponentScore(); }); el.reset.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); resetGame(); }); el.export.addEventListener('click', async (e)=>{ e.preventDefault(); e.stopPropagation(); const choice = await modal.choose({ title: 'Export & Share', sub: 'Choose export format', choices: [ {label: '📄 PDF Summary', sub: 'Print-ready report', value: 'pdf', primary: true}, {label: '📊 Excel', sub: 'Spreadsheet with all stats', value: 'excel'}, {label: '🖼️ PNG Image', sub: 'Shot chart for social media', value: 'png'}, {label: '🔗 Share Link', sub: 'Copy link to clipboard', value: 'share'} ] }); if(choice === 'pdf') generateSummaryPdf(); else if(choice === 'excel') exportToExcel(); else if(choice === 'png') exportPng(); else if(choice === 'share') share(); }); el.undo.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); undo(); }); el.quickUndo.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); undo(); }); el.ftMake.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); recordFreeThrow(true); }); el.ftMiss.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); recordFreeThrow(false); }); el.addPlayer.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); addPlayer(); }); el.bulkImport.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); bulkImportPlayers(); }); el.aiAnalyze.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); runAiAnalysis(); }); el.seasonStats.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); showSeasonStats(); }); el.playerCompare.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); showPlayerCompare(); }); el.shotTendencies.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); showShotTendencies(); }); // Live Mode listeners el.liveMode.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); enterLiveMode(); }); el.exitLive.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); exitLiveMode(); }); el.liveUndo.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); if(livePendingShot){ livePendingShot = null; toast('Pending shot cancelled'); renderLiveMode(); } else { undo(); renderLiveMode(); } }); el.liveNextPeriod.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); nextPeriod(); renderLiveMode(); }); el.liveFtMake.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); liveRecordFt(true); }); el.liveFtMiss.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); liveRecordFt(false); }); el.liveOpp1.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); liveAdjustOppScore(1); }); el.liveOpp2.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); liveAdjustOppScore(2); }); el.liveOpp3.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); liveAdjustOppScore(3); }); el.liveOppMinus.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); liveAdjustOppScore(-1); }); // Tutorial listeners el.helpTour.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); showTutorial(); }); el.tutNext.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); tutorialNext(); }); el.tutPrev.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); tutorialPrev(); }); el.tutorial.querySelector('.tutorialBackdrop').addEventListener('click', ()=> hideTutorial()); el.statsHelp.addEventListener('click', async (e)=>{ e.preventDefault(); e.stopPropagation(); const statsHTML = `
FG% (Field Goal %)
Makes ÷ Attempts. Basic shooting accuracy.
2PT (2-Point %)
Percentage on shots inside the 3-point line.
3PT (3-Point %)
Percentage on shots beyond the 3-point arc.
FT% (Free Throw %)
Percentage on free throw attempts.
PTS (Points)
Total points scored (2s + 3s + FTs).
eFG% (Effective FG %)
(FGM + 0.5 × 3PM) ÷ FGA. Gives 3-pointers extra credit since they're worth more.
TS% (True Shooting %)
PTS ÷ (2 × (FGA + 0.44 × FTA)). Overall scoring efficiency including free throws.
`; await modal.actionSheet({ title: 'Stats Glossary', sub: 'Tap any stat explanation below', bodyHTML: statsHTML, actions: [], cancelText: 'Got it' }); }); el.roster.addEventListener('click', (e)=>{ const b = e.target.closest('button'); if(!b) return; const act = b.dataset.act; const id = b.dataset.id; if(act === 'edit' && id) editPlayer(id); if(act === 'delete' && id) deletePlayer(id); if(act === 'clearUnassigned') clearUnassignedShots(); }); el.pickerClose.addEventListener('click', ()=>{ closePicker(); renderAll(); }); el.pickerBackdrop.addEventListener('click', ()=>{ closePicker(); renderAll(); }); el.periodNav.addEventListener('click', async (e)=>{ const b = e.target.closest('.periodNavBtn'); if(!b) return; const g = curGame(); if(b.dataset.period){ const p = parseInt(b.dataset.period,10); if(p === g.currentPeriod && state.viewingPeriod===null) return; state.viewingPeriod = p; closePicker(); renderAll(); return; } const act = b.dataset.action; if(act === 'total'){ state.viewingPeriod='total'; closePicker(); renderAll(); return; } if(act === 'resume'){ state.viewingPeriod=null; closePicker(); renderAll(); return; } if(act === 'next'){ await nextPeriod(); return; } if(act === 'final'){ await finalScore(); return; } }); el.pickerGrid.addEventListener('click', (e)=>{ const b = e.target.closest('.pickBtn'); if(!b) return; const pid = b.dataset.playerId; commitShot(pid); }); el.markers.addEventListener('pointerdown', (e)=>{ const c = e.target.closest('circle'); if(!c) return; e.preventDefault(); e.stopPropagation(); if(state.viewingPeriod !== null){ toast('Resume to edit shots'); return; } const shotId = c.dataset.shotId; if(!shotId) return; openShotEditor(shotId); }); let lastTap = 0; let lastPos = null; let tapTimeout = null; el.court.addEventListener('pointerdown', (e)=>{ if(state.viewingPeriod !== null) return; if(e.target.closest('circle')) return; e.preventDefault(); e.stopPropagation(); const now = Date.now(); const pos = svgPoint(e.clientX, e.clientY); const wrapPos = svgToWrapPx(pos.x, pos.y); const isLive = root.classList.contains('live-mode'); const isDouble = (lastPos && now - lastTap < DOUBLE_TAP_MS && Math.hypot(pos.x-lastPos.x, pos.y-lastPos.y) < 30); // Clear any pending single-tap timeout if(tapTimeout) { clearTimeout(tapTimeout); tapTimeout = null; } if(isDouble){ // Double tap detected - it's a MAKE if(isLive){ liveRecordShot(pos.x, pos.y, true); } else { state.pendingShot = { x: pos.x, y: pos.y, made: true }; openPickerAt(wrapPos.x, wrapPos.y, true); } lastTap = 0; lastPos = null; } else { // First tap - wait to see if second tap comes lastTap = now; lastPos = pos; tapTimeout = setTimeout(()=>{ // No second tap came - it's a MISS if(isLive){ liveRecordShot(pos.x, pos.y, false); } else { state.pendingShot = { x: pos.x, y: pos.y, made: false }; openPickerAt(wrapPos.x, wrapPos.y, false); } tapTimeout = null; }, DOUBLE_TAP_MS); } }); load(); loadFromHash(); if(!state.currentGameId) state.currentGameId = state.games[0].id; renderAll(); checkFirstVisit(); } if(document.readyState === 'loading'){ document.addEventListener('DOMContentLoaded', init); } else { init(); } })();