2026-06-05 01:32:52 +03:00

590 lines
20 KiB
HTML
Raw 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="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>IoT Контроллер давления насоса | 08 атм</title>
<style>
* {
box-sizing: border-box;
user-select: none;
}
body {
margin: 0;
min-height: 100vh;
background: linear-gradient(145deg, #e0e5ec 0%, #cbd0d9 100%);
font-family: 'Segoe UI', system-ui, sans-serif;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.dashboard {
max-width: 900px;
width: 100%;
background: rgba(255, 255, 255, 0.3);
backdrop-filter: blur(2px);
border-radius: 64px 64px 48px 48px;
box-shadow: 0 20px 35px rgba(0, 0, 0, 0.2), inset 0 1px 1px rgba(255, 255, 255, 0.6);
padding: 24px 20px 35px;
}
.gauge-container {
display: flex;
justify-content: center;
margin: 10px 0 15px;
}
canvas {
width: 100%;
height: auto;
max-width: 480px;
aspect-ratio: 1 / 1;
background: #fef9ef;
border-radius: 50%;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.25), inset 0 2px 4px rgba(255, 255, 255, 0.8);
display: block;
}
.pressure-value {
text-align: center;
font-size: 1.9rem;
font-weight: 700;
margin: -5px auto 20px;
color: #1f2a3e;
background: rgba(255, 255, 240, 0.75);
padding: 6px 28px;
border-radius: 60px;
backdrop-filter: blur(4px);
display: inline-block;
width: auto;
}
.value-wrapper {
text-align: center;
}
.sliders-panel {
background: #eef2f7;
border-radius: 48px;
padding: 24px 20px 20px;
margin-top: 20px;
box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.05), 0 8px 20px rgba(0, 0, 0, 0.1);
}
.slider-group {
margin-bottom: 28px;
}
.slider-group label {
display: flex;
justify-content: space-between;
font-weight: 600;
color: #1e2b3c;
font-size: 1.2rem;
margin-bottom: 12px;
}
.slider-group span {
background: #2c3e4e;
color: white;
padding: 4px 14px;
border-radius: 30px;
font-size: 0.9rem;
}
input[type="range"] {
width: 100%;
height: 8px;
-webkit-appearance: none;
background: linear-gradient(90deg, #2c7da0, #61a5c2);
border-radius: 12px;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 34px;
height: 34px;
background: #1f6392;
border-radius: 50%;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
border: 2px solid white;
cursor: pointer;
transition: transform 0.08s;
}
input[type="range"]::-webkit-slider-thumb:active {
transform: scale(1.15);
}
.threshold-hint {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-size: 0.75rem;
color: #2c3e50;
}
.save-btn {
width: 100%;
background: #2b6e4f;
border: none;
padding: 16px;
font-size: 1.35rem;
font-weight: bold;
color: white;
border-radius: 60px;
margin-top: 28px;
cursor: pointer;
box-shadow: 0 6px 0 #1b4d36;
transition: transform 0.08s, box-shadow 0.08s;
}
.save-btn:active {
transform: translateY(3px);
box-shadow: 0 3px 0 #1b4d36;
}
.toast-msg {
visibility: hidden;
background: rgba(30, 47, 60, 0.92);
color: #e9f5e9;
text-align: center;
border-radius: 40px;
padding: 10px 20px;
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
opacity: 0;
transition: opacity 0.2s;
z-index: 1000;
pointer-events: none;
white-space: nowrap;
}
.toast-msg.show {
visibility: visible;
opacity: 1;
}
@media (max-width: 550px) {
.dashboard { padding: 18px 16px 30px; }
.slider-group label { font-size: 1rem; }
.save-btn { font-size: 1.2rem; }
.pressure-value { font-size: 1.5rem; }
}
footer {
text-align: center;
font-size: 0.7rem;
margin-top: 22px;
color: #2c4359;
opacity: 0.75;
}
</style>
</head>
<body>
<div class="dashboard">
<div class="gauge-container">
<canvas id="manometerCanvas" width="500" height="500"></canvas>
</div>
<div class="value-wrapper">
<div class="pressure-value" id="currentPressureDisplay">0.0 атм</div>
</div>
<div class="sliders-panel">
<div class="slider-group">
<label>
Нижний порог (включение)
<span id="minThresholdVal">1.40</span> атм
</label>
<input type="range" id="minSlider" min="0" max="8" step="0.05" value="1.4">
</div>
<div class="slider-group">
<label>
Верхний порог (выключение)
<span id="maxThresholdVal">6.20</span> атм
</label>
<input type="range" id="maxSlider" min="0" max="8" step="0.05" value="6.2">
</div>
<button class="save-btn" id="saveButton">💾 СОХРАНИТЬ ЗНАЧЕНИЯ</button>
</div>
</div>
<div id="toastMsg" class="toast-msg">✓ Пороги сохранены</div>
<script>
(function() {
// Константы
const MIN_PRESSURE = 0;
const MAX_PRESSURE = 8;
const START_ANGLE = -225; // градусы
const END_ANGLE = 45;
// DOM элементы
const canvas = document.getElementById('manometerCanvas');
const ctx = canvas.getContext('2d');
const minSlider = document.getElementById('minSlider');
const maxSlider = document.getElementById('maxSlider');
const minSpan = document.getElementById('minThresholdVal');
const maxSpan = document.getElementById('maxThresholdVal');
const pressureDisplay = document.getElementById('currentPressureDisplay');
const saveBtn = document.getElementById('saveButton');
const toast = document.getElementById('toastMsg');
// Состояние
let minThreshold = 1.4;
let maxThreshold = 6.2;
let currentPressure = 2.7;
let pressureDirection = 1;
let animationFrameId = null;
let lastTimestamp = 0;
// Предрасчет для отрисовки шкалы (кеширование)
let cachedScale = null;
let canvasWidth = 500, canvasHeight = 500;
// Утилиты
const degToRad = (deg) => (deg * Math.PI) / 180;
// Предрасчет всех меток шкалы (оптимизация)
function buildScaleCache() {
const cache = [];
const centerX = canvasWidth / 2;
const centerY = canvasHeight / 2;
const radius = canvasWidth * 0.42;
const startRad = degToRad(START_ANGLE);
const endRad = degToRad(END_ANGLE);
const angleRange = endRad - startRad;
for (let p = MIN_PRESSURE; p <= MAX_PRESSURE + 0.01; p += 0.1) {
const pressure = Math.round(p * 10) / 10;
if (pressure > MAX_PRESSURE) continue;
const t = (pressure - MIN_PRESSURE) / (MAX_PRESSURE - MIN_PRESSURE);
const angle = startRad + t * angleRange;
const isFullAtm = Math.abs(pressure - Math.round(pressure)) < 0.01;
const isHalfAtm = !isFullAtm && (Math.abs(pressure * 2 - Math.round(pressure * 2)) < 0.05);
let innerR, outerR, lineWidth;
if (isFullAtm) {
innerR = radius - 18;
outerR = radius + 8;
lineWidth = 2.8;
} else if (isHalfAtm) {
innerR = radius - 13;
outerR = radius + 5;
lineWidth = 2.0;
} else {
innerR = radius - 9;
outerR = radius + 2;
lineWidth = 1.2;
}
const x1 = centerX + innerR * Math.cos(angle);
const y1 = centerY + innerR * Math.sin(angle);
const x2 = centerX + outerR * Math.cos(angle);
const y2 = centerY + outerR * Math.sin(angle);
cache.push({
pressure,
isFullAtm,
x1, y1, x2, y2,
lineWidth,
textX: isFullAtm ? centerX + (radius - 27) * Math.cos(angle) - 8 : null,
textY: isFullAtm ? centerY + (radius - 27) * Math.sin(angle) + 7 : null
});
}
return cache;
}
// Отрисовка шкалы из кеша
function drawScale() {
if (!cachedScale) return;
for (const mark of cachedScale) {
ctx.beginPath();
ctx.moveTo(mark.x1, mark.y1);
ctx.lineTo(mark.x2, mark.y2);
ctx.lineWidth = mark.lineWidth;
ctx.strokeStyle = '#2f415b';
ctx.stroke();
if (mark.isFullAtm && mark.textX !== null) {
ctx.font = `600 ${canvasWidth * 0.048}px "Segoe UI"`;
ctx.fillStyle = '#2c4b67';
ctx.fillText(Math.round(mark.pressure).toString(), mark.textX, mark.textY);
}
}
}
function drawThresholdMark(pressure, color) {
const w = canvas.width, h = canvas.height;
const centerX = w / 2, centerY = h / 2;
const radius = w * 0.42;
const startRad = degToRad(START_ANGLE);
const endRad = degToRad(END_ANGLE);
const t = Math.min(Math.max((pressure - MIN_PRESSURE) / (MAX_PRESSURE - MIN_PRESSURE), 0), 1);
const angle = startRad + t * (endRad - startRad);
const innerRad = radius - 18;
const outerRad = radius + 22;
const x1 = centerX + innerRad * Math.cos(angle);
const y1 = centerY + innerRad * Math.sin(angle);
const x2 = centerX + outerRad * Math.cos(angle);
const y2 = centerY + outerRad * Math.sin(angle);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = 8;
ctx.strokeStyle = color;
ctx.shadowBlur = 2;
ctx.shadowColor = "rgba(0,0,0,0.3)";
ctx.stroke();
ctx.shadowBlur = 0;
}
// Отрисовка стрелки (упрощенная и быстрая)
function drawPointer(angle, centerX, centerY, radius) {
const mainLength = radius - 20;
const tailLength = radius * 0.27;
const dirX = Math.cos(angle);
const dirY = Math.sin(angle);
const perpX = -Math.sin(angle);
const perpY = Math.cos(angle);
// Тело стрелки
const baseDist = 14;
const baseWidth = 18;
const tipWidth = 4;
const baseLeftX = centerX + baseDist * dirX + (baseWidth/2) * perpX;
const baseLeftY = centerY + baseDist * dirY + (baseWidth/2) * perpY;
const baseRightX = centerX + baseDist * dirX - (baseWidth/2) * perpX;
const baseRightY = centerY + baseDist * dirY - (baseWidth/2) * perpY;
const tipX = centerX + mainLength * dirX;
const tipY = centerY + mainLength * dirY;
const tipLeftX = tipX + (tipWidth/2) * perpX;
const tipLeftY = tipY + (tipWidth/2) * perpY;
const tipRightX = tipX - (tipWidth/2) * perpX;
const tipRightY = tipY - (tipWidth/2) * perpY;
ctx.beginPath();
ctx.moveTo(baseLeftX, baseLeftY);
ctx.lineTo(tipLeftX, tipLeftY);
ctx.lineTo(tipRightX, tipRightY);
ctx.lineTo(baseRightX, baseRightY);
ctx.closePath();
const metalGrad = ctx.createLinearGradient(
centerX + dirX * 10, centerY + dirY * 10,
centerX + dirX * (mainLength - 15), centerY + dirY * (mainLength - 15)
);
metalGrad.addColorStop(0, '#e0e5ec');
metalGrad.addColorStop(0.5, '#9aa0ac');
metalGrad.addColorStop(1, '#cbd0d9');
ctx.fillStyle = metalGrad;
ctx.fill();
ctx.strokeStyle = '#4f5a66';
ctx.lineWidth = 0.8;
ctx.stroke();
// Хвостовик
const tailAngle = angle + Math.PI;
const tailDirX = Math.cos(tailAngle);
const tailDirY = Math.sin(tailAngle);
const tailPerpX = -Math.sin(tailAngle);
const tailPerpY = Math.cos(tailAngle);
const tailBaseDist = 12;
const tailWidth = 12;
const tailEndX = centerX + tailLength * tailDirX;
const tailEndY = centerY + tailLength * tailDirY;
const tailBaseX = centerX + tailBaseDist * tailDirX;
const tailBaseY = centerY + tailBaseDist * tailDirY;
const tailLeftX = tailBaseX + (tailWidth/2) * tailPerpX;
const tailLeftY = tailBaseY + (tailWidth/2) * tailPerpY;
const tailRightX = tailBaseX - (tailWidth/2) * tailPerpX;
const tailRightY = tailBaseY - (tailWidth/2) * tailPerpY;
const tailEndLeftX = tailEndX + (tailWidth/2.5) * tailPerpX;
const tailEndLeftY = tailEndY + (tailWidth/2.5) * tailPerpY;
const tailEndRightX = tailEndX - (tailWidth/2.5) * tailPerpX;
const tailEndRightY = tailEndY - (tailWidth/2.5) * tailPerpY;
ctx.beginPath();
ctx.moveTo(tailLeftX, tailLeftY);
ctx.lineTo(tailEndLeftX, tailEndLeftY);
ctx.lineTo(tailEndRightX, tailEndRightY);
ctx.lineTo(tailRightX, tailRightY);
ctx.fillStyle = '#8f95a1';
ctx.fill();
ctx.stroke();
}
// Основная отрисовка
function drawGauge() {
if (!ctx) return;
const w = canvas.width, h = canvas.height;
const centerX = w / 2, centerY = h / 2;
const radius = w * 0.42;
ctx.clearRect(0, 0, w, h);
ctx.shadowBlur = 0;
// Внешний ободок
ctx.beginPath();
ctx.arc(centerX, centerY, radius + 8, 0, Math.PI * 2);
ctx.strokeStyle = '#bfb8a8';
ctx.lineWidth = 3;
ctx.stroke();
// Шкала (из кеша)
drawScale();
// Подпись "атм"
ctx.font = `500 ${w * 0.045}px "Segoe UI"`;
ctx.fillStyle = "#2c5a6e";
ctx.fillText("атм", centerX - 18, centerY + radius * 0.72);
// Пороги
drawThresholdMark(minThreshold, '#d43f34');
drawThresholdMark(maxThreshold, '#e68a2e');
// Стрелка
const startRad = degToRad(START_ANGLE);
const endRad = degToRad(END_ANGLE);
const t = (currentPressure - MIN_PRESSURE) / (MAX_PRESSURE - MIN_PRESSURE);
const angle = startRad + Math.min(Math.max(t, 0), 1) * (endRad - startRad);
ctx.shadowBlur = 6;
ctx.shadowColor = "rgba(0,0,0,0.35)";
drawPointer(angle, centerX, centerY, radius);
ctx.shadowBlur = 0;
// Центральная ступица
ctx.fillStyle = "#353c45";
ctx.beginPath();
ctx.arc(centerX, centerY, 13, 0, 2 * Math.PI);
ctx.fill();
const hubGrad = ctx.createLinearGradient(centerX - 5, centerY - 5, centerX + 5, centerY + 5);
hubGrad.addColorStop(0, "#a9b2c2");
hubGrad.addColorStop(1, "#7a8494");
ctx.fillStyle = hubGrad;
ctx.beginPath();
ctx.arc(centerX, centerY, 9, 0, 2 * Math.PI);
ctx.fill();
ctx.fillStyle = "#d4dce6";
ctx.beginPath();
ctx.arc(centerX, centerY, 4, 0, 2 * Math.PI);
ctx.fill();
}
// Симуляция давления с requestAnimationFrame (более плавно)
function updatePressure(timestamp) {
if (!lastTimestamp) {
lastTimestamp = timestamp;
animationFrameId = requestAnimationFrame(updatePressure);
return;
}
const delta = Math.min(timestamp - lastTimestamp, 50) / 1000;
lastTimestamp = timestamp;
const step = 0.4 * delta; // ~0.4 атм/сек
if (pressureDirection === 1) {
currentPressure = Math.min(currentPressure + step, maxThreshold);
if (currentPressure >= maxThreshold) pressureDirection = -1;
} else {
currentPressure = Math.max(currentPressure - step, minThreshold);
if (currentPressure <= minThreshold) pressureDirection = 1;
}
currentPressure = Math.min(Math.max(currentPressure, MIN_PRESSURE), MAX_PRESSURE);
pressureDisplay.innerText = currentPressure.toFixed(2) + " атм";
drawGauge();
animationFrameId = requestAnimationFrame(updatePressure);
}
// Обновление UI
function updateUI() {
minSlider.value = minThreshold;
maxSlider.value = maxThreshold;
minSpan.innerText = minThreshold.toFixed(2);
maxSpan.innerText = maxThreshold.toFixed(2);
drawGauge();
}
// Обработчики событий
function initEvents() {
minSlider.addEventListener('input', () => {
let val = parseFloat(minSlider.value);
if (val >= maxThreshold) {
val = Math.max(MIN_PRESSURE, maxThreshold - 0.08);
minSlider.value = val;
}
minThreshold = val;
minSpan.innerText = minThreshold.toFixed(2);
drawGauge();
});
maxSlider.addEventListener('input', () => {
let val = parseFloat(maxSlider.value);
if (val <= minThreshold) {
val = Math.min(MAX_PRESSURE, minThreshold + 0.08);
maxSlider.value = val;
}
maxThreshold = val;
maxSpan.innerText = maxThreshold.toFixed(2);
drawGauge();
});
saveBtn.addEventListener('click', () => {
if (minThreshold >= maxThreshold) {
minThreshold = Math.max(MIN_PRESSURE, maxThreshold - 0.1);
maxThreshold = Math.min(MAX_PRESSURE, minThreshold + 0.1);
minSlider.value = minThreshold;
maxSlider.value = maxThreshold;
}
minSpan.innerText = minThreshold.toFixed(2);
maxSpan.innerText = maxThreshold.toFixed(2);
drawGauge();
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 1500);
});
}
// Инициализация
function init() {
canvasWidth = canvas.width;
canvasHeight = canvas.height;
cachedScale = buildScaleCache();
initEvents();
updateUI();
animationFrameId = requestAnimationFrame(updatePressure);
window.addEventListener('resize', () => {
drawGauge();
});
}
init();
})();
</script>
</body>
</html>