Добавление камеры на сервер
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ===== EMBED WEB =====
|
// ===== EMBED WEB =====
|
||||||
|
//
|
||||||
//go:embed web/*
|
//go:embed web/*
|
||||||
var webFiles embed.FS
|
var webFiles embed.FS
|
||||||
|
|
||||||
@@ -75,6 +76,12 @@ func (a *API) HandleHistory(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *API) HandleStream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, map[string]string{
|
||||||
|
"cam": "http://192.168.77.1:1984/stream.html?src=cam",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ===== PIPE READER =====
|
// ===== PIPE READER =====
|
||||||
|
|
||||||
func startPipeReader(ctx context.Context, buf *adapter.RingBuffer, path string) {
|
func startPipeReader(ctx context.Context, buf *adapter.RingBuffer, path string) {
|
||||||
@@ -137,7 +144,6 @@ func main() {
|
|||||||
|
|
||||||
// ===== API =====
|
// ===== API =====
|
||||||
http.Handle("/api/", http.StripPrefix("/api", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
http.Handle("/api/", http.StripPrefix("/api", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
case "/latest":
|
case "/latest":
|
||||||
cors(api.HandleLatest)(w, r)
|
cors(api.HandleLatest)(w, r)
|
||||||
@@ -145,6 +151,8 @@ func main() {
|
|||||||
cors(api.HandleHistory)(w, r)
|
cors(api.HandleHistory)(w, r)
|
||||||
case "/health":
|
case "/health":
|
||||||
cors(api.HandleHealth)(w, r)
|
cors(api.HandleHealth)(w, r)
|
||||||
|
case "/stream":
|
||||||
|
cors(api.HandleStream)(w, r)
|
||||||
default:
|
default:
|
||||||
http.Error(w, "not found", 404)
|
http.Error(w, "not found", 404)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,6 +224,165 @@ h1 {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ОТКРЫТОЕ СОСТОЯНИЕ */
|
||||||
|
.cam-modal.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cam-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px;
|
||||||
|
color: white;
|
||||||
|
background: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cam-frame {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #000;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cam-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #00ff88;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SPLIT LAYOUT ===== */
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
flex: 0 0 40%;
|
||||||
|
min-width: 300px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right.hidden {
|
||||||
|
flex: 0 0 0 !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
width: 0 !important;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DRAG BAR */
|
||||||
|
.resizer {
|
||||||
|
width: 6px;
|
||||||
|
cursor: col-resize;
|
||||||
|
background: #00ff88;
|
||||||
|
opacity: 0.25;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizer:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizer.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== CAMERA ===== */
|
||||||
|
|
||||||
|
.cam-modal {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cam-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px;
|
||||||
|
background: #111;
|
||||||
|
color: #00ff88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cam-frame {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
background: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cam-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #00ff88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cam-btn {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 1001;
|
||||||
|
padding: 10px 15px;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: #00ff88;
|
||||||
|
border: 1px solid #00ff88;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cam-btn:hover {
|
||||||
|
background: rgba(0, 255, 136, 0.2);
|
||||||
|
box-shadow: 0 0 15px rgba(0, 255, 136, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cam-btn-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Панель камеры скрыта */
|
||||||
|
.panel.right[style*="display: none"] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Когда камера скрыта, левая панель на весь экран */
|
||||||
|
.layout:has(.panel.right[style*="display: none"]) .left {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout:has(.panel.right[style*="display: none"]) .resizer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== MOBILE ===== */
|
/* ===== MOBILE ===== */
|
||||||
@media (max-width: 500px) {
|
@media (max-width: 500px) {
|
||||||
h1 {
|
h1 {
|
||||||
|
|||||||
@@ -13,55 +13,81 @@
|
|||||||
|
|
||||||
<h1>GPIO МОНИТОРИНГ В РЕАЛЬНОМ ВРЕМЕНИ</h1>
|
<h1>GPIO МОНИТОРИНГ В РЕАЛЬНОМ ВРЕМЕНИ</h1>
|
||||||
|
|
||||||
<div class="grid">
|
<!-- Кнопка переключения камеры -->
|
||||||
|
<button id="camToggle" class="cam-btn">
|
||||||
|
<span class="cam-btn-icon">📷</span>
|
||||||
|
<span class="cam-btn-text">Камера</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="card">
|
<div class="layout">
|
||||||
<h3>СОСТОЯНИЕ</h3>
|
<!-- LEFT: графики / дашборд -->
|
||||||
<div id="status">...</div>
|
<div class="panel left" id="leftPanel">
|
||||||
<div id="meta"></div>
|
<div class="grid">
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card wide">
|
<div class="card">
|
||||||
<h3>УРОВНИ КАНАЛОВ</h3>
|
<h3>СОСТОЯНИЕ</h3>
|
||||||
|
<div id="status">...</div>
|
||||||
|
<div id="meta"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="progress-row">
|
<div class="card wide">
|
||||||
<div class="progress-label">Канал 1 — <span id="last-byte">0</span></div>
|
<h3>УРОВНИ КАНАЛОВ</h3>
|
||||||
<div class="progress-with-signal">
|
|
||||||
<div class="progress progress-90">
|
<div class="progress-row">
|
||||||
<div class="fill" id="bar1"></div>
|
<div class="progress-label">Канал 1 — <span id="last-byte">0</span></div>
|
||||||
|
<div class="progress-with-signal">
|
||||||
|
<div class="progress progress-90">
|
||||||
|
<div class="fill" id="bar1"></div>
|
||||||
|
</div>
|
||||||
|
<div class="signal-icon" id="signal-icon" aria-label="Уровень сигнала" role="img">
|
||||||
|
<div class="sig-bar sig-bar-1"></div>
|
||||||
|
<div class="sig-bar sig-bar-2"></div>
|
||||||
|
<div class="sig-bar sig-bar-3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="signal-icon" id="signal-icon" aria-label="Уровень сигнала" role="img">
|
|
||||||
<div class="sig-bar sig-bar-1"></div>
|
<div class="progress-row">
|
||||||
<div class="sig-bar sig-bar-2"></div>
|
<div class="progress-label">Звук</div>
|
||||||
<div class="sig-bar sig-bar-3"></div>
|
<div class="progress">
|
||||||
|
<div class="fill" id="bar2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-row">
|
||||||
|
<div class="progress-label">Высота</div>
|
||||||
|
<div class="progress">
|
||||||
|
<div class="fill" id="bar3"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="progress-row">
|
<div class="card wide">
|
||||||
<div class="progress-label">Звук</div>
|
<h3>ГРАФИК КАНАЛА 1</h3>
|
||||||
<div class="progress">
|
<canvas id="signalChart" height="120"></canvas>
|
||||||
<div class="fill" id="bar2"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="progress-row">
|
<div class="card wide">
|
||||||
<div class="progress-label">Высота</div>
|
<h3 class="accordion-header" id="history-header">▶ ПРИНЯТЫЕ ДАННЫЕ</h3>
|
||||||
<div class="progress">
|
<div class="accordion-body" id="history-body">
|
||||||
<div class="fill" id="bar3"></div>
|
<div id="history"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card wide">
|
<!-- DRAG HANDLE -->
|
||||||
<h3>ГРАФИК КАНАЛА 1</h3>
|
<div class="resizer" id="dragBar"></div>
|
||||||
<canvas id="signalChart" height="120"></canvas>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card wide">
|
<!-- RIGHT: камера -->
|
||||||
<h3 class="accordion-header" id="history-header">▶ ПРИНЯТЫЕ ДАННЫЕ</h3>
|
<div class="panel right" id="rightPanel">
|
||||||
<div class="accordion-body" id="history-body">
|
<div class="cam-modal">
|
||||||
<div id="history"></div>
|
<div class="cam-header">
|
||||||
|
<span>Camera</span>
|
||||||
|
<button id="camClose">✖</button>
|
||||||
|
</div>
|
||||||
|
<img id="camImage" src="" alt="Camera" class="cam-frame">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -70,4 +96,4 @@
|
|||||||
<script type="module" src="/js/app.js"></script>
|
<script type="module" src="/js/app.js"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
|
|
||||||
import { initAccordion } from "./accordion.js";
|
import { initAccordion } from "./accordion.js";
|
||||||
import { drawChart } from "./chart.js";
|
import { drawChart } from "./chart.js";
|
||||||
|
import { initResize } from "./layout.js";
|
||||||
|
import { initCamera } from "./camera.js";
|
||||||
|
|
||||||
let lastChart = 0;
|
let lastChart = 0;
|
||||||
let connectionLost = false;
|
let connectionLost = false;
|
||||||
@@ -23,6 +25,29 @@ let pollTimer = null;
|
|||||||
window.addEventListener("DOMContentLoaded", () => {
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
initDOM();
|
initDOM();
|
||||||
initAccordion();
|
initAccordion();
|
||||||
|
initCamera();
|
||||||
|
initResize();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
switch(e.key.toLowerCase()) {
|
||||||
|
case 'c':
|
||||||
|
// Переключение камеры
|
||||||
|
const camBtn = document.getElementById('camToggle');
|
||||||
|
if (camBtn) camBtn.click();
|
||||||
|
break;
|
||||||
|
case 'f':
|
||||||
|
// На весь экран для левой панели
|
||||||
|
if (leftPanel) {
|
||||||
|
leftPanel.style.width = "100%";
|
||||||
|
if (rightPanel) rightPanel.style.display = "none";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'escape':
|
||||||
|
if (rightPanel) rightPanel.style.display = "flex";
|
||||||
|
applyRatio(0.6); // 60% левая, 40% правая
|
||||||
|
break;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ================= UPDATE =================
|
// ================= UPDATE =================
|
||||||
|
|||||||
73
cmd/server/web/js/camera.js
Normal file
73
cmd/server/web/js/camera.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
//camera.js
|
||||||
|
let rightPanel, camImage, camClose;
|
||||||
|
let isOpen = false;
|
||||||
|
let streamUrl;
|
||||||
|
|
||||||
|
export function initCamera() {
|
||||||
|
rightPanel = document.getElementById("rightPanel");
|
||||||
|
camImage = document.getElementById("camImage");
|
||||||
|
camClose = document.getElementById("camClose");
|
||||||
|
const btn = document.getElementById("camToggle");
|
||||||
|
|
||||||
|
streamUrl = "http://192.168.77.1:1984/api/stream.mjpeg?src=cam";
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (isOpen) return;
|
||||||
|
|
||||||
|
isOpen = true;
|
||||||
|
rightPanel.classList.remove("hidden");
|
||||||
|
camImage.src = streamUrl + "&t=" + Date.now();
|
||||||
|
|
||||||
|
// Обновляем текст кнопки
|
||||||
|
if (btn) {
|
||||||
|
btn.querySelector('.cam-btn-text').textContent = 'Скрыть';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[camera] Камера открыта");
|
||||||
|
updateResizerVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
isOpen = false;
|
||||||
|
camImage.src = "about:blank";
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
camImage.src = "";
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
rightPanel.classList.add("hidden");
|
||||||
|
|
||||||
|
// Обновляем текст кнопки
|
||||||
|
if (btn) {
|
||||||
|
btn.querySelector('.cam-btn-text').textContent = 'Камера';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[camera] Камера закрыта");
|
||||||
|
updateResizerVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
isOpen ? close() : open();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработчики событий
|
||||||
|
btn?.addEventListener("click", toggle);
|
||||||
|
camClose?.addEventListener("click", close);
|
||||||
|
|
||||||
|
// Горячие клавиши
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === 'c' || e.key === 'C') {
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape' && isOpen) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Автозапуск камеры
|
||||||
|
open();
|
||||||
|
|
||||||
|
console.log("[camera] Модуль камеры инициализирован");
|
||||||
|
}
|
||||||
92
cmd/server/web/js/layout.js
Normal file
92
cmd/server/web/js/layout.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
//layout.js
|
||||||
|
let leftPanel, rightPanel, dragBar;
|
||||||
|
let isDragging = false;
|
||||||
|
|
||||||
|
export function initResize() {
|
||||||
|
leftPanel = document.getElementById("leftPanel");
|
||||||
|
rightPanel = document.getElementById("rightPanel");
|
||||||
|
dragBar = document.getElementById("dragBar");
|
||||||
|
|
||||||
|
if (!leftPanel || !rightPanel || !dragBar) return;
|
||||||
|
|
||||||
|
// Загрузка сохранённых пропорций
|
||||||
|
const savedRatio = localStorage.getItem('layoutRatio');
|
||||||
|
if (savedRatio) {
|
||||||
|
const ratio = parseFloat(savedRatio);
|
||||||
|
applyRatio(ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
dragBar.addEventListener("mousedown", (e) => {
|
||||||
|
isDragging = true;
|
||||||
|
document.body.style.cursor = "col-resize";
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("mouseup", () => {
|
||||||
|
if (isDragging) {
|
||||||
|
isDragging = false;
|
||||||
|
document.body.style.cursor = "default";
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
|
||||||
|
// Сохраняем пропорции
|
||||||
|
const total = window.innerWidth;
|
||||||
|
const leftWidth = leftPanel.getBoundingClientRect().width;
|
||||||
|
const ratio = leftWidth / total;
|
||||||
|
localStorage.setItem('layoutRatio', ratio.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ЕДИНСТВЕННЫЙ обработчик mousemove
|
||||||
|
document.addEventListener("mousemove", (e) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
if (rightPanel.classList.contains("hidden")) return; // Не ресайзим если камера скрыта
|
||||||
|
|
||||||
|
const total = window.innerWidth;
|
||||||
|
let leftWidth = e.clientX;
|
||||||
|
|
||||||
|
// Минимальные размеры
|
||||||
|
const minWidth = 300;
|
||||||
|
if (leftWidth < minWidth) leftWidth = minWidth;
|
||||||
|
if (leftWidth > total - minWidth) leftWidth = total - minWidth;
|
||||||
|
|
||||||
|
const rightWidth = total - leftWidth - dragBar.offsetWidth;
|
||||||
|
|
||||||
|
leftPanel.style.flex = "none";
|
||||||
|
rightPanel.style.flex = "none";
|
||||||
|
leftPanel.style.width = leftWidth + "px";
|
||||||
|
rightPanel.style.width = rightWidth + "px";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Адаптация при ресайзе окна
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
if (rightPanel.classList.contains("hidden")) return;
|
||||||
|
|
||||||
|
const total = window.innerWidth;
|
||||||
|
const leftWidth = leftPanel.getBoundingClientRect().width;
|
||||||
|
const ratio = leftWidth / total;
|
||||||
|
if (ratio > 0.2 && ratio < 0.8) {
|
||||||
|
applyRatio(ratio);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRatio(ratio) {
|
||||||
|
const total = window.innerWidth;
|
||||||
|
const leftWidth = Math.floor(total * ratio);
|
||||||
|
const rightWidth = total - leftWidth - dragBar.offsetWidth;
|
||||||
|
|
||||||
|
leftPanel.style.flex = "none";
|
||||||
|
rightPanel.style.flex = "none";
|
||||||
|
leftPanel.style.width = leftWidth + "px";
|
||||||
|
rightPanel.style.width = rightWidth + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateResizerVisibility() {
|
||||||
|
if (!dragBar) return;
|
||||||
|
if (rightPanel?.classList.contains("hidden")) {
|
||||||
|
dragBar.classList.add("hidden");
|
||||||
|
} else {
|
||||||
|
dragBar.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user