2026-06-13 17:54:22 +03:00

605 lines
27 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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