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:// или вне основного хоста
|
||||
SG_API_FALLBACK_ORIGIN=https://sparkguardian.ru
|
||||
|
||||
# Преролл видео в интерактивном режиме (мс): сколько видео записано до старта телеметрии.
|
||||
SG_INTERACTIVE_PREROLL_MS=4000
|
||||
|
||||
# Только dev-сервер (`ng serve`): куда проксировать `/api/**`
|
||||
SG_DEV_PROXY_TARGET=http://sparkguardian.ru:8080
|
||||
|
||||
160
README.md
160
README.md
@@ -1,59 +1,149 @@
|
||||
# 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
|
||||
ng generate --help
|
||||
```
|
||||
Стили опираются на дизайн-токены (CSS-переменные); для компонентов по возможности используются примитивы Taiga UI.
|
||||
|
||||
## 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
|
||||
ng build
|
||||
```
|
||||
Подробная схема REST описана в `docs/doc_v1.json` (OpenAPI).
|
||||
|
||||
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
|
||||
ng test
|
||||
```
|
||||
1. Скопируйте `.env.example` в `.env` и при необходимости измените переменные (`SG_API_BASE_PATH`, `SG_API_FALLBACK_ORIGIN`, `SG_INTERACTIVE_PREROLL_MS`, для dev — `SG_DEV_PROXY_TARGET`).
|
||||
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
|
||||
ng e2e
|
||||
```
|
||||
После `npm start` приложение доступно по адресу, который выводит Angular CLI (по умолчанию `http://localhost:4200/`).
|
||||
|
||||
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-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 {
|
||||
font-size: 17px;
|
||||
font-weight: var(--sg-keyboard-font-weight);
|
||||
@@ -127,6 +134,13 @@
|
||||
text-align: center;
|
||||
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 {
|
||||
font-size: 11px;
|
||||
font-weight: var(--sg-keyboard-font-weight);
|
||||
@@ -308,6 +322,36 @@
|
||||
<text id="T_kb5g2" x="348" y="172" >B</text>
|
||||
<text id="T_kb5h2" x="402" y="172" >N</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 id="T_stdspecial">
|
||||
@@ -363,6 +407,32 @@
|
||||
<text id="T_kb5j2" x="508" y="170" ><</text>
|
||||
<text id="T_kb5k2" x="562" 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 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 = {
|
||||
SG_API_FALLBACK_ORIGIN: 'https://sparkguardian.ru',
|
||||
SG_API_BASE_PATH: '/api/v1',
|
||||
SG_INTERACTIVE_PREROLL_MS: '4000',
|
||||
};
|
||||
|
||||
function val(key) {
|
||||
@@ -20,11 +21,20 @@ function val(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
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiFallbackOrigin: ${JSON.stringify(val('SG_API_FALLBACK_ORIGIN'))},
|
||||
apiBasePath: ${JSON.stringify(val('SG_API_BASE_PATH'))},
|
||||
interactivePrerollMs: ${JSON.stringify(intVal('SG_INTERACTIVE_PREROLL_MS'))},
|
||||
} as const;
|
||||
`;
|
||||
|
||||
|
||||
@@ -15,14 +15,19 @@ export interface DevHttpLogDetails {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DevTelemetryLogDetails {
|
||||
rawEventJson: string;
|
||||
}
|
||||
|
||||
export interface DevLogEntry {
|
||||
id: number;
|
||||
time: string;
|
||||
level: DevLogLevel;
|
||||
source: 'http' | 'system';
|
||||
source: 'http' | 'system' | 'telemetry';
|
||||
message: string;
|
||||
status?: DevLogStatus;
|
||||
details?: DevHttpLogDetails;
|
||||
telemetryDetails?: DevTelemetryLogDetails;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -49,4 +54,8 @@ export class DevLogService {
|
||||
clear(): void {
|
||||
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'],
|
||||
caps: ['K_kb4a'],
|
||||
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]) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ParsedEvent } from '../models/api.types';
|
||||
import { unwrapJsonPayload } from '../../shared/utils/json.util';
|
||||
|
||||
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 {
|
||||
const t = (event.event_type ?? '').toLowerCase();
|
||||
@@ -44,6 +44,43 @@ export function parseKeyboardVirtualKey(data: unknown): number | 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 {
|
||||
try {
|
||||
return JSON.stringify(data, null, 2);
|
||||
@@ -55,16 +92,20 @@ export function eventPayloadJson(data: unknown): string {
|
||||
export function parseKeyboardAction(data: unknown): 'press' | 'release' | null {
|
||||
const raw = unwrapJsonPayload(data);
|
||||
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') {
|
||||
const normalized = action.toLowerCase();
|
||||
if (normalized === 'press') {
|
||||
if (normalized === 'press' || normalized === 'down' || normalized === 'key_down') {
|
||||
return 'press';
|
||||
}
|
||||
if (normalized === 'release') {
|
||||
if (normalized === 'release' || normalized === 'up' || normalized === 'key_up') {
|
||||
return 'release';
|
||||
}
|
||||
}
|
||||
if (typeof o['is_down'] === 'boolean') {
|
||||
return o['is_down'] ? 'press' : 'release';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -82,7 +123,8 @@ export function parseKeyboardHighlightKeyIds(data: unknown): string[] {
|
||||
}
|
||||
const vk = parseKeyboardVirtualKey(raw);
|
||||
if (vk != null) {
|
||||
const id = vkToKeyboardSvgKeyId(normalizeVirtualKey(vk));
|
||||
const scheme = parseKeyboardVkScheme(raw);
|
||||
const id = vkToKeyboardSvgKeyId(vk, scheme);
|
||||
return id ? [id] : [];
|
||||
}
|
||||
return [];
|
||||
|
||||
@@ -4,35 +4,154 @@ import { Observable, defer, from, shareReplay } from 'rxjs';
|
||||
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/...`.
|
||||
*/
|
||||
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' })
|
||||
export class KeyboardSvgHighlightService {
|
||||
private readonly sanitizer = inject(DomSanitizer);
|
||||
|
||||
private readonly baseSvg$ = defer(() =>
|
||||
from(
|
||||
fetch(KEYBOARD_SVG_PATH).then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error(`Не удалось загрузить клавиатуру: ${r.status} ${r.statusText}`);
|
||||
}
|
||||
return r.text();
|
||||
}),
|
||||
),
|
||||
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
|
||||
private readonly baseSvgCache = new Map<string, Observable<string>>();
|
||||
|
||||
svgWithHighlight(keyIds: string[] | null, animated = true): Observable<SafeHtml> {
|
||||
return this.baseSvg$.pipe(
|
||||
private baseSvg$(path: string): Observable<string> {
|
||||
let cached = this.baseSvgCache.get(path);
|
||||
if (!cached) {
|
||||
cached = defer(() =>
|
||||
from(
|
||||
fetch(path).then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error(`Не удалось загрузить SVG: ${path} (${r.status} ${r.statusText})`);
|
||||
}
|
||||
return r.text();
|
||||
}),
|
||||
),
|
||||
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
|
||||
this.baseSvgCache.set(path, cached);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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((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 {
|
||||
const valid = [...new Set(keyIds.filter((id) => /^K_kb[0-9a-z]+$/i.test(id)))];
|
||||
const valid = this.validIds(keyIds);
|
||||
if (valid.length === 0) {
|
||||
return svgText;
|
||||
}
|
||||
@@ -68,6 +187,25 @@ export class KeyboardSvgHighlightService {
|
||||
rules.push(`@keyframes sgInputHighlightFade{0%{opacity:.45;}100%{opacity:1;}}`);
|
||||
}
|
||||
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 {
|
||||
if (vk >= 0x61 && vk <= 0x7a) {
|
||||
return vk - 0x20;
|
||||
@@ -28,7 +32,7 @@ const LETTER_TO_ID: Record<string, string> = {
|
||||
I: 'K_kb3i',
|
||||
J: 'K_kb4i',
|
||||
K: 'K_kb4j',
|
||||
L: 'K_kb4l',
|
||||
L: 'K_kb4k',
|
||||
M: 'K_kb5i',
|
||||
N: 'K_kb5h',
|
||||
O: 'K_kb3j',
|
||||
@@ -45,10 +49,76 @@ const LETTER_TO_ID: Record<string, string> = {
|
||||
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> = {
|
||||
0x08: 'K_kb2n',
|
||||
0x09: 'K_kb3a',
|
||||
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',
|
||||
0x11: 'K_kb6a',
|
||||
0x12: 'K_kb6c',
|
||||
@@ -76,7 +146,7 @@ const EXTRA_VK: Record<number, string> = {
|
||||
0xde: 'K_kb4m',
|
||||
};
|
||||
|
||||
export function vkToKeyboardSvgKeyId(vk: number): string | null {
|
||||
function windowsVkToKeyboardSvgKeyId(vk: number): string | null {
|
||||
const k = normalizeVirtualKey(vk);
|
||||
const fromExtra = EXTRA_VK[k];
|
||||
if (fromExtra) {
|
||||
@@ -93,6 +163,16 @@ export function vkToKeyboardSvgKeyId(vk: number): string | 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 {
|
||||
const c = name.trim();
|
||||
if (c.length !== 1) {
|
||||
@@ -105,5 +185,12 @@ export function charKeyNameToSvgKeyId(name: string): string | null {
|
||||
if (ch >= 'A' && ch <= 'Z') {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,37 @@ export function isMouseTelemetryEvent(event: ParsedEvent): boolean {
|
||||
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[] {
|
||||
const raw = unwrapJsonPayload(data);
|
||||
if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
@@ -32,6 +63,17 @@ export function parseMouseHighlightTargets(data: unknown): MouseHighlightTarget[
|
||||
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 isDown = o['is_down'];
|
||||
if (button == null) {
|
||||
@@ -40,14 +82,19 @@ export function parseMouseHighlightTargets(data: unknown): MouseHighlightTarget[
|
||||
if (isDown === false) {
|
||||
return [];
|
||||
}
|
||||
if (button === 0) {
|
||||
return ['left'];
|
||||
}
|
||||
// Primary scheme in backend telemetry is 1-based: 1=left, 2=right, 3=middle.
|
||||
if (button === 1) {
|
||||
return ['middle'];
|
||||
return ['left'];
|
||||
}
|
||||
if (button === 2) {
|
||||
return ['right'];
|
||||
}
|
||||
if (button === 3) {
|
||||
return ['middle'];
|
||||
}
|
||||
// Compatibility fallback for legacy/DOM-style 0-based payloads.
|
||||
if (button === 0) {
|
||||
return ['left'];
|
||||
}
|
||||
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>`;
|
||||
normalized = normalized.replace(/<svg\b[^>]*>/i, (open) => `${open}${defsAndStyles}`);
|
||||
normalized = normalized.replace(/<\/svg>/i, `${defsAndStyles}</svg>`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isMouseMovePayload } from '../mouse/mouse-payload.util';
|
||||
import {
|
||||
formatTelemetryKeyboardKeySummary,
|
||||
formatTelemetryMouseClickSummary,
|
||||
@@ -24,8 +25,7 @@ export const mouseClickRule: TelemetrySummaryRule = {
|
||||
|
||||
export const mouseMoveRule: TelemetrySummaryRule = {
|
||||
id: 'mouse-move',
|
||||
match: (o) =>
|
||||
o['action'] === 'move' && readNumericField(o, 'x') !== null && readNumericField(o, 'y') !== null,
|
||||
match: (o) => isMouseMovePayload(o),
|
||||
summarize: (o) =>
|
||||
formatTelemetryMouseMoveSummary(
|
||||
readNumericField(o, 'x')!,
|
||||
|
||||
@@ -26,6 +26,25 @@
|
||||
<div class="dev-console__main">
|
||||
<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) {
|
||||
<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 { 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 { 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 { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||
import { TuiTitle } from '@taiga-ui/core/components/title';
|
||||
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 type { ParsedEvent } from '../../../core/models/api.types';
|
||||
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',
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
TuiCheckbox,
|
||||
TuiLink,
|
||||
TuiLoader,
|
||||
TuiTitle,
|
||||
@@ -37,10 +53,12 @@ export class SessionDetailComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly api = inject(SessionsApiService);
|
||||
private readonly userErrors = inject(UserErrorNotifyService);
|
||||
private readonly devLog = inject(DevLogService);
|
||||
|
||||
protected readonly telemetryToMs = signal<number | null>(null);
|
||||
protected readonly recordingStartMs = signal<number | null>(null);
|
||||
protected readonly recordingEndMs = signal<number | null>(null);
|
||||
protected readonly excludeMouseMoves = model(false);
|
||||
protected readonly activeTabIndex = model(0);
|
||||
|
||||
private readonly sessionId$ = this.route.paramMap.pipe(
|
||||
@@ -110,6 +128,25 @@ export class SessionDetailComponent {
|
||||
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) => {
|
||||
this.userErrors.notifyError(e, 'Телеметрия');
|
||||
return of({
|
||||
|
||||
@@ -6,6 +6,26 @@
|
||||
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. */
|
||||
.session-tabs {
|
||||
display: flex;
|
||||
|
||||
@@ -23,6 +23,20 @@
|
||||
</tui-tabs>
|
||||
|
||||
@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()) {
|
||||
@case (0) {
|
||||
<app-session-view-tab
|
||||
@@ -30,6 +44,7 @@
|
||||
[telemetryState]="telemetryState"
|
||||
[recordingStartMs]="recordingStartMs()"
|
||||
[recordingEndMs]="recordingEndMs()"
|
||||
[excludeMouseMoves]="excludeMouseMoves()"
|
||||
(telemetryToMsChange)="telemetryToMs.set($event)"
|
||||
/>
|
||||
}
|
||||
@@ -39,6 +54,7 @@
|
||||
[telemetryEvents]="telemetryState.telemetry"
|
||||
[recordingStartMs]="recordingStartMs()"
|
||||
[recordingEndMs]="recordingEndMs()"
|
||||
[excludeMouseMoves]="excludeMouseMoves()"
|
||||
/>
|
||||
}
|
||||
@case (2) {
|
||||
|
||||
@@ -1,33 +1,43 @@
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
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 { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||
import { distinctUntilChanged, switchMap } from 'rxjs';
|
||||
|
||||
import {
|
||||
isKeyboardTelemetryEvent,
|
||||
parseKeyboardAction,
|
||||
parseKeyboardHighlightKeyIds,
|
||||
} from '../../../../core/keyboard/keyboard-payload.util';
|
||||
import { KeyboardSvgHighlightService } from '../../../../core/keyboard/keyboard-svg-highlight.service';
|
||||
import { isMouseTelemetryEvent, parseMouseHighlightTargets } from '../../../../core/mouse/mouse-payload.util';
|
||||
import {
|
||||
isMouseMoveTelemetryEvent,
|
||||
isMouseTelemetryEvent,
|
||||
parseMouseHighlightTargets,
|
||||
} 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 type { ParsedEvent, SessionDetailResponse } from '../../../../core/models/api.types';
|
||||
import { formatClockTime, formatUnixMs } from '../../../../shared/utils/date-time.util';
|
||||
import { HlsPlayerComponent } from '../../hls-player/hls-player.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({
|
||||
selector: 'app-session-interactive-tab',
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
TuiButton,
|
||||
TuiLoader,
|
||||
HlsPlayerComponent,
|
||||
StreamSelectorComponent,
|
||||
KeyboardViewComponent,
|
||||
MouseViewComponent,
|
||||
],
|
||||
templateUrl: './session-interactive-tab.html',
|
||||
styleUrl: './session-interactive-tab.css',
|
||||
@@ -35,13 +45,12 @@ import { StreamSelectorComponent } from '../../stream-selector/stream-selector.c
|
||||
})
|
||||
export class SessionInteractiveTabComponent {
|
||||
private readonly api = inject(SessionsApiService);
|
||||
private readonly keyboardSvg = inject(KeyboardSvgHighlightService);
|
||||
private readonly mouseSvg = inject(MouseSvgHighlightService);
|
||||
|
||||
readonly detail = input.required<SessionDetailResponse>();
|
||||
readonly telemetryEvents = input.required<ParsedEvent[]>();
|
||||
readonly recordingStartMs = input.required<number | null>();
|
||||
readonly recordingEndMs = input.required<number | null>();
|
||||
readonly excludeMouseMoves = input<boolean>(false);
|
||||
|
||||
protected readonly selectedStreamType = signal<string | null>(null);
|
||||
protected readonly timelineSec = signal(0);
|
||||
@@ -61,12 +70,35 @@ export class SessionInteractiveTabComponent {
|
||||
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(() => {
|
||||
const start = this.recordingStartMs();
|
||||
const start = this.telemetryAnchorStartMs();
|
||||
if (start == 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(() =>
|
||||
this.telemetryEvents()
|
||||
.filter(isMouseTelemetryEvent)
|
||||
.filter((e) => {
|
||||
if (!isMouseTelemetryEvent(e)) {
|
||||
return false;
|
||||
}
|
||||
return !this.excludeMouseMoves() || !isMouseMoveTelemetryEvent(e);
|
||||
})
|
||||
.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();
|
||||
if (cursorMs == null) {
|
||||
return [];
|
||||
return new Set();
|
||||
}
|
||||
const active = new Set<string>();
|
||||
// Events are sorted; break early once we exceed cursorMs.
|
||||
for (const event of this.sortedKeyboardEvents()) {
|
||||
const seen = new Set<string>();
|
||||
const events = this.sortedKeyboardEvents();
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const event = events[i]!;
|
||||
if (event.timestamp > cursorMs) {
|
||||
break;
|
||||
}
|
||||
const keyIds = parseKeyboardHighlightKeyIds(event.data);
|
||||
if (keyIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const action = parseKeyboardAction(event.data);
|
||||
if (action === 'release') {
|
||||
for (const keyId of keyIds) {
|
||||
active.delete(keyId);
|
||||
}
|
||||
} else {
|
||||
for (const keyId of keyIds) {
|
||||
active.add(keyId);
|
||||
}
|
||||
if (event.timestamp < cursorMs - KEY_DISPLAY_MS) {
|
||||
break;
|
||||
}
|
||||
if (parseKeyboardAction(event.data) !== 'press') {
|
||||
continue;
|
||||
}
|
||||
for (const keyId of parseKeyboardHighlightKeyIds(event.data)) {
|
||||
seen.add(keyId);
|
||||
}
|
||||
}
|
||||
return [...active];
|
||||
return seen;
|
||||
});
|
||||
|
||||
private readonly mouseTargets = computed(() => {
|
||||
protected readonly mouseTargets = computed(() => {
|
||||
const cursorMs = this.cursorMs();
|
||||
if (cursorMs == null) {
|
||||
return [];
|
||||
@@ -134,21 +171,6 @@ export class SessionInteractiveTabComponent {
|
||||
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 {
|
||||
const streams = this.detail().streams;
|
||||
if (!streams?.length) {
|
||||
@@ -245,21 +267,3 @@ export class SessionInteractiveTabComponent {
|
||||
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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
gap: 2.75rem;
|
||||
gap: 2rem;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.mouse-svg-host {
|
||||
width: 144px;
|
||||
flex: 0 0 144px;
|
||||
border-radius: var(--tui-radius-m);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mouse-svg-host ::ng-deep svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.mouse-loading {
|
||||
width: 144px;
|
||||
flex: 0 0 144px;
|
||||
.mouse-sidebar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.input-preview {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mouse-svg-host,
|
||||
.mouse-loading {
|
||||
width: min(220px, 100%);
|
||||
flex-basis: auto;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,19 +133,9 @@
|
||||
|
||||
<section class="card" aria-label="Интерактивный режим: клавиатура">
|
||||
<div class="input-preview">
|
||||
@if (keyboardSvg$ | async; as keyboardSvg) {
|
||||
<div class="keyboard-svg-host keyboard-svg-host_compact" [innerHTML]="keyboardSvg"></div>
|
||||
} @else {
|
||||
<div class="keyboard-loading">
|
||||
<tui-loader [loading]="true" size="m" />
|
||||
</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>
|
||||
}
|
||||
<app-keyboard-view [pressedIds]="pressedKeyIds()" />
|
||||
<div class="mouse-sidebar">
|
||||
<app-mouse-view [targets]="mouseTargets()" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TuiButton } from '@taiga-ui/core/components/button';
|
||||
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||
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 { SessionStatusPipe } from '../../../../core/sessions/session-status.pipe';
|
||||
import { TelemetryEventTypePipe } from '../../../../core/sessions/telemetry-event-type.pipe';
|
||||
@@ -59,6 +60,7 @@ export class SessionViewTabComponent {
|
||||
readonly telemetryState = input.required<TelemetryLoadState>();
|
||||
readonly recordingStartMs = input.required<number | null>();
|
||||
readonly recordingEndMs = input.required<number | null>();
|
||||
readonly excludeMouseMoves = input<boolean>(false);
|
||||
readonly telemetryToMsChange = output<number>();
|
||||
|
||||
protected readonly selectedStreamType = signal<string | null>(null);
|
||||
@@ -117,6 +119,14 @@ export class SessionViewTabComponent {
|
||||
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[] {
|
||||
const filter = this.telemetryEventTypeFilter();
|
||||
if (filter === null) {
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<div class="telemetry-head">
|
||||
<div class="telemetry-head__main">
|
||||
<h3 class="section-title">
|
||||
События телеметрии ({{ filteredTelemetryEvents(telemetryState().telemetry).length }})
|
||||
События телеметрии ({{ filteredTelemetryEvents(visibleTelemetryEvents()).length }})
|
||||
</h3>
|
||||
<p class="telemetry-range muted small">{{ telemetryRangeLabel(telemetryState().toMs) }}</p>
|
||||
</div>
|
||||
@@ -125,6 +125,8 @@
|
||||
} @else {
|
||||
@if (telemetryState().telemetry.length === 0) {
|
||||
<p class="muted">Событий пока нет.</p>
|
||||
} @else if (visibleTelemetryEvents().length === 0) {
|
||||
<p class="muted">После исключения перемещений мыши событий нет.</p>
|
||||
} @else {
|
||||
<div class="telemetry-type-tabs stream-tabs" aria-label="Фильтр по типу события">
|
||||
<button
|
||||
@@ -135,9 +137,9 @@
|
||||
[class.stream-active]="telemetryEventTypeFilter() === null"
|
||||
(click)="pickTelemetryEventTypeFilter(null)"
|
||||
>
|
||||
Все ({{ telemetryState().telemetry.length }})
|
||||
Все ({{ visibleTelemetryEvents().length }})
|
||||
</button>
|
||||
@for (t of uniqueTelemetryEventTypes(telemetryState().telemetry); track t) {
|
||||
@for (t of uniqueTelemetryEventTypes(visibleTelemetryEvents()); track t) {
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
@@ -146,11 +148,11 @@
|
||||
[class.stream-active]="telemetryEventTypeFilter() === t"
|
||||
(click)="pickTelemetryEventTypeFilter(t)"
|
||||
>
|
||||
{{ t === '' ? 'Без типа' : (t | telemetryEventType) }} ({{ telemetryEventsOfType(telemetryState().telemetry, t) }})
|
||||
{{ t === '' ? 'Без типа' : (t | telemetryEventType) }} ({{ telemetryEventsOfType(visibleTelemetryEvents(), t) }})
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (filteredTelemetryEvents(telemetryState().telemetry).length === 0) {
|
||||
@if (filteredTelemetryEvents(visibleTelemetryEvents()).length === 0) {
|
||||
<p class="muted">Нет событий выбранного типа.</p>
|
||||
} @else {
|
||||
<div class="table-wrap">
|
||||
@@ -164,7 +166,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (
|
||||
row of filteredTelemetryEvents(telemetryState().telemetry);
|
||||
row of filteredTelemetryEvents(visibleTelemetryEvents());
|
||||
track $index;
|
||||
let i = $index
|
||||
) {
|
||||
|
||||
@@ -2,4 +2,5 @@ export const environment = {
|
||||
production: true,
|
||||
apiFallbackOrigin: 'https://sparkguardian.ru',
|
||||
apiBasePath: '/api/v1',
|
||||
interactivePrerollMs: 4000,
|
||||
} as const;
|
||||
|
||||
@@ -3,4 +3,5 @@ export const environment = {
|
||||
production: false,
|
||||
apiFallbackOrigin: "https://sparkguardian.ru",
|
||||
apiBasePath: "/api/v1",
|
||||
interactivePrerollMs: 4000,
|
||||
} as const;
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
<meta charset="utf-8">
|
||||
<title>GUARD</title>
|
||||
<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="shortcut icon" href="favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
|
||||
@@ -68,7 +68,8 @@
|
||||
--sg-session-status-unknown-fg: var(--sg-color-text);
|
||||
--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-weight: 400;
|
||||
--sg-keyboard-letter-spacing: 0.03em;
|
||||
|
||||
Reference in New Issue
Block a user