Files
SHiNE-server/shine-UI/static-sites/arm-walking-monitor/index.html
T

344 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,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>