3626 lines
115 KiB
JavaScript
3626 lines
115 KiB
JavaScript
const MATCH_DATA = window.MATCH_DATA || {};
|
||
console.log("SCRIPT VERSION NEW PLAYER LINKS");
|
||
const TEAM_NAMES = MATCH_DATA.teamNames || {
|
||
home: "",
|
||
away: ""
|
||
};
|
||
|
||
const goalEditorState = {
|
||
open: false,
|
||
eventId: null,
|
||
side: null,
|
||
type: null
|
||
};
|
||
|
||
const squadEditState = {
|
||
open: false,
|
||
side: "home",
|
||
starting: { home: [], away: [] },
|
||
bench: { home: [], away: [] },
|
||
coaches: { home: [], away: [] }
|
||
};
|
||
|
||
const refereeEditorState = {
|
||
items: Array.isArray(MATCH_DATA.referees) ? [...MATCH_DATA.referees] : [],
|
||
pool: Array.isArray(MATCH_DATA.refereePool) ? [...MATCH_DATA.refereePool] : []
|
||
};
|
||
refereeEditorState.items = refereeEditorState.items.map(normalizeReferee);
|
||
refereeEditorState.pool = refereeEditorState.pool.map(normalizeReferee);
|
||
|
||
const REFEREE_ROLE_OPTIONS = [
|
||
"Главный судья",
|
||
"Ассистент 1",
|
||
"Ассистент 2",
|
||
"Резервный судья",
|
||
"VAR",
|
||
"AVAR",
|
||
"Инспектор"
|
||
];
|
||
|
||
const VMIX_TARGET_CLIENT_ID = MATCH_DATA.vmixTargetClientId || null;
|
||
const VMIX_TARGET_GROUP = MATCH_DATA.vmixTargetGroup || null;
|
||
|
||
function vmixCmd(path) {
|
||
const value = String(path || "").trim();
|
||
if (!value) return "";
|
||
if (value.startsWith("/api/")) return value;
|
||
return `/api/?${value.replace(/^\?/, "")}`;
|
||
}
|
||
|
||
|
||
let currentPeriod = null;
|
||
let matchClockSeconds = 0;
|
||
let timerId = null;
|
||
let periodStartedAt = null;
|
||
let pausedAccumulatedSeconds = 0;
|
||
let eventSeq = 1;
|
||
let matchEvents = [];
|
||
let scoreState = { home: 0, away: 0 };
|
||
let activeSubstitution = null;
|
||
let extraTimeMinutes = { first: 0, second: 0 };
|
||
let originalLineupState = null;
|
||
|
||
const SESSION_TOKEN = MATCH_DATA.sessionToken ?? null;
|
||
const MATCH_ID = MATCH_DATA.matchId ?? null;
|
||
const STORAGE_KEY = `match_admin_state_match_${MATCH_ID}`;
|
||
|
||
const formationState = {
|
||
home: Array.isArray(MATCH_DATA.homeFormations) ? MATCH_DATA.homeFormations : [],
|
||
away: Array.isArray(MATCH_DATA.awayFormations) ? MATCH_DATA.awayFormations : [],
|
||
};
|
||
|
||
const fallbackLineups = {
|
||
home: Array.isArray(MATCH_DATA.homeStarting) ? MATCH_DATA.homeStarting : [],
|
||
away: Array.isArray(MATCH_DATA.awayStarting) ? MATCH_DATA.awayStarting : [],
|
||
};
|
||
|
||
function getLiveClockSeconds() {
|
||
if (!periodStartedAt) {
|
||
return pausedAccumulatedSeconds;
|
||
}
|
||
|
||
return pausedAccumulatedSeconds + Math.max(0, Math.floor((Date.now() - periodStartedAt) / 1000));
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function formatClock(totalSeconds) {
|
||
const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0");
|
||
const seconds = String(totalSeconds % 60).padStart(2, "0");
|
||
return `${minutes}:${seconds}`;
|
||
}
|
||
|
||
function getFootballMinute(totalSeconds) {
|
||
if (currentPeriod === "2H" || currentPeriod === "FT") {
|
||
const secondHalfSeconds = Math.max(0, totalSeconds - 45 * 60);
|
||
return 46 + Math.floor(secondHalfSeconds / 60);
|
||
}
|
||
|
||
return Math.floor(totalSeconds / 60) + 1;
|
||
}
|
||
|
||
|
||
function formatMinuteLabel(totalSeconds) {
|
||
return getEventDisplayMinuteBySeconds(totalSeconds);
|
||
}
|
||
|
||
|
||
|
||
function getEventDisplayMinuteBySeconds(totalSeconds, periodType = null) {
|
||
const safeSeconds = Math.max(0, Number(totalSeconds) || 0);
|
||
|
||
if (periodType === "period_start_1h") {
|
||
return "1'";
|
||
}
|
||
|
||
if (periodType === "period_start_2h") {
|
||
return "46'";
|
||
}
|
||
|
||
const minute = Math.floor(safeSeconds / 60) + 1;
|
||
|
||
// Добавленное время 1-го тайма — только если ещё не начался 2-й тайм
|
||
if (
|
||
safeSeconds > 45 * 60 &&
|
||
safeSeconds < 90 * 60 &&
|
||
(currentPeriod === "1H" || currentPeriod === "HT")
|
||
) {
|
||
const extra = Math.floor((safeSeconds - 45 * 60) / 60);
|
||
return `45'+${Math.max(1, extra)}`;
|
||
}
|
||
|
||
// Добавленное время 2-го тайма — только после 90 минут
|
||
if (
|
||
safeSeconds > 90 * 60 &&
|
||
(currentPeriod === "2H" || currentPeriod === "FT")
|
||
) {
|
||
const extra = Math.floor((safeSeconds - 90 * 60) / 60);
|
||
return `90'+${Math.max(1, extra)}`;
|
||
}
|
||
|
||
return `${minute}'`;
|
||
}
|
||
|
||
|
||
function getEventSortPriority(event) {
|
||
const type = String(event?.type || "");
|
||
|
||
if (type === "period_ht" || type === "period_ft") {
|
||
return 3;
|
||
}
|
||
|
||
if (type === "period_start_1h" || type === "period_start_2h") {
|
||
return 1;
|
||
}
|
||
|
||
return 2;
|
||
}
|
||
|
||
|
||
|
||
|
||
function sortEventsForStorage(events) {
|
||
return [...events].sort((a, b) => {
|
||
const secondsDiff = (a.seconds || 0) - (b.seconds || 0);
|
||
if (secondsDiff !== 0) return secondsDiff;
|
||
|
||
const priorityDiff = getEventSortPriority(a) - getEventSortPriority(b);
|
||
if (priorityDiff !== 0) return priorityDiff;
|
||
|
||
return (a.id || 0) - (b.id || 0);
|
||
});
|
||
}
|
||
|
||
function sortEventsForDisplay(events) {
|
||
return [...events].sort((a, b) => {
|
||
const secondsDiff = (b.seconds || 0) - (a.seconds || 0);
|
||
if (secondsDiff !== 0) return secondsDiff;
|
||
|
||
const priorityDiff = getEventSortPriority(b) - getEventSortPriority(a);
|
||
if (priorityDiff !== 0) return priorityDiff;
|
||
|
||
return (b.id || 0) - (a.id || 0);
|
||
});
|
||
}
|
||
|
||
|
||
|
||
function addCoachEvent(side, cardType, coachName, btn = null) {
|
||
console.log("addCoachEvent called", { side, cardType, coachName, btn });
|
||
|
||
const counts = getCoachCardCounts(side, coachName);
|
||
|
||
const sentOffByTwoYellows = counts.yellow >= 2;
|
||
const sentOffByRed = counts.red >= 1;
|
||
const sentOff = sentOffByTwoYellows || sentOffByRed;
|
||
|
||
if (sentOff) {
|
||
console.log("blocked: sent off");
|
||
return;
|
||
}
|
||
|
||
if (cardType === "yellow" && counts.yellow >= 2) {
|
||
console.log("blocked: yellow limit");
|
||
return;
|
||
}
|
||
|
||
if (cardType === "red" && counts.red >= 1) {
|
||
console.log("blocked: red limit");
|
||
return;
|
||
}
|
||
|
||
const type = cardType === "yellow" ? "coach_yellow" : "coach_red";
|
||
|
||
console.log("pushEvent", { side, type, coachName });
|
||
|
||
pushEvent(side, type, coachName, "Тренер", null).then(() => {
|
||
console.log("pushEvent done");
|
||
updateCoachCardButtons();
|
||
});
|
||
|
||
if (btn) {
|
||
setTimeout(updateCoachCardButtons, 0);
|
||
}
|
||
}
|
||
|
||
|
||
function updateTimerUI() {
|
||
matchClockSeconds = getLiveClockSeconds();
|
||
const timer = document.getElementById("matchTimer");
|
||
if (timer) timer.textContent = formatClock(matchClockSeconds);
|
||
}
|
||
|
||
function updateScoreUI() {
|
||
const score = document.getElementById("matchScore");
|
||
if (score) score.textContent = `${scoreState.home} : ${scoreState.away}`;
|
||
}
|
||
|
||
function updatePeriodBadge(text) {
|
||
const badge = document.getElementById("periodBadge");
|
||
if (badge) badge.textContent = text;
|
||
}
|
||
|
||
function getPersistedState() {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_KEY);
|
||
return raw ? JSON.parse(raw) : null;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function saveMatchState() {
|
||
try {
|
||
matchClockSeconds = getLiveClockSeconds();
|
||
|
||
const payload = {
|
||
currentPeriod,
|
||
matchClockSeconds,
|
||
timerRunning: !!timerId,
|
||
savedAt: Date.now(),
|
||
periodStartedAt,
|
||
pausedAccumulatedSeconds,
|
||
eventSeq,
|
||
matchEvents,
|
||
scoreState,
|
||
extraTimeMinutes,
|
||
originalLineupState
|
||
};
|
||
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
||
} catch (e) {
|
||
console.warn("Не удалось сохранить состояние матча", e);
|
||
}
|
||
}
|
||
|
||
function restoreMatchState() {
|
||
const state = getPersistedState();
|
||
if (!state) return false;
|
||
|
||
currentPeriod = state.currentPeriod ?? null;
|
||
eventSeq = Number(state.eventSeq || 1);
|
||
matchEvents = Array.isArray(state.matchEvents) ? state.matchEvents : [];
|
||
scoreState = state.scoreState || { home: 0, away: 0 };
|
||
extraTimeMinutes = state.extraTimeMinutes || { first: 0, second: 0 };
|
||
// originalLineupState = state.originalLineupState || originalLineupState;
|
||
|
||
pausedAccumulatedSeconds = Number(state.pausedAccumulatedSeconds ?? 0);
|
||
periodStartedAt = state.periodStartedAt ?? null;
|
||
|
||
if (!state.timerRunning) {
|
||
periodStartedAt = null;
|
||
}
|
||
|
||
updatePeriodBadge(
|
||
currentPeriod === "1H" ? (extraTimeMinutes.first ? `1-й тайм +${extraTimeMinutes.first}` : "1-й тайм") :
|
||
currentPeriod === "HT" ? "Перерыв" :
|
||
currentPeriod === "2H" ? (extraTimeMinutes.second ? `2-й тайм +${extraTimeMinutes.second}` : "2-й тайм") :
|
||
currentPeriod === "FT" ? "Матч завершён" :
|
||
"Матч не начат"
|
||
);
|
||
|
||
updateTimerUI();
|
||
updateScoreUI();
|
||
renderEvents();
|
||
|
||
if (typeof rebuildLineupsFromEvents === "function") {
|
||
rebuildLineupsFromEvents();
|
||
}
|
||
|
||
if (state.timerRunning && (currentPeriod === "1H" || currentPeriod === "2H")) {
|
||
if (timerId) clearInterval(timerId);
|
||
timerId = setInterval(() => {
|
||
updateTimerUI();
|
||
saveMatchState();
|
||
}, 1000);
|
||
}
|
||
updateCoachCardButtons();
|
||
updatePeriodButtons();
|
||
return true;
|
||
}
|
||
|
||
function stopTimer() {
|
||
if (timerId) {
|
||
clearInterval(timerId);
|
||
timerId = null;
|
||
}
|
||
|
||
pausedAccumulatedSeconds = getLiveClockSeconds();
|
||
periodStartedAt = null;
|
||
matchClockSeconds = pausedAccumulatedSeconds;
|
||
}
|
||
|
||
function startTimer() {
|
||
if (timerId) {
|
||
clearInterval(timerId);
|
||
timerId = null;
|
||
}
|
||
|
||
periodStartedAt = Date.now();
|
||
timerId = setInterval(() => {
|
||
updateTimerUI();
|
||
saveMatchState();
|
||
}, 1000);
|
||
|
||
updateTimerUI();
|
||
saveMatchState();
|
||
}
|
||
|
||
function applyExtraTime() {
|
||
const input = document.getElementById("extraTimeMinutes");
|
||
const value = Math.max(0, Number(input ? input.value : 0));
|
||
|
||
if (!value) {
|
||
saveMatchState();
|
||
return;
|
||
}
|
||
|
||
if (currentPeriod === "1H" || currentPeriod === "HT") {
|
||
extraTimeMinutes.first = value;
|
||
updatePeriodBadge(`1-й тайм +${value}`);
|
||
|
||
addPeriodMarker(
|
||
"extra_time_first",
|
||
`Добавлено ${value} мин. к 1-му тайму`,
|
||
matchClockSeconds
|
||
);
|
||
} else if (currentPeriod === "2H" || currentPeriod === "FT") {
|
||
extraTimeMinutes.second = value;
|
||
updatePeriodBadge(`2-й тайм +${value}`);
|
||
|
||
addPeriodMarker(
|
||
"extra_time_second",
|
||
`Добавлено ${value} мин. ко 2-му тайму`,
|
||
matchClockSeconds
|
||
);
|
||
}
|
||
|
||
saveMatchState();
|
||
}
|
||
|
||
async function saveEventToServer(eventData) {
|
||
if (!SESSION_TOKEN) return null;
|
||
try {
|
||
const res = await fetch(`/admin/session/${SESSION_TOKEN}/event`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(eventData)
|
||
});
|
||
if (!res.ok) return null;
|
||
return await res.json();
|
||
} catch (e) {
|
||
console.warn("Не удалось сохранить событие в БД", e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function deleteEvent(eventId) {
|
||
if (eventId === null || eventId === undefined || eventId === "") return;
|
||
|
||
try {
|
||
const res = await fetch(`/admin/session/${SESSION_TOKEN}/event/${eventId}`, {
|
||
method: "DELETE"
|
||
});
|
||
if (!res.ok) {
|
||
console.warn("Удаление события не удалось", res.status);
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
console.warn("Не удалось удалить событие", e);
|
||
return;
|
||
}
|
||
|
||
matchEvents = matchEvents.filter((event) => String(event.id) !== String(eventId));
|
||
|
||
scoreState = { home: 0, away: 0 };
|
||
matchEvents.forEach((event) => {
|
||
if (["goal", "penalty", "own_goal"].includes(event.type)) {
|
||
scoreState[event.side] = (scoreState[event.side] || 0) + 1;
|
||
}
|
||
});
|
||
|
||
updateScoreUI();
|
||
renderEvents();
|
||
rebuildLineupsFromEvents();
|
||
saveMatchState();
|
||
updateCoachCardButtons();
|
||
updatePeriodButtons();
|
||
}
|
||
|
||
async function clearEventsOnServer() {
|
||
if (!SESSION_TOKEN) return;
|
||
try {
|
||
await fetch(`/admin/session/${SESSION_TOKEN}/events`, { method: "DELETE" });
|
||
} catch (e) {
|
||
console.warn("Не удалось очистить события в БД", e);
|
||
}
|
||
}
|
||
|
||
function getOppositeSide(side) {
|
||
return side === "home" ? "away" : "home";
|
||
}
|
||
|
||
function getEventIcon(type) {
|
||
const map = {
|
||
goal: "⚽",
|
||
own_goal: "АГ",
|
||
penalty: "П",
|
||
sub: "⇄",
|
||
yellow: "🟨",
|
||
red: "🟥",
|
||
coach_yellow: "🟨",
|
||
coach_red: "🟥",
|
||
period_start_1h: "",
|
||
period_ht: "",
|
||
period_start_2h: "",
|
||
period_ft: "",
|
||
extra_time_first: "",
|
||
extra_time_second: ""
|
||
};
|
||
return map[type] || "•";
|
||
}
|
||
|
||
function getEventTitle(type, name) {
|
||
const map = {
|
||
goal: "Гол",
|
||
own_goal: "Автогол",
|
||
penalty: "Пенальти",
|
||
sub: "Замена",
|
||
yellow: "Жёлтая карточка",
|
||
red: "Красная карточка",
|
||
coach_yellow: "Жёлтая карточка тренеру",
|
||
coach_red: "Красная карточка тренеру",
|
||
period_start_1h: "Начало 1-го тайма",
|
||
period_ht: "Конец 1-го тайма",
|
||
period_start_2h: "Начало 2-го тайма",
|
||
period_ft: "Конец 2-го тайма",
|
||
extra_time_first: "Добавлено время к 1-му тайму",
|
||
extra_time_second: "Добавлено время ко 2-му тайму"
|
||
};
|
||
return `${map[type] || "Событие"}${name ? " — " + name : ""}`;
|
||
}
|
||
|
||
|
||
function addPeriodMarker(type, title, forcedSeconds = null) {
|
||
const markerSeconds = forcedSeconds === null ? matchClockSeconds : forcedSeconds;
|
||
|
||
const markerMinute = getEventDisplayMinuteBySeconds(markerSeconds, type);
|
||
|
||
const item = {
|
||
id: null,
|
||
side: "center",
|
||
type,
|
||
icon: "",
|
||
title,
|
||
minute: markerMinute,
|
||
seconds: markerSeconds,
|
||
meta: null,
|
||
player_name: title,
|
||
player_id: null,
|
||
player_out_id: null,
|
||
player_in_id: null
|
||
};
|
||
|
||
matchEvents.push(item);
|
||
// matchEvents = sortEventsForStorage(matchEvents);
|
||
|
||
renderEvents();
|
||
saveMatchState();
|
||
|
||
const numericMinute =
|
||
type === "period_start_1h" ? 1 :
|
||
type === "period_start_2h" ? 46 :
|
||
Math.floor(markerSeconds / 60) + 1;
|
||
|
||
saveEventToServer({
|
||
side: "center",
|
||
type,
|
||
player_name: title,
|
||
minute: numericMinute,
|
||
seconds: markerSeconds,
|
||
meta: null,
|
||
player_id: null,
|
||
player_out_id: null,
|
||
player_in_id: null
|
||
}).then((saved) => {
|
||
if (saved && saved.id) {
|
||
item.id = saved.id;
|
||
renderEvents();
|
||
saveMatchState();
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
function setMatchPeriod(period) {
|
||
const markerMap = {
|
||
"1H": "period_start_1h",
|
||
"HT": "period_ht",
|
||
"2H": "period_start_2h",
|
||
"FT": "period_ft"
|
||
};
|
||
|
||
const markerType = markerMap[period];
|
||
const alreadyUsed = matchEvents.some((event) => event.type === markerType);
|
||
if (alreadyUsed) {
|
||
return;
|
||
}
|
||
|
||
currentPeriod = period;
|
||
|
||
if (period === "1H") {
|
||
if (timerId) {
|
||
clearInterval(timerId);
|
||
timerId = null;
|
||
}
|
||
|
||
pausedAccumulatedSeconds = 0;
|
||
periodStartedAt = null;
|
||
matchClockSeconds = 0;
|
||
|
||
updateTimerUI();
|
||
updatePeriodBadge(extraTimeMinutes.first ? `1-й тайм +${extraTimeMinutes.first}` : "1-й тайм");
|
||
startTimer();
|
||
addPeriodMarker("period_start_1h", "Начало 1-го тайма", 0);
|
||
triggerVmixTimerAction("first_half_start");
|
||
saveMatchState();
|
||
clearExtraTimeInput();
|
||
updatePeriodButtons();
|
||
return;
|
||
}
|
||
|
||
if (period === "HT") {
|
||
stopTimer();
|
||
|
||
const actualEndSeconds = matchClockSeconds;
|
||
|
||
updateTimerUI();
|
||
updatePeriodBadge("Перерыв");
|
||
addPeriodMarker("period_ht", "Конец 1-го тайма", actualEndSeconds);
|
||
triggerVmixTimerAction("halftime");
|
||
saveMatchState();
|
||
clearExtraTimeInput();
|
||
updatePeriodButtons();
|
||
return;
|
||
}
|
||
|
||
|
||
if (period === "2H") {
|
||
if (timerId) {
|
||
clearInterval(timerId);
|
||
timerId = null;
|
||
}
|
||
|
||
pausedAccumulatedSeconds = 45 * 60;
|
||
periodStartedAt = null;
|
||
matchClockSeconds = 45 * 60;
|
||
|
||
updateTimerUI();
|
||
updatePeriodBadge(extraTimeMinutes.second ? `2-й тайм +${extraTimeMinutes.second}` : "2-й тайм");
|
||
addPeriodMarker("period_start_2h", "Начало 2-го тайма", 45 * 60);
|
||
triggerVmixTimerAction("second_half_start");
|
||
startTimer();
|
||
saveMatchState();
|
||
updatePeriodButtons();
|
||
return;
|
||
}
|
||
|
||
if (period === "FT") {
|
||
stopTimer();
|
||
updatePeriodBadge("Матч завершён");
|
||
addPeriodMarker("period_ft", "Конец 2-го тайма", getLiveClockSeconds());
|
||
triggerVmixTimerAction("full_time");
|
||
saveMatchState();
|
||
clearExtraTimeInput();
|
||
updatePeriodButtons();
|
||
}
|
||
}
|
||
|
||
function setBenchHint(side, visible) {
|
||
const hint = document.getElementById(side === "home" ? "homeBenchHint" : "awayBenchHint");
|
||
if (hint) hint.style.display = visible ? "block" : "none";
|
||
}
|
||
|
||
function clearBenchSelection() {
|
||
document.querySelectorAll(".bench-pick-active").forEach(el => el.classList.remove("bench-pick-active"));
|
||
setBenchHint("home", false);
|
||
setBenchHint("away", false);
|
||
}
|
||
|
||
function beginSubstitution(side, playerId, playerName, btn) {
|
||
const row = btn ? btn.closest("tr.lineup-row") : null;
|
||
activeSubstitution = {
|
||
side,
|
||
outPlayerId: playerId,
|
||
outPlayerName: playerName,
|
||
outRow: row
|
||
};
|
||
clearBenchSelection();
|
||
setBenchHint(side, true);
|
||
}
|
||
|
||
async function pushEvent(side, type, personName, meta = "", playerId = null) {
|
||
let eventSide = side;
|
||
|
||
if (type === "own_goal") {
|
||
eventSide = getOppositeSide(side);
|
||
}
|
||
|
||
if (type === "goal" || type === "penalty" || type === "own_goal") {
|
||
scoreState[eventSide] += 1;
|
||
updateScoreUI();
|
||
}
|
||
|
||
const minuteLabel = formatMinuteLabel(matchClockSeconds);
|
||
const numericMinute =
|
||
parseInt(String(minuteLabel).split("+").pop().replace("'", ""), 10) ||
|
||
getFootballMinute(matchClockSeconds);
|
||
|
||
const item = {
|
||
id: null,
|
||
side: eventSide,
|
||
type,
|
||
icon: getEventIcon(type),
|
||
title: getEventTitle(type, personName),
|
||
teamName: TEAM_NAMES[eventSide] || "Команда",
|
||
minute: minuteLabel,
|
||
seconds: matchClockSeconds,
|
||
meta,
|
||
player_name: personName,
|
||
player_id: playerId,
|
||
player_out_id: null,
|
||
player_in_id: null
|
||
};
|
||
|
||
matchEvents.push(item);
|
||
|
||
renderEvents();
|
||
rebuildLineupsFromEvents();
|
||
saveMatchState();
|
||
|
||
const saved = await saveEventToServer({
|
||
side: eventSide,
|
||
type,
|
||
player_name: personName,
|
||
minute: numericMinute,
|
||
seconds: matchClockSeconds,
|
||
meta,
|
||
player_id: playerId,
|
||
player_out_id: null,
|
||
player_in_id: null
|
||
});
|
||
|
||
if (saved && saved.id) {
|
||
item.id = saved.id;
|
||
renderEvents();
|
||
rebuildLineupsFromEvents();
|
||
saveMatchState();
|
||
}
|
||
}
|
||
|
||
function getCoachCardCounts(side, coachName) {
|
||
const safeName = String(coachName || "").trim();
|
||
|
||
let yellow = 0;
|
||
let red = 0;
|
||
|
||
matchEvents.forEach((event) => {
|
||
if (event.side !== side) return;
|
||
if (String(event.player_name || "").trim() !== safeName) return;
|
||
|
||
if (event.type === "coach_yellow") yellow += 1;
|
||
if (event.type === "coach_red") red += 1;
|
||
});
|
||
|
||
return { yellow, red };
|
||
}
|
||
|
||
function updateCoachCardButtons() {
|
||
document.querySelectorAll("[data-card-type][data-coach-side][data-coach-name]").forEach((btn) => {
|
||
const side = btn.dataset.coachSide;
|
||
const coachName = btn.dataset.coachName;
|
||
const cardType = btn.dataset.cardType;
|
||
|
||
const counts = getCoachCardCounts(side, coachName);
|
||
|
||
const sentOffByTwoYellows = counts.yellow >= 2;
|
||
const sentOffByRed = counts.red >= 1;
|
||
const sentOff = sentOffByTwoYellows || sentOffByRed;
|
||
|
||
let disabled = false;
|
||
|
||
if (sentOff) {
|
||
disabled = true;
|
||
} else if (cardType === "yellow") {
|
||
disabled = counts.yellow >= 2;
|
||
} else if (cardType === "red") {
|
||
disabled = counts.red >= 1;
|
||
}
|
||
|
||
btn.disabled = disabled;
|
||
btn.style.opacity = disabled ? "0.45" : "";
|
||
btn.style.cursor = disabled ? "not-allowed" : "";
|
||
});
|
||
}
|
||
|
||
|
||
function updatePeriodButtons() {
|
||
const usedPeriods = new Set(
|
||
matchEvents
|
||
.filter((event) =>
|
||
["period_start_1h", "period_ht", "period_start_2h", "period_ft"].includes(event.type)
|
||
)
|
||
.map((event) => event.type)
|
||
);
|
||
|
||
const map = {
|
||
"1H": "period_start_1h",
|
||
"HT": "period_ht",
|
||
"2H": "period_start_2h",
|
||
"FT": "period_ft"
|
||
};
|
||
|
||
document.querySelectorAll("[data-period-btn]").forEach((btn) => {
|
||
const period = btn.dataset.periodBtn;
|
||
const eventType = map[period];
|
||
const disabled = usedPeriods.has(eventType);
|
||
|
||
btn.disabled = disabled;
|
||
btn.style.opacity = disabled ? "0.45" : "";
|
||
btn.style.cursor = disabled ? "not-allowed" : "";
|
||
});
|
||
}
|
||
|
||
function addPlayerEvent(side, type, playerId, playerName) {
|
||
pushEvent(side, type, playerName, `Игрок #${playerId ?? ""}`, playerId);
|
||
}
|
||
|
||
|
||
let editModeEnabled = false;
|
||
|
||
function isGameTabActive() {
|
||
const activeTab = document.querySelector(".tab-link.active");
|
||
return activeTab && activeTab.textContent.trim() === "Игра";
|
||
}
|
||
|
||
function setEditMode(enabled) {
|
||
editModeEnabled = !!enabled;
|
||
|
||
document.body.classList.toggle("edit-mode-on", editModeEnabled);
|
||
document.querySelectorAll("[data-edit-lock-wrap]").forEach((el) => {
|
||
el.style.display = editModeEnabled ? "flex" : "none";
|
||
});
|
||
|
||
const btn = document.getElementById("editModeToggle");
|
||
if (btn) {
|
||
btn.title = editModeEnabled
|
||
? "Режим редактирования: ВКЛ"
|
||
: "Режим редактирования: ВЫКЛ";
|
||
|
||
btn.classList.toggle("active", editModeEnabled);
|
||
}
|
||
|
||
document.querySelectorAll("[data-edit-lock]").forEach((el) => {
|
||
if ("disabled" in el) {
|
||
el.disabled = !editModeEnabled;
|
||
}
|
||
});
|
||
renderRefereesEditor();
|
||
|
||
if (editModeEnabled && isGameTabActive()) {
|
||
openSquadEditor();
|
||
} else {
|
||
closeSquadEditor();
|
||
}
|
||
}
|
||
|
||
function toggleEditMode() {
|
||
setEditMode(!editModeEnabled);
|
||
}
|
||
|
||
|
||
function updateEditModeUI() {
|
||
const btn = document.getElementById("editModeToggle");
|
||
if (btn) {
|
||
btn.innerHTML = "✏️";
|
||
btn.title = editModeEnabled
|
||
? "Режим редактирования: ВКЛ"
|
||
: "Режим редактирования: ВЫКЛ";
|
||
|
||
btn.classList.toggle("active", editModeEnabled);
|
||
}
|
||
|
||
// document.querySelectorAll("[data-edit-only]").forEach((el) => {
|
||
// el.style.display = editModeEnabled ? "" : "none";
|
||
// });
|
||
|
||
document.querySelectorAll("[data-edit-lock]").forEach((el) => {
|
||
if ("disabled" in el) {
|
||
el.disabled = !editModeEnabled;
|
||
}
|
||
});
|
||
}
|
||
|
||
function stepExtraTime(step) {
|
||
const input = document.getElementById("extraTimeMinutes");
|
||
if (!input) return;
|
||
|
||
let value = Number(input.value) || 0;
|
||
value = Math.max(0, value + step);
|
||
|
||
input.value = value;
|
||
}
|
||
|
||
function clearMatchEvents() {
|
||
if (timerId) {
|
||
clearInterval(timerId);
|
||
timerId = null;
|
||
}
|
||
|
||
periodStartedAt = null;
|
||
pausedAccumulatedSeconds = 0;
|
||
|
||
matchEvents = [];
|
||
scoreState = { home: 0, away: 0 };
|
||
eventSeq = 1;
|
||
activeSubstitution = null;
|
||
currentPeriod = null;
|
||
matchClockSeconds = 0;
|
||
extraTimeMinutes = { first: 0, second: 0 };
|
||
|
||
clearBenchSelection();
|
||
restoreOriginalLineupRows();
|
||
|
||
const extraInput = document.getElementById("extraTimeMinutes");
|
||
if (extraInput) extraInput.value = "";
|
||
|
||
updateTimerUI();
|
||
updatePeriodBadge("Матч не начат");
|
||
updateScoreUI();
|
||
renderEvents();
|
||
|
||
saveMatchState();
|
||
clearEventsOnServer();
|
||
updateCoachCardButtons();
|
||
updatePeriodButtons();
|
||
triggerVmixTimerAction("clear_match");
|
||
}
|
||
|
||
async function updateEventOnServer(eventId, eventData) {
|
||
if (!SESSION_TOKEN || !eventId) return false;
|
||
|
||
try {
|
||
const res = await fetch(`/admin/session/${SESSION_TOKEN}/event/${eventId}`, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(eventData)
|
||
});
|
||
|
||
return res.ok;
|
||
} catch (e) {
|
||
console.warn("Не удалось обновить событие в БД", e);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function getPlayersForSide(side) {
|
||
return Array.from(document.querySelectorAll(`tr.lineup-row[data-side="${side}"]`))
|
||
.filter((row) => row.dataset.temporary !== "1")
|
||
.map((row) => ({
|
||
player_id: row.dataset.playerId || "",
|
||
player_name: row.dataset.playerName || "",
|
||
player_number: row.dataset.playerNumber || ""
|
||
}))
|
||
.filter((p) => p.player_name.trim() !== "");
|
||
}
|
||
|
||
|
||
function openGoalEditor(eventId) {
|
||
const event = matchEvents.find((e) => String(e.id) === String(eventId));
|
||
if (!event) return;
|
||
if (!["goal", "penalty", "own_goal"].includes(event.type)) return;
|
||
|
||
const editorSide = event.type === "own_goal"
|
||
? getOppositeSide(event.side)
|
||
: event.side;
|
||
|
||
goalEditorState.open = true;
|
||
goalEditorState.eventId = event.id;
|
||
goalEditorState.side = editorSide;
|
||
goalEditorState.type = event.type;
|
||
|
||
const players = getPlayersForSide(editorSide);
|
||
const wrap = document.getElementById("goalEditorList");
|
||
const title = document.getElementById("goalEditorTitle");
|
||
|
||
if (title) {
|
||
title.textContent = `Изменить автора: ${event.player_name || "без игрока"}`;
|
||
}
|
||
|
||
if (wrap) {
|
||
wrap.innerHTML = players.map((p) => `
|
||
<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 getVmixTeamLogo(teamName) {
|
||
const basePath = "D:\\Графика\\ФУТБОЛ\\Женская Суперлига 2026\\Teams Logos";
|
||
let clean = String(teamName || "").replaceAll("«", "").replaceAll("»", "").trim();
|
||
|
||
if (clean === "Зенит" || clean === "Динамо") {
|
||
clean += "_Синий";
|
||
}
|
||
|
||
return `${basePath}\\${clean}.png`;
|
||
}
|
||
|
||
function getScoreSum() {
|
||
return Number(scoreState?.home || 0) + Number(scoreState?.away || 0);
|
||
}
|
||
|
||
function getSubOutName(payload) {
|
||
const raw = String(payload.player_name || "");
|
||
if (raw.includes("→")) return raw.split("→")[0].trim();
|
||
return String(payload.player_name_out || "").trim();
|
||
}
|
||
|
||
function getSubInName(payload) {
|
||
const raw = String(payload.player_name || "");
|
||
if (raw.includes("→")) return raw.split("→")[1].trim();
|
||
return String(payload.player_name_in || "").trim();
|
||
}
|
||
|
||
function buildVmixCommandsFromEvent(payload) {
|
||
const eventType = String(payload?.event_type || "");
|
||
const playerName = escapeVmixValue(payload?.player_name || "");
|
||
const teamName = escapeVmixValue(payload?.team_name || "");
|
||
const playerNumber = escapeVmixValue(payload?.player_number || "");
|
||
const playerOutNumber = escapeVmixValue(payload?.player_out_number || "");
|
||
const playerInNumber = escapeVmixValue(payload?.player_in_number || "");
|
||
|
||
const teamLogo = getVmixTeamLogo(teamName);
|
||
const scoreSum = getScoreSum();
|
||
|
||
if (eventType === "period_start_1h") {
|
||
return [
|
||
vmixCmd("Function=SetText&Input=НИЖНИЙ СЧЕТ ВО ВРЕМЯ ИГРЫ&SelectedName=СтатусМатча.Text&Value=1-й тайм"),
|
||
vmixCmd("Function=PreviewInput&Input=НИЖНИЙ СЧЕТ ВО ВРЕМЯ ИГРЫ")
|
||
];
|
||
}
|
||
|
||
if (eventType === "period_ht") {
|
||
return [
|
||
scoreSum === 0
|
||
? vmixCmd("Function=PreviewInput&Input=НИЖНИЙ СЧЕТ БЕЗ АВТОРОВ ГОЛОВ - ПЕРЕРЫВ")
|
||
: vmixCmd("Function=PreviewInput&Input=НИЖНИЙ СЧЕТ С АВТОРАМИ - ПЕРЕРЫВ")
|
||
];
|
||
}
|
||
|
||
if (eventType === "period_start_2h") {
|
||
return [
|
||
vmixCmd("Function=SetText&Input=НИЖНИЙ СЧЕТ ВО ВРЕМЯ ИГРЫ&SelectedName=СтатусМатча.Text&Value=2-й тайм"),
|
||
vmixCmd("Function=PreviewInput&Input=НИЖНИЙ СЧЕТ ВО ВРЕМЯ ИГРЫ")
|
||
];
|
||
}
|
||
|
||
if (eventType === "period_ft") {
|
||
return [
|
||
scoreSum === 0
|
||
? vmixCmd("Function=PreviewInput&Input=НИЖНИЙ СЧЕТ БЕЗ АВТОРОВ ГОЛОВ - МАТЧ ОКОНЧЕН")
|
||
: vmixCmd("Function=PreviewInput&Input=НИЖНИЙ СЧЕТ С АВТОРАМИ ГОЛОВ - МАТЧ ОКОНЧЕН")
|
||
];
|
||
}
|
||
|
||
if (eventType === "goal" || eventType === "penalty") {
|
||
return [
|
||
vmixCmd(`Function=SetText&Input=ГОЛ - НЕЙТРАЛЬНЫЙ&SelectedName=Игрок.Text&Value=${playerNumber} ${playerName}`),
|
||
vmixCmd(`Function=SetImage&Input=ГОЛ - НЕЙТРАЛЬНЫЙ&SelectedName=Лого.Source&Value=${teamLogo}`),
|
||
vmixCmd("Function=PreviewInput&Input=ГОЛ - НЕЙТРАЛЬНЫЙ")
|
||
];
|
||
}
|
||
|
||
if (eventType === "yellow") {
|
||
return [
|
||
vmixCmd(`Function=SetText&Input=ЖЕЛТАЯ - НЕЙТРАЛЬНАЯ&SelectedName=Игрок.Text&Value=${playerNumber} ${playerName}`),
|
||
vmixCmd(`Function=SetImage&Input=ЖЕЛТАЯ - НЕЙТРАЛЬНАЯ&SelectedName=Лого.Source&Value=${teamLogo}`),
|
||
vmixCmd("Function=PreviewInput&Input=ЖЕЛТАЯ - НЕЙТРАЛЬНАЯ")
|
||
];
|
||
}
|
||
|
||
if (eventType === "red") {
|
||
return [
|
||
vmixCmd(`Function=SetText&Input=КРАСНАЯ - НЕЙТРАЛЬНАЯ&SelectedName=Игрок.Text&Value=${playerNumber} ${playerName}`),
|
||
vmixCmd(`Function=SetImage&Input=КРАСНАЯ - НЕЙТРАЛЬНАЯ&SelectedName=Лого.Source&Value=${teamLogo}`),
|
||
vmixCmd("Function=PreviewInput&Input=КРАСНАЯ - НЕЙТРАЛЬНАЯ")
|
||
];
|
||
}
|
||
|
||
if (eventType === "sub") {
|
||
const outName = escapeVmixValue(getSubOutName(payload));
|
||
const inName = escapeVmixValue(getSubInName(payload));
|
||
|
||
return [
|
||
vmixCmd(`Function=SetText&Input=ЗАМЕНА - НЕЙТРАЛЬНАЯ&SelectedName=НомерИгрокУход.Text&Value=${playerOutNumber}`),
|
||
vmixCmd(`Function=SetText&Input=ЗАМЕНА - НЕЙТРАЛЬНАЯ&SelectedName=ИгрокУходящий.Text&Value=${outName}`),
|
||
vmixCmd(`Function=SetText&Input=ЗАМЕНА - НЕЙТРАЛЬНАЯ&SelectedName=НомерИгрокВыход.Text&Value=${playerInNumber}`),
|
||
vmixCmd(`Function=SetText&Input=ЗАМЕНА - НЕЙТРАЛЬНАЯ&SelectedName=ИгрокВыходящий.Text&Value=${inName}`),
|
||
vmixCmd(`Function=SetImage&Input=ЗАМЕНА - НЕЙТРАЛЬНАЯ&SelectedName=Лого.Source&Value=${teamLogo}`),
|
||
vmixCmd("Function=PreviewInput&Input=ЗАМЕНА - НЕЙТРАЛЬНАЯ")
|
||
];
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
async function triggerVmixEventCommand(eventPayload) {
|
||
const commands = buildVmixCommandsFromEvent(eventPayload);
|
||
|
||
if (!commands.length) {
|
||
console.warn("Для события нет команд vMix", eventPayload);
|
||
return;
|
||
}
|
||
|
||
console.log("VMIX DIRECT EVENT:", eventPayload);
|
||
console.log("VMIX DIRECT COMMANDS:", commands);
|
||
|
||
// await executeVmixCommands(commands.join("\n"));
|
||
await sendDirectVmixCommands(commands);
|
||
}
|
||
|
||
|
||
function getPlayerNumberByEvent(event) {
|
||
if (!event) return "";
|
||
|
||
if (event.type === "sub") {
|
||
const outRow = findLineupRowByPlayerId(event.side, "starting", event.player_out_id)
|
||
|| findLineupRowByPlayerId(event.side, "bench", event.player_out_id);
|
||
const inRow = findLineupRowByPlayerId(event.side, "starting", event.player_in_id)
|
||
|| findLineupRowByPlayerId(event.side, "bench", event.player_in_id);
|
||
|
||
return {
|
||
out: outRow?.dataset.playerNumber || "",
|
||
in: inRow?.dataset.playerNumber || ""
|
||
};
|
||
}
|
||
|
||
const row = findLineupRowByPlayerId(event.side, "starting", event.player_id)
|
||
|| findLineupRowByPlayerId(event.side, "bench", event.player_id);
|
||
|
||
return row?.dataset.playerNumber || "";
|
||
}
|
||
|
||
|
||
function buildVmixClickAttr(payload) {
|
||
const json = JSON.stringify(payload);
|
||
const encoded = btoa(unescape(encodeURIComponent(json)));
|
||
return `onclick="triggerVmixEventCommandFromEncoded('${encoded}')"`
|
||
}
|
||
|
||
function triggerVmixEventCommandFromEncoded(encodedPayload) {
|
||
try {
|
||
const json = decodeURIComponent(escape(atob(encodedPayload)));
|
||
const payload = JSON.parse(json);
|
||
triggerVmixEventCommand(payload);
|
||
} catch (e) {
|
||
console.warn("Не удалось разобрать payload для vMix", e);
|
||
}
|
||
}
|
||
|
||
function syncFormationCaptainsInState(side) {
|
||
const starting = side === "home"
|
||
? (window.MATCH_DATA.homeStarting || [])
|
||
: (window.MATCH_DATA.awayStarting || []);
|
||
|
||
const captainIds = new Set(
|
||
starting
|
||
.filter(p => !!p.is_captain)
|
||
.map(p => String(p.player_id ?? p.id ?? p.number ?? ""))
|
||
);
|
||
|
||
if (!Array.isArray(formationState[side])) {
|
||
formationState[side] = [];
|
||
}
|
||
|
||
formationState[side] = formationState[side].map(player => {
|
||
const playerKey = String(player.player_id ?? player.id ?? player.number ?? "");
|
||
return {
|
||
...player,
|
||
is_captain: captainIds.has(playerKey)
|
||
};
|
||
});
|
||
}
|
||
|
||
function getPlayerNamesByEvent(event) {
|
||
if (!event) return { out: "", in: "" };
|
||
|
||
if (event.type !== "sub") {
|
||
return {
|
||
out: event.player_name || "",
|
||
in: ""
|
||
};
|
||
}
|
||
|
||
const outRow =
|
||
findLineupRowByPlayerId(event.side, "starting", event.player_out_id) ||
|
||
findLineupRowByPlayerId(event.side, "bench", event.player_out_id);
|
||
|
||
const inRow =
|
||
findLineupRowByPlayerId(event.side, "starting", event.player_in_id) ||
|
||
findLineupRowByPlayerId(event.side, "bench", event.player_in_id);
|
||
|
||
return {
|
||
out: outRow?.dataset.playerName || "",
|
||
in: inRow?.dataset.playerName || ""
|
||
};
|
||
}
|
||
|
||
function renderEvents() {
|
||
const list = document.getElementById("eventsList");
|
||
if (!list) return;
|
||
|
||
if (!matchEvents.length) {
|
||
list.innerHTML = '<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">${escapeHtml(event.minute)}</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,
|
||
session_token: SESSION_TOKEN,
|
||
target_client_id: VMIX_TARGET_CLIENT_ID,
|
||
target_group: VMIX_TARGET_GROUP,
|
||
commands: items
|
||
})
|
||
});
|
||
|
||
const data = await resp.json();
|
||
|
||
if (!resp.ok) {
|
||
console.warn("Ошибка публикации команд vMix:", data);
|
||
alert("Не удалось отправить команды в vMix");
|
||
return;
|
||
}
|
||
|
||
console.log("VMIX PUBLISH OK:", data);
|
||
} catch (e) {
|
||
console.warn("Ошибка отправки команд на сервер:", e);
|
||
alert("Ошибка связи с сервером при отправке команд vMix");
|
||
}
|
||
}
|
||
|
||
|
||
function syncFormationCaptainsFromMatchData(side) {
|
||
const starting = side === "home"
|
||
? (window.MATCH_DATA.homeStarting || [])
|
||
: (window.MATCH_DATA.awayStarting || []);
|
||
|
||
if (!Array.isArray(formationState[side]) || formationState[side].length === 0) {
|
||
formationState[side] = starting.slice(0, 11).map((p, idx) => ({
|
||
player_id: p.player_id || p.id || p.number || null,
|
||
player_name: p.player_name || `${p.last_name || ""} ${p.first_name || ""}`.trim(),
|
||
last_name: p.last_name || p.player_name || "",
|
||
first_name: p.first_name || "",
|
||
number: p.number || "",
|
||
position: p.position || "",
|
||
is_captain: !!p.is_captain,
|
||
photo: p.photo || "",
|
||
x: 50,
|
||
y: 12 + idx * 6,
|
||
}));
|
||
|
||
renderFormation(side);
|
||
return;
|
||
}
|
||
|
||
const captainIds = new Set(
|
||
starting
|
||
.filter(p => !!p.is_captain)
|
||
.map(p => String(p.player_id ?? p.id ?? p.number ?? ""))
|
||
);
|
||
|
||
formationState[side] = formationState[side].map(player => ({
|
||
...player,
|
||
is_captain: captainIds.has(String(player.player_id ?? player.id ?? player.number ?? ""))
|
||
}));
|
||
|
||
renderFormation(side);
|
||
}
|
||
|
||
|
||
async function triggerVmixTimerAction(action) {
|
||
const map = {
|
||
first_half_start: [
|
||
vmixCmd("Function=SetCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text&Value=02:00:00"),
|
||
vmixCmd("Function=ChangeCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text&Value=00:00:00"),
|
||
vmixCmd("Function=StartCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text"),
|
||
vmixCmd("Function=PreviewInput&Input=ВЕРХНИЙ СЧЕТ")
|
||
],
|
||
halftime: [
|
||
vmixCmd("Function=PauseCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text"),
|
||
vmixCmd("Function=OverlayInput1Out"),
|
||
vmixCmd("Function=OverlayInput2Out")
|
||
],
|
||
second_half_start: [
|
||
vmixCmd("Function=SetCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text&Value=02:00:00"),
|
||
vmixCmd("Function=ChangeCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text&Value=00:45:00"),
|
||
vmixCmd("Function=StartCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text"),
|
||
vmixCmd("Function=PreviewInput&Input=ВЕРХНИЙ СЧЕТ")
|
||
],
|
||
full_time: [
|
||
vmixCmd("Function=PauseCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text"),
|
||
vmixCmd("Function=OverlayInput1Out"),
|
||
vmixCmd("Function=OverlayInput2Out")
|
||
],
|
||
clear_match: [
|
||
vmixCmd("Function=SetCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text&Value=02:00:00"),
|
||
vmixCmd("Function=ChangeCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text&Value=00:00:00"),
|
||
vmixCmd("Function=PauseCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text&Value=00:00:00"),
|
||
]
|
||
};
|
||
|
||
const commands = map[action] || [];
|
||
if (!commands.length) return;
|
||
|
||
await sendDirectVmixCommands(commands);
|
||
}
|
||
|
||
function clearExtraTimeInput() {
|
||
const input = document.getElementById("extraTimeMinutes");
|
||
if (input) input.value = "";
|
||
}
|
||
|
||
function clearTemporaryExpelledRows() {
|
||
document.querySelectorAll('tr.lineup-row[data-temporary="1"]').forEach((row) => row.remove());
|
||
}
|
||
|
||
function getBenchTbody(side) {
|
||
const benchTable = Array.from(document.querySelectorAll("tr.lineup-row"))
|
||
.find((row) => row.dataset.side === side && row.dataset.role === "bench")
|
||
?.closest("tbody");
|
||
return benchTable || null;
|
||
}
|
||
|
||
function createTemporaryExpelledRow(sourceRow, disciplineType) {
|
||
const benchTbody = getBenchTbody(sourceRow.dataset.side);
|
||
if (!benchTbody) return null;
|
||
|
||
const tempRow = document.createElement("tr");
|
||
tempRow.className = "lineup-row";
|
||
tempRow.dataset.side = sourceRow.dataset.side || "";
|
||
tempRow.dataset.role = "expelled";
|
||
tempRow.dataset.temporary = "1";
|
||
|
||
const player = getRowPlayer(sourceRow);
|
||
player.disciplineType = disciplineType;
|
||
setRowPlayer(tempRow, player);
|
||
renderLineupRow(tempRow);
|
||
tempRow.classList.add("lineup-row-sub-out");
|
||
|
||
benchTbody.appendChild(tempRow);
|
||
return tempRow;
|
||
}
|
||
|
||
function getPlayerDisciplineMap() {
|
||
const map = new Map();
|
||
|
||
matchEvents
|
||
.filter((event) => event.side !== "center" && event.player_id)
|
||
.sort((a, b) => {
|
||
if ((a.seconds || 0) !== (b.seconds || 0)) return (a.seconds || 0) - (b.seconds || 0);
|
||
return (a.id || 0) - (b.id || 0);
|
||
})
|
||
.forEach((event) => {
|
||
const key = `${event.side}::${event.player_id}`;
|
||
const current = map.get(key) || { yellow: 0, red: false, disciplineType: "" };
|
||
|
||
if (event.type === "yellow") {
|
||
current.yellow += 1;
|
||
if (current.yellow >= 2 && !current.red) {
|
||
current.disciplineType = "yellow_red";
|
||
}
|
||
}
|
||
|
||
if (event.type === "red") {
|
||
current.red = true;
|
||
current.disciplineType = "red";
|
||
}
|
||
|
||
map.set(key, current);
|
||
});
|
||
|
||
return map;
|
||
}
|
||
|
||
function applyDisciplinaryEvents() {
|
||
clearTemporaryExpelledRows();
|
||
|
||
Array.from(document.querySelectorAll("tr.lineup-row"))
|
||
.filter((row) => row.dataset.temporary !== "1")
|
||
.forEach((row) => {
|
||
row.classList.remove("lineup-row-expelled-hidden");
|
||
row.dataset.disciplineType = "";
|
||
renderLineupRow(row);
|
||
});
|
||
|
||
const disciplineMap = getPlayerDisciplineMap();
|
||
|
||
disciplineMap.forEach((value, key) => {
|
||
if (!value.disciplineType) return;
|
||
|
||
const [side, playerId] = key.split("::");
|
||
|
||
let row = findLineupRowByPlayerId(side, "starting", playerId);
|
||
if (row) {
|
||
row.dataset.disciplineType = value.disciplineType;
|
||
row.classList.add("lineup-row-expelled-hidden");
|
||
createTemporaryExpelledRow(row, value.disciplineType);
|
||
return;
|
||
}
|
||
|
||
row = findLineupRowByPlayerId(side, "bench", playerId);
|
||
if (row) {
|
||
row.dataset.disciplineType = value.disciplineType;
|
||
renderLineupRow(row);
|
||
}
|
||
});
|
||
|
||
sortLineupRows("home", "starting");
|
||
sortLineupRows("home", "bench");
|
||
sortLineupRows("away", "starting");
|
||
sortLineupRows("away", "bench");
|
||
}
|
||
|
||
function sortLineupRows(side, role) {
|
||
const rows = Array.from(document.querySelectorAll(
|
||
`tr.lineup-row[data-side="${side}"][data-role="${role}"]`
|
||
));
|
||
|
||
const tbody = rows[0]?.closest("tbody");
|
||
if (!tbody) return;
|
||
|
||
rows.sort((a, b) => {
|
||
const pa = getRowPlayer(a);
|
||
const pb = getRowPlayer(b);
|
||
|
||
const gkA = isGoalkeeperPosition(pa.position) ? 1 : 0;
|
||
const gkB = isGoalkeeperPosition(pb.position) ? 1 : 0;
|
||
|
||
if (gkA !== gkB) return gkB - gkA;
|
||
|
||
const numA = parseInt(pa.number, 10);
|
||
const numB = parseInt(pb.number, 10);
|
||
|
||
const safeA = Number.isFinite(numA) ? numA : 999;
|
||
const safeB = Number.isFinite(numB) ? numB : 999;
|
||
|
||
return safeA - safeB;
|
||
});
|
||
|
||
rows.forEach(row => tbody.appendChild(row));
|
||
}
|
||
|
||
function captureCurrentLineupState() {
|
||
const rows = Array.from(document.querySelectorAll("tr.lineup-row"));
|
||
return rows.map((row, index) => ({
|
||
index,
|
||
side: row.dataset.side || "",
|
||
role: row.dataset.role || "",
|
||
player: getRowPlayer(row)
|
||
}));
|
||
}
|
||
|
||
function restoreOriginalLineupRows() {
|
||
if (!Array.isArray(originalLineupState) || !originalLineupState.length) return;
|
||
|
||
clearTemporaryExpelledRows();
|
||
|
||
const rows = Array.from(document.querySelectorAll("tr.lineup-row"))
|
||
.filter((row) => row.dataset.temporary !== "1");
|
||
|
||
originalLineupState.forEach((item) => {
|
||
const row = rows[item.index];
|
||
if (!row) return;
|
||
row.classList.remove("lineup-row-expelled-hidden", "lineup-row-sub-in", "lineup-row-sub-out", "bench-pick-active");
|
||
setRowPlayer(row, { ...item.player, disciplineType: "" });
|
||
renderLineupRow(row);
|
||
});
|
||
|
||
sortLineupRows("home", "starting");
|
||
sortLineupRows("home", "bench");
|
||
sortLineupRows("away", "starting");
|
||
sortLineupRows("away", "bench");
|
||
}
|
||
|
||
function findLineupRowByPlayerId(side, role, playerId) {
|
||
if (playerId === null || playerId === undefined || playerId === "") return null;
|
||
|
||
return Array.from(document.querySelectorAll("tr.lineup-row")).find((row) =>
|
||
row.dataset.temporary !== "1" &&
|
||
String(row.dataset.side || "") === String(side) &&
|
||
String(row.dataset.role || "") === String(role) &&
|
||
String(row.dataset.playerId || "") === String(playerId)
|
||
) || null;
|
||
}
|
||
|
||
function swapLineupPlayers(startingRow, benchRow) {
|
||
const startingPlayer = getRowPlayer(startingRow);
|
||
const benchPlayer = getRowPlayer(benchRow);
|
||
|
||
setRowPlayer(startingRow, benchPlayer);
|
||
setRowPlayer(benchRow, startingPlayer);
|
||
|
||
renderLineupRow(startingRow);
|
||
renderLineupRow(benchRow);
|
||
sortLineupRows(startingRow.dataset.side, "starting");
|
||
sortLineupRows(startingRow.dataset.side, "bench");
|
||
startingRow.classList.add("lineup-row-sub-in");
|
||
benchRow.classList.add("lineup-row-sub-out");
|
||
saveMatchState();
|
||
}
|
||
|
||
function rebuildLineupsFromEvents() {
|
||
restoreOriginalLineupRows();
|
||
|
||
const subEvents = matchEvents
|
||
.filter((event) => event.type === "sub" && event.player_out_id && event.player_in_id)
|
||
.sort((a, b) => {
|
||
if ((a.seconds || 0) !== (b.seconds || 0)) return (a.seconds || 0) - (b.seconds || 0);
|
||
return (a.id || 0) - (b.id || 0);
|
||
});
|
||
|
||
subEvents.forEach((event) => {
|
||
const outRow = findLineupRowByPlayerId(event.side, "starting", event.player_out_id);
|
||
const inRow = findLineupRowByPlayerId(event.side, "bench", event.player_in_id);
|
||
if (outRow && inRow) swapLineupPlayers(outRow, inRow);
|
||
});
|
||
|
||
applyDisciplinaryEvents();
|
||
}
|
||
|
||
async function selectBenchPlayer(side, playerId, playerName, btn) {
|
||
if (!activeSubstitution || activeSubstitution.side !== side) return;
|
||
|
||
clearBenchSelection();
|
||
if (btn) btn.classList.add("bench-pick-active");
|
||
|
||
const benchRow = btn ? btn.closest("tr.lineup-row") : null;
|
||
const outRow = activeSubstitution.outRow || null;
|
||
|
||
const outName = outRow
|
||
? getRowDisplayName(outRow)
|
||
: activeSubstitution.outPlayerName;
|
||
|
||
const inName = benchRow
|
||
? getRowDisplayName(benchRow)
|
||
: playerName;
|
||
|
||
if (outRow && benchRow) {
|
||
swapLineupPlayers(outRow, benchRow);
|
||
}
|
||
|
||
const minuteLabel = formatMinuteLabel(matchClockSeconds);
|
||
const numericMinute =
|
||
parseInt(String(minuteLabel).split("+").pop().replace("'", ""), 10) ||
|
||
getFootballMinute(matchClockSeconds);
|
||
|
||
const item = {
|
||
id: null,
|
||
side,
|
||
type: "sub",
|
||
icon: getEventIcon("sub"),
|
||
title: `${outName} → ${inName}`,
|
||
minute: minuteLabel,
|
||
seconds: matchClockSeconds,
|
||
player_name: `${outName} → ${inName}`,
|
||
player_out_id: activeSubstitution.outPlayerId,
|
||
player_in_id: playerId,
|
||
player_id: null
|
||
};
|
||
|
||
matchEvents.push(item);
|
||
renderEvents();
|
||
rebuildLineupsFromEvents();
|
||
saveMatchState();
|
||
|
||
const saved = await saveEventToServer({
|
||
side,
|
||
type: "sub",
|
||
player_name: `${outName} → ${inName}`,
|
||
player_out_id: activeSubstitution.outPlayerId,
|
||
player_in_id: playerId,
|
||
player_id: null,
|
||
minute: numericMinute,
|
||
seconds: matchClockSeconds,
|
||
meta: null
|
||
});
|
||
|
||
if (saved && saved.id) {
|
||
item.id = saved.id;
|
||
renderEvents();
|
||
rebuildLineupsFromEvents();
|
||
saveMatchState();
|
||
}
|
||
|
||
activeSubstitution = null;
|
||
clearBenchSelection();
|
||
}
|
||
|
||
|
||
function ensureInitialState(side) {
|
||
if (!formationState[side] || formationState[side].length === 0) {
|
||
const currentStarting = side === "home"
|
||
? (window.MATCH_DATA.homeStarting || [])
|
||
: (window.MATCH_DATA.awayStarting || []);
|
||
|
||
formationState[side] = currentStarting.slice(0, 11).map((p, idx) => ({
|
||
player_id: p.player_id || p.id || p.number || null,
|
||
player_name: p.player_name || `${p.last_name || ""} ${p.first_name || ""}`.trim(),
|
||
last_name: p.last_name || p.player_name || "",
|
||
first_name: p.first_name || "",
|
||
number: p.number || "",
|
||
position: p.position || "",
|
||
is_captain: !!p.is_captain,
|
||
photo: p.photo || "",
|
||
x: 50,
|
||
y: 12 + idx * 6,
|
||
}));
|
||
}
|
||
}
|
||
|
||
function extractLastName(player) {
|
||
return String(player.last_name || "").trim().toUpperCase();
|
||
}
|
||
|
||
function buildPlayerSuffix(player) {
|
||
const pos = String(player.position || "").trim().toLowerCase();
|
||
const isGoalkeeper = ["вр.", "вр", "вратарь", "gk", "goalkeeper"].includes(pos);
|
||
const isCaptain = !!player.is_captain;
|
||
|
||
if (isGoalkeeper && isCaptain) return " (ВР, К)";
|
||
if (isGoalkeeper) return " (ВР)";
|
||
if (isCaptain) return " (К)";
|
||
return "";
|
||
}
|
||
|
||
function buildPlayerLabel(player) {
|
||
const number = player.number || "";
|
||
const lastName = extractLastName(player);
|
||
return `${number} ${lastName}${buildPlayerSuffix(player)}`.trim();
|
||
}
|
||
|
||
function createPlayerNode(player, side) {
|
||
const el = document.createElement("div");
|
||
el.className = "player-node";
|
||
el.dataset.playerId = player.player_id ?? "";
|
||
el.dataset.side = side;
|
||
const safeX = clampWebPercent(player.x);
|
||
const safeY = clampWebPercent(player.y);
|
||
|
||
el.style.left = `${safeX}%`;
|
||
el.style.top = `${safeY}%`;
|
||
|
||
|
||
const visual = player.photo
|
||
? `<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 || "",
|
||
positionFull: row.dataset.playerPositionFull || "",
|
||
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.playerPositionFull = player.positionFull ?? "";
|
||
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.positionFull || "")}"
|
||
>
|
||
<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 inputName = getFormationVmixInputName(side);
|
||
const commands = [];
|
||
const playerPrefix = side === "home" ? "HOME" : "AWAY";
|
||
|
||
if (players.length < 2) {
|
||
return [];
|
||
}
|
||
|
||
// Сбрасываем только свою сторону
|
||
for (let i = 1; i <= 10; i++) {
|
||
commands.push(
|
||
vmixCmd(`Function=ResetInput&Input=${encodeURIComponent(`${playerPrefix} - Игрок${i}`)}`)
|
||
);
|
||
}
|
||
|
||
commands.push(
|
||
vmixCmd(`Function=ResetInput&Input=${encodeURIComponent(inputName)}`)
|
||
);
|
||
|
||
// Вратарь players[0], полевые players[1..10]
|
||
players.slice(1, 11).forEach((player, index) => {
|
||
const layer = index + 1;
|
||
const x = convertPitchXToPan(player.x).toFixed(3);
|
||
const y = convertPitchYToPan(player.y).toFixed(3);
|
||
|
||
commands.push(
|
||
vmixCmd(`Function=SetLayer${layer}PanX&Input=${encodeURIComponent(inputName)}&Value=${x}`)
|
||
);
|
||
|
||
commands.push(
|
||
vmixCmd(`Function=SetLayer${layer}PanY&Input=${encodeURIComponent(inputName)}&Value=${y}`)
|
||
);
|
||
});
|
||
|
||
commands.push(
|
||
vmixCmd(`Function=PreviewInput&Input=${encodeURIComponent(inputName)}`)
|
||
);
|
||
|
||
return commands;
|
||
}
|
||
|
||
|
||
async function sendFormationToVmix(side) {
|
||
syncFormationCaptainsInState(side);
|
||
|
||
const commands = buildVmixCommandsFromFormation(side);
|
||
|
||
if (!commands.length) {
|
||
alert("Нет данных для отправки расстановки в vMix");
|
||
return;
|
||
}
|
||
|
||
console.log(`FORMATION VMIX ${side}`, formationState[side]);
|
||
console.log(`FORMATION VMIX COMMANDS ${side}`, commands);
|
||
|
||
await sendDirectVmixCommands(commands);
|
||
}
|
||
const FORMATION_COORDS = {
|
||
"4-4-2": [
|
||
{ x: 50, y: 10 },
|
||
{ x: 0, y: 40 }, { x: 30, y: 30 }, { x: 70, y: 30 }, { x: 100, y: 40 },
|
||
{ x: 0, y: 75 }, { x: 30, y: 65 }, { x: 70, y: 65 }, { x: 100, y: 75 },
|
||
{ x: 25, y: 100 }, { x: 75, y: 100 }
|
||
]
|
||
};
|
||
|
||
const BASE_GEOMETRY = {
|
||
goalkeeper: { x: 50, y: 10 },
|
||
|
||
rowsY: {
|
||
defenders: 35,
|
||
midfielders: 70,
|
||
forwards: 100
|
||
},
|
||
|
||
rowProfiles: {
|
||
1: { xSlots: [50], arc: 0 },
|
||
2: { xSlots: [25, 75], arc: 0 },
|
||
|
||
3: { xSlots: [8, 50, 92], arc: 0 },
|
||
4: { xSlots: [0, 30, 70, 100], arc: 10 },
|
||
5: { xSlots: [0, 24, 50, 78, 100], arc: 20, yOffset: 5 },
|
||
6: { xSlots: [0, 16, 36, 64, 84, 100], arc: 0 }
|
||
}
|
||
};
|
||
|
||
function distributeLine(count, yCenter, profile) {
|
||
const { minX, maxX, arc = 0, xSlots = null, yOffset = 0 } = profile;
|
||
|
||
if (count <= 0) return [];
|
||
if (count === 1) return [{ x: 50, y: yCenter }];
|
||
|
||
const xs = Array.isArray(xSlots) && xSlots.length === count
|
||
? xSlots
|
||
: Array.from({ length: count }, (_, i) => minX + ((maxX - minX) * i / (count - 1)));
|
||
|
||
// Специальная полудуга для линии из 5 игроков
|
||
if (count === 5 && arc > 0) {
|
||
const offsets = [
|
||
arc * 0.11, // левый фланг чуть ниже базы
|
||
-arc * 0.5, // левый полуфланг выше
|
||
-arc, // центр выше всех
|
||
-arc * 0.5, // правый полуфланг выше
|
||
arc * 0.11 // правый фланг чуть ниже базы
|
||
];
|
||
|
||
return xs.map((x, i) => ({
|
||
x,
|
||
y: yCenter + offsets[i] + yOffset
|
||
}));
|
||
}
|
||
|
||
// Общая логика для остальных линий
|
||
const center = (count - 1) / 2;
|
||
|
||
return xs.map((x, i) => {
|
||
let y = yCenter;
|
||
|
||
if (arc > 0) {
|
||
const dist = Math.abs(i - center);
|
||
const maxDist = center || 1;
|
||
const normalized = 1 - dist / maxDist;
|
||
y = yCenter - arc * normalized;
|
||
}
|
||
|
||
return { x, y: y + yOffset };
|
||
});
|
||
}
|
||
|
||
const FORMATION_TEMPLATE_442 = {
|
||
gk: { x: 50, y: 10 },
|
||
|
||
rows: [
|
||
{ y: 35, xSlots: [0, 30, 70, 100] }, // защита 4
|
||
{ y: 70, xSlots: [0, 30, 70, 100] }, // полузащита 4
|
||
{ y: 100, xSlots: [25, 75] } // нападение 2
|
||
]
|
||
};
|
||
|
||
// function getDistributedXSlots(count) {
|
||
// if (count <= 0) return [];
|
||
// if (count === 1) return [50];
|
||
// if (count === 2) return [25, 75];
|
||
// if (count === 3) return [8, 50, 92];
|
||
// if (count === 4) return [0, 30, 70, 100];
|
||
// if (count === 5) return [0, 18, 50, 82, 100];
|
||
// if (count === 6) return [0, 16, 36, 64, 84, 100];
|
||
|
||
// return Array.from({ length: count }, (_, i) => (i / (count - 1)) * 100);
|
||
// }
|
||
|
||
// function getArcYOffsetSlots(count, strength = 10) {
|
||
// if (count < 4) {
|
||
// return Array(count).fill(0);
|
||
// }
|
||
|
||
// if (count === 5) {
|
||
// return [
|
||
// strength * 0.11, // левый фланг ниже
|
||
// -strength * 0.5, // левый полуфланг чуть выше базы
|
||
// -strength, // центр выше всех
|
||
// -strength * 0.5, // правый полуфланг чуть выше базы
|
||
// strength * 0.11 // правый фланг ниже
|
||
// ];
|
||
// }
|
||
|
||
// const center = (count - 1) / 2;
|
||
|
||
// return Array.from({ length: count }, (_, i) => {
|
||
// const dist = Math.abs(i - center);
|
||
// const maxDist = center || 1;
|
||
// const normalized = dist / maxDist;
|
||
|
||
// return -strength * Math.pow(1 - normalized, 1.5);
|
||
// });
|
||
// }
|
||
|
||
function clamp(value, min, max) {
|
||
return Math.max(min, Math.min(max, value));
|
||
}
|
||
|
||
function lerp(a, b, t) {
|
||
return a + (b - a) * t;
|
||
}
|
||
|
||
function distributeEvenly(count, start, end) {
|
||
if (count <= 0) return [];
|
||
if (count === 1) return [(start + end) / 2];
|
||
|
||
const step = (end - start) / (count - 1);
|
||
return Array.from({ length: count }, (_, i) => start + step * i);
|
||
}
|
||
|
||
function getRowSpacingFactor(rowIndex, rowCount, options = {}) {
|
||
// можно управлять “сжатием” ближе к воротам или к атаке
|
||
const {
|
||
curve = 1 // 1 = линейно, >1 сильнее прижимает первые линии
|
||
} = options;
|
||
|
||
if (rowCount <= 1) return 1;
|
||
const t = rowIndex / (rowCount - 1);
|
||
return Math.pow(t, curve);
|
||
}
|
||
|
||
function getHorizontalInsetsForRow(count, rowType, options = {}) {
|
||
const {
|
||
sideInset = 8,
|
||
wideRowExtraInset = 0,
|
||
strikerInset = 18
|
||
} = options;
|
||
|
||
// можно по типу линии менять ширину доступной зоны
|
||
if (rowType === "forwards" && count <= 2) {
|
||
return { left: strikerInset, right: strikerInset };
|
||
}
|
||
|
||
if (count >= 5) {
|
||
return {
|
||
left: sideInset + wideRowExtraInset,
|
||
right: sideInset + wideRowExtraInset
|
||
};
|
||
}
|
||
|
||
return { left: sideInset, right: sideInset };
|
||
}
|
||
|
||
function getRowArcOffsets(count, arcStrength = 0, direction = "up") {
|
||
if (count <= 1 || arcStrength === 0) {
|
||
return Array(count).fill(0);
|
||
}
|
||
|
||
const center = (count - 1) / 2;
|
||
const sign = direction === "down" ? 1 : -1;
|
||
|
||
return Array.from({ length: count }, (_, i) => {
|
||
const dist = Math.abs(i - center);
|
||
const maxDist = center || 1;
|
||
const normalized = 1 - (dist / maxDist); // центр = 1, края = 0
|
||
return sign * arcStrength * Math.pow(normalized, 1.4);
|
||
});
|
||
}
|
||
|
||
function getRowTopLift(profile, count) {
|
||
const arc = Number(profile?.arc || 0);
|
||
const yOffset = Number(profile?.yOffset || 0);
|
||
|
||
if (count === 5 && arc > 0) {
|
||
// у пятёрки самая верхняя точка — центр
|
||
return Math.max(0, arc - yOffset);
|
||
}
|
||
|
||
if (count > 1 && arc > 0) {
|
||
// для обычной дуги максимум подъёма в центре
|
||
return Math.max(0, arc - yOffset);
|
||
}
|
||
|
||
return Math.max(0, -yOffset);
|
||
}
|
||
|
||
|
||
|
||
function buildFormationCoords(formation) {
|
||
const parts = String(formation).split("-").map(Number).filter(Number.isFinite);
|
||
const coords = [];
|
||
|
||
coords.push(BASE_GEOMETRY.goalkeeper);
|
||
|
||
if (!parts.length) {
|
||
return coords;
|
||
}
|
||
const rowCount = parts.length;
|
||
const firstRowY = 25;
|
||
let rowGap = 23;
|
||
|
||
if (formation === "4-1-4-1") {
|
||
rowGap = 16;
|
||
}
|
||
|
||
let previousBottomY = firstRowY - rowGap;
|
||
|
||
parts.forEach((count, idx) => {
|
||
const profile = BASE_GEOMETRY.rowProfiles[count] || {
|
||
minX: 10,
|
||
maxX: 90,
|
||
arc: 0
|
||
};
|
||
|
||
const topLift = getRowTopLift(profile, count);
|
||
|
||
// стартуем новую линию так, чтобы её верхняя точка была ниже предыдущей линии + gap
|
||
const yCenter = previousBottomY + rowGap + topLift;
|
||
|
||
const rowCoords = distributeLine(count, yCenter, profile);
|
||
coords.push(...rowCoords);
|
||
|
||
previousBottomY = Math.max(...rowCoords.map(p => p.y));
|
||
});
|
||
|
||
return coords;
|
||
}
|
||
|
||
function generateFormationCoordsFromLayout(preset) {
|
||
const layout = FORMATION_ASSIGN_LAYOUTS[preset];
|
||
if (!layout) return [];
|
||
|
||
return buildFormationCoords(preset);
|
||
}
|
||
|
||
|
||
function generateFormationCoordsFromLayout2(preset) {
|
||
const layout = FORMATION_ASSIGN_LAYOUTS[preset];
|
||
if (!layout || !layout.length) return [];
|
||
|
||
const coords = [];
|
||
|
||
const gk = FORMATION_COORDS["4-4-2"]?.[0] || { x: 50, y: 10 };
|
||
coords.push({ x: gk.x, y: gk.y });
|
||
|
||
const fieldRows = layout.slice(1);
|
||
const rowCount = fieldRows.length;
|
||
|
||
const yStart = 35;
|
||
const yEnd = 100;
|
||
|
||
fieldRows.forEach((row, rowIndex) => {
|
||
const count = row.length;
|
||
|
||
const baseY =
|
||
rowCount === 1
|
||
? yEnd
|
||
: yStart + ((yEnd - yStart) * rowIndex / (rowCount - 1));
|
||
|
||
const xSlots = getDistributedXSlots(count);
|
||
|
||
let arcStrength = count >= 4 ? 10 : 0;
|
||
|
||
// Для 3-5-2 средняя линия из 5 должна быть ровно посередине
|
||
if (preset === "3-5-2" && rowIndex === 1 && count === 5) {
|
||
arcStrength = 20;
|
||
}
|
||
|
||
const arcOffsets = getArcYOffsetSlots(count, arcStrength);
|
||
|
||
xSlots.forEach((x, i) => {
|
||
coords.push({
|
||
x,
|
||
y: baseY + arcOffsets[i]
|
||
});
|
||
});
|
||
});
|
||
|
||
return coords;
|
||
}
|
||
|
||
const FORMATION_ASSIGN_LAYOUTS = {
|
||
"4-4-2": [
|
||
["GK"],
|
||
["LB", "LCB", "RCB", "RB"],
|
||
["LM", "LCM", "RCM", "RM"],
|
||
["LS", "RS"]
|
||
],
|
||
"4-3-3": [
|
||
["GK"],
|
||
["LB", "LCB", "RCB", "RB"],
|
||
["LCM", "CM", "RCM"],
|
||
["LW", "ST", "RW"]
|
||
],
|
||
"4-2-3-1": [
|
||
["GK"],
|
||
["LB", "LCB", "RCB", "RB"],
|
||
["LDM", "RDM"],
|
||
["LAM", "CAM", "RAM"],
|
||
["ST"]
|
||
],
|
||
"3-5-2": [
|
||
["GK"],
|
||
["LCB", "CB", "RCB"],
|
||
["LM", "LCM", "CM", "RCM", "RM"],
|
||
["LS", "RS"]
|
||
],
|
||
"5-3-2": [
|
||
["GK"],
|
||
["LWB", "LCB", "CB", "RCB", "RWB"],
|
||
["LCM", "CM", "RCM"],
|
||
["LS", "RS"]
|
||
],
|
||
"4-1-4-1": [
|
||
["GK"],
|
||
["LB", "LCB", "RCB", "RB"],
|
||
["CDM"],
|
||
["LM", "LCM", "RCM", "RM"],
|
||
["ST"]
|
||
],
|
||
|
||
"3-4-3": [
|
||
["GK"],
|
||
["LCB", "CB", "RCB"],
|
||
["LM", "LCM", "RCM", "RM"],
|
||
["LW", "ST", "RW"]
|
||
],
|
||
|
||
"4-5-1": [
|
||
["GK"],
|
||
["LB", "LCB", "RCB", "RB"],
|
||
["LM", "LCM", "CM", "RCM", "RM"],
|
||
["ST"]
|
||
],
|
||
|
||
"5-4-1": [
|
||
["GK"],
|
||
["LWB", "LCB", "CB", "RCB", "RWB"],
|
||
["LM", "LCM", "RCM", "RM"],
|
||
["ST"]
|
||
],
|
||
|
||
"4-3-1-2": [
|
||
["GK"],
|
||
["LB", "LCB", "RCB", "RB"],
|
||
["LCM", "CM", "RCM"],
|
||
["CAM"],
|
||
["LS", "RS"]
|
||
],
|
||
|
||
"3-6-1": [
|
||
["GK"],
|
||
["LCB", "CB", "RCB"],
|
||
["LM", "LCM", "CM", "RCM", "RM", "CAM"],
|
||
["ST"]
|
||
]
|
||
};
|
||
|
||
let formationAssignState = {
|
||
side: null,
|
||
preset: null
|
||
};
|
||
|
||
function getFormationAssignSquad(side) {
|
||
return side === "home"
|
||
? (window.MATCH_DATA.homeStarting || [])
|
||
: (window.MATCH_DATA.awayStarting || []);
|
||
}
|
||
|
||
function getFormationAssignTeamName(side) {
|
||
return side === "home"
|
||
? (window.MATCH_DATA.teamNames?.home || "")
|
||
: (window.MATCH_DATA.teamNames?.away || "");
|
||
}
|
||
|
||
function getFormationAssignPhoto(player) {
|
||
return player.photo || player.player_photo || player.image || player.avatar || "";
|
||
}
|
||
|
||
function getFormationAssignPlayerName(player) {
|
||
return `${player.last_name || player.player_name || ""} ${player.first_name || ""}`.trim();
|
||
}
|
||
|
||
function getExistingAssignedNumber(side, rowIndex, colIndex) {
|
||
const list = Array.isArray(formationState[side]) ? formationState[side] : [];
|
||
const item = list.find(p => Number(p.row_index) === rowIndex && Number(p.col_index) === colIndex);
|
||
return item ? String(item.number || "") : "";
|
||
}
|
||
|
||
function openFormationAssignModal(side) {
|
||
const presetSelect = document.getElementById(`${side}-preset`);
|
||
if (!presetSelect) return;
|
||
|
||
const preset = presetSelect.value;
|
||
const layout = FORMATION_ASSIGN_LAYOUTS[preset];
|
||
if (!layout) return;
|
||
|
||
formationAssignState.side = side;
|
||
formationAssignState.preset = preset;
|
||
|
||
const subtitle = document.getElementById("formationAssignSubtitle");
|
||
if (subtitle) {
|
||
subtitle.textContent = `${getFormationAssignTeamName(side)} — схема ${preset}`;
|
||
}
|
||
|
||
const squad = getFormationAssignSquad(side);
|
||
const wrap = document.getElementById("formationAssignTableWrap");
|
||
if (!wrap) return;
|
||
|
||
let html = `
|
||
<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,
|
||
positionFull: player.position_full || player.positionFull || "",
|
||
isCaptain: !!player.is_captain,
|
||
disciplineType: ""
|
||
});
|
||
renderLineupRow(row);
|
||
tbody.appendChild(row);
|
||
});
|
||
|
||
sortLineupRows(side, role);
|
||
}
|
||
|
||
function rebuildFormationFromMatchData(side) {
|
||
const starting = side === "home"
|
||
? (window.MATCH_DATA.homeStarting || [])
|
||
: (window.MATCH_DATA.awayStarting || []);
|
||
|
||
const prev = Array.isArray(formationState[side]) ? formationState[side] : [];
|
||
|
||
formationState[side] = starting.slice(0, 11).map((p, idx) => {
|
||
const playerId = String(p.player_id ?? p.id ?? p.number ?? "");
|
||
const old = prev.find(x => String(x.player_id ?? x.id ?? x.number ?? "") === playerId);
|
||
|
||
return {
|
||
player_id: p.player_id || p.id || p.number || null,
|
||
player_name: p.player_name || `${p.last_name || ""} ${p.first_name || ""}`.trim(),
|
||
last_name: p.last_name || p.player_name || "",
|
||
first_name: p.first_name || "",
|
||
number: p.number || "",
|
||
position: p.position || "",
|
||
is_captain: !!p.is_captain,
|
||
photo: p.photo || "",
|
||
x: old?.x ?? 50,
|
||
y: old?.y ?? (12 + idx * 6)
|
||
};
|
||
});
|
||
|
||
renderFormation(side);
|
||
}
|
||
|
||
|
||
async function applySquadEditorChanges() {
|
||
if (!squadEditorState.home || !squadEditorState.away) return;
|
||
|
||
const payload = buildSquadSavePayload();
|
||
const result = await saveSquadEditorChangesToServer(payload);
|
||
|
||
if (!result.success) {
|
||
alert(`Не удалось сохранить состав: ${result.error}`);
|
||
return;
|
||
}
|
||
|
||
console.log("SAVE PAYLOAD HOME STARTING", payload.home_starting);
|
||
window.MATCH_DATA.homeStarting = payload.home_starting.map(p => ({ ...p }));
|
||
window.MATCH_DATA.homeBench = payload.home_bench.map(p => ({ ...p }));
|
||
window.MATCH_DATA.homeCoaches = payload.home_coaches.map(c => ({ ...c }));
|
||
|
||
window.MATCH_DATA.awayStarting = payload.away_starting.map(p => ({ ...p }));
|
||
window.MATCH_DATA.awayBench = payload.away_bench.map(p => ({ ...p }));
|
||
window.MATCH_DATA.awayCoaches = payload.away_coaches.map(c => ({ ...c }));
|
||
fallbackLineups.home = window.MATCH_DATA.homeStarting;
|
||
fallbackLineups.away = window.MATCH_DATA.awayStarting;
|
||
rebuildFormationFromMatchData("home");
|
||
rebuildFormationFromMatchData("away");
|
||
console.log("MATCH_DATA HOME STARTING AFTER SAVE", window.MATCH_DATA.homeStarting);
|
||
|
||
rerenderLineupTable("home", "starting");
|
||
rerenderLineupTable("home", "bench");
|
||
rerenderLineupTable("away", "starting");
|
||
rerenderLineupTable("away", "bench");
|
||
|
||
renderLiveCoachBlocks();
|
||
|
||
originalLineupState = captureCurrentLineupState();
|
||
rebuildLineupsFromEvents();
|
||
saveMatchState();
|
||
closeSquadEditor();
|
||
|
||
alert("Состав сохранён");
|
||
}
|
||
|
||
async function loadSquadEditorData() {
|
||
if (!SESSION_TOKEN) return false;
|
||
|
||
try {
|
||
const res = await fetch(`/admin/session/${SESSION_TOKEN}/squad-editor-data`, {
|
||
method: "GET",
|
||
headers: { "Accept": "application/json" }
|
||
});
|
||
|
||
if (!res.ok) {
|
||
console.warn("Не удалось загрузить данные редактора состава", res.status);
|
||
return false;
|
||
}
|
||
|
||
const data = await res.json();
|
||
|
||
window.MATCH_DATA.homeStarting = Array.isArray(data.home_starting) ? data.home_starting : [];
|
||
window.MATCH_DATA.awayStarting = Array.isArray(data.away_starting) ? data.away_starting : [];
|
||
window.MATCH_DATA.homeBench = Array.isArray(data.home_bench) ? data.home_bench : [];
|
||
window.MATCH_DATA.awayBench = Array.isArray(data.away_bench) ? data.away_bench : [];
|
||
|
||
window.MATCH_DATA.homeCoaches = Array.isArray(data.home_coaches) ? data.home_coaches : [];
|
||
window.MATCH_DATA.awayCoaches = Array.isArray(data.away_coaches) ? data.away_coaches : [];
|
||
|
||
window.MATCH_DATA.homeSquadPool = Array.isArray(data.home_squad_pool) ? data.home_squad_pool : [];
|
||
window.MATCH_DATA.awaySquadPool = Array.isArray(data.away_squad_pool) ? data.away_squad_pool : [];
|
||
|
||
window.MATCH_DATA.homeCoachPool = Array.isArray(data.home_coach_pool) ? data.home_coach_pool : [];
|
||
window.MATCH_DATA.awayCoachPool = Array.isArray(data.away_coach_pool) ? data.away_coach_pool : [];
|
||
|
||
return true;
|
||
} catch (e) {
|
||
console.warn("Ошибка загрузки squad editor data", e);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function getFormationPresetStorageKey(side) {
|
||
return `match_formation_preset_${MATCH_ID}_${side}`;
|
||
}
|
||
|
||
function saveSelectedPreset(side) {
|
||
const select = document.getElementById(`${side}-preset`);
|
||
if (!select) return;
|
||
localStorage.setItem(getFormationPresetStorageKey(side), select.value);
|
||
}
|
||
|
||
function restoreSelectedPreset(side) {
|
||
const select = document.getElementById(`${side}-preset`);
|
||
if (!select) return;
|
||
|
||
const saved = localStorage.getItem(getFormationPresetStorageKey(side));
|
||
if (!saved) return;
|
||
|
||
const exists = Array.from(select.options).some(opt => opt.value === saved);
|
||
if (exists) {
|
||
select.value = saved;
|
||
}
|
||
}
|
||
|
||
function buildSquadSavePayload() {
|
||
return {
|
||
home_starting: (squadEditorState.home?.starting || []).map(p => ({ ...p })),
|
||
home_bench: (squadEditorState.home?.bench || []).map(p => ({ ...p })),
|
||
away_starting: (squadEditorState.away?.starting || []).map(p => ({ ...p })),
|
||
away_bench: (squadEditorState.away?.bench || []).map(p => ({ ...p })),
|
||
|
||
home_coaches: (squadEditorState.home?.coaches || []).map(c => ({ ...c })),
|
||
away_coaches: (squadEditorState.away?.coaches || []).map(c => ({ ...c }))
|
||
};
|
||
}
|
||
|
||
async function saveSquadEditorChangesToServer(payload) {
|
||
if (!SESSION_TOKEN) return { success: false, error: "Нет SESSION_TOKEN" };
|
||
|
||
try {
|
||
const res = await fetch(`/admin/session/${SESSION_TOKEN}/squad-editor-save`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
const data = await res.json().catch(() => ({}));
|
||
|
||
if (!res.ok || !data.success) {
|
||
return {
|
||
success: false,
|
||
error: data.error || `HTTP ${res.status}`
|
||
};
|
||
}
|
||
|
||
return { success: true, data };
|
||
} catch (e) {
|
||
return {
|
||
success: false,
|
||
error: e?.message || "Ошибка сети"
|
||
};
|
||
}
|
||
}
|
||
|
||
|
||
function normalizeReferee(item = {}) {
|
||
return {
|
||
referee_id: item.referee_id ?? item.id ?? "",
|
||
referee_name: (item.referee_name || item.full_name || item.name || "").trim(),
|
||
role: (item.role || "").trim()
|
||
};
|
||
}
|
||
|
||
let filteredRefereePool = [];
|
||
|
||
function renderRefereesEditor() {
|
||
const wrap = document.getElementById("refereesEditorWrap");
|
||
if (!wrap) return;
|
||
|
||
const items = Array.isArray(refereeEditorState.items)
|
||
? refereeEditorState.items.map(normalizeReferee)
|
||
: [];
|
||
|
||
const searchHtml = editModeEnabled ? `
|
||
<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("Ошибка сети при сохранении судей");
|
||
}
|
||
} |