12817 lines
688 KiB
JavaScript
12817 lines
688 KiB
JavaScript
(() => {
|
||
"use strict";
|
||
|
||
const boot = window.UI_BUILDER_BOOT || {
|
||
api: "/api/ui-builder/runtime",
|
||
authApi: "/api/ui-builder/auth",
|
||
editorUrl: "/editor",
|
||
runtimeUrl: "/",
|
||
editorHotkey: "Ctrl+Shift+E",
|
||
mode: "runtime",
|
||
};
|
||
|
||
const uid = () => crypto.randomUUID?.() || `cmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
||
const escapeHtml = (value) => String(value ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
|
||
const actionRegistry = window.UIBuilderActions = window.UIBuilderActions || {};
|
||
actionRegistry.handlers = actionRegistry.handlers || {};
|
||
actionRegistry.register = actionRegistry.register || ((name, handler) => {
|
||
if (!name || typeof handler !== "function") throw new TypeError("UIBuilderActions.register(name, handler)");
|
||
actionRegistry.handlers[String(name)] = handler;
|
||
return () => delete actionRegistry.handlers[String(name)];
|
||
});
|
||
actionRegistry.unregister = actionRegistry.unregister || ((name) => delete actionRegistry.handlers[String(name)]);
|
||
actionRegistry.has = actionRegistry.has || ((name) => typeof actionRegistry.handlers[String(name)] === "function");
|
||
|
||
const state = {
|
||
config: {
|
||
version: 21,
|
||
project_name: "Новый интерфейс",
|
||
data_source: "golf",
|
||
canvas: {
|
||
width: 1440,
|
||
height: 900,
|
||
grid_size: 10,
|
||
snap_enabled: true,
|
||
snap_threshold: 8,
|
||
show_grid: true,
|
||
auto_bind_containers: true,
|
||
background: "#0c1421",
|
||
},
|
||
tabs: [{ id: "main", label: "Основное" }],
|
||
components: [],
|
||
triggers: [],
|
||
shortcut_sequences: [],
|
||
prematch_groups: [],
|
||
prematch_buttons: [],
|
||
quick_panel_selectors: [],
|
||
},
|
||
data: {},
|
||
sources: [],
|
||
dataPaths: [],
|
||
activeTab: "main",
|
||
selectedId: null,
|
||
showAllTabs: false,
|
||
zoom: 0.70,
|
||
preview: false,
|
||
formValues: {},
|
||
componentStates: {},
|
||
uiNavigationStates: {},
|
||
runtimeVisibility: {},
|
||
triggerDepth: 0,
|
||
triggersEnabled: true,
|
||
triggersPreferenceLoaded: false,
|
||
operatorToastsEnabled: true,
|
||
operatorToastsPreferenceLoaded: false,
|
||
runtimeScale: 1,
|
||
runtimeResizeFrame: null,
|
||
runtimeResizeObserver: null,
|
||
shortcutCapture: null,
|
||
pressedShortcutModifiers: new Set(),
|
||
modifierShortcutChordModifiers: new Set(),
|
||
modifierShortcutChordUsedKey: false,
|
||
modifierShortcutChordFired: false,
|
||
runningShortcutSequences: new Set(),
|
||
vmixTimerMirrors: new Map(),
|
||
vmixPenaltyMirrors: new Map(),
|
||
activeHockeyVmixTimerSteps: new Set(),
|
||
vmixPenaltyTargetAssignments: new Map(),
|
||
vmixFinishOverlayTimers: new Map(),
|
||
vmixStrengthMappingRefreshPending: false,
|
||
vmixStrengthMappingRefreshQueued: null,
|
||
vmixTabMappingRefreshPending: false,
|
||
vmixTabMappingRefreshQueued: "",
|
||
hockeyTeamStateOverlayActive: new Map(),
|
||
shortcutSequenceOverlayState: new Map(),
|
||
quickPanelActiveTab: "",
|
||
vmixOverlayRuntime: new Map(),
|
||
quickPanelOnAirSequences: new Set(),
|
||
triggerEditorOpenIds: new Set(),
|
||
shortcutSequenceOpenId: "",
|
||
shortcutInventory: { device_id: "", device_name: "", online: false, vmix_connected: false, inventory: { inputs: [] }, devices: [] },
|
||
shortcutInventoryLoading: false,
|
||
shortcutEditorScrollTop: 0,
|
||
modalLocked: false,
|
||
timerQuickEditorInterval: null,
|
||
timers: {},
|
||
timerNodes: new Map(),
|
||
timerEngineStarted: false,
|
||
hockeyPenaltyBoards: {},
|
||
hockeyPenaltyBoardNodes: new Map(),
|
||
hockeyGameControl: {},
|
||
hockeyGameControlLoading: new Set(),
|
||
hockeyTimerGameId: "",
|
||
hockeyTimerHydrating: false,
|
||
hockeyTimerActivationGameId: "",
|
||
hockeyTimerActivationPromise: null,
|
||
hockeyTimerSaveTimer: null,
|
||
hockeyTimerSavePromise: null,
|
||
hockeyTimerDirty: false,
|
||
hockeyTimerRevision: 0,
|
||
hockeyDragPlayer: null,
|
||
hockeyDragInfraction: null,
|
||
hockeyDragPreset: null,
|
||
editorAuthTimer: null,
|
||
customTooltip: null,
|
||
customTooltipTimer: null,
|
||
};
|
||
|
||
const el = Object.fromEntries([
|
||
"projectName", "dataSource", "reloadDataBtn", "previewBtn", "saveBtn", "publishBtn", "openRuntimeBtn", "lockEditorBtn", "editorSessionBadge", "shortcutsBtn", "shortcutsCount", "triggersBtn", "triggersCount", "backupsBtn", "exportBtn", "importInput",
|
||
"editor", "componentLibrary", "componentSearch", "tabsManager", "addTabBtn", "activeTabSelect", "showAllTabs",
|
||
"canvasWidth", "canvasHeight", "gridSize", "snapThreshold", "canvasBackground", "canvasBackgroundText", "canvasBackgroundClear",
|
||
"snapEnabled", "gridEnabled", "autoBindEnabled", "zoomOutBtn", "zoomInBtn", "zoomLabel", "bringFrontBtn", "sendBackBtn",
|
||
"duplicateBtn", "deleteBtn", "canvasViewport", "canvasSizer", "canvasStage", "rawData", "dataPaths",
|
||
"selectedType", "emptyInspector", "inspector", "runtimeView", "runtimeTitle", "runtimeTabs", "runtimeEventsToggleBtn", "runtimeEditorBtn", "runtimeShortcutsBtn", "quickTimersBtn", "runtimeLogoutBtn", "runtimeButtonDock",
|
||
"closePreviewBtn", "runtimeViewport", "runtimeSizer", "runtimeStage", "modalHost", "toast", "dataPathList",
|
||
].map((id) => [id, document.getElementById(id)]));
|
||
|
||
const componentCatalog = [
|
||
// Layout
|
||
catalog("container", "Контейнер", "Макет", "▣", "Фоновый блок для группировки", 420, 220, {}, []),
|
||
catalog("card", "Карточка", "Макет", "▤", "Карточка с заголовком и текстом", 320, 170,
|
||
{ body: "Описание карточки" }, [textField("body", "Текст", "textarea")]),
|
||
catalog("divider", "Разделитель", "Макет", "―", "Горизонтальная линия", 360, 24, {}, []),
|
||
catalog("spacer", "Пустое место", "Макет", "↔", "Свободный прозрачный блок", 220, 60, {}, []),
|
||
catalog("tab_bar", "Панель вкладок", "Навигация", "▦", "Переключает вкладки проекта", 560, 52, {}, []),
|
||
catalog("accordion", "Аккордеон", "Макет", "⌄", "Раскрываемый блок", 360, 150,
|
||
{ header: "Подробнее", body: "Содержимое раскрываемого блока", opened: true }, [
|
||
textField("header", "Заголовок"), textField("body", "Содержимое", "textarea"), checkField("opened", "Открыт по умолчанию"),
|
||
]),
|
||
catalog("breadcrumb", "Хлебные крошки", "Навигация", "›", "Строка навигации", 440, 48,
|
||
{ items: "Главная|Турнир|Матч" }, [textField("items", "Пункты", "text", "Через символ |")]),
|
||
catalog("pagination", "Пагинация", "Навигация", "•••", "Переключатель страниц", 300, 48,
|
||
{ pages: 5, current: 1 }, [numberField("pages", "Страниц", 1, 100), numberField("current", "Текущая", 1, 100)]),
|
||
|
||
// Content
|
||
catalog("heading", "Заголовок", "Текст", "H", "Крупный заголовок", 420, 70,
|
||
{ text: "Новый заголовок", level: "h2" }, [textField("text", "Текст"), selectField("level", "Уровень", [["h1","H1"],["h2","H2"],["h3","H3"]])]),
|
||
catalog("text", "Обычный текст", "Текст", "¶", "Статический текст", 360, 100,
|
||
{ text: "Введите текст" }, [textField("text", "Текст", "textarea")]),
|
||
catalog("data_text", "Текст из JSON", "Данные", "{}", "Одно значение по пути JSON", 300, 100,
|
||
{ label: "Значение", path: "event.title", prefix: "", suffix: "", fallback: "—" }, dataValueFields()),
|
||
catalog("kpi", "KPI", "Данные", "#", "Крупное значение с подписью", 260, 130,
|
||
{ label: "Показатель", path: "event.status", prefix: "", suffix: "", fallback: "—" }, dataValueFields()),
|
||
catalog("badge", "Статус / badge", "Текст", "●", "Компактная цветная метка", 160, 42,
|
||
{ text: "LIVE", path: "" }, [textField("text", "Текст"), pathField("path", "Путь JSON (необязательно)")]),
|
||
catalog("image", "Изображение", "Медиа", "▧", "URL или путь из JSON", 300, 190,
|
||
{ src: "https://placehold.co/600x360/162238/eef4ff?text=IMAGE", path: "", alt: "Изображение", fit: "cover" }, [
|
||
textField("src", "URL"), pathField("path", "Путь JSON (вместо URL)"), textField("alt", "Описание"),
|
||
selectField("fit", "Заполнение", [["cover","Обрезать"],["contain","Вместить"],["fill","Растянуть"]]),
|
||
]),
|
||
catalog("icon", "Иконка / emoji", "Медиа", "★", "Символ или emoji", 90, 90,
|
||
{ icon: "★" }, [textField("icon", "Символ")]),
|
||
catalog("alert", "Уведомление", "Текст", "!", "Информационный блок", 380, 90,
|
||
{ text: "Важная информация", path: "" }, [textField("text", "Текст", "textarea"), pathField("path", "Путь JSON")]),
|
||
catalog("progress", "Прогресс", "Данные", "%", "Полоса прогресса", 360, 90,
|
||
{ label: "Прогресс", path: "session.progress", value: 56, max: 100, suffix: "%" }, [
|
||
textField("label", "Подпись"), pathField("path", "Путь JSON"), numberField("value", "Статическое значение", 0, 100000),
|
||
numberField("max", "Максимум", 1, 100000), textField("suffix", "Суффикс"),
|
||
]),
|
||
|
||
catalog("timer", "Таймер", "Таймеры", "◷", "Компактный прямой/обратный таймер без экранных кнопок", 300, 96,
|
||
{
|
||
label: "Таймер",
|
||
mode: "count_up",
|
||
startTime: "00:00",
|
||
endTime: "01:00",
|
||
targetDateTime: "",
|
||
externalPath: "",
|
||
format: "mm_ss",
|
||
customFormat: "{totalMinutes}:{seconds}",
|
||
footballBaseMinute: 45,
|
||
afterEnd: "stop",
|
||
autoStart: false,
|
||
showStatus: true,
|
||
persist: false,
|
||
continueBackground: false,
|
||
updateInterval: 100,
|
||
milestones: ""
|
||
}, [
|
||
textField("label", "Подпись"),
|
||
selectField("mode", "Режим", [
|
||
["count_up", "Прямой отсчёт"],
|
||
["count_down", "Обратный отсчёт"],
|
||
["stopwatch", "Секундомер"],
|
||
["clock", "Текущее время"],
|
||
["until_datetime", "До даты и времени"],
|
||
["external", "Время из JSON"]
|
||
]),
|
||
textField("startTime", "Начальное время", "text", "00:00, 45:00 или 01:30:00"),
|
||
textField("endTime", "Конечное время", "text", "Пусто — без ограничения"),
|
||
textField("targetDateTime", "Дата и время цели", "text", "Например: 2026-07-15T18:00"),
|
||
pathField("externalPath", "Путь к внешнему времени"),
|
||
selectField("format", "Формат", [
|
||
["m_ss", "M:SS"],
|
||
["mm_ss", "MM:SS"],
|
||
["hh_mm_ss", "HH:MM:SS"],
|
||
["m_ss_tenths", "M:SS.d"],
|
||
["mm_ss_ms", "MM:SS.fff"],
|
||
["football", "Футбол: 45′ + 02:15"],
|
||
["clock_hm", "Часы: HH:MM"],
|
||
["clock_hms", "Часы: HH:MM:SS"],
|
||
["custom", "Пользовательский"]
|
||
]),
|
||
textField("customFormat", "Пользовательский формат", "text", "{hours}:{minutes}:{seconds}"),
|
||
numberField("footballBaseMinute", "Базовая минута", 0, 9999),
|
||
selectField("afterEnd", "После конечного времени", [
|
||
["stop", "Остановить"],
|
||
["continue", "Продолжать"],
|
||
["loop", "Запустить заново"]
|
||
]),
|
||
checkField("autoStart", "Автозапуск"),
|
||
checkField("showStatus", "Показывать статус"),
|
||
checkField("persist", "Сохранять состояние"),
|
||
checkField("continueBackground", "Учитывать время после закрытия страницы"),
|
||
numberField("updateInterval", "Частота обновления, мс", 16, 5000),
|
||
textField("milestones", "Контрольные точки", "textarea", "Например: 00:10|00:30|01:00")
|
||
]),
|
||
|
||
catalog("penalty_timer", "Хоккей: таймер удаления", "Таймеры", "2′", "Компактная карточка удаления игрока", 310, 78,
|
||
{
|
||
label: "Удаление",
|
||
playerNumber: "17",
|
||
playerName: "Игрок",
|
||
team: "HOME",
|
||
teamSide: "home",
|
||
mode: "count_down",
|
||
startTime: "02:00",
|
||
endTime: "00:00",
|
||
format: "m_ss",
|
||
afterEnd: "stop",
|
||
autoStart: false,
|
||
showStatus: false,
|
||
persist: false,
|
||
continueBackground: false,
|
||
updateInterval: 100,
|
||
milestones: "00:30|00:10|00:00",
|
||
warningAt: "00:15",
|
||
showProgress: true,
|
||
hideWhenFinished: false,
|
||
expiredText: "Штраф завершён"
|
||
}, [
|
||
textField("label", "Тип штрафа"),
|
||
textField("playerNumber", "Номер игрока"),
|
||
textField("playerName", "Игрок"),
|
||
textField("team", "Команда"),
|
||
selectField("teamSide", "Сторона", [
|
||
["home", "Хозяева"],
|
||
["away", "Гости"],
|
||
["neutral", "Нейтральная"]
|
||
]),
|
||
textField("startTime", "Длительность удаления", "text", "Например: 02:00 или 05:00"),
|
||
textField("endTime", "Конечное время", "text", "Обычно 00:00"),
|
||
checkField("autoStart", "Автозапуск"),
|
||
checkField("showProgress", "Показывать полосу времени"),
|
||
checkField("hideWhenFinished", "Скрывать после завершения"),
|
||
textField("warningAt", "Предупреждение с", "text", "Например: 00:15"),
|
||
textField("expiredText", "Текст после завершения"),
|
||
checkField("persist", "Сохранять состояние"),
|
||
checkField("continueBackground", "Учитывать время после закрытия страницы"),
|
||
textField("milestones", "Контрольные точки", "textarea", "Например: 01:00|00:30|00:10")
|
||
]),
|
||
|
||
catalog("hockey_penalty_dashboard", "Хоккей: дашборд удалений", "Хоккей", "⇄", "Составы, drag-and-drop, быстрые штрафы и журнал", 1320, 690,
|
||
{
|
||
homePlayersPath: "hockey.home.players",
|
||
awayPlayersPath: "hockey.away.players",
|
||
homeTeamPath: "hockey.home.name",
|
||
awayTeamPath: "hockey.away.name",
|
||
idField: "id",
|
||
numberField: "number",
|
||
nameField: "name",
|
||
positionField: "position",
|
||
presets: "2=02:00|2+2=04:00|4=04:00|5=05:00|5+20=05:00|10=10:00",
|
||
infractions: "TRIP=Подножка=2|HOOK=Задержка клюшкой=2|HOLD=Задержка соперника=2|INTERF=Атака игрока, не владеющего шайбой=2|ROUGH=Грубость=2|SLASH=Удар клюшкой=2|HIGH=Игра высоко поднятой клюшкой=2+2|BOARD=Толчок на борт=2|CHARGE=Неправильная атака=2|ELBOW=Удар локтем=2|TOO_MANY=Нарушение численного состава=2|DELAY=Задержка игры=2|UNSPORT=Неспортивное поведение=10|FIGHT=Драка=5+20",
|
||
defaultPreset: "2",
|
||
gameTimerActionId: "hockey_game_timer",
|
||
maxActivePerTeam: 8,
|
||
rosterLimit: 40,
|
||
historyLimit: 16,
|
||
autoStartOnAssign: false,
|
||
showSearch: true,
|
||
persist: false,
|
||
homeColor: "#4d9cff",
|
||
awayColor: "#ff5f79",
|
||
warningAt: "00:15",
|
||
emptyText: "Перетащите игрока сюда"
|
||
}, [
|
||
pathField("homePlayersPath", "Игроки хозяев"),
|
||
pathField("awayPlayersPath", "Игроки гостей"),
|
||
pathField("homeTeamPath", "Название хозяев"),
|
||
pathField("awayTeamPath", "Название гостей"),
|
||
textField("idField", "Поле ID"),
|
||
textField("numberField", "Поле номера"),
|
||
textField("nameField", "Поле имени"),
|
||
textField("positionField", "Поле позиции"),
|
||
textField("presets", "Длительности штрафов", "textarea", "Формат: 2=02:00|2+2=04:00|5+20=05:00"),
|
||
textField("infractions", "Список нарушений", "textarea", "Формат: CODE=Название=Пресет"),
|
||
textField("defaultPreset", "Стандартная длительность"),
|
||
textField("gameTimerActionId", "Action ID времени матча"),
|
||
numberField("maxActivePerTeam", "Событий на команду", 1, 30),
|
||
numberField("rosterLimit", "Игроков в составе", 1, 100),
|
||
numberField("historyLimit", "Строк журнала", 1, 100),
|
||
checkField("showSearch", "Показывать поиск"),
|
||
checkField("persist", "Сохранять активные удаления"),
|
||
textField("homeColor", "Цвет хозяев", "color"),
|
||
textField("awayColor", "Цвет гостей", "color"),
|
||
textField("warningAt", "Предупреждение с", "text", "Например: 00:15"),
|
||
textField("emptyText", "Подсказка области")
|
||
]),
|
||
|
||
catalog("hockey_team_statistics", "Хоккей: командная статистика", "Хоккей", "▥", "Сравнение команд за матч и по периодам", 1320, 690,
|
||
{
|
||
statsPath: "hockey.selected_game.team_statistics",
|
||
playerStatsPath: "hockey.selected_game.player_statistics",
|
||
seasonStatsPath: "hockey.selected_game.season_player_statistics",
|
||
eventsPath: "hockey.selected_game.events",
|
||
shotsMapPath: "hockey.selected_game.shots_map",
|
||
powerplayStatsPath: "hockey.tournament_statistics.powerplay",
|
||
rankStatsPath: "hockey.tournament_statistics.rank",
|
||
homeTeamPath: "hockey.home.name",
|
||
awayTeamPath: "hockey.away.name",
|
||
homeScorePath: "hockey.home.score",
|
||
awayScorePath: "hockey.away.score",
|
||
homeColor: "#4d9cff",
|
||
awayColor: "#ff5f79"
|
||
}, [
|
||
pathField("statsPath", "Командная статистика"),
|
||
pathField("playerStatsPath", "Статистика игроков"),
|
||
pathField("seasonStatsPath", "Сезонная статистика"),
|
||
pathField("eventsPath", "События матча"),
|
||
pathField("shotsMapPath", "Карта бросков"),
|
||
pathField("powerplayStatsPath", "Турнирное большинство"),
|
||
pathField("rankStatsPath", "Турнирный рейтинг"),
|
||
pathField("homeTeamPath", "Название хозяев"),
|
||
pathField("awayTeamPath", "Название гостей"),
|
||
pathField("homeScorePath", "Счёт хозяев"),
|
||
pathField("awayScorePath", "Счёт гостей"),
|
||
textField("homeColor", "Цвет хозяев", "color"),
|
||
textField("awayColor", "Цвет гостей", "color")
|
||
]),
|
||
|
||
catalog("hockey_schedule", "Хоккей: расписание", "Хоккей", "▣", "Красивые карточки всех матчей выбранной лиги на текущую дату", 1320, 690,
|
||
{
|
||
schedulePath: "hockey.schedule",
|
||
allSchedulePath: "hockey.team_schedule",
|
||
tournamentPath: "hockey.selected_tournament",
|
||
selectedGamePath: "hockey.selected_game.external_id",
|
||
homeColor: "#4d9cff",
|
||
awayColor: "#ff5f79"
|
||
}, [
|
||
pathField("schedulePath", "Расписание дня"),
|
||
pathField("allSchedulePath", "Полное расписание лиги"),
|
||
pathField("tournamentPath", "Выбранный турнир"),
|
||
pathField("selectedGamePath", "ID выбранного матча"),
|
||
textField("homeColor", "Цвет хозяев", "color"),
|
||
textField("awayColor", "Цвет гостей", "color")
|
||
]),
|
||
|
||
catalog("hockey_prematch_panel", "Хоккей: прематч", "Хоккей", "◫", "Настраиваемые операторские кнопки прематча и запуск общих Shortcut Sequence", 1320, 690,
|
||
{
|
||
title: "Прематч",
|
||
}, [
|
||
textField("title", "Заголовок"),
|
||
]),
|
||
|
||
catalog("hockey_shootout_control", "Хоккей: буллиты", "Хоккей", "◎", "Участники, журнал и управление серией буллитов", 1320, 690,
|
||
{
|
||
gamePath: "hockey.selected_game",
|
||
tournamentPath: "hockey.selected_tournament",
|
||
homeColor: "#4d9cff",
|
||
awayColor: "#ff5f79"
|
||
}, [
|
||
pathField("gamePath", "Выбранный матч"),
|
||
pathField("tournamentPath", "Выбранный турнир"),
|
||
textField("homeColor", "Цвет хозяев", "color"),
|
||
textField("awayColor", "Цвет гостей", "color")
|
||
]),
|
||
|
||
catalog("hockey_referees", "Хоккей: судьи", "Хоккей", "⚑", "Главные и линейные судьи выбранного матча", 1320, 690,
|
||
{
|
||
refereesPath: "hockey.referees",
|
||
homeTeamPath: "hockey.home.name",
|
||
awayTeamPath: "hockey.away.name"
|
||
}, [
|
||
pathField("refereesPath", "Список судей"),
|
||
pathField("homeTeamPath", "Название хозяев"),
|
||
pathField("awayTeamPath", "Название гостей")
|
||
]),
|
||
|
||
catalog("hockey_tournament_standings", "Хоккей: турнирная таблица", "Хоккей", "▦", "Общая таблица, конференции и дивизионы", 1320, 690,
|
||
{
|
||
standingsPath: "hockey.standings",
|
||
homeColor: "#4d9cff",
|
||
awayColor: "#ff5f79"
|
||
}, [
|
||
pathField("standingsPath", "Данные турнирной таблицы"),
|
||
textField("homeColor", "Цвет хозяев", "color"),
|
||
textField("awayColor", "Цвет гостей", "color")
|
||
]),
|
||
|
||
catalog("hockey_player_statistics", "Хоккей: статистика игроков", "Хоккей", "№", "Полевые игроки и отдельная статистика вратарей", 1320, 690,
|
||
{
|
||
statsPath: "hockey.selected_game.player_statistics",
|
||
homeTeamPath: "hockey.home.name",
|
||
awayTeamPath: "hockey.away.name",
|
||
homeColor: "#4d9cff",
|
||
awayColor: "#ff5f79"
|
||
}, [
|
||
pathField("statsPath", "Персональная статистика"),
|
||
pathField("homeTeamPath", "Название хозяев"),
|
||
pathField("awayTeamPath", "Название гостей"),
|
||
textField("homeColor", "Цвет хозяев", "color"),
|
||
textField("awayColor", "Цвет гостей", "color")
|
||
]),
|
||
|
||
catalog("list", "Список", "Данные", "☷", "Массив JSON или статические строки", 320, 190,
|
||
{ path: "filters.rounds", items: "Первый|Второй|Третий" }, [pathField("path", "Путь к массиву"), textField("items", "Статические пункты", "textarea", "Через |")]),
|
||
catalog("key_value", "Ключ — значение", "Данные", "≡", "Вывод объекта в две колонки", 360, 210,
|
||
{ path: "event" }, [pathField("path", "Путь к объекту")]),
|
||
catalog("iframe", "Встроенная страница", "Медиа", "◫", "iframe по URL", 520, 300,
|
||
{ url: "https://example.com", title: "Встроенная страница" }, [textField("url", "URL"), textField("title", "Описание")]),
|
||
catalog("video", "Видео", "Медиа", "▶", "Видео по URL", 420, 240,
|
||
{ url: "", controls: true, autoplay: false, muted: true }, [textField("url", "URL видео"), checkField("controls", "Показывать управление"), checkField("autoplay", "Автозапуск"), checkField("muted", "Без звука")]),
|
||
|
||
// Data visualization
|
||
catalog("table", "Таблица", "Данные", "▦", "Таблица из массива JSON", 760, 330,
|
||
{ path: "leaderboard", columns: "position:Поз.|name:Имя|team:Команда|score:Счёт", limit: 20, emptyText: "Нет данных" }, [
|
||
pathField("path", "Путь к массиву"), textField("columns", "Колонки", "textarea", "field:Название|field2:Название"),
|
||
numberField("limit", "Лимит строк", 1, 1000), textField("emptyText", "Текст без данных"),
|
||
]),
|
||
catalog("cards", "Повторяющиеся карточки", "Данные", "▥", "Карточки из массива JSON", 700, 300,
|
||
{ path: "leaderboard", titleField: "name", subtitleField: "team", valueField: "score", limit: 12 }, [
|
||
pathField("path", "Путь к массиву"), textField("titleField", "Поле заголовка"), textField("subtitleField", "Поле подписи"),
|
||
textField("valueField", "Поле значения"), numberField("limit", "Лимит", 1, 100),
|
||
]),
|
||
catalog("bar_chart", "Столбчатый график", "Графики", "▥", "Простой bar chart", 560, 280,
|
||
{ path: "chart", labelField: "label", valueField: "value", limit: 20 }, chartFields()),
|
||
catalog("line_chart", "Линейный график", "Графики", "⌁", "SVG line chart", 560, 280,
|
||
{ path: "chart", labelField: "label", valueField: "value", limit: 30 }, chartFields()),
|
||
catalog("json_viewer", "Просмотр JSON", "Данные", "{…}", "Форматированный JSON", 520, 300,
|
||
{ path: "" }, [pathField("path", "Путь JSON (пусто = всё)")]),
|
||
|
||
// Forms
|
||
catalog("text_input", "Текстовое поле", "Формы", "Aa", "Однострочный ввод", 320, 76,
|
||
inputProps("Текст", "Введите текст"), inputFields()),
|
||
catalog("number_input", "Числовое поле", "Формы", "123", "Ввод числа", 260, 76,
|
||
{ ...inputProps("Число", "0"), min: 0, max: 100, step: 1 }, [...inputFields(), numberField("min", "Минимум", -999999, 999999), numberField("max", "Максимум", -999999, 999999), numberField("step", "Шаг", 0.001, 999999)]),
|
||
catalog("textarea", "Многострочное поле", "Формы", "¶", "Большое поле ввода", 360, 150,
|
||
inputProps("Комментарий", "Введите текст"), inputFields()),
|
||
catalog("select", "Выпадающий список", "Формы", "⌄", "Одиночный выбор", 320, 76,
|
||
{ label: "Выберите", options: "one:Первый|two:Второй|three:Третий", optionsPath: "", value: "one" }, optionFields(false)),
|
||
catalog("multiselect", "Мультивыбор", "Формы", "☑", "Выбор нескольких пунктов", 340, 125,
|
||
{ label: "Выберите несколько", options: "one:Первый|two:Второй|three:Третий", optionsPath: "", value: "one|two" }, optionFields(true)),
|
||
catalog("checkbox", "Checkbox", "Формы", "☑", "Обычный флажок", 260, 54,
|
||
{ label: "Включено", checked: true }, [textField("label", "Подпись"), checkField("checked", "Включён")]),
|
||
catalog("switch", "Переключатель", "Формы", "◉", "Визуальный switch", 260, 54,
|
||
{ label: "Онлайн", checked: true }, [textField("label", "Подпись"), checkField("checked", "Включён")]),
|
||
catalog("radio", "Radio-группа", "Формы", "◉", "Один вариант из нескольких", 380, 80,
|
||
{ label: "Режим", options: "auto:Авто|manual:Ручной|off:Выкл.", value: "auto" }, [textField("label", "Подпись"), textField("options", "Варианты", "textarea", "value:Название|...") , textField("value", "Выбрано")]),
|
||
catalog("date", "Дата", "Формы", "▣", "Выбор даты", 250, 76, inputProps("Дата", ""), inputFields()),
|
||
catalog("time", "Время", "Формы", "◷", "Выбор времени", 220, 76, inputProps("Время", ""), inputFields()),
|
||
catalog("datetime", "Дата и время", "Формы", "◴", "Выбор даты и времени", 300, 76, inputProps("Дата и время", ""), inputFields()),
|
||
catalog("color", "Выбор цвета", "Формы", "◈", "Color picker", 240, 76,
|
||
{ label: "Цвет", value: "#48dfbd" }, [textField("label", "Подпись"), textField("value", "Цвет")]),
|
||
catalog("range", "Ползунок", "Формы", "↔", "Range slider", 340, 82,
|
||
{ label: "Значение", value: 50, min: 0, max: 100, step: 1 }, [textField("label", "Подпись"), numberField("value", "Значение", -999999, 999999), numberField("min", "Минимум", -999999, 999999), numberField("max", "Максимум", -999999, 999999), numberField("step", "Шаг", .001, 999999)]),
|
||
catalog("file", "Загрузка файла", "Формы", "⇧", "File input", 340, 76,
|
||
{ label: "Выберите файл", accept: "*/*", multiple: false }, [textField("label", "Подпись"), textField("accept", "Типы файлов"), checkField("multiple", "Несколько файлов")]),
|
||
|
||
// Actions
|
||
catalog("button", "Кнопка", "Действия", "▰", "Кнопка с действием", 220, 52,
|
||
{ text: "Кнопка", action: "none", targetTab: "main", url: "", message: "", eventName: "ui-builder-action" }, actionFields()),
|
||
catalog("link", "Ссылка", "Действия", "↗", "Ссылка, стилизованная как кнопка", 220, 52,
|
||
{ text: "Открыть ссылку", url: "https://example.com", newTab: true, variant: "secondary" }, [textField("text", "Текст"), textField("url", "URL"), checkField("newTab", "В новой вкладке"), selectField("variant", "Стиль", [["primary","Основной"],["secondary","Вторичный"]])]),
|
||
catalog("button_group", "Группа кнопок", "Действия", "▰▰", "Несколько кнопок", 430, 52,
|
||
{ buttons: "start:Старт|stop:Стоп|reset:Сброс" }, [textField("buttons", "Кнопки", "textarea", "Формат: id:Название|id2:Название")]),
|
||
catalog("modal", "Кнопка модального окна", "Действия", "□", "Открывает окно", 240, 52,
|
||
{ text: "Открыть окно", modalTitle: "Модальное окно", modalBody: "Содержимое окна" }, [textField("text", "Текст кнопки"), textField("modalTitle", "Заголовок"), textField("modalBody", "Содержимое", "textarea")]),
|
||
];
|
||
|
||
const catalogMap = Object.fromEntries(componentCatalog.map((item) => [item.type, item]));
|
||
|
||
const interactiveTypes = new Set([
|
||
"button", "button_group", "link", "modal", "tab_bar", "accordion", "pagination",
|
||
"text_input", "number_input", "textarea", "select", "multiselect", "checkbox", "switch",
|
||
"radio", "date", "time", "datetime", "color", "range", "file", "timer", "hockey_penalty_dashboard", "hockey_tournament_standings", "hockey_player_statistics"
|
||
]);
|
||
|
||
function isInteractiveComponent(component) { return interactiveTypes.has(component?.type); }
|
||
function defaultInteractionMode(type) {
|
||
if (["text_input","number_input","textarea","select","multiselect","radio","date","time","datetime","color","range","file","tab_bar","pagination"].includes(type)) return "value";
|
||
if (["checkbox","switch","accordion"].includes(type)) return "toggle";
|
||
return "event_only";
|
||
}
|
||
function sanitizeActionId(value, fallback = "action") {
|
||
const clean = String(value || "").trim().replace(/[^a-zA-Z0-9_.:-]+/g, "_").replace(/^_+|_+$/g, "");
|
||
return clean || fallback;
|
||
}
|
||
function actionIdExists(actionId, exceptComponentId = null) {
|
||
return state.config.components.some((item) => item.id !== exceptComponentId && item.action_id === actionId);
|
||
}
|
||
function uniqueActionId(base, exceptComponentId = null) {
|
||
const clean = sanitizeActionId(base, "action");
|
||
let candidate = clean;
|
||
let suffix = 2;
|
||
while (actionIdExists(candidate, exceptComponentId)) candidate = `${clean}_${suffix++}`;
|
||
return candidate;
|
||
}
|
||
function slugFromText(value) {
|
||
const translit = {а:"a",б:"b",в:"v",г:"g",д:"d",е:"e",ё:"e",ж:"zh",з:"z",и:"i",й:"y",к:"k",л:"l",м:"m",н:"n",о:"o",п:"p",р:"r",с:"s",т:"t",у:"u",ф:"f",х:"h",ц:"c",ч:"ch",ш:"sh",щ:"sch",ы:"y",э:"e",ю:"yu",я:"ya",ь:"",ъ:""};
|
||
return sanitizeActionId(String(value || "").toLowerCase().split("").map((char) => translit[char] ?? char).join("").replace(/[^a-z0-9]+/g, "_"), "action");
|
||
}
|
||
|
||
function catalog(type, label, category, icon, description, w, h, props, fields) {
|
||
return { type, label, category, icon, description, w, h, props, fields };
|
||
}
|
||
function textField(key, label, type = "text", help = "") { return { key, label, type, help }; }
|
||
function numberField(key, label, min, max) { return { key, label, type: "number", min, max }; }
|
||
function checkField(key, label) { return { key, label, type: "checkbox" }; }
|
||
function selectField(key, label, options) { return { key, label, type: "select", options }; }
|
||
function pathField(key, label) { return { key, label, type: "path", help: "Можно выбрать путь из списка данных." }; }
|
||
function dataValueFields() {
|
||
return [textField("label", "Подпись"), pathField("path", "Путь JSON"), textField("prefix", "Префикс"), textField("suffix", "Суффикс"), textField("fallback", "Если пусто")];
|
||
}
|
||
function chartFields() {
|
||
return [pathField("path", "Путь к массиву"), textField("labelField", "Поле подписи"), textField("valueField", "Поле значения"), numberField("limit", "Лимит", 1, 100)];
|
||
}
|
||
function inputProps(label, placeholder) { return { label, placeholder, value: "", bindPath: "", required: false, disabled: false }; }
|
||
function inputFields() {
|
||
return [textField("label", "Подпись"), textField("placeholder", "Подсказка"), textField("value", "Начальное значение"), pathField("bindPath", "Начальное значение из JSON"), checkField("required", "Обязательное"), checkField("disabled", "Отключено")];
|
||
}
|
||
function optionFields(multiple) {
|
||
return [textField("label", "Подпись"), textField("options", "Варианты", "textarea", "value:Название|..."), pathField("optionsPath", "Путь к массиву вариантов"), textField("value", multiple ? "Выбрано через |" : "Выбрано")];
|
||
}
|
||
function actionFields() {
|
||
return [
|
||
textField("text", "Текст"),
|
||
selectField("action", "Действие", [["none","Только триггеры"],["refresh","Обновить данные"],["set_tab","Открыть вкладку"],["open_url","Открыть URL"],["message","Показать сообщение"],["event","JS-событие"]]),
|
||
textField("targetTab", "ID вкладки"), textField("url", "URL"), textField("message", "Сообщение"), textField("eventName", "Имя события"),
|
||
];
|
||
}
|
||
|
||
function getByPath(object, path) {
|
||
if (!path) return object;
|
||
return String(path).split(".").filter(Boolean).reduce((acc, key) => {
|
||
if (acc == null) return undefined;
|
||
if (/^\d+$/.test(key) && Array.isArray(acc)) return acc[Number(key)];
|
||
return acc[key];
|
||
}, object);
|
||
}
|
||
|
||
function formatValue(value, fallback = "—") {
|
||
if (value === undefined || value === null || value === "") return fallback;
|
||
if (typeof value === "object") return JSON.stringify(value);
|
||
return String(value);
|
||
}
|
||
|
||
function parseOptions(text) {
|
||
return String(text || "").split("|").map((part) => part.trim()).filter(Boolean).map((part) => {
|
||
const [value, ...label] = part.split(":");
|
||
return { value: value.trim(), label: (label.join(":") || value).trim() };
|
||
});
|
||
}
|
||
|
||
function parseColumns(text) {
|
||
return parseOptions(text).map((item) => ({ key: item.value, label: item.label }));
|
||
}
|
||
|
||
function flattenPaths(value, prefix = "", output = [], depth = 0) {
|
||
if (depth > 5 || output.length > 500) return output;
|
||
if (prefix) output.push(prefix);
|
||
if (Array.isArray(value)) {
|
||
if (value[0] !== undefined) flattenPaths(value[0], prefix ? `${prefix}.0` : "0", output, depth + 1);
|
||
} else if (value && typeof value === "object") {
|
||
Object.entries(value).forEach(([key, child]) => flattenPaths(child, prefix ? `${prefix}.${key}` : key, output, depth + 1));
|
||
}
|
||
return [...new Set(output)];
|
||
}
|
||
|
||
function defaultStyle() {
|
||
return {
|
||
background: "",
|
||
color: "",
|
||
accent: "",
|
||
borderColor: "",
|
||
borderWidth: 0,
|
||
borderRadius: 10,
|
||
padding: 0,
|
||
fontSize: 14,
|
||
fontWeight: 400,
|
||
align: "left",
|
||
opacity: 100,
|
||
shadow: false,
|
||
};
|
||
}
|
||
|
||
function makeComponent(type, x = 30, y = 30, overrides = {}) {
|
||
const meta = catalogMap[type] || catalogMap.text;
|
||
const maxZ = Math.max(0, ...state.config.components.map((item) => Number(item.z) || 0));
|
||
return {
|
||
id: uid(),
|
||
type,
|
||
title: meta.label,
|
||
x,
|
||
y,
|
||
w: meta.w,
|
||
h: meta.h,
|
||
z: maxZ + 1,
|
||
tabs: [state.activeTab || state.config.tabs[0]?.id || "main"],
|
||
props: clone(meta.props),
|
||
style: defaultStyle(),
|
||
locked: false,
|
||
hidden: false,
|
||
parent_id: null,
|
||
action_id: uniqueActionId(`${type}_${Math.random().toString(36).slice(2, 8)}`),
|
||
interaction_mode: defaultInteractionMode(type),
|
||
initial_state: false,
|
||
shortcuts: [],
|
||
...clone(overrides),
|
||
};
|
||
}
|
||
|
||
function comp(type, title, x, y, w, h, tabs, props = {}, style = {}) {
|
||
const item = makeComponent(type, x, y);
|
||
item.title = title;
|
||
item.action_id = uniqueActionId(`${type}_${slugFromText(title)}`);
|
||
item.w = w;
|
||
item.h = h;
|
||
item.tabs = tabs;
|
||
item.props = { ...item.props, ...props };
|
||
item.style = { ...item.style, ...style };
|
||
return item;
|
||
}
|
||
|
||
const templates = {
|
||
hockey_penalties: () => {
|
||
const gameTimer = comp("timer", "Время периода", 520, 28, 400, 82, ["main"], {
|
||
label: "Время периода", mode: "count_down", startTime: "20:00", endTime: "00:00",
|
||
format: "mm_ss", afterEnd: "stop", autoStart: false, showStatus: true,
|
||
persist: false, continueBackground: false, runtimeHidden: true,
|
||
milestones: "10:00|05:00|01:00|00:10|00:00"
|
||
}, {
|
||
background: "#101d31", color: "#ffffff", accent: "#48dfbd", borderColor: "#2e4663",
|
||
borderWidth: 1, borderRadius: 16, padding: 12, fontSize: 17, fontWeight: 900, shadow: true
|
||
});
|
||
gameTimer.id = "hockey-dashboard-game-timer";
|
||
gameTimer.action_id = "hockey_game_timer";
|
||
gameTimer.shortcuts = [];
|
||
|
||
const board = comp("hockey_penalty_dashboard", "Дашборд удалений", 10, 10, 1420, 740, ["main"], {
|
||
homePlayersPath: "hockey.home.players", awayPlayersPath: "hockey.away.players",
|
||
homeTeamPath: "hockey.home.name", awayTeamPath: "hockey.away.name",
|
||
idField: "id", numberField: "number", nameField: "name", positionField: "position",
|
||
presets: "2=02:00|2+2=04:00|4=04:00|5=05:00|5+20=05:00|10=10:00",
|
||
infractions: "TRIP=Подножка=2|HOOK=Задержка клюшкой=2|HOLD=Задержка соперника=2|INTERF=Атака игрока, не владеющего шайбой=2|ROUGH=Грубость=2|SLASH=Удар клюшкой=2|HIGH=Игра высоко поднятой клюшкой=2+2|BOARD=Толчок на борт=2|CHARGE=Неправильная атака=2|ELBOW=Удар локтем=2|TOO_MANY=Нарушение численного состава=2|DELAY=Задержка игры=2|UNSPORT=Неспортивное поведение=10|FIGHT=Драка=5+20",
|
||
defaultPreset: "2", gameTimerActionId: "hockey_game_timer",
|
||
maxActivePerTeam: 8, rosterLimit: 40, historyLimit: 16,
|
||
autoStartOnAssign: false, showSearch: true, persist: false,
|
||
homeColor: "#4d9cff", awayColor: "#ff5f79", warningAt: "00:15",
|
||
emptyText: "Перетащите игрока сюда"
|
||
}, {
|
||
background: "#091321", color: "#eef4ff", accent: "#48dfbd", borderColor: "#263b55",
|
||
borderWidth: 1, borderRadius: 20, padding: 0, fontSize: 14, fontWeight: 700, shadow: true
|
||
});
|
||
board.id = "hockey-penalty-dashboard";
|
||
board.action_id = "hockey_penalty_dashboard";
|
||
|
||
const statistics = comp("hockey_team_statistics", "Командная статистика", 0, 0, 1440, 760, ["statistics"], {
|
||
statsPath: "hockey.selected_game.team_statistics",
|
||
playerStatsPath: "hockey.selected_game.player_statistics",
|
||
seasonStatsPath: "hockey.selected_game.season_player_statistics",
|
||
eventsPath: "hockey.selected_game.events",
|
||
shotsMapPath: "hockey.selected_game.shots_map",
|
||
powerplayStatsPath: "hockey.tournament_statistics.powerplay",
|
||
rankStatsPath: "hockey.tournament_statistics.rank",
|
||
homeTeamPath: "hockey.home.name", awayTeamPath: "hockey.away.name",
|
||
homeScorePath: "hockey.home.score", awayScorePath: "hockey.away.score",
|
||
homeColor: "#4d9cff", awayColor: "#ff5f79"
|
||
}, {
|
||
background: "#091321", color: "#eef4ff", accent: "#48dfbd", borderColor: "#263b55",
|
||
borderWidth: 1, borderRadius: 20, padding: 0, fontSize: 14, fontWeight: 700, shadow: true
|
||
});
|
||
statistics.id = "hockey-team-statistics";
|
||
statistics.action_id = "hockey_team_statistics";
|
||
|
||
const schedule = comp("hockey_schedule", "Расписание", 0, 0, 1440, 760, ["schedule"], {
|
||
schedulePath: "hockey.schedule",
|
||
allSchedulePath: "hockey.team_schedule",
|
||
tournamentPath: "hockey.selected_tournament",
|
||
selectedGamePath: "hockey.selected_game.external_id",
|
||
homeColor: "#4d9cff", awayColor: "#ff5f79"
|
||
}, {
|
||
background: "#091321", color: "#eef4ff", accent: "#48dfbd", borderColor: "#263b55",
|
||
borderWidth: 1, borderRadius: 20, padding: 0, fontSize: 14, fontWeight: 700, shadow: true
|
||
});
|
||
schedule.id = "hockey-schedule";
|
||
|
||
const shootout = comp("hockey_shootout_control", "Буллиты", 0, 0, 1440, 760, ["shootout"], {
|
||
gamePath: "hockey.selected_game",
|
||
tournamentPath: "hockey.selected_tournament",
|
||
homeColor: "#4d9cff", awayColor: "#ff5f79"
|
||
}, {
|
||
background: "#091321", color: "#eef4ff", accent: "#48dfbd", borderColor: "#263b55",
|
||
borderWidth: 1, borderRadius: 20, padding: 0, fontSize: 14, fontWeight: 700, shadow: true
|
||
});
|
||
shootout.id = "hockey-shootout-control";
|
||
shootout.action_id = "hockey_shootout_control";
|
||
|
||
const referees = comp("hockey_referees", "Судьи", 0, 0, 1440, 760, ["referees"], {
|
||
refereesPath: "hockey.referees", homeTeamPath: "hockey.home.name", awayTeamPath: "hockey.away.name"
|
||
}, {
|
||
background: "#091321", color: "#eef4ff", accent: "#48dfbd", borderColor: "#263b55",
|
||
borderWidth: 1, borderRadius: 20, padding: 0, fontSize: 14, fontWeight: 700, shadow: true
|
||
});
|
||
referees.id = "hockey-referees";
|
||
referees.action_id = "hockey_referees";
|
||
|
||
const standings = comp("hockey_tournament_standings", "Турнирная таблица", 0, 0, 1440, 760, ["standings"], {
|
||
standingsPath: "hockey.standings",
|
||
homeColor: "#4d9cff", awayColor: "#ff5f79"
|
||
}, {
|
||
background: "#091321", color: "#eef4ff", accent: "#48dfbd", borderColor: "#263b55",
|
||
borderWidth: 1, borderRadius: 20, padding: 0, fontSize: 14, fontWeight: 700, shadow: true
|
||
});
|
||
standings.id = "hockey-tournament-standings";
|
||
standings.action_id = "hockey_tournament_standings";
|
||
|
||
return {
|
||
project_name: "Хоккей — назначения удалений",
|
||
data_source: "vmix_demo",
|
||
canvas: { ...state.config.canvas, width: 1440, height: 760, background: "#060d17", show_grid: true, snap_enabled: true },
|
||
tabs: [{ id: "main", label: "Игра" }, { id: "shootout", label: "Буллиты" }, { id: "referees", label: "Судьи" }, { id: "statistics", label: "Статистика" }, { id: "schedule", label: "Расписание" }, { id: "standings", label: "Турнирная таблица" }],
|
||
components: [gameTimer, board, shootout, referees, statistics, schedule, standings],
|
||
triggers: []
|
||
};
|
||
},
|
||
hockey: () => {
|
||
const panel = comp("container", "Панель хоккейных таймеров", 30, 30, 1220, 500, ["main"], {}, {
|
||
background: "#0b1422", borderColor: "#263a53", borderWidth: 1, borderRadius: 20, shadow: true
|
||
});
|
||
panel.id = "hockey-timers-panel";
|
||
panel.action_id = "hockey_timers_panel";
|
||
|
||
const title = comp("heading", "Хоккейные таймеры", 65, 55, 500, 42, ["main"], {
|
||
text: "ХОККЕЙ / УПРАВЛЕНИЕ ВРЕМЕНЕМ"
|
||
}, { fontSize: 22, fontWeight: 900, color: "#eef4ff" });
|
||
title.id = "hockey-title";
|
||
title.parent_id = panel.id;
|
||
|
||
const gameTimer = comp("timer", "Время матча", 65, 115, 420, 110, ["main"], {
|
||
label: "Время периода",
|
||
mode: "count_down",
|
||
startTime: "20:00",
|
||
endTime: "00:00",
|
||
format: "mm_ss",
|
||
afterEnd: "stop",
|
||
autoStart: false,
|
||
showStatus: true,
|
||
persist: false,
|
||
continueBackground: false,
|
||
milestones: "10:00|05:00|01:00|00:10|00:00"
|
||
}, {
|
||
background: "#111e31", color: "#ffffff", accent: "#48dfbd",
|
||
borderColor: "#304763", borderWidth: 1, borderRadius: 16,
|
||
padding: 14, fontSize: 18, fontWeight: 900, shadow: true
|
||
});
|
||
gameTimer.id = "hockey-game-timer";
|
||
gameTimer.action_id = "hockey_game_timer";
|
||
gameTimer.parent_id = panel.id;
|
||
gameTimer.shortcuts = [];
|
||
|
||
const homeCaption = comp("heading", "Удаления хозяев", 535, 58, 300, 36, ["main"], {
|
||
text: "ХОЗЯЕВА / УДАЛЕНИЯ"
|
||
}, { fontSize: 14, fontWeight: 900, color: "#8ebfff" });
|
||
homeCaption.id = "home-penalties-title";
|
||
homeCaption.parent_id = panel.id;
|
||
|
||
const awayCaption = comp("heading", "Удаления гостей", 870, 58, 300, 36, ["main"], {
|
||
text: "ГОСТИ / УДАЛЕНИЯ"
|
||
}, { fontSize: 14, fontWeight: 900, color: "#ff9baa" });
|
||
awayCaption.id = "away-penalties-title";
|
||
awayCaption.parent_id = panel.id;
|
||
|
||
const makePenalty = (id, actionId, x, y, side, team, number, player, combo, resetCombo) => {
|
||
const item = comp("penalty_timer", `${team} — ${player}`, x, y, 310, 82, ["main"], {
|
||
label: "2 минуты",
|
||
playerNumber: number,
|
||
playerName: player,
|
||
team,
|
||
teamSide: side,
|
||
mode: "count_down",
|
||
startTime: "02:00",
|
||
endTime: "00:00",
|
||
format: "m_ss",
|
||
afterEnd: "stop",
|
||
autoStart: false,
|
||
showStatus: false,
|
||
persist: false,
|
||
continueBackground: false,
|
||
milestones: "01:00|00:30|00:10|00:00",
|
||
warningAt: "00:15",
|
||
showProgress: true,
|
||
hideWhenFinished: false,
|
||
expiredText: "Удаление завершено"
|
||
}, {
|
||
background: side === "home" ? "#10213a" : "#2a1520",
|
||
color: "#ffffff",
|
||
accent: side === "home" ? "#4d9cff" : "#ff5f79",
|
||
borderColor: side === "home" ? "#315a8b" : "#713345",
|
||
borderWidth: 1, borderRadius: 13, padding: 0,
|
||
fontSize: 15, fontWeight: 800, shadow: true
|
||
});
|
||
item.id = id;
|
||
item.action_id = actionId;
|
||
item.parent_id = panel.id;
|
||
item.shortcuts = [
|
||
normalizeShortcut({ id: `${id}-toggle`, enabled: true, combo, event: "timer_toggle", prevent_default: true, scope: "runtime" }, 0),
|
||
normalizeShortcut({ id: `${id}-reset`, enabled: true, combo: resetCombo, event: "timer_reset", prevent_default: true, scope: "runtime" }, 1)
|
||
];
|
||
return item;
|
||
};
|
||
|
||
const home1 = makePenalty("home-penalty-1", "home_penalty_1", 535, 110, "home", "HOME", "17", "Иванов", "Num1", "Shift+Num1");
|
||
const home2 = makePenalty("home-penalty-2", "home_penalty_2", 535, 205, "home", "HOME", "91", "Петров", "Num2", "Shift+Num2");
|
||
const away1 = makePenalty("away-penalty-1", "away_penalty_1", 870, 110, "away", "AWAY", "24", "Соколов", "Num7", "Shift+Num7");
|
||
const away2 = makePenalty("away-penalty-2", "away_penalty_2", 870, 205, "away", "AWAY", "68", "Орлов", "Num8", "Shift+Num8");
|
||
|
||
const help = comp("text", "Подсказка", 65, 265, 420, 125, ["main"], {
|
||
text: "Num1 / Num2 — удаления хозяев\nNum7 / Num8 — удаления гостей\nShift + клавиша — сброс удаления\nОсновной таймер назначайте через видимые Shortcut Sequence"
|
||
}, { fontSize: 13, color: "#91a5bf", background: "#0c1726", borderColor: "#263b55", borderWidth: 1, borderRadius: 12 });
|
||
help.id = "hockey-shortcuts-help";
|
||
help.parent_id = panel.id;
|
||
|
||
return {
|
||
project_name: "Хоккей — панель таймеров",
|
||
data_source: state.config.data_source || "vmix_demo",
|
||
canvas: {
|
||
...state.config.canvas,
|
||
width: 1280,
|
||
height: 560,
|
||
background: "#07101b",
|
||
show_grid: true,
|
||
snap_enabled: true
|
||
},
|
||
tabs: [{ id: "main", label: "Таймеры" }],
|
||
components: [panel, title, gameTimer, homeCaption, awayCaption, home1, home2, away1, away2, help],
|
||
triggers: [
|
||
normalizeTrigger({
|
||
id: "hockey-game-finished",
|
||
name: "Период завершён",
|
||
enabled: true,
|
||
source_action_id: "hockey_game_timer",
|
||
event: "timer_finished",
|
||
item_id: "",
|
||
condition: { field: "", operator: "equals", value: "" },
|
||
action: {
|
||
type: "show_message", target_action_id: "", state_key: "active", value: "",
|
||
function_name: "", event_name: "ui-builder:custom",
|
||
message: "Время периода завершено", tab_id: "", url: "",
|
||
method: "GET", body: "", timer_command: "toggle", timer_value: ""
|
||
}
|
||
}, 0)
|
||
]
|
||
};
|
||
},
|
||
vmix: () => ({
|
||
project_name: "vMix — Overlay 4 Input 2",
|
||
data_source: "vmix_demo",
|
||
canvas: {
|
||
...state.config.canvas,
|
||
width: 960,
|
||
height: 540,
|
||
background: "#0c1421",
|
||
show_grid: true,
|
||
snap_enabled: true
|
||
},
|
||
tabs: [{ id: "main", label: "Управление" }],
|
||
components: [
|
||
(() => {
|
||
const button = comp(
|
||
"button",
|
||
"Overlay 4 IN",
|
||
330,
|
||
220,
|
||
300,
|
||
80,
|
||
["main"],
|
||
{
|
||
text: "Overlay 4 IN — Input 2",
|
||
action: "none",
|
||
targetTab: "main",
|
||
url: "",
|
||
message: "",
|
||
eventName: "vmix:overlay4-in"
|
||
},
|
||
{
|
||
background: "#48dfbd",
|
||
color: "#07130f",
|
||
accent: "#48dfbd",
|
||
borderColor: "#72efd3",
|
||
borderWidth: 1,
|
||
borderRadius: 14,
|
||
fontSize: 18,
|
||
fontWeight: 850,
|
||
shadow: true
|
||
}
|
||
);
|
||
button.id = "vmix-overlay4-button";
|
||
button.action_id = "vmix_overlay4_input2";
|
||
button.interaction_mode = "event_only";
|
||
button.shortcuts = [normalizeShortcut({
|
||
id: "shortcut-vmix-overlay4-f9",
|
||
enabled: true,
|
||
combo: "F9",
|
||
event: "click",
|
||
item_id: "",
|
||
value: "",
|
||
prevent_default: true,
|
||
allow_in_inputs: false,
|
||
global: false,
|
||
scope: "runtime"
|
||
}, 0)];
|
||
button.shortcuts.push(normalizeShortcut({
|
||
id: "shortcut-vmix-overlay4-numenter",
|
||
enabled: true,
|
||
combo: "NumEnter",
|
||
event: "click",
|
||
item_id: "",
|
||
value: "",
|
||
prevent_default: true,
|
||
allow_in_inputs: false,
|
||
global: false,
|
||
scope: "runtime"
|
||
}, 1));
|
||
return button;
|
||
})(),
|
||
(() => {
|
||
const timer = comp(
|
||
"timer",
|
||
"Тестовый таймер",
|
||
270,
|
||
70,
|
||
300,
|
||
96,
|
||
["main"],
|
||
{
|
||
label: "Тестовый таймер",
|
||
mode: "count_up",
|
||
startTime: "00:00",
|
||
endTime: "01:00",
|
||
targetDateTime: "",
|
||
externalPath: "",
|
||
format: "mm_ss",
|
||
customFormat: "{totalMinutes}:{seconds}",
|
||
footballBaseMinute: 45,
|
||
afterEnd: "stop",
|
||
autoStart: false,
|
||
showStatus: true,
|
||
persist: false,
|
||
continueBackground: false,
|
||
updateInterval: 100,
|
||
milestones: "00:10|00:30|01:00"
|
||
},
|
||
{
|
||
background: "#101b2b",
|
||
color: "#eef4ff",
|
||
accent: "#48dfbd",
|
||
borderColor: "#2d4058",
|
||
borderWidth: 1,
|
||
borderRadius: 16,
|
||
padding: 16,
|
||
fontSize: 16,
|
||
fontWeight: 700,
|
||
shadow: true
|
||
}
|
||
);
|
||
timer.id = "demo-timer";
|
||
timer.action_id = "demo_timer";
|
||
timer.shortcuts = [
|
||
normalizeShortcut({
|
||
id: "shortcut-demo-timer-space",
|
||
enabled: true,
|
||
combo: "Space",
|
||
event: "timer_toggle",
|
||
prevent_default: true,
|
||
allow_in_inputs: false,
|
||
global: false,
|
||
scope: "runtime"
|
||
}, 0),
|
||
normalizeShortcut({
|
||
id: "shortcut-demo-timer-ctrl-r",
|
||
enabled: true,
|
||
combo: "Ctrl+R",
|
||
event: "timer_reset",
|
||
prevent_default: true,
|
||
allow_in_inputs: false,
|
||
global: false,
|
||
scope: "runtime"
|
||
}, 1)
|
||
];
|
||
return timer;
|
||
})()
|
||
],
|
||
triggers: [
|
||
normalizeTrigger({
|
||
id: "trigger-vmix-overlay4-input2",
|
||
name: "vMix: показать Input 2 в Overlay 4",
|
||
enabled: true,
|
||
source_action_id: "vmix_overlay4_input2",
|
||
event: "click",
|
||
item_id: "",
|
||
condition: { field: "", operator: "equals", value: "" },
|
||
action: {
|
||
type: "http_request",
|
||
target_action_id: "",
|
||
state_key: "active",
|
||
value: "",
|
||
function_name: "",
|
||
event_name: "ui-builder:custom",
|
||
message: "Команда отправлена в vMix",
|
||
tab_id: "",
|
||
url: "/api/vmix/command?function=OverlayInput4In&input_value=2",
|
||
method: "GET",
|
||
body: ""
|
||
}
|
||
}, 0),
|
||
normalizeTrigger({
|
||
id: "trigger-demo-timer-finished",
|
||
name: "Таймер завершён",
|
||
enabled: true,
|
||
source_action_id: "demo_timer",
|
||
event: "timer_finished",
|
||
item_id: "",
|
||
condition: { field: "", operator: "equals", value: "" },
|
||
action: {
|
||
type: "show_message",
|
||
target_action_id: "",
|
||
state_key: "active",
|
||
value: "",
|
||
function_name: "",
|
||
event_name: "ui-builder:custom",
|
||
message: "Таймер завершён",
|
||
tab_id: "",
|
||
url: "",
|
||
method: "GET",
|
||
body: "",
|
||
timer_command: "toggle",
|
||
timer_value: ""
|
||
}
|
||
}, 1)
|
||
]
|
||
}),
|
||
empty: () => ({
|
||
project_name: "Пустой интерфейс", data_source: state.config.data_source || "golf",
|
||
canvas: { ...state.config.canvas }, tabs: [{ id: "main", label: "Основное" }], components: [],
|
||
}),
|
||
golf: () => ({
|
||
project_name: "Гольф — панель оператора", data_source: "golf",
|
||
canvas: { ...state.config.canvas, width: 1440, height: 900 },
|
||
tabs: [{ id: "main", label: "Основное" }, { id: "leaderboard", label: "Leaderboard" }, { id: "controls", label: "Управление" }],
|
||
components: [
|
||
comp("container", "Верхняя панель", 30, 25, 1380, 100, ["*"], {}, { background: "#101a2a", borderColor: "#2d4058", borderWidth: 1, borderRadius: 14 }),
|
||
comp("heading", "Название турнира", 60, 45, 650, 60, ["*"], { text: "Гольф / LIVE SCORING" }, { fontSize: 28, fontWeight: 800 }),
|
||
comp("tab_bar", "Навигация", 790, 49, 590, 52, ["*"], {}),
|
||
comp("data_text", "Турнир", 50, 160, 500, 110, ["main"], { label: "Текущий турнир", path: "event.title" }),
|
||
comp("kpi", "Статус", 580, 160, 250, 130, ["main"], { label: "Статус", path: "event.status" }, { accent: "#48dfbd" }),
|
||
comp("kpi", "Раунд", 850, 160, 250, 130, ["main"], { label: "Раунд", path: "round.title" }),
|
||
comp("kpi", "Лидер", 1120, 160, 260, 130, ["main"], { label: "Лидер", path: "leader.name" }),
|
||
comp("bar_chart", "График", 50, 330, 730, 430, ["main"], { path: "chart", labelField: "label", valueField: "value" }),
|
||
comp("cards", "Топ игроков", 810, 330, 570, 430, ["main"], { path: "leaderboard", titleField: "name", subtitleField: "team", valueField: "score" }),
|
||
comp("table", "Leaderboard", 50, 155, 1330, 650, ["leaderboard"], { path: "leaderboard", columns: "position:Поз.|name:Игрок|team:Команда|score:Счёт|through:Лунок", limit: 40 }),
|
||
comp("select", "Выбор раунда", 60, 180, 360, 80, ["controls"], { label: "Раунд", optionsPath: "filters.rounds", value: "Раунд 2" }),
|
||
comp("switch", "Онлайн", 450, 190, 240, 58, ["controls"], { label: "Автообновление", checked: true }),
|
||
comp("button", "Обновление", 720, 185, 250, 62, ["controls"], { text: "Обновить данные", action: "refresh" }),
|
||
comp("json_viewer", "JSON", 60, 310, 1320, 480, ["controls"], { path: "" }),
|
||
],
|
||
}),
|
||
race: () => ({
|
||
project_name: "Гонки — live timing", data_source: "race",
|
||
canvas: { ...state.config.canvas, width: 1440, height: 900 },
|
||
tabs: [{ id: "main", label: "Сессия" }, { id: "timing", label: "Тайминг" }, { id: "settings", label: "Управление" }],
|
||
components: [
|
||
comp("heading", "Заголовок", 45, 35, 650, 65, ["*"], { text: "LIVE TIMING / RACE CONTROL" }, { fontSize: 28, fontWeight: 850 }),
|
||
comp("tab_bar", "Вкладки", 760, 40, 630, 52, ["*"], {}),
|
||
comp("kpi", "Круг", 50, 145, 260, 135, ["main"], { label: "Текущий круг", path: "session.lap", suffix: " круг" }),
|
||
comp("kpi", "Всего", 330, 145, 260, 135, ["main"], { label: "Всего кругов", path: "session.laps_total" }),
|
||
comp("kpi", "Время", 610, 145, 300, 135, ["main"], { label: "Осталось", path: "session.time_left" }),
|
||
comp("badge", "Флаг", 940, 175, 220, 65, ["main"], { path: "event.status" }, { background: "#48dfbd", fontSize: 20, fontWeight: 850 }),
|
||
comp("progress", "Дистанция", 50, 330, 1110, 110, ["main"], { label: "Пройдено дистанции", path: "session.progress", max: 100, suffix: "%" }),
|
||
comp("line_chart", "Темп", 50, 490, 1110, 320, ["main"], { path: "chart", labelField: "label", valueField: "value" }),
|
||
comp("table", "Live timing", 45, 135, 1350, 680, ["timing"], { path: "leaderboard", columns: "position:Поз.|number:№|name:Пилот|gap:Отставание|best_lap:Лучший круг", limit: 50 }),
|
||
comp("select", "Сессия", 60, 170, 370, 82, ["settings"], { label: "Сессия", optionsPath: "filters.sessions", value: "Race" }),
|
||
comp("button_group", "Управление", 470, 185, 500, 60, ["settings"], { buttons: "start:Старт|stop:Стоп|reset:Сброс|finish:Финиш" }),
|
||
comp("json_viewer", "JSON", 60, 310, 1320, 480, ["settings"], { path: "" }),
|
||
],
|
||
}),
|
||
football: () => ({
|
||
project_name: "Футбол — матч-центр", data_source: "football",
|
||
canvas: { ...state.config.canvas, width: 1440, height: 900 },
|
||
tabs: [{ id: "match", label: "Матч" }, { id: "lineups", label: "Составы" }, { id: "control", label: "Управление" }],
|
||
components: [
|
||
comp("heading", "Турнир", 45, 35, 620, 65, ["*"], { text: "ФУТБОЛ / MATCH CENTER" }, { fontSize: 28, fontWeight: 850 }),
|
||
comp("tab_bar", "Навигация", 750, 40, 640, 52, ["*"], {}),
|
||
comp("data_text", "Хозяева", 80, 175, 420, 125, ["match"], { label: "Хозяева", path: "match.home" }, { align: "center", fontSize: 22 }),
|
||
comp("kpi", "Счёт", 540, 145, 360, 180, ["match"], { label: "Счёт", path: "match.score" }, { align: "center", fontSize: 36, fontWeight: 900 }),
|
||
comp("data_text", "Гости", 940, 175, 420, 125, ["match"], { label: "Гости", path: "match.away" }, { align: "center", fontSize: 22 }),
|
||
comp("badge", "Минута", 610, 355, 220, 62, ["match"], { path: "match.minute" }),
|
||
comp("progress", "Матч", 170, 470, 1100, 100, ["match"], { label: "Ход матча", path: "match.progress", max: 100, suffix: "%" }),
|
||
comp("table", "Составы", 50, 145, 1340, 650, ["lineups"], { path: "leaderboard", columns: "number:№|name:Игрок|team:Команда|role:Амплуа", limit: 50 }),
|
||
comp("radio", "Период", 60, 170, 530, 90, ["control"], { label: "Период", options: "match:Матч|first:1 тайм|second:2 тайм", value: "match" }),
|
||
comp("button_group", "Команды", 630, 180, 650, 64, ["control"], { buttons: "start:Старт|break:Перерыв|second_half:2 тайм|finish:Завершить" }),
|
||
comp("json_viewer", "JSON", 60, 320, 1320, 470, ["control"], { path: "" }),
|
||
],
|
||
}),
|
||
dashboard: () => ({
|
||
project_name: "Универсальный dashboard", data_source: state.config.data_source || "golf",
|
||
canvas: { ...state.config.canvas, width: 1440, height: 900 },
|
||
tabs: [{ id: "main", label: "Dashboard" }, { id: "details", label: "Подробности" }],
|
||
components: [
|
||
comp("heading", "Dashboard", 50, 35, 620, 70, ["*"], { text: "OPERATOR DASHBOARD" }, { fontSize: 30, fontWeight: 850 }),
|
||
comp("tab_bar", "Вкладки", 820, 45, 560, 52, ["*"], {}),
|
||
comp("kpi", "KPI 1", 50, 145, 300, 140, ["main"], { label: "Событие", path: "event.title" }),
|
||
comp("kpi", "KPI 2", 375, 145, 300, 140, ["main"], { label: "Статус", path: "event.status" }),
|
||
comp("kpi", "KPI 3", 700, 145, 300, 140, ["main"], { label: "Дата", path: "event.date" }),
|
||
comp("badge", "Live", 1060, 180, 250, 65, ["main"], { text: "ONLINE" }),
|
||
comp("bar_chart", "График", 50, 330, 650, 420, ["main"], { path: "chart", labelField: "label", valueField: "value" }),
|
||
comp("cards", "Карточки", 730, 330, 650, 420, ["main"], { path: "leaderboard", titleField: "name", subtitleField: "team", valueField: "score" }),
|
||
comp("table", "Подробности", 50, 145, 1330, 650, ["details"], { path: "leaderboard", columns: "position:Поз.|name:Имя|team:Команда|score:Значение" }),
|
||
],
|
||
}),
|
||
form: () => ({
|
||
project_name: "Универсальная форма", data_source: state.config.data_source || "golf",
|
||
canvas: { ...state.config.canvas, width: 1200, height: 850 },
|
||
tabs: [{ id: "main", label: "Форма" }, { id: "result", label: "Результат" }],
|
||
components: [
|
||
comp("heading", "Форма", 50, 35, 650, 70, ["*"], { text: "СОЗДАНИЕ СОБЫТИЯ" }, { fontSize: 28, fontWeight: 850 }),
|
||
comp("tab_bar", "Вкладки", 760, 45, 380, 52, ["*"], {}),
|
||
comp("text_input", "Название", 70, 150, 500, 82, ["main"], { label: "Название события", placeholder: "Введите название" }),
|
||
comp("select", "Тип", 610, 150, 500, 82, ["main"], { label: "Тип", options: "golf:Гольф|race:Гонки|football:Футбол" }),
|
||
comp("date", "Дата", 70, 260, 300, 82, ["main"], { label: "Дата" }),
|
||
comp("time", "Время", 400, 260, 260, 82, ["main"], { label: "Время" }),
|
||
comp("switch", "Онлайн", 700, 275, 240, 58, ["main"], { label: "Онлайн", checked: true }),
|
||
comp("textarea", "Описание", 70, 380, 1040, 180, ["main"], { label: "Описание", placeholder: "Комментарий" }),
|
||
comp("file", "Файл", 70, 600, 480, 82, ["main"], { label: "Документы", multiple: true }),
|
||
comp("button", "Сохранить", 830, 610, 280, 62, ["main"], { text: "Сохранить событие", action: "message", message: "Тестовая форма сохранена" }),
|
||
comp("json_viewer", "Данные", 50, 145, 1100, 600, ["result"], { path: "" }),
|
||
],
|
||
}),
|
||
};
|
||
|
||
function pickerColor(value, fallback = "#0c1421") {
|
||
const text = String(value || "").trim();
|
||
if (/^#[0-9a-f]{6}$/i.test(text)) return text;
|
||
if (/^#[0-9a-f]{3}$/i.test(text)) {
|
||
return `#${text.slice(1).split("").map((char) => char + char).join("")}`;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
function syncCanvasBackgroundControls(value) {
|
||
const next = String(value || "#0c1421");
|
||
if (el.canvasBackground) {
|
||
el.canvasBackground.value = pickerColor(next);
|
||
refreshEnhancedControl(el.canvasBackground);
|
||
}
|
||
if (el.canvasBackgroundText) el.canvasBackgroundText.value = next;
|
||
}
|
||
|
||
function createColorControl(value, onChange, { allowEmpty = true } = {}) {
|
||
const control = document.createElement("span");
|
||
control.className = "color-control";
|
||
const picker = document.createElement("input");
|
||
picker.type = "color";
|
||
picker.value = pickerColor(value, "#000000");
|
||
picker.title = "Открыть палитру";
|
||
const text = document.createElement("input");
|
||
text.type = "text";
|
||
text.value = value ?? "";
|
||
text.placeholder = "#RRGGBB, rgba(...) или CSS-цвет";
|
||
const clear = document.createElement("button");
|
||
clear.type = "button";
|
||
clear.className = "color-clear";
|
||
clear.textContent = "×";
|
||
clear.title = "Очистить цвет";
|
||
clear.disabled = !allowEmpty;
|
||
|
||
picker.addEventListener("input", () => {
|
||
text.value = picker.value;
|
||
onChange(picker.value);
|
||
});
|
||
text.addEventListener("input", () => {
|
||
const next = text.value.trim();
|
||
picker.value = pickerColor(next, picker.value || "#000000");
|
||
refreshEnhancedControl(picker);
|
||
onChange(next);
|
||
});
|
||
clear.addEventListener("click", () => {
|
||
text.value = "";
|
||
onChange("");
|
||
});
|
||
control.append(picker, text, clear);
|
||
return control;
|
||
}
|
||
|
||
function numberControlElement(input) {
|
||
if (!input || input.parentElement?.classList.contains("css-number-control")) return input?.parentElement || input;
|
||
const wrapper = document.createElement("span");
|
||
wrapper.className = "css-number-control";
|
||
const up = document.createElement("button");
|
||
const down = document.createElement("button");
|
||
up.type = down.type = "button";
|
||
up.className = down.className = "number-step";
|
||
up.textContent = "▲";
|
||
down.textContent = "▼";
|
||
up.title = "Увеличить";
|
||
down.title = "Уменьшить";
|
||
|
||
const step = (direction) => {
|
||
if (input.disabled) return;
|
||
try {
|
||
direction > 0 ? input.stepUp() : input.stepDown();
|
||
} catch (_) {
|
||
const amount = Number(input.step) || 1;
|
||
input.value = String((Number(input.value) || 0) + amount * direction);
|
||
}
|
||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||
};
|
||
up.addEventListener("click", () => step(1));
|
||
down.addEventListener("click", () => step(-1));
|
||
|
||
const parent = input.parentNode;
|
||
if (parent) parent.insertBefore(wrapper, input);
|
||
wrapper.append(input, up, down);
|
||
return wrapper;
|
||
}
|
||
|
||
function decorateStaticNumberInputs() {
|
||
document.querySelectorAll('input[type="number"]').forEach((input) => numberControlElement(input));
|
||
}
|
||
|
||
|
||
/* Fully styled popover controls: select, color, date and time. */
|
||
let activeControlPopover = null;
|
||
let controlsObserver = null;
|
||
|
||
function refreshEnhancedControl(source) {
|
||
if (source && typeof source._uiBuilderControlRefresh === "function") source._uiBuilderControlRefresh();
|
||
}
|
||
|
||
function closeControlPopover() {
|
||
if (!activeControlPopover) return;
|
||
const { popup, anchor } = activeControlPopover;
|
||
popup.remove();
|
||
anchor?.classList.remove("control-popover-open");
|
||
activeControlPopover = null;
|
||
}
|
||
|
||
function positionControlPopover(anchor, popup) {
|
||
const rect = anchor.getBoundingClientRect();
|
||
const margin = 8;
|
||
const viewportWidth = document.documentElement.clientWidth;
|
||
const viewportHeight = document.documentElement.clientHeight;
|
||
const wide = popup.classList.contains("css-select-popover-wide");
|
||
if (wide) {
|
||
const wideWidth = Math.max(520, Math.min(960, viewportWidth - margin * 2));
|
||
popup.style.width = `${wideWidth}px`;
|
||
popup.style.minWidth = `${Math.min(wideWidth, Math.max(420, rect.width))}px`;
|
||
popup.style.maxWidth = `${viewportWidth - margin * 2}px`;
|
||
} else {
|
||
popup.style.width = "";
|
||
popup.style.minWidth = `${Math.max(220, Math.min(rect.width, 420))}px`;
|
||
popup.style.maxWidth = `${Math.max(280, Math.min(460, viewportWidth - margin * 2))}px`;
|
||
}
|
||
popup.style.visibility = "hidden";
|
||
popup.style.left = "0px";
|
||
popup.style.top = "0px";
|
||
requestAnimationFrame(() => {
|
||
const box = popup.getBoundingClientRect();
|
||
let left = rect.left;
|
||
if (left + box.width > viewportWidth - margin) left = viewportWidth - box.width - margin;
|
||
left = Math.max(margin, left);
|
||
let top = rect.bottom + 6;
|
||
if (top + box.height > viewportHeight - margin && rect.top - box.height - 6 >= margin) top = rect.top - box.height - 6;
|
||
top = Math.max(margin, Math.min(top, viewportHeight - box.height - margin));
|
||
popup.style.left = `${left}px`;
|
||
popup.style.top = `${top}px`;
|
||
popup.style.visibility = "visible";
|
||
});
|
||
}
|
||
|
||
function openControlPopover(anchor, className = "") {
|
||
closeControlPopover();
|
||
const popup = document.createElement("div");
|
||
popup.className = `control-popover ${className}`.trim();
|
||
popup.addEventListener("pointerdown", (event) => event.stopPropagation());
|
||
document.body.appendChild(popup);
|
||
anchor.classList.add("control-popover-open");
|
||
activeControlPopover = { popup, anchor };
|
||
positionControlPopover(anchor, popup);
|
||
return popup;
|
||
}
|
||
|
||
function dispatchControlValue(source, eventNames = ["input", "change"]) {
|
||
eventNames.forEach((eventName) => source.dispatchEvent(new Event(eventName, { bubbles: true })));
|
||
refreshEnhancedControl(source);
|
||
}
|
||
|
||
function selectedOptionLabel(select) {
|
||
if (select.multiple) {
|
||
const selected = [...select.selectedOptions];
|
||
if (!selected.length) return select.dataset.placeholder || "Ничего не выбрано";
|
||
if (selected.length === 1) return selected[0].textContent.trim();
|
||
return `Выбрано: ${selected.length}`;
|
||
}
|
||
return select.selectedOptions[0]?.textContent?.trim() || select.dataset.placeholder || "Выберите значение";
|
||
}
|
||
|
||
function enhanceSelect(select) {
|
||
if (!select || select.dataset.cssEnhanced === "select") return;
|
||
select.dataset.cssEnhanced = "select";
|
||
const parent = select.parentNode;
|
||
if (!parent) return;
|
||
const shell = document.createElement("span");
|
||
shell.className = `css-select-shell${select.multiple ? " is-multiple" : ""}`;
|
||
parent.insertBefore(shell, select);
|
||
shell.appendChild(select);
|
||
select.classList.add("native-control-source");
|
||
|
||
const trigger = document.createElement("button");
|
||
trigger.type = "button";
|
||
trigger.className = "css-select-trigger";
|
||
trigger.innerHTML = '<span class="css-select-value"></span><span class="css-select-arrow" aria-hidden="true"></span>';
|
||
shell.appendChild(trigger);
|
||
|
||
const refresh = () => {
|
||
trigger.querySelector(".css-select-value").textContent = selectedOptionLabel(select);
|
||
trigger.disabled = Boolean(select.disabled);
|
||
trigger.classList.toggle("is-empty", select.multiple ? !select.selectedOptions.length : !select.value);
|
||
trigger.title = selectedOptionLabel(select);
|
||
};
|
||
select._uiBuilderControlRefresh = refresh;
|
||
select.addEventListener("change", refresh);
|
||
|
||
trigger.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (select.disabled) return;
|
||
if (activeControlPopover?.anchor === trigger) { closeControlPopover(); return; }
|
||
const isTriggerLongSelect = Boolean(select.closest(".trigger-editor") && (
|
||
select.dataset.path === "item_id"
|
||
|| select.dataset.path === "source_action_id"
|
||
|| Object.prototype.hasOwnProperty.call(select.dataset, "triggerFilter")
|
||
));
|
||
const popup = openControlPopover(trigger, `css-select-popover${isTriggerLongSelect ? " css-select-popover-wide" : ""}`);
|
||
const options = [...select.options];
|
||
|
||
if (options.length > 8) {
|
||
const searchWrap = document.createElement("div");
|
||
searchWrap.className = "control-popover-search";
|
||
const search = document.createElement("input");
|
||
search.type = "search";
|
||
search.placeholder = "Поиск...";
|
||
searchWrap.appendChild(search);
|
||
popup.appendChild(searchWrap);
|
||
search.addEventListener("input", () => {
|
||
const query = search.value.trim().toLowerCase();
|
||
popup.querySelectorAll(".css-select-option").forEach((button) => {
|
||
button.classList.toggle("hidden", !button.textContent.toLowerCase().includes(query));
|
||
});
|
||
});
|
||
setTimeout(() => search.focus(), 0);
|
||
}
|
||
|
||
const list = document.createElement("div");
|
||
list.className = "css-select-options";
|
||
let currentOptionGroup = "";
|
||
options.forEach((option) => {
|
||
const optionGroup = option.parentElement?.tagName === "OPTGROUP" ? String(option.parentElement.label || "") : "";
|
||
if (optionGroup && optionGroup !== currentOptionGroup) {
|
||
const groupLabel = document.createElement("div");
|
||
groupLabel.className = "css-select-group-label";
|
||
groupLabel.textContent = optionGroup;
|
||
list.appendChild(groupLabel);
|
||
}
|
||
currentOptionGroup = optionGroup;
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "css-select-option";
|
||
button.disabled = option.disabled;
|
||
button.dataset.value = option.value;
|
||
const mark = document.createElement("span");
|
||
mark.className = select.multiple ? "css-select-check" : "css-select-dot";
|
||
const label = document.createElement("span");
|
||
label.className = "css-select-option-label";
|
||
label.textContent = option.textContent;
|
||
button.append(mark, label);
|
||
const updateButton = () => button.classList.toggle("selected", option.selected);
|
||
updateButton();
|
||
button.addEventListener("click", () => {
|
||
if (select.multiple) {
|
||
option.selected = !option.selected;
|
||
updateButton();
|
||
dispatchControlValue(select, ["change"]);
|
||
} else {
|
||
select.value = option.value;
|
||
dispatchControlValue(select, ["change"]);
|
||
closeControlPopover();
|
||
}
|
||
});
|
||
list.appendChild(button);
|
||
});
|
||
if (!options.length) list.innerHTML = '<div class="control-popover-empty">Нет вариантов</div>';
|
||
popup.appendChild(list);
|
||
|
||
if (select.multiple) {
|
||
const footer = document.createElement("div");
|
||
footer.className = "control-popover-footer";
|
||
const clear = document.createElement("button");
|
||
clear.type = "button";
|
||
clear.className = "popover-btn secondary";
|
||
clear.textContent = "Очистить";
|
||
clear.addEventListener("click", () => {
|
||
options.forEach((option) => { option.selected = false; });
|
||
dispatchControlValue(select, ["change"]);
|
||
popup.querySelectorAll(".css-select-option").forEach((button) => button.classList.remove("selected"));
|
||
});
|
||
const done = document.createElement("button");
|
||
done.type = "button";
|
||
done.className = "popover-btn primary";
|
||
done.textContent = "Готово";
|
||
done.addEventListener("click", closeControlPopover);
|
||
footer.append(clear, done);
|
||
popup.appendChild(footer);
|
||
}
|
||
positionControlPopover(trigger, popup);
|
||
});
|
||
|
||
const optionObserver = new MutationObserver(refresh);
|
||
optionObserver.observe(select, { childList: true, subtree: true, attributes: true, attributeFilter: ["selected", "disabled", "label"] });
|
||
refresh();
|
||
}
|
||
|
||
function pad2(value) { return String(value).padStart(2, "0"); }
|
||
function dateValue(date) { return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`; }
|
||
function parseDateValue(value) {
|
||
const match = String(value || "").match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||
if (!match) return null;
|
||
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
||
return Number.isNaN(date.getTime()) ? null : date;
|
||
}
|
||
function parseTimeValue(value) {
|
||
const match = String(value || "").match(/(?:T|^)(\d{2}):(\d{2})/);
|
||
return match ? { hour: clamp(Number(match[1]), 0, 23), minute: clamp(Number(match[2]), 0, 59) } : null;
|
||
}
|
||
function formatDateTimeDisplay(input) {
|
||
const value = input.value;
|
||
if (!value) return input.placeholder || (input.type === "date" ? "Выберите дату" : input.type === "time" ? "Выберите время" : "Выберите дату и время");
|
||
if (input.type === "time") return value.slice(0, 5);
|
||
const date = parseDateValue(value);
|
||
if (!date) return value;
|
||
const dateText = new Intl.DateTimeFormat("ru-RU", { day: "2-digit", month: "long", year: "numeric" }).format(date);
|
||
if (input.type === "datetime-local") return `${dateText}, ${parseTimeValue(value) ? `${pad2(parseTimeValue(value).hour)}:${pad2(parseTimeValue(value).minute)}` : "00:00"}`;
|
||
return dateText;
|
||
}
|
||
|
||
function createTimeStepper(label, value, max, onChange) {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "css-time-unit";
|
||
const title = document.createElement("span");
|
||
title.textContent = label;
|
||
const valueNode = document.createElement("strong");
|
||
const minus = document.createElement("button");
|
||
const plus = document.createElement("button");
|
||
minus.type = plus.type = "button";
|
||
minus.textContent = "−";
|
||
plus.textContent = "+";
|
||
const set = (next) => {
|
||
value = (next + max + 1) % (max + 1);
|
||
valueNode.textContent = pad2(value);
|
||
onChange(value);
|
||
};
|
||
minus.addEventListener("click", () => set(value - 1));
|
||
plus.addEventListener("click", () => set(value + 1));
|
||
valueNode.textContent = pad2(value);
|
||
wrap.append(title, minus, valueNode, plus);
|
||
return wrap;
|
||
}
|
||
|
||
function enhanceDateTimeInput(input) {
|
||
if (!input || input.dataset.cssEnhanced === "datetime") return;
|
||
input.dataset.cssEnhanced = "datetime";
|
||
const parent = input.parentNode;
|
||
if (!parent) return;
|
||
const shell = document.createElement("span");
|
||
shell.className = `css-datetime-shell type-${input.type}`;
|
||
parent.insertBefore(shell, input);
|
||
shell.appendChild(input);
|
||
input.classList.add("native-control-source");
|
||
const trigger = document.createElement("button");
|
||
trigger.type = "button";
|
||
trigger.className = "css-datetime-trigger";
|
||
trigger.innerHTML = `<span class="css-datetime-icon">${input.type === "time" ? "◷" : input.type === "date" ? "▣" : "◴"}</span><span class="css-datetime-value"></span><span class="css-datetime-arrow"></span>`;
|
||
shell.appendChild(trigger);
|
||
|
||
const refresh = () => {
|
||
trigger.querySelector(".css-datetime-value").textContent = formatDateTimeDisplay(input);
|
||
trigger.disabled = Boolean(input.disabled);
|
||
trigger.classList.toggle("is-empty", !input.value);
|
||
};
|
||
input._uiBuilderControlRefresh = refresh;
|
||
input.addEventListener("input", refresh);
|
||
input.addEventListener("change", refresh);
|
||
|
||
trigger.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (input.disabled) return;
|
||
if (activeControlPopover?.anchor === trigger) { closeControlPopover(); return; }
|
||
const popup = openControlPopover(trigger, "css-datetime-popover");
|
||
const now = new Date();
|
||
let selectedDate = parseDateValue(input.value) || new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||
let viewDate = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1);
|
||
const parsedTime = parseTimeValue(input.value) || { hour: now.getHours(), minute: now.getMinutes() };
|
||
let hour = parsedTime.hour;
|
||
let minute = parsedTime.minute;
|
||
|
||
const commit = () => {
|
||
if (input.type === "date") input.value = dateValue(selectedDate);
|
||
else if (input.type === "time") input.value = `${pad2(hour)}:${pad2(minute)}`;
|
||
else input.value = `${dateValue(selectedDate)}T${pad2(hour)}:${pad2(minute)}`;
|
||
dispatchControlValue(input);
|
||
};
|
||
|
||
const renderPanel = () => {
|
||
popup.innerHTML = "";
|
||
if (input.type !== "time") {
|
||
const calendar = document.createElement("div");
|
||
calendar.className = "css-calendar";
|
||
const head = document.createElement("div");
|
||
head.className = "css-calendar-head";
|
||
const prev = document.createElement("button");
|
||
const next = document.createElement("button");
|
||
prev.type = next.type = "button";
|
||
prev.textContent = "‹";
|
||
next.textContent = "›";
|
||
const month = document.createElement("strong");
|
||
month.textContent = new Intl.DateTimeFormat("ru-RU", { month: "long", year: "numeric" }).format(viewDate);
|
||
prev.addEventListener("click", () => { viewDate = new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1); renderPanel(); });
|
||
next.addEventListener("click", () => { viewDate = new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1); renderPanel(); });
|
||
head.append(prev, month, next);
|
||
calendar.appendChild(head);
|
||
|
||
const weekdays = document.createElement("div");
|
||
weekdays.className = "css-calendar-weekdays";
|
||
["Пн","Вт","Ср","Чт","Пт","Сб","Вс"].forEach((day) => { const span = document.createElement("span"); span.textContent = day; weekdays.appendChild(span); });
|
||
calendar.appendChild(weekdays);
|
||
|
||
const grid = document.createElement("div");
|
||
grid.className = "css-calendar-grid";
|
||
const firstWeekday = (new Date(viewDate.getFullYear(), viewDate.getMonth(), 1).getDay() + 6) % 7;
|
||
const gridStart = new Date(viewDate.getFullYear(), viewDate.getMonth(), 1 - firstWeekday);
|
||
for (let index = 0; index < 42; index += 1) {
|
||
const date = new Date(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate() + index);
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "css-calendar-day";
|
||
button.textContent = String(date.getDate());
|
||
button.classList.toggle("outside", date.getMonth() !== viewDate.getMonth());
|
||
button.classList.toggle("today", dateValue(date) === dateValue(now));
|
||
button.classList.toggle("selected", dateValue(date) === dateValue(selectedDate));
|
||
button.addEventListener("click", () => {
|
||
selectedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||
viewDate = new Date(date.getFullYear(), date.getMonth(), 1);
|
||
commit();
|
||
if (input.type === "date") closeControlPopover(); else renderPanel();
|
||
});
|
||
grid.appendChild(button);
|
||
}
|
||
calendar.appendChild(grid);
|
||
popup.appendChild(calendar);
|
||
}
|
||
|
||
if (input.type !== "date") {
|
||
const timePanel = document.createElement("div");
|
||
timePanel.className = "css-time-panel";
|
||
const heading = document.createElement("div");
|
||
heading.className = "css-time-heading";
|
||
heading.textContent = "Время";
|
||
const controls = document.createElement("div");
|
||
controls.className = "css-time-controls";
|
||
controls.append(
|
||
createTimeStepper("Часы", hour, 23, (value) => { hour = value; commit(); }),
|
||
createTimeStepper("Минуты", minute, 59, (value) => { minute = value; commit(); }),
|
||
);
|
||
timePanel.append(heading, controls);
|
||
popup.appendChild(timePanel);
|
||
}
|
||
|
||
const footer = document.createElement("div");
|
||
footer.className = "control-popover-footer";
|
||
const clear = document.createElement("button");
|
||
clear.type = "button";
|
||
clear.className = "popover-btn secondary";
|
||
clear.textContent = "Очистить";
|
||
clear.addEventListener("click", () => { input.value = ""; dispatchControlValue(input); closeControlPopover(); });
|
||
const nowButton = document.createElement("button");
|
||
nowButton.type = "button";
|
||
nowButton.className = "popover-btn secondary";
|
||
nowButton.textContent = input.type === "date" ? "Сегодня" : "Сейчас";
|
||
nowButton.addEventListener("click", () => {
|
||
selectedDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||
hour = now.getHours(); minute = now.getMinutes(); commit(); renderPanel();
|
||
});
|
||
const done = document.createElement("button");
|
||
done.type = "button";
|
||
done.className = "popover-btn primary";
|
||
done.textContent = "Готово";
|
||
done.addEventListener("click", () => { commit(); closeControlPopover(); });
|
||
footer.append(clear, nowButton, done);
|
||
popup.appendChild(footer);
|
||
positionControlPopover(trigger, popup);
|
||
};
|
||
renderPanel();
|
||
});
|
||
refresh();
|
||
}
|
||
|
||
function hexToRgb(hex) {
|
||
const clean = pickerColor(hex, "#000000").slice(1);
|
||
return { r: parseInt(clean.slice(0,2), 16), g: parseInt(clean.slice(2,4), 16), b: parseInt(clean.slice(4,6), 16) };
|
||
}
|
||
function rgbToHex(r, g, b) { return `#${[r,g,b].map((value) => clamp(Math.round(value),0,255).toString(16).padStart(2,"0")).join("")}`; }
|
||
function rgbToHsv(r, g, b) {
|
||
r /= 255; g /= 255; b /= 255;
|
||
const max = Math.max(r, g, b);
|
||
const min = Math.min(r, g, b);
|
||
const delta = max - min;
|
||
let h = 0;
|
||
if (delta !== 0) {
|
||
if (max === r) h = 60 * (((g - b) / delta) % 6);
|
||
else if (max === g) h = 60 * (((b - r) / delta) + 2);
|
||
else h = 60 * (((r - g) / delta) + 4);
|
||
}
|
||
if (h < 0) h += 360;
|
||
return {
|
||
h: Math.round(h),
|
||
s: max === 0 ? 0 : Math.round((delta / max) * 100),
|
||
v: Math.round(max * 100),
|
||
};
|
||
}
|
||
|
||
function hsvToRgb(h, s, v) {
|
||
h = ((Number(h) % 360) + 360) % 360;
|
||
s = clamp(Number(s), 0, 100) / 100;
|
||
v = clamp(Number(v), 0, 100) / 100;
|
||
const chroma = v * s;
|
||
const x = chroma * (1 - Math.abs(((h / 60) % 2) - 1));
|
||
const m = v - chroma;
|
||
let r = 0, g = 0, b = 0;
|
||
if (h < 60) [r, g, b] = [chroma, x, 0];
|
||
else if (h < 120) [r, g, b] = [x, chroma, 0];
|
||
else if (h < 180) [r, g, b] = [0, chroma, x];
|
||
else if (h < 240) [r, g, b] = [0, x, chroma];
|
||
else if (h < 300) [r, g, b] = [x, 0, chroma];
|
||
else [r, g, b] = [chroma, 0, x];
|
||
return {
|
||
r: Math.round((r + m) * 255),
|
||
g: Math.round((g + m) * 255),
|
||
b: Math.round((b + m) * 255),
|
||
};
|
||
}
|
||
|
||
function enhanceColorInput(input) {
|
||
if (!input || input.dataset.cssEnhanced === "color") return;
|
||
input.dataset.cssEnhanced = "color";
|
||
input.classList.add("native-control-source");
|
||
const trigger = document.createElement("button");
|
||
trigger.type = "button";
|
||
trigger.className = "css-color-trigger";
|
||
trigger.innerHTML = '<span class="css-color-swatch"></span><span class="css-color-edit">✎</span>';
|
||
input.parentNode?.insertBefore(trigger, input);
|
||
|
||
const refresh = () => {
|
||
trigger.querySelector(".css-color-swatch").style.background = input.value || "#000000";
|
||
trigger.disabled = Boolean(input.disabled);
|
||
trigger.title = `Выбрать цвет: ${input.value || ""}`;
|
||
};
|
||
input._uiBuilderControlRefresh = refresh;
|
||
input.addEventListener("input", refresh);
|
||
input.addEventListener("change", refresh);
|
||
|
||
trigger.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (input.disabled) return;
|
||
if (activeControlPopover?.anchor === trigger) {
|
||
closeControlPopover();
|
||
return;
|
||
}
|
||
|
||
const popup = openControlPopover(trigger, "css-color-popover");
|
||
let hex = pickerColor(input.value, "#48dfbd").toUpperCase();
|
||
let rgb = hexToRgb(hex);
|
||
let hsv = rgbToHsv(rgb.r, rgb.g, rgb.b);
|
||
|
||
const header = document.createElement("div");
|
||
header.className = "css-color-head";
|
||
const preview = document.createElement("span");
|
||
preview.className = "css-color-preview";
|
||
const hexField = document.createElement("label");
|
||
hexField.className = "css-color-hex-field";
|
||
const hexCaption = document.createElement("span");
|
||
hexCaption.textContent = "HEX";
|
||
const hexInput = document.createElement("input");
|
||
hexInput.type = "text";
|
||
hexInput.value = hex;
|
||
hexInput.maxLength = 7;
|
||
hexInput.spellcheck = false;
|
||
hexField.append(hexCaption, hexInput);
|
||
header.append(preview, hexField);
|
||
popup.appendChild(header);
|
||
|
||
const workspace = document.createElement("div");
|
||
workspace.className = "css-color-workspace";
|
||
const svArea = document.createElement("div");
|
||
svArea.className = "css-color-sv";
|
||
svArea.tabIndex = 0;
|
||
svArea.setAttribute("role", "slider");
|
||
svArea.setAttribute("aria-label", "Насыщенность и яркость");
|
||
const svCursor = document.createElement("span");
|
||
svCursor.className = "css-color-sv-cursor";
|
||
svArea.appendChild(svCursor);
|
||
|
||
const hueBar = document.createElement("div");
|
||
hueBar.className = "css-color-hue";
|
||
hueBar.tabIndex = 0;
|
||
hueBar.setAttribute("role", "slider");
|
||
hueBar.setAttribute("aria-label", "Оттенок");
|
||
const hueCursor = document.createElement("span");
|
||
hueCursor.className = "css-color-hue-cursor";
|
||
hueBar.appendChild(hueCursor);
|
||
workspace.append(svArea, hueBar);
|
||
popup.appendChild(workspace);
|
||
|
||
const channels = document.createElement("div");
|
||
channels.className = "css-color-channels";
|
||
const channelInputs = {};
|
||
[["r", "R"], ["g", "G"], ["b", "B"]].forEach(([key, label]) => {
|
||
const field = document.createElement("label");
|
||
field.className = "css-color-channel";
|
||
const caption = document.createElement("span");
|
||
caption.textContent = label;
|
||
const channelInput = document.createElement("input");
|
||
channelInput.type = "number";
|
||
channelInput.min = "0";
|
||
channelInput.max = "255";
|
||
channelInput.step = "1";
|
||
channelInput.inputMode = "numeric";
|
||
field.append(caption, channelInput);
|
||
channels.appendChild(field);
|
||
channelInputs[key] = channelInput;
|
||
});
|
||
popup.appendChild(channels);
|
||
|
||
const palette = document.createElement("div");
|
||
palette.className = "css-color-palette";
|
||
[
|
||
"#FFFFFF", "#D9E2EF", "#94A3B8", "#475569", "#0F172A", "#000000",
|
||
"#48DFBD", "#22C55E", "#84CC16", "#FACC15", "#F97316", "#EF4444",
|
||
"#FF667D", "#EC4899", "#A855F7", "#8B5CF6", "#3B82F6", "#06B6D4",
|
||
].forEach((color) => {
|
||
const swatch = document.createElement("button");
|
||
swatch.type = "button";
|
||
swatch.className = "css-color-palette-item";
|
||
swatch.style.background = color;
|
||
swatch.title = color;
|
||
swatch.setAttribute("aria-label", `Выбрать ${color}`);
|
||
swatch.addEventListener("click", () => setHex(color, true));
|
||
palette.appendChild(swatch);
|
||
});
|
||
popup.appendChild(palette);
|
||
|
||
const commit = () => {
|
||
input.value = hex.toLowerCase();
|
||
dispatchControlValue(input);
|
||
};
|
||
|
||
const syncPanel = (commitValue = false) => {
|
||
rgb = hsvToRgb(hsv.h, hsv.s, hsv.v);
|
||
hex = rgbToHex(rgb.r, rgb.g, rgb.b).toUpperCase();
|
||
preview.style.background = hex;
|
||
hexInput.value = hex;
|
||
svArea.style.setProperty("--picker-hue", `hsl(${hsv.h} 100% 50%)`);
|
||
svCursor.style.left = `${hsv.s}%`;
|
||
svCursor.style.top = `${100 - hsv.v}%`;
|
||
hueCursor.style.top = `${(hsv.h / 360) * 100}%`;
|
||
channelInputs.r.value = String(rgb.r);
|
||
channelInputs.g.value = String(rgb.g);
|
||
channelInputs.b.value = String(rgb.b);
|
||
svArea.setAttribute("aria-valuetext", `${Math.round(hsv.s)}% насыщенности, ${Math.round(hsv.v)}% яркости`);
|
||
hueBar.setAttribute("aria-valuenow", String(Math.round(hsv.h)));
|
||
palette.querySelectorAll(".css-color-palette-item").forEach((item) => {
|
||
item.classList.toggle("selected", item.title.toUpperCase() === hex);
|
||
});
|
||
if (commitValue) commit();
|
||
};
|
||
|
||
const setHex = (value, commitValue = false) => {
|
||
const normalized = pickerColor(value, "");
|
||
if (!normalized) return false;
|
||
hex = normalized.toUpperCase();
|
||
rgb = hexToRgb(hex);
|
||
hsv = rgbToHsv(rgb.r, rgb.g, rgb.b);
|
||
syncPanel(commitValue);
|
||
return true;
|
||
};
|
||
|
||
const updateSvFromPointer = (event) => {
|
||
const rect = svArea.getBoundingClientRect();
|
||
hsv.s = clamp(((event.clientX - rect.left) / rect.width) * 100, 0, 100);
|
||
hsv.v = clamp(100 - ((event.clientY - rect.top) / rect.height) * 100, 0, 100);
|
||
syncPanel(true);
|
||
};
|
||
|
||
const updateHueFromPointer = (event) => {
|
||
const rect = hueBar.getBoundingClientRect();
|
||
hsv.h = clamp(((event.clientY - rect.top) / rect.height) * 360, 0, 359.999);
|
||
syncPanel(true);
|
||
};
|
||
|
||
const bindPointerDrag = (element, handler) => {
|
||
element.addEventListener("pointerdown", (event) => {
|
||
if (event.button !== 0) return;
|
||
event.preventDefault();
|
||
element.setPointerCapture?.(event.pointerId);
|
||
handler(event);
|
||
const move = (moveEvent) => handler(moveEvent);
|
||
const finish = (finishEvent) => {
|
||
element.releasePointerCapture?.(finishEvent.pointerId);
|
||
element.removeEventListener("pointermove", move);
|
||
element.removeEventListener("pointerup", finish);
|
||
element.removeEventListener("pointercancel", finish);
|
||
};
|
||
element.addEventListener("pointermove", move);
|
||
element.addEventListener("pointerup", finish);
|
||
element.addEventListener("pointercancel", finish);
|
||
});
|
||
};
|
||
bindPointerDrag(svArea, updateSvFromPointer);
|
||
bindPointerDrag(hueBar, updateHueFromPointer);
|
||
|
||
svArea.addEventListener("keydown", (event) => {
|
||
const step = event.shiftKey ? 5 : 1;
|
||
if (event.key === "ArrowLeft") hsv.s = clamp(hsv.s - step, 0, 100);
|
||
else if (event.key === "ArrowRight") hsv.s = clamp(hsv.s + step, 0, 100);
|
||
else if (event.key === "ArrowUp") hsv.v = clamp(hsv.v + step, 0, 100);
|
||
else if (event.key === "ArrowDown") hsv.v = clamp(hsv.v - step, 0, 100);
|
||
else return;
|
||
event.preventDefault();
|
||
syncPanel(true);
|
||
});
|
||
hueBar.addEventListener("keydown", (event) => {
|
||
const step = event.shiftKey ? 10 : 1;
|
||
if (event.key === "ArrowUp") hsv.h = (hsv.h - step + 360) % 360;
|
||
else if (event.key === "ArrowDown") hsv.h = (hsv.h + step) % 360;
|
||
else return;
|
||
event.preventDefault();
|
||
syncPanel(true);
|
||
});
|
||
|
||
hexInput.addEventListener("input", () => {
|
||
const value = hexInput.value.trim();
|
||
hexInput.classList.toggle("invalid", !/^#[0-9a-f]{6}$/i.test(value));
|
||
if (/^#[0-9a-f]{6}$/i.test(value)) setHex(value, true);
|
||
});
|
||
hexInput.addEventListener("blur", () => {
|
||
hexInput.classList.remove("invalid");
|
||
hexInput.value = hex;
|
||
});
|
||
|
||
Object.entries(channelInputs).forEach(([key, channelInput]) => {
|
||
channelInput.addEventListener("input", () => {
|
||
const next = {
|
||
r: clamp(Number(channelInputs.r.value) || 0, 0, 255),
|
||
g: clamp(Number(channelInputs.g.value) || 0, 0, 255),
|
||
b: clamp(Number(channelInputs.b.value) || 0, 0, 255),
|
||
};
|
||
rgb = next;
|
||
hex = rgbToHex(next.r, next.g, next.b).toUpperCase();
|
||
hsv = rgbToHsv(next.r, next.g, next.b);
|
||
syncPanel(true);
|
||
});
|
||
});
|
||
|
||
const footer = document.createElement("div");
|
||
footer.className = "control-popover-footer css-color-footer";
|
||
const tools = document.createElement("div");
|
||
tools.className = "css-color-footer-tools";
|
||
|
||
if ("EyeDropper" in window) {
|
||
const eyedropper = document.createElement("button");
|
||
eyedropper.type = "button";
|
||
eyedropper.className = "popover-btn secondary";
|
||
eyedropper.textContent = "Пипетка";
|
||
eyedropper.addEventListener("click", async () => {
|
||
try {
|
||
const result = await new window.EyeDropper().open();
|
||
if (result?.sRGBHex) setHex(result.sRGBHex, true);
|
||
} catch (_) {}
|
||
});
|
||
tools.appendChild(eyedropper);
|
||
}
|
||
|
||
const copy = document.createElement("button");
|
||
copy.type = "button";
|
||
copy.className = "popover-btn secondary";
|
||
copy.textContent = "Копировать";
|
||
copy.addEventListener("click", async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(hex);
|
||
toast(`Цвет ${hex} скопирован`);
|
||
} catch (_) {}
|
||
});
|
||
tools.appendChild(copy);
|
||
|
||
const done = document.createElement("button");
|
||
done.type = "button";
|
||
done.className = "popover-btn primary";
|
||
done.textContent = "Готово";
|
||
done.addEventListener("click", () => {
|
||
commit();
|
||
closeControlPopover();
|
||
});
|
||
footer.append(tools, done);
|
||
popup.appendChild(footer);
|
||
|
||
syncPanel(false);
|
||
positionControlPopover(trigger, popup);
|
||
});
|
||
refresh();
|
||
}
|
||
|
||
function enhanceStyledControls(root = document) {
|
||
const scope = root?.querySelectorAll ? root : document;
|
||
scope.querySelectorAll?.('select:not([data-css-enhanced])').forEach(enhanceSelect);
|
||
scope.querySelectorAll?.('input[type="date"]:not([data-css-enhanced]), input[type="time"]:not([data-css-enhanced]), input[type="datetime-local"]:not([data-css-enhanced])').forEach(enhanceDateTimeInput);
|
||
scope.querySelectorAll?.('input[type="color"]:not([data-css-enhanced])').forEach(enhanceColorInput);
|
||
}
|
||
|
||
let styledControlsScheduled = false;
|
||
let styledControlListenersBound = false;
|
||
|
||
function scheduleStyledControls() {
|
||
if (styledControlsScheduled) return;
|
||
styledControlsScheduled = true;
|
||
queueMicrotask(() => {
|
||
styledControlsScheduled = false;
|
||
enhanceStyledControls(document);
|
||
});
|
||
}
|
||
|
||
function startStyledControls() {
|
||
enhanceStyledControls(document);
|
||
if (styledControlListenersBound) return;
|
||
styledControlListenersBound = true;
|
||
document.addEventListener("pointerdown", (event) => {
|
||
if (!activeControlPopover) return;
|
||
if (activeControlPopover.popup.contains(event.target) || activeControlPopover.anchor.contains(event.target)) return;
|
||
closeControlPopover();
|
||
}, true);
|
||
document.addEventListener("keydown", (event) => { if (event.key === "Escape" && activeControlPopover) closeControlPopover(); }, true);
|
||
window.addEventListener("resize", closeControlPopover);
|
||
window.addEventListener("scroll", () => activeControlPopover && positionControlPopover(activeControlPopover.anchor, activeControlPopover.popup), true);
|
||
}
|
||
|
||
function toast(message, error = false, options = {}) {
|
||
if (!el.toast) return;
|
||
const runtimeSession = Boolean(state.preview || boot.mode === "runtime");
|
||
const force = Boolean(options?.force);
|
||
if (runtimeSession && !error && !force && state.operatorToastsEnabled === false) return;
|
||
el.toast.textContent = message;
|
||
el.toast.style.background = error ? "#ff667d" : "#48dfbd";
|
||
el.toast.classList.add("show");
|
||
clearTimeout(toast.timer);
|
||
toast.timer = setTimeout(() => el.toast.classList.remove("show"), 2200);
|
||
}
|
||
|
||
|
||
function customTooltipTarget(node) {
|
||
const target = node?.closest?.("[data-tooltip], [title]");
|
||
if (!target) return null;
|
||
const nativeTitle = target.getAttribute("title");
|
||
if (nativeTitle) {
|
||
target.dataset.tooltip = nativeTitle;
|
||
target.removeAttribute("title");
|
||
if (!target.getAttribute("aria-label")) target.setAttribute("aria-label", nativeTitle);
|
||
}
|
||
return target.dataset.tooltip ? target : null;
|
||
}
|
||
|
||
function hideCustomTooltip() {
|
||
clearTimeout(state.customTooltipTimer);
|
||
state.customTooltipTimer = null;
|
||
if (state.customTooltip) {
|
||
state.customTooltip.classList.remove("show");
|
||
state.customTooltip.setAttribute("aria-hidden", "true");
|
||
}
|
||
}
|
||
|
||
function positionCustomTooltip(target) {
|
||
if (!state.customTooltip || !target?.isConnected) return;
|
||
const rect = target.getBoundingClientRect();
|
||
const tooltip = state.customTooltip;
|
||
const margin = 10;
|
||
const width = tooltip.offsetWidth;
|
||
const height = tooltip.offsetHeight;
|
||
|
||
let left = rect.left + rect.width / 2 - width / 2;
|
||
left = clamp(left, margin, window.innerWidth - width - margin);
|
||
|
||
let top = rect.top - height - 10;
|
||
if (top < margin) top = rect.bottom + 10;
|
||
top = clamp(top, margin, window.innerHeight - height - margin);
|
||
|
||
tooltip.style.left = `${Math.round(left)}px`;
|
||
tooltip.style.top = `${Math.round(top)}px`;
|
||
}
|
||
|
||
function showCustomTooltip(target) {
|
||
const message = target?.dataset?.tooltip;
|
||
if (!message) return;
|
||
clearTimeout(state.customTooltipTimer);
|
||
state.customTooltipTimer = setTimeout(() => {
|
||
if (!state.customTooltip) return;
|
||
state.customTooltip.textContent = message;
|
||
state.customTooltip.setAttribute("aria-hidden", "false");
|
||
state.customTooltip.classList.add("show");
|
||
requestAnimationFrame(() => positionCustomTooltip(target));
|
||
}, 220);
|
||
}
|
||
|
||
function startCustomTooltips() {
|
||
if (state.customTooltip) return;
|
||
const tooltip = document.createElement("div");
|
||
tooltip.className = "ui-custom-tooltip";
|
||
tooltip.setAttribute("role", "tooltip");
|
||
tooltip.setAttribute("aria-hidden", "true");
|
||
document.body.appendChild(tooltip);
|
||
state.customTooltip = tooltip;
|
||
|
||
document.addEventListener("pointerover", (event) => {
|
||
const target = customTooltipTarget(event.target);
|
||
if (target) showCustomTooltip(target);
|
||
}, true);
|
||
document.addEventListener("pointerout", (event) => {
|
||
const target = customTooltipTarget(event.target);
|
||
if (!target) return;
|
||
if (event.relatedTarget && target.contains(event.relatedTarget)) return;
|
||
hideCustomTooltip();
|
||
}, true);
|
||
document.addEventListener("focusin", (event) => {
|
||
const target = customTooltipTarget(event.target);
|
||
if (target) showCustomTooltip(target);
|
||
}, true);
|
||
document.addEventListener("focusout", hideCustomTooltip, true);
|
||
document.addEventListener("pointerdown", hideCustomTooltip, true);
|
||
window.addEventListener("scroll", hideCustomTooltip, true);
|
||
window.addEventListener("resize", hideCustomTooltip);
|
||
}
|
||
|
||
function errorDetailText(detail, fallback = "Ошибка") {
|
||
if (typeof detail === "string") return detail;
|
||
if (detail && typeof detail === "object") return detail.message || JSON.stringify(detail);
|
||
return fallback;
|
||
}
|
||
|
||
async function api(path, options = {}) {
|
||
const response = await fetch(`${boot.api}${path}`, {
|
||
cache: "no-store",
|
||
credentials: "same-origin",
|
||
...options
|
||
});
|
||
if (!response.ok) {
|
||
let detail = `HTTP ${response.status}`;
|
||
try { detail = (await response.json()).detail || detail; } catch (_) {}
|
||
if (response.status === 401 && boot.mode === "editor") {
|
||
toast("Сессия конструктора завершена", true);
|
||
setTimeout(() => { window.location.href = boot.runtimeUrl; }, 700);
|
||
}
|
||
throw new Error(errorDetailText(detail, `HTTP ${response.status}`));
|
||
}
|
||
return response.json();
|
||
}
|
||
|
||
async function authApi(path, options = {}) {
|
||
const response = await fetch(`${boot.authApi}${path}`, {
|
||
cache: "no-store",
|
||
credentials: "same-origin",
|
||
...options
|
||
});
|
||
let payload = {};
|
||
try { payload = await response.json(); } catch (_) {}
|
||
if (!response.ok) {
|
||
throw new Error(errorDetailText(payload.detail, `HTTP ${response.status}`));
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
async function loadSources() {
|
||
const payload = await api("/sources");
|
||
state.sources = payload.items || [];
|
||
if (el.dataSource) {
|
||
el.dataSource.innerHTML = state.sources.map((source) => `<option value="${escapeHtml(source.id)}">${escapeHtml(source.label)}</option>`).join("");
|
||
refreshEnhancedControl(el.dataSource);
|
||
}
|
||
}
|
||
|
||
async function loadConfig() {
|
||
state.config = await api("/config");
|
||
ensureConfig();
|
||
state.activeTab = state.config.tabs[0]?.id || "main";
|
||
state.selectedId = null;
|
||
syncTopControls();
|
||
}
|
||
|
||
const shortcutModifierOrder = ["Ctrl", "Alt", "Shift", "Meta"];
|
||
|
||
function normalizeShortcutCombo(value) {
|
||
const aliases = {
|
||
control: "Ctrl", ctrl: "Ctrl", alt: "Alt", option: "Alt", shift: "Shift",
|
||
meta: "Meta", cmd: "Meta", command: "Meta", win: "Meta", windows: "Meta",
|
||
esc: "Escape", escape: "Escape", return: "Enter", spacebar: "Space", space: "Space",
|
||
del: "Delete", delete: "Delete", backspace: "Backspace", tab: "Tab",
|
||
up: "ArrowUp", down: "ArrowDown", left: "ArrowLeft", right: "ArrowRight",
|
||
plus: "Plus", minus: "Minus",
|
||
numpadenter: "NumEnter", numenter: "NumEnter", "num enter": "NumEnter",
|
||
keypadenter: "NumEnter", "keypad enter": "NumEnter"
|
||
};
|
||
const raw = String(value || "").trim();
|
||
if (!raw) return "";
|
||
const parts = raw.split("+").map((part) => part.trim()).filter(Boolean);
|
||
const modifiers = new Set();
|
||
let key = "";
|
||
parts.forEach((part) => {
|
||
const lower = part.toLowerCase();
|
||
const normalized = aliases[lower] || (/^f\d{1,2}$/i.test(part) ? part.toUpperCase() : part.length === 1 ? part.toUpperCase() : part);
|
||
if (shortcutModifierOrder.includes(normalized)) modifiers.add(normalized);
|
||
else key = normalized;
|
||
});
|
||
const orderedModifiers = shortcutModifierOrder.filter((modifier) => modifiers.has(modifier));
|
||
if (!key) return orderedModifiers.length >= 2 ? orderedModifiers.join("+") : "";
|
||
return [...orderedModifiers, key].join("+");
|
||
}
|
||
|
||
function shortcutModifierFromEvent(event) {
|
||
const code = String(event.code || "");
|
||
const key = String(event.key || "");
|
||
if (key === "Control" || code === "ControlLeft" || code === "ControlRight") return "Ctrl";
|
||
if (key === "Alt" || code === "AltLeft" || code === "AltRight") return "Alt";
|
||
if (key === "Shift" || code === "ShiftLeft" || code === "ShiftRight") return "Shift";
|
||
if (key === "Meta" || code === "MetaLeft" || code === "MetaRight") return "Meta";
|
||
return "";
|
||
}
|
||
|
||
function syncShortcutModifiersFromEvent(event, isDown = true) {
|
||
const modifier = shortcutModifierFromEvent(event);
|
||
if (modifier && isDown && state.pressedShortcutModifiers.size === 0) {
|
||
state.modifierShortcutChordModifiers.clear();
|
||
state.modifierShortcutChordUsedKey = false;
|
||
state.modifierShortcutChordFired = false;
|
||
}
|
||
if (modifier) {
|
||
if (isDown) {
|
||
state.pressedShortcutModifiers.add(modifier);
|
||
state.modifierShortcutChordModifiers.add(modifier);
|
||
} else {
|
||
state.pressedShortcutModifiers.delete(modifier);
|
||
}
|
||
}
|
||
if (event.ctrlKey) { state.pressedShortcutModifiers.add("Ctrl"); if (isDown) state.modifierShortcutChordModifiers.add("Ctrl"); }
|
||
if (event.altKey) { state.pressedShortcutModifiers.add("Alt"); if (isDown) state.modifierShortcutChordModifiers.add("Alt"); }
|
||
if (event.shiftKey) { state.pressedShortcutModifiers.add("Shift"); if (isDown) state.modifierShortcutChordModifiers.add("Shift"); }
|
||
if (event.metaKey) { state.pressedShortcutModifiers.add("Meta"); if (isDown) state.modifierShortcutChordModifiers.add("Meta"); }
|
||
}
|
||
|
||
function shortcutKeyFromEvent(event) {
|
||
const code = String(event.code || "");
|
||
if (/^Key[A-Z]$/.test(code)) return code.slice(3);
|
||
if (/^Digit\d$/.test(code)) return code.slice(5);
|
||
if (/^F\d{1,2}$/.test(code)) return code;
|
||
const byCode = {
|
||
Space: "Space", Enter: "Enter", NumpadEnter: "NumEnter", Escape: "Escape", Tab: "Tab", Backspace: "Backspace", Delete: "Delete",
|
||
ArrowUp: "ArrowUp", ArrowDown: "ArrowDown", ArrowLeft: "ArrowLeft", ArrowRight: "ArrowRight",
|
||
Home: "Home", End: "End", PageUp: "PageUp", PageDown: "PageDown", Insert: "Insert",
|
||
Numpad0: "Num0", Numpad1: "Num1", Numpad2: "Num2", Numpad3: "Num3", Numpad4: "Num4",
|
||
Numpad5: "Num5", Numpad6: "Num6", Numpad7: "Num7", Numpad8: "Num8", Numpad9: "Num9",
|
||
NumpadAdd: "NumPlus", NumpadSubtract: "NumMinus", NumpadMultiply: "NumMultiply", NumpadDivide: "NumDivide",
|
||
Minus: "Minus", Equal: "Plus", Comma: "Comma", Period: "Period", Slash: "Slash", Semicolon: "Semicolon",
|
||
Quote: "Quote", BracketLeft: "BracketLeft", BracketRight: "BracketRight", Backslash: "Backslash", Backquote: "Backquote"
|
||
};
|
||
if (byCode[code]) return byCode[code];
|
||
const key = String(event.key || "");
|
||
if (["Control", "Alt", "Shift", "Meta"].includes(key)) return "";
|
||
return key.length === 1 ? key.toUpperCase() : key;
|
||
}
|
||
|
||
function shortcutFromKeyboardEvent(event) {
|
||
const key = shortcutKeyFromEvent(event);
|
||
if (!key) return "";
|
||
|
||
const modifiers = new Set(state.pressedShortcutModifiers);
|
||
if (event.ctrlKey) modifiers.add("Ctrl");
|
||
if (event.altKey) modifiers.add("Alt");
|
||
if (event.shiftKey) modifiers.add("Shift");
|
||
if (event.metaKey) modifiers.add("Meta");
|
||
|
||
const parts = shortcutModifierOrder.filter((modifier) => modifiers.has(modifier));
|
||
parts.push(key);
|
||
return normalizeShortcutCombo(parts.join("+"));
|
||
}
|
||
|
||
function modifierOnlyShortcutCombo() {
|
||
const parts = shortcutModifierOrder.filter((modifier) => state.modifierShortcutChordModifiers.has(modifier));
|
||
return parts.length >= 2 ? normalizeShortcutCombo(parts.join("+")) : "";
|
||
}
|
||
|
||
function normalizeShortcut(shortcut = {}, index = 0) {
|
||
return {
|
||
id: String(shortcut.id || `shortcut-${index + 1}-${Math.random().toString(36).slice(2, 7)}`),
|
||
enabled: shortcut.enabled !== false,
|
||
combo: normalizeShortcutCombo(shortcut.combo || ""),
|
||
event: String(shortcut.event || "click"),
|
||
item_id: String(shortcut.item_id || ""),
|
||
value: shortcut.value ?? "",
|
||
prevent_default: shortcut.prevent_default !== false,
|
||
allow_in_inputs: Boolean(shortcut.allow_in_inputs),
|
||
global: Boolean(shortcut.global),
|
||
scope: shortcut.scope === "all" ? "all" : "runtime",
|
||
};
|
||
}
|
||
|
||
function enabledShortcutText(component) {
|
||
return (component.shortcuts || []).filter((item) => item.enabled && item.combo).map((item) => item.combo).join(" · ");
|
||
}
|
||
|
||
function shortcutConflict(component, shortcut) {
|
||
if (!shortcut?.enabled || !shortcut.combo) return null;
|
||
const combo = normalizeShortcutCombo(shortcut.combo);
|
||
for (const other of state.config.components) {
|
||
for (const binding of other.shortcuts || []) {
|
||
if (other.id === component.id && binding.id === shortcut.id) continue;
|
||
if (binding.enabled && normalizeShortcutCombo(binding.combo) === combo) return { component: other, shortcut: binding };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const sequenceConditions = [
|
||
["always", "Всегда"],
|
||
["has_penalties", "Есть хотя бы одно удаление"],
|
||
["no_penalties", "Удалений нет"],
|
||
["has_home_penalties", "Есть удаление хозяев"],
|
||
["has_away_penalties", "Есть удаление гостей"],
|
||
["has_both_penalties", "Удаления у обеих команд"],
|
||
["no_home_penalties", "Нет удалений хозяев"],
|
||
["no_away_penalties", "Нет удалений гостей"],
|
||
["home_delayed_penalty", "HOME: отложенный штраф включён"],
|
||
["away_delayed_penalty", "AWAY: отложенный штраф включён"],
|
||
["any_delayed_penalty", "У кого-то включён отложенный штраф"],
|
||
["home_empty_net", "HOME: пустые ворота включены"],
|
||
["away_empty_net", "AWAY: пустые ворота включены"],
|
||
["any_empty_net", "У кого-то включены пустые ворота"],
|
||
["prematch_button_active", "Кнопка нижней панели включена"],
|
||
["prematch_button_inactive", "Кнопка нижней панели выключена"],
|
||
["active_tab", "Открыта выбранная вкладка"],
|
||
["inactive_tab", "Выбранная вкладка не открыта"],
|
||
];
|
||
|
||
|
||
function normalizePenaltyVmixTargets(rawTargets, legacyInputs = "", legacyNames = "") {
|
||
let targets = [];
|
||
if (Array.isArray(rawTargets)) {
|
||
targets = rawTargets;
|
||
} else {
|
||
const inputs = splitVmixInputs(legacyInputs);
|
||
const names = splitVmixInputs(legacyNames);
|
||
targets = inputs.map((input, index) => ({
|
||
input,
|
||
selected_name: names[index] || "",
|
||
}));
|
||
}
|
||
return targets.slice(0, 8).map((target, index) => ({
|
||
id: String(target?.id || `penalty-target-${index + 1}-${Math.random().toString(36).slice(2, 7)}`),
|
||
input: String(target?.input || ""),
|
||
selected_name: String(target?.selected_name || target?.selectedName || ""),
|
||
overlay: ["1", "2", "3", "4"].includes(String(target?.overlay || "")) ? String(target.overlay) : "2",
|
||
auto_hide_on_finish: target?.auto_hide_on_finish !== false,
|
||
}));
|
||
}
|
||
|
||
function normalizeTimerFinishActions(rawActions) {
|
||
if (!Array.isArray(rawActions)) return [];
|
||
const allowedSources = new Set(["game", "any_penalty", "home_penalty", "away_penalty"]);
|
||
return rawActions.slice(0, 12).map((action, index) => ({
|
||
id: String(action?.id || `finish-action-${index + 1}-${Math.random().toString(36).slice(2, 7)}`),
|
||
enabled: action?.enabled !== false,
|
||
source: allowedSources.has(String(action?.source || "")) ? String(action.source) : "game",
|
||
input: String(action?.input || ""),
|
||
overlay: ["1", "2", "3", "4"].includes(String(action?.overlay || "")) ? String(action.overlay) : "1",
|
||
duration_ms: clamp(Number(action?.duration_ms) || 3000, 100, 120000),
|
||
only_when_side_clear: action?.only_when_side_clear !== false,
|
||
}));
|
||
}
|
||
|
||
function normalizeSequenceStep(step = {}, index = 0) {
|
||
const allowedTypes = new Set(["timer_command", "hockey_penalties_command", "vmix_command", "hockey_vmix_timers_start", "delay", "dispatch_event"]);
|
||
const allowedConditions = new Set(sequenceConditions.map(([value]) => value));
|
||
const type = allowedTypes.has(String(step.type || "")) ? String(step.type) : "vmix_command";
|
||
const condition = allowedConditions.has(String(step.condition || "")) ? String(step.condition) : "always";
|
||
return {
|
||
id: String(step.id || `step-${index + 1}-${Math.random().toString(36).slice(2, 7)}`),
|
||
enabled: step.enabled !== false,
|
||
type,
|
||
label: String(step.label || ""),
|
||
condition,
|
||
condition_value: String(step.condition_value || ""),
|
||
target_action_id: String(step.target_action_id || ""),
|
||
timer_command: String(step.timer_command || "start"),
|
||
timer_value: step.timer_value ?? "",
|
||
penalty_command: String(step.penalty_command || "start"),
|
||
function: String(step.function || ""),
|
||
input: String(step.input || ""),
|
||
value: step.value ?? "",
|
||
selected_name: String(step.selected_name || ""),
|
||
duration: String(step.duration || ""),
|
||
mix: String(step.mix || ""),
|
||
use_scoreboard_alternate: Boolean(step.use_scoreboard_alternate),
|
||
scoreboard_alternate_input: String(step.scoreboard_alternate_input || ""),
|
||
scoreboard_alternate_selected_name: String(step.scoreboard_alternate_selected_name || ""),
|
||
game_timer_action_id: String(step.game_timer_action_id || "hockey_game_timer"),
|
||
hockey_timer_command: ["toggle", "start", "pause", "resume"].includes(String(step.hockey_timer_command || "")) ? String(step.hockey_timer_command) : "toggle",
|
||
game_vmix_mode: ["countdown", "text"].includes(String(step.game_vmix_mode || "")) ? String(step.game_vmix_mode) : "text",
|
||
penalty_vmix_mode: ["countdown", "text"].includes(String(step.penalty_vmix_mode || "")) ? String(step.penalty_vmix_mode) : "text",
|
||
penalty_display_mode: String(step.penalty_display_mode || "soonest") === "all" ? "all" : "soonest",
|
||
game_vmix_input: String(step.game_vmix_input || ""),
|
||
game_vmix_selected_name: String(step.game_vmix_selected_name || ""),
|
||
home_penalty_inputs: String(step.home_penalty_inputs || ""),
|
||
away_penalty_inputs: String(step.away_penalty_inputs || ""),
|
||
home_penalty_selected_names: String(step.home_penalty_selected_names || ""),
|
||
away_penalty_selected_names: String(step.away_penalty_selected_names || ""),
|
||
home_penalty_targets: normalizePenaltyVmixTargets(step.home_penalty_targets, step.home_penalty_inputs, step.home_penalty_selected_names),
|
||
away_penalty_targets: normalizePenaltyVmixTargets(step.away_penalty_targets, step.away_penalty_inputs, step.away_penalty_selected_names),
|
||
timer_finish_actions: normalizeTimerFinishActions(step.timer_finish_actions),
|
||
start_web_game: step.start_web_game !== false,
|
||
start_web_penalties: step.start_web_penalties !== false,
|
||
sync_vmix_game: step.sync_vmix_game !== false,
|
||
sync_vmix_penalties: step.sync_vmix_penalties !== false,
|
||
milliseconds: clamp(Number(step.milliseconds) || 0, 0, 10000),
|
||
event_name: String(step.event_name || "ui-builder:shortcut-sequence"),
|
||
};
|
||
}
|
||
|
||
function normalizePrematchGroups(rawGroups) {
|
||
const source = Array.isArray(rawGroups) ? rawGroups : [];
|
||
const seen = new Set();
|
||
return source.slice(0, 24).map((group, index) => {
|
||
let id = String(group?.id || `group_${index + 1}`).trim().replace(/[^A-Za-z0-9_-]+/g, "_").slice(0, 48).replace(/^_+|_+$/g, "") || `group_${index + 1}`;
|
||
const base = id; let suffix = 2;
|
||
while (seen.has(id)) id = `${base}_${suffix++}`.slice(0, 48);
|
||
seen.add(id);
|
||
return {
|
||
id,
|
||
label: String(group?.label || `Вкладка ${index + 1}`).slice(0, 80),
|
||
sort_order: Number.isFinite(Number(group?.sort_order)) ? Number(group.sort_order) : index * 10,
|
||
enabled: group?.enabled !== false,
|
||
};
|
||
}).sort((a, b) => Number(a.sort_order) - Number(b.sort_order) || a.label.localeCompare(b.label, "ru"));
|
||
}
|
||
|
||
function normalizePrematchButtons(rawButtons) {
|
||
const source = Array.isArray(rawButtons) ? rawButtons : [];
|
||
const seen = new Set();
|
||
return source.slice(0, 64).map((button, index) => {
|
||
let id = String(button?.id || `prematch_${index + 1}`).trim().replace(/[^A-Za-z0-9_-]+/g, "_").slice(0, 48).replace(/^_+|_+$/g, "") || `prematch_${index + 1}`;
|
||
const base = id;
|
||
let suffix = 2;
|
||
while (seen.has(id)) id = `${base}_${suffix++}`.slice(0, 48);
|
||
seen.add(id);
|
||
return {
|
||
id,
|
||
label: String(button?.label || `Кнопка ${index + 1}`).slice(0, 80),
|
||
description: String(button?.description || "").slice(0, 300),
|
||
mode: String(button?.mode || "action") === "toggle" ? "toggle" : "action",
|
||
sequence_id: String(button?.sequence_id || "").slice(0, 128),
|
||
group_id: String(button?.group_id || "").slice(0, 48),
|
||
sort_order: Number.isFinite(Number(button?.sort_order)) ? Number(button.sort_order) : index * 10,
|
||
enabled: button?.enabled !== false,
|
||
};
|
||
});
|
||
}
|
||
|
||
function normalizeQuickPanelSelectors(rawSelectors) {
|
||
const source = Array.isArray(rawSelectors) ? rawSelectors : [];
|
||
const seen = new Set();
|
||
return source.slice(0, 64).map((selector, index) => {
|
||
let id = String(selector?.id || `selector_${index + 1}`).trim().replace(/[^A-Za-z0-9_-]+/g, "_").slice(0, 48).replace(/^_+|_+$/g, "") || `selector_${index + 1}`;
|
||
const base = id; let suffix = 2;
|
||
while (seen.has(id)) id = `${base}_${suffix++}`.slice(0, 48);
|
||
seen.add(id);
|
||
const rawOptions = Array.isArray(selector?.options) ? selector.options : [];
|
||
const options = rawOptions.slice(0, 16).map((option, optionIndex) => {
|
||
if (option && typeof option === "object") {
|
||
const value = String(option.value ?? option.id ?? optionIndex + 1).slice(0, 64);
|
||
return { value, label: String(option.label ?? value).slice(0, 80) };
|
||
}
|
||
const value = String(option ?? optionIndex + 1).slice(0, 64);
|
||
return { value, label: value.slice(0, 80) };
|
||
}).filter((option) => option.value !== "");
|
||
const fallbackOptions = options.length ? options : [
|
||
{ value: "1", label: "1" },
|
||
{ value: "2", label: "2" },
|
||
{ value: "3", label: "3" },
|
||
];
|
||
let defaultValue = String(selector?.default_value ?? fallbackOptions[0]?.value ?? "").slice(0, 64);
|
||
if (!fallbackOptions.some((option) => option.value === defaultValue)) defaultValue = fallbackOptions[0]?.value || "";
|
||
return {
|
||
id,
|
||
label: String(selector?.label || `Переключатель ${index + 1}`).slice(0, 80),
|
||
description: String(selector?.description || "").slice(0, 300),
|
||
group_id: String(selector?.group_id || "").slice(0, 48),
|
||
button_id: String(selector?.button_id || "").slice(0, 48),
|
||
style: String(selector?.style || "segments") === "select" ? "select" : "segments",
|
||
options: fallbackOptions,
|
||
default_value: defaultValue,
|
||
sort_order: Number.isFinite(Number(selector?.sort_order)) ? Number(selector.sort_order) : index * 10,
|
||
enabled: selector?.enabled !== false,
|
||
};
|
||
});
|
||
}
|
||
|
||
function normalizeShortcutSequence(sequence = {}, index = 0) {
|
||
return {
|
||
id: String(sequence.id || `sequence-${index + 1}-${Math.random().toString(36).slice(2, 7)}`),
|
||
name: String(sequence.name || `Шорткат ${index + 1}`),
|
||
description: String(sequence.description || ""),
|
||
enabled: sequence.enabled !== false,
|
||
combo: normalizeShortcutCombo(sequence.combo || ""),
|
||
prevent_default: sequence.prevent_default !== false,
|
||
allow_in_inputs: Boolean(sequence.allow_in_inputs),
|
||
toggle_all_overlays_on_repeat: Boolean(sequence.toggle_all_overlays_on_repeat),
|
||
sync_hockey_team_states: sequence.sync_hockey_team_states === undefined
|
||
? Boolean(sequence.toggle_all_overlays_on_repeat)
|
||
: Boolean(sequence.sync_hockey_team_states),
|
||
is_scoreboard_sequence: sequence.is_scoreboard_sequence === undefined
|
||
? Boolean(sequence.sync_hockey_team_states ?? sequence.toggle_all_overlays_on_repeat)
|
||
: Boolean(sequence.is_scoreboard_sequence),
|
||
scope: sequence.scope === "all" ? "all" : "runtime",
|
||
steps: Array.isArray(sequence.steps) ? sequence.steps.map((step, stepIndex) => normalizeSequenceStep(step, stepIndex)) : [],
|
||
};
|
||
}
|
||
|
||
function shortcutSequenceById(sequenceId) {
|
||
return (state.config.shortcut_sequences || []).find((item) => item.id === sequenceId);
|
||
}
|
||
|
||
function updateShortcutsCount() {
|
||
if (el.shortcutsCount) el.shortcutsCount.textContent = String((state.config.shortcut_sequences || []).filter((item) => item.enabled !== false && normalizeShortcutCombo(item.combo)).length);
|
||
}
|
||
|
||
function stripLegacyHockeyComponentShortcuts() {
|
||
const components = Array.isArray(state.config.components) ? state.config.components : [];
|
||
const isHockeyProject = components.some((component) =>
|
||
String(component?.type || "").startsWith("hockey_") || String(component?.action_id || "").startsWith("hockey_")
|
||
);
|
||
if (!isHockeyProject) return 0;
|
||
let removed = 0;
|
||
components.forEach((component) => {
|
||
const shortcuts = Array.isArray(component.shortcuts) ? component.shortcuts : [];
|
||
component.shortcuts = shortcuts.filter((shortcut) => {
|
||
const combo = normalizeShortcutCombo(shortcut?.combo || "");
|
||
const legacy = combo === "Space" || combo === "Ctrl+R";
|
||
if (legacy) removed += 1;
|
||
return !legacy;
|
||
});
|
||
});
|
||
return removed;
|
||
}
|
||
|
||
function ensureHockeyQuickCommandWorkspace() {
|
||
const components = Array.isArray(state.config.components) ? state.config.components : [];
|
||
const isHockeyProject = components.some((component) => String(component?.type || "").startsWith("hockey_") && component?.type !== "hockey_prematch_panel");
|
||
if (!isHockeyProject) return;
|
||
|
||
// BUILD53: prematch controls now live in the persistent bottom command dock.
|
||
// Remove the legacy top-level tab/component in memory; existing button/group data is preserved.
|
||
state.config.tabs = (Array.isArray(state.config.tabs) ? state.config.tabs : []).filter((tab) => tab?.id !== "prematch");
|
||
state.config.components = components.filter((component) => component?.type !== "hockey_prematch_panel" && component?.action_id !== "hockey_prematch_panel");
|
||
if (!state.config.tabs.length) state.config.tabs = [{ id: "main", label: "Игра" }];
|
||
if (state.activeTab === "prematch") state.activeTab = state.config.tabs.find((tab) => tab.id === "main")?.id || state.config.tabs[0].id;
|
||
}
|
||
|
||
function ensureConfig() {
|
||
state.config.version = 21;
|
||
state.config.canvas ||= {};
|
||
state.config.canvas.auto_bind_containers = state.config.canvas.auto_bind_containers !== false;
|
||
state.config.tabs = Array.isArray(state.config.tabs) && state.config.tabs.length ? state.config.tabs : [{ id: "main", label: "Основное" }];
|
||
state.config.components = Array.isArray(state.config.components) ? state.config.components : [];
|
||
state.config.triggers = Array.isArray(state.config.triggers) ? state.config.triggers : [];
|
||
state.config.shortcut_sequences = Array.isArray(state.config.shortcut_sequences) ? state.config.shortcut_sequences : [];
|
||
state.config.prematch_groups = normalizePrematchGroups(state.config.prematch_groups);
|
||
const validPrematchGroups = new Set(state.config.prematch_groups.map((group) => group.id));
|
||
state.config.prematch_buttons = normalizePrematchButtons(state.config.prematch_buttons).map((button) => ({
|
||
...button,
|
||
group_id: validPrematchGroups.has(button.group_id) ? button.group_id : "",
|
||
}));
|
||
const validPrematchButtons = new Set(state.config.prematch_buttons.map((button) => button.id));
|
||
state.config.quick_panel_selectors = normalizeQuickPanelSelectors(state.config.quick_panel_selectors).map((selector) => ({
|
||
...selector,
|
||
group_id: validPrematchGroups.has(selector.group_id) ? selector.group_id : "",
|
||
button_id: validPrematchButtons.has(selector.button_id) ? selector.button_id : "",
|
||
}));
|
||
ensureHockeyQuickCommandWorkspace();
|
||
const validIds = new Set();
|
||
const usedActionIds = new Set();
|
||
state.config.components.forEach((component, index) => {
|
||
const meta = catalogMap[component.type] || catalogMap.text;
|
||
component.id ||= uid();
|
||
validIds.add(component.id);
|
||
component.title ||= meta.label;
|
||
component.x = Number(component.x ?? 20);
|
||
component.y = Number(component.y ?? 20);
|
||
component.w = Number(component.w ?? meta.w);
|
||
component.h = Number(component.h ?? meta.h);
|
||
component.z = Number(component.z ?? index + 1);
|
||
component.tabs = Array.isArray(component.tabs) && component.tabs.length ? component.tabs : ["*"];
|
||
component.props = { ...clone(meta.props), ...(component.props || {}) };
|
||
component.style = { ...defaultStyle(), ...(component.style || {}) };
|
||
component.locked = Boolean(component.locked);
|
||
component.hidden = Boolean(component.hidden);
|
||
component.parent_id = component.parent_id ? String(component.parent_id) : null;
|
||
let actionId = sanitizeActionId(component.action_id || `${component.type}_${index + 1}`, `${component.type}_${index + 1}`);
|
||
const baseActionId = actionId;
|
||
let suffix = 2;
|
||
while (usedActionIds.has(actionId)) actionId = `${baseActionId}_${suffix++}`;
|
||
usedActionIds.add(actionId);
|
||
component.action_id = actionId;
|
||
component.interaction_mode ||= defaultInteractionMode(component.type);
|
||
component.initial_state = Boolean(component.initial_state);
|
||
component.shortcuts = Array.isArray(component.shortcuts) ? component.shortcuts.map((shortcut, shortcutIndex) => normalizeShortcut(shortcut, shortcutIndex)) : [];
|
||
});
|
||
stripLegacyHockeyComponentShortcuts();
|
||
state.config.components.forEach((component) => {
|
||
if (!validIds.has(component.parent_id) || component.parent_id === component.id) component.parent_id = null;
|
||
});
|
||
state.config.triggers = state.config.triggers.map((trigger, index) => normalizeTrigger(trigger, index));
|
||
state.config.shortcut_sequences = state.config.shortcut_sequences.map((sequence, index) => normalizeShortcutSequence(sequence, index));
|
||
state.componentStates = {};
|
||
state.runtimeVisibility = {};
|
||
state.timers = {};
|
||
state.timerNodes = new Map();
|
||
state.hockeyPenaltyBoards = {};
|
||
state.hockeyPenaltyBoardNodes = new Map();
|
||
state.config.components.forEach((component) => ensureComponentState(component));
|
||
updateTriggersCount();
|
||
updateShortcutsCount();
|
||
}
|
||
|
||
async function loadData(showToast = false) {
|
||
try {
|
||
state.data = await api(`/data/${encodeURIComponent(state.config.data_source)}`);
|
||
state.dataPaths = flattenPaths(state.data);
|
||
if (el.rawData) el.rawData.textContent = JSON.stringify(state.data, null, 2);
|
||
renderDataPaths();
|
||
renderCanvas();
|
||
renderRuntime();
|
||
if (showToast) toast("Данные обновлены");
|
||
} catch (error) {
|
||
state.data = {};
|
||
state.dataPaths = [];
|
||
if (el.rawData) el.rawData.textContent = JSON.stringify({ error: error.message }, null, 2);
|
||
toast(`Ошибка данных: ${error.message}`, true);
|
||
}
|
||
}
|
||
|
||
function syncTopControls() {
|
||
if (el.projectName) el.projectName.value = state.config.project_name;
|
||
if (el.dataSource) { el.dataSource.value = state.config.data_source; refreshEnhancedControl(el.dataSource); }
|
||
if (el.snapEnabled) el.snapEnabled.checked = Boolean(state.config.canvas.snap_enabled);
|
||
if (el.gridEnabled) el.gridEnabled.checked = Boolean(state.config.canvas.show_grid);
|
||
if (el.canvasWidth) el.canvasWidth.value = state.config.canvas.width;
|
||
if (el.canvasHeight) el.canvasHeight.value = state.config.canvas.height;
|
||
if (el.gridSize) el.gridSize.value = state.config.canvas.grid_size;
|
||
if (el.snapThreshold) el.snapThreshold.value = state.config.canvas.snap_threshold;
|
||
if (el.autoBindEnabled) el.autoBindEnabled.checked = state.config.canvas.auto_bind_containers !== false;
|
||
syncCanvasBackgroundControls(state.config.canvas.background || "#0c1421");
|
||
renderTabsManager();
|
||
renderActiveTabSelect();
|
||
renderLibrary();
|
||
renderCanvas();
|
||
renderInspector();
|
||
updateTriggersCount();
|
||
}
|
||
|
||
function renderLibrary(filter = "") {
|
||
if (!el.componentLibrary) return;
|
||
const query = filter.trim().toLowerCase();
|
||
const grouped = new Map();
|
||
componentCatalog.filter((item) => !query || `${item.label} ${item.description} ${item.category}`.toLowerCase().includes(query)).forEach((item) => {
|
||
if (!grouped.has(item.category)) grouped.set(item.category, []);
|
||
grouped.get(item.category).push(item);
|
||
});
|
||
el.componentLibrary.innerHTML = "";
|
||
grouped.forEach((items, category) => {
|
||
const section = document.createElement("section");
|
||
section.className = "component-category";
|
||
section.innerHTML = `<div class="component-category-title">${escapeHtml(category)}</div>`;
|
||
const grid = document.createElement("div");
|
||
grid.className = "component-category-grid";
|
||
items.forEach((item) => {
|
||
const button = document.createElement("button");
|
||
button.className = "component-add";
|
||
button.type = "button";
|
||
button.innerHTML = `<span class="component-icon">${escapeHtml(item.icon)}</span><strong>${escapeHtml(item.label)}</strong><small>${escapeHtml(item.description)}</small>`;
|
||
button.addEventListener("click", () => addComponent(item.type));
|
||
grid.appendChild(button);
|
||
});
|
||
section.appendChild(grid);
|
||
el.componentLibrary.appendChild(section);
|
||
});
|
||
}
|
||
|
||
function addComponent(type) {
|
||
const viewport = el.canvasViewport;
|
||
const x = Math.max(20, Math.round(((viewport?.scrollLeft || 0) / state.zoom + 50) / state.config.canvas.grid_size) * state.config.canvas.grid_size);
|
||
const y = Math.max(20, Math.round(((viewport?.scrollTop || 0) / state.zoom + 50) / state.config.canvas.grid_size) * state.config.canvas.grid_size);
|
||
const component = makeComponent(type, x, y);
|
||
component.x = clamp(component.x, 0, state.config.canvas.width - component.w);
|
||
component.y = clamp(component.y, 0, state.config.canvas.height - component.h);
|
||
state.config.components.push(component);
|
||
state.selectedId = component.id;
|
||
renderCanvas();
|
||
renderInspector();
|
||
toast(`${catalogMap[type]?.label || type} добавлен`);
|
||
}
|
||
|
||
function setTemplate(name) {
|
||
const factory = templates[name];
|
||
if (!factory) return;
|
||
const next = factory();
|
||
state.config = { version: 21, triggers: [], ...next };
|
||
ensureConfig();
|
||
state.activeTab = state.config.tabs[0]?.id || "main";
|
||
if (state.config.canvas.auto_bind_containers) {
|
||
state.config.components
|
||
.filter((component) => !component.parent_id)
|
||
.forEach((component) => autoBindComponent(component));
|
||
}
|
||
state.selectedId = state.config.components[0]?.id || null;
|
||
syncTopControls();
|
||
loadData();
|
||
toast("Шаблон загружен");
|
||
}
|
||
|
||
function renderTabsManager() {
|
||
if (!el.tabsManager) return;
|
||
el.tabsManager.innerHTML = "";
|
||
state.config.tabs.forEach((tab, index) => {
|
||
const row = document.createElement("div");
|
||
row.className = "tab-manager-row";
|
||
const input = document.createElement("input");
|
||
input.type = "text";
|
||
input.value = tab.label;
|
||
input.title = `ID: ${tab.id}`;
|
||
input.addEventListener("input", () => {
|
||
tab.label = input.value || tab.id;
|
||
renderActiveTabSelect();
|
||
renderRuntimeTabs();
|
||
renderCanvas();
|
||
});
|
||
const selectBtn = document.createElement("button");
|
||
selectBtn.type = "button";
|
||
selectBtn.textContent = "●";
|
||
selectBtn.title = "Редактировать вкладку";
|
||
selectBtn.addEventListener("click", () => {
|
||
state.activeTab = tab.id;
|
||
renderActiveTabSelect();
|
||
renderCanvas();
|
||
renderInspector();
|
||
});
|
||
const deleteBtn = document.createElement("button");
|
||
deleteBtn.type = "button";
|
||
deleteBtn.textContent = "×";
|
||
deleteBtn.title = "Удалить вкладку";
|
||
deleteBtn.disabled = state.config.tabs.length <= 1;
|
||
deleteBtn.addEventListener("click", () => deleteTab(index));
|
||
row.append(input, selectBtn, deleteBtn);
|
||
el.tabsManager.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function addTab() {
|
||
let number = state.config.tabs.length + 1;
|
||
let id = `tab-${number}`;
|
||
while (state.config.tabs.some((tab) => tab.id === id)) id = `tab-${++number}`;
|
||
state.config.tabs.push({ id, label: `Вкладка ${number}` });
|
||
state.activeTab = id;
|
||
renderTabsManager();
|
||
renderActiveTabSelect();
|
||
renderCanvas();
|
||
renderInspector();
|
||
}
|
||
|
||
function deleteTab(index) {
|
||
if (state.config.tabs.length <= 1) return;
|
||
const [removed] = state.config.tabs.splice(index, 1);
|
||
state.config.components.forEach((component) => {
|
||
component.tabs = component.tabs.filter((id) => id !== removed.id);
|
||
if (!component.tabs.length) component.tabs = [state.config.tabs[0].id];
|
||
});
|
||
if (state.activeTab === removed.id) state.activeTab = state.config.tabs[0].id;
|
||
renderTabsManager();
|
||
renderActiveTabSelect();
|
||
renderCanvas();
|
||
renderInspector();
|
||
}
|
||
|
||
function renderActiveTabSelect() {
|
||
if (!el.activeTabSelect) return;
|
||
if (!state.config.tabs.some((tab) => tab.id === state.activeTab)) state.activeTab = state.config.tabs[0]?.id || "main";
|
||
el.activeTabSelect.innerHTML = state.config.tabs.map((tab) => `<option value="${escapeHtml(tab.id)}">${escapeHtml(tab.label)}</option>`).join("");
|
||
el.activeTabSelect.value = state.activeTab;
|
||
refreshEnhancedControl(el.activeTabSelect);
|
||
}
|
||
|
||
function isOnActiveTab(component) {
|
||
return component.tabs.includes("*") || component.tabs.includes(state.activeTab);
|
||
}
|
||
|
||
function componentById(id) {
|
||
return id ? state.config.components.find((item) => item.id === id) || null : null;
|
||
}
|
||
|
||
function descendantsOf(componentId) {
|
||
const result = [];
|
||
const visit = (parentId) => {
|
||
state.config.components
|
||
.filter((item) => item.parent_id === parentId)
|
||
.forEach((child) => {
|
||
result.push(child);
|
||
visit(child.id);
|
||
});
|
||
};
|
||
visit(componentId);
|
||
return result;
|
||
}
|
||
|
||
function ancestorsOf(component) {
|
||
const result = [];
|
||
const seen = new Set();
|
||
let current = componentById(component?.parent_id);
|
||
while (current && !seen.has(current.id)) {
|
||
seen.add(current.id);
|
||
result.push(current);
|
||
current = componentById(current.parent_id);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function isEffectivelyOnActiveTab(component) {
|
||
if (!isOnActiveTab(component) || component.hidden) return false;
|
||
return ancestorsOf(component).every((parent) => isOnActiveTab(parent) && !parent.hidden);
|
||
}
|
||
|
||
function isInsideContainer(component, container) {
|
||
if (!component || !container) return false;
|
||
const centerX = component.x + component.w / 2;
|
||
const centerY = component.y + component.h / 2;
|
||
return centerX >= container.x && centerX <= container.x + container.w
|
||
&& centerY >= container.y && centerY <= container.y + container.h;
|
||
}
|
||
|
||
function eligibleParentContainers(component) {
|
||
const blocked = new Set([component.id, ...descendantsOf(component.id).map((item) => item.id)]);
|
||
return state.config.components
|
||
.filter((item) => item.type === "container" && !blocked.has(item.id))
|
||
.filter((item) => state.showAllTabs || isOnActiveTab(item));
|
||
}
|
||
|
||
function findDropContainer(component) {
|
||
const candidates = eligibleParentContainers(component)
|
||
.filter((container) => isInsideContainer(component, container))
|
||
.sort((a, b) => {
|
||
const areaDelta = (a.w * a.h) - (b.w * b.h);
|
||
return areaDelta || Number(b.z) - Number(a.z);
|
||
});
|
||
return candidates[0] || null;
|
||
}
|
||
|
||
function clearDropTargets() {
|
||
el.canvasStage?.querySelectorAll(".drop-target").forEach((node) => node.classList.remove("drop-target"));
|
||
}
|
||
|
||
function highlightDropContainer(component) {
|
||
clearDropTargets();
|
||
if (!state.config.canvas.auto_bind_containers) return null;
|
||
const target = findDropContainer(component);
|
||
if (target) el.canvasStage?.querySelector(`[data-id="${CSS.escape(target.id)}"]`)?.classList.add("drop-target");
|
||
return target;
|
||
}
|
||
|
||
function attachToContainer(component, parentId, reposition = false) {
|
||
const previous = component.parent_id || null;
|
||
const parent = componentById(parentId);
|
||
if (!parent || parent.type !== "container" || parent.id === component.id || descendantsOf(component.id).some((item) => item.id === parent.id)) {
|
||
component.parent_id = null;
|
||
return previous !== null;
|
||
}
|
||
component.parent_id = parent.id;
|
||
component.z = Math.max(Number(component.z) || 0, (Number(parent.z) || 0) + 1);
|
||
if (reposition && !isInsideContainer(component, parent)) {
|
||
component.x = clamp(parent.x + 20, 0, state.config.canvas.width - component.w);
|
||
component.y = clamp(parent.y + 38, 0, state.config.canvas.height - component.h);
|
||
}
|
||
return previous !== component.parent_id;
|
||
}
|
||
|
||
function autoBindComponent(component) {
|
||
if (!state.config.canvas.auto_bind_containers) return false;
|
||
const target = findDropContainer(component);
|
||
const previous = component.parent_id || null;
|
||
component.parent_id = target?.id || null;
|
||
if (target) component.z = Math.max(Number(component.z) || 0, (Number(target.z) || 0) + 1);
|
||
return previous !== component.parent_id;
|
||
}
|
||
|
||
function hierarchyStartPositions(component) {
|
||
return [component, ...descendantsOf(component.id)].map((item) => ({
|
||
item,
|
||
x: Number(item.x) || 0,
|
||
y: Number(item.y) || 0,
|
||
node: el.canvasStage?.querySelector(`[data-id="${CSS.escape(item.id)}"]`) || null,
|
||
}));
|
||
}
|
||
|
||
function boundedGroupDelta(items, requestedDx, requestedDy) {
|
||
const minX = Math.min(...items.map(({ x }) => x));
|
||
const minY = Math.min(...items.map(({ y }) => y));
|
||
const maxX = Math.max(...items.map(({ item, x }) => x + item.w));
|
||
const maxY = Math.max(...items.map(({ item, y }) => y + item.h));
|
||
return {
|
||
dx: clamp(requestedDx, -minX, state.config.canvas.width - maxX),
|
||
dy: clamp(requestedDy, -minY, state.config.canvas.height - maxY),
|
||
};
|
||
}
|
||
|
||
function moveHierarchyBy(component, dx, dy) {
|
||
const items = hierarchyStartPositions(component);
|
||
const bounded = boundedGroupDelta(items, dx, dy);
|
||
items.forEach(({ item, x, y }) => {
|
||
item.x = Math.round(x + bounded.dx);
|
||
item.y = Math.round(y + bounded.dy);
|
||
});
|
||
}
|
||
|
||
function renderCanvas() {
|
||
if (!el.canvasStage || boot.mode === "runtime") return;
|
||
const canvas = state.config.canvas;
|
||
el.canvasSizer.style.width = `${canvas.width * state.zoom}px`;
|
||
el.canvasSizer.style.height = `${canvas.height * state.zoom}px`;
|
||
el.canvasStage.style.width = `${canvas.width}px`;
|
||
el.canvasStage.style.height = `${canvas.height}px`;
|
||
el.canvasStage.style.transform = `scale(${state.zoom})`;
|
||
el.canvasStage.style.backgroundColor = canvas.background || "#0c1421";
|
||
el.canvasStage.style.setProperty("--grid-size", `${canvas.grid_size || 10}px`);
|
||
el.canvasStage.classList.toggle("show-grid", Boolean(canvas.show_grid));
|
||
el.zoomLabel.textContent = `${Math.round(state.zoom * 100)}%`;
|
||
el.canvasStage.innerHTML = "";
|
||
|
||
const guideX = document.createElement("div");
|
||
guideX.id = "guideX";
|
||
guideX.className = "snap-guide horizontal hidden";
|
||
const guideY = document.createElement("div");
|
||
guideY.id = "guideY";
|
||
guideY.className = "snap-guide vertical hidden";
|
||
el.canvasStage.append(guideX, guideY);
|
||
|
||
const visible = state.config.components
|
||
.filter((component) => state.showAllTabs || isEffectivelyOnActiveTab(component))
|
||
.sort((a, b) => Number(a.z) - Number(b.z));
|
||
|
||
if (!visible.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "canvas-empty";
|
||
empty.textContent = "На этой вкладке пока нет компонентов.";
|
||
el.canvasStage.appendChild(empty);
|
||
}
|
||
|
||
visible.forEach((component) => {
|
||
const wrapper = document.createElement("div");
|
||
wrapper.className = "builder-component";
|
||
if (component.id === state.selectedId) wrapper.classList.add("selected");
|
||
if (!isEffectivelyOnActiveTab(component)) wrapper.classList.add("is-hidden-layer");
|
||
if (component.locked) wrapper.classList.add("locked");
|
||
if (component.hidden) wrapper.classList.add("is-hidden-layer");
|
||
if (component.type === "container") wrapper.classList.add("is-container");
|
||
if (component.parent_id) wrapper.classList.add("has-parent");
|
||
wrapper.dataset.id = component.id;
|
||
Object.assign(wrapper.style, {
|
||
left: `${component.x}px`, top: `${component.y}px`, width: `${component.w}px`, height: `${component.h}px`, zIndex: String(component.z),
|
||
});
|
||
|
||
const shell = document.createElement("div");
|
||
shell.className = "component-shell";
|
||
shell.style.pointerEvents = "none";
|
||
shell.appendChild(renderComponent(component, false));
|
||
const tag = document.createElement("div");
|
||
tag.className = "component-tag";
|
||
const parent = componentById(component.parent_id);
|
||
tag.textContent = `${catalogMap[component.type]?.label || component.type} · ${component.title}${parent ? ` · ${parent.title}` : ""}`;
|
||
wrapper.append(shell, tag);
|
||
const shortcutText = enabledShortcutText(component);
|
||
if (shortcutText) {
|
||
const shortcutBadge = document.createElement("div");
|
||
shortcutBadge.className = "component-shortcut-badge";
|
||
shortcutBadge.textContent = shortcutText;
|
||
shortcutBadge.title = `Горячие клавиши: ${shortcutText}`;
|
||
wrapper.appendChild(shortcutBadge);
|
||
}
|
||
|
||
if (!component.locked) {
|
||
const resize = document.createElement("div");
|
||
resize.className = "resize-handle";
|
||
resize.addEventListener("pointerdown", (event) => startResize(event, component));
|
||
wrapper.appendChild(resize);
|
||
}
|
||
|
||
wrapper.addEventListener("pointerdown", (event) => startDrag(event, component));
|
||
wrapper.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
state.selectedId = component.id;
|
||
renderCanvas();
|
||
renderInspector();
|
||
});
|
||
el.canvasStage.appendChild(wrapper);
|
||
});
|
||
|
||
el.canvasStage.onpointerdown = (event) => {
|
||
if (event.target === el.canvasStage) {
|
||
state.selectedId = null;
|
||
renderCanvas();
|
||
renderInspector();
|
||
}
|
||
};
|
||
scheduleStyledControls();
|
||
}
|
||
|
||
function startDrag(event, component) {
|
||
if (event.button !== 0 || event.target.classList.contains("resize-handle")) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
state.selectedId = component.id;
|
||
renderInspector();
|
||
if (component.locked) { renderCanvas(); return; }
|
||
|
||
const node = event.currentTarget;
|
||
try { node.setPointerCapture?.(event.pointerId); } catch (_) {}
|
||
node.classList.add("dragging", "selected");
|
||
const moving = hierarchyStartPositions(component);
|
||
const start = { clientX: event.clientX, clientY: event.clientY, x: component.x, y: component.y };
|
||
|
||
const move = (moveEvent) => {
|
||
const rawX = start.x + (moveEvent.clientX - start.clientX) / state.zoom;
|
||
const rawY = start.y + (moveEvent.clientY - start.clientY) / state.zoom;
|
||
const snapped = snapPosition(component, rawX, rawY);
|
||
const desiredDx = snapped.x - start.x;
|
||
const desiredDy = snapped.y - start.y;
|
||
const bounded = boundedGroupDelta(moving, desiredDx, desiredDy);
|
||
|
||
moving.forEach(({ item, x, y, node: itemNode }) => {
|
||
item.x = Math.round(x + bounded.dx);
|
||
item.y = Math.round(y + bounded.dy);
|
||
if (itemNode) {
|
||
itemNode.style.left = `${item.x}px`;
|
||
itemNode.style.top = `${item.y}px`;
|
||
}
|
||
});
|
||
|
||
showGuides(snapped.guideX, snapped.guideY);
|
||
highlightDropContainer(component);
|
||
updateGeometryInspector(component);
|
||
};
|
||
|
||
const up = () => {
|
||
node.classList.remove("dragging");
|
||
hideGuides();
|
||
clearDropTargets();
|
||
const bindingChanged = autoBindComponent(component);
|
||
window.removeEventListener("pointermove", move);
|
||
window.removeEventListener("pointerup", up);
|
||
renderCanvas();
|
||
renderInspector();
|
||
if (bindingChanged) {
|
||
const parent = componentById(component.parent_id);
|
||
toast(parent ? `Элемент привязан к «${parent.title}»` : "Элемент отсоединён от контейнера");
|
||
}
|
||
};
|
||
window.addEventListener("pointermove", move);
|
||
window.addEventListener("pointerup", up, { once: true });
|
||
}
|
||
|
||
function startResize(event, component) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const start = { clientX: event.clientX, clientY: event.clientY, w: component.w, h: component.h };
|
||
const move = (moveEvent) => {
|
||
let w = start.w + (moveEvent.clientX - start.clientX) / state.zoom;
|
||
let h = start.h + (moveEvent.clientY - start.clientY) / state.zoom;
|
||
if (state.config.canvas.snap_enabled) {
|
||
const grid = state.config.canvas.grid_size || 10;
|
||
w = Math.round(w / grid) * grid;
|
||
h = Math.round(h / grid) * grid;
|
||
}
|
||
component.w = clamp(Math.round(w), 40, state.config.canvas.width - component.x);
|
||
component.h = clamp(Math.round(h), 28, state.config.canvas.height - component.y);
|
||
const wrapper = el.canvasStage.querySelector(`[data-id="${CSS.escape(component.id)}"]`);
|
||
if (wrapper) {
|
||
wrapper.style.width = `${component.w}px`;
|
||
wrapper.style.height = `${component.h}px`;
|
||
}
|
||
updateGeometryInspector(component);
|
||
};
|
||
const up = () => {
|
||
window.removeEventListener("pointermove", move);
|
||
window.removeEventListener("pointerup", up);
|
||
renderCanvas();
|
||
};
|
||
window.addEventListener("pointermove", move);
|
||
window.addEventListener("pointerup", up, { once: true });
|
||
}
|
||
|
||
function snapPosition(component, rawX, rawY) {
|
||
if (!state.config.canvas.snap_enabled) return { x: rawX, y: rawY, guideX: null, guideY: null };
|
||
const grid = state.config.canvas.grid_size || 10;
|
||
let x = Math.round(rawX / grid) * grid;
|
||
let y = Math.round(rawY / grid) * grid;
|
||
const threshold = state.config.canvas.snap_threshold || 8;
|
||
const blocked = new Set([component.id, ...descendantsOf(component.id).map((item) => item.id)]);
|
||
const others = state.config.components.filter((item) => !blocked.has(item.id) && (state.showAllTabs || isEffectivelyOnActiveTab(item)));
|
||
|
||
const xTargets = [0, state.config.canvas.width / 2, state.config.canvas.width];
|
||
const yTargets = [0, state.config.canvas.height / 2, state.config.canvas.height];
|
||
others.forEach((item) => {
|
||
xTargets.push(item.x, item.x + item.w / 2, item.x + item.w);
|
||
yTargets.push(item.y, item.y + item.h / 2, item.y + item.h);
|
||
});
|
||
|
||
const xEdges = [x, x + component.w / 2, x + component.w];
|
||
const yEdges = [y, y + component.h / 2, y + component.h];
|
||
let bestX = { delta: Infinity, guide: null };
|
||
let bestY = { delta: Infinity, guide: null };
|
||
xTargets.forEach((target) => xEdges.forEach((edge) => {
|
||
const delta = target - edge;
|
||
if (Math.abs(delta) < Math.abs(bestX.delta) && Math.abs(delta) <= threshold) bestX = { delta, guide: target };
|
||
}));
|
||
yTargets.forEach((target) => yEdges.forEach((edge) => {
|
||
const delta = target - edge;
|
||
if (Math.abs(delta) < Math.abs(bestY.delta) && Math.abs(delta) <= threshold) bestY = { delta, guide: target };
|
||
}));
|
||
if (Number.isFinite(bestX.delta)) x += bestX.delta;
|
||
if (Number.isFinite(bestY.delta)) y += bestY.delta;
|
||
return { x, y, guideX: bestX.guide, guideY: bestY.guide };
|
||
}
|
||
|
||
function showGuides(x, y) {
|
||
const guideX = el.canvasStage.querySelector("#guideX");
|
||
const guideY = el.canvasStage.querySelector("#guideY");
|
||
if (guideY) {
|
||
guideY.classList.toggle("hidden", x == null);
|
||
if (x != null) guideY.style.left = `${x}px`;
|
||
}
|
||
if (guideX) {
|
||
guideX.classList.toggle("hidden", y == null);
|
||
if (y != null) guideX.style.top = `${y}px`;
|
||
}
|
||
}
|
||
|
||
function hideGuides() { showGuides(null, null); }
|
||
|
||
function renderInspector() {
|
||
if (!el.inspector) return;
|
||
const component = state.config.components.find((item) => item.id === state.selectedId);
|
||
if (!component) {
|
||
el.emptyInspector.classList.remove("hidden");
|
||
el.inspector.classList.add("hidden");
|
||
el.selectedType.textContent = "—";
|
||
return;
|
||
}
|
||
const meta = catalogMap[component.type] || catalogMap.text;
|
||
el.emptyInspector.classList.add("hidden");
|
||
el.inspector.classList.remove("hidden");
|
||
el.selectedType.textContent = meta.label;
|
||
el.inspector.innerHTML = "";
|
||
|
||
const common = group("Основное");
|
||
common.appendChild(fieldInput({ key: "title", label: "Название в конструкторе", type: "text" }, component.title, (value) => { component.title = value; renderCanvas(); }));
|
||
common.appendChild(fieldRow([
|
||
numberControl("X", component.x, (v) => setGeometry(component, "x", v), "geom-x"),
|
||
numberControl("Y", component.y, (v) => setGeometry(component, "y", v), "geom-y"),
|
||
numberControl("Ширина", component.w, (v) => setGeometry(component, "w", v), "geom-w"),
|
||
numberControl("Высота", component.h, (v) => setGeometry(component, "h", v), "geom-h"),
|
||
]));
|
||
common.appendChild(fieldRow([
|
||
numberControl("Слой Z", component.z, (v) => { component.z = Number(v); renderCanvas(); }, "geom-z"),
|
||
checkboxControl("Зафиксировать", component.locked, (v) => { component.locked = v; renderCanvas(); }),
|
||
checkboxControl("Скрыть", component.hidden, (v) => { component.hidden = v; renderCanvas(); }),
|
||
]));
|
||
el.inspector.appendChild(common);
|
||
|
||
if (isInteractiveComponent(component)) {
|
||
const interactionGroup = group("События, ID и состояния");
|
||
interactionGroup.appendChild(fieldInput({
|
||
key: "action_id", label: "Action ID", type: "text",
|
||
help: "Стабильный идентификатор для кода и триггеров. Допустимы латиница, цифры, _, -, . и :"
|
||
}, component.action_id, (value) => {
|
||
const requested = sanitizeActionId(value, `${component.type}_${component.id.slice(0, 6)}`);
|
||
const next = uniqueActionId(requested, component.id);
|
||
const previous = component.action_id;
|
||
component.action_id = next;
|
||
state.config.triggers.forEach((trigger) => {
|
||
if (trigger.source_action_id === previous) trigger.source_action_id = next;
|
||
if (trigger.action?.target_action_id === previous) trigger.action.target_action_id = next;
|
||
});
|
||
ensureComponentState(component);
|
||
renderInspector();
|
||
renderCanvas();
|
||
renderRuntime();
|
||
updateTriggersCount();
|
||
}));
|
||
interactionGroup.appendChild(fieldInput({
|
||
key: "interaction_mode", label: "Поведение состояния", type: "select", options: [
|
||
["event_only", "Только событие"], ["toggle", "Переключать true / false"],
|
||
["set_true", "После нажатия = true"], ["momentary", "true, пока удерживается"], ["value", "Хранить выбранное значение"]
|
||
]
|
||
}, component.interaction_mode, (value) => { component.interaction_mode = value; ensureComponentState(component, true); renderRuntime(); }));
|
||
interactionGroup.appendChild(checkboxControl("Начальное состояние true", component.initial_state, (value) => {
|
||
component.initial_state = value; ensureComponentState(component, true); renderRuntime();
|
||
}));
|
||
const currentState = document.createElement("pre");
|
||
currentState.className = "interaction-state-preview";
|
||
currentState.textContent = JSON.stringify(ensureComponentState(component), null, 2);
|
||
interactionGroup.appendChild(currentState);
|
||
const triggerButton = document.createElement("button");
|
||
triggerButton.type = "button";
|
||
triggerButton.className = "btn trigger-open-button";
|
||
triggerButton.textContent = `Настроить триггеры (${triggersFor(component.action_id).length})`;
|
||
triggerButton.addEventListener("click", () => showTriggersEditor(component.action_id));
|
||
interactionGroup.appendChild(triggerButton);
|
||
const note = document.createElement("p");
|
||
note.className = "container-help";
|
||
note.textContent = component.type === "button_group"
|
||
? "Для отдельных кнопок используйте формат item_id:Название, например start:Старт|stop:Стоп."
|
||
: component.type === "tab_bar"
|
||
? "У вкладки item_id равен ID вкладки проекта."
|
||
: "Runtime отправляет нормализованные события click, change, state_change, pointer_down и pointer_up.";
|
||
interactionGroup.appendChild(note);
|
||
el.inspector.appendChild(interactionGroup);
|
||
}
|
||
|
||
el.inspector.appendChild(shortcutsInspectorGroup(component));
|
||
|
||
const parentGroup = group("Привязка к контейнеру");
|
||
const parentInfo = document.createElement("div");
|
||
parentInfo.className = "parent-info";
|
||
const parent = componentById(component.parent_id);
|
||
const parentOptions = [["", "Без контейнера"], ...eligibleParentContainers(component).map((item) => [item.id, item.title])];
|
||
parentInfo.appendChild(fieldInput({
|
||
key: "parent_id",
|
||
label: "Родительский контейнер",
|
||
type: "select",
|
||
options: parentOptions,
|
||
help: "Можно выбрать здесь или просто перенести элемент внутрь контейнера на холсте.",
|
||
}, component.parent_id || "", (value) => {
|
||
if (value) attachToContainer(component, value, true);
|
||
else component.parent_id = null;
|
||
renderCanvas();
|
||
renderInspector();
|
||
renderRuntime();
|
||
}));
|
||
const badge = document.createElement("div");
|
||
badge.className = "parent-badge";
|
||
badge.textContent = parent ? `Сейчас привязан к: ${parent.title}` : "Элемент пока расположен непосредственно на холсте.";
|
||
const help = document.createElement("p");
|
||
help.className = "container-help";
|
||
help.textContent = component.type === "container"
|
||
? "Контейнер тоже может быть вложен в другой контейнер. Все дочерние элементы перемещаются вместе с ним."
|
||
: "После привязки элемент сохраняет собственные координаты, но двигается вместе с контейнером.";
|
||
parentInfo.append(badge, help);
|
||
parentGroup.appendChild(parentInfo);
|
||
el.inspector.appendChild(parentGroup);
|
||
|
||
const tabsGroup = group("Видимость по вкладкам");
|
||
const checks = document.createElement("div");
|
||
checks.className = "tab-checks";
|
||
checks.appendChild(tabCheckbox("Все вкладки", "*", component));
|
||
state.config.tabs.forEach((tab) => checks.appendChild(tabCheckbox(tab.label, tab.id, component)));
|
||
tabsGroup.appendChild(checks);
|
||
el.inspector.appendChild(tabsGroup);
|
||
|
||
if (meta.fields.length) {
|
||
const propsGroup = group("Содержимое и данные");
|
||
meta.fields.forEach((definition) => {
|
||
propsGroup.appendChild(fieldInput(definition, component.props[definition.key], (value) => {
|
||
component.props[definition.key] = value;
|
||
renderCanvas();
|
||
renderRuntime();
|
||
}));
|
||
});
|
||
el.inspector.appendChild(propsGroup);
|
||
}
|
||
|
||
const styleGroup = group("Внешний вид");
|
||
const styleFields = [
|
||
{ key: "background", label: "Фон", type: "color" },
|
||
{ key: "color", label: "Цвет текста", type: "color" },
|
||
{ key: "accent", label: "Акцент", type: "color" },
|
||
{ key: "borderColor", label: "Цвет рамки", type: "color" },
|
||
{ key: "borderWidth", label: "Толщина рамки", type: "number", min: 0, max: 20 },
|
||
{ key: "borderRadius", label: "Скругление", type: "number", min: 0, max: 200 },
|
||
{ key: "padding", label: "Внутренний отступ", type: "number", min: 0, max: 100 },
|
||
{ key: "fontSize", label: "Размер шрифта", type: "number", min: 6, max: 100 },
|
||
{ key: "fontWeight", label: "Жирность", type: "select", options: [["300","Light"],["400","Normal"],["600","SemiBold"],["800","Bold"],["900","Black"]] },
|
||
{ key: "align", label: "Выравнивание", type: "select", options: [["left","Слева"],["center","По центру"],["right","Справа"]] },
|
||
{ key: "opacity", label: "Прозрачность %", type: "number", min: 0, max: 100 },
|
||
{ key: "shadow", label: "Тень", type: "checkbox" },
|
||
];
|
||
styleFields.forEach((definition) => styleGroup.appendChild(fieldInput(definition, component.style[definition.key], (value) => {
|
||
component.style[definition.key] = value;
|
||
renderCanvas();
|
||
renderRuntime();
|
||
})));
|
||
el.inspector.appendChild(styleGroup);
|
||
scheduleStyledControls();
|
||
}
|
||
|
||
function actionIdField(component) {
|
||
return fieldInput({
|
||
key: "action_id", label: "Action ID", type: "text",
|
||
help: "Идентификатор элемента для горячих клавиш, событий, кода и триггеров."
|
||
}, component.action_id, (value) => {
|
||
const requested = sanitizeActionId(value, `${component.type}_${component.id.slice(0, 6)}`);
|
||
const next = uniqueActionId(requested, component.id);
|
||
const previous = component.action_id;
|
||
component.action_id = next;
|
||
state.config.triggers.forEach((trigger) => {
|
||
if (trigger.source_action_id === previous) trigger.source_action_id = next;
|
||
if (trigger.action?.target_action_id === previous) trigger.action.target_action_id = next;
|
||
});
|
||
ensureComponentState(component);
|
||
renderInspector(); renderCanvas(); renderRuntime(); updateTriggersCount();
|
||
});
|
||
}
|
||
|
||
function beginShortcutCapture(component, shortcut, input, button, onDone = null) {
|
||
state.shortcutCapture = { component, shortcut, input, button, previousText: button.textContent, onDone };
|
||
state.pressedShortcutModifiers.clear();
|
||
button.textContent = "Нажмите сочетание…";
|
||
button.classList.add("is-capturing");
|
||
input.placeholder = "Ожидание клавиш…";
|
||
input.focus();
|
||
}
|
||
|
||
function finishShortcutCapture(combo = null) {
|
||
const capture = state.shortcutCapture;
|
||
if (!capture) return false;
|
||
state.shortcutCapture = null;
|
||
capture.button.classList.remove("is-capturing");
|
||
capture.button.textContent = capture.previousText || "Записать";
|
||
if (combo) {
|
||
capture.shortcut.combo = normalizeShortcutCombo(combo);
|
||
capture.shortcut.enabled = true;
|
||
capture.input.value = capture.shortcut.combo;
|
||
toast(`Горячая клавиша: ${capture.shortcut.combo}`);
|
||
if (typeof capture.onDone === "function") capture.onDone(capture.shortcut.combo);
|
||
else { renderInspector(); renderCanvas(); }
|
||
} else {
|
||
capture.input.placeholder = "Ctrl+Shift, Ctrl+Shift+O, Shift+Num1 или F9";
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function shortcutsInspectorGroup(component) {
|
||
const section = group("Горячие клавиши");
|
||
if (!isInteractiveComponent(component)) section.appendChild(actionIdField(component));
|
||
|
||
const help = document.createElement("p");
|
||
help.className = "container-help";
|
||
help.innerHTML = `Каждая комбинация создаёт обычное событие элемента и запускает его триггеры. Текущий ID: <code>${escapeHtml(component.action_id)}</code>.`;
|
||
section.appendChild(help);
|
||
|
||
const list = document.createElement("div");
|
||
list.className = "shortcut-list";
|
||
const shortcuts = component.shortcuts ||= [];
|
||
|
||
if (!shortcuts.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "shortcut-empty";
|
||
empty.textContent = "Горячие клавиши пока не назначены.";
|
||
list.appendChild(empty);
|
||
}
|
||
|
||
shortcuts.forEach((shortcut, index) => {
|
||
const card = document.createElement("div");
|
||
card.className = "shortcut-card";
|
||
const conflict = shortcutConflict(component, shortcut);
|
||
if (conflict) card.classList.add("has-conflict");
|
||
|
||
const head = document.createElement("div");
|
||
head.className = "shortcut-head";
|
||
const enabled = document.createElement("label");
|
||
enabled.className = "shortcut-enabled";
|
||
const enabledInput = document.createElement("input");
|
||
enabledInput.type = "checkbox";
|
||
enabledInput.checked = shortcut.enabled;
|
||
enabledInput.addEventListener("change", () => { shortcut.enabled = enabledInput.checked; renderInspector(); renderCanvas(); });
|
||
enabled.append(enabledInput, document.createTextNode(` Shortcut ${index + 1}`));
|
||
const remove = document.createElement("button");
|
||
remove.type = "button";
|
||
remove.className = "shortcut-remove";
|
||
remove.textContent = "×";
|
||
remove.title = "Удалить горячую клавишу";
|
||
remove.addEventListener("click", () => { component.shortcuts = shortcuts.filter((item) => item.id !== shortcut.id); renderInspector(); renderCanvas(); });
|
||
head.append(enabled, remove);
|
||
card.appendChild(head);
|
||
|
||
const comboWrap = document.createElement("div");
|
||
comboWrap.className = "shortcut-combo-row";
|
||
const comboInput = document.createElement("input");
|
||
comboInput.type = "text";
|
||
comboInput.value = shortcut.combo || "";
|
||
comboInput.placeholder = "Ctrl+Shift, Ctrl+Shift+O или F9";
|
||
comboInput.addEventListener("change", () => { shortcut.combo = normalizeShortcutCombo(comboInput.value); comboInput.value = shortcut.combo; renderInspector(); renderCanvas(); });
|
||
const captureButton = document.createElement("button");
|
||
captureButton.type = "button";
|
||
captureButton.className = "btn shortcut-capture";
|
||
captureButton.textContent = "Записать";
|
||
captureButton.addEventListener("click", () => beginShortcutCapture(component, shortcut, comboInput, captureButton));
|
||
const clearButton = document.createElement("button");
|
||
clearButton.type = "button";
|
||
clearButton.className = "shortcut-clear";
|
||
clearButton.textContent = "Очистить";
|
||
clearButton.addEventListener("click", () => { shortcut.combo = ""; comboInput.value = ""; renderInspector(); renderCanvas(); });
|
||
comboWrap.append(comboInput, captureButton, clearButton);
|
||
card.appendChild(comboWrap);
|
||
|
||
const grid = document.createElement("div");
|
||
grid.className = "shortcut-grid";
|
||
grid.appendChild(fieldInput({ key: "event", label: "Событие", type: "select", options: [
|
||
["click", "click — нажатие"], ["change", "change — изменение"], ["open", "open — открытие"],
|
||
["tab_change", "tab_change — вкладка"], ["page_change", "page_change — страница"],
|
||
["state_change", "state_change — состояние"],
|
||
["timer_start", "timer_start — запустить таймер"],
|
||
["timer_pause", "timer_pause — пауза"],
|
||
["timer_resume", "timer_resume — продолжить"],
|
||
["timer_stop", "timer_stop — остановить"],
|
||
["timer_reset", "timer_reset — сбросить"],
|
||
["timer_toggle", "timer_toggle — старт / пауза"],
|
||
["timer_restart", "timer_restart — заново"],
|
||
["shortcut", "shortcut — отдельное событие"]
|
||
] }, shortcut.event, (value) => { shortcut.event = value; }));
|
||
grid.appendChild(fieldInput({ key: "item_id", label: "item_id", type: "text", help: "Для кнопки в группе или вкладки." }, shortcut.item_id, (value) => { shortcut.item_id = value; }));
|
||
grid.appendChild(fieldInput({ key: "value", label: "Значение", type: "text", help: "Передаётся как {{value}}." }, shortcut.value, (value) => { shortcut.value = value; }));
|
||
grid.appendChild(fieldInput({ key: "scope", label: "Где работает", type: "select", options: [["runtime", "Runtime и предпросмотр"], ["all", "Также в редакторе"]] }, shortcut.scope, (value) => { shortcut.scope = value; }));
|
||
card.appendChild(grid);
|
||
|
||
const toggles = document.createElement("div");
|
||
toggles.className = "shortcut-toggles";
|
||
toggles.appendChild(checkboxControl("Блокировать действие браузера", shortcut.prevent_default, (value) => { shortcut.prevent_default = value; }));
|
||
toggles.appendChild(checkboxControl("Работает во время ввода", shortcut.allow_in_inputs, (value) => { shortcut.allow_in_inputs = value; }));
|
||
toggles.appendChild(checkboxControl("Работает на других вкладках", shortcut.global, (value) => { shortcut.global = value; }));
|
||
card.appendChild(toggles);
|
||
|
||
if (conflict) {
|
||
const warning = document.createElement("div");
|
||
warning.className = "shortcut-warning";
|
||
warning.textContent = `Комбинация также назначена элементу «${conflict.component.title}». Сработают оба элемента.`;
|
||
card.appendChild(warning);
|
||
}
|
||
list.appendChild(card);
|
||
});
|
||
|
||
section.appendChild(list);
|
||
const add = document.createElement("button");
|
||
add.type = "button";
|
||
add.className = "btn shortcut-add";
|
||
add.textContent = "+ Добавить горячую клавишу";
|
||
add.addEventListener("click", () => {
|
||
component.shortcuts.push(normalizeShortcut({ id: uid(), enabled: true, combo: "", event: "click" }, component.shortcuts.length));
|
||
renderInspector();
|
||
});
|
||
section.appendChild(add);
|
||
return section;
|
||
}
|
||
|
||
function group(title) {
|
||
const node = document.createElement("section");
|
||
node.className = "inspector-group";
|
||
node.innerHTML = `<div class="inspector-group-title">${escapeHtml(title)}</div>`;
|
||
return node;
|
||
}
|
||
|
||
function fieldRow(nodes) {
|
||
const row = document.createElement("div");
|
||
row.className = "field-row";
|
||
nodes.forEach((node) => row.appendChild(node));
|
||
return row;
|
||
}
|
||
|
||
function fieldInput(definition, value, onChange) {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "field";
|
||
const label = document.createElement("label");
|
||
label.textContent = definition.label;
|
||
wrap.appendChild(label);
|
||
|
||
if (definition.type === "color") {
|
||
wrap.appendChild(createColorControl(value, onChange, { allowEmpty: true }));
|
||
if (definition.help) {
|
||
const small = document.createElement("small");
|
||
small.textContent = definition.help;
|
||
wrap.appendChild(small);
|
||
}
|
||
return wrap;
|
||
}
|
||
|
||
let input;
|
||
if (definition.type === "textarea") {
|
||
input = document.createElement("textarea");
|
||
input.rows = 3;
|
||
} else if (definition.type === "select") {
|
||
input = document.createElement("select");
|
||
(definition.options || []).forEach(([optionValue, optionLabel]) => {
|
||
const option = document.createElement("option");
|
||
option.value = optionValue;
|
||
option.textContent = optionLabel;
|
||
input.appendChild(option);
|
||
});
|
||
} else if (definition.type === "checkbox") {
|
||
input = document.createElement("input");
|
||
input.type = "checkbox";
|
||
input.checked = Boolean(value);
|
||
} else {
|
||
input = document.createElement("input");
|
||
input.type = definition.type === "number" ? "number" : "text";
|
||
if (definition.type === "path") input.setAttribute("list", "dataPathList");
|
||
if (definition.min !== undefined) input.min = definition.min;
|
||
if (definition.max !== undefined) input.max = definition.max;
|
||
if (definition.type === "number") input.step = definition.step || "any";
|
||
}
|
||
|
||
if (definition.type !== "checkbox") input.value = value ?? "";
|
||
const eventName = definition.type === "checkbox" || definition.type === "select" ? "change" : "input";
|
||
input.addEventListener(eventName, () => {
|
||
let next;
|
||
if (definition.type === "checkbox") next = input.checked;
|
||
else if (definition.type === "number") next = input.value === "" ? 0 : Number(input.value);
|
||
else next = input.value;
|
||
onChange(next);
|
||
});
|
||
|
||
if (definition.type === "number") wrap.appendChild(numberControlElement(input));
|
||
else wrap.appendChild(input);
|
||
|
||
if (definition.help) {
|
||
const small = document.createElement("small");
|
||
small.textContent = definition.help;
|
||
wrap.appendChild(small);
|
||
}
|
||
return wrap;
|
||
}
|
||
|
||
function numberControl(label, value, onChange, className = "") {
|
||
const node = fieldInput({ label, type: "number" }, value, onChange);
|
||
if (className) node.classList.add(className);
|
||
return node;
|
||
}
|
||
|
||
function checkboxControl(label, value, onChange) {
|
||
return fieldInput({ label, type: "checkbox" }, value, onChange);
|
||
}
|
||
|
||
function tabCheckbox(label, tabId, component) {
|
||
const wrap = document.createElement("label");
|
||
wrap.className = "tab-check";
|
||
const input = document.createElement("input");
|
||
input.type = "checkbox";
|
||
input.checked = component.tabs.includes(tabId);
|
||
input.addEventListener("change", () => {
|
||
if (tabId === "*") {
|
||
component.tabs = input.checked ? ["*"] : [state.activeTab];
|
||
} else if (input.checked) {
|
||
component.tabs = component.tabs.filter((id) => id !== "*");
|
||
if (!component.tabs.includes(tabId)) component.tabs.push(tabId);
|
||
} else {
|
||
component.tabs = component.tabs.filter((id) => id !== tabId);
|
||
if (!component.tabs.length) component.tabs = [state.activeTab];
|
||
}
|
||
renderInspector();
|
||
renderCanvas();
|
||
renderRuntime();
|
||
});
|
||
wrap.append(input, document.createTextNode(label));
|
||
return wrap;
|
||
}
|
||
|
||
function setGeometry(component, key, value) {
|
||
const number = Number(value) || 0;
|
||
if (key === "x" || key === "y") {
|
||
const targetX = key === "x" ? clamp(number, 0, state.config.canvas.width - component.w) : component.x;
|
||
const targetY = key === "y" ? clamp(number, 0, state.config.canvas.height - component.h) : component.y;
|
||
moveHierarchyBy(component, targetX - component.x, targetY - component.y);
|
||
}
|
||
if (key === "w") component.w = clamp(number, 40, state.config.canvas.width - component.x);
|
||
if (key === "h") component.h = clamp(number, 28, state.config.canvas.height - component.y);
|
||
renderCanvas();
|
||
renderRuntime();
|
||
}
|
||
|
||
function updateGeometryInspector(component) {
|
||
const map = { ".geom-x input": component.x, ".geom-y input": component.y, ".geom-w input": component.w, ".geom-h input": component.h, ".geom-z input": component.z };
|
||
Object.entries(map).forEach(([selector, value]) => {
|
||
const input = el.inspector?.querySelector(selector);
|
||
if (input && document.activeElement !== input) input.value = Math.round(value);
|
||
});
|
||
}
|
||
|
||
function normalizeTrigger(trigger = {}, index = 0) {
|
||
const action = trigger.action && typeof trigger.action === "object" ? trigger.action : {};
|
||
const condition = trigger.condition && typeof trigger.condition === "object" ? trigger.condition : {};
|
||
return {
|
||
id: String(trigger.id || `trigger-${index + 1}-${Math.random().toString(36).slice(2, 7)}`),
|
||
name: String(trigger.name || `Триггер ${index + 1}`),
|
||
enabled: trigger.enabled !== false,
|
||
source_action_id: String(trigger.source_action_id || ""),
|
||
event: String(trigger.event || "click"),
|
||
item_id: String(trigger.item_id || ""),
|
||
condition: { field: String(condition.field || ""), operator: String(condition.operator || "equals"), value: condition.value ?? "" },
|
||
action: {
|
||
type: String(action.type || "show_message"), target_action_id: String(action.target_action_id || ""),
|
||
state_key: String(action.state_key || "active"), value: action.value ?? "true",
|
||
function_name: String(action.function_name || ""), event_name: String(action.event_name || "ui-builder:custom"),
|
||
message: String(action.message || "Триггер выполнен"), tab_id: String(action.tab_id || ""),
|
||
url: String(action.url || ""), method: String(action.method || "POST").toUpperCase(), body: String(action.body || ""),
|
||
timer_command: String(action.timer_command || "toggle"), timer_value: action.timer_value ?? "",
|
||
sequence_id: String(action.sequence_id || ""),
|
||
},
|
||
};
|
||
}
|
||
|
||
function triggersFor(actionId) { return state.config.triggers.filter((trigger) => trigger.source_action_id === actionId); }
|
||
function updateTriggersCount() { if (el.triggersCount) el.triggersCount.textContent = String(state.config.triggers?.length || 0); }
|
||
|
||
function ensureComponentState(component, reset = false) {
|
||
const actionId = component.action_id;
|
||
if (!actionId) return {};
|
||
if (reset || !state.componentStates[actionId]) {
|
||
const initialValue = state.formValues[component.id] ?? component.props?.value ?? null;
|
||
state.componentStates[actionId] = {
|
||
active: Boolean(component.initial_state), pressed: false, value: initialValue,
|
||
active_item: null, items: {}, last_event: null, updated_at: null,
|
||
};
|
||
}
|
||
return state.componentStates[actionId];
|
||
}
|
||
|
||
function componentByActionId(actionId) { return state.config.components.find((item) => item.action_id === actionId); }
|
||
|
||
function parseTypedValue(value) {
|
||
if (typeof value !== "string") return value;
|
||
const text = value.trim();
|
||
if (text === "true") return true;
|
||
if (text === "false") return false;
|
||
if (text === "null") return null;
|
||
if (text === "undefined") return undefined;
|
||
if (text !== "" && !Number.isNaN(Number(text))) return Number(text);
|
||
if ((text.startsWith("{") && text.endsWith("}")) || (text.startsWith("[") && text.endsWith("]"))) {
|
||
try { return JSON.parse(text); } catch (_) {}
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function templateValue(text, context) {
|
||
return String(text ?? "").replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_, path) => formatValue(getByPath(context, path.trim()), ""));
|
||
}
|
||
|
||
function conditionMatches(condition, context) {
|
||
if (!condition?.field) return true;
|
||
const actual = getByPath(context, condition.field);
|
||
const expected = parseTypedValue(condition.value);
|
||
switch (condition.operator) {
|
||
case "not_equals": return actual !== expected && String(actual) !== String(expected);
|
||
case "truthy": return Boolean(actual);
|
||
case "falsy": return !actual;
|
||
case "contains": return String(actual ?? "").includes(String(expected ?? ""));
|
||
case "greater": return Number(actual) > Number(expected);
|
||
case "less": return Number(actual) < Number(expected);
|
||
default: return actual === expected || String(actual) === String(expected);
|
||
}
|
||
}
|
||
|
||
function applyInteractionMode(component, eventName, detail, componentState) {
|
||
const itemId = detail.item_id || "";
|
||
if (eventName === "pointer_down") componentState.pressed = true;
|
||
if (eventName === "pointer_up") componentState.pressed = false;
|
||
if (eventName === "click") {
|
||
if (component.interaction_mode === "toggle") {
|
||
if (itemId) componentState.items[itemId] = !Boolean(componentState.items[itemId]);
|
||
else componentState.active = !Boolean(componentState.active);
|
||
} else if (component.interaction_mode === "set_true") {
|
||
componentState.active = true;
|
||
if (itemId) componentState.active_item = itemId;
|
||
}
|
||
}
|
||
if (["change", "tab_change", "page_change"].includes(eventName) || component.interaction_mode === "value") {
|
||
if (detail.value !== undefined) componentState.value = detail.value;
|
||
if (itemId) componentState.active_item = itemId;
|
||
}
|
||
componentState.last_event = eventName;
|
||
componentState.updated_at = new Date().toISOString();
|
||
}
|
||
|
||
function interactionContext(component, eventName, detail = {}) {
|
||
const componentState = ensureComponentState(component);
|
||
applyInteractionMode(component, eventName, detail, componentState);
|
||
const context = {
|
||
event: eventName, action_id: component.action_id, component_id: component.id, component_type: component.type,
|
||
item_id: detail.item_id || "", value: detail.value, state: clone(componentState), detail: clone(detail),
|
||
form_values: clone(state.formValues), data: state.data, active_tab: state.activeTab, navigation: clone(state.uiNavigationStates), timestamp: new Date().toISOString(),
|
||
};
|
||
updateInteractiveDom(component.action_id);
|
||
return context;
|
||
}
|
||
|
||
function emitInteraction(component, eventName, detail = {}) {
|
||
if (!component?.action_id) return;
|
||
const context = interactionContext(component, eventName, detail);
|
||
window.dispatchEvent(new CustomEvent("ui-builder:interaction", { detail: context }));
|
||
window.dispatchEvent(new CustomEvent(`ui-builder:${component.action_id}:${eventName}`, { detail: context }));
|
||
processTriggers(context);
|
||
return context;
|
||
}
|
||
|
||
const PROJECT_TABS_ACTION_ID = "ui_tabs";
|
||
const UI_NAVIGATION_ACTION_ID = "ui_navigation";
|
||
const TAB_TRIGGER_EVENTS = ["tab_change", "tab_enter", "tab_leave"];
|
||
|
||
function uiNavigationKey(value) {
|
||
return String(value || "ui")
|
||
.trim()
|
||
.replace(/[^a-zA-Z0-9_]+/g, "_")
|
||
.replace(/^_+|_+$/g, "") || "ui";
|
||
}
|
||
|
||
function uiNavigationItemId(actionId, scope, value) {
|
||
return `${String(actionId || "ui")}::${String(scope || "view")}::${String(value ?? "")}`;
|
||
}
|
||
|
||
function rememberUiNavigationState(componentOrActionId, scope, value, label = "", { emit = true, detail = {} } = {}) {
|
||
const actionId = typeof componentOrActionId === "string"
|
||
? String(componentOrActionId || "")
|
||
: String(componentOrActionId?.action_id || "");
|
||
if (!actionId || !scope) return null;
|
||
const actionKey = uiNavigationKey(actionId);
|
||
const scopeKey = uiNavigationKey(scope);
|
||
state.uiNavigationStates[actionKey] ||= {};
|
||
state.uiNavigationStates[actionKey][scopeKey] = value;
|
||
const itemId = uiNavigationItemId(actionId, scope, value);
|
||
if (!emit) return itemId;
|
||
const eventDetail = {
|
||
item_id: itemId,
|
||
value,
|
||
navigation_action_id: actionId,
|
||
navigation_scope: String(scope),
|
||
navigation_value: value,
|
||
navigation_label: String(label || value || ""),
|
||
...detail,
|
||
};
|
||
emitVirtualInteraction(UI_NAVIGATION_ACTION_ID, "tab_change", eventDetail);
|
||
const component = typeof componentOrActionId === "string" ? componentByActionId(actionId) : componentOrActionId;
|
||
if (component?.action_id) {
|
||
emitInteraction(component, "tab_change", {
|
||
...eventDetail,
|
||
item_id: `${String(scope)}::${String(value ?? "")}`,
|
||
});
|
||
}
|
||
return itemId;
|
||
}
|
||
|
||
function componentVisibleOnRuntimeTab(component, tabId) {
|
||
if (!component || component.hidden) return false;
|
||
const tabs = Array.isArray(component.tabs) && component.tabs.length ? component.tabs : ["*"];
|
||
return tabs.includes("*") || tabs.includes(tabId);
|
||
}
|
||
|
||
function emitVirtualInteraction(actionId, eventName, detail = {}) {
|
||
const context = {
|
||
event: eventName,
|
||
action_id: actionId,
|
||
component_id: "",
|
||
component_type: actionId === PROJECT_TABS_ACTION_ID ? "project_tabs" : "virtual",
|
||
item_id: detail.item_id || "",
|
||
value: detail.value,
|
||
state: clone(detail.state || {}),
|
||
detail: clone(detail),
|
||
form_values: clone(state.formValues),
|
||
data: state.data,
|
||
active_tab: state.activeTab,
|
||
navigation: clone(state.uiNavigationStates),
|
||
timestamp: new Date().toISOString(),
|
||
};
|
||
window.dispatchEvent(new CustomEvent("ui-builder:interaction", { detail: context }));
|
||
window.dispatchEvent(new CustomEvent(`ui-builder:${actionId}:${eventName}`, { detail: context }));
|
||
processTriggers(context);
|
||
return context;
|
||
}
|
||
|
||
function emitRuntimeTabLifecycle(previousTab, nextTab, source = "runtime") {
|
||
if (!nextTab || previousTab === nextTab) return;
|
||
const detail = {
|
||
item_id: nextTab,
|
||
value: nextTab,
|
||
previous_tab: previousTab || "",
|
||
tab_id: nextTab,
|
||
source,
|
||
state: { previous_tab: previousTab || "", active_tab: nextTab },
|
||
};
|
||
|
||
// Project-level source for the trigger editor: ui_tabs + tab_change + item_id=referees.
|
||
emitVirtualInteraction(PROJECT_TABS_ACTION_ID, "tab_change", detail);
|
||
rememberUiNavigationState(PROJECT_TABS_ACTION_ID, "project_tab", nextTab, nextTab, { emit: true, detail: { previous_tab: previousTab || "", source } });
|
||
window.dispatchEvent(new CustomEvent("ui-builder:runtime-tab-change", { detail: clone(detail) }));
|
||
|
||
// Keep ordinary tab_bar components compatible with existing tab_change triggers.
|
||
(state.config.components || []).filter((component) => component.type === "tab_bar").forEach((component) => {
|
||
ensureComponentState(component).value = nextTab;
|
||
emitInteraction(component, "tab_change", detail);
|
||
});
|
||
|
||
// Components bound to a concrete tab get explicit enter/leave events.
|
||
(state.config.components || []).forEach((component) => {
|
||
if (!component?.action_id || component.type === "tab_bar") return;
|
||
const wasVisible = previousTab ? componentVisibleOnRuntimeTab(component, previousTab) : false;
|
||
const isVisible = componentVisibleOnRuntimeTab(component, nextTab);
|
||
if (!wasVisible && isVisible) emitInteraction(component, "tab_enter", detail);
|
||
else if (wasVisible && !isVisible) emitInteraction(component, "tab_leave", { ...detail, item_id: previousTab || "", value: previousTab || "" });
|
||
});
|
||
}
|
||
|
||
function activateRuntimeTab(tabId, { source = "runtime", render = true } = {}) {
|
||
const nextTab = String(tabId || "");
|
||
if (!state.config.tabs.some((tab) => tab.id === nextTab)) return false;
|
||
const previousTab = state.activeTab;
|
||
state.activeTab = nextTab;
|
||
if (previousTab !== nextTab) {
|
||
emitRuntimeTabLifecycle(previousTab, nextTab, source);
|
||
window.UIBuilderRuntime?.patchData?.({ hockey: { ui: { active_tab: nextTab, previous_tab: previousTab || "" } } }, { render: false });
|
||
hockeyRefreshVmixMappingForTab(nextTab).catch(() => {});
|
||
}
|
||
if (render) {
|
||
renderRuntime();
|
||
renderCanvas();
|
||
renderActiveTabSelect();
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function updateInteractiveDom(actionId) {
|
||
const interactionState = state.componentStates[actionId] || {};
|
||
document.querySelectorAll(`[data-action-id="${CSS.escape(actionId)}"]`).forEach((node) => {
|
||
node.classList.toggle("interaction-active", Boolean(interactionState.active));
|
||
node.classList.toggle("interaction-pressed", Boolean(interactionState.pressed));
|
||
node.dataset.state = interactionState.active ? "true" : "false";
|
||
node.dataset.value = interactionState.value ?? "";
|
||
node.querySelectorAll("[data-item-id]").forEach((item) => {
|
||
const itemId = item.dataset.itemId;
|
||
item.classList.toggle("interaction-active", Boolean(interactionState.items?.[itemId]) || interactionState.active_item === itemId);
|
||
});
|
||
});
|
||
}
|
||
|
||
function setRuntimeState(actionId, key, value, { emit = true } = {}) {
|
||
const component = componentByActionId(actionId);
|
||
if (!component) return false;
|
||
const componentState = ensureComponentState(component);
|
||
componentState[key] = value;
|
||
componentState.updated_at = new Date().toISOString();
|
||
updateInteractiveDom(actionId);
|
||
if (emit) emitInteraction(component, "state_change", { state_key: key, value });
|
||
return true;
|
||
}
|
||
|
||
function setRuntimeValue(actionId, value, { emit = true } = {}) {
|
||
const component = componentByActionId(actionId);
|
||
if (!component) return false;
|
||
state.formValues[component.id] = value;
|
||
const componentState = ensureComponentState(component);
|
||
componentState.value = value;
|
||
updateInteractiveDom(actionId);
|
||
renderRuntime();
|
||
if (emit) emitInteraction(component, "change", { value });
|
||
return true;
|
||
}
|
||
|
||
async function processTriggers(context) {
|
||
// Automatic triggers always remain active. The per-account switch controls
|
||
// only operator toast notifications and never changes broadcast logic.
|
||
if (state.triggerDepth > 12) { console.warn("UI Builder: trigger depth limit"); return; }
|
||
const matching = state.config.triggers.filter((trigger) => trigger.enabled !== false
|
||
&& trigger.source_action_id === context.action_id
|
||
&& trigger.event === context.event
|
||
&& (!trigger.item_id || trigger.item_id === context.item_id)
|
||
&& conditionMatches(trigger.condition, context));
|
||
if (!matching.length) return;
|
||
state.triggerDepth += 1;
|
||
try {
|
||
for (const trigger of matching) await runTriggerAction(trigger, context);
|
||
} finally { state.triggerDepth -= 1; }
|
||
}
|
||
|
||
async function runTriggerAction(trigger, context) {
|
||
const action = trigger.action || {};
|
||
const target = action.target_action_id || context.action_id;
|
||
const value = parseTypedValue(templateValue(action.value, context));
|
||
try {
|
||
switch (action.type) {
|
||
case "set_state": setRuntimeState(target, action.state_key || "active", value); break;
|
||
case "toggle_state": {
|
||
const component = componentByActionId(target); const current = ensureComponentState(component || {}).hasOwnProperty(action.state_key || "active") ? ensureComponentState(component)[action.state_key || "active"] : false;
|
||
setRuntimeState(target, action.state_key || "active", !Boolean(current)); break;
|
||
}
|
||
case "set_value": setRuntimeValue(target, value); break;
|
||
case "timer_command": {
|
||
const timerValue = templateValue(action.timer_value ?? action.value ?? "", context);
|
||
if (!controlTimer(target, action.timer_command || "toggle", timerValue)) throw new Error(`Таймер «${target}» не найден`);
|
||
break;
|
||
}
|
||
case "run_sequence": {
|
||
if (!action.sequence_id) throw new Error("Сценарий не выбран");
|
||
const ok = await runShortcutSequence(action.sequence_id, { source: "trigger", trigger_id: trigger.id, event: context });
|
||
if (!ok) throw new Error("Сценарий не выполнен");
|
||
break;
|
||
}
|
||
case "show_component": state.runtimeVisibility[target] = true; renderRuntime(); break;
|
||
case "hide_component": state.runtimeVisibility[target] = false; renderRuntime(); break;
|
||
case "toggle_component": state.runtimeVisibility[target] = state.runtimeVisibility[target] === false; renderRuntime(); break;
|
||
case "set_tab": activateRuntimeTab(action.tab_id || state.config.tabs[0]?.id || "main", { source: "trigger" }); break;
|
||
case "refresh_data": await loadData(true); break;
|
||
case "open_url": if (action.url) window.open(templateValue(action.url, context), "_blank", "noopener"); break;
|
||
case "dispatch_event": window.dispatchEvent(new CustomEvent(action.event_name || "ui-builder:custom", { detail: { trigger: clone(trigger), event: context } })); break;
|
||
case "call_function": {
|
||
const handler = actionRegistry.handlers[action.function_name] || actionRegistry[action.function_name];
|
||
if (typeof handler !== "function") throw new Error(`Функция «${action.function_name}» не зарегистрирована`);
|
||
await handler({ event: context, trigger: clone(trigger), config: state.config, data: state.data, formValues: clone(state.formValues), runtime: window.UIBuilderRuntime });
|
||
break;
|
||
}
|
||
case "http_request": {
|
||
const url = templateValue(action.url, context);
|
||
const method = String(action.method || "POST").toUpperCase();
|
||
const bodyText = templateValue(action.body, context);
|
||
const options = { method, headers: {}, cache: "no-store" };
|
||
if (!/^(GET|HEAD)$/.test(method) && bodyText) {
|
||
options.headers["Content-Type"] = "application/json";
|
||
options.body = bodyText;
|
||
}
|
||
const response = await fetch(url, options);
|
||
if (!response.ok) {
|
||
let detail = `HTTP ${response.status}`;
|
||
try {
|
||
const payload = await response.json();
|
||
detail = typeof payload.detail === "string"
|
||
? payload.detail
|
||
: payload.detail?.message || JSON.stringify(payload.detail || payload);
|
||
} catch (_) {
|
||
try { detail = (await response.text()) || detail; } catch (_) {}
|
||
}
|
||
throw new Error(detail);
|
||
}
|
||
toast(templateValue(action.message || "HTTP-запрос выполнен", context));
|
||
break;
|
||
}
|
||
default: toast(templateValue(action.message || "Триггер выполнен", context));
|
||
}
|
||
} catch (error) {
|
||
console.error("UI Builder trigger error", trigger, error);
|
||
toast(`Триггер «${trigger.name}»: ${error.message}`, true);
|
||
}
|
||
}
|
||
|
||
function isEditingTarget(target) {
|
||
return Boolean(target?.closest?.('input, textarea, select, [contenteditable="true"]'));
|
||
}
|
||
|
||
function shortcutAllowed(component, shortcut, event) {
|
||
if (!shortcut.enabled || !shortcut.combo) return false;
|
||
const inRuntime = boot.mode === "runtime" || state.preview;
|
||
if (shortcut.scope !== "all" && !inRuntime) return false;
|
||
if (isEditingTarget(event.target) && !shortcut.allow_in_inputs) return false;
|
||
if (!shortcut.global) {
|
||
if (component.hidden || state.runtimeVisibility[component.action_id] === false) return false;
|
||
if (!isEffectivelyOnActiveTab(component)) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function runShortcutBinding(component, shortcut, keyboardEvent) {
|
||
const eventName = shortcut.event || "click";
|
||
const shortcutSuccessToast = () => toast(`Шорткат выполнен: ${component.title || component.action_id || shortcut.combo}`);
|
||
const parsedValue = parseTypedValue(shortcut.value);
|
||
const detail = {
|
||
item_id: shortcut.item_id || "",
|
||
value: shortcut.value === "" ? undefined : parsedValue,
|
||
shortcut: true,
|
||
shortcut_combo: shortcut.combo,
|
||
keyboard: {
|
||
key: keyboardEvent.key,
|
||
code: keyboardEvent.code,
|
||
ctrl: keyboardEvent.ctrlKey,
|
||
alt: keyboardEvent.altKey,
|
||
shift: keyboardEvent.shiftKey,
|
||
meta: keyboardEvent.metaKey,
|
||
},
|
||
};
|
||
|
||
if (eventName.startsWith("timer_")) {
|
||
const command = eventName.replace(/^timer_/, "");
|
||
controlTimer(component.action_id, command, shortcut.value);
|
||
shortcutSuccessToast();
|
||
return;
|
||
}
|
||
|
||
if (eventName === "tab_change") {
|
||
const tabId = shortcut.item_id || String(shortcut.value || "");
|
||
if (state.config.tabs.some((tab) => tab.id === tabId)) {
|
||
activateRuntimeTab(tabId, { source: "shortcut" });
|
||
if (component.type !== "tab_bar") emitInteraction(component, "tab_change", { ...detail, item_id: tabId, value: tabId });
|
||
} else emitInteraction(component, "tab_change", detail);
|
||
shortcutSuccessToast();
|
||
return;
|
||
}
|
||
|
||
if (eventName === "page_change") {
|
||
const page = shortcut.value === "" ? shortcut.item_id : parsedValue;
|
||
if (page !== "" && page !== undefined) {
|
||
state.formValues[component.id] = page;
|
||
ensureComponentState(component).value = page;
|
||
}
|
||
emitInteraction(component, "page_change", { ...detail, item_id: shortcut.item_id || String(page ?? ""), value: page });
|
||
renderRuntime();
|
||
shortcutSuccessToast();
|
||
return;
|
||
}
|
||
|
||
if (eventName === "change" && shortcut.value !== "") {
|
||
state.formValues[component.id] = parsedValue;
|
||
ensureComponentState(component).value = parsedValue;
|
||
emitInteraction(component, "change", detail);
|
||
renderRuntime();
|
||
shortcutSuccessToast();
|
||
return;
|
||
}
|
||
|
||
emitInteraction(component, eventName, detail);
|
||
|
||
if (eventName === "click") {
|
||
if (component.type === "button") executeAction(component.props || {}, component);
|
||
else if (component.type === "link" && component.props?.url) window.open(component.props.url, "_blank", "noopener");
|
||
else if (component.type === "modal") {
|
||
emitInteraction(component, "open", { ...detail, value: true });
|
||
showModal(component.props.modalTitle, `<p>${escapeHtml(component.props.modalBody || "")}</p>`);
|
||
}
|
||
} else if (eventName === "open") {
|
||
if (component.type === "modal") showModal(component.props.modalTitle, `<p>${escapeHtml(component.props.modalBody || "")}</p>`);
|
||
if (component.type === "link" && component.props?.url) window.open(component.props.url, "_blank", "noopener");
|
||
}
|
||
shortcutSuccessToast();
|
||
}
|
||
|
||
function handleShortcutCapture(event) {
|
||
if (!state.shortcutCapture) return false;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
syncShortcutModifiersFromEvent(event, true);
|
||
const modifier = shortcutModifierFromEvent(event);
|
||
|
||
if (event.key === "Escape") {
|
||
finishShortcutCapture(null);
|
||
state.pressedShortcutModifiers.clear();
|
||
toast("Запись горячей клавиши отменена");
|
||
return true;
|
||
}
|
||
|
||
if (modifier) {
|
||
const held = shortcutModifierOrder.filter((name) => state.pressedShortcutModifiers.has(name));
|
||
state.shortcutCapture.input.value = held.length >= 2 ? held.join("+") : (held.length ? `${held.join("+")}+…` : "");
|
||
return true;
|
||
}
|
||
|
||
const combo = shortcutFromKeyboardEvent(event);
|
||
if (!combo) return true;
|
||
finishShortcutCapture(combo);
|
||
return true;
|
||
}
|
||
|
||
function shortcutSequenceAllowed(sequence, event) {
|
||
if (!sequence?.enabled || !sequence.combo) return false;
|
||
const inRuntime = boot.mode === "runtime" || state.preview;
|
||
if (sequence.scope !== "all" && !inRuntime) return false;
|
||
if (isEditingTarget(event.target) && !sequence.allow_in_inputs) return false;
|
||
return true;
|
||
}
|
||
|
||
function currentHockeyPenaltyEntries() {
|
||
const items = [];
|
||
state.config.components
|
||
.filter((component) => component.type === "hockey_penalty_dashboard" && !component.hidden)
|
||
.forEach((component) => {
|
||
const board = ensureHockeyBoardState(component);
|
||
(board?.penalties || []).forEach((event, index) => {
|
||
if (event.finished || !hockeyEventReady(event)) return;
|
||
const side = String(event.player?.side || event.side || "").toLowerCase();
|
||
items.push({ component, board, event, side, index });
|
||
});
|
||
});
|
||
return items;
|
||
}
|
||
|
||
function vmixCountdownValue(milliseconds) {
|
||
const totalSeconds = Math.max(0, Math.ceil(Number(milliseconds || 0) / 1000));
|
||
const hours = Math.floor(totalSeconds / 3600);
|
||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||
const seconds = totalSeconds % 60;
|
||
return `${padTimer(hours)}:${padTimer(minutes)}:${padTimer(seconds)}`;
|
||
}
|
||
|
||
function buildShortcutRuntimeContext(sequence = null) {
|
||
const timers = {};
|
||
state.config.components.filter((component) => isTimerComponent(component)).forEach((component) => {
|
||
const timerState = ensureTimerState(component);
|
||
timers[component.action_id] = {
|
||
current_ms: Math.max(0, Number(timerState.currentMs || 0)),
|
||
formatted: formatTimerValue(component, timerState),
|
||
vmix_time: vmixCountdownValue(timerState.currentMs),
|
||
running: Boolean(timerState.running),
|
||
paused: Boolean(timerState.paused),
|
||
finished: Boolean(timerState.finished),
|
||
};
|
||
});
|
||
const penalties = currentHockeyPenaltyEntries();
|
||
const penaltyRuntimeItem = (item) => ({
|
||
id: item.event.id,
|
||
remaining_ms: Math.max(0, Number(item.event.remainingMs || 0)),
|
||
vmix_time: vmixCountdownValue(item.event.remainingMs),
|
||
player: item.event.player?.name || "",
|
||
number: item.event.player?.number || "",
|
||
preset: item.event.preset || "",
|
||
running: Boolean(item.event.running),
|
||
created_at: Number(item.event.createdAt || 0),
|
||
});
|
||
const bySoonest = (a, b) => Number(a.remaining_ms) - Number(b.remaining_ms) || Number(a.created_at) - Number(b.created_at);
|
||
const home = penalties.filter((item) => item.side === "home").map(penaltyRuntimeItem).sort(bySoonest);
|
||
const away = penalties.filter((item) => item.side === "away").map(penaltyRuntimeItem).sort(bySoonest);
|
||
const flags = getByPath(state.data, "hockey.game_control.flags") || {};
|
||
const strength = getByPath(state.data, "hockey.game_control.strength") || {};
|
||
return {
|
||
sequence: sequence ? clone(sequence) : null,
|
||
timers,
|
||
hockey: {
|
||
penalties: { home, away, all: [...home, ...away] },
|
||
has_penalties: penalties.length > 0,
|
||
has_home_penalties: home.length > 0,
|
||
has_away_penalties: away.length > 0,
|
||
has_both_penalties: home.length > 0 && away.length > 0,
|
||
flags,
|
||
strength,
|
||
home_delayed_penalty: Boolean(flags.home_delayed_penalty),
|
||
away_delayed_penalty: Boolean(flags.away_delayed_penalty),
|
||
home_empty_net: Boolean(flags.home_empty_net),
|
||
away_empty_net: Boolean(flags.away_empty_net),
|
||
},
|
||
data: state.data,
|
||
form_values: clone(state.formValues),
|
||
active_tab: state.activeTab,
|
||
timestamp: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
function sequenceConditionMatches(condition, context, conditionValue = "") {
|
||
const flags = context.hockey?.flags || {};
|
||
switch (condition || "always") {
|
||
case "has_penalties": return Boolean(context.hockey?.has_penalties);
|
||
case "no_penalties": return !context.hockey?.has_penalties;
|
||
case "has_home_penalties": return Boolean(context.hockey?.has_home_penalties);
|
||
case "has_away_penalties": return Boolean(context.hockey?.has_away_penalties);
|
||
case "has_both_penalties": return Boolean(context.hockey?.has_both_penalties);
|
||
case "no_home_penalties": return !context.hockey?.has_home_penalties;
|
||
case "no_away_penalties": return !context.hockey?.has_away_penalties;
|
||
case "home_delayed_penalty": return Boolean(flags.home_delayed_penalty);
|
||
case "away_delayed_penalty": return Boolean(flags.away_delayed_penalty);
|
||
case "any_delayed_penalty": return Boolean(flags.home_delayed_penalty || flags.away_delayed_penalty);
|
||
case "home_empty_net": return Boolean(flags.home_empty_net);
|
||
case "away_empty_net": return Boolean(flags.away_empty_net);
|
||
case "any_empty_net": return Boolean(flags.home_empty_net || flags.away_empty_net);
|
||
case "prematch_button_active": return Boolean(flags[`prematch.${String(conditionValue || "")}`]);
|
||
case "prematch_button_inactive": return !flags[`prematch.${String(conditionValue || "")}`];
|
||
case "active_tab": return String(context.active_tab || "") === String(conditionValue || "");
|
||
case "inactive_tab": return String(context.active_tab || "") !== String(conditionValue || "");
|
||
default: return true;
|
||
}
|
||
}
|
||
|
||
function compactVmixCommand(command) {
|
||
const result = {};
|
||
Object.entries(command || {}).forEach(([key, value]) => {
|
||
if (value === null || value === undefined) return;
|
||
if (key !== "Value" && String(value) === "") return;
|
||
result[key] = value;
|
||
});
|
||
return result;
|
||
}
|
||
|
||
function currentRuntimeVmixDeviceId() {
|
||
return String(window.HockeyAgentRuntime?.currentDeviceId?.() || localStorage.getItem("hockey.vmix.selected_device") || "").trim();
|
||
}
|
||
|
||
function currentRuntimeHockeySessionToken() {
|
||
return String(state.data?.hockey?.operator_session?.token || localStorage.getItem("hockey.operatorSessionToken") || "").trim();
|
||
}
|
||
|
||
function vmixOverlayLayer(functionName) {
|
||
// BUILD62: accept the canonical vMix names and tolerate legacy/saved strings
|
||
// that contain harmless whitespace or a query-string suffix. A few old
|
||
// configs stored values such as "OverlayInput2In&Input=..."; vMix still
|
||
// executes the command, but the strict BUILD58 parser did not recognise it,
|
||
// so the quick-panel button never received the on-air state.
|
||
const normalized = String(functionName || "").trim().replace(/\s+/g, "");
|
||
const match = normalized.match(/(?:^|[^a-z0-9])OverlayInput([1-4])(In|Out|Off)?(?=$|[^a-z])/i)
|
||
|| normalized.match(/^OverlayInput([1-4])(In|Out|Off)?/i);
|
||
if (!match) return null;
|
||
const raw = String(match[2] || "toggle").toLowerCase();
|
||
const action = raw === "in" ? "In" : raw === "out" ? "Out" : raw === "off" ? "Off" : "toggle";
|
||
return { layer: match[1], action };
|
||
}
|
||
|
||
function sequenceStillOwnsRuntimeOverlay(sequenceId) {
|
||
const id = String(sequenceId || "");
|
||
if (!id) return false;
|
||
return [...state.vmixOverlayRuntime.values()].some((entry) => String(entry?.sequence_id || "") === id);
|
||
}
|
||
|
||
function shortcutSequenceOverlayIntent(sequenceOrId) {
|
||
const sequence = typeof sequenceOrId === "string" ? shortcutSequenceById(sequenceOrId) : sequenceOrId;
|
||
if (!sequence) return [];
|
||
const context = buildShortcutRuntimeContext(sequence);
|
||
const result = [];
|
||
for (const step of sequence.steps || []) {
|
||
if (!step || step.enabled === false || step.type !== "vmix_command") continue;
|
||
if (!sequenceConditionMatches(step.condition, context, step.condition_value)) continue;
|
||
const functionValue = templateSequenceValue(step.function, context);
|
||
const parsed = vmixOverlayLayer(functionValue);
|
||
if (!parsed) continue;
|
||
const useAlternate = Boolean(step.use_scoreboard_alternate && hockeyScoreboardIsLive() && step.scoreboard_alternate_input);
|
||
const inputTemplate = useAlternate ? step.scoreboard_alternate_input : step.input;
|
||
result.push({
|
||
layer: String(parsed.layer),
|
||
action: parsed.action,
|
||
input: String(templateSequenceValue(inputTemplate, context) || ""),
|
||
});
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function reconcileShortcutSequenceOnAir(sequenceOrId, { wasOnAir = false } = {}) {
|
||
const sequence = typeof sequenceOrId === "string" ? shortcutSequenceById(sequenceOrId) : sequenceOrId;
|
||
const id = String(sequence?.id || "").trim();
|
||
if (!id) return false;
|
||
|
||
// If an ACK-confirmed command already assigned a live Overlay to this
|
||
// sequence, that is the strongest signal and needs no fallback.
|
||
if (sequenceStillOwnsRuntimeOverlay(id)) {
|
||
state.quickPanelOnAirSequences.add(id);
|
||
refreshQuickPanelOnAirClasses();
|
||
return true;
|
||
}
|
||
|
||
const intents = shortcutSequenceOverlayIntent(sequence);
|
||
if (!intents.length) {
|
||
refreshQuickPanelOnAirClasses();
|
||
return state.quickPanelOnAirSequences.has(id);
|
||
}
|
||
|
||
// BUILD62 fallback: older configs can successfully execute an Overlay
|
||
// command while not producing an owner entry (for example legacy Function
|
||
// strings). Latch the sequence according to its actual intended Overlay
|
||
// action, so a normal title button behaves like the scoreboard button.
|
||
let active = state.quickPanelOnAirSequences.has(id) || Boolean(wasOnAir);
|
||
for (const intent of intents) {
|
||
if (intent.action === "In") active = true;
|
||
else if (intent.action === "Out" || intent.action === "Off") active = false;
|
||
else if (intent.action === "toggle") active = !active;
|
||
}
|
||
if (active) state.quickPanelOnAirSequences.add(id);
|
||
else state.quickPanelOnAirSequences.delete(id);
|
||
refreshQuickPanelOnAirClasses();
|
||
return active;
|
||
}
|
||
|
||
function normalizeRuntimeVmixInputRef(value) {
|
||
return String(value || "").trim().toLowerCase();
|
||
}
|
||
|
||
function shortcutSequenceOverlayTargets(sequenceOrId) {
|
||
const sequence = typeof sequenceOrId === "string" ? shortcutSequenceById(sequenceOrId) : sequenceOrId;
|
||
if (!sequence) return [];
|
||
const targets = [];
|
||
const context = buildShortcutRuntimeContext(sequence);
|
||
const addTarget = (layer, input) => {
|
||
const normalizedLayer = String(layer || "").trim();
|
||
const normalizedInput = String(input || "").trim();
|
||
if (!/[1-4]/.test(normalizedLayer) || !normalizedInput) return;
|
||
if (targets.some((item) => item.layer === normalizedLayer && normalizeRuntimeVmixInputRef(item.input) === normalizeRuntimeVmixInputRef(normalizedInput))) return;
|
||
targets.push({ layer: normalizedLayer, input: normalizedInput });
|
||
};
|
||
(sequence.steps || []).forEach((step) => {
|
||
if (!step || step.enabled === false || step.type !== "vmix_command") return;
|
||
if (!sequenceConditionMatches(step.condition, context, step.condition_value)) return;
|
||
const parsed = vmixOverlayLayer(templateSequenceValue(step.function, context));
|
||
if (!parsed || (parsed.action !== "In" && parsed.action !== "toggle")) return;
|
||
addTarget(parsed.layer, templateSequenceValue(step.input, context));
|
||
if (step.use_scoreboard_alternate) addTarget(parsed.layer, templateSequenceValue(step.scoreboard_alternate_input, context));
|
||
});
|
||
return targets;
|
||
}
|
||
|
||
function sequenceMatchesRuntimeOverlay(sequenceOrId) {
|
||
const targets = shortcutSequenceOverlayTargets(sequenceOrId);
|
||
if (!targets.length) return false;
|
||
return targets.some((target) => {
|
||
const current = state.vmixOverlayRuntime.get(String(target.layer));
|
||
if (!current) return false;
|
||
return normalizeRuntimeVmixInputRef(current.input) === normalizeRuntimeVmixInputRef(target.input);
|
||
});
|
||
}
|
||
|
||
function setQuickPanelSequenceOnAir(sequenceId, active) {
|
||
const id = String(sequenceId || "").trim();
|
||
if (!id) return;
|
||
if (active) state.quickPanelOnAirSequences.add(id);
|
||
else if (!sequenceStillOwnsRuntimeOverlay(id)) state.quickPanelOnAirSequences.delete(id);
|
||
}
|
||
|
||
function shortcutSequenceIsOnAir(sequenceId) {
|
||
const id = String(sequenceId || "");
|
||
if (!id) return false;
|
||
const sequence = shortcutSequenceById(id);
|
||
const targets = shortcutSequenceOverlayTargets(sequence);
|
||
// BUILD58: first compare the actual Input currently tracked on the Overlay layer
|
||
// with every Input that this sequence can put on air. This keeps the button lit
|
||
// even when an auxiliary command/trigger sent the Overlay without sequence owner
|
||
// metadata, and turns it off as soon as another Input replaces that layer.
|
||
// BUILD60: if this exact Shortcut Sequence owns any currently tracked Overlay,
|
||
// trust the ACK-confirmed ownership first. This is more robust than comparing
|
||
// Input aliases (GUID/title/number), which can differ between saved config and
|
||
// the concrete command sent to vMix. Input matching remains as the ownerless
|
||
// fallback for commands emitted by auxiliary logic/triggers.
|
||
if (sequenceStillOwnsRuntimeOverlay(id)) return true;
|
||
if (targets.length && sequenceMatchesRuntimeOverlay(sequence)) return true;
|
||
// Fallback for scoreboard/group sequences and legacy toggle state.
|
||
return state.quickPanelOnAirSequences.has(id)
|
||
|| Boolean(state.shortcutSequenceOverlayState.get(id));
|
||
}
|
||
|
||
function refreshQuickPanelOnAirClasses() {
|
||
document.querySelectorAll("[data-quick-command-button]").forEach((node) => {
|
||
const sequenceId = String(node.dataset.sequenceId || "");
|
||
const onAir = shortcutSequenceIsOnAir(sequenceId);
|
||
node.classList.toggle("is-on-air", onAir);
|
||
node.setAttribute("aria-pressed", onAir ? "true" : "false");
|
||
});
|
||
}
|
||
|
||
function trackRuntimeOverlayCommands(commands, execution = null) {
|
||
const owner = execution && typeof execution === "object" ? execution : {};
|
||
const ownerSequenceId = String(owner.sequence_id || "").trim();
|
||
const clearLayer = (layer) => {
|
||
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);
|
||
return previous;
|
||
};
|
||
const setLayer = (layer, command) => {
|
||
const key = String(layer);
|
||
const previous = state.vmixOverlayRuntime.get(key) || null;
|
||
state.vmixOverlayRuntime.set(key, {
|
||
input: String(command?.Input || ""),
|
||
sequence_id: ownerSequenceId,
|
||
sequence_name: String(owner.sequence_name || ""),
|
||
button_id: String(owner.button_id || ""),
|
||
});
|
||
if (previous?.sequence_id && String(previous.sequence_id) !== ownerSequenceId) {
|
||
setQuickPanelSequenceOnAir(previous.sequence_id, false);
|
||
}
|
||
if (ownerSequenceId) setQuickPanelSequenceOnAir(ownerSequenceId, true);
|
||
};
|
||
(commands || []).forEach((command) => {
|
||
const fn = String(command?.Function || "").trim();
|
||
if (fn.toLowerCase() === "overlayinputalloff") {
|
||
const owners = new Set([...state.vmixOverlayRuntime.values()].map((entry) => String(entry?.sequence_id || "")).filter(Boolean));
|
||
state.vmixOverlayRuntime.clear();
|
||
owners.forEach((sequenceId) => state.quickPanelOnAirSequences.delete(sequenceId));
|
||
state.shortcutSequenceOverlayState.forEach((_active, sequenceId) => state.shortcutSequenceOverlayState.set(sequenceId, false));
|
||
return;
|
||
}
|
||
const parsed = vmixOverlayLayer(fn);
|
||
if (!parsed) return;
|
||
if (parsed.action === "In") {
|
||
setLayer(parsed.layer, command);
|
||
} else if (parsed.action === "Out" || parsed.action === "Off") {
|
||
clearLayer(parsed.layer);
|
||
if (ownerSequenceId) setQuickPanelSequenceOnAir(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);
|
||
} else {
|
||
setLayer(parsed.layer, command);
|
||
}
|
||
}
|
||
});
|
||
refreshQuickPanelOnAirClasses();
|
||
}
|
||
|
||
async function sendRuntimeVmixSequence(commands, execution = null) {
|
||
const clean = (commands || []).map(compactVmixCommand).filter((command) => command.Function);
|
||
if (!clean.length) return { ok: true, applied: 0, results: [] };
|
||
const response = await fetch("/api/hockey/vmix/sequence", {
|
||
method: "POST",
|
||
cache: "no-store",
|
||
credentials: "same-origin",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
commands: clean,
|
||
device_id: currentRuntimeVmixDeviceId(),
|
||
session_token: currentRuntimeHockeySessionToken(),
|
||
}),
|
||
});
|
||
let payload = {};
|
||
try { payload = await response.json(); } catch (_) {}
|
||
if (!response.ok) {
|
||
const detail = payload?.detail?.message || payload?.detail || `HTTP ${response.status}`;
|
||
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
||
}
|
||
trackRuntimeOverlayCommands(clean, execution);
|
||
return payload;
|
||
}
|
||
|
||
function splitVmixInputs(value) {
|
||
return String(value || "").split(/[;,\n]+/).map((item) => item.trim()).filter(Boolean);
|
||
}
|
||
|
||
function templateSequenceValue(value, context) {
|
||
return templateValue(value ?? "", context);
|
||
}
|
||
|
||
function setVmixTimerMirror(actionId, input, selectedName) {
|
||
const key = String(actionId || "").trim();
|
||
const targetInput = String(input || "").trim();
|
||
const targetName = String(selectedName || "").trim();
|
||
if (!key || !targetInput || !targetName) return false;
|
||
const previous = state.vmixTimerMirrors.get(key) || {};
|
||
state.vmixTimerMirrors.set(key, { ...previous, input: targetInput, selectedName: targetName, lastValue: "", pending: false, lastError: "" });
|
||
return true;
|
||
}
|
||
|
||
async function pushVmixTimerMirror(component, timerState, { force = false } = {}) {
|
||
const mirror = state.vmixTimerMirrors.get(component?.action_id);
|
||
if (!mirror || mirror.pending) return false;
|
||
const value = formatTimerValue(component, timerState);
|
||
if (!force && mirror.lastValue === value) return true;
|
||
mirror.pending = true;
|
||
try {
|
||
await sendRuntimeVmixSequence([{ Function: "SetText", Input: mirror.input, SelectedName: mirror.selectedName, Value: value }]);
|
||
mirror.lastValue = value;
|
||
mirror.lastError = "";
|
||
return true;
|
||
} catch (error) {
|
||
mirror.lastError = String(error?.message || error || "Ошибка vMix");
|
||
console.error("vMix timer mirror error", error);
|
||
return false;
|
||
} finally {
|
||
mirror.pending = false;
|
||
}
|
||
}
|
||
|
||
|
||
function penaltyMirrorKey(component, event) {
|
||
return `${String(component?.action_id || "hockey_penalty_dashboard")}:${String(event?.id || "")}`;
|
||
}
|
||
|
||
function setVmixPenaltyMirror(component, event, input, selectedName, metadata = {}) {
|
||
const key = penaltyMirrorKey(component, event);
|
||
const targetInput = String(input || "").trim();
|
||
const targetName = String(selectedName || "").trim();
|
||
if (!event?.id || !targetInput || !targetName) return false;
|
||
const previous = state.vmixPenaltyMirrors.get(key) || {};
|
||
state.vmixPenaltyMirrors.set(key, {
|
||
...previous,
|
||
input: targetInput,
|
||
selectedName: targetName,
|
||
stepId: String(metadata.stepId || previous.stepId || ""),
|
||
targetId: String(metadata.targetId || previous.targetId || ""),
|
||
side: String(metadata.side || previous.side || ""),
|
||
overlay: String(metadata.overlay || previous.overlay || "2"),
|
||
lastValue: "",
|
||
pending: false,
|
||
lastError: "",
|
||
});
|
||
return true;
|
||
}
|
||
|
||
async function pushVmixPenaltyMirror(component, event, { force = false } = {}) {
|
||
const key = penaltyMirrorKey(component, event);
|
||
const mirror = state.vmixPenaltyMirrors.get(key);
|
||
if (!mirror || mirror.pending || !event) return false;
|
||
const value = formatHockeyPenaltyTime(Math.max(0, Number(event.remainingMs || 0)));
|
||
if (!force && mirror.lastValue === value) return true;
|
||
mirror.pending = true;
|
||
try {
|
||
await sendRuntimeVmixSequence([{ Function: "SetText", Input: mirror.input, SelectedName: mirror.selectedName, Value: value }]);
|
||
mirror.lastValue = value;
|
||
mirror.lastError = "";
|
||
return true;
|
||
} catch (error) {
|
||
mirror.lastError = String(error?.message || error || "Ошибка vMix");
|
||
console.error("vMix penalty mirror error", error);
|
||
return false;
|
||
} finally {
|
||
mirror.pending = false;
|
||
}
|
||
}
|
||
|
||
function sequencePenaltyTargets(step, side) {
|
||
const raw = side === "home" ? step.home_penalty_targets : step.away_penalty_targets;
|
||
const legacyInputs = side === "home" ? step.home_penalty_inputs : step.away_penalty_inputs;
|
||
const legacyNames = side === "home" ? step.home_penalty_selected_names : step.away_penalty_selected_names;
|
||
return normalizePenaltyVmixTargets(raw, legacyInputs, legacyNames);
|
||
}
|
||
|
||
function hockeyTimerSyncStepById(stepId) {
|
||
for (const sequence of state.config.shortcut_sequences || []) {
|
||
const step = (sequence.steps || []).find((item) => item.id === stepId && item.type === "hockey_vmix_timers_start");
|
||
if (step) return step;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function sortedPenaltyEntries(side = "") {
|
||
return currentHockeyPenaltyEntries()
|
||
.filter((item) => !side || item.side === side)
|
||
.sort((a, b) => Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0)
|
||
|| Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0));
|
||
}
|
||
|
||
function penaltyTargetAssignmentKey(step, side, target) {
|
||
return `${String(step?.id || "")}:${side}:${String(target?.id || "")}`;
|
||
}
|
||
|
||
async function rebalanceVmixPenaltyTargets({ force = false, hideUnused = true } = {}) {
|
||
const commands = [];
|
||
const assignedMirrorKeys = new Set();
|
||
for (const stepId of Array.from(state.activeHockeyVmixTimerSteps)) {
|
||
const step = hockeyTimerSyncStepById(stepId);
|
||
if (!step || step.enabled === false || !step.sync_vmix_penalties) {
|
||
state.activeHockeyVmixTimerSteps.delete(stepId);
|
||
continue;
|
||
}
|
||
if (step.penalty_vmix_mode !== "text") continue;
|
||
for (const side of ["home", "away"]) {
|
||
const allEntries = sortedPenaltyEntries(side);
|
||
const allTargets = sequencePenaltyTargets(step, side);
|
||
const soonestOnly = String(step.penalty_display_mode || "soonest") !== "all";
|
||
const entries = soonestOnly ? allEntries.slice(0, 1) : allEntries;
|
||
const targets = soonestOnly ? allTargets.slice(0, 1) : allTargets;
|
||
const inactiveTargets = soonestOnly ? allTargets.slice(1) : [];
|
||
targets.forEach((target, index) => {
|
||
const assignmentKey = penaltyTargetAssignmentKey(step, side, target);
|
||
const previous = state.vmixPenaltyTargetAssignments.get(assignmentKey);
|
||
const entry = entries[index] || null;
|
||
if (entry && target.input && target.selected_name) {
|
||
const eventKey = penaltyMirrorKey(entry.component, entry.event);
|
||
assignedMirrorKeys.add(eventKey);
|
||
setVmixPenaltyMirror(entry.component, entry.event, target.input, target.selected_name, {
|
||
stepId: step.id,
|
||
targetId: target.id,
|
||
side,
|
||
overlay: target.overlay,
|
||
});
|
||
state.vmixPenaltyTargetAssignments.set(assignmentKey, {
|
||
eventKey,
|
||
input: target.input,
|
||
selectedName: target.selected_name,
|
||
overlay: target.overlay,
|
||
});
|
||
const value = formatHockeyPenaltyTime(entry.event.remainingMs);
|
||
const mirror = state.vmixPenaltyMirrors.get(eventKey);
|
||
if (force || !mirror || mirror.lastValue !== value || previous?.eventKey !== eventKey) {
|
||
commands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: value });
|
||
if (mirror) mirror.lastValue = value;
|
||
}
|
||
} else {
|
||
if (previous && hideUnused && target.auto_hide_on_finish !== false && target.input) {
|
||
const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2";
|
||
commands.push({ Function: `OverlayInput${overlay}Out`, Input: target.input });
|
||
}
|
||
state.vmixPenaltyTargetAssignments.delete(assignmentKey);
|
||
}
|
||
});
|
||
inactiveTargets.forEach((target) => {
|
||
const assignmentKey = penaltyTargetAssignmentKey(step, side, target);
|
||
const previous = state.vmixPenaltyTargetAssignments.get(assignmentKey);
|
||
if (previous && hideUnused && target.auto_hide_on_finish !== false && target.input) {
|
||
const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2";
|
||
commands.push({ Function: `OverlayInput${overlay}Out`, Input: target.input });
|
||
}
|
||
state.vmixPenaltyTargetAssignments.delete(assignmentKey);
|
||
});
|
||
}
|
||
}
|
||
for (const [mirrorKey, mirror] of Array.from(state.vmixPenaltyMirrors.entries())) {
|
||
if (mirror?.stepId && state.activeHockeyVmixTimerSteps.has(mirror.stepId) && !assignedMirrorKeys.has(mirrorKey)) {
|
||
state.vmixPenaltyMirrors.delete(mirrorKey);
|
||
}
|
||
}
|
||
if (commands.length) await sendRuntimeVmixSequence(commands);
|
||
return commands.length;
|
||
}
|
||
|
||
function finishActionMatchesSource(action, source, side = "") {
|
||
if (action.source === "game") return source === "game";
|
||
if (action.source === "any_penalty") return source === "penalty";
|
||
if (action.source === "home_penalty") return source === "penalty" && side === "home";
|
||
if (action.source === "away_penalty") return source === "penalty" && side === "away";
|
||
return false;
|
||
}
|
||
|
||
async function runVmixTimedFinishAction(action, meta = {}) {
|
||
const input = String(action?.input || "").trim();
|
||
if (!input || action?.enabled === false) return false;
|
||
const overlay = ["1", "2", "3", "4"].includes(String(action.overlay || "")) ? String(action.overlay) : "1";
|
||
const durationMs = clamp(Number(action.duration_ms) || 3000, 100, 120000);
|
||
const timerKey = `${action.id || input}:${overlay}:${input}`;
|
||
const previousTimer = state.vmixFinishOverlayTimers.get(timerKey);
|
||
if (previousTimer) clearTimeout(previousTimer);
|
||
await sendRuntimeVmixSequence([{ Function: `OverlayInput${overlay}In`, Input: input }]);
|
||
const timeoutId = window.setTimeout(() => {
|
||
state.vmixFinishOverlayTimers.delete(timerKey);
|
||
sendRuntimeVmixSequence([{ Function: `OverlayInput${overlay}Out`, Input: input }]).catch((error) => {
|
||
console.error("vMix finish overlay out error", meta, error);
|
||
});
|
||
}, durationMs);
|
||
state.vmixFinishOverlayTimers.set(timerKey, timeoutId);
|
||
return true;
|
||
}
|
||
|
||
function fireConfiguredTimerFinishActions(source, meta = {}) {
|
||
const side = String(meta.side || "");
|
||
const gameActionId = String(meta.gameActionId || "");
|
||
const actions = [];
|
||
(state.config.shortcut_sequences || []).forEach((sequence) => {
|
||
if (!sequence || sequence.enabled === false) return;
|
||
(sequence.steps || []).forEach((step) => {
|
||
if (!step || step.enabled === false || step.type !== "hockey_vmix_timers_start") return;
|
||
if (source === "game" && String(step.game_timer_action_id || "hockey_game_timer") !== gameActionId) return;
|
||
normalizeTimerFinishActions(step.timer_finish_actions).forEach((action) => {
|
||
if (!finishActionMatchesSource(action, source, side)) return;
|
||
if (source === "penalty" && action.only_when_side_clear !== false && Number(meta.remaining_on_side || 0) > 0) return;
|
||
actions.push(action);
|
||
});
|
||
});
|
||
});
|
||
actions.forEach((action) => {
|
||
runVmixTimedFinishAction(action, meta).catch((error) => console.error("vMix timer finish action error", action, error));
|
||
});
|
||
return actions.length;
|
||
}
|
||
|
||
async function runShortcutSequenceStep(sequence, step, execution = null) {
|
||
const context = buildShortcutRuntimeContext(sequence);
|
||
if (!step.enabled || !sequenceConditionMatches(step.condition, context, step.condition_value)) return { skipped: true };
|
||
switch (step.type) {
|
||
case "timer_command": {
|
||
const value = templateSequenceValue(step.timer_value, context);
|
||
if (!controlTimer(step.target_action_id, step.timer_command || "start", value)) {
|
||
throw new Error(`Таймер «${step.target_action_id || "—"}» не найден`);
|
||
}
|
||
return { ok: true };
|
||
}
|
||
case "hockey_penalties_command": {
|
||
const entries = currentHockeyPenaltyEntries().filter(({ component }) => !step.target_action_id || component.action_id === step.target_action_id);
|
||
entries.forEach(({ component, event }) => controlHockeyPenalty(component, event.id, step.penalty_command || "start", step.timer_value || ""));
|
||
return { ok: true, applied: entries.length };
|
||
}
|
||
case "vmix_command": {
|
||
const useAlternate = Boolean(step.use_scoreboard_alternate && hockeyScoreboardIsLive() && step.scoreboard_alternate_input);
|
||
const inputTemplate = useAlternate ? step.scoreboard_alternate_input : step.input;
|
||
const selectedNameTemplate = useAlternate && step.scoreboard_alternate_selected_name
|
||
? step.scoreboard_alternate_selected_name
|
||
: step.selected_name;
|
||
const command = {
|
||
Function: templateSequenceValue(step.function, context),
|
||
Input: templateSequenceValue(inputTemplate, context),
|
||
Value: templateSequenceValue(step.value, context),
|
||
SelectedName: templateSequenceValue(selectedNameTemplate, context),
|
||
Duration: templateSequenceValue(step.duration, context),
|
||
Mix: templateSequenceValue(step.mix, context),
|
||
};
|
||
if (!command.Function) throw new Error("В шаге vMix не указана Function");
|
||
return await sendRuntimeVmixSequence([command], execution);
|
||
}
|
||
case "hockey_vmix_timers_start": {
|
||
const gameTimer = componentByActionId(step.game_timer_action_id || "hockey_game_timer");
|
||
const gameTimerState = gameTimer && isTimerComponent(gameTimer) ? ensureTimerState(gameTimer) : null;
|
||
const penalties = currentHockeyPenaltyEntries();
|
||
const homeTargets = sequencePenaltyTargets(step, "home");
|
||
const awayTargets = sequencePenaltyTargets(step, "away");
|
||
const configuredCommand = ["toggle", "start", "pause", "resume"].includes(step.hockey_timer_command) ? step.hockey_timer_command : "toggle";
|
||
const action = configuredCommand === "toggle" ? (gameTimerState?.running ? "pause" : "start") : configuredCommand;
|
||
const pausing = action === "pause";
|
||
const commands = [];
|
||
|
||
if (step.sync_vmix_game && step.game_vmix_input) {
|
||
if (!gameTimerState) throw new Error(`Основной таймер «${step.game_timer_action_id}» не найден`);
|
||
if (!step.game_vmix_selected_name) throw new Error("Выберите Text / SelectedName основного таймера в vMix");
|
||
if (step.game_vmix_mode === "text") {
|
||
setVmixTimerMirror(step.game_timer_action_id || "hockey_game_timer", step.game_vmix_input, step.game_vmix_selected_name);
|
||
commands.push({ Function: "SetText", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: formatTimerValue(gameTimer, gameTimerState) });
|
||
} else if (pausing) {
|
||
commands.push({ Function: "SuspendCountdown", Input: step.game_vmix_input });
|
||
} else {
|
||
commands.push({ Function: "SetCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: vmixCountdownValue(gameTimerState.currentMs) });
|
||
commands.push({ Function: "StartCountdown", Input: step.game_vmix_input });
|
||
}
|
||
}
|
||
|
||
if (step.sync_vmix_penalties) {
|
||
let sortedHome = penalties.filter((item) => item.side === "home").sort((a, b) => Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0) || Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0));
|
||
let sortedAway = penalties.filter((item) => item.side === "away").sort((a, b) => Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0) || Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0));
|
||
const soonestOnly = String(step.penalty_display_mode || "soonest") !== "all";
|
||
const activeHomeTargets = soonestOnly ? homeTargets.slice(0, 1) : homeTargets;
|
||
const activeAwayTargets = soonestOnly ? awayTargets.slice(0, 1) : awayTargets;
|
||
if (soonestOnly) {
|
||
sortedHome = sortedHome.slice(0, 1);
|
||
sortedAway = sortedAway.slice(0, 1);
|
||
}
|
||
state.activeHockeyVmixTimerSteps.add(step.id);
|
||
[["home", sortedHome, activeHomeTargets], ["away", sortedAway, activeAwayTargets]].forEach(([side, sideEntries, targets]) => {
|
||
sideEntries.forEach(({ component, event }, index) => {
|
||
const target = targets[index] || null;
|
||
if (!target?.input) return;
|
||
if (!target.selected_name) throw new Error(`Для таймера удаления ${side === "home" ? "HOME" : "AWAY"} выберите Text / SelectedName`);
|
||
if (step.penalty_vmix_mode === "text") {
|
||
setVmixPenaltyMirror(component, event, target.input, target.selected_name, { stepId: step.id, targetId: target.id, side, overlay: target.overlay });
|
||
state.vmixPenaltyTargetAssignments.set(penaltyTargetAssignmentKey(step, side, target), {
|
||
eventKey: penaltyMirrorKey(component, event), input: target.input, selectedName: target.selected_name, overlay: target.overlay,
|
||
});
|
||
commands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: formatHockeyPenaltyTime(event.remainingMs) });
|
||
} else if (pausing) {
|
||
commands.push({ Function: "SuspendCountdown", Input: target.input });
|
||
} else {
|
||
commands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(event.remainingMs) });
|
||
commands.push({ Function: "StartCountdown", Input: target.input });
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
const vmixResult = commands.length ? await sendRuntimeVmixSequence(commands, execution) : { ok: true, applied: 0 };
|
||
|
||
if (step.start_web_game) {
|
||
if (!controlTimer(step.game_timer_action_id || "hockey_game_timer", pausing ? "pause" : (action === "resume" ? "resume" : "start"))) {
|
||
throw new Error(`Основной таймер «${step.game_timer_action_id || "hockey_game_timer"}» не найден`);
|
||
}
|
||
if (step.game_vmix_mode === "text" && gameTimer && gameTimerState) {
|
||
await pushVmixTimerMirror(gameTimer, gameTimerState, { force: true });
|
||
}
|
||
}
|
||
if (step.start_web_penalties) {
|
||
penalties.forEach(({ component, event }) => controlHockeyPenalty(component, event.id, pausing ? "pause" : "start"));
|
||
}
|
||
if (step.penalty_vmix_mode === "text" && !pausing) {
|
||
for (const { component, event } of penalties) {
|
||
await pushVmixPenaltyMirror(component, event, { force: true });
|
||
}
|
||
}
|
||
return { ok: true, action, vmix: vmixResult, penalties: penalties.length };
|
||
}
|
||
case "delay":
|
||
await new Promise((resolve) => setTimeout(resolve, clamp(Number(step.milliseconds) || 0, 0, 10000)));
|
||
return { ok: true };
|
||
case "dispatch_event":
|
||
window.dispatchEvent(new CustomEvent(step.event_name || "ui-builder:shortcut-sequence", { detail: context }));
|
||
return { ok: true };
|
||
default:
|
||
return { skipped: true };
|
||
}
|
||
}
|
||
|
||
async function runShortcutSequence(sequenceOrId, meta = {}) {
|
||
const sequence = typeof sequenceOrId === "string" ? shortcutSequenceById(sequenceOrId) : sequenceOrId;
|
||
if (!sequence || sequence.enabled === false) return false;
|
||
if (state.runningShortcutSequences.has(sequence.id)) return false;
|
||
const wasOnAir = shortcutSequenceIsOnAir(sequence.id);
|
||
state.runningShortcutSequences.add(sequence.id);
|
||
const execution = { sequence_id: String(sequence.id || ""), sequence_name: String(sequence.name || ""), button_id: String(meta.button_id || "") };
|
||
window.dispatchEvent(new CustomEvent("ui-builder:shortcut-sequence-start", { detail: { sequence: clone(sequence), meta } }));
|
||
try {
|
||
if (sequence.toggle_all_overlays_on_repeat && state.shortcutSequenceOverlayState.get(sequence.id)) {
|
||
// IMPORTANT: do not use OverlayInputAllOff here. vMix documents AllOff as an
|
||
// immediate cut, which bypasses the configured overlay/GT transition-out.
|
||
// Explicit OverlayInputNOut commands preserve the exit animation while still
|
||
// clearing every overlay channel controlled by vMix.
|
||
await sendRuntimeVmixSequence([
|
||
{ Function: "OverlayInput1Out" },
|
||
{ Function: "OverlayInput2Out" },
|
||
{ Function: "OverlayInput3Out" },
|
||
{ Function: "OverlayInput4Out" },
|
||
], execution);
|
||
if (sequence.sync_hockey_team_states) hockeyClearTeamStateOverlayTracking();
|
||
state.shortcutSequenceOverlayState.set(sequence.id, false);
|
||
state.quickPanelOnAirSequences.delete(String(sequence.id || ""));
|
||
refreshQuickPanelOnAirClasses();
|
||
window.dispatchEvent(new CustomEvent("ui-builder:shortcut-sequence-finish", { detail: { sequence: clone(sequence), meta: { ...meta, overlay_group_action: "off" } } }));
|
||
if (meta.source === "quick-panel-button") toast(`Выполнено: ${sequence.name} · кнопка «${meta.button_label || sequence.name}»`);
|
||
else if (String(meta.source || "").startsWith("keyboard")) toast(`Шорткат выполнен: ${sequence.name}`);
|
||
return true;
|
||
}
|
||
for (const step of sequence.steps || []) await runShortcutSequenceStep(sequence, step, execution);
|
||
if (sequence.toggle_all_overlays_on_repeat) {
|
||
state.shortcutSequenceOverlayState.set(sequence.id, true);
|
||
state.quickPanelOnAirSequences.add(String(sequence.id || ""));
|
||
}
|
||
reconcileShortcutSequenceOnAir(sequence, { wasOnAir });
|
||
if (sequence.sync_hockey_team_states) {
|
||
await hockeySyncTeamStateOverlays({ scoreboardActive: true, force: true });
|
||
}
|
||
window.dispatchEvent(new CustomEvent("ui-builder:shortcut-sequence-finish", { detail: { sequence: clone(sequence), meta: { ...meta, overlay_group_action: sequence.toggle_all_overlays_on_repeat ? "on" : "" } } }));
|
||
if (meta.source === "quick-panel-button") toast(`Выполнено: ${sequence.name} · кнопка «${meta.button_label || sequence.name}»`);
|
||
else if (String(meta.source || "").startsWith("keyboard")) toast(`Шорткат выполнен: ${sequence.name}`);
|
||
return true;
|
||
} catch (error) {
|
||
console.error("UI Builder shortcut sequence error", sequence, error);
|
||
if (meta.source !== "quick-panel-button") toast(`Шорткат «${sequence.name}»: ${error.message}`, true);
|
||
window.dispatchEvent(new CustomEvent("ui-builder:shortcut-sequence-error", { detail: { sequence: clone(sequence), error: String(error.message || error), meta } }));
|
||
return false;
|
||
} finally {
|
||
state.runningShortcutSequences.delete(sequence.id);
|
||
}
|
||
}
|
||
|
||
function handleConfiguredShortcutCombo(combo, event, source = "keyboard") {
|
||
const normalizedCombo = normalizeShortcutCombo(combo);
|
||
if (!normalizedCombo) return false;
|
||
|
||
const sequenceMatches = (state.config.shortcut_sequences || []).filter((sequence) =>
|
||
normalizeShortcutCombo(sequence.combo) === normalizedCombo && shortcutSequenceAllowed(sequence, event)
|
||
);
|
||
if (sequenceMatches.length) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation();
|
||
sequenceMatches.forEach((sequence) => runShortcutSequence(sequence, { source, combo: normalizedCombo }));
|
||
return true;
|
||
}
|
||
|
||
const matches = [];
|
||
state.config.components.forEach((component) => {
|
||
(component.shortcuts || []).forEach((shortcut) => {
|
||
if (normalizeShortcutCombo(shortcut.combo) === normalizedCombo && shortcutAllowed(component, shortcut, event)) matches.push({ component, shortcut });
|
||
});
|
||
});
|
||
if (!matches.length) return false;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation();
|
||
matches.forEach(({ component, shortcut }) => runShortcutBinding(component, shortcut, event));
|
||
return true;
|
||
}
|
||
|
||
function handleConfiguredShortcuts(event) {
|
||
if (event.repeat) return false;
|
||
const combo = shortcutFromKeyboardEvent(event);
|
||
if (!combo) return false;
|
||
return handleConfiguredShortcutCombo(combo, event, "keyboard");
|
||
}
|
||
|
||
function handleModifierOnlyShortcutRelease(event) {
|
||
const combo = modifierOnlyShortcutCombo();
|
||
if (!combo || state.modifierShortcutChordUsedKey || state.modifierShortcutChordFired) return false;
|
||
const handled = handleConfiguredShortcutCombo(combo, event, "keyboard-modifiers");
|
||
if (handled) state.modifierShortcutChordFired = true;
|
||
return handled;
|
||
}
|
||
|
||
|
||
function mergeExternalData(target, patch) {
|
||
if (Array.isArray(patch)) return clone(patch);
|
||
if (!patch || typeof patch !== "object") return patch;
|
||
const result = (
|
||
target && typeof target === "object" && !Array.isArray(target)
|
||
) ? { ...target } : {};
|
||
Object.entries(patch).forEach(([key, value]) => {
|
||
result[key] = (
|
||
value && typeof value === "object" && !Array.isArray(value)
|
||
)
|
||
? mergeExternalData(result[key], value)
|
||
: clone(value);
|
||
});
|
||
return result;
|
||
}
|
||
|
||
function applyExternalDataPatch(patch, { render = true } = {}) {
|
||
state.data = mergeExternalData(state.data, patch || {});
|
||
state.dataPaths = flattenPaths(state.data);
|
||
if (el.rawData) el.rawData.textContent = JSON.stringify(state.data, null, 2);
|
||
renderDataPaths();
|
||
if (render) {
|
||
renderCanvas();
|
||
renderRuntime();
|
||
}
|
||
window.dispatchEvent(
|
||
new CustomEvent("ui-builder:data-patched", { detail: clone(patch || {}) })
|
||
);
|
||
return clone(state.data);
|
||
}
|
||
|
||
window.UIBuilderRuntime = {
|
||
getState: (actionId) => clone(state.componentStates[actionId] || {}),
|
||
setState: (actionId, key, value) => setRuntimeState(actionId, key, value),
|
||
setValue: (actionId, value) => setRuntimeValue(actionId, value),
|
||
emit: (actionId, eventName, detail = {}) => { const component = componentByActionId(actionId); return component ? emitInteraction(component, eventName, detail) : null; },
|
||
runShortcut: (combo) => { const fake = { key: combo, code: "", ctrlKey: false, altKey: false, shiftKey: false, metaKey: false, repeat: false, target: document.body, preventDefault() {}, stopPropagation() {} }; const normalized = normalizeShortcutCombo(combo); const sequences = (state.config.shortcut_sequences || []).filter((item) => item.enabled && normalizeShortcutCombo(item.combo) === normalized); if (sequences.length) sequences.forEach((item) => runShortcutSequence(item, { source: "api", combo: normalized })); else state.config.components.forEach((component) => (component.shortcuts || []).filter((item) => item.enabled && normalizeShortcutCombo(item.combo) === normalized).forEach((item) => runShortcutBinding(component, item, fake))); },
|
||
runSequence: (sequenceId) => runShortcutSequence(sequenceId, { source: "api" }),
|
||
controlTimer: (actionId, command = "toggle", value = "") => controlTimer(actionId, command, value),
|
||
getTimer: (actionId) => { const component = componentByActionId(actionId); const timerState = isTimerComponent(component) ? ensureTimerState(component) : null; return timerState ? clone({ ...timerState, formatted: formatTimerValue(component, timerState) }) : null; },
|
||
openTimersEditor: (actionId = "") => openTimerQuickEditor(actionId),
|
||
getHockeyPenaltyBoard: (actionId) => clone(state.hockeyPenaltyBoards[actionId] || {}),
|
||
assignHockeyPenalty: (actionId, player, preset = "2") => { const component = componentByActionId(actionId); return component?.type === "hockey_penalty_dashboard" ? assignHockeyPenalty(component, player, preset) : false; },
|
||
controlHockeyPenalty: (actionId, penaltyId, command, value = "") => { const component = componentByActionId(actionId); return component?.type === "hockey_penalty_dashboard" ? controlHockeyPenalty(component, penaltyId, command, value) : false; },
|
||
getConfig: () => clone(state.config),
|
||
getFormValues: () => clone(state.formValues),
|
||
getData: () => clone(state.data),
|
||
patchData: (patch, options = {}) => applyExternalDataPatch(patch, options),
|
||
reloadData: (showToast = false) => loadData(showToast),
|
||
refreshLayout: () => scheduleRuntimeScale(),
|
||
};
|
||
|
||
function applyInteractionClasses(root, component) {
|
||
if (!component?.action_id) return;
|
||
const componentState = ensureComponentState(component);
|
||
root.dataset.actionId = component.action_id;
|
||
root.dataset.state = componentState.active ? "true" : "false";
|
||
root.dataset.value = componentState.value ?? "";
|
||
const shortcutText = enabledShortcutText(component);
|
||
if (shortcutText) { root.dataset.shortcuts = shortcutText; root.title = root.title ? `${root.title} · ${shortcutText}` : `Горячие клавиши: ${shortcutText}`; }
|
||
root.classList.toggle("interaction-active", Boolean(componentState.active));
|
||
root.classList.toggle("interaction-pressed", Boolean(componentState.pressed));
|
||
}
|
||
|
||
function applyCommonStyle(node, component) {
|
||
const style = component.style || {};
|
||
if (style.background) node.style.background = style.background;
|
||
if (style.color) node.style.color = style.color;
|
||
if (style.accent) node.style.setProperty("--accent", style.accent);
|
||
if (style.borderColor) node.style.borderColor = style.borderColor;
|
||
if (Number(style.borderWidth) > 0) node.style.borderWidth = `${style.borderWidth}px`;
|
||
if (style.borderRadius !== "" && style.borderRadius != null) node.style.borderRadius = `${Number(style.borderRadius)}px`;
|
||
if (Number(style.padding) > 0) node.style.padding = `${style.padding}px`;
|
||
if (Number(style.fontSize) > 0) node.style.fontSize = `${style.fontSize}px`;
|
||
if (style.fontWeight) node.style.fontWeight = String(style.fontWeight);
|
||
if (style.align) node.style.textAlign = style.align;
|
||
node.style.opacity = String((Number(style.opacity ?? 100)) / 100);
|
||
if (style.shadow) node.style.boxShadow = "0 12px 30px rgba(0,0,0,.28)";
|
||
return node;
|
||
}
|
||
|
||
|
||
function parseTimerMilliseconds(value, fallback = 0) {
|
||
if (typeof value === "number" && Number.isFinite(value)) return value * 1000;
|
||
const raw = String(value ?? "").trim();
|
||
if (!raw) return fallback;
|
||
const negative = raw.startsWith("-");
|
||
const clean = raw.replace(/^[+-]/, "").replace(",", ".");
|
||
const parts = clean.split(":").map((part) => part.trim());
|
||
if (parts.some((part) => part === "" || Number.isNaN(Number(part)))) return fallback;
|
||
let seconds = 0;
|
||
if (parts.length === 3) seconds = Number(parts[0]) * 3600 + Number(parts[1]) * 60 + Number(parts[2]);
|
||
else if (parts.length === 2) seconds = Number(parts[0]) * 60 + Number(parts[1]);
|
||
else seconds = Number(parts[0]);
|
||
return (negative ? -1 : 1) * seconds * 1000;
|
||
}
|
||
|
||
function isTimerComponent(component) {
|
||
return Boolean(component && (component.type === "timer" || component.type === "penalty_timer"));
|
||
}
|
||
|
||
function timerInitialMilliseconds(component) {
|
||
const props = component.props || {};
|
||
if (props.mode === "clock") return Date.now();
|
||
if (props.mode === "until_datetime") {
|
||
const target = Date.parse(props.targetDateTime || "");
|
||
return Number.isFinite(target) ? Math.max(0, target - Date.now()) : 0;
|
||
}
|
||
if (props.mode === "external") return parseTimerMilliseconds(getByPath(state.data, props.externalPath), 0);
|
||
return parseTimerMilliseconds(props.startTime, 0);
|
||
}
|
||
|
||
function timerEndMilliseconds(component) {
|
||
const value = String(component.props?.endTime ?? "").trim();
|
||
if (!value) return null;
|
||
return parseTimerMilliseconds(value, null);
|
||
}
|
||
|
||
function timerConfigSignature(component) {
|
||
const props = component.props || {};
|
||
return JSON.stringify([
|
||
props.mode, props.startTime, props.endTime, props.targetDateTime, props.externalPath,
|
||
props.afterEnd, props.autoStart, props.persist, props.continueBackground
|
||
]);
|
||
}
|
||
|
||
function timerStorageKey(component) {
|
||
return `ui-builder:timer:${state.config.project_name}:${component.action_id}`;
|
||
}
|
||
|
||
function loadPersistedTimer(component, timerState) {
|
||
if (!component.props?.persist) return timerState;
|
||
try {
|
||
const stored = JSON.parse(localStorage.getItem(timerStorageKey(component)) || "null");
|
||
if (!stored || stored.signature !== timerState.signature) return timerState;
|
||
timerState.currentMs = Number(stored.currentMs ?? timerState.currentMs);
|
||
timerState.running = Boolean(stored.running);
|
||
timerState.paused = Boolean(stored.paused);
|
||
timerState.finished = Boolean(stored.finished);
|
||
timerState.reached = stored.reached && typeof stored.reached === "object" ? stored.reached : {};
|
||
if (timerState.running && component.props?.continueBackground && stored.savedAt) {
|
||
const elapsed = Math.max(0, Date.now() - Number(stored.savedAt));
|
||
const direction = component.props.mode === "count_down" || component.props.mode === "until_datetime" ? -1 : 1;
|
||
timerState.currentMs += elapsed * direction;
|
||
}
|
||
} catch (_) {}
|
||
return timerState;
|
||
}
|
||
|
||
function persistTimer(component, timerState, force = false) {
|
||
const isHockeyMainTimer = component?.action_id === "hockey_game_timer";
|
||
if (component.props?.persist) {
|
||
const now = Date.now();
|
||
if (force || now - Number(timerState.lastPersistAt || 0) >= 1000) {
|
||
timerState.lastPersistAt = now;
|
||
try {
|
||
localStorage.setItem(timerStorageKey(component), JSON.stringify({
|
||
signature: timerState.signature,
|
||
currentMs: timerState.currentMs,
|
||
running: timerState.running,
|
||
paused: timerState.paused,
|
||
finished: timerState.finished,
|
||
reached: timerState.reached,
|
||
savedAt: now
|
||
}));
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
if (isHockeyMainTimer) {
|
||
const now = Date.now();
|
||
const remoteSignature = [
|
||
Math.round(Number(timerState.currentMs || 0) / 1000),
|
||
Boolean(timerState.running),
|
||
Boolean(timerState.paused),
|
||
Boolean(timerState.finished),
|
||
].join("|");
|
||
if (
|
||
force
|
||
|| remoteSignature !== timerState.lastHockeyPersistSignature
|
||
) {
|
||
timerState.lastHockeyPersistAt = now;
|
||
timerState.lastHockeyPersistSignature = remoteSignature;
|
||
hockeyScheduleTimerSave(force);
|
||
}
|
||
}
|
||
}
|
||
|
||
function createTimerState(component) {
|
||
const mode = component.props?.mode || "count_up";
|
||
const autoRunning = mode === "clock" || mode === "until_datetime" || Boolean(component.props?.autoStart);
|
||
const timerState = {
|
||
signature: timerConfigSignature(component),
|
||
currentMs: timerInitialMilliseconds(component),
|
||
running: autoRunning,
|
||
paused: !autoRunning,
|
||
finished: false,
|
||
lastTimestamp: performance.now(),
|
||
lastWholeSecond: null,
|
||
reached: {},
|
||
lastPersistAt: 0
|
||
};
|
||
return loadPersistedTimer(component, timerState);
|
||
}
|
||
|
||
function ensureTimerState(component, reset = false) {
|
||
if (!component?.action_id || !isTimerComponent(component)) return null;
|
||
const current = state.timers[component.action_id];
|
||
const signature = timerConfigSignature(component);
|
||
if (reset || !current || current.signature !== signature) {
|
||
state.timers[component.action_id] = createTimerState(component);
|
||
}
|
||
return state.timers[component.action_id];
|
||
}
|
||
|
||
function timerParts(milliseconds) {
|
||
const negative = milliseconds < 0;
|
||
const absolute = Math.abs(Math.round(milliseconds));
|
||
const hours = Math.floor(absolute / 3600000);
|
||
const totalMinutes = Math.floor(absolute / 60000);
|
||
const minutes = Math.floor(absolute / 60000) % 60;
|
||
const seconds = Math.floor(absolute / 1000) % 60;
|
||
const tenths = Math.floor(absolute / 100) % 10;
|
||
const millisecondsPart = absolute % 1000;
|
||
return { negative, hours, totalMinutes, minutes, seconds, tenths, milliseconds: millisecondsPart };
|
||
}
|
||
|
||
function padTimer(value, length = 2) {
|
||
return String(Math.max(0, Math.trunc(value))).padStart(length, "0");
|
||
}
|
||
|
||
function formatTimerValue(component, timerState) {
|
||
const props = component.props || {};
|
||
if (props.mode === "clock") {
|
||
const date = new Date(timerState.currentMs || Date.now());
|
||
return props.format === "clock_hm"
|
||
? `${padTimer(date.getHours())}:${padTimer(date.getMinutes())}`
|
||
: `${padTimer(date.getHours())}:${padTimer(date.getMinutes())}:${padTimer(date.getSeconds())}`;
|
||
}
|
||
|
||
const parts = timerParts(timerState.currentMs);
|
||
const sign = parts.negative ? "−" : "";
|
||
switch (props.format) {
|
||
case "m_ss":
|
||
return `${sign}${parts.totalMinutes}:${padTimer(parts.seconds)}`;
|
||
case "hh_mm_ss":
|
||
return `${sign}${padTimer(parts.hours)}:${padTimer(parts.minutes)}:${padTimer(parts.seconds)}`;
|
||
case "m_ss_tenths":
|
||
return `${sign}${parts.totalMinutes}:${padTimer(parts.seconds)}.${parts.tenths}`;
|
||
case "mm_ss_ms":
|
||
return `${sign}${padTimer(parts.totalMinutes)}:${padTimer(parts.seconds)}.${padTimer(parts.milliseconds, 3)}`;
|
||
case "football": {
|
||
const base = Math.max(0, Number(props.footballBaseMinute) || 45);
|
||
const totalSeconds = Math.floor(Math.abs(timerState.currentMs) / 1000);
|
||
const baseSeconds = base * 60;
|
||
if (totalSeconds < baseSeconds) return `${Math.floor(totalSeconds / 60)}′ ${padTimer(totalSeconds % 60)}″`;
|
||
const addedSeconds = totalSeconds - baseSeconds;
|
||
return `${base}′ + ${padTimer(Math.floor(addedSeconds / 60))}:${padTimer(addedSeconds % 60)}`;
|
||
}
|
||
case "clock_hm":
|
||
case "clock_hms": {
|
||
const date = new Date(timerState.currentMs || Date.now());
|
||
return props.format === "clock_hm"
|
||
? `${padTimer(date.getHours())}:${padTimer(date.getMinutes())}`
|
||
: `${padTimer(date.getHours())}:${padTimer(date.getMinutes())}:${padTimer(date.getSeconds())}`;
|
||
}
|
||
case "custom": {
|
||
const values = {
|
||
sign,
|
||
hours: padTimer(parts.hours),
|
||
minutes: padTimer(parts.minutes),
|
||
totalMinutes: parts.totalMinutes,
|
||
seconds: padTimer(parts.seconds),
|
||
tenths: parts.tenths,
|
||
milliseconds: padTimer(parts.milliseconds, 3),
|
||
baseMinute: Math.max(0, Number(props.footballBaseMinute) || 45),
|
||
addedMinutes: Math.max(0, parts.totalMinutes - (Number(props.footballBaseMinute) || 45))
|
||
};
|
||
return String(props.customFormat || "{totalMinutes}:{seconds}")
|
||
.replace(/\{([^}]+)\}/g, (_, key) => values[key] ?? "");
|
||
}
|
||
default:
|
||
return `${sign}${padTimer(parts.totalMinutes)}:${padTimer(parts.seconds)}`;
|
||
}
|
||
}
|
||
|
||
function timerStatusText(timerState) {
|
||
if (timerState.finished) return "Завершён";
|
||
if (timerState.running) return "Идёт";
|
||
if (timerState.paused) return "Пауза";
|
||
return "Остановлен";
|
||
}
|
||
|
||
function publishTimerState(component, timerState) {
|
||
const formatted = formatTimerValue(component, timerState);
|
||
const componentState = ensureComponentState(component);
|
||
componentState.active = Boolean(timerState.running);
|
||
componentState.value = formatted;
|
||
componentState.running = Boolean(timerState.running);
|
||
componentState.paused = Boolean(timerState.paused);
|
||
componentState.finished = Boolean(timerState.finished);
|
||
componentState.current_ms = Math.round(timerState.currentMs);
|
||
componentState.total_seconds = timerState.currentMs / 1000;
|
||
componentState.formatted = formatted;
|
||
componentState.timer = {
|
||
running: componentState.running,
|
||
paused: componentState.paused,
|
||
finished: componentState.finished,
|
||
current_ms: componentState.current_ms,
|
||
total_seconds: componentState.total_seconds,
|
||
formatted
|
||
};
|
||
updateInteractiveDom(component.action_id);
|
||
return formatted;
|
||
}
|
||
|
||
function registerTimerNode(component, node) {
|
||
const nodes = state.timerNodes.get(component.action_id) || new Set();
|
||
nodes.add(node);
|
||
state.timerNodes.set(component.action_id, nodes);
|
||
}
|
||
|
||
function updateTimerNodes(component, timerState) {
|
||
const formatted = publishTimerState(component, timerState);
|
||
const nodes = state.timerNodes.get(component.action_id) || [];
|
||
nodes.forEach((node) => {
|
||
const display = node.querySelector("[data-timer-display]");
|
||
const status = node.querySelector("[data-timer-status]");
|
||
const progress = node.querySelector("[data-timer-progress]");
|
||
const expired = node.querySelector("[data-timer-expired]");
|
||
if (display) display.textContent = formatted;
|
||
if (status) status.textContent = timerStatusText(timerState);
|
||
if (expired) expired.textContent = component.props?.expiredText || "Штраф завершён";
|
||
|
||
node.classList.toggle("is-running", timerState.running);
|
||
node.classList.toggle("is-paused", timerState.paused && !timerState.finished);
|
||
node.classList.toggle("is-finished", timerState.finished);
|
||
|
||
if (component.type === "penalty_timer") {
|
||
const startMs = Math.max(1, Math.abs(parseTimerMilliseconds(component.props?.startTime, 1)));
|
||
const remaining = Math.max(0, Math.abs(timerState.currentMs));
|
||
const ratio = Math.max(0, Math.min(1, remaining / startMs));
|
||
const warningMs = Math.max(0, Math.abs(parseTimerMilliseconds(component.props?.warningAt, 0)));
|
||
const isWarning = !timerState.finished && warningMs > 0 && remaining <= warningMs;
|
||
node.classList.toggle("is-warning", isWarning);
|
||
node.classList.toggle("is-auto-hidden", Boolean(component.props?.hideWhenFinished && timerState.finished && node.dataset.runtime === "1"));
|
||
if (progress) progress.style.width = `${ratio * 100}%`;
|
||
}
|
||
});
|
||
}
|
||
|
||
function timerMilestones(component) {
|
||
return String(component.props?.milestones || "")
|
||
.split("|")
|
||
.map((value) => value.trim())
|
||
.filter(Boolean)
|
||
.map((label) => ({ label, ms: parseTimerMilliseconds(label, NaN) }))
|
||
.filter((item) => Number.isFinite(item.ms));
|
||
}
|
||
|
||
function crossedTimerValue(previous, current, target, direction) {
|
||
return direction >= 0 ? previous < target && current >= target : previous > target && current <= target;
|
||
}
|
||
|
||
function emitTimerEvent(component, eventName, timerState, detail = {}) {
|
||
updateTimerNodes(component, timerState);
|
||
emitInteraction(component, eventName, {
|
||
value: timerState.currentMs,
|
||
formatted: formatTimerValue(component, timerState),
|
||
running: timerState.running,
|
||
paused: timerState.paused,
|
||
finished: timerState.finished,
|
||
...detail
|
||
});
|
||
if (eventName === "timer_tick" && state.vmixTimerMirrors.has(component.action_id)) {
|
||
pushVmixTimerMirror(component, timerState).catch(() => {});
|
||
}
|
||
if (eventName === "timer_finished") {
|
||
fireConfiguredTimerFinishActions("game", { gameActionId: component.action_id, component, timerState });
|
||
}
|
||
}
|
||
|
||
function finishTimerBoundary(component, timerState, boundary, now) {
|
||
const afterEnd = component.props?.afterEnd || "stop";
|
||
if (afterEnd === "loop") {
|
||
timerState.currentMs = timerInitialMilliseconds(component);
|
||
timerState.finished = false;
|
||
timerState.reached = {};
|
||
timerState.lastTimestamp = now;
|
||
emitTimerEvent(component, "timer_finished", timerState, { loop: true, boundary });
|
||
emitTimerEvent(component, "timer_restart", timerState, { loop: true });
|
||
return;
|
||
}
|
||
timerState.finished = true;
|
||
emitTimerEvent(component, "timer_finished", timerState, { boundary });
|
||
if (afterEnd === "stop") {
|
||
timerState.currentMs = boundary;
|
||
timerState.running = false;
|
||
timerState.paused = false;
|
||
}
|
||
}
|
||
|
||
function updateSingleTimer(component, timerState, now) {
|
||
const props = component.props || {};
|
||
const mode = props.mode || "count_up";
|
||
const interval = clamp(Number(props.updateInterval) || 100, 16, 5000);
|
||
if (timerState.lastFrameAt != null && now - timerState.lastFrameAt < interval) return;
|
||
timerState.lastFrameAt = now;
|
||
const previous = timerState.currentMs;
|
||
|
||
if (mode === "clock") {
|
||
timerState.currentMs = Date.now();
|
||
timerState.running = true;
|
||
} else if (mode === "until_datetime") {
|
||
const target = Date.parse(props.targetDateTime || "");
|
||
if (timerState.finished && props.afterEnd === "stop") {
|
||
timerState.currentMs = 0;
|
||
timerState.running = false;
|
||
} else {
|
||
timerState.currentMs = Number.isFinite(target) ? target - Date.now() : 0;
|
||
timerState.running = true;
|
||
if (timerState.currentMs <= 0 && !timerState.finished) finishTimerBoundary(component, timerState, 0, now);
|
||
}
|
||
} else if (mode === "external") {
|
||
timerState.currentMs = parseTimerMilliseconds(getByPath(state.data, props.externalPath), timerState.currentMs);
|
||
} else if (timerState.running) {
|
||
const delta = Math.max(0, now - Number(timerState.lastTimestamp || now));
|
||
const direction = mode === "count_down" ? -1 : 1;
|
||
timerState.currentMs += delta * direction;
|
||
const end = timerEndMilliseconds(component);
|
||
if (end != null && crossedTimerValue(previous, timerState.currentMs, end, direction) && !timerState.finished) {
|
||
finishTimerBoundary(component, timerState, end, now);
|
||
}
|
||
}
|
||
|
||
timerState.lastTimestamp = now;
|
||
const direction = mode === "count_down" || mode === "until_datetime" ? -1 : 1;
|
||
timerMilestones(component).forEach((milestone) => {
|
||
if (!timerState.reached[milestone.label] && crossedTimerValue(previous, timerState.currentMs, milestone.ms, direction)) {
|
||
timerState.reached[milestone.label] = true;
|
||
emitTimerEvent(component, "timer_reached", timerState, {
|
||
item_id: milestone.label,
|
||
milestone: milestone.label,
|
||
milestone_ms: milestone.ms
|
||
});
|
||
}
|
||
});
|
||
|
||
const wholeSecond = Math.floor(Math.abs(timerState.currentMs) / 1000);
|
||
if (wholeSecond !== timerState.lastWholeSecond) {
|
||
timerState.lastWholeSecond = wholeSecond;
|
||
emitTimerEvent(component, "timer_tick", timerState, { second: wholeSecond });
|
||
} else {
|
||
updateTimerNodes(component, timerState);
|
||
}
|
||
persistTimer(component, timerState);
|
||
}
|
||
|
||
function timerEngineFrame(now) {
|
||
const activeRuntime = boot.mode === "runtime" || state.preview;
|
||
if (activeRuntime) {
|
||
state.config.components
|
||
.filter((component) => isTimerComponent(component) && !component.hidden)
|
||
.forEach((component) => updateSingleTimer(component, ensureTimerState(component), now));
|
||
updateHockeyPenaltyBoards(now);
|
||
}
|
||
requestAnimationFrame(timerEngineFrame);
|
||
}
|
||
|
||
function ensureTimerEngine() {
|
||
if (state.timerEngineStarted) return;
|
||
state.timerEngineStarted = true;
|
||
requestAnimationFrame(timerEngineFrame);
|
||
}
|
||
|
||
function controlTimer(actionId, command = "toggle", rawValue = "") {
|
||
const component = componentByActionId(actionId);
|
||
if (!component || !isTimerComponent(component)) return false;
|
||
const timerState = ensureTimerState(component);
|
||
const now = performance.now();
|
||
const valueMs = parseTimerMilliseconds(rawValue, 0);
|
||
let eventName = `timer_${command}`;
|
||
|
||
switch (command) {
|
||
case "start":
|
||
if (timerState.finished) {
|
||
timerState.currentMs = timerInitialMilliseconds(component);
|
||
timerState.finished = false;
|
||
timerState.reached = {};
|
||
}
|
||
timerState.running = true;
|
||
timerState.paused = false;
|
||
eventName = "timer_start";
|
||
break;
|
||
case "resume":
|
||
timerState.running = true;
|
||
timerState.paused = false;
|
||
eventName = "timer_resume";
|
||
break;
|
||
case "pause":
|
||
timerState.running = false;
|
||
timerState.paused = true;
|
||
eventName = "timer_pause";
|
||
break;
|
||
case "stop":
|
||
timerState.running = false;
|
||
timerState.paused = false;
|
||
eventName = "timer_stop";
|
||
break;
|
||
case "reset":
|
||
timerState.currentMs = timerInitialMilliseconds(component);
|
||
timerState.running = false;
|
||
timerState.paused = true;
|
||
timerState.finished = false;
|
||
timerState.reached = {};
|
||
eventName = "timer_reset";
|
||
break;
|
||
case "restart":
|
||
timerState.currentMs = timerInitialMilliseconds(component);
|
||
timerState.running = true;
|
||
timerState.paused = false;
|
||
timerState.finished = false;
|
||
timerState.reached = {};
|
||
eventName = "timer_restart";
|
||
break;
|
||
case "set_time":
|
||
timerState.currentMs = valueMs;
|
||
timerState.finished = false;
|
||
eventName = "timer_set_time";
|
||
break;
|
||
case "add_time":
|
||
timerState.currentMs += valueMs;
|
||
timerState.finished = false;
|
||
eventName = "timer_add_time";
|
||
break;
|
||
case "subtract_time":
|
||
timerState.currentMs -= valueMs;
|
||
timerState.finished = false;
|
||
eventName = "timer_subtract_time";
|
||
break;
|
||
case "toggle":
|
||
default:
|
||
if (timerState.running) {
|
||
timerState.running = false;
|
||
timerState.paused = true;
|
||
eventName = "timer_pause";
|
||
} else {
|
||
if (timerState.finished) {
|
||
timerState.currentMs = timerInitialMilliseconds(component);
|
||
timerState.finished = false;
|
||
timerState.reached = {};
|
||
}
|
||
timerState.running = true;
|
||
timerState.paused = false;
|
||
eventName = "timer_start";
|
||
}
|
||
break;
|
||
}
|
||
|
||
timerState.lastTimestamp = now;
|
||
persistTimer(component, timerState, true);
|
||
emitTimerEvent(component, eventName, timerState, { command, amount: rawValue });
|
||
return true;
|
||
}
|
||
|
||
|
||
function editableTimerValue(milliseconds) {
|
||
const negative = milliseconds < 0;
|
||
const absolute = Math.abs(Math.round(milliseconds));
|
||
const hours = Math.floor(absolute / 3600000);
|
||
const minutes = Math.floor(absolute / 60000) % 60;
|
||
const totalMinutes = Math.floor(absolute / 60000);
|
||
const seconds = Math.floor(absolute / 1000) % 60;
|
||
const millisecondsPart = absolute % 1000;
|
||
const sign = negative ? "-" : "";
|
||
if (hours > 0) return `${sign}${padTimer(hours)}:${padTimer(minutes)}:${padTimer(seconds)}`;
|
||
if (millisecondsPart > 0) return `${sign}${padTimer(totalMinutes)}:${padTimer(seconds)}.${padTimer(millisecondsPart, 3)}`;
|
||
return `${sign}${padTimer(totalMinutes)}:${padTimer(seconds)}`;
|
||
}
|
||
|
||
function timerQuickEditorItems() {
|
||
return state.config.components
|
||
.filter((component) => isTimerComponent(component) && !component.hidden)
|
||
.sort((a, b) => Number(a.y || 0) - Number(b.y || 0) || Number(a.x || 0) - Number(b.x || 0));
|
||
}
|
||
|
||
function hockeyQuickEditorItems() {
|
||
return state.config.components
|
||
.filter((component) => component.type === "hockey_penalty_dashboard" && !component.hidden)
|
||
.flatMap((component) => {
|
||
const board = ensureHockeyBoardState(component);
|
||
return board.penalties.map((event) => ({ component, event }));
|
||
});
|
||
}
|
||
|
||
function timerQuickEditorName(component) {
|
||
if (component.type === "penalty_timer") {
|
||
const number = String(component.props?.playerNumber || "").replace(/^#/, "");
|
||
const player = component.props?.playerName || component.title;
|
||
const team = component.props?.team || "";
|
||
return `${team ? `${team} · ` : ""}${number ? `#${number} ` : ""}${player}`.trim();
|
||
}
|
||
return component.props?.label || component.title || component.action_id;
|
||
}
|
||
|
||
function timerQuickEditorModeLabel(component) {
|
||
if (component.type === "penalty_timer") return component.props?.label || "Удаление";
|
||
const labels = {
|
||
count_up: "Прямой отсчёт",
|
||
count_down: "Обратный отсчёт",
|
||
stopwatch: "Секундомер",
|
||
clock: "Текущее время",
|
||
until_datetime: "До даты",
|
||
external: "Из JSON"
|
||
};
|
||
return labels[component.props?.mode] || "Таймер";
|
||
}
|
||
|
||
function mainTimerActionIds() {
|
||
return new Set(
|
||
state.config.components
|
||
.filter((component) => component.type === "hockey_penalty_dashboard")
|
||
.map((component) => component.props?.gameTimerActionId || "hockey_game_timer")
|
||
);
|
||
}
|
||
|
||
function renderComponentTimerEditorRow(component, main = false, focused = false) {
|
||
const timerState = ensureTimerState(component);
|
||
const automatic = ["clock", "until_datetime", "external"].includes(component.props?.mode);
|
||
return `
|
||
<article class="timer-editor-row ${main ? "is-main-timer" : ""} ${focused ? "is-focused" : ""}" data-timer-editor-row="${escapeHtml(component.action_id)}">
|
||
<div class="timer-editor-kind">${main ? "⏱" : component.type === "penalty_timer" ? "2′" : "◷"}</div>
|
||
<div class="timer-editor-info">
|
||
<strong>${escapeHtml(timerQuickEditorName(component))}</strong>
|
||
<span>${escapeHtml(timerQuickEditorModeLabel(component))} · <code>${escapeHtml(component.action_id)}</code></span>
|
||
</div>
|
||
<div class="timer-editor-live">
|
||
<strong data-current-time>${escapeHtml(formatTimerValue(component, timerState))}</strong>
|
||
<span data-current-status>${escapeHtml(timerStatusText(timerState))}</span>
|
||
</div>
|
||
<label class="timer-editor-input">
|
||
<span>Новое время</span>
|
||
<input
|
||
type="text"
|
||
data-quick-timer-value
|
||
value="${escapeHtml(editableTimerValue(timerState.currentMs))}"
|
||
placeholder="20:00"
|
||
${automatic ? "disabled" : ""}
|
||
>
|
||
</label>
|
||
<div class="timer-editor-actions">
|
||
<button type="button" class="timer-editor-icon-btn is-apply" data-quick-timer-apply data-tooltip="Применить введённое время" aria-label="Применить введённое время" ${automatic ? "disabled" : ""}><span aria-hidden="true">✓</span></button>
|
||
<button type="button" class="timer-editor-icon-btn is-toggle" data-quick-timer-toggle data-command="${timerState.running ? "pause" : "start"}" data-tooltip="${timerState.running ? "Поставить таймер на паузу" : "Запустить таймер"}" aria-label="${timerState.running ? "Поставить таймер на паузу" : "Запустить таймер"}"><span data-button-icon aria-hidden="true">${timerState.running ? "Ⅱ" : "▶"}</span></button>
|
||
<button type="button" class="timer-editor-icon-btn is-reset" data-quick-timer-reset data-tooltip="Сбросить таймер" aria-label="Сбросить таймер"><span aria-hidden="true">↺</span></button>
|
||
</div>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
function renderHockeyTimerEditorRow(item, focused = false) {
|
||
const { component, event } = item;
|
||
const side = event.player?.side || event.side || "neutral";
|
||
const name = event.teamPenalty
|
||
? `Командное удаление · ${side === "home" ? "левая команда" : side === "away" ? "правая команда" : "команда не выбрана"}`
|
||
: event.player
|
||
? `#${event.player.number || "—"} ${event.player.name}`
|
||
: "Заготовка без игрока";
|
||
const description = [
|
||
event.infraction?.label || "Нарушение не выбрано",
|
||
event.preset || "Длительность не выбрана",
|
||
event.eventTime ? `момент ${event.eventTime}` : ""
|
||
].filter(Boolean).join(" · ");
|
||
|
||
return `
|
||
<article class="timer-editor-row timer-editor-hockey-row team-${escapeHtml(side)} ${focused ? "is-focused" : ""}" data-hockey-dashboard-id="${escapeHtml(component.action_id)}" data-hockey-penalty-id="${escapeHtml(event.id)}">
|
||
<div class="timer-editor-kind team-${escapeHtml(side)}">${side === "home" ? "Л" : side === "away" ? "П" : "—"}</div>
|
||
<div class="timer-editor-info">
|
||
<strong>${escapeHtml(name)}</strong>
|
||
<span>${escapeHtml(description)}</span>
|
||
</div>
|
||
<div class="timer-editor-live">
|
||
<strong data-current-time>${escapeHtml(event.preset ? formatHockeyPenaltyTime(event.remainingMs) : "—:—")}</strong>
|
||
<span data-current-status>${escapeHtml(event.finished ? "Завершён" : event.running ? "Идёт" : hockeyEventReady(event) ? "Пауза" : "Заготовка")}</span>
|
||
</div>
|
||
<label class="timer-editor-input">
|
||
<span>Новое время</span>
|
||
<input
|
||
type="text"
|
||
data-quick-hockey-value
|
||
value="${escapeHtml(editableTimerValue(event.remainingMs))}"
|
||
placeholder="02:00"
|
||
${event.preset ? "" : "disabled"}
|
||
>
|
||
</label>
|
||
<div class="timer-editor-actions">
|
||
<button type="button" class="timer-editor-icon-btn is-apply" data-quick-hockey-apply data-tooltip="Применить введённое время удаления" aria-label="Применить введённое время удаления" ${event.preset ? "" : "disabled"}><span aria-hidden="true">✓</span></button>
|
||
<button type="button" class="timer-editor-icon-btn is-toggle" data-quick-hockey-toggle data-command="${event.running ? "pause" : "start"}" data-tooltip="${event.running ? "Поставить удаление на паузу" : "Запустить удаление"}" aria-label="${event.running ? "Поставить удаление на паузу" : "Запустить удаление"}" ${hockeyEventReady(event) ? "" : "disabled"}><span data-button-icon aria-hidden="true">${event.running ? "Ⅱ" : "▶"}</span></button>
|
||
<button type="button" class="timer-editor-icon-btn is-reset" data-quick-hockey-reset data-tooltip="Сбросить удаление" aria-label="Сбросить удаление" ${event.preset ? "" : "disabled"}><span aria-hidden="true">↺</span></button>
|
||
</div>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
function timerQuickEditorSignature() {
|
||
const componentIds = timerQuickEditorItems().map((component) => component.action_id);
|
||
const eventIds = hockeyQuickEditorItems().map(({ component, event }) => `${component.action_id}:${event.id}`);
|
||
return [...componentIds, ...eventIds].join("|");
|
||
}
|
||
|
||
function updateTimerQuickEditorRows() {
|
||
const editor = el.modalHost.querySelector(".timer-quick-editor");
|
||
if (!editor) return;
|
||
|
||
if (editor.dataset.signature !== timerQuickEditorSignature()) {
|
||
const focused = editor.dataset.focusId || "";
|
||
openTimerQuickEditor(focused);
|
||
return;
|
||
}
|
||
|
||
timerQuickEditorItems().forEach((component) => {
|
||
const timerState = ensureTimerState(component);
|
||
const row = editor.querySelector(`[data-timer-editor-row="${CSS.escape(component.action_id)}"]`);
|
||
if (!row) return;
|
||
row.querySelector("[data-current-time]").textContent = formatTimerValue(component, timerState);
|
||
row.querySelector("[data-current-status]").textContent = timerStatusText(timerState);
|
||
const toggle = row.querySelector("[data-quick-timer-toggle]");
|
||
if (toggle) {
|
||
const tooltip = timerState.running ? "Поставить таймер на паузу" : "Запустить таймер";
|
||
const icon = toggle.querySelector("[data-button-icon]");
|
||
if (icon) icon.textContent = timerState.running ? "Ⅱ" : "▶";
|
||
toggle.dataset.command = timerState.running ? "pause" : "start";
|
||
toggle.dataset.tooltip = tooltip;
|
||
toggle.removeAttribute("title");
|
||
toggle.setAttribute("aria-label", tooltip);
|
||
}
|
||
row.classList.toggle("is-running", timerState.running);
|
||
row.classList.toggle("is-finished", timerState.finished);
|
||
});
|
||
|
||
hockeyQuickEditorItems().forEach(({ component, event }) => {
|
||
const row = editor.querySelector(`[data-hockey-dashboard-id="${CSS.escape(component.action_id)}"][data-hockey-penalty-id="${CSS.escape(event.id)}"]`);
|
||
if (!row) return;
|
||
row.querySelector("[data-current-time]").textContent = event.preset ? formatHockeyPenaltyTime(event.remainingMs) : "—:—";
|
||
row.querySelector("[data-current-status]").textContent = event.finished ? "Завершён" : event.running ? "Идёт" : hockeyEventReady(event) ? "Пауза" : "Заготовка";
|
||
const toggle = row.querySelector("[data-quick-hockey-toggle]");
|
||
if (toggle) {
|
||
const tooltip = event.running ? "Поставить удаление на паузу" : "Запустить удаление";
|
||
const icon = toggle.querySelector("[data-button-icon]");
|
||
if (icon) icon.textContent = event.running ? "Ⅱ" : "▶";
|
||
toggle.dataset.command = event.running ? "pause" : "start";
|
||
toggle.dataset.tooltip = tooltip;
|
||
toggle.removeAttribute("title");
|
||
toggle.setAttribute("aria-label", tooltip);
|
||
toggle.disabled = !hockeyEventReady(event);
|
||
}
|
||
row.classList.toggle("is-running", event.running);
|
||
row.classList.toggle("is-finished", event.finished);
|
||
});
|
||
}
|
||
|
||
function applyTimerQuickEditorRow(row) {
|
||
const actionId = row.dataset.timerEditorRow;
|
||
const input = row.querySelector("[data-quick-timer-value]");
|
||
if (!actionId || !input || input.disabled) return;
|
||
const value = String(input.value || "").trim();
|
||
if (value) controlTimer(actionId, "set_time", value);
|
||
}
|
||
|
||
function applyHockeyQuickEditorRow(row) {
|
||
const dashboardId = row.dataset.hockeyDashboardId;
|
||
const penaltyId = row.dataset.hockeyPenaltyId;
|
||
const input = row.querySelector("[data-quick-hockey-value]");
|
||
if (!dashboardId || !penaltyId || !input || input.disabled) return;
|
||
const component = componentByActionId(dashboardId);
|
||
const value = String(input.value || "").trim();
|
||
if (component && value) controlHockeyPenalty(component, penaltyId, "set_time", value);
|
||
}
|
||
|
||
function openTimerQuickEditor(focusActionId = "") {
|
||
const timers = timerQuickEditorItems();
|
||
const hockeyItems = hockeyQuickEditorItems();
|
||
if (!timers.length && !hockeyItems.length) {
|
||
toast("В проекте нет таймеров");
|
||
return;
|
||
}
|
||
|
||
const mainIds = mainTimerActionIds();
|
||
let mainTimers = timers.filter((component) => mainIds.has(component.action_id));
|
||
if (!mainTimers.length && timers.length) mainTimers = [timers[0]];
|
||
const mainSet = new Set(mainTimers.map((component) => component.action_id));
|
||
const otherTimers = timers.filter((component) => !mainSet.has(component.action_id));
|
||
|
||
const homeItems = hockeyItems.filter(({ event }) => (event.player?.side || event.side) === "home");
|
||
const awayItems = hockeyItems.filter(({ event }) => (event.player?.side || event.side) === "away");
|
||
const neutralItems = hockeyItems.filter(({ event }) => !(event.player?.side || event.side));
|
||
|
||
const componentRows = (items, main = false) => items.map((component) =>
|
||
renderComponentTimerEditorRow(component, main, focusActionId === component.action_id)
|
||
).join("");
|
||
|
||
const hockeyRows = (items) => items.map((item) =>
|
||
renderHockeyTimerEditorRow(item, focusActionId === item.event.id)
|
||
).join("");
|
||
|
||
showSettingsModal("Таймеры", `
|
||
<div class="timer-quick-editor" data-signature="${escapeHtml(timerQuickEditorSignature())}" data-focus-id="${escapeHtml(focusActionId)}">
|
||
<div class="timer-editor-toolbar">
|
||
<div>
|
||
<strong>Быстрая правка всех таймеров</strong>
|
||
<p>Основное время сверху; удаления разделены по командам.</p>
|
||
</div>
|
||
<div class="timer-editor-toolbar-actions">
|
||
<button type="button" class="btn" data-quick-timers-pause-all>Пауза всем</button>
|
||
<button type="button" class="btn btn-accent" data-quick-timers-apply-all>Применить всё</button>
|
||
</div>
|
||
</div>
|
||
|
||
${mainTimers.length ? `
|
||
<section class="timer-editor-main-section">
|
||
<div class="timer-editor-section-title"><strong>Основное время</strong></div>
|
||
<div class="timer-editor-main-list">${componentRows(mainTimers, true)}</div>
|
||
</section>
|
||
` : ""}
|
||
|
||
<div class="timer-editor-team-grid">
|
||
<section class="timer-editor-team-section team-home">
|
||
<div class="timer-editor-section-title">
|
||
<strong>Левая команда</strong>
|
||
<span>${homeItems.length}</span>
|
||
</div>
|
||
<div class="timer-editor-team-list">
|
||
${hockeyRows(homeItems) || `<div class="timer-editor-empty">Таймеров удалений нет</div>`}
|
||
</div>
|
||
</section>
|
||
|
||
<section class="timer-editor-team-section team-away">
|
||
<div class="timer-editor-section-title">
|
||
<strong>Правая команда</strong>
|
||
<span>${awayItems.length}</span>
|
||
</div>
|
||
<div class="timer-editor-team-list">
|
||
${hockeyRows(awayItems) || `<div class="timer-editor-empty">Таймеров удалений нет</div>`}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
${neutralItems.length ? `
|
||
<section class="timer-editor-neutral-section">
|
||
<div class="timer-editor-section-title">
|
||
<strong>Заготовки без команды</strong>
|
||
<span>${neutralItems.length}</span>
|
||
</div>
|
||
<div class="timer-editor-neutral-list">${hockeyRows(neutralItems)}</div>
|
||
</section>
|
||
` : ""}
|
||
|
||
${otherTimers.length ? `
|
||
<section class="timer-editor-other-section">
|
||
<div class="timer-editor-section-title"><strong>Другие таймеры</strong></div>
|
||
<div class="timer-editor-list">${componentRows(otherTimers)}</div>
|
||
</section>
|
||
` : ""}
|
||
</div>
|
||
`);
|
||
|
||
const editor = el.modalHost.querySelector(".timer-quick-editor");
|
||
|
||
editor.querySelectorAll("[data-quick-timer-apply]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
applyTimerQuickEditorRow(button.closest("[data-timer-editor-row]"));
|
||
updateTimerQuickEditorRows();
|
||
});
|
||
});
|
||
editor.querySelectorAll("[data-quick-timer-toggle]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const row = button.closest("[data-timer-editor-row]");
|
||
controlTimer(row.dataset.timerEditorRow, button.dataset.command || "toggle");
|
||
updateTimerQuickEditorRows();
|
||
});
|
||
});
|
||
editor.querySelectorAll("[data-quick-timer-reset]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const row = button.closest("[data-timer-editor-row]");
|
||
controlTimer(row.dataset.timerEditorRow, "reset");
|
||
const component = componentByActionId(row.dataset.timerEditorRow);
|
||
const timerState = ensureTimerState(component);
|
||
const input = row.querySelector("[data-quick-timer-value]");
|
||
if (input && !input.disabled) input.value = editableTimerValue(timerState.currentMs);
|
||
updateTimerQuickEditorRows();
|
||
});
|
||
});
|
||
|
||
editor.querySelectorAll("[data-quick-hockey-apply]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
applyHockeyQuickEditorRow(button.closest("[data-hockey-penalty-id]"));
|
||
updateTimerQuickEditorRows();
|
||
});
|
||
});
|
||
editor.querySelectorAll("[data-quick-hockey-toggle]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const row = button.closest("[data-hockey-penalty-id]");
|
||
const component = componentByActionId(row.dataset.hockeyDashboardId);
|
||
controlHockeyPenalty(component, row.dataset.hockeyPenaltyId, button.dataset.command || "toggle");
|
||
updateTimerQuickEditorRows();
|
||
});
|
||
});
|
||
editor.querySelectorAll("[data-quick-hockey-reset]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const row = button.closest("[data-hockey-penalty-id]");
|
||
const component = componentByActionId(row.dataset.hockeyDashboardId);
|
||
controlHockeyPenalty(component, row.dataset.hockeyPenaltyId, "reset");
|
||
const event = findHockeyPenalty(component, row.dataset.hockeyPenaltyId);
|
||
const input = row.querySelector("[data-quick-hockey-value]");
|
||
if (input && event) input.value = editableTimerValue(event.remainingMs);
|
||
updateTimerQuickEditorRows();
|
||
});
|
||
});
|
||
|
||
editor.querySelector("[data-quick-timers-apply-all]")?.addEventListener("click", () => {
|
||
editor.querySelectorAll("[data-timer-editor-row]").forEach(applyTimerQuickEditorRow);
|
||
editor.querySelectorAll("[data-hockey-penalty-id]").forEach(applyHockeyQuickEditorRow);
|
||
updateTimerQuickEditorRows();
|
||
toast("Время таймеров обновлено");
|
||
});
|
||
|
||
editor.querySelector("[data-quick-timers-pause-all]")?.addEventListener("click", () => {
|
||
timers.forEach((component) => controlTimer(component.action_id, "pause"));
|
||
hockeyQuickEditorItems().forEach(({ component, event }) => {
|
||
if (event.running) controlHockeyPenalty(component, event.id, "pause");
|
||
});
|
||
updateTimerQuickEditorRows();
|
||
});
|
||
|
||
editor.querySelectorAll("[data-quick-timer-value]").forEach((input) => {
|
||
input.addEventListener("keydown", (event) => {
|
||
if (event.key !== "Enter") return;
|
||
event.preventDefault();
|
||
applyTimerQuickEditorRow(input.closest("[data-timer-editor-row]"));
|
||
updateTimerQuickEditorRows();
|
||
});
|
||
});
|
||
editor.querySelectorAll("[data-quick-hockey-value]").forEach((input) => {
|
||
input.addEventListener("keydown", (event) => {
|
||
if (event.key !== "Enter") return;
|
||
event.preventDefault();
|
||
applyHockeyQuickEditorRow(input.closest("[data-hockey-penalty-id]"));
|
||
updateTimerQuickEditorRows();
|
||
});
|
||
});
|
||
|
||
if (focusActionId) {
|
||
const focused =
|
||
editor.querySelector(`[data-timer-editor-row="${CSS.escape(focusActionId)}"]`) ||
|
||
editor.querySelector(`[data-hockey-penalty-id="${CSS.escape(focusActionId)}"]`);
|
||
focused?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||
focused?.querySelector("input:not(:disabled)")?.select();
|
||
}
|
||
|
||
clearInterval(state.timerQuickEditorInterval);
|
||
state.timerQuickEditorInterval = window.setInterval(updateTimerQuickEditorRows, 200);
|
||
updateTimerQuickEditorRows();
|
||
}
|
||
|
||
function bindTimerQuickEditorOpen(component, display, runtime) {
|
||
if (!runtime || !display) return;
|
||
display.title = "Двойной клик — быстро изменить время";
|
||
display.classList.add("timer-editable-display");
|
||
display.addEventListener("dblclick", (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
openTimerQuickEditor(component.action_id);
|
||
});
|
||
}
|
||
|
||
function timerNode(component, runtime) {
|
||
const props = component.props || {};
|
||
const timerState = ensureTimerState(component);
|
||
const node = div("ui-timer ui-timer--compact");
|
||
node.dataset.runtime = runtime ? "1" : "0";
|
||
|
||
const meta = div("ui-timer-meta");
|
||
const label = div("ui-timer-label");
|
||
label.textContent = props.label || component.title;
|
||
meta.appendChild(label);
|
||
|
||
if (props.showStatus !== false) {
|
||
const status = div("ui-timer-status");
|
||
status.dataset.timerStatus = "";
|
||
status.textContent = timerStatusText(timerState);
|
||
meta.appendChild(status);
|
||
}
|
||
|
||
const display = div("ui-timer-display");
|
||
display.dataset.timerDisplay = "";
|
||
display.textContent = formatTimerValue(component, timerState);
|
||
bindTimerQuickEditorOpen(component, display, runtime);
|
||
node.append(meta, display);
|
||
|
||
if (runtime) registerTimerNode(component, node);
|
||
updateTimerNodes(component, timerState);
|
||
return node;
|
||
}
|
||
|
||
function penaltyTimerNode(component, runtime) {
|
||
const props = component.props || {};
|
||
const timerState = ensureTimerState(component);
|
||
const node = div(`ui-penalty-timer team-${props.teamSide || "neutral"}`);
|
||
node.dataset.runtime = runtime ? "1" : "0";
|
||
|
||
const accent = div("ui-penalty-accent");
|
||
const number = div("ui-penalty-number");
|
||
number.textContent = String(props.playerNumber || "—").replace(/^#/, "");
|
||
|
||
const info = div("ui-penalty-info");
|
||
const topline = div("ui-penalty-topline");
|
||
const team = document.createElement("span");
|
||
team.className = "ui-penalty-team";
|
||
team.textContent = props.team || "";
|
||
const penalty = document.createElement("span");
|
||
penalty.className = "ui-penalty-label";
|
||
penalty.textContent = props.label || "Удаление";
|
||
topline.append(team, penalty);
|
||
|
||
const player = div("ui-penalty-player");
|
||
player.textContent = props.playerName || "Игрок";
|
||
info.append(topline, player);
|
||
|
||
const timeWrap = div("ui-penalty-time-wrap");
|
||
const display = div("ui-penalty-time");
|
||
display.dataset.timerDisplay = "";
|
||
display.textContent = formatTimerValue(component, timerState);
|
||
bindTimerQuickEditorOpen(component, display, runtime);
|
||
const stateDot = document.createElement("span");
|
||
stateDot.className = "ui-penalty-state-dot";
|
||
stateDot.title = "Состояние таймера";
|
||
timeWrap.append(display, stateDot);
|
||
|
||
node.append(accent, number, info, timeWrap);
|
||
|
||
if (props.showProgress !== false) {
|
||
const track = div("ui-penalty-progress-track");
|
||
const bar = div("ui-penalty-progress");
|
||
bar.dataset.timerProgress = "";
|
||
track.appendChild(bar);
|
||
node.appendChild(track);
|
||
}
|
||
|
||
const expired = div("ui-penalty-expired");
|
||
expired.dataset.timerExpired = "";
|
||
expired.textContent = props.expiredText || "Штраф завершён";
|
||
node.appendChild(expired);
|
||
|
||
if (runtime) registerTimerNode(component, node);
|
||
updateTimerNodes(component, timerState);
|
||
return node;
|
||
}
|
||
|
||
|
||
|
||
|
||
function parseHockeyPenaltyPresets(value) {
|
||
return String(value || "")
|
||
.split("|")
|
||
.map((entry) => entry.trim())
|
||
.filter(Boolean)
|
||
.map((entry, index) => {
|
||
const separator = entry.indexOf("=");
|
||
const label = (separator >= 0 ? entry.slice(0, separator) : entry).trim() || `Штраф ${index + 1}`;
|
||
const time = (separator >= 0 ? entry.slice(separator + 1) : entry).trim() || "02:00";
|
||
return {
|
||
id: label,
|
||
label,
|
||
time,
|
||
durationMs: Math.max(0, parseTimerMilliseconds(time, 120000)),
|
||
note: label.includes("+20") ? "+20" : "",
|
||
doubleMinor: label === "2+2"
|
||
};
|
||
});
|
||
}
|
||
|
||
function parseHockeyInfractions(value) {
|
||
const directory = getByPath(state.data, "hockey.penalty_directory");
|
||
if (Array.isArray(directory)) {
|
||
return directory
|
||
.filter((item) => item && item.active !== false)
|
||
.map((item, index) => ({
|
||
id: String(item.code || item.id || `INF_${index + 1}`),
|
||
label: String(item.name || item.name_ru || item.name_en || item.code || `Нарушение ${index + 1}`),
|
||
labelRu: String(item.name_ru || ""),
|
||
labelEn: String(item.name_en || ""),
|
||
defaultPreset: String(item.default_preset || item.defaultPreset || "2"),
|
||
teamPenalty: Boolean(item.team_penalty || item.teamPenalty)
|
||
}));
|
||
}
|
||
return String(value || "")
|
||
.split("|")
|
||
.map((entry) => entry.trim())
|
||
.filter(Boolean)
|
||
.map((entry, index) => {
|
||
const parts = entry.split("=").map((part) => part.trim());
|
||
if (parts.length >= 3) {
|
||
return {
|
||
id: parts[0] || `INF_${index + 1}`,
|
||
label: parts.slice(1, -1).join("=") || parts[0],
|
||
defaultPreset: parts.at(-1) || "2",
|
||
teamPenalty: false
|
||
};
|
||
}
|
||
if (parts.length === 2) {
|
||
return { id: parts[0] || `INF_${index + 1}`, label: parts[1] || parts[0], defaultPreset: "2", teamPenalty: false };
|
||
}
|
||
return { id: `INF_${index + 1}`, label: parts[0] || `Нарушение ${index + 1}`, defaultPreset: "2", teamPenalty: false };
|
||
});
|
||
}
|
||
|
||
function normalizeHockeyPlayer(component, row, side, index) {
|
||
const props = component.props || {};
|
||
const get = (fieldName, fallback = "") => formatValue(getByPath(row, props[fieldName]), fallback);
|
||
return {
|
||
id: String(get("idField", `${side}-${index + 1}`)),
|
||
side,
|
||
number: get("numberField", ""),
|
||
name: get("nameField", `Игрок ${index + 1}`),
|
||
position: get("positionField", ""),
|
||
raw: clone(row)
|
||
};
|
||
}
|
||
|
||
function hockeyRoster(component, side) {
|
||
const props = component.props || {};
|
||
const rows = getByPath(state.data, side === "home" ? props.homePlayersPath : props.awayPlayersPath);
|
||
const limit = Math.max(1, Number(props.rosterLimit) || 40);
|
||
return (Array.isArray(rows) ? rows : [])
|
||
.map((row, index) => normalizeHockeyPlayer(component, row, side, index))
|
||
.sort((left, right) => {
|
||
const leftNumber = String(left.number || "").replace(/^#/, "");
|
||
const rightNumber = String(right.number || "").replace(/^#/, "");
|
||
if (!leftNumber && rightNumber) return 1;
|
||
if (leftNumber && !rightNumber) return -1;
|
||
return leftNumber.localeCompare(rightNumber, undefined, {
|
||
numeric: true,
|
||
sensitivity: "base",
|
||
}) || left.name.localeCompare(right.name);
|
||
})
|
||
.slice(0, limit);
|
||
}
|
||
|
||
function hockeyTeamName(component, side) {
|
||
const path = side === "home" ? component.props?.homeTeamPath : component.props?.awayTeamPath;
|
||
return formatValue(getByPath(state.data, path), side === "home" ? "Хозяева" : "Гости");
|
||
}
|
||
|
||
function hockeyBoardStorageKey(component) {
|
||
return `ui-builder:hockey-board:${state.config.project_name}:${component.action_id}`;
|
||
}
|
||
|
||
function normalizeStoredHockeyEvent(component, event) {
|
||
const preset = parseHockeyPenaltyPresets(component.props?.presets)
|
||
.find((item) => item.id === event.preset);
|
||
const durationMs = Number(event.durationMs ?? preset?.durationMs ?? 0);
|
||
const teamPenalty = Boolean(
|
||
event.teamPenalty
|
||
|| event.team_penalty
|
||
|| event.infraction?.teamPenalty
|
||
|| event.infraction?.team_penalty
|
||
);
|
||
return {
|
||
id: String(event.id || uid()),
|
||
eventTime: String(event.eventTime || ""),
|
||
eventTimeMs: Number(event.eventTimeMs ?? 0),
|
||
createdAt: Number(event.createdAt || Date.now()),
|
||
updatedAt: Number(event.updatedAt || Date.now()),
|
||
side: event.player?.side || event.side || "",
|
||
teamPenalty,
|
||
player: teamPenalty ? null : (event.player ? clone(event.player) : null),
|
||
infraction: event.infraction ? clone(event.infraction) : null,
|
||
preset: event.preset || "",
|
||
durationMs,
|
||
remainingMs: Math.max(0, Number(event.remainingMs ?? durationMs)),
|
||
running: Boolean(event.running) && !Boolean(event.finished),
|
||
finished: Boolean(event.finished),
|
||
readyEmitted: Boolean(event.readyEmitted),
|
||
assignedEmitted: Boolean(event.assignedEmitted),
|
||
lastTimestamp: performance.now(),
|
||
warningAtMs: Math.max(0, Number(event.warningAtMs ?? parseTimerMilliseconds(component.props?.warningAt, 15000))),
|
||
note: event.note || preset?.note || ""
|
||
};
|
||
}
|
||
|
||
function createHockeyBoardState(component) {
|
||
const board = {
|
||
selectedPlayer: null,
|
||
selectedInfraction: null,
|
||
selectedPreset: null,
|
||
selectedEventId: null,
|
||
penalties: [],
|
||
history: [],
|
||
historyOpen: false,
|
||
search: { home: "", away: "", infractions: "" },
|
||
lastPersistAt: 0
|
||
};
|
||
|
||
if (component.props?.persist) {
|
||
try {
|
||
const stored = JSON.parse(localStorage.getItem(hockeyBoardStorageKey(component)) || "null");
|
||
if (stored) {
|
||
board.penalties = (Array.isArray(stored.penalties) ? stored.penalties : [])
|
||
.map((event) => normalizeStoredHockeyEvent(component, event));
|
||
board.history = Array.isArray(stored.history) ? stored.history : [];
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
return board;
|
||
}
|
||
|
||
function ensureHockeyBoardState(component) {
|
||
if (!component?.action_id) return null;
|
||
if (!state.hockeyPenaltyBoards[component.action_id]) {
|
||
state.hockeyPenaltyBoards[component.action_id] = createHockeyBoardState(component);
|
||
}
|
||
return state.hockeyPenaltyBoards[component.action_id];
|
||
}
|
||
|
||
function persistHockeyBoard(component, board, force = false) {
|
||
if (component.props?.persist) {
|
||
const now = Date.now();
|
||
if (force || now - Number(board.lastPersistAt || 0) >= 1000) {
|
||
board.lastPersistAt = now;
|
||
try {
|
||
localStorage.setItem(hockeyBoardStorageKey(component), JSON.stringify({
|
||
penalties: board.penalties,
|
||
history: board.history.slice(0, Math.max(1, Number(component.props?.historyLimit) || 16))
|
||
}));
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
const remoteNow = Date.now();
|
||
const remoteSignature = [
|
||
board.penalties.map((event) => [
|
||
event.id,
|
||
Math.round(Number(event.remainingMs || 0) / 1000),
|
||
Boolean(event.running),
|
||
Boolean(event.finished),
|
||
event.preset || "",
|
||
event.player?.id || "",
|
||
event.infraction?.id || "",
|
||
].join(":" )).join(";"),
|
||
board.history.map((item) => item.id || "").join(","),
|
||
].join("|");
|
||
if (
|
||
force
|
||
|| remoteSignature !== board.lastRemotePersistSignature
|
||
) {
|
||
board.lastRemotePersistAt = remoteNow;
|
||
board.lastRemotePersistSignature = remoteSignature;
|
||
hockeyScheduleTimerSave(force);
|
||
}
|
||
}
|
||
|
||
function addHockeyHistory(component, board, type, event, message) {
|
||
board.history.unshift({
|
||
id: uid(),
|
||
type,
|
||
at: new Date().toISOString(),
|
||
side: event?.player?.side || event?.side || "",
|
||
player: event?.player ? clone(event.player) : null,
|
||
preset: event?.preset || "",
|
||
infraction: event?.infraction ? clone(event.infraction) : null,
|
||
eventTime: event?.eventTime || "",
|
||
message
|
||
});
|
||
board.history = board.history.slice(0, Math.max(1, Number(component.props?.historyLimit) || 16));
|
||
}
|
||
|
||
function hockeyGameClockSnapshot(component) {
|
||
const timerActionId = component.props?.gameTimerActionId || "hockey_game_timer";
|
||
const timerComponent = componentByActionId(timerActionId);
|
||
if (!isTimerComponent(timerComponent)) {
|
||
return { formatted: "—:—", milliseconds: 0 };
|
||
}
|
||
const timerState = ensureTimerState(timerComponent);
|
||
return {
|
||
formatted: formatTimerValue(timerComponent, timerState),
|
||
milliseconds: Math.round(timerState.currentMs)
|
||
};
|
||
}
|
||
|
||
function hockeyEventReady(event) {
|
||
const participantReady = event.teamPenalty
|
||
? ["home", "away"].includes(event.player?.side || event.side)
|
||
: Boolean(event.player);
|
||
return Boolean(participantReady && event.infraction && event.preset && event.durationMs > 0);
|
||
}
|
||
|
||
function hockeyPenaltyContext(event) {
|
||
return {
|
||
penalty_id: event.id,
|
||
event_time: event.eventTime,
|
||
event_time_ms: event.eventTimeMs,
|
||
player: event.player ? clone(event.player) : null,
|
||
team_penalty: Boolean(event.teamPenalty),
|
||
infraction: event.infraction ? clone(event.infraction) : null,
|
||
preset: event.preset,
|
||
remaining_ms: Math.round(event.remainingMs),
|
||
duration_ms: Math.round(event.durationMs),
|
||
running: event.running,
|
||
finished: event.finished,
|
||
ready: hockeyEventReady(event),
|
||
side: event.player?.side || event.side || ""
|
||
};
|
||
}
|
||
|
||
function emitHockeyEventUpdated(component, event, source = "") {
|
||
event.updatedAt = Date.now();
|
||
emitInteraction(component, "penalty_updated", {
|
||
item_id: event.id,
|
||
value: source,
|
||
source,
|
||
...hockeyPenaltyContext(event)
|
||
});
|
||
|
||
if (hockeyEventReady(event) && !event.readyEmitted) {
|
||
event.readyEmitted = true;
|
||
emitInteraction(component, "penalty_ready", {
|
||
item_id: event.id,
|
||
...hockeyPenaltyContext(event)
|
||
});
|
||
}
|
||
|
||
if (hockeyEventReady(event) && !event.assignedEmitted) {
|
||
event.assignedEmitted = true;
|
||
const board = ensureHockeyBoardState(component);
|
||
addHockeyHistory(
|
||
component,
|
||
board,
|
||
"prepared",
|
||
event,
|
||
`${event.eventTime || "—:—"} · ${event.teamPenalty ? "Командное удаление" : event.player?.name || "Игрок не выбран"} · ${event.infraction.label} · ${event.preset}`
|
||
);
|
||
emitInteraction(component, "penalty_assigned", {
|
||
item_id: event.id,
|
||
...hockeyPenaltyContext(event)
|
||
});
|
||
}
|
||
if (hockeyEventReady(event) && state.activeHockeyVmixTimerSteps.size) {
|
||
rebalanceVmixPenaltyTargets({ force: true, hideUnused: false }).catch((error) => console.error("Penalty target rebalance error", error));
|
||
}
|
||
}
|
||
|
||
function createHockeyPenaltyDraft(component, seed = {}, source = "manual") {
|
||
const board = ensureHockeyBoardState(component);
|
||
const clock = hockeyGameClockSnapshot(component);
|
||
const event = {
|
||
id: uid(),
|
||
eventTime: String(seed.eventTime ?? clock.formatted),
|
||
eventTimeMs: Number(seed.eventTimeMs ?? clock.milliseconds),
|
||
createdAt: Date.now(),
|
||
updatedAt: Date.now(),
|
||
side: seed.player?.side || seed.side || "",
|
||
teamPenalty: Boolean(seed.teamPenalty || seed.infraction?.teamPenalty),
|
||
player: seed.teamPenalty || seed.infraction?.teamPenalty
|
||
? null
|
||
: (seed.player ? clone(seed.player) : null),
|
||
infraction: seed.infraction ? clone(seed.infraction) : null,
|
||
preset: "",
|
||
durationMs: 0,
|
||
remainingMs: 0,
|
||
running: false,
|
||
finished: false,
|
||
readyEmitted: false,
|
||
assignedEmitted: false,
|
||
lastTimestamp: performance.now(),
|
||
warningAtMs: Math.max(0, parseTimerMilliseconds(component.props?.warningAt, 15000)),
|
||
note: ""
|
||
};
|
||
|
||
board.penalties.unshift(event);
|
||
board.selectedEventId = event.id;
|
||
|
||
const requestedPreset =
|
||
seed.preset ||
|
||
seed.infraction?.defaultPreset ||
|
||
board.selectedPreset?.id ||
|
||
"";
|
||
|
||
if (requestedPreset) applyPresetToHockeyEvent(component, event, requestedPreset, false);
|
||
if (seed.player || event.player) board.selectedPlayer = null;
|
||
if (event.infraction) board.selectedInfraction = null;
|
||
|
||
addHockeyHistory(component, board, "draft", event, `${event.eventTime || "—:—"} · создана заготовка удаления`);
|
||
emitInteraction(component, "penalty_draft_created", {
|
||
item_id: event.id,
|
||
value: source,
|
||
source,
|
||
...hockeyPenaltyContext(event)
|
||
});
|
||
emitHockeyEventUpdated(component, event, source);
|
||
persistHockeyBoard(component, board, true);
|
||
refreshHockeyBoardNodes(component);
|
||
return event;
|
||
}
|
||
|
||
function findHockeyPenalty(component, eventId) {
|
||
return ensureHockeyBoardState(component).penalties.find((item) => item.id === eventId);
|
||
}
|
||
|
||
function applyPlayerToHockeyEvent(component, event, player, refresh = true) {
|
||
if (!event || !player) return false;
|
||
event.side = player.side;
|
||
event.player = event.teamPenalty ? null : clone(player);
|
||
event.finished = false;
|
||
emitHockeyEventUpdated(component, event, "player");
|
||
if (refresh) {
|
||
persistHockeyBoard(component, ensureHockeyBoardState(component), true);
|
||
refreshHockeyBoardNodes(component);
|
||
}
|
||
return event;
|
||
}
|
||
|
||
function applyInfractionToHockeyEvent(component, event, infraction, refresh = true) {
|
||
if (!event || !infraction) return false;
|
||
event.infraction = clone(infraction);
|
||
event.teamPenalty = Boolean(infraction.teamPenalty);
|
||
event.readyEmitted = false;
|
||
event.assignedEmitted = false;
|
||
if (event.teamPenalty && event.player) {
|
||
event.side = event.player.side || event.side;
|
||
event.player = null;
|
||
}
|
||
event.finished = false;
|
||
if (!event.preset && infraction.defaultPreset) {
|
||
applyPresetToHockeyEvent(component, event, infraction.defaultPreset, false);
|
||
}
|
||
emitHockeyEventUpdated(component, event, "infraction");
|
||
if (refresh) {
|
||
persistHockeyBoard(component, ensureHockeyBoardState(component), true);
|
||
refreshHockeyBoardNodes(component);
|
||
}
|
||
return event;
|
||
}
|
||
|
||
function setHockeyTeamPenalty(component, event, enabled, refresh = true) {
|
||
if (!event || event.running || event.finished) return false;
|
||
event.teamPenalty = Boolean(enabled);
|
||
if (event.teamPenalty && event.player) {
|
||
event.side = event.player.side || event.side;
|
||
event.player = null;
|
||
}
|
||
event.readyEmitted = false;
|
||
event.assignedEmitted = false;
|
||
emitHockeyEventUpdated(component, event, "team-penalty");
|
||
if (refresh) {
|
||
persistHockeyBoard(component, ensureHockeyBoardState(component), true);
|
||
refreshHockeyBoardNodes(component);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function applyPresetToHockeyEvent(component, event, presetId, refresh = true) {
|
||
if (!event) return false;
|
||
const preset = parseHockeyPenaltyPresets(component.props?.presets)
|
||
.find((item) => item.id === presetId);
|
||
if (!preset) return false;
|
||
|
||
event.preset = preset.label;
|
||
event.durationMs = preset.durationMs;
|
||
event.remainingMs = preset.durationMs;
|
||
event.note = preset.note;
|
||
event.running = false;
|
||
event.finished = false;
|
||
event.lastTimestamp = performance.now();
|
||
emitHockeyEventUpdated(component, event, "preset");
|
||
|
||
if (refresh) {
|
||
persistHockeyBoard(component, ensureHockeyBoardState(component), true);
|
||
refreshHockeyBoardNodes(component);
|
||
}
|
||
return event;
|
||
}
|
||
|
||
function selectedHockeyEvent(component) {
|
||
const board = ensureHockeyBoardState(component);
|
||
return board.selectedEventId ? findHockeyPenalty(component, board.selectedEventId) : null;
|
||
}
|
||
|
||
function getOrCreateHockeyDraft(component, seed = {}, source = "") {
|
||
const selected = selectedHockeyEvent(component);
|
||
if (selected && !selected.running && !selected.finished) return selected;
|
||
return createHockeyPenaltyDraft(component, seed, source);
|
||
}
|
||
|
||
function chooseHockeyPlayer(component, player, dropped = false) {
|
||
const board = ensureHockeyBoardState(component);
|
||
const selectedEvent = selectedHockeyEvent(component);
|
||
|
||
if (selectedEvent && !selectedEvent.running && !selectedEvent.finished) {
|
||
applyPlayerToHockeyEvent(component, selectedEvent, player);
|
||
} else if (board.selectedInfraction) {
|
||
createHockeyPenaltyDraft(
|
||
component,
|
||
{
|
||
player,
|
||
infraction: board.selectedInfraction,
|
||
preset: board.selectedInfraction.defaultPreset
|
||
},
|
||
dropped ? "drop-player-on-infraction" : "player-after-infraction"
|
||
);
|
||
board.selectedInfraction = null;
|
||
} else {
|
||
board.selectedPlayer = clone(player);
|
||
refreshHockeyBoardNodes(component);
|
||
}
|
||
|
||
emitInteraction(component, dropped ? "player_dropped" : "player_selected", {
|
||
item_id: player.id,
|
||
value: player.name,
|
||
player: clone(player),
|
||
side: player.side
|
||
});
|
||
}
|
||
|
||
function chooseHockeyInfraction(component, infraction, dropped = false) {
|
||
const board = ensureHockeyBoardState(component);
|
||
const selectedEvent = selectedHockeyEvent(component);
|
||
|
||
if (selectedEvent && !selectedEvent.running && !selectedEvent.finished) {
|
||
applyInfractionToHockeyEvent(component, selectedEvent, infraction);
|
||
} else if (board.selectedPlayer) {
|
||
createHockeyPenaltyDraft(
|
||
component,
|
||
{
|
||
player: board.selectedPlayer,
|
||
infraction,
|
||
preset: infraction.defaultPreset
|
||
},
|
||
dropped ? "drop-infraction-on-player" : "infraction-after-player"
|
||
);
|
||
board.selectedPlayer = null;
|
||
} else {
|
||
board.selectedInfraction = clone(infraction);
|
||
refreshHockeyBoardNodes(component);
|
||
}
|
||
|
||
emitInteraction(component, "infraction_selected", {
|
||
item_id: infraction.id,
|
||
value: infraction.label,
|
||
infraction: clone(infraction)
|
||
});
|
||
}
|
||
|
||
function chooseHockeyPreset(component, preset) {
|
||
const board = ensureHockeyBoardState(component);
|
||
const selectedEvent = selectedHockeyEvent(component);
|
||
|
||
if (selectedEvent && !selectedEvent.running && !selectedEvent.finished) {
|
||
applyPresetToHockeyEvent(component, selectedEvent, preset.id);
|
||
return;
|
||
}
|
||
|
||
const seed = {
|
||
player: board.selectedPlayer,
|
||
infraction: board.selectedInfraction,
|
||
preset: preset.id
|
||
};
|
||
createHockeyPenaltyDraft(component, seed, "preset");
|
||
board.selectedPlayer = null;
|
||
board.selectedInfraction = null;
|
||
board.selectedPreset = null;
|
||
}
|
||
|
||
function controlHockeyPenalty(component, eventId, command, rawValue = "") {
|
||
const board = ensureHockeyBoardState(component);
|
||
const event = board.penalties.find((item) => item.id === eventId);
|
||
if (!event) return false;
|
||
const now = performance.now();
|
||
|
||
if (command === "toggle") {
|
||
return controlHockeyPenalty(component, eventId, event.running ? "pause" : "start", rawValue);
|
||
}
|
||
|
||
if (command === "start") {
|
||
if (!hockeyEventReady(event)) {
|
||
toast(event.teamPenalty
|
||
? "Выберите команду, нарушение и длительность"
|
||
: "Заполните игрока, нарушение и длительность", true);
|
||
return false;
|
||
}
|
||
event.running = true;
|
||
event.finished = false;
|
||
event.lastTimestamp = now;
|
||
emitInteraction(component, "penalty_started", {
|
||
item_id: event.id,
|
||
...hockeyPenaltyContext(event)
|
||
});
|
||
} else if (command === "pause") {
|
||
event.running = false;
|
||
emitInteraction(component, "penalty_paused", {
|
||
item_id: event.id,
|
||
...hockeyPenaltyContext(event)
|
||
});
|
||
} else if (command === "reset") {
|
||
event.remainingMs = event.durationMs;
|
||
event.running = false;
|
||
event.finished = false;
|
||
event.lastTimestamp = now;
|
||
} else if (command === "finish") {
|
||
event.remainingMs = 0;
|
||
event.running = false;
|
||
event.finished = true;
|
||
addHockeyHistory(
|
||
component,
|
||
board,
|
||
"finished",
|
||
event,
|
||
`${event.teamPenalty ? "Командное удаление" : event.player?.name || "Событие"}: штраф завершён`
|
||
);
|
||
emitInteraction(component, "penalty_finished", {
|
||
item_id: event.id,
|
||
...hockeyPenaltyContext(event)
|
||
});
|
||
state.vmixPenaltyMirrors.delete(penaltyMirrorKey(component, event));
|
||
board.penalties = board.penalties.filter((item) => item.id !== event.id);
|
||
const side = String(event.player?.side || event.side || "").toLowerCase();
|
||
const remainingOnSide = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length;
|
||
if (board.selectedEventId === event.id) board.selectedEventId = null;
|
||
persistHockeyBoard(component, board, true);
|
||
refreshHockeyBoardNodes(component);
|
||
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true })
|
||
.catch((error) => console.error("Penalty target finish rebalance error", error))
|
||
.finally(() => fireConfiguredTimerFinishActions("penalty", { side, component, event, remaining_on_side: remainingOnSide }));
|
||
return true;
|
||
} else if (command === "set_time") {
|
||
event.remainingMs = Math.max(0, parseTimerMilliseconds(rawValue, event.remainingMs));
|
||
event.durationMs = Math.max(event.durationMs, event.remainingMs);
|
||
event.finished = event.remainingMs <= 0;
|
||
event.lastTimestamp = now;
|
||
emitHockeyEventUpdated(component, event, "time");
|
||
} else if (command === "set_event_time") {
|
||
event.eventTime = String(rawValue || event.eventTime);
|
||
emitHockeyEventUpdated(component, event, "event-time");
|
||
} else if (command === "remove") {
|
||
board.penalties = board.penalties.filter((item) => item.id !== eventId);
|
||
if (board.selectedEventId === eventId) board.selectedEventId = null;
|
||
addHockeyHistory(
|
||
component,
|
||
board,
|
||
"removed",
|
||
event,
|
||
`${event.eventTime || "—:—"} · событие удаления убрано`
|
||
);
|
||
persistHockeyBoard(component, board, true);
|
||
refreshHockeyBoardNodes(component);
|
||
emitInteraction(component, "penalty_removed", {
|
||
item_id: event.id,
|
||
...hockeyPenaltyContext(event)
|
||
});
|
||
state.vmixPenaltyMirrors.delete(penaltyMirrorKey(component, event));
|
||
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true }).catch((error) => console.error("Penalty target remove rebalance error", error));
|
||
return true;
|
||
}
|
||
|
||
persistHockeyBoard(component, board, true);
|
||
refreshHockeyBoardNodes(component);
|
||
return true;
|
||
}
|
||
|
||
function formatHockeyPenaltyTime(milliseconds) {
|
||
const parts = timerParts(Math.max(0, milliseconds));
|
||
return `${parts.totalMinutes}:${padTimer(parts.seconds)}`;
|
||
}
|
||
|
||
function openHockeyEventEditor(component, eventId) {
|
||
const event = findHockeyPenalty(component, eventId);
|
||
if (!event) return;
|
||
|
||
showModal("Быстрая правка события", `
|
||
<div class="hpd-time-editor">
|
||
<strong>${escapeHtml(event.teamPenalty ? "Командное удаление" : event.player ? `#${event.player.number} ${event.player.name}` : "Заготовка удаления")}</strong>
|
||
<span>${escapeHtml(event.infraction?.label || "Нарушение пока не выбрано")}</span>
|
||
<label>Время события<input id="hpdEventTimeValue" type="text" value="${escapeHtml(event.eventTime || "")}" placeholder="18:42"></label>
|
||
<label>Оставшееся время<input id="hpdRemainingValue" type="text" value="${escapeHtml(editableTimerValue(event.remainingMs))}" placeholder="02:00"></label>
|
||
<button id="hpdApplyEvent" type="button" class="btn btn-accent">Применить</button>
|
||
</div>
|
||
`);
|
||
|
||
const eventInput = document.getElementById("hpdEventTimeValue");
|
||
const remainingInput = document.getElementById("hpdRemainingValue");
|
||
const apply = () => {
|
||
controlHockeyPenalty(component, eventId, "set_event_time", eventInput?.value || event.eventTime);
|
||
if (remainingInput?.value) {
|
||
controlHockeyPenalty(component, eventId, "set_time", remainingInput.value);
|
||
}
|
||
closeModal();
|
||
};
|
||
|
||
document.getElementById("hpdApplyEvent")?.addEventListener("click", apply);
|
||
[eventInput, remainingInput].forEach((input) => input?.addEventListener("keydown", (keyboardEvent) => {
|
||
if (keyboardEvent.key !== "Enter") return;
|
||
keyboardEvent.preventDefault();
|
||
apply();
|
||
}));
|
||
eventInput?.select();
|
||
}
|
||
|
||
function updateHockeyPenaltyBoards(now) {
|
||
state.config.components
|
||
.filter((component) => component.type === "hockey_penalty_dashboard" && !component.hidden)
|
||
.forEach((component) => {
|
||
const board = ensureHockeyBoardState(component);
|
||
const completedEvents = [];
|
||
let changed = false;
|
||
|
||
board.penalties.forEach((event) => {
|
||
if (!event.running || event.finished || !hockeyEventReady(event)) {
|
||
event.lastTimestamp = now;
|
||
return;
|
||
}
|
||
|
||
const delta = Math.max(0, now - Number(event.lastTimestamp || now));
|
||
event.remainingMs = Math.max(0, event.remainingMs - delta);
|
||
event.lastTimestamp = now;
|
||
changed = true;
|
||
if (state.vmixPenaltyMirrors.has(penaltyMirrorKey(component, event))) {
|
||
pushVmixPenaltyMirror(component, event).catch(() => {});
|
||
}
|
||
|
||
if (event.remainingMs <= 0) {
|
||
event.running = false;
|
||
event.finished = true;
|
||
state.vmixPenaltyMirrors.delete(penaltyMirrorKey(component, event));
|
||
completedEvents.push(event);
|
||
addHockeyHistory(
|
||
component,
|
||
board,
|
||
"finished",
|
||
event,
|
||
`${event.player?.name || "Событие"}: штраф завершён`
|
||
);
|
||
emitInteraction(component, "penalty_finished", {
|
||
item_id: event.id,
|
||
...hockeyPenaltyContext(event)
|
||
});
|
||
}
|
||
});
|
||
|
||
if (completedEvents.length) {
|
||
const completed = new Set(completedEvents.map((event) => event.id));
|
||
board.penalties = board.penalties.filter((event) => !completed.has(event.id));
|
||
if (completed.has(board.selectedEventId)) board.selectedEventId = null;
|
||
persistHockeyBoard(component, board, true);
|
||
refreshHockeyBoardNodes(component);
|
||
const finishBySide = new Map();
|
||
completedEvents.forEach((event) => {
|
||
const side = String(event.player?.side || event.side || "").toLowerCase();
|
||
if (side && !finishBySide.has(side)) finishBySide.set(side, event);
|
||
});
|
||
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true })
|
||
.catch((error) => console.error("Penalty target ticker rebalance error", error))
|
||
.finally(() => {
|
||
finishBySide.forEach((event, side) => {
|
||
const remainingOnSide = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length;
|
||
fireConfiguredTimerFinishActions("penalty", { side, component, event, remaining_on_side: remainingOnSide });
|
||
});
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (changed) updateHockeyBoardTickerNodes(component);
|
||
persistHockeyBoard(component, board);
|
||
});
|
||
}
|
||
|
||
function registerHockeyBoardNode(component, node) {
|
||
const nodes = state.hockeyPenaltyBoardNodes.get(component.action_id) || new Set();
|
||
nodes.add(node);
|
||
state.hockeyPenaltyBoardNodes.set(component.action_id, nodes);
|
||
}
|
||
|
||
function refreshHockeyBoardNodes(component) {
|
||
const nodes = state.hockeyPenaltyBoardNodes.get(component.action_id) || [];
|
||
nodes.forEach((node) => renderHockeyPenaltyDashboard(node, component, true));
|
||
}
|
||
|
||
function updateHockeyBoardTickerNodes(component) {
|
||
const board = ensureHockeyBoardState(component);
|
||
const clock = hockeyGameClockSnapshot(component);
|
||
const nodes = state.hockeyPenaltyBoardNodes.get(component.action_id) || [];
|
||
|
||
nodes.forEach((node) => {
|
||
node.querySelectorAll("[data-game-clock]").forEach((clockNode) => {
|
||
clockNode.textContent = clock.formatted;
|
||
});
|
||
|
||
board.penalties.forEach((event) => {
|
||
const card = node.querySelector(`[data-penalty-id="${CSS.escape(event.id)}"]`);
|
||
if (!card) return;
|
||
|
||
const time = card.querySelector("[data-penalty-time]");
|
||
const progress = card.querySelector("[data-penalty-progress]");
|
||
const toggle = card.querySelector("[data-penalty-command='toggle']");
|
||
|
||
if (time) time.textContent = event.preset ? formatHockeyPenaltyTime(event.remainingMs) : "—:—";
|
||
if (progress) {
|
||
progress.style.width = `${event.durationMs > 0
|
||
? clamp(event.remainingMs / event.durationMs * 100, 0, 100)
|
||
: 0}%`;
|
||
}
|
||
if (toggle) toggle.textContent = event.running ? "Ⅱ" : "▶";
|
||
|
||
card.classList.toggle("is-running", event.running);
|
||
card.classList.toggle("is-finished", event.finished);
|
||
card.classList.toggle("is-ready", hockeyEventReady(event) && !event.running && !event.finished);
|
||
card.classList.toggle(
|
||
"is-warning",
|
||
!event.finished &&
|
||
event.warningAtMs > 0 &&
|
||
event.remainingMs > 0 &&
|
||
event.remainingMs <= event.warningAtMs
|
||
);
|
||
});
|
||
|
||
const count = node.querySelector("[data-active-count]");
|
||
if (count) {
|
||
count.textContent = String(board.penalties.filter((event) => event.running && !event.finished).length);
|
||
}
|
||
});
|
||
}
|
||
|
||
function setHockeyDragData(event, type, value) {
|
||
const mime = `application/x-ui-builder-hockey-${type}`;
|
||
if (type === "player") state.hockeyDragPlayer = clone(value);
|
||
if (type === "infraction") state.hockeyDragInfraction = clone(value);
|
||
if (type === "preset") state.hockeyDragPreset = clone(value);
|
||
|
||
event.dataTransfer.effectAllowed = "copy";
|
||
event.dataTransfer.setData(mime, JSON.stringify(value));
|
||
event.dataTransfer.setData("text/plain", value.label || value.name || value.id || type);
|
||
}
|
||
|
||
function clearHockeyDragData(type = "") {
|
||
if (!type || type === "player") state.hockeyDragPlayer = null;
|
||
if (!type || type === "infraction") state.hockeyDragInfraction = null;
|
||
if (!type || type === "preset") state.hockeyDragPreset = null;
|
||
}
|
||
|
||
function readHockeyDragData(event, type) {
|
||
try {
|
||
const raw = event.dataTransfer.getData(`application/x-ui-builder-hockey-${type}`);
|
||
return raw ? JSON.parse(raw) : null;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function readAnyHockeyDragData(event) {
|
||
return {
|
||
player: state.hockeyDragPlayer || readHockeyDragData(event, "player"),
|
||
infraction: state.hockeyDragInfraction || readHockeyDragData(event, "infraction"),
|
||
preset: state.hockeyDragPreset || readHockeyDragData(event, "preset")
|
||
};
|
||
}
|
||
|
||
function applyDropToHockeyEvent(component, eventId, event) {
|
||
const target = findHockeyPenalty(component, eventId);
|
||
if (!target || target.running || target.finished) return false;
|
||
|
||
const payload = readAnyHockeyDragData(event);
|
||
let changed = false;
|
||
if (payload.player) {
|
||
applyPlayerToHockeyEvent(component, target, payload.player, false);
|
||
changed = true;
|
||
}
|
||
if (payload.infraction) {
|
||
applyInfractionToHockeyEvent(component, target, payload.infraction, false);
|
||
changed = true;
|
||
}
|
||
if (payload.preset) {
|
||
applyPresetToHockeyEvent(component, target, payload.preset.id, false);
|
||
changed = true;
|
||
}
|
||
|
||
if (changed) {
|
||
persistHockeyBoard(component, ensureHockeyBoardState(component), true);
|
||
refreshHockeyBoardNodes(component);
|
||
}
|
||
return changed;
|
||
}
|
||
|
||
function hockeyPlayerRow(component, player, runtime) {
|
||
const row = div("hpd-player-row");
|
||
row.draggable = Boolean(runtime);
|
||
row.dataset.playerId = player.id;
|
||
row.innerHTML = `
|
||
<span class="hpd-drag-handle">⋮⋮</span>
|
||
<strong class="hpd-player-number">${escapeHtml(player.number || "—")}</strong>
|
||
<span class="hpd-player-name">${escapeHtml(player.name)}</span>
|
||
<small>${escapeHtml(player.position || "")}</small>
|
||
`;
|
||
|
||
if (runtime) {
|
||
row.addEventListener("click", () => chooseHockeyPlayer(component, player));
|
||
row.addEventListener("dblclick", () => {
|
||
createHockeyPenaltyDraft(
|
||
component,
|
||
{ player, preset: component.props?.defaultPreset || "2" },
|
||
"player-double-click"
|
||
);
|
||
});
|
||
row.addEventListener("dragstart", (dragEvent) => {
|
||
state.hockeyDragPlayer = clone(player);
|
||
row.classList.add("is-dragging");
|
||
setHockeyDragData(dragEvent, "player", player);
|
||
});
|
||
row.addEventListener("dragend", () => {
|
||
row.classList.remove("is-dragging");
|
||
clearHockeyDragData("player");
|
||
});
|
||
|
||
row.addEventListener("dragover", (dragEvent) => {
|
||
const infraction = readHockeyDragData(dragEvent, "infraction");
|
||
if (!infraction) return;
|
||
dragEvent.preventDefault();
|
||
row.classList.add("is-drop-target");
|
||
});
|
||
row.addEventListener("dragleave", () => row.classList.remove("is-drop-target"));
|
||
row.addEventListener("drop", (dropEvent) => {
|
||
dropEvent.preventDefault();
|
||
row.classList.remove("is-drop-target");
|
||
const infraction = readHockeyDragData(dropEvent, "infraction");
|
||
if (!infraction) return;
|
||
createHockeyPenaltyDraft(
|
||
component,
|
||
{
|
||
player,
|
||
infraction,
|
||
preset: infraction.defaultPreset
|
||
},
|
||
"infraction-on-player"
|
||
);
|
||
});
|
||
}
|
||
return row;
|
||
}
|
||
|
||
function hockeyRosterPanel(component, side, runtime) {
|
||
const panel = div(`hpd-roster team-${side}`);
|
||
const players = hockeyRoster(component, side);
|
||
const header = div("hpd-roster-header");
|
||
header.innerHTML = `
|
||
<div>
|
||
<span>${side === "home" ? "ЛЕВАЯ КОМАНДА" : "ПРАВАЯ КОМАНДА"}</span>
|
||
<strong>${escapeHtml(hockeyTeamName(component, side))}</strong>
|
||
${getByPath(state.data, `hockey.selected_game.${side}.coach`) ? `<small class="hpd-roster-coach">Тренер: ${escapeHtml(getByPath(state.data, `hockey.selected_game.${side}.coach`))}</small>` : ""}
|
||
</div>
|
||
<em>${players.length}</em>
|
||
`;
|
||
panel.appendChild(header);
|
||
|
||
if (component.props?.showSearch !== false) {
|
||
const search = document.createElement("input");
|
||
search.type = "search";
|
||
search.className = "hpd-roster-search";
|
||
search.placeholder = "Поиск по номеру или фамилии";
|
||
search.value = ensureHockeyBoardState(component).search[side] || "";
|
||
search.disabled = !runtime;
|
||
search.addEventListener("input", () => {
|
||
const query = search.value.trim().toLowerCase();
|
||
ensureHockeyBoardState(component).search[side] = query;
|
||
panel.querySelectorAll(".hpd-player-row").forEach((row) => {
|
||
row.classList.toggle("hidden", !row.textContent.toLowerCase().includes(query));
|
||
});
|
||
});
|
||
panel.appendChild(search);
|
||
}
|
||
|
||
const list = div("hpd-roster-list");
|
||
players.forEach((player) => list.appendChild(hockeyPlayerRow(component, player, runtime)));
|
||
panel.appendChild(list);
|
||
return panel;
|
||
}
|
||
|
||
function hockeyCreateEventButton(component, runtime) {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "hpd-create-event";
|
||
button.disabled = !runtime;
|
||
button.innerHTML = `
|
||
<span>+</span>
|
||
<div>
|
||
<strong>Новое удаление</strong>
|
||
<small>Момент матча сохранится автоматически</small>
|
||
</div>
|
||
`;
|
||
if (runtime) {
|
||
button.addEventListener("click", () => {
|
||
createHockeyPenaltyDraft(component, {}, "new-event-button");
|
||
});
|
||
}
|
||
return button;
|
||
}
|
||
|
||
function hockeyPresetBar(component, runtime) {
|
||
const wrap = div("hpd-penalty-tools");
|
||
const bar = div("hpd-presets");
|
||
|
||
parseHockeyPenaltyPresets(component.props?.presets).forEach((preset) => {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.draggable = Boolean(runtime);
|
||
button.disabled = !runtime;
|
||
button.innerHTML = `<strong>${escapeHtml(preset.label)}</strong><small>${escapeHtml(preset.time)}</small>`;
|
||
|
||
if (runtime) {
|
||
button.addEventListener("click", () => chooseHockeyPreset(component, preset));
|
||
button.addEventListener("dragstart", (dragEvent) => {
|
||
setHockeyDragData(dragEvent, "preset", preset);
|
||
button.classList.add("is-dragging");
|
||
});
|
||
button.addEventListener("dragend", () => {
|
||
button.classList.remove("is-dragging");
|
||
clearHockeyDragData("preset");
|
||
});
|
||
|
||
button.addEventListener("dragover", (dragEvent) => {
|
||
const payload = readAnyHockeyDragData(dragEvent);
|
||
if (!payload.player && !payload.infraction) return;
|
||
dragEvent.preventDefault();
|
||
dragEvent.dataTransfer.dropEffect = "copy";
|
||
button.classList.add("is-over");
|
||
});
|
||
button.addEventListener("dragleave", () => button.classList.remove("is-over"));
|
||
button.addEventListener("drop", (dropEvent) => {
|
||
dropEvent.preventDefault();
|
||
dropEvent.stopPropagation();
|
||
button.classList.remove("is-over");
|
||
const payload = readAnyHockeyDragData(dropEvent);
|
||
createHockeyPenaltyDraft(
|
||
component,
|
||
{
|
||
player: payload.player,
|
||
infraction: payload.infraction,
|
||
preset: preset.id
|
||
},
|
||
"drop-on-preset"
|
||
);
|
||
clearHockeyDragData();
|
||
});
|
||
}
|
||
bar.appendChild(button);
|
||
});
|
||
|
||
wrap.append(bar, hockeyInfractionDropdown(component, runtime));
|
||
return wrap;
|
||
}
|
||
|
||
function hockeyInfractionRow(component, infraction, runtime) {
|
||
const board = ensureHockeyBoardState(component);
|
||
const row = div("hpd-infraction-row");
|
||
row.draggable = Boolean(runtime);
|
||
row.classList.toggle("is-selected", board.selectedInfraction?.id === infraction.id);
|
||
row.innerHTML = `
|
||
<span class="hpd-infraction-drag">⋮⋮</span>
|
||
<strong>${escapeHtml(infraction.label)}</strong>
|
||
${infraction.teamPenalty ? `<em>КОМ</em>` : ""}
|
||
<small>${escapeHtml(infraction.defaultPreset)}</small>
|
||
`;
|
||
|
||
if (runtime) {
|
||
row.addEventListener("click", () => chooseHockeyInfraction(component, infraction));
|
||
row.addEventListener("dragstart", (dragEvent) => {
|
||
setHockeyDragData(dragEvent, "infraction", infraction);
|
||
row.classList.add("is-dragging");
|
||
});
|
||
row.addEventListener("dragend", () => {
|
||
row.classList.remove("is-dragging");
|
||
clearHockeyDragData("infraction");
|
||
});
|
||
|
||
// A player can be dropped directly on a violation.
|
||
row.addEventListener("dragover", (dragEvent) => {
|
||
const player = state.hockeyDragPlayer || readHockeyDragData(dragEvent, "player");
|
||
if (!player) return;
|
||
dragEvent.preventDefault();
|
||
dragEvent.dataTransfer.dropEffect = "copy";
|
||
row.classList.add("is-drop-target");
|
||
});
|
||
row.addEventListener("dragleave", () => row.classList.remove("is-drop-target"));
|
||
row.addEventListener("drop", (dropEvent) => {
|
||
dropEvent.preventDefault();
|
||
dropEvent.stopPropagation();
|
||
row.classList.remove("is-drop-target");
|
||
const player = state.hockeyDragPlayer || readHockeyDragData(dropEvent, "player");
|
||
if (!player) return;
|
||
createHockeyPenaltyDraft(
|
||
component,
|
||
{
|
||
player,
|
||
infraction,
|
||
preset: infraction.defaultPreset
|
||
},
|
||
"player-on-infraction"
|
||
);
|
||
clearHockeyDragData();
|
||
});
|
||
}
|
||
return row;
|
||
}
|
||
|
||
function hockeyInfractionDropdown(component, runtime) {
|
||
const board = ensureHockeyBoardState(component);
|
||
const details = document.createElement("details");
|
||
details.className = "hpd-infraction-dropdown";
|
||
|
||
const summary = document.createElement("summary");
|
||
summary.innerHTML = `
|
||
<span>Нарушения</span>
|
||
<strong>${parseHockeyInfractions(component.props?.infractions).length}</strong>
|
||
<i>▾</i>
|
||
`;
|
||
details.appendChild(summary);
|
||
|
||
const popup = div("hpd-infraction-popup");
|
||
const search = document.createElement("input");
|
||
search.type = "search";
|
||
search.className = "hpd-infraction-search";
|
||
search.placeholder = "Поиск нарушения";
|
||
search.value = board.search.infractions || "";
|
||
search.disabled = !runtime;
|
||
popup.appendChild(search);
|
||
|
||
const hint = div("hpd-infraction-hint");
|
||
hint.textContent = "Нарушение можно перетащить прямо на карточку удаления";
|
||
popup.appendChild(hint);
|
||
|
||
const list = div("hpd-infraction-list");
|
||
parseHockeyInfractions(component.props?.infractions).forEach((infraction) => {
|
||
list.appendChild(hockeyInfractionRow(component, infraction, runtime));
|
||
});
|
||
popup.appendChild(list);
|
||
details.appendChild(popup);
|
||
|
||
search.addEventListener("input", () => {
|
||
const query = search.value.trim().toLowerCase();
|
||
board.search.infractions = query;
|
||
list.querySelectorAll(".hpd-infraction-row").forEach((row) => {
|
||
row.classList.toggle("hidden", !row.textContent.toLowerCase().includes(query));
|
||
});
|
||
});
|
||
|
||
details.addEventListener("toggle", () => {
|
||
if (details.open) requestAnimationFrame(() => search.focus());
|
||
});
|
||
return details;
|
||
}
|
||
|
||
function hockeyEventDropField(component, event, kind, runtime) {
|
||
const field = div(`hpd-event-field field-${kind}`);
|
||
const hasPlayer = kind === "player" && event.player;
|
||
const hasInfraction = kind === "infraction" && event.infraction;
|
||
const hasPreset = kind === "preset" && event.preset;
|
||
|
||
if (hasPlayer) {
|
||
field.innerHTML = `
|
||
<small>Кому</small>
|
||
<strong>#${escapeHtml(event.player.number || "—")} ${escapeHtml(event.player.name)}</strong>
|
||
<span>${escapeHtml(event.player.position || "")}</span>
|
||
`;
|
||
} else if (hasInfraction) {
|
||
field.innerHTML = `
|
||
<small>За что</small>
|
||
<strong>${escapeHtml(event.infraction.label)}</strong>
|
||
<span>${escapeHtml(event.infraction.id)}</span>
|
||
`;
|
||
} else if (hasPreset) {
|
||
field.innerHTML = `
|
||
<small>Штраф</small>
|
||
<strong>${escapeHtml(event.preset)}</strong>
|
||
<span>${escapeHtml(formatHockeyPenaltyTime(event.durationMs))}</span>
|
||
`;
|
||
} else {
|
||
const labels = {
|
||
player: ["Кому", "Перетащите игрока"],
|
||
infraction: ["За что", "Перетащите нарушение"],
|
||
preset: ["Штраф", "Перетащите длительность"]
|
||
};
|
||
field.classList.add("is-empty");
|
||
field.innerHTML = `<small>${labels[kind][0]}</small><strong>${labels[kind][1]}</strong>`;
|
||
}
|
||
|
||
if (runtime && !event.running && !event.finished) {
|
||
field.addEventListener("dragover", (dragEvent) => {
|
||
const payload = readAnyHockeyDragData(dragEvent);
|
||
const matches =
|
||
(kind === "player" && payload.player) ||
|
||
(kind === "infraction" && payload.infraction) ||
|
||
(kind === "preset" && payload.preset);
|
||
if (!matches) return;
|
||
dragEvent.preventDefault();
|
||
field.classList.add("is-over");
|
||
});
|
||
field.addEventListener("dragleave", () => field.classList.remove("is-over"));
|
||
field.addEventListener("drop", (dropEvent) => {
|
||
dropEvent.preventDefault();
|
||
field.classList.remove("is-over");
|
||
applyDropToHockeyEvent(component, event.id, dropEvent);
|
||
});
|
||
}
|
||
return field;
|
||
}
|
||
|
||
async function hockeySelectPenaltyForPreview(component, event) {
|
||
const board = ensureHockeyBoardState(component);
|
||
board.selectedEventId = event.id;
|
||
const side = String(event.player?.side || event.side || "").toLowerCase();
|
||
const teamId = side === "home"
|
||
? String(getByPath(state.data, "hockey.selected_game.home.id") || getByPath(state.data, "hockey.selected_game.home.external_id") || "")
|
||
: side === "away"
|
||
? String(getByPath(state.data, "hockey.selected_game.away.id") || getByPath(state.data, "hockey.selected_game.away.external_id") || "")
|
||
: "";
|
||
const detail = {
|
||
item_id: String(event.external_id || event.id || ""),
|
||
value: String(event.external_id || event.id || ""),
|
||
penalty_id: String(event.external_id || event.id || ""),
|
||
player_id: String(event.player?.id || ""),
|
||
team_id: teamId,
|
||
infraction_id: String(event.infraction?.id || ""),
|
||
preset_id: String(event.preset || ""),
|
||
side,
|
||
event_time: String(event.eventTime || ""),
|
||
remaining_ms: Math.max(0, Number(event.remainingMs || 0)),
|
||
duration_ms: Math.max(0, Number(event.durationMs || 0)),
|
||
status: event.finished ? "finished" : event.running ? "running" : hockeyEventReady(event) ? "ready" : "draft",
|
||
selected: true,
|
||
};
|
||
window.UIBuilderRuntime?.patchData?.({ hockey: { selected_penalty: detail } }, { render: false });
|
||
refreshHockeyBoardNodes(component);
|
||
|
||
const gameId = hockeyTimerSelectedGameId();
|
||
if (!gameId) {
|
||
emitInteraction(component, "penalty_selected", detail);
|
||
return detail;
|
||
}
|
||
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_penalty_id: detail.penalty_id,
|
||
selected_penalty_player_id: detail.player_id,
|
||
selected_penalty_team_id: detail.team_id,
|
||
selected_penalty_infraction_id: detail.infraction_id,
|
||
selected_penalty_preset_id: detail.preset_id,
|
||
selected_penalty_side: detail.side,
|
||
selected_penalty_event_time: detail.event_time,
|
||
selected_penalty_status: detail.status,
|
||
selected_penalty_remaining_ms: String(detail.remaining_ms),
|
||
selected_penalty_duration_ms: String(detail.duration_ms),
|
||
selected_event_id: detail.penalty_id,
|
||
},
|
||
context: {
|
||
game_id: gameId,
|
||
device_id: currentRuntimeVmixDeviceId(),
|
||
session_token: currentRuntimeHockeySessionToken(),
|
||
},
|
||
}),
|
||
});
|
||
if (!response.ok) {
|
||
let payload = {}; try { payload = await response.json(); } catch (_) {}
|
||
throw new Error(errorDetailText(payload.detail, `HTTP ${response.status}`));
|
||
}
|
||
emitInteraction(component, "penalty_selected", detail);
|
||
} catch (error) {
|
||
console.error("Selected penalty Mapping context error", error);
|
||
toast(`Подсмотр удаления: ${error.message}`, true);
|
||
return null;
|
||
}
|
||
return detail;
|
||
}
|
||
|
||
function hockeyPenaltyCard(component, event, runtime) {
|
||
const board = ensureHockeyBoardState(component);
|
||
const side = event.player?.side || event.side || "neutral";
|
||
const card = div(`hpd-team-penalty-card team-${side}`);
|
||
card.dataset.penaltyId = event.id;
|
||
card.classList.toggle("is-selected", board.selectedEventId === event.id);
|
||
card.classList.toggle("is-running", event.running);
|
||
card.classList.toggle("is-finished", event.finished);
|
||
card.classList.toggle("is-ready", hockeyEventReady(event) && !event.running && !event.finished);
|
||
|
||
const playerText = event.teamPenalty
|
||
? (["home", "away"].includes(side) ? escapeHtml(hockeyTeamName(component, side)) : "Выберите команду")
|
||
: event.player
|
||
? `#${escapeHtml(event.player.number || "—")} ${escapeHtml(event.player.name)}`
|
||
: "Перетащите игрока";
|
||
const infractionText = event.infraction
|
||
? escapeHtml(event.infraction.label)
|
||
: "Перетащите сюда нарушение";
|
||
const presetText = event.preset
|
||
? `${escapeHtml(event.preset)} · ${escapeHtml(formatHockeyPenaltyTime(event.durationMs))}`
|
||
: "Выберите длительность";
|
||
|
||
card.innerHTML = `
|
||
<div class="hpd-team-card-accent"></div>
|
||
<div class="hpd-team-card-main">
|
||
<div class="hpd-team-card-head">
|
||
<div class="hpd-team-card-player ${event.player || event.teamPenalty ? "" : "is-empty"}">
|
||
<small>${event.teamPenalty ? "Командное удаление" : side === "home" ? "Хозяева" : side === "away" ? "Гости" : "Без команды"}</small>
|
||
<strong>${playerText}</strong>
|
||
</div>
|
||
<button type="button" class="hpd-team-card-clock" title="Двойной клик — изменить время">
|
||
<small>${escapeHtml(event.eventTime || "—:—")}</small>
|
||
<strong data-penalty-time>${event.preset ? escapeHtml(formatHockeyPenaltyTime(event.remainingMs)) : "—:—"}</strong>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="hpd-team-card-details">
|
||
<div class="hpd-team-card-infraction ${event.infraction ? "" : "is-empty"}" data-card-drop="infraction">
|
||
<small>Нарушение</small>
|
||
<strong>${infractionText}</strong>
|
||
</div>
|
||
<div class="hpd-team-card-preset ${event.preset ? "" : "is-empty"}" data-card-drop="preset">
|
||
<small>Штраф</small>
|
||
<strong>${presetText}</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<label class="hpd-team-penalty-toggle">
|
||
<input type="checkbox" data-team-penalty-toggle ${event.teamPenalty ? "checked" : ""} ${event.running || event.finished ? "disabled" : ""}>
|
||
<span>Командное удаление — без игрока</span>
|
||
</label>
|
||
|
||
<div class="hpd-team-card-footer">
|
||
<span class="hpd-team-card-status">${
|
||
board.selectedEventId === event.id
|
||
? "Выбрано · подсмотр"
|
||
: event.finished
|
||
? "Завершено"
|
||
: event.running
|
||
? "Идёт"
|
||
: hockeyEventReady(event)
|
||
? "Готово · пауза"
|
||
: "Заготовка"
|
||
}</span>
|
||
<div class="hpd-event-actions">
|
||
<button type="button" data-penalty-command="toggle" ${hockeyEventReady(event) ? "" : "disabled"}>${event.running ? "Ⅱ" : "▶"}</button>
|
||
<button type="button" data-penalty-command="reset" ${event.preset ? "" : "disabled"}>↺</button>
|
||
<button type="button" data-penalty-command="finish">✓</button>
|
||
<button type="button" data-penalty-command="remove">×</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<span class="hpd-penalty-progress-track">
|
||
<i data-penalty-progress style="width:${
|
||
event.durationMs > 0
|
||
? clamp(event.remainingMs / event.durationMs * 100, 0, 100)
|
||
: 0
|
||
}%"></i>
|
||
</span>
|
||
`;
|
||
|
||
if (runtime) {
|
||
card.addEventListener("click", (clickEvent) => {
|
||
if (clickEvent.target.closest("button, input, label")) return;
|
||
hockeySelectPenaltyForPreview(component, event).catch(() => {});
|
||
});
|
||
|
||
const allowDrop = (dragEvent) => {
|
||
if (event.running || event.finished) return false;
|
||
const payload = readAnyHockeyDragData(dragEvent);
|
||
if (!payload.player && !payload.infraction && !payload.preset) return false;
|
||
dragEvent.preventDefault();
|
||
dragEvent.dataTransfer.dropEffect = "copy";
|
||
card.classList.add("is-over");
|
||
return true;
|
||
};
|
||
|
||
card.addEventListener("dragover", allowDrop);
|
||
card.addEventListener("dragleave", (dragEvent) => {
|
||
if (!card.contains(dragEvent.relatedTarget)) card.classList.remove("is-over");
|
||
});
|
||
card.addEventListener("drop", (dropEvent) => {
|
||
dropEvent.preventDefault();
|
||
dropEvent.stopPropagation();
|
||
card.classList.remove("is-over");
|
||
applyDropToHockeyEvent(component, event.id, dropEvent);
|
||
clearHockeyDragData();
|
||
});
|
||
|
||
card.querySelector(".hpd-team-card-clock")?.addEventListener("dblclick", (doubleEvent) => {
|
||
doubleEvent.stopPropagation();
|
||
openHockeyEventEditor(component, event.id);
|
||
});
|
||
|
||
card.querySelectorAll("[data-penalty-command]").forEach((button) => {
|
||
button.addEventListener("click", (clickEvent) => {
|
||
clickEvent.stopPropagation();
|
||
controlHockeyPenalty(component, event.id, button.dataset.penaltyCommand);
|
||
});
|
||
});
|
||
card.querySelector(".hpd-team-penalty-toggle")?.addEventListener("click", (clickEvent) => {
|
||
clickEvent.stopPropagation();
|
||
});
|
||
card.querySelector("[data-team-penalty-toggle]")?.addEventListener("change", (changeEvent) => {
|
||
changeEvent.stopPropagation();
|
||
setHockeyTeamPenalty(component, event, changeEvent.currentTarget.checked);
|
||
});
|
||
} else {
|
||
card.querySelectorAll("button, input").forEach((control) => control.disabled = true);
|
||
}
|
||
return card;
|
||
}
|
||
|
||
function createHockeyDraftFromDrop(component, side, dropEvent, source) {
|
||
const payload = readAnyHockeyDragData(dropEvent);
|
||
const player = payload.player ? { ...payload.player } : null;
|
||
if (player && side && side !== "neutral") player.side = side;
|
||
|
||
const created = createHockeyPenaltyDraft(
|
||
component,
|
||
{
|
||
side: player?.side || side || "",
|
||
player,
|
||
infraction: payload.infraction,
|
||
preset: payload.preset?.id
|
||
},
|
||
source
|
||
);
|
||
clearHockeyDragData();
|
||
return created;
|
||
}
|
||
|
||
function bindHockeyColumnDrop(component, node, side, runtime) {
|
||
if (!runtime) return;
|
||
node.addEventListener("dragover", (dragEvent) => {
|
||
const payload = readAnyHockeyDragData(dragEvent);
|
||
if (!payload.player && !payload.infraction && !payload.preset) return;
|
||
dragEvent.preventDefault();
|
||
dragEvent.dataTransfer.dropEffect = "copy";
|
||
node.classList.add("is-over");
|
||
});
|
||
node.addEventListener("dragleave", (dragEvent) => {
|
||
if (!node.contains(dragEvent.relatedTarget)) node.classList.remove("is-over");
|
||
});
|
||
node.addEventListener("drop", (dropEvent) => {
|
||
if (dropEvent.target.closest("[data-penalty-id]")) return;
|
||
dropEvent.preventDefault();
|
||
dropEvent.stopPropagation();
|
||
node.classList.remove("is-over");
|
||
createHockeyDraftFromDrop(component, side, dropEvent, `drop-on-${side}-column`);
|
||
});
|
||
}
|
||
|
||
function hockeyUnassignedEvents(component, runtime) {
|
||
const board = ensureHockeyBoardState(component);
|
||
const events = board.penalties.filter((event) => !(event.player?.side || event.side));
|
||
const panel = div("hpd-unassigned-events");
|
||
panel.classList.toggle("is-empty", !events.length);
|
||
panel.innerHTML = `
|
||
<div class="hpd-unassigned-title">
|
||
<strong>Заготовки без команды</strong>
|
||
<span>${events.length}</span>
|
||
</div>
|
||
`;
|
||
|
||
const list = div("hpd-unassigned-list");
|
||
events
|
||
.sort((a, b) => Number(b.createdAt) - Number(a.createdAt))
|
||
.forEach((event) => list.appendChild(hockeyPenaltyCard(component, event, runtime)));
|
||
|
||
if (!events.length) {
|
||
list.innerHTML = `<div class="hpd-unassigned-empty">Перетащите сюда игрока, нарушение или длительность</div>`;
|
||
}
|
||
|
||
bindHockeyColumnDrop(component, list, "", runtime);
|
||
panel.appendChild(list);
|
||
return panel;
|
||
}
|
||
|
||
function hockeyPrematchSequenceOptions(selectedId = "") {
|
||
const options = [["", "— не запускать сценарий —"]].concat(
|
||
(state.config.shortcut_sequences || []).map((sequence) => [String(sequence.id), `${sequence.combo ? `${sequence.combo} · ` : ""}${sequence.name}`])
|
||
);
|
||
return triggerSelectOptions(options, selectedId);
|
||
}
|
||
|
||
async function hockeyPersistPrematchButtons(buttons, groups = state.config.prematch_groups, selectors = state.config.quick_panel_selectors) {
|
||
const normalizedGroups = normalizePrematchGroups(groups);
|
||
const validGroups = new Set(normalizedGroups.map((group) => group.id));
|
||
const normalizedButtons = normalizePrematchButtons(buttons).map((button) => ({
|
||
...button,
|
||
group_id: validGroups.has(button.group_id) ? button.group_id : "",
|
||
}));
|
||
const validButtons = new Set(normalizedButtons.map((button) => button.id));
|
||
const normalizedSelectors = normalizeQuickPanelSelectors(selectors).map((selector) => ({
|
||
...selector,
|
||
group_id: validGroups.has(selector.group_id) ? selector.group_id : "",
|
||
button_id: validButtons.has(selector.button_id) ? selector.button_id : "",
|
||
}));
|
||
state.config.prematch_groups = normalizedGroups;
|
||
state.config.prematch_buttons = normalizedButtons;
|
||
state.config.quick_panel_selectors = normalizedSelectors;
|
||
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 }),
|
||
});
|
||
let payload = {};
|
||
try { payload = await response.json(); } catch (_) {}
|
||
if (!response.ok) throw new Error(errorDetailText(payload.detail, `HTTP ${response.status}`));
|
||
if (payload.config && typeof payload.config === "object") state.config = payload.config;
|
||
else {
|
||
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);
|
||
}
|
||
ensureConfig();
|
||
toast("Нижняя панель кнопок сохранена");
|
||
renderRuntime();
|
||
return true;
|
||
} catch (error) {
|
||
toast(`Не удалось сохранить панель кнопок: ${error.message}`, true);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function hockeyPrematchGroupOptions(groups, selectedId = "") {
|
||
return triggerSelectOptions(
|
||
[["", "Без вкладки"], ...groups.map((group) => [group.id, group.label])],
|
||
selectedId
|
||
);
|
||
}
|
||
|
||
function quickPanelSelectorOptionsText(selector) {
|
||
return normalizeQuickPanelSelectors([selector])[0].options
|
||
.map((option) => `${option.value}=${option.label}`)
|
||
.join("; ");
|
||
}
|
||
|
||
function parseQuickPanelSelectorOptions(text) {
|
||
return String(text || "").split(/[;\n]+/).map((part) => part.trim()).filter(Boolean).slice(0, 16).map((part, index) => {
|
||
const splitAt = part.indexOf("=");
|
||
if (splitAt < 0) {
|
||
const value = part.slice(0, 64);
|
||
return { value, label: value.slice(0, 80) };
|
||
}
|
||
const value = part.slice(0, splitAt).trim().slice(0, 64) || String(index + 1);
|
||
const label = part.slice(splitAt + 1).trim().slice(0, 80) || value;
|
||
return { value, label };
|
||
});
|
||
}
|
||
|
||
function hockeyQuickPanelButtonOptions(buttons, selectedId = "") {
|
||
return triggerSelectOptions([["", "— отдельный элемент —"], ...buttons.map((button) => [button.id, button.label])], selectedId);
|
||
}
|
||
|
||
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 dragButtonId = "";
|
||
let dragSelectorId = "";
|
||
let activeEditorGroupId = String(state.quickPanelActiveTab || "");
|
||
if (activeEditorGroupId === "__ungrouped") activeEditorGroupId = "";
|
||
if (activeEditorGroupId && !draftGroups.some((group) => group.id === activeEditorGroupId)) activeEditorGroupId = draftGroups[0]?.id || "";
|
||
const collapsedEditorGroups = new Set();
|
||
|
||
const groupButtons = (groupId) => draft
|
||
.filter((button) => String(button.group_id || "") === String(groupId || ""))
|
||
.sort((a, b) => Number(a.sort_order) - Number(b.sort_order) || a.label.localeCompare(b.label, "ru"));
|
||
|
||
const renumberGroup = (groupId, orderedIds = null) => {
|
||
const items = groupButtons(groupId);
|
||
const byId = new Map(items.map((item) => [item.id, item]));
|
||
const ordered = Array.isArray(orderedIds)
|
||
? orderedIds.map((id) => byId.get(id)).filter(Boolean)
|
||
: items;
|
||
ordered.forEach((item, index) => { item.sort_order = index * 10; });
|
||
};
|
||
|
||
const moveButton = (buttonId, targetGroupId, beforeId = "") => {
|
||
const button = draft.find((item) => item.id === buttonId);
|
||
if (!button) return;
|
||
const previousGroup = String(button.group_id || "");
|
||
button.group_id = String(targetGroupId || "");
|
||
const ids = groupButtons(button.group_id).filter((item) => item.id !== button.id).map((item) => item.id);
|
||
const beforeIndex = beforeId ? ids.indexOf(beforeId) : -1;
|
||
if (beforeIndex >= 0) ids.splice(beforeIndex, 0, button.id);
|
||
else ids.push(button.id);
|
||
renumberGroup(button.group_id, ids);
|
||
if (previousGroup !== button.group_id) renumberGroup(previousGroup);
|
||
};
|
||
|
||
const renderButtonRow = (button, index) => `
|
||
<article class="prematch-editor-row" draggable="true" data-prematch-row="${escapeHtml(button.id)}" data-prematch-drag-button="${escapeHtml(button.id)}">
|
||
<span class="prematch-drag-handle" title="Перетащить">⋮⋮</span>
|
||
<span class="shortcut-target-index">${index + 1}</span>
|
||
<label class="shortcut-inline-check"><input type="checkbox" data-prematch-field="enabled" data-prematch-id="${escapeHtml(button.id)}" ${button.enabled ? "checked" : ""}> Активна</label>
|
||
<label>Название<input type="text" maxlength="80" data-prematch-field="label" data-prematch-id="${escapeHtml(button.id)}" value="${escapeHtml(button.label)}"></label>
|
||
<label>ID / условие<input type="text" maxlength="48" data-prematch-field="id" data-prematch-id="${escapeHtml(button.id)}" value="${escapeHtml(button.id)}"></label>
|
||
<label>Вкладка<select data-prematch-field="group_id" data-prematch-id="${escapeHtml(button.id)}">${hockeyPrematchGroupOptions(draftGroups, button.group_id)}</select></label>
|
||
<label>Режим<select data-prematch-field="mode" data-prematch-id="${escapeHtml(button.id)}">${triggerSelectOptions([["action","Кнопка действия"],["toggle","Переключатель состояния"]], button.mode)}</select></label>
|
||
<label>Shortcut Sequence<select data-prematch-field="sequence_id" data-prematch-id="${escapeHtml(button.id)}">${hockeyPrematchSequenceOptions(button.sequence_id)}</select></label>
|
||
<label class="prematch-editor-description">Описание<input type="text" maxlength="300" data-prematch-field="description" data-prematch-id="${escapeHtml(button.id)}" value="${escapeHtml(button.description)}"></label>
|
||
<button type="button" class="icon-btn danger" data-prematch-delete="${escapeHtml(button.id)}" title="Удалить">×</button>
|
||
</article>`;
|
||
|
||
const renderSelectorRow = (selector, index) => `
|
||
<article class="prematch-editor-selector-row" draggable="true" data-quick-selector-row="${escapeHtml(selector.id)}" data-quick-selector-drag="${escapeHtml(selector.id)}">
|
||
<span class="quick-selector-symbol" title="Перетащить переключатель">⋮⋮</span>
|
||
<span class="shortcut-target-index">${index + 1}</span>
|
||
<label class="shortcut-inline-check"><input type="checkbox" data-quick-selector-field="enabled" data-quick-selector-id="${escapeHtml(selector.id)}" ${selector.enabled ? "checked" : ""}> Активен</label>
|
||
<label>Название<input type="text" maxlength="80" data-quick-selector-field="label" data-quick-selector-id="${escapeHtml(selector.id)}" value="${escapeHtml(selector.label)}"></label>
|
||
<label>ID для SQL<input type="text" maxlength="48" data-quick-selector-field="id" data-quick-selector-id="${escapeHtml(selector.id)}" value="${escapeHtml(selector.id)}"></label>
|
||
<label>Вкладка<select data-quick-selector-field="group_id" data-quick-selector-id="${escapeHtml(selector.id)}">${hockeyPrematchGroupOptions(draftGroups, selector.group_id)}</select></label>
|
||
<label>Рядом с кнопкой<select data-quick-selector-field="button_id" data-quick-selector-id="${escapeHtml(selector.id)}">${hockeyQuickPanelButtonOptions(draft, selector.button_id)}</select></label>
|
||
<label>Вид<select data-quick-selector-field="style" data-quick-selector-id="${escapeHtml(selector.id)}">${triggerSelectOptions([["segments","Маленькие кнопки"],["select","Выпадающий список"]], selector.style)}</select></label>
|
||
<label class="quick-selector-options-field">Варианты <small>значение=подпись; ...</small><input type="text" data-quick-selector-options="${escapeHtml(selector.id)}" value="${escapeHtml(quickPanelSelectorOptionsText(selector))}" placeholder="all=МАТЧ; 1=1; 2=2; 3=3"></label>
|
||
<label>По умолчанию<input type="text" maxlength="64" data-quick-selector-field="default_value" data-quick-selector-id="${escapeHtml(selector.id)}" value="${escapeHtml(selector.default_value)}"></label>
|
||
<button type="button" class="icon-btn danger" data-quick-selector-delete="${escapeHtml(selector.id)}" title="Удалить переключатель">×</button>
|
||
</article>`;
|
||
|
||
const renderGroup = (group, groupIndex) => {
|
||
const groupId = group?.id || "";
|
||
const buttons = groupButtons(groupId);
|
||
const selectors = draftSelectors.filter((selector) => String(selector.group_id || "") === String(groupId || ""));
|
||
const isUngrouped = !groupId;
|
||
return `
|
||
<section class="prematch-editor-group ${collapsedEditorGroups.has(groupId) ? "is-collapsed" : ""} ${String(activeEditorGroupId) === String(groupId) ? "is-editor-active" : ""}" data-prematch-group="${escapeHtml(groupId)}">
|
||
<header class="prematch-editor-group-head" data-prematch-group-activate="${escapeHtml(groupId)}">
|
||
<button type="button" class="prematch-group-collapse" data-prematch-group-collapse="${escapeHtml(groupId)}" title="Свернуть / раскрыть">${collapsedEditorGroups.has(groupId) ? "▸" : "▾"}</button>
|
||
<span class="prematch-group-mark">${isUngrouped ? "—" : "▦"}</span>
|
||
${isUngrouped
|
||
? `<strong>Без вкладки</strong><small>Кнопки, не назначенные во вкладку</small>`
|
||
: `<label>Название вкладки<input type="text" maxlength="80" data-prematch-group-label="${escapeHtml(groupId)}" value="${escapeHtml(group.label)}"></label>
|
||
<button type="button" class="mini-btn" data-prematch-group-up="${escapeHtml(groupId)}" ${groupIndex <= 0 ? "disabled" : ""}>↑</button>
|
||
<button type="button" class="mini-btn" data-prematch-group-down="${escapeHtml(groupId)}" ${groupIndex >= draftGroups.length - 1 ? "disabled" : ""}>↓</button>
|
||
<button type="button" class="icon-btn danger" data-prematch-group-delete="${escapeHtml(groupId)}" title="Удалить вкладку">×</button>`}
|
||
</header>
|
||
<div class="prematch-editor-group-drop" data-prematch-drop-group="${escapeHtml(groupId)}">
|
||
${buttons.length ? buttons.map(renderButtonRow).join("") : `<div class="prematch-group-empty">Перетащите кнопку в эту вкладку</div>`}
|
||
${selectors.length ? `<div class="quick-selector-editor-stack"><div class="quick-selector-editor-title">Параметры / переключатели</div>${selectors.map(renderSelectorRow).join("")}</div>` : ""}
|
||
</div>
|
||
</section>`;
|
||
};
|
||
|
||
const renderEditor = () => {
|
||
draftGroups = normalizePrematchGroups(draftGroups);
|
||
const validGroups = new Set(draftGroups.map((group) => 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));
|
||
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 : "" }));
|
||
showSettingsModal("Нижняя панель · кнопки", `
|
||
<div class="prematch-editor">
|
||
<div class="prematch-editor-head">
|
||
<p>Создавайте вкладки, кнопки и маленькие параметры рядом с кнопками. Значение параметра попадает в Mapping/SQL до запуска Shortcut Sequence.</p>
|
||
<div class="prematch-editor-head-actions">
|
||
<button type="button" class="btn" data-prematch-add-group>+ Вкладка</button>
|
||
<button type="button" class="btn" data-quick-selector-add>+ Переключатель</button>
|
||
<button type="button" class="btn btn-accent" data-prematch-add>+ Кнопка</button>
|
||
</div>
|
||
</div>
|
||
<div class="prematch-editor-list">
|
||
${renderGroup(null, -1)}
|
||
${draftGroups.map((group, index) => renderGroup(group, index)).join("")}
|
||
</div>
|
||
<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>
|
||
</div>
|
||
</div>
|
||
`, { className: "modal-prematch-full" });
|
||
|
||
document.querySelector("[data-prematch-add-group]")?.addEventListener("click", () => {
|
||
if (draftGroups.length >= 24) return toast("Можно создать до 24 вкладок", true);
|
||
const newGroup = { id: `group_${draftGroups.length + 1}`, label: `Вкладка ${draftGroups.length + 1}`, sort_order: draftGroups.length * 10, enabled: true };
|
||
draftGroups.push(newGroup);
|
||
activeEditorGroupId = newGroup.id;
|
||
collapsedEditorGroups.delete(newGroup.id);
|
||
renderEditor();
|
||
});
|
||
document.querySelector("[data-quick-selector-add]")?.addEventListener("click", () => {
|
||
if (draftSelectors.length >= 64) return toast("Можно создать до 64 переключателей", true);
|
||
const targetGroup = draftGroups.some((group) => group.id === activeEditorGroupId) ? activeEditorGroupId : "";
|
||
const targetButton = draft.find((button) => String(button.group_id || "") === String(targetGroup || ""))?.id || "";
|
||
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-prematch-add]")?.addEventListener("click", () => {
|
||
if (draft.length >= 64) return toast("Можно создать до 64 кнопок", true);
|
||
const targetGroup = draftGroups.some((group) => group.id === activeEditorGroupId) ? activeEditorGroupId : "";
|
||
draft.push({ id: `prematch_${draft.length + 1}`, label: `Кнопка ${draft.length + 1}`, description: "", mode: "action", sequence_id: "", group_id: targetGroup, sort_order: groupButtons(targetGroup).length * 10, enabled: true });
|
||
renderEditor();
|
||
});
|
||
|
||
document.querySelectorAll("[data-prematch-group-collapse]").forEach((button) => button.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const id = String(button.dataset.prematchGroupCollapse || "");
|
||
if (collapsedEditorGroups.has(id)) collapsedEditorGroups.delete(id);
|
||
else collapsedEditorGroups.add(id);
|
||
activeEditorGroupId = id;
|
||
renderEditor();
|
||
}));
|
||
document.querySelectorAll("[data-prematch-group-activate]").forEach((head) => head.addEventListener("click", (event) => {
|
||
if (event.target.closest("button,input,select,label")) return;
|
||
activeEditorGroupId = String(head.dataset.prematchGroupActivate || "");
|
||
renderEditor();
|
||
}));
|
||
|
||
document.querySelectorAll("[data-prematch-group-label]").forEach((control) => control.addEventListener("input", () => {
|
||
const group = draftGroups.find((item) => item.id === control.dataset.prematchGroupLabel);
|
||
if (group) group.label = control.value;
|
||
}));
|
||
document.querySelectorAll("[data-prematch-group-up]").forEach((button) => button.addEventListener("click", () => {
|
||
const index = draftGroups.findIndex((item) => item.id === button.dataset.prematchGroupUp);
|
||
if (index > 0) [draftGroups[index - 1], draftGroups[index]] = [draftGroups[index], draftGroups[index - 1]];
|
||
draftGroups.forEach((item, i) => { item.sort_order = i * 10; });
|
||
renderEditor();
|
||
}));
|
||
document.querySelectorAll("[data-prematch-group-down]").forEach((button) => button.addEventListener("click", () => {
|
||
const index = draftGroups.findIndex((item) => item.id === button.dataset.prematchGroupDown);
|
||
if (index >= 0 && index < draftGroups.length - 1) [draftGroups[index + 1], draftGroups[index]] = [draftGroups[index], draftGroups[index + 1]];
|
||
draftGroups.forEach((item, i) => { item.sort_order = i * 10; });
|
||
renderEditor();
|
||
}));
|
||
document.querySelectorAll("[data-prematch-group-delete]").forEach((button) => button.addEventListener("click", () => {
|
||
const id = button.dataset.prematchGroupDelete;
|
||
const group = draftGroups.find((item) => item.id === id);
|
||
if (!group || !window.confirm(`Удалить вкладку «${group.label}»? Кнопки останутся без вкладки.`)) return;
|
||
draft.forEach((item) => { if (item.group_id === id) item.group_id = ""; });
|
||
draftSelectors.forEach((item) => { if (item.group_id === id) item.group_id = ""; });
|
||
draftGroups = draftGroups.filter((item) => item.id !== id);
|
||
renderEditor();
|
||
}));
|
||
|
||
document.querySelectorAll("[data-prematch-field]").forEach((control) => {
|
||
const eventName = control.type === "checkbox" || control.tagName === "SELECT" ? "change" : "input";
|
||
control.addEventListener(eventName, () => {
|
||
const button = draft.find((item) => item.id === control.dataset.prematchId);
|
||
if (!button) return;
|
||
const field = control.dataset.prematchField;
|
||
const previousGroup = button.group_id;
|
||
button[field] = control.type === "checkbox" ? control.checked : control.value;
|
||
if (field === "group_id") {
|
||
renumberGroup(previousGroup);
|
||
renumberGroup(button.group_id);
|
||
activeEditorGroupId = String(button.group_id || "");
|
||
collapsedEditorGroups.delete(activeEditorGroupId);
|
||
renderEditor();
|
||
}
|
||
});
|
||
});
|
||
document.querySelectorAll("[data-prematch-delete]").forEach((button) => button.addEventListener("click", () => {
|
||
const deletedId = button.dataset.prematchDelete;
|
||
draft = draft.filter((item) => item.id !== deletedId);
|
||
draftSelectors.forEach((selector) => { if (selector.button_id === deletedId) selector.button_id = ""; });
|
||
renderEditor();
|
||
}));
|
||
|
||
document.querySelectorAll("[data-quick-selector-field]").forEach((control) => {
|
||
const eventName = control.type === "checkbox" || control.tagName === "SELECT" ? "change" : "input";
|
||
control.addEventListener(eventName, () => {
|
||
const selector = draftSelectors.find((item) => item.id === control.dataset.quickSelectorId);
|
||
if (!selector) return;
|
||
const field = control.dataset.quickSelectorField;
|
||
selector[field] = control.type === "checkbox" ? control.checked : control.value;
|
||
if (field === "group_id") {
|
||
activeEditorGroupId = String(selector.group_id || "");
|
||
collapsedEditorGroups.delete(activeEditorGroupId);
|
||
renderEditor();
|
||
}
|
||
});
|
||
});
|
||
document.querySelectorAll("[data-quick-selector-options]").forEach((control) => control.addEventListener("change", () => {
|
||
const selector = draftSelectors.find((item) => item.id === control.dataset.quickSelectorOptions);
|
||
if (!selector) return;
|
||
const options = parseQuickPanelSelectorOptions(control.value);
|
||
if (!options.length) return toast("Добавьте хотя бы один вариант переключателя", true);
|
||
selector.options = options;
|
||
if (!options.some((option) => option.value === selector.default_value)) selector.default_value = options[0].value;
|
||
renderEditor();
|
||
}));
|
||
document.querySelectorAll("[data-quick-selector-delete]").forEach((button) => button.addEventListener("click", () => {
|
||
draftSelectors = draftSelectors.filter((item) => item.id !== button.dataset.quickSelectorDelete);
|
||
renderEditor();
|
||
}));
|
||
|
||
document.querySelectorAll("[data-quick-selector-drag]").forEach((row) => {
|
||
row.addEventListener("dragstart", (event) => {
|
||
dragSelectorId = String(row.dataset.quickSelectorDrag || "");
|
||
dragButtonId = "";
|
||
event.dataTransfer.effectAllowed = "move";
|
||
event.dataTransfer.setData("text/plain", `selector:${dragSelectorId}`);
|
||
row.classList.add("is-dragging");
|
||
});
|
||
row.addEventListener("dragend", () => { dragSelectorId = ""; row.classList.remove("is-dragging"); });
|
||
});
|
||
|
||
document.querySelectorAll("[data-prematch-drag-button]").forEach((row) => {
|
||
row.addEventListener("dragstart", (event) => {
|
||
dragButtonId = row.dataset.prematchDragButton || "";
|
||
event.dataTransfer.effectAllowed = "move";
|
||
event.dataTransfer.setData("text/plain", dragButtonId);
|
||
row.classList.add("is-dragging");
|
||
});
|
||
row.addEventListener("dragend", () => { dragButtonId = ""; row.classList.remove("is-dragging"); });
|
||
});
|
||
document.querySelectorAll("[data-prematch-drop-group]").forEach((zone) => {
|
||
zone.addEventListener("dragover", (event) => {
|
||
if (!dragButtonId && !dragSelectorId) return;
|
||
event.preventDefault();
|
||
event.dataTransfer.dropEffect = "move";
|
||
zone.classList.add("is-over");
|
||
});
|
||
zone.addEventListener("dragleave", (event) => { if (!zone.contains(event.relatedTarget)) zone.classList.remove("is-over"); });
|
||
zone.addEventListener("drop", (event) => {
|
||
if (!dragButtonId && !dragSelectorId) return;
|
||
event.preventDefault();
|
||
zone.classList.remove("is-over");
|
||
const targetGroupId = String(zone.dataset.prematchDropGroup || "");
|
||
if (dragSelectorId) {
|
||
const selector = draftSelectors.find((item) => item.id === dragSelectorId);
|
||
if (selector) {
|
||
selector.group_id = targetGroupId;
|
||
const linked = draft.find((button) => button.id === selector.button_id);
|
||
if (linked && String(linked.group_id || "") !== targetGroupId) selector.button_id = "";
|
||
selector.sort_order = draftSelectors.filter((item) => item.id !== selector.id && String(item.group_id || "") === targetGroupId).length * 10;
|
||
}
|
||
activeEditorGroupId = targetGroupId;
|
||
collapsedEditorGroups.delete(targetGroupId);
|
||
dragSelectorId = "";
|
||
} else {
|
||
const targetRow = event.target.closest("[data-prematch-row]");
|
||
const beforeId = targetRow?.dataset?.prematchRow || "";
|
||
moveButton(dragButtonId, targetGroupId, beforeId === dragButtonId ? "" : beforeId);
|
||
activeEditorGroupId = targetGroupId;
|
||
collapsedEditorGroups.delete(targetGroupId);
|
||
dragButtonId = "";
|
||
}
|
||
renderEditor();
|
||
});
|
||
});
|
||
|
||
document.querySelector("[data-prematch-cancel]")?.addEventListener("click", closeModal);
|
||
document.querySelector("[data-prematch-save]")?.addEventListener("click", async () => {
|
||
document.querySelectorAll("[data-prematch-row]").forEach((row) => {
|
||
const button = draft.find((item) => item.id === row.dataset.prematchRow);
|
||
if (!button) return;
|
||
row.querySelectorAll("[data-prematch-field]").forEach((control) => {
|
||
const field = control.dataset.prematchField;
|
||
button[field] = control.type === "checkbox" ? control.checked : control.value;
|
||
});
|
||
});
|
||
document.querySelectorAll("[data-quick-selector-options]").forEach((control) => {
|
||
const selector = draftSelectors.find((item) => item.id === control.dataset.quickSelectorOptions);
|
||
if (!selector) return;
|
||
const options = parseQuickPanelSelectorOptions(control.value);
|
||
if (options.length) selector.options = options;
|
||
});
|
||
draftGroups.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();
|
||
});
|
||
};
|
||
renderEditor();
|
||
}
|
||
|
||
function hockeyQuickPanelTabs() {
|
||
const groups = normalizePrematchGroups(state.config.prematch_groups).filter((group) => group.enabled !== false);
|
||
const validGroups = new Set(groups.map((group) => group.id));
|
||
const buttons = normalizePrematchButtons(state.config.prematch_buttons).filter((button) => button.enabled !== false && (!button.group_id || validGroups.has(button.group_id)));
|
||
const selectors = normalizeQuickPanelSelectors(state.config.quick_panel_selectors).filter((selector) => selector.enabled !== false && (!selector.group_id || validGroups.has(selector.group_id)));
|
||
const tabs = groups.map((group) => ({ id: group.id, label: group.label }));
|
||
if (buttons.some((button) => !button.group_id) || selectors.some((selector) => !selector.group_id)) tabs.unshift({ id: "__ungrouped", label: "Без вкладки" });
|
||
return { tabs, buttons, selectors };
|
||
}
|
||
|
||
function hockeyMatchRuntimeValues() {
|
||
const values = getByPath(state.data, "hockey.game_control.values");
|
||
return values && typeof values === "object" ? values : {};
|
||
}
|
||
|
||
function quickPanelSelectorValue(selector) {
|
||
const values = hockeyMatchRuntimeValues();
|
||
const stored = String(values[`panel.${selector.id}.value`] ?? "");
|
||
if (selector.options.some((option) => option.value === stored)) return stored;
|
||
return selector.default_value || selector.options[0]?.value || "";
|
||
}
|
||
|
||
async function hockeyRefreshQuickPanelMapping() {
|
||
const deviceId = currentRuntimeVmixDeviceId();
|
||
if (!deviceId) return false;
|
||
try {
|
||
const response = await fetch(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/apply-mapping?only_changed=true`, { method: "POST", cache: "no-store", credentials: "same-origin" });
|
||
if (!response.ok) return false;
|
||
return true;
|
||
} catch (_) { return false; }
|
||
}
|
||
|
||
async function hockeySetMatchValues(patch, { refreshMapping = true } = {}) {
|
||
const gameId = hockeyTimerSelectedGameId();
|
||
if (!gameId) throw new Error("Сначала выберите матч");
|
||
const values = {};
|
||
Object.entries(patch || {}).forEach(([key, value]) => {
|
||
const safeKey = String(key || "").trim();
|
||
if (safeKey) values[safeKey] = String(value ?? "");
|
||
});
|
||
if (!Object.keys(values).length) return null;
|
||
const language = hockeyGameControlLanguage();
|
||
const payload = await hockeyUpdateGameControl(gameId, "/control/values", { method: "PUT", body: JSON.stringify({ values, language }) });
|
||
if (refreshMapping) await hockeyRefreshQuickPanelMapping();
|
||
return payload;
|
||
}
|
||
|
||
function quickPanelSelectorMarkup(selector) {
|
||
const current = quickPanelSelectorValue(selector);
|
||
const tooltip = selector.description || selector.label;
|
||
if (selector.style === "select") {
|
||
return `<label class="quick-command-selector is-select" data-tooltip="${escapeHtml(tooltip)}"><span>${escapeHtml(selector.label)}</span><select data-quick-selector-runtime="${escapeHtml(selector.id)}">${selector.options.map((option) => `<option value="${escapeHtml(option.value)}" ${option.value === current ? "selected" : ""}>${escapeHtml(option.label)}</option>`).join("")}</select></label>`;
|
||
}
|
||
return `<div class="quick-command-selector is-segments" data-tooltip="${escapeHtml(tooltip)}"><span>${escapeHtml(selector.label)}</span><div>${selector.options.map((option) => `<button type="button" data-quick-selector-runtime="${escapeHtml(selector.id)}" data-quick-selector-value="${escapeHtml(option.value)}" class="${option.value === current ? "active" : ""}">${escapeHtml(option.label)}</button>`).join("")}</div></div>`;
|
||
}
|
||
|
||
async function hockeyApplyQuickSelector(selector, value, label = "") {
|
||
const option = selector.options.find((item) => item.value === value);
|
||
const displayLabel = String(label || option?.label || value);
|
||
await hockeySetMatchValues({
|
||
[`panel.${selector.id}.value`]: value,
|
||
[`panel.${selector.id}.label`]: displayLabel,
|
||
}, { refreshMapping: true });
|
||
}
|
||
|
||
async function hockeyCommitQuickPanelButtonContext(button, attachedSelectors) {
|
||
const patch = {
|
||
"panel.last_button.id": button.id,
|
||
"panel.last_button.label": button.label,
|
||
};
|
||
for (const selector of attachedSelectors) {
|
||
const value = quickPanelSelectorValue(selector);
|
||
const option = selector.options.find((item) => item.value === value);
|
||
patch[`panel.${selector.id}.value`] = value;
|
||
patch[`panel.${selector.id}.label`] = option?.label || value;
|
||
patch[`panel.last_button.${selector.id}`] = value;
|
||
}
|
||
await hockeySetMatchValues(patch, { refreshMapping: true });
|
||
}
|
||
|
||
function renderHockeyQuickCommandDock() {
|
||
if (!el.runtimeButtonDock) return;
|
||
const isHockeyProject = (state.config.components || []).some((component) => String(component?.type || "").startsWith("hockey_"));
|
||
if (!isHockeyProject) {
|
||
el.runtimeButtonDock.classList.add("hidden");
|
||
el.runtimeButtonDock.innerHTML = "";
|
||
return;
|
||
}
|
||
el.runtimeButtonDock.classList.remove("hidden");
|
||
const { tabs, buttons, selectors } = hockeyQuickPanelTabs();
|
||
const availableIds = new Set(tabs.map((tab) => tab.id));
|
||
if (!availableIds.has(state.quickPanelActiveTab)) state.quickPanelActiveTab = tabs[0]?.id || "";
|
||
const activeId = state.quickPanelActiveTab;
|
||
const visibleButtons = buttons
|
||
.filter((button) => activeId === "__ungrouped" ? !button.group_id : button.group_id === activeId)
|
||
.sort((a, b) => Number(a.sort_order) - Number(b.sort_order) || a.label.localeCompare(b.label, "ru"));
|
||
const flags = hockeyMatchFlags();
|
||
const tabMarkup = tabs.map((tab) => `<button type="button" class="quick-command-tab ${tab.id === activeId ? "active" : ""}" data-quick-command-tab="${escapeHtml(tab.id)}">${escapeHtml(tab.label)}</button>`).join("");
|
||
const buttonMarkup = visibleButtons.map((button) => {
|
||
const active = button.mode === "toggle" && Boolean(flags[hockeyPrematchFlagKey(button.id)]);
|
||
const onAir = shortcutSequenceIsOnAir(button.sequence_id);
|
||
const attachedSelectors = selectors.filter((selector) => selector.button_id === button.id && (activeId === "__ungrouped" ? !selector.group_id : selector.group_id === activeId));
|
||
return `<div class="quick-command-item" data-quick-command-item="${escapeHtml(button.id)}">
|
||
<button type="button" class="quick-command-button ${active ? "is-active" : ""} ${onAir ? "is-on-air" : ""}" data-quick-command-button="${escapeHtml(button.id)}" data-sequence-id="${escapeHtml(button.sequence_id)}" data-tooltip="${escapeHtml(button.description || button.label)}" aria-pressed="${onAir ? "true" : "false"}"><strong>${escapeHtml(button.label)}</strong></button>
|
||
${attachedSelectors.map(quickPanelSelectorMarkup).join("")}
|
||
</div>`;
|
||
}).join("");
|
||
const standaloneSelectors = selectors.filter((selector) => !selector.button_id && (activeId === "__ungrouped" ? !selector.group_id : selector.group_id === activeId));
|
||
const standaloneMarkup = standaloneSelectors.map((selector) => `<div class="quick-command-item is-selector-only">${quickPanelSelectorMarkup(selector)}</div>`).join("");
|
||
el.runtimeButtonDock.innerHTML = `
|
||
<div class="quick-command-dock-tabs">
|
||
<div class="quick-command-tab-scroll">${tabMarkup || `<span class="quick-command-no-tabs">Создайте вкладку для операторских кнопок</span>`}</div>
|
||
<button type="button" class="quick-command-settings" data-quick-command-settings data-tooltip="Настроить вкладки и кнопки">⚙</button>
|
||
</div>
|
||
<div class="quick-command-dock-buttons">${buttonMarkup || standaloneMarkup ? `${buttonMarkup}${standaloneMarkup}` : `<span class="quick-command-empty">Во вкладке пока нет кнопок</span>`}</div>
|
||
`;
|
||
el.runtimeButtonDock.querySelectorAll("[data-quick-command-tab]").forEach((tab) => tab.addEventListener("click", () => {
|
||
state.quickPanelActiveTab = String(tab.dataset.quickCommandTab || "");
|
||
renderHockeyQuickCommandDock();
|
||
scheduleRuntimeScale();
|
||
}));
|
||
el.runtimeButtonDock.querySelector("[data-quick-command-settings]")?.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
openHockeyPrematchButtonsEditor();
|
||
});
|
||
el.runtimeButtonDock.querySelectorAll("[data-quick-selector-runtime]").forEach((control) => {
|
||
const eventName = control.tagName === "SELECT" ? "change" : "click";
|
||
control.addEventListener(eventName, async (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const selector = selectors.find((item) => item.id === control.dataset.quickSelectorRuntime);
|
||
if (!selector) return;
|
||
const value = control.tagName === "SELECT" ? control.value : String(control.dataset.quickSelectorValue || "");
|
||
const option = selector.options.find((item) => item.value === value);
|
||
try {
|
||
await hockeyApplyQuickSelector(selector, value, option?.label || value);
|
||
toast(`Параметр «${selector.label}»: ${option?.label || value}`);
|
||
} catch (error) {
|
||
toast(`Параметр «${selector.label}»: ${String(error?.message || error)}`, true);
|
||
}
|
||
});
|
||
});
|
||
el.runtimeButtonDock.querySelectorAll("[data-quick-command-button]").forEach((control) => control.addEventListener("click", async (event) => {
|
||
event.preventDefault();
|
||
const button = buttons.find((item) => item.id === control.dataset.quickCommandButton);
|
||
if (!button || control.dataset.busy === "1") return;
|
||
control.dataset.busy = "1";
|
||
control.classList.add("is-sending");
|
||
try {
|
||
const attachedSelectors = selectors.filter((selector) => selector.button_id === button.id);
|
||
await hockeyCommitQuickPanelButtonContext(button, attachedSelectors);
|
||
if (button.mode === "toggle") {
|
||
const flagResult = await hockeyToggleMatchFlag(hockeyPrematchFlagKey(button.id));
|
||
if (!flagResult) throw new Error("Не удалось изменить состояние кнопки");
|
||
}
|
||
if (!button.sequence_id) throw new Error(`Для «${button.label}» не выбран Shortcut Sequence`);
|
||
const sent = await runShortcutSequence(button.sequence_id, { source: "quick-panel-button", button_id: button.id, button_label: button.label });
|
||
if (!sent) throw new Error("Сценарий не был выполнен");
|
||
} catch (error) {
|
||
toast(`Кнопка «${button.label}»: ${String(error?.message || error || "Ошибка команды")}`, true);
|
||
} finally {
|
||
delete control.dataset.busy;
|
||
control.classList.remove("is-sending");
|
||
refreshQuickPanelOnAirClasses();
|
||
}
|
||
}));
|
||
}
|
||
|
||
function hockeyPrematchPanelNode(component, runtime) {
|
||
const node = div("hockey-prematch-panel");
|
||
node.innerHTML = `<div class="hockey-prematch-empty"><strong>Панель перенесена вниз</strong><span>Операторские кнопки теперь доступны в нижней панели на всех вкладках.</span></div>`;
|
||
return node;
|
||
}
|
||
|
||
function hockeyTeamStateButtonPresentation(key) {
|
||
const language = hockeyGameControlLanguage();
|
||
const emptyNet = String(key || "").includes("empty_net");
|
||
const activeText = language === "en" ? "Active" : "Активно";
|
||
const inactiveText = language === "en" ? "Off" : "Выкл";
|
||
if (emptyNet) {
|
||
return {
|
||
activeText,
|
||
inactiveText,
|
||
description: language === "en"
|
||
? "Empty net. Mark the team as playing without a goalie; the configured scorebug overlay will be added while the scorebug is on air."
|
||
: "Пустые ворота. Отмечает игру команды без вратаря; при верхнем счёте будет добавлен настроенный дополнительный титр.",
|
||
icon: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 19V7h16v12M4 7h16M8 7v12M16 7v12"/><path d="M4 11h16M4 15h16"/><path class="accent" d="M18.5 4.5 21 2m0 0v4m0-4h-4"/></svg>`,
|
||
};
|
||
}
|
||
return {
|
||
activeText,
|
||
inactiveText,
|
||
description: language === "en"
|
||
? "Delayed penalty. Mark a delayed call for this team; the configured scorebug overlay will be added while the scorebug is on air."
|
||
: "Отложенный штраф. Отмечает отложенное удаление у команды; при верхнем счёте будет добавлен настроенный дополнительный титр.",
|
||
icon: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 21V3"/><path d="M7 4h10l-2.2 4L17 12H7z"/><path class="accent" d="M11.8 6.4v2.4m0 1.7v.1"/></svg>`,
|
||
};
|
||
}
|
||
|
||
function hockeyTeamStateButtonMarkup(key, setting, active) {
|
||
const meta = hockeyTeamStateButtonPresentation(key);
|
||
const label = String(setting?.label || "").trim() || (String(key).includes("empty_net") ? "Пустые ворота" : "Отложенный штраф");
|
||
const tooltip = `${label}. ${meta.description}`;
|
||
return `<button type="button" class="hpd-team-state-btn ${String(key).includes("empty_net") ? "is-empty-net" : "is-delayed"} ${active ? "is-active" : ""}" data-hockey-team-flag="${escapeHtml(key)}" data-tooltip="${escapeHtml(tooltip)}" aria-label="${escapeHtml(tooltip)}" aria-pressed="${active ? "true" : "false"}">
|
||
<span class="hpd-team-state-icon">${meta.icon}</span>
|
||
<span class="hpd-team-state-copy"><b>${escapeHtml(label)}</b><small>${active ? meta.activeText : meta.inactiveText}</small></span>
|
||
<span class="hpd-team-state-led" aria-hidden="true"></span>
|
||
</button>`;
|
||
}
|
||
|
||
function hockeyTeamPenaltyColumn(component, side, runtime) {
|
||
const board = ensureHockeyBoardState(component);
|
||
const events = board.penalties.filter((event) => (event.player?.side || event.side) === side);
|
||
const column = div(`hpd-team-penalty-column team-${side}`);
|
||
const activeCount = events.filter((event) => event.running && !event.finished).length;
|
||
const readyCount = events.filter((event) => hockeyEventReady(event) && !event.running && !event.finished).length;
|
||
|
||
const flags = hockeyMatchFlags();
|
||
const delayedKey = `${side}_delayed_penalty`;
|
||
const emptyNetKey = `${side}_empty_net`;
|
||
const delayedSetting = hockeyTeamStateSetting(delayedKey);
|
||
const emptyNetSetting = hockeyTeamStateSetting(emptyNetKey);
|
||
column.innerHTML = `
|
||
<div class="hpd-team-column-header">
|
||
<div class="hpd-team-name-block">
|
||
<small>${side === "home" ? "ЛЕВАЯ КОМАНДА" : "ПРАВАЯ КОМАНДА"}</small>
|
||
<strong>${escapeHtml(hockeyTeamName(component, side))}</strong>
|
||
<div class="hpd-team-state-buttons" aria-label="${side === "home" ? "Состояния HOME" : "Состояния AWAY"}">
|
||
${hockeyTeamStateButtonMarkup(delayedKey, delayedSetting, Boolean(flags[delayedKey]))}
|
||
${hockeyTeamStateButtonMarkup(emptyNetKey, emptyNetSetting, Boolean(flags[emptyNetKey]))}
|
||
</div>
|
||
</div>
|
||
<div class="hpd-team-column-counts">
|
||
<span title="Готовые">${readyCount}</span>
|
||
<b title="Запущенные">${activeCount}</b>
|
||
</div>
|
||
</div>
|
||
`;
|
||
column.querySelectorAll("[data-hockey-team-flag]").forEach((button) => button.addEventListener("click", async (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
button.disabled = true;
|
||
try { await hockeyToggleMatchFlag(button.dataset.hockeyTeamFlag); }
|
||
finally { button.disabled = false; }
|
||
}));
|
||
|
||
const list = div("hpd-team-column-list");
|
||
events
|
||
.sort((a, b) => Number(a.finished) - Number(b.finished) || Number(b.createdAt) - Number(a.createdAt))
|
||
.forEach((event) => list.appendChild(hockeyPenaltyCard(component, event, runtime)));
|
||
|
||
if (!events.length) {
|
||
list.innerHTML = `
|
||
<div class="hpd-team-column-empty">
|
||
Перетащите игрока, нарушение или длительность в эту колонку
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
bindHockeyColumnDrop(component, list, side, runtime);
|
||
column.appendChild(list);
|
||
return column;
|
||
}
|
||
|
||
function hockeyTeamPenaltyBoard(component, runtime) {
|
||
const board = div("hpd-team-penalty-board");
|
||
board.append(
|
||
hockeyTeamPenaltyColumn(component, "home", runtime),
|
||
hockeyTeamPenaltyColumn(component, "away", runtime)
|
||
);
|
||
return board;
|
||
}
|
||
|
||
function hockeyHistoryPanel(component) {
|
||
const board = ensureHockeyBoardState(component);
|
||
const panel = document.createElement("details");
|
||
panel.className = "hpd-history hpd-history-collapsible";
|
||
panel.open = Boolean(board.historyOpen);
|
||
|
||
const summary = document.createElement("summary");
|
||
summary.className = "hpd-history-header";
|
||
summary.innerHTML = `
|
||
<div>
|
||
<span class="hpd-history-chevron">›</span>
|
||
<strong>Журнал удалений</strong>
|
||
</div>
|
||
<span class="hpd-history-count">${board.history.length}</span>
|
||
`;
|
||
panel.appendChild(summary);
|
||
|
||
const list = div("hpd-history-list");
|
||
board.history.forEach((entry) => {
|
||
const time = new Date(entry.at).toLocaleTimeString("ru-RU", {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
second: "2-digit"
|
||
});
|
||
const item = div("hpd-history-item");
|
||
item.innerHTML = `
|
||
<time>${escapeHtml(time)}</time>
|
||
<span class="team-${escapeHtml(entry.side || "neutral")}"></span>
|
||
<strong>${escapeHtml(entry.message || "")}</strong>
|
||
`;
|
||
list.appendChild(item);
|
||
});
|
||
|
||
if (!board.history.length) {
|
||
list.innerHTML = `<div class="hpd-empty-history">История пока пуста</div>`;
|
||
}
|
||
|
||
panel.appendChild(list);
|
||
panel.addEventListener("toggle", () => {
|
||
board.historyOpen = panel.open;
|
||
});
|
||
return panel;
|
||
}
|
||
|
||
function hockeyEventCategoryLabels(language) {
|
||
return language === "en"
|
||
? {all:"All",goal:"Goal",penalty:"Penalty",shot:"Shot",shootout:"Shootout",period:"Period",timeout:"Timeout",goalie:"Goalie",comment:"Comment",info:"Event"}
|
||
: {all:"Все",goal:"Гол",penalty:"Удаление",shot:"Бросок",shootout:"Буллит",period:"Период",timeout:"Тайм-аут",goalie:"Вратарь",comment:"Комментарий",info:"Событие"};
|
||
}
|
||
|
||
function hockeyEventCategory(item) {
|
||
const declared = String(item?.category || "").trim().toLowerCase();
|
||
// Prefer the explicit Stat2TV code (`pn`, `go`, ...). `type` can contain a
|
||
// human-readable value or a stale cached category and is only a fallback.
|
||
const code = String(item?.code || item?.event_code || item?.type || "").trim().toLowerCase();
|
||
const known = ["goal", "penalty", "shot", "shootout", "period", "timeout", "goalie", "comment", "info"];
|
||
|
||
// Explicit Stat2TV event codes are authoritative. This keeps a real `pn`
|
||
// penalty as its own timeline event even when an older cached payload marked
|
||
// it as `info`. Comments (`ce`/`co`) remain ordinary messages and are never
|
||
// merged with the following penalty.
|
||
if (["pn", "pen", "penalty"].includes(code)) return "penalty";
|
||
if (["go", "goal", "score"].includes(code)) return "goal";
|
||
if (["bu", "shootout"].includes(code)) return "shootout";
|
||
if (["ga", "periodstart", "periodend"].includes(code)) return "period";
|
||
if (["to", "timeout"].includes(code)) return "timeout";
|
||
if (["gc", "goaliechange"].includes(code)) return "goalie";
|
||
if (["co", "comment"].includes(code)) return "comment";
|
||
if (["ce", "event", "info"].includes(code)) return "info";
|
||
if (code === "shot") return "shot";
|
||
|
||
if (known.includes(declared)) return declared;
|
||
|
||
// Compatibility fallback for sources without a reliable event code.
|
||
const text = [item?.label, item?.title, item?.penalty_reason]
|
||
.filter(Boolean)
|
||
.join(" ")
|
||
.toLowerCase();
|
||
if (/^(удаление|штраф|penalty|team penalty)\b/.test(text)) return "penalty";
|
||
return "info";
|
||
}
|
||
|
||
function hockeyEventIconMarkup(category, language, extraClass = "") {
|
||
const safe = String(category || "info");
|
||
if (safe === "penalty") {
|
||
const title = language === "en" ? "Penalty" : "Удаление";
|
||
return `<i class="hockey-event-icon is-penalty ${escapeHtml(extraClass)}" data-tooltip="${escapeHtml(title)}" aria-label="${escapeHtml(title)}"><span>2′</span></i>`;
|
||
}
|
||
const icons = {goal:"●",shot:"➤",shootout:"◎",period:"◷",timeout:"Ⅱ",goalie:"▣",comment:"✦",info:"•"};
|
||
return `<i class="hockey-event-icon is-${escapeHtml(safe)} ${escapeHtml(extraClass)}">${icons[safe] || "•"}</i>`;
|
||
}
|
||
|
||
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.
|
||
if (!el.runtimeView || !el.runtimeViewport || state.activeTab !== "main") return false;
|
||
|
||
const component = state.config.components.find((item) => item.type === "hockey_penalty_dashboard" && !item.hidden) || { props: {} };
|
||
const props = component.props || {};
|
||
const language = getByPath(state.data, "hockey.language.display") === "en" ? "en" : "ru";
|
||
const payload = getByPath(state.data, props.eventsPath || "hockey.selected_game.events") || {};
|
||
const sourceItems = Array.isArray(payload.items) ? payload.items : [];
|
||
if (!payload.available || !sourceItems.length) return false;
|
||
|
||
// Period 0 contains technical pre/post-game records. Keep them in “All”,
|
||
// but do not expose a separate “0 period” filter.
|
||
const periodValues = [
|
||
...(Array.isArray(payload.segments) ? payload.segments : []),
|
||
...sourceItems.map((item) => String(item?.period || "")),
|
||
].map(String).filter((period) => period && period !== "0" && period !== "all");
|
||
const segmentSet = new Set(["all", ...periodValues]);
|
||
const segments = [...segmentSet].sort((a, b) => a === "all" ? -1 : b === "all" ? 1 : Number(a) - Number(b));
|
||
const stateKey = "hockey-standalone-pbp-period";
|
||
const collapsedKey = "hockey-standalone-pbp-collapsed";
|
||
let selectedPeriod = String(state.formValues[stateKey] || "all");
|
||
if (!segments.includes(selectedPeriod)) selectedPeriod = "all";
|
||
rememberUiNavigationState("hockey_play_by_play", "period", selectedPeriod, hockeyStatisticsSegmentLabel(selectedPeriod, language), { emit: false });
|
||
const collapsed = Boolean(state.formValues[collapsedKey]);
|
||
const labels = hockeyEventCategoryLabels(language);
|
||
const items = sourceItems
|
||
.filter((item) => selectedPeriod === "all" || String(item?.period || "") === selectedPeriod)
|
||
.slice()
|
||
.sort((a, b) => Number(b?.sort_seconds ?? -1) - Number(a?.sort_seconds ?? -1) || Number(a?.source_index || 0) - Number(b?.source_index || 0));
|
||
|
||
const windowNode = document.createElement("aside");
|
||
windowNode.id = "hockeyStandalonePbp";
|
||
windowNode.className = `hockey-pbp-window ${collapsed ? "is-collapsed" : ""}`;
|
||
windowNode.style.setProperty("--pbp-home", props.homeColor || "#4d9cff");
|
||
windowNode.style.setProperty("--pbp-away", props.awayColor || "#ff5f79");
|
||
windowNode.innerHTML = `
|
||
<header class="hockey-pbp-window-header">
|
||
<div><span>PLAY-BY-PLAY</span><strong>${language === "en" ? "Match messages" : "Сообщения матча"}</strong></div>
|
||
<div class="hockey-pbp-window-actions"><b>${items.length}</b><button type="button" data-pbp-collapse data-tooltip="${language === "en" ? "Collapse" : "Свернуть"}" aria-label="${language === "en" ? "Collapse" : "Свернуть"}">${collapsed ? "+" : "−"}</button></div>
|
||
</header>
|
||
<nav class="hockey-pbp-window-periods">${segments.map((period) => `<button type="button" data-pbp-period="${escapeHtml(period)}" class="${period === selectedPeriod ? "active" : ""}">${escapeHtml(period === "all" ? (language === "en" ? "All" : "Все") : hockeyStatisticsSegmentLabel(period, language))}</button>`).join("")}</nav>
|
||
<div class="hockey-pbp-window-list">${items.map((item) => {
|
||
const category = hockeyEventCategory(item);
|
||
const side = item?.side === "away" ? "away" : item?.side === "home" ? "home" : "neutral";
|
||
const title = item?.title || item?.label || labels[category] || labels.info;
|
||
const description = item?.description && item.description !== title ? item.description : "";
|
||
const player = [item?.player_number ? `№${item.player_number}` : "", item?.player_name || ""].filter(Boolean).join(" ");
|
||
const penaltyReason = category === "penalty" ? String(item?.penalty_reason || "").trim() : "";
|
||
const penaltyMinutes = category === "penalty" && item?.penalty_minutes
|
||
? `${item.penalty_minutes} ${language === "en" ? "min" : "мин"}`
|
||
: "";
|
||
const details = category === "penalty"
|
||
? [item?.team_name || "", item?.team_penalty ? "" : player, penaltyMinutes, penaltyReason]
|
||
.filter((value, index, values) => value && values.indexOf(value) === index)
|
||
.join(" · ")
|
||
: [description, player, item?.team_name || ""].filter(Boolean).join(" · ");
|
||
return `<article class="side-${side} category-${escapeHtml(category)}">
|
||
${hockeyEventIconMarkup(category, language, "is-compact")}
|
||
<time>${escapeHtml(item?.time || item?.moscow_time || "—")}</time>
|
||
<div><span>${escapeHtml(labels[category] || labels.info)}</span><strong>${escapeHtml(title)}</strong>${details ? `<small>${escapeHtml(details)}</small>` : ""}</div>
|
||
${item?.score ? `<b class="hockey-pbp-score">${escapeHtml(item.score)}</b>` : ""}
|
||
</article>`;
|
||
}).join("") || `<div class="hockey-pbp-window-empty">${language === "en" ? "No messages for this period" : "В этом периоде сообщений нет"}</div>`}</div>
|
||
`;
|
||
windowNode.querySelectorAll("[data-pbp-period]").forEach((button) => button.addEventListener("click", () => {
|
||
state.formValues[stateKey] = button.dataset.pbpPeriod || "all";
|
||
rememberUiNavigationState("hockey_play_by_play", "period", state.formValues[stateKey], button.textContent || state.formValues[stateKey]);
|
||
renderRuntime();
|
||
}));
|
||
windowNode.querySelector("[data-pbp-collapse]")?.addEventListener("click", () => {
|
||
state.formValues[collapsedKey] = !collapsed;
|
||
renderRuntime();
|
||
});
|
||
el.runtimeViewport.appendChild(windowNode);
|
||
el.runtimeViewport.classList.add("has-hockey-pbp");
|
||
return true;
|
||
}
|
||
|
||
function renderHockeyPenaltyDashboard(node, component, runtime) {
|
||
const board = ensureHockeyBoardState(component);
|
||
node.innerHTML = "";
|
||
node.className = "hockey-penalty-dashboard";
|
||
node.style.setProperty("--hpd-home", component.props?.homeColor || "#4d9cff");
|
||
node.style.setProperty("--hpd-away", component.props?.awayColor || "#ff5f79");
|
||
|
||
const content = div("hpd-layout");
|
||
const center = div("hpd-center hpd-center--teams");
|
||
|
||
const tools = div("hpd-tools-row hpd-tools-row--compact");
|
||
tools.append(hockeyPresetBar(component, runtime));
|
||
|
||
center.append(
|
||
tools,
|
||
hockeyUnassignedEvents(component, runtime),
|
||
hockeyTeamPenaltyBoard(component, runtime),
|
||
hockeyHistoryPanel(component)
|
||
);
|
||
|
||
content.append(
|
||
hockeyRosterPanel(component, "home", runtime),
|
||
center,
|
||
hockeyRosterPanel(component, "away", runtime)
|
||
);
|
||
node.append(content);
|
||
|
||
updateHockeyBoardTickerNodes(component);
|
||
}
|
||
|
||
function hockeyPenaltyDashboardNode(component, runtime) {
|
||
const node = div("hockey-penalty-dashboard");
|
||
if (runtime) registerHockeyBoardNode(component, node);
|
||
renderHockeyPenaltyDashboard(node, component, runtime);
|
||
return node;
|
||
}
|
||
|
||
function hockeyStatColumnInfo(ref, language, section = "") {
|
||
const root = getByPath(state.data, "hockey.stat_labels") || {};
|
||
const sectionEntry = section ? root?.columns_by_section?.[section]?.[ref] : null;
|
||
const entry = sectionEntry || root?.columns?.[ref] || {};
|
||
const lang = language === "en" ? "en" : "ru";
|
||
const fallbackLang = lang === "en" ? "ru" : "en";
|
||
const selected = entry?.[lang] || {};
|
||
const fallback = entry?.[fallbackLang] || {};
|
||
return {
|
||
label: selected.label || fallback.label || "",
|
||
description: selected.description || fallback.description || "",
|
||
short: selected.short || fallback.short || "",
|
||
};
|
||
}
|
||
|
||
function hockeyStatLabel(ref, fallback, language, section = "") {
|
||
return hockeyStatColumnInfo(ref, language, section).label || fallback;
|
||
}
|
||
|
||
function hockeyStatTooltipAttrs(ref, language, section = "") {
|
||
const description = hockeyStatColumnInfo(ref, language, section).description;
|
||
return description ? ` title="${escapeHtml(description)}" data-tooltip="${escapeHtml(description)}"` : "";
|
||
}
|
||
|
||
const HOCKEY_TEAM_STAT_METRICS = [
|
||
["goals", "Голы", "Goals", "number", "g"],
|
||
["shot_attempts", "Броски", "Shots", "number", "shots"],
|
||
["shots", "Броски в створ", "Shots on goal", "number", "sog"],
|
||
["faceoffs", "Вбрасывания", "Faceoffs", "number", "fo"],
|
||
["faceoffs_won", "Выиграно вбрасываний", "Faceoffs won", "number", "fow"],
|
||
["faceoffs_won_pct", "% выигранных вбрасываний", "Faceoff win %", "percent", "fo_pct"],
|
||
["hits", "Силовые приёмы", "Hits", "number", "hits"],
|
||
["blocked_shots", "Блокированные броски", "Blocked shots", "number", "bls"],
|
||
["penalty_minutes", "Штрафные минуты", "Penalty minutes", "number", "pim"],
|
||
["penalties", "Удаления", "Penalties", "number", "outs"],
|
||
["takeaways", "Отборы", "Takeaways", "number", "tka"],
|
||
["giveaways", "Потери", "Giveaways", "number", "gva"],
|
||
["interceptions", "Перехваты", "Interceptions", "number", "p_intc"],
|
||
["time_on_attack", "Время в атаке", "Time on attack", "time", "toa"],
|
||
["even_strength_time", "В равных составах", "Even strength", "time", "tie"],
|
||
["power_play_time", "В большинстве", "Power play", "time", "tipp"],
|
||
["short_handed_time", "В меньшинстве", "Short-handed", "time", "tish"],
|
||
["empty_net_time", "С пустыми воротами", "Empty net", "time", "tien"]
|
||
];
|
||
|
||
function hockeyStatisticComparable(value) {
|
||
if (typeof value === "number") return Number.isFinite(value) ? value : 0;
|
||
const text = String(value ?? "").trim();
|
||
const time = text.match(/^(\d+):(\d{2})$/);
|
||
if (time) return Number(time[1]) * 60 + Number(time[2]);
|
||
const numeric = Number(text.replace(",", ".").replace(/[^\d.-]/g, ""));
|
||
return Number.isFinite(numeric) ? numeric : 0;
|
||
}
|
||
|
||
function hockeyStatisticValue(value, format) {
|
||
if (value === null || value === undefined || value === "") return "—";
|
||
if (format === "percent") return `${value}%`;
|
||
return String(value);
|
||
}
|
||
|
||
function hockeyStatisticsSegmentLabel(segment, language) {
|
||
if (segment === "total") return language === "en" ? "Game" : "Матч";
|
||
const number = Number(segment);
|
||
if (number === 4) return language === "en" ? "OT" : "ОТ";
|
||
if (number >= 5) return language === "en" ? "Shootout" : "Буллиты";
|
||
return language === "en" ? `Period ${segment}` : `${segment} период`;
|
||
}
|
||
|
||
function hockeyVisualIdentity(value) {
|
||
return String(value || "").toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
|
||
}
|
||
|
||
function hockeyShotPlayerKey(item) {
|
||
const id = String(item?.player_id || "").trim();
|
||
return id ? `id:${id}` : `name:${hockeyVisualIdentity(item?.player_name)}`;
|
||
}
|
||
|
||
function hockeyNormalisedShotCoordinates(item) {
|
||
let x = Number(item?.x);
|
||
let y = Number(item?.y);
|
||
const width = Number(item?.rink_width || 0) || (item?.coordinate_system === "khl1000x500" ? 1000 : 0);
|
||
const height = Number(item?.rink_height || 0) || (item?.coordinate_system === "khl1000x500" ? 500 : 0);
|
||
if (!Number.isFinite(x) || !Number.isFinite(y)) return { x, y, width, height };
|
||
if (width > 0 && height > 0) {
|
||
const side = item?.side === "away" ? "away" : "home";
|
||
const leftAB = String(item?.attacking_left || "").trim().toUpperCase();
|
||
let attacksLeft = null;
|
||
if (leftAB === "A" || leftAB === "B") {
|
||
attacksLeft = side === "home" ? leftAB === "B" : leftAB === "A";
|
||
}
|
||
const desiredAttacksLeft = side === "away";
|
||
if (attacksLeft !== null && attacksLeft !== desiredAttacksLeft) x = width - x;
|
||
else if (attacksLeft === null) {
|
||
if (side === "home" && x < width / 2) x = width - x;
|
||
if (side === "away" && x > width / 2) x = width - x;
|
||
}
|
||
}
|
||
return { x, y, width, height };
|
||
}
|
||
|
||
function hockeyShotMapPosition(item, sourceItems, profile = false) {
|
||
const normalized = hockeyNormalisedShotCoordinates(item);
|
||
const x = normalized.x;
|
||
const y = normalized.y;
|
||
const coordinates = (Array.isArray(sourceItems) ? sourceItems : [])
|
||
.map((row) => { const value = hockeyNormalisedShotCoordinates(row); return [value.x, value.y]; })
|
||
.filter(([px, py]) => Number.isFinite(px) && Number.isFinite(py));
|
||
if (!Number.isFinite(x) || !Number.isFinite(y)) return { left: 50, top: 50 };
|
||
const rinkWidth = normalized.width;
|
||
const rinkHeight = normalized.height;
|
||
if (rinkWidth > 0 && rinkHeight > 0) {
|
||
if (profile) {
|
||
const side = item?.side === "away" ? "away" : "home";
|
||
const distanceFromGoal = side === "away" ? x : rinkWidth - x;
|
||
return {
|
||
left: Math.max(4, Math.min(96, y / rinkHeight * 100)),
|
||
top: Math.max(8, Math.min(96, 8 + Math.min(rinkWidth / 2, Math.max(0, distanceFromGoal)) / (rinkWidth / 2) * 86)),
|
||
};
|
||
}
|
||
return {
|
||
left: Math.max(1.5, Math.min(98.5, x / rinkWidth * 100)),
|
||
top: Math.max(2, Math.min(98, y / rinkHeight * 100)),
|
||
};
|
||
}
|
||
const xs = coordinates.map(([px]) => px);
|
||
const ys = coordinates.map(([, py]) => py);
|
||
const minX = Math.min(x, ...xs);
|
||
const maxX = Math.max(x, ...xs);
|
||
const minY = Math.min(y, ...ys);
|
||
const maxY = Math.max(y, ...ys);
|
||
let left;
|
||
let top;
|
||
if (minX >= 0 && maxX <= 1.25 && minY >= 0 && maxY <= 1.25) {
|
||
left = x * 100; top = (1 - y) * 100;
|
||
} else if (minX >= -1.25 && maxX <= 1.25 && minY >= -1.25 && maxY <= 1.25) {
|
||
left = (x + 1) / 2 * 100; top = (1 - (y + 1) / 2) * 100;
|
||
} else if (minX >= 0 && maxX <= 105 && minY >= 0 && maxY <= 105) {
|
||
left = x; top = 100 - y;
|
||
} else if (minX >= 0 && maxX <= 205 && minY >= 0 && maxY <= 105) {
|
||
left = x / 200 * 100; top = 100 - y / 100 * 100;
|
||
} else if (minX >= -110 && maxX <= 110 && minY >= -60 && maxY <= 60) {
|
||
left = (x + 100) / 200 * 100; top = (50 - y) / 100 * 100;
|
||
} else {
|
||
const spanX = Math.max(1, maxX - minX);
|
||
const spanY = Math.max(1, maxY - minY);
|
||
left = (x - minX) / spanX * 90 + 5;
|
||
top = (1 - (y - minY) / spanY) * 90 + 5;
|
||
}
|
||
return { left: Math.max(2, Math.min(98, left)), top: Math.max(3, Math.min(97, top)) };
|
||
}
|
||
|
||
function hockeyShotPointMarkup(item, sourceItems, language, compact = false, profile = false) {
|
||
const position = hockeyShotMapPosition(item, sourceItems, profile);
|
||
const side = item?.side === "away" ? "away" : "home";
|
||
const kind = item?.goal ? "goal" : item?.blocked ? "blocked" : item?.missed ? "missed" : item?.on_target ? "target" : "shot";
|
||
const player = [item?.player_number ? `№${item.player_number}` : "", item?.player_name || ""].filter(Boolean).join(" ") || (language === "en" ? "Unknown player" : "Игрок не указан");
|
||
const details = [
|
||
item?.time || "",
|
||
item?.result_label || item?.result || (language === "en" ? "Shot" : "Бросок"),
|
||
player,
|
||
item?.team_name || "",
|
||
].filter(Boolean).join(" · ");
|
||
return `<i class="hsm-point side-${side} is-${kind} ${compact ? "is-compact" : ""}" style="left:${position.left.toFixed(2)}%;top:${position.top.toFixed(2)}%" title="${escapeHtml(details)}"><span>${item?.goal ? "★" : ""}</span></i>`;
|
||
}
|
||
|
||
function hockeyShotRinkMarkup(items, sourceItems, language, compact = false, profile = false) {
|
||
const imagePath = profile
|
||
? "/hockey-assets/hockey_ice_attack_half.png"
|
||
: "/hockey-assets/hockey_ice.png";
|
||
return `
|
||
<div class="hsm-rink is-khl-image ${compact ? "is-compact" : ""} ${profile ? "is-profile-half" : ""}">
|
||
<img class="hsm-rink-image" src="${imagePath}" alt="" draggable="false" />
|
||
${items.map((item) => hockeyShotPointMarkup(item, sourceItems, language, compact || profile, profile)).join("")}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function hockeyShotsMapNode(component, runtime) {
|
||
const props = component.props || {};
|
||
const language = getByPath(state.data, "hockey.language.display") === "en" ? "en" : "ru";
|
||
const payload = getByPath(state.data, props.shotsMapPath || "hockey.selected_game.shots_map") || {};
|
||
const allItems = Array.isArray(payload.items) ? payload.items : [];
|
||
const periods = Array.isArray(payload.segments) && payload.segments.length ? payload.segments.map(String) : ["all"];
|
||
const periodKey = `hockey-shots-map-period:${component.id}`;
|
||
const sideKey = `hockey-shots-map-side:${component.id}`;
|
||
const playerKey = `hockey-shots-map-player:${component.id}`;
|
||
let selectedPeriod = String(state.formValues[periodKey] || "all");
|
||
let selectedSide = String(state.formValues[sideKey] || "all");
|
||
let selectedPlayer = String(state.formValues[playerKey] || "all");
|
||
if (!periods.includes(selectedPeriod)) selectedPeriod = periods[0] || "all";
|
||
if (!["all", "home", "away"].includes(selectedSide)) selectedSide = "all";
|
||
const availablePlayers = (Array.isArray(payload.players) ? payload.players : []).filter((player) => selectedSide === "all" || player?.side === selectedSide);
|
||
if (selectedPlayer !== "all" && !availablePlayers.some((player) => hockeyShotPlayerKey({ player_id: player?.id, player_name: player?.name }) === selectedPlayer)) selectedPlayer = "all";
|
||
rememberUiNavigationState(component, "shots_period", selectedPeriod, selectedPeriod, { emit: false });
|
||
rememberUiNavigationState(component, "shots_side", selectedSide, selectedSide, { emit: false });
|
||
const filtered = allItems.filter((item) => {
|
||
if (selectedPeriod !== "all" && String(item?.period || "") !== selectedPeriod) return false;
|
||
if (selectedSide !== "all" && item?.side !== selectedSide) return false;
|
||
if (selectedPlayer !== "all" && hockeyShotPlayerKey(item) !== selectedPlayer) return false;
|
||
return true;
|
||
});
|
||
const goals = filtered.filter((item) => item?.goal).length;
|
||
const onTarget = filtered.filter((item) => item?.goal || item?.on_target).length;
|
||
const node = div("hockey-shots-map");
|
||
node.style.setProperty("--hsm-home", props.homeColor || "#4d9cff");
|
||
node.style.setProperty("--hsm-away", props.awayColor || "#ff5f79");
|
||
if (!payload.available || !allItems.length) {
|
||
node.innerHTML = `<div class="hsm-empty"><strong>${language === "en" ? "Shot map is unavailable" : "Карта бросков недоступна"}</strong><span>${language === "en" ? "The match JSON does not contain shots_map" : "В JSON матча нет данных shots_map"}</span></div>`;
|
||
return node;
|
||
}
|
||
node.innerHTML = `
|
||
<header class="hsm-header">
|
||
<div><span>SHOTS MAP</span><strong>${language === "en" ? "Team shot map" : "Карта бросков команд"}</strong></div>
|
||
<div class="hsm-summary"><b>${filtered.length}</b><span>${language === "en" ? "shots" : "бросков"}</span><b>${onTarget}</b><span>${language === "en" ? "on target" : "в створ"}</span><b>${goals}</b><span>${language === "en" ? "goals" : "голов"}</span></div>
|
||
</header>
|
||
<div class="hsm-filters">
|
||
<nav class="hsm-periods">${periods.map((period) => `<button type="button" data-shot-period="${escapeHtml(period)}" class="${period === selectedPeriod ? "active" : ""}">${escapeHtml(period === "all" ? (language === "en" ? "All periods" : "Весь матч") : hockeyStatisticsSegmentLabel(period, language))}</button>`).join("")}</nav>
|
||
<nav class="hsm-sides">${[["all", language === "en" ? "Both teams" : "Обе команды"], ["home", formatValue(getByPath(state.data, props.homeTeamPath), language === "en" ? "Home" : "Хозяева")], ["away", formatValue(getByPath(state.data, props.awayTeamPath), language === "en" ? "Away" : "Гости")]].map(([id, label]) => `<button type="button" data-shot-side="${id}" class="${id === selectedSide ? "active" : ""}">${escapeHtml(label)}</button>`).join("")}</nav>
|
||
<label class="hsm-player-filter"><span>${language === "en" ? "Player" : "Игрок"}</span><select data-shot-player><option value="all">${language === "en" ? "All players" : "Все игроки"}</option>${availablePlayers.map((player) => { const value = hockeyShotPlayerKey({ player_id: player?.id, player_name: player?.name }); const label = [player?.number ? `№${player.number}` : "", player?.name || "—"].filter(Boolean).join(" "); return `<option value="${escapeHtml(value)}" ${value === selectedPlayer ? "selected" : ""}>${escapeHtml(label)}</option>`; }).join("")}</select></label>
|
||
</div>
|
||
<div class="hsm-map-wrap">
|
||
${hockeyShotRinkMarkup(filtered, allItems, language)}
|
||
${filtered.length ? "" : `<div class="hsm-no-results">${language === "en" ? "No shots for selected filters" : "Для выбранных фильтров бросков нет"}</div>`}
|
||
</div>
|
||
<footer class="hsm-legend"><span class="side-home">● ${language === "en" ? "Home" : "Хозяева"}</span><span class="side-away">● ${language === "en" ? "Away" : "Гости"}</span><span class="goal">★ ${language === "en" ? "Goal" : "Гол"}</span><span class="target">● ${language === "en" ? "On target" : "В створ"}</span><span class="missed">○ ${language === "en" ? "Missed / blocked" : "Мимо / блок"}</span></footer>
|
||
`;
|
||
node.querySelectorAll("[data-shot-period]").forEach((button) => {
|
||
button.disabled = !runtime;
|
||
button.addEventListener("click", () => {
|
||
if (!runtime) return;
|
||
state.formValues[periodKey] = button.dataset.shotPeriod || "all";
|
||
rememberUiNavigationState(component, "shots_period", state.formValues[periodKey], button.textContent || state.formValues[periodKey]);
|
||
renderRuntime();
|
||
});
|
||
});
|
||
node.querySelectorAll("[data-shot-side]").forEach((button) => {
|
||
button.disabled = !runtime;
|
||
button.addEventListener("click", () => {
|
||
if (!runtime) return;
|
||
state.formValues[sideKey] = button.dataset.shotSide || "all";
|
||
state.formValues[playerKey] = "all";
|
||
rememberUiNavigationState(component, "shots_side", state.formValues[sideKey], button.textContent || state.formValues[sideKey]);
|
||
renderRuntime();
|
||
});
|
||
});
|
||
const playerSelect = node.querySelector("[data-shot-player]");
|
||
if (playerSelect) {
|
||
playerSelect.disabled = !runtime;
|
||
playerSelect.addEventListener("change", () => { if (!runtime) return; state.formValues[playerKey] = playerSelect.value || "all"; renderRuntime(); });
|
||
}
|
||
return node;
|
||
}
|
||
|
||
function hockeyPlayerShotMapMarkup(player, language) {
|
||
const payload = getByPath(state.data, "hockey.selected_game.shots_map") || {};
|
||
const allItems = Array.isArray(payload.items) ? payload.items : [];
|
||
if (!payload.available || !allItems.length || !player) return "";
|
||
const playerId = String(player.id || player.external_id || "").trim();
|
||
const playerName = hockeyVisualIdentity(player.name);
|
||
const items = allItems.filter((item) => {
|
||
if (playerId && String(item?.player_id || "").trim() === playerId) return true;
|
||
return playerName && hockeyVisualIdentity(item?.player_name) === playerName;
|
||
});
|
||
if (!items.length) return "";
|
||
const goals = items.filter((item) => item?.goal).length;
|
||
return `<div class="ppi-player-shot-map"><header><div><span>SHOTS MAP</span><strong>${language === "en" ? "Shots in this match" : "Карта бросков в матче"}</strong></div><b>${items.length} · ${language === "en" ? "goals" : "голы"}: ${goals}</b></header>${hockeyShotRinkMarkup(items, allItems, language, true, true)}</div>`;
|
||
}
|
||
|
||
function hockeyRefereesNode(component, runtime) {
|
||
const props = component.props || {};
|
||
const language = getByPath(state.data, "hockey.language.display") === "en" ? "en" : "ru";
|
||
const referees = getByPath(state.data, props.refereesPath || "hockey.referees") || [];
|
||
const items = Array.isArray(referees) ? referees : [];
|
||
const heads = items.filter((item) => item?.role === "head");
|
||
const linesmen = items.filter((item) => item?.role === "linesman");
|
||
const home = formatValue(getByPath(state.data, props.homeTeamPath || "hockey.home.name"), language === "en" ? "Home" : "Хозяева");
|
||
const away = formatValue(getByPath(state.data, props.awayTeamPath || "hockey.away.name"), language === "en" ? "Away" : "Гости");
|
||
const node = div("hockey-referees");
|
||
const group = (title, rows, kind) => `
|
||
<section class="hrf-group ${kind}">
|
||
<header><span>${kind === "head" ? "REFEREES" : "LINESMEN"}</span><strong>${escapeHtml(title)}</strong><b>${rows.length}</b></header>
|
||
<div>${rows.map((item) => `
|
||
<article>
|
||
<b>${escapeHtml(item?.number || "—")}</b>
|
||
<div><span>${escapeHtml(item?.role_label || title)}</span><strong>${hockeyPlayerFlagMarkup(item)}${escapeHtml(item?.name || "—")}</strong></div>
|
||
</article>
|
||
`).join("")}</div>
|
||
</section>`;
|
||
if (!items.length) {
|
||
node.innerHTML = `<div class="hrf-empty"><strong>${language === "en" ? "Officials are unavailable" : "Данные о судьях не загружены"}</strong><span>${language === "en" ? "Open a game with referee data" : "Откройте матч, в JSON которого есть судьи"}</span></div>`;
|
||
return node;
|
||
}
|
||
node.innerHTML = `
|
||
<header class="hrf-header"><div><span>${language === "en" ? "GAME OFFICIALS" : "СУДЕЙСКАЯ БРИГАДА"}</span><strong>${escapeHtml(home)} — ${escapeHtml(away)}</strong></div><b>${items.length}</b></header>
|
||
<div class="hrf-grid">
|
||
${group(language === "en" ? "Referees" : "Главные судьи", heads, "head")}
|
||
${group(language === "en" ? "Linesmen" : "Линейные судьи", linesmen, "linesman")}
|
||
</div>`;
|
||
return node;
|
||
}
|
||
|
||
function hockeyEventsNode(component, runtime) {
|
||
const props = component.props || {};
|
||
const language = getByPath(state.data, "hockey.language.display") === "en" ? "en" : "ru";
|
||
const payload = getByPath(state.data, props.eventsPath || "hockey.selected_game.events") || {};
|
||
const shotsPayload = getByPath(state.data, props.shotsMapPath || "hockey.selected_game.shots_map") || {};
|
||
const eventItems = Array.isArray(payload.items) ? payload.items : [];
|
||
const shotItems = shotsPayload.available && Array.isArray(shotsPayload.items)
|
||
? shotsPayload.items.map((item, index) => ({
|
||
...item,
|
||
id: `shot-${item.id || index}`,
|
||
category: "shot",
|
||
type: "shot",
|
||
title: item.result_label || (language === "en" ? "Shot" : "Бросок"),
|
||
description: [item.player_number ? `№${item.player_number}` : "", item.player_name || "", item.team_name || ""].filter(Boolean).join(" · "),
|
||
label: language === "en" ? "Shot" : "Бросок",
|
||
}))
|
||
: [];
|
||
const allItems = [...eventItems, ...shotItems];
|
||
const segmentSet = new Set(["all", ...(payload.segments || []), ...(shotsPayload.segments || [])].map(String));
|
||
const segments = [...segmentSet].sort((a, b) => a === "all" ? -1 : b === "all" ? 1 : Number(a) - Number(b));
|
||
const singularLabels = hockeyEventCategoryLabels(language);
|
||
const categoryLabels = language === "en" ? {
|
||
all: "All", goal: "Goals", penalty: "Penalties", shot: "Shots", shootout: "Shootout", period: "Periods", timeout: "Timeouts", goalie: "Goalies", comment: "Comments", info: "Other"
|
||
} : {
|
||
all: "Все", goal: "Голы", penalty: "Удаления", shot: "Броски", shootout: "Буллиты", period: "Периоды", timeout: "Тайм-ауты", goalie: "Вратари", comment: "Комментарии", info: "Прочее"
|
||
};
|
||
const categories = ["all", ...Object.keys(categoryLabels).filter((key) => key !== "all" && allItems.some((item) => hockeyEventCategory(item) === key))];
|
||
const periodKey = `hockey-events-period:${component.id}`;
|
||
const typeKey = `hockey-events-type:${component.id}`;
|
||
let selectedPeriod = String(state.formValues[periodKey] || "all");
|
||
let selectedType = String(state.formValues[typeKey] || "all");
|
||
if (!segments.includes(selectedPeriod)) selectedPeriod = "all";
|
||
if (!categories.includes(selectedType)) selectedType = "all";
|
||
rememberUiNavigationState(component, "events_period", selectedPeriod, selectedPeriod, { emit: false });
|
||
rememberUiNavigationState(component, "events_type", selectedType, selectedType, { emit: false });
|
||
const items = allItems.filter((item) => {
|
||
if (selectedPeriod !== "all" && String(item?.period || "") !== selectedPeriod) return false;
|
||
if (selectedType !== "all" && hockeyEventCategory(item) !== selectedType) return false;
|
||
return true;
|
||
}).sort((a, b) => Number(b?.sort_seconds ?? -1) - Number(a?.sort_seconds ?? -1) || Number(a?.source_index || 0) - Number(b?.source_index || 0));
|
||
const node = div("hockey-match-events");
|
||
node.style.setProperty("--hme-home", props.homeColor || "#4d9cff");
|
||
node.style.setProperty("--hme-away", props.awayColor || "#ff5f79");
|
||
if (!payload.available && !shotItems.length) {
|
||
node.innerHTML = `<div class="hme-empty"><strong>${language === "en" ? "Events are unavailable" : "События недоступны"}</strong><span>${language === "en" ? "The match JSON does not contain events" : "В JSON матча нет массива events"}</span></div>`;
|
||
return node;
|
||
}
|
||
node.innerHTML = `
|
||
<header class="hme-header"><div><span>PLAY-BY-PLAY</span><strong>${language === "en" ? "Match timeline" : "Лента событий"}</strong><small>${language === "en" ? "Events and shots from match JSON" : "События и броски из JSON матча"}</small></div><b>${items.length}</b></header>
|
||
<div class="hme-filter-row">
|
||
<nav class="hme-periods">${segments.map((period) => `<button type="button" data-event-period="${escapeHtml(period)}" class="${period === selectedPeriod ? "active" : ""}">${escapeHtml(period === "all" ? (language === "en" ? "Whole game" : "Весь матч") : hockeyStatisticsSegmentLabel(period, language))}</button>`).join("")}</nav>
|
||
<nav class="hme-types">${categories.map((category) => `<button type="button" data-event-type="${category}" class="${category === selectedType ? "active" : ""}">${escapeHtml(categoryLabels[category] || category)}</button>`).join("")}</nav>
|
||
</div>
|
||
<div class="hme-timeline">${items.length ? items.map((item) => {
|
||
const category = hockeyEventCategory(item);
|
||
const side = item?.side === "away" ? "away" : item?.side === "home" ? "home" : "neutral";
|
||
const period = item?.period ? hockeyStatisticsSegmentLabel(String(item.period), language) : "";
|
||
const title = item?.title || item?.label || singularLabels[category] || singularLabels.info;
|
||
const baseDescription = item?.description && item.description !== title ? item.description : "";
|
||
const player = [item?.player_number ? `№${item.player_number}` : "", item?.player_name || ""].filter(Boolean).join(" ");
|
||
const penaltyReason = category === "penalty" ? String(item?.penalty_reason || "").trim() : "";
|
||
const penaltyMinutes = category === "penalty" && item?.penalty_minutes
|
||
? `${item.penalty_minutes} ${language === "en" ? "min" : "мин"}`
|
||
: "";
|
||
const description = category === "penalty"
|
||
? [item?.team_penalty ? "" : player, penaltyMinutes, penaltyReason]
|
||
.filter((value, index, values) => value && values.indexOf(value) === index)
|
||
.join(" · ") || baseDescription
|
||
: baseDescription;
|
||
return `<article class="hme-event side-${side} category-${escapeHtml(category)}">
|
||
<div class="hme-event-time"><strong>${escapeHtml(item?.time || item?.moscow_time || "—")}</strong><span>${escapeHtml(period)}</span></div>
|
||
${hockeyEventIconMarkup(category, language)}
|
||
<div class="hme-event-body"><span>${escapeHtml(categoryLabels[category] || item?.label || "")}</span><strong>${escapeHtml(title)}</strong>${description ? `<p>${escapeHtml(description)}</p>` : ""}${player && !description.includes(player) ? `<small>${escapeHtml(player)}</small>` : ""}</div>
|
||
<div class="hme-event-team"><strong>${escapeHtml(item?.team_name || "")}</strong>${item?.score ? `<b>${escapeHtml(item.score)}</b>` : ""}</div>
|
||
</article>`;
|
||
}).join("") : `<div class="hme-no-results">${language === "en" ? "No events for selected filters" : "Нет событий для выбранных фильтров"}</div>`}</div>`;
|
||
node.querySelectorAll("[data-event-period]").forEach((button) => {
|
||
button.disabled = !runtime;
|
||
button.addEventListener("click", () => {
|
||
if (!runtime) return;
|
||
state.formValues[periodKey] = button.dataset.eventPeriod || "all";
|
||
rememberUiNavigationState(component, "events_period", state.formValues[periodKey], button.textContent || state.formValues[periodKey]);
|
||
renderRuntime();
|
||
});
|
||
});
|
||
node.querySelectorAll("[data-event-type]").forEach((button) => {
|
||
button.disabled = !runtime;
|
||
button.addEventListener("click", () => {
|
||
if (!runtime) return;
|
||
state.formValues[typeKey] = button.dataset.eventType || "all";
|
||
rememberUiNavigationState(component, "events_type", state.formValues[typeKey], button.textContent || state.formValues[typeKey]);
|
||
renderRuntime();
|
||
});
|
||
});
|
||
return node;
|
||
}
|
||
|
||
function hockeyTeamStatisticsNode(component, runtime) {
|
||
const props = component.props || {};
|
||
const language = getByPath(state.data, "hockey.language.display") === "en" ? "en" : "ru";
|
||
const statistics = getByPath(state.data, props.statsPath) || {};
|
||
const shotsMap = getByPath(state.data, props.shotsMapPath || "hockey.selected_game.shots_map") || {};
|
||
const modeKey = `hockey-team-statistics-mode:${component.id}`;
|
||
const hasShotsMap = Boolean(shotsMap.available && Array.isArray(shotsMap.items) && shotsMap.items.length);
|
||
let selectedMode = String(state.formValues[modeKey] || "metrics");
|
||
if (!["metrics", "shots-map"].includes(selectedMode) || (selectedMode === "shots-map" && !hasShotsMap)) selectedMode = "metrics";
|
||
rememberUiNavigationState(component, "team_mode", selectedMode, selectedMode, { emit: false });
|
||
const modeMarkup = hasShotsMap ? `<nav class="hts-mode-tabs"><button type="button" data-team-stat-mode="metrics" class="${selectedMode === "metrics" ? "active" : ""}">${language === "en" ? "Metrics" : "Показатели"}</button><button type="button" data-team-stat-mode="shots-map" class="${selectedMode === "shots-map" ? "active" : ""}">${language === "en" ? "Shot map" : "Карта бросков"}</button></nav>` : "";
|
||
const attachModeNavigation = (root) => root.querySelectorAll("[data-team-stat-mode]").forEach((button) => {
|
||
button.disabled = !runtime;
|
||
button.addEventListener("click", () => {
|
||
if (!runtime) return;
|
||
state.formValues[modeKey] = button.dataset.teamStatMode || "metrics";
|
||
rememberUiNavigationState(component, "team_mode", state.formValues[modeKey], button.textContent || state.formValues[modeKey]);
|
||
renderRuntime();
|
||
});
|
||
});
|
||
const segments = Array.isArray(statistics.segments) && statistics.segments.length
|
||
? statistics.segments.map(String)
|
||
: ["total"];
|
||
const stateKey = `hockey-statistics:${component.id}`;
|
||
let selected = String(state.formValues[stateKey] || "total");
|
||
if (!segments.includes(selected)) selected = segments[0];
|
||
rememberUiNavigationState(component, "team_segment", selected, hockeyStatisticsSegmentLabel(selected, language), { emit: false });
|
||
const selectedData = selected === "total"
|
||
? statistics.total
|
||
: statistics.periods?.[selected];
|
||
const home = selectedData?.home || {};
|
||
const away = selectedData?.away || {};
|
||
const homeName = formatValue(getByPath(state.data, props.homeTeamPath), language === "en" ? "Home" : "Хозяева");
|
||
const awayName = formatValue(getByPath(state.data, props.awayTeamPath), language === "en" ? "Away" : "Гости");
|
||
const homeScore = formatValue(getByPath(state.data, props.homeScorePath), "0");
|
||
const awayScore = formatValue(getByPath(state.data, props.awayScorePath), "0");
|
||
const node = div("hockey-team-statistics");
|
||
node.classList.toggle("has-mode-tabs", hasShotsMap);
|
||
node.style.setProperty("--hts-home", props.homeColor || "#4d9cff");
|
||
node.style.setProperty("--hts-away", props.awayColor || "#ff5f79");
|
||
|
||
if (selectedMode === "shots-map" && hasShotsMap) {
|
||
node.classList.add("is-shot-map");
|
||
node.innerHTML = modeMarkup;
|
||
node.appendChild(hockeyShotsMapNode({ ...component, id: `${component.id}-shots-map` }, runtime));
|
||
attachModeNavigation(node);
|
||
return node;
|
||
}
|
||
|
||
if (!statistics.available) {
|
||
node.innerHTML = `
|
||
${modeMarkup}
|
||
<div class="hts-empty">
|
||
<strong>${language === "en" ? "Team statistics are not loaded" : "Командная статистика не загружена"}</strong>
|
||
<span>${language === "en" ? "Open a game to load Stat2TV data" : "Откройте матч, чтобы загрузить данные Stat2TV"}</span>
|
||
</div>
|
||
`;
|
||
attachModeNavigation(node);
|
||
return node;
|
||
}
|
||
|
||
const metricItems = HOCKEY_TEAM_STAT_METRICS
|
||
.filter(([key]) => home[key] !== null && home[key] !== undefined || away[key] !== null && away[key] !== undefined)
|
||
.map(([key, labelRu, labelEn, format, ref]) => {
|
||
const homeRaw = home[key];
|
||
const awayRaw = away[key];
|
||
const homeComparable = Math.max(0, hockeyStatisticComparable(homeRaw));
|
||
const awayComparable = Math.max(0, hockeyStatisticComparable(awayRaw));
|
||
const total = homeComparable + awayComparable;
|
||
const homeShare = total > 0 ? homeComparable / total * 100 : 50;
|
||
const awayShare = total > 0 ? awayComparable / total * 100 : 50;
|
||
return `
|
||
<article class="hts-metric">
|
||
<strong class="hts-home-value">${escapeHtml(hockeyStatisticValue(homeRaw, format))}</strong>
|
||
<div>
|
||
<span${hockeyStatTooltipAttrs(ref, language, "team")}>${escapeHtml(language === "en" ? labelEn : labelRu)}</span>
|
||
<div class="hts-comparison" aria-hidden="true">
|
||
<i class="home" style="width:${homeShare.toFixed(2)}%"></i>
|
||
<i class="away" style="width:${awayShare.toFixed(2)}%"></i>
|
||
</div>
|
||
</div>
|
||
<strong class="hts-away-value">${escapeHtml(hockeyStatisticValue(awayRaw, format))}</strong>
|
||
</article>
|
||
`;
|
||
});
|
||
const metricBreak = Math.ceil(metricItems.length / 2);
|
||
const metricMarkup = `
|
||
<div class="hts-column">${metricItems.slice(0, metricBreak).join("")}</div>
|
||
<div class="hts-column">${metricItems.slice(metricBreak).join("")}</div>
|
||
`;
|
||
|
||
node.innerHTML = `
|
||
${modeMarkup}
|
||
<header class="hts-header">
|
||
<div class="hts-team home"><span>${language === "en" ? "HOME" : "ХОЗЯЕВА"}</span><strong>${escapeHtml(homeName)}</strong>${selected === "total" && statistics.coaches?.home ? `<small>${language === "en" ? "Coach" : "Тренер"}: ${escapeHtml(statistics.coaches.home)}</small>` : ""}</div>
|
||
<div class="hts-score"><strong>${escapeHtml(homeScore)} : ${escapeHtml(awayScore)}</strong><span>${language === "en" ? "TEAM STATISTICS" : "КОМАНДНАЯ СТАТИСТИКА"}</span></div>
|
||
<div class="hts-team away"><span>${language === "en" ? "AWAY" : "ГОСТИ"}</span><strong>${escapeHtml(awayName)}</strong>${selected === "total" && statistics.coaches?.away ? `<small>${language === "en" ? "Coach" : "Тренер"}: ${escapeHtml(statistics.coaches.away)}</small>` : ""}</div>
|
||
</header>
|
||
<nav class="hts-segments" aria-label="${language === "en" ? "Statistics segment" : "Отрезок статистики"}">
|
||
${segments.map((segment) => `<button type="button" data-statistics-segment="${escapeHtml(segment)}" class="${segment === selected ? "active" : ""}">${escapeHtml(hockeyStatisticsSegmentLabel(segment, language))}</button>`).join("")}
|
||
</nav>
|
||
<section class="hts-grid">${metricMarkup}</section>
|
||
`;
|
||
attachModeNavigation(node);
|
||
node.querySelectorAll("[data-statistics-segment]").forEach((button) => {
|
||
button.disabled = !runtime;
|
||
button.addEventListener("click", () => {
|
||
if (!runtime) return;
|
||
state.formValues[stateKey] = button.dataset.statisticsSegment || "total";
|
||
ensureComponentState(component).value = state.formValues[stateKey];
|
||
rememberUiNavigationState(component, "team_segment", state.formValues[stateKey], button.textContent || state.formValues[stateKey]);
|
||
emitInteraction(component, "change", {
|
||
value: state.formValues[stateKey],
|
||
item_id: state.formValues[stateKey],
|
||
});
|
||
renderRuntime();
|
||
});
|
||
});
|
||
return node;
|
||
}
|
||
|
||
function hockeyStandingCell(value, fallback = "—") {
|
||
return value === null || value === undefined || value === "" ? fallback : String(value);
|
||
}
|
||
|
||
function hockeyStandingsGroupMarkup(group, language) {
|
||
const teams = Array.isArray(group?.teams) ? group.teams : [];
|
||
const playoffPlaces = Math.max(0, Number(group?.playoff_places || 0));
|
||
const title = group?.name || (language === "en" ? "Standings" : "Таблица");
|
||
const defs = [["rank","#"],["team",language === "en" ? "Team" : "Команда"],["gp",language === "en" ? "GP" : "И"],["w",language === "en" ? "W" : "В"],["otw",language === "en" ? "OTW" : "ВО"],["sow",language === "en" ? "SOW" : "ВБ"],["sol",language === "en" ? "SOL" : "ПБ"],["otl",language === "en" ? "OTL" : "ПО"],["l",language === "en" ? "L" : "П"],["g",language === "en" ? "G" : "Ш"],["pts",language === "en" ? "PTS" : "О"]];
|
||
const rows = teams.map((team, index) => {
|
||
const highlighted = Boolean(team?.highlighted); const side = team?.side === "away" ? "away" : team?.side === "home" ? "home" : ""; const cutoff = playoffPlaces > 0 && index === playoffPlaces;
|
||
const goals = `${hockeyStandingCell(team?.goals_for, "0")}:${hockeyStandingCell(team?.goals_against, "0")}`;
|
||
return `<tr class="${highlighted ? `is-highlighted side-${side}` : ""} ${cutoff ? "playoff-cut" : ""}"><td class="htb-rank">${escapeHtml(hockeyStandingCell(team?.rank, String(index+1)))}</td><td class="htb-team"><i></i><strong>${escapeHtml(team?.name || "—")}</strong></td><td>${escapeHtml(hockeyStandingCell(team?.games,"0"))}</td><td>${escapeHtml(hockeyStandingCell(team?.wins,"0"))}</td><td>${escapeHtml(hockeyStandingCell(team?.overtime_wins,"0"))}</td><td>${escapeHtml(hockeyStandingCell(team?.shootout_wins,"0"))}</td><td>${escapeHtml(hockeyStandingCell(team?.shootout_losses,"0"))}</td><td>${escapeHtml(hockeyStandingCell(team?.overtime_losses,"0"))}</td><td>${escapeHtml(hockeyStandingCell(team?.losses,"0"))}</td><td class="htb-goals">${escapeHtml(goals)}</td><td class="htb-points">${escapeHtml(hockeyStandingCell(team?.points,"0"))}</td></tr>`;
|
||
}).join("");
|
||
const headers=defs.map(([ref,fallback],index)=>`<th class="${index===1?"htb-team":""}"${hockeyStatTooltipAttrs(ref,language,"standings")}>${escapeHtml(hockeyStatLabel(ref,fallback,language,"standings"))}</th>`).join("");
|
||
return `<section class="htb-group"><header><strong>${escapeHtml(title)}</strong><span>${teams.length}</span></header><div class="htb-table-scroll"><table><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table></div></section>`;
|
||
}
|
||
|
||
function hockeyTournamentStandingsNode(component, runtime) {
|
||
const props = component.props || {};
|
||
const language = getByPath(state.data, "hockey.language.display") === "en" ? "en" : "ru";
|
||
const standings = getByPath(state.data, props.standingsPath) || {};
|
||
const variants = Array.isArray(standings.variants) ? standings.variants : [];
|
||
const stateKey = `hockey-standings:${component.id}`;
|
||
let selected = String(state.formValues[stateKey] || component.initial_state || "league");
|
||
if (!variants.some((variant) => String(variant.id) === selected)) {
|
||
selected = String(variants[0]?.id || "league");
|
||
}
|
||
rememberUiNavigationState(component, "standings_view", selected, selected, { emit: false });
|
||
const variant = variants.find((item) => String(item.id) === selected) || variants[0];
|
||
const groups = Array.isArray(variant?.groups) ? variant.groups : [];
|
||
const node = div("hockey-tournament-standings");
|
||
node.style.setProperty("--htb-home", props.homeColor || "#4d9cff");
|
||
node.style.setProperty("--htb-away", props.awayColor || "#ff5f79");
|
||
|
||
if (!standings.available || !variants.length) {
|
||
node.innerHTML = `
|
||
<div class="htb-empty">
|
||
<strong>${language === "en" ? "Standings are not loaded" : "Турнирная таблица не загружена"}</strong>
|
||
<span>${language === "en" ? "Select a tournament to load Stat2TV data" : "Выберите турнир, чтобы загрузить данные Stat2TV"}</span>
|
||
</div>
|
||
`;
|
||
return node;
|
||
}
|
||
|
||
node.innerHTML = `
|
||
<header class="htb-header">
|
||
<div>
|
||
<span>${language === "en" ? "TOURNAMENT" : "ТУРНИР"}</span>
|
||
<strong>${language === "en" ? "Standings" : "Турнирная таблица"}</strong>
|
||
</div>
|
||
<nav aria-label="${language === "en" ? "Standings view" : "Вид турнирной таблицы"}">
|
||
${variants.map((item) => `<button type="button" data-standings-variant="${escapeHtml(item.id)}" class="${String(item.id) === selected ? "active" : ""}">${escapeHtml(item.label || item.id)}</button>`).join("")}
|
||
</nav>
|
||
<small>${standings.generated_at ? `${language === "en" ? "Updated" : "Обновлено"}: ${escapeHtml(standings.generated_at)}` : ""}</small>
|
||
</header>
|
||
<div class="htb-groups groups-${Math.min(4, Math.max(1, groups.length))} variant-${escapeHtml(selected)}">
|
||
${groups.map((group) => hockeyStandingsGroupMarkup(group, language)).join("")}
|
||
</div>
|
||
<footer class="htb-legend">
|
||
<span class="home"><i></i>${language === "en" ? "Home team" : "Хозяева"}</span>
|
||
<span class="away"><i></i>${language === "en" ? "Away team" : "Гости"}</span>
|
||
</footer>
|
||
`;
|
||
node.querySelectorAll("[data-standings-variant]").forEach((button) => {
|
||
button.disabled = !runtime;
|
||
button.addEventListener("click", () => {
|
||
if (!runtime) return;
|
||
state.formValues[stateKey] = button.dataset.standingsVariant || "league";
|
||
ensureComponentState(component).value = state.formValues[stateKey];
|
||
rememberUiNavigationState(component, "standings_view", state.formValues[stateKey], button.textContent || state.formValues[stateKey]);
|
||
emitInteraction(component, "change", {
|
||
value: state.formValues[stateKey],
|
||
item_id: state.formValues[stateKey],
|
||
});
|
||
renderRuntime();
|
||
});
|
||
});
|
||
return node;
|
||
}
|
||
|
||
function hockeyPlayerTimeSeconds(value) {
|
||
const match = String(value || "").match(/^(\d+):(\d{2})$/);
|
||
return match ? Number(match[1]) * 60 + Number(match[2]) : 0;
|
||
}
|
||
|
||
function hockeyPlayerMetric(value, suffix = "") {
|
||
if (value === null || value === undefined || value === "") return "—";
|
||
return `${value}${suffix}`;
|
||
}
|
||
|
||
function hockeyPlayerFlagMarkup(player) {
|
||
const code = String(player?.country_code || "").trim().toLowerCase().replace(/[^a-z]/g, "").slice(0, 2);
|
||
const title = escapeHtml(player?.country_name || player?.country_code || "");
|
||
if (code.length === 2) {
|
||
return `<img class="ppi-country-flag" src="/hockey-assets/flags/${code}.svg" alt="" title="${title}">`;
|
||
}
|
||
const flag = String(player?.country_flag || player?.flag || "").trim();
|
||
return flag ? `<i class="ppi-country-flag" title="${title}">${escapeHtml(flag)}</i>` : "";
|
||
}
|
||
|
||
function hockeyPlayerBioItems(player, language) {
|
||
const birth = String(player?.birth_date || "").trim();
|
||
const age = player?.age;
|
||
const birthAge = birth ? `${birth}${age !== null && age !== undefined ? ` · ${age} ${language === "en" ? "y.o." : "лет"}` : ""}` : (age !== null && age !== undefined ? `${age} ${language === "en" ? "y.o." : "лет"}` : "");
|
||
const position = String(player?.position || "").trim();
|
||
const country = [player?.country_flag, player?.country_name || player?.country_code].filter(Boolean).join(" ");
|
||
return [
|
||
[language === "en" ? "Position" : "Амплуа", position],
|
||
[language === "en" ? "Born / age" : "Дата рождения / возраст", birthAge],
|
||
[language === "en" ? "Height" : "Рост", player?.height_cm ? `${player.height_cm} ${language === "en" ? "cm" : "см"}` : ""],
|
||
[language === "en" ? "Weight" : "Вес", player?.weight_kg ? `${player.weight_kg} ${language === "en" ? "kg" : "кг"}` : ""],
|
||
[language === "en" ? "Stick" : "Хват", player?.stick || ""],
|
||
[language === "en" ? "Country" : "Страна", country],
|
||
].filter(([, value]) => value !== null && value !== undefined && String(value).trim() !== "");
|
||
}
|
||
|
||
function hockeyLeaderGroup(players, key, kind = "number", source = "statistics") {
|
||
const values = (players || []).map((player) => {
|
||
const raw = player?.[source]?.[key];
|
||
const value = kind === "time" ? hockeyPlayerTimeSeconds(raw) : Number(raw || 0);
|
||
return { player, raw, value: Number.isFinite(value) ? value : 0 };
|
||
});
|
||
if (!values.length) return { value: "—", players: [] };
|
||
const max = Math.max(...values.map((item) => item.value));
|
||
if (!(max > 0)) return { value: "—", players: [] };
|
||
const leaders = values.filter((item) => item.value === max).map((item) => item.player);
|
||
const display = kind === "time" ? values.find((item) => item.value === max)?.raw : max;
|
||
return { value: hockeyPlayerMetric(display), players: leaders };
|
||
}
|
||
|
||
function hockeyLeaderNamesMarkup(players) {
|
||
if (!players?.length) return "—";
|
||
const item = (player) => `<span>${hockeyPlayerFlagMarkup(player)}<strong>${escapeHtml(player?.name || "—")}</strong></span>`;
|
||
if (players.length === 1) return `<span class="ppi-leader-single">${hockeyPlayerFlagMarkup(players[0])}<strong>${escapeHtml(players[0]?.name || "—")}</strong></span>`;
|
||
return `<details class="ppi-leader-list"><summary>${hockeyPlayerFlagMarkup(players[0])}<strong>${escapeHtml(players[0]?.name || "—")}</strong><b>+${players.length - 1}</b></summary><div>${players.map(item).join("")}</div></details>`;
|
||
}
|
||
|
||
function hockeyPlayerTableMarkup(side, teamName, players, language) {
|
||
const sideLabel = language === "en" ? (side === "home" ? "HOME" : "AWAY") : (side === "home" ? "ХОЗЯЕВА" : "ГОСТИ");
|
||
const headerDefs = [
|
||
["number", language === "en" ? "#" : "№", ""], ["player", language === "en" ? "Player" : "Игрок", "skaters"],
|
||
["position", language === "en" ? "Pos" : "Поз", "skaters"], ["g", language === "en" ? "G" : "Г", "skaters"],
|
||
["a", language === "en" ? "A" : "П", "skaters"], ["pts", language === "en" ? "P" : "О", "skaters"],
|
||
["sog", language === "en" ? "S" : "Бр", "skaters"], ["hits", language === "en" ? "H" : "Сил", "skaters"],
|
||
["bls", language === "en" ? "BL" : "Бл", "skaters"], ["pim", language === "en" ? "PIM" : "Штр", "skaters"],
|
||
["toi", language === "en" ? "TOI" : "Время", "skaters"],
|
||
];
|
||
const rows = players.map((player) => {
|
||
const stats = player.statistics || {};
|
||
const captain = player.captain_role === "captain" ? "C" : player.captain_role === "assistant" ? "A" : "";
|
||
const playerKey = `${side}:${player.id || player.external_id || player.number}`;
|
||
const search = `${player.number || ""} ${player.name || ""}`.toLocaleLowerCase();
|
||
return `<tr data-player-key="${escapeHtml(playerKey)}" data-player-search="${escapeHtml(search)}" class="${stats.played ? "" : "not-played"}">
|
||
<td class="ppi-number">${escapeHtml(player.number || "—")}</td>
|
||
<td class="ppi-player-name">${hockeyPlayerFlagMarkup(player)}<strong>${escapeHtml(player.name || "—")}</strong>${captain ? `<i>${captain}</i>` : ""}</td>
|
||
<td>${escapeHtml(player.position || "—")}</td><td>${escapeHtml(hockeyPlayerMetric(stats.goals, ""))}</td>
|
||
<td>${escapeHtml(hockeyPlayerMetric(stats.assists, ""))}</td><td class="ppi-points">${escapeHtml(hockeyPlayerMetric(stats.points, ""))}</td>
|
||
<td>${escapeHtml(hockeyPlayerMetric(stats.shots, ""))}</td><td>${escapeHtml(hockeyPlayerMetric(stats.hits, ""))}</td>
|
||
<td>${escapeHtml(hockeyPlayerMetric(stats.blocked_shots, ""))}</td><td>${escapeHtml(hockeyPlayerMetric(stats.penalty_minutes, ""))}</td>
|
||
<td class="ppi-toi">${escapeHtml(hockeyPlayerMetric(stats.time_on_ice, ""))}</td></tr>`;
|
||
}).join("");
|
||
const headers = headerDefs.map(([ref, fallback, section], index) => `<th class="${index === 1 ? "ppi-player-name" : ""}"${hockeyStatTooltipAttrs(ref, language, section)}>${escapeHtml(hockeyStatLabel(ref, fallback, language, section))}</th>`).join("");
|
||
return `<section class="ppi-team-table side-${side}"><header><div><span>${sideLabel}</span><strong>${escapeHtml(teamName)}</strong></div><b>${players.length}</b></header><div class="ppi-table-wrap"><table><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table></div></section>`;
|
||
}
|
||
|
||
function hockeyGoalkeeperCardMarkup(side, player, language) {
|
||
const stats = player.statistics || {};
|
||
const played = Boolean(stats.played);
|
||
const playerKey = `${side}:${player.id || player.external_id || player.number}`;
|
||
return `
|
||
<article class="ppi-goalie-card side-${side} ${played ? "" : "not-played"}" data-player-key="${escapeHtml(playerKey)}">
|
||
<div class="ppi-goalie-person"><b>${escapeHtml(player.number || "—")}</b><span><strong>${hockeyPlayerFlagMarkup(player)}${escapeHtml(player.name || "—")}</strong><small>${played ? (language === "en" ? "Played" : "Играл") : (language === "en" ? "Did not play" : "Не играл")}</small></span></div>
|
||
<dl>
|
||
<div><dt>${language === "en" ? "SA" : "Броски"}</dt><dd>${escapeHtml(hockeyPlayerMetric(stats.shots_against))}</dd></div>
|
||
<div><dt>${language === "en" ? "SV" : "Сейвы"}</dt><dd>${escapeHtml(hockeyPlayerMetric(stats.saves))}</dd></div>
|
||
<div><dt>${language === "en" ? "GA" : "Проп."}</dt><dd>${escapeHtml(hockeyPlayerMetric(stats.goals_against))}</dd></div>
|
||
<div><dt>${language === "en" ? "SV%" : "% ОБ"}</dt><dd>${escapeHtml(hockeyPlayerMetric(stats.save_pct, stats.save_pct === null || stats.save_pct === undefined ? "" : "%"))}</dd></div>
|
||
<div><dt>${language === "en" ? "TOI" : "Время"}</dt><dd>${escapeHtml(hockeyPlayerMetric(stats.time_on_ice))}</dd></div>
|
||
</dl>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
function hockeyPlayerDetailMarkup(player, language) {
|
||
const stats = player?.statistics || {};
|
||
const seasonStats = player?.season_statistics || {};
|
||
const goalie = player?.role === "goalkeeper" || stats.kind === "goalkeeper";
|
||
const bioItems = hockeyPlayerBioItems(player, language);
|
||
const metrics = goalie ? [
|
||
[language === "en" ? "Match participation" : "Участие в матче", stats.played ? (language === "en" ? "Played" : "Играл") : (language === "en" ? "Did not play" : "Не играл")],
|
||
...(seasonStats.available ? [[language === "en" ? "Tournament games" : "Матчи в турнире", seasonStats.games]] : []),
|
||
[language === "en" ? "Shots against" : "Броски по воротам", stats.shots_against],
|
||
[language === "en" ? "Saves" : "Отражено", stats.saves],
|
||
[language === "en" ? "Goals against" : "Пропущено", stats.goals_against],
|
||
[language === "en" ? "Save percentage" : "Процент отражённых", hockeyPlayerMetric(stats.save_pct, stats.save_pct === null || stats.save_pct === undefined ? "" : "%")],
|
||
[language === "en" ? "Time on ice" : "Время на льду", stats.time_on_ice],
|
||
[language === "en" ? "Assists" : "Передачи", stats.assists],
|
||
[language === "en" ? "Penalty minutes" : "Штрафные минуты", stats.penalty_minutes],
|
||
] : [
|
||
[language === "en" ? "Goals" : "Голы", stats.goals],
|
||
[language === "en" ? "Assists" : "Передачи", stats.assists],
|
||
[language === "en" ? "Points" : "Очки", stats.points],
|
||
[language === "en" ? "Shots" : "Броски", stats.shots],
|
||
[language === "en" ? "Hits" : "Силовые приёмы", stats.hits],
|
||
[language === "en" ? "Blocked shots" : "Блокированные броски", stats.blocked_shots],
|
||
[language === "en" ? "Takeaways" : "Перехваты", stats.takeaways],
|
||
[language === "en" ? "Penalty minutes" : "Штрафные минуты", stats.penalty_minutes],
|
||
[language === "en" ? "Faceoffs" : "Вбрасывания", `${hockeyPlayerMetric(stats.faceoffs_won, "")} / ${hockeyPlayerMetric(stats.faceoffs, "")}`],
|
||
[language === "en" ? "Faceoff percentage" : "Выиграно вбрасываний", hockeyPlayerMetric(stats.faceoff_pct, stats.faceoff_pct === null || stats.faceoff_pct === undefined ? "" : "%")],
|
||
[language === "en" ? "Time on ice" : "Время на льду", stats.time_on_ice],
|
||
[language === "en" ? "Even strength" : "В равных составах", stats.even_strength_time],
|
||
[language === "en" ? "Power play" : "В большинстве", stats.power_play_time],
|
||
[language === "en" ? "Short-handed" : "В меньшинстве", stats.short_handed_time],
|
||
[language === "en" ? "Shifts" : "Смены", stats.shifts],
|
||
];
|
||
const shotMap = hockeyPlayerShotMapMarkup(player, language);
|
||
return `
|
||
<div class="ppi-detail-backdrop" data-player-detail-close></div>
|
||
<aside class="ppi-player-detail ${shotMap ? "has-shot-map" : ""}">
|
||
<button type="button" data-player-detail-close aria-label="${language === "en" ? "Close" : "Закрыть"}">×</button>
|
||
<div class="ppi-detail-layout">
|
||
<div class="ppi-detail-info">
|
||
<header>
|
||
<b>${escapeHtml(player?.number || "—")}</b>
|
||
<div><span>${hockeyPlayerFlagMarkup(player)}${goalie ? (language === "en" ? "GOALKEEPER" : "ВРАТАРЬ") : escapeHtml(player?.position || "")}</span><strong>${escapeHtml(player?.name || "—")}</strong><small>${player?.line ? `${language === "en" ? "Line" : "Звено"}: ${escapeHtml(player.line)}` : ""}</small></div>
|
||
</header>
|
||
${bioItems.length ? `<div class="ppi-profile-bio">${bioItems.map(([label, value]) => `<div><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`).join("")}</div>` : ""}
|
||
<section>${metrics.map(([label, value]) => `<div><span>${escapeHtml(label)}</span><strong>${escapeHtml(hockeyPlayerMetric(value))}</strong></div>`).join("")}</section>
|
||
</div>
|
||
${shotMap}
|
||
</div>
|
||
</aside>
|
||
`;
|
||
}
|
||
|
||
function hockeyPlayerStatisticsNode(component, runtime) {
|
||
const props = component.props || {};
|
||
const language = getByPath(state.data, "hockey.language.display") === "en" ? "en" : "ru";
|
||
const payload = getByPath(state.data, props.statsPath) || {};
|
||
const homeName = formatValue(getByPath(state.data, props.homeTeamPath), language === "en" ? "Home" : "Хозяева");
|
||
const awayName = formatValue(getByPath(state.data, props.awayTeamPath), language === "en" ? "Away" : "Гости");
|
||
const filterKey = `hockey-player-filter:${component.id}`;
|
||
const searchKey = `hockey-player-search:${component.id}`;
|
||
const detailKey = `hockey-player-detail:${component.id}`;
|
||
const allowedFilters = ["all", "home", "away"];
|
||
let selectedFilter = String(state.formValues[filterKey] || component.initial_state || "all");
|
||
if (!allowedFilters.includes(selectedFilter)) selectedFilter = "all";
|
||
rememberUiNavigationState(component, "players_filter", selectedFilter, selectedFilter, { emit: false });
|
||
const query = String(state.formValues[searchKey] || "").trim().toLocaleLowerCase();
|
||
const sides = selectedFilter === "all" ? ["home", "away"] : [selectedFilter];
|
||
const teamNames = { home: homeName, away: awayName };
|
||
const allPlayers = [
|
||
...(payload.home?.skaters || []), ...(payload.home?.goalkeepers || []),
|
||
...(payload.away?.skaters || []), ...(payload.away?.goalkeepers || []),
|
||
];
|
||
const selectedPlayerKey = String(state.formValues[detailKey] || "");
|
||
const selectedPlayer = allPlayers.find((player) => {
|
||
const side = player.side || (
|
||
(payload.home?.skaters || []).includes(player)
|
||
|| (payload.home?.goalkeepers || []).includes(player)
|
||
? "home"
|
||
: "away"
|
||
);
|
||
return `${side}:${player.id || player.external_id || player.number}` === selectedPlayerKey;
|
||
});
|
||
const visibleSkaters = sides.flatMap((side) =>
|
||
(payload[side]?.skaters || []).map((player) => ({ ...player, side }))
|
||
).filter((player) => !query || `${player.number || ""} ${player.name || ""}`.toLocaleLowerCase().includes(query));
|
||
const playedSkaters = visibleSkaters.filter((player) => player.statistics?.played);
|
||
const leaderDefinitions = [
|
||
["points", language === "en" ? "Points" : "Очки", "points"],
|
||
["goals", language === "en" ? "Goals" : "Голы", "goals"],
|
||
["assists", language === "en" ? "Assists" : "Передачи", "assists"],
|
||
["shots", language === "en" ? "Shots" : "Броски", "shots"],
|
||
["time_on_ice", language === "en" ? "Time on ice" : "Время на льду", "time"],
|
||
];
|
||
const leaders = leaderDefinitions.map(([key, label, kind]) => ({
|
||
key, label, group: hockeyLeaderGroup(playedSkaters, key, kind, "statistics"),
|
||
}));
|
||
const node = div("hockey-player-statistics");
|
||
node.style.setProperty("--ppi-home", props.homeColor || "#4d9cff");
|
||
node.style.setProperty("--ppi-away", props.awayColor || "#ff5f79");
|
||
|
||
if (!payload.available) {
|
||
node.innerHTML = `<div class="ppi-empty"><strong>${language === "en" ? "Player statistics are not loaded" : "Статистика игроков не загружена"}</strong><span>${language === "en" ? "Open a game to load Stat2TV data" : "Откройте матч, чтобы загрузить данные Stat2TV"}</span></div>`;
|
||
return node;
|
||
}
|
||
|
||
node.innerHTML = `
|
||
<header class="ppi-header">
|
||
<div><span>${language === "en" ? "MATCH" : "МАТЧ"}</span><strong>${language === "en" ? "Player statistics" : "Статистика игроков"}</strong></div>
|
||
<nav>${[
|
||
["all", language === "en" ? "All" : "Все"],
|
||
["home", homeName],
|
||
["away", awayName],
|
||
].map(([id, label]) => `<button type="button" data-player-filter="${id}" class="${id === selectedFilter ? "active" : ""}">${escapeHtml(label)}</button>`).join("")}</nav>
|
||
<label><span>⌕</span><input type="search" data-player-search-input value="${escapeHtml(query)}" placeholder="${language === "en" ? "Number or player" : "Номер или игрок"}"></label>
|
||
</header>
|
||
<section class="ppi-leaders leaders-${leaders.length}">
|
||
${leaders.map((leader) => `<article><span>${escapeHtml(leader.label)}</span><strong>${escapeHtml(leader.group.value)}</strong><small class="ppi-leader-names">${hockeyLeaderNamesMarkup(leader.group.players)}</small></article>`).join("")}
|
||
</section>
|
||
<div class="ppi-skater-tables columns-${sides.length}">
|
||
${sides.map((side) => hockeyPlayerTableMarkup(side, teamNames[side], (payload[side]?.skaters || []).filter((player) => !query || `${player.number || ""} ${player.name || ""}`.toLocaleLowerCase().includes(query)), language)).join("")}
|
||
</div>
|
||
<section class="ppi-goalkeepers">
|
||
<header><strong>${language === "en" ? "Goalkeepers" : "Вратари"}</strong><span>${language === "en" ? "Separate match statistics" : "Отдельная статистика матча"}</span></header>
|
||
<div class="columns-${sides.length}">
|
||
${sides.map((side) => `<section class="ppi-goalie-team side-${side}"><strong>${escapeHtml(teamNames[side])}</strong><div>${(payload[side]?.goalkeepers || []).map((player) => hockeyGoalkeeperCardMarkup(side, player, language)).join("")}</div></section>`).join("")}
|
||
</div>
|
||
</section>
|
||
${selectedPlayer ? hockeyPlayerDetailMarkup(selectedPlayer, language) : ""}
|
||
`;
|
||
node.querySelectorAll("[data-player-filter]").forEach((button) => {
|
||
button.disabled = !runtime;
|
||
button.addEventListener("click", () => {
|
||
if (!runtime) return;
|
||
state.formValues[filterKey] = button.dataset.playerFilter || "all";
|
||
state.formValues[detailKey] = "";
|
||
ensureComponentState(component).value = state.formValues[filterKey];
|
||
rememberUiNavigationState(component, "players_filter", state.formValues[filterKey], button.textContent || state.formValues[filterKey]);
|
||
emitInteraction(component, "change", { value: state.formValues[filterKey], item_id: state.formValues[filterKey] });
|
||
renderRuntime();
|
||
});
|
||
});
|
||
const searchInput = node.querySelector("[data-player-search-input]");
|
||
if (searchInput) {
|
||
searchInput.disabled = !runtime;
|
||
searchInput.addEventListener("input", () => {
|
||
state.formValues[searchKey] = searchInput.value;
|
||
const needle = searchInput.value.trim().toLocaleLowerCase();
|
||
node.querySelectorAll("[data-player-search]").forEach((row) => {
|
||
row.hidden = Boolean(needle) && !String(row.dataset.playerSearch || "").includes(needle);
|
||
});
|
||
});
|
||
}
|
||
node.querySelectorAll("[data-player-key]").forEach((target) => {
|
||
target.addEventListener("click", () => {
|
||
if (!runtime) return;
|
||
state.formValues[detailKey] = target.dataset.playerKey || "";
|
||
renderRuntime();
|
||
});
|
||
});
|
||
node.querySelectorAll("[data-player-detail-close]").forEach((target) => {
|
||
target.addEventListener("click", () => {
|
||
if (!runtime) return;
|
||
state.formValues[detailKey] = "";
|
||
renderRuntime();
|
||
});
|
||
});
|
||
attachHockeyStatisticsTableSorting(node, `${component.id}:match-players`, runtime);
|
||
return node;
|
||
}
|
||
|
||
function hockeySeasonPlayerTableMarkup(side, teamName, players, language) {
|
||
const sideLabel = language === "en" ? (side === "home" ? "HOME" : "AWAY") : (side === "home" ? "ХОЗЯЕВА" : "ГОСТИ");
|
||
const headerDefs = [
|
||
["number", language === "en" ? "#" : "№", ""], ["player", language === "en" ? "Player" : "Игрок", "skaters"],
|
||
["gp", language === "en" ? "GP" : "И", "skaters"], ["g", language === "en" ? "G" : "Г", "skaters"],
|
||
["a", language === "en" ? "A" : "П", "skaters"], ["pts", language === "en" ? "P" : "О", "skaters"],
|
||
["pm", "+/-", "skaters"], ["sog", language === "en" ? "S" : "Бр", "skaters"],
|
||
["sog_pct", language === "en" ? "S%" : "%", "skaters"], ["pim", language === "en" ? "PIM" : "Штр", "skaters"],
|
||
["toi_avg", language === "en" ? "AVG" : "Ср. время", "skaters"],
|
||
];
|
||
const rows = players.map((player) => {
|
||
const stats = player.season_statistics || {};
|
||
const playerKey = `${side}:${player.id || player.external_id || player.number}`;
|
||
const search = `${player.number || ""} ${player.name || ""}`.toLocaleLowerCase();
|
||
return `<tr data-player-key="${escapeHtml(playerKey)}" data-player-search="${escapeHtml(search)}" class="${stats.games ? "" : "not-played"}">
|
||
<td class="ppi-number">${escapeHtml(player.number || stats.jersey_number || "—")}</td>
|
||
<td class="ppi-player-name">${hockeyPlayerFlagMarkup(player)}<strong>${escapeHtml(player.name || "—")}</strong></td>
|
||
<td>${escapeHtml(hockeyPlayerMetric(stats.games))}</td><td>${escapeHtml(hockeyPlayerMetric(stats.goals))}</td>
|
||
<td>${escapeHtml(hockeyPlayerMetric(stats.assists))}</td><td class="ppi-points">${escapeHtml(hockeyPlayerMetric(stats.points))}</td>
|
||
<td class="ppi-plus-minus ${Number(stats.plus_minus || 0) > 0 ? "positive" : Number(stats.plus_minus || 0) < 0 ? "negative" : ""}">${escapeHtml(hockeyPlayerMetric(stats.plus_minus))}</td>
|
||
<td>${escapeHtml(hockeyPlayerMetric(stats.shots))}</td><td>${escapeHtml(hockeyPlayerMetric(stats.shot_pct, "%"))}</td>
|
||
<td>${escapeHtml(hockeyPlayerMetric(stats.penalty_minutes))}</td><td class="ppi-toi">${escapeHtml(hockeyPlayerMetric(stats.average_time_on_ice))}</td></tr>`;
|
||
}).join("");
|
||
const headers = headerDefs.map(([ref, fallback, section], index) => `<th class="${index === 1 ? "ppi-player-name" : ""}"${hockeyStatTooltipAttrs(ref, language, section)}>${escapeHtml(hockeyStatLabel(ref, fallback, language, section))}</th>`).join("");
|
||
return `<section class="ppi-team-table ppi-season-table side-${side}"><header><div><span>${sideLabel}</span><strong>${escapeHtml(teamName)}</strong></div><b>${players.length}</b></header><div class="ppi-table-wrap"><table><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table></div></section>`;
|
||
}
|
||
|
||
function hockeySeasonGoalkeeperCardMarkup(side, player, language) {
|
||
const stats = player.season_statistics || {};
|
||
const playerKey = `${side}:${player.id || player.external_id || player.number}`;
|
||
return `
|
||
<article class="ppi-goalie-card ppi-season-goalie side-${side} ${stats.games ? "" : "not-played"}" data-player-key="${escapeHtml(playerKey)}">
|
||
<div class="ppi-goalie-person"><b>${escapeHtml(player.number || stats.jersey_number || "—")}</b><span><strong>${hockeyPlayerFlagMarkup(player)}${escapeHtml(player.name || "—")}</strong><small>${escapeHtml(`${language === "en" ? "Tournament games" : "Матчи в турнире"}: ${hockeyPlayerMetric(stats.games)}`)}</small></span></div>
|
||
<dl>
|
||
<div><dt>${language === "en" ? "W" : "В"}</dt><dd>${escapeHtml(hockeyPlayerMetric(stats.wins))}</dd></div>
|
||
<div><dt>${language === "en" ? "L" : "П"}</dt><dd>${escapeHtml(hockeyPlayerMetric(stats.losses))}</dd></div>
|
||
<div><dt>${language === "en" ? "SO" : "Сух"}</dt><dd>${escapeHtml(hockeyPlayerMetric(stats.shutouts))}</dd></div>
|
||
<div><dt>${language === "en" ? "SV" : "Сейвы"}</dt><dd>${escapeHtml(hockeyPlayerMetric(stats.saves))}</dd></div>
|
||
<div><dt>${language === "en" ? "SV%" : "% ОБ"}</dt><dd>${escapeHtml(hockeyPlayerMetric(stats.save_pct, "%"))}</dd></div>
|
||
<div><dt>${language === "en" ? "GAA" : "КН"}</dt><dd>${escapeHtml(hockeyPlayerMetric(stats.goals_against_average))}</dd></div>
|
||
</dl>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
function hockeySeasonPlayerDetailMarkup(player, language) {
|
||
const stats = player?.season_statistics || {};
|
||
const goalie = player?.role === "goalkeeper" || stats.kind === "goalkeeper";
|
||
const bioItems = hockeyPlayerBioItems(player, language);
|
||
const metrics = goalie ? [
|
||
[language === "en" ? "Tournament games" : "Матчи в турнире", stats.games],
|
||
[language === "en" ? "Wins" : "Победы", stats.wins],
|
||
[language === "en" ? "Losses" : "Поражения", stats.losses],
|
||
[language === "en" ? "Overtime losses" : "Поражения в ОТ", stats.overtime_losses],
|
||
[language === "en" ? "Shutouts" : "Сухие матчи", stats.shutouts],
|
||
[language === "en" ? "Shots against" : "Броски по воротам", stats.shots_against],
|
||
[language === "en" ? "Saves" : "Сейвы", stats.saves],
|
||
[language === "en" ? "Goals against" : "Пропущено", stats.goals_against],
|
||
[language === "en" ? "Save percentage" : "Процент отражённых", hockeyPlayerMetric(stats.save_pct, "%")],
|
||
[language === "en" ? "Goals against average" : "Коэффициент надёжности", stats.goals_against_average],
|
||
[language === "en" ? "Time on ice" : "Время на льду", stats.time_on_ice],
|
||
[language === "en" ? "Penalty minutes" : "Штрафные минуты", stats.penalty_minutes],
|
||
] : [
|
||
[language === "en" ? "Games" : "Игры", stats.games],
|
||
[language === "en" ? "Goals" : "Голы", stats.goals],
|
||
[language === "en" ? "Assists" : "Передачи", stats.assists],
|
||
[language === "en" ? "Points" : "Очки", stats.points],
|
||
["+/-", stats.plus_minus],
|
||
[language === "en" ? "Shots" : "Броски", stats.shots],
|
||
[language === "en" ? "Shooting percentage" : "Процент реализации", hockeyPlayerMetric(stats.shot_pct, "%")],
|
||
[language === "en" ? "Power-play goals" : "Голы в большинстве", stats.power_play_goals],
|
||
[language === "en" ? "Short-handed goals" : "Голы в меньшинстве", stats.short_handed_goals],
|
||
[language === "en" ? "Game-winning goals" : "Победные голы", stats.game_winning_goals],
|
||
[language === "en" ? "Hits" : "Силовые приёмы", stats.hits],
|
||
[language === "en" ? "Blocked shots" : "Блокированные броски", stats.blocked_shots],
|
||
[language === "en" ? "Faceoff percentage" : "Выиграно вбрасываний", hockeyPlayerMetric(stats.faceoff_pct, "%")],
|
||
[language === "en" ? "Average time" : "Среднее время", stats.average_time_on_ice],
|
||
[language === "en" ? "Penalty minutes" : "Штрафные минуты", stats.penalty_minutes],
|
||
];
|
||
return `
|
||
<div class="ppi-detail-backdrop" data-player-detail-close></div>
|
||
<aside class="ppi-player-detail"><button type="button" data-player-detail-close aria-label="${language === "en" ? "Close" : "Закрыть"}">×</button>
|
||
<header><b>${escapeHtml(player?.number || stats.jersey_number || "—")}</b><div><span>${hockeyPlayerFlagMarkup(player)}${goalie ? (language === "en" ? "GOALKEEPER · SEASON" : "ВРАТАРЬ · СЕЗОН") : `${escapeHtml(player?.position || (language === "en" ? "PLAYER" : "ИГРОК"))} · ${language === "en" ? "SEASON" : "СЕЗОН"}`}</span><strong>${escapeHtml(player?.name || "—")}</strong><small>${escapeHtml(stats.team_name || "")}</small></div></header>
|
||
${bioItems.length ? `<div class="ppi-profile-bio">${bioItems.map(([label, value]) => `<div><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`).join("")}</div>` : ""}
|
||
<section>${metrics.map(([label, value]) => `<div><span>${escapeHtml(label)}</span><strong>${escapeHtml(hockeyPlayerMetric(value))}</strong></div>`).join("")}</section>
|
||
</aside>
|
||
`;
|
||
}
|
||
|
||
function hockeySeasonStatisticsNode(component, runtime) {
|
||
const props = component.props || {};
|
||
const language = getByPath(state.data, "hockey.language.display") === "en" ? "en" : "ru";
|
||
const payload = getByPath(state.data, props.statsPath) || {};
|
||
const homeName = formatValue(getByPath(state.data, props.homeTeamPath), language === "en" ? "Home" : "Хозяева");
|
||
const awayName = formatValue(getByPath(state.data, props.awayTeamPath), language === "en" ? "Away" : "Гости");
|
||
const filterKey = `hockey-season-filter:${component.id}`;
|
||
const searchKey = `hockey-season-search:${component.id}`;
|
||
const detailKey = `hockey-season-detail:${component.id}`;
|
||
let selectedFilter = String(state.formValues[filterKey] || "all");
|
||
if (!["all", "home", "away"].includes(selectedFilter)) selectedFilter = "all";
|
||
rememberUiNavigationState(component, "season_filter", selectedFilter, selectedFilter, { emit: false });
|
||
const query = String(state.formValues[searchKey] || "").trim().toLocaleLowerCase();
|
||
const sides = selectedFilter === "all" ? ["home", "away"] : [selectedFilter];
|
||
const teamNames = { home: homeName, away: awayName };
|
||
const allPlayers = [
|
||
...(payload.home?.skaters || []), ...(payload.home?.goalkeepers || []),
|
||
...(payload.away?.skaters || []), ...(payload.away?.goalkeepers || []),
|
||
];
|
||
const selectedPlayerKey = String(state.formValues[detailKey] || "");
|
||
const selectedPlayer = allPlayers.find((player) => `${player.side}:${player.id || player.external_id || player.number}` === selectedPlayerKey);
|
||
const visibleSkaters = sides.flatMap((side) => (payload[side]?.skaters || []).map((player) => ({ ...player, side })))
|
||
.filter((player) => !query || `${player.number || ""} ${player.name || ""}`.toLocaleLowerCase().includes(query));
|
||
const definitions = [
|
||
["points", language === "en" ? "Points" : "Очки"],
|
||
["goals", language === "en" ? "Goals" : "Голы"],
|
||
["assists", language === "en" ? "Assists" : "Передачи"],
|
||
["plus_minus", "+/-"],
|
||
["games", language === "en" ? "Games" : "Игры"],
|
||
];
|
||
const leaders = definitions.map(([key, label]) => ({
|
||
key, label, group: hockeyLeaderGroup(visibleSkaters, key, "number", "season_statistics"),
|
||
}));
|
||
const node = div("hockey-player-statistics hockey-season-statistics");
|
||
node.style.setProperty("--ppi-home", props.homeColor || "#4d9cff");
|
||
node.style.setProperty("--ppi-away", props.awayColor || "#ff5f79");
|
||
if (!payload.available) {
|
||
node.innerHTML = `<div class="ppi-empty"><strong>${language === "en" ? "Season statistics are not loaded" : "Сезонная статистика не загружена"}</strong><span>${language === "en" ? "Reopen the game to sync players XML" : "Откройте матч заново для загрузки players XML"}</span></div>`;
|
||
return node;
|
||
}
|
||
node.innerHTML = `
|
||
<header class="ppi-header"><div><span>${language === "en" ? "TOURNAMENT" : "ТУРНИР"}</span><strong>${language === "en" ? "Season statistics" : "Сезонная статистика"}</strong></div>
|
||
<nav>${[["all", language === "en" ? "All" : "Все"], ["home", homeName], ["away", awayName]].map(([id, label]) => `<button type="button" data-season-filter="${id}" class="${id === selectedFilter ? "active" : ""}">${escapeHtml(label)}</button>`).join("")}</nav>
|
||
<label><span>⌕</span><input type="search" data-season-search-input value="${escapeHtml(query)}" placeholder="${language === "en" ? "Number or player" : "Номер или игрок"}"></label></header>
|
||
<section class="ppi-leaders leaders-${leaders.length}">${leaders.map((leader) => `<article><span>${escapeHtml(leader.label)}</span><strong>${escapeHtml(leader.group.value)}</strong><small class="ppi-leader-names">${hockeyLeaderNamesMarkup(leader.group.players)}</small></article>`).join("")}</section>
|
||
<div class="ppi-skater-tables columns-${sides.length}">${sides.map((side) => hockeySeasonPlayerTableMarkup(side, teamNames[side], (payload[side]?.skaters || []).filter((player) => !query || `${player.number || ""} ${player.name || ""}`.toLocaleLowerCase().includes(query)), language)).join("")}</div>
|
||
<section class="ppi-goalkeepers"><header><strong>${language === "en" ? "Goalkeepers · season" : "Вратари · сезон"}</strong><span>${language === "en" ? "Separate tournament statistics" : "Отдельная турнирная статистика"}</span></header><div class="columns-${sides.length}">${sides.map((side) => `<section class="ppi-goalie-team side-${side}"><strong>${escapeHtml(teamNames[side])}</strong><div>${(payload[side]?.goalkeepers || []).map((player) => hockeySeasonGoalkeeperCardMarkup(side, player, language)).join("")}</div></section>`).join("")}</div></section>
|
||
${selectedPlayer ? hockeySeasonPlayerDetailMarkup(selectedPlayer, language) : ""}
|
||
`;
|
||
node.querySelectorAll("[data-season-filter]").forEach((button) => {
|
||
button.disabled = !runtime;
|
||
button.addEventListener("click", () => {
|
||
if (!runtime) return;
|
||
state.formValues[filterKey] = button.dataset.seasonFilter || "all";
|
||
state.formValues[detailKey] = "";
|
||
rememberUiNavigationState(component, "season_filter", state.formValues[filterKey], button.textContent || state.formValues[filterKey]);
|
||
renderRuntime();
|
||
});
|
||
});
|
||
const searchInput = node.querySelector("[data-season-search-input]");
|
||
if (searchInput) {
|
||
searchInput.disabled = !runtime;
|
||
searchInput.addEventListener("input", () => {
|
||
state.formValues[searchKey] = searchInput.value;
|
||
const needle = searchInput.value.trim().toLocaleLowerCase();
|
||
node.querySelectorAll("[data-player-search]").forEach((row) => { row.hidden = Boolean(needle) && !String(row.dataset.playerSearch || "").includes(needle); });
|
||
});
|
||
}
|
||
node.querySelectorAll("[data-player-key]").forEach((target) => target.addEventListener("click", () => {
|
||
if (!runtime) return;
|
||
state.formValues[detailKey] = target.dataset.playerKey || "";
|
||
renderRuntime();
|
||
}));
|
||
node.querySelectorAll("[data-player-detail-close]").forEach((target) => target.addEventListener("click", () => {
|
||
if (!runtime) return;
|
||
state.formValues[detailKey] = "";
|
||
renderRuntime();
|
||
}));
|
||
attachHockeyStatisticsTableSorting(node, `${component.id}:season-players`, runtime);
|
||
return node;
|
||
}
|
||
|
||
function hockeyTournamentStatisticValue(value, format) {
|
||
if (value === null || value === undefined || value === "") return "—";
|
||
const text = String(value);
|
||
if (format === "percent" && !text.includes("%")) return `${text}%`;
|
||
return text;
|
||
}
|
||
|
||
function hockeyStatisticsCellComparable(cell) {
|
||
const explicit = cell?.dataset?.sortValue;
|
||
const text = String(explicit !== undefined ? explicit : (cell?.textContent || ""))
|
||
.replace(/\u00a0/g, " ")
|
||
.trim();
|
||
if (!text || text === "—" || text === "-") return { empty: true, type: "text", value: "" };
|
||
|
||
const compact = text.replace(/\s+/g, "").replace(/,/g, ".");
|
||
const time = compact.match(/^([+-]?\d+):(\d{2})(?::(\d{2}))?$/);
|
||
if (time) {
|
||
const first = Number(time[1]);
|
||
const second = Number(time[2]);
|
||
const third = time[3] === undefined ? null : Number(time[3]);
|
||
const value = third === null ? first * 60 + second : first * 3600 + second * 60 + third;
|
||
return { empty: false, type: "number", value };
|
||
}
|
||
|
||
const fraction = compact.match(/^([+-]?\d+(?:\.\d+)?)\/([+-]?\d+(?:\.\d+)?)$/);
|
||
if (fraction) {
|
||
const numerator = Number(fraction[1]);
|
||
const denominator = Number(fraction[2]);
|
||
return {
|
||
empty: false,
|
||
type: "number",
|
||
value: denominator ? numerator / denominator : numerator,
|
||
};
|
||
}
|
||
|
||
const numericText = compact.replace(/%$/, "");
|
||
if (/^[+-]?\d+(?:\.\d+)?$/.test(numericText)) {
|
||
return { empty: false, type: "number", value: Number(numericText) };
|
||
}
|
||
return { empty: false, type: "text", value: text.toLocaleLowerCase() };
|
||
}
|
||
|
||
function attachHockeyStatisticsTableSorting(root, componentId, runtime) {
|
||
if (!root) return;
|
||
const tables = Array.from(root.querySelectorAll("table"));
|
||
tables.forEach((table, tableIndex) => {
|
||
if (table.dataset.hockeySortingAttached === "true") return;
|
||
const body = table.tBodies?.[0];
|
||
const headerRow = table.tHead?.rows?.[0];
|
||
if (!body || !headerRow || !body.rows.length) return;
|
||
const headers = Array.from(headerRow.cells);
|
||
const originalRows = Array.from(body.rows);
|
||
originalRows.forEach((row, index) => {
|
||
row.dataset.hockeyOriginalIndex = String(index);
|
||
});
|
||
table.dataset.hockeySortingAttached = "true";
|
||
|
||
const sortKey = `hockey-statistics-sort:${componentId}:${tableIndex}`;
|
||
const readSort = () => {
|
||
const match = String(state.formValues[sortKey] || "").match(/^(\d+):(asc|desc)$/);
|
||
return match ? { column: Number(match[1]), direction: match[2] } : { column: -1, direction: "default" };
|
||
};
|
||
|
||
const applySort = () => {
|
||
const sort = readSort();
|
||
const rows = Array.from(body.rows);
|
||
rows.sort((left, right) => {
|
||
if (sort.direction === "default" || sort.column < 0) {
|
||
return Number(left.dataset.hockeyOriginalIndex || 0) - Number(right.dataset.hockeyOriginalIndex || 0);
|
||
}
|
||
const a = hockeyStatisticsCellComparable(left.cells[sort.column]);
|
||
const b = hockeyStatisticsCellComparable(right.cells[sort.column]);
|
||
if (a.empty !== b.empty) return a.empty ? 1 : -1;
|
||
let result = 0;
|
||
if (a.type === "number" && b.type === "number") result = a.value - b.value;
|
||
else result = String(a.value).localeCompare(String(b.value), undefined, { numeric: true, sensitivity: "base" });
|
||
if (result === 0) {
|
||
result = Number(left.dataset.hockeyOriginalIndex || 0) - Number(right.dataset.hockeyOriginalIndex || 0);
|
||
}
|
||
return sort.direction === "desc" ? -result : result;
|
||
});
|
||
rows.forEach((row) => body.appendChild(row));
|
||
|
||
headers.forEach((header, index) => {
|
||
header.classList.toggle("hst-sort-active", index === sort.column && sort.direction !== "default");
|
||
header.classList.toggle("hst-sort-asc", index === sort.column && sort.direction === "asc");
|
||
header.classList.toggle("hst-sort-desc", index === sort.column && sort.direction === "desc");
|
||
header.setAttribute(
|
||
"aria-sort",
|
||
index === sort.column && sort.direction !== "default"
|
||
? (sort.direction === "asc" ? "ascending" : "descending")
|
||
: "none"
|
||
);
|
||
});
|
||
};
|
||
|
||
headers.forEach((header, column) => {
|
||
header.classList.add("hst-sortable");
|
||
header.tabIndex = runtime ? 0 : -1;
|
||
header.setAttribute("role", "button");
|
||
const activate = () => {
|
||
if (!runtime) return;
|
||
const current = readSort();
|
||
let direction = "asc";
|
||
if (current.column === column && current.direction === "asc") direction = "desc";
|
||
else if (current.column === column && current.direction === "desc") direction = "default";
|
||
state.formValues[sortKey] = direction === "default" ? "" : `${column}:${direction}`;
|
||
applySort();
|
||
};
|
||
header.addEventListener("click", activate);
|
||
header.addEventListener("keydown", (event) => {
|
||
if (event.key !== "Enter" && event.key !== " ") return;
|
||
event.preventDefault();
|
||
activate();
|
||
});
|
||
});
|
||
applySort();
|
||
});
|
||
}
|
||
|
||
function hockeyTournamentTeamKey(value) {
|
||
return String(value || "")
|
||
.normalize("NFKD")
|
||
.toLocaleLowerCase()
|
||
.replace(/ё/g, "е")
|
||
.replace(/[^a-zа-я0-9]+/g, "")
|
||
.replace(/^(?:хк|hc)/, "");
|
||
}
|
||
|
||
function hockeyTournamentPersonKeys(value) {
|
||
const text = String(value || "")
|
||
.normalize("NFKD")
|
||
.toLocaleLowerCase()
|
||
.replace(/ё/g, "е")
|
||
.replace(/[^a-zа-я0-9]+/g, " ")
|
||
.trim();
|
||
if (!text) return [];
|
||
const tokens = text.split(/\s+/).filter(Boolean);
|
||
return Array.from(new Set([tokens.join(""), [...tokens].sort().join("")]));
|
||
}
|
||
|
||
function hockeyTournamentKeysMatch(left, right) {
|
||
if (!left || !right) return false;
|
||
return left === right;
|
||
}
|
||
|
||
function hockeyTournamentCurrentTeamSide(row, resourceType = "powerplay") {
|
||
const isRank = resourceType === "rank";
|
||
const values = row?.values && typeof row.values === "object" ? row.values : {};
|
||
const valueEntries = Object.entries(values);
|
||
const valueByKeys = (pattern) => valueEntries
|
||
.filter(([key, value]) => pattern.test(String(key || "")) && value !== null && value !== undefined && value !== "")
|
||
.map(([, value]) => value);
|
||
|
||
const rowTeamIds = new Set([
|
||
row?.team_id, row?.club_id, row?.team_entry_id, row?.club_entry_id,
|
||
...valueByKeys(/^(?:team|club)(?:_?entry)?_?id$|^(?:id_?team|id_?club)$/i),
|
||
].map((value) => String(value || "").trim()).filter(Boolean));
|
||
const rowTeamNames = [
|
||
row?.team, row?.team_names?.ru, row?.team_names?.en,
|
||
...(isRank ? [] : [row?.name, row?.names?.ru, row?.names?.en]),
|
||
...valueByKeys(/(?:^|_)(?:team|club)(?:_|$|name|title|short)/i),
|
||
].map(hockeyTournamentTeamKey).filter(Boolean);
|
||
|
||
const rowPlayerIds = new Set([
|
||
row?.player_id, isRank ? row?.id : "",
|
||
...valueByKeys(/^(?:player|person|athlete)_?id$|^id_?(?:player|person|athlete)$/i),
|
||
].map((value) => String(value || "").trim()).filter((value) => value && !/^row-/i.test(value)));
|
||
const rowPlayerNames = [row?.name, row?.names?.ru, row?.names?.en]
|
||
.flatMap(hockeyTournamentPersonKeys)
|
||
.filter(Boolean);
|
||
|
||
for (const side of ["home", "away"]) {
|
||
const team = getByPath(state.data, `hockey.${side}`) || {};
|
||
const teamIds = [team.id, team.external_id, team.entry_id]
|
||
.map((value) => String(value || "").trim())
|
||
.filter(Boolean);
|
||
if (teamIds.some((value) => rowTeamIds.has(value))) return side;
|
||
|
||
const teamNames = [
|
||
team.name, team.short_name,
|
||
team.names?.ru, team.names?.en,
|
||
team.short_names?.ru, team.short_names?.en,
|
||
].map(hockeyTournamentTeamKey).filter(Boolean);
|
||
if (teamNames.some((teamName) => rowTeamNames.some((rowName) => hockeyTournamentKeysMatch(teamName, rowName)))) {
|
||
return side;
|
||
}
|
||
|
||
if (isRank) {
|
||
const playerStatistics = getByPath(state.data, `hockey.selected_game.player_statistics.${side}`) || {};
|
||
const teamPlayers = [
|
||
...(Array.isArray(playerStatistics.skaters) ? playerStatistics.skaters : []),
|
||
...(Array.isArray(playerStatistics.goalkeepers) ? playerStatistics.goalkeepers : []),
|
||
...(Array.isArray(team.players) ? team.players : []),
|
||
];
|
||
const playerIdMatch = teamPlayers.some((player) => [
|
||
player?.id, player?.external_id, player?.player_id, player?.person_id,
|
||
].map((value) => String(value || "").trim()).filter(Boolean).some((value) => rowPlayerIds.has(value)));
|
||
if (playerIdMatch) return side;
|
||
|
||
const playerNameMatch = teamPlayers.some((player) => {
|
||
const candidateKeys = [
|
||
player?.name, player?.full_name,
|
||
player?.names?.ru, player?.names?.en,
|
||
].flatMap(hockeyTournamentPersonKeys);
|
||
return candidateKeys.some((candidate) => rowPlayerNames.includes(candidate));
|
||
});
|
||
if (playerNameMatch) return side;
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function hockeyTournamentStatisticNode(component, runtime, resourceType) {
|
||
const props = component.props || {};
|
||
const language = getByPath(state.data, "hockey.language.display") === "en" ? "en" : "ru";
|
||
const defaultPath = resourceType === "powerplay"
|
||
? "hockey.tournament_statistics.powerplay"
|
||
: "hockey.tournament_statistics.rank";
|
||
const path = resourceType === "powerplay"
|
||
? (props.powerplayStatsPath || defaultPath)
|
||
: (props.rankStatsPath || defaultPath);
|
||
const payload = getByPath(state.data, path) || {};
|
||
const sections = Array.isArray(payload.sections) ? payload.sections.filter((section) => Array.isArray(section?.rows) && section.rows.length) : [];
|
||
const sectionKey = `hockey-tournament-stat-section:${component.id}:${resourceType}`;
|
||
let selectedSectionId = String(state.formValues[sectionKey] || sections[0]?.id || "");
|
||
if (!sections.some((section) => String(section.id) === selectedSectionId)) {
|
||
selectedSectionId = String(sections[0]?.id || "");
|
||
}
|
||
rememberUiNavigationState(component, `${resourceType}_section`, selectedSectionId, selectedSectionId, { emit: false });
|
||
const section = sections.find((item) => String(item.id) === selectedSectionId) || sections[0];
|
||
const rows = Array.isArray(section?.rows) ? section.rows : [];
|
||
const columns = Array.isArray(section?.columns) ? section.columns : [];
|
||
const isPowerplay = resourceType === "powerplay";
|
||
const node = div(`hockey-tournament-statistics hts-resource-${resourceType}`);
|
||
node.style.setProperty("--htr-home", props.homeColor || "#4d9cff");
|
||
node.style.setProperty("--htr-away", props.awayColor || "#ff5f79");
|
||
|
||
if (!payload.available || !sections.length) {
|
||
node.innerHTML = `<div class="htr-empty"><strong>${language === "en" ? "Statistics are unavailable" : "Статистика недоступна"}</strong><span>${language === "en" ? "This league does not provide this XML" : "Для этой лиги соответствующий XML не предоставляется"}</span></div>`;
|
||
return node;
|
||
}
|
||
|
||
const hasRank = rows.some((row) => row?.rank !== null && row?.rank !== undefined && row?.rank !== "");
|
||
const hasNumber = !isPowerplay && rows.some((row) => row?.number !== null && row?.number !== undefined && row?.number !== "");
|
||
const hasTeam = false;
|
||
const title = isPowerplay
|
||
? (language === "en" ? "Power play" : "Большинство")
|
||
: (language === "en" ? "Team ranking" : "Рейтинг команд");
|
||
const subtitle = isPowerplay
|
||
? (language === "en" ? "Tournament special-teams statistics" : "Турнирная статистика специальных бригад")
|
||
: (language === "en" ? "Tournament team ranking by category" : "Рейтинг команд турнира по категориям");
|
||
const generated = payload.generated_at
|
||
? `${language === "en" ? "Updated" : "Обновлено"}: ${payload.generated_at}`
|
||
: "";
|
||
|
||
node.innerHTML = `
|
||
<header class="htr-header">
|
||
<div><span>${isPowerplay ? "POWER PLAY" : "RANK"}</span><strong>${escapeHtml(title)}</strong><small>${escapeHtml(subtitle)}</small></div>
|
||
${sections.length > 1 ? `<nav>${sections.map((item) => `<button type="button" data-tournament-stat-section="${escapeHtml(String(item.id))}" class="${String(item.id) === selectedSectionId ? "active" : ""}">${escapeHtml(item.label || item.id)}</button>`).join("")}</nav>` : `<b>${escapeHtml(section?.label || title)}</b>`}
|
||
<time>${escapeHtml(generated)}</time>
|
||
</header>
|
||
<div class="htr-table-scroll">
|
||
<table>
|
||
<thead><tr>
|
||
${hasRank ? `<th class="htr-rank">#</th>` : ""}
|
||
${hasNumber ? `<th class="htr-number">№</th>` : ""}
|
||
<th class="htr-name">${language === "en" ? "Team" : "Команда"}</th>
|
||
${hasTeam ? `<th class="htr-team">${language === "en" ? "Team" : "Команда"}</th>` : ""}
|
||
${columns.map((column) => { const sectionName = isPowerplay ? "powerplay" : "team"; const label = hockeyStatLabel(column.key, column.label || column.key, language, sectionName); return `<th${hockeyStatTooltipAttrs(column.key, language, sectionName)}>${escapeHtml(label)}</th>`; }).join("")}
|
||
</tr></thead>
|
||
<tbody>${rows.map((row, index) => {
|
||
const currentSide = hockeyTournamentCurrentTeamSide(row, resourceType);
|
||
const rowClass = currentSide ? `htr-current-team side-${currentSide}` : "";
|
||
return `<tr class="${rowClass}">
|
||
${hasRank ? `<td class="htr-rank">${escapeHtml(row.rank || String(index + 1))}</td>` : ""}
|
||
${hasNumber ? `<td class="htr-number">${escapeHtml(row.number || "—")}</td>` : ""}
|
||
<td class="htr-name"><strong>${escapeHtml(isPowerplay ? (row.name || row.team || "—") : (row.team || row.name || "—"))}</strong></td>
|
||
${hasTeam ? `<td class="htr-team">${escapeHtml(row.team || "—")}</td>` : ""}
|
||
${columns.map((column) => `<td>${escapeHtml(hockeyTournamentStatisticValue(row.values?.[column.key], column.format))}</td>`).join("")}
|
||
</tr>`;
|
||
}).join("")}</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
node.querySelectorAll("[data-tournament-stat-section]").forEach((button) => {
|
||
button.disabled = !runtime;
|
||
button.addEventListener("click", () => {
|
||
if (!runtime) return;
|
||
state.formValues[sectionKey] = button.dataset.tournamentStatSection || "";
|
||
rememberUiNavigationState(component, `${resourceType}_section`, state.formValues[sectionKey], button.textContent || state.formValues[sectionKey]);
|
||
renderRuntime();
|
||
});
|
||
});
|
||
attachHockeyStatisticsTableSorting(node, `${component.id}:${resourceType}:${selectedSectionId}`, runtime);
|
||
return node;
|
||
}
|
||
|
||
function hockeyStatisticsHubNode(component, runtime) {
|
||
const language = getByPath(state.data, "hockey.language.display") === "en" ? "en" : "ru";
|
||
const stateKey = `hockey-statistics-view:${component.id}`;
|
||
const events = getByPath(state.data, component.props?.eventsPath || "hockey.selected_game.events") || {};
|
||
const powerplay = getByPath(state.data, component.props?.powerplayStatsPath || "hockey.tournament_statistics.powerplay") || {};
|
||
const rank = getByPath(state.data, component.props?.rankStatsPath || "hockey.tournament_statistics.rank") || {};
|
||
const tabs = [];
|
||
if (events.available) tabs.push(["events", language === "en" ? "Events" : "События"]);
|
||
tabs.push(
|
||
["team", language === "en" ? "Team" : "Командная"],
|
||
["players", language === "en" ? "Players" : "Игроки"],
|
||
);
|
||
const season = getByPath(state.data, component.props?.seasonStatsPath || "hockey.selected_game.season_player_statistics") || {};
|
||
if (season.available) tabs.push(["season", language === "en" ? "Season" : "Сезон"]);
|
||
if (powerplay.available) tabs.push(["powerplay", language === "en" ? "Power play" : "Большинство"]);
|
||
if (rank.available) tabs.push(["rank", language === "en" ? "Ranking" : "Рейтинг"]);
|
||
const availableViews = tabs.map(([id]) => id);
|
||
let selected = String(state.formValues[stateKey] || (events.available ? "events" : "team"));
|
||
if (!availableViews.includes(selected)) selected = availableViews[0] || "team";
|
||
rememberUiNavigationState(component, "view", selected, selected, { emit: false });
|
||
const node = div("hockey-statistics-hub");
|
||
const navigation = div("hsh-navigation");
|
||
navigation.innerHTML = `
|
||
<span>${language === "en" ? "STATISTICS" : "СТАТИСТИКА"}</span>
|
||
<nav>${tabs.map(([id, label]) => `<button type="button" data-statistics-view="${id}" class="${selected === id ? "active" : ""}">${escapeHtml(label)}</button>`).join("")}</nav>
|
||
`;
|
||
const content = div("hsh-content");
|
||
if (selected === "events") {
|
||
content.appendChild(hockeyEventsNode({ ...component, id: `${component.id}-events` }, runtime));
|
||
} else if (selected === "powerplay") {
|
||
content.appendChild(hockeyTournamentStatisticNode({ ...component, id: `${component.id}-powerplay` }, runtime, "powerplay"));
|
||
} else if (selected === "rank") {
|
||
content.appendChild(hockeyTournamentStatisticNode({ ...component, id: `${component.id}-rank` }, runtime, "rank"));
|
||
} else if (selected === "season") {
|
||
content.appendChild(hockeySeasonStatisticsNode({
|
||
...component,
|
||
id: `${component.id}-season`,
|
||
props: {
|
||
...component.props,
|
||
statsPath: component.props?.seasonStatsPath || "hockey.selected_game.season_player_statistics",
|
||
},
|
||
}, runtime));
|
||
} else if (selected === "players") {
|
||
content.appendChild(hockeyPlayerStatisticsNode({
|
||
...component,
|
||
id: `${component.id}-players`,
|
||
initial_state: "all",
|
||
props: {
|
||
...component.props,
|
||
statsPath: component.props?.playerStatsPath || "hockey.selected_game.player_statistics",
|
||
},
|
||
}, runtime));
|
||
} else {
|
||
content.appendChild(hockeyTeamStatisticsNode({
|
||
...component,
|
||
id: `${component.id}-team`,
|
||
}, runtime));
|
||
}
|
||
node.append(navigation, content);
|
||
navigation.querySelectorAll("[data-statistics-view]").forEach((button) => {
|
||
button.disabled = !runtime;
|
||
button.addEventListener("click", () => {
|
||
if (!runtime) return;
|
||
state.formValues[stateKey] = button.dataset.statisticsView || "team";
|
||
ensureComponentState(component).value = state.formValues[stateKey];
|
||
rememberUiNavigationState(component, "view", state.formValues[stateKey], button.textContent || state.formValues[stateKey]);
|
||
emitInteraction(component, "change", {
|
||
value: state.formValues[stateKey],
|
||
item_id: state.formValues[stateKey],
|
||
});
|
||
renderRuntime();
|
||
});
|
||
});
|
||
return node;
|
||
}
|
||
|
||
|
||
function hockeyGameControlLanguage() {
|
||
return getByPath(state.data, "hockey.language.display") === "en" ? "en" : "ru";
|
||
}
|
||
|
||
async function hockeyGameControlRequest(path, options = {}) {
|
||
const response = await fetch(`/api/hockey${path}`, {
|
||
cache: "no-store",
|
||
credentials: "same-origin",
|
||
...options,
|
||
headers: {
|
||
...(options.body ? { "Content-Type": "application/json" } : {}),
|
||
...(options.headers || {}),
|
||
},
|
||
});
|
||
let payload = {};
|
||
try { payload = await response.json(); } catch (_) {}
|
||
if (!response.ok) {
|
||
throw new Error(errorDetailText(payload.detail, `HTTP ${response.status}`));
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
function publishRuntimeToastPreference() {
|
||
window.HOCKEY_RUNTIME_TOASTS_ENABLED = state.operatorToastsEnabled !== false;
|
||
window.dispatchEvent(new CustomEvent("hockey:toast-preference", {
|
||
detail: { enabled: window.HOCKEY_RUNTIME_TOASTS_ENABLED },
|
||
}));
|
||
}
|
||
|
||
function renderRuntimeEventsPreference() {
|
||
const button = el.runtimeEventsToggleBtn;
|
||
if (!button) return;
|
||
const enabled = state.operatorToastsEnabled !== false;
|
||
button.classList.toggle("is-on", enabled);
|
||
button.classList.toggle("is-off", !enabled);
|
||
button.setAttribute("aria-pressed", enabled ? "true" : "false");
|
||
button.title = enabled
|
||
? "Toast-уведомления включены для этого аккаунта"
|
||
: "Toast-уведомления выключены для этого аккаунта";
|
||
button.setAttribute("aria-label", enabled ? "Выключить уведомления" : "Включить уведомления");
|
||
}
|
||
|
||
async function loadRuntimeEventsPreference() {
|
||
try {
|
||
const payload = await hockeyGameControlRequest("/preferences");
|
||
state.operatorToastsEnabled = payload?.toasts_enabled !== false;
|
||
state.operatorToastsPreferenceLoaded = true;
|
||
// Kept true for backwards compatibility: triggers are no longer disabled
|
||
// by this account-level UI preference.
|
||
state.triggersEnabled = true;
|
||
state.triggersPreferenceLoaded = true;
|
||
publishRuntimeToastPreference();
|
||
} catch (error) {
|
||
console.warn("Could not load toast preference", error);
|
||
state.operatorToastsEnabled = true;
|
||
state.triggersEnabled = true;
|
||
publishRuntimeToastPreference();
|
||
}
|
||
renderRuntimeEventsPreference();
|
||
}
|
||
|
||
async function toggleRuntimeEventsPreference() {
|
||
const button = el.runtimeEventsToggleBtn;
|
||
if (button?.disabled) return;
|
||
const next = !state.operatorToastsEnabled;
|
||
if (button) button.disabled = true;
|
||
try {
|
||
const payload = await hockeyGameControlRequest("/preferences/toasts", {
|
||
method: "PUT",
|
||
body: JSON.stringify({ enabled: next }),
|
||
});
|
||
state.operatorToastsEnabled = payload?.toasts_enabled !== false;
|
||
state.operatorToastsPreferenceLoaded = true;
|
||
state.triggersEnabled = true;
|
||
state.triggersPreferenceLoaded = true;
|
||
publishRuntimeToastPreference();
|
||
renderRuntimeEventsPreference();
|
||
toast(state.operatorToastsEnabled
|
||
? "Toast-уведомления включены для вашего аккаунта"
|
||
: "Toast-уведомления выключены для вашего аккаунта", false, { force: true });
|
||
} catch (error) {
|
||
toast(`Не удалось изменить уведомления: ${error.message}`, true, { force: true });
|
||
} finally {
|
||
if (button) button.disabled = false;
|
||
}
|
||
}
|
||
|
||
function hockeyApplyTimerRules(payload) {
|
||
const component = hockeyMainTimerComponent();
|
||
const minutes = Number(payload?.timer_rules?.minutes);
|
||
if (!component || !Number.isFinite(minutes) || minutes < 0) return;
|
||
const startTime = `${Math.floor(minutes)}:00`;
|
||
component.props = component.props || {};
|
||
if (component.props.startTime !== startTime) {
|
||
component.props.startTime = startTime;
|
||
}
|
||
}
|
||
|
||
function hockeyStrengthMappingSignature(control) {
|
||
const strength = control?.strength && typeof control.strength === "object" ? control.strength : {};
|
||
const period = control?.period_status && typeof control.period_status === "object" ? control.period_status : {};
|
||
return JSON.stringify([
|
||
control?.current_period ?? "",
|
||
period.label ?? "",
|
||
period.compact ?? "",
|
||
period.short ?? "",
|
||
period.long ?? "",
|
||
period.ru ?? "",
|
||
period.en ?? "",
|
||
strength.state_key ?? "",
|
||
strength.state_label ?? "",
|
||
strength.strength_label ?? "",
|
||
strength.advantage_numbers ?? "",
|
||
strength.advantage_side ?? "",
|
||
strength.home_label ?? "",
|
||
strength.away_label ?? "",
|
||
Number(strength.home_skaters || 0),
|
||
Number(strength.away_skaters || 0),
|
||
Number(strength.home_penalties || 0),
|
||
Number(strength.away_penalties || 0),
|
||
]);
|
||
}
|
||
|
||
async function hockeyRefreshVmixMappingForStrength(gameId, previousControl, nextControl) {
|
||
gameId = String(gameId || "").trim();
|
||
if (!gameId || !nextControl) return false;
|
||
const previousSignature = previousControl ? hockeyStrengthMappingSignature(previousControl) : "";
|
||
const nextSignature = hockeyStrengthMappingSignature(nextControl);
|
||
if (previousSignature && previousSignature === nextSignature) return false;
|
||
const selectedGameId = String(hockeyTimerSelectedGameId() || "").trim();
|
||
if (selectedGameId && selectedGameId !== gameId) return false;
|
||
const deviceId = currentRuntimeVmixDeviceId();
|
||
if (!deviceId) return false;
|
||
|
||
if (state.vmixStrengthMappingRefreshPending) {
|
||
state.vmixStrengthMappingRefreshQueued = { gameId, previousControl, nextControl };
|
||
return true;
|
||
}
|
||
|
||
state.vmixStrengthMappingRefreshPending = true;
|
||
try {
|
||
const response = await fetch(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/apply-mapping?only_changed=true`, {
|
||
method: "POST",
|
||
cache: "no-store",
|
||
credentials: "same-origin",
|
||
});
|
||
let payload = {};
|
||
try { payload = await response.json(); } catch (_) {}
|
||
if (!response.ok) {
|
||
const detail = payload?.detail?.message || payload?.detail || `HTTP ${response.status}`;
|
||
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
||
}
|
||
return true;
|
||
} catch (error) {
|
||
console.error("vMix strength Mapping refresh error", error);
|
||
return false;
|
||
} finally {
|
||
state.vmixStrengthMappingRefreshPending = false;
|
||
const queued = state.vmixStrengthMappingRefreshQueued;
|
||
state.vmixStrengthMappingRefreshQueued = null;
|
||
if (queued) {
|
||
hockeyRefreshVmixMappingForStrength(queued.gameId, queued.previousControl, queued.nextControl).catch(() => {});
|
||
}
|
||
}
|
||
}
|
||
|
||
async function hockeyRefreshVmixMappingForTab(tabId) {
|
||
const activeTab = String(tabId || "").trim();
|
||
if (!activeTab) return false;
|
||
const gameId = String(hockeyTimerSelectedGameId() || "").trim();
|
||
const deviceId = currentRuntimeVmixDeviceId();
|
||
const sessionToken = currentRuntimeHockeySessionToken();
|
||
if (!gameId || !deviceId) return false;
|
||
if (state.vmixTabMappingRefreshPending) {
|
||
state.vmixTabMappingRefreshQueued = activeTab;
|
||
return true;
|
||
}
|
||
state.vmixTabMappingRefreshPending = true;
|
||
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: { active_tab: activeTab },
|
||
context: {
|
||
game_id: gameId,
|
||
device_id: deviceId,
|
||
session_token: sessionToken,
|
||
},
|
||
}),
|
||
});
|
||
if (!response.ok) {
|
||
let payload = {}; try { payload = await response.json(); } catch (_) {}
|
||
throw new Error(errorDetailText(payload.detail, `HTTP ${response.status}`));
|
||
}
|
||
return true;
|
||
} catch (error) {
|
||
console.error("vMix tab Mapping refresh error", error);
|
||
return false;
|
||
} finally {
|
||
state.vmixTabMappingRefreshPending = false;
|
||
const queued = state.vmixTabMappingRefreshQueued;
|
||
state.vmixTabMappingRefreshQueued = "";
|
||
if (queued && queued !== activeTab) hockeyRefreshVmixMappingForTab(queued).catch(() => {});
|
||
}
|
||
}
|
||
|
||
function hockeyStoreGameControl(gameId, payload, { render = true, dispatch = true } = {}) {
|
||
if (!gameId || !payload) return;
|
||
const previousControl = state.hockeyGameControl[String(gameId)] || null;
|
||
const previousPeriod = String(previousControl?.current_period || "");
|
||
const nextPeriod = String(payload?.current_period || "");
|
||
hockeyApplyTimerRules(payload);
|
||
state.hockeyGameControl[String(gameId)] = payload;
|
||
window.UIBuilderRuntime?.patchData?.(
|
||
{ hockey: { game_control: payload } },
|
||
{ render }
|
||
);
|
||
if (!previousControl || hockeyStrengthMappingSignature(previousControl) !== hockeyStrengthMappingSignature(payload)) {
|
||
hockeyRefreshVmixMappingForStrength(gameId, previousControl, payload).catch(() => {});
|
||
}
|
||
if (previousControl && hockeyTeamStateFlagSignature(previousControl) !== hockeyTeamStateFlagSignature(payload) && hockeyTeamStateScoreboardIsLive()) {
|
||
hockeySyncTeamStateOverlays({ force: true }).catch((error) => console.error("vMix team-state overlay sync error", error));
|
||
}
|
||
if (dispatch) {
|
||
window.dispatchEvent(new CustomEvent("hockey:game-control-updated", {
|
||
detail: {
|
||
game_id: String(gameId),
|
||
control: payload,
|
||
apply_timers: Boolean(previousPeriod && nextPeriod && previousPeriod !== nextPeriod),
|
||
},
|
||
}));
|
||
}
|
||
}
|
||
|
||
async function hockeyLoadGameControl(gameId, { force = false, rerender = true } = {}) {
|
||
gameId = String(gameId || "").trim();
|
||
if (!gameId) return null;
|
||
if (!force && state.hockeyGameControl[gameId]) return state.hockeyGameControl[gameId];
|
||
if (state.hockeyGameControlLoading.has(gameId)) return null;
|
||
state.hockeyGameControlLoading.add(gameId);
|
||
try {
|
||
const language = hockeyGameControlLanguage();
|
||
const payload = await hockeyGameControlRequest(`/games/${encodeURIComponent(gameId)}/control?language=${language}`);
|
||
hockeyStoreGameControl(gameId, payload, { render: false, dispatch: true });
|
||
if (rerender) renderRuntime();
|
||
return payload;
|
||
} catch (error) {
|
||
console.error("Hockey game control load failed", error);
|
||
return null;
|
||
} finally {
|
||
state.hockeyGameControlLoading.delete(gameId);
|
||
}
|
||
}
|
||
|
||
async function hockeyUpdateGameControl(gameId, path, options = {}) {
|
||
try {
|
||
const payload = await hockeyGameControlRequest(`/games/${encodeURIComponent(gameId)}${path}`, options);
|
||
hockeyStoreGameControl(gameId, payload);
|
||
renderRuntime();
|
||
return payload;
|
||
} catch (error) {
|
||
toast(error.message || "Не удалось изменить данные матча", true);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
function hockeyMatchFlags() {
|
||
return getByPath(state.data, "hockey.game_control.flags") || {};
|
||
}
|
||
|
||
function hockeyScoreboardTeamStateSettings() {
|
||
const raw = getByPath(state.data, "hockey.game_control.scoreboard_team_states");
|
||
return raw && typeof raw === "object" ? raw : {};
|
||
}
|
||
|
||
function hockeyTeamStateSetting(key) {
|
||
const source = hockeyScoreboardTeamStateSettings()[key];
|
||
const value = source && typeof source === "object" ? source : {};
|
||
const language = hockeyGameControlLanguage();
|
||
const fallback = key.includes("empty_net")
|
||
? (language === "en" ? "Empty net" : "Пустые ворота")
|
||
: (language === "en" ? "Delayed penalty" : "Отложенный штраф");
|
||
return {
|
||
...value,
|
||
label: String(value.label || value[language] || fallback),
|
||
input: String(value.input || "").trim(),
|
||
input_title: String(value.input_title || "").trim(),
|
||
overlay: ["1", "2", "3", "4"].includes(String(value.overlay || "")) ? String(value.overlay) : (key.includes("empty_net") ? "3" : "2"),
|
||
enabled: value.enabled !== false,
|
||
};
|
||
}
|
||
|
||
function hockeyScoreboardIsLive() {
|
||
for (const [sequenceId, active] of state.shortcutSequenceOverlayState.entries()) {
|
||
if (!active) continue;
|
||
const sequence = shortcutSequenceById(sequenceId);
|
||
if (sequence?.is_scoreboard_sequence) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function hockeyTeamStateScoreboardIsLive() {
|
||
return hockeyScoreboardIsLive();
|
||
}
|
||
|
||
async function hockeySyncTeamStateOverlays({ scoreboardActive = null, force = false } = {}) {
|
||
const live = scoreboardActive === null ? hockeyTeamStateScoreboardIsLive() : Boolean(scoreboardActive);
|
||
const flags = hockeyMatchFlags();
|
||
const commands = [];
|
||
for (const key of ["home_delayed_penalty", "home_empty_net", "away_delayed_penalty", "away_empty_net"]) {
|
||
const setting = hockeyTeamStateSetting(key);
|
||
const previous = state.hockeyTeamStateOverlayActive.get(key) || null;
|
||
const shouldShow = live && setting.enabled && Boolean(flags[key]) && Boolean(setting.input);
|
||
if (shouldShow) {
|
||
if (previous && (previous.input !== setting.input || previous.overlay !== setting.overlay)) {
|
||
commands.push({ Function: `OverlayInput${previous.overlay}Out`, Input: previous.input });
|
||
}
|
||
if (force || !previous || previous.input !== setting.input || previous.overlay !== setting.overlay) {
|
||
commands.push({ Function: `OverlayInput${setting.overlay}In`, Input: setting.input });
|
||
}
|
||
state.hockeyTeamStateOverlayActive.set(key, { input: setting.input, overlay: setting.overlay });
|
||
} else if (previous) {
|
||
commands.push({ Function: `OverlayInput${previous.overlay}Out`, Input: previous.input });
|
||
state.hockeyTeamStateOverlayActive.delete(key);
|
||
}
|
||
}
|
||
if (commands.length) await sendRuntimeVmixSequence(commands);
|
||
return commands.length;
|
||
}
|
||
|
||
function hockeyClearTeamStateOverlayTracking() {
|
||
state.hockeyTeamStateOverlayActive.clear();
|
||
}
|
||
|
||
function hockeyTeamStateFlagSignature(control) {
|
||
const flags = control?.flags && typeof control.flags === "object" ? control.flags : {};
|
||
return [
|
||
"home_delayed_penalty",
|
||
"home_empty_net",
|
||
"away_delayed_penalty",
|
||
"away_empty_net",
|
||
].map((key) => flags[key] ? "1" : "0").join("");
|
||
}
|
||
|
||
async function hockeySetMatchFlags(patch) {
|
||
const gameId = hockeyTimerSelectedGameId();
|
||
if (!gameId) {
|
||
toast("Сначала выберите матч", true);
|
||
return null;
|
||
}
|
||
const flags = {};
|
||
Object.entries(patch || {}).forEach(([key, value]) => {
|
||
if (key) flags[String(key)] = Boolean(value);
|
||
});
|
||
if (!Object.keys(flags).length) return null;
|
||
const language = hockeyGameControlLanguage();
|
||
return hockeyUpdateGameControl(gameId, "/control/flags", {
|
||
method: "PUT",
|
||
body: JSON.stringify({ flags, language }),
|
||
});
|
||
}
|
||
|
||
async function hockeyToggleMatchFlag(key) {
|
||
const current = Boolean(hockeyMatchFlags()[key]);
|
||
return hockeySetMatchFlags({ [key]: !current });
|
||
}
|
||
|
||
function hockeyPrematchFlagKey(buttonId) {
|
||
return `prematch.${String(buttonId || "").trim()}`;
|
||
}
|
||
|
||
function hockeyTimerSelectedGameId() {
|
||
return String(
|
||
state.hockeyTimerGameId
|
||
|| getByPath(state.data, "hockey.selected_game.external_id")
|
||
|| getByPath(state.data, "hockey.selected_game.id")
|
||
|| ""
|
||
).trim();
|
||
}
|
||
|
||
function hockeyMainTimerComponent() {
|
||
return componentByActionId("hockey_game_timer")
|
||
|| state.config.components.find((item) => item.type === "timer" && !item.hidden)
|
||
|| null;
|
||
}
|
||
|
||
function hockeyPenaltyBoardComponent() {
|
||
return componentByActionId("hockey_penalty_dashboard")
|
||
|| state.config.components.find((item) => item.type === "hockey_penalty_dashboard" && !item.hidden)
|
||
|| null;
|
||
}
|
||
|
||
function hockeyCompactPlayer(player) {
|
||
if (!player || typeof player !== "object") return null;
|
||
return {
|
||
id: String(player.id || ""),
|
||
side: player.side === "away" ? "away" : player.side === "home" ? "home" : "",
|
||
number: String(player.number || ""),
|
||
name: String(player.name || ""),
|
||
position: String(player.position || ""),
|
||
};
|
||
}
|
||
|
||
function hockeyCompactInfraction(infraction) {
|
||
if (!infraction || typeof infraction !== "object") return null;
|
||
return {
|
||
id: String(infraction.id || ""),
|
||
label: String(infraction.label || ""),
|
||
defaultPreset: String(infraction.defaultPreset || ""),
|
||
teamPenalty: Boolean(infraction.teamPenalty || infraction.team_penalty),
|
||
};
|
||
}
|
||
|
||
function hockeyPenaltySnapshot(event) {
|
||
return {
|
||
id: String(event.id || uid()),
|
||
eventTime: String(event.eventTime || ""),
|
||
eventTimeMs: Math.round(Number(event.eventTimeMs || 0)),
|
||
createdAt: Math.round(Number(event.createdAt || Date.now())),
|
||
updatedAt: Math.round(Number(event.updatedAt || Date.now())),
|
||
side: event.player?.side || event.side || "",
|
||
teamPenalty: Boolean(event.teamPenalty),
|
||
player: event.teamPenalty ? null : hockeyCompactPlayer(event.player),
|
||
infraction: hockeyCompactInfraction(event.infraction),
|
||
preset: String(event.preset || ""),
|
||
durationMs: Math.max(0, Math.round(Number(event.durationMs || 0))),
|
||
remainingMs: Math.max(0, Math.round(Number(event.remainingMs || 0))),
|
||
running: Boolean(event.running) && !Boolean(event.finished),
|
||
finished: Boolean(event.finished),
|
||
readyEmitted: Boolean(event.readyEmitted),
|
||
assignedEmitted: Boolean(event.assignedEmitted),
|
||
warningAtMs: Math.max(0, Math.round(Number(event.warningAtMs || 0))),
|
||
note: String(event.note || ""),
|
||
};
|
||
}
|
||
|
||
function hockeyHistorySnapshot(item) {
|
||
return {
|
||
id: String(item?.id || uid()),
|
||
type: String(item?.type || ""),
|
||
at: String(item?.at || new Date().toISOString()),
|
||
side: String(item?.side || item?.player?.side || ""),
|
||
player: hockeyCompactPlayer(item?.player),
|
||
preset: String(item?.preset || ""),
|
||
infraction: hockeyCompactInfraction(item?.infraction),
|
||
eventTime: String(item?.eventTime || ""),
|
||
message: String(item?.message || ""),
|
||
};
|
||
}
|
||
|
||
function hockeyGameTimerSnapshot() {
|
||
const mainComponent = hockeyMainTimerComponent();
|
||
const mainState = mainComponent ? ensureTimerState(mainComponent) : null;
|
||
const boardComponent = hockeyPenaltyBoardComponent();
|
||
const board = boardComponent ? ensureHockeyBoardState(boardComponent) : null;
|
||
return {
|
||
main_timer: {
|
||
current_ms: Math.round(Number(mainState?.currentMs ?? (mainComponent ? timerInitialMilliseconds(mainComponent) : 20 * 60 * 1000))),
|
||
running: Boolean(mainState?.running),
|
||
paused: Boolean(mainState?.paused ?? true),
|
||
finished: Boolean(mainState?.finished),
|
||
reached: clone(mainState?.reached || {}),
|
||
},
|
||
penalty_board: {
|
||
penalties: (board?.penalties || []).slice(0, 64).map(hockeyPenaltySnapshot),
|
||
history: (board?.history || []).slice(0, 64).map(hockeyHistorySnapshot),
|
||
},
|
||
};
|
||
}
|
||
|
||
function hockeyApplySavedTimers(gameId, timers) {
|
||
const mainComponent = hockeyMainTimerComponent();
|
||
if (mainComponent) {
|
||
const saved = timers?.main_timer && typeof timers.main_timer === "object"
|
||
? timers.main_timer
|
||
: null;
|
||
const timerState = createTimerState(mainComponent);
|
||
if (saved) {
|
||
timerState.currentMs = Number.isFinite(Number(saved.current_ms))
|
||
? Number(saved.current_ms)
|
||
: timerInitialMilliseconds(mainComponent);
|
||
timerState.finished = Boolean(saved.finished);
|
||
timerState.running = Boolean(saved.running) && !timerState.finished;
|
||
timerState.paused = Boolean(saved.paused ?? !timerState.running);
|
||
timerState.reached = saved.reached && typeof saved.reached === "object" ? clone(saved.reached) : {};
|
||
} else {
|
||
timerState.currentMs = timerInitialMilliseconds(mainComponent);
|
||
timerState.running = false;
|
||
timerState.paused = true;
|
||
timerState.finished = false;
|
||
timerState.reached = {};
|
||
}
|
||
timerState.lastTimestamp = performance.now();
|
||
timerState.lastWholeSecond = null;
|
||
timerState.lastPersistAt = 0;
|
||
state.timers[mainComponent.action_id] = timerState;
|
||
updateTimerNodes(mainComponent, timerState);
|
||
}
|
||
|
||
const boardComponent = hockeyPenaltyBoardComponent();
|
||
if (boardComponent) {
|
||
const source = timers?.penalty_board && typeof timers.penalty_board === "object"
|
||
? timers.penalty_board
|
||
: {};
|
||
const board = createHockeyBoardState(boardComponent);
|
||
board.penalties = (Array.isArray(source.penalties) ? source.penalties : [])
|
||
.slice(0, 64)
|
||
.map((event) => normalizeStoredHockeyEvent(boardComponent, event));
|
||
board.history = (Array.isArray(source.history) ? source.history : []).slice(0, 64);
|
||
board.selectedPlayer = null;
|
||
board.selectedInfraction = null;
|
||
board.selectedPreset = null;
|
||
board.selectedEventId = null;
|
||
board.historyOpen = false;
|
||
board.lastPersistAt = 0;
|
||
state.hockeyPenaltyBoards[boardComponent.action_id] = board;
|
||
refreshHockeyBoardNodes(boardComponent);
|
||
}
|
||
state.hockeyTimerGameId = String(gameId || "");
|
||
}
|
||
|
||
async function hockeyPersistGameTimers(gameId, { keepalive = false, force = false } = {}) {
|
||
gameId = String(gameId || "").trim();
|
||
if (!gameId || (state.hockeyTimerHydrating && !force)) return null;
|
||
if (state.hockeyTimerSaveTimer) {
|
||
clearTimeout(state.hockeyTimerSaveTimer);
|
||
state.hockeyTimerSaveTimer = null;
|
||
}
|
||
if (state.hockeyTimerSavePromise && !keepalive) {
|
||
try { await state.hockeyTimerSavePromise; } catch (_) {}
|
||
if (!force && !state.hockeyTimerDirty) return null;
|
||
}
|
||
const revision = state.hockeyTimerRevision;
|
||
const snapshot = hockeyGameTimerSnapshot();
|
||
const language = hockeyGameControlLanguage();
|
||
const request = hockeyGameControlRequest(`/games/${encodeURIComponent(gameId)}/control/timers`, {
|
||
method: "PUT",
|
||
body: JSON.stringify({ ...snapshot, language }),
|
||
...(keepalive ? { keepalive: true } : {}),
|
||
}).then((payload) => {
|
||
// The save response already contains the recalculated strength.
|
||
// Dispatch it immediately so the scorebar changes as soon as a penalty
|
||
// is assigned, without waiting for the penalty clock or polling cycle.
|
||
hockeyStoreGameControl(gameId, payload, { render: false, dispatch: true });
|
||
if (revision === state.hockeyTimerRevision) state.hockeyTimerDirty = false;
|
||
return payload;
|
||
}).catch((error) => {
|
||
state.hockeyTimerDirty = true;
|
||
if (!keepalive) console.error("Hockey timer save failed", error);
|
||
return null;
|
||
}).finally(() => {
|
||
if (state.hockeyTimerSavePromise === request) state.hockeyTimerSavePromise = null;
|
||
if (state.hockeyTimerDirty && !keepalive && hockeyTimerSelectedGameId() === gameId) {
|
||
hockeyScheduleTimerSave(false);
|
||
}
|
||
});
|
||
if (!keepalive) state.hockeyTimerSavePromise = request;
|
||
return request;
|
||
}
|
||
|
||
function hockeyScheduleTimerSave(force = false) {
|
||
if (state.hockeyTimerHydrating) return;
|
||
const gameId = hockeyTimerSelectedGameId();
|
||
if (!gameId) return;
|
||
state.hockeyTimerDirty = true;
|
||
state.hockeyTimerRevision += 1;
|
||
if (state.hockeyTimerSaveTimer) clearTimeout(state.hockeyTimerSaveTimer);
|
||
state.hockeyTimerSaveTimer = setTimeout(
|
||
() => hockeyPersistGameTimers(gameId, { force }),
|
||
force ? 40 : 850
|
||
);
|
||
}
|
||
|
||
async function hockeyActivateGameTimers(gameId) {
|
||
gameId = String(gameId || "").trim();
|
||
if (!gameId) return null;
|
||
if (state.hockeyTimerActivationPromise && state.hockeyTimerActivationGameId === gameId) {
|
||
return state.hockeyTimerActivationPromise;
|
||
}
|
||
const activation = (async () => {
|
||
const previousGameId = String(state.hockeyTimerGameId || "").trim();
|
||
state.hockeyTimerHydrating = true;
|
||
try {
|
||
if (previousGameId && previousGameId !== gameId) {
|
||
await hockeyPersistGameTimers(previousGameId, { force: true });
|
||
}
|
||
if (state.hockeyTimerSaveTimer) {
|
||
clearTimeout(state.hockeyTimerSaveTimer);
|
||
state.hockeyTimerSaveTimer = null;
|
||
}
|
||
state.hockeyTimerDirty = false;
|
||
const payload = await hockeyLoadGameControl(gameId, { force: true, rerender: false });
|
||
hockeyApplySavedTimers(gameId, payload?.timers || null);
|
||
state.hockeyTimerDirty = false;
|
||
return payload;
|
||
} finally {
|
||
state.hockeyTimerHydrating = false;
|
||
}
|
||
})();
|
||
state.hockeyTimerActivationGameId = gameId;
|
||
state.hockeyTimerActivationPromise = activation;
|
||
try {
|
||
const payload = await activation;
|
||
renderRuntime();
|
||
return payload;
|
||
} finally {
|
||
if (state.hockeyTimerActivationPromise === activation) {
|
||
state.hockeyTimerActivationPromise = null;
|
||
state.hockeyTimerActivationGameId = "";
|
||
}
|
||
}
|
||
}
|
||
|
||
function hockeyShootoutSlots(attempts, count, language) {
|
||
const byNumber = new Map((attempts || []).map((item) => [Number(item.team_attempt_number), item]));
|
||
return Array.from({ length: Math.max(0, Number(count) || 0) }, (_, index) => {
|
||
const attempt = byNumber.get(index + 1);
|
||
const className = attempt ? (attempt.scored ? "is-goal" : "is-miss") : "is-empty";
|
||
const title = attempt
|
||
? `${attempt.player_name || "—"}: ${attempt.scored ? (language === "en" ? "Goal" : "Гол") : (language === "en" ? "Miss" : "Не забил")}`
|
||
: (language === "en" ? "Pending attempt" : "Ожидает броска");
|
||
return `<span class="hso-slot ${className}" title="${escapeHtml(title)}">${attempt ? (attempt.scored ? "✓" : "×") : index + 1}</span>`;
|
||
}).join("");
|
||
}
|
||
|
||
function hockeyShootoutControlNode(component, runtime) {
|
||
const props = component.props || {};
|
||
const game = getByPath(state.data, props.gamePath || "hockey.selected_game") || null;
|
||
const tournament = getByPath(state.data, props.tournamentPath || "hockey.selected_tournament") || null;
|
||
const language = hockeyGameControlLanguage();
|
||
const node = div("hockey-shootout-control");
|
||
const gameId = String(game?.external_id || game?.id || "").trim();
|
||
if (!gameId) {
|
||
node.innerHTML = `<div class="hso-empty"><strong>${language === "en" ? "Select a game" : "Выберите матч"}</strong><span>${language === "en" ? "The shootout roster will appear after the game is loaded" : "Составы для буллитов появятся после загрузки матча"}</span></div>`;
|
||
return node;
|
||
}
|
||
const regular = String(tournament?.stage_key || "").toLowerCase() === "regular";
|
||
if (!regular) {
|
||
node.innerHTML = `<div class="hso-empty"><strong>${language === "en" ? "Shootout panel is unavailable" : "Вкладка буллитов недоступна"}</strong><span>${language === "en" ? "It is enabled for regular-season games" : "Она включается только для матчей регулярного чемпионата"}</span></div>`;
|
||
return node;
|
||
}
|
||
|
||
const control = state.hockeyGameControl[gameId];
|
||
if (!control) {
|
||
if (runtime) queueMicrotask(() => hockeyLoadGameControl(gameId));
|
||
node.innerHTML = `<div class="hso-empty is-loading"><strong>${language === "en" ? "Loading shootout data…" : "Загружаем данные буллитов…"}</strong></div>`;
|
||
return node;
|
||
}
|
||
const shootout = control.shootout || {};
|
||
const attempts = Array.isArray(shootout.attempts) ? shootout.attempts : [];
|
||
const homeAttempts = attempts.filter((item) => item.side === "home");
|
||
const awayAttempts = attempts.filter((item) => item.side === "away");
|
||
const allowed = Number(shootout.allowed_per_side || shootout.initial_attempts || 3);
|
||
const initialAttempts = Number(shootout.initial_attempts || 3) === 5 ? 5 : 3;
|
||
const selectedShootoutSize = Number(state.formValues[`hockey-shootout-size:${gameId}`] || initialAttempts) === 5 ? "5" : "3";
|
||
rememberUiNavigationState(component, "shootout_size", selectedShootoutSize, selectedShootoutSize, { emit: false });
|
||
const home = game.home || {};
|
||
const away = game.away || {};
|
||
const homePlayers = Array.isArray(home.players) ? [...home.players] : [];
|
||
const awayPlayers = Array.isArray(away.players) ? [...away.players] : [];
|
||
const sorter = (left, right) => (Number(left?.number) || 999) - (Number(right?.number) || 999)
|
||
|| String(left?.name || "").localeCompare(String(right?.name || ""));
|
||
homePlayers.sort(sorter);
|
||
awayPlayers.sort(sorter);
|
||
const selectedKey = `hockey-shootout-selected:${gameId}`;
|
||
const selected = state.formValues[selectedKey] || null;
|
||
const sideCount = selected?.side === "home" ? homeAttempts.length : awayAttempts.length;
|
||
const selectedAllowed = selected && sideCount < allowed;
|
||
const searchKey = (side) => `hockey-shootout-search:${gameId}:${side}`;
|
||
const normalizePlayerSearch = (value) => String(value || "")
|
||
.toLocaleLowerCase(language === "en" ? "en" : "ru")
|
||
.replaceAll("ё", "е")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
const homeSearch = normalizePlayerSearch(state.formValues[searchKey("home")] || "");
|
||
const awaySearch = normalizePlayerSearch(state.formValues[searchKey("away")] || "");
|
||
|
||
const playerRows = (players, side, query) => {
|
||
const rows = players.map((player) => {
|
||
const playerId = String(player?.external_id || player?.id || "");
|
||
const isSelected = selected?.side === side && String(selected?.player_id || "") === playerId;
|
||
const disabled = !runtime || !shootout.started || (side === "home" ? homeAttempts.length : awayAttempts.length) >= allowed;
|
||
const searchable = normalizePlayerSearch([
|
||
player?.number,
|
||
player?.name,
|
||
player?.first_name,
|
||
player?.last_name,
|
||
player?.middle_name,
|
||
].filter(Boolean).join(" "));
|
||
const hidden = Boolean(query && !searchable.includes(query));
|
||
return `<button type="button" class="hso-player ${isSelected ? "is-selected" : ""}" data-shootout-player="${escapeHtml(playerId)}" data-shootout-side="${side}" data-player-search="${escapeHtml(searchable)}" ${hidden ? "hidden" : ""} ${disabled ? "disabled" : ""}>
|
||
<span>#${escapeHtml(player?.number || "—")}</span><strong>${escapeHtml(player?.name || "—")}</strong><small>${escapeHtml(player?.position || player?.role || "")}</small>
|
||
</button>`;
|
||
});
|
||
if (!rows.length) return `<div class="hso-no-roster">${language === "en" ? "Roster is empty" : "Состав не загружен"}</div>`;
|
||
const visibleCount = players.filter((player) => normalizePlayerSearch([
|
||
player?.number,
|
||
player?.name,
|
||
player?.first_name,
|
||
player?.last_name,
|
||
player?.middle_name,
|
||
].filter(Boolean).join(" ")).includes(query)).length;
|
||
return `${rows.join("")}<div class="hso-search-empty" data-shootout-search-empty="${side}" ${visibleCount ? "hidden" : ""}>${language === "en" ? "No players found" : "Игроки не найдены"}</div>`;
|
||
};
|
||
|
||
const searchBox = (side, value) => `<label class="hso-player-search side-${side}">
|
||
<span aria-hidden="true">⌕</span>
|
||
<input type="search" data-shootout-search="${side}" value="${escapeHtml(value)}" placeholder="${language === "en" ? "Number, last or first name" : "Номер, фамилия или имя"}" autocomplete="off" spellcheck="false">
|
||
<button type="button" data-shootout-search-clear="${side}" ${value ? "" : "hidden"} aria-label="${language === "en" ? "Clear search" : "Очистить поиск"}">×</button>
|
||
</label>`;
|
||
|
||
const journal = attempts.map((attempt) => {
|
||
const sideName = attempt.side === "home" ? (home.name || "Хозяева") : (away.name || "Гости");
|
||
const seriesLabel = Number(attempt.series_number || 1) <= 1
|
||
? (language === "en" ? "Main series" : "Основная серия")
|
||
: `${language === "en" ? "Sudden death" : "Доп. серия"} ${Number(attempt.series_number) - 1}`;
|
||
return `<article class="hso-attempt side-${escapeHtml(attempt.side)} ${attempt.scored ? "is-goal" : "is-miss"}">
|
||
<div class="hso-attempt-order"><b>${escapeHtml(attempt.sequence_number)}</b><span>${escapeHtml(seriesLabel)}</span></div>
|
||
<div class="hso-attempt-player"><strong>${escapeHtml(attempt.player_name || "—")}</strong><span>${escapeHtml(sideName)} · ${language === "en" ? "attempt" : "буллит"} №${escapeHtml(attempt.team_attempt_number)}</span></div>
|
||
<div class="hso-attempt-result">${attempt.scored ? `<b>✓</b><span>${language === "en" ? "GOAL" : "ГОЛ"}</span>` : `<b>×</b><span>${language === "en" ? "MISS" : "НЕ ЗАБИЛ"}</span>`}</div>
|
||
<button type="button" class="hso-attempt-delete" data-shootout-delete="${escapeHtml(attempt.id)}" ${runtime ? "" : "disabled"} title="${language === "en" ? "Delete attempt" : "Удалить буллит"}">×</button>
|
||
</article>`;
|
||
}).join("") || `<div class="hso-journal-empty">${language === "en" ? "No attempts yet" : "Журнал пока пуст"}</div>`;
|
||
|
||
node.innerHTML = `<header class="hso-header">
|
||
<div><span>${language === "en" ? "SHOOTOUT" : "БУЛЛИТЫ"}</span><strong>${escapeHtml(home.name || "—")} — ${escapeHtml(away.name || "—")}</strong></div>
|
||
<div class="hso-score"><span>${escapeHtml(home.name || "HOME")}</span><strong>${Number(shootout.home?.goals || 0)} : ${Number(shootout.away?.goals || 0)}</strong><span>${escapeHtml(away.name || "AWAY")}</span></div>
|
||
</header>
|
||
<section class="hso-setup">
|
||
<div class="hso-series-size"><span>${language === "en" ? "Main series" : "Основная серия"}</span><button type="button" data-shootout-size="3" class="${initialAttempts === 3 ? "active" : ""}">3</button><button type="button" data-shootout-size="5" class="${initialAttempts === 5 ? "active" : ""}">5</button></div>
|
||
<button type="button" class="hso-start" data-shootout-start ${runtime ? "" : "disabled"}>${shootout.started ? (language === "en" ? "Restart series" : "Начать заново") : (language === "en" ? "Start series" : "Начать серию")}</button>
|
||
${shootout.can_add_round ? `<button type="button" class="hso-next-series" data-shootout-round ${runtime ? "" : "disabled"}>+ ${language === "en" ? "1 attempt each" : "по 1 буллиту"}</button>` : ""}
|
||
<div class="hso-series-info"><strong>${allowed}</strong><span>${language === "en" ? "attempts per team available" : "буллитов доступно каждой команде"}</span></div>
|
||
</section>
|
||
<section class="hso-progress">
|
||
<div class="side-home"><strong>${escapeHtml(home.name || "—")}</strong><div>${hockeyShootoutSlots(homeAttempts, allowed, language)}</div></div>
|
||
<div class="side-away"><strong>${escapeHtml(away.name || "—")}</strong><div>${hockeyShootoutSlots(awayAttempts, allowed, language)}</div></div>
|
||
</section>
|
||
<div class="hso-grid">
|
||
<section class="hso-roster side-home"><header><span>${language === "en" ? "HOME TEAM" : "ЛЕВАЯ КОМАНДА"}</span><strong>${escapeHtml(home.name || "—")}</strong></header>${searchBox("home", state.formValues[searchKey("home")] || "")}<div class="hso-player-list">${playerRows(homePlayers, "home", homeSearch)}</div></section>
|
||
<section class="hso-center">
|
||
<div class="hso-selected ${selected ? `side-${escapeHtml(selected.side)}` : ""}">${selected
|
||
? `<span>${language === "en" ? "Shooter selected" : "Выбран игрок"}</span><strong>#${escapeHtml(selected.number || "—")} ${escapeHtml(selected.name || "—")}</strong><div><button type="button" class="is-goal" data-shootout-result="goal" ${selectedAllowed && runtime ? "" : "disabled"}>✓ ${language === "en" ? "Goal" : "Забил"}</button><button type="button" class="is-miss" data-shootout-result="miss" ${selectedAllowed && runtime ? "" : "disabled"}>× ${language === "en" ? "Miss" : "Не забил"}</button></div>`
|
||
: `<span>${language === "en" ? "Select a player from either roster" : "Выберите игрока в одном из составов"}</span><strong>—</strong>`}</div>
|
||
<div class="hso-journal"><header><span>${language === "en" ? "ATTEMPT LOG" : "ЖУРНАЛ БУЛЛИТОВ"}</span><strong>${attempts.length}</strong></header><div class="hso-journal-list">${journal}</div></div>
|
||
</section>
|
||
<section class="hso-roster side-away"><header><span>${language === "en" ? "AWAY TEAM" : "ПРАВАЯ КОМАНДА"}</span><strong>${escapeHtml(away.name || "—")}</strong></header>${searchBox("away", state.formValues[searchKey("away")] || "")}<div class="hso-player-list">${playerRows(awayPlayers, "away", awaySearch)}</div></section>
|
||
</div>`;
|
||
|
||
node.querySelectorAll("[data-shootout-size]").forEach((button) => button.addEventListener("click", () => {
|
||
if (!runtime) return;
|
||
state.formValues[`hockey-shootout-size:${gameId}`] = Number(button.dataset.shootoutSize || 3);
|
||
rememberUiNavigationState(component, "shootout_size", String(state.formValues[`hockey-shootout-size:${gameId}`]), button.textContent || button.dataset.shootoutSize || "3");
|
||
node.querySelectorAll("[data-shootout-size]").forEach((item) => item.classList.toggle("active", item === button));
|
||
}));
|
||
node.querySelector("[data-shootout-start]")?.addEventListener("click", async () => {
|
||
if (!runtime) return;
|
||
const attemptsExist = attempts.length > 0;
|
||
if (attemptsExist && !window.confirm(language === "en" ? "Delete the current shootout log and start again?" : "Удалить текущий журнал буллитов и начать заново?")) return;
|
||
const chosen = Number(state.formValues[`hockey-shootout-size:${gameId}`] || initialAttempts);
|
||
await hockeyUpdateGameControl(gameId, "/shootout/setup", {
|
||
method: "PUT",
|
||
body: JSON.stringify({ initial_attempts: chosen === 5 ? 5 : 3, reset: attemptsExist, language }),
|
||
});
|
||
state.formValues[selectedKey] = null;
|
||
});
|
||
node.querySelector("[data-shootout-round]")?.addEventListener("click", async () => {
|
||
if (!runtime) return;
|
||
await hockeyUpdateGameControl(gameId, `/shootout/round?language=${language}`, { method: "POST" });
|
||
});
|
||
const applyShootoutSearch = (side, rawValue) => {
|
||
const query = normalizePlayerSearch(rawValue);
|
||
state.formValues[searchKey(side)] = rawValue;
|
||
let visible = 0;
|
||
node.querySelectorAll(`[data-shootout-player][data-shootout-side="${side}"]`).forEach((button) => {
|
||
const matches = !query || normalizePlayerSearch(button.dataset.playerSearch || "").includes(query);
|
||
button.hidden = !matches;
|
||
if (matches) visible += 1;
|
||
});
|
||
const empty = node.querySelector(`[data-shootout-search-empty="${side}"]`);
|
||
if (empty) empty.hidden = visible > 0;
|
||
const clear = node.querySelector(`[data-shootout-search-clear="${side}"]`);
|
||
if (clear) clear.hidden = !rawValue;
|
||
};
|
||
node.querySelectorAll("[data-shootout-search]").forEach((input) => {
|
||
input.addEventListener("input", () => applyShootoutSearch(input.dataset.shootoutSearch, input.value));
|
||
input.addEventListener("keydown", (event) => event.stopPropagation());
|
||
});
|
||
node.querySelectorAll("[data-shootout-search-clear]").forEach((button) => button.addEventListener("click", () => {
|
||
const side = button.dataset.shootoutSearchClear;
|
||
const input = node.querySelector(`[data-shootout-search="${side}"]`);
|
||
if (!input) return;
|
||
input.value = "";
|
||
applyShootoutSearch(side, "");
|
||
input.focus();
|
||
}));
|
||
|
||
node.querySelectorAll("[data-shootout-player]").forEach((button) => button.addEventListener("click", () => {
|
||
if (!runtime || button.disabled) return;
|
||
const side = button.dataset.shootoutSide;
|
||
const players = side === "home" ? homePlayers : awayPlayers;
|
||
const player = players.find((item) => String(item?.external_id || item?.id || "") === String(button.dataset.shootoutPlayer || ""));
|
||
if (!player) return;
|
||
state.formValues[selectedKey] = {
|
||
side,
|
||
player_id: String(player.external_id || player.id || ""),
|
||
number: player.number || "",
|
||
name: player.name || "",
|
||
};
|
||
renderRuntime();
|
||
}));
|
||
node.querySelectorAll("[data-shootout-result]").forEach((button) => button.addEventListener("click", async () => {
|
||
if (!runtime || !selected) return;
|
||
await hockeyUpdateGameControl(gameId, "/shootout/attempts", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
side: selected.side,
|
||
player_external_id: selected.player_id,
|
||
scored: button.dataset.shootoutResult === "goal",
|
||
language,
|
||
}),
|
||
});
|
||
state.formValues[selectedKey] = null;
|
||
}));
|
||
node.querySelectorAll("[data-shootout-delete]").forEach((button) => button.addEventListener("click", async () => {
|
||
if (!runtime) return;
|
||
await hockeyUpdateGameControl(gameId, `/shootout/attempts/${encodeURIComponent(button.dataset.shootoutDelete)}?language=${language}`, { method: "DELETE" });
|
||
}));
|
||
return node;
|
||
}
|
||
|
||
|
||
function hockeyScheduleDateLabel(value, language) {
|
||
const raw = String(value || "").trim();
|
||
if (!raw) return language === "en" ? "Selected day" : "Выбранный день";
|
||
const date = new Date(`${raw}T12:00:00`);
|
||
if (Number.isNaN(date.getTime())) return raw;
|
||
try {
|
||
return new Intl.DateTimeFormat(language === "en" ? "en-GB" : "ru-RU", {
|
||
weekday: "long", day: "numeric", month: "long", year: "numeric",
|
||
}).format(date);
|
||
} catch (_) {
|
||
return raw;
|
||
}
|
||
}
|
||
|
||
function hockeyScheduleStatus(game, language) {
|
||
const finished = Boolean(game?.is_finished || game?.status === "finished");
|
||
const live = game?.status === "live";
|
||
const finish = String(game?.score?.finish_label || "").trim();
|
||
if (live) return { key: "live", label: language === "en" ? "LIVE" : "ИДЁТ" };
|
||
if (finished) return {
|
||
key: "finished",
|
||
label: `${language === "en" ? "FINISHED" : "ЗАВЕРШЁН"}${finish ? ` · ${finish}` : ""}`,
|
||
};
|
||
return { key: "scheduled", label: language === "en" ? "SCHEDULED" : "НЕ НАЧАЛСЯ" };
|
||
}
|
||
|
||
function hockeyScheduleTeamMarkup(team, score, side, showScore) {
|
||
const name = String(team?.name || team?.short_name || "—");
|
||
const city = String(team?.city || "");
|
||
const monogram = name.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]).join("").toUpperCase() || "•";
|
||
return `
|
||
<div class="hsc-team hsc-team-${side}">
|
||
<span class="hsc-monogram">${escapeHtml(monogram)}</span>
|
||
<span class="hsc-team-copy"><strong>${escapeHtml(name)}</strong>${city ? `<small>${escapeHtml(city)}</small>` : ""}</span>
|
||
<b>${showScore ? Number(score || 0) : "—"}</b>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function hockeyScheduleNode(component, runtime) {
|
||
const props = component.props || {};
|
||
const language = getByPath(state.data, "hockey.language.display") === "en" ? "en" : "ru";
|
||
const payload = getByPath(state.data, props.schedulePath || "hockey.schedule") || {};
|
||
const allPayload = getByPath(state.data, props.allSchedulePath || "hockey.team_schedule") || {};
|
||
const tournament = getByPath(state.data, props.tournamentPath || "hockey.selected_tournament") || {};
|
||
const selectedGame = getByPath(state.data, "hockey.selected_game") || {};
|
||
const selectedGameId = String(getByPath(state.data, props.selectedGamePath || "hockey.selected_game.external_id") || "");
|
||
const items = Array.isArray(payload.items) ? [...payload.items] : [];
|
||
const allItems = Array.isArray(allPayload.items) ? [...allPayload.items] : [];
|
||
items.sort((a, b) => String(a?.time || "99:99").localeCompare(String(b?.time || "99:99")) || String(a?.external_id || "").localeCompare(String(b?.external_id || "")));
|
||
const meta = payload.meta || {};
|
||
const dateValue = payload.selected_date || meta.date || items[0]?.date || "";
|
||
const liveCount = items.filter((game) => game?.status === "live").length;
|
||
const finishedCount = items.filter((game) => game?.is_finished || game?.status === "finished").length;
|
||
const scheduledCount = Math.max(0, items.length - liveCount - finishedCount);
|
||
const tournamentName = tournament.name || tournament.full_name || tournament.common_name || tournament.league || (language === "en" ? "Tournament" : "Турнир");
|
||
const node = div("hockey-schedule");
|
||
const viewKey = "hockey-schedule-inner-view";
|
||
const statusKey = "hockey-schedule-team-status";
|
||
const venueKey = "hockey-schedule-team-venue";
|
||
const sortKey = "hockey-schedule-team-sort";
|
||
const homeTeam = selectedGame?.home || {};
|
||
const awayTeam = selectedGame?.away || {};
|
||
|
||
function identity(value) {
|
||
return String(value || "").toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
|
||
}
|
||
function teamMatches(candidate, target) {
|
||
const targetId = String(target?.id || target?.external_id || "").trim();
|
||
const candidateId = String(candidate?.id || candidate?.external_id || "").trim();
|
||
if (targetId && candidateId) return targetId === candidateId;
|
||
const targetNames = [target?.name, target?.short_name].map(identity).filter(Boolean);
|
||
const candidateNames = [candidate?.name, candidate?.short_name].map(identity).filter(Boolean);
|
||
return targetNames.some((name) => candidateNames.includes(name));
|
||
}
|
||
function gameHasTeam(game, team) {
|
||
return teamMatches(game?.home, team) || teamMatches(game?.away, team);
|
||
}
|
||
function isHeadToHead(game) {
|
||
return gameHasTeam(game, homeTeam) && gameHasTeam(game, awayTeam);
|
||
}
|
||
const canShowTeams = Boolean((homeTeam?.id || homeTeam?.name) && (awayTeam?.id || awayTeam?.name) && allItems.length);
|
||
let innerView = String(state.formValues[viewKey] || (items.length ? "day" : canShowTeams ? "teams" : "day"));
|
||
if (innerView === "teams" && !canShowTeams) innerView = "day";
|
||
const selectedStatus = String(state.formValues[statusKey] || "all");
|
||
const selectedVenue = String(state.formValues[venueKey] || "all");
|
||
const selectedSort = String(state.formValues[sortKey] || "closest");
|
||
rememberUiNavigationState(component, "schedule_view", innerView, innerView, { emit: false });
|
||
rememberUiNavigationState(component, "schedule_status", selectedStatus, selectedStatus, { emit: false });
|
||
rememberUiNavigationState(component, "schedule_venue", selectedVenue, selectedVenue, { emit: false });
|
||
rememberUiNavigationState(component, "schedule_sort", selectedSort, selectedSort, { emit: false });
|
||
|
||
function renderDayCards() {
|
||
if (!items.length) {
|
||
return `<div class="hsc-empty"><span>▣</span><strong>${language === "en" ? "No games for this date" : "На выбранную дату матчей нет"}</strong><small>${language === "en" ? "Switch to Team matches to see the full calendars of both clubs." : "Переключитесь на «Матчи команд», чтобы увидеть полный календарь обеих команд."}</small></div>`;
|
||
}
|
||
return `<div class="hsc-grid">${items.map((game) => {
|
||
const status = hockeyScheduleStatus(game, language);
|
||
const selected = String(game?.external_id || game?.id || "") === selectedGameId;
|
||
const showScore = status.key !== "scheduled" || Number(game?.home?.score) || Number(game?.away?.score);
|
||
const periods = Array.isArray(game?.score?.periods) ? game.score.periods.filter((value) => String(value || "").trim()) : [];
|
||
const round = String(game?.round?.name || "").trim();
|
||
const arena = String(game?.arena || "").trim();
|
||
const city = String(game?.arena_city || "").trim();
|
||
const series = String(game?.score?.series || "").trim();
|
||
return `<article class="hsc-card is-${status.key} ${selected ? "is-selected" : ""}">
|
||
<div class="hsc-card-top"><time>${escapeHtml(game?.time || "—:—")}</time><span class="hsc-status">${escapeHtml(status.label)}</span>${selected ? `<span class="hsc-current">${language === "en" ? "CURRENT" : "ТЕКУЩИЙ"}</span>` : ""}${game?.game_number ? `<small>#${escapeHtml(game.game_number)}</small>` : ""}</div>
|
||
<div class="hsc-matchup">${hockeyScheduleTeamMarkup(game?.home, game?.home?.score, "home", showScore)}<div class="hsc-versus">${showScore ? "" : "VS"}</div>${hockeyScheduleTeamMarkup(game?.away, game?.away?.score, "away", showScore)}</div>
|
||
${periods.length ? `<div class="hsc-periods">${periods.map((value, index) => `<span><i>${index + 1}</i><b>${escapeHtml(value)}</b></span>`).join("")}</div>` : ""}
|
||
<footer class="hsc-card-footer"><span>${arena ? `⌂ ${escapeHtml(arena)}${city ? ` · ${escapeHtml(city)}` : ""}` : (language === "en" ? "Arena not specified" : "Арена не указана")}</span><small>${[round, series ? `${language === "en" ? "Series" : "Серия"}: ${series}` : ""].filter(Boolean).map(escapeHtml).join(" · ")}</small></footer>
|
||
</article>`;
|
||
}).join("")}</div>`;
|
||
}
|
||
|
||
function gameDateTime(game) {
|
||
const raw = `${game?.date || "9999-12-31"}T${game?.time || "23:59"}:00`;
|
||
const parsed = Date.parse(raw);
|
||
return Number.isFinite(parsed) ? parsed : Number.MAX_SAFE_INTEGER;
|
||
}
|
||
function filterTeamGames(team) {
|
||
const now = Date.now();
|
||
let result = allItems.filter((game) => gameHasTeam(game, team));
|
||
if (selectedStatus === "finished") result = result.filter((game) => game?.is_finished || game?.status === "finished");
|
||
if (selectedStatus === "upcoming") result = result.filter((game) => !game?.is_finished && game?.status !== "finished" && game?.status !== "live");
|
||
if (selectedStatus === "live") result = result.filter((game) => game?.status === "live");
|
||
if (selectedVenue === "home") result = result.filter((game) => teamMatches(game?.home, team));
|
||
if (selectedVenue === "away") result = result.filter((game) => teamMatches(game?.away, team));
|
||
if (selectedVenue === "h2h") result = result.filter(isHeadToHead);
|
||
result.sort((a, b) => {
|
||
const aTime = gameDateTime(a); const bTime = gameDateTime(b);
|
||
if (selectedSort === "asc") return aTime - bTime;
|
||
if (selectedSort === "desc") return bTime - aTime;
|
||
const aFuture = aTime >= now; const bFuture = bTime >= now;
|
||
if (aFuture !== bFuture) return aFuture ? -1 : 1;
|
||
return aFuture ? aTime - bTime : bTime - aTime;
|
||
});
|
||
return result;
|
||
}
|
||
function teamHistoryCard(game, team) {
|
||
const teamIsHome = teamMatches(game?.home, team);
|
||
const opponent = teamIsHome ? game?.away : game?.home;
|
||
const status = hockeyScheduleStatus(game, language);
|
||
const h2h = isHeadToHead(game);
|
||
const selected = String(game?.external_id || game?.id || "") === selectedGameId;
|
||
const showScore = status.key !== "scheduled" || Number(game?.home?.score) || Number(game?.away?.score);
|
||
const ownScore = teamIsHome ? game?.home?.score : game?.away?.score;
|
||
const opponentScore = teamIsHome ? game?.away?.score : game?.home?.score;
|
||
const venue = teamIsHome ? (language === "en" ? "HOME" : "ДОМА") : (language === "en" ? "AWAY" : "В ГОСТЯХ");
|
||
return `<article class="hsc-history-card is-${status.key} ${h2h ? "is-head-to-head" : ""} ${selected ? "is-selected" : ""}">
|
||
<div class="hsc-history-date"><time>${escapeHtml(hockeyScheduleDateLabel(game?.date || "", language))}</time><b>${escapeHtml(game?.time || "—:—")}</b></div>
|
||
<div class="hsc-history-main"><span class="hsc-history-venue">${venue}</span><strong>${escapeHtml(opponent?.name || opponent?.short_name || "—")}</strong><small>${escapeHtml(game?.arena || game?.arena_city || "")}</small></div>
|
||
<div class="hsc-history-result"><span class="hsc-status">${escapeHtml(status.label)}</span><b>${showScore ? `${Number(ownScore || 0)}:${Number(opponentScore || 0)}` : "—"}</b></div>
|
||
${h2h ? `<em>${language === "en" ? "HEAD-TO-HEAD" : "ОЧНАЯ ВСТРЕЧА"}</em>` : ""}
|
||
</article>`;
|
||
}
|
||
function teamColumn(team, side) {
|
||
const games = filterTeamGames(team);
|
||
return `<section class="hsc-history-column side-${side}"><header><span>${side === "home" ? (language === "en" ? "LEFT TEAM" : "ЛЕВАЯ КОМАНДА") : (language === "en" ? "RIGHT TEAM" : "ПРАВАЯ КОМАНДА")}</span><strong>${escapeHtml(team?.name || team?.short_name || "—")}</strong><b>${games.length}</b></header><div class="hsc-history-list">${games.length ? games.map((game) => teamHistoryCard(game, team)).join("") : `<div class="hsc-history-empty">${language === "en" ? "No matches for selected filters" : "Нет матчей по выбранным фильтрам"}</div>`}</div></section>`;
|
||
}
|
||
|
||
const teamControls = innerView === "teams" ? `<div class="hsc-filters">
|
||
<label><span>${language === "en" ? "Status" : "Статус"}</span><select data-hsc-filter="status"><option value="all">${language === "en" ? "All" : "Все"}</option><option value="finished">${language === "en" ? "Finished" : "Завершённые"}</option><option value="upcoming">${language === "en" ? "Upcoming" : "Предстоящие"}</option><option value="live">Live</option></select></label>
|
||
<label><span>${language === "en" ? "Matches" : "Матчи"}</span><select data-hsc-filter="venue"><option value="all">${language === "en" ? "Home and away" : "Дома и в гостях"}</option><option value="home">${language === "en" ? "Home only" : "Только дома"}</option><option value="away">${language === "en" ? "Away only" : "Только в гостях"}</option><option value="h2h">${language === "en" ? "Head-to-head only" : "Только очные"}</option></select></label>
|
||
<label><span>${language === "en" ? "Sort" : "Сортировка"}</span><select data-hsc-filter="sort"><option value="closest">${language === "en" ? "Nearest first" : "Сначала ближайшие"}</option><option value="asc">${language === "en" ? "Date ascending" : "Дата по возрастанию"}</option><option value="desc">${language === "en" ? "Date descending" : "Дата по убыванию"}</option></select></label>
|
||
</div>` : "";
|
||
|
||
node.innerHTML = `<header class="hsc-header"><div class="hsc-title"><span>${language === "en" ? "LEAGUE SCHEDULE" : "РАСПИСАНИЕ ЛИГИ"}</span><strong>${escapeHtml(tournamentName)}</strong><small>${innerView === "day" ? escapeHtml(hockeyScheduleDateLabel(dateValue, language)) : (language === "en" ? "Full schedules of the selected teams" : "Полный календарь выбранных команд")}</small></div>${innerView === "day" ? `<div class="hsc-summary"><div><b>${items.length}</b><span>${language === "en" ? "games" : "матчей"}</span></div>${liveCount ? `<div class="live"><b>${liveCount}</b><span>${language === "en" ? "live" : "сейчас"}</span></div>` : ""}${scheduledCount ? `<div><b>${scheduledCount}</b><span>${language === "en" ? "upcoming" : "впереди"}</span></div>` : ""}${finishedCount ? `<div><b>${finishedCount}</b><span>${language === "en" ? "finished" : "завершено"}</span></div>` : ""}</div>` : `<div class="hsc-summary"><div><b>${allItems.filter((game) => isHeadToHead(game)).length}</b><span>${language === "en" ? "head-to-head" : "очных"}</span></div></div>`}</header>
|
||
<div class="hsc-subnav"><button type="button" data-hsc-view="day" class="${innerView === "day" ? "active" : ""}">${language === "en" ? "Games of the day" : "Матчи дня"}</button>${canShowTeams ? `<button type="button" data-hsc-view="teams" class="${innerView === "teams" ? "active" : ""}">${language === "en" ? "Team matches" : "Матчи команд"}</button>` : ""}${teamControls}</div>
|
||
<div class="hsc-content ${innerView === "teams" ? "is-team-history" : "is-day"}">${innerView === "teams" && canShowTeams ? `<div class="hsc-history-grid">${teamColumn(homeTeam, "home")}${teamColumn(awayTeam, "away")}</div>` : renderDayCards()}</div>`;
|
||
|
||
node.querySelectorAll("[data-hsc-view]").forEach((button) => button.addEventListener("click", () => {
|
||
state.formValues[viewKey] = button.dataset.hscView || "day";
|
||
rememberUiNavigationState(component, "schedule_view", state.formValues[viewKey], button.textContent || state.formValues[viewKey]);
|
||
renderRuntime();
|
||
}));
|
||
const statusSelect = node.querySelector('[data-hsc-filter="status"]'); if (statusSelect) statusSelect.value = selectedStatus;
|
||
const venueSelect = node.querySelector('[data-hsc-filter="venue"]'); if (venueSelect) venueSelect.value = selectedVenue;
|
||
const sortSelect = node.querySelector('[data-hsc-filter="sort"]'); if (sortSelect) sortSelect.value = selectedSort;
|
||
node.querySelectorAll("[data-hsc-filter]").forEach((select) => select.addEventListener("change", () => {
|
||
const kind = select.dataset.hscFilter || "status";
|
||
const key = kind === "status" ? statusKey : kind === "venue" ? venueKey : sortKey;
|
||
state.formValues[key] = select.value;
|
||
rememberUiNavigationState(component, `schedule_${kind}`, select.value, select.selectedOptions?.[0]?.textContent || select.value);
|
||
renderRuntime();
|
||
}));
|
||
node.style.setProperty("--hsc-home", props.homeColor || "#4d9cff");
|
||
node.style.setProperty("--hsc-away", props.awayColor || "#ff5f79");
|
||
return node;
|
||
}
|
||
|
||
function renderComponent(component, runtime = true) {
|
||
const props = component.props || {};
|
||
const root = document.createElement("div");
|
||
root.className = "ui-box";
|
||
const pathValue = (key = "path") => getByPath(state.data, props[key]);
|
||
|
||
let node;
|
||
switch (component.type) {
|
||
case "container":
|
||
node = div("ui-container");
|
||
break;
|
||
case "card":
|
||
node = div("ui-card", `<strong>${escapeHtml(component.title)}</strong><div style="margin-top:8px;color:#94a7c1">${escapeHtml(props.body)}</div>`);
|
||
break;
|
||
case "divider": node = div("ui-divider"); break;
|
||
case "spacer": node = div("ui-spacer"); break;
|
||
case "heading": {
|
||
node = document.createElement(props.level || "h2");
|
||
node.className = "ui-heading";
|
||
node.textContent = props.text || component.title;
|
||
break;
|
||
}
|
||
case "text": node = div("ui-text"); node.textContent = props.text || ""; break;
|
||
case "data_text": node = dataTextNode(props, false); break;
|
||
case "kpi": node = dataTextNode(props, true); break;
|
||
case "badge": {
|
||
const value = props.path ? pathValue() : props.text;
|
||
node = div("ui-badge"); node.textContent = formatValue(value, props.text || "—"); break;
|
||
}
|
||
case "image": {
|
||
node = document.createElement("img"); node.className = "ui-image"; node.alt = props.alt || ""; node.src = props.path ? formatValue(pathValue(), props.src) : props.src; node.style.objectFit = props.fit || "cover"; break;
|
||
}
|
||
case "icon": node = div("ui-icon"); node.textContent = props.icon || "★"; break;
|
||
case "alert": node = div("ui-alert"); node.textContent = formatValue(props.path ? pathValue() : props.text, "—"); break;
|
||
case "progress": node = progressNode(props); break;
|
||
case "timer": node = timerNode(component, runtime); break;
|
||
case "penalty_timer": node = penaltyTimerNode(component, runtime); break;
|
||
case "hockey_penalty_dashboard": node = hockeyPenaltyDashboardNode(component, runtime); break;
|
||
case "hockey_prematch_panel": node = hockeyPrematchPanelNode(component, runtime); break;
|
||
case "hockey_team_statistics": node = hockeyStatisticsHubNode(component, runtime); break;
|
||
case "hockey_shootout_control": node = hockeyShootoutControlNode(component, runtime); break;
|
||
case "hockey_schedule": node = hockeyScheduleNode(component, runtime); break;
|
||
case "hockey_referees": node = hockeyRefereesNode(component, runtime); break;
|
||
case "hockey_tournament_standings": node = hockeyTournamentStandingsNode(component, runtime); break;
|
||
case "hockey_player_statistics": node = hockeyPlayerStatisticsNode(component, runtime); break;
|
||
case "list": node = listNode(props); break;
|
||
case "key_value": node = keyValueNode(pathValue()); break;
|
||
case "table": node = tableNode(props); break;
|
||
case "cards": node = cardsNode(props); break;
|
||
case "bar_chart": node = barChartNode(props); break;
|
||
case "line_chart": node = lineChartNode(props); break;
|
||
case "json_viewer": {
|
||
node = document.createElement("pre"); node.className = "ui-json"; node.textContent = JSON.stringify(props.path ? pathValue() : state.data, null, 2); break;
|
||
}
|
||
case "button": node = buttonNode(component, runtime); break;
|
||
case "link": node = linkNode(component, runtime); break;
|
||
case "button_group": node = buttonGroupNode(component, runtime); break;
|
||
case "text_input": node = inputNode(component, "text", runtime); break;
|
||
case "number_input": node = inputNode(component, "number", runtime); break;
|
||
case "textarea": node = inputNode(component, "textarea", runtime); break;
|
||
case "select": node = selectNode(component, false, runtime); break;
|
||
case "multiselect": node = selectNode(component, true, runtime); break;
|
||
case "checkbox": node = checkNode(component, false, runtime); break;
|
||
case "switch": node = checkNode(component, true, runtime); break;
|
||
case "radio": node = radioNode(component, runtime); break;
|
||
case "date": node = inputNode(component, "date", runtime); break;
|
||
case "time": node = inputNode(component, "time", runtime); break;
|
||
case "datetime": node = inputNode(component, "datetime-local", runtime); break;
|
||
case "color": node = inputNode(component, "color", runtime); break;
|
||
case "range": node = rangeNode(component, runtime); break;
|
||
case "file": node = fileNode(component, runtime); break;
|
||
case "accordion": node = accordionNode(component, runtime); break;
|
||
case "breadcrumb": node = breadcrumbNode(props); break;
|
||
case "pagination": node = paginationNode(component, runtime); break;
|
||
case "tab_bar": node = tabBarNode(component, runtime); break;
|
||
case "modal": node = modalButtonNode(component, runtime); break;
|
||
case "iframe": {
|
||
node = document.createElement("iframe"); node.src = props.url || "about:blank"; node.title = props.title || "iframe"; node.style.width = "100%"; node.style.height = "100%"; node.style.border = "0"; break;
|
||
}
|
||
case "video": {
|
||
node = document.createElement("video"); node.src = props.url || ""; node.controls = Boolean(props.controls); node.autoplay = Boolean(props.autoplay); node.muted = Boolean(props.muted); node.style.width = "100%"; node.style.height = "100%"; node.style.objectFit = "contain"; break;
|
||
}
|
||
default:
|
||
node = div("ui-text"); node.textContent = `Неизвестный компонент: ${component.type}`;
|
||
}
|
||
applyCommonStyle(node, component);
|
||
applyInteractionClasses(root, component);
|
||
root.appendChild(node);
|
||
return root;
|
||
}
|
||
|
||
function div(className, html = "") { const node = document.createElement("div"); node.className = className; node.innerHTML = html; return node; }
|
||
|
||
function dataTextNode(props, kpi) {
|
||
const node = div(kpi ? "ui-kpi" : "ui-data-text");
|
||
const value = getByPath(state.data, props.path);
|
||
node.innerHTML = `<div class="ui-label">${escapeHtml(props.label || "Значение")}</div><div class="ui-value">${escapeHtml(`${props.prefix || ""}${formatValue(value, props.fallback || "—")}${props.suffix || ""}`)}</div>`;
|
||
return node;
|
||
}
|
||
|
||
function progressNode(props) {
|
||
const raw = props.path ? getByPath(state.data, props.path) : props.value;
|
||
const value = Number(raw) || 0;
|
||
const max = Math.max(1, Number(props.max) || 100);
|
||
const percent = clamp(value / max * 100, 0, 100);
|
||
const node = div("ui-progress");
|
||
node.innerHTML = `<div><span>${escapeHtml(props.label || "Прогресс")}</span><strong style="float:right">${escapeHtml(`${formatValue(raw, "0")}${props.suffix || ""}`)}</strong></div><div class="ui-progress-track"><div class="ui-progress-fill" style="width:${percent}%"></div></div>`;
|
||
return node;
|
||
}
|
||
|
||
function listNode(props) {
|
||
let items = getByPath(state.data, props.path);
|
||
if (!Array.isArray(items)) items = String(props.items || "").split("|").map((x) => x.trim()).filter(Boolean);
|
||
const node = document.createElement("ul");
|
||
node.className = "ui-list";
|
||
items.forEach((item) => {
|
||
const li = document.createElement("li");
|
||
li.textContent = typeof item === "object" ? JSON.stringify(item) : String(item);
|
||
node.appendChild(li);
|
||
});
|
||
return node;
|
||
}
|
||
|
||
function keyValueNode(value) {
|
||
const node = document.createElement("dl");
|
||
node.className = "ui-key-value";
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) value = { value: formatValue(value) };
|
||
Object.entries(value).forEach(([key, item]) => {
|
||
const dt = document.createElement("dt"); dt.textContent = key;
|
||
const dd = document.createElement("dd"); dd.textContent = formatValue(item);
|
||
node.append(dt, dd);
|
||
});
|
||
return node;
|
||
}
|
||
|
||
function tableNode(props) {
|
||
const rows = getByPath(state.data, props.path);
|
||
const data = Array.isArray(rows) ? rows.slice(0, Math.max(1, Number(props.limit) || 20)) : [];
|
||
const columns = parseColumns(props.columns);
|
||
const wrap = div("ui-table-wrap");
|
||
const table = document.createElement("table");
|
||
table.className = "ui-table";
|
||
table.innerHTML = `<thead><tr>${columns.map((col) => `<th>${escapeHtml(col.label)}</th>`).join("")}</tr></thead><tbody>${data.length ? data.map((row) => `<tr>${columns.map((col) => `<td>${escapeHtml(formatValue(getByPath(row, col.key)))}</td>`).join("")}</tr>`).join("") : `<tr><td colspan="${Math.max(1, columns.length)}">${escapeHtml(props.emptyText || "Нет данных")}</td></tr>`}</tbody>`;
|
||
wrap.appendChild(table);
|
||
return wrap;
|
||
}
|
||
|
||
function cardsNode(props) {
|
||
const rows = getByPath(state.data, props.path);
|
||
const data = Array.isArray(rows) ? rows.slice(0, Math.max(1, Number(props.limit) || 12)) : [];
|
||
const node = div("ui-cards");
|
||
data.forEach((row) => {
|
||
const card = div("ui-repeat-card");
|
||
card.innerHTML = `<strong>${escapeHtml(formatValue(getByPath(row, props.titleField)))}</strong><small>${escapeHtml(formatValue(getByPath(row, props.subtitleField), ""))}</small><div style="margin-top:8px;font-size:20px;font-weight:850">${escapeHtml(formatValue(getByPath(row, props.valueField), ""))}</div>`;
|
||
node.appendChild(card);
|
||
});
|
||
return node;
|
||
}
|
||
|
||
function chartData(props) {
|
||
const rows = getByPath(state.data, props.path);
|
||
return (Array.isArray(rows) ? rows : []).slice(0, Math.max(1, Number(props.limit) || 20)).map((row) => ({ label: formatValue(getByPath(row, props.labelField), ""), value: Number(getByPath(row, props.valueField)) || 0 }));
|
||
}
|
||
|
||
function barChartNode(props) {
|
||
const data = chartData(props);
|
||
const max = Math.max(1, ...data.map((item) => Math.abs(item.value)));
|
||
const node = div("ui-chart");
|
||
data.forEach((item) => {
|
||
const barItem = div("ui-bar-item");
|
||
const bar = div("ui-bar");
|
||
bar.style.height = `${Math.max(2, Math.abs(item.value) / max * 85)}%`;
|
||
bar.title = `${item.label}: ${item.value}`;
|
||
const label = div("ui-bar-label"); label.textContent = item.label;
|
||
barItem.append(bar, label);
|
||
node.appendChild(barItem);
|
||
});
|
||
return node;
|
||
}
|
||
|
||
function lineChartNode(props) {
|
||
const data = chartData(props);
|
||
const node = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||
node.setAttribute("viewBox", "0 0 600 260");
|
||
node.setAttribute("preserveAspectRatio", "none");
|
||
node.classList.add("ui-line-chart");
|
||
if (!data.length) return node;
|
||
const values = data.map((item) => item.value);
|
||
const min = Math.min(...values);
|
||
const max = Math.max(...values);
|
||
const range = max - min || 1;
|
||
const points = data.map((item, index) => {
|
||
const x = data.length === 1 ? 300 : 25 + index * 550 / (data.length - 1);
|
||
const y = 225 - (item.value - min) / range * 190;
|
||
return `${x},${y}`;
|
||
}).join(" ");
|
||
node.innerHTML = `<line x1="25" y1="225" x2="575" y2="225" stroke="#30425b"/><line x1="25" y1="25" x2="25" y2="225" stroke="#30425b"/><polyline points="${points}" fill="none" stroke="#48dfbd" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>`;
|
||
return node;
|
||
}
|
||
|
||
function inputNode(component, type, runtime) {
|
||
const props = component.props;
|
||
const node = div("ui-field");
|
||
const label = document.createElement("label");
|
||
label.textContent = props.label || component.title;
|
||
const initial = state.formValues[component.id] ?? (props.bindPath ? getByPath(state.data, props.bindPath) : props.value);
|
||
|
||
if (type === "color") {
|
||
const control = div("ui-color-control");
|
||
const picker = document.createElement("input");
|
||
picker.type = "color";
|
||
picker.value = pickerColor(initial, "#48dfbd");
|
||
picker.disabled = !runtime || Boolean(props.disabled);
|
||
const valueLabel = div("ui-color-value");
|
||
valueLabel.textContent = picker.value;
|
||
picker.addEventListener("input", () => {
|
||
state.formValues[component.id] = picker.value;
|
||
ensureComponentState(component).value = picker.value;
|
||
valueLabel.textContent = picker.value;
|
||
if (runtime) emitInteraction(component, "change", { value: picker.value });
|
||
});
|
||
control.append(picker, valueLabel);
|
||
node.append(label, control);
|
||
return node;
|
||
}
|
||
|
||
let input;
|
||
if (type === "textarea") input = document.createElement("textarea");
|
||
else { input = document.createElement("input"); input.type = type; }
|
||
if (type !== "file") input.value = initial ?? "";
|
||
input.placeholder = props.placeholder || "";
|
||
input.required = Boolean(props.required);
|
||
input.disabled = !runtime || Boolean(props.disabled);
|
||
if (type === "number") { input.min = props.min; input.max = props.max; input.step = props.step || 1; }
|
||
input.addEventListener("input", () => { state.formValues[component.id] = input.value; ensureComponentState(component).value = input.value; if (runtime) emitInteraction(component, "change", { value: input.value }); });
|
||
|
||
node.appendChild(label);
|
||
if (type === "number") node.appendChild(numberControlElement(input));
|
||
else node.appendChild(input);
|
||
return node;
|
||
}
|
||
|
||
function optionsFor(props) {
|
||
const data = getByPath(state.data, props.optionsPath);
|
||
if (Array.isArray(data)) return data.map((item) => typeof item === "object" ? { value: String(item.value ?? item.id ?? item.label ?? item.name), label: String(item.label ?? item.name ?? item.title ?? item.value ?? item.id) } : { value: String(item), label: String(item) });
|
||
return parseOptions(props.options);
|
||
}
|
||
|
||
function selectNode(component, multiple, runtime) {
|
||
const props = component.props;
|
||
const node = div("ui-field");
|
||
const label = document.createElement("label"); label.textContent = props.label || component.title;
|
||
const select = document.createElement("select");
|
||
select.multiple = multiple;
|
||
select.disabled = !runtime;
|
||
const selected = String(state.formValues[component.id] ?? props.value ?? "").split("|");
|
||
optionsFor(props).forEach((item) => {
|
||
const option = document.createElement("option"); option.value = item.value; option.textContent = item.label; option.selected = selected.includes(item.value); select.appendChild(option);
|
||
});
|
||
select.addEventListener("change", () => { const value = multiple ? [...select.selectedOptions].map((o) => o.value).join("|") : select.value; state.formValues[component.id] = value; ensureComponentState(component).value = value; if (runtime) emitInteraction(component, "change", { value, item_id: multiple ? "" : value }); });
|
||
node.append(label, select);
|
||
return node;
|
||
}
|
||
|
||
function checkNode(component, asSwitch, runtime) {
|
||
const props = component.props;
|
||
const node = div("ui-check-field");
|
||
const checked = state.formValues[component.id] ?? Boolean(props.checked);
|
||
const input = document.createElement("input"); input.type = "checkbox"; input.checked = checked; input.disabled = !runtime;
|
||
if (asSwitch) input.className = "switch-native";
|
||
input.addEventListener("change", () => { state.formValues[component.id] = input.checked; const st = ensureComponentState(component); st.active = input.checked; st.value = input.checked; if (runtime) emitInteraction(component, "change", { value: input.checked }); });
|
||
if (asSwitch) {
|
||
const switchLabel = document.createElement("label"); switchLabel.className = "ui-switch";
|
||
const track = document.createElement("span"); track.className = "ui-switch-track";
|
||
switchLabel.append(input, track);
|
||
node.append(switchLabel, document.createTextNode(props.label || component.title));
|
||
} else node.append(input, document.createTextNode(props.label || component.title));
|
||
return node;
|
||
}
|
||
|
||
function radioNode(component, runtime) {
|
||
const props = component.props;
|
||
const node = div("ui-field");
|
||
const label = document.createElement("label"); label.textContent = props.label || component.title;
|
||
const group = div("ui-radio-group");
|
||
const selected = state.formValues[component.id] ?? props.value;
|
||
parseOptions(props.options).forEach((item) => {
|
||
const wrap = document.createElement("label"); wrap.className = "ui-radio-option";
|
||
const input = document.createElement("input"); input.type = "radio"; input.name = `radio-${component.id}`; input.value = item.value; input.checked = selected === item.value; input.disabled = !runtime;
|
||
input.addEventListener("change", () => { if (input.checked) { state.formValues[component.id] = input.value; ensureComponentState(component).value = input.value; if (runtime) emitInteraction(component, "change", { value: input.value, item_id: input.value }); } });
|
||
wrap.append(input, document.createTextNode(item.label)); group.appendChild(wrap);
|
||
});
|
||
node.append(label, group);
|
||
return node;
|
||
}
|
||
|
||
function rangeNode(component, runtime) {
|
||
const props = component.props;
|
||
const node = div("ui-field");
|
||
const label = document.createElement("label");
|
||
const value = state.formValues[component.id] ?? props.value;
|
||
label.textContent = `${props.label || component.title}: ${value}`;
|
||
const input = document.createElement("input"); input.type = "range"; input.min = props.min; input.max = props.max; input.step = props.step; input.value = value; input.disabled = !runtime;
|
||
input.addEventListener("input", () => { state.formValues[component.id] = input.value; ensureComponentState(component).value = input.value; label.textContent = `${props.label || component.title}: ${input.value}`; if (runtime) emitInteraction(component, "change", { value: input.value }); });
|
||
node.append(label, input);
|
||
return node;
|
||
}
|
||
|
||
function fileNode(component, runtime) {
|
||
const props = component.props;
|
||
const node = div("ui-field");
|
||
const label = document.createElement("label"); label.textContent = props.label || component.title;
|
||
const input = document.createElement("input"); input.type = "file"; input.accept = props.accept || "*/*"; input.multiple = Boolean(props.multiple); input.disabled = !runtime;
|
||
input.addEventListener("change", () => { const value = [...input.files].map((file) => file.name); state.formValues[component.id] = value; ensureComponentState(component).value = value; if (runtime) emitInteraction(component, "change", { value }); });
|
||
node.append(label, input);
|
||
return node;
|
||
}
|
||
|
||
function bindPointerStatus(node, component, runtime, itemId = "") {
|
||
if (!runtime) return;
|
||
node.addEventListener("pointerdown", () => emitInteraction(component, "pointer_down", { item_id: itemId }));
|
||
const release = () => emitInteraction(component, "pointer_up", { item_id: itemId });
|
||
node.addEventListener("pointerup", release);
|
||
node.addEventListener("pointercancel", release);
|
||
node.addEventListener("pointerleave", () => { if (ensureComponentState(component).pressed) release(); });
|
||
}
|
||
|
||
function buttonNode(component, runtime) {
|
||
const button = document.createElement("button"); button.className = "ui-button"; button.type = "button"; button.textContent = component.props.text || component.title; button.disabled = !runtime;
|
||
bindPointerStatus(button, component, runtime);
|
||
button.addEventListener("click", () => { if (!runtime) return; emitInteraction(component, "click"); executeAction(component.props, component); });
|
||
return button;
|
||
}
|
||
|
||
function linkNode(component, runtime) {
|
||
const props = component.props;
|
||
const link = document.createElement("a"); link.className = `ui-link-button ${props.variant === "secondary" ? "secondary" : ""}`; link.href = runtime ? (props.url || "#") : "#"; link.textContent = props.text || "Ссылка"; if (props.newTab) { link.target = "_blank"; link.rel = "noopener"; }
|
||
bindPointerStatus(link, component, runtime);
|
||
link.addEventListener("click", (event) => { if (!runtime) { event.preventDefault(); return; } emitInteraction(component, "click", { value: props.url || "" }); });
|
||
return link;
|
||
}
|
||
|
||
function buttonGroupNode(component, runtime) {
|
||
const node = div("ui-button-group");
|
||
parseOptions(component.props.buttons).forEach((item) => {
|
||
const button = document.createElement("button"); button.type = "button"; button.textContent = item.label; button.dataset.itemId = item.value; button.disabled = !runtime;
|
||
bindPointerStatus(button, component, runtime, item.value);
|
||
button.addEventListener("click", () => { if (runtime) emitInteraction(component, "click", { item_id: item.value, value: item.value, label: item.label }); });
|
||
node.appendChild(button);
|
||
});
|
||
return node;
|
||
}
|
||
|
||
function executeAction(props, component = null) {
|
||
if (!props.action || props.action === "none") return;
|
||
if (props.action === "refresh") loadData(true);
|
||
else if (props.action === "set_tab") { activateRuntimeTab(props.targetTab || state.config.tabs[0]?.id, { source: "component-action" }); }
|
||
else if (props.action === "open_url" && props.url) window.open(props.url, "_blank", "noopener");
|
||
else if (props.action === "event") window.dispatchEvent(new CustomEvent(props.eventName || "ui-builder-action", { detail: { component: component ? clone(component) : null, props, formValues: clone(state.formValues) } }));
|
||
else if (props.action === "message") toast(props.message || "Действие выполнено");
|
||
}
|
||
|
||
function accordionNode(component, runtime) {
|
||
const props = component.props;
|
||
const node = div("ui-accordion");
|
||
const button = document.createElement("button"); button.type = "button"; button.textContent = props.header || component.title;
|
||
const body = div("ui-accordion-body"); body.textContent = props.body || "";
|
||
const opened = state.formValues[component.id] ?? Boolean(props.opened);
|
||
body.classList.toggle("hidden", !opened);
|
||
button.addEventListener("click", () => { if (!runtime) return; const next = body.classList.contains("hidden"); body.classList.toggle("hidden", !next); state.formValues[component.id] = next; ensureComponentState(component).active = next; emitInteraction(component, "change", { value: next }); });
|
||
node.append(button, body);
|
||
return node;
|
||
}
|
||
|
||
function breadcrumbNode(props) {
|
||
const node = div("ui-breadcrumb");
|
||
const items = String(props.items || "").split("|").map((x) => x.trim()).filter(Boolean);
|
||
items.forEach((item, index) => {
|
||
const child = index === items.length - 1 ? document.createElement("strong") : document.createElement("span"); child.textContent = item; node.appendChild(child); if (index < items.length - 1) node.appendChild(document.createTextNode("›"));
|
||
});
|
||
return node;
|
||
}
|
||
|
||
function paginationNode(component, runtime) {
|
||
const props = component.props;
|
||
const node = div("ui-pagination");
|
||
const current = Number(state.formValues[component.id] ?? props.current ?? 1);
|
||
const pages = clamp(Number(props.pages) || 1, 1, 100);
|
||
for (let i = 1; i <= Math.min(pages, 10); i++) {
|
||
const button = document.createElement("button"); button.type = "button"; button.textContent = i; button.classList.toggle("active", i === current); button.disabled = !runtime; button.dataset.itemId = String(i); button.addEventListener("click", () => { state.formValues[component.id] = i; ensureComponentState(component).value = i; emitInteraction(component, "page_change", { value: i, item_id: String(i) }); renderRuntime(); }); node.appendChild(button);
|
||
}
|
||
return node;
|
||
}
|
||
|
||
function runtimeVisibleTabs() {
|
||
const standings = getByPath(state.data, "hockey.standings") || {};
|
||
const schedule = getByPath(state.data, "hockey.schedule") || {};
|
||
const teamSchedule = getByPath(state.data, "hockey.team_schedule") || {};
|
||
const selectedGame = getByPath(state.data, "hockey.selected_game") || null;
|
||
const selectedTournament = getByPath(state.data, "hockey.selected_tournament") || null;
|
||
const hasSchedule = (Array.isArray(schedule.items) && schedule.items.length > 0)
|
||
|| (Array.isArray(teamSchedule.items) && teamSchedule.items.length > 0);
|
||
const visible = state.config.tabs.filter((tab) => {
|
||
if (tab.id === "standings") return Boolean(standings.available && Array.isArray(standings.variants) && standings.variants.length);
|
||
if (tab.id === "schedule") return hasSchedule;
|
||
if (tab.id === "shootout") return Boolean(selectedGame && String(selectedTournament?.stage_key || "").toLowerCase() === "regular");
|
||
return true;
|
||
});
|
||
if (!visible.some((tab) => tab.id === state.activeTab)) state.activeTab = visible[0]?.id || state.config.tabs[0]?.id || "";
|
||
return visible;
|
||
}
|
||
|
||
function tabBarNode(component, runtime) {
|
||
const node = div("ui-tab-bar");
|
||
runtimeVisibleTabs().forEach((tab) => {
|
||
const button = document.createElement("button"); button.type = "button"; button.textContent = tab.label; button.dataset.itemId = tab.id; button.classList.toggle("active", tab.id === state.activeTab); button.disabled = !runtime; button.addEventListener("click", () => { if (runtime) activateRuntimeTab(tab.id, { source: "tab-bar" }); }); node.appendChild(button);
|
||
});
|
||
return node;
|
||
}
|
||
|
||
function modalButtonNode(component, runtime) {
|
||
const button = document.createElement("button"); button.className = "ui-button secondary"; button.type = "button"; button.textContent = component.props.text || component.title; button.disabled = !runtime; button.addEventListener("click", () => { if (!runtime) return; emitInteraction(component, "click"); emitInteraction(component, "open", { value: true }); showModal(component.props.modalTitle, `<p>${escapeHtml(component.props.modalBody || "")}</p>`); }); return button;
|
||
}
|
||
|
||
function renderRuntimeTabs() {
|
||
if (!el.runtimeTabs) return;
|
||
el.runtimeTabs.innerHTML = "";
|
||
runtimeVisibleTabs().forEach((tab) => {
|
||
const button = document.createElement("button"); button.className = `runtime-tab ${tab.id === state.activeTab ? "active" : ""}`; button.type = "button"; button.textContent = tab.label; button.addEventListener("click", () => activateRuntimeTab(tab.id, { source: "runtime-tabs" })); el.runtimeTabs.appendChild(button);
|
||
});
|
||
}
|
||
|
||
function runtimeHasFixedHockeyCanvas() {
|
||
return state.config.components.some((component) =>
|
||
[
|
||
"hockey_penalty_dashboard",
|
||
"hockey_team_statistics",
|
||
"hockey_shootout_control",
|
||
"hockey_schedule",
|
||
"hockey_tournament_standings",
|
||
].includes(component.type) && !component.hidden
|
||
);
|
||
}
|
||
|
||
function runtimeViewportSpace() {
|
||
const viewport = el.runtimeViewport;
|
||
if (!viewport) {
|
||
return { width: window.innerWidth, height: window.innerHeight };
|
||
}
|
||
const style = window.getComputedStyle(viewport);
|
||
const horizontalPadding = (parseFloat(style.paddingLeft) || 0) + (parseFloat(style.paddingRight) || 0);
|
||
const verticalPadding = (parseFloat(style.paddingTop) || 0) + (parseFloat(style.paddingBottom) || 0);
|
||
const playByPlay = viewport.querySelector("#hockeyStandalonePbp");
|
||
const reservedWidth = playByPlay && !playByPlay.classList.contains("is-collapsed")
|
||
? playByPlay.offsetWidth + 14
|
||
: playByPlay
|
||
? playByPlay.offsetWidth + 14
|
||
: 0;
|
||
return {
|
||
width: Math.max(1, viewport.clientWidth - horizontalPadding - reservedWidth),
|
||
height: Math.max(1, viewport.clientHeight - verticalPadding),
|
||
};
|
||
}
|
||
|
||
function updateRuntimeScale() {
|
||
if (!el.runtimeStage || !el.runtimeSizer || (!state.preview && boot.mode !== "runtime")) return;
|
||
const canvasWidth = Math.max(1, Number(state.config.canvas.width) || 1440);
|
||
const canvasHeight = Math.max(1, Number(state.config.canvas.height) || 760);
|
||
const available = runtimeViewportSpace();
|
||
const fixedHockeyCanvas = runtimeHasFixedHockeyCanvas();
|
||
|
||
// The hockey panel is designed on a fixed 1440x760 canvas. It may shrink
|
||
// uniformly on smaller screens and may now grow on larger/F11 viewports.
|
||
// A bounded 150% scale keeps the fixed logical canvas stable while using
|
||
// otherwise empty screen space.
|
||
const maxScale = fixedHockeyCanvas ? 1.6 : 1.8;
|
||
const heightRatio = available.height / canvasHeight;
|
||
const rawScale = Math.min(maxScale, available.width / canvasWidth, heightRatio);
|
||
const scale = Math.max(0.1, Math.floor(rawScale * 10000) / 10000);
|
||
|
||
state.runtimeScale = scale;
|
||
el.runtimeSizer.style.width = `${Math.round(canvasWidth * scale * 1000) / 1000}px`;
|
||
el.runtimeSizer.style.height = `${Math.round(canvasHeight * scale * 1000) / 1000}px`;
|
||
el.runtimeStage.style.width = `${canvasWidth}px`;
|
||
el.runtimeStage.style.height = `${canvasHeight}px`;
|
||
el.runtimeStage.style.transform = `translate3d(0, 0, 0) scale(${scale})`;
|
||
el.runtimeStage.style.background = state.config.canvas.background || "#0c1421";
|
||
el.runtimeStage.dataset.runtimeScale = String(scale);
|
||
}
|
||
|
||
function scheduleRuntimeScale() {
|
||
if (state.runtimeResizeFrame !== null) cancelAnimationFrame(state.runtimeResizeFrame);
|
||
state.runtimeResizeFrame = requestAnimationFrame(() => {
|
||
state.runtimeResizeFrame = null;
|
||
updateRuntimeScale();
|
||
});
|
||
}
|
||
|
||
function renderRuntime() {
|
||
if (!el.runtimeStage || (!state.preview && boot.mode !== "runtime")) return;
|
||
const activeShootoutSearch = document.activeElement?.matches?.("[data-shootout-search]")
|
||
? {
|
||
side: document.activeElement.dataset.shootoutSearch,
|
||
start: document.activeElement.selectionStart,
|
||
end: document.activeElement.selectionEnd,
|
||
}
|
||
: null;
|
||
const shootoutRosterScroll = {};
|
||
el.runtimeStage.querySelectorAll(".hso-roster").forEach((roster) => {
|
||
const side = roster.classList.contains("side-away") ? "away" : "home";
|
||
shootoutRosterScroll[side] = roster.querySelector(".hso-player-list")?.scrollTop || 0;
|
||
});
|
||
const visibleTabs = runtimeVisibleTabs();
|
||
if (!visibleTabs.some((tab) => tab.id === state.activeTab)) state.activeTab = visibleTabs[0]?.id || "main";
|
||
renderRuntimeTabs();
|
||
el.runtimeTitle.textContent = state.config.project_name;
|
||
el.runtimeStage.innerHTML = "";
|
||
state.timerNodes = new Map();
|
||
state.hockeyPenaltyBoardNodes = new Map();
|
||
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();
|
||
renderHockeyQuickCommandDock();
|
||
Object.entries(shootoutRosterScroll).forEach(([side, top]) => {
|
||
const list = el.runtimeStage.querySelector(`.hso-roster.side-${side} .hso-player-list`);
|
||
if (list) list.scrollTop = top;
|
||
});
|
||
if (activeShootoutSearch?.side) {
|
||
const input = el.runtimeStage.querySelector(`[data-shootout-search="${activeShootoutSearch.side}"]`);
|
||
if (input) {
|
||
input.focus({ preventScroll: true });
|
||
try { input.setSelectionRange(activeShootoutSearch.start, activeShootoutSearch.end); } catch (_) {}
|
||
}
|
||
}
|
||
updateRuntimeScale();
|
||
scheduleStyledControls();
|
||
scheduleRuntimeScale();
|
||
}
|
||
|
||
function showPreview() {
|
||
state.preview = true;
|
||
document.querySelectorAll(".editor-only").forEach((node) => node.classList.add("hidden"));
|
||
el.runtimeView.classList.remove("hidden");
|
||
el.closePreviewBtn.classList.remove("hidden");
|
||
renderRuntime();
|
||
}
|
||
|
||
function closePreview() {
|
||
if (boot.mode === "runtime") return;
|
||
state.preview = false;
|
||
document.querySelectorAll(".editor-only").forEach((node) => node.classList.remove("hidden"));
|
||
el.runtimeView.classList.add("hidden");
|
||
renderCanvas();
|
||
}
|
||
|
||
function renderDataPaths() {
|
||
if (el.dataPathList) el.dataPathList.innerHTML = state.dataPaths.map((path) => `<option value="${escapeHtml(path)}"></option>`).join("");
|
||
if (!el.dataPaths) return;
|
||
el.dataPaths.innerHTML = "";
|
||
state.dataPaths.forEach((path) => {
|
||
const button = document.createElement("button"); button.type = "button"; button.textContent = path; button.addEventListener("click", async () => { await navigator.clipboard?.writeText(path); toast(`Путь скопирован: ${path}`); }); el.dataPaths.appendChild(button);
|
||
});
|
||
}
|
||
|
||
async function saveConfig({ silent = false } = {}) {
|
||
state.config.project_name = el.projectName.value.trim() || "Новый интерфейс";
|
||
state.config.data_source = el.dataSource.value;
|
||
try {
|
||
const payload = await api("/config", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(state.config)
|
||
});
|
||
state.config = payload.config;
|
||
ensureConfig();
|
||
if (!silent) toast("Черновик сохранён");
|
||
return true;
|
||
} catch (error) {
|
||
toast(`Ошибка сохранения: ${error.message}`, true);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function publishConfig() {
|
||
if (!(await saveConfig({ silent: true }))) return;
|
||
try {
|
||
const payload = await api("/publish", { method: "POST" });
|
||
toast(`Панель опубликована · ${payload.published_at || "готово"}`);
|
||
} catch (error) {
|
||
toast(`Ошибка публикации: ${error.message}`, true);
|
||
}
|
||
}
|
||
|
||
function editorHotkeyPressed(event) {
|
||
return (
|
||
event.code === "KeyE" &&
|
||
event.ctrlKey &&
|
||
event.shiftKey &&
|
||
!event.altKey &&
|
||
!event.metaKey
|
||
);
|
||
}
|
||
|
||
function formatSessionTime(seconds) {
|
||
const value = Math.max(0, Number(seconds) || 0);
|
||
const minutes = Math.floor(value / 60);
|
||
const rest = value % 60;
|
||
return `${minutes}:${String(rest).padStart(2, "0")}`;
|
||
}
|
||
|
||
async function refreshEditorSessionBadge() {
|
||
if (boot.mode !== "editor" || !el.editorSessionBadge) return;
|
||
try {
|
||
const status = await authApi("/status");
|
||
if (!status.authenticated) {
|
||
window.location.href = boot.runtimeUrl;
|
||
return;
|
||
}
|
||
el.editorSessionBadge.textContent = `Сессия ${formatSessionTime(status.expires_in)}`;
|
||
el.editorSessionBadge.dataset.tooltip = `Конструктор будет заблокирован через ${formatSessionTime(status.expires_in)}`;
|
||
} catch (_) {}
|
||
}
|
||
|
||
function startEditorSessionMonitor() {
|
||
if (boot.mode !== "editor") return;
|
||
clearInterval(state.editorAuthTimer);
|
||
refreshEditorSessionBadge();
|
||
state.editorAuthTimer = window.setInterval(refreshEditorSessionBadge, 1000);
|
||
}
|
||
|
||
async function logoutEditor() {
|
||
try { await authApi("/logout", { method: "POST" }); } catch (_) {}
|
||
window.location.href = boot.runtimeUrl;
|
||
}
|
||
|
||
async function logoutHockeyAccount() {
|
||
if (boot.mode !== "runtime") return;
|
||
if (!window.confirm("Выйти из аккаунта?")) return;
|
||
try {
|
||
const response = await fetch("/logout", {
|
||
method: "POST",
|
||
credentials: "same-origin",
|
||
cache: "no-store",
|
||
redirect: "follow",
|
||
});
|
||
window.location.href = response.url || "/login?reason=logout";
|
||
} catch (_) {
|
||
window.location.href = "/login";
|
||
}
|
||
}
|
||
|
||
function pinDigitBoxes(input) {
|
||
return [...String(input.value || "")].map((digit) => `<span>${escapeHtml(digit)}</span>`).join("");
|
||
}
|
||
|
||
async function openEditorPinDialog() {
|
||
try {
|
||
const status = await authApi("/status");
|
||
if (status.authenticated) {
|
||
window.location.href = boot.editorUrl;
|
||
return;
|
||
}
|
||
|
||
showModal("Вход в конструктор", `
|
||
<form class="editor-pin-dialog" data-editor-pin-form>
|
||
<div class="editor-pin-icon">⌘</div>
|
||
<div class="editor-pin-copy">
|
||
<strong>Защищённый режим конструктора</strong>
|
||
<p>Введите ежедневный четырёхзначный PIN или аварийный мастер-PIN.</p>
|
||
</div>
|
||
<label class="editor-pin-field">
|
||
<span>PIN-код</span>
|
||
<input
|
||
type="password"
|
||
inputmode="numeric"
|
||
autocomplete="one-time-code"
|
||
maxlength="12"
|
||
pattern="[0-9]*"
|
||
data-editor-pin-input
|
||
placeholder="••••"
|
||
>
|
||
</label>
|
||
<div class="editor-pin-preview" data-editor-pin-preview><i></i><i></i><i></i><i></i></div>
|
||
<div class="editor-pin-message" data-editor-pin-message>
|
||
Дата проекта: ${escapeHtml(status.date || "")} · ${escapeHtml(status.timezone || "")}
|
||
</div>
|
||
<div class="editor-pin-actions">
|
||
<button type="button" class="btn" data-close-pin>Отмена</button>
|
||
<button type="submit" class="btn btn-accent">Войти</button>
|
||
</div>
|
||
</form>
|
||
`);
|
||
|
||
const form = el.modalHost.querySelector("[data-editor-pin-form]");
|
||
const input = form.querySelector("[data-editor-pin-input]");
|
||
const preview = form.querySelector("[data-editor-pin-preview]");
|
||
const message = form.querySelector("[data-editor-pin-message]");
|
||
const submit = form.querySelector('button[type="submit"]');
|
||
|
||
const updatePreview = () => {
|
||
input.value = input.value.replace(/\D+/g, "").slice(0, 12);
|
||
const digits = [...input.value.slice(0, 4)];
|
||
preview.querySelectorAll("i").forEach((node, index) => {
|
||
node.classList.toggle("filled", Boolean(digits[index]));
|
||
});
|
||
};
|
||
input.addEventListener("input", updatePreview);
|
||
form.querySelector("[data-close-pin]")?.addEventListener("click", closeModal);
|
||
|
||
form.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
if (input.value.length < 4) {
|
||
message.textContent = "Введите не менее четырёх цифр";
|
||
message.classList.add("error");
|
||
return;
|
||
}
|
||
submit.disabled = true;
|
||
message.textContent = "Проверяем PIN…";
|
||
message.classList.remove("error");
|
||
try {
|
||
await authApi("/login", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ pin: input.value })
|
||
});
|
||
message.textContent = "Доступ разрешён";
|
||
form.classList.add("success");
|
||
setTimeout(() => { window.location.href = boot.editorUrl; }, 300);
|
||
} catch (error) {
|
||
message.textContent = error.message;
|
||
message.classList.add("error");
|
||
input.select();
|
||
submit.disabled = false;
|
||
}
|
||
});
|
||
|
||
setTimeout(() => input.focus(), 50);
|
||
} catch (error) {
|
||
toast(`Ошибка входа: ${error.message}`, true);
|
||
}
|
||
}
|
||
|
||
function exportConfig() {
|
||
const blob = new Blob([JSON.stringify(state.config, null, 2)], { type: "application/json" });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement("a"); link.href = url; link.download = "ui_builder.json"; link.click(); URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function importConfig(file) {
|
||
const reader = new FileReader();
|
||
reader.onload = async () => {
|
||
try {
|
||
const parsed = JSON.parse(reader.result);
|
||
const payload = await api("/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(parsed) });
|
||
state.config = payload.config;
|
||
ensureConfig();
|
||
state.activeTab = state.config.tabs[0]?.id || "main";
|
||
state.selectedId = null;
|
||
syncTopControls();
|
||
await loadData();
|
||
toast("Конфигурация импортирована");
|
||
} catch (error) { toast(`Ошибка импорта: ${error.message}`, true); }
|
||
};
|
||
reader.readAsText(file, "utf-8");
|
||
}
|
||
|
||
function interactiveOptions(selected = "") {
|
||
const items = state.config.components;
|
||
return `<option value="">— выберите элемент —</option>${items.map((item) => `<option value="${escapeHtml(item.action_id)}" ${item.action_id === selected ? "selected" : ""}>${escapeHtml(item.title)} · ${escapeHtml(item.action_id)}</option>`).join("")}`;
|
||
}
|
||
|
||
function triggerSourceOptions(selected = "") {
|
||
const virtualTabs = `<option value="${PROJECT_TABS_ACTION_ID}" ${selected === PROJECT_TABS_ACTION_ID ? "selected" : ""}>Вкладки проекта · ${PROJECT_TABS_ACTION_ID}</option>`;
|
||
const virtualNavigation = `<option value="${UI_NAVIGATION_ACTION_ID}" ${selected === UI_NAVIGATION_ACTION_ID ? "selected" : ""}>Все вкладки и внутренние статусы · ${UI_NAVIGATION_ACTION_ID}</option>`;
|
||
return `<option value="">— выберите источник —</option>${virtualTabs}${virtualNavigation}${(state.config.components || []).map((item) => `<option value="${escapeHtml(item.action_id)}" ${item.action_id === selected ? "selected" : ""}>${escapeHtml(item.title)} · ${escapeHtml(item.action_id)}</option>`).join("")}`;
|
||
}
|
||
|
||
function triggerUsesTabItem(trigger = {}) {
|
||
return String(trigger.source_action_id || "") === PROJECT_TABS_ACTION_ID
|
||
|| TAB_TRIGGER_EVENTS.filter((eventName) => eventName !== "tab_change").includes(String(trigger.event || ""));
|
||
}
|
||
|
||
function triggerTabItemOptions(selected = "") {
|
||
const tabs = Array.isArray(state.config.tabs) ? state.config.tabs : [];
|
||
const selectedId = String(selected || "");
|
||
const knownIds = new Set(tabs.map((tab) => String(tab?.id || "")));
|
||
const legacyOption = selectedId && !knownIds.has(selectedId)
|
||
? `<option value="${escapeHtml(selectedId)}" selected>⚠ Сохранённое значение · ${escapeHtml(selectedId)}</option>`
|
||
: "";
|
||
return `<option value="">— любая вкладка —</option>${legacyOption}${tabs.map((tab) => {
|
||
const id = String(tab?.id || "");
|
||
const label = String(tab?.label || id || "Вкладка");
|
||
return `<option value="${escapeHtml(id)}" ${id === selectedId ? "selected" : ""}>${escapeHtml(label)} · ${escapeHtml(id)}</option>`;
|
||
}).join("")}`;
|
||
}
|
||
|
||
function uiNavigationCatalog() {
|
||
const items = [];
|
||
const seen = new Set();
|
||
const add = (actionId, title, scope, scopeLabel, values = []) => {
|
||
const action = String(actionId || "").trim();
|
||
const stateScope = String(scope || "view").trim();
|
||
if (!action || !stateScope) return;
|
||
values.forEach((entry) => {
|
||
const value = String(Array.isArray(entry) ? entry[0] : entry ?? "");
|
||
const label = String(Array.isArray(entry) ? (entry[1] ?? entry[0]) : entry ?? value);
|
||
if (!value) return;
|
||
const itemId = uiNavigationItemId(action, stateScope, value);
|
||
if (seen.has(itemId)) return;
|
||
seen.add(itemId);
|
||
items.push({
|
||
item_id: itemId,
|
||
source_action_id: action,
|
||
source_title: String(title || action),
|
||
scope: stateScope,
|
||
scope_label: String(scopeLabel || stateScope),
|
||
value,
|
||
label,
|
||
path: `${String(title || action)} → ${String(scopeLabel || stateScope)} → ${label}`,
|
||
});
|
||
});
|
||
};
|
||
const uniqueRows = (rows = []) => {
|
||
const result = [];
|
||
const ids = new Set();
|
||
rows.forEach((row) => {
|
||
const value = String(Array.isArray(row) ? row[0] : row ?? "");
|
||
if (!value || ids.has(value)) return;
|
||
ids.add(value);
|
||
result.push(Array.isArray(row) ? row : [value, value]);
|
||
});
|
||
return result;
|
||
};
|
||
const segmentRows = (values = [], includeTotal = false) => uniqueRows([
|
||
...(includeTotal ? [["total", "Весь матч"]] : [["all", "Все"]]),
|
||
...values.map((value) => [String(value), hockeyStatisticsSegmentLabel(String(value), "ru")]),
|
||
...["1", "2", "3", "4", "5"].map((value) => [value, hockeyStatisticsSegmentLabel(value, "ru")]),
|
||
]);
|
||
|
||
add(PROJECT_TABS_ACTION_ID, "Вкладки проекта", "project_tab", "Раздел", (state.config.tabs || []).map((tab) => [String(tab.id || ""), String(tab.label || tab.id || "Вкладка")]));
|
||
|
||
const pbpEvents = getByPath(state.data, "hockey.selected_game.events") || {};
|
||
add("hockey_play_by_play", "Игра → Play-by-play", "period", "Период", segmentRows(Array.isArray(pbpEvents.segments) ? pbpEvents.segments.map(String).filter((value) => value !== "0") : [], false));
|
||
|
||
(state.config.components || []).forEach((component) => {
|
||
const title = String(component.title || component.action_id || component.type || "Компонент");
|
||
const props = component.props || {};
|
||
if (component.type === "hockey_team_statistics") {
|
||
const stats = getByPath(state.data, props.statsPath || "hockey.selected_game.team_statistics") || {};
|
||
const events = getByPath(state.data, props.eventsPath || "hockey.selected_game.events") || {};
|
||
const shots = getByPath(state.data, props.shotsMapPath || "hockey.selected_game.shots_map") || {};
|
||
const powerplay = getByPath(state.data, props.powerplayStatsPath || "hockey.tournament_statistics.powerplay") || {};
|
||
const rank = getByPath(state.data, props.rankStatsPath || "hockey.tournament_statistics.rank") || {};
|
||
add(component.action_id, title, "view", "Раздел статистики", [
|
||
["events", "События"], ["team", "Командная"], ["players", "Игроки"],
|
||
["season", "Сезон"], ["powerplay", "Большинство"], ["rank", "Рейтинг"],
|
||
]);
|
||
add(component.action_id, title, "team_mode", "Командная → Вид", [["metrics", "Показатели"], ["shots-map", "Карта бросков"]]);
|
||
add(component.action_id, title, "team_segment", "Командная → Период", segmentRows(Array.isArray(stats.segments) ? stats.segments.map(String).filter((value) => value !== "total") : [], true));
|
||
add(component.action_id, title, "events_period", "События → Период", segmentRows(Array.isArray(events.segments) ? events.segments.map(String).filter((value) => value !== "0") : [], false));
|
||
add(component.action_id, title, "events_type", "События → Тип", [
|
||
["all", "Все"], ["goal", "Голы"], ["penalty", "Удаления"], ["shot", "Броски"],
|
||
["shootout", "Буллиты"], ["period", "Периоды"], ["timeout", "Тайм-ауты"],
|
||
["goalie", "Вратари"], ["comment", "Комментарии"], ["info", "Прочее"],
|
||
]);
|
||
add(component.action_id, title, "players_filter", "Игроки → Команда", [["all", "Все"], ["home", "Хозяева"], ["away", "Гости"]]);
|
||
add(component.action_id, title, "season_filter", "Сезон → Команда", [["all", "Все"], ["home", "Хозяева"], ["away", "Гости"]]);
|
||
add(component.action_id, title, "shots_period", "Карта бросков → Период", segmentRows(Array.isArray(shots.segments) ? shots.segments.map(String) : [], false));
|
||
add(component.action_id, title, "shots_side", "Карта бросков → Команда", [["all", "Обе команды"], ["home", "Хозяева"], ["away", "Гости"]]);
|
||
add(component.action_id, title, "powerplay_section", "Большинство → Раздел", (Array.isArray(powerplay.sections) ? powerplay.sections : []).map((item) => [String(item.id || ""), String(item.label || item.id || "Раздел")]));
|
||
add(component.action_id, title, "rank_section", "Рейтинг → Раздел", (Array.isArray(rank.sections) ? rank.sections : []).map((item) => [String(item.id || ""), String(item.label || item.id || "Раздел")]));
|
||
} else if (component.type === "hockey_schedule") {
|
||
add(component.action_id, title, "schedule_view", "Вид", [["day", "Матчи дня"], ["teams", "Матчи команд"]]);
|
||
add(component.action_id, title, "schedule_status", "Матчи команд → Статус", [["all", "Все"], ["finished", "Завершённые"], ["upcoming", "Предстоящие"], ["live", "Live"]]);
|
||
add(component.action_id, title, "schedule_venue", "Матчи команд → Площадка", [["all", "Дома и в гостях"], ["home", "Только дома"], ["away", "Только в гостях"], ["h2h", "Только очные"]]);
|
||
add(component.action_id, title, "schedule_sort", "Матчи команд → Сортировка", [["closest", "Сначала ближайшие"], ["asc", "Дата по возрастанию"], ["desc", "Дата по убыванию"]]);
|
||
} else if (component.type === "hockey_tournament_standings") {
|
||
const standings = getByPath(state.data, props.standingsPath || "hockey.standings") || {};
|
||
const variants = Array.isArray(standings.variants) ? standings.variants : [];
|
||
add(component.action_id, title, "standings_view", "Вид таблицы", uniqueRows([
|
||
...variants.map((item) => [String(item.id || ""), String(item.label || item.id || "Вид")]),
|
||
["league", "Лига"], ["conference", "Конференции"], ["division", "Дивизионы"],
|
||
]));
|
||
} else if (component.type === "hockey_player_statistics") {
|
||
add(component.action_id, title, "players_filter", "Команда", [["all", "Все"], ["home", "Хозяева"], ["away", "Гости"]]);
|
||
} else if (component.type === "hockey_shootout_control") {
|
||
add(component.action_id, title, "shootout_size", "Основная серия", [["3", "3 буллита"], ["5", "5 буллитов"]]);
|
||
} else if (component.type === "tab_bar") {
|
||
add(component.action_id, title, "project_tab", "Вкладка", (state.config.tabs || []).map((tab) => [String(tab.id || ""), String(tab.label || tab.id || "Вкладка")]));
|
||
}
|
||
});
|
||
return items;
|
||
}
|
||
|
||
function triggerUsesNavigationItem(trigger = {}) {
|
||
const source = String(trigger.source_action_id || "");
|
||
if (source === UI_NAVIGATION_ACTION_ID) return true;
|
||
if (String(trigger.event || "") !== "tab_change" || !source || source === PROJECT_TABS_ACTION_ID) return false;
|
||
return uiNavigationCatalog().some((item) => item.source_action_id === source);
|
||
}
|
||
|
||
function triggerNavigationItemOptions(selected = "", sourceActionId = UI_NAVIGATION_ACTION_ID) {
|
||
const selectedId = String(selected || "");
|
||
const allItems = uiNavigationCatalog();
|
||
const virtual = sourceActionId === UI_NAVIGATION_ACTION_ID;
|
||
const rows = virtual ? allItems : allItems.filter((item) => item.source_action_id === sourceActionId);
|
||
const optionValue = (item) => virtual ? item.item_id : `${item.scope}::${item.value}`;
|
||
const knownValues = new Set(rows.map(optionValue));
|
||
const legacyOption = selectedId && !knownValues.has(selectedId)
|
||
? `<option value="${escapeHtml(selectedId)}" selected>⚠ Сохранённое значение · ${escapeHtml(selectedId)}</option>`
|
||
: "";
|
||
const grouped = new Map();
|
||
rows.forEach((item) => {
|
||
const group = virtual ? item.source_title : item.scope_label;
|
||
if (!grouped.has(group)) grouped.set(group, []);
|
||
grouped.get(group).push(item);
|
||
});
|
||
const groupsHtml = [...grouped.entries()].map(([group, groupItems]) => `<optgroup label="${escapeHtml(group)}">${groupItems.map((item) => {
|
||
const value = optionValue(item);
|
||
const label = virtual ? item.path : `${item.scope_label} → ${item.label}`;
|
||
return `<option value="${escapeHtml(value)}" ${value === selectedId ? "selected" : ""}>${escapeHtml(label)}</option>`;
|
||
}).join("")}</optgroup>`).join("");
|
||
return `<option value="">— любое внутреннее состояние —</option>${legacyOption}${groupsHtml}`;
|
||
}
|
||
|
||
function triggerItemIdFieldHtml(trigger = {}) {
|
||
const source = String(trigger.source_action_id || "");
|
||
if (triggerUsesNavigationItem(trigger)) {
|
||
const help = source === UI_NAVIGATION_ACTION_ID
|
||
? "Здесь собраны верхние вкладки и все поддерживаемые внутренние вкладки/фильтры. Для дополнительных условий текущие значения доступны в context.navigation, например navigation.hockey_team_statistics.team_segment."
|
||
: "Выберите внутреннюю вкладку или статус этого блока. Значение сохраняется как стабильный scope::value.";
|
||
return `<label>Вкладка / статус<select data-path="item_id">${triggerNavigationItemOptions(trigger.item_id, source || UI_NAVIGATION_ACTION_ID)}</select><small>${escapeHtml(help)}</small></label>`;
|
||
}
|
||
if (triggerUsesTabItem(trigger)) {
|
||
return `<label>Вкладка<select data-path="item_id">${triggerTabItemOptions(trigger.item_id)}</select><small>Выберите верхнюю вкладку проекта. «Любая вкладка» сработает для всех вкладок.</small></label>`;
|
||
}
|
||
return `<label>item_id <input type="text" data-path="item_id" value="${escapeHtml(trigger.item_id)}" placeholder="start, stop, main..."></label>`;
|
||
}
|
||
|
||
function createTrigger(sourceActionId = "") {
|
||
return normalizeTrigger({
|
||
id: uid(), name: "Новый триггер", enabled: true, source_action_id: sourceActionId,
|
||
event: "click", item_id: "", condition: { field: "", operator: "equals", value: "" },
|
||
action: { type: "show_message", target_action_id: "", state_key: "active", value: "true", message: "Триггер выполнен", event_name: "ui-builder:custom", method: "POST", sequence_id: "" },
|
||
}, state.config.triggers.length);
|
||
}
|
||
|
||
function timerActionOptions(selected = "") {
|
||
const items = state.config.components.filter((item) => isTimerComponent(item));
|
||
return `<option value="">— выберите таймер —</option>${items.map((item) => `<option value="${escapeHtml(item.action_id)}" ${item.action_id === selected ? "selected" : ""}>${escapeHtml(item.title)} · ${escapeHtml(item.action_id)}</option>`).join("")}`;
|
||
}
|
||
|
||
function hockeyPenaltyBoardOptions(selected = "") {
|
||
const items = state.config.components.filter((item) => item.type === "hockey_penalty_dashboard");
|
||
return `<option value="">— все дашборды удалений —</option>${items.map((item) => `<option value="${escapeHtml(item.action_id)}" ${item.action_id === selected ? "selected" : ""}>${escapeHtml(item.title)} · ${escapeHtml(item.action_id)}</option>`).join("")}`;
|
||
}
|
||
|
||
function shortcutSequenceOptions(selected = "") {
|
||
const items = state.config.shortcut_sequences || [];
|
||
return `<option value="">— выберите сценарий —</option>${items.map((item) => `<option value="${escapeHtml(item.id)}" ${item.id === selected ? "selected" : ""}>${escapeHtml(item.combo || "—")} · ${escapeHtml(item.name)}</option>`).join("")}`;
|
||
}
|
||
|
||
const VMIX_FUNCTION_BASE = [
|
||
// Transitions available as API Functions in addition to the reference list.
|
||
"Cut","Fade","Zoom","Wipe","Slide","Fly","CrossZoom","FlyRotate","Cube","CubeZoom","VerticalWipe","VerticalSlide","Merge","WipeReverse","SlideReverse","VerticalWipeReverse","VerticalSlideReverse",
|
||
// General
|
||
"ActivatorRefresh","CallManagerShowHide","KeyPress","SendKeys","SetDynamicValue1","SetDynamicValue2","SetDynamicValue3","SetDynamicValue4","SetDynamicInput1","SetDynamicInput2","SetDynamicInput3","SetDynamicInput4","Undo",
|
||
// Audio
|
||
"Audio","AudioAuto","AudioAutoOff","AudioAutoOn","AudioBus","AudioBusOff","AudioBusOn","AudioChannelMatrixApplyPreset","AudioMixerShowHide","AudioOff","AudioOn","AudioPluginOff","AudioPluginOn","AudioPluginOnOff","AudioPluginShow",
|
||
"BusAAudio","BusAAudioOff","BusAAudioOn","BusAAudioPluginOff","BusAAudioPluginOn","BusAAudioPluginOnOff","BusAAudioPluginShow",
|
||
"BusBAudio","BusBAudioOff","BusBAudioOn","BusBAudioPluginOff","BusBAudioPluginOn","BusBAudioPluginOnOff","BusBAudioPluginShow",
|
||
"BusXAudio","BusXAudioOff","BusXAudioOn","BusXAudioPluginOff","BusXAudioPluginOn","BusXAudioPluginOnOff","BusXAudioPluginShow","BusXSendToMaster","BusXSendToMasterOff","BusXSendToMasterOn","BusXSolo","BusXSoloOff","BusXSoloOn",
|
||
"MasterAudio","MasterAudioOff","MasterAudioOn","MasterAudioPluginOff","MasterAudioPluginOn","MasterAudioPluginOnOff","MasterAudioPluginShow","SetBalance","SetBusAVolume","SetBusAVolumeFade","SetBusBVolume","SetBusBVolumeFade","SetBusCVolume","SetBusCVolumeFade","SetBusDVolume","SetBusDVolumeFade","SetBusEVolume","SetBusEVolumeFade","SetBusFVolume","SetBusFVolumeFade","SetBusGVolume","SetBusGVolumeFade","SetGain","SetGainChannel1","SetGainChannel2","SetHeadphonesVolume","SetMasterVolume","SetMasterVolumeFade","SetVolume","SetVolumeBusMixer","SetVolumeBusMixerA","SetVolumeBusMixerB","SetVolumeBusMixerC","SetVolumeBusMixerD","SetVolumeBusMixerE","SetVolumeBusMixerF","SetVolumeBusMixerG","SetVolumeBusMixerM","SetVolumeChannel1","SetVolumeChannel2","SetVolumeChannelMixer","SetVolumeFade","Solo","SoloAllOff","SoloOff","SoloOn","SoloPFL","SoloPFLOff","SoloPFLOn",
|
||
// Transition / output
|
||
"CutDirect","FadeToBlack","QuickPlay","SetFader","SetStingerGTInput1","SetStingerGTInput2","SetStingerGTInput3","SetStingerGTInput4","SetStingerGTInput5","SetStingerGTInput6","SetStingerGTInput7","SetStingerGTInput8","SetTransitionDuration1","SetTransitionDuration2","SetTransitionDuration3","SetTransitionDuration4","SetTransitionEffect1","SetTransitionEffect2","SetTransitionEffect3","SetTransitionEffect4","Stinger1","Stinger2","Stinger3","Stinger4","Stinger5","Stinger6","Stinger7","Stinger8","Transition1","Transition2","Transition3","Transition4",
|
||
"Fullscreen","FullscreenOff","FullscreenOn","SetOutput2","SetOutput3","SetOutput4","SetOutputExternal2","SetOutputFullscreen","SetOutputFullscreen2","Snapshot","SnapshotInput","StartExternal","StartMultiCorder","StartRecording","StartSRTOutput","StartStopExternal","StartStopMultiCorder","StartStopRecording","StartStopSRTOutput","StartStopStreaming","StartStreaming","StopExternal","StopMultiCorder","StopRecording","StopSRTOutput","StopStreaming","StreamingSetKey","StreamingSetPassword","StreamingSetURL","StreamingSetUsername","WriteDurationToRecordingLog",
|
||
// Title / GT
|
||
"AdjustCountdown","ChangeCountdown","NextTitlePreset","PauseCountdown","PauseRender","PreviousTitlePreset","ResumeRender","SelectTitlePreset","SetColor","SetCountdown","SetImage","SetImageVisible","SetImageVisibleOff","SetImageVisibleOn","SetText","SetTextColour","SetTextVisible","SetTextVisibleOff","SetTextVisibleOn","SetTickerSpeed","StartCountdown","StopCountdown","SuspendCountdown","TitleBeginAnimation",
|
||
// Input / playback
|
||
"ActiveInput","AddInput","AutoPauseOff","AutoPauseOn","AutoPlayFirst","AutoPlayFirstOff","AutoPlayFirstOn","AutoPlayNext","AutoPlayNextOff","AutoPlayNextOn","AutoPlayOff","AutoPlayOn","AutoRestartOff","AutoRestartOn","ColourCorrectionAuto","ColourCorrectionReset","CreateVirtualInput","DeinterlaceOff","DeinterlaceOn","Effect1","Effect1Off","Effect1On","Effect2","Effect2Off","Effect2On","Effect3","Effect3Off","Effect3On","Effect4","Effect4Off","Effect4On","GO","InputPreviewHide","InputPreviewShow","InputPreviewShowHide","LayerOff","LayerOn","LayerOnOff","ListAdd","ListExport","ListPlayOut","ListRemove","ListRemoveAll","ListShowHide","ListShuffle","LivePlayPause","Loop","LoopOff","LoopOn","MarkIn","MarkOut","MarkReset","MarkResetIn","MarkResetOut","MirrorOff","MirrorOn","MoveInput","MoveLayer","NextItem","NextPicture","Pause","Play","PlayPause","PreviewInput","PreviewInputNext","PreviewInputPrevious","PreviousItem","PreviousPicture","RemoveInput","ResetInput","Restart","SaveVideoDelay","SelectCategory","SelectIndex","SetAlpha","SetCrop","SetCropX1","SetCropX2","SetCropY1","SetCropY2","SetLayer","SetLayerAnimated","SetLayerDynamicCrop","SetLayerDynamicCropX1","SetLayerDynamicCropX2","SetLayerDynamicCropY1","SetLayerDynamicCropY2","SetLayerDynamicHeight","SetLayerDynamicPanX","SetLayerDynamicPanY","SetLayerDynamicRectangle","SetLayerDynamicWidth","SetLayerDynamicX","SetLayerDynamicY","SetLayerDynamicZoom","SetPanX","SetPanY","SetPictureEffect","SetPictureEffectDuration","SetPictureTransition","SetPosition","SetRate","SetRateSlowMotion","SetZoom","SharpenOff","SharpenOn","SwapLayerAnimated",
|
||
// Colour correction
|
||
"SetCCGainB","SetCCGainG","SetCCGainR","SetCCGainRGB","SetCCGainY","SetCCGammaB","SetCCGammaG","SetCCGammaR","SetCCGammaRGB","SetCCGammaY","SetCCHue","SetCCLiftB","SetCCLiftG","SetCCLiftR","SetCCLiftRGB","SetCCLiftY","SetCCSaturation",
|
||
// Overlay
|
||
"OverlayInput1","OverlayInput1In","OverlayInput1Out","OverlayInput2","OverlayInput2In","OverlayInput2Out","OverlayInput3","OverlayInput3In","OverlayInput3Out","OverlayInput4","OverlayInput4In","OverlayInput4Out",
|
||
// Data sources / browser / scripts
|
||
"DataSourceAutoNext","DataSourceAutoNextOff","DataSourceAutoNextOn","DataSourceNextRow","DataSourcePreviousRow","DataSourceSelectRow","BrowserBack","BrowserForward","BrowserHome","BrowserNavigate","BrowserReload","BrowserSetURL","BrowserExecuteJavascript","ScriptStart","ScriptStop","ScriptStartDynamic",
|
||
// NDI / OMT
|
||
"NDICommand","NDISelectSourceByIndex","NDISelectSourceByName","NDIStartRecording","NDIStopRecording","OMTSelectSourceByIndex","OMTSelectSourceByName",
|
||
// PTZ common functions
|
||
"PTZHome","PTZMoveDown","PTZMoveLeft","PTZMoveRight","PTZMoveStop","PTZMoveUp","PTZFocusFar","PTZFocusNear","PTZFocusStop","PTZPresetRecall","PTZPresetStore","PTZUpdateVirtualInput","PTZZoomIn","PTZZoomOut","PTZZoomStop",
|
||
// Replay core
|
||
"ReplayACamera1","ReplayACamera2","ReplayACamera3","ReplayACamera4","ReplayACamera5","ReplayACamera6","ReplayACamera7","ReplayACamera8","ReplayBCamera1","ReplayBCamera2","ReplayBCamera3","ReplayBCamera4","ReplayBCamera5","ReplayBCamera6","ReplayBCamera7","ReplayBCamera8","ReplayChangeDirection","ReplayFastForward","ReplayFastForwardOff","ReplayFastForwardOn","ReplayJumpFrames","ReplayJumpToNow","ReplayLive","ReplayMarkCancel","ReplayMarkIn","ReplayMarkInOut","ReplayMarkOut","ReplayMoveLastEvent","ReplayPause","ReplayPlay","ReplayPlayAllEvents","ReplayPlayAllEventsToOutput","ReplayPlayEventsByID","ReplayPlayEventsByIDToOutput","ReplayPlayEventToOutput","ReplayPlayForward","ReplayPlayLastEvent","ReplayPlayLastEventToOutput","ReplayPlayNext","ReplayPlayPause","ReplayPlayPrevious","ReplayPlaySelectedEvent","ReplayPlaySelectedEventToOutput","ReplayQuadModeOff","ReplayQuadModeOn","ReplayRecorded","ReplayScrollSelectedEvent","ReplaySelectAllEvents","ReplaySelectChannelA","ReplaySelectChannelAB","ReplaySelectChannelB","ReplaySelectFirstEvent","ReplaySelectLastEvent","ReplaySelectNextEvent","ReplaySelectPreviousEvent","ReplaySetAudioSource","ReplaySetChannelAToBTimecode","ReplaySetChannelAToBTimecodeAndCamera","ReplaySetChannelBToATimecode","ReplaySetChannelBToATimecodeAndCamera","ReplaySetDirectionBackward","ReplaySetDirectionForward","ReplaySetLastEventText","ReplaySetLastEventTextCamera","ReplaySetSelectedEventText","ReplaySetSelectedEventTextCamera","ReplaySetSpeed","ReplaySetTimecode","ReplayShowHide","ReplayStartRecording","ReplayStartStopRecording","ReplayStopEvents","ReplayStopRecording","ReplaySwapChannels","ReplayToggleQuadMode","ReplayUpdateSelectedInPoint","ReplayUpdateSelectedOutPoint","ReplayUpdateSelectedSpeed","ReplayUpdateSelectedSpeedDefault","ReplayUpdateSelectedSpeedFromValue"
|
||
];
|
||
|
||
function vmixFunctionCatalog() {
|
||
const items = new Set(VMIX_FUNCTION_BASE);
|
||
for (let channel = 1; channel <= 16; channel += 1) items.add(`SetVolumeChannelMixer${channel}`);
|
||
for (let layer = 1; layer <= 10; layer += 1) {
|
||
["Crop","CropX1","CropX2","CropY1","CropY2","Height","PanX","PanY","Rectangle","Width","X","Y","Zoom"].forEach((suffix) => items.add(`SetLayer${layer}${suffix}`));
|
||
}
|
||
for (let number = 1; number <= 20; number += 1) items.add(`ReplaySelectEvents${number}`);
|
||
for (let camera = 1; camera <= 8; camera += 1) {
|
||
items.add(`ReplaySelectedEventCameraOff`);
|
||
items.add(`ReplaySelectedEventCameraOn`);
|
||
items.add(`ReplaySelectedEventSingleCameraOn`);
|
||
items.add(`ReplayToggleLastEventCamera${camera}`);
|
||
items.add(`ReplayToggleSelectedEventCamera${camera}`);
|
||
}
|
||
return [...items].sort((a, b) => a.localeCompare(b, "en", { numeric: true, sensitivity: "base" }));
|
||
}
|
||
|
||
const VMIX_FUNCTIONS = vmixFunctionCatalog();
|
||
|
||
function vmixInventoryInputs() {
|
||
const inputs = Array.isArray(state.shortcutInventory?.inventory?.inputs) ? [...state.shortcutInventory.inventory.inputs] : [];
|
||
const number = (item) => {
|
||
const match = String(item?.number ?? "").match(/-?\d+/);
|
||
return match ? Number(match[0]) : Number.MAX_SAFE_INTEGER;
|
||
};
|
||
return inputs.sort((a, b) => number(a) - number(b) || String(a?.title || a?.key || "").localeCompare(String(b?.title || b?.key || ""), "ru", { numeric: true, sensitivity: "base" }));
|
||
}
|
||
|
||
function vmixInputRef(item) {
|
||
// Stable vMix identity: key is immutable when an Input is moved/reordered.
|
||
// Title is the fallback for old/third-party inventories without key. Number
|
||
// is used only as a last-resort legacy reference.
|
||
return String(item?.key || item?.title || item?.number || "").trim();
|
||
}
|
||
|
||
function vmixInputByRef(ref) {
|
||
const wanted = String(ref || "").trim();
|
||
if (!wanted) return null;
|
||
return vmixInventoryInputs().find((item) => [item.number, item.key, item.title].some((value) => String(value || "").trim() === wanted)) || null;
|
||
}
|
||
|
||
function vmixFunctionOptions(selected = "") {
|
||
const current = String(selected || "").trim();
|
||
const known = VMIX_FUNCTIONS.includes(current);
|
||
return `<option value="">— выберите Function —</option>${VMIX_FUNCTIONS.map((name) => `<option value="${escapeHtml(name)}" ${name === current ? "selected" : ""}>${escapeHtml(name)}</option>`).join("")}<option value="__custom__" ${current && !known ? "selected" : ""}>✎ Другая функция…</option>`;
|
||
}
|
||
|
||
function vmixInputOptions(selected = "") {
|
||
const current = String(selected || "").trim();
|
||
const inputs = vmixInventoryInputs();
|
||
const values = new Set(inputs.map(vmixInputRef));
|
||
const fixed = [
|
||
["", "— Input не требуется —"],
|
||
["-1", "-1 · Active"],
|
||
["0", "0 · Preview"],
|
||
["Dynamic1", "Dynamic1"], ["Dynamic2", "Dynamic2"], ["Dynamic3", "Dynamic3"], ["Dynamic4", "Dynamic4"],
|
||
];
|
||
const fixedValues = new Set(fixed.map(([value]) => value));
|
||
const legacyIsNumber = /^\d+$/.test(current);
|
||
const legacy = current && !values.has(current) && !fixedValues.has(current) ? `<option value="${escapeHtml(current)}" selected>${escapeHtml(current)} · ${legacyIsNumber ? "⚠ старый номер Input — выберите Input заново один раз" : "сохранённое значение"}</option>` : "";
|
||
return `${fixed.map(([value, label]) => `<option value="${escapeHtml(value)}" ${value === current ? "selected" : ""}>${escapeHtml(label)}</option>`).join("")}${legacy}${inputs.map((item) => {
|
||
const value = vmixInputRef(item);
|
||
const number = String(item.number || "").trim();
|
||
const title = String(item.title || item.key || "Без названия").trim();
|
||
const type = String(item.type || "").trim();
|
||
const label = `${number ? `#${number} · ` : ""}${title}${type ? ` · ${type}` : ""}`;
|
||
return `<option value="${escapeHtml(value)}" ${value === current ? "selected" : ""}>${escapeHtml(label)}</option>`;
|
||
}).join("")}`;
|
||
}
|
||
|
||
function vmixSelectedNameOptions(inputRef, selected = "") {
|
||
const current = String(selected || "").trim();
|
||
const input = vmixInputByRef(inputRef);
|
||
const fields = Array.isArray(input?.fields) ? input.fields : [];
|
||
const names = new Set(fields.map((field) => String(field?.name || "").trim()).filter(Boolean));
|
||
const legacy = current && !names.has(current) ? `<option value="${escapeHtml(current)}" selected>${escapeHtml(current)} · сохранённое значение</option>` : "";
|
||
return `<option value="">— SelectedName не требуется —</option>${legacy}${fields.map((field) => {
|
||
const name = String(field?.name || "").trim();
|
||
const type = String(field?.type || "").trim();
|
||
return `<option value="${escapeHtml(name)}" ${name === current ? "selected" : ""}>${escapeHtml(name)}${type ? ` · ${escapeHtml(type)}` : ""}</option>`;
|
||
}).join("")}`;
|
||
}
|
||
|
||
|
||
function vmixTextSelectedNameOptions(inputRef, selected = "") {
|
||
const current = String(selected || "").trim();
|
||
const input = vmixInputByRef(inputRef);
|
||
const fields = (Array.isArray(input?.fields) ? input.fields : []).filter((field) => {
|
||
const name = String(field?.name || "").trim();
|
||
const type = String(field?.type || field?.kind || "").trim().toLowerCase();
|
||
return type === "text" || name.toLowerCase().endsWith(".text");
|
||
});
|
||
const names = new Set(fields.map((field) => String(field?.name || "").trim()).filter(Boolean));
|
||
const legacy = current && !names.has(current) ? `<option value="${escapeHtml(current)}" selected>${escapeHtml(current)} · сохранённое значение</option>` : "";
|
||
return `<option value="">— выберите Text / SelectedName —</option>${legacy}${fields.map((field) => {
|
||
const name = String(field?.name || "").trim();
|
||
return `<option value="${escapeHtml(name)}" ${name === current ? "selected" : ""}>${escapeHtml(name)}</option>`;
|
||
}).join("")}`;
|
||
}
|
||
|
||
function shortcutInventoryLabel() {
|
||
if (state.shortcutInventoryLoading) return "vMix: обновление…";
|
||
const name = String(state.shortcutInventory?.device_name || "").trim();
|
||
const inputs = vmixInventoryInputs().length;
|
||
if (!name) return "vMix inventory не найден";
|
||
return `${state.shortcutInventory?.online ? "●" : "○"} ${name} · ${inputs} Inputs`;
|
||
}
|
||
|
||
async function loadShortcutInventory({ silent = false } = {}) {
|
||
if (boot.mode !== "editor") return state.shortcutInventory;
|
||
state.shortcutInventoryLoading = true;
|
||
try {
|
||
const payload = await api("/vmix-inventory");
|
||
state.shortcutInventory = payload && typeof payload === "object" ? payload : state.shortcutInventory;
|
||
if (!silent && !vmixInventoryInputs().length) toast("Agent пока не передал структуру текущего vMix-проекта", true);
|
||
} catch (error) {
|
||
if (!silent) toast(`Не удалось получить vMix Inputs: ${error.message}`, true);
|
||
} finally {
|
||
state.shortcutInventoryLoading = false;
|
||
}
|
||
return state.shortcutInventory;
|
||
}
|
||
|
||
function createSequenceStep(type = "vmix_command") {
|
||
return normalizeSequenceStep({ id: uid(), type, enabled: true, condition: "always" }, 0);
|
||
}
|
||
|
||
function createShortcutSequence() {
|
||
return normalizeShortcutSequence({
|
||
id: uid(),
|
||
name: "Новый шорткат",
|
||
description: "",
|
||
enabled: true,
|
||
combo: "",
|
||
prevent_default: true,
|
||
allow_in_inputs: false,
|
||
scope: "runtime",
|
||
steps: [],
|
||
}, (state.config.shortcut_sequences || []).length);
|
||
}
|
||
|
||
function sequenceStepTitle(step) {
|
||
switch (step.type) {
|
||
case "timer_command": return `Веб-таймер · ${step.timer_command || "start"}`;
|
||
case "hockey_penalties_command": return `Удаления в вебе · ${step.penalty_command || "start"}`;
|
||
case "vmix_command": return `vMix · ${step.function || "Function"}${step.input ? ` · Input ${step.input}` : ""}`;
|
||
case "hockey_vmix_timers_start": return `Хоккей · таймеры · ${step.hockey_timer_command === "pause" ? "пауза" : step.hockey_timer_command === "start" ? "старт" : step.hockey_timer_command === "resume" ? "продолжить" : "старт / пауза"}`;
|
||
case "delay": return `Задержка · ${Number(step.milliseconds) || 0} мс`;
|
||
case "dispatch_event": return `JS-событие · ${step.event_name || "ui-builder:shortcut-sequence"}`;
|
||
default: return step.type;
|
||
}
|
||
}
|
||
|
||
function sequenceConditionLabel(value) {
|
||
return sequenceConditions.find(([key]) => key === value)?.[1] || "Всегда";
|
||
}
|
||
|
||
function shortcutSequenceSummary(sequence) {
|
||
const steps = (sequence.steps || []).filter((step) => step.enabled !== false);
|
||
if (!steps.length) return "Нет действий";
|
||
return steps.map((step) => {
|
||
const condition = step.condition && step.condition !== "always" ? ` [${sequenceConditionLabel(step.condition)}]` : "";
|
||
return `${sequenceStepTitle(step)}${condition}`;
|
||
}).join(" → ");
|
||
}
|
||
|
||
|
||
function penaltyTargetEditorRows(step, side) {
|
||
const targets = sequencePenaltyTargets(step, side);
|
||
const sideLabel = side === "home" ? "HOME" : "AWAY";
|
||
if (!targets.length) {
|
||
return `<div class="shortcut-empty-targets">Нет настроенных слотов ${sideLabel}. Добавьте слот и выберите Input + Text.</div>`;
|
||
}
|
||
return targets.map((target, index) => `
|
||
<div class="shortcut-penalty-target" data-penalty-target-row="${escapeHtml(target.id)}">
|
||
<span class="shortcut-target-index">${sideLabel} ${index + 1}</span>
|
||
<label>Input<select data-penalty-target-side="${side}" data-penalty-target-id="${escapeHtml(target.id)}" data-penalty-target-field="input">${vmixInputOptions(target.input)}</select></label>
|
||
<label>Text / SelectedName<select data-penalty-target-side="${side}" data-penalty-target-id="${escapeHtml(target.id)}" data-penalty-target-field="selected_name">${vmixTextSelectedNameOptions(target.input, target.selected_name)}</select></label>
|
||
<label>Overlay<select data-penalty-target-side="${side}" data-penalty-target-id="${escapeHtml(target.id)}" data-penalty-target-field="overlay">${triggerSelectOptions([["1","Overlay 1"],["2","Overlay 2"],["3","Overlay 3"],["4","Overlay 4"]], target.overlay)}</select></label>
|
||
<label class="shortcut-inline-check"><input type="checkbox" data-penalty-target-side="${side}" data-penalty-target-id="${escapeHtml(target.id)}" data-penalty-target-field="auto_hide_on_finish" ${target.auto_hide_on_finish !== false ? "checked" : ""}> Снимать после последнего удаления</label>
|
||
<button type="button" class="icon-btn danger" data-penalty-target-delete="${escapeHtml(target.id)}" data-penalty-target-side="${side}" title="Удалить слот">×</button>
|
||
</div>`).join("");
|
||
}
|
||
|
||
function timerFinishActionRows(step) {
|
||
const actions = normalizeTimerFinishActions(step.timer_finish_actions);
|
||
if (!actions.length) {
|
||
return `<div class="shortcut-empty-targets">Действия по окончании не настроены.</div>`;
|
||
}
|
||
return actions.map((action, index) => `
|
||
<div class="shortcut-finish-action" data-finish-action-row="${escapeHtml(action.id)}">
|
||
<span class="shortcut-target-index">${index + 1}</span>
|
||
<label class="shortcut-inline-check"><input type="checkbox" data-finish-action-id="${escapeHtml(action.id)}" data-finish-action-field="enabled" ${action.enabled ? "checked" : ""}> Активно</label>
|
||
<label>Когда<select data-finish-action-id="${escapeHtml(action.id)}" data-finish-action-field="source">${triggerSelectOptions([
|
||
["game","Основной таймер завершён"],
|
||
["any_penalty","Любое удаление завершено"],
|
||
["home_penalty","Удаление HOME завершено"],
|
||
["away_penalty","Удаление AWAY завершено"],
|
||
], action.source)}</select></label>
|
||
<label>Показать Input<select data-finish-action-id="${escapeHtml(action.id)}" data-finish-action-field="input">${vmixInputOptions(action.input)}</select></label>
|
||
<label>Overlay<select data-finish-action-id="${escapeHtml(action.id)}" data-finish-action-field="overlay">${triggerSelectOptions([["1","Overlay 1"],["2","Overlay 2"],["3","Overlay 3"],["4","Overlay 4"]], action.overlay)}</select></label>
|
||
<label>Показывать, мс<input type="number" min="100" max="120000" step="100" data-finish-action-id="${escapeHtml(action.id)}" data-finish-action-field="duration_ms" value="${Number(action.duration_ms) || 3000}"></label>
|
||
<label class="shortcut-inline-check"><input type="checkbox" data-finish-action-id="${escapeHtml(action.id)}" data-finish-action-field="only_when_side_clear" ${action.only_when_side_clear !== false ? "checked" : ""}> Только когда у команды больше нет удалений</label>
|
||
<button type="button" class="icon-btn danger" data-finish-action-delete="${escapeHtml(action.id)}" title="Удалить действие">×</button>
|
||
</div>`).join("");
|
||
}
|
||
|
||
function sequenceStepEditor(step, sequence, rerender) {
|
||
const card = document.createElement("article");
|
||
card.className = "shortcut-step-card";
|
||
card.dataset.stepId = step.id;
|
||
const index = sequence.steps.findIndex((item) => item.id === step.id);
|
||
card.innerHTML = `
|
||
<div class="shortcut-step-head">
|
||
<span class="shortcut-step-number">${index + 1}</span>
|
||
<label class="shortcut-step-enabled"><input type="checkbox" data-step-field="enabled" ${step.enabled ? "checked" : ""}> Активен</label>
|
||
<select data-step-field="type">
|
||
${triggerSelectOptions([
|
||
["timer_command","Веб: управление таймером"],
|
||
["hockey_penalties_command","Веб: все текущие удаления"],
|
||
["vmix_command","vMix: произвольная команда"],
|
||
["hockey_vmix_timers_start","Хоккей: синхронный старт / пауза таймеров"],
|
||
["delay","Задержка"],
|
||
["dispatch_event","JS-событие"],
|
||
], step.type)}
|
||
</select>
|
||
<button type="button" class="mini-btn" data-step-up ${index <= 0 ? "disabled" : ""} title="Выше">↑</button>
|
||
<button type="button" class="mini-btn" data-step-down ${index >= sequence.steps.length - 1 ? "disabled" : ""} title="Ниже">↓</button>
|
||
<button type="button" class="icon-btn danger" data-step-delete title="Удалить шаг">×</button>
|
||
</div>
|
||
<div class="shortcut-step-common">
|
||
<label>Описание шага<input type="text" data-step-field="label" value="${escapeHtml(step.label)}" placeholder="Необязательно"></label>
|
||
<label>Выполнять, если<select data-step-field="condition">${triggerSelectOptions(sequenceConditions, step.condition)}</select></label>
|
||
${["prematch_button_active","prematch_button_inactive"].includes(step.condition) ? `<label>Кнопка нижней панели<select data-step-field="condition_value">${triggerSelectOptions([["","— выберите кнопку —"], ...normalizePrematchButtons(state.config.prematch_buttons).map((button) => [button.id, button.label])], step.condition_value)}</select></label>` : ""}
|
||
${["active_tab","inactive_tab"].includes(step.condition) ? `<label>Вкладка<select data-step-field="condition_value">${triggerSelectOptions([["","— выберите вкладку —"], ...(state.config.tabs || []).map((tab) => [tab.id, tab.label])], step.condition_value)}</select></label>` : ""}
|
||
</div>
|
||
<div class="shortcut-step-body"></div>`;
|
||
|
||
const body = card.querySelector(".shortcut-step-body");
|
||
if (step.type === "timer_command") {
|
||
body.innerHTML = `<div class="shortcut-step-grid">
|
||
<label>Таймер<select data-step-field="target_action_id">${timerActionOptions(step.target_action_id)}</select></label>
|
||
<label>Команда<select data-step-field="timer_command">${triggerSelectOptions([["start","Запустить"],["pause","Пауза"],["resume","Продолжить"],["toggle","Старт / пауза"],["stop","Остановить"],["reset","Сбросить"],["restart","Заново"],["set_time","Установить время"]], step.timer_command)}</select></label>
|
||
<label>Время / значение<input type="text" data-step-field="timer_value" value="${escapeHtml(step.timer_value)}" placeholder="18:42"></label>
|
||
</div>`;
|
||
} else if (step.type === "hockey_penalties_command") {
|
||
body.innerHTML = `<div class="shortcut-step-grid">
|
||
<label>Дашборд<select data-step-field="target_action_id">${hockeyPenaltyBoardOptions(step.target_action_id)}</select></label>
|
||
<label>Команда<select data-step-field="penalty_command">${triggerSelectOptions([["start","Запустить все"],["pause","Пауза всем"],["reset","Сбросить все"]], step.penalty_command)}</select></label>
|
||
</div><p class="shortcut-step-note">Если Action ID не выбран, команда применяется ко всем текущим удалениям во всех хоккейных дашбордах.</p>`;
|
||
} else if (step.type === "vmix_command") {
|
||
const functionKnown = !step.function || VMIX_FUNCTIONS.includes(String(step.function));
|
||
body.innerHTML = `<div class="shortcut-step-grid vmix-command-grid">
|
||
<label>Function<select data-vmix-function-select>${vmixFunctionOptions(step.function)}</select><input class="shortcut-vmix-custom ${functionKnown ? "hidden" : ""}" type="text" data-vmix-function-custom value="${functionKnown ? "" : escapeHtml(step.function)}" placeholder="Своя Function"></label>
|
||
<label>Input<select data-step-field="input">${vmixInputOptions(step.input)}</select></label>
|
||
<label>SelectedName<select data-step-field="selected_name">${vmixSelectedNameOptions(step.input, step.selected_name)}</select></label>
|
||
<label>Value<input type="text" data-step-field="value" value="${escapeHtml(step.value)}" placeholder="значение или {{...}}"></label>
|
||
<label>Duration<input type="text" data-step-field="duration" value="${escapeHtml(step.duration)}" placeholder="300"></label>
|
||
<label>Mix<input type="text" data-step-field="mix" value="${escapeHtml(step.mix)}" placeholder="0"></label>
|
||
</div>
|
||
<div class="shortcut-scoreboard-alternate">
|
||
<label class="check-inline"><input type="checkbox" data-step-field="use_scoreboard_alternate" ${step.use_scoreboard_alternate ? "checked" : ""}> другой Input, если верхний счёт уже в эфире</label>
|
||
<div class="shortcut-step-grid vmix-command-grid ${step.use_scoreboard_alternate ? "" : "is-disabled"}">
|
||
<label>Input при верхнем счёте<select data-step-field="scoreboard_alternate_input" ${step.use_scoreboard_alternate ? "" : "disabled"}>${vmixInputOptions(step.scoreboard_alternate_input)}</select></label>
|
||
<label>SelectedName<select data-step-field="scoreboard_alternate_selected_name" ${step.use_scoreboard_alternate ? "" : "disabled"}>${vmixSelectedNameOptions(step.scoreboard_alternate_input, step.scoreboard_alternate_selected_name)}</select></label>
|
||
</div>
|
||
</div>
|
||
<div class="shortcut-vmix-inventory-note"><span>${escapeHtml(shortcutInventoryLabel())}</span><small>При выборе сохраняется стабильный vMix key (название — резерв). Номер # показывается только для удобства и не используется как постоянная связь.</small></div><p class="shortcut-step-note">Можно настроить два варианта одного титра: обычный Input и альтернативный Input, который автоматически используется, пока сценарий «верхний счёт» находится в эфире.</p>`;
|
||
} else if (step.type === "hockey_vmix_timers_start") {
|
||
body.innerHTML = `<div class="shortcut-hockey-sync">
|
||
<div class="shortcut-section-title">Основной таймер</div>
|
||
<div class="shortcut-step-grid vmix-command-grid">
|
||
<label>Действие<select data-step-field="hockey_timer_command">${triggerSelectOptions([["toggle","Старт / пауза одной кнопкой"],["start","Только запустить"],["pause","Только пауза"],["resume","Продолжить"]], step.hockey_timer_command)}</select></label>
|
||
<label>Основной веб-таймер<select data-step-field="game_timer_action_id">${timerActionOptions(step.game_timer_action_id)}</select></label>
|
||
<label>Режим основного таймера в vMix<select data-step-field="game_vmix_mode">${triggerSelectOptions([["text","Text mirror · рекомендуется"],["countdown","Встроенный Countdown vMix"]], step.game_vmix_mode)}</select></label>
|
||
<label>vMix Input основного таймера<select data-step-field="game_vmix_input">${vmixInputOptions(step.game_vmix_input)}</select></label>
|
||
<label>Text / SelectedName основного таймера<select data-step-field="game_vmix_selected_name">${vmixTextSelectedNameOptions(step.game_vmix_input, step.game_vmix_selected_name)}</select></label>
|
||
<label>Режим таймеров удалений<select data-step-field="penalty_vmix_mode">${triggerSelectOptions([["text","Text mirror · рекомендуется"],["countdown","Встроенный Countdown vMix"]], step.penalty_vmix_mode)}</select></label>
|
||
<label>Что показывать при нескольких удалениях<select data-step-field="penalty_display_mode">${triggerSelectOptions([["soonest","Одно ближайшее окончание"],["all","Все удаления по слотам"]], step.penalty_display_mode)}</select></label>
|
||
</div>
|
||
|
||
<div class="shortcut-section-title shortcut-section-title-with-action">
|
||
<span>Таймеры удалений HOME</span>
|
||
<button type="button" class="mini-btn" data-penalty-target-add="home">+ Добавить слот HOME</button>
|
||
</div>
|
||
<div class="shortcut-penalty-target-list" data-penalty-target-list="home">${penaltyTargetEditorRows(step, "home")}</div>
|
||
|
||
<div class="shortcut-section-title shortcut-section-title-with-action">
|
||
<span>Таймеры удалений AWAY</span>
|
||
<button type="button" class="mini-btn" data-penalty-target-add="away">+ Добавить слот AWAY</button>
|
||
</div>
|
||
<div class="shortcut-penalty-target-list" data-penalty-target-list="away">${penaltyTargetEditorRows(step, "away")}</div>
|
||
|
||
<div class="shortcut-sync-toggles">
|
||
<label><input type="checkbox" data-step-field="sync_vmix_game" ${step.sync_vmix_game ? "checked" : ""}> Синхронизировать основной таймер с vMix</label>
|
||
<label><input type="checkbox" data-step-field="sync_vmix_penalties" ${step.sync_vmix_penalties ? "checked" : ""}> Синхронизировать удаления с vMix</label>
|
||
<label><input type="checkbox" data-step-field="start_web_game" ${step.start_web_game ? "checked" : ""}> Управлять основным таймером в вебе</label>
|
||
<label><input type="checkbox" data-step-field="start_web_penalties" ${step.start_web_penalties ? "checked" : ""}> Управлять текущими удалениями в вебе</label>
|
||
</div>
|
||
|
||
<div class="shortcut-section-title shortcut-section-title-with-action">
|
||
<span>Действия по окончании таймера</span>
|
||
<button type="button" class="mini-btn" data-finish-action-add>+ Добавить действие</button>
|
||
</div>
|
||
<div class="shortcut-finish-action-list">${timerFinishActionRows(step)}</div>
|
||
|
||
<div class="shortcut-vmix-inventory-note"><span>${escapeHtml(shortcutInventoryLabel())}</span><small>Для каждого countdown теперь обязательно выбирается конкретный Text / SelectedName. Это исключает отправку времени в первый текстовый элемент по умолчанию.</small></div>
|
||
<p class="shortcut-step-note">Режим <b>Text mirror</b> рекомендуется: веб-таймер является источником истины и раз в секунду отправляет <code>SetText</code> строго в выбранные <code>Input + SelectedName</code>. Режим <b>Countdown</b> оставлен для титров, где countdown уже настроен внутри vMix. Для верхнего счёта по умолчанию используется одно ближайшее к окончанию удаление на сторону: при двойном штрафе в поле идёт минимальное оставшееся время, а после его завершения то же поле автоматически переключается на следующий штраф. Режим «Все удаления по слотам» оставлен как дополнительный. Действие по окончании показывает выбранный Input в заданном Overlay и автоматически убирает его через указанное время.</p>
|
||
</div>`;
|
||
} else if (step.type === "delay") {
|
||
body.innerHTML = `<div class="shortcut-step-grid"><label>Задержка, мс<input type="number" min="0" max="10000" step="10" data-step-field="milliseconds" value="${Number(step.milliseconds) || 0}"></label></div>`;
|
||
} else if (step.type === "dispatch_event") {
|
||
body.innerHTML = `<div class="shortcut-step-grid"><label>Имя события<input type="text" data-step-field="event_name" value="${escapeHtml(step.event_name)}" placeholder="ui-builder:custom"></label></div>`;
|
||
}
|
||
|
||
card.querySelectorAll("[data-step-field]").forEach((input) => {
|
||
const key = input.dataset.stepField;
|
||
const eventName = input.type === "checkbox" || input.tagName === "SELECT" ? "change" : "input";
|
||
input.addEventListener(eventName, () => {
|
||
const value = input.type === "checkbox" ? input.checked : input.type === "number" ? Number(input.value) : input.value;
|
||
step[key] = value;
|
||
if (key === "type") {
|
||
Object.assign(step, normalizeSequenceStep(step, index));
|
||
rerender();
|
||
return;
|
||
}
|
||
if (key === "condition") {
|
||
if (!["prematch_button_active", "prematch_button_inactive", "active_tab", "inactive_tab"].includes(step.condition)) step.condition_value = "";
|
||
rerender();
|
||
return;
|
||
}
|
||
if (step.type === "vmix_command" && key === "use_scoreboard_alternate") {
|
||
rerender();
|
||
return;
|
||
}
|
||
if (step.type === "vmix_command" && key === "input") {
|
||
const selectedName = card.querySelector('[data-step-field="selected_name"]');
|
||
if (selectedName) {
|
||
const inputInfo = vmixInputByRef(step.input);
|
||
const validNames = new Set((Array.isArray(inputInfo?.fields) ? inputInfo.fields : []).map((field) => String(field?.name || "").trim()).filter(Boolean));
|
||
if (step.selected_name && !validNames.has(String(step.selected_name))) step.selected_name = "";
|
||
selectedName.innerHTML = vmixSelectedNameOptions(step.input, step.selected_name);
|
||
selectedName.value = step.selected_name;
|
||
refreshEnhancedControl(selectedName);
|
||
}
|
||
}
|
||
if (step.type === "vmix_command" && key === "scoreboard_alternate_input") {
|
||
const selectedName = card.querySelector('[data-step-field="scoreboard_alternate_selected_name"]');
|
||
if (selectedName) {
|
||
const inputInfo = vmixInputByRef(step.scoreboard_alternate_input);
|
||
const validNames = new Set((Array.isArray(inputInfo?.fields) ? inputInfo.fields : []).map((field) => String(field?.name || "").trim()).filter(Boolean));
|
||
if (step.scoreboard_alternate_selected_name && !validNames.has(String(step.scoreboard_alternate_selected_name))) step.scoreboard_alternate_selected_name = "";
|
||
selectedName.innerHTML = vmixSelectedNameOptions(step.scoreboard_alternate_input, step.scoreboard_alternate_selected_name);
|
||
selectedName.value = step.scoreboard_alternate_selected_name;
|
||
refreshEnhancedControl(selectedName);
|
||
}
|
||
}
|
||
if (step.type === "hockey_vmix_timers_start" && key === "game_vmix_input") {
|
||
const selectedName = card.querySelector('[data-step-field="game_vmix_selected_name"]');
|
||
if (selectedName) {
|
||
const inputInfo = vmixInputByRef(step.game_vmix_input);
|
||
const validNames = new Set((Array.isArray(inputInfo?.fields) ? inputInfo.fields : []).filter((field) => {
|
||
const name = String(field?.name || "").trim();
|
||
const type = String(field?.type || field?.kind || "").trim().toLowerCase();
|
||
return type === "text" || name.toLowerCase().endsWith(".text");
|
||
}).map((field) => String(field?.name || "").trim()).filter(Boolean));
|
||
if (step.game_vmix_selected_name && !validNames.has(String(step.game_vmix_selected_name))) step.game_vmix_selected_name = "";
|
||
selectedName.innerHTML = vmixTextSelectedNameOptions(step.game_vmix_input, step.game_vmix_selected_name);
|
||
selectedName.value = step.game_vmix_selected_name;
|
||
refreshEnhancedControl(selectedName);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
if (step.type === "vmix_command") {
|
||
const functionSelect = card.querySelector("[data-vmix-function-select]");
|
||
const functionCustom = card.querySelector("[data-vmix-function-custom]");
|
||
const syncFunctionUi = () => {
|
||
const isCustom = functionSelect?.value === "__custom__";
|
||
functionCustom?.classList.toggle("hidden", !isCustom);
|
||
if (isCustom) {
|
||
step.function = functionCustom?.value || step.function || "";
|
||
setTimeout(() => functionCustom?.focus(), 0);
|
||
} else if (functionSelect) {
|
||
step.function = functionSelect.value;
|
||
}
|
||
};
|
||
functionSelect?.addEventListener("change", syncFunctionUi);
|
||
functionCustom?.addEventListener("input", () => { step.function = functionCustom.value; });
|
||
}
|
||
|
||
if (step.type === "hockey_vmix_timers_start") {
|
||
step.home_penalty_targets = sequencePenaltyTargets(step, "home");
|
||
step.away_penalty_targets = sequencePenaltyTargets(step, "away");
|
||
step.timer_finish_actions = normalizeTimerFinishActions(step.timer_finish_actions);
|
||
|
||
card.querySelectorAll("[data-penalty-target-field]").forEach((control) => {
|
||
control.addEventListener("change", () => {
|
||
const side = control.dataset.penaltyTargetSide;
|
||
const targetId = control.dataset.penaltyTargetId;
|
||
const field = control.dataset.penaltyTargetField;
|
||
const targets = side === "home" ? step.home_penalty_targets : step.away_penalty_targets;
|
||
const target = targets.find((item) => item.id === targetId);
|
||
if (!target) return;
|
||
target[field] = control.type === "checkbox" ? control.checked : control.value;
|
||
if (field === "input") {
|
||
target.selected_name = "";
|
||
rerender();
|
||
}
|
||
});
|
||
});
|
||
card.querySelectorAll("[data-penalty-target-add]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const side = button.dataset.penaltyTargetAdd;
|
||
const targets = side === "home" ? step.home_penalty_targets : step.away_penalty_targets;
|
||
if (targets.length >= 8) { toast("Можно настроить до 8 слотов удалений на сторону", true); return; }
|
||
targets.push({ id: uid(), input: "", selected_name: "", overlay: "2", auto_hide_on_finish: true });
|
||
rerender();
|
||
});
|
||
});
|
||
card.querySelectorAll("[data-penalty-target-delete]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const side = button.dataset.penaltyTargetSide;
|
||
const targetId = button.dataset.penaltyTargetDelete;
|
||
if (side === "home") step.home_penalty_targets = step.home_penalty_targets.filter((item) => item.id !== targetId);
|
||
else step.away_penalty_targets = step.away_penalty_targets.filter((item) => item.id !== targetId);
|
||
rerender();
|
||
});
|
||
});
|
||
|
||
card.querySelector("[data-finish-action-add]")?.addEventListener("click", () => {
|
||
if (step.timer_finish_actions.length >= 12) { toast("Можно настроить до 12 действий по окончании", true); return; }
|
||
step.timer_finish_actions.push({ id: uid(), enabled: true, source: "game", input: "", overlay: "1", duration_ms: 3000, only_when_side_clear: true });
|
||
rerender();
|
||
});
|
||
card.querySelectorAll("[data-finish-action-field]").forEach((control) => {
|
||
const eventName = control.type === "checkbox" || control.tagName === "SELECT" ? "change" : "input";
|
||
control.addEventListener(eventName, () => {
|
||
const action = step.timer_finish_actions.find((item) => item.id === control.dataset.finishActionId);
|
||
if (!action) return;
|
||
const field = control.dataset.finishActionField;
|
||
action[field] = control.type === "checkbox" ? control.checked : control.type === "number" ? Number(control.value) : control.value;
|
||
if (field === "duration_ms") action.duration_ms = clamp(Number(action.duration_ms) || 3000, 100, 120000);
|
||
});
|
||
});
|
||
card.querySelectorAll("[data-finish-action-delete]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
step.timer_finish_actions = step.timer_finish_actions.filter((item) => item.id !== button.dataset.finishActionDelete);
|
||
rerender();
|
||
});
|
||
});
|
||
}
|
||
card.querySelector("[data-step-delete]")?.addEventListener("click", () => {
|
||
sequence.steps = sequence.steps.filter((item) => item.id !== step.id);
|
||
rerender();
|
||
});
|
||
card.querySelector("[data-step-up]")?.addEventListener("click", () => {
|
||
const current = sequence.steps.findIndex((item) => item.id === step.id);
|
||
if (current > 0) [sequence.steps[current - 1], sequence.steps[current]] = [sequence.steps[current], sequence.steps[current - 1]];
|
||
rerender();
|
||
});
|
||
card.querySelector("[data-step-down]")?.addEventListener("click", () => {
|
||
const current = sequence.steps.findIndex((item) => item.id === step.id);
|
||
if (current >= 0 && current < sequence.steps.length - 1) [sequence.steps[current + 1], sequence.steps[current]] = [sequence.steps[current], sequence.steps[current + 1]];
|
||
rerender();
|
||
});
|
||
return card;
|
||
}
|
||
|
||
function shortcutSequenceCard(sequence, rerenderAll) {
|
||
const card = document.createElement("article");
|
||
card.className = "shortcut-sequence-card";
|
||
card.dataset.sequenceId = sequence.id;
|
||
const expanded = state.shortcutSequenceOpenId === sequence.id;
|
||
const legacyConflict = state.config.components.some((component) => (component.shortcuts || []).some((shortcut) => shortcut.enabled && shortcut.combo && normalizeShortcutCombo(shortcut.combo) === normalizeShortcutCombo(sequence.combo)));
|
||
const sequenceConflicts = (state.config.shortcut_sequences || []).filter((item) => item.id !== sequence.id && item.enabled && item.combo && normalizeShortcutCombo(item.combo) === normalizeShortcutCombo(sequence.combo));
|
||
if (legacyConflict || sequenceConflicts.length) card.classList.add("has-conflict");
|
||
|
||
if (!expanded) {
|
||
card.classList.add("is-collapsed");
|
||
card.innerHTML = `<div class="shortcut-sequence-compact">
|
||
<label class="shortcut-enabled compact-toggle" title="Включить / выключить"><input type="checkbox" data-sequence-enabled ${sequence.enabled ? "checked" : ""}></label>
|
||
<kbd>${escapeHtml(sequence.combo || "—")}</kbd>
|
||
<div class="shortcut-sequence-compact-copy"><strong>${escapeHtml(sequence.name || "Без названия")}</strong><small>${escapeHtml(sequence.description || shortcutSequenceSummary(sequence))}</small></div>
|
||
<span class="shortcut-sequence-step-count">${(sequence.steps || []).length} шаг.</span>
|
||
<button type="button" class="btn" data-sequence-test>▶</button>
|
||
<button type="button" class="btn btn-accent" data-sequence-edit>Редактировать</button>
|
||
</div>`;
|
||
card.querySelector("[data-sequence-enabled]")?.addEventListener("change", (event) => { sequence.enabled = event.target.checked; updateShortcutsCount(); });
|
||
card.querySelector("[data-sequence-test]")?.addEventListener("click", () => runShortcutSequence(sequence, { source: "editor-test" }));
|
||
card.querySelector("[data-sequence-edit]")?.addEventListener("click", () => { state.shortcutSequenceOpenId = sequence.id; rerenderAll({ focusId: sequence.id }); });
|
||
return card;
|
||
}
|
||
|
||
card.classList.add("is-expanded");
|
||
card.innerHTML = `
|
||
<div class="shortcut-sequence-head">
|
||
<label class="shortcut-enabled"><input type="checkbox" data-sequence-enabled ${sequence.enabled ? "checked" : ""}> Включён</label>
|
||
<input class="shortcut-sequence-name" type="text" data-sequence-name value="${escapeHtml(sequence.name)}" aria-label="Название шортката">
|
||
<button type="button" class="btn" data-sequence-test>▶ Проверить</button>
|
||
<button type="button" class="btn" data-sequence-collapse>Свернуть</button>
|
||
<button type="button" class="icon-btn danger" data-sequence-delete title="Удалить">×</button>
|
||
</div>
|
||
<textarea class="shortcut-sequence-description" data-sequence-description rows="2" placeholder="Описание для оператора и печатной памятки">${escapeHtml(sequence.description)}</textarea>
|
||
<div class="shortcut-sequence-key-row">
|
||
<label><span>Клавиша</span><input type="text" data-sequence-combo value="${escapeHtml(sequence.combo)}" placeholder="F1, Space, Ctrl+1"></label>
|
||
<button type="button" class="btn" data-sequence-capture>Записать</button>
|
||
<label><span>Где работает</span><select data-sequence-scope>${triggerSelectOptions([["runtime","Runtime и предпросмотр"],["all","Также в редакторе"]], sequence.scope)}</select></label>
|
||
<label class="check-inline"><input type="checkbox" data-sequence-prevent ${sequence.prevent_default ? "checked" : ""}> блокировать действие браузера</label>
|
||
<label class="check-inline"><input type="checkbox" data-sequence-inputs ${sequence.allow_in_inputs ? "checked" : ""}> во время ввода</label>
|
||
<label class="check-inline"><input type="checkbox" data-sequence-overlay-group ${sequence.toggle_all_overlays_on_repeat ? "checked" : ""}> повторное нажатие снимает все Overlay</label>
|
||
<label class="check-inline"><input type="checkbox" data-sequence-scoreboard ${sequence.is_scoreboard_sequence ? "checked" : ""}> этот сценарий — верхний счёт</label>
|
||
<label class="check-inline"><input type="checkbox" data-sequence-team-states ${sequence.sync_hockey_team_states ? "checked" : ""}> добавлять состояния команд к верхнему счёту</label>
|
||
</div>
|
||
${(legacyConflict || sequenceConflicts.length) ? '<div class="shortcut-sequence-warning">Эта комбинация уже используется. Новый глобальный сценарий будет иметь приоритет над старым shortcut элемента.</div>' : ''}
|
||
<div class="shortcut-sequence-summary"><strong>Цепочка:</strong> ${escapeHtml(shortcutSequenceSummary(sequence))}</div>
|
||
<div class="shortcut-sequence-steps"></div>
|
||
<div class="shortcut-add-steps">
|
||
<span>Добавить шаг:</span>
|
||
<button type="button" class="mini-btn" data-add-step="hockey_vmix_timers_start">🏒 Таймеры</button>
|
||
<button type="button" class="mini-btn" data-add-step="timer_command">⏱ Веб-таймер</button>
|
||
<button type="button" class="mini-btn" data-add-step="hockey_penalties_command">2′ Удаления</button>
|
||
<button type="button" class="mini-btn" data-add-step="vmix_command">vMix</button>
|
||
<button type="button" class="mini-btn" data-add-step="delay">Пауза</button>
|
||
</div>`;
|
||
|
||
const rerender = () => rerenderAll({ focusId: sequence.id });
|
||
const stepsHost = card.querySelector(".shortcut-sequence-steps");
|
||
(sequence.steps || []).forEach((step) => stepsHost.appendChild(sequenceStepEditor(step, sequence, rerender)));
|
||
card.querySelector("[data-sequence-enabled]").addEventListener("change", (event) => { sequence.enabled = event.target.checked; updateShortcutsCount(); });
|
||
card.querySelector("[data-sequence-name]").addEventListener("input", (event) => { sequence.name = event.target.value; });
|
||
card.querySelector("[data-sequence-description]").addEventListener("input", (event) => { sequence.description = event.target.value; });
|
||
const comboInput = card.querySelector("[data-sequence-combo]");
|
||
comboInput.addEventListener("change", () => { sequence.combo = normalizeShortcutCombo(comboInput.value); comboInput.value = sequence.combo; rerender(); });
|
||
const captureButton = card.querySelector("[data-sequence-capture]");
|
||
captureButton.addEventListener("click", () => beginShortcutCapture(null, sequence, comboInput, captureButton, () => rerender()));
|
||
card.querySelector("[data-sequence-scope]").addEventListener("change", (event) => { sequence.scope = event.target.value; });
|
||
card.querySelector("[data-sequence-prevent]").addEventListener("change", (event) => { sequence.prevent_default = event.target.checked; });
|
||
card.querySelector("[data-sequence-inputs]").addEventListener("change", (event) => { sequence.allow_in_inputs = event.target.checked; });
|
||
card.querySelector("[data-sequence-overlay-group]").addEventListener("change", (event) => {
|
||
sequence.toggle_all_overlays_on_repeat = event.target.checked;
|
||
if (!sequence.toggle_all_overlays_on_repeat) state.shortcutSequenceOverlayState.delete(sequence.id);
|
||
});
|
||
card.querySelector("[data-sequence-scoreboard]").addEventListener("change", (event) => {
|
||
sequence.is_scoreboard_sequence = event.target.checked;
|
||
});
|
||
card.querySelector("[data-sequence-team-states]").addEventListener("change", (event) => {
|
||
sequence.sync_hockey_team_states = event.target.checked;
|
||
});
|
||
card.querySelector("[data-sequence-test]").addEventListener("click", () => runShortcutSequence(sequence, { source: "editor-test" }));
|
||
card.querySelector("[data-sequence-collapse]").addEventListener("click", () => { state.shortcutSequenceOpenId = ""; rerenderAll(); });
|
||
card.querySelector("[data-sequence-delete]").addEventListener("click", () => {
|
||
if (!window.confirm(`Удалить шорткат «${sequence.name || sequence.combo || "без названия"}»?`)) return;
|
||
state.config.shortcut_sequences = (state.config.shortcut_sequences || []).filter((item) => item.id !== sequence.id);
|
||
state.config.triggers.forEach((trigger) => { if (trigger.action?.sequence_id === sequence.id) trigger.action.sequence_id = ""; });
|
||
state.shortcutSequenceOpenId = "";
|
||
rerenderAll(); updateShortcutsCount();
|
||
});
|
||
card.querySelectorAll("[data-add-step]").forEach((button) => button.addEventListener("click", () => {
|
||
sequence.steps.push(createSequenceStep(button.dataset.addStep));
|
||
rerender();
|
||
}));
|
||
return card;
|
||
}
|
||
|
||
function showShortcutsEditor() {
|
||
state.shortcutSequenceOpenId = "";
|
||
showSettingsModal("Шорткаты и сценарии", `<div class="shortcut-sequence-editor">
|
||
<div class="shortcut-sequence-intro"><div><strong>Одна клавиша → цепочка действий</strong><p>Готовые шорткаты показаны компактно. Нажмите «Редактировать», чтобы раскрыть только нужный сценарий.</p></div><button type="button" class="btn" data-open-shortcut-reference>Памятка оператора</button></div>
|
||
<div class="shortcut-sequence-toolbar">
|
||
<div class="shortcut-toolbar-main"><button type="button" class="btn btn-accent" data-add-sequence>+ Добавить шорткат</button><button type="button" class="btn" data-preset-timers>🏒 Space · таймеры</button><button type="button" class="btn" data-preset-score>F1 Счёт</button></div>
|
||
<label class="shortcut-search"><span>Поиск</span><input type="search" data-shortcut-search placeholder="F1, счёт, таймер…"></label>
|
||
<div class="shortcut-vmix-status"><span data-vmix-inventory-status>${escapeHtml(shortcutInventoryLabel())}</span><button type="button" class="btn" data-refresh-vmix-inventory>↻ vMix</button></div>
|
||
<div class="shortcut-toolbar-actions"><button type="button" class="btn btn-accent" data-save-shortcuts>💾 Сохранить</button><button type="button" class="btn" data-close-shortcuts>Закрыть</button></div>
|
||
</div>
|
||
<div class="shortcut-sequence-list"></div>
|
||
</div>`, { locked: true, className: "modal-shortcuts-full" });
|
||
const editor = el.modalHost.querySelector(".shortcut-sequence-editor");
|
||
const modalCard = editor?.closest(".modal-card");
|
||
const searchInput = editor.querySelector("[data-shortcut-search]");
|
||
|
||
const render = ({ focusId = "" } = {}) => {
|
||
const previousScroll = modalCard?.scrollTop ?? state.shortcutEditorScrollTop ?? 0;
|
||
const list = editor.querySelector(".shortcut-sequence-list");
|
||
list.innerHTML = "";
|
||
const query = String(searchInput?.value || "").trim().toLowerCase();
|
||
const sequences = (state.config.shortcut_sequences || []).filter((sequence) => {
|
||
if (!query) return true;
|
||
return [sequence.combo, sequence.name, sequence.description, shortcutSequenceSummary(sequence)].some((value) => String(value || "").toLowerCase().includes(query));
|
||
});
|
||
if (!sequences.length) list.innerHTML = '<div class="empty-state">Шорткаты не найдены.</div>';
|
||
sequences.forEach((sequence) => list.appendChild(shortcutSequenceCard(sequence, render)));
|
||
const status = editor.querySelector("[data-vmix-inventory-status]");
|
||
if (status) status.textContent = shortcutInventoryLabel();
|
||
scheduleStyledControls();
|
||
updateShortcutsCount();
|
||
requestAnimationFrame(() => {
|
||
if (!modalCard) return;
|
||
if (focusId) {
|
||
const target = list.querySelector(`[data-sequence-id="${CSS.escape(focusId)}"]`);
|
||
target?.scrollIntoView({ block: "nearest" });
|
||
} else {
|
||
modalCard.scrollTop = previousScroll;
|
||
}
|
||
state.shortcutEditorScrollTop = modalCard.scrollTop;
|
||
});
|
||
};
|
||
|
||
modalCard?.addEventListener("scroll", () => { state.shortcutEditorScrollTop = modalCard.scrollTop; }, { passive: true });
|
||
searchInput?.addEventListener("input", () => render());
|
||
editor.querySelector("[data-add-sequence]").addEventListener("click", () => {
|
||
state.config.shortcut_sequences ||= [];
|
||
const sequence = createShortcutSequence();
|
||
state.config.shortcut_sequences.push(sequence);
|
||
state.shortcutSequenceOpenId = sequence.id;
|
||
render({ focusId: sequence.id });
|
||
});
|
||
editor.querySelector("[data-preset-timers]").addEventListener("click", () => {
|
||
state.config.shortcut_sequences ||= [];
|
||
const sequence = normalizeShortcutSequence({
|
||
id: uid(), name: "Старт игрового времени", description: "Запускает основной таймер матча, синхронизирует vMix и запускает текущие удаления.",
|
||
enabled: true, combo: "Space", prevent_default: true, scope: "runtime", steps: [{ ...createSequenceStep("hockey_vmix_timers_start"), hockey_timer_command: "toggle" }],
|
||
}, state.config.shortcut_sequences.length);
|
||
state.config.shortcut_sequences.push(sequence);
|
||
state.shortcutSequenceOpenId = sequence.id;
|
||
render({ focusId: sequence.id });
|
||
});
|
||
editor.querySelector("[data-preset-score]").addEventListener("click", () => {
|
||
state.config.shortcut_sequences ||= [];
|
||
const scoreStep = createSequenceStep("vmix_command");
|
||
Object.assign(scoreStep, { function: "OverlayInput1In", label: "Показать счёт" });
|
||
const penaltyStep = createSequenceStep("vmix_command");
|
||
Object.assign(penaltyStep, { function: "OverlayInput2In", condition: "has_penalties", label: "Если есть удаление — показать слой удаления" });
|
||
const sequence = normalizeShortcutSequence({
|
||
id: uid(), name: "Счёт", description: "Показывает счёт; повторное нажатие снимает все Overlay.",
|
||
enabled: true, combo: "F1", prevent_default: true, toggle_all_overlays_on_repeat: true, sync_hockey_team_states: true, is_scoreboard_sequence: true, scope: "runtime", steps: [scoreStep, penaltyStep],
|
||
}, state.config.shortcut_sequences.length);
|
||
state.config.shortcut_sequences.push(sequence);
|
||
state.shortcutSequenceOpenId = sequence.id;
|
||
render({ focusId: sequence.id });
|
||
});
|
||
editor.querySelector("[data-open-shortcut-reference]").addEventListener("click", showShortcutsReference);
|
||
editor.querySelector("[data-refresh-vmix-inventory]").addEventListener("click", async () => { await loadShortcutInventory(); render({ focusId: state.shortcutSequenceOpenId }); });
|
||
editor.querySelector("[data-save-shortcuts]").addEventListener("click", () => saveConfig());
|
||
editor.querySelector("[data-close-shortcuts]").addEventListener("click", closeModal);
|
||
render();
|
||
loadShortcutInventory({ silent: true }).then(() => render({ focusId: state.shortcutSequenceOpenId }));
|
||
}
|
||
|
||
function shortcutReferenceItems() {
|
||
const globalItems = (state.config.shortcut_sequences || []).filter((sequence) => sequence.enabled && sequence.combo).map((sequence) => ({
|
||
combo: sequence.combo, name: sequence.name, description: sequence.description, summary: shortcutSequenceSummary(sequence), kind: "Сценарий",
|
||
}));
|
||
const legacyItems = [];
|
||
state.config.components.forEach((component) => {
|
||
(component.shortcuts || []).filter((shortcut) => shortcut.enabled && shortcut.combo).forEach((shortcut) => {
|
||
legacyItems.push({
|
||
combo: shortcut.combo,
|
||
name: component.title || component.action_id,
|
||
description: `Событие: ${shortcut.event || "click"}${shortcut.item_id ? ` · ${shortcut.item_id}` : ""}`,
|
||
summary: `Action ID: ${component.action_id}`,
|
||
kind: "Элемент",
|
||
});
|
||
});
|
||
});
|
||
return [...globalItems, ...legacyItems].sort((a, b) => a.combo.localeCompare(b.combo, "ru", { numeric: true }));
|
||
}
|
||
|
||
function shortcutReferenceHtml() {
|
||
const items = shortcutReferenceItems();
|
||
if (!items.length) return '<div class="shortcut-reference-empty">Активных шорткатов пока нет.</div>';
|
||
return `<div class="shortcut-reference-grid">${items.map((item) => `<article class="shortcut-reference-card">
|
||
<kbd>${escapeHtml(item.combo)}</kbd>
|
||
<div><span class="shortcut-reference-kind">${escapeHtml(item.kind)}</span><strong>${escapeHtml(item.name)}</strong>${item.description ? `<p>${escapeHtml(item.description)}</p>` : ""}<small>${escapeHtml(item.summary)}</small></div>
|
||
</article>`).join("")}</div>`;
|
||
}
|
||
|
||
function printShortcutsReference() {
|
||
const items = shortcutReferenceItems();
|
||
const title = state.config.project_name || "Шорткаты";
|
||
const rows = items.map((item) => `<article><kbd>${escapeHtml(item.combo)}</kbd><div><h2>${escapeHtml(item.name)}</h2><p>${escapeHtml(item.description || "")}</p><small>${escapeHtml(item.summary)}</small></div></article>`).join("");
|
||
const popup = window.open("", "_blank");
|
||
if (!popup) { toast("Браузер заблокировал окно печати", true); return; }
|
||
popup.document.write(`<!doctype html><html lang="ru"><head><meta charset="utf-8"><title>${escapeHtml(title)} — шорткаты</title><style>
|
||
@page{size:A4;margin:14mm}*{box-sizing:border-box}body{font-family:Arial,sans-serif;color:#121826;margin:0}header{display:flex;justify-content:space-between;align-items:end;border-bottom:2px solid #111827;padding-bottom:12px;margin-bottom:16px}h1{font-size:22px;margin:0}header span{font-size:11px;color:#64748b}article{display:grid;grid-template-columns:105px 1fr;gap:16px;align-items:start;padding:13px 0;border-bottom:1px solid #d9dee8;break-inside:avoid}kbd{display:inline-flex;min-height:44px;align-items:center;justify-content:center;padding:8px 12px;border:1.5px solid #111827;border-bottom-width:4px;border-radius:8px;font-weight:800;font-size:18px;background:#f8fafc}h2{font-size:15px;margin:0 0 4px}p{font-size:12px;margin:0 0 5px;color:#334155}small{font-size:10px;color:#64748b;line-height:1.35}footer{margin-top:18px;font-size:9px;color:#94a3b8;text-align:right}</style></head><body><header><h1>${escapeHtml(title)} · Шорткаты</h1><span>Операторская памятка</span></header>${rows || '<p>Активных шорткатов нет.</p>'}<footer>UI Builder · ${new Date().toLocaleDateString("ru-RU")}</footer><script>window.onload=()=>{window.print();}</script></body></html>`);
|
||
popup.document.close();
|
||
}
|
||
|
||
function showShortcutsReference() {
|
||
showSettingsModal("Шорткаты", `<div class="shortcut-reference">
|
||
<div class="shortcut-reference-head"><div><strong>${escapeHtml(state.config.project_name || "Проект")}</strong><p>Краткая памятка оператора</p></div><button type="button" class="btn btn-accent" data-print-shortcuts>🖨 Печать</button></div>
|
||
${shortcutReferenceHtml()}
|
||
</div>`);
|
||
el.modalHost.querySelector("[data-print-shortcuts]")?.addEventListener("click", printShortcutsReference);
|
||
}
|
||
|
||
async function hockeyPersistTriggers() {
|
||
const triggers = Array.isArray(state.config.triggers) ? state.config.triggers : [];
|
||
const response = await fetch("/api/hockey/ui/triggers", {
|
||
method: "POST",
|
||
cache: "no-store",
|
||
credentials: "same-origin",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ triggers }),
|
||
});
|
||
let payload = {};
|
||
try { payload = await response.json(); } catch (_) {}
|
||
if (!response.ok) {
|
||
const detail = payload?.detail?.message || payload?.detail || `HTTP ${response.status}`;
|
||
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
||
}
|
||
if (Array.isArray(payload.triggers)) state.config.triggers = payload.triggers;
|
||
updateTriggersCount();
|
||
return payload;
|
||
}
|
||
|
||
function showTriggersEditor(filterActionId = "") {
|
||
const accountEventsLabel = state.operatorToastsEnabled !== false ? "🔔 Toast: ВКЛ" : "🔕 Toast: ВЫКЛ";
|
||
const accountEventsClass = state.operatorToastsEnabled !== false ? "is-on" : "is-off";
|
||
state.triggerEditorOpenIds = new Set();
|
||
showSettingsModal("События и триггеры", `<div class="trigger-editor"><div class="trigger-help"><strong>Событие → условие → действие</strong><p>Триггеры всегда работают. Кнопка Toast включает/выключает только нижние информационные уведомления для вашего аккаунта. Для вкладок выберите вкладку из списка. Нажмите на стрелку или «Редактировать», чтобы раскрыть триггер, и после изменений нажмите <b>Сохранить</b>.</p></div><div class="trigger-toolbar"><select data-trigger-filter><option value="">Все элементы</option>${triggerSourceOptions(filterActionId).replace('<option value="">— выберите источник —</option>', '')}</select><button type="button" class="btn trigger-account-events ${accountEventsClass}" data-account-events-toggle>${accountEventsLabel}</button><button type="button" class="btn" data-save-triggers>💾 Сохранить</button><button type="button" class="btn btn-accent" data-add-trigger>+ Добавить триггер</button></div><div class="trigger-list"></div></div>`);
|
||
const editor = el.modalHost.querySelector(".trigger-editor");
|
||
const filter = editor.querySelector("[data-trigger-filter]");
|
||
filter.value = filterActionId;
|
||
const renderList = () => {
|
||
const list = editor.querySelector(".trigger-list");
|
||
const items = state.config.triggers.filter((trigger) => !filter.value || trigger.source_action_id === filter.value);
|
||
list.innerHTML = items.length ? "" : '<div class="empty-state">Триггеров для выбранного элемента пока нет.</div>';
|
||
items.forEach((trigger) => list.appendChild(triggerCard(trigger, renderList)));
|
||
updateTriggersCount();
|
||
scheduleStyledControls();
|
||
};
|
||
filter.addEventListener("change", renderList);
|
||
editor.querySelector("[data-account-events-toggle]")?.addEventListener("click", async (event) => {
|
||
await toggleRuntimeEventsPreference();
|
||
const button = event.currentTarget;
|
||
button.classList.toggle("is-on", state.operatorToastsEnabled !== false);
|
||
button.classList.toggle("is-off", state.operatorToastsEnabled === false);
|
||
button.textContent = state.operatorToastsEnabled !== false ? "🔔 Toast: ВКЛ" : "🔕 Toast: ВЫКЛ";
|
||
});
|
||
editor.querySelector("[data-save-triggers]")?.addEventListener("click", async (event) => {
|
||
const button = event.currentTarget;
|
||
if (button.dataset.busy === "1") return;
|
||
button.dataset.busy = "1";
|
||
button.disabled = true;
|
||
const original = button.textContent;
|
||
button.textContent = "Сохранение…";
|
||
try {
|
||
await hockeyPersistTriggers();
|
||
toast("Триггеры сохранены");
|
||
renderList();
|
||
} catch (error) {
|
||
toast(`Не удалось сохранить триггеры: ${String(error?.message || error)}`, true);
|
||
} finally {
|
||
delete button.dataset.busy;
|
||
button.disabled = false;
|
||
button.textContent = original;
|
||
}
|
||
});
|
||
editor.querySelector("[data-add-trigger]").addEventListener("click", () => {
|
||
const trigger = createTrigger(filter.value || filterActionId);
|
||
state.config.triggers.push(trigger);
|
||
state.triggerEditorOpenIds.add(trigger.id);
|
||
renderList();
|
||
});
|
||
renderList();
|
||
}
|
||
|
||
function triggerCard(trigger, rerender) {
|
||
const card = document.createElement("article");
|
||
const isOpen = state.triggerEditorOpenIds.has(trigger.id);
|
||
card.className = `trigger-card ${isOpen ? "is-open" : "is-collapsed"}`;
|
||
card.dataset.triggerId = trigger.id;
|
||
const summary = `${trigger.source_action_id || "Источник не выбран"} · ${trigger.event || "событие"}${trigger.item_id ? ` · ${trigger.item_id}` : ""}`;
|
||
card.innerHTML = `
|
||
<div class="trigger-card-head">
|
||
<button type="button" class="trigger-collapse-btn" data-toggle-trigger aria-expanded="${isOpen ? "true" : "false"}" title="${isOpen ? "Свернуть" : "Развернуть"}">${isOpen ? "⌄" : "›"}</button>
|
||
<label class="trigger-enabled"><input type="checkbox" data-path="enabled" ${trigger.enabled ? "checked" : ""}> Включён</label>
|
||
<input type="text" data-path="name" value="${escapeHtml(trigger.name)}" aria-label="Название триггера">
|
||
<code class="trigger-head-summary">${escapeHtml(summary)}</code>
|
||
<button type="button" class="btn trigger-edit-btn" data-toggle-trigger>${isOpen ? "Свернуть" : "Редактировать"}</button>
|
||
<button type="button" class="icon-btn danger" data-delete-trigger title="Удалить">×</button>
|
||
</div>
|
||
<div class="trigger-card-body ${isOpen ? "" : "hidden"}">
|
||
<div class="trigger-flow">
|
||
<section><h4>1. Источник</h4>
|
||
<label>Action ID<select data-path="source_action_id">${triggerSourceOptions(trigger.source_action_id)}</select></label>
|
||
<label>Событие<select data-path="event">${triggerSelectOptions([["click","click — нажатие"],["pointer_down","pointer_down — нажали"],["pointer_up","pointer_up — отпустили"],["change","change — значение изменилось"],["state_change","state_change — статус изменился"],["tab_change","tab_change — вкладка изменилась"],["tab_enter","tab_enter — вкладка открыта"],["tab_leave","tab_leave — вкладка закрыта"],["page_change","page_change — страница"],["open","open — открытие"],["timer_start","timer_start — запуск"],["timer_pause","timer_pause — пауза"],["timer_resume","timer_resume — продолжение"],["timer_stop","timer_stop — остановка"],["timer_reset","timer_reset — сброс"],["timer_restart","timer_restart — заново"],["timer_tick","timer_tick — каждую секунду"],["timer_reached","timer_reached — контрольная точка"],["timer_finished","timer_finished — завершение"],["player_selected","player_selected — выбран игрок"],["player_dropped","player_dropped — игрок перенесён"],["penalty_selected","penalty_selected — удаление выбрано для подсмотра"],["infraction_selected","infraction_selected — выбрано нарушение"],["penalty_draft_created","penalty_draft_created — создана заготовка"],["penalty_updated","penalty_updated — событие дополнено"],["penalty_ready","penalty_ready — событие готово"],["penalty_assigned","penalty_assigned — штраф подготовлен"],["penalty_started","penalty_started — таймер запущен"],["penalty_paused","penalty_paused — таймер на паузе"],["penalty_finished","penalty_finished — штраф завершён"],["penalty_removed","penalty_removed — штраф удалён"]], trigger.event)}</select></label>
|
||
${triggerItemIdFieldHtml(trigger)}
|
||
</section>
|
||
<section><h4>2. Условие <small>необязательно</small></h4>
|
||
<label>Поле события<input type="text" data-path="condition.field" value="${escapeHtml(trigger.condition.field)}" placeholder="state.active или value"></label>
|
||
<label>Оператор<select data-path="condition.operator">${triggerSelectOptions([["equals","Равно"],["not_equals","Не равно"],["truthy","Истина"],["falsy","Ложь"],["contains","Содержит"],["greater","Больше"],["less","Меньше"]], trigger.condition.operator)}</select></label>
|
||
<label>Значение<input type="text" data-path="condition.value" value="${escapeHtml(trigger.condition.value)}" placeholder="true, 10, LIVE"></label>
|
||
</section>
|
||
<section class="trigger-action-section"><h4>3. Действие</h4>
|
||
<label>Тип<select data-path="action.type">${triggerSelectOptions([
|
||
["show_message","Показать сообщение"],["call_function","Вызвать функцию проекта"],["dispatch_event","Отправить JS-событие"],
|
||
["set_state","Установить статус"],["toggle_state","Переключить статус"],["set_value","Установить значение"],
|
||
["run_sequence","Запустить сценарий / шорткат"],["timer_command","Управлять таймером"],
|
||
["show_component","Показать элемент"],["hide_component","Скрыть элемент"],["toggle_component","Переключить видимость"],
|
||
["set_tab","Открыть вкладку"],["refresh_data","Обновить данные"],["open_url","Открыть URL"],["http_request","HTTP-запрос"]
|
||
], trigger.action.type)}</select></label>
|
||
<div class="action-field" data-for="run_sequence"><label>Сценарий<select data-path="action.sequence_id">${shortcutSequenceOptions(trigger.action.sequence_id)}</select></label></div>
|
||
<div class="action-field" data-for="set_state toggle_state set_value show_component hide_component toggle_component timer_command"><label>Целевой Action ID<select data-path="action.target_action_id">${interactiveOptions(trigger.action.target_action_id)}</select></label></div>
|
||
<div class="action-field" data-for="timer_command"><label>Команда<select data-path="action.timer_command">${triggerSelectOptions([["toggle","Старт / пауза"],["start","Запустить"],["pause","Пауза"],["resume","Продолжить"],["stop","Остановить"],["reset","Сбросить"],["restart","Запустить заново"],["set_time","Установить время"],["add_time","Добавить время"],["subtract_time","Вычесть время"]], trigger.action.timer_command)}</select></label><label>Время / величина<input type="text" data-path="action.timer_value" value="${escapeHtml(trigger.action.timer_value)}" placeholder="00:30"></label></div>
|
||
<div class="action-field" data-for="set_state toggle_state"><label>Ключ статуса<input type="text" data-path="action.state_key" value="${escapeHtml(trigger.action.state_key)}" placeholder="active"></label></div>
|
||
<div class="action-field" data-for="set_state set_value"><label>Значение<input type="text" data-path="action.value" value="${escapeHtml(trigger.action.value)}" placeholder="true или {{value}}"></label></div>
|
||
<div class="action-field" data-for="call_function"><label>Имя функции<input type="text" data-path="action.function_name" value="${escapeHtml(trigger.action.function_name)}" placeholder="start_timer"></label></div>
|
||
<div class="action-field" data-for="dispatch_event"><label>Имя JS-события<input type="text" data-path="action.event_name" value="${escapeHtml(trigger.action.event_name)}"></label></div>
|
||
<div class="action-field" data-for="show_message http_request"><label>Сообщение при успехе<input type="text" data-path="action.message" value="${escapeHtml(trigger.action.message)}" placeholder="Команда выполнена"></label></div>
|
||
<div class="action-field" data-for="set_tab"><label>Вкладка<select data-path="action.tab_id">${state.config.tabs.map((tab) => `<option value="${escapeHtml(tab.id)}" ${tab.id === trigger.action.tab_id ? "selected" : ""}>${escapeHtml(tab.label)} · ${escapeHtml(tab.id)}</option>`).join("")}</select></label></div>
|
||
<div class="action-field" data-for="open_url http_request"><label>URL<input type="text" data-path="action.url" value="${escapeHtml(trigger.action.url)}" placeholder="/api/start"></label></div>
|
||
<div class="action-field" data-for="http_request"><label>Метод<select data-path="action.method">${triggerSelectOptions([["GET","GET"],["POST","POST"],["PUT","PUT"],["PATCH","PATCH"],["DELETE","DELETE"]], trigger.action.method)}</select></label><label>JSON body<textarea data-path="action.body" rows="3" placeholder='{"id":"{{action_id}}"}'>${escapeHtml(trigger.action.body)}</textarea></label></div>
|
||
</section>
|
||
</div>
|
||
<div class="trigger-card-foot"><code>${escapeHtml(trigger.source_action_id || "action_id")} · ${escapeHtml(trigger.event)}${trigger.item_id ? ` · ${escapeHtml(trigger.item_id)}` : ""}</code><span>Шаблоны: {{value}}, {{state.active}}, {{data.event.title}}</span></div>
|
||
</div>`;
|
||
|
||
card.querySelectorAll("[data-path]").forEach((input) => {
|
||
const path = input.dataset.path;
|
||
const eventName = input.type === "checkbox" || input.tagName === "SELECT" ? "change" : "input";
|
||
input.addEventListener(eventName, () => {
|
||
const value = input.type === "checkbox" ? input.checked : input.value;
|
||
setNested(trigger, path, value);
|
||
if (path === "action.type") updateTriggerActionFields(card, value);
|
||
if (["source_action_id","event","item_id"].includes(path)) {
|
||
const text = `${trigger.source_action_id || "action_id"} · ${trigger.event}${trigger.item_id ? ` · ${trigger.item_id}` : ""}`;
|
||
const code = card.querySelector(".trigger-card-foot code");
|
||
if (code) code.textContent = text;
|
||
const headSummary = card.querySelector(".trigger-head-summary");
|
||
if (headSummary) headSummary.textContent = text;
|
||
}
|
||
if (["source_action_id", "event"].includes(path)) {
|
||
if (path === "source_action_id") {
|
||
trigger.item_id = "";
|
||
if ([PROJECT_TABS_ACTION_ID, UI_NAVIGATION_ACTION_ID].includes(String(value || ""))) trigger.event = "tab_change";
|
||
}
|
||
rerender();
|
||
return;
|
||
}
|
||
});
|
||
});
|
||
card.querySelectorAll("[data-toggle-trigger]").forEach((button) => button.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
if (state.triggerEditorOpenIds.has(trigger.id)) state.triggerEditorOpenIds.delete(trigger.id);
|
||
else state.triggerEditorOpenIds.add(trigger.id);
|
||
rerender();
|
||
}));
|
||
card.querySelector("[data-delete-trigger]").addEventListener("click", () => {
|
||
state.triggerEditorOpenIds.delete(trigger.id);
|
||
state.config.triggers = state.config.triggers.filter((item) => item.id !== trigger.id);
|
||
rerender(); renderInspector();
|
||
});
|
||
updateTriggerActionFields(card, trigger.action.type);
|
||
return card;
|
||
}
|
||
|
||
function triggerSelectOptions(options, selected) {
|
||
return options.map(([value, label]) => `<option value="${escapeHtml(value)}" ${String(value) === String(selected) ? "selected" : ""}>${escapeHtml(label)}</option>`).join("");
|
||
}
|
||
function setNested(object, path, value) {
|
||
const keys = path.split("."); let target = object;
|
||
keys.slice(0, -1).forEach((key) => { target[key] ||= {}; target = target[key]; });
|
||
target[keys.at(-1)] = value;
|
||
}
|
||
function updateTriggerActionFields(card, actionType) {
|
||
card.querySelectorAll(".action-field").forEach((field) => field.classList.toggle("hidden", !field.dataset.for.split(" ").includes(actionType)));
|
||
}
|
||
|
||
async function showBackups() {
|
||
try {
|
||
const payload = await api("/backups");
|
||
const items = payload.items || [];
|
||
const body = `<div class="backup-list">${items.length ? items.map((item) => `<div class="backup-row"><div><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.modified)} · ${Math.round(item.size / 1024)} KB</small></div><button class="btn" data-restore="${escapeHtml(item.name)}">Восстановить</button></div>`).join("") : "<p>Резервных копий пока нет. Они создаются при сохранении.</p>"}</div>`;
|
||
showSettingsModal("Резервные копии", body);
|
||
el.modalHost.querySelectorAll("[data-restore]").forEach((button) => button.addEventListener("click", async () => {
|
||
try {
|
||
const restored = await api("/backups/restore", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: button.dataset.restore }) });
|
||
state.config = restored.config; ensureConfig(); state.activeTab = state.config.tabs[0]?.id || "main"; state.selectedId = null; closeModal(); syncTopControls(); await loadData(); toast("Копия восстановлена");
|
||
} catch (error) { toast(`Ошибка: ${error.message}`, true); }
|
||
}));
|
||
} catch (error) { toast(`Ошибка копий: ${error.message}`, true); }
|
||
}
|
||
|
||
function showModal(title, bodyHtml, options = {}) {
|
||
const locked = Boolean(options.locked);
|
||
const className = String(options.className || "").trim();
|
||
state.modalLocked = locked;
|
||
el.modalHost.innerHTML = `<div class="modal-backdrop ${locked ? "is-locked" : ""}"><div class="modal-card ${escapeHtml(className)}"><div class="modal-head"><strong>${escapeHtml(title || "Окно")}</strong>${locked ? '<span class="modal-lock-hint">🔒 закрытие только кнопкой</span>' : ''}<button type="button" data-close-modal title="Закрыть">×</button></div>${bodyHtml}</div></div>`;
|
||
el.modalHost.querySelectorAll("[data-tooltip][title]").forEach((node) => node.removeAttribute("title"));
|
||
el.modalHost.querySelector("[data-close-modal]").addEventListener("click", () => {
|
||
if (!locked || window.confirm(`Закрыть окно «${title || "Настройки"}»? Несохранённые изменения могут быть потеряны.`)) closeModal();
|
||
});
|
||
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");
|
||
scheduleRuntimeScale();
|
||
scheduleStyledControls();
|
||
}
|
||
|
||
function showSettingsModal(title, bodyHtml, options = {}) {
|
||
const className = ["modal-settings-full", String(options.className || "").trim()].filter(Boolean).join(" ");
|
||
showModal(title, bodyHtml, { ...options, locked: true, className });
|
||
}
|
||
|
||
function closeModal() {
|
||
closeControlPopover();
|
||
if (state.shortcutCapture) { state.shortcutCapture = null; state.pressedShortcutModifiers.clear(); }
|
||
clearInterval(state.timerQuickEditorInterval);
|
||
state.timerQuickEditorInterval = null;
|
||
state.modalLocked = false;
|
||
el.modalHost.innerHTML = "";
|
||
renderStandaloneHockeyPlayByPlayWindow();
|
||
scheduleRuntimeScale();
|
||
}
|
||
|
||
function selectedComponent() { return state.config.components.find((item) => item.id === state.selectedId); }
|
||
|
||
function duplicateSelected() {
|
||
const current = selectedComponent();
|
||
if (!current) return;
|
||
const originals = [current, ...descendantsOf(current.id)];
|
||
const idMap = new Map(originals.map((item) => [item.id, uid()]));
|
||
const maxZ = Math.max(...state.config.components.map((item) => Number(item.z) || 0), 0);
|
||
const bounds = {
|
||
maxX: Math.max(...originals.map((item) => item.x + item.w)),
|
||
maxY: Math.max(...originals.map((item) => item.y + item.h)),
|
||
};
|
||
const dx = Math.min(20, Math.max(0, state.config.canvas.width - bounds.maxX));
|
||
const dy = Math.min(20, Math.max(0, state.config.canvas.height - bounds.maxY));
|
||
|
||
const copies = originals.map((original, index) => {
|
||
const copy = clone(original);
|
||
copy.id = idMap.get(original.id);
|
||
copy.title = original.id === current.id ? `${original.title} — копия` : original.title;
|
||
copy.x = original.x + dx;
|
||
copy.y = original.y + dy;
|
||
copy.z = maxZ + index + 1;
|
||
copy.parent_id = idMap.get(original.parent_id) || original.parent_id || null;
|
||
return copy;
|
||
});
|
||
state.config.components.push(...copies);
|
||
state.selectedId = idMap.get(current.id);
|
||
renderCanvas();
|
||
renderInspector();
|
||
renderRuntime();
|
||
}
|
||
|
||
function deleteSelected() {
|
||
if (!state.selectedId) return;
|
||
const removedId = state.selectedId;
|
||
state.config.components.forEach((item) => {
|
||
if (item.parent_id === removedId) item.parent_id = null;
|
||
});
|
||
state.config.components = state.config.components.filter((item) => item.id !== removedId);
|
||
state.selectedId = null;
|
||
renderCanvas();
|
||
renderInspector();
|
||
renderRuntime();
|
||
}
|
||
|
||
function bringFront() { const item = selectedComponent(); if (!item) return; item.z = Math.max(...state.config.components.map((x) => Number(x.z) || 0), 0) + 1; renderCanvas(); renderInspector(); }
|
||
function sendBack() { const item = selectedComponent(); if (!item) return; item.z = Math.min(...state.config.components.map((x) => Number(x.z) || 0), 0) - 1; renderCanvas(); renderInspector(); }
|
||
|
||
function bindEvents() {
|
||
document.querySelectorAll("[data-template]").forEach((button) => button.addEventListener("click", () => setTemplate(button.dataset.template)));
|
||
el.componentSearch?.addEventListener("input", () => renderLibrary(el.componentSearch.value));
|
||
el.addTabBtn?.addEventListener("click", addTab);
|
||
el.activeTabSelect?.addEventListener("change", () => { state.activeTab = el.activeTabSelect.value; renderCanvas(); renderInspector(); });
|
||
el.showAllTabs?.addEventListener("change", () => { state.showAllTabs = el.showAllTabs.checked; renderCanvas(); });
|
||
el.snapEnabled?.addEventListener("change", () => { state.config.canvas.snap_enabled = el.snapEnabled.checked; });
|
||
el.gridEnabled?.addEventListener("change", () => { state.config.canvas.show_grid = el.gridEnabled.checked; renderCanvas(); });
|
||
el.autoBindEnabled?.addEventListener("change", () => { state.config.canvas.auto_bind_containers = el.autoBindEnabled.checked; });
|
||
el.canvasWidth?.addEventListener("change", () => { state.config.canvas.width = clamp(Number(el.canvasWidth.value) || 1440, 640, 7680); renderCanvas(); });
|
||
el.canvasHeight?.addEventListener("change", () => { state.config.canvas.height = clamp(Number(el.canvasHeight.value) || 900, 360, 4320); renderCanvas(); });
|
||
el.gridSize?.addEventListener("change", () => { state.config.canvas.grid_size = clamp(Number(el.gridSize.value) || 10, 1, 100); renderCanvas(); });
|
||
el.snapThreshold?.addEventListener("change", () => { state.config.canvas.snap_threshold = clamp(Number(el.snapThreshold.value) || 8, 1, 50); });
|
||
el.canvasBackground?.addEventListener("input", () => {
|
||
state.config.canvas.background = el.canvasBackground.value || "#0c1421";
|
||
if (el.canvasBackgroundText) el.canvasBackgroundText.value = state.config.canvas.background;
|
||
renderCanvas();
|
||
renderRuntime();
|
||
});
|
||
el.canvasBackgroundText?.addEventListener("input", () => {
|
||
state.config.canvas.background = el.canvasBackgroundText.value.trim() || "#0c1421";
|
||
if (el.canvasBackground) {
|
||
el.canvasBackground.value = pickerColor(state.config.canvas.background, el.canvasBackground.value || "#0c1421");
|
||
refreshEnhancedControl(el.canvasBackground);
|
||
}
|
||
renderCanvas();
|
||
renderRuntime();
|
||
});
|
||
el.canvasBackgroundClear?.addEventListener("click", () => {
|
||
state.config.canvas.background = "#0c1421";
|
||
syncCanvasBackgroundControls(state.config.canvas.background);
|
||
renderCanvas();
|
||
renderRuntime();
|
||
});
|
||
el.zoomOutBtn?.addEventListener("click", () => { state.zoom = clamp(state.zoom - .1, .3, 1.5); renderCanvas(); });
|
||
el.zoomInBtn?.addEventListener("click", () => { state.zoom = clamp(state.zoom + .1, .3, 1.5); renderCanvas(); });
|
||
el.duplicateBtn?.addEventListener("click", duplicateSelected);
|
||
el.deleteBtn?.addEventListener("click", deleteSelected);
|
||
el.bringFrontBtn?.addEventListener("click", bringFront);
|
||
el.sendBackBtn?.addEventListener("click", sendBack);
|
||
el.reloadDataBtn?.addEventListener("click", () => loadData(true));
|
||
el.quickTimersBtn?.addEventListener("click", () => openTimerQuickEditor());
|
||
el.runtimeEventsToggleBtn?.addEventListener("click", () => toggleRuntimeEventsPreference());
|
||
el.runtimeLogoutBtn?.addEventListener("click", logoutHockeyAccount);
|
||
el.runtimeEditorBtn?.addEventListener("click", openEditorPinDialog);
|
||
el.previewBtn?.addEventListener("click", showPreview);
|
||
el.closePreviewBtn?.addEventListener("click", closePreview);
|
||
el.saveBtn?.addEventListener("click", () => saveConfig());
|
||
el.publishBtn?.addEventListener("click", publishConfig);
|
||
el.openRuntimeBtn?.addEventListener("click", () => { window.location.href = boot.runtimeUrl; });
|
||
el.lockEditorBtn?.addEventListener("click", logoutEditor);
|
||
el.shortcutsBtn?.addEventListener("click", showShortcutsEditor);
|
||
el.runtimeShortcutsBtn?.addEventListener("click", showShortcutsReference);
|
||
el.triggersBtn?.addEventListener("click", () => showTriggersEditor());
|
||
el.backupsBtn?.addEventListener("click", showBackups);
|
||
el.exportBtn?.addEventListener("click", exportConfig);
|
||
el.importInput?.addEventListener("change", () => { const file = el.importInput.files?.[0]; if (file) importConfig(file); el.importInput.value = ""; });
|
||
el.projectName?.addEventListener("input", () => { state.config.project_name = el.projectName.value; });
|
||
el.dataSource?.addEventListener("change", async () => { state.config.data_source = el.dataSource.value; await loadData(); });
|
||
window.addEventListener("resize", scheduleRuntimeScale);
|
||
window.visualViewport?.addEventListener("resize", scheduleRuntimeScale);
|
||
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");
|
||
} else {
|
||
renderStandaloneHockeyPlayByPlayWindow();
|
||
}
|
||
scheduleRuntimeScale();
|
||
});
|
||
window.addEventListener("hockey:settings-updated", () => {
|
||
const gameId = hockeyTimerSelectedGameId();
|
||
if (gameId) hockeyLoadGameControl(gameId, { force: true, rerender: false }).then(() => {
|
||
if (hockeyTeamStateScoreboardIsLive()) hockeySyncTeamStateOverlays({ force: true }).catch(() => {});
|
||
});
|
||
});
|
||
window.addEventListener("hockey:game-selected", (event) => {
|
||
const gameId = String(event?.detail?.game?.external_id || event?.detail?.game?.id || "").trim();
|
||
if (!gameId) return;
|
||
// Live Stat2TV polling emits this event every second. Timer state must
|
||
// only be switched when the operator actually selects another match.
|
||
if (gameId !== String(state.hockeyTimerGameId || "")) {
|
||
hockeyActivateGameTimers(gameId);
|
||
} else if (!state.hockeyGameControl[gameId]) {
|
||
hockeyLoadGameControl(gameId, { force: false, rerender: false });
|
||
}
|
||
});
|
||
window.addEventListener("beforeunload", () => {
|
||
const gameId = hockeyTimerSelectedGameId();
|
||
if (gameId && !state.hockeyTimerHydrating) {
|
||
hockeyPersistGameTimers(gameId, { keepalive: true, force: true });
|
||
}
|
||
});
|
||
window.addEventListener("hockey:game-control-updated", (event) => {
|
||
const payload = event?.detail?.control;
|
||
const gameId = String(event?.detail?.game_id || payload?.game_id || "").trim();
|
||
if (!payload || !gameId) return;
|
||
hockeyApplyTimerRules(payload);
|
||
state.hockeyGameControl[gameId] = payload;
|
||
if (event?.detail?.apply_timers && gameId === hockeyTimerSelectedGameId() && payload.timers) {
|
||
state.hockeyTimerHydrating = true;
|
||
try {
|
||
hockeyApplySavedTimers(gameId, payload.timers);
|
||
state.hockeyTimerDirty = false;
|
||
state.hockeyTimerRevision += 1;
|
||
} finally {
|
||
state.hockeyTimerHydrating = false;
|
||
}
|
||
}
|
||
if (state.activeTab === "shootout") renderRuntime();
|
||
});
|
||
// Capture phase is intentional: shortcuts must run before focused runtime
|
||
// controls (for example the match header) receive Space/Enter themselves.
|
||
window.addEventListener("keydown", (event) => {
|
||
syncShortcutModifiersFromEvent(event, true);
|
||
if (!shortcutModifierFromEvent(event) && state.pressedShortcutModifiers.size) {
|
||
state.modifierShortcutChordUsedKey = true;
|
||
}
|
||
if (editorHotkeyPressed(event)) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (boot.mode === "editor") window.location.href = boot.runtimeUrl;
|
||
else openEditorPinDialog();
|
||
return;
|
||
}
|
||
if (handleShortcutCapture(event)) return;
|
||
if (handleConfiguredShortcuts(event)) return;
|
||
if (event.key === "Escape") {
|
||
if (el.modalHost.innerHTML) {
|
||
if (!state.modalLocked) closeModal();
|
||
else toast("Это окно защищено от случайного закрытия. Используйте кнопку «Закрыть».");
|
||
} else if (state.preview) closePreview();
|
||
}
|
||
const editing = ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement?.tagName);
|
||
if (editing) return;
|
||
if (event.key === "Delete") deleteSelected();
|
||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "d") { event.preventDefault(); duplicateSelected(); }
|
||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") { event.preventDefault(); saveConfig(); }
|
||
const item = selectedComponent();
|
||
if (item && ["ArrowLeft","ArrowRight","ArrowUp","ArrowDown"].includes(event.key) && !item.locked) {
|
||
event.preventDefault();
|
||
const step = event.shiftKey ? 10 : 1;
|
||
const dx = event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0;
|
||
const dy = event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0;
|
||
moveHierarchyBy(item, dx, dy);
|
||
renderCanvas();
|
||
renderRuntime();
|
||
updateGeometryInspector(item);
|
||
}
|
||
}, true);
|
||
|
||
window.addEventListener("keyup", (event) => {
|
||
const modifier = shortcutModifierFromEvent(event);
|
||
if (!modifier) return;
|
||
|
||
if (state.shortcutCapture && !state.modifierShortcutChordUsedKey) {
|
||
const captureCombo = modifierOnlyShortcutCombo();
|
||
if (captureCombo) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
finishShortcutCapture(captureCombo);
|
||
state.modifierShortcutChordFired = true;
|
||
}
|
||
} else {
|
||
handleModifierOnlyShortcutRelease(event);
|
||
}
|
||
|
||
state.pressedShortcutModifiers.delete(modifier);
|
||
if (state.pressedShortcutModifiers.size === 0) {
|
||
state.modifierShortcutChordModifiers.clear();
|
||
state.modifierShortcutChordUsedKey = false;
|
||
state.modifierShortcutChordFired = false;
|
||
}
|
||
}, true);
|
||
|
||
window.addEventListener("blur", () => {
|
||
state.pressedShortcutModifiers.clear();
|
||
state.modifierShortcutChordModifiers.clear();
|
||
state.modifierShortcutChordUsedKey = false;
|
||
state.modifierShortcutChordFired = false;
|
||
});
|
||
}
|
||
|
||
async function init() {
|
||
try {
|
||
startStyledControls();
|
||
startCustomTooltips();
|
||
ensureTimerEngine();
|
||
await loadSources();
|
||
await loadConfig();
|
||
bindEvents();
|
||
if ("ResizeObserver" in window && el.runtimeViewport) {
|
||
state.runtimeResizeObserver = new ResizeObserver(scheduleRuntimeScale);
|
||
state.runtimeResizeObserver.observe(el.runtimeViewport);
|
||
}
|
||
decorateStaticNumberInputs();
|
||
await loadData();
|
||
await loadRuntimeEventsPreference();
|
||
if (boot.mode === "runtime") {
|
||
document.querySelectorAll(".editor-only").forEach((node) => node.classList.add("hidden"));
|
||
el.runtimeView.classList.remove("hidden");
|
||
el.closePreviewBtn.classList.add("hidden");
|
||
renderRuntime();
|
||
window.UIBuilderRuntime?.patchData?.({ hockey: { ui: { active_tab: state.activeTab, previous_tab: "" } } }, { render: false });
|
||
rememberUiNavigationState(PROJECT_TABS_ACTION_ID, "project_tab", state.activeTab, state.activeTab, { emit: false });
|
||
hockeyRefreshVmixMappingForTab(state.activeTab).catch(() => {});
|
||
} else {
|
||
document.querySelectorAll(".runtime-only").forEach((node) => node.classList.add("hidden"));
|
||
el.runtimeLogoutBtn?.classList.add("hidden");
|
||
syncTopControls();
|
||
startEditorSessionMonitor();
|
||
}
|
||
} catch (error) {
|
||
toast(`Ошибка запуска: ${error.message}`, true);
|
||
console.error(error);
|
||
}
|
||
}
|
||
|
||
init();
|
||
})();
|