605 lines
27 KiB
HTML
605 lines
27 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset='UTF-8'>
|
||
<meta name='viewport' content='width=device-width, initial-scale=1.0, user-scalable=no'>
|
||
<title>Logic Analyzer - Ring Buffer Waveform</title>
|
||
<style>
|
||
* {
|
||
box-sizing: border-box;
|
||
user-select: none;
|
||
}
|
||
|
||
body {
|
||
background: #0a0f1e;
|
||
font-family: 'Segoe UI', 'Roboto', 'Consolas', monospace;
|
||
margin: 0;
|
||
min-height: 100dvh;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
padding: 20px;
|
||
}
|
||
|
||
main {
|
||
background: #11161f;
|
||
border-radius: 28px;
|
||
box-shadow: 0 20px 35px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.05);
|
||
padding: 20px 24px 24px 24px;
|
||
backdrop-filter: blur(2px);
|
||
}
|
||
|
||
.canvas-container {
|
||
background: #010101;
|
||
border-radius: 16px;
|
||
padding: 8px;
|
||
box-shadow: inset 0 0 8px #00000055, 0 8px 20px rgba(0,0,0,0.3);
|
||
}
|
||
|
||
canvas {
|
||
display: block;
|
||
width: 100%;
|
||
height: auto;
|
||
background: #0b0e14;
|
||
border-radius: 12px;
|
||
cursor: crosshair;
|
||
}
|
||
|
||
.info-panel {
|
||
margin-top: 20px;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 18px;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.status-card {
|
||
background: #1e2533;
|
||
padding: 8px 18px;
|
||
border-radius: 60px;
|
||
font-size: 0.85rem;
|
||
font-weight: 500;
|
||
color: #b9e6ff;
|
||
letter-spacing: 0.5px;
|
||
box-shadow: inset 0 1px 0 #2f3a4a, 0 4px 8px #00000030;
|
||
}
|
||
|
||
.ring-stats {
|
||
background: #151e2a;
|
||
border-radius: 20px;
|
||
padding: 6px 18px;
|
||
font-family: monospace;
|
||
font-weight: bold;
|
||
color: #7ee0ff;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.legend {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 16px;
|
||
background: #0f141fcc;
|
||
backdrop-filter: blur(4px);
|
||
border-radius: 24px;
|
||
padding: 8px 20px;
|
||
}
|
||
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
color: #cdddf7;
|
||
}
|
||
|
||
.legend-color {
|
||
width: 18px;
|
||
height: 12px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.sample-badge {
|
||
background: #00000066;
|
||
border-radius: 14px;
|
||
padding: 4px 12px;
|
||
font-family: monospace;
|
||
font-size: 0.8rem;
|
||
color: #9cf2ff;
|
||
}
|
||
|
||
button {
|
||
background: #2a3b4e;
|
||
border: none;
|
||
border-radius: 40px;
|
||
padding: 6px 14px;
|
||
color: white;
|
||
font-weight: bold;
|
||
font-size: 0.7rem;
|
||
cursor: pointer;
|
||
transition: 0.2s;
|
||
box-shadow: 0 2px 5px black;
|
||
}
|
||
|
||
button:active {
|
||
transform: scale(0.96);
|
||
}
|
||
|
||
@media (max-width: 700px) {
|
||
main { padding: 14px; }
|
||
.legend-item { font-size: 0.65rem; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<div class="canvas-container">
|
||
<canvas id="logicCanvas" width="1000" height="500" style="width:100%; height:auto; max-width:1000px; aspect-ratio:1000/500"></canvas>
|
||
</div>
|
||
<div class="info-panel">
|
||
<div class="status-card" id="wsStatus">⚡ Ожидание подключения</div>
|
||
<div class="ring-stats" id="ringStats">🔔 буфер: 0 / 0 семплов</div>
|
||
<div class="legend" id="channelLegend"></div>
|
||
<div class="sample-badge" id="sampleCounter">📊 приём: --</div>
|
||
<button id="clearBtn" title="Сбросить буфер (очистить историю)">⟳ Очистить</button>
|
||
</div>
|
||
</main>
|
||
|
||
<script>
|
||
(function(){
|
||
// ---------- КОНФИГУРАЦИЯ ----------
|
||
const MAX_RING_SIZE = 2048; // максимальная длина кольцевого буфера (глубина истории)
|
||
let ringBuffer = new Array(MAX_RING_SIZE).fill(0); // храним 8-битные sample (целые 0..255)
|
||
let ringHead = 0; // следующая позиция для записи
|
||
let ringCount = 0; // сколько реально семплов в буфере (0..MAX_RING_SIZE)
|
||
|
||
// Параметры отображения (каналы: 0...7)
|
||
const TOTAL_CHANNELS = 8;
|
||
const CHANNEL_NAMES = ['CH0', 'CH1', 'CH2', 'CH3', 'CH4', 'CH5', 'CH6', 'CH7'];
|
||
// Цветовая палитра (яркие, различимые на тёмном фоне)
|
||
const CHANNEL_COLORS = [
|
||
'#FFB86C', '#50FA7B', '#FF5555', '#8BE9FD',
|
||
'#F1FA8C', '#BD93F9', '#FF79C6', '#6BE0D9'
|
||
];
|
||
|
||
let canvas = document.getElementById('logicCanvas');
|
||
let ctx = canvas.getContext('2d');
|
||
let animationFrameId = null;
|
||
let lastRenderedCount = -1; // для оптимизации, перерисовка только при изменении
|
||
|
||
// WebSocket
|
||
let ws = null;
|
||
let connectionAttempts = 0;
|
||
|
||
// Элементы UI
|
||
const wsStatusDiv = document.getElementById('wsStatus');
|
||
const ringStatsSpan = document.getElementById('ringStats');
|
||
const sampleCounterSpan = document.getElementById('sampleCounter');
|
||
const clearBtn = document.getElementById('clearBtn');
|
||
const legendContainer = document.getElementById('channelLegend');
|
||
|
||
// ---- Построение легенды каналов ----
|
||
function buildLegend() {
|
||
legendContainer.innerHTML = '';
|
||
for (let ch = 0; ch < TOTAL_CHANNELS; ch++) {
|
||
const item = document.createElement('div');
|
||
item.className = 'legend-item';
|
||
const colorBox = document.createElement('div');
|
||
colorBox.className = 'legend-color';
|
||
colorBox.style.backgroundColor = CHANNEL_COLORS[ch % CHANNEL_COLORS.length];
|
||
const label = document.createElement('span');
|
||
label.innerText = `${CHANNEL_NAMES[ch]}`;
|
||
item.appendChild(colorBox);
|
||
item.appendChild(label);
|
||
legendContainer.appendChild(item);
|
||
}
|
||
}
|
||
|
||
// ---- Управление кольцевым буфером ----
|
||
function pushSample(sampleValue) {
|
||
// sampleValue: число 0..255 (8 бит)
|
||
if (typeof sampleValue !== 'number' || isNaN(sampleValue)) return;
|
||
let val = Math.min(255, Math.max(0, Math.floor(sampleValue)));
|
||
ringBuffer[ringHead] = val;
|
||
ringHead = (ringHead + 1) % MAX_RING_SIZE;
|
||
if (ringCount < MAX_RING_SIZE) {
|
||
ringCount++;
|
||
}
|
||
// триггерим перерисовку при новом данных (асинхронно)
|
||
scheduleRender();
|
||
}
|
||
|
||
// Очистка буфера (сброс истории)
|
||
function clearRingBuffer() {
|
||
ringHead = 0;
|
||
ringCount = 0;
|
||
// затирать массив необязательно, но для эстетики заполним нулями
|
||
for (let i = 0; i < MAX_RING_SIZE; i++) ringBuffer[i] = 0;
|
||
scheduleRender();
|
||
updateStatsUI();
|
||
}
|
||
|
||
// Получить массив семплов в порядке от старых к новым (для отрисовки)
|
||
function getOrderedSamples() {
|
||
if (ringCount === 0) return [];
|
||
const result = new Array(ringCount);
|
||
const startIdx = (ringHead - ringCount + MAX_RING_SIZE) % MAX_RING_SIZE;
|
||
for (let i = 0; i < ringCount; i++) {
|
||
result[i] = ringBuffer[(startIdx + i) % MAX_RING_SIZE];
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// ---- ОТРИСОВКА ВСЕХ ВОЛНОВЫХ ФОРМ (логический анализатор) ----
|
||
// рисуем все 8 каналов, каждый бит - цифровой сигнал (0/1)
|
||
function drawWaveforms() {
|
||
if (!canvas || !ctx) return;
|
||
const samplesArray = getOrderedSamples();
|
||
const sampleCount = samplesArray.length;
|
||
if (sampleCount === 0) {
|
||
// пустой буфер: рисуем тёмный фон и надпись
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
ctx.fillStyle = '#0b0e14';
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
ctx.fillStyle = '#6c7a8e';
|
||
ctx.font = '14px "Segoe UI", monospace';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('⏳ Ожидание данных...', canvas.width/2, canvas.height/2);
|
||
ctx.textAlign = 'left';
|
||
return;
|
||
}
|
||
|
||
// Настройки отрисовки
|
||
const w = canvas.width;
|
||
const h = canvas.height;
|
||
const channelHeight = h / TOTAL_CHANNELS; // высота на один канал
|
||
const paddingTop = 4;
|
||
const effectiveChannelH = channelHeight - 2; // небольшой отступ между каналами
|
||
|
||
ctx.clearRect(0, 0, w, h);
|
||
// фон сетки (очень тёмный)
|
||
ctx.fillStyle = '#0a0d14';
|
||
ctx.fillRect(0, 0, w, h);
|
||
|
||
// Рисуем горизонтальные разделители и подписи каналов
|
||
for (let ch = 0; ch < TOTAL_CHANNELS; ch++) {
|
||
const yTop = ch * channelHeight;
|
||
const yMid = yTop + channelHeight/2;
|
||
// линия разделителя (тонкая)
|
||
ctx.beginPath();
|
||
ctx.strokeStyle = '#2a3343';
|
||
ctx.lineWidth = 0.8;
|
||
ctx.setLineDash([4, 6]);
|
||
ctx.moveTo(0, yTop + channelHeight);
|
||
ctx.lineTo(w, yTop + channelHeight);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
// подпись канала слева
|
||
ctx.fillStyle = CHANNEL_COLORS[ch % CHANNEL_COLORS.length];
|
||
ctx.font = 'bold 12px "JetBrains Mono", monospace';
|
||
ctx.shadowBlur = 0;
|
||
ctx.fillText(CHANNEL_NAMES[ch], 8, yMid + 4);
|
||
// фоновая область канала (лёгкая подсветка)
|
||
ctx.fillStyle = '#10141e';
|
||
ctx.fillRect(0, yTop + 1, w, channelHeight - 1);
|
||
}
|
||
|
||
// --- Рисуем сигналы для каждого канала ---
|
||
// Используем линейную интерполяцию/ступенчатый вид: рисуем линии по точкам (x0,y0) -> (x1, y1)
|
||
// Горизонтальная шкала: ширина одного семпла = w / sampleCount
|
||
const stepX = w / sampleCount;
|
||
if (stepX < 0.5) {
|
||
// при огромном количестве точек можно упростить, но всё равно корректно отрисуем линии, даже если шаг меньше пикселя
|
||
// сохраняем точность - canvas сгладит
|
||
}
|
||
|
||
for (let ch = 0; ch < TOTAL_CHANNELS; ch++) {
|
||
const yBase = ch * channelHeight;
|
||
const yLow = yBase + channelHeight - 6; // уровень логического 0 (нижняя часть)
|
||
const yHigh = yBase + 6; // уровень логической 1 (верх)
|
||
// более чёткие границы: для наглядности опускаем 0 почти к низу, 1 к верху канала
|
||
const zeroLevel = yBase + channelHeight - 4;
|
||
const oneLevel = yBase + 6;
|
||
|
||
ctx.beginPath();
|
||
ctx.lineWidth = 2.2;
|
||
ctx.strokeStyle = CHANNEL_COLORS[ch % CHANNEL_COLORS.length];
|
||
ctx.lineJoin = 'round';
|
||
ctx.lineCap = 'round';
|
||
|
||
let prevX = 0;
|
||
// берем первый sample, получаем бит для канала ch
|
||
let prevBit = (samplesArray[0] >> ch) & 1;
|
||
let prevY = prevBit === 1 ? oneLevel : zeroLevel;
|
||
ctx.moveTo(0, prevY);
|
||
|
||
for (let i = 1; i < sampleCount; i++) {
|
||
const sampleVal = samplesArray[i];
|
||
const bit = (sampleVal >> ch) & 1;
|
||
const currentX = i * stepX;
|
||
const currentY = bit === 1 ? oneLevel : zeroLevel;
|
||
|
||
// если значение бита изменилось — рисуем вертикальный переход
|
||
if (bit !== prevBit) {
|
||
// сначала линия до той же X, но с предыдущим Y (горизонтальная)
|
||
ctx.lineTo(currentX, prevY);
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.moveTo(currentX, prevY);
|
||
ctx.lineTo(currentX, currentY);
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.moveTo(currentX, currentY);
|
||
prevBit = bit;
|
||
prevY = currentY;
|
||
} else {
|
||
// горизонтальный сегмент
|
||
ctx.lineTo(currentX, currentY);
|
||
}
|
||
}
|
||
// финальный штрих до конца
|
||
const lastX = w;
|
||
ctx.lineTo(lastX, prevY);
|
||
ctx.stroke();
|
||
|
||
// небольшая заливка для области нуля/единицы (дополнительно полупрозрачная тень)
|
||
ctx.save();
|
||
ctx.globalAlpha = 0.12;
|
||
ctx.fillStyle = CHANNEL_COLORS[ch % CHANNEL_COLORS.length];
|
||
for (let seg = 0; seg < sampleCount-1; seg++) {
|
||
const bitSeg = (samplesArray[seg] >> ch) & 1;
|
||
if (bitSeg === 1) {
|
||
const segX1 = seg * stepX;
|
||
const segX2 = (seg+1) * stepX;
|
||
ctx.fillRect(segX1, yBase+2, segX2-segX1, channelHeight-4);
|
||
}
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
// сетка вертикальных тактов (опционально каждые 8/16/32 семпла)
|
||
ctx.beginPath();
|
||
ctx.strokeStyle = '#2e3b4e';
|
||
ctx.lineWidth = 0.6;
|
||
ctx.setLineDash([5, 8]);
|
||
const gridSpacing = Math.max(16, Math.floor(sampleCount / 20));
|
||
for (let i = 0; i <= sampleCount; i += gridSpacing) {
|
||
const xPos = i * stepX;
|
||
if (xPos <= w) {
|
||
ctx.moveTo(xPos, 0);
|
||
ctx.lineTo(xPos, h);
|
||
}
|
||
}
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
|
||
// отрисовка текста с числом семплов поверх (информативно)
|
||
ctx.font = '10px "Fira Code", monospace';
|
||
ctx.fillStyle = '#b9d9ff';
|
||
ctx.shadowBlur = 0;
|
||
ctx.fillText(`${sampleCount} выборок`, w - 70, 18);
|
||
}
|
||
|
||
let renderScheduled = false;
|
||
function scheduleRender() {
|
||
if (renderScheduled) return;
|
||
renderScheduled = true;
|
||
requestAnimationFrame(() => {
|
||
drawWaveforms();
|
||
updateStatsUI(); // обновляем счетчики буфера и приёма
|
||
renderScheduled = false;
|
||
});
|
||
}
|
||
|
||
// обновление UI статистики (размер буфера, кол-во семплов)
|
||
function updateStatsUI() {
|
||
if (ringStatsSpan) {
|
||
ringStatsSpan.innerHTML = `💾 кольц. буфер: ${ringCount} / ${MAX_RING_SIZE} семпл`;
|
||
}
|
||
if (sampleCounterSpan) {
|
||
sampleCounterSpan.innerHTML = `📈 последний пакет: ${ringCount > 0 ? ringCount : '—'}`;
|
||
}
|
||
}
|
||
|
||
// обновление статуса WebSocket
|
||
function updateStatus(message, isConnected = false) {
|
||
if (wsStatusDiv) {
|
||
wsStatusDiv.innerHTML = `🔌 ${message}`;
|
||
if (isConnected) {
|
||
wsStatusDiv.style.background = '#1a4731';
|
||
wsStatusDiv.style.color = '#b4ffcf';
|
||
} else {
|
||
wsStatusDiv.style.background = '#3d2a2a';
|
||
wsStatusDiv.style.color = '#ffbc9a';
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- ОБРАБОТКА ВХОДЯЩИХ ДАННЫХ (WebSocket) ----
|
||
// Формат ожидаемого JSON:
|
||
// { type: 'data', channels_total: 8, samples_total: N, samples: [12, 200, ...] }
|
||
// или массив байт. samples - массив целых 0..255
|
||
function processSamplesFromMessage(dataMsg) {
|
||
let samplesArray = null;
|
||
// если пришёл массив samples
|
||
if (dataMsg.samples && Array.isArray(dataMsg.samples)) {
|
||
samplesArray = dataMsg.samples;
|
||
}
|
||
// поддержка альтернативного поля data
|
||
else if (dataMsg.data && Array.isArray(dataMsg.data)) {
|
||
samplesArray = dataMsg.data;
|
||
}
|
||
// также если в поле samples_total подсказка
|
||
if (samplesArray && samplesArray.length > 0) {
|
||
// фильтруем и добавляем каждый sample в кольцевой буфер
|
||
for (let i = 0; i < samplesArray.length; i++) {
|
||
let smp = Number(samplesArray[i]);
|
||
if (isNaN(smp)) continue;
|
||
pushSample(smp);
|
||
}
|
||
// также можно отобразить быстрый счётчик
|
||
if (sampleCounterSpan) {
|
||
sampleCounterSpan.innerHTML = `📊 +${samplesArray.length} семпл(ов) | всего ${ringCount}`;
|
||
}
|
||
} else {
|
||
// нет данных, но возможно samples_total = 0
|
||
console.warn("Нет массива samples в сообщении", dataMsg);
|
||
}
|
||
}
|
||
|
||
// ---- WEBSOCKET подключение (адаптация под ring buffer) ----
|
||
function connectWebSocket() {
|
||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
||
try { ws.close(); } catch(e) {}
|
||
}
|
||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const wsUrl = `ws://${window.location.hostname}:81`;
|
||
ws = new WebSocket(wsUrl);
|
||
|
||
ws.onopen = () => {
|
||
console.log('[LOGIC] WebSocket connected');
|
||
updateStatus('Подключено к анализатору', true);
|
||
connectionAttempts = 0;
|
||
};
|
||
|
||
ws.onmessage = (event) => {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
if (data.type === 'data') {
|
||
// стандартное сообщение от логического анализатора
|
||
// ожидаем data.samples — массив байт
|
||
if (data.samples) {
|
||
processSamplesFromMessage(data);
|
||
}
|
||
// поддержка также прямого массива samples_total и samples
|
||
else if (data.samples_total !== undefined && data.samples) {
|
||
processSamplesFromMessage(data);
|
||
}
|
||
else {
|
||
// возможно сообщение просто содержит samples массив? проверяем корневой массив
|
||
if (Array.isArray(data)) {
|
||
for (let val of data) pushSample(val);
|
||
} else if (data.data && Array.isArray(data.data)) {
|
||
processSamplesFromMessage(data);
|
||
} else {
|
||
// неизвестный формат, но пробуем извлечь samples поле рекурсивно
|
||
if (data.payload && data.payload.samples) processSamplesFromMessage(data.payload);
|
||
}
|
||
}
|
||
}
|
||
else if (data.type === 'status') {
|
||
updateStatus(data.message || data.status, true);
|
||
}
|
||
else {
|
||
// попробуем универсально: если есть свойство samples использовать
|
||
if (data.samples) processSamplesFromMessage(data);
|
||
else if (Array.isArray(data)) {
|
||
data.forEach(v => pushSample(v));
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error("JSON parse error or invalid data:", err, event.data);
|
||
// возможно raw binary данные? по тексту не ожидается, но игнорируем
|
||
}
|
||
};
|
||
|
||
ws.onclose = (ev) => {
|
||
console.warn('WebSocket closed, reconnecting...', ev.code);
|
||
updateStatus('Отключено. Переподключение через 1с...', false);
|
||
setTimeout(() => {
|
||
connectWebSocket();
|
||
}, 1000);
|
||
};
|
||
|
||
ws.onerror = (err) => {
|
||
console.error('WebSocket error', err);
|
||
updateStatus('Ошибка соединения', false);
|
||
};
|
||
}
|
||
|
||
// ---- ИНИЦИАЛИЗАЦИЯ И АДАПТАЦИЯ РАЗМЕРА CANVAS ----
|
||
function resizeCanvasAndRedraw() {
|
||
const container = canvas.parentElement;
|
||
const maxWidth = Math.min(1200, window.innerWidth - 60);
|
||
// устанавливаем размер canvas в пикселях для чёткой графики
|
||
const targetWidth = Math.min(1000, maxWidth);
|
||
const targetHeight = 500;
|
||
canvas.width = targetWidth;
|
||
canvas.height = targetHeight;
|
||
canvas.style.width = `${targetWidth}px`;
|
||
canvas.style.height = `${targetHeight}px`;
|
||
scheduleRender();
|
||
}
|
||
|
||
// очистка буфера по кнопке
|
||
function handleClear() {
|
||
clearRingBuffer();
|
||
updateStatus('Буфер сброшен', !!ws && ws.readyState === WebSocket.OPEN);
|
||
scheduleRender();
|
||
}
|
||
|
||
// Имитация демо-данных, если сервер не отвечает (для теста интерфейса кольцевого буфера)
|
||
// но только если нет подключения в течение 4 секунд - демонстрация работы (добавим генератор)
|
||
let demoInterval = null;
|
||
function startDemoDataIfNeeded() {
|
||
// Проверяем каждые 5 секунд: если нет ws или не открыт и буфер пуст, то генерируем демо
|
||
if (demoInterval) clearInterval(demoInterval);
|
||
demoInterval = setInterval(() => {
|
||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||
// генерируем несколько тестовых семплов для наглядности, но только если буфер не слишком большой
|
||
if (ringCount < 300) {
|
||
const demoCount = 4;
|
||
for (let i = 0; i < demoCount; i++) {
|
||
// Создаём осмысленную последовательность: счётчик, меандр на разных каналах
|
||
const timeSeed = Date.now() + i;
|
||
let sampleVal = 0;
|
||
// CH0: квадратная волна (каждые 8 семплов)
|
||
if ((ringCount + i) % 16 < 8) sampleVal |= (1 << 0);
|
||
// CH1: меандр с другой частотой
|
||
if ((ringCount + i) % 24 < 12) sampleVal |= (1 << 1);
|
||
// CH2: случайный импульс
|
||
if (Math.sin((ringCount + i) * 0.3) > 0) sampleVal |= (1 << 2);
|
||
// CH3: активный каждые 3 семпла
|
||
if ((ringCount + i) % 6 < 3) sampleVal |= (1 << 3);
|
||
// CH4: половина
|
||
if ((ringCount + i) % 10 > 4) sampleVal |= (1 << 4);
|
||
// CH5: шумоподобный
|
||
if (Math.floor(Math.random() * 2)) sampleVal |= (1 << 5);
|
||
// CH6, CH7 (доп)
|
||
if ((ringCount + i) % 7 < 3) sampleVal |= (1 << 6);
|
||
if ((ringCount + i) % 5 === 0) sampleVal |= (1 << 7);
|
||
pushSample(sampleVal);
|
||
}
|
||
}
|
||
}
|
||
}, 360);
|
||
}
|
||
|
||
// --- Отслеживание изменения размера окна ---
|
||
window.addEventListener('resize', () => {
|
||
resizeCanvasAndRedraw();
|
||
});
|
||
|
||
// Инициализация UI
|
||
function init() {
|
||
buildLegend();
|
||
resizeCanvasAndRedraw();
|
||
clearRingBuffer(); // чистый старт
|
||
connectWebSocket();
|
||
clearBtn.addEventListener('click', handleClear);
|
||
// небольшая анимация рендера (периодическая перерисовка для обновления UI даже без данных)
|
||
function periodicRender() {
|
||
drawWaveforms();
|
||
requestAnimationFrame(periodicRender);
|
||
}
|
||
// Запускаем непрерывный рендер (эффективно, не нагружает)
|
||
periodicRender();
|
||
}
|
||
|
||
init();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|