const MATCH_DATA = window.MATCH_DATA || {}; console.log("SCRIPT VERSION NEW PLAYER LINKS"); const TEAM_NAMES = MATCH_DATA.teamNames || { home: "", away: "" }; const goalEditorState = { open: false, eventId: null, side: null, type: null }; const squadEditState = { open: false, side: "home", starting: { home: [], away: [] }, bench: { home: [], away: [] }, coaches: { home: [], away: [] } }; const refereeEditorState = { items: Array.isArray(MATCH_DATA.referees) ? [...MATCH_DATA.referees] : [], pool: Array.isArray(MATCH_DATA.refereePool) ? [...MATCH_DATA.refereePool] : [] }; refereeEditorState.items = refereeEditorState.items.map(normalizeReferee); refereeEditorState.pool = refereeEditorState.pool.map(normalizeReferee); const REFEREE_ROLE_OPTIONS = [ "Главный судья", "Ассистент судьи №1", "Ассистент судьи №2", "Резервный судья", "VAR", "AVAR", "Инспектор" ]; const VMIX_TARGET_CLIENT_ID = MATCH_DATA.vmixTargetClientId || null; const VMIX_TARGET_GROUP = MATCH_DATA.vmixTargetGroup || null; const CURRENT_USER_LOGIN = MATCH_DATA.currentUser?.username || null; function vmixCmd(path) { const value = String(path || "").trim(); if (!value) return ""; if (value.startsWith("/api/")) return value; return `/api/?${value.replace(/^\?/, "")}`; } let currentPeriod = null; let matchClockSeconds = 0; let timerId = null; let periodStartedAt = null; let pausedAccumulatedSeconds = 0; let eventSeq = 1; let matchEvents = []; let scoreState = { home: 0, away: 0 }; let activeSubstitution = null; let extraTimeMinutes = { first: 0, second: 0 }; let originalLineupState = null; const SESSION_TOKEN = MATCH_DATA.sessionToken ?? null; const MATCH_ID = MATCH_DATA.matchId ?? null; const STORAGE_KEY = `match_admin_state_match_${MATCH_ID}`; const CLOCK_CLIENT_ID_KEY = `match_admin_clock_client_${MATCH_ID}`; let CLOCK_CLIENT_ID = localStorage.getItem(CLOCK_CLIENT_ID_KEY); if (!CLOCK_CLIENT_ID) { CLOCK_CLIENT_ID = `${Date.now()}_${Math.random().toString(16).slice(2)}`; localStorage.setItem(CLOCK_CLIENT_ID_KEY, CLOCK_CLIENT_ID); } let lastClockServerSyncAt = 0; let clockServerSyncInFlight = false; let applyingRemoteClockState = false; let clockEditorIsOpen = false; let eventsServerSyncInFlight = false; let applyingRemoteEventsState = false; let lastEventsSnapshot = ""; const formationState = { home: Array.isArray(MATCH_DATA.homeFormations) ? MATCH_DATA.homeFormations : [], away: Array.isArray(MATCH_DATA.awayFormations) ? MATCH_DATA.awayFormations : [], }; const fallbackLineups = { home: Array.isArray(MATCH_DATA.homeStarting) ? MATCH_DATA.homeStarting : [], away: Array.isArray(MATCH_DATA.awayStarting) ? MATCH_DATA.awayStarting : [], }; function getLiveClockSeconds() { if (!periodStartedAt) { return pausedAccumulatedSeconds; } return pausedAccumulatedSeconds + Math.max(0, Math.floor((Date.now() - periodStartedAt) / 1000)); } function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function formatClock(totalSeconds) { const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0"); const seconds = String(totalSeconds % 60).padStart(2, "0"); return `${minutes}:${seconds}`; } function formatVmixClock(totalSeconds) { const safeSeconds = Math.max(0, Math.floor(Number(totalSeconds) || 0)); const hours = String(Math.floor(safeSeconds / 3600)).padStart(2, "0"); const minutes = String(Math.floor((safeSeconds % 3600) / 60)).padStart(2, "0"); const seconds = String(safeSeconds % 60).padStart(2, "0"); return `${hours}:${minutes}:${seconds}`; } function parseClockInput(value) { const raw = String(value || "").trim().replaceAll(".", ":").replaceAll(",", ":"); if (!raw) return null; const parts = raw.split(":").map(part => part.trim()); if (parts.length > 3 || parts.some(part => part === "" || !/^\d+$/.test(part))) { return null; } let hours = 0; let minutes = 0; let seconds = 0; if (parts.length === 1) { minutes = Number(parts[0]); } else if (parts.length === 2) { minutes = Number(parts[0]); seconds = Number(parts[1]); } else { hours = Number(parts[0]); minutes = Number(parts[1]); seconds = Number(parts[2]); } if (![hours, minutes, seconds].every(Number.isFinite) || seconds > 59 || minutes > 999) { return null; } return Math.max(0, hours * 3600 + minutes * 60 + seconds); } function getFootballMinute(totalSeconds) { if (currentPeriod === "2H" || currentPeriod === "FT") { const secondHalfSeconds = Math.max(0, totalSeconds - 45 * 60); return 46 + Math.floor(secondHalfSeconds / 60); } return Math.floor(totalSeconds / 60) + 1; } function formatMinuteLabel(totalSeconds) { return getEventDisplayMinuteBySeconds(totalSeconds); } function getEventDisplayMinuteBySeconds(totalSeconds, periodType = null) { const safeSeconds = Math.max(0, Number(totalSeconds) || 0); if (periodType === "period_start_1h") { return "1'"; } if (periodType === "period_start_2h") { return "46'"; } const minute = Math.floor(safeSeconds / 60) + 1; // Добавленное время 1-го тайма — только если ещё не начался 2-й тайм if ( safeSeconds > 45 * 60 && safeSeconds < 90 * 60 && (currentPeriod === "1H" || currentPeriod === "HT") ) { const extra = Math.floor((safeSeconds - 45 * 60) / 60); return `45'+${Math.max(1, extra)}`; } // Добавленное время 2-го тайма — только после 90 минут if ( safeSeconds > 90 * 60 && (currentPeriod === "2H" || currentPeriod === "FT") ) { const extra = Math.floor((safeSeconds - 90 * 60) / 60); return `90'+${Math.max(1, extra)}`; } return `${minute}'`; } function getEventSortPriority(event) { const type = String(event?.type || ""); if (type === "period_ht" || type === "period_ft") { return 3; } if (type === "period_start_1h" || type === "period_start_2h") { return 1; } return 2; } function sortEventsForStorage(events) { return [...events].sort((a, b) => { const secondsDiff = (a.seconds || 0) - (b.seconds || 0); if (secondsDiff !== 0) return secondsDiff; const priorityDiff = getEventSortPriority(a) - getEventSortPriority(b); if (priorityDiff !== 0) return priorityDiff; return (a.id || 0) - (b.id || 0); }); } function sortEventsForDisplay(events) { return [...events].sort((a, b) => { const secondsDiff = (b.seconds || 0) - (a.seconds || 0); if (secondsDiff !== 0) return secondsDiff; const priorityDiff = getEventSortPriority(b) - getEventSortPriority(a); if (priorityDiff !== 0) return priorityDiff; return (b.id || 0) - (a.id || 0); }); } function addCoachEvent(side, cardType, coachName, btn = null) { console.log("addCoachEvent called", { side, cardType, coachName, btn }); const counts = getCoachCardCounts(side, coachName); const sentOffByTwoYellows = counts.yellow >= 2; const sentOffByRed = counts.red >= 1; const sentOff = sentOffByTwoYellows || sentOffByRed; if (sentOff) { console.log("blocked: sent off"); return; } if (cardType === "yellow" && counts.yellow >= 2) { console.log("blocked: yellow limit"); return; } if (cardType === "red" && counts.red >= 1) { console.log("blocked: red limit"); return; } const type = cardType === "yellow" ? "coach_yellow" : "coach_red"; console.log("pushEvent", { side, type, coachName }); pushEvent(side, type, coachName, "Тренер", null).then(() => { console.log("pushEvent done"); updateCoachCardButtons(); }); if (btn) { setTimeout(updateCoachCardButtons, 0); } } function updateTimerUI() { matchClockSeconds = getLiveClockSeconds(); const timer = document.getElementById("matchTimer"); if (timer) timer.textContent = formatClock(matchClockSeconds); } function updateScoreUI() { const score = document.getElementById("matchScore"); if (score) score.textContent = `${scoreState.home} : ${scoreState.away}`; } function updatePeriodBadge(text) { const badge = document.getElementById("periodBadge"); if (badge) badge.textContent = text; } function getPersistedState() { try { const raw = localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : null; } catch (e) { return null; } } function saveMatchState() { try { matchClockSeconds = getLiveClockSeconds(); const payload = { currentPeriod, matchClockSeconds, timerRunning: !!timerId, savedAt: Date.now(), periodStartedAt, pausedAccumulatedSeconds, eventSeq, matchEvents, scoreState, extraTimeMinutes, originalLineupState }; localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); } catch (e) { console.warn("Не удалось сохранить состояние матча", e); } } async function syncClockStateToServer(force = false) { if (!SESSION_TOKEN || applyingRemoteClockState) return; const now = Date.now(); if (!force && now - lastClockServerSyncAt < 1500) return; if (clockServerSyncInFlight) return; lastClockServerSyncAt = now; clockServerSyncInFlight = true; try { const liveSeconds = getLiveClockSeconds(); await fetch(`/admin/session/${SESSION_TOKEN}/clock-state`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentPeriod, matchClockSeconds: liveSeconds, timerRunning: !!timerId, pausedAccumulatedSeconds: liveSeconds, sourceClientId: CLOCK_CLIENT_ID }) }); } catch (e) { console.warn("Не удалось синхронизировать время с сервером", e); } finally { clockServerSyncInFlight = false; } } async function loadClockStateFromServer() { if (!SESSION_TOKEN || clockEditorIsOpen) return false; try { const res = await fetch(`/admin/session/${SESSION_TOKEN}/clock-state`, { cache: "no-store" }); if (!res.ok) return false; const state = await res.json(); if (!state || !state.exists) return false; // Если это только что отправил этот же браузер — не дёргаем локальный таймер лишний раз. if (state.sourceClientId && state.sourceClientId === CLOCK_CLIENT_ID) { return true; } applyingRemoteClockState = true; currentPeriod = state.currentPeriod ?? currentPeriod; const seconds = Math.max(0, Number(state.matchClockSeconds) || 0); const shouldRun = !!state.timerRunning && (currentPeriod === "1H" || currentPeriod === "2H"); if (timerId) { clearInterval(timerId); timerId = null; } pausedAccumulatedSeconds = seconds; matchClockSeconds = seconds; periodStartedAt = shouldRun ? Date.now() : null; if (shouldRun) { timerId = setInterval(() => { updateTimerUI(); saveMatchState(); }, 1000); } updateTimerUI(); updatePeriodBadge( currentPeriod === "1H" ? (extraTimeMinutes.first ? `1-й тайм +${extraTimeMinutes.first}` : "1-й тайм") : currentPeriod === "HT" ? "Перерыв" : currentPeriod === "2H" ? (extraTimeMinutes.second ? `2-й тайм +${extraTimeMinutes.second}` : "2-й тайм") : currentPeriod === "FT" ? "Матч завершён" : "Матч не начат" ); saveMatchState(); updatePeriodButtons(); return true; } catch (e) { console.warn("Не удалось получить время с сервера", e); return false; } finally { applyingRemoteClockState = false; } } function initClockStateSync() { loadClockStateFromServer(); setInterval(loadClockStateFromServer, 2000); } function restoreMatchState() { const state = getPersistedState(); if (!state) return false; currentPeriod = state.currentPeriod ?? null; eventSeq = Number(state.eventSeq || 1); matchEvents = Array.isArray(state.matchEvents) ? state.matchEvents : []; scoreState = state.scoreState || { home: 0, away: 0 }; extraTimeMinutes = state.extraTimeMinutes || { first: 0, second: 0 }; pausedAccumulatedSeconds = Number(state.pausedAccumulatedSeconds ?? 0); periodStartedAt = state.periodStartedAt ?? null; if (!state.timerRunning) { periodStartedAt = null; } updatePeriodBadge( currentPeriod === "1H" ? (extraTimeMinutes.first ? `1-й тайм +${extraTimeMinutes.first}` : "1-й тайм") : currentPeriod === "HT" ? "Перерыв" : currentPeriod === "2H" ? (extraTimeMinutes.second ? `2-й тайм +${extraTimeMinutes.second}` : "2-й тайм") : currentPeriod === "FT" ? "Матч завершён" : "Матч не начат" ); updateTimerUI(); updateScoreUI(); renderEvents(); if (typeof rebuildLineupsFromEvents === "function") { rebuildLineupsFromEvents(); } if (state.timerRunning && (currentPeriod === "1H" || currentPeriod === "2H")) { if (timerId) clearInterval(timerId); timerId = setInterval(() => { updateTimerUI(); saveMatchState(); }, 1000); } updateCoachCardButtons(); updatePeriodButtons(); return true; } function stopTimer() { if (timerId) { clearInterval(timerId); timerId = null; } pausedAccumulatedSeconds = getLiveClockSeconds(); periodStartedAt = null; matchClockSeconds = pausedAccumulatedSeconds; } function startTimer() { if (timerId) { clearInterval(timerId); timerId = null; } periodStartedAt = Date.now(); timerId = setInterval(() => { updateTimerUI(); saveMatchState(); }, 1000); updateTimerUI(); saveMatchState(); } async function syncEditedClockToVmix(totalSeconds, shouldRun) { const commands = [ vmixCmd("Function=SetCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text&Value=02:00:00"), vmixCmd(`Function=ChangeCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text&Value=${formatVmixClock(totalSeconds)}`), vmixCmd(shouldRun ? "Function=StartCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text" : "Function=PauseCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text" ), vmixCmd("Function=PreviewInput&Input=ВЕРХНИЙ СЧЕТ") ]; await sendDirectVmixCommands(commands); } function closeClockEditor() { clockEditorIsOpen = false; const modal = document.getElementById("clockEditorModal"); if (modal) modal.classList.remove("show"); } function openClockEditor() { clockEditorIsOpen = true; if (!(currentPeriod === "1H" || currentPeriod === "2H")) { clockEditorIsOpen = false; alert("Редактировать время можно после запуска 1-го или 2-го тайма"); return; } updateTimerUI(); const modal = document.getElementById("clockEditorModal"); const input = document.getElementById("clockEditorInput"); const hint = document.getElementById("clockEditorHint"); if (input) { input.value = formatClock(matchClockSeconds); } if (hint) { hint.textContent = timerId ? "Таймер сейчас идёт. После сохранения он продолжит идти с нового времени." : "Таймер сейчас на паузе. После сохранения он останется на паузе."; } if (modal) modal.classList.add("show"); setTimeout(() => { if (input) { input.focus(); input.select(); } }, 0); } async function applyClockEditor() { const input = document.getElementById("clockEditorInput"); const newSeconds = parseClockInput(input ? input.value : ""); if (newSeconds === null) { alert("Введите время в формате MM:SS, например 12:30 или 57:20"); return; } const wasRunning = !!timerId; if (timerId) { clearInterval(timerId); timerId = null; } pausedAccumulatedSeconds = newSeconds; matchClockSeconds = newSeconds; periodStartedAt = wasRunning ? Date.now() : null; if (wasRunning) { timerId = setInterval(() => { updateTimerUI(); saveMatchState(); }, 1000); } updateTimerUI(); saveMatchState(); await syncClockStateToServer(true); closeClockEditor(); await syncEditedClockToVmix(newSeconds, wasRunning); } async function triggerVmixExtraTimeAction(minutes, half) { const title = "ДОБАВЛЕННОЕ ВРЕМЯ ДЛЯ ВЕРХНЕГО СЧЕТА" const value = `+${minutes}`; const commands = [ vmixCmd(`Function=SetText&Input=${encodeURIComponent(title)}&SelectedName=Добавленное.Text&Value=${encodeURIComponent(value)}`), vmixCmd(`Function=PreviewInput&Input=${encodeURIComponent(title)}`) ]; await sendDirectVmixCommands(commands); } function applyExtraTime() { const input = document.getElementById("extraTimeMinutes"); const value = Math.max(0, Number(input ? input.value : 0)); if (!value) { saveMatchState(); return; } if (currentPeriod === "1H" || currentPeriod === "HT") { extraTimeMinutes.first = value; updatePeriodBadge(`1-й тайм +${value}`); addPeriodMarker( "extra_time_first", `Добавлено ${value} мин. к 1-му тайму`, matchClockSeconds ); triggerVmixExtraTimeAction(value, "first"); } else if (currentPeriod === "2H" || currentPeriod === "FT") { extraTimeMinutes.second = value; updatePeriodBadge(`2-й тайм +${value}`); addPeriodMarker( "extra_time_second", `Добавлено ${value} мин. ко 2-му тайму`, matchClockSeconds ); triggerVmixExtraTimeAction(value, "second"); } saveMatchState(); } async function saveEventToServer(eventData) { if (!SESSION_TOKEN) return null; try { const res = await fetch(`/admin/session/${SESSION_TOKEN}/event`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(eventData) }); if (!res.ok) return null; return await res.json(); } catch (e) { console.warn("Не удалось сохранить событие в БД", e); return null; } } async function deleteEvent(eventId) { if (eventId === null || eventId === undefined || eventId === "") return; try { const res = await fetch(`/admin/session/${SESSION_TOKEN}/event/${eventId}`, { method: "DELETE" }); if (!res.ok) { console.warn("Удаление события не удалось", res.status); return; } } catch (e) { console.warn("Не удалось удалить событие", e); return; } matchEvents = matchEvents.filter((event) => String(event.id) !== String(eventId)); scoreState = { home: 0, away: 0 }; matchEvents.forEach((event) => { if (["goal", "penalty", "own_goal"].includes(event.type)) { scoreState[event.side] = (scoreState[event.side] || 0) + 1; } }); updateScoreUI(); renderEvents(); rebuildLineupsFromEvents(); saveMatchState(); updateCoachCardButtons(); updatePeriodButtons(); } async function clearEventsOnServer() { if (!SESSION_TOKEN) return; try { await fetch(`/admin/session/${SESSION_TOKEN}/events`, { method: "DELETE" }); } catch (e) { console.warn("Не удалось очистить события в БД", e); } } function getOppositeSide(side) { return side === "home" ? "away" : "home"; } function getEventIcon(type) { const map = { goal: "⚽", own_goal: "АГ", penalty: "П", sub: "⇄", yellow: "🟨", red: "🟥", coach_yellow: "🟨", coach_red: "🟥", period_start_1h: "", period_ht: "", period_start_2h: "", period_ft: "", extra_time_first: "", extra_time_second: "" }; return map[type] || "•"; } function getEventTitle(type, name) { const map = { goal: "Гол", own_goal: "Автогол", penalty: "Пенальти", sub: "Замена", yellow: "Жёлтая карточка", red: "Красная карточка", coach_yellow: "Жёлтая карточка тренеру", coach_red: "Красная карточка тренеру", period_start_1h: "Начало 1-го тайма", period_ht: "Конец 1-го тайма", period_start_2h: "Начало 2-го тайма", period_ft: "Конец 2-го тайма", extra_time_first: "Добавлено время к 1-му тайму", extra_time_second: "Добавлено время ко 2-му тайму" }; return `${map[type] || "Событие"}${name ? " — " + name : ""}`; } function addPeriodMarker(type, title, forcedSeconds = null) { const markerSeconds = forcedSeconds === null ? matchClockSeconds : forcedSeconds; const markerMinute = getEventDisplayMinuteBySeconds(markerSeconds, type); const item = { id: null, side: "center", type, icon: "", title, minute: markerMinute, seconds: markerSeconds, meta: null, player_name: title, player_id: null, player_out_id: null, player_in_id: null }; matchEvents.push(item); renderEvents(); saveMatchState(); const numericMinute = type === "period_start_1h" ? 1 : type === "period_start_2h" ? 46 : Math.floor(markerSeconds / 60) + 1; saveEventToServer({ side: "center", type, player_name: title, minute: numericMinute, seconds: markerSeconds, meta: null, player_id: null, player_out_id: null, player_in_id: null }).then((saved) => { if (saved && saved.id) { item.id = saved.id; renderEvents(); saveMatchState(); } }); } function setMatchPeriod(period) { const markerMap = { "1H": "period_start_1h", "HT": "period_ht", "2H": "period_start_2h", "FT": "period_ft" }; const markerType = markerMap[period]; const alreadyUsed = matchEvents.some((event) => event.type === markerType); if (alreadyUsed) { return; } currentPeriod = period; if (period === "1H") { if (timerId) { clearInterval(timerId); timerId = null; } pausedAccumulatedSeconds = 0; periodStartedAt = null; matchClockSeconds = 0; updateTimerUI(); updatePeriodBadge(extraTimeMinutes.first ? `1-й тайм +${extraTimeMinutes.first}` : "1-й тайм"); startTimer(); addPeriodMarker("period_start_1h", "Начало 1-го тайма", 0); triggerVmixTimerAction("first_half_start"); saveMatchState(); syncClockStateToServer(true); clearExtraTimeInput(); updatePeriodButtons(); return; } if (period === "HT") { stopTimer(); const actualEndSeconds = matchClockSeconds; updateTimerUI(); updatePeriodBadge("Перерыв"); addPeriodMarker("period_ht", "Конец 1-го тайма", actualEndSeconds); triggerVmixTimerAction("halftime"); saveMatchState(); syncClockStateToServer(true); clearExtraTimeInput(); updatePeriodButtons(); return; } if (period === "2H") { if (timerId) { clearInterval(timerId); timerId = null; } pausedAccumulatedSeconds = 45 * 60; periodStartedAt = null; matchClockSeconds = 45 * 60; updateTimerUI(); updatePeriodBadge(extraTimeMinutes.second ? `2-й тайм +${extraTimeMinutes.second}` : "2-й тайм"); addPeriodMarker("period_start_2h", "Начало 2-го тайма", 45 * 60); triggerVmixTimerAction("second_half_start"); startTimer(); saveMatchState(); syncClockStateToServer(true); syncClockStateToServer(true); updatePeriodButtons(); return; } if (period === "FT") { stopTimer(); updatePeriodBadge("Матч завершён"); addPeriodMarker("period_ft", "Конец 2-го тайма", getLiveClockSeconds()); triggerVmixTimerAction("full_time"); saveMatchState(); syncClockStateToServer(true); clearExtraTimeInput(); updatePeriodButtons(); } } function setBenchHint(side, visible) { const hint = document.getElementById(side === "home" ? "homeBenchHint" : "awayBenchHint"); if (hint) hint.style.display = visible ? "block" : "none"; } function clearBenchSelection() { document.querySelectorAll(".bench-pick-active").forEach(el => el.classList.remove("bench-pick-active")); setBenchHint("home", false); setBenchHint("away", false); } function beginSubstitution(side, playerId, playerName, btn) { const row = btn ? btn.closest("tr.lineup-row") : null; activeSubstitution = { side, outPlayerId: playerId, outPlayerName: playerName, outRow: row }; clearBenchSelection(); setBenchHint(side, true); } async function pushEvent(side, type, personName, meta = "", playerId = null) { let eventSide = side; if (type === "own_goal") { eventSide = getOppositeSide(side); } if (type === "goal" || type === "penalty" || type === "own_goal") { scoreState[eventSide] += 1; updateScoreUI(); } const minuteLabel = formatMinuteLabel(matchClockSeconds); const numericMinute = parseInt(String(minuteLabel).split("+").pop().replace("'", ""), 10) || getFootballMinute(matchClockSeconds); const item = { id: null, side: eventSide, type, icon: getEventIcon(type), title: getEventTitle(type, personName), teamName: TEAM_NAMES[eventSide] || "Команда", minute: minuteLabel, seconds: matchClockSeconds, meta, player_name: personName, player_id: playerId, player_out_id: null, player_in_id: null }; matchEvents.push(item); renderEvents(); rebuildLineupsFromEvents(); saveMatchState(); const saved = await saveEventToServer({ side: eventSide, type, player_name: personName, minute: numericMinute, seconds: matchClockSeconds, meta, player_id: playerId, player_out_id: null, player_in_id: null }); if (saved && saved.id) { item.id = saved.id; renderEvents(); rebuildLineupsFromEvents(); saveMatchState(); } } function getCoachCardCounts(side, coachName) { const safeName = String(coachName || "").trim(); let yellow = 0; let red = 0; matchEvents.forEach((event) => { if (event.side !== side) return; if (String(event.player_name || "").trim() !== safeName) return; if (event.type === "coach_yellow") yellow += 1; if (event.type === "coach_red") red += 1; }); return { yellow, red }; } function updateCoachCardButtons() { document.querySelectorAll("[data-card-type][data-coach-side][data-coach-name]").forEach((btn) => { const side = btn.dataset.coachSide; const coachName = btn.dataset.coachName; const cardType = btn.dataset.cardType; const counts = getCoachCardCounts(side, coachName); const sentOffByTwoYellows = counts.yellow >= 2; const sentOffByRed = counts.red >= 1; const sentOff = sentOffByTwoYellows || sentOffByRed; let disabled = false; if (sentOff) { disabled = true; } else if (cardType === "yellow") { disabled = counts.yellow >= 2; } else if (cardType === "red") { disabled = counts.red >= 1; } btn.disabled = disabled; btn.style.opacity = disabled ? "0.45" : ""; btn.style.cursor = disabled ? "not-allowed" : ""; }); } function updatePeriodButtons() { const usedPeriods = new Set( matchEvents .filter((event) => ["period_start_1h", "period_ht", "period_start_2h", "period_ft"].includes(event.type) ) .map((event) => event.type) ); const map = { "1H": "period_start_1h", "HT": "period_ht", "2H": "period_start_2h", "FT": "period_ft" }; document.querySelectorAll("[data-period-btn]").forEach((btn) => { const period = btn.dataset.periodBtn; const eventType = map[period]; const disabled = usedPeriods.has(eventType); btn.disabled = disabled; btn.style.opacity = disabled ? "0.45" : ""; btn.style.cursor = disabled ? "not-allowed" : ""; }); } function addPlayerEvent(side, type, playerId, playerName) { pushEvent(side, type, playerName, `Игрок #${playerId ?? ""}`, playerId); } let editModeEnabled = false; function isGameTabActive() { const activeTab = document.querySelector(".tab-link.active"); return activeTab && activeTab.textContent.trim() === "Игра"; } function setEditMode(enabled) { editModeEnabled = !!enabled; document.body.classList.toggle("edit-mode-on", editModeEnabled); document.querySelectorAll("[data-edit-lock-wrap]").forEach((el) => { el.style.display = editModeEnabled ? "flex" : "none"; }); const btn = document.getElementById("editModeToggle"); if (btn) { btn.title = editModeEnabled ? "Режим редактирования: ВКЛ" : "Режим редактирования: ВЫКЛ"; btn.classList.toggle("active", editModeEnabled); } document.querySelectorAll("[data-edit-lock]").forEach((el) => { if ("disabled" in el) { el.disabled = !editModeEnabled; } }); renderRefereesEditor(); if (editModeEnabled && isGameTabActive()) { openSquadEditor(); } else { closeSquadEditor(); } } function toggleEditMode() { setEditMode(!editModeEnabled); } function updateEditModeUI() { const btn = document.getElementById("editModeToggle"); if (btn) { btn.innerHTML = "✏️"; btn.title = editModeEnabled ? "Режим редактирования: ВКЛ" : "Режим редактирования: ВЫКЛ"; btn.classList.toggle("active", editModeEnabled); } document.querySelectorAll("[data-edit-lock]").forEach((el) => { if ("disabled" in el) { el.disabled = !editModeEnabled; } }); } function stepExtraTime(step) { const input = document.getElementById("extraTimeMinutes"); if (!input) return; let value = Number(input.value) || 0; value = Math.max(0, value + step); input.value = value; } async function clearMatchEvents() { if (timerId) { clearInterval(timerId); timerId = null; } periodStartedAt = null; pausedAccumulatedSeconds = 0; matchEvents = []; scoreState = { home: 0, away: 0 }; eventSeq = 1; activeSubstitution = null; currentPeriod = null; matchClockSeconds = 0; extraTimeMinutes = { first: 0, second: 0 }; clearBenchSelection(); restoreOriginalLineupRows(); const extraInput = document.getElementById("extraTimeMinutes"); if (extraInput) extraInput.value = ""; updateTimerUI(); updatePeriodBadge("Матч не начат"); updateScoreUI(); renderEvents(); saveMatchState(); await clearEventsOnServer(); await syncClockStateToServer(true); updateCoachCardButtons(); updatePeriodButtons(); triggerVmixTimerAction("clear_match"); } async function updateEventOnServer(eventId, eventData) { if (!SESSION_TOKEN || !eventId) return false; try { const res = await fetch(`/admin/session/${SESSION_TOKEN}/event/${eventId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(eventData) }); return res.ok; } catch (e) { console.warn("Не удалось обновить событие в БД", e); return false; } } function getPlayersForSide(side) { return Array.from(document.querySelectorAll(`tr.lineup-row[data-side="${side}"]`)) .filter((row) => row.dataset.temporary !== "1") .map((row) => ({ player_id: row.dataset.playerId || "", player_name: row.dataset.playerName || "", player_number: row.dataset.playerNumber || "" })) .filter((p) => p.player_name.trim() !== ""); } function openGoalEditor(eventId) { const event = matchEvents.find((e) => String(e.id) === String(eventId)); if (!event) return; if (!["goal", "penalty", "own_goal"].includes(event.type)) return; const editorSide = event.type === "own_goal" ? getOppositeSide(event.side) : event.side; goalEditorState.open = true; goalEditorState.eventId = event.id; goalEditorState.side = editorSide; goalEditorState.type = event.type; const players = getPlayersForSide(editorSide); const wrap = document.getElementById("goalEditorList"); const title = document.getElementById("goalEditorTitle"); if (title) { title.textContent = `Изменить автора: ${event.player_name || "без игрока"}`; } if (wrap) { wrap.innerHTML = players.map((p) => ` `).join(""); } document.getElementById("goalEditorModal")?.classList.add("show"); } async function selectGoalScorer(playerId, playerName) { const event = matchEvents.find((e) => String(e.id) === String(goalEditorState.eventId)); if (!event) return; const updated = { side: event.side, type: event.type, player_name: playerName, minute: parseInt(String(event.minute).split("+").pop().replace("'", ""), 10) || getFootballMinute(event.seconds || 0), seconds: event.seconds || 0, meta: event.meta || "", player_id: playerId ? Number(playerId) : null, player_out_id: event.player_out_id ?? null, player_in_id: event.player_in_id ?? null }; const ok = await updateEventOnServer(event.id, updated); if (!ok) { alert("Не удалось обновить автора гола"); return; } event.player_name = playerName; event.player_id = playerId ? Number(playerId) : null; event.title = getEventTitle(event.type, playerName); renderEvents(); saveMatchState(); closeGoalEditor(); } function closeGoalEditor() { goalEditorState.open = false; goalEditorState.eventId = null; goalEditorState.side = null; goalEditorState.type = null; document.getElementById("goalEditorModal")?.classList.remove("show"); } function getVmixTeamLogo(teamName) { const basePath = "D:\\Графика\\ФУТБОЛ\\Женская Суперлига 2026\\Teams Logos"; let clean = String(teamName || "").replaceAll("«", "").replaceAll("»", "").trim(); if (clean === "Зенит" || clean === "Динамо") { clean += "_Синий"; } return `${basePath}\\${clean}.png`; } function getScoreSum() { return Number(scoreState?.home || 0) + Number(scoreState?.away || 0); } function getSubOutName(payload) { const raw = String(payload.player_name || ""); if (raw.includes("→")) { return raw.split("→")[0].trim(); } return String(payload.player_name_out || "").trim(); } function getSubInName(payload) { const raw = String(payload.player_name || ""); if (raw.includes("→")) { return raw.split("→")[1].trim(); } return String(payload.player_name_in || "").trim(); } function escapeVmixValue(value) { return String(value ?? "").trim(); } function buildVmixCommandsFromEvent(payload) { const eventType = String(payload?.event_type || ""); const playerName = escapeVmixValue(payload?.player_name || ""); const teamName = escapeVmixValue(payload?.team_name || ""); const playerNumber = escapeVmixValue(payload?.player_number || ""); const playerOutNumber = escapeVmixValue(payload?.player_out_number || ""); const playerInNumber = escapeVmixValue(payload?.player_in_number || ""); const teamLogo = getVmixTeamLogo(teamName); const scoreSum = getScoreSum(); if (eventType === "period_start_1h") { return [ vmixCmd("Function=SetText&Input=НИЖНИЙ СЧЕТ ВО ВРЕМЯ ИГРЫ&SelectedName=СтатусМатча.Text&Value=1-й тайм"), vmixCmd("Function=PreviewInput&Input=НИЖНИЙ СЧЕТ ВО ВРЕМЯ ИГРЫ") ]; } if (eventType === "period_ht") { return [ scoreSum === 0 ? vmixCmd("Function=PreviewInput&Input=НИЖНИЙ СЧЕТ БЕЗ АВТОРОВ ГОЛОВ - ПЕРЕРЫВ") : vmixCmd("Function=PreviewInput&Input=НИЖНИЙ СЧЕТ С АВТОРАМИ - ПЕРЕРЫВ") ]; } if (eventType === "period_start_2h") { return [ vmixCmd("Function=SetText&Input=НИЖНИЙ СЧЕТ ВО ВРЕМЯ ИГРЫ&SelectedName=СтатусМатча.Text&Value=2-й тайм"), vmixCmd("Function=PreviewInput&Input=НИЖНИЙ СЧЕТ ВО ВРЕМЯ ИГРЫ") ]; } if (eventType === "period_ft") { return [ scoreSum === 0 ? vmixCmd("Function=PreviewInput&Input=НИЖНИЙ СЧЕТ БЕЗ АВТОРОВ ГОЛОВ - МАТЧ ОКОНЧЕН") : vmixCmd("Function=PreviewInput&Input=НИЖНИЙ СЧЕТ С АВТОРАМИ ГОЛОВ - МАТЧ ОКОНЧЕН") ]; } if (eventType === "goal" || eventType === "penalty") { return [ vmixCmd(`Function=SetText&Input=ГОЛ - НЕЙТРАЛЬНЫЙ&SelectedName=Игрок.Text&Value=${playerNumber} ${playerName}`), vmixCmd(`Function=SetImage&Input=ГОЛ - НЕЙТРАЛЬНЫЙ&SelectedName=Лого.Source&Value=${teamLogo}`), vmixCmd("Function=PreviewInput&Input=ГОЛ - НЕЙТРАЛЬНЫЙ") ]; } if (eventType === "yellow") { return [ vmixCmd(`Function=SetText&Input=ЖЕЛТАЯ - НЕЙТРАЛЬНАЯ&SelectedName=Игрок.Text&Value=${playerNumber} ${playerName}`), vmixCmd(`Function=SetImage&Input=ЖЕЛТАЯ - НЕЙТРАЛЬНАЯ&SelectedName=Лого.Source&Value=${teamLogo}`), vmixCmd("Function=PreviewInput&Input=ЖЕЛТАЯ - НЕЙТРАЛЬНАЯ") ]; } if (eventType === "red") { return [ vmixCmd(`Function=SetText&Input=КРАСНАЯ - НЕЙТРАЛЬНАЯ&SelectedName=Игрок.Text&Value=${playerNumber} ${playerName}`), vmixCmd(`Function=SetImage&Input=КРАСНАЯ - НЕЙТРАЛЬНАЯ&SelectedName=Лого.Source&Value=${teamLogo}`), vmixCmd("Function=PreviewInput&Input=КРАСНАЯ - НЕЙТРАЛЬНАЯ") ]; } if (eventType === "sub") { const outName = escapeVmixValue(getSubOutName(payload)); const inName = escapeVmixValue(getSubInName(payload)); return [ vmixCmd(`Function=SetText&Input=ЗАМЕНА - НЕЙТРАЛЬНАЯ&SelectedName=НомерИгрокУход.Text&Value=${playerOutNumber}`), vmixCmd(`Function=SetText&Input=ЗАМЕНА - НЕЙТРАЛЬНАЯ&SelectedName=ИгрокУходящий.Text&Value=${outName}`), vmixCmd(`Function=SetText&Input=ЗАМЕНА - НЕЙТРАЛЬНАЯ&SelectedName=НомерИгрокВыход.Text&Value=${playerInNumber}`), vmixCmd(`Function=SetText&Input=ЗАМЕНА - НЕЙТРАЛЬНАЯ&SelectedName=ИгрокВыходящий.Text&Value=${inName}`), vmixCmd(`Function=SetImage&Input=ЗАМЕНА - НЕЙТРАЛЬНАЯ&SelectedName=Лого.Source&Value=${teamLogo}`), vmixCmd("Function=PreviewInput&Input=ЗАМЕНА - НЕЙТРАЛЬНАЯ") ]; } return []; } async function triggerVmixEventCommand(eventPayload) { const commands = buildVmixCommandsFromEvent(eventPayload); if (!commands.length) { console.warn("Для события нет команд vMix", eventPayload); return; } console.log("VMIX DIRECT EVENT:", eventPayload); console.log("VMIX DIRECT COMMANDS:", commands); await sendDirectVmixCommands(commands); } function getPlayerNumberByEvent(event) { if (!event) return ""; if (event.type === "sub") { const outRow = findLineupRowByPlayerId(event.side, "starting", event.player_out_id) || findLineupRowByPlayerId(event.side, "bench", event.player_out_id); const inRow = findLineupRowByPlayerId(event.side, "starting", event.player_in_id) || findLineupRowByPlayerId(event.side, "bench", event.player_in_id); return { out: outRow?.dataset.playerNumber || "", in: inRow?.dataset.playerNumber || "" }; } const row = findLineupRowByPlayerId(event.side, "starting", event.player_id) || findLineupRowByPlayerId(event.side, "bench", event.player_id); return row?.dataset.playerNumber || ""; } function buildVmixClickAttr(payload) { const json = JSON.stringify(payload); const encoded = btoa(unescape(encodeURIComponent(json))); return `onclick="triggerVmixEventCommandFromEncoded('${encoded}')"` } function triggerVmixEventCommandFromEncoded(encodedPayload) { try { const json = decodeURIComponent(escape(atob(encodedPayload))); const payload = JSON.parse(json); triggerVmixEventCommand(payload); } catch (e) { console.warn("Не удалось разобрать payload для vMix", e); } } function syncFormationCaptainsInState(side) { const starting = side === "home" ? (window.MATCH_DATA.homeStarting || []) : (window.MATCH_DATA.awayStarting || []); const captainIds = new Set( starting .filter(p => !!p.is_captain) .map(p => String(p.player_id ?? p.id ?? p.number ?? "")) ); if (!Array.isArray(formationState[side])) { formationState[side] = []; } formationState[side] = formationState[side].map(player => { const playerKey = String(player.player_id ?? player.id ?? player.number ?? ""); return { ...player, is_captain: captainIds.has(playerKey) }; }); } function getPlayerNamesByEvent(event) { if (!event) return { out: "", in: "" }; if (event.type !== "sub") { return { out: getVmixPlayerDisplayName(event), in: "" }; } const outRow = findLineupRowByPlayerId(event.side, "starting", event.player_out_id) || findLineupRowByPlayerId(event.side, "bench", event.player_out_id); const inRow = findLineupRowByPlayerId(event.side, "starting", event.player_in_id) || findLineupRowByPlayerId(event.side, "bench", event.player_in_id); const formatRow = (row) => { if (!row) return ""; const firstName = String(row.dataset.playerFirst || "").trim(); const lastName = String(row.dataset.playerLast || "").trim(); if (firstName && lastName) return `${firstName} ${lastName}`; return lastName || firstName || String(row.dataset.playerName || "").trim(); }; return { out: formatRow(outRow), in: formatRow(inRow) }; } function renderEvents() { const list = document.getElementById("eventsList"); if (!list) return; if (!matchEvents.length) { list.innerHTML = '
Событий пока нет.
'; return; } const displayEvents = [...matchEvents].reverse(); list.innerHTML = displayEvents.map((event) => { const compactTitle = String(event.title || "").includes(" — ") ? String(event.title).split(" — ").slice(1).join(" — ") : String(event.title || ""); const deleteHtml = event.id !== null && event.id !== undefined && event.id !== "" ? `` : ""; const canEditScorer = event.id !== null && event.id !== undefined && event.id !== "" && ["goal", "penalty", "own_goal"].includes(event.type); const editHtml = canEditScorer ? `` : ""; if (event.side === "center") { const clickableTypes = [ "period_start_1h", "period_ht", "period_start_2h", "period_ft", "extra_time_first", "extra_time_second" ]; const isClickable = clickableTypes.includes(event.type); const eventNameData = getPlayerNamesByEvent(event); const vmixPlayerName = getVmixPlayerDisplayName(event); const clickAttr = isClickable ? buildVmixClickAttr({ event_type: event.type, event_id: event.id ?? null, side: event.side ?? "center", minute: event.minute ?? "", seconds: event.seconds ?? 0, player_name: vmixPlayerName, player_id: event.player_id ?? null, player_out_id: event.player_out_id ?? null, player_in_id: event.player_in_id ?? null, player_name_out: eventNameData.out ?? "", player_name_in: eventNameData.in ?? "", title: compactTitle ?? "", team_name: "" }) : ""; const clickableClass = isClickable ? " event-card-clickable" : ""; return `
${deleteHtml}
${escapeHtml(compactTitle)}
`; } let actionsHtml = ""; if (event.side === "home") { actionsHtml = `${deleteHtml}${editHtml}`; } else { actionsHtml = `${editHtml}${deleteHtml}`; } const clickableTypes = [ "goal", "penalty", "yellow", "red", "sub" ]; const eventNumberData = getPlayerNumberByEvent(event); const isClickable = clickableTypes.includes(event.type); const clickAttr = isClickable ? buildVmixClickAttr({ event_type: event.type, event_id: event.id ?? null, side: event.side ?? "", minute: event.minute ?? "", seconds: event.seconds ?? 0, player_name: event.player_name ?? "", player_id: event.player_id ?? null, player_out_id: event.player_out_id ?? null, player_in_id: event.player_in_id ?? null, player_number: typeof eventNumberData === "string" ? eventNumberData : "", player_out_number: typeof eventNumberData === "object" ? (eventNumberData.out ?? "") : "", player_in_number: typeof eventNumberData === "object" ? (eventNumberData.in ?? "") : "", title: compactTitle ?? "", team_name: event.teamName ?? TEAM_NAMES[event.side] ?? "" }) : ""; const clickableClass = isClickable ? " event-card-clickable" : ""; const cardHtml = `
${actionsHtml}
${escapeHtml(event.icon)}
${escapeHtml(compactTitle)}
`; return `
${event.side === "home" ? cardHtml : ""}
${ event.id !== null && event.id !== undefined && event.id !== "" ? `` : "" } ${escapeHtml(event.minute)}
${event.side === "away" ? cardHtml : ""}
`; }).join(""); } function getVmixPlayerDisplayName(event) { if (!event || !event.player_id) { return String(event?.player_name || "").trim(); } const row = findLineupRowByPlayerId(event.side, "starting", event.player_id) || findLineupRowByPlayerId(event.side, "bench", event.player_id); if (!row) { return String(event.player_name || "").trim(); } const firstName = String(row.dataset.playerFirst || "").trim(); const lastName = String(row.dataset.playerLast || "").trim(); if (firstName && lastName) return `${firstName} ${lastName}`; if (lastName) return lastName; if (firstName) return firstName; return String(row.dataset.playerName || event.player_name || "").trim(); } function restoreEventsClickOrder(serverEvents, persistedEvents) { const persistedIds = new Map( persistedEvents .filter(e => e.id !== null && e.id !== undefined && e.id !== "") .map((e, index) => [String(e.id), index]) ); return [...serverEvents].sort((a, b) => { const aIndex = persistedIds.has(String(a.id)) ? persistedIds.get(String(a.id)) : 999999; const bIndex = persistedIds.has(String(b.id)) ? persistedIds.get(String(b.id)) : 999999; if (aIndex !== bIndex) return aIndex - bIndex; return (a.id || 0) - (b.id || 0); }); } async function loadEventsFromServer() { if (!SESSION_TOKEN || applyingRemoteEventsState || clockEditorIsOpen) return; if (eventsServerSyncInFlight) return; eventsServerSyncInFlight = true; try { const persisted = getPersistedState(); const persistedEvents = Array.isArray(persisted?.matchEvents) ? persisted.matchEvents : []; const res = await fetch(`/admin/session/${SESSION_TOKEN}/events`, { cache: "no-store" }); if (!res.ok) return; const data = await res.json(); if (!Array.isArray(data)) return; const snapshot = JSON.stringify(data.map((event) => ({ id: event.id ?? null, side: event.side ?? "", type: event.type ?? "", player_name: event.player_name ?? "", minute: event.minute ?? null, seconds: event.seconds ?? 0, meta: event.meta ?? null, player_id: event.player_id ?? null, player_out_id: event.player_out_id ?? null, player_in_id: event.player_in_id ?? null }))); if (snapshot === lastEventsSnapshot) return; lastEventsSnapshot = snapshot; applyingRemoteEventsState = true; matchEvents = data.map((event) => { const fallback = persistedEvents.find((p) => String(p.type || "") === String(event.type || "") && String(p.player_name || "") === String(event.player_name || "") && String(p.seconds || "") === String(event.seconds || "") ); const displayPlayerName = getDisplayNameForEvent(event); return { ...event, id: event.id ?? fallback?.id ?? null, side: event.side, icon: getEventIcon(event.type), title: getEventTitle(event.type, event.player_name), player_name: displayPlayerName, minute: typeof event.minute === "string" && event.minute.includes("'") ? event.minute : (fallback?.minute || getEventDisplayMinuteBySeconds(event.seconds || 0, event.type)), player_id: event.player_id ?? fallback?.player_id ?? null, player_out_id: event.player_out_id ?? fallback?.player_out_id ?? null, player_in_id: event.player_in_id ?? fallback?.player_in_id ?? null }; }); matchEvents = restoreEventsClickOrder(matchEvents, persistedEvents); scoreState = { home: 0, away: 0 }; matchEvents.forEach((event) => { if (["goal", "penalty", "own_goal"].includes(event.type)) { scoreState[event.side] = (scoreState[event.side] || 0) + 1; } }); if (!matchEvents.length) { activeSubstitution = null; clearBenchSelection(); restoreOriginalLineupRows(); } updateScoreUI(); renderEvents(); rebuildLineupsFromEvents(); saveMatchState(); updateCoachCardButtons(); updatePeriodButtons(); } catch (e) { console.warn("Не удалось загрузить события из БД", e); } finally { applyingRemoteEventsState = false; eventsServerSyncInFlight = false; } } function initEventsStateSync() { loadEventsFromServer(); setInterval(loadEventsFromServer, 2000); } async function sendDirectVmixCommands(commands) { const items = Array.isArray(commands) ? commands.map(x => String(x || "").trim()).filter(Boolean) : String(commands || "").split("\n").map(x => x.trim()).filter(Boolean); if (!items.length) return; try { const resp = await fetch("/api/vmix/publish-command", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ match_id: MATCH_ID, target_client_id: VMIX_TARGET_CLIENT_ID, target_group: VMIX_TARGET_GROUP, target_login: CURRENT_USER_LOGIN, commands: items }) }); const data = await resp.json(); if (!resp.ok) { console.warn("Ошибка публикации команд vMix:", data); alert("Не удалось отправить команды в vMix"); return; } console.log("VMIX PUBLISH OK:", data); } catch (e) { console.warn("Ошибка отправки команд на сервер:", e); alert("Ошибка связи с сервером при отправке команд vMix"); } } function syncFormationCaptainsFromMatchData(side) { const starting = side === "home" ? (window.MATCH_DATA.homeStarting || []) : (window.MATCH_DATA.awayStarting || []); if (!Array.isArray(formationState[side]) || formationState[side].length === 0) { formationState[side] = starting.slice(0, 11).map((p, idx) => ({ player_id: p.player_id || p.id || p.number || null, player_name: p.player_name || `${p.last_name || ""} ${p.first_name || ""}`.trim(), last_name: p.last_name || p.player_name || "", first_name: p.first_name || "", number: p.number || "", position: p.position || "", is_captain: !!p.is_captain, photo: p.photo || "", x: 50, y: 12 + idx * 6, })); renderFormation(side); return; } const captainIds = new Set( starting .filter(p => !!p.is_captain) .map(p => String(p.player_id ?? p.id ?? p.number ?? "")) ); formationState[side] = formationState[side].map(player => ({ ...player, is_captain: captainIds.has(String(player.player_id ?? player.id ?? player.number ?? "")) })); renderFormation(side); } async function triggerVmixTimerAction(action) { const map = { first_half_start: [ vmixCmd("Function=SetCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text&Value=02:00:00"), vmixCmd("Function=ChangeCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text&Value=00:00:00"), vmixCmd("Function=StartCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text"), vmixCmd("Function=PreviewInput&Input=ВЕРХНИЙ СЧЕТ") ], halftime: [ vmixCmd("Function=PauseCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text"), vmixCmd("Function=OverlayInput1Out"), vmixCmd("Function=OverlayInput2Out") ], second_half_start: [ vmixCmd("Function=SetCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text&Value=02:00:00"), vmixCmd("Function=ChangeCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text&Value=00:45:00"), vmixCmd("Function=StartCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text"), vmixCmd("Function=PreviewInput&Input=ВЕРХНИЙ СЧЕТ") ], full_time: [ vmixCmd("Function=PauseCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text"), vmixCmd("Function=OverlayInput1Out"), vmixCmd("Function=OverlayInput2Out") ], clear_match: [ vmixCmd("Function=SetCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text&Value=02:00:00"), vmixCmd("Function=ChangeCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text&Value=00:00:00"), vmixCmd("Function=PauseCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text&Value=00:00:00"), ] }; const commands = map[action] || []; if (!commands.length) return; await sendDirectVmixCommands(commands); } function clearExtraTimeInput() { const input = document.getElementById("extraTimeMinutes"); if (input) input.value = ""; } function clearTemporaryExpelledRows() { document.querySelectorAll('tr.lineup-row[data-temporary="1"]').forEach((row) => row.remove()); } function getBenchTbody(side) { const benchTable = Array.from(document.querySelectorAll("tr.lineup-row")) .find((row) => row.dataset.side === side && row.dataset.role === "bench") ?.closest("tbody"); return benchTable || null; } function createTemporaryExpelledRow(sourceRow, disciplineType) { const benchTbody = getBenchTbody(sourceRow.dataset.side); if (!benchTbody) return null; const tempRow = document.createElement("tr"); tempRow.className = "lineup-row"; tempRow.dataset.side = sourceRow.dataset.side || ""; tempRow.dataset.role = "expelled"; tempRow.dataset.temporary = "1"; const player = getRowPlayer(sourceRow); player.disciplineType = disciplineType; setRowPlayer(tempRow, player); renderLineupRow(tempRow); tempRow.classList.add("lineup-row-sub-out"); benchTbody.appendChild(tempRow); return tempRow; } function getPlayerDisciplineMap() { const map = new Map(); matchEvents .filter((event) => event.side !== "center" && event.player_id) .sort((a, b) => { if ((a.seconds || 0) !== (b.seconds || 0)) return (a.seconds || 0) - (b.seconds || 0); return (a.id || 0) - (b.id || 0); }) .forEach((event) => { const key = `${event.side}::${event.player_id}`; const current = map.get(key) || { yellow: 0, red: false, disciplineType: "" }; if (event.type === "yellow") { current.yellow += 1; if (current.yellow >= 2 && !current.red) { current.disciplineType = "yellow_red"; } } if (event.type === "red") { current.red = true; current.disciplineType = "red"; } map.set(key, current); }); return map; } function applyDisciplinaryEvents() { clearTemporaryExpelledRows(); Array.from(document.querySelectorAll("tr.lineup-row")) .filter((row) => row.dataset.temporary !== "1") .forEach((row) => { row.classList.remove("lineup-row-expelled-hidden"); row.dataset.disciplineType = ""; renderLineupRow(row); }); const disciplineMap = getPlayerDisciplineMap(); disciplineMap.forEach((value, key) => { if (!value.disciplineType) return; const [side, playerId] = key.split("::"); let row = findLineupRowByPlayerId(side, "starting", playerId); if (row) { row.dataset.disciplineType = value.disciplineType; row.classList.add("lineup-row-expelled-hidden"); createTemporaryExpelledRow(row, value.disciplineType); return; } row = findLineupRowByPlayerId(side, "bench", playerId); if (row) { row.dataset.disciplineType = value.disciplineType; renderLineupRow(row); } }); sortLineupRows("home", "starting"); sortLineupRows("home", "bench"); sortLineupRows("away", "starting"); sortLineupRows("away", "bench"); } function sortLineupRows(side, role) { const rows = Array.from(document.querySelectorAll( `tr.lineup-row[data-side="${side}"][data-role="${role}"]` )); const tbody = rows[0]?.closest("tbody"); if (!tbody) return; rows.sort((a, b) => { const pa = getRowPlayer(a); const pb = getRowPlayer(b); const gkA = isGoalkeeperPosition(pa.position) ? 1 : 0; const gkB = isGoalkeeperPosition(pb.position) ? 1 : 0; if (gkA !== gkB) return gkB - gkA; const numA = parseInt(pa.number, 10); const numB = parseInt(pb.number, 10); const safeA = Number.isFinite(numA) ? numA : 999; const safeB = Number.isFinite(numB) ? numB : 999; return safeA - safeB; }); rows.forEach(row => tbody.appendChild(row)); } function captureCurrentLineupState() { const rows = Array.from(document.querySelectorAll("tr.lineup-row")); return rows.map((row, index) => ({ index, side: row.dataset.side || "", role: row.dataset.role || "", player: getRowPlayer(row) })); } function restoreOriginalLineupRows() { if (!Array.isArray(originalLineupState) || !originalLineupState.length) return; clearTemporaryExpelledRows(); const rows = Array.from(document.querySelectorAll("tr.lineup-row")) .filter((row) => row.dataset.temporary !== "1"); originalLineupState.forEach((item) => { const row = rows[item.index]; if (!row) return; row.classList.remove("lineup-row-expelled-hidden", "lineup-row-sub-in", "lineup-row-sub-out", "bench-pick-active"); setRowPlayer(row, { ...item.player, disciplineType: "" }); renderLineupRow(row); }); sortLineupRows("home", "starting"); sortLineupRows("home", "bench"); sortLineupRows("away", "starting"); sortLineupRows("away", "bench"); } function findLineupRowByPlayerId(side, role, playerId) { if (playerId === null || playerId === undefined || playerId === "") return null; return Array.from(document.querySelectorAll("tr.lineup-row")).find((row) => row.dataset.temporary !== "1" && String(row.dataset.side || "") === String(side) && String(row.dataset.role || "") === String(role) && String(row.dataset.playerId || "") === String(playerId) ) || null; } function swapLineupPlayers(startingRow, benchRow) { const startingPlayer = getRowPlayer(startingRow); const benchPlayer = getRowPlayer(benchRow); setRowPlayer(startingRow, benchPlayer); setRowPlayer(benchRow, startingPlayer); renderLineupRow(startingRow); renderLineupRow(benchRow); sortLineupRows(startingRow.dataset.side, "starting"); sortLineupRows(startingRow.dataset.side, "bench"); startingRow.classList.add("lineup-row-sub-in"); benchRow.classList.add("lineup-row-sub-out"); saveMatchState(); } function rebuildLineupsFromEvents() { restoreOriginalLineupRows(); const subEvents = matchEvents .filter((event) => event.type === "sub" && event.player_out_id && event.player_in_id) .sort((a, b) => { if ((a.seconds || 0) !== (b.seconds || 0)) return (a.seconds || 0) - (b.seconds || 0); return (a.id || 0) - (b.id || 0); }); subEvents.forEach((event) => { const outRow = findLineupRowByPlayerId(event.side, "starting", event.player_out_id); const inRow = findLineupRowByPlayerId(event.side, "bench", event.player_in_id); if (outRow && inRow) swapLineupPlayers(outRow, inRow); }); applyDisciplinaryEvents(); } async function selectBenchPlayer(side, playerId, playerName, btn) { if (!activeSubstitution || activeSubstitution.side !== side) return; clearBenchSelection(); if (btn) btn.classList.add("bench-pick-active"); const benchRow = btn ? btn.closest("tr.lineup-row") : null; const outRow = activeSubstitution.outRow || null; const outName = outRow ? getRowDisplayName(outRow) : activeSubstitution.outPlayerName; const inName = benchRow ? getRowDisplayName(benchRow) : playerName; if (outRow && benchRow) { swapLineupPlayers(outRow, benchRow); } const minuteLabel = formatMinuteLabel(matchClockSeconds); const numericMinute = parseInt(String(minuteLabel).split("+").pop().replace("'", ""), 10) || getFootballMinute(matchClockSeconds); const item = { id: null, side, type: "sub", icon: getEventIcon("sub"), title: `${outName} → ${inName}`, minute: minuteLabel, seconds: matchClockSeconds, player_name: `${outName} → ${inName}`, player_out_id: activeSubstitution.outPlayerId, player_in_id: playerId, player_id: null }; matchEvents.push(item); renderEvents(); rebuildLineupsFromEvents(); saveMatchState(); const saved = await saveEventToServer({ side, type: "sub", player_name: `${outName} → ${inName}`, player_out_id: activeSubstitution.outPlayerId, player_in_id: playerId, player_id: null, minute: numericMinute, seconds: matchClockSeconds, meta: null }); if (saved && saved.id) { item.id = saved.id; renderEvents(); rebuildLineupsFromEvents(); saveMatchState(); } activeSubstitution = null; clearBenchSelection(); } function ensureInitialState(side) { if (!formationState[side] || formationState[side].length === 0) { const currentStarting = side === "home" ? (window.MATCH_DATA.homeStarting || []) : (window.MATCH_DATA.awayStarting || []); formationState[side] = currentStarting.slice(0, 11).map((p, idx) => ({ player_id: p.player_id || p.id || p.number || null, player_name: p.player_name || `${p.last_name || ""} ${p.first_name || ""}`.trim(), last_name: p.last_name || p.player_name || "", first_name: p.first_name || "", number: p.number || "", position: p.position || "", is_captain: !!p.is_captain, photo: p.photo || "", x: 50, y: 12 + idx * 6, })); } } function extractLastName(player) { return String(player.last_name || "").trim().toUpperCase(); } function buildPlayerSuffix(player) { const pos = String(player.position || "").trim().toLowerCase(); const isGoalkeeper = ["вр.", "вр", "вратарь", "gk", "goalkeeper"].includes(pos); const isCaptain = !!player.is_captain; if (isGoalkeeper && isCaptain) return " (ВР, К)"; if (isGoalkeeper) return " (ВР)"; if (isCaptain) return " (К)"; return ""; } function buildPlayerLabel(player) { const number = player.number || ""; const lastName = extractLastName(player); return `${number} ${lastName}${buildPlayerSuffix(player)}`.trim(); } function createPlayerNode(player, side) { const el = document.createElement("div"); el.className = "player-node"; el.dataset.playerId = player.player_id ?? ""; el.dataset.side = side; const safeX = clampWebPercent(player.x); const safeY = clampWebPercent(player.y); el.style.left = `${safeX}%`; el.style.top = `${safeY}%`; const visual = player.photo ? `${player.player_name || ` : `
`; el.innerHTML = ` ${visual}
${buildPlayerLabel(player)}
`; makeDraggable(el); return el; } function clampWebPercent(value) { const n = Number(value); if (!Number.isFinite(n)) return 50; return Math.max(4, Math.min(96, n)); } function renderFormation(side) { ensureInitialState(side); syncFormationCaptainsInState(side); const pitch = document.getElementById(`pitch-${side}`); if (!pitch) return; const overlays = pitch.querySelectorAll(".player-node"); overlays.forEach(node => node.remove()); const players = Array.isArray(formationState[side]) ? formationState[side] : []; players.forEach(player => { pitch.appendChild(createPlayerNode(player, side)); }); } function isGoalkeeperPosition(position) { const pos = String(position || "").trim().toLowerCase(); return ["вр.", "вр", "вратарь", "gk", "goalkeeper"].includes(pos); } function getRowPlayer(row) { return { id: row.dataset.playerId || "", name: row.dataset.playerName || "", lastName: row.dataset.playerLast || "", firstName: row.dataset.playerFirst || "", number: row.dataset.playerNumber || "", position: row.dataset.playerPosition || "", isCaptain: row.dataset.playerCaptain === "1", photoEnabled: row.dataset.playerPhotoEnabled === "1", disciplineType: row.dataset.disciplineType || "" }; } function setRowPlayer(row, player) { row.dataset.playerId = player.id ?? ""; row.dataset.playerName = player.name ?? ""; row.dataset.playerLast = player.lastName ?? ""; row.dataset.playerFirst = player.firstName ?? ""; row.dataset.playerNumber = player.number ?? ""; row.dataset.playerPosition = player.position ?? ""; row.dataset.playerCaptain = player.isCaptain ? "1" : "0"; row.dataset.playerPhotoEnabled = player.photoEnabled ? "1" : "0"; row.dataset.disciplineType = player.disciplineType ?? ""; } function getDisplayNameForEvent(event) { if (!event) return ""; if (event.type === "sub") { const subNames = getSubstitutionDisplayNames(event); if (subNames.out || subNames.in) { return `${subNames.out} → ${subNames.in}`.trim(); } return String(event.player_name || "").trim(); } if (event.player_id) { const row = findLineupRowByPlayerId(event.side, "starting", event.player_id) || findLineupRowByPlayerId(event.side, "bench", event.player_id); if (row) { return getRowDisplayName(row); } } return String(event.player_name || "").trim(); } function buildPersonDisplayName(firstName, lastName, fallback = "") { const first = String(firstName || "").trim(); const last = String(lastName || "").trim(); const safeFallback = String(fallback || "").trim(); if (first && last) return `${first} ${last}`; if (last) return last; if (first) return first; return safeFallback; } function getRowDisplayName(row) { if (!row) return ""; return buildPersonDisplayName( row.dataset.playerFirst || "", row.dataset.playerLast || "", row.dataset.playerName || "" ); } function getSubstitutionDisplayNames(event) { if (!event || event.type !== "sub") { return { out: "", in: "" }; } const outRow = findLineupRowByPlayerId(event.side, "starting", event.player_out_id) || findLineupRowByPlayerId(event.side, "bench", event.player_out_id); const inRow = findLineupRowByPlayerId(event.side, "starting", event.player_in_id) || findLineupRowByPlayerId(event.side, "bench", event.player_in_id); return { out: getRowDisplayName(outRow), in: getRowDisplayName(inRow) }; } function renderLineupRow(row) { const side = row.dataset.side; const role = row.dataset.role; const player = getRowPlayer(row); const displayName = buildPersonDisplayName( player.firstName, player.lastName, player.name ); const gkBadge = isGoalkeeperPosition(player.position) ? 'ВР' : ''; const captainBadge = player.isCaptain ? 'К' : ''; const disciplineBadge = player.disciplineType === "yellow_red" ? '2ЖК' : (player.disciplineType === "red" ? 'КК' : ''); const firstNameHtml = player.firstName ? ` ${escapeHtml(player.firstName)}` : ''; const idJs = JSON.stringify(player.id || player.number || ""); const nameJs = JSON.stringify(displayName); const noPhotoIconHtml = player.photoEnabled ? '' : '📷'; let actionsHtml = ''; if (role === "starting") { actionsHtml = `
`; } else if (role === "bench") { actionsHtml = `
`; } else { actionsHtml = `
`; } row.innerHTML = ` ${noPhotoIconHtml} ${escapeHtml(player.number || "")} ${actionsHtml} `; } document.addEventListener("click", async function (event) { const tab = event.target.closest(".tab-link[data-vmix-title]"); if (!tab) return; event.preventDefault(); const title = String(tab.dataset.vmixTitle || "").trim(); try { if (title) { await sendDirectVmixCommands([ vmixCmd(`Function=PreviewInput&Input=${encodeURIComponent(title)}`) ]); } } finally { window.location.href = tab.href; } }); function makeDraggable(el) { let dragging = false; el.addEventListener("pointerdown", (e) => { if (!editModeEnabled) return; e.preventDefault(); dragging = true; el.setPointerCapture(e.pointerId); el.classList.add("dragging"); }); el.addEventListener("pointermove", (e) => { if (!editModeEnabled || !dragging) return; const pitch = el.closest(".pitch-half"); if (!pitch) return; const rect = pitch.getBoundingClientRect(); let x = ((e.clientX - rect.left) / rect.width) * 100; let y = ((e.clientY - rect.top) / rect.height) * 100; x = Math.max(4, Math.min(96, x)); y = Math.max(4, Math.min(96, y)); el.style.left = `${x}%`; el.style.top = `${y}%`; const side = el.dataset.side; const playerId = el.dataset.playerId; const list = formationState[side] || []; const item = list.find((p) => String(p.player_id) === String(playerId)); if (item) { item.x = x; item.y = y; } }); const stopDrag = (e) => { if (!dragging) return; dragging = false; el.classList.remove("dragging"); try { el.releasePointerCapture(e.pointerId); } catch (_) {} saveMatchState(); }; el.addEventListener("pointerup", stopDrag); el.addEventListener("pointercancel", stopDrag); } document.addEventListener("DOMContentLoaded", () => { setEditMode(false); renderRefereesEditor(); }); async function saveFormation(side) { syncFormationCaptainsInState(side); const response = await fetch(`${window.location.pathname}/formations/save`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ side, players: formationState[side] }) }); const data = await response.json(); if (data.success) { await sendFormationToVmix(side); alert("Расстановка сохранена"); } else { alert("Ошибка сохранения"); } } restoreSelectedPreset("home"); restoreSelectedPreset("away"); rebuildFormationFromMatchData("home"); rebuildFormationFromMatchData("away"); sortLineupRows("home", "starting"); sortLineupRows("home", "bench"); sortLineupRows("away", "starting"); sortLineupRows("away", "bench"); // берём актуальный состав прямо из HTML, который пришёл с сервера originalLineupState = captureCurrentLineupState(); const restored = restoreMatchState(); saveMatchState(); if (!restored) { updateTimerUI(); updateScoreUI(); renderEvents(); saveMatchState(); } initEventsStateSync(); initClockStateSync(); window.addEventListener("beforeunload", saveMatchState); window.addEventListener("pagehide", saveMatchState); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") { saveMatchState(); } }); const VMIX_ORIGIN = { x: 0.003, y: -0.075 }; const VMIX_X_MIN = -0.338; const VMIX_X_MAX = 0.346; const VMIX_Y_TOP = 0; const VMIX_Y_BOTTOM = -1.535; // const VMIX_Y_OFFSET = -0.225; // подгонишь руками function getFormationVmixInputName(side) { return side === "home" ? "РАССТАНОВКА - ХОЗЯЕВА" : "РАССТАНОВКА - ГОСТИ"; } function clampVmixPercent(value) { const num = Number(value); if (!Number.isFinite(num)) return 0; return Math.max(0, Math.min(100, num)); } function convertPitchXToPan(x) { const percent = clampVmixPercent(x) / 100; return VMIX_X_MIN + percent * (VMIX_X_MAX - VMIX_X_MIN); } function convertPitchYToPan(y) { const percent = clampVmixPercent(y) / 100; return VMIX_Y_TOP + percent * (VMIX_Y_BOTTOM - VMIX_Y_TOP); } function buildVmixCommandsFromFormation(side) { const players = Array.isArray(formationState[side]) ? formationState[side] : []; const commands = []; const playerPrefix = side === "home" ? "HOME" : "AWAY"; const inputName = side === "home" ? "РАССТАНОВКА - ХОЗЯЕВА" : "РАССТАНОВКА - ГОСТИ"; if (players.length < 2) { return []; } for (let i = 1; i <= 10; i++) { commands.push( vmixCmd(`Function=ResetInput&Input=${encodeURIComponent(`${playerPrefix} - Игрок${i}`)}`) ); } commands.push( vmixCmd(`Function=ResetInput&Input=${encodeURIComponent(inputName)}`) ); players.slice(1, 11).forEach((player, index) => { const playerNumber = index + 1; const playerInputName = `${playerPrefix} - Игрок${playerNumber}`; const x = convertPitchXToPan(player.x).toFixed(3); const y = convertPitchYToPan(player.y).toFixed(3); commands.push( vmixCmd(`Function=SetPanX&Input=${encodeURIComponent(playerInputName)}&Value=${x}`) ); commands.push( vmixCmd(`Function=SetPanY&Input=${encodeURIComponent(playerInputName)}&Value=${y}`) ); }); return commands; } async function sendFormationToVmix(side) { syncFormationCaptainsInState(side); const commands = buildVmixCommandsFromFormation(side); if (!commands.length) { alert("Нет данных для отправки расстановки в vMix"); return; } console.log(`FORMATION VMIX ${side}`, formationState[side]); console.log(`FORMATION VMIX COMMANDS ${side}`, commands); await sendDirectVmixCommands(commands); } const FORMATION_COORDS = { "4-4-2": [ { x: 50, y: 10 }, { x: 0, y: 40 }, { x: 30, y: 30 }, { x: 70, y: 30 }, { x: 100, y: 40 }, { x: 0, y: 75 }, { x: 30, y: 65 }, { x: 70, y: 65 }, { x: 100, y: 75 }, { x: 25, y: 100 }, { x: 75, y: 100 } ] }; const BASE_GEOMETRY = { goalkeeper: { x: 50, y: 10 }, rowsY: { defenders: 35, midfielders: 70, forwards: 100 }, rowProfiles: { 1: { xSlots: [50], arc: 0 }, 2: { xSlots: [25, 75], arc: 0 }, 3: { xSlots: [8, 50, 92], arc: 0 }, 4: { xSlots: [0, 30, 70, 100], arc: 10 }, 5: { xSlots: [0, 24, 50, 78, 100], arc: 20, yOffset: 5 }, 6: { xSlots: [0, 16, 36, 64, 84, 100], arc: 0 } } }; function distributeLine(count, yCenter, profile) { const { minX, maxX, arc = 0, xSlots = null, yOffset = 0 } = profile; if (count <= 0) return []; if (count === 1) return [{ x: 50, y: yCenter }]; const xs = Array.isArray(xSlots) && xSlots.length === count ? xSlots : Array.from({ length: count }, (_, i) => minX + ((maxX - minX) * i / (count - 1))); // Специальная полудуга для линии из 5 игроков if (count === 5 && arc > 0) { const offsets = [ arc * 0.11, // левый фланг чуть ниже базы -arc * 0.5, // левый полуфланг выше -arc, // центр выше всех -arc * 0.5, // правый полуфланг выше arc * 0.11 // правый фланг чуть ниже базы ]; return xs.map((x, i) => ({ x, y: yCenter + offsets[i] + yOffset })); } // Общая логика для остальных линий const center = (count - 1) / 2; return xs.map((x, i) => { let y = yCenter; if (arc > 0) { const dist = Math.abs(i - center); const maxDist = center || 1; const normalized = 1 - dist / maxDist; y = yCenter - arc * normalized; } return { x, y: y + yOffset }; }); } const FORMATION_TEMPLATE_442 = { gk: { x: 50, y: 10 }, rows: [ { y: 35, xSlots: [0, 30, 70, 100] }, // защита 4 { y: 70, xSlots: [0, 30, 70, 100] }, // полузащита 4 { y: 100, xSlots: [25, 75] } // нападение 2 ] }; function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } function lerp(a, b, t) { return a + (b - a) * t; } function distributeEvenly(count, start, end) { if (count <= 0) return []; if (count === 1) return [(start + end) / 2]; const step = (end - start) / (count - 1); return Array.from({ length: count }, (_, i) => start + step * i); } function getRowSpacingFactor(rowIndex, rowCount, options = {}) { // можно управлять “сжатием” ближе к воротам или к атаке const { curve = 1 // 1 = линейно, >1 сильнее прижимает первые линии } = options; if (rowCount <= 1) return 1; const t = rowIndex / (rowCount - 1); return Math.pow(t, curve); } function getHorizontalInsetsForRow(count, rowType, options = {}) { const { sideInset = 8, wideRowExtraInset = 0, strikerInset = 18 } = options; // можно по типу линии менять ширину доступной зоны if (rowType === "forwards" && count <= 2) { return { left: strikerInset, right: strikerInset }; } if (count >= 5) { return { left: sideInset + wideRowExtraInset, right: sideInset + wideRowExtraInset }; } return { left: sideInset, right: sideInset }; } function getRowArcOffsets(count, arcStrength = 0, direction = "up") { if (count <= 1 || arcStrength === 0) { return Array(count).fill(0); } const center = (count - 1) / 2; const sign = direction === "down" ? 1 : -1; return Array.from({ length: count }, (_, i) => { const dist = Math.abs(i - center); const maxDist = center || 1; const normalized = 1 - (dist / maxDist); // центр = 1, края = 0 return sign * arcStrength * Math.pow(normalized, 1.4); }); } function parseEventTimeInput(value) { const raw = String(value || "").trim().replace("’", "'"); // варианты: 12, 12', 45+2, 45'+2, 90+4 const match = raw.match(/^(\d{1,3})'?(?:\+(\d{1,2}))?$/); if (!match) return null; const baseMinute = Number(match[1]); const extraMinute = Number(match[2] || 0); if (!Number.isFinite(baseMinute) || baseMinute < 1) return null; const totalMinute = baseMinute + extraMinute; const seconds = Math.max(0, (totalMinute - 1) * 60); const label = extraMinute > 0 ? `${baseMinute}'+${extraMinute}` : `${baseMinute}'`; return { label, minute: totalMinute, seconds }; } async function editEventTime(eventId) { const event = matchEvents.find((e) => String(e.id) === String(eventId)); if (!event) return; const value = prompt("Новое время события. Например: 23, 45+2, 90+4", event.minute || ""); if (value === null) return; const parsed = parseEventTimeInput(value); if (!parsed) { alert("Неверный формат времени. Пример: 23 или 45+2"); return; } const updated = { side: event.side, type: event.type, player_name: event.player_name || "", minute: parsed.minute, seconds: parsed.seconds, meta: event.meta || "", player_id: event.player_id ?? null, player_out_id: event.player_out_id ?? null, player_in_id: event.player_in_id ?? null }; const ok = await updateEventOnServer(event.id, updated); if (!ok) { alert("Не удалось обновить время события"); return; } event.minute = parsed.label; event.seconds = parsed.seconds; // matchEvents = sortEventsForDisplay(matchEvents); renderEvents(); rebuildLineupsFromEvents(); saveMatchState(); } function getRowTopLift(profile, count) { const arc = Number(profile?.arc || 0); const yOffset = Number(profile?.yOffset || 0); if (count === 5 && arc > 0) { // у пятёрки самая верхняя точка — центр return Math.max(0, arc - yOffset); } if (count > 1 && arc > 0) { // для обычной дуги максимум подъёма в центре return Math.max(0, arc - yOffset); } return Math.max(0, -yOffset); } function buildFormationCoords(formation) { const parts = String(formation).split("-").map(Number).filter(Number.isFinite); const coords = []; coords.push(BASE_GEOMETRY.goalkeeper); if (!parts.length) { return coords; } const rowCount = parts.length; const firstRowY = 25; let rowGap = 23; if (formation === "4-1-4-1") { rowGap = 16; } let previousBottomY = firstRowY - rowGap; parts.forEach((count, idx) => { const profile = BASE_GEOMETRY.rowProfiles[count] || { minX: 10, maxX: 90, arc: 0 }; const topLift = getRowTopLift(profile, count); // стартуем новую линию так, чтобы её верхняя точка была ниже предыдущей линии + gap const yCenter = previousBottomY + rowGap + topLift; const rowCoords = distributeLine(count, yCenter, profile); coords.push(...rowCoords); previousBottomY = Math.max(...rowCoords.map(p => p.y)); }); return coords; } function generateFormationCoordsFromLayout(preset) { const layout = FORMATION_ASSIGN_LAYOUTS[preset]; if (!layout) return []; return buildFormationCoords(preset); } function generateFormationCoordsFromLayout2(preset) { const layout = FORMATION_ASSIGN_LAYOUTS[preset]; if (!layout || !layout.length) return []; const coords = []; const gk = FORMATION_COORDS["4-4-2"]?.[0] || { x: 50, y: 10 }; coords.push({ x: gk.x, y: gk.y }); const fieldRows = layout.slice(1); const rowCount = fieldRows.length; const yStart = 35; const yEnd = 100; fieldRows.forEach((row, rowIndex) => { const count = row.length; const baseY = rowCount === 1 ? yEnd : yStart + ((yEnd - yStart) * rowIndex / (rowCount - 1)); const xSlots = getDistributedXSlots(count); let arcStrength = count >= 4 ? 10 : 0; // Для 3-5-2 средняя линия из 5 должна быть ровно посередине if (preset === "3-5-2" && rowIndex === 1 && count === 5) { arcStrength = 20; } const arcOffsets = getArcYOffsetSlots(count, arcStrength); xSlots.forEach((x, i) => { coords.push({ x, y: baseY + arcOffsets[i] }); }); }); return coords; } const FORMATION_ASSIGN_LAYOUTS = { "4-4-2": [ ["GK"], ["LB", "LCB", "RCB", "RB"], ["LM", "LCM", "RCM", "RM"], ["LS", "RS"] ], "4-3-3": [ ["GK"], ["LB", "LCB", "RCB", "RB"], ["LCM", "CM", "RCM"], ["LW", "ST", "RW"] ], "4-2-3-1": [ ["GK"], ["LB", "LCB", "RCB", "RB"], ["LDM", "RDM"], ["LAM", "CAM", "RAM"], ["ST"] ], "3-5-2": [ ["GK"], ["LCB", "CB", "RCB"], ["LM", "LCM", "CM", "RCM", "RM"], ["LS", "RS"] ], "5-3-2": [ ["GK"], ["LWB", "LCB", "CB", "RCB", "RWB"], ["LCM", "CM", "RCM"], ["LS", "RS"] ], "4-1-4-1": [ ["GK"], ["LB", "LCB", "RCB", "RB"], ["CDM"], ["LM", "LCM", "RCM", "RM"], ["ST"] ], "3-4-3": [ ["GK"], ["LCB", "CB", "RCB"], ["LM", "LCM", "RCM", "RM"], ["LW", "ST", "RW"] ], "4-5-1": [ ["GK"], ["LB", "LCB", "RCB", "RB"], ["LM", "LCM", "CM", "RCM", "RM"], ["ST"] ], "5-4-1": [ ["GK"], ["LWB", "LCB", "CB", "RCB", "RWB"], ["LM", "LCM", "RCM", "RM"], ["ST"] ], "4-3-1-2": [ ["GK"], ["LB", "LCB", "RCB", "RB"], ["LCM", "CM", "RCM"], ["CAM"], ["LS", "RS"] ], "3-6-1": [ ["GK"], ["LCB", "CB", "RCB"], ["LM", "LCM", "CM", "RCM", "RM", "CAM"], ["ST"] ] }; let formationAssignState = { side: null, preset: null }; function getFormationAssignSquad(side) { return side === "home" ? (window.MATCH_DATA.homeStarting || []) : (window.MATCH_DATA.awayStarting || []); } function getFormationAssignTeamName(side) { return side === "home" ? (window.MATCH_DATA.teamNames?.home || "") : (window.MATCH_DATA.teamNames?.away || ""); } function getFormationAssignPhoto(player) { return player.photo || player.player_photo || player.image || player.avatar || ""; } function getFormationAssignPlayerName(player) { return `${player.last_name || player.player_name || ""} ${player.first_name || ""}`.trim(); } function getExistingAssignedNumber(side, rowIndex, colIndex) { const list = Array.isArray(formationState[side]) ? formationState[side] : []; const item = list.find(p => Number(p.row_index) === rowIndex && Number(p.col_index) === colIndex); return item ? String(item.number || "") : ""; } function openFormationAssignModal(side) { const presetSelect = document.getElementById(`${side}-preset`); if (!presetSelect) return; const preset = presetSelect.value; const layout = FORMATION_ASSIGN_LAYOUTS[preset]; if (!layout) return; formationAssignState.side = side; formationAssignState.preset = preset; const subtitle = document.getElementById("formationAssignSubtitle"); if (subtitle) { subtitle.textContent = `${getFormationAssignTeamName(side)} — схема ${preset}`; } const squad = getFormationAssignSquad(side); const wrap = document.getElementById("formationAssignTableWrap"); if (!wrap) return; let html = ` `; layout.forEach((row, rowIndex) => { row.forEach((pos, colIndex) => { const existingNumber = getExistingAssignedNumber(side, rowIndex, colIndex); html += ` `; }); }); html += `
Линия Позиция № игрока Доступные номера
${rowIndex + 1} ${pos} ${squad.map(p => p.number).filter(Boolean).join(", ")}
`; wrap.innerHTML = html; document.getElementById("formationAssignModal")?.classList.add("show"); } function closeFormationAssignModal() { document.getElementById("formationAssignModal")?.classList.remove("show"); } function confirmFormationAssign() { const side = formationAssignState.side; const preset = formationAssignState.preset; if (!side || !preset) return; const layout = FORMATION_ASSIGN_LAYOUTS[preset]; const squad = getFormationAssignSquad(side); const inputs = document.querySelectorAll("#formationAssignTableWrap .formation-number-input"); const usedNumbers = new Set(); const players = []; for (const input of inputs) { const rawNumber = String(input.value || "").trim(); if (!rawNumber) continue; if (usedNumbers.has(rawNumber)) { alert(`Номер ${rawNumber} указан дважды.`); return; } usedNumbers.add(rawNumber); const player = squad.find(p => String(p.number || "").trim() === rawNumber); if (!player) { alert(`Игрок с номером ${rawNumber} не найден в стартовом составе.`); return; } players.push({ player_id: player.player_id || player.id || player.number || "", player_name: getFormationAssignPlayerName(player), last_name: player.last_name || player.player_name || "", first_name: player.first_name || "", number: player.number || "", position: player.position || "", is_captain: !!player.is_captain, photo: getFormationAssignPhoto(player), x: 50, y: 50, row_index: Number(input.dataset.row), col_index: Number(input.dataset.col), slot_position: input.dataset.pos || "" }); } const expectedCount = layout.flat().length; if (players.length !== expectedCount) { alert(`Нужно заполнить все позиции. Сейчас заполнено ${players.length} из ${expectedCount}.`); return; } const rowCount = layout.length; const topPadding = 18; const bottomPadding = 14; const usableHeight = 100 - topPadding - bottomPadding; const coords = FORMATION_COORDS[preset] || generateFormationCoordsFromLayout(preset); players.forEach((item, index) => { if (coords[index]) { item.x = coords[index].x; item.y = coords[index].y; } }); formationState[side] = players; syncFormationCaptainsInState(side); renderFormation(side); closeFormationAssignModal(); renderFormation(side); closeFormationAssignModal(); } async function applyPreset(side) { openFormationAssignModal(side); } const squadEditorState = { home: null, away: null }; function normalizePlayer(item = {}) { return { player_id: item.player_id ?? item.id ?? item.number ?? "", player_name: item.player_name || `${item.last_name || ""} ${item.first_name || ""}`.trim(), last_name: item.last_name || item.player_name || "", first_name: item.first_name || "", number: item.number || "", position: item.position || "", is_captain: !!item.is_captain, photo_enabled: item.photo_enabled === true || item.photo_enabled === 1 || item.photo_enabled === "1" }; } function normalizeCoach(item = {}) { return { coach_id: item.coach_id ?? item.id ?? item.coach_name ?? "", coach_name: item.coach_name || item.name || "", role: item.role || "" }; } function cloneSideState(side) { return { starting: (side === "home" ? (window.MATCH_DATA.homeStarting || []) : (window.MATCH_DATA.awayStarting || [])) .map(normalizePlayer), bench: (side === "home" ? (window.MATCH_DATA.homeBench || []) : (window.MATCH_DATA.awayBench || [])) .map(normalizePlayer), coaches: (side === "home" ? (window.MATCH_DATA.homeCoaches || []) : (window.MATCH_DATA.awayCoaches || [])) .map(normalizeCoach), squadPool: (side === "home" ? (window.MATCH_DATA.homeSquadPool || []) : (window.MATCH_DATA.awaySquadPool || [])) .map(normalizePlayer), coachPool: (side === "home" ? (window.MATCH_DATA.homeCoachPool || []) : (window.MATCH_DATA.awayCoachPool || [])) .map(normalizeCoach) }; } async function openSquadEditor() { if (!editModeEnabled) return; const ok = await loadSquadEditorData(); if (!ok) { alert("Не удалось загрузить игроков и тренеров из базы."); return; } squadEditorState.home = cloneSideState("home"); squadEditorState.away = cloneSideState("away"); renderSquadEditorSide("home"); renderSquadEditorSide("away"); document.getElementById("squadEditorModal")?.classList.add("show"); } function closeSquadEditor() { document.getElementById("squadEditorModal")?.classList.remove("show"); } function getSideEditorState(side) { return side === "home" ? squadEditorState.home : squadEditorState.away; } function getSideContainerId(side) { return side === "home" ? "squadEditorHome" : "squadEditorAway"; } function getAvailableSquadPool(side) { const state = getSideEditorState(side); if (!state) return []; const used = new Set( [...state.starting, ...state.bench].map(p => String(p.player_id)) ); return state.squadPool.filter(p => !used.has(String(p.player_id))); } function buildPlayerOptionText(p) { const fullName = `${p.last_name || p.player_name || ""} ${p.first_name || ""}`.trim(); const num = p.number ? `#${p.number}` : "без №"; const pos = p.position ? ` • ${p.position}` : ""; return `${num} • ${fullName}${pos}`; } function renderSquadEditorSide(side) { const state = getSideEditorState(side); const mount = document.getElementById(getSideContainerId(side)); if (!state || !mount) return; const availablePlayers = getAvailableSquadPool(side); const coachOptions = state.coachPool; mount.innerHTML = `
Добавить игрока
В списке показываются только игроки, которых ещё нет в текущем составе.
Основной состав
${state.starting.length ? state.starting.map(player => renderSquadEditorPlayerRow(side, "starting", player)).join("") : `
Пока пусто.
`}
Запасные
${state.bench.length ? state.bench.map(player => renderSquadEditorPlayerRow(side, "bench", player)).join("") : `
Пока пусто.
`}
Тренеры
${renderCoachEditorSection(side, coachOptions)}
`; } function renderSquadEditorPlayerRow(side, role, player) { const fullName = `${player.last_name || player.player_name || ""} ${player.first_name || ""}`.trim(); const captainClass = player.is_captain ? "active" : ""; const captainText = player.is_captain ? "Капитан" : "Сделать капитаном"; return `
${escapeHtml(fullName)}
#${escapeHtml(String(player.number || "—"))} ${player.position ? ` • ${escapeHtml(player.position)}` : ""}
${role === "starting" ? ` ` : ` `}
`; } function renderCoachEditorSection(side, coachOptions) { const state = getSideEditorState(side); if (!state) return ""; const existingRoles = state.coaches.length ? state.coaches : [{ coach_id: "", coach_name: "", role: "Главный тренер" }]; return `
${existingRoles.map((coach, index) => `
${escapeHtml(coach.role || "Тренер")}
${escapeHtml(coach.coach_name || "Не выбран")}
`).join("")}
`; } function addPlayerFromPool(side, targetRole) { const state = getSideEditorState(side); if (!state) return; const select = document.getElementById(`addPlayerSelect-${side}`); const playerId = String(select?.value || "").trim(); if (!playerId) return; const player = state.squadPool.find(p => String(p.player_id) === playerId); if (!player) return; state[targetRole].push({ ...player }); renderSquadEditorSide(side); } function removePlayerFromEditor(side, playerId, role) { const state = getSideEditorState(side); if (!state) return; const hasLinkedEvents = matchEvents.some(event => String(event.side) === String(side) && ( String(event.player_id || "") === String(playerId) || String(event.player_out_id || "") === String(playerId) || String(event.player_in_id || "") === String(playerId) ) ); if (hasLinkedEvents) { alert("Нельзя удалить игрока: по нему уже есть события матча."); return; } state[role] = state[role].filter(p => String(p.player_id) !== String(playerId)); renderSquadEditorSide(side); } function movePlayerBetweenZones(side, playerId, fromRole, toRole) { const state = getSideEditorState(side); if (!state) return; const idx = state[fromRole].findIndex(p => String(p.player_id) === String(playerId)); if (idx === -1) return; const [player] = state[fromRole].splice(idx, 1); if (toRole === "bench") { player.is_captain = false; } state[toRole].push(player); const hasCaptainInStarting = state.starting.some(p => p.is_captain); if (!hasCaptainInStarting && state.starting.length) { state.starting[0].is_captain = true; } renderSquadEditorSide(side); } function setCaptainInEditor(side, playerId, role) { const state = getSideEditorState(side); if (!state) return; if (role !== "starting") { alert("Капитан должен быть в основном составе."); return; } state.starting.forEach(player => { player.is_captain = String(player.player_id) === String(playerId); }); state.bench.forEach(player => { player.is_captain = false; }); renderSquadEditorSide(side); } function updateCoachInEditor(side, index, coachId) { const state = getSideEditorState(side); if (!state) return; const coach = state.coachPool.find(c => String(c.coach_id) === String(coachId)); if (!coach) return; state.coaches[index] = { coach_id: coach.coach_id, coach_name: coach.coach_name, role: coach.role || "Тренер" }; renderSquadEditorSide(side); } function renderLiveCoachBlocks() { renderLiveCoachBlockForSide("home"); renderLiveCoachBlockForSide("away"); updateCoachCardButtons(); } function renderLiveCoachBlockForSide(side) { const selector = side === "home" ? ".panel .coaches-block" : ".panel .coaches-block-right"; const block = Array.from(document.querySelectorAll(selector)).find(Boolean); const source = side === "home" ? (window.MATCH_DATA.homeCoaches || []) : (window.MATCH_DATA.awayCoaches || []); if (!block) return; block.innerHTML = source.map(c => `
`).join(""); } function rerenderLineupTable(side, role) { const rows = Array.from(document.querySelectorAll(`tr.lineup-row[data-side="${side}"][data-role="${role}"]`)); const tbody = rows[0]?.closest("tbody"); if (!tbody) return; rows.forEach(row => row.remove()); const source = side === "home" ? (role === "starting" ? window.MATCH_DATA.homeStarting : window.MATCH_DATA.homeBench) : (role === "starting" ? window.MATCH_DATA.awayStarting : window.MATCH_DATA.awayBench); source.forEach(playerRaw => { const player = normalizePlayer(playerRaw); const row = document.createElement("tr"); row.className = "lineup-row"; row.dataset.side = side; row.dataset.role = role; setRowPlayer(row, { id: player.player_id, name: player.player_name, lastName: player.last_name, firstName: player.first_name, number: player.number, position: player.position, isCaptain: !!player.is_captain, photoEnabled: !!player.photo_enabled, disciplineType: "" }); renderLineupRow(row); tbody.appendChild(row); }); sortLineupRows(side, role); } function rebuildFormationFromMatchData(side) { const starting = side === "home" ? (window.MATCH_DATA.homeStarting || []) : (window.MATCH_DATA.awayStarting || []); const prev = Array.isArray(formationState[side]) ? formationState[side] : []; formationState[side] = starting.slice(0, 11).map((p, idx) => { const playerId = String(p.player_id ?? p.id ?? p.number ?? ""); const old = prev.find(x => String(x.player_id ?? x.id ?? x.number ?? "") === playerId); return { player_id: p.player_id || p.id || p.number || null, player_name: p.player_name || `${p.last_name || ""} ${p.first_name || ""}`.trim(), last_name: p.last_name || p.player_name || "", first_name: p.first_name || "", number: p.number || "", position: p.position || "", is_captain: !!p.is_captain, photo: p.photo || "", photo_enabled: p.photo_enabled === true || p.photo_enabled === 1 || p.photo_enabled === "1", x: old?.x ?? 50, y: old?.y ?? (12 + idx * 6) }; }); renderFormation(side); } async function applySquadEditorChanges() { if (!squadEditorState.home || !squadEditorState.away) return; const payload = buildSquadSavePayload(); const result = await saveSquadEditorChangesToServer(payload); if (!result.success) { alert(`Не удалось сохранить состав: ${result.error}`); return; } console.log("SAVE PAYLOAD HOME STARTING", payload.home_starting); window.MATCH_DATA.homeStarting = payload.home_starting.map(p => ({ ...p })); window.MATCH_DATA.homeBench = payload.home_bench.map(p => ({ ...p })); window.MATCH_DATA.homeCoaches = payload.home_coaches.map(c => ({ ...c })); window.MATCH_DATA.awayStarting = payload.away_starting.map(p => ({ ...p })); window.MATCH_DATA.awayBench = payload.away_bench.map(p => ({ ...p })); window.MATCH_DATA.awayCoaches = payload.away_coaches.map(c => ({ ...c })); fallbackLineups.home = window.MATCH_DATA.homeStarting; fallbackLineups.away = window.MATCH_DATA.awayStarting; rebuildFormationFromMatchData("home"); rebuildFormationFromMatchData("away"); console.log("MATCH_DATA HOME STARTING AFTER SAVE", window.MATCH_DATA.homeStarting); rerenderLineupTable("home", "starting"); rerenderLineupTable("home", "bench"); rerenderLineupTable("away", "starting"); rerenderLineupTable("away", "bench"); renderLiveCoachBlocks(); originalLineupState = captureCurrentLineupState(); rebuildLineupsFromEvents(); saveMatchState(); closeSquadEditor(); alert("Состав сохранён"); } async function loadSquadEditorData() { if (!SESSION_TOKEN) return false; try { const res = await fetch(`/admin/session/${SESSION_TOKEN}/squad-editor-data`, { method: "GET", headers: { "Accept": "application/json" } }); if (!res.ok) { console.warn("Не удалось загрузить данные редактора состава", res.status); return false; } const data = await res.json(); window.MATCH_DATA.homeStarting = Array.isArray(data.home_starting) ? data.home_starting : []; window.MATCH_DATA.awayStarting = Array.isArray(data.away_starting) ? data.away_starting : []; window.MATCH_DATA.homeBench = Array.isArray(data.home_bench) ? data.home_bench : []; window.MATCH_DATA.awayBench = Array.isArray(data.away_bench) ? data.away_bench : []; window.MATCH_DATA.homeCoaches = Array.isArray(data.home_coaches) ? data.home_coaches : []; window.MATCH_DATA.awayCoaches = Array.isArray(data.away_coaches) ? data.away_coaches : []; window.MATCH_DATA.homeSquadPool = Array.isArray(data.home_squad_pool) ? data.home_squad_pool : []; window.MATCH_DATA.awaySquadPool = Array.isArray(data.away_squad_pool) ? data.away_squad_pool : []; window.MATCH_DATA.homeCoachPool = Array.isArray(data.home_coach_pool) ? data.home_coach_pool : []; window.MATCH_DATA.awayCoachPool = Array.isArray(data.away_coach_pool) ? data.away_coach_pool : []; if (typeof renderLiveCoachBlocks === "function") { renderLiveCoachBlocks(); } return true; } catch (e) { console.warn("Ошибка загрузки squad editor data", e); return false; } } function getFormationPresetStorageKey(side) { return `match_formation_preset_${MATCH_ID}_${side}`; } function saveSelectedPreset(side) { const select = document.getElementById(`${side}-preset`); if (!select) return; localStorage.setItem(getFormationPresetStorageKey(side), select.value); } function restoreSelectedPreset(side) { const select = document.getElementById(`${side}-preset`); if (!select) return; const saved = localStorage.getItem(getFormationPresetStorageKey(side)); if (!saved) return; const exists = Array.from(select.options).some(opt => opt.value === saved); if (exists) { select.value = saved; } } function buildSquadSavePayload() { return { home_starting: (squadEditorState.home?.starting || []).map(p => ({ ...p })), home_bench: (squadEditorState.home?.bench || []).map(p => ({ ...p })), away_starting: (squadEditorState.away?.starting || []).map(p => ({ ...p })), away_bench: (squadEditorState.away?.bench || []).map(p => ({ ...p })), home_coaches: (squadEditorState.home?.coaches || []).map(c => ({ ...c })), away_coaches: (squadEditorState.away?.coaches || []).map(c => ({ ...c })) }; } async function saveSquadEditorChangesToServer(payload) { if (!SESSION_TOKEN) return { success: false, error: "Нет SESSION_TOKEN" }; try { const res = await fetch(`/admin/session/${SESSION_TOKEN}/squad-editor-save`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) { return { success: false, error: data.error || `HTTP ${res.status}` }; } return { success: true, data }; } catch (e) { return { success: false, error: e?.message || "Ошибка сети" }; } } function normalizeReferee(item = {}) { return { referee_id: item.referee_id ?? item.id ?? "", referee_name: (item.referee_name || item.full_name || item.name || "").trim(), role: (item.role || "").trim() }; } let filteredRefereePool = []; function renderRefereesEditor() { const wrap = document.getElementById("refereesEditorWrap"); if (!wrap) return; const items = Array.isArray(refereeEditorState.items) ? refereeEditorState.items.map(normalizeReferee) : []; const searchHtml = editModeEnabled ? ` ` : ""; wrap.innerHTML = ` ${editModeEnabled ? "" : ""} ${items.map((r, i) => ` ${editModeEnabled ? `` : ""} `).join("")}
Роль ФИО
${r.role} ${r.referee_name}
${searchHtml} `; } function filterRefereeList(q) { const used = new Set(refereeEditorState.items.map(r => String(r.referee_id))); filteredRefereePool = refereeEditorState.pool.filter(r => r.referee_name.toLowerCase().includes(q.toLowerCase()) && !used.has(String(r.referee_id)) ); renderRefereeSearchList(); } function renderRefereeSearchList() { const list = document.getElementById("refereeSearchList"); if (!list) return; list.innerHTML = filteredRefereePool.map(r => `
${r.referee_name}
` ).join(""); } function selectRefereeFromSearch(id) { selectedRefereeForAdd = refereeEditorState.pool.find(r => String(r.referee_id) === String(id)); document.getElementById("refereeSearchInput").value = selectedRefereeForAdd.referee_name; filteredRefereePool = []; renderRefereeSearchList(); } function addRefereeFromSelect() { const role = document.getElementById("newRefereeRole").value; if (!selectedRefereeForAdd || !role) return alert("Выбери судью и роль"); refereeEditorState.items.push({ referee_id: selectedRefereeForAdd.referee_id, referee_name: selectedRefereeForAdd.referee_name, role }); selectedRefereeForAdd = null; renderRefereesEditor(); } function removeRefereeRow(i) { refereeEditorState.items.splice(i, 1); renderRefereesEditor(); } async function saveReferees() { const payload = refereeEditorState.items .map(normalizeReferee) .filter(item => (item.referee_id || item.referee_name) && item.role); console.log("SAVE referees payload:", payload); if (!payload.length) { alert("Нельзя сохранить пустой список судей"); return; } try { const response = await fetch(`${window.location.pathname}/referees/save`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ referees: payload }) }); const data = await response.json().catch(() => ({})); if (!response.ok || !data.success) { alert(data.error || `Ошибка сохранения судей (HTTP ${response.status})`); return; } window.MATCH_DATA.referees = payload; refereeEditorState.items = [...payload]; filteredRefereePool = []; selectedRefereeForAdd = null; renderRefereesEditor(); alert("Судьи сохранены"); window.location.reload(); } catch (e) { console.error("saveReferees failed", e); alert("Ошибка сети при сохранении судей"); } } function getTourChannelLogoPath(matchId) { const checked = Array.from( document.querySelectorAll(`.tour-channel-checkbox[data-match-id="${matchId}"]:checked`) ).map(el => el.dataset.channel); const hasMatch = checked.includes("match"); const hasPremier = checked.includes("match_premier"); if (hasMatch && hasPremier) { return "D:\\Графика\\ФУТБОЛ\\Женская Суперлига 2026\\Лого каналов\\матч-матч_премьер.png"; } if (hasPremier) { return "D:\\Графика\\ФУТБОЛ\\Женская Суперлига 2026\\Лого каналов\\матч_премьер.png"; } if (hasMatch) { return "D:\\Графика\\ФУТБОЛ\\Женская Суперлига 2026\\Photo\\EMPTY.png"; } return ""; } async function saveTourChannel(matchExternalId) { const channel = buildChannelValue(matchExternalId); const res = await fetch(`/admin/session/${MATCH_DATA.sessionToken}/schedule/channel`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ match_external_id: String(matchExternalId), channel }) }); if (!res.ok) { alert("Не удалось сохранить канал"); } } function buildChannelValue(matchId) { const checked = Array.from( document.querySelectorAll(`.tour-channel-checkbox[data-match-id="${matchId}"]:checked`) ).map(el => el.dataset.channel); const hasMatch = checked.includes("match"); const hasPremier = checked.includes("match_premier"); if (hasMatch && hasPremier) return "match_and_premier"; if (hasPremier) return "match_premier"; if (hasMatch) return "match"; return ""; } function openClearMatchModal() { const modal = document.getElementById("clearMatchModal"); if (modal) modal.classList.add("show"); } function closeClearMatchModal() { const modal = document.getElementById("clearMatchModal"); if (modal) modal.classList.remove("show"); } function confirmClearMatch() { closeClearMatchModal(); clearMatchEvents(); }