SHA256
UI: обновить стартовые кнопки и static-sites
This commit is contained in:
@@ -0,0 +1,920 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<title>Дыхание по движению живота</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #07111f;
|
||||
--card: rgba(15, 23, 42, 0.9);
|
||||
--card2: rgba(30, 41, 59, 0.72);
|
||||
--text: #f8fafc;
|
||||
--muted: #94a3b8;
|
||||
--line: rgba(148, 163, 184, 0.18);
|
||||
--accent: #7dd3fc;
|
||||
--accent2: #c4b5fd;
|
||||
--good: #86efac;
|
||||
--warn: #fde68a;
|
||||
--bad: #fca5a5;
|
||||
--shadow: 0 18px 50px rgba(0,0,0,.24);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; min-height: 100%; background: radial-gradient(circle at 50% -10%, #14233d 0%, var(--bg) 45%, #040913 100%); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
body { padding: max(16px, env(safe-area-inset-top)) 14px max(24px, env(safe-area-inset-bottom)); }
|
||||
.app { width: min(900px, 100%); margin: 0 auto; }
|
||||
.hero { padding: 8px 4px 14px; }
|
||||
h1 { font-size: clamp(24px, 7vw, 38px); line-height: 1.05; margin: 0 0 8px; letter-spacing: -0.035em; }
|
||||
.subtitle { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.45; }
|
||||
.grid { display: grid; gap: 12px; }
|
||||
.card { background: linear-gradient(180deg, rgba(30,41,59,.84), rgba(15,23,42,.9)); border: 1px solid var(--line); border-radius: 22px; box-shadow: var(--shadow); overflow: hidden; }
|
||||
.status-card { padding: 18px; }
|
||||
.status-row { display: flex; gap: 12px; align-items: center; justify-content: space-between; }
|
||||
.status-main { min-width: 0; }
|
||||
.eyebrow { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .12em; }
|
||||
.phase { font-size: clamp(30px, 10vw, 52px); font-weight: 750; margin-top: 2px; letter-spacing: -.04em; }
|
||||
.phase.inhale { color: var(--accent); }
|
||||
.phase.exhale { color: var(--accent2); }
|
||||
.phase.idle { color: #cbd5e1; }
|
||||
.quality { min-width: 96px; text-align: right; }
|
||||
.quality strong { display: block; font-size: 18px; }
|
||||
.quality span { color: var(--muted); font-size: 12px; }
|
||||
.meter { height: 8px; background: rgba(148,163,184,.12); border-radius: 999px; margin-top: 14px; overflow: hidden; }
|
||||
.meter > i { display: block; height: 100%; width: 0%; border-radius: inherit; background: linear-gradient(90deg, var(--accent2), var(--accent)); transition: width .18s linear; }
|
||||
.calibration { margin-top: 9px; min-height: 20px; color: var(--muted); font-size: 13px; }
|
||||
|
||||
.controls { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 12px; }
|
||||
button { appearance: none; border: 0; border-radius: 16px; padding: 14px 16px; min-height: 50px; font: inherit; font-weight: 700; cursor: pointer; color: #07111f; background: #e2e8f0; }
|
||||
button.primary { background: linear-gradient(135deg, #bae6fd, #ddd6fe); }
|
||||
button.secondary { color: var(--text); background: rgba(148,163,184,.11); border: 1px solid var(--line); }
|
||||
button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.small-controls { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 8px; }
|
||||
.small-controls button { padding: 10px 8px; min-height: 42px; font-size: 12px; border-radius: 13px; }
|
||||
|
||||
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; padding: 12px; }
|
||||
.stat { background: rgba(2,6,23,.34); border: 1px solid var(--line); border-radius: 17px; padding: 13px 10px; min-width: 0; }
|
||||
.stat .period { color: var(--muted); font-size: 11px; margin-bottom: 8px; }
|
||||
.triplet { display: grid; gap: 5px; }
|
||||
.metric-line { display:flex; justify-content:space-between; align-items:baseline; gap:8px; }
|
||||
.metric-line span { color:var(--muted); font-size:11px; }
|
||||
.metric-line strong { font-size: clamp(15px, 4vw, 20px); letter-spacing:-.02em; }
|
||||
.metric-line.cycle strong { font-size: clamp(20px, 6vw, 28px); }
|
||||
.stat .unit { color: var(--muted); font-size: 11px; margin-top: 8px; }
|
||||
|
||||
.settings-card { margin-top:12px; padding:14px; }
|
||||
.settings-grid { display:grid; grid-template-columns:1fr; gap:12px; }
|
||||
.setting-row { display:flex; align-items:center; justify-content:space-between; gap:14px; background:rgba(2,6,23,.28); border:1px solid var(--line); border-radius:16px; padding:12px; }
|
||||
.setting-title { font-weight:700; font-size:14px; }
|
||||
.setting-sub { color:var(--muted); font-size:11px; margin-top:3px; line-height:1.35; }
|
||||
.switch { position:relative; width:52px; height:30px; flex:0 0 auto; }
|
||||
.switch input { opacity:0; width:0; height:0; }
|
||||
.slider-switch { position:absolute; inset:0; border-radius:999px; background:rgba(148,163,184,.2); border:1px solid var(--line); transition:.2s; }
|
||||
.slider-switch:before { content:""; position:absolute; width:22px; height:22px; left:3px; top:3px; border-radius:50%; background:#e2e8f0; transition:.2s; }
|
||||
.switch input:checked + .slider-switch { background:linear-gradient(135deg,#7dd3fc,#c4b5fd); }
|
||||
.switch input:checked + .slider-switch:before { transform:translateX(22px); background:#07111f; }
|
||||
.threshold-box { min-width:130px; text-align:right; }
|
||||
.threshold-value { font-size:24px; font-weight:800; letter-spacing:-.04em; }
|
||||
input[type="range"] { width:100%; margin-top:8px; accent-color:#7dd3fc; }
|
||||
.warning-box { margin-top:12px; border-radius:16px; padding:12px 14px; border:1px solid var(--line); background:rgba(2,6,23,.25); display:flex; justify-content:space-between; gap:12px; align-items:center; }
|
||||
.warning-box.alert { border-color:rgba(252,165,165,.55); background:rgba(127,29,29,.18); }
|
||||
.warning-box.ok { border-color:rgba(134,239,172,.35); background:rgba(20,83,45,.14); }
|
||||
.warning-count { font-size:18px; font-weight:800; }
|
||||
|
||||
.wave-card { padding: 12px 12px 8px; }
|
||||
.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 2px 4px 8px; }
|
||||
.section-head h2 { margin: 0; font-size: 15px; }
|
||||
.section-head span { color: var(--muted); font-size: 11px; text-align: right; }
|
||||
canvas { display: block; width: 100%; height: 170px; border-radius: 16px; background: rgba(2,6,23,.32); }
|
||||
|
||||
.table-card { overflow: hidden; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 560px; }
|
||||
th, td { text-align: right; padding: 12px 12px; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
||||
th:first-child, td:first-child { text-align: left; }
|
||||
th { color: var(--muted); font-size: 11px; font-weight: 650; position: sticky; top: 0; background: #111c2f; }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
.empty { text-align: center !important; color: var(--muted); padding: 28px 14px !important; }
|
||||
|
||||
details { margin-top: 12px; }
|
||||
summary { cursor: pointer; color: var(--muted); font-size: 13px; padding: 6px 4px; }
|
||||
.debug { margin-top: 8px; background: rgba(2,6,23,.35); border: 1px solid var(--line); border-radius: 16px; padding: 12px; color: var(--muted); font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; }
|
||||
.note { color: var(--muted); font-size: 12px; line-height: 1.45; margin: 12px 4px 0; }
|
||||
.pill { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; border: 1px solid var(--line); border-radius: 999px; font-size: 11px; color: var(--muted); }
|
||||
.dot { width: 7px; height: 7px; border-radius: 999px; background: #64748b; }
|
||||
.dot.on { background: var(--good); box-shadow: 0 0 12px rgba(134,239,172,.7); }
|
||||
|
||||
@media (min-width: 720px) {
|
||||
.top-grid { grid-template-columns: 1.15fr .85fr; }
|
||||
.stats { grid-template-columns: 1fr; height: 100%; }
|
||||
canvas { height: 210px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="app">
|
||||
<header class="hero">
|
||||
<h1>Дыхание</h1>
|
||||
<p class="subtitle">Положите телефон экраном вверх на живот и лежите спокойно. Приложение использует датчики движения телефона, чтобы оценивать ритм дыхания.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid top-grid">
|
||||
<section class="card status-card">
|
||||
<div class="status-row">
|
||||
<div class="status-main">
|
||||
<div class="eyebrow">Сейчас</div>
|
||||
<div id="phase" class="phase idle">Готово</div>
|
||||
</div>
|
||||
<div class="quality">
|
||||
<strong id="qualityText">—</strong>
|
||||
<span>качество сигнала</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meter"><i id="meterBar"></i></div>
|
||||
<div id="calibrationText" class="calibration">Нажмите «Начать». Первые 10 секунд — калибровка.</div>
|
||||
|
||||
<div class="controls">
|
||||
<button id="startBtn" class="primary">Начать</button>
|
||||
<button id="stopBtn" class="secondary" disabled>Остановить</button>
|
||||
</div>
|
||||
<div class="small-controls">
|
||||
<button id="recalibrateBtn" class="secondary" disabled>Калибровка</button>
|
||||
<button id="invertBtn" class="secondary" disabled>Вдох ↔ выдох</button>
|
||||
<button id="clearBtn" class="secondary">Очистить</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card stats" aria-label="Средняя длительность дыхания">
|
||||
<div class="stat">
|
||||
<div class="period">За последнюю 1 минуту</div>
|
||||
<div class="triplet">
|
||||
<div class="metric-line"><span>Вдох</span><strong id="inhale1">—</strong></div>
|
||||
<div class="metric-line"><span>Выдох</span><strong id="exhale1">—</strong></div>
|
||||
<div class="metric-line cycle"><span>Цикл</span><strong id="avg1">—</strong></div>
|
||||
</div>
|
||||
<div id="rate1" class="unit">— дых./мин</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="period">За последние 3 минуты</div>
|
||||
<div class="triplet">
|
||||
<div class="metric-line"><span>Вдох</span><strong id="inhale3">—</strong></div>
|
||||
<div class="metric-line"><span>Выдох</span><strong id="exhale3">—</strong></div>
|
||||
<div class="metric-line cycle"><span>Цикл</span><strong id="avg3">—</strong></div>
|
||||
</div>
|
||||
<div id="rate3" class="unit">— дых./мин</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="period">За последние 5 минут</div>
|
||||
<div class="triplet">
|
||||
<div class="metric-line"><span>Вдох</span><strong id="inhale5">—</strong></div>
|
||||
<div class="metric-line"><span>Выдох</span><strong id="exhale5">—</strong></div>
|
||||
<div class="metric-line cycle"><span>Цикл</span><strong id="avg5">—</strong></div>
|
||||
</div>
|
||||
<div id="rate5" class="unit">— дых./мин</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card settings-card">
|
||||
<div class="section-head" style="margin-bottom:10px">
|
||||
<h2>Контроль темпа</h2>
|
||||
<span>порог проверяется после каждого полного цикла</span>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-title">Звуковые предупреждения</div>
|
||||
<div class="setting-sub">Если средний цикл за последнюю минуту короче порога: 1 сигнал, затем 2, 3 и т.д.</div>
|
||||
</div>
|
||||
<label class="switch" aria-label="Включить звук">
|
||||
<input id="soundEnabled" type="checkbox" checked>
|
||||
<span class="slider-switch"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div style="min-width:0; flex:1">
|
||||
<div class="setting-title">Минимальный средний цикл</div>
|
||||
<div class="setting-sub">Полный цикл = вдох + выдох. Диапазон 5–15 секунд.</div>
|
||||
<input id="thresholdRange" type="range" min="5" max="15" step="1" value="10">
|
||||
</div>
|
||||
<div class="threshold-box">
|
||||
<div id="thresholdValue" class="threshold-value">10 c</div>
|
||||
<div class="setting-sub">порог</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="warningBox" class="warning-box">
|
||||
<div>
|
||||
<div class="setting-title">Предупреждение</div>
|
||||
<div id="warningText" class="setting-sub">Пока недостаточно данных.</div>
|
||||
</div>
|
||||
<div id="warningCount" class="warning-count">0</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card wave-card" style="margin-top:12px">
|
||||
<div class="section-head">
|
||||
<h2>Сигнал движения</h2>
|
||||
<span id="sensorLabel">датчик ещё не запущен</span>
|
||||
</div>
|
||||
<canvas id="wave" width="900" height="210"></canvas>
|
||||
</section>
|
||||
|
||||
<section class="card table-card" style="margin-top:12px">
|
||||
<div class="section-head" style="padding:14px 14px 4px">
|
||||
<h2>Вдохи и выдохи</h2>
|
||||
<span id="cycleCount">0 циклов</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Время</th>
|
||||
<th>Вдох</th>
|
||||
<th>Выдох</th>
|
||||
<th>Цикл</th>
|
||||
<th>Темп</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="breathRows">
|
||||
<tr><td colspan="5" class="empty">Пока нет завершённых циклов</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details>
|
||||
<summary>Диагностика датчика</summary>
|
||||
<div id="debug" class="debug">—</div>
|
||||
</details>
|
||||
<p class="note">Это экспериментальная оценка ритма дыхания по движению телефона, а не медицинский измерительный прибор. Для наиболее стабильного результата телефон должен лежать свободно и не скользить по одежде.</p>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const els = {
|
||||
start: $('startBtn'), stop: $('stopBtn'), recal: $('recalibrateBtn'), invert: $('invertBtn'), clear: $('clearBtn'),
|
||||
phase: $('phase'), quality: $('qualityText'), meter: $('meterBar'), cal: $('calibrationText'), sensor: $('sensorLabel'),
|
||||
avg1: $('avg1'), avg3: $('avg3'), avg5: $('avg5'), rate1: $('rate1'), rate3: $('rate3'), rate5: $('rate5'),
|
||||
inhale1: $('inhale1'), inhale3: $('inhale3'), inhale5: $('inhale5'), exhale1: $('exhale1'), exhale3: $('exhale3'), exhale5: $('exhale5'),
|
||||
soundEnabled: $('soundEnabled'), thresholdRange: $('thresholdRange'), thresholdValue: $('thresholdValue'), warningBox: $('warningBox'), warningText: $('warningText'), warningCount: $('warningCount'),
|
||||
rows: $('breathRows'), count: $('cycleCount'), debug: $('debug'), canvas: $('wave')
|
||||
};
|
||||
const ctx = els.canvas.getContext('2d');
|
||||
|
||||
const CALIBRATION_MS = 10000;
|
||||
const MIN_HALF_PHASE_MS = 800;
|
||||
const MAX_HALF_PHASE_MS = 20000;
|
||||
const MAX_CYCLE_MS = 40000;
|
||||
const MIN_CYCLE_MS = 1800;
|
||||
const HISTORY_MS = 30000;
|
||||
const G = 9.80665;
|
||||
|
||||
let running = false;
|
||||
let calibrating = false;
|
||||
let calibrationStartedAt = 0;
|
||||
let lastTs = null;
|
||||
let sampleCount = 0;
|
||||
let chosen = null; // {kind:'tilt'|'linear', axis:0..2, sign:1}
|
||||
let inverted = false;
|
||||
let wakeLock = null;
|
||||
let noDataTimer = null;
|
||||
|
||||
let gravity = [0,0,0];
|
||||
let gravityReady = false;
|
||||
let filters = makeFilters();
|
||||
let calibrationSamples = [];
|
||||
let signalHistory = [];
|
||||
let cycleRows = [];
|
||||
let turns = [];
|
||||
let detector = freshDetector();
|
||||
let pending = { inhale: null, exhale: null };
|
||||
let currentPhase = null;
|
||||
let latestSignal = 0;
|
||||
let latestThreshold = 0.002;
|
||||
let latestSNR = 0;
|
||||
let latestInterval = 0;
|
||||
let audioCtx = null;
|
||||
let warningLevel = 0;
|
||||
let lastOneMinuteAvgMs = null;
|
||||
|
||||
function makeFilters() {
|
||||
return Array.from({length: 6}, () => ({fast: null, slow: null, last: 0}));
|
||||
}
|
||||
|
||||
function freshDetector() {
|
||||
return {
|
||||
mode: null, // seekingPeak | seekingTrough
|
||||
candidateValue: null,
|
||||
candidateTs: null,
|
||||
lastTurnTs: null
|
||||
};
|
||||
}
|
||||
|
||||
function ema(prev, value, dt, tau) {
|
||||
if (prev == null || !Number.isFinite(prev)) return value;
|
||||
const a = 1 - Math.exp(-Math.max(0.001, dt) / tau);
|
||||
return prev + a * (value - prev);
|
||||
}
|
||||
|
||||
function variance(arr) {
|
||||
if (!arr.length) return 0;
|
||||
const m = arr.reduce((a,b) => a+b, 0) / arr.length;
|
||||
return arr.reduce((s,v) => s + (v-m)*(v-m), 0) / arr.length;
|
||||
}
|
||||
|
||||
function std(arr) { return Math.sqrt(variance(arr)); }
|
||||
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
||||
function fmtSec(ms) { return (ms / 1000).toFixed(1) + ' c'; }
|
||||
|
||||
|
||||
function thresholdSeconds() {
|
||||
return Number(els.thresholdRange.value) || 10;
|
||||
}
|
||||
|
||||
function ensureAudio() {
|
||||
if (!els.soundEnabled.checked) return null;
|
||||
try {
|
||||
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
if (audioCtx.state === 'suspended') audioCtx.resume();
|
||||
return audioCtx;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function playWarningBeeps(count) {
|
||||
if (!els.soundEnabled.checked || count <= 0) return;
|
||||
const ac = ensureAudio();
|
||||
if (!ac) return;
|
||||
const startAt = ac.currentTime + 0.03;
|
||||
const beepLength = 0.12;
|
||||
const gap = 0.13;
|
||||
const maxBeeps = Math.min(count, 20); // защита от бесконечно длинной серии
|
||||
for (let i = 0; i < maxBeeps; i++) {
|
||||
const osc = ac.createOscillator();
|
||||
const gain = ac.createGain();
|
||||
const t0 = startAt + i * (beepLength + gap);
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(740, t0);
|
||||
gain.gain.setValueAtTime(0.0001, t0);
|
||||
gain.gain.exponentialRampToValueAtTime(0.14, t0 + 0.015);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + beepLength);
|
||||
osc.connect(gain);
|
||||
gain.connect(ac.destination);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + beepLength + 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
function getWindowAverages(minutes) {
|
||||
const now = performance.now();
|
||||
const cutoff = now - minutes * 60000;
|
||||
const rows = cycleRows.filter(r => r.endPerf >= cutoff);
|
||||
if (!rows.length) return null;
|
||||
return {
|
||||
rows,
|
||||
inhaleMs: rows.reduce((s,r) => s + r.inhaleMs, 0) / rows.length,
|
||||
exhaleMs: rows.reduce((s,r) => s + r.exhaleMs, 0) / rows.length,
|
||||
cycleMs: rows.reduce((s,r) => s + r.cycleMs, 0) / rows.length
|
||||
};
|
||||
}
|
||||
|
||||
function updateWarningUI() {
|
||||
const threshold = thresholdSeconds();
|
||||
els.thresholdValue.textContent = `${threshold} c`;
|
||||
els.warningCount.textContent = String(warningLevel);
|
||||
|
||||
if (lastOneMinuteAvgMs == null) {
|
||||
els.warningBox.className = 'warning-box';
|
||||
els.warningText.textContent = 'Пока недостаточно данных.';
|
||||
return;
|
||||
}
|
||||
const avgSec = lastOneMinuteAvgMs / 1000;
|
||||
if (avgSec < threshold) {
|
||||
els.warningBox.className = 'warning-box alert';
|
||||
els.warningText.textContent = `Средний цикл за 1 минуту ${avgSec.toFixed(1)} c — ниже порога ${threshold} c.`;
|
||||
} else {
|
||||
els.warningBox.className = 'warning-box ok';
|
||||
els.warningText.textContent = `Средний цикл ${avgSec.toFixed(1)} c — порог ${threshold} c соблюдается.`;
|
||||
}
|
||||
}
|
||||
|
||||
function evaluatePaceAndMaybeWarn(playSound = true) {
|
||||
const avg = getWindowAverages(1);
|
||||
lastOneMinuteAvgMs = avg ? avg.cycleMs : null;
|
||||
const thresholdMs = thresholdSeconds() * 1000;
|
||||
|
||||
if (!avg) {
|
||||
warningLevel = 0;
|
||||
updateWarningUI();
|
||||
return;
|
||||
}
|
||||
|
||||
if (avg.cycleMs < thresholdMs) {
|
||||
warningLevel += 1;
|
||||
if (playSound) playWarningBeeps(warningLevel);
|
||||
} else {
|
||||
warningLevel = 0;
|
||||
}
|
||||
updateWarningUI();
|
||||
}
|
||||
|
||||
async function requestMotionPermission() {
|
||||
if (typeof DeviceMotionEvent === 'undefined') {
|
||||
throw new Error('Этот браузер не поддерживает DeviceMotionEvent.');
|
||||
}
|
||||
if (typeof DeviceMotionEvent.requestPermission === 'function') {
|
||||
const result = await DeviceMotionEvent.requestPermission();
|
||||
if (result !== 'granted') throw new Error('Доступ к датчикам движения не разрешён.');
|
||||
}
|
||||
}
|
||||
|
||||
async function keepAwake() {
|
||||
try {
|
||||
if ('wakeLock' in navigator) wakeLock = await navigator.wakeLock.request('screen');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (running) return;
|
||||
try {
|
||||
await requestMotionPermission();
|
||||
} catch (err) {
|
||||
els.cal.textContent = err.message + ' Если это iPhone — откройте страницу по HTTPS и нажмите кнопку ещё раз.';
|
||||
els.phase.textContent = 'Нет доступа';
|
||||
els.phase.className = 'phase idle';
|
||||
return;
|
||||
}
|
||||
|
||||
running = true;
|
||||
sampleCount = 0;
|
||||
lastTs = null;
|
||||
gravityReady = false;
|
||||
gravity = [0,0,0];
|
||||
filters = makeFilters();
|
||||
signalHistory = [];
|
||||
resetDetection(false);
|
||||
beginCalibration();
|
||||
|
||||
window.addEventListener('devicemotion', onMotion, {passive: true});
|
||||
await keepAwake();
|
||||
els.start.disabled = true;
|
||||
els.stop.disabled = false;
|
||||
els.recal.disabled = false;
|
||||
els.invert.disabled = false;
|
||||
els.phase.textContent = 'Калибровка';
|
||||
els.phase.className = 'phase idle';
|
||||
els.sensor.textContent = 'ожидание данных датчика…';
|
||||
|
||||
clearTimeout(noDataTimer);
|
||||
noDataTimer = setTimeout(() => {
|
||||
if (running && sampleCount < 3) {
|
||||
els.cal.textContent = 'Данные от датчика не приходят. Проверьте HTTPS, разрешение Motion & Orientation и попробуйте другой браузер.';
|
||||
els.quality.textContent = 'нет данных';
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
if (!running) return;
|
||||
running = false;
|
||||
calibrating = false;
|
||||
window.removeEventListener('devicemotion', onMotion);
|
||||
clearTimeout(noDataTimer);
|
||||
if (wakeLock) { try { await wakeLock.release(); } catch (_) {} wakeLock = null; }
|
||||
els.start.disabled = false;
|
||||
els.stop.disabled = true;
|
||||
els.recal.disabled = true;
|
||||
els.invert.disabled = true;
|
||||
els.phase.textContent = 'Остановлено';
|
||||
els.phase.className = 'phase idle';
|
||||
els.cal.textContent = 'Измерение остановлено.';
|
||||
els.meter.style.width = '0%';
|
||||
}
|
||||
|
||||
function beginCalibration() {
|
||||
calibrating = true;
|
||||
calibrationStartedAt = performance.now();
|
||||
calibrationSamples = [];
|
||||
chosen = null;
|
||||
filters = makeFilters();
|
||||
resetDetection(false);
|
||||
els.cal.textContent = 'Калибровка: лежите спокойно и дышите как обычно…';
|
||||
}
|
||||
|
||||
function resetDetection(clearRows = false) {
|
||||
detector = freshDetector();
|
||||
turns = [];
|
||||
pending = { inhale: null, exhale: null };
|
||||
currentPhase = null;
|
||||
if (clearRows) cycleRows = [];
|
||||
renderTable();
|
||||
updateStats();
|
||||
}
|
||||
|
||||
function onMotion(ev) {
|
||||
if (!running) return;
|
||||
const now = performance.now();
|
||||
const dt = lastTs == null ? (ev.interval ? ev.interval/1000 : 0.02) : clamp((now-lastTs)/1000, 0.005, 0.2);
|
||||
lastTs = now;
|
||||
latestInterval = ev.interval || dt*1000;
|
||||
sampleCount++;
|
||||
|
||||
const ag = ev.accelerationIncludingGravity;
|
||||
if (!ag || [ag.x,ag.y,ag.z].some(v => v == null || !Number.isFinite(v))) return;
|
||||
const a = ev.acceleration;
|
||||
const agv = [ag.x, ag.y, ag.z];
|
||||
|
||||
if (!gravityReady) {
|
||||
gravity = agv.slice();
|
||||
gravityReady = true;
|
||||
} else {
|
||||
// Gravity follows slow orientation changes but ignores quick vibration.
|
||||
const tauG = 0.55;
|
||||
for (let i=0;i<3;i++) gravity[i] = ema(gravity[i], agv[i], dt, tauG);
|
||||
}
|
||||
|
||||
const gm = Math.hypot(gravity[0], gravity[1], gravity[2]) || G;
|
||||
const tilt = gravity.map(v => v / gm);
|
||||
const lin = (a && [a.x,a.y,a.z].every(v => v != null && Number.isFinite(v)))
|
||||
? [a.x/G, a.y/G, a.z/G]
|
||||
: agv.map((v,i) => (v-gravity[i])/G);
|
||||
const candidates = [...tilt, ...lin];
|
||||
const band = candidates.map((v, i) => {
|
||||
const f = filters[i];
|
||||
f.fast = ema(f.fast, v, dt, 0.38);
|
||||
f.slow = ema(f.slow, v, dt, 7.0);
|
||||
f.last = f.fast - f.slow;
|
||||
return f.last;
|
||||
});
|
||||
|
||||
if (calibrating) {
|
||||
calibrationSamples.push({t: now, band: band.slice()});
|
||||
const elapsed = now - calibrationStartedAt;
|
||||
const pct = clamp(elapsed / CALIBRATION_MS, 0, 1);
|
||||
els.meter.style.width = (pct*100).toFixed(0) + '%';
|
||||
els.cal.textContent = `Калибровка: ${Math.max(0, Math.ceil((CALIBRATION_MS-elapsed)/1000))} c. Дышите спокойно.`;
|
||||
if (elapsed >= CALIBRATION_MS) finishCalibration();
|
||||
drawSignal(now);
|
||||
updateDebug(tilt, lin);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chosen) return;
|
||||
const idx = chosen.kind === 'tilt' ? chosen.axis : chosen.axis + 3;
|
||||
latestSignal = band[idx] * chosen.sign;
|
||||
signalHistory.push({t: now, v: latestSignal});
|
||||
trimSignal(now);
|
||||
updateThreshold();
|
||||
detectTurn(now, latestSignal);
|
||||
updateQuality();
|
||||
drawSignal(now);
|
||||
updateDebug(tilt, lin);
|
||||
}
|
||||
|
||||
function finishCalibration() {
|
||||
calibrating = false;
|
||||
const recent = calibrationSamples.filter(s => s.t >= performance.now() - CALIBRATION_MS);
|
||||
const cols = Array.from({length:6}, (_,i) => recent.map(s => s.band[i]));
|
||||
const scores = cols.map(values => {
|
||||
if (values.length < 10) return 0;
|
||||
const s = std(values);
|
||||
// Prefer a coherent slow signal: downsample rough derivative as a noise penalty.
|
||||
let rough = 0;
|
||||
for (let i=1;i<values.length;i++) rough += Math.abs(values[i]-values[i-1]);
|
||||
rough /= Math.max(1, values.length-1);
|
||||
return s / (rough + 1e-6);
|
||||
});
|
||||
|
||||
const tiltStd = cols.slice(0,3).map(std);
|
||||
const linearStd = cols.slice(3,6).map(std);
|
||||
const bestTiltAxis = tiltStd.indexOf(Math.max(...tiltStd));
|
||||
const bestLinearAxis = linearStd.indexOf(Math.max(...linearStd));
|
||||
|
||||
// Tilt is closer to actual abdominal position. Use linear acceleration only when tilt is almost absent.
|
||||
if (tiltStd[bestTiltAxis] >= 0.0012) {
|
||||
chosen = {kind:'tilt', axis:bestTiltAxis, sign:1};
|
||||
} else {
|
||||
chosen = {kind:'linear', axis:bestLinearAxis, sign:1};
|
||||
}
|
||||
|
||||
const labels = ['X','Y','Z'];
|
||||
els.sensor.textContent = `${chosen.kind === 'tilt' ? 'наклон' : 'ускорение'} · ось ${labels[chosen.axis]}`;
|
||||
els.cal.textContent = 'Калибровка завершена. Если вдох и выдох перепутаны — нажмите «Вдох ↔ выдох».';
|
||||
els.meter.style.width = '100%';
|
||||
setTimeout(() => { if (running && !calibrating) els.meter.style.width = '0%'; }, 600);
|
||||
|
||||
signalHistory = [];
|
||||
detector = freshDetector();
|
||||
pending = {inhale:null, exhale:null};
|
||||
}
|
||||
|
||||
function trimSignal(now) {
|
||||
const cutoff = now - HISTORY_MS;
|
||||
while (signalHistory.length && signalHistory[0].t < cutoff) signalHistory.shift();
|
||||
}
|
||||
|
||||
function updateThreshold() {
|
||||
const values = signalHistory.filter(s => s.t >= performance.now()-12000).map(s => s.v);
|
||||
if (values.length < 20) return;
|
||||
const s = std(values);
|
||||
latestThreshold = clamp(s * 0.55, chosen && chosen.kind === 'tilt' ? 0.00045 : 0.00018, 0.035);
|
||||
|
||||
let rough = 0;
|
||||
for (let i=1;i<values.length;i++) rough += Math.abs(values[i]-values[i-1]);
|
||||
rough /= Math.max(1, values.length-1);
|
||||
latestSNR = s / Math.max(rough*4, 1e-6);
|
||||
}
|
||||
|
||||
function detectTurn(now, value) {
|
||||
const d = detector;
|
||||
if (d.mode == null) {
|
||||
d.mode = 'seekingPeak';
|
||||
d.candidateValue = value;
|
||||
d.candidateTs = now;
|
||||
return;
|
||||
}
|
||||
|
||||
if (d.mode === 'seekingPeak') {
|
||||
if (d.candidateValue == null || value > d.candidateValue) {
|
||||
d.candidateValue = value;
|
||||
d.candidateTs = now;
|
||||
}
|
||||
const drop = d.candidateValue - value;
|
||||
const enoughTime = d.lastTurnTs == null || d.candidateTs - d.lastTurnTs >= MIN_HALF_PHASE_MS;
|
||||
if (drop >= latestThreshold && enoughTime) {
|
||||
confirmTurn('peak', d.candidateTs, d.candidateValue);
|
||||
d.mode = 'seekingTrough';
|
||||
d.candidateValue = value;
|
||||
d.candidateTs = now;
|
||||
}
|
||||
} else {
|
||||
if (d.candidateValue == null || value < d.candidateValue) {
|
||||
d.candidateValue = value;
|
||||
d.candidateTs = now;
|
||||
}
|
||||
const rise = value - d.candidateValue;
|
||||
const enoughTime = d.lastTurnTs == null || d.candidateTs - d.lastTurnTs >= MIN_HALF_PHASE_MS;
|
||||
if (rise >= latestThreshold && enoughTime) {
|
||||
confirmTurn('trough', d.candidateTs, d.candidateValue);
|
||||
d.mode = 'seekingPeak';
|
||||
d.candidateValue = value;
|
||||
d.candidateTs = now;
|
||||
}
|
||||
}
|
||||
|
||||
if (d.lastTurnTs != null && now - d.lastTurnTs > MAX_HALF_PHASE_MS) {
|
||||
// Lost the rhythm; start looking again instead of inventing a huge breath.
|
||||
detector = freshDetector();
|
||||
pending = {inhale:null, exhale:null};
|
||||
currentPhase = null;
|
||||
setPhaseUI(null);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmTurn(type, ts, value) {
|
||||
const d = detector;
|
||||
const prev = turns.length ? turns[turns.length-1] : null;
|
||||
if (prev && prev.type === type) return;
|
||||
if (prev && ts - prev.ts < MIN_HALF_PHASE_MS) return;
|
||||
|
||||
const turn = {type, ts, value};
|
||||
turns.push(turn);
|
||||
if (turns.length > 30) turns.shift();
|
||||
d.lastTurnTs = ts;
|
||||
|
||||
if (!prev) return;
|
||||
const duration = ts - prev.ts;
|
||||
if (duration > MAX_HALF_PHASE_MS) return;
|
||||
|
||||
// In the default orientation: trough -> peak = inhale, peak -> trough = exhale.
|
||||
let phase = type === 'peak' ? 'inhale' : 'exhale';
|
||||
if (inverted) phase = phase === 'inhale' ? 'exhale' : 'inhale';
|
||||
currentPhase = phase === 'inhale' ? 'exhale' : 'inhale'; // after the turning point the next phase begins
|
||||
setPhaseUI(currentPhase);
|
||||
|
||||
pending[phase] = {duration, endTs: ts};
|
||||
maybeCompleteCycle(ts);
|
||||
}
|
||||
|
||||
function maybeCompleteCycle(endTs) {
|
||||
if (!pending.inhale || !pending.exhale) return;
|
||||
const cycleMs = pending.inhale.duration + pending.exhale.duration;
|
||||
if (cycleMs < MIN_CYCLE_MS || cycleMs > MAX_CYCLE_MS) {
|
||||
pending = {inhale:null, exhale:null};
|
||||
return;
|
||||
}
|
||||
|
||||
const row = {
|
||||
endPerf: endTs,
|
||||
wallTime: Date.now() - (performance.now() - endTs),
|
||||
inhaleMs: pending.inhale.duration,
|
||||
exhaleMs: pending.exhale.duration,
|
||||
cycleMs
|
||||
};
|
||||
cycleRows.push(row);
|
||||
if (cycleRows.length > 500) cycleRows.shift();
|
||||
|
||||
// Keep the just-started opposite phase only if its end is the most recent turn; otherwise reset.
|
||||
pending = {inhale:null, exhale:null};
|
||||
renderTable();
|
||||
updateStats();
|
||||
evaluatePaceAndMaybeWarn(true);
|
||||
}
|
||||
|
||||
function setPhaseUI(phase) {
|
||||
if (!phase) {
|
||||
els.phase.textContent = running ? 'Ищу ритм…' : 'Готово';
|
||||
els.phase.className = 'phase idle';
|
||||
return;
|
||||
}
|
||||
els.phase.textContent = phase === 'inhale' ? 'Вдох' : 'Выдох';
|
||||
els.phase.className = `phase ${phase}`;
|
||||
}
|
||||
|
||||
function updateQuality() {
|
||||
if (signalHistory.length < 20) {
|
||||
els.quality.textContent = '…';
|
||||
return;
|
||||
}
|
||||
const values = signalHistory.filter(s => s.t >= performance.now()-10000).map(s => s.v);
|
||||
const amplitude = std(values);
|
||||
const floor = chosen && chosen.kind === 'tilt' ? 0.0008 : 0.00018;
|
||||
const q = clamp((amplitude / floor) * 24 + clamp(latestSNR,0,2)*22, 0, 100);
|
||||
let label = 'низкое';
|
||||
if (q >= 72) label = 'хорошее';
|
||||
else if (q >= 42) label = 'среднее';
|
||||
els.quality.textContent = label;
|
||||
els.quality.style.color = q >= 72 ? 'var(--good)' : q >= 42 ? 'var(--warn)' : 'var(--bad)';
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
[
|
||||
[1, els.inhale1, els.exhale1, els.avg1, els.rate1],
|
||||
[3, els.inhale3, els.exhale3, els.avg3, els.rate3],
|
||||
[5, els.inhale5, els.exhale5, els.avg5, els.rate5]
|
||||
].forEach(([minutes, inhaleEl, exhaleEl, cycleEl, rateEl]) => {
|
||||
const avg = getWindowAverages(minutes);
|
||||
if (!avg) {
|
||||
inhaleEl.textContent = '—';
|
||||
exhaleEl.textContent = '—';
|
||||
cycleEl.textContent = '—';
|
||||
rateEl.textContent = '— дых./мин';
|
||||
return;
|
||||
}
|
||||
const rate = 60000 / avg.cycleMs;
|
||||
inhaleEl.textContent = (avg.inhaleMs/1000).toFixed(1) + ' c';
|
||||
exhaleEl.textContent = (avg.exhaleMs/1000).toFixed(1) + ' c';
|
||||
cycleEl.textContent = (avg.cycleMs/1000).toFixed(1) + ' c';
|
||||
rateEl.textContent = `${rate.toFixed(1)} дых./мин · n=${avg.rows.length}`;
|
||||
});
|
||||
|
||||
const oneMinute = getWindowAverages(1);
|
||||
lastOneMinuteAvgMs = oneMinute ? oneMinute.cycleMs : null;
|
||||
updateWarningUI();
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
els.count.textContent = `${cycleRows.length} ${pluralCycles(cycleRows.length)}`;
|
||||
if (!cycleRows.length) {
|
||||
els.rows.innerHTML = '<tr><td colspan="5" class="empty">Пока нет завершённых циклов</td></tr>';
|
||||
return;
|
||||
}
|
||||
const recent = cycleRows.slice(-40).reverse();
|
||||
els.rows.innerHTML = recent.map(r => {
|
||||
const time = new Date(r.wallTime).toLocaleTimeString('ru-RU', {hour:'2-digit', minute:'2-digit', second:'2-digit'});
|
||||
const rate = 60000 / r.cycleMs;
|
||||
return `<tr><td>${time}</td><td>${fmtSec(r.inhaleMs)}</td><td>${fmtSec(r.exhaleMs)}</td><td><strong>${fmtSec(r.cycleMs)}</strong></td><td>${rate.toFixed(1)}/мин</td></tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function pluralCycles(n) {
|
||||
const m10 = n % 10, m100 = n % 100;
|
||||
if (m10 === 1 && m100 !== 11) return 'цикл';
|
||||
if ([2,3,4].includes(m10) && !(m100 >= 12 && m100 <= 14)) return 'цикла';
|
||||
return 'циклов';
|
||||
}
|
||||
|
||||
function drawSignal(now = performance.now()) {
|
||||
const rect = els.canvas.getBoundingClientRect();
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const w = Math.max(320, Math.floor(rect.width*dpr));
|
||||
const h = Math.max(150, Math.floor(rect.height*dpr));
|
||||
if (els.canvas.width !== w || els.canvas.height !== h) {
|
||||
els.canvas.width = w; els.canvas.height = h;
|
||||
}
|
||||
ctx.clearRect(0,0,w,h);
|
||||
|
||||
ctx.strokeStyle = 'rgba(148,163,184,.10)';
|
||||
ctx.lineWidth = 1*dpr;
|
||||
for (let i=1;i<4;i++) {
|
||||
const y = h*i/4;
|
||||
ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke();
|
||||
}
|
||||
|
||||
if (!signalHistory.length) return;
|
||||
const data = signalHistory.filter(s => s.t >= now-HISTORY_MS);
|
||||
if (data.length < 2) return;
|
||||
const vals = data.map(s=>s.v);
|
||||
const maxAbs = Math.max(latestThreshold*2.5, ...vals.map(v=>Math.abs(v)), 0.001);
|
||||
const mapX = t => ((t-(now-HISTORY_MS))/HISTORY_MS)*w;
|
||||
const mapY = v => h/2 - (v/maxAbs)*(h*0.38);
|
||||
|
||||
ctx.strokeStyle = 'rgba(125,211,252,.95)';
|
||||
ctx.lineWidth = 2*dpr;
|
||||
ctx.beginPath();
|
||||
data.forEach((s,i) => {
|
||||
const x=mapX(s.t), y=mapY(s.v);
|
||||
if (i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = 'rgba(196,181,253,.34)';
|
||||
ctx.lineWidth = 1*dpr;
|
||||
[latestThreshold,-latestThreshold].forEach(v => {
|
||||
const y = mapY(v); ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke();
|
||||
});
|
||||
|
||||
for (const t of turns) {
|
||||
if (t.ts < now-HISTORY_MS) continue;
|
||||
const x = mapX(t.ts), y = mapY(t.value);
|
||||
ctx.fillStyle = t.type === 'peak' ? '#7dd3fc' : '#c4b5fd';
|
||||
ctx.beginPath(); ctx.arc(x,y,3.5*dpr,0,Math.PI*2); ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
function updateDebug(tilt, lin) {
|
||||
const ch = chosen ? `${chosen.kind}/${['X','Y','Z'][chosen.axis]}` : '—';
|
||||
els.debug.textContent = [
|
||||
`samples: ${sampleCount}`,
|
||||
`interval: ${latestInterval.toFixed(1)} ms`,
|
||||
`channel: ${ch}`,
|
||||
`tilt: ${tilt.map(v=>v.toFixed(5)).join(' ')}`,
|
||||
`linear/g: ${lin.map(v=>v.toFixed(5)).join(' ')}`,
|
||||
`signal: ${latestSignal.toFixed(6)}`,
|
||||
`threshold: ${latestThreshold.toFixed(6)}`,
|
||||
`snr-ish: ${latestSNR.toFixed(2)}`,
|
||||
`inverted: ${inverted}`
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function invertPhases() {
|
||||
inverted = !inverted;
|
||||
pending = {inhale:null, exhale:null};
|
||||
turns = [];
|
||||
detector = freshDetector();
|
||||
els.invert.textContent = inverted ? 'Инверсия: вкл' : 'Вдох ↔ выдох';
|
||||
els.cal.textContent = inverted ? 'Направление фаз инвертировано.' : 'Обычное направление фаз.';
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
cycleRows = [];
|
||||
signalHistory = [];
|
||||
turns = [];
|
||||
pending = {inhale:null, exhale:null};
|
||||
detector = freshDetector();
|
||||
warningLevel = 0;
|
||||
lastOneMinuteAvgMs = null;
|
||||
renderTable();
|
||||
updateStats();
|
||||
drawSignal();
|
||||
if (running && !calibrating) els.cal.textContent = 'История очищена, измерение продолжается.';
|
||||
}
|
||||
|
||||
els.start.addEventListener('click', async () => {
|
||||
ensureAudio();
|
||||
await start();
|
||||
});
|
||||
els.stop.addEventListener('click', stop);
|
||||
els.recal.addEventListener('click', () => { if (running) beginCalibration(); });
|
||||
els.invert.addEventListener('click', invertPhases);
|
||||
els.clear.addEventListener('click', clearAll);
|
||||
els.soundEnabled.addEventListener('change', () => {
|
||||
if (els.soundEnabled.checked) ensureAudio();
|
||||
});
|
||||
els.thresholdRange.addEventListener('input', () => {
|
||||
const avg = getWindowAverages(1);
|
||||
lastOneMinuteAvgMs = avg ? avg.cycleMs : null;
|
||||
if (avg && avg.cycleMs >= thresholdSeconds() * 1000) warningLevel = 0;
|
||||
updateWarningUI();
|
||||
});
|
||||
window.addEventListener('resize', () => drawSignal());
|
||||
document.addEventListener('visibilitychange', async () => {
|
||||
if (document.visibilityState === 'visible' && running && !wakeLock) await keepAwake();
|
||||
});
|
||||
|
||||
// Recalculate rolling cards even if a motion event pauses briefly.
|
||||
setInterval(() => { if (running) updateStats(); }, 3000);
|
||||
renderTable();
|
||||
updateStats();
|
||||
updateWarningUI();
|
||||
drawSignal();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user