SHA256
344 lines
23 KiB
HTML
344 lines
23 KiB
HTML
<!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>
|