VDM Basketball Playbook
🌐
English
Español
Italiano
Français
Português
Deutsch
Lietuvių
Polski
Русский
中文
日本語
한국어
🏀
VDM BASKETBALL COACH
BASKETBALL COACHING TOOLS
Tagga video, disegna schemi, tieni lo scouting report — tutto in un unico posto.
📹
Analizza una partita singola
Importa un video e taggalo subito: solo Video, niente lega da creare.
Crea o gestisci una lega
Squadre, roster, playbook, scouting report e allenamenti per l'intera stagione.
Passo 1 · Crea la lega
Benvenuto! Iniziamo con la tua prima lega
Una lega raggruppa le tue squadre. Creane una per sbloccare tutto il resto: video taggati, schemi di gioco, scouting report e piani di allenamento — ogni squadra con il suo spazio, sempre a portata di mano.
🎥 Video
📋 Playbook
🔎 Scouting Report
🏋️ Practice
📖 Istruzioni
Crea la tua prima lega
Passo 2 · Aggiungi squadra
Ora aggiungi una squadra
Ogni squadra ha il suo roster, i video taggati, gli schemi di gioco, lo scouting report e i piani di allenamento — tutto pronto non appena la crei.
👕 Aggiungi la tua prima squadra
Prototipo web: Lega → Squadra → Roster, con cinque voci di menu qui sopra — Video, Playbook (schemi disegnati sul campo),
Scouting Report (schede player + posizioni + calendario impegni), Practice (piani di allenamento: sedute → fasi →
esercizi, con schizzo sul campo per esercizio) e Istruzioni (guida organizzativa) — tutte nello stesso record squadra,
così le info inserite restano lì automaticamente ogni volta che riapri quella squadra. Il logo della squadra, una volta
inserito, si ripete da solo in tutte queste sezioni: non va reinserito. Playbook e Scouting Report sono nella prima
versione senza schemi multi-fase animati/export GIF (arriveranno se servono). Non ancora incluse: import automatico
roster/calendario dal sito della lega, collegamento cartella locale persistente. Il selettore lingua in alto copre 7
lingue (italiano, inglese, spagnolo, francese, cinese, russo, tedesco). Il salvataggio è tramite Esporta/Importa JSON
(nessun dato viene mai perso in automatico dal browser).
Il tagging video è legato a una squadra: crea o seleziona una lega e una squadra dal menu in alto per importare e taggare i video. Playbook, invece, si può usare anche senza squadra selezionata — apri quella voce di menu qui accanto.
✂️ Edit Video
🎞️ Apri un filmato dal disco
+ Capitolo
Qui si monta il filmato: scegli i pezzi, mettili in ordine, disegnaci sopra (fascio di luce, cerchi, frecce), ferma l'immagine dove serve, scrivi sopra la clip e separa i capitoli con un fotogramma col logo della squadra. Poi "Monta ed esporta" li unisce in un unico video.
Nessun pezzo taggato per questa squadra: vai nella scheda Video, segna Inizio e Fine e assegna una giocata — i pezzi compaiono qui.
Premi ▶ su un pezzo per vederlo qui, con i suoi disegni
▶
▶ Riproduci sequenza
⏹ Ferma
✏️ Disegna sul pezzo
Strumenti disponibili su ogni pezzo: 🔦 fascio di luce dall'alto · ⭕ cerchio · ➟ freccia · 🧱 muro del blocco · 🅣 scritta · 🐢 rallenty · ⏸ fermo immagine. Si aprono con ✏️ qui sopra o con il pulsante ✏️ sulla riga del pezzo.
🎬 Montaggio
+ Tutti
Svuota
🎬 Monta ed esporta
Scegli i pezzi da montare e mettili nell'ordine che vuoi con le frecce. Il file finale li unisce in quest'ordine.
Playbook — Schemi Attacco/Difesa
Nessuna squadra selezionata: gli schemi che disegni qui vengono salvati in una libreria generale (non legata a una squadra). Selezionane una in seguito, se vuoi, per organizzarli per squadra.
Ogni foglio è una griglia 4x4: 4 giochi per foglio, 4 diagrammi (varianti) per gioco. "+ Aggiungi gioco" per crearne uno — pieno un foglio, il prossimo gioco parte in automatico su un foglio nuovo. Clicca su un diagramma per aprirlo subito con canvas e strumenti e disegnarci sopra — se è vuoto e non è il primo, riparte dall'ultimo disegno della casella precedente, pronto da modificare per il passo successivo dell'azione.
+ Aggiungi gioco
📚 Libreria giochi
🖨️ Stampa foglio schemi
Squadre della lega
+ Nuova Squadra
Clicca una squadra per aprirla: ritrovi il suo roster, i suoi giochi e le sue difese. Il logo che carichi qui la segue in tutta l'app e nelle stampe.
🎬 Clip della lega
Tutti i video caricati per le squadre di questa lega. Clicca una clip per aprirla nel Video con i suoi tag.
🆚 Squadre Avversarie
🌍 Precompila Girone D (Mondiale)
🖨️ Stampa rose avversarie
Seleziona o crea prima una lega qui sopra: le squadre avversarie sono condivise fra tutte le squadre di quella lega.
Un archivio per lega: le squadre della lega con nome e logo. Clicca su un box per aprire il suo roster — 12 schede player già pronte da compilare.
Personnel — Schede player
🎥 Tagga il video con questi player
🔄 Aggiorna foglio
↻ Ridisponi
🤖 USO IA
🖨️ Stampa Personnel
Numero, nome e ruolo arrivano dal Roster (Anagrafica squadra) qui sopra — non vanno reinseriti: qui aggiungi solo foto, altezza, anno di nascita, statistiche e caratteristiche.
📄 Anteprima — si aggiorna mentre scrivi
Personnel e calendario mostrano roster, foto e impegni di una squadra specifica: crea o seleziona una squadra dal menu in alto per vederli. Il Playbook, invece, funziona anche senza squadra selezionata.
Team Report
🖨️ Stampa Scouting Report
Le categorie di scouting su 3 colonne, con la sezione finale delle statistiche — valide per qualunque squadra, non solo per le avversarie: un punto per riga.
🆚 Avversario da studiare
Nessun avversario ancora. Aggiungine uno per iniziare a compilare il suo Scouting Report.
⚡ Punti di forza
Forza mentale
Difesa
Attacco
🎯 Per vincere
Forza mentale
Attacco
Difesa
🎯 Migliori tiratrici da 3
⚠️ Peggiori tiratrici tiri liberi
📊 Statistiche
📊 Statistiche di squadra
Rimbalzi Off.
Tiro da 3pt
Palle perse
Recuperi
✅ Miglior tiratrice di TL
📄 Anteprima — si aggiorna mentre scrivi
I piani di allenamento sono legati a una squadra: crea o seleziona una squadra dal menu in alto per iniziare.
Practice — Piani di allenamento
🖨️ Stampa piani
+ Nuova seduta
Ogni seduta si organizza in fasi (es. riscaldamento, parte centrale, partite) e ogni fase in esercizi, con durata, descrizione e uno schizzo sul campo opzionale — stesso motore di disegno di Playbook e Scouting Report.
🏀 Allenamenti
🎯 Tiro
🎞️ Presentazione
▶ Proietta
⬇ Esporta presentazione
+ Copertina
+ Cartello
+ Scheda scouting
+ Player
🛡 Logo avversaria
+ Video
+ Foto
Colore
Svuota
La scaletta della riunione: metti in fila i montaggi che hai già esportato, i cartelli con il titolo della parte, e i video o le foto che vuoi far vedere. Poi premi Proietta: parte a schermo intero, con spazio per andare avanti e indietro.
Clicca un pezzo della barra per vederlo qui
▶
0 pezzi
0:00 durata
Trascina i box qui sotto dentro alla barra. Trascina i pezzi dentro alla barra per cambiarne l'ordine.
Montaggi già esportati
🔄 Rileggi la cartella
✍️ I tuoi appunti
Note tue sull'organizzazione della stagione: dove tieni i video, come chiami le cartelle, cosa ricordarti. Restano salvate insieme al lavoro.
◀
⏸
▶
spazio = pausa · ← → = avanti/indietro · Esc = esci
Esci
🎬 Effetti sul pezzo
Chiudi
MENU
Schizzo esercizio
✕
Campo:
Mezzo campo
Campo intero
Annulla
💾 Salva schizzo
🖍️ Lavagna sul fotogramma
Seleziona uno strumento e clicca sul fotogramma. Freccia/Cerchio: clicca il punto di partenza poi quello di arrivo. In modalità "Seleziona" trascina un elemento per spostarlo; selezionalo e premi "Rimuovi selezionato" per cancellarlo.
Chiudi
💾 Scarica immagine
Premi ▶ per vedere la sequenza
▶ Riproduci
⏹ Ferma
Chiudi
🔑 Licenza d'uso di VDM
Una licenza vale per un computer. Serve il codice qui sotto.
Chiudi
👥 Lavoro in comune
Più allenatori, la stessa cartella su server o in rete: si lavora insieme.
Chiudi
Chiudi
" finale non chiude più il blocco: il risultato è che TUTTA
// l'app smette di essere eseguita, senza nemmeno un errore in console.
// Si compongono quindi a pezzi.
const OPEN_TAG = '<' + 'script';
const CLOSE_TAG = '<' + '/script>';
html = html.replace(new RegExp(OPEN_TAG + ' id="vbcSavedState"[\\s\\S]*?' + CLOSE_TAG + '\\s*', 'i'), '');
const snap = buildWorkSnapshot();
const json = JSON.stringify(snap)
.split(CLOSE_TAG).join('<' + '\\/script>')
.split('<' + '!--').join('<' + '\\!--');
const block = OPEN_TAG + ' id="vbcSavedState" type="application/json">' + json + CLOSE_TAG + '\n';
const idx = html.lastIndexOf('');
html = (idx >= 0) ? (html.slice(0, idx) + block + html.slice(idx)) : (html + block);
const d = new Date();
const p2 = (n) => String(n).padStart(2, '0');
const stamp = d.getFullYear() + '-' + p2(d.getMonth() + 1) + '-' + p2(d.getDate()) + '_' + p2(d.getHours()) + p2(d.getMinutes());
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'VBC_' + stamp + '.html';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 4000);
toast(t('toast.savedIntoFile'));
}
/* L'ISTANTANEA DEL LAVORO — una sola volta, usata da tutti.
* E' la fotografia completa di dove sei: leghe, squadre, roster, playbook,
* scouting, practice, i tag del video, i montaggi e il punto in cui avevi
* lasciato il filmato. Prima viveva dentro al salvataggio automatico; adesso
* sta per conto suo, perche' la stessa fotografia serve anche a "Salva lavoro
* come...": il file .vdm non e' altro che questa, scritta dove dici tu. */
function costruisciIstantanea() {
return {
db: DB,
activeLeagueId, activeTeamId, activeTeamTab,
singleMatchMode, activeSingleMatchId,
// vt (clip video/tag/posizione) NON fa parte di DB — va salvato a
// parte qui. Niente url/cutUrl: sono blob: string legati alla memoria
// di QUESTA pagina, morti al prossimo caricamento — il file vero sta
// in IndexedDB (videoBlobDbSave), riletto da resumePreviousSession.
vt: {
activeClip: vt.activeClip,
montageOrder: (vt.montageOrder || []).slice(),
// Si copia TUTTA la clip togliendo solo l'indirizzo del filmato in
// memoria: prima si salvavano cinque campi scelti a mano e tutto il
// resto (sessione di lavoro, proporzioni, stato di archiviazione)
// spariva alla riapertura senza che nessuno se ne accorgesse.
clips: vt.clips.map(c => {
const copia = Object.assign({}, c);
delete copia.url;
copia.lastPosition = c.lastPosition || 0;
copia.tags = (c.tags || []).map(tg => { const q = Object.assign({}, tg); delete q.cutUrl; return q; });
return copia;
}),
sessioneAttivaId: vt.sessioneAttivaId || null,
},
savedAt: Date.now()
};
}
function autosaveNow() {
/* SENZA CHIAVE NON SI SALVA, E SOPRATTUTTO NON SI URLA. Il processo
* principale rifiuta di scrivere (LICENZA_SCADUTA) ed e' giusto cosi',
* ma prima quella promessa rifiutata non veniva raccolta da nessuno e
* finiva nella barra rossa degli errori: il collaudatore si trovava
* l'app tappezzata di scritte in inglese da programmatore invece della
* finestra che gli chiede il codice. Qui si esce prima, in silenzio. */
if (licStato && licStato.bloccata) return;
condSegnaDaInviare();
try {
const snapshot = costruisciIstantanea();
const testo = JSON.stringify(snapshot);
// Nell'app installata il lavoro va in un file vero (nessun limite di
// spazio, non si perde svuotando la cache del browser); nel browser
// resta la memoria locale come prima.
if (NATIVO) {
/* .catch() obbligatorio: se il salvataggio viene rifiutato (licenza,
* disco pieno, cartella sparita) la promessa scoperta finiva dritta
* nella barra rossa con un messaggio da programmatore. */
Promise.resolve(window.VDM.lavoro.salva(testo)).catch(() => {
if (!vbcAutosaveBroken) { vbcAutosaveBroken = true; try { toast(t('toast.autosaveFailed')); } catch (e3) {} }
});
try { localStorage.setItem(VBC_AUTOSAVE_KEY + '_mini', JSON.stringify({ savedAt: snapshot.savedAt })); } catch (e2) {}
}
else localStorage.setItem(VBC_AUTOSAVE_KEY, testo);
vbcAutosaveBroken = false;
} catch (e) {
if (!vbcAutosaveBroken) {
vbcAutosaveBroken = true;
try { toast(t('toast.autosaveFailed')); } catch (e2) {}
}
}
}
/* =====================================================================
* IL LAVORO COME FILE — Salva lavoro come... / Apri lavoro...
* Il file .vdm e' esattamente l'istantanea qui sopra, scritta dove dici tu.
* Cosi' si possono tenere lavori distinti — "Italia europei", "Rep Ceca 2" —
* e riaprirne uno esattamente com'era, invece di avere un unico lavoro che
* si sovrascrive da solo. */
let lavoroFileCorrente = null;
function lavoroNomeSuggerito() {
const sq = (typeof activeTeam === 'function' && activeTeam()) ? (activeTeam().name || '') : '';
const lg = (typeof activeLeague === 'function' && activeLeague()) ? (activeLeague().name || '') : '';
const base = (sq || lg || 'Lavoro VDM').trim();
const oggi = new Date().toISOString().slice(0, 10);
return (base + ' ' + oggi).replace(/[\\/:*?"<>|]/g, '-');
}
async function lavoroSalvaCome() {
if (!NATIVO) { toast(t('lavoro.soloApp')); return; }
try {
const testo = JSON.stringify(costruisciIstantanea());
const percorso = await window.VDM.lavoro.salvaComeFile(lavoroNomeSuggerito(), testo);
if (!percorso) return; // ha annullato: nessun rumore
lavoroFileCorrente = percorso;
toast(t('lavoro.salvato', { nome: percorso.split(/[\\/]/).pop() }));
} catch (e) { toast(t('lavoro.nonScritto')); }
}
async function lavoroApri() {
if (!NATIVO) { toast(t('lavoro.soloApp')); return; }
try {
const r = await window.VDM.lavoro.apriFile();
if (!r) return;
lavoroCarica(r);
} catch (e) { toast(t('lavoro.nonLetto')); }
}
async function lavoroApriPercorso(percorso) {
if (!NATIVO || !percorso) return;
try { lavoroCarica(await window.VDM.lavoro.leggiFile(percorso)); }
catch (e) { toast(t('lavoro.nonLetto')); }
}
function lavoroCarica(r) {
let snap = null;
try { snap = JSON.parse(r.testo); } catch (e) { snap = null; }
/* Un file che non e' un lavoro di VDM non deve MAI passare di qui: se
* applicassimo un oggetto qualunque, l'app si troverebbe senza leghe e
* sembrerebbe che il lavoro sia sparito. */
if (!snap || !snap.db || !Array.isArray(snap.db.leagues)) { toast(t('lavoro.nonLetto')); return; }
if (!applicaIstantanea(snap)) { toast(t('lavoro.nonLetto')); return; }
lavoroFileCorrente = r.percorso;
try { window.VDM.lavoro.ricorda(r.percorso); } catch (e) {}
scheduleAutosave();
toast(t('lavoro.aperto', { nome: r.nome || '' }));
}
function scheduleAutosave() {
if (vbcAutosaveTimer) clearTimeout(vbcAutosaveTimer);
vbcAutosaveTimer = setTimeout(autosaveNow, 1200);
}
let vbcSnapshotNativo = null; // letto dal file all'avvio dell'app installata
function readAutosaveSnapshot() {
try {
const raw = NATIVO ? vbcSnapshotNativo : localStorage.getItem(VBC_AUTOSAVE_KEY);
if (!raw) return null;
const snap = JSON.parse(raw);
if (!snap || !snap.db) return null;
return snap;
} catch (e) { return null; }
}
// Dopo aver ripristinato un DB salvato (autosalvataggio o Importa Lega), il
// contatore id (usato da nextId per creare NUOVI elementi) riparte sempre da
// 1: senza questo, il primo nuovo schema/giocatrice creato dopo il ripristino
// rischia di riusare un id già esistente nel DB ripristinato, sovrascrivendo
// dati a sua insaputa. Si scansiona il DB ripristinato e si riallinea il
// contatore oltre l'id numerico più alto trovato.
function reseedIdSequenceFromDB(db) {
let maxN = 0;
const scan = (val) => {
if (Array.isArray(val)) { val.forEach(scan); return; }
if (val && typeof val === 'object') {
if (typeof val.id === 'string') {
const m = val.id.match(/(\d+)$/);
if (m) { const n = parseInt(m[1], 10); if (n > maxN) maxN = n; }
}
for (const k in val) if (Object.prototype.hasOwnProperty.call(val, k)) scan(val[k]);
}
};
scan(db);
if (maxN >= seq) seq = maxN + 1;
}
function formatAutosaveWhen(ts) {
const diffMin = Math.max(0, Math.round((Date.now() - ts) / 60000));
if (diffMin < 1) return t('splash.resumeJustNow');
if (diffMin < 60) return t('splash.resumeMinutesAgo', { n: diffMin });
const diffH = Math.round(diffMin / 60);
if (diffH < 24) return t('splash.resumeHoursAgo', { n: diffH });
const diffD = Math.round(diffH / 24);
return t('splash.resumeDaysAgo', { n: diffD });
}
// Mostra (o nasconde) il banner "Continua sessione precedente" in cima alla
// splash, con un cenno a cosa contiene l'ultimo salvataggio automatico.
// Vero quando il lavoro è già in memoria perché stava dentro al file: il
// pulsante della prima pagina deve solo ENTRARE, senza ricaricare niente.
let statoCaricatoDalFile = false;
/* LE LEGHE GIA' FATTE, SUBITO NELLA PRIMA SCHERMATA.
* Prima l'unica cosa che si vedeva era "crea una lega", e ogni apertura
* sembrava un ricominciare da capo: il lavoro c'era, ma bisognava sapere che
* stava dietro al pulsante "continua". Adesso le leghe si vedono e si aprono
* con un clic, come i file recenti di un qualunque programma. */
function elencoLegheSplashHtml() {
const leghe = (DB.leagues || []);
if (!leghe.length) return '';
const righe = leghe.map(l => {
const nSq = allLeagueTeams(l, false).length;
return `
${escapeHtml(l.name || '—')}
${nSq} ${escapeHtml(t(nSq === 1 ? 'splash.resumeTeamWord' : 'splash.resumeTeamsWord'))}
`;
}).join('');
return `
${escapeHtml(t('splash.leTueLeghe'))}
${righe}
`;
}
function apriLegaDallaSchermataIniziale(id) {
const lega = (DB.leagues || []).find(l => l.id === id);
if (!lega) return;
singleMatchMode = false;
activeSingleMatchId = null;
activeLeagueId = lega.id;
// Si apre sulla squadra del coach, se c'e'; altrimenti sulla prima.
const tutte = allLeagueTeams(lega, false).map(e => e.team);
const mia = tutte.find(t2 => t2.id === lega.ownTeamId) || tutte[0];
activeTeamId = mia ? mia.id : null;
hideSplash();
renderAll();
toast(t('splash.legaAperta', { nome: lega.name || '' }));
}
function renderSplashResumeBanner() {
const el = document.getElementById('splashResumeBanner');
if (!el) return;
if (statoCaricatoDalFile) {
// Lavoro già dentro a questo file: si entra e basta, niente da ricaricare.
const nLeghe = (DB.leagues || []).length;
const nSquadre = (DB.leagues || []).reduce((n, l) => n + (l.teams || []).length, 0);
el.style.display = 'block';
el.innerHTML = elencoLegheSplashHtml() + `
📂
${escapeHtml(t('splash.fromFileTitle'))}
${nLeghe} ${escapeHtml(t(nLeghe === 1 ? 'splash.resumeLeagueWord' : 'splash.resumeLeaguesWord'))}, ${nSquadre} ${escapeHtml(t(nSquadre === 1 ? 'splash.resumeTeamWord' : 'splash.resumeTeamsWord'))} — ${escapeHtml(t('splash.fromFileDesc'))}
`;
return;
}
const snap = readAutosaveSnapshot();
if (!snap) { el.style.display = 'none'; el.innerHTML = ''; return; }
const leagues = (snap.db.leagues || []).length;
const teams = (snap.db.leagues || []).reduce((n, l) => n + (l.teams || []).length, 0);
let desc;
if (snap.singleMatchMode) desc = t('splash.resumeSingleMatchDesc');
else desc = leagues + ' ' + t(leagues === 1 ? 'splash.resumeLeagueWord' : 'splash.resumeLeaguesWord')
+ ', ' + teams + ' ' + t(teams === 1 ? 'splash.resumeTeamWord' : 'splash.resumeTeamsWord');
el.style.display = 'block';
el.innerHTML = `
🔄
${escapeHtml(t('splash.resumeTitle'))}
${escapeHtml(desc)} — ${escapeHtml(formatAutosaveWhen(snap.savedAt))}
`;
}
// Ricostruisce vt (clip/tag/posizione) dall'ultimo autosalvataggio. Gli id
// clip e i tag sono già pronti e sincroni (arrivano dal JSON in localStorage);
// il FILE video vero invece va riletto da IndexedDB in modo asincrono (può
// pesare centinaia di MB) — finché non arriva, quella clip resta con
// url=null e il player mostra "Recupero il video salvato…" (vedi
// renderVideoSection) invece di un rotto. Appena il blob della clip
// ATTIVA arriva, si ridisegna da sola sul punto lasciato (clip.lastPosition,
// vedi renderVideoSection/applyPlayhead) — le altre clip si aggiornano
// silenziosamente e compaiono pronte al prossimo cambio scheda/clip.
function restoreVideoStateFromSnapshot(vtSnap) {
if (!vtSnap || !Array.isArray(vtSnap.clips)) return;
vt.clips = vtSnap.clips.map(c => Object.assign({}, c, { url: null }));
vt.activeClip = vtSnap.activeClip != null && vtSnap.activeClip < vt.clips.length ? vtSnap.activeClip : (vt.clips.length ? vt.clips.length - 1 : -1);
vt.trimStart = null; vt.trimEnd = null; vt.pendingTagId = null;
vt.montageOrder = Array.isArray(vtSnap.montageOrder) ? vtSnap.montageOrder.slice() : [];
// I file dei pezzi già tagliati sono blob legati alla pagina precedente:
// dopo un ripristino non esistono più. Se resta scritto "pronto" senza il
// file, il link di scarico punta a "undefined" e il pulsante ✂️ per
// ritagliare non compare nemmeno — il pezzo diventa irrecuperabile.
// Si azzera lo stato: il tag torna semplicemente "da tagliare".
vt.clips.forEach(clip => {
(clip.tags || []).forEach(tag => {
if (!tag.cutUrl && tag.cutStatus) { delete tag.cutStatus; delete tag.cutExt; }
});
});
vt.sessioneAttivaId = vtSnap.sessioneAttivaId || null;
ricollegaTagliSuDisco();
sistemaSessioni();
vt.clips.forEach((clip, idx) => {
// Sessione gia' chiusa: il filmato pesante e' stato buttato apposta, non
// lo si va a cercare (restano i pezzi tagliati, che bastano).
if (clip.archiviata) return;
const pronto = (url) => {
if (url) clip.url = url;
// RICERCA FINITA, con o senza successo. Serve a non lasciare la
// clessidra girare all'infinito quando il filmato non c'e' piu': prima
// il player restava su "Recupero il video salvato..." per sempre, senza
// dire niente e senza un modo per uscirne.
clip.ricercaFinita = true;
if (idx === vt.activeClip && activeTeamTab === 'video') renderVideoSection();
};
// All'avvio si usa solo quello che si sa gia' (percorso salvato o copia
// nell'archivio): NIENTE frugate nelle cartelle, o l'app si apre e resta
// ferma. La ricerca la si fa quando serve davvero, o quando la chiedi.
if (NATIVO) urlSenzaCercare(clip).then(pronto).catch(() => pronto(null));
else videoBlobDbLoad(clip.id).then(blob => pronto(blob ? URL.createObjectURL(blob) : null)).catch(() => pronto(null));
});
}
/* Rimette l'app esattamente dov'era quando quell'istantanea e' stata presa.
* La usano tutte e due le strade: "continua sessione precedente" e l'apertura
* di un file .vdm salvato da te. */
function applicaIstantanea(snap) {
if (!snap || !snap.db) return false;
DB = snap.db;
if (!Array.isArray(DB.singleMatches)) DB.singleMatches = [];
reseedIdSequenceFromDB(DB);
singleMatchMode = !!snap.singleMatchMode;
activeSingleMatchId = snap.activeSingleMatchId || null;
activeLeagueId = snap.activeLeagueId || null;
activeTeamId = snap.activeTeamId || null;
activeTeamTab = snap.activeTeamTab || 'video';
restoreVideoStateFromSnapshot(snap.vt);
hideSplash();
renderAll();
return true;
}
function resumePreviousSession(silent) {
const snap = readAutosaveSnapshot();
if (!snap) return false;
if (!applicaIstantanea(snap)) return false;
if (!silent) toast(t('toast.sessionResumed'));
return true;
}
// Il coach non deve ridigitare lega/squadra ogni volta che chiude e riapre
// il browser: se esiste già un autosalvataggio valido con una lega/squadra
// attiva, si riparte DIRETTAMENTE da lì (niente splash, niente click su
// "Continua sessione precedente" — quel banner/pulsante resta comunque
// utile per tornare indietro dopo "↩️ Home" in modalità partita singola).
/* IL LAVORO C'E' GIA' QUANDO APRI, ANCHE DOPO UN AGGIORNAMENTO.
* Il lavoro sta in una cartella tutta sua, che l'aggiornamento non tocca mai:
* si sostituisce solo il programma. Ma finche' non lo si caricava in memoria,
* la prima schermata restava vuota — niente leghe nell'elenco — e sembrava
* che tutto fosse sparito. Adesso all'avvio il lavoro viene RIPRESO SUBITO in
* memoria (le tue leghe compaiono nell'elenco della prima pagina), pero' si
* resta sulla prima pagina: si entra quando lo decidi tu.
* Non calpesta mai niente: se in memoria c'e' gia' del lavoro, non tocca nulla. */
function caricaLavoroInMemoria() {
const snap = readAutosaveSnapshot();
if (!snap || !snap.db) return false;
if (statoCaricatoDalFile) return false;
if ((DB.leagues || []).length || (DB.singleMatches || []).length) return false;
DB = snap.db;
if (!Array.isArray(DB.singleMatches)) DB.singleMatches = [];
reseedIdSequenceFromDB(DB);
singleMatchMode = false;
activeSingleMatchId = null;
activeLeagueId = snap.activeLeagueId || null;
activeTeamId = snap.activeTeamId || null;
activeTeamTab = snap.activeTeamTab || 'squadre';
try { restoreVideoStateFromSnapshot(snap.vt); } catch (e) {}
statoCaricatoDalFile = true;
renderAll();
renderSplashResumeBanner();
return true;
}
function autoResumeOnBoot() {
const snap = readAutosaveSnapshot();
if (!snap) return false;
const hadActiveWork = !!(snap.activeLeagueId || snap.activeTeamId || snap.singleMatchMode);
if (!hadActiveWork) return false;
return resumePreviousSession(true);
}
function startSingleMatchMode() {
const coachName = document.getElementById('splashCoachName').value.trim();
rememberCoachName(coachName);
const matchName = document.getElementById('splashTeamName').value.trim() || t('splash.defaultMatchName');
rememberSplashTeamName(matchName);
const folderRef = document.getElementById('splashFolderRef').value.trim();
if (folderRef) DB.folderRef = folderRef;
const match = newTeamShapedRecord(matchName, { coachName });
ensureSingleMatches().push(match);
singleMatchMode = true;
activeSingleMatchId = match.id;
activeTeamTab = 'video';
hideSplash();
renderAll();
}
function startLeagueMode() {
const folderRef = document.getElementById('splashFolderRef').value.trim();
if (folderRef) DB.folderRef = folderRef;
rememberCoachName(document.getElementById('splashCoachName').value.trim());
singleMatchMode = false;
activeSingleMatchId = null;
hideSplash();
renderAll();
}
// Torna alla schermata iniziale senza perdere i dati della partita singola
// (resta salvata in DB.singleMatches, la si potrebbe anche solo nascondere
// invece di ributtarla via — qui usciamo semplicemente dalla modalità).
function exitSingleMatchMode() {
singleMatchMode = false;
activeSingleMatchId = null;
document.getElementById('splashScreen').style.display = 'flex';
// Se una cartella di riferimento è già stata scritta in precedenza, la
// ripropone invece di far ripartire il campo vuoto.
const folderInput = document.getElementById('splashFolderRef');
if (folderInput) folderInput.value = DB.folderRef || '';
renderSplashResumeBanner();
}
function hideSplash() {
// Difesa: se la schermata d'apertura non c'e' piu' in pagina, qui si
// leggeva .style su un elemento inesistente e l'app si fermava di colpo
// proprio mentre stava rimettendo dentro un lavoro salvato.
const sp = document.getElementById('splashScreen');
if (sp) sp.style.display = 'none';
chiediCartellaClip();
}
/* ============ "DOVE VUOI SALVARE LE CLIP?" ============
* Si chiede subito, appena si entra: e' la domanda che rende utile tutto il
* resto. Finche' non c'e' una cartella, i pezzi vengono tagliati ma restano
* dentro all'app e il coach non se ne accorge. Se risponde "piu' tardi" la
* domanda torna alla prossima apertura; se spunta "non chiedermelo piu'"
* non si fa piu' vedere e resta il tasto 📁 in alto. */
function chiediCartellaClip() {
setTimeout(() => {
if (!NATIVO) return; // nel browser la cartella e' un'altra storia
if (cartellaDiRiferimentoPronta()) return; // gia' scelta
if (DB.nonChiedereCartella) return; // il coach ha detto di no
const ov = document.getElementById('cartellaOverlay');
if (!ov || ov.classList.contains('show')) return;
// non ci si mette sopra a un'altra finestra gia' aperta
if (document.querySelector('.modal-overlay.show')) return;
const spunta = document.getElementById('cartellaNonPiu');
if (spunta) spunta.checked = false;
applyTranslations();
ov.classList.add('show');
}, 700);
}
function cartellaChiediChiudi() {
const ov = document.getElementById('cartellaOverlay');
if (ov) ov.classList.remove('show');
}
function cartellaChiediDopo() {
const spunta = document.getElementById('cartellaNonPiu');
if (spunta && spunta.checked) { DB.nonChiedereCartella = true; scheduleAutosave(); }
cartellaChiediChiudi();
}
async function cartellaChiediScegli() {
cartellaChiediChiudi();
await pickFolderRef();
// Scelta la cartella, i pezzi gia' tagliati in questa sessione ci vanno
// subito: non ha senso farglieli rifare a mano.
if (cartellaDiRiferimentoPronta() && currentClip()) {
const daFare = (currentClip().tags || []).some(tg => tg.cutUrl && !tg.cutFile);
if (daFare) salvaTutteLeClipNellaCartella();
}
}
// --------------------------------------------------------------- ISTRUZIONI
// Guida generale (non legata a una squadra), pensata per l'organizzazione dei
// file FUORI dall'app — l'app gira nel browser e non può creare cartelle sul
// computer del coach, quindi qui restano istruzioni testuali da seguire.
// Il testo di partenza degli appunti esce dalle traduzioni (chiave
// istruzioni.note, presente in tutte e 12 le lingue). Prima queste tre
// funzioni chiamavano una costante DEFAULT_INSTRUCTIONS_BY_LANG che non
// esiste piu' nel file: bastava aprire la pagina Istruzioni per andare in
// errore, e la pagina non si apriva affatto.
function istruzioniDiPartenza(lang) {
const dizio = I18N[lang || currentLang] || {};
return dizio['istruzioni.note']
|| (I18N.en && I18N.en['istruzioni.note'])
|| (I18N.it && I18N.it['istruzioni.note'])
|| '';
}
function istruzioniDiPartenzaOgniLingua() {
return Object.keys(I18N).map(l => istruzioniDiPartenza(l)).filter(Boolean);
}
function ensureInstructions() {
if (typeof DB.instructions !== 'string') DB.instructions = istruzioniDiPartenza();
return DB.instructions;
}
function updateInstructions(value) {
DB.instructions = value;
}
function renderIstruzioniSection() {
const el = document.getElementById('istruzioniText');
if (!el) return;
// Se il testo e' ancora esattamente quello di partenza (mai toccato dal
// coach) lo si riallinea alla lingua scelta; se invece ci ha scritto lui,
// non si tocca.
try {
if (istruzioniDiPartenzaOgniLingua().includes(DB.instructions)) DB.instructions = istruzioniDiPartenza();
} catch (e) {}
el.value = ensureInstructions();
}
let activeTeamTab = 'video'; // 'video' | 'playbook' | 'personnel' | 'calendario' — sotto-schede della squadra selezionata, tutte nello stesso record squadra
let seq = 1;
/* Versione di QUESTO file. Chrome numera i file scaricati (_25, _26, _27...)
* ed è facilissimo riaprire per sbaglio quello di due ore prima: questa
* targhetta in alto dice esattamente quale versione si sta usando. */
/* ============== MODALITÀ APP INSTALLATA ==============
* Lo stesso file gira in due modi: dentro al browser (come sempre) oppure
* dentro all'applicazione installata (Windows/Mac). Quando c'è il ponte
* "VDM" — creato da src/main/preload.js — si usano le cartelle vere del
* computer, i filmati stanno su disco e il lavoro si salva in un file
* dell'applicazione invece che nella memoria del browser (che è piccola e
* si svuota con la cache). Tutto il resto del codice non cambia. */
const NATIVO = !!(window.VDM && window.VDM.nativo);
/* SU MAC LA FINESTRA NON HA LA BARRA DEL TITOLO: i tre pallini stanno
* dentro la testata dell'app (vedi titleBarStyle in main.js). Questa
* classe accende lo spazio a sinistra e la zona da afferrare. Nel browser
* o su Windows non si accende, e la testata resta com'era. */
try {
if (NATIVO && /Mac OS X/.test(navigator.userAgent)) {
document.body.classList.add('mac');
}
} catch (e) {}
/* ============== EDIZIONE BETA (versione da far provare) ==============
* Stessa app, stesso codice: cambia solo un'etichetta messa al momento di
* impacchettarla. La beta serve a far vedere come funziona senza regalare
* il lavoro di una stagione: si prova tutto, ma con dei tetti — una lega,
* tre squadre, pochi tag per video, niente montaggi lunghi e niente lavoro
* in comune (che è la parte che si vende alle società). Sui fogli stampati
* resta scritto BETA, così una stampa fatta con la versione di prova non
* gira in giro come se fosse il prodotto vero. */
let EDIZIONE = 'completa';
// Una partita arriva spesso spezzata in 4-6 file: con due soli filmati la
// prova non permetteva nemmeno di provare una sessione di lavoro vera.
/* I LIMITI DELLA VERSIONE DI PROVA, in due tabelle.
* · normale — la prova che si da' in giro ai collaudatori;
* · mondiali — pacchetto fatto apposta per lo staff durante un torneo:
* cinque squadre, tag e video liberi (con dodici tag e sei
* video non si arriva nemmeno a fine primo tempo), montaggi
* ancora corti. Il profilo e' scritto DENTRO al pacchetto,
* non si sceglie dalla pagina. */
const LIMITI_BETA_NORMALE = { leghe: 1, squadre: 3, tagPerVideo: 12, video: 6, clipMontaggio: 4 };
const LIMITI_BETA_MONDIALI = { leghe: 1, squadre: 5, tagPerVideo: Infinity, video: Infinity, clipMontaggio: 4 };
let BETA_PROFILO = '';
function LIMITI_BETA_ORA() { return BETA_PROFILO === 'mondiali' ? LIMITI_BETA_MONDIALI : LIMITI_BETA_NORMALE; }
// Si continua a scrivere LIMITI_BETA.qualcosa in giro per il file: resta un
// oggetto, ma legge sempre la tabella del profilo in corso.
const LIMITI_BETA = new Proxy({}, { get: (_t, k) => LIMITI_BETA_ORA()[k] });
function beta() { return EDIZIONE === 'beta'; }
function limite(nome) { return beta() ? LIMITI_BETA_ORA()[nome] : Infinity; }
// Dice di no con gentilezza e spiega perché, invece di non fare niente.
function bloccoBeta(messaggio) {
toast(t('beta.limitToast', { messaggio: messaggio }));
return false;
}
let INFO_APP = null;
if (NATIVO) {
try {
window.VDM.info().then(i => {
INFO_APP = i;
EDIZIONE = i.edizione || 'completa';
BETA_PROFILO = i.betaProfilo || '';
if (beta()) document.body.classList.add('edizione-beta');
try { manRender((document.getElementById('manCerca') || {}).value || ''); } catch (e) {}
});
} catch (e) {}
}
const APP_BUILD = 'v74 · 3 set — righe che si spostano';
function nextId(prefix) { return prefix + (seq++); }
// ---- Video tagging state (adattato da vtState in ITALBASKET WOMAN) -------
// Misura di partenza del player: 80% del massimo che ci starebbe nello
// spazio disponibile. Era stata portata a 100% quando l'obiettivo era "il
// player più grande possibile", ma a schermo intero risultava troppo grande:
// si torna alla misura di prima. Resta tutto regolabile — cursore "↔️" e
// angolo in basso a destra del video arrivano fino al 100% — e la misura che
// scegli viene ricordata anche alla riapertura.
const VT_VIDEO_WIDTH_DEFAULT = 100;
let vt = { clips: [], activeClip: -1, trimStart: null, trimEnd: null, pendingTagId: null, videoWidthPct: VT_VIDEO_WIDTH_DEFAULT, expanded: false, montageOrder: [] };
// Modificatore di ampiezza del player, portato da ITALBASKET WOMAN
// (vtApplyVideoWidth/VT_VIDEO_WIDTH_MIN/MAX): lo slider agisce SOLO sullo
// stile inline delle tre colonne (Attacco/Video/Difesa) già in pagina, senza
// richiamare renderVideoSection() — un re-render ricrea il da zero a
// ogni trascinamento, interrompendo la riproduzione; così invece il video
// continua a suonare tranquillo mentre si trascina lo slider.
// Il cursore non spartisce più percentuali di larghezza fra le tre colonne
// (era quello a creare gli scatti e il buco nero fra le caselle e il video):
// ora indica QUANTO GRANDE è il player rispetto al massimo che ci sta nello
// spazio disponibile, a parità di proporzioni. 100 = il più grande possibile.
// Passo di 1 -> movimento continuo, non a scatti da 10 in 10.
/* IL CURSORE NON SI FERMA PIU' AL 100%.
* Fino a 100 il player cresce dentro allo spazio che avanza fra le due
* colonne dei tag. Da 100 in su continua a crescere STRINGENDO le colonne,
* fino a una larghezza in cui i nomi delle giocate si leggono ancora. Prima
* lì il cursore si bloccava e l'unico modo per avere il video più grande era
* "Espandi", che faceva sparire le colonne di colpo: o tutto o niente.
* Adesso la corsa è una sola e continua, e ognuno si sceglie il proprio
* compromesso fra colonne e video. "Espandi" resta come scorciatoia per
* andare in fondo con un clic. */
const VT_VIDEO_WIDTH_MIN = 35, VT_VIDEO_WIDTH_MAX = 160;
// Oltre il 100% il cursore lavora qui: da 100 a 160 le colonne passano da
// 132px a 74px. Sotto i 74 i nomi delle giocate diventano illeggibili, e una
// colonna che non si legge tanto vale toglierla con "Espandi".
const VT_SIDE_STRETTA = 74;
// Larghezza riservata a CIASCUNA colonna dei tag (Attacco / Difesa): restano
// sempre visibili, il player usa tutto il resto.
const VT_SIDE_MIN_W = 132;
/* Quanto sono larghe ADESSO le due colonne dei tag, in base al cursore. */
function larghezzaColonneTag() {
const pct = vt.videoWidthPct || VT_VIDEO_WIDTH_DEFAULT;
if (pct <= 100) return VT_SIDE_MIN_W;
const oltre = Math.min(1, (pct - 100) / (VT_VIDEO_WIDTH_MAX - 100));
return Math.round(VT_SIDE_MIN_W - oltre * (VT_SIDE_MIN_W - VT_SIDE_STRETTA));
}
// La misura scelta con lo slider "↔️" resta fissa da un'apertura all'altra
// dell'app (stessa eccezione mirata già usata per il nome coach: solo un
// numero in localStorage, non i dati di lega/squadra che restano JSON
// export/import), invece di ripartire sempre dal 56% di default.
// Chiave nuova (v3) apposta: chi aveva già usato la versione col player al
// 100% se la ritrovava memorizzata e non avrebbe visto alcun cambiamento —
// cambiando chiave si riparte dalla misura predefinita, poi vale di nuovo
// quella che sceglie il coach.
const VBC_VIDEO_WIDTH_KEY = 'vbc_video_size_pct_v4';
const VBC_VIDEO_EXPAND_KEY = 'vbc_video_expand';
function rememberVideoWidth(pct) {
try { localStorage.setItem(VBC_VIDEO_WIDTH_KEY, String(pct)); } catch (e) {}
}
function prefillVideoWidthFromMemory() {
try { vt.expanded = localStorage.getItem(VBC_VIDEO_EXPAND_KEY) === '1'; } catch (e) {}
try {
const saved = parseFloat(localStorage.getItem(VBC_VIDEO_WIDTH_KEY));
if (!isNaN(saved)) vt.videoWidthPct = saved; // verrà comunque riportato nel range valido da applyVideoWidth al primo render
else vt.videoWidthPct = VT_VIDEO_WIDTH_DEFAULT;
} catch (e) {}
}
window.addEventListener('resize', invalidateVideoBox);
/* "ESPANDI": IL BOTTONE CHE NON FACEVA NIENTE.
* Il bottone c'era, disegnato, con tanto di scritta che cambia in "Riduci"
* — ma la funzione che avrebbe dovuto chiamare NON ESISTEVA. Cliccandolo
* usciva un errore da programmatore nella barra rossa e il video restava
* com'era. Tutto il resto era gia' pronto e aspettava solo questo: i due
* pannelli laterali si nascondono da soli quando vt.expanded e' acceso, e la
* misura del player tiene gia' conto dell'espansione.
* Lo stato si ricorda, come la larghezza: chi lavora a schermo pieno lo
* vuole cosi' anche al filmato dopo. */
/* ESPANDI NON FA PIU' SPARIRE NIENTE.
* Prima "Espandi" toglieva di mezzo le due colonne dei tag: il video
* diventava grande, ma per taggare bisognava rimetterle — cioe' proprio nel
* momento in cui servono. La richiesta del coach e' chiara: espanso deve
* contenere lo stesso le colonne laterali, le giocatrici sopra e i tag
* rapidi sotto, semmai piu' piccoli. Quindi ora "Espandi" e' solo la
* scorciatoia per portare il cursore in fondo alla sua corsa: le colonne si
* stringono al minimo leggibile (74px) e il player prende tutto il resto,
* ma non sparisce nulla. "Riduci" riporta alla misura di partenza. */
function toggleVideoExpand() {
vt.expanded = !vt.expanded;
try { localStorage.setItem(VBC_VIDEO_EXPAND_KEY, vt.expanded ? '1' : '0'); } catch (e) {}
applyVideoWidth(vt.expanded ? VT_VIDEO_WIDTH_MAX : VT_VIDEO_WIDTH_DEFAULT);
try { vtModoTagAggiorna(); } catch (e) {}
invalidateVideoBox(); // la misura del player cambia: va rifatta
renderVideoSection();
scheduleAutosave();
}
/* ================= MODO TAG =================
* Il cursore della larghezza si fermava di fatto al 100%: oltre quella soglia
* stringeva le colonne, ma il player non cresceva perche' a limitarlo non era
* la larghezza, era l'ALTEZZA (misurato: 999x562 al 100% e 999x562 al 160%,
* su finestra 1660x1040). L'unico modo di dare piu' spazio al video e'
* togliere altezza a qualcos'altro. Sopra al player ci sono il menu (112px) e
* la riga della sessione (42px): mentre si tagga non servono, e vanno via.
* Restano le giocatrici, le due colonne dei tag e i tag rapidi sotto — le tre
* cose che servono davvero per taggare.
* Il menu non sparisce e basta: resta una linguetta in cima, e passandoci
* sopra col mouse il menu torna, disegnato SOPRA la pagina. */
let _vtMenuTimer = null;
/* DUE GRADINI, non uno.
* Liberata l'altezza, il video torna subito a essere limitato dall'altezza:
* se si togliesse tutto in un colpo solo, il cursore farebbe un salto e poi
* resterebbe fermo per meta' corsa. Cosi' invece la corsa e' divisa: oltre il
* 100% se ne va la riga della sessione (42px), oltre il 125% anche il menu in
* alto (112px). Due scatti, ognuno con un guadagno che si vede. */
function vtModoTagLivello() {
if (activeTeamTab !== 'video' || !currentClip()) return 0;
if (vt.expanded) return 2;
if (vt.videoWidthPct > 125) return 2;
if (vt.videoWidthPct > 100) return 1;
return 0;
}
function vtModoTagAggiorna() {
const liv = vtModoTagLivello();
const b = document.body;
const ora = (b.classList.contains('vt-tag2') ? 2 : (b.classList.contains('vt-tag') ? 1 : 0));
if (ora === liv) return false;
b.classList.toggle('vt-tag', liv >= 1);
b.classList.toggle('vt-tag2', liv >= 2);
if (liv < 2) { b.classList.remove('vt-menu'); if (_vtMenuTimer) { clearTimeout(_vtMenuTimer); _vtMenuTimer = null; } }
// Cambiando quelle due strisce cambia lo spazio verticale: la misura del
// player tenuta da parte non vale piu'.
invalidateVideoBox();
try { requestAnimationFrame(aggiornaBarreFisse); } catch (e) {}
return true;
}
function vtMenuMostra() {
if (!document.body.classList.contains('vt-tag2')) return;
document.body.classList.add('vt-menu');
if (_vtMenuTimer) clearTimeout(_vtMenuTimer);
_vtMenuTimer = setTimeout(vtMenuNascondi, 4000);
}
function vtMenuNascondi() {
document.body.classList.remove('vt-menu');
if (_vtMenuTimer) { clearTimeout(_vtMenuTimer); _vtMenuTimer = null; }
}
/* Il menu si richiude da solo quando il mouse se ne va, ma non di scatto: chi
* sta scegliendo una voce muove il mouse e puo' uscirne per un attimo. */
document.addEventListener('DOMContentLoaded', () => {
const nav = document.querySelector('nav.topmenu');
if (!nav) return;
nav.addEventListener('mouseenter', () => { if (_vtMenuTimer) { clearTimeout(_vtMenuTimer); _vtMenuTimer = null; } });
nav.addEventListener('mouseleave', () => {
if (!document.body.classList.contains('vt-menu')) return;
if (_vtMenuTimer) clearTimeout(_vtMenuTimer);
_vtMenuTimer = setTimeout(vtMenuNascondi, 700);
});
});
function applyVideoWidth(pct) {
pct = Math.max(VT_VIDEO_WIDTH_MIN, Math.min(VT_VIDEO_WIDTH_MAX, parseFloat(pct) || VT_VIDEO_WIDTH_DEFAULT));
vt.videoWidthPct = pct;
rememberVideoWidth(pct);
// Oltre il 100% il video cresce solo se gli si da' altezza: qui il menu e la
// riga della sessione si ritirano (o tornano, scendendo sotto il 100).
try { vtModoTagAggiorna(); } catch (e) {}
// Cambiando il cursore cambiano anche le colonne, quindi lo spazio
// disponibile: la misura tenuta da parte non vale piu'.
invalidateVideoBox();
const label = document.getElementById('vtSizeLabel');
if (label) label.textContent = Math.round(pct) + '%';
/* "Espandi" adesso vuol dire "cursore in fondo": se il coach torna
* indietro trascinando, il bottone deve tornare a dire Espandi, se no
* resta scritto Riduci mentre il video e' gia' ridotto. */
vt.expanded = pct >= VT_VIDEO_WIDTH_MAX;
try { localStorage.setItem(VBC_VIDEO_EXPAND_KEY, vt.expanded ? '1' : '0'); } catch (e) {}
const bott = document.getElementById('vtExpandBtn');
if (bott) bott.textContent = vt.expanded ? t('video.reduceBtn') : t('video.expandBtn');
const clip = currentClip();
if (clip) applyVideoBoxSize(clip);
}
// Dimensione MASSIMA che il player può avere qui e ora, a proporzioni
// invariate: è il riferimento sia per il cursore sia per il trascinamento
// dall'angolo (100% = questa misura).
// La misura del player si CALCOLA UNA VOLTA e poi resta ferma finché non
// cambia davvero qualcosa che la riguarda: clip, finestra, "Espandi",
// cursore della larghezza. Prima veniva ricalcolata ad ogni ridisegno — cioè
// ad ogni tag — partendo da due misure instabili: la posizione del player
// nella finestra (che cambia se la pagina è scrollata) e lo spazio riservato
// sotto (che cresceva di 64px appena compariva il pannello del tag da
// classificare). Risultato: il video si rimpiccioliva e si spostava su e giù
// mentre si taggava, portandosi dietro le caselle.
let videoBoxCache = null;
/* Quanto spazio tenere libero sotto al player. Si misura a pannello del tag
* chiuso e resta buono finche' la finestra non cambia. */
let riservatoSottoIlPlayer = 118;
function invalidateVideoBox() { videoBoxCache = null; }
function videoBoxCacheKey(clip) {
// Nella chiave entra anche l'altezza delle due strisce SOPRA al player
// (pulsantiera giocatrici e schede clip): non cambiano mai mentre si tagga,
// ma cambiano se si aggiunge una giocatrice o un video — e in quel caso la
// misura va rifatta davvero, invece di lasciare il player fuori posto.
const chips = document.getElementById('playerChipRow');
const tabs = document.getElementById('clipTabs');
return [clip.id, window.innerWidth, window.innerHeight, vt.expanded ? 1 : 0,
document.body.classList.contains('vt-tag') ? 1 : 0,
document.body.classList.contains('vt-tag2') ? 1 : 0,
(clip.aspectRatio || 0).toFixed(4), larghezzaColonneTag(),
chips ? chips.offsetHeight : 0, tabs ? tabs.offsetHeight : 0,
(document.getElementById('quickTagRow') || {}).offsetHeight || 0].join('|');
}
function videoMaxBox(clip) {
const wrap = document.getElementById('videoPlayerWrap');
const col = document.getElementById('videoCentralCol');
if (!wrap || !col || !clip) return null;
const chiave = videoBoxCacheKey(clip);
if (videoBoxCache && videoBoxCache.key === chiave) return videoBoxCache.box;
const stage = col.parentElement;
const ratio = clip.aspectRatio || (16 / 9);
// Spazio verticale: da dove inizia il player fino a fondo finestra, meno i
// controlli che restano sempre sotto (barra del tempo, riga Inizio/Fine,
// tag rapidi). La posizione di partenza si prende SENZA lo scroll, così
// scorrere la pagina non cambia la misura del player.
const wrapTop = wrap.getBoundingClientRect().top + (window.scrollY || 0);
// Spazio sotto sempre uguale, pannello del tag aperto o chiuso, così quando
// il pannello compare non ruba altezza al video e niente si muove. Tenuto
// al minimo che serve alla barra del tempo e alla riga dei comandi (118px
// invece di 168): i 50px recuperati vanno tutti al player, che a parità di
// schermo diventa circa 90px più largo. Il pannello del tag da classificare,
// quando compare, scende semplicemente più in basso — non tocca il video.
/* QUELLO CHE STA SOTTO AL PLAYER SI MISURA, NON SI INDOVINA.
* Erano 118 pixel fissi: bastavano per la barra del tempo e la riga dei
* comandi, ma NON per la riga dei tag rapidi che viene dopo. Risultato:
* i tag rapidi finivano sotto il bordo dello schermo, la pagina si
* metteva a scorrere e scorrendo spariva in alto la riga delle
* giocatrici — cioe' proprio le due cose che servono per taggare.
* Adesso si misura davvero la distanza fra il fondo del player e il
* fondo dei tag rapidi, e quella si tiene libera. Il pannello del tag da
* classificare si sottrae dal conto: compare e scompare di continuo, e se
* entrasse nella misura il video si rimpicciolirebbe ad ogni tag. */
/* IL PANNELLO DEL TAG NON DEVE ENTRARE NELLA MISURA, MAI.
* Prima si misurava con il pannello aperto e poi gli si toglieva la sua
* altezza. Sembrava giusto, ma offsetHeight NON conta il margine, e quel
* pannello ne ha dieci pixel sopra: ad ogni tag il player saltava di dieci
* pixel. Invece di rincorrere il conto, la misura si fa SOLO quando il
* pannello e' chiuso e ci si tiene il valore buono: finche' e' aperto si
* riusa quello, e il video non si muove di un pixel. */
let RESERVED_BELOW = riservatoSottoIlPlayer;
try {
const quick = document.getElementById('quickTagRow');
const pend = document.getElementById('pendingTagPanel');
const pannelloAperto = !!(pend && pend.offsetParent);
if (!pannelloAperto) {
const rw = wrap.getBoundingClientRect();
const rq = quick ? quick.getBoundingClientRect() : null;
if (rq && rq.height) {
RESERVED_BELOW = Math.max(118, Math.round(rq.bottom - rw.bottom) + 12);
riservatoSottoIlPlayer = RESERVED_BELOW;
}
}
} catch (e) {}
const availH = Math.max(240, window.innerHeight - wrapTop - RESERVED_BELOW);
// Spazio orizzontale: tutta la riga meno quello che serve alle due colonne
// dei tag, che restano SEMPRE visibili (è la condizione posta dal coach).
const stageW = (stage ? stage.clientWidth : window.innerWidth) || window.innerWidth;
const SIDE_W = 2 * (larghezzaColonneTag() + 12); // le colonne ci sono sempre, anche espanso
const availW = Math.max(300, stageW - SIDE_W);
let h = availH, w = h * ratio;
if (w > availW) { w = availW; h = w / ratio; }
const box = { w: w, h: h, ratio: ratio };
videoBoxCache = { key: chiave, box };
return box;
}
function applyVideoBoxSize(clip) {
const wrap = document.getElementById('videoPlayerWrap');
const col = document.getElementById('videoCentralCol');
const box = videoMaxBox(clip);
if (!wrap || !col || !box) return;
// Sotto il 100 il player e' una frazione del massimo. Sopra il 100 il
// massimo stesso e' gia' cresciuto (le colonne si sono strette), quindi qui
// si sta a 1: altrimenti il video sborderebbe fuori dalla riga.
const scale = Math.min(100, vt.videoWidthPct) / 100;
const w = Math.round(box.w * scale), h = Math.round(box.h * scale);
// Le colonne devono poter davvero diventare piu' strette: il minimo sta nel
// CSS, e senza toccarlo il browser non le lascerebbe scendere sotto 132px.
const strette = larghezzaColonneTag();
document.querySelectorAll('.video-stage .side-panel').forEach(sp => {
sp.style.minWidth = strette + 'px';
});
wrap.style.width = w + 'px';
wrap.style.height = h + 'px';
// La colonna si stringe esattamente sul player: lo spazio che avanza va
// alle caselle dei tag invece di restare un buco nero accanto al video.
col.style.flex = '0 0 ' + w + 'px';
col.style.width = w + 'px';
col.style.maxWidth = '100%';
const slider = document.getElementById('vtWidthSlider');
if (slider && document.activeElement !== slider) slider.value = String(Math.round(vt.videoWidthPct));
}
/* Maniglia d'angolo: si afferra l'angolo in basso a destra del player e lo si
* trascina IN DIAGONALE per ingrandirlo/rimpicciolirlo. Le proporzioni non si
* toccano mai — si tiene il rapporto del video e si guarda quanto l'angolo si
* è spostato lungo la diagonale, così il gesto è quello naturale di una
* finestra ma il video non si deforma né lascia bande nere. Il cursore "↔️"
* resta e si muove insieme: sono due modi di comandare la stessa misura. */
function startVideoCornerResize(ev) {
const clip = currentClip();
const wrap = document.getElementById('videoPlayerWrap');
const box = videoMaxBox(clip);
if (!clip || !wrap || !box) return;
ev.preventDefault();
ev.stopPropagation();
const rect = wrap.getBoundingClientRect();
const originX = rect.left, originY = rect.top;
wrap.classList.add('vt-resizing');
const onMove = (e) => {
// Se il tasto del mouse non è più premuto (rilasciato fuori dalla finestra,
// o "mouseup" perso perché il fuoco è passato a un'altra applicazione), si
// chiude qui: senza questo controllo il player continuava a ridimensionarsi
// seguendo il mouse anche dopo aver mollato.
if (!e.touches && e.buttons === 0) { onUp(); return; }
const px = (e.touches ? e.touches[0].clientX : e.clientX);
const py = (e.touches ? e.touches[0].clientY : e.clientY);
// Larghezza richiesta dal puntatore, e larghezza equivalente dedotta
// dall'altezza: si prende la maggiore, così trascinando in diagonale il
// player segue la mano su entrambi gli assi.
const wFromX = px - originX;
const wFromY = (py - originY) * box.ratio;
const wanted = Math.max(wFromX, wFromY);
const pct = Math.max(VT_VIDEO_WIDTH_MIN, Math.min(VT_VIDEO_WIDTH_MAX, (wanted / box.w) * 100));
applyVideoWidth(pct);
};
const onUp = () => {
wrap.classList.remove('vt-resizing');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.removeEventListener('touchmove', onMove);
document.removeEventListener('touchend', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('touchend', onUp);
}
// Doppio click sulla maniglia: torna al massimo possibile.
function resetVideoSize() { applyVideoWidth(VT_VIDEO_WIDTH_MAX); }
window.addEventListener('resize', () => {
const clip = currentClip();
if (clip) applyVideoBoxSize(clip);
});
function toast(msg) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(toast._t);
toast._t = setTimeout(() => el.classList.remove('show'), 2600);
}
// ------------------------------------------------------ ZOOM DIAGRAMMA
// Click su una miniatura schema (lista Playbook o anteprima di stampa) la
// ingrandisce in overlay — stesso pattern "clicca per ingrandire" già usato
// in ITALBASKET WOMAN (zoomDiagram/cursor:zoom-in) applicato qui ai campi
// disegnati dal motore diagrammi condiviso.
function zoomDiagramImage(src, title) {
document.getElementById('diagramZoomImg').src = src;
document.getElementById('diagramZoomTitle').textContent = title || '';
document.getElementById('diagramZoomOverlay').classList.add('show');
}
function closeDiagramZoom() {
document.getElementById('diagramZoomOverlay').classList.remove('show');
}
/* =====================================================================
* TRASCINARE I FILE DENTRO AL PROGRAMMA, dal Desktop o da una cartella.
*
* Non funzionava, e non era un difetto di Windows: non era mai stato
* scritto. Una pagina, se nessuno le dice il contrario, un file trascinato
* sopra lo APRE al posto della pagina — quindi il programma o non reagiva o
* rischiava di sparire. Ecco perche' l'unica strada era "Apri file".
* Adesso: si prende quello che viene mollato e lo si manda dove serve, a
* seconda della scheda aperta. I filmati nella scheda Video diventano
* clip da taggare; nella Presentazione entrano nella scaletta.
* ===================================================================== */
const ESTENSIONI_FILMATO = /\.(mp4|mov|m4v|webm|avi|mkv|mts|m2ts)$/i;
function fileTrascinatiSopra(ev) {
/* Senza questo, "molla" non arriva nemmeno: e' la regola del web. */
if (!ev.dataTransfer) return;
const tipi = Array.from(ev.dataTransfer.types || []);
if (!tipi.includes('Files')) return; // i trascinamenti interni non si toccano
ev.preventDefault();
try { ev.dataTransfer.dropEffect = 'copy'; } catch (e) {}
document.body.classList.add('file-in-arrivo');
}
function fileTrascinatiVia(ev) {
if (ev && ev.relatedTarget) return; // si sta solo passando sopra a un altro pezzo
document.body.classList.remove('file-in-arrivo');
}
function fileMollati(ev) {
const tipi = Array.from((ev.dataTransfer && ev.dataTransfer.types) || []);
if (!tipi.includes('Files')) return;
ev.preventDefault();
document.body.classList.remove('file-in-arrivo');
const files = Array.from((ev.dataTransfer && ev.dataTransfer.files) || []);
if (!files.length) return;
const filmati = files.filter(f => (f.type || '').startsWith('video/') || ESTENSIONI_FILMATO.test(f.name || ''));
/* Nella Presentazione un filmato entra nella scaletta. Serve il percorso
* sul disco: la scaletta si riapre domani e deve ritrovarlo. */
if (activeTeamTab === 'presentazione') {
if (!filmati.length) { toast(t('pres.soloFilmati')); return; }
let messi = 0;
filmati.forEach(f => {
const percorso = percorsoDelFile(f);
if (!percorso) return;
presAggiungiVoce({ tipo: 'video', nome: presNomePulito(f.name), percorso, size: f.size || 0 });
messi++;
});
if (messi) toast(t('pres.aggiuntiN', { n: messi }));
else toast(t('pres.senzaPercorso'));
return;
}
if (filmati.length) { importVideoFiles(filmati); return; }
toast(t('drop.soloFilmati'));
}
document.addEventListener('dragover', fileTrascinatiSopra);
document.addEventListener('dragleave', fileTrascinatiVia);
document.addEventListener('drop', fileMollati);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDiagramZoom(); });
/* Una via d'uscita che non dipende da dove cade un bottone sullo schermo:
* il tasto Esc chiude lo schizzo dell'esercizio e la lavagna. Se qualcosa
* finisce comunque fuori vista, da qui si esce lo stesso. */
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
const ex = document.getElementById('exerciseDiagramOverlay');
if (ex && ex.classList.contains('show')) { closeExerciseDiagram(); return; }
const fa = document.getElementById('frameAnnotateOverlay');
if (fa && fa.classList.contains('show') && typeof closeFrameAnnotator === 'function') closeFrameAnnotator();
});
/* Esc chiude la finestra dei video individuali/di un gioco: e' la finestra
* piu' alta dell'app e senza questa, con un elenco lungo, il tasto Chiudi
* finiva sotto al bordo dello schermo e non si usciva piu'. */
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
const ov = document.getElementById('playerVideoOverlay');
if (ov && ov.classList.contains('show')) { closePlayerVideos(); }
});
// ---------------------------------------------------------- MODALE INTERNA
// Sostituisce window.prompt/confirm/alert, bloccati in silenzio dentro la
// webview sandboxata dell'artefatto: senza questa modale i bottoni "+ Nuova
// Lega" / "+ Nuova Squadra" non facevano letteralmente nulla.
let modalResolve = null;
function showPromptModal(title, placeholder, onConfirm, prefill) {
document.getElementById('modalTitle').textContent = title;
const msgEl = document.getElementById('modalMessage');
msgEl.style.display = 'none';
const input = document.getElementById('modalInput');
input.style.display = 'block';
input.value = prefill || '';
input.placeholder = placeholder || '';
document.getElementById('modalOverlay').classList.add('show');
modalResolve = onConfirm;
setTimeout(() => { input.focus(); input.select(); }, 30);
}
// onCancel opzionale: serve quando "Annulla" non significa "non fare nulla"
// ma "fai comunque il resto" (es. carica le rose del Girone D lasciando però
// intatta quella squadra che il coach aveva già compilato a mano).
let modalCancel = null;
function showConfirmModal(message, onConfirm, onCancel) {
document.getElementById('modalTitle').textContent = t('modal.confirmTitle');
const msgEl = document.getElementById('modalMessage');
msgEl.style.display = 'block';
msgEl.textContent = message;
document.getElementById('modalInput').style.display = 'none';
document.getElementById('modalOverlay').classList.add('show');
modalResolve = onConfirm;
modalCancel = onCancel || null;
}
function confirmModal() {
const input = document.getElementById('modalInput');
const value = input.style.display !== 'none' ? input.value.trim() : true;
document.getElementById('modalOverlay').classList.remove('show');
const resolve = modalResolve;
modalResolve = null; modalCancel = null;
if (resolve && value) resolve(value);
}
function cancelModal() {
document.getElementById('modalOverlay').classList.remove('show');
const cancel = modalCancel;
modalResolve = null; modalCancel = null;
if (cancel) cancel();
}
// ------------------------------------------------------- ANTEPRIMA DI STAMPA
// Motore condiviso da Playbook (foglio schemi 4x5), Scouting Report e
// Personnel: ogni "print*()" costruisce l'HTML delle pagine (stesse classi
// .print-page/.print-header/... viste sia a video nel modal sia su carta via
// @media print) e lo passa qui, così restano identiche l'anteprima e la stampa.
// Selettori resi modificabili nell'anteprima: titoli, righe dei box, nomi
// squadra/lega, nomi degli schemi, celle delle tabelle, schede giocatrici —
// praticamente ogni scritta stampata, "qualunque scritta e qualunque spazio"
// come richiesto, senza dover ricostruire ogni pagina come un vero editor.
const PRINT_EDITABLE_SELECTOR = [
'.print-page h4', '.print-page li', '.print-page .cell-name',
'.print-page .print-team-name', '.print-page .print-league-name', '.print-page .print-meta',
'.print-page .pp-head', '.print-page .pp-line', '.print-page .pp-traits',
'.print-page .print-row-head span', '.print-page td', '.print-page th',
'.print-page .print-empty-line',
'.print-page .pb-sheet-name', '.print-page .pb-sheet-note',
'.print-page h5',
].join(', ');
/* QUELLO CHE SI CORREGGE SUL PDF SI CORREGGE ANCHE A VIDEO.
* L'anteprima di stampa lasciava riscrivere i titoli, ma solo li': chiudevi
* la finestra e sul foglio a schermo c'era ancora il titolo vecchio, e alla
* stampa dopo tornava fuori quello. Ora i titoli dei box dello scouting
* report portano con se' la loro chiave (data-scout-titolo / -sotto /
* -libero) e quello che si scrive qui finisce nei dati, esattamente come
* quando lo si scrive sul foglio sempre visibile. */
let _correttoDalPdf = false;
function salvaCorrezioneDalPdf(el) {
const team = activeTeam(); const opp = team && activeOpponent(team);
if (!opp) return;
ensureScoutPersonalizzazioni(opp);
const testo = (el.textContent || '').trim();
const kTit = el.getAttribute('data-scout-titolo');
const kSot = el.getAttribute('data-scout-sotto');
const kLib = el.getAttribute('data-scout-libero');
if (kTit) opp.titoliBox[kTit] = testo;
else if (kSot) opp.titoliSezione[kSot] = testo;
else if (kLib) {
const b = (opp.boxLiberi || []).find(x => x.id === kLib);
if (!b) return;
b.titolo = testo;
} else return;
_correttoDalPdf = true;
scheduleAutosave();
}
/* ====== IL FOGLIO SI MISURA DA SOLO ======
* Cerca il piu' grande --sf che sta ancora dentro una pagina sola. Ricerca
* per meta' (venti passi): parte fra 0,85 e 3 e stringe. Venti passi bastano
* per arrivare al millesimo, e costano meno di un millisecondo.
* Mentre si sta scrivendo dentro al foglio NON si tocca niente: il testo che
* cambia misura sotto le dita e' fastidioso. (2 settembre 2026) */
function adattaFoglioScouting(p) {
const bordo = p.getBoundingClientRect();
if (!bordo.width) return 1;
const altezzaA4 = bordo.width * 297 / 210;
const hPrima = p.style.height, ovPrima = p.style.overflow;
p.style.height = altezzaA4 + 'px'; p.style.overflow = 'hidden';
const sfora = (v) => { p.style.setProperty('--sf', v); return p.scrollHeight > p.clientHeight + 1; };
let basso = 0.85, alto = 4; // tetto alzato da 3 a 4: un report corto deve crescere finche' il foglio e' pieno
if (!sfora(alto)) basso = alto;
else { for (let i = 0; i < 20; i++) { const m = (basso + alto) / 2; if (sfora(m)) alto = m; else basso = m; } }
p.style.setProperty('--sf', (Math.floor(basso * 1000) / 1000)); // arrotondare per DIFETTO: .toFixed() arrotondava anche in su e l'ultimo mezzo millesimo faceva sforare
p.style.height = hPrima; p.style.overflow = ovPrima;
return basso;
}
function adattaFoglioPersonnel(p) {
const schede = Array.prototype.slice.call(p.querySelectorAll('.print-player-card'));
if (!schede.length) return 1;
const stretto = (v) => {
p.style.setProperty('--sf', v);
return schede.some(c => {
if (c.scrollHeight > c.clientHeight + 1) return true;
return Array.prototype.slice.call(c.querySelectorAll('.pp-trait-line')).some(l => {
const f = parseFloat(getComputedStyle(l).fontSize);
return l.getBoundingClientRect().height < f * 1.25;
});
});
};
let basso = 0.85, alto = 2.4;
if (!stretto(alto)) basso = alto;
else { for (let i = 0; i < 20; i++) { const m = (basso + alto) / 2; if (stretto(m)) alto = m; else basso = m; } }
p.style.setProperty('--sf', (Math.floor(basso * 1000) / 1000)); // arrotondare per DIFETTO: .toFixed() arrotondava anche in su e l'ultimo mezzo millesimo faceva sforare
return basso;
}
function adattaFogliCoach(dove) {
if (!dove) return;
const dentro = document.activeElement;
dove.querySelectorAll('.pr-scouting-page').forEach(p => {
if (dentro && p.contains(dentro)) return; // si sta scrivendo qui: fermi
adattaFoglioScouting(p);
});
dove.querySelectorAll('.pr-personnel-page').forEach(p => {
if (dentro && p.contains(dentro)) return;
adattaFoglioPersonnel(p);
});
}
function openPrintPreview(title, pagesHtml) {
document.getElementById('printPreviewTitle').textContent = title;
const content = document.getElementById('printPreviewContent');
content.innerHTML = pagesHtml;
content.querySelectorAll(PRINT_EDITABLE_SELECTOR).forEach(el => el.setAttribute('contenteditable', 'true'));
_correttoDalPdf = false;
content.oninput = (ev) => {
const el = ev.target;
if (el && el.nodeType === 1 && (el.hasAttribute('data-scout-titolo') || el.hasAttribute('data-scout-sotto') || el.hasAttribute('data-scout-libero'))) {
salvaCorrezioneDalPdf(el);
}
};
document.getElementById('printPreviewOverlay').classList.add('show');
// Il foglio dell'allenamento si rimpicciolisce da solo se non ci sta:
// va fatto DOPO che l'anteprima e' visibile, altrimenti le misure sono 0.
requestAnimationFrame(() => {
adattaFoglioAllenamento(content);
adattaCaselleTroppoPiene(content);
adattaFogliCoach(content);
});
}
/* IL PDF DEL FOGLIO CHE SI STA GUARDANDO.
* Un bottone solo per tutti i fogli: questa anteprima e' la stessa per
* Personnel, Scouting Report, Playbook, Allenamenti e il resto, quindi il
* PDF esce identico alla stampa senza scrivere niente di specifico per
* ognuno. Nel browser non serve: li' la finestra di stampa il PDF lo offre
* gia' da sola. */
async function salvaAnteprimaPdf() {
if (!NATIVO || !window.VDM.stampa) { window.print(); return; }
const titolo = ((document.getElementById('printPreviewTitle') || {}).textContent || 'VDM').trim();
const oggi = new Date().toISOString().slice(0, 10);
try {
const percorso = await window.VDM.stampa.pdf(titolo + ' ' + oggi);
if (percorso) toast(t('print.pdfSalvato', { nome: percorso.split(/[\\/]/).pop() }));
} catch (e) { toast(t('print.pdfNo')); }
}
function closePrintPreview() {
document.getElementById('printPreviewOverlay').classList.remove('show');
// Se sul PDF si e' corretto un titolo, il foglio a video si riallinea
// subito: non si esce mai con due versioni diverse dello stesso foglio.
if (_correttoDalPdf) {
_correttoDalPdf = false;
const team = activeTeam();
if (team && typeof renderScoutingLivePreview === 'function' && activeTeamTab === 'relazione') {
sincronizzaTitoliAVideo();
renderScoutingLivePreview(team);
}
}
}
// Intestazione comune a tutte le pagine stampabili: logo squadra (se presente),
// nome squadra/lega, e un'etichetta a destra (titolo pagina o data).
/* La squadra DEL COACH: la prima squadra vera della lega (quelle che alleni,
* non le caselle delle avversarie). Serve per firmare i fogli — su un foglio
* di scouting deve vedersi anche di chi è il lavoro. */
function squadraDelCoach(league, squadraStampata) {
const lg = league || activeLeague();
if (!lg) return singleMatchMode ? activeTeam() : null;
const proprie = Array.isArray(lg.teams) ? lg.teams : [];
// 1) quella scelta a mano col ★ (vedi impostaSquadraDelCoach): comanda su tutto
if (lg.ownTeamId) {
const scelta = proprie.find(x => x.id === lg.ownTeamId) || (lg.oppRosters || []).find(x => x.id === lg.ownTeamId);
if (scelta) return scelta;
}
// 2) Senza scelta esplicita: MAI la squadra che si sta studiando. Capita di
// creare per sbaglio una squadra con il nome dell'avversaria (o di
// averla davvero in lega): firmare il foglio della Repubblica Ceca con
// "Repubblica Ceca" non ha senso. Si scartano quindi le omonime e si
// sceglie quella più "viva" — con più giocatrici e più giochi — che è
// quasi sempre la propria.
const norm = (x) => (x || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '');
const nomeStampato = norm(squadraStampata && squadraStampata.name);
const peso = (tm) => (tm.players || []).filter(p => (p && p.name || '').trim()).length * 10
+ (tm.playbook || []).length + ((tm.logo || tm.flag) ? 1 : 0);
const candidate = proprie.filter(tm => tm && (!nomeStampato || norm(tm.name) !== nomeStampato)
&& (!squadraStampata || tm.id !== squadraStampata.id));
if (candidate.length) {
return candidate.slice().sort((a, b) => peso(b) - peso(a))[0];
}
if (singleMatchMode) return activeTeam();
return null;
}
/* Il coach dice QUAL È la sua squadra: con più squadre in lega (o con una
* casella riempita per sbaglio) non si può indovinare, e sui fogli finiva
* firmata quella sbagliata. Il ★ sulla griglia delle squadre la fissa. */
function impostaSquadraDelCoach(teamId) {
const lg = activeLeague();
if (!lg) return;
lg.ownTeamId = (lg.ownTeamId === teamId) ? null : teamId;
renderLeagueTeamsSection();
scheduleAutosave();
const sq = squadraDelCoach(lg);
toast(lg.ownTeamId ? t('squadre.ownSet', { nome: (sq && sq.name) || '' }) : t('squadre.ownCleared'));
}
/* ---- BANDIERE NAZIONALI -------------------------------------------------
* Sui fogli l'intestazione mostrava "IT" al posto della bandiera: la squadra
* del coach (Italia) non ha un logo caricato, e l'emoji bandiera non c'è
* quasi mai (e in stampa spesso non viene nemmeno disegnata dal browser).
* Qui la bandiera viene DISEGNATA a partire dal nome della squadra — niente
* file da caricare: se il nome è quello di una nazione conosciuta si stampa
* il suo vessillo, altrimenti si torna come prima a logo/emoji/iniziali.
* ------------------------------------------------------------------------ */
const BANDIERE_NAZIONI = (function () {
const V = function () { return { tipo: 'v', colori: Array.prototype.slice.call(arguments) }; };
const H = function () { return { tipo: 'h', colori: Array.prototype.slice.call(arguments) }; };
const HP = function (colori, pesi) { return { tipo: 'h', colori: colori, pesi: pesi }; };
const NORD = function (campo, croce, interna) { return { tipo: 'nordica', campo: campo, croce: croce, interna: interna }; };
const CROCE = function (campo, croce) { return { tipo: 'croce', campo: campo, croce: croce }; };
const m = {
// tricolori verticali
italia: V('#008C45', '#F4F5F0', '#CD212A'),
francia: V('#002395', '#FFFFFF', '#ED2939'),
belgio: V('#141414', '#FDDA24', '#EF3340'),
romania: V('#002B7F', '#FCD116', '#CE1126'),
irlanda: V('#169B62', '#FFFFFF', '#FF883E'),
nigeria: V('#008751', '#FFFFFF', '#008751'),
mali: V('#14B53A', '#FCD116', '#CE1126'),
guinea: V('#CE1126', '#FCD116', '#009460'),
messico: V('#006847', '#FFFFFF', '#CE1126'),
senegal: V('#00853F', '#FDEF42', '#E31B23'),
// tricolori orizzontali
germania: H('#141414', '#DD0000', '#FFCE00'),
paesibassi: H('#AE1C28', '#FFFFFF', '#21468B'),
russia: H('#FFFFFF', '#0039A6', '#D52B1E'),
serbia: H('#C6363C', '#0C4076', '#FFFFFF'),
ungheria: H('#CD2A3E', '#FFFFFF', '#436F4D'),
bulgaria: H('#FFFFFF', '#00966E', '#D62612'),
lituania: H('#FDB913', '#006A44', '#C1272D'),
estonia: H('#0072CE', '#141414', '#FFFFFF'),
austria: H('#ED2939', '#FFFFFF', '#ED2939'),
slovenia: H('#FFFFFF', '#0000A0', '#FF0000'),
slovacchia: H('#FFFFFF', '#0B4EA2', '#EE1C25'),
croazia: H('#FF0000', '#FFFFFF', '#171796'),
lussemburgo: H('#ED2939', '#FFFFFF', '#00A1DE'),
armenia: H('#D90012', '#0033A0', '#F2A800'),
lettonia: HP(['#9E3039', '#FFFFFF', '#9E3039'], [2, 1, 2]),
spagna: HP(['#AA151B', '#F1BF00', '#AA151B'], [1, 2, 1]),
polonia: H('#FFFFFF', '#DC143C'),
ucraina: H('#0057B7', '#FFD700'),
indonesia: H('#FF0000', '#FFFFFF'),
montenegro: { tipo: 'bordo', campo: '#C40308', bordo: '#D4AF37' },
// croci
finlandia: NORD('#FFFFFF', '#002F6C'),
svezia: NORD('#006AA7', '#FECC02'),
danimarca: NORD('#C8102E', '#FFFFFF'),
norvegia: NORD('#BA0C2F', '#FFFFFF', '#00205B'),
islanda: NORD('#02529C', '#FFFFFF', '#DC1E35'),
inghilterra: CROCE('#FFFFFF', '#CE1124'),
georgia: CROCE('#FFFFFF', '#FF0000'),
svizzera: { tipo: 'svizzera' },
// disegni particolari
giappone: { tipo: 'disco', campo: '#FFFFFF', disco: '#BC002D' },
repubblicaceca: { tipo: 'ceca' },
grecia: { tipo: 'grecia' },
portogallo: { tipo: 'portogallo' },
turchia: { tipo: 'turchia' },
statiuniti: { tipo: 'usa' },
canada: { tipo: 'canada' },
brasile: { tipo: 'brasile' },
argentina: { tipo: 'argentina' },
cina: { tipo: 'cina' },
israele: { tipo: 'israele' },
bosnia: { tipo: 'bosnia' },
scozia: { tipo: 'scozia' },
regnounito: { tipo: 'unionjack' },
australia: { tipo: 'unionjack' },
nuovazelanda: { tipo: 'unionjack' }
};
// Nomi alternativi (inglese, forma locale, abbreviazioni del coach).
const alias = {
italy: 'italia', italiana: 'italia', italie: 'italia', italien: 'italia', ita: 'italia', azzurre: 'italia', azzurri: 'italia',
france: 'francia', francese: 'francia', fra: 'francia',
belgium: 'belgio', belgique: 'belgio', bel: 'belgio',
romania: 'romania', rou: 'romania',
ireland: 'irlanda', irl: 'irlanda',
germany: 'germania', deutschland: 'germania', ger: 'germania', deu: 'germania',
olanda: 'paesibassi', netherlands: 'paesibassi', holland: 'paesibassi', nederland: 'paesibassi', ned: 'paesibassi', ola: 'paesibassi',
russia: 'russia', rus: 'russia',
srbija: 'serbia', srb: 'serbia',
hungary: 'ungheria', magyarorszag: 'ungheria', hun: 'ungheria',
bulgaria: 'bulgaria', bul: 'bulgaria',
lithuania: 'lituania', lietuva: 'lituania', ltu: 'lituania', lit: 'lituania',
estonia: 'estonia', est: 'estonia',
austria: 'austria', aut: 'austria',
latvia: 'lettonia', latvija: 'lettonia', lat: 'lettonia', lva: 'lettonia',
spain: 'spagna', espana: 'spagna', esp: 'spagna', spa: 'spagna',
poland: 'polonia', polska: 'polonia', pol: 'polonia',
ukraine: 'ucraina', ukr: 'ucraina',
slovenija: 'slovenia', slo: 'slovenia', svn: 'slovenia',
slovakia: 'slovacchia', slovensko: 'slovacchia', svk: 'slovacchia',
croatia: 'croazia', hrvatska: 'croazia', cro: 'croazia',
finland: 'finlandia', suomi: 'finlandia', fin: 'finlandia',
sweden: 'svezia', sverige: 'svezia', swe: 'svezia',
denmark: 'danimarca', danmark: 'danimarca', den: 'danimarca',
norway: 'norvegia', norge: 'norvegia', nor: 'norvegia',
iceland: 'islanda', isl: 'islanda',
switzerland: 'svizzera', suisse: 'svizzera', schweiz: 'svizzera', sui: 'svizzera', svizzera: 'svizzera',
japan: 'giappone', jpn: 'giappone',
cechia: 'repubblicaceca', cekia: 'repubblicaceca', czechia: 'repubblicaceca', czechrepublic: 'repubblicaceca',
ceskarepublika: 'repubblicaceca', cze: 'repubblicaceca', repceca: 'repubblicaceca', repubblicacecaa: 'repubblicaceca',
greece: 'grecia', hellas: 'grecia', ellada: 'grecia', gre: 'grecia',
portugal: 'portogallo', por: 'portogallo', prt: 'portogallo',
turkey: 'turchia', turkiye: 'turchia', tur: 'turchia',
usa: 'statiuniti', unitedstates: 'statiuniti', america: 'statiuniti', stateuniti: 'statiuniti', us: 'statiuniti',
can: 'canada', brazil: 'brasile', brasil: 'brasile', bra: 'brasile', arg: 'argentina',
china: 'cina', chn: 'cina', israel: 'israele', isr: 'israele',
bosniaerzegovina: 'bosnia', bih: 'bosnia',
scotland: 'scozia', england: 'inghilterra', eng: 'inghilterra',
unitedkingdom: 'regnounito', granbretagna: 'regnounito', greatbritain: 'regnounito', gbr: 'regnounito',
mexico: 'messico', mex: 'messico', montenegro: 'montenegro', mne: 'montenegro',
georgia: 'georgia', geo: 'georgia', armenia: 'armenia', arm: 'armenia',
luxembourg: 'lussemburgo', australia: 'australia', newzealand: 'nuovazelanda'
};
Object.keys(alias).forEach(function (k) { if (m[alias[k]]) m[k] = m[alias[k]]; });
return m;
})();
function nomeNazioneNormalizzato(nome) {
return String(nome == null ? '' : nome)
.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z]/g, '');
}
// Restituisce il markup della bandiera, o '' se il nome non è una
// nazione riconosciuta. Riquadro 60x40 (3:2), disegnato solo con rettangoli
// e poche forme: si stampa uguale su qualsiasi stampante.
function bandieraNazionaleSvg(nome) {
const chiave = nomeNazioneNormalizzato(nome);
if (!chiave) return '';
let spec = BANDIERE_NAZIONI[chiave];
if (!spec) {
// "Italia U18", "Nazionale Italia", "Italia Femminile"... — se il nome
// CONTIENE il nome di una nazione (parola intera) vale lo stesso.
const chiavi = Object.keys(BANDIERE_NAZIONI).filter(k => k.length >= 5 && chiave.indexOf(k) >= 0);
if (chiavi.length) { chiavi.sort((a, b) => b.length - a.length); spec = BANDIERE_NAZIONI[chiavi[0]]; }
}
if (!spec) return '';
const W = 60, H = 40;
const r = (x, y, w, h, c) => ` `;
let dentro = '';
if (spec.tipo === 'v' || spec.tipo === 'h') {
const colori = spec.colori;
const pesi = spec.pesi || colori.map(() => 1);
const tot = pesi.reduce((a, b) => a + b, 0);
let pos = 0;
colori.forEach((c, i) => {
const q = (spec.tipo === 'v' ? W : H) * pesi[i] / tot;
dentro += (spec.tipo === 'v') ? r(pos, 0, q + .3, H, c) : r(0, pos, W, q + .3, c);
pos += q;
});
} else if (spec.tipo === 'nordica') {
dentro = r(0, 0, W, H, spec.campo)
+ r(0, 16, W, 8, spec.croce) + r(20, 0, 8, H, spec.croce);
if (spec.interna) dentro += r(0, 18, W, 4, spec.interna) + r(22, 0, 4, H, spec.interna);
} else if (spec.tipo === 'croce') {
dentro = r(0, 0, W, H, spec.campo) + r(0, 16, W, 8, spec.croce) + r(26, 0, 8, H, spec.croce);
} else if (spec.tipo === 'svizzera') {
dentro = r(0, 0, W, H, '#D52B1E') + r(26, 10, 8, 20, '#FFFFFF') + r(20, 16, 20, 8, '#FFFFFF');
} else if (spec.tipo === 'disco') {
dentro = r(0, 0, W, H, spec.campo) + ` `;
} else if (spec.tipo === 'bordo') {
dentro = r(0, 0, W, H, spec.bordo) + r(2.5, 2.5, W - 5, H - 5, spec.campo);
} else if (spec.tipo === 'ceca') {
dentro = r(0, 0, W, 20, '#FFFFFF') + r(0, 20, W, 20, '#D7141A')
+ ' ';
} else if (spec.tipo === 'grecia') {
dentro = r(0, 0, W, H, '#FFFFFF');
for (let i = 0; i < 5; i++) dentro += r(0, i * 8.888, W, 4.444, '#0D5EAF');
dentro += r(0, 0, 22.2, 22.2, '#0D5EAF') + r(8.9, 0, 4.4, 22.2, '#FFFFFF') + r(0, 8.9, 22.2, 4.4, '#FFFFFF');
} else if (spec.tipo === 'portogallo') {
dentro = r(0, 0, 24, H, '#046A38') + r(24, 0, 36, H, '#DA291C')
+ ' '
+ ' ';
} else if (spec.tipo === 'turchia') {
dentro = r(0, 0, W, H, '#E30A17')
+ ' '
+ ' ';
} else if (spec.tipo === 'usa') {
dentro = r(0, 0, W, H, '#FFFFFF');
for (let i = 0; i < 7; i++) dentro += r(0, i * 6.15, W, 3.08, '#B31942');
dentro += r(0, 0, 24, 21.5, '#0A3161');
for (let f = 0; f < 4; f++) for (let c = 0; c < 5; c++) dentro += ` `;
} else if (spec.tipo === 'canada') {
dentro = r(0, 0, W, H, '#FFFFFF') + r(0, 0, 15, H, '#D80621') + r(45, 0, 15, H, '#D80621')
+ ' ';
} else if (spec.tipo === 'brasile') {
dentro = r(0, 0, W, H, '#009B3A') + ' '
+ ' ';
} else if (spec.tipo === 'argentina') {
dentro = r(0, 0, W, 13.3, '#75AADB') + r(0, 13.3, W, 13.4, '#FFFFFF') + r(0, 26.7, W, 13.3, '#75AADB')
+ ' ';
} else if (spec.tipo === 'cina') {
dentro = r(0, 0, W, H, '#EE1C25')
+ ' ';
[[21, 4], [26, 8], [26, 14], [21, 18]].forEach(p => {
dentro += ` `;
});
} else if (spec.tipo === 'israele') {
dentro = r(0, 0, W, H, '#FFFFFF') + r(0, 4, W, 5, '#0038B8') + r(0, 31, W, 5, '#0038B8')
+ ' '
+ ' ';
} else if (spec.tipo === 'bosnia') {
dentro = r(0, 0, W, H, '#002F6C') + ' ';
for (let i = 0; i < 7; i++) dentro += ` `;
} else if (spec.tipo === 'scozia') {
dentro = r(0, 0, W, H, '#0065BD')
+ ' ';
} else if (spec.tipo === 'unionjack') {
dentro = r(0, 0, W, H, '#012169')
+ ' '
+ ' '
+ r(0, 15, W, 10, '#FFFFFF') + r(25, 0, 10, H, '#FFFFFF')
+ r(0, 17, W, 6, '#C8102E') + r(27, 0, 6, H, '#C8102E');
}
if (!dentro) return '';
return ``
+ dentro
+ ` `;
}
// Stemma della squadra (logo caricato, bandiera, oppure le iniziali).
function printBadgeHtml(team, cls) {
if (!team) return '';
if (team.logo) return ` `;
// Bandiera disegnata dal nome (Italia -> tricolore): viene PRIMA dell'emoji
// perché in stampa l'emoji spesso non viene resa dal browser.
const vessillo = bandieraNazionaleSvg(team.name);
if (vessillo) return `${vessillo} `;
if (team.flag) return `${escapeHtml(team.flag)} `;
return `${escapeHtml((team.name || '?').trim().slice(0, 2).toUpperCase())} `;
}
function printPageHeader(team, league, rightLabel) {
const vessilloTeam = team ? bandieraNazionaleSvg(team.name) : '';
const logoHtml = team && team.logo
? ` `
: (vessilloTeam
? `${vessilloTeam} `
: (team && team.flag
? `${escapeHtml(team.flag)} `
: '
'));
// Firma della squadra di casa a destra: stemma + nome, così sul foglio si
// vede sia CHI si sta studiando sia PER CHI è stato preparato.
const casa = squadraDelCoach(league, team);
const firma = (casa && (!team || casa.id !== team.id))
? `
${printBadgeHtml(casa, 'print-owner-badge')}${escapeHtml(casa.name || '')}
`
: '';
return ``;
}
/* L'INTESTAZIONE DEL FOGLIO DELL'ALLENAMENTO.
* Quella generale (printPageHeader) porta a destra la firma "per chi e'
* stato preparato": ha senso su uno scouting report, che studia GLI ALTRI.
* Su un allenamento no: non c'e' nessuna squadra avversaria di mezzo, e
* vederla stampata confonde e basta. Qui quindi niente firma: al suo posto
* IL NOME DELL'ALLENATORE, che e' chi il foglio lo firma davvero. */
/* Data, orario e luogo: se li scrivi devono comparire sul foglio. Sono le
* tre cose che una giocatrice guarda per prime — quando, a che ora, dove. */
function practiceMetaTesto(s) {
const voci = [];
if ((s.data || '').trim()) {
let d = s.data;
try {
const dd = new Date(s.data + 'T00:00:00');
if (!isNaN(dd)) d = dd.toLocaleDateString(currentLang || 'it');
} catch (e) {}
voci.push([t('practice.metaData'), d]);
}
if ((s.ora || '').trim()) voci.push([t('practice.metaOra'), s.ora]);
if ((s.luogo || '').trim()) voci.push([t('practice.metaLuogo'), s.luogo]);
if (!voci.length) return '';
// Una riga sola, dentro all'intestazione: "18/08/2026 · 18:30 · PalaBerlino"
return '' + voci.map(([k, v]) =>
`${escapeHtml(k)} ${escapeHtml(v)} `).join('· ') + '
';
}
function practiceLetterheadHtml(team, league, titolo, coach, seduta) {
const vessillo = team ? bandieraNazionaleSvg(team.name) : '';
const logoHtml = team && team.logo
? ` `
: (vessillo
? `${vessillo} `
: (team && team.flag
? `${escapeHtml(team.flag)} `
: '
'));
return ``;
}
// ---------------------------------------------------------------- LEAGUES
function createLeague() {
if ((DB.leagues || []).length >= limite('leghe')) return bloccoBeta(t('beta.limitLeagues'));
showPromptModal(t('prompt.leagueName'), t('prompt.leagueNamePh'), (name) => {
const league = { id: nextId('lg'), name, sourceUrl: '', teams: [] };
DB.leagues.push(league);
activeLeagueId = league.id;
activeTeamId = null;
// Appena creata la lega si atterra sulle SQUADRE che ne fanno parte — è
// da lì che si comincia (aggiungi la tua squadra e le avversarie, poi
// entri in una per roster/giochi/difese), invece che su una pagina Video
// vuota con la tendina delle squadre da scoprire in cima.
activeTeamTab = 'squadre';
renderAll();
});
}
function selectLeague(id) {
activeLeagueId = id;
const league = DB.leagues.find(l => l.id === id);
activeTeamId = league && league.teams.length ? league.teams[0].id : null;
// Cambiando lega si riparte sempre dal suo elenco squadre, per lo stesso
// motivo: è la mappa di quella lega.
activeTeamTab = 'squadre';
renderAll();
}
function activeLeague() {
return DB.leagues.find(l => l.id === activeLeagueId) || null;
}
// ------------------------------------------------------------- ROSE AVVERSARIE
// Archivio DI LEGA (non della singola squadra che gestisci): 16 spazi fissi
// per le squadre della lega, condivisi da tutte le tue squadre che ci
// giocano contro. Scrivi il nome della squadra avversaria in uno spazio e
// il suo roster (numero/nome/ruolo, ordinata da sola per ruolo): lo ritrovi
// già pronta la prossima volta, senza doverla ridigitare ogni volta che la
// riaffronti — stessa logica "16 caselle sempre visibili" del Playbook.
// 12 caselle: è il numero di squadre di un girone/campionato tipico, e con
// 12 loghi grandi il tabellone della lega si legge tutto in una schermata.
// Non è un tetto: "+ Nuova Squadra" ne aggiunge altre in fondo.
const AVV_SLOT_COUNT = 12;
// Caselle mostrate sul tabellone della lega (squadre inserite + libere).
const LEAGUE_BOARD_SIZE = 12;
// Ogni cartella squadra ha 14 caselle giocatrici fisse (numero/nome/ruolo),
// sempre tutte visibili e pronte da compilare — stesso principio delle 12
// schede sempre pronte del Personnel, non più un elenco libero con un
// modulo "+ aggiungi" a parte.
// 12 caselle giocatrice per squadra: tante quante gli slot del Personnel,
// così il roster e la griglia del Personnel sono la stessa cosa — si compila
// una volta e le dodici schede sono lì. (Prima erano 14 e due restavano
// sempre fuori dalla griglia del Personnel.)
const AVV_PLAYERS_PER_SLOT = 12;
function avvSlotColor(i) { return `hsl(${Math.round(i * (360 / AVV_SLOT_COUNT))}, 62%, 58%)`; }
// Ogni casella della lega NON è più solo un roster: è una SQUADRA a tutti gli
// effetti, con la stessa forma-oggetto di una squadra propria (stessa tecnica
// già usata da newTeamShapedRecord per la Partita Singola). Così tutto il
// motore esistente — Playbook con giochi e difese, pulsantiera giocatrici nel
// Video, Scouting Report, stampe, logo nelle intestazioni — funziona su una
// squadra della lega senza doverlo riscrivere: basta che activeTeam() punti
// lì. È quello che serve per "se nel box metto Repubblica Ceca dove ho già
// inserito il roster devo ritrovarmi la pulsantiera con le giocatrici della
// Repubblica Ceca, i giochi che ho lasciato e le difese che ho lasciato per
// quella squadra".
function upgradeLeagueTeamRecord(slot) {
if (!Array.isArray(slot.players)) slot.players = [];
if (slot.flag == null) slot.flag = '';
if (slot.logo === undefined) slot.logo = null;
if (typeof slot.colorHex !== 'string') slot.colorHex = '#3b82f6';
if (!Array.isArray(slot.strategyNotes)) slot.strategyNotes = [];
// ATTENZIONE: defaultTagCategories() torna un OGGETTO {attacco:[],difesa:[]},
// non un array — controllarlo con Array.isArray lo avrebbe riscritto da capo
// ad ogni chiamata di activeTeam(), cancellando le categorie che il coach si
// fosse personalizzato su quella squadra.
if (!slot.tagCategories || typeof slot.tagCategories !== 'object') slot.tagCategories = defaultTagCategories();
if (!Array.isArray(slot.quickTags)) slot.quickTags = defaultQuickTags();
if (!Array.isArray(slot.playbook)) slot.playbook = [];
if (!Array.isArray(slot.fixtures)) slot.fixtures = [];
if (!Array.isArray(slot.practice)) slot.practice = [];
if (!Array.isArray(slot.opponents)) slot.opponents = [];
if (!slot.personnel || typeof slot.personnel !== 'object') slot.personnel = { positions: [] };
if (!slot.scoutingReport || typeof slot.scoutingReport !== 'object') slot.scoutingReport = defaultScoutingReport();
return slot;
}
// Tutte le squadre della lega in un unico elenco: prima le squadre proprie
// (quelle che alleni), poi le caselle della lega a cui hai dato un nome. È la
// lista che alimenta la pagina "Squadre della lega".
function allLeagueTeams(league, includeEmpty) {
if (!league) return [];
const own = (league.teams || []).map(t => ({ team: t, own: true, empty: false }));
const others = ensureOppRosterSlots(league)
.map((t, i) => ({ team: t, own: false, empty: !(t.name || '').trim(), slotIndex: i }))
.filter(e => includeEmpty || !e.empty);
return own.concat(others);
}
function ensureOppRosterSlots(league) {
if (!Array.isArray(league.oppRosters)) league.oppRosters = [];
while (league.oppRosters.length < AVV_SLOT_COUNT) {
league.oppRosters.push({ id: nextId('oppr'), name: '', players: [], logo: null, flag: '' });
}
league.oppRosters.forEach(slot => {
upgradeLeagueTeamRecord(slot);
while (slot.players.length < AVV_PLAYERS_PER_SLOT) slot.players.push({ number: '', name: '', role: '' });
// Forma completa (e id stabile) fin da subito: serve per legare i pezzi
// di video alla giocatrice giusta anche se la scheda non è mai stata
// aperta prima di taggare.
slot.players.forEach((pl, i) => { slot.players[i] = ensureRosterPlayerShape(pl); });
});
return league.oppRosters;
}
// Logo squadra avversaria: stesso meccanismo del logo squadra propria
// (setTeamLogo/setTeamLogoUrl) ma per-slot, dentro l'archivio di lega.
function setOppRosterSlotLogo(slotId, file) {
const league = activeLeague();
if (!league || !file) return;
const slot = ensureOppRosterSlots(league).find(s => s.id === slotId);
if (!slot) return;
const reader = new FileReader();
reader.onload = () => {
slot.logo = reader.result;
renderAvversarieSection();
scheduleAutosave();
};
reader.readAsDataURL(file);
}
// Bandiera nazionale (emoji): alternativa rapida al logo caricato — comoda
// per le nazionali (es. Mondiale), dove la bandiera è già un'icona chiara
// e non serve andare a cercare/caricare un file immagine. Se impostata ha
// la precedenza sul logo caricato nel box compatto (vedi avvSlotCardHtml).
function setOppRosterSlotFlag(slotId, value) {
const league = activeLeague();
if (!league) return;
const slot = ensureOppRosterSlots(league).find(s => s.id === slotId);
if (!slot) return;
slot.flag = value;
slot.flagAuto = false; // scelta a mano: da qui in poi comanda il coach
renderAvversarieSection();
scheduleAutosave();
}
function updateOppRosterSlotName(slotId, value) {
const league = activeLeague();
if (!league) return;
const slot = ensureOppRosterSlots(league).find(s => s.id === slotId);
if (!slot) return;
slot.name = value;
scheduleAutosave();
}
// "Precompila Girone D" (Mondiale FIBA 2026, Berlino): scorciatoia per non
// dover scrivere a mano i nomi delle 3 avversarie del girone — riempie i
// primi 3 spazi ancora vuoti con Stati Uniti/Cina/Repubblica Ceca (solo il
// nome: il logo resta da caricare a mano su ogni casella, i loghi veri sono
// la scelta del coach — vedi avvSlotCardHtml, la bandiera resta solo un
// ripiego automatico per gli spazi senza logo). Non tocca spazi già
// compilati con un'altra squadra.
const GIRONE_D_TEAMS = ['Stati Uniti', 'Cina', 'Repubblica Ceca'];
// Rose complete del Girone D (Mondiale 2026: Repubblica Ceca, Stati Uniti,
// Cina) portate una volta per tutte dal file ITALBASKET WOMAN del coach:
// numero, nome, ruolo, altezza, anno, medie punti/rimbalzi/assist, note
// caratteristiche e foto. Le foto degli Stati Uniti sono incorporate nel
// file, quelle di Cechia e Cina restano collegate al sito da cui vengono
// (si vedono con la connessione attiva). Le rose si caricano dal bottone
// "Precompila Girone D" nella pagina Squadre Avversarie.
const GIRONE_D_ROSTERS = {"cechia":{"name":"Repubblica Ceca","flag":"🇨🇿","players":[{"number":"10","name":"Eliska Hamzova","role":"1","height":"182","birthYear":"2001","stats":{"pts":"7.0","reb":"5.0","ast":"2.8"},"traits":[],"photo":"data:image/jpeg;base64,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"},{"number":"13","name":"Petra Holesinska","role":"2","height":"178","birthYear":"1997","stats":{"pts":"11.4","reb":"3.0","ast":"2.4"},"traits":[],"photo":"data:image/jpeg;base64,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"},{"number":"8","name":"Veronika Vorackova","role":"3","height":"180","birthYear":"1999","stats":{"pts":"9.4","reb":"5.0","ast":"3.6"},"traits":[],"photo":"data:image/jpeg;base64,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"},{"number":"5","name":"Natalie Stoupalova","role":"4","height":"185","birthYear":"1998","stats":{"pts":"9.0","reb":"3.0","ast":"1.0"},"traits":[],"photo":"data:image/jpeg;base64,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"},{"number":"22","name":"Emma Cechova","role":"5","height":"195","birthYear":"2004","stats":{"pts":"14.4","reb":"6.4","ast":"1.0"},"traits":[],"photo":"https://basketball.instatscout.com/images/players/180/823714.png"},{"number":"44","name":"Julia Reisingerova","role":"5","height":"194","birthYear":"1998","stats":{"pts":"9.0","reb":"2.8","ast":"1.5"},"traits":[],"photo":"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAkGBxISEhAQEBAPEBAQEBAQEBAPDw8PDw8QFREWFhURFRUYHSggGBolGxUVITEhJSkrLi4uFx8zODMtNygtLisBCgoKDg0OGhAQGC0dHx8tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLf/AABEIAioCKgMBIgACEQEDEQH/xAAcAAEAAQUBAQAAAAAAAAAAAAAAAgEDBAUGBwj/xABHEAACAQIDBQUFBAYIBQUBAAAAAQIDEQQFIQYSMUFRBxMiYXEygZGhsUJScsEUI2KC0eEkM0NTc5Ky8BU0Y6LxFhdUk8J0/8QAGwEBAAMBAQEBAAAAAAAAAAAAAAECBAMFBgf/xAA0EQACAgEDAwMDAgUEAgMAAAAAAQIDEQQSIQUxQRMyUSJhcQaRFEKBobEzNFJTwdEVIyT/2gAMAwEAAhEDEQA/APcQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACjDZyO1+3uFwUZR31Vr8qcNbP8AaZDZKWTrJTSTbaSXFt2scjnvaPgMK3F1e9mvs0VvpPo5cDxraHbzGYxtObjBv+rh4Y280uJzDpyb1Tu/eyjmdFX8nq+adtbd1h8K10lUkn8kcnju0nMqrb75010ppJfQ5ulh3zj+Rnwwyte1n5x0Ic2XUEbbBdoOY02pfpEqiX2ZK9zpYdrmIluR7qMZ/aa1jLztyOCjT14fDgXXhdU7arminqNF/TTPT8p7W05buIotJO29Td/kdFgu0nAVJbu/OPnKD3V6s8Gq07b3VkFSklZX82iytKOpH1LgcdTrR36U4zj1i7mSfN+zu0+JwUt6lO6ejhLWLXn5nquyPaHTxLVKulTqPhJew/LyLxmmcnW0d4CiZUuUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKEZzSTbaSWrb4JFWzxrtV293pSwWFm1GOlacftS+6mQ3gmKyynaF2mTcp4bAyUIK8Z1l7cnzUei8zyitNt70pSk3rq73frxJJuT4GywmBekmk10VmzjKRpjHBh4OjN6qLS62t8zZ06KS1qJPpo/nY3UKEXHVa+cn9DGq5ffhC/ySKOR0UTWVa8FwW8+vIlRrTfC1uljYLK7cVFPpG5Onlj5FXIuoFrD4a/tKz6oyoYezS4q5m0MBwvdsvxwzT4aFNxfYaWpglvOyu/kiM8HK1tF5aHSxwd9baso8Avu3ZG4n0zk6mWu127empZgnB3TkmuD4HYSwd9OC8tDFr5enxjfz5kqeCrrOu2G27b3KGKd+EY1enRS/iemQknqtU1dNcGfONfCOD3oXi15P5nonZ9ta1u4bES00VOTfDyZorszwzJbVjlHpgKJlTuZwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUKlnEVlCMpydoxTk2+SQBzHaLtCsJhZJSSrVU4U1ezs9HL3HzrUV23xbu231b1fmdJtdnUsdiqlRtuN3GnHko8kWcJlLerX8DPOeWbKqzW4PD35Nvy4fE32By+T6R87GdhcCtEkbijQ3Ti5GmMDApZdYyo4RL19WZTiTjA5tnRQRjRoF+nh15E1EuwiVbZbBbhRsV7rqZEYlWiMsskWFTCVi9Yi4EEtEacL8SGJwt19DIprkXGwVZz1fD8b8fqa6tRau1a3R/wAjqa9C5qq9Pimv5l1Jo5ygmeg9n2cOtQ7uTvOk93V3e7yOrR5PsdjI0MRFu8YTW7LXRdLnq8JXSa4M31yyjzLYbZEgUKnQ5AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFDi+1DMpU8K6UH4q73XbjucztDy3tWr+OK10sl8ys3wdK1mR5/luESlZq7+h0dCnraxoMvr+JOz6I63CU9LvizHLuehBFKVC2pdsTSJqBzOyI06ZejSLtGJeaQZOTG7okqZkboSKklrcK92XrFbEYJyY7phQLzQSGCGRVIt1EZSLdSAwDELGKw+8mZTRKJAZoVBxbvw+nmem7I5h3tFJyvKHh93I8/zGO6r205mz2Mxe7PdUnaTW70T8zTRLDMeojlHpRUjCV0mSNp54AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABGbPK+1ejuunKV3vXt5M9UZ5j2yS/wCXXJbzKz7HSv3HnWVQvP0+J2+GWhxmU1LSSUVdvidxgldGKZ6NRcpwLiiUTsSiVOrLkSZaiXoDBAuGyW6UcSuC4uGU3WVsMAjcnEKBdjAJAoUkSkrEJMNEGPNCCLjRFLUrgnJiZlHT1NfkUHGTceMGpW6pO7Rts2p+Exti6feV5QfSx2qWTNa+D1HCSvCD6xT+RfMXCUZQSjfeS0TfFLoZRvPMYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQ827YcO3CjN+yrx97PSTge19P8ARqdrf1q4lZdi8PceZ5PQvP0O5oKyXocjsvSvK3G2rfU68xS7np19i3UlYwq+Ol/Z29TLqUXJ+RlYfBQXFIqXyaKGLq3d2l0bRl0M7a0lT3n1XA28sFS+6WamV0+C09CSC3TzSMvstGbRqJq6NfHL+VzIoU90gumZkkVUByJQZACiWauKjHiy5iJPhY1c8K3e+twQXqmdUVe7fqYdXOoP2IyZD/hDb5ebsXqeR/tW9CSC1TzTVb0LJ87m1w01KzWq8izHKorm5fIrRwm47x0V+CK4DZk4+G9E12w1NrGNLTRvyNpV1TRrtiK/9OlHqpL0sdqu5nu9p6gipQqbTzgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADFzDGRpRcpP0XNs8j7Us9lVpU4tRinVtGzvK9uB2faHinCldfdaXk3zPNM8y2Tjh2oua3ZSev2pWszJO179p6FWnXpb/LL+x1JpXfNaeh1CiaLZmi4vW+kToTlJ8neCwg2kjExGM3U7atal2tUNPmOGdVaNx67rs2uhCZfBg43aTV7snJ29mmryj5voa6G1jVRRk5XaXh3vF62Npg8vjGnUpKDvOLW/wDa9Tn6+SVXiadfcvOmoKMoqKp+FaNrmaYxhjkzzdmeEdpg8wm1CVpOMldKSs2vJ8zZvEKyd/8AfQxK9dLDQowi3JRb3rbq329Wa3KsROcbVY+KEk30kjk0snVZxydRh6ifEyaKXuNO8RZXRdo4s5s6JMvYyvb0NdiM1jFaK76GViYb6t5f+Tjq2L3cSotXp38/TUskir4M/F7TqD9v3RV7e8hR2rvJJz3Y8Fv2V+rOZ2qy1zlBR0tNy343feJ8E1fSx0WT5TF06ssRTjGNRR3Ic7qNnJLzO/pxwZlbNvGDeYXN99Ju1nwad0zcYWZw+X5XOnUe5J93e6pyalur1OtwcnZJozSaTNOG1yZ0kczsrjO6zC8mlGTkm3yZ1CehwOaUpQxClBaqonbzTuWrlg5WRysHvMJXSa5kjWZBW36EJeRszcnlZPMksPAABJAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABxXaTTvR4X0NDlslPC03a8rW9EnY7fa3B95QlpwTOIyWmo0Jx1vGUk38/gefbFqzJ6+mnuoUfghg6aTdvRmTMxsu1u+rZmSjqUOmMEFSXMOjHoXUiqpgFuEPJFqtFfdVy7JW5mHiatuZZMcmDjpSeif8AArhKbS1JQs2ZCVkTkjHyW6k9C7QMKrPWxl0XoUkdkkZkWaHM8vbk318je0Xw8yVWJMZYOckaPA4fgpa2Nz+iwa4P6lru7cjJpSfQs5FcFYYdLgjIjAjCDZeULHJvJYlTRqc1w0YzhJpXk7L5G5oR1MXMMN3tbDQ6OUtPckW+xRYy2ztckpbtGmuHhRsC1RhuxS6KxdPSisLB5EnmTAAJKgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFuvC8WuqscFmOCdKdWHKaTS6s9AZzG2eHbjGceKdm0cL45jk1aSeJ4+Tk8sukoSjZttozporg2pRjLRyWjfMnXiY/B6LfJjpl6EWyG6Z2GhclLIbMKrTNJmHGx1FeiaLM8P0J7FovJh4C3MysXOK0ve5rqN72MqtTjHxTkkvN2QJUTGhSvK5mxosyMunTafCSfBp3RnJwXFaeXEhk5aNbCW6Z9OUXG/MwZ4ilvbspxTb8MXJXZeqU7R8N9CoaySki7Rpss4WDZtqNEnBRvBbiUbMiULFmwaIyJTcYSklray95sNn8Lv1nUfCEYpfAxZLT4G82Yp2puX3pfQ61RzP8ABnvk1Xn5NyVKFTceYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUZqNoWpQ7tK8pa26G1qzSTb5I19JaSqy4u7V+SKy5WC8Hh5OKoYR029W7te63IyJO5iY7OlCrKO7dNvhx1MHC53GeIjSSdmnq+tjBtakeqppxNq0X6NQg0UiR2LmZKRj1KSer6ElMtVJvlwHcdixPCRV9OJrq2BU9JapO6T1Rnzb8iCml5ssSmyNLBWirJLpZFyng3zZk0alldorDExb0uRhDMjBxOXJyTcY3XOyuZeGgkrF6pLyLLWpGA2/JkU6aRkJmJCQdRjsVL9SfItQKSJU4hcshvCLlReG3U6TIaO7Sin1bMXAZXfdnLhbRG8jGysjVVW08sw6i1SW1AqAaDIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUYAMLHvecaa+07v8KMDabGqjRly0/2jY4Z3lUk+T3F6Liefbe5i6lZUYvwqylZ+9lJywjrXHLNJV0pvET0lUm4wvxsld/VHPZbjN3FUqnLfs/O5lbRY/eaivYpx3KfLTm/ic5CpeSfNar1TMzNS4PZd0iomPlOJ7ylTnzlGN/VLUy7HJmpPgl3XBos4luKdkn77MyaUjHxDIGTQY3NnF7rpTvy4OJhzzyaWlOaXlY3leipKzRjwyzo17zomjpFpdzU0s8b4qpF+af5Eqefb325adYtG5/4Zbp8Cn/DV0XwHB23xNTHPOneN/ha+pfoZ5OTSVKbb/D82bD/AIYvK3oX6WHUOC16kNo5zlEng3P7Vo+S1MzcLFJmVc5HLyRa4F3DRu0urSLRsslo7010Wp0rWWc7ZYhk6SlGyS6JFxFEVPQPJAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABCq7JvomTLWI4eunzDBqc4x6w2Gc/tbrt5yav8AmeSurKblNu8p3V/Xizrdv8w7yqsPCVo01ebXJ2/h9TjlXSUpPRRTsui5R9TLZLLwbaY4WTTZtVTla97aL8zRYSt436pr0MzFVPafU01WdteF3f8AkURZnsuys74eP7LaN2mc3sDU3sP+9+R0koW9DmzTHsRci3ORIi4lS2CiRchTLUPMuuRKQJsRLEVqXLPkCcE5JlqcSXePmRmVYLcS/cjCmTigOCUTp8hoWhvc2c9Rhz5HS5I/1fvNNCMepf0mfYqAazAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUZgZ1jFSpyqP7KuvN8jPZzm1C72VKh9nWrU/DHl7ysnwWgss80zCo7uc346rdR347t9L+pz2ZYvS3x9UzabRYq9WbT04LyS0S+Ry9WrdyV+EeJi7s9B8IsyldPy0v6rU1WOfi+CNpUelzTtty95dFGevdnEv6O/xfkdjFX9DiuzJfqJ/4j+h20dCh2j2LFWlbgWJGwLFSkVcS6l8mJzL1OxSVF8i29Ai/cvuKJQaLCmVjMkjBerRXEspEWy5GJVrkdkVZcpUr+hWlR5vUyUicFGyL006G/wAj9h+poWzeZG/A/U7U9zLqPabMAGsxAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFGcvmlXw4mu+N+5h6LWR01WVk30TfyOI2uxkaODgm/HU3pJX1cpX1+Zzs7HSr3HkuZ4u85X6t+hocLU3qkl96LXzMrHV0276O/xNTgp7tS74q7f0sZYo2yZsK89be4wKVF774P8AkjYYmm/bitGrrom+JTC0Vfelw5vyROcEJZPSezaFsPLzqM7SCOa2KpWw1JvRzTqem8+H0OlgyiZ2xgnYi4l1INFipYcEO4T8y64k4IYJyYv6CijwCNjYo0MDczA/RbFVTMuSLdiBnJFISZOxGRAIG5yKWkl5pmmZn5ViFCWvCWnvOlTwzjem4nQMIomVuazAVBGNRPg0/R3K3JBUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFLmrzbaDD4Zfrqii+UV4pfBEN4JSybUt1asYpyk1FLi27JHA47tQoq6pUpz42cvCmzgNqtsa+IvvzcY8qcXaK9TnK1LsdY0yfc9H2n7QKFJTp0X3tSzV17Eb878zy/H4yvXtUqScklZPkl0S9DTZWnVk2/ElqkuCOhzOW5QcVo7aWM1lme5rrrUTkM0lGnu85NuxhZbhnObf2eMn8yWLpPSc+baRu6NLu8PvOybV2/InPAceTGo4tSn3UeHBryM7GYGTUYQTkpNRvbgr8GS2cy3dhLEVFrP2b8deCL+UOUsXCKbSUvFZ/JlG+S8Y4PTsmp7tOEOG7GK+RtqaMenCzXovoZUEREtJlzdKpEoorY6FC3JFYk2ilgCSYYRWwBaZAuSiLAkikQmXS1UKsktTdhPhqSZbkyjyWXIoZ1UpeH248En/E5naXaLFTe626VO9kqbaT/E+Jv6tO5rsdg1KMk1o4u4Vsu2Svow+DC2OzCqqj8cnprre56RhMxUrKS3X8medbBYJuNSo72ct1fu6M7P8AR/P3G6vODJbFZOgTJHm1fbhYLGyw9ZOVCSjLfWsqTfHTnE77L8wp14KpRnGcJK6kn/uxZST4KW6aypKUlw/JlgomLljgVBS4uAVAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFSjBiZhjY0o3fF6JfmG8EpZ7GFtJncMLSlOTvKzUY82+R4Rm+azq1JVKjcnOXXkdJtxnvfSsm2k3d35+Rxs1e89dPeYrLNzwbqq1FGVGGjkuS09TQZpd9dbe9t8Dosue/CzXispfIwJYZOauvZ8TXWV9DkuGde5lbPwdCKlJLXS3U3NeKqRbtaKWmt9TAzKk96lSXK1/hqZ2/u7tKKv7Lk7+ZzfJ0XBpc+wFlRTsrWb+pDC03iXCjFaXvJeS5G12qjo7coxXXqbfZHKVQpd61+skrq/nwLZwiuOSWPwK/VUY8KbTlbm7cDVbFYNzxdao+Cm7fGyOhxicWm+Pd1Jt+qsl8zM2Ky3cpudtZybYWcFuDpIx4F6KKbpOKLo5tk0SiikUTSOhXJRkSdilgAilypEAo2EVaAJItluaLjRFkMkszLVi/JFvdKMsmRcTX5tLdpzfOzS82zaRVzVY2Pe1qVBcN5OXx/kVUcsndg2+zeXd1hqUPtbu8/WWr+ZmVKD4tmaopK3IxczxCp0qtSXCEJS+EWz0VxEwxTnLHyzw/bPFb+MxElqoy3F+6v/Ja2U2sxGBk5U5b0JNOVKT8L/gzW4uq5b037U5OT9W7v8yGFpc36nnuTUso+7lpYzrjVNZSR7RlfalhJpd9GpQlZX3opx+KOhwW1+Cq+xiaTfRvdfzPnGrr6EKdK13wOy1D8o8e7oNTf0No+q6dVSV4tNPmi4eC9nubYlV+5VapuSg2otuVmuaPTqOaYiLSclP8AFE0xs3LJ4Gq0UtPZsbOtBq8uzTfuqiUGnZa6M2SZbJjaaJAoESQVAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABG5r8xzmjRXjmr/dWsvgQ2kSk32Ni2YmLzKlTvv1Ixa5Nq5ymO2uqT8NCG6vvT4/A0v6POpJzqycpPq/yOMr0uxohpm+50GZbZxT3aEXJ/ea8K8znczxk62sqjfJK+mvkZ1HALojJ/wCHRSbt5nCUpSNcaoQOKxWSqzlJN89Dn+73pTik7RdnFaaHuEcujKjFWWsUcpmGzUYN1VZNcX18ikqpR5LQsjN4PP8AB0G5Sv4XyXktC5l+Gfe2mrpzj8Em/qkdE8vjvp6RfH1V9S7LBJSi+Fpcfcc0zo4NeDTQw29iJyfCF5eVi1SpuU5S6tP3XZs80gqEKsn/AGkowT521b/Inl2G8MJL3+/VfVkdiph5thd901a6k0n7mdfgsGpWuvBHS3lE1csO5Spx/bd3brxsdRQgvDBcEvidIQ3MpOWEYWdZbv0pSS9lp/u8GZeSUlGnFLkjc4enFwcXzVmc5hazo1JUpcN5pfkd7IY5OcJ7lg27QSDZOBTAZWJMJAkgoyjRKxGQJIyKIq2UBYqCqKtAgtsi0XGRsQSWpIool2USliMAx8XWVODk+S09TA2Ro95VqV5cvCvV/wAjX7T47xKmuXH1Os2ewfdUIL7UvHL1f8jrVHLK2vETYs43tPx+5hHTTs6slG37N3f5I7Jnkfanj9/ERpJ6Uo3f4mdrpYid+kU+pqo58cnDtXdi9UW6rc2Tw9Lm0W6srts85cn3yWEYyjyLlRWsi7hKV9SFXWT6DJRRwdH2dL+nUl1jUX/aev8A6Oro8k2BmljsP576/wC1ns1tTdp/afHdeWNR/Q0uf0LUrrTxR4GHlub16SV5b8fuz5LyZus8j+qfrFml7rT3HO5uMuDzq4xlDlHQ4HaWlOyk+7l0lovibmE01dNNdU7o88q4a5TDTq0nenOUfK7a+AjqMdyktJn2noxU5XL9pZK0a0f3or6o6TD4iM0pQkmn0NEbFLsZZ1yh3ReBQqXOYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALdWoopuTslzJSlY5HPszdRunF2gnZtP2mik57UdK63NlM3z6U24Urxh977TNEsLd3d2/PUvxgZVKmY5Scu56Ua4xXBao4NLkZlOgiUUXEyqQbKxh0GI0iycWRxPBLrKK+Z0j3KSZvaKtCC/ZX0OZ7QMS6eFbXGUoo6iS0S8kcL2qVbUaUeszTb/pjpsN+qgvueY5pjqsopKclbVNOzM/YzHTdTcqTlJP7zvZmrraplvAVXTnF9Pceb4PttVpI2ZwjvNsIb1FOKvbivMpsrXvBLkkk17jHynMo1oOEuKTtfnHqW8ihOFdwXs/aXJLl7yGz5uzSSrbTR1OJqqlCVR6qKdrcTZ7J4mOIoxrRd0/inzuc7nsYTjud4oyinz52NX2cZ06GJlhKkvBWb3HyVRK7V+jRo08sS5K2aNy07mlyj1elSSNBtXgtFVS8pfkzoYDE0VOMovhJWZunHdE8SE9sjlMlx+8tyT8S0N1BHIYuhPD1rcN1/FHUYTEKSTRjXHDNcllZRmRQkikZE7g5kSM0TKNAsWlGxXdJWBIyUSKkkAMkN0WDZblUIJ5JSMPHYlQjKXRMuTqdDUZ6/Bbq0RkskanJMK8Tiot6xi+8n+FcF7z0dGj2RyvuqW+14qru3z3VwRvjXXHCMt09zMfF4iNOE6ktFGLbPBMwxDr1qlV/am37uS+B6h2l5r3VDuYvx1dGue7zPK6a0M2pn4Pqv0/p9sHa137fgrWlZGE9TIxBHD07u5lXB9J4L0I7sfca+PH1Zn4mWljBguBKK+To9j9MZhX/ANS3yt+Z7a0eFbPVd3E4Z/8AVh9T3Zm3Te0+P/UUcXxf2MXNl+pl7jT0XovQ3eZq9Gp6GioPwr0Iv7nk0P6SUolqUS80QcTKzQjGcC9hMROlLeg7dVyfqVaIWIy1yizSlwzsMux8asbrRr2ovkzMucThK8qc1OPvXJo7DC11OKlHg1c31WbkeZfVsZfBQqdTgAAAAAAAAAAAAAAAAAAAAAAAAChVlnEVVGLk+CQyPJqtocdux7uL8Ulq1yRzSpmXWm5zc3zZRUjHZLcz06oKES1CkX4InGBcjS1uUwX3EFAq4l9QG4Tgo2WYoNXqUo9Zou7owcL16f7KlL8i8VyVlLg3dQ847VKl5YeHk2ejzPLO0qpfEwj92n9WddQ8QNfQ47tXF/Bw1TmjFtqjNmtWYk1qYEfePsbPLKrhJNc9Pd0OgnmsacU423puza6JHJU2TnK+hDRxu00bJZZkYzGylq3dybfpdmHGpJNOLtKLUk+aad0/iRkysSy4OvpxxjHB7hsdnSxWHjJtd5G0ai6SS4+86CJ4jsbnLwtdSbfdz8NReXU9soVFKKlF3i1dNcGuR6FNm6P3Pz/q2hemueOz7GuzzLFWhdLxxXh810NFlsnHwvRrkzs0jT5xl+vewWq9pLmupS2ryjLTb/LIswqF2FUxaTui4kZ1k7NGWpi5jxZVyZOSNpebI7xalUI74yMGQpkZzLSYZJO0rKRbk7kkiu6CS1Yw/wBF72rCHJay8kjLxM1FN/A2GQ4W0d9rxT114pFq4ZZSc9qNlGCSSXBcPQt1Gkm3oldv0Lkmch2hZ53FDu4v9ZWvFJPVR5s1Se1HDTUzvtUI+Wee7WZo8TiZzveEHuQXktGzU3K048P9+ZCpI8uUtzyfpFNSqhGC8LBj1NWZEEki1Eq5EHchiJcjHgi7VZahxLEG0yzSpSf3akHf3nvcdUn1S+h4BhnZp9Gn8Gj33Bz3qdN9YQfyNWlfc+S/Ucfrg/sUxsb0p/hZocPHwx9DpZxvGS6pr5Ggw0PCvK6+Gha9HhUPghulXAv7hLcOGDvuMVwIOBluJblErgsmYzgbTIcVuy3H7M9V5Mwmimqaa4p3LQe15KWrdHB2KKmPga+/CMvLX1Mg3J5PLxgAAkAAAAAAAAAAAAAAAAAAAAAFGaPaDE8Ka56v0NzXqKMXJ8ErnJ1Km/JyfN/I5WywjvRDMskacS9GJWES5FGc2ZKKBcjElGJcUScFWyCiHEu7pRxJwVyWHEuZTD9bUl92MYr3sNGRlMPDKX3pv5F61llLHiJlzPHNt629jKr4qKUfgexVXo/K54bnlber158nUn8mRq39OD2f07DN0pfCNS7304GNViZKV7amZSymUqNTES8FKFkna+/JuyijJFZPr7L4V43PuaykXGWl0LrRB0z8luxFLUzcJl9Wo7U4Sl5paL3l7F4CFH+smpVXwp03fd85SLbWcnqK09qeWYVN6no/Z7tNu2wtaWn9jJ8v2Dzl6GRSqcGnZrVNaNMQscJZOGu0cdVU4SX4PoSLJvU47YjahV4qjWdq0FZPh3iXP1OvTPSjJSWT881FE6J7J90abHYN03vwV4P2l90hCz1RvZK+jNNjMG6bcoawerX3ThbX5Req3PDLbRSSKwmmroq0cMHcstkoonulbDBOSiRVIkkVSLIjJTdI1ZqKu9CtWooq7NROcq81CCfH4LqCfGWZeCpOvUv/AGcdX5+R1EVbTp8jFy7BxpQUF731fUv1ZGqEdqMVk9zLGKrqMZSk7KKbb8keIbR5s8XiZVPsRvGmvLqdX2l7SWTwlKWr/rZLkvunA4eBn1NmeD63oeg9ODun57fgvXMaepeqX4F/A5ZVq/1VKpUf7MW18eBkjFvse/O6utZm0vyYaiWpnRx2Pxr1/R5LybimYuN2cxdNXnh6luqjvfQvskcY6/Tt43o0ZCK1Lk1Z2asQa1RBri1JZTyZtJX+B7ls7V3sNQfWlD6Hh2G4r3nsWwFffwdL9m8H+6zvpX9TPmv1HD/64SOlgtDQ0I+0uk5L5m/gaaUbTqL9q/xNFx8tS+5RIk0VRU4HbJacSDiX2iLRGC2TGcSMkXmiDKNFkzOyLEWbhylqvU3pyVOe5KMlyZ1VKd0muauaqZZWDFqI4eS4AgdjgAAAAAAAAAAAAAAAAAACjKkZdQDU5/ibRUFxlx9DTUoksbX7yo3yTsvQlTRkk8s9CuO2Jdii5FEETiQiWXIF0tImSVJlGRuVJyMFutKyb8vqbDB092nFeRrqyu4x6yRuGjrUjjc/Bg5tV3KVSXSEn8jwuu7tt/abfxZ6/tzidzCVesrRXvPH5K+nkZ9XL6kj6n9O14rlP5ZjpWR7BRyvDYnAxoprccINbr8Sas7+p4+lbTzL7rzh7E5wXSE5RXyOddihng9PqGhlqdrhPa0djV2Gw9NuVTESjBdZRi/iYVWtltDSlTeImucpNxv68zlnOU9ZSlLzlKUvqSiTK5fyorDp9j/1rXL8cG0zDPa9RbsXGjT+5Sio6ebNDNXlbzMuSMV8W/8AdzlvlLuejTTCtYisE6kCVMbtkRUiDoZFOvKnKNSEnGcXdNcj1jY7ayOKioVGo1ktVwU/NHkctUUw+IlTkpRk4yTumtLM6VWuB5vUOnV6uPxLwz6IUiVr8ThdkdtI1FGliGo1OU3pGfr0Z3EHfVcOXmejCamsnwmp01mnk4zWDV43LrPfp++PUxaNW+j0a4rmdAzXY7L1PxR8M+q5+pynVxlCu3wzGsUsQu46VFZ8nyZdv7/TU4YO2SNy1iMUoLXjy8ymKrqK8+SLGDympWe/UvGF+HBtFlFvsNyXLMSKqYiW7FafJI6bLMujRjZayftS6mTh8PGnHdgkl5cyUmd4V45M9lrnwuwucttptJHC0nutd7NNQWl03zMvajaKlhKbcpJza8ML+Jv0PEs1zGpiKkqtRtyk9FyiuSRW63bwu56/SOmSvn6k/av7lqc5VJuc25Sk7yb5szYR0SWreiSMfDR0Ox2JwMb1MXUScKFow3uEqr+tlqYoQdk8H1Ou1UdJQ544XZGRluztHDU/0nMGt5JSVGUrQhF8HUa4t/dMnJ9pauNqyw+AhCnTpxvKpUi6dJRTtpCFm/fI53C7VYWvjZyx6dTDRdqEZJuDm2k6s1wcvPkjPz+pSybE9/g505fpC8eDesY07Jxaa4cG16nuQ08a/pxyfnOp1tupn6k5cfA2vzPM8FUpRnWwzp1XaFSNBNJripKTb0Wpsto84xuX0qFedXD4qnVe61GE6M+CknHVpq1+KONzrazF42pSrrC71LCyuod1OrSUubm7GTLaGWaVsLDF4eXdUJSlVnhoVJeGSVnKy8KTidXU8LKXBmVvLwzpFicDmUYqvSlh8ROG/TbW5UlH70XwqLQ43aDI6mFnuy8UJa06i9ma/j5G+7TNraE408Nhe7qKCjJV4e1RknZRpvlw4mZszmMczwksNX3XXitJ2t+st4KiXnz815mK/Spx3o93pXWJ02KE3mJx+FWqPTOyqvejWp/cqy+DVzzWlTcZbstJRk4yXRrRo7TsuxO7iMRS+/CM0vR2Z51DxYfT9airdI5LxyeowNRjtK34kmbeBrM5jZxkbLex8TU/qLSKlIvQqjOaCjItlZEGQTgjIhIuMg0VLFuRu8jr3huvjH6GlkZGV1t2or8JeFnSqWJHO+O6J0wKIqazAAAAAAAAAAAAAAAAAAADXZzidym7cXojYnNbQ196aguEV8ylksI60x3TwYFGJlwRj0jIiZTeyRO5FEmgUwSiy4mW4onFEkEioSLdWfIAngo71RPkjatmBlUfafuM6RprXBlteZHBdqGLtClS+9Jya9Fb8zzarpc6vtDxfeYpxXClFR971f0OWkuK6nn3yzNn33R6fT0sfvyY7nd36Ine6IShYvKOhzbR6hCCLkUUSJMgghUd+djGk7ei1ZfqQuWqiXBEosi5Hh5stsv8i3JAgnEs1oF2BWcSMhPBapVbHZbN7a1KFoVr1KXr4oo4mcSdKZeMnHlGbU6SrURcZrJ75lWdUcRHepTT8rq69TYbx894bEzpy3qcpRfWLszssk7RKkLQxEe8itN9O01/E2w1OfcfK6voFkMun6l8eT1CUVLRpNdGY8sBD7kfddGJlWf4fEK9KpFv7rdmvcbRTO6w+x4M4TreJLBj08JCLvuq64czIjIpOpHm18UabN9p8Nh4tyqRlLlCLTbfSwbSXJMKrLHiKbN3OaS1aS6s43ajbanQvTotVKvC69mPqcftDtnXxF4x/U078E/HL1OTq1+Nnr16mazUZ4ifS6DoOPrv/Yu5rmE69R1KknOT435eSMWlHUtozKELIyt/J9TCMYRUUuEXIHXY6r3OTRlHR1FVm7cXKU1BfKRycjra9Pv8m3I6ypd5BrzTVRfKJp0DxYfN/qWLenWO2TlobO08NhpV8bSlUl3tOmoUq6UoQnDe3mlfXhozoamPh3GFlSj3tGpCW/HFQhKdSUZWe+/JWS9DBnL9JpJLX9OwkEvLF4ZWcX5vduvxGv2drb+EnRft4arvpP7k9JaeTR6eq3Sqcs8pnzPTYQjqYxkvpki9tZm1TC1cLDCSlh6EKFOpGlTdo3d95SX2r+Z02JxbhWpwoKNGnKVKpu0lub8p7rcpW46tnE7cxvHB1OtCVNv9qnJ/kzq6Piq4Vv8AucPJ+kYXb+RoralBP5R4XVVKu1xXH1GupZjGpmNfCqjRp4a+JVSMKcXOdoSbm5PW99S3kdCGEzChGhKfd4nDuVqjTkpJtxvbTjD5mBs5eVbHYvXXvKcGuLqVp6Je42WXUHUzSEI6rCUYUrrh3jiotf56kvgy9kUoM6VTbuSJbWUFDHVlHRSkpr95Jkdkcd3WOpSbspSdN+8rtRXU8bWkndRkqa/dVvyNEqjjVUucZRkvc7nzTeLMo/UoVOzRKEu7j/4PoqJi5vC8E+jI5Viu9pU6i4ShF/IyMbG9OXxN8uYnwS+meH4NPRlyLrZYi9S8pGRGooyNiTKAkgyhKRCxBJGpwLSfTyt6l2ZaRHknusHVYGtvwjLqtfUyDUZFWupQ6ao21zdF5WTzZxxJoqACxQAAAAAAAAAAAAAFGAUnKybfJXOMqz3pylxbb/kdLnNbdpS6vwo5mmjPdLnBs0se7L0EZECzAvROKNLROxJFIkkSUyVJIiirZJUq2WakiU5FmS3pRiuMml7uZUt2WTcZfC0F56k8VVUYzk9FGLb9yuZKjZJdEcx2gZh3WFmr+Kp4F7+Jsb2xM1FbttUV5Z5XmWI72pUqc5zb+ZgTk970L0eHki1FXbZ5Oe7P0quOyO1eCFfkSiy3NP56FaN970DXB1wXLBlWUuQQRa06MtblvUuvUioc2WRKEItFJovKxbmQiCMCbf8AIgTT6gFqrEs8DIkWZolE5KqTKyafHiQgbins5imlJYerKLXhcVvRa8mi6i2s4OVl9dXE5YNPTrSg7xk01waepvIbb4yMNxVZaaXai38bFHs5iOeGr/8A1yIrZuv/APGr/wD1yJTku2TJZPR28ycWYOIz3E1PbrVHfzZgPESve7v5nQR2fxHLDV//AK5F6OymLnosNUXnK0UHufglanSV9pRRy7k5cWRn0RlZhhJUZypzVpQdmr3szG3WyMYNqaksolQhc2EYlrD0jIZzbJZbnHpwOi2NzOMJzoVXaliEld8I1I+zJvzu0znplveL1zcHlGbV6aOpqdcvPY22PwTwVWph6rlTw1SoquHra/0bEJXV30/Kz6kKlCVKt+lRhdzi1jaEdVVpS9vEUrcYv2tODNhl+0VOpT/RsfF1adt2NVRUpqN9IyX2kuvEvYfZ6rFKWXYiliKKe9GjOW9uPrF3UoP0fqe3XqI2R+58DqdDfpJYlHOOzNHtRhd7CeF76pVY1aU1wnQrJx3v827ddTb5hvwg1TTdadKnhaC/adNb8/JRje780ajNcszFU6iqQqretuUsPRk4veknPRR0Wl+NrpGZlOTZnPu5xlWg91qo8VTdk21e28tV4V8DvXthDGeDy9XGWptU8ckZbmBo04R8c1eVGFnvYjEy0dWy4Qjol1tfmbXIcN/w/CzxdfXE1ryinZynVle3wu7+rEMLhMFJ1sVW/S8XbgnvyXldaRXoaDNs1qYmp3k2t1aQpq25Tjyil6GTV6tYxE9ro3RJznvsWF/kxoSbe83eUm3J9ZPizHxatNPqzK3SxmUNIyR42eT75LHB632a4zvMKoPjRm4e7ivqddNXTR5Z2XY+1aVNvSrC6X7S0PVEelU90D8/6nT6Wqkvnk5+pGzJxkSxkbTkvO5Yi7GXs2VXKRkEWyikASUZQqRkCSFWRZLsy0yCyM7J6m7Uj+1odIcfRnaUWuTT+Z18XdXNVD4MOpX1ZJAA7GcAAAAAAAAAAAAFCpQA0e0lTSEeruaiKMvO6m9Va5RVveYsTHPmR6dKxBF2BciW4FxFSzLkWTTMdTdy8mEyjRcLcpFbluQYwJSL+SU96pKb4R0Rg156M3uR0t2lF85alqlmZS54h+TPaPK+0/MN6tCin4YLefqz07G11CE5vRRi38DwjN8d31apN/2kpP0V9DpqZYhg9H9Paf1LnY1xH/JjyXJFvDrj6l1xsnYjQVkeb4PtTGrvxCg/EUre0Sox8Rd9i/guzIk3z9SMlr8ipBamnxRCTkybuvQlGdy2SckI3RcmuZNxTIW5EZIyQKxIlbgFb/yISiTsJkgxUrP3m22c2vxGDlaLdSjfxUpNtee70ZrZxMCtHV+rPe6TFTjJNHx/6obUoYPfdm9p8PjI/qpWqJXlSnaM4+n3l6G7sfNeGrzhKM6cpRmno4uzT8mej5J2g1o01HE0lUkrWmnuya/aXXzJ1lMKXnceHpdPfqV9EcnptijODl2i9MO/fUX8CzPtEnyw8V61H/A8/wBWPyb49I1j/k/ucjtZC+NxF/v/AJI1ypLoZGaY11q1Sq0oubTaWqWhZizDN/VlH2+njKFMYy7pE0UbKyLcmUwdcEaky2yTLcyyRZEXLoVhNxe9FuMuUouz+KLLkTgWXBEoxaw0bSjn2KjpHE1kvxt/Ut4nN69TSpXrSXnOVn7kYROnTDm/k5fw1K5UFn8EVT6GTCmVhAunNvJ0yRsQxMLxaJsk0QQNlMd3ValO/sVFf8MtGe+UpppNcGj5vit2o49fzPd9lMf3uEw9R+1KlHet95K0vmj0NK+Gj5X9RU8xsX4Lmbq0ovroYbdzOzv2Yy6SRroSKW8TPFr5gi9EkW4slcqWKtkblJEJME4KzLDZOTLbKl0SR1eBqXpxfkjk0zosjqXp2+7Jr8zvQ/Bl1S4ybMAGoxAAAAAAAAAAAAAi+ZIsYypuwnLomQ+xKWWcri5b1Sb/AGn8hAsw1163LyMTfJ6qWFguJEkyMSSIDD0LhBMJgqXCMg2W5SJGDDxkrL3/AJnY4SNoQX7K+hxeMd0zr8sq71KnLrFfwOtHdnDVrhHMdpWad3h+6i7SreHz3eZ4/Qlec3ySsjq+0/Mt7FSjfSlDcXS74nKYBeFvqzhqJZl+D7Poun9HSp+Zcma+BbpSumTb0Mak+JmSyeskQnHVkqL1DRKgtSzLF2S/iW5Mm58fQuYaooyjKUFOK4wbaUl00KpcnOctsW8ZMXvOo7u/A9QybK8qr01UjSpxf2ozm1KEunEv4LZvLaLm5OjO7vapOL3Y/dWvA1LTt8pnhS69CLadbyjydQZP6nR7WZhh5N0sJSpQgn4qkYq8rck+hzTn15HGcdrPY0t8rq97jtyGRTJMo+PIrhnbfFcZQRUfD4oP6+gwxuXyQaMdYOUpN2sm/iZE1b/aEKj6s2UaudKaj5MOs6fVq5RnPlIuUcNGPBXfV6l1GP3r6sq5y6szznKbzLk111wqjtgkjJRVGI6kur+QVdrSTZQ6d+zJzVpMlGRSM0+PxI1JLhp8UVwyNyzjIlUZBzIuRJegxgtwQqTMeTL09eGvoP0edr7k91cZbklFe9o6JcZK+rDtlfuWqdO5kwpcC7Qo8ravy1+Bs6WSYmaThh60vPcaRHL8HKzU1Q4lJI1ap+RejH8jay2bxa1eFrcPumDicLUp6VITg/24uJVwku6KR1lM+00Wd78yrmQbKMrg0oOZOMy1IXAMXHrWM1xvZ+p6t2Y4newso/cqyt5J6/xPLasd5Ne/3nc9lNd2rx5fq379UatM8SPG65Xu0z+zR3WeT/VpdZRNZSZbzfHqdRU48KfF9ZFaL4FrJZmfLVx2w5MqLJFtMncoSwRkyrIAlEZEGTbKEFiKN1s7P+sj6NGlNjklS1RL7yaOlTxI43rMDpQAbTzQAAAAAAAAAAAAavP6tqTXOTSNmc7tJVvOEFyV2UseInWmOZo1lIvosQL6RiPTJoqRRMsVKFbkQyCCrZYqyLjkY9RgskYmKqHQ7PYxfo8rv+rcvcuJyePnZPn5Ix5Zz3WDxCvac7KK8mrMvTLDZ0np3btivLOA2gxbq1qs3q51JS+L0+Vi7RjaMUa1tylrzZtGtF5GWb5Pua4KEVFeOCc3oY9PmXZPQtQfIqixUrSmQqvQt0XqWxwWwZ2Gw8qk1CnFynKW6ormz0TJNgIRSeKbnL+7i92K8m+ZhdlmATnXrtXcFCEf3tW/od/j8R3dOdTjuQcvguBppqjjcz47q3UbZXOmt4S4/qYdDJMJTVlQorzlCL+bLyweHeipUPdCB45muf1q83OVSaTd1GMmopdNDBWNqxd41ai9Jy/iT68U+xePQLZLdKzk9sxOQYWorSoU/dFL6HB7Y7FKjGVfD3cF7dNu7S6xZDJu0CpSpqFSn30lwm57rt0ehLH9oM6sJ0/0eKU04t95ey+AlKuSOWm02v01qw+F9za9meApzw03Upwm+9kryinwS6nVVcmwvGVCjbq6cTRdmUf6I31qzf0OkzLBqtTnSblFTi470dGr80zrWltXB5utsl/Ezy2uTBWT4L+5of5Yh5Lgl/YYf/LE0b7PqfLFYpfvop/7ew/+Xiv838hz/wAS26v/ALn+zMXtCwOHhhb0qdKMt+PsRW9b3GHsrsFCpShXxMn41vRpxdkou1nJ9TC202ZhhKcJwrVqjlLdaqSTjw4ncbIZxTrYeklJKcIRhKDeqcVY5pJz5Rusttq0cXTNtNvLKQ2QwMF/URsucm/zIPZ/LuHdUf8AN/M22b5bHEUpUpylFSt4oScZL3nJT7NqfLFYheu63c6SjjtE8+m3cs2WyT/qbiWx2BktKKSfOMmaHO+zqm4uWHlKM0rqE3eL8r8jc5Xs1XwylGljZtO2lWkqiXp4lYs5pg80jFuniKNRWfhVLu5NeWrQcVjmJ2p1NsLFst4++TiOz7LYTxk6NempKNKd4yV7TjKK/M9M/wDTWES/5al/lRwGwUp/8SfepxqOFXfTVnvaHq7K1RW3sder32K9Yk+y8ngmd4S2Kr04Q/tWoxjG/SySO32U2Dtu1sWlfSUaHG34vPyOuwWQUKdWrXUd6rVm5OctXHh4Y9EZObV6kKU50qfezUfDBO12I1JPLLajq9tsI0w445fya7N6+EwdN1JwpQ5JKnHek+iPP44mtmuJjSX6uktdxcIw6vqzRZzjsRXqyliN5ST9iSsoeSXI67spS76vf2u6jb03mc3PdLHg3PRrSaSV+d0sd/j8HdZRkNDDxSp043X22k5N+pfxuZ0aKvVqQh+J6/Ay5O6fXU8KzqvUnXqSqNuSqTVne0dX4UdbJqtcHldP0b11rU5Ywet0trMHJ2Ven5XdjZyjTrR13KkWudpJngiO07NMxqKvKhdunOG8k3fda6HOvUOTwzdruiqip21yfHyZe2exkYxeIwy3d3WdNcLc3Fcmec1FZn0RWinFp6ppp+h4HmtBRrVYrhGrUS9N9lNRBLlG3oGtnbF1zeccmHclco5dFctSpTlzsZz6MrOpZrU3uzuc/oyqqCe9Ws4ydtyNuNzmatGSIVK7jFrqtC8Xh5Rl1lStqcWeg5Lid7W923dvqzpsPI4bZir4Y3fQ7PCy0JTyz5O+GHheDPiy6mY8GXUy5laJtkSrItgIjYqggQSQkXcHUtOEukl8y1MQZMXhlZrMcHapgsYGpvQg+qRfPQPJZUAAAAAAAAAoypRgFDksyq71Wb87fA6nET3YyfSLfyOMjq2/O5wvfg16VctlyCLqIwRNGc2skVKXFwQVuUbIplHIgghJlmqy5LmWajBdGDiadzltsqSjSpu/ilKSsuCikddUkupxG20m5U1y3br4u/0HZHq9MjuvX2OewcLy9DZyMbBU7K5lGeTPqGy1Ulp8S3HgTrIguAXYkszlfQnSpFpRu2ZdPgS3jgsz0Hsnxa/pFJtb3gmurVmmd9iqCqQnCXCUXF+jR4LluPqUKsatKW7JfBr7r8j0PLO0ak1atTnGXNxW9E102x24Z8b1Xpd/ru2pZz/Y0eZbAYmMmqSjVhd7ruoytyuQwfZ9i5NbyhTXNuV/kjs1t5g7e3P/ACMs1u0LCLh3kvSBLhVnORHWdU27VF/sZ2S7J4ehTjCUIVZ8ZTnFXb8vIwdsqmGw+Hmo06aqzTjBKMb68zSZj2jyd1h6dvOb/I4zMMwqV5OpVk5y83ovJLkVstgliJfR9K1U7PUveF378nqnZxC2Ch5zqf6rfkbnO6daVGosNJRrW8DduNzzzZ3baGGw8KPdSluuT3k1zk2bL/3Kh/cT+MTpC2O3DZiv6Zq3fKcYZWSv6Fna/tYP3w/gU7jO/vU3/kKrtLp/3E/jEku0mjzo1PkRmH/I7fw+t/6F+yOf2rw+YqnGeMcXTjJW3d292vI6XLdhcO4xq0sRiYuUU1KE4L/8nP7X7W08XSVOEJxakpXlbkYOzm19bCpU9KlJcIy4x8l5FN8FP5NktJrLNKtq2tN8djvMzynGUqUnhcXWqVI2tCqqck109lO5yks6ziLs6c9P+in80zfYftHw7Xjp1IvnZKSL/wD7h4P/AKn+Q6OUH2lgwVU6qvKnRu/oXtkMwx9VyWLoqMErxnu7jculrnUnG1O0XCLhGo/3LGhzntEnNONCG5dW3pe0vRIn1IxXfJx/+M1V9nFe3P7GfhakXnT3Wrbk4u33lFHoMjw3ZvNlQxUcTU3p23963GTkuJ2z7SqPKlUfruorXbHDbNXUOl3uyKhHckksmBj9rKuEx2Ii26lHfg3B8Y+CPsnd5Rm9LEwU6U1JW1T9qPk0eLZ/mCxGIqV0nFT3dHxVopfkRyrNauHnGpSk4tcekvJrmc434k88o33dEVlMXH6Z45PXNpdl6WLje25VXs1Y2v6Nc0cJkzqZZjIqvFxhO8HLjGUb6ST/ACNpDtKdlegr82paXNfnO2lPE03Tq4a64p7y3ovqnyLSlB/Uu5l02k10IumyGYP7/wBz1OjVU0pRaaaumuZy+0WxNPESdWnJ0qkvasrwk+rXXzPPcm2oxGG0pyvTv/Vz1jby6HW4TtKjb9bQafWLUl8yfVhJYkcH0rW6Se6nn8GE+zavf+upW62kmjqdlNlIYNubk6lWSS3noorpFGBHtEw/OFX/ACosYntFpW/V05t/tWihH0o8oi6PUr1skn+x1+Z4yNGlOpNpKMW9dDwrE1e8nUm/tzlP/M7/AJm12g2krYp2m92nypp6e/qaKczhdZv4R7nSOmy0sW5+5klYuRRYSL0EcGeyyUoI02b0rKL82v4G6ZmZJglXqqjJJ78ZpX+zJRun8UWr5eDLqpYqb+Cmy0/DFdDuMG/oc7hMuVN6f7fM3tDgdcYZ8vdJS5RsYSL0WYtNl5SJMjRfbKlpSKQl1GSMFxskQRK4BCRElIhcgk6XIal6ducZNGzRoNnKnilHqr+835vreYnlWrEmVABc5gAAAAAAoypRgGuzytu0pW4vQ5iBu9pZ6Qj1d/gaemjLc/qPQ00cQyXYIuWI0yUjkdyLKXIsiwSVItlUQkwTgpKRj16hKrIwMXW0ILxiYWYYtRuafbPCeLDPlLD03fk3xf1LGeYqyfHQ3uNwzxOW4XER8U6MXTnbVqMXb+DJSbiz0dNJUXQk+zyjkoqyEpFSMkZD6jJZqMhUdkXpRMerq7FkWRSl1E6/QVNEUowL/csVSsr8ydNc3xKJXduhKtLkiGCjqPkN58ydKBccdCraK5RaST4cR9Q1YnJc0AW1/wCULE7arzFvkwCBSxNriUlEEkAXEuRFxJBC5UlYpu/yAIr/AGg0St8SVvgSC3H/AH5kmiqj/Iq1poQCNtNA18CTjz5Fd34MAiijK7rt6FbfMEFCiJW/NFGCQ2U3mCjBGCLZRNkmR5+8ElxE4zLcS9EqyrLqNvshpi6P73+lmoRudkIb2Kp25Kb/AOxlqvejDrmv4eZvZR1L0DQ5bmu/pL2k2n7mbehVv6GiXc+VcWuDY02XUzFpyLkWVKYL6kTRYTJoqMF5SJoxrl6LJRDRMtyLly3InBCNhkcrVY+d0dQcfl892pB/tHYJmuh/SedqViZUAHYzgAAAAAAoypRgHM7QTvVt0jYwaT/Iv5pK9WfrYswMUu56lSxFF5EZsXITZUuhJkQLkF8FLluTJSeham9ACxiJaGlx9Y2GJla5oMzraMg71R5NLmdTelY73YxQw2DqTrStRnNKz1Sb0bPOoy3qi9T0nNsHbJ0utpP3yO1PGWX1fOyv5Zyu0mVqhVtF3pz8VNrVWepp5HT52m8DgZzd5qG7fnu8jmTJdFRlwfR9Psc6Vu5ayv2LUpFhR1uZU4kEimTcmY1aJcprQpViTpE5LMhS4slOJVxs7ly1wwLFYlYMk7FGVLNVEmtBJXZWS4IsSUlHRfIo1xJ219Bu/MgEHHj6FWvoTaDQQbLbh9Cm4ZWFwc6r3aUJTl92PTqZ/wD6bxf/AMar/l/mXUZPsjhPVU1vbKSTNNu8CiX1M7GZfVouKq05U3K7SkrNoxUvqQ013OsLIzW6LyiG7qUUdH5Fy2qEVxILkEtEyq4+4ycHgqlW8aUJTa1agnJoynkmJv8A8vW4f3cv4FlCT7Izz1VMHiUkmazd0fqRfIycRhp0/DOMoS42krO3UsyXAryu52jJSWV2IuOpFcvUuW1KKPD1GSxbt9RGP1Li/MJfUAtbv5kX/AvNfV/QtzRKCLf8yj/h9STRT+BYknBcfUuxLUf4Mux6FGUZNv4nUdnkL4ic3oqdGcr+45U9E7NMu3qWJqNaTTpL4O/zZ106zM8nrFqhpZJ+cI80weK/WVHyc5tejkzrMBXukrnG46j3VacOG7OS+DN5lWI4HV92eTbBYTXwddQmZCZrcLU4GdCRQxtF+LLiZZRJMqQXJevqi5BljjqXIMAyEyjIoSZcrglTdnf3nZ0JXjF9UvocUjrsqnelB+VvgaKDFq12ZmAA0mIAAAAAAFGVI1Ho/QMI43Eu9Sb/AGmVgW3xfq/qXEYnyetHskVZCTJstyKssijKNlHIiyC5JsxqjLkmY1WRBZIw8ZPicxmtTzOixkjls3fEGmnuYeVU71Vz1R7licu7zBdzbV0Ul62PGdlqG9Xgus19T36mrJLokjTSuDD1OxqcceDy/b2j3dLCUuUIJfCKRxZ6r2i5U6lFVILxUnvW6x5o8qkzNqYtTPo+h3RnpkvKItFLkkRkjMe0W5IpTWpKQgWfYsXXG5SmivAQKeColAi1wLxEZGSkEV5kiMQQFzK2Avr7gCgKiKu7Li7JepKWeCspbU2z0DsxwGlWu1xapwfp7XzO9Nbs5l/cYelS5qKcn1k9WbI9WuO2KR+c6y71r5T+5512pL9ZQ9J/kcG/zO+7UvaoP9mf1RwT5GC73s+06L/tIDmI8yvMvYLDOpUhTjxnKMV73/5OS5eD0bbFCLk/B6T2a5duUJVmrSrS0/AuHzOwRYwGGVKnCnHhCKj8FxMg9aEVFYPzfU2u22U/k8n7R1/S3/hw+sjleh1faP8A82/8KH1ZyvI8y5/Wz73pn+1r/AZRLgClyhvQS4L1KdPiVv8AmRbBYov4kW/p+ZRshJk4BWTI3/MjKX5Ed76lsEl6P5FxFmDJ7xVoqybZ7dsRhO7wVBWs5Q33+9qeL5dQ7ypCmlrOcY/Fn0DhqShCEUrKMYxXuVjXpY+T5T9SXeyv+p4T2jYLcxlVrm7/AB1NdlVXgdR2rUbYhvqkzicDUsxYsSIp+uiLO2wVU21OZz2WT0N5SeiORmkuTLi7kky3BlblSuC9Fl2KMaLMikCrL8CkmIhosihRM6nZ+d6SXRs5ZnSbNPwSXSR3o7mXVe03AANZ54AAAAAALWJdoyf7L+hdMfHv9XP8L+hD7Ex7nHwZdRZii5fmYj1kTZakySkRkyCyIMgVkyDZUsUkYtZmS2YlV8QWRr8ZLRnK5nLVo6PHVOJzeLV5e8Gqrg32wGEvXpt9VY9nPNOzfDXq71tIRb/I9MRsqXB43UJ7rTHxyW5O6ulFu3XTgeN7S5ZGO5Xpf1Ne8or7suh7TWjdNdU/oebbU4TcwNGP3Ksl6eJlb4bos19Fvddyin3eDhS3NkpstI83B94g2VTK7pG4JJK5diWVIuxkQ0QydxctTmRTbIURguylckRjEAFZcAU5lScEFUbzY3Lu+xVNNXjB95L0Wq+dvgaNHo3ZjgN2nVryXty3I/hjx+bO1EczR5XV9R6Onfy+DuCguVZ6Z8GjzvtT9qh6T/I4OXI7ztT9rD+k/wAjg3xPNv8Aez73ov8AtI/1EeJ13Zvl3eYh1mvDRTt+OWn0OSXE9d2HyzucNBteKr+sl7+HyJ08Mzyceu6n06Ni7yOiBS5U9E+JPJ+0N3xkv8OH5nLwR0vaA/6ZU/BD6HNJ6Hk2e5n6H03jS1/hFLEGTKMobkQIzXH4k39WQk+JJYtz/ItTZd3eHoyElw9C6JLLKJFzdJQhyJyBFFUi4vQmo3KZK5On7N8B3uLjK3horffray/M9lR572S0YqOIl9reiv3d26PQj0qFiB+e9Ztdmrl9uDzLtZw2sJ24xfyPLKDtI9u7TcLvYdTt7MtfR2PFJxtN+pxtWJHpdOnuox8HUZXLRG+oy0OZyyZ0OHlojgVsXJnKRJFiMiaZByMhF+mY0WZFMEMvoqyMStyUcwzotmH4Z/iX0OckdDsv7M/VfQ70e4z6pfQb0AGw80AAAAAAGLmb/VT/AAsyjEzVfqqn4WRLsWj3RyUSaLWHldF0wnqhstsk2RbILotshIuTZYkyCyRGUjEry0L85mHiJkF0arMJcTQrWRtcznozBy2nvT6lkd09scnqHZ1hN2nOo/tNRX1OwNds/hO6oU4c91N+rNiborCPnrpbptixyu22ETwtZJcFv+/idUc7nWJU6lXCPjOhvR89Wn9UJ+1nTSScbVJeOTxStIUkXcTRcZSjLjFtP1QSPJfDwfpcZJpNFJMtWLkmQZBZArAXKIkkjUepeTsWWtblb3YBd3+RPgiKViqVypDKx01Kx4FJC3BAqXKUHKSiuLaSXne35nt+TYNUaFKkvsQSfm7as8v2Dy/vcXFtXjSTqS9VpH5t/A9XxtZQpzm+EYtv5m7TRxHcfHdev9S5UrwarB5j3mMrUk9KVOKfTebu/ojds8/7PcQ6mJxdR8Z2l7nJ2PQGd63lZPI1dPo2bPjB552qe1hv3/yOCfE77tUXiw/7/wCRwF9TDqPefZdFf/44/wBTZ7PYB18TSpcnNOXlFas9qbUY9FFfBJHBdl+Xf1uJa4vu4efNv6I6TbHH9zharT8UluR9XoaaYqEHI8Dqtr1OsVcfHH/su7OY51oVanJ1qij+FaI3By3Zyv6Ivxz+p1DO8HlI8nUQ2WyivDPItvXfGVfJQX/ac70Rvtt5Xxtb9z/SaG/M8uz3M+/6esaaH4X+CkijJWKNcveUNqI24eRbktH5snLm/cRlyRKLEJL6Ft8i43xIc0vIsSQ3bpk93RMrBXT9StPoGQ2SjUJKV/Ioooq+iKlT0HsnrePEQ/ZjL4Ox6UeV9lc7YmpHrS+kj1Q9LT/6aPz7rMdurkaranCd5hq0bXe7de4+fcyhuzfqfSteF4tdU18j592qwnd1qkek2vmRevJ26TZjdAZXM6HDy0RymVSOjw9TRGRm6xcmyhMvJmJCRkQZBwaMqmy/FmLFmRBgqzITJXLaJNknMI6TZb2an4l9Dm2dJsr7M/xL6Haj3GbU+w3oANp5oAAAAAALOLjeEl1i/oXiMyH2JXc89ws7O3m/qZ9jXVYbtWa6Tf1M+nK6PPPW8ISLdy6y0wyyLcixUkX5GLVZB1RYqzNdiqxkYiZoswxXEHSKMbHVbm92Hy3va0FbS+9L0Ry9O85I9g2EyjuaKqSVp1LW/Zidqo5Zw1lyjXg6iK5FQVNh4gPP9sMQ6eY4OS52g/OMnZnoBxW1WB7zHYF20UnJ+kLMpNNrg2aGUY2Pd8P/AAcVtvgu7xU2tFUSmvXmaBs7PtMiu9pPm4P8jjDzr1ixn3XTJuelg38EZECTKHI9BFLCT1KspckEeZWm+LDXEi+QJLqbZe4IhT0JrUqyrCXUrFFL3JXsQQz0rs0wSjRqVvtVJWv0jHS3xOjz3L5V6M6UZ925pJytfS+qNL2cP+iL/En9TqUerVH6Ej861k5fxM5Z5TOZ2U2XeEnObqqe9FRtu7ttW/zOnKNlS6iksIz3WTtlvk+TnNq9mnjO7tU3Nze4x3r3t5o4HaXZl4NQbqKbqNpeFxs7erPYTg+1Dhhv8R/Q43Vx258nqdM1l0bI1KWI8nUbN5eqGGo0lygm31k9WzB2syCpi9xQqxhGN204uV5cjeYT2IL9mP0LqR225jhnnK6cLXYnzk1OzGUvC0FRlJTalJ7yW6nd9DbBsqSlhYOUpucnJ+Tg9oNiq1evUrRq00ptWUlK6srHDZpl7oVZ0ZNNw4tXsz3RnjW2b/pmI/Ev9KMeorSWUfTdC1ttlnpSfCXBpl9SjH5FDGj6oo1wXvLbfFkpPRv3EJckSSiD4L1I8yc+JbhxZYkrSerRNw10IWsTVQjkFVFk4q1yO+GRyVO57KoXxFWXSn9WeqHm/ZJT/wCYnzvCPyPRz06FiCPz7rMs6uX2DR5N2o5Vu1VUitKiu/VI9aNBtnlar4eWnjh4o/mi9kdyMmkt9OxM8JwM7Ssb/C1eBo8bS3JMy8FiDA0fQzxLlHRUpmXTkavD1DPoPqVODM6mzIjIxaRkRBzZeiy4izBl25JzZJnS7KrwS/Ecwzqtl1+qb6zZ3o9xl1XsNyADYecAAAAAACjKlGAcJmsf1s3+0xh3oXcxj+sn+Jlii7aHny9x68fajIaLcy42W5hlkWZmLVRlzMSsiheJq8a+JyOZVLytwOsx74o5PMaUnLetouJKO8To9hMuhUr0lO1lK9nz57vvPZ4q2iPBsix+5KLi7NO6fRnsWzudRxEOK7yPtL80a6ZLGDzNdCW7d4NyClyp3PPBgY7DpzhU+1FSiv3rX+hnmPjFovJolExeGeS9oWJ3sTu8qcUvezl2zc7ZX/S63r8rGlaPJuf1s/SOnwUdNBL4KkWSaIs5m1FChUNAFLEEtSauRXMkkup3di7ItUXpcuFWVZWKsUsJskgyDosj2rqYWl3UIRkt5yu21xM//wBwq/8AdU/izjUOp0V00u550+l6acnJxy2eqbH7T1MXOpCcIxUYppxvxZ1Z5v2Xv9bW/BH6no5vpbcE2fHdTphVqJRgsJHM7Y7Qzwndd3GMt9yvvN8l/M4LP9pamK3N+EY9295brep0faj/AGHrP6I4CXMy32S3NH0XR9FTKiNso5lydhR7Q6ySXc09NOL4F6PaJV/uafxf8Dh48is5HP1p/JufR9I/5D2zZzMniaEKziouV9E9FZ2Nozn9hI2wdH0b+Z0DPRg8xyfDaiKjbKK7Js4nN9uXRrVKPc7yg7X3rX0T/M4PN8Z31apVtbfd7XvYydqJf0vEfj//ACjUvU862yTbR9v03RVVQjZFctEX9SpW3Fg5HqotyXBEWtfRE5cS3N6NkosQ5kaK4klwuUokguOJRQRR3IptEYILyiRlK2pW5bmrteQRCPSOyip4a6/bh/pPSDyvssq2qV49Yxl8P/J6omepR7Efn3WI7dXMqRmr6dSpSUrcTqeWeJ7eZZ3NepH7Le9H0f8AtnK4F6nZ9p2awq1rQae5Ddb5N3d/qcZl/XqYLO/B9Hp23UsnSYJ6Gzos1mDNpSRyJZmUWX4mPBGREHJl2JMgiZJzK3Ov2cjajHzcn8zjbna7P/1MP3v9TO+n7mTV+1GyABsPPAAAAAABRlSjAOZz3CuM977MvqaeWjuddnK/VS9xyczHbFJno6eblDklvFJMjEqjkaCNjHrQMlEJ/wASCYmpxFLU0ePoXvodHWNTjAjumctODhK5vMgzuVGcZRdmvmujNfmHMwKPFF1wy8oKS5PfciziGJhvR0kvajzTNojyzYObVeFm1fjq9dD1M2QeUeDfBQm0ipCrC6aJlGXOJ5L2j5e4Vo1baTTT6byOPZ6n2lr9Svxx+h5YzzdSsTPv+i2uzSrPgoUbKhnA9dFCtyhUApch1JS5ESUC7TehegWY8EXWVZDKIN8hAQ4sMCWhVLQjPiTIKvg7Ds7xtOlOvKpOME4wSu+PM7n/ANQYX+/p/wCZHisef++RH+ZphqHFJYPE1XRIX2uxzaydt2jY+lVdDupxnbfvuu9uBxZRcQcpz3vJ6Wi0y09Ma08kHK1w3oR6+pbxHIhI2bcnsOyeZ0YYSjGVWmpKOqckmuZuHm1B8K1N/vI8HpPWPv8A9Jk34mlahrCwfNW/p+Lm5b/PwZ+0FRSxNeSs06js+KasjARRkn+Rlk8ts9+qvZCMO+EUXAi+IfIo+ZVHVEXw9S3VXBFx8I+pGXEsixaqdCNJFKnEuU+BbIDfQpF3JosviQQX48CNR2RJcvQtzIRCOs7L6r/Sai5Ok/yPWsuxKnC65SlB+sZNP6Hj3Zj/AM1P/Cken7LPw4j/APrxH+s9On2nxHXYr+Jk/wAG8lK2vQ85282zSToUJdVUmv8ASjsdqJNYWs02nu8U7M+fM3fiYuk4rgw6CiNjy/BDE1+8lZ31ZnYXB/d4GrwPH3/mdFgDJnJ67M/CRdkbSjyMOjxRn0+RQ5SZkQReiW4F2IObLkSkpCHMtLiChdidvs+v1FP3v4s4iPE7zKP6mn+FGnT9zHrPajNABrMAAAB//9k="},{"number":"7","name":"Petra Malikova","role":"1","height":"178","birthYear":"2002","stats":{"pts":"1.3","reb":"1.3","ast":"0.3"},"traits":[],"photo":"https://basketball.instatscout.com/images/players/180/522068.png"},{"number":"4","name":"Gabriela Andelova","role":"2","height":"173","birthYear":"1996","stats":{"pts":"2.8","reb":"1.4","ast":"0.2"},"traits":[],"photo":"data:image/jpeg;base64,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"},{"number":"2","name":"Julie Pospisilova","role":"3","height":"183","birthYear":"1999","stats":{"pts":"4.2","reb":"4.0","ast":"2.2"},"traits":[],"photo":"data:image/jpeg;base64,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"},{"number":"9","name":"Karolina Sotolova","role":"1","height":"176","birthYear":"1999","stats":{"pts":"3.3","reb":"1.5","ast":"2.5"},"traits":[],"photo":"https://basketball.instatscout.com/images/players/180/28516.png"},{"number":"12","name":"Monika Fucikova","role":"1","height":"174","birthYear":"2002","stats":{"pts":"1.0","reb":"1.5","ast":"0.0"},"traits":[],"photo":"https://basketball.instatscout.com/images/players/180/522070.png"},{"number":"1","name":"Katerina Zeithammerova","role":"1","height":"162","birthYear":"2002","stats":{"pts":"2.4","reb":"0.6","ast":"2.0"},"traits":[],"photo":"data:image/jpeg;base64,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"}]},"usa":{"name":"Stati Uniti","flag":"🇺🇸","players":[{"number":"12","name":"Caitlin Clark","role":"1","height":"183","birthYear":"2002","stats":{"pts":"11.6","reb":"1.6","ast":"6.0"},"traits":["Tiro da 3pt - palleggio/spot anche da lunga distanza","1c1 step back per tiro rapido","1c1 in area spesso per creare","Preferisce mano dx"],"photo":"data:image/jpeg;base64,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"},{"number":"7","name":"Kahleah Copper","role":"2","height":"175","birthYear":"1995","stats":{"pts":"12.8","reb":"1.5","ast":"0.5"},"traits":["PnR attenzione reject","1c1 senza palla - backdoor","1c1 palleggio - pull up"],"photo":"data:image/jpeg;base64,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"},{"number":"6","name":"Dearica Hamby","role":"3","height":"185","birthYear":"1993","stats":{"pts":"6.8","reb":"5.2","ast":"1.8"},"traits":["Chiudere mano dx","In area da tagli/slip/rollate","Mani veloci in difesa (show)"],"photo":"data:image/jpeg;base64,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"},{"number":"9","name":"Angel Reese","role":"3","height":"193","birthYear":"2002","stats":{"pts":"6.8","reb":"7.8","ast":"0.4"},"traits":["1c1 fronte - finta hand off","1c1 spalle, puo' finire mano dx/sx","Rimbalzo offensivo"],"photo":"data:image/jpeg;base64,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"},{"number":"14","name":"Monique Billings","role":"5","height":"193","birthYear":"1996","stats":{"pts":"6.4","reb":"3.2","ast":"0.8"},"traits":["Corre in contropiede","In area da rollata - preferisce mano dx","Tiro dalla media"],"photo":"data:image/jpeg;base64,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"},{"number":"5","name":"Kelsey Plum","role":"2","height":"170","birthYear":"1994","stats":{"pts":"11.2","reb":"2.0","ast":"3.0"},"traits":["3Pt spot","1c1 chiudere mano sx, step side/back","PnR attenzione reject verso sx"],"photo":"data:image/jpeg;base64,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"},{"number":"8","name":"Chelsea Gray","role":"1","height":"178","birthYear":"1992","stats":{"pts":"5.6","reb":"1.4","ast":"4.4"},"traits":["1c1/PnR principalmente per passare","Preferisce mano dx","Difesa mani veloci per rubare"],"photo":"data:image/jpeg;base64,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"},{"number":"4","name":"Paige Bueckers","role":"2","height":"183","birthYear":"2002","stats":{"pts":"10.6","reb":"3.4","ast":"2.0"},"traits":["3Pt spot/transizione","Close out dx/sx max 1 palleggio + pull up","Difesa per rubare"],"photo":"data:image/jpeg;base64,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"},{"number":"11","name":"Rae Burrell","role":"3","height":"185","birthYear":"1999","stats":{"pts":"3.6","reb":"1.2","ast":"1.6"},"traits":["Corre in contropiede","Close out mano dx","3Pt spot"],"photo":"data:image/jpeg;base64,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"},{"number":"15","name":"Kiki Iriafen","role":"3","height":"190","birthYear":"2003","stats":{"pts":"5.4","reb":"4.0","ast":"0.8"},"traits":["Dinamica, corre in contropiede","Rollata profonda","Preferisce mano dx","Rimbalzo offensivo"],"photo":"data:image/jpeg;base64,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"},{"number":"10","name":"Rhyne Howard","role":"2","height":"185","birthYear":"2000","stats":{"pts":"10.0","reb":"2.2","ast":"1.8"},"traits":["3Pt uscite/palleggio/spot","1c1 al ferro preferisce mano dx","Pop se gioca da #4"],"photo":"data:image/jpeg;base64,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"},{"number":"13","name":"Jackie Young","role":"2","height":"180","birthYear":"1997","stats":{"pts":"7.6","reb":"3.6","ast":"1.8"},"traits":["Corre e spinge il contropiede","1c1 preferisce mano dx","Conclusioni in area up&under/virate","Tiro in uscita"],"photo":"data:image/jpeg;base64,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"}]},"cina":{"name":"Cina","flag":"🇨🇳","players":[{"number":"6","name":"Shuyu Yang","role":"2","height":"183","birthYear":"2002","stats":{"pts":"10.8","reb":"3.4","ast":"4.5"},"traits":[],"photo":"https://basketball.instatscout.com/images/players/180/522850.png"},{"number":"5","name":"Siyu Wang","role":"2","height":"180","birthYear":"1995","stats":{"pts":"10.1","reb":"2.4","ast":"3.2"},"traits":[],"photo":"https://basketball.instatscout.com/images/players/180/523606.png"},{"number":"11","name":"Luo Xinyu","role":"3","height":"188","birthYear":"2001","stats":{"pts":"7.7","reb":"3.3","ast":"1.6"},"traits":[],"photo":"https://basketball.instatscout.com/images/players/180/664119.png"},{"number":"9","name":"Ziting Tang","role":"4","height":"190","birthYear":"2000","stats":{"pts":"2.8","reb":"2.5","ast":"0.8"},"traits":[],"photo":"https://basketball.instatscout.com/images/players/180/666789.png"},{"number":"19","name":"Ziyu Zhang","role":"5","height":"220","birthYear":"2007","stats":{"pts":"14.0","reb":"5.2","ast":"0.3"},"traits":[],"photo":"https://basketball.instatscout.com/images/players/180/1945255.png"},{"number":"15","name":"Xu Han","role":"5","height":"205","birthYear":"1999","stats":{"pts":"12.8","reb":"6.4","ast":"1.5"},"traits":[],"photo":"https://basketball.instatscout.com/images/players/180/32620.png"},{"number":"1","name":"Manman Zhang","role":"2","height":"170","birthYear":"1997","stats":{"pts":"5.0","reb":"1.3","ast":"3.7"},"traits":[],"photo":"https://basketball.instatscout.com/images/players/180/26779.png"},{"number":"12","name":"Chen Yujie","role":"2","height":"178","birthYear":"2000","stats":{"pts":"6.3","reb":"2.2","ast":"1.8"},"traits":[],"photo":"https://basketball.instatscout.com/images/players/180/666788.png"},{"number":"10","name":"Ru Zhang","role":"3","height":"185","birthYear":"1999","stats":{"pts":"4.0","reb":"1.4","ast":"1.4"},"traits":[],"photo":"https://basketball.instatscout.com/images/players/180/32617.png"},{"number":"18","name":"Mingling Chen","role":"5","height":"192","birthYear":"2000","stats":{"pts":"1.6","reb":"2.8","ast":"1.4"},"traits":[],"photo":"https://basketball.instatscout.com/images/players/180/594309.png"},{"number":"14","name":"Li Yueru","role":"5","height":"201","birthYear":"1999","stats":{"pts":"8.2","reb":"5.0","ast":"0.4"},"traits":[],"photo":"https://basketball.instatscout.com/images/players/180/38755.png"},{"number":"3","name":"Jiaqi Wang","role":"2","height":"170","birthYear":"1999","stats":{"pts":"2.4","reb":"3.4","ast":"3.4"},"traits":[],"photo":"https://basketball.instatscout.com/images/players/180/32612.png"}]}};
// Alias con cui il coach può aver già scritto una di queste squadre.
const GIRONE_D_ALIASES = {
cechia: ['repubblica ceca', 'rep. ceca', 'rep ceca', 'cechia', 'czechia', 'czech republic'],
usa: ['stati uniti', 'usa', 'u.s.a.', 'united states', 'america'],
cina: ['cina', 'china'],
};
function gironeDKeyForName(name) {
const n = (name || '').trim().toLowerCase();
if (!n) return null;
for (const key in GIRONE_D_ALIASES) {
if (GIRONE_D_ALIASES[key].includes(n)) return key;
}
return null;
}
function applyGironeDRoster(slot, key) {
const src = GIRONE_D_ROSTERS[key];
if (!src) return false;
upgradeLeagueTeamRecord(slot);
if (!(slot.name || '').trim()) slot.name = src.name;
/* LA BANDIERA DELLA SQUADRA DI PRIMA NON DEVE RESTARE ATTACCATA.
* Prima la bandiera si metteva solo se la casella era vuota, e non si
* toccava mai piu'. Risultato: caricato un roster (poniamo la Repubblica
* Ceca, che si porta dietro la sua bandiera) e poi cambiata squadra in
* quella stessa casella, restava la bandiera ceca sopra un'altra squadra —
* e bisognava accorgersene e toglierla a mano. Adesso si tiene il conto di
* chi l'ha messa: se l'ha messa il programma la si rifa' col roster nuovo,
* se l'ha scelta il coach non si tocca. */
if (!slot.flag || slot.flagAuto) { slot.flag = src.flag || ''; slot.flagAuto = !!src.flag; }
slot.players = src.players.map((sp, k) => {
const pl = ensureRosterPlayerShape({});
pl.number = sp.number; pl.name = sp.name; pl.role = sp.role;
pl.height = sp.height; pl.birthYear = sp.birthYear;
pl.stats.pts = sp.stats.pts; pl.stats.reb = sp.stats.reb; pl.stats.ast = sp.stats.ast;
(sp.traits || []).forEach((tr, ti) => { if (ti < 4) pl.traits[ti] = tr; });
if (sp.photo) pl.photo = sp.photo;
pl.slot = k + 1;
return pl;
});
while (slot.players.length < AVV_PLAYERS_PER_SLOT) slot.players.push(ensureRosterPlayerShape({}));
return true;
}
// Carica le rose del Girone D (Repubblica Ceca, Stati Uniti, Cina).
// IMPORTANTE: prima cerca una casella che PORTA GIÀ QUEL NOME — anche scritto
// in un altro modo ("USA" per "Stati Uniti", "Rep. Ceca" per "Repubblica
// Ceca") — e riempie quella. Prima si limitava alle caselle vuote: chi aveva
// già creato a mano la casella "Repubblica Ceca" non vedeva arrivare né le
// giocatrici né le statistiche, perché quella casella veniva saltata e i dati
// finivano (in duplicato) in tre caselle libere più avanti.
// Una casella che ha già un roster scritto a mano non viene mai sovrascritta
// senza chiedere: si conta a parte e si avvisa.
function prefillGironeD() {
const league = activeLeague();
if (!league) return;
const slots = ensureOppRosterSlots(league);
const keys = ['usa', 'cina', 'cechia'];
const taken = new Set();
const loaded = [], skipped = [], noSpace = [];
const doLoad = () => {
keys.forEach(key => {
// 1) una casella che si chiama già così
let target = slots.find(sl => !taken.has(sl) && gironeDKeyForName(sl.name) === key);
let hadRoster = false;
if (target) {
hadRoster = (target.players || []).some(pl => (pl.name || '').trim());
} else {
// 2) altrimenti la prima casella libera
target = slots.find(sl => !taken.has(sl) && !(sl.name || '').trim());
}
if (!target) { noSpace.push(GIRONE_D_ROSTERS[key].name); return; }
taken.add(target);
if (hadRoster && !prefillGironeDOverwrite) { skipped.push(target.name); return; }
applyGironeDRoster(target, key);
loaded.push(target.name);
});
renderAvversarieSection();
if (typeof renderLeagueTeamsSection === 'function') renderLeagueTeamsSection();
scheduleAutosave();
const parts = [];
if (loaded.length) parts.push(t('avversarie.gironeDLoaded', { teams: loaded.join(', ') }));
if (skipped.length) parts.push(t('avversarie.gironeDKept', { teams: skipped.join(', ') }));
if (noSpace.length) parts.push(t('avversarie.gironeDNoRoom', { teams: noSpace.join(', ') }));
toast(parts.join(' '));
};
// Se una delle tre ha già un roster scritto a mano, si chiede prima.
const conflicting = slots.filter(sl => gironeDKeyForName(sl.name) && (sl.players || []).some(pl => (pl.name || '').trim()));
if (conflicting.length) {
showConfirmModal(t('avversarie.gironeDOverwriteAsk', { teams: conflicting.map(c => c.name).join(', ') }), () => {
prefillGironeDOverwrite = true;
doLoad();
prefillGironeDOverwrite = false;
}, () => { prefillGironeDOverwrite = false; doLoad(); });
return;
}
doLoad();
}
let prefillGironeDOverwrite = false;
function clearOppRosterSlot(slotId) {
const league = activeLeague();
if (!league) return;
showConfirmModal(t('confirm.clearOppRosterSlot'), () => {
const slot = ensureOppRosterSlots(league).find(s => s.id === slotId);
if (!slot) return;
slot.name = '';
slot.players = Array.from({ length: AVV_PLAYERS_PER_SLOT }).map(() => ({ number: '', name: '', role: '' }));
slot.logo = null;
slot.flag = '';
slot.flagAuto = false;
renderAvversarieSection();
scheduleAutosave();
});
}
// Casella FISSA (numero/nome/ruolo sempre editabile sul posto, niente
// pulsante "+ aggiungi" separato): stesso principio delle 12 schede sempre
// pronte del Personnel, qui applicato ai player avversari di ogni squadra
// della lega — il coach compila direttamente la casella libera invece di
// scrivere in un modulo a parte e vederla comparire sopra.
// Una giocatrice del roster porta gli STESSI campi di una giocatrice di una
// squadra propria (altezza, anno, medie, caratteristiche, foto, slot di
// rotazione): è quello che permette di compilare il roster una volta sola e
// ritrovarselo già pronto nel Personnel e nello Scouting Report, invece di
// riscrivere gli stessi dati in tre posti diversi.
function ensureRosterPlayerShape(p) {
if (!p || typeof p !== 'object') p = {};
if (p.number == null) p.number = '';
if (p.name == null) p.name = '';
if (p.role == null) p.role = '';
if (p.height == null) p.height = '';
if (p.birthYear == null) p.birthYear = '';
if (!p.stats || typeof p.stats !== 'object') p.stats = {};
// Percentuali di tiro: 2 punti, 3 punti e tiri liberi. Sono i numeri che
// il coach guarda per primi su una scheda avversaria.
['pts', 'reb', 'ast', 'p2', 'p3', 'ft'].forEach(k => { if (p.stats[k] == null) p.stats[k] = ''; });
if (!Array.isArray(p.traits)) p.traits = [];
while (p.traits.length < 4) p.traits.push('');
// Posizione nello Scouting Report (P1..P12). Vuoto = questa giocatrice NON
// compare nel report: si flagga solo chi si vuole studiare.
if (p.reportSlot == null) p.reportSlot = '';
// Flag "video individuali": questa giocatrice entra nel montaggio degli
// individuali della squadra (le sue clip si flaggano poi una per una).
if (p.indivFlag == null) p.indivFlag = false;
if (p.id == null) p.id = nextId('pl');
return p;
}
// Le clip già taggate con questa giocatrice (il tag porta con sé playerId
// quando lo si assegna dalla pulsantiera giocatrici nel Video): non c'è
// niente da collegare a mano, i suoi video compaiono da soli nella scheda.
function playerVideoTags(team, giocatrice) {
if (!team || !giocatrice) return [];
const id = giocatrice.id;
const nome = (giocatrice.name || '').trim().toLowerCase();
const num = (giocatrice.number == null ? '' : String(giocatrice.number)).trim();
/* Si guarda dentro a TUTTI i video, non solo a quelli intestati a questa
* squadra. Motivo: le caselle con i nomi possono venire da una squadra
* diversa da quella a cui e' intestato il filmato (succede sempre quando
* si guarda la partita dell'avversaria stando dentro alla propria
* squadra, o dopo aver premuto "Tagga il video con queste giocatrici").
* Prima, in quel caso, la scheda della giocatrice diceva "0 video" pur
* avendo dieci pezzi tagliati con il suo nome sopra. */
const tutti = [];
(vt.clips || []).forEach(clip => {
(clip.tags || []).forEach(tag => { if (tag.end > tag.start) tutti.push({ clip, tag }); });
});
const trovati = tutti.filter(e => {
const tag = e.tag;
if (id && tag.playerId === id) return true;
// Il ripiego sul nome vale solo dentro al perimetro di questa squadra:
// due squadre diverse possono avere una "#7 Rossi" ciascuna.
const suaSquadra = (e.clip.teamId === team.id) || (tag.playerTeamId === team.id);
if (!suaSquadra) return false;
// Ripiego sul NOME: i pezzi taggati prima che la scheda avesse un id
// stabile (o taggati da un'altra pulsantiera) portano comunque scritto
// "#7 Cognome" — senza questo confronto la scheda diceva "0 video"
// anche con dieci pezzi già tagliati su quella giocatrice.
const lab = (tag.playerLabel || '').trim().toLowerCase();
if (!lab) return false;
if (nome && lab.indexOf(nome) !== -1) return true;
if (num && lab.indexOf('#' + num + ' ') === 0) return true;
return false;
});
return ordinaClip(trovati, team, chiaveOrdineGiocatrice(giocatrice));
}
// Apre nel Video la clip di quel tag, posizionata sul suo inizio.
function openPlayerVideoTag(clipId, tagId) {
const idx = (vt.clips || []).findIndex(c => c.id === clipId);
if (idx < 0) { toast(t('toast.tagGone')); return; }
const clip = vt.clips[idx];
if (clip.teamId) activeTeamId = clip.teamId;
vt.activeClip = idx;
activeTeamTab = 'video';
renderAll();
const tag = (clip.tags || []).find(x => x.id === tagId);
if (tag) setTimeout(() => { const v = document.getElementById('vPlayer'); if (v) { v.currentTime = tag.start; v.play().catch(() => {}); } }, 400);
}
// Assegna la posizione P1..P12: se quel posto è già occupato da un'altra
// giocatrice le due si SCAMBIANO invece di ritrovarsi doppie nello stesso
// riquadro del report (che poi ne mostrerebbe una sola, a caso).
/* Flag "video individuali" sulla scheda del roster: dice se QUESTA
* giocatrice entra nel montaggio degli individuali della squadra. Le clip poi
* si scelgono una per una dentro alla sua scheda 🎬 (ognuna ha il suo flag).
* Due livelli, come per lo Scouting Report: prima chi, poi cosa. */
function toggleRosterIndivFlag(slotId, index) {
const league = activeLeague();
if (!league) return;
const team = ensureOppRosterSlots(league).find(x => x.id === slotId);
if (!team) return;
const p = ensureRosterPlayerShape(team.players[index]);
p.indivFlag = !p.indivFlag;
renderAvversarieSection();
scheduleAutosave();
toast(t(p.indivFlag ? 'avversarie.indivFlagOn' : 'avversarie.indivFlagOff', { nome: pvNomeGiocatrice(p) }));
}
/* MONTAGGIO INDIVIDUALI DI TUTTA LA SQUADRA: un unico filmato con, per ogni
* giocatrice flaggata, un fotogramma col suo nome seguito dai suoi pezzi
* flaggati. È il "dopo li unisco" — un video solo da far vedere allo staff
* invece di dodici file sparsi. */
async function montaIndividualiSquadra(slotId) {
const league = activeLeague();
if (!league) return;
const team = ensureOppRosterSlots(league).find(x => x.id === slotId);
if (!team) return;
const scelte = (team.players || []).map(ensureRosterPlayerShape).filter(p => p && p.indivFlag);
if (!scelte.length) { toast(t('avversarie.indivNoPlayers')); return; }
const segments = [];
let pezzi = 0;
for (const p of scelte) {
const nome = pvNomeGiocatrice(p);
const suoi = playerVideoTags(team, p).filter(e => playerIndivOn(e.tag));
if (!suoi.length) continue;
/* LA COPERTINA DI OGNI GIOCATRICE E' LA SUA SCHEDA, non piu' il solo
* nome: foto, numero, ruolo/altezza/anno, medie e caratteristiche —
* la stessa che si mette in scaletta col pulsante "+ Player", disegnata
* dalla stessa funzione, cosi' le due non possono divergere. Se la
* scheda non si potesse disegnare (roster senza dati) si torna al
* cartello di prima: meglio un nome che una schermata vuota. */
const haScheda = !!(p && (p.photo || p.number || (p.stats && Object.keys(p.stats).some(k => String(p.stats[k] || '').trim())) || (p.traits || []).some(z => (z || '').trim())));
if (haScheda) {
const suaScheda = ensurePlayerProfile(Object.assign({}, p, { name: nome }));
segments.push({
sec: PRES_SCHEDA_SEC, label: nome,
disegna: (ctx, W, H) => { try { presDisegnaGiocatrice({}, { ctx, W, H }, suaScheda); } catch (e) {} }
});
} else {
segments.push({ chapter: { title: nome, subtitle: team.name || '', sec: COPERTINA_SEC }, label: nome });
}
for (const e of suoi) {
const url = e.clip.url || await evAssicuraUrlClip(e.clip);
if (!url) continue;
const fx = (Array.isArray(e.tag.fx) ? e.tag.fx.slice() : []);
fx.push({ type: 'text', t: 0, dur: Math.min(3, Math.max(1.2, e.tag.end - e.tag.start)),
text: (p.videoLabel || '').trim() || nome, cx: 0.5, cy: 0.09, size: 0.055, color: '#ffffff' });
const useCut = e.tag.cutStatus === 'ready' && e.tag.cutUrl;
segments.push(useCut
? { src: e.tag.cutUrl, start: 0, end: e.tag.end - e.tag.start, label: e.tag.label, fx, clipRef: e.clip, percorso: e.tag.cutFile || '' }
: { src: url, start: e.tag.start, end: e.tag.end, label: e.tag.label, fx, clipRef: e.clip, percorso: e.clip.percorsoDisco || '' });
pezzi++;
}
}
if (!pezzi) { toast(t('avversarie.indivNoClips')); return; }
toast(t('avversarie.indivExporting', { n: pezzi }));
const nomeFile = nomeFileMontaggio('individuali', (team.name || 'squadra'));
await exportTeamMontage(segments, nomeFile);
}
/* ============ POSIZIONI P1…P12 SENZA PERDERCI IL POMERIGGIO ============
* Prima l'unico modo era aprire il menu a tendina su OGNI scheda e cercare
* il numero: dodici volte, per ogni squadra. Ora ci sono tre strade, e la
* tendina resta per le correzioni fini:
* · "P1…P12 in automatico": un colpo solo, le numera tutte nell'ordine in
* cui stanno nel roster (solo le righe compilate);
* · clic sulla bandierina della singola scheda: le da' la prima posizione
* libera, e un secondo clic gliela toglie;
* · "Svuota posizioni": azzera tutto e si riparte. */
function squadraDellaLegaPerId(slotId) {
const league = activeLeague();
if (!league) return null;
return ensureOppRosterSlots(league).find(x => x.id === slotId)
|| (league.teams || []).find(x => x.id === slotId) || null;
}
function numeraPosizioniAuto(slotId) {
const team = squadraDellaLegaPerId(slotId);
if (!team) return;
let n = 0;
(team.players || []).forEach((pl, i) => {
if (!pl) return;
const p = ensureRosterPlayerShape(pl);
team.players[i] = p;
if (!rosterRowIsFilled(p)) { p.reportSlot = ''; return; }
n++;
p.reportSlot = n <= 12 ? String(n) : '';
});
renderAvversarieSection();
if (typeof scheduleAutosave === 'function') scheduleAutosave();
toast(t('avversarie.autoPosDone', { n: Math.min(n, 12) }));
}
function svuotaPosizioni(slotId) {
const team = squadraDellaLegaPerId(slotId);
if (!team) return;
(team.players || []).forEach((pl, i) => {
if (!pl) return;
const p = ensureRosterPlayerShape(pl);
team.players[i] = p;
p.reportSlot = '';
});
renderAvversarieSection();
if (typeof scheduleAutosave === 'function') scheduleAutosave();
toast(t('avversarie.clearPosDone'));
}
function posizionePrimaLibera(slotId, index) {
const team = squadraDellaLegaPerId(slotId);
if (!team) return;
const p = ensureRosterPlayerShape(team.players[index]);
team.players[index] = p;
if (p.reportSlot) { p.reportSlot = ''; }
else {
const prese = new Set((team.players || []).map(x => x && String(ensureRosterPlayerShape(x).reportSlot || '')));
let libera = '';
for (let k = 1; k <= 12; k++) { if (!prese.has(String(k))) { libera = String(k); break; } }
if (!libera) { toast(t('avversarie.noFreePos')); return; }
p.reportSlot = libera;
}
renderAvversarieSection();
if (typeof scheduleAutosave === 'function') scheduleAutosave();
}
function setRosterPlayerReportSlot(slotId, index, value) {
const league = activeLeague();
if (!league) return;
const team = ensureOppRosterSlots(league).find(x => x.id === slotId);
if (!team) return;
const p = ensureRosterPlayerShape(team.players[index]);
const nuovo = (value || '').toString().trim();
if (nuovo) {
const precedente = p.reportSlot;
team.players.forEach((altro, i) => {
if (i === index || !altro) return;
if (String(ensureRosterPlayerShape(altro).reportSlot) === nuovo) altro.reportSlot = precedente || '';
});
}
p.reportSlot = nuovo;
renderAvversarieSection();
if (typeof scheduleAutosave === 'function') scheduleAutosave();
}
function updateOppRosterPlayerField(slotId, index, field, value) {
const league = activeLeague();
if (!league) return;
const slot = ensureOppRosterSlots(league).find(s => s.id === slotId);
if (!slot) return;
while (slot.players.length <= index) slot.players.push(ensureRosterPlayerShape({}));
const p = ensureRosterPlayerShape(slot.players[index]);
slot.players[index] = p;
// Campi annidati scritti con la notazione "stats.pts" / "traits.0", così
// un unico gestore copre anagrafica, medie e caratteristiche.
const dot = field.indexOf('.');
if (dot > 0) {
const head = field.slice(0, dot), tail = field.slice(dot + 1);
if (head === 'stats') p.stats[tail] = value;
else if (head === 'traits') p.traits[parseInt(tail, 10)] = value;
} else {
p[field] = value;
}
scheduleAutosave();
const countEl = document.getElementById('avvCount_' + slotId);
if (countEl) countEl.textContent = '👥 ' + slot.players.filter(p => p && p.name && p.name.trim()).length + '/' + AVV_PLAYERS_PER_SLOT;
// Il Personnel qui sotto si riadatta subito: una giocatrice appena scritta
// prende la sua posizione, una svuotata la libera.
aggiornaPersonnelSeVisibile();
}
// Ridisegna il Personnel solo se è quello della squadra che si sta guardando
// (e senza toccare il campo su cui si sta scrivendo).
let personnelTimer = null;
function aggiornaPersonnelSeVisibile() {
if (activeTeamTab !== 'avversarie') return;
clearTimeout(personnelTimer);
personnelTimer = setTimeout(() => {
const tp = personnelTeam();
if (tp && document.getElementById('personnelDossierGrid')) renderPersonnelDossiers(tp);
}, 450);
}
/* IL FOGLIO BIANCO SI AGGIORNA SEMPRE. L'anteprima sotto al Personnel veniva
* ridisegnata solo dai campi che passavano dalle sue funzioni: bastava
* scrivere da un'altra parte (roster in Anagrafica squadra, schede della
* pagina avversarie, foto, ruoli) e il foglio restava indietro. Ora QUALUNQUE
* digitazione o cambio nella pagina lo rinfresca — e si rinfresca SOLO il
* foglio, non la griglia, così il campo in cui si sta scrivendo non perde mai
* il cursore. */
let personnelFoglioTimer = null;
function aggiornaFoglioPersonnel() {
const el = document.getElementById('personnelLivePreview');
if (!el) return;
clearTimeout(personnelFoglioTimer);
personnelFoglioTimer = setTimeout(() => {
const tp = personnelTeam();
if (!tp) return;
renderPersonnelLivePreview(tp);
// Se la griglia delle schede è rimasta vuota (capitava riaprendo il file
// salvato), la si ricostruisce: senza schede non c'è dove scrivere.
const grid = document.getElementById('personnelDossierGrid');
if (grid && !grid.children.length) renderPersonnelDossiers(tp);
}, 250);
}
document.addEventListener('input', aggiornaFoglioPersonnel, true);
document.addEventListener('change', aggiornaFoglioPersonnel, true);
function clearOppRosterPlayerBox(slotId, index) {
const league = activeLeague();
if (!league) return;
const slot = ensureOppRosterSlots(league).find(s => s.id === slotId);
if (!slot) return;
// Con la casella già compilata si chiede conferma: la ✕ è piccola e sta
// vicino agli altri pulsanti, un colpo di troppo cancellava foto, medie e
// caratteristiche senza dire niente.
const p = slot.players[index] || {};
const nome = (p.name || '').trim();
if (nome) {
showConfirmModal(t('confirm.deletePlayerBox', { name: nome }), () => svuotaCasellaGiocatrice(slot, index));
return;
}
svuotaCasellaGiocatrice(slot, index);
}
function svuotaCasellaGiocatrice(slot, index) {
slot.players[index] = { number: '', name: '', role: '' };
renderAvversarieSection();
const tp = personnelTeam();
if (tp) renderPersonnelDossiers(tp);
scheduleAutosave();
}
// I ruoli si scrivono come li dice un allenatore: il NUMERO (1-5) è il ruolo,
// il nome è solo la sua traduzione a parole — 1 play, 2 guardia, 3 ala
// piccola, 4 ala grande, 5 centro. Il dato salvato è quindi il numero, non
// l'etichetta: così resta lo stesso cambiando lingua e si può ordinare/
// confrontare per ruolo. I roster già scritti con le vecchie etichette
// ("Playmaker", "Ala Grande", ...) vengono ricondotti al numero corrispondente
// da normalizeRoleValue, quindi non si perde nulla di quanto già compilato.
const LEGACY_ROLE_LABELS = {
'playmaker': '1', 'play': '1', 'meneur': '1', 'point guard': '1', 'base': '1',
'guardia': '2', 'guard': '2', 'arrière': '2', 'escolta': '2',
'ala': '3', 'ala piccola': '3', 'small forward': '3', 'ailier': '3', 'alero': '3',
'ala grande': '4', 'ala forte': '4', 'forward': '4', 'power forward': '4', 'ailier fort': '4', 'ala-pívot': '4',
'centro': '5', 'center': '5', 'pivot': '5', 'pívot': '5', 'centre': '5',
};
// Sigle internazionali dei cinque ruoli. Un giocatore può coprirne DUE:
// il valore salvato è "3" oppure "34" (primo = ruolo principale), e si
// scrive semplicemente digitando uno o due numeri.
const ROLE_CODES = { '1': 'PG', '2': 'SG', '3': 'SF', '4': 'PF', '5': 'C' };
function normalizeRoleValue(v) {
const raw = (v == null ? '' : String(v)).trim();
if (!raw) return '';
// Uno o due numeri, scritti come si vuole: "3", "34", "3/4", "3-4", "3 4".
const cifre = raw.match(/[1-5]/g);
if (cifre && /^[1-5][^a-zA-Z]*$/.test(raw)) {
const uniche = [];
cifre.forEach(c => { if (uniche.indexOf(c) === -1 && uniche.length < 2) uniche.push(c); });
return uniche.join('');
}
if (/^[1-5]$/.test(raw)) return raw;
const m = raw.match(/^([1-5])\s*[-–—.]/); // vecchia forma "3 - Ala piccola"
if (m) return m[1];
// Sigle già scritte a mano (PG, SG/SF, C...)
const sigle = raw.toUpperCase().match(/PG|SG|SF|PF|C/g);
if (sigle) {
const nums = sigle.map(sg => Object.keys(ROLE_CODES).find(k => ROLE_CODES[k] === sg)).filter(Boolean);
const uniche = [];
nums.forEach(c => { if (uniche.indexOf(c) === -1 && uniche.length < 2) uniche.push(c); });
if (uniche.length) return uniche.join('');
}
return LEGACY_ROLE_LABELS[raw.toLowerCase()] || '';
}
// Etichetta da mostrare: la sigla, "SF" oppure "SF/PF" per chi copre due
// ruoli. Se il ruolo non è riconosciuto si mostra così com'è stato scritto,
// senza inventare niente.
function roleLabel(v) {
const n = normalizeRoleValue(v);
if (!n) return (v == null ? '' : String(v));
return n.split('').map(c => ROLE_CODES[c] || c).join('/');
}
// Stessa sigla anche negli spazi stretti: "PG", "SF/PF".
function roleShort(v) { return roleLabel(v); }
function avvRoleOptionsHtml(selected) {
const cur = normalizeRoleValue(selected);
const opts = [' '];
for (let n = 1; n <= 5; n++) {
const label = n + ' · ' + ROLE_CODES[String(n)];
opts.push(`${escapeHtml(label)} `);
}
// Doppi ruoli più comuni, così non serve digitarli a mano
[['1','2'],['2','3'],['3','4'],['4','5'],['1','3'],['2','4'],['3','5']].forEach(pair => {
const v = pair.join('');
const label = pair.join('') + ' · ' + pair.map(c => ROLE_CODES[c]).join('/');
opts.push(`${escapeHtml(label)} `);
});
// Valore già presente ma fuori da questa lista (es. "51"): non va perso
if (cur && opts.join('').indexOf(`value="${cur}"`) === -1) {
opts.push(`${escapeHtml(roleLabel(cur))} `);
}
return opts.join('');
}
function rosterInitials(name) {
const parts = (name || '').trim().split(/\s+/).filter(Boolean);
if (!parts.length) return '';
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
function avvPlayerBoxHtml(slot, index) {
const p = ensureRosterPlayerShape(slot.players[index]);
const upd = (field) => `oninput="updateOppRosterPlayerField('${slot.id}', ${index}, '${field}', this.value)"`;
const val = (v) => escapeHtml(v == null ? '' : String(v));
const filled = (p.name || '').trim();
const videos = playerVideoTags(slot, p);
// Scheda giocatrice completa, tutto a vista (niente più sezione "altri
// dati" a scomparsa): foto grande, riga ruolo/altezza/anno, riga medie,
// quattro righe di caratteristiche individuali, e il flag della posizione
// che decide se e dove finisce nello Scouting Report.
return `
`;
}
// Elenco dei video di UNA giocatrice: i tag che portano il suo nome, con il
// nome della clip e il minutaggio. Si clicca e il Video si apre già fermo sul
// suo pezzo, invece di dover ricordare in quale partita era.
/* GIOCHI AVVERSARI --------------------------------------------------------
* Una scheda per ogni gioco d'attacco e per ogni situazione difensiva, con
* dentro le clip di quel gioco. Le schede NON si creano a mano: sono le
* stesse caselle che si usano taggando il video (team.tagCategories), quindi
* appena si aggiunge una casella con il nome di un gioco, qui compare da sola
* la sua scheda. Ogni gioco ha un flag (entra o no nel montaggio) e si può
* spostare su e giù per decidere l'ordine del filmato.
*/
function ensureGiochiStato(team) {
if (!team.giochiVideo || typeof team.giochiVideo !== 'object') team.giochiVideo = {};
if (!team.giochiVideo.flags || typeof team.giochiVideo.flags !== 'object') team.giochiVideo.flags = {};
if (!Array.isArray(team.giochiVideo.ordine)) team.giochiVideo.ordine = [];
// Giochi tolti da QUESTA pagina: la casella per taggare resta al suo posto
// nel Video, sparisce solo la scheda qui.
if (!Array.isArray(team.giochiVideo.nascosti)) team.giochiVideo.nascosti = [];
if (typeof team.giochiVideo.soloConClip !== 'boolean') team.giochiVideo.soloConClip = false;
return team.giochiVideo;
}
// L'ordine del montaggio: i giochi già ordinati, più quelli nuovi in coda.
function giochiOrdinati(team, lato) {
const st = ensureGiochiStato(team);
const cat = ensureTagCategories(team)[lato] || [];
const dentro = st.ordine.filter(n => cat.includes(n));
cat.forEach(n => { if (!dentro.includes(n)) dentro.push(n); });
st.ordine = st.ordine.filter(n => !cat.includes(n)).concat(dentro);
return dentro;
}
function giocoFlagOn(team, nome) { return !!ensureGiochiStato(team).flags[nome]; }
function toggleGiocoFlag(nome) {
const team = activeTeam();
if (!team) return;
const st = ensureGiochiStato(team);
st.flags[nome] = !st.flags[nome];
renderGiochiSection();
scheduleAutosave();
}
function spostaGioco(lato, nome, delta) {
const team = activeTeam();
if (!team) return;
const st = ensureGiochiStato(team);
const lista = giochiOrdinati(team, lato);
const i = lista.indexOf(nome);
const j = i + delta;
if (i < 0 || j < 0 || j >= lista.length) return;
lista.splice(j, 0, lista.splice(i, 1)[0]);
const altri = st.ordine.filter(n => !lista.includes(n));
st.ordine = altri.concat(lista);
renderGiochiSection();
scheduleAutosave();
}
/* Togliere un gioco da questa pagina NON tocca le caselle per taggare: quelle
* restano nella scheda Video esattamente come sono. Qui si nasconde solo la
* scheda, e si può rimettere quando si vuole. */
function nascondiGioco(nome) {
const team = activeTeam();
if (!team) return;
const st = ensureGiochiStato(team);
if (!st.nascosti.includes(nome)) st.nascosti.push(nome);
renderGiochiSection();
scheduleAutosave();
toast(t('giochi.hidden', { nome }));
}
function mostraTuttiGiochi() {
const team = activeTeam();
if (!team) return;
const st = ensureGiochiStato(team);
st.nascosti = [];
st.soloConClip = false;
renderGiochiSection();
scheduleAutosave();
}
function toggleSoloConClip(on) {
const team = activeTeam();
if (!team) return;
ensureGiochiStato(team).soloConClip = !!on;
renderGiochiSection();
scheduleAutosave();
}
// Nasconde in un colpo solo tutte le schede senza nemmeno una clip.
function nascondiGiochiVuoti() {
const team = activeTeam();
if (!team) return;
const st = ensureGiochiStato(team);
let n = 0;
['attacco', 'difesa'].forEach(lato => {
giochiOrdinati(team, lato).forEach(nome => {
if (giocoVideoTags(team, nome).length) return;
if (!st.nascosti.includes(nome)) { st.nascosti.push(nome); n++; }
});
});
renderGiochiSection();
scheduleAutosave();
toast(t('giochi.hiddenN', { n }));
}
function apriGiocoVideo(lato, nome) {
const team = activeTeam();
if (!team) return;
pvContesto = { kind: 'gioco', teamId: team.id, lato, nome };
renderPlayerVideos();
const ov = document.getElementById('playerVideoOverlay');
if (ov) ov.classList.add('show');
}
/* ANTEPRIMA: la sequenza dei giochi flaggati, oppure le clip di un solo
* gioco. Stesso motore dell'Edit Video (disegni ed effetti compresi), su un
* palco a parte in una finestra. */
let gvSegmenti = [];
function apriGiochiPreview(titolo, segmenti) {
if (!segmenti.length) { toast(t('giochi.previewEmpty')); return; }
gvSegmenti = segmenti;
document.getElementById('gvTitle').textContent = '▶ ' + titolo + ' — ' + t('giochi.previewN', { n: segmenti.filter(x => !x.chapter).length });
document.getElementById('giochiPreviewOverlay').classList.add('show');
applyTranslations();
setTimeout(gvAvvia, 250);
}
function chiudiGiochiPreview() {
evStop();
evPrefisso = 'ev';
const ov = document.getElementById('giochiPreviewOverlay');
if (ov) ov.classList.remove('show');
}
function gvAvvia() {
evPrefisso = 'gv';
evRiproduci(gvSegmenti).then(() => { evPrefisso = 'ev'; });
}
// I pezzi di un gioco: quelli flaggati, e se non ce n'è nessuno tutti quanti
// (per l'anteprima ha senso vedere comunque cosa c'è).
async function segmentiGioco(team, nome, soloFlaggati) {
const tutte = giocoVideoTags(team, nome);
const scelte = soloFlaggati ? tutte.filter(e => playerIndivOn(e.tag)) : (tutte.filter(e => playerIndivOn(e.tag)).length ? tutte.filter(e => playerIndivOn(e.tag)) : tutte);
const out = [];
for (const e of scelte) {
const url = e.clip.url || await evAssicuraUrlClip(e.clip);
const fx = (Array.isArray(e.tag.fx) ? e.tag.fx.slice() : []);
if (pvNomeSulVideo) {
fx.push({ type: 'text', t: 0, dur: Math.min(3, Math.max(1.2, e.tag.end - e.tag.start)),
text: nome, cx: 0.5, cy: 0.09, size: 0.055, color: '#ffffff' });
}
const useCut = e.tag.cutStatus === 'ready' && e.tag.cutUrl;
out.push(useCut
? { src: e.tag.cutUrl, start: 0, end: e.tag.end - e.tag.start, label: nome, fx, clipRef: e.clip }
: { src: url, start: e.tag.start, end: e.tag.end, label: nome, fx, clipRef: e.clip });
}
return out;
}
async function anteprimaGioco(lato, nome) {
const team = activeTeam();
if (!team) return;
const segs = await segmentiGioco(team, nome, false);
apriGiochiPreview(nome, segs);
}
async function anteprimaSequenza() {
const team = activeTeam();
if (!team) return;
const segs = [];
for (const lato of ['attacco', 'difesa']) {
for (const nome of giochiOrdinati(team, lato)) {
if (!giocoFlagOn(team, nome)) continue;
const s2 = await segmentiGioco(team, nome, true);
if (!s2.length) continue;
if (pvCardIniziale) segs.push({ chapter: { title: nome, subtitle: team.name || '', sec: 2 }, label: nome });
segs.push(...s2);
}
}
apriGiochiPreview(t('giochi.previewSeq'), segs);
}
function renderGiochiSection() {
const box = document.getElementById('giochiBody');
if (!box) return;
const team = activeTeam();
if (!team) { box.innerHTML = `${escapeHtml(t('giochi.noTeam'))}
`; return; }
ensureGiochiStato(team);
const st = ensureGiochiStato(team);
const visibile = (nome) => !st.nascosti.includes(nome) && (!st.soloConClip || giocoVideoTags(team, nome).length > 0);
const blocco = (lato, titolo) => {
const lista = giochiOrdinati(team, lato).filter(visibile);
if (!lista.length) return `${titolo} ${escapeHtml(t('giochi.emptyCat'))}
`;
const cards = lista.map((nome, i) => {
const clip = giocoVideoTags(team, nome);
const flaggate = clip.filter(e => playerIndivOn(e.tag)).length;
const on = giocoFlagOn(team, nome);
const q = JSON.stringify(nome).replace(/"/g, '"');
const secondi = clip.reduce((n, e) => n + Math.max(0, e.tag.end - e.tag.start), 0);
return `
${clip.length ? `
🎬 ${clip.length} ` : ''}
${i + 1}
▲
▼
${on ? '🚩' : '⚐'}
${clip.length ? '' : `✕ `}
${escapeHtml(nome)}
${clip.length ? '🎬 ' : ''}${clip.length} ${escapeHtml(t('giochi.clipWord'))}
${clip.length ? `${flaggate} 🚩 ` : ''}
${secondi > 0 ? `${secondi < 60 ? Math.round(secondi) + 's' : Math.floor(secondi / 60) + '\u2032' + String(Math.round(secondi % 60)).padStart(2, '0')} ` : ''}
▶
🎬 ${escapeHtml(t('giochi.openBtn'))}
`;
}).join('');
// Quante clip ci sono IN TUTTO in questo blocco: il coach deve capirlo
// dall'intestazione, senza contare le schede una per una.
const clipTot = lista.reduce((n, nome) => n + giocoVideoTags(team, nome).length, 0);
const schedeConClip = lista.filter(nome => giocoVideoTags(team, nome).length).length;
return `${titolo}${lista.length} ${
clipTot ? `🎬 ${clipTot} ${escapeHtml(t('giochi.clipWord'))} ` : ''
} ${cards}
`;
};
const nAtt = giochiOrdinati(team, 'attacco').filter(n => giocoFlagOn(team, n)).length;
const nDif = giochiOrdinati(team, 'difesa').filter(n => giocoFlagOn(team, n)).length;
box.innerHTML = `
${escapeHtml(t('giochi.flagged', { n: nAtt + nDif }))}
${escapeHtml(t('pv.card'))}
${escapeHtml(t('giochi.nameOnVideo'))}
${escapeHtml(t('giochi.onlyWithClips'))}
🧹 ${escapeHtml(t('giochi.hideEmpty'))}
${(st.nascosti.length || st.soloConClip) ? `↺ ${escapeHtml(t('giochi.showAll', { n: st.nascosti.length }))} ` : ''}
▶ ${escapeHtml(t('giochi.previewSeqBtn'))}
🎬 ${escapeHtml(t('giochi.montaBtn'))}
${blocco('attacco', '⚔️ ' + escapeHtml(t('giochi.attacco')))}
${blocco('difesa', '🛡️ ' + escapeHtml(t('giochi.difesa')))}
`;
}
/* Montaggio dei giochi: nell'ordine deciso qui, per ogni gioco flaggato un
* fotogramma col suo nome (se lo si vuole) e poi le sue clip flaggate. */
async function montaGiochi() {
const team = activeTeam();
if (!team) return;
const stato = document.getElementById('giochiStatus');
const segments = [];
let pezzi = 0;
// Perche' a volte non usciva niente e non si capiva il motivo: si contano
// i giochi flaggati, i pezzi flaggati e quelli rimasti senza sorgente,
// cosi' alla fine si puo' DIRE cosa manca invece di un generico "niente".
let giochiFlaggati = 0, pezziFlaggati = 0, senzaSorgente = 0;
for (const lato of ['attacco', 'difesa']) {
for (const nome of giochiOrdinati(team, lato)) {
if (!giocoFlagOn(team, nome)) continue;
giochiFlaggati++;
const clip = giocoVideoTags(team, nome).filter(e => playerIndivOn(e.tag));
if (!clip.length) continue;
pezziFlaggati += clip.length;
/* Il cartello del capitolo si metteva PRIMA di sapere se almeno una
* clip di quel gioco fosse davvero montabile: se poi non lo era
* nessuna (filmato non piu' raggiungibile), nel montaggio restava il
* cartello con il nome del gioco e subito dopo il gioco successivo —
* due secondi di titolo davanti al nulla. Ora si segna il posto e il
* cartello ci si infila solo se qualcosa da mostrare c'e'. */
const postoCartello = pvCardIniziale ? segments.length : -1;
let messiDiQuestoGioco = 0;
for (const e of clip) {
// IL PEZZO GIA' TAGLIATO VIENE PRIMA. Prima si pretendeva comunque il
// filmato di partenza: se la partita era stata chiusa (filmato
// liberato) il pezzo veniva saltato in silenzio, anche quando il suo
// file tagliato era li' pronto. Ed e' il caso piu' comune di
// "provo a montare e non me li da".
const giaTagliato = e.tag.cutStatus === 'ready' && e.tag.cutUrl;
let url = null;
if (!giaTagliato) {
url = e.clip.url || await evAssicuraUrlClip(e.clip);
if (!url) { senzaSorgente++; continue; }
}
const fx = (Array.isArray(e.tag.fx) ? e.tag.fx.slice() : []);
if (pvNomeSulVideo) {
fx.push({ type: 'text', t: 0, dur: Math.min(3, Math.max(1.2, e.tag.end - e.tag.start)),
text: nome, cx: 0.5, cy: 0.09, size: 0.055, color: '#ffffff' });
}
segments.push(giaTagliato
? { src: e.tag.cutUrl, start: 0, end: e.tag.end - e.tag.start, label: nome, fx, clipRef: e.clip, percorso: e.tag.cutFile || '' }
: { src: url, start: e.tag.start, end: e.tag.end, label: nome, fx, clipRef: e.clip, percorso: e.clip.percorsoDisco || '' });
pezzi++;
messiDiQuestoGioco++;
}
if (postoCartello >= 0 && messiDiQuestoGioco) {
segments.splice(postoCartello, 0, { chapter: { title: nome, subtitle: team.name || '', sec: COPERTINA_SEC }, label: nome });
}
}
}
if (!pezzi) {
// Si dice PERCHE', non solo che non e' uscito niente.
const motivo = !giochiFlaggati ? t('montage.motivoGiochi')
: (!pezziFlaggati ? t('montage.motivoClip')
: t('montage.motivoSorgenti', { n: senzaSorgente || pezziFlaggati }));
montNiente(motivo);
if (stato) stato.textContent = motivo;
return;
}
/* Il montaggio riesce, ma qualche pezzo e' rimasto fuori perche' il suo
* filmato non e' piu' raggiungibile. Prima usciva solo "pronto: 5 clip" e
* chi ne aveva flaggate 8 doveva accorgersene contando. */
if (senzaSorgente) toast(t('montage.motivoSorgenti', { n: senzaSorgente }));
if (stato) stato.textContent = t('giochi.exporting', { n: pezzi });
await exportTeamMontage(segments, nomeFileMontaggio('attacco', (team.name || 'squadra') + '_giochi'));
if (stato) stato.textContent = t('giochi.done', { n: pezzi });
}
/* VIDEO DELLA GIOCATRICE ---------------------------------------------------
* Una scheda a sé per ogni giocatrice: ci finiscono sia i pezzi già taggati
* col suo nome nelle partite, sia i VIDEO CHE SI AGGIUNGONO A MANO (presi da
* qualsiasi cartella del computer: un filmato di allenamento, una clip
* scaricata, un montaggio di un'altra fonte). Ogni riga ha un FLAG: quelle
* flaggate finiscono nel montaggio individuale, che parte con un fotogramma
* col suo nome e le porta tutte in fila in un unico file.
*/
let pvContesto = null; // { kind:'player', slotId, index } | { kind:'gioco', teamId, lato, nome }
let pvCardIniziale = true; // il fotogramma col nome in apertura: si decide se metterlo
// Il flag parte SPENTO: taggando la partita i pezzi finiscono da soli nella
// scheda della giocatrice (o del gioco), ma nel montaggio ci vanno solo
// quelli che il coach flagga — "poi decido se introdurli o meno". I video
// aggiunti a mano nascono invece già flaggati: se li ha caricati lì è per
// metterceli.
function playerIndivOn(tag) { return tag.indiv === true; }
function openPlayerVideos(slotId, index) {
pvContesto = { kind: 'player', slotId, index };
renderPlayerVideos();
const ov = document.getElementById('playerVideoOverlay');
if (ov) ov.classList.add('show');
}
// Soggetto della scheda video aperta: una giocatrice oppure un gioco/difesa.
// Da qui in giù il pannello è identico per tutti e due — stesse righe, stessi
// flag, stesso montaggio.
function pvGiocatrice() {
if (!pvContesto) return null;
if (pvContesto.kind === 'gioco') {
const team = teamById(pvContesto.teamId);
if (!team) return null;
return { team, gioco: pvContesto.nome, lato: pvContesto.lato, nome: pvContesto.nome };
}
const league = activeLeague();
if (!league) return null;
const team = ensureOppRosterSlots(league).find(x => x.id === pvContesto.slotId);
if (!team) return null;
const p = ensureRosterPlayerShape(team.players[pvContesto.index]);
if (!p) return null;
return { team, p, nome: pvNomeGiocatrice(p) };
}
// I pezzi del soggetto aperto: i tag della giocatrice, oppure tutti i tag che
// portano il nome di quel gioco.
function pvElenco(g) {
if (!g) return [];
return g.gioco ? giocoVideoTags(g.team, g.gioco) : playerVideoTags(g.team, g.p);
}
/* ============ L'ORDINE DEI PEZZI DENTRO AL MONTAGGIO ============
* Dentro alla scheda di un gioco (o di una giocatrice) i pezzi non vanno
* quasi mai mostrati nell'ordine in cui sono successi: ce n'e' uno che fa
* capire la cosa e va messo per primo, e altri che vengono dopo come
* conferma. Qui si tiene l'ordine deciso dal coach, uno per scheda, e lo
* usano TUTTI: l'elenco, l'anteprima e il montaggio esportato.
* I pezzi nuovi (mai spostati) restano in fondo, in ordine di partita. */
function ordineClipDi(team) {
if (!team.ordineClip || typeof team.ordineClip !== 'object') team.ordineClip = {};
return team.ordineClip;
}
function chiaveOrdineGioco(nome) { return 'gioco:' + String(nome || '').trim().toLowerCase(); }
function chiaveOrdineGiocatrice(p) { return 'player:' + ((p && p.id) || ''); }
function ordinaClip(entries, team, chiave) {
if (!team || !chiave) return entries;
const lista = ordineClipDi(team)[chiave];
if (!Array.isArray(lista) || !lista.length) return entries;
const posto = new Map(lista.map((id, i) => [id, i]));
return entries.slice().sort((a, b) => {
const ia = posto.has(a.tag.id) ? posto.get(a.tag.id) : 1e6;
const ib = posto.has(b.tag.id) ? posto.get(b.tag.id) : 1e6;
if (ia !== ib) return ia - ib;
return (a.tag.start || 0) - (b.tag.start || 0);
});
}
/* Sposta un pezzo su o giu' dentro alla sua scheda. La lista si scrive per
* intero ad ogni spostamento: cosi' resta valida anche se nel frattempo sono
* arrivati pezzi nuovi. */
function spostaClipNellOrdine(team, chiave, entries, tagId, dir) {
if (!team) return;
const ids = entries.map(e => e.tag.id);
const i = ids.indexOf(tagId);
const j = i + dir;
if (i < 0 || j < 0 || j >= ids.length) return;
const tmp = ids[i]; ids[i] = ids[j]; ids[j] = tmp;
ordineClipDi(team)[chiave] = ids;
scheduleAutosave();
}
function giocoVideoTags(team, nome) {
const n = (nome || '').trim().toLowerCase();
if (!team || !n) return [];
const trovati = allTeamTags(team).filter(e => (e.tag.label || '').trim().toLowerCase() === n);
return ordinaClip(trovati, team, chiaveOrdineGioco(nome));
}
function teamById(id) {
const league = activeLeague();
if (league) {
const t1 = ensureOppRosterSlots(league).find(x => x.id === id);
if (t1) return t1;
const t2 = (league.teams || []).find(x => x.id === id);
if (t2) return t2;
}
return activeTeam();
}
function pvNomeGiocatrice(p) {
const n = (p.name || '').trim();
const num = (p.number == null ? '' : String(p.number)).trim();
if (n && num) return '#' + num + ' ' + n;
return n || (num ? '#' + num : t('relazione.oppPlayerNamePh'));
}
/* Su quale scheda stiamo lavorando: quella di un gioco o quella di una
* giocatrice. Serve a sapere DOVE scrivere l'ordine deciso dal coach. */
/* DI CHE ARGOMENTO E' LA SCHEDA CHE HO APERTO.
* Prima ogni scheda si chiamava "individuali", anche quella di un gioco di
* difesa, e il file esportato finiva con "_individuali". Ma individuali sono
* le giocatrici; i giochi di difesa sono DIFESA e quelli d'attacco ATTACCO.
* Serve anche per un'altra cosa: dare ai file esportati un numero d'ordine
* per argomento, cosi' nella cartella finale si ordinano da soli —
* attacchi, difese, individuali, tag veloci. */
const PV_ARGOMENTI = {
attacco: { n: 1, chiave: 'pv.argAttacco', cartella: 'ATTACCO' },
difesa: { n: 2, chiave: 'pv.argDifesa', cartella: 'DIFESA' },
individuali: { n: 3, chiave: 'pv.argIndividuali', cartella: 'INDIVIDUALI' },
tagveloci: { n: 4, chiave: 'pv.argTagVeloci', cartella: 'TAG-VELOCI' },
puntiforza: { n: 5, chiave: 'pv.argPuntiForza', cartella: 'PUNTI-DI-FORZA' },
puntideboli: { n: 6, chiave: 'pv.argPuntiDeboli', cartella: 'PUNTI-DEBOLI' },
};
const ETICHETTA_PUNTI_FORZA = 'PUNTI DI FORZA';
const ETICHETTA_PUNTI_DEBOLI = 'PUNTI DEBOLI';
function argomentoDelNome(team, nome, lato) {
const n = String(nome || '').trim().toLowerCase();
if (n === ETICHETTA_PUNTI_FORZA.toLowerCase()) return 'puntiforza';
if (n === ETICHETTA_PUNTI_DEBOLI.toLowerCase()) return 'puntideboli';
const cat = (team && team.tagCategories) || {};
const dentro = (arr) => (arr || []).some(x => String(x || '').trim().toLowerCase() === n);
if (dentro(cat.difesa)) return 'difesa';
if (dentro(cat.attacco)) return 'attacco';
if (team && dentro(team.quickTags)) return 'tagveloci';
const l = String(lato || '').toLowerCase();
if (l === 'difesa') return 'difesa';
if (l === 'attacco') return 'attacco';
return 'tagveloci';
}
function pvArgomento(g) {
if (!g) return 'individuali';
if (!g.gioco) return 'individuali';
return argomentoDelNome(g.team, g.gioco, g.lato);
}
function pvArgomentoEtichetta(g) { return t(PV_ARGOMENTI[pvArgomento(g)].chiave); }
/* Il nome del file esportato: numero dell'argomento davanti, poi la cartella
* dell'argomento, poi il nome. Cosi' nella cartella di destinazione i
* montaggi si mettono in fila da soli per argomento. */
function nomeFileMontaggio(argomento, nome) {
const a = PV_ARGOMENTI[argomento] || PV_ARGOMENTI.individuali;
return (a.n + '-' + a.cartella + '_' + String(nome || '')).replace(/[^a-z0-9]+/gi, '_');
}
function pvChiaveOrdine() {
const g = pvGiocatrice();
if (!g) return null;
return { team: g.team, chiave: g.gioco ? chiaveOrdineGioco(g.gioco) : chiaveOrdineGiocatrice(g.p) };
}
function pvSposta(tagId, dir) {
const k = pvChiaveOrdine();
if (!k) return;
spostaClipNellOrdine(k.team, k.chiave, pvElenco(pvGiocatrice()), tagId, dir);
renderPlayerVideos();
}
/* ORDINARE TRASCINANDO. Le frecce ▲▼ restano (sono precise), ma nessuno le
* vedeva: erano due triangolini di sei pixel in fondo a sinistra. Adesso la
* riga intera si prende e si molla dove serve, ed e' scritto sopra l'elenco
* che questo e' l'ordine con cui verranno montate. */
let pvTrascinato = null;
function pvTrascinaInizio(ev, tagId) {
pvTrascinato = tagId;
try { ev.dataTransfer.effectAllowed = 'move'; ev.dataTransfer.setData('text/plain', tagId); } catch (e) {}
const riga = ev.currentTarget; if (riga) riga.classList.add('pv-in-mano');
}
function pvTrascinaSopra(ev) {
ev.preventDefault();
try { ev.dataTransfer.dropEffect = 'move'; } catch (e) {}
const riga = ev.currentTarget;
if (riga && riga.dataset.tag !== pvTrascinato) riga.classList.add('pv-bersaglio');
}
function pvTrascinaFine() {
pvTrascinato = null;
document.querySelectorAll('.pv-in-mano, .pv-bersaglio').forEach(x => x.classList.remove('pv-in-mano', 'pv-bersaglio'));
}
function pvTrascinaMolla(ev, tagIdSopra) {
ev.preventDefault();
const preso = pvTrascinato;
pvTrascinaFine();
if (!preso || preso === tagIdSopra) return;
const k = pvChiaveOrdine();
if (!k) return;
const ids = pvElenco(pvGiocatrice()).map(e => e.tag.id);
const da = ids.indexOf(preso), a = ids.indexOf(tagIdSopra);
if (da < 0 || a < 0) return;
ids.splice(da, 1);
ids.splice(a, 0, preso);
ordineClipDi(k.team)[k.chiave] = ids;
scheduleAutosave();
renderPlayerVideos();
}
function pvOrdineAzzera() {
const k = pvChiaveOrdine();
if (!k) return;
delete ordineClipDi(k.team)[k.chiave];
scheduleAutosave();
renderPlayerVideos();
toast(t('pv.ordineAzzerato'));
}
function renderPlayerVideos() {
const g = pvGiocatrice();
if (!g) return;
const list = pvElenco(g);
const flaggati = list.filter(e => playerIndivOn(e.tag)).length;
const righe = list.length
? list.map((e, idx) => {
const dur = Math.max(0, e.tag.end - e.tag.start);
const suo = !!(e.clip.playerId && ((g.p && e.clip.playerId === g.p.id) || (g.gioco && e.clip.playerId === 'gioco:' + g.gioco)));
return `
${idx + 1}
▲
▼
${playerIndivOn(e.tag) ? '🚩' : '⚐'}
▶
${escapeHtml(e.tag.label || e.clip.name || t('tag.toClassify'))}
${suo ? '📁 ' + escapeHtml(t('pv.added')) : escapeHtml(e.clip.name || '')} · ${fmtTime(e.tag.start)} – ${fmtTime(e.tag.end)} · ${dur.toFixed(1)}s
✏️
↗
${suo ? `✕ ` : ''}
`;
}).join('')
: `${escapeHtml(t('avversarie.playerVideosEmpty'))}
`;
const etichettaOrdine = list.length > 1
? `↕ ${escapeHtml(t('pv.ordineTitolo'))}
` : '';
const body = `
+ ${escapeHtml(t('pv.addBtn'))}
${escapeHtml(t('pv.addHint'))}
${etichettaOrdine}
${righe}
${escapeHtml(t('pv.flagged', { n: flaggati, tot: list.length }))}
${escapeHtml(t('pv.allOn'))}
${escapeHtml(t('pv.allOff'))}
↕︎ ${escapeHtml(t('pv.ordineReset'))}
${escapeHtml(t('pv.nameOnVideo'))}
${escapeHtml(t('pv.card'))}
${pvCardIniziale ? copertinaBarraHtml() : ''}
▶ ${escapeHtml(t('pv.previewBtn'))}
🎬 ${escapeHtml(t('pv.montaArgomento', { arg: pvArgomentoEtichetta(g) }))}
`;
document.getElementById('playerVideoTitle').textContent = '🎬 ' + g.nome + ' — ' + pvArgomentoEtichetta(g);
document.getElementById('playerVideoBody').innerHTML = body;
pvMisuraFascia();
}
/* Quanto e' alta DAVVERO la fascia col titolo: con un nome lungo o una
* lingua che va a capo non sono 44px, e il palco si fermerebbe troppo in
* alto (o troppo in basso) lasciando che la banda scenda sul video. */
function pvMisuraFascia() {
try {
const modale = document.querySelector('#playerVideoOverlay .pv-modal');
const testa = document.querySelector('#playerVideoOverlay .pv-testa');
if (!modale || !testa) return;
const h = Math.round(testa.getBoundingClientRect().height);
if (h > 0) modale.style.setProperty('--pv-testa-h', h + 'px');
} catch (e) {}
}
window.addEventListener('resize', () => { try { pvMisuraFascia(); } catch (e) {} });
let pvNomeSulVideo = true;
let pvRipeti = false; // il pezzo/la sequenza riparte da capo all'infinito
/* Cosa c'è scritto SOPRA al video. Di default è automatico: numero di maglia
* + cognome, presi dalla scheda ("#7 ANDELOVA"). Se il coach preferisce
* scriverci altro (un soprannome, "attacca sempre a sinistra", il nome del
* gioco) lo scrive nel campo e vale quello — resta salvato sulla giocatrice,
* quindi lo ritrova ogni volta. */
function pvEtichetta(g) {
if (!g) return '';
if (g.p && (g.p.videoLabel || '').trim()) return g.p.videoLabel.trim();
return g.nome;
}
function pvSetEtichetta(valore) {
const g = pvGiocatrice();
if (!g || !g.p) return;
g.p.videoLabel = valore;
scheduleAutosave();
}
// Aggiunge uno o più video presi da QUALSIASI cartella: ognuno diventa una
// clip a sé, legata a questa giocatrice, con un pezzo che copre tutto il
// filmato — da lì si può ritagliare, disegnarci sopra e montarlo come tutti
// gli altri.
function pvAggiungiVideo() {
const g = pvGiocatrice();
if (!g) return;
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'video/*'; inp.multiple = true;
inp.onchange = async () => {
const files = Array.from(inp.files || []);
if (!files.length) return;
const stato = document.getElementById('pvStatus');
for (let i = 0; i < files.length; i++) {
const f = files[i];
if (stato) stato.textContent = t('pv.loading', { nome: f.name, i: i + 1, n: files.length });
const url = URL.createObjectURL(f);
const durata = await pvDurataVideo(url);
const clipId = nextId('clip');
const tagId = nextId('tag');
vt.clips.push({
id: clipId, name: f.name, url, teamId: g.team.id,
playerId: g.gioco ? ('gioco:' + g.gioco) : g.p.id, lastPosition: 0,
duration: durata,
tags: [{
id: tagId, start: 0, end: durata || 10, side: g.lato || 'attacco',
// Per un gioco l'etichetta DEVE essere il nome del gioco: è così che
// il pezzo si ritrova nella sua scheda.
label: g.gioco ? g.gioco : f.name.replace(/\.[^.]+$/, ''),
playerId: g.gioco ? null : g.p.id,
playerLabel: g.gioco ? '' : g.nome,
indiv: true, fx: [],
}],
});
videoBlobDbSave(clipId, f);
}
if (stato) stato.textContent = '';
renderPlayerVideos();
renderAll();
scheduleAutosave();
toast(t('pv.added_n', { n: files.length }));
};
inp.click();
}
function pvDurataVideo(url) {
return new Promise((res) => {
const v = document.createElement('video');
v.preload = 'metadata';
v.onloadedmetadata = () => res(isFinite(v.duration) ? v.duration : 0);
v.onerror = () => res(0);
v.src = url;
setTimeout(() => res(isFinite(v.duration) ? v.duration : 0), 4000);
});
}
function pvTrovaTag(clipId, tagId) {
const clip = (vt.clips || []).find(c => c.id === clipId);
if (!clip) return null;
const tag = (clip.tags || []).find(x => x.id === tagId);
return tag ? { clip, tag } : null;
}
/* Le bandierine ridisegnavano TUTTO il pannello a ogni click: l'elenco ha
* un'altezza fissa e scorre, quindi tornava in cima ogni volta e la riga
* sotto il dito cambiava. Con venti clip flaggarle una per una diventava
* impossibile e non restava che "Flagga tutti". Ora cambio solo la riga
* toccata e i due contatori in fondo: l'elenco resta fermo dov'e'. */
function pvAggiornaContatori() {
const g = pvGiocatrice();
if (!g) return;
const list = pvElenco(g);
const flaggati = list.filter(e => playerIndivOn(e.tag)).length;
const c = document.querySelector('#playerVideoBody .pv-bottom .pv-count');
if (c) c.textContent = t('pv.flagged', { n: flaggati, tot: list.length });
/* Qui c'era un difetto mio, ed e' quello che ha bloccato l'esportazione di
* Double High. Cercavo il bottone del montaggio con ".btn.primary" dentro
* alla barra in fondo — ma li' dentro c'e' anche la barra della copertina,
* e il suo bottone scelto porta la stessa classe ed e' PRIMO. Cosi'
* mettevo e toglievo il grigio al bottone "Grafica" della copertina,
* mentre il bottone "Monta" restava spento per sempre: il contatore diceva
* "5 flaggati su 5" e il bottone non si accendeva. Si sbloccava solo
* riaprendo il pannello, ed e' esattamente il "all'inizio non me lo faceva
* esportare". Adesso il bottone ha un nome suo e non si puo' sbagliare. */
const m = document.getElementById('pvMontaBtn');
if (m) m.disabled = !flaggati;
}
function pvDisegnaBandierina(tag) {
const riga = document.querySelector('#playerVideoBody .pv-row2[data-tag="' + tag.id + '"]');
if (!riga) return false;
const on = playerIndivOn(tag);
riga.classList.toggle('on', on);
const b = riga.querySelector('.pv-flag');
if (b) b.textContent = on ? '\u{1F6A9}' : '\u2690';
return true;
}
function pvToggleFlag(clipId, tagId) {
const r = pvTrovaTag(clipId, tagId);
if (!r) return;
r.tag.indiv = !playerIndivOn(r.tag);
if (pvDisegnaBandierina(r.tag)) pvAggiornaContatori();
else renderPlayerVideos();
scheduleAutosave();
}
function pvFlagTutti(acceso) {
const g = pvGiocatrice();
if (!g) return;
const list = pvElenco(g);
list.forEach(e => { e.tag.indiv = !!acceso; });
const tutteFatte = list.every(e => pvDisegnaBandierina(e.tag));
if (tutteFatte) pvAggiornaContatori(); else renderPlayerVideos();
scheduleAutosave();
}
function pvRimuoviVideo(clipId) {
const i = (vt.clips || []).findIndex(c => c.id === clipId);
if (i < 0) return;
showConfirmModal(t('pv.removeAsk'), () => {
if (vt.activeClip === i) vt.activeClip = -1;
else if (vt.activeClip > i) vt.activeClip--;
vt.clips.splice(i, 1);
renderPlayerVideos();
renderAll();
scheduleAutosave();
});
}
/* MONTAGGIO INDIVIDUALE: tutti i pezzi flaggati di questa giocatrice, uno
* dietro l'altro, aperti da un fotogramma col suo nome e con il nome scritto
* sopra ogni pezzo (si può togliere). Stesso motore del montaggio squadra,
* quindi disegni ed effetti restano incisi dentro. */
// Guarda la sequenza di questa scheda senza esportare niente: i pezzi
// flaggati, o tutti se non ne è flaggato nessuno.
async function pvAnteprima() {
const g = pvGiocatrice();
if (!g) return;
const tutti = pvElenco(g);
const scelti = tutti.filter(e => playerIndivOn(e.tag)).length ? tutti.filter(e => playerIndivOn(e.tag)) : tutti;
const segs = [];
for (const e of scelti) {
const url = e.clip.url || await evAssicuraUrlClip(e.clip);
const fx = (Array.isArray(e.tag.fx) ? e.tag.fx.slice() : []);
if (pvNomeSulVideo) {
fx.push({ type: 'text', t: 0, dur: Math.min(3, Math.max(1.2, e.tag.end - e.tag.start)),
text: pvEtichetta(g), cx: 0.5, cy: 0.09, size: 0.055, color: '#ffffff' });
}
const useCut = e.tag.cutStatus === 'ready' && e.tag.cutUrl;
segs.push(useCut
? { src: e.tag.cutUrl, start: 0, end: e.tag.end - e.tag.start, label: e.tag.label, fx, clipRef: e.clip, percorso: e.tag.cutFile || '' }
: { src: url, start: e.tag.start, end: e.tag.end, label: e.tag.label, fx, clipRef: e.clip, percorso: e.clip.percorsoDisco || '' });
}
pvRiproduciQui(segs);
}
// Riproduce nel palco DENTRO alla scheda: qui il coach sta già guardando
// quella giocatrice/quel gioco, non ha senso sbatterlo in un'altra pagina.
function pvRiproduciQui(segs) {
if (!segs || !segs.length) { toast(t('giochi.previewEmpty')); return; }
evStop();
evPrefisso = 'pvp';
pvUltimi = segs;
evRiproduci(segs).then(() => {
// Se nel frattempo e' partita un'altra sequenza, il palco e' suo: chi ha
// finito non deve rimetterlo su 'ev', o la nuova si ritrova a disegnare
// da un'altra parte e l'immagine resta ferma.
if (pvUltimi !== segs) return;
evPrefisso = 'ev';
// Con "ripeti" acceso il pezzo riparte da capo: un video si guarda dieci
// volte di fila quando si studia un dettaglio, non una sola.
if (pvRipeti && pvUltimi === segs && document.getElementById('pvpCanvas')) {
setTimeout(() => { if (pvRipeti) pvRiproduciQui(segs); }, 300);
}
});
}
let pvUltimi = null;
function pvFerma() { pvUltimi = null; evStop(); evPrefisso = 'ev'; }
// ▶ su una riga: solo quel pezzo, subito, nella scheda.
async function pvSuonaRiga(clipId, tagId) {
const r = pvTrovaTag(clipId, tagId);
if (!r) return;
const g = pvGiocatrice();
const url = r.clip.url || await evAssicuraUrlClip(r.clip);
const fx = (Array.isArray(r.tag.fx) ? r.tag.fx.slice() : []);
const useCut = r.tag.cutStatus === 'ready' && r.tag.cutUrl;
pvRiproduciQui([useCut
? { src: r.tag.cutUrl, start: 0, end: r.tag.end - r.tag.start, label: r.tag.label || (g && g.nome), fx, clipRef: r.clip }
: { src: url, start: r.tag.start, end: r.tag.end, label: r.tag.label || (g && g.nome), fx, clipRef: r.clip }]);
}
async function pvMontaIndividuali() {
const g = pvGiocatrice();
if (!g) return;
const scelti = pvElenco(g).filter(e => playerIndivOn(e.tag));
if (!scelti.length) { toast(t('pv.noneFlagged')); return; }
const stato = document.getElementById('pvStatus');
if (stato) stato.textContent = t('pv.preparing', { n: scelti.length });
const segments = [];
if (pvCardIniziale) segments.push({ chapter: { title: g.nome, subtitle: g.team.name || '', sec: COPERTINA_SEC }, label: g.nome });
let senzaSorgente = 0;
for (const e of scelti) {
const giaTagliato = e.tag.cutStatus === 'ready' && e.tag.cutUrl;
let url = null;
if (!giaTagliato) {
url = e.clip.url || await evAssicuraUrlClip(e.clip);
if (!url) { senzaSorgente++; continue; }
}
const fx = (Array.isArray(e.tag.fx) ? e.tag.fx.slice() : []);
if (pvNomeSulVideo) {
// Nome della giocatrice scritto sopra il pezzo, in bianco su
// trasparente, per i primi secondi.
fx.push({ type: 'text', t: 0, dur: Math.min(3, Math.max(1.2, e.tag.end - e.tag.start)),
text: pvEtichetta(g), cx: 0.5, cy: 0.09, size: 0.055, color: '#ffffff' });
}
segments.push(giaTagliato
? { src: e.tag.cutUrl, start: 0, end: e.tag.end - e.tag.start, label: e.tag.label, fx, clipRef: e.clip, percorso: e.tag.cutFile || '' }
: { src: url, start: e.tag.start, end: e.tag.end, label: e.tag.label, fx, clipRef: e.clip, percorso: e.clip.percorsoDisco || '' });
}
if (segments.filter(x => !x.chapter).length === 0) {
const motivo = t('montage.motivoSorgenti', { n: senzaSorgente || scelti.length });
montNiente(motivo);
if (stato) stato.textContent = motivo;
return;
}
const nomeFile = nomeFileMontaggio(pvArgomento(g), g.nome);
if (stato) stato.textContent = t('pv.exporting');
await exportTeamMontage(segments, nomeFile);
if (stato) stato.textContent = t('pv.done', { n: segments.filter(x => !x.chapter).length });
}
function closePlayerVideos() {
pvFerma();
const ov = document.getElementById('playerVideoOverlay');
if (ov) ov.classList.remove('show');
}
// Carica in una volta sola le foto di tutta la squadra: si selezionano tutti
// i file insieme e ognuno viene abbinato da solo alla giocatrice giusta, in
// base al NOME DEL FILE. Funzionano sia il numero di maglia ("4.jpg",
// "n4.png", "04 andelova.jpg") sia il cognome ("andelova.jpg",
// "Gabriela_Andelova.png"): senza questo bisognava aprire dodici volte il
// selettore file, una giocatrice alla volta.
function matchRosterPlayerForFile(players, fileName) {
const base = fileName.replace(/\.[a-z0-9]+$/i, '').toLowerCase();
const clean = base.replace(/[_\-.]+/g, ' ').trim();
// 1) cognome (o nome) contenuto nel nome del file
let best = null, bestLen = 0;
players.forEach((pl, i) => {
const nm = (pl.name || '').trim().toLowerCase();
if (!nm) return;
nm.split(/\s+/).forEach(word => {
if (word.length >= 4 && clean.includes(word) && word.length > bestLen) { best = i; bestLen = word.length; }
});
});
if (best != null) return best;
// 2) numero di maglia, preso dal primo gruppo di cifre del nome file
const m = clean.match(/\d{1,2}/);
if (m) {
const num = String(parseInt(m[0], 10));
const idx = players.findIndex(pl => String(parseInt(pl.number, 10)) === num && (pl.name || '').trim());
if (idx >= 0) return idx;
}
return -1;
}
function importRosterPhotos(slotId, fileList) {
const league = activeLeague();
if (!league || !fileList || !fileList.length) return;
const slot = ensureOppRosterSlots(league).find(x => x.id === slotId);
if (!slot) return;
const files = Array.from(fileList);
const used = new Set();
let done = 0, matched = 0;
const unmatched = [];
files.forEach(file => {
const reader = new FileReader();
reader.onload = () => {
let idx = matchRosterPlayerForFile(slot.players, file.name);
if (idx >= 0 && used.has(idx)) idx = -1;
if (idx >= 0) {
used.add(idx);
ensureRosterPlayerShape(slot.players[idx]).photo = reader.result;
matched++;
} else {
unmatched.push(file.name);
}
if (++done === files.length) finish();
};
reader.onerror = () => { unmatched.push(file.name); if (++done === files.length) finish(); };
reader.readAsDataURL(file);
});
function finish() {
renderAvversarieSection();
if (typeof scheduleAutosave === 'function') scheduleAutosave();
const msg = [t('avversarie.bulkPhotoDone', { n: matched })];
if (unmatched.length) msg.push(t('avversarie.bulkPhotoUnmatched', { files: unmatched.slice(0, 4).join(', ') }));
toast(msg.join(' '));
}
}
// Foto della giocatrice sulla scheda del roster: stesso campo "photo" che usa
// il dossier Personnel, quindi caricandola qui compare anche là.
function setRosterPlayerPhoto(slotId, index, file) {
const league = activeLeague();
if (!league || !file) return;
const slot = ensureOppRosterSlots(league).find(x => x.id === slotId);
if (!slot) return;
const reader = new FileReader();
reader.onload = () => {
ensureRosterPlayerShape(slot.players[index]).photo = reader.result;
renderAvversarieSection();
if (typeof scheduleAutosave === 'function') scheduleAutosave();
};
reader.readAsDataURL(file);
}
// Apre/chiude i dettagli di UNA scheda giocatrice, senza ridisegnare tutta la
// griglia: un re-render mentre si scrive farebbe perdere il fuoco al campo.
function toggleAvvPlayerDetail(btn) {
const box = btn.closest('.avv-pbox');
if (!box) return;
const det = box.querySelector('.avv-pbox-detail');
if (!det) return;
const open = det.hasAttribute('hidden');
if (open) det.removeAttribute('hidden'); else det.setAttribute('hidden', '');
btn.textContent = open ? '▴' : '▾';
box.classList.toggle('avv-pbox-open', open);
}
// Box COMPATTO (solo logo + nome + contatore): il roster delle 14 giocatrici
// non sta più qui dentro (prima la griglia era sempre 16 box "grandi" con
// tutte le caselle player a vista, troppo affollata) — si apre solo
// cliccando il box, vedi avvSlotEditorHtml/toggleAvvSlotExpand più sotto,
// stesso principio del Playbook (griglia compatta -> editor dedicato).
// Il logo qui è SOLO visualizzato (niente più + dentro al
// box compatto): un che avvolge un file input intercetta il click
// per aprire il selettore file del sistema invece di far salire l'evento al
// box, e proprio il logo/numero è l'elemento più grande e centrale del box —
// il punto più probabile su cui un coach clicca "per aprire la squadra".
// Il caricamento del logo si fa invece dal pannello roster aperto (vedi
// avvSlotEditorHtml), dove c'è spazio dedicato e nessun rischio di click
// ambigui. Così l'intero box, ovunque ci si clicchi, apre sempre il roster.
function avvSlotCardHtml(slot, index) {
const color = avvSlotColor(index);
const filledCount = slot.players.filter(p => p && p.name && p.name.trim()).length;
// Il logo caricato resta la scelta principale (il coach preferisce i loghi
// veri delle squadre): la bandiera è solo un ripiego per gli spazi senza
// ancora un logo caricato, non lo sostituisce più una volta presente.
const logoInnerHtml = slot.logo
? ` `
: slot.flag
? `${escapeHtml(slot.flag)} `
: `${index + 1} `;
return `
`;
}
// Pannello roster (14 caselle player), mostrato solo per lo slot aperto — sotto
// alla griglia dei 16 box, non più dentro ad ognuno di essi. Il logo si
// carica da qui (click sul cerchio nell'intestazione del pannello), non più
// dal box compatto (vedi commento sopra).
function avvSlotEditorHtml(slot) {
// Normalmente 12 caselle. Se un roster salvato in passato ne aveva di più
// (quando erano 14) e quelle righe sono compilate, si continuano a mostrare:
// meglio una casella in più che far sparire una giocatrice già scritta.
let lastFilled = -1;
(slot.players || []).forEach((pl, i) => { if (pl && rosterRowIsFilled(pl)) lastFilled = i; });
const boxCount = Math.max(AVV_PLAYERS_PER_SLOT, lastFilled + 1);
const boxesHtml = Array.from({ length: boxCount }).map((_, i) => avvPlayerBoxHtml(slot, i)).join('');
const logoInnerHtml = slot.logo
? ` `
: slot.flag
? `${escapeHtml(slot.flag)} `
: `🖼️ `;
return `
`;
}
let avvExpandedSlotId = null;
function toggleAvvSlotExpand(slotId) {
avvExpandedSlotId = (avvExpandedSlotId === slotId) ? null : slotId;
renderAvversarieSection();
const tp = personnelTeam();
if (tp) renderPersonnelDossiers(tp);
}
function renderAvversarieSection() {
// Dopo ogni ridisegno i campi delle caratteristiche si riadattano
// all'altezza del testo che contengono.
requestAnimationFrame(() => crescoTuttiICampi(document.getElementById('avversarieSection')));
const grid = document.getElementById('avvGrid');
const hint = document.getElementById('avvHint');
const noLeagueHint = document.getElementById('avvNoLeagueHint');
const editorHolder = document.getElementById('avvEditorHolder');
if (!grid) return;
const league = activeLeague();
if (!league) {
grid.innerHTML = '';
if (editorHolder) editorHolder.innerHTML = '';
if (hint) hint.style.display = 'none';
if (noLeagueHint) noLeagueHint.style.display = 'block';
return;
}
if (hint) hint.style.display = '';
if (noLeagueHint) noLeagueHint.style.display = 'none';
const slots = ensureOppRosterSlots(league);
grid.innerHTML = slots.map((slot, i) => avvSlotCardHtml(slot, i)).join('');
if (editorHolder) {
if (avvExpandedSlotId && !slots.some(s => s.id === avvExpandedSlotId)) avvExpandedSlotId = null;
const openSlot = avvExpandedSlotId ? slots.find(s => s.id === avvExpandedSlotId) : null;
editorHolder.innerHTML = openSlot ? avvSlotEditorHtml(openSlot) : '';
// Il pannello roster appare SOTTO la griglia dei 16 box: se il coach ha
// cliccato una squadra più in basso nella pagina, senza scorrimento
// automatico il pannello che si apre può restare fuori dallo schermo e
// sembrare che "non sia successo niente" — lo portiamo subito in vista.
if (openSlot) {
requestAnimationFrame(() => editorHolder.scrollIntoView({ behavior: 'smooth', block: 'nearest' }));
}
}
}
// ------------------------------------------------------------------ TEAMS
// Categorie di giocate di partenza (modificabili/rimovibili dal coach) e
// tag rapidi di default — coerenti con lo schema Attacco/Difesa + Tag Rapidi
// del motore di riferimento, ma generici (non legati a una squadra specifica).
/* Le difese sono sempre queste: non si inventano partita per partita, sono le
* situazioni che si guardano ogni volta. Restano comunque rinominabili o
* cancellabili come tutte le altre caselle. */
const DIFESE_FISSE = [
'Difesa Contropiede', 'Difesa Pick Centrale', 'Difesa Pick Laterale', 'Difesa Area',
'Difesa 1c1 Fronte', 'Difesa 1c1 Spalle', 'Difesa Press', 'Difesa Blocchi',
'Difesa Zona', 'Difesa Tattica',
];
function defaultTagCategories() {
return {
attacco: [
'Transizione', 'Transizione: Drag', 'Trans. 2', 'Pick and Roll', 'Post Basso', 'Blocco Cieco',
'Isolamento', 'Vs Zona', 'Base', 'Double High', 'Floppy', 'Ram + Pick', 'Zipper +',
'Pick Laterale', 'Pick Centrale', 'Iverson', 'Pindown', 'Widescreen', 'Cross Screen',
'Rimesse Lato', 'Rimesse Fondo',
],
// Elenco fisso delle situazioni difensive da scoutizzare, quello che il
// coach usa davvero: sono le caselle che compaiono in Video e le schede
// della sezione Giochi Avversari.
difesa: DIFESE_FISSE.slice(),
};
}
/* CP SUBITO = contropiede subito (quello che l'avversario ci fa in
* transizione). Va nei tag rapidi di serie perche' e' una delle cose che si
* riguardano piu' spesso, e ha bisogno di piu' respiro delle altre: il
* contropiede comincia dalla palla persa o dal rimbalzo, non dal canestro
* subito — vedi SECONDI_TAG_RAPIDO. */
function defaultQuickTags() { return ['PP', 'RIM OFF', 'REC', 'CP SUBITO']; }
/* Quanti secondi prendere all'indietro per ogni tag rapido. Tre bastano per
* un tiro o un rimbalzo; il contropiede subito no: quando arriva il canestro
* l'azione e' gia' cominciata da un pezzo, dall'altra parte del campo. */
const SECONDI_TAG_RAPIDO_NORMALE = 3;
const SECONDI_TAG_RAPIDO = { 'CP SUBITO': 5, 'CONTROPIEDE SUBITO': 5, 'CP AVVERSARIO': 5, 'CONTROPIEDE AVVERSARIO': 5 };
function secondiIndietroDelTagRapido(label) {
const chiave = (label || '').trim().toUpperCase();
return SECONDI_TAG_RAPIDO[chiave] || SECONDI_TAG_RAPIDO_NORMALE;
}
function addTeamToLeague(league) {
showPromptModal(t('team.nameLabel'), t('prompt.teamNamePh'), (name) => {
avvisaSeEsisteGia(name, () => creaSquadraNellaLega(league, name));
});
}
function creaSquadraNellaLega(league, name) {
{
const team = {
id: nextId('tm'), name, logo: '', colorHex: '#00b8d4', players: [], strategyNotes: [],
tagCategories: defaultTagCategories(), quickTags: defaultQuickTags(),
playbook: [], personnel: { positions: [] }, fixtures: [], practice: [],
scoutingReport: { puntiForza: '', perVincere: '', strategie: '', partita: '', miglior3pt: '', peggiorLiberi: '' },
};
league.teams.push(team);
activeTeamId = team.id;
activeTeamTab = 'video';
renderAll();
scheduleAutosave();
}
}
function createTeam() {
if (allLeagueTeams(activeLeague(), false).length >= limite('squadre')) return bloccoBeta(t('beta.limitTeams', { n: LIMITI_BETA.squadre }));
const league = activeLeague();
if (!league) {
// Nessuna lega ancora: invece di bloccarsi in silenzio, la chiediamo
// subito e poi si passa automaticamente alla creazione della squadra.
toast(t('toast.createLeagueFirst'));
showPromptModal(t('prompt.leagueName'), t('prompt.leagueNamePh'), (leagueName) => {
const newLeague = { id: nextId('lg'), name: leagueName, sourceUrl: '', teams: [] };
DB.leagues.push(newLeague);
activeLeagueId = newLeague.id;
activeTeamId = null;
renderAll();
addTeamToLeague(newLeague);
});
return;
}
addTeamToLeague(league);
}
/* CAMBIARE SQUADRA VUOL DIRE CAMBIARE LAVORO.
* Il filmato "appuntato" esiste per non perdere il pezzo su cui stai
* lavorando mentre giri fra le schede: quello va tenuto. Ma quando scegli
* un'altra squadra dal menu in alto stai dicendo "adesso lavoro su questa",
* e ritrovarsi davanti la pulsantiera e il video della squadra di prima
* confonde e basta — sembra che l'app non abbia capito. Da qui in poi: si
* apre la squadra che hai scelto, con i SUOI filmati; se non ne ha, la
* scheda Video parte pulita. Il lavoro sull'altra squadra resta tutto dov'e'
* e torna com'era appena la riapri. */
function apriIVideoDellaSquadra() {
const team = activeTeam();
/* E QUI STAVA IL DIFETTO. La barra "Giocatrici di:" permette di scegliere a
* mano di chi sono i nomi da taggare, e quella scelta ha la precedenza su
* tutto. Giusto finche' si lavora su una squadra — ma restava incollata li'
* anche cambiando squadra: si passava dalla prima alla seconda e ci si
* ritrovava davanti le giocatrici della prima, senza nessun modo di capire
* perche'. La scelta a mano vale per il lavoro che stai facendo, non per
* sempre: cambiando squadra si azzera e la pulsantiera torna a seguire la
* squadra aperta (o il video che si sta guardando). */
vt.chipTeamId = null;
vt.trimStart = null;
vt.trimEnd = null;
vt.pendingTagId = null;
vtSequencePlaying = false;
const suoi = (vt.clips || []).map((c, i) => ({ c, i })).filter(x => team && x.c.teamId === team.id);
vt.activeClip = suoi.length ? suoi[suoi.length - 1].i : -1;
vt.clipAppuntata = (vt.activeClip >= 0) ? vt.clips[vt.activeClip].id : null;
}
function selectTeam(id) {
const cambiata = id !== activeTeamId;
activeTeamId = id;
activeTeamTab = 'video';
if (cambiata) apriIVideoDellaSquadra();
renderAll();
}
function activeTeam() {
if (singleMatchMode) {
return ensureSingleMatches().find(m => m.id === activeSingleMatchId) || null;
}
const league = activeLeague();
if (!league) return null;
const own = league.teams.find(t => t.id === activeTeamId);
if (own) return own;
// ...altrimenti può essere una squadra della lega (una delle caselle):
// adesso sono squadre complete, quindi tutto il resto dell'app (Playbook
// con giochi e difese, pulsantiera, stampe, logo) lavora su di loro
// esattamente come su una squadra propria.
const other = (league.oppRosters || []).find(t => t.id === activeTeamId);
return other ? upgradeLeagueTeamRecord(other) : null;
}
// Vero solo per le squadre della lega che NON alleni tu: serve a mostrare
// l'etichetta giusta e a non offrire, su una squadra avversaria, le cose che
// hanno senso solo sulla propria (es. il calendario delle TUE partite).
function isOwnTeam(team) {
const league = activeLeague();
if (!league || !team) return true;
return (league.teams || []).some(t => t.id === team.id);
}
function updateActiveTeamName(value) {
const team = activeTeam();
if (!team) return;
team.name = value;
renderTopMenu();
const bannerName = document.getElementById('teamBannerName');
if (bannerName) bannerName.textContent = value;
}
function setTeamLogo(file) {
const team = activeTeam();
if (!team || !file) return;
const reader = new FileReader();
reader.onload = () => {
team.logo = reader.result;
renderAll();
};
reader.readAsDataURL(file);
}
// Logo da URL: prova prima a "scaricarlo" (fetch + conversione in data URL,
// così resta dentro il JSON esportato come il logo caricato da file); se il
// sito non permette il fetch (CORS) lo colleghiamo comunque come URL diretto
// — il coach lo vede lo stesso, semplicemente non è incorporato nel salvataggio.
function setTeamLogoUrl(url) {
const team = activeTeam();
url = (url || '').trim();
if (!team || !url) return;
fetch(url)
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.blob(); })
.then(blob => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
}))
.then(dataUrl => {
team.logo = dataUrl;
renderAll();
toast(t('toast.logoDownloaded'));
})
.catch(() => {
team.logo = url;
renderAll();
toast(t('toast.logoLinked'));
});
}
/* ===================== SI PUÒ CANCELLARE TUTTO =====================
* Ogni cosa creata si può anche togliere: la lega intera, una squadra del
* tabellone (sia una propria sia una casella avversaria), un video importato
* con i suoi tag, una giocatrice. Sempre con una domanda di conferma che dice
* COSA si sta per perdere — cancellare per sbaglio il lavoro di una stagione
* sarebbe il danno peggiore che questa app possa fare.
* ================================================================== */
function eliminaLegaCorrente() {
const lg = activeLeague();
if (!lg) return;
const quante = (lg.teams || []).length + (lg.oppRosters || []).filter(x => (x.name || '').trim()).length;
showConfirmModal(t('confirm.deleteLeague', { name: lg.name || '—', n: quante }), () => {
DB.leagues = DB.leagues.filter(l => l.id !== lg.id);
activeLeagueId = DB.leagues.length ? DB.leagues[0].id : null;
activeTeamId = null;
avvExpandedSlotId = null;
personnelTeamIdCorrente = null;
renderAll();
scheduleAutosave();
toast(t('toast.leagueDeleted', { name: lg.name || '' }));
});
}
// Toglie dal tabellone una squadra qualunque: propria (lg.teams) o casella
// avversaria (lg.oppRosters). I video NON si toccano: restano nell'archivio,
// così un'eliminazione sbagliata non porta via anche i filmati.
/* ============ DUE SQUADRE CON LO STESSO NOME: SI UNISCONO ============
* È la trappola numero uno di questa app: "Belgio" creato come squadra e
* "Belgio" riempito come casella avversaria diventano due schedari diversi.
* Si scrive il roster in uno, si guarda nell'altro, e sembra che i dati
* spariscano — nel tabellone si vede appunto "BELGIO (0)".
* Qui i due si fondono in uno solo: giocatrici, giochi, scouting, allenamenti
* e video passano tutti nella scheda che resta. */
function gemellaOmonima(team) {
const lg = activeLeague();
if (!lg || !team) return null;
const n = (x) => ((x && x.name) || '').trim().toLowerCase();
if (!n(team)) return null;
return allLeagueTeams(lg, false).map(e => e.team).find(x => x.id !== team.id && n(x) === n(team)) || null;
}
function quantePiene(t) { return ((t && t.players) || []).filter(p => (p && p.name || '').trim()).length; }
function unisciOmonime(teamId) {
const lg = activeLeague();
if (!lg) return;
const tutte = allLeagueTeams(lg, false).map(e => e.team);
const a = tutte.find(x => x.id === teamId);
if (!a) return;
const b = gemellaOmonima(a);
if (!b) { toast(t('squadre.nessunDoppione')); return; }
// resta quella con più roba dentro; l'altra ci versa quello che ha
const peso = (t2) => quantePiene(t2) * 100 + ((t2.playbook || []).length) * 10 + ((t2.practice || []).length);
const resta = peso(a) >= peso(b) ? a : b;
const versa = resta === a ? b : a;
showConfirmModal(t('squadre.unisciChiedi', { nome: resta.name || '—', n: quantePiene(versa) }), () => {
// giocatrici: si tengono quelle della scheda più piena, si aggiungono le
// altre solo se quel numero/nome non c'è già
const chiave = (p) => ((p.number == null ? '' : String(p.number)).trim() + '|' + (p.name || '').trim().toLowerCase());
const dentro = new Set((resta.players || []).filter(p => (p && p.name || '').trim()).map(chiave));
(versa.players || []).forEach(p => {
if (!(p && p.name || '').trim()) return;
if (dentro.has(chiave(p))) return;
resta.players.push(JSON.parse(JSON.stringify(p)));
dentro.add(chiave(p));
});
// Le CASELLE delle giocate (attacco/difesa) che hai creato a mano non si
// perdono: si uniscono le due liste, senza doppioni.
if (versa.tagCategories) {
const mie = ensureTagCategories(resta);
['attacco', 'difesa'].forEach(lato => {
(versa.tagCategories[lato] || []).forEach(nome => {
if (!mie[lato].includes(nome)) mie[lato].push(nome);
});
});
}
if (Array.isArray(versa.quickTags) && versa.quickTags.length) {
if (!Array.isArray(resta.quickTags)) resta.quickTags = [];
versa.quickTags.forEach(q => { if (!resta.quickTags.includes(q)) resta.quickTags.push(q); });
}
if (versa.giochiVideo) {
const st = ensureGiochiStato(resta);
Object.keys(versa.giochiVideo.flags || {}).forEach(k => { if (st.flags[k] === undefined) st.flags[k] = versa.giochiVideo.flags[k]; });
}
['playbook', 'practice', 'strategyNotes', 'fixtures'].forEach(campo => {
if (Array.isArray(versa[campo]) && versa[campo].length) {
if (!Array.isArray(resta[campo])) resta[campo] = [];
resta[campo] = resta[campo].concat(JSON.parse(JSON.stringify(versa[campo])));
}
});
if (versa.scoutingReport && !resta.scoutingReport) resta.scoutingReport = versa.scoutingReport;
if (versa.logo && !resta.logo) resta.logo = versa.logo;
// i video seguono la scheda che resta
(vt.clips || []).forEach(c => { if (c.teamId === versa.id) c.teamId = resta.id; });
// via il doppione
lg.teams = (lg.teams || []).filter(x => x.id !== versa.id);
lg.oppRosters = (lg.oppRosters || []).filter(x => x.id !== versa.id);
if (activeTeamId === versa.id) activeTeamId = resta.id;
if (typeof avvExpandedSlotId !== 'undefined' && avvExpandedSlotId === versa.id) avvExpandedSlotId = resta.id;
if (typeof personnelTeamIdCorrente !== 'undefined' && personnelTeamIdCorrente === versa.id) personnelTeamIdCorrente = resta.id;
if (vt.chipTeamId === versa.id) vt.chipTeamId = resta.id;
normalizePersonnelSlots(resta);
renderAll();
scheduleAutosave();
toast(t('squadre.unite', { nome: resta.name || '', n: quantePiene(resta) }));
});
}
function eliminaSquadraDallaLega(teamId) {
const lg = activeLeague();
if (!lg) return;
const propria = (lg.teams || []).find(x => x.id === teamId);
const casella = (lg.oppRosters || []).find(x => x.id === teamId);
const tm = propria || casella;
if (!tm) return;
const quanti = (tm.players || []).filter(p => (p && p.name || '').trim()).length;
showConfirmModal(t('confirm.deleteLeagueTeam', { name: tm.name || '—', n: quanti }), () => {
// I VIDEO NON SI BUTTANO VIA. Se in lega c'è un'altra squadra con lo
// stesso nome (il caso del doppione) i filmati passano a lei; altrimenti
// restano senza padrone e si continuano a vedere nella scheda Video, con
// il ⚠︎, pronti da riassegnare dal menu a tendina.
const gemella = allLeagueTeams(lg, false)
.map(e => e.team)
.find(x => x.id !== teamId && (x.name || '').trim().toLowerCase() === (tm.name || '').trim().toLowerCase());
let spostati = 0;
(vt.clips || []).forEach(c => {
if (c.teamId !== teamId) return;
c.teamId = gemella ? gemella.id : null;
spostati++;
});
if (propria) lg.teams = (lg.teams || []).filter(x => x.id !== teamId);
else lg.oppRosters = (lg.oppRosters || []).filter(x => x.id !== teamId);
if (lg.ownTeamId === teamId) lg.ownTeamId = null;
if (activeTeamId === teamId) {
// Ripiego su una squadra QUALSIASI che esista ancora (anche una casella
// della lega): restare senza nessuna squadra aperta lasciava la scheda
// Video completamente muta.
const rimaste = allLeagueTeams(lg, false).map(e => e.team);
activeTeamId = rimaste.length ? rimaste[0].id : null;
}
if (avvExpandedSlotId === teamId) avvExpandedSlotId = null;
if (personnelTeamIdCorrente === teamId) personnelTeamIdCorrente = null;
renderAll();
scheduleAutosave();
toast(t('toast.teamDeleted', { name: tm.name || '' }));
if (spostati) {
setTimeout(() => toast(gemella
? t('video.clipsMovedToTwin', { n: spostati, squadra: gemella.name || '' })
: t('video.clipsNowOrphan', { n: spostati })), 900);
}
});
}
// Elimina un video importato: sparisce dall'elenco, con i suoi tag, e il file
// viene tolto anche dall'archivio del browser.
/* Un video importato con aperta la squadra sbagliata restava suo per sempre:
* le schede delle giocatrici di un'altra squadra non lo vedevano nemmeno, e
* sembrava che i pezzi "non fossero collegati". Da qui lo si sposta, tag
* compresi, senza reimportare niente. */
function squadreDoveSpostare() {
const lg = activeLeague();
if (!lg) return [];
return allLeagueTeams(lg, false).map(e => e.team);
}
function spostaClipSuSquadra(clipId, teamId) {
const c = (vt.clips || []).find(x => x.id === clipId);
if (!c || !teamId || c.teamId === teamId) return;
// Un filmato rimasto senza padrone si riassegna al volo, senza domande:
// e' esattamente quello che il coach sta chiedendo scegliendo dal menu.
if (clipSenzaPadrone(c)) {
const squadraNuova = squadreDoveSpostare().find(t2 => t2.id === teamId);
if (!squadraNuova) return;
c.teamId = teamId;
activeTeamId = teamId;
renderAll(); scheduleAutosave();
toast(t('video.moved', { nome: c.name || '', squadra: squadraNuova.name || '' }));
return;
}
const squadre = squadreDoveSpostare();
const dove = squadre.find(t => t.id === teamId);
if (!dove) return;
const quanti = (c.tags || []).length;
showConfirmModal(t('video.moveAsk', { nome: c.name || '—', squadra: dove.name || '—', n: quanti }), () => {
c.teamId = teamId;
activeTeamId = teamId;
renderAll();
scheduleAutosave();
toast(t('video.moved', { nome: c.name || '', squadra: dove.name || '' }));
});
}
function eliminaClipVideo(index) {
const c = vt.clips[index];
if (!c) return;
showConfirmModal(t('confirm.deleteClip', { name: c.name || '—', n: (c.tags || []).length }), () => {
// Lapide sul filmato e su tutti i suoi pezzi: la condivisione non li
// rimette dentro al primo giro.
segnaEliminato(c.id);
(c.tags || []).forEach(tg => segnaEliminato(tg.id));
videoBlobDbDelete(c.id);
vt.clips.splice(index, 1);
if (vt.activeClip >= vt.clips.length) vt.activeClip = vt.clips.length - 1;
if (vt.activeClip < 0) vt.activeClip = 0;
vt.montageOrder = (vt.montageOrder || []).filter(id => (vt.clips || []).some(cl => (cl.tags || []).some(tg => tg.id === id)));
renderAll();
scheduleAutosave();
toast(t('toast.clipDeleted', { name: c.name || '' }));
});
}
function deleteActiveTeam() {
const league = activeLeague();
const team = activeTeam();
if (!league || !team) return;
showConfirmModal(t('confirm.deleteTeam', {name: team.name}), () => {
league.teams = league.teams.filter(t => t.id !== team.id);
activeTeamId = league.teams.length ? league.teams[0].id : null;
renderAll();
});
}
// ---------------------------------------------------------------- PLAYERS
function addPlayer() {
const team = activeTeam();
if (!team) return;
const numberInput = document.getElementById('newPlayerNumber');
const nameInput = document.getElementById('newPlayerName');
const roleInput = document.getElementById('newPlayerRole');
const heightInput = document.getElementById('newPlayerHeight');
const yearInput = document.getElementById('newPlayerYear');
const number = parseInt(numberInput.value, 10);
const name = nameInput.value.trim();
if (!name || isNaN(number)) { toast(t('toast.playerFieldsMissing')); return; }
team.players.push({ id: nextId('pl'), number, name, role: roleInput.value, height: heightInput ? heightInput.value.trim() : '', birthYear: yearInput ? yearInput.value.trim() : '' });
numberInput.value = ''; nameInput.value = ''; if (heightInput) heightInput.value = ''; if (yearInput) yearInput.value = '';
renderAll();
}
function deletePlayer(playerId) {
const team = activeTeam();
if (!team) return;
team.players = team.players.filter(p => p.id !== playerId);
renderAll();
}
// ---------------------------------------------------------- PERSISTENZA
function exportDB() {
const blob = new Blob([JSON.stringify(DB, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'vbc-lega.json';
a.click();
URL.revokeObjectURL(url);
toast(t('toast.leagueExported'));
}
function importDB(file) {
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const parsed = JSON.parse(reader.result);
if (!parsed || !Array.isArray(parsed.leagues)) throw new Error('Formato non valido');
DB = parsed;
if (!Array.isArray(DB.singleMatches)) DB.singleMatches = [];
if (!linguaRicordata() && typeof DB.uiLang === 'string' && I18N[DB.uiLang]) currentLang = DB.uiLang;
reseedIdSequenceFromDB(DB);
singleMatchMode = false;
activeSingleMatchId = null;
hideSplash();
activeLeagueId = DB.leagues.length ? DB.leagues[0].id : null;
const league = activeLeague();
activeTeamId = league && league.teams.length ? league.teams[0].id : null;
activeTeamTab = 'video';
renderAll();
toast(t('toast.leagueImported'));
} catch (e) {
toast(t('toast.invalidFile') + e.message);
}
};
reader.readAsText(file);
}
// -------------------------------------------------------------- VIDEO
function importVideoFiles(fileList) {
if ((vt.clips || []).length >= limite('video')) return bloccoBeta(t('beta.limitVideos', { n: LIMITI_BETA.video }));
const team = activeTeam();
// Ogni filmato entra nella SESSIONE DI LAVORO aperta: e' cosi' che quattro
// file della stessa partita restano un lavoro solo.
sistemaSessioni();
if (!sessioneAttiva() || sessioneAttiva().archiviata) nuovaSessione('', team ? team.id : null);
const ses = sessioneAttiva();
for (const f of Array.from(fileList || [])) {
const url = URL.createObjectURL(f);
const clipId = nextId('clip');
// DA DOVE ARRIVA QUESTO FILMATO. Con il percorso in mano il video si
// riapre da solo alla riapertura dell'app, senza copiarne un giga
// dentro all'archivio (che sui file grossi non riusciva e falliva in
// silenzio: era per questo che ritrovavi "video non caricato").
vt.clips.push({ id: clipId, name: f.name, url, tags: [], teamId: team ? team.id : null, lastPosition: 0, sessionId: ses ? ses.id : null, percorsoDisco: percorsoDelFile(f) });
// Salva anche il file vero in IndexedDB (best-effort, non blocca l'uso
// immediato del video che parte comunque dal blob url qui sopra) — è
// quello che permette a "Continua sessione precedente" di ripartire
// dallo stesso identico video invece di doverlo reimportare.
videoBlobDbSave(clipId, f);
}
if (vt.clips.length) { vt.activeClip = vt.clips.length - 1; vt.clipAppuntata = vt.clips[vt.activeClip].id; }
renderAll();
}
function setActiveClip(i) {
vt.activeClip = i;
// Da qui in poi questo e' "il video su cui sto lavorando": non sparisce
// piu' cambiando squadra.
vt.clipAppuntata = (vt.clips[i] || {}).id || null;
vt.trimStart = null;
vt.trimEnd = null;
vt.pendingTagId = null;
vtSequencePlaying = false;
renderVideoSection();
}
function currentClip() {
return vt.clips[vt.activeClip] || null;
}
/* =========================================================================
* SESSIONI DI LAVORO
*
* Una partita spesso arriva spezzata in quattro file. Studiarla e' UN lavoro
* solo: i pezzi taggati sui quattro filmati si sommano e finiscono tutti
* nelle stesse schede (giocatrici, giochi, difese) di quella squadra.
* La "sessione di lavoro" e' il contenitore di quel lavoro: tiene insieme i
* filmati importati mentre la si sta studiando.
*
* A studio finito la sessione si CHIUDE: i filmati pesanti vengono buttati
* via dal disco (sono i megabyte, e ormai non servono piu'), mentre restano
* · i pezzi gia' ritagliati, come file veri nella cartella di riferimento;
* · tutti i tag, quindi le schede dei giochi e delle giocatrici restano
* piene esattamente com'erano;
* · scouting, personnel, allenamenti — che con i video non c'entrano.
* La sessione chiusa si puo' riaprire (i pezzi si guardano ancora, sono su
* disco) oppure eliminare del tutto, ma solo dicendolo di persona.
* ====================================================================== */
function sessioni() {
if (!Array.isArray(DB.sessioni)) DB.sessioni = [];
return DB.sessioni;
}
function sessioneAttiva() {
return sessioni().find(x => x.id === vt.sessioneAttivaId) || null;
}
/* Il nome che l'app propone da sola: "LAVORO PARTITA DI ", cioe'
* come lo chiamerebbe un allenatore parlando. Se non c'e' nessuna squadra
* aperta si ripiega sulla data. */
function nomeSessioneProposto(teamId) {
const d = new Date();
const p2 = (n) => String(n).padStart(2, '0');
const sq = teamById(teamId || activeTeamId);
const nomeSq = (sq && (sq.name || '').trim()) || '';
return nomeSq
? t('sessione.nomeDefaultSquadra', { squadra: nomeSq.toUpperCase() })
: t('sessione.nomeDefault', { data: p2(d.getDate()) + '/' + p2(d.getMonth() + 1) });
}
function nuovaSessione(nome, teamId) {
const d = new Date();
const p2 = (n) => String(n).padStart(2, '0');
const ses = {
id: nextId('ses'),
nome: (nome || '').trim() || nomeSessioneProposto(teamId),
data: d.getFullYear() + '-' + p2(d.getMonth() + 1) + '-' + p2(d.getDate()),
teamId: teamId || activeTeamId || null,
archiviata: false,
};
sessioni().push(ses);
vt.sessioneAttivaId = ses.id;
// Il filmato appuntato apparteneva al lavoro di prima: aprendo una
// sessione nuova si riparte davvero da zero.
vt.clipAppuntata = null;
return ses;
}
/* Rimette in ordine dopo un ripristino: ogni filmato deve stare in una
* sessione, anche quelli importati prima che le sessioni esistessero. */
function sistemaSessioni() {
const elenco = sessioni();
const senza = (vt.clips || []).filter(c => !c.sessionId || !elenco.find(x => x.id === c.sessionId));
if (senza.length) {
let vecchia = elenco.find(x => x.ereditata);
if (!vecchia) {
vecchia = { id: nextId('ses'), nome: t('sessione.nomeEreditata'), data: '', teamId: null, archiviata: false, ereditata: true };
elenco.unshift(vecchia);
}
senza.forEach(c => { c.sessionId = vecchia.id; });
}
if (!vt.sessioneAttivaId || !elenco.find(x => x.id === vt.sessioneAttivaId)) {
const aperte = elenco.filter(x => !x.archiviata);
vt.sessioneAttivaId = (aperte.length ? aperte[aperte.length - 1] : elenco[elenco.length - 1] || {}).id || null;
}
}
/* I pezzi gia' salvati come file nella cartella tornano riproducibili anche
* dopo aver riaperto l'app (o dopo aver chiuso la sessione): si riaggancia
* il file vero al tag, e da li' in poi tutto il resto dell'app — anteprime,
* schede giocatrici, schede giochi, montaggi — funziona come se il taglio
* fosse appena stato fatto. */
function ricollegaTagliSuDisco() {
if (!NATIVO) return;
(vt.clips || []).forEach(clip => {
(clip.tags || []).forEach(tag => {
if (tag.cutUrl || !tag.cutFile) return;
// anche qui: in gabbia i file fuori dalla cartella dati si leggono
// solo passando dallo schema vdm-file
tag.cutUrl = urlDaPercorso(String(tag.cutFile));
tag.cutStatus = 'ready';
if (!tag.cutExt) tag.cutExt = (tag.cutFile.split('.').pop() || 'mp4');
});
});
}
/* ============ NESSUN VIDEO SI PERDE PIU' PER STRADA ============
* Prima il player mostrava SOLO i filmati intestati alla squadra aperta in
* quel momento. Conseguenza: bastava cancellare o rinominare una squadra, o
* semplicemente cambiare squadra dal menu, e il video su cui si stava
* lavorando spariva dallo schermo — sembrava che l'app "non caricasse piu' i
* video", mentre erano tutti li'.
* Adesso, oltre ai filmati della squadra aperta, restano visibili:
* · quello su cui si sta lavorando ADESSO (resta appuntato anche cambiando
* squadra, con scritto di chi e');
* · quelli rimasti senza padrone, perche' la loro squadra e' stata
* cancellata: si vedono con un ⚠︎ e si riassegnano dal menu a tendina
* accanto al nome, senza reimportarli. */
function squadraEsisteAncora(id) {
if (!id) return false;
const lg = activeLeague();
if (!lg) return false;
return !!(lg.teams || []).find(x => x.id === id) || !!(lg.oppRosters || []).find(x => x.id === id);
}
function clipSenzaPadrone(clip) {
return !!clip && (!clip.teamId || !squadraEsisteAncora(clip.teamId));
}
function clipDaMostrare(team) {
const appuntata = vt.clipAppuntata;
const ses = vt.sessioneAttivaId;
return (vt.clips || []).filter(c => {
const suoi = (team && c.teamId === team.id) || clipSenzaPadrone(c) || (appuntata && c.id === appuntata);
if (!suoi) return false;
// Dentro alla scheda Video si vede la SESSIONE APERTA. Le altre restano
// dov'erano, si passa da una all'altra dal menu delle sessioni.
if (ses && c.sessionId && c.sessionId !== ses && c.id !== appuntata) return false;
return true;
});
}
function activeVideoClipForTeam() {
const team = activeTeam();
const clip = currentClip();
if (!clip) return null;
if (team && clip.teamId === team.id) return clip;
if (clipSenzaPadrone(clip)) return clip;
if (vt.clipAppuntata && clip.id === vt.clipAppuntata) return clip;
return null;
}
function markStart() {
const v = document.getElementById('vPlayer');
if (!v) return;
vt.trimStart = v.currentTime;
vt.sopraChiuso = false; // nuovo pezzo da marcare: le caselle tornano
if (vt.trimEnd != null && vt.trimEnd <= vt.trimStart) vt.trimEnd = null;
// Re-render completo (non solo il testo): aggiorna anche la tacca "Inizio"
// sulla barra di taglio visuale — la posizione di riproduzione resta
// comunque quella giusta grazie al preserved/restore in renderVideoSection.
renderVideoSection();
}
function markEnd() {
const v = document.getElementById('vPlayer');
if (!v) return;
vt.trimEnd = v.currentTime;
renderVideoSection();
// Meccanismo Inizio→Fine→Tag più immediato: appena entrambi sono marcati
// il pannello "tag pronto" appare (vedi renderPendingTagPanel) E il focus
// ci va dentro subito, pronto a scrivere il nome della giocata e premere
// Invio, senza dover cercare/cliccare altrove.
if (vt.trimStart != null && vt.trimEnd != null) {
const inp = document.getElementById('pendingTagInput');
if (inp) inp.focus();
}
}
function renderTrimReadout() {
const el = document.getElementById('trimReadout');
if (el) {
// Con nessun taglio in corso la scritta era solo "Inizio: — · Fine: —":
// 150px di riga occupati per non dire niente, che mandavano a capo la
// riga dei controlli. Compare solo quando serve davvero, cioè da quando
// si marca l'inizio.
const attivo = vt.trimStart != null || vt.trimEnd != null;
el.style.display = attivo ? '' : 'none';
// Forma compatta ("✂ 0:12 → 0:18"): le parole "Inizio"/"Fine" sono già
// scritte sui due pulsanti accanto, ripeterle qui allargava la riga fino
// a mandarla a capo proprio mentre stai tagliando.
el.textContent = '✂ ' + fmtTime(vt.trimStart) + ' → ' + fmtTime(vt.trimEnd);
}
// "Inizio" resta ACCESO finché il taglio è aperto, cioè da quando marchi
// l'inizio a quando marchi la fine: guardando il video si vede subito che
// c'è un taglio in corso e che manca la fine, senza doverlo ricordare.
// Il taglio è "aperto" da quando marchi l'inizio FINO A QUANDO non hai
// assegnato il tag: Inizio resta acceso in attesa della fine, e appena
// marchi la fine passa il testimone a Fine, che resta acceso finché la
// giocata non viene scelta (o scritta nel pannello del tag pronto).
const open = vt.trimStart != null;
const waitingEnd = open && vt.trimEnd == null;
const waitingTag = (open && vt.trimEnd != null) || !!vt.pendingTagId;
const startBtn = document.getElementById('vMarkStartBtn');
if (startBtn) startBtn.classList.toggle('cutting', waitingEnd);
const endBtn = document.getElementById('vMarkEndBtn');
if (endBtn) endBtn.classList.toggle('cutting', waitingTag);
}
// Play/pausa: i controlli nativi del browser sono stati tolti (erano la
// seconda barra del tempo, che rubava altezza al player) — restano la nostra
// barra unica sotto al video, questo bottone, il click sul video e la barra
// spaziatrice.
// Volume dell'ascolto, ricordato per tutta la sessione (non solo fra un
// ri-render e l'altro): cambiando clip o dopo un tag il player si ricrea da
// zero e senza questo tornava sempre a volume pieno.
let vtVolume = 1, vtMuted = false;
function setVideoVolume(value) {
vtVolume = Math.max(0, Math.min(1, Number(value) / 100));
if (vtVolume > 0) vtMuted = false;
applyVideoVolume();
}
function toggleVideoMute() {
// Conta il silenzio EFFETTIVO, non solo il flag "muto": col cursore già a
// zero il video non si sente comunque, e un click sul pulsante doveva
// riaccendere l'audio — non "mutarlo" una seconda volta senza effetto.
const silenzioso = vtMuted || vtVolume === 0;
vtMuted = !silenzioso;
// Riattivando l'audio con il cursore a zero non si sentirebbe comunque
// nulla: si riporta a metà, così il pulsante fa sempre qualcosa di udibile.
if (!vtMuted && vtVolume === 0) vtVolume = 0.5;
applyVideoVolume();
}
function applyVideoVolume() {
const v = document.getElementById('vPlayer');
if (v) { v.volume = vtVolume; v.muted = vtMuted || vtVolume === 0; }
const btn = document.getElementById('vMuteBtn');
if (btn) btn.textContent = (vtMuted || vtVolume === 0) ? '🔇' : (vtVolume < 0.5 ? '🔉' : '🔊');
const slider = document.getElementById('vVolume');
if (slider && document.activeElement !== slider) slider.value = String(Math.round(vtVolume * 100));
}
function togglePlayPause() {
const v = document.getElementById('vPlayer');
if (!v) return;
if (v.paused) v.play(); else v.pause();
syncPlayPauseBtn();
}
function syncPlayPauseBtn() {
const v = document.getElementById('vPlayer');
const b = document.getElementById('vPlayPauseBtn');
if (!v || !b) return;
b.textContent = v.paused ? '▶' : '⏸';
b.classList.toggle('primary', !v.paused);
}
/* Pannello "tag pronto": si accende SUBITO appena Inizio+Fine sono entrambi
* marcati (o dopo aver premuto "classifica" su un tag dell'elenco,
* vt.pendingTagId) — un campo per scrivere/
* confermare la giocata al volo (Invio o bottone), oltre a poter comunque
* cliccare una categoria/giocatrice esistente nei pannelli laterali. */
function renderPendingTagPanel() {
const panel = document.getElementById('pendingTagPanel');
if (!panel) return;
const ready = !!vt.pendingTagId || (vt.trimStart != null && vt.trimEnd != null);
if (!ready) { panel.style.display = 'none'; panel.innerHTML = ''; return; }
const rangeLabel = vt.pendingTagId
? t('video.pendingTagLabel')
: t('video.readyTagLabel', { start: fmtTime(vt.trimStart), end: fmtTime(vt.trimEnd) });
panel.style.display = 'flex';
panel.innerHTML = `
🏷️ ${escapeHtml(rangeLabel)}
${t('video.pendingTagConfirmBtn')}
`;
}
/* Conferma il tag scritto a mano nel pannello "tag pronto": riusa quickTag
* (che sa già gestire sia il caso Inizio/Fine marcati sia il caso "Tag Qui"
* in attesa di classificazione tramite vt.pendingTagId). */
/* LE CASELLE SOPRA IL VIDEO. Stesso momento del pannello "tag pronto"
* (Inizio+Fine marcati, oppure un tag in attesa di classificazione), ma
* disegnate dentro al riquadro del video: si sceglie senza staccare gli
* occhi dalla partita. Sono le stesse identiche giocate dei pannelli di
* lato e gli stessi tag rapidi, e chiamano la stessa quickTag(), cosi' non
* esistono due strade che si comportano in modo diverso.
* NOTA sui tag rapidi: qui NON creano il taglio da 3 secondi (quello lo fa
* quickCutLabel quando non c'e' niente da classificare). Qui danno il nome
* al pezzo che hai appena marcato, esattamente come scriverlo a mano. */
function renderTagOverlay() {
/* SI SPENGONO TUTTI, non solo quello trovato per id. Ridisegnando la
* sezione (o cambiando la grandezza del video) puo' restare in giro un
* riquadro vecchio: cercandone uno solo, quello rimaneva acceso sopra la
* partita anche tornando dalla finestra grande. Qui si spengono tutti e si
* riempie l'ultimo, che e' quello attaccato al video di adesso. */
const tutti = [...document.querySelectorAll('.vt-sopra')];
if (!tutti.length) return;
tutti.forEach(b => { b.classList.remove('show'); b.innerHTML = ''; });
const box = tutti[tutti.length - 1];
/* SOLO A SCHERMO INTERO. A schermo normale i pannelli Attacco e Difesa
* sono gia' li' di fianco al video: il riquadro traslucido sarebbe un
* doppione che copre la partita per niente. Serve quando quei pannelli
* non si vedono, cioe' a tutto schermo — ed era la richiesta di partenza.
* (23 settembre: prima compariva sempre, e a schermo normale si finiva con
* le caselle due volte, sul video e di lato.) */
if (!document.fullscreenElement) return;
const clip = currentClip();
const pronto = !!clip && (!!vt.pendingTagId || (vt.trimStart != null && vt.trimEnd != null));
if (!pronto || vt.sopraChiuso) return;
const team = activeTeam();
const cats = team ? ensureTagCategories(team) : { attacco: [], difesa: [] };
const rapidi = (team && Array.isArray(team.quickTags)) ? team.quickTags : [];
const eti = vt.pendingTagId
? t('video.pendingTagLabel')
: t('video.readyTagLabel', { start: fmtTime(vt.trimStart), end: fmtTime(vt.trimEnd) });
const chip = (side, nome, cls) =>
`${escapeHtml(nome)} `;
// I GIOCATORI, sopra: stessa squadra della striscia normale e stessa
// chiamata, cosi' il pezzo finisce nella scheda della persona giusta.
const sq = (typeof squadraDeiNomi === 'function') ? squadraDeiNomi(team, clip) : team;
const gioc = (sq && Array.isArray(sq.players)) ? sq.players.filter(p => (p.name || '').trim() || String(p.number || '').trim()) : [];
const chipGioc = (p, i) => {
const nome = (p.name || '').trim() || ('#' + (p.number || (i + 1)));
const scritta = (p.number ? '#' + p.number + ' ' : '') + nome;
return `${escapeHtml(scritta)} `;
};
const colonna = (eti2, roba) => roba
? `${escapeHtml(eti2)}
${roba}
`
: `
`;
box.innerHTML = `
\ud83c\udff7\ufe0f ${escapeHtml(eti)}
\u2715
${rapidi.map(x => chip(null, x, 'rapido')).join('')}
${gioc.map(chipGioc).join('')}
${colonna(t('playbook.groupAtt'), (cats.attacco || []).map(x => chip('attacco', x)).join(''))}
${colonna(t('playbook.groupDif'), (cats.difesa || []).map(x => chip('difesa', x)).join(''))}
`;
box.classList.add('show');
}
/* Via le caselle senza classificare: il pezzo marcato resta, e si puo'
* sempre usare il pannello di lato o la casella per scriverlo a mano. */
function chiudiTagOverlay() {
vt.sopraChiuso = true;
renderTagOverlay();
}
/* Cambiando la grandezza del video, o entrando e uscendo da schermo intero,
* il riquadro va comunque ridisegnato: e' il momento in cui prima restava
* appiccicato. */
document.addEventListener('fullscreenchange', () => { try { renderTagOverlay(); } catch (e) {} });
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
const box = document.getElementById('vtSopra');
if (box && box.classList.contains('show')) { e.preventDefault(); chiudiTagOverlay(); }
});
/* Schermo intero VERO. Va chiesto per il RIQUADRO, non per il : se lo
* si chiedesse al video, il sistema lo mostrerebbe da solo a tutto schermo e
* le caselle qui sopra sparirebbero, che e' esattamente il problema che
* stiamo risolvendo. */
function videoSchermoIntero() {
if (document.fullscreenElement) { document.exitFullscreen(); return; }
// la COLONNA, non il video: dentro ci sta anche la barra con Inizio e Fine
const col = document.getElementById('videoCentralCol') || document.getElementById('videoPlayerWrap');
if (col && col.requestFullscreen) col.requestFullscreen();
}
function confirmPendingTag(name) {
const clean = (name || '').trim();
if (!clean) { toast(t('toast.typeTagNameFirst')); return; }
quickTag(null, clean.toUpperCase(), null);
}
function fmtTime(s) {
if (s == null || !isFinite(s)) return '—';
const m = Math.floor(s / 60), sec = Math.floor(s % 60);
return String(m).padStart(2, '0') + ':' + String(sec).padStart(2, '0');
}
/* Clic sulla barra di taglio: sposta il playhead nel punto cliccato, come
* la vt-trimbar di ITALBASKET WOMAN (vtTrimBarClick). */
function trimBarClick(ev) {
const clip = currentClip();
const v = document.getElementById('vPlayer');
const bar = document.getElementById('vTrimBar');
if (!clip || !v || !bar || !clip.duration) return;
const rect = bar.getBoundingClientRect();
const pct = Math.min(1, Math.max(0, (ev.clientX - rect.left) / rect.width));
v.currentTime = pct * clip.duration;
}
/* Risolve la durata del video anche per i file "problematici" (tipico bug
* Chrome con blob generati da MediaRecorder, incluso il nostro stesso
* "Esporta montaggio": video.duration resta Infinity finché non si forza un
* seek verso la fine) — stessa tecnica di resolveVideoDuration in ITALBASKET
* WOMAN: seek a un tempo enorme, aspetta che il browser corregga la durata,
* poi riporta il playhead a 0. Timeout di sicurezza se non si assesta mai. */
function resolveVideoDuration(video, cb) {
if (isFinite(video.duration) && video.duration > 0) { cb(video.duration); return; }
/* Certi filmati (registrazioni dello schermo, esportazioni "in diretta",
* webm tagliati) non scrivono la durata nell'intestazione del file: il
* lettore risponde "Infinito" e la barra del tempo non sa quanto e' lunga
* la partita. Il trucco per farla saltare fuori e' chiedere di andare a un
* punto lontanissimo: il lettore ci va, si ferma alla fine vera e a quel
* punto la durata la sa. Lo si fa su un lettore NASCOSTO, gemello di
* quello a schermo, cosi' il coach non vede il video schizzare alla fine. */
const sorgente = video.currentSrc || video.src;
if (!sorgente) { cb(0); return; }
const spia = document.createElement('video');
spia.preload = 'auto';
spia.muted = true;
spia.style.cssText = 'position:fixed;left:-9999px;top:0;width:2px;height:2px;opacity:0;pointer-events:none;';
document.body.appendChild(spia);
let chiuso = false;
const buona = () => {
if (isFinite(spia.duration) && spia.duration > 0) return spia.duration;
if (spia.seekable && spia.seekable.length) {
const f = spia.seekable.end(spia.seekable.length - 1);
if (isFinite(f) && f > 0) return f;
}
return 0;
};
const chiudi = (d) => {
if (chiuso) return;
chiuso = true;
clearInterval(battito);
clearTimeout(resa);
try { spia.removeAttribute('src'); spia.load(); } catch (e) {}
try { spia.remove(); } catch (e) {}
cb(d || 0);
};
const salta = () => { try { spia.currentTime = 1e101; } catch (e) {} };
spia.addEventListener('loadedmetadata', salta);
spia.addEventListener('canplay', salta);
spia.addEventListener('canplaythrough', salta);
spia.addEventListener('error', () => chiudi(0));
// Si ricontrolla di continuo invece di fidarsi di un singolo evento:
// a seconda del formato la durata compare dopo "seeked", dopo
// "durationchange" o semplicemente quando il file finisce di caricarsi.
const battito = setInterval(() => { const d = buona(); if (d) chiudi(d); else salta(); }, 400);
const resa = setTimeout(() => chiudi(buona()), 15000);
spia.src = sorgente;
}
/* Al primo caricamento metadati di una clip, memorizza la durata (serve per
* calcolare le percentuali sulla barra di taglio) e ri-renderizza per
* disegnarla — un solo ri-render per clip, non ad ogni "loadedmetadata". */
function onVideoMeta(clipId, video) {
const clip = vt.clips.find(c => c.id === clipId);
if (!clip) return;
// Proporzioni reali del video (larghezza/altezza native): le salviamo sulla
// clip così restano note anche dopo un ri-render (che ricrea il tag
// da zero), e ridimensioniamo subito il contenitore di conseguenza
// — vedi applyVideoBoxSize. Va fatto ad OGNI onloadedmetadata (non solo la
// prima volta), perché ogni ri-render crea un nuovo elemento che
// rifà scattare questo evento e il nuovo contenitore parte senza stile.
if (video && video.videoWidth && video.videoHeight) {
clip.aspectRatio = video.videoWidth / video.videoHeight;
applyVideoBoxSize(clip);
}
// Una durata gia' NOTA (>0) non va ricalcolata. Ma se vale 0 / null /
// undefined (video il cui header non dichiara la durata, oppure un
// tentativo precedente andato a vuoto) si riprova: senza durata la barra
// del tempo non puo' scorrere.
if (clip.duration) return;
resolveVideoDuration(video, (d) => {
clip.duration = d || 0;
renderVideoSection();
});
}
/* Un video che il browser non riesce a decodificare restava prima solo nero
* senza nessuna spiegazione — come ecOnClipError/vtOnClipError in ITALBASKET
* WOMAN, mostriamo un avviso con la causa più probabile. */
function onVideoError(clipId, video) {
const clip = vt.clips.find(c => c.id === clipId);
if (!clip) return;
const codeMsgs = {
1: t('video.errAborted'),
2: t('video.errNetwork'),
3: t('video.errDecode'),
4: t('video.errUnsupported'),
};
const code = video && video.error ? video.error.code : 4;
clip.error = codeMsgs[code] || t('video.errUnknown');
renderVideoSection();
}
// -------------------------------------------------------- CONTROLLI PLAYER
function frameStep(dir) {
const v = document.getElementById('vPlayer');
if (!v) return;
v.pause();
v.currentTime = Math.max(0, v.currentTime + dir * (1 / 30));
}
function skipSeconds(sec) {
const v = document.getElementById('vPlayer');
if (!v) return;
v.currentTime = Math.max(0, v.currentTime + sec);
}
function setSpeed(val) {
const v = document.getElementById('vPlayer');
if (!v) return;
v.playbackRate = parseFloat(val) || 1;
}
// ------------------------------------------------- TAGLIO AUTOMATICO PER-TAG
// Come il trimmer di ITALBASKET WOMAN: appena una giocata viene classificata
// (categoria, giocatrice o tag rapido) il pezzo taggato non resta solo un
// segno inizio/fine sul video originale — viene SUBITO ritagliato in un vero
// file video a parte (stessa tecnica canvas+MediaRecorder di "Esporta
// montaggio", ma per un solo segmento), così è già pronto da solo (anteprima/
// download in Elenco Tag) prima ancora di montare qualunque cosa. Un solo
// taglio alla volta (recorderBusy) — evita due MediaRecorder/AudioContext
// in corsa insieme, cosa che "Esporta montaggio" rispetta a sua volta.
let recorderBusy = false;
// Attesa che il registratore si liberi, ma con un tetto: se un taglio
// precedente è rimasto appeso (video sorgente sparito, permessi audio), senza
// tetto TUTTO il resto restava bloccato dietro di lui per sempre.
function waitRecorderFree(maxMs) {
const limite = Date.now() + (maxMs || 15000);
return new Promise(resolve => {
(function check() {
if (!recorderBusy || Date.now() > limite) { recorderBusy = false; resolve(); return; }
setTimeout(check, 150);
})();
});
}
let cutQueue = [];
let cutQueueRunning = false;
/* Un solo avviso ogni tanto invece di uno per clip: tagliando dieci pezzi
* di fila il coach vedrebbe dieci messaggi uno sopra l'altro. */
let clipSalvateInCartella = 0;
let avvisatoNienteCartella = false;
let avvisoClipTimer = null;
function avvisaClipSalvate() {
if (avvisoClipTimer) clearTimeout(avvisoClipTimer);
avvisoClipTimer = setTimeout(() => {
const n = clipSalvateInCartella;
clipSalvateInCartella = 0;
avvisoClipTimer = null;
if (!n) return;
const ses = sessioneAttiva();
toast(t(n === 1 ? 'cut.savedOne' : 'cut.savedMany', { n: n, cartella: (ses && ses.nome) ? (DB.folderRef || '') + ' / ' + ses.nome : (DB.folderRef || '') }));
scheduleAutosave();
}, 1500);
}
function enqueueCut(clipId, tagId) {
cutQueue.push({ clipId, tagId });
if (!cutQueueRunning) processCutQueue();
}
/* ============ LE CLIP TAGLIATE FINISCONO DA SOLE SUL DISCO ============
* Appena un pezzo e' stato ritagliato, il file vero viene scritto dentro
* alla CARTELLA DI RIFERIMENTO scelta dal coach, gia' ordinato:
*
* ///0007 PICK CENTRALE - 7 Vanloo - 12m03s.mp4
*
* Cosi' le clip stanno gia' dove servono — pronte da mandare, da mettere in
* un montaggio esterno o semplicemente da ritrovare — senza doverle
* scaricare una per una. Se nessuna cartella e' collegata non succede
* niente di male: il pezzo resta comunque tagliato dentro all'app e lo si
* puo' scaricare a mano come prima. */
/* Windows ha tre regole in piu' del Mac sui nomi dei file, e le fa valere in
* silenzio: niente punto o spazio in fondo (li toglie lui e poi non ritrova
* piu' il file), e una manciata di nomi riservati alle porte del computer
* (CON, PRN, AUX, NUL, COM1..9, LPT1..9) che non si possono proprio usare.
* Una squadra chiamata "Aux" bastava a far fallire il salvataggio. */
const NOMI_RISERVATI_WINDOWS = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
function nomeDiFileSicuro(s, max) {
let out = String(s || '')
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[\\/:*?"<>|]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, max || 60)
.replace(/[. ]+$/, ''); // il taglio puo' rimettere uno spazio in fondo
if (NOMI_RISERVATI_WINDOWS.test(out)) out = out + '_';
return out;
}
function minutiSecondi(sec) {
const s = Math.max(0, Math.round(+sec || 0));
return Math.floor(s / 60) + 'm' + String(s % 60).padStart(2, '0') + 's';
}
function cartellaDiRiferimentoPronta() {
return !!(NATIVO ? DB.folderRefPath : folderRefHandle);
}
function nomeFileDelTaglio(clip, tag, ext) {
const tutti = [];
(vt.clips || []).forEach(c => (c.tags || []).forEach(tg => tutti.push(tg)));
const n = String(Math.max(1, tutti.indexOf(tag) + 1)).padStart(4, '0');
const pezzi = [n];
if (tag.label) pezzi.push(nomeDiFileSicuro(tag.label, 40));
if (tag.playerLabel) pezzi.push(nomeDiFileSicuro(tag.playerLabel.replace('#', ''), 30));
if (!tag.label && !tag.playerLabel && tag.side) pezzi.push(nomeDiFileSicuro(tag.side, 20));
// Da QUALE filmato viene, e a che minuto di quel filmato: con una partita
// spezzata in sei file, "0m30s" da solo non dice niente.
const daDove = nomeDiFileSicuro(String(clip.name || '').replace(/\.[^.]+$/, ''), 18);
pezzi.push((daDove ? daDove + ' ' : '') + minutiSecondi(tag.start));
return pezzi.join(' - ').replace(/\s+-\s+$/, '') + '.' + (ext || 'mp4');
}
function cartelleDelTaglio(clip, tag) {
// La sottocartella e' quella della squadra STUDIATA: se il pezzo porta il
// nome di una giocatrice del Belgio finisce nella cartella "Belgio", anche
// se il filmato e' intestato alla partita della propria squadra.
const squadra = teamById((tag && tag.playerTeamId) || clip.teamId) || activeTeam();
const lega = activeLeague();
const parti = [];
if (lega && lega.name) parti.push(nomeDiFileSicuro(lega.name, 40));
parti.push(nomeDiFileSicuro((squadra && squadra.name) || 'SQUADRA', 40));
// ...e dentro, la SESSIONE DI LAVORO: e' il riferimento che dice da quale
// partita viene quel pezzo. Senza, aprendo la cartella fra un mese ci si
// ritrova cento file senza sapere di che gara sono.
const ses = (sessioni() || []).find(x => x.id === clip.sessionId);
if (ses && (ses.nome || '').trim()) parti.push(nomeDiFileSicuro(ses.nome, 50));
return parti;
}
/* Dove finisce un montaggio: stessa radice delle clip, stessa lega e stessa
* squadra, poi una cartella MONTAGGI. Cosi' aprendo la cartella della squadra
* ci sono i pezzi da una parte e i filmati montati dall'altra. */
function cartelleDelMontaggio(team) {
const lega = activeLeague();
const parti = [];
if (lega && lega.name) parti.push(nomeDiFileSicuro(lega.name, 40));
parti.push(nomeDiFileSicuro((team && team.name) || 'SQUADRA', 40));
parti.push('MONTAGGI');
return parti;
}
async function salvaMontaggioNellaCartella(team, blob, nome) {
if (!cartellaDiRiferimentoPronta()) return null;
const cartelle = cartelleDelMontaggio(team);
try {
if (NATIVO) {
const percorso = [DB.folderRefPath].concat(cartelle, [nome]).join('/');
await window.VDM.fs.scriviBinario(percorso, await blob.arrayBuffer());
return cartelle.join('/') + '/' + nome;
}
if (folderRefHandle.requestPermission) {
const ok = await folderRefHandle.requestPermission({ mode: 'readwrite' });
if (ok !== 'granted') return null;
}
let dir = folderRefHandle;
for (const c of cartelle) dir = await dir.getDirectoryHandle(c, { create: true });
const f = await dir.getFileHandle(nome, { create: true });
const w = await f.createWritable();
await w.write(blob);
await w.close();
return cartelle.join('/') + '/' + nome;
} catch (e) {
console.error('Non sono riuscito a scrivere il montaggio nella cartella:', e);
return null;
}
}
async function salvaTaglioNellaCartella(clip, tag, blob, ext) {
if (!cartellaDiRiferimentoPronta()) return null;
const cartelle = cartelleDelTaglio(clip, tag);
const nome = nomeFileDelTaglio(clip, tag, ext);
try {
if (NATIVO) {
const percorso = [DB.folderRefPath].concat(cartelle, [nome]).join('/');
const dati = await blob.arrayBuffer();
await window.VDM.fs.scriviBinario(percorso, dati);
return percorso;
}
// Nel browser serve il permesso di scrittura sulla cartella scelta.
if (folderRefHandle.requestPermission) {
const ok = await folderRefHandle.requestPermission({ mode: 'readwrite' });
if (ok !== 'granted') return null;
}
let dir = folderRefHandle;
for (const c of cartelle) dir = await dir.getDirectoryHandle(c, { create: true });
const f = await dir.getFileHandle(nome, { create: true });
const w = await f.createWritable();
await w.write(blob);
await w.close();
return cartelle.join('/') + '/' + nome;
} catch (e) {
/* Prima qui finiva tutto in silenzio: su Windows un percorso troppo lungo
* (lega + squadra + sessione + nome, oltre i 260 caratteri) fa fallire la
* scrittura, e il coach restava convinto di avere le clip nella cartella
* mentre non c'era niente. Adesso almeno lo dice. */
console.error('Non sono riuscito a scrivere la clip nella cartella:', e);
try {
const lungo = /ENAMETOOLONG|ENOENT/.test(String((e && e.name) || '') + String((e && e.message) || ''));
toast(lungo ? t('cut.saveFailedLong') : t('cut.saveFailed'));
} catch (e2) {}
return null;
}
}
/* Bottone "Salva le clip nella cartella": riscrive sul disco tutti i pezzi
* gia' tagliati del filmato aperto. Serve quando la cartella e' stata
* collegata DOPO aver taggato, o quando si vuole rifare una copia. */
async function salvaTutteLeClipNellaCartella() {
if (!cartellaDiRiferimentoPronta()) { toast(t('cut.noFolder')); return; }
const clip = currentClip();
const tutti = clip ? (clip.tags || []).filter(tg => tg.end > tg.start && (tg.side || tg.label || tg.playerId)) : [];
if (!tutti.length) { toast(t('cut.nothingToSave')); return; }
// Chiudendo l'app i ritagli tenuti in memoria si perdono (restano i tag,
// non i pezzi di filmato). Quindi: quelli ancora pronti si scrivono
// subito, gli altri si rimettono in coda e vengono RITAGLIATI da capo —
// il taglio passa dalla registrazione in tempo reale, quindi ci mette
// all'incirca la durata dei pezzi. Appena pronti finiscono da soli nella
// cartella (vedi processCutQueue).
const pronti = tutti.filter(tg => tg.cutUrl);
const daRifare = tutti.filter(tg => !tg.cutUrl);
if (daRifare.length) {
const secondi = Math.ceil(daRifare.reduce((s, tg) => s + (tg.end - tg.start), 0));
toast(t('cut.recutting', { n: daRifare.length, sec: secondi }));
daRifare.forEach(tg => enqueueCut(clip.id, tg.id));
}
let fatti = 0;
for (const tag of pronti) {
try {
const risposta = await fetch(tag.cutUrl);
const blob = await risposta.blob();
const dove = await salvaTaglioNellaCartella(clip, tag, blob, tag.cutExt || 'mp4');
if (dove) { tag.cutFile = dove; fatti++; }
} catch (e) {
// ritaglio non più valido (app riaperta): si rifà da capo
enqueueCut(clip.id, tag.id);
}
}
if (fatti) toast(t(fatti === 1 ? 'cut.savedOne' : 'cut.savedMany', { n: fatti, cartella: DB.folderRef || '' }));
/* Se non se n'e' scritta NESSUNA il bottone non diceva niente, e si
* restava convinti di avere le clip nella cartella. Un rifiuto di
* scrittura (permesso negato, percorso troppo lungo su Windows) non passa
* dal messaggio d'errore piu' sopra, quindi va detto qui. */
else if (pronti.length) toast(t('cut.saveFailed'));
scheduleAutosave();
}
async function processCutQueue() {
cutQueueRunning = true;
while (cutQueue.length) {
const { clipId, tagId } = cutQueue.shift();
const clip = vt.clips.find(c => c.id === clipId);
const tag = clip && clip.tags.find(t => t.id === tagId);
if (!clip || !tag || tag.end <= tag.start) continue;
tag.cutStatus = 'cutting';
if (currentClip() === clip) renderTagList(clip);
try {
/* PRIMA SI PROVA A TAGLIARE PER DAVVERO: i fotogrammi originali
* copiati dalla partita al file nuovo. Se il pezzo ha dei disegni
* sopra, o il filmato non sta su disco, questo risponde null e si
* riprende la strada di sempre. */
const diretto = await tagliaTagDiretto(clip, tag);
if (diretto) {
tag.cutFile = diretto.percorso;
tag.cutUrl = urlDaPercorso(diretto.percorso);
tag.cutExt = 'mp4';
tag.cutStatus = 'ready';
clipSalvateInCartella++;
avvisaClipSalvate();
if (currentClip() === clip) renderTagList(clip);
continue;
}
await waitRecorderFree();
recorderBusy = true;
const blob = await recordSingleSegment(clip.url, tag.start, tag.end);
tag.cutUrl = URL.createObjectURL(blob);
// L'estensione del file scaricato deve seguire il formato davvero
// registrato (MP4 dove il browser lo supporta, WebM come ripiego):
// scriverla fissa avrebbe prodotto un ".webm" con dentro un MP4.
tag.cutExt = (blob.type && blob.type.indexOf('mp4') >= 0) ? 'mp4' : 'webm';
tag.cutStatus = 'ready';
// ...e subito dopo il file vero nella cartella di riferimento.
const dove = await salvaTaglioNellaCartella(clip, tag, blob, tag.cutExt);
if (dove) {
tag.cutFile = dove;
clipSalvateInCartella++;
avvisaClipSalvate();
} else if (!cartellaDiRiferimentoPronta() && !avvisatoNienteCartella) {
// Una volta sola per sessione: il pezzo e' tagliato ma non ha una
// cartella dove andare, e senza dirlo il coach non lo saprebbe mai.
avvisatoNienteCartella = true;
toast(t('cut.noFolder'));
}
} catch (err) {
console.error('Taglio automatico del tag fallito:', err);
tag.cutStatus = 'error';
} finally {
recorderBusy = false;
}
if (currentClip() === clip) renderTagList(clip);
}
cutQueueRunning = false;
}
/* Formato di registrazione delle clip: si prova PRIMA l'MP4 (H.264/AAC), il
* formato che il coach si aspetta di ottenere tagliando una clip — si apre
* ovunque, si manda su WhatsApp, si carica sui gestionali federali e lo
* digeriscono i programmi di montaggio, mentre il .webm di prima veniva
* spesso rifiutato o non si apriva proprio su Mac/iPhone. MediaRecorder in
* MP4 è supportato da Safari e da Chrome recente; dove non c'è si ricade sul
* WebM di prima (meglio una clip in WebM che nessuna clip), quindi
* l'estensione del file scaricato segue sempre il formato davvero usato. */
function pickRecorderFormat() {
// Ordine importante. Prima l'MP4 con H.264 dichiarato esplicitamente: è
// quello che apre ovunque. Il "video/mp4" generico va invece DOPO il WebM,
// perché alcuni browser lo dichiarano supportato ma poi ci mettono dentro
// VP9: un file chiamato .mp4 che diversi lettori non aprono. Meglio allora
// un .webm onesto dove è disponibile; il video/mp4 generico resta l'ultima
// scelta per Safari, che registra davvero in H.264 ma non conosce il WebM.
const candidates = [
{ mimeType: 'video/mp4;codecs=avc1.42E01E,mp4a.40.2', blobType: 'video/mp4', ext: 'mp4' },
{ mimeType: 'video/mp4;codecs=avc1', blobType: 'video/mp4', ext: 'mp4' },
{ mimeType: 'video/webm;codecs=vp9,opus', blobType: 'video/webm', ext: 'webm' },
{ mimeType: 'video/webm;codecs=vp8,opus', blobType: 'video/webm', ext: 'webm' },
{ mimeType: 'video/webm', blobType: 'video/webm', ext: 'webm' },
{ mimeType: 'video/mp4', blobType: 'video/mp4', ext: 'mp4' },
];
for (const c of candidates) {
if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(c.mimeType)) return c;
}
return { mimeType: '', blobType: 'video/webm', ext: 'webm' };
}
/* Registra in un nuovo blob video SOLO il segmento [start,end] di un video
* sorgente (src): un nascosto viene riprodotto in tempo reale, i
* fotogrammi disegnati su un canvas fisso e l'audio instradato tramite
* AudioContext, entrambi catturati insieme da un MediaRecorder — identica
* tecnica di exportTeamMontage, isolata qui per un singolo pezzo così può
* essere richiamata sia dal taglio automatico sia (indirettamente) dal
* montaggio, che riusa i pezzi già tagliati invece di riaprire il sorgente. */
/* ======================================================================
* LA MISURA DEL MONTAGGIO — perche' non e' piu' fissa a 960x540
*
* Fino ad ora ogni montaggio usciva 960x540, qualunque cosa gli si desse
* in pasto. Su un portatile non si nota. Su un televisore in sala video
* si': l'immagine viene ingrandita quattro volte e i numeri sulle maglie
* diventano macchie. E per far vedere una giocata alla squadra,
* riconoscere chi e' chi conta piu' di ogni altra cosa.
* Le partite vere arrivano a 1920x1080: buttarne via tre quarti e poi
* proiettarle grandi era il difetto piu' costoso del montaggio.
* Adesso la tela prende la misura del filmato di partenza, con un tetto
* a 1920x1080 (oltre non serve, e i file diventerebbero enormi) e le
* misure arrotondate a numeri pari, che il compressore H.264 pretende.
* Se qualcosa non si riesce a leggere si torna ai vecchi 960x540. */
/* SPENTO PER ORA. La misura piena arrivera' col motore nuovo, quello che
* copia i fotogrammi invece di ridisegnarli. Sul motore vecchio una tela
* quattro volte piu' grande vuol dire quattro volte piu' lavoro per ogni
* fotogramma, e su una macchina che gia' fatica peggiora invece di
* migliorare. Per riaccenderla basta rimettere true. */
const MONT_MISURA_PIENA = false;
const MONT_MAX_L = 1920, MONT_MAX_A = 1080;
function montMisuraDa(vw, vh) {
if (!MONT_MISURA_PIENA) return { w: 960, h: 540 };
if (!vw || !vh) return { w: 960, h: 540 };
let s = Math.min(1, MONT_MAX_L / vw, MONT_MAX_A / vh);
const w = Math.max(160, Math.round(vw * s / 2) * 2);
const h = Math.max(120, Math.round(vh * s / 2) * 2);
return { w: w, h: h };
}
/* Apre il filmato solo per leggerne misura e ritmo, poi lo butta. */
async function montSondaSorgente(src) {
return await new Promise((risolvi) => {
const v = document.createElement('video');
v.muted = true; v.preload = 'metadata'; v.crossOrigin = 'anonymous';
v.style.cssText = 'position:fixed;left:-9999px;top:0;width:2px;height:2px;';
let chiuso = false;
const fine = (r) => { if (chiuso) return; chiuso = true;
try { v.removeAttribute('src'); v.load(); v.remove(); } catch (e) {}
risolvi(r); };
v.onloadedmetadata = () => fine(Object.assign(
montMisuraDa(v.videoWidth, v.videoHeight),
{ vw: v.videoWidth, vh: v.videoHeight }));
v.onerror = () => fine({ w: 960, h: 540, vw: 0, vh: 0 });
setTimeout(() => fine({ w: 960, h: 540, vw: 0, vh: 0 }), 4000);
document.body.appendChild(v);
v.src = src;
});
}
async function recordSingleSegment(src, start, end) {
const W = 960, H = 540;
const canvas = document.createElement('canvas');
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, W, H);
const AudioCtx = window.AudioContext || window.webkitAudioContext;
const audioCtx = new AudioCtx();
const dest = audioCtx.createMediaStreamDestination();
const videoStream = canvas.captureStream(30);
const combined = new MediaStream([...videoStream.getVideoTracks(), ...dest.stream.getAudioTracks()]);
const rec = pickRecorderFormat();
const mimeType = rec.mimeType;
const chunks = [];
const recorder = new MediaRecorder(combined, { mimeType });
recorder.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); };
const stopped = new Promise((resolve) => { recorder.onstop = resolve; });
const v = document.createElement('video');
v.muted = false; v.playsInline = true; v.crossOrigin = 'anonymous';
v.style.position = 'fixed'; v.style.left = '-9999px'; v.style.top = '0'; v.style.width = '2px'; v.style.height = '2px';
document.body.appendChild(v);
const sourceNode = audioCtx.createMediaElementSource(v);
sourceNode.connect(dest);
let LARG = W, ALT = H, misurato = false;
function fitDraw() {
/* Al primo fotogramma buono si sa quanto e' grande il filmato: la tela
prende quella misura, e da li' in poi non si tocca piu'. Succede
prima di recorder.start(), quindi il flusso nasce gia' giusto. */
if (!misurato && v.videoWidth) {
const m = montMisuraDa(v.videoWidth, v.videoHeight);
LARG = m.w; ALT = m.h; misurato = true;
if (canvas.width !== LARG || canvas.height !== ALT) {
canvas.width = LARG; canvas.height = ALT;
}
}
const vw = v.videoWidth || LARG, vh = v.videoHeight || ALT;
const scale = Math.min(LARG / vw, ALT / vh);
const dw = vw * scale, dh = vh * scale;
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, LARG, ALT);
ctx.drawImage(v, (LARG - dw) / 2, (ALT - dh) / 2, dw, dh);
}
try {
v.src = src;
await new Promise((resolve, reject) => {
v.onloadedmetadata = resolve;
v.onerror = () => reject(new Error('Impossibile aprire il video sorgente per il taglio'));
});
v.currentTime = start;
await new Promise((resolve) => { v.onseeked = resolve; });
/* IL PRIMO FOTOGRAMMA NON DEVE ESSERE NERO.
* La tela di registrazione nasce nera e veniva accesa PRIMA di
* disegnarci sopra il video: i primi decimi di secondo di ogni pezzo
* uscivano quindi neri. Su un tag veloce di tre secondi quel buco si
* mangiava un pezzo della giocata. Ora si disegna il fotogramma giusto,
* gli si lascia il tempo di entrare davvero nel flusso video (due giri
* di schermo), e SOLO DOPO parte la registrazione. */
fitDraw();
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
recorder.start();
await v.play();
await new Promise((resolve) => {
function frame() {
if (v.paused || v.ended || v.currentTime >= end) { v.pause(); resolve(); return; }
fitDraw();
requestAnimationFrame(frame);
}
frame();
});
} finally {
recorder.stop();
await stopped;
try { sourceNode.disconnect(); } catch (err) { /* già disconnesso */ }
document.body.removeChild(v);
await audioCtx.close();
}
return new Blob(chunks, { type: rec.blobType });
}
/* Tag: due modi per crearlo/classificarlo, come nel motore di riferimento.
* 1) Inizio/Fine marcati a mano -> click su una categoria o su una giocatrice
* crea subito il tag su quell'intervallo (quickTag).
* 2) "Tag Qui" marca un punto ±3s SENZA classificazione e lo mette "in
* attesa" (vt.pendingTagId, da "classifica" nell'elenco tag): il
* prossimo click su una categoria o una
* giocatrice lo classifica invece di crearne uno nuovo — così puoi
* taggare al volo e decidere la giocata un istante dopo, senza dover
* fermare il video per segnare Inizio/Fine con precisione.
* In entrambi i casi, appena il tag risulta classificato il pezzo viene
* accodato per il TAGLIO AUTOMATICO (enqueueCut): al coach serve trovarsi la
* clip già pronta come file a parte, senza doverla chiedere. Il pannello
* Montaggio poi riusa direttamente quei file già tagliati, quindi montare
* diventa quasi immediato. Resta anche il pulsante ✂️ sulla riga del tag per
* tagliare a mano un pezzo che, per qualche motivo, non fosse stato tagliato. */
/* Da quale squadra arrivano le caselle con i nomi in questo momento: e'
* quella che decide a chi appartiene la giocatrice appena premuta. */
let ultimaSquadraDelleCaselle = null;
function squadraDelleCaselle(clip) {
if (ultimaSquadraDelleCaselle) return ultimaSquadraDelleCaselle;
try {
const sq = squadraDeiNomi(activeTeam(), clip || currentClip());
return sq ? sq.id : null;
} catch (e) { return null; }
}
function quickTag(side, label, player) {
if (!possoScrivere()) return;
const clip = currentClip();
if (!clip) { toast(t('toast.importVideoFirst')); return; }
if (vt.pendingTagId) {
const tag = clip.tags.find(t => t.id === vt.pendingTagId);
vt.pendingTagId = null;
if (!tag) { toast(t('toast.tagGone')); renderVideoSection(); return; }
if (side) tag.side = side;
if (label) tag.label = label;
if (player) { tag.playerId = player.id; tag.playerLabel = '#' + player.number + ' ' + player.name; tag.playerTeamId = squadraDelleCaselle(clip); }
if (tag.side || tag.label || tag.playerId) enqueueCut(clip.id, tag.id);
renderVideoSection();
toast(t('toast.tagClassified') + (label || (player ? tag.playerLabel : side)));
return;
}
if (vt.trimStart == null || vt.trimEnd == null || vt.trimEnd <= vt.trimStart) {
toast(t('toast.markStartEndFirst'));
return;
}
if ((clip.tags || []).length >= limite('tagPerVideo')) return bloccoBeta(t('beta.limitTags', { n: LIMITI_BETA.tagPerVideo }));
const tag = {
id: nextId('tag'),
start: vt.trimStart,
end: vt.trimEnd,
side: side || null,
label: label || null,
playerId: player ? player.id : null,
playerLabel: player ? ('#' + player.number + ' ' + player.name) : null,
// Di quale squadra e' la casella che ho premuto: serve per ritrovare il
// pezzo nella scheda di quella giocatrice anche se il filmato e'
// intestato a un'altra squadra.
playerTeamId: player ? squadraDelleCaselle(clip) : null,
};
clip.tags.push(tag);
vt.trimStart = null;
vt.trimEnd = null;
if (tag.side || tag.label || tag.playerId) enqueueCut(clip.id, tag.id);
renderVideoSection();
toast(t('toast.tagAdded') + (label || (player ? tag.playerLabel : side)));
}
/* "Tag Qui": marca all'istante un punto ±3s dal playhead, non classificato,
* e lo mette in attesa di classificazione (vedi commento su quickTag). */
/* Tag rapidi (PP / RIM OFF / REC...): etichetta breve, taglio automatico
* degli ultimi 3s dal playhead, creato subito (non richiede Inizio/Fine
* né passa dallo stato "in attesa" — è pensato per un click solo). */
function quickCutLabel(label) {
if (!possoScrivere()) return;
const clip = currentClip();
const v = document.getElementById('vPlayer');
if (!clip || !v) { toast(t('toast.importVideoFirst')); return; }
const now = v.currentTime;
// Nei primissimi secondi del video "gli ultimi 3s" darebbero un pezzo di
// durata zero (start = end = 0): un tag simile non è tagliabile e restava
// lì per sempre senza spiegazioni. Si prende quel che c'è, con un minimo.
const start = Math.max(0, now - secondiIndietroDelTagRapido(label));
const end = Math.max(now, start + 0.5);
if (end - start < 0.4) { toast(t('toast.tooEarlyForQuickTag')); return; }
if ((clip.tags || []).length >= limite('tagPerVideo')) return bloccoBeta(t('beta.limitTags', { n: LIMITI_BETA.tagPerVideo }));
const tag = { id: nextId('tag'), start, end, side: null, label, playerId: null, playerLabel: null };
clip.tags.push(tag);
enqueueCut(clip.id, tag.id);
renderVideoSection();
toast(t('toast.quickTag') + label);
}
// Taglia ORA questo singolo pezzo in un file a parte. Normalmente il taglio
// parte da solo appena il tag è classificato (enqueueCut): questo pulsante
// serve per i casi in cui non è successo — un tag lasciato senza giocata e
// classificato dopo, un taglio fallito, o un pezzo importato da un
// salvataggio precedente in cui il file non c'era più.
function cutTagNow(tagId) {
const clip = currentClip();
if (!clip) { toast(t('toast.importVideoFirst')); return; }
const tag = (clip.tags || []).find(x => x.id === tagId);
if (!tag) return;
enqueueCut(clip.id, tag.id);
renderTagList(clip);
}
function classifyTag(tagId) {
vt.pendingTagId = tagId;
renderVideoSection();
toast(t('toast.classifyPrompt'));
}
function deleteTag(tagId) {
const clip = currentClip();
if (!clip) return;
segnaEliminato(tagId);
const removed = clip.tags.find(t => t.id === tagId);
// Il pezzo già tagliato (blob in memoria) va rilasciato esplicitamente,
// altrimenti resterebbe agganciato finché non si ricarica la pagina.
if (removed && removed.cutUrl) { try { URL.revokeObjectURL(removed.cutUrl); } catch (e) { /* già rilasciato */ } }
clip.tags = clip.tags.filter(t => t.id !== tagId);
cutQueue = cutQueue.filter(job => job.tagId !== tagId);
if (vt.pendingTagId === tagId) vt.pendingTagId = null;
renderVideoSection();
}
function seekTag(tag) {
const v = document.getElementById('vPlayer');
if (!v) return;
v.currentTime = tag.start;
v.play();
}
// ------------------------------------------------- LAVAGNA SUL FOTOGRAMMA
// Per spiegare una giocata o un errore a una giocatrice: congela un
// fotogramma (quello a cui il video è fermo, o l'inizio di un tag specifico)
// e ci si disegna sopra frecce/cerchi/testo. Stessa logica di interazione
// del motore diagrammi di Playbook/Personnel (clic per selezionare/
// trascinare, due clic per una freccia), ma con uno STATO A PARTE (faState)
// e uno sfondo immagine invece del campo da gioco, per non toccare in alcun
// modo quel motore condiviso già in uso altrove. Come tutta la sezione
// Video, non è persistente tra sessioni: il modo per "salvare" un'annotazione
// è scaricarla come immagine PNG, pronta da mandare alla giocatrice.
const FRAME_TOOLS = [
{ id: 'select', labelKey: 'tool.select' },
{ id: 'arrow', labelKey: 'frame.toolArrow' },
{ id: 'circle', labelKey: 'frame.toolCircle' },
{ id: 'text', labelKey: 'tool.text' },
];
// Colori accesi, pensati per restare leggibili sopra qualunque fotogramma
// (chiaro, scuro, "rumoroso"): il coach può differenziare più errori/note
// sullo stesso frame invece di avere sempre e solo il rosa di default.
const FRAME_COLORS = ['#ff2e6a', '#ffe600', '#00e5ff', '#ffffff', '#22c55e'];
let faState = {
bgImage: null, elements: [], tool: 'select', selectedIndex: -1,
pendingStart: null, dragging: false, dragOffset: null, _inited: false, label: '',
color: FRAME_COLORS[0], history: [], onSave: null,
};
function faInit() {
if (faState._inited) return;
faState._inited = true;
const canvas = document.getElementById('frameAnnotateCanvas');
canvas.addEventListener('mousedown', faHandleDown);
canvas.addEventListener('mousemove', faHandleMove);
window.addEventListener('mouseup', faHandleUp);
canvas.addEventListener('touchstart', e => { e.preventDefault(); const tt = e.touches[0]; faHandleDown({ clientX: tt.clientX, clientY: tt.clientY }); }, { passive: false });
canvas.addEventListener('touchmove', e => { e.preventDefault(); const tt = e.touches[0]; faHandleMove({ clientX: tt.clientX, clientY: tt.clientY }); }, { passive: false });
canvas.addEventListener('touchend', faHandleUp);
// Scorciatoie da tastiera, attive solo mentre la lavagna è aperta e non si
// sta scrivendo in un campo di testo (es. la modale interna del tool
// "Testo"): Canc/Backspace rimuove l'elemento selezionato, Ctrl/Cmd+Z
// annulla l'ultima azione, Esc chiude la lavagna.
document.addEventListener('keydown', (e) => {
const overlay = document.getElementById('frameAnnotateOverlay');
if (!overlay || !overlay.classList.contains('show')) return;
const tag = (document.activeElement && document.activeElement.tagName) || '';
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); faRemoveSelected(); }
else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') { e.preventDefault(); faUndo(); }
else if (e.key === 'Escape') { closeFrameAnnotator(); }
});
}
function grabVideoFrameDataUrl(video) {
const w = video.videoWidth, h = video.videoHeight;
if (!w || !h) return null;
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(video, 0, 0, w, h);
return canvas.toDataURL('image/png');
}
/* Bottone "🖍️ Lavagna" nei controlli di riproduzione: congela il fotogramma
* a cui il player è fermo in quel momento (in pausa o in scrubbing). */
function captureCurrentFrameAndAnnotate() {
const v = document.getElementById('vPlayer');
const clip = currentClip();
if (!v || !clip) { toast(t('toast.importVideoFirst')); return; }
const dataUrl = grabVideoFrameDataUrl(v);
if (!dataUrl) { toast(t('toast.frameNotReady')); return; }
openFrameAnnotator(dataUrl, clip.name + ' — ' + fmtTime(v.currentTime));
}
/* Icona 🖍️ su una riga di Elenco Tag: congela il fotogramma all'INIZIO di
* quel tag usando un nascosto, così non si sposta la riproduzione
* visibile del coach (che magari sta guardando un punto diverso). Se quel
* tag ha già un'annotazione salvata (vedi tag.annotation/onSave sotto), la
* lavagna riparte da lì invece che vuota — si può correggere/completare un
* disegno già fatto senza perderlo. */
async function annotateTagFrame(tagId) {
const clip = currentClip();
const tag = clip && clip.tags.find(t => t.id === tagId);
if (!clip || !tag) return;
toast(t('frame.preparing'));
const v = document.createElement('video');
v.muted = true; v.playsInline = true; v.crossOrigin = 'anonymous';
v.style.position = 'fixed'; v.style.left = '-9999px'; v.style.top = '0';
document.body.appendChild(v);
try {
v.src = clip.url;
await new Promise((resolve, reject) => {
v.onloadedmetadata = resolve;
v.onerror = () => reject(new Error('Impossibile aprire il video per catturare il fotogramma'));
});
v.currentTime = tag.start;
await new Promise((resolve) => { v.onseeked = resolve; });
const dataUrl = grabVideoFrameDataUrl(v);
if (!dataUrl) { toast(t('toast.frameNotReady')); return; }
openFrameAnnotator(dataUrl, (tag.label || tag.playerLabel || t('tag.toClassify')) + ' — ' + fmtTime(tag.start), {
initialElements: (tag.annotation && tag.annotation.elements) || [],
onSave: (elements) => {
tag.annotation = elements.length ? { elements } : null;
// Ri-disegna solo il badge/pulsante di questa riga (non l'intera
// lista, che scrollerebbe/lampeggerebbe ad ogni singola modifica).
renderTagList(currentClip());
},
});
} catch (err) {
console.error('Impossibile catturare il fotogramma del tag:', err);
toast(t('toast.frameNotReady'));
} finally {
document.body.removeChild(v);
}
}
function openFrameAnnotator(dataUrl, label, opts) {
opts = opts || {};
faInit();
faState.elements = (opts.initialElements || []).map(el => ({ ...el }));
faState.onSave = opts.onSave || null;
faState.history = [];
faState.selectedIndex = -1;
faState.pendingStart = null;
faState.tool = 'select';
faState.color = FRAME_COLORS[0];
faState.label = label || '';
const img = new Image();
img.onload = () => {
faState.bgImage = img;
const canvas = document.getElementById('frameAnnotateCanvas');
const MAXW = 900;
const scale = Math.min(1, MAXW / img.naturalWidth);
canvas.width = Math.max(1, Math.round(img.naturalWidth * scale));
canvas.height = Math.max(1, Math.round(img.naturalHeight * scale));
const titleEl = document.getElementById('frameAnnotateTitle');
if (titleEl) titleEl.textContent = '🖍️ ' + t('frame.title') + (label ? ' — ' + label : '');
faRenderToolbar();
faRedraw();
document.getElementById('frameAnnotateOverlay').classList.add('show');
};
img.src = dataUrl;
}
function closeFrameAnnotator() {
document.getElementById('frameAnnotateOverlay').classList.remove('show');
}
function faRenderToolbar() {
const el = document.getElementById('frameAnnotateToolbar');
if (!el) return;
const swatches = FRAME_COLORS.map(c =>
` `
).join('');
el.innerHTML = FRAME_TOOLS.map(tool =>
`${escapeHtml(etichettaCorta(t(tool.labelKey)))} `
).join('')
+ `${swatches} `
+ `${t('frame.undoBtn')} `
+ `${escapeHtml(etichettaCorta(t('dyn.removeSelected')))} `
+ `${escapeHtml(etichettaCorta(t('dyn.clearCourt')))} `;
}
function setFaTool(tool) {
faState.tool = tool;
faState.pendingStart = null;
faRenderToolbar();
}
function setFaColor(color) {
faState.color = color;
// Se un elemento è selezionato, il cambio colore lo ricolora subito
// (comodo per correggere una freccia già disegnata) invece di valere solo
// per i prossimi elementi.
if (faState.selectedIndex >= 0) {
faPushHistory();
faState.elements[faState.selectedIndex].color = color;
faSaveIfNeeded();
}
faRenderToolbar();
faRedraw();
}
// Cronologia leggera (istantanee dell'array elementi) per un "Annulla" che
// copre aggiunte, spostamenti, rimozioni e "Pulisci campo" in modo uniforme,
// senza dover distinguere caso per caso cosa è stato modificato.
function faPushHistory() {
faState.history.push(JSON.stringify(faState.elements));
if (faState.history.length > 20) faState.history.shift();
}
function faUndo() {
if (!faState.history.length) { toast(t('toast.nothingToUndo')); return; }
faState.elements = JSON.parse(faState.history.pop());
faState.selectedIndex = -1;
faRenderToolbar();
faRedraw();
faSaveIfNeeded();
}
function faSaveIfNeeded() {
if (faState.onSave) faState.onSave(faState.elements);
}
function faRemoveSelected() {
if (faState.selectedIndex < 0) { toast(t('toast.noElementSelected')); return; }
faPushHistory();
faState.elements.splice(faState.selectedIndex, 1);
faState.selectedIndex = -1;
faRenderToolbar();
faRedraw();
faSaveIfNeeded();
}
function faClearAll() {
if (!faState.elements.length) return;
showConfirmModal(t('confirm.clearCourt'), () => {
faPushHistory();
faState.elements = [];
faState.selectedIndex = -1;
faState.pendingStart = null;
faRenderToolbar();
faRedraw();
faSaveIfNeeded();
});
}
function faPointFromEvent(e) {
const canvas = document.getElementById('frameAnnotateCanvas');
const rect = canvas.getBoundingClientRect();
const px = (e.clientX - rect.left) * (canvas.width / rect.width);
const py = (e.clientY - rect.top) * (canvas.height / rect.height);
return { fx: px / canvas.width, fy: py / canvas.height };
}
// Il cerchio si seleziona cliccando ovunque al suo interno (area piena),
// non solo sul bordo — più naturale che colpire esattamente il perimetro;
// freccia/testo riusano lo stesso criterio segmento/punto del motore schemi.
function faHitTest(fx, fy) {
for (let i = faState.elements.length - 1; i >= 0; i--) {
const el = faState.elements[i];
if (el.type === 'circle') {
const r = Math.hypot(el.x2 - el.x, el.y2 - el.y);
const d = Math.hypot(fx - el.x, fy - el.y);
if (d < r + 0.02) return i;
} else if (el.x2 != null) {
const d = distanceToSegment(fx, fy, el.x, el.y, el.x2, el.y2);
if (d < 0.03) return i;
} else {
const d = Math.hypot(fx - el.x, fy - el.y);
if (d < 0.05) return i;
}
}
return -1;
}
function faHandleDown(e) {
const { fx, fy } = faPointFromEvent(e);
if (faState.tool === 'select') {
const idx = faHitTest(fx, fy);
faState.selectedIndex = idx;
if (idx >= 0) {
faPushHistory(); // snapshot presa PRIMA di spostare, così il drag è annullabile
faState.dragging = true;
const el = faState.elements[idx];
faState.dragOffset = { dx: fx - el.x, dy: fy - el.y };
}
faRenderToolbar();
faRedraw();
return;
}
if (faState.tool === 'text') {
showPromptModal(t('prompt.diagramTextTitle'), t('prompt.diagramTextPh'), (txt) => {
faPushHistory();
faState.elements.push({ type: 'text', x: fx, y: fy, text: txt, color: faState.color });
faRenderToolbar();
faRedraw();
faSaveIfNeeded();
});
return;
}
// Freccia/Cerchio: due clic, punto di partenza poi punto di arrivo (per il
// cerchio, il secondo clic definisce il raggio) — stesso schema "due clic"
// di frecce/blocchi nel motore diagrammi condiviso.
if (!faState.pendingStart) {
faState.pendingStart = { x: fx, y: fy };
faRedraw();
toast(t('toast.clickEndPoint'));
} else {
faPushHistory();
faState.elements.push({ type: faState.tool, x: faState.pendingStart.x, y: faState.pendingStart.y, x2: fx, y2: fy, color: faState.color });
faState.pendingStart = null;
faRenderToolbar();
faRedraw();
faSaveIfNeeded();
}
}
function faHandleMove(e) {
if (!faState.dragging || faState.selectedIndex < 0) return;
const { fx, fy } = faPointFromEvent(e);
const el = faState.elements[faState.selectedIndex];
const nx = fx - faState.dragOffset.dx, ny = fy - faState.dragOffset.dy;
const deltaX = nx - el.x, deltaY = ny - el.y;
el.x = nx; el.y = ny;
if (el.x2 != null) { el.x2 += deltaX; el.y2 += deltaY; }
faRedraw();
}
function faHandleUp() {
const wasDragging = faState.dragging;
faState.dragging = false;
faState.dragOffset = null;
if (wasDragging) faSaveIfNeeded();
}
function faRedraw() {
const canvas = document.getElementById('frameAnnotateCanvas');
if (!canvas || !faState.bgImage) return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(faState.bgImage, 0, 0, canvas.width, canvas.height);
faState.elements.forEach((el, i) => faDrawElement(ctx, canvas.width, canvas.height, el, i === faState.selectedIndex));
if (faState.pendingStart) {
ctx.save();
ctx.fillStyle = '#ffe600'; ctx.strokeStyle = '#000'; ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(faState.pendingStart.x * canvas.width, faState.pendingStart.y * canvas.height, 5, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.restore();
}
}
// Colore acceso + contorno scuro (shadow) per restare leggibile sopra
// QUALUNQUE fotogramma — a differenza del campo bianco fisso dei diagrammi
// Playbook, qui lo sfondo può essere chiaro, scuro o molto "rumoroso".
function faDrawElement(ctx, W, H, el, selected) {
const color = el.color || FRAME_COLORS[0];
if (el.type === 'arrow') {
const x1 = el.x * W, y1 = el.y * H, x2 = el.x2 * W, y2 = el.y2 * H;
ctx.save();
ctx.strokeStyle = color; ctx.lineWidth = 4; ctx.lineCap = 'round';
ctx.shadowColor = 'rgba(0,0,0,.85)'; ctx.shadowBlur = 3;
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
const ang = Math.atan2(y2 - y1, x2 - x1);
ctx.beginPath();
ctx.moveTo(x2, y2); ctx.lineTo(x2 - 14 * Math.cos(ang - 0.45), y2 - 14 * Math.sin(ang - 0.45));
ctx.moveTo(x2, y2); ctx.lineTo(x2 - 14 * Math.cos(ang + 0.45), y2 - 14 * Math.sin(ang + 0.45));
ctx.stroke();
ctx.restore();
} else if (el.type === 'circle') {
const cx = el.x * W, cy = el.y * H;
const r = Math.hypot((el.x2 - el.x) * W, (el.y2 - el.y) * H);
ctx.save();
ctx.strokeStyle = color; ctx.lineWidth = 4;
ctx.shadowColor = 'rgba(0,0,0,.85)'; ctx.shadowBlur = 3;
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
ctx.restore();
} else if (el.type === 'text') {
ctx.save();
ctx.font = '700 20px -apple-system,sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.lineWidth = 4; ctx.strokeStyle = 'rgba(0,0,0,.85)';
ctx.strokeText(el.text || '', el.x * W, el.y * H);
ctx.fillStyle = color;
ctx.fillText(el.text || '', el.x * W, el.y * H);
ctx.restore();
}
if (selected) {
ctx.save();
ctx.strokeStyle = '#00e5ff'; ctx.lineWidth = 2; ctx.setLineDash([4, 3]);
if (el.type === 'circle') {
const cx = el.x * W, cy = el.y * H;
const r = Math.hypot((el.x2 - el.x) * W, (el.y2 - el.y) * H);
ctx.beginPath(); ctx.arc(cx, cy, r + 6, 0, Math.PI * 2); ctx.stroke();
} else if (el.x2 != null) {
ctx.beginPath(); ctx.moveTo(el.x * W, el.y * H); ctx.lineTo(el.x2 * W, el.y2 * H); ctx.stroke();
} else {
ctx.beginPath(); ctx.arc(el.x * W, el.y * H, 16, 0, Math.PI * 2); ctx.stroke();
}
ctx.restore();
}
}
function faDownloadImage() {
const canvas = document.getElementById('frameAnnotateCanvas');
if (!canvas) return;
const a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = 'lavagna_' + (faState.label || 'fotogramma').replace(/[^a-z0-9]+/gi, '_') + '.png';
a.click();
toast(t('frame.downloaded'));
}
// ---------------------------------------------------------- SEQUENZA TAG
let vtSequencePlaying = false;
function playSequence() {
const clip = activeVideoClipForTeam();
const v = document.getElementById('vPlayer');
if (!clip || !v || !clip.tags.length) { toast(t('toast.noSequence')); return; }
const ordered = clip.tags.slice().sort((a, b) => a.start - b.start);
let idx = 0;
vtSequencePlaying = true;
toast(t('toast.sequenceStarted', {n: ordered.length}));
function playNext() {
if (!vtSequencePlaying || idx >= ordered.length) { vtSequencePlaying = false; return; }
const tag = ordered[idx];
v.currentTime = tag.start;
v.play();
const onTime = () => {
if (v.currentTime >= tag.end || v.paused) {
v.removeEventListener('timeupdate', onTime);
if (!vtSequencePlaying) return;
idx++;
playNext();
}
};
v.addEventListener('timeupdate', onTime);
}
playNext();
}
// ------------------------------------------------- MONTAGGIO MULTI-CLIP
// Unisce TUTTI i tag (i pezzi tagliati) di TUTTI i video importati per la
// squadra attiva in un unico file video scaricabile — così il coach può
// salvarlo, come da istruzioni, nella cartella generale di quella squadra.
// Tecnica: un video nascosto riproduce in sequenza ogni segmento tagliato
// (anche da clip sorgente diverse, cambiando la sua src quando serve); i
// fotogrammi vengono disegnati su un canvas a risoluzione fissa (per uniformare
// clip di dimensioni diverse) e l'audio passa da un AudioContext condiviso —
// canvas.captureStream() + audio del contesto vengono registrati insieme con
// MediaRecorder in un unico file .webm, senza librerie esterne.
let montageRunning = false;
/* ------------------------------------------------------- EFFETTI SUI PEZZI
* Sul modello dell'"Edit Clip" di ITALBASKET WOMAN: su ogni pezzo taggato si
* possono mettere effetti che durano pochi secondi — spotlight (evidenzia una
* giocatrice scurendo il resto), rallenty e fermo immagine. Gli effetti sono
* salvati sul tag (tag.fx) e vengono BRUCIATI dentro al video quando si
* esporta il montaggio, quindi si vedono anche a chi guarda il file.
* Il tempo di ogni effetto è relativo all'INIZIO DEL PEZZO, non del video
* sorgente: così restano al loro posto anche se il pezzo viene riordinato. */
const FX_TYPES = {
spotlight: { icon: '🔦', labelKey: 'fx.spotlight' },
// Ritaglio spostabile: si seleziona un pezzo di immagine (una giocatrice,
// due giocatrici, una zona) e LO SI TRASCINA altrove sul fotogramma, per
// far vedere dove sarebbe dovuta stare. Nel punto di partenza resta il
// riquadro tratteggiato e una freccia che porta alla nuova posizione: è il
// modo più diretto di mostrare un errore di posizionamento.
cutout: { icon: '⧉', labelKey: 'fx.cutout' },
// Lente d'ingrandimento: un cerchio che mostra INGRANDITO il pezzo di
// fotogramma che ci sta sotto. Si trascina dove serve e si regola quanto
// ingrandisce: serve per far vedere i piedi, le mani, la posizione della
// testa — le cose che nel campo largo non si distinguono.
lente: { icon: '🔍', labelKey: 'fx.lente' },
circle: { icon: '⭕', labelKey: 'fx.circle' },
arrow: { icon: '➟', labelKey: 'fx.arrow' },
wall: { icon: '🧱', labelKey: 'fx.wall' },
text: { icon: '🅣', labelKey: 'fx.text' },
slow: { icon: '🐢', labelKey: 'fx.slow' },
freeze: { icon: '⏸', labelKey: 'fx.freeze' },
};
// Effetti che si posizionano cliccando sull'anteprima (spotlight e cerchio un
// punto solo, la freccia due: da dove parte e dove arriva).
const FX_PLACEABLE = { spotlight: 1, circle: 1, arrow: 2, wall: 1, cutout: 1, lente: 1 };
/* RITAGLIO SPOSTABILE — tempi. Le giocatrici scivolano nel posto giusto in
* mezzo secondo e POI CI RESTANO FERME un bel po': è lì che si capisce dove
* sarebbero dovute stare, e mezzo secondo non basta a farlo vedere. Il tempo
* di sosta si regola con il cursore "fermi là" (fino a 6 secondi). */
const FX_CUT_SLIDE = 0.55; // secondi di scivolata
const FX_CUT_HOLD_DEF = 2.5; // quanto restano fermi là, di partenza
function fxCutoutHold(fx) { return (fx && fx.hold != null) ? Math.max(0.2, +fx.hold) : FX_CUT_HOLD_DEF; }
// Durata minima perché la sosta ci stia tutta dentro all'effetto (con "vanno,
// restano, tornano" ci vuole anche il tempo del ritorno).
function fxCutoutDurataMinima(fx) {
const sosta = fxCutoutHold(fx);
return (fx && fx.moto === 'vaietorna') ? FX_CUT_SLIDE * 2 + sosta + 0.6 : FX_CUT_SLIDE + sosta + 0.25;
}
// Colori di partenza: la freccia nasce blu (si stacca meglio dal parquet e dal
// giallo della luce), il muro azzurro, il cerchio giallo.
const FX_ARROW_COLOR = '#2f7dff';
// Colore di partenza del fascio di luce: rosso, come nel filmato che il coach
// ha portato come riferimento (si stacca dal parquet meglio di qualsiasi
// altro colore). Resta comunque cambiabile con la tavolozza.
const FX_SPOT_COLOR = '#ff2f2f';
let fxPlacing = null; // { index, step }
/* STRUMENTO ATTIVO nell'editor. Prima ogni disegno era in tre tempi: premi
* "+ Freccia", l'elemento nasce in una riga in fondo, poi lo si posiziona —
* e per farne un secondo si ricominciava da capo. Ora si sceglie lo
* strumento UNA VOLTA e resta attivo: ogni trascinamento sul fotogramma
* disegna un nuovo elemento di quel tipo, uno dietro l'altro, finché non si
* torna a "✋ Sposta" (o si preme ESC). */
let fxStrumento = null;
// Effetti del pezzo che si sta incidendo in questo momento nel montaggio, e
// istante da cui contarli.
let fxCorrenti = [];
let fxInizio = 0;
function tagFx(tag) {
if (!Array.isArray(tag.fx)) tag.fx = [];
return tag.fx;
}
// Disegna sul canvas gli effetti "visivi" attivi al secondo `tRel` del pezzo.
// dw/dh/ox/oy descrivono il rettangolo in cui il fotogramma è stato disegnato,
// così le posizioni salvate (frazioni 0..1) cadono sempre sul punto giusto
// qualunque sia la dimensione del canvas.
// "#4dc3ff" + trasparenza -> "rgba(77,195,255,.32)"
function fxColorAlpha(hex, a) {
const h = (hex || '#4dc3ff').replace('#', '');
const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16);
return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + a + ')';
}
/* Foto fisse dei ritagli: si tengono in memoria già decodificate, altrimenti
* a ogni fotogramma si ricomincerebbe da capo a leggere il dataURL. */
const fxSnapImgs = new Map();
function fxSnapImage(dataUrl) {
if (!dataUrl) return null;
let img = fxSnapImgs.get(dataUrl);
if (!img) { img = new Image(); img.src = dataUrl; fxSnapImgs.set(dataUrl, img); }
return img;
}
// Aspetta che tutte le foto dei ritagli di questi pezzi siano pronte: serve
// PRIMA di incidere il filmato, altrimenti i primi fotogrammi uscirebbero col
// ritaglio vuoto.
function fxPreparaRitagli(segments) {
const attese = [];
(segments || []).forEach(seg => {
(seg.fx || []).forEach(f => {
if (f.type !== 'cutout' || !f.snap) return;
const img = fxSnapImage(f.snap);
if (img.complete && img.naturalWidth) return;
attese.push(new Promise(res => {
img.onload = res; img.onerror = res;
setTimeout(res, 3000);
}));
});
});
return Promise.all(attese);
}
/* Tela d'appoggio riusata: serve alla lente quando la sorgente e' la tela
* stessa (in esportazione). Copiare un canvas su se stesso dentro a un clip
* non disegna niente, quindi si fa il giro: qui dentro, e poi indietro. */
let _fxTelaAppoggio = null;
function fxTelaDAppoggio(w, h) {
if (!_fxTelaAppoggio) {
const el = document.createElement('canvas');
_fxTelaAppoggio = { el, ctx: el.getContext('2d') };
}
if (_fxTelaAppoggio.el.width !== w || _fxTelaAppoggio.el.height !== h) {
_fxTelaAppoggio.el.width = w; _fxTelaAppoggio.el.height = h;
}
return _fxTelaAppoggio;
}
function drawFxOverlay(ctx, fxList, tRel, ox, oy, dw, dh, sorgente) {
// Un elemento appena aggiunto è "da posizionare": non compare sulla clip
// finché non clicchi dove lo vuoi. Prima si piazzava da solo in mezzo allo
// schermo e bisognava spostarlo.
const attivo = (fx) => !fx.pending && tRel >= fx.t && tRel < fx.t + fx.dur;
const PX = (fx, kx, def) => ox + (fx[kx] != null ? fx[kx] : def) * dw;
const PY = (fx, ky, def) => oy + (fx[ky] != null ? fx[ky] : def) * dh;
// RITAGLI SPOSTABILI: per primi di tutti, perché copiano un pezzo del
// FOTOGRAMMA e vanno presi prima che ci si disegni sopra qualsiasi altra
// cosa. La sorgente è la tela stessa (dove il fotogramma è già stato
// disegnato) oppure, nell'anteprima, l'elemento passato in
// "sorgente".
/* LA LENTE. Va disegnata PRIMA di tutto il resto (come i ritagli), perche'
* ingrandisce il FOTOGRAMMA: se venisse dopo, ingrandirebbe anche le frecce
* e i cerchi gia' disegnati sopra. La sorgente e' il nell'anteprima
* dell'editor, oppure la tela stessa in esportazione (dove il fotogramma e'
* gia' stato disegnato) — stessa strada del ritaglio. */
(fxList || []).forEach(fx => {
if (fx.type !== 'lente' || !attivo(fx)) return;
const lato = Math.min(dw, dh);
const r = Math.max(8, (fx.r != null ? fx.r : 0.15) * lato);
const zoom = Math.max(1.1, Math.min(4, +fx.zoom || 2));
const cx = ox + (fx.cx != null ? fx.cx : 0.5) * dw;
const cy = oy + (fx.cy != null ? fx.cy : 0.5) * dh;
const rs = r / zoom; // raggio della zona guardata
ctx.save();
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.clip();
try {
if (sorgente && sorgente.el && sorgente.vw) {
// dal video: si converte la zona in pixel del filmato
const kx = sorgente.vw / dw, ky = sorgente.vh / dh;
ctx.drawImage(sorgente.el,
((cx - ox) - rs) * kx, ((cy - oy) - rs) * ky, rs * 2 * kx, rs * 2 * ky,
cx - r, cy - r, r * 2, r * 2);
} else {
// Dalla tela stessa (esportazione): il fotogramma e' gia' disegnato
// li'. NON si puo' copiare la tela su se stessa mentre e' ritagliata
// dal clip — il disegno non avviene. Si passa da una tela d'appoggio:
// prima ci si ritaglia dentro la zona da guardare, poi la si
// ridisegna ingrandita.
const zona = fxTelaDAppoggio(Math.max(2, Math.round(rs * 2)), Math.max(2, Math.round(rs * 2)));
zona.ctx.clearRect(0, 0, zona.el.width, zona.el.height);
zona.ctx.drawImage(ctx.canvas, cx - rs, cy - rs, rs * 2, rs * 2, 0, 0, zona.el.width, zona.el.height);
ctx.drawImage(zona.el, 0, 0, zona.el.width, zona.el.height, cx - r, cy - r, r * 2, r * 2);
}
} catch (e) {}
ctx.restore();
// L'anello, perche' si capisca che quello e' un ingrandimento e non un
// pezzo di campo qualsiasi: cerchio spesso col suo colore e un velo
// scuro appena fuori, che stacca la lente dal resto.
ctx.save();
ctx.beginPath(); ctx.arc(cx, cy, r + Math.max(2, r * 0.05), 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(0,0,0,.55)'; ctx.lineWidth = Math.max(3, r * 0.10); ctx.stroke();
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.strokeStyle = fx.color || '#ffd400'; ctx.lineWidth = Math.max(2, r * 0.055); ctx.stroke();
ctx.restore();
});
(fxList || []).forEach(fx => {
if (fx.type !== 'cutout' || !attivo(fx)) return;
const sx = ox + (fx.sx != null ? fx.sx : 0.4) * dw;
const sy = oy + (fx.sy != null ? fx.sy : 0.3) * dh;
const sw = Math.max(6, (fx.sw != null ? fx.sw : 0.16) * dw);
const sh = Math.max(6, (fx.sh != null ? fx.sh : 0.32) * dh);
// Lo spostamento si VEDE succedere: le giocatrici partono da dove erano
// davvero, scivolano nel posto in cui sarebbero dovute stare e LÌ
// RESTANO FERME per il resto dell'effetto (almeno un secondo). Fermo
// immagine + spostamento: si capisce al volo che quello era il posto
// giusto. Con anim:false (anteprima in pausa) si disegnano subito
// arrivate, così si possono trascinare.
const SCIVOLA = FX_CUT_SLIDE; // secondi di scivolata
const FERMO = fxCutoutHold(fx); // quanto restano ferme là
// Come si comporta lo spostamento (fx.moto):
// 'vai' → scivolano dove le hai trascinate e LÌ RESTANO FERME
// fino alla fine dell'effetto. È il modo di partenza.
// 'fermo' → nessun movimento: stanno da subito dove le hai messe.
// 'vaietorna' → vanno, restano ferme un secondo, poi tornano dov'erano
// davvero e restano ferme lì un attimo.
const moto = fx.moto || 'vai';
let avanz = 1;
if (fx.anim !== false && moto !== 'fermo') {
const morbido = (q) => (q < 0.5 ? 2 * q * q : 1 - Math.pow(-2 * q + 2, 2) / 2);
const q = Math.max(0, tRel - (+fx.t || 0));
if (q < SCIVOLA) avanz = morbido(q / SCIVOLA);
else if (moto !== 'vaietorna') avanz = 1;
else if (q < SCIVOLA + FERMO) avanz = 1;
else if (q < SCIVOLA * 2 + FERMO) avanz = 1 - morbido((q - SCIVOLA - FERMO) / SCIVOLA);
else avanz = 0;
}
const tx = sx + (fx.dx || 0) * dw * avanz;
const ty = sy + (fx.dy || 0) * dh * avanz;
const col = fx.color || '#ffd400';
ctx.save();
// 1) il pezzo di immagine. È una FOTO FISSA, scattata nell'istante in cui
// il coach ha tirato il rettangolo: le due giocatrici che sposta
// restano ferme come le aveva prese, non continuano a muoversi col
// filmato sotto. Se la foto manca (ritagli fatti prima) si ricade sul
// campionamento dal vivo di prima.
const foto = fx.snap ? fxSnapImage(fx.snap) : null;
try {
if (foto && foto.complete && foto.naturalWidth) {
ctx.drawImage(foto, tx, ty, sw, sh);
} else if (sorgente && sorgente.el) {
const vw = sorgente.vw, vh = sorgente.vh;
ctx.drawImage(sorgente.el,
((sx - ox) / dw) * vw, ((sy - oy) / dh) * vh, (sw / dw) * vw, (sh / dh) * vh,
tx, ty, sw, sh);
} else {
ctx.drawImage(ctx.canvas, sx, sy, sw, sh, tx, ty, sw, sh);
}
} catch (err) { /* fotogramma non ancora pronto */ }
// Velo leggerissimo: garantisce che il riquadro si veda (e quindi si
// possa afferrare) anche quando il fotogramma non è ancora disponibile.
ctx.fillStyle = fxColorAlpha(col, 0.10);
ctx.fillRect(tx, ty, sw, sh);
// 2) dove stava prima: riquadro tratteggiato, così si vede l'errore
const spostato = Math.abs(fx.dx || 0) > 0.002 || Math.abs(fx.dy || 0) > 0.002;
if (spostato) {
ctx.setLineDash([7, 5]);
ctx.strokeStyle = 'rgba(255,255,255,.85)';
ctx.lineWidth = Math.max(1.5, Math.min(dw, dh) * 0.004);
ctx.shadowColor = 'rgba(0,0,0,.8)'; ctx.shadowBlur = 6;
ctx.strokeRect(sx, sy, sw, sh);
ctx.setLineDash([]);
// 3) freccia A TERRA e SCURA: parte dai piedi della posizione dove le
// giocatrici stavano davvero e arriva ai piedi di dove sarebbero
// dovute stare. Scura apposta: sul parquet chiaro è il segno che si
// legge meglio, e non si confonde con le frecce colorate del
// movimento — questa racconta un errore, non una giocata.
const bxFine = sx + (fx.dx || 0) * dw, byFine = sy + (fx.dy || 0) * dh;
const ax = sx + sw / 2, ay = sy + sh * 0.98;
const bx = bxFine + sw / 2, by = byFine + sh * 0.98;
const ang = Math.atan2(by - ay, bx - ax);
const testa = Math.max(11, Math.min(dw, dh) * 0.032);
const spess = Math.max(3, Math.min(dw, dh) * 0.008);
const corpoFreccia = () => {
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx - Math.cos(ang) * testa * 0.85, by - Math.sin(ang) * testa * 0.85);
ctx.stroke();
};
const puntaFreccia = (extra) => {
ctx.beginPath();
ctx.moveTo(bx + Math.cos(ang) * extra, by + Math.sin(ang) * extra);
ctx.lineTo(bx - (testa + extra) * Math.cos(ang - 0.42), by - (testa + extra) * Math.sin(ang - 0.42));
ctx.lineTo(bx - (testa + extra) * Math.cos(ang + 0.42), by - (testa + extra) * Math.sin(ang + 0.42));
ctx.closePath(); ctx.fill();
};
ctx.lineCap = 'round';
// filo chiaro sotto: tiene la freccia scura leggibile anche sull'ombra
ctx.strokeStyle = 'rgba(255,255,255,.75)'; ctx.fillStyle = 'rgba(255,255,255,.75)';
ctx.lineWidth = spess + Math.max(2, Math.min(dw, dh) * 0.004);
corpoFreccia(); puntaFreccia(Math.max(1.5, Math.min(dw, dh) * 0.003));
ctx.strokeStyle = '#0d1420'; ctx.fillStyle = '#0d1420';
ctx.lineWidth = spess;
corpoFreccia(); puntaFreccia(0);
}
// 4) contorno del pezzo spostato, per staccarlo dallo sfondo
ctx.shadowColor = 'rgba(0,0,0,.8)'; ctx.shadowBlur = 8;
ctx.strokeStyle = col;
ctx.lineWidth = Math.max(1.8, Math.min(dw, dh) * 0.005);
ctx.strokeRect(tx, ty, sw, sh);
ctx.restore();
});
// Il fascio di luce si disegna PER PRIMO: scurisce tutto intorno, quindi
// deve stare SOTTO a cerchi, frecce, muro e scritte — altrimenti li
// spegne insieme al resto del campo.
(fxList || []).forEach(fx => {
if (fx.type !== 'spotlight') return;
if (tRel < fx.t || tRel >= fx.t + fx.dur) return;
const cx = ox + (fx.cx != null ? fx.cx : 0.5) * dw;
const cy = oy + (fx.cy != null ? fx.cy : 0.5) * dh;
const r = Math.max(24, (fx.r != null ? fx.r : 0.16) * Math.min(dw, dh));
const colL = fx.color || FX_SPOT_COLOR;
ctx.save();
// FASCIO DI LUCE COLORATO, come nel filmato di riferimento: un cilindro
// traslucido che scende dall'alto e si posa sul parquet con un anello
// luminoso attorno alla giocatrice. NON si scurisce niente fuori: il
// resto del campo resta esattamente com'è. Dentro, l'immagine viene
// TINTA del colore scelto (rosso di default, come nel riferimento) e
// insieme SCHIARITA, così la zona salta all'occhio senza perdere
// dettaglio.
const ry = r * 0.55; // base del fascio, schiacciata dalla prospettiva
const topY = oy; // il fascio scende dal bordo alto del fotogramma
const corpo = () => {
ctx.beginPath();
ctx.moveTo(cx - r, topY);
ctx.lineTo(cx - r, cy);
ctx.ellipse(cx, cy, r, ry, 0, Math.PI, 0, false);
ctx.lineTo(cx + r, topY);
ctx.closePath();
};
// 1) la tinta del fascio: più tenue in alto, più densa alla base
ctx.save();
corpo();
ctx.clip();
const fianchi = ctx.createLinearGradient(cx - r, 0, cx + r, 0);
fianchi.addColorStop(0, fxColorAlpha(colL, 0));
fianchi.addColorStop(0.5, fxColorAlpha(colL, 0.24));
fianchi.addColorStop(1, fxColorAlpha(colL, 0));
ctx.fillStyle = fianchi;
ctx.fillRect(cx - r, topY, r * 2, (cy + ry) - topY);
const giu = ctx.createLinearGradient(0, topY, 0, cy + ry);
giu.addColorStop(0, fxColorAlpha(colL, 0.09));
giu.addColorStop(0.65, fxColorAlpha(colL, 0.20));
giu.addColorStop(1, fxColorAlpha(colL, 0.30));
ctx.fillStyle = giu;
ctx.fillRect(cx - r, topY, r * 2, (cy + ry) - topY);
ctx.restore();
// 2) la stessa luce, ma in ADDITIVA: illumina invece di velare, così
// quello che c'è dentro si vede meglio e non peggio.
ctx.save();
ctx.globalCompositeOperation = 'lighter';
corpo();
ctx.clip();
const su = ctx.createLinearGradient(0, topY, 0, cy + ry);
su.addColorStop(0, fxColorAlpha(colL, 0.09));
su.addColorStop(1, fxColorAlpha(colL, 0.26));
ctx.fillStyle = su;
ctx.fillRect(cx - r, topY, r * 2, (cy + ry) - topY);
ctx.restore();
// 3) l'ANELLO A TERRA attorno ai piedi: è quello che nel filmato di
// riferimento fa capire su CHI è puntata la luce. Sfumato, dello
// stesso colore del fascio — non un contorno secco disegnato sopra.
ctx.save();
ctx.globalCompositeOperation = 'lighter';
const anello = ctx.createRadialGradient(cx, cy, r * 0.55, cx, cy, r * 1.12);
anello.addColorStop(0, fxColorAlpha(colL, 0));
anello.addColorStop(0.55, fxColorAlpha(colL, 0.62));
anello.addColorStop(1, fxColorAlpha(colL, 0));
ctx.fillStyle = anello;
ctx.beginPath();
ctx.ellipse(cx, cy, r * 1.12, ry * 1.12, 0, 0, Math.PI * 2);
ctx.fill();
// alone morbido tutt'attorno alla base, per staccare la zona dal resto
const base = ctx.createRadialGradient(cx, cy, r * 0.1, cx, cy, r * 1.5);
base.addColorStop(0, fxColorAlpha(colL, 0.13));
base.addColorStop(1, fxColorAlpha(colL, 0));
ctx.fillStyle = base;
ctx.beginPath();
ctx.ellipse(cx, cy, r * 1.5, ry * 1.6, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// Cerchio netto attorno ai piedi: sfumature e basta non bastavano a
// "puntare" la giocatrice — questo è il segno che si vede subito, anche
// su un parquet chiaro o con la telecamera lontana.
ctx.save();
ctx.beginPath();
ctx.ellipse(cx, cy, r * 0.92, ry * 0.92, 0, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(0,0,0,.35)';
ctx.lineWidth = Math.max(3, r * 0.075);
ctx.stroke();
ctx.strokeStyle = fxColorAlpha(colL, 0.95);
ctx.lineWidth = Math.max(2, r * 0.05);
ctx.shadowColor = fxColorAlpha(colL, 0.9);
ctx.shadowBlur = 12;
ctx.stroke();
ctx.restore();
ctx.restore();
});
// Cerchi: evidenziano una giocatrice o uno spazio, senza scurire il resto.
(fxList || []).forEach(fx => {
if (fx.type !== 'circle' || !attivo(fx)) return;
const cx = PX(fx, 'cx', 0.5), cy = PY(fx, 'cy', 0.5);
const r = Math.max(14, (fx.r != null ? fx.r : 0.10) * Math.min(dw, dh));
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.strokeStyle = fx.color || '#ffd400';
ctx.lineWidth = Math.max(1.5, r * 0.045);
ctx.shadowColor = 'rgba(0,0,0,.65)';
ctx.shadowBlur = 8;
ctx.stroke();
ctx.restore();
});
// Frecce: spesse e CURVE, come nel filmato di riferimento — una freccia
// sottile e dritta sul parquet non si vede, mentre l'arco spesso segue la
// traiettoria vera della giocatrice e si legge al primo colpo d'occhio.
// La curvatura si regola (fx.curv: 0 = dritta, ±1 = arco molto largo).
(fxList || []).forEach(fx => {
if (fx.type !== 'arrow' || !attivo(fx)) return;
const x1 = PX(fx, 'x1', 0.3), y1 = PY(fx, 'y1', 0.6);
const x2 = PX(fx, 'x2', 0.7), y2 = PY(fx, 'y2', 0.4);
const S = Math.min(dw, dh);
const w = Math.max(4, S * 0.011); // spessore (prima 0.0045: un filo)
const testa = Math.max(16, S * 0.045); // punta ben visibile
const dx = x2 - x1, dy = y2 - y1, len = Math.hypot(dx, dy) || 1;
const curv = fx.curv != null ? +fx.curv : 0.22;
const nx = -dy / len, ny = dx / len;
const cxq = (x1 + x2) / 2 + nx * len * curv;
const cyq = (y1 + y2) / 2 + ny * len * curv;
// direzione di arrivo: tangente della curva nel punto finale
const angF = Math.atan2(y2 - cyq, x2 - cxq);
// il tratto si ferma prima della punta, altrimenti la punta risulta
// "infilata" sopra la linea e si ingrossa a caso
const fx2 = x2 - Math.cos(angF) * testa * 0.72;
const fy2 = y2 - Math.sin(angF) * testa * 0.72;
const colF = fx.color || FX_ARROW_COLOR;
ctx.save();
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
const traccia = (spessore, colore) => {
ctx.strokeStyle = colore; ctx.lineWidth = spessore;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(cxq, cyq, fx2, fy2);
ctx.stroke();
};
const punta = (extra, colore) => {
ctx.fillStyle = colore;
ctx.beginPath();
ctx.moveTo(x2 + Math.cos(angF) * extra, y2 + Math.sin(angF) * extra);
ctx.lineTo(x2 - (testa + extra) * Math.cos(angF - 0.44), y2 - (testa + extra) * Math.sin(angF - 0.44));
ctx.lineTo(x2 - (testa + extra) * Math.cos(angF + 0.44), y2 - (testa + extra) * Math.sin(angF + 0.44));
ctx.closePath();
ctx.fill();
};
// contorno scuro sotto: la freccia resta leggibile anche sul parquet
// chiaro o sopra una maglia dello stesso colore
traccia(w + Math.max(2.5, S * 0.005), 'rgba(0,0,0,.55)');
punta(Math.max(2, S * 0.004), 'rgba(0,0,0,.55)');
traccia(w, colF);
punta(0, colF);
ctx.restore();
});
// Muro del blocco: un pannello che sta IN PIEDI sul campo, grande come una
// giocatrice. L'inclinazione lo fa RUOTARE A VOLO D'UCCELLO — cioè gira
// l'appoggio sul parquet, come si girerebbe visto dall'alto — così puoi
// dargli l'angolo giusto del blocco invece di averlo sempre di fronte alla
// telecamera. L'impronta a terra si vede, ed è quella che racconta la
// rotazione.
(fxList || []).forEach(fx => {
if (fx.type !== 'wall' || !attivo(fx)) return;
const col = fx.color || '#4dc3ff';
const bx = PX(fx, 'cx', 0.5), by = PY(fx, 'cy', 0.5);
const L = Math.max(12, (fx.len != null ? fx.len : 0.055) * dw);
const Hm = Math.max(20, (fx.thick != null ? fx.thick : 0.20) * dh);
const a = ((fx.ang != null ? fx.ang : 0) * Math.PI) / 180;
const PROSP = 0.55; // schiacciamento della profondità
const dxp = Math.cos(a) * L / 2, dyp = Math.sin(a) * L / 2 * PROSP;
const b1 = { x: bx - dxp, y: by - dyp }, b2 = { x: bx + dxp, y: by + dyp };
const t1 = { x: b1.x, y: b1.y - Hm }, t2 = { x: b2.x, y: b2.y - Hm };
ctx.save();
// PANNELLO come nel filmato di riferimento: una lastra che sta in piedi
// sul parquet, quasi trasparente (si vede la giocatrice attraverso) con
// un bordo sottile e luminoso. Niente più impronta piena a terra né
// "cimasa" in cima: erano loro a farlo sembrare un blocco di cemento
// invece di un pannello. L'inclinazione continua a girare l'appoggio a
// volo d'uccello, quindi la lastra diventa un parallelogramma e si legge
// subito da che parte è messo il blocco.
ctx.beginPath();
ctx.moveTo(b1.x, b1.y); ctx.lineTo(b2.x, b2.y); ctx.lineTo(t2.x, t2.y); ctx.lineTo(t1.x, t1.y);
ctx.closePath();
const vetro = ctx.createLinearGradient(0, by - Hm, 0, by);
vetro.addColorStop(0, fxColorAlpha(col, 0.10));
vetro.addColorStop(0.55, fxColorAlpha(col, 0.17));
vetro.addColorStop(1, fxColorAlpha(col, 0.26));
ctx.fillStyle = vetro;
ctx.fill();
// velo bianco: è quello che nel riferimento fa sembrare una lastra di
// vetro illuminata invece di una superficie colorata piatta
ctx.fillStyle = 'rgba(255,255,255,.10)';
ctx.fill();
// bordo sottile e ben in risalto (prima nero spesso + colore: sembrava
// disegnato col pennarello)
// Contorni BEN INCISI: prima una banda scura che stacca la lastra dallo
// sfondo qualunque cosa ci sia dietro, poi il filo bianco luminoso, poi
// un secondo passaggio nel colore del blocco. Così il perimetro si legge
// anche sul parquet chiaro, da lontano e sul telefono.
ctx.lineJoin = 'round';
const perimetro = () => {
ctx.beginPath();
ctx.moveTo(b1.x, b1.y); ctx.lineTo(b2.x, b2.y); ctx.lineTo(t2.x, t2.y); ctx.lineTo(t1.x, t1.y);
ctx.closePath();
};
ctx.strokeStyle = 'rgba(0,10,22,.85)';
ctx.lineWidth = Math.max(5, Hm * 0.045);
perimetro(); ctx.stroke();
ctx.strokeStyle = fxColorAlpha(col, 1);
ctx.lineWidth = Math.max(3.4, Hm * 0.030);
ctx.shadowColor = fxColorAlpha(col, 1);
ctx.shadowBlur = 14;
perimetro(); ctx.stroke();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = Math.max(1.6, Hm * 0.014);
ctx.shadowBlur = 6;
perimetro(); ctx.stroke();
// linea di appoggio a terra: il tratto che dice dove poggia il blocco
ctx.shadowColor = fxColorAlpha(col, 1);
ctx.shadowBlur = 10;
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = Math.max(2.6, Hm * 0.024);
ctx.beginPath();
ctx.moveTo(b1.x, b1.y); ctx.lineTo(b2.x, b2.y);
ctx.stroke();
ctx.restore();
});
// Scritta sulla clip: BIANCA SU SFONDO TRASPARENTE — niente più fascia
// blu piena, che copriva un pezzo di campo. Per restare leggibile su
// qualunque fondo ha un contorno scuro e un'ombra, non un rettangolo.
// Si mette dove si vuole (cx, cy: di partenza in alto al centro) e si
// regola di misura (size).
(fxList || []).forEach(fx => {
if (fx.type !== 'text' || !attivo(fx) || !(fx.text || '').trim()) return;
const fs = Math.max(12, dh * (fx.size != null ? +fx.size : 0.062));
const x = ox + (fx.cx != null ? fx.cx : 0.5) * dw;
const y = oy + (fx.cy != null ? fx.cy : 0.09) * dh;
ctx.save();
ctx.font = '800 ' + Math.round(fs) + 'px system-ui, -apple-system, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const testo = fx.text.trim().toUpperCase();
// ombra morbida: stacca la scritta anche dal bianco del fondo campo
ctx.shadowColor = 'rgba(0,0,0,.85)';
ctx.shadowBlur = Math.max(6, fs * 0.30);
ctx.lineJoin = 'round'; ctx.miterLimit = 2;
ctx.strokeStyle = 'rgba(0,0,0,.85)';
ctx.lineWidth = Math.max(3, fs * 0.16);
ctx.strokeText(testo, x, y);
ctx.shadowBlur = 0;
ctx.fillStyle = fx.color || '#ffffff';
ctx.fillText(testo, x, y);
ctx.restore();
});
}
// Velocità di riproduzione al secondo `tRel` (1 = normale).
function fxRateAt(fxList, tRel) {
let rate = 1;
(fxList || []).forEach(fx => {
if (fx.type === 'slow' && tRel >= fx.t && tRel < fx.t + fx.dur) rate = fx.rate || 0.35;
});
return rate;
}
// Fermo immagine che comincia in questo istante (se non ancora consumato).
function fxFreezeAt(fxList, tRel, gia) {
return (fxList || []).find(fx => fx.type === 'freeze' && !gia.has(fx) && tRel >= fx.t);
}
/* ------------------------------------------------------------ MONTAGGIO
* Il coach tagga tutta la partita, poi a lavoro finito sceglie quali pezzi
* mettere nel montaggio e in che ordine. L'ordine è una semplice lista di id
* di tag (vt.montageOrder): finché è vuota il montaggio prende tutto in
* ordine di tempo, come prima; appena si sceglie qualcosa comanda la lista. */
function allTeamTags(team) {
if (!team) return [];
const out = [];
(vt.clips || []).forEach(clip => {
const suo = clip.teamId === team.id;
// Filmati rimasti SENZA PADRONE (la loro squadra e' stata cancellata o
// rinominata in passato): i loro pezzi non devono sparire dalle schede
// dei giochi. Se il pezzo porta il nome di una giocatrice di questa
// squadra, o se il filmato non e' di nessun altro, lo si tiene qui.
const orfano = !suo && clipSenzaPadrone(clip);
if (!suo && !orfano) return;
(clip.tags || []).forEach(tag => {
if (!(tag.end > tag.start)) return;
if (orfano && tag.playerTeamId && tag.playerTeamId !== team.id) return;
out.push({ clip, tag });
});
});
return out;
}
function montageEntryLabel(tag) {
return tag.label
? tag.label + (tag.playerLabel ? ' · ' + tag.playerLabel : '')
: (tag.playerLabel || t('tag.toClassify'));
}
function montageToggle(tagId) {
const i = vt.montageOrder.indexOf(tagId);
if (i >= 0) vt.montageOrder.splice(i, 1); else vt.montageOrder.push(tagId);
renderMontagePanel();
scheduleAutosave();
}
function montageMove(tagId, dir) {
const i = vt.montageOrder.indexOf(tagId);
if (i < 0) return;
const j = i + dir;
if (j < 0 || j >= vt.montageOrder.length) return;
const tmp = vt.montageOrder[i];
vt.montageOrder[i] = vt.montageOrder[j];
vt.montageOrder[j] = tmp;
renderMontagePanel();
scheduleAutosave();
}
function montageAddAll() {
const team = activeTeam();
if (!team) return;
allTeamTags(team)
.sort((a, b) => a.tag.start - b.tag.start)
.forEach(({ tag }) => { if (!vt.montageOrder.includes(tag.id)) vt.montageOrder.push(tag.id); });
renderMontagePanel();
scheduleAutosave();
}
function montageClear() {
vt.montageOrder = [];
renderMontagePanel();
scheduleAutosave();
}
/* Editor degli effetti di UN pezzo: si apre dalla riga del montaggio con 🎬.
* Mostra il primo fotogramma del pezzo per posizionare lo spotlight con un
* click, e l'elenco degli effetti con i loro tempi. */
let fxEditorTagId = null;
function findTagAnywhere(tagId) {
for (const clip of (vt.clips || [])) {
const tag = (clip.tags || []).find(x => x.id === tagId);
if (tag) return { clip, tag };
}
return null;
}
function openFxEditor(tagId) {
const found = findTagAnywhere(tagId);
if (!found) return;
fxEditorTagId = tagId;
fxStrumento = null;
fxPlacing = null;
renderFxEditor();
document.getElementById('fxOverlay').classList.add('show');
}
function closeFxEditor() {
fxStopPreview();
fxFermaPendolo();
fxEditorTagId = null;
document.getElementById('fxOverlay').classList.remove('show');
renderMontagePanel();
scheduleAutosave();
}
// Sceglie lo strumento con cui disegnare (resta attivo finché non lo si
// cambia). "Sposta" = nessuno strumento: si trascinano gli elementi già
// messi, come prima.
function fxStrumentoScegli(tipo) {
fxStrumento = (fxStrumento === tipo) ? null : (tipo || null);
fxPlacing = null;
renderFxEditor();
if (fxStrumento) toast(t('fx.toolOn', { nome: t(FX_TYPES[fxStrumento].labelKey) }));
}
// Crea l'elemento dello strumento attivo SENZA ricostruire il pannello: il
// disegno è già cominciato (siamo dentro al mousedown sull'anteprima) e un
// render qui staccherebbe il gesto a metà.
function fxCreaAlVolo(tipo, tag) {
const lista = tagFx(tag);
const dur = tag.end - tag.start;
const tIniz = Math.max(0, Math.min(Math.max(0.1, dur - 0.2), fxEditorPlayhead || dur / 2));
const base = { type: tipo, t: tIniz, dur: Math.min(2, dur), pending: true };
if (tipo === 'spotlight') lista.push(Object.assign(base, { cx: 0.5, cy: 0.5, r: 0.16, color: FX_SPOT_COLOR }));
else if (tipo === 'lente') lista.push(Object.assign(base, { cx: 0.5, cy: 0.5, r: 0.15, zoom: 2, color: '#ffd400' }));
else if (tipo === 'circle') lista.push(Object.assign(base, { cx: 0.5, cy: 0.5, r: 0.10 }));
else if (tipo === 'arrow') lista.push(Object.assign(base, { x1: 0.35, y1: 0.65, x2: 0.65, y2: 0.4, curv: 0.22 }));
else if (tipo === 'wall') lista.push(Object.assign(base, { cx: 0.5, cy: 0.68, len: 0.055, thick: 0.20, ang: 0 }));
else if (tipo === 'text') lista.push(Object.assign(base, { t: 0, dur: Math.min(dur, 3), text: '', cx: 0.5, cy: 0.09, size: 0.062, color: '#ffffff' }));
else if (tipo === 'cutout') lista.push(Object.assign(base, { dur: Math.min(Math.max(1.2, dur), FX_CUT_SLIDE + FX_CUT_HOLD_DEF + 0.25), hold: FX_CUT_HOLD_DEF, sx: 0.42, sy: 0.34, sw: 0.16, sh: 0.32, dx: 0, dy: 0, color: '#ffd400' }));
else return -1;
return lista.length - 1;
}
function fxAdd(type) {
const found = findTagAnywhere(fxEditorTagId);
if (!found) return;
const { tag } = found;
const dur = tag.end - tag.start;
const lista = tagFx(tag);
// Il nuovo effetto parte DA DOVE SEI FERMO, non da metà pezzo: fermi la
// clip sul fotogramma che ti interessa, aggiungi il ritaglio (o qualunque
// altro effetto) e quello nasce lì. Prima nasceva sempre a metà e il
// cursore ci saltava sopra: sembrava che l'app "tornasse indietro".
// NB: la variabile NON si può chiamare "t" — coprirebbe la funzione t()
// delle traduzioni, e ogni toast qui dentro andrebbe in errore (era quello
// che faceva fallire l'aggiunta della freccia).
const tIniz = Math.max(0, Math.min(Math.max(0.1, dur - 0.2), fxEditorPlayhead || dur / 2));
const iNuovo = lista.length;
if (type === 'spotlight') lista.push({ type, t: tIniz, dur: Math.min(2, dur), cx: 0.5, cy: 0.5, r: 0.16, color: FX_SPOT_COLOR, pending: true });
// Il ritaglio nasce "da tirare": il primo trascinamento sull'anteprima
// disegna il rettangolo attorno a quello che si vuole prendere.
// Durata di partenza = il ciclo intero (vai, resta un secondo, torna).
else if (type === 'cutout') lista.push({ type, t: tIniz, dur: Math.min(Math.max(1.2, dur), FX_CUT_SLIDE + FX_CUT_HOLD_DEF + 0.25), hold: FX_CUT_HOLD_DEF, sx: 0.42, sy: 0.34, sw: 0.16, sh: 0.32, dx: 0, dy: 0, color: '#ffd400', pending: true });
else if (type === 'lente') lista.push({ type, t: tIniz, dur: Math.min(2.5, dur), cx: 0.5, cy: 0.5, r: 0.15, zoom: 2, color: '#ffd400', pending: true });
else if (type === 'circle') lista.push({ type, t: tIniz, dur: Math.min(2, dur), cx: 0.5, cy: 0.5, r: 0.10, pending: true });
else if (type === 'arrow') lista.push({ type, t: tIniz, dur: Math.min(2, dur), x1: 0.35, y1: 0.65, x2: 0.65, y2: 0.4, pending: true });
else if (type === 'wall') lista.push({ type, t: tIniz, dur: Math.min(2, dur), cx: 0.5, cy: 0.68, len: 0.055, thick: 0.20, ang: 0, pending: true });
else if (type === 'text') lista.push({ type, t: 0, dur: Math.min(dur, 3), text: '', cx: 0.5, cy: 0.09, size: 0.062, color: '#ffffff' });
else if (type === 'slow') lista.push({ type, t: 0, dur: Math.min(dur, 3), rate: 0.35 });
else if (type === 'freeze') lista.push({ type, t: tIniz, dur: 1.5 });
// Gli elementi che si "mettono" sul fotogramma partono già in modalità
// posizionamento: appena aggiunta la freccia si TRASCINA sull'anteprima da
// dove parte a dove arriva, senza dover scoprire prima un pulsante.
// Il cursore NON si muove: l'effetto è nato qui, quindi qui è gia' acceso.
// Spostarlo era proprio il salto all'indietro di cui sopra.
const nuovo = lista[iNuovo];
if (nuovo && fxEditorPlayhead < (+nuovo.t || 0)) fxEditorPlayhead = +nuovo.t || 0;
if (FX_PLACEABLE[type]) { fxPlacing = { index: iNuovo, step: 0 }; toast(t(type === 'arrow' ? 'fx.dragArrowHint' : type === 'cutout' ? 'fx.cutoutDrawHint' : 'fx.placeHint')); }
renderFxEditor();
scheduleAutosave();
}
function fxRemove(i) {
const found = findTagAnywhere(fxEditorTagId);
if (!found) return;
tagFx(found.tag).splice(i, 1);
renderFxEditor();
scheduleAutosave();
}
// Rimette il ritaglio esattamente dov'era nel filmato (scostamento a zero).
/* SCATTA la foto del riquadro dal fotogramma che si sta guardando: da quel
* momento il ritaglio è statico, le giocatrici dentro restano ferme dove le
* hai prese anche mentre la clip va avanti. */
function fxCatturaRitaglio(fx) {
const pv = document.getElementById('fxPreviewVideo');
if (!pv || !pv.videoWidth) return false;
const vw = pv.videoWidth, vh = pv.videoHeight;
const sx = Math.max(0, Math.min(1, fx.sx != null ? fx.sx : 0.4));
const sy = Math.max(0, Math.min(1, fx.sy != null ? fx.sy : 0.3));
const sw = Math.max(0.01, Math.min(1 - sx, fx.sw != null ? fx.sw : 0.16));
const sh = Math.max(0.01, Math.min(1 - sy, fx.sh != null ? fx.sh : 0.32));
const W = Math.max(8, Math.round(sw * vw)), H = Math.max(8, Math.round(sh * vh));
try {
const c = document.createElement('canvas');
c.width = W; c.height = H;
c.getContext('2d').drawImage(pv, sx * vw, sy * vh, sw * vw, sh * vh, 0, 0, W, H);
fx.snap = c.toDataURL('image/jpeg', 0.86);
fxSnapImage(fx.snap);
return true;
} catch (e) { return false; }
}
// Ri-scatta la foto dal fotogramma attuale (per esempio dopo aver cambiato
// misura al riquadro, o se si vuole prendere un istante diverso).
function fxRicatturaRitaglio(i) {
const found = findTagAnywhere(fxEditorTagId);
if (!found) return;
const fx = tagFx(found.tag)[i];
if (!fx) return;
const ok = fxCatturaRitaglio(fx);
renderFxEditor();
scheduleAutosave();
toast(t(ok ? 'fx.cutoutSnapped' : 'fx.cutoutSnapFail'));
}
function fxSetMoto(i, valore) {
const found = findTagAnywhere(fxEditorTagId);
if (!found) return;
const fx = tagFx(found.tag)[i];
if (!fx) return;
fx.moto = valore;
// "vai e torna" ha bisogno di più tempo: vai + resta + torna + un attimo.
if (valore !== 'fermo') fx.dur = Math.max(+fx.dur || 0, fxCutoutDurataMinima(fx));
renderFxEditor();
scheduleAutosave();
}
function fxCutoutReset(i) {
const found = findTagAnywhere(fxEditorTagId);
if (!found) return;
const fx = tagFx(found.tag)[i];
if (!fx) return;
fx.dx = 0; fx.dy = 0;
renderFxEditor();
scheduleAutosave();
}
function fxSet(i, field, value) {
const found = findTagAnywhere(fxEditorTagId);
if (!found) return;
const fx = tagFx(found.tag)[i];
if (!fx) return;
// Il testo della scritta è testo, non un numero: parseFloat lo azzererebbe.
fx[field] = (field === 'text' || field === 'color') ? value : parseFloat(value);
// Cambiando misura al ritaglio la foto fissa non corrisponde più: si
// riscatta dal fotogramma che si sta guardando.
if (fx.type === 'cutout' && (field === 'sw' || field === 'sh')) fxCatturaRitaglio(fx);
// Il ciclo (vai + resta + torna) deve starci tutto dentro alla durata.
// vanno (0,55) + restano (hold) + tornano (0,55) + un attimo ferme al loro
// posto (0,6): il ciclo intero deve starci dentro alla durata.
if (fx.type === 'cutout' && field === 'hold') fx.dur = Math.max(+fx.dur || 0, fxCutoutDurataMinima(fx));
if (field === 'color') { renderFxPreviewOverlay(); return; }
if (field !== 'text') renderFxEditor();
scheduleAutosave();
}
// Arma il posizionamento: il click successivo sull'anteprima mette lì
// l'effetto scelto (per la freccia servono due click: partenza e arrivo).
function fxStartPlacing(i) {
fxPlacing = { index: i, step: 0 };
renderFxEditor();
toast(t('fx.placeHint'));
}
// Click sull'anteprima: mette dove hai cliccato l'effetto che stai
// posizionando (fascio di luce, cerchio, freccia). Senza nulla di armato,
// sposta il primo fascio di luce presente — comportamento di prima.
// Quale elemento sta sotto al punto cliccato (in frazioni 0..1 del riquadro):
// serve per AFFERRARLO e spostarlo direttamente, senza armare niente.
// Ingombro della scritta in frazioni del riquadro: serve per afferrarla e
// spostarla. Si misura col vero font, così il bersaglio combacia con quello
// che si vede.
let _fxMisuraCtx = null;
function fxTextRect(fx, ratio) {
const dw = 1000, dh = 1000 * (ratio || 0.5625);
const fs = Math.max(12, dh * (fx.size != null ? +fx.size : 0.062));
if (!_fxMisuraCtx) _fxMisuraCtx = document.createElement('canvas').getContext('2d');
const c = _fxMisuraCtx;
c.font = '800 ' + Math.round(fs) + 'px system-ui, -apple-system, sans-serif';
const testo = ((fx.text || '').trim() || 'SCRITTA').toUpperCase();
const w = c.measureText(testo).width / dw;
const h = (fs * 1.35) / dh;
const cx = fx.cx != null ? fx.cx : 0.5, cy = fx.cy != null ? fx.cy : 0.09;
return { x: cx - w / 2, y: cy - h / 2, w, h };
}
function fxHitTest(lista, fx0, fy0, ratio) {
// VINCE IL PIU' PICCOLO. Prima si prendeva semplicemente l'ultimo elemento
// della lista che conteneva il punto: un fascio di luce disegnato sopra un
// ritaglio se ne prendeva tutti i click e il ritaglio non si spostava più
// ("si associa sempre allo spotlight"). Ora, fra tutti quelli che stanno
// sotto al cursore, si afferra quello con l'ingombro MINORE: un fascio
// piccolo dentro a un ritaglio grande resta suo, ma tutto il resto del
// ritaglio resta del ritaglio. A parità, l'ultimo disegnato.
let scelto = -1, minArea = Infinity;
const candidato = (i, area) => { if (area <= minArea) { minArea = area; scelto = i; } };
for (let i = 0; i < (lista || []).length; i++) {
const fx = lista[i];
if (fx.type === 'spotlight' || fx.type === 'circle' || fx.type === 'lente') {
// Il raggio è in frazione del LATO CORTO (come nel disegno): sull'asse
// orizzontale va riportato in frazione di larghezza, altrimenti la zona
// di presa esce larga il doppio di quello che si vede.
const r = fx.r != null ? fx.r : (fx.type === 'spotlight' ? 0.16 : fx.type === 'lente' ? 0.15 : 0.10);
const rx = r * (ratio || 1), ry = r;
const dx = (fx0 - (fx.cx != null ? fx.cx : 0.5)) / (rx || 1);
const dy = (fy0 - (fx.cy != null ? fx.cy : 0.5)) / (ry || 1);
if (Math.hypot(dx, dy) <= 1.15) candidato(i, Math.PI * rx * ry);
} else if (fx.type === 'cutout') {
const sx = (fx.sx != null ? fx.sx : 0.4), sy = (fx.sy != null ? fx.sy : 0.3);
const w = fx.sw != null ? fx.sw : 0.16, h = fx.sh != null ? fx.sh : 0.32;
const tx = sx + (fx.dx || 0), ty = sy + (fx.dy || 0);
// la presa ✥ al centro ha sempre la precedenza su tutto
if (Math.hypot(fx0 - (tx + w / 2), (fy0 - (ty + h / 2)) / (ratio || 1)) <= 0.045) { candidato(i, 0); continue; }
const dentro = (ax, ay) => fx0 >= ax - 0.012 && fx0 <= ax + w + 0.012 && fy0 >= ay - 0.012 && fy0 <= ay + h + 0.012;
// si afferra sia dove si vede adesso sia dal riquadro di partenza
if (dentro(tx, ty) || dentro(sx, sy)) candidato(i, w * h);
} else if (fx.type === 'wall') {
const L = (fx.len != null ? fx.len : 0.10), H = (fx.thick != null ? fx.thick : 0.20);
const cx = fx.cx != null ? fx.cx : 0.5, cy = fx.cy != null ? fx.cy : 0.5;
if (fx0 >= cx - L * 0.7 && fx0 <= cx + L * 0.7 && fy0 <= cy + 0.02 && fy0 >= cy - H * 1.02) candidato(i, L * 1.4 * H);
} else if (fx.type === 'text') {
const r = fxTextRect(fx, ratio);
if (fx0 >= r.x - 0.01 && fx0 <= r.x + r.w + 0.01 && fy0 >= r.y - 0.01 && fy0 <= r.y + r.h + 0.01) candidato(i, r.w * r.h);
} else if (fx.type === 'arrow') {
const x1 = fx.x1 != null ? fx.x1 : 0.35, y1 = fx.y1 != null ? fx.y1 : 0.65;
const x2 = fx.x2 != null ? fx.x2 : 0.65, y2 = fx.y2 != null ? fx.y2 : 0.4;
const vx = x2 - x1, vy = y2 - y1;
const len2 = vx * vx + vy * vy || 1;
let tt = ((fx0 - x1) * vx + (fy0 - y1) * vy) / len2;
tt = Math.max(0, Math.min(1, tt));
const d = Math.hypot(fx0 - (x1 + vx * tt), fy0 - (y1 + vy * tt));
// la freccia è sottile: se ci si clicca sopra è perché la si vuole
if (d <= 0.035) candidato(i, 0.0005);
}
}
return scelto;
}
/* I due vertici della BASE del muro (in frazioni 0..1 del riquadro): sono le
* maniglie con cui lo si gira. La base è schiacciata dalla prospettiva
* (PROSP) esattamente come nel disegno, altrimenti la maniglia non cadrebbe
* dove si vede il vertice. */
const FX_WALL_PROSP = 0.55;
function fxWallBaseVertices(fx, ratio) {
const cx = fx.cx != null ? fx.cx : 0.5, cy = fx.cy != null ? fx.cy : 0.5;
const len = fx.len != null ? fx.len : 0.10;
const a = ((fx.ang != null ? fx.ang : 0) * Math.PI) / 180;
const dx = Math.cos(a) * len / 2;
const dy = Math.sin(a) * len / 2 * FX_WALL_PROSP / (ratio || 1);
return [{ x: cx - dx, y: cy - dy }, { x: cx + dx, y: cy + dy }];
}
// Quale vertice della base di quale muro sta sotto al punto premuto.
function fxWallVertexHit(lista, fx0, fy0, ratio) {
for (let i = (lista || []).length - 1; i >= 0; i--) {
const fx = lista[i];
if (fx.type !== 'wall') continue;
const vs = fxWallBaseVertices(fx, ratio);
for (let k = 0; k < vs.length; k++) {
const d = Math.hypot(vs[k].x - fx0, (vs[k].y - fy0) * (ratio || 1));
if (d <= 0.030) return { index: i, vertice: k };
}
}
return null;
}
function fxPlaceSpotlight(ev) {
const found = findTagAnywhere(fxEditorTagId);
if (!found) return;
const lista = tagFx(found.tag);
const box = ev.currentTarget.getBoundingClientRect();
const punto = (e) => ({
x: Math.max(0, Math.min(1, (e.clientX - box.left) / box.width)),
y: Math.max(0, Math.min(1, (e.clientY - box.top) / box.height)),
});
const p0 = punto(ev);
const ratioBox = box.height / (box.width || 1);
// Strumento attivo: ogni trascinamento sul fotogramma disegna un elemento
// NUOVO di quel tipo, senza passare dal pulsante "+" ogni volta.
if (fxStrumento && !fxPlacing) {
const iNuovo = fxCreaAlVolo(fxStrumento, found.tag);
if (iNuovo >= 0) fxPlacing = { index: iNuovo, step: 0 };
}
// MURO: premendo su uno dei due VERTICI DELLA BASE lo si RUOTA, a scatti di
// 15 gradi, come si girerebbe il blocco visto dall'alto — così si trova
// l'angolo giusto del blocco senza andare a caccia col cursore.
if (!fxPlacing) {
const vh = fxWallVertexHit(lista, p0.x, p0.y, ratioBox);
if (vh) {
const muro = lista[vh.index];
const gira = (e) => {
const p = punto(e);
const dx = p.x - (muro.cx != null ? muro.cx : 0.5);
const dy = (p.y - (muro.cy != null ? muro.cy : 0.5)) * ratioBox / FX_WALL_PROSP;
let g = Math.atan2(dy, dx) * 180 / Math.PI;
if (vh.vertice === 0) g += 180; // si sta tirando il vertice opposto
g = Math.round(g / 15) * 15; // scatti di 15°
while (g > 90) g -= 180;
while (g < -90) g += 180;
muro.ang = g;
renderFxPreviewOverlay();
const et = document.querySelector('.fx-wall-ang-live');
if (et) et.textContent = Math.round(g) + '°';
};
const molla = () => {
window.removeEventListener('mousemove', gira);
window.removeEventListener('mouseup', molla);
renderFxEditor();
scheduleAutosave();
};
window.addEventListener('mousemove', gira);
window.addEventListener('mouseup', molla);
toast(t('fx.wallRotateHint'));
if (ev.preventDefault) ev.preventDefault();
return;
}
}
let fx = fxPlacing ? lista[fxPlacing.index] : null;
// Nessun elemento armato: se si preme SOPRA a un elemento lo si AFFERRA e
// lo si trascina dove si vuole (luce, cerchio, muro, freccia).
if (!fx) {
const iSotto = fxHitTest(lista, p0.x, p0.y, ratioBox);
if (iSotto >= 0) {
const el = lista[iSotto];
const inizio = { cx: el.cx, cy: el.cy, x1: el.x1, y1: el.y1, x2: el.x2, y2: el.y2, dx: el.dx || 0, dy: el.dy || 0 };
const sposta = (e) => {
const p = punto(e);
const ddx = p.x - p0.x, ddy = p.y - p0.y;
if (el.type === 'cutout') {
// Il ritaglio si sposta cambiando lo SCOSTAMENTO dalla posizione
// originale: il riquadro di partenza resta dov'era, così si vede
// da dove a dove.
el.dx = inizio.dx + ddx;
el.dy = inizio.dy + ddy;
} else if (el.type === 'arrow') {
el.x1 = inizio.x1 + ddx; el.y1 = inizio.y1 + ddy;
el.x2 = inizio.x2 + ddx; el.y2 = inizio.y2 + ddy;
} else {
el.cx = Math.max(0, Math.min(1, inizio.cx + ddx));
el.cy = Math.max(0, Math.min(1, inizio.cy + ddy));
}
renderFxPreviewOverlay();
};
const lascia = () => {
window.removeEventListener('mousemove', sposta);
window.removeEventListener('mouseup', lascia);
scheduleAutosave();
};
window.addEventListener('mousemove', sposta);
window.addEventListener('mouseup', lascia);
if (ev.preventDefault) ev.preventDefault();
return;
}
}
if (!fx) {
// Nessun elemento "armato": si sposta il primo fascio di luce/cerchio,
// come prima.
fx = lista.find(f => f.type === 'spotlight' || f.type === 'circle');
if (!fx) { toast(t('fx.addSpotlightFirst')); return; }
}
// TRASCINAMENTO: per la freccia si parte dal punto in cui si preme e si
// finisce dove si lascia (era il gesto che mancava — "le frecce non riesco
// a farle": servivano due click su un pulsante nascosto). Per luce, cerchio
// e muro il punto premuto è il centro e trascinando si regola la misura.
// RITAGLIO appena aggiunto: il trascinamento sull'anteprima DISEGNA il
// rettangolo attorno a quello che si vuole prendere (una giocatrice, due,
// una zona). Dopo, lo si trascina per portarlo dove sarebbe dovuto stare.
if (fx.type === 'cutout') {
delete fx.pending;
fx.dx = 0; fx.dy = 0;
let tirato = false;
const tira = (e) => {
const p = punto(e);
tirato = true;
fx.sx = Math.min(p0.x, p.x); fx.sy = Math.min(p0.y, p.y);
fx.sw = Math.max(0.02, Math.abs(p.x - p0.x)); fx.sh = Math.max(0.02, Math.abs(p.y - p0.y));
renderFxPreviewOverlay();
};
const molla = () => {
window.removeEventListener('mousemove', tira);
window.removeEventListener('mouseup', molla);
if (!tirato) {
// click secco: riquadro di partenza grande come una giocatrice,
// centrato dove si è cliccato
fx.sw = 0.14; fx.sh = 0.34;
fx.sx = Math.max(0, Math.min(1 - fx.sw, p0.x - fx.sw / 2));
fx.sy = Math.max(0, Math.min(1 - fx.sh, p0.y - fx.sh / 2));
}
// Scatto: da qui in poi il ritaglio è una FOTO FERMA di quell'istante.
fxCatturaRitaglio(fx);
fxPlacing = null;
// Il ritaglio si fa in due tempi (prima il rettangolo, poi lo si porta
// dove sarebbe dovuta stare): finito il rettangolo si torna da soli in
// modalità "✋ Sposta", così il trascinamento successivo lo SPOSTA
// invece di disegnarne un altro.
if (fxStrumento === 'cutout') fxStrumento = null;
renderFxEditor();
toast(t('fx.cutoutReady'));
scheduleAutosave();
};
window.addEventListener('mousemove', tira);
window.addEventListener('mouseup', molla);
if (ev.preventDefault) ev.preventDefault();
return;
}
const iniziale = { cx: fx.cx, cy: fx.cy, r: fx.r, len: fx.len, size: fx.size };
delete fx.pending;
if (fx.type === 'arrow') { fx.x1 = p0.x; fx.y1 = p0.y; fx.x2 = p0.x; fx.y2 = p0.y; }
else { fx.cx = p0.x; fx.cy = p0.y; }
let trascinato = false;
const muovi = (e) => {
const p = punto(e);
trascinato = true;
if (fx.type === 'arrow') { fx.x2 = p.x; fx.y2 = p.y; }
else if (fx.type === 'wall') fx.len = Math.max(0.06, Math.min(0.8, Math.abs(p.x - p0.x) * 2));
else if (fx.type === 'text') fx.size = Math.max(0.03, Math.min(0.20, 0.045 + Math.abs(p.y - p0.y) * 0.9));
else fx.r = Math.max(0.04, Math.min(0.45, Math.hypot(p.x - p0.x, (p.y - p0.y) * 0.6)));
renderFxMarkersOnly();
};
const su = () => {
window.removeEventListener('mousemove', muovi);
window.removeEventListener('mouseup', su);
if (fx.type === 'arrow' && !trascinato) {
// click secco: la freccia si crea comunque, con una lunghezza di
// partenza, così non resta un punto invisibile
fx.x2 = Math.min(1, fx.x1 + 0.18); fx.y2 = Math.max(0, fx.y1 - 0.12);
}
if (!trascinato && (fx.type === 'spotlight' || fx.type === 'circle' || fx.type === 'lente')) { fx.r = iniziale.r || (fx.type === 'spotlight' ? 0.16 : fx.type === 'lente' ? 0.15 : 0.10); }
if (!trascinato && fx.type === 'wall') fx.len = iniziale.len || 0.22;
if (!trascinato && fx.type === 'text') fx.size = iniziale.size || 0.062;
fxPlacing = null;
renderFxEditor();
scheduleAutosave();
};
window.addEventListener('mousemove', muovi);
window.addEventListener('mouseup', su);
if (ev.preventDefault) ev.preventDefault();
}
// Disegna sull'anteprima gli elementi COSÌ COME saranno nel video: quelli
// accesi nell'istante del cursore a piena intensità, gli altri in trasparenza
// (per poterli comunque vedere e spostare).
function renderFxPreviewOverlay() {
const cv = document.getElementById('fxPreviewCanvas');
const box = document.querySelector('.fx-preview');
if (!cv || !box) return;
const found = findTagAnywhere(fxEditorTagId);
if (!found) return;
const r = box.getBoundingClientRect();
const W = Math.max(80, Math.round(r.width)), H = Math.max(60, Math.round(r.height));
if (cv.width !== W || cv.height !== H) { cv.width = W; cv.height = H; }
const ctx = cv.getContext('2d');
ctx.clearRect(0, 0, W, H);
const lista = tagFx(found.tag);
// Mentre il pezzo GIRA si vede ESATTAMENTE quello che finirà nel video: gli
// elementi fuori dal loro intervallo spariscono del tutto. In pausa restano
// invece visibili in trasparenza, perché è lì che li si sposta. Prima
// erano sempre disegnati e sembrava che il fascio di luce restasse acceso
// per tutta la clip e lasciasse il cerchio a terra anche dopo la fine.
const inRiproduzione = !!fxPreviewLoop;
lista.forEach((fx) => {
if (fx.type === 'slow' || fx.type === 'freeze') return;
const acceso = fxEditorPlayhead >= (+fx.t || 0) && fxEditorPlayhead < (+fx.t || 0) + (+fx.dur || 0);
if (!acceso && inRiproduzione) return;
if (fx.pending) return;
ctx.save();
ctx.globalAlpha = acceso ? 1 : 0.35;
// si passa un solo elemento con il tempo "dentro" al suo intervallo, così
// viene disegnato anche quando il cursore è altrove
const pv = document.getElementById('fxPreviewVideo');
const sorgente = ((fx.type === 'cutout' || fx.type === 'lente') && pv && pv.videoWidth)
? { el: pv, vw: pv.videoWidth, vh: pv.videoHeight } : null;
if (inRiproduzione) {
// Mentre gira si guarda la cosa VERA, coi tempi veri: il ritaglio va,
// resta fermo e torna indietro esattamente come nel video esportato.
drawFxOverlay(ctx, [fx], fxEditorPlayhead, 0, 0, W, H, sorgente);
} else {
// In pausa il ritaglio si mostra già arrivato (anim:false), altrimenti
// starebbe sempre sul punto di partenza e non lo si potrebbe piazzare.
drawFxOverlay(ctx, [Object.assign({}, fx, { t: 0, dur: 9999, anim: false })], 0, 0, 0, W, H, sorgente);
}
ctx.restore();
});
// Maniglie: solo in pausa. Mentre il pezzo gira si guarda il risultato
// pulito, senza pallini e prese sopra al fotogramma.
if (inRiproduzione) return;
// Maniglia del RITAGLIO: una presa ben visibile al centro del riquadro, per
// non dover indovinare dove afferrarlo. Solo qui nell'editor: nel video
// esportato non c'è.
lista.filter(f => f.type === 'cutout').forEach(f => {
const x = ((f.sx != null ? f.sx : 0.4) + (f.dx || 0)) * W;
const y = ((f.sy != null ? f.sy : 0.3) + (f.dy || 0)) * H;
const w = (f.sw != null ? f.sw : 0.16) * W, h = (f.sh != null ? f.sh : 0.32) * H;
const cx = x + w / 2, cy = y + h / 2;
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, 15, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(10,20,35,.72)';
ctx.strokeStyle = f.color || '#ffd400';
ctx.lineWidth = 2.5;
ctx.fill(); ctx.stroke();
ctx.fillStyle = '#fff';
ctx.font = '700 15px system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('✥', cx, cy + 1);
ctx.restore();
});
// Maniglie di rotazione del muro: due pallini sui vertici della base. Si
// disegnano SOLO qui nell'editor (non finiscono nel video esportato) e
// servono a far capire dove afferrare per girarlo a scatti di 15°.
const ratio = H / (W || 1);
lista.filter(f => f.type === 'wall').forEach(f => {
fxWallBaseVertices(f, ratio).forEach(v => {
ctx.save();
ctx.beginPath();
ctx.arc(v.x * W, v.y * H, 5.5, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.strokeStyle = f.color || '#4dc3ff';
ctx.lineWidth = 2.5;
ctx.fill(); ctx.stroke();
ctx.restore();
});
});
}
// Nome vecchio, tenuto perché richiamato durante il trascinamento.
function renderFxMarkersOnly() { renderFxPreviewOverlay(); }
/* Il pezzo SI GUARDA mentre ci si disegna sopra: l'anteprima non è più un
* fotogramma fermo. Il video gira in cerchio dentro all'intervallo del pezzo,
* il cursore della barra del tempo lo segue e i disegni si accendono e si
* spengono quando devono — e nel frattempo si può continuare a trascinarli. */
let fxPreviewLoop = null;
function fxTogglePreviewPlay(ev) {
if (ev) { ev.preventDefault(); ev.stopPropagation(); }
const pv = document.getElementById('fxPreviewVideo');
const found = findTagAnywhere(fxEditorTagId);
if (!pv || !found) return;
if (fxPreviewLoop) { fxStopPreview(); return; }
fxFermaPendolo(); // si smette di inseguire fotogrammi: adesso il pezzo gira
const { tag } = found;
const durata = Math.max(0.1, tag.end - tag.start);
const btn = document.getElementById('fxPlayBtn');
if (btn) btn.textContent = '⏸ ' + t('fx.pausePreview');
try { pv.currentTime = tag.start + Math.min(fxEditorPlayhead, durata - 0.05); } catch (e) {}
pv.play().catch(() => {});
const lista = tagFx(tag);
const freezeFatti = new Set();
let inFreeze = false;
const passo = () => {
if (!fxPreviewLoop) return;
const rel = pv.currentTime - tag.start;
if (!inFreeze) {
if (rel >= durata || pv.ended) { try { pv.currentTime = tag.start; } catch (e) {} freezeFatti.clear(); }
// RALLENTY: anche qui nell'anteprima, non solo nell'esportazione —
// altrimenti si imposta il rallentatore e guardando il pezzo sembra che
// non faccia niente.
const rate = fxRateAt(lista, rel);
if (Math.abs(pv.playbackRate - rate) > 0.01) { try { pv.playbackRate = rate; } catch (e) {} }
// FERMO IMMAGINE: si mette in pausa per la sua durata e poi riparte.
const fz = fxFreezeAt(lista, rel, freezeFatti);
if (fz) {
freezeFatti.add(fz);
inFreeze = true;
try { pv.pause(); } catch (e) {}
setTimeout(() => {
inFreeze = false;
if (fxPreviewLoop) pv.play().catch(() => {});
}, (fz.dur || 1) * 1000);
}
}
fxEditorPlayhead = Math.max(0, Math.min(durata, pv.currentTime - tag.start));
const head = document.getElementById('fxTimelineHead');
if (head) head.style.left = ((fxEditorPlayhead / durata) * 100).toFixed(2) + '%';
fxAggiornaOrologio();
renderFxPreviewOverlay();
fxPreviewLoop = requestAnimationFrame(passo);
};
fxPreviewLoop = requestAnimationFrame(passo);
}
function fxStopPreview() {
if (fxPreviewLoop) { cancelAnimationFrame(fxPreviewLoop); fxPreviewLoop = null; }
const pv = document.getElementById('fxPreviewVideo');
if (pv) { try { pv.pause(); pv.playbackRate = 1; } catch (e) {} }
const btn = document.getElementById('fxPlayBtn');
if (btn) btn.textContent = '▶ ' + t('fx.playPreview');
renderFxPreviewOverlay();
}
// Segnaposto sull'anteprima: uno per ogni fascio di luce/cerchio/muro, e una
// linea per ogni freccia — così si vede dove cadranno prima di esportare.
function fxMarkersHtml(lista) {
// Un segnaposto è "acceso" solo se il cursore della barra del tempo cade
// dentro al suo intervallo: prima erano tutti sempre visibili e sembrava
// che il fascio di luce restasse acceso per tutta la clip. Quelli fuori
// tempo restano visibili ma sbiaditi, così si possono comunque spostare.
const acceso = (fx) => fxEditorPlayhead >= (+fx.t || 0) && fxEditorPlayhead < (+fx.t || 0) + (+fx.dur || 0);
return (lista || []).map((fx) => {
const off = acceso(fx) ? '' : ' fx-marker-off';
if (fx.type === 'spotlight' || fx.type === 'circle' || fx.type === 'lente') {
const r = fx.r || (fx.type === 'spotlight' ? 0.16 : 0.10);
return `
`;
}
if (fx.type === 'wall') {
const w = (fx.len != null ? fx.len : 0.22) * 100;
const h = (fx.thick != null ? fx.thick : 0.035) * 100;
return `
`;
}
if (fx.type === 'arrow') {
const x1 = (fx.x1 != null ? fx.x1 : 0.35) * 100, y1 = (fx.y1 != null ? fx.y1 : 0.65) * 100;
const x2 = (fx.x2 != null ? fx.x2 : 0.65) * 100, y2 = (fx.y2 != null ? fx.y2 : 0.4) * 100;
return ` `;
}
return '';
}).join('');
}
// Posizione del cursore sulla barra del tempo del pezzo (secondi dall'inizio
// del pezzo, non dall'inizio del video).
let fxEditorPlayhead = 0;
let fxDrag = null;
function fxTimelineDurata() {
const found = findTagAnywhere(fxEditorTagId);
return found ? Math.max(0.1, found.tag.end - found.tag.start) : 1;
}
// Click/trascinamento sul righello: sposta il cursore e porta l'anteprima a
// quell'istante, così si vede davvero il fotogramma con gli effetti accesi.
/* PERCHE' L'EDIT CLIP SI BLOCCAVA.
* Trascinando la barra, ad OGNI micro-movimento del mouse (anche 60 volte al
* secondo) veniva assegnato un nuovo pv.currentTime. Un pero' non e'
* istantaneo: ogni assegnazione apre una ricerca del fotogramma, e le clip
* tagliate da noi sono webm senza indice, quindi la ricerca costa. Chiedere
* il fotogramma nuovo mentre la ricerca precedente e' ancora in corso le
* accavalla: dopo qualche decina il resta appeso in stato "seeking"
* e non risponde piu' — il video si pianta e da quel momento l'anteprima con
* i disegni sopra non si aggiorna piu'.
* Adesso c'e' una regola sola: UNA RICERCA ALLA VOLTA. Le posizioni chieste
* col mouse non vanno al video, vanno in una casella ("dove voglio andare").
* Un pendolo su requestAnimationFrame guarda quella casella e serve la
* richiesta solo quando il video ha finito la precedente. Se una ricerca si
* impianta comunque, un cane da guardia la sveglia; se proprio non si sblocca,
* ricarica il filmato a quel punto invece di lasciarti con l'app morta. */
let fxDoveAndare = null; // secondi assoluti chiesti dall'utente
let fxPendolo = null; // handle del requestAnimationFrame
let fxSeekPartito = 0; // quando e' cominciata la ricerca in corso
let fxSeekTentativi = 0;
function fxChiediFotogramma(secAssoluti) {
fxDoveAndare = secAssoluti;
if (fxPendolo == null) fxPendolo = requestAnimationFrame(fxGiroDelPendolo);
}
function fxFermaPendolo() {
if (fxPendolo != null) { cancelAnimationFrame(fxPendolo); fxPendolo = null; }
fxDoveAndare = null;
fxSeekPartito = 0;
fxSeekTentativi = 0;
}
function fxGiroDelPendolo() {
fxPendolo = null;
const pv = document.getElementById('fxPreviewVideo');
if (!pv || fxDoveAndare == null) { fxFermaPendolo(); return; }
if (pv.readyState < 1) { fxPendolo = requestAnimationFrame(fxGiroDelPendolo); return; }
if (pv.seeking) {
// Ricerca ancora in corso: si aspetta. Ma non all'infinito.
const passati = Date.now() - fxSeekPartito;
if (fxSeekPartito && passati > 1500) {
fxSeekTentativi++;
if (fxSeekTentativi <= 2) {
// Una scossa: si richiede lo stesso istante spostato di un pelo.
try { pv.currentTime = Math.max(0, pv.currentTime + 0.001); } catch (e) {}
fxSeekPartito = Date.now();
} else {
// Non ne esce: si ricarica il filmato e ci si rimette dove eravamo.
fxRianimaVideo(pv, fxDoveAndare);
fxFermaPendolo();
return;
}
}
fxPendolo = requestAnimationFrame(fxGiroDelPendolo);
return;
}
const meta = fxDoveAndare;
if (Math.abs(pv.currentTime - meta) < 0.015) { fxFermaPendolo(); return; }
try {
fxSeekPartito = Date.now();
fxSeekTentativi = 0;
// fastSeek, dove c'e', va al keyframe piu' vicino: e' molto piu' leggero
// ed e' esattamente quello che serve mentre si trascina.
if (typeof pv.fastSeek === 'function') pv.fastSeek(meta);
else pv.currentTime = meta;
} catch (e) {}
fxPendolo = requestAnimationFrame(fxGiroDelPendolo);
}
/* Ultima spiaggia: il non risponde piu'. Si ricarica lo stesso
* filmato e lo si riporta al punto giusto, invece di lasciare il pannello
* morto e costringere a chiudere e riaprire tutto. */
function fxRianimaVideo(pv, secAssoluti) {
const sorgente = pv.dataset.sorgente || pv.src;
if (!sorgente) return;
try {
pv.pause();
pv.addEventListener('loadeddata', () => {
try { pv.currentTime = secAssoluti; } catch (e) {}
renderFxPreviewOverlay();
fxAggiornaOrologio();
}, { once: true });
pv.src = sorgente;
pv.load();
} catch (e) {}
}
function fxSeekBar(ev) {
const barra = document.getElementById('fxTimelinePista');
if (!barra) return;
document.body.style.cursor = 'ew-resize';
const muovi = (e) => {
const r = barra.getBoundingClientRect();
const frac = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
fxEditorPlayhead = frac * fxTimelineDurata();
const head = document.getElementById('fxTimelineHead');
if (head) head.style.left = (frac * 100).toFixed(2) + '%';
renderFxMarkersOnly();
fxAggiornaOrologio();
const found = findTagAnywhere(fxEditorTagId);
if (found) fxChiediFotogramma(found.tag.start + fxEditorPlayhead);
};
muovi(ev);
const su = () => {
document.body.style.cursor = '';
window.removeEventListener('mousemove', muovi);
window.removeEventListener('mouseup', su);
window.removeEventListener('blur', su);
window.removeEventListener('pointercancel', su);
};
window.addEventListener('mousemove', muovi);
window.addEventListener('mouseup', su);
window.addEventListener('blur', su);
window.addEventListener('pointercancel', su);
ev.preventDefault();
}
/* LAVORARE SUL TEMPO SENZA IMPAZZIRE.
* Il cursore si muove a passi decisi (un secondo, due decimi, un fotogramma)
* e l'orologio dice sempre a che punto sei. Il video NON si ricarica: si
* sposta e basta, quindi il fotogramma cambia all'istante.
* Un fotogramma = 0,04s (25 al secondo): il passo piu' piccolo che serve a
* un allenatore per fermarsi sull'attimo esatto del blocco o del passaggio. */
function fxVaiA(sec) {
const found = findTagAnywhere(fxEditorTagId);
if (!found) return;
const durata = fxTimelineDurata();
fxEditorPlayhead = Math.max(0, Math.min(durata - 0.02, +sec || 0));
fxChiediFotogramma(found.tag.start + fxEditorPlayhead);
const head = document.getElementById('fxTimelineHead');
if (head) head.style.left = ((fxEditorPlayhead / durata) * 100).toFixed(2) + '%';
fxAggiornaOrologio();
renderFxPreviewOverlay();
}
function fxSalta(delta) { fxVaiA(fxEditorPlayhead + (+delta || 0)); }
// L'orologio sotto all'anteprima: dove sei / quanto dura il pezzo.
function fxAggiornaOrologio() {
const el = document.getElementById('fxOrologio');
if (!el) return;
const durata = fxTimelineDurata();
el.textContent = fxEditorPlayhead.toFixed(2).replace('.', ',') + 's / ' + durata.toFixed(1).replace('.', ',') + 's';
}
// Porta l'effetto DOVE SEI FERMO: fermi la clip sul fotogramma buono, premi
// ⤓ e quell'effetto comincia li'. E' il gesto opposto (e complementare) a
// "l'effetto nuovo nasce dove sei".
function fxPortaQui(i) {
const found = findTagAnywhere(fxEditorTagId);
if (!found) return;
const fx = tagFx(found.tag)[i];
if (!fx) return;
fx.t = Math.max(0, Math.min(Math.max(0.1, fxTimelineDurata() - 0.2), fxEditorPlayhead));
// Il ritaglio e' una FOTO di un istante: portandolo qui, la foto si rifa'
// da questo fotogramma, altrimenti resterebbe quella di prima.
if (fx.type === 'cutout' && fx.snap) fxCatturaRitaglio(fx);
renderFxEditor();
scheduleAutosave();
}
// Tastiera, mentre il pannello e' aperto: spazio, frecce, virgola e punto.
// Non ruba i tasti quando stai scrivendo dentro a una casella.
function fxTastiera(e) {
if (!fxEditorTagId) return;
const ov = document.getElementById('fxOverlay');
if (!ov || !ov.classList.contains('show')) return;
const dentroCampo = e.target && /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName);
if (dentroCampo) return;
if (e.code === 'Space') { e.preventDefault(); fxTogglePreviewPlay(); return; }
if (e.key === 'ArrowLeft') { e.preventDefault(); fxSalta(e.shiftKey ? -1 : -0.2); return; }
if (e.key === 'ArrowRight') { e.preventDefault(); fxSalta(e.shiftKey ? 1 : 0.2); return; }
if (e.key === ',') { e.preventDefault(); fxSalta(-0.04); return; }
if (e.key === '.') { e.preventDefault(); fxSalta(0.04); return; }
}
window.addEventListener('keydown', fxTastiera);
// Trascinamento della barretta di un elemento: al centro lo sposta, sul
// bordo destro ne cambia la durata.
function fxBarDown(ev, i, modo) {
ev.preventDefault();
ev.stopPropagation();
const found = findTagAnywhere(fxEditorTagId);
if (!found) return;
const fx = tagFx(found.tag)[i];
if (!fx) return;
const barra = document.getElementById('fxTimeline');
const pista = document.getElementById('fxTimelinePista') || barra;
const r = pista.getBoundingClientRect();
const durata = fxTimelineDurata();
const x0 = ev.clientX;
const t0 = +fx.t || 0, d0 = +fx.dur || 0.5;
fxDrag = true;
const muovi = (e) => {
const delta = ((e.clientX - x0) / r.width) * durata;
if (modo === 'move') {
fx.t = Math.max(0, Math.min(durata - 0.2, t0 + delta));
} else {
fx.dur = Math.max(0.2, Math.min(durata - (+fx.t || 0) + 4, d0 + delta));
}
const lane = barra.querySelector(`.fxtl-lane[data-i="${i}"] .fxtl-bar`);
if (lane) {
lane.style.left = Math.max(0, Math.min(100, ((+fx.t) / durata) * 100)).toFixed(2) + '%';
lane.style.width = Math.max(1.5, ((+fx.dur) / durata) * 100).toFixed(2) + '%';
const et = lane.querySelector('.fxtl-dur');
if (et) et.textContent = (+fx.dur).toFixed(1) + 's';
}
};
const su = () => {
window.removeEventListener('mousemove', muovi);
window.removeEventListener('mouseup', su);
fxDrag = false;
renderFxEditor();
scheduleAutosave();
};
window.addEventListener('mousemove', muovi);
window.addEventListener('mouseup', su);
}
function renderFxEditor() {
const box = document.getElementById('fxEditorBody');
if (!box) return;
// ogni ricostruzione del pannello ricrea il : il ciclo di
// riproduzione precedente va spento, altrimenti resta a girare a vuoto
if (fxPreviewLoop) { cancelAnimationFrame(fxPreviewLoop); fxPreviewLoop = null; }
const found = findTagAnywhere(fxEditorTagId);
if (!found) { box.innerHTML = ''; return; }
const { clip, tag } = found;
const durata = tag.end - tag.start;
const lista = tagFx(tag);
const titolo = document.getElementById('fxEditorTitle');
if (titolo) titolo.textContent = (tag.label || tag.playerLabel || t('tag.toClassify')) + ' · ' + durata.toFixed(1) + 's';
// Il gia' caricato su QUESTO pezzo si mette da parte adesso (prima
// che innerHTML lo butti via) e si rimette dentro in fondo alla funzione:
// e' cosi' che il fotogramma sotto gli occhi non cambia mai.
const pvVecchio = document.getElementById('fxPreviewVideo');
let vecchioVideo = null;
if (pvVecchio && pvVecchio.dataset.tagId === (fxEditorTagId || '') && pvVecchio.readyState >= 1) {
try { pvVecchio.pause(); } catch (e) {}
vecchioVideo = pvVecchio;
vecchioVideo.remove();
}
const righe = lista.map((fx, i) => {
const meta = FX_TYPES[fx.type] || { icon: '•', labelKey: 'fx.slow' };
const extra = fx.type === 'slow'
? `${t('fx.rate')}${Math.round((fx.rate || 0.35) * 100)}% `
: fx.type === 'lente'
? `${t('fx.radius')}
${t('fx.lenteZoom')}${(+(fx.zoom != null ? fx.zoom : 2)).toFixed(1)}× `
: (fx.type === 'spotlight' || fx.type === 'circle')
? `${t('fx.radius')} `
: fx.type === 'text'
? `
${t('fx.textSize')} `
: fx.type === 'arrow'
? `${t('fx.arrowCurv')} `
: fx.type === 'cutout'
? `${t('fx.cutoutW')}
${t('fx.cutoutH')}
${t('fx.cutoutMoto')}
${escapeHtml(t('fx.motoVai'))}
${escapeHtml(t('fx.motoFermo'))}
${escapeHtml(t('fx.motoVaiTorna'))}
${fx.moto !== 'fermo' ? `${t('fx.cutoutHold')}${fxCutoutHold(fx).toFixed(1)}s ` : ''}
📸 ${escapeHtml(t('fx.cutoutSnap'))}
↺ ${escapeHtml(t('fx.cutoutReset'))} `
: fx.type === 'wall'
? `${t('fx.wallLen')}
${t('fx.wallThick')}
${t('fx.wallAng')}${Math.round(fx.ang || 0)}° `
: '';
const COLORI = ['#ff2f2f', '#ffd400', '#2f7dff', '#4dc3ff', '#22c55e', '#ffffff'];
const colore = (fx.type === 'circle' || fx.type === 'arrow' || fx.type === 'wall' || fx.type === 'cutout' || fx.type === 'spotlight' || fx.type === 'text' || fx.type === 'lente')
? `${COLORI.map(c => ` `).join('')}
`
: '';
// La scritta non è uno "strumento da disegno" (prima la si scrive), ma si
// posiziona e si trascina come tutto il resto.
const posiziona = (FX_PLACEABLE[fx.type] || fx.type === 'text')
? `✥ `
: '';
return `
${meta.icon}
${t(meta.labelKey)}${fx.pending ? ' ' + escapeHtml(t('fx.pending')) + ' ' : ''}
${t('fx.at')} s
${t('fx.dur')} s
${extra}
${colore}
${posiziona}
⤓
✕
`;
}).join('');
// Niente più segnaposto disegnati col CSS (un cerchietto, una linea): sopra
// il fotogramma si disegna ESATTAMENTE quello che finirà nel video —
// cilindro di luce, cerchio, freccia, muro, scritta. Prima nell'editor si
// vedeva solo un cerchio sbiadito e sembrava che cerchi e frecce non
// funzionassero.
const marker = ' ';
// Barra del tempo del pezzo: una corsia per ogni elemento, larga quanto
// dura e messa dove comincia — si vede a colpo d'occhio quanto sta in campo
// ciascuno. Si trascina per spostarlo, si tira il bordo destro per
// allungarlo, e il cursore rosso scorre la clip mostrando cosa è acceso in
// quell'istante.
const scala = (x) => Math.max(0, Math.min(100, (x / (durata || 1)) * 100));
const corsie = lista.map((fx, i) => {
const meta = FX_TYPES[fx.type] || { icon: '•' };
const left = scala(+fx.t || 0);
const wdt = Math.max(1.5, scala(+fx.dur || 0.5));
return `
${meta.icon}
${(+fx.dur).toFixed(1)}s
`;
}).join('');
const tacche = [];
const passo = durata > 8 ? 2 : 1;
for (let sec = 0; sec <= durata + 0.001; sec += passo) tacche.push(` ${sec.toFixed(0)}s `);
box.innerHTML = `
${escapeHtml(t('fx.moveHint'))}
${marker}
▶ ${escapeHtml(t('fx.playPreview'))}
⬇ ${escapeHtml(t('fx.exportPiece'))}
🗑 ${escapeHtml(t('fx.elimina'))}
${escapeHtml(t('fx.playHint'))}
${escapeHtml(t('fx.esportaFerma'))}
${escapeHtml(t('fx.timeLabel'))}
⏮
−1s
−0,2s
‹
0,00s
›
+0,2s
+1s
⏭
${escapeHtml(t('fx.keysHint'))}
${escapeHtml(t('fx.shiftTitle'))}
⏪ −1s
◀ −0,2s
+0,2s ▶
+1s ⏩
${fmtTime(tag.start)} → ${fmtTime(tag.end)}
✋ ${escapeHtml(t('fx.toolMove'))}
${Object.keys(FX_TYPES).map(k => FX_PLACEABLE[k]
? `${FX_TYPES[k].icon} ${t(FX_TYPES[k].labelKey)} `
: `${FX_TYPES[k].icon} + ${t(FX_TYPES[k].labelKey)} `).join('')}
${escapeHtml(fxStrumento ? t('fx.toolHintOn', { nome: t(FX_TYPES[fxStrumento].labelKey) }) : t('fx.toolHintOff'))}
${tacche.join('')}
${corsie || `
${t('fx.empty')}
`}
${righe || ''}
`;
requestAnimationFrame(renderFxPreviewOverlay);
// IL VIDEO SI RIMETTE, NON SI RIFA'. Ogni ritocco (aggiungere un effetto,
// cambiare un numero, trascinare una barretta) ricostruisce questo
// pannello: prima dentro ci finiva un NUOVO, che ripartiva da capo
// e si piazzava a meta' pezzo. Risultato: fermavi la clip sul fotogramma
// giusto, toccavi qualcosa e ti ritrovavi indietro.
// Adesso il di prima viene messo da parte prima di ricostruire e
// rimesso dentro subito dopo: stesso elemento, stesso filmato in memoria,
// STESSO FOTOGRAMMA. Non si ricarica e non si sposta di un centesimo.
const segnaposto = document.getElementById('fxPreviewVideo');
if (vecchioVideo && segnaposto) {
segnaposto.replaceWith(vecchioVideo);
fxAggiornaOrologio();
return;
}
const pv = segnaposto;
if (pv) {
pv.dataset.tagId = fxEditorTagId || '';
pv.addEventListener('loadeddata', () => renderFxPreviewOverlay(), { once: true });
pv.addEventListener('seeked', () => { renderFxPreviewOverlay(); fxAggiornaOrologio(); });
const avvia = (url) => {
if (!url) {
const box2 = document.querySelector('.fx-preview');
if (box2 && !box2.querySelector('.fx-no-video')) {
// Due casi diversi, due spiegazioni diverse: il file spostato si
// ritrova con un bottone, senza rifare niente del lavoro.
const spostato = !!(clip.percorsoDisco && clip.fileSpostato);
const testo = spostato
? t('editvideo.fileSpostato', { nome: clip.name || '', dove: clip.percorsoDisco })
: t('editvideo.clipMancanteVecchia', { nome: clip.name || '' });
box2.insertAdjacentHTML('beforeend', `
${escapeHtml(testo)}
${escapeHtml(t('editvideo.ritrovaFile'))}
${escapeHtml(t('editvideo.cartellaFilmati'))}
${escapeHtml(t('editvideo.cartellaSpiega'))}
`);
}
return;
}
pv.dataset.sorgente = url;
pv.src = url;
// Alla PRIMA apertura ci si mette dove sta il cursore (che vale 0
// quando il pezzo si apre da zero), non a meta' pezzo a caso.
pv.addEventListener('loadedmetadata', () => {
try { pv.currentTime = tag.start + Math.min(fxEditorPlayhead, durata - 0.05); } catch (e) {}
fxAggiornaOrologio();
}, { once: true });
};
if (clip.url) avvia(clip.url);
else evAssicuraUrlClip(clip).then(avvia);
}
}
/* Esporta SOLO il pezzo che si sta editando, con tutti i suoi disegni
* incisi sopra (fascio di luce, cerchi, frecce, muro, scritte, rallenty,
* fermo immagine): stesso motore del montaggio, con un solo segmento. Così
* non serve più passare dal montaggio di tutta la squadra per portarsi a
* casa la clip appena sistemata. */
/* VIA QUESTO PEZZO, DEFINITIVAMENTE.
* Da dentro all'editor non c'era modo di buttarlo: si poteva solo chiudere e
* andarlo a cercare nella scheda Video. Qui si cancella il pezzo dov'e' —
* con i suoi effetti, il suo posto nel montaggio e il suo taglio in coda. Il
* FILMATO resta: si butta il pezzo, non la partita. */
function fxEliminaPezzo() {
const found = findTagAnywhere(fxEditorTagId);
if (!found) return;
const { clip, tag } = found;
const nome = tag.label || tag.playerLabel || t('montage.noTag');
showConfirmModal(t('fx.eliminaConferma', { nome }), () => {
if (tag.cutUrl && String(tag.cutUrl).indexOf('blob:') === 0) {
try { URL.revokeObjectURL(tag.cutUrl); } catch (e) { /* gia' rilasciato */ }
}
segnaEliminato(tag.id);
clip.tags = (clip.tags || []).filter(x => x.id !== tag.id);
cutQueue = cutQueue.filter(job => job.tagId !== tag.id);
if (vt.pendingTagId === tag.id) vt.pendingTagId = null;
if (Array.isArray(vt.montageOrder)) vt.montageOrder = vt.montageOrder.filter(x => x !== tag.id);
closeFxEditor();
scheduleAutosave();
renderAll();
toast(t('fx.eliminato', { nome }));
});
}
/* =====================================================================
* PRESENTAZIONE — il film della riunione, proiettato dal vivo.
*
* Non e' un montaggio: qui NON si incide niente. La scaletta e' un elenco
* ordinato di cose da far vedere una dietro l'altra — i montaggi che hai
* gia' esportato (letti dalla cartella MONTAGGI, quindi senza doverli
* reimportare), i cartelli col titolo della parte, e i video o le foto che
* peschi dal disco. Premi Proietta e parte a schermo intero, subito: niente
* attesa, perche' non c'e' nessuna registrazione di mezzo.
* ===================================================================== */
function ensurePresentazione(team) {
if (!team) return [];
if (!Array.isArray(team.presentazione)) team.presentazione = [];
return team.presentazione;
}
function presCartellaMontaggi(team) {
if (!NATIVO || !DB.folderRefPath || !team) return '';
return [DB.folderRefPath].concat(cartelleDelMontaggio(team)).join('/');
}
let presDisponibiliCache = null;
async function presLeggiMontaggi(team) {
const dir = presCartellaMontaggi(team);
if (!dir) return [];
try {
const voci = await window.VDM.fs.elenco(dir) || [];
return voci.filter(v => !v.cartella && /\.(webm|mp4|mov|m4v)$/i.test(v.nome))
.sort((a, b) => (b.mtime || 0) - (a.mtime || 0))
.map(v => ({ nome: v.nome, percorso: dir + '/' + v.nome, size: v.size || 0 }));
} catch (e) { return []; }
}
async function presRileggiCartella() {
presDisponibiliCache = null;
await renderPresentazioneSection();
toast(t('pres.riletta'));
}
/* Da che parte dell'allenamento viene questo montaggio: il nome del file lo
* dice gia' (li scrive nomeFileMontaggio), quindi l'elenco si raggruppa da
* solo invece di essere una lista piatta di quaranta file. */
function presArgomentoDelFile(nome) {
const n = String(nome || '').toUpperCase();
if (/PUNTI[-_ ]?DI[-_ ]?FORZA|PUNTI[-_ ]?FORZA/.test(n)) return 'puntiforza';
if (/PUNTI[-_ ]?DEBOLI/.test(n)) return 'puntideboli';
if (/DIFES|DEFENS/.test(n)) return 'difesa';
if (/INDIVIDUAL/.test(n)) return 'individuali';
if (/TAG[-_ ]?VELOC|RAPID/.test(n)) return 'tagveloci';
if (/ATTACC|OFFENS|GIOCH/.test(n)) return 'attacco';
return 'altro';
}
function presNomePulito(nome) {
return String(nome || '').replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim();
}
function presPeso(bytes) {
if (!bytes) return '';
const mb = bytes / (1024 * 1024);
return (mb >= 1 ? mb.toFixed(0) + ' MB' : Math.max(1, Math.round(bytes / 1024)) + ' KB');
}
function presAggiungiVoce(voce) {
const team = activeTeam();
if (!team) { toast(t('toast.selectTeamFirst')); return; }
ensurePresentazione(team).push(Object.assign({ id: nextId('pres') }, voce));
scheduleAutosave();
renderPresentazioneSection();
}
function presAggiungiMontaggio(nome, percorso, size) {
presAggiungiVoce({ tipo: 'montaggio', nome: presNomePulito(nome), percorso, size: +size || 0 });
toast(t('pres.aggiunto', { nome: presNomePulito(nome) }));
}
function presAggiungiCartello() {
showPromptModal(t('pres.cartelloChiedi'), t('pres.cartelloPh'), (testo) => {
const titolo = (testo || '').trim();
if (!titolo) return;
presAggiungiVoce({ tipo: 'cartello', nome: titolo, sec: 4 });
});
}
/* LA COPERTINA: la prima schermata, intestata all'avversaria che hai
* studiato. Prende da sola nome, competizione e data dal foglio di scouting:
* non c'e' niente da riscrivere. */
function presAggiungiCopertina() {
const team = activeTeam();
const opp = team && activeOpponent(team);
presAggiungiVoce({ tipo: 'copertina', nome: (opp && opp.name) || (team && team.name) || '', sec: 6 });
}
/* UNA SCHEDA DELLO SCOUTING REPORT, disegnata grande per la proiezione:
* stesso ordine e stessi titoli del foglio, ma leggibile dal fondo della
* sala invece che stampata in corpo 11. */
const PRES_SCHEDE = [
{ chiave: 'forza', etichetta: 'relazione.boxForza' },
{ chiave: 'vincere', etichetta: 'relazione.boxVincere' },
{ chiave: 'partita', etichetta: 'relazione.boxPartita' },
{ chiave: 'strategie', etichetta: 'relazione.boxStrategie' },
{ chiave: 'tiro3', etichetta: 'relazione.boxMiglior3pt' },
{ chiave: 'liberi', etichetta: 'relazione.boxPeggiorLiberi' },
];
function presAggiungiScheda() {
const team = activeTeam();
const opp = team && activeOpponent(team);
if (!opp) { toast(t('toast.selectTeamFirst')); return; }
const righe = PRES_SCHEDE.map(x => titoloBox(opp, x.chiave, t(x.etichetta))).join('\n');
showPromptModal(t('pres.schedaChiedi') + '\n' + righe, t('pres.schedaPh'), (risposta) => {
const n = parseInt(String(risposta || '').trim(), 10);
const sc = PRES_SCHEDE[(isFinite(n) && n >= 1 && n <= PRES_SCHEDE.length) ? n - 1 : 0];
presAggiungiVoce({ tipo: 'scheda', scheda: sc.chiave, nome: titoloBox(opp, sc.chiave, t(sc.etichetta)), sec: 12 });
}, '1');
}
/* LA SCHEDA DELLA GIOCATRICE: foto, numero, medie e le sue quattro
* caratteristiche — da far vedere PRIMA del video dei suoi individuali,
* cosi' la squadra sa chi sta guardando e cosa deve notare. */
function presRosaPerScheda() {
const team = activeTeam();
const opp = team && activeOpponent(team);
const lg = activeLeague();
// le giocatrici dell'avversaria stanno nel roster di lega con il suo nome
let rosa = [];
if (opp && lg) {
const key = (opp.name || '').trim().toLowerCase();
/* SI CERCA IL ROSTER CHE HA DAVVERO LE GIOCATRICI.
* Prima si prendeva il primo record con quel nome e ci si fermava li':
* se in lega c'era una squadra omonima ancora vuota (capita spesso: la
* casella creata e mai compilata), la scheda giocatrice non trovava
* nessuno pur avendo il roster pieno due righe piu' sotto. */
const candidati = []
.concat((lg.teams || []).filter(x => x && (x.name || '').trim().toLowerCase() === key))
.concat((lg.oppRosters || []).filter(x => x && (x.name || '').trim().toLowerCase() === key));
const conGiocatrici = candidati.find(x => Array.isArray(x.players) && x.players.some(pl => pl && (pl.name || '').trim()));
const sq = conGiocatrici || candidati[0];
if (sq && Array.isArray(sq.players)) rosa = sq.players.filter(x => x && (x.name || '').trim());
}
if (!rosa.length && team && Array.isArray(team.players)) rosa = team.players.filter(x => x && (x.name || '').trim());
return rosa;
}
function presAggiungiGiocatrice() {
const rosa = presRosaPerScheda();
if (!rosa.length) { toast(t('pres.nessunaGiocatrice')); return; }
const elenco = rosa.map((pl, i) => (i + 1) + ') ' + (pl.number ? '#' + pl.number + ' ' : '') + pl.name).join('\n');
showPromptModal(t('pres.giocatriceChiedi') + '\n' + elenco, '1', (risposta) => {
const n = parseInt(String(risposta || '').trim(), 10);
const pl = rosa[(isFinite(n) && n >= 1 && n <= rosa.length) ? n - 1 : 0];
presAggiungiVoce({ tipo: 'giocatrice', playerId: pl.id,
nome: (pl.number ? '#' + pl.number + ' ' : '') + pl.name, sec: 8 });
}, '1');
}
function presTrovaGiocatrice(id) {
return presRosaPerScheda().find(x => x.id === id) || null;
}
/* Il contenuto di una scheda, preso dal foglio di scouting cosi' com'e'. */
function presContenutoScheda(opp, chiave) {
const sr = ensureScoutingReport(opp);
const sotto = (k, i, testo) => ({ eti: sottotitoloBox(opp, chiave + ':' + i, t(k)), testo: testo });
if (chiave === 'forza' || chiave === 'partita') {
const b = (chiave === 'forza') ? sr.puntiForza : sr.partita;
const tre = [b.tiro3pt && b.tiro3pt.media, b.tiro3pt && b.tiro3pt.tiratrici].filter(Boolean).join('\n');
return [sotto('relazione.subForzaMentale', 0, b.forzaMentale), sotto('relazione.subDifesa', 1, b.difesa),
sotto('relazione.subAttacco', 2, b.attacco), sotto('relazione.subTiro3pt', 3, tre),
sotto('relazione.subUnoUno', 4, b.unoUno)];
}
if (chiave === 'vincere') {
return [sotto('relazione.subForzaMentale', 0, sr.perVincere.forzaMentale),
sotto('relazione.subAttacco', 1, sr.perVincere.attacco),
sotto('relazione.subDifesa', 2, sr.perVincere.difesa)];
}
if (chiave === 'strategie') return [{ eti: '', testo: sr.strategie.difesa }];
if (chiave === 'tiro3') return [{ eti: '', testo: sr.migliorTiratrice3pt }];
if (chiave === 'liberi') return [{ eti: '', testo: sr.peggiorTiratriceTL }];
return [];
}
async function presAggiungiDaDisco(tipo) {
if (!NATIVO) { toast(t('editvideo.ritrovaSoloApp')); return; }
const filtri = (tipo === 'foto')
? [{ name: 'Immagini', extensions: ['jpg', 'jpeg', 'png', 'heic', 'webp'] }]
: [{ name: 'Video', extensions: ['mp4', 'webm', 'mov', 'm4v', 'avi', 'mkv'] }];
let percorsi = [];
try { percorsi = await window.VDM.scegliFile({ titolo: t(tipo === 'foto' ? 'pres.aggFoto' : 'pres.aggVideo'), multipli: true, filtri }) || []; }
catch (e) { percorsi = []; }
percorsi.forEach(pp => {
const nome = pp.split(/[\\/]/).pop();
presAggiungiVoce({ tipo: (tipo === 'foto' ? 'foto' : 'video'), nome, percorso: pp, sec: tipo === 'foto' ? 6 : 0 });
});
if (percorsi.length) toast(t('pres.aggiuntiN', { n: percorsi.length }));
}
function presMuovi(id, delta) {
const team = activeTeam(); if (!team) return;
const el = ensurePresentazione(team);
const i = el.findIndex(x => x.id === id);
const j = i + delta;
if (i < 0 || j < 0 || j >= el.length) return;
const tmp = el[i]; el[i] = el[j]; el[j] = tmp;
scheduleAutosave();
renderPresentazioneSection();
}
function presTogli(id) {
const team = activeTeam(); if (!team) return;
team.presentazione = ensurePresentazione(team).filter(x => x.id !== id);
scheduleAutosave();
renderPresentazioneSection();
}
/* =========================================================================
* ESPORTA LA PRESENTAZIONE IN UN FILE
*
* Proiettare dal vivo va bene quando il computer e' li' con te. Ma la
* presentazione serve anche da mandare: al vice che non c'era, alla
* giocatrice infortunata, o semplicemente per averla pronta su una chiavetta
* quando nel palazzetto il proiettore lo attacchi a un altro portatile.
*
* Esce un unico filmato con dentro tutta la scaletta nell'ordine giusto: le
* pagine disegnate (copertina, cartelli, schede, giocatrici) incise per i
* loro secondi, le foto uguale, i montaggi per intero. Le pagine le disegna
* LA STESSA funzione che disegna la proiezione, quindi non possono venire
* diverse da quello che hai visto sullo schermo.
* ====================================================================== */
async function esportaPresentazione() {
const team = activeTeam();
if (!team) { toast(t('toast.selectTeamFirst')); return; }
const voci = ensurePresentazione(team);
if (!voci.length) { toast(t('pres.scalettaVuota')); return; }
if (montageRunning) { toast(t('toast.montageRunning')); return; }
const btn = document.getElementById('presEsportaBtn');
if (btn) btn.disabled = true;
try {
// le durate dei filmati servono per sapere dove finisce ognuno
await Promise.all(voci.filter(v => v.percorso && (v.tipo === 'montaggio' || v.tipo === 'video'))
.map(v => presDurataFile(v.percorso)));
// le foto vanno decodificate PRIMA: se si comincia a incidere mentre
// sono ancora per aria, escono riquadri vuoti
const foto = {};
await Promise.all(voci.filter(v => v.tipo === 'foto' && v.percorso).map(v => new Promise((res) => {
const im = new Image();
im.onload = () => { foto[v.percorso] = im; res(); };
im.onerror = () => res();
im.src = urlDaPercorso(v.percorso) || '';
setTimeout(res, 5000);
})));
const segments = [];
const mancanti = [];
voci.forEach(v => {
if (v.tipo === 'montaggio' || v.tipo === 'video') {
const url = urlDaPercorso(v.percorso);
const dur = _presDurate[v.percorso] || 0;
if (!url || !dur) { mancanti.push(v.nome || '?'); return; }
segments.push({ src: url, start: 0, end: dur, label: v.nome || '', fx: [] });
return;
}
if (v.tipo === 'foto') {
const im = foto[v.percorso];
if (!im) { mancanti.push(v.nome || '?'); return; }
segments.push({
sec: presDurataVoce(v), label: v.nome || '',
disegna: (ctx, W, H) => disegnaImmagineCoprendo(ctx, im, W, H)
});
return;
}
// pagina disegnata: copertina, cartello, scheda scouting, giocatrice
segments.push({
sec: presDurataVoce(v), label: v.nome || '',
disegna: (ctx, W, H) => {
const fuori = { ctx, W, H };
if (v.tipo === 'copertina') presDisegnaCopertina(v, fuori);
else if (v.tipo === 'scheda') presDisegnaScheda(v, fuori);
else if (v.tipo === 'giocatrice') presDisegnaGiocatrice(v, fuori);
else presDisegnaCartello(v, fuori);
}
});
});
if (!segments.length) { toast(t('pres.nienteDaEsportare')); if (btn) btn.disabled = false; return; }
montageRunning = true;
/* L'ESPORTAZIONE DELLA PRESENTAZIONE HA UNA STRADA TUTTA SUA e non passa
* da exportTeamMontage: senza questo, l'avanzamento finiva solo dentro al
* pannello effetti (che qui non e' aperto) e premendo Esporta non si
* vedeva assolutamente niente — "non mi esce nessun visualizzatore dello
* stato". Adesso alimenta anche la fascia in basso a destra, la stessa
* dei montaggi. */
let _presFr = 0, _presMsg = t('pres.esportoInCorso');
const avanti = (testo, frazione) => {
if (testo != null) _presMsg = testo;
if (frazione != null) _presFr = frazione;
fxAvanza(_presMsg, _presFr);
montFascia(_presMsg, _presFr, false);
};
avanti(t('pres.esportoInCorso'), 0);
let esito = null;
if (veloceUsabile()) {
try {
esito = await esportaMontaggioVeloce(segments, team, await caricaLogoSquadra(team),
(msg) => avanti(msg, null), (fr) => avanti(t('pres.esportoInCorso'), fr));
} catch (e) {
console.warn('VDM: presentazione, motore veloce in errore —', e && e.message);
}
}
if (!montageRunning) { if (btn) btn.disabled = false; montFasciaChiudi(); return; } // fermata a meta'
if (esito && esito.blob && esito.blob.size > 1000) {
montageRunning = false;
const nome = (team.name || 'squadra').replace(/[^a-z0-9]+/gi, '_') + '_presentazione';
await concludiMontaggio(team, esito.blob, esito.ext, nome,
(esito.saltati || []).concat(mancanti), segments.length, () => {});
} else {
/* Il motore veloce non ce l'ha fatta: si rifa' con quello storico, che
* incide in tempo reale ma sa fare le stesse pagine. */
montageRunning = false;
await exportTeamMontage(segments, (team.name || 'squadra').replace(/[^a-z0-9]+/gi, '_') + '_presentazione');
}
if (mancanti.length) toast(t('pres.saltati', { lista: mancanti.slice(0, 3).join(', ') }));
} catch (e) {
toast(t('pres.esportoFallito'));
montFascia(t('pres.esportoFallito'), 1, true);
console.warn('VDM: esportazione presentazione —', e && e.message);
} finally {
montageRunning = false;
if (btn) btn.disabled = false;
}
}
function presSvuota() {
const team = activeTeam(); if (!team) return;
if (!ensurePresentazione(team).length) return;
showConfirmModal(t('pres.svuotaChiedi'), () => {
team.presentazione = [];
scheduleAutosave();
renderPresentazioneSection();
});
}
/* DUE SECONDI, NON DI PIU'.
* Le schermate ferme — copertina, cartello, scheda scouting, scheda
* giocatrice, foto — restavano in aria quattro secondi. In riunione sono
* lunghissimi: si legge in due, e gli altri due si aspetta guardando il
* muro. Adesso il tetto e' due secondi, e chi ne vuole meno lo puo' ancora
* scegliere. */
const PRES_SEC_MAX = 2;
function presDurataVoce(v) {
if (v.tipo === 'cartello' || v.tipo === 'foto' || v.tipo === 'copertina' || v.tipo === 'scheda' || v.tipo === 'giocatrice') {
return Math.max(1, Math.min(PRES_SEC_MAX, +v.sec || PRES_SEC_MAX));
}
return 0; // video: dura quanto dura
}
/* Quanto dura un filmato: si legge una volta e si tiene, serve a dare alla
* barra del tempo le proporzioni vere (un pezzo da un minuto deve essere
* largo il doppio di uno da trenta secondi, come su iMovie). */
const _presDurate = {};
function presDurataFile(percorso) {
if (!percorso) return Promise.resolve(0);
if (_presDurate[percorso] != null) return Promise.resolve(_presDurate[percorso]);
return new Promise((res) => {
const v = document.createElement('video');
v.preload = 'metadata';
const fine = (d) => { _presDurate[percorso] = d; res(d); };
v.onloadedmetadata = () => fine(isFinite(v.duration) ? v.duration : 0);
v.onerror = () => fine(0);
setTimeout(() => fine(isFinite(v.duration) ? v.duration : 0), 4000);
v.src = urlDaPercorso(percorso) || percorso;
});
}
function presMmSs(sec) {
const s2 = Math.max(0, Math.round(+sec || 0));
return Math.floor(s2 / 60) + ':' + String(s2 % 60).padStart(2, '0');
}
function presSecondiVoce(v) {
if (v.tipo === 'montaggio' || v.tipo === 'video') return _presDurate[v.percorso] || 0;
return presDurataVoce(v);
}
let presScelto = -1;
async function renderPresentazioneSection() {
const linea = document.getElementById('presScaletta');
const disp = document.getElementById('presDisponibili');
if (!linea || !disp) return;
const team = activeTeam();
const eti = document.getElementById('presNomeSquadra');
if (eti) eti.textContent = team ? '· ' + (team.name || '') : '';
const voci = ensurePresentazione(team);
const inCol = document.getElementById('presColoreInput');
if (inCol) inCol.value = presColore(team);
// le durate dei filmati servono per le proporzioni della barra
await Promise.all(voci.filter(v => v.percorso && (v.tipo === 'montaggio' || v.tipo === 'video'))
.map(v => presDurataFile(v.percorso)));
const icona = { montaggio: '🎬', cartello: '🏷️', video: '🎞️', foto: '🖼️', copertina: '📕', scheda: '📊', giocatrice: '👤' };
const totale = voci.reduce((n, v) => n + presSecondiVoce(v), 0);
const massimo = Math.max(8, ...voci.map(presSecondiVoce));
linea.innerHTML = voci.length ? voci.map((v, i) => {
const sec = presSecondiVoce(v);
// larghezza proporzionale, con un minimo perche' resti leggibile
const larg = Math.round(74 + (sec / massimo) * 210);
return `
✕
${icona[v.tipo] || ''} ${escapeHtml(t('pres.tipo_' + v.tipo))}
${escapeHtml(v.nome || '')}
${sec ? presMmSs(sec) : '—'}
`;
}).join('') : `${escapeHtml(t('pres.scalettaVuota'))}
`;
const np = document.getElementById('presTotPezzi'); if (np) np.textContent = voci.length;
const nd = document.getElementById('presTotDurata'); if (nd) nd.textContent = presMmSs(totale);
/* Lo schermo non deve restare nero ad aspettare un click che il coach non
* sa di dover fare: appena c'e' qualcosa in scaletta si mostra il primo
* pezzo (o quello scelto, se si sta gia' lavorando su uno). */
if (voci.length) {
const quale = (presScelto >= 0 && presScelto < voci.length) ? presScelto : 0;
try { presAnteprima(quale); } catch (e) { console.error('anteprima', e); }
} else {
presScelto = -1;
const vuoto = document.getElementById('presAntVuoto');
if (vuoto) vuoto.style.display = '';
['presAntVideo', 'presAntTela', 'presAntFoto'].forEach(id => {
const n2 = document.getElementById(id); if (n2) n2.style.display = 'none';
});
const nome2 = document.getElementById('presAntNome'); if (nome2) nome2.textContent = '';
}
try { presAdattaAltezza(); } catch (e) {}
// l'aiuto lungo serve solo quando la scaletta e' ancora vuota: dopo ruba
// righe preziose al player.
const aiuto = document.querySelector('#presentazioneSection p.muted');
if (aiuto) aiuto.style.display = voci.length ? 'none' : '';
if (!team) { disp.innerHTML = ''; return; }
if (!presCartellaMontaggi(team)) {
disp.innerHTML = `${escapeHtml(t('pres.senzaCartella'))}
`;
return;
}
if (!presDisponibiliCache) presDisponibiliCache = await presLeggiMontaggi(team);
const lista = presDisponibiliCache;
const dir = presCartellaMontaggi(team);
if (!lista.length) {
/* Vuota: si dice ANCHE IN QUALE CARTELLA si e' guardato. Cambiando la
* cartella di riferimento i montaggi vecchi restano in quella di prima e
* qui non compaiono piu': senza il percorso sotto gli occhi sembra che
* siano spariti, e non c'e' modo di capire dove sono andati a finire. */
disp.innerHTML = `
${escapeHtml(t('pres.nessunMontaggio'))}
${escapeHtml(t('pres.cercatoIn', { dove: dir || '' }))}
`;
return;
}
const gruppi = {};
lista.forEach(m => { const a2 = presArgomentoDelFile(m.nome); (gruppi[a2] = gruppi[a2] || []).push(m); });
const ordine = ['puntiforza', 'attacco', 'difesa', 'individuali', 'tagveloci', 'puntideboli', 'altro'];
disp.innerHTML = ordine.filter(a2 => gruppi[a2]).map(a2 => `
${escapeHtml(PV_ARGOMENTI[a2] ? t(PV_ARGOMENTI[a2].chiave) : t('pres.argAltro'))}
${gruppi[a2].map(m => `
${escapeHtml(presNomePulito(m.nome))}${presPeso(m.size)}
+
`).join('')}
`).join('');
}
/* ---- TRASCINAMENTO, come su iMovie ---- */
let _presTrascinato = null; // {da:indice} oppure {nuovo:{...}}
function presDragInizio(ev, i) {
_presTrascinato = { da: i };
try { ev.dataTransfer.effectAllowed = 'move'; ev.dataTransfer.setData('text/plain', String(i)); } catch (e) {}
}
function presDragDallaLibreria(ev, nome, percorso, size) {
_presTrascinato = { nuovo: { tipo: 'montaggio', nome: presNomePulito(nome), percorso, size: +size || 0 } };
try { ev.dataTransfer.effectAllowed = 'copy'; ev.dataTransfer.setData('text/plain', 'nuovo'); } catch (e) {}
}
function presDragSopra(ev) {
ev.preventDefault();
try { ev.dataTransfer.dropEffect = _presTrascinato && _presTrascinato.nuovo ? 'copy' : 'move'; } catch (e) {}
const linea = document.getElementById('presScaletta');
if (linea) linea.classList.add('sopra');
}
function presDrop(ev, dove) {
ev.preventDefault();
ev.stopPropagation();
const linea = document.getElementById('presScaletta');
if (linea) linea.classList.remove('sopra');
const team = activeTeam();
if (!team || !_presTrascinato) return;
const el = ensurePresentazione(team);
if (_presTrascinato.nuovo) {
const voce = Object.assign({ id: nextId('pres') }, _presTrascinato.nuovo);
if (dove < 0 || dove >= el.length) el.push(voce); else el.splice(dove, 0, voce);
} else {
const da = _presTrascinato.da;
if (da == null || da < 0 || da >= el.length) { _presTrascinato = null; return; }
const [voce] = el.splice(da, 1);
let a2 = (dove < 0 || dove > el.length) ? el.length : dove;
if (da < a2) a2 = Math.max(0, a2 - 1);
el.splice(a2, 0, voce);
}
_presTrascinato = null;
scheduleAutosave();
renderPresentazioneSection();
}
document.addEventListener('dragend', () => {
const linea = document.getElementById('presScaletta');
if (linea) linea.classList.remove('sopra');
_presTrascinato = null;
});
/* ---- ANTEPRIMA: il piccolo schermo in alto ---- */
/* Player sopra, clip sotto, tutti e due dentro alla finestra senza scorrere:
* si misura da dove comincia lo schermo fino al fondo, si toglie la barra
* del play e la striscia delle clip, e quello che resta e' l'altezza. */
function presAdattaAltezza() {
const schermo = document.getElementById('presSchermo');
const linea = document.getElementById('presScaletta');
const sez = document.getElementById('presentazioneSection');
if (!schermo || !linea || !sez || sez.style.display === 'none') return;
const rs = schermo.getBoundingClientRect();
if (!rs.height && !rs.top) return;
const barra = document.querySelector('.pres-ant-barra');
const hBarra = barra ? Math.round(barra.getBoundingClientRect().height) : 34;
const hLinea = Math.max(112, Math.round(linea.getBoundingClientRect().height));
const spazio = window.innerHeight - rs.top - hBarra - hLinea - 30;
const h = Math.max(148, Math.min(560, Math.round(spazio)));
schermo.style.setProperty('--pres-h', h + 'px');
}
window.addEventListener('resize', () => { try { presAdattaAltezza(); } catch (e) {} });
function presAnteprima(i) {
const team = activeTeam();
const v = ensurePresentazione(team)[i];
if (!v) return;
presScelto = i;
document.querySelectorAll('.pres-slot').forEach((n, k) => n.classList.toggle('scelto', k === i));
const vid = document.getElementById('presAntVideo');
const tela = document.getElementById('presAntTela');
const foto = document.getElementById('presAntFoto');
const vuoto = document.getElementById('presAntVuoto');
const nome = document.getElementById('presAntNome');
if (nome) nome.textContent = (i + 1) + '. ' + (v.nome || '');
if (vuoto) vuoto.style.display = 'none';
vid.style.display = 'none'; tela.style.display = 'none'; foto.style.display = 'none';
try { vid.pause(); } catch (e) {}
if (v.tipo === 'montaggio' || v.tipo === 'video') {
vid.style.display = 'block';
vid.src = urlDaPercorso(v.percorso) || '';
return;
}
if (v.tipo === 'foto') { foto.style.display = 'block'; foto.src = urlDaPercorso(v.percorso) || ''; return; }
// i cartelli si disegnano con lo STESSO codice della proiezione, cosi'
// quello che vedi qui e' esattamente quello che vedra' la squadra.
tela.style.display = 'block';
const vecchia = document.getElementById('presTela');
const finta = { id: 'anteprima' };
// presTelaPronta lavora su #presTela: si disegna li' e si copia qui.
if (v.tipo === 'copertina') presDisegnaCopertina(v);
else if (v.tipo === 'scheda') presDisegnaScheda(v);
else if (v.tipo === 'giocatrice') presDisegnaGiocatrice(v);
else presDisegnaCartello(v);
tela.width = vecchia.width; tela.height = vecchia.height;
tela.getContext('2d').drawImage(vecchia, 0, 0);
}
function presAnteprimaPlay() {
const vid = document.getElementById('presAntVideo');
const b = document.getElementById('presAntPlay');
if (!vid || vid.style.display === 'none') return;
if (vid.paused) { vid.play().catch(() => {}); if (b) b.textContent = '⏸'; }
else { vid.pause(); if (b) b.textContent = '▶'; }
}
/* ---------------- IL SIPARIO ---------------- */
let presIndice = -1;
let presTimer = null;
let presInPausa = false;
function presElementi() {
return {
sipario: document.getElementById('presSipario'),
video: document.getElementById('presVideo'),
tela: document.getElementById('presTela'),
foto: document.getElementById('presFoto'),
quale: document.getElementById('presQuale'),
play: document.getElementById('presPlayBtn'),
};
}
/* ------- LA BARRA DEL TEMPO DEL SIPARIO -------
* Dentro a un filmato ci si muove: si clicca (o si trascina) e si va li'.
* Sui cartelli e sulle foto, che durano un tempo deciso e basta, la barra
* non porta da nessuna parte: mostra solo quanto manca, e si vede che non
* e' trascinabile (grigia, niente manina). */
let _presRaf = null;
let _presTrascina = false;
function presMMSS(sec) {
const s2 = Math.max(0, Math.floor(+sec || 0));
return Math.floor(s2 / 60) + ':' + String(s2 % 60).padStart(2, '0');
}
function presVoceCorrente() {
const voci = ensurePresentazione(activeTeam());
return voci[presIndice] || null;
}
function presVoceEUnFilmato(v) { return !!v && (v.tipo === 'montaggio' || v.tipo === 'video'); }
let _presAttesaInizio = 0, _presAttesaDurata = 0;
function presTempoAggiorna() {
const box = document.getElementById('presTempoBox');
const sip = document.getElementById('presSipario');
if (!box || !sip || !sip.classList.contains('show')) return;
const v = presVoceCorrente();
const barra = document.getElementById('presTBarra');
const fill = document.getElementById('presTFill');
const cur = document.getElementById('presTCur');
const tot = document.getElementById('presTTot');
let adesso = 0, durata = 0, scorribile = false;
if (presVoceEUnFilmato(v)) {
const vid = document.getElementById('presVideo');
adesso = (vid && isFinite(vid.currentTime)) ? vid.currentTime : 0;
durata = (vid && isFinite(vid.duration)) ? vid.duration : 0;
scorribile = durata > 0;
} else if (v) {
durata = presDurataVoce(v);
adesso = _presAttesaDurata ? Math.min(durata, (performance.now() - _presAttesaInizio) / 1000) : 0;
}
if (barra) barra.classList.toggle('ferma', !scorribile);
if (fill) fill.style.width = (durata > 0 ? Math.max(0, Math.min(100, adesso / durata * 100)) : 0).toFixed(2) + '%';
if (cur) cur.textContent = presMMSS(adesso);
if (tot) tot.textContent = presMMSS(durata);
}
function presCercaFrazione(fr) {
const v = presVoceCorrente();
if (!presVoceEUnFilmato(v)) return;
const vid = document.getElementById('presVideo');
if (!vid || !isFinite(vid.duration) || vid.duration <= 0) return;
vid.currentTime = Math.max(0, Math.min(vid.duration - 0.05, fr * vid.duration));
presTempoAggiorna();
}
function presFrazioneDaEvento(ev) {
const barra = document.getElementById('presTBarra');
if (!barra) return 0;
const r = barra.getBoundingClientRect();
return r.width > 0 ? Math.max(0, Math.min(1, (ev.clientX - r.left) / r.width)) : 0;
}
function presCercaGiu(ev) {
const v = presVoceCorrente();
if (!presVoceEUnFilmato(v)) return;
ev.preventDefault();
_presTrascina = true;
presCercaFrazione(presFrazioneDaEvento(ev));
}
document.addEventListener('mousemove', (ev) => { if (_presTrascina) presCercaFrazione(presFrazioneDaEvento(ev)); });
document.addEventListener('mouseup', () => { _presTrascina = false; });
function presTempoAvvia() {
if (_presRaf) return;
const giro = () => {
const sip = document.getElementById('presSipario');
if (!sip || !sip.classList.contains('show')) { _presRaf = null; return; }
presTempoAggiorna();
_presRaf = requestAnimationFrame(giro);
};
_presRaf = requestAnimationFrame(giro);
}
function presProietta() {
/* Se qui dentro qualcosa va storto, prima non succedeva NIENTE: nessuna
* finestra, nessun messaggio, e da fuori sembrava che il bottone non
* funzionasse. Un guasto muto e' il peggior tipo di guasto — non si puo'
* nemmeno raccontare. Ora l'errore si vede, con il suo nome. */
try {
const team = activeTeam();
if (!team) { toast(t('toast.selectTeamFirst')); return; }
const voci = ensurePresentazione(team);
if (!voci.length) { toast(t('pres.scalettaVuota')); return; }
const e = presElementi();
if (!e.sipario) { toast('Schermo di proiezione non trovato'); return; }
e.sipario.classList.add('show');
presInPausa = false;
presMostra(0);
presTempoAvvia();
} catch (err) {
const testo = 'Proiezione non riuscita: ' + ((err && err.message) || err);
try { toast(testo); } catch (e2) {}
const q = document.getElementById('presQuale');
if (q) q.textContent = testo;
console.error('presProietta', err);
}
}
function presChiudi() {
const e = presElementi();
clearTimeout(presTimer); presTimer = null;
try { e.video.pause(); e.video.removeAttribute('src'); e.video.load(); } catch (err) {}
e.sipario.classList.remove('show');
presIndice = -1;
}
function presVai(delta) {
presMostra(presIndice + delta);
}
function presPausa() {
const e = presElementi();
presInPausa = !presInPausa;
if (e.play) e.play.textContent = presInPausa ? '▶' : '⏸';
const v = ensurePresentazione(activeTeam())[presIndice];
if (!v) return;
if (v.tipo === 'montaggio' || v.tipo === 'video') {
try { presInPausa ? e.video.pause() : e.video.play().catch(() => {}); } catch (err) {}
} else {
// cartelli e foto: si ferma (o riparte) il conto alla rovescia
if (presInPausa) { clearTimeout(presTimer); presTimer = null; }
else presAvviaAttesa(presDurataVoce(v) * 1000);
}
}
function presAvviaAttesa(ms) {
clearTimeout(presTimer);
// la barra del tempo, sui cartelli, mostra questo conto alla rovescia
_presAttesaInizio = performance.now();
_presAttesaDurata = ms / 1000;
presTimer = setTimeout(() => presVai(1), ms);
}
function presDisegnaCartello(v, fuori) {
const { ctx, W, H, team } = presTelaPronta(fuori);
ctx.fillStyle = '#fff';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.font = '900 ' + Math.round(H * 0.11) + 'px system-ui, -apple-system, Segoe UI, Roboto, sans-serif';
const testo = String(v.nome || '').toUpperCase();
// il titolo va a capo da solo se e' lungo
const parole = testo.split(/\s+/);
const righe = []; let riga = '';
parole.forEach(w => {
const prova = riga ? riga + ' ' + w : w;
if (ctx.measureText(prova).width > W * 0.82 && riga) { righe.push(riga); riga = w; } else riga = prova;
});
if (riga) righe.push(riga);
const passo = H * 0.13;
const y0 = H / 2 - (righe.length - 1) * passo / 2;
righe.forEach((r, i) => ctx.fillText(r, W / 2, y0 + i * passo));
if (team && team.name) {
ctx.font = '800 ' + Math.round(H * 0.035) + 'px system-ui, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,.6)';
ctx.fillText(String(team.name).toUpperCase(), W / 2, H * 0.88);
}
}
/* IL LOGO DI CHI STIAMO STUDIANDO, caricato da qui.
* Le iniziali nel tondo sono un ripiego decoroso, ma il logo vero e' un'altra
* cosa: si sceglie il file una volta e da quel momento compare sulla
* copertina, sulle schede e anche sui fogli stampati dello scouting report.
* L'immagine viene rimpicciolita a 512px prima di essere salvata: il lavoro
* si salva in un file di testo, e infilarci dentro un PNG da cinque mega lo
* farebbe diventare pesantissimo per niente. */
function presRimpicciolisciLogo(dataUrl, lato) {
return new Promise((res) => {
const im = new Image();
im.onload = () => {
const L = lato || 512;
const s2 = Math.min(1, L / Math.max(im.naturalWidth || L, im.naturalHeight || L));
const w = Math.max(1, Math.round((im.naturalWidth || L) * s2));
const h = Math.max(1, Math.round((im.naturalHeight || L) * s2));
const c = document.createElement('canvas');
c.width = w; c.height = h;
c.getContext('2d').drawImage(im, 0, 0, w, h);
try { res(c.toDataURL('image/png')); } catch (e) { res(dataUrl); }
};
im.onerror = () => res(dataUrl);
im.src = dataUrl;
});
}
function presMetteIlLogo(opp, dataUrl) {
return presRimpicciolisciLogo(dataUrl, 512).then((piccolo) => {
opp.logo = piccolo;
Object.keys(_presStemmi).forEach(k => delete _presStemmi[k]); // la cache va buttata
scheduleAutosave();
renderPresentazioneSection();
if (presScelto >= 0) setTimeout(() => presAnteprima(presScelto), 250);
toast(t('pres.logoMesso', { nome: opp.name || '' }));
});
}
async function presCaricaLogoAvversaria() {
const team = activeTeam();
const opp = team && activeOpponent(team);
if (!opp) { toast(t('toast.selectTeamFirst')); return; }
const filtri = [{ name: 'Immagini', extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif'] }];
if (NATIVO) {
let percorsi = [];
try { percorsi = await window.VDM.scegliFile({ titolo: t('pres.logoAvv'), multipli: false, filtri }) || []; }
catch (e) { percorsi = []; }
if (!percorsi.length) return;
try {
const buf = await window.VDM.fs.leggiBinario(percorsi[0]);
if (!buf) { toast(t('pres.logoKo')); return; }
const est = (percorsi[0].split('.').pop() || 'png').toLowerCase();
const mime = est === 'jpg' || est === 'jpeg' ? 'image/jpeg' : est === 'webp' ? 'image/webp' : est === 'gif' ? 'image/gif' : 'image/png';
const blob = new Blob([buf], { type: mime });
const lettore = new FileReader();
lettore.onload = () => presMetteIlLogo(opp, lettore.result);
lettore.onerror = () => toast(t('pres.logoKo'));
lettore.readAsDataURL(blob);
} catch (e) { toast(t('pres.logoKo')); }
return;
}
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'image/*';
inp.onchange = () => {
const f = (inp.files || [])[0];
if (!f) return;
const lettore = new FileReader();
lettore.onload = () => presMetteIlLogo(opp, lettore.result);
lettore.readAsDataURL(f);
};
inp.click();
}
/* IL COLORE DELLA PRESENTAZIONE.
* Prima usava il colore della squadra su cui stai lavorando e basta: se
* quella era rossa, tutta la presentazione usciva rossa. Adesso lo scegli
* tu — e resta scelto per quella squadra. */
const PRES_COLORI = ['#16345e', '#0f3b33', '#2a2350', '#3a2410', '#4a1020', '#101418'];
function presColore(team) {
const c = team && team.presColore;
if (typeof c === 'string' && /^#[0-9a-f]{6}$/i.test(c)) return c;
// Non scelto: si parte dal colore della squadra del coach (l'Italia parte
// azzurra) invece che da un blu qualsiasi.
try {
const casa = presSquadraDiCasa();
if (casa && /^#[0-9a-f]{6}$/i.test(casa.colorHex || '')) return casa.colorHex;
} catch (e) {}
return '#16345e';
}
function presCambiaColore(valore) {
const team = activeTeam();
if (!team || !/^#[0-9a-f]{6}$/i.test(valore || '')) return;
team.presColore = valore;
scheduleAutosave();
renderPresentazioneSection();
if (presScelto >= 0) presAnteprima(presScelto);
}
/* LA SQUADRA DEL COACH: quella per cui si prepara la riunione. E' lei che
* va firmata in fondo alla copertina — non il nome dell'allenatore: la
* presentazione la guarda la squadra, e in fondo ci vuole il suo nome. */
function presSquadraDiCasa() {
const team = activeTeam();
const opp = team && activeOpponent(team);
return squadraDelCoach(activeLeague(), opp || team) || team || null;
}
/* LO STEMMA DI CHI STIAMO STUDIANDO. Il logo caricato se c'e', altrimenti la
* bandiera disegnata dal nome della nazione: la stessa che compare sui fogli
* stampati, cosi' la presentazione e' della stessa famiglia. */
const _presStemmi = {};
function presStemma(opp) {
if (!opp) return null;
const chiave = (opp.logo ? 'L' : 'B') + (opp.id || '') + '|' + (opp.name || '');
if (_presStemmi[chiave]) return _presStemmi[chiave];
let src = '';
if (opp.logo) src = opp.logo;
else {
const svg = bandieraNazionaleSvg(opp.name || '');
if (svg) src = 'data:image/svg+xml;utf8,' + encodeURIComponent(svg.replace(/^ w[0]).join('').toUpperCase();
ctx.save();
ctx.beginPath(); ctx.arc(x, y, lato / 2, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,.12)'; ctx.fill();
ctx.lineWidth = Math.max(1, lato * 0.03); ctx.strokeStyle = 'rgba(255,255,255,.35)'; ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,.92)';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.font = '900 ' + Math.round(lato * 0.42) + 'px system-ui, sans-serif';
ctx.fillText(ini, x, y + lato * 0.02);
ctx.restore();
return true;
}
const s2 = Math.min(lato / im.naturalWidth, lato / im.naturalHeight);
const dw = im.naturalWidth * s2, dh = im.naturalHeight * s2;
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,.45)'; ctx.shadowBlur = lato * 0.12; ctx.shadowOffsetY = lato * 0.03;
ctx.drawImage(im, x - dw / 2, y - dh / 2, dw, dh);
ctx.restore();
return true;
}
/* Prepara la tela su cui disegnare una schermata della presentazione.
* Di solito e' quella grande della proiezione, ma passando "fuori"
* ({ctx, W, H}) si disegna sulla tela dell'esportazione: stesse schermate,
* stessa grafica, una volta sullo schermo e una volta dentro al file. */
function presTelaPronta(fuori) {
const team = activeTeam();
const acc = presColore(team);
let ctx, W, H;
if (fuori && fuori.ctx) {
ctx = fuori.ctx; W = fuori.W; H = fuori.H;
} else {
const tela = document.getElementById('presTela');
W = Math.min(1920, Math.max(1000, window.innerWidth));
H = Math.round(W * 9 / 16);
tela.width = W; tela.height = H;
ctx = tela.getContext('2d');
}
vdmFondoRicco(ctx, W, H, acc, tonoColore(acc, -0.72));
vdmFirmetta(ctx, W, H, false);
/* NIENTE FILIGRANA DIETRO. Ci avevo provato con la bandiera sfumata sullo
sfondo, ma su un blu scuro il tricolore si spegne e non si legge piu':
resta una macchia che sporca e basta. Meglio un gradiente pulito — la
bandiera si vede dove serve, accanto al nome della squadra. */
return { ctx, W, H, acc, team };
}
function presScriviACapo(ctx, testo, x, y, largMax, passo, allinea) {
const parole = String(testo || '').split(/\s+/).filter(Boolean);
const righe = []; let riga = '';
parole.forEach(w => {
const prova = riga ? riga + ' ' + w : w;
if (ctx.measureText(prova).width > largMax && riga) { righe.push(riga); riga = w; } else riga = prova;
});
if (riga) righe.push(riga);
ctx.textAlign = allinea || 'center';
righe.forEach((r, i) => ctx.fillText(r, x, y + i * passo));
return righe.length;
}
/* La copertina: chi abbiamo studiato, e chi la presenta. */
function presDisegnaCopertina(v, fuori) {
const { ctx, W, H, team } = presTelaPronta(fuori);
const opp = team && activeOpponent(team);
const sr = opp ? ensureOpponentMatchInfo(opp) : null;
ctx.textBaseline = 'middle';
ctx.fillStyle = 'rgba(255,255,255,.62)';
ctx.font = '800 ' + Math.round(H * 0.030) + 'px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(String(t('print.scoutingTitle')).toUpperCase(), W / 2, H * 0.20);
// lo stemma di chi stiamo studiando, sopra al suo nome
const conStemma = presDisegnaStemma(ctx, opp, W / 2, H * 0.315, H * 0.185);
ctx.fillStyle = '#fff';
ctx.font = '900 ' + Math.round(H * 0.105) + 'px system-ui, sans-serif';
presScriviACapo(ctx, (v.nome || (opp && opp.name) || ''), W / 2, conStemma ? H * 0.525 : H * 0.40, W * 0.84, H * 0.115);
// la data scritta come la scrive un allenatore, non come la scrive un file
let dataBella = (sr && sr.dataPartita) || '';
if (dataBella) {
try { const dd = new Date(dataBella + 'T00:00:00'); if (!isNaN(dd)) dataBella = dd.toLocaleDateString(currentLang || 'it'); } catch (e) {}
}
const meta = [sr && sr.competizione, sr && sr.girone, dataBella, sr && sr.sede].filter(Boolean).join(' · ');
if (meta) {
ctx.fillStyle = 'rgba(255,255,255,.78)';
ctx.font = '700 ' + Math.round(H * 0.036) + 'px system-ui, sans-serif';
ctx.fillText(meta, W / 2, H * 0.645);
}
ctx.strokeStyle = 'rgba(255,255,255,.28)'; ctx.lineWidth = Math.max(1, H * 0.002);
ctx.beginPath(); ctx.moveTo(W * 0.32, H * 0.72); ctx.lineTo(W * 0.68, H * 0.72); ctx.stroke();
// In fondo la squadra del coach — quella per cui si prepara la riunione —
// con il suo stemma accanto. Il nome dell'allenatore qui non serve: chi
// guarda sa gia' chi parla.
const casa = presSquadraDiCasa();
const nomeCasa = String((casa && casa.name) || (team && team.name) || '').toUpperCase();
ctx.font = '800 ' + Math.round(H * 0.046) + 'px system-ui, sans-serif';
const larg = ctx.measureText(nomeCasa).width;
const lato = H * 0.075;
const tutto = larg + lato + W * 0.014;
presDisegnaStemma(ctx, casa, W / 2 - tutto / 2 + lato / 2, H * 0.825, lato);
ctx.textAlign = 'left';
ctx.fillStyle = 'rgba(255,255,255,.94)';
ctx.fillText(nomeCasa, W / 2 - tutto / 2 + lato + W * 0.014, H * 0.825);
ctx.textAlign = 'center';
}
/* La scheda: titolo in fascia, sotto le voci del foglio di scouting. */
function presDisegnaScheda(v, fuori) {
const { ctx, W, H, acc, team } = presTelaPronta(fuori);
const opp = team && activeOpponent(team);
ctx.textBaseline = 'middle';
// fascia del titolo
const hb = H * 0.15;
const gb = ctx.createLinearGradient(0, 0, W, 0);
gb.addColorStop(0, 'rgba(255,255,255,.16)'); gb.addColorStop(1, 'rgba(255,255,255,.04)');
ctx.fillStyle = gb; ctx.fillRect(0, 0, W, hb);
ctx.fillStyle = '#fff';
ctx.font = '900 ' + Math.round(H * 0.068) + 'px system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(String(v.nome || '').toUpperCase(), W * 0.06, hb / 2);
if (opp && opp.name) {
const lato = hb * 0.62;
const c1 = presDisegnaStemma(ctx, opp, W * 0.94 - lato / 2, hb / 2, lato);
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,.72)';
ctx.font = '800 ' + Math.round(H * 0.030) + 'px system-ui, sans-serif';
ctx.fillText(String(opp.name).toUpperCase(), W * 0.94 - (c1 ? lato + W * 0.012 : 0), hb / 2);
}
if (!opp) return;
let y = hb + H * 0.10;
const voci = presContenutoScheda(opp, v.scheda).filter(x => (x.testo || '').toString().trim());
if (!voci.length) {
ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(255,255,255,.45)';
ctx.font = '700 ' + Math.round(H * 0.040) + 'px system-ui, sans-serif';
ctx.fillText(t('pres.schedaVuota'), W / 2, H * 0.55);
return;
}
voci.forEach(voce => {
if (y > H * 0.94) return;
if (voce.eti) {
ctx.textAlign = 'left'; ctx.fillStyle = 'rgba(255,255,255,.55)';
ctx.font = '900 ' + Math.round(H * 0.028) + 'px system-ui, sans-serif';
ctx.fillText(String(voce.eti).toUpperCase(), W * 0.06, y);
y += H * 0.052;
}
ctx.fillStyle = '#fff';
ctx.font = '700 ' + Math.round(H * 0.042) + 'px system-ui, sans-serif';
String(voce.testo).split('\n').map(r => r.trim()).filter(Boolean).forEach(r => {
if (y > H * 0.94) return;
const n = presScriviACapo(ctx, '· ' + r, W * 0.06, y, W * 0.88, H * 0.055, 'left');
y += n * H * 0.055;
});
y += H * 0.030;
});
}
/* La scheda si puo' disegnare in due modi: cercando la giocatrice per id
* dentro alla rosa (e' quello che fa la voce "+ Player" della scaletta),
* oppure ricevendola gia' pronta. Il secondo serve alle copertine dei
* montaggi individuali, che partono dalle Squadre Avversarie e hanno la
* giocatrice in mano: cercarla per id li' non funzionerebbe, perche' quella
* ricerca guarda la rosa dell'avversaria del momento. */
function presDisegnaGiocatrice(v, fuori, plDiretta) {
const { ctx, W, H } = presTelaPronta(fuori);
const pl = plDiretta || presTrovaGiocatrice(v.playerId);
ctx.textBaseline = 'middle';
if (!pl) {
ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(255,255,255,.6)';
ctx.font = '800 ' + Math.round(H * 0.05) + 'px system-ui, sans-serif';
ctx.fillText(String(v.nome || ''), W / 2, H / 2);
return;
}
// foto a sinistra (se c'e'), riquadro alto quanto la scheda
const mx = W * 0.06, my = H * 0.12;
const lf = W * 0.26, hf = H * 0.62;
const img = pl.photo ? presFotoCache(pl.photo) : null;
ctx.fillStyle = 'rgba(255,255,255,.08)';
ctx.fillRect(mx, my, lf, hf);
if (img && img.complete && img.naturalWidth) {
const s2 = Math.max(lf / img.naturalWidth, hf / img.naturalHeight);
const dw = img.naturalWidth * s2, dh = img.naturalHeight * s2;
ctx.save(); ctx.beginPath(); ctx.rect(mx, my, lf, hf); ctx.clip();
ctx.drawImage(img, mx + (lf - dw) / 2, my + (hf - dh) / 2, dw, dh);
ctx.restore();
}
const x = mx + lf + W * 0.05;
let y = my + H * 0.06;
ctx.textAlign = 'left'; ctx.fillStyle = '#fff';
/* Il nome non deve uscire dal foglio: con un nome lungo (o con il numero
* davanti) la riga arrivava a filo del bordo destro e le ultime lettere si
* perdevano. Si parte dalla misura piena e si stringe finche' ci sta. */
const testoNome = (pl.number ? '#' + pl.number + ' ' : '') + String(pl.name || '').toUpperCase();
const largMax = W - x - W * 0.05;
let dimNome = Math.round(H * 0.095);
const minimo = Math.round(H * 0.038);
do {
ctx.font = '900 ' + dimNome + 'px system-ui, sans-serif';
if (ctx.measureText(testoNome).width <= largMax) break;
dimNome -= 2;
} while (dimNome > minimo);
// Nome fuori misura anche al corpo piu' piccolo (capita con i nomi doppi
// spagnoli o portoghesi): si taglia con i puntini, mai a meta' parola in
// mezzo al bordo.
let daScrivere = testoNome;
if (ctx.measureText(daScrivere).width > largMax) {
while (daScrivere.length > 4 && ctx.measureText(daScrivere + '…').width > largMax) daScrivere = daScrivere.slice(0, -1);
daScrivere = daScrivere.replace(/\s+$/, '') + '…';
}
ctx.fillText(daScrivere, x, y);
y += H * 0.09;
const bio = [roleLabel(pl.role), pl.height ? pl.height + ' cm' : '', pl.birthYear || ''].filter(Boolean).join(' · ');
if (bio) {
ctx.fillStyle = 'rgba(255,255,255,.65)';
ctx.font = '700 ' + Math.round(H * 0.034) + 'px system-ui, sans-serif';
ctx.fillText(bio, x, y); y += H * 0.075;
}
const st = pl.stats || {};
const medie = [['PT', st.pts], ['RB', st.reb], ['AS', st.ast], ['2P', st.p2], ['3P', st.p3], ['TL', st.ft]]
.filter(a => a[1] !== undefined && a[1] !== null && String(a[1]).trim() !== '');
if (medie.length) {
let xs = x;
medie.forEach(([k, val]) => {
ctx.fillStyle = '#fff';
ctx.font = '900 ' + Math.round(H * 0.052) + 'px system-ui, sans-serif';
ctx.fillText(String(val), xs, y);
const lar = ctx.measureText(String(val)).width;
ctx.fillStyle = 'rgba(255,255,255,.5)';
ctx.font = '800 ' + Math.round(H * 0.024) + 'px system-ui, sans-serif';
ctx.fillText(k, xs + lar + W * 0.006, y + H * 0.012);
xs += lar + W * 0.055;
});
y += H * 0.085;
}
const car = (pl.traits || []).filter(z => (z || '').trim());
car.forEach(c => {
if (y > H * 0.92) return;
ctx.fillStyle = '#fff';
ctx.font = '700 ' + Math.round(H * 0.040) + 'px system-ui, sans-serif';
const n = presScriviACapo(ctx, '· ' + c, x, y, W - x - W * 0.06, H * 0.052, 'left');
y += n * H * 0.052 + H * 0.012;
});
}
// Le foto sono data URL: si tengono decodificate, o ad ogni disegno
// ripartirebbero da zero e la scheda comparirebbe vuota per un attimo.
const _presFoto = {};
function presFotoCache(src) {
if (!_presFoto[src]) { const im = new Image(); im.src = src; _presFoto[src] = im; }
return _presFoto[src];
}
function presMostra(i) {
try { return presMostraDavvero(i); }
catch (err) {
const testo = 'Pezzo ' + (i + 1) + ' non riproducibile: ' + ((err && err.message) || err);
const q = document.getElementById('presQuale');
if (q) q.textContent = testo;
console.error('presMostra', err);
}
}
function presMostraDavvero(i) {
const team = activeTeam();
const voci = ensurePresentazione(team);
const e = presElementi();
clearTimeout(presTimer); presTimer = null;
if (i < 0) i = 0;
if (i >= voci.length) { presChiudi(); return; }
presIndice = i;
const v = voci[i];
if (e.quale) e.quale.textContent = (i + 1) + '/' + voci.length + ' · ' + (v.nome || '');
e.video.style.display = 'none'; e.tela.style.display = 'none'; e.foto.style.display = 'none';
try { e.video.pause(); } catch (err) {}
if (v.tipo === 'cartello' || v.tipo === 'copertina' || v.tipo === 'scheda' || v.tipo === 'giocatrice') {
e.tela.style.display = 'block';
if (v.tipo === 'copertina') presDisegnaCopertina(v);
else if (v.tipo === 'scheda') presDisegnaScheda(v);
else if (v.tipo === 'giocatrice') presDisegnaGiocatrice(v);
else presDisegnaCartello(v);
if (!presInPausa) presAvviaAttesa(presDurataVoce(v) * 1000);
return;
}
if (v.tipo === 'foto') {
e.foto.style.display = 'block';
e.foto.src = urlDaPercorso(v.percorso) || '';
if (!presInPausa) presAvviaAttesa(presDurataVoce(v) * 1000);
return;
}
// montaggio o video: si riproduce, e alla fine si passa al pezzo dopo
e.video.style.display = 'block';
const indirizzo = urlDaPercorso(v.percorso) || '';
if (!indirizzo) {
if (e.quale) e.quale.textContent = t('pres.nonSiApre', { nome: v.nome || '' });
presAvviaAttesa(2500);
return;
}
e.video.src = indirizzo;
e.video.onended = () => presVai(1);
e.video.onerror = () => { if (e.quale) e.quale.textContent = t('pres.nonSiApre', { nome: v.nome || '' }); presAvviaAttesa(2500); };
if (!presInPausa) { try { e.video.play().catch(() => {}); } catch (err) {} }
}
document.addEventListener('keydown', (ev) => {
const sip = document.getElementById('presSipario');
if (!sip || !sip.classList.contains('show')) return;
if (ev.key === 'Escape') { ev.preventDefault(); presChiudi(); }
else if (ev.code === 'Space') { ev.preventDefault(); presPausa(); }
else if (ev.key === 'ArrowRight') { ev.preventDefault(); presVai(1); }
else if (ev.key === 'ArrowLeft') { ev.preventDefault(); presVai(-1); }
});
/* Quello che sta succedendo, scritto dove lo stai guardando. */
/* La fascia di lavorazione del montaggio: si vede da qualsiasi sezione
* dell'app, non solo dalla scheda Edit Video. Tre stati soli — sto
* lavorando, ho finito, chiusa. */
let _montFasciaTimer = null;
function montFasciaChiudi() {
const box = document.getElementById('montFascia');
if (box) box.classList.remove('show');
if (_montFasciaTimer) { clearTimeout(_montFasciaTimer); _montFasciaTimer = null; }
}
function montFascia(messaggio, frazione, finito) {
const box = document.getElementById('montFascia');
if (!box) return;
if (messaggio == null) { montFasciaChiudi(); return; }
if (_montFasciaTimer) { clearTimeout(_montFasciaTimer); _montFasciaTimer = null; }
box.classList.add('show');
box.classList.remove('niente');
box.classList.toggle('finito', !!finito);
const ic = document.getElementById('montFasciaIcona');
if (ic) ic.textContent = finito ? '✅' : '🎬';
const tit = document.getElementById('montFasciaTit');
if (tit) tit.textContent = t(finito ? 'montage.fasciaFinito' : 'montage.fasciaTitolo');
const msg = document.getElementById('montFasciaMsg');
if (msg) msg.textContent = messaggio;
const barra = document.getElementById('montFasciaBarra');
if (barra) barra.style.width = Math.max(0, Math.min(100, (finito ? 1 : (frazione || 0)) * 100)).toFixed(1) + '%';
// A lavoro finito la fascia resta un po' e poi se ne va da sola: se il
// coach e' andato a prendere un caffe' la trova ancora li'.
if (finito) _montFasciaTimer = setTimeout(montFasciaChiudi, 20000);
}
/* NON E' USCITO NIENTE: DILLO FORTE. Prima il motivo finiva in un toast che
* sparisce e in una riga grigia da 11px sotto al bottone: il coach preme
* "Monta", non succede niente e non capisce. Adesso il motivo va anche nella
* fascia in basso a destra, quella del montaggio, che resta li' a lungo. */
function montNiente(motivo) {
try { toast(t('montage.perche', { motivo })); } catch (e) {}
const box = document.getElementById('montFascia');
if (!box) return;
montFascia(motivo, 1, true);
box.classList.add('niente');
const ic = document.getElementById('montFasciaIcona');
if (ic) ic.textContent = '\u26a0\ufe0f';
const tit = document.getElementById('montFasciaTit');
if (tit) tit.textContent = t('montage.fasciaNiente');
if (_montFasciaTimer) { clearTimeout(_montFasciaTimer); _montFasciaTimer = null; }
_montFasciaTimer = setTimeout(montFasciaChiudi, 45000);
}
/* AVVISO ANCHE FUORI DALL'APP. Un montaggio lungo si lascia lavorare e si va
* a fare altro: se la finestra di VDM non e' quella davanti, a lavoro finito
* arriva l'avviso del sistema, come per la posta. Se il permesso non c'e' o
* il sistema non li fa, non succede niente di male: resta la fascia. */
function montAvvisaFuori(testo) {
try {
if (typeof Notification === 'undefined') return;
if (!document.hidden && document.hasFocus()) return;
const manda = () => { try { new Notification('VDM Basketball Coach', { body: testo }); } catch (e) {} };
if (Notification.permission === 'granted') manda();
else if (Notification.permission !== 'denied') Notification.requestPermission().then(p => { if (p === 'granted') manda(); }).catch(() => {});
} catch (e) {}
}
function fxAvanza(testo, frazione) {
const box = document.getElementById('fxAvanzamento');
if (!box) return;
box.style.display = (testo == null) ? 'none' : 'block';
if (testo == null) return;
const et = document.getElementById('fxAvanzamentoTesto');
if (et) et.textContent = testo;
const barra = document.getElementById('fxAvanzamentoBarra');
if (barra) barra.style.width = Math.max(0, Math.min(100, (frazione || 0) * 100)).toFixed(1) + '%';
}
function fxFermaEsportazione() {
montageRunning = false;
fxAvanza(t('fx.esportaFermata'), 1);
setTimeout(() => fxAvanza(null), 2500);
montFascia(t('fx.esportaFermata'), 1, true);
}
/* Due pezzi con la stessa etichetta (due "DOUBLE HIGH" nella stessa partita)
* finivano nello stesso identico nome di file: il secondo copriva il primo e
* sembrava che uscisse sempre lo stesso pezzo. Nel nome ci va anche il punto
* del video da cui e' preso, che e' l'unica cosa che li distingue davvero. */
function marcaTempoPerNome(sec) {
const s2 = Math.max(0, Math.round(+sec || 0));
return String(Math.floor(s2 / 60)).padStart(2, '0') + 'm' + String(s2 % 60).padStart(2, '0') + 's';
}
async function fxExportPezzo() {
const found = findTagAnywhere(fxEditorTagId);
if (!found) return;
const { clip, tag } = found;
const url = clip.url || await evAssicuraUrlClip(clip);
if (!url) { toast(t('editvideo.clipMancante', { nome: clip.name || '' })); return; }
const useCut = tag.cutStatus === 'ready' && tag.cutUrl;
const seg = useCut
? { src: tag.cutUrl, start: 0, end: tag.end - tag.start, label: tag.label, fx: tagFx(tag), clipRef: clip, percorso: tag.cutFile || '' }
: { src: url, start: tag.start, end: tag.end, label: tag.label, fx: tagFx(tag), clipRef: clip, percorso: clip.percorsoDisco || '' };
const nome = ((tag.label || tag.playerLabel || 'clip') + '_' + marcaTempoPerNome(tag.start) + '_editata')
.replace(/[^a-z0-9]+/gi, '_');
const bottone = document.getElementById('fxExportBtn');
if (bottone) bottone.disabled = true;
const secondi = Math.max(1, (tag.end - tag.start));
fxAvanza(t('fx.esportaInizio', { sec: Math.ceil(secondi) }), 0);
try {
await exportTeamMontage([seg], nome);
} finally {
if (bottone) bottone.disabled = false;
}
}
/* TRASLA il pezzo lungo il video: sposta insieme inizio e fine, la durata
* resta identica. Serve quando il taglio è giusto come lunghezza ma parte
* mezzo secondo troppo presto o troppo tardi. */
function fxTraslaPezzo(delta) {
const found = findTagAnywhere(fxEditorTagId);
if (!found) return;
const { clip, tag } = found;
const durata = tag.end - tag.start;
const massimo = (clip.duration && isFinite(clip.duration)) ? clip.duration : Math.max(tag.end + 600, 600);
let nuovoInizio = tag.start + delta;
nuovoInizio = Math.max(0, Math.min(massimo - durata, nuovoInizio));
if (Math.abs(nuovoInizio - tag.start) < 0.001) { toast(t('fx.shiftLimit')); return; }
tag.start = nuovoInizio;
tag.end = nuovoInizio + durata;
// Il pezzo è stato spostato: un eventuale ritaglio già fatto non
// corrisponde più, quindi si riparte dal video sorgente.
if (tag.cutStatus === 'ready') { tag.cutStatus = null; tag.cutUrl = null; }
fxEditorPlayhead = 0;
renderFxEditor();
renderMontagePanel();
const clipCorrente = vt.clips[vt.activeClip];
if (clipCorrente) renderTagList(clipCorrente);
scheduleAutosave();
toast(t('fx.shifted', { s: (delta > 0 ? '+' : '') + delta.toFixed(1) }));
}
/* --------------------------------------------------------- CAPITOLI
* Fra un pezzo e l'altro si può infilare un "fotogramma di capitolo": una
* schermata col logo della squadra e un titolo scritto dal coach, che nel
* filmato finale separa le sezioni (es. "TRANSIZIONE", "DIFESA SUL PICK").
* Vive nella stessa lista d'ordine dei pezzi (vt.montageOrder), quindi si
* sposta su e giù esattamente come loro. */
function ensureChapters() {
if (!Array.isArray(vt.chapters)) vt.chapters = [];
return vt.chapters;
}
function chapterById(id) { return ensureChapters().find(c => c.id === id) || null; }
function isChapterId(id) { return typeof id === 'string' && id.indexOf('ch_') === 0; }
function montageAddChapter() {
const team = activeTeam();
if (!team) { toast(t('toast.selectTeamFirst')); return; }
const ch = { id: 'ch_' + nextId('cap'), title: '', subtitle: '', sec: COPERTINA_SEC };
ensureChapters().push(ch);
if (!Array.isArray(vt.montageOrder)) vt.montageOrder = [];
vt.montageOrder.push(ch.id);
renderMontagePanel();
scheduleAutosave();
}
function montageSetChapter(id, field, value) {
const ch = chapterById(id);
if (!ch) return;
ch[field] = (field === 'sec') ? Math.max(1, Math.min(10, parseFloat(value) || 3)) : value;
const el = document.querySelector(`.montage-row[data-ch="${id}"] .mch-sum`);
if (el && field === 'sec') el.textContent = ch.sec.toFixed(0) + 's';
scheduleAutosave();
}
function montageRemoveChapter(id) {
vt.chapters = ensureChapters().filter(c => c.id !== id);
vt.montageOrder = (vt.montageOrder || []).filter(x => x !== id);
renderMontagePanel();
scheduleAutosave();
}
// Il fotogramma di capitolo disegnato sul canvas: logo della squadra al
// centro, titolo sotto, sottotitolo più piccolo. Stesso disegno nell'anteprima
// e nel file esportato.
/* ============ LA COPERTINA DEL MONTAGGIO ============
* Il fotogramma di apertura (nome della giocatrice o del gioco) e' un gusto
* personale: a un allenatore la grafica blu di VDM piace, a un altro no.
* Quindi si sceglie: la grafica di sempre, una PROPRIA IMMAGINE (un jpeg,
* la locandina della partita, il logo della societa'), oppure un FERMO
* IMMAGINE preso dal video stesso con il nome scritto sopra.
* La scelta sta in DB.copertina e vale per tutti i montaggi. */
/* QUANTO DURA LA COPERTINA fra un argomento e l'altro. Due secondi: il
* tempo di leggere il titolo e via. A tre la squadra comincia a guardarsi
* intorno, e in una presentazione da sette minuti con cinque argomenti sono
* cinque secondi buttati. Il coach puo' scendere a uno, ma non salire. */
const COPERTINA_SEC = 2;
/* La scheda in copertina dura piu' del cartello: c'e' da leggere numero,
* ruolo, medie e caratteristiche, e due secondi non bastano a nessuno. */
const PRES_SCHEDA_SEC = 2;
const COPERTINA_SEC_MAX = 2;
function copertinaSecondi(ch) {
return Math.max(1, Math.min(COPERTINA_SEC_MAX, (ch && ch.sec) || COPERTINA_SEC));
}
function copertinaStato() {
if (!DB.copertina || typeof DB.copertina !== 'object') DB.copertina = { tipo: 'grafica', img: '' };
if (!DB.copertina.tipo) DB.copertina.tipo = 'grafica';
return DB.copertina;
}
let copertinaImg = null; // gia' decodificata, pronta da incidere
function copertinaPrepara() {
const c = copertinaStato();
return new Promise((res) => {
if (c.tipo !== 'immagine' || !c.img) { copertinaImg = null; res(null); return; }
const im = new Image();
im.onload = () => { copertinaImg = im; res(im); };
im.onerror = () => { copertinaImg = null; res(null); };
im.src = c.img;
setTimeout(() => res(copertinaImg), 3000);
});
}
/* La strisciolina per scegliere la copertina, uguale nei due posti dove
* si monta: pannello Montaggio e finestra dei video individuali/giochi. */
function copertinaBarraHtml() {
const c = copertinaStato();
const bott = (tipo, eti) => `${escapeHtml(eti)} `;
return `
🖼️ ${escapeHtml(t('copertina.etichetta'))}
${bott('grafica', t('copertina.grafica'))}
${bott('fermo', t('copertina.fermo'))}
${escapeHtml(t('copertina.immagine'))}
${c.img ? `
✕ ` : ''}
`;
}
function copertinaScegliTipo(tipo) {
const c = copertinaStato();
c.tipo = tipo;
scheduleAutosave();
renderAll();
try { renderPlayerVideos(); } catch (e) {}
toast(t('copertina.scelta', { tipo: t('copertina.' + tipo) }));
}
function copertinaCaricaImmagine(file) {
if (!file) return;
const r = new FileReader();
r.onload = () => {
const c = copertinaStato();
c.img = r.result;
c.tipo = 'immagine';
scheduleAutosave();
renderAll();
try { renderPlayerVideos(); } catch (e) {}
toast(t('copertina.caricata'));
};
r.readAsDataURL(file);
}
function copertinaTogliImmagine() {
const c = copertinaStato();
c.img = '';
if (c.tipo === 'immagine') c.tipo = 'grafica';
scheduleAutosave();
renderAll();
try { renderPlayerVideos(); } catch (e) {}
}
/* Scritta leggibile sopra a una foto: fascia scura sotto e testo bianco.
* Senza la fascia, su un fotogramma chiaro il nome sparisce. */
function scriviTitoloSuFoto(ctx, W, H, titolo, sottotitolo) {
const hFascia = H * 0.30;
const g = ctx.createLinearGradient(0, H - hFascia, 0, H);
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(0.45, 'rgba(0,0,0,.72)');
g.addColorStop(1, 'rgba(0,0,0,.92)');
ctx.fillStyle = g;
ctx.fillRect(0, H - hFascia, W, hFascia);
ctx.fillStyle = '#ffd400';
ctx.fillRect(0, H - Math.max(4, H * 0.014), W, Math.max(4, H * 0.014));
ctx.textAlign = 'center';
ctx.fillStyle = '#fff';
ctx.font = '800 ' + Math.round(H * 0.095) + 'px system-ui, -apple-system, sans-serif';
ctx.fillText(String(titolo || '').toUpperCase(), W / 2, H - hFascia * 0.42);
if ((sottotitolo || '').trim()) {
ctx.fillStyle = '#cfe0f7';
ctx.font = '600 ' + Math.round(H * 0.046) + 'px system-ui, -apple-system, sans-serif';
ctx.fillText(String(sottotitolo).toUpperCase(), W / 2, H - hFascia * 0.12);
}
}
function disegnaImmagineCoprendo(ctx, im, W, H) {
const r = Math.max(W / (im.width || W), H / (im.height || H));
const dw = (im.width || W) * r, dh = (im.height || H) * r;
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, W, H);
ctx.drawImage(im, (W - dw) / 2, (H - dh) / 2, dw, dh);
}
/* ============ LA GRAFICA VDM: FONDO E FIRMA ============
* Il fondo dei cartelli era un gradiente diagonale a due tinte: pulito, ma
* piatto. Qui sopra ci si mettono due luci morbide — una in alto, una che
* sale dal basso nel colore della squadra — e una vignettatura appena
* accennata ai bordi. Restano colori scuri e il titolo sopra si legge
* uguale: cambia solo che l'immagine ha profondita' invece di sembrare una
* cartolina stampata male.
* La firma VDM in fondo e' volutamente MINUSCOLA e quasi trasparente: si
* nota solo se la si cerca, come il marchio in un angolo di una locandina. */
function vdmFondoRicco(ctx, W, H, tinta, tintaScura) {
const g = ctx.createLinearGradient(0, 0, W, H);
g.addColorStop(0, tinta); g.addColorStop(1, tintaScura);
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
// luce alta al centro: da' l'idea di un riflettore sul palco
const alto = ctx.createRadialGradient(W * 0.5, -H * 0.12, 0, W * 0.5, -H * 0.12, H * 1.15);
alto.addColorStop(0, 'rgba(255,255,255,0.13)');
alto.addColorStop(0.55, 'rgba(255,255,255,0.04)');
alto.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = alto; ctx.fillRect(0, 0, W, H);
// alone che sale dal basso a sinistra, nella tinta della squadra
const basso = ctx.createRadialGradient(W * 0.16, H * 1.05, 0, W * 0.16, H * 1.05, H * 1.0);
basso.addColorStop(0, 'rgba(0,229,255,0.10)');
basso.addColorStop(1, 'rgba(0,229,255,0)');
ctx.fillStyle = basso; ctx.fillRect(0, 0, W, H);
// vignettatura appena accennata: tiene l'occhio al centro
const vig = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.35, W / 2, H / 2, Math.max(W, H) * 0.78);
vig.addColorStop(0, 'rgba(0,0,0,0)');
vig.addColorStop(1, 'rgba(0,0,0,0.34)');
ctx.fillStyle = vig; ctx.fillRect(0, 0, W, H);
}
/* La firma. Le lettere si scrivono una per una con lo spazio in mezzo:
* ctx.letterSpacing non c'e' su tutti i sistemi e qui non si puo' rischiare
* che il marchio esca accavallato. */
function vdmFirmetta(ctx, W, H, chiaro) {
const testo = 'VDM';
const dim = Math.max(8, Math.round(H * 0.021));
const spazio = dim * 0.42;
ctx.save();
ctx.font = '800 ' + dim + 'px system-ui, -apple-system, Segoe UI, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.globalAlpha = chiaro ? 0.34 : 0.26;
ctx.fillStyle = chiaro ? '#ffffff' : '#dbe9ff';
const larg = testo.split('').reduce((a, c) => a + ctx.measureText(c).width + spazio, 0) - spazio;
let x = W / 2 - larg / 2;
const y = H - Math.max(8, H * 0.035);
testo.split('').forEach(c => { ctx.fillText(c, x, y); x += ctx.measureText(c).width + spazio; });
ctx.restore();
}
function drawChapterCard(ctx, W, H, ch, logoImg, teamName) {
const scelta = copertinaStato();
// 1) una mia immagine
if (scelta.tipo === 'immagine' && copertinaImg && copertinaImg.width) {
disegnaImmagineCoprendo(ctx, copertinaImg, W, H);
scriviTitoloSuFoto(ctx, W, H, ch.title || teamName || '', ch.subtitle || '');
return;
}
// 2) fermo immagine: il fotogramma e' gia' sulla tela (lo mette il
// montaggio prima di chiamare qui), ci si scrive solo sopra.
if (scelta.tipo === 'fermo' && ch.fotogrammaPronto) {
scriviTitoloSuFoto(ctx, W, H, ch.title || teamName || '', ch.subtitle || '');
return;
}
// 3) la grafica di sempre, con il fondo lavorato
vdmFondoRicco(ctx, W, H, '#0a1428', '#16326a');
ctx.fillStyle = '#ffd400';
ctx.fillRect(0, H - Math.max(4, H * 0.014), W, Math.max(4, H * 0.014));
let y = H * 0.30;
if (logoImg && logoImg.width) {
const lato = Math.min(W, H) * 0.30;
const r = Math.min(lato / (logoImg.width || 1), lato / (logoImg.height || 1));
const lw = (logoImg.width || lato) * r, lh = (logoImg.height || lato) * r;
ctx.drawImage(logoImg, (W - lw) / 2, y - lh / 2, lw, lh);
y += lh / 2 + H * 0.10;
} else {
y = H * 0.36;
}
ctx.textAlign = 'center';
ctx.fillStyle = '#fff';
ctx.font = '800 ' + Math.round(H * 0.105) + 'px system-ui, -apple-system, sans-serif';
ctx.fillText((ch.title || teamName || '').toUpperCase(), W / 2, y);
if ((ch.subtitle || '').trim()) {
ctx.fillStyle = '#a9c4e8';
ctx.font = '600 ' + Math.round(H * 0.052) + 'px system-ui, -apple-system, sans-serif';
ctx.fillText(ch.subtitle.toUpperCase(), W / 2, y + H * 0.095);
}
vdmFirmetta(ctx, W, H, false);
}
function renderMontagePanel() {
const card = document.getElementById('montageCard');
if (!card) return;
const team = activeTeam();
const selBox = document.getElementById('montageSelected');
const availBox = document.getElementById('montageAvailable');
const copBox = document.getElementById('montageCopertina');
if (copBox) copBox.innerHTML = copertinaBarraHtml();
if (!team) { card.style.display = 'none'; return; }
const all = allTeamTags(team);
card.style.display = all.length ? '' : 'none';
if (!all.length) return;
const byId = new Map(all.map(e => [e.tag.id, e]));
// ripulisco l'ordine da eventuali tag cancellati nel frattempo (i capitoli
// restano: non sono tag, vivono in vt.chapters)
ensureChapters();
vt.montageOrder = vt.montageOrder.filter(id => byId.has(id) || (isChapterId(id) && chapterById(id)));
const rowHtml = (entry, opts) => {
const { tag } = entry;
const dur = Math.max(0, tag.end - tag.start);
const side = tag.side === 'attacco' ? 'attacco' : tag.side === 'difesa' ? 'difesa' : 'unclassified';
return `
${opts.index != null ? `${opts.index + 1} ` : ''}
▶
${escapeHtml(montageEntryLabel(tag))}
${fmtTime(tag.start)} → ${fmtTime(tag.end)} · ${dur.toFixed(1)}s
✏️${(tag.fx && tag.fx.length) ? ' ' + tag.fx.length : ''}
${opts.selected ? `
▲
▼
✕ `
: `+ `}
`;
};
const chapterRowHtml = (ch, index) => `
${index + 1}
▶
s
▲
▼
✕
`;
const selectedIds = vt.montageOrder.slice();
const selected = selectedIds.map(id => byId.get(id)).filter(Boolean);
const available = all.filter(e => !vt.montageOrder.includes(e.tag.id))
.sort((a, b) => a.tag.start - b.tag.start);
/* Ogni + o ✕ ridisegna tutte e due le colonne. Senza questa accortezza le
* liste tornavano in cima a ogni click: chi voleva aggiungere venti pezzi
* uno per uno doveva riscorrere l'elenco ogni volta, e la riga sotto il
* dito cambiava sotto il dito. Qui mi segno dov'era la vista e ce la
* rimetto, così l'elenco resta fermo e si clicca in fila. */
const scrollSel = selBox.scrollTop;
const scrollAvail = availBox.scrollTop;
selBox.innerHTML = selectedIds.length
? selectedIds.map((id, i) => isChapterId(id)
? chapterRowHtml(chapterById(id), i)
: rowHtml(byId.get(id), { selected: true, index: i })).join('')
: `${t('montage.emptySelected')}
`;
availBox.innerHTML = available.length
? available.map(e => rowHtml(e, { selected: false })).join('')
: `${t('montage.emptyAvailable')}
`;
selBox.scrollTop = Math.min(scrollSel, Math.max(0, selBox.scrollHeight - selBox.clientHeight));
availBox.scrollTop = Math.min(scrollAvail, Math.max(0, availBox.scrollHeight - availBox.clientHeight));
const totSec = selected.reduce((n, e) => n + Math.max(0, e.tag.end - e.tag.start), 0);
/* il menu veloce/ottimizzato ricorda la scelta fatta l'ultima volta */
const sumEl = document.getElementById('montageSummary');
if (sumEl) sumEl.textContent = selected.length
? t('montage.summary', { n: selected.length, dur: fmtTime(totSec) })
: t('montage.summaryAll', { n: all.length });
const selC = document.getElementById('montageSelCount'); if (selC) selC.textContent = selected.length;
const avC = document.getElementById('montageAvailCount'); if (avC) avC.textContent = available.length;
const btn = document.getElementById('montageExportBtn');
if (btn) btn.textContent = selected.length ? t('montage.exportBtnN', { n: selected.length }) : t('montage.exportBtn');
}
/* ------------------------------------------------- ANTEPRIMA EDIT VIDEO
* Lo schermo della sezione Edit Video è un canvas: ci si disegna sopra il
* fotogramma del video PIÙ i disegni (luce dall'alto, cerchi, frecce, muro,
* scritte) e i fotogrammi di capitolo, esattamente come nel file esportato.
* Prima da questa pagina non si poteva far partire niente: il pulsante ▶
* delle righe cercava il player della scheda Video, che qui non esiste. */
let evStato = null;
let evTagCorrente = null;
// Il video di una clip può non essere in memoria: riaprendo l'app i file
// vengono ripresi da IndexedDB in sottofondo e per qualche istante clip.url è
// vuoto. Prima, in Edit Video, un pezzo senza video veniva saltato in
// silenzio: si premeva ▶ e non succedeva NIENTE, e nell'editor disegni
// l'anteprima restava nera — sembrava tutto rotto. Ora si aspetta il
// recupero, e se davvero non c'è si dice.
/* LA CARTELLA DOVE TIENI LE PARTITE.
* I lavori fatti prima della 3.1.0 non sanno da dove veniva il filmato:
* l'app non se lo segnava. Invece di farteli ripescare uno per uno, basta
* indicare UNA VOLTA la cartella dove stanno i video: da li' in poi ogni
* pezzo rimasto orfano si ritrova da solo, cercando per nome del file. */
function cartellaDeiFilmati() { return (DB && DB.cartellaFilmati) || ''; }
function ricordaCartellaDelFile(percorso) {
if (!percorso) return;
const su = String(percorso).replace(/\\/g, '/');
const cartella = su.slice(0, su.lastIndexOf('/'));
if (cartella) { DB.cartellaFilmati = cartella; scheduleAutosave(); }
}
/* LA RICERCA SI FA UNA VOLTA SOLA.
* Qui c'era il guaio della 3.1.1: ogni pezzo senza filmato faceva ripartire
* la frugata nella cartella. Con dieci pezzi orfani e una cartella grossa
* erano dieci frugate una dietro l'altra — e siccome tutto il resto aspetta
* il suo turno, l'app restava piantata. Adesso si scava UNA volta, il
* risultato si tiene (compresi i nomi NON trovati, che e' la parte che
* conta: senza quelli si ricomincia da capo all'infinito), e non si riparte
* mai da soli. Si rifa' solo se lo chiedi tu, cambiando cartella. */
let _ricerca = { cartella: null, trovati: {}, cercati: new Set(), inCorso: null, completa: true };
function dimenticaLaRicerca() {
_ricerca = { cartella: null, trovati: {}, cercati: new Set(), inCorso: null, completa: true };
}
async function cercaFilmatiMancanti() {
if (!NATIVO) return {};
const cartella = cartellaDeiFilmati();
if (!cartella) return {};
if (_ricerca.cartella !== cartella) dimenticaLaRicerca();
if (_ricerca.inCorso) return _ricerca.inCorso;
// Solo i nomi mai cercati prima: quelli gia' cercati e non trovati non si
// ricercano, altrimenti si riparte ogni volta.
const nomi = [];
(vt.clips || []).forEach(c => {
const n = (c && !c.url && c.name) ? c.name.toLowerCase() : '';
if (n && !_ricerca.cercati.has(n)) nomi.push(c.name);
});
if (!nomi.length) return _ricerca.trovati;
_ricerca.cartella = cartella;
_ricerca.inCorso = (async () => {
try {
const esito = await window.VDM.video.cerca(cartella, nomi) || {};
const trovati = esito.trovati || esito || {};
Object.keys(trovati).forEach(k => { _ricerca.trovati[k] = trovati[k]; });
_ricerca.completa = esito.completa !== false;
} catch (e) { _ricerca.completa = false; }
finally {
/* UNA FRUGATA PER CARTELLA, PUNTO — anche se e' andata male.
* Qui stava il resto del blocco: se la cartella era troppo grande la
* frugata finiva "incompleta", i nomi non venivano segnati come gia'
* cercati, e il pezzo dopo ne faceva partire un'altra. Dodici pezzi
* orfani = dodici frugate in fila, e l'app ferma per un minuto.
* Adesso si segna comunque: riprovare lo decidi tu, indicando di
* nuovo la cartella (che azzera tutto). */
nomi.forEach(n => _ricerca.cercati.add(n.toLowerCase()));
_ricerca.inCorso = null;
}
return _ricerca.trovati;
})();
return _ricerca.inCorso;
}
/* Il percorso vero del file trascinato dentro (solo app installata). */
function percorsoDelFile(f) {
try { return (NATIVO && window.VDM.percorsoDelFile) ? (window.VDM.percorsoDelFile(f) || '') : ''; }
catch (e) { return ''; }
}
/* Come si apre un filmato che sta sul disco. Nella versione dell'App Store
* l'app gira in gabbia e file:// non passa: i byte li serve il processo
* principale sullo schema vdm-file (vedi main.js). L'edizione si chiede una
* volta sola e SUBITO, perche' il primo video puo' partire prima che le
* informazioni complete siano arrivate. */
let _inGabbia = null;
function inGabbia() {
if (_inGabbia === null) {
try { _inGabbia = !!(NATIVO && window.VDM.edizioneSubito && window.VDM.edizioneSubito() === 'appstore'); }
catch (e) { _inGabbia = false; }
}
return _inGabbia;
}
function urlDaPercorso(percorso) {
if (!percorso) return null;
const grezzo = String(percorso);
/* Percorso di rete di Windows: \\NAS\squadra\gara.mp4. Trasformato alla
* lettera diventava file:////NAS/... — quattro barre, nessun computer
* indicato — e il filmato non si apriva: schermo nero, senza nemmeno un
* errore, perche' il controllo dell'esistenza (che passa da Node) invece
* lo trovava benissimo. La forma giusta mette il nome del computer dove
* va l'indirizzo: file://NAS/squadra/gara.mp4 */
if (!inGabbia() && /^\\\\[^\\]/.test(grezzo)) {
const pezzi = grezzo.slice(2).split(/[\\/]+/).filter(Boolean);
const computer = pezzi.shift();
const resto = pezzi.map(encodeURIComponent).join('/');
return 'file://' + encodeURIComponent(computer) + (resto ? '/' + resto : '/');
}
const pulito = encodeURI(grezzo.replace(/\\/g, '/')).replace(/#/g, '%23');
/* Su Windows un percorso normale e' C:\Video\gara.mp4 -> file://C:/... :
* il browser lo raddrizza da solo in file:///C:/..., ma scriverlo giusto
* qui costa una riga e toglie di mezzo una stranezza in meno. */
if (!inGabbia() && /^[A-Za-z]:\//.test(pulito)) return 'file:///' + pulito;
return (inGabbia() ? 'vdm-file://film' : 'file://') + pulito;
}
/* Il filmato di partenza sta dove l'hai lasciato tu: si riapre da li'.
* Se nel frattempo lo hai spostato o rinominato, si dice — e si offre di
* ritrovarlo, invece di lasciare uno schermo nero. */
/* Quello che si sa gia', senza andare a frugare da nessuna parte. */
async function urlSenzaCercare(clip) {
if (!clip) return null;
if (clip.url) return clip.url;
if (NATIVO && clip.percorsoDisco) {
try {
const st = await window.VDM.video.esiste(clip.percorsoDisco);
if (st && st.esiste) { clip.fileSpostato = false; clip.url = urlDaPercorso(clip.percorsoDisco); return clip.url; }
clip.fileSpostato = true;
} catch (e) { /* si prova l'archivio */ }
}
if (NATIVO) {
const dallArchivio = await videoUrlDaDisco(clip.id);
if (dallArchivio) { clip.url = dallArchivio; return dallArchivio; }
}
return null;
}
async function urlDalDiscoDellUtente(clip) {
if (!NATIVO || !clip) return null;
if (clip.percorsoDisco) {
try {
const st = await window.VDM.video.esiste(clip.percorsoDisco);
if (st && st.esiste) { clip.fileSpostato = false; return urlDaPercorso(clip.percorsoDisco); }
clip.fileSpostato = true;
} catch (e) { /* si prova a cercarlo */ }
}
// Nessun percorso (lavoro vecchio) o file spostato: si cerca per nome
// dentro alla cartella dei filmati, se e' stata indicata.
const trovati = await cercaFilmatiMancanti();
const suo = trovati[(clip.name || '').toLowerCase()];
if (suo) {
clip.percorsoDisco = suo;
clip.fileSpostato = false;
scheduleAutosave();
return urlDaPercorso(suo);
}
if (clip.percorsoDisco) clip.fileSpostato = true;
return null;
}
/* Indica la cartella delle partite e rimetti in piedi TUTTI i pezzi
* rimasti senza filmato, in un colpo solo. */
async function scegliCartellaFilmati() {
if (!NATIVO) { toast(t('editvideo.ritrovaSoloApp')); return; }
let cartella = null;
try { cartella = await window.VDM.scegliCartella(t('editvideo.cartellaFilmati')); }
catch (e) { cartella = null; }
if (!cartella) return;
DB.cartellaFilmati = (typeof cartella === 'string') ? cartella : (cartella.percorso || cartella.path || '');
dimenticaLaRicerca();
toast(t('editvideo.cercoFilmati'));
const senza = (vt.clips || []).filter(c => c && !c.url);
// UNA frugata sola per tutti, poi si distribuisce quello che si e' trovato.
const trovatiPerNome = await cercaFilmatiMancanti();
let trovati = 0;
senza.forEach(c => {
const p = trovatiPerNome[(c.name || '').toLowerCase()];
if (p) { c.percorsoDisco = p; c.url = urlDaPercorso(p); c.fileSpostato = false; trovati++; }
});
if (!_ricerca.completa) toast(t('editvideo.cercaTroppoGrande'));
scheduleAutosave();
if (fxEditorTagId) renderFxEditor();
renderAll();
toast(t(trovati ? 'editvideo.ritrovatiN' : 'editvideo.ritrovatiZero', { n: trovati, tot: senza.length }));
}
/* ---- CARICARE UNA CLIP DA FUORI E METTERCI GLI EFFETTI ------------
* Non serve passare dalla scheda Video, taggare e tornare qui: si sceglie
* un filmato dal disco e si apre dritto nell'editor degli effetti, gia'
* pronto da inizio a fine. Il file NON viene copiato: si lavora su quello
* che sta gia' sul tuo disco. */
async function caricaClipEsterna() {
if ((vt.clips || []).length >= limite('video')) return bloccoBeta(t('beta.limitVideos', { n: LIMITI_BETA.video }));
if (NATIVO) {
let percorsi = [];
try { percorsi = await window.VDM.scegliFile({ titolo: t('editvideo.apriEsterna'), multipli: false }) || []; }
catch (e) { percorsi = []; }
if (!percorsi.length) return;
await aggiungiClipEsterna(percorsi[0].split(/[\\/]/).pop(), urlDaPercorso(percorsi[0]), percorsi[0]);
return;
}
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'video/*';
inp.onchange = async () => {
const f = (inp.files || [])[0];
if (f) await aggiungiClipEsterna(f.name, URL.createObjectURL(f), '', f);
};
inp.click();
}
async function aggiungiClipEsterna(nome, url, percorso, fileVero) {
const team = activeTeam();
sistemaSessioni();
if (!sessioneAttiva() || sessioneAttiva().archiviata) nuovaSessione('', team ? team.id : null);
const ses = sessioneAttiva();
const durata = await pvDurataVideo(url);
if (!durata) { toast(t('editvideo.clipEsternaKo')); return; }
const clipId = nextId('clip');
const tagId = nextId('tag');
vt.clips.push({
id: clipId, name: nome, url, percorsoDisco: percorso || '',
teamId: team ? team.id : null, lastPosition: 0, duration: durata,
sessionId: ses ? ses.id : null,
tags: [{
id: tagId, start: 0, end: durata, side: 'attacco',
label: (nome || '').replace(/\.[^.]+$/, ''),
playerId: null, playerLabel: '', indiv: true, fx: [],
}],
});
vt.activeClip = vt.clips.length - 1;
vt.clipAppuntata = clipId;
// Nel browser non c'e' un percorso: li' si tiene la copia, altrimenti al
// riavvio il filmato sparisce.
if (!NATIVO && fileVero) videoBlobDbSave(clipId, fileVero);
scheduleAutosave();
renderAll();
openFxEditor(tagId);
toast(t('editvideo.clipEsternaOk', { nome }));
}
/* Il filmato e' stato spostato o rinominato: si ritrova a mano, una volta
* sola, e il lavoro fatto sopra (tagli, tag, effetti) resta tutto. */
async function ritrovaFilmato(clipId) {
const clip = (vt.clips || []).find(c => c.id === clipId);
if (!clip) return;
if (!NATIVO) { toast(t('editvideo.ritrovaSoloApp')); return; }
let percorsi = [];
try { percorsi = await window.VDM.scegliFile({ titolo: t('editvideo.ritrovaFile'), multipli: false }) || []; }
catch (e) { percorsi = []; }
if (!percorsi.length) return;
clip.percorsoDisco = percorsi[0];
clip.url = urlDaPercorso(percorsi[0]);
clip.fileSpostato = false;
// La cartella di questo file diventa quella dove cercare gli altri: si
// ripesca un filmato a mano, e gli altri vengono dietro da soli.
ricordaCartellaDelFile(percorsi[0]);
dimenticaLaRicerca();
scheduleAutosave();
if (fxEditorTagId) renderFxEditor();
renderAll();
toast(t('editvideo.ritrovato', { nome: clip.name || '' }));
}
async function evAssicuraUrlClip(clip) {
if (!clip) return null;
if (clip.url) return clip.url;
if (NATIVO) {
// Prima il file originale dove sta l'utente: e' quello vero, non serve
// che l'app ne abbia una copia.
const suo = await urlDalDiscoDellUtente(clip);
if (suo) { clip.url = suo; return suo; }
const url = await videoUrlDaDisco(clip.id);
if (url) { clip.url = url; return url; }
}
try {
const blob = await videoBlobDbLoad(clip.id);
if (blob) { clip.url = URL.createObjectURL(blob); return clip.url; }
} catch (e) { /* niente da recuperare */ }
return null;
}
function evMessaggio(testo) {
const ov = document.getElementById('evOverlay');
if (!ov) return;
ov.textContent = testo;
ov.classList.remove('hidden');
}
// Il motore d'anteprima può disegnare su PIU' palchi: quello dell'Edit Video
// ('ev') e quello della finestra Giochi Avversari ('gv'). Cambiando prefisso
// si riusa lo stesso codice invece di duplicarlo.
let evPrefisso = 'ev';
function evElementi() {
const P = evPrefisso;
return {
canvas: document.getElementById(P + 'Canvas'),
video: document.getElementById(P + 'Video'),
overlay: document.getElementById(P + 'Overlay'),
now: document.getElementById(P + 'Now'),
};
}
function evPalco() {
const c = document.getElementById(evPrefisso + 'Canvas');
return c ? c.closest('.ev-stage') : null;
}
function evStop() {
if (evStato) { evStato.attivo = false; }
const palco = evPalco();
if (palco) palco.classList.remove('ev-playing');
const { video, overlay, now } = evElementi();
if (video) { try { video.pause(); } catch (e) {} }
if (overlay) overlay.classList.remove('hidden');
if (now) now.textContent = '';
evStato = null;
}
/* IL DISEGNO VA SUL PALCO SU CUI LA RIPRODUZIONE E' PARTITA.
* Il palco si sceglie con evPrefisso, una variabile sola per tutta l'app. Se
* quella cambia mentre una riproduzione e' in corso, i fotogrammi finiscono
* su un altro schermo e quello che stai guardando resta fermo sull'ultima
* immagine — il filmato va avanti (il tempo scorre, l'audio pure) ma
* l'immagine e' congelata. E' esattamente quello che si vedeva cliccando
* play su una seconda sequenza dentro alla scheda giocatrice. Quindi chi
* riproduce si tiene stretti il suo canvas e il suo video, e non li molla. */
function evDisegnaFotogramma(seg, suoi) {
const { canvas, video } = suoi || evElementi();
if (!canvas) return;
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
if (seg && seg.chapter) {
drawChapterCard(ctx, W, H, seg.chapter, evStato && evStato.logo, (activeTeam() || {}).name);
return;
}
const vw = video.videoWidth || W, vh = video.videoHeight || H;
const scala = Math.min(W / vw, H / vh);
const dw = vw * scala, dh = vh * scala;
const ox = (W - dw) / 2, oy = (H - dh) / 2;
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, W, H);
try { ctx.drawImage(video, ox, oy, dw, dh); } catch (e) {}
if (seg) drawFxOverlay(ctx, seg.fx || [], video.currentTime - seg.start, ox, oy, dw, dh);
}
// Riproduce una lista di pezzi uno dopo l'altro sul canvas.
async function evRiproduci(segments, etichetta) {
evStop();
const { canvas, video, overlay, now } = evElementi();
if (!canvas || !video) return;
if (!segments.length) { toast(t('toast.noClipsToJoin')); return; }
const logo = await new Promise((resolve) => {
const team = activeTeam();
if (!team || !team.logo) { resolve(null); return; }
const im = new Image(); im.onload = () => resolve(im); im.onerror = () => resolve(null); im.src = team.logo;
});
const stato = { attivo: true, logo };
evStato = stato;
if (overlay) overlay.classList.add('hidden');
const palco = evPalco();
if (palco) palco.classList.add('ev-playing');
for (let i = 0; i < segments.length && stato.attivo; i++) {
const seg = segments[i];
if (now) now.textContent = (i + 1) + '/' + segments.length + ' · ' + (seg.label || t('montage.noTag'));
if (seg.chapter) {
const fine = Date.now() + copertinaSecondi(seg.chapter) * 1000;
await new Promise((res) => {
const it = setInterval(() => {
evDisegnaFotogramma(seg, { canvas, video });
if (Date.now() >= fine || !stato.attivo) { clearInterval(it); res(); }
}, 60);
});
continue;
}
if (!seg.src && seg.clipRef) seg.src = await evAssicuraUrlClip(seg.clipRef);
if (!seg.src) {
evMessaggio(t('editvideo.clipMancante', { nome: seg.label || t('montage.noTag') }));
await new Promise(r => setTimeout(r, 1200));
if (overlay) overlay.classList.add('hidden');
continue;
}
if (video.src !== seg.src) {
video.src = seg.src;
await new Promise((res) => {
let fatto = false;
const ok = () => { if (!fatto) { fatto = true; res(); } };
video.onloadedmetadata = ok; video.onerror = ok;
setTimeout(ok, 6000);
});
}
if (!stato.attivo) break;
try { video.currentTime = seg.start; } catch (e) {}
await new Promise((res) => { let f = false; const ok = () => { if (!f) { f = true; res(); } }; video.onseeked = ok; setTimeout(ok, 3000); });
if (!stato.attivo) break;
try { await video.play(); } catch (e) {}
const freezeFatti = new Set();
await new Promise((res) => {
let inFreeze = false, chiuso = false;
/* CHI HA FINITO NON DEVE METTERE IN PAUSA IL VIDEO DI CHI E' SUBENTRATO.
* Il palco ha UN SOLO elemento video, condiviso. Guardando una sequenza
* e cliccando play su un'altra, la vecchia riproduzione si accorge di
* essere stata fermata e chiude — e chiudendo faceva pause() su quel
* video, che nel frattempo era gia' passato alla nuova sequenza. La
* nuova partiva e mezzo secondo dopo si trovava messa in pausa da una
* riproduzione che non esisteva piu': restava ferma sul primo
* fotogramma e sembrava bloccata. Verificato: il video restava in pausa
* per sempre sul punto di partenza della seconda sequenza.
* Quindi la pausa la fa solo chi e' ancora il padrone del palco. */
const fermaSeMio = () => { if (evStato === stato) { try { video.pause(); } catch (e) {} } };
const fine = () => { if (chiuso) return; chiuso = true; clearInterval(bat); fermaSeMio(); res(); };
const mio = { canvas, video };
const passo = () => {
if (!stato.attivo) { fine(); return true; }
if (inFreeze) return false;
if (video.paused || video.ended || video.currentTime >= seg.end) { fine(); return true; }
const tRel = video.currentTime - seg.start;
const rate = fxRateAt(seg.fx, tRel);
if (Math.abs(video.playbackRate - rate) > 0.01) { try { video.playbackRate = rate; } catch (e) {} }
const fz = fxFreezeAt(seg.fx, tRel, freezeFatti);
if (fz) {
freezeFatti.add(fz); inFreeze = true;
try { video.pause(); } catch (e) {}
const stop = Date.now() + (fz.dur || 1) * 1000;
const it = setInterval(() => {
evDisegnaFotogramma(seg, mio);
if (Date.now() >= stop || !stato.attivo) {
clearInterval(it); inFreeze = false;
if (stato.attivo) video.play().catch(() => {}); else fine();
}
}, 60);
}
return false;
};
const bat = setInterval(() => { if (!passo()) evDisegnaFotogramma(seg, mio); }, 100);
(function frame() {
if (chiuso || passo()) return;
evDisegnaFotogramma(seg, mio);
requestAnimationFrame(frame);
})();
});
}
if (evStato === stato) {
if (now) now.textContent = t('editvideo.previewDone');
evStato = null;
if (palco) palco.classList.remove('ev-playing');
}
}
// ▶ su una riga: fa vedere QUEL pezzo, con i suoi disegni.
function evPlayTag(tagId) {
const team = activeTeam();
const e = allTeamTags(team).find(x => x.tag.id === tagId);
if (!e) return;
evTagCorrente = tagId;
const { clip, tag } = e;
const useCut = tag.cutStatus === 'ready' && tag.cutUrl;
const seg = useCut
? { src: tag.cutUrl, start: 0, end: tag.end - tag.start, label: tag.label, fx: tagFx(tag), clipRef: clip }
: { src: clip.url, start: tag.start, end: tag.end, label: tag.label, fx: tagFx(tag), clipRef: clip };
evRiproduci([seg]);
}
function evPlayChapter(chId) {
const ch = chapterById(chId);
if (ch) evRiproduci([{ chapter: ch, label: ch.title || t('editvideo.chapter') }]);
}
function evPlayAll() {
const team = activeTeam();
if (!team) return;
evRiproduci(teamMontageSegments(team));
}
// Apre gli strumenti di disegno sul pezzo che si sta guardando; se non se ne
// sta guardando nessuno, sul primo del montaggio (o sul primo disponibile).
function evApriDisegni() {
const team = activeTeam();
if (!team) return;
const tutti = allTeamTags(team);
if (!tutti.length) { toast(t('editvideo.noClips')); return; }
let id = evTagCorrente;
if (!id || !tutti.some(e => e.tag.id === id)) {
id = (vt.montageOrder || []).find(x => !isChapterId(x)) || tutti[0].tag.id;
}
evStop();
openFxEditor(id);
}
function teamMontageSegments(team) {
// Se il coach ha scelto dei pezzi nel pannello Montaggio, comandano quelli,
// nell'ordine in cui li ha messi. Altrimenti si prende tutto in ordine di
// tempo, come prima.
if (vt.montageOrder && vt.montageOrder.length) {
const byId = new Map(allTeamTags(team).map(e => [e.tag.id, e]));
return vt.montageOrder.map(id => {
if (isChapterId(id)) {
const ch = chapterById(id);
return ch ? { chapter: ch, label: ch.title || t('editvideo.chapter') } : null;
}
const e = byId.get(id);
if (!e) return null;
const { clip, tag } = e;
const useCut = tag.cutStatus === 'ready' && tag.cutUrl;
const fx = Array.isArray(tag.fx) ? tag.fx : [];
return useCut
? { src: tag.cutUrl, start: 0, end: tag.end - tag.start, label: tag.label, fx, clipRef: clip, percorso: tag.cutFile || '' }
: { src: clip.url, start: tag.start, end: tag.end, label: tag.label, fx, clipRef: clip, percorso: clip.percorsoDisco || '' };
}).filter(Boolean);
}
const teamClips = vt.clips.filter(c => c.teamId === team.id);
const segments = [];
teamClips.forEach(clip => {
clip.tags.slice().sort((a, b) => a.start - b.start).forEach(tag => {
if (tag.end <= tag.start) return;
// Se il pezzo è già stato tagliato in automatico (vedi enqueueCut) il
// montaggio riusa direttamente quel file (src=tag.cutUrl, dall'inizio
// alla sua stessa durata) invece di riaprire da capo il video sorgente
// e riposizionarsi su tag.start/tag.end — più veloce, e coerente con
// "prima le clip tagliate, poi il montaggio da quelle già tagliate".
const useCut = tag.cutStatus === 'ready' && tag.cutUrl;
const fx = Array.isArray(tag.fx) ? tag.fx : [];
segments.push(useCut
? { src: tag.cutUrl, start: 0, end: tag.end - tag.start, label: tag.label, fx, percorso: tag.cutFile || '' }
: { src: clip.url, start: tag.start, end: tag.end, label: tag.label, fx, percorso: clip.percorsoDisco || '' });
});
});
return segments;
}
/* Il logo della squadra, decodificato una volta sola: serve ai cartelli di
* capitolo. Se manca o non si carica, il cartello esce col solo titolo. */
function caricaLogoSquadra(team) {
return new Promise((resolve) => {
if (!team || !team.logo) { resolve(null); return; }
const im = new Image();
im.onload = () => resolve(im);
im.onerror = () => resolve(null);
im.src = team.logo;
setTimeout(() => resolve(im.complete ? im : null), 3000);
});
}
/* =========================================================================
* MOTORE DI TAGLIO DIRETTO
*
* Gli altri due motori GUARDANO il filmato: lo aprono in un lettore, lo
* ridisegnano su una tela e da li' fanno il file nuovo. Va fatto per forza
* quando sopra ci sono i disegni del coach (cerchi, frecce, fascio di luce)
* o i cartelli di capitolo, perche' quelle cose vanno incise dentro
* all'immagine. Ma quando sopra non c'e' niente da incidere e' fatica
* sprecata: il pezzo esce rimasticato, piu' piccolo dell'originale, e ci
* mette quanto dura.
*
* Questo invece non guarda niente: copia i fotogrammi originali da un file
* all'altro. Stessa misura della partita, stessa nitidezza, tempo che si
* misura in decimi di secondo. Il lavoro vero lo fa il processo principale
* (la pagina non puo' toccare i file), qui si decide solo SE si puo' fare.
*
* Si puo' fare quando, per ogni pezzo:
* - siamo nell'app e non nel browser
* - il filmato sta su disco e sappiamo dove
* - non ci sono effetti disegnati sopra
* - non e' un cartello di capitolo ne' una pagina disegnata
* In ogni altro caso si risponde null e comanda il motore di prima: da qui
* non si perde mai un montaggio, al massimo non si guadagna tempo.
* ========================================================================= */
function tagliaDirettoPossibile(segments) {
if (!NATIVO || !window.VDM || !window.VDM.taglio) return false;
if (!Array.isArray(segments) || !segments.length) return false;
return segments.every(seg =>
seg && seg.percorso && !seg.chapter && !seg.disegna &&
!(seg.fx && seg.fx.length) &&
(+seg.end > +seg.start));
}
/* Il taglio di UN tag: il pezzo che il coach fa vedere alla squadra. */
async function tagliaTagDiretto(clip, tag) {
if (!tagliaDirettoPossibile([{ percorso: clip && clip.percorsoDisco, start: tag.start, end: tag.end, fx: tag.fx }])) return null;
if (!cartellaDiRiferimentoPronta() || !DB.folderRefPath) return null; // senza cartella non c'e' dove metterlo
const cartelle = cartelleDelTaglio(clip, tag);
const nome = nomeFileDelTaglio(clip, tag, 'mp4');
const percorso = [DB.folderRefPath].concat(cartelle, [nome]).join('/');
let esito = null;
try {
esito = await window.VDM.taglio.diretto(
[{ file: clip.percorsoDisco, da: tag.start, a: tag.end }], percorso);
} catch (e) {
console.warn('VDM: taglio diretto in errore —', e && e.message);
return null;
}
if (!esito || !esito.ok) {
console.warn('VDM: taglio diretto non riuscito —', esito && esito.motivo);
return null;
}
return { percorso, esito };
}
/* Il montaggio intero, quando nessun pezzo ha niente da incidere sopra. */
async function montaggioDiretto(segments, team, nomeFile) {
if (!tagliaDirettoPossibile(segments)) return null;
if (!cartellaDiRiferimentoPronta() || !DB.folderRefPath) return null;
const cartelle = cartelleDelMontaggio(team);
const nomeFinale = (nomeFile ? nomeFile : (team.name || 'squadra').replace(/[^a-z0-9]+/gi, '_') + '_montaggio') + '.mp4';
const percorso = [DB.folderRefPath].concat(cartelle, [nomeFinale]).join('/');
const pezzi = segments.map(seg => ({ file: seg.percorso, da: +seg.start || 0, a: +seg.end || 0 }));
let esito = null;
try {
esito = await window.VDM.taglio.diretto(pezzi, percorso);
} catch (e) {
console.warn('VDM: montaggio diretto in errore —', e && e.message);
return null;
}
if (!esito || !esito.ok) {
console.warn('VDM: montaggio diretto non riuscito —', esito && esito.motivo);
return null;
}
return { relativo: cartelle.join('/') + '/' + nomeFinale, percorso, esito };
}
/* DOVE FINISCE IL MONTAGGIO, uguale per i due motori: nella cartella della
* squadra se c'e', altrimenti scaricato come sempre. */
async function concludiMontaggio(team, blob, ext, nomeFile, saltati, quanti, setStatus) {
const nomeFinale = (nomeFile ? nomeFile : (team.name || 'squadra').replace(/[^a-z0-9]+/gi, '_') + '_montaggio') + '.' + ext;
/* Il taglio diretto scrive gia' lui il file al posto giusto: non c'e'
* nessun blob da salvare, si sa solo dove e' finito. */
const dove = (blob && blob.giaSalvato)
? blob.giaSalvato
: await salvaMontaggioNellaCartella(team, blob, nomeFinale);
if (dove) {
toast(t('montage.salvatoInCartella', { dove }));
} else {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = nomeFinale;
a.click();
URL.revokeObjectURL(url);
}
/* Accanto al montaggio, il suo diario: cosa e' successo mentre veniva
* fatto. E' un file di testo di poche righe, si apre con un doppio clic e
* si puo' mandare. Serve a rispondere alla domanda "perche' questo pezzo
* si e' fermato" con un fatto invece che con un'ipotesi. */
try {
const testoDiario = diarioTesto(nomeFinale);
if (testoDiario) {
const nomeDiario = nomeFinale.replace(/\.[^.]+$/, '') + '-diario.txt';
const bd = new Blob([testoDiario], { type: 'text/plain' });
if (!(await salvaMontaggioNellaCartella(team, bd, nomeDiario))) {
const u2 = URL.createObjectURL(bd);
const a2 = document.createElement('a');
a2.href = u2; a2.download = nomeDiario; a2.click();
URL.revokeObjectURL(u2);
}
}
} catch (e) { /* il diario non deve mai far fallire un montaggio */ }
fxAvanza(t('fx.esportaFinito'), 1);
setTimeout(() => fxAvanza(null), 3500);
const montati = quanti - (saltati || []).length;
if (!montati) {
setStatus(t('montage.allSkipped'));
toast(t('montage.allSkipped'));
montFascia(t('montage.allSkipped'), 1, true);
montAvvisaFuori(t('montage.allSkipped'));
return;
}
const coda = (saltati && saltati.length) ? ' ' + t('montage.skipped', { n: saltati.length, list: saltati.slice(0, 3).join(', ') }) : '';
setStatus(t('montage.ready', { n: montati }) + coda);
toast(t('toast.montageExported') + (coda ? ' —' + coda : ''));
// Il messaggio di fine dice anche DOVE e' finito il file: e' la prima cosa
// che si cerca appena il montaggio e' pronto.
const dettaglio = (dove ? t('montage.salvatoInCartella', { dove }) : t('montage.ready', { n: montati })) + coda;
montFascia(dettaglio, 1, true);
montAvvisaFuori(t('montage.ready', { n: montati }));
}
/* =========================================================================
* MOTORE DI ESPORTAZIONE VELOCE
*
* Il motore storico (piu' sotto) incide il montaggio COME SI GUARDA: fa
* scorrere il filmato a velocita' normale e registra lo schermo. Onesto e
* a prova di bomba, ma sette minuti di presentazione costano sette minuti.
*
* Questo invece non registra: comprime. Legge i fotogrammi piu' in fretta
* del normale e li consegna gia' pronti al compressore del sistema
* (WebCodecs), che ci mette molto meno del tempo reale. Due trucchi:
*
* 1) OGNI FILMATO SI LEGGE AL DOPPIO. Piu' del doppio non conviene: il
* motore video del sistema non riesce a mostrare piu' di una sessantina
* di immagini al secondo, quindi a 4x se ne perderebbe meta' e il
* montaggio uscirebbe a scatti. Misurato, non supposto.
* 2) PIU' PEZZI INSIEME. Due o piu' lettori lavorano in parallelo su pezzi
* diversi: la velocita' si somma. Quanti lettori tenere lo decide da
* solo guardando quanti fotogrammi riesce davvero a prendere: se la
* macchina regge ne aggiunge, se arranca ne toglie.
*
* Il montaggio esce MUTO — l'audio della partita non serve in riunione e
* teneva legato tutto al tempo reale.
*
* Se qualunque cosa qui dentro non funziona (formato che il compressore non
* digerisce, filmato illeggibile, memoria) NON si perde il montaggio: si
* torna al motore storico e il file esce lo stesso, solo piu' lentamente.
* ====================================================================== */
const VEL_FPS = 30; // fotogrammi al secondo del file finito
const VEL_VELOCITA = 2; // quanto piu' in fretta si legge il filmato
const VEL_LETTORI_INIZIO = 2;
/* QUANTI FILMATI SI LEGGONO INSIEME.
* Il montaggio si taglia in pezzi e piu' "lettori" (ognuno con il suo
* filmato nascosto) ne macinano uno per volta in parallelo. Su Mac quattro
* insieme vanno bene. Su Windows no, e si vede dai numeri: piu' lungo era il
* montaggio, peggio veniva — perche' piu' e' lungo, piu' lettori il
* programma aggiungeva.
* Base editata 11s, 2 lettori 76.2% di fotogrammi veri, fermo max 3
* Base 25s 68.7%, fermo max 7
* Presentazione 70s, 4 lettori 62.1%, fermo max 32
* Il chip video di Windows decodifica un filmato alla volta come si deve;
* a quattro va in coda e per un secondo intero non consegna piu' niente.
* Quel secondo, nel file finito, e' un fermo immagine. Meglio due lettori e
* un'attesa piu' lunga che quattro e un video che si pianta. */
/* Per ora resta 4 anche su Windows, apposta: il diario qui sotto deve poter
* confrontare come vanno le cose con due, tre e quattro lettori. Quando il
* confronto avra' risposto, questo numero si mette una volta sola e con una
* prova in mano, invece che a tentativi. */
const VEL_LETTORI_MAX = 4;
/* =====================================================================
* IL DIARIO DEL MONTAGGIO — per smettere di andare a tentativi.
*
* Dal file finito si vede CHE mancano dei fotogrammi, non PERCHE'. Da fuori
* si possono solo fare ipotesi e provarle una per una, ed e' un modo lento
* e frustrante di lavorare. Qui invece il montaggio si annota da solo cosa
* gli succede mentre lo fa: ogni volta che il filmato smette di consegnare
* immagini, segna quando, per quanto, quale pezzo, quale lettore e quanti
* lettori stavano lavorando in quel momento.
* Il resoconto finisce in un file di testo accanto al montaggio. Una sola
* esportazione e si sa cos'e' successo, senza indovinare.
* ===================================================================== */
let diarioMontaggio = null;
function diarioAvvia(quantiPezzi) {
diarioMontaggio = {
inizio: performance.now(),
pezzi: quantiPezzi,
buchi: [], // le pause vere nella consegna dei fotogrammi
fine: [], // com'e' andato ogni pezzo
lettoriMax: 0
};
}
function diarioBuco(dati) {
if (diarioMontaggio && diarioMontaggio.buchi.length < 500) diarioMontaggio.buchi.push(dati);
}
function diarioPezzo(dati) {
if (diarioMontaggio) {
diarioMontaggio.fine.push(dati);
diarioMontaggio.lettoriMax = Math.max(diarioMontaggio.lettoriMax, dati.lettoriVivi || 0);
}
}
function diarioTesto(nomeMontaggio) {
const d = diarioMontaggio;
if (!d) return null;
const sec = (performance.now() - d.inizio) / 1000;
const r = [];
r.push('DIARIO DEL MONTAGGIO — ' + (nomeMontaggio || ''));
/* VDM.info() torna una promessa: qui usciva sempre "VDM ?". Il numero di
versione sta gia' nella firma del programma, si legge da li'. */
const firma = String(navigator.userAgent || '').match(/VDMBasketballCoach\/([0-9.]+)/);
r.push('VDM ' + (firma ? firma[1] : '?') + ' (' + APP_BUILD + ')');
r.push(navigator.userAgent);
r.push('');
r.push('pezzi: ' + d.pezzi + ' lettori in parallelo: fino a ' + d.lettoriMax
+ ' (tetto ' + VEL_LETTORI_MAX + ')');
r.push('durata esportazione: ' + sec.toFixed(1) + 's');
if (d.fotogrammiUsciti != null) {
r.push('fotogrammi nel file finito: ' + d.fotogrammiUsciti
+ ' (a 30 tondi sarebbero stati ' + (d.fotogrammiPrevisti || '?') + ')');
r.push(' sono TUTTI fotogrammi veri: nessuno e ripetuto per riempire');
r.push(' una casella vuota. Il file esce alla cadenza a cui il filmato');
r.push(' consegna davvero, non a 30 forzati.');
}
if (d.cadenzaMedia != null) {
r.push('cadenza del file finito: ' + d.cadenzaMedia.toFixed(2) + ' fotogrammi al secondo');
}
if (d.sceltaPasso) {
r.push('scelta a passo regolare: uno ogni ' + d.sceltaPasso.enne
+ ' fotogrammi del filmato (il filmato ne ha '
+ d.sceltaPasso.sorgente.toFixed(1) + ' al secondo)');
}
if (d.durataUscita != null) {
r.push('durata del montaggio: ' + d.durataUscita.toFixed(2) + 's');
}
r.push('velocita di lettura: ' + montaggioVelocitaLettura() + 'x');
r.push('');
const daUno = d.buchi.filter(b => b.vuote === 1).length;
const veri = d.buchi.filter(b => b.vuote >= 2);
r.push('--- CASELLE RIPETUTE PER SFASAMENTO (una per volta) ---');
r.push(' ' + daUno + ' in tutto. Se sono tante e sparse, il filmato di');
r.push(' partenza ha una cadenza che non combacia con la griglia.');
r.push('');
r.push('--- PAUSE VERE NELLA CONSEGNA DEI FOTOGRAMMI ---');
r.push('(due o piu caselle di fila rimaste vuote: sono i fermi immagine)');
if (!veri.length) r.push(' nessuna pausa degna di nota.');
const perLettori = {};
d.buchi.forEach(b => {
const k = b.lettoriVivi;
if (!perLettori[k]) perLettori[k] = { n: 0, tot: 0, max: 0 };
perLettori[k].n++; perLettori[k].tot += b.vuote; perLettori[k].max = Math.max(perLettori[k].max, b.vuote);
if (b.vuote >= 5) {
r.push(' pezzo ' + b.pezzo + ' (' + (b.etichetta || '') + ') a ' + b.dentro.toFixed(1) + 's dal suo inizio'
+ ' -> ' + b.vuote + ' fotogrammi fermi (' + (b.vuote / VEL_FPS).toFixed(2) + 's)'
+ ' [lettore ' + b.lettore + ' di ' + b.lettoriVivi + ' attivi]');
}
});
r.push('');
r.push('--- LE PAUSE, RAGGRUPPATE PER QUANTI LETTORI LAVORAVANO ---');
r.push('(se il numero peggiora al crescere dei lettori, la causa e quella)');
Object.keys(perLettori).sort().forEach(k => {
const p = perLettori[k];
r.push(' con ' + k + ' lettori: ' + p.n + ' pause, ' + p.tot + ' fotogrammi persi in tutto, la piu lunga ' + p.max);
});
r.push('');
r.push('--- COM E ANDATO OGNI PEZZO ---');
d.fine.forEach(f => {
r.push(' pezzo ' + f.pezzo + ' (' + (f.etichetta || '') + ') ' + f.fotogrammi + ' fotogrammi tenuti'
+ (f.arrivati != null ? ' (su ' + f.arrivati + ' arrivati, ' + (f.scartati || 0) + ' scartati perche troppo vicini)' : '')
+ ' ' + f.fps.toFixed(1) + '/s letto in ' + f.secondi.toFixed(1) + 's'
+ ' [lettore ' + f.lettore + ' di ' + f.lettoriVivi + ']');
});
return r.join('\n') + '\n';
}
const VEL_FPS_BUONI = 27; // sopra questo si puo' aggiungere un lettore
const VEL_FPS_SCARSI = 20; // sotto questo se ne toglie uno
/* Il motore veloce si puo' usare su questa macchina? */
function veloceUsabile() {
return typeof VideoEncoder !== 'undefined'
&& typeof VideoFrame !== 'undefined'
&& typeof Mp4Muxer !== 'undefined'
&& 'requestVideoFrameCallback' in HTMLVideoElement.prototype;
}
/* QUANTO DURA NEL FILE FINITO un pezzo che sul filmato dura tot.
* Non e' la stessa cosa: il rallenty allunga (mezza velocita' = doppio
* tempo) e ogni fermo immagine aggiunge i suoi secondi. Serve saperlo
* PRIMA di cominciare, perche' i lettori lavorano in parallelo e ognuno
* deve sapere in che punto del file finito scrivere. */
function veloceDurataPezzo(seg) {
// schermata disegnata (copertina, cartello, scheda, giocatrice, foto):
// dura i secondi che le ha dato il coach
if (seg.disegna) return Math.max(0.5, +seg.sec || 4);
if (seg.chapter) return copertinaSecondi(seg.chapter);
const durata = Math.max(0, (seg.end || 0) - (seg.start || 0));
let uscita = durata;
(seg.fx || []).forEach(fx => {
if (fx.type === 'slow') {
const r = fx.rate || 0.35;
// la parte rallentata occupa piu' tempo: quanto dura diviso r
const dentro = Math.max(0, Math.min(durata, fx.t + fx.dur) - Math.max(0, fx.t));
if (dentro > 0 && r > 0) uscita += dentro / r - dentro;
} else if (fx.type === 'freeze') {
if (fx.t >= 0 && fx.t <= durata) uscita += (fx.dur || 1);
}
});
return uscita;
}
/* Dove sta, nel file finito, l'istante tRel di questo pezzo. Stessa
* matematica di sopra ma fermata a meta' strada. */
/* MONTAGGIO PRECISO — la strada alternativa, per le macchine lente.
*
* PERCHE' ESISTE. Il montaggio normale fa scorrere il filmato e afferra i
* fotogrammi mentre passano: e' veloce, ma se il decodificatore non sta
* dietro quei fotogrammi non arrivano, e le caselle rimaste vuote si
* riempiono ripetendo l'ultima immagine. Nessun accorgimento sulla velocita'
* di lettura puo' inventare un fotogramma che non e' mai stato consegnato —
* ci abbiamo provato, ed e' andata peggio.
* MISURATO, stesso montaggio, stessa partita a 25 fotogrammi:
* MacBook M5 83,4% di fotogrammi veri (il massimo: 25 su 30)
* PC MSI 50,9%
* Qui invece non si guarda il film che scorre: per ogni casella della
* griglia ci si POSIZIONA all'istante esatto, si aspetta che l'immagine sia
* pronta, e la si cattura. Un fotogramma vero per ogni casella, sempre,
* indipendentemente da quanto e' veloce la macchina. Si paga in tempo.
*
* Per adesso si accende su Windows e resta spento sul Mac, dove la strada
* veloce da' gia' il massimo ottenibile e non c'e' niente da guadagnare. */
const MONTAGGIO_PRECISO = false; // spento: corretto ma troppo lento, vedi sotto
/* LA VELOCITA' DI LETTURA, E PERCHE' SU WINDOWS E' DIVERSA.
*
* Il montaggio legge il filmato piu' in fretta del normale per finire prima,
* e cattura i fotogrammi mentre passano. A velocita' doppia, per riempire 30
* caselle al secondo bisogna che il computer ne decodifichi 50: il Mac ce la
* fa, il PC no — e i fotogrammi che non arrivano diventano copie.
*
* TUTTO QUELLO CHE ABBIAMO MISURATO, stesso montaggio, stessa partita a 25:
* Mac, lettura a 2x 83,4% di fotogrammi veri (il massimo)
* PC, lettura a 2x 50,9%
* PC, con il rallentamento
* automatico piu' reattivo 30,0% (peggio: vedi il commento la')
* PC, a salti, uno per uno 49,4% e troppo lento da usare
*
* La lettura a velocita' NORMALE non l'avevamo mai provata, ed e' la cosa
* ovvia: a 1x il computer deve decodificare 25 fotogrammi al secondo invece
* di 50 — cioe' esattamente quello che fa quando uno guarda il filmato. Il
* montaggio ci mette il doppio del tempo, che e' comunque molto meno del
* metodo a salti, e non chiede alla macchina niente di piu' di quello che
* gia' sa fare.
* Sul Mac resta 2x: li' non serve, e rallentare vorrebbe dire far aspettare
* senza motivo. */
/* VELOCE O OTTIMIZZATO — la scelta del coach.
*
* Il montaggio legge il filmato e cattura i fotogrammi mentre passano. Piu'
* in fretta lo legge, prima finisce — ma piu' fotogrammi il computer non
* riesce a consegnare, e quelli mancanti diventano copie dell'immagine
* precedente: e' quello che si vede come "va a scatti".
*
* MISURATO, stesso montaggio, stessa partita ripresa a 25 fotogrammi
* (percentuale di fotogrammi VERI, e quanto a lungo resta fermo il peggiore):
* MacBook M5 a 2x 83,4% mai piu' di 2 caselle
* PC MSI a 2x 50,9% fino a 12 caselle
* PC MSI a 1x 62,3% fino a 6
* PC MSI a 0,5x 78,0% fino a 3
* Sul PC il tetto non e' il montaggio: e' quanto in fretta quella macchina
* riesce a decodificare quel filmato — le clip balbettano anche solo a
* guardarle. Dandogli il doppio del tempo per fotogramma il problema
* praticamente sparisce.
*
* Da qui le due voci: VELOCE legge al doppio e finisce presto, OTTIMIZZATO
* legge a meta' velocita' e viene liscio, al prezzo del quadruplo del tempo.
* Di suo: ottimizzato su Windows, veloce sul Mac — dove il veloce da' gia'
* il massimo ottenibile e rallentare sarebbe far aspettare per niente.
* La scelta resta salvata: chi ha un computer lento la mette una volta. */
const VEL_VELOCITA_PULITA = 0.5;
/* LA SCELTA VELOCE/OTTIMIZZATO NON C'E' PIU'. Misurata su Windows il 25
* agosto 2026, sullo stesso montaggio e con le stesse clip:
* veloce (2x) 36.6% di fotogrammi veri, 11.0 al secondo, un fermo
* immagine di 62 fotogrammi (due secondi)
* ottimizzato 81.1% di fotogrammi veri, 24.3 al secondo, fermo
* immagine massimo 5 fotogrammi
* Sul Mac il veloce reggeva (83.4%), ma un programma che consegna un video
* a scatti su meta' dei computer non e' un programma che funziona: meglio
* un'attesa piu' lunga e un filmato che si guarda. Restano le funzioni,
* cosi' chi le chiamava non si rompe, ma rispondono sempre allo stesso modo.
* Per rimettere la scelta basta ridare a montaggioQualita() il valore
* salvato e rimettere le tendine (una per barra del montaggio). */
function montaggioQualita() { return 'ottimizzato'; }
function montaggioQualitaImposta() { /* la scelta non esiste piu' */ }
function montaggioVelocitaLettura() {
return VEL_VELOCITA_PULITA;
}
/* Quanto avanza il filmato per ogni casella di montaggio: normalmente 1, ma
* dentro a un rallentatore il filmato avanza piu' piano della griglia. */
function veloceRitmoLocale(seg, tRel) {
let r = 1;
(seg.fx || []).forEach(fx => {
if (fx.type === 'slow' && tRel >= fx.t && tRel < fx.t + fx.dur) r = fx.rate || 0.35;
});
return r;
}
function veloceIstanteUscita(seg, tRel, freezeFatti) {
let u = Math.max(0, tRel);
(seg.fx || []).forEach(fx => {
if (fx.type === 'slow') {
const r = fx.rate || 0.35;
const dentro = Math.max(0, Math.min(tRel, fx.t + fx.dur) - Math.max(0, fx.t));
if (dentro > 0 && r > 0) u += dentro / r - dentro;
}
});
// i fermi immagine gia' consumati hanno gia' allungato il file
(freezeFatti || []).forEach(fx => { u += (fx.dur || 1); });
return u;
}
/* Il compressore. Uno per lettore: cosi' i lettori non si aspettano fra
* loro. Tutti configurati uguali, quindi i pezzi si possono poi rimettere
* in fila uno dietro l'altro. */
function veloceNuovoCompressore(W, H, raccogli, segnalaGuaio) {
const enc = new VideoEncoder({
output: (chunk, meta) => raccogli(chunk, meta),
error: (e) => segnalaGuaio(e)
});
enc.configure({
codec: 'avc1.42001f', // H.264, quello che aprono tutti
width: W, height: H,
// Quanto "pesa" un secondo di filmato. Misurato contro il motore
// storico: a 5 Mbit i montaggi uscivano tre volte piu' grossi di prima,
// e un allenatore che ne fa venti a stagione se ne accorge. A 2,5 la
// qualita' e' un filo meglio di oggi e il peso resta ragionevole.
bitrate: 2500000,
framerate: VEL_FPS,
latencyMode: 'quality',
avc: { format: 'avc' }
});
return enc;
}
/* IL MOTORE. Torna il Blob del .mp4, oppure null se non ce l'ha fatta
* (e allora chi ha chiamato riparte con il motore storico). */
/* SALTARE DOVE SI E' GIA' — ed e' qui che il montaggio si piantava.
*
* Il cartello del capitolo, per mostrare il fermo immagine, va a prendere il
* primo fotogramma del pezzo che viene DOPO: porta il filmato a quel punto e
* lo disegna. Subito dopo tocca a quel pezzo, che fa la stessa cosa —
* currentTime = seg.start — ma il filmato E' GIA' esattamente li'. Chiedere
* a un filmato di andare dov'e' gia' non lo fa muovere, e quindi in molti
* casi non arriva nessun "sono arrivato": si restava ad aspettare fino allo
* scadere dei sei secondi e poi il pezzo veniva SALTATO. Nel filmato finito
* quel pezzo non c'era: restava la copertina ferma e sembrava bloccato — la
* prima di ogni gruppo, che e' proprio quella subito dopo un cartello.
*
* Quindi: se siamo gia' li' e c'e' abbastanza filmato in memoria per
* disegnare, si va avanti senza aspettare niente. */
function giaAlPunto(v, t) {
return !!v && v.readyState >= 2 && Math.abs((v.currentTime || 0) - (+t || 0)) < 0.04;
}
async function esportaMontaggioVeloce(segments, team, logoImg, setStatus, setAvanzamento) {
if (!veloceUsabile()) return null;
/* La misura del file finito la detta il filmato di partenza, non una
costante: si sbircia il primo pezzo e si prende la sua (con il tetto
di 1920x1080). Il compressore va acceso con la misura definitiva,
percio' questo deve succedere prima di isConfigSupported. */
let W = 960, H = 540;
try {
if (segments && segments.length && segments[0] && segments[0].src) {
const m = await montSondaSorgente(segments[0].src);
if (m && m.w && m.h) { W = m.w; H = m.h; }
}
} catch (e) { /* misura non leggibile: si resta a 960x540 */ }
try {
const prova = await VideoEncoder.isConfigSupported({
codec: 'avc1.42001f', width: W, height: H, bitrate: 5000000, framerate: VEL_FPS, avc: { format: 'avc' }
});
if (!prova || !prova.supported) return null;
} catch (e) { return null; }
diarioAvvia(segments.length);
// Dove comincia ogni pezzo dentro al file finito.
const inizi = [];
let scorre = 0;
segments.forEach(seg => { inizi.push(scorre); scorre += veloceDurataPezzo(seg); });
const durataTotale = Math.max(0.1, scorre);
const pezziFatti = new Array(segments.length).fill(null); // fotogrammi compressi
const saltati = [];
let configDecoder = null;
let guaio = null;
let prossimo = 0; // il prossimo pezzo da assegnare a un lettore
let lettoriVivi = 0;
let ultimoFps = VEL_FPS;
const fattiPerAvanzamento = new Array(segments.length).fill(0);
const aggiornaBarra = () => {
const fatto = fattiPerAvanzamento.reduce((a, b) => a + b, 0);
setAvanzamento(Math.min(0.98, fatto / durataTotale));
};
/* Un lettore: un tutto suo, una tela tutta sua, un compressore
* tutto suo. Prende pezzi dalla fila finche' ce ne sono. */
async function lettore(numero) {
lettoriVivi++;
const v = document.createElement('video');
v.muted = true; v.playsInline = true; v.crossOrigin = 'anonymous';
v.preload = 'auto';
try { v.preservesPitch = false; } catch (e) {}
v.style.cssText = 'position:fixed;left:-9999px;top:0;width:2px;height:2px;opacity:0.01;';
document.body.appendChild(v);
const tela = document.createElement('canvas');
tela.width = W; tela.height = H;
const cx = tela.getContext('2d', { alpha: false, desynchronized: true });
let mioSrc = null;
let miei = [];
const enc = veloceNuovoCompressore(W, H, (chunk, meta) => {
if (meta && meta.decoderConfig && !configDecoder) configDecoder = meta.decoderConfig;
const dati = new Uint8Array(chunk.byteLength);
chunk.copyTo(dati);
miei.push({ tipo: chunk.type, ts: chunk.timestamp, dur: chunk.duration, dati });
}, (e) => { guaio = guaio || e; });
const attesa = (assegna, ms) => new Promise((res) => {
let fatto = false;
const fine = (esito) => { if (!fatto) { fatto = true; res(esito); } };
assegna(fine);
setTimeout(() => fine('timeout'), ms);
});
const disegna = (seg, tRel) => {
const vw = v.videoWidth || W, vh = v.videoHeight || H;
const scala = Math.min(W / vw, H / vh);
const dw = vw * scala, dh = vh * scala;
const ox = (W - dw) / 2, oy = (H - dh) / 2;
cx.fillStyle = '#000'; cx.fillRect(0, 0, W, H);
cx.drawImage(v, ox, oy, dw, dh);
drawFxOverlay(cx, seg.fx || [], tRel, ox, oy, dw, dh);
};
let ultimoTs = -1;
let pezzoCorrente = -1; // quale pezzo sto macinando (serve al diario)
let etichettaCorrente = '';
let inizioPezzoUscita = 0; // dove comincia il pezzo dentro al montaggio
let incisi = 0; // fotogrammi mandati al compressore per QUESTO pezzo
let ultimoSlot = null; // casella della griglia gia' occupata
let reali = 0; // di quelli incisi, quanti venivano dal filmato
let arrivati = 0; // quanti ne ha consegnati il filmato per QUESTO pezzo
const incidiGrezzo = (tsSecondi) => {
let ts = Math.round(tsSecondi * 1e6);
if (ts <= ultimoTs) ts = ultimoTs + 1;
ultimoTs = ts;
const f = new VideoFrame(tela, { timestamp: ts, duration: Math.round(1e6 / VEL_FPS) });
// UN fotogramma chiave all'inizio di ogni pezzo, e uno solo: serve
// perche' i pezzi vengono compressi da lettori diversi e poi rimessi
// in fila, e ognuno deve poter cominciare da se'. Contarli qui e non
// sui pezzi gia' compressi: quelli arrivano con calma, e per i primi
// fotogrammi la lista sarebbe ancora vuota — uscivano tutti chiave,
// e il file pesava il doppio per niente.
enc.encode(f, { keyFrame: incisi === 0 });
incisi++;
f.close();
};
/* I BUCHI ALL'INIZIO DEL PEZZO — perche' il montaggio "parte a scatti".
* I fotogrammi di un pezzo normale non si prendono a cadenza fissa: si
* prendono quando il filmato ne consegna uno (requestVideoFrameCallback).
* Nei primi decimi di secondo dopo il salto e l'avvio, il decodificatore
* del sistema deve ancora ingranare e ne consegna pochi: il file finito
* riceve due o tre fotogrammi dove ne servirebbero dieci, e all'inizio si
* vede procedere a scatti. Piu' avanti la cadenza si stabilizza e non si
* nota piu' — ed e' esattamente com'e' stato descritto: "all'inizio".
* Quindi qui si tappano i buchi: se fra un fotogramma e il precedente e'
* passato piu' di un fotogramma e mezzo di tempo d'uscita, si incidono
* delle copie a cadenza regolare per coprire il vuoto. Costano pochissimo
* (un fotogramma identico al precedente si comprime a quasi niente) e
* l'occhio vede un'immagine ferma un istante invece di uno strappo.
* Tetto di un secondo di riempimento, per non rincorrere buchi veri. */
/* LA GRIGLIA DEI 30. Il file finito dichiara 30 fotogrammi al secondo,
* ma i fotogrammi arrivavano col ritmo del filmato di partenza: una
* partita ripresa a 25 ne consegna 25, spaziati di 40 millesimi mentre
* la griglia ne vuole 33,3. Nessuno dei due era sbagliato di per se',
* ma messi insieme davano una cadenza storta — ed e' quello che si vede
* come "va a scatti", per tutta la durata e non solo all'avvio. Il
* tappabuchi di prima non se ne accorgeva nemmeno: interveniva sopra i
* 53 millesimi, e 40 gli passavano sotto il naso.
* Adesso ogni fotogramma viene messo nella casella della griglia che
* gli spetta; le caselle rimaste vuote si riempiono ripetendo l'ultimo
* (25 che diventano 30: cinque volte al secondo un fotogramma dura il
* doppio, ed e' il modo standard di farlo). Se invece il filmato va
* piu' fitto della griglia — 50 fotogrammi al secondo — quelli in piu'
* si scartano, invece di allungare il montaggio. */
/* LO SFASAMENTO — perche' rimontare un montaggio lo peggiorava.
* Ogni fotogramma va messo nella casella della griglia dei 30 che gli
* spetta, e la casella si calcolava arrotondando il suo istante dentro
* al montaggio. Ma quell'istante nasce da seg.start, cioe' dal punto in
* cui il coach ha tagliato: un numero qualunque, quasi mai un multiplo
* esatto di un trentesimo di secondo. Con mezzo trentesimo di
* sfasamento l'arrotondamento sbaglia a intermittenza: qualche casella
* riceve due fotogrammi (uno si butta) e qualcuna nessuno (si ripete il
* precedente). Misurato su un montaggio rimontato: 87 doppioni in piu'
* su 1051 fotogrammi, circa due e mezzo al secondo — invisibili al
* diario, perche' sono buchi da UNA casella sola.
* Si vedeva soprattutto ricomponendo materiale gia' montato, che e' poi
* il modo normale di fare una presentazione.
* La cura: le caselle si contano a partire dal PRIMO fotogramma del
* pezzo, non dallo zero del montaggio. Cosi' quello che conta sono le
* differenze fra un fotogramma e l'altro — e quelle sono multipli esatti
* del passo del filmato, quindi lo sfasamento sparisce. Un filmato a 30
* esce identico a com'e' entrato; uno a 25 si allarga a 30 come prima. */
/* ===== LA SCELTA DEI FOTOGRAMMI, A PASSO REGOLARE =====
*
* Ultimo pezzo del puzzle, e il piu' sottile. Il filmato di Vince e' un
* 1920x1080 a CINQUANTA fotogrammi al secondo (verificato prendendo
* l'impronta di tutti e 743 i fotogrammi di una clip: nessuna uguale a
* un'altra, sono tutte immagini diverse). Il computer, mentre monta, ne
* consegna circa 28,8 per ogni secondo di film — non riesce a starci
* dietro con tutti e 50, e va benissimo cosi'.
* Il guaio era QUALI 28,8. Si prendeva "il primo che capita" in ogni
* casella: cosi' due fotogrammi lontani 20 millesimi finivano uno
* accanto all'altro, e la coppia dopo era lontana 60. Nel file finito i
* fotogrammi sono equidistanti, ma il MOVIMENTO dentro no: un passetto
* corto, uno lungo, un passetto corto. E' quello che l'occhio legge
* come "poco fluido", ed e' anche quello che lo strumento di misura
* conta come immagine ripetuta.
*
* Adesso si sceglie a passo regolare. Si misura da sola la distanza fra
* un fotogramma e l'altro del filmato di partenza (0,02s se e' un 50) e
* si tiene UNO OGNI N, con N scelto perche' la cadenza in uscita cada
* vicino ai 27 al secondo: un 50 diventa 25 (uno si', uno no — passo
* perfetto), un 30 resta 30, un 60 diventa 30. Fra un fotogramma tenuto
* e il successivo c'e' sempre la stessa identica quantita' di gioco.
* Il fotogramma tenuto e' quello che cade PIU' VICINO al bersaglio, non
* il primo che passa: si guarda se il prossimo cadra' piu' vicino di
* questo e, se si', si aspetta.
* Misurato: la stessa clip riportata a 25 a passo perfetto da uno
* strumento professionale fa il 100% di fotogrammi veri; a 30 con passo
* regolare fa il 91,5% (a 30 il tetto e' quello, non il 100%).
* (27 agosto 2026) */
let periodoSorgente = 0; // quanto dista un fotogramma dall'altro NEL FILMATO
let ultimoFilm = null; // istante del filmato dell'ultimo arrivo
let bersaglioFilm = null; // dove vorrei il prossimo fotogramma
let passoScelta = 0; // ogni quanto tengo un fotogramma
let scartati = 0; // arrivati ma non tenuti (troppo vicini)
/* QUANTO DISTA UN FOTOGRAMMA DALL'ALTRO NEL FILMATO.
*
* Prima lo deducevo dal salto piu' piccolo fra due arrivi. Sbagliato, e
* misurato: su due filmati che sono entrambi a 50 al secondo il diario
* ha scritto 69,1 e 40,2. Con quei numeri la regola sceglieva un passo
* sbagliato e non scartava quasi niente — 15 fotogrammi su 870 in un
* caso, 1 su 1017 nell'altro — quindi la 3.7.20 e' rimasta identica
* alla 3.7.19 (83,7% contro 84,9%, cioe' rumore di macchina).
*
* Adesso il numero non si deduce: si legge. Il filmato dice quanti
* fotogrammi ha mostrato in tutto (presentedFrames), COMPRESI quelli che
* a noi non sono arrivati. Il passo e' quanto tempo di film e' passato
* diviso quanti fotogrammi ha mostrato: esatto, non stimato.
* (27 agosto 2026) */
let ultimiMostrati = null;
const misuraPasso = (tRel, meta) => {
const mostrati = meta && typeof meta.presentedFrames === 'number' ? meta.presentedFrames : null;
if (mostrati != null && ultimiMostrati && ultimoFilm != null) {
const dFilm = tRel - ultimoFilm;
const dFrames = mostrati - ultimiMostrati;
if (dFrames > 0 && dFilm > 0 && dFilm < 1) {
const p = dFilm / dFrames;
// fra 10 e 240 fotogrammi al secondo: fuori di li' e' un errore
if (p > 0.004 && p < 0.1) periodoSorgente = periodoSorgente ? (periodoSorgente * 0.8 + p * 0.2) : p;
}
} else if (ultimoFilm != null && mostrati == null) {
// il filmato non dice quanti ne ha mostrati: si ripiega sul salto minimo
const d = tRel - ultimoFilm;
if (d > 0.004 && d < 0.5 && (!periodoSorgente || d < periodoSorgente)) periodoSorgente = d;
}
if (mostrati != null) ultimiMostrati = mostrati;
ultimoFilm = tRel;
};
/* Vero se questo fotogramma va tenuto. Da chiamare PRIMA di disegnarlo:
* quelli scartati non si disegnano nemmeno, e cosi' il montaggio va
* anche piu' svelto. */
const daTenere = (tRel, meta) => {
misuraPasso(tRel, meta);
const base = periodoSorgente || (1 / VEL_FPS);
const enne = Math.max(1, Math.round(1 / (27 * base)));
passoScelta = enne * base;
try {
if (diarioMontaggio && periodoSorgente > 0) {
diarioMontaggio.sceltaPasso = { enne: enne, sorgente: 1 / periodoSorgente };
}
} catch (e) {}
if (bersaglioFilm === null) { bersaglioFilm = tRel; return true; }
/* SI SCARTA SOLO CHI E' TROPPO ATTACCATO AL PRECEDENTE.
* Provate due regole e simulate tutte e due (sorgente a 25, 30, 50 e
* 60, con il computer che consegna dai 20 ai 50 fotogrammi):
* - inseguire una griglia fissa toglieva anche fotogrammi buoni e
* faceva scendere la cadenza da 28,8 a 20;
* - questa toglie SOLO le coppie troppo vicine — quelle che valgono
* mezzo passo e si vedono come tremolio — e lascia stare tutto il
* resto: le coppie sbagliate vanno a zero e la cadenza resta alta.
* Su un filmato gia' regolare (un 30 letto bene) non scarta niente,
* quindi non puo' peggiorare quello che gia' funzionava. */
if (tRel - bersaglioFilm < passoScelta * 0.6) { scartati++; return false; }
bersaglioFilm = tRel;
return true;
};
let riferimento = null;
const incidi = (tsSecondi) => {
const passo = 1 / VEL_FPS;
if (riferimento === null) riferimento = { t: tsSecondi, slot: Math.round(tsSecondi / passo) };
let slot = riferimento.slot + Math.round((tsSecondi - riferimento.t) / passo);
if (ultimoSlot != null) {
if (slot <= ultimoSlot) slot = ultimoSlot + 1; // i tempi qui servono solo a tenere l'ordine
const buchi = Math.min(VEL_FPS, slot - ultimoSlot - 1);
/* Ogni casella vuota riempita e' un fotogramma che il filmato non ha
* consegnato: e' esattamente cio' che poi si vede come fermo
* immagine. Segnarlo qui, con il suo contorno, e' l'unico modo di
* sapere DA COSA dipende invece di tirare a indovinare. */
diarioBuco({
pezzo: pezzoCorrente, etichetta: etichettaCorrente, lettore: numero,
lettoriVivi: lettoriVivi, vuote: buchi, dentro: slot * passo - inizioPezzoUscita
});
/* LE CASELLE VUOTE NON SI RIEMPIONO PIU'.
* Riempirle voleva dire ripetere il fotogramma precedente, e quello
* e' esattamente il 26% di immagini finte che si misurava nel file
* finito: il filmato consegna 28,8 fotogrammi per secondo di film,
* la griglia da 30 ne pretende 30, e la differenza la inventavamo
* noi. Adesso si tengono solo i fotogrammi VERI e il file finito
* viene scritto alla cadenza a cui sono arrivati (vedi piu' sotto,
* dove si impacchetta): stessa durata, stessa velocita', ma nessuna
* immagine ripetuta. (27 agosto 2026) */
}
incidiGrezzo(slot * passo);
ultimoSlot = slot;
return true;
};
while (montageRunning && !guaio) {
const i = prossimo++;
if (i >= segments.length) break;
// un lettore di troppo si ritira, ma solo se non resta scoperto
if (lettoriVivi > 1 && ultimoFps < VEL_FPS_SCARSI) { prossimo--; break; }
const seg = segments[i];
// Diario di bordo, acceso solo se qualcuno lo chiede (window.__diarioMontaggio = []):
// serve a capire, su una macchina che va piano, dove se ne va il tempo.
if (window.__diarioMontaggio) window.__diarioMontaggio.push({ ev: 'inizio', lettore: numero, pezzo: i, t: Math.round(performance.now()) });
miei = [];
ultimoTs = -1;
incisi = 0;
ultimoSlot = null;
riferimento = null;
reali = 0;
periodoSorgente = 0;
ultimoFilm = null;
bersaglioFilm = null;
passoScelta = 0;
scartati = 0;
arrivati = 0;
ultimiMostrati = null;
const t0uscita = inizi[i];
pezzoCorrente = i;
etichettaCorrente = seg.chapter ? 'cartello' : (seg.disegna ? 'pagina' : (seg.label || ''));
inizioPezzoUscita = t0uscita;
// Con piu' lettori in parallelo dire "sto facendo il pezzo 3" faceva
// saltellare il numero avanti e indietro: si conta quanti ne sono
// FINITI, che e' quello che interessa a chi guarda.
const finiti = pezziFatti.filter(x => x !== null).length;
setStatus(t('montage.progress', { i: Math.min(segments.length, finiti + 1), n: segments.length, label: seg.label || t('montage.noTag') }));
/* ---- SCHERMATA DISEGNATA (le pagine della presentazione):
* nessun filmato da leggere, si disegna una volta e si incide per i
* secondi richiesti. La funzione che disegna e' la STESSA che disegna
* la proiezione: quello che vedi sullo schermo e quello che finisce
* nel file non possono divergere. */
if (seg.disegna) {
cx.fillStyle = '#000'; cx.fillRect(0, 0, W, H);
try { seg.disegna(cx, W, H); } catch (err) { /* pagina saltata, non si ferma tutto */ }
const quanti = Math.max(1, Math.round(veloceDurataPezzo(seg) * VEL_FPS));
for (let k = 0; k < quanti && montageRunning; k++) {
incidi(t0uscita + k / VEL_FPS);
if (enc.encodeQueueSize > 24) await new Promise(r => setTimeout(r, 1));
}
fattiPerAvanzamento[i] = veloceDurataPezzo(seg);
aggiornaBarra();
await enc.flush();
pezziFatti[i] = miei;
continue;
}
// ---- CARTELLO DI CAPITOLO: nessun filmato da leggere
if (seg.chapter) {
const scelta = copertinaStato();
let fermoPronto = false;
if (scelta.tipo === 'fermo') {
const succ = segments.slice(i + 1).find(x => x && x.src);
if (succ) {
try {
if (mioSrc !== succ.src) {
v.src = succ.src;
const es = await attesa((f) => { v.onloadedmetadata = () => f('ok'); v.onerror = () => f('errore'); }, 8000);
mioSrc = (es === 'ok') ? succ.src : null;
}
if (mioSrc === succ.src && v.videoWidth) {
if (!giaAlPunto(v, succ.start)) {
v.currentTime = succ.start;
await attesa((f) => { v.onseeked = () => f('ok'); }, 5000);
}
disegna({ fx: [] }, 0);
fermoPronto = true;
}
} catch (e) { fermoPronto = false; }
}
}
seg.chapter.fotogrammaPronto = fermoPronto;
if (!fermoPronto) { cx.fillStyle = '#000'; cx.fillRect(0, 0, W, H); }
drawChapterCard(cx, W, H, seg.chapter, logoImg, team.name);
const quanti = Math.round(veloceDurataPezzo(seg) * VEL_FPS);
for (let k = 0; k < quanti && montageRunning; k++) {
incidi(t0uscita + k / VEL_FPS);
if (enc.encodeQueueSize > 24) await new Promise(r => setTimeout(r, 1));
}
fattiPerAvanzamento[i] = veloceDurataPezzo(seg);
aggiornaBarra();
await enc.flush();
pezziFatti[i] = miei;
continue;
}
// ---- PEZZO NORMALE
if (!seg.src) { saltati.push(seg.label || t('montage.noTag')); pezziFatti[i] = []; continue; }
if (mioSrc !== seg.src) {
v.src = seg.src;
const es = await attesa((f) => { v.onloadedmetadata = () => f('ok'); v.onerror = () => f('errore'); }, 8000);
if (es !== 'ok' || !v.videoWidth) { saltati.push(seg.label || t('montage.noTag')); pezziFatti[i] = []; mioSrc = null; continue; }
mioSrc = seg.src;
}
let seek = 'ok';
if (!giaAlPunto(v, seg.start)) {
v.currentTime = seg.start;
seek = await attesa((f) => { v.onseeked = () => f('ok'); }, 6000);
}
if (seek !== 'ok') { saltati.push(seg.label || t('montage.noTag')); pezziFatti[i] = []; continue; }
/* IL PRIMO FOTOGRAMMA DEL PEZZO SI INCIDE SUBITO.
* Fin qui il pezzo cominciava dove arrivava il primo fotogramma che il
* filmato si degnava di consegnare dopo il via: misurato, anche un
* TERZO DI SECONDO dopo. In quel buco il montaggio resta fermo sul
* cartello, e sembra bloccato — si vedeva soprattutto sul primo player
* della raccolta, che e' il primo pezzo subito dopo una copertina.
* Adesso il fermo immagine appena raggiunto col salto viene inciso
* subito, al suo istante giusto: il pezzo parte quando deve, e il
* tappabuchi qui sopra copre il resto dell'avvio. */
disegna(seg, 0);
incidi(t0uscita);
const freezeFatti = [];
const gia = new Set();
let fotogrammi = 0;
/* ====== STRADA PRECISA (macchine lente): una casella per volta ======
* Non si fa scorrere niente: ci si sposta all'istante esatto di ogni
* casella, si aspetta che il fotogramma sia dipinto, lo si incide.
* Cosi' ogni casella contiene un'immagine VERA e diversa dalla
* precedente — che e' esattamente quello che mancava sul PC. */
if (MONTAGGIO_PRECISO) {
const durataFilmP = Math.max(0.05, seg.end - seg.start);
let tRelP = 0;
let contaP = 0;
try { v.pause(); } catch (e) {}
while (tRelP < durataFilmP && montageRunning && !guaio) {
const bersaglio = seg.start + tRelP;
/* QUI NON SI USA giaAlPunto, ED E' IL PUNTO DI TUTTO.
* Quella funzione considera "gia' arrivati" uno scarto fino a 40
* millesimi — giusto per far ripartire un video, sbagliato qui:
* noi avanziamo di 33 millesimi per volta, quindi un salto su due
* veniva considerato inutile e saltato, e si catturava due volte
* lo stesso fotogramma. Misurato: 155 fotogrammi su 168 duravano
* esattamente il doppio — regolarissimo, come solo un difetto sa
* essere. Qui la tolleranza deve valere meno di mezza casella. */
if (Math.abs((v.currentTime || 0) - bersaglio) > (0.4 / VEL_FPS)) {
v.currentTime = bersaglio;
const es = await attesa((f) => { v.onseeked = () => f('ok'); }, 4000);
if (es !== 'ok') break;
}
/* Il salto dice che il fotogramma e' decodificato, ma non sempre
* che e' gia' finito sullo schermo: si da' una finestra breve al
* segnale del fotogramma dipinto, e se non arriva si disegna lo
* stesso — a quel punto l'immagine c'e' comunque. */
await attesa((f) => { try { v.requestVideoFrameCallback(() => f('ok')); } catch (e) { f('no'); } }, 250);
disegna(seg, tRelP);
incidi(t0uscita + veloceIstanteUscita(seg, tRelP, freezeFatti));
const fzP = fxFreezeAt(seg.fx, tRelP, gia);
if (fzP) {
gia.add(fzP);
const baseP = t0uscita + veloceIstanteUscita(seg, tRelP, freezeFatti);
const quantiP = Math.round((fzP.dur || 1) * VEL_FPS);
for (let k = 1; k <= quantiP; k++) incidi(baseP + k / VEL_FPS);
freezeFatti.push(fzP);
}
if (enc.encodeQueueSize > 24) await new Promise(r => setTimeout(r, 2));
tRelP += veloceRitmoLocale(seg, tRelP) / VEL_FPS;
contaP++;
fattiPerAvanzamento[i] = Math.min(veloceDurataPezzo(seg), veloceIstanteUscita(seg, tRelP, freezeFatti));
if (contaP % 10 === 0) aggiornaBarra();
}
/* NIENTE somma a t0uscita: l'inizio di ogni pezzo nel montaggio e'
* gia' calcolato prima (inizi[i]) ed e' una costante. Sommarci
* qualcosa qui non solo e' inutile, e' un errore che in modalita'
* rigorosa fa fermare tutto. */
fattiPerAvanzamento[i] = veloceDurataPezzo(seg);
aggiornaBarra();
await enc.flush();
pezziFatti[i] = miei;
continue;
}
const inizioOrologio = performance.now();
v.playbackRate = montaggioVelocitaLettura();
const avvio = await Promise.race([
v.play().then(() => 'ok').catch(() => 'errore'),
new Promise(r => setTimeout(() => r('timeout'), 5000))
]);
if (avvio !== 'ok') { saltati.push(seg.label || t('montage.noTag')); pezziFatti[i] = []; try { v.pause(); } catch (e) {} continue; }
const durataFilm = Math.max(0.05, seg.end - seg.start);
// Il tetto di sicurezza si calcola sulla velocita' PIU' LENTA possibile
// (1x): da quando la lettura puo' rallentare, un tetto tarato sul 2x
// taglierebbe il pezzo a meta' proprio sulle macchine lente.
const tetto = performance.now() + durataFilm * 1000 * 3 + 8000;
await new Promise((chiudi) => {
let finito = false;
const basta = () => { if (finito) return; finito = true; try { v.onended = null; v.pause(); } catch (e) {} chiudi(); };
const passo = (ora, meta) => {
if (finito) return;
if (!montageRunning || guaio) { basta(); return; }
/* mediaTime dice ESATTAMENTE a che punto del filmato sta il
* fotogramma che si ha davanti; currentTime dice dove sta la
* lettura in questo istante, che e' un'altra cosa e balla di
* qualche millesimo. Per scegliere a passo regolare serve il
* primo. */
const tFilm = (meta && typeof meta.mediaTime === 'number') ? meta.mediaTime : v.currentTime;
const tRel = tFilm - seg.start;
if (tRel >= durataFilm || v.ended || performance.now() > tetto) { basta(); return; }
arrivati++;
if (daTenere(tRel, meta)) {
disegna(seg, tRel);
if (incidi(t0uscita + veloceIstanteUscita(seg, tRel, freezeFatti))) reali++;
fotogrammi++;
}
/* SI RALLENTA LA LETTURA SE IL MAC NON STA DIETRO.
* I fotogrammi si prendono quando il filmato ne consegna uno, e un
* Mac non ne mostra piu' di una sessantina al secondo. Leggendo al
* DOPPIO, per riempire 30 fotogrammi di file al secondo gliene si
* chiedono 60: se e' un filmato lungo, grande, o la macchina ha
* altro da fare, non ce la fa e ne consegna meno. Il buco che ne
* esce e' di UN fotogramma alla volta — invisibile a fermo, ma in
* movimento e' quel tremolio leggero che da' fastidio piu' di uno
* scatto vero (misurato su una presentazione: 27,7 fotogrammi al
* secondo invece di 30).
* Il tappabuchi da solo non basta: quello ripara, ma raddoppiare un
* fotogramma vero e' sempre meglio che inventarlo. Quindi qui si
* guarda come sta andando e, se si resta sotto, si legge piu'
* piano: il montaggio ci mette un po' di piu' e viene liscio.
* Si controlla dopo mezzo secondo di lavoro, per dare tempo al
* decodificatore di ingranare, e si scende un gradino alla volta. */
if (fotogrammi % 15 === 0 && tRel > 0.5 && v.playbackRate > 1) {
/* Non si guarda piu' "quanti fotogrammi al secondo escono" —
* adesso ne escono sempre 30, perche' la griglia si riempie da
* sola. Si guarda QUANTI DI QUELLI SONO VERI: con una partita a
* 25 la quota giusta e' 0,83 e non c'e' niente da rallentare,
* mentre se il Mac arranca la quota crolla ed e' li' che
* conviene leggere piu' piano.
*
* PROVATO A MIGLIORARLO, ED E' ANDATA PEGGIO — lasciato com'era.
* L'idea era: misurare sull'ultimo secondo invece che sulla media
* del pezzo, e saltare subito alla velocita' giusta invece di
* scendere a gradini. Misurato sullo stesso PC, stesso montaggio:
* prima 50,9% di fotogrammi veri, blocco fermo max 12
* "dopo" 30,0% di fotogrammi veri, blocco fermo max 32
* Il motivo: la finestra contava le CASELLE, e le caselle sono
* gonfiate proprio dal tappabuchi che si voleva ridurre. Meno
* fotogrammi veri -> piu' caselle finte -> quota piu' bassa ->
* rallenta ancora -> peggio. Un anello che si mangia la coda, e
* finiva sempre appiccicato al minimo.
* Se un giorno si riprova: la misura va fatta sui fotogrammi
* CONSEGNATI DAL FILMATO, non sulle caselle scritte. */
/* La vecchia regola (reali/incisi) non vale piu': da quando le
* caselle vuote non si riempiono, quei due numeri sono sempre
* uguali e la quota faceva sempre 1. Adesso si guarda la cosa
* giusta — quanti fotogrammi CONSEGNA il filmato per ogni
* secondo di film — che e' esattamente quello che mi ero
* ripromesso di fare qui sopra. Se non ne consegna almeno un
* quinto in piu' di quanti me ne servono, si legge piu' piano. */
const consegnatiAlSecondo = arrivati / Math.max(0.001, tRel);
const servonoAlSecondo = passoScelta > 0 ? (1 / passoScelta) : VEL_FPS;
const quota = consegnatiAlSecondo / (servonoAlSecondo * 1.2);
if (quota < 0.75) {
const nuova = Math.max(1, Math.round((v.playbackRate - 0.5) * 10) / 10);
if (nuova < v.playbackRate) {
try { v.playbackRate = nuova; } catch (e) {}
if (window.__diarioMontaggio) window.__diarioMontaggio.push({ ev: 'rallento', pezzo: i, a: nuova, quota: Math.round(quota * 100) / 100 });
}
}
}
// FERMO IMMAGINE: si mette in pausa e si incide lo stesso
// fotogramma per i secondi richiesti, poi si riparte.
const fz = fxFreezeAt(seg.fx, tRel, gia);
if (fz) {
gia.add(fz);
// il fermo immagine deve congelare QUESTO fotogramma, anche se la
// scelta a passo regolare l'aveva scartato
disegna(seg, tRel);
try { v.pause(); } catch (e) {}
const base = t0uscita + veloceIstanteUscita(seg, tRel, freezeFatti);
const quanti = Math.round((fz.dur || 1) * VEL_FPS);
for (let k = 1; k <= quanti; k++) incidi(base + k / VEL_FPS);
freezeFatti.push(fz);
if (!montageRunning) { basta(); return; }
v.play().catch(() => {});
}
if (enc.encodeQueueSize > 24) {
setTimeout(() => { if (!finito) v.requestVideoFrameCallback(passo); }, 2);
} else {
v.requestVideoFrameCallback(passo);
}
// barra: quanto di questo pezzo e' gia' inciso
fattiPerAvanzamento[i] = Math.min(veloceDurataPezzo(seg), veloceIstanteUscita(seg, tRel, freezeFatti));
if (fotogrammi % 10 === 0) aggiornaBarra();
};
v.requestVideoFrameCallback(passo);
/* IL FILMATO E' FINITO. Serve un ascoltatore a parte, e questo e' un
* difetto che e' costato caro: la fine del pezzo la decideva solo il
* giro dei fotogrammi, ma quando il filmato arriva in fondo i
* fotogrammi SMETTONO DI ARRIVARE, e quel giro non gira piu'. Sui
* pezzi presi in mezzo alla partita non si vedeva; su un montaggio
* intero — cioe' sempre, nella presentazione — il pezzo finiva e
* l'esportazione restava ferma ad aspettare fino allo scadere del
* tempo massimo. Un montaggio da mezzo minuto ci metteva un minuto
* e mezzo invece di un quarto. */
v.onended = () => basta();
// rete di sicurezza: se i fotogrammi non arrivano piu' (filmato
// rotto, o si e' inchiodato) non si resta appesi
let ultimoPunto = -1, fermoDa = 0;
const salvagente = setInterval(() => {
if (finito) { clearInterval(salvagente); return; }
if (performance.now() > tetto || !montageRunning || v.ended) { clearInterval(salvagente); basta(); return; }
// il filmato non avanza piu' da due secondi e non e' un fermo
// immagine voluto: e' piantato, si chiude il pezzo e si va avanti.
if (!v.paused) {
if (Math.abs(v.currentTime - ultimoPunto) < 0.01) {
fermoDa += 300;
if (fermoDa > 2000) { clearInterval(salvagente); basta(); return; }
} else { fermoDa = 0; ultimoPunto = v.currentTime; }
} else { fermoDa = 0; }
}, 300);
});
await enc.flush();
pezziFatti[i] = miei;
fattiPerAvanzamento[i] = veloceDurataPezzo(seg);
aggiornaBarra();
/* Nessun fotogramma? Vuol dire che da quel punto del filmato non c'era
* niente da prendere (il pezzo comincia dopo la fine della partita,
* oppure il file e' troncato). Meglio dirlo: prima restava un buco muto
* nel montaggio e l'allenatore non capiva perche'. */
if (!fotogrammi) saltati.push(seg.label || t('montage.noTag'));
// quanti fotogrammi di filmato siamo riusciti a prendere davvero?
const secondiFilm = Math.max(0.05, durataFilm);
ultimoFps = fotogrammi / secondiFilm;
if (window.__diarioMontaggio) window.__diarioMontaggio.push({ ev: 'fine', lettore: numero, pezzo: i, t: Math.round(performance.now()), fotogrammi, fps: Math.round(ultimoFps * 10) / 10, lettoriVivi });
diarioPezzo({ pezzo: i, etichetta: etichettaCorrente, lettore: numero, lettoriVivi: lettoriVivi,
fotogrammi: fotogrammi, fps: ultimoFps, arrivati: arrivati, scartati: scartati,
secondi: (performance.now() - inizioOrologio) / 1000 });
// se la macchina regge, un lettore in piu'
if (ultimoFps >= VEL_FPS_BUONI && lettoriVivi < VEL_LETTORI_MAX && prossimo < segments.length) {
lettore(lettoriVivi + 1);
}
}
try { enc.close(); } catch (e) {}
try { v.pause(); v.removeAttribute('src'); v.load(); } catch (e) {}
try { v.remove(); } catch (e) {}
lettoriVivi--;
}
// si parte con due lettori; gli altri li aggiunge lui se la macchina tiene
const avviati = [];
for (let k = 0; k < Math.min(VEL_LETTORI_INIZIO, segments.length); k++) avviati.push(lettore(k + 1));
await Promise.all(avviati);
// i lettori aggiunti in corsa non stanno in "avviati": si aspetta che
// finiscano tutti e che la fila sia vuota
while (lettoriVivi > 0) await new Promise(r => setTimeout(r, 60));
if (guaio) return null;
if (!configDecoder) return null;
const qualcosa = pezziFatti.some(p => p && p.length);
if (!qualcosa) return null;
// ---- si rimettono in fila e si impacchetta
const muxer = new Mp4Muxer.Muxer({
target: new Mp4Muxer.ArrayBufferTarget(),
/* NIENTE frameRate QUI, ED E' VOLUTO.
* mp4-muxer usa questo numero come unita' di misura del tempo del
* filmato: mettendoci 30, ogni fotogramma puo' cadere solo su multipli
* esatti di un trentesimo, e la cadenza vera (28,8) veniva riarrotondata
* a 30 vanificando tutto. Senza questa riga l'unita' e' 57600 per
* secondo — 2000 tacche esatte per un fotogramma a 28,8 — e i tempi
* restano quelli veri. Misurato: con 30, i pezzi uscivano di 9,6s
* invece che di 10. (27 agosto 2026) */
video: { codec: 'avc', width: W, height: H },
fastStart: 'in-memory',
// Il primo fotogramma non cade mai esattamente a zero (il filmato
// comincia a dare immagini un attimo dopo il via): senza questo il
// contenitore si rifiuta di partire. Cosi' invece sposta tutto in modo
// che il montaggio cominci da zero.
firstTimestampBehavior: 'offset'
});
/* I FOTOGRAMMI SI METTONO IN FILA, NON SU UNA GRIGLIA DECISA PRIMA.
*
* Qui c'era il difetto che spiegava i fermi immagine che il diario non
* riusciva a giustificare. Ogni pezzo veniva inciso con i tempi calcolati
* PRIMA di cominciare (inizi[i], dalle durate previste). Ma se un pezzo
* consegnava meno fotogrammi del previsto — perche' in quel momento il
* computer era occupato — la sua fine cadeva prima dell'inizio del pezzo
* dopo, e in mezzo restava un BUCO. Nel file finito quel buco si vede come
* immagine ferma: il lettore tiene l'ultimo fotogramma finche' non arriva
* il successivo.
* Misurato: stesse identiche clip, stesso programma, due montaggi
* diversi — 78.9% di fotogrammi veri con la macchina libera, 71.1%
* con la macchina occupata, e un fermo da 13 fotogrammi che il diario
* non aveva registrato come pausa, perche' pausa non era.
*
* Adesso i tempi si riscrivono qui, alla fine: i fotogrammi prodotti
* davvero si mettono uno dietro l'altro a cadenza fissa. Un buco non puo'
* piu' esistere, per costruzione, qualunque cosa stia facendo il computer.
* Il prezzo e' che un montaggio puo' uscire una frazione di secondo piu'
* corto del previsto — che e' esattamente quello che dovrebbe fare, visto
* che quei fotogrammi non sono mai esistiti. Meglio mezzo secondo in meno
* che mezzo secondo di immagine congelata. */
/* OGNI PEZZO ALLA CADENZA CHE HA DAVVERO.
*
* Prima si scriveva tutto a 30 fotogrammi al secondo, tondi. Ma un filmato
* girato a 30 nominali ne consegna 28,5-28,8 per ogni secondo di film: la
* differenza — poco piu' di uno al secondo — veniva coperta ripetendo
* l'immagine precedente. Misurato: 74% di fotogrammi veri, cioe' un quarto
* del montaggio erano copie. Non erano scatti del programma: era la
* pretesa di 30 caselle da riempire con 28,8 immagini.
*
* Adesso ogni pezzo prende la sua durata VERA (quella della clip, che il
* coach ha deciso tagliando) e la divide per i fotogrammi che sono
* arrivati davvero. Il pezzo dura esattamente quello che deve durare, la
* velocita' e' quella giusta, e non c'e' una sola immagine ripetuta.
* I fotogrammi vanno comunque uno dietro l'altro senza buchi, quindi il
* fermo immagine resta impossibile per costruzione come nella 3.7.16.
* Il file finito e' a cadenza variabile: e' legale nell'mp4 e i lettori
* lo reggono, ma e' la cosa da controllare per prima. (27 agosto 2026) */
/* OGNI FOTOGRAMMA AL SUO ISTANTE VERO.
*
* Qui prima spalmavo i fotogrammi di un pezzo a distanza uguale lungo
* tutta la sua durata. Sembrava ragionevole ed era sbagliato: all'inizio
* di ogni pezzo il decodificatore deve ingranare e ne consegna pochi, e
* quel vuoto veniva riempito stirando all'indietro i fotogrammi arrivati
* dopo. Risultato: la prima parte del pezzo si vede PIU' LENTA del vero e
* la seconda recupera. Vince l'ha visto a occhio prima che io lo trovassi
* nei numeri — pezzo 1 a 20,3 fotogrammi al secondo, pezzo 2 a 25.
*
* L'istante giusto ce l'ho gia': e' il tempo con cui il fotogramma e'
* stato inciso (c.ts), che tiene conto anche di rallenty e fermi immagine.
* Adesso lo uso. Dentro al pezzo i tempi sono quelli veri; i pezzi si
* attaccano uno all'altro spostandoli in blocco, quindi fra un pezzo e
* l'altro non si apre nessun buco — che era il motivo per cui nella
* 3.7.16 avevo cominciato a rinumerare tutto. (27 agosto 2026) */
let casella = 0;
let primo = true;
let quandoUs = 0;
let sommaCadenze = 0, pezziContati = 0;
for (let i = 0; i < pezziFatti.length; i++) {
const pezzo = pezziFatti[i];
if (!pezzo || !pezzo.length) continue;
const t0 = pezzo[0].ts || 0;
// passo tipico del pezzo: serve per la durata dell'ultimo fotogramma
const ultimoRel = (pezzo[pezzo.length - 1].ts || 0) - t0;
const passoTipico = pezzo.length > 1
? Math.max(1000, Math.round(ultimoRel / (pezzo.length - 1)))
: Math.round(1e6 / VEL_FPS);
for (let k = 0; k < pezzo.length; k++) {
const c = pezzo[k];
const rel = Math.max(0, (c.ts || 0) - t0);
const relDopo = (k + 1 < pezzo.length) ? Math.max(0, (pezzo[k + 1].ts || 0) - t0) : (rel + passoTipico);
const durata = Math.max(1000, relDopo - rel);
const chunk = new EncodedVideoChunk({
type: c.tipo, timestamp: quandoUs + rel, duration: durata, data: c.dati
});
muxer.addVideoChunk(chunk, primo ? { decoderConfig: configDecoder } : undefined);
casella++;
primo = false;
}
// il pezzo dopo comincia dove finisce l'ultimo fotogramma di questo
quandoUs += ultimoRel + passoTipico;
sommaCadenze += 1e6 / passoTipico; pezziContati++;
}
try {
if (diarioMontaggio) {
diarioMontaggio.fotogrammiUsciti = casella;
diarioMontaggio.fotogrammiPrevisti = Math.round(durataTotale * VEL_FPS);
diarioMontaggio.durataUscita = quandoUs / 1e6;
diarioMontaggio.cadenzaMedia = pezziContati ? (sommaCadenze / pezziContati) : VEL_FPS;
}
} catch (e) {}
muxer.finalize();
setAvanzamento(1);
return { blob: new Blob([muxer.target.buffer], { type: 'video/mp4' }), ext: 'mp4', saltati };
}
// segmentiEspliciti/nomeFile: usati da "Esporta questo pezzo" nell'editor
// (un solo segmento, stesso motore e quindi STESSI effetti incisi sopra).
async function exportTeamMontage(segmentiEspliciti, nomeFile) {
if (beta() && Array.isArray(segmentiEspliciti) && segmentiEspliciti.length > LIMITI_BETA.clipMontaggio) {
return bloccoBeta(t('beta.limitMontage', { n: LIMITI_BETA.clipMontaggio }));
}
const team = activeTeam();
if (!team) { toast(t('toast.selectTeamFirst')); return; }
if (montageRunning) { toast(t('toast.montageRunning')); return; }
const segments = Array.isArray(segmentiEspliciti) && segmentiEspliciti.length
? segmentiEspliciti
: teamMontageSegments(team);
if (!segments.length) { toast(t('toast.noClipsToJoin')); return; }
if (typeof MediaRecorder === 'undefined') { toast(t('toast.noMediaRecorder')); return; }
montageRunning = true;
const btn = document.getElementById('montageBtn');
const statusEl = document.getElementById('montageStatus');
if (btn) btn.disabled = true;
// Lo stato va DOVE L'UTENTE STA GUARDANDO: nella scheda Edit Video e,
// se il pannello degli effetti e' aperto, anche li' davanti.
let _fxFrazione = 0;
let _fxUltimoTesto = '';
const setStatus = (msg) => {
if (statusEl) statusEl.textContent = msg;
_fxUltimoTesto = msg;
const fxAperto = (document.getElementById('fxOverlay') || {}).classList;
if (fxAperto && fxAperto.contains('show')) fxAvanza(msg, _fxFrazione);
// ...e nella fascia che si vede da tutte le sezioni.
montFascia(msg, _fxFrazione, false);
};
// La barra avanza davvero: il pezzo si registra in tempo reale, quindi la
// frazione e' "quanto del pezzo ho gia' inciso".
const setAvanzamento = (frazione) => {
_fxFrazione = Math.max(0, Math.min(1, frazione || 0));
const fxAperto = (document.getElementById('fxOverlay') || {}).classList;
if (fxAperto && fxAperto.contains('show')) fxAvanza(_fxUltimoTesto, _fxFrazione);
montFascia(_fxUltimoTesto, _fxFrazione, false);
};
setStatus(t('montage.preparing', {n: segments.length}));
/* PRIMA DI TUTTO: SI PUO' TAGLIARE INVECE DI RIDISEGNARE?
* Se nessun pezzo ha disegni sopra ne' cartelli di capitolo, il montaggio
* si fa copiando i fotogrammi originali: esce alla misura della partita e
* ci mette un attimo. Se anche un solo pezzo ha qualcosa da incidere, si
* scende ai motori di sotto e non cambia niente rispetto a prima. */
{
const diretto = await montaggioDiretto(segments, team, nomeFile);
if (diretto) {
montageRunning = false;
if (btn) btn.disabled = false;
await concludiMontaggio(team, { giaSalvato: diretto.relativo }, 'mp4',
nomeFile, [], segments.length, setStatus);
return;
}
}
/* PRIMA SI PROVA IL MOTORE VELOCE. Se ce la fa, il montaggio e' pronto in
* un quarto del tempo e qui si finisce. Se per qualunque motivo non ce la
* fa (compressore non disponibile, filmato che non si legge, memoria) NON
* si perde niente: si prosegue con il motore storico qui sotto, che e'
* lento ma non ha mai sbagliato un colpo. */
if (veloceUsabile()) {
try {
const logoV = await caricaLogoSquadra(team);
await copertinaPrepara();
await fxPreparaRitagli(segments);
const esito = await esportaMontaggioVeloce(segments, team, logoV, setStatus, setAvanzamento);
if (!montageRunning) { // fermato dall'utente a meta'
if (btn) btn.disabled = false;
return;
}
if (esito && esito.blob && esito.blob.size > 1000) {
montageRunning = false;
if (btn) btn.disabled = false;
await concludiMontaggio(team, esito.blob, esito.ext, nomeFile, esito.saltati, segments.length, setStatus);
return;
}
console.warn('VDM: motore veloce non riuscito, si passa a quello storico');
} catch (e) {
console.warn('VDM: motore veloce in errore, si passa a quello storico —', e && e.message);
}
setAvanzamento(0);
setStatus(t('montage.preparing', { n: segments.length }));
}
// Un solo MediaRecorder/AudioContext alla volta in tutta l'app — se il
// taglio automatico di un tag è ancora in corso (vedi recorderBusy),
// aspettiamo che finisca invece di far correre due registrazioni insieme.
await waitRecorderFree();
recorderBusy = true;
const W = 960, H = 540;
const canvas = document.createElement('canvas');
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, W, H);
const AudioCtx = window.AudioContext || window.webkitAudioContext;
const audioCtx = new AudioCtx();
const dest = audioCtx.createMediaStreamDestination();
const videoStream = canvas.captureStream(30);
const combined = new MediaStream([...videoStream.getVideoTracks(), ...dest.stream.getAudioTracks()]);
const rec = pickRecorderFormat();
const mimeType = rec.mimeType;
const chunks = [];
const recorder = new MediaRecorder(combined, { mimeType });
recorder.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); };
const stopped = new Promise((resolve) => { recorder.onstop = resolve; });
const v = document.createElement('video');
v.muted = false; v.playsInline = true; v.crossOrigin = 'anonymous';
v.style.position = 'fixed'; v.style.left = '-9999px'; v.style.top = '0'; v.style.width = '2px'; v.style.height = '2px';
document.body.appendChild(v);
// Un solo nodo audio per l'intera vita dell'elemento : segue
// automaticamente qualunque src gli venga assegnato dopo, quindi va creato
// una volta sola qui (ricrearlo per ogni clip sorgente farebbe fallire
// createMediaElementSource con "elemento già collegato").
const sourceNode = audioCtx.createMediaElementSource(v);
sourceNode.connect(dest);
let LARG = W, ALT = H, misurato = false;
function fitDraw() {
if (!misurato && v.videoWidth) {
const m = montMisuraDa(v.videoWidth, v.videoHeight);
LARG = m.w; ALT = m.h; misurato = true;
if (canvas.width !== LARG || canvas.height !== ALT) {
canvas.width = LARG; canvas.height = ALT;
}
}
const vw = v.videoWidth || LARG, vh = v.videoHeight || ALT;
const scale = Math.min(LARG / vw, ALT / vh);
const dw = vw * scale, dh = vh * scale;
const ox = (LARG - dw) / 2, oy = (ALT - dh) / 2;
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, LARG, ALT);
ctx.drawImage(v, ox, oy, dw, dh);
// Disegni sopra il fotogramma (fascio di luce, cerchi, frecce, scritte):
// vanno incisi QUI, altrimenti restano solo nell'anteprima e il filmato
// esportato esce pulito — che è esattamente com'era finora.
drawFxOverlay(ctx, fxCorrenti, v.currentTime - fxInizio, ox, oy, dw, dh);
}
// Logo della squadra, caricato una volta sola: serve ai fotogrammi di
// capitolo. Se manca o non si carica, il capitolo esce col solo titolo.
const logoImg = await caricaLogoSquadra(team);
// Copertina scelta dal coach (grafica / immagine sua / fermo immagine):
// se e' un'immagine va decodificata prima di cominciare a incidere.
await copertinaPrepara();
// Le foto fisse dei ritagli devono essere già decodificate: se si comincia
// a incidere prima, i primi fotogrammi escono col riquadro vuoto.
await fxPreparaRitagli(segments);
const saltati = [];
// Stesso motivo del taglio singolo: il registratore si accende alla prima
// immagine VERA incisa sulla tela, non prima — altrimenti il montaggio
// comincia con mezzo secondo di nero.
let registratoreAcceso = false;
const accendiRegistratore = () => {
if (registratoreAcceso) return;
registratoreAcceso = true;
recorder.start();
};
try {
let lastUrl = null;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
setStatus(t('montage.progress', {i: i + 1, n: segments.length, label: seg.label || t('montage.noTag')}));
// Schermata disegnata (le pagine della presentazione): si disegna
// una volta e si tiene ferma per i secondi richiesti.
if (seg.disegna) {
try { v.pause(); } catch (err) {}
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, LARG, ALT);
try { seg.disegna(ctx, LARG, ALT); } catch (err) {}
accendiRegistratore();
const fineDis = Date.now() + Math.max(0.5, +seg.sec || 4) * 1000;
await new Promise((resolve) => {
const tieni = setInterval(() => {
if (Date.now() >= fineDis || !montageRunning) { clearInterval(tieni); resolve(); }
}, 60);
});
if (!montageRunning) break;
continue;
}
// Fotogramma di capitolo: niente video da leggere, si incide la
// schermata col logo per i secondi richiesti e si passa oltre.
if (seg.chapter) {
try { v.pause(); } catch (err) {}
const scelta = copertinaStato();
// FERMO IMMAGINE: la copertina e' il primo fotogramma del pezzo che
// viene subito dopo, con il nome scritto sopra.
let fermoPronto = false;
if (scelta.tipo === 'fermo') {
const succ = segments.slice(i + 1).find(x => x && x.src);
if (succ) {
const aspetta = (assegna, ms) => new Promise((res) => {
let fatto = false; const fine2 = (e2) => { if (!fatto) { fatto = true; res(e2); } };
assegna(fine2); setTimeout(() => fine2('timeout'), ms);
});
try {
if (lastUrl !== succ.src) {
v.src = succ.src;
const es = await aspetta((f) => { v.onloadedmetadata = () => f('ok'); v.onerror = () => f('errore'); }, 8000);
lastUrl = (es === 'ok') ? succ.src : null;
}
if (lastUrl === succ.src && v.videoWidth) {
if (!giaAlPunto(v, succ.start)) {
v.currentTime = succ.start;
await aspetta((f) => { v.onseeked = () => f('ok'); }, 5000);
}
fxCorrenti = []; fxInizio = succ.start;
fitDraw();
fermoPronto = true;
}
} catch (err) { fermoPronto = false; }
}
}
seg.chapter.fotogrammaPronto = fermoPronto;
// Sul fermo immagine si incide UNA VOLTA SOLA (ridisegnare la fascia
// scura ad ogni giro la scurirebbe fino a diventare nera); sulle
// altre copertine si ridisegna il fotogramma intero, e non fa danno.
drawChapterCard(ctx, LARG, ALT, seg.chapter, logoImg, team.name);
accendiRegistratore();
const fine = Date.now() + copertinaSecondi(seg.chapter) * 1000;
await new Promise((resolve) => {
const tieni = setInterval(() => {
if (!fermoPronto) drawChapterCard(ctx, LARG, ALT, seg.chapter, logoImg, team.name);
if (Date.now() >= fine || !montageRunning) { clearInterval(tieni); resolve(); }
}, 60);
});
if (!montageRunning) break;
continue;
}
// Ogni attesa ha un tetto di tempo. Senza, bastava un pezzo il cui
// video non è più disponibile (blob perso, sessione ripresa senza il
// file) perché "seeked" non arrivasse mai: l'esportazione restava
// appesa all'infinito, con il bottone disabilitato e nessun modo di
// annullare se non ricaricare la pagina.
const attesa = (assegna, ms) => new Promise((resolve) => {
let fatto = false;
const fine = (esito) => { if (!fatto) { fatto = true; resolve(esito); } };
assegna(fine);
setTimeout(() => fine('timeout'), ms);
});
// Pezzo senza sorgente: si scarta SUBITO. Prima si finiva qui con
// lastUrl e seg.src entrambi nulli, quindi il caricamento veniva
// saltato (null !== null è falso) e si arrivava a play() su un video
// vuoto — che in Chrome non torna né in successo né in errore: da lì
// l'esportazione non ripartiva più.
if (!seg.src) { saltati.push(seg.label || t('montage.noTag')); continue; }
let sorgenteRotta = false;
if (lastUrl !== seg.src) {
v.src = seg.src;
const esito = await attesa((fine) => { v.onloadedmetadata = () => fine('ok'); v.onerror = () => fine('errore'); }, 8000);
if (esito !== 'ok' || !v.videoWidth) sorgenteRotta = true;
lastUrl = seg.src;
}
if (sorgenteRotta) {
saltati.push(seg.label || t('montage.noTag'));
lastUrl = null;
continue;
}
if (!giaAlPunto(v, seg.start)) {
v.currentTime = seg.start;
await attesa((fine) => { v.onseeked = () => fine('ok'); }, 6000);
}
// Fotogramma inciso PRIMA di accendere (o di riprendere): niente nero.
fitDraw();
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
accendiRegistratore();
// play() con tetto di tempo: su una sorgente difettosa la promessa può
// non risolversi mai.
const avvio = await Promise.race([
v.play().then(() => 'ok').catch(() => 'errore'),
new Promise(r => setTimeout(() => r('timeout'), 5000)),
]);
if (avvio !== 'ok') { saltati.push(seg.label || t('montage.noTag')); try { v.pause(); } catch (err) {} continue; }
// Tetto anche sulla riproduzione del singolo pezzo: se il video si
// ferma senza mai raggiungere seg.end (sorgente difettosa) il ciclo
// resterebbe a girare a vuoto.
fxCorrenti = seg.fx || [];
fxInizio = seg.start;
const freezeFatti = new Set();
// I fermi immagine allungano il pezzo: il tetto ne tiene conto.
const durataFreeze = (seg.fx || []).filter(f => f.type === 'freeze').reduce((n, f) => n + (f.dur || 1), 0);
const limitePezzo = Date.now() + Math.max(4000, ((seg.end - seg.start) * 3 + durataFreeze * 2) * 1000 + 3000);
await new Promise((resolve) => {
let chiuso = false;
const finito = () => {
if (chiuso) return;
chiuso = true;
clearInterval(battito);
try { v.pause(); } catch (err) {}
resolve();
};
let inFreeze = false;
const controlla = () => {
if (inFreeze) return false; // durante il fermo immagine il video è in pausa apposta
if (!montageRunning || v.paused || v.ended || v.currentTime >= seg.end || Date.now() > limitePezzo) { finito(); return true; }
// rallenty: la velocità cambia dove il coach l'ha chiesto
const tRel = v.currentTime - seg.start;
const rate = fxRateAt(seg.fx, tRel);
if (Math.abs(v.playbackRate - rate) > 0.01) { try { v.playbackRate = rate; } catch (err) {} }
// fermo immagine: si mette in pausa e si continua a incidere lo
// stesso fotogramma per la durata richiesta, poi si riparte
const fz = fxFreezeAt(seg.fx, tRel, freezeFatti);
if (fz) {
freezeFatti.add(fz);
inFreeze = true;
try { v.pause(); } catch (err) {}
const fine = Date.now() + (fz.dur || 1) * 1000;
const tieni = setInterval(() => {
fitDraw();
if (Date.now() >= fine || !montageRunning) {
clearInterval(tieni);
inFreeze = false;
if (montageRunning) { v.play().catch(() => {}); } else { finito(); }
}
}, 60);
}
return false;
};
// requestAnimationFrame disegna in modo fluido, MA il browser lo
// congela quando la pagina non è in primo piano: bastava cambiare
// scheda durante l'esportazione perché il montaggio si piantasse a
// metà, senza errori e senza più tornare indietro. Il battito con
// setInterval continua comunque (anche se rallentato) e garantisce
// che il pezzo finisca e che il tetto di tempo venga rispettato.
const battito = setInterval(() => {
if (!controlla()) {
fitDraw();
// quota del pezzo gia' incisa, dentro alla quota dei pezzi fatti
const dentro = Math.max(0, Math.min(1, (v.currentTime - seg.start) / Math.max(0.1, seg.end - seg.start)));
setAvanzamento((i + dentro) / segments.length);
}
}, 200);
(function frame() {
if (chiuso || controlla()) return;
fitDraw();
requestAnimationFrame(frame);
})();
});
if (!montageRunning) break; // interrotto (es. squadra cambiata a metà)
}
} finally {
// Qualunque cosa vada storta, il montaggio deve tornare disponibile:
// prima montageRunning restava true e il bottone rimaneva disabilitato
// per sempre, senza modo di riprovare se non ricaricando la pagina.
try { recorder.stop(); } catch (err) {}
// Anche qui un tetto: se onstop non arriva (registrazione mai partita
// davvero perché nessun pezzo era leggibile) restavamo appesi in chiusura.
await Promise.race([stopped.catch(() => {}), new Promise(r => setTimeout(r, 4000))]);
try { sourceNode.disconnect(); } catch (err) { /* già disconnesso */ }
try { document.body.removeChild(v); } catch (err) {}
try { await audioCtx.close(); } catch (err) {}
recorderBusy = false;
montageRunning = false;
if (btn) btn.disabled = false;
// La barra arriva in fondo appena l'incisione e' finita: restare al 93%
// mentre si salva il file sembra un'altra volta "piantato".
setAvanzamento(1);
}
const blob = new Blob(chunks, { type: rec.blobType });
/* IL MONTAGGIO VA DOVE STANNO LE CLIP, senza richiedere niente: nella
* sottocartella MONTAGGI della squadra. Solo se quella cartella non c'e'
* si torna al vecchio scaricamento. Stessa coda del motore veloce. */
await concludiMontaggio(team, blob, rec.ext, nomeFile, saltati, segments.length, setStatus);
}
// --------------------------------------------------- CATEGORIE (GIOCATE)
function ensureTagCategories(team) {
if (!team.tagCategories) team.tagCategories = defaultTagCategories();
if (!Array.isArray(team.tagCategories.attacco)) team.tagCategories.attacco = [];
if (!Array.isArray(team.tagCategories.difesa)) team.tagCategories.difesa = [];
// Nuove giocate di default aggiunte dopo la creazione della squadra (Drag,
// Base, Double High, Zipper, Cambio su Pick, 1c1, ecc.): le squadre già
// esistenti le ricevono UNA SOLA volta qui (flag tagCategoriesDefaultsV2),
// così compaiono "sempre" come chiesto, ma restano comunque rinominabili
// o cancellabili in seguito senza che ricompaiano da sole al render dopo.
// Le difese fisse arrivano anche sulle squadre create prima, una volta sola.
if (!team.tagCategoriesDefaultsV3) {
DIFESE_FISSE.forEach(name => { if (!team.tagCategories.difesa.includes(name)) team.tagCategories.difesa.push(name); });
team.tagCategoriesDefaultsV3 = true;
}
if (!team.tagCategoriesDefaultsV2) {
const d = defaultTagCategories();
d.attacco.forEach(name => { if (!team.tagCategories.attacco.includes(name)) team.tagCategories.attacco.push(name); });
d.difesa.forEach(name => { if (!team.tagCategories.difesa.includes(name)) team.tagCategories.difesa.push(name); });
team.tagCategoriesDefaultsV2 = true;
}
return team.tagCategories;
}
function ensureQuickTags(team) {
if (!Array.isArray(team.quickTags)) team.quickTags = defaultQuickTags();
// Le squadre create PRIMA di questa versione non hanno CP SUBITO: glielo
// aggiungiamo una volta sola (il segno resta sulla squadra), cosi' chi lo
// toglie apposta non se lo ritrova rispuntare ad ogni apertura.
if (!team.quickTagsCpV1) {
if (!team.quickTags.some(q => (q || '').trim().toUpperCase() === 'CP SUBITO')) team.quickTags.push('CP SUBITO');
team.quickTagsCpV1 = true;
}
// PP e' quello che si preme piu' spesso: va per primo, sotto al pollice.
// Una volta sola: se poi lo sposti tu, resta dove lo metti.
if (!team.quickTagsPpV1) {
const i = team.quickTags.findIndex(q => (q || '').trim().toUpperCase() === 'PP');
if (i > 0) { const [pp] = team.quickTags.splice(i, 1); team.quickTags.unshift(pp); }
team.quickTagsPpV1 = true;
}
return team.quickTags;
}
// ---------------------------------------- PLAYBOOK / PERSONNEL / CALENDARIO
// Campi opzionali: le leghe importate da un JSON più vecchio (o creato a mano)
// potrebbero non averli — questi helper li creano al volo senza far fallire nulla.
//
// Struttura Playbook — come il foglio stampato ITALBASKET WOMAN: ogni RIGA è
// un gioco con un nome (es. "BASE", "CORNA"), con 4 caselle diagramma per le
// sue varianti/opzioni (es. "BASE 4 opz. post basso", "BASE 5 opz. DHO con
// 1"...) invece di 4 schemi indipendenti senza nesso tra loro. Ogni casella
// ha anche un piccolo campo nota (la didascalia sotto al diagramma nel
// foglio stampato, es. "SE N.4 è primo bloccante").
function emptyPlaybookSlot() { return { elements: [], campoMode: 'half', note: '' }; }
function defaultPlaybookRow(nome, gruppo) {
return { id: nextId('pbrow'), nome: nome || '', serie: '', gruppo: gruppo || 'attacco', slots: [emptyPlaybookSlot(), emptyPlaybookSlot(), emptyPlaybookSlot(), emptyPlaybookSlot()] };
}
// Migra il vecchio elenco piatto di schemi (sia quello originale pre-riga,
// sia quello della versione "griglia 4x5" di schemi indipendenti) in righe:
// ogni vecchio schema diventa una riga con la sua prima casella già
// compilata (nome/gruppo/disegno/nota), le altre 3 caselle vuote e pronte —
// nessun dato perso, il coach può poi ridistribuirlo come vuole.
function migratePlaybookListToRows(legacyList) {
return (legacyList || []).map(s => {
const row = defaultPlaybookRow(s.nome, s.gruppo || 'attacco');
row.slots[0] = { elements: s.elements || [], campoMode: s.campoMode || 'half', note: s.note || '' };
return row;
});
}
/* MIGRAZIONE, fatta per aggiunta e basta: ai giochi che esistono gia' si
* mette una serie VUOTA. Niente viene rinominato, niente spostato, niente
* perso. Chi ha dentro il lavoro di una stagione non si accorge di nulla. */
function assicuraSerie(righe) {
(righe || []).forEach(r => { if (typeof r.serie !== 'string') r.serie = ''; });
return righe;
}
function ensurePlaybookRows(team) {
if (!Array.isArray(team.playbookRows)) team.playbookRows = migratePlaybookListToRows(team.playbook);
return assicuraSerie(team.playbookRows);
}
// Libreria generale di schemi, usata quando il Playbook viene aperto senza
// nessuna squadra selezionata (il coach potrebbe voler disegnare solo schemi/
// diagrammi, senza dover prima creare una squadra). Vive in DB così viene
// esportata/importata insieme al resto.
function ensureScratchPlaybookRows() {
if (!Array.isArray(DB.scratchPlaybookRows)) DB.scratchPlaybookRows = migratePlaybookListToRows(DB.scratchPlaybook);
return assicuraSerie(DB.scratchPlaybookRows);
}
// Righe su cui sta lavorando il Playbook in questo momento: quelle della
// squadra attiva, oppure la libreria generale se nessuna squadra è selezionata.
function activePlaybookRows() {
const team = activeTeam();
return team ? ensurePlaybookRows(team) : ensureScratchPlaybookRows();
}
// Seconda pagina del Playbook: opzionale ("può esserci o meno"), non legata
// alla griglia 4x4 dei giochi — un foglio completamente bianco/libero dove il
// coach scrive quel che vuole (statistiche, note...), con la stessa
// intestazione (logo/squadra/lega) delle pagine con i giochi. Stessa coppia
// squadra-attiva/libreria generale delle righe di gioco qui sopra.
function defaultPlaybookExtraPage() { return { active: false, content: '' }; }
function ensurePlaybookExtraPage(team) {
if (team) {
if (!team.playbookExtraPage || typeof team.playbookExtraPage !== 'object') team.playbookExtraPage = defaultPlaybookExtraPage();
return team.playbookExtraPage;
}
if (!DB.scratchPlaybookExtraPage || typeof DB.scratchPlaybookExtraPage !== 'object') DB.scratchPlaybookExtraPage = defaultPlaybookExtraPage();
return DB.scratchPlaybookExtraPage;
}
function activePlaybookExtraPage() {
return ensurePlaybookExtraPage(activeTeam());
}
// team.personnel.positions: UNA sola disposizione in campo per squadra (le "12
// posizioni" del roster), che resta in memoria e si auto-sincronizza col
// roster — non una libreria di formazioni salvate a parte (quella, se serve,
// è un'estensione futura: "poi sarà possibile fare altri personnel").
function ensurePersonnel(team) {
if (!team.personnel || !Array.isArray(team.personnel.positions)) team.personnel = { positions: [] };
return team.personnel;
}
/* ==================================================================
* ARCHIVIO DEI FOGLI — Scouting Report, Personnel, Playbook
*
* "Salvare in archivio e poterli poi modificare". Ogni sezione ha il suo
* scaffale: si preme SALVA IN ARCHIVIO e finisce li' dentro una COPIA del
* foglio com'e' in quel momento, con un nome messo in automatico (data +
* avversario, oppure data + squadra) che si puo' rinominare quando si vuole.
*
* Dall'elenco si puo':
* · APRIRE E MODIFICARE la copia — il foglio su cui stavi lavorando viene
* messo da parte e torna al suo posto appena esci. Il "messo da parte"
* e' scritto nel DB (DB.arcSospeso), non solo in memoria: se l'app si
* chiude mentre stai dentro una copia, al riavvio il foglio vero torna
* da solo. Senza questo, chiudere l'app a meta' lavoro avrebbe lasciato
* la copia d'archivio al posto del foglio buono.
* · RIPORTARE la copia sul foglio attuale (sostituisce, con conferma);
* · COPIARLA SU UN'ALTRA SQUADRA (i playbook si riusano spesso);
* · rinominare, eliminare.
*
* L'archivio vive DENTRO la squadra (team.archivio), cosi' segue la squadra
* quando si esporta/importa il lavoro. Il Playbook funziona anche senza
* squadra selezionata: in quel caso l'archivio e' quello generale (DB).
* ================================================================== */
const ARC_SEZIONI = ['scouting', 'personnel', 'playbook'];
const ARC_TAB = { scouting: 'relazione', personnel: 'avversarie', playbook: 'playbook' };
const ARC_SEZ_HTML = { scouting: 'relazioneSection', personnel: 'avversarieSection', playbook: 'playbookSection' };
let arcInModifica = null; // { sezione, id } finche' si lavora su una copia
let _arcControllato = false;
function arcClona(x) { try { return x == null ? null : JSON.parse(JSON.stringify(x)); } catch (e) { return null; } }
function arcCasa(team) { return team || DB; }
function arcContenitore(team) {
const casa = arcCasa(team);
if (!casa.archivio || typeof casa.archivio !== 'object') casa.archivio = {};
ARC_SEZIONI.forEach(s => { if (!Array.isArray(casa.archivio[s])) casa.archivio[s] = []; });
return casa.archivio;
}
function arcLista(sezione) { return arcContenitore(activeTeam())[sezione] || []; }
function arcVoce(sezione, id) { return arcLista(sezione).find(v => v.id === id) || null; }
function arcSquadraPerId(id) {
if (!id) return null;
for (const lg of (DB.leagues || [])) {
const a = (lg.teams || []).find(x => x && x.id === id); if (a) return a;
const b = (lg.oppRosters || []).find(x => x && x.id === id); if (b) return b;
}
return (DB.singleMatches || []).find(x => x && x.id === id) || null;
}
/* Tutte le squadre su cui si puo' copiare un foglio d'archivio. */
function arcSquadreDisponibili() {
const fuori = [];
const attiva = activeTeam();
(DB.leagues || []).forEach(lg => {
(lg.teams || []).forEach(tm => { if (tm && (!attiva || tm.id !== attiva.id)) fuori.push({ id: tm.id, nome: (tm.name || '?') + ' · ' + (lg.name || '') }); });
(lg.oppRosters || []).forEach(tm => { if (tm && (tm.name || '').trim() && (!attiva || tm.id !== attiva.id)) fuori.push({ id: tm.id, nome: (tm.name || '?') + ' · ' + (lg.name || '') }); });
});
return fuori;
}
function arcNomeAuto(sezione) {
const team = activeTeam();
const data = tiroDataBella(tiroOggiISO());
if (sezione === 'scouting') {
const opp = team ? activeOpponent(team) : null;
return ((opp && (opp.name || '').trim()) || t('relazione.defaultOpponentName')) + ' — ' + data;
}
return ((team && (team.name || '').trim()) || t('arc.senzaSquadra')) + ' — ' + data;
}
/* ---- LEGGERE il foglio vivo, e RISCRIVERLO ---------------------------- */
function arcRaccogliDa(team, sezione, oppId) {
if (sezione === 'playbook') {
if (team) return { rows: arcClona(ensurePlaybookRows(team)), extra: arcClona(ensurePlaybookExtraPage(team)) };
return { rows: arcClona(ensureScratchPlaybookRows()), extra: arcClona(ensurePlaybookExtraPage(null)) };
}
if (!team) return null;
if (sezione === 'scouting') {
const lista = ensureOpponents(team);
const opp = lista.find(o => o.id === oppId) || lista[0];
if (!opp) return null;
ensureScoutingReport(opp); ensureOpponentMatchInfo(opp); ensureOpponentPlayers(opp);
return { opp: arcClona(opp) };
}
const pers = ensurePersonnel(team);
const players = (team.players || []).map(p => {
ensureRosterPlayerShape(p); ensurePlayerProfile(p);
return {
id: p.id, number: p.number, name: p.name, role: p.role, height: p.height, birthYear: p.birthYear,
stats: arcClona(p.stats), traits: arcClona(p.traits), slot: p.slot,
reportSlot: p.reportSlot, fuoriFoglio: !!p.fuoriFoglio, photo: p.photo || '',
};
});
return { positions: arcClona(pers.positions), players };
}
function arcRaccogli(sezione) {
const team = activeTeam();
return arcRaccogliDa(team, sezione, team ? ((activeOpponent(team) || {}).id || null) : null);
}
/* La giocatrice giusta nel roster: prima per id, poi per numero+cognome —
* un roster reimportato ha id nuovi ma le stesse giocatrici. */
function arcTrovaGiocatrice(team, sp) {
const rosa = team.players || [];
const perId = rosa.find(x => x && x.id === sp.id);
if (perId) return perId;
const num = String(sp.number == null ? '' : sp.number).trim();
const nome = (sp.name || '').trim().toLowerCase();
if (!num && !nome) return null;
return rosa.find(x => x && String(x.number == null ? '' : x.number).trim() === num
&& (x.name || '').trim().toLowerCase() === nome) || null;
}
function arcApplicaA(team, sezione, dati, oppId) {
if (!dati) return { ok: false };
if (sezione === 'playbook') {
const rows = arcClona(dati.rows) || [];
const extra = arcClona(dati.extra) || defaultPlaybookExtraPage();
if (team) { team.playbookRows = rows; team.playbookExtraPage = extra; }
else { DB.scratchPlaybookRows = rows; DB.scratchPlaybookExtraPage = extra; }
return { ok: true, n: rows.length };
}
if (!team) return { ok: false };
if (sezione === 'scouting') {
const lista = ensureOpponents(team);
const copia = arcClona(dati.opp);
if (!copia) return { ok: false };
const opp = lista.find(o => o.id === oppId) || lista[0];
if (opp) { copia.id = opp.id; lista[lista.indexOf(opp)] = copia; }
else { copia.id = nextId('opp'); lista.push(copia); activeOpponentId = copia.id; }
return { ok: true, n: (copia.players || []).length };
}
const pers = ensurePersonnel(team);
pers.positions = arcClona(dati.positions) || [];
let messe = 0, saltate = 0;
(dati.players || []).forEach(sp => {
const p = arcTrovaGiocatrice(team, sp);
if (!p) { saltate++; return; }
ensureRosterPlayerShape(p); ensurePlayerProfile(p);
p.height = sp.height || '';
p.birthYear = sp.birthYear || '';
p.stats = arcClona(sp.stats) || {};
p.traits = arcClona(sp.traits) || ['', '', '', ''];
p.slot = sp.slot;
p.reportSlot = sp.reportSlot || '';
p.fuoriFoglio = !!sp.fuoriFoglio;
// La foto d'archivio e' una MINIATURA: si mette solo se la giocatrice
// adesso non ne ha una. Non si sostituisce mai una foto buona con una
// rimpicciolita.
if (sp.photo && !(p.photo || '').trim()) p.photo = sp.photo;
messe++;
});
return { ok: true, n: messe, saltate };
}
function arcApplica(sezione, dati) {
const team = activeTeam();
return arcApplicaA(team, sezione, dati, team ? ((activeOpponent(team) || {}).id || null) : null);
}
/* ---- FOTO: in archivio ci va la miniatura -----------------------------
* Le foto delle giocatrici sono immagini intere (anche qualche mega l'una):
* copiarle tali e quali in ogni salvataggio avrebbe gonfiato il lavoro di
* decine di mega dopo pochi archivi. In archivio va una copia piccola, che
* sul foglio stampato non si distingue. */
function arcMiniFoto(dataUrl) {
return new Promise((ok) => {
const s = dataUrl || '';
if (s.length < 40000) return ok(s);
try {
const im = new Image();
im.onload = () => {
try {
const lato = 280;
const w0 = im.width || lato, h0 = im.height || lato;
const k = Math.min(1, lato / Math.max(w0, h0));
const c = document.createElement('canvas');
c.width = Math.max(1, Math.round(w0 * k));
c.height = Math.max(1, Math.round(h0 * k));
c.getContext('2d').drawImage(im, 0, 0, c.width, c.height);
ok(c.toDataURL('image/jpeg', 0.78));
} catch (e) { ok(s); }
};
im.onerror = () => ok(s);
im.src = s;
} catch (e) { ok(s); }
});
}
async function arcAlleggerisci(dati) {
if (!dati || !Array.isArray(dati.players)) return dati;
for (const p of dati.players) p.photo = await arcMiniFoto(p.photo);
return dati;
}
/* ---- I COMANDI -------------------------------------------------------- */
async function arcSalva(sezione) {
if (arcInModifica) { toast(t('arc.giaDentro')); return; }
const team = activeTeam();
if (!team && sezione !== 'playbook') { toast(t('arc.nienteSquadra')); return; }
const dati = arcRaccogli(sezione);
if (!dati) { toast(t('arc.nienteDaSalvare')); return; }
if (sezione === 'personnel') await arcAlleggerisci(dati);
const voce = {
id: nextId('arc'), nome: arcNomeAuto(sezione), quando: tiroOggiISO(),
teamId: team ? team.id : '', teamName: team ? (team.name || '') : '', dati,
};
arcContenitore(team)[sezione].unshift(voce);
scheduleAutosave();
arcRenderTutti();
toast(t('arc.salvato', { nome: voce.nome }));
}
function arcApriModifica(sezione, id) {
if (arcInModifica) { toast(t('arc.giaDentro')); return; }
const v = arcVoce(sezione, id);
if (!v) return;
const team = activeTeam();
const oppId = team ? ((activeOpponent(team) || {}).id || null) : null;
DB.arcSospeso = {
sezione, id, oppId,
leagueId: activeLeagueId || null,
teamId: team ? team.id : null,
dati: arcRaccogliDa(team, sezione, oppId),
};
arcInModifica = { sezione, id };
arcApplicaA(team, sezione, v.dati, oppId);
scheduleAutosave();
selectTeamTab(ARC_TAB[sezione]);
toast(t('arc.aperto', { nome: v.nome }));
}
async function arcSalvaModifiche() {
if (!arcInModifica) return;
const { sezione, id } = arcInModifica;
const v = arcVoce(sezione, id);
if (v) {
const nuovi = arcRaccogli(sezione);
if (sezione === 'personnel') await arcAlleggerisci(nuovi);
v.dati = nuovi;
}
arcChiudiModifica(true);
toast(t('arc.modificheSalvate'));
}
function arcChiudiModifica(zitto) {
const s = DB.arcSospeso;
if (s) {
const team = s.teamId ? arcSquadraPerId(s.teamId) : null;
arcApplicaA(team, s.sezione, s.dati, s.oppId);
delete DB.arcSospeso;
}
arcInModifica = null;
scheduleAutosave();
renderAll();
if (!zitto) toast(t('arc.uscito'));
}
/* Se l'app si e' chiusa mentre si stava dentro una copia d'archivio, il
* foglio vero era messo da parte nel DB: si rimette al suo posto da solo. */
function arcRipristinaSeInterrotto() {
if (_arcControllato) return;
_arcControllato = true;
if (!DB || !DB.arcSospeso) return;
const s = DB.arcSospeso;
try {
const team = s.teamId ? arcSquadraPerId(s.teamId) : null;
arcApplicaA(team, s.sezione, s.dati, s.oppId);
} catch (e) {}
delete DB.arcSospeso;
arcInModifica = null;
scheduleAutosave();
setTimeout(() => { try { toast(t('arc.ripristinato')); } catch (e) {} }, 600);
}
function arcRiporta(sezione, id) {
if (arcInModifica) { toast(t('arc.giaDentro')); return; }
const v = arcVoce(sezione, id);
if (!v) return;
showConfirmModal(t('arc.confermaRiporta', { nome: v.nome }), () => {
const esito = arcApplica(sezione, v.dati);
scheduleAutosave();
renderAll();
toast(t('arc.riportato', { nome: v.nome }));
if (esito && esito.saltate) setTimeout(() => toast(t('arc.personSaltate', { n: esito.saltate })), 900);
});
}
function arcRinomina(sezione, id) {
const v = arcVoce(sezione, id);
if (!v) return;
showPromptModal(t('arc.rinominaTitolo'), '', (nome) => {
if (!nome || !nome.trim()) return;
v.nome = nome.trim();
scheduleAutosave();
arcRenderTutti();
}, v.nome);
}
function arcElimina(sezione, id) {
const v = arcVoce(sezione, id);
if (!v) return;
if (arcInModifica && arcInModifica.id === id) { toast(t('arc.giaDentro')); return; }
showConfirmModal(t('arc.confermaElimina', { nome: v.nome }), () => {
const lista = arcContenitore(activeTeam())[sezione];
const i = lista.indexOf(v);
if (i >= 0) lista.splice(i, 1);
scheduleAutosave();
arcRenderTutti();
});
}
/* Copia su un'altra squadra. Non sostituisce mai a sorpresa:
* · playbook → i giochi si AGGIUNGONO in coda a quelli che ci sono;
* · scouting → nasce un avversario nuovo in quella squadra;
* · personnel → si riempiono le schede delle giocatrici che ci sono
* davvero (numero+cognome), e si dice quante sono saltate. */
function arcCopiaSu(sezione, id, teamId) {
const v = arcVoce(sezione, id);
const dest = arcSquadraPerId(teamId);
if (!v || !dest) return;
if (sezione === 'playbook') {
const rows = arcClona(v.dati.rows) || [];
rows.forEach(r => { r.id = nextId('pbrow'); });
ensurePlaybookRows(dest).push(...rows);
toast(t('arc.copiatoGiochi', { n: rows.length, squadra: dest.name || '' }));
} else if (sezione === 'scouting') {
const copia = arcClona(v.dati.opp);
if (!copia) return;
copia.id = nextId('opp');
ensureOpponents(dest).push(copia);
toast(t('arc.copiato', { squadra: dest.name || '' }));
} else {
const esito = arcApplicaA(dest, 'personnel', v.dati, null);
toast(t('arc.copiato', { squadra: dest.name || '' }));
if (esito && esito.saltate) setTimeout(() => toast(t('arc.personSaltate', { n: esito.saltate })), 900);
}
scheduleAutosave();
arcRenderTutti();
}
/* ---- L'ELENCO A SCHERMO ---------------------------------------------- */
function arcCard(sezione) {
const sec = document.getElementById(ARC_SEZ_HTML[sezione]);
if (!sec) return null;
let box = document.getElementById('arcCard-' + sezione);
if (!box) {
box = document.createElement('div');
box.id = 'arcCard-' + sezione;
box.className = 'card arc-card';
sec.appendChild(box);
}
return box;
}
function arcRenderTutti() { ARC_SEZIONI.forEach(arcRenderSezione); }
function arcRenderSezione(sezione) {
const box = arcCard(sezione);
if (!box) return;
const team = activeTeam();
if (!team && sezione !== 'playbook') { box.style.display = 'none'; return; }
box.style.display = '';
const lista = arcLista(sezione);
const dentro = arcInModifica && arcInModifica.sezione === sezione ? arcVoce(sezione, arcInModifica.id) : null;
const squadre = arcSquadreDisponibili();
const titolo = t(sezione === 'scouting' ? 'arc.titoloScouting' : sezione === 'personnel' ? 'arc.titoloPersonnel' : 'arc.titoloPlaybook');
const banner = dentro ? `
${escapeHtml(t('arc.inModifica', { nome: dentro.nome }))}
${escapeHtml(t('arc.salvaModBtn'))}
${escapeHtml(t('arc.esciBtn'))}
` : '';
const righe = lista.length ? lista.map(v => {
const suo = dentro && dentro.id === v.id;
const tendina = squadre.length ? `
${escapeHtml(t('arc.copiaSu'))}
${squadre.map(s => `${escapeHtml(s.nome)} `).join('')}
` : '';
return `
${escapeHtml(v.nome)}
${escapeHtml(tiroDataBella(v.quando) || '')}${v.teamName ? ' · ' + escapeHtml(v.teamName) : ''}
${escapeHtml(t('arc.modificaBtn'))}
${escapeHtml(t('arc.riportaBtn'))}
${tendina}
${escapeHtml(t('arc.rinominaBtn'))}
${escapeHtml(t('arc.eliminaBtn'))}
`;
}).join('') : `${escapeHtml(t('arc.vuoto'))}
`;
box.innerHTML = `
${escapeHtml(titolo)}
${escapeHtml(t('arc.salvaBtn'))}
${escapeHtml(t('arc.nota'))}
${banner}
${righe}
`;
}
// ==================================================================
// AVVERSARI (Opponents) — "Squadra" nel menu in alto resta SEMPRE la
// propria squadra (roster, playbook, practice...): lo Scouting Report
// invece è per definizione uno studio della squadra AVVERSARIA, quella
// contro cui si deve giocare — non della propria. Ogni squadra ha quindi
// una propria lista di avversari (uno per ogni prossimo impegno/rivale da
// studiare), ciascuno con il proprio Team Report a 3 colonne + statistiche,
// stesso principio di DATA.scouting in ITALBASKET WOMAN (un report per
// nazionale avversaria, non uno unico per l'Italia stessa).
let activeOpponentId = null;
function ensureOpponents(team) {
if (!Array.isArray(team.opponents)) {
team.opponents = [];
// Migrazione: le squadre create PRIMA di questa versione avevano un solo
// Team Report generico (team.scoutingReport) invece che per avversario —
// se conteneva già qualcosa di scritto, diventa qui il primo avversario
// invece di andare perso.
const legacy = team.scoutingReport;
if (legacy && typeof legacy === 'object' && Object.keys(legacy).some(k => (legacy[k] || '').toString().trim())) {
team.opponents.push({
id: nextId('opp'),
name: t('relazione.defaultOpponentName'),
scoutingReport: Object.assign(defaultScoutingReport(), { puntiForza: legacy.puntiForza, perVincere: legacy.perVincere, strategie: legacy.strategie, partita: legacy.partita, miglior3pt: legacy.miglior3pt, peggiorLiberi: legacy.peggiorLiberi }),
});
}
}
// Il coach deve trovare SUBITO un primo avversario pronto da compilare
// (struttura completa, tutti i campi già editabili), senza dover prima
// cliccare "+ Nuovo avversario" e dargli un nome solo per sbloccare le
// caselle — se la lista è vuota (squadra nuova, o nessun dato da
// migrare sopra), ne creiamo subito una di default.
if (!team.opponents.length) {
team.opponents.push({ id: nextId('opp'), name: t('relazione.defaultOpponentName'), scoutingReport: defaultScoutingReport() });
}
return team.opponents;
}
function activeOpponent(team) {
if (!team) return null;
const list = ensureOpponents(team);
if (!list.length) return null;
return list.find(o => o.id === activeOpponentId) || list[0];
}
function createOpponent() {
const team = activeTeam();
if (!team) return;
showPromptModal(t('prompt.newOpponentTitle'), t('prompt.newOpponentPh'), (name) => {
if (!name || !name.trim()) return;
const opp = {
id: nextId('opp'), name: name.trim(),
scoutingReport: defaultScoutingReport(),
};
ensureOpponents(team).push(opp);
activeOpponentId = opp.id;
renderScoutingReport(team);
// La lista avversari qui creata è la stessa usata dal selettore
// "Preparazione avversario" in Video (vedi renderVideoOppFocusBar): la
// sezione Video è già in pagina (renderVideoSection gira sempre da
// renderAll, solo nascosta via CSS se non è la scheda attiva) ma non si
// ridisegna da sola quando cambia solo team.opponents — va aggiornata
// qui a parte, altrimenti il nuovo avversario non comparirebbe nel
// menu a tendina finché non si cambia scheda e si ritorna su Video.
renderVideoOppFocusBar(team);
});
}
function selectOpponentTab(id) {
activeOpponentId = id;
renderScoutingReport(activeTeam());
}
function deleteOpponent(id) {
const team = activeTeam();
if (!team) return;
showConfirmModal(t('confirm.deleteOpponent'), () => {
team.opponents = ensureOpponents(team).filter(o => o.id !== id);
if (activeOpponentId === id) activeOpponentId = null;
if (team.videoFocusOpponentId === id) team.videoFocusOpponentId = null;
renderScoutingReport(team);
renderVideoOppFocusBar(team);
renderPlayerChips(team, currentClip());
});
}
function renderOpponentTabs(team) {
const el = document.getElementById('opponentTabs');
if (!el) return;
const list = ensureOpponents(team);
const opp = activeOpponent(team);
el.innerHTML = list.map(o => `
🆚 ${escapeHtml(o.name)}
✎
✕
`).join('') + `${t('relazione.addOpponentBtn')} `;
}
/* IL NOME DELL'AVVERSARIA SI CAMBIA. Prima sulla linguetta c'era solo la ✕
* per cancellarla: per correggere un nome scritto male bisognava buttarla
* via e rifare tutto il lavoro. Ora c'e' la matita (o doppio click sulla
* linguetta). Il roster di lega agganciato per nome viene rinominato
* insieme, altrimenti la pagina delle giocatrici si svuoterebbe di colpo. */
function rinominaOpponent(id) {
const team = activeTeam();
if (!team) return;
const o = ensureOpponents(team).find(x => x.id === id);
if (!o) return;
showPromptModal(t('relazione.rinominaAvversaria'), t('relazione.rinominaAvversariaPh'), (nome) => {
const nuovo = (nome || '').trim();
if (!nuovo || nuovo === o.name) return;
const vecchio = (o.name || '').trim().toLowerCase();
o.name = nuovo;
const lg = activeLeague();
if (lg && vecchio) {
(lg.oppRosters || []).forEach(r => { if (r && (r.name || '').trim().toLowerCase() === vecchio) r.name = nuovo; });
(lg.teams || []).forEach(r => { if (r && r.id !== team.id && (r.name || '').trim().toLowerCase() === vecchio) r.name = nuovo; });
}
scheduleAutosave();
renderScoutingReport(team);
try { renderVideoOppFocusBar(team); } catch (e) {}
}, o.name);
}
// Team Report dello Scouting Report — stesse categorie di ITALBASKET WOMAN
// (Punti di forza / Per vincere / Strategie / Partita + statistiche), ma
// generiche: niente campi Italia-specifici (bandiere, girone D...). Opera
// sull'AVVERSARIO attivo (vedi sopra), non più sulla squadra stessa.
// Struttura granulare del Team Report, presa 1:1 dai sotto-campi di
// ITALBASKET WOMAN (teamReportHTML): non più un'unica casella di testo
// libero per box, ma le stesse sotto-voci (Forza mentale/Attacco/Tiro da
// 3pt/1 contro 1/Difesa dentro "Punti di forza", + Statistiche di squadra/
// avanzate/tiratrici in fondo) — sempre tutte presenti e già editabili,
// niente pulsanti "aggiungi campo" da premere prima di poter scrivere.
function defaultScoutingReport() {
return {
puntiForza: { forzaMentale: '', attacco: '', tiro3pt: { media: '', tiratrici: '' }, unoUno: '', difesa: '' },
perVincere: { forzaMentale: '', difesa: '', attacco: '' },
strategie: { difesa: '' },
// "Partita" rispecchia le stesse voci di "Punti di forza" (stesse 5
// sotto-caselle: Forza mentale/Attacco/Tiro da 3pt/1 contro 1/Difesa),
// ma sono campi indipendenti — qui il coach scrive le note aggiornate al
// giorno partita, senza sovrascrivere quanto già scritto in "Punti di
// forza" durante lo scouting preventivo.
partita: { forzaMentale: '', attacco: '', tiro3pt: { media: '', tiratrici: '' }, unoUno: '', difesa: '' },
statisticheSquadra: { rimbalziOff: '', tiro3pt: '', pallePerse: '', recuperi: '' },
migliorTiratrice3pt: '',
migliorTiratriceTL: '',
peggiorTiratriceTL: '',
// Le due caselle finali "sotto" la griglia principale (2 colonne x 3
// box): player generici da tenere d'occhio, non legati a una sola
// statistica come le tiratrici qui sopra.
miglioriGiocatori: '',
statisticheAvanzate: { puntiPerPossesso: '', tiro2pt: '', tiro3pt: '', fonte: '', nota: '' },
};
}
function getByPath(obj, path) {
return path.split('.').reduce((o, k) => (o == null ? o : o[k]), obj);
}
function setByPath(obj, path, value) {
const parts = path.split('.');
let o = obj;
for (let i = 0; i < parts.length - 1; i++) o = o[parts[i]];
o[parts[parts.length - 1]] = value;
}
// [id casella HTML, percorso nell'oggetto scoutingReport] — un solo elenco
// usato sia per leggere/scrivere i valori (renderScoutingReport) sia per
// salvarli (updateScoutingReportField), invece di ripetere ~23 campi a mano
// in più punti.
const SCOUTING_FIELD_MAP = [
['srPfForzaMentale', 'puntiForza.forzaMentale'],
['srPfAttacco', 'puntiForza.attacco'],
['srPfTiro3ptMedia', 'puntiForza.tiro3pt.media'],
['srPfTiro3ptTiratrici', 'puntiForza.tiro3pt.tiratrici'],
['srPfUnoUno', 'puntiForza.unoUno'],
['srPfDifesa', 'puntiForza.difesa'],
['srPvForzaMentale', 'perVincere.forzaMentale'],
['srPvDifesa', 'perVincere.difesa'],
['srPvAttacco', 'perVincere.attacco'],
['srStDifesa', 'strategie.difesa'],
['srPaForzaMentale', 'partita.forzaMentale'],
['srPaAttacco', 'partita.attacco'],
['srPaTiro3ptMedia', 'partita.tiro3pt.media'],
['srPaTiro3ptTiratrici', 'partita.tiro3pt.tiratrici'],
['srPaUnoUno', 'partita.unoUno'],
['srPaDifesa', 'partita.difesa'],
['srSsRimbalziOff', 'statisticheSquadra.rimbalziOff'],
['srSsTiro3pt', 'statisticheSquadra.tiro3pt'],
['srSsPallePerse', 'statisticheSquadra.pallePerse'],
['srSsRecuperi', 'statisticheSquadra.recuperi'],
['srMiglior3pt', 'migliorTiratrice3pt'],
['srMigliorTL', 'migliorTiratriceTL'],
['srPeggiorLiberi', 'peggiorTiratriceTL'],
['srMiglioriGiocatori', 'miglioriGiocatori'],
['srSaPuntiPerPossesso', 'statisticheAvanzate.puntiPerPossesso'],
['srSaTiro2pt', 'statisticheAvanzate.tiro2pt'],
['srSaTiro3pt', 'statisticheAvanzate.tiro3pt'],
['srSaFonte', 'statisticheAvanzate.fonte'],
['srSaNota', 'statisticheAvanzate.nota'],
];
// Direzione inversa (percorso -> id casella): serve a onScoutingBoxFieldEdit
// per tenere sincronizzata la casella del modulo quando si scrive invece
// direttamente sul foglio sempre visibile.
const SCOUTING_PATH_TO_ID = Object.fromEntries(SCOUTING_FIELD_MAP.map(([id, path]) => [path, id]));
// ------------------------------------------------- DA MAIUSCOLO A MINUSCOLO
// Il 2 settembre 2026 e' entrata la regola "una parola in MAIUSCOLO esce in
// GRASSETTO sul foglio". Il coach pero' aveva scritto TUTTO in maiuscolo negli
// anni prima: alla stampa gli e' uscito il foglio interamente in grassetto.
// Questa conversione riporta in minuscolo quello che era gia' scritto, UNA
// VOLTA SOLA per avversario (bandierina opponent.scoutMinuscoloFatto).
// Sulla STAMPA non cambia niente: il foglio maiuscola da solo
// (text-transform). Cambia cosa c'e' scritto nel file, ed e' quello che
// rimette il maiuscolo nel ruolo di segnale invece che di norma.
// PRIMA PROVA (fallita): era appesa a renderAll() e girava su tutto il DB.
// Non e' mai partita — la stampa delle 22.18 era ancora tutta in grassetto.
// Adesso sta dentro ensureScoutingReport, che e' il passaggio obbligato di
// chiunque apra, disegni o stampi un report: se il foglio si vede, questa
// e' passata.
function scoutRigaTuttaAlta(v) {
const s = (v == null ? '' : String(v));
if (!s.trim()) return false;
return s === s.toUpperCase() && s !== s.toLowerCase(); // ha lettere ed e' tutta alta
}
function abbassaScoutInMinuscolo(opponent) {
if (!opponent || opponent.scoutMinuscoloFatto) return;
const sr = opponent.scoutingReport;
if (!sr) return;
// SOLO i campi che finiscono nelle caselle del foglio. Statistiche, fonte e
// note metodologiche restano come sono state battute: quelle si leggono a
// schermo e sul foglio non ci vanno.
const campi = [
'puntiForza.forzaMentale', 'puntiForza.difesa', 'puntiForza.attacco',
'puntiForza.unoUno', 'puntiForza.tiro3pt.media', 'puntiForza.tiro3pt.tiratrici',
'perVincere.forzaMentale', 'perVincere.attacco', 'perVincere.difesa',
'strategie.difesa',
'partita.forzaMentale', 'partita.difesa', 'partita.attacco',
'partita.unoUno', 'partita.tiro3pt.media', 'partita.tiro3pt.tiratrici',
'migliorTiratrice3pt', 'migliorTiratriceTL', 'peggiorTiratriceTL',
'miglioriGiocatori',
];
campi.forEach(percorso => {
let v;
try { v = getByPath(sr, percorso); } catch (e) { return; }
if (scoutRigaTuttaAlta(v)) { try { setByPath(sr, percorso, String(v).toLowerCase()); } catch (e) {} }
});
(opponent.boxLiberi || []).forEach(b => { // le caselle aggiunte a mano
if (scoutRigaTuttaAlta(b.titolo)) b.titolo = String(b.titolo).toLowerCase();
if (scoutRigaTuttaAlta(b.testo)) b.testo = String(b.testo).toLowerCase();
// le righe nome/valore delle caselle "dati" NON si toccano: quelle sul
// foglio escono come sono scritte (non passano dal maiuscolo automatico,
// che vale solo per .print-line), quindi abbassarle si vedrebbe.
});
opponent.scoutMinuscoloFatto = true;
try { scheduleAutosave(); } catch (e) {}
}
function ensureScoutingReport(opponent) {
if (!opponent.scoutingReport || typeof opponent.scoutingReport !== 'object') {
opponent.scoutingReport = defaultScoutingReport();
}
const sr = opponent.scoutingReport;
const d = defaultScoutingReport();
// Migrazione dal vecchio formato (una sola casella di testo libero per
// box, prima di questa versione) al nuovo formato a sotto-campi: quanto
// il coach aveva già scritto non va perso, finisce nel sotto-campo più
// vicino (di solito "Attacco"/"Difesa") della stessa casella.
if (typeof sr.puntiForza === 'string') { const old = sr.puntiForza; sr.puntiForza = d.puntiForza; sr.puntiForza.attacco = old; }
if (typeof sr.perVincere === 'string') { const old = sr.perVincere; sr.perVincere = d.perVincere; sr.perVincere.attacco = old; }
if (typeof sr.strategie === 'string') { const old = sr.strategie; sr.strategie = d.strategie; sr.strategie.difesa = old; }
if (typeof sr.partita === 'string') { const old = sr.partita; sr.partita = d.partita; sr.partita.attacco = old; }
if (typeof sr.miglior3pt === 'string') { sr.migliorTiratrice3pt = sr.miglior3pt; delete sr.miglior3pt; }
if (typeof sr.peggiorLiberi === 'string') { sr.peggiorTiratriceTL = sr.peggiorLiberi; delete sr.peggiorLiberi; }
/* 30/09/2026 — LE DUE CASELLE DEI LIBERI DIVENTANO UNA. Nel riquadro
"Peggiori tiratrici tiri liberi" ce n'erano due identiche, una sotto
l'altra, e sul foglio stampato finivano insieme: a schermo sembrava di
dover scrivere due volte la stessa cosa. Ne resta una, e quello che era
gia' scritto nell'altra ci finisce dentro invece di sparire. */
if (typeof sr.peggioriTiratoriLiberi === 'string' && sr.peggioriTiratoriLiberi.trim()) {
const prima = String(sr.peggiorTiratriceTL || '').trim();
sr.peggiorTiratriceTL = prima
? (prima + '\n' + sr.peggioriTiratoriLiberi.trim())
: sr.peggioriTiratoriLiberi.trim();
}
delete sr.peggioriTiratoriLiberi;
// Backfill: qualunque sotto-campo mancante (report creati con una versione
// precedente, o parzialmente migrati sopra) viene aggiunto senza toccare i
// valori già presenti.
if (!sr.puntiForza || typeof sr.puntiForza !== 'object') sr.puntiForza = d.puntiForza;
if (!sr.puntiForza.tiro3pt || typeof sr.puntiForza.tiro3pt !== 'object') sr.puntiForza.tiro3pt = { media: '', tiratrici: '' };
['forzaMentale', 'attacco', 'unoUno', 'difesa'].forEach(k => { if (sr.puntiForza[k] == null) sr.puntiForza[k] = ''; });
['media', 'tiratrici'].forEach(k => { if (sr.puntiForza.tiro3pt[k] == null) sr.puntiForza.tiro3pt[k] = ''; });
if (!sr.perVincere || typeof sr.perVincere !== 'object') sr.perVincere = d.perVincere;
['forzaMentale', 'difesa', 'attacco'].forEach(k => { if (sr.perVincere[k] == null) sr.perVincere[k] = ''; });
if (!sr.strategie || typeof sr.strategie !== 'object') sr.strategie = d.strategie;
if (sr.strategie.difesa == null) sr.strategie.difesa = '';
if (!sr.partita || typeof sr.partita !== 'object') sr.partita = d.partita;
if (!sr.partita.tiro3pt || typeof sr.partita.tiro3pt !== 'object') sr.partita.tiro3pt = { media: '', tiratrici: '' };
['forzaMentale', 'attacco', 'unoUno', 'difesa'].forEach(k => { if (sr.partita[k] == null) sr.partita[k] = ''; });
['media', 'tiratrici'].forEach(k => { if (sr.partita.tiro3pt[k] == null) sr.partita.tiro3pt[k] = ''; });
if (!sr.statisticheSquadra || typeof sr.statisticheSquadra !== 'object') sr.statisticheSquadra = { rimbalziOff: '', tiro3pt: '', pallePerse: '', recuperi: '' };
['rimbalziOff', 'tiro3pt', 'pallePerse', 'recuperi'].forEach(k => { if (sr.statisticheSquadra[k] == null) sr.statisticheSquadra[k] = ''; });
if (sr.migliorTiratrice3pt == null) sr.migliorTiratrice3pt = '';
if (sr.migliorTiratriceTL == null) sr.migliorTiratriceTL = '';
if (sr.peggiorTiratriceTL == null) sr.peggiorTiratriceTL = '';
if (sr.miglioriGiocatori == null) sr.miglioriGiocatori = '';
if (!sr.statisticheAvanzate || typeof sr.statisticheAvanzate !== 'object') sr.statisticheAvanzate = { puntiPerPossesso: '', tiro2pt: '', tiro3pt: '', fonte: '', nota: '' };
['puntiPerPossesso', 'tiro2pt', 'tiro3pt', 'fonte', 'nota'].forEach(k => { if (sr.statisticheAvanzate[k] == null) sr.statisticheAvanzate[k] = ''; });
try { abbassaScoutInMinuscolo(opponent); } catch (e) { console.error('minuscolo', e); }
return sr;
}
function updateScoutingReportField(path, value) {
const team = activeTeam();
if (!team) return;
const opp = activeOpponent(team);
if (!opp) return;
setByPath(ensureScoutingReport(opp), path, value);
// Solo l'anteprima live (non le textarea, che l'utente sta già scrivendo
// lui stesso lì dentro): la si aggiorna ad ogni tasto, senza toccare il
// focus/cursore di chi sta scrivendo.
renderScoutingLivePreview(team);
}
// Intestazione partita (bandiera/competizione/girone/data/ora/sede), come il
// letterhead del foglio ITALBASKET WOMAN caricato dal coach come riferimento:
// dati che vivono sull'avversario stesso (ogni avversario ha la sua propria
// partita/girone), non sulla squadra.
function ensureOpponentMatchInfo(opponent) {
if (opponent.flag == null) opponent.flag = '🏀';
if (opponent.competizione == null) opponent.competizione = '';
if (opponent.girone == null) opponent.girone = '';
if (opponent.dataPartita == null) opponent.dataPartita = '';
if (opponent.ora == null) opponent.ora = '';
if (opponent.sede == null) opponent.sede = '';
return opponent;
}
const MATCH_INFO_FIELD_MAP = [
['srMatchFlag', 'flag'], ['srMatchCompetizione', 'competizione'], ['srMatchGirone', 'girone'],
['srMatchData', 'dataPartita'], ['srMatchOra', 'ora'], ['srMatchSede', 'sede'],
];
function updateOpponentMatchInfo(field, value) {
const team = activeTeam();
if (!team) return;
const opp = activeOpponent(team);
if (!opp) return;
ensureOpponentMatchInfo(opp)[field] = value;
renderScoutingLivePreview(team);
}
// Giocatrici AVVERSARIE (pagina 2 del foglio ITALBASKET WOMAN caricato come
// riferimento: "GIOCATRICI [nome avversario]") — schede scoutate a mano
// dall'avversario da studiare, DIVERSE dal Personnel (che è invece il roster
// della PROPRIA squadra): numero/ruolo/statistiche/play type/note tattiche,
// tutte compilabili liberamente perché arrivano da fonti esterne (HUDL/
// Instat) e non dal roster interno.
function ensureOpponentPlayers(opponent) {
if (!Array.isArray(opponent.players)) opponent.players = [];
return opponent.players;
}
function defaultOpponentPlayer() {
return {
id: nextId('opl'), number: '', name: '', role: '', height: '', birthYear: '',
stats: { pt: '', tiro: '', tiro3: '', ast: '', reb: '', pp: '', stoppate: '' },
playType: '', note: '',
};
}
function addOpponentPlayer() {
const team = activeTeam();
if (!team) return;
const opp = activeOpponent(team);
if (!opp) return;
ensureOpponentPlayers(opp).push(defaultOpponentPlayer());
renderScoutingReport(team);
}
function removeOpponentPlayer(playerId) {
const team = activeTeam();
if (!team) return;
const opp = activeOpponent(team);
if (!opp) return;
opp.players = ensureOpponentPlayers(opp).filter(p => p.id !== playerId);
renderScoutingReport(team);
}
function updateOpponentPlayerField(playerId, field, value) {
const team = activeTeam();
if (!team) return;
const opp = activeOpponent(team);
if (!opp) return;
const p = ensureOpponentPlayers(opp).find(x => x.id === playerId);
if (!p) return;
if (field.indexOf('stats.') === 0) p.stats[field.slice(6)] = value;
else p[field] = value;
// Solo l'anteprima (non le caselle, dove il coach sta già scrivendo lui
// stesso): niente re-render della griglia ad ogni tasto premuto.
renderScoutingLivePreview(team);
}
function opponentPlayerCardHtml(p) {
return `
`;
}
function renderOpponentPlayersEditor(team, opp) {
const wrap = document.getElementById('opponentPlayersSection');
if (!wrap) return;
if (!opp) { wrap.style.display = 'none'; return; }
wrap.style.display = 'block';
const titleEl = document.getElementById('opponentPlayersTitle');
if (titleEl) titleEl.textContent = t('relazione.oppPlayersTitle', { name: opp.name });
const grid = document.getElementById('opponentPlayersGrid');
const players = ensureOpponentPlayers(opp);
grid.innerHTML = players.length
? players.map(opponentPlayerCardHtml).join('')
: `${t('relazione.noOppPlayersYet')}
`;
}
function renderScoutingReport(team) {
renderOpponentTabs(team);
const opp = activeOpponent(team);
const sr = opp ? ensureScoutingReport(opp) : null;
SCOUTING_FIELD_MAP.forEach(([id, path]) => {
const el = document.getElementById(id);
if (!el) return;
el.value = sr ? (getByPath(sr, path) || '') : '';
el.disabled = !opp;
});
MATCH_INFO_FIELD_MAP.forEach(([id, field]) => {
const el = document.getElementById(id);
if (!el) return;
el.value = opp ? (ensureOpponentMatchInfo(opp)[field] || '') : '';
el.disabled = !opp;
});
const emptyHint = document.getElementById('opponentEmptyHint');
if (emptyHint) emptyHint.style.display = opp ? 'none' : 'block';
renderOpponentPlayersEditor(team, opp);
sincronizzaTitoliAVideo();
renderScoutingLivePreview(team);
}
function ensureFixtures(team) {
if (!Array.isArray(team.fixtures)) team.fixtures = [];
return team.fixtures;
}
// Il coach deve trovare SUBITO un foglio seduta vuoto pronto da compilare
// (fasi/esercizi già lì, striscia diagrammi campo inclusa), esattamente come
// ensureOpponents fa per gli avversari — senza dover prima cliccare
// "+ Nuova seduta" solo per sbloccare i campi/diagrammi.
function ensurePractice(team) {
if (!Array.isArray(team.practice)) team.practice = [];
if (!team.practice.length) {
team.practice.push({
id: nextId('prs'),
numero: 1,
data: '', ora: '', luogo: '',
fasi: PRACTICE_DEFAULT_FASI.map(nome => ({ id: nextId('fase'), nome, esercizi: [{ id: nextId('ex'), durata: '', testo: '', diagram: null }] })),
gruppi: practiceDefaultGruppi(),
striscia: practiceDefaultStriscia(),
});
}
return team.practice;
}
// Campi della "scheda giocatrice" (Personnel) oltre a quelli già nel roster
// (numero/nome/ruolo, che NON si ripetono qui — arrivano dal roster stesso,
// unica fonte). Creati al volo per non rompere roster più vecchi importati.
function ensurePlayerProfile(p) {
if (p.photo == null) p.photo = '';
if (p.height == null) p.height = '';
if (p.birthYear == null) p.birthYear = '';
if (!p.stats || typeof p.stats !== 'object') p.stats = {};
['pts', 'reb', 'ast', 'p2', 'p3', 'ft'].forEach(k => { if (p.stats[k] == null) p.stats[k] = ''; });
if (!Array.isArray(p.traits)) p.traits = ['', '', '', ''];
if (p.slot == null) p.slot = ''; // slot di rotazione 1-12 nella griglia Personnel, assegnato in automatico da assignMissingSlots()
return p;
}
function addCategory(side) {
const team = activeTeam();
if (!team) return;
if (!possoCambiareImpianto()) return;
showPromptModal(t('prompt.newPlayTitle') + (side === 'attacco' ? t('common.attack') : t('common.defense')), t('prompt.newPlayPh'), (name) => {
ensureTagCategories(team)[side].push(name.toUpperCase());
renderVideoSection();
});
}
function removeCategory(side, name) {
const team = activeTeam();
if (!team) return;
if (!possoCambiareImpianto()) return;
ensureTagCategories(team)[side] = team.tagCategories[side].filter(c => c !== name);
renderVideoSection();
}
/* Rinomina una giocata esistente (Attacco O Difesa).
* I PEZZI GIA' TAGGATI SEGUONO IL NOME NUOVO. Prima l'etichetta restava
* "congelata" su quella vecchia: rinominavi il gioco e le clip che ci avevi
* messo dentro sparivano dalla sua scheda, perche' la scheda cerca i tag che
* portano il nome del gioco. Da fuori sembrava che rinominare cancellasse il
* lavoro fatto — ed e' esattamente quello che non deve succedere: il nome
* cambia, il materiale resta attaccato. Si porta dietro anche l'ordine del
* montaggio deciso per quel gioco. */
function renameCategory(side, oldName) {
const team = activeTeam();
if (!team) return;
if (!possoCambiareImpianto()) return;
showPromptModal(t('prompt.renamePlayTitle'), t('prompt.newPlayPh'), (name) => {
if (!name || !name.trim()) return;
const list = ensureTagCategories(team)[side];
const i = list.indexOf(oldName);
if (i === -1) return;
const nuovo = name.toUpperCase();
list[i] = nuovo;
// i tag di TUTTI i filmati della squadra, non solo di quello aperto
const vecchio = String(oldName || '').trim().toLowerCase();
let quanti = 0;
allTeamTags(team).forEach(e => {
if (String(e.tag.label || '').trim().toLowerCase() === vecchio) { e.tag.label = nuovo; quanti++; }
});
// e l'ordine del montaggio scelto per quel gioco
const ord = ordineClipDi(team);
const kVecchia = chiaveOrdineGioco(oldName), kNuova = chiaveOrdineGioco(nuovo);
if (ord[kVecchia] && kVecchia !== kNuova) { ord[kNuova] = ord[kVecchia]; delete ord[kVecchia]; }
scheduleAutosave();
renderVideoSection();
if (quanti) toast(t('video.rinominatiTag', { n: quanti }));
}, oldName);
}
function addQuickTag() {
const team = activeTeam();
if (!team) return;
if (!possoCambiareImpianto()) return;
showPromptModal(t('prompt.newQuickTagTitle'), t('prompt.newQuickTagPh'), (name) => {
ensureQuickTags(team).push(name.toUpperCase());
renderVideoSection();
});
}
function removeQuickTag(name) {
if (!possoCambiareImpianto()) return;
const team = activeTeam();
if (!team) return;
team.quickTags = ensureQuickTags(team).filter(q => q !== name);
renderVideoSection();
}
// ==================================================================
// RENDER
// ==================================================================
function renderAll() {
// Se l'app si e' chiusa mentre si stava dentro una copia d'archivio, il
// foglio vero torna al suo posto PRIMA di disegnare qualsiasi cosa.
try { arcRipristinaSeInterrotto(); } catch (e) {}
applyTranslations();
renderTopMenu();
renderWelcomeHero();
renderTeamManagement();
renderVideoSection();
renderTeamTabsUI();
renderActiveTeamBanner();
renderFolderRefUI();
try { arcRenderTutti(); } catch (e) {}
// Di nuovo alla fine: le sezioni appena ridisegnate contengono pezzi di
// pagina creati adesso, con i loro data-i18n, che al primo giro non
// esistevano ancora e quindi restavano nella lingua scritta nel codice.
applyTranslations();
}
// Schermata di benvenuto in cima alla pagina: mostra una card diversa a
// seconda di dove si trova il coach nel percorso Lega → Squadra, al posto
// del solo note-banner tecnico. Appena c'è almeno una squadra nella lega
// scelta, sparisce e torna il note-banner di riferimento come prima.
// Griglia delle squadre della lega: la propria (o le proprie) e tutte quelle
// a cui è stato dato un nome nelle caselle. Il logo mostrato qui è lo STESSO
// campo (team.logo) che l'app usa nel banner, nelle intestazioni di stampa e
// nel letterhead dello Scouting Report — caricato una volta, si ripete
// ovunque quella squadra compare.
function leagueTeamBadgeHtml(team) {
if (team.logo) return ` `;
if (team.flag) return `${escapeHtml(team.flag)} `;
return `${escapeHtml((team.name || '?').trim().slice(0, 2).toUpperCase())} `;
}
function renderLeagueTeamsSection() {
const grid = document.getElementById('leagueTeamsGrid');
if (!grid) return;
const league = activeLeague();
if (!league) { grid.innerHTML = ''; return; }
const titleEl = document.getElementById('squadreTitle');
if (titleEl) titleEl.textContent = t('squadre.titleWithLeague', { league: league.name });
// TUTTE le caselle, comprese quelle ancora vuote: la pagina è il "tabellone"
// della lega — si vedono i 12/16 loghi tutti insieme e da lì si entra nel
// roster di ciascuna. Le caselle vuote restano visibili in tono minore,
// pronte da riempire, così una lega da 12 squadre semplicemente ne lascia
// quattro libere invece di dover configurare da qualche parte quante sono.
// Il tabellone mostra 12 caselle: le squadre già inserite più tante caselle
// libere quante ne servono per arrivare a 12. Se le squadre sono più di 12
// (aggiunte con "+ Nuova Squadra") si vedono tutte, senza tagliarne fuori
// nessuna: 12 è la misura di partenza, non un tetto.
const tutte = allLeagueTeams(league, true);
const filled = allLeagueTeams(league, false);
const vuote = tutte.filter(e => e.empty);
/* Il tabellone parte da 12 caselle, ma una lega puo' averne di piu': una
* A2 ne ha venti, un europeo ventiquattro. Ogni casella aggiunta dal coach
* allarga il tabellone di uno, cosi' le caselle in piu' restano visibili
* anche prima di avere un nome. */
const aggiunte = Math.max(0, (league.oppRosters || []).length - AVV_SLOT_COUNT);
const daMostrare = Math.max(0, LEAGUE_BOARD_SIZE + aggiunte - filled.length);
const entries = tutte.filter(e => !e.empty).concat(vuote.slice(0, daMostrare));
const countEl = document.getElementById('squadreCount');
if (countEl) countEl.textContent = t('squadre.count', { n: filled.length, tot: entries.length });
grid.innerHTML = entries.map(({ team, own, empty, slotIndex }) => {
if (empty) {
return `
+
${escapeHtml(t('squadre.emptySlotLabel', { n: slotIndex + 1 }))}
`;
}
const roster = (team.players || []).filter(p => (p.name || '').trim());
const rows = ensurePlaybookRows(team);
const used = (r) => (r.nome || '').trim() || r.slots.some(sl => sl.elements && sl.elements.length);
const giochi = rows.filter(r => r.gruppo === 'attacco' && used(r)).length;
const difese = rows.filter(r => r.gruppo === 'difesa' && used(r)).length;
// Quanti pezzi taggati ha questa squadra: si vede dal tabellone, senza
// doverci entrare dentro.
const clipDiSquadra = allTeamTags(team).length;
return `
${leagueTeamBadgeHtml(team)}
${escapeHtml(team.name || '—')}
${escapeHtml(t('squadre.tileMeta', { players: roster.length, giochi, difese }))}
${clipDiSquadra ? `🎬 ${clipDiSquadra} ` : ''}
${`★ `}
${own ? ''
: `🖼️ `}
📋
${gemellaOmonima(team) ? `⚠︎⇄ ` : ''}
🗑
`;
}).join('');
/* Tabellone pieno: senza questo non c'era piu' modo di mettere la
* tredicesima squadra, e una lega da venti squadre non ci stava. */
if (daMostrare === 0) {
grid.innerHTML += `
+
${escapeHtml(t('squadre.aggiungi'))}
`;
}
renderLeagueClipsList(league, filled);
}
/* UNA CASELLA IN PIU' NELLA LEGA. Le dodici di partenza sono la misura di un
* girone tipico, non un tetto: chi allena in A2 o segue un europeo ne ha
* bisogno di venti o ventiquattro. */
function aggiungiCasellaLega() {
const league = activeLeague();
if (!league) return;
if (allLeagueTeams(league, false).length >= limite('squadre')) return bloccoBeta(t('beta.limitTeams', { n: LIMITI_BETA.squadre }));
ensureOppRosterSlots(league);
const slot = { id: nextId('oppr'), name: '', players: [], logo: null, flag: '' };
league.oppRosters.push(slot);
ensureOppRosterSlots(league); // da' forma completa anche alla nuova
renderAll();
scheduleAutosave();
nameLeagueSlot(slot.id); // e si chiede subito come si chiama
}
// Dà un nome a una casella ancora vuota della lega e ci entra subito dentro
// per compilarne il roster — il gesto naturale su un riquadro vuoto.
/* Due squadre con lo stesso nome nella stessa lega sono la strada più corta
* per impazzire: si scrivono le giocatrici in una e si guardano nell'altra,
* e sembra che l'app "non colleghi" niente. Prima di crearne una si guarda
* se c'è già, e nel caso si propone di aprire quella. */
function squadraOmonima(nome) {
const lg = activeLeague();
if (!lg) return null;
const n = (nome || '').trim().toLowerCase();
if (!n) return null;
return allLeagueTeams(lg, false).map(e => e.team).find(t => (t.name || '').trim().toLowerCase() === n) || null;
}
function avvisaSeEsisteGia(nome, quandoConfermato) {
const gia = squadraOmonima(nome);
if (!gia) { quandoConfermato(); return; }
showConfirmModal(t('squadre.gaExists', { nome: (gia.name || nome) }), () => {
// "OK" = apro quella che c'è già invece di farne una seconda
activeTeamId = gia.id;
if (typeof avvExpandedSlotId !== 'undefined') avvExpandedSlotId = gia.id;
renderAll();
toast(t('squadre.giaApertaOra', { nome: gia.name || '' }));
}, () => {
// "Annulla" = la voglio davvero doppia (capita: due squadre omonime)
quandoConfermato();
});
}
function nameLeagueSlot(slotId) {
if (allLeagueTeams(activeLeague(), false).length >= limite('squadre')) return bloccoBeta(t('beta.limitTeams', { n: LIMITI_BETA.squadre }));
const league = activeLeague();
if (!league) return;
const slot = ensureOppRosterSlots(league).find(x => x.id === slotId);
if (!slot) return;
showPromptModal(t('squadre.newOpponentTitle'), t('squadre.newOpponentPh'), (name) => {
if (!name || !name.trim()) return;
const scrivi = () => {
slot.name = name.trim();
upgradeLeagueTeamRecord(slot);
openLeagueTeamRoster(slot.id, false);
};
avvisaSeEsisteGia(name, scrivi);
});
}
// Logo caricato direttamente dal tabellone della lega: è lo stesso campo
// team.logo usato ovunque, quindi da qui si propaga subito a banner, video,
// scouting report e intestazioni di stampa.
function setLeagueTeamLogo(teamId, file) {
const league = activeLeague();
if (!league || !file) return;
const slot = ensureOppRosterSlots(league).find(x => x.id === teamId);
if (!slot) return;
const reader = new FileReader();
reader.onload = () => {
slot.logo = reader.result;
renderLeagueTeamsSection();
if (typeof scheduleAutosave === 'function') scheduleAutosave();
};
reader.readAsDataURL(file);
}
// Tutte le clip caricate per una qualunque squadra di questa lega, raggruppate
// per squadra. Le clip portano già il teamId di chi le ha importate, quindi
// basta tenere quelle che appartengono a una squadra di questa lega — così la
// pagina della lega diventa anche il posto da cui ritrovare i video, invece di
// doverli cercare squadra per squadra dentro la scheda Video.
function renderLeagueClipsList(league, entries) {
const box = document.getElementById('leagueClipsList');
if (!box) return;
const byTeam = new Map(entries.map(e => [e.team.id, e.team]));
const clips = (vt.clips || []).filter(c => byTeam.has(c.teamId));
if (!clips.length) {
box.innerHTML = `${t('squadre.clipsEmpty')}
`;
return;
}
box.innerHTML = entries.map(({ team }) => {
const teamClips = clips.filter(c => c.teamId === team.id);
if (!teamClips.length) return '';
const items = teamClips.map(c => {
const gi = vt.clips.indexOf(c);
return `
${escapeHtml(c.name)}
${t('squadre.clipTags', { n: (c.tags || []).length })}
`;
}).join('');
return `
${leagueTeamBadgeHtml(team)}${escapeHtml(team.name || '—')}
${items}
`;
}).join('');
}
// Apre una clip della lega: rende attiva la squadra a cui appartiene (così i
// suoi tag e la sua pulsantiera sono quelli giusti) e passa al Video.
function openLeagueClip(globalIndex) {
const clip = vt.clips[globalIndex];
if (!clip) return;
if (clip.teamId) activeTeamId = clip.teamId;
vt.activeClip = globalIndex;
activeTeamTab = 'video';
renderAll();
}
// Apre una squadra della lega: da qui in poi TUTTA l'app lavora su di lei —
// pulsantiera giocatrici nel Video, Playbook con i suoi giochi e le sue
// difese, logo nelle intestazioni. Si atterra sul Playbook perché è lì che
// stanno giochi e difese, cioè quello che si va a cercare aprendo una squadra.
function openLeagueTeam(teamId) {
activeTeamId = teamId;
activeTeamTab = 'playbook';
renderAll();
}
// Scorciatoia al roster: per una squadra della lega apre la sua cartella
// nell'archivio (già espansa, pronta da compilare), per la propria squadra
// porta all'anagrafica dove si gestiscono le giocatrici.
// Pannello anagrafica della propria squadra dentro la pagina della lega:
// chiuso di default (la pagina è il tabellone dei loghi), si apre cliccando
// il proprio box.
let squadreShowOwnTeamCard = false;
function openLeagueTeamRoster(teamId, own) {
activeTeamId = teamId;
if (own) {
// La propria squadra si apre lì dove sei, senza cambiare pagina: compare
// il pannello con nome, logo e roster, già portato a schermo.
squadreShowOwnTeamCard = true;
renderAll();
setTimeout(() => {
const el = document.getElementById('teamManagementSection');
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 60);
return;
}
if (own) {
activeTeamTab = 'relazione';
} else {
avvExpandedSlotId = teamId;
activeTeamTab = 'avversarie';
}
renderAll();
}
// Aggiunge una squadra della lega usando la prima casella ancora libera.
function addLeagueOpponentTeam() {
const league = activeLeague();
if (!league) return;
const slots = ensureOppRosterSlots(league);
const free = slots.find(s => !(s.name || '').trim());
if (!free) { toast(t('squadre.noFreeSlot')); return; }
showPromptModal(t('squadre.newOpponentTitle'), t('squadre.newOpponentPh'), (name) => {
if (!name || !name.trim()) return;
free.name = name.trim();
upgradeLeagueTeamRecord(free);
renderAll();
});
}
function renderWelcomeHero() {
const heroNoLeague = document.getElementById('welcomeHeroNoLeague');
const heroNoTeam = document.getElementById('welcomeHeroNoTeam');
const noteBanner = document.getElementById('mainNoteBanner');
// In modalità Partita Singola non c'è concetto di lega: niente hero di
// benvenuto né note-banner tecnico, solo la pagina Video ripulita.
if (singleMatchMode) {
heroNoLeague.style.display = 'none';
heroNoTeam.style.display = 'none';
noteBanner.style.display = 'none';
return;
}
const league = activeLeague();
if (!league) {
heroNoLeague.style.display = 'block';
heroNoTeam.style.display = 'none';
noteBanner.style.display = 'none';
} else if (!activeTeam() && activeTeamTab !== 'squadre') {
// Condizione corretta: "non c'è NESSUNA squadra attiva", non "la lega non
// ha squadre proprie". Da quando anche le squadre della lega sono squadre
// a tutti gli effetti, si può lavorare benissimo su una di loro senza
// averne creata una propria: in quel caso l'invito "aggiungi la tua prima
// squadra" non ha senso e, restando visibile, rubava oltre 400px di
// altezza proprio sopra al player video.
// Sulla pagina "Squadre della lega" NON si mostra l'invito "aggiungi la
// prima squadra": quella pagina è già il posto da cui si aggiungono, e
// l'hero sopra la coprirebbe.
heroNoLeague.style.display = 'none';
heroNoTeam.style.display = 'block';
document.getElementById('whNoTeamTitle').textContent = t('hero.noTeamTitle', { league: league.name });
document.getElementById('whNoTeamText').textContent = t('hero.noTeamText', { league: league.name });
noteBanner.style.display = 'none';
} else {
heroNoLeague.style.display = 'none';
heroNoTeam.style.display = 'none';
noteBanner.style.display = '';
}
}
// Striscia logo+nome squadra sopra Video/Playbook/Scouting Report/Practice:
// un solo punto dove il logo viene letto da team.logo, così non va reinserito
// sezione per sezione — "si ripete da solo" seguendo la stessa squadra.
function renderActiveTeamBanner() {
const team = activeTeam();
const banner = document.getElementById('activeTeamBanner');
if (!team) { banner.style.display = 'none'; return; }
banner.style.display = 'flex';
document.getElementById('teamBannerLogo').src = team.logo || 'data:image/svg+xml;utf8, ';
document.getElementById('teamBannerName').textContent = team.name;
const league = activeLeague();
document.getElementById('teamBannerLeague').textContent = league ? league.name : '';
}
/* Sotto-schede della squadra: Video & Tagging / Playbook / Personnel / Calendario.
* Tutte vivono nello stesso record squadra (team.playbook, team.personnel,
* team.fixtures accanto a team.players e ai clip video): aprendo una squadra
* per taggare un video, il coach trova già qui schemi, formazioni e calendario
* inseriti in precedenza, senza dover ricollegare nulla. */
function selectTeamTab(tab) {
activeTeamTab = tab;
renderTeamTabsUI();
}
/* Altezza VERA delle due barre fisse in cima (intestazione e menu): le barre
* vanno a capo a seconda della larghezza della finestra e della lingua, e
* un'altezza fissa scritta a mano faceva scivolare il menu sotto
* all'intestazione (o lasciava una fessura da cui si vedeva passare la
* pagina). Si misura e si scrive in due variabili CSS. */
function aggiornaBarreFisse() {
const h = document.querySelector('header');
const n = document.querySelector('nav.topmenu');
const r = document.documentElement.style;
const hh = (h && getComputedStyle(h).display !== 'none') ? Math.round(h.getBoundingClientRect().height) : 0;
r.setProperty('--h-header', hh + 'px');
if (n) r.setProperty('--h-nav', Math.round(n.getBoundingClientRect().height) + 'px');
}
window.addEventListener('resize', () => requestAnimationFrame(aggiornaBarreFisse));
function renderTeamTabsUI() {
const team = activeTeam();
const nav = document.getElementById('topmenuViewNav');
// Video - Playbook - Relazione (Relazione = Personnel/posizioni + Calendario
// impegni insieme) sono voci di navigazione nel menu in alto, allo stesso
// livello di Lega/Squadra — visibili anche nella schermata iniziale, prima
// di scegliere una squadra: Video e Relazione mostrano un avviso perché
// dipendono dal roster/calendario di una squadra, ma il Playbook resta
// usabile subito (schemi/diagrammi generali, poi eventualmente riassegnabili
// a una squadra) perché il coach potrebbe voler disegnare solo quello.
// In modalità Partita Singola tutte le voci (Video, Playbook, Scouting
// Report, Practice, Istruzioni) restano visibili e con lo stesso peso:
// la partita singola è comunque una "squadra" a tutti gli effetti
// (activeTeam() la risolve da DB.singleMatches), quindi playbook/scouting/
// practice funzionano subito su di essa esattamente come per una squadra
// di lega vera e propria.
// Rose Avversarie è un archivio DI LEGA: in modalità Partita Singola non
// esiste il concetto di lega, quindi quella voce non avrebbe nulla da
// mostrare — si nasconde solo lì, come le altre schede restano invece
// tutte disponibili.
if (singleMatchMode && activeTeamTab === 'avversarie') activeTeamTab = 'video';
// La pagina "Squadre della lega" non ha senso in Partita Singola (non c'è
// nessuna lega): lì si ricade sul Video, come già succede per Avversarie.
if (singleMatchMode && activeTeamTab === 'squadre') activeTeamTab = 'video';
const sections = { squadre: 'squadreSection', video: 'videoSection', playbook: 'playbookSection', avversarie: 'avversarieSection', giochi: 'giochiSection', relazione: 'relazioneSection', practice: 'practiceSection', editvideo: 'editvideoSection', presentazione: 'presentazioneSection', istruzioni: 'istruzioniSection' };
nav.style.display = 'flex';
nav.querySelectorAll('.topmenu-view-btn').forEach(btn => {
btn.style.display = (singleMatchMode && (btn.dataset.view === 'avversarie' || btn.dataset.view === 'squadre')) ? 'none' : '';
btn.classList.toggle('active', btn.dataset.view === activeTeamTab);
});
Object.entries(sections).forEach(([tab, id]) => { const n = document.getElementById(id); if (n) n.style.display = (tab === activeTeamTab) ? 'block' : 'none'; });
if (activeTeamTab === 'squadre') renderLeagueTeamsSection();
if (activeTeamTab === 'giochi') renderGiochiSection();
if (activeTeamTab === 'presentazione') renderPresentazioneSection();
// Nella scheda Video sfruttiamo anche lo spazio laterale fuori dai 1400px
// (vedi main.video-wide in CSS): più posto sia per il player che per le
// caselle Attacco/Difesa su schermi larghi.
const appMain = document.getElementById('appMain');
if (appMain) appMain.classList.toggle('video-wide', activeTeamTab === 'video');
// L'intestazione (titolo, Lingua, Esporta/Importa Lega) resta sempre
// visibile sopra "sticky", occupando riga fissa anche sulla scheda Video
// dove serve tutta l'altezza per il player: qui la nascondiamo solo in
// quella scheda (torna visibile su Playbook/Scouting Report/Practice/Home,
// dove Lingua ed Esporta/Importa Lega restano comunque raggiungibili).
const headerEl = document.querySelector('header');
if (headerEl) headerEl.style.display = (activeTeamTab === 'video') ? 'none' : 'flex';
document.body.classList.toggle('header-hidden', activeTeamTab === 'video');
try { vtModoTagAggiorna(); } catch (e) {}
// Il box Anagrafica Squadra + Roster (renderTeamManagement) restava sempre
// visibile SOPRA il player, con centinaia di pixel vuoti che spingevano
// video e caselle Attacco/Difesa sotto il bordo dello schermo — bisognava
// alzare/scrollare il video solo per vederle. Resta disponibile nelle
// altre schede (serve per rinominare la squadra o modificare il roster),
// ma nella scheda Video si nasconde per lasciare tutto lo spazio al player.
requestAnimationFrame(aggiornaBarreFisse);
applyTranslations();
const teamMgmt = document.getElementById('teamManagementSection');
if (teamMgmt && team) {
// Nella pagina della lega ci sono SOLO i box delle squadre: l'anagrafica
// della propria squadra (nome, logo, roster) è un pannello che si apre
// cliccando il proprio box, non un blocco fisso sopra al tabellone.
/* PRACTICE E ISTRUZIONI SONO SEZIONI STACCATE.
* L'anagrafica con nome, logo e ROSTER della squadra selezionata in alto
* compariva sopra a queste due pagine. Ma un allenamento e' tuo, e le
* istruzioni sono dell'app: nessuna delle due c'entra con la squadra che
* stai studiando. Risultato: preparando una partita ti ritrovavi il
* roster dell'avversaria (la Repubblica Ceca) stampato sopra al tuo
* piano di allenamento. Vale per QUALUNQUE squadra, non solo per quella:
* su queste due pagine l'anagrafica non ci va. */
const nascosta = activeTeamTab === 'video' || activeTeamTab === 'playbook' || activeTeamTab === 'editvideo'
|| activeTeamTab === 'practice' || activeTeamTab === 'istruzioni' || activeTeamTab === 'presentazione'
|| (activeTeamTab === 'squadre' && !squadreShowOwnTeamCard);
teamMgmt.style.display = nascosta ? 'none' : 'block';
}
// Il note-banner tecnico ("Prototipo web: Lega → Squadra...") restava
// visibile anche sulla scheda Video, rubando altezza al player e alle
// caselle Attacco/Difesa/giocatrici proprio dove serve più spazio.
// renderWelcomeHero() decide se andrebbe mostrato (lega/squadra presenti),
// poi qui lo forziamo comunque nascosto sulla sola scheda Video; sulle
// altre schede (Playbook/Scouting Report/Practice/Istruzioni) resta come
// deciso da renderWelcomeHero(), dove non ruba spazio a nulla.
renderWelcomeHero();
const noteBannerEl = document.getElementById('mainNoteBanner');
/* Presentazione aggiunta all'elenco: quel riquadro di testo si mangia
ottanta righe di altezza, e in Presentazione l'altezza e' esattamente
cio' che manca perche' player e clip stiano insieme nello schermo. */
if (noteBannerEl && (activeTeamTab === 'video' || activeTeamTab === 'squadre'
|| activeTeamTab === 'editvideo' || activeTeamTab === 'presentazione')) noteBannerEl.style.display = 'none';
// Importa Video + tab clip vivono ora dentro alla striscia Partita
// singola/squadra (#activeTeamBanner, visibile su tutte le schede) — ha
// senso solo sulla scheda Video, quindi lo mostriamo solo lì.
const videoToolbarInBanner = document.getElementById('videoToolbarInBanner');
if (videoToolbarInBanner) videoToolbarInBanner.style.display = (activeTeamTab === 'video') ? 'flex' : 'none';
document.getElementById('videoNoTeamHint').style.display = team ? 'none' : 'block';
document.getElementById('videoCard').style.display = team ? 'block' : 'none';
// Nascondere/mostrare l'intestazione sposta il player (wrapTop cambia):
// ricalcoliamo subito le sue dimensioni invece di aspettare un resize.
if (activeTeamTab === 'video') {
const clipNow = currentClip();
if (clipNow && clipNow.aspectRatio) applyVideoBoxSize(clipNow);
}
// Edit Video: il pannello del montaggio veniva riempito SOLO da
// renderVideoSection, cioè solo entrando nella scheda Video — aprendo Edit
// Video restava vuoto e sembrava che il montaggio non ci fosse. Qui viene
// riempito ogni volta che si apre la sezione.
if (activeTeamTab === 'editvideo') {
try { renderMontagePanel(); } catch (e) {}
const senzaPezzi = !team || !allTeamTags(team).length;
const avviso = document.getElementById('editVideoNoTeam');
if (avviso) avviso.style.display = senzaPezzi ? 'block' : 'none';
const btnAll = document.getElementById('evPlayAllBtn');
if (btnAll) btnAll.disabled = senzaPezzi;
} else {
// uscendo dalla sezione l'anteprima si ferma, altrimenti resterebbe a
// suonare da sola in sottofondo
try { evStop(); } catch (e) {}
}
document.getElementById('relazioneNoTeamHint').style.display = team ? 'none' : 'block';
document.getElementById('relazioneTeamCards').style.display = team ? 'block' : 'none';
document.getElementById('pbNoTeamNote').style.display = team ? 'none' : 'block';
document.getElementById('practiceNoTeamHint').style.display = team ? 'none' : 'block';
document.getElementById('practiceCard').style.display = team ? 'block' : 'none';
// Inizializzazione/render "pigra": il canvas del campo viene creato solo la
// prima volta che la relativa scheda è aperta (evita di disegnare campi
// invisibili ad ogni render). Il Playbook si inizializza sempre (funziona
// anche senza squadra); Scouting Report e Practice solo se una squadra è
// selezionata (dipendono dal roster/calendario di quella squadra).
if (activeTeamTab === 'playbook') { initDiagramCanvas(pbState); renderPlaybookSection(); }
if (activeTeamTab === 'avversarie') {
renderAvversarieSection();
// Il Personnel vive qui, insieme alle schede delle giocatrici, e mostra
// la squadra il cui box è aperto.
const tp = personnelTeam();
if (tp) renderPersonnelDossiers(tp);
}
if (activeTeamTab === 'relazione' && team) {
renderScoutingReport(team);
renderCalendarSection(team);
}
if (activeTeamTab === 'practice' && team) {
renderPracticeSection(team);
}
if (activeTeamTab === 'istruzioni') {
renderIstruzioniSection();
}
}
/* Menu Lega/Squadra in alto (due ), al posto del vecchio sidebar
* a sinistra che spariva/si stringeva quando il video aveva bisogno di
* tutta la larghezza per le pulsantiere Attacco/Difesa ai lati. */
function renderTopMenu() {
const leagueGroup = document.getElementById('leagueSelectGroup');
const teamGroup = document.getElementById('teamSelectGroup');
const smBanner = document.getElementById('singleMatchBanner');
if (singleMatchMode) {
leagueGroup.style.display = 'none';
teamGroup.style.display = 'none';
smBanner.style.display = 'flex';
const match = activeTeam();
document.getElementById('smbName').textContent = match ? match.name : '';
document.getElementById('smbCoach').textContent = match && match.coachName ? t('splash.coachLabel') + ': ' + match.coachName : '';
return;
}
leagueGroup.style.display = '';
teamGroup.style.display = '';
smBanner.style.display = 'none';
const leagueSelect = document.getElementById('leagueSelect');
const previousLeagueValue = activeLeagueId || '';
leagueSelect.innerHTML = '' + t('nav.leagueNone') + ' ' + DB.leagues.map(l =>
`${escapeHtml(l.name)} (${l.teams.length}) `
).join('');
leagueSelect.value = previousLeagueValue;
const btnDelLega = document.getElementById('delLeagueBtn');
if (btnDelLega) {
btnDelLega.style.display = activeLeagueId ? '' : 'none';
btnDelLega.title = t('squadre.deleteLeagueHint');
}
const league = activeLeague();
const teamSelect = document.getElementById('teamSelect');
const addTeamBtn = document.getElementById('addTeamBtn');
// Il bottone resta sempre cliccabile: se manca una lega, createTeam() la
// chiede lui stesso invece di restare disabilitato senza spiegazione.
addTeamBtn.title = league ? '' : t('nav.addTeamHint');
if (!league) {
teamSelect.innerHTML = '' + t('nav.selectLeagueFirst') + ' ';
} else if (!allLeagueTeams(league, false).length) {
// "nessuna squadra" solo se non c'è NIENTE: né squadre proprie né
// caselle avversarie compilate (prima bastava non avere squadre proprie
// e il menu si svuotava anche con dieci avversarie dentro).
teamSelect.innerHTML = '' + t('nav.noTeamsInLeague') + ' ';
} else {
// TUTTE le squadre della lega, non solo le proprie: le avversarie sono
// caselle, e prima non comparivano qui — si finiva a lavorare sul video
// con aperta la squadra sbagliata senza capire perché. Il numero fra
// parentesi è quello delle giocatrici SCRITTE, non delle righe vuote.
const elenco = allLeagueTeams(league, false).map(e => e.team);
const conta = (x) => (x.players || []).filter(p => (p && p.name || '').trim()).length;
teamSelect.innerHTML = elenco.map(x =>
`${escapeHtml(x.name || '—')} (${conta(x)}) `
).join('');
teamSelect.value = (activeTeamId && elenco.some(x => x.id === activeTeamId))
? activeTeamId : (elenco[0] ? elenco[0].id : '');
}
}
function renderTeamManagement() {
const team = activeTeam();
const section = document.getElementById('teamManagementSection');
if (!team) {
section.style.display = 'none';
return;
}
// La visibilità finale (mostrata/nascosta a seconda della scheda aperta)
// viene decisa in renderTeamTabsUI, che gira anche ad ogni cambio scheda
// (qui invece si passa solo dopo un render completo) — vedi lì il perché.
section.style.display = (activeTeamTab === 'video') ? 'none' : 'block';
document.getElementById('teamLogoPreview').src = team.logo || 'data:image/svg+xml;utf8, ';
document.getElementById('teamNameInput').value = team.name;
const tbody = document.getElementById('rosterTableBody');
if (!team.players.length) {
tbody.innerHTML = 'Nessun atleta in roster. ';
} else {
tbody.innerHTML = team.players
.slice()
.sort((a, b) => a.number - b.number)
.map(p => `
#${p.number}
${escapeHtml(p.name)}
${escapeHtml(roleLabel(p.role))}
${escapeHtml(p.height || '—')}
${escapeHtml(p.birthYear || '—')}
✕
`).join('');
}
}
function renderSessioneBar() {
const bar = document.getElementById('sessioneBar');
if (!bar) return;
sistemaSessioni();
const elenco = sessioni();
const attiva = sessioneAttiva();
if (!elenco.length) {
bar.innerHTML = `${escapeHtml(t('sessione.nessuna'))}
${escapeHtml(t('sessione.nuovaBtn'))} `;
return;
}
const quante = (id) => (vt.clips || []).filter(c => c.sessionId === id).length;
const pezzi = (id) => (vt.clips || []).filter(c => c.sessionId === id).reduce((n, c) => n + (c.tags || []).length, 0);
bar.innerHTML = `
🗂 ${escapeHtml(t('sessione.etichetta'))}
${elenco.map(x => `${x.archiviata ? '📦 ' : ''}${escapeHtml(x.nome)} — ${quante(x.id)} 🎥 · ${pezzi(x.id)} ✂️ `).join('')}
✏️
${escapeHtml(t('sessione.nuovaBtn'))}
${attiva && !attiva.archiviata
? `${escapeHtml(t('sessione.chiudiBtn'))} `
: `📦 ${escapeHtml(t('sessione.archiviata'))} `}
🗑 `;
}
function creaSessioneChiedi() {
// Il nome arriva gia' scritto ("LAVORO PARTITA DI BELGIO"): il coach
// preme Invio e va, oppure ci aggiunge la data o l'avversaria giusta.
showPromptModal(t('sessione.nuovaTitolo'), t('sessione.nuovaPh'), (nome) => {
nuovaSessione(nome);
renderAll();
scheduleAutosave();
toast(t('sessione.creata', { nome: sessioneAttiva().nome }));
}, nomeSessioneProposto());
}
function apriSessione(id) {
if (!id) return;
vt.sessioneAttivaId = id;
// Ci si mette sopra al primo filmato di quella sessione, se c'e'.
const i = (vt.clips || []).findIndex(c => c.sessionId === id);
if (i >= 0) { vt.activeClip = i; vt.clipAppuntata = vt.clips[i].id; }
else { vt.activeClip = -1; vt.clipAppuntata = null; }
renderAll();
scheduleAutosave();
}
function rinominaSessione() {
const ses = sessioneAttiva();
if (!ses) return;
showPromptModal(t('sessione.rinominaTitolo'), ses.nome, (nome) => {
const n = (nome || '').trim();
if (!n) return;
ses.nome = n;
renderAll(); scheduleAutosave();
}, ses.nome);
}
/* CHIUDERE la sessione: prima si mettono al sicuro i pezzi (ritagliati e
* scritti nella cartella), POI si buttano i filmati pesanti. Mai il
* contrario: se si buttasse prima il filmato, i pezzi non taggati sarebbero
* persi per sempre. */
/* UNA PARTITA ALLA VOLTA, NON TUTTA LA SESSIONE.
* Un lavoro sono tre, quattro, sei partite. Finita di taggare la prima non ha
* senso tenersi in casa due giga di filmato: quello che serve da li' in poi
* sono i PEZZI TAGLIATI, non la partita intera. Questo bottone fa per un
* singolo filmato quello che "chiudi sessione" faceva per tutti: mette al
* sicuro i tagli, libera il filmato pesante, e lascia la sessione APERTA
* cosi' subito dopo si importa la partita successiva. Tag, pezzi, scouting e
* personnel non si toccano. */
async function chiudiFilmato(indice) {
const c = vt.clips[indice];
if (!c) return;
if (c.archiviata) { toast(t('sessione.giaArchiviata')); return; }
const pezzi = (c.tags || []).filter(tg => tg.end > tg.start && (tg.side || tg.label || tg.playerId)).length;
showConfirmModal(t('sessione.finitoChiedi', { nome: c.name || '—', pezzi }), async () => {
// 1) prima si mettono al sicuro i tagli non ancora su disco
if (cartellaDiRiferimentoPronta()) {
const prima = vt.activeClip;
vt.activeClip = indice;
try { await salvaTutteLeClipNellaCartella(); } catch (e) {}
vt.activeClip = prima;
await attendiCodaTagli();
}
// 2) poi si libera il filmato pesante
try { await videoBlobDbDelete(c.id); } catch (e) {}
c.archiviata = true;
c.url = '';
c.mancante = true;
ricollegaTagliSuDisco();
// se era il filmato aperto, si passa al primo ancora vivo
if (vt.activeClip === indice) {
const vivo = (vt.clips || []).findIndex(x => !x.archiviata);
vt.activeClip = vivo >= 0 ? vivo : indice;
}
renderAll();
scheduleAutosave();
toast(t('sessione.finitoFatto', { nome: c.name || '—', pezzi }));
});
}
async function chiudiSessione() {
const ses = sessioneAttiva();
if (!ses) return;
const clip = (vt.clips || []).filter(c => c.sessionId === ses.id && !c.archiviata);
if (!clip.length) { toast(t('sessione.giaChiusa')); return; }
const daTagliare = clip.reduce((n, c) => n + (c.tags || []).filter(tg => tg.end > tg.start && (tg.side || tg.label || tg.playerId) && !tg.cutFile).length, 0);
const messaggio = cartellaDiRiferimentoPronta()
? t('sessione.chiudiChiedi', { nome: ses.nome, n: clip.length, pezzi: daTagliare })
: t('sessione.chiudiSenzaCartella', { nome: ses.nome, n: clip.length });
showConfirmModal(messaggio, async () => {
// 1) i pezzi non ancora su disco si salvano adesso
if (cartellaDiRiferimentoPronta() && daTagliare) {
toast(t('sessione.metteAlSicuro', { n: daTagliare }));
for (const c of clip) {
const prima = vt.activeClip;
vt.activeClip = vt.clips.indexOf(c);
await salvaTutteLeClipNellaCartella();
vt.activeClip = prima;
}
await attendiCodaTagli();
}
// 2) via i filmati pesanti
let liberati = 0;
for (const c of clip) {
try { await videoBlobDbDelete(c.id); liberati++; } catch (e) {}
c.archiviata = true;
c.url = '';
c.mancante = true;
}
ses.archiviata = true;
ricollegaTagliSuDisco();
renderAll();
scheduleAutosave();
toast(t('sessione.chiusa', { nome: ses.nome, n: liberati }));
});
}
function attendiCodaTagli() {
return new Promise((res) => {
const guarda = () => {
if (!cutQueueRunning && !cutQueue.length) { res(); return; }
setTimeout(guarda, 400);
};
guarda();
});
}
function eliminaSessione() {
const ses = sessioneAttiva();
if (!ses) return;
const clip = (vt.clips || []).filter(c => c.sessionId === ses.id);
const pezzi = clip.reduce((n, c) => n + (c.tags || []).length, 0);
showConfirmModal(t('sessione.eliminaChiedi', { nome: ses.nome, n: clip.length, pezzi: pezzi }), async () => {
for (const c of clip) { try { await videoBlobDbDelete(c.id); } catch (e) {} }
vt.clips = (vt.clips || []).filter(c => c.sessionId !== ses.id);
DB.sessioni = sessioni().filter(x => x.id !== ses.id);
vt.sessioneAttivaId = null;
vt.activeClip = vt.clips.length ? 0 : -1;
vt.clipAppuntata = vt.clips.length ? vt.clips[0].id : null;
sistemaSessioni();
renderAll();
scheduleAutosave();
toast(t('sessione.eliminata', { nome: ses.nome }));
});
}
/* Avviso grosso e chiaro quando ci sono filmati senza padrone: e' la
* situazione che fa sparire i pezzi dalle schede dei giochi, e senza dirlo
* il coach pensa di aver perso il lavoro. Un bottone solo lo rimette a posto. */
function clipOrfaneElenco() {
return (vt.clips || []).filter(c => clipSenzaPadrone(c));
}
function renderAvvisoOrfane(team) {
const box = document.getElementById('avvisoOrfane');
if (!box) return;
const orfane = clipOrfaneElenco();
if (!orfane.length || !team) { box.style.display = 'none'; box.innerHTML = ''; return; }
const pezzi = orfane.reduce((n, c) => n + (c.tags || []).length, 0);
box.style.display = 'block';
box.innerHTML = `
⚠︎ ${escapeHtml(t('orfane.avviso', { n: orfane.length, pezzi: pezzi }))}
${escapeHtml(t('orfane.recupera', { squadra: team.name || '' }))} `;
}
function recuperaClipOrfane() {
const team = activeTeam();
if (!team) return;
const orfane = clipOrfaneElenco();
if (!orfane.length) return;
showConfirmModal(t('orfane.chiedi', { n: orfane.length, squadra: team.name || '' }), () => {
orfane.forEach(c => { c.teamId = team.id; });
renderAll();
scheduleAutosave();
toast(t('orfane.fatto', { n: orfane.length, squadra: team.name || '' }));
});
}
function renderVideoSection() {
const team = activeTeam();
// Anche senza nessuna squadra aperta (es. appena cancellata l'ultima) la
// scheda Video deve restare usabile: i filmati ci sono e si devono poter
// guardare, importare e riassegnare.
if (!document.getElementById('clipTabs')) return;
// La pagina non deve saltare mentre si tagga: il viene ricreato da
// zero ad ogni ridisegno e il browser, per un istante, si ritrova con meno
// contenuto e riporta lo scroll più in alto. Ce lo riprendiamo com'era.
const scrollPrima = window.scrollY || 0;
requestAnimationFrame(() => {
if (Math.abs((window.scrollY || 0) - scrollPrima) > 1) window.scrollTo(0, scrollPrima);
});
renderSessioneBar();
renderAvvisoOrfane(team);
const teamClips = clipDaMostrare(team);
const clipTabs = document.getElementById('clipTabs');
if (!teamClips.length) {
clipTabs.innerHTML = '' + t('video.noVideoImported') + '
';
} else {
const squadre = squadreDoveSpostare();
clipTabs.innerHTML = teamClips.map(c => {
const globalIndex = vt.clips.indexOf(c);
const attiva = globalIndex === vt.activeClip;
// Sul video aperto compare anche a CHI appartiene: si cambia squadra
// dal menù, senza reimportarlo.
const orfana = clipSenzaPadrone(c);
const altrui = !orfana && team && c.teamId !== team.id;
const proprietaria = altrui ? (teamById(c.teamId) || {}).name : '';
const menuSquadra = ((attiva || orfana) && squadre.length)
? `
${orfana ? `${escapeHtml(t('video.clipNoTeam'))} ` : ''}
${squadre.map(t2 => `${escapeHtml(t2.name || '—')} `).join('')}
`
: '';
const segno = orfana ? '⚠︎ ' : (altrui ? '📌 ' : '');
const spiega = orfana ? t('video.clipOrphanHint')
: (altrui ? t('video.clipPinnedHint', { squadra: proprietaria || '—' }) : '');
return `
${segno}${escapeHtml(c.name)} (${c.tags.length})
${menuSquadra}
${c.archiviata ? '📦 '
: `✔ `}
✕
`;
}).join('');
}
const playerArea = document.getElementById('playerArea');
const playbackControls = document.getElementById('playbackControls');
const clip = activeVideoClipForTeam();
// Modificatore di ampiezza del player (come ITALBASKET WOMAN): riapplica
// subito la percentuale corrente alle tre colonne, e nasconde/mostra i
// pannelli laterali a seconda di "Espandi". La barra con lo slider vive
// SOTTO il video, dentro alla riga dei controlli di taglio (vedi
// #videoSizeBar dentro #playbackControls) — non più sopra, per recuperare
// quella riga di spazio a favore del player.
applyVideoWidth(vt.videoWidthPct);
const attackPanelEl = document.getElementById('attackPanel');
const defensePanelEl = document.getElementById('defensePanel');
// niente piu' display:none: espanso o no, le colonne restano al loro posto
if (attackPanelEl) attackPanelEl.style.display = '';
if (defensePanelEl) defensePanelEl.style.display = '';
const sizeBar = document.getElementById('videoSizeBar');
if (sizeBar) {
sizeBar.innerHTML = clip ? `
${`↔️${Math.round(vt.videoWidthPct)}% `}
${vt.expanded ? t('video.reduceBtn') : t('video.expandBtn')}
` : '';
}
// Preserva posizione/pausa/velocità di riproduzione: l'innerHTML qui sotto
// ricrea il da zero ad ogni render (anche solo per un tag appena
// creato), quindi senza questo il video sarebbe sempre "saltato" a 0:00 —
// stesso identico problema che ITALBASKET WOMAN risolve salvando lo stato
// sul vecchio elemento e ripristinandolo su quello nuovo appena pronto.
const oldVideo = document.getElementById('vPlayer');
const preserved = (oldVideo && clip) ? {
time: oldVideo.currentTime,
wasPaused: oldVideo.paused,
rate: oldVideo.playbackRate,
// Volume e muto non stanno più qui: ora sono ricordati a livello di
// sessione (vtVolume/vtMuted, vedi setVideoVolume) e riapplicati con
// applyVideoVolume() ad ogni nuovo . Così restano quelli scelti dal
// coach anche cambiando clip, non solo fra un ri-render e l'altro.
} : null;
// Non farla "saltare" alla forma di default (16:9 fallback) ad ogni
// ri-render (es. dopo un tag): se le proporzioni reali del video sono già
// note (clip.aspectRatio, impostato la prima volta in onVideoMeta),
// ricalcoliamo subito la misura giusta invece di aspettare che il nuovo
// rifaccia scattare onloadedmetadata.
const oldWrap = document.getElementById('videoPlayerWrap');
const preservedSize = (oldWrap && clip && !clip.aspectRatio) ? { width: oldWrap.style.width, height: oldWrap.style.height } : null;
if (clip && clip.archiviata) {
// Sessione chiusa: il filmato pesante e' stato buttato apposta. I pezzi
// ritagliati restano e si guardano dalle schede (giochi, giocatrici) o
// dall'elenco tag qui a fianco.
playerArea.innerHTML = `📦
${escapeHtml(t('sessione.videoLiberato'))}
`;
playbackControls.style.display = 'none';
const ptp0 = document.getElementById('pendingTagPanel');
if (ptp0) ptp0.style.display = 'none';
} else if (clip && !clip.url && clip.ricercaFinita) {
/* CERCATO E NON TROVATO — e va detto.
* Prima qui girava la clessidra "Recupero il video salvato..." e non si
* fermava mai: se il filmato era stato spostato, rinominato, o stava su
* una cartella di rete scollegata, il coach restava a guardare un
* orologio per sempre senza sapere perche' e senza un'uscita. Adesso si
* dice cosa manca, DOVE lo si era cercato, e si offrono gli stessi due
* bottoni che ci sono in Edit Video: ritrovare quel file, o indicare la
* cartella delle partite. Il lavoro fatto sopra (tag, ritagli) non si
* perde: e' attaccato alla clip, non al file. */
const dove = clip.percorsoDisco || '';
const testo = dove
? t('editvideo.fileSpostato', { nome: clip.name || '', dove: dove })
: t('editvideo.clipMancanteVecchia', { nome: clip.name || '' });
playerArea.innerHTML = `🔍
${escapeHtml(testo)}
${escapeHtml(t('editvideo.ritrovaFile'))}
${escapeHtml(t('editvideo.cartellaFilmati'))}
${escapeHtml(t('editvideo.cartellaSpiega'))}
`;
playbackControls.style.display = 'none';
const ptpX = document.getElementById('pendingTagPanel');
if (ptpX) ptpX.style.display = 'none';
} else if (clip && !clip.url) {
// Il file vero sta ancora arrivando (dal disco o da IndexedDB): questo
// placeholder evita di mostrare per un istante un
// rotto. Appena e' pronto il player si ridisegna da solo, sul punto
// lasciato. Se invece la ricerca finisce a vuoto si passa al ramo qui
// sopra, che spiega cosa e' successo.
playerArea.innerHTML = '⏳
' + t('video.restoringClip') + '
';
playbackControls.style.display = 'none';
const ptp = document.getElementById('pendingTagPanel');
if (ptp) ptp.style.display = 'none';
} else if (clip) {
const dur = clip.duration || 0;
const startPct = dur && vt.trimStart != null ? (vt.trimStart / dur) * 100 : null;
const endPct = dur && vt.trimEnd != null ? (vt.trimEnd / dur) * 100 : null;
const seedStyle = preservedSize
? ` style="width:${preservedSize.width};height:${preservedSize.height};"`
: '';
/* LO SPAZIO NERO A OGNI TAG.
* Ogni volta che si crea un tag l'app ridisegna questa sezione, e finora
* dentro ci finiva un NUOVO con lo stesso src: il lettore doveva
* ricaricare il filmato, e per quel mezzo secondo mostrava nero. Ecco il
* "piccolo spazio nero" che compariva sul video ad ogni tag — e con lui
* il salto di posizione.
* Adesso, se e' lo stesso filmato, il di prima si mette da parte
* e si rimette dentro alla sezione appena ricostruita: stesso elemento,
* stessa immagine, nessun ricaricamento. Le manine (i listener) restano
* quelle gia' attaccate, quindi non si duplicano. */
const videoVecchio = document.getElementById('vPlayer');
const riusoIlVideo = !!(videoVecchio && videoVecchio.dataset.clipId === clip.id
&& videoVecchio.readyState >= 1 && !videoVecchio.error);
let videoDaParte = null;
if (riusoIlVideo) { videoDaParte = videoVecchio; videoDaParte.remove(); }
playerArea.innerHTML = `
${clip.error ? `⚠️ ${escapeHtml(clip.error)}
` : ''}
${(startPct != null && endPct != null) ? `
` : ''}
${startPct != null ? `
` : ''}
${endPct != null ? `
` : ''}
`;
playbackControls.style.display = 'flex';
renderTrimReadout();
renderPendingTagPanel();
renderTagOverlay();
// Se le proporzioni reali sono già note da un caricamento precedente
// (es. si torna su questa clip dopo averne guardata un'altra), non
// serve aspettare che onloadedmetadata rifaccia scattare tutto da capo:
// dimensioniamo subito il contenitore in modo corretto.
if (clip.aspectRatio) applyVideoBoxSize(clip);
// Riaggancia lo stato preservato (o parte semplicemente da 0) e collega
// "timeupdate" per tenere playhead + etichetta tempo sincronizzati in
// tempo reale con la riproduzione, esattamente come vtCurrentTimeLabel/
// vtPlayhead in ITALBASKET WOMAN.
if (videoDaParte) {
const segnaposto = document.getElementById('vPlayer');
if (segnaposto) segnaposto.replaceWith(videoDaParte);
}
const newVideo = document.getElementById('vPlayer');
if (newVideo) newVideo.dataset.clipId = clip.id;
let lastPositionAutosaveTs = 0;
/* Durata da usare ADESSO per la barra. Prima si guardava solo
* clip.duration: se quel valore era rimasto a 0 (filmato il cui header
* non dichiara la durata, o un rilevamento andato a vuoto) la barra
* restava ferma a sinistra e il tempo segnava 0:00 / 0:00 anche mentre
* il video andava. Ora si crede prima di tutto al lettore, che la
* durata vera ce l'ha appena i metadati sono arrivati. */
const durataViva = () => {
const d = newVideo.duration;
if (isFinite(d) && d > 0) {
if (!clip.duration || Math.abs(clip.duration - d) > 0.05) clip.duration = d;
return d;
}
return clip.duration || 0;
};
const applyPlayhead = () => {
const durNow = durataViva();
const ph = document.getElementById('vPlayhead');
if (ph && durNow) ph.style.left = Math.min(100, (newVideo.currentTime / durNow) * 100) + '%';
const tl = document.getElementById('vCurrentTimeLabel');
if (tl) tl.textContent = fmtTime(newVideo.currentTime) + ' / ' + fmtTime(durNow);
// Riempimento fucsia "live": appena Inizio è marcato ma Fine non ancora,
// cresce in tempo reale dalla tacca Inizio fino al punto corrente di
// riproduzione, così si vede subito quale tratto sta per diventare la
// giocata taggata — appena si preme Fine questo si spegne e prende il
// posto il tratto fisso (.vt-trimbar-range) tra Inizio e Fine.
const live = document.getElementById('vTrimLive');
if (live && durNow) {
if (vt.trimStart != null && vt.trimEnd == null) {
const startPctNow = (vt.trimStart / durNow) * 100;
const curPctNow = Math.min(100, (newVideo.currentTime / durNow) * 100);
const left = Math.min(startPctNow, curPctNow);
const width = Math.max(0.6, Math.abs(curPctNow - startPctNow));
live.style.display = 'block';
live.style.left = left + '%';
live.style.width = width + '%';
} else {
live.style.display = 'none';
}
}
// Tiene il "punto lasciato" sempre aggiornato in memoria (economico,
// nessun I/O), e ogni ~5s durante la riproduzione dà una spinta anche
// al salvataggio locale — scheduleAutosave da sola si limiterebbe a
// click/digitazioni, che durante la sola visione del video non
// arrivano mai. beforeunload cattura comunque l'ultimo istante alla
// chiusura, questo serve per il caso di chiusura "brusca"/crash.
clip.lastPosition = newVideo.currentTime;
const nowTs = Date.now();
if (nowTs - lastPositionAutosaveTs > 5000) { lastPositionAutosaveTs = nowTs; scheduleAutosave(); }
};
// Sul video RIUSATO le manine ci sono gia' da prima: si aggiorna solo la
// barra. Su quello nuovo si attacca tutto, una volta sola.
if (riusoIlVideo) applyPlayhead();
if (!riusoIlVideo) {
newVideo.addEventListener('timeupdate', applyPlayhead);
/* "timeupdate" arriva solo 4 volte al secondo: la lineetta bianca
* avanzava a scatti. Finche' il video corre la si muove ad ogni
* fotogramma dello schermo, cosi' scorre liscia. Il ciclo si spegne da
* solo appena il video si ferma o il player viene sostituito da un
* ri-render. */
let animazioneBarra = 0;
const giroBarra = () => {
if (document.getElementById('vPlayer') !== newVideo || newVideo.paused || newVideo.ended) { animazioneBarra = 0; return; }
applyPlayhead();
animazioneBarra = requestAnimationFrame(giroBarra);
};
newVideo.addEventListener('play', () => { if (!animazioneBarra) animazioneBarra = requestAnimationFrame(giroBarra); });
newVideo.addEventListener('pause', () => { if (animazioneBarra) { cancelAnimationFrame(animazioneBarra); animazioneBarra = 0; } });
/* La durata di certi filmati si conosce solo dopo: quando arriva,
* si ridisegna la barra (tacche Inizio/Fine comprese). */
newVideo.addEventListener('durationchange', () => {
const d = newVideo.duration;
if (isFinite(d) && d > 0 && !clip.duration) { clip.duration = d; renderVideoSection(); return; }
applyPlayhead();
});
newVideo.addEventListener('loadedmetadata', applyPlayhead);
newVideo.addEventListener('seeked', applyPlayhead);
newVideo.addEventListener('play', syncPlayPauseBtn);
newVideo.addEventListener('pause', syncPlayPauseBtn);
syncPlayPauseBtn();
newVideo.addEventListener('pause', () => { clip.lastPosition = newVideo.currentTime; scheduleAutosave(); });
applyVideoVolume();
if (preserved) {
newVideo.playbackRate = preserved.rate;
const restore = () => {
newVideo.currentTime = preserved.time;
applyPlayhead();
if (!preserved.wasPaused) newVideo.play().catch(() => {});
};
if (newVideo.readyState >= 1) restore();
else newVideo.addEventListener('loadedmetadata', restore, { once: true });
} else if (clip.lastPosition) {
// Nessun precedente da cui ripartire (primo render di questa
// clip in questa sessione, es. appena dopo "Continua sessione
// precedente"): riparte dal punto salvato invece che da 0:00, senza
// avviare la riproduzione da sola.
const seedFromSaved = () => { newVideo.currentTime = clip.lastPosition; applyPlayhead(); };
if (newVideo.readyState >= 1) seedFromSaved();
else newVideo.addEventListener('loadedmetadata', seedFromSaved, { once: true });
} else {
applyPlayhead();
}
} // fine del blocco "solo se il e' nuovo"
} else {
// Lo spazio del player resta "occupato" con lo stesso ingombro anche
// prima di importare un video, invece di un piccolo avviso tratteggiato
// che sembra area vuota/inutilizzata.
playerArea.innerHTML = '🎬
' + t('video.selectOrImport') + '
';
playbackControls.style.display = 'none';
const ptp = document.getElementById('pendingTagPanel');
if (ptp) ptp.style.display = 'none';
}
// Le caselle (giocate, tag rapidi, nomi) appartengono a una squadra: senza
// nessuna squadra aperta si svuotano invece di far saltare tutto il
// ridisegno — il player e le linguette dei filmati restano usabili.
if (team) {
renderSidePanels(team, clip);
renderQuickTagRow(team, clip);
renderVideoOppFocusBar(team);
renderPlayerChips(team, clip);
} else {
['attaccoPanel', 'difesaPanel', 'quickTagRow', 'videoOppFocusBar'].forEach(id => {
const n = document.getElementById(id);
if (n) n.innerHTML = '';
});
const chips = document.getElementById('playerChipRow');
if (chips) chips.innerHTML = `${escapeHtml(t('video.noTeamOpen'))}
`;
}
renderTagList(clip);
}
/* Pannelli laterali Attacco (sx) / Difesa (dx): liste di GIOCATE modificabili
* (aggiungi/rimuovi), non un bottone generico unico — coerente con lo
* schema del motore di riferimento (colonne piene di categorie taggabili). */
function renderSidePanels(team, clip) {
const disabled = !clip;
const cats = ensureTagCategories(team);
function listHTML(side) {
const items = cats[side];
if (!items.length) return '' + t('video.noPlaysYet') + '
';
return items.map(name => {
// Quanti tagli, sulla clip aperta, sono già stati taggati con questa
// giocata (stesso side+label) — non i tag ancora "in attesa" di nome.
const tagCount = clip ? clip.tags.filter(tg => tg.side === side && tg.label === name).length : 0;
return `
${escapeHtml(name)}
${tagCount}
✎
✕
`;
}).join('');
}
document.getElementById('attackPanel').innerHTML = `
${t('playbook.groupAtt')} ${cats.attacco.length}
${listHTML('attacco')}
${t('team.addBtn')}
`;
document.getElementById('defensePanel').innerHTML = `
${t('playbook.groupDif')} ${cats.difesa.length}
${listHTML('difesa')}
${t('team.addBtn')}
`;
}
/* Quanti pezzi ho gia' segnato con questo tag rapido sulla clip aperta.
* Come per le giocate: senza il numero sotto gli occhi si perde il conto di
* quante palle perse (o rimbalzi, o recuperi) si sono gia' segnate. I tag
* rapidi nascono senza lato (side: null), quindi si conta per etichetta. */
function contaTagRapido(clip, etichetta) {
if (!clip || !Array.isArray(clip.tags)) return 0;
const e = (etichetta || '').trim().toUpperCase();
return clip.tags.filter(tg => ((tg.label || '').trim().toUpperCase() === e)).length;
}
/* Tag rapidi (PP / RIM OFF / REC...): un click, taglio automatico ultimi 3s. */
function renderQuickTagRow(team, clip) {
const disabled = !clip;
const quick = ensureQuickTags(team);
const container = document.getElementById('quickTagRow');
container.innerHTML = `
${t('video.quickTagsLabel')}
${quick.map(name => `
${escapeHtml(name)}
${contaTagRapido(clip, name)}
✕
`).join('')}
${t('team.addBtn')}
${escapeHtml(t('video.puntiForzaBtn'))}
${contaTagRapido(clip, ETICHETTA_PUNTI_FORZA)}
${escapeHtml(t('video.puntiDeboliBtn'))}
${contaTagRapido(clip, ETICHETTA_PUNTI_DEBOLI)}
`;
}
/* 12 colori ben differenziati, uno per slot fisso (P1..P12): sempre lo
* stesso colore per la stessa posizione, indipendentemente da chi la
* occupa, così le 12 caselle sulla riga sono riconoscibili a colpo d'occhio
* anche prima di leggere il nome/numero scritto dentro. */
const PLAYER_SLOT_COLORS = ['#ef4444','#f59e0b','#eab308','#22c55e','#10b981','#06b6d4','#3b82f6','#6366f1','#8b5cf6','#d946ef','#ec4899','#14b8a6'];
/* "Preparazione avversario" in Video: quando il coach sta preparando la
* partita contro un avversario specifico (uno di team.opponents, lo stesso
* elenco dello Scouting Report), la striscia caselle giocatrici può mettere
* in primo piano LE GIOCATRICI DI QUELL'AVVERSARIO invece delle proprie —
* la pulsantiera per taggare quella partita è già pronta, senza dover
* ricercare foto/numeri/nomi ogni volta che si riaffronta la stessa
* squadra: bastano un paio di clic su un video già confezionato per quella
* partita. Restano comunque modificabili in qualsiasi momento (Scouting
* Report → 🆚 Giocatrici) se serve cambiare qualche giocatrice.
* team.videoFocusOpponentId punta a un id di team.opponents, o è vuoto/null
* per la modalità normale (giocatrici della propria squadra). */
function videoFocusOpponent(team) {
if (!team || !team.videoFocusOpponentId) return null;
return ensureOpponents(team).find(o => o.id === team.videoFocusOpponentId) || null;
}
function setVideoFocusOpponent(value) {
const team = activeTeam();
if (!team) return;
team.videoFocusOpponentId = value || null;
renderVideoOppFocusBar(team);
renderPlayerChips(team, currentClip());
scheduleAutosave();
}
function renderVideoOppFocusBar(team) {
const el = document.getElementById('videoOppFocusBar');
if (!el) return;
if (!team) { el.innerHTML = ''; el.classList.remove('voc-active'); return; }
const opponents = ensureOpponents(team);
const focusId = team.videoFocusOpponentId || '';
const options = `${escapeHtml(t('video.oppFocusMyTeam', { name: team.name }))} `
+ opponents.map(o => `${escapeHtml(o.name)} `).join('');
el.innerHTML = `
${t('video.oppFocusLabel')}
${options}
`;
el.classList.toggle('voc-active', !!focusId);
}
/* Striscia compatta giocatrici/giocatori: tag specifico sull'atleta,
* indipendente da Attacco/Difesa (si posiziona "su quella giocatrice").
* Sempre 12 caselle pronte (stessi 12 slot fissi del dossier Personnel), su
* UNA SOLA riga (si restringono per starci tutte, vedi CSS .player-chip-row):
* le giocatrici già in roster occupano la loro casella con un bottone di
* tag rapido, quelle mancanti restano una casella vuota "P#" con un campo
* per scrivere subito il nome — non serve aprire il Roster qui sopra per
* iniziare a taggare, il nome scritto qui viene aggiunto al roster stesso.
* In "preparazione avversario" (vedi sopra) mostra invece le giocatrici di
* quell'avversario: elenco libero (non 12 slot fissi), sola lettura qui —
* si aggiungono/modificano da Scouting Report → 🆚 Giocatrici. */
/* Di quale squadra sono i nomi da taggare. In ordine:
* 1) quella scelta a mano nella barra "Giocatrici di:";
* 2) quella del VIDEO che si sta guardando (è quasi sempre quella giusta:
* si tagga la partita che si ha davanti);
* 3) la squadra aperta nel menu in alto.
* Prima esisteva solo la terza, e bastava avere aperta un'altra squadra per
* ritrovarsi le caselle vuote senza capire perché. */
function squadraDeiNomi(team, clip) {
const lg = activeLeague();
const tutte = lg ? allLeagueTeams(lg, false).map(e => e.team) : [];
if (vt.chipTeamId) {
const scelta = tutte.find(t => t.id === vt.chipTeamId);
if (scelta) return scelta;
}
if (clip && clip.teamId) {
const delVideo = tutte.find(t => t.id === clip.teamId);
if (delVideo && piene(delVideo)) return delVideo;
if (delVideo && !omonimaPiena(delVideo, tutte)) return delVideo;
if (delVideo) return omonimaPiena(delVideo, tutte);
}
// Rete di sicurezza contro il doppione: la squadra aperta è vuota ma in
// lega ce n'è un'altra CON LO STESSO NOME che ha il roster dentro (capita
// creando "Belgio" come squadra e riempiendo poi la casella "Belgio" delle
// avversarie). Si usano quelle, invece di mostrare dodici caselle mute.
if (team && !piene(team)) {
const gemella = omonimaPiena(team, tutte);
if (gemella) return gemella;
}
return team;
}
function piene(t) { return !!(t && (t.players || []).some(p => (p && p.name || '').trim())); }
function omonimaPiena(t, tutte) {
if (!t) return null;
const n = (t.name || '').trim().toLowerCase();
if (!n) return null;
return (tutte || []).find(x => x.id !== t.id && (x.name || '').trim().toLowerCase() === n && piene(x)) || null;
}
function impostaSquadraDeiNomi(id) {
vt.chipTeamId = id || null;
renderVideoSection();
scheduleAutosave();
}
// L'avviso del doppione si accende SOLO quando è stata l'app a ripiegare da
// sola su un'omonima: se la squadra l'hai scelta tu dal menù, non c'è niente
// da segnalare.
function teamOriginaleDiverso(team, scelta) {
if (vt.chipTeamId) return false;
if (!team || !scelta || team.id === scelta.id) return false;
const n = (x) => ((x && x.name) || '').trim().toLowerCase();
return n(team) === n(scelta);
}
function renderChipTeamBar(team, clip) {
const bar = document.getElementById('chipTeamBar');
if (!bar) return;
const lg = activeLeague();
const tutte = lg ? allLeagueTeams(lg, false).map(e => e.team) : [];
const scelta = squadraDeiNomi(team, clip);
if (!tutte.length) { bar.innerHTML = ''; return; }
const quante = scelta ? (scelta.players || []).filter(p => (p && p.name || '').trim()).length : 0;
const diversa = scelta && teamOriginaleDiverso(team, scelta);
bar.innerHTML = `${escapeHtml(t('video.chipTeamLabel'))}
${tutte.map(x => `${escapeHtml(x.name || '—')} `).join('')}
${quante ? `${quante} ` : `${escapeHtml(t('video.chipTeamEmpty'))} `}
${diversa ? `${escapeHtml(t('video.chipTeamAltra'))} ` : ''}
${(vt.chipTeamId && team && scelta && scelta.id !== team.id)
? `${escapeHtml(t('video.chipTeamTorna', { squadra: team.name || '' }))} `
: ''}`;
}
/* Quanti pezzi porta gia' il nome di questa giocatrice: sul filmato aperto
* (il numero che si vede) e su tutti i filmati messi insieme (nel
* suggerimento). Stessa idea del contatore che c'e' gia' sulle caselle
* dei giochi e delle difese. */
function contaPezziGiocatrice(clip, squadra, p) {
const id = p && p.id;
const nome = ((p && p.name) || '').trim().toLowerCase();
const num = (p && p.number != null) ? String(p.number).trim() : '';
const suo = (tag, delClip) => {
if (id && tag.playerId === id) return true;
const lab = (tag.playerLabel || '').trim().toLowerCase();
if (!lab) return false;
const stessaSquadra = !squadra || delClip.teamId === squadra.id || tag.playerTeamId === squadra.id;
if (!stessaSquadra) return false;
if (nome && lab.indexOf(nome) !== -1) return true;
if (num && lab.indexOf('#' + num + ' ') === 0) return true;
return false;
};
const quiDentro = clip ? (clip.tags || []).filter(tg => suo(tg, clip)).length : 0;
let ovunque = 0;
(vt.clips || []).forEach(c => (c.tags || []).forEach(tg => { if (suo(tg, c)) ovunque++; }));
return { quiDentro, ovunque };
}
function pezziBadgeHtml(clip, squadra, p) {
const c = contaPezziGiocatrice(clip || currentClip(), squadra, p);
return `${c.quiDentro} `;
}
function renderPlayerChips(teamOriginale, clip) {
const container = document.getElementById('playerChipRow');
const disabled = !clip;
renderChipTeamBar(teamOriginale, clip);
const team = squadraDeiNomi(teamOriginale, clip) || teamOriginale;
const focusOpp = videoFocusOpponent(team);
/* Da quale squadra vengono i nomi che sto per disegnare: lo si segna qui,
* mentre lo si sa per certo. Ricalcolarlo dopo, al momento del tag,
* poteva dare una risposta diversa (dipende dal filmato aperto) e il
* pezzo finiva intestato alla squadra sbagliata. */
ultimaSquadraDelleCaselle = (focusOpp || team) ? (focusOpp || team).id : null;
if (focusOpp) {
const players = ensureOpponentPlayers(focusOpp);
if (!players.length) {
container.innerHTML = `${t('video.oppFocusNoPlayers', { name: focusOpp.name })}
`;
return;
}
container.innerHTML = players.map((p, i) => {
const colorStyle = `--slot-color:${PLAYER_SLOT_COLORS[i % PLAYER_SLOT_COLORS.length]};`;
const label = (p.name || '').trim() || t('relazione.oppPlayerNamePh');
return `
#${escapeHtml(p.number || '—')} ${escapeHtml(label)} ${pezziBadgeHtml(clip, focusOpp, p)}
`;
}).join('');
return;
}
// Squadra della lega (una delle caselle): il roster lì è fatto di 14 righe
// fisse, molte delle quali restano vuote. La pulsantiera mostra solo quelle
// davvero compilate, in ordine di roster — è la "pulsantiera con le
// giocatrici della Repubblica Ceca" che ci si aspetta di ritrovare dopo
// aver scritto il roster in quella casella. Sulle squadre proprie resta
// invece la griglia fissa a 12 caselle con gli spazi da riempire.
if (!isOwnTeam(team)) {
const roster = (team.players || []).filter(p => (p.name || '').trim() || (p.number || '').toString().trim());
if (!roster.length) {
container.innerHTML = `${t('video.leagueTeamNoPlayers', { name: team.name })}
`;
return;
}
container.innerHTML = roster.map((p, i) => {
const colorStyle = `--slot-color:${PLAYER_SLOT_COLORS[i % PLAYER_SLOT_COLORS.length]};`;
const label = (p.name || '').trim() || ('#' + (p.number || (i + 1)));
return `
#${escapeHtml((p.number || '—').toString())} ${escapeHtml(label)} ${pezziBadgeHtml(clip, team, p)}
`;
}).join('');
return;
}
assignMissingSlots(team);
const bySlot = {};
team.players.forEach(p => { bySlot[parseInt(p.slot, 10)] = p; });
const boxes = [];
for (let slot = 1; slot <= 12; slot++) {
const p = bySlot[slot];
const colorStyle = `--slot-color:${PLAYER_SLOT_COLORS[slot - 1]};`;
if (p) {
boxes.push(`
#${p.number} ${escapeHtml(p.name)} ${pezziBadgeHtml(clip, team, p)}
`);
} else {
boxes.push(`
P${slot}
`);
}
}
container.innerHTML = boxes.join('');
}
/* Aggiunge al volo una giocatrice/giocatore da una casella vuota della
* striscia Video (vedi renderPlayerChips sopra): stesso identico record che
* crea addPlayer() dal Roster in Anagrafica squadra, così resta un'unica
* lista giocatrici condivisa fra Video/Roster/Personnel — solo un altro
* punto d'ingresso più comodo mentre si tagga. */
function addPlayerAtSlot(slot, name) {
const team = activeTeam();
name = (name || '').trim();
if (!team || !name) return;
team.players.push({ id: nextId('pl'), number: slot, name, role: '1', slot });
renderAll();
}
// ------------------------------------------------------------ STRATEGIA
// Note persistenti per squadra (salvate nel JSON esportabile), divise in
// Attacco/Difesa, con giocatrice/giocatore opzionale collegato.
function ensureStrategyNotes(team) {
if (!Array.isArray(team.strategyNotes)) team.strategyNotes = [];
return team.strategyNotes;
}
function addStrategyNote(side) {
const team = activeTeam();
if (!team) return;
const players = team.players.slice().sort((a, b) => a.number - b.number);
const playerOptions = players.map(p => `#${p.number} ${escapeHtml(p.name)} `).join('');
document.getElementById('modalTitle').textContent = t('prompt.newNoteTitle') + (side === 'attacco' ? t('common.attack') : t('common.defense'));
const msgEl = document.getElementById('modalMessage');
msgEl.style.display = 'none';
const input = document.getElementById('modalInput');
input.style.display = 'block';
input.value = '';
input.placeholder = t('prompt.newNotePh');
document.getElementById('modalOverlay').classList.add('show');
modalResolve = (text) => {
ensureStrategyNotes(team).push({ id: nextId('note'), side, text, playerId: null });
renderStrategyPanel(team);
};
setTimeout(() => input.focus(), 30);
}
function deleteStrategyNote(noteId) {
const team = activeTeam();
if (!team) return;
team.strategyNotes = ensureStrategyNotes(team).filter(n => n.id !== noteId);
renderStrategyPanel(team);
}
function renderStrategyPanel(team) {
const container = document.getElementById('strategyPanel');
// Il pannello Strategia non fa più parte della pagina Video (rimosso su
// richiesta, per lasciare più spazio al video): questa funzione resta
// solo per compatibilità con eventuali chiamate residue, ma senza il suo
// contenitore nel DOM non ha più nulla da disegnare.
if (!container) return;
if (!team) { container.innerHTML = ''; return; }
const notes = ensureStrategyNotes(team);
function colHTML(side, icon, label) {
const sideNotes = notes.filter(n => n.side === side);
const rows = sideNotes.length
? sideNotes.map(n => `
${escapeHtml(n.text)}
✕
`).join('')
: '' + t('empty.noNotesYet') + '
';
return `
${icon} ${label}
${rows}
${t('dyn.addNoteBtn')}
`;
}
container.innerHTML = colHTML('attacco', '⚡', t('strategy.attackTitle')) + colHTML('difesa', '🛡️', t('strategy.defenseTitle'));
}
function renderTagList(clip) {
// il pannello Montaggio vive accanto all'elenco: si aggiorna insieme
setTimeout(() => { try { renderMontagePanel(); } catch (e) {} }, 0);
const body = document.getElementById('tagListBody');
if (!clip || !clip.tags.length) {
body.innerHTML = '' + t('empty.noTagsYet') + '
';
return;
}
const sortSelect = document.getElementById('tagSortSelect');
const sortBy = sortSelect ? sortSelect.value : 'time';
const sorted = clip.tags.slice().sort((a, b) => {
if (sortBy === 'label') {
const la = (a.label || a.playerLabel || '').toLowerCase();
const lb = (b.label || b.playerLabel || '').toLowerCase();
return la.localeCompare(lb);
}
return a.start - b.start;
});
body.innerHTML = sorted.map(tag => {
const unclassified = !tag.side && !tag.label && !tag.playerId;
const pillClass = tag.side || (unclassified ? 'unclassified' : 'giocatore');
const pillLabel = tag.side === 'attacco' ? t('common.attack') : tag.side === 'difesa' ? t('common.defense') :
unclassified ? t('tag.toClassify') : (tag.label ? t('tag.tagLabel') : t('tag.playerLabel'));
const mainText = tag.label
? escapeHtml(tag.label) + (tag.playerLabel ? ' · ' + escapeHtml(tag.playerLabel) : '')
: (tag.playerLabel ? escapeHtml(tag.playerLabel) : t('tag.toClassify'));
const pendingOutline = vt.pendingTagId === tag.id ? ' style="outline:2px solid var(--orange);border-radius:6px;"' : '';
// Stato del taglio automatico (vedi enqueueCut/processCutQueue): clip
// già pronta come file a parte -> scaricabile subito, senza aspettare
// "Esporta montaggio".
const cutBadge = tag.cutStatus === 'cutting'
? `✂️… `
: tag.cutStatus === 'ready'
? `⬇ `
: tag.cutStatus === 'error'
? `⚠️ `
: `✂️ `;
// Il pezzo è già un FILE sul disco, dentro alla cartella di riferimento:
// il coach vuole saperlo a colpo d'occhio, senza andare a controllare
// nel Finder. Passandoci sopra si legge il percorso esatto.
const salvatoBadge = tag.cutFile
? `💾 `
: '';
return `
${pillLabel}
${salvatoBadge}
${cutBadge}
${mainText}
${fmtTime(tag.start)} – ${fmtTime(tag.end)}
▶
🖍️
✏️
✕
`;
}).join('');
}
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
/* UN VALORE PASSATO A UN PULSANTE SCRITTO DENTRO ALL'HTML.
*
* I pulsanti costruiti al volo hanno il codice dentro all'attributo:
*
* e li' JSON.stringify da solo NON basta, perche' mette le virgolette e le
* virgolette CHIUDONO l'attributo. Il pulsante esce spezzato a meta' e il
* browser risponde "Unexpected end of input": e' quello che e' successo a
* tutti i pulsanti + della libreria dei montaggi.
* Con gli apostrofi succedeva la stessa cosa nell'altro verso: un nome come
* DELL'ACQUA rompeva il pulsante della giocatrice.
* Qui le virgolette e gli apostrofi diventano codici HTML: il browser li
* rimette a posto da solo prima di leggere il codice, e il nome arriva
* intero comunque sia scritto. */
function jsAttr(valore) {
return JSON.stringify(valore === undefined ? null : valore)
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(//g, '>');
}
/* =========================================================================
* DIAGRAMMA CAMPO (canvas) — motore condiviso tra Playbook e Personnel.
* Ispirato alla parte "schemi tattici" di ITALBASKET WOMAN, ma riscritto da
* zero in versione generica/editabile (multi-squadra, non solo Nazionale) e
* SENZA le fasi animate/export GIF-video di quella versione (prima passata
* più semplice, per prompt esplicito del coach — "no animazione").
* Ogni elemento disegnato è salvato in coordinate FRAZIONARIE (0..1) rispetto
* alla tela, così la stessa formazione/schema si ridisegna correttamente sia
* a mezzo campo che a campo intero, a qualunque dimensione del canvas.
* ========================================================================= */
// Niente più uno strumento "Palla" a parte: piazzava un'icona di pallone col
// disegno delle cuciture (due linee dritte + due archi), che a uno sguardo
// veloce può leggersi come una croce dentro un cerchio — non quello che
// serve. Chi ha la palla si indica SOLO cerchiando il numero della
// giocatrice già piazzata (seleziona + "⭕ Ha la palla" in toolbar, vedi
// toggleSelectedPlayerBall/drawDiagramElement): un numero con un anello
// intorno, niente altro. Il rendering del vecchio tipo 'ball' resta in
// drawDiagramElement solo per non rompere diagrammi salvati in precedenza.
// Invece di un unico bottone "Attacco" e uno "Difesa" che piazzavano la
// giocatrice successiva in ordine automatico, ci sono i NUMERI veri: 1-5 per
// l'attacco e X1-X5 per la difesa. Il coach clicca il numero che gli serve e
// lo mette dove vuole, senza dover contare in che ordine le sta piazzando —
// e ricliccando lo stesso numero lo SPOSTA invece di crearne un doppione
// (vedi handleDiagramDown), quindi il limite di 5 per squadra viene da sé.
const DIAGRAM_PLAYER_TOOLS = [1, 2, 3, 4, 5].map(n => ({ id: 'off' + n, label: String(n), team: 'off' }))
.concat([1, 2, 3, 4, 5].map(n => ({ id: 'def' + n, label: 'X' + n, team: 'def' })));
/* NEGLI ALLENAMENTI si va oltre il quintetto: in palestra ci sono dodici
* ragazze e un esercizio si disegna con otto, dieci giocatori — file, onde,
* tre contro tre con i cambi. Solo qui quindi ci sono anche i numeri 6-10 e
* X6-X10. Nel Playbook restano cinque contro cinque, che e' il gioco. */
const DIAGRAM_PLAYER_TOOLS_ALLENAMENTO = DIAGRAM_PLAYER_TOOLS
.concat([6, 7, 8, 9, 10].map(n => ({ id: 'off' + n, label: String(n), team: 'off' })))
.concat([6, 7, 8, 9, 10].map(n => ({ id: 'def' + n, label: 'X' + n, team: 'def' })));
function attrezziGiocatori(state) {
return (state && state.ref === 'praState') ? DIAGRAM_PLAYER_TOOLS_ALLENAMENTO : DIAGRAM_PLAYER_TOOLS;
}
const DIAGRAM_TOOLS = [
{ id: 'select', labelKey: 'tool.select' },
{ id: 'move', labelKey: 'tool.move' },
{ id: 'pass', labelKey: 'tool.pass' },
{ id: 'dribble', labelKey: 'tool.dribble' },
{ id: 'screen', labelKey: 'tool.screen' },
{ id: 'cone', labelKey: 'tool.cone' },
// Passaggio consegnato (hand-off): il simbolo classico — tre lineette
// orizzontali attraversate da una verticale al centro, grande come un
// numero — si piazza nel punto dove avviene la consegna.
{ id: 'handoff', labelKey: 'tool.handoff' },
// Tiro: linea continua che finisce con due lineette orizzontali vicino al
// canestro — il simbolo classico del tiro, distinto dal passaggio
// (tratteggiato) e dal movimento (freccia).
{ id: 'shot', labelKey: 'tool.shot' },
{ id: 'text', labelKey: 'tool.text' },
// Palla: click diretto su una giocatrice d'attacco per assegnarle/
// toglierle la palla, come il tool "palla" di ITALBASKET WOMAN (deOnDown)
// — NIENTE selezione di mezzo. Prima "Ha la palla" era un bottone-azione
// che agiva sull'elemento selezionato: per usarlo bisognava prima
// selezionare la giocatrice (Seleziona + click), il che disegnava
// l'anello ciano tratteggiato della selezione attorno al numero PRIMA
// ancora di aver assegnato la palla — il coach vedeva un cerchio azzurro
// e lo scambiava per l'indicatore "ha la palla" ("è blu nel diagramma").
// Ora è un vero strumento: si clicca la giocatrice direttamente, la
// palla si assegna/toglie sul colpo, senza passare mai da uno stato
// "selezionato" e quindi senza che l'anello ciano compaia mai in questo
// flusso — resta sempre e solo il cerchio nero.
{ id: 'ballTool', labelKey: 'tool.ball' },
// Gomma: click diretto su un elemento per cancellarlo subito, senza
// passare da "Seleziona" + "Rimuovi selezionato" — stesso tool "gomma" di
// ITALBASKET WOMAN, un modo più immediato di correggere un errore mentre
// si disegna (quello resta comunque disponibile per chi preferisce
// selezionare prima).
{ id: 'erase', labelKey: 'tool.erase' },
];
function createDiagramState(ref, canvasId, toolbarId) {
return {
ref, canvasId, toolbarId,
mode: 'half', tool: 'select', elements: [], selectedIndex: -1,
// pendingPath: freccia/percorso in corso di disegno (move/pass/dribble/
// screen) — { kind, points:[{x,y},...] }, stessa struttura di
// deState.pendingPath in ITALBASKET WOMAN: si clicca un punto alla
// volta (il tratto curvo per "Movimento" passa per tutti), poi
// "✔ Fine tratto" per confermarlo. Sostituisce il vecchio pendingStart
// (un solo punto, arrivo automatico al secondo click).
pendingPath: null, dragging: false, dragOffset: null, _inited: false,
// Il Blocco può essere disegnato SIA spezzato (segmenti dritti, la
// giocatrice cambia direzione di netto) SIA curvo (traiettoria morbida
// che passa per ogni tappa) — si sceglie con il bottone "Blocco:
// spezzato/curvo" nella toolbar prima di chiudere il tratto, e la scelta
// resta salvata sul singolo elemento (el.curved) così due blocchi dello
// stesso schema possono essere uno spezzato e uno curvo.
screenCurved: false,
};
}
let pbState = createDiagramState('pbState', 'pbCanvas', 'pbToolbar');
function initDiagramCanvas(state) {
if (state._inited) { renderDiagramToolbar(state); redrawDiagram(state); return; }
state._inited = true;
resizeDiagramCanvas(state);
const canvas = document.getElementById(state.canvasId);
canvas.addEventListener('mousedown', e => handleDiagramDown(state, e));
canvas.addEventListener('mousemove', e => handleDiagramMove(state, e));
window.addEventListener('mouseup', () => handleDiagramUp(state));
canvas.addEventListener('touchstart', e => { e.preventDefault(); const t = e.touches[0]; handleDiagramDown(state, { clientX: t.clientX, clientY: t.clientY }); }, { passive: false });
canvas.addEventListener('touchmove', e => { e.preventDefault(); const t = e.touches[0]; handleDiagramMove(state, { clientX: t.clientX, clientY: t.clientY }); }, { passive: false });
canvas.addEventListener('touchend', () => handleDiagramUp(state));
renderDiagramToolbar(state);
redrawDiagram(state);
}
// Stesse dimensioni canvas di ITALBASKET WOMAN (deCanvasSize), per riusare
// esattamente le proporzioni del suo drawCourt() invece di reinventarle.
function diagramCanvasSize(mode) {
// Mezzo campo un po' più ALTO di prima (360 -> 420): sul foglio stampato la
// larghezza della casella è fissa (4 diagrammi in fila), quindi l'unico modo
// di avere campi più grandi è usare l'altezza che avanzava; in più 504x420
// è più vicino alle proporzioni vere di un mezzo campo FIBA (15m x 14m) di
// quanto non fosse 504x360.
return mode === 'full' ? { w: 576, h: 324 } : { w: 504, h: 420 };
}
/* CALAMITE (magneti) attorno a ogni giocatrice ----------------------------
* Otto punti d'aggancio disposti tutt'intorno al numero (N, NE, E, SE, S, SW,
* W, NW), su un anello appena fuori dalla cifra. Quando è attivo uno
* strumento-linea (movimento, passaggio, palleggio, blocco, tiro) il click
* NON cade dove capita: si aggancia alla calamita più vicina. Serve a due
* cose insieme:
* - le linee partono e arrivano sempre pulite sul bordo del numero, senza
* coprirlo e senza restare staccate a caso;
* - il fotogramma successivo riconosce SEMPRE chi passa, chi palleggia e chi
* si muove, perché il punto agganciato è a distanza nota dalla giocatrice
* invece che "da qualche parte lì vicino".
* La calamita usata su chi ha la palla dice anche da che lato parte l'azione
* (verso canestro = palleggio/tiro, verso un compagno = passaggio). */
const DIAGRAM_MAGNET_TOOLS = ['move', 'pass', 'dribble', 'screen', 'shot'];
const DIAGRAM_MAGNET_HOSTS = ['off', 'def', 'cone'];
function diagramMagnetPoints(state) {
const size = diagramCanvasSize(state.mode);
const W = size.w, H = size.h, S = Math.min(W, H);
// Anello ADERENTE alla figura: appena attorno al numero (0.098 -> 0.068 di
// S, dentro l'anello della palla). Con l'anello largo di prima, due
// giocatrici vicine avevano le calamite sovrapposte e il tratto si
// agganciava a quella sbagliata.
const R = S * 0.042;
const out = [];
(state.elements || []).forEach(el => {
if (!DIAGRAM_MAGNET_HOSTS.includes(el.type)) return;
for (let k = 0; k < 8; k++) {
const a = k * Math.PI / 4;
out.push({ x: el.x + (R * Math.cos(a)) / W, y: el.y + (R * Math.sin(a)) / H, el });
}
});
return out;
}
// Aggancia un punto cliccato alla calamita più vicina (entro 30px reali di
// canvas). Ritorna il punto originale se non c'è nessuna calamita a tiro.
function snapDiagramToMagnet(state, fx, fy) {
if (!DIAGRAM_MAGNET_TOOLS.includes(state.tool)) return { fx, fy };
const size = diagramCanvasSize(state.mode);
const W = size.w, H = size.h, S = Math.min(W, H);
// Calamite MENO POTENTI: agganciano solo se si clicca davvero addosso alla
// giocatrice. Prima tiravano da 30px e, con due giocatrici vicine, un
// tratto pensato per l'una finiva agganciato all'altra.
// Si aggancia SOLO cliccando addosso alla giocatrice (dentro al cerchietto
// del suo numero), e solo a QUELLA giocatrice: fra le sue otto calamite si
// prende quella dalla parte in cui si è cliccato. Prima tirava da 30px in
// qualunque direzione e, con due giocatrici vicine, un tratto pensato per
// l'una finiva agganciato all'altra.
const RAGGIO_FIGURA = S * 0.065;
let vicina = null, dVicina = RAGGIO_FIGURA;
(state.elements || []).forEach(el => {
if (!DIAGRAM_MAGNET_HOSTS.includes(el.type)) return;
const d = Math.hypot((el.x - fx) * W, (el.y - fy) * H);
if (d < dVicina) { dVicina = d; vicina = el; }
});
if (!vicina) return { fx, fy };
let best = null, bestD = Infinity;
diagramMagnetPoints(state).forEach(m => {
if (m.el !== vicina) return;
const d = Math.hypot((m.x - fx) * W, (m.y - fy) * H);
if (d < bestD) { bestD = d; best = m; }
});
// Oltre al punto, si restituisce ANCHE a chi ci si e' agganciati: e' il
// dato che poi permette al campo successivo di sapere con certezza chi ha
// passato e chi ha ricevuto, senza doverlo indovinare dalle distanze.
const rif = { tipo: vicina.type, label: vicina.label || '' };
return best ? { fx: best.x, fy: best.y, rif } : { fx, fy, rif };
}
// Puntini d'aggancio disegnati SOLO mentre è attivo uno strumento-linea, così
// il campo resta pulito quando si piazzano i giocatori.
function drawDiagramMagnets(ctx, state, W, H) {
if (!DIAGRAM_MAGNET_TOOLS.includes(state.tool)) return;
const S = Math.min(W, H);
ctx.save();
diagramMagnetPoints(state).forEach(m => {
ctx.beginPath();
ctx.arc(m.x * W, m.y * H, Math.max(1, S * 0.0032), 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,150,200,.5)';
ctx.fill();
});
ctx.restore();
}
/* IL CAMPO DISEGNATO ALLA MISURA VERA (03/10/2026).
*
* Luciano Nunzi, dal suo PC: «quando il campo è grande le linee vanno bene,
* quando si rimpicciolisce sono troppo spesse».
*
* Non erano piu' spesse: erano SFOCATE. Il campo veniva disegnato sempre a
* 504x420 punti e poi era il browser a rimpicciolirlo con un filtro grezzo,
* e una linea sfocata l'occhio la legge come piu' grassa. Lo stesso succede
* al contrario sugli schermi a punti doppi, dove il campo veniva ingrandito.
*
* Qui il canvas prende la risoluzione VERA a cui si vede (per la larghezza
* che ha sullo schermo, moltiplicata per i punti del monitor), e il disegno
* viene ingrandito della stessa misura con una sola riga in redrawDiagram.
* Tutto il resto del codice continua a ragionare in 504x420: le calamite, i
* bersagli del click, le proporzioni della stampa non cambiano di un punto.
* Cambia solo la nitidezza — ed e' quella che Nunzi chiamava spessore. */
function diagramFattoreNitidezza(state) {
const canvas = document.getElementById(state.canvasId);
const size = diagramCanvasSize(state.mode);
if (!canvas) return 1;
let larghezzaVista = 0;
try { larghezzaVista = canvas.getBoundingClientRect().width; } catch (e) {}
if (!larghezzaVista) larghezzaVista = size.w;
const punti = (window.devicePixelRatio || 1);
/* SI SCENDE ANCHE SOTTO 1, ed e' il caso di Nunzi: quando il riquadro e'
piu' stretto di 504 punti il campo veniva comunque disegnato a 504 e poi
RIMPICCIOLITO dal browser — ed e' li' che le linee diventano sfocate e
sembrano grasse. Disegnandolo gia' alla misura giusta il browser non
deve rimpicciolire niente e il tratto resta pulito.
Il limite in basso e' 0,5 (sotto si perderebbe davvero dettaglio) e in
alto 3 (oltre e' memoria buttata). */
return Math.max(0.5, Math.min(3, (larghezzaVista * punti) / size.w));
}
function resizeDiagramCanvas(state) {
const canvas = document.getElementById(state.canvasId);
if (!canvas) return;
const size = diagramCanvasSize(state.mode);
canvas.width = size.w; canvas.height = size.h;
}
function setDiagramCourtMode(state, mode) {
state.mode = mode;
resizeDiagramCanvas(state);
redrawDiagram(state);
}
// Cambiare strumento con un tratto a metà (pendingPath) lo CONFERMA subito
// se ha già almeno 2 punti, invece di perderlo — stesso comportamento di
// deSetTool in ITALBASKET WOMAN (chiama deFinishPath prima di cambiare
// tool). Con un solo punto (niente ancora da confermare) lo scarta e basta.
function setDiagramTool(state, tool) {
if (state.pendingPath) finishDiagramPath(state);
state.tool = tool;
renderDiagramToolbar(state);
}
/* IL NOME DEL TASTO, SCRITTO SUL BOTTONE (03/10/2026).
* Luciano Nunzi, il giorno dopo aver comprato la licenza: «se col tasto
* Canc si potesse cancellare direttamente la cosa che evidenzi». Si poteva
* gia': stava scritto solo nell'aiuto in fondo al riquadro, che non legge
* nessuno. Adesso il tasto e' scritto sul bottone che fa quella cosa.
* Simboli e non parole: non vanno tradotti e non allungano la barra. */
/* ETICHETTE CORTE SUI BOTTONI (03/10/2026, idea di Vince: «PALL. PASS.
* MOV. BLOC., cosi' la grandezza e' uguale e si riduce»).
* Si tiene il simbolo davanti e si accorcia la parola, con il nome intero
* nel suggerimento che esce passandoci sopra. Niente traduzioni nuove: si
* taglia quella che c'e' gia', e nelle lingue dove le parole sono gia'
* corte (cinese, giapponese, coreano) non cambia niente. */
function etichettaCorta(testo) {
const m = String(testo || '').match(/^(\S+)\s+(.+)$/);
if (!m) return testo;
const simbolo = m[1];
if (/\./.test(m[2])) return testo; // gia' abbreviata a mano: si lascia
const parola = m[2].split(/[\s\/(,:]/)[0];
if (!parola) return testo;
return simbolo + ' ' + (parola.length > 7 ? parola.slice(0, 5) + '.' : parola);
}
function tastoCanc() {
try { return navigator.platform.indexOf('Mac') >= 0 ? '\u232B' : 'Canc'; } catch (e) { return 'Canc'; }
}
function tastoAnnulla() {
try { return navigator.platform.indexOf('Mac') >= 0 ? '\u2318Z' : 'Ctrl+Z'; } catch (e) { return 'Ctrl+Z'; }
}
function renderDiagramToolbar(state) {
const el = document.getElementById(state.toolbarId);
if (!el) return;
// Prima riga: i dieci numeri (1-5 attacco, X1-X5 difesa), raggruppati e
// colorati per squadra così si distinguono a colpo d'occhio. Un numero già
// in campo si vede subito perché il suo bottone resta "piazzato".
const placedLabels = new Set(state.elements.filter(e => e.type === 'off' || e.type === 'def').map(e => e.type + ':' + e.label));
const playerBtns = attrezziGiocatori(state).map(p => {
const placed = placedLabels.has(p.team + ':' + p.label);
return `${p.label} `;
}).join('');
/* DUE PEZZI, NON UNO (03/10/2026, idea di Vince): «i numeri puoi metterli
sotto al diagramma, che uno li usa solo all'inizio, e recuperi due righe
per mettere tutti gli strumenti nello schermo». Per poterli spostare
devono essere due scatole separate; sotto gli 860 punti .diagram-tool-row
e' display:contents e non cambia niente rispetto a prima. */
el.innerHTML = `${playerBtns}
`
+ ``;
}
/* IL CAMPO GIRATO DI 180 GRADI (03/10/2026, chiesto da Vince).
* Serve quando si disegna il gioco "dall'altra parte": si gira la lavagna
* invece di ridisegnare tutto al contrario. Gira il campo E quello che c'e'
* sopra, tutto insieme, e i click seguono: quello che vedi sotto il dito e'
* quello che tocchi.
* E' un modo di GUARDARE, non si salva nel gioco: riaprendo il foglio il
* campo torna com'e' sempre stato, e la stampa non cambia. */
function setDiagramRuotato(state, giu) {
state.ruotato = !!giu;
renderDiagramToolbar(state);
redrawDiagram(state);
}
function giraDiagramma(state) {
state.ruotato = !state.ruotato;
renderDiagramToolbar(state);
redrawDiagram(state);
}
function removeSelectedDiagramElement(state) {
if (state.selectedIndex < 0) { toast(t('toast.noElementSelected')); return; }
segnaRimosso(state, state.elements[state.selectedIndex]);
state.elements.splice(state.selectedIndex, 1);
state.selectedIndex = -1;
redrawDiagram(state);
}
// Annulla rapido: se c'è un tratto a metà (pendingPath) toglie solo
// l'ULTIMO PUNTO cliccato (utile per correggere un click impreciso senza
// dover ricominciare tutto il percorso), oppure annulla l'intero tratto se
// era rimasto un solo punto; altrimenti toglie l'ultimo elemento già
// piazzato — senza doverlo prima selezionare a mano. Stesso principio di
// deUndo() in ITALBASKET WOMAN.
function undoLastDiagramElement(state) {
if (state.pendingPath) {
if (state.pendingPath.points.length > 1) state.pendingPath.points.pop();
else state.pendingPath = null;
renderDiagramToolbar(state);
redrawDiagram(state);
return;
}
if (!state.elements.length) { toast(t('toast.noElementSelected')); return; }
state.elements.pop();
state.selectedIndex = -1;
redrawDiagram(state);
}
// Conferma il tratto in corso (pendingPath) come elemento vero: con solo 2
// punti resta la stessa struttura x/y/x2/y2 di sempre (nessuna modifica per
// i diagrammi già salvati); con più di 2 punti aggiunge anche "pts", letto
// da drawDiagramArrow per disegnare la curva che passa per tutte le tappe
// invece del solo arco fra primo e ultimo punto.
function finishDiagramPath(state) {
const path = state.pendingPath;
state.pendingPath = null;
if (!path || path.points.length < 2) { renderDiagramToolbar(state); redrawDiagram(state); return; }
const first = path.points[0], last = path.points[path.points.length - 1];
const el = { type: path.kind, x: first.x, y: first.y, x2: last.x, y2: last.y };
if (path.points.length > 2) el.pts = path.points.map(p => ({ x: p.x, y: p.y }));
// DA CHI PARTE / A CHI ARRIVA, scritto nero su bianco al momento del
// disegno: se il click e' finito addosso a una giocatrice (calamita), il
// suo numero viene conservato qui. Il campo successivo legge questo, non
// le distanze — ed e' il motivo per cui adesso il passaggio non viene piu'
// attribuito alla giocatrice sbagliata.
if (first.rif) el.daRif = first.rif;
if (last.rif) el.aRif = last.rif;
// La scelta spezzato/curvo viene fissata SULL'ELEMENTO al momento della
// chiusura, non letta dallo stato al momento del disegno: così cambiare
// l'interruttore per il blocco successivo non ridisegna quelli già fatti.
if (path.kind === 'screen' && state.screenCurved) el.curved = true;
state.elements.push(el);
renderDiagramToolbar(state);
redrawDiagram(state);
}
function toggleDiagramScreenCurved(state) {
state.screenCurved = !state.screenCurved;
renderDiagramToolbar(state);
redrawDiagram(state);
toast(t(state.screenCurved ? 'toast.screenCurved' : 'toast.screenBent'));
}
// Cerchia/scerchia il numero della giocatrice cliccata per indicare che ha
// la palla — stessa convenzione di ITALBASKET WOMAN (deDrawElement: circled =
// team==='off' && hasBall). Chiamata direttamente da un click sul campo col
// tool "🏀 Palla" attivo (vedi handleDiagramDown), NON su un elemento
// selezionato: passare prima da "Seleziona" disegnava l'anello ciano
// tratteggiato della selezione PRIMA ancora di assegnare la palla, e quel
// cerchio azzurro veniva scambiato per l'indicatore stesso ("è blu nel
// diagramma"). Un click diretto col tool dedicato non seleziona mai
// l'elemento, quindi quell'anello ciano non compare mai in questo flusso —
// resta sempre e solo il cerchio nero.
function togglePlayerBallAt(state, idx) {
const el = state.elements[idx];
if (el.type !== 'off') { toast(t('toast.ballOnlyOnOffense')); return; }
el.hasBall = !el.hasBall;
redrawDiagram(state);
}
function clearDiagram(state) {
if (!state.elements.length) return;
showConfirmModal(t('confirm.clearCourt'), () => {
state.elements = [];
state.selectedIndex = -1;
state.pendingPath = null;
redrawDiagram(state);
});
}
function diagramPointFromEvent(state, e) {
const canvas = document.getElementById(state.canvasId);
const rect = canvas.getBoundingClientRect();
const px = (e.clientX - rect.left) * (canvas.width / rect.width);
const py = (e.clientY - rect.top) * (canvas.height / rect.height);
let fx = px / canvas.width, fy = py / canvas.height;
/* col campo girato, quello che vedi sotto il dito e' il punto opposto */
if (state && state.ruotato) { fx = 1 - fx; fy = 1 - fy; }
return { fx: fx, fy: fy };
}
// Distanza in PIXEL reali del canvas, non nelle coordinate frazionarie (0-1)
// usate per salvare gli elementi: il campo intero e il mezzo campo hanno
// proporzioni larghezza/altezza diverse (576x324 vs 504x360), quindi una
// soglia unica in frazionario disegna un bersaglio ellittico (più
// permissivo in un asse che nell'altro) invece che un cerchio — cliccare
// vicino a un elemento senza centrarlo esattamente "non funzionava" più
// spesso su un asse che sull'altro, uno dei motivi per cui disegnare
// sembrava difficile. In pixel il bersaglio è un vero cerchio, uguale su
// entrambi gli assi e su entrambe le modalità campo — stessa tolleranza
// (32px) di ITALBASKET WOMAN (deHitTest).
// preferisciPunti: con la gomma (e con la palla) la precedenza va a
// giocatrici/coni/scritte anche se sopra ci passa una freccia. Senza questa
// regola, una freccia che parte proprio dal numero veniva colpita per prima
// (la lista si scorre dall'alto in basso, e le frecce si disegnano dopo) e il
// click "cancellava la freccia" invece della giocatrice: è il motivo per cui
// la 5 sembrava non si potesse cancellare per ridisegnarla senza palla.
function hitTestDiagramElements(state, fx, fy, preferisciPunti) {
if (preferisciPunti) {
const soloPunti = hitTestDiagramPoints(state, fx, fy);
if (soloPunti >= 0) return soloPunti;
}
const size = diagramCanvasSize(state.mode);
const W = size.w, H = size.h;
const px = fx * W, py = fy * H;
for (let i = state.elements.length - 1; i >= 0; i--) {
const el = state.elements[i];
if (Array.isArray(el.pts) && el.pts.length > 2) {
// Movimento con più di 2 tappe: la curva si allontana dalla corda
// diretta primo-ultimo punto, quindi il test va fatto su OGNI
// segmento fra punti consecutivi, non solo su quella corda —
// altrimenti cliccare/trascinare esattamente sulla curva disegnata
// (che è quello che si vede) a volte non selezionava nulla.
let best = Infinity;
for (let s = 0; s < el.pts.length - 1; s++) {
const d = distanceToSegment(px, py, el.pts[s].x * W, el.pts[s].y * H, el.pts[s + 1].x * W, el.pts[s + 1].y * H);
if (d < best) best = d;
}
if (best < 16) return i;
} else if (el.x2 != null) {
const d = distanceToSegment(px, py, el.x * W, el.y * H, el.x2 * W, el.y2 * H);
if (d < 16) return i;
} else {
const d = Math.hypot(px - el.x * W, py - el.y * H);
if (d < 32) return i;
}
}
return -1;
}
// Solo gli elementi "puntuali" (giocatrici, coni, scritte, consegna): stesso
// bersaglio circolare da 32px di hitTestDiagramElements.
/* Memoria di quello che il coach ha CANCELLATO in questa casella: serve alla
* propagazione per non rimetterlo dentro al prossimo ricalcolo. */
function segnaRimosso(state, el) {
if (!state || !el) return;
if (!Array.isArray(state.rimossi)) state.rimossi = [];
const k = el.type + '|' + (el.label || '');
if (state.rimossi.indexOf(k) < 0) state.rimossi.push(k);
}
function hitTestDiagramPoints(state, fx, fy) {
const size = diagramCanvasSize(state.mode);
const W = size.w, H = size.h;
const px = fx * W, py = fy * H;
let best = -1, bestD = Infinity;
for (let i = state.elements.length - 1; i >= 0; i--) {
const el = state.elements[i];
if (el.x2 != null || Array.isArray(el.pts)) continue;
const d = Math.hypot(px - el.x * W, py - el.y * H);
if (d < 32 && d < bestD) { bestD = d; best = i; }
}
return best;
}
function distanceToSegment(px, py, x1, y1, x2, y2) {
const dx = x2 - x1, dy = y2 - y1;
const lenSq = dx * dx + dy * dy;
let t = lenSq ? ((px - x1) * dx + (py - y1) * dy) / lenSq : 0;
t = Math.max(0, Math.min(1, t));
return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
}
function handleDiagramDown(state, e) {
let { fx, fy } = diagramPointFromEvent(state, e);
// Trascinare un elemento già piazzato funziona anche senza passare da
// "Seleziona": con gli strumenti che piazzano un singolo elemento (numero
// attacco/difesa, palla, cono, testo) un click su un elemento esistente lo
// sposta invece di piazzarne uno nuovo sopra — così puoi aggiustare una
// giocatrice senza andare avanti e indietro sulla toolbar. Esclusi gli
// strumenti freccia (movimento/passaggio/palleggio/blocco): lì un click
// DEVE poter cadere su un giocatore per agganciarci l'inizio o la fine
// della freccia (è il caso normale, "freccia da questo giocatore a
// quello") — se anche lì si trascinasse, il gesto a due click si
// romperebbe ogni volta che l'inizio/fine coincide con una giocatrice.
const DRAG_SHORTCUT_TOOLS = ['off', 'def', 'ball', 'cone', 'text', 'handoff'];
if (DRAG_SHORTCUT_TOOLS.includes(state.tool)) {
const dragIdx = hitTestDiagramElements(state, fx, fy, true);
if (dragIdx >= 0) {
state.selectedIndex = dragIdx;
state.dragging = true;
state.dragOffset = { dx: fx - state.elements[dragIdx].x, dy: fy - state.elements[dragIdx].y };
redrawDiagram(state);
return;
}
}
if (state.tool === 'select') {
const idx = hitTestDiagramElements(state, fx, fy);
state.selectedIndex = idx;
if (idx >= 0) {
state.dragging = true;
state.dragOffset = { dx: fx - state.elements[idx].x, dy: fy - state.elements[idx].y };
}
redrawDiagram(state);
return;
}
if (state.tool === 'erase') {
const idx = hitTestDiagramElements(state, fx, fy, true);
// Si tiene nota di COSA è stato cancellato in questa casella: quello che
// il coach toglie non deve tornare dalla propagazione, mentre tutto il
// resto (consegna compresa) deve invece seguire il campo precedente.
if (idx >= 0) segnaRimosso(state, state.elements[idx]);
if (idx >= 0) {
state.elements.splice(idx, 1);
state.selectedIndex = -1;
redrawDiagram(state);
}
return;
}
if (state.tool === 'ballTool') {
// Click diretto su una giocatrice: assegna/toglie la palla sul colpo,
// senza passare da "Seleziona" (vedi togglePlayerBallAt) — niente anello
// ciano di selezione in questo flusso.
const idx = hitTestDiagramElements(state, fx, fy, true);
if (idx >= 0) togglePlayerBallAt(state, idx);
return;
}
// Numeri espliciti: il coach sceglie PRIMA quale giocatrice piazzare
// (1-5 attacco, X1-X5 difesa) invece di lasciare che l'app numeri da sé in
// ordine di piazzamento. Se quel numero è già in campo non se ne crea un
// doppione: lo si SPOSTA nel punto cliccato — così il massimo di 5 per
// squadra è garantito dalla struttura stessa, senza bisogno di un blocco.
const numTool = /^(off|def)([1-9]|10)$/.exec(state.tool);
if (numTool) {
const type = numTool[1];
const label = type === 'off' ? numTool[2] : 'X' + numTool[2];
const existing = state.elements.findIndex(e => e.type === type && e.label === label);
if (existing >= 0) {
state.elements[existing].x = fx;
state.elements[existing].y = fy;
} else {
state.elements.push({ type, x: fx, y: fy, label });
}
renderDiagramToolbar(state);
redrawDiagram(state);
return;
}
if (state.tool === 'ball') {
state.elements.push({ type: 'ball', x: fx, y: fy });
redrawDiagram(state);
return;
}
if (state.tool === 'text') {
showPromptModal(t('prompt.diagramTextTitle'), t('prompt.diagramTextPh'), (txt) => {
state.elements.push({ type: 'text', x: fx, y: fy, text: txt });
redrawDiagram(state);
});
return;
}
if (state.tool === 'handoff') {
state.elements.push({ type: 'handoff', x: fx, y: fy });
redrawDiagram(state);
return;
}
if (state.tool === 'cone') {
// Punto/cono con lettera auto-incrementale (A, B, C…), come i segnaposto
// numerati di attacco/difesa ma per marcare posizioni/coni fissi
// sull'esercizio (es. "vai da A a B") invece di giocatori — un click,
// niente prompt da compilare ogni volta come per il Testo libero.
const count = state.elements.filter(e => e.type === 'cone').length;
const label = String.fromCharCode(65 + (count % 26)) + (count >= 26 ? String(Math.floor(count / 26) + 1) : '');
state.elements.push({ type: 'cone', x: fx, y: fy, label });
redrawDiagram(state);
return;
}
if (DIAGRAM_MAGNET_TOOLS.includes(state.tool)) {
// Aggancio alla calamita più vicina: il punto cliccato si "incolla" a uno
// degli otto punti attorno alla giocatrice.
const ag = snapDiagramToMagnet(state, fx, fy);
fx = ag.fx; fy = ag.fy;
const rifAggancio = ag.rif || null;
// MECCANISMO UNICO per TUTTE le linee (Movimento, Passaggio, Palleggio,
// Blocco), sempre lo stesso: primo click = punto di partenza, ogni click
// successivo aggiunge una tappa, INVIO (o "✔ Fine tratto") chiude e
// disegna la freccia. Prima il Passaggio faceva eccezione — si chiudeva
// da solo al secondo click — e questo rendeva il comportamento
// imprevedibile: dopo un passaggio la freccia compariva da sola, dopo un
// palleggio no, e non si capiva più quando bisognava premere Invio e
// quando no ("non è troppo chiaro il meccanismo della freccia dopo un
// passaggio, un palleggio ecc."). Ora la regola è una sola per tutti:
// clicca i punti che vuoi, poi Invio.
if (!state.pendingPath || state.pendingPath.kind !== state.tool) {
state.pendingPath = { kind: state.tool, points: [{ x: fx, y: fy, rif: rifAggancio }] };
renderDiagramToolbar(state);
redrawDiagram(state);
toast(t('toast.clickEndPoint'));
} else {
state.pendingPath.points.push({ x: fx, y: fy, rif: rifAggancio });
renderDiagramToolbar(state);
redrawDiagram(state);
}
}
}
// Mentre si trascina un elemento già piazzato SI RIDISEGNA a ogni micro-
// movimento (comportamento invariato, ora sposta anche "pts" se presente).
// In più, ora, mentre si sta piazzando un tratto (move/pass/dribble/screen:
// almeno un punto già cliccato, "pendingPath" impostato) il mouse che si
// muove disegna un'ANTEPRIMA dal vivo di dove finirebbe il prossimo tratto
// — prima non c'era nessun feedback fra un click e l'altro, si disegnava
// "alla cieca": uno dei motivi principali per cui disegnare risultava
// difficile. Stesso principio del live-preview di ITALBASKET WOMAN
// (deOnMove/pendingPath).
function handleDiagramMove(state, e) {
const { fx, fy } = diagramPointFromEvent(state, e);
if (state.dragging && state.selectedIndex >= 0) {
const el = state.elements[state.selectedIndex];
const nx = fx - state.dragOffset.dx, ny = fy - state.dragOffset.dy;
const deltaX = nx - el.x, deltaY = ny - el.y;
el.x = nx; el.y = ny;
if (el.x2 != null) { el.x2 += deltaX; el.y2 += deltaY; }
if (Array.isArray(el.pts)) el.pts.forEach(p => { p.x += deltaX; p.y += deltaY; });
redrawDiagram(state);
return;
}
if (state.pendingPath) {
const ag = snapDiagramToMagnet(state, fx, fy);
redrawDiagram(state, { x: ag.fx, y: ag.fy });
}
}
function handleDiagramUp(state) {
state.dragging = false;
state.dragOffset = null;
}
function redrawDiagram(state, previewEnd) {
const canvas = document.getElementById(state.canvasId);
if (!canvas) return;
const ctx = canvas.getContext('2d');
/* la misura logica resta 504x420: tutto il disegno la usa, e la riga qui
sotto la porta alla risoluzione vera del canvas. Vedi resizeDiagramCanvas. */
const size = diagramCanvasSize(state.mode);
const W = size.w, H = size.h;
/* La misura sullo schermo cambia: quando il riquadro si apre (prima e'
nascosto e misura zero), quando si ridimensiona la finestra, quando si
cambia monitor. Qui il canvas si riadatta da solo se serve davvero —
niente ricorsione, si tocca solo la risoluzione. */
ctx.setTransform(1, 0, 0, 1, 0, 0);
campoGirato = !!state.ruotato;
if (state.ruotato) { ctx.translate(W, H); ctx.rotate(Math.PI); }
drawCourt(ctx, W, H, state.mode);
drawDiagramMagnets(ctx, state, W, H);
state.elements.forEach((el, i) => drawDiagramElement(ctx, W, H, el, i === state.selectedIndex));
if (state.pendingPath) {
const pts = state.pendingPath.points;
ctx.save();
ctx.fillStyle = '#00e5ff';
pts.forEach(p => {
ctx.beginPath();
ctx.arc(p.x * W, p.y * H, 4, 0, Math.PI * 2);
ctx.fill();
});
ctx.restore();
// La linea di anteprima va ridisegnata SEMPRE che ci siano almeno 2 punti
// già cliccati, non solo mentre il mouse si sta muovendo (previewEnd):
// prima, appena il mouse si fermava (es. tra un click e l'altro, o prima
// di premere Invio per confermare), redrawDiagram veniva richiamato senza
// previewEnd e la linea spariva del tutto, lasciando solo i puntini ciano
// slegati — la "imperfezione"/"la linea scompare" segnalata. Ora, con
// mouse fermo, resta visibile il tratto già confermato fra i punti
// cliccati finora; quando il mouse si muove, si allunga fino al cursore
// come prima (stesso drawDiagramArrow usato per l'elemento vero, così
// l'anteprima corrisponde ESATTAMENTE a quello che verrebbe disegnato
// confermando in quel momento).
if (previewEnd || pts.length >= 2) {
ctx.save();
ctx.globalAlpha = 0.55;
const previewPts = previewEnd ? pts.concat([previewEnd]) : pts.slice();
const lastPt = previewPts[previewPts.length - 1];
const previewEl = { type: state.pendingPath.kind, x: previewPts[0].x, y: previewPts[0].y, x2: lastPt.x, y2: lastPt.y };
if (previewPts.length > 2) previewEl.pts = previewPts;
if (state.pendingPath.kind === 'screen' && state.screenCurved) previewEl.curved = true;
drawDiagramArrow(ctx, canvas.width, canvas.height, previewEl);
ctx.restore();
}
}
// Il Playbook è "sempre editabile": ogni disegno sul campo va salvato subito
// nello schema aperto (nessun bottone "Salva" separato) e la sua miniatura
// nella griglia va aggiornata a vista, così l'anteprima resta sempre in
// sincrono con quello che il coach sta disegnando in questo momento.
if (state === pbState && pbEditingRowId) syncExpandedPlaybookSlot();
}
/* Disegno di giocatrici/frecce ripreso 1:1 dalle convenzioni dell'editor
* schemi di ITALBASKET WOMAN (deDrawElement/deDrawPath): X/O testuali per
* attacco/difesa, cerchio bianco attorno al numero SOLO per chi ha la palla
* (el.hasBall — stessa regola di deDrawElement: "circled = team==='off' &&
* hasBall"), pallone con le cuciture, passaggio tratteggiato, palleggio a
* zigzag, blocco con barra a T, taglio con freccia a "V" (non piena) — così
* i diagrammi hanno lo stesso linguaggio visivo già validato in ITALBASKET,
* invece di una convenzione inventata da zero.
* Il tipo 'roster' (segnaposto verde col numero) resta invariato: è
* specifico della scheda Personnel, non fa parte del linguaggio schemi. */
function drawDiagramElement(ctx, W, H, el, selected) {
const S = Math.min(W, H);
if (el.type === 'off' || el.type === 'def') {
const isDef = el.type === 'def';
const color = isDef ? '#d32f2f' : '#000';
if (!isDef && el.hasBall) {
// Numero disegnato alla stessa dimensione/stile di sempre (nessun
// cerchio pieno bianco sotto che lo rimpiccioliva e lo "copriva"): qui
// solo un anello (stroke, senza fill) tracciato ATTORNO al numero già
// scritto, così il numero resta identico e il cerchio è solo un
// contorno intorno a lui, come richiesto.
ctx.fillStyle = color;
ctx.font = 'bold ' + Math.round(S * 0.088) + 'px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
scriviDritto(ctx, el.label || '', el.x * W, el.y * H);
// Anello ancora piu' stretto attorno al numero (0.074 -> 0.058 di S):
// sta appena fuori dalla cifra invece di girarle larghissimo intorno,
// cosi' con cinque giocatrici vicine il campo resta leggibile e non si
// sovrappone ai numeri accanto. Sotto 0.058 il cerchio taglierebbe i
// numeri a due cifre (il "10" e' largo ~0.05 di S).
const r = S * 0.058;
ctx.beginPath(); ctx.arc(el.x * W, el.y * H, r, 0, Math.PI * 2);
ctx.strokeStyle = color; ctx.lineWidth = Math.max(1.6, S * 0.008); ctx.stroke();
} else {
ctx.fillStyle = color;
ctx.font = (isDef ? '600 ' : 'bold ') + Math.round(S * 0.088) + 'px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
scriviDritto(ctx, el.label || '', el.x * W, el.y * H);
}
} else if (el.type === 'roster') {
const r = S * 0.045;
ctx.beginPath(); ctx.arc(el.x * W, el.y * H, r, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(34,197,94,.85)'; ctx.fill();
ctx.strokeStyle = '#16803c'; ctx.lineWidth = 2; ctx.stroke();
ctx.fillStyle = '#04140d'; ctx.font = '700 ' + Math.round(S * 0.042) + 'px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
scriviDritto(ctx, el.number != null ? String(el.number) : '', el.x * W, el.y * H);
} else if (el.type === 'handoff') {
// Consegna: tre lineette orizzontali + una verticale al centro, della
// stessa altezza del numero di una giocatrice.
// Misura contenuta: l'ingombro totale resta sull'altezza del numero di una
// giocatrice, così non ruba la scena al resto del disegno.
const w = S * 0.018, h = S * 0.021;
const x = el.x * W, y = el.y * H;
ctx.save();
ctx.strokeStyle = '#000';
ctx.lineWidth = Math.max(1.2, S * 0.0045);
ctx.lineCap = 'round';
ctx.beginPath();
[-h, 0, h].forEach(dy => { ctx.moveTo(x - w, y + dy); ctx.lineTo(x + w, y + dy); });
ctx.moveTo(x, y - h * 1.35); ctx.lineTo(x, y + h * 1.35);
ctx.stroke();
ctx.restore();
} else if (el.type === 'ball') {
drawBallIcon(ctx, el.x * W, el.y * H, S * 0.030);
} else if (el.type === 'text') {
ctx.fillStyle = '#000'; ctx.font = 'bold ' + Math.round(Math.max(11, S * 0.042)) + 'px -apple-system,sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
scriviDritto(ctx, el.text || '', el.x * W, el.y * H);
} else if (el.type === 'cone') {
// Triangolo (cono) invece del cerchio dei giocatori, così si distingue
// subito a colpo d'occhio anche negli esercizi più affollati.
const r = S * 0.058;
ctx.save();
ctx.strokeStyle = '#c2740a'; ctx.lineWidth = Math.max(2, S * 0.0095);
ctx.beginPath();
ctx.moveTo(el.x * W, el.y * H - r);
ctx.lineTo(el.x * W - r * 0.9, el.y * H + r * 0.75);
ctx.lineTo(el.x * W + r * 0.9, el.y * H + r * 0.75);
ctx.closePath();
ctx.stroke();
ctx.fillStyle = '#c2740a';
ctx.font = 'bold ' + Math.round(S * 0.058) + 'px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
scriviDritto(ctx, el.label || '', el.x * W, el.y * H + r * 1.7);
ctx.restore();
} else if (el.type === 'move' || el.type === 'pass' || el.type === 'dribble' || el.type === 'screen' || el.type === 'shot') {
drawDiagramArrow(ctx, W, H, el);
}
// Niente anello ciano di selezione su chi ha già la palla: per SPOSTARE
// una giocatrice bisogna prima selezionarla (anche solo trascinandola), e
// quell'anello tratteggiato ciano si disegnava proprio sopra/dentro al
// cerchio nero "ha la palla" — il cerchio smetteva di essere sempre nero
// ogni volta che lo spostavi ("voglio il cerchio dell'uomo con la palla
// sempre nero"). Il trascinamento funziona comunque senza questo
// indicatore in più: il cerchio nero stesso resta ben visibile.
if (selected && !(el.type === 'off' && el.hasBall)) {
ctx.save();
ctx.strokeStyle = '#00e5ff'; ctx.lineWidth = 2; ctx.setLineDash([4, 3]);
if (el.x2 != null) { ctx.beginPath(); ctx.moveTo(el.x * W, el.y * H); ctx.lineTo(el.x2 * W, el.y2 * H); ctx.stroke(); }
else { ctx.beginPath(); ctx.arc(el.x * W, el.y * H, S * 0.06, 0, Math.PI * 2); ctx.stroke(); }
ctx.restore();
}
}
// Pallone con cuciture, identico (a parte il raggio parametrico) al 'palla'
// di ITALBASKET WOMAN.
function drawBallIcon(ctx, cx, cy, r) {
const k = r / 9;
ctx.save();
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fillStyle = '#fff'; ctx.fill();
ctx.strokeStyle = '#000'; ctx.lineWidth = 1.2; ctx.stroke();
ctx.beginPath();
ctx.moveTo(cx, cy - r); ctx.lineTo(cx, cy + r);
ctx.moveTo(cx - r, cy); ctx.lineTo(cx + r, cy);
ctx.stroke();
ctx.beginPath(); ctx.arc(cx - 4.5 * k, cy, 6.5 * k, -Math.PI * 0.38, Math.PI * 0.38); ctx.stroke();
ctx.beginPath(); ctx.arc(cx + 4.5 * k, cy, 6.5 * k, Math.PI * 0.62, Math.PI * 1.38); ctx.stroke();
ctx.restore();
}
// Freccia a "V" (non piena), come deDrawPath in ITALBASKET.
// Punta piena/chiusa (triangolo riempito) invece della "V" aperta di prima
// (due segmenti a stroke, senza riempimento) — freccia più leggibile a
// colpo d'occhio, come richiesto.
/* I NUMERI RESTANO DRITTI ANCHE A CAMPO GIRATO (03/10/2026).
* Girando la tela gira tutto, scritte comprese: Vince ha visto l'1 e il 2
* a testa in giu'. Le cifre vanno girate di nuovo attorno al loro centro,
* cosi' tornano leggibili mentre il campo resta girato. */
let campoGirato = false;
function scriviDritto(ctx, testo, x, y) {
if (!campoGirato) { ctx.fillText(testo, x, y); return; }
ctx.save();
ctx.translate(x, y);
ctx.rotate(Math.PI);
ctx.fillText(testo, 0, 0);
ctx.restore();
}
function drawDiagramArrowhead(ctx, x, y, ang, size) {
size = size || 12;
const backLeft = { x: x - size * Math.cos(ang - 0.42), y: y - size * Math.sin(ang - 0.42) };
const backRight = { x: x - size * Math.cos(ang + 0.42), y: y - size * Math.sin(ang + 0.42) };
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(backLeft.x, backLeft.y);
ctx.lineTo(backRight.x, backRight.y);
ctx.closePath();
ctx.fillStyle = '#000';
ctx.fill();
}
function drawDiagramArrow(ctx, W, H, el) {
const x1 = el.x * W, y1 = el.y * H, x2 = el.x2 * W, y2 = el.y2 * H;
// Tutte le misure del tratto (spessore, punta della freccia, ampiezza del
// palleggio, barra del blocco) sono ora in proporzione al campo invece che
// in pixel fissi, e un po' più generose di prima: così i simboli restano
// leggibili sia nell'editor sia sul foglio stampato, e crescono insieme al
// campo quando il campo diventa più grande.
const S = Math.min(W, H);
const LW = Math.max(1.8, S * 0.0072);
const AH = Math.max(9, S * 0.041); // 03/10: +5%, chiesto da Nunzi
ctx.save();
ctx.strokeStyle = '#000'; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
if (el.type === 'dribble' && Array.isArray(el.pts) && el.pts.length > 2) {
// Palleggio con più di 2 tappe cliccate: come il Movimento, la
// sinusoide segue una curva morbida che passa per OGNI tappa invece del
// solo segmento dritto fra primo e ultimo punto — serve tanto quanto il
// Movimento per aggirare una difensore o cambiare direzione a metà
// palleggio ("devo riuscire a disegnare un palleggio curvo"). Si
// campiona la STESSA curva di diagramSmoothCurvePoints (stessi segmenti
// quadratici) con diagramCurveSamplePoints, poi si applica lo zigzag
// seguendo la tangente locale punto per punto invece della singola
// direzione fissa usata nel caso dritto a 2 punti sotto.
const pts = el.pts.map(p => ({ x: p.x * W, y: p.y * H }));
const curvePts = diagramCurveSamplePoints(pts, 24, S);
let total = 0;
const cum = [0];
for (let i = 1; i < curvePts.length; i++) {
total += Math.hypot(curvePts[i].x - curvePts[i - 1].x, curvePts[i].y - curvePts[i - 1].y);
cum.push(total);
}
// Sinusoide FITTA: più onde per la stessa lunghezza, come su una lavagna
// vera dove il palleggio si scarabocchia stretto. Il passo è sceso da
// ~0.078 a ~0.050 del lato del campo e il tetto da 7 a 11 onde. Onde più
// corte vogliono anche onde meno alte, altrimenti la riga diventa una
// matassa: l'ampiezza resta legata alla mezza-onda (fattore 0.42, un po'
// più generoso di prima proprio perché la mezza-onda ora è più corta).
const waves = Math.max(3, Math.min(11, Math.round(total / (S * 0.050))));
const halfWaveLen = total / (waves * 2);
const amp = Math.max(S * 0.007, Math.min(S * 0.022, halfWaveLen * 0.42));
ctx.lineWidth = LW;
ctx.beginPath();
// La direzione della punta va presa dall'ULTIMO SEGMENTO EFFETTIVAMENTE
// DISEGNATO (fra gli ultimi due punti già spostati dalla sinusoide), non
// dalla tangente della curva "centrale": vicino alla fine, lo scarto
// laterale della sinusoide (che torna a 0 ma con pendenza diversa da
// zero) fa deviare la linea vera dalla tangente della curva di base —
// prendere quest'ultima faceva puntare la freccia in una direzione
// leggermente sbagliata rispetto a dove la riga arriva davvero.
//
// Dove la curva cambia direzione bruscamente (raggio di curvatura
// piccolo, es. un palleggio che torna quasi indietro su se stesso), uno
// scarto laterale fisso può superare il raggio di curvatura locale e la
// linea "spostata" si autointerseca formando un nodino — l'imperfezione
// segnalata ("solito problema tra linea curva e dritta"). Si smorza
// l'ampiezza punto per punto in base al raggio di curvatura locale
// (stimato dalla variazione dell'angolo tangente fra segmenti vicini)
// così lo scarto resta sempre ben dentro la curva, senza mai incrociarsi.
let prevPx = null, prevPy = null, lastPx = 0, lastPy = 0;
for (let i = 0; i < curvePts.length; i++) {
const p = curvePts[i];
const prev = curvePts[Math.max(0, i - 1)], next = curvePts[Math.min(curvePts.length - 1, i + 1)];
const tdx = next.x - prev.x, tdy = next.y - prev.y, tlen = Math.hypot(tdx, tdy) || 1;
const nx = -tdy / tlen, ny = tdx / tlen;
let localAmp = amp;
if (i > 0 && i < curvePts.length - 1) {
const ang0 = Math.atan2(p.y - prev.y, p.x - prev.x);
const ang1 = Math.atan2(next.y - p.y, next.x - p.x);
let dAng = ang1 - ang0;
while (dAng > Math.PI) dAng -= Math.PI * 2;
while (dAng < -Math.PI) dAng += Math.PI * 2;
const segLen = (Math.hypot(p.x - prev.x, p.y - prev.y) + Math.hypot(next.x - p.x, next.y - p.y)) / 2;
const curvature = segLen > 0.001 ? Math.abs(dAng) / segLen : 0;
const radius = curvature > 0.0001 ? 1 / curvature : Infinity;
localAmp = Math.min(amp, radius * 0.7);
}
const off = localAmp * Math.sin((total ? cum[i] / total : 0) * Math.PI * 2 * waves);
const px = p.x + nx * off, py = p.y + ny * off;
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
if (i === curvePts.length - 1) { lastPx = px; lastPy = py; } else { prevPx = px; prevPy = py; }
}
ctx.stroke();
// LA PUNTA SEGUE LA DIREZIONE DEL PALLEGGIO, NON L'ULTIMA ONDINA.
// Prima si prendeva l'inclinazione dell'ultimo segmento davvero
// disegnato, cioe' della sinusoide. Ma la sinusoide, proprio alla fine,
// sta risalendo di traverso: la sua pendenza li' vale circa 1, cioe'
// quasi 45 gradi fuori rotta. Risultato: la freccia sembrava ruotata a
// caso rispetto a dove il palleggio stava andando, e la si raddrizzava a
// mano ogni volta ("ruotano e fanno perdere tempo"). Adesso la punta
// guarda la TANGENTE DELLA CURVA su cui corre il palleggio — quella che
// il coach ha in mente quando disegna, curva o dritta che sia.
const finC = curvePts[curvePts.length - 1];
const primaC = curvePts[Math.max(0, curvePts.length - 2)];
const lastAng = Math.atan2(finC.y - primaC.y, finC.x - primaC.x);
drawDiagramArrowhead(ctx, finC.x, finC.y, lastAng, AH);
} else if (el.type === 'dribble') {
// Palleggio a 2 punti: linea a zigzag dritta, come 'palleggio' in
// ITALBASKET (comportamento invariato per i diagrammi già disegnati).
const dx = x2 - x1, dy = y2 - y1, len = Math.hypot(dx, dy) || 1;
const nx = -dy / len, ny = dx / len;
// Ampiezza proporzionale alla mezza-lunghezza d'onda effettiva (con un
// tetto per i palleggi lunghi e un minimo per restare riconoscibile su
// un tratto cortissimo) — più stretta e con più sinusoidi di prima,
// come richiesto.
const segs = 48, waves = Math.max(3, Math.min(11, Math.round(len / (S * 0.050))));
const halfWaveLen = len / (waves * 2);
const amp = Math.max(S * 0.007, Math.min(S * 0.022, halfWaveLen * 0.42));
ctx.lineWidth = LW;
ctx.beginPath();
// Stesso motivo del ramo curvo: la direzione della punta va presa
// dall'ultimo segmento EFFETTIVAMENTE disegnato (post-sinusoide), non
// dalla direzione della linea di base x1,y1->x2,y2 — vicino alla fine lo
// scarto della sinusoide ha una pendenza diversa da zero anche se torna
// a offset 0, quindi la freccia puntava in una direzione leggermente
// sbagliata rispetto a dove il tratto arriva davvero.
let prevPx = null, prevPy = null, lastPx = x2, lastPy = y2;
for (let i = 0; i <= segs; i++) {
const t = i / segs, bx = x1 + dx * t, by = y1 + dy * t, off = amp * Math.sin(t * Math.PI * 2 * waves);
const px = bx + nx * off, py = by + ny * off;
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
if (i === segs) { lastPx = px; lastPy = py; } else { prevPx = px; prevPy = py; }
}
ctx.stroke();
// Stesso motivo del ramo curvo: la punta segue la direttrice del
// palleggio (da dove parte a dove arriva), non l'inclinazione
// dell'ultima ondina della sinusoide.
drawDiagramArrowhead(ctx, x2, y2, Math.atan2(dy, dx), AH);
} else if (el.type === 'shot') {
// TIRO: linea continua (dritta, o spezzata se sono state cliccate più
// tappe) che finisce con DUE lineette orizzontali corte vicino al
// canestro — il simbolo classico del tiro. Niente punta di freccia: è
// quello che lo distingue dal movimento.
const pts = (Array.isArray(el.pts) && el.pts.length > 2)
? el.pts.map(p => ({ x: p.x * W, y: p.y * H }))
: [{ x: x1, y: y1 }, { x: x2, y: y2 }];
// Punta TAGLIATA NETTA, non arrotondata: con la punta tonda il tratto
// sporgeva oltre le due lineette di mezzo spessore e, su un tiro in
// diagonale, quel pezzetto in fuori insieme alle due sbarrette corte
// faceva sembrare che ci fosse ancora una freccia ("sul tiro dopo le due
// lineette c'e' comunque la freccia"). Il tiro non ha punta: qui la linea
// finisce esattamente dove finiscono le sbarrette.
ctx.lineCap = 'butt';
ctx.lineWidth = LW * 1.15;
ctx.beginPath();
pts.forEach((p, i) => { if (i === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y); });
ctx.stroke();
const last = pts[pts.length - 1], prev = pts[pts.length - 2];
const ang = Math.atan2(last.y - prev.y, last.x - prev.x), perp = ang + Math.PI / 2;
// Sbarrette lunghe come la barra del blocco e piu' staccate fra loro: da
// lontano si leggono come DUE lineette, non come un cuneo.
const half = Math.max(9, S * 0.036);
const gap = Math.max(5, S * 0.030);
ctx.lineWidth = LW * 1.8;
// due sbarrette parallele, l'ultima proprio sulla punta della linea
[0, gap].forEach(d => {
const bx = last.x - Math.cos(ang) * d, by = last.y - Math.sin(ang) * d;
ctx.beginPath();
ctx.moveTo(bx - half * Math.cos(perp), by - half * Math.sin(perp));
ctx.lineTo(bx + half * Math.cos(perp), by + half * Math.sin(perp));
ctx.stroke();
});
ctx.lineCap = 'round';
} else if (el.type === 'pass') {
// Passaggio: linea tratteggiata, come 'passaggio' in ITALBASKET.
ctx.lineWidth = LW; ctx.setLineDash([S * 0.019, S * 0.016]);
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.setLineDash([]);
drawDiagramArrowhead(ctx, x2, y2, Math.atan2(y2 - y1, x2 - x1), AH);
} else if (el.type === 'screen') {
// Blocco: linea continua che termina con una barra a "T", niente freccia
// (il simbolo classico del blocco), come 'bloccolinea' in ITALBASKET.
// Con più di 2 tappe cliccate è una linea SPEZZATA — segmenti dritti da
// un punto all'altro, NON una curva morbida come Movimento/Palleggio:
// una giocatrice che corre a bloccare cambia direzione di netto, non
// curva ("va in una direzione poi cambia direzione, quindi non curvo").
// ...ma il coach può anche volerlo CURVO ("anche il blocco può essere sia
// una linea spezzata sia una curva"): con el.curved si usa la stessa
// curva morbida del Movimento invece dei segmenti dritti. La scelta è per
// singolo blocco (fissata alla chiusura del tratto), non globale.
const pts = (Array.isArray(el.pts) && el.pts.length > 2)
? el.pts.map(p => ({ x: p.x * W, y: p.y * H }))
: [{ x: x1, y: y1 }, { x: x2, y: y2 }];
ctx.lineWidth = LW * 1.4;
ctx.beginPath();
let approach;
if (el.curved) {
approach = diagramSmoothCurvePoints(ctx, pts, S);
} else {
pts.forEach((p, i) => { if (i === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y); });
approach = pts[pts.length - 2];
}
ctx.stroke();
const last = pts[pts.length - 1], prev = approach;
const ang = Math.atan2(last.y - prev.y, last.x - prev.x), perp = ang + Math.PI / 2;
ctx.lineWidth = LW * 1.9;
const tHalf = Math.max(9, S * 0.036);
ctx.beginPath();
ctx.moveTo(last.x - tHalf * Math.cos(perp), last.y - tHalf * Math.sin(perp));
ctx.lineTo(last.x + tHalf * Math.cos(perp), last.y + tHalf * Math.sin(perp));
ctx.stroke();
} else if (Array.isArray(el.pts) && el.pts.length > 2) {
// Movimento con più di 2 tappe cliccate: curva morbida che passa per
// OGNI punto (diagramSmoothCurvePoints, porto 1:1 di deSmoothCurvePoints
// di ITALBASKET WOMAN) invece del solo arco fisso fra il primo e
// l'ultimo — utile per disegnare un taglio che gira attorno a un blocco
// o cambia direzione più volte.
const pts = el.pts.map(p => ({ x: p.x * W, y: p.y * H }));
ctx.lineWidth = LW;
ctx.beginPath();
const arrowFrom = diagramSmoothCurvePoints(ctx, pts, S);
ctx.stroke();
const last = pts[pts.length - 1];
drawDiagramArrowhead(ctx, last.x, last.y, Math.atan2(last.y - arrowFrom.y, last.x - arrowFrom.x), AH);
} else {
// Movimento/taglio, 2 punti: leggera curva con freccia finale, come la
// variante di default di deSmoothCurvePoints applicata a un segmento di
// 2 punti (comportamento invariato per i diagrammi già disegnati).
ctx.lineWidth = LW;
const dx = x2 - x1, dy = y2 - y1, len = Math.hypot(dx, dy) || 1;
const nx = -dy / len, ny = dx / len, bow = Math.min(S * 0.078, len * 0.18);
const ctrlX = (x1 + x2) / 2 + nx * bow, ctrlY = (y1 + y2) / 2 + ny * bow;
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.quadraticCurveTo(ctrlX, ctrlY, x2, y2); ctx.stroke();
drawDiagramArrowhead(ctx, x2, y2, Math.atan2(y2 - ctrlY, x2 - ctrlX), AH);
}
ctx.restore();
}
// Porto 1:1 di deSmoothCurvePoints (ITALBASKET WOMAN): traccia (con
// ctx.quadraticCurveTo) una curva morbida che passa per TUTTI i punti
// cliccati, e ritorna il punto di "approccio" all'ultimo, usato per
// orientare la punta della freccia. Con 2 punti resta un arco leggero
// (stesso identico calcolo del ramo "2 punti" sopra); con più punti la
// curva passa dolcemente per ogni tappa intermedia.
function diagramSmoothCurvePoints(ctx, pts, S) {
// S (lato corto del campo in pixel) va PASSATO: prima veniva letto da una
// variabile globale che non esiste, e un blocco "curvo" disegnato con soli
// 2 punti faceva saltare tutto il disegno con un errore.
if (S == null) S = Math.min(ctx.canvas ? ctx.canvas.width : 500, ctx.canvas ? ctx.canvas.height : 420);
ctx.moveTo(pts[0].x, pts[0].y);
if (pts.length === 2) {
const p0 = pts[0], p1 = pts[1];
const dx = p1.x - p0.x, dy = p1.y - p0.y, len = Math.hypot(dx, dy) || 1;
const nx = -dy / len, ny = dx / len, bow = Math.min(S * 0.078, len * 0.18);
const ctrl = { x: (p0.x + p1.x) / 2 + nx * bow, y: (p0.y + p1.y) / 2 + ny * bow };
ctx.quadraticCurveTo(ctrl.x, ctrl.y, p1.x, p1.y);
return ctrl;
}
// NB: il ciclo si ferma a "pts.length - 2" (non "- 1"): l'ultimo punto
// pts[pts.length-2] va usato come controllo SOLO nel segmento finale qui
// sotto. Fermarsi un passo troppo tardi (bug precedente) faceva ripetere
// lo stesso punto di controllo su due segmenti consecutivi: il secondo
// segmento, partendo già vicinissimo al punto finale ma con un controllo
// "alle spalle", piegava indietro su se stesso creando un nodino/cappio
// proprio dove la curva cambiava direzione bruscamente.
for (let i = 1; i < pts.length - 2; i++) {
const mid = { x: (pts[i].x + pts[i + 1].x) / 2, y: (pts[i].y + pts[i + 1].y) / 2 };
ctx.quadraticCurveTo(pts[i].x, pts[i].y, mid.x, mid.y);
}
const ctrl = pts[pts.length - 2];
ctx.quadraticCurveTo(ctrl.x, ctrl.y, pts[pts.length - 1].x, pts[pts.length - 1].y);
return ctrl;
}
// Stessa curva di diagramSmoothCurvePoints (stessi segmenti quadratici,
// stessi punti di controllo), ma restituisce una polilinea densa di punti
// campionati lungo la curva invece di limitarsi a tracciarla su un ctx —
// serve al Palleggio curvo per far seguire lo zigzag alla curva vera
// (tangente locale segmento per segmento) invece di una singola direzione
// fissa fra primo e ultimo punto.
function diagramCurveSamplePoints(pts, segsPerSpan, S) {
segsPerSpan = segsPerSpan || 16;
if (S == null) S = 420;
const spans = [];
if (pts.length === 2) {
const p0 = pts[0], p1 = pts[1];
const dx = p1.x - p0.x, dy = p1.y - p0.y, len = Math.hypot(dx, dy) || 1;
const nx = -dy / len, ny = dx / len, bow = Math.min(S * 0.078, len * 0.18);
const ctrl = { x: (p0.x + p1.x) / 2 + nx * bow, y: (p0.y + p1.y) / 2 + ny * bow };
spans.push({ p0, ctrl, p1 });
} else {
let start = pts[0];
// Stesso ciclo (stesso limite "pts.length - 2") di diagramSmoothCurvePoints
// sopra, per generare esattamente la stessa curva senza il nodino.
for (let i = 1; i < pts.length - 2; i++) {
const mid = { x: (pts[i].x + pts[i + 1].x) / 2, y: (pts[i].y + pts[i + 1].y) / 2 };
spans.push({ p0: start, ctrl: pts[i], p1: mid });
start = mid;
}
spans.push({ p0: start, ctrl: pts[pts.length - 2], p1: pts[pts.length - 1] });
}
const out = [];
spans.forEach((sp, si) => {
for (let s = (si === 0 ? 0 : 1); s <= segsPerSpan; s++) {
const t = s / segsPerSpan, mt = 1 - t;
out.push({
x: mt * mt * sp.p0.x + 2 * mt * t * sp.ctrl.x + t * t * sp.p1.x,
y: mt * mt * sp.p0.y + 2 * mt * t * sp.ctrl.y + t * t * sp.p1.y,
});
}
});
return out;
}
/* Campo da basket: PORTATO 1:1 da drawCourt()/deCanvasSize() dell'editor
* schemi di ITALBASKET WOMAN (stesse proporzioni FIBA, stesso bianco/nero,
* stesso disegno del campo intero con canestri a sx/dx) invece della
* geometria approssimata scritta da zero per il primo giro di VBC. */
function drawCourt(ctx, w, h, mode) {
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = '#000000';
ctx.lineWidth = 2;
ctx.strokeRect(3, 3, w - 6, h - 6);
ctx.lineWidth = 1.6;
if (mode === 'full') {
const cx = w / 2;
ctx.beginPath(); ctx.moveTo(cx, 3); ctx.lineTo(cx, h - 3); ctx.stroke();
ctx.beginPath(); ctx.arc(cx, h / 2, h * 0.14, 0, Math.PI * 2); ctx.stroke();
[0, 1].forEach(side => {
const hoopX = side === 0 ? 18 : w - 18;
const baseX = side === 0 ? 3 : w - 3;
const paintH = h * 0.327;
const paintD = w * 0.207;
ctx.strokeRect(side === 0 ? 3 : w - 3 - paintD, h / 2 - paintH / 2, paintD, paintH);
// Lunetta (cerchio del tiro libero), mancante prima nel campo intero:
// stessa proporzione FIBA già usata nel mezzo campo qui sotto (raggio
// 1.8m -> h*0.12, dato che qui è h — non w — a rappresentare la
// larghezza reale del campo). Centrata sulla linea del tiro libero
// (il lato dell'area lontano dal canestro), mezzo cerchio continuo
// verso centrocampo e mezzo tratteggiato verso il canestro/area —
// stessa convenzione del mezzo campo.
const ftLineX = side === 0 ? 3 + paintD : w - 3 - paintD;
const ftRadius = h * 0.12;
ctx.beginPath();
ctx.arc(ftLineX, h / 2, ftRadius, side === 0 ? -Math.PI / 2 : Math.PI / 2, side === 0 ? Math.PI / 2 : Math.PI * 1.5);
ctx.stroke();
ctx.beginPath();
ctx.setLineDash([4, 4]);
ctx.arc(ftLineX, h / 2, ftRadius, side === 0 ? Math.PI / 2 : -Math.PI / 2, side === 0 ? Math.PI * 1.5 : Math.PI / 2);
ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath(); ctx.arc(hoopX, h / 2, 5, 0, Math.PI * 2); ctx.stroke();
// Le due linee rette dell'arco da 3 punti, dal fondo campo fino
// all'inizio dell'arco — stessa derivazione (proporzioni FIBA reali,
// 6.75m di raggio / 6.6m di rientro dalla linea laterale = 7.5m-0.9m)
// usata sotto per il mezzo campo, invece di un angolo scelto a occhio.
const scaleH = h / 15;
const r3 = scaleH * 6.75;
const dirX = side === 0 ? 1 : -1;
ctx.beginPath(); ctx.moveTo(baseX, h / 2 - r3); ctx.lineTo(hoopX, h / 2 - r3); ctx.stroke();
ctx.beginPath(); ctx.moveTo(baseX, h / 2 + r3); ctx.lineTo(hoopX, h / 2 + r3); ctx.stroke();
ctx.beginPath();
ctx.arc(hoopX, h / 2, r3, side === 0 ? -Math.PI / 2 : Math.PI / 2, side === 0 ? Math.PI / 2 : Math.PI * 1.5);
ctx.stroke();
});
return;
}
// Proporzioni reali FIBA (campo largo 15m -> scala px/m = w/15): area 4.9m,
// cerchio tiro libero raggio 1.8m, arco da 3 raggio 6.75m, canestro a
// ~1.575m dalla linea di fondo, linea tiro libero a 5.8m.
const cx = w / 2;
const baseline = h - 5;
const scale = w / 15;
const paintW = w * 0.327;
const paintH = scale * 5.8;
const ftRadius = w * 0.12;
const hoopY = baseline - scale * 1.575;
// Raggio dell'arco da 3 punti: 6.75m FIBA -> scale*6.75 (= w*0.45, non
// w*0.48 come prima). Il raggio a 0.48*w era troppo grande: le due rette
// laterali finivano a soli ~0.43m dalla linea laterale invece dei 0.9m
// reali (il rientro FIBA della retta rispetto al bordo campo, 7.5m-6.6m),
// cioè circa la metà del dovuto — da qui l'arco "troppo grande, a ridosso
// della linea laterale" mentre il resto del campo (area, lunetta, già in
// proporzione corretta) lasciava spazio libero attorno. legDX è calcolato
// direttamente dal rientro reale (6.6m = 7.5m-0.9m), non da un angolo
// scelto a occhio; legAngle è poi derivato da legDX/threeRadius (invece
// del contrario) così la retta tocca l'arco esattamente nel punto giusto,
// niente scalino/incrocio come prima della correzione sulla linea dei 3
// punti.
const threeRadius = scale * 6.75;
ctx.strokeRect(cx - paintW / 2, baseline - paintH, paintW, paintH);
ctx.beginPath(); ctx.arc(cx, baseline - paintH, ftRadius, Math.PI, Math.PI * 2); ctx.stroke();
ctx.beginPath(); ctx.arc(cx, baseline - paintH, ftRadius, 0, Math.PI); ctx.setLineDash([4, 4]); ctx.stroke(); ctx.setLineDash([]);
// Linea da 3 punti: NON un semplice tondo. Come sul campo vero (e come sul
// campo disegnato in ITALBASKET WOMAN) è fatta di due TRATTI DRITTI che
// partono dalla linea di fondo, paralleli alle linee laterali, e di un
// ARCO che li unisce passando davanti al canestro. Prima si calcolava il
// punto di innesto con la geometria FIBA esatta (rientro 0,9 m): a quella
// scala i due tratti dritti erano lunghi pochi pixel e la linea sembrava
// un semicerchio pieno. Qui i tratti partono dal fondo e salgono fino
// all'altezza del canestro, dove l'arco (mezzo cerchio centrato sul
// canestro) si innesta esattamente sulla loro punta — nessuno scalino.
ctx.beginPath(); ctx.arc(cx, hoopY, threeRadius, Math.PI, Math.PI * 2); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx - threeRadius, hoopY); ctx.lineTo(cx - threeRadius, baseline); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx + threeRadius, hoopY); ctx.lineTo(cx + threeRadius, baseline); ctx.stroke();
ctx.beginPath(); ctx.arc(cx, hoopY, 5, 0, Math.PI * 2); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx - 16, hoopY - 9); ctx.lineTo(cx + 16, hoopY - 9); ctx.stroke();
}
// schema può essere null (campo vuoto, usato per riempire gli slot avanzati
// nel foglio di stampa 4x5): in quel caso disegna solo il campo, senza giocate.
// big=true usa una risoluzione più alta, per la stampa invece che per la lista.
// Stesse PROPORZIONI del canvas vero su cui si disegna (diagramCanvasSize,
// 576x324 campo intero / 504x360 mezzo campo, prese da ITALBASKET WOMAN):
// le miniature usavano proporzioni proprie, quasi quadrate, diverse da
// quelle per cui drawCourt() è pensato — da lì usciva l'arco da 3 punti dal
// bordo. Qui si scala semplicemente lo stesso rettangolo, non se ne inventa uno nuovo.
// "rotate": usato SOLO dalla striscia diagrammi di Allenamento per i 2 campi
// interi (vedi printPracticeStripHtml/practiceStripHtml) — ruotati di 90°
// così il campo intero (naturalmente "sdraiato", largo il doppio dell'
// altezza) diventa verticale e sfrutta per intero la colonna stretta della
// striscia invece di restare piccolo e schiacciato. Ruota l'intero disegno
// (campo + eventuali elementi già disegnati) dentro un canvas con
// larghezza/altezza scambiate, non è un semplice transform CSS sull' :
// così il file immagine risultante è già nell'orientamento giusto, nitido a
// qualunque dimensione mostrata.
function renderSchemaThumbnail(schema, mode, big, rotate) {
const c = document.createElement('canvas');
const base = diagramCanvasSize(mode);
// Risoluzione più alta per "big" (foglio di stampa + box 4 varianti): i
// campi nel PDF stampato ora si allargano per riempire la pagina, quindi
// servono più pixel sorgente per restare nitidi invece di sgranarsi.
const scale = big ? 0.9 : 0.4;
const dims = { w: Math.round(base.w * scale), h: Math.round(base.h * scale) };
const ctx = c.getContext('2d');
if (rotate) {
c.width = dims.h; c.height = dims.w;
ctx.save();
ctx.translate(c.width / 2, c.height / 2);
ctx.rotate(Math.PI / 2);
ctx.translate(-dims.w / 2, -dims.h / 2);
drawCourt(ctx, dims.w, dims.h, mode);
if (schema) (schema.elements || []).forEach(el => drawDiagramElement(ctx, dims.w, dims.h, el, false));
ctx.restore();
} else {
c.width = dims.w; c.height = dims.h;
drawCourt(ctx, c.width, c.height, mode);
if (schema) (schema.elements || []).forEach(el => drawDiagramElement(ctx, c.width, c.height, el, false));
}
return c.toDataURL();
}
// -------------------------------------------------------------- PLAYBOOK
// Griglia 4 colonne x N righe, SEMPRE visibile ed editabile — come il foglio
// stampato di riferimento (4 diagrammi per riga): ma qui ogni RIGA è UN
// GIOCO con nome (es. "BASE", "CORNA"), e le sue 4 caselle sono le varianti/
// opzioni di quel gioco (es. "opz. post basso", "opz. DHO con 1"...), non 4
// schemi indipendenti — così scrivere il nome del gioco nella riga libera fa
// comparire subito le 4 caselle diagramma pronte per costruirci sopra tutte
// le sue opzioni. Cliccando la miniatura di una casella, il suo riquadro si
// allarga qui sotto con il vero pbCanvas/pbToolbar (lo stesso elemento DOM di
// sempre, solo spostato dentro la riga aperta) per disegnarci sopra; ogni
// modifica (disegno, nome gioco, gruppo, nota, campo) si salva subito, senza
// bottone "Salva". Il logo/nome squadra restano visibili sopra (stessa
// striscia #activeTeamBanner di Video/Scouting Report/Practice).
let pbEditingRowId = null; // id della riga (gioco) aperta nel vero editor (canvas+strumenti), o null
let pbEditingSlotIndex = null; // quale delle 4 caselle (0-3) è aperta nell'editor, o null
// Livello intermedio fra la griglia 16 caselle e l'editor vero: un primo
// clic su una casella apre solo questo "box" con le 4 varianti del gioco
// ingrandite; da lì un secondo clic su una di queste apre canvas+strumenti
// (togglePlaybookSlotExpand, sotto) — così la griglia resta un colpo
// d'occhio sui 16 diagrammi, non si entra subito a disegnare per sbaglio.
let pbExpandedRowId = null;
// Un "foglio" Playbook = una pagina A4 con 4 giochi (righe) x 4 diagrammi —
// esattamente la griglia 4x4/16 caselle del foglio di riferimento. Le righe
// restano un unico elenco piatto nel modello dati (activePlaybookRows());
// la paginazione a 4 è solo di rendering (vedi renderPlaybookSection):
// quando un foglio è pieno, il gioco successivo creato da "+ Aggiungi
// gioco" ricade semplicemente sul foglio dopo, che compare da solo.
const PB_ROWS_PER_PAGE = 4;
// L'aspect-ratio VERO del campo (mezzo/intero, stesse proporzioni del
// canvas su cui si disegna — vedi diagramCanvasSize) impostato inline sulla
// miniatura: niente più riquadro basso fisso che schiacciava/tagliava il
// disegno, l'immagine riempie tutta la larghezza della colonna mantenendo
// le proporzioni corrette (niente arco da 3 punti che esce dal campo).
function pbAspectRatioStyle(mode) {
const s = diagramCanvasSize(mode);
return `aspect-ratio:${s.w}/${s.h};`;
}
// Solo l'immagine del campo nella griglia 16 caselle: la nota testuale
// (che prima stava qui, sottraendo spazio al disegno) si scrive ora solo
// nell'editor vero (casella "Nota" lì dentro) — "tutto lo spazio ai 16
// diagrammi, solo il nome del gioco resta in vista sul foglio".
// Primo click sulla griglia -> apre il box coi 4 diagrammi ingranditi di
// QUELLA riga/gioco (openPlaybookRowBox), non l'editor con gli strumenti:
// il coach vuole prima vedere/scegliere fra le 4 varianti del gioco, poi
// cliccarne una per disegnarci sopra (secondo click, dentro al box —
// vedi playbookRowBoxHtml -> togglePlaybookSlotExpand).
function playbookSlotCellHtml(row, slotIndex) {
const slot = row.slots[slotIndex];
const isExpanded = pbEditingRowId === row.id && pbEditingSlotIndex === slotIndex;
const hasContent = slot.elements && slot.elements.length;
return `
`;
}
// Dispatcher del click sulle 4 miniature piccole della griglia principale:
// se questa riga ha già il box aperto o è già in editing (qualunque delle
// 4 caselle), un click passa DIRETTAMENTE a quella casella nel vero editor —
// prima invece richiamava sempre openPlaybookRowBox(), che con l'editor già
// aperto sulla stessa riga non faceva letteralmente nulla (pbEditingRowId
// restava quello di prima): sembrava che "il secondo diagramma non si apra"
// dopo aver disegnato il primo. Sulla primissima apertura di una riga
// (niente ancora aperto) resta il comportamento originale: prima il box
// con le 4 varianti ingrandite, poi un secondo click per entrare a disegnare.
function onPlaybookGridCellClick(rowId, slotIndex) {
if (pbExpandedRowId === rowId || pbEditingRowId === rowId) {
togglePlaybookSlotExpand(rowId, slotIndex);
} else {
openPlaybookRowBox(rowId);
}
}
// row === null → casella "dormiente" (nessun gioco ancora creato in questa
// posizione del foglio): mostra comunque le 4 caselline vuote, così il
// foglio si vede SUBITO nel suo formato 4x4 completo anche prima di aver
// creato un solo gioco, invece di apparire una riga alla volta.
// IMPORTANTE: prima queste 4 caselline non avevano NESSUN onclick — identiche
// a vista a quelle di un gioco vero, ma cliccandole non succedeva
// letteralmente nulla (nessun errore, nessun feedback). Con solo 1-2 giochi
// creati, la maggior parte delle 16 caselle del foglio sono proprio queste
// "dormienti": è molto probabile che sia QUESTO il motivo di fondo dietro
// "i diagrammi non si aprono" — il coach clicca su una casella ancora vuota
// (che sembra identica alle altre) e non ottiene alcuna reazione. Ora anche
// queste aprono la creazione di un nuovo gioco, esattamente come "+ Aggiungi
// gioco" in alto.
function playbookRowHtml(row) {
if (!row) {
const cell = `
`;
return `
${cell}${cell}${cell}${cell}
`;
}
const icon = row.gruppo === 'attacco' ? '⚡' : '🛡️';
let cellsHtml = '';
for (let i = 0; i < 4; i++) cellsHtml += playbookSlotCellHtml(row, i);
// Il box coi 4 diagrammi ingranditi (secondo livello) va scritto qui solo
// se questa riga è "espansa" ma non ancora in editing vero: quando invece
// pbEditingRowId combacia, questo slot resta vuoto in stringa e viene
// riempito subito dopo da renderPlaybookSection riattaccandoci il vero
// pbEditorPanel (nodo DOM persistente, non ricreabile da stringa).
const showBox = pbExpandedRowId === row.id && pbEditingRowId !== row.id;
return `
${icon}
${escapeHtml(row.nome)}
▲
▼
✕
${cellsHtml}
${showBox ? playbookRowBoxHtml(row) : ''}
`;
}
function renderPlaybookSection() {
setTimeout(aggiornaSerieUsate, 0);
const container = document.getElementById('pbPages');
if (!document.getElementById('pbSerieUsate')) {
const dl = document.createElement('datalist'); dl.id = 'pbSerieUsate';
document.body.appendChild(dl);
}
if (!container) return;
const rows = activePlaybookRows();
if (pbEditingRowId && !rows.some(r => r.id === pbEditingRowId)) { pbEditingRowId = null; pbEditingSlotIndex = null; }
if (pbExpandedRowId && !rows.some(r => r.id === pbExpandedRowId)) { pbExpandedRowId = null; }
// Il pannello editor (con dentro il vero pbCanvas, listener già agganciati
// una volta sola da initDiagramCanvas) va staccato dalla griglia PRIMA di
// riscriverne l'innerHTML, altrimenti verrebbe distrutto insieme al resto.
const holder = document.getElementById('pbEditorHolder');
const panel = document.getElementById('pbEditorPanel');
if (panel && holder && panel.parentElement !== holder) holder.appendChild(panel);
const team = activeTeam();
const league = activeLeague();
const pageCount = Math.max(1, Math.ceil(rows.length / PB_ROWS_PER_PAGE));
let html = '';
for (let p = 0; p < pageCount; p++) {
const pageRows = rows.slice(p * PB_ROWS_PER_PAGE, p * PB_ROWS_PER_PAGE + PB_ROWS_PER_PAGE);
const label = pageCount > 1 ? t('playbook.title') + ' — ' + t('playbook.sheetLabel', { n: p + 1 }) : t('playbook.title');
let rowsHtml = '';
for (let r = 0; r < PB_ROWS_PER_PAGE; r++) rowsHtml += playbookRowHtml(pageRows[r] || null);
html += ``;
}
container.innerHTML = html;
if (pbEditingRowId && panel) {
const slotEl = container.querySelector(`.pb-row[data-row-id="${pbEditingRowId}"] .pb-row-editor-slot`);
if (slotEl) { slotEl.appendChild(panel); panel.style.display = 'block'; }
} else if (panel) {
panel.style.display = 'none';
}
renderPlaybookExtraPageUI();
}
// Foglio "seconda pagina" libero: un bottone per aggiungerla/toglierla (resta
// opzionale) e, se attiva, un foglio bianco con la stessa intestazione delle
// pagine di gioco e un'unica area contenteditable dove scrivere qualunque
// cosa. Il testo digitato NON causa un re-render distruttivo (stesso
// principio di onScoutingBoxFieldEdit/onPlaybookRowNameLiveEdit): si scrive
// solo nel modello dati, senza toccare il DOM mentre si sta scrivendo.
function renderPlaybookExtraPageUI() {
const wrap = document.getElementById('pbExtraPageWrap');
if (!wrap) return;
const extra = activePlaybookExtraPage();
if (!extra.active) {
wrap.innerHTML = `
${escapeHtml(t('playbook.addExtraPageBtn'))}
`;
return;
}
const team = activeTeam();
const league = activeLeague();
const ph = escapeHtml(t('playbook.extraPagePh'));
wrap.innerHTML = ``;
}
function togglePlaybookExtraPage(active) {
const extra = activePlaybookExtraPage();
extra.active = active;
scheduleAutosave();
renderPlaybookExtraPageUI();
}
function onPlaybookExtraPageEdit(el) {
const extra = activePlaybookExtraPage();
extra.content = (el.innerText || '').replace(/\r/g, '');
scheduleAutosave();
}
function createPlaybookRow(rawName) {
const name = (rawName || '').trim();
if (!name) return;
activePlaybookRows().push(defaultPlaybookRow(name, 'attacco'));
renderPlaybookSection();
toast(activeTeam() ? t('toast.schemaSavedTeam') : t('toast.schemaSavedLib'));
}
// "+ Aggiungi gioco": unico modo per creare un gioco ora (niente più riga
// vuota da compilare in linea) — chiede solo il nome, poi lo aggiunge in
// coda. Quando il foglio corrente (4 giochi) è pieno, il nuovo gioco ricade
// da solo sul foglio successivo, che compare in automatico.
/* LA LIBRERIA DEI GIOCHI (23 settembre 2026).
* Il foglio del playbook e' fatto per STAMPARE: quattro giochi per pagina, in
* fila. Va benissimo finche' i giochi sono venti. A duecento — e c'e' chi ne
* ha duemila — per ritrovare una rimessa si scorrono pagine a vuoto.
* Qui i giochi della squadra aperta si vedono tutti insieme, raggruppati per
* SERIE, con il primo diagramma sotto gli occhi: clicchi e vai. Non sposta e
* non cambia niente, e' solo un altro modo di guardare le stesse righe. */
let pbLibTutte = false; // libreria: solo questa squadra, o tutte
/* TUTTE LE SQUADRE INSIEME.
* Lo stesso gioco ha nomi diversi in squadre diverse: Schio lo chiama "3",
* Campobasso lo chiama "L", ma e' sempre un Double High. Scrivendo la stessa
* sezione su tutti e due, qui li si ritrova affiancati — e si vede anche
* COME lo giocano diversamente, che e' meta' del lavoro di uno scout. */
function pbRigheLibreria() {
if (!pbLibTutte) return activePlaybookRows().map(r => ({ r, sq: (activeTeam() || {}).name || '' }));
const fuori = [];
(DB.leagues || []).forEach(lg => (lg.teams || []).forEach(tm => {
(tm.playbookRows || []).forEach(r => fuori.push({ r, sq: tm.name || '' }));
}));
(DB.scratchPlaybookRows || []).forEach(r => fuori.push({ r, sq: '' }));
return fuori;
}
function pbLibCambiaAmbito() {
pbLibTutte = !pbLibTutte;
apriLibreriaGiochi();
}
function apriLibreriaGiochi() {
const coppie = pbRigheLibreria();
const righe = coppie.map(c => c.r);
const squadraDi = new Map(coppie.map(c => [c.r.id, c.sq]));
let box = document.getElementById('pbLibreria');
if (!box) {
box = document.createElement('div');
box.id = 'pbLibreria';
box.className = 'pb-lib';
box.onclick = (e) => { if (e.target === box) chiudiLibreriaGiochi(); };
document.body.appendChild(box);
}
const gruppi = new Map();
righe.forEach(r => {
const sue = sezioniDi(r);
const dove = sue.length ? sue : [t('playbook.serieNessuna')];
dove.forEach(k => {
if (!gruppi.has(k)) gruppi.set(k, []);
gruppi.get(k).push(r);
});
});
const chiavi = [...gruppi.keys()].sort((a, b) => {
const na = a === t('playbook.serieNessuna'), nb = b === t('playbook.serieNessuna');
if (na !== nb) return na ? 1 : -1; // "senza serie" in fondo
return a.localeCompare(b);
});
const team = activeTeam();
let html = `
\ud83d\udcda ${escapeHtml(t('playbook.libraryBtn'))}
${escapeHtml(team ? (team.name || '') : '')} · ${righe.length} · ${gruppi.size} ${escapeHtml(t('playbook.sezioniEti'))}
${escapeHtml(t(pbLibTutte ? 'playbook.libTutte' : 'playbook.libSolaSquadra'))}
✕
`;
if (!righe.length) {
html += `
${escapeHtml(t('playbook.libVuota'))}
`;
}
chiavi.forEach(k => {
const elenco = gruppi.get(k);
html += `
${escapeHtml(k)} ${elenco.length}
`;
elenco.forEach(r => {
const primo = (r.slots || []).find(sl => sl && (sl.elements || []).length) || null;
const img = renderSchemaThumbnail(primo, (primo && primo.campoMode) || 'half');
const sq = squadraDi.get(r.id) || '';
html += `
${escapeHtml(r.nome || t('playbook.senzaNome'))}${sq && pbLibTutte ? `${escapeHtml(sq)} ` : ''}
`;
});
html += `
`;
});
html += `
`;
box.innerHTML = html;
box.classList.add('show');
const c = document.getElementById('pbLibCerca');
if (c) c.focus();
}
function chiudiLibreriaGiochi() {
const box = document.getElementById('pbLibreria');
if (box) box.classList.remove('show');
}
/* Cerca per nome del gioco o per serie: le carte che non c'entrano spariscono
* e le serie rimaste vuote si nascondono da sole. */
function filtraLibreriaGiochi(testo) {
const q = (testo || '').trim().toLowerCase();
document.querySelectorAll('#pbLibreria .pb-lib-serie').forEach(sez => {
let visibili = 0;
sez.querySelectorAll('.pb-lib-carta').forEach(c => {
const ok = !q || (c.dataset.nome || '').includes(q) || (sez.dataset.serie || '').includes(q);
c.style.display = ok ? '' : 'none';
if (ok) visibili++;
});
sez.style.display = visibili ? '' : 'none';
});
}
/* DALLA LIBRERIA AL FOGLIO.
* Se il gioco e' di QUESTA squadra: si apre e ce lo si trova sotto gli occhi.
* Se e' di un'ALTRA squadra (lo stesso Double High che Schio chiama "3" e
* Campobasso chiama "L"): se ne prende una copia e la si porta nel foglio su
* cui si sta lavorando, chiedendo prima come chiamarlo — perche' ogni squadra
* ha il suo nome per lo stesso gioco. L'originale non si tocca. */
function vaiAlGioco(rowId) {
const mie = activePlaybookRows();
if (mie.some(r => r.id === rowId)) {
chiudiLibreriaGiochi();
pbExpandedRowId = rowId;
renderPlaybookSection();
setTimeout(() => {
const el = document.querySelector('.pb-row[data-row-id="' + rowId + '"]');
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 60);
return;
}
// sta in un'altra squadra: se ne prende una copia
let trovato = null, daSquadra = '';
(DB.leagues || []).forEach(lg => (lg.teams || []).forEach(tm => {
(tm.playbookRows || []).forEach(r => { if (r.id === rowId) { trovato = r; daSquadra = tm.name || ''; } });
}));
(DB.scratchPlaybookRows || []).forEach(r => { if (r.id === rowId) trovato = r; });
if (!trovato) return;
const team = activeTeam();
if (!team) { toast(t('arc.nienteSquadra')); return; }
chiudiLibreriaGiochi();
showPromptModal(
t('playbook.copiaQuiTitolo', { squadra: daSquadra, mia: team.name || '' }),
trovato.nome || '',
(nome) => { // (il campo parte gia' col nome originale: vedi l'ultimo argomento)
const copia = JSON.parse(JSON.stringify(trovato));
copia.id = nextId('pbrow');
copia.nome = (nome || '').trim() || trovato.nome || '';
activePlaybookRows().push(copia);
scheduleAutosave();
pbExpandedRowId = copia.id;
renderPlaybookSection();
toast(t('playbook.copiaQuiFatto', { nome: copia.nome }));
setTimeout(() => {
const el = document.querySelector('.pb-row[data-row-id="' + copia.id + '"]');
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 80);
},
trovato.nome || ''
);
}
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
const box = document.getElementById('pbLibreria');
if (box && box.classList.contains('show')) { e.preventDefault(); chiudiLibreriaGiochi(); }
});
function addPlaybookGame() {
showPromptModal(t('playbook.newGamePromptTitle'), t('playbook.rowNamePh'), (name) => {
createPlaybookRow(name);
});
}
// Secondo livello (il "box" con le 4 varianti ingrandite): 4 miniature più
// grandi di quelle della griglia, solo il nome del gioco come titolo — un
// clic su una di queste 4 apre davvero canvas+strumenti (togglePlaybookSlotExpand).
function playbookRowBoxHtml(row) {
const cellsHtml = row.slots.map((slot, i) => `
${escapeHtml(t('playbook.variantLabel', { n: i + 1 }))}
`).join('');
return `
${escapeHtml(row.nome)}
✕
${cellsHtml}
`;
}
// Primo clic (sulla griglia 16 caselle): apre solo il box con le 4
// varianti ingrandite di quel gioco, non entra ancora nell'editor — un
// eventuale editor aperto su un'ALTRA riga si chiude, per non avere due
// righe "aperte" insieme.
function openPlaybookRowBox(rowId) {
if (pbEditingRowId && pbEditingRowId !== rowId) { pbEditingRowId = null; pbEditingSlotIndex = null; }
pbExpandedRowId = (pbExpandedRowId === rowId && !pbEditingRowId) ? null : rowId;
renderPlaybookSection();
}
function closePlaybookRowBox() {
pbExpandedRowId = null;
pbEditingRowId = null;
pbEditingSlotIndex = null;
renderPlaybookSection();
}
// Nota: NON richiudere più l'editor se si riclicca sullo stesso diagramma
// (comportamento "toggle" tolto): con un solo click per aprire, un doppio
// click involontario sulla stessa casella lo apriva e richiudeva di scatto,
// dando l'impressione che "non si apra nessun diagramma". Per chiudere
// resta il pulsante esplicito "✕ Chiudi" (collapsePlaybookEditor).
function togglePlaybookSlotExpand(rowId, slotIndex) {
const row = activePlaybookRows().find(r => r.id === rowId);
if (!row) return;
const slot = row.slots[slotIndex];
// Continuità fra le 4 varianti dello stesso gioco: se questa casella è
// ancora vuota (e non è la prima), riparte da una copia dell'ultimo
// disegno della casella precedente invece che dal campo bianco — il
// coach la modifica per mostrare il passo successivo dell'azione, il
// disegno di partenza resta visibile finché non lo cambia.
if ((!slot.elements || !slot.elements.length) && slotIndex > 0) {
const prevSlot = row.slots[slotIndex - 1];
if (prevSlot && prevSlot.elements && prevSlot.elements.length) {
slot.elements = playbookNextFrameElements(prevSlot.elements);
slot.campoMode = prevSlot.campoMode || 'half';
markPlaybookSlotAuto(slot);
}
}
pbEditingRowId = rowId; pbEditingSlotIndex = slotIndex; pbExpandedRowId = rowId;
pbState.mode = slot.campoMode || 'half';
resizeDiagramCanvas(pbState);
pbState.elements = JSON.parse(JSON.stringify(slot.elements || []));
pbState.rimossi = Array.isArray(slot.rimossi) ? slot.rimossi.slice() : [];
pbState.selectedIndex = -1; pbState.pendingPath = null;
// Riparte sempre dallo strumento "Attacco" (non da "Seleziona", il
// default di createDiagramState): con "Seleziona" attivo su un campo
// vuoto il primo click non fa NULLA di visibile (nessun elemento da
// selezionare) — sembrava che il diagramma "non si aprisse per
// disegnarci sopra", quando in realtà mancava solo un click sulla
// toolbar prima di poter disegnare. Così il primo click sul campo piazza
// subito un giocatore.
pbState.tool = 'off1';
renderPlaybookSection();
// Porta il campo appena aperto in vista, sotto le barre fisse in alto. Dal
// secondo gioco in poi l'editor si apre più in basso nella pagina e la
// parte alta del campo finiva dietro al menu: lì i click non arrivavano al
// canvas ma alla barra, quindi si disegnava in cima al campo (dove sta la
// playmaker, l'arco dei 3) e non compariva niente.
requestAnimationFrame(() => {
const panel = document.getElementById('pbEditorPanel');
// Spostamento immediato, non "smooth": con l'animazione il campo è ancora
// in movimento quando ci si clicca sopra, e i primi click cadono fuori.
if (panel) panel.scrollIntoView({ block: 'start' });
});
const nameEl = document.getElementById('pbSchemaName');
const groupEl = document.getElementById('pbSchemaGroup');
const noteEl = document.getElementById('pbSchemaNote');
const courtEl = document.getElementById('pbCourtMode');
const variantEl = document.getElementById('pbVariantLabel');
if (nameEl) nameEl.value = row.nome;
if (groupEl) groupEl.value = row.gruppo;
if (noteEl) noteEl.value = slot.note || '';
if (courtEl) courtEl.value = pbState.mode;
if (variantEl) variantEl.textContent = t('playbook.variantLabel', { n: slotIndex + 1 });
renderDiagramToolbar(pbState);
redrawDiagram(pbState);
renderPlaybookEditorVariants();
}
// Striscia delle 4 varianti dentro l'editor stesso (vedi #pbEditorVariants
// nell'HTML statico del pannello): resta sempre a vista mentre si disegna,
// così si passa da una casella all'altra dello stesso gioco con un click
// diretto su questa striscia, senza dover premere "✕ Chiudi" per tornare
// al box intermedio — il flusso che il coach ha chiesto esplicitamente
// ("apri un box di 4 diagrammi e disegno il gioco in quei 4 diagrammi").
function renderPlaybookEditorVariants() {
const el = document.getElementById('pbEditorVariants');
if (!el) return;
if (!pbEditingRowId) { el.innerHTML = ''; return; }
const row = activePlaybookRows().find(r => r.id === pbEditingRowId);
if (!row) { el.innerHTML = ''; return; }
el.innerHTML = row.slots.map((slot, i) => `
${escapeHtml(t('playbook.variantLabel', { n: i + 1 }))}
`).join('');
}
function collapsePlaybookEditor() {
pbEditingRowId = null;
pbEditingSlotIndex = null;
renderPlaybookSection();
}
// Il fotogramma SUCCESSIVO di un'azione: le 4 caselle di un gioco sono i
// passi consecutivi della stessa azione, quindi la casella dopo non riparte
// dal campo bianco ma da DOVE SI È ARRIVATI in quella prima. Non è una copia
// identica: le frecce del passo precedente sono già "avvenute", quindi
// - ogni giocatrice che aveva una freccia (movimento/palleggio/blocco) che
// partiva da lei viene spostata alla FINE di quella freccia;
// - la palla passa dal passatore alla ricevente più vicina alla punta del
// passaggio;
// - le frecce stesse spariscono (si ridisegnano nel nuovo passo).
// Così il coach ritrova le giocatrici nella posizione giusta e disegna solo
// il movimento nuovo, invece di ripiazzare tutto da capo ogni volta.
// Firma del disegno "messo dall'automatismo": serve a distinguere una
// casella che il coach ha davvero modificato da una che ha solo APERTO. Senza
// questa distinzione bastava aprire il secondo campo per congelarlo (vedi
// syncExpandedPlaybookSlot).
function playbookFrameSignature(elements) {
return JSON.stringify(elements || []);
}
function markPlaybookSlotAuto(slot) {
slot.auto = true;
slot.autoSig = playbookFrameSignature(slot.elements);
}
// Quello che cambia nel campo precedente deve arrivare anche nei campi
// successivi GIÀ DISEGNATI: posizioni delle giocatrici e possesso di palla si
// riallineano, mentre tutto quello che il coach ha aggiunto lì dentro (frecce,
// blocchi, coni, scritte, giocatrici in più) resta intatto. Prima passava solo
// la palla: se avevi già disegnato qualcosa nel secondo campo, spostando una
// giocatrice nel primo il secondo restava indietro — da qui "a volte non
// succede".
function applyInheritedFrame(slot, derived, aggiungiMancanti) {
const els = slot.elements || [];
const chiave = (e) => e.type + '|' + (e.label || '');
const tolti = Array.isArray(slot.rimossi) ? slot.rimossi : [];
// Chi segue il campo precedente: giocatrici, difensori, coni, consegne e
// scritte. Prima passavano solo giocatrici e difensori, e così il simbolo
// della CONSEGNA spariva nel campo dopo appena quella casella era già stata
// toccata dal coach.
const SEGUONO = ['off', 'def', 'cone', 'text'];
const perChiave = new Map();
els.forEach(e => { if (SEGUONO.includes(e.type)) perChiave.set(chiave(e), e); });
let aggiunte = false;
(derived || []).forEach(d => {
if (!SEGUONO.includes(d.type)) return;
const k = chiave(d);
const esistente = perChiave.get(k);
if (esistente) {
esistente.x = d.x; esistente.y = d.y;
} else if (tolti.indexOf(k) < 0) {
// Non c'è ancora: si aggiunge — a meno che il coach l'abbia CANCELLATO
// apposta con la gomma in questa casella (slot.rimossi), nel qual caso
// deve restare cancellato.
els.push(JSON.parse(JSON.stringify(d)));
perChiave.set(k, els[els.length - 1]);
aggiunte = true;
}
});
// La palla: ce l'ha chi la teneva alla fine del campo precedente — MA SOLO
// se in questa casella il coach non l'ha già decisa lui a mano. Se l'ha
// fatto (slot.ballManual, vedi syncExpandedPlaybookSlot) la sua scelta
// vince: prima la propagazione gliela rimetteva addosso a ogni ritocco del
// campo precedente, ed era il motivo per cui "il 5 ha ancora la palla nel
// secondo diagramma" anche dopo avergliela tolta.
const holder = (derived || []).find(e => e.type === 'off' && e.hasBall);
const holderLabel = holder ? holder.label : null;
// La scelta fatta a mano vale finché a MONTE non succede qualcosa di nuovo:
// se nel campo precedente si disegna un altro passaggio, chi ha la palla
// cambia e quel cambiamento deve arrivare comunque fin qui. Senza questo,
// una palla spostata a mano una volta bloccava per sempre la catena — ed è
// il motivo per cui nel quarto diagramma non arrivava il passaggio da 1 a 4.
const nuovoEvento = (slot.autoBallLabel !== undefined && slot.autoBallLabel !== holderLabel);
if (!slot.ballManual || nuovoEvento) {
if (holder) {
const target = els.find(e => e.type === 'off' && e.label === holder.label);
if (target) {
els.forEach(e => { if (e.type === 'off') e.hasBall = (e === target); });
slot.ballManual = false;
}
}
slot.autoBallLabel = holderLabel;
}
if (aggiunte) slot.elements = els;
}
// Etichetta di chi ha la palla in un elenco di elementi (null = nessuno).
function playbookBallLabel(elements) {
const h = (elements || []).find(e => e.type === 'off' && e.hasBall);
return h ? (h.label || '') : null;
}
function playbookNextFrameElements(elements) {
const src = JSON.parse(JSON.stringify(elements || []));
const POINT_TYPES = ['off', 'def', 'cone', 'text', 'handoff'];
const points = src.filter(e => POINT_TYPES.includes(e.type));
const arrows = src.filter(e => !POINT_TYPES.includes(e.type));
const NEAR = 0.075; // stessa scala del raggio di hit-test sul campo
// Raggio più largo per la PUNTA del passaggio: la freccia si disegna quasi
// sempre poco PRIMA della ricevente (per non coprirle il numero), quindi
// col raggio stretto del hit-test l'aggancio falliva e la palla restava in
// mano a chi aveva passato.
const NEAR_PASS = 0.18;
const nearest = (x, y, pool, radius) => {
let best = null, bestD = radius || NEAR;
pool.forEach(p => {
const d = Math.hypot(p.x - x, p.y - y);
if (d < bestD) { bestD = d; best = p; }
});
return best;
};
// Il riferimento scritto sul tratto al momento del disegno (daRif/aRif):
// vale solo se quella giocatrice e' ancora li' vicino, cosi' un tratto
// trascinato altrove dopo non continua a puntare al vecchio proprietario.
const perRiferimento = (rif, pool, punto) => {
if (!rif || !rif.label) return null;
const p = pool.find(q => q.type === rif.tipo && (q.label || '') === rif.label);
if (!p) return null;
return Math.hypot(p.x - punto.x, p.y - punto.y) <= 0.30 ? p : null;
};
// Stima della ricevente quando la calamita non ha agganciato nulla: conta
// quanto la giocatrice e' FUORI DALLA LINEA del passaggio (lo scarto
// laterale pesa il doppio) e si scartano quelle che stanno dietro la punta,
// cioe' dalla parte da cui il pallone e' partito.
const riceventeStimata = (coda, punta, pool, arrivi) => {
let dx = punta.x - coda.x, dy = punta.y - coda.y;
const len = Math.hypot(dx, dy);
if (!len) return nearest(punta.x, punta.y, pool, NEAR_PASS);
const ux = dx / len, uy = dy / len;
const punteggioDa = (px, py) => {
const qx = px - punta.x, qy = py - punta.y;
const dist = Math.hypot(qx, qy);
if (dist > 0.34) return Infinity;
const avanti = qx * ux + qy * uy; // oltre la punta, nel verso del passaggio
const lato = Math.abs(qx * -uy + qy * ux); // quanto e' fuori dalla linea
if (avanti < -0.07) return Infinity; // indietro rispetto alla punta: non riceve
return lato * 2 + Math.max(0, avanti) * 0.6;
};
let best = null, bestPunteggio = Infinity;
pool.forEach(q => {
// IL PASSAGGIO AL TAGLIO. Su una lavagna la freccia del passaggio si
// tira verso il punto dove il ricevente STA ARRIVANDO, non dove si
// trova adesso. Guardando solo le posizioni di partenza, chi taglia
// risultava "dietro" alla punta e veniva scartato, e la palla finiva a
// chi per caso stava gia' da quelle parti ("passo la palla a 1 e nel
// campo dopo risulta che ce l'ha il 4"). Quindi ogni player vale per
// due posizioni: dov'e' adesso e dove lo porta la sua freccia di
// movimento. Conta la migliore delle due.
let punteggio = punteggioDa(q.x, q.y);
const arr = arrivi && arrivi.get(q);
if (arr) punteggio = Math.min(punteggio, punteggioDa(arr.x, arr.y));
if (punteggio < bestPunteggio) { bestPunteggio = punteggio; best = q; }
});
return best || nearest(punta.x, punta.y, pool, NEAR_PASS);
};
// Dove arriva ciascuno con la propria freccia di movimento (il TIRO no: la
// linea va al canestro ma chi tira non ci corre dentro). Serve alla stima
// qui sopra e si calcola sulle posizioni di partenza, prima di muovere
// chiunque, esattamente come il resto del passaggio.
const arriviDeiPunti = new Map();
arrows.filter(a => a.type !== 'pass' && a.type !== 'shot').forEach(a => {
const partenza = (Array.isArray(a.pts) && a.pts.length) ? a.pts[0] : { x: a.x, y: a.y };
const arrivo = (Array.isArray(a.pts) && a.pts.length > 1) ? a.pts[a.pts.length - 1] : { x: a.x2, y: a.y2 };
if (!arrivo || !isFinite(arrivo.x) || !isFinite(arrivo.y)) return;
const mover = perRiferimento(a.daRif, points, partenza) || nearest(partenza.x, partenza.y, points, NEAR_PASS);
if (mover) arriviDeiPunti.set(mover, { x: arrivo.x, y: arrivo.y });
});
// 1) La palla segue il passaggio: si calcola PRIMA di muovere chiunque,
// sulle posizioni di partenza. Conta SOLO dove il passaggio ARRIVA: chi
// riceve prende la palla e a tutte le altre viene tolta. Prima si
// cercava anche la passatrice vicino all'inizio della freccia e si
// spostava la palla solo se si trovavano entrambe — ma se il tratto
// partiva anche solo un po' staccato dalla giocatrice, quella ricerca
// falliva e la palla non si spostava affatto: nel campo dopo restava
// attaccata a chi aveva passato ("era corretto tutto tranne il numero 1
// che risultava ancora con la palla"). Il punto d'arrivo invece è
// sempre cliccato sulla ricevente, quindi è un aggancio affidabile.
arrows.filter(a => a.type === 'pass').forEach(a => {
const offs = points.filter(p => p.type === 'off');
// Punta e coda vere del tratto: con un passaggio spezzato (piu' click)
// il punto d'arrivo e' l'ULTIMA tappa, non x2/y2 di un tratto dritto.
const coda = (Array.isArray(a.pts) && a.pts.length) ? a.pts[0] : { x: a.x, y: a.y };
const punta = (Array.isArray(a.pts) && a.pts.length > 1) ? a.pts[a.pts.length - 1] : { x: a.x2, y: a.y2 };
const from = perRiferimento(a.daRif, offs, coda) || nearest(coda.x, coda.y, offs, NEAR_PASS);
const cand = offs.filter(p => p !== from);
// 1) Se al momento del disegno la punta si e' agganciata a una giocatrice
// (calamita), quella E' la ricevente: nessuna stima, nessun dubbio.
let to = perRiferimento(a.aRif, cand, punta);
// 2) Altrimenti si stima, ma guardando anche il VERSO del passaggio e non
// solo la distanza: la ricevente sta davanti alla punta, nella
// direzione in cui la freccia stava andando. Prima si prendeva la piu'
// vicina in assoluto e, con due giocatrici ravvicinate o con la punta
// tirata corta, finiva cerchiata quella sbagliata nel campo dopo.
if (!to) to = riceventeStimata(coda, punta, cand, arriviDeiPunti);
if (!to || to === from) return;
offs.forEach(p => { p.hasBall = false; });
to.hasBall = true;
});
// 1-bis) Passaggio CONSEGNATO: il simbolo della consegna sta fra le due
// giocatrici che se la scambiano di mano. Nel campo dopo la palla è di
// quella delle due che non ce l'aveva — è il senso stesso della consegna.
src.filter(e => e.type === 'handoff').forEach(ho => {
// La consegna passa la palla UNA VOLTA SOLA. Il simbolo resta sul campo
// nei fotogrammi successivi (è successo, si vede), ma segnato come già
// avvenuto: senza questo, ad ogni campo la palla rimbalzava avanti e
// indietro fra le due giocatrici.
if (ho.fatta) return;
const offs = points.filter(p => p.type === 'off');
const chiHa = offs.find(p => p.hasBall);
if (!chiHa) return;
// La consegna vale se chi ha la palla è lì vicino al simbolo: la palla va
// alla PIÙ VICINA fra le altre. Prima si guardavano solo le due più
// vicine in assoluto: bastava una terza giocatrice di passaggio più
// vicina del ricevente perché la coppia risultasse sbagliata e la palla
// restasse ferma.
const dist = (p) => Math.hypot(p.x - ho.x, p.y - ho.y);
if (dist(chiHa) > 0.35) return;
const altre = offs.filter(p => p !== chiHa).map(p => ({ p, d: dist(p) })).sort((a, b) => a.d - b.d);
if (!altre.length || altre[0].d > 0.35) return;
offs.forEach(p => { p.hasBall = false; });
altre[0].p.hasBall = true;
ho.fatta = true;
});
// 2) Chi aveva una freccia di spostamento finisce dove la freccia arriva.
// L'aggancio a chi parte usa un raggio largo come per il passaggio: un
// movimento SPEZZATO si disegna con più click e il primo cade quasi
// sempre un po' fuori dalla giocatrice — col raggio stretto non la si
// trovava e nel campo dopo restava ferma dov'era.
// Il TIRO è escluso: la linea va verso il canestro, ma la tiratrice non ci
// corre dentro — nel fotogramma dopo resta dove ha tirato.
// NB: la consegna resta fra i "punti" solo per non farsi trattare come una
// freccia qui sotto (non ha un punto d'arrivo); viene però tolta dal
// fotogramma successivo alla fine della funzione.
arrows.filter(a => a.type !== 'pass' && a.type !== 'shot').forEach(a => {
const partenza = (Array.isArray(a.pts) && a.pts.length) ? a.pts[0] : { x: a.x, y: a.y };
const arrivo = (Array.isArray(a.pts) && a.pts.length > 1) ? a.pts[a.pts.length - 1] : { x: a.x2, y: a.y2 };
const mover = perRiferimento(a.daRif, points, partenza) || nearest(partenza.x, partenza.y, points, NEAR_PASS);
if (mover) { mover.x = arrivo.x; mover.y = arrivo.y; }
});
// La CONSEGNA è un passaggio: vive nel diagramma in cui avviene e nel campo
// successivo non c'è più — esattamente come la freccia del passaggio. Quello
// che resta è il suo effetto: la palla in mano all'altra giocatrice.
return points.filter(pt => pt.type !== 'handoff');
}
// Propaga il fotogramma successivo in avanti, fino alla prima casella che il
// coach ha già aperto/modificato di suo (slot.auto === false): quelle non si
// toccano mai. Serve perché prima la continuità scattava SOLO al momento in
// cui si apriva la casella successiva — sul foglio (e in stampa) le caselle
// 2/3/4 restavano quindi bianche finché non le si apriva una per una, da cui
// "dopo ogni diagramma il secondo campo è pulito, non riprende quanto fatto
// nel primo diagramma". Ora si aggiornano dal vivo mentre si disegna.
function propagatePlaybookFrames(row, fromIndex) {
for (let i = fromIndex; i < row.slots.length - 1; i++) {
const cur = row.slots[i], next = row.slots[i + 1];
const derived = playbookNextFrameElements(cur.elements);
const nextIsUntouched = next.auto === true || !(next.elements && next.elements.length);
if (nextIsUntouched) {
next.elements = derived;
next.campoMode = cur.campoMode || 'half';
next.ballManual = false;
next.autoBallLabel = playbookBallLabel(derived);
markPlaybookSlotAuto(next);
} else {
// Casella già disegnata dal coach: il SUO disegno non si tocca, ma chi
// ha la palla sì — dopo un passaggio da 2 a 1 il pallone è di 1, e in
// tutti i campi successivi deve risultare cerchiata 1, non 2. Prima qui
// c'era un "break": la catena si fermava e il secondo campo restava
// congelato col passatore ancora cerchiato.
applyInheritedFrame(next, derived, false);
}
refreshPlaybookSlotThumb(row.id, i + 1, next);
}
}
function refreshPlaybookSlotThumb(rowId, slotIndex, slot) {
const img = document.querySelector(`.pb-slot[data-row-id="${rowId}"][data-slot-index="${slotIndex}"] .pb-slot-thumb`);
if (img) img.src = renderSchemaThumbnail(slot, slot.campoMode || 'half');
if (rowId === pbEditingRowId) {
const variantImg = document.querySelector(`#pbEditorVariants .pb-editor-variant-cell:nth-child(${slotIndex + 1}) img`);
if (variantImg) variantImg.src = renderSchemaThumbnail(slot, slot.campoMode || 'half', true);
}
}
function syncExpandedPlaybookSlot() {
if (!pbEditingRowId || pbEditingSlotIndex == null) return;
const row = activePlaybookRows().find(r => r.id === pbEditingRowId);
if (!row) return;
const slot = row.slots[pbEditingSlotIndex];
slot.elements = JSON.parse(JSON.stringify(pbState.elements));
slot.campoMode = pbState.mode;
if (Array.isArray(pbState.rimossi)) slot.rimossi = pbState.rimossi.slice();
// "Toccata dal coach" = il disegno è DIVERSO da quello che ci aveva messo
// la propagazione automatica. Prima bastava APRIRE la casella per marcarla
// come toccata (redrawDiagram sincronizza ad ogni ridisegno, anche senza un
// solo click sul campo): da quel momento non seguiva più il fotogramma
// precedente e restava congelata — ed è il motivo per cui, disegnando nel
// primo campo un passaggio da 2 a 1, nel secondo campo restava cerchiato il 2.
slot.auto = (slot.autoSig != null && slot.autoSig === playbookFrameSignature(slot.elements));
// Palla decisa a mano: se in questa casella chi ha la palla è DIVERSO da
// quello che ci aveva messo la propagazione (slot.autoBallLabel), vuol dire
// che il coach l'ha spostata o tolta lui — da quel momento la sua scelta
// non viene più sovrascritta dai ritocchi ai campi precedenti. Vale per
// qualsiasi strada: tool 🏀 Palla, gomma sulla giocatrice, ridisegno.
if (!slot.auto && slot.autoBallLabel !== undefined) {
if (playbookBallLabel(slot.elements) !== slot.autoBallLabel) slot.ballManual = true;
}
refreshPlaybookSlotThumb(pbEditingRowId, pbEditingSlotIndex, slot);
propagatePlaybookFrames(row, pbEditingSlotIndex);
}
function onPlaybookEditorNameInput(value) {
const row = activePlaybookRows().find(r => r.id === pbEditingRowId);
if (!row) return;
row.nome = value;
const nameEl = document.querySelector(`.pb-row[data-row-id="${pbEditingRowId}"] .pb-row-name`);
if (nameEl) nameEl.textContent = value;
}
// Modifica diretta sul foglio (contenteditable): il coach clicca sul nome
// del gioco stampato sul foglio e digita — nessun form separato in mezzo.
// Niente render completo ad ogni tasto (perderebbe il focus/il cursore):
// scrive solo nel modello dati e, se il pannello editor di quella riga è
// aperto, tiene sincronizzato anche il campo #pbSchemaName.
function onPlaybookRowNameLiveEdit(rowId, el) {
const row = activePlaybookRows().find(r => r.id === rowId);
if (!row) return;
row.nome = (el.textContent || '');
if (pbEditingRowId === rowId) {
const nameField = document.getElementById('pbSchemaName');
if (nameField && nameField.value !== row.nome) nameField.value = row.nome;
}
}
/* SPOSTA UN GIOCO SU O GIU' NEL FOGLIO. Stessa logica del Playbook per
* iPad: si scambia con quello sopra o sotto, e il foglio si ridisegna. E'
* l'ordine con cui il playbook viene stampato e sfogliato. */
function spostaGiocoNelFoglio(rowId, verso) {
const righe = activePlaybookRows();
const i = righe.findIndex(r => r.id === rowId);
const j = i + verso;
if (i < 0 || j < 0 || j >= righe.length) return;
const x = righe[i]; righe[i] = righe[j]; righe[j] = x;
scheduleAutosave();
renderPlaybookSection();
}
/* La serie scritta sulla riga. Testo libero: si salva com'e', e l'elenco dei
* suggerimenti (pbSerieUsate) si aggiorna da solo con quelle gia' usate. */
function onPlaybookRowSerieEdit(rowId, el) {
const row = activePlaybookRows().find(r => r.id === rowId);
if (!row) return;
row.serie = (el.value || '').slice(0, 40);
scheduleAutosave();
aggiornaSerieUsate();
}
/* Tutte le serie gia' scritte, in questa squadra e nelle altre: cosi' se
* scrivi "Pick" ti viene offerto "Pick and Roll" che avevi usato a ottobre,
* e non finisci con tre modi diversi di scrivere la stessa cosa. */
/* LE SEZIONI DI UN GIOCO. Un gioco puo' stare in PIU' sezioni: si scrivono
* separate da virgola ("Transizione, Pick Centrale") e nella libreria quel
* gioco compare sotto tutte. E' come lo tiene in testa un allenatore: un
* pick centrale in transizione e' tutte e due le cose, e lo cerca da tutte
* e due le parti. */
function sezioniDi(row) {
return String((row && row.serie) || '').split(',').map(x => x.trim()).filter(Boolean);
}
function serieUsate() {
const viste = new Set();
const prendi = r => sezioniDi(r).forEach(x => viste.add(x));
(DB.leagues || []).forEach(lg => (lg.teams || []).forEach(tm => (tm.playbookRows || []).forEach(prendi)));
((DB.scratchPlaybookRows) || []).forEach(prendi);
return [...viste].sort((a, b) => a.localeCompare(b));
}
function aggiornaSerieUsate() {
const dl = document.getElementById('pbSerieUsate');
if (!dl) return;
dl.innerHTML = serieUsate().map(x => ` `).join('');
}
function onPlaybookSlotNoteLiveEdit(rowId, slotIndex, el) {
const row = activePlaybookRows().find(r => r.id === rowId);
if (!row) return;
row.slots[slotIndex].note = (el.textContent || '');
if (pbEditingRowId === rowId && pbEditingSlotIndex === slotIndex) {
const noteField = document.getElementById('pbSchemaNote');
if (noteField && noteField.value !== row.slots[slotIndex].note) noteField.value = row.slots[slotIndex].note;
}
}
function onPlaybookEditorGroupChange(value) {
const row = activePlaybookRows().find(r => r.id === pbEditingRowId);
if (!row) return;
row.gruppo = value;
renderPlaybookSection();
}
function onPlaybookEditorNoteInput(value) {
const row = activePlaybookRows().find(r => r.id === pbEditingRowId);
if (!row || pbEditingSlotIndex == null) return;
row.slots[pbEditingSlotIndex].note = value;
const noteEl = document.querySelector(`.pb-slot[data-row-id="${pbEditingRowId}"][data-slot-index="${pbEditingSlotIndex}"] .pb-slot-note`);
if (noteEl) noteEl.textContent = value;
}
function onPlaybookCourtModeChange(value) {
const row = activePlaybookRows().find(r => r.id === pbEditingRowId);
if (row && pbEditingSlotIndex != null) row.slots[pbEditingSlotIndex].campoMode = value;
syncExpandedPlaybookSlot();
}
function duplicatePlaybookSchemaFromEditor() {
const row = activePlaybookRows().find(r => r.id === pbEditingRowId);
if (!row || pbEditingSlotIndex == null) return;
const slot = row.slots[pbEditingSlotIndex];
const newRow = defaultPlaybookRow(row.nome + t('common.copySuffix'), row.gruppo);
newRow.slots[0] = { elements: JSON.parse(JSON.stringify(slot.elements || [])), campoMode: slot.campoMode || 'half', note: slot.note || '' };
activePlaybookRows().push(newRow);
renderPlaybookSection();
toast(t('toast.schemaDuplicated'));
}
function deletePlaybookSchemaFromEditor() {
const row = activePlaybookRows().find(r => r.id === pbEditingRowId);
if (!row || pbEditingSlotIndex == null) return;
showConfirmModal(t('confirm.clearCourt'), () => {
row.slots[pbEditingSlotIndex] = emptyPlaybookSlot();
pbState.elements = []; pbState.selectedIndex = -1; pbState.pendingPath = null;
redrawDiagram(pbState);
const noteEl = document.getElementById('pbSchemaNote');
if (noteEl) noteEl.value = '';
renderPlaybookSection();
});
}
function deletePlaybookRow(rowId) {
showConfirmModal(t('confirm.deleteSchema'), () => {
const rows = activePlaybookRows();
const idx = rows.findIndex(r => r.id === rowId);
if (idx !== -1) rows.splice(idx, 1);
if (pbEditingRowId === rowId) { pbEditingRowId = null; pbEditingSlotIndex = null; }
renderPlaybookSection();
});
}
// Foglio stampabile del Playbook: griglia 4 diagrammi x riga, una riga per
// gioco (le sue 4 varianti/opzioni), con logo/nome squadra in testa —
// pensato per essere stampato e dato in mano alle giocatrici, stessa
// struttura del foglio di riferimento ITALBASKET WOMAN. Se in Video è
// impostata una "Preparazione avversario" (team.videoFocusOpponentId), la
// pagina usa lo stesso letterhead blu VS dello Scouting Report (logo club +
// "Squadra VS Avversario", es. Italia vs Rep. Ceca) invece della semplice
// intestazione bianca — la stessa preparazione vale per tutta la scheda.
// Logo dell'avversaria: gli avversari dello Scouting Report sono record della
// squadra (nome + info partita), i loghi invece stanno sulle caselle della
// lega — si aggancia il logo per nome, così il logo caricato una volta nella
// pagina Squadre della lega compare anche in testa al foglio schemi.
function opponentLogoFromLeague(opp, league) {
if (!opp || !league) return '';
const key = (opp.name || '').trim().toLowerCase();
if (!key) return '';
const slot = (league.teams || []).find(x => x && (x.name || '').trim().toLowerCase() === key);
return (slot && slot.logo) || '';
}
// Intestazione del foglio schemi: NOI (logo + nome) — VS — AVVERSARIA (logo o
// bandiera + nome), e al centro il campionato con data, luogo e girone della
// partita. Senza avversario impostato resta la stessa banda, con il
// campionato a destra al posto della squadra avversaria.
function playbookLetterheadHtml(team, opp, league) {
const compMain = (opp && opp.competizione) || (league ? league.name : '');
const compSub = (opp && opp.competizione && league && league.name && opp.competizione !== league.name) ? league.name : '';
const nostra = { badge: team, eyebrow: t('playbook.title'), title: (team && team.name) || '', sub: compMain };
// Senza avversaria impostata: a destra resta il campionato, com'era prima.
if (!opp) {
return sheetLetterheadHtml(nostra, null, { title: compMain, eyebrow: t('print.pbChampionship') },
{ foglio: 'playbook', teamId: (team && team.id) || '' });
}
ensureOpponentMatchInfo(opp);
const avversaria = { name: opp.name || '', flag: opp.flag || '', logo: opponentLogoFromLeague(opp, league) || '' };
const metaParts = [];
if (opp.dataPartita) metaParts.push('📅 ' + escapeHtml(opp.dataPartita));
if (opp.ora) metaParts.push('🕐 ' + escapeHtml(opp.ora));
if (opp.sede) metaParts.push('📍 ' + escapeHtml(opp.sede));
const destra = [];
if (opp.girone) destra.push(escapeHtml(opp.girone));
if (compSub) destra.push(escapeHtml(compSub));
// Stessa fascia + stessa barra grigia dello Scouting Report e del Personnel.
const ctx = { foglio: 'playbook', teamId: ((team && team.id) || '') + '~' + (opp.id || '') };
return sheetLetterheadHtml(nostra, t('relazione.vsLabel'), { badge: avversaria, title: opp.name || '' }, ctx)
+ `
${sheetHeadField(ctx, 'bar.sx', metaParts.length ? metaParts.join(' ') : ' ', 'mb-txt', true)}
${sheetHeadField(ctx, 'bar.dx', destra.length ? destra.join(' · ') : ' ', 'mb-txt mb-txt-r', true)}
`;
}
function printPlaybookSheet() {
const rows = activePlaybookRows().filter(r => r.nome || r.slots.some(s => s.elements && s.elements.length));
const extraForGuard = activePlaybookExtraPage();
const hasExtraContent = extraForGuard.active && (extraForGuard.content || '').trim();
// Niente giochi disegnati ma la pagina libera ha contenuto: si stampa
// comunque solo quella (con la sua intestazione), invece di bloccare tutto.
if (!rows.length && !hasExtraContent) { toast(t('toast.noSchemesToPrint')); return; }
const team = activeTeam();
const league = activeLeague();
const opp = team ? videoFocusOpponent(team) : null;
const PER_PAGE = PB_ROWS_PER_PAGE; // stessa paginazione a 4 righe/foglio della vista a video
const rowLogoHtml = team && team.logo
? ` `
: '
';
const headerHtml = playbookLetterheadHtml(team, opp, league);
// Campo vuoto (senza disegno) usato per completare la griglia: si genera
// una volta sola e si riusa, invece di ridisegnarlo per ogni casella.
const emptyCourtSrc = renderSchemaThumbnail({ elements: [] }, 'half', true);
const cellHtml = (slot, rowName) => {
const drawn = !!(slot && slot.elements && slot.elements.length);
const src = drawn ? renderSchemaThumbnail(slot, slot.campoMode || 'half', true) : emptyCourtSrc;
return ``;
};
// Riga "gioco": nome sopra i 4 diagrammi, nota sotto. Le righe rimaste
// libere sul foglio si stampano comunque, con i campi vuoti e le due righe
// da riempire a penna: così un foglio con un solo gioco disegnato resta un
// foglio schemi normale (campi della stessa misura di sempre) invece di
// gonfiare quei 4 diagrammi a tutta pagina.
const rowHtml = (row) => {
const name = row ? row.nome : '';
const isDef = row && row.gruppo === 'difesa';
const tag = row ? t(isDef ? 'print.pbTagDefense' : 'print.pbTagOffense') : '';
const note = row ? row.slots.map(sl => (sl.note || '').trim()).filter(Boolean).join(' · ') : '';
const slots = row ? row.slots : [null, null, null, null];
const cells = [0, 1, 2, 3].map(k => cellHtml(slots[k], name)).join('');
return `
${rowLogoHtml}
${row ? (isDef ? '🛡️ ' : '⚡ ') + escapeHtml(name) : ''}
${row ? `${escapeHtml(tag)} ` : ''}
${cells}
${escapeHtml(note)}
`;
};
let pagesHtml = '';
for (let i = 0; i < rows.length; i += PER_PAGE) {
const pageRows = rows.slice(i, i + PER_PAGE);
const rowsHtml = pageRows.map(row => rowHtml(row)).join('')
// il foglio si completa sempre a griglia piena: le righe che mancano
// sono campi vuoti pronti da disegnare a mano.
+ Array.from({ length: Math.max(0, PER_PAGE - pageRows.length) }, () => rowHtml(null)).join('');
if (!rowsHtml) continue;
pagesHtml += `
${headerHtml}
${rowsHtml}
`;
}
// Seconda pagina libera (opzionale): stessa intestazione della prima
// pagina (letterhead avversario se impostato, altrimenti quella semplice),
// stampata solo se il coach l'ha attivata e ci ha scritto qualcosa dentro.
const extra = activePlaybookExtraPage();
if (extra.active && (extra.content || '').trim()) {
const extraBodyHtml = escapeHtml(extra.content).replace(/\n/g, ' ');
pagesHtml += `
${headerHtml}
${extraBodyHtml}
`;
}
openPrintPreview(t('print.playbookTitle'), pagesHtml);
}
// -------------------------------------------------------------- PRACTICE
// Piani di allenamento per squadra: sedute → fasi → esercizi, sullo stesso
// impianto di ITALBASKET WOMAN (Piani di allenamento), ma con lo schizzo per
// esercizio che riusa il motore diagrammi già presente (Playbook/Scouting
// Report) invece di un tool di disegno a mano libera separato.
const PRACTICE_DEFAULT_FASI = ['ATTIVAZIONE: PREP FISICO', 'RISCALDAMENTO', 'PARTE CENTRALE', 'SECONDO OBIETTIVO', 'PARTITE'];
// Griglie Terzetti (4 gruppi x 3 giocatrici), Quartetti (3 gruppi x 4) e
// Quintetti (2 gruppi x 5): stesso schema di ITALBASKET WOMAN — ogni riga è
// un gruppo, ogni colonna uno slot nome-giocatrice dentro il gruppo. Testo
// libero (non legato al roster): può servire anche per abbinare giocatrici
// di squadre diverse in allenamenti congiunti.
function practiceDefaultGruppi() {
return {
terzetti: Array.from({ length: 4 }, () => Array(3).fill('')),
quartetti: Array.from({ length: 3 }, () => Array(4).fill('')),
quintetti: Array.from({ length: 2 }, () => Array(5).fill('')),
};
}
// Striscia di 10 mini-campi per schizzi generali della seduta (non legati a un
// esercizio specifico): 8 mezzi campi + 2 campi interi, come in ITALBASKET
// WOMAN, ma disegnati con lo stesso motore diagrammi di Playbook/Practice
// invece di un pennino a mano libera separato.
// 12 caselle (10 mezzo campo + 2 campo intero): prima erano 10 e, dopo aver
// distribuito il foglio su tutta l'altezza A4, restava spazio vuoto TRA le
// caselle invece che riempito da altri diagrammi — 2 in più bastano a
// coprire quello spazio con contenuto vero invece che con solo margini.
function practiceDefaultStriscia() {
return [
{ tipo: 'half', diagram: null }, { tipo: 'half', diagram: null }, { tipo: 'half', diagram: null }, { tipo: 'half', diagram: null },
{ tipo: 'half', diagram: null }, { tipo: 'half', diagram: null }, { tipo: 'half', diagram: null }, { tipo: 'half', diagram: null },
{ tipo: 'half', diagram: null }, { tipo: 'half', diagram: null },
{ tipo: 'full', diagram: null }, { tipo: 'full', diagram: null },
];
}
const PRACTICE_STRIP_MIN_COUNT = 12;
// Sedute create prima di questa versione non hanno ancora gruppi/striscia
// (o ne hanno solo 10, dal formato precedente): completa quel che manca al
// volo, senza far fallire nulla e senza perdere gli schizzi già disegnati
// nelle caselle esistenti.
/* ARCHIVIO DEGLI ALLENAMENTI.
* Un allenamento non vive da solo: appartiene a una stagione, a una squadra,
* a volte al club intero (le sedute che si passano a tutti gli allenatori).
* E lo stesso allenamento puo' stare in PIU' archivi: la seduta di tiro della
* prima squadra e' anche materiale del club. Per questo ogni seduta porta un
* elenco di archivi, non uno solo. */
const PRACTICE_TIPI_ARCHIVIO = ['stagione', 'squadra', 'club'];
/* L'ALLENAMENTO E' TUO, NON DELL'AVVERSARIO.
* Fin qui Practice seguiva la squadra aperta nel menu in alto: se stavi
* preparando una partita, quella era l'AVVERSARIA — e ti ritrovavi il foglio
* dell'allenamento intestato a lei, col suo logo e il suo roster. Un
* allenamento invece lo fai tu, con la tua squadra. Qui si sceglie una volta
* quale, e da li' vengono intestazione, logo e colori; le sedute stesse
* vivono su quella squadra, non su chi capita di avere aperto. */
function squadreAllenabili() {
const lega = activeLeague();
if (!lega) return [];
// Tutte quelle disponibili, comprese le caselle della lega: il coach deve
// poter scegliere la SUA anche quando la lega e' fatta di sole avversarie.
const elenco = (lega.teams || []).slice();
try {
(ensureOppRosterSlots(lega) || []).forEach(x => { if (x && !elenco.some(y => y.id === x.id)) elenco.push(x); });
} catch (e) {}
return elenco;
}
/* QUALE SQUADRA SI STA ALLENANDO.
* Regola sola: o l'hai scelta tu, o e' quella che nella lega e' segnata come
* TUA. Punto. Prima, se non c'era ne' l'una ne' l'altra, ripiegava sulla
* prima squadra della lega o — peggio — su quella aperta nel menu in alto:
* ed e' cosi' che il foglio dell'allenamento usciva intestato REP.CECA
* mentre preparavi la partita contro di lei. Adesso NON INDOVINA PIU': se
* non lo sa, non sceglie, e ti chiede di dirglielo. Meglio una domanda che
* un foglio intestato all'avversaria. */
function squadraDellAllenamento() {
const lega = activeLeague();
const mie = squadreAllenabili();
if (DB.practiceSquadraId) {
const scelta = mie.find(x => x.id === DB.practiceSquadraId);
if (scelta) return scelta;
}
if (lega && lega.ownTeamId) {
const mia = mie.find(x => x.id === lega.ownTeamId);
if (mia) return mia;
}
/* UNA SOLA SQUADRA TUA: non c'e' niente da indovinare, e' quella.
* Il pasticcio del foglio intestato con la squadra sbagliata nasceva
* quando le squadre erano PIU' DI UNA e Practice seguiva il menu in alto.
* Con una sola squadra propria quel rischio non esiste, e senza questo
* ripiego il pulsante "+ Nuova seduta" non faceva assolutamente NULLA:
* nessuna seduta, nessun messaggio. (27 agosto 2026) */
const proprie = lega ? (lega.teams || []) : [];
if (proprie.length === 1) {
DB.practiceSquadraId = proprie[0].id;
return proprie[0];
}
return null;
}
/* Quando la squadra dell'allenamento davvero non si puo' dedurre, il coach
* deve VEDERE perche' non succede niente: messaggio, scomparto giusto e
* il menu a tendina che lampeggia. */
function chiediSquadraAllenamento() {
toast(t('practice.nessunaSquadra'));
try {
const sel = document.querySelector('.arc-barra-squadra .arc-squadra');
if (sel) {
sel.scrollIntoView({ block: 'center', behavior: 'smooth' });
sel.classList.add('arc-squadra-chiama');
setTimeout(() => sel.classList.remove('arc-squadra-chiama'), 2400);
try { sel.focus(); } catch (e) {}
}
} catch (e) {}
}
/* Il logo sul foglio dell'allenamento e' quello della squadra: si mette da
* qui, senza andarlo a cercare nell'anagrafica. Vale per tutti i suoi fogli,
* non solo per questo — e' il logo della squadra, uno solo. */
function metteLogoAllenamento(file) {
const team = squadraDellAllenamento();
if (!team || !file) return;
const lettore = new FileReader();
lettore.onload = () => {
team.logo = lettore.result;
scheduleAutosave();
renderPracticeSection(team);
toast(t('practice.logoMesso', { squadra: team.name || '' }));
};
lettore.readAsDataURL(file);
}
function toglieLogoAllenamento() {
const team = squadraDellAllenamento();
if (!team) return;
team.logo = '';
scheduleAutosave();
renderPracticeSection(team);
}
function cambiaSquadraAllenamento(id) {
DB.practiceSquadraId = id || '';
scheduleAutosave();
renderPracticeSection(squadraDellAllenamento());
}
function ensureArchiviPractice(team) {
if (!Array.isArray(team.archiviPractice)) team.archiviPractice = [];
return team.archiviPractice;
}
function nuovoArchivioPractice() {
const team = squadraDellAllenamento();
if (!team) return;
showPromptModal(t('practice.archivioTitolo'), t('practice.archivioNomePh'), (nome) => {
if (!nome || !nome.trim()) return;
ensureArchiviPractice(team).push({ id: nextId('arc'), nome: nome.trim(), tipo: 'stagione' });
scheduleAutosave();
renderPracticeSection(team);
});
}
function cambiaTipoArchivio(id, tipo) {
const team = squadraDellAllenamento();
if (!team) return;
const a = ensureArchiviPractice(team).find(x => x.id === id);
if (!a) return;
a.tipo = tipo;
scheduleAutosave();
renderPracticeSection(team);
}
function eliminaArchivioPractice(id) {
const team = squadraDellAllenamento();
if (!team) return;
team.archiviPractice = ensureArchiviPractice(team).filter(a => a.id !== id);
// Gli allenamenti NON si toccano: perdono solo l'etichetta.
ensurePractice(team).forEach(s2 => { if (Array.isArray(s2.archivi)) s2.archivi = s2.archivi.filter(x => x !== id); });
if (practiceArchivioAperto === id) practiceArchivioAperto = null;
scheduleAutosave();
renderPracticeSection(team);
}
let practiceArchivioAperto = null; // null = tutti
function apriArchivioPractice(id) {
practiceArchivioAperto = id;
renderPracticeSection(squadraDellAllenamento());
}
function sessioneNegliArchivi(s2, id) { return Array.isArray(s2.archivi) && s2.archivi.indexOf(id) >= 0; }
function togglaArchivioSessione(sessionId, archivioId) {
const team = squadraDellAllenamento();
if (!team) return;
const s2 = ensurePractice(team).find(x => x.id === sessionId);
if (!s2) return;
if (!Array.isArray(s2.archivi)) s2.archivi = [];
const i = s2.archivi.indexOf(archivioId);
if (i >= 0) s2.archivi.splice(i, 1); else s2.archivi.push(archivioId);
scheduleAutosave();
renderPracticeSection(team);
}
/* Il numero della seduta si cambia a mano: chi numera per stagione, chi
* riparte da 1 ad ogni ciclo. Non e' l'app a decidere. */
function cambiaNumeroSessione(sessionId, valore) {
const team = squadraDellAllenamento();
if (!team) return;
const s2 = ensurePractice(team).find(x => x.id === sessionId);
if (!s2) return;
s2.numero = String(valore || '').trim();
scheduleAutosave();
}
function ensurePracticeSessionExtras(s) {
if (!s.gruppi || !s.gruppi.terzetti) s.gruppi = practiceDefaultGruppi();
if (!Array.isArray(s.archivi)) s.archivi = [];
if (!Array.isArray(s.striscia)) s.striscia = practiceDefaultStriscia();
else { while (s.striscia.length < PRACTICE_STRIP_MIN_COUNT) s.striscia.push({ tipo: 'half', diagram: null }); }
return s;
}
function addPracticeSession() {
const team = squadraDellAllenamento();
if (!team) { chiediSquadraAllenamento(); return; }
const list = ensurePractice(team);
list.push({
id: nextId('prs'),
numero: list.length + 1,
data: '', ora: '', luogo: '',
fasi: PRACTICE_DEFAULT_FASI.map(nome => ({ id: nextId('fase'), nome, esercizi: [{ id: nextId('ex'), durata: '', testo: '', diagram: null }] })),
gruppi: practiceDefaultGruppi(),
striscia: practiceDefaultStriscia(),
});
renderPracticeSection(team);
toast(t('toast.newSessionCreated'));
}
function updatePracticeGruppo(sessionId, key, ri, ci, value) {
const team = squadraDellAllenamento();
if (!team) return;
const s = ensurePractice(team).find(x => x.id === sessionId);
if (!s) return;
ensurePracticeSessionExtras(s);
s.gruppi[key][ri][ci] = value;
// Mancava: le altre modifiche (data/ora/luogo, nome fase, esercizi) già
// aggiornavano il "foglio bianco" live sotto ad ogni tasto — i gruppi di
// lavoro no, restavano invisibili lì finché non scattava un render intero
// per un altro motivo. Ora si vede subito anche questa modifica.
refreshPracticeSheet(sessionId);
}
// Righe da visualizzare per una colonna di gruppi: normalmente una riga a
// video = un gruppo. Per i quintetti (transpose) la visualizzazione ruota
// (2 colonne x 5 righe) mantenendo però invariato il modello dati sottostante
// (gruppi.quintetti resta [gruppo][slot]); ri/ci restituiti sono sempre gli
// indici ORIGINALI [gruppo][slot] da usare nei binding onchange.
function practiceGroupsCols(gruppi) {
return [
{ key: 'terzetti', label: t('group.terzetti'), rows: gruppi.terzetti || [], transpose: false },
{ key: 'quartetti', label: t('group.quartetti'), rows: gruppi.quartetti || [], transpose: false },
{ key: 'quintetti', label: t('group.quintetti'), rows: gruppi.quintetti || [], transpose: true },
];
}
function practiceGroupsDisplayRows(col) {
if (!col.transpose) return col.rows.map((row, ri) => row.map((val, ci) => ({ val, ri, ci })));
const groups = col.rows.length;
const slots = groups ? col.rows[0].length : 0;
const out = [];
for (let sIdx = 0; sIdx < slots; sIdx++) {
const rowCells = [];
for (let g = 0; g < groups; g++) rowCells.push({ val: col.rows[g][sIdx], ri: g, ci: sIdx });
out.push(rowCells);
}
return out;
}
function removePracticeSession(sessionId) {
const team = squadraDellAllenamento();
if (!team) return;
showConfirmModal(t('confirm.deleteSession'), () => {
team.practice = ensurePractice(team).filter(s => s.id !== sessionId);
renderPracticeSection(team);
});
}
function updatePracticeField(sessionId, field, value) {
const team = squadraDellAllenamento();
if (!team) return;
const s = ensurePractice(team).find(x => x.id === sessionId);
if (s) s[field] = value;
refreshPracticeSheet(sessionId);
}
// Il "Foglio seduta" (practiceSheetHtml) è sola lettura: possiamo rigenerarlo
// per intero ad ogni tocco dei campi sopra (oninput) senza rischiare di far
// perdere il focus a un input — a differenza del form di modifica, che invece
// resta con un render "pigro" (solo su add/remove) per non interrompere chi
// sta scrivendo in un campo.
/* Rimpicciolisce il contenuto del foglio finche' non ci sta nell'A4.
* Va chiamata dopo ogni ridisegno dell'anteprima e prima di stampare. */
function adattaFoglioAllenamento(radice) {
const pagine = (radice || document).querySelectorAll('.pr-practice-page');
pagine.forEach((pg) => {
const dentro = pg.querySelector('.pr-foglio-fit');
if (!dentro) return;
dentro.style.zoom = '';
const st = getComputedStyle(pg);
const spazio = pg.clientHeight - parseFloat(st.paddingTop || 0) - parseFloat(st.paddingBottom || 0);
if (!(spazio > 0)) return;
let k = 1;
for (let giro = 0; giro < 5; giro++) {
const alto = dentro.scrollHeight * k;
if (alto <= spazio + 0.5) break;
k = Math.max(0.5, k * (spazio / alto) - 0.004);
dentro.style.zoom = String(k);
}
});
}
/* QUANDO SI SCRIVE TROPPO, IL TESTO NON DEVE SPARIRE.
*
* Le caselle dello Scouting Report avevano l'overflow nascosto: scritto un
* capoverso di troppo, il testo in eccesso semplicemente non c'era piu' —
* non a video, non sul foglio, non nel PDF. E non lo diceva nessuno. Su un
* foglio che si consegna allo staff prima di una partita e' il difetto
* peggiore possibile: credi di aver scritto una cosa, e quella cosa non
* arriva. Misurato su una casella piena: mostrava 463 punti di altezza su
* 1987 che ne servivano, cioe' i tre quarti del testo invisibili.
*
* Adesso ogni casella troppo piena si RIMPICCIOLISCE finche' ci sta, una
* casella alla volta — non tutto il foglio, cosi' le altre restano della
* misura giusta. C'e' un limite sotto al quale non si scende (il foglio si
* legge in palestra, non al microscopio): se anche stretto al massimo il
* testo non ci sta, la casella lo dice con un segno in un angolo, invece di
* mangiarselo di nascosto. */
const FIT_MINIMO = 0.58;
function adattaCaselleTroppoPiene(radice) {
const caselle = (radice || document).querySelectorAll('.pr-scouting-page .print-report-box');
caselle.forEach((cs) => {
const dentro = cs.querySelector('.print-report-body') || cs;
if (dentro === cs) return; // niente corpo separato: si lascia stare
dentro.style.zoom = '';
cs.classList.remove('pr-troppo');
const st = getComputedStyle(cs);
const testa = cs.querySelector('h4');
const spazio = cs.clientHeight - (testa ? testa.offsetHeight : 0)
- parseFloat(st.paddingTop || 0) - parseFloat(st.paddingBottom || 0) - 2;
if (!(spazio > 0)) return;
let k = 1;
for (let giro = 0; giro < 6; giro++) {
const alto = dentro.scrollHeight * k;
if (alto <= spazio + 0.5) break;
k = Math.max(FIT_MINIMO, k * (spazio / alto) - 0.004);
dentro.style.zoom = String(k);
if (k <= FIT_MINIMO) break;
}
if (dentro.scrollHeight * k > spazio + 1) cs.classList.add('pr-troppo');
});
}
// Cambiando la larghezza della finestra cambia anche l'altezza del foglio
// (e' un A4, tiene le proporzioni): va rimisurato, o lo zoom resta quello
// vecchio e il contenuto torna a sforare.
let _timerAdattaFoglio = null;
window.addEventListener('resize', () => {
clearTimeout(_timerAdattaFoglio);
_timerAdattaFoglio = setTimeout(() => { adattaFoglioAllenamento(); adattaCaselleTroppoPiene(); }, 140);
});
function refreshPracticeSheet(sessionId) {
/* ERA QUI CHE L'ANTEPRIMA NON SI AGGIORNAVA.
* Da quando Practice sta sulla squadra dell'allenamento e non piu' su
* quella aperta in alto, questa funzione continuava a cercare la seduta
* dentro ad activeTeam(): la cercava nella squadra sbagliata, non la
* trovava, e usciva senza ridisegnare niente. Scrivevi l'esercizio e il
* foglio restava fermo. */
const team = squadraDellAllenamento();
if (!team) return;
const s = ensurePractice(team).find(x => x.id === sessionId);
if (!s) return;
const el = document.getElementById('practiceSheet-' + sessionId);
if (el) el.outerHTML = practiceSheetHtml(s, team);
requestAnimationFrame(() => adattaFoglioAllenamento());
}
function addPracticeFase(sessionId) {
const team = squadraDellAllenamento();
if (!team) return;
const s = ensurePractice(team).find(x => x.id === sessionId);
if (!s) return;
s.fasi = s.fasi || [];
s.fasi.push({ id: nextId('fase'), nome: '', esercizi: [{ id: nextId('ex'), durata: '', testo: '', diagram: null }] });
renderPracticeSection(team);
}
function removePracticeFase(sessionId, faseId) {
const team = squadraDellAllenamento();
if (!team) return;
const s = ensurePractice(team).find(x => x.id === sessionId);
if (!s) return;
s.fasi = (s.fasi || []).filter(f => f.id !== faseId);
renderPracticeSection(team);
}
function updatePracticeFaseName(sessionId, faseId, value) {
const team = squadraDellAllenamento();
if (!team) return;
const s = ensurePractice(team).find(x => x.id === sessionId);
const f = s && (s.fasi || []).find(x => x.id === faseId);
if (f) f.nome = value;
refreshPracticeSheet(sessionId);
}
function addPracticeEsercizio(sessionId, faseId) {
const team = squadraDellAllenamento();
if (!team) return;
const s = ensurePractice(team).find(x => x.id === sessionId);
const f = s && (s.fasi || []).find(x => x.id === faseId);
if (!f) return;
f.esercizi = f.esercizi || [];
f.esercizi.push({ id: nextId('ex'), durata: '', testo: '', diagram: null });
renderPracticeSection(team);
}
function removePracticeEsercizio(sessionId, faseId, exId) {
const team = squadraDellAllenamento();
if (!team) return;
const s = ensurePractice(team).find(x => x.id === sessionId);
const f = s && (s.fasi || []).find(x => x.id === faseId);
if (!f) return;
f.esercizi = (f.esercizi || []).filter(e => e.id !== exId);
renderPracticeSection(team);
}
function updatePracticeEsercizio(sessionId, faseId, exId, field, value) {
const team = squadraDellAllenamento();
if (!team) return;
const s = ensurePractice(team).find(x => x.id === sessionId);
const f = s && (s.fasi || []).find(x => x.id === faseId);
const e = f && (f.esercizi || []).find(x => x.id === exId);
if (e) e[field] = value;
refreshPracticeSheet(sessionId);
}
// ---- Schizzo esercizio: riusa il motore diagrammi condiviso in un modal ----
let praState = createDiagramState('praState', 'praCanvas', 'praToolbar');
// praTarget: { kind:'exercise', sessionId, faseId, exId } oppure
// { kind:'strip', sessionId, idx } — lo stesso modal/canvas serve sia gli
// schizzi per esercizio sia quelli della striscia generale della seduta.
let praTarget = null;
// Scorciatoie da tastiera per il diagramma attualmente visibile (Playbook o
// Practice, quale dei due ha il canvas mostrato in quel momento): Invio
// chiude/conferma il tratto in corso (stesso effetto di "✔ Fine tratto",
// per chi prova istintivamente il tasto Invio invece di cercare il
// bottone — "non si chiude premendo invio" era proprio questo, mancava),
// Esc annulla il tratto in corso, Canc/Backspace rimuove l'elemento
// selezionato, Ctrl/Cmd+Z annulla l'ultimo. Disattive mentre si scrive in
// un campo di testo (Nota, nome schema, ecc.) per non rubare i tasti lì.
function visibleDiagramState() {
for (const state of [pbState, praState]) {
const canvas = document.getElementById(state.canvasId);
if (canvas && canvas.offsetParent !== null) return state;
}
return null;
}
document.addEventListener('keydown', (e) => {
const tag = (document.activeElement && document.activeElement.tagName) || '';
if (tag === 'INPUT' || tag === 'TEXTAREA' || (document.activeElement && document.activeElement.isContentEditable)) return;
// ESC nell'editor degli effetti: smette di disegnare e torna a "✋ Sposta".
if (e.key === 'Escape' && fxEditorTagId && fxStrumento) {
e.preventDefault(); fxStrumentoScegli(null); return;
}
const state = visibleDiagramState();
if (!state) return;
if (e.key === 'Enter') {
if (state.pendingPath) { e.preventDefault(); finishDiagramPath(state); }
} else if (e.key === 'Escape') {
if (state.pendingPath) { e.preventDefault(); state.pendingPath = null; renderDiagramToolbar(state); redrawDiagram(state); }
} else if (e.key === 'Delete' || e.key === 'Backspace') {
if (state.selectedIndex >= 0) { e.preventDefault(); removeSelectedDiagramElement(state); }
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
e.preventDefault(); undoLastDiagramElement(state);
}
});
function praOpenModal(target, diagramObj, mode) {
praTarget = target;
document.getElementById('exerciseDiagramOverlay').classList.add('show');
praState.mode = (diagramObj && diagramObj.mode) || mode || 'half';
document.getElementById('praCourtMode').value = praState.mode;
// initDiagramCanvas attacca gli event listener al canvas SOLO la prima
// volta (state._inited): riaprendo il modal per un altro schizzo basta
// ridimensionare il canvas in base al campoMode salvato, senza reinizializzare
// (altrimenti si accumulerebbero listener duplicati sullo stesso canvas).
if (!praState._inited) {
initDiagramCanvas(praState);
} else {
resizeDiagramCanvas(praState);
}
praState.elements = JSON.parse(JSON.stringify((diagramObj && diagramObj.elements) || []));
praState.selectedIndex = -1; praState.pendingPath = null;
renderDiagramToolbar(praState);
redrawDiagram(praState);
}
function openExerciseDiagram(sessionId, faseId, exId) {
const team = squadraDellAllenamento();
if (!team) return;
const s = ensurePractice(team).find(x => x.id === sessionId);
const f = s && (s.fasi || []).find(x => x.id === faseId);
const e = f && (f.esercizi || []).find(x => x.id === exId);
if (!e) return;
praOpenModal({ kind: 'exercise', sessionId, faseId, exId }, e.diagram, 'half');
}
function openStripDiagram(sessionId, idx) {
// Anche questa cercava la seduta nella squadra aperta in alto invece che in
// quella dell'allenamento: non la trovava e usciva subito, quindi cliccando
// un campo della striscia NON SI APRIVA NIENTE. Da fuori: "si blocca".
const team = squadraDellAllenamento();
if (!team) return;
const s = ensurePractice(team).find(x => x.id === sessionId);
if (!s) return;
ensurePracticeSessionExtras(s);
const cell = s.striscia[idx];
if (!cell) return;
praOpenModal({ kind: 'strip', sessionId, idx }, cell.diagram, cell.tipo === 'full' ? 'full' : 'half');
}
function closeExerciseDiagram() {
document.getElementById('exerciseDiagramOverlay').classList.remove('show');
praTarget = null;
}
function saveExerciseDiagram() {
// Stessa storia in salvataggio: cercava nella squadra sbagliata, non
// trovava la seduta e il disegno finiva nel nulla senza dire niente.
const team = squadraDellAllenamento();
if (!team || !praTarget) { closeExerciseDiagram(); return; }
const s = ensurePractice(team).find(x => x.id === praTarget.sessionId);
const savedDiagram = { mode: praState.mode, elements: JSON.parse(JSON.stringify(praState.elements)) };
if (praTarget.kind === 'exercise') {
const f = s && (s.fasi || []).find(x => x.id === praTarget.faseId);
const e = f && (f.esercizi || []).find(x => x.id === praTarget.exId);
if (e) e.diagram = savedDiagram;
} else if (praTarget.kind === 'strip' && s) {
ensurePracticeSessionExtras(s);
s.striscia[praTarget.idx].diagram = savedDiagram;
}
closeExerciseDiagram();
renderPracticeSection(team);
toast(t('toast.sketchSaved'));
}
function practiceGroupsHtml(s) {
const gruppi = s.gruppi;
return `
${t('practice.workGroupsTitle')}
${practiceGroupsCols(gruppi).map(col => `
${escapeHtml(col.label)}
${practiceGroupsDisplayRows(col).map(row => `
${row.map(cell => ` `).join('')}
`).join('')}
`).join('')}
`;
}
// I mezzi campo prima (2 per riga, invariato); i campi interi poi, affiancati
// nella stessa riga finale — coerenti con il foglio sotto (vedi
// printPracticeStripHtml), dove le due celle "full" restano nella normale
// griglia a 2 colonne invece di occupare una riga intera ciascuna.
function practiceStripRows(striscia) {
const halves = striscia.map((c, i) => ({ c, i })).filter(x => x.c.tipo !== 'full');
const fulls = striscia.map((c, i) => ({ c, i })).filter(x => x.c.tipo === 'full');
const rows = [];
for (let i = 0; i < halves.length; i += 2) rows.push(halves.slice(i, i + 2));
if (fulls.length) rows.push(fulls);
return rows;
}
function practiceStripHtml(s) {
return `
${t('practice.stripTitle')}
${practiceStripRows(s.striscia).map(row => `
${row.map(({ c, i }) => `
${c.diagram && c.diagram.elements && c.diagram.elements.length
? `
`
: `
`}
${c.tipo === 'full' ? t('playbook.full') : t('playbook.half')}
`).join('')}
`).join('')}
`;
}
// Foglio seduta: riepilogo sempre visibile (non dietro a un bottone stampa),
// stesso identico foglio bianco "carta" usato da Scouting Report/Personnel
// (.print-page dentro .live-preview-wrap, con la stessa intestazione logo/
// squadra/lega di printPageHeader) invece dello stile scuro/neon iniziale —
// così a video si vede davvero come un PDF. Si aggiorna da solo ad ogni
// modifica dei campi sopra (vedi refreshPracticeSheet).
function practiceSheetHtml(s, team) {
const league = activeLeague();
const miaVera = squadraDellAllenamento() || team;
// Il nome stampato si puo' riscrivere senza rinominare la squadra: sul
// foglio a volte ci va la denominazione per esteso della societa'.
const nomeStampato = (s.squadraLibera || '').trim() || (miaVera && miaVera.name) || '';
const mia = Object.assign({}, miaVera, { name: nomeStampato });
const titolo = (s.titoloLibero || '').trim() || (t('practice.sessionLabel') + (s.numero == null ? '' : s.numero));
// Il coach: quello scritto qui sopra, altrimenti quello della prima pagina.
const coach = (s.coach || '').trim() || nomeDelCoach();
return `
${practiceLetterheadHtml(mia, league, titolo, coach, s)}
${escapeHtml(t('practice.squadraSulFoglio'))}
${escapeHtml(nomeStampato)}
${escapeHtml(t('practice.titoloLibero'))}
${escapeHtml(s.titoloLibero || '')}
${escapeHtml(t('practice.coachLabel'))}
${escapeHtml(coach)}
${printPageFaseHtml(s.fasi)}
${printPracticeStripHtml(s.striscia || [])}
${printPracticeGroupsHtml(s.gruppi || {})}
`;
}
// Foglio stampabile Practice — stessa struttura del foglio di riferimento
// ITALBASKET WOMAN (ALLENAMENTO N. X): fasi/esercizi a sinistra, striscia
// diagrammi a due colonne a destra, gruppi di lavoro in fondo, una pagina
// per seduta. Il "Foglio seduta" scuro sopra è la versione sempre visibile
// per lavorare a schermo; questa è la versione carta per la stampa/PDF.
function printPageFaseHtml(fasi) {
const list = (fasi || []).filter(f => (f.nome || '').trim() || (f.esercizi || []).some(e => (e.testo || '').trim() || (e.durata || '').trim()));
if (!list.length) return `${t('empty.noSessions')}
`;
// Ogni fase avvolta nel proprio "print-fase-group": serve a
// .print-practice-left (display:flex;flex-direction:column;justify-content:
// space-between) per distribuire le fasi su tutta l'altezza disponibile del
// foglio A4 invece di accumularle tutte in alto lasciando spazio vuoto sotto.
return list.map(f => {
const esercizi = (f.esercizi || []).filter(e => (e.testo || '').trim() || (e.durata || '').trim());
return `` +
`
${escapeHtml(f.nome || '')}
` +
(esercizi.length
? esercizi.map(e => `
${e.durata ? `${escapeHtml(e.durata)} ` : ''}${escapeHtml(e.testo || '')}
`).join('')
: `
—
`)
+ `
`;
}).join('');
}
// I 10 campi "mezzo campo" prima (2 per riga, come sempre), POI i 2 "campo
// intero" ruotati di 90° (verticali, come un campo "in piedi") e affiancati
// nell'ultima riga della stessa griglia a 2 colonne, invece di restare piccoli
// e sdraiati come sarebbero se disegnati in orizzontale in una colonna stretta.
function printPracticeStripHtml(striscia) {
const list = striscia || [];
const halves = list.filter(c => c.tipo !== 'full');
const fulls = list.filter(c => c.tipo === 'full');
const halfCellsHtml = halves.map(c => `
`).join('');
const fullCellsHtml = fulls.map(c => `
`).join('');
return halfCellsHtml + fullCellsHtml;
}
function printPracticeGroupsHtml(gruppi) {
const col = (label, rows) => `
${escapeHtml(label)}
${(rows || []).map(row => `
${row.map(v => `${escapeHtml(v || '')} `).join('')}
`).join('')}
`;
return `
${col(t('group.terzetti'), gruppi.terzetti)}
${col(t('group.quartetti'), gruppi.quartetti)}
${col(t('group.quintetti'), gruppi.quintetti)}
`;
}
/* LA STAMPA DEVE DARE LO STESSO IDENTICO FOGLIO DELL'ANTEPRIMA.
* Prima questa funzione era rimasta indietro: prendeva activeTeam() (cioe'
* la squadra che si sta STUDIANDO, non quella che si allena), usava la
* vecchia intestazione senza allenatore ne' data/orario/luogo, e soprattutto
* non rimpiccioliva niente — con un allenamento lungo la tabella dei gruppi
* usciva dal foglio. Ora stampa esattamente il foglio dell'anteprima. */
/* IL FOGLIO DEL TIRO: la stagione su un A4.
* Sopra la tabella — una riga per player, una colonna per voce, con
* realizzati/tentati e la percentuale — e sotto la curva dell'andamento
* della squadra. Stessa intestazione e stessa grafica degli altri fogli,
* disegnata dalla stessa funzione della curva a video: quello che vedi e'
* quello che stampi. */
function tiroTotaliStagione(team) {
const tr = ensureTiro(team);
const players = tiroPlayers(team);
const perPlayer = players.map(p => {
const celle = tr.voci.map(v => {
let T = 0, R = 0;
tr.sedute.forEach(sd => {
const c = (sd.righe[p.id] || {})[v.id]; if (!c) return;
const tt = parseInt(c.t, 10), rr = parseInt(c.r, 10);
if (isFinite(tt) && tt > 0) { T += tt; R += (isFinite(rr) ? rr : 0); }
});
return { voce: v, T, R, pc: tiroPerc(T, R) };
});
const T = celle.reduce((a, c) => a + c.T, 0), R = celle.reduce((a, c) => a + c.R, 0);
return { p, celle, T, R, pc: tiroPerc(T, R) };
});
const perVoce = tr.voci.map((v, i) => {
const T = perPlayer.reduce((a, r) => a + r.celle[i].T, 0);
const R = perPlayer.reduce((a, r) => a + r.celle[i].R, 0);
return { voce: v, T, R, pc: tiroPerc(T, R) };
});
const T = perVoce.reduce((a, c) => a + c.T, 0), R = perVoce.reduce((a, c) => a + c.R, 0);
return { voci: tr.voci, players, perPlayer, perVoce, T, R, pc: tiroPerc(T, R), sedute: tr.sedute.length };
}
/* LA SCHEDA DEL TIRO DI UNA SINGOLA PLAYER.
* Il foglio di squadra dice chi tira meglio; questa dice come sta andando
* LEI. Foto, numero e nome come nella scheda del Personnel, la tabella
* delle sue voci con i totali dell'anno, e la sua curva. Una pagina a
* testa: e' il foglio che si consegna in mano alla giocatrice. */
function tiroSchedaPlayerHtml(team, league, riga, coach) {
const p = riga.p;
const foto = (p.photo || '').trim();
const bio = [roleLabel(p.role), p.height ? p.height + ' cm' : '', p.birthYear || ''].filter(Boolean).join(' · ');
const cv = document.createElement('canvas'); cv.width = 1500; cv.height = 430;
disegnaTiroCurva(cv.getContext('2d'), cv.width, cv.height, tiroSerie(team, p.id, 'tutte'), true);
let curva = '';
try { curva = cv.toDataURL('image/png'); } catch (e) { curva = ''; }
const cella = (c) => c.T > 0 ? `${c.R}/${c.T} ${c.pc}% ` : '— ';
return `
${practiceLetterheadHtml(team, league, t('tiro.schedaTitolo'), coach, { data: '', ora: '', luogo: '' })}
${foto ? `
` : '
— '}
${escapeHtml((p.number ? '#' + p.number + ' ' : '') + String(p.name || '').toUpperCase())}
${bio ? `
${escapeHtml(bio)}
` : ''}
${riga.pc == null ? '—' : riga.R + '/' + riga.T + ' ' + riga.pc + '%'}
${escapeHtml(t('tiro.totaleAnno'))}
${escapeHtml(t('tiro.voce'))} ${escapeHtml(t('tiro.realizzati'))} / ${escapeHtml(t('tiro.tentati'))} %
${riga.celle.map(c => `
${escapeHtml(c.voce.nome)}
${c.T > 0 ? c.R + '/' + c.T : '— '}
${c.pc == null ? '— ' : '' + c.pc + '% '} `).join('')}
${escapeHtml(t('tiro.totale'))}
${riga.T > 0 ? riga.R + '/' + riga.T : '—'}
${riga.pc == null ? '—' : riga.pc + '%'}
${escapeHtml(t('tiro.andamentoSuo'))}
${curva ? `
` : ''}
`;
}
function printTiroSchede() {
const team = squadraDellAllenamento();
if (!team) { toast(t('practice.nessunaSquadra')); return; }
const dati = tiroTotaliStagione(team);
if (!dati.sedute) { toast(t('tiro.nienteDaStampare')); return; }
const league = activeLeague();
const coach = nomeDelCoach();
// Solo chi ha davvero tirato: una scheda con tutti i trattini non serve a
// nessuno e sarebbero dodici fogli sprecati.
const righe = dati.perPlayer.filter(r => r.T > 0);
if (!righe.length) { toast(t('tiro.nienteDaStampare')); return; }
openPrintPreview(t('tiro.schedaTitolo'), righe.map(r => tiroSchedaPlayerHtml(team, league, r, coach)).join(''));
}
function printTiroSheet() {
const team = squadraDellAllenamento();
if (!team) { toast(t('practice.nessunaSquadra')); return; }
const dati = tiroTotaliStagione(team);
if (!dati.sedute) { toast(t('tiro.nienteDaStampare')); return; }
const league = activeLeague();
const coach = nomeDelCoach();
const cella = (c) => c.T > 0 ? `${c.R}/${c.T} ${c.pc}% ` : '— ';
// la curva della squadra, disegnata su una tela e infilata come immagine
const cv = document.createElement('canvas'); cv.width = 1500; cv.height = 460;
disegnaTiroCurva(cv.getContext('2d'), cv.width, cv.height, tiroSerie(team, 'squadra', 'tutte'), true);
let curva = '';
try { curva = cv.toDataURL('image/png'); } catch (e) { curva = ''; }
const pagina = `
${practiceLetterheadHtml(team, league, t('tiro.foglioTitolo'), coach, { data: '', ora: '', luogo: '' })}
${escapeHtml(t('tiro.seduteFatte', { n: dati.sedute }))}
${dati.pc == null ? '—' : dati.R + '/' + dati.T + ' ' + dati.pc + '%'}
${escapeHtml(t('tiro.player'))}
${dati.voci.map(v => `${escapeHtml(v.nome)} `).join('')}
${escapeHtml(t('tiro.totale'))}
${dati.perPlayer.map(r => `
${escapeHtml((r.p.number ? '#' + r.p.number + ' ' : '') + (r.p.name || ''))}
${r.celle.map(c => `${cella(c)} `).join('')}
${cella(r)} `).join('')}
${escapeHtml(t('tiro.squadra'))}
${dati.perVoce.map(c => `${cella(c)} `).join('')}
${cella(dati)}
${escapeHtml(t('tiro.andamentoSquadra'))}
${curva ? `
` : ''}
`;
openPrintPreview(t('tiro.foglioTitolo'), pagina);
}
function printPracticeSheet() {
const team = squadraDellAllenamento();
if (!team) { toast(t('practice.nessunaSquadra')); return; }
const tutte = ensurePractice(team);
tutte.forEach(ensurePracticeSessionExtras);
// Se si sta guardando un archivio si stampa quello che si ha davanti.
let sessions = practiceArchivioAperto
? tutte.filter(x => sessioneNegliArchivi(x, practiceArchivioAperto))
: tutte;
if (!sessions.length) { toast(t('toast.noSessionsToPrint')); return; }
/* NIENTE FOGLI BIANCHI.
* Si stampavano TUTTE le sedute della squadra, comprese quelle appena
* create e mai compilate — e la prima la crea il programma da solo. Cosi'
* un allenamento scritto usciva su due pagine, la seconda completamente
* vuota: carta buttata e, in un PDF da mandare in giro, un foglio che
* sembra un errore. Adesso le sedute senza niente dentro si saltano. Se
* pero' sono vuote TUTTE (magari il coach vuole proprio il foglio in
* bianco da riempire a penna) si stampa comunque, che e' il caso in cui
* quel foglio serve davvero. */
const sessioneHaRoba = (s) => {
if ((s.titoloLibero || '').trim() || (s.data || '').trim() || (s.ora || '').trim() || (s.luogo || '').trim()) return true;
if ((s.fasi || []).some(f => (f.esercizi || []).some(e => (e.testo || '').trim() || (e.durata || '').toString().trim() || e.diagram))) return true;
if ((s.striscia || []).some(c => c && c.diagram)) return true;
const g = s.gruppi || {};
if (['terzetti', 'quartetti', 'quintetti'].some(k => (g[k] || []).some(riga => (riga || []).some(c => (c || '').trim())))) return true;
return false;
};
const piene = sessions.filter(sessioneHaRoba);
if (piene.length) sessions = piene;
const league = activeLeague();
const pagesHtml = sessions.map(s => {
const nomeStampato = (s.squadraLibera || '').trim() || team.name || '';
const mia = Object.assign({}, team, { name: nomeStampato });
const titolo = (s.titoloLibero || '').trim() || (t('practice.sessionLabel') + (s.numero == null ? '' : s.numero));
const coach = (s.coach || '').trim() || nomeDelCoach();
return `
${practiceLetterheadHtml(mia, league, titolo, coach, s)}
${printPageFaseHtml(s.fasi)}
${printPracticeStripHtml(s.striscia)}
${printPracticeGroupsHtml(s.gruppi)}
`;
}).join('');
openPrintPreview(t('print.practiceTitle'), pagesHtml);
}
// Foglio stampabile "Rose Avversarie": una pagina per squadra compilata
// (nome e/o almeno un player), stessa tabella semplice N./Nome/Ruolo usata
// per le convocazioni — le 16-slot-14 vuote non generano pagine, altrimenti
// sarebbe quasi sempre un archivio da 224 caselle bianche da stampare.
function buildAvversariePageHtml(league) {
const slots = ensureOppRosterSlots(league).filter(s => (s.name && s.name.trim()) || s.players.some(p => p && p.name && p.name.trim()));
if (!slots.length) {
return `${escapeHtml(t('avversarie.noTeamsCompiled'))}
`;
}
return slots.map(slot => {
const players = slot.players.filter(p => p && (p.number || p.name || p.role));
const rows = players.length
? players.map(p => `${escapeHtml(p.number || '')} ${escapeHtml(p.name || '')} ${escapeHtml(roleLabel(p.role))} `).join('')
: `${escapeHtml(t('relazione.noOppPlayersYet'))} `;
const flagPrefix = slot.flag ? escapeHtml(slot.flag) + ' ' : '';
return `
${flagPrefix}${escapeHtml(slot.name || t('avversarie.slotNamePh'))}
${escapeHtml(t('print.avvColNum'))} ${escapeHtml(t('print.avvColName'))} ${escapeHtml(t('print.avvColRole'))} ${rows}
`;
}).join('');
}
function printAvversarie() {
const league = activeLeague();
if (!league) return;
openPrintPreview(t('avversarie.title'), buildAvversariePageHtml(league));
}
// Foglio stampabile "Calendario": tutti gli impegni (manuali + importati dal
// calendario di lega, vedi combinedFixtures) in tabella — stessa tabella
// .print-fixture-table già predisposta in CSS ma finora mai usata.
function buildCalendarPageHtml(team, league) {
const fixtures = combinedFixtures(team);
const rows = fixtures.length
? fixtures.map(f => `${escapeHtml(f.date || '—')} ${escapeHtml(f.competition || '—')} ${f.home ? '🏠' : '🚗'} ${escapeHtml(f.opponent || '—')} `).join('')
: `${escapeHtml(t('toast.noFixturesToPrint'))} `;
return `
${printPageHeader(team, league, t('print.calendarTitle'))}
${escapeHtml(t('print.fxColDate'))} ${escapeHtml(t('print.fxColComp'))} ${escapeHtml(t('print.fxColOpp'))} ${rows}
`;
}
function printCalendar() {
const team = activeTeam();
if (!team) return;
const league = activeLeague();
openPrintPreview(t('print.calendarTitle'), buildCalendarPageHtml(team, league));
}
// Foglio stampabile "Elenco Tag": tutti i tag della clip attiva, ordinati
// per tempo, stessa tabella semplice delle Rose Avversarie.
function buildTagListPageHtml(team, clip) {
const league = activeLeague();
const rows = (clip && clip.tags && clip.tags.length)
? clip.tags.slice().sort((a, b) => a.start - b.start).map(tag => {
const sideLabel = tag.side === 'attacco' ? t('common.attack') : tag.side === 'difesa' ? t('common.defense') : '';
const mainText = tag.label
? escapeHtml(tag.label) + (tag.playerLabel ? ' · ' + escapeHtml(tag.playerLabel) : '')
: (tag.playerLabel ? escapeHtml(tag.playerLabel) : escapeHtml(t('tag.toClassify')));
return `${fmtTime(tag.start)} – ${fmtTime(tag.end)} ${escapeHtml(sideLabel)} ${mainText} `;
}).join('')
: `${escapeHtml(t('empty.noTagsYet'))} `;
return `
${printPageHeader(team, league, t('video.tagListTitle'))}
${escapeHtml(t('print.tagListColTime'))} ${escapeHtml(t('print.tagListColSide'))} ${escapeHtml(t('print.tagListColTag'))} ${rows}
`;
}
function printTagList() {
const team = activeTeam();
if (!team) return;
const clip = activeVideoClipForTeam();
openPrintPreview(t('video.tagListTitle'), buildTagListPageHtml(team, clip));
}
function barraArchiviHtml(team, quanteInTutto) {
const archivi = team ? ensureArchiviPractice(team) : [];
const eti = { stagione: 'practice.tipoStagione', squadra: 'practice.tipoSquadra', club: 'practice.tipoClub' };
const chip = (id, testo, n) => `${escapeHtml(testo)}${n} `;
const sessioni = team ? ensurePractice(team) : [];
const mie = squadreAllenabili();
const scelta = squadraDellAllenamento();
const selettore = `
👕 ${escapeHtml(t('practice.squadraLabel'))}
${escapeHtml(t('practice.scegliSquadra'))}
${mie.map(x => `${escapeHtml(x.name || '—')} `).join('')}
${escapeHtml(t('practice.logoBtn'))}
${scelta && scelta.logo ? `${escapeHtml(t('practice.logoTolto'))} ` : ''}
${escapeHtml(t('practice.squadraHint'))}
`;
return selettore + `
🗂 ${escapeHtml(t('practice.archivioTitolo'))}
${chip(null, t('practice.archivioTutti'), quanteInTutto)}
${archivi.map(a => `
${chip(a.id, a.nome, sessioni.filter(x => sessioneNegliArchivi(x, a.id)).length)}
${PRACTICE_TIPI_ARCHIVIO.map(tp => `${escapeHtml(t(eti[tp]))} `).join('')}
✕
`).join('')}
${escapeHtml(t('practice.archivioNuovo'))}
`;
}
/* =========================================================================
* IL REGISTRO DEL TIRO — la stagione, non la singola seduta
*
* Un allenatore il tiro lo fa fare tutti i giorni, ma il numero della
* singola seduta non dice niente: 6 su 10 puo' essere una giornata storta o
* un passo avanti, non si sa. Quello che dice qualcosa e' la RIGA LUNGA:
* tre mesi di liberi di quella giocatrice, e la curva che sale o che non
* sale. Per questo si segna per player e per voce, e si somma da solo.
*
* Le voci le decide il coach. Si parte con quelle che usano tutti — liberi,
* da 2, da 3 — piu' le zone, ma si possono rinominare, togliere e
* aggiungere: se uno la chiama "catch and shoot" e' giusto che sul suo
* foglio ci sia scritto "catch and shoot".
* ====================================================================== */
const TIRO_VOCI_INIZIALI = [
{ chiave: 'tiro.vLiberi', base: 'Liberi' },
{ chiave: 'tiro.vDa2', base: 'Da 2' },
{ chiave: 'tiro.vDa3', base: 'Da 3' },
{ chiave: 'tiro.vAngolo', base: 'Angolo' },
{ chiave: 'tiro.vAla', base: 'Ala' },
{ chiave: 'tiro.vCentrale', base: 'Centrale' },
{ chiave: 'tiro.vGomito', base: 'Gomito' },
{ chiave: 'tiro.vPost', base: 'Post' },
];
function ensureTiro(team) {
if (!team.tiro || typeof team.tiro !== 'object') team.tiro = {};
if (!Array.isArray(team.tiro.voci) || !team.tiro.voci.length) {
team.tiro.voci = TIRO_VOCI_INIZIALI.map(v => ({ id: nextId('tv'), nome: t(v.chiave) || v.base }));
}
if (!Array.isArray(team.tiro.sedute)) team.tiro.sedute = [];
team.tiro.sedute.forEach(sd => { if (!sd.righe || typeof sd.righe !== 'object') sd.righe = {}; });
return team.tiro;
}
function tiroOggiISO() {
const d = new Date();
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}
function tiroDataBella(iso) {
if (!iso) return '';
try { const d = new Date(iso + 'T00:00:00'); if (!isNaN(d)) return d.toLocaleDateString(localeUI()); } catch (e) {}
return iso;
}
/* Tentati e realizzati di una casella. Si tiene {t, r} e basta: la
* percentuale non si salva mai: si ricalcola, cosi' non puo' mai essere in
* disaccordo con i numeri che la generano. */
function tiroCella(sd, playerId, voceId) {
const r = sd.righe[playerId] = sd.righe[playerId] || {};
const c = r[voceId] = r[voceId] || { t: '', r: '' };
return c;
}
function tiroPerc(tent, real) {
const T = parseInt(tent, 10), R = parseInt(real, 10);
if (!isFinite(T) || T <= 0 || !isFinite(R)) return null;
return Math.round(Math.min(100, Math.max(0, R / T * 100)));
}
function tiroTotaliSeduta(sd, voci, players) {
let T = 0, R = 0;
(players || []).forEach(p => (voci || []).forEach(v => {
const c = (sd.righe[p.id] || {})[v.id]; if (!c) return;
const tt = parseInt(c.t, 10), rr = parseInt(c.r, 10);
if (isFinite(tt) && tt > 0) { T += tt; R += (isFinite(rr) ? rr : 0); }
}));
return { T, R, pc: tiroPerc(T, R) };
}
/* Chi tira: il roster della squadra dell'allenamento. Le righe senza nome
* non compaiono — una griglia con dodici righe vuote non la compila
* nessuno. */
function tiroPlayers(team) {
return (team.players || []).map(ensurePlayerProfile)
.filter(p => (p.name || '').trim() || (p.number || '').toString().trim());
}
let tiroSedutaAperta = null;
let tiroGraficoChi = 'squadra'; // 'squadra' oppure l'id di una player
let tiroGraficoVoce = 'tutte';
let practiceVista = 'allenamenti';
function practiceScomparto(quale) {
practiceVista = quale;
const a = document.getElementById('prScompAllen'), b = document.getElementById('prScompTiro');
if (a) a.classList.toggle('attivo', quale === 'allenamenti');
if (b) b.classList.toggle('attivo', quale === 'tiro');
const la = document.getElementById('practiceSessionList'), lb = document.getElementById('practiceTiroBox');
if (la) la.style.display = quale === 'allenamenti' ? '' : 'none';
if (lb) lb.style.display = quale === 'tiro' ? '' : 'none';
const bt = document.getElementById('practicePrintBtn');
if (bt) bt.textContent = quale === 'tiro' ? t('tiro.stampaBtn') : t('practice.printBtn');
if (quale === 'tiro') renderTiro();
}
function practiceStampaScomparto() {
if (practiceVista === 'tiro') printTiroSheet(); else printPracticeSheet();
}
function tiroNuovaSeduta() {
const team = squadraDellAllenamento();
if (!team) { chiediSquadraAllenamento(); return; }
const tr = ensureTiro(team);
const sd = { id: nextId('tsd'), data: tiroOggiISO(), nota: '', righe: {} };
tr.sedute.unshift(sd);
tiroSedutaAperta = sd.id;
scheduleAutosave();
renderTiro();
}
function tiroEliminaSeduta(id) {
const team = squadraDellAllenamento(); if (!team) return;
showConfirmModal(t('tiro.eliminaChiedi'), () => {
const tr = ensureTiro(team);
tr.sedute = tr.sedute.filter(x => x.id !== id);
if (tiroSedutaAperta === id) tiroSedutaAperta = null;
scheduleAutosave(); renderTiro();
});
}
function tiroApri(id) { tiroSedutaAperta = (tiroSedutaAperta === id) ? null : id; renderTiro(); }
function tiroSetData(id, valore) {
const team = squadraDellAllenamento(); if (!team) return;
const sd = ensureTiro(team).sedute.find(x => x.id === id); if (!sd) return;
sd.data = valore; scheduleAutosave(); renderTiro();
}
function tiroSetNota(id, valore) {
const team = squadraDellAllenamento(); if (!team) return;
const sd = ensureTiro(team).sedute.find(x => x.id === id); if (!sd) return;
sd.nota = valore; scheduleAutosave();
}
function tiroSetNumero(sedutaId, playerId, voceId, campo, valore) {
const team = squadraDellAllenamento(); if (!team) return;
const sd = ensureTiro(team).sedute.find(x => x.id === sedutaId); if (!sd) return;
const c = tiroCella(sd, playerId, voceId);
const n = String(valore || '').replace(/[^0-9]/g, '').slice(0, 4);
c[campo] = n;
// I realizzati non possono superare i tentati: e' un errore di battitura,
// non un dato, e falserebbe la percentuale di tutta la stagione.
const T = parseInt(c.t, 10), R = parseInt(c.r, 10);
if (isFinite(T) && isFinite(R) && R > T) c.r = String(T);
scheduleAutosave();
tiroAggiornaPercentuali(sedutaId);
}
/* Si riscrivono solo le percentuali, non tutta la griglia: ridisegnandola
* intera ad ogni tasto si perdeva il cursore dalla casella. */
function tiroAggiornaPercentuali(sedutaId) {
const team = squadraDellAllenamento(); if (!team) return;
const tr = ensureTiro(team);
const sd = tr.sedute.find(x => x.id === sedutaId); if (!sd) return;
const players = tiroPlayers(team);
players.forEach(p => tr.voci.forEach(v => {
const el = document.getElementById('tpc-' + sd.id + '-' + p.id + '-' + v.id);
if (!el) return;
const c = (sd.righe[p.id] || {})[v.id] || {};
const pc = tiroPerc(c.t, c.r);
el.textContent = pc == null ? '' : pc + '%';
}));
const tot = tiroTotaliSeduta(sd, tr.voci, players);
const et = document.getElementById('ttot-' + sd.id);
if (et) et.textContent = tot.pc == null ? '—' : tot.R + '/' + tot.T + ' ' + tot.pc + '%';
disegnaTiroGrafico();
}
function tiroVoceAggiungi() {
const team = squadraDellAllenamento(); if (!team) return;
showPromptModal(t('tiro.nuovaVoceChiedi'), t('tiro.nuovaVocePh'), (nome) => {
const n = (nome || '').trim(); if (!n) return;
ensureTiro(team).voci.push({ id: nextId('tv'), nome: n });
scheduleAutosave(); renderTiro();
});
}
function tiroVoceRinomina(id, valore) {
const team = squadraDellAllenamento(); if (!team) return;
const v = ensureTiro(team).voci.find(x => x.id === id); if (!v) return;
v.nome = String(valore || '').slice(0, 28); scheduleAutosave();
}
function tiroVoceTogli(id) {
const team = squadraDellAllenamento(); if (!team) return;
const tr = ensureTiro(team);
if (tr.voci.length <= 1) { toast(t('tiro.unaVoceAlmeno')); return; }
showConfirmModal(t('tiro.togliVoceChiedi'), () => {
tr.voci = tr.voci.filter(x => x.id !== id);
tr.sedute.forEach(sd => Object.keys(sd.righe).forEach(pid => { delete sd.righe[pid][id]; }));
scheduleAutosave(); renderTiro();
});
}
function tiroSetGrafico(chi, voce) {
if (chi != null) tiroGraficoChi = chi;
if (voce != null) tiroGraficoVoce = voce;
disegnaTiroGrafico();
}
/* ---- IL REGISTRO DEL TIRO, disegnato ---- */
function renderTiro() {
const box = document.getElementById('practiceTiroBox');
if (!box) return;
const team = squadraDellAllenamento();
if (!team) {
// Prima qui c'era solo la scritta: il menu per scegliere la squadra stava
// nell'altro scomparto e da qui non si raggiungeva.
box.innerHTML = barraArchiviHtml(null, 0)
+ `${escapeHtml(t('practice.nessunaSquadra'))}
`;
return;
}
const tr = ensureTiro(team);
const players = tiroPlayers(team);
const vociHtml = tr.voci.map(v => `
×
`).join('');
const grafico = `
${escapeHtml(t('tiro.andamento'))}
${escapeHtml(t('tiro.tuttaLaSquadra'))}
${players.map(p => `${escapeHtml((p.number ? '#' + p.number + ' ' : '') + (p.name || ''))} `).join('')}
${escapeHtml(t('tiro.tutteLeVoci'))}
${tr.voci.map(v => `${escapeHtml(v.nome)} `).join('')}
`;
if (!tr.sedute.length) {
box.innerHTML = `
${escapeHtml(t('tiro.nuovaSeduta'))}
${escapeHtml(t('tiro.nuovaVoce'))}
${vociHtml}
${escapeHtml(t('tiro.nessunaSeduta'))}
`;
return;
}
const sedute = tr.sedute.slice().sort((a, b) => String(b.data || '').localeCompare(String(a.data || '')));
box.innerHTML = `
${escapeHtml(t('tiro.nuovaSeduta'))}
${escapeHtml(t('tiro.nuovaVoce'))}
${escapeHtml(t('tiro.schedeBtn'))}
${escapeHtml(t('tiro.quante', { n: tr.sedute.length }))}
${vociHtml}
${grafico}
${sedute.map(sd => {
const tot = tiroTotaliSeduta(sd, tr.voci, players);
const aperta = tiroSedutaAperta === sd.id;
return `
`;
}).join('')}`;
disegnaTiroGrafico();
}
function tiroGrigliaHtml(sd, voci, players) {
if (!players.length) return `${escapeHtml(t('tiro.rosterVuoto'))}
`;
return ``;
}
/* I punti della curva: una seduta, una percentuale. Le sedute senza numeri
* non fanno punto — un buco nel grafico e' piu' onesto di uno zero che non
* e' mai stato tirato. */
function tiroSerie(team, chi, voceId) {
const tr = ensureTiro(team);
const players = tiroPlayers(team);
const quali = (chi === 'squadra') ? players : players.filter(p => p.id === chi);
const voci = (voceId === 'tutte') ? tr.voci : tr.voci.filter(v => v.id === voceId);
return tr.sedute.slice()
.sort((a, b) => String(a.data || '').localeCompare(String(b.data || '')))
.map(sd => {
let T = 0, R = 0;
quali.forEach(p => voci.forEach(v => {
const c = (sd.righe[p.id] || {})[v.id]; if (!c) return;
const tt = parseInt(c.t, 10), rr = parseInt(c.r, 10);
if (isFinite(tt) && tt > 0) { T += tt; R += (isFinite(rr) ? rr : 0); }
}));
return { data: sd.data || '', T, R, pc: tiroPerc(T, R) };
})
.filter(x => x.pc != null);
}
function disegnaTiroGrafico() {
const cv = document.getElementById('tiroGrafico');
if (!cv) return;
const team = squadraDellAllenamento(); if (!team) return;
const serie = tiroSerie(team, tiroGraficoChi, tiroGraficoVoce);
disegnaTiroCurva(cv.getContext('2d'), cv.width, cv.height, serie, false);
}
/* La curva. Disegnata a mano: e' una linea con dei pallini, non serve altro,
* e cosi' viene identica sullo schermo e sul foglio stampato. */
function disegnaTiroCurva(ctx, W, H, serie, perStampa) {
const sfondo = perStampa ? '#ffffff' : 'rgba(255,255,255,0.02)';
const testo = perStampa ? '#25313f' : '#9fb3cc';
const griglia = perStampa ? '#dbe2ea' : 'rgba(255,255,255,.10)';
const linea = perStampa ? '#12325f' : '#4c8dff';
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = sfondo; ctx.fillRect(0, 0, W, H);
/* I margini in PROPORZIONE, non in pixel fissi: la stessa curva si disegna
* su una tela piccola a video e su una grande per la stampa, e con margini
* fissi le percentuali a sinistra finivano fuori dal bordo (si leggeva
* "0%" al posto di "100%") e l'ultima data usciva tagliata a destra. */
const corpo = Math.max(9, Math.round(Math.min(W * 0.019, H * 0.065)));
const ml = Math.round(corpo * 3.4), mr = Math.round(corpo * 2.6);
const mt = Math.round(corpo * 2.6), mb = Math.round(corpo * 2.8);
const w = W - ml - mr, h = H - mt - mb;
ctx.font = '600 ' + corpo + 'px system-ui, sans-serif';
ctx.textBaseline = 'middle';
// righe orizzontali ogni 25%
for (let q = 0; q <= 100; q += 25) {
const y = mt + h - (q / 100) * h;
ctx.strokeStyle = griglia; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(ml, y + 0.5); ctx.lineTo(ml + w, y + 0.5); ctx.stroke();
ctx.fillStyle = testo; ctx.textAlign = 'right';
ctx.fillText(q + '%', ml - 8, y);
}
if (!serie.length) {
ctx.fillStyle = testo; ctx.textAlign = 'center';
ctx.fillText(t('tiro.graficoVuoto'), ml + w / 2, mt + h / 2);
return;
}
const passo = serie.length > 1 ? w / (serie.length - 1) : 0;
const px = (i) => serie.length > 1 ? ml + i * passo : ml + w / 2;
const py = (pc) => mt + h - (pc / 100) * h;
ctx.strokeStyle = linea; ctx.lineWidth = Math.max(2, H * 0.007);
ctx.lineJoin = 'round'; ctx.lineCap = 'round';
ctx.beginPath();
serie.forEach((s2, i) => { const x = px(i), y = py(s2.pc); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); });
ctx.stroke();
serie.forEach((s2, i) => {
const x = px(i), y = py(s2.pc);
ctx.fillStyle = linea;
ctx.beginPath(); ctx.arc(x, y, Math.max(3, H * 0.011), 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = perStampa ? '#12325f' : '#cfe0ff';
ctx.textAlign = 'center';
ctx.font = '800 ' + corpo + 'px system-ui, sans-serif';
// se il punto e' in alto l'etichetta va SOTTO, o finisce fuori dalla tela
const sopra = (y - corpo * 1.5) > mt;
ctx.fillText(s2.pc + '%', x, sopra ? y - corpo * 1.3 : y + corpo * 1.5);
ctx.font = '600 ' + corpo + 'px system-ui, sans-serif';
});
// le date sotto: se sono tante si scrivono una sì e una no, per non
// sovrapporle
const salta = Math.ceil(serie.length / 12);
ctx.fillStyle = testo;
ctx.font = '600 ' + Math.round(corpo * 0.92) + 'px system-ui, sans-serif';
serie.forEach((s2, i) => {
if (i % salta) return;
const et = (s2.data || '').slice(5).replace('-', '/');
const x = px(i);
// la prima e l'ultima si allineano al bordo, cosi' non escono dalla tela
ctx.textAlign = (i === 0 && serie.length > 1) ? 'left' : (i === serie.length - 1 && serie.length > 1) ? 'right' : 'center';
ctx.fillText(et, x, mt + h + corpo * 1.4);
});
}
function renderPracticeSection(teamPassata) {
const listEl = document.getElementById('practiceSessionList');
// Chi chiama passa la squadra aperta nel menu in alto: qui si ignora, e si
// lavora su quella scelta per gli allenamenti (vedi squadraDellAllenamento).
const team = squadraDellAllenamento();
const eti0 = document.getElementById('practiceTeamName');
if (!team) {
// Nessuna squadra scelta: si chiede, non si indovina.
if (eti0) eti0.textContent = '';
listEl.innerHTML = barraArchiviHtml(null, 0)
+ `${escapeHtml(t('practice.nessunaSquadra'))}
`;
return;
}
const tutte = ensurePractice(team);
tutte.forEach(ensurePracticeSessionExtras);
const archivi = ensureArchiviPractice(team);
if (practiceArchivioAperto && !archivi.some(a => a.id === practiceArchivioAperto)) practiceArchivioAperto = null;
// Il nome della squadra accanto al titolo: adesso che Practice non segue
// piu' il menu in alto, senza questo non si capisce di CHI sia
// l'allenamento che si ha davanti.
const eti = document.getElementById('practiceTeamName');
if (eti) eti.textContent = (team.name || '').trim() ? '· ' + team.name : '';
const barra = barraArchiviHtml(team, tutte.length);
const sessions = practiceArchivioAperto ? tutte.filter(x => sessioneNegliArchivi(x, practiceArchivioAperto)) : tutte;
if (!sessions.length) {
listEl.innerHTML = barra + ''
+ (practiceArchivioAperto ? t('practice.archivioVuoto') : t('empty.noSessions')) + '
';
return;
}
listEl.innerHTML = barra + sessions.map(s => `
${escapeHtml(t('practice.inArchivi'))}
${ensureArchiviPractice(team).length
? ensureArchiviPractice(team).map(a => `
${escapeHtml(a.nome)} `).join('')
: `${escapeHtml(t('practice.archivioNomePh'))} `}
${(s.fasi || []).map(f => `
${t('dyn.removeFase')}
${(f.esercizi || []).length ? f.esercizi.map(e => `
`).join('') : '
' + t('practice.noExercises') + '
'}
${t('practice.addExerciseBtn')}
`).join('')}
${t('practice.addFaseBtn')}
${practiceGroupsHtml(s)}
${practiceStripHtml(s)}
${t('print.livePreviewLabel')}
${practiceSheetHtml(s, team)}
`).join('');
// Ogni foglio si rimisura appena e' nel DOM: se l'allenamento e' lungo si
// stringe da solo invece di uscire dalla pagina.
requestAnimationFrame(() => adattaFoglioAllenamento(listEl));
}
// ------------------------------------------------------------- PERSONNEL
// Scheda giocatrice: numero/nome/ruolo arrivano dal roster (sola lettura qui,
// si modificano nell'Anagrafica squadra) — non si ripetono. Foto, altezza,
// anno di nascita, statistiche e 4 caratteristiche sono propri di questa scheda.
// Apici singoli DENTRO l'SVG (non doppi): questo data URI finisce in un
// attributo HTML src="..." tra doppi apici via innerHTML — apici doppi qui
// dentro chiuderebbero l'attributo in anticipo e romperebbero il markup.
const DOSSIER_PLACEHOLDER = "data:image/svg+xml;utf8, ";
function setPlayerPhoto(playerId, file) {
const trovata = trovaGiocatriceOvunque(playerId);
if (!trovata || !file) return;
const { team, p } = trovata;
const reader = new FileReader();
reader.onload = () => { p.photo = reader.result; renderPersonnelDossiers(team); };
reader.readAsDataURL(file);
}
// oninput su campi di testo: mutano lo stato subito (auto-salvato nel JSON
// esportabile) SENZA ri-renderizzare la scheda, altrimenti l'input perderebbe
// il focus a ogni carattere digitato — l'anteprima live invece SI aggiorna
// (è un semplice specchio, non un campo dove si sta scrivendo).
function updatePlayerField(playerId, field, value) {
const trovata = trovaGiocatriceOvunque(playerId);
if (!trovata) return;
ensurePlayerProfile(trovata.p)[field] = value;
renderPersonnelLivePreview(trovata.team);
scheduleAutosave();
}
function updatePlayerStat(playerId, key, value) {
const trovata = trovaGiocatriceOvunque(playerId);
if (!trovata) return;
ensurePlayerProfile(trovata.p).stats[key] = value;
renderPersonnelLivePreview(trovata.team);
scheduleAutosave();
}
function updatePlayerTrait(playerId, idx, value) {
const trovata = trovaGiocatriceOvunque(playerId);
if (!trovata) return;
ensurePlayerProfile(trovata.p).traits[idx] = value;
renderPersonnelLivePreview(trovata.team);
scheduleAutosave();
}
// Slot di rotazione 1-12 (6 per lato, come ITALBASKET WOMAN). Etichetta con i
// numeri di ruolo del basket (1 playmaker, 2 guardia, 3 ala, 4 ala grande, 5
// centro) invece del nome per esteso — nel basket il ruolo si riconosce dal
// numero. Il 6° slot per lato è la rotazione corta: può coprire uno qualsiasi
// dei ruoli 1-5, quindi resta genericamente "1-5" invece di un numero fisso.
const PERSONNEL_SLOT_ROLES = ['1', '2', '3', '4', '5', '1-5'];
function personnelSlotRoleLabel(slot) {
return PERSONNEL_SLOT_ROLES[(slot - 1) % 6];
}
// Assegna in automatico il primo slot libero alle giocatrici che non ne hanno
// ancora uno (appena aggiunte al roster, o importate da uno schema più vecchio
// senza slot): così, aggiungendo giocatrici, "vanno già in ordine" nella
// griglia senza doverlo compilare a mano — resta comunque modificabile scheda
// per scheda per aggiustare la rotazione.
// Assegna lo slot di rotazione (1-12) del Personnel alle giocatrici che ancora
// non ce l'hanno. IMPORTANTE: solo alle righe DAVVERO compilate. Le squadre
// della lega hanno 14 caselle roster fisse, quasi tutte vuote: contandole,
// quelle vuote si prendevano gli slot 1-12 e una giocatrice scritta più in
// basso restava senza slot, finendo fuori dalla griglia del Personnel (nel
// gruppo "altri player") invece di comparire dove ci si aspetta.
/* Trova la giocatrice DOVUNQUE sia: nelle proprie squadre o nelle caselle
* della lega. Prima le modifiche fatte sulle schede del Personnel cercavano
* la giocatrice SOLO dentro alla squadra che in quel momento risultava
* "attiva": bastava che il box della squadra fosse chiuso, o che nel menu in
* alto ci fosse selezionata un'altra squadra, perché la ricerca non trovasse
* nessuno — e quello che si scriveva non finiva da nessuna parte, senza
* nemmeno un avviso. Da qui il "scrivo ma non compare niente".
* Cercando per id in tutto l'archivio, il dato va sempre sulla giocatrice
* giusta, qualunque cosa sia selezionata nel menu. */
/* Tutte le copie della stessa giocatrice, ovunque siano. Lo stesso player
* puo' trovarsi in due strutture diverse (la rosa della lega e la scheda
* avversaria): se si cambia una copia sola, il foglio che legge l'altra
* continua a mostrare la vecchia. E' cosi' che una giocatrice messa "fuori
* dal foglio" restava sul Personnel. */
function tutteLeCopieDellaGiocatrice(playerId) {
const fuori = [];
if (!playerId) return fuori;
const squadre = [];
(DB.leagues || []).forEach(lg => {
(lg.teams || []).forEach(tm => squadre.push(tm));
(lg.oppRosters || []).forEach(sl => squadre.push(sl));
});
(DB.singleMatches || []).forEach(tm => squadre.push(tm));
const att = activeTeam();
if (att) squadre.push(att);
squadre.forEach(tm => {
if (!tm || !Array.isArray(tm.players)) return;
tm.players.forEach(x => { if (x && x.id === playerId && fuori.indexOf(x) < 0) fuori.push(x); });
});
return fuori;
}
function trovaGiocatriceOvunque(playerId) {
if (!playerId) return null;
/* Prima si guarda nella squadra che il Personnel sta mostrando in questo
* momento: e' quella che il coach ha davanti, ed e' quella che deve
* cambiare. Prima si prendeva la prima copia trovata girando tutto il
* database, che poteva essere un'altra. */
if (personnelTeamIdCorrente) {
const squadre2 = [];
(DB.leagues || []).forEach(lg => {
(lg.teams || []).forEach(tm => squadre2.push(tm));
(lg.oppRosters || []).forEach(sl => squadre2.push(sl));
});
(DB.singleMatches || []).forEach(tm => squadre2.push(tm));
const scheda = squadre2.find(tm => tm && tm.id === personnelTeamIdCorrente);
if (scheda && Array.isArray(scheda.players)) {
const p0 = scheda.players.find(x => x && x.id === playerId);
if (p0) return { team: scheda, p: p0 };
}
}
const squadre = [];
(DB.leagues || []).forEach(lg => {
(lg.teams || []).forEach(tm => squadre.push(tm));
(lg.oppRosters || []).forEach(sl => squadre.push(sl));
});
(DB.singleMatches || []).forEach(tm => squadre.push(tm));
for (const tm of squadre) {
if (!tm || !Array.isArray(tm.players)) continue;
const p = tm.players.find(x => x && x.id === playerId);
if (p) return { team: tm, p };
}
const att = activeTeam();
if (att && Array.isArray(att.players)) {
const p = att.players.find(x => x && x.id === playerId);
if (p) return { team: att, p };
}
return null;
}
/* Due giocatrici con lo STESSO id manderebbero ogni modifica sempre sulla
* prima: qui gli id doppi vengono sciolti. */
function sciogliIdDoppi(team) {
if (!team || !Array.isArray(team.players)) return;
const visti = new Set();
team.players.forEach(p => {
if (!p) return;
if (!p.id || visti.has(p.id)) p.id = nextId('pl');
visti.add(p.id);
});
}
function rosterRowIsFilled(p) {
return !!((p.name || '').trim() || (p.number != null && String(p.number).trim()));
}
/* PERSONNEL SEMPRE COERENTE, a ogni cambiamento -----------------------------
* Le dodici posizioni sono due file da sei: 1-6 sopra, 7-12 sotto. Questa
* funzione RIMETTE IN ORDINE le posizioni ogni volta che si guarda o si
* stampa il Personnel, invece di limitarsi a riempire i buchi:
* 1. chi ha una posizione valida e non doppia se la tiene (le scelte del
* coach non si toccano mai);
* 2. le posizioni DOPPIE si sciolgono: la prima resta, l'altra ne prende
* una libera. Prima una posizione doppia faceva sparire una giocatrice
* dalla griglia e dal PDF — era il caso di Resingerova;
* 3. chi resta senza posizione la riceve seguendo l'ordine bilanciato
* (1..n/2 sopra, poi 7.. sotto), così le due file restano pari;
* 4. se le giocatrici sono più di dodici, quelle in eccesso non spariscono:
* restano elencate a parte sotto la griglia.
* Il risultato è che aggiungere, togliere o rinominare una giocatrice non
* scombina mai il foglio: si riadatta da solo. */
function normalizePersonnelSlots(team) {
if (!team || !Array.isArray(team.players)) return;
sciogliIdDoppi(team);
// Chi il coach ha messo "fuori dal foglio" non entra nel giro delle
// caselle: niente slot assegnato, e la sua casella resta libera per un'altra.
team.players.forEach(p => { if (p && p.fuoriFoglio) p.slot = ''; });
const filled = team.players.filter(p => rosterRowIsFilled(p) && !p.fuoriFoglio);
filled.forEach(ensurePlayerProfile);
const occupate = new Map();
filled.forEach(p => {
const n = parseInt(p.slot, 10);
if (n >= 1 && n <= 12 && !occupate.has(n)) occupate.set(n, p);
else p.slot = ''; // fuori scala oppure doppia
});
const quante = Math.min(filled.length, 12);
const sopra = Math.ceil(quante / 2);
const ordine = [];
for (let i = 1; i <= sopra; i++) ordine.push(i);
for (let i = 7; i < 7 + (quante - sopra); i++) ordine.push(i);
for (let i = 1; i <= 12; i++) if (ordine.indexOf(i) < 0) ordine.push(i);
filled.forEach(p => {
if (p.slot) return;
const libero = ordine.find(i => !occupate.has(i));
if (libero) { p.slot = libero; occupate.set(libero, p); }
});
}
// Nome storico, usato in più punti: ora fa la normalizzazione completa.
function assignMissingSlots(team) { normalizePersonnelSlots(team); }
/* Ridisposizione completa: azzera tutte le posizioni e le riassegna
* bilanciate, nell'ordine del roster. Serve quando il foglio è diventato
* disordinato a forza di aggiungere e togliere giocatrici. */
/* Aggiornamento a mano del foglio bianco: normalmente si ridisegna da solo a
* ogni carattere, ma avere il pulsante toglie ogni dubbio — e ricostruisce
* anche le schede qui sopra, non solo l'anteprima. */
/* Dal Personnel al Video in un click (idea del coach): le giocatrici che
* hai davanti diventano SUBITO la pulsantiera con cui taggare, senza dover
* indovinare quale squadra è aperta nel menu in alto. È la scorciatoia che
* mancava, ed è anche la risposta al caso "le ho caricate ma nel video non
* ci sono". */
function portaPersonnelNelVideo() {
const team = personnelTeam();
if (!team) { toast(t('relazione.noTeamForSheet')); return; }
const quante = (team.players || []).filter(p => (p && p.name || '').trim()).length;
if (!quante) { toast(t('video.chipTeamEmpty')); return; }
vt.chipTeamId = team.id;
selectTeamTab('video');
renderAll();
scheduleAutosave();
toast(t('video.chipTeamPortate', { n: quante, squadra: team.name || '' }));
}
function aggiornaFoglioSubito() {
const team = personnelTeam();
if (!team) { toast(t('relazione.noTeamForSheet')); return; }
renderPersonnelDossiers(team);
renderPersonnelLivePreview(team);
// Porta il foglio sotto agli occhi: capitava di premere il pulsante e di
// guardare un'altra parte della pagina.
const el = document.getElementById('personnelLivePreview');
if (el) el.scrollIntoView({ block: 'center' });
const n = (team.players || []).filter(rosterRowIsFilled).length;
toast(t('relazione.refreshSheetDone2', { squadra: team.name || '—', n }));
}
function ridisponiPersonnel() {
const team = personnelTeam();
if (!team) return;
team.players.forEach(p => { if (p) p.slot = ''; });
normalizePersonnelSlots(team);
renderPersonnelDossiers(team);
scheduleAutosave();
toast(t('relazione.reflowDone'));
}
/* SPOSTARE UNA GIOCATRICE IN UNA POSIZIONE GIA' OCCUPATA = SCAMBIO.
* Prima la posizione nuova veniva semplicemente scartata: normalizePersonnelSlots
* vede due giocatrici sullo stesso numero, tiene la PRIMA che incontra
* nell'ordine del roster e azzera l'altra, che si ritrova poi rispedita nella
* prima casella libera — cioe' quasi sempre esattamente dov'era. Da fuori
* sembrava che il campo non funzionasse: "aggiorno le posizioni ma non vanno".
* Adesso le due si scambiano di posto, che e' quello che uno si aspetta
* quando dice "questa va in P2". E si salva: prima mancava anche quello.
* Resta valida la regola del foglio: P1-P6 colonna di SINISTRA, P7-P12
* colonna di DESTRA. */
function updatePlayerSlot(playerId, value) {
const trovata = trovaGiocatriceOvunque(playerId);
if (!trovata) return;
const team = trovata.team, p = trovata.p;
// "Fuori dal foglio": la giocatrice resta in rosa a tutti gli effetti, ma
// non prende nessuna casella e non finisce ne' in anteprima ne' in stampa.
if (value === 'fuori') {
/* Su TUTTE le copie, non solo su quella trovata per prima. */
tutteLeCopieDellaGiocatrice(playerId).forEach(q => { q.fuoriFoglio = true; q.slot = ''; });
p.fuoriFoglio = true;
p.slot = '';
scheduleAutosave();
toast(t('personnel.toastFuori', { a: p.name || '—' }));
setTimeout(() => { renderPersonnelDossiers(team); renderPersonnelLivePreview(team); }, 0);
return;
}
const tornaDentro = !!p.fuoriFoglio;
tutteLeCopieDellaGiocatrice(playerId).forEach(q => { q.fuoriFoglio = false; });
p.fuoriFoglio = false;
const n = parseInt(value, 10);
const nuovoPosto = (n >= 1 && n <= 12) ? n : '';
const vecchioPosto = parseInt(p.slot, 10);
let scambiata = null;
if (nuovoPosto) {
scambiata = (team.players || []).find(q => q && q !== p && parseInt(q.slot, 10) === nuovoPosto);
if (scambiata) scambiata.slot = (vecchioPosto >= 1 && vecchioPosto <= 12) ? vecchioPosto : '';
}
p.slot = nuovoPosto;
scheduleAutosave();
if (tornaDentro) toast(t('personnel.toastDentro', { a: p.name || '—' }));
if (scambiata) {
toast(t('personnel.slotScambio', {
a: p.name || '—', na: nuovoPosto,
b: scambiata.name || '—', nb: scambiata.slot || '—'
}));
}
// Rimandato di un tick: l'evento "change" arriva ancora dentro l'input che
// sta per essere rimosso quando sostituiamo grid.innerHTML — farlo subito
// rischia "node to be removed is no longer a child" nel browser durante il
// suo stesso blur/change; con setTimeout(...,0) lasciamo prima finire quel
// giro di eventi.
setTimeout(() => { renderPersonnelDossiers(team); renderPersonnelLivePreview(team); }, 0);
}
/* LA POSIZIONE SI SCEGLIE DA UN MENU A TENDINA, non piu' scrivendo un numero
* in una casellina. Ogni voce dice anche DA CHE PARTE del foglio finisce, che
* e' la cosa che conta davvero: P1-P6 stanno nella colonna di sinistra,
* P7-P12 in quella di destra.
* Nella stessa tendina c'e' anche "Fuori dal foglio": serve per le giocatrici
* che restano in rosa (e quindi nei video, nei tag, ovunque) ma che NON si
* vogliono sul Personnel stampato — l'infortunata lungodegente, la ragazza
* aggregata dal settore giovanile, quella che non entra nelle rotazioni. */
function sceltaPostoHtml(p) {
const attuale = p.fuoriFoglio ? 'fuori' : (parseInt(p.slot, 10) || '');
const voce = (val, testo) => `${escapeHtml(testo)} `;
let opzioni = voce('', t('personnel.slotAuto'));
for (let n = 1; n <= 12; n++) {
const lato = n <= 6 ? t('personnel.latoSx') : t('personnel.latoDx');
opzioni += voce(n, 'P' + n + ' · ' + lato + ' · ' + personnelSlotRoleLabel(n));
}
opzioni += voce('fuori', t('personnel.slotFuori'));
return `${opzioni} `;
}
function dossierCardHtml(p) {
return `
`;
}
// Slot vuoto ma SUBITO scrivibile: prima mostrava solo un avviso muto ("vai
// nell'Anagrafica squadra qui sopra") — nessun campo dentro cui scrivere,
// esattamente il "non riuscivo a scriverci dentro" segnalato dal coach.
// Stesso meccanismo già usato dalle caselle giocatrici vuote in Video
// (addPlayerAtSlot): basta scrivere il nome e premere Invio per creare la
// giocatrice ed assegnarle subito questo slot, senza dover cambiare scheda.
function emptyDossierSlotHtml(slot) {
return `
`;
}
// Griglia SEMPRE a 12 slot fissi (6 per lato: playmaker/guardia/ala/ala
// grande/centro/rotazione corta), riempiti dalle giocatrici in roster in base
// allo slot assegnato — quelli senza giocatrice restano visibili vuoti, così
// la struttura del Personnel si vede subito anche a roster non ancora completo.
/* Il Personnel lavora sulla squadra le cui SCHEDE SONO APERTE nella pagina
* Squadre Avversarie: apri il box di una squadra e sotto trovi il Personnel di
* quella squadra, che si aggiorna insieme alle sue schede (sono gli stessi
* dati: foto, altezza, anno, medie e caratteristiche stanno su un'unica
* giocatrice). Se non c'è nessun box aperto, resta la propria squadra. */
let personnelTeamIdCorrente = null; // di quale squadra è il Personnel a schermo
function personnelTeam() {
if (activeTeamTab === 'avversarie' && avvExpandedSlotId) {
const league = activeLeague();
if (league) {
const slot = ensureOppRosterSlots(league).find(x => x.id === avvExpandedSlotId);
if (slot) return upgradeLeagueTeamRecord(slot);
}
}
// Nessun box aperto: si resta sulla squadra che il Personnel STA GIÀ
// mostrando, invece di saltare su quella selezionata nel menu in alto —
// altrimenti bastava chiudere il box perché le schede a schermo e i dati
// scritti si riferissero a due squadre diverse.
if (personnelTeamIdCorrente) {
const league = activeLeague();
if (league) {
const slot = (league.oppRosters || []).find(x => x.id === personnelTeamIdCorrente);
if (slot) return upgradeLeagueTeamRecord(slot);
const tm = (league.teams || []).find(x => x.id === personnelTeamIdCorrente);
if (tm) return tm;
}
}
return activeTeam();
}
/* Un campo di testo che cresce con quello che ci scrivi dentro: niente barre
* di scorrimento e niente testo che sparisce a destra. */
function crescoCampo(el) {
if (!el) return;
el.style.height = 'auto';
el.style.height = (el.scrollHeight + 2) + 'px';
}
function crescoTuttiICampi(radice) {
(radice || document).querySelectorAll('.dossier-traits textarea, .rp-traits textarea').forEach(crescoCampo);
}
function renderPersonnelDossiers(team) {
const grid = document.getElementById('personnelDossierGrid');
if (!grid) return;
personnelTeamIdCorrente = team.id || null;
normalizePersonnelSlots(team);
const bySlot = {};
const overflow = [];
// Solo le righe di roster davvero compilate: le squadre della lega hanno 14
// caselle giocatrice fisse, quasi tutte vuote finché non le si riempie, e
// senza questo filtro il Personnel mostrava una scheda-dossier per ognuna
// (quattordici riquadri quasi identici e vuoti) invece dei soli slot liberi.
const fuori = [];
team.players.filter(p => (p.name || '').trim() || (p.number != null && String(p.number).trim())).forEach(p => {
if (p.fuoriFoglio) { fuori.push(p); return; }
const n = parseInt(p.slot, 10);
if (n >= 1 && n <= 12 && !bySlot[n]) bySlot[n] = p;
else overflow.push(p);
});
let html = '';
for (let slot = 1; slot <= 12; slot++) {
html += bySlot[slot] ? dossierCardHtml(bySlot[slot]) : emptyDossierSlotHtml(slot);
}
html += '
';
if (overflow.length) {
html += '' + escapeHtml(t('personnel.gruppoAltri')) + '
'
+ '' + overflow.slice().sort((a, b) => a.number - b.number).map(dossierCardHtml).join('') + '
';
}
// Le "fuori dal foglio" restano visibili qui sotto, con la loro scheda
// intera: si continuano a compilare, e dalla stessa tendina si rimettono
// dentro quando serve.
if (fuori.length) {
html += '' + escapeHtml(t('personnel.gruppoFuori')) + '
'
+ '' + fuori.slice().sort((a, b) => a.number - b.number).map(dossierCardHtml).join('') + '
';
}
grid.innerHTML = html;
const tit = document.getElementById('personnelTeamName');
if (tit) tit.textContent = (team.name || '').trim() ? '· ' + team.name : '';
requestAnimationFrame(() => crescoTuttiICampi(grid));
renderPersonnelLivePreview(team);
}
// ------------------------------------------------------- CALENDARIO & RELAZIONE
/* Impegni inseriti a mano per la squadra, PIÙ (se presente) il calendario a
* livello di lega importato dal JSON: se league.calendar contiene partite con
* home/away pari al nome della squadra, compaiono qui in automatico, senza
* doverle reinserire — è la parte "se scarico il calendario della lega me lo
* ritrovo già nella relazione" richiesta dal coach. */
function leagueFixturesForTeam(team) {
const league = activeLeague();
if (!league || !Array.isArray(league.calendar)) return [];
const name = (team.name || '').toLowerCase();
return league.calendar.filter(m => (m.home || '').toLowerCase() === name || (m.away || '').toLowerCase() === name);
}
function addFixture() {
const team = activeTeam();
if (!team) return;
const date = document.getElementById('newFixtureDate').value;
const comp = document.getElementById('newFixtureComp').value.trim();
const opp = document.getElementById('newFixtureOpp').value.trim();
const home = document.getElementById('newFixtureHome').value === '1';
if (!date || !opp) { toast(t('toast.fixtureFieldsMissing')); return; }
ensureFixtures(team).push({ id: nextId('fx'), date, competition: comp, opponent: opp, home, note: '' });
document.getElementById('newFixtureDate').value = '';
document.getElementById('newFixtureComp').value = '';
document.getElementById('newFixtureOpp').value = '';
renderCalendarSection(team);
toast(t('toast.fixtureAdded'));
}
function deleteFixture(id) {
const team = activeTeam();
if (!team) return;
team.fixtures = ensureFixtures(team).filter(f => f.id !== id);
renderCalendarSection(team);
}
// Unisce impegni inseriti a mano e partite trovate nel calendario di lega
// (stessa logica usata sia a video in renderCalendarSection sia nel foglio
// stampabile dello Scouting Report, così le due viste restano coerenti).
function combinedFixtures(team) {
const leagueFx = leagueFixturesForTeam(team);
const teamNameLower = (team.name || '').toLowerCase();
const manual = ensureFixtures(team).map(f => Object.assign({}, f, { sourceLeague: false }));
const fromLeague = leagueFx.map(m => ({
id: 'lg-' + (m.id || (m.date + '-' + m.home + '-' + m.away)),
date: m.date, competition: m.competition || '',
opponent: (m.home || '').toLowerCase() === teamNameLower ? m.away : m.home,
home: (m.home || '').toLowerCase() === teamNameLower,
sourceLeague: true,
}));
return manual.concat(fromLeague).sort((a, b) => (a.date || '').localeCompare(b.date || ''));
}
function renderCalendarSection(team) {
const body = document.getElementById('fixtureListBody');
const noteEl = document.getElementById('calendarLeagueNote');
const leagueFx = leagueFixturesForTeam(team);
if (leagueFx.length) {
noteEl.style.display = 'block';
noteEl.textContent = '📅 ' + leagueFx.length + ' partite trovate nel calendario della lega importato — mostrate qui automaticamente insieme agli impegni aggiunti a mano.';
} else {
noteEl.style.display = 'none';
}
const all = combinedFixtures(team);
if (!all.length) { body.innerHTML = 'Nessun impegno ancora. Aggiungine uno qui sotto, oppure importa una lega con calendario incluso.
'; return; }
body.innerHTML = all.map(f => `
${escapeHtml(f.date || '—')}
${escapeHtml(f.competition || '—')}
${f.home ? '🏠' : '🚗'} ${escapeHtml(f.opponent || '—')}
${f.sourceLeague ? 'da calendario lega ' : `✕ `}
`).join('');
}
// Foglio stampabile "Scouting Report": le 4 categorie del Team Report (un
// punto per riga, come compilate nelle textarea) più il calendario impegni
// (manuali + quelli importati dal calendario di lega) in tabella.
// label arriva già completa (emoji incluso) dalle chiavi i18n relazione.box*,
// così non c'è bisogno di spezzare/ricostruire l'emoji per lingua.
// Un box di stampa può avere più sotto-sezioni (label + testo) invece di un
// solo blocco di testo libero — stessa granularità della vista a schermo
// (SCOUTING_FIELD_MAP). "sections" è un array di {label?, text}: le
// sotto-sezioni vuote non compaiono in stampa (foglio pulito), a meno che
// TUTTE lo siano, nel qual caso mostriamo un singolo "—" per il box intero.
// Ogni riga diventa un blocco a parte (NON un elenco puntato): il foglio
// ITALBASKET WOMAN portato dal coach come riferimento non usa mai bullet,
// solo paragrafi/righe che vanno a capo — replichiamo esattamente quello,
// non il pallino elenco che avevamo prima.
// "editable" (usato SOLO dal foglio sempre visibile sotto il modulo,
// renderScoutingLivePreview — NON dalla vera stampa/anteprima di stampa,
// che resta il foglio "pulito" di sempre): quando true, ogni sotto-sezione
// resta sempre a vista (anche se vuota, con un placeholder) come un unico
// blocco contenteditable, invece di sparire — così il coach può scrivere
// direttamente sul foglio invece di dover per forza usare le caselle del
// modulo qui sopra. Le sezioni SENZA "path" (es. il combo Tiro da 3pt, che
// unisce due campi diversi in una sola scritta) restano di sola lettura
// anche in modalità editabile: non c'è un modo sicuro di riscrivere quella
// scritta unica in due campi separati.
/* LO SCOUTING REPORT E' TUO, NON MIO.
* I titoli dei box si riscrivono (e restano riscritti), i box che non servono
* si tolgono, e se ne aggiungono di nuovi: o a testo libero, o a righe
* "nome + efficienza" — che e' il modo in cui si scrivono i playtypes e le
* statistiche avanzate: PICK AND ROLL 0.94, ISO 0.71, la 12 in post 1.08. */
function ensureScoutPersonalizzazioni(opp) {
if (!opp.titoliBox || typeof opp.titoliBox !== 'object') opp.titoliBox = {};
// Anche i sottotitoli DENTRO ai box (Forza mentale, Difesa, Attacco...)
// si riscrivono e restano riscritti: prima si lasciavano scrivere ma il
// testo spariva al primo ridisegno, perche' nessuno lo salvava.
if (!opp.titoliSezione || typeof opp.titoliSezione !== 'object') opp.titoliSezione = {};
if (!Array.isArray(opp.boxNascosti)) opp.boxNascosti = [];
if (!Array.isArray(opp.boxLiberi)) opp.boxLiberi = [];
return opp;
}
function titoloBox(opp, chiave, predefinito) {
if (!opp || !chiave) return predefinito;
ensureScoutPersonalizzazioni(opp);
const mio = opp.titoliBox[chiave];
return (mio != null && String(mio).trim()) ? String(mio) : predefinito;
}
function onScoutTitoloEdit(chiave, el) {
const team = activeTeam(); const opp = team && activeOpponent(team);
if (!opp || !chiave) return;
ensureScoutPersonalizzazioni(opp);
opp.titoliBox[chiave] = (el.textContent || '').trim();
scheduleAutosave();
sincronizzaTitoliAVideo();
}
/* Il sottotitolo di una riga dentro a un box: stessa storia del titolo.
* La chiave e' "box:indice" (es. forza:1), cosi' resta agganciata alla sua
* riga anche se il testo predefinito cambia lingua. */
function sottotitoloBox(opp, chiave, predefinito) {
if (!opp || !chiave) return predefinito;
ensureScoutPersonalizzazioni(opp);
const mio = opp.titoliSezione[chiave];
return (mio != null && String(mio).trim()) ? String(mio) : predefinito;
}
function onScoutSottotitoloEdit(chiave, el) {
const team = activeTeam(); const opp = team && activeOpponent(team);
if (!opp || !chiave) return;
ensureScoutPersonalizzazioni(opp);
opp.titoliSezione[chiave] = (el.textContent || '').trim();
scheduleAutosave();
sincronizzaTitoliAVideo();
}
/* I BOX COLORATI DELL'APP PORTANO GLI STESSI TITOLI DEL FOGLIO.
* Prima erano etichette fisse: riscrivevi "Punti di forza" sul foglio bianco
* e qui sopra, dove scrivi davvero, restava il nome vecchio. Adesso i titoli
* e i sottotitoli dei box si riscrivono anche qui (basta cliccarci sopra) e
* i due posti sono sempre d'accordo, perche' leggono e scrivono nello stesso
* punto. Il testo predefinito resta quello tradotto (data-i18n): il nome
* scritto a mano lo copre soltanto. */
function sincronizzaTitoliAVideo() {
const team = activeTeam();
const opp = team && activeOpponent(team);
const suggerimento = t('scout.titoloModificabile');
document.querySelectorAll('[data-tit], [data-sot]').forEach((el) => {
const kTit = el.getAttribute('data-tit');
const kSot = el.getAttribute('data-sot');
const chiaveI18n = el.getAttribute('data-i18n');
const predefinito = chiaveI18n ? t(chiaveI18n) : (el.textContent || '');
const mio = opp ? (kTit ? titoloBox(opp, kTit, predefinito) : sottotitoloBox(opp, kSot, predefinito)) : predefinito;
// Non si tocca il testo di chi ci sta scrivendo dentro in questo momento.
if (document.activeElement !== el && el.textContent !== mio) el.textContent = mio;
el.setAttribute('contenteditable', opp ? 'true' : 'false');
el.setAttribute('spellcheck', 'false');
el.title = opp ? suggerimento : '';
el.classList.toggle('tit-scrivibile', !!opp);
});
}
/* Scritto qui, si vede subito sul foglio bianco (e viceversa). Il modulo NON
* si ridisegna: si perderebbe il cursore ad ogni lettera. */
function onTitoloAVideoEdit(el) {
const team = activeTeam();
const opp = team && activeOpponent(team);
if (!opp) return;
ensureScoutPersonalizzazioni(opp);
const testo = (el.textContent || '').trim();
const kTit = el.getAttribute('data-tit');
if (kTit) opp.titoliBox[kTit] = testo;
else opp.titoliSezione[el.getAttribute('data-sot')] = testo;
scheduleAutosave();
renderScoutingLivePreview(team);
}
document.addEventListener('input', (e) => {
const el = e.target;
if (el && el.nodeType === 1 && (el.hasAttribute('data-tit') || el.hasAttribute('data-sot'))) onTitoloAVideoEdit(el);
});
function scoutBoxTogli(chiave) {
const team = activeTeam(); const opp = team && activeOpponent(team);
if (!opp) return;
ensureScoutPersonalizzazioni(opp);
if (opp.boxNascosti.indexOf(chiave) < 0) opp.boxNascosti.push(chiave);
scheduleAutosave();
renderScoutingLivePreview(team);
}
/* Rimette i titoli come li avevo scritti io: serve quando si e' pasticciato
* con un rinomino e non si ricorda piu' com'era. Non tocca il contenuto. */
function scoutTitoliOriginali() {
const team = activeTeam(); const opp = team && activeOpponent(team);
if (!opp) return;
ensureScoutPersonalizzazioni(opp);
showConfirmModal(t('scout.confermaTitoliOriginali'), () => {
opp.titoliBox = {};
opp.titoliSezione = {};
scheduleAutosave();
sincronizzaTitoliAVideo();
renderScoutingLivePreview(team);
});
}
function scoutBoxRimettiTutti() {
const team = activeTeam(); const opp = team && activeOpponent(team);
if (!opp) return;
ensureScoutPersonalizzazioni(opp);
opp.boxNascosti = [];
scheduleAutosave();
renderScoutingLivePreview(team);
}
function scoutBoxNuovo(tipo) {
const team = activeTeam(); const opp = team && activeOpponent(team);
if (!opp) return;
ensureScoutPersonalizzazioni(opp);
opp.boxLiberi.push(tipo === 'dati'
? { id: nextId('sbx'), tipo: 'dati', titolo: '', righe: [{ nome: '', valore: '' }, { nome: '', valore: '' }, { nome: '', valore: '' }] }
: { id: nextId('sbx'), tipo: 'testo', titolo: '', testo: '' });
scheduleAutosave();
renderScoutingLivePreview(team);
}
function scoutBoxLiberoTrova(id) {
const team = activeTeam(); const opp = team && activeOpponent(team);
if (!opp) return null;
ensureScoutPersonalizzazioni(opp);
return opp.boxLiberi.find(b => b.id === id) || null;
}
function scoutBoxLiberoTitolo(id, el) {
const b = scoutBoxLiberoTrova(id); if (!b) return;
b.titolo = (el.textContent || '').trim(); scheduleAutosave();
}
function scoutBoxLiberoTesto(id, el) {
const b = scoutBoxLiberoTrova(id); if (!b) return;
b.testo = (el.innerText || '').replace(/\n{3,}/g, '\n\n'); scheduleAutosave();
}
function scoutBoxLiberoRiga(id, i, campo, el) {
const b = scoutBoxLiberoTrova(id); if (!b || !b.righe || !b.righe[i]) return;
b.righe[i][campo] = (el.textContent || '').trim(); scheduleAutosave();
}
function scoutBoxLiberoAggiungiRiga(id) {
const b = scoutBoxLiberoTrova(id); if (!b) return;
if (!Array.isArray(b.righe)) b.righe = [];
b.righe.push({ nome: '', valore: '' });
scheduleAutosave(); renderScoutingLivePreview(activeTeam());
}
function scoutBoxLiberoElimina(id) {
const team = activeTeam(); const opp = team && activeOpponent(team);
if (!opp) return;
ensureScoutPersonalizzazioni(opp);
opp.boxLiberi = opp.boxLiberi.filter(b => b.id !== id);
scheduleAutosave(); renderScoutingLivePreview(team);
}
let scoutSoloPrimaPagina = true;
function scoutCambiaPagine(v) {
scoutSoloPrimaPagina = !!v;
renderScoutingLivePreview(activeTeam());
}
/* Il disegno di un box libero, sia a schermo che in stampa. */
function scoutBoxLiberoHtml(b, editable) {
const tit = escapeHtml(b.titolo || (editable ? '' : ''));
const testaTit = editable
? `${tit} `
: `${tit} `;
const x = editable ? `✕ ` : '';
if (b.tipo === 'dati') {
const righe = (b.righe || []).filter(r => editable || (r.nome || '').trim() || (r.valore || '').trim());
const corpo = righe.map((r, i) => editable
? `
${escapeHtml(r.nome || '')}
${escapeHtml(r.valore || '')}
`
: `${escapeHtml(r.nome || '')} ${escapeHtml(r.valore || '')}
`).join('');
const piu = editable ? `${escapeHtml(t('scout.aggiungiRiga'))} ` : '';
return ``;
}
const corpo = editable
? `${escapeHtml(b.testo || '')}
`
: ((b.testo || '').trim()
? (b.testo || '').split('\n').map(l => l.trim()).filter(Boolean).map(l => `${escapeHtml(l)}
`).join('')
: '—
');
return ``;
}
// EVIDENZIARE COL MAIUSCOLO (deciso col coach il 2 settembre 2026).
// Patto: lui scrive in minuscolo, il foglio stampato mette tutto in
// MAIUSCOLO da solo (text-transform, i dati salvati restano come li ha
// battuti); la parola - o la riga intera - che batte in MAIUSCOLO esce in
// GRASSETTO. Cosi' l'aspetto del foglio non cambia e il segnale non costa
// nessun simbolo strano da ricordare.
// Nella vista dove scrive (pr-scouting-page-editable) NON si maiuscola
// niente, cosi' vede quali parole ha messo in maiuscolo.
function rigaTuttaMaiuscola(l) {
const s = (l || '').toString().trim();
if (!s || s.length > 60) return false;
return s === s.toUpperCase() && s !== s.toLowerCase(); // ha lettere ed e' tutta alta
}
function grassettoMaiuscole(l) {
return (l || '').toString().split(/(\s+)/).map(pezzo => {
const nudo = pezzo.replace(/[^\p{L}\p{N}]/gu, '');
const alta = nudo.length >= 2 && nudo === nudo.toUpperCase() && nudo !== nudo.toLowerCase();
return alta ? `${escapeHtml(pezzo)} ` : escapeHtml(pezzo);
}).join('');
}
// ---------------------------------------------- RIGHE CHE SI SPOSTANO
// Chiesto dal coach il 2 settembre 2026 ("posso giostrare le righe all'interno
// delle box?" -> ha scelto "trascinare col mouse").
// Nella vista dove SCRIVE, le caselle a testo libero non sono piu' un unico
// riquadro: ogni riga e' una riga sua con una maniglia a sinistra, e si puo'
// prendere e portare altrove, anche in un'altra voce o in un'altra casella.
// Il trascinamento e' fatto a mano con mousedown/mousemove/mouseup e NON col
// drag-and-drop di HTML: quello, dentro a contenteditable, litiga con la
// selezione del testo ed e' quasi impossibile da collaudare.
// Sul foglio STAMPATO non cambia niente: li' le righe le disegna sempre
// scoutingReportBoxHtml nel ramo non modificabile.
function scoutRigaHtml(testo, segnaposto) {
return ''
+ '
\u283f '
+ '
'
+ escapeHtml(testo == null ? '' : String(testo)) + '
';
}
function scoutRigheHtml(valore, segnaposto) {
const righe = String(valore == null ? '' : valore).split('\n');
if (righe.length === 1 && !righe[0].trim()) return scoutRigaHtml('', segnaposto);
return righe.map(r => scoutRigaHtml(r, segnaposto)).join('');
}
function scoutValoreRighe(wrap) {
return Array.prototype.slice.call(wrap.querySelectorAll('.pr-testo'))
.map(t => (t.innerText || '').replace(/\n/g, ' ').trim())
.filter((r, i, a) => r !== '' || i < a.length - 1) // l'ultima riga vuota non conta
.join('\n');
}
// Scrive nel modello SENZA ridisegnare il foglio: se ridisegnasse, il cursore
// salterebbe via a ogni tasto. Tiene allineata anche la casella del modulo.
function scoutScriviCampo(percorso, valore) {
const team = activeTeam();
if (!team) return;
const opp = activeOpponent(team);
if (!opp) return;
setByPath(ensureScoutingReport(opp), percorso, valore);
const id = SCOUTING_PATH_TO_ID[percorso];
const campo = id ? document.getElementById(id) : null;
if (campo) campo.value = valore;
scheduleAutosave();
}
function scoutSalvaRighe(wrap) {
if (!wrap || !wrap.dataset || !wrap.dataset.percorso) return;
scoutScriviCampo(wrap.dataset.percorso, scoutValoreRighe(wrap));
}
function scoutRigaScritta(el) { scoutSalvaRighe(el.closest('.pr-righe')); }
function scoutRigaTasto(ev, el) {
if (ev.key === 'Enter') {
ev.preventDefault();
const riga = el.closest('.pr-riga');
const culla = document.createElement('div');
culla.innerHTML = scoutRigaHtml('', el.getAttribute('data-placeholder') || '');
const nuova = culla.firstChild;
riga.parentNode.insertBefore(nuova, riga.nextSibling);
nuova.querySelector('.pr-testo').focus();
scoutSalvaRighe(nuova.closest('.pr-righe'));
return;
}
if (ev.key === 'Backspace' && !(el.innerText || '').trim()) {
const riga = el.closest('.pr-riga');
const wrap = riga.parentNode;
if (wrap.querySelectorAll('.pr-riga').length <= 1) return; // l'ultima resta
ev.preventDefault();
const prima = riga.previousElementSibling;
riga.remove();
if (prima) {
const t = prima.querySelector('.pr-testo');
t.focus();
const sel = window.getSelection(), r = document.createRange();
r.selectNodeContents(t); r.collapse(false); sel.removeAllRanges(); sel.addRange(r);
}
scoutSalvaRighe(wrap);
}
}
let scoutRigaPresa = null;
document.addEventListener('mousedown', function (ev) {
if (!ev.target || !ev.target.closest) return;
const m = ev.target.closest('.pr-maniglia');
if (!m) return;
ev.preventDefault(); // se no parte la selezione del testo
const riga = m.closest('.pr-riga');
scoutRigaPresa = { riga: riga, da: riga.parentNode };
riga.classList.add('pr-riga-presa');
document.body.classList.add('pr-sto-trascinando');
});
document.addEventListener('mousemove', function (ev) {
if (!scoutRigaPresa) return;
const sotto = document.elementFromPoint(ev.clientX, ev.clientY);
if (!sotto || !sotto.closest) return;
const bersaglio = sotto.closest('.pr-riga');
const wrap = sotto.closest('.pr-righe');
if (bersaglio && bersaglio !== scoutRigaPresa.riga) {
const r = bersaglio.getBoundingClientRect();
const sopra = ev.clientY < r.top + r.height / 2;
bersaglio.parentNode.insertBefore(scoutRigaPresa.riga, sopra ? bersaglio : bersaglio.nextSibling);
} else if (wrap && !bersaglio && wrap !== scoutRigaPresa.riga.parentNode) {
wrap.appendChild(scoutRigaPresa.riga); // casella vuota, o lo spazio sotto le righe
}
});
document.addEventListener('mouseup', function () {
if (!scoutRigaPresa) return;
const riga = scoutRigaPresa.riga, da = scoutRigaPresa.da, arrivo = riga.parentNode;
riga.classList.remove('pr-riga-presa');
document.body.classList.remove('pr-sto-trascinando');
scoutRigaPresa = null;
scoutSalvaRighe(arrivo.closest ? arrivo.closest('.pr-righe') : null);
if (da !== arrivo) scoutSalvaRighe(da.closest ? da.closest('.pr-righe') : null);
});
function scoutingReportBoxHtml(label, sections, extraClass, editable, chiave) {
const squadraOra = activeTeam();
const oppOra = squadraOra ? activeOpponent(squadraOra) : null;
// Il sottotitolo scritto a mano vince su quello predefinito, in stampa
// come a schermo.
const etichetta = (sec, i) => sottotitoloBox(oppOra, chiave ? chiave + ':' + i : '', sec.label);
let body;
if (editable) {
body = sections.map((sec, i) => {
const mia = etichetta(sec, i);
const headHtml = sec.label
? `${escapeHtml(mia)} `
: '';
if (sec.path) {
// Una riga per riga, con la maniglia per spostarla (vedi scoutRigheHtml).
const ph = escapeHtml(t('relazione.boxPhSingle'));
return headHtml + `${scoutRigheHtml(sec.text, ph)}
`;
}
const lines = (sec.text || '').toString().split('\n').map(l => l.trim()).filter(Boolean);
return headHtml + (lines.length ? lines.map(l => `${escapeHtml(l)}
`).join('') : `—
`);
}).join('');
} else {
const filled = sections.map((sec, i) => ({ sec, i })).filter(x => (x.sec.text || '').toString().trim());
body = filled.length
? filled.map(({ sec, i }) => {
const lines = sec.text.toString().split('\n').map(l => l.trim()).filter(Boolean);
const valueHtml = lines.map(l => rigaTuttaMaiuscola(l)
? `${escapeHtml(l)} `
: `${grassettoMaiuscole(l)}
`).join('');
return (sec.label ? `${escapeHtml(etichetta(sec, i))} ` : '') + valueHtml;
}).join('')
: `—
`;
}
const team = squadraOra;
const opp = oppOra;
const titolo = chiave ? titoloBox(opp, chiave, label) : label;
const testa = (editable && chiave)
? `${escapeHtml(titolo)} `
: `${escapeHtml(titolo)} `;
const x = (editable && chiave) ? `✕ ` : '';
// Il corpo sta in un involucro suo: serve a poterlo rimpicciolire quando
// il coach ha scritto piu' di quanto ci stia, senza toccare il titolo ne'
// la misura della casella dentro alla griglia del foglio.
return ``;
}
// Scrive dal foglio sempre visibile (contenteditable) direttamente nel
// modello dati, e tiene sincronizzata anche la casella "sorgente"
// corrispondente nel modulo qui sopra (SCOUTING_FIELD_MAP) — ma NON
// ri-renderizza il foglio stesso: altrimenti si perderebbe il cursore ad
// ogni tasto premuto qui dentro (stesso principio già usato per il nome del
// gioco nel Playbook, onPlaybookRowNameLiveEdit).
function onScoutingBoxFieldEdit(path, el) {
const team = activeTeam();
if (!team) return;
const opp = activeOpponent(team);
if (!opp) return;
const value = (el.innerText || '').replace(/\r/g, '');
setByPath(ensureScoutingReport(opp), path, value);
const fieldId = SCOUTING_PATH_TO_ID[path];
const fieldEl = fieldId ? document.getElementById(fieldId) : null;
if (fieldEl && fieldEl !== el) fieldEl.value = value;
scheduleAutosave();
}
// Letterhead dedicato allo Scouting Report (banda blu con stemma/bandiera/VS
// + riga data-ora-sede/girone), copiato dal foglio ITALBASKET WOMAN portato
// dal coach come riferimento — diverso dal printPageHeader generico usato da
// Playbook/Personnel/Practice, che qui non basta più (niente bandiera/VS/
// girone in quello).
function scoutingLetterheadHtml(team, opp) {
ensureOpponentMatchInfo(opp);
const metaParts = [];
if (opp.dataPartita) metaParts.push('📅 ' + escapeHtml(opp.dataPartita));
if (opp.ora) metaParts.push('🕐 ' + escapeHtml(opp.ora));
if (opp.sede) metaParts.push('📍 ' + escapeHtml(opp.sede));
// Stessa fascia del Personnel: la nostra squadra a sinistra, VS al centro,
// l'avversaria (con la sua bandiera) a destra.
const avversaria = { name: opp.name || '', flag: opp.flag || '', logo: opp.logo || '' };
const ctx = { foglio: 'scouting', teamId: ((team && team.id) || '') + '~' + (opp.id || '') };
return sheetLetterheadHtml(
{ badge: team, eyebrow: t('print.scoutingTitle'), title: (team && team.name) || '', sub: opp.competizione || '' },
t('relazione.vsLabel'),
{ badge: avversaria, title: opp.name || '' },
ctx
) + `
${sheetHeadField(ctx, 'bar.sx', metaParts.length ? metaParts.join(' ') : ' ', 'mb-txt', true)}
${sheetHeadField(ctx, 'bar.dx', opp.girone ? escapeHtml(opp.girone) : ' ', 'mb-txt mb-txt-r', true)}
`;
}
// Scheda stampabile di UNA giocatrice avversaria (pagina 2 del foglio di
// riferimento): riusa lo stile .print-player-card già usato dal Personnel
// (foglio proprio roster), con in più la riga statistiche estesa, il Play
// Type e le note tattiche — tutte e tre nascoste se non compilate, così una
// scheda con solo il nome resta comunque leggibile e non enorme.
function oppPlayerPrintCardHtml(p) {
const statParts = [
p.stats.pt && (p.stats.pt + ' PT'),
p.stats.tiro && (p.stats.tiro + (('' + p.stats.tiro).indexOf('%') === -1 ? '% TIRO' : ' TIRO')),
p.stats.tiro3 && (p.stats.tiro3 + (('' + p.stats.tiro3).indexOf('%') === -1 ? '% DA 3' : ' DA 3')),
p.stats.ast && (p.stats.ast + ' AS'),
p.stats.reb && (p.stats.reb + ' REC'),
p.stats.pp && (p.stats.pp + ' PP'),
p.stats.stoppate && (p.stats.stoppate + ' STOPPATE'),
].filter(Boolean);
const playTypeLines = (p.playType || '').split('\n').map(l => l.trim()).filter(Boolean);
const noteLines = (p.note || '').split('\n').map(l => l.trim()).filter(Boolean);
return `
#${escapeHtml(p.number || '—')} ${escapeHtml(p.name || t('relazione.oppPlayerNamePh'))} ${escapeHtml(roleLabel(p.role))}
${(p.height || p.birthYear) ? `
${escapeHtml([p.height && p.height + ' cm', p.birthYear].filter(Boolean).join(' · '))}
` : ''}
${statParts.length ? `
${escapeHtml(statParts.join(' - '))}
` : ''}
${playTypeLines.length ? `
${escapeHtml(t('relazione.playTypeLabel'))} ${escapeHtml(playTypeLines.join(' - '))}
` : ''}
${noteLines.length ? `
${noteLines.map(l => escapeHtml(l)).join(' ')}
` : ''}
`;
}
// Foglio "Scouting Report" come ITALBASKET WOMAN (riferimento caricato dal
// coach): pagina 1 con letterhead + griglia Punti di forza/Per vincere/
// Strategie/Partita + le 2 caselle tiratrici finali (Miglior da 3/Peggior
// TL — Statistiche di squadra/avanzate/Miglior TL restano SOLO nella vista a
// schermo per la compilazione, non in stampa/anteprima, esattamente come nel
// foglio di riferimento), pagina 2 con le schede delle giocatrici avversarie.
// Estratta dalla vecchia printScoutingReport() così la stessa identica pagina
// si può sia aprire nel modale di stampa, sia tenerla sempre visibile a
// schermo (vedi renderScoutingLivePreview) — un solo posto che genera il
// foglio "come un foglio A4", niente logica duplicata fra le due viste.
function buildScoutingReportPageHtml(team, league, editable) {
const opp = activeOpponent(team);
// Nessun avversario ancora: foglio "vuoto" con solo l'invito ad
// aggiungerne uno, invece di un Team Report senza senso (lo scouting è
// per definizione uno studio della squadra AVVERSARIA). In pratica non
// dovrebbe quasi mai capitare (ensureOpponents ne crea sempre almeno uno
// di default), ma resta come rete di sicurezza.
if (!opp) {
return `
${printPageHeader(team, league, t('print.scoutingTitle'))}
${escapeHtml(t('relazione.teamReportTitle'))}
${escapeHtml(t('relazione.noOpponentYet'))}
`;
}
const sr = ensureScoutingReport(opp);
const tiro3ptCombo = [sr.puntiForza.tiro3pt.media, sr.puntiForza.tiro3pt.tiratrici].filter(Boolean).join('\n');
const tiro3ptComboPartita = [sr.partita.tiro3pt.media, sr.partita.tiro3pt.tiratrici].filter(Boolean).join('\n');
// Ordine dei sotto-campi in stampa = stesso ordine del foglio di
// riferimento (Forza mentale, Difesa, Attacco, Tiro da 3pt, 1 contro 1 per
// "Punti di forza" — diverso dall'ordine di compilazione a schermo qui
// sopra, che invece segue l'ordine più naturale Attacco→Difesa).
// Nella casella dei liberi finiscono INSIEME il peggior tiratore singolo e
// l'elenco degli altri (prima erano due caselle separate, una a metà foglio
// e una in fondo): sul foglio è un'informazione sola. In anteprima
// editabile le righe restano due, per poterle scrivere entrambe.
const sezioniLiberi = [{ text: sr.peggiorTiratriceTL, path: 'peggiorTiratriceTL' }];
ensureScoutPersonalizzazioni(opp);
const tolto = (k) => opp.boxNascosti.indexOf(k) >= 0;
const box = (k, label, sezioni, cls) => tolto(k) ? '' : scoutingReportBoxHtml(label, sezioni, cls, editable, k);
const reportGrid = ''
+ box('forza', t('relazione.boxForza'), [
{ label: t('relazione.subForzaMentale'), text: sr.puntiForza.forzaMentale, path: 'puntiForza.forzaMentale' },
{ label: t('relazione.subDifesa'), text: sr.puntiForza.difesa, path: 'puntiForza.difesa' },
{ label: t('relazione.subAttacco'), text: sr.puntiForza.attacco, path: 'puntiForza.attacco' },
/* TIRO DA 3 e 1 CONTRO 1 TOLTI dal foglio il 2 settembre 2026, ordine
del coach: le tiratrici da 3 hanno gia' la loro casella in fondo al
foglio e ripeterle qui dentro era doppio lavoro. I dati restano
salvati (sr.puntiForza.tiro3pt, sr.puntiForza.unoUno): per
rimetterli basta togliere questo commento.
{ label: t('relazione.subTiro3pt'), text: tiro3ptCombo },
{ label: t('relazione.subUnoUno'), text: sr.puntiForza.unoUno, path: 'puntiForza.unoUno' },
*/
], 'pr-a-forza')
/* LA CASELLA PARTITA NON VA PIU' SUL FOGLIO. Ordine chiesto dal coach
il 2 settembre 2026: PUNTI DI FORZA a sinistra e PER VINCERE a
destra sulla stessa riga, poi STRATEGIE PARTITA larga tutta la
pagina, poi le due dei tiri. "E niente altro: se devo aggiungere
qualche box c'e' gia' l'opzione."
I dati di sr.partita restano salvati nel file e la parte a schermo
per scriverli c'e' ancora: per rimettere la casella sul foglio
basta togliere questo commento.
+ box('partita', t('relazione.boxPartita'), [
{ label: t('relazione.subForzaMentale'), text: sr.partita.forzaMentale, path: 'partita.forzaMentale' },
{ label: t('relazione.subDifesa'), text: sr.partita.difesa, path: 'partita.difesa' },
{ label: t('relazione.subAttacco'), text: sr.partita.attacco, path: 'partita.attacco' },
{ label: t('relazione.subTiro3pt'), text: tiro3ptComboPartita },
{ label: t('relazione.subUnoUno'), text: sr.partita.unoUno, path: 'partita.unoUno' },
], 'pr-a-partita')
*/
+ box('vincere', t('relazione.boxVincere'), [
{ label: t('relazione.subForzaMentale'), text: sr.perVincere.forzaMentale, path: 'perVincere.forzaMentale' },
{ label: t('relazione.subAttacco'), text: sr.perVincere.attacco, path: 'perVincere.attacco' },
{ label: t('relazione.subDifesa'), text: sr.perVincere.difesa, path: 'perVincere.difesa' },
], 'pr-a-vincere')
// La strategia sta SOPRA la riga dei tiratori, con il suo box a tutta
// larghezza (richiesta del coach).
+ box('strategie', t('relazione.boxStrategie'), [{ text: sr.strategie.difesa, path: 'strategie.difesa' }], 'pr-a-strategie')
+ opp.boxLiberi.map(b => scoutBoxLiberoHtml(b, editable)).join('')
+ '
'
// Ultima riga del foglio: liberi a sinistra, tiro da 3 a destra.
+ ''
+ box('tiro3', t('relazione.boxMiglior3pt'), [{ text: sr.migliorTiratrice3pt, path: 'migliorTiratrice3pt' }], 'pr-stat')
+ box('liberi', t('relazione.boxPeggiorLiberi'), sezioniLiberi, 'pr-stat')
+ '
';
const accSr = (team && typeof team.colorHex === 'string' && /^#[0-9a-f]{6}$/i.test(team.colorHex)) ? team.colorHex : '#2f6ba8';
const page1 = `
${scoutingLetterheadHtml(team, opp)}
${reportGrid}
`;
// Pagina delle giocatrici: si compone DA SOLA dalle schede del roster di
// quella squadra nella lega. Compaiono solo quelle a cui il coach ha messo
// il flag della posizione, nel posto che ha scelto: P1..P6 nella colonna di
// destra (P1 in alto a destra), P7..P12 nella colonna di sinistra. Così
// mentre si compilano le schede si vede già come verrà il report, senza
// riscrivere i dati una seconda volta qui dentro.
const page2 = scoutSoloPrimaPagina ? '' : scoutingReportPlayersPageHtml(team, opp, league);
const barra = editable ? `
${escapeHtml(t('scout.boxNuovoTesto'))}
${escapeHtml(t('scout.boxNuovoDati'))}
🤖 USO IA
${opp.boxNascosti.length ? `${escapeHtml(t('scout.rimettiBox'))} (${opp.boxNascosti.length}) ` : ''}
${(Object.keys(opp.titoliBox || {}).length + Object.keys(opp.titoliSezione || {}).length) ? `${escapeHtml(t('scout.titoliOriginali'))} ` : ''}
${escapeHtml(t('scout.soloPrimaPagina'))}
${opp.statAvanzate ? ` 📊 Statistiche avanzate ` : ''}
${escapeHtml(t('scout.titoloModificabile'))}
` : '';
return barra + page1 + page2;
}
// Scheda giocatrice stampata nel report: foto, numero e nome, sigla ruolo,
// altezza e anno, medie, e le quattro caratteristiche individuali.
function reportPlayerCardHtml(p, pos) {
const stats = [['PT', p.stats && p.stats.pts], ['RB', p.stats && p.stats.reb], ['AS', p.stats && p.stats.ast],
['2P', p.stats && p.stats.p2], ['3P', p.stats && p.stats.p3], ['TL', p.stats && p.stats.ft]];
const bio = [roleShort(p.role), p.height ? p.height + ' cm' : '', p.birthYear || ''].filter(Boolean).join(' · ');
const traits = (p.traits || []).filter(x => (x || '').trim());
return `
P${pos}
${p.photo ? `
` : '
'}
${p.number ? '#' + escapeHtml(String(p.number)) + ' ' : ''}${escapeHtml(p.name || '')}
${bio ? `
${escapeHtml(bio)}
` : ''}
${stats.map(([k, v]) => `${escapeHtml(v == null || v === '' ? '—' : String(v))} ${k} `).join('')}
${[0, 1, 2, 3].map(i => `
${escapeHtml(traits[i] || '')}
`).join('')}
`;
}
function reportEmptyPlayerCardHtml(pos) {
return `
P${pos}
${[0, 1, 2, 3].map(() => '
').join('')}
`;
}
// Le 12 posizioni del report, prese dalle schede flaggate del roster di lega
// di quella squadra (agganciata per nome, come il logo in testa al foglio).
function scoutingReportSlottedPlayers(opp, league) {
const perPos = {};
if (!opp || !league) return perPos;
const key = (opp.name || '').trim().toLowerCase();
if (!key) return perPos;
const squadra = (league.teams || []).find(x => x && (x.name || '').trim().toLowerCase() === key)
|| (league.oppRosters || []).find(x => x && (x.name || '').trim().toLowerCase() === key);
if (!squadra || !Array.isArray(squadra.players)) return perPos;
squadra.players.forEach(pl => {
if (!pl) return;
const n = parseInt(pl.reportSlot, 10);
if (n >= 1 && n <= 12 && !perPos[n] && (pl.name || '').trim()) perPos[n] = pl;
});
return perPos;
}
function scoutingReportPlayersPageHtml(team, opp, league) {
const perPos = scoutingReportSlottedPlayers(opp, league);
const flaggate = Object.keys(perPos).length;
// Nessun flag ancora: resta la vecchia pagina compilata a mano dentro lo
// Scouting Report, così chi aveva già scritto lì i suoi dati non li perde.
if (!flaggate) {
const players = ensureOpponentPlayers(opp);
return `
${escapeHtml(t('relazione.oppPlayersTitle', { name: opp.name }))}
${players.length
? `
${players.map(oppPlayerPrintCardHtml).join('')}
`
: `
${escapeHtml(t('relazione.noFlaggedPlayers'))}
`}
`;
}
const colonna = (da, a) => {
const celle = [];
for (let n = da; n <= a; n++) celle.push(perPos[n] ? reportPlayerCardHtml(perPos[n], n) : reportEmptyPlayerCardHtml(n));
return `${celle.join('')}
`;
};
return `
${scoutingLetterheadHtml(team, opp)}
${escapeHtml(t('relazione.oppPlayersTitle', { name: opp.name }))}
${colonna(7, 12)}
${colonna(1, 6)}
`;
}
function printScoutingReport() {
const team = activeTeam();
if (!team) return;
const league = activeLeague();
openPrintPreview(t('print.scoutingTitle'), buildScoutingReportPageHtml(team, league));
}
// Anteprima sempre visibile (non modale) sotto il Team Report: si aggiorna
// da sola ad ogni tasto premuto nelle caselle (vedi updateScoutingReportField/
// updateOpponentMatchInfo/updateOpponentPlayerField), così il coach vede in
// tempo reale come uscirà il foglio stampato — niente bisogno di aprire
// "Anteprima e Stampa" per controllare.
function renderScoutingLivePreview(team) {
const el = document.getElementById('scoutingLivePreview');
if (!el) return;
if (!team) { el.innerHTML = ''; return; }
el.innerHTML = buildScoutingReportPageHtml(team, activeLeague(), true);
requestAnimationFrame(() => adattaFogliCoach(el));
}
// Foglio stampabile "Personnel": le 12 caselle a slot fisso (stessa griglia
// 6+6 della vista a video). Stesso foglio bianco/nero "carta" del resto
// dell'app (Playbook/Practice/Scouting Report) — non più il tema scuro
// dedicato di prima — con foto, nome/ruolo, altezza/anno, una riga di
// statistiche e 4 righe per le caratteristiche, come sulla scheda vera.
function printPersonnelCardHtml(p) {
const num = String(p.number == null ? '' : p.number).trim();
const stat = (etichetta, valore) =>
`${escapeHtml(String(valore == null || valore === '' ? '—' : valore))} ${etichetta} `;
// Percentuali con una barretta sotto al numero: si vede a colpo d'occhio
// chi tira bene e chi no, senza doverli leggere uno per uno.
const pct = (etichetta, valore) => {
const n = parseFloat(String(valore == null ? '' : valore).replace(',', '.'));
const q = isFinite(n) ? Math.max(0, Math.min(100, n)) : null;
return `
${escapeHtml(String(valore == null || valore === '' ? '—' : valore))} ${etichetta}
`;
};
const posto = (p.slot >= 1 && p.slot <= 12) ? `P${p.slot} ` : '';
return `
${posto}
${escapeHtml(num || '—')} ${escapeHtml(p.name)}
${escapeHtml(roleLabel(p.role))}
${escapeHtml(p.height ? p.height + ' cm' : '—')}
${escapeHtml(p.birthYear || '—')}
${stat('PT', p.stats.pts)}${stat('RB', p.stats.reb)}${stat('AS', p.stats.ast)}
${pct('2P', p.stats.p2)}${pct('3P', p.stats.p3)}${pct('TL', p.stats.ft)}
${[0, 1, 2, 3].map(i => {
const testo = (p.traits || [])[i] || '';
return `
${escapeHtml(testo)}
`;
}).join('')}
`;
}
// Anche la casella "slot vuoto" (nessuna giocatrice ancora assegnata a quel
// numero di rotazione) ha ORA la stessa struttura completa della casella
// piena — riga statistiche e 4 righe caratteristiche, tutte vuote/pronte da
// compilare a mano — invece di restare un semplice segnaposto minimo: le 12
// caselle così restano tutte uguali in altezza/struttura, coerenti fra loro
// sul foglio.
function printEmptySlotCardHtml(slot) {
return `
P${slot} ${escapeHtml(personnelSlotRoleLabel(slot))}
—
${['PT','RB','AS','2P','3P','TL'].map(e => `— ${e} `).join('')}
${[0, 1, 2, 3].map(() => `
`).join('')}
`;
}
// Come per lo Scouting Report: la generazione della pagina è estratta in una
// funzione a parte, condivisa fra il modale di stampa e l'anteprima live
// sempre visibile sotto le schede giocatrici.
// Schiarisce/scurisce un colore esadecimale: serve per la fascia di
// intestazione del Personnel (sfumatura dal colore squadra a una sua tinta
// più scura) senza dover chiedere al coach due colori invece di uno.
function tonoColore(hex, q) {
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || ''));
const n = m ? parseInt(m[1], 16) : 0x12365c;
const canale = (v) => {
const x = q < 0 ? v * (1 + q) : v + (255 - v) * q;
return Math.max(0, Math.min(255, Math.round(x)));
};
const r = canale((n >> 16) & 255), g = canale((n >> 8) & 255), b = canale(n & 255);
return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('');
}
// Fascia di intestazione CONDIVISA fra i fogli (Scouting Report, Personnel):
// stemma/bandiera + occhiello + titolo a sinistra, eventuale blocco centrale,
// e a destra la squadra del coach (o l'avversaria) con la sua bandiera.
// lati = { badge, eyebrow, title, sub } — badge è una squadra qualsiasi
// (o un oggetto finto {name, flag, logo}) da cui si ricava lo stemma.
function sheetLetterheadHtml(sinistra, centro, destra, ctx) {
const lato = (d, classe, pref) => {
if (!d) return `
`;
const stemma = d.badge ? printBadgeHtml(d.badge, 'lh-badge') : '';
const testo = `
${d.eyebrow ? sheetHeadField(ctx, pref + '.eyebrow', d.eyebrow, 'lh-eyebrow') : ''}
${sheetHeadField(ctx, pref + '.title', d.title || '', 'lh-title')}
${d.sub ? sheetHeadField(ctx, pref + '.sub', d.sub, 'lh-sub') : ''}
`;
return `${classe === 'lh-side-r' ? testo + stemma : stemma + testo}
`;
};
const kicker = (typeof centro === 'string' ? centro : (centro && centro.kicker) || '');
const sotto = (centro && typeof centro === 'object' && centro.sub) ? centro.sub : '';
return `
${lato(sinistra, 'lh-side-l', 'sx')}
${centro ? `
${sheetHeadField(ctx, 'centro', kicker, 'lh-kicker')}
${sotto ? sheetHeadField(ctx, 'centro.sub', sotto, 'lh-center-sub') : ''}
` : ''}
${lato(destra, 'lh-side-r', 'dx')}
`;
}
/* Ogni scritta della fascia è riscrivibile direttamente sul foglio (basta
* cliccarci sopra): utile a chi vuole intitolare il foglio a modo suo senza
* rinominare squadra o campionato. Con html=true il testo automatico può
* contenere icone (la barra data/ora/sede); appena viene riscritto a mano
* diventa testo semplice. */
function sheetHeadField(ctx, campo, testo, cls, html) {
const automatico = html ? testo : escapeHtml(String(testo == null ? '' : testo));
if (!ctx || !ctx.foglio) return `${automatico}
`;
const scritto = ensureHeaderOverrides()[sheetHeadKey(ctx.foglio, ctx.teamId, campo)];
const corpo = (typeof scritto === 'string' && scritto.trim()) ? escapeHtml(scritto) : automatico;
const k = escapeHtml(sheetHeadKey(ctx.foglio, ctx.teamId, campo));
return `${corpo}
`;
}
// Intestazione del foglio Personnel: squadra studiata a sinistra, PERSONNEL
// al centro, squadra del coach (con la sua bandiera) a destra — nessuna
// dicitura "preparato per", tolta su richiesta.
function personnelLetterheadHtml(team, league, quante) {
const casa = squadraDelCoach(league, team);
const data = new Date().toLocaleDateString(localeUI(), { day: '2-digit', month: 'short', year: 'numeric' }).toUpperCase();
const propria = (casa && (!team || casa.id !== team.id))
? { badge: casa, title: casa.name || '' }
: null;
const ctx = { foglio: 'personnel', teamId: (team && team.id) || '' };
return sheetLetterheadHtml(
{ badge: team, eyebrow: t('print.personnelTitle'), title: (team && team.name) || '', sub: (league && league.name) || '' },
null,
propria,
ctx
) + `
${sheetHeadField(ctx, 'bar.sx', `${t('print.personnelCards', { n: quante || 0 })} · ${escapeHtml(data)}`, 'mb-txt', true)}
${sheetHeadField(ctx, 'bar.dx', t('print.personnelLegend'), 'mb-txt mb-txt-r', true)}
`;
}
function buildPersonnelPageHtml(team, league) {
normalizePersonnelSlots(team);
const bySlot = {};
const senzaPosto = [];
team.players.filter(p => rosterRowIsFilled(p) && !p.fuoriFoglio).forEach(p => {
const n = parseInt(p.slot, 10);
if (n >= 1 && n <= 12 && !bySlot[n]) bySlot[n] = p;
else senzaPosto.push(p);
});
// Nessuna giocatrice deve restare fuori dal foglio per un incastro di
// posizioni: quelle rimaste senza casella occupano le prime libere.
for (let i = 1; i <= 12 && senzaPosto.length; i++) if (!bySlot[i]) bySlot[i] = senzaPosto.shift();
/* SUL FOGLIO CI VANNO SOLO LE GIOCATRICI CHE CI SONO. Con dieci in rosa si
* stampano dieci schede — cinque per colonna — più grandi e con più spazio
* per le caratteristiche, invece di dodici caselle di cui due vuote. Le
* posizioni restano quelle: la colonna di sinistra è la prima metà (1-6),
* quella di destra la seconda (7-12), e chi sta in P3 resta il terzo di
* sinistra. Se non c'è NESSUNA giocatrice si stampa comunque il foglio
* vuoto con le dodici caselle, che serve da modulo da riempire a mano. */
const sinistra = [], destra = [];
for (let slot = 1; slot <= 6; slot++) if (bySlot[slot]) sinistra.push(bySlot[slot]);
for (let slot = 7; slot <= 12; slot++) if (bySlot[slot]) destra.push(bySlot[slot]);
const quante = sinistra.length + destra.length;
let cards = [];
let righe = 6;
if (quante === 0) {
for (let slot = 1; slot <= 12; slot++) cards.push(printEmptySlotCardHtml(slot));
} else {
righe = Math.max(sinistra.length, destra.length, 1);
// Le due colonne devono avere lo stesso numero di righe: il buco eventuale
// resta in fondo, non spezza l'incolonnamento.
const colonna = (arr) => {
const out = arr.map(printPersonnelCardHtml);
while (out.length < righe) out.push('
');
return out;
};
cards = colonna(sinistra).concat(colonna(destra));
}
const acc = (team && typeof team.colorHex === 'string' && /^#[0-9a-f]{6}$/i.test(team.colorHex)) ? team.colorHex : '#12365c';
const stile = `--pacc:${acc};--pacc2:${tonoColore(acc, -0.45)};--pacc3:${tonoColore(acc, 0.35)};`;
return `
${personnelLetterheadHtml(team, league, quante || 12)}
${cards.join('')}
`;
}
function printPersonnel() {
// Stampa il Personnel della squadra che si sta guardando (il box aperto),
// non sempre e solo la propria.
const team = personnelTeam();
if (!team) return;
const league = activeLeague();
openPrintPreview(t('print.personnelTitle'), buildPersonnelPageHtml(team, league));
}
// Anteprima sempre visibile sotto le 12 schede giocatrici: si aggiorna da
// sola ad ogni campo modificato (foto/altezza/anno/statistiche/caratteristiche,
// numero/nome/ruolo dal roster) — stesso principio di renderScoutingLivePreview.
function renderPersonnelLivePreview(team) {
const el = document.getElementById('personnelLivePreview');
if (!el) return;
if (!team) { el.innerHTML = ''; return; }
el.innerHTML = buildPersonnelPageHtml(team, activeLeague());
requestAnimationFrame(() => adattaFogliCoach(el));
// Targhetta sotto al foglio: DI CHI è, quante giocatrici ci sono dentro e a
// che ora è stato ridisegnato. Se si scrive qualcosa e l'ora non cambia,
// vuol dire che si sta guardando un foglio di un'altra squadra (o un file
// vecchio) — invece di restare col dubbio si vede subito.
const stampa = document.getElementById('personnelSheetStamp');
if (stampa) {
const n = (team.players || []).filter(rosterRowIsFilled).length;
const ora = new Date().toLocaleTimeString(localeUI(), { hour: '2-digit', minute: '2-digit', second: '2-digit' });
stampa.textContent = t('relazione.sheetStamp', { squadra: (team.name || '—'), n, ora });
}
}
// Riporta la misura del player scelta l'ultima volta (slider "↔️") PRIMA del
// primo render, così la scheda Video si apre già alla grandezza giusta
// invece di ripartire dal 56% di default e "saltare" un istante dopo.
prefillVideoWidthFromMemory();
renderAll();
// Prova a ricollegarsi da sola alla cartella di riferimento già collegata in
// una sessione precedente di questo stesso browser (vedi tryAutoReconnectFolderRef
// più sopra) — silenzioso, nessun popup se il permesso non è più valido.
tryAutoReconnectFolderRef();
// Stessa cosa per la cartella del LAVORO IN COMUNE: se era già collegata,
// si riattacca da sola e riparte lo scambio automatico con gli altri
// allenatori (vedi condAutoRiconnetti).
condAutoRiconnetti();
// Uscendo si toglie il proprio segnale di presenza dalla cartella, così gli
// altri non vedono un allenatore "collegato" che in realtà ha chiuso.
window.addEventListener('beforeunload', () => { try { condFirmaUscita(); } catch (e) {} });
// Ripropone il nome coach scritto l'ultima volta, così resta fisso da
// un'apertura all'altra finché non lo si cambia di persona.
prefillCoachNameFromMemory();
(function mostraVersione() {
const el = document.getElementById('appBuildTag');
if (!el) return;
el.textContent = APP_BUILD;
// Nell'app installata conta la versione dell'APP, non quella del file:
// è quella che il coach vede negli aggiornamenti.
if (NATIVO) {
window.VDM.info().then(i => { el.textContent = 'v' + i.versione; el.title = 'VDM ' + i.versione + ' · ' + i.piattaforma; }).catch(() => {});
}
})();
// L'app si apre SEMPRE dalla prima pagina (la splash), anche se l'ultima
// volta il coach aveva già una lega/squadra attiva — niente più salto
// automatico. Il banner "Continua sessione precedente" resta comunque
// disponibile in splash se c'è un autosalvataggio da riprendere: un click
// e si è di nuovo dove si era rimasti, ma è una scelta del coach, non
// automatica.
// Se QUESTO file è stato salvato con "💾 Salva lavoro nel file", il lavoro è
// scritto dentro di lui: si riparte da lì senza chiedere niente. È il
// salvataggio che viaggia col file e non dipende dalla memoria del browser.
function bootFromEmbeddedState() {
const emb = readEmbeddedState();
if (!emb) return;
DB = emb.db;
if (!Array.isArray(DB.singleMatches)) DB.singleMatches = [];
reseedIdSequenceFromDB(DB);
singleMatchMode = !!emb.singleMatchMode;
activeSingleMatchId = emb.activeSingleMatchId || null;
activeLeagueId = emb.activeLeagueId || null;
activeTeamId = emb.activeTeamId || null;
activeTeamTab = emb.activeTeamTab || 'squadre';
try { restoreVideoStateFromSnapshot(emb.vt); } catch (e) {}
// Il lavoro salvato dentro al file viene caricato SUBITO in memoria, ma
// l'app resta sulla PRIMA PAGINA: si apre sempre da lì e si decide da lì
// in che sezione andare. Prima si entrava dritti nell'ultima schermata
// usata e non si passava più dalla pagina iniziale.
statoCaricatoDalFile = true;
renderAll();
renderSplashResumeBanner();
setTimeout(() => { try { toast(t('toast.loadedFromFile')); } catch (e) {} }, 400);
}
function entraDaFile() {
hideSplash();
renderAll();
}
// Il blocco di stato è scritto appena prima di , quindi DOPO questo
// script: quando lo script gira, quell'elemento non esiste ancora. Va letto a
// documento completo, altrimenti si riapre sempre sulla schermata iniziale
// come se il file non contenesse nulla.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootFromEmbeddedState);
} else {
bootFromEmbeddedState();
}
renderSplashResumeBanner();
// Nell'app installata il lavoro sta in un file: si legge all'avvio (è una
// lettura da disco, quindi asincrona) e SOLO DOPO si ridisegna il banner
// "Continua sessione precedente", altrimenti sembrerebbe che non ci sia
// niente da riprendere.
if (NATIVO) {
window.VDM.lavoro.leggi().then(testo => {
vbcSnapshotNativo = testo || null;
// Il lavoro torna in memoria da solo: l'elenco delle leghe nella prima
// pagina deve essere pieno gia' adesso, senza dover cliccare niente.
caricaLavoroInMemoria();
renderSplashResumeBanner();
condAutoRiconnetti();
}).catch(() => {});
}
// Il manuale si compone all'avvio, così è pronto appena si apre Istruzioni.
try { manRender(''); } catch (e) {}
// Licenza: solo nell'app installata. Si controlla all'avvio e poi ogni
// mezz'ora (una prova che scade mentre l'app è aperta deve farsi sentire).
if (NATIVO) {
licAggiorna();
setInterval(licAggiorna, 1800000);
}
/* Doppio clic su un file .vdm nel Finder, o scelta dal menu "Aperti di
* recente": il percorso arriva da fuori e qui si apre. */
if (NATIVO && window.VDM.lavoro.quandoAprono) {
try { window.VDM.lavoro.quandoAprono((percorso) => lavoroApriPercorso(percorso)); } catch (e) {}
try {
window.VDM.lavoro.daAprireAllAvvio().then(p => { if (p) lavoroApriPercorso(p); }).catch(() => {});
} catch (e) {}
}
// Agganciamo l'autosalvataggio: qualunque digitazione o click nell'app
// (capture:true, così arriva anche dai contenteditable e dai bottoni con
// onclick inline) pianifica un salvataggio locale con un piccolo debounce,
// e la chiusura/ricarica pagina forza un ultimo salvataggio subito.
document.addEventListener('input', scheduleAutosave, true);
document.addEventListener('change', scheduleAutosave, true);
document.addEventListener('click', scheduleAutosave, true);
window.addEventListener('beforeunload', autosaveNow);