// Nouvelle logique : chaque phase a une séquence d'actions jouées dans l'ordre.
// Chaque action a une durée (ACTION_MS), pendant laquelle :
// - dribble/cut/drive/curl : le joueur glisse vers la fin
// - pass : le ballon vole en arc vers le receveur, owner change à la fin
// - screen : le joueur glisse vers le point d'écran
// - shoot : le ballon vole vers la cible
// - handoff : ballon transmis
// À la fin de toutes les actions d'une phase, on enchaîne sur la phase suivante.
const ACTION_MS_DEFAULT = 900; // durée d'une action
const PHASE_TRANSITION_MS = 600; // transition entre phases (si pas d'actions)
function togglePlay(){
if(editor.isPlaying){ pauseAnim(); } else { startAnim(); }
}
function startAnim(){
if(editor.phases.length < 1){ toast('Aucune phase à jouer', 'error'); return; }
// Vérifier qu'il y a au moins UNE action ou UNE phase suivante
const hasAnyAction = editor.phases.some(p => p.actions.length > 0);
if(!hasAnyAction && editor.phases.length < 2){
toast('Crée des actions ou des phases pour animer', 'error');
return;
}
editor.isPlaying = true;
editor.animStart = performance.now();
// Repart de la phase courante (si on est à la fin, repart du début)
editor.animFromPhase = editor.currentPhase >= editor.phases.length - 1 && editor.currentPhase > 0 ? 0 : editor.currentPhase;
editor.animSequence = buildAnimSequence();
editor.animSeqIdx = findStartIndexForPhase(editor.animFromPhase);
editor.animSeqStartTime = editor.animStart;
// État dynamique des joueurs/ballon pendant l'anim
editor.animState = cloneAnimState(editor.phases[editor.animFromPhase]);
document.getElementById('tlPlay').textContent = '⏸';
editor.selectedId = null;
editor.selectedKind = null;
requestAnimationFrame(animTick);
}
function pauseAnim(){
editor.isPlaying = false;
document.getElementById('tlPlay').textContent = '▶';
}
function stopAnim(){
editor.isPlaying = false;
editor.animProgress = 0;
document.getElementById('tlPlay').textContent = '▶';
document.getElementById('tlBar').style.width = '0%';
editor.animState = null;
render();
}
// Construit la séquence d'animation
// Chaque entrée = { phaseIdx, actions: [...] | null, duration, isTransition }
// - actions = null : transition entre 2 phases sans actions
// - actions = [a1] : 1 action seule
// - actions = [a1, a2, ...] : plusieurs actions jouées en PARALLÈLE
// (les actions consécutives avec startWith=true sont groupées avec la précédente)
function buildAnimSequence(){
const seq = [];
for(let i=0; i<editor.phases.length; i++){
const phase = editor.phases[i];
if(phase.actions.length === 0){
if(i < editor.phases.length - 1){
seq.push({ phaseIdx:i, actions:null, duration:PHASE_TRANSITION_MS / editor.speed, isTransition:true });
}
} else {
// Grouper les actions consécutives avec startWith=true
let currentGroup = null;
for(const a of phase.actions){
if(a.startWith && currentGroup){
// Ajouter à l'étape précédente (= jouer en parallèle)
currentGroup.actions.push(a);
} else {
// Nouvelle étape
if(currentGroup) seq.push(currentGroup);
currentGroup = { phaseIdx:i, actions:[a], duration:ACTION_MS_DEFAULT / editor.speed, isTransition:false };
}
}
if(currentGroup) seq.push(currentGroup);
}
}
return seq;
}
function findStartIndexForPhase(phaseIdx){
for(let i=0; i<editor.animSequence.length; i++){
if(editor.animSequence[i].phaseIdx === phaseIdx) return i;
}
return 0;
}
function cloneAnimState(phase){
return {
players: phase.players.map(p => ({ ...p })),
balls: getPhaseBalls(phase).map(b => ({ ...b })),
ballFlying: null // tableau [{ ballId, x, y }] pendant les passes/tirs
};
}
function animTick(now){
if(!editor.isPlaying) return;
const seq = editor.animSequence;
if(!seq || seq.length === 0){ pauseAnim(); return; }
const elapsed = now - editor.animSeqStartTime;
const current = seq[editor.animSeqIdx];
if(!current){ finishAnim(); return; }
const totalMs = seq.reduce((s,e) => s + e.duration, 0);
const elapsedTotal = now - editor.animStart;
document.getElementById('tlBar').style.width = (Math.min(1, elapsedTotal/totalMs) * 100) + '%';
const t = Math.min(1, elapsed / current.duration);
const tEased = ease(t);
// Mettre à jour l'état pour cette frame
applyActionAtProgress(current, tEased);
// Synchroniser currentPhase pour les vignettes
if(editor.currentPhase !== current.phaseIdx){
editor.currentPhase = current.phaseIdx;
renderPhasesList();
}
render();
if(t >= 1){
// Action terminée : appliquer l'effet définitif sur animState
commitAction(current);
editor.animSeqIdx++;
editor.animSeqStartTime = now;
if(editor.animSeqIdx >= seq.length){
finishAnim();
return;
}
// Si nouvelle phase, snapshot animState depuis la phase
const newCurrent = seq[editor.animSeqIdx];
if(newCurrent.phaseIdx !== current.phaseIdx){
// Snap : on récupère l'état de la nouvelle phase, mais en gardant
// les modifs (positions finales) appliquées via commitAction
editor.animState = cloneAnimState(editor.phases[newCurrent.phaseIdx]);
}
}
requestAnimationFrame(animTick);
}
function finishAnim(){
// Aller à la dernière phase
editor.currentPhase = editor.phases.length - 1;
pauseAnim();
editor.animState = null;
document.getElementById('tlBar').style.width = '100%';
renderPhasesList();
render();
}
// Applique l'effet visuel d'une étape (= 1 ou plusieurs actions en parallèle) à un certain progrès
function applyActionAtProgress(entry, t){
if(!editor.animState) return;
const phase = editor.phases[entry.phaseIdx];
if(!entry.actions){
// Transition entre 2 phases sans actions
if(entry.phaseIdx < editor.phases.length - 1){
const next = editor.phases[entry.phaseIdx + 1];
editor.animState.players = phase.players.map(p => {
const np = next.players.find(x => x.id === p.id);
if(!np) return { ...p };
return { ...p, x: p.x + (np.x - p.x) * t, y: p.y + (np.y - p.y) * t };
});
if(next.balls){ editor.animState.balls = next.balls.map(b => ({ ...b })); }
editor.animState.ballFlying = null;
}
return;
}
// Cumul des ballons en vol pour cette étape (1 par passe/tir/handoff parallèle)
editor.animState.ballFlying = [];
// 1) D'abord traiter TOUS les mouvements de joueurs (dribble/cut/drive/curl/screen)
// pour que leurs positions soient à jour avant qu'on calcule les trajectoires de ballons
for(const a of entry.actions){
if(a.kind === 'dribble' || a.kind === 'cut' || a.kind === 'drive' || a.kind === 'curl' || a.kind === 'screen'){
applySingleActionAtProgress(a, t, phase);
}
}
// 2) Puis les passes/tirs/handoffs (qui utilisent les positions à jour)
for(const a of entry.actions){
if(a.kind === 'pass' || a.kind === 'shoot' || a.kind === 'handoff'){
applySingleActionAtProgress(a, t, phase);
}
}
if(editor.animState.ballFlying.length === 0) editor.animState.ballFlying = null;
}
// Applique l'effet d'UNE action à un certain progrès (ajoute à ballFlying si besoin)
function applySingleActionAtProgress(a, t, phase){
const stateP = editor.animState.players;
const stateBalls = editor.animState.balls || [];
if(a.kind === 'dribble' || a.kind === 'cut' || a.kind === 'drive' || a.kind === 'curl' || a.kind === 'screen'){
const player = stateP.find(p => p.id === a.fromPlayerId);
if(player){
if(!a._animStart){
a._animStart = { x: player.x, y: player.y };
}
const endPos = a.endPos || (a.toPlayerId ? phase.players.find(x => x.id === a.toPlayerId) : null);
if(endPos){
const ctrls = getActionCtrls(a);
const pos = positionOnPath(a._animStart, endPos, ctrls, t);
player.x = pos.x;
player.y = pos.y;
}
}
} else if(a.kind === 'pass'){
const fromP = stateP.find(p => p.id === a.fromPlayerId);
const toP = a.toPlayerId ? stateP.find(p => p.id === a.toPlayerId) : null;
if(fromP){
const ball = stateBalls.find(b => b.ownerId === a.fromPlayerId);
const targetX = toP ? toP.x : (a.endPos ? a.endPos.x : fromP.x);
const targetY = toP ? toP.y : (a.endPos ? a.endPos.y : fromP.y);
const arcHeight = 40;
editor.animState.ballFlying.push({
ballId: ball ? ball.id : null,
x: fromP.x + (targetX - fromP.x) * t,
y: fromP.y + (targetY - fromP.y) * t - Math.sin(t * Math.PI) * arcHeight
});
if(ball) ball.ownerId = null;
}
} else if(a.kind === 'shoot'){
const fromP = stateP.find(p => p.id === a.fromPlayerId);
if(fromP && a.endPos){
const ball = stateBalls.find(b => b.ownerId === a.fromPlayerId);
const arcHeight = 60;
editor.animState.ballFlying.push({
ballId: ball ? ball.id : null,
x: fromP.x + (a.endPos.x - fromP.x) * t,
y: fromP.y + (a.endPos.y - fromP.y) * t - Math.sin(t * Math.PI) * arcHeight
});
if(ball) ball.ownerId = null;
}
} else if(a.kind === 'handoff'){
const fromP = stateP.find(p => p.id === a.fromPlayerId);
const toP = a.toPlayerId ? stateP.find(p => p.id === a.toPlayerId) : null;
if(fromP){
const ball = stateBalls.find(b => b.ownerId === a.fromPlayerId);
const targetX = toP ? toP.x : (a.endPos ? a.endPos.x : fromP.x);
const targetY = toP ? toP.y : (a.endPos ? a.endPos.y : fromP.y);
editor.animState.ballFlying.push({
ballId: ball ? ball.id : null,
x: fromP.x + (targetX - fromP.x) * t,
y: fromP.y + (targetY - fromP.y) * t
});
if(ball) ball.ownerId = null;
}
}
}
function commitAction(entry){
if(!entry.actions || !editor.animState) return;
for(const a of entry.actions){
commitSingleAction(a, entry);
}
editor.animState.ballFlying = null;
}
function commitSingleAction(a, entry){
const stateP = editor.animState.players;
if(a.kind === 'dribble' || a.kind === 'cut' || a.kind === 'drive' || a.kind === 'curl' || a.kind === 'screen'){
const player = stateP.find(p => p.id === a.fromPlayerId);
if(player){
const endPos = a.endPos || (a.toPlayerId ? editor.phases[entry.phaseIdx].players.find(x => x.id === a.toPlayerId) : null);
if(endPos){
player.x = endPos.x;
player.y = endPos.y;
}
}
delete a._animStart;
} else if(a.kind === 'pass' || a.kind === 'handoff'){
const stateBalls = editor.animState.balls || [];
if(a.toPlayerId){
// Trouver un ballon "sans owner" (en vol)
let ball = stateBalls.find(b => !b.ownerId);
if(!ball){
ball = { id:'b_'+Date.now()+'_'+Math.floor(Math.random()*9999), ownerId: a.toPlayerId };
stateBalls.push(ball);
} else {
ball.ownerId = a.toPlayerId;
}
}
} else if(a.kind === 'shoot'){
const stateBalls = editor.animState.balls || [];
if(a.fromPlayerId){
let ball = stateBalls.find(b => !b.ownerId);
if(ball) ball.ownerId = a.fromPlayerId;
}
}
}
function ease(t){ return t<0.5 ? 2*t*t : 1 - Math.pow(-2*t+2, 2)/2; }
// === RENDER ===
function render(){
if(!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawCourt();
const phase = editor.phases[editor.currentPhase];
let players, balls, ballFlying;
if(editor.isPlaying && editor.animState){
players = editor.animState.players;
balls = editor.animState.balls || [];
ballFlying = editor.animState.ballFlying; // tableau { ballId, x, y } pendant les passes
} else {
players = phase.players;
balls = getPhaseBalls(phase);
ballFlying = null;
}
phase.drawings.forEach(d => drawFreedraw(d));
phase.objects.forEach(o => drawObject(o, o.id === editor.selectedId && !editor.isPlaying));
if(!editor.isPlaying){
// État statique : toutes les flèches visibles avec numéro d'ordre
phase.actions.forEach((a, i) => drawAction(a, phase, a.id === editor.selectedId, i+1));
} else if(editor.animSequence && editor.animState){
const seqEntry = editor.animSequence[editor.animSeqIdx];
if(seqEntry && seqEntry.phaseIdx === editor.currentPhase){
const phaseActions = phase.actions;
const currentActions = seqEntry.actions || [];
// Indices des actions en cours (qui peuvent être plusieurs si parallèles)
const currentIndices = new Set();
let minCurrentIdx = -1;
currentActions.forEach(ca => {
const idx = phaseActions.indexOf(ca);
if(idx >= 0){
currentIndices.add(idx);
if(minCurrentIdx < 0 || idx < minCurrentIdx) minCurrentIdx = idx;
}
});
const elapsed = performance.now() - editor.animSeqStartTime;
const progress = Math.min(1, elapsed / seqEntry.duration);
phaseActions.forEach((a, i) => {
if(minCurrentIdx >= 0 && i < minCurrentIdx) return; // déjà jouée
if(currentIndices.has(i)){
const alpha = Math.max(0, 1 - progress);
drawAction(a, phase, false, null, alpha);
} else {
drawAction(a, phase, false, null, 0.55);
}
});
}
}
// Dessiner TOUS les ballons :
// - Ballons en vol (ballFlying) : à la position interpolée
// - Ballons sans propriétaire (au sol) : à leur x,y
// - Ballons avec propriétaire : halo autour du porteur
const flyingIds = new Set();
if(ballFlying && Array.isArray(ballFlying)){
ballFlying.forEach(bf => {
drawBallHalo(bf.x, bf.y, 1);
if(bf.ballId) flyingIds.add(bf.ballId);
});
}
balls.forEach(b => {
if(b.id && flyingIds.has(b.id)) return; // déjà dessiné en vol
if(b.ownerId){
const owner = players.find(p => p.id === b.ownerId);
if(owner) drawBallHalo(owner.x, owner.y, 1);
} else if(b.x !== undefined && b.y !== undefined){
// Ballon au sol
drawGroundBall(b.x, b.y, b.id === editor.selectedId && !editor.isPlaying);
}
});
// Joueurs : le 3e paramètre dit si le joueur porte un ballon (n'importe lequel)
players.forEach(p => {
const hasBall = balls.some(b => b.ownerId === p.id);
drawPlayer(p, p.id === editor.selectedId && !editor.isPlaying, hasBall);
});
}
// Dessine un ballon au sol (sans porteur)
function drawGroundBall(x, y, selected){
ctx.save();
// Cercle orange avec lignes du ballon (réduit en terrain complet)
const r = 18 /* V7.53: rayon hitbox identique */;
ctx.shadowColor = 'rgba(0,0,0,.4)';
ctx.shadowBlur = 6;
ctx.shadowOffsetY = 3;
ctx.fillStyle = '#E8742C';
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI*2);
ctx.fill();
ctx.shadowBlur = 0; ctx.shadowOffsetY = 0;
// Lignes du ballon (style basket)
ctx.strokeStyle = '#3A1A0A';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI*2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x - r, y); ctx.lineTo(x + r, y);
ctx.moveTo(x, y - r); ctx.lineTo(x, y + r);
ctx.stroke();
// Sélection
if(selected){
ctx.strokeStyle = '#D4A24C';
ctx.lineWidth = 2.5;
ctx.setLineDash([4, 3]);
ctx.beginPath();
ctx.arc(x, y, r + 6, 0, Math.PI*2);
ctx.stroke();
ctx.setLineDash([]);
}
ctx.restore();
}
function drawCourt(){
const img = editor.courtType === 'half' ? demiImg : fullImg;
const ready = editor.courtType === 'half' ? imgsReady.demi : imgsReady.full;
if(img && ready){
if(editor.courtType === 'full'){
// V7.53 : terrain entier paysage, dessiné centré à sa taille naturelle
// Fond transparent autour (effacer le canvas d'abord)
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Calculer la taille pour rentrer dans le canvas en conservant le ratio (contain)
const imgRatio = img.naturalWidth / img.naturalHeight;
const canvasRatio = canvas.width / canvas.height;
let dw, dh;
if(imgRatio > canvasRatio){
// Image plus large : limiter par largeur canvas
dw = canvas.width;
dh = dw / imgRatio;
} else {
dh = canvas.height;
dw = dh * imgRatio;
}
const dx = (canvas.width - dw) / 2;
const dy = (canvas.height - dh) / 2;
// Stocker pour les conversions de coordonnées des joueurs
editor._fullCourtRect = { x: dx, y: dy, w: dw, h: dh };
ctx.drawImage(img, dx, dy, dw, dh);
} else {
// Demi-terrain : remplit le canvas (ratio adapté)
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
editor._fullCourtRect = null;
}
} else {
ctx.fillStyle = '#6B1A2C';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
}
// Cercle d'ancrage ballon = "halo orange autour du porteur"
function drawBallHalo(x, y, alpha){
ctx.save();
const scale = 1 /* V7.53: joueurs taille identique en full et half */;
const R = 40 * scale;
const BR = 10 * scale; // rayon petit ballon
const BD = 28 * scale; // décalage petit ballon
// Halo lumineux orange en arrière
ctx.shadowColor = 'rgba(232, 116, 60, 0.85)';
ctx.shadowBlur = 20 * scale;
ctx.strokeStyle = `rgba(232, 116, 60, ${alpha || 1})`;
ctx.lineWidth = 6 * scale;
ctx.beginPath();
ctx.arc(x, y, R, 0, Math.PI*2);
ctx.stroke();
ctx.shadowBlur = 0;
// Anneau or vif intérieur
ctx.strokeStyle = '#FFB570';
ctx.lineWidth = 3 * scale;
ctx.beginPath();
ctx.arc(x, y, R, 0, Math.PI*2);
ctx.stroke();
// Petit ballon orange en haut à droite
ctx.shadowColor = 'rgba(0,0,0,0.5)';
ctx.shadowBlur = 4;
ctx.shadowOffsetY = 2;
const bx = x + BD, by = y - BD;
const grad = ctx.createRadialGradient(bx-3*scale, by-3*scale, 1, bx, by, BR);
grad.addColorStop(0, '#FFB570');
grad.addColorStop(1, '#C0501A');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(bx, by, BR, 0, Math.PI*2);
ctx.fill();
ctx.shadowBlur = 0; ctx.shadowOffsetY = 0;
ctx.strokeStyle = '#0F0F12';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(bx, by, BR, 0, Math.PI*2);
ctx.moveTo(bx-BR, by); ctx.lineTo(bx+BR, by);
ctx.moveTo(bx, by-BR); ctx.lineTo(bx, by+BR);
ctx.stroke();
ctx.restore();
}
// === CACHE D'IMAGES POUR LES PHOTOS DE JOUEURS ===
// Évite de re-décoder l'image data: à chaque frame
const _imageCache = {};
function getCachedImage(dataUrl){
if(!dataUrl) return null;
// Utiliser une clé courte (hash naïf) pour le cache
const key = dataUrl.length + '_' + dataUrl.slice(-32);
if(_imageCache[key]){
return _imageCache[key];
}
const img = new Image();
img.src = dataUrl;
img.onload = () => {
// Re-rendre quand l'image est chargée
if(typeof render === 'function') render();
};
_imageCache[key] = img;
return img;
}
function drawPlayer(p, selected, hasBall){
// Taille des joueurs : réduite pour terrain complet (plus grand espace)
const SIZE = 28 /* V7.53: taille joueur identique */;
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,0.45)';
ctx.shadowBlur = 6;
ctx.shadowOffsetY = 3;
const customColor = p.color;
const fillCol = customColor || (p.kind === 'attack-sq' ? '#D4A24C' : '#6B1A2C');
if(p.kind === 'attack' || p.kind === 'attack-ball'){
ctx.fillStyle = fillCol;
ctx.strokeStyle = '#D4A24C';
ctx.lineWidth = 3.5;
ctx.beginPath();
ctx.arc(p.x, p.y, SIZE, 0, Math.PI*2);
ctx.fill();
ctx.stroke();
ctx.shadowBlur = 0; ctx.shadowOffsetY = 0;
// Si le joueur a une photo liée, on l'affiche à la place du numéro
const photoImg = p.photo ? getCachedImage(p.photo) : null;
if(photoImg && photoImg.complete && photoImg.naturalWidth > 0){
// Clipper en cercle pour que la photo épouse la forme du pion
ctx.save();
ctx.beginPath();
ctx.arc(p.x, p.y, SIZE - 2, 0, Math.PI*2);
ctx.clip();
ctx.drawImage(photoImg, p.x - SIZE, p.y - SIZE, SIZE * 2, SIZE * 2);
ctx.restore();
// Bord doré par-dessus pour cacher les pixels du bord
ctx.strokeStyle = '#D4A24C';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(p.x, p.y, SIZE, 0, Math.PI*2);
ctx.stroke();
} else {
// Numéro classique
ctx.fillStyle = '#FFFFFF';
ctx.strokeStyle = '#0F0F12';
ctx.lineWidth = 2;
ctx.font = `bold ${Math.round(SIZE * 0.86)}px Arial, sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.strokeText(p.num, p.x, p.y + 1);
ctx.fillText(p.num, p.x, p.y + 1);
}
} else if(p.kind === 'attack-sq'){
ctx.fillStyle = fillCol;
ctx.strokeStyle = '#0F0F12';
ctx.lineWidth = 3.5;
ctx.fillRect(p.x-SIZE, p.y-SIZE, SIZE*2, SIZE*2);
ctx.strokeRect(p.x-SIZE, p.y-SIZE, SIZE*2, SIZE*2);
ctx.shadowBlur = 0; ctx.shadowOffsetY = 0;
// Photo possible pour carré aussi
const photoImg = p.photo ? getCachedImage(p.photo) : null;
if(photoImg && photoImg.complete && photoImg.naturalWidth > 0){
ctx.save();
ctx.beginPath();
ctx.rect(p.x-SIZE+2, p.y-SIZE+2, SIZE*2-4, SIZE*2-4);
ctx.clip();
ctx.drawImage(photoImg, p.x-SIZE, p.y-SIZE, SIZE*2, SIZE*2);
ctx.restore();
ctx.strokeStyle = '#0F0F12';
ctx.lineWidth = 3;
ctx.strokeRect(p.x-SIZE, p.y-SIZE, SIZE*2, SIZE*2);
} else {
ctx.fillStyle = '#0F0F12';
ctx.font = `bold ${Math.round(SIZE * 0.86)}px Arial, sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(p.num, p.x, p.y + 1);
}
} else if(p.kind === 'defense'){
// Défenseur : cercle rouge avec numéro + 2 "moustaches" arquées de chaque côté
// Pivotable via p.rotation (angle en radians)
const scale = 1 /* V7.53: joueurs taille identique en full et half */;
const R = 22 * scale; // rayon du cercle central
const W = 36 * scale; // largeur des moustaches
const HOFF = 4 * scale;
const col = customColor || '#E63946';
const angle = p.rotation || 0;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(angle);
// Moustaches : 2 arcs symétriques (dans le repère tourné, origine = p)
ctx.strokeStyle = col;
ctx.lineWidth = 6 * scale;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(-R - W, HOFF + 6*scale);
ctx.quadraticCurveTo(-R - W/2, -14*scale, -R - 2, HOFF);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(R + 2, HOFF);
ctx.quadraticCurveTo(R + W/2, -14*scale, R + W, HOFF + 6*scale);
ctx.stroke();
// Cercle central plein rouge
ctx.shadowBlur = 6;
ctx.fillStyle = col;
ctx.beginPath();
ctx.arc(0, 0, R, 0, Math.PI*2);
ctx.fill();
ctx.restore();
// Numéro blanc au centre — TOUJOURS droit (pas tourné)
ctx.shadowBlur = 0; ctx.shadowOffsetY = 0;
ctx.fillStyle = '#FFFFFF';
ctx.font = `bold ${Math.round(20 * scale)}px Arial, sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(p.num, p.x, p.y + 1);
// Poignée de rotation : visible quand le défenseur est sélectionné
if(selected){
// Position dans le sens "haut" du défenseur (suit la rotation)
const handleDist = R + 22 * scale;
const hx = p.x + Math.sin(angle) * (-handleDist);
const hy = p.y - Math.cos(angle) * handleDist;
// Trait reliant le pion à la poignée
ctx.strokeStyle = '#D4A24C';
ctx.lineWidth = 2;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(hx, hy);
ctx.stroke();
ctx.setLineDash([]);
// Cercle poignée or
ctx.shadowColor = 'rgba(0,0,0,.35)';
ctx.shadowBlur = 4;
ctx.fillStyle = '#D4A24C';
ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.arc(hx, hy, 9, 0, Math.PI*2);
ctx.fill();
ctx.stroke();
ctx.shadowBlur = 0;
// Icône ↻ dedans
ctx.fillStyle = '#0F0F12';
ctx.font = 'bold 11px Arial';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('↻', hx, hy + 1);
}
} else if(p.kind === 'coach'){
// Coach : cercle blanc avec C rouge (comme dans la maquette)
ctx.fillStyle = '#FFFFFF';
ctx.strokeStyle = '#E63946';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(p.x, p.y, SIZE, 0, Math.PI*2);
ctx.fill();
ctx.stroke();
ctx.shadowBlur = 0; ctx.shadowOffsetY = 0;
ctx.fillStyle = '#E63946';
ctx.font = `bold ${Math.round(SIZE)}px Arial, sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('C', p.x, p.y + 1);
}
ctx.restore();
if(selected){
ctx.save();
ctx.strokeStyle = '#D4A24C';
ctx.lineWidth = 4;
ctx.setLineDash([8, 5]);
ctx.beginPath();
const r = p.kind === 'defense' ? 38 : SIZE + 10;
ctx.arc(p.x, p.y, r, 0, Math.PI*2);
ctx.stroke();
ctx.restore();
}
}
// Récupère le point de contrôle (pour Bézier quadratique) si l'action est courbée,
// sinon null (= ligne droite)
// Note : seuls dribble et screen supportent la courbure
// Position le long d'un chemin (0=start, 1=end), supporte les courbes
function positionOnPath(start, end, ctrls, t){
const C = ctrls || [];
if(C.length === 0){
return {
x: start.x + (end.x - start.x) * t,
y: start.y + (end.y - start.y) * t
};
}
if(C.length === 1){
const mt = 1 - t;
return {
x: mt*mt*start.x + 2*mt*t*C[0].x + t*t*end.x,
y: mt*mt*start.y + 2*mt*t*C[0].y + t*t*end.y
};
}
// Catmull-Rom : on échantillonne et on indexe linéairement
const samples = samplePath(start, end, C, 100);
const idx = Math.min(samples.length - 1, Math.max(0, Math.floor(t * (samples.length - 1))));
return samples[idx];
}
// Types d'action qui supportent la courbure (= peuvent suivre un chemin avec ctrlPoints)
function canCurveAction(a){
return a.kind === 'dribble' || a.kind === 'screen' || a.kind === 'cut' || a.kind === 'drive' || a.kind === 'curl';
}
// Récupère les points de contrôle (tableau, peut être vide)
// Compatibilité : si `ctrlPos` existe (ancien format), on le retourne en single-element
function getActionCtrls(a){
if(!canCurveAction(a)) return [];
if(a.ctrlPoints && a.ctrlPoints.length) return a.ctrlPoints;
if(a.ctrlPos) return [a.ctrlPos];
return [];
}
// Conserver l'ancienne fonction pour compat
function getActionCtrl(a){
const c = getActionCtrls(a);
return c.length === 1 ? c[0] : null;
}
// Échantillonne des points le long du chemin
// - 0 ctrl : ligne droite start → end
// - 1 ctrl : Bézier quadratique (start, ctrl, end)
// - 2+ ctrls : spline Catmull-Rom passant par start, ctrls..., end
function samplePath(start, end, ctrls, n){
const C = ctrls || [];
const pts = [];
if(C.length === 0){
for(let i=0; i<=n; i++){
const t = i/n;
pts.push({
x: start.x + (end.x - start.x) * t,
y: start.y + (end.y - start.y) * t
});
}
} else if(C.length === 1){
const ctrl = C[0];
for(let i=0; i<=n; i++){
const t = i/n;
const mt = 1 - t;
pts.push({
x: mt*mt*start.x + 2*mt*t*ctrl.x + t*t*end.x,
y: mt*mt*start.y + 2*mt*t*ctrl.y + t*t*end.y
});
}
} else {
// Catmull-Rom spline passant par [start, ...ctrls, end]
// On échantillonne sur chaque segment et on concatène
const allPts = [start, ...C, end];
const segments = allPts.length - 1;
const ptsPerSeg = Math.max(8, Math.floor(n / segments));
// Pour Catmull-Rom on a besoin de "points fantômes" aux extrémités
for(let s = 0; s < segments; s++){
const p0 = s === 0 ? allPts[0] : allPts[s-1];
const p1 = allPts[s];
const p2 = allPts[s+1];
const p3 = s+2 >= allPts.length ? allPts[s+1] : allPts[s+2];
for(let i=0; i<ptsPerSeg; i++){
const t = i / ptsPerSeg;
const t2 = t*t, t3 = t2*t;
// Catmull-Rom (tension 0.5)
const x = 0.5 * (
(2 * p1.x) +
(-p0.x + p2.x) * t +
(2*p0.x - 5*p1.x + 4*p2.x - p3.x) * t2 +
(-p0.x + 3*p1.x - 3*p2.x + p3.x) * t3
);
const y = 0.5 * (
(2 * p1.y) +
(-p0.y + p2.y) * t +
(2*p0.y - 5*p1.y + 4*p2.y - p3.y) * t2 +
(-p0.y + 3*p1.y - 3*p2.y + p3.y) * t3
);
pts.push({ x, y });
}
}
pts.push({ x: end.x, y: end.y });
}
return pts;
}
// Tangente (direction) en t=1 sur le chemin (pour orienter la flèche)
function tangentAtEnd(start, end, ctrls){
const C = ctrls || [];
if(C.length === 0){
return { dx: end.x - start.x, dy: end.y - start.y };
}
if(C.length === 1){
// Dérivée Bézier en t=1 : 2·(E - C)
return { dx: end.x - C[0].x, dy: end.y - C[0].y };
}
// Plusieurs ctrls : tangente = end - dernier ctrl (approximation correcte)
const lastCtrl = C[C.length - 1];
return { dx: end.x - lastCtrl.x, dy: end.y - lastCtrl.y };
}
function drawAction(a, phase, selected, orderNum, alpha){
const start = getActionStart(a, phase);
const end = getActionEnd(a, phase);
const ctrls = getActionCtrls(a);
const color = a.color || '#0F0F12';
const A = (alpha === undefined ? 1 : alpha);
if(A <= 0.02) return; // invisible, on saute
ctx.save();
ctx.globalAlpha = A;
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = 3.5;
ctx.lineCap = 'round';
if(a.kind === 'dribble'){
drawWavyPath(start, end, ctrls, color);
drawArrowheadOriented(end, tangentAtEnd(start, end, ctrls), color);
} else if(a.kind === 'pass'){
ctx.setLineDash([10, 6]);
drawPathLine(start, end, ctrls);
ctx.setLineDash([]);
drawArrowheadOriented(end, tangentAtEnd(start, end, ctrls), color);
} else if(a.kind === 'cut' || a.kind === 'drive' || a.kind === 'curl'){
drawPathLine(start, end, ctrls);
drawArrowheadOriented(end, tangentAtEnd(start, end, ctrls), color);
} else if(a.kind === 'screen'){
drawPathLine(start, end, ctrls);
// Barre perpendiculaire à la tangente en fin
const t = tangentAtEnd(start, end, ctrls);
const len = Math.hypot(t.dx, t.dy) || 1;
const nx = -t.dy/len, ny = t.dx/len;
ctx.lineWidth = 6;
ctx.beginPath();
ctx.moveTo(end.x + nx*14, end.y + ny*14);
ctx.lineTo(end.x - nx*14, end.y - ny*14);
ctx.stroke();
} else if(a.kind === 'shoot'){
ctx.setLineDash([10, 5]);
drawPathLine(start, end, ctrls);
ctx.setLineDash([]);
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(end.x, end.y, 10, 0, Math.PI*2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(end.x-10, end.y); ctx.lineTo(end.x+10, end.y);
ctx.moveTo(end.x, end.y-10); ctx.lineTo(end.x, end.y+10);
ctx.stroke();
} else if(a.kind === 'handoff'){
drawPathLine(start, end, ctrls);
drawArrowheadOriented(end, tangentAtEnd(start, end, ctrls), color);
// Petit trait perpendiculaire au milieu
const mid = ctrls.length ?
{ x:(start.x+ctrls[0].x+end.x)/3, y:(start.y+ctrls[0].y+end.y)/3 } :
{ x:(start.x+end.x)/2, y:(start.y+end.y)/2 };
const dx = end.x - start.x, dy = end.y - start.y;
const len = Math.hypot(dx, dy) || 1;
const nx = -dy/len, ny = dx/len;
ctx.beginPath();
ctx.moveTo(mid.x + nx*7, mid.y + ny*7);
ctx.lineTo(mid.x - nx*7, mid.y - ny*7);
ctx.stroke();
}
ctx.restore();
// Numéro d'ordre (1, 2, 3...) près du milieu pour montrer la séquence
if(orderNum && !selected){
let midX, midY;
// Échantillonner le chemin et prendre le point au milieu
const pts = samplePath(start, end, ctrls, 20);
const midPt = pts[Math.floor(pts.length/2)];
midX = midPt.x; midY = midPt.y;
const dx = end.x - start.x, dy = end.y - start.y;
const len = Math.hypot(dx, dy) || 1;
const nx = -dy/len, ny = dx/len;
const ox = midX + nx * 16;
const oy = midY + ny * 16;
ctx.save();
ctx.fillStyle = '#D4A24C';
ctx.strokeStyle = '#0F0F12';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(ox, oy, 11, 0, Math.PI*2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#0F0F12';
ctx.font = 'bold 13px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(orderNum, ox, oy + 1);
// Si l'action est en parallèle avec la précédente, ajouter une indication "↔"
if(a.startWith){
ctx.fillStyle = '#1B5E9C';
ctx.font = 'bold 11px Arial';
ctx.fillText('↔', ox + 16, oy + 1);
}
ctx.restore();
}
// Poignées si sélectionné
if(selected){
drawHandle(start.x, start.y, '#16A34A');
drawHandle(end.x, end.y, '#E63946');
// Poignée de courbure SEULEMENT pour dribble et screen
if(canCurveAction(a)){
if(ctrls.length === 0){
// Aucun ctrl : on dessine une poignée "fantôme" au milieu pour pouvoir commencer à courber
const ctrlX = (start.x + end.x) / 2;
const ctrlY = (start.y + end.y) / 2;
drawHandle(ctrlX, ctrlY, '#D4A24C', true); // semi-transparente
} else {
// Une poignée or par point de contrôle
ctrls.forEach(c => drawHandle(c.x, c.y, '#D4A24C'));
}
}
}
}
// Dessine une ligne droite OU une courbe (suivant le nombre de ctrls)
function drawPathLine(start, end, ctrls){
const C = ctrls || [];
ctx.beginPath();
ctx.moveTo(start.x, start.y);
if(C.length === 0){
ctx.lineTo(end.x, end.y);
} else if(C.length === 1){
// Bézier quadratique
ctx.quadraticCurveTo(C[0].x, C[0].y, end.x, end.y);
} else {
// Catmull-Rom : on échantillonne et on trace une polyligne
const pts = samplePath(start, end, C, 60);
for(let i=1; i<pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
}
ctx.stroke();
}
// Flèche orientée selon une tangente (utile pour les courbes)
function drawArrowheadOriented(point, tangent, color){
const len = Math.hypot(tangent.dx, tangent.dy);
if(len < 5) return;
const angle = Math.atan2(tangent.dy, tangent.dx);
const headLen = 14;
ctx.save();
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(point.x, point.y);
ctx.lineTo(point.x - headLen*Math.cos(angle - Math.PI/7), point.y - headLen*Math.sin(angle - Math.PI/7));
ctx.lineTo(point.x - headLen*Math.cos(angle + Math.PI/7), point.y - headLen*Math.sin(angle + Math.PI/7));
ctx.closePath();
ctx.fill();
ctx.restore();
}
function drawHandle(x, y, color, ghost){
ctx.save();
if(ghost){
ctx.globalAlpha = 0.5;
}
ctx.shadowColor = 'rgba(0,0,0,.3)';
ctx.shadowBlur = 4;
ctx.fillStyle = color;
ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x, y, ghost ? 7 : 9, 0, Math.PI*2);
ctx.fill();
ctx.stroke();
ctx.restore();
}
// Dessine un zigzag (dribble) le long d'une ligne droite, d'une Bézier ou d'une spline
function drawWavyPath(start, end, ctrls, color){
const N = 200; // bonne résolution
let totalLen = 0;
const samples = samplePath(start, end, ctrls, N);
for(let i=1; i<samples.length; i++){
totalLen += Math.hypot(samples[i].x - samples[i-1].x, samples[i].y - samples[i-1].y);
}
if(totalLen < 10) return;
const headSpace = 14; // place pour la tête de flèche
const useLen = Math.max(0, totalLen - headSpace);
if(useLen <= 0) return;
// Nombre de vagues : ~1 vague tous les 22px, minimum 3
const waves = Math.max(3, Math.round(useLen / 22));
const amplitude = 9;
// Reconstruire en s'arrêtant à useLen
ctx.beginPath();
let acc = 0;
let prevPt = samples[0];
ctx.moveTo(prevPt.x, prevPt.y);
for(let i=1; i<samples.length; i++){
const seg = Math.hypot(samples[i].x - prevPt.x, samples[i].y - prevPt.y);
acc += seg;
if(acc > useLen){
// Couper ici (fin du zigzag, à 14px avant l'arrivée)
break;
}
const t = acc / useLen;
// Tangente locale : direction entre prevPt et samples[i]
const tdx = samples[i].x - prevPt.x;
const tdy = samples[i].y - prevPt.y;
const tlen = Math.hypot(tdx, tdy) || 1;
// Normale perpendiculaire
const nx = -tdy/tlen, ny = tdx/tlen;
// Atténuation près de la fin
const fade = t > 0.92 ? (1 - (t - 0.92)/0.08) : 1;
const wave = Math.sin(t * Math.PI * 2 * waves) * amplitude * fade;
const px = samples[i].x + nx*wave;
const py = samples[i].y + ny*wave;
ctx.lineTo(px, py);
prevPt = samples[i];
}
ctx.stroke();
}
// Ancien drawWavyLine kept for compatibility
function drawWavyLine(start, end, color){
drawWavyPath(start, end, [], color);
}
function drawArrowhead(start, end, color){
const dx = end.x - start.x, dy = end.y - start.y;
const len = Math.hypot(dx, dy);
if(len < 5) return;
const angle = Math.atan2(dy, dx);
const headLen = 14;
ctx.save();
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(end.x, end.y);
ctx.lineTo(end.x - headLen*Math.cos(angle - Math.PI/7), end.y - headLen*Math.sin(angle - Math.PI/7));
ctx.lineTo(end.x - headLen*Math.cos(angle + Math.PI/7), end.y - headLen*Math.sin(angle + Math.PI/7));
ctx.closePath();
ctx.fill();
ctx.restore();
}
function drawFreedraw(d){
if(d.points.length < 2) return;
ctx.save();
ctx.strokeStyle = d.color || '#0F0F12';
ctx.lineWidth = d.width || 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(d.points[0].x, d.points[0].y);
for(let i=1; i<d.points.length; i++) ctx.lineTo(d.points[i].x, d.points[i].y);
ctx.stroke();
ctx.restore();
}
function drawObject(o, selected){
ctx.save();
const fillCol = o.color || '#FFA500';
const sx = (o.sizeX !== undefined ? o.sizeX : (o.size || 1));
const sy = (o.sizeY !== undefined ? o.sizeY : (o.size || 1));
const angle = o.rotation || 0;
ctx.globalAlpha = o.opacity === undefined ? 1 : o.opacity;
// Translation + rotation : tout est dessiné autour de (0,0)
ctx.translate(o.x, o.y);
ctx.rotate(angle);
if(o.kind === 'cone'){
ctx.fillStyle = fillCol;
ctx.strokeStyle = '#0F0F12';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, -16*sy);
ctx.lineTo(13*sx, 11*sy);
ctx.lineTo(-13*sx, 11*sy);
ctx.closePath();
ctx.fill();
ctx.stroke();
} else if(o.kind === 'triangle'){
ctx.strokeStyle = o.color || '#0F0F12';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(0, -18*sy);
ctx.lineTo(16*sx, 12*sy);
ctx.lineTo(-16*sx, 12*sy);
ctx.closePath();
ctx.stroke();
} else if(o.kind === 'square'){
ctx.fillStyle = fillCol;
ctx.strokeStyle = '#0F0F12';
ctx.lineWidth = 2;
ctx.fillRect(-14*sx, -14*sy, 28*sx, 28*sy);
ctx.strokeRect(-14*sx, -14*sy, 28*sx, 28*sy);
} else if(o.kind === 'circle'){
ctx.fillStyle = fillCol;
ctx.strokeStyle = '#0F0F12';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(0, 0, 14*sx, 14*sy, 0, 0, Math.PI*2);
ctx.fill();
ctx.stroke();
} else if(o.kind === 'text'){
ctx.fillStyle = o.color || '#0F0F12';
ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = 3;
const sAvg = (sx + sy) / 2;
ctx.font = `bold ${Math.round(18*sAvg)}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.strokeText(o.text, 0, 0);
ctx.fillText(o.text, 0, 0);
} else if(o.kind === 'handoff'){
const col = o.color || '#0F0F12';
const height = 26 * sy;
const halfW = 8 * sx;
const overshoot = 5 * sx;
const thick = Math.max(3, 4 * Math.min(sx, sy));
ctx.strokeStyle = col;
ctx.lineWidth = thick;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(-halfW, -height/2);
ctx.lineTo(-halfW, height/2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(halfW, -height/2);
ctx.lineTo(halfW, height/2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(-halfW - overshoot, 0);
ctx.lineTo(halfW + overshoot, 0);
ctx.stroke();
}
if(selected){
// Cadre pointillé + poignées dans le repère TOURNÉ (la sélection suit la rotation)
ctx.globalAlpha = 1;
const local = getObjectLocalBBox(o); // bbox dans le repère local (sans translation)
ctx.strokeStyle = '#D4A24C';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
ctx.strokeRect(local.x, local.y, local.w, local.h);
ctx.setLineDash([]);
// 8 poignées en coordonnées locales
const handles = [
{ x: local.x, y: local.y }, // tl
{ x: local.x + local.w, y: local.y }, // tr
{ x: local.x, y: local.y + local.h }, // bl
{ x: local.x + local.w, y: local.y + local.h }, // br
{ x: local.x + local.w/2, y: local.y }, // t
{ x: local.x + local.w/2, y: local.y + local.h }, // b
{ x: local.x, y: local.y + local.h/2 }, // l
{ x: local.x + local.w, y: local.y + local.h/2 }, // r
];
handles.forEach(h => {
ctx.fillStyle = '#FFFFFF';
ctx.strokeStyle = '#D4A24C';
ctx.lineWidth = 2;
ctx.fillRect(h.x - 5, h.y - 5, 10, 10);
ctx.strokeRect(h.x - 5, h.y - 5, 10, 10);
});
// Poignée de rotation : au-dessus du cadre, reliée par un trait pointillé
const rotY = local.y - 25;
ctx.strokeStyle = '#D4A24C';
ctx.lineWidth = 1.5;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(local.x + local.w/2, local.y);
ctx.lineTo(local.x + local.w/2, rotY);
ctx.stroke();
ctx.setLineDash([]);
// Cercle or de rotation
ctx.fillStyle = '#D4A24C';
ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.arc(local.x + local.w/2, rotY, 9, 0, Math.PI*2);
ctx.fill();
ctx.stroke();
// Icône ↻ (toujours droite, donc on dé-tourne juste pour ce dessin)
ctx.save();
ctx.translate(local.x + local.w/2, rotY);
ctx.rotate(-angle); // contre-rotation
ctx.fillStyle = '#0F0F12';
ctx.font = 'bold 11px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('↻', 0, 1);
ctx.restore();
}
ctx.restore();
}
// BBox dans le repère LOCAL (autour de (0,0), sans translation, sans rotation)
function getObjectLocalBBox(o){
const sx = (o.sizeX !== undefined ? o.sizeX : (o.size || 1));
const sy = (o.sizeY !== undefined ? o.sizeY : (o.size || 1));
let halfW, halfH;
if(o.kind === 'cone'){ halfW = 13*sx; halfH = 13.5*sy; }
else if(o.kind === 'triangle'){ halfW = 16*sx; halfH = 15*sy; }
else if(o.kind === 'square'){ halfW = 14*sx; halfH = 14*sy; }
else if(o.kind === 'circle'){ halfW = 14*sx; halfH = 14*sy; }
else if(o.kind === 'text'){
const sAvg = (sx+sy)/2;
halfW = (o.text||'').length * 5 * sAvg + 5;
halfH = 10 * sAvg;
}
else if(o.kind === 'handoff'){ halfW = 13*sx + 5*sx; halfH = 13*sy; }
else { halfW = 15*sx; halfH = 15*sy; }
return { x: -halfW, y: -halfH, w: halfW*2, h: halfH*2 };
}
// BBox dans le repère MONDE (rectangle englobant après rotation — pour hitbox)
function getObjectBBox(o){
const local = getObjectLocalBBox(o);
const angle = o.rotation || 0;
if(!angle){
return { x: o.x + local.x, y: o.y + local.y, w: local.w, h: local.h };
}
// Calculer la bbox englobante du rect tourné
const corners = [
{ x: local.x, y: local.y },
{ x: local.x + local.w, y: local.y },
{ x: local.x, y: local.y + local.h },
{ x: local.x + local.w, y: local.y + local.h }
];
const cos = Math.cos(angle), sin = Math.sin(angle);
const rotated = corners.map(c => ({
x: o.x + c.x * cos - c.y * sin,
y: o.y + c.x * sin + c.y * cos
}));
const xs = rotated.map(p => p.x);
const ys = rotated.map(p => p.y);
const xMin = Math.min(...xs), xMax = Math.max(...xs);
const yMin = Math.min(...ys), yMax = Math.max(...ys);
return { x: xMin, y: yMin, w: xMax - xMin, h: yMax - yMin };
}
// Helper : convertir un point monde en point local (inverse de translate+rotate)
function worldToLocal(o, wx, wy){
const angle = o.rotation || 0;
const dx = wx - o.x, dy = wy - o.y;
const cos = Math.cos(-angle), sin = Math.sin(-angle);
return { x: dx * cos - dy * sin, y: dx * sin + dy * cos };
}
// Helper : convertir un point local en point monde (translate+rotate appliqué)
function localToWorld(o, lx, ly){
const angle = o.rotation || 0;
const cos = Math.cos(angle), sin = Math.sin(angle);
return { x: o.x + lx * cos - ly * sin, y: o.y + lx * sin + ly * cos };
}
function drawArrowPreview(start, end, kind){
ctx.save();
ctx.strokeStyle = editor.currentColor;
ctx.fillStyle = editor.currentColor;
ctx.lineWidth = 3;
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.moveTo(start.x, start.y);
ctx.lineTo(end.x, end.y);
ctx.stroke();
ctx.setLineDash([]);
drawArrowhead(start, end, editor.currentColor);
ctx.restore();
}
// === UNDO / REDO ===
function snapshot(){
return JSON.stringify({ phases: editor.phases, currentPhase: editor.currentPhase });
}
function pushUndo(){
editor.undoStack.push(snapshot());
if(editor.undoStack.length > 30) editor.undoStack.shift();
editor.redoStack = [];
}
function undo(){
if(editor.undoStack.length === 0){ toast('Rien à annuler'); return; }
editor.redoStack.push(snapshot());
const s = JSON.parse(editor.undoStack.pop());
editor.phases = s.phases;
editor.currentPhase = Math.min(s.currentPhase, editor.phases.length-1);
editor.selectedId = null;
render(); renderPhasesList();
}
function redo(){
if(editor.redoStack.length === 0){ toast('Rien à refaire'); return; }
editor.undoStack.push(snapshot());
const s = JSON.parse(editor.redoStack.pop());
editor.phases = s.phases;
editor.currentPhase = Math.min(s.currentPhase, editor.phases.length-1);
render(); renderPhasesList();
}
// === EXPORT / SAVE ===
function exportPng(){
render();
// Le canvas a maintenant le ratio adapté au type de terrain → pas besoin de cropper
const url = canvas.toDataURL('image/png');
download(url, (document.getElementById('edTitle').value || 'play')+'.png');
toast('PNG téléchargé ✓');
}
function exportJson(){
const data = { title: document.getElementById('edTitle').value, courtType: editor.courtType, phases: editor.phases };
const blob = new Blob([JSON.stringify(data, null, 2)], { type:'application/json' });
download(URL.createObjectURL(blob), (data.title || 'play')+'.json');
toast('JSON téléchargé ✓');
}
function importJson(e){
const f = e.target.files[0]; if(!f) return;
const r = new FileReader();
r.onload = ev => {
try {
const data = JSON.parse(ev.target.result);
pushUndo();
editor.phases = (data.phases && data.phases.length) ? data.phases : [emptyPhase()];
editor.currentPhase = 0;
if(data.courtType){
editor.courtType = data.courtType;
// Ajuster le canvas aux dimensions du mode
const dims = COURT_DIMS[editor.courtType];
canvas.width = dims.w; canvas.height = dims.h; canvas.setAttribute('data-court', editor.courtType);
}
if(data.title) document.getElementById('edTitle').value = data.title;
const btn = document.getElementById('courtToggleBtn');
if(btn) btn.textContent = editor.courtType === 'half' ? '🏟 Terrain : Demi' : '🏟 Terrain : Complet';
render(); renderPhasesList();
toast('JSON importé ✓');
} catch(err){ toast('JSON invalide', 'error'); }
};
r.readAsText(f);
}
// Charge ffmpeg.wasm dynamiquement la première fois
let _ffmpegInstance = null;
let _ffmpegLoading = null;
async function loadFFmpeg(){
if(_ffmpegInstance) return _ffmpegInstance;
if(_ffmpegLoading) return _ffmpegLoading;
_ffmpegLoading = (async () => {
// Charger le script ffmpeg via <script>
const scriptUrl = 'https://unpkg.com/@ffmpeg/ffmpeg@0.12.10/dist/umd/ffmpeg.js';
const utilUrl = 'https://unpkg.com/@ffmpeg/util@0.12.1/dist/umd/index.js';
await new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = utilUrl;
s.onload = resolve;
s.onerror = () => reject(new Error('Impossible de charger ffmpeg/util'));
document.head.appendChild(s);
});
await new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = scriptUrl;
s.onload = resolve;
s.onerror = () => reject(new Error('Impossible de charger ffmpeg.js'));
document.head.appendChild(s);
});
const { FFmpeg } = window.FFmpegWASM || {};
if(!FFmpeg) throw new Error('ffmpeg.wasm indisponible');
const ffmpeg = new FFmpeg();
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd';
await ffmpeg.load({
coreURL: await window.FFmpegUtil.toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await window.FFmpegUtil.toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm')
});
_ffmpegInstance = ffmpeg;
return ffmpeg;
})();
return _ffmpegLoading;
}
// Calcule la durée totale de l'animation en ms (selon buildAnimSequence)
function computeTotalAnimMs(){
const seq = buildAnimSequence();
if(!seq || seq.length === 0){
// Fallback : transition entre phases
return Math.max(1000, (editor.phases.length - 1) * 600);
}
return seq.reduce((s, e) => s + e.duration, 0);
}
async function exportVideo(){
if(editor.phases.length < 1){ toast('Aucune phase à exporter', 'error'); return; }
if(!canvas.captureStream || !window.MediaRecorder){ toast('Navigateur incompatible', 'error'); return; }
// Modale de progression
const progressHtml = `
<h2 style="margin-top:0">🎬 Export vidéo</h2>
<div id="vidProgress" style="text-align:center;padding:1rem 0">
<div style="font-size:1.1rem;font-weight:600;margin-bottom:.8rem" id="vidStatus">Préparation...</div>
<div style="background:#eee;border-radius:8px;height:14px;overflow:hidden;margin:.8rem 0">
<div id="vidBar" style="background:linear-gradient(90deg,#D4A24C,#C0501A);height:100%;width:0%;transition:width .3s"></div>
</div>
<div style="font-size:.8rem;color:#666;margin-top:.5rem" id="vidDetail">Ne ferme pas cette fenêtre</div>
</div>
`;
openModal(progressHtml);
const setStatus = (msg, pct, detail) => {
const s = document.getElementById('vidStatus');
const b = document.getElementById('vidBar');
const d = document.getElementById('vidDetail');
if(s) s.textContent = msg;
if(b) b.style.width = pct + '%';
if(d && detail) d.textContent = detail;
};
try {
// Le canvas est déjà au bon ratio selon le mode terrain → pas besoin de cropper
const stream = canvas.captureStream(30);
const chunks = [];
const rec = new MediaRecorder(stream, { mimeType: 'video/webm;codecs=vp9' });
rec.ondataavailable = e => { if(e.data.size > 0) chunks.push(e.data); };
const baseFilename = (document.getElementById('edTitle').value || 'play').replace(/[^a-z0-9_-]/gi, '_');
const onStopPromise = new Promise(resolve => {
rec.onstop = () => {
const blob = new Blob(chunks, { type:'video/webm' });
resolve(blob);
};
});
// Étape 1 : enregistrement
setStatus('Enregistrement de l\'animation...', 5);
rec.start();
// FIX V7.48 : forcer le démarrage à la phase 0 pour capturer TOUTES les phases dans l'export vidéo
editor.currentPhase = 0;
// Rendre la phase 0 immédiatement (le canvas doit afficher la phase 0 avant que rec démarre la capture)
if(typeof render === 'function') render();
if(editor.isPlaying) pauseAnim();
editor.phases.forEach(ph => ph.actions.forEach(a => delete a._animStart));
// Petite pause pour que la phase 0 soit visible 1 frame avant le démarrage anim
await new Promise(r => setTimeout(r, 100));
startAnim();
const totalMs = computeTotalAnimMs() + 600;
// Avancée pendant l'enregistrement (jusqu'à 40%)
const recStart = performance.now();
const recInterval = setInterval(() => {
const elapsed = performance.now() - recStart;
const pct = 5 + Math.min(35, (elapsed / totalMs) * 35);
setStatus('Enregistrement de l\'animation...', pct, `${Math.round(elapsed/1000)}s / ${Math.round(totalMs/1000)}s`);
}, 200);
await new Promise(r => setTimeout(r, totalMs));
clearInterval(recInterval);
if(editor.isPlaying) pauseAnim();
await new Promise(r => setTimeout(r, 200));
rec.stop();
const webmBlob = await onStopPromise;
setStatus('Chargement de l\'outil de conversion...', 45, '~10 Mo à télécharger la 1ère fois');
// Étape 2 : charger ffmpeg avec timeout 60s
let ffmpeg;
try {
const ffmpegPromise = loadFFmpeg();
const timeoutPromise = new Promise((_, rej) => setTimeout(() => rej(new Error('Timeout')), 60000));
ffmpeg = await Promise.race([ffmpegPromise, timeoutPromise]);
} catch(loadErr){
// Fallback WebM
setStatus('MP4 indisponible — Téléchargement WebM', 90);
const blobUrl = URL.createObjectURL(webmBlob);
download(blobUrl, baseFilename + '.webm');
await new Promise(r => setTimeout(r, 1500));
closeModal();
toast('⚠ ' + (loadErr.message === 'Timeout' ? 'Conversion trop lente, fichier WebM téléchargé' : 'MP4 indisponible, WebM téléchargé'), 'error');
return;
}
// Étape 3 : conversion
setStatus('Conversion en MP4...', 60, 'Cela peut prendre 10-30 secondes');
const inputName = 'input.webm';
const outputName = 'output.mp4';
const arrayBuffer = await webmBlob.arrayBuffer();
await ffmpeg.writeFile(inputName, new Uint8Array(arrayBuffer));
await ffmpeg.exec([
'-i', inputName,
'-c:v', 'libx264',
'-preset', 'fast',
'-crf', '23',
'-pix_fmt', 'yuv420p',
'-movflags', '+faststart',
outputName
]);
setStatus('Téléchargement du fichier...', 95);
const mp4Data = await ffmpeg.readFile(outputName);
const mp4Blob = new Blob([mp4Data.buffer], { type: 'video/mp4' });
// Téléchargement avec délai pour laisser le navigateur déclencher la sauvegarde
const url = URL.createObjectURL(mp4Blob);
const a = document.createElement('a');
a.href = url;
a.download = baseFilename + '.mp4';
a.style.display = 'none';
document.body.appendChild(a);
a.click();
// Garder l'URL vivante plus longtemps (Safari/iOS notamment)
setTimeout(() => {
a.remove();
URL.revokeObjectURL(url);
}, 5000);
setStatus('✓ Vidéo téléchargée !', 100, `Fichier : ${baseFilename}.mp4`);
await new Promise(r => setTimeout(r, 1500));
closeModal();
toast('🎬 Vidéo MP4 téléchargée ✓');
try { await ffmpeg.deleteFile(inputName); } catch(e){}
try { await ffmpeg.deleteFile(outputName); } catch(e){}
} catch(err){
console.error(err);
closeModal();
toast('Erreur vidéo : '+err.message, 'error');
}
}
function savePlay(){
const title = document.getElementById('edTitle').value.trim() || 'Play sans titre';
render();
const thumbnail = canvas.toDataURL('image/png');
state.plays.push({
id: 'pl_'+Date.now(), title,
phases: JSON.parse(JSON.stringify(editor.phases)),
courtType: editor.courtType,
thumbnail,
date: Date.now()
});
saveState();
toast('Play sauvegardé ✓');
renderSavedPlays();
}
function renderSavedPlays(){
const list = document.getElementById('savedPlaysList');
const msg = document.getElementById('noPlaysMsg');
if(!list) return;
msg.style.display = state.plays.length ? 'none' : 'block';
list.innerHTML = state.plays.map(p => `
<div style="padding:.5rem;background:var(--gris-bg);border-radius:6px;cursor:pointer;display:flex;gap:.4rem;align-items:center" data-load="${p.id}">
${p.thumbnail ? `<img src="${p.thumbnail}" style="width:42px;height:30px;object-fit:cover;border-radius:3px">` : ''}
<div style="flex:1;min-width:0">
<strong style="font-size:.82rem;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${p.title}</strong>
<small style="color:var(--gris-text)">${p.phases.length} phases</small>
</div>
<span style="color:var(--rouge);padding:0 .25rem;cursor:pointer" data-pdel="${p.id}">✕</span>
</div>
`).join('');
list.querySelectorAll('[data-pdel]').forEach(b => b.addEventListener('click', e => {
e.stopPropagation();
state.plays = state.plays.filter(p => p.id !== b.dataset.pdel);
saveState(); renderSavedPlays();
}));
list.querySelectorAll('[data-load]').forEach(el => el.addEventListener('click', () => {
const p = state.plays.find(x => x.id === el.dataset.load);
if(p){
pushUndo();
editor.phases = JSON.parse(JSON.stringify(p.phases));
editor.currentPhase = 0;
editor.courtType = p.courtType || 'half';
// Ajuster le canvas selon le mode
const dims = COURT_DIMS[editor.courtType];
canvas.width = dims.w; canvas.height = dims.h; canvas.setAttribute('data-court', editor.courtType);
const btn = document.getElementById('courtToggleBtn');
if(btn) btn.textContent = editor.courtType === 'half' ? '🏟 Terrain : Demi' : '🏟 Terrain : Complet';
document.getElementById('edTitle').value = p.title;
render(); renderPhasesList();
toast('Play chargé ✓');
}
}));
}
function download(url, filename){
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click();
setTimeout(() => { a.remove(); if(url.startsWith('blob:')) URL.revokeObjectURL(url); }, 100);
}
// Dimensions du canvas selon le mode terrain
// Demi : 900×704 (ratio image demi 746/584 ≈ 1.277, ratio canvas 900/704 ≈ 1.278)
// Entier : 440×688 (vertical, ratio image entier inversé 658/1024 ≈ 0.642, ratio canvas 440/688 ≈ 0.640)
// Convention : le demi-terrain représente la MOITIÉ HAUTE du terrain entier (zone d'attaque côté panier).
// Donc dans le terrain entier, la zone d'attaque haute correspond à y ∈ [0, H_full/2].
const COURT_DIMS = {
half: { w: 900, h: 704 },
full: { w: 1100, h: 704 } // V7.53 : canvas paysage assez large pour l'image 1024x658 + marges transparentes
};
// Conversion : transforme les coordonnées (x,y) d'un mode à l'autre
// - half → full : la zone demi-terrain devient la moitié haute du terrain entier
// - full → half : seuls les éléments dans la moitié haute du terrain entier sont conservés
// (les éléments en moitié basse sont conservés mais ramenés dans le visible avec un facteur)
function convertCoord(x, y, fromMode, toMode){
if(fromMode === toMode) return { x, y };
const from = COURT_DIMS[fromMode];
const to = COURT_DIMS[toMode];
if(fromMode === 'half' && toMode === 'full'){
// V7.53 : demi → full : centrer le demi dans la moitié gauche du full (zone d'attaque)
// Le canvas full est plus large que demi, on garde la position relative
return { x: x * to.w / from.w, y: y * to.h / from.h };
} else {
// full → demi : étire le full pour remplir le demi
return { x: x * to.w / from.w, y: y * to.h / from.h };
}
}
// Convertit toutes les coordonnées d'une phase (joueurs, objets, actions, drawings, ballons)
function convertPhaseCoords(phase, fromMode, toMode){
if(fromMode === toMode) return;
const conv = (pt) => convertCoord(pt.x, pt.y, fromMode, toMode);
// Joueurs
phase.players.forEach(p => {
const c = conv(p);
p.x = c.x; p.y = c.y;
});
// Ballons (s'ils ont position au sol)
(phase.balls || []).forEach(b => {
if(b.x !== undefined && b.y !== undefined){
const c = conv(b);
b.x = c.x; b.y = c.y;
}
});
// Objets
(phase.objects || []).forEach(o => {
const c = conv(o);
o.x = c.x; o.y = c.y;
});
// Actions : startPos, endPos, ctrlPoints
(phase.actions || []).forEach(a => {
if(a.startPos){ const c = conv(a.startPos); a.startPos.x = c.x; a.startPos.y = c.y; }
if(a.endPos){ const c = conv(a.endPos); a.endPos.x = c.x; a.endPos.y = c.y; }
if(a.ctrlPoints){
a.ctrlPoints.forEach(cp => {
const c = conv(cp);
cp.x = c.x; cp.y = c.y;
});
}
});
// Drawings (chemins libres)
(phase.drawings || []).forEach(d => {
if(d.points){
d.points.forEach(pt => {
const c = conv(pt);
pt.x = c.x; pt.y = c.y;
});
}
});
}
function toggleCourtType(){
const oldMode = editor.courtType;
const newMode = oldMode === 'half' ? 'full' : 'half';
// Convertir toutes les coordonnées dans toutes les phases
editor.phases.forEach(phase => convertPhaseCoords(phase, oldMode, newMode));
editor.courtType = newMode;
// Redimensionner le canvas
const dims = COURT_DIMS[newMode];
canvas.width = dims.w;
canvas.height = dims.h;
canvas.setAttribute('data-court', newMode); // V7.53 : aspect-ratio CSS dynamique
// Mettre à jour le style CSS du canvas pour le redimensionnement responsive
canvas.style.maxWidth = '100%';
canvas.style.height = 'auto';
const label = newMode === 'half' ? '🏟 Terrain : Demi' : '🏟 Terrain : Complet';
const btn = document.getElementById('courtToggleBtn');
if(btn) btn.textContent = label;
toast(newMode === 'half' ? 'Demi-terrain' : 'Terrain complet');
render(); renderPhasesList();
}
function clearAll(){
if(!confirm('Tout effacer ?')) return;
pushUndo();
editor.phases = [emptyPhase()];
editor.currentPhase = 0;
editor.selectedId = null;
render(); renderPhasesList();
}
function editNote(){
const phase = editor.phases[editor.currentPhase];
const n = prompt('Note pour cette phase :', phase.note || '');
if(n !== null){ phase.note = n; toast('Note enregistrée'); }
}
function toExo(){
const title = document.getElementById('edTitle').value;
render();
const png = canvas.toDataURL('image/png');
navigate('exo-creer');
setTimeout(() => {
const f = document.getElementById('exoCreerForm');
if(f){
f.title.value = title;
f.org.value = 'Système créé via l\'outil tactique MyBasket';
f.deroul.value = editor.phases.map((p, i) => `Phase ${i+1} : ${p.players.length} joueur(s)${p.note?' — '+p.note:''}`).join('\n');
document.getElementById('exoDiagramPng').value = png;
document.getElementById('exoDiagramPreview').src = png;
document.getElementById('exoEditorBlock').classList.add('has-diagram');
}
toast('Formulaire pré-rempli ✓');
}, 150);
}
// Contexte de retour après l'ouverture de l'éditeur dans un popup.
// 'exo' = retour vers le formulaire exo-creer (comportement historique)
// 'sys' = retour vers le formulaire sys-editor (ajoute un fichier dessiné dans _sysEditorState.files)
let _editorReturnTo = 'exo';
function setupPopupBindings(){
const block = document.getElementById('exoEditorBlock');
if(block) block.addEventListener('click', () => openPopup('exo'));
const epClose = document.getElementById('epClose');
if(epClose) epClose.addEventListener('click', () => closePopup(false));
const epSave = document.getElementById('epSaveBack');
if(epSave) epSave.addEventListener('click', () => closePopup(true));
}
function openPopup(returnTo){
_editorReturnTo = returnTo || 'exo';
ensureInit();
setTimeout(() => {
const editorSection = document.querySelector('[data-page="plaquette"]');
const popup = document.getElementById('editorPopup');
const body = document.getElementById('epBody');
body.appendChild(editorSection);
editorSection.classList.add('active');
editorSection.style.display = 'block';
popup.classList.add('open');
popup.style.display = ''; // V7.59 : retirer le inline display:none posé par navigate()
document.body.style.overflow = 'hidden';
// Adapter le texte du bouton selon le contexte de retour
const epSave = document.getElementById('epSaveBack');
if(epSave){
epSave.innerHTML = _editorReturnTo === 'sys'
? '💾 Sauvegarder & insérer dans le système'
: _editorReturnTo === 'playbook'
? '💾 Sauvegarder & ajouter au playbook'
: '💾 Sauvegarder & insérer dans l\'exo';
}
render();
}, 50);
}
function closePopup(saveDrawing){
const editorSection = document.querySelector('[data-page="plaquette"]');
const popup = document.getElementById('editorPopup');
const main = document.querySelector('main');
if(saveDrawing){
// Générer un PNG pour chaque phase
const savedPhaseIdx = editor.currentPhase;
const phaseThumbs = [];
for(let i = 0; i < editor.phases.length; i++){
editor.currentPhase = i;
render();
phaseThumbs.push(canvas.toDataURL('image/png'));
}
// Revenir à la phase active et générer le PNG principal (la 1ère par défaut)
editor.currentPhase = savedPhaseIdx;
render();
const png = phaseThumbs[0] || canvas.toDataURL('image/png');
const title = document.getElementById('edTitle') ? document.getElementById('edTitle').value : '';
if(_editorReturnTo === 'sys'){
// === Retour vers le formulaire système (V7.55 : structure identique à exo) ===
const exportData = {
phases: editor.phases,
courtType: editor.courtType || 'half',
phaseThumbs,
title
};
const json = JSON.stringify(exportData);
const pngInput = document.getElementById('sysDiagramPng');
const jsonInput = document.getElementById('sysDiagramJson');
const preview = document.getElementById('sysDiagramPreview');
const block = document.getElementById('sysEditorBlock');
if(pngInput) pngInput.value = png;
if(jsonInput) jsonInput.value = json;
if(preview) preview.src = png;
if(block) block.classList.add('has-diagram');
// Stocker aussi dans _sysEditorState pour la persistance lors du publish
if(window._sysEditorState){
window._sysEditorState.diagramPng = png;
window._sysEditorState.diagramJson = json;
window._sysEditorState.phaseImages = phaseThumbs;
}
toast(`Dessin inséré ✓ (${phaseThumbs.length} phase${phaseThumbs.length>1?'s':''})`);
} else if(_editorReturnTo === 'playbook'){
// === V7.56 : Retour vers la page playbook ===
const pbId = window._currentPlaybookId;
if(window.appState && pbId){
const pb = (window.appState.playbooks || []).find(p => p.id === pbId);
if(pb){
if(!pb.plays) pb.plays = [];
pb.plays.push({
title: title || `Play ${pb.plays.length + 1}`,
diagramPng: png,
diagramJson: JSON.stringify({
phases: editor.phases,
courtType: editor.courtType || 'half',
phaseThumbs,
title
}),
phaseImages: phaseThumbs,
createdAt: new Date().toISOString()
});
if(typeof saveState === 'function') saveState();
toast(`Play ajouté au playbook ✓ (${phaseThumbs.length} phase${phaseThumbs.length>1?'s':''})`);
}
}
} else {
// === Retour vers le formulaire exo (comportement historique) ===
const exportData = {
phases: editor.phases,
courtType: editor.courtType || 'half',
phaseThumbs: phaseThumbs,
title
};
const json = JSON.stringify(exportData);
document.getElementById('exoDiagramPng').value = png;
document.getElementById('exoDiagramJson').value = json;
document.getElementById('exoDiagramPreview').src = png;
document.getElementById('exoEditorBlock').classList.add('has-diagram');
toast(`Dessin inséré ✓ (${phaseThumbs.length} phase${phaseThumbs.length>1?'s':''})`);
}
}
main.appendChild(editorSection);
editorSection.classList.remove('active');
editorSection.style.display = ''; // V7.55c : retirer le inline display='block' posé par openPopup
popup.classList.remove('open');
document.body.style.overflow = '';
// Naviguer vers la bonne page selon le contexte
if(_editorReturnTo === 'sys'){
navigate('sys-editor');
} else if(_editorReturnTo === 'playbook'){
const pbId = window._currentPlaybookId;
if(pbId) navigate('playbook-detail', pbId);
else navigate('profil');
} else {
navigate('exo-creer');
}
}
document.addEventListener('DOMContentLoaded', setupPopupBindings);
if(document.readyState !== 'loading') setupPopupBindings();
return { ensureInit, render, editor, openPopup };
})();
</script>
</body>
</html>