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;
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 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 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);
}
}
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 };
// originalLineupState = state.originalLineupState || originalLineupState;
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();
}
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
);
} else if (currentPeriod === "2H" || currentPeriod === "FT") {
extraTimeMinutes.second = value;
updatePeriodBadge(`2-й тайм +${value}`);
addPeriodMarker(
"extra_time_second",
`Добавлено ${value} мин. ко 2-му тайму`,
matchClockSeconds
);
}
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);
// matchEvents = sortEventsForStorage(matchEvents);
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();
clearExtraTimeInput();
updatePeriodButtons();
return;
}
if (period === "HT") {
stopTimer();
const actualEndSeconds = matchClockSeconds;
updateTimerUI();
updatePeriodBadge("Перерыв");
addPeriodMarker("period_ht", "Конец 1-го тайма", actualEndSeconds);
triggerVmixTimerAction("halftime");
saveMatchState();
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();
updatePeriodButtons();
return;
}
if (period === "FT") {
stopTimer();
updatePeriodBadge("Матч завершён");
addPeriodMarker("period_ft", "Конец 2-го тайма", getLiveClockSeconds());
triggerVmixTimerAction("full_time");
saveMatchState();
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-only]").forEach((el) => {
// el.style.display = editModeEnabled ? "" : "none";
// });
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;
}
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();
clearEventsOnServer();
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 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 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 executeVmixCommands(commands.join("\n"));
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: event.player_name || "",
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: outRow?.dataset.playerName || "",
in: inRow?.dataset.playerName || ""
};
}
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 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: 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_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 : ""}
${escapeHtml(event.minute)}
${event.side === "away" ? cardHtml : ""}
`;
}).join("");
}
async function loadEventsFromServer() {
if (!SESSION_TOKEN) return;
try {
const persisted = getPersistedState();
const persistedEvents = Array.isArray(persisted?.matchEvents) ? persisted.matchEvents : [];
const res = await fetch(`/admin/session/${SESSION_TOKEN}/events`);
if (!res.ok) return;
const data = await res.json();
if (!Array.isArray(data)) return;
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 = sortEventsForStorage(matchEvents);
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();
} catch (e) {
console.warn("Не удалось загрузить события из БД", e);
}
}
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,
session_token: SESSION_TOKEN,
target_client_id: VMIX_TARGET_CLIENT_ID,
target_group: VMIX_TARGET_GROUP,
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
? `
`
: ``;
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",
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.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 = String(player.name || "").trim()
|| buildPersonDisplayName(player.firstName, player.lastName, "");
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);
let actionsHtml = '';
if (role === "starting") {
actionsHtml = `
`;
} else if (role === "bench") {
actionsHtml = `
`;
} else {
actionsHtml = ``;
}
row.innerHTML = `
${escapeHtml(player.number || "")} |
|
${actionsHtml} |
`;
}
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();
}
loadEventsFromServer();
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 inputName = getFormationVmixInputName(side);
const commands = [];
const playerPrefix = side === "home" ? "HOME" : "AWAY";
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[0], полевые players[1..10]
players.slice(1, 11).forEach((player, index) => {
const layer = index + 1;
const x = convertPitchXToPan(player.x).toFixed(3);
const y = convertPitchYToPan(player.y).toFixed(3);
commands.push(
vmixCmd(`Function=SetLayer${layer}PanX&Input=${encodeURIComponent(inputName)}&Value=${x}`)
);
commands.push(
vmixCmd(`Function=SetLayer${layer}PanY&Input=${encodeURIComponent(inputName)}&Value=${y}`)
);
});
commands.push(
vmixCmd(`Function=PreviewInput&Input=${encodeURIComponent(inputName)}`)
);
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 getDistributedXSlots(count) {
// if (count <= 0) return [];
// if (count === 1) return [50];
// if (count === 2) return [25, 75];
// if (count === 3) return [8, 50, 92];
// if (count === 4) return [0, 30, 70, 100];
// if (count === 5) return [0, 18, 50, 82, 100];
// if (count === 6) return [0, 16, 36, 64, 84, 100];
// return Array.from({ length: count }, (_, i) => (i / (count - 1)) * 100);
// }
// function getArcYOffsetSlots(count, strength = 10) {
// if (count < 4) {
// return Array(count).fill(0);
// }
// if (count === 5) {
// return [
// strength * 0.11, // левый фланг ниже
// -strength * 0.5, // левый полуфланг чуть выше базы
// -strength, // центр выше всех
// -strength * 0.5, // правый полуфланг чуть выше базы
// strength * 0.11 // правый фланг ниже
// ];
// }
// const center = (count - 1) / 2;
// return Array.from({ length: count }, (_, i) => {
// const dist = Math.abs(i - center);
// const maxDist = center || 1;
// const normalized = dist / maxDist;
// return -strength * Math.pow(1 - normalized, 1.5);
// });
// }
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 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 = `
`;
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
};
}
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,
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 || "",
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 : [];
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) => `
| ${r.role} |
${r.referee_name} |
${editModeEnabled ? ` | ` : ""}
`).join("")}
${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("Ошибка сети при сохранении судей");
}
}