Compare commits

...

3 Commits

Author SHA1 Message Date
Maxim
d785f4a481 корректировка отображения данных в журнале 2026-06-17 16:35:32 +03:00
Maxim
580b78e5c0 регулировка ресайза 2026-06-17 15:59:53 +03:00
Maxim
30923eb9ce рефактор 2026-06-17 15:25:46 +03:00
8 changed files with 840 additions and 598 deletions

View File

@@ -40,211 +40,213 @@ func writeJSON(w http.ResponseWriter, v any) {
// HandleLogFiles - список доступных лог-файлов
func (a *API) HandleLogFiles(w http.ResponseWriter, r *http.Request) {
dataDir, err := logger.GetDataLogsDir()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
dataDir, err := logger.GetDataLogsDir()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
files, err := filepath.Glob(filepath.Join(dataDir, "gpio-*.bin"))
if err != nil {
http.Error(w, err.Error(), 500)
return
}
files, err := filepath.Glob(filepath.Join(dataDir, "gpio-*.bin"))
if err != nil {
http.Error(w, err.Error(), 500)
return
}
type LogFileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
ModTime time.Time `json:"mod_time"`
Time string `json:"time"`
Date string `json:"date"`
Hour int `json:"hour"`
IsActive bool `json:"is_active"`
}
type LogFileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
ModTime time.Time `json:"mod_time"`
Time string `json:"time"`
Date string `json:"date"`
Hour int `json:"hour"`
IsActive bool `json:"is_active"`
}
var result []LogFileInfo
for _, file := range files {
info, err := os.Stat(file)
if err != nil {
continue
}
var result []LogFileInfo
for _, file := range files {
info, err := os.Stat(file)
if err != nil {
continue
}
// Парсим имя файла: gpio-2026-06-16-11.bin
base := filepath.Base(file)
parts := strings.Split(strings.TrimSuffix(base, ".bin"), "-")
base := filepath.Base(file)
parts := strings.Split(strings.TrimSuffix(base, ".bin"), "-")
var fileTime time.Time
if len(parts) == 5 {
year, _ := strconv.Atoi(parts[1])
month, _ := strconv.Atoi(parts[2])
day, _ := strconv.Atoi(parts[3])
hour, _ := strconv.Atoi(parts[4])
fileTime = time.Date(year, time.Month(month), day, hour, 0, 0, 0, time.Local)
} else {
fileTime = info.ModTime()
}
var fileTime time.Time
if len(parts) == 5 {
year, _ := strconv.Atoi(parts[1])
month, _ := strconv.Atoi(parts[2])
day, _ := strconv.Atoi(parts[3])
hour, _ := strconv.Atoi(parts[4])
fileTime = time.Date(year, time.Month(month), day, hour, 0, 0, 0, time.Local)
} else {
fileTime = info.ModTime()
}
result = append(result, LogFileInfo{
Name: base,
Path: file,
Size: info.Size(),
ModTime: info.ModTime(),
Time: fileTime.Format("02.01.2006 15:00"),
Date: fileTime.Format("2006-01-02"),
Hour: fileTime.Hour(),
IsActive: false,
})
}
result = append(result, LogFileInfo{
Name: base,
Path: file,
Size: info.Size(),
ModTime: info.ModTime(),
Time: fileTime.Format("02.01.2006 15:00"),
Date: fileTime.Format("2006-01-02"),
Hour: fileTime.Hour(),
IsActive: false,
})
}
// Сортируем по времени (новые сверху)
sort.Slice(result, func(i, j int) bool {
return result[i].ModTime.After(result[j].ModTime)
})
// Сортируем по времени (новые сверху)
sort.Slice(result, func(i, j int) bool {
return result[i].ModTime.After(result[j].ModTime)
})
writeJSON(w, result)
writeJSON(w, result)
}
// HandleLogEvents - чтение событий
func (a *API) HandleLogEvents(w http.ResponseWriter, r *http.Request) {
limit := 100
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil {
limit = parsed
}
}
eventsPath, err := logger.GetEventHumanLogPath()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
content, err := os.ReadFile(eventsPath)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
lines := strings.Split(string(content), "\n")
// Берем последние N строк
if len(lines) > limit+1 {
lines = lines[len(lines)-limit-1:]
}
// Парсим строки для JSON
type Event struct {
Time string `json:"time"`
Event string `json:"event"`
}
var events []Event
for _, line := range lines {
if line == "" {
continue
}
// Формат: [2026-06-16 11:23:45.123] EVENT: СЕРВЕР_ЗАПУЩЕН
parts := strings.SplitN(line, "] EVENT: ", 2)
if len(parts) == 2 {
events = append(events, Event{
Time: strings.TrimPrefix(parts[0], "["),
Event: parts[1],
})
}
}
writeJSON(w, map[string]any{
"events": events,
"total": len(events),
})
}
// HandleLogData - чтение данных из конкретного bin файла
func (a *API) HandleLogData(w http.ResponseWriter, r *http.Request) {
filename := r.URL.Query().Get("file")
if filename == "" {
http.Error(w, "missing file parameter", 400)
return
}
filename := r.URL.Query().Get("file")
if filename == "" {
http.Error(w, "отсутствует параметр file", 400)
return
}
// Защита от path traversal
filename = filepath.Base(filename)
dataDir, err := logger.GetDataLogsDir()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
// Защита от path traversal
filename = filepath.Base(filename)
dataDir, err := logger.GetDataLogsDir()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
filePath := filepath.Join(dataDir, filename)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
http.Error(w, "file not found", 404)
return
}
filePath := filepath.Join(dataDir, filename)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
http.Error(w, "файл не найден", 404)
return
}
// Читаем бинарный файл
f, err := os.Open(filePath)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer f.Close()
// Читаем бинарный файл
f, err := os.Open(filePath)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer f.Close()
type Sample struct {
Timestamp int64 `json:"ts"`
Time string `json:"time"`
Value byte `json:"value"`
Count byte `json:"count"`
Strength byte `json:"strength"`
Timestamp int64 `json:"ts"`
Time string `json:"time"`
Value byte `json:"value"`
Count byte `json:"count"`
Strength byte `json:"strength"`
StrengthName string `json:"strength_name"`
Amplitude byte `json:"amplitude"` // Старшие 2 бита (0-3)
Signal byte `json:"signal"` // Младшие 6 бит (0-63)
}
var samples []Sample
var samples []Sample
buf := make([]byte, 9) // 8 байт timestamp + 1 байт value
for {
n, err := f.Read(buf)
if err == io.EOF {
break
}
if err != nil {
http.Error(w, err.Error(), 500)
return
}
if n < 9 {
continue
}
for {
n, err := f.Read(buf)
if err == io.EOF {
break
}
if err != nil {
http.Error(w, err.Error(), 500)
return
}
if n < 9 {
continue
}
ts := int64(binary.LittleEndian.Uint64(buf[0:8]))
ts := int64(binary.LittleEndian.Uint64(buf[0:8]))
value := buf[8]
data := logger.ParseGPIO(value)
samples = append(samples, Sample{
Timestamp: ts,
Time: time.UnixMicro(ts).Format("2006-01-02 15:04:05.000"),
Value: value,
Count: data.Count,
Strength: data.Strength,
Timestamp: ts,
Time: time.UnixMicro(ts).Format("02.01.2006 15:04:05"),
Value: value,
Count: data.Count,
Strength: data.Strength,
StrengthName: logger.GetStrengthName(data.Strength),
Amplitude: (value >> 6) & 0x3, // Старшие 2 бита (0-3) - АМПЛИТУДА
Signal: value & 0x3F, // Младшие 6 бит (0-63) - СИГНАЛ
})
}
}
// Ограничиваем количество точек для производительности
if len(samples) > 10000 {
// Берем каждую N-ю точку
step := len(samples) / 10000
var filtered []Sample
for i := 0; i < len(samples); i += step {
filtered = append(filtered, samples[i])
}
samples = filtered
}
// Ограничиваем количество точек для производительности
if len(samples) > 10000 {
step := len(samples) / 10000
var filtered []Sample
for i := 0; i < len(samples); i += step {
filtered = append(filtered, samples[i])
}
samples = filtered
}
writeJSON(w, map[string]any{
"filename": filename,
"total": len(samples),
"samples": samples,
})
}
// HandleLogEvents - чтение событий
func (a *API) HandleLogEvents(w http.ResponseWriter, r *http.Request) {
limit := 100
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil {
limit = parsed
}
}
eventsPath, err := logger.GetEventHumanLogPath()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
content, err := os.ReadFile(eventsPath)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
lines := strings.Split(string(content), "\n")
// Берем последние N строк
if len(lines) > limit+1 {
lines = lines[len(lines)-limit-1:]
}
// Парсим строки для JSON
type Event struct {
Time string `json:"time"`
Event string `json:"event"`
}
var events []Event
for _, line := range lines {
if line == "" {
continue
}
// Формат: [2026-06-16 11:23:45.123] EVENT: СЕРВЕР_ЗАПУЩЕН
parts := strings.SplitN(line, "] EVENT: ", 2)
if len(parts) == 2 {
events = append(events, Event{
Time: strings.TrimPrefix(parts[0], "["),
Event: parts[1],
})
}
}
writeJSON(w, map[string]any{
"events": events,
"total": len(events),
})
writeJSON(w, map[string]any{
"filename": filename,
"total": len(samples),
"samples": samples,
})
}
func (a *API) HandleHealth(w http.ResponseWriter, r *http.Request) {
@@ -313,12 +315,12 @@ func (a *API) HandleStream(w http.ResponseWriter, r *http.Request) {
// Прокси для MJPEG потока — просто ретранслирует готовый поток из go2rtc
func handleCamProxy(w http.ResponseWriter, r *http.Request) {
log.Printf("[cam] proxy request from %s", r.RemoteAddr)
log.Printf("[cam] запрос от %s", r.RemoteAddr)
resp, err := http.Get("http://127.0.0.1:1984/api/stream.mjpeg?src=cam_mjpeg")
if err != nil {
log.Printf("[cam] go2rtc unavailable: %v", err)
http.Error(w, "camera unavailable", 503)
log.Printf("[cam] go2rtc недоступен: %v", err)
http.Error(w, "камера недоступна", 503)
return
}
defer resp.Body.Close()
@@ -335,7 +337,7 @@ func handleCamProxy(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "stream unsupported", 500)
http.Error(w, "поток не поддерживается", 500)
return
}
@@ -346,14 +348,14 @@ func handleCamProxy(w http.ResponseWriter, r *http.Request) {
if n > 0 {
_, err = w.Write(buf[:n])
if err != nil {
log.Printf("[cam] client disconnected")
log.Printf("[cam] клиент отключился")
return
}
flusher.Flush()
}
if err != nil {
if err != io.EOF {
log.Printf("[cam] stream ended: %v", err)
log.Printf("[cam] поток завершен: %v", err)
}
return
}
@@ -429,7 +431,7 @@ func main() {
// Логируем конфигурацию
log.Printf("=== КОНФИГУРАЦИЯ ===")
log.Printf("Retention: %d часов, %d MB (проверка каждые %d мин)",
log.Printf("Хранение: %d часов, %d MB (проверка каждые %d мин)",
*retentionHours, *retentionMB, *retentionIntervalMin)
log.Printf("Ротация: проверка каждые %d мин", *rotationCheckIntervalMin)
log.Printf("Буфер: %d элементов", *bufferSize)
@@ -514,11 +516,11 @@ func main() {
// Передаем URL камеры из конфигурации
handleCamProxyWithURL(w, r, *cameraURL)
case "/log/files":
cors(api.HandleLogFiles)(w, r)
case "/log/data":
cors(api.HandleLogData)(w, r)
case "/log/events":
cors(api.HandleLogEvents)(w, r)
cors(api.HandleLogFiles)(w, r)
case "/log/data":
cors(api.HandleLogData)(w, r)
case "/log/events":
cors(api.HandleLogEvents)(w, r)
default:
http.Error(w, "not found", 404)
}
@@ -531,19 +533,19 @@ func main() {
}
http.Handle("/", http.FileServer(http.FS(webFS)))
log.Printf("Server started on %s", *serverPort)
log.Printf("Camera proxy available at /api/cam (source: %s)", *cameraURL)
log.Printf("Сервер запущен на %s", *serverPort)
log.Printf("Прокси камеры доступен по адресу /api/cam (источник: %s)", *cameraURL)
log.Fatal(http.ListenAndServe(*serverPort, nil))
}
// handleCamProxyWithURL проксирует MJPEG поток с указанным URL
func handleCamProxyWithURL(w http.ResponseWriter, r *http.Request, cameraURL string) {
log.Printf("[cam] proxy request from %s to %s", r.RemoteAddr, cameraURL)
log.Printf("[cam] запрос от %s к %s", r.RemoteAddr, cameraURL)
resp, err := http.Get(cameraURL)
if err != nil {
log.Printf("[cam] camera unavailable: %v", err)
http.Error(w, "camera unavailable", 503)
log.Printf("[cam] камера недоступна: %v", err)
http.Error(w, "камера недоступна", 503)
return
}
defer resp.Body.Close()
@@ -560,7 +562,7 @@ func handleCamProxyWithURL(w http.ResponseWriter, r *http.Request, cameraURL str
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "stream unsupported", 500)
http.Error(w, "поток не поддерживается", 500)
return
}
@@ -571,14 +573,14 @@ func handleCamProxyWithURL(w http.ResponseWriter, r *http.Request, cameraURL str
if n > 0 {
_, err = w.Write(buf[:n])
if err != nil {
log.Printf("[cam] client disconnected")
log.Printf("[cam] клиент отключился")
return
}
flusher.Flush()
}
if err != nil {
if err != io.EOF {
log.Printf("[cam] stream ended: %v", err)
log.Printf("[cam] поток завершен: %v", err)
}
return
}

View File

@@ -166,7 +166,7 @@ h1 {
.fill {
height: 100%;
width: 0%;
width: 0;
background: #e6852d;
box-shadow:
inset 0 0 4px rgba(255,255,255,0.2),
@@ -177,7 +177,7 @@ h1 {
.fill.no-connection {
background: #555555 !important;
box-shadow: none !important;
width: 0% !important;
width: 0 !important;
}
/* ===== SIGNAL ICON ===== */
@@ -194,7 +194,7 @@ h1 {
flex: 1;
border-radius: 2px;
min-width: 5px;
border: 1.5px solid #e6852d;
border: 2px solid #e6852d;
background-color: transparent;
transition: background-color 0.3s, border-color 0.3s;
}

View File

@@ -22,17 +22,21 @@ export class AudioEngine {
/**
* Инициализация AudioContext (требует взаимодействия пользователя)
*/
private initAudio(): boolean {
private async initAudio(): Promise<boolean> {
try {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
const AudioContextClass = (window as any).AudioContext || (window as any).webkitAudioContext;
this.audioContext = new AudioContextClass();
}
if (this.audioContext.state === 'suspended') {
this.audioContext.resume();
const context = this.audioContext;
if (!context) return false;
if (context.state === 'suspended') {
await context.resume();
}
return this.audioContext.state === 'running';
return context.state === 'running';
} catch (error) {
console.warn('[Audio] Ошибка инициализации:', error);
return false;
@@ -104,18 +108,26 @@ export class AudioEngine {
* Обновление уровня сигнала
* @param level - уровень сигнала (0-3)
*/
public updateLevel(level: number): void {
public async updateLevel(level: number): Promise<void> {
// Инициализация при первом вызове
if (!this.audioContext) {
if (!this.initAudio()) {
const initialized = await this.initAudio();
if (!initialized) {
return;
}
}
const context = this.audioContext;
if (!context) return;
// Если AudioContext не готов, пробуем восстановить
if (this.audioContext?.state !== 'running') {
this.audioContext?.resume();
return;
if (context.state !== 'running') {
try {
await context.resume();
} catch (error) {
console.warn('[Audio] Ошибка возобновления контекста:', error);
return;
}
}
// Приводим уровень к числу
@@ -260,9 +272,14 @@ export class AudioEngine {
console.log('[Audio] Звук остановлен');
}
// ============================================
// ПУБЛИЧНЫЕ МЕТОДЫ
// ============================================
/**
* Проверка, активен ли звук
*/
// noinspection JSUnusedGlobalSymbols
public isActive(): boolean {
return this.isPlaying && this.currentLevel > 0;
}
@@ -270,6 +287,7 @@ export class AudioEngine {
/**
* Получение текущей частоты
*/
// noinspection JSUnusedGlobalSymbols
public getCurrentFrequency(): number {
return this.currentFrequency;
}
@@ -277,6 +295,7 @@ export class AudioEngine {
/**
* Получение текущего уровня
*/
// noinspection JSUnusedGlobalSymbols
public getCurrentLevel(): number {
return this.currentLevel;
}
@@ -284,11 +303,12 @@ export class AudioEngine {
/**
* Полная остановка и освобождение ресурсов
*/
public dispose(): void {
// noinspection JSUnusedGlobalSymbols
public async dispose(): Promise<void> {
this.stopSound();
if (this.audioContext) {
try {
this.audioContext.close();
await this.audioContext.close();
} catch (e) {
// Игнорируем
}
@@ -300,5 +320,6 @@ export class AudioEngine {
}
}
// Экспортируем одиночный экземпляр
// noinspection JSUnusedGlobalSymbols
export const audioEngine = new AudioEngine();

View File

@@ -1,23 +1,18 @@
// camera.ts
import { updateResizerVisibility } from './layout.js';
import { updateResizerVisibility, openRightPanel, closeRightPanel } from './layout.js';
let rightPanel: HTMLElement | null = null;
let camImage: HTMLImageElement | null = null;
let crosshair: HTMLElement | null = null;
let isOpen: boolean = false;
let streamCheckInterval: number | null = null;
interface StreamResponse {
cam: string;
available: boolean;
source: string;
}
let resizer: HTMLElement | null = null;
export function initCamera(): void {
rightPanel = document.getElementById("rightPanel");
camImage = document.getElementById("camImage") as HTMLImageElement;
crosshair = document.getElementById("crosshair");
resizer = document.getElementById("dragBar");
const btn = document.getElementById("camToggle");
@@ -28,9 +23,8 @@ export function initCamera(): void {
isOpen = true;
if (rightPanel) {
rightPanel.classList.remove("hidden");
}
// Открываем правую панель через layout
openRightPanel();
// Показываем перекрестие
if (crosshair) {
@@ -44,13 +38,15 @@ export function initCamera(): void {
if (btn) {
const textSpan = btn.querySelector('.cam-btn-text');
if (textSpan) textSpan.textContent = 'Скрыть';
if (textSpan) {
textSpan.textContent = 'Скрыть';
} else {
btn.innerHTML = '📷 Скрыть';
}
}
console.log("[camera] opened");
updateResizerVisibility();
// Запускаем мониторинг потока
startStreamMonitoring();
}
@@ -65,24 +61,25 @@ export function initCamera(): void {
crosshair.style.display = "none";
}
// остановка stream
// Останавливаем поток
if (camImage) {
camImage.src = "";
}
if (rightPanel) {
rightPanel.classList.add("hidden");
}
// Закрываем правую панель через layout
closeRightPanel();
if (btn) {
const textSpan = btn.querySelector('.cam-btn-text');
if (textSpan) textSpan.textContent = 'Камера';
if (textSpan) {
textSpan.textContent = 'Камера';
} else {
btn.innerHTML = '📷 Камера';
}
}
console.log("[camera] closed");
updateResizerVisibility();
// Останавливаем мониторинг
stopStreamMonitoring();
}
@@ -139,7 +136,15 @@ export function initCamera(): void {
}
}
// Экспортируем для возможности ручного управления
// ============================================
// ПУБЛИЧНЫЕ ФУНКЦИИ (используются в других модулях)
// ============================================
/**
* Показать/скрыть перекрестие на видео
* @param show - показывать или скрыть
*/
// noinspection JSUnusedGlobalSymbols
export function showCrosshair(show: boolean = true): void {
const crosshairElem = document.getElementById("crosshair");
if (crosshairElem) {
@@ -147,9 +152,14 @@ export function showCrosshair(show: boolean = true): void {
}
}
/**
* Установить прозрачность перекрестия
* @param opacity - значение прозрачности (0-1)
*/
// noinspection JSUnusedGlobalSymbols
export function setCrosshairOpacity(opacity: string | number): void {
const crosshairElem = document.getElementById("crosshair");
if (crosshairElem) {
crosshairElem.style.opacity = String(opacity);
}
}
}

View File

@@ -1,140 +1,138 @@
// chart.js
export function drawChart(canvas: HTMLCanvasElement, history: number[]): void {
if (!canvas || !history?.length) return;
if (!canvas || !history?.length) return;
const rect = canvas.getBoundingClientRect();
const displayHeight = rect.height || 200;
const displayWidth = rect.width || 800;
const rect = canvas.getBoundingClientRect();
const displayHeight = rect.height || 200;
canvas.width = rect.width || 800;
canvas.height = displayHeight;
canvas.width = displayWidth;
canvas.height = displayHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const w = canvas.width;
const h = canvas.height;
const w = canvas.width;
const h = canvas.height;
const padLeft = 45;
const padRight = 15;
const padTop = 10;
const padBottom = 25;
const plotW = w - padLeft - padRight;
const plotH = h - padTop - padBottom;
const padLeft = 45;
const padRight = 15;
const padTop = 10;
const padBottom = 25;
const plotW = w - padLeft - padRight;
const plotH = h - padTop - padBottom;
// Фон
ctx.fillStyle = "#0a0f0a";
ctx.fillRect(0, 0, w, h);
// Фон
ctx.fillStyle = "#0a0f0a";
ctx.fillRect(0, 0, w, h);
// Сетка
const maxVal = 63;
const gridLines = 4;
// Сетка
const maxVal = 63;
const gridLines = 4;
ctx.strokeStyle = "#0d2a0d";
ctx.lineWidth = 0.5;
ctx.setLineDash([4, 4]);
ctx.strokeStyle = "#0d2a0d";
ctx.lineWidth = 0.5;
ctx.setLineDash([4, 4]);
for (let i = 0; i <= gridLines; i++) {
const val = (maxVal / gridLines) * i;
const y = padTop + plotH - (val / maxVal) * plotH;
for (let i = 0; i <= gridLines; i++) {
const val = (maxVal / gridLines) * i;
const y = padTop + plotH - (val / maxVal) * plotH;
ctx.beginPath();
ctx.moveTo(padLeft, y);
ctx.lineTo(w - padRight, y);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(padLeft, y);
ctx.lineTo(w - padRight, y);
ctx.stroke();
ctx.fillStyle = "#00ff88";
ctx.font = "11px monospace";
ctx.textAlign = "right";
ctx.fillText(Math.round(val).toString(), padLeft - 8, y + 4);
}
ctx.setLineDash([]);
// Подписи осей
ctx.fillStyle = "#00ff88";
ctx.font = "bold 11px monospace";
ctx.save();
ctx.translate(12, padTop + plotH / 2);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = "center";
ctx.fillText("ЗНАЧЕНИЕ", 0, 0);
ctx.restore();
ctx.textAlign = "center";
ctx.fillText("БУФЕР ПРИНЯТЫХ ДАННЫХ", padLeft + plotW / 2, h - 4);
const hasSignal = history.some(v => v > 0);
if (!hasSignal) {
ctx.fillStyle = "#ff4444";
ctx.font = "14px monospace";
ctx.textAlign = "center";
ctx.fillText("Ожидание сигнала...", w / 2, h / 2);
return;
}
let plotData = history;
if (history.length > plotW) {
const step = history.length / plotW;
plotData = [];
for (let i = 0; i < plotW; i++) {
const idx = Math.floor(i * step);
plotData.push(history[idx]);
ctx.fillStyle = "#00ff88";
ctx.font = "11px monospace";
ctx.textAlign = "right";
ctx.fillText(Math.round(val).toString(), padLeft - 8, y + 4);
}
}
ctx.strokeStyle = "#e6852d";
ctx.lineWidth = 1.5;
ctx.shadowColor = "#e6852d";
ctx.shadowBlur = 3;
ctx.beginPath();
const stepX = plotW / Math.max(plotData.length - 1, 1);
for (let i = 0; i < plotData.length; i++) {
const x = padLeft + i * stepX;
const y = padTop + plotH - (plotData[i] / maxVal) * plotH;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.shadowBlur = 0;
if (history.length > 0) {
const lastVal = history[history.length - 1];
const lastX = padLeft + (plotData.length - 1) * stepX;
const lastY = padTop + plotH - (lastVal / maxVal) * plotH;
ctx.fillStyle = "#c46b1f";
ctx.beginPath();
ctx.arc(lastX, lastY, 4, 0, Math.PI * 2);
ctx.fill();
const labelText = `${lastVal}`;
const labelWidth = ctx.measureText(labelText).width;
ctx.fillStyle = "rgba(0,0,0,0.8)";
ctx.fillRect(lastX - labelWidth / 2 - 4, lastY - 22, labelWidth + 8, 18);
ctx.setLineDash([]);
// Подписи осей
ctx.fillStyle = "#00ff88";
ctx.font = "bold 12px monospace";
ctx.font = "bold 11px monospace";
ctx.save();
ctx.translate(12, padTop + plotH / 2);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = "center";
ctx.fillText(labelText, lastX, lastY - 8);
ctx.fillText("ЗНАЧЕНИЕ", 0, 0);
ctx.restore();
const peak = Math.max(...history);
const min = Math.min(...history);
ctx.textAlign = "center";
ctx.fillText("БУФЕР ПРИНЯТЫХ ДАННЫХ", padLeft + plotW / 2, h - 4);
ctx.fillStyle = "rgba(0,0,0,0.7)";
ctx.fillRect(w - 120, padTop, 110, 42);
const hasSignal = history.some(v => v > 0);
ctx.fillStyle = "#00ff88";
ctx.font = "10px monospace";
ctx.textAlign = "left";
ctx.fillText(`Пик: ${peak}`, w - 110, padTop + 14);
ctx.fillText(`Мин: ${min}`, w - 110, padTop + 28);
ctx.fillText(`Точек: ${history.length}`, w - 110, padTop + 42);
}
if (!hasSignal) {
ctx.fillStyle = "#ff4444";
ctx.font = "14px monospace";
ctx.textAlign = "center";
ctx.fillText("Ожидание сигнала...", w / 2, h / 2);
return;
}
let plotData = history;
if (history.length > plotW) {
const step = history.length / plotW;
plotData = [];
for (let i = 0; i < plotW; i++) {
const idx = Math.floor(i * step);
plotData.push(history[idx]);
}
}
ctx.strokeStyle = "#e6852d";
ctx.lineWidth = 1.5;
ctx.shadowColor = "#e6852d";
ctx.shadowBlur = 3;
ctx.beginPath();
const stepX = plotW / Math.max(plotData.length - 1, 1);
for (let i = 0; i < plotData.length; i++) {
const x = padLeft + i * stepX;
const y = padTop + plotH - (plotData[i] / maxVal) * plotH;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.shadowBlur = 0;
if (history.length > 0) {
const lastVal = history[history.length - 1];
const lastX = padLeft + (plotData.length - 1) * stepX;
const lastY = padTop + plotH - (lastVal / maxVal) * plotH;
ctx.fillStyle = "#c46b1f";
ctx.beginPath();
ctx.arc(lastX, lastY, 4, 0, Math.PI * 2);
ctx.fill();
const labelText = `${lastVal}`;
const labelWidth = ctx.measureText(labelText).width;
ctx.fillStyle = "rgba(0,0,0,0.8)";
ctx.fillRect(lastX - labelWidth / 2 - 4, lastY - 22, labelWidth + 8, 18);
ctx.fillStyle = "#00ff88";
ctx.font = "bold 12px monospace";
ctx.textAlign = "center";
ctx.fillText(labelText, lastX, lastY - 8);
const peak = Math.max(...history);
const min = Math.min(...history);
ctx.fillStyle = "rgba(0,0,0,0.7)";
ctx.fillRect(w - 120, padTop, 110, 42);
ctx.fillStyle = "#00ff88";
ctx.font = "10px monospace";
ctx.textAlign = "left";
ctx.fillText(`Пик: ${peak}`, w - 110, padTop + 14);
ctx.fillText(`Мин: ${min}`, w - 110, padTop + 28);
ctx.fillText(`Точек: ${history.length}`, w - 110, padTop + 42);
}
}

View File

@@ -1,88 +1,236 @@
// layout.ts
let resizer: HTMLElement | null = null;
let leftPanel: HTMLElement | null = null;
let rightPanel: HTMLElement | null = null;
let dragBar: HTMLElement | null = null;
let isDragging: boolean = false;
let pendingX: number | null = null;
let isOpen: boolean = false;
let lastLeftWidth: number = 0;
let lastRightWidth: number = 0;
export function initResize(): void {
resizer = document.getElementById("dragBar");
leftPanel = document.getElementById("leftPanel");
rightPanel = document.getElementById("rightPanel");
dragBar = document.getElementById("dragBar");
if (!leftPanel || !rightPanel || !dragBar) return;
const savedRatio = localStorage.getItem('layoutRatio');
if (savedRatio) {
applyRatio(parseFloat(savedRatio));
if (!resizer || !leftPanel || !rightPanel) {
console.warn("[layout] Элементы не найдены");
return;
}
dragBar.addEventListener("mousedown", (e) => {
if (rightPanel!.classList.contains("hidden")) return;
// Проверяем видимость при инициализации
updateResizerVisibility();
let isDragging = false;
// Обработчик начала перетаскивания
resizer.addEventListener("mousedown", (e: MouseEvent) => {
// Сохраняем ссылки на элементы в локальные переменные
const left = leftPanel;
const right = rightPanel;
// Проверяем, что элементы существуют
if (!left || !right) {
console.warn("[layout] Панели не найдены");
return;
}
// Проверяем, что правая панель видима
if (right.classList.contains("hidden")) {
return;
}
isDragging = true;
document.body.style.cursor = "col-resize";
e.preventDefault();
});
document.body.style.userSelect = "none";
document.addEventListener("mouseup", () => {
if (!isDragging) return;
// Сохраняем начальные размеры
const startX = e.clientX;
const leftWidth = left.getBoundingClientRect().width;
const rightWidth = right.getBoundingClientRect().width;
const totalWidth = leftWidth + rightWidth;
isDragging = false;
document.body.style.cursor = "default";
function onMouseMove(moveEvent: MouseEvent): void {
if (!isDragging) return;
if (rightPanel && !rightPanel.classList.contains("hidden")) {
const total = window.innerWidth;
const leftWidth = leftPanel!.getBoundingClientRect().width;
localStorage.setItem('layoutRatio', String(leftWidth / total));
// Проверяем, что элементы всё ещё существуют
if (!left || !right) return;
const deltaX = moveEvent.clientX - startX;
let newLeftWidth = leftWidth + deltaX;
// Ограничиваем минимальные размеры
const minWidth = 200;
newLeftWidth = Math.max(minWidth, Math.min(totalWidth - minWidth, newLeftWidth));
const newRightWidth = totalWidth - newLeftWidth;
left.style.flex = `0 0 ${newLeftWidth}px`;
right.style.flex = `0 0 ${newRightWidth}px`;
// Сохраняем последние размеры
lastLeftWidth = newLeftWidth;
lastRightWidth = newRightWidth;
}
function onMouseUp(): void {
isDragging = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
}
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
});
document.addEventListener("mousemove", (e) => {
if (!isDragging) return;
if (rightPanel && rightPanel.classList.contains("hidden")) return;
pendingX = e.clientX;
requestAnimationFrame(() => {
if (pendingX === null) return;
if (!leftPanel || !rightPanel || !dragBar) return;
const total = window.innerWidth;
let leftWidth = pendingX;
const min = 300;
if (leftWidth < min) leftWidth = min;
if (leftWidth > total - min) leftWidth = total - min;
const rightWidth = total - leftWidth - dragBar.offsetWidth;
leftPanel.style.flex = `0 0 ${leftWidth}px`;
rightPanel.style.flex = `0 0 ${rightWidth}px`;
pendingX = null;
});
// Обработчик изменения размера окна
window.addEventListener("resize", () => {
handleWindowResize();
});
}
function applyRatio(ratio: number): void {
const total = window.innerWidth;
const leftWidth = Math.floor(total * ratio);
const rightWidth = total - leftWidth;
/**
* Обработка изменения размера окна
*/
function handleWindowResize(): void {
if (!leftPanel || !rightPanel) return;
if (leftPanel && rightPanel) {
leftPanel.style.flex = `0 0 ${leftWidth}px`;
rightPanel.style.flex = `0 0 ${rightWidth}px`;
// Если правая панель видима, пересчитываем размеры
if (!rightPanel.classList.contains("hidden")) {
// Если есть сохраненные размеры, используем их пропорционально
if (lastLeftWidth > 0 && lastRightWidth > 0) {
const totalWidth = window.innerWidth;
const ratio = lastLeftWidth / (lastLeftWidth + lastRightWidth);
const newLeftWidth = totalWidth * ratio;
const newRightWidth = totalWidth - newLeftWidth;
// Ограничиваем минимальные размеры
const minWidth = 200;
const finalLeftWidth = Math.max(minWidth, Math.min(totalWidth - minWidth, newLeftWidth));
const finalRightWidth = totalWidth - finalLeftWidth;
leftPanel.style.flex = `0 0 ${finalLeftWidth}px`;
rightPanel.style.flex = `0 0 ${finalRightWidth}px`;
lastLeftWidth = finalLeftWidth;
lastRightWidth = finalRightWidth;
} else {
// Если нет сохраненных размеров, делим пополам
const totalWidth = window.innerWidth;
const halfWidth = totalWidth / 2;
leftPanel.style.flex = `0 0 ${halfWidth}px`;
rightPanel.style.flex = `0 0 ${halfWidth}px`;
lastLeftWidth = halfWidth;
lastRightWidth = halfWidth;
}
}
}
/**
* Обновление видимости разделителя в зависимости от состояния правой панели
*/
export function updateResizerVisibility(): void {
if (!dragBar || !rightPanel || !leftPanel) return;
if (!resizer || !rightPanel) return;
// Если правая панель скрыта - скрываем разделитель
if (rightPanel.classList.contains("hidden")) {
resizer.classList.add("hidden");
} else {
resizer.classList.remove("hidden");
}
}
/**
* Открыть правую панель
*/
export function openRightPanel(): void {
if (!leftPanel || !rightPanel) return;
// Убираем класс hidden
rightPanel.classList.remove("hidden");
// Обновляем разделитель
updateResizerVisibility();
// Устанавливаем размеры
const totalWidth = window.innerWidth;
const halfWidth = totalWidth / 2;
// Проверяем, есть ли сохраненные размеры
if (lastLeftWidth > 0 && lastRightWidth > 0) {
// Используем сохраненные пропорции
const ratio = lastLeftWidth / (lastLeftWidth + lastRightWidth);
const newLeftWidth = totalWidth * ratio;
const newRightWidth = totalWidth - newLeftWidth;
const minWidth = 200;
const finalLeftWidth = Math.max(minWidth, Math.min(totalWidth - minWidth, newLeftWidth));
const finalRightWidth = totalWidth - finalLeftWidth;
leftPanel.style.flex = `0 0 ${finalLeftWidth}px`;
rightPanel.style.flex = `0 0 ${finalRightWidth}px`;
lastLeftWidth = finalLeftWidth;
lastRightWidth = finalRightWidth;
} else {
// Делим пополам
leftPanel.style.flex = `0 0 ${halfWidth}px`;
rightPanel.style.flex = `0 0 ${halfWidth}px`;
lastLeftWidth = halfWidth;
lastRightWidth = halfWidth;
}
}
/**
* Закрыть правую панель
*/
export function closeRightPanel(): void {
if (!rightPanel) return;
// Добавляем класс hidden
rightPanel.classList.add("hidden");
// Обновляем разделитель
updateResizerVisibility();
// Сбрасываем flex для левой панели
if (leftPanel) {
leftPanel.style.flex = "1 1 auto";
}
}
// ============================================
// ПУБЛИЧНЫЕ ФУНКЦИИ (используются в других модулях)
// ============================================
/**
* Установить видимость разделителя вручную
* @param visible - показывать или скрыть
*/
// noinspection JSUnusedGlobalSymbols
export function setResizerVisible(visible: boolean): void {
if (resizer) {
if (visible) {
resizer.classList.remove("hidden");
} else {
resizer.classList.add("hidden");
}
}
}
/**
* Переключить состояние правой панели
*/
// noinspection JSUnusedGlobalSymbols
export function toggleRightPanel(): void {
if (!rightPanel) return;
if (rightPanel.classList.contains("hidden")) {
dragBar.classList.add("hidden");
leftPanel.style.flex = "1";
openRightPanel();
} else {
dragBar.classList.remove("hidden");
closeRightPanel();
}
}
}

View File

@@ -1,192 +1,243 @@
// state.ts - Peak Holder алгоритм с таймером
interface AppState {
maxPoints: number;
signalHistory: number[];
lastBufferSize: number;
maxPoints: number;
signalHistory: number[];
lastBufferSize: number;
// Peak Holder
peakValue: number; // Текущее удерживаемое пиковое значение амплитуды
currentValue: number; // Текущее реальное значение амплитуды
peakTimer: number | null; // Таймер для сброса пика амплитуды
peakHoldTime: number; // Время удержания пика в миллисекундах
// Peak Holder
peakValue: number; // Текущее удерживаемое пиковое значение амплитуды
currentValue: number; // Текущее реальное значение амплитуды
peakTimer: number | null; // Таймер для сброса пика амплитуды
peakHoldTime: number; // Время удержания пика в миллисекундах
// Peak Holder для уровня сигнала
peakSignalLevel: number;
currentSignalLevel: number;
signalPeakTimer: number | null;
signalPeakHoldTime: number;
// Peak Holder для уровня сигнала
peakSignalLevel: number;
currentSignalLevel: number;
signalPeakTimer: number | null;
signalPeakHoldTime: number;
hasPeak: boolean;
hasPeak: boolean;
}
type PeakHolderListener = () => void;
export const state: AppState = {
maxPoints: 1000,
signalHistory: [],
lastBufferSize: 0,
maxPoints: 1000,
signalHistory: [],
lastBufferSize: 0,
peakValue: 0,
currentValue: 0,
peakTimer: null,
peakHoldTime: 3000, // 3 секунды
peakValue: 0,
currentValue: 0,
peakTimer: null,
peakHoldTime: 3000, // 3 секунды
peakSignalLevel: 0,
currentSignalLevel: 0,
signalPeakTimer: null,
signalPeakHoldTime: 3000,
peakSignalLevel: 0,
currentSignalLevel: 0,
signalPeakTimer: null,
signalPeakHoldTime: 3000,
hasPeak: false,
hasPeak: false,
};
let peakHolderListener: PeakHolderListener | null = null;
/** Обновление UI при сбросе peak holder по таймеру (между опросами API). */
export function setPeakHolderListener(listener: PeakHolderListener | null): void {
peakHolderListener = listener;
// ============================================
// ПУБЛИЧНЫЕ ФУНКЦИИ
// ============================================
/**
* Установка времени удержания пика сигнала
* @param ms - время в миллисекундах
*/
// noinspection JSUnusedGlobalSymbols
export function setSignalPeakHoldTime(ms: number): void {
state.signalPeakHoldTime = ms;
}
/**
* Получение информации о пике сигнала
* @returns { peak: number; current: number; timerActive: boolean }
*/
// noinspection JSUnusedGlobalSymbols
export function getSignalPeakInfo(): { peak: number; current: number; timerActive: boolean } {
return {
peak: state.peakSignalLevel,
current: state.currentSignalLevel,
timerActive: state.signalPeakTimer !== null,
};
}
/**
* Добавление нового значения сигнала в историю
* @param value - значение сигнала
*/
// noinspection JSUnusedGlobalSymbols
export function addSignal(value: number): void {
state.signalHistory.push(value);
if (state.signalHistory.length > state.maxPoints) {
state.signalHistory = state.signalHistory.slice(-state.maxPoints);
}
}
/**
* Установка слушателя для обновления пика
* @param listener - функция-слушатель
*/
// noinspection JSUnusedGlobalSymbols
export function setPeakHolderListener(listener: PeakHolderListener | null): void {
peakHolderListener = listener;
}
/**
* Обновление пикового значения амплитуды
* @param target - текущее значение
* @returns пиковое значение
*/
// noinspection JSUnusedGlobalSymbols
export function updatePeakHolder(target: number): number {
// Обновляем текущее значение
state.currentValue = target;
// Если новое значение больше текущего пика
if (target > state.peakValue) {
// Устанавливаем новый пик
state.peakValue = target;
state.hasPeak = true;
scheduleAmplitudePeakRelease();
} else if (target < state.peakValue && state.peakTimer === null) {
// Пик выше текущего значения, но таймер пропал — перезапускаем удержание
scheduleAmplitudePeakRelease();
} // Возвращаем текущий пик
return state.peakValue;
}
/**
* Обновление пикового уровня сигнала
* @param level - текущий уровень
* @returns пиковый уровень
*/
// noinspection JSUnusedGlobalSymbols
export function updateSignalPeakHolder(level: number): number {
// Обновляем текущее значение
state.currentSignalLevel = level;
// Если новый уровень больше пика — устанавливаем новый пик
if (level > state.peakSignalLevel) {
state.peakSignalLevel = level;
state.hasPeak = true;
scheduleSignalPeakRelease();
} else if (level < state.peakSignalLevel && state.signalPeakTimer === null) {
scheduleSignalPeakRelease();
}
// Возвращаем текущий пик
return state.peakSignalLevel;
}
/**
* Принудительный сброс пика сигнала
*/
// noinspection JSUnusedGlobalSymbols
export function forceResetSignalPeak(): void {
if (state.signalPeakTimer !== null) {
clearTimeout(state.signalPeakTimer);
state.signalPeakTimer = null;
}
state.peakSignalLevel = 0;
state.currentSignalLevel = 0;
state.hasPeak = false;
}
/**
* Принудительный сброс всех пиков
*/
// noinspection JSUnusedGlobalSymbols
export function forceResetPeak(): void {
if (state.peakTimer !== null) {
clearTimeout(state.peakTimer);
state.peakTimer = null;
}
state.peakValue = 0;
state.currentValue = 0;
state.hasPeak = false;
forceResetSignalPeak();
}
/**
* Добавление массива значений сигнала
* @param values - массив значений
* @param bufferSize - размер буфера
*/
// noinspection JSUnusedGlobalSymbols
export function addSignals(values: number[], bufferSize: number): void {
if (!Array.isArray(values) || values.length === 0) return;
const newBytes = bufferSize - state.lastBufferSize;
if (newBytes > 0 && newBytes <= values.length) {
const fresh = values.slice(-newBytes);
const decoded = fresh.map(b => b & 0x3F);
state.signalHistory.push(...decoded);
}
state.lastBufferSize = bufferSize;
if (state.signalHistory.length > state.maxPoints) {
state.signalHistory = state.signalHistory.slice(-state.maxPoints);
}
}
/**
* Сброс отслеживания буфера
*/
// noinspection JSUnusedGlobalSymbols
export function resetBufferTracking(): void {
state.lastBufferSize = 0;
forceResetPeak();
}
// ============================================
// ВНУТРЕННИЕ ФУНКЦИИ
// ============================================
function notifyPeakHolderChange(): void {
if (peakHolderListener) {
peakHolderListener();
}
if (peakHolderListener) {
peakHolderListener();
}
}
function scheduleAmplitudePeakRelease(): void {
// Сбрасываем старый таймер
if (state.peakTimer !== null) {
clearTimeout(state.peakTimer);
state.peakTimer = null;
}
// Сбрасываем старый таймер
if (state.peakTimer !== null) {
clearTimeout(state.peakTimer);
state.peakTimer = null;
}
// Запускаем новый таймер на 3 секунды
state.peakTimer = window.setTimeout(() => {
state.peakValue = state.currentValue;
state.peakTimer = null;
state.hasPeak = false;
console.log(`[Peak] Сброс пика до: ${state.peakValue}`);
notifyPeakHolderChange();
}, state.peakHoldTime);
// Запускаем новый таймер на 3 секунды
state.peakTimer = window.setTimeout(() => {
state.peakValue = state.currentValue;
state.peakTimer = null;
state.hasPeak = false;
console.log(`[Peak] Сброс пика до: ${state.peakValue}`);
notifyPeakHolderChange();
}, state.peakHoldTime);
}
function scheduleSignalPeakRelease(): void {
// Сбрасываем старый таймер
if (state.signalPeakTimer !== null) {
clearTimeout(state.signalPeakTimer);
state.signalPeakTimer = null;
}
// Сбрасываем старый таймер
if (state.signalPeakTimer !== null) {
clearTimeout(state.signalPeakTimer);
state.signalPeakTimer = null;
}
// Запускаем новый таймер на 3 секунды
state.signalPeakTimer = window.setTimeout(() => {
// Сбрасываем пик к текущему уровню
state.peakSignalLevel = state.currentSignalLevel;
state.signalPeakTimer = null;
state.hasPeak = false;
console.log(`[SignalPeak] Сброс пика до: ${state.peakSignalLevel}`);
notifyPeakHolderChange();
}, state.signalPeakHoldTime);
}
// ========== ФУНКЦИИ ДЛЯ АМПЛИТУДЫ ==========
export function updatePeakHolder(target: number): number {
// Обновляем текущее значение
state.currentValue = target;
// Если новое значение больше текущего пика
if (target > state.peakValue) {
// Устанавливаем новый пик
state.peakValue = target;
state.hasPeak = true;
scheduleAmplitudePeakRelease();
} else if (target < state.peakValue && state.peakTimer === null) {
// Пик выше текущего значения, но таймер пропал — перезапускаем удержание
scheduleAmplitudePeakRelease();
} // Возвращаем текущий пик
return state.peakValue;
}
// ========== ФУНКЦИИ ДЛЯ УРОВНЯ СИГНАЛА ==========
export function updateSignalPeakHolder(level: number): number {
// Обновляем текущее значение
state.currentSignalLevel = level;
// Если новый уровень больше пика — устанавливаем новый пик
if (level > state.peakSignalLevel) {
state.peakSignalLevel = level;
state.hasPeak = true;
scheduleSignalPeakRelease();
} else if (level < state.peakSignalLevel && state.signalPeakTimer === null) {
scheduleSignalPeakRelease();
}
// Возвращаем текущий пик
return state.peakSignalLevel;
}
// ========== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ==========
export function forceResetSignalPeak(): void {
if (state.signalPeakTimer !== null) {
clearTimeout(state.signalPeakTimer);
state.signalPeakTimer = null;
}
state.peakSignalLevel = 0;
state.currentSignalLevel = 0;
state.hasPeak = false;
}
export function setSignalPeakHoldTime(ms: number): void {
state.signalPeakHoldTime = ms;
}
export function getSignalPeakInfo(): { peak: number; current: number; timerActive: boolean } {
return {
peak: state.peakSignalLevel,
current: state.currentSignalLevel,
timerActive: state.signalPeakTimer !== null,
};
}
export function forceResetPeak(): void {
if (state.peakTimer !== null) {
clearTimeout(state.peakTimer);
state.peakTimer = null;
}
state.peakValue = 0;
state.currentValue = 0;
state.hasPeak = false;
forceResetSignalPeak();
}
export function addSignal(value: number): void {
state.signalHistory.push(value);
if (state.signalHistory.length > state.maxPoints) {
state.signalHistory = state.signalHistory.slice(-state.maxPoints);
}
}
export function addSignals(values: number[], bufferSize: number): void {
if (!Array.isArray(values) || values.length === 0) return;
const newBytes = bufferSize - state.lastBufferSize;
if (newBytes > 0 && newBytes <= values.length) {
const fresh = values.slice(-newBytes);
const decoded = fresh.map(b => b & 0x3F);
state.signalHistory.push(...decoded);
}
state.lastBufferSize = bufferSize;
if (state.signalHistory.length > state.maxPoints) {
state.signalHistory = state.signalHistory.slice(-state.maxPoints);
}
}
export function resetBufferTracking(): void {
state.lastBufferSize = 0;
forceResetPeak();
}
// Запускаем новый таймер на 3 секунды
state.signalPeakTimer = window.setTimeout(() => {
// Сбрасываем пик к текущему уровню
state.peakSignalLevel = state.currentSignalLevel;
state.signalPeakTimer = null;
state.hasPeak = false;
console.log(`[SignalPeak] Сброс пика до: ${state.peakSignalLevel}`);
notifyPeakHolderChange();
}, state.signalPeakHoldTime);
}

View File

@@ -166,7 +166,6 @@
<div class="logs-content">
<div class="tabs">
<button class="tab-btn active" data-tab="data">📊 Данные</button>
<button class="tab-btn" data-tab="stats">📈 Статистика</button>
<button class="tab-btn" data-tab="events">📋 События</button>
</div>
@@ -178,10 +177,6 @@
<div id="dataTableContainer" style="max-height:400px; overflow-y:auto;"></div>
</div>
<div id="tab-stats" class="tab-content">
<div id="statsContent">Выберите файл для просмотра статистики</div>
</div>
<div id="tab-events" class="tab-content">
<div id="eventsContent" class="events-list">Загрузка...</div>
</div>
@@ -262,25 +257,29 @@ function renderData(data) {
// Статистика
if (samples.length > 0) {
const values = samples.map(s => s.value);
const counts = samples.map(s => s.count);
const max = Math.max(...values);
const min = Math.min(...values);
const avg = values.reduce((a,b) => a + b, 0) / values.length;
const totalObjects = counts.reduce((a,b) => a + b, 0);
const totalPoints = samples.length;
// Амплитуда > 0 (старшие 2 бита)
const amplitudeOver0Count = samples.filter(s => s.amplitude > 0).length;
// Сигнал > 10 (младшие 6 бит)
const signalOver10Count = samples.filter(s => s.signal > 10).length;
// Общее количество объектов (сумма всех сигналов)
const totalObjects = samples.reduce((sum, s) => sum + s.signal, 0);
logStats.innerHTML = `
<div class="log-stat-card">
<div class="value">${samples.length}</div>
<div class="value">${totalPoints}</div>
<div class="label">Всего точек</div>
</div>
<div class="log-stat-card">
<div class="value">${max}</div>
<div class="label">Максимум</div>
<div class="value">${amplitudeOver0Count}</div>
<div class="label">Амплитуда > 0</div>
</div>
<div class="log-stat-card">
<div class="value">${avg.toFixed(1)}</div>
<div class="label">Среднее</div>
<div class="value">${signalOver10Count}</div>
<div class="label">Сигнал > 10</div>
</div>
<div class="log-stat-card">
<div class="value">${totalObjects}</div>
@@ -291,29 +290,35 @@ function renderData(data) {
// График
if (logChart && samples.length > 0) {
const history = samples.map(s => s.count);
const history = samples.map(s => s.signal); // Используем signal вместо count
drawChart(logChart, history);
}
// Таблица — только Время, Объектов, Сила
// Таблица - убрали колонку "Объектов"
if (samples.length > 0) {
let html = `<table class="data-table">
<thead>
<tr>
<th>Время</th>
<th>Объектов</th>
<th>Сила</th>
<th>Амплитуда</th>
<th>Сигнал</th>
</tr>
</thead>
<tbody>`;
const displaySamples = samples.slice(-200);
displaySamples.forEach(s => {
const strengthClass = `strength-${s.strength}`;
// Цвет в зависимости от амплитуды
let ampClass = '';
if (s.amplitude === 0) ampClass = 'strength-0';
else if (s.amplitude === 1) ampClass = 'strength-1';
else if (s.amplitude === 2) ampClass = 'strength-2';
else if (s.amplitude === 3) ampClass = 'strength-3';
html += `<tr>
<td>${s.time}</td>
<td>${s.count}</td>
<td class="${strengthClass}">${s.strength_name}</td>
<td class="${ampClass}">${s.amplitude}</td>
<td>${s.signal}</td>
</tr>`;
});
@@ -360,10 +365,17 @@ document.querySelectorAll('.tab-btn[data-tab]').forEach(btn => {
// Инициализация
loadFileList();
// Обновление времени
function updateTime() {
const now = new Date();
document.getElementById('server-time').textContent = now.toLocaleTimeString();
// Обновление времени с сервера (единый формат)
async function updateTime() {
try {
const response = await fetch('/api/health');
const data = await response.json();
document.getElementById('server-time').textContent = data.server_time || new Date().toLocaleTimeString();
} catch (error) {
// Если сервер недоступен, используем локальное время
const now = new Date();
document.getElementById('server-time').textContent = now.toLocaleTimeString();
}
}
setInterval(updateTime, 1000);
updateTime();