(() => { "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: 22, 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: [], player_selection_panels: [], }, 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(), // BUILD100: track physical non-modifier keys until keyup. Browser key repeat, // focus quirks or duplicated keydown events must never enqueue several toggle actions // for one physical press (Space is especially important for the game clock). pressedShortcutKeys: new Set(), modifierShortcutChordModifiers: new Set(), modifierShortcutChordUsedKey: false, modifierShortcutChordFired: false, runningShortcutSequences: new Set(), // BUILD100: one second physical press while the same sequence is waiting for Agent ACK // is remembered instead of being silently dropped. Extra impatient presses are // coalesced, so they cannot build a future queue of Start/Stop toggles. pendingShortcutSequenceRuns: new Map(), // BUILD90: all runtime vMix requests share one browser-side FIFO. This prevents // two different shortcuts from interleaving commands while an Agent ACK is pending. vmixCommandQueue: Promise.resolve(), vmixCommandQueueDepth: 0, vmixTimerMirrors: new Map(), vmixPenaltyMirrors: new Map(), activeHockeyVmixTimerSteps: new Set(), vmixPenaltyTargetAssignments: new Map(), hockeyPenaltyAdvantageCycle: { hadAdvantage: false, lastAdvantageSide: "" }, hockeyPenaltyMappingContextSignature: "", hockeyPenaltyMappingContextPending: false, hockeyPenaltyMappingContextQueued: false, hockeyPlayerPanelSeedPending: false, hockeyPlayerPanelSeedSignature: "", vmixFinishOverlayTimers: new Map(), vmixStrengthMappingRefreshPending: false, vmixStrengthMappingRefreshQueued: null, vmixTabMappingRefreshPending: false, vmixTabMappingRefreshQueued: "", hockeyTeamStateOverlayActive: new Map(), shortcutSequenceOverlayState: new Map(), quickPanelActiveTab: "", vmixOverlayRuntime: new Map(), quickPanelOnAirSequences: new Set(), quickPanelServerOnAirSequences: new Set(), quickPanelOverlayPollTimer: null, quickPanelOverlayPollPending: false, triggerEditorOpenIds: new Set(), shortcutSequenceOpenId: "", shortcutInventory: { device_id: "", device_name: "", online: false, vmix_connected: false, inventory: { inputs: [] }, devices: [] }, shortcutInventoryLoading: false, shortcutEditorScrollTop: 0, preparedTitles: [], preparedTitleInventory: { device_id: "", device_name: "", inventory: { inputs: [] } }, preparedTitlesLoading: false, preparedTitlesLoadedKey: "", preparedTitleSearch: "", preparedTitleSourceKey: "", preparedTitleFieldValues: {}, preparedTitleName: "", preparedTitleEditingId: "", preparedTitlePanelId: "", preparedTitleMappingSources: [], preparedTitleSnapshotLoading: false, 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, hockeyPlayerSelectionDrag: 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 = ''; 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 = '
Нет вариантов
'; 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 = `${input.type === "time" ? "◷" : input.type === "date" ? "▣" : "◴"}`; 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 = ''; 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) => ``).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 shortcutPhysicalKeyToken(event) { const modifier = shortcutModifierFromEvent(event); if (modifier) return `modifier:${modifier}:${String(event.code || event.key || modifier)}`; const code = String(event.code || "").trim(); if (code) return `key:${code}`; const key = shortcutKeyFromEvent(event); return key ? `key:${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) : "countdown", penalty_vmix_mode: ["countdown", "text"].includes(String(step.penalty_vmix_mode || "")) ? String(step.penalty_vmix_mode) : "countdown", 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 normalizePlayerSelectionPanels(rawPanels) { const source = Array.isArray(rawPanels) ? rawPanels : []; const seen = new Set(); const allowedRules = new Set(["any", "home", "away", "different_teams"]); return source.slice(0, 16).map((panel, index) => { let id = String(panel?.id || `players_${index + 1}`).trim().replace(/[^A-Za-z0-9_]+/g, "_").slice(0, 48).replace(/^_+|_+$/g, "") || `players_${index + 1}`; if (!/^[A-Za-z]/.test(id)) id = `p_${id}`.slice(0, 48); const base = id; let suffix = 2; while (seen.has(id)) id = `${base}_${suffix++}`.slice(0, 48); seen.add(id); const rule = allowedRules.has(String(panel?.rule || "any")) ? String(panel.rule || "any") : "any"; return { id, label: String(panel?.label || `Игроки ${index + 1}`).slice(0, 80), description: String(panel?.description || "").slice(0, 300), slots: clamp(Number(panel?.slots) || 1, 1, 6), rule, sync_selected_player: Boolean(panel?.sync_selected_player), collapsed_default: panel?.collapsed_default !== false, sort_order: Number.isFinite(Number(panel?.sort_order)) ? Number(panel.sort_order) : index * 10, enabled: panel?.enabled !== false, }; }).sort((a, b) => Number(a.sort_order) - Number(b.sort_order) || a.label.localeCompare(b.label, "ru")); } function normalizeShortcutSequence(sequence = {}, index = 0) { return { id: String(sequence.id || `sequence-${index + 1}-${Math.random().toString(36).slice(2, 7)}`), 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: "Игра" }]; // BUILD86: "Заготовки" is always the final top-level runtime tab. // Reorder it on every config normalisation as older published configs may // already contain the tab near "Игра" from BUILD84/85. state.config.tabs = state.config.tabs.filter((tab) => tab?.id !== "prepared_titles"); state.config.tabs.push({ id: "prepared_titles", label: "Заготовки" }); if (state.activeTab === "prematch") state.activeTab = state.config.tabs.find((tab) => tab.id === "main")?.id || state.config.tabs[0].id; } function ensureConfig() { state.config.version = 22; 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 : "", })); state.config.player_selection_panels = normalizePlayerSelectionPanels(state.config.player_selection_panels); 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 = `
${escapeHtml(category)}
`; 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 = `${escapeHtml(item.icon)}${escapeHtml(item.label)}${escapeHtml(item.description)}`; 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: 22, 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) => ``).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: ${escapeHtml(component.action_id)}.`; 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 = `
${escapeHtml(title)}
`; 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, `

${escapeHtml(component.props.modalBody || "")}

`); } } else if (eventName === "open") { if (component.type === "modal") showModal(component.props.modalTitle, `

${escapeHtml(component.props.modalBody || "")}

`); 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; } // BUILD95: a prepared penalty is not an active vMix countdown yet. // It becomes active only after the operator explicitly presses Start. // Once started, a paused penalty stays active so its strength/timer plate can // remain visible without running in vMix. function hockeyPenaltyHasStarted(event) { if (!event || event.finished) return false; if (event.startedOnce === true || event.running) return true; const duration = Math.max(0, Number(event.durationMs || 0)); const remaining = Math.max(0, Number(event.remainingMs ?? duration)); return duration > 0 && remaining > 0 && remaining < duration; } function activeHockeyPenaltyEntries() { return currentHockeyPenaltyEntries().filter(({ event }) => hockeyPenaltyHasStarted(event)); } 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 vmixCountdownSyncCommands(input, selectedName, millisecondsProvider, action = "start") { // BUILD107: Runtime remains the source of truth, but normal operator control no // longer uses StopCountdown because vMix defines it as Stop + Reset. // ChangeCountdown updates the CURRENT countdown position; SetCountdown only changes // Duration. PauseCountdown is used only for an actual Runtime pause transition. const target = { Input: String(input || "").trim(), SelectedName: String(selectedName || "").trim() }; const renderTarget = { Input: target.Input }; const currentValue = () => { const milliseconds = typeof millisecondsProvider === "function" ? millisecondsProvider() : millisecondsProvider; return vmixCountdownValue(milliseconds); }; if (!target.Input || !target.SelectedName) return []; if (action === "pause" || action === "stop") { // Space pause: toggle the native countdown into Pause, then pin its current value // to the exact Runtime time. No StopCountdown reset is allowed in this path. return [ { Function: "PauseRender", ...renderTarget }, { Function: "PauseCountdown", ...target }, { Function: "ChangeCountdown", ...target, Value: currentValue }, { Function: "ResumeRender", ...renderTarget }, ]; } if (action === "set") { // Scoreboard/F1 pre-sync while Runtime is not running: update only the current // vMix position. Do not toggle PauseCountdown because it is a Pause/Resume toggle. return [ { Function: "PauseRender", ...renderTarget }, { Function: "ChangeCountdown", ...target, Value: currentValue }, { Function: "ResumeRender", ...renderTarget }, ]; } // Start/Resume: set the native CURRENT position from Runtime and then run it. // This avoids both StopCountdown reset and SetCountdown duration-only semantics. return [ { Function: "PauseRender", ...renderTarget }, { Function: "ChangeCountdown", ...target, Value: currentValue }, { Function: "ResumeRender", ...renderTarget }, { Function: "StartCountdown", ...target }, ]; } function vmixGameCountdownControlCommands(input, selectedName, millisecondsProvider, action = "start") { // BUILD108: the native vMix game clock is seeded only on an explicit time change. // Space must never re-seed the countdown: Start/Resume only starts it and Pause/Stop // only pauses it. This keeps the already-running native vMix countdown continuous // and removes jumps caused by writing the browser value during a pause transition. const target = { Input: String(input || "").trim(), SelectedName: String(selectedName || "").trim() }; if (!target.Input || !target.SelectedName) return []; if (action === "pause" || action === "stop") { return [{ Function: "PauseCountdown", ...target }]; } if (action === "set") { const milliseconds = typeof millisecondsProvider === "function" ? millisecondsProvider() : millisecondsProvider; return [{ Function: "ChangeCountdown", ...target, Value: vmixCountdownValue(milliseconds) }]; } return [{ Function: "StartCountdown", ...target }]; } 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") || {}; // Legacy BUILD43 shortest-timer behaviour was allEntries.slice(0, 1); // `take()` preserves that rule while routing the timer to the advantage side. 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) { // BUILD98: countdown values may be supplied as functions. They are resolved // only when the command actually reaches the front of the browser vMix queue, // so a delayed Agent/ACK cannot make vMix start several seconds behind Runtime. const source = typeof command === "function" ? command() : command; const result = {}; Object.entries(source || {}).forEach(([key, rawValue]) => { const value = typeof rawValue === "function" ? rawValue() : rawValue; 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); state.shortcutSequenceOverlayState.set(id, true); 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); state.shortcutSequenceOverlayState.set(id, active); 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; if (state.quickPanelServerOnAirSequences.has(id)) return true; 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 applyServerRuntimeOverlayState(payload) { const source = payload?.overlay_state && typeof payload.overlay_state === "object" ? payload.overlay_state : payload; const overlays = source?.overlays && typeof source.overlays === "object" ? source.overlays : {}; const previousServerIds = new Set(state.quickPanelServerOnAirSequences); const nextServerIds = new Set(); for (const layer of ["1", "2", "3", "4"]) { const raw = overlays[layer]; if (raw && typeof raw === "object") { const entry = { input: String(raw.input || ""), sequence_id: String(raw.sequence_id || ""), sequence_name: String(raw.sequence_name || ""), button_id: String(raw.button_id || ""), server_confirmed: true, }; state.vmixOverlayRuntime.set(layer, entry); if (entry.sequence_id) nextServerIds.add(entry.sequence_id); } else { const current = state.vmixOverlayRuntime.get(layer); if (current?.server_confirmed) state.vmixOverlayRuntime.delete(layer); } } state.quickPanelServerOnAirSequences = nextServerIds; previousServerIds.forEach((sequenceId) => { if (nextServerIds.has(sequenceId)) return; if (!sequenceStillOwnsRuntimeOverlay(sequenceId)) { state.quickPanelOnAirSequences.delete(sequenceId); state.shortcutSequenceOverlayState.set(sequenceId, false); } }); nextServerIds.forEach((sequenceId) => { state.quickPanelOnAirSequences.add(sequenceId); state.shortcutSequenceOverlayState.set(sequenceId, true); }); refreshQuickPanelOnAirClasses(); } async function pollQuickPanelOverlayState({ force = false } = {}) { if (state.quickPanelOverlayPollPending && !force) return false; const deviceId = currentRuntimeVmixDeviceId(); if (!deviceId) return false; state.quickPanelOverlayPollPending = true; try { const response = await fetch(`/api/hockey/vmix/overlay-state?device_id=${encodeURIComponent(deviceId)}`, { method: "GET", cache: "no-store", credentials: "same-origin", }); if (!response.ok) return false; const payload = await response.json(); applyServerRuntimeOverlayState(payload); return true; } catch (_) { return false; } finally { state.quickPanelOverlayPollPending = false; } } function startQuickPanelOverlayPolling() { if (state.quickPanelOverlayPollTimer) window.clearInterval(state.quickPanelOverlayPollTimer); pollQuickPanelOverlayState({ force: true }).catch(() => {}); state.quickPanelOverlayPollTimer = window.setInterval(() => { pollQuickPanelOverlayState().catch(() => {}); }, 1000); } 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); state.shortcutSequenceOverlayState.set(String(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); state.shortcutSequenceOverlayState.set(String(previous.sequence_id), false); } if (ownerSequenceId) { setQuickPanelSequenceOnAir(ownerSequenceId, true); state.shortcutSequenceOverlayState.set(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); state.shortcutSequenceOverlayState.set(ownerSequenceId, false); } } else if (parsed.action === "toggle") { const current = state.vmixOverlayRuntime.get(String(parsed.layer)); const sameOwner = Boolean(current && ownerSequenceId && String(current.sequence_id || "") === ownerSequenceId); const sameInput = Boolean(current && String(current.input || "") === String(command?.Input || "")); if (current && (sameOwner || sameInput || !ownerSequenceId)) { clearLayer(parsed.layer); if (ownerSequenceId) { setQuickPanelSequenceOnAir(ownerSequenceId, false); state.shortcutSequenceOverlayState.set(ownerSequenceId, false); } } else { setLayer(parsed.layer, command); } } }); refreshQuickPanelOnAirClasses(); } async function sendRuntimeVmixSequence(commands, execution = null) { // Do not resolve dynamic command values here. A sequence can sit behind a // previous Agent request for a few seconds; countdowns must be sampled at // the instant this queued request is really sent. const run = async () => { const rawCommands = typeof commands === "function" ? commands() : commands; const clean = (rawCommands || []).map(compactVmixCommand).filter((command) => command.Function); if (!clean.length) return { ok: true, applied: 0, results: [] }; state.vmixCommandQueueDepth += 1; const controller = new AbortController(); // BUILD100: runtime vMix delivery is ACKed command-by-command on the server. // The browser timeout must cover the whole small sequence, otherwise fetch can // abort while the server is still legitimately delivering later title/timer commands. const requestTimeoutMs = Math.min(60000, Math.max(20000, 10000 + clean.length * 5000)); const timeoutId = window.setTimeout(() => controller.abort(), requestTimeoutMs); try { const response = await fetch("/api/hockey/vmix/sequence", { method: "POST", cache: "no-store", credentials: "same-origin", signal: controller.signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ commands: clean, device_id: currentRuntimeVmixDeviceId(), session_token: currentRuntimeHockeySessionToken(), sequence_id: String(execution?.sequence_id || ""), sequence_name: String(execution?.sequence_name || ""), button_id: String(execution?.button_id || ""), }), }); 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)); } // BUILD102: interactive shortcuts return as soon as the server has handed // commands to the live Agent WebSocket. `ok` therefore means dispatched to // Agent, not ACK-confirmed by vMix. This keeps F-keys and dock buttons instant. // BUILD53 compatibility marker retained for older regression tests: // trackRuntimeOverlayCommands(clean, execution) const resultRows = Array.isArray(payload?.results) ? payload.results : []; const successfulCommands = clean.filter((_command, index) => { const row = resultRows[index]; return !row || row.ok !== false; }); trackRuntimeOverlayCommands(successfulCommands, execution); if (payload?.overlay_state) applyServerRuntimeOverlayState(payload.overlay_state); if (payload?.ok === false) { const failed = resultRows.filter((row) => row && row.ok === false); const first = failed[0] || {}; const label = first.function || "vMix"; const reason = first.reason || payload?.error || "команда не выполнена"; throw new Error(`${label}: ${reason}${failed.length > 1 ? ` · ошибок ${failed.length}` : ""}`); } return payload; } catch (error) { if (error?.name === "AbortError") { throw new Error(`vMix/Agent не завершил очередь команд за ${Math.round(requestTimeoutMs / 1000)} сек.`); } throw error; } finally { clearTimeout(timeoutId); state.vmixCommandQueueDepth = Math.max(0, state.vmixCommandQueueDepth - 1); } }; // Keep the queue alive after a failed command: one timeout must not permanently // block every shortcut pressed afterwards. No automatic retry is performed because // toggle/overlay commands are not safely idempotent. const queued = state.vmixCommandQueue.catch(() => {}).then(run); state.vmixCommandQueue = queued.catch(() => {}); return queued; } function sendRuntimeVmixTimerSequence(commands) { // BUILD103: timer transport is deliberately independent from the generic // shortcut/title/Mapping ACK queue. The web timer has already changed state before // this function is called. Server delivery_mode=timer-fast-ordered sends native Countdown commands to Agent // immediately without ACK, but as separate ordered WebSocket frames. This avoids any // ambiguity about command ordering inside Agent vmix.batch handling. const rawCommands = typeof commands === "function" ? commands() : commands; const clean = (rawCommands || []).map(compactVmixCommand).filter((command) => command.Function); if (!clean.length) return Promise.resolve({ ok: true, applied: 0, requested: 0, results: [] }); const request = fetch("/api/hockey/vmix/sequence", { method: "POST", cache: "no-store", credentials: "same-origin", keepalive: true, headers: { "Content-Type": "application/json" }, // Intentionally omit sequence_id/button_id. Timer transport is fire-and-forget, // but BUILD106 uses ordered single WebSocket frames rather than vmix.batch. body: JSON.stringify({ commands: clean, device_id: currentRuntimeVmixDeviceId(), session_token: currentRuntimeHockeySessionToken(), delivery_mode: "timer-fast-ordered", }), }).then(async (response) => { let payload = {}; try { payload = await response.json(); } catch (_) {} if (!response.ok) { console.warn("vMix timer sync skipped", payload?.detail || `HTTP ${response.status}`); return { ok: false, applied: 0, requested: clean.length, results: [], error: payload?.detail || `HTTP ${response.status}` }; } const resultRows = Array.isArray(payload?.results) ? payload.results : []; const successfulCommands = clean.filter((_command, index) => { const row = resultRows[index]; return !row || row.ok !== false; }); trackRuntimeOverlayCommands(successfulCommands, null); if (payload?.overlay_state) applyServerRuntimeOverlayState(payload.overlay_state); if (payload?.ok === false) console.warn("vMix timer sync partially failed", payload); return payload; }).catch((error) => { console.warn("vMix timer sync unavailable; Runtime continues locally", error); return { ok: false, applied: 0, requested: clean.length, results: [], error: String(error?.message || error || "vmix_timer_sync_error") }; }); // Do not await this from timer controls. The returned Promise is only useful // for diagnostics/tests; local Runtime state is never rolled back on failure. return request; } 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; } } async function syncActiveVmixGameCountdown(component, timerState, eventName) { if (!component?.action_id || eventName === "timer_tick") return false; const explicitSeedEvent = ["timer_reset", "timer_set_time", "timer_add_time", "timer_subtract_time", "timer_restart"].includes(eventName); const candidateSteps = []; if (explicitSeedEvent) { // BUILD108: an explicit time edit must seed the configured vMix game clock even // before Space has ever been pressed. This is the moment where 20:00 / 05:00 // (or any manual value) is transferred to vMix. (state.config.shortcut_sequences || []).forEach((sequence) => { if (sequence?.enabled === false) return; (sequence.steps || []).forEach((step) => { if (step?.enabled === false || step?.type !== "hockey_vmix_timers_start") return; candidateSteps.push(step); }); }); } else { Array.from(state.activeHockeyVmixTimerSteps).forEach((stepId) => { const step = hockeyTimerSyncStepById(stepId); if (step) candidateSteps.push(step); }); } const commands = []; const seenTargets = new Set(); for (const step of candidateSteps) { if (!step || step.enabled === false || !step.sync_vmix_game || step.game_vmix_mode !== "countdown") continue; if (String(step.game_timer_action_id || "hockey_game_timer") !== String(component.action_id)) continue; const input = String(step.game_vmix_input || "").trim(); const selectedName = String(step.game_vmix_selected_name || "").trim(); if (!input || !selectedName) continue; const targetKey = `${input}\u0000${selectedName}`; if (seenTargets.has(targetKey)) continue; seenTargets.add(targetKey); const valueProvider = () => timerState.currentMs; if (["timer_start", "timer_resume"].includes(eventName)) { commands.push(...vmixGameCountdownControlCommands(input, selectedName, valueProvider, "start")); } else if (eventName === "timer_restart") { commands.push(...vmixGameCountdownControlCommands(input, selectedName, valueProvider, "set")); commands.push(...vmixGameCountdownControlCommands(input, selectedName, valueProvider, "start")); } else if (eventName === "timer_pause") { commands.push(...vmixGameCountdownControlCommands(input, selectedName, valueProvider, "pause")); } else if (["timer_stop", "timer_finished"].includes(eventName)) { commands.push(...vmixGameCountdownControlCommands(input, selectedName, valueProvider, "stop")); } else if (["timer_reset", "timer_set_time", "timer_add_time", "timer_subtract_time"].includes(eventName)) { commands.push(...vmixGameCountdownControlCommands(input, selectedName, valueProvider, "set")); } } if (!commands.length) return false; sendRuntimeVmixTimerSequence(commands); return true; } function configuredHockeyVmixGameCountdownTargets() { const targets = []; const seen = new Set(); (state.config.shortcut_sequences || []).forEach((sequence) => { if (sequence?.enabled === false) return; (sequence.steps || []).forEach((step) => { if (!step || step.enabled === false || step.type !== "hockey_vmix_timers_start") return; if (!step.sync_vmix_game || step.game_vmix_mode !== "countdown") return; const input = String(step.game_vmix_input || "").trim(); const selectedName = String(step.game_vmix_selected_name || "").trim(); if (!input || !selectedName) return; const timer = componentByActionId(step.game_timer_action_id || "hockey_game_timer"); if (!timer || !isTimerComponent(timer)) return; const key = `${input}\u0000${selectedName}`; if (seen.has(key)) return; seen.add(key); targets.push({ input, selectedName, timer, timerState: ensureTimerState(timer) }); }); }); return targets; } async function syncConfiguredScoreboardCountdownsToRuntime() { const commands = []; configuredHockeyVmixGameCountdownTargets().forEach(({ input, selectedName, timerState }) => { // F1 only seeds a stopped/paused game clock. If it is already running, // do not touch its native vMix time at all. if (!timerState.running) { commands.push(...vmixGameCountdownControlCommands( input, selectedName, () => timerState.currentMs, "set" )); } }); if (!commands.length) return { ok: true, applied: 0, requested: 0 }; // Await only WebSocket dispatch (never vMix ACK) so the countdown reseed reaches the // Agent before the scoreboard OverlayIn command. This fixes stale timer values when F1 // shows a scoreboard before Space has ever been pressed. return await sendRuntimeVmixTimerSequence(commands); } 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 activeHockeyPenaltyEntries() .filter((item) => !side || item.side === side) .sort((a, b) => { // BUILD98: a paused penalty cannot be the next real strength transition // while another active penalty is actually running. Prefer running // clocks; only fall back to paused ones when every active clock is paused. const runningOrder = Number(Boolean(b.event?.running)) - Number(Boolean(a.event?.running)); if (runningOrder) return runningOrder; return Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0) || Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0); }); } // BUILD89: the scorebug has only ONE penalty/power-play plate at a time. // The plate belongs to the team that currently has the numerical advantage. // Its timer is the next strength-transition timer: the shortest remaining // active penalty across BOTH benches. Pure coincidental/equal strength shows // no penalty plate at all. When that shortest timer expires we recalculate and // keep the same single plate with the next timer if an advantage still exists. function penaltyLocalAdvantageSide(homeCount, awayCount) { const strength = getByPath(state.data, "hockey.game_control.strength") || {}; const authoritativeHome = Number(strength.home_penalties); const authoritativeAway = Number(strength.away_penalties); if (Number.isFinite(authoritativeHome) && Number.isFinite(authoritativeAway) && authoritativeHome === homeCount && authoritativeAway === awayCount) { const side = String(strength.advantage_side || ""); if (side === "home" || side === "away") return side; return ""; } const base = Math.max(3, Math.min(6, Number(strength.base_skaters || 5))); const minimum = Math.max(2, Math.min(base, Number(strength.minimum_skaters || 3))); const mode = String(strength.penalty_mode || "subtract"); let homeSkaters; let awaySkaters; if (mode === "add_opponent") { homeSkaters = Math.min(5, base + awayCount); awaySkaters = Math.min(5, base + homeCount); } else { homeSkaters = Math.max(minimum, base - homeCount); awaySkaters = Math.max(minimum, base - awayCount); } return homeSkaters > awaySkaters ? "home" : awaySkaters > homeSkaters ? "away" : ""; } // BUILD96: one persistent strength-transition plate. // When the game moves from a real advantage into temporary equal strength // (for example 4x5 -> 3x4 -> 4x4), keep the same plate on the team that held // the advantage. Only the configured strength caption and the next transition // countdown change. A pure coincidental 4x4 that did not originate from an // advantage still produces no plate. function penaltyDisplayEntriesByTargetSide(step) { const home = sortedPenaltyEntries("home"); const away = sortedPenaltyEntries("away"); const all = [...home, ...away].sort((a, b) => { const runningOrder = Number(Boolean(b.event?.running)) - Number(Boolean(a.event?.running)); if (runningOrder) return runningOrder; return Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0) || Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0); }); if (!all.length) { return { home: [], away: [], routedToAdvantage: false, advantageSide: "", plateSide: "", holdingEqualStrength: false, transitionEntry: null }; } const advantageSide = penaltyLocalAdvantageSide(home.length, away.length); const transitionEntry = all[0] || null; if (!advantageSide) { const rememberedSide = String(state.hockeyPenaltyAdvantageCycle.lastAdvantageSide || ""); const canHoldEqualStrength = Boolean(state.hockeyPenaltyAdvantageCycle.hadAdvantage) && ["home", "away"].includes(rememberedSide); if (!canHoldEqualStrength) { return { home: [], away: [], routedToAdvantage: false, advantageSide: "", plateSide: "", holdingEqualStrength: false, transitionEntry }; } return { home: rememberedSide === "home" && transitionEntry ? [transitionEntry] : [], away: rememberedSide === "away" && transitionEntry ? [transitionEntry] : [], routedToAdvantage: false, advantageSide: "", plateSide: rememberedSide, holdingEqualStrength: true, transitionEntry, }; } return { home: advantageSide === "home" && transitionEntry ? [transitionEntry] : [], away: advantageSide === "away" && transitionEntry ? [transitionEntry] : [], routedToAdvantage: true, advantageSide, plateSide: advantageSide, holdingEqualStrength: false, transitionEntry, }; } function rememberPenaltyAdvantagePlan(displayPlan) { if (!displayPlan?.routedToAdvantage || !["home", "away"].includes(String(displayPlan.advantageSide || ""))) return; const nextSide = String(displayPlan.advantageSide); const changed = !state.hockeyPenaltyAdvantageCycle.hadAdvantage || String(state.hockeyPenaltyAdvantageCycle.lastAdvantageSide || "") !== nextSide; state.hockeyPenaltyAdvantageCycle.hadAdvantage = true; state.hockeyPenaltyAdvantageCycle.lastAdvantageSide = nextSide; // BUILD96: persist the owner immediately. This matters if Runtime is reloaded // during the following temporary equal-strength phase (for example 4x4). if (changed && !state.hockeyTimerHydrating) hockeyScheduleTimerSave(true); } function resetPenaltyAdvantageCycle() { state.hockeyPenaltyAdvantageCycle.hadAdvantage = false; state.hockeyPenaltyAdvantageCycle.lastAdvantageSide = ""; } function penaltyFullStrengthSide() { const advantage = String(state.hockeyPenaltyAdvantageCycle.lastAdvantageSide || ""); return advantage === "home" ? "away" : advantage === "away" ? "home" : ""; } function hockeyPenaltySideMappingDetail(item, side) { if (!item?.event) return null; const event = item.event; const playerIds = hockeyPenaltyPlayerIdentifiers(item.component, event); return { penalty_id: String(event.external_id || event.id || ""), player_id: String(playerIds.externalId || ""), player_db_id: String(playerIds.dbId || ""), team_penalty: Boolean(event.teamPenalty), side: String(side || event.player?.side || event.side || ""), }; } async function hockeySyncPenaltySideMappingContext({ force = false } = {}) { const gameId = String(hockeyTimerSelectedGameId() || "").trim(); if (!gameId) return false; const home = hockeyPenaltySideMappingDetail(sortedPenaltyEntries("home")[0] || null, "home"); const away = hockeyPenaltySideMappingDetail(sortedPenaltyEntries("away")[0] || null, "away"); const values = { active_home_penalty_id: home?.penalty_id || "", active_home_penalty_player_id: home?.player_id || "", active_home_penalty_player_db_id: home?.player_db_id || "", active_home_penalty_team_penalty: home ? (home.team_penalty ? "1" : "0") : "", active_away_penalty_id: away?.penalty_id || "", active_away_penalty_player_id: away?.player_id || "", active_away_penalty_player_db_id: away?.player_db_id || "", active_away_penalty_team_penalty: away ? (away.team_penalty ? "1" : "0") : "", }; const signature = JSON.stringify([gameId, values]); if (!force && signature === state.hockeyPenaltyMappingContextSignature) return false; if (state.hockeyPenaltyMappingContextPending) { state.hockeyPenaltyMappingContextQueued = true; return true; } state.hockeyPenaltyMappingContextPending = 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, context: { game_id: gameId, device_id: currentRuntimeVmixDeviceId(), session_token: currentRuntimeHockeySessionToken(), }, }), }); let payload = {}; try { payload = await response.json(); } catch (_) {} if (!response.ok) { throw new Error(errorDetailText(payload.detail, `HTTP ${response.status}`)); } state.hockeyPenaltyMappingContextSignature = signature; window.UIBuilderRuntime?.patchData?.({ hockey: { active_penalties: { home, away }, }, }, { render: false }); window.dispatchEvent(new CustomEvent("hockey:mapping-context-updated", { detail: { game_id: gameId, values: clone(values) }, })); return true; } catch (error) { console.error("Penalty side Mapping context error", error); return false; } finally { state.hockeyPenaltyMappingContextPending = false; if (state.hockeyPenaltyMappingContextQueued) { state.hockeyPenaltyMappingContextQueued = false; hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {}); } } } function penaltyTargetAssignmentKey(step, side, target) { return `${String(step?.id || "")}:${side}:${String(target?.id || "")}`; } async function rebalanceVmixPenaltyTargets({ force = false, hideUnused = true, preservePausedCountdown = false } = {}) { const outCommands = []; const stopCommands = []; const setCommands = []; const runCommands = []; const inCommands = []; 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; } const countdownMode = step.penalty_vmix_mode === "countdown"; const displayPlan = penaltyDisplayEntriesByTargetSide(step); rememberPenaltyAdvantagePlan(displayPlan); for (const side of ["home", "away"]) { const allTargets = sequencePenaltyTargets(step, side); const soonestOnly = String(step.penalty_display_mode || "soonest") !== "all"; const entries = Array.isArray(displayPlan[side]) ? displayPlan[side] : []; 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); const sourceSide = String(entry.side || entry.event?.side || entry.event?.player?.side || side); const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2"; const assignmentChanged = !previous || previous.eventKey !== eventKey || String(previous.input || "") !== String(target.input) || String(previous.selectedName || "") !== String(target.selected_name); if (previous && countdownMode && assignmentChanged && previous.input && String(previous.input) !== String(target.input)) { stopCommands.push({ Function: "StopCountdown", Input: previous.input, SelectedName: previous.selectedName || target.selected_name }); } if (countdownMode) { state.vmixPenaltyMirrors.delete(eventKey); state.vmixPenaltyTargetAssignments.set(assignmentKey, { eventKey, input: target.input, selectedName: target.selected_name, overlay, sourceSide, targetSide: side, mode: "countdown", running: Boolean(entry.event.running), }); const startingCountdown = Boolean(entry.event.running) && (force || assignmentChanged || previous?.running !== true); if (entry.event.running) { if (force || assignmentChanged || startingCountdown) { // BUILD106: penalty countdowns follow the same Runtime-authoritative rule // as the game clock. Never continue an old native position: Stop first, // then seed the exact web remaining time, then Start. stopCommands.push({ Function: "StopCountdown", Input: target.input, SelectedName: target.selected_name }); setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: () => vmixCountdownValue(entry.event.remainingMs) }); } if (startingCountdown) { runCommands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name }); } } else if (force || assignmentChanged || previous?.running !== false) { // prepared/paused penalty: reseed from Runtime. preservePausedCountdown // remains in the public call signature for compatibility, but native vMix time is // never trusted as the authoritative value anymore. stopCommands.push({ Function: "StopCountdown", Input: target.input, SelectedName: target.selected_name }); setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: () => vmixCountdownValue(entry.event.remainingMs) }); } } else { assignedMirrorKeys.add(eventKey); setVmixPenaltyMirror(entry.component, entry.event, target.input, target.selected_name, { stepId: step.id, targetId: target.id, side: sourceSide, overlay, }); state.vmixPenaltyTargetAssignments.set(assignmentKey, { eventKey, input: target.input, selectedName: target.selected_name, overlay, sourceSide, targetSide: side, mode: "text", }); const value = formatHockeyPenaltyTime(entry.event.remainingMs); const mirror = state.vmixPenaltyMirrors.get(eventKey); if (force || !mirror || mirror.lastValue !== value || previous?.eventKey !== eventKey) { setCommands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: value }); if (mirror) mirror.lastValue = value; } } if (hockeyScoreboardIsLive()) { const targetWasVisible = Boolean(previous?.input) && String(previous.input) === String(target.input) && String(previous.overlay || overlay) === overlay; if (!targetWasVisible) { if (previous?.input && (String(previous.input) !== String(target.input) || String(previous.overlay || overlay) !== overlay)) { const previousOverlay = ["1", "2", "3", "4"].includes(String(previous.overlay || "")) ? String(previous.overlay) : overlay; outCommands.push({ Function: `OverlayInput${previousOverlay}Out`, Input: previous.input }); } inCommands.push({ Function: `OverlayInput${overlay}In`, Input: target.input }); } } } else { if (previous?.mode === "countdown" && previous.input) { stopCommands.push({ Function: "StopCountdown", Input: previous.input, SelectedName: previous.selectedName || target.selected_name }); } 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"; outCommands.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?.mode === "countdown" && previous.input) { stopCommands.push({ Function: "StopCountdown", Input: previous.input, SelectedName: previous.selectedName || target.selected_name }); } 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"; outCommands.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); } } // Old plate OUT/Stop first, then set/start the single current countdown, then IN. const commands = [...outCommands, ...stopCommands, ...setCommands, ...runCommands, ...inCommands]; if (commands.length) sendRuntimeVmixTimerSequence(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) { // BUILD89: the full-strength/final plate is a FINAL transition only. // Do not fire it when a coincidental timer ends while another penalty // is still active, and do not fire it after a purely coincidental // sequence that never produced a numerical advantage. if (Number(meta.remaining_total || 0) > 0) return; if (!Boolean(meta.had_advantage)) return; const fullStrengthSide = String(meta.full_strength_side || ""); if (fullStrengthSide && side !== fullStrengthSide) 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 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 = []; state.activeHockeyVmixTimerSteps.add(step.id); // BUILD95: update the web clocks FIRST. The vMix SetCountdown commands below // are then always seeded from the exact post-action Runtime value. if (step.start_web_game) { if (!controlTimer(step.game_timer_action_id || "hockey_game_timer", pausing ? "pause" : (action === "resume" ? "resume" : "start"), "", { syncVmix: false })) { throw new Error(`Основной таймер «${step.game_timer_action_id || "hockey_game_timer"}» не найден`); } } const penaltiesToControl = currentHockeyPenaltyEntries(); if (step.start_web_penalties) { penaltiesToControl.forEach(({ component, event }) => controlHockeyPenalty(component, event.id, pausing ? "pause" : "start", "", { syncVmix: false })); } const penalties = currentHockeyPenaltyEntries(); if (step.sync_vmix_game && step.game_vmix_input) { if (!gameTimerState || !step.game_vmix_selected_name) { console.warn("vMix game timer sync skipped: timer target is incomplete", { action_id: step.game_timer_action_id || "hockey_game_timer", input: step.game_vmix_input || "", selected_name: step.game_vmix_selected_name || "", }); } else 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) { // BUILD108 Space pause: native pause only, absolutely no time writeback. commands.push(...vmixGameCountdownControlCommands( step.game_vmix_input, step.game_vmix_selected_name, () => gameTimerState.currentMs, "pause" )); } else { // BUILD108 Space start/resume: native start only. The countdown was seeded // when the operator set/reset the period time (or by F1 before first start). commands.push(...vmixGameCountdownControlCommands( step.game_vmix_input, step.game_vmix_selected_name, () => gameTimerState.currentMs, "start" )); } } if (step.sync_vmix_penalties) { const displayPlan = penaltyDisplayEntriesByTargetSide(step); rememberPenaltyAdvantagePlan(displayPlan); 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; state.activeHockeyVmixTimerSteps.add(step.id); [["home", displayPlan.home, activeHomeTargets], ["away", displayPlan.away, activeAwayTargets]].forEach(([side, sideEntries, targets]) => { sideEntries.forEach(({ component, event }, index) => { const target = targets[index] || null; if (!target?.input) return; if (!target.selected_name) { console.warn(`vMix penalty timer ${side === "home" ? "HOME" : "AWAY"} skipped: SelectedName is empty`, target); return; } const sourceSide = String(event.player?.side || event.side || side); if (step.penalty_vmix_mode === "text") { setVmixPenaltyMirror(component, event, target.input, target.selected_name, { stepId: step.id, targetId: target.id, side: sourceSide, overlay: target.overlay }); state.vmixPenaltyTargetAssignments.set(penaltyTargetAssignmentKey(step, side, target), { eventKey: penaltyMirrorKey(component, event), input: target.input, selectedName: target.selected_name, overlay: target.overlay, sourceSide, targetSide: side, mode: "text", }); commands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: formatHockeyPenaltyTime(event.remainingMs) }); } else { const eventRunning = Boolean(event.running); state.vmixPenaltyTargetAssignments.set(penaltyTargetAssignmentKey(step, side, target), { eventKey: penaltyMirrorKey(component, event), input: target.input, selectedName: target.selected_name, overlay: target.overlay, sourceSide, targetSide: side, mode: "countdown", running: eventRunning, }); commands.push(...vmixCountdownSyncCommands( target.input, target.selected_name, () => event.remainingMs, (pausing || !eventRunning) ? "pause" : "start" )); } }); }); } // BUILD101: local timer state is already final at this point. vMix sync is // fire-and-forget and cannot keep the Shortcut in a "running" state while // Agent is offline/slow. The next Space press therefore always controls // Runtime immediately. if (commands.length) sendRuntimeVmixTimerSequence(commands); if (step.start_web_game && step.game_vmix_mode === "text" && gameTimer && gameTimerState) { pushVmixTimerMirror(gameTimer, gameTimerState, { force: true }).catch(() => {}); } if (step.penalty_vmix_mode === "text" && !pausing) { penalties.forEach(({ component, event }) => { pushVmixPenaltyMirror(component, event, { force: true }).catch(() => {}); }); } return { ok: true, action, vmix: { ok: true, queued: commands.length }, 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 }; } } function shortcutSequenceTimerControlSteps(sequence) { return (sequence?.steps || []).filter((step) => step?.enabled !== false && ["timer_command", "hockey_vmix_timers_start"].includes(step?.type)); } function runShortcutTimerControlsWhileBusy(sequence, meta = {}) { const timerSteps = shortcutSequenceTimerControlSteps(sequence); if (!timerSteps.length) return false; const execution = { sequence_id: "", sequence_name: "", button_id: "" }; timerSteps.forEach((step) => { // runShortcutSequenceStep performs the local state change synchronously before // its Promise resolves. BUILD101 timer/vMix transport does not await Agent. runShortcutSequenceStep(sequence, step, execution).catch((error) => { console.warn("Timer shortcut while sequence busy failed locally", error); }); }); return 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)) { // BUILD101: timer controls are never queued behind title/Agent ACK traffic. // If the timer is already visibly running, the next physical Space press must // stop it immediately even while some unrelated title command is still waiting. if (runShortcutTimerControlsWhileBusy(sequence, meta)) return true; // Non-timer shortcuts keep one safe follow-up action rather than accumulating. if (!state.pendingShortcutSequenceRuns.has(sequence.id)) { state.pendingShortcutSequenceRuns.set(sequence.id, { ...meta, queued: true }); } return true; } 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; } if (sequence.is_scoreboard_sequence) { await syncConfiguredScoreboardCountdownsToRuntime(); } const stepErrors = []; for (const [stepIndex, step] of (sequence.steps || []).entries()) { try { await runShortcutSequenceStep(sequence, step, execution); } catch (error) { const functionName = step?.type === "vmix_command" ? String(step.function || "vMix") : String(step?.type || "step"); const message = String(error?.message || error || "Ошибка шага"); stepErrors.push({ index: stepIndex, function: functionName, message }); console.error("UI Builder shortcut step error", { sequence, stepIndex, step, error }); // BUILD100: one stale/broken title must not prevent the remaining title, // timer and overlay steps from being sent to Agent. } } if (stepErrors.length) { const first = stepErrors[0]; throw new Error(`шаг ${first.index + 1} (${first.function}): ${first.message}${stepErrors.length > 1 ? ` · всего ошибок ${stepErrors.length}` : ""}`); } 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); const nextMeta = state.pendingShortcutSequenceRuns.get(sequence.id) || null; state.pendingShortcutSequenceRuns.delete(sequence.id); if (nextMeta) queueMicrotask(() => runShortcutSequence(sequence.id, nextMeta)); } } 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) { const physicalKey = shortcutPhysicalKeyToken(event); if (physicalKey && state.pressedShortcutKeys.has(physicalKey)) { // This key already fired a configured shortcut and has not been released yet. // Swallow browser auto-repeat/default activation until keyup. event.preventDefault(); event.stopPropagation(); if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation(); return true; } if (event.repeat) return false; const combo = shortcutFromKeyboardEvent(event); if (!combo) return false; const handled = handleConfiguredShortcutCombo(combo, event, "keyboard"); if (handled && physicalKey) state.pressedShortcutKeys.add(physicalKey); return handled; } 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)) { // Legacy Text mirror only. Native countdown mode never emits a per-second request. pushVmixTimerMirror(component, timerState).catch(() => {}); } else if (eventName !== "timer_tick" && !detail.suppressVmixSync) { syncActiveVmixGameCountdown(component, timerState, eventName).catch((error) => console.error("vMix game countdown sync error", error)); } 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 = "", options = {}) { 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, suppressVmixSync: options.syncVmix === false }); 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 `
${main ? "⏱" : component.type === "penalty_timer" ? "2′" : "◷"}
${escapeHtml(timerQuickEditorName(component))} ${escapeHtml(timerQuickEditorModeLabel(component))} · ${escapeHtml(component.action_id)}
${escapeHtml(formatTimerValue(component, timerState))} ${escapeHtml(timerStatusText(timerState))}
`; } 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 `
${side === "home" ? "Л" : side === "away" ? "П" : "—"}
${escapeHtml(name)} ${escapeHtml(description)}
${escapeHtml(event.preset ? formatHockeyPenaltyTime(event.remainingMs) : "—:—")} ${escapeHtml(event.finished ? "Завершён" : event.running ? "Идёт" : hockeyEventReady(event) ? "Пауза" : "Заготовка")}
`; } 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("Таймеры", `
Быстрая правка всех таймеров

Основное время сверху; удаления разделены по командам.

${mainTimers.length ? `
Основное время
${componentRows(mainTimers, true)}
` : ""}
Левая команда ${homeItems.length}
${hockeyRows(homeItems) || `
Таймеров удалений нет
`}
Правая команда ${awayItems.length}
${hockeyRows(awayItems) || `
Таймеров удалений нет
`}
${neutralItems.length ? `
Заготовки без команды ${neutralItems.length}
${hockeyRows(neutralItems)}
` : ""} ${otherTimers.length ? `
Другие таймеры
${componentRows(otherTimers)}
` : ""}
`); 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); const externalId = String(get("idField", "") || row?.external_id || row?.id || ""); const databaseId = String(row?.db_id ?? row?.database_id ?? ""); return { // `id` остаётся external_id для совместимости со старым Mapping. // `dbId` — реальный PK hockey_players.id из нашей базы. id: externalId || `${side}-${index + 1}`, externalId, dbId: databaseId, 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), startedOnce: event.startedOnce === true || event.started_once === true || (Boolean(event.running) && !Boolean(event.finished)) || (durationMs > 0 && Number(event.remainingMs ?? durationMs) > 0 && Number(event.remainingMs ?? durationMs) < durationMs), 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, // Последний выбранный для редактирования штраф остаётся общим, а // preview/Mapping-выбор хранится отдельно для левой и правой команды. selectedPreviewEventIds: { home: "", away: "" }, 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.startedOnce), Boolean(event.finished), event.preset || "", event.player?.id || "", event.infraction?.id || "", ].join(":" )).join(";"), board.history.map((item) => item.id || "").join(","), Boolean(state.hockeyPenaltyAdvantageCycle.hadAdvantage), String(state.hockeyPenaltyAdvantageCycle.lastAdvantageSide || ""), ].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) { const playerExternalId = String(event.player?.externalId || event.player?.external_id || event.player?.id || ""); const playerDbId = String(event.player?.dbId || event.player?.db_id || event.player?.database_id || event.player?.raw?.db_id || ""); return { penalty_id: event.id, event_time: event.eventTime, event_time_ms: event.eventTimeMs, player: event.player ? clone(event.player) : null, player_id: playerExternalId, player_db_id: playerDbId, 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) }); } // BUILD95: filling in a penalty must not touch/start the vMix countdown. // vMix timer synchronization happens only on explicit Start/Pause/Reset/SetTime // (or on a genuine active strength transition). hockeySyncPenaltySideMappingContext().catch(() => {}); } 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, startedOnce: 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 = "", options = {}) { 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.startedOnce = 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)); const hadAdvantage = Boolean(state.hockeyPenaltyAdvantageCycle.hadAdvantage); const fullStrengthSide = penaltyFullStrengthSide(); 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) && hockeyPenaltyHasStarted(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length; const remainingTotal = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item)).length; const clearCommonSelection = board.selectedEventId === event.id; const clearSideSelection = board.selectedPreviewEventIds?.[side] === event.id; if (clearCommonSelection) board.selectedEventId = null; if (clearSideSelection) board.selectedPreviewEventIds[side] = ""; if (clearSideSelection) hockeyClearSelectedPenaltyMappingContext(side, { clearCommon: clearCommonSelection }).catch(() => {}); persistHockeyBoard(component, board, true); refreshHockeyBoardNodes(component); hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {}); 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, remaining_total: remainingTotal, had_advantage: hadAdvantage, full_strength_side: fullStrengthSide, }); if (remainingTotal <= 0) { resetPenaltyAdvantageCycle(); hockeyScheduleTimerSave(true); } }); 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); const clearCommonSelection = board.selectedEventId === eventId; if (clearCommonSelection) board.selectedEventId = null; { const removedSide = String(event.player?.side || event.side || "").toLowerCase(); const clearSideSelection = board.selectedPreviewEventIds?.[removedSide] === eventId; if (clearSideSelection) board.selectedPreviewEventIds[removedSide] = ""; if (clearSideSelection) hockeyClearSelectedPenaltyMappingContext(removedSide, { clearCommon: clearCommonSelection }).catch(() => {}); } 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)); hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {}); if (!board.penalties.some((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item))) { resetPenaltyAdvantageCycle(); hockeyScheduleTimerSave(true); } rebalanceVmixPenaltyTargets({ force: true, hideUnused: true }).catch((error) => console.error("Penalty target remove rebalance error", error)); return true; } persistHockeyBoard(component, board, true); refreshHockeyBoardNodes(component); if (options.syncVmix !== false && state.activeHockeyVmixTimerSteps.size && ["start", "pause", "reset", "set_time"].includes(command)) { rebalanceVmixPenaltyTargets({ force: true, hideUnused: true, preservePausedCountdown: command === "pause", }).catch((error) => console.error("Penalty countdown state sync error", error)); } 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("Быстрая правка события", `
${escapeHtml(event.teamPenalty ? "Командное удаление" : event.player ? `#${event.player.number} ${event.player.name}` : "Заготовка удаления")} ${escapeHtml(event.infraction?.label || "Нарушение пока не выбрано")}
`); 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); hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {}); const hadAdvantage = Boolean(state.hockeyPenaltyAdvantageCycle.hadAdvantage); const fullStrengthSide = penaltyFullStrengthSide(); const remainingTotal = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item)).length; 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) && hockeyPenaltyHasStarted(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length; fireConfiguredTimerFinishActions("penalty", { side, component, event, remaining_on_side: remainingOnSide, remaining_total: remainingTotal, had_advantage: hadAdvantage, full_strength_side: fullStrengthSide, }); }); if (remainingTotal <= 0) { resetPenaltyAdvantageCycle(); hockeyScheduleTimerSave(true); } }); 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 = ` ⋮⋮ ${escapeHtml(player.number || "—")} ${escapeHtml(player.name)} ${escapeHtml(player.position || "")} `; 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 = `
${side === "home" ? "ЛЕВАЯ КОМАНДА" : "ПРАВАЯ КОМАНДА"} ${escapeHtml(hockeyTeamName(component, side))} ${getByPath(state.data, `hockey.selected_game.${side}.coach`) ? `Тренер: ${escapeHtml(getByPath(state.data, `hockey.selected_game.${side}.coach`))}` : ""}
${players.length} `; 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 = `
Новое удаление Момент матча сохранится автоматически
`; 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 = `${escapeHtml(preset.label)}${escapeHtml(preset.time)}`; 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 = ` ⋮⋮ ${escapeHtml(infraction.label)} ${infraction.teamPenalty ? `КОМ` : ""} ${escapeHtml(infraction.defaultPreset)} `; 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 = ` Нарушения ${parseHockeyInfractions(component.props?.infractions).length} `; 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 = ` Кому #${escapeHtml(event.player.number || "—")} ${escapeHtml(event.player.name)} ${escapeHtml(event.player.position || "")} `; } else if (hasInfraction) { field.innerHTML = ` За что ${escapeHtml(event.infraction.label)} ${escapeHtml(event.infraction.id)} `; } else if (hasPreset) { field.innerHTML = ` Штраф ${escapeHtml(event.preset)} ${escapeHtml(formatHockeyPenaltyTime(event.durationMs))} `; } else { const labels = { player: ["Кому", "Перетащите игрока"], infraction: ["За что", "Перетащите нарушение"], preset: ["Штраф", "Перетащите длительность"] }; field.classList.add("is-empty"); field.innerHTML = `${labels[kind][0]}${labels[kind][1]}`; } 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; } function hockeyPenaltyPlayerIdentifiers(component, event) { const side = String(event.player?.side || event.side || "").toLowerCase(); let externalId = String( event.player?.externalId || event.player?.external_id || event.player?.id || event.player?.raw?.external_id || event.player?.raw?.id || "" ); let dbId = String( event.player?.dbId || event.player?.db_id || event.player?.database_id || event.player?.raw?.db_id || event.player?.raw?.database_id || "" ); // Для сохранённых удалений из старых сборок dbId мог отсутствовать. // В этом случае восстанавливаем его по актуальному составу из БД. if (externalId && !dbId && ["home", "away"].includes(side)) { const rosterPlayer = hockeyRoster(component, side).find((player) => String(player.externalId || player.id || "") === externalId ); if (rosterPlayer) { dbId = String(rosterPlayer.dbId || rosterPlayer.raw?.db_id || ""); externalId = String(rosterPlayer.externalId || rosterPlayer.id || externalId); } } return { externalId, dbId }; } async function hockeyClearSelectedPenaltyMappingContext(side, { clearCommon = false } = {}) { const gameId = hockeyTimerSelectedGameId(); const cleanSide = ["home", "away"].includes(String(side || "").toLowerCase()) ? String(side).toLowerCase() : ""; if (!gameId || !cleanSide) return false; const values = cleanSide === "home" ? { selected_home_penalty_id: "", selected_home_penalty_player_id: "", selected_home_penalty_player_db_id: "", selected_home_penalty_team_penalty: "", } : { selected_away_penalty_id: "", selected_away_penalty_player_id: "", selected_away_penalty_player_db_id: "", selected_away_penalty_team_penalty: "", }; if (clearCommon) Object.assign(values, { selected_penalty_id: "", selected_penalty_player_id: "", selected_penalty_player_db_id: "", selected_penalty_team_penalty: "", selected_penalty_team_id: "", selected_penalty_infraction_id: "", selected_penalty_preset_id: "", selected_penalty_side: "", selected_penalty_event_time: "", selected_penalty_status: "", selected_penalty_remaining_ms: "", selected_penalty_duration_ms: "", selected_event_id: "", }); 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, context: { game_id: gameId, device_id: currentRuntimeVmixDeviceId(), session_token: currentRuntimeHockeySessionToken(), }, }), }); return response.ok; } catch (_) { return false; } } function hockeyPenaltyIsPreviewSelected(board, event) { const side = String(event.player?.side || event.side || "").toLowerCase(); return ["home", "away"].includes(side) && String(board.selectedPreviewEventIds?.[side] || "") === String(event.id || ""); } async function hockeySelectPenaltyForPreview(component, event) { const board = ensureHockeyBoardState(component); board.selectedEventId = event.id; if (!board.selectedPreviewEventIds || typeof board.selectedPreviewEventIds !== "object") { board.selectedPreviewEventIds = { home: "", away: "" }; } const side = String(event.player?.side || event.side || "").toLowerCase(); if (["home", "away"].includes(side)) board.selectedPreviewEventIds[side] = event.id; 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 playerIds = hockeyPenaltyPlayerIdentifiers(component, event); 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 остаётся external_id, чтобы не ломать существующие SQL. player_id: playerIds.externalId, player_external_id: playerIds.externalId, player_db_id: playerIds.dbId, team_id: teamId, team_penalty: Boolean(event.teamPenalty), 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, }; const sidePatch = ["home", "away"].includes(side) ? { [side]: detail } : {}; window.UIBuilderRuntime?.patchData?.({ hockey: { selected_penalty: detail, selected_penalties: sidePatch, } }, { render: false }); refreshHockeyBoardNodes(component); const gameId = hockeyTimerSelectedGameId(); if (!gameId) { emitInteraction(component, "penalty_selected", detail); return detail; } const values = { selected_penalty_id: detail.penalty_id, selected_penalty_player_id: detail.player_id, selected_penalty_player_db_id: detail.player_db_id, selected_penalty_team_penalty: detail.team_penalty ? "1" : "0", 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, }; if (side === "home") { Object.assign(values, { selected_home_penalty_id: detail.penalty_id, selected_home_penalty_player_id: detail.player_id, selected_home_penalty_player_db_id: detail.player_db_id, selected_home_penalty_team_penalty: detail.team_penalty ? "1" : "0", }); } else if (side === "away") { Object.assign(values, { selected_away_penalty_id: detail.penalty_id, selected_away_penalty_player_id: detail.player_id, selected_away_penalty_player_db_id: detail.player_db_id, selected_away_penalty_team_penalty: detail.team_penalty ? "1" : "0", }); } 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, 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-preview-selected", hockeyPenaltyIsPreviewSelected(board, event)); 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))}` : "Выберите длительность"; const previewSelected = hockeyPenaltyIsPreviewSelected(board, event); const playerIds = hockeyPenaltyPlayerIdentifiers(component, event); card.innerHTML = `
${event.teamPenalty ? "Командное удаление" : side === "home" ? "Хозяева" : side === "away" ? "Гости" : "Без команды"} ${playerText}
Нарушение ${infractionText}
Штраф ${presetText}
`; 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 = `
Заготовки без команды ${events.length}
`; 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 = `
Перетащите сюда игрока, нарушение или длительность
`; } 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, playerPanels = state.config.player_selection_panels) { const normalizedGroups = normalizePrematchGroups(groups); const validGroups = new Set(normalizedGroups.map((group) => group.id)); const normalizedButtons = normalizePrematchButtons(buttons).map((button) => ({ ...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; const normalizedPlayerPanels = normalizePlayerSelectionPanels(playerPanels); state.config.player_selection_panels = normalizedPlayerPanels; try { const response = await fetch("/api/hockey/ui/prematch-buttons", { method: "POST", credentials: "same-origin", cache: "no-store", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prematch_groups: normalizedGroups, prematch_buttons: normalizedButtons, quick_panel_selectors: normalizedSelectors, player_selection_panels: normalizedPlayerPanels }), }); 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); state.config.player_selection_panels = normalizePlayerSelectionPanels(payload.player_selection_panels || normalizedPlayerPanels); } 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 draftPlayerPanels = normalizePlayerSelectionPanels(state.config.player_selection_panels); let dragButtonId = ""; let dragSelectorId = ""; let activeEditorGroupId = String(state.quickPanelActiveTab || ""); let activeEditorSection = "buttons"; 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) => `
⋮⋮ ${index + 1}
`; const renderSelectorRow = (selector, index) => `
⋮⋮ ${index + 1}
`; const renderPlayerPanelRow = (panel, index) => `
${index + 1}
`; 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 `
${isUngrouped ? "—" : "▦"} ${isUngrouped ? `Без вкладкиКнопки, не назначенные во вкладку` : ` `}
${buttons.length ? buttons.map(renderButtonRow).join("") : `
Перетащите кнопку в эту вкладку
`} ${selectors.length ? `
Параметры / переключатели
${selectors.map(renderSelectorRow).join("")}
` : ""}
`; }; 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 : "" })); draftPlayerPanels = normalizePlayerSelectionPanels(draftPlayerPanels); showSettingsModal("Нижняя панель · кнопки", `
${activeEditorSection === "buttons" ? `

Создавайте вкладки, кнопки и маленькие параметры рядом с кнопками. Значение параметра попадает в Mapping/SQL до запуска Shortcut Sequence.

${renderGroup(null, -1)} ${draftGroups.map((group, index) => renderGroup(group, index)).join("")}
` : `
DRAG & DROP ИГРОКОВБлоки выбора игроковСоздание сравнений, «3 звезды» и одиночных выборов. ID автоматически формирует Mapping: player_select.<ID>.player1_id и т. д.
${draftPlayerPanels.length ? draftPlayerPanels.map(renderPlayerPanelRow).join("") : `
Блоков игроков пока нет
`}
`}
`, { className: "modal-prematch-full" }); document.querySelectorAll("[data-prematch-editor-tab]").forEach((button) => button.addEventListener("click", () => { const section = String(button.dataset.prematchEditorTab || "buttons"); if (!["buttons", "players"].includes(section) || section === activeEditorSection) return; activeEditorSection = section; renderEditor(); })); 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-player-panel-add]")?.addEventListener("click", () => { if (draftPlayerPanels.length >= 16) return toast("Можно создать до 16 блоков игроков", true); draftPlayerPanels.push({ id: `players_${draftPlayerPanels.length + 1}`, label: `Игроки ${draftPlayerPanels.length + 1}`, description: "", slots: 2, rule: "any", sync_selected_player: false, collapsed_default: true, sort_order: draftPlayerPanels.length * 10, enabled: true }); renderEditor(); }); document.querySelector("[data-prematch-add]")?.addEventListener("click", () => { if (draft.length >= 64) return toast("Можно создать до 64 кнопок", true); const targetGroup = draftGroups.some((group) => group.id === activeEditorGroupId) ? activeEditorGroupId : ""; 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-player-panel-field]").forEach((control) => { const field = control.dataset.playerPanelField; // Changing the system ID also changes every data-player-panel-id attribute // in this editor row. Commit it on blur/change and rebuild the row at once, // otherwise the remaining controls would still point at the old ID. const eventName = field === "id" || control.type === "checkbox" || control.tagName === "SELECT" || control.type === "number" ? "change" : "input"; control.addEventListener(eventName, () => { const panel = draftPlayerPanels.find((item) => item.id === control.dataset.playerPanelId); if (!panel) return; const previousId = panel.id; panel[field] = control.type === "checkbox" ? control.checked : (control.type === "number" ? clamp(Number(control.value) || 1, 1, 6) : control.value); if (field === "id") { const normalized = normalizePlayerSelectionPanels(draftPlayerPanels); draftPlayerPanels = normalized; const updated = draftPlayerPanels.find((item) => item.id === String(panel.id || "").trim().replace(/[^A-Za-z0-9_]+/g, "_").replace(/^_+|_+$/g, "")) || draftPlayerPanels.find((item) => item.sort_order === panel.sort_order && item.label === panel.label); if (updated && previousId !== updated.id) toast(`ID блока: ${updated.id}`); renderEditor(); } }); }); document.querySelectorAll("[data-player-panel-up]").forEach((button) => button.addEventListener("click", () => { const index = draftPlayerPanels.findIndex((item) => item.id === button.dataset.playerPanelUp); if (index > 0) [draftPlayerPanels[index - 1], draftPlayerPanels[index]] = [draftPlayerPanels[index], draftPlayerPanels[index - 1]]; draftPlayerPanels.forEach((item, i) => { item.sort_order = i * 10; }); renderEditor(); })); document.querySelectorAll("[data-player-panel-down]").forEach((button) => button.addEventListener("click", () => { const index = draftPlayerPanels.findIndex((item) => item.id === button.dataset.playerPanelDown); if (index >= 0 && index < draftPlayerPanels.length - 1) [draftPlayerPanels[index + 1], draftPlayerPanels[index]] = [draftPlayerPanels[index], draftPlayerPanels[index + 1]]; draftPlayerPanels.forEach((item, i) => { item.sort_order = i * 10; }); renderEditor(); })); document.querySelectorAll("[data-player-panel-delete]").forEach((button) => button.addEventListener("click", () => { const panel = draftPlayerPanels.find((item) => item.id === button.dataset.playerPanelDelete); if (!panel || !window.confirm(`Удалить блок «${panel.label}»? Сохранённые значения матча останутся в базе, но блок исчезнет из интерфейса.`)) return; draftPlayerPanels = draftPlayerPanels.filter((item) => item.id !== panel.id); renderEditor(); })); document.querySelectorAll("[data-quick-selector-field]").forEach((control) => { const eventName = control.type === "checkbox" || control.tagName === "SELECT" ? "change" : "input"; control.addEventListener(eventName, () => { 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; }); document.querySelectorAll("[data-player-panel-row]").forEach((row) => { const panel = draftPlayerPanels.find((item) => item.id === row.dataset.playerPanelRow); if (!panel) return; row.querySelectorAll("[data-player-panel-field]").forEach((control) => { const field = control.dataset.playerPanelField; panel[field] = control.type === "checkbox" ? control.checked : (control.type === "number" ? clamp(Number(control.value) || 1, 1, 6) : control.value); }); }); draftGroups.forEach((item, index) => { item.sort_order = index * 10; }); draftPlayerPanels.forEach((item, index) => { item.sort_order = index * 10; }); for (const groupId of ["", ...draftGroups.map((group) => group.id)]) renumberGroup(groupId); if (await hockeyPersistPrematchButtons(draft, draftGroups, draftSelectors, draftPlayerPanels)) 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 }) }); hockeyBackupPlayerSelectionValues(gameId, payload?.values || values); if (refreshMapping) await hockeyRefreshQuickPanelMapping(); return payload; } function quickPanelSelectorMarkup(selector) { const current = quickPanelSelectorValue(selector); const tooltip = selector.description || selector.label; if (selector.style === "select") { return ``; } return `
${escapeHtml(selector.label)}
${selector.options.map((option) => ``).join("")}
`; } 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, { refreshMapping = false } = {}) { 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 }); } 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) => ``).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 `
${attachedSelectors.map(quickPanelSelectorMarkup).join("")}
`; }).join(""); const standaloneSelectors = selectors.filter((selector) => !selector.button_id && (activeId === "__ungrouped" ? !selector.group_id : selector.group_id === activeId)); const standaloneMarkup = standaloneSelectors.map((selector) => `
${quickPanelSelectorMarkup(selector)}
`).join(""); el.runtimeButtonDock.innerHTML = `
${tabMarkup || `Создайте вкладку для операторских кнопок`}
${buttonMarkup || standaloneMarkup ? `${buttonMarkup}${standaloneMarkup}` : `Во вкладке пока нет кнопок`}
`; 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); // BUILD102: save the button context without a full Mapping ACK round-trip. // Selector changes already refresh Mapping; the operator click itself must not // wait behind Mapping before its Shortcut Sequence is dispatched. await hockeyCommitQuickPanelButtonContext(button, attachedSelectors, { refreshMapping: false }); 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 = `
Панель перенесена внизОператорские кнопки теперь доступны в нижней панели на всех вкладках.
`; 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: ``, }; } 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: ``, }; } 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 ``; } 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 = `
${side === "home" ? "ЛЕВАЯ КОМАНДА" : "ПРАВАЯ КОМАНДА"} ${escapeHtml(hockeyTeamName(component, side))}
${hockeyTeamStateButtonMarkup(delayedKey, delayedSetting, Boolean(flags[delayedKey]))} ${hockeyTeamStateButtonMarkup(emptyNetKey, emptyNetSetting, Boolean(flags[emptyNetKey]))}
${readyCount} ${activeCount}
`; 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 = `
Перетащите игрока, нарушение или длительность в эту колонку
`; } 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 = `
Журнал удалений
${board.history.length} `; 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 = ` ${escapeHtml(entry.message || "")} `; list.appendChild(item); }); if (!board.history.length) { list.innerHTML = `
История пока пуста
`; } 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 hockeyPlayerSelectionStorageKey(gameId) { return `hockey:player-selections:${String(gameId || "").trim()}`; } function hockeyPlayerSelectionValuesOnly(values) { const result = {}; Object.entries(values && typeof values === "object" ? values : {}).forEach(([key, value]) => { if (String(key).startsWith("player_select.")) result[String(key)] = String(value ?? ""); }); return result; } function hockeyBackupPlayerSelectionValues(gameId, values) { const id = String(gameId || "").trim(); if (!id) return false; const selected = hockeyPlayerSelectionValuesOnly(values); if (!Object.keys(selected).length) return false; try { localStorage.setItem(hockeyPlayerSelectionStorageKey(id), JSON.stringify({ game_id: id, saved_at: Date.now(), values: selected })); return true; } catch (_) { return false; } } function hockeyStoredPlayerSelectionValues(gameId) { const id = String(gameId || "").trim(); if (!id) return {}; try { const payload = JSON.parse(localStorage.getItem(hockeyPlayerSelectionStorageKey(id)) || "{}"); if (String(payload?.game_id || "") !== id || !payload?.values || typeof payload.values !== "object") return {}; return hockeyPlayerSelectionValuesOnly(payload.values); } catch (_) { return {}; } } function hockeyPlayerSelectionPanels() { return normalizePlayerSelectionPanels(state.config.player_selection_panels).filter((panel) => panel.enabled !== false); } function hockeyPlayerSelectionValueKey(panelId, slot, field) { return `player_select.${panelId}.player${slot}_${field}`; } function hockeyPlayerSelectionTeamId(player) { if (!player) return ""; const side = player.side === "away" ? "away" : player.side === "home" ? "home" : ""; const raw = player.raw || {}; const direct = player.teamId || player.team_id || raw.team_external_id || raw.team_id || raw.club_external_id || raw.club_id || raw.team?.external_id || raw.team?.id || ""; if (direct) return String(direct); if (!side) return ""; const team = getByPath(state.data, `hockey.selected_game.${side}`) || {}; return String(team.external_id || team.team_external_id || team.team_id || team.club_id || team.id || ""); } function hockeyPlayerSelectionSlot(panel, slot) { const values = hockeyMatchRuntimeValues(); const read = (field) => String(values[hockeyPlayerSelectionValueKey(panel.id, slot, field)] ?? ""); return { id: read("id"), dbId: read("db_id"), teamId: read("team_id"), side: read("side"), number: read("number"), name: read("name"), }; } function hockeyPlayerSelectionRuleLabel(rule) { return ({ any: "любые команды", home: "только левая", away: "только правая", different_teams: "разные команды" })[rule] || "любые команды"; } function hockeyPlayerSelectionAllows(panel, slot, player) { const side = String(player?.side || ""); if (panel.rule === "home" && side !== "home") return { ok: false, message: "В этот блок можно добавлять только игроков левой команды" }; if (panel.rule === "away" && side !== "away") return { ok: false, message: "В этот блок можно добавлять только игроков правой команды" }; if (panel.rule === "different_teams") { const occupiedSides = []; for (let index = 1; index <= panel.slots; index += 1) { if (index === slot) continue; const current = hockeyPlayerSelectionSlot(panel, index); if (current.id || current.dbId) occupiedSides.push(current.side); } if (side && occupiedSides.includes(side)) return { ok: false, message: "В этом блоке игроки должны быть из разных команд" }; } return { ok: true }; } async function hockeySyncLegacySelectedPlayer(player, teamId = "") { const gameId = hockeyTimerSelectedGameId(); if (!gameId) return false; const compact = player ? hockeyCompactPlayer(player) : null; try { const response = await fetch("/api/hockey/context/batch", { method: "POST", cache: "no-store", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ values: { selected_player_id: compact?.externalId || "", selected_player_team_id: String(teamId || ""), }, context: { game_id: gameId, device_id: currentRuntimeVmixDeviceId(), session_token: currentRuntimeHockeySessionToken(), }, }), }); return response.ok; } catch (_) { return false; } } function hockeyPlayerSelectionPatch(panel, slot, player = null) { const compact = player ? hockeyCompactPlayer(player) : null; const teamId = player ? hockeyPlayerSelectionTeamId(player) : ""; return { [`player_select.${panel.id}._label`]: panel.label, [`player_select.${panel.id}._slots`]: String(panel.slots), [`player_select.${panel.id}._rule`]: panel.rule, [hockeyPlayerSelectionValueKey(panel.id, slot, "id")]: compact?.externalId || "", [hockeyPlayerSelectionValueKey(panel.id, slot, "db_id")]: compact?.dbId || "", [hockeyPlayerSelectionValueKey(panel.id, slot, "team_id")]: teamId, [hockeyPlayerSelectionValueKey(panel.id, slot, "side")]: compact?.side || "", [hockeyPlayerSelectionValueKey(panel.id, slot, "number")]: compact?.number || "", [hockeyPlayerSelectionValueKey(panel.id, slot, "name")]: compact?.name || "", }; } async function hockeySetPlayerSelectionSlot(panel, slot, player) { if (!player) return false; const validation = hockeyPlayerSelectionAllows(panel, slot, player); if (!validation.ok) { toast(validation.message, true); return false; } await hockeySetMatchValues(hockeyPlayerSelectionPatch(panel, slot, player), { refreshMapping: true }); if (panel.sync_selected_player) await hockeySyncLegacySelectedPlayer(player, hockeyPlayerSelectionTeamId(player)); toast(`${panel.label}: игрок ${slot} — ${player.name || player.id}`); return true; } async function hockeySwapPlayerSelectionSlots(panel, fromSlot, toSlot) { const sourceSlot = clamp(Number(fromSlot) || 1, 1, panel.slots); const targetSlot = clamp(Number(toSlot) || 1, 1, panel.slots); if (sourceSlot === targetSlot) return false; const source = hockeyPlayerSelectionSlot(panel, sourceSlot); if (!source.id && !source.dbId) return false; const target = hockeyPlayerSelectionSlot(panel, targetSlot); const sourcePlayer = { externalId: source.id, dbId: source.dbId, teamId: source.teamId, side: source.side, number: source.number, name: source.name }; const targetPlayer = (target.id || target.dbId) ? { externalId: target.id, dbId: target.dbId, teamId: target.teamId, side: target.side, number: target.number, name: target.name } : null; const patch = { ...hockeyPlayerSelectionPatch(panel, targetSlot, sourcePlayer), ...hockeyPlayerSelectionPatch(panel, sourceSlot, targetPlayer), }; await hockeySetMatchValues(patch, { refreshMapping: true }); if (panel.sync_selected_player) await hockeySyncLegacySelectedPlayer(sourcePlayer, source.teamId); toast(targetPlayer ? `${panel.label}: игроки ${sourceSlot} и ${targetSlot} поменяны местами` : `${panel.label}: игрок перемещён ${sourceSlot} → ${targetSlot}`); return true; } async function hockeyClearPlayerSelectionSlot(panel, slot) { await hockeySetMatchValues(hockeyPlayerSelectionPatch(panel, slot, null), { refreshMapping: true }); if (panel.sync_selected_player) await hockeySyncLegacySelectedPlayer(null, ""); return true; } async function hockeyClearPlayerSelectionPanel(panel) { const patch = { [`player_select.${panel.id}._label`]: panel.label, [`player_select.${panel.id}._slots`]: String(panel.slots), [`player_select.${panel.id}._rule`]: panel.rule, }; for (let slot = 1; slot <= panel.slots; slot += 1) Object.assign(patch, hockeyPlayerSelectionPatch(panel, slot, null)); await hockeySetMatchValues(patch, { refreshMapping: true }); if (panel.sync_selected_player) await hockeySyncLegacySelectedPlayer(null, ""); } async function hockeyEnsurePlayerSelectionRuntimeValues() { const gameId = hockeyTimerSelectedGameId(); const panels = hockeyPlayerSelectionPanels(); if (!gameId || !panels.length || state.hockeyPlayerPanelSeedPending) return false; // Do not seed empty values before the saved GameControlState has arrived. // On a hard refresh that race used to erase an already prepared comparison. const control = getByPath(state.data, "hockey.game_control"); if (!control || String(control.game_id || "") !== String(gameId)) return false; const current = hockeyMatchRuntimeValues(); const patch = {}; const hasServerSelectionKeys = Object.keys(current).some((key) => String(key).startsWith("player_select.")); if (!hasServerSelectionKeys) { const backup = hockeyStoredPlayerSelectionValues(gameId); Object.entries(backup).forEach(([key, value]) => { patch[key] = value; }); } for (const panel of panels) { const meta = { [`player_select.${panel.id}._label`]: panel.label, [`player_select.${panel.id}._slots`]: String(panel.slots), [`player_select.${panel.id}._rule`]: panel.rule, }; Object.entries(meta).forEach(([key, value]) => { if (!(key in current) || String(current[key]) !== String(value)) patch[key] = value; }); for (let slot = 1; slot <= panel.slots; slot += 1) { for (const field of ["id", "db_id", "team_id", "side", "number", "name"]) { const key = hockeyPlayerSelectionValueKey(panel.id, slot, field); if (!(key in current)) patch[key] = ""; } } } const entries = Object.entries(patch); if (!entries.length) return false; const signature = `${gameId}|${entries.map(([key, value]) => `${key}=${value}`).join("|")}`; if (state.hockeyPlayerPanelSeedSignature === signature) return false; state.hockeyPlayerPanelSeedSignature = signature; state.hockeyPlayerPanelSeedPending = true; try { const language = hockeyGameControlLanguage(); let latest = null; for (let offset = 0; offset < entries.length; offset += 60) { const chunk = Object.fromEntries(entries.slice(offset, offset + 60)); latest = await hockeyGameControlRequest(`/games/${encodeURIComponent(gameId)}/control/values`, { method: "PUT", body: JSON.stringify({ values: chunk, language }), }); hockeyStoreGameControl(gameId, latest, { render: false, dispatch: false }); } await hockeyRefreshQuickPanelMapping(); renderRuntime(); return true; } catch (error) { console.error("Player selection panel seed failed", error); return false; } finally { state.hockeyPlayerPanelSeedPending = false; } } function hockeyEnsureRuntimeSideStack() { let stack = document.getElementById("hockeyRuntimeSideStack"); if (!stack && el.runtimeViewport) { stack = document.createElement("aside"); stack.id = "hockeyRuntimeSideStack"; stack.className = "hockey-runtime-side-stack"; el.runtimeViewport.appendChild(stack); } if (stack) el.runtimeViewport?.classList.add("has-hockey-pbp"); return stack; } function hockeyRemoveRuntimeSideStack() { document.getElementById("hockeyRuntimeSideStack")?.remove(); document.getElementById("hockeyStandalonePbp")?.remove(); el.runtimeViewport?.classList.remove("has-hockey-pbp"); } function renderHockeyPlayerSelectionWindows() { document.querySelectorAll(".hockey-player-select-panel").forEach((node) => node.remove()); if (!el.runtimeView || !el.runtimeViewport || state.activeTab !== "main") return false; const panels = hockeyPlayerSelectionPanels(); if (!panels.length) return false; const stack = hockeyEnsureRuntimeSideStack(); if (!stack) return false; for (const panel of panels) { const stateKey = `hockey-player-panel:${panel.id}:collapsed`; const hasStored = Object.prototype.hasOwnProperty.call(state.formValues, stateKey); const collapsed = hasStored ? Boolean(state.formValues[stateKey]) : Boolean(panel.collapsed_default); const slots = Array.from({ length: panel.slots }, (_, index) => hockeyPlayerSelectionSlot(panel, index + 1)); const filled = slots.filter((slot) => slot.id || slot.dbId).length; const node = document.createElement("section"); node.className = `hockey-player-select-panel ${collapsed ? "is-collapsed" : ""}`; node.dataset.playerPanelId = panel.id; node.innerHTML = `
PLAYER SELECT${escapeHtml(panel.label)}${escapeHtml(panel.description || hockeyPlayerSelectionRuleLabel(panel.rule))}
${filled}/${panel.slots}
${escapeHtml(hockeyPlayerSelectionRuleLabel(panel.rule))} · Mapping: player_select.${escapeHtml(panel.id)}.*
${slots.map((slot, index) => { const number = index + 1; const filledSlot = Boolean(slot.id || slot.dbId); const sideLabel = slot.side === "home" ? "HOME" : slot.side === "away" ? "AWAY" : ""; const title = [slot.number ? `#${slot.number}` : "", slot.name].filter(Boolean).join(" ") || (filledSlot ? `Игрок ${number}` : "Перетащите игрока"); const ids = filledSlot ? [sideLabel, slot.id ? `ID ${slot.id}` : "", slot.dbId ? `DB ${slot.dbId}` : ""].filter(Boolean).join(" · ") : "из состава слева или справа"; return `
${number}
${escapeHtml(title)}${escapeHtml(ids)}
${filledSlot ? `` : `DROP`}
`; }).join("")}
${filled ? `` : ""}
`; stack.appendChild(node); node.querySelector("[data-player-panel-collapse]")?.addEventListener("click", () => { state.formValues[stateKey] = !collapsed; renderRuntime(); }); node.querySelectorAll("[data-player-panel-slot]").forEach((slotNode) => { slotNode.addEventListener("dragstart", (event) => { const slot = Number(slotNode.dataset.playerPanelSlot || 1); const current = hockeyPlayerSelectionSlot(panel, slot); if (!current.id && !current.dbId) { event.preventDefault(); return; } state.hockeyPlayerSelectionDrag = { panelId: panel.id, slot }; event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("application/x-ui-builder-hockey-player-selection-slot", JSON.stringify(state.hockeyPlayerSelectionDrag)); event.dataTransfer.setData("text/plain", current.name || current.id || `player-${slot}`); slotNode.classList.add("is-dragging"); }); slotNode.addEventListener("dragend", () => { state.hockeyPlayerSelectionDrag = null; slotNode.classList.remove("is-dragging"); node.querySelectorAll(".hockey-player-select-slot.is-over").forEach((item) => item.classList.remove("is-over")); }); slotNode.addEventListener("dragover", (event) => { const internal = state.hockeyPlayerSelectionDrag; if (internal && internal.panelId === panel.id) { event.preventDefault(); event.dataTransfer.dropEffect = "move"; slotNode.classList.add("is-over"); return; } const player = state.hockeyDragPlayer || readHockeyDragData(event, "player"); if (!player) return; event.preventDefault(); event.dataTransfer.dropEffect = "copy"; slotNode.classList.add("is-over"); }); slotNode.addEventListener("dragleave", () => slotNode.classList.remove("is-over")); slotNode.addEventListener("drop", async (event) => { event.preventDefault(); slotNode.classList.remove("is-over"); const targetSlot = Number(slotNode.dataset.playerPanelSlot || 1); const internal = state.hockeyPlayerSelectionDrag; if (internal && internal.panelId === panel.id) { const sourceSlot = Number(internal.slot || 1); state.hockeyPlayerSelectionDrag = null; await hockeySwapPlayerSelectionSlots(panel, sourceSlot, targetSlot); return; } const player = state.hockeyDragPlayer || readHockeyDragData(event, "player"); if (!player) return; await hockeySetPlayerSelectionSlot(panel, targetSlot, player); }); }); node.querySelectorAll("[data-player-panel-clear-slot]").forEach((button) => button.addEventListener("click", async (event) => { event.preventDefault(); event.stopPropagation(); await hockeyClearPlayerSelectionSlot(panel, Number(button.dataset.playerPanelClearSlot || 1)); })); node.querySelector("[data-player-panel-prepared]")?.addEventListener("click", () => hockeyOpenPreparedFromPlayerPanel(panel)); node.querySelector("[data-player-panel-clear-all]")?.addEventListener("click", async () => hockeyClearPlayerSelectionPanel(panel)); } setTimeout(() => hockeyEnsurePlayerSelectionRuntimeValues().catch(() => {}), 0); return true; } function renderHockeyRuntimeSideWindows() { hockeyRemoveRuntimeSideStack(); if (!el.runtimeView || !el.runtimeViewport || state.activeTab !== "main") return false; const players = renderHockeyPlayerSelectionWindows(); const pbp = renderStandaloneHockeyPlayByPlayWindow(); const stack = document.getElementById("hockeyRuntimeSideStack"); if (!stack || !stack.children.length) hockeyRemoveRuntimeSideStack(); return Boolean(players || pbp); } function hockeyPreparedNumber(value, fallback = 999999) { const raw = String(value ?? "").trim(); return /^\d+$/.test(raw) ? Number(raw) : fallback; } function hockeyPreparedInventoryInputs() { const inventory = state.preparedTitleInventory?.inventory || {}; return (Array.isArray(inventory.inputs) ? inventory.inputs : []) .filter((item) => { if (!item || typeof item !== "object") return false; const fields = Array.isArray(item.fields) ? item.fields : []; const type = String(item.type || "").trim(); // BUILD86: keep title Inputs visible even when an older Agent inventory // did not yet include their child fields. This makes the left-hand title // browser useful instead of showing an empty list. return fields.length > 0 || /(?:^|\b)(GT|XAML|TITLE)(?:$|\b)/i.test(type); }) .slice() .sort((a, b) => hockeyPreparedNumber(a.number) - hockeyPreparedNumber(b.number) || String(a.title || "").localeCompare(String(b.title || ""), "ru", { numeric: true, sensitivity: "base" })); } function hockeyPreparedSourceIdentity(input) { if (!input) return ""; return String(input.key || input.number || input.title || "").trim(); } function hockeyPreparedSelectedInput() { const identity = String(state.preparedTitleSourceKey || "").trim(); if (!identity) return null; return hockeyPreparedInventoryInputs().find((item) => hockeyPreparedSourceIdentity(item) === identity || String(item.key || "") === identity || String(item.number || "") === identity) || null; } function hockeyPreparedFieldType(field) { const explicit = String(field?.type || "").toLowerCase(); if (["color", "colour"].includes(explicit) || /\.Color$/i.test(String(field?.name || ""))) return "color"; if (["image", "source"].includes(explicit) || /\.Source$/i.test(String(field?.name || ""))) return "image"; return "text"; } function hockeyPreparedFieldValuesForInput(input, { preserve = true } = {}) { const next = {}; (Array.isArray(input?.fields) ? input.fields : []).forEach((field) => { const name = String(field?.name || "").trim(); if (!name) return; const type = hockeyPreparedFieldType(field); const previous = state.preparedTitleFieldValues?.[name]; next[name] = { type, value: preserve && previous && typeof previous === "object" ? String(previous.value ?? "") : "", touched: Boolean(preserve && previous && typeof previous === "object" && previous.touched), }; }); state.preparedTitleFieldValues = next; return next; } function hockeyPreparedWorkspaceKey() { return [currentRuntimeVmixDeviceId(), hockeyTimerSelectedGameId()].join("|"); } // BUILD86: direct backend endpoint family: /api/hockey/prepared-titles // hockeyGameControlRequest() adds the /api/hockey prefix exactly once. async function hockeyLoadPreparedMappingSources(panelId = state.preparedTitlePanelId, { render = false } = {}) { const clean = String(panelId || "").trim(); if (!clean) { state.preparedTitleMappingSources = []; if (render && state.activeTab === "prepared_titles") renderRuntime(); return []; } try { const params = new URLSearchParams(); const deviceId = currentRuntimeVmixDeviceId(); if (deviceId) params.set("device_id", deviceId); params.set("panel_id", clean); const payload = await hockeyGameControlRequest(`/prepared-titles/mapping-sources?${params.toString()}`); state.preparedTitleMappingSources = Array.isArray(payload?.items) ? payload.items : []; if (!state.preparedTitleSourceKey && state.preparedTitleMappingSources.length === 1) { const source = state.preparedTitleMappingSources[0]; const inventoryMatch = hockeyPreparedInventoryInputs().find((item) => (source.key && String(item.key || "") === String(source.key)) || (source.number && String(item.number || "") === String(source.number)) || (source.title && String(item.title || "") === String(source.title)) ); if (inventoryMatch) { state.preparedTitleSourceKey = hockeyPreparedSourceIdentity(inventoryMatch); hockeyPreparedFieldValuesForInput(inventoryMatch, { preserve: false }); } } if (render && state.activeTab === "prepared_titles") renderRuntime(); return state.preparedTitleMappingSources; } catch (error) { console.error("Prepared title mapping sources error", error); state.preparedTitleMappingSources = []; return []; } } async function hockeyLoadPreparedTitlesWorkspace({ force = false, render = true } = {}) { if (state.preparedTitlesLoading) return false; const key = hockeyPreparedWorkspaceKey(); if (!force && state.preparedTitlesLoadedKey === key && state.preparedTitleInventory?.inventory) { if (state.preparedTitlePanelId && !state.preparedTitleMappingSources.length) await hockeyLoadPreparedMappingSources(state.preparedTitlePanelId); return true; } state.preparedTitlesLoading = true; if (render && state.activeTab === "prepared_titles") renderRuntime(); try { const deviceId = currentRuntimeVmixDeviceId(); const gameId = hockeyTimerSelectedGameId(); const invParams = new URLSearchParams(); if (deviceId) invParams.set("device_id", deviceId); const listParams = new URLSearchParams(); if (deviceId) listParams.set("device_id", deviceId); if (gameId) listParams.set("game_id", gameId); let inventoryPayload = null; try { inventoryPayload = await hockeyGameControlRequest(`/agents/vmix-inventory${invParams.toString() ? `?${invParams}` : ""}`); } catch (inventoryError) { // A browser can retain an obsolete locally-selected Agent id. Retry the // active account Agent before giving up, so the title browser still opens. if (deviceId) { inventoryPayload = await hockeyGameControlRequest("/agents/vmix-inventory"); } else { throw inventoryError; } } let preparedPayload = { items: [] }; try { preparedPayload = await hockeyGameControlRequest(`/prepared-titles${listParams.toString() ? `?${listParams}` : ""}`); } catch (preparedListError) { console.error("Prepared titles saved-list error", preparedListError); // Saved-list failure must not hide the source title inventory/editor. preparedPayload = { items: [] }; } state.preparedTitleInventory = inventoryPayload || { device_id: "", device_name: "", inventory: { inputs: [] } }; state.preparedTitles = Array.isArray(preparedPayload?.items) ? preparedPayload.items : []; state.preparedTitlesLoadedKey = key; const selected = hockeyPreparedSelectedInput(); if (state.preparedTitleSourceKey && !selected) { state.preparedTitleSourceKey = ""; state.preparedTitleFieldValues = {}; } else if (selected && !Object.keys(state.preparedTitleFieldValues || {}).length) { hockeyPreparedFieldValuesForInput(selected, { preserve: false }); } if (state.preparedTitlePanelId) await hockeyLoadPreparedMappingSources(state.preparedTitlePanelId); if (state.preparedTitleAutoSnapshotPending && state.preparedTitleSourceKey) { state.preparedTitleAutoSnapshotPending = false; setTimeout(() => hockeyApplyPreparedMappingSnapshot().catch(() => {}), 0); } } catch (error) { toast(`Заготовки: ${error.message}`, true); console.error(error); } finally { state.preparedTitlesLoading = false; if (render && state.activeTab === "prepared_titles") renderRuntime(); } return true; } function hockeySelectPreparedSource(input, { clearPanel = false } = {}) { if (!input) return false; state.preparedTitleEditingId = ""; state.preparedTitleSourceKey = hockeyPreparedSourceIdentity(input); hockeyPreparedFieldValuesForInput(input, { preserve: false }); state.preparedTitleName = ""; if (clearPanel) { state.preparedTitlePanelId = ""; state.preparedTitleMappingSources = []; } renderRuntime(); return true; } async function hockeyApplyPreparedMappingSnapshot() { const input = hockeyPreparedSelectedInput(); if (!input) { toast("Сначала выберите vMix Input", true); return false; } state.preparedTitleSnapshotLoading = true; renderRuntime(); try { const params = new URLSearchParams(); const deviceId = currentRuntimeVmixDeviceId(); if (deviceId) params.set("device_id", deviceId); if (input.key) params.set("input_key", input.key); if (input.number) params.set("input_number", input.number); if (input.title) params.set("input_title", input.title); if (state.preparedTitlePanelId) params.set("panel_id", state.preparedTitlePanelId); const payload = await hockeyGameControlRequest(`/prepared-titles/mapping-snapshot?${params.toString()}`); const fields = Array.isArray(payload?.fields) ? payload.fields : []; if (!fields.length) { toast(state.preparedTitlePanelId ? "Для этого блока и Input нет Mapping-связей" : "Для этого Input нет Mapping-связей", true); return false; } const next = { ...(state.preparedTitleFieldValues || {}) }; fields.forEach((field) => { const name = String(field?.name || ""); if (!name || !Object.prototype.hasOwnProperty.call(next, name)) return; next[name] = { type: hockeyPreparedFieldType(field), value: String(field?.value ?? ""), touched: true }; }); state.preparedTitleFieldValues = next; toast(`Подставлено из Mapping: ${fields.length}`); return true; } catch (error) { toast(`Mapping → заготовка: ${error.message}`, true); return false; } finally { state.preparedTitleSnapshotLoading = false; renderRuntime(); } } async function hockeyCreatePreparedTitle() { const input = hockeyPreparedSelectedInput(); if (!input) { toast("Сначала выберите исходный vMix Input", true); return false; } const name = String(state.preparedTitleName || "").trim(); const fieldValues = {}; Object.entries(state.preparedTitleFieldValues || {}).forEach(([fieldName, entry]) => { if (!entry?.touched) return; fieldValues[fieldName] = { type: String(entry?.type || "text"), value: String(entry?.value ?? ""), }; }); state.preparedTitlesLoading = true; renderRuntime(); try { const payload = await hockeyGameControlRequest("/prepared-titles", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, device_id: currentRuntimeVmixDeviceId(), session_token: currentRuntimeHockeySessionToken(), source_input_key: String(input.key || ""), source_input_number: String(input.number || ""), source_input_title: String(input.title || ""), source_kind: state.preparedTitlePanelId ? "player_selection" : "manual", source_ref: state.preparedTitlePanelId || "", field_values: fieldValues, }), }); toast(`Заготовка сохранена${payload?.clone_input?.number ? ` · Input #${payload.clone_input.number}` : ""}`); state.preparedTitleName = ""; state.preparedTitleEditingId = ""; state.preparedTitlePanelId = ""; state.preparedTitleMappingSources = []; state.preparedTitlesLoadedKey = ""; await hockeyLoadPreparedTitlesWorkspace({ force: true, render: false }); return true; } catch (error) { toast(`Не удалось создать заготовку: ${error.message}`, true); return false; } finally { state.preparedTitlesLoading = false; renderRuntime(); } } function hockeyPreparedInventoryMatch(ref = {}) { const inputs = hockeyPreparedInventoryInputs(); return inputs.find((item) => (ref?.key && String(item.key || "") === String(ref.key)) || (ref?.number && String(item.number || "") === String(ref.number)) || (ref?.title && String(item.title || "") === String(ref.title)) ) || null; } function hockeyBeginEditPreparedTitle(id) { const item = state.preparedTitles.find((entry) => String(entry?.id) === String(id)); if (!item) return false; const input = hockeyPreparedInventoryMatch(item.clone_input) || hockeyPreparedInventoryMatch(item.source_input); state.preparedTitleEditingId = String(item.id || ""); state.preparedTitleName = String(item.name || ""); state.preparedTitlePanelId = ""; state.preparedTitleMappingSources = []; state.preparedTitleSourceKey = input ? hockeyPreparedSourceIdentity(input) : ""; state.preparedTitleFieldValues = {}; if (input) hockeyPreparedFieldValuesForInput(input, { preserve: false }); Object.entries(item.field_values || {}).forEach(([name, raw]) => { if (!name) return; const entry = raw && typeof raw === "object" ? raw : { value: raw, type: "text" }; const currentType = state.preparedTitleFieldValues?.[name]?.type || String(entry.type || "text"); state.preparedTitleFieldValues[name] = { type: currentType, value: String(entry.value ?? ""), touched: true, }; }); renderRuntime(); return true; } function hockeyCancelPreparedEdit() { state.preparedTitleEditingId = ""; state.preparedTitleName = ""; state.preparedTitleSourceKey = ""; state.preparedTitleFieldValues = {}; renderRuntime(); return true; } async function hockeyUpdatePreparedTitle() { const id = String(state.preparedTitleEditingId || "").trim(); if (!id) return false; const fieldValues = {}; Object.entries(state.preparedTitleFieldValues || {}).forEach(([fieldName, entry]) => { if (!entry?.touched) return; fieldValues[fieldName] = { type: String(entry?.type || "text"), value: String(entry?.value ?? ""), }; }); state.preparedTitlesLoading = true; renderRuntime(); try { const payload = await hockeyGameControlRequest(`/prepared-titles/${encodeURIComponent(id)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: String(state.preparedTitleName || "").trim(), device_id: currentRuntimeVmixDeviceId(), session_token: currentRuntimeHockeySessionToken(), field_values: fieldValues, }), }); toast(`Заготовка обновлена${payload?.clone_input?.number ? ` · Input #${payload.clone_input.number}` : ""}`); state.preparedTitleEditingId = ""; state.preparedTitleName = ""; state.preparedTitleSourceKey = ""; state.preparedTitleFieldValues = {}; state.preparedTitlesLoadedKey = ""; await hockeyLoadPreparedTitlesWorkspace({ force: true, render: false }); return true; } catch (error) { toast(`Не удалось обновить заготовку: ${error.message}`, true); return false; } finally { state.preparedTitlesLoading = false; renderRuntime(); } } async function hockeyPreviewPreparedTitle(id) { try { await hockeyGameControlRequest(`/prepared-titles/${encodeURIComponent(id)}/preview`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ device_id: currentRuntimeVmixDeviceId(), session_token: currentRuntimeHockeySessionToken() }), }); toast("Заготовка отправлена в Preview"); return true; } catch (error) { toast(`Preview: ${error.message}`, true); return false; } } async function hockeyDeletePreparedTitle(id) { if (!confirm("Убрать заготовку из списка? Сам vMix Input останется в проекте.")) return false; try { await hockeyGameControlRequest(`/prepared-titles/${encodeURIComponent(id)}`, { method: "DELETE" }); state.preparedTitles = state.preparedTitles.filter((item) => String(item.id) !== String(id)); if (String(state.preparedTitleEditingId || "") === String(id)) hockeyCancelPreparedEdit(); renderRuntime(); return true; } catch (error) { toast(`Удаление заготовки: ${error.message}`, true); return false; } } function hockeyOpenPreparedFromPlayerPanel(panel) { if (!panel) return false; state.preparedTitleEditingId = ""; state.preparedTitlePanelId = String(panel.id || ""); state.preparedTitleName = ""; state.preparedTitleSourceKey = ""; state.preparedTitleFieldValues = {}; state.preparedTitleMappingSources = []; state.preparedTitleAutoSnapshotPending = true; state.preparedTitlesLoadedKey = ""; activateRuntimeTab("prepared_titles", { source: "player-selection" }); setTimeout(() => hockeyLoadPreparedTitlesWorkspace({ force: true }).catch(() => {}), 0); return true; } function renderHockeyPreparedTitlesWorkspace() { const root = document.createElement("section"); root.className = "hockey-prepared-workspace"; const inputs = hockeyPreparedInventoryInputs(); const search = String(state.preparedTitleSearch || "").trim().toLowerCase(); const filtered = inputs.filter((item) => !search || `${item.number || ""} ${item.title || ""} ${item.type || ""}`.toLowerCase().includes(search)); const selected = hockeyPreparedSelectedInput(); const fields = selected && Array.isArray(selected.fields) ? selected.fields.slice().sort((a, b) => String(a?.name || "").localeCompare(String(b?.name || ""), "ru", { numeric: true, sensitivity: "base" })) : []; const panel = state.preparedTitlePanelId ? hockeyPlayerSelectionPanels().find((item) => item.id === state.preparedTitlePanelId) : null; const mappingSources = Array.isArray(state.preparedTitleMappingSources) ? state.preparedTitleMappingSources : []; const editingId = String(state.preparedTitleEditingId || "").trim(); const editingItem = editingId ? state.preparedTitles.find((item) => String(item?.id) === editingId) : null; root.innerHTML = `
VMIX PRESETSЗаготовки${escapeHtml(state.preparedTitleInventory?.device_name || "Agent не выбран")} · матч ${escapeHtml(hockeyTimerSelectedGameId() || "—")}
${panel ? `
БЛОК ИГРОКОВ${escapeHtml(panel.label)}Выберите титр, связанный с player_select.${escapeHtml(panel.id)}.*, затем подставьте текущие данные.
` : ""}
${selected ? `
${editingItem ? "РЕДАКТИРОВАНИЕ" : "ИСТОЧНИК"}#${escapeHtml(selected.number || "—")} · ${escapeHtml(selected.title || "Input")}${editingItem ? `Заготовка #${escapeHtml(editingItem.id)} · изменяется существующий Input` : `${escapeHtml(selected.type || "")} · элементов ${fields.length}`}
${editingItem ? `` : panel ? `` : ``}
${fields.map((field) => { const name = String(field?.name || ""); const type = hockeyPreparedFieldType(field); const value = String(state.preparedTitleFieldValues?.[name]?.value ?? ""); const color = /^#[0-9A-Fa-f]{6}$/.test(value) ? value : "#ffffff"; return ``; }).join("") || `
У Input нет доступных элементов
`}
${editingItem ? "Изменения применятся к уже созданному vMix Input: название и отредактированные поля обновятся без создания новой копии." : "Создаётся виртуальная копия, Input получает имя заготовки. vMix API не даёт назначить/создать категорию программно, поэтому копия остаётся в конце проекта."}
` : `
Выберите vMix InputСправа появятся все его .Text / .Source / .Color элементы для ручной заготовки.
`}
`; root.querySelector("[data-prepared-refresh]")?.addEventListener("click", () => { state.preparedTitlesLoadedKey = ""; hockeyLoadPreparedTitlesWorkspace({ force: true }).catch(() => {}); }); root.querySelector(".hockey-prepared-search input")?.addEventListener("input", (event) => { state.preparedTitleSearch = event.target.value || ""; renderRuntime(); setTimeout(() => el.runtimeStage.querySelector(".hockey-prepared-search input")?.focus({ preventScroll: true }), 0); }); root.querySelectorAll("[data-prepared-input]").forEach((button) => button.addEventListener("click", () => { const identity = button.dataset.preparedInput || ""; const input = inputs.find((item) => hockeyPreparedSourceIdentity(item) === identity); if (input) hockeySelectPreparedSource(input); })); root.querySelectorAll("[data-prepared-mapping-source]").forEach((button) => button.addEventListener("click", async () => { const identity = button.dataset.preparedMappingSource || ""; const source = mappingSources.find((item) => String(item.key || item.number || item.title || "") === identity); const input = source ? inputs.find((item) => (source.key && String(item.key || "") === String(source.key)) || (source.number && String(item.number || "") === String(source.number)) || (source.title && String(item.title || "") === String(source.title)) ) : null; if (!input) return; state.preparedTitleSourceKey = hockeyPreparedSourceIdentity(input); hockeyPreparedFieldValuesForInput(input, { preserve: false }); renderRuntime(); await hockeyApplyPreparedMappingSnapshot(); })); root.querySelector("[data-prepared-clear-context]")?.addEventListener("click", () => { state.preparedTitlePanelId = ""; state.preparedTitleMappingSources = []; renderRuntime(); }); root.querySelector("[data-prepared-apply-mapping]")?.addEventListener("click", () => hockeyApplyPreparedMappingSnapshot()); root.querySelector("[data-prepared-cancel-edit]")?.addEventListener("click", () => hockeyCancelPreparedEdit()); root.querySelector("[data-prepared-name]")?.addEventListener("input", (event) => { state.preparedTitleName = event.target.value || ""; }); root.querySelectorAll("[data-prepared-field]").forEach((inputNode) => inputNode.addEventListener("input", () => { const name = inputNode.dataset.preparedField || ""; if (!name) return; state.preparedTitleFieldValues[name] = { type: inputNode.dataset.preparedFieldType || "text", value: inputNode.value || "", touched: true }; if ((inputNode.dataset.preparedFieldType || "") === "color") { const picker = root.querySelector(`[data-prepared-color="${CSS.escape(name)}"]`); if (picker && /^#[0-9A-Fa-f]{6}$/.test(inputNode.value || "")) picker.value = inputNode.value; } })); root.querySelectorAll("[data-prepared-color]").forEach((picker) => picker.addEventListener("input", () => { const name = picker.dataset.preparedColor || ""; const text = root.querySelector(`[data-prepared-field="${CSS.escape(name)}"]`); if (text) text.value = picker.value; state.preparedTitleFieldValues[name] = { type: "color", value: picker.value, touched: true }; })); root.querySelector("[data-prepared-save]")?.addEventListener("click", () => hockeyCreatePreparedTitle()); root.querySelector("[data-prepared-update]")?.addEventListener("click", () => hockeyUpdatePreparedTitle()); root.querySelectorAll("[data-prepared-edit]").forEach((button) => button.addEventListener("click", () => hockeyBeginEditPreparedTitle(button.dataset.preparedEdit))); root.querySelectorAll("[data-prepared-preview]").forEach((button) => button.addEventListener("click", () => hockeyPreviewPreparedTitle(button.dataset.preparedPreview))); root.querySelectorAll("[data-prepared-delete]").forEach((button) => button.addEventListener("click", () => hockeyDeletePreparedTitle(button.dataset.preparedDelete))); return root; } 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 `2′`; } const icons = {goal:"●",shot:"➤",shootout:"◎",period:"◷",timeout:"Ⅱ",goalie:"▣",comment:"✦",info:"•"}; return `${icons[safe] || "•"}`; } function renderStandaloneHockeyPlayByPlayWindow() { document.getElementById("hockeyStandalonePbp")?.remove(); // 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 = Object.prototype.hasOwnProperty.call(state.formValues, collapsedKey) ? Boolean(state.formValues[collapsedKey]) : true; 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 = `
PLAY-BY-PLAY${language === "en" ? "Match messages" : "Сообщения матча"}
${items.length}
${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 `
${hockeyEventIconMarkup(category, language, "is-compact")}
${escapeHtml(labels[category] || labels.info)}${escapeHtml(title)}${details ? `${escapeHtml(details)}` : ""}
${item?.score ? `${escapeHtml(item.score)}` : ""}
`; }).join("") || `
${language === "en" ? "No messages for this period" : "В этом периоде сообщений нет"}
`}
`; 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(); }); const sideStack = hockeyEnsureRuntimeSideStack(); if (!sideStack) return false; sideStack.appendChild(windowNode); 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 `${item?.goal ? "★" : ""}`; } 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 `
${items.map((item) => hockeyShotPointMarkup(item, sourceItems, language, compact || profile, profile)).join("")}
`; } 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 = `
${language === "en" ? "Shot map is unavailable" : "Карта бросков недоступна"}${language === "en" ? "The match JSON does not contain shots_map" : "В JSON матча нет данных shots_map"}
`; return node; } node.innerHTML = `
SHOTS MAP${language === "en" ? "Team shot map" : "Карта бросков команд"}
${filtered.length}${language === "en" ? "shots" : "бросков"}${onTarget}${language === "en" ? "on target" : "в створ"}${goals}${language === "en" ? "goals" : "голов"}
${hockeyShotRinkMarkup(filtered, allItems, language)} ${filtered.length ? "" : `
${language === "en" ? "No shots for selected filters" : "Для выбранных фильтров бросков нет"}
`}
`; 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 `
SHOTS MAP${language === "en" ? "Shots in this match" : "Карта бросков в матче"}
${items.length} · ${language === "en" ? "goals" : "голы"}: ${goals}
${hockeyShotRinkMarkup(items, allItems, language, true, true)}
`; } 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) => `
${kind === "head" ? "REFEREES" : "LINESMEN"}${escapeHtml(title)}${rows.length}
${rows.map((item) => `
${escapeHtml(item?.number || "—")}
${escapeHtml(item?.role_label || title)}${hockeyPlayerFlagMarkup(item)}${escapeHtml(item?.name || "—")}
`).join("")}
`; if (!items.length) { node.innerHTML = `
${language === "en" ? "Officials are unavailable" : "Данные о судьях не загружены"}${language === "en" ? "Open a game with referee data" : "Откройте матч, в JSON которого есть судьи"}
`; return node; } node.innerHTML = `
${language === "en" ? "GAME OFFICIALS" : "СУДЕЙСКАЯ БРИГАДА"}${escapeHtml(home)} — ${escapeHtml(away)}
${items.length}
${group(language === "en" ? "Referees" : "Главные судьи", heads, "head")} ${group(language === "en" ? "Linesmen" : "Линейные судьи", linesmen, "linesman")}
`; 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 = `
${language === "en" ? "Events are unavailable" : "События недоступны"}${language === "en" ? "The match JSON does not contain events" : "В JSON матча нет массива events"}
`; return node; } node.innerHTML = `
PLAY-BY-PLAY${language === "en" ? "Match timeline" : "Лента событий"}${language === "en" ? "Events and shots from match JSON" : "События и броски из JSON матча"}
${items.length}
${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 `
${escapeHtml(item?.time || item?.moscow_time || "—")}${escapeHtml(period)}
${hockeyEventIconMarkup(category, language)}
${escapeHtml(categoryLabels[category] || item?.label || "")}${escapeHtml(title)}${description ? `

${escapeHtml(description)}

` : ""}${player && !description.includes(player) ? `${escapeHtml(player)}` : ""}
${escapeHtml(item?.team_name || "")}${item?.score ? `${escapeHtml(item.score)}` : ""}
`; }).join("") : `
${language === "en" ? "No events for selected filters" : "Нет событий для выбранных фильтров"}
`}
`; 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 ? `` : ""; 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}
${language === "en" ? "Team statistics are not loaded" : "Командная статистика не загружена"} ${language === "en" ? "Open a game to load Stat2TV data" : "Откройте матч, чтобы загрузить данные Stat2TV"}
`; 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 `
${escapeHtml(hockeyStatisticValue(homeRaw, format))}
${escapeHtml(language === "en" ? labelEn : labelRu)}
${escapeHtml(hockeyStatisticValue(awayRaw, format))}
`; }); const metricBreak = Math.ceil(metricItems.length / 2); const metricMarkup = `
${metricItems.slice(0, metricBreak).join("")}
${metricItems.slice(metricBreak).join("")}
`; node.innerHTML = ` ${modeMarkup}
${language === "en" ? "HOME" : "ХОЗЯЕВА"}${escapeHtml(homeName)}${selected === "total" && statistics.coaches?.home ? `${language === "en" ? "Coach" : "Тренер"}: ${escapeHtml(statistics.coaches.home)}` : ""}
${escapeHtml(homeScore)} : ${escapeHtml(awayScore)}${language === "en" ? "TEAM STATISTICS" : "КОМАНДНАЯ СТАТИСТИКА"}
${language === "en" ? "AWAY" : "ГОСТИ"}${escapeHtml(awayName)}${selected === "total" && statistics.coaches?.away ? `${language === "en" ? "Coach" : "Тренер"}: ${escapeHtml(statistics.coaches.away)}` : ""}
${metricMarkup}
`; 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 `${escapeHtml(hockeyStandingCell(team?.rank, String(index+1)))}${escapeHtml(team?.name || "—")}${escapeHtml(hockeyStandingCell(team?.games,"0"))}${escapeHtml(hockeyStandingCell(team?.wins,"0"))}${escapeHtml(hockeyStandingCell(team?.overtime_wins,"0"))}${escapeHtml(hockeyStandingCell(team?.shootout_wins,"0"))}${escapeHtml(hockeyStandingCell(team?.shootout_losses,"0"))}${escapeHtml(hockeyStandingCell(team?.overtime_losses,"0"))}${escapeHtml(hockeyStandingCell(team?.losses,"0"))}${escapeHtml(goals)}${escapeHtml(hockeyStandingCell(team?.points,"0"))}`; }).join(""); const headers=defs.map(([ref,fallback],index)=>`${escapeHtml(hockeyStatLabel(ref,fallback,language,"standings"))}`).join(""); return `
${escapeHtml(title)}${teams.length}
${headers}${rows}
`; } 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 = `
${language === "en" ? "Standings are not loaded" : "Турнирная таблица не загружена"} ${language === "en" ? "Select a tournament to load Stat2TV data" : "Выберите турнир, чтобы загрузить данные Stat2TV"}
`; return node; } node.innerHTML = `
${language === "en" ? "TOURNAMENT" : "ТУРНИР"} ${language === "en" ? "Standings" : "Турнирная таблица"}
${standings.generated_at ? `${language === "en" ? "Updated" : "Обновлено"}: ${escapeHtml(standings.generated_at)}` : ""}
${groups.map((group) => hockeyStandingsGroupMarkup(group, language)).join("")}
`; 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 ``; } const flag = String(player?.country_flag || player?.flag || "").trim(); return flag ? `${escapeHtml(flag)}` : ""; } 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) => `${hockeyPlayerFlagMarkup(player)}${escapeHtml(player?.name || "—")}`; if (players.length === 1) return `${hockeyPlayerFlagMarkup(players[0])}${escapeHtml(players[0]?.name || "—")}`; return `
${hockeyPlayerFlagMarkup(players[0])}${escapeHtml(players[0]?.name || "—")}+${players.length - 1}
${players.map(item).join("")}
`; } 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 ` ${escapeHtml(player.number || "—")} ${hockeyPlayerFlagMarkup(player)}${escapeHtml(player.name || "—")}${captain ? `${captain}` : ""} ${escapeHtml(player.position || "—")}${escapeHtml(hockeyPlayerMetric(stats.goals, ""))} ${escapeHtml(hockeyPlayerMetric(stats.assists, ""))}${escapeHtml(hockeyPlayerMetric(stats.points, ""))} ${escapeHtml(hockeyPlayerMetric(stats.shots, ""))}${escapeHtml(hockeyPlayerMetric(stats.hits, ""))} ${escapeHtml(hockeyPlayerMetric(stats.blocked_shots, ""))}${escapeHtml(hockeyPlayerMetric(stats.penalty_minutes, ""))} ${escapeHtml(hockeyPlayerMetric(stats.time_on_ice, ""))}`; }).join(""); const headers = headerDefs.map(([ref, fallback, section], index) => `${escapeHtml(hockeyStatLabel(ref, fallback, language, section))}`).join(""); return `
${sideLabel}${escapeHtml(teamName)}
${players.length}
${headers}${rows}
`; } 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 `
${escapeHtml(player.number || "—")}${hockeyPlayerFlagMarkup(player)}${escapeHtml(player.name || "—")}${played ? (language === "en" ? "Played" : "Играл") : (language === "en" ? "Did not play" : "Не играл")}
${language === "en" ? "SA" : "Броски"}
${escapeHtml(hockeyPlayerMetric(stats.shots_against))}
${language === "en" ? "SV" : "Сейвы"}
${escapeHtml(hockeyPlayerMetric(stats.saves))}
${language === "en" ? "GA" : "Проп."}
${escapeHtml(hockeyPlayerMetric(stats.goals_against))}
${language === "en" ? "SV%" : "% ОБ"}
${escapeHtml(hockeyPlayerMetric(stats.save_pct, stats.save_pct === null || stats.save_pct === undefined ? "" : "%"))}
${language === "en" ? "TOI" : "Время"}
${escapeHtml(hockeyPlayerMetric(stats.time_on_ice))}
`; } 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 `
`; } 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 = `
${language === "en" ? "Player statistics are not loaded" : "Статистика игроков не загружена"}${language === "en" ? "Open a game to load Stat2TV data" : "Откройте матч, чтобы загрузить данные Stat2TV"}
`; return node; } node.innerHTML = `
${language === "en" ? "MATCH" : "МАТЧ"}${language === "en" ? "Player statistics" : "Статистика игроков"}
${leaders.map((leader) => `
${escapeHtml(leader.label)}${escapeHtml(leader.group.value)}${hockeyLeaderNamesMarkup(leader.group.players)}
`).join("")}
${sides.map((side) => hockeyPlayerTableMarkup(side, teamNames[side], (payload[side]?.skaters || []).filter((player) => !query || `${player.number || ""} ${player.name || ""}`.toLocaleLowerCase().includes(query)), language)).join("")}
${language === "en" ? "Goalkeepers" : "Вратари"}${language === "en" ? "Separate match statistics" : "Отдельная статистика матча"}
${sides.map((side) => `
${escapeHtml(teamNames[side])}
${(payload[side]?.goalkeepers || []).map((player) => hockeyGoalkeeperCardMarkup(side, player, language)).join("")}
`).join("")}
${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 ` ${escapeHtml(player.number || stats.jersey_number || "—")} ${hockeyPlayerFlagMarkup(player)}${escapeHtml(player.name || "—")} ${escapeHtml(hockeyPlayerMetric(stats.games))}${escapeHtml(hockeyPlayerMetric(stats.goals))} ${escapeHtml(hockeyPlayerMetric(stats.assists))}${escapeHtml(hockeyPlayerMetric(stats.points))} ${escapeHtml(hockeyPlayerMetric(stats.plus_minus))} ${escapeHtml(hockeyPlayerMetric(stats.shots))}${escapeHtml(hockeyPlayerMetric(stats.shot_pct, "%"))} ${escapeHtml(hockeyPlayerMetric(stats.penalty_minutes))}${escapeHtml(hockeyPlayerMetric(stats.average_time_on_ice))}`; }).join(""); const headers = headerDefs.map(([ref, fallback, section], index) => `${escapeHtml(hockeyStatLabel(ref, fallback, language, section))}`).join(""); return `
${sideLabel}${escapeHtml(teamName)}
${players.length}
${headers}${rows}
`; } function hockeySeasonGoalkeeperCardMarkup(side, player, language) { const stats = player.season_statistics || {}; const playerKey = `${side}:${player.id || player.external_id || player.number}`; return `
${escapeHtml(player.number || stats.jersey_number || "—")}${hockeyPlayerFlagMarkup(player)}${escapeHtml(player.name || "—")}${escapeHtml(`${language === "en" ? "Tournament games" : "Матчи в турнире"}: ${hockeyPlayerMetric(stats.games)}`)}
${language === "en" ? "W" : "В"}
${escapeHtml(hockeyPlayerMetric(stats.wins))}
${language === "en" ? "L" : "П"}
${escapeHtml(hockeyPlayerMetric(stats.losses))}
${language === "en" ? "SO" : "Сух"}
${escapeHtml(hockeyPlayerMetric(stats.shutouts))}
${language === "en" ? "SV" : "Сейвы"}
${escapeHtml(hockeyPlayerMetric(stats.saves))}
${language === "en" ? "SV%" : "% ОБ"}
${escapeHtml(hockeyPlayerMetric(stats.save_pct, "%"))}
${language === "en" ? "GAA" : "КН"}
${escapeHtml(hockeyPlayerMetric(stats.goals_against_average))}
`; } 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 `
`; } 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 = `
${language === "en" ? "Season statistics are not loaded" : "Сезонная статистика не загружена"}${language === "en" ? "Reopen the game to sync players XML" : "Откройте матч заново для загрузки players XML"}
`; return node; } node.innerHTML = `
${language === "en" ? "TOURNAMENT" : "ТУРНИР"}${language === "en" ? "Season statistics" : "Сезонная статистика"}
${leaders.map((leader) => `
${escapeHtml(leader.label)}${escapeHtml(leader.group.value)}${hockeyLeaderNamesMarkup(leader.group.players)}
`).join("")}
${sides.map((side) => hockeySeasonPlayerTableMarkup(side, teamNames[side], (payload[side]?.skaters || []).filter((player) => !query || `${player.number || ""} ${player.name || ""}`.toLocaleLowerCase().includes(query)), language)).join("")}
${language === "en" ? "Goalkeepers · season" : "Вратари · сезон"}${language === "en" ? "Separate tournament statistics" : "Отдельная турнирная статистика"}
${sides.map((side) => `
${escapeHtml(teamNames[side])}
${(payload[side]?.goalkeepers || []).map((player) => hockeySeasonGoalkeeperCardMarkup(side, player, language)).join("")}
`).join("")}
${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 = `
${language === "en" ? "Statistics are unavailable" : "Статистика недоступна"}${language === "en" ? "This league does not provide this XML" : "Для этой лиги соответствующий XML не предоставляется"}
`; 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 = `
${isPowerplay ? "POWER PLAY" : "RANK"}${escapeHtml(title)}${escapeHtml(subtitle)}
${sections.length > 1 ? `` : `${escapeHtml(section?.label || title)}`}
${hasRank ? `` : ""} ${hasNumber ? `` : ""} ${hasTeam ? `` : ""} ${columns.map((column) => { const sectionName = isPowerplay ? "powerplay" : "team"; const label = hockeyStatLabel(column.key, column.label || column.key, language, sectionName); return `${escapeHtml(label)}`; }).join("")} ${rows.map((row, index) => { const currentSide = hockeyTournamentCurrentTeamSide(row, resourceType); const rowClass = currentSide ? `htr-current-team side-${currentSide}` : ""; return ` ${hasRank ? `` : ""} ${hasNumber ? `` : ""} ${hasTeam ? `` : ""} ${columns.map((column) => ``).join("")} `; }).join("")}
#${language === "en" ? "Team" : "Команда"}${language === "en" ? "Team" : "Команда"}
${escapeHtml(row.rank || String(index + 1))}${escapeHtml(row.number || "—")}${escapeHtml(isPowerplay ? (row.name || row.team || "—") : (row.team || row.name || "—"))}${escapeHtml(row.team || "—")}${escapeHtml(hockeyTournamentStatisticValue(row.values?.[column.key], column.format))}
`; 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 = ` ${language === "en" ? "STATISTICS" : "СТАТИСТИКА"} `; 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 } ); hockeyBackupPlayerSelectionValues(gameId, payload?.values || {}); const strengthChanged = !previousControl || hockeyStrengthMappingSignature(previousControl) !== hockeyStrengthMappingSignature(payload); if (strengthChanged) { hockeyRefreshVmixMappingForStrength(gameId, previousControl, payload).catch(() => {}); // BUILD89: keep a second rebalance after the authoritative control payload // arrives. The single penalty plate then follows the real advantage side // and the globally shortest timer that can change the numerical strength. if (state.activeHockeyVmixTimerSteps.size) { // Build87 compatibility marker: rebalanceVmixPenaltyTargets({ force: true, hideUnused: true }) // BUILD95 uses a non-forced pass so a merely prepared penalty cannot // unnecessarily restart an already running vMix countdown. rebalanceVmixPenaltyTargets({ force: false, hideUnused: true }) .catch((error) => console.error("Penalty strength rebalance error", error)); } } 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; const externalId = String(player.externalId || player.external_id || player.id || player.raw?.external_id || player.raw?.id || ""); const dbId = String(player.dbId || player.db_id || player.database_id || player.raw?.db_id || player.raw?.database_id || ""); return { id: externalId, externalId, dbId, 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), startedOnce: Boolean(event.startedOnce || event.running) || (Math.max(0, Number(event.durationMs || 0)) > 0 && Math.max(0, Number(event.remainingMs ?? event.durationMs ?? 0)) < Math.max(0, Number(event.durationMs || 0))), 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), advantage_cycle: { had_advantage: Boolean(state.hockeyPenaltyAdvantageCycle.hadAdvantage), last_advantage_side: ["home", "away"].includes(String(state.hockeyPenaltyAdvantageCycle.lastAdvantageSide || "")) ? String(state.hockeyPenaltyAdvantageCycle.lastAdvantageSide) : "", }, }, }; } 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); } let restoredPenaltyAdvantageCycle = null; const boardComponent = hockeyPenaltyBoardComponent(); if (boardComponent) { const source = timers?.penalty_board && typeof timers.penalty_board === "object" ? timers.penalty_board : {}; const savedCycle = source?.advantage_cycle && typeof source.advantage_cycle === "object" ? source.advantage_cycle : null; if (savedCycle) { const savedSide = String(savedCycle.last_advantage_side || ""); restoredPenaltyAdvantageCycle = { hadAdvantage: Boolean(savedCycle.had_advantage) && ["home", "away"].includes(savedSide), lastAdvantageSide: ["home", "away"].includes(savedSide) ? savedSide : "", }; } 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 || ""); if (restoredPenaltyAdvantageCycle) { state.hockeyPenaltyAdvantageCycle.hadAdvantage = Boolean(restoredPenaltyAdvantageCycle.hadAdvantage); state.hockeyPenaltyAdvantageCycle.lastAdvantageSide = String(restoredPenaltyAdvantageCycle.lastAdvantageSide || ""); } else { resetPenaltyAdvantageCycle(); } state.hockeyPenaltyMappingContextSignature = ""; hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {}); } 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 `${attempt ? (attempt.scored ? "✓" : "×") : index + 1}`; }).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 = `
${language === "en" ? "Select a game" : "Выберите матч"}${language === "en" ? "The shootout roster will appear after the game is loaded" : "Составы для буллитов появятся после загрузки матча"}
`; return node; } const regular = String(tournament?.stage_key || "").toLowerCase() === "regular"; if (!regular) { node.innerHTML = `
${language === "en" ? "Shootout panel is unavailable" : "Вкладка буллитов недоступна"}${language === "en" ? "It is enabled for regular-season games" : "Она включается только для матчей регулярного чемпионата"}
`; return node; } const control = state.hockeyGameControl[gameId]; if (!control) { if (runtime) queueMicrotask(() => hockeyLoadGameControl(gameId)); node.innerHTML = `
${language === "en" ? "Loading shootout data…" : "Загружаем данные буллитов…"}
`; 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 ``; }); if (!rows.length) return `
${language === "en" ? "Roster is empty" : "Состав не загружен"}
`; 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("")}
${language === "en" ? "No players found" : "Игроки не найдены"}
`; }; const searchBox = (side, value) => ``; 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 `
${escapeHtml(attempt.sequence_number)}${escapeHtml(seriesLabel)}
${escapeHtml(attempt.player_name || "—")}${escapeHtml(sideName)} · ${language === "en" ? "attempt" : "буллит"} №${escapeHtml(attempt.team_attempt_number)}
${attempt.scored ? `${language === "en" ? "GOAL" : "ГОЛ"}` : `×${language === "en" ? "MISS" : "НЕ ЗАБИЛ"}`}
`; }).join("") || `
${language === "en" ? "No attempts yet" : "Журнал пока пуст"}
`; node.innerHTML = `
${language === "en" ? "SHOOTOUT" : "БУЛЛИТЫ"}${escapeHtml(home.name || "—")} — ${escapeHtml(away.name || "—")}
${escapeHtml(home.name || "HOME")}${Number(shootout.home?.goals || 0)} : ${Number(shootout.away?.goals || 0)}${escapeHtml(away.name || "AWAY")}
${language === "en" ? "Main series" : "Основная серия"}
${shootout.can_add_round ? `` : ""}
${allowed}${language === "en" ? "attempts per team available" : "буллитов доступно каждой команде"}
${escapeHtml(home.name || "—")}
${hockeyShootoutSlots(homeAttempts, allowed, language)}
${escapeHtml(away.name || "—")}
${hockeyShootoutSlots(awayAttempts, allowed, language)}
${language === "en" ? "HOME TEAM" : "ЛЕВАЯ КОМАНДА"}${escapeHtml(home.name || "—")}
${searchBox("home", state.formValues[searchKey("home")] || "")}
${playerRows(homePlayers, "home", homeSearch)}
${selected ? `${language === "en" ? "Shooter selected" : "Выбран игрок"}#${escapeHtml(selected.number || "—")} ${escapeHtml(selected.name || "—")}
` : `${language === "en" ? "Select a player from either roster" : "Выберите игрока в одном из составов"}`}
${language === "en" ? "ATTEMPT LOG" : "ЖУРНАЛ БУЛЛИТОВ"}${attempts.length}
${journal}
${language === "en" ? "AWAY TEAM" : "ПРАВАЯ КОМАНДА"}${escapeHtml(away.name || "—")}
${searchBox("away", state.formValues[searchKey("away")] || "")}
${playerRows(awayPlayers, "away", awaySearch)}
`; 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 `
${escapeHtml(monogram)} ${escapeHtml(name)}${city ? `${escapeHtml(city)}` : ""} ${showScore ? Number(score || 0) : "—"}
`; } 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 `
${language === "en" ? "No games for this date" : "На выбранную дату матчей нет"}${language === "en" ? "Switch to Team matches to see the full calendars of both clubs." : "Переключитесь на «Матчи команд», чтобы увидеть полный календарь обеих команд."}
`; } return `
${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 `
${escapeHtml(status.label)}${selected ? `${language === "en" ? "CURRENT" : "ТЕКУЩИЙ"}` : ""}${game?.game_number ? `#${escapeHtml(game.game_number)}` : ""}
${hockeyScheduleTeamMarkup(game?.home, game?.home?.score, "home", showScore)}
${showScore ? "" : "VS"}
${hockeyScheduleTeamMarkup(game?.away, game?.away?.score, "away", showScore)}
${periods.length ? `
${periods.map((value, index) => `${index + 1}${escapeHtml(value)}`).join("")}
` : ""}
`; }).join("")}
`; } 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 `
${escapeHtml(game?.time || "—:—")}
${venue}${escapeHtml(opponent?.name || opponent?.short_name || "—")}${escapeHtml(game?.arena || game?.arena_city || "")}
${escapeHtml(status.label)}${showScore ? `${Number(ownScore || 0)}:${Number(opponentScore || 0)}` : "—"}
${h2h ? `${language === "en" ? "HEAD-TO-HEAD" : "ОЧНАЯ ВСТРЕЧА"}` : ""}
`; } function teamColumn(team, side) { const games = filterTeamGames(team); return `
${side === "home" ? (language === "en" ? "LEFT TEAM" : "ЛЕВАЯ КОМАНДА") : (language === "en" ? "RIGHT TEAM" : "ПРАВАЯ КОМАНДА")}${escapeHtml(team?.name || team?.short_name || "—")}${games.length}
${games.length ? games.map((game) => teamHistoryCard(game, team)).join("") : `
${language === "en" ? "No matches for selected filters" : "Нет матчей по выбранным фильтрам"}
`}
`; } const teamControls = innerView === "teams" ? `
` : ""; node.innerHTML = `
${language === "en" ? "LEAGUE SCHEDULE" : "РАСПИСАНИЕ ЛИГИ"}${escapeHtml(tournamentName)}${innerView === "day" ? escapeHtml(hockeyScheduleDateLabel(dateValue, language)) : (language === "en" ? "Full schedules of the selected teams" : "Полный календарь выбранных команд")}
${innerView === "day" ? `
${items.length}${language === "en" ? "games" : "матчей"}
${liveCount ? `
${liveCount}${language === "en" ? "live" : "сейчас"}
` : ""}${scheduledCount ? `
${scheduledCount}${language === "en" ? "upcoming" : "впереди"}
` : ""}${finishedCount ? `
${finishedCount}${language === "en" ? "finished" : "завершено"}
` : ""}
` : `
${allItems.filter((game) => isHeadToHead(game)).length}${language === "en" ? "head-to-head" : "очных"}
`}
${canShowTeams ? `` : ""}${teamControls}
${innerView === "teams" && canShowTeams ? `
${teamColumn(homeTeam, "home")}${teamColumn(awayTeam, "away")}
` : renderDayCards()}
`; 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", `${escapeHtml(component.title)}
${escapeHtml(props.body)}
`); 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 = `
${escapeHtml(props.label || "Значение")}
${escapeHtml(`${props.prefix || ""}${formatValue(value, props.fallback || "—")}${props.suffix || ""}`)}
`; 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 = `
${escapeHtml(props.label || "Прогресс")}${escapeHtml(`${formatValue(raw, "0")}${props.suffix || ""}`)}
`; 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 = `${columns.map((col) => `${escapeHtml(col.label)}`).join("")}${data.length ? data.map((row) => `${columns.map((col) => `${escapeHtml(formatValue(getByPath(row, col.key)))}`).join("")}`).join("") : `${escapeHtml(props.emptyText || "Нет данных")}`}`; 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 = `${escapeHtml(formatValue(getByPath(row, props.titleField)))}${escapeHtml(formatValue(getByPath(row, props.subtitleField), ""))}
${escapeHtml(formatValue(getByPath(row, props.valueField), ""))}
`; 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 = ``; 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, `

${escapeHtml(component.props.modalBody || "")}

`); }); 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() { if (state.activeTab === "prepared_titles") return true; 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`; // BUILD 70: scale the operator UI with CSS zoom when available. Unlike a // fractional transform: scale(), CSS zoom asks Chromium to lay out and // rasterize text/1px borders at the requested size instead of enlarging a // pre-rendered texture. This keeps every runtime tab visibly sharper. const canUseCssZoom = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("zoom", "1"); if (canUseCssZoom) { el.runtimeStage.style.zoom = String(scale); el.runtimeStage.style.transform = "none"; el.runtimeStage.style.willChange = "auto"; } else { // Old embedded browsers still get the previous responsive behaviour, // but avoid translate3d so we do not force an extra GPU texture layer. el.runtimeStage.style.zoom = ""; el.runtimeStage.style.transform = `scale(${scale})`; el.runtimeStage.style.willChange = "transform"; } 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(); if (state.activeTab === "prepared_titles") { el.runtimeStage.appendChild(renderHockeyPreparedTitlesWorkspace()); if (state.preparedTitlesLoadedKey !== hockeyPreparedWorkspaceKey() && !state.preparedTitlesLoading) { setTimeout(() => hockeyLoadPreparedTitlesWorkspace({ force: true }).catch(() => {}), 0); } } else { state.config.components.filter((component) => !component.hidden && component.props?.runtimeHidden !== true && state.runtimeVisibility[component.action_id] !== false && isEffectivelyOnActiveTab(component)).sort((a, b) => Number(a.z) - Number(b.z)).forEach((component) => { const wrapper = document.createElement("div"); wrapper.className = "runtime-component"; Object.assign(wrapper.style, { left: `${component.x}px`, top: `${component.y}px`, width: `${component.w}px`, height: `${component.h}px`, zIndex: String(component.z) }); wrapper.appendChild(renderComponent(component, true)); el.runtimeStage.appendChild(wrapper); }); renderHockeyRuntimeSideWindows(); 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) => ``).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) => `${escapeHtml(digit)}`).join(""); } function explicitEditorUrl() { const base = String(boot.editorUrl || "/editor"); return `${base}${base.includes("?") ? "&" : "?"}open=1`; } async function openEditorPinDialog() { try { const status = await authApi("/status"); if (status.authenticated) { window.location.href = explicitEditorUrl(); return; } showModal("Вход в конструктор", `
Защищённый режим конструктора

Введите PIN доступа к конструктору.

Дата проекта: ${escapeHtml(status.date || "")} · ${escapeHtml(status.timezone || "")}
`); 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 = explicitEditorUrl(); }, 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 `${items.map((item) => ``).join("")}`; } function triggerSourceOptions(selected = "") { const virtualTabs = ``; const virtualNavigation = ``; return `${virtualTabs}${virtualNavigation}${(state.config.components || []).map((item) => ``).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) ? `` : ""; return `${legacyOption}${tabs.map((tab) => { const id = String(tab?.id || ""); const label = String(tab?.label || id || "Вкладка"); return ``; }).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) ? `` : ""; 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]) => `${groupItems.map((item) => { const value = optionValue(item); const label = virtual ? item.path : `${item.scope_label} → ${item.label}`; return ``; }).join("")}`).join(""); return `${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 ``; } if (triggerUsesTabItem(trigger)) { return ``; } return ``; } 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 `${items.map((item) => ``).join("")}`; } function hockeyPenaltyBoardOptions(selected = "") { const items = state.config.components.filter((item) => item.type === "hockey_penalty_dashboard"); return `${items.map((item) => ``).join("")}`; } function shortcutSequenceOptions(selected = "") { const items = state.config.shortcut_sequences || []; return `${items.map((item) => ``).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 `${VMIX_FUNCTIONS.map((name) => ``).join("")}`; } 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) ? `` : ""; return `${fixed.map(([value, label]) => ``).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 ``; }).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) ? `` : ""; return `${legacy}${fields.map((field) => { const name = String(field?.name || "").trim(); const type = String(field?.type || "").trim(); return ``; }).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) ? `` : ""; return `${legacy}${fields.map((field) => { const name = String(field?.name || "").trim(); return ``; }).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 `
Нет настроенных слотов ${sideLabel}. Добавьте слот и выберите Input + Text.
`; } return targets.map((target, index) => `
${sideLabel} ${index + 1}
`).join(""); } function timerFinishActionRows(step) { const actions = normalizeTimerFinishActions(step.timer_finish_actions); if (!actions.length) { return `
Действия по окончании не настроены.
`; } return actions.map((action, index) => `
${index + 1}
`).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 = `
${index + 1}
${["prematch_button_active","prematch_button_inactive"].includes(step.condition) ? `` : ""} ${["active_tab","inactive_tab"].includes(step.condition) ? `` : ""}
`; const body = card.querySelector(".shortcut-step-body"); if (step.type === "timer_command") { body.innerHTML = `
`; } else if (step.type === "hockey_penalties_command") { body.innerHTML = `

Если Action ID не выбран, команда применяется ко всем текущим удалениям во всех хоккейных дашбордах.

`; } else if (step.type === "vmix_command") { const functionKnown = !step.function || VMIX_FUNCTIONS.includes(String(step.function)); body.innerHTML = `
${escapeHtml(shortcutInventoryLabel())}При выборе сохраняется стабильный vMix key (название — резерв). Номер # показывается только для удобства и не используется как постоянная связь.

Можно настроить два варианта одного титра: обычный Input и альтернативный Input, который автоматически используется, пока сценарий «верхний счёт» находится в эфире.

`; } else if (step.type === "hockey_vmix_timers_start") { body.innerHTML = `
Основной таймер
Плашка удаления / большинства HOME
${penaltyTargetEditorRows(step, "home")}
Плашка удаления / большинства AWAY
${penaltyTargetEditorRows(step, "away")}
Действия по окончании таймера
${timerFinishActionRows(step)}
${escapeHtml(shortcutInventoryLabel())}Для каждого countdown теперь обязательно выбирается конкретный Text / SelectedName. Это исключает отправку времени в первый текстовый элемент по умолчанию.

Режим Countdown vMix: время передаётся в vMix только при явной установке/сбросе (например, 20:00 или 05:00). После этого Space не синхронизирует значение: Start/Resume отправляет только StartCountdown, Pause/Stop — только PauseCountdown. Это исключает скачки времени при паузе. StopCountdown в обычном управлении не используется. Команды идут в Agent без ACK. Text mirror оставлен только для совместимости со старыми титрами. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывает ближайшее изменение численного состава; при чистом равном обоюдном удалении сама по себе не появляется.

`; } else if (step.type === "delay") { body.innerHTML = `
`; } else if (step.type === "dispatch_event") { body.innerHTML = `
`; } 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 = `
${escapeHtml(sequence.combo || "—")}
${escapeHtml(sequence.name || "Без названия")}${escapeHtml(sequence.description || shortcutSequenceSummary(sequence))}
${(sequence.steps || []).length} шаг.
`; 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 = `
${(legacyConflict || sequenceConflicts.length) ? '
Эта комбинация уже используется. Новый глобальный сценарий будет иметь приоритет над старым shortcut элемента.
' : ''}
Цепочка: ${escapeHtml(shortcutSequenceSummary(sequence))}
Добавить шаг:
`; 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("Шорткаты и сценарии", `
Одна клавиша → цепочка действий

Готовые шорткаты показаны компактно. Нажмите «Редактировать», чтобы раскрыть только нужный сценарий.

${escapeHtml(shortcutInventoryLabel())}
`, { 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 = '
Шорткаты не найдены.
'; 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 '
Активных шорткатов пока нет.
'; return `
${items.map((item) => `
${escapeHtml(item.combo)}
${escapeHtml(item.kind)}${escapeHtml(item.name)}${item.description ? `

${escapeHtml(item.description)}

` : ""}${escapeHtml(item.summary)}
`).join("")}
`; } function printShortcutsReference() { const items = shortcutReferenceItems(); const title = state.config.project_name || "Шорткаты"; const rows = items.map((item) => `
${escapeHtml(item.combo)}

${escapeHtml(item.name)}

${escapeHtml(item.description || "")}

${escapeHtml(item.summary)}
`).join(""); const popup = window.open("", "_blank"); if (!popup) { toast("Браузер заблокировал окно печати", true); return; } popup.document.write(`${escapeHtml(title)} — шорткаты

${escapeHtml(title)} · Шорткаты

Операторская памятка
${rows || '

Активных шорткатов нет.

'}
UI Builder · ${new Date().toLocaleDateString("ru-RU")}
`); popup.document.close(); } function showShortcutsReference() { showSettingsModal("Шорткаты", `
${escapeHtml(state.config.project_name || "Проект")}

Краткая памятка оператора

${shortcutReferenceHtml()}
`); 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("События и триггеры", `
Событие → условие → действие

Триггеры всегда работают. Кнопка Toast включает/выключает только нижние информационные уведомления для вашего аккаунта. Для вкладок выберите вкладку из списка. Нажмите на стрелку или «Редактировать», чтобы раскрыть триггер, и после изменений нажмите Сохранить.

`); 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 ? "" : '
Триггеров для выбранного элемента пока нет.
'; 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 = `
${escapeHtml(summary)}

1. Источник

${triggerItemIdFieldHtml(trigger)}

2. Условие необязательно

3. Действие

${escapeHtml(trigger.source_action_id || "action_id")} · ${escapeHtml(trigger.event)}${trigger.item_id ? ` · ${escapeHtml(trigger.item_id)}` : ""}Шаблоны: {{value}}, {{state.active}}, {{data.event.title}}
`; 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]) => ``).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 = `
${items.length ? items.map((item) => `
${escapeHtml(item.name)}${escapeHtml(item.modified)} · ${Math.round(item.size / 1024)} KB
`).join("") : "

Резервных копий пока нет. Они создаются при сохранении.

"}
`; 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 = ``; 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(); }); hockeyRemoveRuntimeSideStack(); 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 = ""; renderHockeyRuntimeSideWindows(); 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) { hockeyRemoveRuntimeSideStack(); } else { renderHockeyRuntimeSideWindows(); } 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)) { // In runtime the editor entry button is revealed only for admins by // the hockey account bootstrap. Hidden means editor access is denied. if (boot.mode !== "editor" && el.runtimeEditorBtn?.classList.contains("hidden")) return; 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 physicalKey = shortcutPhysicalKeyToken(event); if (physicalKey) state.pressedShortcutKeys.delete(physicalKey); 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.pressedShortcutKeys.clear(); 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(); startQuickPanelOverlayPolling(); 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(); })();