-
-
-
Hello, {{ title() }}
-
Congratulations! Your app is running. 🎉
-
-
-
-
- @for (item of [
- { title: 'Explore the Docs', link: 'https://angular.dev' },
- { title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
- { title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'},
- { title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
- { title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
- { title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
- ]; track item.title) {
-
- {{ item.title }}
-
-
- }
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+ @if (isDev) {
+
+ }
+
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index dc39edb..2c3d309 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -1,3 +1,17 @@
import { Routes } from '@angular/router';
-export const routes: Routes = [];
+export const routes: Routes = [
+ {
+ path: '',
+ loadComponent: () =>
+ import('./features/sessions/sessions-list/sessions-list.component').then((m) => m.SessionsListComponent),
+ },
+ {
+ path: 'sessions/:id',
+ loadComponent: () =>
+ import('./features/sessions/session-detail/session-detail.component').then(
+ (m) => m.SessionDetailComponent,
+ ),
+ },
+ { path: '**', redirectTo: '' },
+];
diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts
index 5de2561..5e1837f 100644
--- a/src/app/app.spec.ts
+++ b/src/app/app.spec.ts
@@ -1,10 +1,26 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
+import { appConfig } from './app.config';
describe('App', () => {
beforeEach(async () => {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ }),
+ });
+
await TestBed.configureTestingModule({
imports: [App],
+ providers: [...appConfig.providers],
}).compileComponents();
});
@@ -14,10 +30,10 @@ describe('App', () => {
expect(app).toBeTruthy();
});
- it('should render title', async () => {
+ it('should show app name in header', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
- expect(compiled.querySelector('h1')?.textContent).toContain('Hello, sparkguardian');
+ expect(compiled.querySelector('.brand')?.textContent).toContain('SparkGuardian');
});
});
diff --git a/src/app/app.ts b/src/app/app.ts
index 1188fce..91a460a 100644
--- a/src/app/app.ts
+++ b/src/app/app.ts
@@ -1,12 +1,14 @@
-import { Component, signal } from '@angular/core';
-import { RouterOutlet } from '@angular/router';
+import { TuiRoot } from '@taiga-ui/core';
+import { Component, isDevMode } from '@angular/core';
+import { RouterLink, RouterOutlet } from '@angular/router';
+import { DevConsoleComponent } from './features/devtools/dev-console/dev-console.component';
@Component({
selector: 'app-root',
- imports: [RouterOutlet],
+ imports: [RouterLink, RouterOutlet, TuiRoot, DevConsoleComponent],
templateUrl: './app.html',
- styleUrl: './app.css'
+ styleUrl: './app.css',
})
export class App {
- protected readonly title = signal('sparkguardian');
+ protected readonly isDev = isDevMode();
}
diff --git a/src/app/core/config/api.tokens.ts b/src/app/core/config/api.tokens.ts
new file mode 100644
index 0000000..0ec98b5
--- /dev/null
+++ b/src/app/core/config/api.tokens.ts
@@ -0,0 +1,34 @@
+import { DOCUMENT } from '@angular/common';
+import { inject, InjectionToken } from '@angular/core';
+
+import { environment } from '../../../environments/environment';
+
+/**
+ * Origin для разрешения относительных `playlist_url` (HLS).
+ * При `ng serve` — origin dev-сервера; API проксируется (см. `proxy.conf.cjs`, `SG_DEV_PROXY_TARGET` в `.env`).
+ */
+export const API_ORIGIN = new InjectionToken
('API_ORIGIN', {
+ factory: () => {
+ const doc = inject(DOCUMENT);
+ const loc = doc.defaultView?.location;
+ if (!loc || loc.protocol === 'file:') {
+ return environment.apiFallbackOrigin;
+ }
+ return loc.origin;
+ },
+});
+
+/**
+ * Базовый путь API. Относительный путь работает в dev (прокси) и при деплое на тот же домен.
+ * Для `file://` — абсолютный URL из `environment`.
+ */
+export const API_BASE_URL = new InjectionToken('API_BASE_URL', {
+ factory: () => {
+ const doc = inject(DOCUMENT);
+ const loc = doc.defaultView?.location;
+ if (loc?.protocol === 'file:') {
+ return `${environment.apiFallbackOrigin}${environment.apiBasePath}`;
+ }
+ return environment.apiBasePath;
+ },
+});
diff --git a/src/app/core/devtools/dev-log.service.ts b/src/app/core/devtools/dev-log.service.ts
new file mode 100644
index 0000000..9642894
--- /dev/null
+++ b/src/app/core/devtools/dev-log.service.ts
@@ -0,0 +1,52 @@
+import { Injectable, signal } from '@angular/core';
+
+export type DevLogLevel = 'info' | 'warn' | 'error';
+export type DevLogStatus = 'pending' | 'ok' | 'error';
+
+export interface DevHttpLogDetails {
+ method: string;
+ url: string;
+ requestHeaders?: Record;
+ requestBody?: unknown;
+ statusCode?: number;
+ durationMs?: number;
+ responseHeaders?: Record;
+ responseBody?: unknown;
+ error?: string;
+}
+
+export interface DevLogEntry {
+ id: number;
+ time: string;
+ level: DevLogLevel;
+ source: 'http' | 'system';
+ message: string;
+ status?: DevLogStatus;
+ details?: DevHttpLogDetails;
+}
+
+@Injectable({ providedIn: 'root' })
+export class DevLogService {
+ private seq = 0;
+ private readonly max = 300;
+ readonly entries = signal([]);
+
+ add(entry: Omit): void {
+ const withMeta: DevLogEntry = {
+ id: ++this.seq,
+ time: new Date().toISOString(),
+ ...entry,
+ };
+ this.entries.update((curr) => [...curr.slice(-(this.max - 1)), withMeta]);
+ }
+
+ update(id: number, patch: Partial>): void {
+ this.entries.update((curr) =>
+ curr.map((item) => (item.id === id ? { ...item, ...patch } : item)),
+ );
+ }
+
+ clear(): void {
+ this.entries.set([]);
+ }
+}
diff --git a/src/app/core/http/api-base-url.interceptor.ts b/src/app/core/http/api-base-url.interceptor.ts
new file mode 100644
index 0000000..7e5aff3
--- /dev/null
+++ b/src/app/core/http/api-base-url.interceptor.ts
@@ -0,0 +1,17 @@
+import { HttpInterceptorFn } from '@angular/common/http';
+import { inject } from '@angular/core';
+
+import { API_BASE_URL } from '../config/api.tokens';
+
+/**
+ * Подставляет базовый URL ко всем относительным запросам.
+ * Абсолютные URL (`http…`) не трогаем — как в Axios с `baseURL`, только через перехватчик.
+ */
+export const apiBaseUrlInterceptor: HttpInterceptorFn = (req, next) => {
+ const base = inject(API_BASE_URL);
+ if (/^https?:\/\//i.test(req.url)) {
+ return next(req);
+ }
+ const path = req.url.startsWith('/') ? req.url : `/${req.url}`;
+ return next(req.clone({ url: `${base}${path}` }));
+};
diff --git a/src/app/core/http/dev-log.interceptor.ts b/src/app/core/http/dev-log.interceptor.ts
new file mode 100644
index 0000000..f84a22b
--- /dev/null
+++ b/src/app/core/http/dev-log.interceptor.ts
@@ -0,0 +1,117 @@
+import {
+ HttpErrorResponse,
+ HttpEvent,
+ HttpHeaders,
+ HttpInterceptorFn,
+ HttpResponse,
+} from '@angular/common/http';
+import { inject, isDevMode } from '@angular/core';
+import { tap } from 'rxjs';
+
+import { httpErrorMessage } from './http-error.util';
+import { DevLogService } from '../devtools/dev-log.service';
+
+function headersToObject(headers: HttpHeaders): Record {
+ const out: Record = {};
+ for (const key of headers.keys()) {
+ out[key] = headers.get(key);
+ }
+ return out;
+}
+
+function normalizeBody(value: unknown): unknown {
+ if (value === undefined) {
+ return null;
+ }
+ if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
+ return value;
+ }
+ if (value instanceof Blob) {
+ return `[Blob ${value.type || 'unknown'} ${value.size} bytes]`;
+ }
+ if (value instanceof FormData) {
+ const fields: Record = {};
+ value.forEach((v, k) => {
+ fields[k] = typeof v === 'string' ? v : `[File ${(v as File).name}]`;
+ });
+ return fields;
+ }
+ if (value instanceof ArrayBuffer) {
+ return `[ArrayBuffer ${value.byteLength} bytes]`;
+ }
+ return value;
+}
+
+export const devLogInterceptor: HttpInterceptorFn = (req, next) => {
+ if (!isDevMode()) {
+ return next(req);
+ }
+
+ const logs = inject(DevLogService);
+ const started = performance.now();
+ logs.add({
+ level: 'info',
+ source: 'http',
+ status: 'pending',
+ message: `→ ${req.method} ${req.urlWithParams} (pending)`,
+ details: {
+ method: req.method,
+ url: req.urlWithParams,
+ requestHeaders: headersToObject(req.headers),
+ requestBody: normalizeBody(req.body),
+ },
+ });
+ const entryId = logs.entries().at(-1)?.id;
+
+ return next(req).pipe(
+ tap({
+ next: (event: HttpEvent) => {
+ if (!(event instanceof HttpResponse)) {
+ return;
+ }
+ const ms = Math.round(performance.now() - started);
+ if (entryId) {
+ logs.update(entryId, {
+ level: 'info',
+ status: 'ok',
+ message: `✓ ${req.method} ${req.urlWithParams} [${event.status}] ${ms}ms`,
+ details: {
+ method: req.method,
+ url: req.urlWithParams,
+ requestHeaders: headersToObject(req.headers),
+ requestBody: normalizeBody(req.body),
+ statusCode: event.status,
+ durationMs: ms,
+ responseHeaders: headersToObject(event.headers),
+ responseBody: normalizeBody(event.body),
+ },
+ });
+ }
+ },
+ error: (error: unknown) => {
+ const ms = Math.round(performance.now() - started);
+ const status = error instanceof HttpErrorResponse ? error.status : undefined;
+ if (entryId) {
+ logs.update(entryId, {
+ level: 'error',
+ status: 'error',
+ message: `✕ ${req.method} ${req.urlWithParams}${status ? ` [${status}]` : ''} ${ms}ms: ${httpErrorMessage(error)}`,
+ details: {
+ method: req.method,
+ url: req.urlWithParams,
+ requestHeaders: headersToObject(req.headers),
+ requestBody: normalizeBody(req.body),
+ statusCode: status,
+ durationMs: ms,
+ responseHeaders:
+ error instanceof HttpErrorResponse ? headersToObject(error.headers) : undefined,
+ responseBody:
+ error instanceof HttpErrorResponse ? normalizeBody(error.error) : undefined,
+ error: httpErrorMessage(error),
+ },
+ });
+ }
+ },
+ }),
+ );
+};
diff --git a/src/app/core/http/error-classification.util.ts b/src/app/core/http/error-classification.util.ts
new file mode 100644
index 0000000..726d016
--- /dev/null
+++ b/src/app/core/http/error-classification.util.ts
@@ -0,0 +1,67 @@
+import { HttpErrorResponse } from '@angular/common/http';
+import { TimeoutError } from 'rxjs';
+
+import type { UserErrorKind } from '../notifications/user-error-messages.config';
+
+function isParseFailureMessage(message: string | undefined): boolean {
+ return !!message?.includes('Http failure during parsing');
+}
+
+/**
+ * Определяет категорию ошибки для пользовательского сообщения (без утечки технических деталей).
+ */
+export function classifyUserError(err: unknown): UserErrorKind {
+ if (err instanceof TimeoutError) {
+ return 'timeout';
+ }
+
+ if (err instanceof HttpErrorResponse) {
+ if (isParseFailureMessage(err.message)) {
+ return 'parse_error';
+ }
+ const s = err.status;
+ if (s === 0) {
+ return 'network';
+ }
+ if (s === 408) {
+ return 'timeout';
+ }
+ if (s === 401) {
+ return 'unauthorized';
+ }
+ if (s === 403) {
+ return 'forbidden';
+ }
+ if (s === 404) {
+ return 'not_found';
+ }
+ if (s === 400) {
+ return 'bad_request';
+ }
+ if (s >= 500) {
+ return 'server_error';
+ }
+ if (s >= 400 && s < 500) {
+ return 'client_error';
+ }
+ return 'unknown';
+ }
+
+ if (err instanceof Error) {
+ const m = err.message;
+ if (isParseFailureMessage(m)) {
+ return 'parse_error';
+ }
+ if (/идентификатор|некорректн/i.test(m)) {
+ return 'invalid_input';
+ }
+ if (/timeout|timed out/i.test(m)) {
+ return 'timeout';
+ }
+ if (/network|failed to fetch|load failed|net::/i.test(m)) {
+ return 'network';
+ }
+ }
+
+ return 'unknown';
+}
diff --git a/src/app/core/http/http-error.util.ts b/src/app/core/http/http-error.util.ts
new file mode 100644
index 0000000..3f0fb23
--- /dev/null
+++ b/src/app/core/http/http-error.util.ts
@@ -0,0 +1,56 @@
+import { HttpErrorResponse } from '@angular/common/http';
+
+import type { ApiErrorBody } from '../models/api.types';
+
+/** Безопасная подстановка текста в разметку с innerHTML (например, Taiga notification). */
+export function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+function summarizeHtmlError(text: string): string | null {
+ const t = text.trim();
+ if (!t.startsWith(']*>([\s\S]*?)<\/pre>/i);
+ if (pre?.[1]) {
+ return pre[1].trim();
+ }
+ const title = t.match(/]*>([\s\S]*?)<\/title>/i);
+ if (title?.[1]) {
+ return title[1].trim();
+ }
+ return 'Сервер вернул HTML вместо JSON (часто редирект на вход или ошибка прокси).';
+}
+
+export function httpErrorMessage(err: unknown): string {
+ if (err instanceof HttpErrorResponse) {
+ const body = err.error as ApiErrorBody | string | null | undefined;
+ if (body && typeof body === 'object' && typeof body.error === 'string') {
+ return body.error;
+ }
+ if (typeof body === 'string' && body.length > 0) {
+ const fromHtml = summarizeHtmlError(body);
+ if (fromHtml) {
+ return fromHtml;
+ }
+ return body.length > 200 ? `${body.slice(0, 200)}…` : body;
+ }
+ if (err.message?.includes('Http failure during parsing')) {
+ return 'Ответ не JSON (часто HTML страницы входа или ошибка прокси /api). Перезапустите `npm start` и проверьте proxy.conf.cjs и `SG_DEV_PROXY_TARGET` в `.env`.';
+ }
+ return err.message || `HTTP ${err.status}`;
+ }
+ if (err instanceof Error) {
+ if (err.message.includes('Http failure during parsing')) {
+ return 'Ответ не JSON — проверьте авторизацию и прокси для API.';
+ }
+ return err.message;
+ }
+ return 'Неизвестная ошибка';
+}
diff --git a/src/app/core/keyboard/keyboard-key-name-map.ts b/src/app/core/keyboard/keyboard-key-name-map.ts
new file mode 100644
index 0000000..c5c6106
--- /dev/null
+++ b/src/app/core/keyboard/keyboard-key-name-map.ts
@@ -0,0 +1,71 @@
+import { charKeyNameToSvgKeyId } from './vk-to-keyboard-svg-id';
+
+/**
+ * Токены из `key_name` и из `modifiers` (через "+"), в нижнем регистре.
+ * Соответствие имён бэкенда → id прямоугольников в KB_USA-standard.svg.
+ */
+function tokenToSvgIds(token: string): string[] {
+ const t = token.trim().toLowerCase();
+ if (!t) {
+ return [];
+ }
+
+ const named: Record = {
+ shift: ['K_kb5a'],
+ meta: ['K_kb6b'],
+ super: ['K_kb6b'],
+ win: ['K_kb6b'],
+ windows: ['K_kb6b'],
+ control: ['K_kb6a'],
+ ctrl: ['K_kb6a'],
+ alt: ['K_kb6c'],
+ tab: ['K_kb3a'],
+ enter: ['K_kb4n'],
+ return: ['K_kb4n'],
+ backspace: ['K_kb2n'],
+ space: ['K_kb6d'],
+ caps: ['K_kb4a'],
+ menu: ['K_kb6m'],
+ };
+
+ if (named[t]) {
+ return named[t]!;
+ }
+
+ const single = charKeyNameToSvgKeyId(t);
+ if (single) {
+ return [single];
+ }
+
+ return [];
+}
+
+/**
+ * Формат бэкенда: `{ key_name, modifiers: "Shift+Meta", action }` и т.п.
+ * Собирает уникальные id клавиш для подсветки (модификаторы + основная клавиша).
+ */
+export function keyNameModifiersPayloadToSvgIds(o: Record): string[] {
+ const out: string[] = [];
+ const seen = new Set();
+
+ const add = (ids: string[]) => {
+ for (const id of ids) {
+ if (id && !seen.has(id)) {
+ seen.add(id);
+ out.push(id);
+ }
+ }
+ };
+
+ const modifiers = typeof o['modifiers'] === 'string' ? o['modifiers'] : '';
+ const keyName = typeof o['key_name'] === 'string' ? o['key_name'] : '';
+
+ for (const part of modifiers.split('+').map((s) => s.trim()).filter(Boolean)) {
+ add(tokenToSvgIds(part));
+ }
+ if (keyName.trim()) {
+ add(tokenToSvgIds(keyName));
+ }
+
+ return out;
+}
diff --git a/src/app/core/keyboard/keyboard-payload.util.ts b/src/app/core/keyboard/keyboard-payload.util.ts
new file mode 100644
index 0000000..9f7c3d5
--- /dev/null
+++ b/src/app/core/keyboard/keyboard-payload.util.ts
@@ -0,0 +1,88 @@
+import type { ParsedEvent } from '../models/api.types';
+
+import { keyNameModifiersPayloadToSvgIds } from './keyboard-key-name-map';
+import { normalizeVirtualKey, vkToKeyboardSvgKeyId } from './vk-to-keyboard-svg-id';
+
+export function isKeyboardTelemetryEvent(event: ParsedEvent): boolean {
+ const t = (event.event_type ?? '').toLowerCase();
+ return t.includes('keyboard') || t.includes('key');
+}
+
+/**
+ * Извлекает виртуальный код клавиши из payload (массив чисел, объект с полями vk/keyCode и т.д.).
+ */
+export function parseKeyboardVirtualKey(data: unknown): number | null {
+ if (data == null) {
+ return null;
+ }
+ if (Array.isArray(data)) {
+ if (data.length >= 1) {
+ const v = data[0];
+ if (typeof v === 'number' && Number.isFinite(v)) {
+ return v;
+ }
+ if (typeof v === 'string') {
+ const n = parseInt(v, 10);
+ return Number.isFinite(n) ? n : null;
+ }
+ }
+ return null;
+ }
+ if (typeof data === 'object') {
+ const o = data as Record;
+ for (const key of ['vk', 'virtualKey', 'VirtualKey', 'keyCode', 'KeyCode', 'wParam']) {
+ const v = o[key];
+ if (typeof v === 'number' && Number.isFinite(v)) {
+ return v;
+ }
+ if (typeof v === 'string') {
+ const n = parseInt(v, 10);
+ if (Number.isFinite(n)) {
+ return n;
+ }
+ }
+ }
+ }
+ return null;
+}
+
+export function eventPayloadJson(data: unknown): string {
+ try {
+ return JSON.stringify(data, null, 2);
+ } catch {
+ return String(data);
+ }
+}
+
+function unwrapJsonPayload(data: unknown): unknown {
+ if (typeof data === 'string') {
+ try {
+ return JSON.parse(data) as unknown;
+ } catch {
+ return data;
+ }
+ }
+ return data;
+}
+
+/**
+ * Id элементов `K_kb*` для подсветки: сначала объект `key_name` / `modifiers`, иначе VK из массива/полей.
+ */
+export function parseKeyboardHighlightKeyIds(data: unknown): string[] {
+ const raw = unwrapJsonPayload(data);
+ if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) {
+ const o = raw as Record;
+ if (typeof o['key_name'] === 'string' || typeof o['modifiers'] === 'string') {
+ const fromNames = keyNameModifiersPayloadToSvgIds(o);
+ if (fromNames.length > 0) {
+ return fromNames;
+ }
+ }
+ }
+ const vk = parseKeyboardVirtualKey(raw);
+ if (vk != null) {
+ const id = vkToKeyboardSvgKeyId(normalizeVirtualKey(vk));
+ 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
new file mode 100644
index 0000000..8dc0168
--- /dev/null
+++ b/src/app/core/keyboard/keyboard-svg-highlight.service.ts
@@ -0,0 +1,54 @@
+import { Injectable, inject } from '@angular/core';
+import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
+import { Observable, defer, from, shareReplay } from 'rxjs';
+import { map } from 'rxjs/operators';
+
+/**
+ * Файл из `public/KB_USA-standard.svg` — отдаётся с корня приложения (`/KB_USA-standard.svg`),
+ * не через API. HttpClient нельзя: `apiBaseUrlInterceptor` превратил бы путь в `/api/v1/...`.
+ */
+const KEYBOARD_SVG_PATH = '/KB_USA-standard.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 }));
+
+ svgWithHighlight(keyIds: string[] | null): Observable {
+ return this.baseSvg$.pipe(
+ map((svg) => this.injectHighlight(svg, keyIds ?? [])),
+ map((html) => this.sanitizer.bypassSecurityTrustHtml(html)),
+ );
+ }
+
+ /** Подсветка: заливка `--sg-keyboard-key-pressed-fill` (акцент), подписи/иконки `--sg-keyboard-key-pressed-ink`. */
+ private injectHighlight(svgText: string, keyIds: string[]): string {
+ const valid = keyIds.filter((id) => /^K_kb[0-9a-z]+$/i.test(id));
+ if (valid.length === 0) {
+ return svgText;
+ }
+ const rules: string[] = [];
+ for (const id of valid) {
+ const suffix = id.slice(2);
+ rules.push(
+ `#${id}{fill:var(--sg-keyboard-key-pressed-fill)!important;stroke:none!important;}`,
+ );
+ rules.push(`[id^="T_${suffix}"]{fill:var(--sg-keyboard-key-pressed-ink)!important;}`);
+ rules.push(
+ `#S_${suffix} path,#S_${suffix} line,#S_${suffix} polyline,#S_${suffix} rect{stroke:var(--sg-keyboard-key-pressed-ink)!important;fill:none!important;}`,
+ );
+ }
+ const styleBlock = ``;
+ return svgText.replace(/