From 84586b5ce2a23329a81756360effc0803f46b951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D0=BA=D0=B0=D1=8D=D0=BB=20=D0=9E=D0=B3=D0=B0?= =?UTF-8?q?=D0=BD=D0=B5=D1=81=D1=8F=D0=BD?= Date: Fri, 10 Apr 2026 02:54:32 +0300 Subject: [PATCH] refactor keyboard and mouse code: SVG -> div components & logical refactoring. Add some new DEV-features --- .env.example | 3 + README.md | 160 +++++++++++++---- public/favicon.ico | Bin 252990 -> 900 bytes public/favicon.png | Bin 0 -> 878 bytes public/svg/visual/arrow-keys.svg | 45 +++++ public/svg/visual/keyboard.svg | 70 ++++++++ scripts/sync-env.cjs | 10 ++ src/app/core/devtools/dev-log.service.ts | 11 +- .../core/keyboard/keyboard-key-name-map.ts | 38 ++++ .../core/keyboard/keyboard-payload.util.ts | 52 +++++- .../keyboard-svg-highlight.service.ts | 170 ++++++++++++++++-- .../keyboard/macos-vk-to-keyboard-svg-id.ts | 88 +++++++++ .../core/keyboard/vk-to-keyboard-svg-id.ts | 91 +++++++++- src/app/core/mouse/mouse-payload.util.ts | 55 +++++- .../core/mouse/mouse-svg-highlight.service.ts | 2 +- .../telemetry-event-summary.handlers.ts | 4 +- .../devtools/dev-console/dev-console.html | 19 ++ .../keyboard-view/keyboard-view.component.ts | 110 ++++++++++++ .../sessions/keyboard-view/keyboard-view.css | 129 +++++++++++++ .../sessions/keyboard-view/keyboard-view.html | 36 ++++ .../mouse-view/mouse-view.component.ts | 20 +++ .../sessions/mouse-view/mouse-view.css | 71 ++++++++ .../sessions/mouse-view/mouse-view.html | 14 ++ .../session-detail.component.ts | 41 ++++- .../session-detail/session-detail.css | 20 +++ .../session-detail/session-detail.html | 16 ++ .../session-interactive-tab.component.ts | 138 +++++++------- .../session-interactive-tab.css | 56 +----- .../session-interactive-tab.html | 18 +- .../session-view-tab.component.ts | 10 ++ .../session-view-tab/session-view-tab.html | 14 +- src/environments/environment.prod.ts | 1 + src/environments/environment.ts | 1 + src/index.html | 4 +- src/styles/color-tokens.css | 3 +- 35 files changed, 1315 insertions(+), 205 deletions(-) create mode 100644 public/favicon.png create mode 100644 public/svg/visual/arrow-keys.svg create mode 100644 src/app/core/keyboard/macos-vk-to-keyboard-svg-id.ts create mode 100644 src/app/features/sessions/keyboard-view/keyboard-view.component.ts create mode 100644 src/app/features/sessions/keyboard-view/keyboard-view.css create mode 100644 src/app/features/sessions/keyboard-view/keyboard-view.html create mode 100644 src/app/features/sessions/mouse-view/mouse-view.component.ts create mode 100644 src/app/features/sessions/mouse-view/mouse-view.css create mode 100644 src/app/features/sessions/mouse-view/mouse-view.html diff --git a/.env.example b/.env.example index 2b99525..f1eee80 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 6116f17..b26bff9 100644 --- a/README.md +++ b/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`. diff --git a/public/favicon.ico b/public/favicon.ico index 2c6933eff773381666154f4a79ce0591a6db9132..266779afd5fc5eb691388f566600b13f773688c7 100644 GIT binary patch literal 900 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 z%0A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 z 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 Ef(T@W*Z=?k literal 0 HcmV?d00001 diff --git a/public/svg/visual/arrow-keys.svg b/public/svg/visual/arrow-keys.svg new file mode 100644 index 0000000..13792e9 --- /dev/null +++ b/public/svg/visual/arrow-keys.svg @@ -0,0 +1,45 @@ + + + diff --git a/public/svg/visual/keyboard.svg b/public/svg/visual/keyboard.svg index 1ed81f9..00a97b7 100644 --- a/public/svg/visual/keyboard.svg +++ b/public/svg/visual/keyboard.svg @@ -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 @@ B N M + + + й + ц + у + к + е + н + г + ш + щ + з + + ф + ы + в + а + п + р + о + л + д + + я + ч + с + м + и + т + ь @@ -363,6 +407,32 @@ < > ? + + + ё + ! + " + + ; + % + : + ? + * + ( + ) + - + + + + х + ъ + / + + ж + э + + б + ю + . diff --git a/scripts/sync-env.cjs b/scripts/sync-env.cjs index c0da5a2..658ae25 100644 --- a/scripts/sync-env.cjs +++ b/scripts/sync-env.cjs @@ -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; `; diff --git a/src/app/core/devtools/dev-log.service.ts b/src/app/core/devtools/dev-log.service.ts index 9642894..40b541e 100644 --- a/src/app/core/devtools/dev-log.service.ts +++ b/src/app/core/devtools/dev-log.service.ts @@ -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)); + } } diff --git a/src/app/core/keyboard/keyboard-key-name-map.ts b/src/app/core/keyboard/keyboard-key-name-map.ts index 2947923..27ed405 100644 --- a/src/app/core/keyboard/keyboard-key-name-map.ts +++ b/src/app/core/keyboard/keyboard-key-name-map.ts @@ -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]) { diff --git a/src/app/core/keyboard/keyboard-payload.util.ts b/src/app/core/keyboard/keyboard-payload.util.ts index b9e0a7d..f3cfc0c 100644 --- a/src/app/core/keyboard/keyboard-payload.util.ts +++ b/src/app/core/keyboard/keyboard-payload.util.ts @@ -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; + 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)['action']; + const o = raw as Record; + 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 []; diff --git a/src/app/core/keyboard/keyboard-svg-highlight.service.ts b/src/app/core/keyboard/keyboard-svg-highlight.service.ts index d72594c..fc89a15 100644 --- a/src/app/core/keyboard/keyboard-svg-highlight.service.ts +++ b/src/app/core/keyboard/keyboard-svg-highlight.service.ts @@ -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>(); - svgWithHighlight(keyIds: string[] | null, animated = true): Observable { - return this.baseSvg$.pipe( + private baseSvg$(path: string): Observable { + 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 { + 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 { + 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 { + 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 = ``; + return svgText.replace(/<\/svg>/i, `${styleBlock}`); + } + + 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 = ``; - return svgText.replace(/]*>/i, (open) => `${open}${styleBlock}`); + return svgText.replace(/<\/svg>/i, `${styleBlock}`); + } + + 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 = ``; + return svgText.replace(/<\/svg>/i, `${styleBlock}`); } } diff --git a/src/app/core/keyboard/macos-vk-to-keyboard-svg-id.ts b/src/app/core/keyboard/macos-vk-to-keyboard-svg-id.ts new file mode 100644 index 0000000..fcf2de3 --- /dev/null +++ b/src/app/core/keyboard/macos-vk-to-keyboard-svg-id.ts @@ -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 = { + // 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; +} diff --git a/src/app/core/keyboard/vk-to-keyboard-svg-id.ts b/src/app/core/keyboard/vk-to-keyboard-svg-id.ts index c3feb4c..b76d130 100644 --- a/src/app/core/keyboard/vk-to-keyboard-svg-id.ts +++ b/src/app/core/keyboard/vk-to-keyboard-svg-id.ts @@ -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 = { 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 = { Z: 'K_kb5c', }; +/** Символы пунктуации и shifted-варианты цифр → физическая клавиша SVG. */ +const PUNCT_CHAR_TO_ID: Record = { + // Ряд цифр (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 = { + ё: '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 = { 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 = { 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; } diff --git a/src/app/core/mouse/mouse-payload.util.ts b/src/app/core/mouse/mouse-payload.util.ts index 88fdbbe..281f571 100644 --- a/src/app/core/mouse/mouse-payload.util.ts +++ b/src/app/core/mouse/mouse-payload.util.ts @@ -20,6 +20,37 @@ export function isMouseTelemetryEvent(event: ParsedEvent): boolean { return t.includes('mouse'); } +/** + * Payload распознаётся как перемещение курсора (координаты + action «move», без учёта регистра). + * Сводка телеметрии и фильтр «Исключить перемещения мыши» должны опираться на одну логику. + */ +export function isMouseMovePayload(o: Record): 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; + 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 []; } diff --git a/src/app/core/mouse/mouse-svg-highlight.service.ts b/src/app/core/mouse/mouse-svg-highlight.service.ts index f0e6b9a..b20784b 100644 --- a/src/app/core/mouse/mouse-svg-highlight.service.ts +++ b/src/app/core/mouse/mouse-svg-highlight.service.ts @@ -61,7 +61,7 @@ export class MouseSvgHighlightService { : ''; const defsAndStyles = ``; - normalized = normalized.replace(/]*>/i, (open) => `${open}${defsAndStyles}`); + normalized = normalized.replace(/<\/svg>/i, `${defsAndStyles}`); return normalized; } diff --git a/src/app/core/sessions/telemetry-event-summary.handlers.ts b/src/app/core/sessions/telemetry-event-summary.handlers.ts index b7b5927..3133835 100644 --- a/src/app/core/sessions/telemetry-event-summary.handlers.ts +++ b/src/app/core/sessions/telemetry-event-summary.handlers.ts @@ -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')!, diff --git a/src/app/features/devtools/dev-console/dev-console.html b/src/app/features/devtools/dev-console/dev-console.html index e2b11ee..65a8473 100644 --- a/src/app/features/devtools/dev-console/dev-console.html +++ b/src/app/features/devtools/dev-console/dev-console.html @@ -26,6 +26,25 @@
{{ entry.message }} + @if (entry.source === 'telemetry' && entry.telemetryDetails) { + + + @if (isExpanded(entry.id)) { +
+
+ Событие (как пришло с API) +
{{ entry.telemetryDetails.rawEventJson }}
+
+
+ } + } + @if (entry.source === 'http' && entry.details) { - @for (t of uniqueTelemetryEventTypes(telemetryState().telemetry); track t) { + @for (t of uniqueTelemetryEventTypes(visibleTelemetryEvents()); track t) { }
- @if (filteredTelemetryEvents(telemetryState().telemetry).length === 0) { + @if (filteredTelemetryEvents(visibleTelemetryEvents()).length === 0) {

Нет событий выбранного типа.

} @else {
@@ -164,7 +166,7 @@ @for ( - row of filteredTelemetryEvents(telemetryState().telemetry); + row of filteredTelemetryEvents(visibleTelemetryEvents()); track $index; let i = $index ) { diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index 456072e..83725a4 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -2,4 +2,5 @@ export const environment = { production: true, apiFallbackOrigin: 'https://sparkguardian.ru', apiBasePath: '/api/v1', + interactivePrerollMs: 4000, } as const; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 17719a0..5474064 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -3,4 +3,5 @@ export const environment = { production: false, apiFallbackOrigin: "https://sparkguardian.ru", apiBasePath: "/api/v1", + interactivePrerollMs: 4000, } as const; diff --git a/src/index.html b/src/index.html index fba22b1..0239f34 100644 --- a/src/index.html +++ b/src/index.html @@ -4,8 +4,10 @@ GUARD - + + + diff --git a/src/styles/color-tokens.css b/src/styles/color-tokens.css index 9fcea95..8e61192 100644 --- a/src/styles/color-tokens.css +++ b/src/styles/color-tokens.css @@ -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;