|
| 1 | +<!doctype html> |
| 2 | +<html lang="en"> |
| 3 | +<head> |
| 4 | +<meta charset="utf-8" /> |
| 5 | +<meta name="viewport" content="width=device-width,initial-scale=1" /> |
| 6 | +<title>DeviceMotion Rate Tester</title> |
| 7 | +<style> |
| 8 | + :root{ |
| 9 | + --bg:#0b1220; --card:#0f1724; --muted:#9aa4b2; --accent:#60a5fa; --text:#e6eef6; |
| 10 | + } |
| 11 | + *{box-sizing:border-box} |
| 12 | + body{ |
| 13 | + margin:0; |
| 14 | + font-family:Inter,system-ui,-apple-system,"Segoe UI",Roboto,Arial; |
| 15 | + background:linear-gradient(180deg,#071022 0%, #0b1220 100%); |
| 16 | + color:var(--text); |
| 17 | + display:flex; |
| 18 | + align-items:center; |
| 19 | + justify-content:center; |
| 20 | + min-height:100vh; |
| 21 | + padding:20px; |
| 22 | + } |
| 23 | + .card{ |
| 24 | + width:min(760px,96%); |
| 25 | + background:linear-gradient(180deg, rgba(255,255,255,0.02), var(--card)); |
| 26 | + padding:20px; |
| 27 | + border-radius:12px; |
| 28 | + box-shadow:0 10px 30px rgba(0,0,0,0.6); |
| 29 | + } |
| 30 | + h1{margin:0 0 10px;font-size:1.2rem} |
| 31 | + p {color:var(--muted);margin:0 0 12px} |
| 32 | + .controls{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px} |
| 33 | + button{ |
| 34 | + background:transparent;border:1px solid rgba(255,255,255,0.06); |
| 35 | + color:var(--text);padding:10px 12px;border-radius:8px;cursor:pointer; |
| 36 | + } |
| 37 | + button.primary{border-color:var(--accent);box-shadow:0 6px 18px rgba(96,165,250,0.08)} |
| 38 | + .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:14px} |
| 39 | + .stat{ |
| 40 | + background:rgba(255,255,255,0.02);padding:12px;border-radius:8px;text-align:center; |
| 41 | + } |
| 42 | + .stat .label{color:var(--muted);font-size:0.85rem} |
| 43 | + .stat .value{font-weight:700;font-size:1.3rem;margin-top:6px} |
| 44 | + canvas{width:100%;height:80px;border-radius:8px;background:linear-gradient(180deg,#06101a, #08121a);display:block} |
| 45 | + .note{font-size:0.85rem;color:var(--muted);margin-top:12px} |
| 46 | + a {color:var(--accent)} |
| 47 | +</style> |
| 48 | +</head> |
| 49 | +<body> |
| 50 | + <div class="card"> |
| 51 | + <h1>DeviceMotion Rate Tester</h1> |
| 52 | + <p>Shows how fast your browser is sending <code>devicemotion</code> events (events per second, intervals, and a small live graph).</p> |
| 53 | + |
| 54 | + <div class="controls"> |
| 55 | + <button id="btn-perm" class="primary">Request Motion Permission</button> |
| 56 | + <button id="btn-start">Start Listening</button> |
| 57 | + <button id="btn-stop">Stop</button> |
| 58 | + <button id="btn-reset">Reset</button> |
| 59 | + </div> |
| 60 | + |
| 61 | + <div class="stats"> |
| 62 | + <div class="stat"> |
| 63 | + <div class="label">Events / sec (instant)</div> |
| 64 | + <div id="eps" class="value">—</div> |
| 65 | + </div> |
| 66 | + |
| 67 | + <div class="stat"> |
| 68 | + <div class="label">Average interval (ms)</div> |
| 69 | + <div id="avg-interval" class="value">—</div> |
| 70 | + </div> |
| 71 | + |
| 72 | + <div class="stat"> |
| 73 | + <div class="label">Last interval (ms)</div> |
| 74 | + <div id="last-interval" class="value">—</div> |
| 75 | + </div> |
| 76 | + |
| 77 | + <div class="stat"> |
| 78 | + <div class="label">Total events</div> |
| 79 | + <div id="total" class="value">0</div> |
| 80 | + </div> |
| 81 | + </div> |
| 82 | + |
| 83 | + <canvas id="chart" width="800" height="120" aria-label="event rate chart"></canvas> |
| 84 | + |
| 85 | + <p class="note"> |
| 86 | + Notes: Most desktop browsers do not emit device motion events. On iOS 13+ you must explicitly grant permission; press <strong>Request Motion Permission</strong> first. Try moving or rotating the device to see events. |
| 87 | + </p> |
| 88 | + |
| 89 | + <p class="note">Repo: <a href="https://chaicode.com/" target="_blank" rel="noopener">ChaiCode example</a></p> |
| 90 | + </div> |
| 91 | + |
| 92 | +<script> |
| 93 | +(() => { |
| 94 | + // DOM |
| 95 | + const btnPerm = document.getElementById('btn-perm'); |
| 96 | + const btnStart = document.getElementById('btn-start'); |
| 97 | + const btnStop = document.getElementById('btn-stop'); |
| 98 | + const btnReset = document.getElementById('btn-reset'); |
| 99 | + const epsEl = document.getElementById('eps'); |
| 100 | + const avgEl = document.getElementById('avg-interval'); |
| 101 | + const lastEl = document.getElementById('last-interval'); |
| 102 | + const totalEl = document.getElementById('total'); |
| 103 | + const canvas = document.getElementById('chart'); |
| 104 | + const ctx = canvas.getContext('2d'); |
| 105 | + |
| 106 | + // State |
| 107 | + let listening = false; |
| 108 | + let eventCount = 0; |
| 109 | + let lastTs = null; |
| 110 | + let intervals = []; // ms |
| 111 | + const MAX_SAMPLES = 120; |
| 112 | + let timestamps = []; // recent timestamps for instantaneous EPS |
| 113 | + |
| 114 | + // Helpers |
| 115 | + function msToFixed(n){ return (Math.round(n*10)/10).toFixed(1); } |
| 116 | + |
| 117 | + // Permission flow for iOS Safari |
| 118 | + async function requestPermission() { |
| 119 | + if (typeof DeviceMotionEvent !== 'undefined' && typeof DeviceMotionEvent.requestPermission === 'function') { |
| 120 | + try { |
| 121 | + const resp = await DeviceMotionEvent.requestPermission(); |
| 122 | + if (resp === 'granted') { |
| 123 | + alert('Motion permission granted. Now press "Start Listening".'); |
| 124 | + } else { |
| 125 | + alert('Motion permission denied.'); |
| 126 | + } |
| 127 | + } catch (err) { |
| 128 | + console.error(err); |
| 129 | + alert('Permission request failed: ' + err); |
| 130 | + } |
| 131 | + } else { |
| 132 | + alert('No permission prompt required (or not supported). Try "Start Listening".'); |
| 133 | + } |
| 134 | + } |
| 135 | + |
| 136 | + function onDeviceMotion(e) { |
| 137 | + const t = performance.now(); // high-res timestamp |
| 138 | + eventCount++; |
| 139 | + totalEl.textContent = eventCount; |
| 140 | + |
| 141 | + if (lastTs !== null) { |
| 142 | + const interval = t - lastTs; |
| 143 | + intervals.push(interval); |
| 144 | + if (intervals.length > MAX_SAMPLES) intervals.shift(); |
| 145 | + lastEl.textContent = msToFixed(interval); |
| 146 | + } |
| 147 | + lastTs = t; |
| 148 | + |
| 149 | + // keep recent timestamps for instant EPS (1s window) |
| 150 | + timestamps.push(t); |
| 151 | + // drop timestamps older than 1s |
| 152 | + const cutoff = t - 1000; |
| 153 | + while (timestamps.length && timestamps[0] < cutoff) timestamps.shift(); |
| 154 | + const eps = timestamps.length; |
| 155 | + epsEl.textContent = eps; |
| 156 | + |
| 157 | + // average interval over stored intervals |
| 158 | + if (intervals.length) { |
| 159 | + const sum = intervals.reduce((a,b)=>a+b,0); |
| 160 | + const avg = sum / intervals.length; |
| 161 | + avgEl.textContent = msToFixed(avg); |
| 162 | + } else { |
| 163 | + avgEl.textContent = '—'; |
| 164 | + } |
| 165 | + |
| 166 | + drawChart(); |
| 167 | + } |
| 168 | + |
| 169 | + function startListening() { |
| 170 | + if (listening) return; |
| 171 | + // Some browsers require the handler to be passive: false if you want preventDefault (not needed here) |
| 172 | + window.addEventListener('devicemotion', onDeviceMotion); |
| 173 | + listening = true; |
| 174 | + btnStart.disabled = true; |
| 175 | + btnStop.disabled = false; |
| 176 | + } |
| 177 | + |
| 178 | + function stopListening() { |
| 179 | + if (!listening) return; |
| 180 | + window.removeEventListener('devicemotion', onDeviceMotion); |
| 181 | + listening = false; |
| 182 | + btnStart.disabled = false; |
| 183 | + btnStop.disabled = true; |
| 184 | + } |
| 185 | + |
| 186 | + function reset() { |
| 187 | + stopListening(); |
| 188 | + eventCount = 0; |
| 189 | + lastTs = null; |
| 190 | + intervals = []; |
| 191 | + timestamps = []; |
| 192 | + epsEl.textContent = '—'; |
| 193 | + avgEl.textContent = '—'; |
| 194 | + lastEl.textContent = '—'; |
| 195 | + totalEl.textContent = '0'; |
| 196 | + clearChart(); |
| 197 | + } |
| 198 | + |
| 199 | + // Chart drawing (simple sparkline for events per frame calculated from intervals) |
| 200 | + function drawChart(){ |
| 201 | + // compute samples: convert intervals[] to EPS-like values (1000 / interval) |
| 202 | + const samples = intervals.map(i => 1000 / i); |
| 203 | + // pad to length |
| 204 | + const len = MAX_SAMPLES; |
| 205 | + const padded = Array(Math.max(0, len - samples.length)).fill(0).concat(samples); |
| 206 | + |
| 207 | + const w = canvas.width; |
| 208 | + const h = canvas.height; |
| 209 | + ctx.clearRect(0,0,w,h); |
| 210 | + |
| 211 | + // background gradient |
| 212 | + const g = ctx.createLinearGradient(0,0,0,h); |
| 213 | + g.addColorStop(0,'rgba(96,165,250,0.08)'); |
| 214 | + g.addColorStop(1,'rgba(6,8,12,0.02)'); |
| 215 | + ctx.fillStyle = g; |
| 216 | + ctx.fillRect(0,0,w,h); |
| 217 | + |
| 218 | + // find max for scaling (or use fallback) |
| 219 | + const max = Math.max(5, ...padded); |
| 220 | + // draw line |
| 221 | + ctx.beginPath(); |
| 222 | + padded.forEach((v,i) => { |
| 223 | + const x = (i / (padded.length-1)) * w; |
| 224 | + const y = h - ((v / max) * (h - 8) + 4); |
| 225 | + if (i === 0) ctx.moveTo(x,y); |
| 226 | + else ctx.lineTo(x,y); |
| 227 | + }); |
| 228 | + ctx.strokeStyle = 'rgba(96,165,250,0.95)'; |
| 229 | + ctx.lineWidth = 2.0; |
| 230 | + ctx.stroke(); |
| 231 | + |
| 232 | + // fill under curve |
| 233 | + ctx.lineTo(w, h); |
| 234 | + ctx.lineTo(0, h); |
| 235 | + ctx.closePath(); |
| 236 | + ctx.fillStyle = 'rgba(96,165,250,0.08)'; |
| 237 | + ctx.fill(); |
| 238 | + |
| 239 | + // draw current EPS text small |
| 240 | + ctx.fillStyle = 'rgba(230,238,246,0.9)'; |
| 241 | + ctx.font = '12px Inter, Arial'; |
| 242 | + const current = padded[padded.length-1] ? padded[padded.length-1].toFixed(1) : '0.0'; |
| 243 | + ctx.fillText('EPS (recent): ' + current, 8, 14); |
| 244 | + } |
| 245 | + |
| 246 | + function clearChart(){ |
| 247 | + ctx.clearRect(0,0,canvas.width,canvas.height); |
| 248 | + } |
| 249 | + |
| 250 | + // Attach events |
| 251 | + btnPerm.addEventListener('click', requestPermission); |
| 252 | + btnStart.addEventListener('click', startListening); |
| 253 | + btnStop.addEventListener('click', stopListening); |
| 254 | + btnReset.addEventListener('click', reset); |
| 255 | + |
| 256 | + // initial state |
| 257 | + btnStop.disabled = true; |
| 258 | + clearChart(); |
| 259 | + |
| 260 | + // Helpful UX: if devicemotion events were active in the past, offer auto-start on load (commented out) |
| 261 | + // startListening(); |
| 262 | + |
| 263 | + // Minor: handle page visibility to pause chart updates if needed (not strictly necessary) |
| 264 | + document.addEventListener('visibilitychange', () => { |
| 265 | + if (document.hidden) { |
| 266 | + // optionally pause chart updates or logging |
| 267 | + } |
| 268 | + }); |
| 269 | +})(); |
| 270 | +</script> |
| 271 | +</body> |
| 272 | +</html> |
0 commit comments