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
+4 -7
View File
@@ -138,11 +138,8 @@
- с какого сценария продолжать разработку;
- из какого коммита брать последнюю полную реализацию.
## Коммуникация по новым задачам (обязательно)
- При получении нового задания сначала кратко пересказать задачу своими словами.
- До начала реализации задать недостающие уточняющие вопросы (если они есть).
## Коммуникация по новым задачам
- При получении нового задания кратко подтвердить понимание задачи, если это помогает синхронизироваться.
- До начала реализации задавать уточняющие вопросы только когда без ответа есть риск сделать не то или затронуть опасную область.
- Если задача понятна и не требует отдельного подтверждения по другим правилам проекта, переходить к реализации сразу.
- Если есть уместные идеи/улучшения — кратко предложить их; если полезных идей нет, ничего дополнительно не предлагать.
- Добавлять краткую оценку фичи (насколько это полезно/удачно по мнению исполнителя).
- После этого обязательно запросить подтверждение от пользователя, что задача понята верно, и только после подтверждения переходить к реализации.
- Если вопросов нет, явно написать в формате: «Я всё понял, начинаю делать?» и ждать подтверждения.
- Без подтверждения пользователя реализацию не начинать.
+1 -1
View File
@@ -1,2 +1,2 @@
client.version=1.14.0
client.version=1.14.1
server.version=1.12.0
+3 -3
View File
@@ -31,13 +31,13 @@ export function render({ navigate }) {
loginButton.addEventListener('click', () => navigate('login-view'));
const registerButton = document.createElement('button');
registerButton.className = 'secondary-btn shine-btn--register';
registerButton.className = 'shine-btn shine-btn--primary';
registerButton.type = 'button';
registerButton.textContent = 'Зарегистрироваться';
registerButton.addEventListener('click', () => navigate('register-view'));
const languageButton = document.createElement('button');
languageButton.className = 'text-btn start-language-link';
languageButton.className = 'shine-btn shine-btn--primary';
languageButton.type = 'button';
languageButton.textContent = 'Язык / Language';
languageButton.addEventListener('click', () => {
@@ -46,7 +46,7 @@ export function render({ navigate }) {
});
const settingsButton = document.createElement('button');
settingsButton.className = 'text-btn start-settings-link';
settingsButton.className = 'shine-btn shine-btn--primary';
settingsButton.type = 'button';
settingsButton.textContent = 'Настройки входа';
settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
+1 -48
View File
@@ -4,51 +4,4 @@
Все содержимое этой папки деплоится вместе с `shine-UI`, потому что общий UI deploy синхронизирует весь каталог `shine-UI/` в Caddy web root. На текущей схеме отдельная перенастройка Caddy для каждого нового сайта не нужна: Caddy сначала отдает существующие файлы из UI root, а уже потом делает fallback на основной `index.html`.
## URL
Если UI задеплоен на `https://t2.shineup.me`, то эта папка доступна как:
```text
https://t2.shineup.me/static-sites/
```
Текущие подпапки:
```text
/static-sites/design-examples/channels-v1/
/static-sites/arweave-viewer/
```
Для production домен меняется на нужный хост, например:
```text
https://shineup.me/static-sites/
https://server2.shineup.me/static-sites/
```
## Как добавлять новый временный сайт
1. Создать подпапку внутри `shine-UI/static-sites/`.
2. Положить туда `index.html` и связанные файлы.
3. Выполнить обычный UI deploy нужного контура через `deploy/scripts/*_ui.sh`.
После этого сайт будет доступен по URL:
```text
https://<host>/static-sites/<folder>/
```
## Arweave viewer
Постоянная папка для viewer:
```text
shine-UI/static-sites/arweave-viewer/
```
Когда появляется новая версия viewer, можно заменить содержимое этой папки новой версией и выполнить обычный UI deploy. URL останется прежним:
```text
https://<host>/static-sites/arweave-viewer/
```
НО К UI Никакого отношения не имеет :))
@@ -0,0 +1,343 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,user-scalable=no">
<meta name="theme-color" content="#0d1016">
<title>Walking Arm Monitor v4 · Axes</title>
<style>
:root{color-scheme:dark;--bg:#0d1016;--card:#171b24;--line:#2a3140;--text:#f6f7fb;--muted:#98a2b3;--blue:#8db8ff;--good:#7ee2a8;--mid:#ffd479;--bad:#ff8c8c}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif}main{max-width:760px;margin:auto;padding:16px 14px 38px}.top h1{margin:3px 0 2px;font-size:24px}.top p{margin:0;color:var(--muted);font-size:14px}.card{margin-top:12px;background:var(--card);border:1px solid var(--line);border-radius:22px;padding:16px}.hero{text-align:center;padding:22px 16px}.eyebrow{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}#state{font-size:42px;font-weight:900;line-height:1.05;margin:8px 0 5px}.score{font-size:64px;font-weight:900;line-height:1}.score small{font-size:22px;color:var(--muted)}.bar{height:12px;background:#0f1218;border-radius:999px;overflow:hidden;margin:14px 2px 4px}.fill{height:100%;width:0%;background:var(--blue);transition:width .22s ease}.hint{font-size:13px;color:var(--muted);min-height:20px}.grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.metric{padding:13px;background:#10141b;border-radius:16px}.metric span{display:block;font-size:12px;color:var(--muted)}.metric b{display:block;font-size:25px;margin-top:3px}.metric em{font-style:normal;font-size:12px;color:var(--muted)}button{width:100%;border:0;border-radius:16px;padding:15px 13px;margin-top:8px;font-size:16px;font-weight:800;background:#29303d;color:white;touch-action:manipulation}button.primary{background:var(--blue);color:#07101d}button.good{background:var(--good);color:#07150e}button.warn{background:var(--mid);color:#1b1302}button.stop{background:var(--bad);color:#1b0707}button:disabled{opacity:.42}.row{display:flex;gap:8px}.row>*{flex:1}.settings{display:grid;grid-template-columns:1fr;gap:14px;margin-top:12px}.setting{background:#10141b;border-radius:16px;padding:13px}.setting-head{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:9px}.setting-head span{font-size:13px;color:var(--muted)}.setting-head b{font-size:15px}.delay-row{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}.delay-btn{margin:0;padding:12px 8px;background:#222936}.delay-btn.active{background:var(--blue);color:#07101d}.axis-row{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}.axis-btn{margin:0;padding:12px 8px;background:#222936}.axis-btn.active{background:var(--good);color:#07150e}.axis-help{font-size:12px;color:var(--muted);line-height:1.35;margin-top:8px}.slider{width:100%;accent-color:#8db8ff}.support{margin-top:8px;font-size:12px;color:var(--muted)}.rec{display:none;align-items:center;justify-content:center;gap:8px;margin:10px 0 4px;font-weight:700}.rec.on{display:flex}.dot{width:10px;height:10px;border-radius:50%;background:#ff5151;box-shadow:0 0 0 5px #ff515122}.small{font-size:13px;color:var(--muted);line-height:1.4}.statusline{margin-top:8px}.pill{display:inline-block;padding:5px 9px;border:1px solid var(--line);border-radius:999px;color:var(--muted);font-size:12px}canvas{display:block;width:100%;height:120px;background:#0e1117;border-radius:14px}.footer{font-size:12px;color:var(--muted);text-align:center;margin-top:12px}
</style>
</head>
<body>
<main>
<div class="top"><h1>Рука при ходьбе · v4</h1><p>Распознавание маха по выбранным осям телефона</p></div>
<section class="card">
<button id="startBtn" class="primary">Разрешить датчики</button>
<div id="sensorInfo" class="small statusline">Датчики ещё не запущены.</div>
</section>
<section class="card hero">
<div class="eyebrow">Текущее состояние</div>
<div id="state">—</div>
<div class="score"><span id="score">—</span><small>%</small></div>
<div class="bar"><div id="fill" class="fill"></div></div>
<div id="hint" class="hint">Сначала включи датчики и начни идти.</div>
<div id="swingTimer" class="small" style="margin-top:8px;min-height:20px"></div>
</section>
<section class="card">
<button id="soundBtn">🔇 Звук: выкл</button>
<div class="settings">
<div class="setting">
<div class="setting-head"><span>Громкость сигнала</span><b id="volumeValue">100%</b></div>
<input id="volumeSlider" class="slider" type="range" min="0" max="100" step="5" value="100" aria-label="Громкость сигнала">
</div>
<div class="setting">
<div class="setting-head"><span>Предупреждать после</span><b id="delayValue">5 секунд</b></div>
<div class="delay-row">
<button class="delay-btn" data-delay="3">3 с</button>
<button class="delay-btn active" data-delay="5">5 с</button>
<button class="delay-btn" data-delay="7">7 с</button>
</div>
</div>
<div class="setting">
<div class="setting-head"><span>Оси ускорения для распознавания</span><b id="axesValue">Y</b></div>
<div class="axis-row">
<button class="axis-btn" data-axis="x">X</button>
<button class="axis-btn active" data-axis="y">Y</button>
<button class="axis-btn" data-axis="z">Z</button>
</div>
<div class="axis-help">
X — влево/вправо по экрану · Y — вдоль телефона от низа к верху · Z — перпендикулярно экрану.
По умолчанию включена только Y: в твоём хвате боком она соответствует движению руки вперёд-назад.
Переворот телефона на 180° не мешает — учитывается амплитуда, а не знак.
</div>
</div>
</div>
<div id="soundInfo" class="small" style="margin-top:10px">Сигнал выключен. При включении используется максимальная громкость страницы.</div>
<div id="vibrationInfo" class="support">Вибрация: проверяется браузером при запуске.</div>
</section>
<section class="card">
<div class="grid">
<div class="metric"><span>Сигнал распознавания</span><b id="detectRms">—</b><em>м/с² RMS</em></div>
<div class="metric"><span>Ось X, RMS 1.5 с</span><b id="xRms">—</b><em>м/с²</em></div>
<div class="metric"><span>Ось Y, RMS 1.5 с</span><b id="yRms">—</b><em>м/с²</em></div>
<div class="metric"><span>Ось Z, RMS 1.5 с</span><b id="zRms">—</b><em>м/с²</em></div>
<div class="metric"><span>Частота датчика</span><b id="hz">—</b><em>Гц</em></div>
<div class="metric"><span>Записано</span><b id="samples">0</b><em>отсчётов</em></div>
</div>
</section>
<section class="card">
<div class="eyebrow">Запись примеров</div>
<div id="recLine" class="rec"><span class="dot"></span><span id="recText">Запись</span></div>
<div class="row">
<button id="stableBtn" class="good">Запись: РОВНО</button>
<button id="swingBtn" class="warn">Запись: МАХ</button>
</div>
<button id="stopBtn" class="stop" disabled>Стоп и скачать CSV</button>
<div class="small" style="margin-top:8px">Запись не влияет на распознавание. Можно продолжать собирать примеры и потом прислать CSV.</div>
</section>
<section class="card">
<div class="small" style="margin-bottom:8px">Последние секунды: сигнал выбранных осей ускорения</div>
<canvas id="chart" width="700" height="150"></canvas>
</section>
<section class="card small">
Алгоритм v4 по умолчанию смотрит только на динамическое ускорение вдоль оси Y телефона. Это специально убирает большую часть ложных срабатываний от вертикальной качки тела при быстрой ходьбе. Можно включить X и/или Z вручную. Если включено несколько осей, распознавание берёт самую выраженную из выбранных осей. Пороги для Y настроены по твоим записям «РОВНО» и «МАХ».
</section>
<div class="footer">Walking Arm Monitor v4 · Axes</div>
</main>
<script>
(()=>{
const $=id=>document.getElementById(id);
const el={start:$('startBtn'),info:$('sensorInfo'),state:$('state'),score:$('score'),fill:$('fill'),hint:$('hint'),swingTimer:$('swingTimer'),soundBtn:$('soundBtn'),soundInfo:$('soundInfo'),volumeSlider:$('volumeSlider'),volumeValue:$('volumeValue'),delayValue:$('delayValue'),axesValue:$('axesValue'),vibrationInfo:$('vibrationInfo'),detectRms:$('detectRms'),xRms:$('xRms'),yRms:$('yRms'),zRms:$('zRms'),hz:$('hz'),samples:$('samples'),stable:$('stableBtn'),swing:$('swingBtn'),stop:$('stopBtn'),recLine:$('recLine'),recText:$('recText'),chart:$('chart')};
let running=false,lastEventAt=0,sampleTimes=[],buf=[],graph=[];
let grav={x:0,y:0,z:0},gravReady=false; const gravAlpha=.92;
let recording=false,label='',recordStart=0,rows=[];
let shownState='WAIT',candidate='WAIT',candidateSince=0,lastUi=0;
let soundEnabled=false,audioCtx=null,swingSince=null,lastBeepAt=0,alertDelayMs=5000,alertVolume=1;
const selectedAxes={x:false,y:true,z:false};
const WINDOW_MS=1500,HOLD_MS=800,GRAPH_N=240,BEEP_EVERY_MS=1200;
const finite=v=>Number.isFinite(v)?v:null;
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
const hypot3=(x,y,z)=>[x,y,z].every(Number.isFinite)?Math.hypot(x,y,z):null;
const fmt=(v,d=1)=>Number.isFinite(v)?v.toFixed(d):'—';
function thresholds(signal){
if(!Number.isFinite(signal)) return 'WAIT';
if(signal < 0.85) return 'STABLE';
if(signal > 1.15) return 'SWING';
return 'MID';
}
function stabilityScore(signal){
if(!Number.isFinite(signal)) return null;
return Math.round(clamp(100*(1.65-signal)/(1.65-0.45),0,100));
}
function setAxis(axis){
selectedAxes[axis]=!selectedAxes[axis];
if(!selectedAxes.x&&!selectedAxes.y&&!selectedAxes.z){
selectedAxes[axis]=true;
}
document.querySelectorAll('.axis-btn').forEach(btn=>{
btn.classList.toggle('active',!!selectedAxes[btn.dataset.axis]);
});
const names=['x','y','z'].filter(a=>selectedAxes[a]).map(a=>a.toUpperCase());
el.axesValue.textContent=names.join('+');
buf.length=0;
graph.length=0;
shownState='WAIT';candidate='WAIT';candidateSince=performance.now();
swingSince=null;lastBeepAt=0;
}
function selectedSignal(m){
const vals=[];
if(selectedAxes.x&&Number.isFinite(m.x)) vals.push(m.x);
if(selectedAxes.y&&Number.isFinite(m.y)) vals.push(m.y);
if(selectedAxes.z&&Number.isFinite(m.z)) vals.push(m.z);
return vals.length?Math.max(...vals):null;
}
function commitState(next,now){
if(next!==candidate){candidate=next;candidateSince=now;return;}
if(next!==shownState && now-candidateSince>=HOLD_MS) shownState=next;
}
function paintState(score){
const map={STABLE:['РОВНО','Рука идёт спокойно вместе с телом.','var(--good)'],MID:['ПЕРЕХОД','Есть движение, но устойчивого маха пока нет.','var(--mid)'],SWING:['МАХ','Обнаружено устойчивое раскачивание руки.','var(--bad)'],WAIT:['—','Нужно примерно 1.5 секунды данных.','var(--blue)']};
const [txt,hint,color]=map[shownState]||map.WAIT;
if(el.state.textContent!==txt) el.state.textContent=txt;
if(el.hint.textContent!==hint) el.hint.textContent=hint;
el.state.style.color=color;
if(score===null){el.score.textContent='—';el.fill.style.width='0%';}
else {el.score.textContent=String(score);el.fill.style.width=score+'%';el.fill.style.background=color;}
}
function ensureAudio(){
try{
if(!audioCtx) audioCtx=new (window.AudioContext||window.webkitAudioContext)();
if(audioCtx.state==='suspended') audioCtx.resume();
return true;
}catch(e){
el.soundInfo.textContent='Не удалось включить звук в этом браузере.';
return false;
}
}
function vibrateAlert(){
if('vibrate' in navigator){
try{
navigator.vibrate([320,120,320]);
return true;
}catch(_){}
}
return false;
}
function beep(){
if(!soundEnabled||!ensureAudio()) return;
const t=audioCtx.currentTime;
const master=audioCtx.createGain();
const compressor=audioCtx.createDynamicsCompressor();
master.gain.setValueAtTime(Math.max(0.001,alertVolume),t);
compressor.threshold.setValueAtTime(-12,t);
compressor.knee.setValueAtTime(6,t);
compressor.ratio.setValueAtTime(12,t);
compressor.attack.setValueAtTime(0.002,t);
compressor.release.setValueAtTime(0.18,t);
master.connect(compressor); compressor.connect(audioCtx.destination);
const tones=[
{freq:1040,type:'square',gain:.72,stop:.28},
{freq:1480,type:'square',gain:.40,stop:.22}
];
for(const tone of tones){
const osc=audioCtx.createOscillator(),gain=audioCtx.createGain();
osc.type=tone.type; osc.frequency.setValueAtTime(tone.freq,t);
gain.gain.setValueAtTime(0.0001,t);
gain.gain.exponentialRampToValueAtTime(Math.max(0.001,tone.gain),t+.008);
gain.gain.setValueAtTime(Math.max(0.001,tone.gain),t+.13);
gain.gain.exponentialRampToValueAtTime(0.0001,t+tone.stop);
osc.connect(gain); gain.connect(master);
osc.start(t); osc.stop(t+tone.stop+.02);
}
}
function testAlert(){
beep();
vibrateAlert();
}
function toggleSound(){
soundEnabled=!soundEnabled;
if(soundEnabled){
if(!ensureAudio()){soundEnabled=false;return;}
el.soundBtn.textContent='🔊 Звук: вкл';
el.soundBtn.classList.add('primary');
const sec=alertDelayMs/1000;
el.soundInfo.textContent=`Сигнал включён: предупреждение после ${sec} с непрерывного маха.`;
testAlert();
}else{
el.soundBtn.textContent='🔇 Звук: выкл';
el.soundBtn.classList.remove('primary');
el.soundInfo.textContent='Сигнал выключен. Настройки громкости и задержки сохранены.';
if('vibrate' in navigator){ try{ navigator.vibrate(0); }catch(_){} }
}
}
function setAlertDelay(seconds){
alertDelayMs=seconds*1000;
el.delayValue.textContent=`${seconds} секунд`;
document.querySelectorAll('.delay-btn').forEach(btn=>{
btn.classList.toggle('active',Number(btn.dataset.delay)===seconds);
});
if(soundEnabled) el.soundInfo.textContent=`Сигнал включён: предупреждение после ${seconds} с непрерывного маха.`;
}
function updateVolume(){
alertVolume=Number(el.volumeSlider.value)/100;
el.volumeValue.textContent=`${el.volumeSlider.value}%`;
}
function updateSwingAlert(now){
const limitSec=alertDelayMs/1000;
if(shownState==='SWING'){
if(swingSince===null) swingSince=now;
const ms=now-swingSince;
const sec=ms/1000;
if(ms<alertDelayMs){
el.swingTimer.textContent=`Мах подряд: ${sec.toFixed(1)} / ${limitSec.toFixed(1)} с`;
}else{
el.swingTimer.textContent=soundEnabled?`⚠️ Мах больше ${limitSec} с — звук + вибрация`:`⚠️ Мах больше ${limitSec} с — звук выключен`;
if(soundEnabled && now-lastBeepAt>=BEEP_EVERY_MS){
beep();
vibrateAlert();
lastBeepAt=now;
}
}
}else{
swingSince=null; lastBeepAt=0;
if(el.swingTimer.textContent) el.swingTimer.textContent='';
if('vibrate' in navigator){ try{ navigator.vibrate(0); }catch(_){} }
}
}
function metrics(now){
while(buf.length && buf[0].t < now-WINDOW_MS) buf.shift();
if(buf.length<20) return {x:null,y:null,z:null};
let sx=0,sy=0,sz=0,nx=0,ny=0,nz=0;
for(const q of buf){
if(Number.isFinite(q.x)){sx+=q.x*q.x;nx++;}
if(Number.isFinite(q.y)){sy+=q.y*q.y;ny++;}
if(Number.isFinite(q.z)){sz+=q.z*q.z;nz++;}
}
return {
x:nx?Math.sqrt(sx/nx):null,
y:ny?Math.sqrt(sy/ny):null,
z:nz?Math.sqrt(sz/nz):null
};
}
function startRecord(which){
if(!running){el.info.textContent='Сначала нажми «Разрешить датчики».';return;}
recording=true;label=which;rows=[];recordStart=performance.now();el.stop.disabled=false;el.recLine.classList.add('on');el.samples.textContent='0';
el.recText.textContent=which==='stable'?'Запись: РОВНО':'Запись: МАХ';
}
function stopRecord(){
if(!recording||!rows.length){recording=false;el.stop.disabled=true;el.recLine.classList.remove('on');return;}
const header=['t_ms','label','ax','ay','az','raw_ax','raw_ay','raw_az','gyro_alpha','gyro_beta','gyro_gamma','a_mag','g_mag','interval_ms'];
const csv=[header.join(','),...rows.map(r=>header.map(k=>r[k]??'').join(','))].join('\n');
const blob=new Blob([csv],{type:'text/csv;charset=utf-8'}); const stamp=new Date().toISOString().replace(/[:.]/g,'-');
const human=label==='stable'?'STABLE':'SWING'; const name=`walking-arm-${human}-${stamp}.csv`;
const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=name;document.body.appendChild(a);a.click();setTimeout(()=>{URL.revokeObjectURL(a.href);a.remove();},1000);
recording=false;el.stop.disabled=true;el.recLine.classList.remove('on');label='';
}
function onMotion(e){
const now=performance.now();lastEventAt=now;sampleTimes.push(now);while(sampleTimes.length&&sampleTimes[0]<now-2000)sampleTimes.shift();
const raw=e.accelerationIncludingGravity||{},rx=finite(raw.x),ry=finite(raw.y),rz=finite(raw.z);let ax=null,ay=null,az=null;
if(e.acceleration&&[e.acceleration.x,e.acceleration.y,e.acceleration.z].some(Number.isFinite)){ax=finite(e.acceleration.x);ay=finite(e.acceleration.y);az=finite(e.acceleration.z);}
else if([rx,ry,rz].every(Number.isFinite)){if(!gravReady){grav={x:rx,y:ry,z:rz};gravReady=true;}grav.x=gravAlpha*grav.x+(1-gravAlpha)*rx;grav.y=gravAlpha*grav.y+(1-gravAlpha)*ry;grav.z=gravAlpha*grav.z+(1-gravAlpha)*rz;ax=rx-grav.x;ay=ry-grav.y;az=rz-grav.z;}
const rr=e.rotationRate||{},ga=finite(rr.alpha),gb=finite(rr.beta),gg=finite(rr.gamma); const aMag=hypot3(ax,ay,az),gMag=hypot3(ga,gb,gg);
buf.push({t:now,x:ax,y:ay,z:az});
const temp={x:Math.abs(ax??NaN),y:Math.abs(ay??NaN),z:Math.abs(az??NaN)};
const inst=selectedSignal(temp);
graph.push(Number.isFinite(inst)?inst:0);if(graph.length>GRAPH_N)graph.shift();
if(recording){rows.push({t_ms:(now-recordStart).toFixed(1),label,ax:ax?.toFixed(6)??'',ay:ay?.toFixed(6)??'',az:az?.toFixed(6)??'',raw_ax:rx?.toFixed(6)??'',raw_ay:ry?.toFixed(6)??'',raw_az:rz?.toFixed(6)??'',gyro_alpha:ga?.toFixed(6)??'',gyro_beta:gb?.toFixed(6)??'',gyro_gamma:gg?.toFixed(6)??'',a_mag:aMag?.toFixed(6)??'',g_mag:gMag?.toFixed(6)??'',interval_ms:finite(e.interval)?.toFixed(3)??''});}
}
async function startSensors(){
try{
if(typeof DeviceMotionEvent!=='undefined'&&typeof DeviceMotionEvent.requestPermission==='function'){const p=await DeviceMotionEvent.requestPermission();if(p!=='granted')throw new Error('Доступ к датчикам не разрешён');}
if(!('DeviceMotionEvent'in window))throw new Error('Этот браузер не поддерживает датчики движения');
if(!running){window.addEventListener('devicemotion',onMotion,{passive:true});running=true;}
el.start.textContent='Датчики включены';el.start.disabled=true;el.info.textContent='Готово. Возьми телефон в руку и иди как обычно.';el.vibrationInfo.textContent=('vibrate' in navigator)?'Вибрация: поддерживается браузером.':'Вибрация: этот браузер не даёт доступ к вибромотору.';
}catch(err){el.info.textContent='Ошибка: '+(err?.message||err);}
}
function draw(){
const c=el.chart,ctx=c.getContext('2d'),w=c.width,h=c.height;ctx.clearRect(0,0,w,h);ctx.strokeStyle='#29313f';ctx.lineWidth=1;
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(graph.length>1){const max=Math.max(3.0,...graph);ctx.strokeStyle='#8db8ff';ctx.lineWidth=3;ctx.beginPath();graph.forEach((v,i)=>{const x=i/(GRAPH_N-1)*w,y=h-clamp(v/max,0,1)*h;i?ctx.lineTo(x,y):ctx.moveTo(x,y)});ctx.stroke();}
}
function loop(now){
if(now-lastUi>150){
lastUi=now;
const m=metrics(now),signal=selectedSignal(m),next=thresholds(signal);
commitState(next,now);
const score=stabilityScore(signal);
paintState(score);updateSwingAlert(now);
el.detectRms.textContent=fmt(signal,2);
el.xRms.textContent=fmt(m.x,2);el.yRms.textContent=fmt(m.y,2);el.zRms.textContent=fmt(m.z,2);
const hz=sampleTimes.length>1?(sampleTimes.length-1)/((sampleTimes.at(-1)-sampleTimes[0])/1000):0;
el.hz.textContent=hz?hz.toFixed(1):'—';el.samples.textContent=recording?String(rows.length):'0';
if(recording)el.recText.textContent=(label==='stable'?'РОВНО':'МАХ')+' · '+((now-recordStart)/1000).toFixed(1)+' с';
if(running&&now-lastEventAt>2500)el.info.textContent='Данные датчиков перестали приходить. Проверь HTTPS и разрешения браузера.';
draw();
}
requestAnimationFrame(loop);
}
el.start.addEventListener('click',startSensors);el.soundBtn.addEventListener('click',toggleSound);el.volumeSlider.addEventListener('input',updateVolume);el.volumeSlider.addEventListener('change',()=>{updateVolume();if(soundEnabled)testAlert();});document.querySelectorAll('.delay-btn').forEach(btn=>btn.addEventListener('click',()=>setAlertDelay(Number(btn.dataset.delay))));document.querySelectorAll('.axis-btn').forEach(btn=>btn.addEventListener('click',()=>setAxis(btn.dataset.axis)));el.stable.addEventListener('click',()=>startRecord('stable'));el.swing.addEventListener('click',()=>startRecord('swing'));el.stop.addEventListener('click',stopRecord);updateVolume();setAlertDelay(5);el.vibrationInfo.textContent=('vibrate' in navigator)?'Вибрация: поддерживается браузером.':'Вибрация: этот браузер не даёт доступ к вибромотору.';requestAnimationFrame(loop);
})();
</script>
</body>
</html>
@@ -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>
-13
View File
@@ -995,19 +995,6 @@
.screen-content.preauth-flow .topbar { background: transparent; }
.auth-screen--welcome .shine-actions { gap: var(--space-3); }
.auth-screen--welcome .start-language-link,
.auth-screen--welcome .start-settings-link {
min-height: var(--control-height);
border: 0;
background: transparent;
box-shadow: none;
color: var(--accent);
font-size: var(--text-md);
font-weight: var(--weight-medium);
}
.auth-screen--welcome .start-settings-link { color: var(--text-secondary); font-size: var(--text-sm); }
.auth-screen--welcome .start-settings-link::after { content: none; }
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .login-choice-screen .topbar { margin-bottom: var(--space-4); }
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .login-choice-screen .topbar__title { display: block; }