Files
WFL/static/script.js
2026-05-15 13:10:41 +03:00

3639 lines
115 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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 };
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);
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-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) => `
<button
type="button"
class="goal-editor-player-btn"
data-player-id="${escapeHtml(String(p.player_id ?? ""))}"
data-player-name="${escapeHtml(p.player_name)}"
onclick="selectGoalScorer(this.dataset.playerId, this.dataset.playerName)"
>
<span class="goal-editor-player-number">${escapeHtml(p.player_number || "—")}</span>
<span class="goal-editor-player-name">${escapeHtml(p.player_name)}</span>
</button>
`).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: 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 = '<div class="empty-box">Событий пока нет.</div>';
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 !== ""
? `<button type="button" class="event-delete-btn" title="Удалить событие"
onclick="event.stopPropagation(); deleteEvent('${String(event.id)}')">×</button>`
: "";
const canEditScorer = event.id !== null &&
event.id !== undefined &&
event.id !== "" &&
["goal", "penalty", "own_goal"].includes(event.type);
const editHtml = canEditScorer
? `<button type="button" class="event-edit-btn" title="Изменить автора гола"
onclick="event.stopPropagation(); openGoalEditor('${String(event.id)}')">✏️</button>`
: "";
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 `
<div class="event-row event-row-center">
<div class="event-side"></div>
<div class="event-time event-time-center-card">
<div class="event-card center${clickableClass}" ${clickAttr}>
<div class="event-card-actions">${deleteHtml}</div>
<div class="event-title">${escapeHtml(compactTitle)}</div>
</div>
</div>
<div class="event-side"></div>
</div>
`;
}
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 = `
<div class="event-card ${event.side}${clickableClass}" ${clickAttr}>
<div class="event-card-actions">${actionsHtml}</div>
<div class="event-badge">${escapeHtml(event.icon)}</div>
<div class="event-title">${escapeHtml(compactTitle)}</div>
</div>
`;
return `
<div class="event-row">
<div class="event-side">${event.side === "home" ? cardHtml : ""}</div>
<div class="event-time event-time-editable">
${
event.id !== null && event.id !== undefined && event.id !== ""
? `<button
type="button"
class="event-time-edit-btn"
title="Изменить время события"
onclick="event.stopPropagation(); editEventTime('${String(event.id)}')"
>✏️</button>`
: ""
}
<span class="event-time-label">
${escapeHtml(event.minute)}
</span>
</div>
<div class="event-side">${event.side === "away" ? cardHtml : ""}</div>
</div>
`;
}).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,
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
? `<img src="${player.photo}" alt="${player.player_name || ""}" class="player-photo">`
: `<div class="player-silhouette"></div>`;
el.innerHTML = `
${visual}
<div class="player-label">
<span class="player-label-text">${buildPlayerLabel(player)}</span>
</div>
`;
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) ? '<span class="gk-badge">ВР</span>' : '';
const captainBadge = player.isCaptain ? '<span class="captain-badge">К</span>' : '';
const disciplineBadge = player.disciplineType === "yellow_red"
? '<span class="discipline-badge-yellow2">2ЖК</span>'
: (player.disciplineType === "red"
? '<span class="discipline-badge-red">КК</span>'
: '');
const firstNameHtml = player.firstName
? `<span class="first-name"> ${escapeHtml(player.firstName)}</span>`
: '';
const idJs = JSON.stringify(player.id || player.number || "");
const nameJs = JSON.stringify(displayName);
let actionsHtml = '';
if (role === "starting") {
actionsHtml = `
<div class="row-actions">
<button type="button" class="icon-btn goal" title="Гол"
onclick='addPlayerEvent("${side}", "goal", ${idJs}, ${nameJs})'>⚽</button>
<button type="button" class="icon-btn" title="Автогол"
onclick='addPlayerEvent("${side}", "own_goal", ${idJs}, ${nameJs})'>АГ</button>
<button type="button" class="icon-btn" title="Пенальти"
onclick='addPlayerEvent("${side}", "penalty", ${idJs}, ${nameJs})'>П</button>
<button type="button" class="icon-btn sub" title="Замена"
onclick='beginSubstitution("${side}", ${idJs}, ${nameJs}, this)'>⇄</button>
<button type="button" class="icon-btn yellow-card" title="Жёлтая карточка"
onclick='addPlayerEvent("${side}", "yellow", ${idJs}, ${nameJs})'></button>
<button type="button" class="icon-btn red-card" title="Красная карточка"
onclick='addPlayerEvent("${side}", "red", ${idJs}, ${nameJs})'></button>
</div>
`;
} else if (role === "bench") {
actionsHtml = `
<div class="row-actions">
<button type="button" class="icon-btn sub" title="Замена"
onclick='selectBenchPlayer("${side}", ${idJs}, ${nameJs}, this)'>⇄</button>
</div>
`;
} else {
actionsHtml = `<div class="row-actions"></div>`;
}
row.innerHTML = `
<td class="num-cell">${escapeHtml(player.number || "")}</td>
<td class="player-cell" title="Показать титр игрока">
<button
type="button"
class="player-link-btn vmix-person-trigger"
data-vmix-person-type="player"
data-side="${escapeHtml(side)}"
data-first-name="${escapeHtml(player.firstName || "")}"
data-last-name="${escapeHtml(player.lastName || "")}"
data-role="${escapeHtml(player.position || "")}"
>
<div class="player-inline">
<div class="player-inline-text">
<span class="last-name">${escapeHtml(player.lastName || player.name || "")}</span>${firstNameHtml}
</div>
<span class="badges">
${gkBadge}
${captainBadge}
${disciplineBadge}
</span>
</div>
</button>
</td>
<td class="actions-col">${actionsHtml}</td>
`;
}
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();
}
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 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 = sortEventsForStorage(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 = `
<table class="formation-input-table">
<thead>
<tr>
<th>Линия</th>
<th>Позиция</th>
<th>№ игрока</th>
<th>Доступные номера</th>
</tr>
</thead>
<tbody>
`;
layout.forEach((row, rowIndex) => {
row.forEach((pos, colIndex) => {
const existingNumber = getExistingAssignedNumber(side, rowIndex, colIndex);
html += `
<tr>
<td>${rowIndex + 1}</td>
<td>${pos}</td>
<td>
<input
type="number"
min="1"
class="formation-number-input"
data-row="${rowIndex}"
data-col="${colIndex}"
data-pos="${pos}"
value="${existingNumber}"
placeholder="номер"
>
</td>
<td class="formation-available-numbers">${squad.map(p => p.number).filter(Boolean).join(", ")}</td>
</tr>
`;
});
});
html += `</tbody></table>`;
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 = `
<div class="squad-editor-section">
<div class="squad-editor-section-title">Добавить игрока</div>
<div class="squad-editor-box">
<select class="squad-editor-select" id="addPlayerSelect-${side}">
<option value="">Выбери игрока из базы команды</option>
${availablePlayers.map(player => `
<option value="${escapeHtml(String(player.player_id))}">
${escapeHtml(buildPlayerOptionText(player))}
</option>
`).join("")}
</select>
<div class="squad-editor-actions">
<button type="button" class="squad-editor-btn"
onclick="addPlayerFromPool('${side}', 'starting')">
В старт
</button>
<button type="button" class="squad-editor-btn"
onclick="addPlayerFromPool('${side}', 'bench')">
В запас
</button>
</div>
<div class="squad-editor-hint">
В списке показываются только игроки, которых ещё нет в текущем составе.
</div>
</div>
</div>
<div class="squad-editor-section">
<div class="squad-editor-section-title">Основной состав</div>
<div class="squad-editor-box">
<div class="squad-editor-list">
${state.starting.length ? state.starting.map(player => renderSquadEditorPlayerRow(side, "starting", player)).join("") : `<div class="squad-editor-hint">Пока пусто.</div>`}
</div>
</div>
</div>
<div class="squad-editor-section">
<div class="squad-editor-section-title">Запасные</div>
<div class="squad-editor-box">
<div class="squad-editor-list">
${state.bench.length ? state.bench.map(player => renderSquadEditorPlayerRow(side, "bench", player)).join("") : `<div class="squad-editor-hint">Пока пусто.</div>`}
</div>
</div>
</div>
<div class="squad-editor-section">
<div class="squad-editor-section-title">Тренеры</div>
<div class="squad-editor-box">
${renderCoachEditorSection(side, coachOptions)}
</div>
</div>
`;
}
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 `
<div class="squad-editor-row">
<div class="squad-editor-player">
<div class="squad-editor-player-name">${escapeHtml(fullName)}</div>
<div class="squad-editor-player-meta">
#${escapeHtml(String(player.number || "—"))}
${player.position ? `${escapeHtml(player.position)}` : ""}
</div>
</div>
<div class="squad-editor-actions">
<button type="button" class="squad-editor-btn ${captainClass}"
onclick="setCaptainInEditor('${side}', '${String(player.player_id)}', '${role}')">
${captainText}
</button>
${role === "starting" ? `
<button type="button" class="squad-editor-btn"
onclick="movePlayerBetweenZones('${side}', '${String(player.player_id)}', 'starting', 'bench')">
В запас
</button>
` : `
<button type="button" class="squad-editor-btn"
onclick="movePlayerBetweenZones('${side}', '${String(player.player_id)}', 'bench', 'starting')">
В старт
</button>
`}
<button type="button" class="squad-editor-btn danger"
onclick="removePlayerFromEditor('${side}', '${String(player.player_id)}', '${role}')">
Удалить
</button>
</div>
</div>
`;
}
function renderCoachEditorSection(side, coachOptions) {
const state = getSideEditorState(side);
if (!state) return "";
const existingRoles = state.coaches.length ? state.coaches : [{ coach_id: "", coach_name: "", role: "Главный тренер" }];
return `
<div class="squad-editor-list">
${existingRoles.map((coach, index) => `
<div class="squad-editor-row">
<div class="squad-editor-player">
<div class="squad-editor-player-name">${escapeHtml(coach.role || "Тренер")}</div>
<div class="squad-editor-player-meta">${escapeHtml(coach.coach_name || "Не выбран")}</div>
</div>
<div style="min-width:260px;">
<select class="squad-editor-select" onchange="updateCoachInEditor('${side}', ${index}, this.value)">
<option value="">Выбери тренера</option>
${coachOptions.map(item => `
<option value="${escapeHtml(String(item.coach_id))}" ${String(item.coach_id) === String(coach.coach_id) ? "selected" : ""}>
${escapeHtml(item.coach_name)}${item.role ? `${escapeHtml(item.role)}` : ""}
</option>
`).join("")}
</select>
</div>
</div>
`).join("")}
</div>
`;
}
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 => `
<div class="coach-line">
<button
type="button"
class="coach-left vmix-person-trigger player-link-btn coach-link-btn"
data-vmix-person-type="coach"
data-side="${escapeHtml(side)}"
data-name="${escapeHtml(c.coach_name || "")}"
data-role="${escapeHtml(c.role || "")}"
title="Показать титр тренера"
>
<span class="coach-name">${escapeHtml(c.coach_name || "")}</span>
${c.role ? `<span class="coach-badge">${escapeHtml(c.role)}</span>` : ""}
</button>
<div class="coach-actions">
<button
type="button"
class="icon-btn yellow-card"
title="Жёлтая карточка"
data-coach-side="${side}"
data-coach-name="${escapeHtml(c.coach_name || "")}"
data-card-type="yellow"
onclick="addCoachEvent('${side}', 'yellow', this.dataset.coachName, this)"
></button>
<button
type="button"
class="icon-btn red-card"
title="Красная карточка"
data-coach-side="${side}"
data-coach-name="${escapeHtml(c.coach_name || "")}"
data-card-type="red"
onclick="addCoachEvent('${side}', 'red', this.dataset.coachName, this)"
></button>
</div>
</div>
`).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 : [];
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 ? `
<div class="referee-search">
<input type="text" id="refereeSearchInput"
placeholder="Поиск судьи..."
oninput="filterRefereeList(this.value)">
<div id="refereeSearchList" class="referee-search-list"></div>
<select id="newRefereeRole">
<option value="">Выбери роль</option>
${REFEREE_ROLE_OPTIONS.map(r => `<option value="${r}">${r}</option>`).join("")}
</select>
</div>
` : "";
wrap.innerHTML = `
<table>
<thead>
<tr>
<th>Роль</th>
<th>ФИО</th>
${editModeEnabled ? "<th></th>" : ""}
</tr>
</thead>
<tbody>
${items.map((r, i) => `
<tr>
<td>${r.role}</td>
<td>${r.referee_name}</td>
${editModeEnabled ? `<td><button
type="button"
class="referee-delete-btn"
onclick="removeRefereeRow(${i})"
title="Удалить судью"
>
</button></td>` : ""}
</tr>
`).join("")}
</tbody>
</table>
${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 =>
`<div onclick="selectRefereeFromSearch('${r.referee_id}')">${r.referee_name}</div>`
).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("Ошибка сети при сохранении судей");
}
}