refactor keyboard and mouse code: SVG -> div components & logical refactoring. Add some new DEV-features
Some checks failed
CI / checks (push) Failing after 6m56s
Some checks failed
CI / checks (push) Failing after 6m56s
This commit is contained in:
@@ -7,5 +7,8 @@ SG_API_BASE_PATH=/api/v1
|
|||||||
# Origin для разрешения относительных URL (HLS playlist и т.д.) при открытии приложения как file:// или вне основного хоста
|
# Origin для разрешения относительных URL (HLS playlist и т.д.) при открытии приложения как file:// или вне основного хоста
|
||||||
SG_API_FALLBACK_ORIGIN=https://sparkguardian.ru
|
SG_API_FALLBACK_ORIGIN=https://sparkguardian.ru
|
||||||
|
|
||||||
|
# Преролл видео в интерактивном режиме (мс): сколько видео записано до старта телеметрии.
|
||||||
|
SG_INTERACTIVE_PREROLL_MS=4000
|
||||||
|
|
||||||
# Только dev-сервер (`ng serve`): куда проксировать `/api/**`
|
# Только dev-сервер (`ng serve`): куда проксировать `/api/**`
|
||||||
SG_DEV_PROXY_TARGET=http://sparkguardian.ru:8080
|
SG_DEV_PROXY_TARGET=http://sparkguardian.ru:8080
|
||||||
|
|||||||
160
README.md
160
README.md
@@ -1,59 +1,149 @@
|
|||||||
# Sparkguardian
|
# Sparkguardian
|
||||||
|
|
||||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.6.
|
Клиентское веб-приложение для просмотра записанных сессий, HLS-потоков и телеметрии (клавиатура, мышь и др.), работающее поверх REST API Sparkguardian.
|
||||||
|
|
||||||
## Development server
|
A single-page web client for reviewing recorded sessions, HLS streams, and telemetry (keyboard, mouse, and more), built on top of the Sparkguardian REST API.
|
||||||
|
|
||||||
To start a local development server, run:
|
---
|
||||||
|
|
||||||
```bash
|
## О проекте
|
||||||
ng serve
|
|
||||||
```
|
|
||||||
|
|
||||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
Приложение предназначено для операторов и разработчиков, которым нужно открыть список сессий, перейти к деталям конкретной записи и синхронно смотреть видео с разбором событий на временной шкале. Бэкенд отвечает за хранение чанков, плейлистов и событий; фронтенд подставляет базовый URL API, подгружает данные и отображает их в интерфейсе на базе Taiga UI.
|
||||||
|
|
||||||
## Code scaffolding
|
## Основные возможности
|
||||||
|
|
||||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
- **Список сессий** с пагинацией и созданием новой сессии по названию.
|
||||||
|
- **Карточка сессии** с вкладками: сводная информация, просмотр записи и телеметрии, интерактивный режим с визуализацией клавиатуры и курсора поверх видео.
|
||||||
|
- **HLS-воспроизведение** через hls.js, выбор потока (например, экран или веб-камера), если бэкенд отдаёт несколько `stream_type`.
|
||||||
|
- **Телеметрия**: загрузка разобранных событий с фильтрацией по типу и времени, привязка к окну записи (`started_at` / `ended_at`).
|
||||||
|
- **Уведомления об ошибках** HTTP с понятными сообщениями для пользователя.
|
||||||
|
|
||||||
```bash
|
## Технологии
|
||||||
ng generate component component-name
|
|
||||||
```
|
|
||||||
|
|
||||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
| Область | Выбор |
|
||||||
|
|--------|--------|
|
||||||
|
| Фреймворк | Angular 21, TypeScript |
|
||||||
|
| UI | Taiga UI, анимации через `@angular/animations` |
|
||||||
|
| HTTP | `HttpClient`, интерсептор базового URL API |
|
||||||
|
| Тесты | Vitest через `@angular/build:unit-test`, jsdom |
|
||||||
|
| Линт | ESLint (`angular-eslint`) |
|
||||||
|
| Видео | hls.js |
|
||||||
|
|
||||||
```bash
|
Стили опираются на дизайн-токены (CSS-переменные); для компонентов по возможности используются примитивы Taiga UI.
|
||||||
ng generate --help
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building
|
## Как устроен код
|
||||||
|
|
||||||
To build the project run:
|
- **Маршруты**: главная страница — список сессий (`/`); детали — `/sessions/:id`. Остальные пути перенаправляются на список.
|
||||||
|
- **Слой API**: `SessionsApiService` ходит на эндпоинты вида `/sessions`, `/sessions/:id/events` и т.д.; префикс API задаётся через `API_BASE_URL` (см. ниже). Относительные URL плейлистов HLS разрешаются к origin через `API_ORIGIN`.
|
||||||
|
- **Окружение**: `src/environments/environment.ts` генерируется скриптом `npm run env:sync` из переменных в `.env` (значения по умолчанию совпадают с `.env.example`). Production-сборка подменяет файл на `environment.prod.ts`.
|
||||||
|
- **Прокси в разработке**: `ng serve` использует `proxy.conf.cjs`: запросы к `/api/**` уходят на хост из `SG_DEV_PROXY_TARGET` в `.env` (по умолчанию указан в примере).
|
||||||
|
|
||||||
```bash
|
Подробная схема REST описана в `docs/doc_v1.json` (OpenAPI).
|
||||||
ng build
|
|
||||||
```
|
|
||||||
|
|
||||||
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
## Требования
|
||||||
|
|
||||||
## Running unit tests
|
- Node.js версии, совместимой с Angular 21 (см. рекомендации в документации Angular CLI).
|
||||||
|
- npm (в проекте зафиксирован `packageManager` в `package.json`).
|
||||||
|
|
||||||
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
|
## Локальная настройка
|
||||||
|
|
||||||
```bash
|
1. Скопируйте `.env.example` в `.env` и при необходимости измените переменные (`SG_API_BASE_PATH`, `SG_API_FALLBACK_ORIGIN`, `SG_INTERACTIVE_PREROLL_MS`, для dev — `SG_DEV_PROXY_TARGET`).
|
||||||
ng test
|
2. Установите зависимости: `npm install` или `make install`.
|
||||||
```
|
3. Сгенерируйте `environment.ts`: `npm run env:sync` или `make env-sync` (перед `start` и `build` это выполняется автоматически через npm-скрипты `prestart` / `prebuild`).
|
||||||
|
|
||||||
## Running end-to-end tests
|
## Запуск и сборка
|
||||||
|
|
||||||
For end-to-end (e2e) testing, run:
|
| Задача | Команда |
|
||||||
|
|--------|---------|
|
||||||
|
| Dev-сервер с прокси | `npm start` или `make start` |
|
||||||
|
| Production-сборка | `npm run build` или `make build` |
|
||||||
|
| Сборка development | `make build-dev` |
|
||||||
|
| Unit-тесты | `npm test` (в режиме разработки без CI обычно удобен интерактивный режим) |
|
||||||
|
| Линт | `npm run lint` |
|
||||||
|
| Очистка артефактов | `make clean` |
|
||||||
|
|
||||||
```bash
|
После `npm start` приложение доступно по адресу, который выводит Angular CLI (по умолчанию `http://localhost:4200/`).
|
||||||
ng e2e
|
|
||||||
```
|
|
||||||
|
|
||||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
## CI
|
||||||
|
|
||||||
## Additional Resources
|
В репозитории описан workflow Gitea Actions: `.gitea/workflows/ci.yml` — линт, тесты с `--watch=false`, production build. На сервере должны быть включены Actions и настроен runner.
|
||||||
|
|
||||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
## Структура каталогов (кратко)
|
||||||
|
|
||||||
|
- `src/app/core` — API, HTTP, модели, уведомления, разбор телеметрии клавиатуры/мыши, подсветка SVG.
|
||||||
|
- `src/app/features/sessions` — список сессий, детальная страница и вкладки, плеер HLS, выбор потока, детали события.
|
||||||
|
- `src/environments` — конфигурация окружения.
|
||||||
|
- `public` — статические ассеты (иконки, SVG для визуализации и т.д.).
|
||||||
|
- `scripts` — вспомогательные скрипты (`sync-env.cjs`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## About the project
|
||||||
|
|
||||||
|
The app is aimed at operators and developers who need a session list, a focused session view, and a way to watch video while inspecting timed telemetry. The backend owns chunks, playlists, and events; the frontend configures the API base URL, loads data, and presents it through Taiga UI.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Session list** with pagination and creating a session with a title.
|
||||||
|
- **Session detail** with tabs: summary, combined playback and telemetry, and an interactive view with keyboard and cursor overlays on top of the video.
|
||||||
|
- **HLS playback** via hls.js, with stream selection when multiple `stream_type` entries exist.
|
||||||
|
- **Telemetry**: parsed events with type and time filters, aligned with the recording window (`started_at` / `ended_at`).
|
||||||
|
- **HTTP error notifications** with user-facing messages.
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
| Area | Choice |
|
||||||
|
|------|--------|
|
||||||
|
| Framework | Angular 21, TypeScript |
|
||||||
|
| UI | Taiga UI, `@angular/animations` for motion |
|
||||||
|
| HTTP | `HttpClient`, API base URL interceptor |
|
||||||
|
| Tests | Vitest via `@angular/build:unit-test`, jsdom |
|
||||||
|
| Lint | ESLint (`angular-eslint`) |
|
||||||
|
| Video | hls.js |
|
||||||
|
|
||||||
|
Styling relies on CSS variables (design tokens); Taiga UI components are preferred where they fit.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Routes**: home is the session list (`/`); details live at `/sessions/:id`. Unknown paths redirect to the list.
|
||||||
|
- **API layer**: `SessionsApiService` calls `/sessions`, `/sessions/:id/events`, etc. The API prefix comes from `API_BASE_URL`. Relative HLS playlist URLs are resolved with `API_ORIGIN`.
|
||||||
|
- **Environment**: `src/environments/environment.ts` is generated by `npm run env:sync` from `.env` (defaults match `.env.example`). Production builds use `environment.prod.ts`.
|
||||||
|
- **Dev proxy**: `ng serve` loads `proxy.conf.cjs`: `/api/**` is forwarded to `SG_DEV_PROXY_TARGET` from `.env`.
|
||||||
|
|
||||||
|
The REST contract is summarized in `docs/doc_v1.json` (OpenAPI).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A Node.js version compatible with Angular 21 (see Angular CLI docs).
|
||||||
|
- npm (see `packageManager` in `package.json`).
|
||||||
|
|
||||||
|
## Local setup
|
||||||
|
|
||||||
|
1. Copy `.env.example` to `.env` and adjust variables (`SG_API_BASE_PATH`, `SG_API_FALLBACK_ORIGIN`, `SG_INTERACTIVE_PREROLL_MS`, and for local dev `SG_DEV_PROXY_TARGET`).
|
||||||
|
2. Install dependencies: `npm install` or `make install`.
|
||||||
|
3. Generate `environment.ts`: `npm run env:sync` or `make env-sync` (also runs automatically before `start` / `build` via npm `prestart` / `prebuild`).
|
||||||
|
|
||||||
|
## Running and building
|
||||||
|
|
||||||
|
| Task | Command |
|
||||||
|
|------|---------|
|
||||||
|
| Dev server with proxy | `npm start` or `make start` |
|
||||||
|
| Production build | `npm run build` or `make build` |
|
||||||
|
| Development build | `make build-dev` |
|
||||||
|
| Unit tests | `npm test` |
|
||||||
|
| Lint | `npm run lint` |
|
||||||
|
| Clean artifacts | `make clean` |
|
||||||
|
|
||||||
|
After `npm start`, open the URL printed by the CLI (typically `http://localhost:4200/`).
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
Gitea Actions workflow: `.gitea/workflows/ci.yml` — lint, tests with `--watch=false`, production build. Actions must be enabled and a runner must be available.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
- `src/app/core` — API client, HTTP, models, notifications, keyboard/mouse telemetry parsing and SVG highlighting.
|
||||||
|
- `src/app/features/sessions` — session list, detail page and tabs, HLS player, stream selector, telemetry event drill-down.
|
||||||
|
- `src/environments` — environment configuration.
|
||||||
|
- `public` — static assets (fonts, SVG overlays, favicon).
|
||||||
|
- `scripts` — helpers such as `sync-env.cjs`.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 900 B |
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 878 B |
45
public/svg/visual/arrow-keys.svg
Normal file
45
public/svg/visual/arrow-keys.svg
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
Chevron icons: Lucide (https://lucide.dev), ISC License, ? Lucide Contributors.
|
||||||
|
-->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 104 88" width="104" height="88" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<style type="text/css">
|
||||||
|
<![CDATA[
|
||||||
|
.key-cap {
|
||||||
|
fill: var(--sg-keyboard-key-surface-idle);
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
.sg-lucide path,
|
||||||
|
.sg-lucide line,
|
||||||
|
.sg-lucide polyline {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--sg-keyboard-ink-soft);
|
||||||
|
stroke-width: 1.55;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
]]>
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<!-- inverted-T: up / left down right -->
|
||||||
|
<rect id="K_kb7u" class="key-cap" x="36" y="2" width="32" height="32" rx="6" ry="6" />
|
||||||
|
<g id="S_kb7u" class="sg-lucide" transform="translate(52,18) scale(0.72) translate(-12,-12)">
|
||||||
|
<path d="m18 15-6-6-6 6" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<rect id="K_kb7l" class="key-cap" x="2" y="38" width="32" height="32" rx="6" ry="6" />
|
||||||
|
<g id="S_kb7l" class="sg-lucide" transform="translate(18,54) scale(0.72) translate(-12,-12)">
|
||||||
|
<path d="m15 18-6-6 6-6" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<rect id="K_kb7d" class="key-cap" x="36" y="38" width="32" height="32" rx="6" ry="6" />
|
||||||
|
<g id="S_kb7d" class="sg-lucide" transform="translate(52,54) scale(0.72) translate(-12,-12)">
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<rect id="K_kb7r" class="key-cap" x="70" y="38" width="32" height="32" rx="6" ry="6" />
|
||||||
|
<g id="S_kb7r" class="sg-lucide" transform="translate(86,54) scale(0.72) translate(-12,-12)">
|
||||||
|
<path d="m9 18 6-6-6-6" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -118,6 +118,13 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
text-anchor: middle;
|
text-anchor: middle;
|
||||||
}
|
}
|
||||||
|
#T_stdletters text.T_ru {
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
opacity: 0.9;
|
||||||
|
text-align: end;
|
||||||
|
text-anchor: end;
|
||||||
|
}
|
||||||
#T_stdspecial text {
|
#T_stdspecial text {
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
font-weight: var(--sg-keyboard-font-weight);
|
font-weight: var(--sg-keyboard-font-weight);
|
||||||
@@ -127,6 +134,13 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
text-anchor: middle;
|
text-anchor: middle;
|
||||||
}
|
}
|
||||||
|
#T_stdspecial text.T_ru {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
opacity: 0.9;
|
||||||
|
text-align: end;
|
||||||
|
text-anchor: end;
|
||||||
|
}
|
||||||
.T_size_s {
|
.T_size_s {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: var(--sg-keyboard-font-weight);
|
font-weight: var(--sg-keyboard-font-weight);
|
||||||
@@ -308,6 +322,36 @@
|
|||||||
<text id="T_kb5g2" x="348" y="172" >B</text>
|
<text id="T_kb5g2" x="348" y="172" >B</text>
|
||||||
<text id="T_kb5h2" x="402" y="172" >N</text>
|
<text id="T_kb5h2" x="402" y="172" >N</text>
|
||||||
<text id="T_kb5i2" x="454" y="172" >M</text>
|
<text id="T_kb5i2" x="454" y="172" >M</text>
|
||||||
|
|
||||||
|
<!-- Russian layout: bottom of key, opposite (right) edge -->
|
||||||
|
<text id="T_kb3b_ru" class="T_ru" x="126" y="92" >й</text>
|
||||||
|
<text id="T_kb3c_ru" class="T_ru" x="180" y="92" >ц</text>
|
||||||
|
<text id="T_kb3d_ru" class="T_ru" x="234" y="92" >у</text>
|
||||||
|
<text id="T_kb3e_ru" class="T_ru" x="288" y="92" >к</text>
|
||||||
|
<text id="T_kb3f_ru" class="T_ru" x="342" y="92" >е</text>
|
||||||
|
<text id="T_kb3g_ru" class="T_ru" x="396" y="92" >н</text>
|
||||||
|
<text id="T_kb3h_ru" class="T_ru" x="450" y="92" >г</text>
|
||||||
|
<text id="T_kb3i_ru" class="T_ru" x="504" y="92" >ш</text>
|
||||||
|
<text id="T_kb3j_ru" class="T_ru" x="558" y="92" >щ</text>
|
||||||
|
<text id="T_kb3k_ru" class="T_ru" x="612" y="92" >з</text>
|
||||||
|
|
||||||
|
<text id="T_kb4c_ru" class="T_ru" x="196" y="142" >ф</text>
|
||||||
|
<text id="T_kb4d_ru" class="T_ru" x="250" y="142" >ы</text>
|
||||||
|
<text id="T_kb4e_ru" class="T_ru" x="304" y="142" >в</text>
|
||||||
|
<text id="T_kb4f_ru" class="T_ru" x="358" y="142" >а</text>
|
||||||
|
<text id="T_kb4g_ru" class="T_ru" x="412" y="142" >п</text>
|
||||||
|
<text id="T_kb4h_ru" class="T_ru" x="466" y="142" >р</text>
|
||||||
|
<text id="T_kb4i_ru" class="T_ru" x="520" y="142" >о</text>
|
||||||
|
<text id="T_kb4j_ru" class="T_ru" x="574" y="142" >л</text>
|
||||||
|
<text id="T_kb4k_ru" class="T_ru" x="628" y="142" >д</text>
|
||||||
|
|
||||||
|
<text id="T_kb5c_ru" class="T_ru" x="162" y="192" >я</text>
|
||||||
|
<text id="T_kb5d_ru" class="T_ru" x="216" y="192" >ч</text>
|
||||||
|
<text id="T_kb5e_ru" class="T_ru" x="270" y="192" >с</text>
|
||||||
|
<text id="T_kb5f_ru" class="T_ru" x="324" y="192" >м</text>
|
||||||
|
<text id="T_kb5g_ru" class="T_ru" x="378" y="192" >и</text>
|
||||||
|
<text id="T_kb5h_ru" class="T_ru" x="432" y="192" >т</text>
|
||||||
|
<text id="T_kb5i_ru" class="T_ru" x="486" y="192" >ь</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<g id="T_stdspecial">
|
<g id="T_stdspecial">
|
||||||
@@ -363,6 +407,32 @@
|
|||||||
<text id="T_kb5j2" x="508" y="170" ><</text>
|
<text id="T_kb5j2" x="508" y="170" ><</text>
|
||||||
<text id="T_kb5k2" x="562" y="170" >></text>
|
<text id="T_kb5k2" x="562" y="170" >></text>
|
||||||
<text id="T_kb5l2" x="616" y="170" >?</text>
|
<text id="T_kb5l2" x="616" y="170" >?</text>
|
||||||
|
|
||||||
|
<!-- Russian layout on punctuation / number row (ЙЦУКЕН, Windows) -->
|
||||||
|
<text id="T_kb2a_ru" class="T_ru" x="46" y="42" >ё</text>
|
||||||
|
<text id="T_kb2b_ru" class="T_ru" x="100" y="42" >!</text>
|
||||||
|
<text id="T_kb2c_ru" class="T_ru" x="152" y="42" >"</text>
|
||||||
|
<text id="T_kb2d_ru" class="T_ru" x="206" y="42" >№</text>
|
||||||
|
<text id="T_kb2e_ru" class="T_ru" x="260" y="42" >;</text>
|
||||||
|
<text id="T_kb2f_ru" class="T_ru" x="314" y="42" >%</text>
|
||||||
|
<text id="T_kb2g_ru" class="T_ru" x="368" y="42" >:</text>
|
||||||
|
<text id="T_kb2h_ru" class="T_ru" x="422" y="42" >?</text>
|
||||||
|
<text id="T_kb2i_ru" class="T_ru" x="476" y="42" >*</text>
|
||||||
|
<text id="T_kb2j_ru" class="T_ru" x="530" y="42" >(</text>
|
||||||
|
<text id="T_kb2k_ru" class="T_ru" x="584" y="42" >)</text>
|
||||||
|
<text id="T_kb2l_ru" class="T_ru" x="638" y="42" >-</text>
|
||||||
|
<text id="T_kb2m_ru" class="T_ru" x="692" y="42" >+</text>
|
||||||
|
|
||||||
|
<text id="T_kb3l_ru" class="T_ru" x="666" y="92" >х</text>
|
||||||
|
<text id="T_kb3m_ru" class="T_ru" x="720" y="92" >ъ</text>
|
||||||
|
<text id="T_kb3n_ru" class="T_ru" x="796" y="92" >/</text>
|
||||||
|
|
||||||
|
<text id="T_kb4l_ru" class="T_ru" x="682" y="142" >ж</text>
|
||||||
|
<text id="T_kb4m_ru" class="T_ru" x="736" y="142" >э</text>
|
||||||
|
|
||||||
|
<text id="T_kb5j_ru" class="T_ru" x="540" y="192" >б</text>
|
||||||
|
<text id="T_kb5k_ru" class="T_ru" x="594" y="192" >ю</text>
|
||||||
|
<text id="T_kb5l_ru" class="T_ru" x="648" y="192" >.</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<g id="T_others">
|
<g id="T_others">
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 19 KiB |
@@ -10,6 +10,7 @@ require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
|||||||
const defaults = {
|
const defaults = {
|
||||||
SG_API_FALLBACK_ORIGIN: 'https://sparkguardian.ru',
|
SG_API_FALLBACK_ORIGIN: 'https://sparkguardian.ru',
|
||||||
SG_API_BASE_PATH: '/api/v1',
|
SG_API_BASE_PATH: '/api/v1',
|
||||||
|
SG_INTERACTIVE_PREROLL_MS: '4000',
|
||||||
};
|
};
|
||||||
|
|
||||||
function val(key) {
|
function val(key) {
|
||||||
@@ -20,11 +21,20 @@ function val(key) {
|
|||||||
return defaults[key];
|
return defaults[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function intVal(key) {
|
||||||
|
const parsed = Number.parseInt(val(key), 10);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
return Number.parseInt(defaults[key], 10);
|
||||||
|
}
|
||||||
|
|
||||||
const content = `// Сгенерировано npm run env:sync — правьте .env и снова запустите sync
|
const content = `// Сгенерировано npm run env:sync — правьте .env и снова запустите sync
|
||||||
export const environment = {
|
export const environment = {
|
||||||
production: false,
|
production: false,
|
||||||
apiFallbackOrigin: ${JSON.stringify(val('SG_API_FALLBACK_ORIGIN'))},
|
apiFallbackOrigin: ${JSON.stringify(val('SG_API_FALLBACK_ORIGIN'))},
|
||||||
apiBasePath: ${JSON.stringify(val('SG_API_BASE_PATH'))},
|
apiBasePath: ${JSON.stringify(val('SG_API_BASE_PATH'))},
|
||||||
|
interactivePrerollMs: ${JSON.stringify(intVal('SG_INTERACTIVE_PREROLL_MS'))},
|
||||||
} as const;
|
} as const;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -15,14 +15,19 @@ export interface DevHttpLogDetails {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DevTelemetryLogDetails {
|
||||||
|
rawEventJson: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DevLogEntry {
|
export interface DevLogEntry {
|
||||||
id: number;
|
id: number;
|
||||||
time: string;
|
time: string;
|
||||||
level: DevLogLevel;
|
level: DevLogLevel;
|
||||||
source: 'http' | 'system';
|
source: 'http' | 'system' | 'telemetry';
|
||||||
message: string;
|
message: string;
|
||||||
status?: DevLogStatus;
|
status?: DevLogStatus;
|
||||||
details?: DevHttpLogDetails;
|
details?: DevHttpLogDetails;
|
||||||
|
telemetryDetails?: DevTelemetryLogDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -49,4 +54,8 @@ export class DevLogService {
|
|||||||
clear(): void {
|
clear(): void {
|
||||||
this.entries.set([]);
|
this.entries.set([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearSource(source: DevLogEntry['source']): void {
|
||||||
|
this.entries.update((curr) => curr.filter((e) => e.source !== source));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,44 @@ function tokenToSvgIds(token: string): string[] {
|
|||||||
space: ['K_kb6d'],
|
space: ['K_kb6d'],
|
||||||
caps: ['K_kb4a'],
|
caps: ['K_kb4a'],
|
||||||
menu: ['K_kb6m'],
|
menu: ['K_kb6m'],
|
||||||
|
|
||||||
|
// Пунктуация по имени (на случай если агент шлёт имя, а не символ)
|
||||||
|
comma: ['K_kb5j'],
|
||||||
|
period: ['K_kb5k'],
|
||||||
|
dot: ['K_kb5k'],
|
||||||
|
slash: ['K_kb5l'],
|
||||||
|
semicolon: ['K_kb4l'],
|
||||||
|
colon: ['K_kb4l'],
|
||||||
|
quote: ['K_kb4m'],
|
||||||
|
apostrophe: ['K_kb4m'],
|
||||||
|
grave: ['K_kb2a'],
|
||||||
|
backtick: ['K_kb2a'],
|
||||||
|
tilde: ['K_kb2a'],
|
||||||
|
minus: ['K_kb2l'],
|
||||||
|
dash: ['K_kb2l'],
|
||||||
|
underscore: ['K_kb2l'],
|
||||||
|
equal: ['K_kb2m'],
|
||||||
|
equals: ['K_kb2m'],
|
||||||
|
plus: ['K_kb2m'],
|
||||||
|
left_bracket: ['K_kb3l'],
|
||||||
|
leftbracket: ['K_kb3l'],
|
||||||
|
right_bracket: ['K_kb3m'],
|
||||||
|
rightbracket: ['K_kb3m'],
|
||||||
|
backslash: ['K_kb3n'],
|
||||||
|
pipe: ['K_kb3n'],
|
||||||
|
|
||||||
|
up: ['K_kb7u'],
|
||||||
|
down: ['K_kb7d'],
|
||||||
|
left: ['K_kb7l'],
|
||||||
|
right: ['K_kb7r'],
|
||||||
|
arrow_up: ['K_kb7u'],
|
||||||
|
arrow_down: ['K_kb7d'],
|
||||||
|
arrow_left: ['K_kb7l'],
|
||||||
|
arrow_right: ['K_kb7r'],
|
||||||
|
arrowup: ['K_kb7u'],
|
||||||
|
arrowdown: ['K_kb7d'],
|
||||||
|
arrowleft: ['K_kb7l'],
|
||||||
|
arrowright: ['K_kb7r'],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (named[t]) {
|
if (named[t]) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { ParsedEvent } from '../models/api.types';
|
|||||||
import { unwrapJsonPayload } from '../../shared/utils/json.util';
|
import { unwrapJsonPayload } from '../../shared/utils/json.util';
|
||||||
|
|
||||||
import { keyNameModifiersPayloadToSvgIds } from './keyboard-key-name-map';
|
import { keyNameModifiersPayloadToSvgIds } from './keyboard-key-name-map';
|
||||||
import { normalizeVirtualKey, vkToKeyboardSvgKeyId } from './vk-to-keyboard-svg-id';
|
import { type KeyboardVkScheme, vkToKeyboardSvgKeyId } from './vk-to-keyboard-svg-id';
|
||||||
|
|
||||||
export function isKeyboardTelemetryEvent(event: ParsedEvent): boolean {
|
export function isKeyboardTelemetryEvent(event: ParsedEvent): boolean {
|
||||||
const t = (event.event_type ?? '').toLowerCase();
|
const t = (event.event_type ?? '').toLowerCase();
|
||||||
@@ -44,6 +44,43 @@ export function parseKeyboardVirtualKey(data: unknown): number | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAC_VK_SCHEME_HINTS = new Set([
|
||||||
|
'carbon',
|
||||||
|
'darwin',
|
||||||
|
'mac',
|
||||||
|
'mac_os',
|
||||||
|
'macos',
|
||||||
|
'macos_vk',
|
||||||
|
'osx',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const WINDOWS_VK_SCHEME_HINTS = new Set(['win', 'win32', 'win64', 'windows', 'windows_vk']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Определяет семейство кодов клавиш по полям payload (агент должен выставлять при записи).
|
||||||
|
* По умолчанию — Windows VK, как раньше.
|
||||||
|
*/
|
||||||
|
export function parseKeyboardVkScheme(data: unknown): KeyboardVkScheme {
|
||||||
|
const raw = unwrapJsonPayload(data);
|
||||||
|
if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
const keys = ['vk_scheme', 'vkScheme', 'platform', 'os', 'os_type', 'OS'] as const;
|
||||||
|
for (const k of keys) {
|
||||||
|
const v = o[k];
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const s = v.trim().toLowerCase();
|
||||||
|
if (MAC_VK_SCHEME_HINTS.has(s)) {
|
||||||
|
return 'macos';
|
||||||
|
}
|
||||||
|
if (WINDOWS_VK_SCHEME_HINTS.has(s)) {
|
||||||
|
return 'windows';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'windows';
|
||||||
|
}
|
||||||
|
|
||||||
export function eventPayloadJson(data: unknown): string {
|
export function eventPayloadJson(data: unknown): string {
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(data, null, 2);
|
return JSON.stringify(data, null, 2);
|
||||||
@@ -55,16 +92,20 @@ export function eventPayloadJson(data: unknown): string {
|
|||||||
export function parseKeyboardAction(data: unknown): 'press' | 'release' | null {
|
export function parseKeyboardAction(data: unknown): 'press' | 'release' | null {
|
||||||
const raw = unwrapJsonPayload(data);
|
const raw = unwrapJsonPayload(data);
|
||||||
if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) {
|
if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||||
const action = (raw as Record<string, unknown>)['action'];
|
const o = raw as Record<string, unknown>;
|
||||||
|
const action = o['action'];
|
||||||
if (typeof action === 'string') {
|
if (typeof action === 'string') {
|
||||||
const normalized = action.toLowerCase();
|
const normalized = action.toLowerCase();
|
||||||
if (normalized === 'press') {
|
if (normalized === 'press' || normalized === 'down' || normalized === 'key_down') {
|
||||||
return 'press';
|
return 'press';
|
||||||
}
|
}
|
||||||
if (normalized === 'release') {
|
if (normalized === 'release' || normalized === 'up' || normalized === 'key_up') {
|
||||||
return 'release';
|
return 'release';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (typeof o['is_down'] === 'boolean') {
|
||||||
|
return o['is_down'] ? 'press' : 'release';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -82,7 +123,8 @@ export function parseKeyboardHighlightKeyIds(data: unknown): string[] {
|
|||||||
}
|
}
|
||||||
const vk = parseKeyboardVirtualKey(raw);
|
const vk = parseKeyboardVirtualKey(raw);
|
||||||
if (vk != null) {
|
if (vk != null) {
|
||||||
const id = vkToKeyboardSvgKeyId(normalizeVirtualKey(vk));
|
const scheme = parseKeyboardVkScheme(raw);
|
||||||
|
const id = vkToKeyboardSvgKeyId(vk, scheme);
|
||||||
return id ? [id] : [];
|
return id ? [id] : [];
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -4,35 +4,154 @@ import { Observable, defer, from, shareReplay } from 'rxjs';
|
|||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Файл из `public/svg/visual/keyboard.svg` — URL в приложении `/svg/visual/keyboard.svg`,
|
* Клавиша с информацией о том, сколько мс прошло с момента нажатия.
|
||||||
|
* Используется для scrub-анимации: чем больше ageMs, тем сильнее затухание.
|
||||||
|
*/
|
||||||
|
export interface KeyTapHighlight {
|
||||||
|
keyId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyHighlightDiff {
|
||||||
|
/** Newly pressed this frame — animate in with pop. */
|
||||||
|
pressed: string[];
|
||||||
|
/** Still held from before — static pressed color. */
|
||||||
|
held: string[];
|
||||||
|
/** Just released this frame — animate back to idle. */
|
||||||
|
released: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Файлы из `public/svg/visual/*.svg` — URL в приложении `/svg/visual/...`,
|
||||||
* не через API. HttpClient нельзя: `apiBaseUrlInterceptor` превратил бы путь в `/api/v1/...`.
|
* не через API. HttpClient нельзя: `apiBaseUrlInterceptor` превратил бы путь в `/api/v1/...`.
|
||||||
*/
|
*/
|
||||||
const KEYBOARD_SVG_PATH = '/svg/visual/keyboard.svg';
|
export const KEYBOARD_SVG_PATH = '/svg/visual/keyboard.svg';
|
||||||
|
|
||||||
|
/** Отдельный блок стрелок (рядом с мышью в интерактивном режиме). */
|
||||||
|
export const ARROW_KEYS_SVG_PATH = '/svg/visual/arrow-keys.svg';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class KeyboardSvgHighlightService {
|
export class KeyboardSvgHighlightService {
|
||||||
private readonly sanitizer = inject(DomSanitizer);
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
|
|
||||||
private readonly baseSvg$ = defer(() =>
|
private readonly baseSvgCache = new Map<string, Observable<string>>();
|
||||||
|
|
||||||
|
private baseSvg$(path: string): Observable<string> {
|
||||||
|
let cached = this.baseSvgCache.get(path);
|
||||||
|
if (!cached) {
|
||||||
|
cached = defer(() =>
|
||||||
from(
|
from(
|
||||||
fetch(KEYBOARD_SVG_PATH).then((r) => {
|
fetch(path).then((r) => {
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
throw new Error(`Не удалось загрузить клавиатуру: ${r.status} ${r.statusText}`);
|
throw new Error(`Не удалось загрузить SVG: ${path} (${r.status} ${r.statusText})`);
|
||||||
}
|
}
|
||||||
return r.text();
|
return r.text();
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
|
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
|
||||||
|
this.baseSvgCache.set(path, cached);
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
svgWithHighlight(keyIds: string[] | null, animated = true): Observable<SafeHtml> {
|
/**
|
||||||
return this.baseSvg$.pipe(
|
* Interactive timeline mode: animates only the changed keys.
|
||||||
|
* Pressed keys pop in, held keys stay static, released keys fade back to idle.
|
||||||
|
*/
|
||||||
|
svgWithKeyDiff(diff: KeyHighlightDiff, svgPath: string = KEYBOARD_SVG_PATH): Observable<SafeHtml> {
|
||||||
|
return this.baseSvg$(svgPath).pipe(
|
||||||
|
map((svg) => this.injectHighlightDiff(svg, diff)),
|
||||||
|
map((html) => this.sanitizer.bypassSecurityTrustHtml(html)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интерактивный режим: каждая клавиша подсвечивается пропорционально своему возрасту (ageMs).
|
||||||
|
* Используется CSS-scrubbing: анимация поставлена на паузу, а animation-delay смещает её
|
||||||
|
* в нужную точку, чтобы отобразить состояние «только что нажато» → «гаснет».
|
||||||
|
*/
|
||||||
|
svgWithKeyTaps(taps: KeyTapHighlight[], svgPath: string = KEYBOARD_SVG_PATH): Observable<SafeHtml> {
|
||||||
|
return this.baseSvg$(svgPath).pipe(
|
||||||
|
map((svg) => this.injectKeyTaps(svg, taps)),
|
||||||
|
map((html) => this.sanitizer.bypassSecurityTrustHtml(html)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
svgWithHighlight(keyIds: string[] | null, animated = true, svgPath: string = KEYBOARD_SVG_PATH): Observable<SafeHtml> {
|
||||||
|
return this.baseSvg$(svgPath).pipe(
|
||||||
map((svg) => this.injectHighlight(svg, keyIds ?? [], animated)),
|
map((svg) => this.injectHighlight(svg, keyIds ?? [], animated)),
|
||||||
map((html) => this.sanitizer.bypassSecurityTrustHtml(html)),
|
map((html) => this.sanitizer.bypassSecurityTrustHtml(html)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private injectHighlightDiff(svgText: string, diff: KeyHighlightDiff): string {
|
||||||
|
const pressed = this.validIds(diff.pressed);
|
||||||
|
const held = this.validIds(diff.held);
|
||||||
|
const released = this.validIds(diff.released);
|
||||||
|
|
||||||
|
if (pressed.length === 0 && held.length === 0 && released.length === 0) {
|
||||||
|
return svgText;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules: string[] = [];
|
||||||
|
|
||||||
|
for (const id of pressed) {
|
||||||
|
const s = id.slice(2);
|
||||||
|
rules.push(
|
||||||
|
`#${id}{fill:var(--sg-keyboard-key-pressed-fill)!important;stroke:none!important;transform-box:fill-box;transform-origin:center;animation:sgKeyPress 160ms cubic-bezier(0.33,1,0.68,1);}`,
|
||||||
|
);
|
||||||
|
rules.push(
|
||||||
|
`[id^="T_${s}"]{fill:var(--sg-keyboard-key-pressed-ink)!important;animation:sgKeyFadeIn 160ms cubic-bezier(0.33,1,0.68,1);}`,
|
||||||
|
);
|
||||||
|
rules.push(
|
||||||
|
`#S_${s} path,#S_${s} line,#S_${s} polyline,#S_${s} rect{stroke:var(--sg-keyboard-key-pressed-ink)!important;fill:none!important;animation:sgKeyFadeIn 160ms cubic-bezier(0.33,1,0.68,1);}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of held) {
|
||||||
|
const s = id.slice(2);
|
||||||
|
rules.push(
|
||||||
|
`#${id}{fill:var(--sg-keyboard-key-pressed-fill)!important;stroke:none!important;}`,
|
||||||
|
);
|
||||||
|
rules.push(`[id^="T_${s}"]{fill:var(--sg-keyboard-key-pressed-ink)!important;}`);
|
||||||
|
rules.push(
|
||||||
|
`#S_${s} path,#S_${s} line,#S_${s} polyline,#S_${s} rect{stroke:var(--sg-keyboard-key-pressed-ink)!important;fill:none!important;}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of released) {
|
||||||
|
const s = id.slice(2);
|
||||||
|
// No fill override — lets SVG's own per-group CSS restore the correct idle color
|
||||||
|
// (GlyphKey/ModifKey/OtherKey each have their own fill token).
|
||||||
|
rules.push(
|
||||||
|
`#${id}{transform-box:fill-box;transform-origin:center;animation:sgKeyRelease 200ms ease;}`,
|
||||||
|
);
|
||||||
|
rules.push(`[id^="T_${s}"]{animation:sgKeyReleaseInk 200ms ease;}`);
|
||||||
|
rules.push(
|
||||||
|
`#S_${s} path,#S_${s} line,#S_${s} polyline,#S_${s} rect{animation:sgKeyReleaseInk 200ms ease;}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
rules.push(
|
||||||
|
`@keyframes sgKeyPress{0%{transform:scale(0.88);opacity:0.5;}55%{transform:scale(1.03);}100%{transform:scale(1);opacity:1;}}`,
|
||||||
|
);
|
||||||
|
rules.push(`@keyframes sgKeyFadeIn{0%{opacity:0.35;}100%{opacity:1;}}`);
|
||||||
|
rules.push(
|
||||||
|
`@keyframes sgKeyRelease{0%{transform:scale(1);}40%{transform:scale(0.96);}100%{transform:scale(1);}}`,
|
||||||
|
);
|
||||||
|
rules.push(
|
||||||
|
`@keyframes sgKeyReleaseInk{0%{fill:var(--sg-keyboard-key-pressed-ink);}100%{fill:var(--sg-keyboard-ink-soft);}}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const styleBlock = `<style type="text/css"><![CDATA[${rules.join('')}]]></style>`;
|
||||||
|
return svgText.replace(/<\/svg>/i, `${styleBlock}</svg>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private validIds(ids: string[]): string[] {
|
||||||
|
return [...new Set(ids.filter((id) => /^K_kb[0-9a-z]+$/i.test(id)))];
|
||||||
|
}
|
||||||
|
|
||||||
private injectHighlight(svgText: string, keyIds: string[], animated: boolean): string {
|
private injectHighlight(svgText: string, keyIds: string[], animated: boolean): string {
|
||||||
const valid = [...new Set(keyIds.filter((id) => /^K_kb[0-9a-z]+$/i.test(id)))];
|
const valid = this.validIds(keyIds);
|
||||||
if (valid.length === 0) {
|
if (valid.length === 0) {
|
||||||
return svgText;
|
return svgText;
|
||||||
}
|
}
|
||||||
@@ -68,6 +187,25 @@ export class KeyboardSvgHighlightService {
|
|||||||
rules.push(`@keyframes sgInputHighlightFade{0%{opacity:.45;}100%{opacity:1;}}`);
|
rules.push(`@keyframes sgInputHighlightFade{0%{opacity:.45;}100%{opacity:1;}}`);
|
||||||
}
|
}
|
||||||
const styleBlock = `<style type="text/css"><![CDATA[${rules.join('')}]]></style>`;
|
const styleBlock = `<style type="text/css"><![CDATA[${rules.join('')}]]></style>`;
|
||||||
return svgText.replace(/<svg\b[^>]*>/i, (open) => `${open}${styleBlock}`);
|
return svgText.replace(/<\/svg>/i, `${styleBlock}</svg>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private injectKeyTaps(svgText: string, taps: KeyTapHighlight[]): string {
|
||||||
|
const valid = taps.filter((t) => /^K_kb[0-9a-z]+$/i.test(t.keyId));
|
||||||
|
if (valid.length === 0) {
|
||||||
|
return svgText;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules: string[] = [];
|
||||||
|
|
||||||
|
for (const { keyId } of valid) {
|
||||||
|
const s = keyId.slice(2);
|
||||||
|
rules.push(`#${keyId}{fill:var(--sg-keyboard-key-pressed-fill)!important;stroke:none!important;}`);
|
||||||
|
rules.push(`[id^="T_${s}"]{fill:var(--sg-keyboard-key-pressed-ink)!important;}`);
|
||||||
|
rules.push(`#S_${s} path,#S_${s} line,#S_${s} polyline,#S_${s} rect{stroke:var(--sg-keyboard-key-pressed-ink)!important;fill:none!important;}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styleBlock = `<style type="text/css"><![CDATA[${rules.join('')}]]></style>`;
|
||||||
|
return svgText.replace(/<\/svg>/i, `${styleBlock}</svg>`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
88
src/app/core/keyboard/macos-vk-to-keyboard-svg-id.ts
Normal file
88
src/app/core/keyboard/macos-vk-to-keyboard-svg-id.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Соответствие macOS virtual key codes (Carbon `kVK_*` из HIToolbox/Events.h)
|
||||||
|
* физическим клавишам на SVG-клавиатуре US QWERTY (`public/svg/visual/keyboard.svg`).
|
||||||
|
*
|
||||||
|
* Коды — позиционные (ANSI US), не символы Unicode; для другой раскладки символ
|
||||||
|
* может отличаться, но подсветка физической клавиши останется корректной.
|
||||||
|
*
|
||||||
|
* См. также: rsms/kod `virtual_key_codes.h`, Apple Carbon Events.h
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAC_VK_TO_SVG_ID: Record<number, string> = {
|
||||||
|
// kVK_ANSI_* — буквы и цифры (порядок enum ≠ порядок на клавиатуре)
|
||||||
|
0x00: 'K_kb4c',
|
||||||
|
0x01: 'K_kb4d',
|
||||||
|
0x02: 'K_kb4e',
|
||||||
|
0x03: 'K_kb4f',
|
||||||
|
0x04: 'K_kb4h',
|
||||||
|
0x05: 'K_kb4g',
|
||||||
|
0x06: 'K_kb5c',
|
||||||
|
0x07: 'K_kb5d',
|
||||||
|
0x08: 'K_kb5e',
|
||||||
|
0x09: 'K_kb5f',
|
||||||
|
0x0b: 'K_kb5g',
|
||||||
|
0x0c: 'K_kb3b',
|
||||||
|
0x0d: 'K_kb3c',
|
||||||
|
0x0e: 'K_kb3d',
|
||||||
|
0x0f: 'K_kb3e',
|
||||||
|
0x10: 'K_kb3g',
|
||||||
|
0x11: 'K_kb3f',
|
||||||
|
0x12: 'K_kb2k',
|
||||||
|
0x13: 'K_kb2b',
|
||||||
|
0x14: 'K_kb2c',
|
||||||
|
0x15: 'K_kb2d',
|
||||||
|
0x16: 'K_kb2f',
|
||||||
|
0x17: 'K_kb2e',
|
||||||
|
0x18: 'K_kb2m',
|
||||||
|
0x19: 'K_kb2i',
|
||||||
|
0x1a: 'K_kb2g',
|
||||||
|
0x1b: 'K_kb2l',
|
||||||
|
0x1c: 'K_kb2h',
|
||||||
|
0x1d: 'K_kb2j',
|
||||||
|
0x1e: 'K_kb3m',
|
||||||
|
0x1f: 'K_kb3j',
|
||||||
|
0x20: 'K_kb3h',
|
||||||
|
0x21: 'K_kb3l',
|
||||||
|
0x22: 'K_kb3i',
|
||||||
|
0x23: 'K_kb3k',
|
||||||
|
0x25: 'K_kb4k',
|
||||||
|
0x26: 'K_kb4i',
|
||||||
|
0x27: 'K_kb4m',
|
||||||
|
0x28: 'K_kb4j',
|
||||||
|
0x29: 'K_kb4l',
|
||||||
|
0x2a: 'K_kb3n',
|
||||||
|
0x2b: 'K_kb5j',
|
||||||
|
0x2c: 'K_kb5l',
|
||||||
|
0x2d: 'K_kb5h',
|
||||||
|
0x2e: 'K_kb5i',
|
||||||
|
0x2f: 'K_kb5k',
|
||||||
|
0x32: 'K_kb2a',
|
||||||
|
|
||||||
|
// Клавиши вне раскладки
|
||||||
|
0x24: 'K_kb4n',
|
||||||
|
0x30: 'K_kb3a',
|
||||||
|
0x31: 'K_kb6d',
|
||||||
|
0x33: 'K_kb2n',
|
||||||
|
0x36: 'K_kb6l',
|
||||||
|
0x37: 'K_kb6b',
|
||||||
|
0x38: 'K_kb5a',
|
||||||
|
0x39: 'K_kb4a',
|
||||||
|
0x3a: 'K_kb6c',
|
||||||
|
0x3b: 'K_kb6a',
|
||||||
|
0x3c: 'K_kb5m',
|
||||||
|
0x3d: 'K_kb6k',
|
||||||
|
0x3e: 'K_kb6n',
|
||||||
|
|
||||||
|
/** kVK_LeftArrow … kVK_UpArrow — блок стрелок в интерактивном UI */
|
||||||
|
0x7b: 'K_kb7l',
|
||||||
|
0x7c: 'K_kb7r',
|
||||||
|
0x7d: 'K_kb7d',
|
||||||
|
0x7e: 'K_kb7u',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param vk — код как в NSEvent.keyCode / Carbon kVK_* (не Windows VK).
|
||||||
|
*/
|
||||||
|
export function macVkToKeyboardSvgKeyId(vk: number): string | null {
|
||||||
|
return MAC_VK_TO_SVG_ID[vk] ?? null;
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { macVkToKeyboardSvgKeyId } from './macos-vk-to-keyboard-svg-id';
|
||||||
|
|
||||||
|
export type KeyboardVkScheme = 'windows' | 'macos';
|
||||||
|
|
||||||
export function normalizeVirtualKey(vk: number): number {
|
export function normalizeVirtualKey(vk: number): number {
|
||||||
if (vk >= 0x61 && vk <= 0x7a) {
|
if (vk >= 0x61 && vk <= 0x7a) {
|
||||||
return vk - 0x20;
|
return vk - 0x20;
|
||||||
@@ -28,7 +32,7 @@ const LETTER_TO_ID: Record<string, string> = {
|
|||||||
I: 'K_kb3i',
|
I: 'K_kb3i',
|
||||||
J: 'K_kb4i',
|
J: 'K_kb4i',
|
||||||
K: 'K_kb4j',
|
K: 'K_kb4j',
|
||||||
L: 'K_kb4l',
|
L: 'K_kb4k',
|
||||||
M: 'K_kb5i',
|
M: 'K_kb5i',
|
||||||
N: 'K_kb5h',
|
N: 'K_kb5h',
|
||||||
O: 'K_kb3j',
|
O: 'K_kb3j',
|
||||||
@@ -45,10 +49,76 @@ const LETTER_TO_ID: Record<string, string> = {
|
|||||||
Z: 'K_kb5c',
|
Z: 'K_kb5c',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Символы пунктуации и shifted-варианты цифр → физическая клавиша SVG. */
|
||||||
|
const PUNCT_CHAR_TO_ID: Record<string, string> = {
|
||||||
|
// Ряд цифр (shifted)
|
||||||
|
'!': 'K_kb2b', '@': 'K_kb2c', '#': 'K_kb2d', '$': 'K_kb2e',
|
||||||
|
'%': 'K_kb2f', '^': 'K_kb2g', '&': 'K_kb2h', '*': 'K_kb2i',
|
||||||
|
'(': 'K_kb2j', ')': 'K_kb2k',
|
||||||
|
// Правый край ряда цифр
|
||||||
|
'-': 'K_kb2l', '_': 'K_kb2l',
|
||||||
|
'=': 'K_kb2m', '+': 'K_kb2m',
|
||||||
|
'`': 'K_kb2a', '~': 'K_kb2a',
|
||||||
|
// Q-ряд, правый край
|
||||||
|
'[': 'K_kb3l', '{': 'K_kb3l',
|
||||||
|
']': 'K_kb3m', '}': 'K_kb3m',
|
||||||
|
'\\': 'K_kb3n', '|': 'K_kb3n',
|
||||||
|
// A-ряд, правый край
|
||||||
|
';': 'K_kb4l', ':': 'K_kb4l',
|
||||||
|
"'": 'K_kb4m', '"': 'K_kb4m',
|
||||||
|
// Z-ряд, правый край
|
||||||
|
',': 'K_kb5j', '<': 'K_kb5j',
|
||||||
|
'.': 'K_kb5k', '>': 'K_kb5k',
|
||||||
|
'/': 'K_kb5l', '?': 'K_kb5l',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RU_CHAR_TO_ID: Record<string, string> = {
|
||||||
|
ё: 'K_kb2a',
|
||||||
|
й: 'K_kb3b',
|
||||||
|
ц: 'K_kb3c',
|
||||||
|
у: 'K_kb3d',
|
||||||
|
к: 'K_kb3e',
|
||||||
|
е: 'K_kb3f',
|
||||||
|
н: 'K_kb3g',
|
||||||
|
г: 'K_kb3h',
|
||||||
|
ш: 'K_kb3i',
|
||||||
|
щ: 'K_kb3j',
|
||||||
|
з: 'K_kb3k',
|
||||||
|
х: 'K_kb3l',
|
||||||
|
ъ: 'K_kb3m',
|
||||||
|
ф: 'K_kb4c',
|
||||||
|
ы: 'K_kb4d',
|
||||||
|
в: 'K_kb4e',
|
||||||
|
а: 'K_kb4f',
|
||||||
|
п: 'K_kb4g',
|
||||||
|
р: 'K_kb4h',
|
||||||
|
о: 'K_kb4i',
|
||||||
|
л: 'K_kb4j',
|
||||||
|
д: 'K_kb4k',
|
||||||
|
ж: 'K_kb4l',
|
||||||
|
э: 'K_kb4m',
|
||||||
|
я: 'K_kb5c',
|
||||||
|
ч: 'K_kb5d',
|
||||||
|
с: 'K_kb5e',
|
||||||
|
м: 'K_kb5f',
|
||||||
|
и: 'K_kb5g',
|
||||||
|
т: 'K_kb5h',
|
||||||
|
ь: 'K_kb5i',
|
||||||
|
б: 'K_kb5j',
|
||||||
|
ю: 'K_kb5k',
|
||||||
|
/** ЙЦУКЕН, верхний ряд (Windows) */
|
||||||
|
'№': 'K_kb2d',
|
||||||
|
};
|
||||||
|
|
||||||
const EXTRA_VK: Record<number, string> = {
|
const EXTRA_VK: Record<number, string> = {
|
||||||
0x08: 'K_kb2n',
|
0x08: 'K_kb2n',
|
||||||
0x09: 'K_kb3a',
|
0x09: 'K_kb3a',
|
||||||
0x0d: 'K_kb4n',
|
0x0d: 'K_kb4n',
|
||||||
|
/** Windows VK_LEFT / UP / RIGHT / DOWN — отдельный блок стрелок в UI */
|
||||||
|
0x25: 'K_kb7l',
|
||||||
|
0x26: 'K_kb7u',
|
||||||
|
0x27: 'K_kb7r',
|
||||||
|
0x28: 'K_kb7d',
|
||||||
0x10: 'K_kb5a',
|
0x10: 'K_kb5a',
|
||||||
0x11: 'K_kb6a',
|
0x11: 'K_kb6a',
|
||||||
0x12: 'K_kb6c',
|
0x12: 'K_kb6c',
|
||||||
@@ -76,7 +146,7 @@ const EXTRA_VK: Record<number, string> = {
|
|||||||
0xde: 'K_kb4m',
|
0xde: 'K_kb4m',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function vkToKeyboardSvgKeyId(vk: number): string | null {
|
function windowsVkToKeyboardSvgKeyId(vk: number): string | null {
|
||||||
const k = normalizeVirtualKey(vk);
|
const k = normalizeVirtualKey(vk);
|
||||||
const fromExtra = EXTRA_VK[k];
|
const fromExtra = EXTRA_VK[k];
|
||||||
if (fromExtra) {
|
if (fromExtra) {
|
||||||
@@ -93,6 +163,16 @@ export function vkToKeyboardSvgKeyId(vk: number): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param vk — Windows virtual-key (по умолчанию) либо macOS kVK при `scheme: 'macos'`.
|
||||||
|
*/
|
||||||
|
export function vkToKeyboardSvgKeyId(vk: number, scheme: KeyboardVkScheme = 'windows'): string | null {
|
||||||
|
if (scheme === 'macos') {
|
||||||
|
return macVkToKeyboardSvgKeyId(vk);
|
||||||
|
}
|
||||||
|
return windowsVkToKeyboardSvgKeyId(vk);
|
||||||
|
}
|
||||||
|
|
||||||
export function charKeyNameToSvgKeyId(name: string): string | null {
|
export function charKeyNameToSvgKeyId(name: string): string | null {
|
||||||
const c = name.trim();
|
const c = name.trim();
|
||||||
if (c.length !== 1) {
|
if (c.length !== 1) {
|
||||||
@@ -105,5 +185,12 @@ export function charKeyNameToSvgKeyId(name: string): string | null {
|
|||||||
if (ch >= 'A' && ch <= 'Z') {
|
if (ch >= 'A' && ch <= 'Z') {
|
||||||
return LETTER_TO_ID[ch] ?? null;
|
return LETTER_TO_ID[ch] ?? null;
|
||||||
}
|
}
|
||||||
|
const ru = c.toLowerCase();
|
||||||
|
if (ru in RU_CHAR_TO_ID) {
|
||||||
|
return RU_CHAR_TO_ID[ru] ?? null;
|
||||||
|
}
|
||||||
|
if (c in PUNCT_CHAR_TO_ID) {
|
||||||
|
return PUNCT_CHAR_TO_ID[c] ?? null;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,37 @@ export function isMouseTelemetryEvent(event: ParsedEvent): boolean {
|
|||||||
return t.includes('mouse');
|
return t.includes('mouse');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload распознаётся как перемещение курсора (координаты + action «move», без учёта регистра).
|
||||||
|
* Сводка телеметрии и фильтр «Исключить перемещения мыши» должны опираться на одну логику.
|
||||||
|
*/
|
||||||
|
export function isMouseMovePayload(o: Record<string, unknown>): boolean {
|
||||||
|
const rawAction = o['action'];
|
||||||
|
const action =
|
||||||
|
typeof rawAction === 'string' ? rawAction.trim().toLowerCase() : '';
|
||||||
|
if (action !== 'move') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return readNumber(o, 'x') !== null && readNumber(o, 'y') !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Событие перемещения курсора (payload или тип события с «move»). */
|
||||||
|
export function isMouseMoveTelemetryEvent(event: ParsedEvent): boolean {
|
||||||
|
const raw = unwrapJsonPayload(event.data);
|
||||||
|
if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
if (isMouseMovePayload(o)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const t = (event.event_type ?? '').toLowerCase();
|
||||||
|
if (t.includes('mouse') && t.includes('move') && readNumber(o, 'x') !== null && readNumber(o, 'y') !== null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseMouseHighlightTargets(data: unknown): MouseHighlightTarget[] {
|
export function parseMouseHighlightTargets(data: unknown): MouseHighlightTarget[] {
|
||||||
const raw = unwrapJsonPayload(data);
|
const raw = unwrapJsonPayload(data);
|
||||||
if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
|
if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||||
@@ -32,6 +63,17 @@ export function parseMouseHighlightTargets(data: unknown): MouseHighlightTarget[
|
|||||||
return ['wheel'];
|
return ['wheel'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buttonName = typeof o['button_name'] === 'string' ? o['button_name'].trim().toLowerCase() : '';
|
||||||
|
if (buttonName === 'left' || buttonName === 'lmb') {
|
||||||
|
return ['left'];
|
||||||
|
}
|
||||||
|
if (buttonName === 'right' || buttonName === 'rmb') {
|
||||||
|
return ['right'];
|
||||||
|
}
|
||||||
|
if (buttonName === 'middle' || buttonName === 'mmb' || buttonName === 'wheel') {
|
||||||
|
return ['middle'];
|
||||||
|
}
|
||||||
|
|
||||||
const button = readNumber(o, 'button');
|
const button = readNumber(o, 'button');
|
||||||
const isDown = o['is_down'];
|
const isDown = o['is_down'];
|
||||||
if (button == null) {
|
if (button == null) {
|
||||||
@@ -40,14 +82,19 @@ export function parseMouseHighlightTargets(data: unknown): MouseHighlightTarget[
|
|||||||
if (isDown === false) {
|
if (isDown === false) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
if (button === 0) {
|
// Primary scheme in backend telemetry is 1-based: 1=left, 2=right, 3=middle.
|
||||||
return ['left'];
|
|
||||||
}
|
|
||||||
if (button === 1) {
|
if (button === 1) {
|
||||||
return ['middle'];
|
return ['left'];
|
||||||
}
|
}
|
||||||
if (button === 2) {
|
if (button === 2) {
|
||||||
return ['right'];
|
return ['right'];
|
||||||
}
|
}
|
||||||
|
if (button === 3) {
|
||||||
|
return ['middle'];
|
||||||
|
}
|
||||||
|
// Compatibility fallback for legacy/DOM-style 0-based payloads.
|
||||||
|
if (button === 0) {
|
||||||
|
return ['left'];
|
||||||
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export class MouseSvgHighlightService {
|
|||||||
: '';
|
: '';
|
||||||
|
|
||||||
const defsAndStyles = `<defs><linearGradient id="sg-mouse-left-fill" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="var(--sg-keyboard-key-pressed-fill)"/><stop offset="58%" stop-color="var(--sg-keyboard-key-pressed-fill)"/><stop offset="58%" stop-color="var(--sg-keyboard-key-surface-idle)"/><stop offset="100%" stop-color="var(--sg-keyboard-key-surface-idle)"/></linearGradient><linearGradient id="sg-mouse-right-fill" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="var(--sg-keyboard-key-surface-idle)"/><stop offset="42%" stop-color="var(--sg-keyboard-key-surface-idle)"/><stop offset="42%" stop-color="var(--sg-keyboard-key-pressed-fill)"/><stop offset="100%" stop-color="var(--sg-keyboard-key-pressed-fill)"/></linearGradient></defs><style type="text/css"><![CDATA[#SG_MOUSE_SURFACE{fill:${surfaceFill}!important;animation:${surfaceAnim};}#SG_MOUSE_WHEEL{fill:${wheelFill}!important;opacity:${wheelOpacity};transform-box:fill-box;transform-origin:center;animation:${wheelAnim};}${keyframes}]]></style>`;
|
const defsAndStyles = `<defs><linearGradient id="sg-mouse-left-fill" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="var(--sg-keyboard-key-pressed-fill)"/><stop offset="58%" stop-color="var(--sg-keyboard-key-pressed-fill)"/><stop offset="58%" stop-color="var(--sg-keyboard-key-surface-idle)"/><stop offset="100%" stop-color="var(--sg-keyboard-key-surface-idle)"/></linearGradient><linearGradient id="sg-mouse-right-fill" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="var(--sg-keyboard-key-surface-idle)"/><stop offset="42%" stop-color="var(--sg-keyboard-key-surface-idle)"/><stop offset="42%" stop-color="var(--sg-keyboard-key-pressed-fill)"/><stop offset="100%" stop-color="var(--sg-keyboard-key-pressed-fill)"/></linearGradient></defs><style type="text/css"><![CDATA[#SG_MOUSE_SURFACE{fill:${surfaceFill}!important;animation:${surfaceAnim};}#SG_MOUSE_WHEEL{fill:${wheelFill}!important;opacity:${wheelOpacity};transform-box:fill-box;transform-origin:center;animation:${wheelAnim};}${keyframes}]]></style>`;
|
||||||
normalized = normalized.replace(/<svg\b[^>]*>/i, (open) => `${open}${defsAndStyles}`);
|
normalized = normalized.replace(/<\/svg>/i, `${defsAndStyles}</svg>`);
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isMouseMovePayload } from '../mouse/mouse-payload.util';
|
||||||
import {
|
import {
|
||||||
formatTelemetryKeyboardKeySummary,
|
formatTelemetryKeyboardKeySummary,
|
||||||
formatTelemetryMouseClickSummary,
|
formatTelemetryMouseClickSummary,
|
||||||
@@ -24,8 +25,7 @@ export const mouseClickRule: TelemetrySummaryRule = {
|
|||||||
|
|
||||||
export const mouseMoveRule: TelemetrySummaryRule = {
|
export const mouseMoveRule: TelemetrySummaryRule = {
|
||||||
id: 'mouse-move',
|
id: 'mouse-move',
|
||||||
match: (o) =>
|
match: (o) => isMouseMovePayload(o),
|
||||||
o['action'] === 'move' && readNumericField(o, 'x') !== null && readNumericField(o, 'y') !== null,
|
|
||||||
summarize: (o) =>
|
summarize: (o) =>
|
||||||
formatTelemetryMouseMoveSummary(
|
formatTelemetryMouseMoveSummary(
|
||||||
readNumericField(o, 'x')!,
|
readNumericField(o, 'x')!,
|
||||||
|
|||||||
@@ -26,6 +26,25 @@
|
|||||||
<div class="dev-console__main">
|
<div class="dev-console__main">
|
||||||
<span class="dev-console__message">{{ entry.message }}</span>
|
<span class="dev-console__message">{{ entry.message }}</span>
|
||||||
|
|
||||||
|
@if (entry.source === 'telemetry' && entry.telemetryDetails) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dev-console__expand"
|
||||||
|
(click)="toggleExpanded(entry.id)"
|
||||||
|
>
|
||||||
|
{{ isExpanded(entry.id) ? 'Скрыть JSON' : 'Показать JSON' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (isExpanded(entry.id)) {
|
||||||
|
<div class="dev-console__details">
|
||||||
|
<details open>
|
||||||
|
<summary>Событие (как пришло с API)</summary>
|
||||||
|
<pre>{{ entry.telemetryDetails.rawEventJson }}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@if (entry.source === 'http' && entry.details) {
|
@if (entry.source === 'http' && entry.details) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||||
|
|
||||||
|
interface KeyDef {
|
||||||
|
id: string;
|
||||||
|
/** Primary label (letter, digit, symbol or icon) */
|
||||||
|
en: string;
|
||||||
|
/** Shifted / alt label shown in the top-left corner */
|
||||||
|
alt?: string;
|
||||||
|
/** Russian glyph shown in the top-right corner */
|
||||||
|
ru?: string;
|
||||||
|
/** Width in key-units (1 = standard key). Default: 1 */
|
||||||
|
flex: number;
|
||||||
|
/** Space bar — grows to fill remaining row width */
|
||||||
|
isSpace?: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROWS: KeyDef[][] = [
|
||||||
|
// ── Number row ──────────────────────────────────────────────────────────
|
||||||
|
[
|
||||||
|
{ id: 'K_kb2a', en: '`', alt: '~', ru: 'ё', flex: 1 },
|
||||||
|
{ id: 'K_kb2b', en: '1', alt: '!', flex: 1 },
|
||||||
|
{ id: 'K_kb2c', en: '2', alt: '@', flex: 1 },
|
||||||
|
{ id: 'K_kb2d', en: '3', alt: '#', flex: 1 },
|
||||||
|
{ id: 'K_kb2e', en: '4', alt: '$', flex: 1 },
|
||||||
|
{ id: 'K_kb2f', en: '5', alt: '%', flex: 1 },
|
||||||
|
{ id: 'K_kb2g', en: '6', alt: '^', flex: 1 },
|
||||||
|
{ id: 'K_kb2h', en: '7', alt: '&', flex: 1 },
|
||||||
|
{ id: 'K_kb2i', en: '8', alt: '*', flex: 1 },
|
||||||
|
{ id: 'K_kb2j', en: '9', alt: '(', flex: 1 },
|
||||||
|
{ id: 'K_kb2k', en: '0', alt: ')', flex: 1 },
|
||||||
|
{ id: 'K_kb2l', en: '-', alt: '_', flex: 1 },
|
||||||
|
{ id: 'K_kb2m', en: '=', alt: '+', flex: 1 },
|
||||||
|
{ id: 'K_kb2n', en: '⌫', flex: 2 },
|
||||||
|
],
|
||||||
|
// ── Q row ───────────────────────────────────────────────────────────────
|
||||||
|
[
|
||||||
|
{ id: 'K_kb3a', en: 'Tab', flex: 1.5 },
|
||||||
|
{ id: 'K_kb3b', en: 'Q', ru: 'Й', flex: 1 },
|
||||||
|
{ id: 'K_kb3c', en: 'W', ru: 'Ц', flex: 1 },
|
||||||
|
{ id: 'K_kb3d', en: 'E', ru: 'У', flex: 1 },
|
||||||
|
{ id: 'K_kb3e', en: 'R', ru: 'К', flex: 1 },
|
||||||
|
{ id: 'K_kb3f', en: 'T', ru: 'Е', flex: 1 },
|
||||||
|
{ id: 'K_kb3g', en: 'Y', ru: 'Н', flex: 1 },
|
||||||
|
{ id: 'K_kb3h', en: 'U', ru: 'Г', flex: 1 },
|
||||||
|
{ id: 'K_kb3i', en: 'I', ru: 'Ш', flex: 1 },
|
||||||
|
{ id: 'K_kb3j', en: 'O', ru: 'Щ', flex: 1 },
|
||||||
|
{ id: 'K_kb3k', en: 'P', ru: 'З', flex: 1 },
|
||||||
|
{ id: 'K_kb3l', en: '[', alt: '{', ru: 'Х', flex: 1 },
|
||||||
|
{ id: 'K_kb3m', en: ']', alt: '}', ru: 'Ъ', flex: 1 },
|
||||||
|
{ id: 'K_kb3n', en: '\\', alt: '|', flex: 1.5 },
|
||||||
|
],
|
||||||
|
// ── A row ───────────────────────────────────────────────────────────────
|
||||||
|
[
|
||||||
|
{ id: 'K_kb4a', en: 'Caps', flex: 1.75 },
|
||||||
|
{ id: 'K_kb4c', en: 'A', ru: 'Ф', flex: 1 },
|
||||||
|
{ id: 'K_kb4d', en: 'S', ru: 'Ы', flex: 1 },
|
||||||
|
{ id: 'K_kb4e', en: 'D', ru: 'В', flex: 1 },
|
||||||
|
{ id: 'K_kb4f', en: 'F', ru: 'А', flex: 1 },
|
||||||
|
{ id: 'K_kb4g', en: 'G', ru: 'П', flex: 1 },
|
||||||
|
{ id: 'K_kb4h', en: 'H', ru: 'Р', flex: 1 },
|
||||||
|
{ id: 'K_kb4i', en: 'J', ru: 'О', flex: 1 },
|
||||||
|
{ id: 'K_kb4j', en: 'K', ru: 'Л', flex: 1 },
|
||||||
|
{ id: 'K_kb4k', en: 'L', ru: 'Д', flex: 1 },
|
||||||
|
{ id: 'K_kb4l', en: ';', alt: ':', ru: 'Ж', flex: 1 },
|
||||||
|
{ id: 'K_kb4m', en: "'", alt: '"', ru: 'Э', flex: 1 },
|
||||||
|
{ id: 'K_kb4n', en: '↵', flex: 2.25 },
|
||||||
|
],
|
||||||
|
// ── Z row ───────────────────────────────────────────────────────────────
|
||||||
|
[
|
||||||
|
{ id: 'K_kb5a', en: '⇧', flex: 2.25 },
|
||||||
|
{ id: 'K_kb5c', en: 'Z', ru: 'Я', flex: 1 },
|
||||||
|
{ id: 'K_kb5d', en: 'X', ru: 'Ч', flex: 1 },
|
||||||
|
{ id: 'K_kb5e', en: 'C', ru: 'С', flex: 1 },
|
||||||
|
{ id: 'K_kb5f', en: 'V', ru: 'М', flex: 1 },
|
||||||
|
{ id: 'K_kb5g', en: 'B', ru: 'И', flex: 1 },
|
||||||
|
{ id: 'K_kb5h', en: 'N', ru: 'Т', flex: 1 },
|
||||||
|
{ id: 'K_kb5i', en: 'M', ru: 'Ь', flex: 1 },
|
||||||
|
{ id: 'K_kb5j', en: ',', alt: '<', ru: 'Б', flex: 1 },
|
||||||
|
{ id: 'K_kb5k', en: '.', alt: '>', ru: 'Ю', flex: 1 },
|
||||||
|
{ id: 'K_kb5l', en: '/', alt: '?', flex: 1 },
|
||||||
|
{ id: 'K_kb5m', en: '⇧', flex: 2.75 },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
const MODIFIER_ROW: KeyDef[] = [
|
||||||
|
{ id: 'K_kb6a', en: 'ctrl', flex: 1.5 },
|
||||||
|
{ id: 'K_kb6c', en: '⌥', flex: 1.5 },
|
||||||
|
{ id: 'K_kb6b', en: '⌘', flex: 1.5 },
|
||||||
|
{ id: 'K_kb6d', en: '', flex: 1, isSpace: true },
|
||||||
|
{ id: 'K_kb6l', en: '⌘', flex: 1.5 },
|
||||||
|
{ id: 'K_kb6k', en: '⌥', flex: 1.5 },
|
||||||
|
{ id: 'K_kb7l', en: '←', flex: 1 },
|
||||||
|
{ id: 'K_kb7u', en: '↑', flex: 1 },
|
||||||
|
{ id: 'K_kb7d', en: '↓', flex: 1 },
|
||||||
|
{ id: 'K_kb7r', en: '→', flex: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-keyboard-view',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './keyboard-view.html',
|
||||||
|
styleUrl: './keyboard-view.css',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class KeyboardViewComponent {
|
||||||
|
readonly pressedIds = input.required<ReadonlySet<string>>();
|
||||||
|
|
||||||
|
protected readonly rows = ROWS;
|
||||||
|
protected readonly modifierRow = MODIFIER_ROW;
|
||||||
|
}
|
||||||
129
src/app/features/sessions/keyboard-view/keyboard-view.css
Normal file
129
src/app/features/sessions/keyboard-view/keyboard-view.css
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard {
|
||||||
|
/* Base unit: standard key size */
|
||||||
|
--ku: 2.4rem;
|
||||||
|
/* Gap between keys */
|
||||||
|
--kg: 0.18rem;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--kg);
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Row ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.keyboard__row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--kg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard__row--modifiers {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Key ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.key {
|
||||||
|
--w: 1;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
/* Width formula accounts for the gap absorbed inside wide keys */
|
||||||
|
width: calc(var(--w) * var(--ku) + (var(--w) - 1) * var(--kg));
|
||||||
|
height: var(--ku);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.2rem 0.25rem;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
background: var(--sg-keyboard-key-surface-idle);
|
||||||
|
border-radius: 0.28rem;
|
||||||
|
box-shadow: inset 0 0 0 0.5px var(--sg-keyboard-key-stroke);
|
||||||
|
|
||||||
|
cursor: default;
|
||||||
|
user-select: none;
|
||||||
|
transition: background 60ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Space bar: fills leftover width in modifier row */
|
||||||
|
.key--space {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pressed state */
|
||||||
|
.key--active {
|
||||||
|
background: var(--sg-keyboard-key-pressed-fill);
|
||||||
|
box-shadow: inset 0 0 0 0.5px color-mix(in srgb, var(--sg-keyboard-key-pressed-fill) 55%, black);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Key labels ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.key__top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shifted/alt label — top-left */
|
||||||
|
.key__alt {
|
||||||
|
font-size: 0.57rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--sg-keyboard-ink);
|
||||||
|
font-family: var(--sg-keyboard-font-family);
|
||||||
|
font-weight: var(--sg-keyboard-font-weight);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Russian glyph — top-right */
|
||||||
|
.key__ru {
|
||||||
|
font-size: 0.57rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--sg-keyboard-ink-soft);
|
||||||
|
font-family: var(--sg-keyboard-font-family);
|
||||||
|
font-weight: var(--sg-keyboard-font-weight);
|
||||||
|
text-align: right;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main English label — bottom-left */
|
||||||
|
.key__en {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--sg-keyboard-ink);
|
||||||
|
font-family: var(--sg-keyboard-font-family);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: var(--sg-keyboard-letter-spacing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Label for special keys (Tab, Caps, Shift, ⌘ …) — centered */
|
||||||
|
.key__label {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--sg-keyboard-ink);
|
||||||
|
font-family: var(--sg-keyboard-font-family);
|
||||||
|
font-weight: var(--sg-keyboard-font-weight);
|
||||||
|
letter-spacing: var(--sg-keyboard-letter-spacing);
|
||||||
|
margin: auto;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active key — invert text colors */
|
||||||
|
.key--active .key__alt,
|
||||||
|
.key--active .key__en,
|
||||||
|
.key--active .key__label {
|
||||||
|
color: var(--sg-keyboard-key-pressed-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key--active .key__ru {
|
||||||
|
color: color-mix(in srgb, var(--sg-keyboard-key-pressed-ink) 65%, transparent);
|
||||||
|
}
|
||||||
36
src/app/features/sessions/keyboard-view/keyboard-view.html
Normal file
36
src/app/features/sessions/keyboard-view/keyboard-view.html
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<div class="keyboard">
|
||||||
|
@for (row of rows; track $index) {
|
||||||
|
<div class="keyboard__row">
|
||||||
|
@for (k of row; track k.id) {
|
||||||
|
<div
|
||||||
|
class="key"
|
||||||
|
[class.key--active]="pressedIds().has(k.id)"
|
||||||
|
[style.--w]="k.flex"
|
||||||
|
>
|
||||||
|
@if (k.alt || k.ru) {
|
||||||
|
<div class="key__top">
|
||||||
|
<span class="key__alt">{{ k.alt ?? '' }}</span>
|
||||||
|
<span class="key__ru">{{ k.ru ?? '' }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="key__en">{{ k.en }}</span>
|
||||||
|
} @else {
|
||||||
|
<span class="key__label">{{ k.en }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="keyboard__row keyboard__row--modifiers">
|
||||||
|
@for (k of modifierRow; track k.id) {
|
||||||
|
<div
|
||||||
|
class="key"
|
||||||
|
[class.key--active]="pressedIds().has(k.id)"
|
||||||
|
[class.key--space]="k.isSpace"
|
||||||
|
[style.--w]="k.isSpace ? null : k.flex"
|
||||||
|
>
|
||||||
|
<span class="key__label">{{ k.en }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
20
src/app/features/sessions/mouse-view/mouse-view.component.ts
Normal file
20
src/app/features/sessions/mouse-view/mouse-view.component.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
||||||
|
|
||||||
|
import type { MouseHighlightTarget } from '../../../core/mouse/mouse-payload.util';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-mouse-view',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './mouse-view.html',
|
||||||
|
styleUrl: './mouse-view.css',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class MouseViewComponent {
|
||||||
|
readonly targets = input.required<readonly MouseHighlightTarget[]>();
|
||||||
|
|
||||||
|
protected readonly hasLeft = computed(() => this.targets().includes('left'));
|
||||||
|
protected readonly hasRight = computed(() => this.targets().includes('right'));
|
||||||
|
protected readonly hasMiddle = computed(
|
||||||
|
() => this.targets().includes('middle') || this.targets().includes('wheel'),
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/app/features/sessions/mouse-view/mouse-view.css
Normal file
71
src/app/features/sessions/mouse-view/mouse-view.css
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
:host {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mouse {
|
||||||
|
width: 120px;
|
||||||
|
border-radius: 26px 26px 18px 18px;
|
||||||
|
background: var(--sg-keyboard-key-surface-idle);
|
||||||
|
box-shadow: inset 0 0 0 0.5px var(--sg-keyboard-key-stroke);
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Buttons area ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mouse__buttons {
|
||||||
|
height: 88px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 10px 1fr;
|
||||||
|
border-bottom: 2px solid var(--sg-keyboard-key-stroke);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mouse__btn {
|
||||||
|
transition: background 60ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mouse__btn--left {
|
||||||
|
border-radius: 26px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mouse__btn--right {
|
||||||
|
border-radius: 0 26px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mouse--left .mouse__btn--left {
|
||||||
|
background: var(--sg-keyboard-key-pressed-fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mouse--right .mouse__btn--right {
|
||||||
|
background: var(--sg-keyboard-key-pressed-fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Center column: scroll wheel ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mouse__divider {
|
||||||
|
border-left: 0.5px solid var(--sg-keyboard-key-stroke);
|
||||||
|
border-right: 0.5px solid var(--sg-keyboard-key-stroke);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mouse__wheel {
|
||||||
|
width: 6px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--sg-keyboard-key-stroke);
|
||||||
|
transition: background 60ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mouse__wheel--active {
|
||||||
|
background: var(--sg-keyboard-key-pressed-fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Lower body: fills remaining height ──────────────────────────────── */
|
||||||
|
|
||||||
|
.mouse__body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 18px;
|
||||||
|
}
|
||||||
14
src/app/features/sessions/mouse-view/mouse-view.html
Normal file
14
src/app/features/sessions/mouse-view/mouse-view.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<div
|
||||||
|
class="mouse"
|
||||||
|
[class.mouse--left]="hasLeft()"
|
||||||
|
[class.mouse--right]="hasRight()"
|
||||||
|
>
|
||||||
|
<div class="mouse__buttons">
|
||||||
|
<div class="mouse__btn mouse__btn--left"></div>
|
||||||
|
<div class="mouse__divider">
|
||||||
|
<div class="mouse__wheel" [class.mouse__wheel--active]="hasMiddle()"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mouse__btn mouse__btn--right"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mouse__body"></div>
|
||||||
|
</div>
|
||||||
@@ -1,14 +1,28 @@
|
|||||||
import { AsyncPipe } from '@angular/common';
|
import { AsyncPipe } from '@angular/common';
|
||||||
import { HttpErrorResponse } from '@angular/common/http';
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
import { ChangeDetectionStrategy, Component, inject, model, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject, isDevMode, model, signal } from '@angular/core';
|
||||||
import { toObservable } from '@angular/core/rxjs-interop';
|
import { toObservable } from '@angular/core/rxjs-interop';
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { TuiCheckbox } from '@taiga-ui/core/components/checkbox';
|
||||||
import { TuiLink } from '@taiga-ui/core/components/link';
|
import { TuiLink } from '@taiga-ui/core/components/link';
|
||||||
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||||
import { TuiTitle } from '@taiga-ui/core/components/title';
|
import { TuiTitle } from '@taiga-ui/core/components/title';
|
||||||
import { TuiTabs } from '@taiga-ui/kit/components/tabs';
|
import { TuiTabs } from '@taiga-ui/kit/components/tabs';
|
||||||
import { catchError, combineLatest, distinctUntilChanged, map, of, startWith, switchMap, tap, timeout } from 'rxjs';
|
import {
|
||||||
|
catchError,
|
||||||
|
combineLatest,
|
||||||
|
distinctUntilChanged,
|
||||||
|
map,
|
||||||
|
of,
|
||||||
|
startWith,
|
||||||
|
switchMap,
|
||||||
|
tap,
|
||||||
|
timeout,
|
||||||
|
} from 'rxjs';
|
||||||
|
|
||||||
|
import { DevLogService } from '../../../core/devtools/dev-log.service';
|
||||||
|
import { isKeyboardTelemetryEvent } from '../../../core/keyboard/keyboard-payload.util';
|
||||||
import { UserErrorNotifyService } from '../../../core/notifications/user-error-notify.service';
|
import { UserErrorNotifyService } from '../../../core/notifications/user-error-notify.service';
|
||||||
import type { ParsedEvent } from '../../../core/models/api.types';
|
import type { ParsedEvent } from '../../../core/models/api.types';
|
||||||
import { SessionsApiService } from '../../../core/services/sessions-api.service';
|
import { SessionsApiService } from '../../../core/services/sessions-api.service';
|
||||||
@@ -20,7 +34,9 @@ import { SessionInfoTabComponent } from './session-info-tab/session-info-tab.com
|
|||||||
selector: 'app-session-detail',
|
selector: 'app-session-detail',
|
||||||
imports: [
|
imports: [
|
||||||
AsyncPipe,
|
AsyncPipe,
|
||||||
|
FormsModule,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
|
TuiCheckbox,
|
||||||
TuiLink,
|
TuiLink,
|
||||||
TuiLoader,
|
TuiLoader,
|
||||||
TuiTitle,
|
TuiTitle,
|
||||||
@@ -37,10 +53,12 @@ export class SessionDetailComponent {
|
|||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly api = inject(SessionsApiService);
|
private readonly api = inject(SessionsApiService);
|
||||||
private readonly userErrors = inject(UserErrorNotifyService);
|
private readonly userErrors = inject(UserErrorNotifyService);
|
||||||
|
private readonly devLog = inject(DevLogService);
|
||||||
|
|
||||||
protected readonly telemetryToMs = signal<number | null>(null);
|
protected readonly telemetryToMs = signal<number | null>(null);
|
||||||
protected readonly recordingStartMs = signal<number | null>(null);
|
protected readonly recordingStartMs = signal<number | null>(null);
|
||||||
protected readonly recordingEndMs = signal<number | null>(null);
|
protected readonly recordingEndMs = signal<number | null>(null);
|
||||||
|
protected readonly excludeMouseMoves = model(false);
|
||||||
protected readonly activeTabIndex = model(0);
|
protected readonly activeTabIndex = model(0);
|
||||||
|
|
||||||
private readonly sessionId$ = this.route.paramMap.pipe(
|
private readonly sessionId$ = this.route.paramMap.pipe(
|
||||||
@@ -110,6 +128,25 @@ export class SessionDetailComponent {
|
|||||||
count: resp.count,
|
count: resp.count,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
tap((result) => {
|
||||||
|
if (!isDevMode() || result.status !== 'ok') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.devLog.clearSource('telemetry');
|
||||||
|
const keyboardEvents = result.telemetry.filter(isKeyboardTelemetryEvent);
|
||||||
|
let rawEventJson: string;
|
||||||
|
try {
|
||||||
|
rawEventJson = JSON.stringify(keyboardEvents, null, 2);
|
||||||
|
} catch {
|
||||||
|
rawEventJson = '(не удалось сериализовать)';
|
||||||
|
}
|
||||||
|
this.devLog.add({
|
||||||
|
level: 'info',
|
||||||
|
source: 'telemetry',
|
||||||
|
message: `События клавиатуры · ${keyboardEvents.length} шт.`,
|
||||||
|
telemetryDetails: { rawEventJson },
|
||||||
|
});
|
||||||
|
}),
|
||||||
catchError((e: unknown) => {
|
catchError((e: unknown) => {
|
||||||
this.userErrors.notifyError(e, 'Телеметрия');
|
this.userErrors.notifyError(e, 'Телеметрия');
|
||||||
return of({
|
return of({
|
||||||
|
|||||||
@@ -6,6 +6,26 @@
|
|||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-telemetry-filter-bar {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-telemetry-filter-bar__label {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-telemetry-filter-bar__text {
|
||||||
|
font: var(--tui-font-text-s);
|
||||||
|
color: var(--tui-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* Do not set display: block — it breaks horizontal flex on tui-tabs and wraps tabs. */
|
/* Do not set display: block — it breaks horizontal flex on tui-tabs and wraps tabs. */
|
||||||
.session-tabs {
|
.session-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -23,6 +23,20 @@
|
|||||||
</tui-tabs>
|
</tui-tabs>
|
||||||
|
|
||||||
@if (telemetry$ | async; as telemetryState) {
|
@if (telemetry$ | async; as telemetryState) {
|
||||||
|
@if (activeTabIndex() === 0 || activeTabIndex() === 1) {
|
||||||
|
<div class="session-telemetry-filter-bar">
|
||||||
|
<label class="session-telemetry-filter-bar__label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
tuiCheckbox
|
||||||
|
size="s"
|
||||||
|
[ngModel]="excludeMouseMoves()"
|
||||||
|
(ngModelChange)="excludeMouseMoves.set($event)"
|
||||||
|
/>
|
||||||
|
<span class="session-telemetry-filter-bar__text">Исключить перемещения мыши</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@switch (activeTabIndex()) {
|
@switch (activeTabIndex()) {
|
||||||
@case (0) {
|
@case (0) {
|
||||||
<app-session-view-tab
|
<app-session-view-tab
|
||||||
@@ -30,6 +44,7 @@
|
|||||||
[telemetryState]="telemetryState"
|
[telemetryState]="telemetryState"
|
||||||
[recordingStartMs]="recordingStartMs()"
|
[recordingStartMs]="recordingStartMs()"
|
||||||
[recordingEndMs]="recordingEndMs()"
|
[recordingEndMs]="recordingEndMs()"
|
||||||
|
[excludeMouseMoves]="excludeMouseMoves()"
|
||||||
(telemetryToMsChange)="telemetryToMs.set($event)"
|
(telemetryToMsChange)="telemetryToMs.set($event)"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -39,6 +54,7 @@
|
|||||||
[telemetryEvents]="telemetryState.telemetry"
|
[telemetryEvents]="telemetryState.telemetry"
|
||||||
[recordingStartMs]="recordingStartMs()"
|
[recordingStartMs]="recordingStartMs()"
|
||||||
[recordingEndMs]="recordingEndMs()"
|
[recordingEndMs]="recordingEndMs()"
|
||||||
|
[excludeMouseMoves]="excludeMouseMoves()"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@case (2) {
|
@case (2) {
|
||||||
|
|||||||
@@ -1,33 +1,43 @@
|
|||||||
import { AsyncPipe } from '@angular/common';
|
|
||||||
import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core';
|
||||||
import { toObservable } from '@angular/core/rxjs-interop';
|
|
||||||
import { TuiButton } from '@taiga-ui/core/components/button';
|
import { TuiButton } from '@taiga-ui/core/components/button';
|
||||||
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
|
||||||
import { distinctUntilChanged, switchMap } from 'rxjs';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isKeyboardTelemetryEvent,
|
isKeyboardTelemetryEvent,
|
||||||
parseKeyboardAction,
|
parseKeyboardAction,
|
||||||
parseKeyboardHighlightKeyIds,
|
parseKeyboardHighlightKeyIds,
|
||||||
} from '../../../../core/keyboard/keyboard-payload.util';
|
} from '../../../../core/keyboard/keyboard-payload.util';
|
||||||
import { KeyboardSvgHighlightService } from '../../../../core/keyboard/keyboard-svg-highlight.service';
|
import {
|
||||||
import { isMouseTelemetryEvent, parseMouseHighlightTargets } from '../../../../core/mouse/mouse-payload.util';
|
isMouseMoveTelemetryEvent,
|
||||||
|
isMouseTelemetryEvent,
|
||||||
|
parseMouseHighlightTargets,
|
||||||
|
} from '../../../../core/mouse/mouse-payload.util';
|
||||||
import type { MouseHighlightTarget } from '../../../../core/mouse/mouse-payload.util';
|
import type { MouseHighlightTarget } from '../../../../core/mouse/mouse-payload.util';
|
||||||
import { MouseSvgHighlightService } from '../../../../core/mouse/mouse-svg-highlight.service';
|
|
||||||
import { SessionsApiService } from '../../../../core/services/sessions-api.service';
|
import { SessionsApiService } from '../../../../core/services/sessions-api.service';
|
||||||
import type { ParsedEvent, SessionDetailResponse } from '../../../../core/models/api.types';
|
import type { ParsedEvent, SessionDetailResponse } from '../../../../core/models/api.types';
|
||||||
import { formatClockTime, formatUnixMs } from '../../../../shared/utils/date-time.util';
|
import { formatClockTime, formatUnixMs } from '../../../../shared/utils/date-time.util';
|
||||||
import { HlsPlayerComponent } from '../../hls-player/hls-player.component';
|
import { HlsPlayerComponent } from '../../hls-player/hls-player.component';
|
||||||
import { StreamSelectorComponent } from '../../stream-selector/stream-selector.component';
|
import { StreamSelectorComponent } from '../../stream-selector/stream-selector.component';
|
||||||
|
import { KeyboardViewComponent } from '../../keyboard-view/keyboard-view.component';
|
||||||
|
import { MouseViewComponent } from '../../mouse-view/mouse-view.component';
|
||||||
|
import { environment } from '../../../../../environments/environment';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Окно видимости нажатия клавиши (мс). Должно быть > интервала timeupdate (~250ms),
|
||||||
|
* чтобы каждое нажатие попало хотя бы в один кадр курсора.
|
||||||
|
*/
|
||||||
|
const KEY_DISPLAY_MS = 400;
|
||||||
|
const INTERACTIVE_PREROLL_MS = Number.isFinite(environment.interactivePrerollMs)
|
||||||
|
? Math.max(0, environment.interactivePrerollMs)
|
||||||
|
: 4000;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-session-interactive-tab',
|
selector: 'app-session-interactive-tab',
|
||||||
imports: [
|
imports: [
|
||||||
AsyncPipe,
|
|
||||||
TuiButton,
|
TuiButton,
|
||||||
TuiLoader,
|
|
||||||
HlsPlayerComponent,
|
HlsPlayerComponent,
|
||||||
StreamSelectorComponent,
|
StreamSelectorComponent,
|
||||||
|
KeyboardViewComponent,
|
||||||
|
MouseViewComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './session-interactive-tab.html',
|
templateUrl: './session-interactive-tab.html',
|
||||||
styleUrl: './session-interactive-tab.css',
|
styleUrl: './session-interactive-tab.css',
|
||||||
@@ -35,13 +45,12 @@ import { StreamSelectorComponent } from '../../stream-selector/stream-selector.c
|
|||||||
})
|
})
|
||||||
export class SessionInteractiveTabComponent {
|
export class SessionInteractiveTabComponent {
|
||||||
private readonly api = inject(SessionsApiService);
|
private readonly api = inject(SessionsApiService);
|
||||||
private readonly keyboardSvg = inject(KeyboardSvgHighlightService);
|
|
||||||
private readonly mouseSvg = inject(MouseSvgHighlightService);
|
|
||||||
|
|
||||||
readonly detail = input.required<SessionDetailResponse>();
|
readonly detail = input.required<SessionDetailResponse>();
|
||||||
readonly telemetryEvents = input.required<ParsedEvent[]>();
|
readonly telemetryEvents = input.required<ParsedEvent[]>();
|
||||||
readonly recordingStartMs = input.required<number | null>();
|
readonly recordingStartMs = input.required<number | null>();
|
||||||
readonly recordingEndMs = input.required<number | null>();
|
readonly recordingEndMs = input.required<number | null>();
|
||||||
|
readonly excludeMouseMoves = input<boolean>(false);
|
||||||
|
|
||||||
protected readonly selectedStreamType = signal<string | null>(null);
|
protected readonly selectedStreamType = signal<string | null>(null);
|
||||||
protected readonly timelineSec = signal(0);
|
protected readonly timelineSec = signal(0);
|
||||||
@@ -61,12 +70,35 @@ export class SessionInteractiveTabComponent {
|
|||||||
return (end - start) / 1000;
|
return (end - start) / 1000;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time anchor for timeline -> telemetry conversion.
|
||||||
|
* `session.started_at` can be slightly shifted vs actual stream start,
|
||||||
|
* so we additionally consider the earliest parsed telemetry timestamp.
|
||||||
|
*/
|
||||||
|
private readonly telemetryAnchorStartMs = computed(() => {
|
||||||
|
const sessionStart = this.recordingStartMs();
|
||||||
|
const firstTelemetryTs = this.telemetryEvents().reduce<number | null>((minTs, e) => {
|
||||||
|
const ts = e.timestamp;
|
||||||
|
if (!Number.isFinite(ts)) {
|
||||||
|
return minTs;
|
||||||
|
}
|
||||||
|
return minTs == null ? ts : Math.min(minTs, ts);
|
||||||
|
}, null);
|
||||||
|
if (sessionStart == null) {
|
||||||
|
return firstTelemetryTs;
|
||||||
|
}
|
||||||
|
if (firstTelemetryTs == null) {
|
||||||
|
return sessionStart;
|
||||||
|
}
|
||||||
|
return Math.min(sessionStart, firstTelemetryTs);
|
||||||
|
});
|
||||||
|
|
||||||
private readonly cursorMs = computed(() => {
|
private readonly cursorMs = computed(() => {
|
||||||
const start = this.recordingStartMs();
|
const start = this.telemetryAnchorStartMs();
|
||||||
if (start == null) {
|
if (start == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return start + this.timelineSec() * 1000;
|
return start + this.timelineSec() * 1000 - INTERACTIVE_PREROLL_MS;
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,40 +116,45 @@ export class SessionInteractiveTabComponent {
|
|||||||
*/
|
*/
|
||||||
private readonly sortedMouseEvents = computed(() =>
|
private readonly sortedMouseEvents = computed(() =>
|
||||||
this.telemetryEvents()
|
this.telemetryEvents()
|
||||||
.filter(isMouseTelemetryEvent)
|
.filter((e) => {
|
||||||
|
if (!isMouseTelemetryEvent(e)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !this.excludeMouseMoves() || !isMouseMoveTelemetryEvent(e);
|
||||||
|
})
|
||||||
.sort((a, b) => a.timestamp - b.timestamp),
|
.sort((a, b) => a.timestamp - b.timestamp),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly keyboardKeyIds = computed(() => {
|
/**
|
||||||
|
* Set of key IDs that were pressed in the window [cursorMs - KEY_DISPLAY_MS, cursorMs].
|
||||||
|
* Iterates backwards (newest first), deduplicates by keyId.
|
||||||
|
*/
|
||||||
|
protected readonly pressedKeyIds = computed((): ReadonlySet<string> => {
|
||||||
const cursorMs = this.cursorMs();
|
const cursorMs = this.cursorMs();
|
||||||
if (cursorMs == null) {
|
if (cursorMs == null) {
|
||||||
return [];
|
return new Set();
|
||||||
}
|
}
|
||||||
const active = new Set<string>();
|
const seen = new Set<string>();
|
||||||
// Events are sorted; break early once we exceed cursorMs.
|
const events = this.sortedKeyboardEvents();
|
||||||
for (const event of this.sortedKeyboardEvents()) {
|
for (let i = events.length - 1; i >= 0; i--) {
|
||||||
|
const event = events[i]!;
|
||||||
if (event.timestamp > cursorMs) {
|
if (event.timestamp > cursorMs) {
|
||||||
break;
|
|
||||||
}
|
|
||||||
const keyIds = parseKeyboardHighlightKeyIds(event.data);
|
|
||||||
if (keyIds.length === 0) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const action = parseKeyboardAction(event.data);
|
if (event.timestamp < cursorMs - KEY_DISPLAY_MS) {
|
||||||
if (action === 'release') {
|
break;
|
||||||
for (const keyId of keyIds) {
|
|
||||||
active.delete(keyId);
|
|
||||||
}
|
}
|
||||||
} else {
|
if (parseKeyboardAction(event.data) !== 'press') {
|
||||||
for (const keyId of keyIds) {
|
continue;
|
||||||
active.add(keyId);
|
}
|
||||||
|
for (const keyId of parseKeyboardHighlightKeyIds(event.data)) {
|
||||||
|
seen.add(keyId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return seen;
|
||||||
return [...active];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
private readonly mouseTargets = computed(() => {
|
protected readonly mouseTargets = computed(() => {
|
||||||
const cursorMs = this.cursorMs();
|
const cursorMs = this.cursorMs();
|
||||||
if (cursorMs == null) {
|
if (cursorMs == null) {
|
||||||
return [];
|
return [];
|
||||||
@@ -134,21 +171,6 @@ export class SessionInteractiveTabComponent {
|
|||||||
return [] as MouseHighlightTarget[];
|
return [] as MouseHighlightTarget[];
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Keyboard SVG without animations — interactive mode shows state, not events.
|
|
||||||
* distinctUntilChanged prevents SVG regeneration when the key set hasn't changed,
|
|
||||||
* which eliminates animation restarts during slider movement.
|
|
||||||
*/
|
|
||||||
protected readonly keyboardSvg$ = toObservable(this.keyboardKeyIds).pipe(
|
|
||||||
distinctUntilChanged(keySetEqual),
|
|
||||||
switchMap((keyIds) => this.keyboardSvg.svgWithHighlight(keyIds, false)),
|
|
||||||
);
|
|
||||||
|
|
||||||
protected readonly mouseSvg$ = toObservable(this.mouseTargets).pipe(
|
|
||||||
distinctUntilChanged(targetSetEqual),
|
|
||||||
switchMap((targets) => this.mouseSvg.svgWithHighlight(targets, false)),
|
|
||||||
);
|
|
||||||
|
|
||||||
protected activeStreamType(): string | null {
|
protected activeStreamType(): string | null {
|
||||||
const streams = this.detail().streams;
|
const streams = this.detail().streams;
|
||||||
if (!streams?.length) {
|
if (!streams?.length) {
|
||||||
@@ -245,21 +267,3 @@ export class SessionInteractiveTabComponent {
|
|||||||
return Math.min(max, Math.max(min, value));
|
return Math.min(max, Math.max(min, value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compares two key-id arrays regardless of order. */
|
|
||||||
function keySetEqual(a: string[], b: string[]): boolean {
|
|
||||||
if (a.length !== b.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const setB = new Set(b);
|
|
||||||
return a.every((v) => setB.has(v));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compares two mouse-target arrays regardless of order. */
|
|
||||||
function targetSetEqual(a: MouseHighlightTarget[], b: MouseHighlightTarget[]): boolean {
|
|
||||||
if (a.length !== b.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const setB = new Set<string>(b);
|
|
||||||
return a.every((v) => setB.has(v));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -157,64 +157,24 @@
|
|||||||
color: var(--tui-text-tertiary);
|
color: var(--tui-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyboard-svg-host {
|
|
||||||
max-width: 100%;
|
|
||||||
overflow: auto;
|
|
||||||
border-radius: var(--tui-radius-m);
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard-svg-host_compact {
|
|
||||||
max-width: min(700px, 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard-svg-host ::ng-deep svg {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
max-width: 800px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard-loading {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-preview {
|
.input-preview {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 2.75rem;
|
gap: 2rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mouse-svg-host {
|
.mouse-sidebar {
|
||||||
width: 144px;
|
flex: 0 0 auto;
|
||||||
flex: 0 0 144px;
|
display: flex;
|
||||||
border-radius: var(--tui-radius-m);
|
align-items: stretch;
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mouse-svg-host ::ng-deep svg {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mouse-loading {
|
|
||||||
width: 144px;
|
|
||||||
flex: 0 0 144px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.input-preview {
|
.input-preview {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
align-items: center;
|
||||||
|
|
||||||
.mouse-svg-host,
|
|
||||||
.mouse-loading {
|
|
||||||
width: min(220px, 100%);
|
|
||||||
flex-basis: auto;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,19 +133,9 @@
|
|||||||
|
|
||||||
<section class="card" aria-label="Интерактивный режим: клавиатура">
|
<section class="card" aria-label="Интерактивный режим: клавиатура">
|
||||||
<div class="input-preview">
|
<div class="input-preview">
|
||||||
@if (keyboardSvg$ | async; as keyboardSvg) {
|
<app-keyboard-view [pressedIds]="pressedKeyIds()" />
|
||||||
<div class="keyboard-svg-host keyboard-svg-host_compact" [innerHTML]="keyboardSvg"></div>
|
<div class="mouse-sidebar">
|
||||||
} @else {
|
<app-mouse-view [targets]="mouseTargets()" />
|
||||||
<div class="keyboard-loading">
|
|
||||||
<tui-loader [loading]="true" size="m" />
|
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
@if (mouseSvg$ | async; as mouseSvg) {
|
|
||||||
<div class="mouse-svg-host" [innerHTML]="mouseSvg"></div>
|
|
||||||
} @else {
|
|
||||||
<div class="keyboard-loading mouse-loading">
|
|
||||||
<tui-loader [loading]="true" size="m" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { TuiButton } from '@taiga-ui/core/components/button';
|
|||||||
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||||
import { TuiChip } from '@taiga-ui/kit/components/chip';
|
import { TuiChip } from '@taiga-ui/kit/components/chip';
|
||||||
|
|
||||||
|
import { isMouseMoveTelemetryEvent } from '../../../../core/mouse/mouse-payload.util';
|
||||||
import { SessionStatusChipClassesPipe } from '../../../../core/sessions/session-status-chip-classes.pipe';
|
import { SessionStatusChipClassesPipe } from '../../../../core/sessions/session-status-chip-classes.pipe';
|
||||||
import { SessionStatusPipe } from '../../../../core/sessions/session-status.pipe';
|
import { SessionStatusPipe } from '../../../../core/sessions/session-status.pipe';
|
||||||
import { TelemetryEventTypePipe } from '../../../../core/sessions/telemetry-event-type.pipe';
|
import { TelemetryEventTypePipe } from '../../../../core/sessions/telemetry-event-type.pipe';
|
||||||
@@ -59,6 +60,7 @@ export class SessionViewTabComponent {
|
|||||||
readonly telemetryState = input.required<TelemetryLoadState>();
|
readonly telemetryState = input.required<TelemetryLoadState>();
|
||||||
readonly recordingStartMs = input.required<number | null>();
|
readonly recordingStartMs = input.required<number | null>();
|
||||||
readonly recordingEndMs = input.required<number | null>();
|
readonly recordingEndMs = input.required<number | null>();
|
||||||
|
readonly excludeMouseMoves = input<boolean>(false);
|
||||||
readonly telemetryToMsChange = output<number>();
|
readonly telemetryToMsChange = output<number>();
|
||||||
|
|
||||||
protected readonly selectedStreamType = signal<string | null>(null);
|
protected readonly selectedStreamType = signal<string | null>(null);
|
||||||
@@ -117,6 +119,14 @@ export class SessionViewTabComponent {
|
|||||||
return events.filter((e) => this.telemetryEventTypeKey(e) === typeKey).length;
|
return events.filter((e) => this.telemetryEventTypeKey(e) === typeKey).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected visibleTelemetryEvents(): ParsedEvent[] {
|
||||||
|
const events = this.telemetryState().telemetry;
|
||||||
|
if (!this.excludeMouseMoves()) {
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
return events.filter((e) => !isMouseMoveTelemetryEvent(e));
|
||||||
|
}
|
||||||
|
|
||||||
protected filteredTelemetryEvents(events: ParsedEvent[]): ParsedEvent[] {
|
protected filteredTelemetryEvents(events: ParsedEvent[]): ParsedEvent[] {
|
||||||
const filter = this.telemetryEventTypeFilter();
|
const filter = this.telemetryEventTypeFilter();
|
||||||
if (filter === null) {
|
if (filter === null) {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
<div class="telemetry-head">
|
<div class="telemetry-head">
|
||||||
<div class="telemetry-head__main">
|
<div class="telemetry-head__main">
|
||||||
<h3 class="section-title">
|
<h3 class="section-title">
|
||||||
События телеметрии ({{ filteredTelemetryEvents(telemetryState().telemetry).length }})
|
События телеметрии ({{ filteredTelemetryEvents(visibleTelemetryEvents()).length }})
|
||||||
</h3>
|
</h3>
|
||||||
<p class="telemetry-range muted small">{{ telemetryRangeLabel(telemetryState().toMs) }}</p>
|
<p class="telemetry-range muted small">{{ telemetryRangeLabel(telemetryState().toMs) }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,6 +125,8 @@
|
|||||||
} @else {
|
} @else {
|
||||||
@if (telemetryState().telemetry.length === 0) {
|
@if (telemetryState().telemetry.length === 0) {
|
||||||
<p class="muted">Событий пока нет.</p>
|
<p class="muted">Событий пока нет.</p>
|
||||||
|
} @else if (visibleTelemetryEvents().length === 0) {
|
||||||
|
<p class="muted">После исключения перемещений мыши событий нет.</p>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="telemetry-type-tabs stream-tabs" aria-label="Фильтр по типу события">
|
<div class="telemetry-type-tabs stream-tabs" aria-label="Фильтр по типу события">
|
||||||
<button
|
<button
|
||||||
@@ -135,9 +137,9 @@
|
|||||||
[class.stream-active]="telemetryEventTypeFilter() === null"
|
[class.stream-active]="telemetryEventTypeFilter() === null"
|
||||||
(click)="pickTelemetryEventTypeFilter(null)"
|
(click)="pickTelemetryEventTypeFilter(null)"
|
||||||
>
|
>
|
||||||
Все ({{ telemetryState().telemetry.length }})
|
Все ({{ visibleTelemetryEvents().length }})
|
||||||
</button>
|
</button>
|
||||||
@for (t of uniqueTelemetryEventTypes(telemetryState().telemetry); track t) {
|
@for (t of uniqueTelemetryEventTypes(visibleTelemetryEvents()); track t) {
|
||||||
<button
|
<button
|
||||||
tuiButton
|
tuiButton
|
||||||
type="button"
|
type="button"
|
||||||
@@ -146,11 +148,11 @@
|
|||||||
[class.stream-active]="telemetryEventTypeFilter() === t"
|
[class.stream-active]="telemetryEventTypeFilter() === t"
|
||||||
(click)="pickTelemetryEventTypeFilter(t)"
|
(click)="pickTelemetryEventTypeFilter(t)"
|
||||||
>
|
>
|
||||||
{{ t === '' ? 'Без типа' : (t | telemetryEventType) }} ({{ telemetryEventsOfType(telemetryState().telemetry, t) }})
|
{{ t === '' ? 'Без типа' : (t | telemetryEventType) }} ({{ telemetryEventsOfType(visibleTelemetryEvents(), t) }})
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (filteredTelemetryEvents(telemetryState().telemetry).length === 0) {
|
@if (filteredTelemetryEvents(visibleTelemetryEvents()).length === 0) {
|
||||||
<p class="muted">Нет событий выбранного типа.</p>
|
<p class="muted">Нет событий выбранного типа.</p>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
@@ -164,7 +166,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (
|
@for (
|
||||||
row of filteredTelemetryEvents(telemetryState().telemetry);
|
row of filteredTelemetryEvents(visibleTelemetryEvents());
|
||||||
track $index;
|
track $index;
|
||||||
let i = $index
|
let i = $index
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ export const environment = {
|
|||||||
production: true,
|
production: true,
|
||||||
apiFallbackOrigin: 'https://sparkguardian.ru',
|
apiFallbackOrigin: 'https://sparkguardian.ru',
|
||||||
apiBasePath: '/api/v1',
|
apiBasePath: '/api/v1',
|
||||||
|
interactivePrerollMs: 4000,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ export const environment = {
|
|||||||
production: false,
|
production: false,
|
||||||
apiFallbackOrigin: "https://sparkguardian.ru",
|
apiFallbackOrigin: "https://sparkguardian.ru",
|
||||||
apiBasePath: "/api/v1",
|
apiBasePath: "/api/v1",
|
||||||
|
interactivePrerollMs: 4000,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -4,8 +4,10 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>GUARD</title>
|
<title>GUARD</title>
|
||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<link rel="icon" type="image/png" sizes="32x32" href="favicon.png">
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
<link rel="shortcut icon" href="favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
|
|||||||
@@ -68,7 +68,8 @@
|
|||||||
--sg-session-status-unknown-fg: var(--sg-color-text);
|
--sg-session-status-unknown-fg: var(--sg-color-text);
|
||||||
--sg-session-status-unknown-border: var(--sg-color-border);
|
--sg-session-status-unknown-border: var(--sg-color-border);
|
||||||
|
|
||||||
/* Встроенная SVG-клавиатура (public/svg/visual/keyboard.svg) */
|
/* Клавиатура (SVG + HTML-компонент) */
|
||||||
|
--sg-keyboard-body: color-mix(in srgb, var(--sg-color-form-bg) 80%, var(--sg-color-border));
|
||||||
--sg-keyboard-font-family: 'Tinkoff Sans', system-ui, sans-serif;
|
--sg-keyboard-font-family: 'Tinkoff Sans', system-ui, sans-serif;
|
||||||
--sg-keyboard-font-weight: 400;
|
--sg-keyboard-font-weight: 400;
|
||||||
--sg-keyboard-letter-spacing: 0.03em;
|
--sg-keyboard-letter-spacing: 0.03em;
|
||||||
|
|||||||
Reference in New Issue
Block a user