build 122 - стартовые пятерки

This commit is contained in:
2026-08-26 14:56:15 +03:00
parent 41ca064b3b
commit fe164c17f2
6 changed files with 529 additions and 22 deletions

View File

@@ -2597,7 +2597,9 @@ function startCustomTooltips() {
// BUILD86: "Заготовки" is always the final top-level runtime tab.
// Reorder it on every config normalisation as older published configs may
// already contain the tab near "Игра" from BUILD84/85.
state.config.tabs = state.config.tabs.filter((tab) => tab?.id !== "prepared_titles");
state.config.tabs = state.config.tabs.filter((tab) => !["lines", "prepared_titles"].includes(tab?.id));
// BUILD122: line/five sorting is a first-class operator workspace.
state.config.tabs.push({ id: "lines", label: "Пятёрки" });
state.config.tabs.push({ id: "prepared_titles", label: "Заготовки" });
if (state.activeTab === "prematch") state.activeTab = state.config.tabs.find((tab) => tab.id === "main")?.id || state.config.tabs[0].id;
}
@@ -6817,6 +6819,10 @@ function openTimerQuickEditor(focusActionId = "") {
name: get("nameField", `Игрок ${index + 1}`),
position: get("positionField", ""),
role: String(row?.role || ""),
line: String(row?.line || row?.line_number || "").trim(),
lineSortOrder: Number(row?.line_sort_order || 0),
lineManual: Boolean(row?.line_manual),
apiLine: String(row?.api_line || row?.api_line_number || "").trim(),
raw: clone(row)
};
}
@@ -6825,19 +6831,33 @@ function openTimerQuickEditor(focusActionId = "") {
const props = component.props || {};
const rows = getByPath(state.data, side === "home" ? props.homePlayersPath : props.awayPlayersPath);
const limit = Math.max(1, Number(props.rosterLimit) || 40);
return (Array.isArray(rows) ? rows : [])
.map((row, index) => normalizeHockeyPlayer(component, row, side, index))
.sort((left, right) => {
const leftNumber = String(left.number || "").replace(/^#/, "");
const rightNumber = String(right.number || "").replace(/^#/, "");
if (!leftNumber && rightNumber) return 1;
if (leftNumber && !rightNumber) return -1;
return leftNumber.localeCompare(rightNumber, undefined, {
numeric: true,
sensitivity: "base",
}) || left.name.localeCompare(right.name);
})
.slice(0, limit);
const players = (Array.isArray(rows) ? rows : [])
.map((row, index) => normalizeHockeyPlayer(component, row, side, index));
const hasLineData = players.some((player) => String(player.line || "").trim());
players.sort((left, right) => {
if (hasLineData) {
const lineValue = (player) => {
const raw = String(player.line || "").trim();
if (!raw) return 10000;
const number = Number(raw);
return Number.isFinite(number) ? number : 9000;
};
const lineDiff = lineValue(left) - lineValue(right);
if (lineDiff) return lineDiff;
const leftOrder = Number(left.lineSortOrder || 0) || 10000;
const rightOrder = Number(right.lineSortOrder || 0) || 10000;
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
}
const leftNumber = String(left.number || "").replace(/^#/, "");
const rightNumber = String(right.number || "").replace(/^#/, "");
if (!leftNumber && rightNumber) return 1;
if (leftNumber && !rightNumber) return -1;
return leftNumber.localeCompare(rightNumber, undefined, {
numeric: true,
sensitivity: "base",
}) || left.name.localeCompare(right.name);
});
return players.slice(0, limit);
}
function hockeyRosterCounts(players) {
@@ -6860,6 +6880,296 @@ function openTimerQuickEditor(focusActionId = "") {
return `<em class="hpd-roster-count-breakdown" data-tooltip="${escapeHtml(tooltip)}" aria-label="${escapeHtml(tooltip)}"><b>${counts.total}</b><span>${counts.forwards}</span><span>${counts.defenders}</span><span>${counts.goalkeepers}</span></em>`;
}
// BUILD122 — operator line/five sorting.
function hockeyLinesGame() {
return getByPath(state.data, "hockey.selected_game") || null;
}
function hockeyLinesPlayers(side) {
const game = hockeyLinesGame();
const rows = game?.[side]?.players;
return Array.isArray(rows) ? rows : [];
}
function hockeyLineNumericValue(value) {
const raw = String(value || "").trim();
if (!raw) return 10000;
const number = Number(raw);
return Number.isFinite(number) ? number : 9000;
}
function hockeyLinePlayerNumberValue(player) {
const raw = String(player?.number || "").replace(/^#/, "").trim();
const number = Number(raw);
return Number.isFinite(number) ? number : 10000;
}
function hockeyLinePlayersSorted(side, lineNumber) {
const line = String(lineNumber || "").trim();
return hockeyLinesPlayers(side)
.filter((player) => String(player?.line || "").trim() === line)
.slice()
.sort((left, right) => {
const leftOrder = Number(left?.line_sort_order || 0) || 10000;
const rightOrder = Number(right?.line_sort_order || 0) || 10000;
return leftOrder - rightOrder
|| hockeyLinePlayerNumberValue(left) - hockeyLinePlayerNumberValue(right)
|| String(left?.name || "").localeCompare(String(right?.name || ""), undefined, { sensitivity: "base" });
});
}
function hockeyLineVisibleCount(side) {
const key = `hockeyLinesVisible:${side}`;
const maxAssigned = hockeyLinesPlayers(side).reduce((maximum, player) => {
const value = Number(String(player?.line || "").trim());
return Number.isFinite(value) ? Math.max(maximum, value) : maximum;
}, 0);
const current = Number(state.formValues[key] || 0);
const next = Math.max(4, maxAssigned, current);
state.formValues[key] = next;
return next;
}
function hockeyReindexLine(side, lineNumber) {
hockeyLinePlayersSorted(side, lineNumber).forEach((player, index) => {
player.line_sort_order = index + 1;
});
}
function hockeyMoveLinePlayer(side, playerId, targetLine, beforePlayerId = "") {
const players = hockeyLinesPlayers(side);
const player = players.find((item) => String(item?.external_id || item?.id || "") === String(playerId || ""));
if (!player) return false;
const sourceLine = String(player.line || "").trim();
const nextLine = String(targetLine || "").trim();
const targetPlayers = hockeyLinePlayersSorted(side, nextLine).filter((item) => item !== player);
let insertIndex = targetPlayers.length;
if (beforePlayerId) {
const found = targetPlayers.findIndex((item) => String(item?.external_id || item?.id || "") === String(beforePlayerId));
if (found >= 0) insertIndex = found;
}
player.line = nextLine;
targetPlayers.splice(insertIndex, 0, player);
targetPlayers.forEach((item, index) => { item.line_sort_order = index + 1; });
if (sourceLine !== nextLine) hockeyReindexLine(side, sourceLine);
return true;
}
async function hockeySaveLineAssignments(side) {
const game = hockeyLinesGame();
const gameId = String(game?.external_id || game?.id || "").trim();
if (!gameId) throw new Error("Сначала выберите матч");
const assignments = hockeyLinesPlayers(side).map((player) => ({
player_external_id: String(player?.external_id || player?.id || ""),
line_number: String(player?.line || "").trim(),
sort_order: Number(player?.line_sort_order || 0),
})).filter((item) => item.player_external_id);
const payload = await hockeyGameControlRequest(`/games/${encodeURIComponent(gameId)}/lines`, {
method: "PUT",
body: JSON.stringify({
side,
assignments,
language: hockeyGameControlLanguage(),
}),
});
state.data.hockey ||= {};
const current = state.data.hockey.selected_game || {};
current[side] ||= {};
current[side].players = Array.isArray(payload?.[side]?.players) ? payload[side].players : current[side].players;
if (payload?.player_statistics) current.player_statistics = payload.player_statistics;
if (payload?.season_player_statistics) current.season_player_statistics = payload.season_player_statistics;
state.data.hockey.selected_game = current;
return payload;
}
async function hockeyResetLineAssignments(side) {
const game = hockeyLinesGame();
const gameId = String(game?.external_id || game?.id || "").trim();
if (!gameId) throw new Error("Сначала выберите матч");
const payload = await hockeyGameControlRequest(`/games/${encodeURIComponent(gameId)}/lines`, {
method: "PUT",
body: JSON.stringify({
side,
assignments: [],
reset: true,
language: hockeyGameControlLanguage(),
}),
});
state.data.hockey ||= {};
const current = state.data.hockey.selected_game || {};
current[side] ||= {};
current[side].players = Array.isArray(payload?.[side]?.players) ? payload[side].players : current[side].players;
if (payload?.player_statistics) current.player_statistics = payload.player_statistics;
if (payload?.season_player_statistics) current.season_player_statistics = payload.season_player_statistics;
state.data.hockey.selected_game = current;
state.formValues[`hockeyLinesVisible:${side}`] = 0;
return payload;
}
function hockeyLinePlayerCard(side, player) {
const node = div("hockey-line-player");
const playerId = String(player?.external_id || player?.id || "");
node.draggable = true;
node.dataset.playerId = playerId;
node.dataset.side = side;
node.innerHTML = `
<span class="hockey-line-drag">⋮⋮</span>
<b>${escapeHtml(player?.number ? `#${player.number}` : "—")}</b>
<strong>${escapeHtml(player?.name || "Игрок")}</strong>
<small>${escapeHtml(player?.position || "")}</small>
`;
node.addEventListener("dragstart", (event) => {
const payload = JSON.stringify({ side, playerId });
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("application/x-hockey-line-player", payload);
event.dataTransfer.setData("text/plain", payload);
node.classList.add("is-dragging");
});
node.addEventListener("dragend", () => node.classList.remove("is-dragging"));
return node;
}
function hockeyReadLineDrag(event) {
try {
const raw = event.dataTransfer?.getData("application/x-hockey-line-player") || event.dataTransfer?.getData("text/plain") || "";
const payload = JSON.parse(raw);
if (!payload?.side || !payload?.playerId) return null;
return payload;
} catch (_) {
return null;
}
}
function hockeyLineDropZone(side, lineNumber, players) {
const zone = div("hockey-line-dropzone");
zone.dataset.side = side;
zone.dataset.line = String(lineNumber || "");
if (!players.length) zone.innerHTML = `<span>Перетащите игрока сюда</span>`;
players.forEach((player) => {
const card = hockeyLinePlayerCard(side, player);
card.addEventListener("dragover", (event) => {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "move";
card.classList.add("is-drop-before");
});
card.addEventListener("dragleave", () => card.classList.remove("is-drop-before"));
card.addEventListener("drop", async (event) => {
event.preventDefault();
event.stopPropagation();
card.classList.remove("is-drop-before");
const dragged = hockeyReadLineDrag(event);
if (!dragged || dragged.side !== side) return;
if (!hockeyMoveLinePlayer(side, dragged.playerId, lineNumber, String(player?.external_id || player?.id || ""))) return;
renderRuntime();
try {
await hockeySaveLineAssignments(side);
renderRuntime();
} catch (error) {
toast(`Не удалось сохранить пятёрки: ${error.message}`, true);
}
});
zone.appendChild(card);
});
zone.addEventListener("dragover", (event) => {
const dragged = hockeyReadLineDrag(event);
if (!dragged || dragged.side !== side) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
zone.classList.add("is-over");
});
zone.addEventListener("dragleave", (event) => {
if (!zone.contains(event.relatedTarget)) zone.classList.remove("is-over");
});
zone.addEventListener("drop", async (event) => {
if (event.target.closest(".hockey-line-player")) return;
event.preventDefault();
zone.classList.remove("is-over");
const dragged = hockeyReadLineDrag(event);
if (!dragged || dragged.side !== side) return;
if (!hockeyMoveLinePlayer(side, dragged.playerId, lineNumber)) return;
renderRuntime();
try {
await hockeySaveLineAssignments(side);
renderRuntime();
} catch (error) {
toast(`Не удалось сохранить пятёрки: ${error.message}`, true);
}
});
return zone;
}
function hockeyLineTeamColumn(side) {
const game = hockeyLinesGame() || {};
const team = game?.[side] || {};
const column = div(`hockey-lines-team side-${side}`);
const players = hockeyLinesPlayers(side);
const manualCount = players.filter((player) => Boolean(player?.line_manual)).length;
const header = div("hockey-lines-team-header");
header.innerHTML = `
<div><span>${side === "home" ? "ЛЕВАЯ КОМАНДА" : "ПРАВАЯ КОМАНДА"}</span><strong>${escapeHtml(team?.name || (side === "home" ? "Хозяева" : "Гости"))}</strong></div>
<em>${manualCount ? `ручн. ${manualCount}` : "API"}</em>
`;
const actions = div("hockey-lines-actions");
const addButton = document.createElement("button");
addButton.type = "button";
addButton.textContent = "+ Звено";
addButton.addEventListener("click", () => {
state.formValues[`hockeyLinesVisible:${side}`] = hockeyLineVisibleCount(side) + 1;
renderRuntime();
});
const resetButton = document.createElement("button");
resetButton.type = "button";
resetButton.textContent = "Вернуть из API";
resetButton.title = "Снять ручные назначения и вернуть последнее звено/порядок, полученные из API";
resetButton.addEventListener("click", async () => {
resetButton.disabled = true;
try {
await hockeyResetLineAssignments(side);
toast(`Пятёрки ${side === "home" ? "HOME" : "AWAY"}: восстановлены из API`);
renderRuntime();
} catch (error) {
resetButton.disabled = false;
toast(`Не удалось вернуть пятёрки: ${error.message}`, true);
}
});
actions.append(addButton, resetButton);
column.append(header, actions);
const unassigned = div("hockey-line-card is-unassigned");
const unassignedPlayers = hockeyLinePlayersSorted(side, "");
unassigned.innerHTML = `<header><strong>Без звена</strong><span>${unassignedPlayers.length}</span></header>`;
unassigned.appendChild(hockeyLineDropZone(side, "", unassignedPlayers));
column.appendChild(unassigned);
const count = hockeyLineVisibleCount(side);
for (let line = 1; line <= count; line += 1) {
const linePlayers = hockeyLinePlayersSorted(side, String(line));
const card = div("hockey-line-card");
card.innerHTML = `<header><strong>Звено ${line}</strong><span>${linePlayers.length}</span></header>`;
card.appendChild(hockeyLineDropZone(side, String(line), linePlayers));
column.appendChild(card);
}
return column;
}
function renderHockeyLinesWorkspace() {
const workspace = div("hockey-lines-workspace");
const game = hockeyLinesGame();
if (!game) {
workspace.innerHTML = `<div class="hockey-lines-empty"><strong>Выберите матч</strong><span>После загрузки матча здесь появятся составы для сортировки по звеньям.</span></div>`;
return workspace;
}
const intro = div("hockey-lines-intro");
intro.innerHTML = `<div><strong>Пятёрки / звенья</strong><span>Перетаскивайте игроков между звеньями и меняйте порядок внутри звена. Изменения сохраняются в базе автоматически.</span></div><small>SQL: hockey_game_rosters.line_number + line_sort_order</small>`;
workspace.append(intro);
const teams = div("hockey-lines-columns");
teams.append(hockeyLineTeamColumn("home"), hockeyLineTeamColumn("away"));
workspace.appendChild(teams);
return workspace;
}
function hockeyTeamName(component, side) {
const path = side === "home" ? component.props?.homeTeamPath : component.props?.awayTeamPath;
return formatValue(getByPath(state.data, path), side === "home" ? "Хозяева" : "Гости");
@@ -13272,7 +13582,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
}
function runtimeHasFixedHockeyCanvas() {
if (state.activeTab === "prepared_titles") return true;
if (["prepared_titles", "lines"].includes(state.activeTab)) return true;
return state.config.components.some((component) =>
[
"hockey_penalty_dashboard",
@@ -13382,6 +13692,8 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
if (state.preparedTitlesLoadedKey !== hockeyPreparedWorkspaceKey() && !state.preparedTitlesLoading) {
setTimeout(() => hockeyLoadPreparedTitlesWorkspace({ force: true }).catch(() => {}), 0);
}
} else if (state.activeTab === "lines") {
el.runtimeStage.appendChild(renderHockeyLinesWorkspace());
} else {
state.config.components.filter((component) => !component.hidden && component.props?.runtimeHidden !== true && state.runtimeVisibility[component.action_id] !== false && isEffectivelyOnActiveTab(component)).sort((a, b) => Number(a.z) - Number(b.z)).forEach((component) => {
const wrapper = document.createElement("div"); wrapper.className = "runtime-component"; Object.assign(wrapper.style, { left: `${component.x}px`, top: `${component.y}px`, width: `${component.w}px`, height: `${component.h}px`, zIndex: String(component.z) }); wrapper.appendChild(renderComponent(component, true)); el.runtimeStage.appendChild(wrapper);

View File

@@ -7778,3 +7778,26 @@ body.hockey-navigation-open .runtime-viewport.has-hockey-pbp { gap: 14px !import
.quick-score-team{display:none}
.quick-score-control{padding-inline:4px}
}
/* BUILD122 — drag-and-drop hockey line/five sorter. */
.hockey-lines-workspace{
position:absolute;inset:0;display:grid;grid-template-rows:auto minmax(0,1fr);gap:10px;
padding:14px;box-sizing:border-box;color:#dcecff;background:linear-gradient(180deg,#0a1522 0%,#09131f 100%);overflow:hidden;
}
.hockey-lines-intro{
min-height:54px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 13px;
border:1px solid #263f56;border-radius:11px;background:#0c1b2a;
}
.hockey-lines-intro>div{min-width:0;display:grid;gap:3px}.hockey-lines-intro strong{font-size:15px}.hockey-lines-intro span{color:#7f96ad;font-size:8px}.hockey-lines-intro small{color:#56e3c1;font-size:8px;font-weight:850;white-space:nowrap}
.hockey-lines-columns{min-height:0;display:grid;grid-template-columns:1fr 1fr;gap:12px;overflow:hidden}
.hockey-lines-team{min-width:0;min-height:0;display:flex;flex-direction:column;gap:8px;padding:10px;border:1px solid #243a50;border-radius:12px;background:#0b1725;overflow-y:auto}
.hockey-lines-team.side-home{box-shadow:inset 3px 0 0 #4d9cff}.hockey-lines-team.side-away{box-shadow:inset -3px 0 0 #ff5f79}
.hockey-lines-team-header{display:flex;align-items:center;justify-content:space-between;gap:10px;position:sticky;top:-10px;z-index:3;padding:7px 4px 8px;background:#0b1725}
.hockey-lines-team-header>div{min-width:0;display:grid;gap:2px}.hockey-lines-team-header span{color:#70879f;font-size:7px;font-weight:950;letter-spacing:.09em}.hockey-lines-team-header strong{overflow:hidden;font-size:13px;text-overflow:ellipsis;white-space:nowrap}.hockey-lines-team-header em{padding:4px 8px;border:1px solid #2b475f;border-radius:999px;color:#80a0ba;background:#102235;font-size:7px;font-style:normal;font-weight:900}
.hockey-lines-actions{display:flex;gap:6px}.hockey-lines-actions button{min-height:28px;padding:0 10px;border:1px solid #31516a;border-radius:7px;color:#cfe3f3;background:#10263a;font-size:8px;font-weight:900;cursor:pointer}.hockey-lines-actions button:hover{border-color:#54dcbf;color:#fff}.hockey-lines-actions button:disabled{opacity:.45;cursor:default}
.hockey-line-card{display:grid;gap:6px;padding:7px;border:1px solid #22394f;border-radius:10px;background:#0e1d2c}.hockey-line-card.is-unassigned{border-style:dashed;background:#0a1622}
.hockey-line-card>header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 2px}.hockey-line-card>header strong{font-size:9px}.hockey-line-card>header span{display:grid;place-items:center;min-width:22px;height:18px;padding:0 5px;border-radius:999px;color:#7e97af;background:#14283b;font-size:7px;font-weight:950}
.hockey-line-dropzone{min-height:38px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:5px;padding:4px;border:1px solid transparent;border-radius:8px;transition:border-color .12s,background .12s}
.hockey-line-dropzone>span{grid-column:1/-1;align-self:center;color:#4f6880;font-size:8px;text-align:center}.hockey-line-dropzone.is-over{border-color:#54dcbf;background:rgba(84,220,191,.06)}
.hockey-line-player{min-width:0;display:grid;grid-template-columns:12px 31px minmax(0,1fr);grid-template-rows:auto auto;column-gap:5px;align-items:center;padding:5px 6px;border:1px solid #29445c;border-radius:8px;background:#102235;cursor:grab;transition:transform .1s,border-color .1s,opacity .1s}.hockey-line-player:hover{border-color:#47708f;transform:translateY(-1px)}.hockey-line-player.is-dragging{opacity:.35}.hockey-line-player.is-drop-before{border-color:#54e5c4;box-shadow:inset 3px 0 0 #54e5c4}.hockey-line-drag{grid-row:1/3;color:#58738c;font-size:9px;letter-spacing:-2px}.hockey-line-player b{grid-row:1/3;display:grid;place-items:center;min-height:27px;border-radius:6px;color:#dcecff;background:#0a1724;font-size:8px}.hockey-line-player strong{min-width:0;overflow:hidden;font-size:9px;text-overflow:ellipsis;white-space:nowrap}.hockey-line-player small{min-width:0;overflow:hidden;color:#6f879e;font-size:7px;text-overflow:ellipsis;white-space:nowrap}
.hockey-lines-empty{position:absolute;inset:0;display:grid;place-content:center;gap:6px;color:#7890a8;text-align:center}.hockey-lines-empty strong{color:#d9e8f6;font-size:17px}.hockey-lines-empty span{font-size:9px}