refactor keyboard and mouse code: SVG -> div components & logical refactoring. Add some new DEV-features
Some checks failed
CI / checks (push) Failing after 6m56s

This commit is contained in:
Микаэл Оганесян
2026-04-10 02:54:32 +03:00
parent d96c152ae3
commit 84586b5ce2
35 changed files with 1315 additions and 205 deletions

View File

@@ -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
View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 B

View 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

View File

@@ -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" >&lt;</text>
<text id="T_kb5k2" x="562" y="170" >&gt;</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

View File

@@ -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;
`;

View File

@@ -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));
}
}

View File

@@ -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]) {

View File

@@ -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 [];

View File

@@ -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>`);
}
}

View 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;
}

View File

@@ -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;
}

View File

@@ -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 [];
}

View File

@@ -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;
}

View File

@@ -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')!,

View File

@@ -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"

View File

@@ -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;
}

View 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);
}

View 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>

View 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'),
);
}

View 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;
}

View 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>

View File

@@ -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({

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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));
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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
) {

View File

@@ -2,4 +2,5 @@ export const environment = {
production: true,
apiFallbackOrigin: 'https://sparkguardian.ru',
apiBasePath: '/api/v1',
interactivePrerollMs: 4000,
} as const;

View File

@@ -3,4 +3,5 @@ export const environment = {
production: false,
apiFallbackOrigin: "https://sparkguardian.ru",
apiBasePath: "/api/v1",
interactivePrerollMs: 4000,
} as const;

View File

@@ -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>

View File

@@ -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;