создан механизм для создания меню для разных сравнений через drap-and-drop

This commit is contained in:
2026-08-20 12:29:07 +03:00
parent 3f2d5caa4a
commit 81fd030846
6 changed files with 727 additions and 22 deletions

9
app.py
View File

@@ -29,7 +29,8 @@ from ui_builder import install_ui_builder
from khl_site.khl_data_center import APP as khl_site_app from khl_site.khl_data_center import APP as khl_site_app
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
BUILD_VERSION = "2026.08.19.28" BUILD_VERSION = "2026.08.20.01"
# compatibility: BUILD_VERSION = "2026.08.19.28"
# compatibility: BUILD_VERSION = "2026.08.19.24" # compatibility: BUILD_VERSION = "2026.08.19.24"
# compatibility: BUILD_VERSION = "2026.08.19.22" # compatibility: BUILD_VERSION = "2026.08.19.22"
load_dotenv(BASE_DIR / ".env.local", override=False) load_dotenv(BASE_DIR / ".env.local", override=False)
@@ -261,12 +262,15 @@ async def save_hockey_prematch_buttons(payload: dict[str, Any]) -> dict[str, Any
buttons = payload.get("prematch_buttons") buttons = payload.get("prematch_buttons")
groups = payload.get("prematch_groups", []) groups = payload.get("prematch_groups", [])
selectors = payload.get("quick_panel_selectors", []) selectors = payload.get("quick_panel_selectors", [])
player_panels = payload.get("player_selection_panels", [])
if not isinstance(buttons, list): if not isinstance(buttons, list):
raise HTTPException(status_code=422, detail="prematch_buttons must be a list") raise HTTPException(status_code=422, detail="prematch_buttons must be a list")
if not isinstance(groups, list): if not isinstance(groups, list):
raise HTTPException(status_code=422, detail="prematch_groups must be a list") raise HTTPException(status_code=422, detail="prematch_groups must be a list")
if not isinstance(selectors, list): if not isinstance(selectors, list):
raise HTTPException(status_code=422, detail="quick_panel_selectors must be a list") raise HTTPException(status_code=422, detail="quick_panel_selectors must be a list")
if not isinstance(player_panels, list):
raise HTTPException(status_code=422, detail="player_selection_panels must be a list")
draft_manager = app.state.ui_builder_draft_manager draft_manager = app.state.ui_builder_draft_manager
published_manager = app.state.ui_builder_published_manager published_manager = app.state.ui_builder_published_manager
@@ -275,12 +279,14 @@ async def save_hockey_prematch_buttons(payload: dict[str, Any]) -> dict[str, Any
draft_config["prematch_groups"] = groups draft_config["prematch_groups"] = groups
draft_config["prematch_buttons"] = buttons draft_config["prematch_buttons"] = buttons
draft_config["quick_panel_selectors"] = selectors draft_config["quick_panel_selectors"] = selectors
draft_config["player_selection_panels"] = player_panels
saved_draft = draft_manager.save(draft_config) saved_draft = draft_manager.save(draft_config)
published_config = published_manager.load() published_config = published_manager.load()
published_config["prematch_groups"] = saved_draft.get("prematch_groups", []) published_config["prematch_groups"] = saved_draft.get("prematch_groups", [])
published_config["prematch_buttons"] = saved_draft.get("prematch_buttons", []) published_config["prematch_buttons"] = saved_draft.get("prematch_buttons", [])
published_config["quick_panel_selectors"] = saved_draft.get("quick_panel_selectors", []) published_config["quick_panel_selectors"] = saved_draft.get("quick_panel_selectors", [])
published_config["player_selection_panels"] = saved_draft.get("player_selection_panels", [])
saved_published = published_manager.save(published_config, create_backup=True) saved_published = published_manager.save(published_config, create_backup=True)
return { return {
@@ -288,6 +294,7 @@ async def save_hockey_prematch_buttons(payload: dict[str, Any]) -> dict[str, Any
"prematch_groups": saved_published.get("prematch_groups", []), "prematch_groups": saved_published.get("prematch_groups", []),
"prematch_buttons": saved_published.get("prematch_buttons", []), "prematch_buttons": saved_published.get("prematch_buttons", []),
"quick_panel_selectors": saved_published.get("quick_panel_selectors", []), "quick_panel_selectors": saved_published.get("quick_panel_selectors", []),
"player_selection_panels": saved_published.get("player_selection_panels", []),
"config": saved_published, "config": saved_published,
} }

View File

@@ -544,16 +544,42 @@ class MappingDataService:
control = session.scalar(select(GameControlState).where(GameControlState.game_external_id == resolved_game_id)) control = session.scalar(select(GameControlState).where(GameControlState.game_external_id == resolved_game_id))
if control is not None: if control is not None:
runtime_values = HockeyDataService._runtime_values_from_row(control) runtime_values = HockeyDataService._runtime_values_from_row(control)
player_panel_labels: dict[str, str] = {}
for meta_key, meta_value in runtime_values.items():
meta_match = re.match(r"^player_select\.([A-Za-z0-9_]+)\._label$", str(meta_key))
if meta_match:
player_panel_labels[meta_match.group(1)] = str(meta_value or meta_match.group(1))
for raw_key, raw_value in runtime_values.items(): for raw_key, raw_value in runtime_values.items():
sql_key = re.sub(r"[^A-Za-z0-9_]+", "_", str(raw_key)).strip("_")[:120] raw_key_text = str(raw_key)
player_match = re.match(r"^player_select\.([A-Za-z0-9_]+)\.(.+)$", raw_key_text)
if player_match and player_match.group(2).startswith("_"):
continue
sql_key = re.sub(r"[^A-Za-z0-9_]+", "_", raw_key_text).strip("_")[:120]
if not sql_key or not re.match(r"^[A-Za-z]", sql_key): if not sql_key or not re.match(r"^[A-Za-z]", sql_key):
sql_key = f"panel_{sql_key}"[:120] sql_key = f"panel_{sql_key}"[:120]
context.setdefault(sql_key, str(raw_value)) context.setdefault(sql_key, str(raw_value))
if player_match:
panel_id, field_key = player_match.groups()
slot_match = re.match(r"player(\d+)_(id|db_id|team_id|side|number|name)$", field_key)
field_labels = {
"id": "external ID", "db_id": "DB ID", "team_id": "ID команды",
"side": "сторона", "number": "номер", "name": "имя",
}
label = field_key
if slot_match:
slot_no, field_name = slot_match.groups()
label = f"Игрок {slot_no}{field_labels.get(field_name, field_name)}"
category = f"Игровые выборки · {player_panel_labels.get(panel_id, panel_id)}"
description = f"Игрок, перетащенный оператором в блок {player_panel_labels.get(panel_id, panel_id)}."
else:
label = raw_key_text
category = "Нижняя панель"
description = f"Runtime-параметр нижней панели: {raw_key_text}"
runtime_variable_payloads.append({ runtime_variable_payloads.append({
"key": sql_key, "key": sql_key,
"label": str(raw_key), "label": label,
"category": "Нижняя панель", "category": category,
"description": f"Runtime-параметр нижней панели: {raw_key}", "description": description,
"scope": "match", "scope": "match",
"source_type": "system", "source_type": "system",
"is_system": True, "is_system": True,
@@ -800,6 +826,11 @@ class MappingDataService:
"description": "Текущее состояние операторской кнопки матча.", "description": "Текущее состояние операторской кнопки матча.",
}) })
runtime_values = HockeyDataService._runtime_values_from_row(control) if control is not None else {} runtime_values = HockeyDataService._runtime_values_from_row(control) if control is not None else {}
player_panel_labels: dict[str, str] = {}
for meta_key, meta_value in runtime_values.items():
meta_match = re.match(r"^player_select\.([A-Za-z0-9_]+)\._label$", str(meta_key))
if meta_match:
player_panel_labels[meta_match.group(1)] = str(meta_value or meta_match.group(1))
for key, value in runtime_values.items(): for key, value in runtime_values.items():
mapping_key = str(key) mapping_key = str(key)
if mapping_key.startswith("panel."): if mapping_key.startswith("panel."):
@@ -812,6 +843,32 @@ class MappingDataService:
"source_code": "live_control", "source_code": "live_control",
"description": "Выбранное значение параметризованной кнопки нижней панели.", "description": "Выбранное значение параметризованной кнопки нижней панели.",
}) })
continue
player_match = re.match(r"^player_select\.([A-Za-z0-9_]+)\.(.+)$", mapping_key)
if not player_match:
continue
panel_id, field_key = player_match.groups()
if field_key.startswith("_"):
continue
slot_match = re.match(r"player(\d+)_(id|db_id|team_id|side|number|name)$", field_key)
field_labels = {
"id": "external ID", "db_id": "DB ID", "team_id": "ID команды",
"side": "сторона", "number": "номер", "name": "имя",
}
label = field_key
if slot_match:
slot_no, field_name = slot_match.groups()
label = f"Игрок {slot_no}{field_labels.get(field_name, field_name)}"
panel_label = player_panel_labels.get(panel_id, panel_id)
result.append({
"key": mapping_key,
"label": label,
"category": f"Игровые выборки · {panel_label}",
"value": value,
"kind": "text",
"source_code": "live_control",
"description": f"Значение слота блока игроков «{panel_label}».",
})
return result return result
def data_catalog( def data_catalog(

View File

@@ -0,0 +1,90 @@
from pathlib import Path
from ui_builder.manager import DEFAULT_HOCKEY_PLAYER_SELECTION_PANELS, UIBuilderManager
ROOT = Path(__file__).resolve().parents[1]
APP_JS = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8")
APP_PY = (ROOT / "app.py").read_text(encoding="utf-8")
MAPPING = (ROOT / "hockey_data/mapping_context.py").read_text(encoding="utf-8")
STYLES = (ROOT / "ui_builder/static/styles.css").read_text(encoding="utf-8")
def test_old_hockey_config_gets_default_player_selection_panels(tmp_path: Path):
manager = UIBuilderManager(tmp_path)
config = {
"version": 21,
"project_name": "Hockey",
"tabs": [{"id": "main", "label": "Игра"}],
"components": [{"id": "h", "type": "hockey_penalty_dashboard", "action_id": "hockey_game"}],
}
normalized = manager._normalize(config)
panels = normalized["player_selection_panels"]
assert [item["id"] for item in panels] == [item["id"] for item in DEFAULT_HOCKEY_PLAYER_SELECTION_PANELS]
assert {item["id"]: item["slots"] for item in panels} == {
"compare_home": 2,
"compare_away": 2,
"compare_mixed": 2,
"three_stars": 3,
"selected_player": 1,
}
def test_player_selection_panels_are_persisted_with_quick_panel_settings():
assert 'player_panels = payload.get("player_selection_panels", [])' in APP_PY
assert 'draft_config["player_selection_panels"] = player_panels' in APP_PY
assert 'published_config["player_selection_panels"]' in APP_PY
assert 'player_selection_panels: normalizedPlayerPanels' in APP_JS
def test_runtime_player_slots_accept_roster_drag_drop_and_generate_identifiers():
for token in (
"function renderHockeyPlayerSelectionWindows()",
'readHockeyDragData(event, "player")',
"hockeySetPlayerSelectionSlot(panel",
"player_select.${panelId}.player${slot}_${field}",
'"db_id"',
'"team_id"',
'"side"',
):
assert token in APP_JS
assert 'panel.rule === "home"' in APP_JS
assert 'panel.rule === "away"' in APP_JS
assert 'panel.rule === "different_teams"' in APP_JS
def test_single_selected_player_panel_mirrors_legacy_signature_context():
assert '"selected_player"' in APP_JS or 'selected_player' in APP_JS
assert "sync_selected_player" in APP_JS
assert "selected_player_id:" in APP_JS
assert "selected_player_team_id:" in APP_JS
def test_mapping_catalog_exposes_dynamic_player_selection_groups():
assert 'r"^player_select\\.([A-Za-z0-9_]+)\\.(.+)$"' in MAPPING
assert 'Игровые выборки ·' in MAPPING
assert "DB ID" in MAPPING
assert "ID команды" in MAPPING
def test_player_panels_are_collapsible_and_have_editor_ui():
assert "hockey-player-select-panel" in STYLES
assert ".hockey-player-select-panel.is-collapsed .hockey-player-select-body" in STYLES
assert 'data-player-panel-field="slots"' in APP_JS
assert 'data-player-panel-field="rule"' in APP_JS
assert 'data-player-panel-add' in APP_JS
def test_all_overlay_sequences_latch_onair_state_for_lower_dock():
start = APP_JS.index("function trackRuntimeOverlayCommands")
end = APP_JS.index("async function sendRuntimeVmixSequence", start)
block = APP_JS[start:end]
assert "state.shortcutSequenceOverlayState.set(ownerSequenceId, true);" in block
assert "state.shortcutSequenceOverlayState.set(ownerSequenceId, false);" in block
assert "refreshQuickPanelOnAirClasses();" in block
def test_player_panel_id_edit_rerenders_row_so_followup_fields_use_new_id():
assert 'const eventName = field === "id"' in APP_JS
assert 'if (field === "id") {' in APP_JS
assert "draftPlayerPanels = normalized;" in APP_JS
assert "renderEditor();" in APP_JS

View File

@@ -34,9 +34,19 @@ DEFAULT_CONFIG: dict[str, Any] = {
"prematch_groups": [], "prematch_groups": [],
"prematch_buttons": [], "prematch_buttons": [],
"quick_panel_selectors": [], "quick_panel_selectors": [],
"player_selection_panels": [],
} }
DEFAULT_HOCKEY_PLAYER_SELECTION_PANELS: list[dict[str, Any]] = [
{"id": "compare_home", "label": "Сравнение · левая команда", "description": "Два игрока левой команды", "slots": 2, "rule": "home", "sync_selected_player": False, "collapsed_default": True, "sort_order": 0, "enabled": True},
{"id": "compare_away", "label": "Сравнение · правая команда", "description": "Два игрока правой команды", "slots": 2, "rule": "away", "sync_selected_player": False, "collapsed_default": True, "sort_order": 10, "enabled": True},
{"id": "compare_mixed", "label": "Сравнение · разные команды", "description": "По одному игроку каждой команды", "slots": 2, "rule": "different_teams", "sync_selected_player": False, "collapsed_default": True, "sort_order": 20, "enabled": True},
{"id": "three_stars", "label": "3 звезды", "description": "Три игрока для финального титра", "slots": 3, "rule": "any", "sync_selected_player": False, "collapsed_default": True, "sort_order": 30, "enabled": True},
{"id": "selected_player", "label": "Выбранный игрок", "description": "Один игрок для подписи / индивидуального титра", "slots": 1, "rule": "any", "sync_selected_player": True, "collapsed_default": True, "sort_order": 40, "enabled": True},
]
class UIBuilderManager: class UIBuilderManager:
"""JSON settings storage with atomic writes and rolling backups.""" """JSON settings storage with atomic writes and rolling backups."""
@@ -546,10 +556,49 @@ class UIBuilderManager:
"enabled": bool(selector.get("enabled", True)), "enabled": bool(selector.get("enabled", True)),
}) })
raw_player_selection_panels = config.get("player_selection_panels") if isinstance(config.get("player_selection_panels"), list) else None
if raw_player_selection_panels is None:
raw_player_selection_panels = deepcopy(DEFAULT_HOCKEY_PLAYER_SELECTION_PANELS) if ("player_selection_panels" not in config and UIBuilderManager._is_hockey_config(config)) else []
normalized_player_selection_panels: list[dict[str, Any]] = []
seen_player_panel_ids: set[str] = set()
allowed_player_panel_rules = {"any", "home", "away", "different_teams"}
for panel_index, panel in enumerate(raw_player_selection_panels[:16]):
if not isinstance(panel, dict):
continue
raw_id = re.sub(r"[^A-Za-z0-9_]+", "_", str(panel.get("id") or f"players_{panel_index + 1}").strip())[:48].strip("_")
panel_id = raw_id or f"players_{panel_index + 1}"
if not re.match(r"^[A-Za-z]", panel_id):
panel_id = f"p_{panel_id}"[:48]
base_id = panel_id
suffix = 2
while panel_id in seen_player_panel_ids:
panel_id = f"{base_id}_{suffix}"[:48]
suffix += 1
seen_player_panel_ids.add(panel_id)
try:
slots = max(1, min(6, int(panel.get("slots") or 1)))
except (TypeError, ValueError):
slots = 1
rule = str(panel.get("rule") or "any").strip().lower()
if rule not in allowed_player_panel_rules:
rule = "any"
normalized_player_selection_panels.append({
"id": panel_id,
"label": str(panel.get("label") or f"Игроки {panel_index + 1}")[:80],
"description": str(panel.get("description") or "")[:300],
"slots": slots,
"rule": rule,
"sync_selected_player": bool(panel.get("sync_selected_player", False)),
"collapsed_default": bool(panel.get("collapsed_default", True)),
"sort_order": int(panel.get("sort_order") if str(panel.get("sort_order", "")).lstrip("-").isdigit() else panel_index * 10),
"enabled": bool(panel.get("enabled", True)),
})
result["components"] = normalized_components result["components"] = normalized_components
result["triggers"] = normalized_triggers result["triggers"] = normalized_triggers
result["shortcut_sequences"] = normalized_shortcut_sequences result["shortcut_sequences"] = normalized_shortcut_sequences
result["prematch_groups"] = sorted(normalized_prematch_groups, key=lambda item: (item["sort_order"], item["label"])) result["prematch_groups"] = sorted(normalized_prematch_groups, key=lambda item: (item["sort_order"], item["label"]))
result["prematch_buttons"] = sorted(normalized_prematch_buttons, key=lambda item: (item.get("group_id", ""), item["sort_order"], item["label"])) result["prematch_buttons"] = sorted(normalized_prematch_buttons, key=lambda item: (item.get("group_id", ""), item["sort_order"], item["label"]))
result["quick_panel_selectors"] = sorted(normalized_quick_panel_selectors, key=lambda item: (item.get("group_id", ""), item["sort_order"], item["label"])) result["quick_panel_selectors"] = sorted(normalized_quick_panel_selectors, key=lambda item: (item.get("group_id", ""), item["sort_order"], item["label"]))
result["player_selection_panels"] = sorted(normalized_player_selection_panels, key=lambda item: (item["sort_order"], item["label"]))
return result return result

View File

@@ -52,6 +52,7 @@
prematch_groups: [], prematch_groups: [],
prematch_buttons: [], prematch_buttons: [],
quick_panel_selectors: [], quick_panel_selectors: [],
player_selection_panels: [],
}, },
data: {}, data: {},
sources: [], sources: [],
@@ -86,6 +87,8 @@
hockeyPenaltyMappingContextSignature: "", hockeyPenaltyMappingContextSignature: "",
hockeyPenaltyMappingContextPending: false, hockeyPenaltyMappingContextPending: false,
hockeyPenaltyMappingContextQueued: false, hockeyPenaltyMappingContextQueued: false,
hockeyPlayerPanelSeedPending: false,
hockeyPlayerPanelSeedSignature: "",
vmixFinishOverlayTimers: new Map(), vmixFinishOverlayTimers: new Map(),
vmixStrengthMappingRefreshPending: false, vmixStrengthMappingRefreshPending: false,
vmixStrengthMappingRefreshQueued: null, 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) { function normalizeShortcutSequence(sequence = {}, index = 0) {
return { return {
id: String(sequence.id || `sequence-${index + 1}-${Math.random().toString(36).slice(2, 7)}`), 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 : "", group_id: validPrematchGroups.has(selector.group_id) ? selector.group_id : "",
button_id: validPrematchButtons.has(selector.button_id) ? selector.button_id : "", button_id: validPrematchButtons.has(selector.button_id) ? selector.button_id : "",
})); }));
state.config.player_selection_panels = normalizePlayerSelectionPanels(state.config.player_selection_panels);
ensureHockeyQuickCommandWorkspace(); ensureHockeyQuickCommandWorkspace();
const validIds = new Set(); const validIds = new Set();
const usedActionIds = new Set(); const usedActionIds = new Set();
@@ -4210,6 +4239,7 @@ function startCustomTooltips() {
// sequence, that is the strongest signal and needs no fallback. // sequence, that is the strongest signal and needs no fallback.
if (sequenceStillOwnsRuntimeOverlay(id)) { if (sequenceStillOwnsRuntimeOverlay(id)) {
state.quickPanelOnAirSequences.add(id); state.quickPanelOnAirSequences.add(id);
state.shortcutSequenceOverlayState.set(id, true);
refreshQuickPanelOnAirClasses(); refreshQuickPanelOnAirClasses();
return true; return true;
} }
@@ -4232,6 +4262,7 @@ function startCustomTooltips() {
} }
if (active) state.quickPanelOnAirSequences.add(id); if (active) state.quickPanelOnAirSequences.add(id);
else state.quickPanelOnAirSequences.delete(id); else state.quickPanelOnAirSequences.delete(id);
state.shortcutSequenceOverlayState.set(id, active);
refreshQuickPanelOnAirClasses(); refreshQuickPanelOnAirClasses();
return active; return active;
} }
@@ -4317,7 +4348,10 @@ function startCustomTooltips() {
const key = String(layer); const key = String(layer);
const previous = state.vmixOverlayRuntime.get(key) || null; const previous = state.vmixOverlayRuntime.get(key) || null;
state.vmixOverlayRuntime.delete(String(layer)); 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; return previous;
}; };
const setLayer = (layer, command) => { const setLayer = (layer, command) => {
@@ -4331,8 +4365,12 @@ function startCustomTooltips() {
}); });
if (previous?.sequence_id && String(previous.sequence_id) !== ownerSequenceId) { if (previous?.sequence_id && String(previous.sequence_id) !== ownerSequenceId) {
setQuickPanelSequenceOnAir(previous.sequence_id, false); 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) => { (commands || []).forEach((command) => {
const fn = String(command?.Function || "").trim(); const fn = String(command?.Function || "").trim();
@@ -4349,14 +4387,20 @@ function startCustomTooltips() {
setLayer(parsed.layer, command); setLayer(parsed.layer, command);
} else if (parsed.action === "Out" || parsed.action === "Off") { } else if (parsed.action === "Out" || parsed.action === "Off") {
clearLayer(parsed.layer); clearLayer(parsed.layer);
if (ownerSequenceId) setQuickPanelSequenceOnAir(ownerSequenceId, false); if (ownerSequenceId) {
setQuickPanelSequenceOnAir(ownerSequenceId, false);
state.shortcutSequenceOverlayState.set(ownerSequenceId, false);
}
} else if (parsed.action === "toggle") { } else if (parsed.action === "toggle") {
const current = state.vmixOverlayRuntime.get(String(parsed.layer)); const current = state.vmixOverlayRuntime.get(String(parsed.layer));
const sameOwner = Boolean(current && ownerSequenceId && String(current.sequence_id || "") === ownerSequenceId); const sameOwner = Boolean(current && ownerSequenceId && String(current.sequence_id || "") === ownerSequenceId);
const sameInput = Boolean(current && String(current.input || "") === String(command?.Input || "")); const sameInput = Boolean(current && String(current.input || "") === String(command?.Input || ""));
if (current && (sameOwner || sameInput || !ownerSequenceId)) { if (current && (sameOwner || sameInput || !ownerSequenceId)) {
clearLayer(parsed.layer); clearLayer(parsed.layer);
if (ownerSequenceId) setQuickPanelSequenceOnAir(ownerSequenceId, false); if (ownerSequenceId) {
setQuickPanelSequenceOnAir(ownerSequenceId, false);
state.shortcutSequenceOverlayState.set(ownerSequenceId, false);
}
} else { } else {
setLayer(parsed.layer, command); setLayer(parsed.layer, command);
} }
@@ -7530,7 +7574,7 @@ function hockeyPrematchSequenceOptions(selectedId = "") {
return triggerSelectOptions(options, 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 normalizedGroups = normalizePrematchGroups(groups);
const validGroups = new Set(normalizedGroups.map((group) => group.id)); const validGroups = new Set(normalizedGroups.map((group) => group.id));
const normalizedButtons = normalizePrematchButtons(buttons).map((button) => ({ 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_groups = normalizedGroups;
state.config.prematch_buttons = normalizedButtons; state.config.prematch_buttons = normalizedButtons;
state.config.quick_panel_selectors = normalizedSelectors; state.config.quick_panel_selectors = normalizedSelectors;
const normalizedPlayerPanels = normalizePlayerSelectionPanels(playerPanels);
state.config.player_selection_panels = normalizedPlayerPanels;
try { try {
const response = await fetch("/api/hockey/ui/prematch-buttons", { const response = await fetch("/api/hockey/ui/prematch-buttons", {
method: "POST", method: "POST",
credentials: "same-origin", credentials: "same-origin",
cache: "no-store", cache: "no-store",
headers: { "Content-Type": "application/json" }, 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 = {}; let payload = {};
try { payload = await response.json(); } catch (_) {} 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_groups = normalizePrematchGroups(payload.prematch_groups || normalizedGroups);
state.config.prematch_buttons = normalizePrematchButtons(payload.prematch_buttons || normalizedButtons); state.config.prematch_buttons = normalizePrematchButtons(payload.prematch_buttons || normalizedButtons);
state.config.quick_panel_selectors = normalizeQuickPanelSelectors(payload.quick_panel_selectors || normalizedSelectors); state.config.quick_panel_selectors = normalizeQuickPanelSelectors(payload.quick_panel_selectors || normalizedSelectors);
state.config.player_selection_panels = normalizePlayerSelectionPanels(payload.player_selection_panels || normalizedPlayerPanels);
} }
ensureConfig(); ensureConfig();
toast("Нижняя панель кнопок сохранена"); toast("Нижняя панель кнопок сохранена");
@@ -7607,6 +7654,7 @@ function openHockeyPrematchButtonsEditor() {
let draftGroups = normalizePrematchGroups(state.config.prematch_groups); let draftGroups = normalizePrematchGroups(state.config.prematch_groups);
let draft = normalizePrematchButtons(state.config.prematch_buttons); let draft = normalizePrematchButtons(state.config.prematch_buttons);
let draftSelectors = normalizeQuickPanelSelectors(state.config.quick_panel_selectors); let draftSelectors = normalizeQuickPanelSelectors(state.config.quick_panel_selectors);
let draftPlayerPanels = normalizePlayerSelectionPanels(state.config.player_selection_panels);
let dragButtonId = ""; let dragButtonId = "";
let dragSelectorId = ""; let dragSelectorId = "";
let activeEditorGroupId = String(state.quickPanelActiveTab || ""); 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> <button type="button" class="icon-btn danger" data-quick-selector-delete="${escapeHtml(selector.id)}" title="Удалить переключатель">×</button>
</article>`; </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 renderGroup = (group, groupIndex) => {
const groupId = group?.id || ""; const groupId = group?.id || "";
const buttons = groupButtons(groupId); 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 : "" })); 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)); 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 : "" })); 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("Нижняя панель · кнопки", ` showSettingsModal("Нижняя панель · кнопки", `
<div class="prematch-editor"> <div class="prematch-editor">
<div class="prematch-editor-head"> <div class="prematch-editor-head">
@@ -7713,6 +7777,10 @@ function openHockeyPrematchButtonsEditor() {
${renderGroup(null, -1)} ${renderGroup(null, -1)}
${draftGroups.map((group, index) => renderGroup(group, index)).join("")} ${draftGroups.map((group, index) => renderGroup(group, index)).join("")}
</div> </div>
<section class="player-selection-editor">
<header><div><span>DRAG & DROP ИГРОКОВ</span><strong>Блоки выбора игроков</strong><small>Название ID автоматически формирует идентификаторы Mapping: player_select.&lt;ID&gt;.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"> <div class="prematch-editor-actions">
<button type="button" class="btn" data-prematch-cancel>Отмена</button> <button type="button" class="btn" data-prematch-cancel>Отмена</button>
<button type="button" class="btn btn-accent" data-prematch-save>Сохранить и опубликовать</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 }); 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(); 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", () => { document.querySelector("[data-prematch-add]")?.addEventListener("click", () => {
if (draft.length >= 64) return toast("Можно создать до 64 кнопок", true); if (draft.length >= 64) return toast("Можно создать до 64 кнопок", true);
const targetGroup = draftGroups.some((group) => group.id === activeEditorGroupId) ? activeEditorGroupId : ""; const targetGroup = draftGroups.some((group) => group.id === activeEditorGroupId) ? activeEditorGroupId : "";
@@ -7807,6 +7881,46 @@ function openHockeyPrematchButtonsEditor() {
renderEditor(); 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) => { document.querySelectorAll("[data-quick-selector-field]").forEach((control) => {
const eventName = control.type === "checkbox" || control.tagName === "SELECT" ? "change" : "input"; const eventName = control.type === "checkbox" || control.tagName === "SELECT" ? "change" : "input";
control.addEventListener(eventName, () => { control.addEventListener(eventName, () => {
@@ -7907,9 +8021,18 @@ function openHockeyPrematchButtonsEditor() {
const options = parseQuickPanelSelectorOptions(control.value); const options = parseQuickPanelSelectorOptions(control.value);
if (options.length) selector.options = options; 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; }); 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); 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(); renderEditor();
@@ -8238,6 +8361,275 @@ function hockeyEventCategoryLabels(language) {
: {all:"Все",goal:"Гол",penalty:"Удаление",shot:"Бросок",shootout:"Буллит",period:"Период",timeout:"Тайм-аут",goalie:"Вратарь",comment:"Комментарий",info:"Событие"}; : {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) { function hockeyEventCategory(item) {
const declared = String(item?.category || "").trim().toLowerCase(); const declared = String(item?.category || "").trim().toLowerCase();
// Prefer the explicit Stat2TV code (`pn`, `go`, ...). `type` can contain a // Prefer the explicit Stat2TV code (`pn`, `go`, ...). `type` can contain a
@@ -8282,7 +8674,6 @@ function hockeyEventIconMarkup(category, language, extraClass = "") {
function renderStandaloneHockeyPlayByPlayWindow() { function renderStandaloneHockeyPlayByPlayWindow() {
document.getElementById("hockeyStandalonePbp")?.remove(); 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 // 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. // menus/settings so opening an operator window never destroys its state.
@@ -8358,8 +8749,9 @@ function renderStandaloneHockeyPlayByPlayWindow() {
state.formValues[collapsedKey] = !collapsed; state.formValues[collapsedKey] = !collapsed;
renderRuntime(); renderRuntime();
}); });
el.runtimeViewport.appendChild(windowNode); const sideStack = hockeyEnsureRuntimeSideStack();
el.runtimeViewport.classList.add("has-hockey-pbp"); if (!sideStack) return false;
sideStack.appendChild(windowNode);
return true; 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) => { 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); 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(); renderHockeyQuickCommandDock();
Object.entries(shootoutRosterScroll).forEach(([side, top]) => { Object.entries(shootoutRosterScroll).forEach(([side, top]) => {
const list = el.runtimeStage.querySelector(`.hso-roster.side-${side} .hso-player-list`); 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) => { el.modalHost.querySelector(".modal-backdrop").addEventListener("click", (event) => {
if (!locked && event.target.classList.contains("modal-backdrop")) closeModal(); if (!locked && event.target.classList.contains("modal-backdrop")) closeModal();
}); });
document.getElementById("hockeyStandalonePbp")?.remove(); hockeyRemoveRuntimeSideStack();
el.runtimeViewport?.classList.remove("has-hockey-pbp");
scheduleRuntimeScale(); scheduleRuntimeScale();
scheduleStyledControls(); scheduleStyledControls();
} }
@@ -12781,7 +13172,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
state.timerQuickEditorInterval = null; state.timerQuickEditorInterval = null;
state.modalLocked = false; state.modalLocked = false;
el.modalHost.innerHTML = ""; el.modalHost.innerHTML = "";
renderStandaloneHockeyPlayByPlayWindow(); renderHockeyRuntimeSideWindows();
scheduleRuntimeScale(); scheduleRuntimeScale();
} }
@@ -12897,10 +13288,9 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
window.addEventListener("hockey:navigation-toggle", (event) => { window.addEventListener("hockey:navigation-toggle", (event) => {
const open = Boolean(event?.detail?.open); const open = Boolean(event?.detail?.open);
if (open) { if (open) {
document.getElementById("hockeyStandalonePbp")?.remove(); hockeyRemoveRuntimeSideStack();
el.runtimeViewport?.classList.remove("has-hockey-pbp");
} else { } else {
renderStandaloneHockeyPlayByPlayWindow(); renderHockeyRuntimeSideWindows();
} }
scheduleRuntimeScale(); scheduleRuntimeScale();
}); });

View File

@@ -7251,3 +7251,115 @@ body.hockey-navigation-open .runtime-viewport.has-hockey-pbp { gap: 14px !import
text-transform: uppercase; text-transform: uppercase;
letter-spacing: .035em; letter-spacing: .035em;
} }
/* BUILD80 — universal player-selection side panels + compact editor. */
.hockey-runtime-side-stack {
flex: 0 0 clamp(330px, 23vw, 410px);
width: clamp(330px, 23vw, 410px);
max-height: 100%;
min-height: 0;
display: grid;
align-content: start;
gap: 8px;
overflow: auto;
scrollbar-width: thin;
}
.hockey-runtime-side-stack > .hockey-pbp-window,
.hockey-runtime-side-stack > .hockey-pbp-window[style] {
position: relative !important;
inset: auto !important;
flex: none !important;
width: 100% !important;
max-width: none !important;
height: min(560px, 70vh);
max-height: 70vh;
margin: 0;
}
.hockey-runtime-side-stack > .hockey-pbp-window.is-collapsed { width: 100% !important; height: auto; }
.hockey-player-select-panel {
overflow: hidden;
color: #edf5fc;
border: 1px solid #36546e;
border-radius: 13px;
background: #071522;
box-shadow: 0 10px 28px rgba(0,0,0,.25);
}
.hockey-player-select-head {
min-height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 9px 8px 12px;
border-bottom: 1px solid #243d54;
background: linear-gradient(100deg, rgba(30,63,88,.8), rgba(8,22,35,.96));
}
.hockey-player-select-head > div:first-child { min-width:0; display:grid; gap:2px; }
.hockey-player-select-head span { color:#48dfbd; font-size:7px; font-weight:950; letter-spacing:.15em; }
.hockey-player-select-head strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:11px; font-weight:950; }
.hockey-player-select-head small { overflow:hidden; color:#6f8aa2; text-overflow:ellipsis; white-space:nowrap; font-size:7px; font-weight:800; }
.hockey-player-select-actions { display:flex; align-items:center; gap:5px; }
.hockey-player-select-actions b { min-width:31px; height:27px; display:grid; place-items:center; border:1px solid #38556d; border-radius:8px; background:#10283b; font:950 9px "Roboto Mono",Consolas,monospace; }
.hockey-player-select-actions button { width:27px; height:27px; display:grid; place-items:center; padding:0; color:#b9cada; border:1px solid #38556d; border-radius:8px; background:#10283b; font-size:15px; font-weight:950; cursor:pointer; }
.hockey-player-select-actions button:hover { color:#07150f; border-color:transparent; background:#48dfbd; }
.hockey-player-select-panel.is-collapsed .hockey-player-select-body { display:none; }
.hockey-player-select-panel.is-collapsed .hockey-player-select-head { border-bottom:0; }
.hockey-player-select-body { display:grid; gap:6px; padding:7px; }
.hockey-player-select-rule { color:#6d859b; font-size:7px; font-weight:800; }
.hockey-player-select-rule code { color:#8db8d9; font:800 7px "Roboto Mono",Consolas,monospace; }
.hockey-player-select-slots { display:grid; gap:5px; }
.hockey-player-select-slot {
min-height:45px;
display:grid;
grid-template-columns:27px minmax(0,1fr) auto;
gap:7px;
align-items:center;
padding:6px 7px;
border:1px dashed #38546d;
border-radius:9px;
background:#0a1a29;
transition:border-color .12s ease, background .12s ease, box-shadow .12s ease;
}
.hockey-player-select-slot.is-filled { border-style:solid; border-color:#2d5168; }
.hockey-player-select-slot.is-over { border-color:#48dfbd; background:#0e302a; box-shadow:0 0 0 2px rgba(72,223,189,.14) inset; }
.hockey-player-select-index { width:24px; height:24px; display:grid; place-items:center; color:#9fb3c4; border:1px solid #355169; border-radius:7px; background:#10283b; font:950 9px "Roboto Mono",Consolas,monospace; }
.hockey-player-select-slot > div { min-width:0; display:grid; gap:2px; }
.hockey-player-select-slot strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:9px; font-weight:950; }
.hockey-player-select-slot small { overflow:hidden; color:#6e879d; text-overflow:ellipsis; white-space:nowrap; font:750 7px "Roboto Mono",Consolas,monospace; }
.hockey-player-select-slot > i { color:#4c6980; font:900 7px "Roboto Mono",Consolas,monospace; font-style:normal; }
.hockey-player-select-slot > button { width:22px; height:22px; padding:0; color:#8ea2b4; border:1px solid #354b60; border-radius:7px; background:#102133; cursor:pointer; }
.hockey-player-select-slot > button:hover { color:#fff; border-color:#d45f6c; background:#772532; }
.hockey-player-select-clear { justify-self:end; padding:5px 8px; color:#8ca1b4; border:1px solid #30475c; border-radius:7px; background:#0d1e2d; font-size:7px; font-weight:900; cursor:pointer; }
.hockey-player-select-clear:hover { color:#fff; border-color:#516d85; }
.player-selection-editor { margin-top:14px; padding-top:14px; border-top:1px solid #263d53; }
.player-selection-editor > header { display:flex; align-items:flex-end; justify-content:space-between; gap:12px; margin-bottom:8px; }
.player-selection-editor > header > div { display:grid; gap:3px; }
.player-selection-editor > header span { color:#48dfbd; font-size:8px; font-weight:950; letter-spacing:.12em; }
.player-selection-editor > header strong { font-size:13px; }
.player-selection-editor > header small { color:#738da4; font-size:8px; }
.player-selection-editor-list { display:grid; gap:6px; }
.player-selection-editor-row {
display:grid;
grid-template-columns:28px 82px minmax(135px,1fr) minmax(125px,.8fr) 65px minmax(145px,.9fr) 135px 135px minmax(160px,1fr) 62px 34px;
gap:6px;
align-items:end;
padding:7px;
border:1px solid #2b4359;
border-radius:9px;
background:#0b1a28;
}
.player-selection-editor-row label { min-width:0; display:grid; gap:3px; color:#7890a7; font-size:7px; font-weight:900; }
.player-selection-editor-row input,.player-selection-editor-row select { min-width:0; height:30px; }
.player-selection-editor-row .shortcut-inline-check { align-self:center; display:flex; align-items:center; gap:5px; }
.player-selection-editor-description { min-width:150px; }
.player-selection-editor-order { display:flex; gap:3px; }
@media (max-width: 1450px) {
.player-selection-editor-row { grid-template-columns:28px 82px minmax(140px,1fr) minmax(120px,1fr) 65px minmax(140px,1fr) 130px 130px; }
.player-selection-editor-description,.player-selection-editor-order,.player-selection-editor-row > .icon-btn { grid-column:auto; }
}
@media (max-width:980px) {
.hockey-runtime-side-stack { flex-basis:290px; width:290px; }
.player-selection-editor-row { grid-template-columns:1fr 1fr; }
}