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

642 lines
24 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 Pump controller</title>
<style>
* {
box-sizing: border-box;
user-select: none;
}
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at 20% 30%, #121826, #0b0f17);
font-family: 'Segoe UI', 'Inter', system-ui, -apple-system, 'Roboto', sans-serif;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.dashboard {
max-width: 920px;
width: 100%;
background: rgba(18, 22, 30, 0.75);
backdrop-filter: blur(8px);
border-radius: 72px 72px 56px 56px;
box-shadow: 0 25px 45px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.08);
padding: 28px 24px 38px;
transition: all 0.2s ease;
border: 1px solid rgba(71, 85, 105, 0.5);
}
.gauge-container {
display: flex;
justify-content: center;
margin: 10px 0 15px;
filter: drop-shadow(0 12px 18px rgba(0, 0, 0, 0.5));
}
canvas {
width: 100%;
height: auto;
max-width: 480px;
aspect-ratio: 1 / 1;
background: #131a24;
border-radius: 50%;
box-shadow: 0 15px 30px rgba(0, 0, 0, 0.7), inset 0 1px 2px rgba(255, 255, 255, 0.1);
display: block;
transition: box-shadow 0.2s;
}
.pressure-value {
text-align: center;
font-size: 2.1rem;
font-weight: 700;
margin: -5px auto 20px;
color: #eef4ff;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(12px);
padding: 8px 32px;
border-radius: 80px;
display: inline-block;
width: auto;
letter-spacing: 1px;
border: 1px solid rgba(80, 140, 200, 0.4);
font-family: 'JetBrains Mono', monospace, 'Segoe UI';
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.value-wrapper {
text-align: center;
}
.sliders-panel {
background: #0e121cb3;
backdrop-filter: blur(12px);
border-radius: 48px;
padding: 28px 24px 24px;
margin-top: 24px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 12px 28px rgba(0, 0, 0, 0.5);
border: 1px solid rgba(72, 92, 118, 0.5);
}
.slider-group {
margin-bottom: 32px;
-webkit-tap-highlight-color: transparent;
}
.slider-group label {
display: flex;
justify-content: space-between;
font-weight: 600;
color: #e2eafc;
font-size: 1.2rem;
margin-bottom: 12px;
letter-spacing: 0.3px;
text-shadow: 0 1px 1px black;
}
.slider-group span {
background: #1e2f3f;
color: #c0e0ff;
padding: 4px 16px;
border-radius: 40px;
font-size: 0.9rem;
font-weight: 500;
box-shadow: inset 0 1px 2px rgba(0,0,0,0.3), 0 1px 0 rgba(255,255,255,0.1);
}
input[type="range"] {
width: 100%;
height: 6px;
-webkit-appearance: none;
background: linear-gradient(90deg, #1e6f9f, #4aa3cf);
border-radius: 12px;
cursor: pointer;
box-shadow: inset 0 1px 2px #00000040;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 36px;
height: 36px;
background: radial-gradient(circle, #6db3e0, #2c6c9e);
border-radius: 50%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5), 0 0 0 2px rgba(210, 230, 250, 0.7);
border: 1px solid #b8d0ff;
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: 10px;
font-size: 0.7rem;
color: #94a9c9;
font-weight: 500;
}
.save-btn {
width: 100%;
background: linear-gradient(95deg, #1f5a3a, #2b7a55);
border: none;
padding: 16px;
font-size: 1.35rem;
font-weight: bold;
color: #f0faf5;
border-radius: 60px;
margin-top: 28px;
cursor: pointer;
box-shadow: 0 6px 0 #0d3522;
transition: transform 0.08s, box-shadow 0.08s, background 0.1s;
letter-spacing: 1px;
backdrop-filter: blur(2px);
font-family: inherit;
-webkit-tap-highlight-color: transparent;
}
.save-btn:active {
transform: translateY(3px);
box-shadow: 0 3px 0 #0d3522;
background: linear-gradient(95deg, #154d33, #216a48);
}
.toast-msg {
visibility: hidden;
background: #1f2f3cee;
backdrop-filter: blur(20px);
color: #c2f0d6;
text-align: center;
border-radius: 60px;
padding: 12px 28px;
position: fixed;
bottom: 32px;
left: 50%;
transform: translateX(-50%);
opacity: 0;
transition: opacity 0.2s ease, visibility 0s linear 0.2s;
z-index: 1000;
pointer-events: none;
white-space: nowrap;
font-weight: 600;
border: 1px solid #3c936e;
letter-spacing: 0.4px;
box-shadow: 0 6px 16px black;
}
.toast-msg.show {
visibility: visible;
opacity: 1;
transition: opacity 0.2s ease;
}
@media (max-width: 550px) {
.dashboard { padding: 20px 16px 30px; }
.slider-group label { font-size: 1rem; }
.save-btn { font-size: 1.2rem; }
.pressure-value { font-size: 1.6rem; }
.toast-msg { white-space: nowrap; font-size: 0.85rem; padding: 8px 20px;}
}
footer {
text-align: center;
font-size: 0.7rem;
margin-top: 22px;
color: #6f8aae;
opacity: 0.7;
font-weight: 500;
}
</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>
<footer>⚡ IoT контроллер давления | © Vladislav Kan &lt;thek4n@yandex.ru&gt;</footer>
</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; // 1 = рост, -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 pressureVal = Math.round(p * 10) / 10;
if (pressureVal > MAX_PRESSURE) continue;
const t = (pressureVal - MIN_PRESSURE) / (MAX_PRESSURE - MIN_PRESSURE);
const angle = startRad + t * angleRange;
const isFullAtm = Math.abs(pressureVal - Math.round(pressureVal)) < 0.01;
const isHalfAtm = !isFullAtm && (Math.abs(pressureVal * 2 - Math.round(pressureVal * 2)) < 0.05);
let innerR, outerR, lineWidth;
if (isFullAtm) {
innerR = radius - 18;
outerR = radius + 9;
lineWidth = 3.2;
} else if (isHalfAtm) {
innerR = radius - 13;
outerR = radius + 5;
lineWidth = 2.2;
} else {
innerR = radius - 9;
outerR = radius + 2;
lineWidth = 1.3;
}
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: pressureVal,
isFullAtm,
x1, y1, x2, y2,
lineWidth,
textX: isFullAtm ? centerX + (radius - 27) * Math.cos(angle) - 9 : null,
textY: isFullAtm ? centerY + (radius - 27) * Math.sin(angle) + 8 : 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 = '#a0bbdf';
ctx.shadowBlur = 0;
ctx.stroke();
if (mark.isFullAtm && mark.textX !== null) {
ctx.font = `600 ${canvasWidth * 0.048}px "Segoe UI", "Inter", system-ui`;
ctx.fillStyle = '#eef2fc';
ctx.shadowBlur = 2;
ctx.shadowColor = "rgba(0,0,0,0.6)";
ctx.fillText(Math.round(mark.pressure).toString(), mark.textX, mark.textY);
ctx.shadowBlur = 0;
}
}
}
// Отрисовка цветных порогов (яркие акценты)
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 + 24;
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 = 9;
ctx.strokeStyle = color;
ctx.shadowBlur = 8;
ctx.shadowColor = "rgba(0,0,0,0.6)";
ctx.stroke();
ctx.shadowBlur = 0;
}
// Эффектная стрелка с металлическим отливом и глянцем
function drawPointer(angle, centerX, centerY, radius) {
const mainLength = radius - 22;
const tailLength = radius * 0.28;
const dirX = Math.cos(angle);
const dirY = Math.sin(angle);
const perpX = -Math.sin(angle);
const perpY = Math.cos(angle);
const baseDist = 16;
const baseWidth = 19;
const tipWidth = 5;
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 * 12, centerY + dirY * 12,
centerX + dirX * (mainLength - 15), centerY + dirY * (mainLength - 15)
);
metalGrad.addColorStop(0, '#cfddee');
metalGrad.addColorStop(0.6, '#919db0');
metalGrad.addColorStop(1, '#5f6c80');
ctx.fillStyle = metalGrad;
ctx.fill();
ctx.strokeStyle = '#2c3d55';
ctx.lineWidth = 1.2;
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 = 13;
const tailWidth = 13;
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 = '#6b788e';
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 + 10, 0, Math.PI * 2);
ctx.strokeStyle = '#3e4b60';
ctx.lineWidth = 4;
ctx.stroke();
ctx.beginPath();
ctx.arc(centerX, centerY, radius + 6, 0, Math.PI * 2);
ctx.strokeStyle = '#232b37';
ctx.lineWidth = 2.5;
ctx.stroke();
// Внутренний фон (ночной глянец)
ctx.beginPath();
ctx.arc(centerX, centerY, radius + 2, 0, Math.PI * 2);
ctx.fillStyle = '#0d1420';
ctx.fill();
// Основная шкала
drawScale();
// Текст "атм" стилизованный
ctx.font = `500 ${w * 0.042}px "Segoe UI"`;
ctx.fillStyle = '#b8cfec';
ctx.shadowBlur = 2;
ctx.fillText("атм", centerX - 18, centerY + radius * 0.69);
ctx.fillStyle = '#85a9d0';
ctx.font = `300 ${w * 0.028}px monospace`;
ctx.shadowBlur = 0;
// пороговые метки
drawThresholdMark(minThreshold, '#f0745c'); // кораллово-красный
drawThresholdMark(maxThreshold, '#fbbf24'); // янтарный
// стрелка
const startRad = degToRad(START_ANGLE);
const endRad = degToRad(END_ANGLE);
const tValue = (currentPressure - MIN_PRESSURE) / (MAX_PRESSURE - MIN_PRESSURE);
const angle = startRad + Math.min(Math.max(tValue, 0), 1) * (endRad - startRad);
ctx.shadowBlur = 8;
ctx.shadowColor = "rgba(0,0,0,0.55)";
drawPointer(angle, centerX, centerY, radius);
ctx.shadowBlur = 0;
// центральная ступица (тёмная)
ctx.fillStyle = "#202632";
ctx.beginPath();
ctx.arc(centerX, centerY, 14, 0, 2 * Math.PI);
ctx.fill();
const hubGrad = ctx.createLinearGradient(centerX - 5, centerY - 5, centerX + 5, centerY + 5);
hubGrad.addColorStop(0, "#5d6f8a");
hubGrad.addColorStop(1, "#2e3b4a");
ctx.fillStyle = hubGrad;
ctx.beginPath();
ctx.arc(centerX, centerY, 9, 0, 2 * Math.PI);
ctx.fill();
ctx.fillStyle = "#bac8dc";
ctx.beginPath();
ctx.arc(centerX, centerY, 4.2, 0, 2 * Math.PI);
ctx.fill();
}
// симуляция давления с сохранением плавности и пределов
function updatePressure(timestamp) {
if (!lastTimestamp) {
lastTimestamp = timestamp;
animationFrameId = requestAnimationFrame(updatePressure);
return;
}
const delta = Math.min(timestamp - lastTimestamp, 45) / 1000;
lastTimestamp = timestamp;
const step = 0.42 * delta; // скорость ~0.42 атм/сек
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);
}
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', () => {
let corrected = false;
if (minThreshold >= maxThreshold) {
minThreshold = Math.max(MIN_PRESSURE, maxThreshold - 0.12);
maxThreshold = Math.min(MAX_PRESSURE, minThreshold + 0.12);
minSlider.value = minThreshold;
maxSlider.value = maxThreshold;
corrected = true;
}
minSpan.innerText = minThreshold.toFixed(2);
maxSpan.innerText = maxThreshold.toFixed(2);
drawGauge();
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 1600);
if (corrected) {
// дополнительное уведомление о корректировке через тост, но уже показываем общий
toast.innerText = "✓ Корректные пороги сохранены";
setTimeout(() => { toast.innerText = "✓ Пороги успешно сохранены"; }, 1500);
} else {
toast.innerText = "✓ Пороги сохранены";
setTimeout(() => { toast.innerText = "✓ Пороги успешно сохранены"; }, 1400);
}
});
}
// инициализация
function init() {
canvasWidth = canvas.width;
canvasHeight = canvas.height;
cachedScale = buildScaleCache();
initEvents();
updateUI();
animationFrameId = requestAnimationFrame(updatePressure);
window.addEventListener('resize', () => {
drawGauge();
});
}
init();
})();
</script>
</body>
</html>