UI: обновить стартовые кнопки и static-sites

This commit is contained in:
AidarKC
2026-09-29 11:00:04 +03:00
parent 9be4c4d3a5
commit 08c7fd7676
8 changed files with 1997 additions and 72 deletions
@@ -0,0 +1,725 @@
<!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; }
.stat .value { font-weight: 800; font-size: clamp(22px, 7vw, 34px); margin-top: 4px; letter-spacing: -.04em; }
.stat .unit { color: var(--muted); font-size: 11px; margin-top: 2px; }
.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 id="avg1" class="value">—</div>
<div id="rate1" class="unit">сек/цикл · — дых./мин</div>
</div>
<div class="stat">
<div class="period">За последние 3 минуты</div>
<div id="avg3" class="value">—</div>
<div id="rate3" class="unit">сек/цикл · — дых./мин</div>
</div>
<div class="stat">
<div class="period">За последние 5 минут</div>
<div id="avg5" class="value">—</div>
<div id="rate5" class="unit">сек/цикл · — дых./мин</div>
</div>
</section>
</div>
<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'),
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;
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'; }
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();
}
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() {
const now = performance.now();
[[1,els.avg1,els.rate1],[3,els.avg3,els.rate3],[5,els.avg5,els.rate5]].forEach(([minutes,valEl,rateEl]) => {
const cutoff = now - minutes*60000;
const rows = cycleRows.filter(r => r.endPerf >= cutoff);
if (!rows.length) {
valEl.textContent = '—';
rateEl.textContent = 'сек/цикл · — дых./мин';
return;
}
const avgMs = rows.reduce((s,r) => s+r.cycleMs,0) / rows.length;
const rate = 60000 / avgMs;
valEl.textContent = (avgMs/1000).toFixed(1);
rateEl.textContent = `сек/цикл · ${rate.toFixed(1)} дых./мин · n=${rows.length}`;
});
}
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();
renderTable();
updateStats();
drawSignal();
if (running && !calibrating) els.cal.textContent = 'История очищена, измерение продолжается.';
}
els.start.addEventListener('click', start);
els.stop.addEventListener('click', stop);
els.recal.addEventListener('click', () => { if (running) beginCalibration(); });
els.invert.addEventListener('click', invertPhases);
els.clear.addEventListener('click', clearAll);
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();
drawSignal();
})();
</script>
</body>
</html>
@@ -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>