создан механизм для создания меню для разных сравнений через drap-and-drop
This commit is contained in:
@@ -52,6 +52,7 @@
|
||||
prematch_groups: [],
|
||||
prematch_buttons: [],
|
||||
quick_panel_selectors: [],
|
||||
player_selection_panels: [],
|
||||
},
|
||||
data: {},
|
||||
sources: [],
|
||||
@@ -86,6 +87,8 @@
|
||||
hockeyPenaltyMappingContextSignature: "",
|
||||
hockeyPenaltyMappingContextPending: false,
|
||||
hockeyPenaltyMappingContextQueued: false,
|
||||
hockeyPlayerPanelSeedPending: false,
|
||||
hockeyPlayerPanelSeedSignature: "",
|
||||
vmixFinishOverlayTimers: new Map(),
|
||||
vmixStrengthMappingRefreshPending: false,
|
||||
vmixStrengthMappingRefreshQueued: null,
|
||||
@@ -2460,6 +2463,31 @@ function startCustomTooltips() {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePlayerSelectionPanels(rawPanels) {
|
||||
const source = Array.isArray(rawPanels) ? rawPanels : [];
|
||||
const seen = new Set();
|
||||
const allowedRules = new Set(["any", "home", "away", "different_teams"]);
|
||||
return source.slice(0, 16).map((panel, index) => {
|
||||
let id = String(panel?.id || `players_${index + 1}`).trim().replace(/[^A-Za-z0-9_]+/g, "_").slice(0, 48).replace(/^_+|_+$/g, "") || `players_${index + 1}`;
|
||||
if (!/^[A-Za-z]/.test(id)) id = `p_${id}`.slice(0, 48);
|
||||
const base = id; let suffix = 2;
|
||||
while (seen.has(id)) id = `${base}_${suffix++}`.slice(0, 48);
|
||||
seen.add(id);
|
||||
const rule = allowedRules.has(String(panel?.rule || "any")) ? String(panel.rule || "any") : "any";
|
||||
return {
|
||||
id,
|
||||
label: String(panel?.label || `Игроки ${index + 1}`).slice(0, 80),
|
||||
description: String(panel?.description || "").slice(0, 300),
|
||||
slots: clamp(Number(panel?.slots) || 1, 1, 6),
|
||||
rule,
|
||||
sync_selected_player: Boolean(panel?.sync_selected_player),
|
||||
collapsed_default: panel?.collapsed_default !== false,
|
||||
sort_order: Number.isFinite(Number(panel?.sort_order)) ? Number(panel.sort_order) : index * 10,
|
||||
enabled: panel?.enabled !== false,
|
||||
};
|
||||
}).sort((a, b) => Number(a.sort_order) - Number(b.sort_order) || a.label.localeCompare(b.label, "ru"));
|
||||
}
|
||||
|
||||
function normalizeShortcutSequence(sequence = {}, index = 0) {
|
||||
return {
|
||||
id: String(sequence.id || `sequence-${index + 1}-${Math.random().toString(36).slice(2, 7)}`),
|
||||
@@ -2541,6 +2569,7 @@ function startCustomTooltips() {
|
||||
group_id: validPrematchGroups.has(selector.group_id) ? selector.group_id : "",
|
||||
button_id: validPrematchButtons.has(selector.button_id) ? selector.button_id : "",
|
||||
}));
|
||||
state.config.player_selection_panels = normalizePlayerSelectionPanels(state.config.player_selection_panels);
|
||||
ensureHockeyQuickCommandWorkspace();
|
||||
const validIds = new Set();
|
||||
const usedActionIds = new Set();
|
||||
@@ -4210,6 +4239,7 @@ function startCustomTooltips() {
|
||||
// sequence, that is the strongest signal and needs no fallback.
|
||||
if (sequenceStillOwnsRuntimeOverlay(id)) {
|
||||
state.quickPanelOnAirSequences.add(id);
|
||||
state.shortcutSequenceOverlayState.set(id, true);
|
||||
refreshQuickPanelOnAirClasses();
|
||||
return true;
|
||||
}
|
||||
@@ -4232,6 +4262,7 @@ function startCustomTooltips() {
|
||||
}
|
||||
if (active) state.quickPanelOnAirSequences.add(id);
|
||||
else state.quickPanelOnAirSequences.delete(id);
|
||||
state.shortcutSequenceOverlayState.set(id, active);
|
||||
refreshQuickPanelOnAirClasses();
|
||||
return active;
|
||||
}
|
||||
@@ -4317,7 +4348,10 @@ function startCustomTooltips() {
|
||||
const key = String(layer);
|
||||
const previous = state.vmixOverlayRuntime.get(key) || null;
|
||||
state.vmixOverlayRuntime.delete(String(layer));
|
||||
if (previous?.sequence_id) setQuickPanelSequenceOnAir(previous.sequence_id, false);
|
||||
if (previous?.sequence_id) {
|
||||
setQuickPanelSequenceOnAir(previous.sequence_id, false);
|
||||
state.shortcutSequenceOverlayState.set(String(previous.sequence_id), false);
|
||||
}
|
||||
return previous;
|
||||
};
|
||||
const setLayer = (layer, command) => {
|
||||
@@ -4331,8 +4365,12 @@ function startCustomTooltips() {
|
||||
});
|
||||
if (previous?.sequence_id && String(previous.sequence_id) !== ownerSequenceId) {
|
||||
setQuickPanelSequenceOnAir(previous.sequence_id, false);
|
||||
state.shortcutSequenceOverlayState.set(String(previous.sequence_id), false);
|
||||
}
|
||||
if (ownerSequenceId) {
|
||||
setQuickPanelSequenceOnAir(ownerSequenceId, true);
|
||||
state.shortcutSequenceOverlayState.set(ownerSequenceId, true);
|
||||
}
|
||||
if (ownerSequenceId) setQuickPanelSequenceOnAir(ownerSequenceId, true);
|
||||
};
|
||||
(commands || []).forEach((command) => {
|
||||
const fn = String(command?.Function || "").trim();
|
||||
@@ -4349,14 +4387,20 @@ function startCustomTooltips() {
|
||||
setLayer(parsed.layer, command);
|
||||
} else if (parsed.action === "Out" || parsed.action === "Off") {
|
||||
clearLayer(parsed.layer);
|
||||
if (ownerSequenceId) setQuickPanelSequenceOnAir(ownerSequenceId, false);
|
||||
if (ownerSequenceId) {
|
||||
setQuickPanelSequenceOnAir(ownerSequenceId, false);
|
||||
state.shortcutSequenceOverlayState.set(ownerSequenceId, false);
|
||||
}
|
||||
} else if (parsed.action === "toggle") {
|
||||
const current = state.vmixOverlayRuntime.get(String(parsed.layer));
|
||||
const sameOwner = Boolean(current && ownerSequenceId && String(current.sequence_id || "") === ownerSequenceId);
|
||||
const sameInput = Boolean(current && String(current.input || "") === String(command?.Input || ""));
|
||||
if (current && (sameOwner || sameInput || !ownerSequenceId)) {
|
||||
clearLayer(parsed.layer);
|
||||
if (ownerSequenceId) setQuickPanelSequenceOnAir(ownerSequenceId, false);
|
||||
if (ownerSequenceId) {
|
||||
setQuickPanelSequenceOnAir(ownerSequenceId, false);
|
||||
state.shortcutSequenceOverlayState.set(ownerSequenceId, false);
|
||||
}
|
||||
} else {
|
||||
setLayer(parsed.layer, command);
|
||||
}
|
||||
@@ -7530,7 +7574,7 @@ function hockeyPrematchSequenceOptions(selectedId = "") {
|
||||
return triggerSelectOptions(options, selectedId);
|
||||
}
|
||||
|
||||
async function hockeyPersistPrematchButtons(buttons, groups = state.config.prematch_groups, selectors = state.config.quick_panel_selectors) {
|
||||
async function hockeyPersistPrematchButtons(buttons, groups = state.config.prematch_groups, selectors = state.config.quick_panel_selectors, playerPanels = state.config.player_selection_panels) {
|
||||
const normalizedGroups = normalizePrematchGroups(groups);
|
||||
const validGroups = new Set(normalizedGroups.map((group) => group.id));
|
||||
const normalizedButtons = normalizePrematchButtons(buttons).map((button) => ({
|
||||
@@ -7546,13 +7590,15 @@ async function hockeyPersistPrematchButtons(buttons, groups = state.config.prema
|
||||
state.config.prematch_groups = normalizedGroups;
|
||||
state.config.prematch_buttons = normalizedButtons;
|
||||
state.config.quick_panel_selectors = normalizedSelectors;
|
||||
const normalizedPlayerPanels = normalizePlayerSelectionPanels(playerPanels);
|
||||
state.config.player_selection_panels = normalizedPlayerPanels;
|
||||
try {
|
||||
const response = await fetch("/api/hockey/ui/prematch-buttons", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prematch_groups: normalizedGroups, prematch_buttons: normalizedButtons, quick_panel_selectors: normalizedSelectors }),
|
||||
body: JSON.stringify({ prematch_groups: normalizedGroups, prematch_buttons: normalizedButtons, quick_panel_selectors: normalizedSelectors, player_selection_panels: normalizedPlayerPanels }),
|
||||
});
|
||||
let payload = {};
|
||||
try { payload = await response.json(); } catch (_) {}
|
||||
@@ -7562,6 +7608,7 @@ async function hockeyPersistPrematchButtons(buttons, groups = state.config.prema
|
||||
state.config.prematch_groups = normalizePrematchGroups(payload.prematch_groups || normalizedGroups);
|
||||
state.config.prematch_buttons = normalizePrematchButtons(payload.prematch_buttons || normalizedButtons);
|
||||
state.config.quick_panel_selectors = normalizeQuickPanelSelectors(payload.quick_panel_selectors || normalizedSelectors);
|
||||
state.config.player_selection_panels = normalizePlayerSelectionPanels(payload.player_selection_panels || normalizedPlayerPanels);
|
||||
}
|
||||
ensureConfig();
|
||||
toast("Нижняя панель кнопок сохранена");
|
||||
@@ -7607,6 +7654,7 @@ function openHockeyPrematchButtonsEditor() {
|
||||
let draftGroups = normalizePrematchGroups(state.config.prematch_groups);
|
||||
let draft = normalizePrematchButtons(state.config.prematch_buttons);
|
||||
let draftSelectors = normalizeQuickPanelSelectors(state.config.quick_panel_selectors);
|
||||
let draftPlayerPanels = normalizePlayerSelectionPanels(state.config.player_selection_panels);
|
||||
let dragButtonId = "";
|
||||
let dragSelectorId = "";
|
||||
let activeEditorGroupId = String(state.quickPanelActiveTab || "");
|
||||
@@ -7669,6 +7717,21 @@ function openHockeyPrematchButtonsEditor() {
|
||||
<button type="button" class="icon-btn danger" data-quick-selector-delete="${escapeHtml(selector.id)}" title="Удалить переключатель">×</button>
|
||||
</article>`;
|
||||
|
||||
const renderPlayerPanelRow = (panel, index) => `
|
||||
<article class="player-selection-editor-row" data-player-panel-row="${escapeHtml(panel.id)}">
|
||||
<span class="shortcut-target-index">${index + 1}</span>
|
||||
<label class="shortcut-inline-check"><input type="checkbox" data-player-panel-field="enabled" data-player-panel-id="${escapeHtml(panel.id)}" ${panel.enabled ? "checked" : ""}> Активен</label>
|
||||
<label>Название<input type="text" maxlength="80" data-player-panel-field="label" data-player-panel-id="${escapeHtml(panel.id)}" value="${escapeHtml(panel.label)}"></label>
|
||||
<label>ID для Mapping<input type="text" maxlength="48" data-player-panel-field="id" data-player-panel-id="${escapeHtml(panel.id)}" value="${escapeHtml(panel.id)}"></label>
|
||||
<label>Слотов<input type="number" min="1" max="6" data-player-panel-field="slots" data-player-panel-id="${escapeHtml(panel.id)}" value="${panel.slots}"></label>
|
||||
<label>Правило<select data-player-panel-field="rule" data-player-panel-id="${escapeHtml(panel.id)}">${triggerSelectOptions([["any","Любые игроки"],["home","Только левая команда"],["away","Только правая команда"],["different_teams","Разные команды"]], panel.rule)}</select></label>
|
||||
<label class="shortcut-inline-check"><input type="checkbox" data-player-panel-field="sync_selected_player" data-player-panel-id="${escapeHtml(panel.id)}" ${panel.sync_selected_player ? "checked" : ""}> Главный выбранный игрок</label>
|
||||
<label class="shortcut-inline-check"><input type="checkbox" data-player-panel-field="collapsed_default" data-player-panel-id="${escapeHtml(panel.id)}" ${panel.collapsed_default ? "checked" : ""}> По умолчанию свёрнут</label>
|
||||
<label class="player-selection-editor-description">Описание<input type="text" maxlength="300" data-player-panel-field="description" data-player-panel-id="${escapeHtml(panel.id)}" value="${escapeHtml(panel.description)}"></label>
|
||||
<div class="player-selection-editor-order"><button type="button" class="mini-btn" data-player-panel-up="${escapeHtml(panel.id)}" ${index <= 0 ? "disabled" : ""}>↑</button><button type="button" class="mini-btn" data-player-panel-down="${escapeHtml(panel.id)}" ${index >= draftPlayerPanels.length - 1 ? "disabled" : ""}>↓</button></div>
|
||||
<button type="button" class="icon-btn danger" data-player-panel-delete="${escapeHtml(panel.id)}" title="Удалить блок">×</button>
|
||||
</article>`;
|
||||
|
||||
const renderGroup = (group, groupIndex) => {
|
||||
const groupId = group?.id || "";
|
||||
const buttons = groupButtons(groupId);
|
||||
@@ -7699,6 +7762,7 @@ function openHockeyPrematchButtonsEditor() {
|
||||
draft = normalizePrematchButtons(draft).map((button) => ({ ...button, group_id: validGroups.has(button.group_id) ? button.group_id : "" }));
|
||||
const validButtons = new Set(draft.map((button) => button.id));
|
||||
draftSelectors = normalizeQuickPanelSelectors(draftSelectors).map((selector) => ({ ...selector, group_id: validGroups.has(selector.group_id) ? selector.group_id : "", button_id: validButtons.has(selector.button_id) ? selector.button_id : "" }));
|
||||
draftPlayerPanels = normalizePlayerSelectionPanels(draftPlayerPanels);
|
||||
showSettingsModal("Нижняя панель · кнопки", `
|
||||
<div class="prematch-editor">
|
||||
<div class="prematch-editor-head">
|
||||
@@ -7713,6 +7777,10 @@ function openHockeyPrematchButtonsEditor() {
|
||||
${renderGroup(null, -1)}
|
||||
${draftGroups.map((group, index) => renderGroup(group, index)).join("")}
|
||||
</div>
|
||||
<section class="player-selection-editor">
|
||||
<header><div><span>DRAG & DROP ИГРОКОВ</span><strong>Блоки выбора игроков</strong><small>Название ID автоматически формирует идентификаторы Mapping: player_select.<ID>.player1_id и т. д.</small></div><button type="button" class="btn" data-player-panel-add>+ Блок игроков</button></header>
|
||||
<div class="player-selection-editor-list">${draftPlayerPanels.length ? draftPlayerPanels.map(renderPlayerPanelRow).join("") : `<div class="prematch-group-empty">Блоков игроков пока нет</div>`}</div>
|
||||
</section>
|
||||
<div class="prematch-editor-actions">
|
||||
<button type="button" class="btn" data-prematch-cancel>Отмена</button>
|
||||
<button type="button" class="btn btn-accent" data-prematch-save>Сохранить и опубликовать</button>
|
||||
@@ -7735,6 +7803,12 @@ function openHockeyPrematchButtonsEditor() {
|
||||
draftSelectors.push({ id: `selector_${draftSelectors.length + 1}`, label: `Период ${draftSelectors.length + 1}`, description: "", group_id: targetGroup, button_id: targetButton, style: "segments", options: [{value:"all",label:"МАТЧ"},{value:"1",label:"1"},{value:"2",label:"2"},{value:"3",label:"3"}], default_value: "all", sort_order: draftSelectors.length * 10, enabled: true });
|
||||
renderEditor();
|
||||
});
|
||||
document.querySelector("[data-player-panel-add]")?.addEventListener("click", () => {
|
||||
if (draftPlayerPanels.length >= 16) return toast("Можно создать до 16 блоков игроков", true);
|
||||
draftPlayerPanels.push({ id: `players_${draftPlayerPanels.length + 1}`, label: `Игроки ${draftPlayerPanels.length + 1}`, description: "", slots: 2, rule: "any", sync_selected_player: false, collapsed_default: true, sort_order: draftPlayerPanels.length * 10, enabled: true });
|
||||
renderEditor();
|
||||
});
|
||||
|
||||
document.querySelector("[data-prematch-add]")?.addEventListener("click", () => {
|
||||
if (draft.length >= 64) return toast("Можно создать до 64 кнопок", true);
|
||||
const targetGroup = draftGroups.some((group) => group.id === activeEditorGroupId) ? activeEditorGroupId : "";
|
||||
@@ -7807,6 +7881,46 @@ function openHockeyPrematchButtonsEditor() {
|
||||
renderEditor();
|
||||
}));
|
||||
|
||||
document.querySelectorAll("[data-player-panel-field]").forEach((control) => {
|
||||
const field = control.dataset.playerPanelField;
|
||||
// Changing the system ID also changes every data-player-panel-id attribute
|
||||
// in this editor row. Commit it on blur/change and rebuild the row at once,
|
||||
// otherwise the remaining controls would still point at the old ID.
|
||||
const eventName = field === "id" || control.type === "checkbox" || control.tagName === "SELECT" || control.type === "number" ? "change" : "input";
|
||||
control.addEventListener(eventName, () => {
|
||||
const panel = draftPlayerPanels.find((item) => item.id === control.dataset.playerPanelId);
|
||||
if (!panel) return;
|
||||
const previousId = panel.id;
|
||||
panel[field] = control.type === "checkbox" ? control.checked : (control.type === "number" ? clamp(Number(control.value) || 1, 1, 6) : control.value);
|
||||
if (field === "id") {
|
||||
const normalized = normalizePlayerSelectionPanels(draftPlayerPanels);
|
||||
draftPlayerPanels = normalized;
|
||||
const updated = draftPlayerPanels.find((item) => item.id === String(panel.id || "").trim().replace(/[^A-Za-z0-9_]+/g, "_").replace(/^_+|_+$/g, ""))
|
||||
|| draftPlayerPanels.find((item) => item.sort_order === panel.sort_order && item.label === panel.label);
|
||||
if (updated && previousId !== updated.id) toast(`ID блока: ${updated.id}`);
|
||||
renderEditor();
|
||||
}
|
||||
});
|
||||
});
|
||||
document.querySelectorAll("[data-player-panel-up]").forEach((button) => button.addEventListener("click", () => {
|
||||
const index = draftPlayerPanels.findIndex((item) => item.id === button.dataset.playerPanelUp);
|
||||
if (index > 0) [draftPlayerPanels[index - 1], draftPlayerPanels[index]] = [draftPlayerPanels[index], draftPlayerPanels[index - 1]];
|
||||
draftPlayerPanels.forEach((item, i) => { item.sort_order = i * 10; });
|
||||
renderEditor();
|
||||
}));
|
||||
document.querySelectorAll("[data-player-panel-down]").forEach((button) => button.addEventListener("click", () => {
|
||||
const index = draftPlayerPanels.findIndex((item) => item.id === button.dataset.playerPanelDown);
|
||||
if (index >= 0 && index < draftPlayerPanels.length - 1) [draftPlayerPanels[index + 1], draftPlayerPanels[index]] = [draftPlayerPanels[index], draftPlayerPanels[index + 1]];
|
||||
draftPlayerPanels.forEach((item, i) => { item.sort_order = i * 10; });
|
||||
renderEditor();
|
||||
}));
|
||||
document.querySelectorAll("[data-player-panel-delete]").forEach((button) => button.addEventListener("click", () => {
|
||||
const panel = draftPlayerPanels.find((item) => item.id === button.dataset.playerPanelDelete);
|
||||
if (!panel || !window.confirm(`Удалить блок «${panel.label}»? Сохранённые значения матча останутся в базе, но блок исчезнет из интерфейса.`)) return;
|
||||
draftPlayerPanels = draftPlayerPanels.filter((item) => item.id !== panel.id);
|
||||
renderEditor();
|
||||
}));
|
||||
|
||||
document.querySelectorAll("[data-quick-selector-field]").forEach((control) => {
|
||||
const eventName = control.type === "checkbox" || control.tagName === "SELECT" ? "change" : "input";
|
||||
control.addEventListener(eventName, () => {
|
||||
@@ -7907,9 +8021,18 @@ function openHockeyPrematchButtonsEditor() {
|
||||
const options = parseQuickPanelSelectorOptions(control.value);
|
||||
if (options.length) selector.options = options;
|
||||
});
|
||||
document.querySelectorAll("[data-player-panel-row]").forEach((row) => {
|
||||
const panel = draftPlayerPanels.find((item) => item.id === row.dataset.playerPanelRow);
|
||||
if (!panel) return;
|
||||
row.querySelectorAll("[data-player-panel-field]").forEach((control) => {
|
||||
const field = control.dataset.playerPanelField;
|
||||
panel[field] = control.type === "checkbox" ? control.checked : (control.type === "number" ? clamp(Number(control.value) || 1, 1, 6) : control.value);
|
||||
});
|
||||
});
|
||||
draftGroups.forEach((item, index) => { item.sort_order = index * 10; });
|
||||
draftPlayerPanels.forEach((item, index) => { item.sort_order = index * 10; });
|
||||
for (const groupId of ["", ...draftGroups.map((group) => group.id)]) renumberGroup(groupId);
|
||||
if (await hockeyPersistPrematchButtons(draft, draftGroups, draftSelectors)) closeModal();
|
||||
if (await hockeyPersistPrematchButtons(draft, draftGroups, draftSelectors, draftPlayerPanels)) closeModal();
|
||||
});
|
||||
};
|
||||
renderEditor();
|
||||
@@ -8238,6 +8361,275 @@ function hockeyEventCategoryLabels(language) {
|
||||
: {all:"Все",goal:"Гол",penalty:"Удаление",shot:"Бросок",shootout:"Буллит",period:"Период",timeout:"Тайм-аут",goalie:"Вратарь",comment:"Комментарий",info:"Событие"};
|
||||
}
|
||||
|
||||
function hockeyPlayerSelectionPanels() {
|
||||
return normalizePlayerSelectionPanels(state.config.player_selection_panels).filter((panel) => panel.enabled !== false);
|
||||
}
|
||||
|
||||
function hockeyPlayerSelectionValueKey(panelId, slot, field) {
|
||||
return `player_select.${panelId}.player${slot}_${field}`;
|
||||
}
|
||||
|
||||
function hockeyPlayerSelectionTeamId(player) {
|
||||
if (!player) return "";
|
||||
const side = player.side === "away" ? "away" : player.side === "home" ? "home" : "";
|
||||
const raw = player.raw || {};
|
||||
const direct = raw.team_external_id || raw.team_id || raw.club_external_id || raw.club_id || raw.team?.external_id || raw.team?.id || "";
|
||||
if (direct) return String(direct);
|
||||
if (!side) return "";
|
||||
const team = getByPath(state.data, `hockey.selected_game.${side}`) || {};
|
||||
return String(team.external_id || team.team_external_id || team.team_id || team.club_id || team.id || "");
|
||||
}
|
||||
|
||||
function hockeyPlayerSelectionSlot(panel, slot) {
|
||||
const values = hockeyMatchRuntimeValues();
|
||||
const read = (field) => String(values[hockeyPlayerSelectionValueKey(panel.id, slot, field)] ?? "");
|
||||
return {
|
||||
id: read("id"),
|
||||
dbId: read("db_id"),
|
||||
teamId: read("team_id"),
|
||||
side: read("side"),
|
||||
number: read("number"),
|
||||
name: read("name"),
|
||||
};
|
||||
}
|
||||
|
||||
function hockeyPlayerSelectionRuleLabel(rule) {
|
||||
return ({ any: "любые команды", home: "только левая", away: "только правая", different_teams: "разные команды" })[rule] || "любые команды";
|
||||
}
|
||||
|
||||
function hockeyPlayerSelectionAllows(panel, slot, player) {
|
||||
const side = String(player?.side || "");
|
||||
if (panel.rule === "home" && side !== "home") return { ok: false, message: "В этот блок можно добавлять только игроков левой команды" };
|
||||
if (panel.rule === "away" && side !== "away") return { ok: false, message: "В этот блок можно добавлять только игроков правой команды" };
|
||||
if (panel.rule === "different_teams") {
|
||||
const occupiedSides = [];
|
||||
for (let index = 1; index <= panel.slots; index += 1) {
|
||||
if (index === slot) continue;
|
||||
const current = hockeyPlayerSelectionSlot(panel, index);
|
||||
if (current.id || current.dbId) occupiedSides.push(current.side);
|
||||
}
|
||||
if (side && occupiedSides.includes(side)) return { ok: false, message: "В этом блоке игроки должны быть из разных команд" };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function hockeySyncLegacySelectedPlayer(player, teamId = "") {
|
||||
const gameId = hockeyTimerSelectedGameId();
|
||||
if (!gameId) return false;
|
||||
const compact = player ? hockeyCompactPlayer(player) : null;
|
||||
try {
|
||||
const response = await fetch("/api/hockey/context/batch", {
|
||||
method: "POST", cache: "no-store", credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
values: {
|
||||
selected_player_id: compact?.externalId || "",
|
||||
selected_player_team_id: String(teamId || ""),
|
||||
},
|
||||
context: {
|
||||
game_id: gameId,
|
||||
device_id: currentRuntimeVmixDeviceId(),
|
||||
session_token: currentRuntimeHockeySessionToken(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
return response.ok;
|
||||
} catch (_) { return false; }
|
||||
}
|
||||
|
||||
function hockeyPlayerSelectionPatch(panel, slot, player = null) {
|
||||
const compact = player ? hockeyCompactPlayer(player) : null;
|
||||
const teamId = player ? hockeyPlayerSelectionTeamId(player) : "";
|
||||
return {
|
||||
[`player_select.${panel.id}._label`]: panel.label,
|
||||
[`player_select.${panel.id}._slots`]: String(panel.slots),
|
||||
[`player_select.${panel.id}._rule`]: panel.rule,
|
||||
[hockeyPlayerSelectionValueKey(panel.id, slot, "id")]: compact?.externalId || "",
|
||||
[hockeyPlayerSelectionValueKey(panel.id, slot, "db_id")]: compact?.dbId || "",
|
||||
[hockeyPlayerSelectionValueKey(panel.id, slot, "team_id")]: teamId,
|
||||
[hockeyPlayerSelectionValueKey(panel.id, slot, "side")]: compact?.side || "",
|
||||
[hockeyPlayerSelectionValueKey(panel.id, slot, "number")]: compact?.number || "",
|
||||
[hockeyPlayerSelectionValueKey(panel.id, slot, "name")]: compact?.name || "",
|
||||
};
|
||||
}
|
||||
|
||||
async function hockeySetPlayerSelectionSlot(panel, slot, player) {
|
||||
if (!player) return false;
|
||||
const validation = hockeyPlayerSelectionAllows(panel, slot, player);
|
||||
if (!validation.ok) {
|
||||
toast(validation.message, true);
|
||||
return false;
|
||||
}
|
||||
await hockeySetMatchValues(hockeyPlayerSelectionPatch(panel, slot, player), { refreshMapping: true });
|
||||
if (panel.sync_selected_player) await hockeySyncLegacySelectedPlayer(player, hockeyPlayerSelectionTeamId(player));
|
||||
toast(`${panel.label}: игрок ${slot} — ${player.name || player.id}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function hockeyClearPlayerSelectionSlot(panel, slot) {
|
||||
await hockeySetMatchValues(hockeyPlayerSelectionPatch(panel, slot, null), { refreshMapping: true });
|
||||
if (panel.sync_selected_player) await hockeySyncLegacySelectedPlayer(null, "");
|
||||
return true;
|
||||
}
|
||||
|
||||
async function hockeyClearPlayerSelectionPanel(panel) {
|
||||
const patch = {
|
||||
[`player_select.${panel.id}._label`]: panel.label,
|
||||
[`player_select.${panel.id}._slots`]: String(panel.slots),
|
||||
[`player_select.${panel.id}._rule`]: panel.rule,
|
||||
};
|
||||
for (let slot = 1; slot <= panel.slots; slot += 1) Object.assign(patch, hockeyPlayerSelectionPatch(panel, slot, null));
|
||||
await hockeySetMatchValues(patch, { refreshMapping: true });
|
||||
if (panel.sync_selected_player) await hockeySyncLegacySelectedPlayer(null, "");
|
||||
}
|
||||
|
||||
async function hockeyEnsurePlayerSelectionRuntimeValues() {
|
||||
const gameId = hockeyTimerSelectedGameId();
|
||||
const panels = hockeyPlayerSelectionPanels();
|
||||
if (!gameId || !panels.length || state.hockeyPlayerPanelSeedPending) return false;
|
||||
const current = hockeyMatchRuntimeValues();
|
||||
const patch = {};
|
||||
for (const panel of panels) {
|
||||
const meta = {
|
||||
[`player_select.${panel.id}._label`]: panel.label,
|
||||
[`player_select.${panel.id}._slots`]: String(panel.slots),
|
||||
[`player_select.${panel.id}._rule`]: panel.rule,
|
||||
};
|
||||
Object.entries(meta).forEach(([key, value]) => { if (!(key in current) || String(current[key]) !== String(value)) patch[key] = value; });
|
||||
for (let slot = 1; slot <= panel.slots; slot += 1) {
|
||||
for (const field of ["id", "db_id", "team_id", "side", "number", "name"]) {
|
||||
const key = hockeyPlayerSelectionValueKey(panel.id, slot, field);
|
||||
if (!(key in current)) patch[key] = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
const entries = Object.entries(patch);
|
||||
if (!entries.length) return false;
|
||||
const signature = `${gameId}|${entries.map(([key, value]) => `${key}=${value}`).join("|")}`;
|
||||
if (state.hockeyPlayerPanelSeedSignature === signature) return false;
|
||||
state.hockeyPlayerPanelSeedSignature = signature;
|
||||
state.hockeyPlayerPanelSeedPending = true;
|
||||
try {
|
||||
const language = hockeyGameControlLanguage();
|
||||
let latest = null;
|
||||
for (let offset = 0; offset < entries.length; offset += 60) {
|
||||
const chunk = Object.fromEntries(entries.slice(offset, offset + 60));
|
||||
latest = await hockeyGameControlRequest(`/games/${encodeURIComponent(gameId)}/control/values`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ values: chunk, language }),
|
||||
});
|
||||
hockeyStoreGameControl(gameId, latest, { render: false, dispatch: false });
|
||||
}
|
||||
await hockeyRefreshQuickPanelMapping();
|
||||
renderRuntime();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Player selection panel seed failed", error);
|
||||
return false;
|
||||
} finally {
|
||||
state.hockeyPlayerPanelSeedPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function hockeyEnsureRuntimeSideStack() {
|
||||
let stack = document.getElementById("hockeyRuntimeSideStack");
|
||||
if (!stack && el.runtimeViewport) {
|
||||
stack = document.createElement("aside");
|
||||
stack.id = "hockeyRuntimeSideStack";
|
||||
stack.className = "hockey-runtime-side-stack";
|
||||
el.runtimeViewport.appendChild(stack);
|
||||
}
|
||||
if (stack) el.runtimeViewport?.classList.add("has-hockey-pbp");
|
||||
return stack;
|
||||
}
|
||||
|
||||
function hockeyRemoveRuntimeSideStack() {
|
||||
document.getElementById("hockeyRuntimeSideStack")?.remove();
|
||||
document.getElementById("hockeyStandalonePbp")?.remove();
|
||||
el.runtimeViewport?.classList.remove("has-hockey-pbp");
|
||||
}
|
||||
|
||||
function renderHockeyPlayerSelectionWindows() {
|
||||
document.querySelectorAll(".hockey-player-select-panel").forEach((node) => node.remove());
|
||||
if (!el.runtimeView || !el.runtimeViewport || state.activeTab !== "main") return false;
|
||||
const panels = hockeyPlayerSelectionPanels();
|
||||
if (!panels.length) return false;
|
||||
const stack = hockeyEnsureRuntimeSideStack();
|
||||
if (!stack) return false;
|
||||
for (const panel of panels) {
|
||||
const stateKey = `hockey-player-panel:${panel.id}:collapsed`;
|
||||
const hasStored = Object.prototype.hasOwnProperty.call(state.formValues, stateKey);
|
||||
const collapsed = hasStored ? Boolean(state.formValues[stateKey]) : Boolean(panel.collapsed_default);
|
||||
const slots = Array.from({ length: panel.slots }, (_, index) => hockeyPlayerSelectionSlot(panel, index + 1));
|
||||
const filled = slots.filter((slot) => slot.id || slot.dbId).length;
|
||||
const node = document.createElement("section");
|
||||
node.className = `hockey-player-select-panel ${collapsed ? "is-collapsed" : ""}`;
|
||||
node.dataset.playerPanelId = panel.id;
|
||||
node.innerHTML = `
|
||||
<header class="hockey-player-select-head">
|
||||
<div><span>PLAYER SELECT</span><strong>${escapeHtml(panel.label)}</strong><small>${escapeHtml(panel.description || hockeyPlayerSelectionRuleLabel(panel.rule))}</small></div>
|
||||
<div class="hockey-player-select-actions"><b>${filled}/${panel.slots}</b><button type="button" data-player-panel-collapse="${escapeHtml(panel.id)}" aria-label="Свернуть / раскрыть">${collapsed ? "+" : "−"}</button></div>
|
||||
</header>
|
||||
<div class="hockey-player-select-body">
|
||||
<div class="hockey-player-select-rule">${escapeHtml(hockeyPlayerSelectionRuleLabel(panel.rule))} · Mapping: <code>player_select.${escapeHtml(panel.id)}.*</code></div>
|
||||
<div class="hockey-player-select-slots">
|
||||
${slots.map((slot, index) => {
|
||||
const number = index + 1;
|
||||
const filledSlot = Boolean(slot.id || slot.dbId);
|
||||
const sideLabel = slot.side === "home" ? "HOME" : slot.side === "away" ? "AWAY" : "";
|
||||
const title = [slot.number ? `#${slot.number}` : "", slot.name].filter(Boolean).join(" ") || (filledSlot ? `Игрок ${number}` : "Перетащите игрока");
|
||||
const ids = filledSlot ? [sideLabel, slot.id ? `ID ${slot.id}` : "", slot.dbId ? `DB ${slot.dbId}` : ""].filter(Boolean).join(" · ") : "из состава слева или справа";
|
||||
return `<div class="hockey-player-select-slot ${filledSlot ? "is-filled" : ""}" data-player-panel-slot="${number}" data-player-panel-id="${escapeHtml(panel.id)}">
|
||||
<span class="hockey-player-select-index">${number}</span>
|
||||
<div><strong>${escapeHtml(title)}</strong><small>${escapeHtml(ids)}</small></div>
|
||||
${filledSlot ? `<button type="button" data-player-panel-clear-slot="${number}" data-player-panel-id="${escapeHtml(panel.id)}" title="Очистить слот">×</button>` : `<i>DROP</i>`}
|
||||
</div>`;
|
||||
}).join("")}
|
||||
</div>
|
||||
${filled ? `<button type="button" class="hockey-player-select-clear" data-player-panel-clear-all="${escapeHtml(panel.id)}">Очистить блок</button>` : ""}
|
||||
</div>`;
|
||||
stack.appendChild(node);
|
||||
node.querySelector("[data-player-panel-collapse]")?.addEventListener("click", () => {
|
||||
state.formValues[stateKey] = !collapsed;
|
||||
renderRuntime();
|
||||
});
|
||||
node.querySelectorAll("[data-player-panel-slot]").forEach((slotNode) => {
|
||||
slotNode.addEventListener("dragover", (event) => {
|
||||
const player = state.hockeyDragPlayer || readHockeyDragData(event, "player");
|
||||
if (!player) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
slotNode.classList.add("is-over");
|
||||
});
|
||||
slotNode.addEventListener("dragleave", () => slotNode.classList.remove("is-over"));
|
||||
slotNode.addEventListener("drop", async (event) => {
|
||||
event.preventDefault();
|
||||
slotNode.classList.remove("is-over");
|
||||
const player = state.hockeyDragPlayer || readHockeyDragData(event, "player");
|
||||
if (!player) return;
|
||||
await hockeySetPlayerSelectionSlot(panel, Number(slotNode.dataset.playerPanelSlot || 1), player);
|
||||
});
|
||||
});
|
||||
node.querySelectorAll("[data-player-panel-clear-slot]").forEach((button) => button.addEventListener("click", async (event) => {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
await hockeyClearPlayerSelectionSlot(panel, Number(button.dataset.playerPanelClearSlot || 1));
|
||||
}));
|
||||
node.querySelector("[data-player-panel-clear-all]")?.addEventListener("click", async () => hockeyClearPlayerSelectionPanel(panel));
|
||||
}
|
||||
setTimeout(() => hockeyEnsurePlayerSelectionRuntimeValues().catch(() => {}), 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderHockeyRuntimeSideWindows() {
|
||||
hockeyRemoveRuntimeSideStack();
|
||||
if (!el.runtimeView || !el.runtimeViewport || state.activeTab !== "main") return false;
|
||||
const players = renderHockeyPlayerSelectionWindows();
|
||||
const pbp = renderStandaloneHockeyPlayByPlayWindow();
|
||||
const stack = document.getElementById("hockeyRuntimeSideStack");
|
||||
if (!stack || !stack.children.length) hockeyRemoveRuntimeSideStack();
|
||||
return Boolean(players || pbp);
|
||||
}
|
||||
|
||||
function hockeyEventCategory(item) {
|
||||
const declared = String(item?.category || "").trim().toLowerCase();
|
||||
// Prefer the explicit Stat2TV code (`pn`, `go`, ...). `type` can contain a
|
||||
@@ -8282,7 +8674,6 @@ function hockeyEventIconMarkup(category, language, extraClass = "") {
|
||||
|
||||
function renderStandaloneHockeyPlayByPlayWindow() {
|
||||
document.getElementById("hockeyStandalonePbp")?.remove();
|
||||
el.runtimeViewport?.classList.remove("has-hockey-pbp");
|
||||
|
||||
// Play-by-play belongs to the Game tab. It stays rendered underneath top-right
|
||||
// menus/settings so opening an operator window never destroys its state.
|
||||
@@ -8358,8 +8749,9 @@ function renderStandaloneHockeyPlayByPlayWindow() {
|
||||
state.formValues[collapsedKey] = !collapsed;
|
||||
renderRuntime();
|
||||
});
|
||||
el.runtimeViewport.appendChild(windowNode);
|
||||
el.runtimeViewport.classList.add("has-hockey-pbp");
|
||||
const sideStack = hockeyEnsureRuntimeSideStack();
|
||||
if (!sideStack) return false;
|
||||
sideStack.appendChild(windowNode);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -11388,7 +11780,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|
||||
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);
|
||||
});
|
||||
renderStandaloneHockeyPlayByPlayWindow();
|
||||
renderHockeyRuntimeSideWindows();
|
||||
renderHockeyQuickCommandDock();
|
||||
Object.entries(shootoutRosterScroll).forEach(([side, top]) => {
|
||||
const list = el.runtimeStage.querySelector(`.hso-roster.side-${side} .hso-player-list`);
|
||||
@@ -12763,8 +13155,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|
||||
el.modalHost.querySelector(".modal-backdrop").addEventListener("click", (event) => {
|
||||
if (!locked && event.target.classList.contains("modal-backdrop")) closeModal();
|
||||
});
|
||||
document.getElementById("hockeyStandalonePbp")?.remove();
|
||||
el.runtimeViewport?.classList.remove("has-hockey-pbp");
|
||||
hockeyRemoveRuntimeSideStack();
|
||||
scheduleRuntimeScale();
|
||||
scheduleStyledControls();
|
||||
}
|
||||
@@ -12781,7 +13172,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|
||||
state.timerQuickEditorInterval = null;
|
||||
state.modalLocked = false;
|
||||
el.modalHost.innerHTML = "";
|
||||
renderStandaloneHockeyPlayByPlayWindow();
|
||||
renderHockeyRuntimeSideWindows();
|
||||
scheduleRuntimeScale();
|
||||
}
|
||||
|
||||
@@ -12897,10 +13288,9 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|
||||
window.addEventListener("hockey:navigation-toggle", (event) => {
|
||||
const open = Boolean(event?.detail?.open);
|
||||
if (open) {
|
||||
document.getElementById("hockeyStandalonePbp")?.remove();
|
||||
el.runtimeViewport?.classList.remove("has-hockey-pbp");
|
||||
hockeyRemoveRuntimeSideStack();
|
||||
} else {
|
||||
renderStandaloneHockeyPlayByPlayWindow();
|
||||
renderHockeyRuntimeSideWindows();
|
||||
}
|
||||
scheduleRuntimeScale();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user